基于本文回答

播面 播面

文图音视,全方位拆解八股文
0
评论

Redis事务与乐观锁及Lua脚本

知识点图片

Redis事务通过MULTI/EXEC打包命令,保证顺序执行和隔离性。它不支持运行时回滚,但可使用WATCH实现乐观锁。对于复杂原子操作,推荐使用Lua脚本。

我们来详细、系统地讲解一下 Redis 的事务。

Redis 的事务(Transaction)是一组命令的集合。它允许将多个命令打包,然后一次性、按顺序地执行。在事务执行期间,Redis 不会处理来自其他客户端的请求,这确保了事务中的所有命令都会被连续执行,不会被其他命令插入打断。

Redis 事务的四大核心命令

Redis 事务主要通过以下四个命令来实现:

  1. MULTI:标记一个事务块的开始。执行此命令后,客户端后续发送的命令都会被放入一个队列中,而不会立即执行。
  2. EXEC:执行所有在 MULTI 之后入队的命令。一旦执行 EXEC,Redis 会顺序执行队列中的所有命令,并一次性返回所有命令的执行结果。
  3. DISCARD:取消事务,放弃执行事务队列中的所有命令。执行此命令后,事务状态会结束,所有已入队的命令都会被清空。
  4. WATCH:监视一个或多个 key。如果在事务执行(EXEC)之前,任何被 WATCH 的 key 被其他客户端修改了,那么整个事务将被取消,EXEC 会返回一个 nil 回复。这是一种实现乐观锁(Optimistic Locking)的机制。

Redis 事务的特性 (与传统数据库ACID的对比)

很多人会拿 Redis 事务和关系型数据库的 ACID 事务作比较。需要特别注意的是,Redis 事务与我们通常理解的 ACID 有很大不同。

1. 原子性 (Atomicity)

Redis 的事务不完全满足原子性。它提供的是一种“部分原子性”。

  • 命令入队阶段的原子性:如果在 MULTIEXEC 之间,客户端发送的某个命令有语法错误(比如命令名写错了),那么在执行 EXEC 时,Redis 会拒绝执行整个事务,所有命令都不会被执行。这一点上,它具备原子性。
  • 命令执行阶段的非原子性:如果在 EXEC 执行期间,某个命令发生了运行时错误(比如对一个字符串类型的 key 执行 INCR 操作),Redis 不会回滚已经执行成功的命令,而是会继续执行完后续的命令。

示例:执行时错误

sh
127.0.0.1:6379> SET mykey "hello"
OK

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR mykey  # 这条命令会失败,因为mykey是字符串
QUEUED
127.0.0.1:6379> SET anotherkey "world" # 这条命令是正确的
QUEUED
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range  # 第一条命令执行失败
2) OK                                                    # 第二条命令执行成功

在这个例子中,即使 INCR mykey 失败了,SET anotherkey "world" 依然被成功执行了。Redis 事务没有回滚(Rollback)机制

Redis 作者认为,命令执行失败通常是编程错误导致的,应该在开发阶段就被发现,而不是在生产环境中依赖事务回滚来解决。这种设计简化了 Redis,使其保持了高性能。

2. 一致性 (Consistency)

Redis 事务可以保证一致性。

  • 入队错误:整个事务不执行,数据库状态不变。
  • 执行错误:虽然不会回滚,但 Redis 会保证出错的命令不会对数据造成破坏(例如,INCR 字符串失败,原字符串的值不会改变)。
  • 中断保证:事务执行期间,不会有其他客户端的命令插入,避免了数据状态的混乱。
  • 持久化:如果开启了 RDB 或 AOF 持久化,Redis 会确保只有成功执行的事务才会被写入磁盘。

3. 隔离性 (Isolation)

Redis 事务具有完全的隔离性

这是因为 Redis 是单线程执行命令的。当一个客户端执行 EXEC 时,Redis 会以独占的方式执行事务队列中的所有命令,直到全部执行完毕。在此期间,任何其他客户端的请求都不会被处理。这提供了最高级别的隔离——串行化(Serializable)

4. 持久性 (Durability)

事务的持久性取决于 Redis 的持久化配置(AOF/RDB),与事务本身无关。

  • 如果 Redis 没有配置持久化,那么服务重启后数据会丢失,事务也就不具备持久性。
  • 如果配置了 AOF 并且 appendfsync 策略是 always,那么每个成功执行的命令都会被立即写入磁盘,持久性最高。
  • 如果 appendfsynceverysec(默认),则最多可能丢失 1 秒的数据。

使用 WATCH 实现乐观锁

WATCH 是 Redis 事务中非常重要的一个功能,它用来解决并发场景下的“竞态条件”(Race Condition)。

