Redis的原子性竟然失效了?记一次分布式锁翻车实录

"明明用了SETNX,为什么锁还是被超卖了?!"凌晨3点,监控报警把我从床上拽起来时,订单系统的库存数据已经乱成了一锅粥。这是一次典型的分布式锁失效事故------但当我翻出Redis日志时,却发现所有SETNX命令都返回了1(成功)。

问题的诡异之处在于:你以为的原子操作,可能根本不是原子。

1. 事故现场:订单超卖与"幽灵锁"

我们的电商促销系统在峰值期间每秒处理约2万笔订单,核心逻辑是用Redis分布式锁保证库存扣减的原子性。伪代码如下:

java 复制代码
// 错误写法:典型的"以为这样就能用"的分布式锁实现
public boolean deductStock(Long itemId) {
    String lockKey = "stock_lock:" + itemId;
    try {
        // 试图用SETNX获取锁
        boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
        if (!locked) return false;
        
        // 执行业务逻辑
        int stock = stockDao.query(itemId);
        if (stock <= 0) return false;
        stockDao.update(itemId, stock - 1);
        return true;
    } finally {
        redisTemplate.delete(lockKey); // 释放锁
    }
}

看起来没问题?但监控显示:同一时刻有3个线程成功获取了锁并执行了库存扣减。更诡异的是,Redis的慢查询日志里根本没有并发SETNX请求------它们确实是串行执行的!

2. 根因解剖:原子性的"时间裂隙"

问题出在锁的获取与过期时间设置不是原子操作。翻看Redis源码会发现:

  • SETNX本身是原子的
  • EXPIRE本身也是原子的
  • 但SETNX + EXPIRE组合起来就不是原子的!

在我们的案例中,RedisTemplate的setIfAbsent方法底层实际上执行了两个命令:

arduino 复制代码
SETNX lock_key 1  // 步骤1
EXPIRE lock_key 30 // 步骤2

如果在步骤1和步骤2之间Redis宕机,就会产生一把永不过期的幽灵锁。而我们的运维恰好在那天晚上做了主从切换...

3. 正确的救赎:从"自以为原子"到"真原子"

解决方案是使用Redis 2.6.12+的SET扩展参数:

java 复制代码
// 正确写法:使用原生SET命令的NX+EX参数
public boolean safeDeductStock(Long itemId) {
    String lockKey = "stock_lock:" + itemId;
    String token = UUID.randomUUID().toString(); // 客户端唯一标识
    
    try {
        // 原子性设置锁和过期时间
        boolean locked = redisTemplate.execute(new RedisCallback<Boolean>() {
            @Override
            public Boolean doInRedis(RedisConnection connection) {
                return connection.set(
                    lockKey.getBytes(),
                    token.getBytes(),
                    Expiration.seconds(30),
                    RedisStringCommands.SetOption.SET_IF_ABSENT
                );
            }
        });
        
        if (!locked) return false;
        
        // 业务逻辑(略)
        return true;
    } finally {
        // 确保只有锁的持有者能删除锁(Lua脚本保证原子性)
        String luaScript = 
            "if redis.call('get', KEYS[1]) == ARGV[1] then " +
            "   return redis.call('del', KEYS[1]) " +
            "else " +
            "   return 0 " +
            "end";
        redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(lockKey),
            token
        );
    }
}

关键改进点:

  1. 用单条SET命令实现NX+EX的原子操作
  2. 引入客户端唯一token防止误删其他线程的锁
  3. 用Lua脚本保证锁释放的原子性

4. 性能对比与代价

方案 TPS(单节点) 网络往返次数 宕机安全性
SETNX + EXPIRE 12,000 2 可能死锁
单SET NX+EX 15,000 1 安全
Lua脚本实现 9,000 1 安全

实测发现:即使是最安全的Lua方案,性能也比错误写法高出25%------因为减少了网络往返。

5. 分布式锁避坑清单

  1. 永远用SET NX+EX替代SETNX+EXPIRE:这是血泪教训
  2. 锁必须有唯一标识:防止线程A删了线程B的锁(我们后来发现那天晚上这种事也发生了)
  3. 设置合理的超时时间:太短会导致业务未完成锁就释放,太长会导致死锁
  4. 避免锁重入:相同客户端重复获取锁时要特别小心
  5. 网络分区时做好预案:Redis集群脑裂时可能出现多个客户端同时持有锁

结语

分布式系统的诡异之处就在于:所有你认为理所当然的原子性,都必须用代码明确保证。那天晚上的事故让我重新理解了Redis的"原子性"------它只在明确的单条命令层面保证,任何多命令组合都需要你亲自验证。

你在项目里还遇到过哪些"看起来原子实际不原子"的坑?欢迎在评论区分享你的战争故事。

相关推荐
笃行350几秒前
用 Rokid AIUI 开发日语学习助手
人工智能
脚本自动化开发1 小时前
折叠双屏MU156LA:写稿修图游戏体验
后端
脚本自动化开发1 小时前
AI短剧800集讲规矩,观众忘了是AI
后端
段一凡-华北理工大学2 小时前
工业智能体:从Codex/Claude到项目实战~系列文章06:一张蓝图看懂“工业智能体×高炉炼铁“
人工智能·claude·codex·工业智能体·高炉炼铁智能化·高炉智能体技术栈
怕浪猫2 小时前
十个人,630 万订阅:一张 AI 学习清单里的五种老师
人工智能
JonaHu3 小时前
5分钟搭建帮你赚钱的 AI API 网站:从品牌门户到客户 Key 交付
人工智能·api·开发者·ai创业·acedatacloud
Csvn3 小时前
模型挂了全站 502?降级链让它先保住“有响应”(R04)
人工智能·aigc·agent
Csvn3 小时前
安装、升级与目录结构
后端·nginx
皮皮林5513 小时前
万能通用!权限系统就该这么设计
后端
isNotNullX3 小时前
仓储管理最全框架:6大流程、8项指标、10个管理动作、12个常见问题
大数据·人工智能