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

文章来源声明: 原文作者:阿橙的百宝箱; 来源站点:掘金; 原文链接:https://juejin.cn/post/7698923838285168674; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

价值在于用真实事故讲清Redis原子性边界,提醒开发者单命令原子不等于组合原子。适合使用Redis分布式锁的后端团队做事故复盘与方案自查。

“明明用了SETNX,为什么锁还是被超卖了?!”凌晨3点,监控报警把我从床上拽起来时,订单系统的库存数据已经乱成了一锅粥。这是一次典型的分布式锁失效事故——但当我翻出Redis日志时,却发现所有SETNX命令都返回了1(成功)。 问题的诡异之处在于:**你以为的原子操作,可能根本不是原子**。
  1. 事故现场:订单超卖与“幽灵锁”

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

// 错误写法:典型的"以为这样就能用"的分布式锁实现
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请求——它们确实是串行执行的!

  1. 根因解剖:原子性的“时间裂隙”

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

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

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

SETNX lock_key 1  // 步骤1
EXPIRE lock_key 30 // 步骤2

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

  1. 正确的救赎:从“自以为原子”到“真原子”

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

// 正确写法:使用原生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 + EXPIRE12,0002可能死锁
单SET NX+EX15,0001安全
Lua脚本实现9,0001安全

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

  1. 分布式锁避坑清单

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

结语

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

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