REDIS · MULTI · EXEC · WATCH · EVAL · EVALSHA · FUNCTION · FCALL

几条命令连着做,中间谁也插不进来,但做坏了不会撤销

想让“查库存、扣库存”这几步一起完成,Redis 有两个办法:事务是先把命令排好队,再一口气执行;Lua 脚本是把一小段程序送进 Redis,在里面一口气跑完。两者都能保证执行期间不被别的命令插队,也都没有回滚。

事务MULTI / EXEC

MULTI OKDECR stock:888 QUEUEDRPUSH orders:888 u1 QUEUEDEXEC 一起执行

像先把商品放进购物车,最后一起结账。排队期间什么都不执行,所以没法“先看库存再决定扣不扣”。

Lua 脚本EVAL / FCALL

整段代码送进 Redis,在里面跑完
local s = tonumber(redis.call('GET', KEYS[1]) or '0')
if s <= 0 then return 0 end
redis.call('DECR', KEYS[1])
return 1

可以读、判断、再写,一次网络来回。跑的时候其他命令都在外面等着。

都不回滚中间某条命令失败了,前面已经执行的不会撤销。Redis 只保证“一起执行、不被插队”,不保证“要么全成功要么全没做”。
购物车MULTI · EXEC · DISCARD

命令先排队,EXEC 时一起执行,DISCARD 就全扔掉。

排队时拿不到结果
盯梢WATCH

EXEC 前这些 key 被别人改了,整个事务作废。

乐观锁,冲突就重试
脚本EVAL · EVALSHA

能读能判断能写,整段原子执行。

别写太久,会堵住所有人
函数库FUNCTION · FCALL

Redis 7 的新写法,脚本存进 Redis 本身,重启和主从都还在。

7.0 起

事务:先排队,再一起执行

MULTI 之后发的命令,Redis 只回一句 QUEUED,放进这个连接的队列里。EXEC 时按顺序一条条执行,执行期间不会插入其他客户端的命令。

WATCH key…(可选)先盯上要用的 key必须在 MULTI 之前
MULTI开始排队回 OK
命令 1、命令 2 …每条都回 QUEUED此时还没执行,读命令也拿不到值
EXEC / DISCARD一起执行,或全部扔掉盯的 key 被改过,EXEC 返回 nil,什么都不做
Redis 事务的 ACID,打几分
A原子性:一半能保证一起执行、不被插队;不能保证出错时全部撤销。
C一致性:有命令要么正常执行,要么报错,不会写出坏数据结构。
I隔离性:最强单线程执行,事务之间天然串行。
D持久性:看配置取决于 RDB / AOF;AOF everysec 断电最多丢 1 秒(Redis 第 02 篇)。

出错了会怎样

同样三步:设置 a、把字符串 name 加 1(会失败)、设置 b。分别用事务和 Lua 来做,看最后 a 和 b 有没有写进去。

错误类型:
Lua 里用:
事务
Lua 脚本

库存只剩 1 件,A 和 B 同时下单

四种写法,同样的并发时序,一步步看 Redis 里的库存最后是多少、卖出去几单。

Lua 脚本:怎么写、怎么调

key 放在 KEYS 里,其他参数放在 ARGV 里,下标从 1 开始。脚本整段执行,期间不会插入任何别的命令。

扣库存的脚本deduct.lua
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
调用redis-cli
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:只传 40 个字符的摘要
EVALSHA sha …先试摘要→ 缓存里有直接执行 或 NOSCRIPTRedis 重启过或换了节点→ EVAL 全文执行并顺便缓存

脚本缓存只在内存里,重启或主从切换后可能没了。Spring 的 RedisTemplate 执行脚本时就是这么做的:先 EVALSHA,收到 NOSCRIPT 再 EVAL。

要注意说明
redis.call 和 redis.pcallcall 遇错直接中断脚本并把错误返回给客户端;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、复制到从节点,重启后还在,调用时用名字。

stock.lua一个库里可以注册多个函数
#!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
redis-cli FUNCTION LOAD "$(cat stock.lua)"
→ "stock"
FCALL deduct 1 stock:888 1        # 和 EVAL 一样:函数名、key 个数、keys、args
FUNCTION LIST                     # 看已加载的库
FCALL_RO …                        # 声明了 no-writes 的函数可以在从节点上调
PipelineMULTI / EXECWATCH + MULTIEVAL 脚本Function
不被插队不保证保证保证保证保证
能先读再决定不能不能在 MULTI 前读,冲突就重来能能
出错回滚都不回滚
网络来回一次每条命令一次(可配合 Pipeline)好几次,冲突还要重试一次一次
重启后还在———脚本缓存会丢,要能重新 EVAL在
集群客户端按节点拆所有 key 要在同一个槽

Java 里怎么用

事务要保证几条命令走同一个连接;脚本交给 RedisScript,Spring 会帮你处理 EVALSHA 和 NOSCRIPT。

执行 Lua推荐
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;
}
WATCH + MULTI必须放进 SessionCallback
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();     // 被别人改过就不执行,要按结果判断后重试
    }
});
JDK 8 和 17用法一样,Boot 2 和 Boot 3 只是配置前缀不同。Spring Data Redis 目前没有专门的 Functions API,要调 FCALL 可以用 connection.execute("FCALL", …) 发原始命令。另外,RedisTemplate 开了 setEnableTransactionSupport(true) 后,在 @Transactional 方法里的 Redis 命令会排进 MULTI,读命令全部返回 null。

常踩的坑

大部分坑来自把 Redis 事务当成数据库事务。

以为事务失败会回滚

运行时出错的那条失败,其余照样执行。需要“要么全做要么全不做”,先在脚本里检查好所有条件,再开始写。

在 MULTI 里 GET,拿到的是 QUEUED

排队期间不执行。要根据读到的值做判断,在 MULTI 之前读并 WATCH,或者直接用 Lua。

ERR EXEC without MULTI

直接用 RedisTemplate 调 multi()、exec(),两次调用可能拿到不同的连接。放进 SessionCallback 里执行。

@Transactional 方法里,Redis 读出来全是 null

开了 enableTransactionSupport,命令都被排进了 MULTI,提交时才执行。读操作放到事务外,或者用另一个没开事务支持的 RedisTemplate。

秒杀时 WATCH 大量失败,重试打满 Redis

冲突越多,重试越多。热点场景改用 Lua,一次判断加扣减,没有重试。

CROSSSLOT 或者脚本在集群里改错了节点

脚本里用到的 key 没放进 KEYS,或者放了但不在同一个槽。所有 key 都从 KEYS 传进去,并用相同的 hash tag。

脚本返回的金额少了小数部分

Lua 数字转成 Redis 整数会截断。返回 tostring(x),Java 那边按字符串解析。

脚本缓存越来越大

每次把参数拼进脚本文本里,每段都不一样,全被缓存下来。脚本写死,变化的部分走 ARGV。

BUSY Redis is busy running a script

脚本里循环处理了大量 key。脚本要短小,大批量的活拆成多次调用。

主从切换后,EVALSHA 报 NOSCRIPT

新主节点的脚本缓存里没有它。客户端要能退回 EVAL(Spring 会自动做);Redis 7 可以改用 Functions。