Redis 事务真的原子吗?揭秘“伪原子性”陷阱与 Lua 脚本的正确打开方式

0 阅读3分钟

Redis 事务真的原子吗?揭秘“伪原子性”陷阱与 Lua 脚本的正确打开方式

在高并发系统中,Redis 常被用作缓存、计数器、分布式锁等关键组件。当多个操作需要“一起成功或一起失败”时,开发者往往会第一时间想到 Redis 的 事务(Transaction) 功能。然而,许多团队在实际使用中发现:Redis 事务并不具备传统数据库意义上的原子性,这导致了数据不一致、竞态条件等严重问题。

本文将深入剖析 Redis 事务的局限性,并解释为何越来越多的团队最终选择 Lua 脚本 作为替代方案——它才是真正实现“原子性”逻辑的利器。


一、Redis 事务的“原子性”错觉

Redis 提供了 MULTI / EXEC 指令来实现事务:

MULTI
INCR user:123:score
DECR user:123:coins
EXEC

表面上看,这两条命令被“打包”执行,似乎具有原子性。但事实并非如此。

Redis 事务的三大限制:

  1. 不支持回滚(No Rollback)
    如果事务中某条命令执行出错(如对字符串执行 INCR),Redis 不会回滚已成功执行的命令,而是继续执行后续命令。
    结果:部分成功,破坏一致性。
  2. 不隔离并发操作(No Isolation)
    MULTIEXEC 之间,其他客户端仍可修改相同 key。Redis 事务只是将命令排队,并非“锁定”数据。
    结果:无法防止竞态条件(Race Condition)。
  3. 仅保证命令顺序执行,不保证逻辑原子性
    事务中的命令会被顺序放入队列,在 EXEC 时一次性执行,但整个过程不是原子操作——中间可能被其他命令插入。

📌 官方文档明确指出: “Redis 事务是原子的,但仅限于命令入队和执行阶段;它不提供 ACID 中的回滚或隔离。”


二、一个真实场景:库存扣减失败

假设我们要实现“扣减库存 + 记录日志”:

MULTI
DECR stock:product_1001
LPUSH log:product_1001 "user_5566 bought at $(now)"
EXEC

问题来了

  • 如果 stock:product_1001 被另一个请求减到 0,当前 DECR 会变成 -1(超卖)!
  • 即使我们在应用层先 GET 再判断,也无法避免 GETDECR 之间的窗口期被并发请求抢占。

Redis 事务对此无能为力——因为它无法在执行前判断条件,也无法在条件不满足时中止整个操作。


三、为什么 Lua 脚本能解决这个问题?

Redis 从 2.6 版本开始支持 Lua 脚本,其核心优势在于:

脚本在服务器端原子执行
执行期间不会被其他命令中断
可包含逻辑判断、循环、条件分支

用 Lua 实现安全的库存扣减:

-- check_and_decr.lua
local current = redis.call('GET', KEYS[1])
if tonumber(current) > 0 then
    redis.call('DECR', KEYS[1])
    redis.call('LPUSH', KEYS[2], ARGV[1])
    return 1  -- 成功
else
    return 0  -- 库存不足
end

在 .NET 中调用(使用 StackExchange.Redis):

var script = @"
    local current = redis.call('GET', KEYS[1])
    if tonumber(current) > 0 then
        redis.call('DECR', KEYS[1])
        redis.call('LPUSH', KEYS[2], ARGV[1])
        return 1
    else
        return 0
    end
";

var result = await db.ScriptEvaluateAsync(script, 
    keys: new RedisKey[] { "stock:product_1001", "log:product_1001" },
    values: new RedisValue[] { $"user_{userId} bought at {DateTime.Now}" }
);

if ((long)result == 1)
{
    Console.WriteLine("购买成功");
}
else
{
    throw new InvalidOperationException("库存不足");
}

✅ 整个逻辑在 Redis 服务端单线程执行,彻底杜绝并发干扰。


四、Lua 脚本 vs Redis 事务:关键对比

特性Redis 事务 (MULTI/EXEC)Lua 脚本
原子性❌ 仅命令排队,无逻辑原子性✅ 服务端原子执行
条件判断❌ 无法实现✅ 支持 ifwhile
错误处理❌ 不回滚✅ 可主动返回错误码
并发安全❌ 无隔离✅ 执行期间独占 Redis
性能⚠️ 多次网络往返✅ 一次请求完成复杂逻辑
可维护性⚠️ 逻辑分散在客户端✅ 逻辑集中,易于测试

五、使用 Lua 脚本的最佳实践

  1. 预加载脚本:使用 SCRIPT LOAD 缓存脚本,后续通过 SHA1 调用,减少带宽。
  2. 避免长时间运行:Lua 脚本会阻塞 Redis 主线程,务必保持轻量(< 几毫秒)。
  3. 参数化设计:通过 KEYSARGV 传参,不要硬编码。
  4. 错误返回规范:统一返回 0/1 或 JSON 结构,便于客户端处理。
  5. 单元测试:可使用 redis-cli --eval 本地调试脚本。

六、结语:别再被“事务”二字误导

Redis 的 MULTI/EXEC 更像是一个“命令批处理”机制,而非真正的事务。在需要强一致性条件性操作的场景下,它往往力不从心。

Lua 脚本,凭借其在 Redis 服务端的原子执行能力,成为实现高可靠业务逻辑的首选方案。无论是库存扣减、积分变更、限流计数,还是分布式锁的续期,Lua 都能以简洁、高效、安全的方式完成任务。

🔑 记住:在 Redis 中,真正的“原子性”只存在于单个命令或 Lua 脚本中。

如果你的系统依赖 Redis 事务来保证数据一致性,现在是时候重新审视并迁移到 Lua 脚本了——这可能是你避免下一次线上事故的关键一步。