IT策士 10余年一线大厂经验,专注 IT 思维、架构、职场进阶。我会在各个平台持续发布最新文章,助你少走弯路。
在高并发环境下,多个客户端同时读写同一个键,很容易出现数据竞态------比如库存扣减成负数、账户余额被并发修改覆盖。Redis 虽然单线程执行命令,但多个命令之间仍然可能被其他客户端插队。
要保证一系列命令的原子性 (要么全执行,要么全不执行,中间不被打断),Redis 提供了两套武器:事务(MULTI/EXEC) 和 Lua 脚本。前者适合简单批处理,后者则是原子性的终极方案。本文就从原理到实践,把这两把利器彻底讲透。
1. Redis 事务:MULTI/EXEC 机制
1.1 事务是什么?
Redis 的事务将多个命令打包,然后一次性地、顺序地执行。核心命令:
-
MULTI:开启事务 -
EXEC:执行事务中所有命令 -
DISCARD:取消事务
在 MULTI 和 EXEC 之间的命令不会立即执行 ,而是被放入队列。当 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
key2 和 key3 设置成功,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),需要重试。
WATCH 在 EXEC(或 DISCARD、UNWATCH)后自动取消。重试逻辑常出现在秒杀、抢红包场景。
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_acc,WatchError 会被触发,循环重试保证数据一致。
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]接收world,ARGV[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() 方法,自动完成 EVAL → EVALSHA 的转换,并缓存脚本。
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 返回一个可调用对象,传入 keys 和 args 即可执行。
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显式传入,集群环境下必须在同一个哈希槽。 -
务必缓存脚本 :使用
EVALSHA或register_script可避免每次发送脚本正文,节省带宽。
7. 动手试试
-
模拟并发扣库存:用线程池启动 20 个线程,同时调用 Lua 库存扣减函数,每个线程扣 1 个,初始 10 个库存。统计成功次数,验证最终库存为 0。
-
抢红包验证:修改红包脚本,确保最后一次抢的红包金额正好是剩余金额,杜绝剩余不可分配的情况。
-
事务 + 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 思维 !