游戏服务器的cache

为啥要做一个cache

一个字因为性能啊!好吧具体解释就是很多功能都是对游戏内少部分数据的频繁访问和修改,没有cache只能每次都操作数据库,io操作毕竟比内存操作慢很多,尤其是短时间内对同一个(些)数据的读写,更明显。

cache失效策略

另外一个subject,此篇暂不讨论。

cache模式

因为游戏服务器不要求那么高的数据安全性一致性,为了性能肯定是write back模式最适用,write back模式的核心就是,在数据更新(写)的时候,只更新到缓存,而不立即更新到数据库或者更低级的存储介质(就是缓存后端的存储,数据库是最常见的),之后再批量把缓存中数据写回数据库。这么做的优点显而易见,就是数据操作快(无论读写),因为都是直接内存操作,缺点就是数据有可能丢失,比如中途宕机或者由于某种原因缓存中数据没有正确写回数据库。

按照write back的wiki说明,应该是当cache block的数据被修改或者替换的时候数据落地,也就是说认为cache的容量有限(甚至是比较小,因为cache很快,如果是空间又大速度又快,其实就不合适叫“cache”了)。 好吧,附一张盗的write back流程图: write back流程

我理解修改是说类似下面这种情况:cache block A第一次被写访问,数据被修改,标记为dirty,当第二次再访问A时发现A是dirty则会把A写回数据库以保持一致性,再去除A的dirty标记;

替换则是:访问B的时候发生cache miss于是去数据库加载B的数据,因为容量有限B占用A在cache中的位置,也就是B替换A,A从缓存中移除,A数据必须落地。

总之,任何情况导致cache block被删除都要落地,至于修改导致落地的情况持保留意见,我觉得做不做都可,也可以考虑换个方法做,比如异步地启动一个执行流程去做定时落地之类的,可能更适合游戏服务器。

如何用lua实现一个write back式缓存

具体到用lua实现,有几个复杂的点:

  1. cache是A:直接引用外部对象还是B:对外部对象封装后再引用
  2. 短时间重复cache miss重复加载数据问题。
  3. 要注意落地时清除缓存和加载数据的顺序,避免出现数据不一致。
  4. 很难判断cache block是“无效且可以从缓存中删除的”。

1是很关键的一个取舍,A的意思就是cache是个简单的table结构,提供一个get接口获取cache的对象,具体到对这个cache中取出来的对象是读是写或是什么操作,完全取决于这个对象提供什么接口,因为是引用,所以外部可以直接对cache对象做修改,而这件事cache层不知道。这样做的好处是外部逻辑用起来简单无脑,脑力负担少;坏处是直接导致了cache层的复杂度增加。

考虑方案A,lua里最直观的异步落地实现是:在一个coroutine里起一个timer,定时对cache中数据做两件事:落地和检查是否有数据需要从cache删除

假设我们不考虑落地带来的额外性能开销,并且认为删除cache的是判断超过一定时间(TTL)未被访问,有两种情况:

  • 数据落地,但是不需要从cache中清除 就算这个时候cache中数据被外部引用着,因为不从cache清除,可以认为只是定时把cache的“快照”落地罢了。当然为保证数据一致性,最后关服的时候一定要再做一次cache全数据落地(先关闭对cache的访问再做)。
  • 数据落地,同时需要从cache中清除 问题来了,由于不知道数据是否还被外部逻辑所引用,所以没法删,假设恰好这个时候外部引用着cache里的数据在做操作,这部分操作会丢失掉。 正确删掉cache block前提是确保除了cache自己没人引用着它,而lua里很难判断这件事(lua没有引用计数)。有一个办法是利用弱表的特性来做,这又牵扯到gc,也就是说无法主动判断出cache block没被外部引用,那么就把cache table设置成弱表再给cache block加__gc元方法,这样等gc的时候就可以知道这个cache block无人引用了。这个时候还不能真的把cache block删掉,想想看,cache block大部分时间都是未被外部引用的才对,外部获取它操作它然后丢弃对它的引用整个过程应该是很快的(不然说明系统设计的有问题,一个处理都那么慢,大量处理一起来的时候只会更慢),一个高速运转的服务的内部应该处于频繁的获取cache block引用-> 操作cache block数据 -> 丢弃cache block引用(在lua里其实就是函数内局部变量函数结束生命周期自动就丢弃引用了)这样一种循环,所以准确来说应该被删的cache block是被gc触发了__gc元方法的且超过TTL时间未被访问的!于是这个逻辑就变成了:

    • cache table用弱表
    • cache slot加gc元方法,在gc触发call finalizer阶段的时候把自己放到另外一个强表里
    • 定时把弱表和强表里的数据落地,强表里的数据如果是“不活跃”数据了,把它从强表中删掉
    • 获取cache的时候,先查弱表再查强表再查数据库,如果在强表里要从强表删除加回弱表
    • 最后记得gc有可能在任何时候发生...

实现起来相当复杂,还有一些小坑就不一一列举了。

如果考虑方案B,对于外部使用者来说就是多了一条限制:必须在修改完cache block数据后调用类似update的方法;而对于cache内部实现来说却简单了很多,不再需要弱表不再需要gc元方法,起timer定时落地和删除cache这些都和前边一样。

再看最复杂的落地+从cache中删除的情况:直接判断TTL就可以删除,不用考虑是否被外部引用,因为如果删除的时候外部在引用,等到外部引用完调用update的时候就会发现这份数据已经不在cache中了,会发现自己“过期”了,这说明这个外部操作太久了!居然能超过TTL一定有问题,当然不能assert,为保证运行时数据不丢,只要这个时候把自己塞回cache就好了,然后记一条log回头再查为什么这个操作这么慢。这样甚至只需要一级cache表就搞定了。

其实大多数情况是不会有一个外部操作拿着一个cache block这么长时间的,加这个update也只是为了保证万无一失。

权衡比较两个方案,个人更偏向第二个,理由是实现简单清晰,不容易错,对于使用者也没有太大麻烦,可接受范围。试举一例代码大概是:

local c = Cache:new()
local item = c:get_item(id)

--do something with item,may change item

c:update_item(id, item)

复杂点2意思就是说对同一份数据请求来得很接近的时候,第一个发生cache miss于是去数据库加载,因为db很慢还没加载完这时候第二个请求来了还是cache miss于是又去db加载,这种情况是忽略还是采取一些办法(比如对请求按照数据分队列)解决,有待商榷值得思考。个人觉得如果是考虑到这个cache的应用场景,比如有些玩法大家都是几乎等到一个时间去做一件事,比如等到9点排行榜停止结算啊之类的,那么可能还是有解决的必要的。解决起来就是cache层复杂度增加,正常情况下cache的性能影响不大,极端情况下(短时间内大量同一数据cache miss请求)有改善。

复杂点3的意思就是要考虑超过TTL从cache中清除的时候,同时又来一个请求访问这个数据。这个涉及到选用的db的如何处理并发请求的问题,假设你的db能保证先请求的先操作一定先完成,那就没什么问题,先删缓存,再写db也不会有问题。因为读在写之后,一定能保证读到的是落地后的最新数据。但是如果db并发处理不能保证这点就要小心,必须先写db再判断是否从cache中删除。

复杂点4就是1里说的如果选了方案A面临的问题,选了方案B避开的问题,或者说让使用者分担了这部分的问题。

一句话,我觉得lua里要实现一个支持读写的cache,让外部使用者明确地调用一个“写回cache”的接口会让cache设计起来变得简单明了,在没有找到更好的办法之前更愿意这么做。