Learn
Redis/12-transactions-lua

事务与 Lua 脚本

"先查库存再扣减"这类多步操作,在并发下必然出现竞态。Redis 给出两种原子化手段:事务(MULTI/EXEC)保证命令打包串行执行,Lua 脚本更进一步——把"读取、判断、写入"整段逻辑放到服务端原子执行。实践中 Lua 几乎总是更好的答案,但你需要先理解事务的语义与局限。

1. 事务:MULTI / EXEC

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET order:1 "created"
QUEUED
127.0.0.1:6379(TX)> INCR order:count
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (integer) 1

MULTI 之后的命令不立即执行,而是进入队列;EXEC 时整个队列一次性、无间隔地串行执行——期间不会插入其他客户端的命令。DISCARD 放弃队列。

ℹ️本章的事务示例为什么没有在线运行

MULTI/EXEC 与 WATCH 都绑定在同一条连接上:命令要排进同一个连接的队列才有意义。本页沙箱每行命令各自建连执行,跑不出事务语义,所以事务部分保留静态示例,请在本机 redis-cli 里演练;而 Lua 脚本一条命令就是一个原子单元,下面的示例都可以直接运行。

1.1 两种错误,两种结局

入队时错误(语法错误、不存在的命令):整个事务被拒绝执行。

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SETT k v
(error) ERR unknown command 'SETT'
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

执行时错误(类型不匹配等):出错的命令失败,其余命令照常执行,不回滚。

127.0.0.1:6379> SET str "hello"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> LPUSH str "x"       # 对 string 用 list 命令
QUEUED
127.0.0.1:6379(TX)> SET after "done"
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) OK                   # 第二条成功了! 没有回滚

1.2 为什么 Redis 事务不是 ACID

特性Redis 事务的情况
原子性 A不完整:执行期错误不回滚,部分成功部分失败
一致性 C依赖使用者:没有约束检查与回滚兜底
隔离性 I满足:单线程串行,天然可串行化
持久性 D取决于持久化配置:everysec 下仍可能丢

官方的理由:回滚只能挽救"程序写错了类型"这类编程错误,对正确的程序没有价值,而支持回滚会显著增加复杂度、拖慢速度。Redis 事务的本质是"打包 + 串行",不是数据库事务。

1.3 WATCH:乐观锁

事务里的命令在 EXEC 前不执行,所以不能根据事务中间的读取结果做判断。想实现"检查再更新"(check-and-set),用 WATCH:

127.0.0.1:6379> WATCH stock:sku:2001
OK
127.0.0.1:6379> GET stock:sku:2001
"5"                     # 客户端判断: 库存 5 > 0, 可以扣
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECR stock:sku:2001
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 4          # 成功: WATCH 到 EXEC 之间没人改过 stock
 
# 如果期间另一个客户端改了 stock:sku:2001:
127.0.0.1:6379(TX)> EXEC
(nil)                   # 事务被放弃, 需要重试整个流程

WATCH 的语义:被监视的 key 在 EXEC 之前被任何客户端修改,EXEC 返回 nil、事务不执行。这是乐观锁——不阻止别人写,只在提交时发现冲突就放弃重试。高并发下冲突率高时会大量重试,此时 Lua 是更好的选择。

2. Lua 脚本:服务端原子逻辑

EVAL 把一段 Lua 交给 Redis 执行,整个脚本是一个原子单元——执行期间不会有任何其他命令插入。读取、判断、分支、写入全在服务端一气呵成,没有竞态,也省了多次网络往返。

2.1 基本用法

EVAL 基本用法:KEYS 与 ARGV
EVAL "return 1 + 1" 0
# KEYS 数组传 key, ARGV 数组传参数; 中间的数字声明 key 个数
EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 greeting "hello world"
EVAL "return redis.call('GET', KEYS[1])" 1 greeting
# Lua 表会转换成多行数组回复
EVAL "return {KEYS[1], ARGV[1], ARGV[2]}" 1 mykey a b

为什么 key 必须走 KEYS 而不是拼进脚本?Cluster 需要根据 KEYS 判断脚本该路由到哪个节点;混在 ARGV 里会导致路由错误。

2.2 实战:原子扣库存

-- deduct.lua: 库存足够则扣减, 返回剩余; 不足返回 -1
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local need  = tonumber(ARGV[1])
if stock >= need then
  return redis.call('DECRBY', KEYS[1], need)
else
  return -1
end

把它压成一行就能直接 EVAL(Lua 不需要分号,语句之间空格分隔即可):

