☰
双写一致性
2026/10/2 20:46:32 网站建设 项目流程

一般系统中都会添加缓存中间件缓存一些热点数据以提高系统的响应速度,但是缓存(Redis)的加入会导致系统复杂性的增加,例如如何保证数据库和缓存的数据保持一致性。而这种情况根据业务的不同,又可以分为强一致性和弱一致性。

强一致性

定义及场景

强一致性一般是指对于一些数据的修改,系统要保证该修改用户立马可见,即任何时刻用户读到的都是最新写入的数据。常见的场景有:

  • 商品库存:下单扣减库存后必须立即生效,防止出现超卖
  • 账户余额、支付金额:扣款、转账之后余额必须立即准确
  • 订单状态:支付成功后订单状态需要用户立马可见
  • 权限类数据:如用户被封禁后需要立即无法再进行操作

例如对于商品库存的修改,需要用户立马可见。

实现方式

对于强一致性的系统而言,为了保证强一致性,一般需要同时操作数据库和缓存。例如修改数据库之后立马修改缓存,但是由于并发带来的问题,导致先修改数据库还是先修改缓存会产生不一样的结果,下面就对先操作数据库和先操作缓存进行分类讨论。

在操作缓存时,一般默认是直接删除缓存,原因有以下两点:

  1. 对于缓存中的修改操作一般是要比删除操作要慢,修改可能需要修改对应 key 中的某一个值,删除时只需要删除整个 key 即可。
  2. 一般系统只会对热点数据进行缓存,即使缓存被删除后该数据不存在了,也可以通过互斥控制的方式实现一次只有一个请求访问数据库,并将数据放入缓存中。
先操作缓存

对于先操作缓存时,可能出现以下这种情况。线程一先删除缓存,在线程一删除缓存和修改数据库之间,线程二进行了查询数据库并写入缓存的操作。这时就会导致数据库的数据是新值,但是缓存由于线程二的加入导致缓存的内容是旧值。

先操作数据库

先操作数据库也会有一些问题存在。如下图,线程一首先查询缓存发现没有数据,就在查询数据库并写入缓存之间,线程二出现,线程二完成了修改数据库以及删除缓存的操作。这又会导致缓存中的是旧值,而数据库中的为新值,从而导致数据不一致的问题。但我认为这种情况是很少见的,原因就是发生这种情况需要满足两个条件:一是线程一查询缓存时缓存刚好失效(也可能是这条数据从未被缓存过),二是就在这时线程二需要修改数据。这种情况个人认为概率是非常低的。除非这是一个写多读少的系统,但对于写多读少的系统我们一般不会对缓存进行操作。

这个概率低同样的论证也适用于先操作缓存,但两者的概率并不在一个量级,关键区别在于并发窗口的大小:

  • 先删缓存的失败条件是:读线程的"查库 + 写缓存"落在写线程"删缓存"到"更新数据库"之间即可。数据库的更新操作本身较慢(中间还可能穿插业务逻辑),这个窗口相对较长;而读请求本身就很频繁,读请求落入这个窗口的概率并不低。
  • 先更新数据库的失败条件是:读线程的"查库 + 写缓存"必须完整跨越写线程"更新数据库 + 删除缓存"这两个都很快的操作,即查库要发生在更新数据库之前、写缓存要发生在删除缓存之后,要求两个线程的操作严格交错,窗口极窄。

所以两种方案理论上都可能出现不一致,但先删缓存出问题的概率明显更高,这正是"先操作数据库、再删除缓存"成为行业通用方案的原因。

使用方案

通过上面的分析,以及行业的经验。一般对于强一致性的系统,通常都是通过先操作数据库、再删除缓存的方式进行的。同时也有其他一些方案,我个人认为这些方案都有一定的缺点,了解即可。

  • 延时双删:延时双删是指在修改数据时,先删除缓存 -> 修改数据库 ->(延时一会)删除缓存。由于先操作数据库再删除缓存出现问题的概率是很低的,同时延时双删中这个延时的时长是多少,这是一个非常吃经验的值。
  • 引入读写锁:引入 Redisson 的读写锁。引入锁之后,会导致系统的响应有一定程度的降低,特别是并发的时候,这需要结合业务综合考量。

Redisson 读写锁代码示例:读锁

public Item getById(Integer id){ RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK"); //读之前加读锁,读锁的作用就是等待该lockkey释放写锁以后再读 RLock readLock = readWriteLock.readLock(); try { //开锁 readLock.lock(); System.out.println("readLock..."); Item item = (Item) redisTemplate.opsForValue().get("item:"+id); if(item != null){ return item; } //查询业务数据 item = new Item(id, "华为手机", "华为手机", 5999.00); //写入缓存 redisTemplate.opsForValue().set("item:"+id,item); //返回数据 return item; } finally { readLock.unlock(); } }

写锁

public void updateById(Integer id){ RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK"); //写之前加写锁,写锁加锁成功,读锁只能等待 RLock writeLock = readWriteLock.writeLock(); try { //开锁 writeLock.lock(); System.out.println("writeLock..."); //更新业务数据 Item item = new Item(id, "华为手机", "华为手机", 5299.00); try { Thread.sleep(10000); } catch (InterruptedException e) { e.printStackTrace(); } //删除缓存 redisTemplate.delete("item:"+id); } finally { writeLock.unlock(); } }

弱一致性

定义及常见场景

弱一致性是指系统允许数据在修改后短时间内存在不一致,即不保证修改对用户立马可见,只要求经过一段时间后,缓存与数据库的数据最终能够达成一致(最终一致性)。常见的场景有:

  • 商品的基础展示信息:如商品标题、描述、图片等非核心字段
  • 内容类数据:如文章、评论、点赞数、浏览量等
  • 用户信息:如昵称、头像等
  • 推荐类数据:如首页推荐、排行榜等

这类数据即使短时间内读到旧值,也不会对业务造成实质性影响。

使用方案

弱一致性一般不需要同时操作数据库和缓存,这种弱一致性处理方式其实很像是主从复制的思路 —— 最终一致性。一般有两种方案:

使用 MQ

使用 canal

Canal 是阿里巴巴开源的一个基于 MySQL binlog 的增量数据订阅与消费组件。它的工作原理是将自己伪装成 MySQL 的一个从库(slave),向主库发送 dump 请求,主库随后将 binlog 推送给 Canal,Canal 解析 binlog 后即可拿到数据库中数据的变更(增、删、改)事件,再将其发送到 MQ 或直接交给业务方处理。这样业务系统就可以在不侵入业务代码的情况下感知数据库的变更,再据此删除或更新对应的缓存,从而实现数据库到缓存的最终一致性。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询