汪之亦 发表于 2025-10-1 11:54:30

Redis事务详解

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 time

r = redis.Redis(host='localhost', port=6379, db=0)

def deduct_balance(user_id, amount):
    """ 扣减用户余额,使用乐观锁保证并发安全 """
    balance_key = f"user:{user_id}:balance"
    # 重试次数,避免死循环
    max_retries = 5
    retry_count = 0

    while retry_count < max_retries:
      retry_count += 1
      try:
            # 1. 开启 Pipeline(包含 WATCH 命令,Python redis库中,pipeline 默认隐含 MULTI/EXEC)
            # `transaction=True` 表示我们要使用事务,它内部会处理 WATCH/MULTI/EXEC
            pipe = r.pipeline(transaction=True)
            
            # 2. WATCH 要监控的 key
            pipe.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() 成功返回命令执行结果的列表,例如
            print(f"Deduction successful! New balance: {result}")
            return True
            
      except redis.WatchError:
            # 6. 捕获 WatchError,表示事务失败,进行重试
            print(f"Transaction conflicted. Retrying... ({retry_count}/{max_retries})")
            time.sleep(0.1) # 稍等片刻再重试,避免激烈竞争
            continue
            
      except Exception as e:
            # 处理其他异常
            print(f"Unexpected error: {e}")
            break

    print("Failed after max retries.")
    return False

# 初始化用户余额
r.set("user:100:balance", 100)
# 模拟并发扣减
deduct_balance(100, 50)3、使用 Lua 脚本替代复杂事务

对于非常复杂的逻辑,使用 WATCH 和 MULTI 会使得重试循环非常笨重。此时,Lua 脚本是更好的选择。
Lua 脚本的优势:

[*]真正的原子性:整个脚本在执行时是以单线程、原子性的方式运行的,中间不会被执行任何其他命令,无需使用 WATCH。
[*]减少网络开销:逻辑在服务器端执行,避免了多次网络往返。
[*]复杂性封装:将复杂逻辑封装在服务器端的一个脚本中。
import redis

r = redis.Redis(host='localhost', port=6379, db=0)

# 定义Lua脚本代码
LUA_DEDUCT_SCRIPT = """
local balance_key = KEYS
local amount = tonumber(ARGV)
local current_balance = tonumber(redis.call('get', balance_key) or 0)

if current_balance < amount then
    return false -- 余额不足,返回false
end

redis.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 摘要来执行,可以节省每次传输完整脚本的网络带宽。

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: Redis事务详解