Lua 原子扣库存:绝不超卖
SET stock:sku:2001 5
# 库存足够: 扣 2, 返回剩余 3
EVAL "local stock = tonumber(redis.call('GET', KEYS[1]) or '0') local need = tonumber(ARGV[1]) if stock >= need then return redis.call('DECRBY', KEYS[1], need) else return -1 end" 1 stock:sku:2001 2
# 库存不足: 返回 -1, 一个都不扣
EVAL "local stock = tonumber(redis.call('GET', KEYS[1]) or '0') local need = tonumber(ARGV[1]) if stock >= need then return redis.call('DECRBY', KEYS[1], need) else return -1 end" 1 stock:sku:2001 9
GET stock:sku:2001

对比 WATCH 方案:无冲突重试、一次网络往返、逻辑集中在一处。这就是"检查再更新"类需求的标准解法。

2.3 EVALSHA:不用每次传脚本

脚本体每次随请求传输很浪费。先 SCRIPT LOAD 得到 SHA1,之后用 EVALSHA 引用:

SCRIPT LOAD 与 EVALSHA
SET greeting "hello"
# 返回值就是脚本内容的 SHA1, 同样的脚本在任何实例上算出的 SHA1 都相同
SCRIPT LOAD "return redis.call('GET', KEYS[1])"
EVALSHA d3c21d0c2b9ca22f82737626a27bcaf5d288f99f 1 greeting
# 1 = 该脚本已在服务端缓存中
SCRIPT EXISTS d3c21d0c2b9ca22f82737626a27bcaf5d288f99f

客户端库通常自动处理"EVALSHA 报 NOSCRIPT 就回退 EVAL"。注意脚本缓存重启即失效,且不主从复制——这是 Function 要解决的问题之一。

2.4 Lua 的规矩

  • 脚本执行期间独占主线程:超过 busy-reply-threshold(默认 5 秒)后其他请求收到 BUSY 错误,且只能 SCRIPT KILL(未执行过写命令时)或 SHUTDOWN NOSAVE。脚本必须短小,别在里面循环百万次;
  • 不要用 Lua 的 math.random(每个副本执行结果会不同破坏复制),用 redis.call('TIME') 或把随机值从客户端以 ARGV 传入;
  • redis.call 出错直接抛给客户端;redis.pcall 返回错误表,可在脚本内处理。

3. Redis 7 Function:脚本的工程化

EVAL 的痛点:脚本以 SHA 标识不可读、不持久化、无法组织成库。Redis 7 的 Function 把脚本当作服务端持久化的命名函数:

#!lua name=mylib
-- 注册一个函数: 带库存检查的扣减
local function deduct(keys, args)
  local stock = tonumber(redis.call('GET', keys[1]) or '0')
  local need  = tonumber(args[1])
  if stock >= need then
    return redis.call('DECRBY', keys[1], need)
  end
  return -1
end
redis.register_function('deduct_stock', deduct)
$ redis-cli -x FUNCTION LOAD < mylib.lua
"mylib"
127.0.0.1:6379> FCALL deduct_stock 1 stock:sku:2001 1
(integer) 2
127.0.0.1:6379> FUNCTION LIST
1) 1) "library_name"
   2) "mylib"
   ...

Function 随 RDB/AOF 持久化、随主从复制传播、有名字可管理——新项目的服务端逻辑建议直接用 Function。

⚠️事务 + Lua 的常见误区

1)以为 EXEC 失败会回滚——不会,执行期错误只影响那一条命令;2)在 pipeline 里混用 WATCH——WATCH 与连接绑定,连接池下必须确保 WATCH/MULTI/EXEC 在同一连接;3)Lua 里写死 key 名而不走 KEYS——单机能跑,上 Cluster 就路由错乱;4)用大循环脚本"批量删 key"——阻塞主线程,应该分批多次调用。

小结

  • MULTI/EXEC 是"打包串行":隔离性满足,但执行期错误不回滚,不是 ACID 事务
  • WATCH 提供乐观锁式 check-and-set,高冲突场景重试成本高
  • Lua 脚本是原子化复杂逻辑的首选:一次往返、无竞态;但必须短小、key 走 KEYS
  • EVALSHA 省带宽但缓存易失;Redis 7 Function 提供持久化、可命名的服务端函数
  • 下一章:发布订阅 →
🎯练习
  1. 复现两种事务错误:入队错误导致 EXECABORT,执行错误导致部分成功,体会"不回滚"。
  2. 开两个终端演练 WATCH:终端 A 监视并准备扣减,终端 B 在 A 执行 EXEC 前修改该 key,观察 A 的 EXEC 返回 nil。
  3. 写一个 Lua 脚本实现"限购":某用户对某商品的购买计数不超过 2,超过返回 0,否则计数加 1 并返回新值(提示:key 形如 buy:uid:sku,记得设 TTL)。