事务回滚了,缓存还在

上周四凌晨两点,运维群里弹出一条告警:用户余额显示 5000,实际账户只剩 3000。

排查链路走了一圈:支付接口调了减库存服务,库存服务内部抛了异常、Spring 事务正常回滚了,数据库里的库存确实没扣。但 Redis 里的库存缓存,在异常抛出之前就已经更新了。

结论一句话:你在 @Transactional 方法里直接操作 Redis,事务回滚了 Redis 没回滚,缓存里躺着的全是脏数据。

这篇文章不跟你讲 CAP 理论,不画花里胡哨的架构图,就聚焦一个问题:怎么让 Redis 缓存的删除/更新,跟数据库事务真正绑定在一起。

为什么 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 一起被你搞丢了。

正确的做法:把 Redis 操作挂在事务回调上

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));
                }
            }
        }
    );
}

重试队列的三层保障:

  1. 即时重试:afterCommit 内删 3 次,间隔 100ms
  2. 异步重试:如果 3 次全失败,丢进 MQ 或 Redis List,由专门的重试消费者处理
  3. 过期兜底:所有缓存必须设 TTL。就算前两层全挂,过期后缓存自动失效,下次读请求回源 DB 加载最新数据

只靠 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

标签:科技   缓存   事务   库存   数据   操作   数据库   队列   异常   窗口   业务

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight All Rights Reserved.
Powered By 71396.com 闽ICP备11008920号
闽公网安备35020302034903号

Top