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** 命令实现乐观锁。

工作流程:

  1. WATCH balance:告诉 Redis,“帮我盯着 balance 这个键,在我 EXEC 之前,只要它被别的客户端改了,就通知我。”
  2. GET balance:读取当前余额,比如是 1000。
  3. 在客户端计算新余额,比如扣减 100,得到 900。
  4. MULTI
  5. SET balance 900
  6. EXEC 第一步是声明事务任务,这个命令是执行事务任务。

**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 实践

  1. 进入redis-cli
  2. 准备一个key SET stock 5
  3. 执行一个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/EXECLua 脚本(EVAL
本质命令打包执行在 Redis 内部运行的小程序
执行方式多条命令排队执行一次执行整段脚本
原子性仅执行阶段原子整体原子
是否支持逻辑判断❌ 不支持✅ 可以 if/else、循环
是否能回滚❌ 不支持✅ 不需要(一次性执行完)
性能一次网络多次命令一次网络一次命令
实际使用场景简单命令批量执行复杂逻辑原子化(如库存扣减、分布式锁)

看得出来事务没有 lua 脚本强大,lua 脚本 真正让“业务逻辑 + 原子性”同时成立 。

lua 脚本在 redis 中之所有有原子特性,多亏了redis是单线程执行,否则 lua 脚本也仅仅是一个轻量级的命令执行工具。

lua 脚本实战场景

分布式锁

场景:多台服务同时访问同一个资源,需要保证“同一时间只有一个服务能操作资源”。

实现方式

  1. 加锁:
plain
复制代码
SET lock_key uuid NX PX 30000

(NX = 仅当不存在才设置,PX = 设置过期时间)

  1. 解锁: 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 死锁,在分布式锁的设计上,其它客户端也是不能帮忙解锁的。

你可以设置锁的过期时间,解决死锁,但本质上要从逻辑上解决。

实现限流 / 计数器

场景:每秒或每分钟限制请求次数。

痛点问题

  1. 并发高时,直接用客户端多条命令实现会有竞态问题:
plain
复制代码
GET counter -> 判断 -> INCR

可能导致两个请求同时判断 counter 还没到阈值 → 都执行 INCR → 超过限制。

  1. 多条命令分开执行,无法保证原子性。
  2. 客户端网络往返多,增加延迟。

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 原子执行;
    • 不会出现两个请求同时扣减导致超卖。
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP