Lua 中 Cache 冷数据的落地

RaymondC31 8年前
   <p>今天有同学跟我讨论了一下最近发现的一个 bug ,我觉得挺有意思的。</p>    <p>需求是这样的:</p>    <p>我们的系统中,有一些数据是从外存(数据库)加载进来的,由于性能考虑,并不需要每次修改这些数据就写回外存。希望在数据变冷后,定期落地即可。</p>    <p>典型的场景是一个 cache 模块,cache 的是一些玩家的业务数据,可以通过 uuid 从数据库索引到。一旦业务需要访问玩家数据,cache 模块会从数据库加载对应数据,然后把数据表交出去。当业务再次需要这些数据的时候,cache 模块一旦发现数据存在于 cache 中,就直接交给玩家。</p>    <p>cache 模块还希望在数据很久没有被业务访问时,将这些数据写回数据库。</p>    <p>我们的系统是基于 lua 构建的,数据 cache 模块和修改这些数据的逻辑在同一个 vm 里。难点在于,修改数据的业务逻辑是可以长期持有数据的,cache 模块需要正确感知这点。</p>    <p>先来看看最朴素的实现方法:</p>    <p>cache 模块其实就是一张 uuid : 数据 表。加载数据的时候,检查 cache 中是否存在,如果没有就把数据写到 cache 表中。然后刷新一下数据的时间戳,表示最后访问的时间。</p>    <p>数据落地流程是:从队列中取出一个超时(最后一次访问过于久远的)待落地数据,擦除 cache 对应项,标记 uuid 为锁定状态(阻止加载流程在落地过程重新加载),落地,完成后解锁(并唤醒潜在的加载需求)。</p>    <p>这个方法可以实现在使用数据的过程中,如果有新的访问需求,是不需要从数据库加载,并贡献内存中的同一份数据对象的。</p>    <p>但是这个方法是有漏洞的,因为访问时间久远,并不意味着没有人持有它。而落地前的锁只能阻止加载的冲突,不能阻止持有数据的人在落地过程中改写数据。</p>    <p>为了解决这个问题,我们之前采取了一个改进方案。</p>    <p>使用 lua 的弱表来管理 cache 。在没有人引用数据后,弱表中对应项会消失,此时才是数据落地的最佳时机。因为不会有改写者干扰这个流程,仅仅锁住新的加载会引起的冲突即可。注:如果需要定期落地,只需要定期把数据复制出去落地即可。</p>    <p>直接给数据块加上 __gc 方法,在 gc 流程中做数据落地是不可行的。因为不提倡在 __gc 方法中做过于复杂的工作。所以我们只是在 __gc 中把对象重新放回一张叫 save 的待处理表,即让这个数据表“复活”了。所谓复活,指在之前的 gc mark 流程,它已不被 vm 里除 __gc 方法外的任何地方引用,但是在 sweep 阶段,又被重新塞会 vm 中,并不真的被 sweep 掉。关于这个用法,lua 实现的很好。</p>    <p>之后,落地流程可以慢慢的逐个处理 save 表。这个过程中,如果有业务需要访问数据,那么它可以同时检查 cache 表和 save 表里是否有数据,如果存在于 save 表中,则移回 cache 表。</p>    <p>方案看起来不错。</p>    <p>当数据正被引用时,它总是存在于 cache 表中,不同地方的多次访问会取到同一个引用。</p>    <p>只有当数据没有任何业务引用时,它才会从 cache 表中移走,有另一个落地流程会逐步处理这些不被人引用的数据。这可以防止在落地流程中,有业务对数据修改。</p>    <p>在待落地处理的数据尚未处理时,如果有新的访问需求,那么会抢在落地前拿回来,整个系统中每份数据的引用还是一致的。</p>    <p>锁只需要加在数据落地和数据加载上,防止数据落地的过程中,同时加载数据。</p>    <p>但今天有同学报告了这个方案的 bug 。</p>    <p>问题出在 gc 把未引用的数据从 cache 这张弱表里抹掉的操作,和被抹掉的数据的 __gc 方法将其加回 save 表这两者并不在一个原子操作内。</p>    <p>也就是说系统会处于某种第三状态。一个 uuid 对应的数据并不在 cache 表中,也不在 save 表中,从业务逻辑上看,这组数据从 vm 中消失了。</p>    <p>当一组数据处于第三态时,如果此刻发生了访问请求,那么就会触发加载流程,从数据库加载一个老版本(新版本尚未落地)。</p>    <p>怎么解决呢?</p>    <p>直接的方法是,当一组数据加载时,我们把 uuid 记录在一个独立集合中;只有在它真正被落地/丢弃处理后,才从这个集合抹掉。</p>    <p>这样数据无论处于三种状态中的什么状态,我们都可以阻止已经处于系统中的数据再次从外存加载一个旧版本。</p>    <p>但一旦处于第三态的数据被请求,我们似乎没有什么好的方法把它从第三态拉回来。因为它的的确确从 vm 中(暂时)消失了。能做的只有等。说到等,这和等数据从数据库加载似乎没有本质区别。我们只能在有等待操作期间,不断的调用 gc 的 step ,督促 gc 过程进行下去,直到(也肯定能等到)数据从第三态出来,进入 save 表。(lua 的 gc 默认行为是只有新的内存申请发生,才可能发生下一步的行动)</p>    <p>有没有别的方案?</p>    <p>利用元表给数据访问加一个间接层能从另一个角度解决这个问题。</p>    <p>如果我们给需要 cache 数据表加上一层代理,让代理的 __index 和 __newindex 都指向真正的数据表。那么业务用起来就是完全一样的。</p>    <p>cache 表里放的仅仅是 uuid : 代理对象。</p>    <p>另外,将真正的数据表全部放在一张额外的 all 表中,并不让业务层直接接触这张表。</p>    <p>业务层不再引用某个 uuid 对应的数据时,cache 中消失的其实是代理对象,而不是真正的数据表。真正的数据表依然存在于 all 表中。</p>    <p>落地流程要处理的其实是 all 和 cache 的差集。它可以定期把差集求出来,放入 save 表中去处理。注:这里依旧生成一张 save 表,而不是直接对差集处理,是因为求差集时刻在变化,而我们无法一次将所有的差集里的数据全部落地。</p>    <p>ps. 以上的方案都隐含着另一个问题没有解决:如果业务私自保留了数据表中的部分子表的引用,cache 模块是无法感知的。不过这点比较容易通过约束业务的使用方法来回避。</p>    <p> </p>    <p>来自:http://blog.codingnow.com/2016/11/cache_data.html</p>    <p> </p>