场景:假设有一个商品库存 stock,值为 10。现在有两个用户(客户端A和B)同时购买。

不使用 WATCH 的错误流程

  1. 客户端A 读取 stock 为 10。
  2. 客户端B 也读取 stock 为 10。
  3. 客户端A 在本地计算 10 - 1 = 9,然后 SET stock 9
  4. 客户端B 在本地计算 10 - 1 = 9,然后 SET stock 9

结果:卖出了两个商品,库存却只减了1,导致数据不一致。

使用 WATCH 的正确流程

  1. 客户端A: WATCH stock
  2. 客户端B: WATCH stock
  3. 客户端A: GET stock -> 得到 "10"
  4. 客户端B: GET stock -> 得到 "10"
  5. 客户端A: MULTI
  6. 客户端A: SET stock 9
  7. 客户端A: EXEC -> 成功执行,stock 变为 9。
  8. 客户端B: MULTI
  9. 客户端B: SET stock 9
  10. 客户端B: EXEC -> 此时 EXEC 会返回 (nil),表示事务执行失败。因为在客户端B执行 EXEC 之前,它所 WATCHstock 键已经被客户端A修改了。

客户端B在发现事务失败后,可以进行重试(重新 WATCHGET、计算、EXEC),直到成功为止。这就是Check-And-Set (CAS) 行为。

WATCH 的工作原理

  • WATCH 命令会监视一个或多个 key 的版本。
  • 当任何一个被 WATCH 的 key 被修改(如 SET, INCR, HSET, LPUSH 等),所有监视该 key 的客户端的“脏位”(dirty flag)都会被设置。
  • 当客户端执行 EXEC 时,Redis 会检查这个“脏位”。如果为真,则事务被取消。
  • EXECDISCARD 命令执行后,之前的所有 WATCH 都会被取消。也可以手动使用 UNWATCH 取消。

Redis 事务的局限性

  1. 无运行时回滚:如前所述,执行期间的错误不会导致已执行命令的回滚。
  2. 不支持检查点:事务队列中的命令结果在 EXEC 之前是不可见的。你不能在一个事务中,根据前一个命令的结果来决定下一个命令的操作。
sh
# 错误的想法:不能这样做
MULTI
SADD myset "user1"  # 希望得到这个命令的返回值(1或0)
IF (返回值 == 1) THEN  # 事务中不支持这种逻辑判断
    INCR user_count
END
EXEC

这种复杂的、带有逻辑判断的原子操作需求,应该使用 Lua 脚本来解决。


替代方案:Lua 脚本

对于需要复杂逻辑的原子操作,Redis 官方更推荐使用 Lua 脚本。

  • 原子性:整个 Lua 脚本在 Redis 中是原子执行的,与事务的 EXEC 类似,执行期间不会被其他命令打断。
  • 灵活性:可以在脚本中编写复杂的逻辑,如 if-else 判断、循环等。
  • 性能:Lua 脚本在 Redis 服务器端执行,减少了客户端与服务器之间的网络往返次数。

示例:使用 Lua 脚本实现安全的库存扣减

plaintext
-- a lua script, e.g., decrease_stock.lua
local stock = redis.call('GET', KEYS[1])
if tonumber(stock) > 0 then
    redis.call('DECR', KEYS[1])
    return 1 -- 代表成功
else
    return 0 -- 代表库存不足
end

在客户端执行:

sh
EVAL "local stock = redis.call('GET', KEYS[1]) if tonumber(stock) > 0 then redis.call('DECR', KEYS[1]); return 1; else return 0; end" 1 stock

这个脚本实现了“读取-判断-写入”的完整原子操作,比 WATCH + MULTI + EXEC 的方式更简洁、更高效。

总结

特性 Redis 事务 (MULTI/EXEC) 传统关系型数据库事务
原子性 部分支持(入队错误则全部取消,执行错误不回滚) 完全支持,出错会回滚
隔离性 完全支持(串行化) 支持多种隔离级别(读未提交、读已提交、可重复读、串行化)
回滚机制
乐观锁 通过 WATCH 命令实现 通常通过版本号或时间戳字段实现
复杂逻辑 不支持 支持

何时使用 Redis 事务?
当你需要确保一组命令能够连续执行而不被其他客户端打断时,Redis 事务是一个简单有效的工具。例如,SADD 一个元素后立即用 EXPIRE 为其设置过期时间。

何时使用 Lua 脚本?
当你需要执行一个包含条件判断、计算或循环的复合操作,并且要求这个操作是原子性的时候,Lua 脚本是更好的选择。例如,安全的库存扣减、限流操作等。

00:00
00:00