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