Redis 集群缓存不一致?这篇把坑给你挖明白了

Redis 集群缓存不一致?这篇把坑给你挖明白了

最近帮朋友排查线上问题,发现一个挺典型的缓存不一致案例:用户下单时库存显示还有 10 件,提交后却提示缺货。打开监控一看,Redis 集群里不同节点的库存数据居然差了 3 件。今天就结合这个实战经验,聊聊 Redis 集群缓存不一致的那些事儿。

一、先搞懂为啥会不一致:这 3 个坑最常见

1. 双写异步的时间差:数据库和缓存的 "异步时差"

很多同学用 "先更新数据库,再删缓存" 的策略,看起来挺合理吧?但在分布式环境下就会出幺蛾子。比如用户下单时:

  • 线程 A 正在更新数据库库存(从 10 减到 9)
  • 这时候线程 B 来读数据,发现缓存里还有 10,直接返回了旧值
  • 等线程 A 删缓存的时候,线程 B 已经把旧数据返回给用户了

就像你去食堂打饭,阿姨刚把菜盆端走准备加菜,你前面的人刚好看到菜盆里还有最后一勺,结果你们俩都以为还有菜,其实数据库里已经扣减了。

2. 集群分片的 "分片键" 陷阱

Redis 集群用哈希分片时,如果分片键设计不合理,会导致数据分布混乱。比如电商系统用商品 ID 做分片键没问题,但有些同学图省事用用户 ID,结果:

  • 用户 A 查询商品 1 的库存,请求落到节点 1
  • 另一个用户 B 修改商品 1 的库存,请求落到节点 2(因为用户 ID 哈希值不同)
  • 节点 2 更新完缓存,节点 1 还是旧数据

这就好比把同一款商品的库存信息,分别记在不同的小本本上,却没规定统一的记账本,结果每个本子记的数都不一样。

3. 网络分区的 "脑裂" 危机

当集群出现网络分区(比如部分节点间通信中断),会形成两个小集群:

  • 主集群继续处理写请求,正常更新数据
  • 被隔离的从集群还以为自己是主集群,继续处理读请求,返回旧数据
  • 等网络恢复后,两个集群的数据就对不上了

就像团队里的两个小组各自为政,都以为自己在处理最新任务,最后合并时发现很多工作都重复或者遗漏了。

二、这些真实场景最容易踩坑,看看你中了几条?

场景 1:电商库存扣减(双写异步踩坑)

某电商 APP 用户下单时,后端逻辑是:

arduino 复制代码
// 伪代码:先更新数据库,再删缓存
public boolean deductStock(String productId, int count) {
    // 1. 更新数据库
    int result = db.update("update stock set count=count-? where productId=?", count, productId);
    if (result > 0) {
        // 2. 删除缓存
        redis.del("stock:" + productId);
        return true;
    }
    return false;
}

高并发下经常出现:数据库扣减成功,但删缓存前服务被重启,导致缓存里还是旧数据。用户看到还有库存,下单后却提示缺货。

场景 2:新闻 feed 缓存(分片键设计错误)

某资讯 APP 用用户 ID 做分片键缓存新闻列表:

ini 复制代码
// 错误示例:用用户ID做分片键
String key = "feed:" + userId;
String feedData = redis.get(key);

当用户 A 分享自己的新闻列表给用户 B 时,用户 B 查询的是用户 A 的分片节点,导致看到的是用户 A 的缓存数据,而不是自己的最新 feed。

场景 3:秒杀活动中的脑裂(网络分区故障)

某秒杀活动期间,Redis 集群突发网络分区:

  • 主节点所在分区继续处理秒杀请求,库存扣减到 50 件
  • 从节点所在分区没收到主节点的更新,还返回库存 100 件
  • 部分用户看到还有库存,下单后却被告知秒杀结束

三、手把手教你填坑,附核心解决方案

方案 1:用 "更新缓存" 代替 "删除缓存",加分布式锁防并发

csharp 复制代码
// 优化后:先加锁,再更新缓存
public String getProductStock(String productId) {
    RLock lock = redissonClient.getLock("stock_lock:" + productId);
    try {
        lock.lock();
        // 先查缓存
        String stock = redis.get("stock:" + productId);
        if (stock != null) {
            return stock;
        }
        // 查数据库
        String dbStock = db.query("select count from stock where productId=?", productId);
        // 更新缓存,设置30秒有效期
        redis.setex("stock:" + productId, 30, dbStock);
        return dbStock;
    } finally {
        lock.unlock();
    }
}
// 扣减库存时:先更新数据库,再更新缓存
public boolean deductStock(String productId, int count) {
    RLock lock = redissonClient.getLock("stock_lock:" + productId);
    try {
        lock.lock();
        // 1. 更新数据库
        int result = db.update("update stock set count=count-? where productId=? and count>=?", count, productId, count);
        if (result > 0) {
            // 2. 直接更新缓存,不是删除
            String currentStock = redis.get("stock:" + productId);
            if (currentStock != null) {
                redis.set("stock:" + productId, String.valueOf(Integer.parseInt(currentStock) - count));
            }
            return true;
        }
        return false;
    } finally {
        lock.unlock();
    }
}

