Redis 从入门到精通:事务与 Lua 脚本

IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。

在高并发环境下,多个客户端同时读写同一个键,很容易出现数据竞态------比如库存扣减成负数、账户余额被并发修改覆盖。Redis 虽然单线程执行命令,但多个命令之间仍然可能被其他客户端插队。

要保证一系列命令的原子性 (要么全执行,要么全不执行,中间不被打断),Redis 提供了两套武器:事务(MULTI/EXEC)Lua 脚本。前者适合简单批处理,后者则是原子性的终极方案。本文就从原理到实践,把这两把利器彻底讲透。

1. Redis 事务:MULTI/EXEC 机制

1.1 事务是什么?

Redis 的事务将多个命令打包,然后一次性地、顺序地执行。核心命令:

  • MULTI:开启事务

  • EXEC:执行事务中所有命令

  • DISCARD:取消事务

MULTIEXEC 之间的命令不会立即执行 ,而是被放入队列。当 EXEC 被调用时,Redis 顺序执行队列中的全部命令,期间不会插入其他客户端的命令

动手体验:

bash 复制代码
127.0.0.1:6379> SET balance 100
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECRBY balance 30
QUEUED
127.0.0.1:6379(TX)> INCRBY balance 50
QUEUED
127.0.0.1:6379(TX)> GET balance
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 70
2) (integer) 120
3) "120"

EXEC 返回一个数组,依次对应每个命令的执行结果。

1.2 事务的局限性(重要!)

Redis 事务和我们熟知的数据库事务(ACID)不同,有以下关键特性:

① 不支持回滚

如果事务中某条命令执行失败(比如对 String 执行 LPUSH),Redis 不会回滚,而是继续执行后续命令。

bash 复制代码
127.0.0.1:6379> SET key1 "hello"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key2 "world"
QUEUED
127.0.0.1:6379(TX)> LPUSH key1 "x"  # key1 是 String,LPUSH 会报错
QUEUED
127.0.0.1:6379(TX)> SET key3 "foo"
QUEUED
127.0.0.1:6379(TX)> EXEC
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
3) OK

key2key3 设置成功,LPUSH 报错但不影响其他命令。

② 编译时错误才会中止

如果命令本身写错了(如拼写错误),在 MULTI 阶段就会失败,此时 EXEC 会返回错误,事务不会执行。

bash 复制代码
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET key4 "a"
QUEUED
127.0.0.1:6379(TX)> WRONGCOMMAND   # 命令不存在
(error) ERR unknown command 'WRONGCOMMAND'
127.0.0.1:6379(TX)> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

③ 无法读取中间结果

事务中的命令不能依赖于之前命令的结果。比如你不能先 GET 一个值,然后用它来做 SET。因为 GET 的结果只有在 EXEC 后才知道。

bash 复制代码
# 这样是不行的:事务内的 GET 不会返回结果供后续命令使用
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> GET counter
QUEUED
127.0.0.1:6379(TX)> SET counter2 (GET counter + 1)  # 无法引用

这种需要根据当前值做判断的逻辑,必须用 Lua 脚本。

④ 无隔离级别概念

Redis 单线程执行命令,事务执行期间其他客户端命令不会插入,天然串行,不存在脏读、不可重复读等问题。

1.3 事务与 WATCH:乐观锁

WATCH 可以监听一个或多个键,如果在 EXEC 之前这些键被其他客户端修改了,事务就会被打断(EXEC 返回 nil)。

原理类似于乐观锁(CAS)。经典场景:并发安全扣减库存。

bash 复制代码
# 客户端 A
127.0.0.1:6379> SET stock 10
OK
127.0.0.1:6379> WATCH stock
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> DECRBY stock 1
QUEUED
127.0.0.1:6379(TX)> EXEC
1) (integer) 9   # 成功

# 如果在 MULTI 后、EXEC 前,另一个客户端修改了 stock,
# 则 EXEC 返回 (nil),需要重试。

WATCHEXEC(或 DISCARDUNWATCH)后自动取消。重试逻辑常出现在秒杀、抢红包场景。

2. Python 中的事务实战

redis-py 通过 Pipeline(transaction=True) 来封装 MULTI/EXEC

2.1 基础事务------转账

bash 复制代码
import redis

r = redis.Redis(decode_responses=True)

# 初始化
r.set('account:A', 100)
r.set('account:B', 200)

def transfer(from_acc, to_acc, amount):
    pipe = r.pipeline(transaction=True)
    pipe.decrby(from_acc, amount)
    pipe.incrby(to_acc, amount)
    result = pipe.execute()
    return result

print("转账前: A =", r.get('account:A'), ", B =", r.get('account:B'))
transfer('account:A', 'account:B', 50)
print("转账后: A =", r.get('account:A'), ", B =", r.get('account:B'))

输出:

bash 复制代码
转账前: A = 100 , B = 200
转账后: A = 50 , B = 250

2.2 带 WATCH 的乐观锁转账

