首页
学习
活动
专区
圈层
工具
发布

高并发下是先写数据库,还是先写缓存?

那天晚上十一点多,我在公司楼下便利店排队买咖啡,手机一震,运维同事发了句:“东哥,用户订单金额又不对了,像是缓存和库打架了,你看下?” 我当时整个人就清醒了,比咖啡管用。

回到工位一看日志,大概场景是这样的:用户改了一个价格,数据库是新值,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。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OSpaajAnm82fHyvwfkx2eO-A0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。
领券