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();
}
}
核心逻辑:
- 用 Redisson 分布式锁保证同一商品的操作串行化
- 读操作时如果缓存失效,查数据库后直接更新缓存(不是删除)
- 写操作时先更新数据库,再同步更新缓存(不是删除)
方案 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 个避坑小贴士
- 缓存有效期别一刀切:核心数据(如库存)设置短有效期(30 秒)+ 提前预热,非核心数据(如商品描述)设置长有效期(24 小时)
- 监控要抓重点:关注这几个指标:
-
- cache命中率(get命中率=成功get次数/(成功get次数+失败get次数))
-
- 集群节点间的延迟(用redis-cli ping -c 100统计)
-
- 主从同步状态(info replication里的master_link_status)
- 别迷信 "强一致性" :分布式系统中,99% 的场景用 "最终一致性" 就够了,强一致性会严重影响性能,得不偿失。
最后总结一下:缓存不一致就像厨房里的油烟,看着不显眼但积多了就麻烦。关键是根据自己的业务场景选对策略 ------ 高频更新的场景用分布式锁 + 同步更新,允许延迟的场景用消息队列 + 异步对账,分片键一定要用业务主键!遇到问题别慌,先复现场景,再按 "查原因→定策略→加监控" 的步骤来,大部分坑都能填上。