微服务架构下 缓存一致性 的设计与权衡
前段时间排查了一个线上问题:运营在后台把商品价格改了,数据库里查是新价格,但 App 上刷出来还是老的。Redis 里的缓存明明删了,查了半天,最后发现是另一个服务的本地缓存没收到失效通知……就这么个小破问题,扯出了多级缓存、消息丢失、删除失败重试一串事。趁着印象还热,把缓存一致性这摊子事重新捋一遍,也算给下次踩坑留个笔记。
为什么这事天生做不到“强一致”
道理其实很简单:缓存和数据库是两个独立的东西,写库和操作缓存没法塞进同一个事务里。不管你的顺序安排得多巧妙,两个操作之间总有那么几毫秒的窗口,别的请求就可能在这个窗口里读到不一致的数据。
所以动手之前得先想明白:你要的是“每一秒都一致”,还是“过一会儿肯定一致”?前者基本等于放弃缓存,或者用分布式锁把性能锁死,绝大多数业务没必要。我们要做的,是把最终一致的时间窗口压到足够短,并且保证“最终”一定会发生。
大多数场景:先更新数据库,再删缓存
这就是常说的 Cache Aside,网上讲烂了,但真到写代码的时候还是容易写岔,这里把原因捋清楚。
读的逻辑没什么可说的:
value = redis.get(key);
if (value == null) {
value = db.query(key);
redis.set(key, value, 30, TimeUnit.MINUTES);
}
写的时候,是“更新缓存”还是“删除缓存”?我建议删,原因有二:
- 并发写的时候,两个请求更新数据库的顺序和更新缓存的顺序可能对不上,后写的反而先把缓存更新了,旧值就一直留在里面。删除就没这个问题,大不了下次读的时候重新加载。
- 很多缓存不是数据库字段的原样拷贝,而是聚合、加工过的结果,更新一遍的成本不低,不如懒加载。
那“先删缓存再更新库”行不行?不行,风险大得多:删除之后、更新完成之前来了一个读请求,缓存 miss,去读库——读到的还是旧值,然后把它写回缓存,这条旧数据会一直活到过期为止。而“先更新库再删缓存”理论上也有一个不一致窗口,但它要求读请求恰好卡在写请求前后、还比写请求慢,概率小到可以忽略,属于理论上有、实际上很难碰到的类型。
嫌不够稳,就上延迟双删
上面那个“理论上”的窗口,再加上主从延迟的存在(读请求打到从库,从库还没同步到最新数据),有些人会再补一刀:删一次缓存,更新数据库,隔几百毫秒再删一次。
redis.del(key);
db.update(record);
scheduler.schedule(() -> redis.del(key), 500, TimeUnit.MILLISECONDS);
这个延迟时间不好拍脑袋,我一般是按主从同步延迟的上限估,再放宽一点。用过的都知道,这方案有点丑:延迟任务本身可能丢(服务重启了),第二次删除也可能失败,所以还得配重试,比如塞进消息队列,消费失败就再来。说白了就是拿复杂度换心安。
写入口多、要求高:订阅 binlog 删缓存
再往上一层,就是业务代码只管写库,另外起一个独立服务去订阅 binlog,收到变更事件后再删缓存,常见的组合是 canal + MQ:
Service ──写──> MySQL
│ binlog
▼
canal ──> MQ ──> 消费者 ──删
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。