时间修改成功,但是没同步?
问题引出
在OJ的一次考试中,中途延长考试后,有一些同学需要重新登录,但是他们登录上去所获取的开放时间依旧是原来的时间,因此一直显示“考试已结束”,无法答题。我迅速排查日志内容,发现并未有ERROR,数据库的题目集时间也是正确的。但在我仔细检查登录方法时,看到登录时需要从Redis获取题目集时间,赶紧打开Redis一看,缓存的时间和数据库对不上了。
解决方案
很多人写更新缓存数据代码时,先删除缓存,然后再更新数据库,利用后续的业务请求把数据重新装载进缓存。
其实这个逻辑是错误的。因为这两步操作不是原子性的,在并发环境下,可能线程A删除了Redis缓存后,还未写库,线程B就来读取,发现缓存为空,则会查询数据库写入缓存,此时缓存中为脏数据。
1
2
3
4
5
6
7
8
9
10
@Override
public ResponseResult<String> resetTimer(UpdateTopiSetTimeDTO info){
// 分别获取原状态和新状态
TopicsetTimeVo timeVo = getBaseMapper().getTopicSetTime(info.getTopicSetId());
if (timeVo.getTopicsetStatus() == TopicSetStatus.OPEN) {
return ResponseResult.errorResult(HttpCodeEnum.ERROR.getCode(), "题目集已开放,不可修改开始时间");
}
redisCache.deleteObject(RedisConstants.TOPICSET_STATUS);
getBaseMapper().updateTopicsetTime(info,newStatus);
}
更新缓存的Design Pattern 有四种:Cache Aside、Read Through、Write Through、Write Behind Caching
Cache Aside Pattern
这是最常用的 Pattern,总结来说:
- 读:先读缓存,未命中则读数据库库,然后取出数据放入缓存
- 写:先更新数据库,再删除缓存
其具体逻辑如下:
- 失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
- 命中:应用程序从cache中取数据,取到后返回。
- 更新:先把数据存到数据库中,成功后,再让缓存失效。
1
2
getBaseMapper().updateTopicsetTime(info,newStatus); // 更新数据库
redisCache.deleteObject(RedisConstants.TOPICSET_STATUS); // 删除缓存
那么,这种方式是否可以没有文章前面提到过的那个问题呢?
线程A查询数据,线程B更新数据,因为不再是先删除缓存,因此缓存有效,线程A获取的是缓存内容(旧),但是线程B马上就让缓存失效了,后续的查询再从数据库获取数据,同步到缓存。
这是标准的design pattern,包括Facebook的论文《Scaling Memcache at Facebook》也使用了这个策略。为什么不是写完数据库后更新缓存?你可以看一下Quora上的这个问答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕两个并发的写操作导致脏数据。
Read/Write Through Pattern 其实就是将同步操作交给缓存代理,读操作由调用方负责把数据载入缓存,写操作如果没有命中缓存,直接更新数据库,然后返回。如果命中了缓存,则更新缓存,然后再由Cache自己更新数据库;
Write Behind Caching Pattern(Write Back),在更新数据的时候,只更新缓存,不更新数据库,而缓存会异步地批量更新数据库。Write Back 的有点是 I/O 快,还可以合并同一数据的多次操作(视频点赞等);缺点是数据不是强一致性的,可能会丢失。
参考
https://coolshell.cn/articles/17416.html#google_vignette
https://pdai.tech/md/db/nosql-redis/db-redis-x-cache.html#%E6%95%B0%E6%8D%AE%E5%BA%93%E5%92%8C%E7%BC%93%E5%AD%98%E4%B8%80%E8%87%B4%E6%80%A7