
上周四凌晨两点,运维群里弹出一条告警:用户余额显示 5000,实际账户只剩 3000。
排查链路走了一圈:支付接口调了减库存服务,库存服务内部抛了异常、Spring 事务正常回滚了,数据库里的库存确实没扣。但 Redis 里的库存缓存,在异常抛出之前就已经更新了。
结论一句话:你在 @Transactional 方法里直接操作 Redis,事务回滚了 Redis 没回滚,缓存里躺着的全是脏数据。
这篇文章不跟你讲 CAP 理论,不画花里胡哨的架构图,就聚焦一个问题:怎么让 Redis 缓存的删除/更新,跟数据库事务真正绑定在一起。
很多人都犯过这个错:
@Transactional
public void deductStock(Long productId, int count) {
// 1. 更新数据库库存
stockMapper.deduct(productId, count);
// 2. 更新 Redis 缓存
redisTemplate.opsForValue().set("stock:" + productId, newStock);
// 3. 这里抛异常了,事务回滚
if (count > 100) {
throw new RuntimeException("超量");
}
}你期望的是:DB 回滚,Redis 也别改。实际发生的是:Redis 在第 2 步已经写进去了,第 3 步抛异常后 Spring 回滚了 stockMapper.deduct(),但 redisTemplate.set() 它管不着。
Spring 的 @Transactional 底层是依靠
PlatformTransactionManager 来管理事务资源的。 跟 JDBC 打交道时,它从 DataSource 拿到的 Connection 会被包装成事务感知的连接,commit/rollback 都能联动。但 RedisTemplate 默认根本不注册任何 TransactionManager——所以 Spring 对它视而不见。
更坑的是,很多人在 finally 块里写 Redis 操作:
@Transactional
public void updateOrder(Order order) {
try {
orderMapper.update(order);
// 可能抛异常
paymentService.charge(order);
} finally {
// finally 块一定会执行,不管事务是否回滚
redisTemplate.delete("order:" + order.getId());
}
}finally 块的语义是"无论如何都执行"。事务回滚了,缓存也被删了,下次读请求从 DB 加载到的还是旧数据,相当于缓存和 DB 一起被你搞丢了。
Spring 提供了一个专门干这件事的工具:
TransactionSynchronizationManager。你把 Redis 操作注册为一个"事务同步回调",让它只在事务真正提交成功后才执行。
@Transactional
public void deductStock(Long productId, int count) {
// 1. 更新数据库(受事务管理)
stockMapper.deduct(productId, count);
// 2. 注册事务提交后的回调——仅在事务成功提交时删除缓存
String cacheKey = "stock:" + productId;
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 数据库事务已成功提交,此时删除缓存最安全
redisTemplate.delete(cacheKey);
}
}
);
}核心逻辑:
这就解决了"事务回滚了但缓存被改了"的问题。但是——它解决不了另一个问题。
考虑这个场景:stockMapper.deduct() 执行成功,Spring 提交事务,触发 afterCommit(),然后 redisTemplate.delete() 因为网络抖动失败了。
此时 DB 里的库存已经是新值,Redis 里还是旧缓存。数据库和缓存不一致,而且这次没有回滚能救你。
所以单靠 afterCommit() 不够,需要加一层删除重试 + 最终一致性兜底。
一个工程上可落地的方案:
@Transactional
public void deductStock(Long productId, int count) {
stockMapper.deduct(productId, count);
String cacheKey = "stock:" + productId;
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
// 第一步:立即删除缓存
Boolean deleted = redisTemplate.delete(cacheKey);
if (Boolean.FALSE.equals(deleted)) {
// 第二步:删除失败,写入重试队列
cacheRetryQueue.push(new CacheDeleteTask(cacheKey, 3));
}
}
}
);
}重试队列的三层保障:
只靠 TTL 行不行?行,但不优雅。TTL 是最后一道防线,不能是第一道。如果 TTL 设了 10 分钟,这 10 分钟内用户看到的就是错误数据。
方案 | 做法 | 问题 |
先删缓存再更新DB | 删缓存 → 更新DB | 并发读会把旧数据写回缓存,造成脏数据窗口 |
先更新DB再删缓存 | 更新DB → 删缓存 | 本文讨论的方案,基础可行,需配合重试 |
延迟双删 | 删缓存 → 更新DB → sleep → 再删 | sleep 的时间不好确定,极端并发仍有窗口 |
事务内操作Redis | 配 RedisTransactionManager | Redis 的 multi/exec 和 DB 事务完全隔离,掩耳盗铃 |
CDC 同步 | 监听 binlog 自动更新缓存 | 架构重,适合大团队,小项目杀鸡用牛刀 |
推荐的务实路径:
下面的代码用 Spring Boot 3 + MyBatis-Plus + Redis,展示了完整的"事务提交后可靠删除缓存"实现:
@Service
@RequiredArgsConstructor
public class ProductStockService {
private final StockMapper stockMapper;
private final StringRedisTemplate redisTemplate;
private final CacheRetryQueue retryQueue; // 自定义的重试队列
/**
* 扣减库存——事务提交后删除缓存,失败则入重试队列
*/
@Transactional(rollbackFor = Exception.class)
public void deduct(Long productId, int count) {
// 1. 数据库操作:受 Spring 事务管理
int rows = stockMapper.deduct(productId, count);
if (rows == 0) {
throw new BusinessException("库存不足");
}
// 2. 注册事务提交后的缓存删除回调
String cacheKey = "stock:" + productId;
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
boolean deleted = deleteWithRetry(cacheKey, 3);
if (!deleted) {
// 本地重试失败,丢进 MQ 异步重试,保证最终一致
retryQueue.push(new CacheDeleteTask(cacheKey, 5));
}
}
}
);
}
/**
* 本地重试:网络抖动场景下 3 次内通常能成功
*/
private boolean deleteWithRetry(String key, int maxRetries) {
for (int i = 0; i < maxRetries; i++) {
Boolean result = redisTemplate.delete(key);
if (Boolean.TRUE.equals(result)) {
return true;
}
try {
Thread.sleep(100L * (i + 1)); // 递增退避:100ms, 200ms, 300ms
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
return false;
}
/**
* 读缓存:永远从 Redis 读,miss 则回源 DB 并回填
*/
public Integer getStock(Long productId) {
String cacheKey = "stock:" + productId;
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return Integer.parseInt(cached);
}
// 缓存 miss,从 DB 加载并回填,TTL 兜底
Integer stock = stockMapper.selectStock(productId);
if (stock != null) {
redisTemplate.opsForValue().set(
cacheKey, String.valueOf(stock),
10 + ThreadLocalRandom.current().nextInt(5), // TTL 加随机抖动防雪崩
TimeUnit.MINUTES
);
}
return stock;
}
}关键解释:
Redis 从来不是数据库的影子副本,你没法让它天然参与事务。 你只能通过"延迟到事务提交后执行 + 失败重试 + TTL 兜底"这套组合拳,把不一致的概率降到可接受的范围内。
大多数业务场景下,afterCommit 加 3 次本地重试、配合 10 分钟 TTL,已经足够用了。别一上来就上 MQ、上 CDC,你的 QPS 可能还没到需要那一步。
缓存不是问题,不一致的时间窗口才是。你要做的不是消灭窗口,是让窗口窄到业务能接受。
更新时间:2026-07-01
本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828
© CopyRight All Rights Reserved.
Powered By 71396.com 闽ICP备11008920号
闽公网安备35020302034903号