那天晚上十一点多,我在公司楼下便利店排队买咖啡,手机一震,运维同事发了句:“东哥,用户订单金额又不对了,像是缓存和库打架了,你看下?” 我当时整个人就清醒了,比咖啡管用。
回到工位一看日志,大概场景是这样的:用户改了一个价格,数据库是新值,Redis 还是老值,接口一会儿查到新,一会儿查到旧,高并发下一片混乱。我们组那个小李在旁边嘟囔了一句:“要不写的时候先改缓存?或者先改数据库?到底谁先谁后啊?”
今天就沿着当时这事儿,把这个问题摊开聊聊:高并发下,修改数据的时候,是先写数据库,还是先写缓存,或者干脆别“写”缓存,只删?顺便把代码逻辑也给你捋一遍,都是 Java 的。
先说读:大家几乎都在用的那种套路
你不管是订单、商品还是用户信息,一般读流程都是所谓“旁路缓存”(Cache Aside),说白了就是:
先查缓存,命中就直接返回。
没命中,查数据库,把结果塞进缓存,再返回。
代码长这样,别嫌土,生产代码八成都这个味:
@Service
public class UserService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private UserMapper userMapper;
public User getUserById(Long userId) {
String key = "user:" + userId;
String cacheJson = redisTemplate.opsForValue().get(key);
if (cacheJson != null) {
// 这里偷懒不做异常处理了
return JsonUtils.fromJson(cacheJson, User.class);
}
User user = userMapper.selectById(userId);
if (user != null) {
redisTemplate.opsForValue().set(
key,
JsonUtils.toJson(user),
10, TimeUnit.MINUTES
);
}
return user;
}
}
读基本就这样,高并发下你再搞点本地缓存、热点 key 保护之类的,那是后话。真正把人整破防的,是“写”的时候:到底是先动谁?
那我们先写缓存试试?很快就被现实抽了
刚入行那会儿,我也天真地想过:“缓存又快又便宜,那我是不是应该先把缓存改了,让大部分读请求都看到新值,再慢悠悠地把数据库改掉?”
大概代码是这样:
public void updateUserName(Long userId, String newName) {
String key = "user:" + userId;
// 1. 先改缓存
User cacheUser = getUserById(userId);
if (cacheUser != null) {
cacheUser.setName(newName);
redisTemplate.opsForValue().set(key, JsonUtils.toJson(cacheUser));
}
// 2. 再改数据库
userMapper.updateName(userId, newName);
}
看着很美对吧?用户下一次读缓存一定是新名字,数据库晚点跟上也行。问题来了,高并发下一堆坑:
要是数据库更新失败了呢? 缓存是新的,数据库是旧的,你连“真实值”都说不清,就很要命。
还有事务问题:缓存这玩意儿跟数据库事务没绑在一起,你数据库回滚了,缓存还躺着新数据,一致性直接没了。
再极端一点,进程刚写完缓存就挂了,数据库根本没机会更新。
所以后来团队直接把这种“先写缓存,再写数据库”的方案给干掉了,线上别想用,除非你外面再包一堆补偿、消息、对账,得不偿失。
那就先写数据库,再更新缓存?看着对,其实也有坑
于是大家第二个自然的想法就是:
先把数据库改对,这是“真相”。
再把缓存也改成新的,保证命中缓存的也是新值。
伪代码大概这样:
@Transactional
public void updateUserName(Long userId, String newName) {
// 1. 更新数据库
userMapper.updateName(userId, newName);
// 2. 更新缓存
String key = "user:" + userId;
User user = userMapper.selectById(userId);
redisTemplate.opsForValue().set(key, JsonUtils.toJson(user));
}
看着比刚才靠谱多了,但你别忘了,我们讲的是“高并发”。
想象一下这种时间线:
T1:线程 A 更新数据库 name = "张三-新",准备更新缓存。
T2:线程 B 更新数据库,把 name 改成了 "张三-最新",速度比 A 快。
T3:B 更新完数据库,更新缓存为 "张三-最新"。
T4:A 这时候才慢腾腾地把刚查出来的“张三-新”写回缓存,把 B 的“最新”给覆盖了。
数据库是最新的,缓存是旧的;你要说一致性,还是翻车了。你可以搞版本号 / 时间戳做 CAS,但是代码就不再是上面这么清爽了,得写一堆 compareAndSet 的逻辑,还得保证所有地方都遵守这个协议。
更现实一点的做法是:我们干脆不“更新缓存”,只删它,让它自己长出来。
业界主流那个方案:写数据库 + 删缓存
后来我们团队里达成的共识,也是现在大部分互联网项目在用的套路,就是:
读:缓存优先,没命中再查库并回填。
写:只保证数据库永远是对的,然后把缓存删掉,让下一次读自己重新从库里捞一份新鲜数据。
简单粗暴,代码反而相当干净:
@Transactional
public void updateUserName(Long userId, String newName) {
// 1. 先更新数据库
userMapper.updateName(userId, newName);
// 2. 再删除缓存(不是更新)
String key = "user:" + userId;
redisTemplate.delete(key);
}
大部分时候,这个策略是够用的,而且很稳:
数据库一定是最新。
缓存删了之后,只要下一次有读请求,查库 + 回填一下就恢复了。
删除失败你还能重试、还能用消息队列补偿、还能用 binlog 做订正,这些都是加法。
那问题来了:是不是所有场景下都用“先写库,再删缓存”?有没有“先删缓存,再写库”的说法?有,而且在高并发下,这俩顺序,各有各的坑。
顺序之争:先删缓存还是先写数据库?
这块我当时和我们组小李在公司楼下抽烟来回画时序图,画了一整张 A4 纸。
先说一个最典型的并发场景:一个人写,一个人读。
情况一:先删缓存,再写数据库
流程是这样的:
写请求 A:先删 key,再更新数据库。
读请求 B:刚好在 A 更新数据库之前来了,发现缓存没了(因为 A 已经删了),于是去查数据库。
B 这时候查到的还是“旧值”,因为 A 还没 commit。
B 把旧值又写回了缓存。
A 更新完数据库,但不会再动缓存。
结果:数据库是新值,缓存是旧值,而且这个旧值会一直躺在那儿,直到过期。高并发下,这种时序不是小概率事件。
情况二:先写数据库,再删缓存
流程换一下:
写请求 A:先更新数据库,再删缓存。
读请求 B:如果在 A 删缓存之前来,可能会读到旧缓存(短暂不一致),但不会回写缓存,因为已经命中。
如果 B 在 A 删缓存之后来,那它直接 miss,再查库,查到的是新值,然后回填。
这里最致命的问题变成了: “要是删缓存失败了怎么办?”——那就会有一段时间,大家一直命中旧缓存。
相比之下,两个方案都没有“绝对完美”,只是侧重点不一样:
先删缓存再写库:容易被并发读回写旧值,缓存可能长期错。
先写库再删缓存:风险主要集中在“删除失败”这件事本身,上层可以用补偿手段兜底。
所以大多数业务场景(尤其是能接受短暂最终一致性的那种),会更偏向:
写流程用:更新数据库 删除缓存 外挂一套“删不掉就重试 / 异步补偿”的机制。
顺便说一句,别忘了你整个 Web 层、线程池、数据库连接池在高并发下也得跟上,不然还没到缓存这步,Tomcat 的默认连接数就先顶不住了,生产环境这块配置是一定要调的 。
高并发下的几招“加强版”玩法
上面那个“写库 + 删缓存”的基础版本,放在中小系统里其实就挺能打了。但当 QPS 上来之后,你会陆续遇到一些很现实的问题,比如某个热点 key 老被并发读写打出各种奇怪时序。
这时候可以在不改变整体思路的前提下,加几层“保险”。
一、延时双删
这个名字听着有点土,但很好懂:
先更新数据库。
立即删一次缓存。
過個几十毫秒,再异步删一次同一个 key。
目的就是防止刚好有人在你删完缓存、写完库这段很短的时间里,把旧值又塞回去了。
Java 里实现挺简单的,加个异步任务就行:
@Transactional
public void updateUserName(Long userId, String newName) {
userMapper.updateName(userId, newName);
String key = "user:" + userId;
redisTemplate.delete(key);
// 异步再删一次
CompletableFuture.runAsync(() -> {
try {
Thread.sleep(50); // 根据实际情况调
} catch (InterruptedException ignored) {}
redisTemplate.delete(key);
});
}
这招不能保证 100% 没问题,但把出问题的概率压得很低,而且改造成本很低。
二、用消息队列补偿删除失败
有些时候 Redis 本身就比较忙,或者网络偶发抖了一下,删缓存这一步就可能失败。与其在业务线程里死命重试,不如直接“记下来,交给另外一个服务慢慢删”。
简单搞个事件对象:
@Data
public class CacheDeleteEvent implements Serializable {
private String key;
private long timestamp;
}
更新逻辑变成:
@Transactional
public void updateUserName(Long userId, String newName) {
userMapper.updateName(userId, newName);
String key = "user:" + userId;
try {
redisTemplate.delete(key);
} catch (Exception e) {
// 删除失败就扔到 MQ 里,让专门的消费者去删
CacheDeleteEvent event = new CacheDeleteEvent();
event.setKey(key);
event.setTimestamp(System.currentTimeMillis());
mqTemplate.convertAndSend("cache.delete.topic", event);
}
}
消费者那边就很简单了,专门负责“补刀”:
@RabbitListener(queues = "cache.delete.queue")
public void handleDelete(CacheDeleteEvent event) {
redisTemplate.delete(event.getKey());
}
这样就算业务线程那边删失败,只要 MQ 正常,这个 key 最终会被删掉。
三、同一个 key 上加个分布式锁,强行串行化
如果你业务上对一致性要求特别高,比如余额、库存这种,你就别指望靠“概率”了,可以在同一个 key 上加锁,把它所有读写串起来处理。
用 Redisson 的话,代码还挺好看:
public void updateUserNameSafely(Long userId, String newName) {
String lockKey = "lock:user:" + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
lock.lock(5, TimeUnit.SECONDS);
// 加锁内再去更新数据库 + 删缓存
userMapper.updateName(userId, newName);
String key = "user:" + userId;
redisTemplate.delete(key);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
坏处也明显:性能打折,同一用户的并发全被你写串行了。所以这招一般只对“小范围热点 + 强一致”那种业务点开,别啥都上锁,不然锁的是自己。
顺带再提一句连接池和线程池
很多人一上来就纠结“到底先写库还是先写缓存”,结果系统一压测,Tomcat 默认 200 个线程就打满了,数据库连接池默认 10 个连接也在那儿排队,所有请求都在“等资源”,根本轮不到你缓存出问题。
SpringBoot 默认给你配的那些参数,在本地开发跑跑 demo 还行,放到高并发生产环境就是坑,你至少得把 Web 容器线程数、数据库连接池最大连接数根据 QPS 和响应时间算一算,再加上限流,不然一场秒杀活动下来,你日志里看到的全是“线程池饱和”“获取连接超时”这种东西,跟缓存没半毛钱关系。
最后,回到那个最开始的问题
如果你现在还在纠结一句话答案,我的个人习惯是这么处理的(你可以对照下自己项目情况):
读:旁路缓存,先查缓存,miss 再查库并回填。
写:永远以数据库为准,更新数据库,然后删除缓存。
高并发、热点 key:视情况加“延时双删”或 MQ 补偿,极端场景再考虑分布式锁。
能接受短暂不一致的地方,就别太纠结那几十毫秒; 真不能接受的,很多时候干脆别上缓存,老老实实走数据库和事务。
那天处理完线上那个问题,已经快一点了,我跟小李说:“你看,写缓存顺序这事,其实比想象中麻烦多了,别被那几行伪代码骗了。”
小李点点头说懂了,转身去改代码,结果两分钟又跑回来问我:“东哥,那缓存穿透、击穿、雪崩那个又怎么算啊?”
算了,这个一说又得聊半天,改天再吐槽吧,我先去泡杯咖啡……
东哥作为一名超级老码农,整理了全网最全《Java高级架构师资料合集》。总量高达650GB。