ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

数据库与缓存双写一致性保障方案与延迟双删考量

数据库与缓存双写一致性保障方案与延迟双删考量 数据库与缓存双写一致性保障方案与延迟双删考量在高并发互联网业务系统中“MySQL 关系型数据库 Redis 内存缓存”是保障系统高吞吐与极速响应的标准架构。然而只要涉及双数据源的更新就必然面临一个经典的技术困境——双写一致性问题。线上经常出现这样的幽灵 Bug用户修改了收货地址或商品价格页面刷新后依然展示旧数据或者在促销大促期间偶发的网络抖动导致缓存中长期驻留脏数据直到运营手动去清缓存才恢复。许多技术文章将“延迟双删”奉为解决一致性的万能药。但在高吞吐的工业级生产环境中延迟双删存在诸多难以克服的天然缺陷。本文将系统剖析常见双写策略的并发时序冲突深入探讨延迟双删的边界并给出主流高可靠的一致性落地架构。常见双写模式的并发时序冲突剖析在 Cache-Aside旁路缓存模式下写操作通常有两种选择模式 A: 先更新数据库再更新缓存 ── [淘汰] 并发写导致后写先到达缓存驻留旧值 模式 B: 先删除缓存再更新数据库 ── [高风险] 读写并发导致脏数据被回写缓存 模式 C: 先更新数据库再删除缓存 ── [主流推荐] 极低概率出现并发脏读1. 先删缓存再更数据库的并发破绽[线程 A (写请求)] [线程 B (读请求)] │ │ ├─ 1. 删除 Redis 缓存 │ │ ├─ 2. 查询 Redis (未命中 Cache Miss) │ ├─ 3. 查询 MySQL (读取到旧数据 V1) ├─ 4. 更新 MySQL (写入新数据 V2) │ │ ├─ 5. 将旧数据 V1 写入 Redis 缓存 (覆盖完成) ▼ ▼ 结果: MySQL 中是最新数据 V2而 Redis 中永久驻留旧数据 V12. 先更数据库再删缓存Cache Aside 核心策略这是目前工程界普遍推荐的基准策略。只有在以下极端并发条件下才可能发生不一致缓存刚好失效Cache Miss读请求先查 MySQL 得到旧值 V1紧接着写请求更新 MySQL 为 V2 并删除了缓存读请求最后才将旧值 V1 写回 Redis。由于通常 MySQL 写入与事务提交耗时远大于读缓存耗时这种并发碰撞的概率极低。但在大并发与主从同步延迟下仍有偶发脏读风险。延迟双删的原理与其生产短板为了解决“先删缓存再更数据库”场景下的读写交叉写脏问题社区提出了**延迟双删Delayed Double Deletion**策略public void updateWithDoubleDelete(Long id, UserEntity newData) { // 1. 第一次删除缓存 redisTemplate.delete(user: id); // 2. 更新数据库 userMapper.updateById(newData); // 3. 异步休眠特定时间后执行第二次删除缓存 taskScheduler.schedule(() - { redisTemplate.delete(user: id); }, 500, TimeUnit.MILLISECONDS); }延迟双删在生产中的四大致命缺陷休眠时间Sleep Time无法科学预估延迟删除的休眠时间必须严格大于“主从同步延迟 读请求业务耗时”。但在生产环境中主从复制延迟会因大事务、网络波动而从几毫秒漂移至数秒固定写死 500ms 极易失效。第二次删除失败缺乏可靠重试机制若第二次删除由于 Redis 网络抖动抛出异常业务主流程通常早已结束脏数据将无法被清除。严重侵入业务代码与占用调度资源在核心业务服务中引入大量Thread.sleep或内存延迟队列浪费线程资源且极大破坏了代码的整洁度。生产级高可靠一致性解决方案方案一Canal / Debezium 监听 Binlog 异步失效缓存推荐通过变更数据捕获CDC, Change Data Capture技术将业务更新与缓存失效彻底解耦。[业务应用] ── 仅操作 MySQL 事务提交 │ ▼ (写入 MySQL Binlog) [Canal / Debezium 监听集群] │ ▼ (解析 Row 级别变更并投递 MQ) [RocketMQ / Kafka 顺序消息] │ ▼ [Cache Consumer 缓存失效消费服务] │ (具备 ACK 与死信重试机制) ▼ [Redis 缓存精准逐出]优势业务代码零侵入业务层只需安心写数据库事务无需关心缓存何时清理。天然的最终一致性保障MQ 提供持久化、顺序投递与消费重试机制。即便 Redis 宕机重启后消费堆积的 Binlog 消息即可自动修复缓存。方案二Redisson 读写锁RReadWriteLock实现强一致性在金融资产、敏感库存等必须杜绝任何脏读的强一致场景中可以借助分布式读写锁实现并发互斥package com.example.cache.service; import org.redisson.api.RLock; import org.redisson.api.RReadWriteLock; import org.redisson.api.RedissonClient; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; Service public class ConsistentAccountService { private final RedissonClient redissonClient; private final StringRedisTemplate redisTemplate; private final AccountMapper accountMapper; public ConsistentAccountService(RedissonClient redissonClient, StringRedisTemplate redisTemplate, AccountMapper accountMapper) { this.redissonClient redissonClient; this.redisTemplate redisTemplate; this.accountMapper accountMapper; } /** * 强一致性读取共享读锁 */ public AccountDTO getAccount(Long accountId) { String lockKey lock:account: accountId; String cacheKey cache:account: accountId; RReadWriteLock rwLock redissonClient.getReadWriteLock(lockKey); RLock readLock rwLock.readLock(); try { readLock.lock(5, TimeUnit.SECONDS); // 1. 先查缓存 String cached redisTemplate.opsForValue().get(cacheKey); if (cached ! null) { return parseJson(cached); } // 2. 未命中查库并回写 AccountDTO account accountMapper.selectById(accountId); if (account ! null) { redisTemplate.opsForValue().set(cacheKey, toJson(account), 30, TimeUnit.MINUTES); } return account; } finally { readLock.unlock(); } } /** * 强一致性更新排他写锁 */ public void updateAccount(AccountDTO account) { String lockKey lock:account: account.getId(); String cacheKey cache:account: account.getId(); RReadWriteLock rwLock redissonClient.getReadWriteLock(lockKey); RLock writeLock rwLock.writeLock(); try { writeLock.lock(10, TimeUnit.SECONDS); // 1. 更新数据库 accountMapper.updateById(account); // 2. 同步逐出缓存 redisTemplate.delete(cacheKey); } finally { writeLock.unlock(); } } }一致性方案的选型决策树业务场景推荐方案一致性级别吞吐量表现架构复杂度大部分通用业务(电商商品详情、用户个人资料)Cache-Aside Binlog/MQ 异步删除最终一致性 (秒级)极高中等敏感核心账户 / 库存Redisson 分布式读写锁强一致性 (线性读写)中等 (受锁竞争影响)较低允许短时不一致的榜单数据仅更新 DB 依赖 Cache TTL 自然过期弱一致性极高极低
返回列表