1、概念
Redis 事务的本质是将多个命令打包,然后按顺序、一次性、隔离地执行。它通过 MULTI, EXEC, DISCARD, WATCH 四个核心命令实现。
- MULTI:开启一个事务,之后的命令都会放入队列,而不是立即执行。
- EXEC:执行事务队列中的所有命令。
- DISCARD:取消事务,清空队列。
- WATCH:在 MULTI 之前,监视一个或多个 key。如果在 EXEC 执行前,这些被监视的 key 被其他客户端修改,则整个事务会失败(EXEC 返回 nil)。这是实现乐观锁(Optimistic Locking) 的基础。
与传统事务的差异
- 无回滚:Redis 设计哲学是简单和高效,失败的命令通常是编程错误(命令用错、数据类型错误),应该在开发阶段被发现,而不是在生产环境通过回滚来挽救。回滚会增加复杂性和性能开销。
- 隔离性:事务中的所有命令都会按顺序串行执行,并且在 EXEC 执行前,事务中的命令不会被其他客户端的命令插队。这保证了事务执行过程中不会受到其他客户端的干扰。
- 持久化无关:事务的 “执行成功” 仅表示命令入队并执行,不保证已持久化到磁盘(需配合 AOF 配置 appendfsync always 提升可靠性,但会牺牲性能)。
- 不保证原子性(Atomicity):这是最大的误区!Redis 事务保证的是隔离性(Isolation) 和顺序执行,但不保证原子性。这意味着如果事务中某个命令执行失败(例如对字符串执行了 HINCRBY),其他命令依然会继续执行,且不会回滚(Rollback)
2、最佳实践详解与示例
2.1 一个失败的事务会发生什么?
127.0.0.1:6379> SET key1 "hello"
OK
127.0.0.1:6379> SET key2 100
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR key1 # 错误:对字符串 'hello' 执行 INCR,这会失败!
QUEUED
127.0.0.1:6379> INCR key2 # 正确:对数字 100 执行 INCR
QUEUED
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range # 第一条命令报错
2) (integer) 101 # 第二条命令成功执行!
127.0.0.1:6379> GET key2
"101"
2.2 使用WATCH实现乐观锁
工作流程:
- WATCH 需要监控的 key。
- 读取 key 的值并进行业务逻辑计算。
- 开启 MULTI。
- 将写命令(如 SET, INCRBY)放入队列。
- 执行 EXEC。
- 如果成功:说明在 WATCH 到 EXEC 期间,被监控的 key 没有被其他客户端改动,事务执行成功,WATCH 可以监控多个 key,只要其中任何一个被修改,事务都会失败。
- 如果返回 nil:说明 key 已被修改,事务执行失败。通常的处理方式是重试整个流程。
import redis
import timer = redis.Redis(host='localhost', port=6379, db=0)def deduct_balance(user_id, amount):""" 扣减用户余额,使用乐观锁保证并发安全 """balance_key = f"user:{user_id}:balance"# 重试次数,避免死循环max_retries = 5retry_count = 0while retry_count < max_retries:retry_count += 1try:# 1. 开启 Pipeline(包含 WATCH 命令,Python redis库中,pipeline 默认隐含 MULTI/EXEC)# `transaction=True` 表示我们要使用事务,它内部会处理 WATCH/MULTI/EXECpipe = r.pipeline(transaction=True)# 2. WATCH 要监控的 keypipe.watch(balance_key)# 3. 读取并检查余额current_balance = int(pipe.get(balance_key) or 0)if current_balance < amount:pipe.unwatch() # 解除监控,可选,EXEC/DISCARD后也会自动解除print("Insufficient balance!")return False# 4. 开启事务,组装命令pipe.multi()pipe.decrby(balance_key, amount)# 5. 执行事务# 如果 balance_key 在 WATCH 后被执行了,这里会抛出 WatchError 异常result = pipe.execute() # execute() 成功返回命令执行结果的列表,例如 [95]print(f"Deduction successful! New balance: {result[0]}")return Trueexcept redis.WatchError:# 6. 捕获 WatchError,表示事务失败,进行重试print(f"Transaction conflicted. Retrying... ({retry_count}/{max_retries})")time.sleep(0.1) # 稍等片刻再重试,避免激烈竞争continueexcept Exception as e:# 处理其他异常print(f"Unexpected error: {e}")breakprint("Failed after max retries.")return False# 初始化用户余额
r.set("user:100:balance", 100)
# 模拟并发扣减
deduct_balance(100, 50)
3、使用 Lua 脚本替代复杂事务
对于非常复杂的逻辑,使用 WATCH 和 MULTI 会使得重试循环非常笨重。此时,Lua 脚本是更好的选择。
Lua 脚本的优势:
- 真正的原子性:整个脚本在执行时是以单线程、原子性的方式运行的,中间不会被执行任何其他命令,无需使用 WATCH。
- 减少网络开销:逻辑在服务器端执行,避免了多次网络往返。
- 复杂性封装:将复杂逻辑封装在服务器端的一个脚本中。
import redisr = redis.Redis(host='localhost', port=6379, db=0)# 定义Lua脚本代码
LUA_DEDUCT_SCRIPT = """
local balance_key = KEYS[1]
local amount = tonumber(ARGV[1])
local current_balance = tonumber(redis.call('get', balance_key) or 0)if current_balance < amount thenreturn false -- 余额不足,返回false
endredis.call('decrby', balance_key, amount)
return true -- 扣减成功,返回true
"""
# 预先加载脚本,获取一个sha1哈希摘要,后续可以用这个摘要来执行,效率更高。
script_sha = r.script_load(LUA_DEDUCT_SCRIPT)def deduct_balance_lua(user_id, amount):balance_key = f"user:{user_id}:balance"# 使用 evalsha 执行脚本# 1 表示后面有1个KEY, amount 是ARGV参数success = r.evalsha(script_sha, 1, balance_key, amount)return bool(success)# 初始化
r.set("user:100:balance", 100)
# 执行扣减
result = deduct_balance_lua(100, 50)
print(f"Deduction result: {result}")
print(f"Remaining balance: {r.get('user:100:balance')}")
最佳实践:
-
对于复杂的、需要原子性执行的逻辑,优先使用 Lua 脚本。
-
使用 SCRIPT LOAD 预加载脚本,然后用 EVALSHA 通过脚本的 SHA1 摘要来执行,可以节省每次传输完整脚本的网络带宽。