什么是事务

Redis 的事务和 MySQL 的事务概念上是类似的,都是把一系列操作绑定成一组,让这一组能够批量执行。

但是注意体会 Redis 的事务和 MySQL 事务的区别:

  • 弱化的原子性:Redis 没有 “回滚机制”,只能做到这些操作 “批量执行”,不能做到 “一个失败就恢复到初始状态”。
  • 不具备一致性:redis 没有回滚机制,事务执行过程中如果某个操作出现失败,就可能引起不一致的情况。
  • 不具备持久性:Redis 本身就是内存数据库,数据是存储在内存中的,虽然 Redis 也有持久化机制,但是事务没有和磁盘直接关系!
  • 不及隔离性:Redis 是单一线程模型的服务端程序,所有的请求 / 事务,都是「串行」执行的。

有的人说 redis 事务有原子性,有的说没有原子性,只是打包一起执行(打包一起正确执行)。

那我们对比一下:

MySQL 事务:把多个操作打包成一个整体,满足一致性,事务执行之后,相互不能再偏离;最终事务中的操作要么全部成功,要么全部失败(事务中的操作有一部分回滚)。

Redis 事务:和 MySQL 对比,就是「打包执行」(已经打上了经济含义),其最原本的意义是把多个操作打包一起,要么全部执行,要么全部不执行。但是 MySQL 这里的原子性,是把多个操作打包一起,要么全部成功,要么全部不执行;如果事务中有操作失败,要进行回滚,将中间已经执行的操作,全部回退。这就是人们说到原子性的时候,更多的是提到的 MySQL 这种有回滚的原子性。


Redis 事务的核心作用

Redis 的事务,主要的意义,就是为了「打包」,避免其他客户端的命令,插入到中间。

我和老婆说好了,晚上去吃烤串~~

女人出门,是一件麻烦的事情~~

我提前订了座位 → 我点了牛肉串、羊肉串、若干、五花肉若干点完之后,我告诉服务员:“我这人还多,你先别弄,都先不急考。”

过了一会,老婆到了~~老婆又加了一些烤串,又加了两个烤腰子~~我告诉服务员:“开始烤吧!”

此时,先点的这些串和后点的这些串是一起烤的,这两组中间,是没有插队的~~这个不被插队,不是先抢占位置,而是先让出位置~~

Redis 实现事务的核心逻辑

Redis 中实现事务,是引入了队列(每个客户端都有一个):

  • 开启事务的时候,此时客户端输入的命令,就会发给服务端并且进入这个队列中而不是立即执行!
  • 当遇到了「执行事务」命令的时候,此时就会把队列中的这些任务按照顺序依次执行~~
  • 主线程把事务中的操作都执行完,再处理别的客户端

所以 Redis 事务本质上是在服务器上搞了一个 “事务队列”。每次客户端在事务中进行一个操作,都会把命令先发给服务器,放到 “事务队列” 中(但是并不会立即执行)。

而是会在真正收到 EXEC 命令之后,才真正执行队列中的所有操作。

因此,Redis 的事务的功能相比于 MySQL 来说,是弱化很多的。只能保证事务中的这几个操作是 “连续的”,不会被别的客户端 “加塞”,仅此而已。

那 Redis 的事务为啥不设计成和 MySQL 一样强大呢?

MySQL 的事务,在每行付出了比较大的代价~~

  • 空间上:要花费更多的空间来存储更多的数据
  • 时间上:也要有更大的开销存储

正因为 MySQL 上的问题,才有了 Redis 上的机会~~

事务操作

MULTI

开启一个事务,执行成功返回 OK。

1 127.0.0.1:6379> MULTI
2 OK

EXEC

真正执行事务。

1 127.0.0.1:6379> MULTI
2 OK
3 127.0.0.1:6379> set k1 1
4 QUEUED
5 127.0.0.1:6379> set k2 2
6 QUEUED
7 127.0.0.1:6379> set k3 3
8 QUEUED
9 127.0.0.1:6379> EXEC
10 1) OK
11 2) OK
12 3) OK

每次添加一个操作,都会提示 "QUEUED",说明命令已经进入客户端的队列了。真正执行 EXEC 的时候,客户端才会真正把上述操作发送给服务器。此时就可以获取到上述 key 的值了。

1 127.0.0.1:6379> get k1
2 "1"
3 127.0.0.1:6379> get k2
4 "2"
5 127.0.0.1:6379> get k3
6 "3"

DISCARD

放弃当前事务,此时直接清空事务队列,之前的操作都不会真正执行到。

1 127.0.0.1:6379> MULTI
2 OK
3 127.0.0.1:6379> set k1 1
4 QUEUED
5 127.0.0.1:6379> set k2 2
6 QUEUED
7 127.0.0.1:6379> DISCARD
8 OK
9
10
11 127.0.0.1:6379> get k1
12 (nil)
13 127.0.0.1:6379> get k2
14 (nil)

WATCH

在执行事务的时候,如果某个事务中修改的值,被别的客户端修改了,此时就容易出现数据不一致的问题。

1 # 客户端1 先执行
2 127.0.0.1:6379> MULTI
3 OK
4 127.0.0.1:6379> set key 100
5 QUEUED
6
7
8 # 客户端2 再执行
9 127.0.0.1:6379> set key 200
10 OK
11
12
13 # 客户端1 最后执行
14 127.0.0.1:6379> EXEC
15 1) OK

此时,key 的值是多少呢??从输入命令的时间看,是客户端 1 先执行的 set key 100,客户端 2 后执行的 set key 200。但是从实际的执行时间看,是客户端 2 先执行的,客户端 1 后执行的。

1 127.0.0.1:6379> get key
2 "100"

这个时候,其实就容易引起歧义。因此,即使不保证严格的隔离性,至少也要告诉用户,当前的操作可能存在风险。

watch 命令就是用来解决这个问题的。watch 在该客户端上监控一组具体的 key。

  • 当开启事务的时候,如果对 watch 的 key 进行修改,就会记录当前 key 的 “版本号”(版本号是个简单的整数,每次修改都会使版本变大,服务器来维护每个 key 的版本号情况)
  • 在真正提交事务的时候,如果发现当前服务器上的 key 的版本号已经超过了事务开始时的版本号,就会让事务执行失败(事务中的所有操作都不执行)。
客户端 1 先执行
1 127.0.0.1:6379> watch k1        # 开始监控 k1
2 OK
3 127.0.0.1:6379> MULTI
4 OK
5 127.0.0.1:6379> set k1 100      # 进行修改,从服务器获取 k1 的版本号是 0,记录 k1 的版本号 0。(还没真修改呢,版本号不变)
6 QUEUED
7 127.0.0.1:6379> set k2 1000
8 QUEUED

只是入队列,但是不提交事务执行。

客户端 2 再执行
1 127.0.0.1:6379> set k1 200      # 修改成功,使服务器端的 k1 的版本号 0 -> 1
2 OK
客户端 1 再执行
1 127.0.0.1:6379> EXEC            # 真正执行修改操作,此时对比版本发现,客户端的 k1 的版本号是 0,服务器上的版本号是 1,版本不一致!说明有其他客户端在事务中间修改了 k1!!!
2 (nil)
3
4 127.0.0.1:6379> get k1
5 "200"
6
7 127.0.0.1:6379> get k2
8 (nil)

此时说明事务已经被取消了,这次提交的所有命令都没有执行。

对于 WATCH 命令来说:

Redis 的 WATCH 机制本质就是乐观锁的思想:它在事务执行前不会加锁阻塞其他线程,只是默默监控目标 key 是否被修改;事务执行时再检查 key 有无变动,没修改就正常执行事务,修改了就直接放弃执行,它默认认为并发冲突概率很低,不提前阻塞其他操作,只在最后提交时做校验,这和乐观锁 “假设冲突少、不加锁、最后验证” 的核心逻辑完全一致。


UNWATCH

取消对 key 的监控。相当于 WATCH 的逆操作,此处不做演示。


下面我们举一个 Redis 事务相关的使用场景吧!

使用场景