bash 复制代码
def safe_transfer(from_acc, to_acc, amount):
    while True:
        r.watch(from_acc)
        balance = int(r.get(from_acc))
        if balance < amount:
            r.unwatch()
            raise ValueError('余额不足')
        
        pipe = r.pipeline(transaction=True)
        pipe.decrby(from_acc, amount)
        pipe.incrby(to_acc, amount)
        try:
            pipe.execute()
            break
        except redis.WatchError:
            print('并发冲突,重试...')
            continue

safe_transfer('account:A', 'account:B', 20)
print("最终: A =", r.get('account:A'), ", B =", r.get('account:B'))

如果模拟并发修改 from_accWatchError 会被触发,循环重试保证数据一致。

3. Lua 脚本:原子性的终极武器

由于事务不能做判断、不能读取中间结果,复杂业务逻辑(如库存不足则拒绝扣减)无法实现。Lua 脚本完美解决了这个问题。

Redis 内置了 Lua 5.1 解释器,你可以把一段 Lua 代码发送到服务端,Redis 会原子地执行它,期间其他命令全部阻塞。

3.1 EVAL 与 EVALSHA

  • EVAL script numkeys key [key ...] arg [arg ...]:直接发送脚本。

  • EVALSHA sha1 numkeys key [key ...] arg [arg ...]:通过脚本的 SHA1 哈希执行已缓存的脚本,避免重复传输脚本内容,节省带宽。

先用 redis-cli 体验:

bash 复制代码
127.0.0.1:6379> EVAL "return 'hello, ' .. KEYS[1] .. ' ' .. ARGV[1]" 1 world Lua
"hello, world Lua"
  • 1 表示有 1 个键名参数。

  • KEYS[1] 接收 worldARGV[1] 接收 Lua

3.2 Lua 脚本原子扣减库存(防超卖)

bash 复制代码
-- 原子库存扣减
local key = KEYS[1]
local delta = tonumber(ARGV[1])
local stock = redis.call('GET', key)
if stock == false then
    return -1   -- 键不存在
end
stock = tonumber(stock)
if stock < delta then
    return 0    -- 库存不足
end
redis.call('DECRBY', key, delta)
return 1        -- 成功

redis-cli 中执行:

bash 复制代码
127.0.0.1:6379> SET product:1001 10
OK
127.0.0.1:6379> EVAL "..." 1 product:1001 3
(integer) 1
127.0.0.1:6379> GET product:1001
"7"

4. Python 中使用 Lua 脚本

redis-py 提供了 register_script() 方法,自动完成 EVALEVALSHA 的转换,并缓存脚本。

4.1 基础调用

bash 复制代码
import redis

r = redis.Redis(decode_responses=True)

script = """
local name = redis.call('GET', KEYS[1])
local count = redis.call('INCR', KEYS[2])
return {name, count}
"""
r.set('username', 'IT策士')

multiply = r.register_script(script)
result = multiply(keys=['username', 'counter'], args=[])
print(result)  # ['IT策士', 1]

register_script 返回一个可调用对象,传入 keysargs 即可执行。

4.2 原子扣减库存函数

bash 复制代码
inventory_lua = """
local key = KEYS[1]
local delta = tonumber(ARGV[1])
local stock = redis.call('GET', key)
if stock == false then
    return -1
end
stock = tonumber(stock)
if stock < delta then
    return 0
end
redis.call('DECRBY', key, delta)
return 1
"""

inventory_script = r.register_script(inventory_lua)

# 使用
r.set('product:1001:stock', 10)
print(inventory_script(keys=['product:1001:stock'], args=[3]))  # 1 成功
print(r.get('product:1001:stock'))  # 7

# 超量扣减
print(inventory_script(keys=['product:1001:stock'], args=[10])) # 0 失败
print(r.get('product:1001:stock'))  # 7 没变

4.3 原子释放分布式锁

在第 2 篇中我们提到,释放锁需要"判断锁持有者 + 删除"两步原子化。Lua 脚本是最佳实践:

bash 复制代码
release_lock_lua = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
"""

release_lock = r.register_script(release_lock_lua)

# 获取锁
lock_key = 'lock:order:1001'
lock_value = 'unique-uuid-123'
r.set(lock_key, lock_value, nx=True, ex=30)

# 释放锁
result = release_lock(keys=[lock_key], args=[lock_value])
print(result)  # 1 删除成功

# 另一个客户端尝试释放
result2 = release_lock(keys=[lock_key], args=['other-uuid'])
print(result2) # 0 无权释放

4.4 复杂业务:抢红包(原子分配金额)

模拟红包:总金额 total_amount,剩余个数 remain,每次抢到随机金额。

bash 复制代码
red_envelope_lua = """
local total_key = KEYS[1]
local remain_key = KEYS[2]
local total = tonumber(redis.call('GET', total_key))
local remain = tonumber(redis.call('GET', remain_key))

if total == nil or remain == nil then
    return -1  -- 红包不存在
