8

缓存是一个常谈常新的话题,作为一名服务端的技术,如果你入行一年都还没用过memcached类产品,那只能说你的公司实在太小了,或者你干的活实在太边缘了。

说起缓存,可能大家最直接想到的就是:“在数据库前面挡一层”。这是缓存最原始的意义,同时也引申出了缓存最普遍的用法。

原始模式

代码示例1(原始模式):

//从缓存中获取数据[较快的方式]
data = getfromcache(id)
if data == null then
    //从数据库中获取数据[较慢的方式]
    data = getfromdb(id)
    //缓存1天
    setintocache(id, data, 86400)
    return data
end

return data

缓存加锁

上面这种情况下,当同时有N个请求到达,都同时执行getfromcache,那么都会发现data在缓存中不存在,然后都会去调用getfromdb,以及setintocache。这是不必要的,那么我们有没有办法减少这些并发呢。

最直接的想法是加锁,当进入if条件中时,加一把锁,让其他进程不再执行下面的逻辑,而是等第一个进程的setintocache执行完成后,再重新执行一次getfromcache。

那这个锁如何加呢?这里推荐一种省时省力的方法。通过直接在缓存value中设置过期时间来实现。

比如缓存的value值为data,那我们修改一下,把它放到一个json中,改成

{data:data,atime:1429618765}

我们增加了一个atime来记录缓存生成的时间。而逻辑就变成下面这样。

代码示例2(缓存加锁):

//从缓存中获取数据[较快的方式]
data = getfromcache(id)
data = json.decode(data)
//如果通过检查缓存生成时间,发现缓存已经过于陈旧,那么就将缓存过期时间设置为现在开始的5分钟以后(这样其他并发进程就会以为此缓存还未过期,还会继续使用5分钟,只让当前这一个请求去重建缓存)
if data != null && data.atime+86400 < now then
    data.atime = now+300-86400
    data = json.encode(data)
    //对真正的cache来说,缓存10天或者更长时间
    setintocache(id, data, 864000)
    //这里把data设置成null是为了走到下面的if中去重建缓存
    data = null
end

if data == null then
    //从数据库中获取数据[较慢的方式]
    data = getfromdb(id)
    data = {data:data, atime:now}
    data = json.encode(data)
    //对真正的cache来说,缓存10天或者更长时间
    setintocache(id, data, 864000)
    return data
end

return data

你可以会发现,这里也会存在并发啊,和上面例1一样,第一个getfromcache到setintocache之间,如果同时有N个请求到来,不还是都会执行这段操作,都会去查库吗。

没错,是这样的。但是我们仔细看一下,例1中,从getfromcache到setintocache之间,经历了一次漫长的getfromdb操作,这个时间耗费可能是上百毫秒的。而我们例2中,并没有进行什么操作,这个时间耗费只在毫秒甚至微秒级的。

所以例1中getfromcache到setintocache之间的并发是远大于例2中的。例2中通过减小时间窗口,有效的模拟了锁机制。同时还没有增强额外的存储复杂度。所以是推荐的一种方式。

可以说,我们所有的缓存都应该是例2的方式,他在各方面都优于例1(多保存的一个atime字段耗费的内存基本可以忽略不计。且atime很多时候对于调试程序还很有用)。

主动更新缓存

那这样就够了吗?对于被动过期型的缓存,这样基本就可以了。但是现实中还有一种缓存,是主动更新的。试想有一种缓存,我们要求必须和数据库中的数据一致,不能出现陈旧数据。那么上面的缓存方式就不合适了。

我们必然会添加一个流程:即当数据库有更新时,同时更新缓存,因为缓存会自己重建,也可以修改为当数据库有更新时,同时删除缓存。

这里提到删除或者更新缓存,就有点意思了。我们上面讲到的都是非常简单的缓存,即一个id对应一个key。那么试想,如果我们有一个分页缓存,缓存了某一个文章最新的前10页数据。分别的key是page_1,page_2...page10。

那么当我们有一条新数据产生,这10页就都失效了,需要更新或者删除10次。这显然是不太科学的做法。

那么我们应该怎么做呢。我们可以借用上面例2中的方法,例2中,我们在缓存中增加了一个atime字段,标识为缓存的生成时间。我们既然知道缓存什么时候生成的,那问题就好解决了。我们在每次有新数据产生时,都去更新一个updatetime字段。然后获取分页缓存的时候,看一下这个updatetime字段是不是在atime之后,如果是,那么说明这份缓存太旧了,需要走更新流程。

代码示例3(避免批量更新):

//从缓存中获取数据[较快的方式][这里的两次get普通的缓存系统都支持一个请求完成]
data = getfromcache(id)
updatetime = getupdatetime(id)
data = json.decode(data)
//如果通过检查缓存生成时间,发现缓存已经过于陈旧,那么就将缓存过期时间设置为现在开始的5分钟以后(这样其他并发进程就会以为此缓存还未过期,还会继续使用5分钟,只让当前这一个请求去重建缓存)
if data != null && (data.atime+86400 < now || date.atime < updatetime) then
    data.atime = now+300-86400
    data = json.encode(data)
    //对真正的cache来说,缓存10天或者更长时间
    setintocache(id, data, 864000)
    //这里把data设置成null是为了走到下面的if中去重建缓存
    data = null
end

if data == null then
    //从数据库中获取数据[较慢的方式]
    data = getfromdb(id)
    data = {data:data, atime:now}
    data = json.encode(data)
    //对真正的cache来说,缓存10天或者更长时间
    setintocache(id, data, 864000)
    return data
end

return data

这仅仅是在代码示例2的基础上增加了下面这一个条件判断而已

date.atime < updatetime

这样,无论是缓存保存时间过期了,还是缓存本身有更新,都会触发带锁机制的缓存更新。

好了,先说到这里,回头有想起来的再做更新。原文地址


顺便插播一则招聘广告。(码字不易,求别删招聘广告,谢!)

易手机坐标深圳,做一款易用安全的老年智能手机,做老年手机第一品牌。现在灰常需要服务端同学入伙。有兴趣的同学请私信或简历发邮箱:ligang#pingyijinren.com


iammutex
1.6k 声望43 粉丝

关注我,不如加入我们团队。深圳易手机团队招各路牛人。PHPer/Web前端/Android/iOS


« 上一篇
谈谈反垃圾