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的"原子性"------它只在明确的单条命令层面保证,任何多命令组合都需要你亲自验证。

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

相关推荐
MobotStone43 分钟前
AI 写代码越来越爽,但程序员可能越来越废了
人工智能·设计模式
vibecoding771 小时前
FLUX 3 Image Klein 实战:int8 量化 8GB 显卡跑商用文生图,ComfyUI 完整部署流程
人工智能·计算机视觉·大模型
shengjk11 小时前
既然 gRPC 底层也是 HTTP,为什么大厂还非要死磕 RPC?真相全在这!
后端
回家路上绕了弯1 小时前
人和 Agent 怎么一起干活:把任务交出去,把判断留在关键处
后端
志尊宝1 小时前
AI原生前端开发(013):端侧文本生成实战:智能提示、自动补全
人工智能·prompt·ai-native
波力海苔夹心脆6751 小时前
C# 正则表达式零基础入门:从“看得懂“到“会用“
经验分享·后端·mysql·正则表达式·c#
2601_965449081 小时前
开源工具做推广没预算?AI自动生成宣传视频的方案
人工智能·开源·音视频
会博通·代码搬运工1 小时前
粮食加工企业档案系统,如何对接 MES/ERP 与质检?——会博通采集与适配实践
人工智能·档案管理·企业数字化·ai赋能·档案系统·知识资产
愤怒火龙果1 小时前
AI攻防 AIRT03-RAG Exploitation & Vector Database Attacks
数据库·人工智能·网络安全