end
if remain <= 0 then
    return 0   -- 已抢完
end

local amount
if remain == 1 then
    amount = total
else
    amount = math.random(1, total - (remain - 1))
end
amount = math.min(amount, total)

redis.call('DECRBY', total_key, amount)
redis.call('DECR', remain_key)
return amount
"""

# 初始化红包
r.set('red:1001:total', 100)
r.set('red:1001:remain', 5)

red_script = r.register_script(red_envelope_lua)

for i in range(6):
    amount = red_script(keys=['red:1001:total', 'red:1001:remain'])
    if amount == -1:
        print('红包不存在')
    elif amount == 0:
        print('已抢完')
    else:
        print(f'第{i+1}次抢到: {amount}元')

输出示例:

bash 复制代码
第1次抢到: 47元
第2次抢到: 22元
第3次抢到: 11元
第4次抢到: 19元
第5次抢到: 1元
已抢完

每次抢到的金额随机且总和为 100,并且不会超发。

4.5 异步环境中的 Lua

redis.asyncio 中,register_script 同样可用:

bash 复制代码
import asyncio
import redis.asyncio as aioredis

async def async_lua():
    r = await aioredis.from_url('redis://localhost', decode_responses=True)
    script = r.register_script("""
        return redis.call('INCR', KEYS[1])
    """)
    result = await script(keys=['async_counter'])
    print(result)  # 1
    await r.close()

asyncio.run(async_lua())

💡 script 调用需要 await,因为内部会执行 Redis 命令。

5. 事务 vs Lua 脚本:如何选择?

一句话建议:凡是需要在命令中间做判断的,直接用 Lua;简单批处理用事务更简洁。

6. 常见误区与最佳实践

  • 事务不保证数据安全:它只保证一组命令原子执行,但没有数据库那样的回滚机制,失败命令不会撤销已执行命令。

  • Lua 脚本要轻量 :脚本执行期间会阻塞整个 Redis 实例,避免死循环或耗时计算。建议单个脚本执行时间不超过 100ms

  • 不要滥用 Lua:能用 Redis 原生命令组合完成的,就不用 Lua。Lua 会增加维护成本和调试难度。

  • 脚本中避免随机写全局键 :Lua 脚本中不能使用 redis.call('FLUSHALL') 等管理命令,且所有键应由 KEYS 显式传入,集群环境下必须在同一个哈希槽。

  • 务必缓存脚本 :使用 EVALSHAregister_script 可避免每次发送脚本正文,节省带宽。

7. 动手试试

  1. 模拟并发扣库存:用线程池启动 20 个线程,同时调用 Lua 库存扣减函数,每个线程扣 1 个,初始 10 个库存。统计成功次数,验证最终库存为 0。

  2. 抢红包验证:修改红包脚本,确保最后一次抢的红包金额正好是剩余金额,杜绝剩余不可分配的情况。

  3. 事务 + WATCH 并发测试 :模拟两个进程对同一个键执行 WATCH + MULTI/EXEC 更新,观察 WatchError 和重试次数。

预期效果:库存扣减 10 次成功、10 次失败;红包金额之和等于总金额;WATCH 冲突时自动重试成功。

8. 总结

本文我们深入了 Redis 事务与 Lua 脚本:

  • MULTI/EXEC:简单原子批处理,但不支持条件判断,无回滚。

  • WATCH:乐观锁实现 CAS,适合并发竞争不激烈的场景。

  • Lua 脚本:真正的原子逻辑执行引擎,能读写判断、计算,是实现库存扣减、分布式锁释放、抢红包等复杂逻辑的银弹。

  • Python 实践register_script 让 Lua 调用像本地函数一样简单,同步/异步无缝支持。

掌握了事务和 Lua,你就拥有了在高并发下保证数据一致性的核心能力。下一篇,我们将走进 Redis 的持久化机制------RDB 与 AOF,探秘数据如何从内存落地到磁盘,确保重启不丢数据。

想了解更多还可以去各个平台搜索「IT策士」,一起升级 IT 思维 !

相关推荐
海兰10 分钟前
【高速缓存】RedisVL 高级查询(全文搜索、混合搜索和 多向量搜索)
数据库·人工智能·redis·缓存
记录者t3 小时前
redis常见问题:大key
数据库·redis
青石路3 小时前
如果写Redis序列化与读Redis序列化不一致,你觉得会发生什么
java·redis
難釋懷3 小时前
Nginx代理https请求
redis·nginx·https
流星白龙13 小时前
【Redis】2.Redis重大版本
数据库·redis·junit
流星白龙13 小时前
【Redis】7.Hash表
数据库·redis·哈希算法
流星白龙14 小时前
【Redis】4.基本全局命令
数据库·redis·缓存
流星白龙17 小时前
【Redis】3.Redis安装与命令行客户端
数据库·redis·缓存
Season45021 小时前
Redis命令 (generic即通用命令)
数据库·redis·bootstrap