Redis 的普通事务(MULTI/EXEC)只是把命令打包排队、一次性执行,它能保证整个事务里的命令不被其他命令插队,实现了执行过程的原子性。但它没有内置的 if 判断逻辑,也不支持命令之间的相互依赖,无法在事务队列里做 “判断库存是否大于 0 再扣减” 这种逻辑。

所以在秒杀、库存扣减这种场景下,单纯的普通事务是不能安全解决超卖问题的。因为事务里的 get 是入队的,要等到 EXEC 才执行,你根本无法在入队时拿到实时值做判断,判断逻辑是在客户端执行的,不是在 Redis 服务器执行的,这就会出现并发安全问题,导致超卖。

为了真正解决超卖、实现 “判断 + 扣减” 的原子性,Redis 提供了 WATCH 机制:先监控 key,如果在事务执行前 key 被别人修改,整个事务就放弃执行,从而避免脏数据。但 WATCH 使用起来复杂,且容易失败重试,不适合高并发场景。

真正生产环境中,Redis 解决扣库存、下单、限流这种需要判断 + 操作原子性的场景,统一使用 Lua 脚本。因为 Lua 脚本在 Redis 中是完全原子执行的 —— 要么全部执行成功,要么全部不执行,中间不会被任何其他命令打断,同时脚本内部可以写 if /else 逻辑,完美实现 “判断库存→扣减” 的安全流程,比事务 + WATCH 更简单、更稳定、性能更高。

超卖的问题

一个典型的买法:获取仓库中剩余商品个数

if(个数 > 0) {
    下单成功
    商品个数--;
}

以前很多线程中,是通过加锁的方式,来避免「超卖」。在 Redis 中就直接使用事务,即可!


针对事务执行的流程

开启事务:

MULTI       # 开启事务,之后的命令不会立即执行,而是放入队列
GET count   # 读取库存,入队列
DECR count  # 库存-1,入队列
EXEC        # 执行事务:一次性执行队列中所有命令

执行逻辑:开启事务 → 命令入队列 → EXEC 才真正执行。第二个客户端的「执行事务」命令发过来之后,服务端才真正执行第二个事务中的内容。此时,第二个事务 GET count 就已经是第一个事务「自减」之后的值了。这个场景中,没问题,也能解决上述超卖问题。

为了避免 key 提前修改带来的业务错误,我们需要观察指定的 key!

MULTI       # 开启事务
EXEC        # 执行事务
DISCARD     # 放弃当前事务
WATCH key   # 监控key,如果key被修改,事务执行失败

命令执行示例:

127.0.0.1:6379> set key 111      # 设置初始值
OK
127.0.0.1:6379> multi            # 开启事务
OK
127.0.0.1:6379> set key 222      # 命令入队列,不执行
QUEUED
127.0.0.1:6379> set key 333      # 命令入队列,不执行
QUEUED

在服务端的事务队列中,保存了上述请求。此时如果另外一个客户端,再尝试修改这几个 key 对应的值,是没有问题的:

127.0.0.1:6379> set key 666
OK

当开启事务,并且给服务端发送了命令之后,此时服务器响应 → 此时的这个事务咋办?此时的效果其实就等同于 discard,事务会失效。


Redis 事务没有 if 判断,必须用 Lua 实现原子性

Redis 原生事务 没有 if、else、判断逻辑。你在客户端写的 if (count> 0) 是客户端执行的,不是 Redis 服务器执行的。这意味着:判断和扣减不是原子的,高并发下仍然会超卖。

真正能保证 “判断 + 扣减” 原子性的方案是 Lua 脚本

Lua 脚本代码(原子执行,自带 if)

# Lua 脚本在 Redis 中是全部执行,或全部不执行,绝对原子
local count = redis.call('get', 'count')  # 读取库存
if tonumber(count) > 0 then               # if 判断:库存足够
    redis.call('decr', 'count')           # 扣减库存
    return 1                              # 下单成功
else
    return 0                              # 库存不足
end

为什么 Lua 才是最终方案?

  1. Redis 执行 Lua 是原子的,中间不被任何命令打断。
  2. 支持 if /else 逻辑,能真正判断库存再扣减。
  3. WATCH + 事务 更简单、更高效、更稳定。
  4. 生产环境秒杀、库存扣减都用 Lua 脚本。
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