Redis进阶 - 事务与 lua 脚本
本文讲解 redis 事务和 lua 脚本, Redis 事务不是完全 ACID,它只保证 :
- 命令队列原子执行
- 事务开始到提交期间命令排队,不立即执行
lua 脚本补充 redis 事务的短板。
redis事务介绍
这里我跟mysql的事务对比着学,比较好理解。
首先了解以下 redis 事务的命令:
| 命令 | 功能 |
|---|---|
MULTI | 开始事务,后续命令进入事务队列 |
EXEC | 提交事务,一次执行队列中的所有命令 |
DISCARD | 放弃事务,清空队列 |
WATCH <key> | 监控 key,如果 key 被修改,事务执行前取消执行 |
用法:
▼bash复制代码# 开始事务 MULTI # 扣钱 DECRBY account:1 100 # 加钱 INCRBY account:2 100 # 提交事务 EXEC
事务的坑
redis事务不像 mysql 具有回滚,所以执行一个多步骤的任务,前半部分执行成功,那就是成功,后半部分失败也不会回滚。这算是redis的大坑,这不是标准的事务,没满足ACID特性。
▼bash复制代码MULTI SET name "redis" -- 成功 LPUSH mylist "item" -- 成功 INCR name -- 糟糕!对字符串用INCR是运行时错误 SADD myset "value" -- 这条会继续执行! EXEC
WATCH 乐观锁
既然没有回滚,那在高并发下怎么安全地做“先读取后写入”的操作呢?比如经典的“余额扣减”问题。
答:用 **WATCH** 命令实现乐观锁。
工作流程:
WATCH balance:告诉 Redis,“帮我盯着balance这个键,在我EXEC之前,只要它被别的客户端改了,就通知我。”GET balance:读取当前余额,比如是 1000。- 在客户端计算新余额,比如扣减 100,得到 900。
MULTISET balance 900EXEC第一步是声明事务任务,这个命令是执行事务任务。
在 **EXEC** 时,Redis 会检查:
- 如果
**WATCH**之后,**balance**没被改过:正常执行SET命令,扣减成功。 - 如果
**WATCH**之后,**balance**被别的客户端改了:整个事务作废!EXEC返回nil。你的客户端收到nil后,就应该自己重新走一遍这个流程(从WATCH开始)。
为什么redis不支持事务回滚?
简单说,redis认为这应该在开发环境解决的,正式生产环境是不会有需要用到回滚的场景,如果有那就是你代码的问题,因为 redis 已经是单线程了,理论上你不应该搞出来 mysql 在多线程下,其它线程也可能同步修改数据出现的报错回滚,所以为了追求极致的性能,从设计哲学来看,不设计回滚是合理的。
lua 脚本
Lua 是一种轻量级的嵌入式脚本语言,被广泛集成在各种中间件中,用于在运行时灵活地扩展逻辑。
Redis 内置了一个 Lua 解释器(基于 lua.c),这使得我们可以在 Redis 内部直接执行 Lua 脚本,从而在服务器端实现复杂的业务逻辑,而无需多次往返客户端。
Redis 会将整段 Lua 脚本视为一条命令执行, 整个 Lua 脚本的执行过程不可被中断,具有原子性。
实践一下:
在redis中,命令以 EVAL 开头,就会被redis的 Lua 解释器 执行这条命令,所以我们用 redis-cli 实践
- 进入redis-cli
- 准备一个key
SET stock 5 - 执行一个lua脚本 ,含义: stock -1
▼java复制代码EVAL "local s=redis.call('GET','stock'); if tonumber(s)>0 then redis.call('DECR','stock'); return 'OK' else return 'NO' end" 0
执行效果:
▼java复制代码127.0.0.1:6379> Set stock 5 OK 127.0.0.1:6379> EVAL "local s=redis.call('GET','stock'); if tonumber(s)>0 then redis.call('DECR','stock'); return 'OK' else return 'NO' end" 0 "OK" 127.0.0.1:6379> get stock "4"
在并发场景下,有多个客户端同时执行 这段lua脚本,不会导致mysql那样的脏读,因为lua脚本执行过程中不会中断去执行另一个lua脚本。
lua 脚本和事务对比
lua脚本和事务很相似,都具有原子特性,我们来进行对比:
| 对比项 | Redis 事务(MULTI/EXEC) | Lua 脚本(EVAL) |
|---|---|---|
| 本质 | 命令打包执行 | 在 Redis 内部运行的小程序 |
| 执行方式 | 多条命令排队执行 | 一次执行整段脚本 |
| 原子性 | 仅执行阶段原子 | 整体原子 |
| 是否支持逻辑判断 | ❌ 不支持 | ✅ 可以 if/else、循环 |
| 是否能回滚 | ❌ 不支持 | ✅ 不需要(一次性执行完) |
| 性能 | 一次网络多次命令 | 一次网络一次命令 |
| 实际使用场景 | 简单命令批量执行 | 复杂逻辑原子化(如库存扣减、分布式锁) |
看得出来事务没有 lua 脚本强大,lua 脚本 真正让“业务逻辑 + 原子性”同时成立 。
lua 脚本在 redis 中之所有有原子特性,多亏了redis是单线程执行,否则 lua 脚本也仅仅是一个轻量级的命令执行工具。
lua 脚本实战场景
分布式锁
场景:多台服务同时访问同一个资源,需要保证“同一时间只有一个服务能操作资源”。
实现方式:
- 加锁:
▼plain复制代码SET lock_key uuid NX PX 30000
(NX = 仅当不存在才设置,PX = 设置过期时间)
- 解锁: Lua 脚本保证“只删除自己加的锁”:
▼lua复制代码if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end
说明: 分布式锁一般使用 Reddison 客户端实现,Reddison 客户端内置了类似于上面的lua脚本,当调用相应命令会发送 lua 脚本给redis执行。
在客户端加锁时,我们会使用唯一键来加锁,比如uuid, 其它客户端是拿不到这个唯一键的,所以无法解锁,这保证了分布式锁的有效性和锁的归属。
▼java复制代码// 节点A尝试加锁 String lockKey = "myLock"; String uuid = UUID.randomUUID().toString(); // 每个节点/线程生成唯一ID String result = redis.set(lockKey, uuid, "NX", "PX", 30000); // NX + PX if ("OK".equals(result)) { // 成功获得锁 }
就算客户端 A 死锁,在分布式锁的设计上,其它客户端也是不能帮忙解锁的。
你可以设置锁的过期时间,解决死锁,但本质上要从逻辑上解决。
实现限流 / 计数器
场景:每秒或每分钟限制请求次数。
痛点问题:
- 并发高时,直接用客户端多条命令实现会有竞态问题:
▼plain复制代码GET counter -> 判断 -> INCR
可能导致两个请求同时判断 counter 还没到阈值 → 都执行 INCR → 超过限制。
- 多条命令分开执行,无法保证原子性。
- 客户端网络往返多,增加延迟。
lua 解决示例(固定窗口计数器):
- 计数器自增和计数器阈值判断原子执行,确保不会超过流量阈值
- 首次拿到限流器,会给限流器设置过期时间,到期了就会清空计时器,这便是固定窗口的思路。
▼lua复制代码-- 原子执行:计数器 key 自增 1 -- 如果 key 不存在,INCR 会先创建 key 并赋值为 1 local current = redis.call('INCR', KEYS[1]) -- 当计数器第一次被创建时(current == 1),设置 key 的过期时间 -- 过期时间 = 时间窗口长度(例如 60 秒) -- 这样保证计数器在窗口结束后自动归零 if tonumber(current) == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end -- 判断当前计数是否超过阈值(ARGV[2] = 最大请求数) -- 超过阈值返回 0 → 拒绝请求 if tonumber(current) > tonumber(ARGV[2]) then return 0 end -- 当前计数未超过阈值 → 允许请求 return 1
秒杀/抢购场景(库存扣减)
场景:高并发下保证库存不会超卖。
痛点:
普通客户端 GET + DECR(计数器+1) 组合
▼plain复制代码GET stock if stock > 0: DECR stock
- 在高并发下,两个请求可能同时读取到 stock=1,然后都执行 DECR → 超卖。
- 因为 Redis 的操作在客户端是分两步走的,可能接收了一条客户端A的get命令,然后接收了两条客户端B的命令,这样客户端A的计数不准确,无法保证原子性。
解决方法
- 使用 lua 脚本,客户端将多条命令打包,一起发给redis原子执行lua脚本
▼lua复制代码local stock = tonumber(redis.call('GET', KEYS[1])) if stock > 0 then redis.call('DECR', KEYS[1]) return "success" else return "fail" end
-
优势:
-
- GET + DECR 原子执行;
- 不会出现两个请求同时扣减导致超卖。
