07.1 - 事务与 Lua 脚本
定位: Redis 的服务端原子操作——事务的隔离性与 Lua 脚本的原子性 面试高频度: ⭐⭐⭐⭐ 考查方式: MULTI/EXEC 事务特点、WATCH 乐观锁、Lua 脚本优势
一、事务(MULTI/EXEC)
Redis 事务的特点:
- 批量操作被放到一个队列中
- EXEC 时一起执行
- 不保证原子性(部分成功部分失败)
- 无回滚(没有 UNDO)
- 不支持隔离级别(串行执行)
与关系型数据库事务的对比:
| 维度 | 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 的配合
- ← 返回 **进阶特性索引**