REDIS · MULTI · EXEC · WATCH · EVAL · EVALSHA · FUNCTION · FCALL
几条命令连着做,中间谁也插不进来,但做坏了不会撤销
想让“查库存、扣库存”这几步一起完成,Redis 有两个办法:事务是先把命令排好队,再一口气执行;Lua 脚本是把一小段程序送进 Redis,在里面一口气跑完。两者都能保证执行期间不被别的命令插队,也都没有回滚。
事务MULTI / EXEC
像先把商品放进购物车,最后一起结账。排队期间什么都不执行,所以没法“先看库存再决定扣不扣”。
Lua 脚本EVAL / FCALL
local s = tonumber(redis.call('GET', KEYS[1]) or '0')
if s <= 0 then return 0 end
redis.call('DECR', KEYS[1])
return 1可以读、判断、再写,一次网络来回。跑的时候其他命令都在外面等着。
MULTI · EXEC · DISCARD命令先排队,EXEC 时一起执行,DISCARD 就全扔掉。
排队时拿不到结果WATCHEXEC 前这些 key 被别人改了,整个事务作废。
乐观锁,冲突就重试EVAL · EVALSHA能读能判断能写,整段原子执行。
别写太久,会堵住所有人FUNCTION · FCALLRedis 7 的新写法,脚本存进 Redis 本身,重启和主从都还在。
7.0 起事务:先排队,再一起执行
MULTI 之后发的命令,Redis 只回一句 QUEUED,放进这个连接的队列里。EXEC 时按顺序一条条执行,执行期间不会插入其他客户端的命令。
出错了会怎样
同样三步:设置 a、把字符串 name 加 1(会失败)、设置 b。分别用事务和 Lua 来做,看最后 a 和 b 有没有写进去。
库存只剩 1 件,A 和 B 同时下单
四种写法,同样的并发时序,一步步看 Redis 里的库存最后是多少、卖出去几单。
Lua 脚本:怎么写、怎么调
key 放在 KEYS 里,其他参数放在 ARGV 里,下标从 1 开始。脚本整段执行,期间不会插入任何别的命令。
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
local n = tonumber(ARGV[1])
if stock < n then
return 0 -- 库存不够
end
redis.call('DECRBY', KEYS[1], n)
redis.call('SADD', KEYS[2], ARGV[2]) -- 记下谁买了
return 1
EVAL "<脚本内容>" 2 stock:{888} buyers:{888} 1 user42
# 脚本 key 个数 KEYS[1] KEYS[2] ARGV[1] ARGV[2]
SCRIPT LOAD "<脚本内容>" # 存进脚本缓存,返回 SHA1
→ "8f7c…e21a"
EVALSHA 8f7c…e21a 2 stock:{888} buyers:{888} 1 user42
# 集群下两个 key 必须在同一个槽,所以都带 {888}
EVALSHA sha …先试摘要→
缓存里有直接执行
或
NOSCRIPTRedis 重启过或换了节点→
EVAL 全文执行并顺便缓存
脚本缓存只在内存里,重启或主从切换后可能没了。Spring 的 RedisTemplate 执行脚本时就是这么做的:先 EVALSHA,收到 NOSCRIPT 再 EVAL。
| 要注意 | 说明 |
|---|---|
| redis.call 和 redis.pcall | call 遇错直接中断脚本并把错误返回给客户端;pcall 把错误当成一个值返回,脚本继续往下跑 |
| 返回小数会被截断 | Lua 的数字转成 Redis 整数:return 3.99 得到 3。要返回小数,用 tostring(x) |
| 返回数组遇到 nil 就截断 | return {1, nil, 3} 只返回 1。用 false 占位 |
| 沙箱 | 不能用全局变量,没有 os、io;可以用 cjson、cmsgpack、bit、struct 这几个库 |
| 执行太久 | 超过 busy-reply-threshold(默认 5 秒,以前叫 lua-time-limit),别的命令都收到 BUSY;没写过数据可以 SCRIPT KILL,写过只能 SHUTDOWN NOSAVE(Redis 第 07 篇) |
| 主从复制 | 5.0 起默认只把脚本里真正执行的写命令复制给从节点,7.0 起只剩这一种方式,所以脚本里用随机数、时间也没问题 |
Redis 7:Functions,把脚本变成 Redis 自己的命令
EVAL 的脚本只在缓存里,属于“客户端带过来的代码”。Functions 是一个函数库,加载后跟数据一样写进 RDB / AOF、复制到从节点,重启后还在,调用时用名字。
#!lua name=stock
redis.register_function('deduct', function(keys, args)
local s = tonumber(redis.call('GET', keys[1]) or '0')
local n = tonumber(args[1])
if s < n then return 0 end
redis.call('DECRBY', keys[1], n)
return 1
end)
redis-cli FUNCTION LOAD "$(cat stock.lua)"
→ "stock"
FCALL deduct 1 stock:888 1 # 和 EVAL 一样:函数名、key 个数、keys、args
FUNCTION LIST # 看已加载的库
FCALL_RO … # 声明了 no-writes 的函数可以在从节点上调
| Pipeline | MULTI / EXEC | WATCH + MULTI | EVAL 脚本 | Function | |
|---|---|---|---|---|---|
| 不被插队 | 不保证 | 保证 | 保证 | 保证 | 保证 |
| 能先读再决定 | 不能 | 不能 | 在 MULTI 前读,冲突就重来 | 能 | 能 |
| 出错回滚 | 都不回滚 | ||||
| 网络来回 | 一次 | 每条命令一次(可配合 Pipeline) | 好几次,冲突还要重试 | 一次 | 一次 |
| 重启后还在 | — | — | — | 脚本缓存会丢,要能重新 EVAL | 在 |
| 集群 | 客户端按节点拆 | 所有 key 要在同一个槽 | |||
Java 里怎么用
事务要保证几条命令走同一个连接;脚本交给 RedisScript,Spring 会帮你处理 EVALSHA 和 NOSCRIPT。
private static final RedisScript<Long> DEDUCT =
RedisScript.of(new ClassPathResource("lua/deduct.lua"), Long.class);
boolean buy(long sku, String user) {
Long ok = redis.execute(DEDUCT,
List.of("stock:{" + sku + "}", "buyers:{" + sku + "}"),
"1", user);
return ok != null && ok == 1;
}
List<Object> res = redis.execute(new SessionCallback<>() {
public List<Object> execute(RedisOperations ops) {
ops.watch("stock:888");
int s = Integer.parseInt((String) ops.opsForValue().get("stock:888"));
if (s <= 0) { ops.unwatch(); return null; }
ops.multi();
ops.opsForValue().decrement("stock:888");
return ops.exec(); // 被别人改过就不执行,要按结果判断后重试
}
});
connection.execute("FCALL", …) 发原始命令。另外,RedisTemplate 开了 setEnableTransactionSupport(true) 后,在 @Transactional 方法里的 Redis 命令会排进 MULTI,读命令全部返回 null。常踩的坑
大部分坑来自把 Redis 事务当成数据库事务。
运行时出错的那条失败,其余照样执行。需要“要么全做要么全不做”,先在脚本里检查好所有条件,再开始写。
排队期间不执行。要根据读到的值做判断,在 MULTI 之前读并 WATCH,或者直接用 Lua。
直接用 RedisTemplate 调 multi()、exec(),两次调用可能拿到不同的连接。放进 SessionCallback 里执行。
开了 enableTransactionSupport,命令都被排进了 MULTI,提交时才执行。读操作放到事务外,或者用另一个没开事务支持的 RedisTemplate。
冲突越多,重试越多。热点场景改用 Lua,一次判断加扣减,没有重试。
脚本里用到的 key 没放进 KEYS,或者放了但不在同一个槽。所有 key 都从 KEYS 传进去,并用相同的 hash tag。
Lua 数字转成 Redis 整数会截断。返回 tostring(x),Java 那边按字符串解析。
每次把参数拼进脚本文本里,每段都不一样,全被缓存下来。脚本写死,变化的部分走 ARGV。
脚本里循环处理了大量 key。脚本要短小,大批量的活拆成多次调用。
新主节点的脚本缓存里没有它。客户端要能退回 EVAL(Spring 会自动做);Redis 7 可以改用 Functions。