Skip to content

07.1 - 事务与 Lua 脚本

定位: Redis 的服务端原子操作——事务的隔离性与 Lua 脚本的原子性 面试高频度: ⭐⭐⭐⭐ 考查方式: MULTI/EXEC 事务特点、WATCH 乐观锁、Lua 脚本优势


一、事务(MULTI/EXEC)

Redis 事务的特点

  1. 批量操作被放到一个队列中
  2. EXEC 时一起执行
  3. 不保证原子性(部分成功部分失败)
  4. 无回滚(没有 UNDO)
  5. 不支持隔离级别(串行执行)

与关系型数据库事务的对比

维度Redis 事务MySQL 事务
原子性不保证(语法错全部取消,运行错部分成功)保证(全部成功或全部回滚)
一致性保证保证
隔离性串行(天然最高隔离)MVCC(可配置各种级别)
持久性AOF 配置相关REDO 日志

事务的基本使用

bash
redis-cli

127.0.0.1:6379> MULTI    # 开启事务
OK
127.0.0.1:6379> SET a 1
QUEUED              # 队列中
127.0.0.1:6379> INCR b
QUEUED
127.0.0.1:6379> GET a
QUEUED
127.0.0.1:6379> EXEC    # 执行
1) OK                   # SET a 1
2) (integer) 1          # INCR b
3) "1"                  # GET a

# 取消事务
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET a 2
QUEUED
127.0.0.1:6379> DISCARD  # 取消
OK

事务的错误处理

两种错误情况:

情况 1:语法错误(命令写错了)→ EXEC 时全部不执行
  MULTI
  SET a 1
  JUNKCMD xyz       # 语法错误
  EXEC
  → (error) EXECABORT Transaction discarded.  ← 所有都不执行

情况 2:运行时错误(如对 string 用 LPUSH)→ 其他正确命令仍执行!
  MULTI
  SET a "hello"     # OK
  LPUSH a xyz       # 运行时错误:对 string 用 LPUSH
  SET b 2           # OK
  EXEC
  → 1) OK
    2) WRONGTYPE Operation against a key holding the wrong kind of value
    3) OK           # ← SET b 2 执行成功!

WATCH 乐观锁

MULTI 不支持条件执行。WATCH 提供了一种"检查后执行"的机制:


二、Lua 脚本

为什么需要 Lua?

Lua 脚本比事务更强

事务的局限

  • ❌ 不能根据 GET 结果决定 SET(没有条件逻辑)
  • ❌ 运行时错误不阻止后续命令执行
  • ❌ 与 Pipeline 混合使用复杂

Lua 脚本的优势

  • ✓ 原子执行——整个脚本一次性执行,中间不会被中断
  • ✓ 条件逻辑——if/else/for 都能写
  • ✓ 一个脚本 = 一次网络往返(内置 Pipeline 效果)
  • ✓ 可复用——SCRIPT LOAD + EVALSHA

Lua 脚本使用

bash
# EVAL 执行脚本
redis-cli EVAL "return redis.call('SET', KEYS[1], ARGV[1])" 1 mykey "hello"
# OK

redis-cli EVAL "return redis.call('GET', KEYS[1])" 1 mykey
# "hello"

# 简单参数
redis-cli EVAL "return {KEYS[1], KEYS[2], ARGV[1], ARGV[2]}" 2 k1 k2 a1 a2
# 1) "k1"
# 2) "k2"
# 3) "a1"
# 4) "a2"

实用脚本示例

lua
-- 1. 分布式锁解锁
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

-- 2. 限流(滑动窗口)
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])

redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
    redis.call('ZADD', key, now, now .. '_' .. math.random())
    redis.call('EXPIRE', key, window)
    return 1
else
    return 0
end

-- 3. 原子更新并检查
local key = KEYS[1]
local max = tonumber(ARGV[1])
local current = redis.call('GET', key) or 0
if tonumber(current) >= max then
    return 0  -- 已达上限
end
redis.call('INCR', key)
return 1  -- 成功

预加载优化

bash
# 1. SCRIPT LOAD 预加载脚本
redis-cli SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# "4e6d3c4d..."  ← 返回 SHA 校验和

# 2. EVALSHA 直接调用(省掉传输脚本的带宽)
redis-cli EVALSHA "4e6d3c4d..." 1 mykey
# "hello"

# 3. 查看缓存中的脚本
redis-cli SCRIPT EXISTS "4e6d3c4d..."
# 1) (integer) 1

# 4. 清空脚本缓存
redis-cli SCRIPT FLUSH

三、面试视角

追问答案要点
Redis 事务是原子性的吗?不完全是。语法错误导致全部取消(原子);运行时错误不影响其他命令执行(非原子)
WATCH 怎么实现乐观锁?WATCH 监控 key,EXEC 时检查 key 是否被修改,被改则取消事务(返回 nil)
Lua 脚本和事务的区别?Lua 脚本:原子执行(不会被其他命令打断)、有条件逻辑、减网络往返
Lua 脚本的注意事项?保持简短(不要长时间执行)、不要用随机函数(影响主从复制)、用 KEYS 和 ARGV 传参
什么时候用 Lua 而不是 Pipeline?需要条件判断、需要原子性保证、需要将多个操作合并为一步时用 Lua
EVAL 和 EVALSHA 的区别?EVAL 每次传脚本;EVALSHA 用 SHA 调用预加载脚本,省带宽

📚 相关链接

  • **分布式锁** — 用 Lua 保证解锁原子性
  • **限流** — 用 Lua 实现滑动窗口限流
  • **Pipeline** — Pipeline 与 Lua 的配合
  • ← 返回 **进阶特性索引**

Knowledge4J — Java 知识库