EdsCache通用缓存框架——(2.9) 缓存一致性

达芬奇密码2018-06-22 17:10


问题描述
1、“查询-更新”并发场景出现的不一致问题,如:
以上就会出现DB数据为V2,但缓存数据为V1的情况,
如果缓存超时时间较长,则会在较长时间内,查询流程一直从缓存中查出老版本的数据。

2、“查询-删除”并发场景出现的不一致问题,如:
以上就会出现缓存中残留老数据,
如果缓存超时时间较长,则会在较长时间内,查询流程一直从缓存中查出已经被删除的数据。

3、更新与更新线程之间,也会可能出现不一致:
以上就会出现缓存中数据与DB不一致,
如果缓存超时时间较长,则会在较长时间内,查询流程一直从缓存中查出与DB不一致的数据。
且我们服务原有的底层缓存以redis为主,而redis缺少了cas接口的支持,缓存框架如何解决这个问题呢?

解决思路
下面以redis为例。

1、查询与更新线程的并发冲突解决:
问题根源在于,查询线程回写缓存的时候,把更新线程写的缓存数据给覆盖了。
所以查询线程,在回写缓存时,使用setnx接口来防止覆盖。

如果查询线程setnx返回失败,证明再此期间,有更新线程,或别的查询线程,将数据写入了缓存,当前查询线程就没必要再写缓存了,所以直接返回即可,就不会覆盖缓存的数据;
如果查询线程setnx返回成功,证明再此期间,没有别的线程去写缓存,查询出来的数据也成功写入了缓存,如:
2、查询与删除的并发冲突解决:
问题根源在于,查询线程回写缓存的时候,覆盖了删除线程的remove操作。
所以删除线程,删除缓存时,不是物理的删除缓存数据,而是为缓存数据打上deleted标记;
查询线程依然采用setnx接口防止覆盖。

如果查询线程从缓存中查出带deleted的数据,则认为该数据不可用,当做没命中处理。
查询线程流程如下:

3.更新线程之间的并发冲突:
问题根源在于,不同线程在更新DB操作、写缓存之间的时序,后写DB的线程反而先写了缓存,导致DB与缓存数据的不一致。
解决思路,
a) 首先,缓存框架中实际存的是wrapper对象,在wrapper对象中维护一个“updateTimestamp”属性,最近更新时间戳,记录每次写缓存的时间戳;
b) 更新线程首先查询key对应的updateTimestamp,记录下来,作为t1;
c) 接着进行DB更新等操作;
d) 使用getset接口,更新缓存,并同时返回缓存原先的数据,从返回值中拿到updateTimestamp,作为t2;
e) 对比t1与t2,发现不一致,则说明 b~d 阶段期间,缓存被别人更新过了,而自己所写的缓存有可能已经不最新的值,为缓存数据打上deleted标记。
该方案虽然保证了数据的一致性,不过也存在如下缺点:
对比发现前后时间戳不一致时,不一定自己写的数据就是老的,但还是打上了deleted标记,而查询线程查询出deleted标记的数据时,会需要去DB数据加载,增加了DB穿透的机会;
更新线程需要先查一次缓存,增加了一次网络IO动作;如果更新操作频率远远小于查询时,一般还是可以接受的;
时间戳不是全局唯一的id,理论上仍有冲突的可能,但概率非常低,如果使用全局id分配器会更好。
所以需要结合业务的实际需求,综合考虑各因素来决定,是否启用一致性功能。

使用样例
启用缓存一致性功能,首先需要打开全局开关,默认是关闭的:
eds:
  cache:
    enableConsistency: true
其次要在接口上,在原有基础上叠加@EdsCacheConsistency注解,如:
@EdsCache(key = constants.USER_CACHE_PREFIX, expire = constants.EXPIRE_SECOND)
@EdsCacheConsistency
public UserDTO getUser(@EdsCacheKey Long id){
    return userDao.getUser(id);
}
缓存框架会根据EdsCacheConsistency注解,结合opType为默认的读写模式,识别出这是“查询流程”,采用查询流程的一致性操作,比如采用setnx接口等。

更新接口:
@EdsCache(key = constants.USER_CACHE_PREFIX, opType = CacheOpEnum.WRITE, expire = constants.EXPIRE_SECOND)
@EdsCacheConsistency
public UserDTO saveUser(@EdsCacheKey Long id, UserDTO update){
    UserDTO ret = userDao.saveUser(id, update);
    return ret;
}
目前缓存框架其实是根据opType = CacheOpEnum.WRITE,来区分出这是“更新流程”,采用更新流程时的一致性操作的。

刪除接口:
@EdsCacheDelete(key = constants.USER_CACHE_PREFIX)
@EdsCacheConsistency
public void deleteUserWithCache(@EdsCacheKey Long id){
    userDao.removeUser(id);
}

优化空间
查询流程setnx失败,可以触发一次异步刷新,更新缓存,减少下次查询同步穿透DB的机会;
查询流程发现数据被打删除标记时,如果业务场景的需求,相比于一定要数据信息敏感度,更关心接口性能,可以考虑使用旧版本数据,通过触发异步刷新来更新缓存。

本文来自网易实践者社区,经作者陈婷授权发布。  

相关阅读:EdsCache通用缓存框架——(1)总览导航