核心逻辑:

  1. 用 Redisson 分布式锁保证同一商品的操作串行化
  1. 读操作时如果缓存失效,查数据库后直接更新缓存(不是删除)
  1. 写操作时先更新数据库,再同步更新缓存(不是删除)

方案 2:分片键统一用 "业务主键",别偷懒

正确做法是:不管谁来操作,都用业务实体的 ID 作为分片键。比如操作商品库存,统一用productId做分片键:

ini 复制代码
// 正确示例:用商品ID做分片键
String key = "stock:" + productId; // 不管是哪个用户操作,都用productId
redis.get(key);

这样不管是读请求还是写请求,都会落到同一个分片节点,避免不同节点数据不一致。

方案 3:脑裂防护 + 异步对账,双保险

(1)配置脑裂防护参数

在 redis.conf 中添加:

python 复制代码
# 要求至少2个从节点同步数据
min-slaves-to-write 2
# 从节点延迟超过10秒就拒绝写请求
min-slaves-max-lag 10

当主节点发现少于 2 个从节点在 10 秒内同步数据,就拒绝接收写请求,避免脑裂时旧数据被写入。

(2)异步对账服务

写一个定时任务,每天凌晨对关键业务数据进行对账:

scss 复制代码
// 简化的对账逻辑
public void reconcileStock() {
    // 1. 从数据库获取全量库存数据
    List<ProductStock> dbStocks = db.query("select productId, count from stock");
    // 2. 遍历Redis集群每个节点
    for (RedisNode node : redisCluster.getNodes()) {
        Jedis jedis = node.getJedis();
        // 3. 获取该节点上的所有库存缓存
        Set<String> keys = jedis.keys("stock:*");
        for (String key : keys) {
            String productId = key.split(":")[1];
            // 4. 对比数据库和缓存
            String cacheStock = jedis.get(key);
            ProductStock dbStock = dbStocks.stream()
                .filter(s -> s.getProductId().equals(productId))
                .findFirst()
                .orElse(null);
            if (dbStock != null && !cacheStock.equals(dbStock.getCount())) {
                // 5. 不一致时更新缓存
                jedis.set(key, dbStock.getCount());
                log.warn("Reconciled stock for productId: {}, cache: {}, db: {}", productId, cacheStock, dbStock.getCount());
            }
        }
    }
}
(3)消息队列异步处理最终一致性

对于允许短暂不一致的场景(比如用户积分更新),可以用消息队列做异步处理:

arduino 复制代码
// 生产者:更新数据库后发送消息
public boolean updateUserPoint(String userId, int point) {
    // 1. 更新数据库
    db.update("update user set point=point+? where userId=?", point, userId);
    // 2. 发送更新缓存的消息到RabbitMQ
    rabbitTemplate.convertAndSend("cache_update_queue", new CacheUpdateMessage("user_point:" + userId, point));
    return true;
}
// 消费者:接收消息后更新缓存
@RabbitListener(queues = "cache_update_queue")
public void updateCache(CacheUpdateMessage message) {
    // 这里可以加重试机制,比如用Spring Retry
    redis.incrBy(message.getKey(), message.getDelta());
}

四、最后再送你 3 个避坑小贴士

  1. 缓存有效期别一刀切:核心数据(如库存)设置短有效期(30 秒)+ 提前预热,非核心数据(如商品描述)设置长有效期(24 小时)
  1. 监控要抓重点:关注这几个指标:
    • cache命中率(get命中率=成功get次数/(成功get次数+失败get次数))
    • 集群节点间的延迟(用redis-cli ping -c 100统计)
    • 主从同步状态(info replication里的master_link_status)
  1. 别迷信 "强一致性" :分布式系统中,99% 的场景用 "最终一致性" 就够了,强一致性会严重影响性能,得不偿失。

最后总结一下:缓存不一致就像厨房里的油烟,看着不显眼但积多了就麻烦。关键是根据自己的业务场景选对策略 ------ 高频更新的场景用分布式锁 + 同步更新,允许延迟的场景用消息队列 + 异步对账,分片键一定要用业务主键!遇到问题别慌,先复现场景,再按 "查原因→定策略→加监控" 的步骤来,大部分坑都能填上。

相关推荐
写bug写bug1 小时前
手把手教你使用JConsole
java·后端·程序员
苏三说技术1 小时前
给你1亿的Redis key,如何高效统计?
后端
JohnYan1 小时前
工作笔记- 记一次MySQL数据移植表空间错误排除
数据库·后端·mysql
程序员清风2 小时前
阿里二面:Kafka 消费者消费消息慢(10 多分钟),会对 Kafka 有什么影响?
java·后端·面试
CodeSheep2 小时前
宇树科技,改名了!
前端·后端·程序员
hstar95272 小时前
三十五、面向对象底层逻辑-Spring MVC中AbstractXlsxStreamingView的设计
java·后端·spring·设计模式·架构·mvc
楽码2 小时前
AI决策树:整理繁杂问题的简单方法
人工智能·后端·openai
星辰大海的精灵2 小时前
基于Dify+MCP实现通过微信发送天气信息给好友
人工智能·后端·python
import_random3 小时前
[深度学习]5大神经网络架构(介绍)
后端
pengyu3 小时前
【Java设计原则与模式之系统化精讲:壹】 | 编程世界的道与术(实战指导篇)
java·后端·设计模式