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% 的场景用 "最终一致性" 就够了,强一致性会严重影响性能,得不偿失。

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

相关推荐
JIngJaneIL16 分钟前
基于springboot + vue古城景区管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
小信啊啊38 分钟前
Go语言切片slice
开发语言·后端·golang
Victor3562 小时前
Netty(20)如何实现基于Netty的WebSocket服务器?
后端
缘不易2 小时前
Springboot 整合JustAuth实现gitee授权登录
spring boot·后端·gitee
Kiri霧3 小时前
Range循环和切片
前端·后端·学习·golang
WizLC3 小时前
【Java】各种IO流知识详解
java·开发语言·后端·spring·intellij idea
Victor3563 小时前
Netty(19)Netty的性能优化手段有哪些?
后端
爬山算法3 小时前
Netty(15)Netty的线程模型是什么?它有哪些线程池类型?
java·后端
白宇横流学长3 小时前
基于SpringBoot实现的冬奥会科普平台设计与实现【源码+文档】
java·spring boot·后端
Python编程学习圈4 小时前
Asciinema - 终端日志记录神器,开发者的福音
后端