目录
二.有关事务的命令------multi,exec,discard
一.Redis里面的事务
1.1.Redis事务特性
事务的核心概念是将一系列操作绑定为一组,作为一个整体执行。Redis 的事务 在基本理念上与MySQL 的事务类似,都是为了实现操作的批量执行和一定程度上的执行隔离。
Redis 事务与 MySQL 事务的关键区别 (基于 ACID 特性分析):
-
弱化的原子性 (Weak Atomicity):
-
Redis 没有回滚 (rollback) 机制。事务中的命令要么全部被服务器接受并排队(入队成功),要么整个事务在入队阶段就被丢弃(例如,当某个命令存在语法错误时)。
-
更重要的是,在
EXEC
命令真正执行事务队列时,即使某个命令执行失败(例如,对错误的数据类型执行操作 - 如对字符串值使用 HINCRBY),Redis 也不会中断执行或回滚之前已成功的命令 。它会继续执行队列中剩余的命令。因此,Redis 事务只能保证操作的批量提交和连续执行 ,不能保证传统数据库"要么全部成功,要么全部失败"的强原子性。
-
-
不保证一致性 (No Consistency Guarantees):
-
Redis 本身不强制执行数据完整性约束(如关系型数据库中的外键、唯一性约束等)。它没有预定义的业务规则来校验数据状态的合理性。
-
同时,缺乏回滚机制意味着即使事务执行过程中某个操作导致了无效或矛盾的中间状态(例如,违反业务逻辑的数据),也无法撤销这些操作,从而导致数据的不一致。这与 MySQL 等数据库通过约束和回滚机制来确保事务执行前后数据状态都满足一致性要求形成了鲜明对比。
-
-
不需要隔离性 (No Isolation Needed):
- Redis 没有事务隔离级别的概念 。这是因为 Redis 采用单线程模型 来处理客户端命令。在任何时刻,只有一个命令(或一个
EXEC
执行的事务队列)正在服务器端执行。这天然地防止了不同客户端事务或命令之间的并发干扰,因此不需要复杂的隔离机制。
- Redis 没有事务隔离级别的概念 。这是因为 Redis 采用单线程模型 来处理客户端命令。在任何时刻,只有一个命令(或一个
-
不需要持久性 (Durability Not Inherent):
-
Redis 的主要工作模式是将数据存储在内存 (RAM) 中。数据的持久化(保存到磁盘)是 Redis 服务器本身通过配置 RDB 快照或 AOF 日志等机制来处理的,是一个独立于事务概念的后台过程。
-
一个事务的成功执行 (
EXEC
) 只意味着命令在内存中执行完毕。事务本身并不保证其修改会被立即或一定持久化到磁盘。持久化的保证取决于服务器的配置和持久化策略。
-
1.2.Redis事务的价值
核心价值:确保事务队列中的一组命令能够被连续(Sequential)且隔离(Isolated)地执行。
-
连续执行 (Sequential Execution): 在事务执行期间,事务队列(
MULTI
和EXEC
之间)内的所有命令会被作为一个整体、按照客户端发送的顺序依次执行。 -
隔离执行 (Isolated Execution): 这是核心价值的关键。在事务执行过程中(即从
EXEC
命令开始到其完成),Redis 单线程处理模型 保证了该事务队列中的命令会被作为一个不可分割的单元连续运行。在此期间,不会有任何其他客户端的命令被插入("加塞")或打断当前事务的执行。 其他客户端发送的命令必须等待当前事务完全执行完毕(EXEC
返回结果)后,才能被服务器处理。
实现机制:
这种连续性和隔离性是由 Redis 的核心架构共同保障的:
-
单线程命令处理: Redis 使用单线程(主线程)来处理所有客户端的网络 I/O 和命令执行。这从根本上避免了多线程环境下的竞态条件(Race Conditions)。
-
命令队列机制: 当客户端开启事务(
MULTI
)后,后续的命令不会立即执行,而是被顺序缓存在该客户端关联的事务队列中。只有当客户端发送EXEC
命令时,服务器才会一次性、连续地、不间断地将该队列中的所有命令取出并按序执行。
重要补充说明:
-
隔离性 ≠ 强一致性/ACID 原子性: 需要强调的是,Redis 事务提供的隔离性是"执行层面的隔离"(命令不会被其他命令打断),并非 数据库 ACID 中的强隔离级别(如可串行化)。在
MULTI
开始后、EXEC
执行前,其他客户端对数据的修改是可见的(无快照隔离)。事务的原子性也仅限于"命令队列要么全部执行,要么全部不执行",但不支持回滚(Rollback)。 -
价值体现: 这种机制的核心价值在于,在并发环境下,开发者可以确信事务块内的多个命令在执行过程中不会被外部命令干扰,从而避免因交错执行导致的数据中间状态不一致问题。这对于需要连续执行多个相关操作(例如:先检查库存再扣减)的场景至关重要。
Redis 事务为啥这么"简单"?为啥不像 MySQL 那样强大?
这其实正是 Redis 设计的聪明之处 !MySQL 的事务功能确实强大(ACID),但这份强大背后是沉重的代价:
-
空间代价: 为了实现回滚、持久化到特定状态,需要存储大量的额外数据(如 undo logs)。
-
时间代价: 为了保证强一致性(尤其是隔离性),需要复杂的锁机制、日志写入和检查点处理,这会显著增加每个事务的执行开销。
正是 MySQL 这种"重"事务在高并发、低延迟场景下的性能瓶颈,才给了 Redis 大放异彩的机会!Redis 的核心定位是高性能的内存数据存储和缓存 。它的设计哲学就是极致的速度与简单 。为了达到这个目标,它在事务上做了关键取舍:
-
只保证命令的连续、隔离执行: 确保事务队列里的命令不会被其他客户端命令打断,按顺序一次性执行完。
-
放弃回滚 (Rollback): 如果事务中某个命令执行出错(比如对字符串执行
INCR
),Redis 不会自动撤销前面已执行的命令。要么全部执行(即使中间有错误命令),要么全部不执行(比如在EXEC
前连接断开)。 -
没有复杂的隔离级别: 不像 MySQL 有读未提交、读已提交、可重复读、串行化等隔离级别。Redis 事务执行期间是隔离的,但在
MULTI
开始后、EXEC
执行前,其他客户端对数据的修改是可见的(没有快照隔离)。
这种"简单"的事务模型,极大地减少了开销,让 Redis 能在超高并发 下依然保持惊人的速度,完美契合了它核心的使用场景。
啥时候该请 Redis 事务"出场"呢?
核心场景就是:当你需要把多个 Redis 操作作为一个不可分割的单元来执行,尤其是在高并发环境下,需要防止其他操作"插队"导致数据中间状态不一致时。
经典战场:秒杀系统 (以你提到的 ROG 掌机抢购为例)
-
痛点: 限量商品(比如 5000 台),瞬间涌入海量请求(远超库存)。最大的风险就是超卖------ 系统错误地让超过 5000 个人下单成功。
-
漏洞代码 (无保护):
1. 获取当前库存数量 (count = GET stock:rog) 2. 判断 if (count > 0) 3. 下单成功 4. 减少库存 (DECR stock:rog)
-
问题: 在超高并发下,多个请求可能同时 执行到第 1 步,都读到库存
count = 1
,然后都判断通过,都去扣减库存,最终导致库存变成负数(比如 -10),这就是超卖!问题的本质就是步骤 1-4 不是原子操作,被其他请求"插队"了。 -
传统多线程方案: 加锁 (如
synchronized
,ReentrantLock
),确保同一时间只有一个线程执行这段代码。但这在分布式系统或极高并发下可能成为瓶颈。 -
Redis 事务方案 (基础版):
MULTI // 开启事务 GET stock:rog // 读库存 (命令进入队列,不执行) ... (这里无法直接做 if count>0 判断!) ... DECR stock:rog // 减库存 (命令进入队列) EXEC // 执行事务 (服务器按序连续执行队列命令)
-
事务的作用:
EXEC
执行时,队列里的命令 (GET
和DECR
) 会连续、不间断、不被其他客户端命令打断 地执行。这解决了"插队"问题,保证了GET
和DECR
这两个操作作为一个整体执行。 -
但是! 原生的 Redis 事务命令本身不支持条件判断 (
if count>0
)。上面的基础版事务只是保证了减库存的操作不会被其他命令打断,但它无法阻止在GET
(读库存)和DECR
(减库存)之间,库存被其他事务修改,并且在EXEC
时,即使库存为 0 也会执行DECR
导致负数!基础事务无法解决超卖问题。
解决方案进阶:Lua 脚本 (Redis 事务的"超进化")
-
Redis 支持运行 Lua 脚本 。Lua 脚本在 Redis 执行时是原子性 的:脚本包含的所有命令会像事务一样连续、隔离执行,并且脚本内部可以包含逻辑(如
if
判断)! -
秒杀防超卖 Lua 脚本 (伪代码):
local count = redis.call('GET', KEYS[1]) -- 获取库存 (KEYS[1] = 'stock:rog') count = tonumber(count) if count > 0 then redis.call('DECR', KEYS[1]) -- 库存大于0,执行减库存 return 1 -- 返回成功标识 (如 1) else return 0 -- 返回失败标识 (如 0) end
-
完美解决: 这个脚本在 Redis 服务器端原子性执行 。它先读库存,然后立即判断,只有库存 >0 时才执行减库存。整个
读->判断->写
过程一气呵成,不会被任何其他命令打断 ,彻底杜绝了超卖的可能!Lua 脚本是 Redis 中实现复杂原子操作(类似数据库存储过程)的首选方式,可以看作是 Redis 基础事务能力的威力加强版。 -
但是这个时候lua脚本不是我们本文的内容,我们会在后续文章进行介绍。
二.有关事务的命令------multi,exec,discard
Redis 事务的执行机制:
-
Redis 服务器为每个客户端连接维护一个"事务队列"。
-
当客户端使用
MULTI
命令开启事务后,后续发送的操作命令(如SET
,GET
,INCR
等)不会立即执行 。这些命令会被服务器放入该客户端的事务队列中暂存。 -
客户端发送
EXEC
命令时,服务器才会按顺序、连续地、不可中断地执行事务队列中的所有命令。 -
可以使用
DISCARD
命令清空事务队列并放弃当前事务。
话不多说,我们直接看例子
演示multi和exec
开启⼀个事务,执⾏成功返回OK.

这个时候3条set语句都没有被真正执行,每次添加⼀个操作,都会提⽰"QUEUED",说明命令都被放到该客户端的事务队列中暂存了。
这个时候我们打开另外一个客户端去查看一下

我们发现是看不到的。
只有我们真正执⾏EXEC的时候,客⼾端才会真正把上述操作发送给服务器.
我们回到最初的那个客户端执行exec

接着我们打开另外一个客户端看看

现在都有了啊。
演示multi和discard


执行discard命令会清空事务队列并放弃当前事务
当开启事务并且给服务器发送了若干个命令之后,还没有来得及执行exec或者discard,这个时候服务器重启,那么此时的这个事务会怎么办呢?
我们看看

接着我们打开另外一个客户端重启redis服务器
service redis-server restart

我们回到原来那个客户端去执行exec

我们发现报错了:Error: Broken pipe`。这个错误通常发生在客户端与Redis服务器的连接意外中断时。
当然有的版本是出现下面这个错误
也是一样的意思
我们查询一下

发现状态都没有变化。
三.watch
3.1.见一见watch
我们看看一个例子
客户端1:

客户端2:

客户端1:

此时,key的值是多少呢??
从输⼊命令的时间看,是客⼾端1先执⾏的set key100.
客⼾端2后执⾏的set key 200.
但是从实际的执⾏时间看,是客⼾端2先执⾏的,客⼾端1后执⾏的.

这个时候,其实就容易引起歧义。
因此,即使不保证严格的隔离性,⾄少也要告诉⽤⼾,当前的操作可能存在⻛险. watch命令就是⽤来解决这个问题的
WATCH
命令允许客户端在开启事务 (MULTI
) 之前 ,监控一个或多个指定的键 (key) 。其本质是实现了一种乐观并发控制 (Optimistic Concurrency Control, OCC)。
工作流程详解:
-
监控与记录版本号:
-
当客户端执行
WATCH key1 key2 ...
时,Redis 服务器会记录下这些被监控键当前的版本号。 -
版本号 (Version Number) :Redis 内部为每个键维护一个简单的整数版本号计数器。每次对这个键进行成功的修改操作(如
SET
,INCR
,LPUSH
,HSET
等)时,其版本号都会自动递增 (变大)。服务器负责维护所有键的版本号状态。
-
-
开启事务与命令入队:
-
客户端执行
MULTI
开启事务。 -
客户端发送后续操作命令(如
GET
,SET
,DECR
等)。这些命令不会立即执行 ,而是被顺序缓存在该客户端关联的事务队列 (Queue) 中,并收到QUEUED
响应。
-
-
提交事务与版本校验:
-
当客户端执行
EXEC
命令提交事务时,Redis 服务器会进行关键检查:-
它会重新检查 所有被
WATCH
监控的键当前的版本号。 -
将当前版本号与
WATCH
命令执行时记录的原始版本号进行比较。
-
-
检查结果:
-
所有键版本号未变: 如果所有被监控键的当前版本号 都等于或小于
WATCH
时记录的版本号(即没有被其他客户端修改过),则服务器会连续、原子性地执行事务队列中的所有命令,并返回每个命令的执行结果。事务成功完成。 -
任一键版本号已变: 如果任何一个 被
WATCH
监控的键的当前版本号 大于其在WATCH
时记录的版本号(表明该键在事务排队期间已被其他客户端修改 ),则服务器会拒绝执行整个事务队列中的所有命令 。EXEC
命令返回(nil)
表示事务执行失败。事务中的所有操作都不会被执行。
-
-
我们看看怎么使用
客户端1:

客户端2:

客户端1:

这个时候我们去查询一下

这就说明了客户端1执行的事务被取消了。这次提交的命令都没有执行。
3.2.watch实现的机制
3.2.1.什么是乐观锁/悲观锁?
🔒 1. 悲观锁 (Pessimistic Locking)
核心思想: "先加锁,再操作"。 它悲观地认为冲突一定会发生。因此,在访问共享数据之前,必须先获得独占锁,阻止其他所有并发操作访问该数据,直到当前操作完成并释放锁。
工作流程:
-
获取锁: 线程/进程 A 在操作共享数据(如数据库记录、内存变量、文件)前,先获取该数据的独占锁。
-
执行操作: A 独占访问数据,执行读取、计算、修改等操作。在此期间,任何其他线程/进程 B 尝试获取该数据的锁都会被阻塞(Blocked)或失败。
-
提交 & 释放锁: A 完成操作后,提交修改(如数据库事务提交),然后释放锁。
-
其他操作继续: 被阻塞的 B 此时才能获取到锁,然后执行它的操作。
典型实现方式:
-
数据库:
-
SELECT ... FOR UPDATE
(行级锁/排他锁) -
表级锁 (
LOCK TABLES ... WRITE
)
-
-
编程语言:
-
synchronized
关键字 (Java) -
ReentrantLock
(Java) -
mutex
(互斥锁,C++/Python/Go等)
-
-
分布式系统:
-
ZooKeeper 分布式锁
-
Redis 的
SETNX
+EXPIRE
(或 RedLock 算法) 实现的分布式锁
-
优点:
-
强一致性保证: 能有效防止任何并发冲突,保证数据的绝对安全。
-
实现相对简单直观: 加锁-操作-释放锁的模式容易理解。
缺点:
-
性能开销大:
-
获取/释放锁本身有开销。
-
阻塞等待: 如果锁竞争激烈,大量线程会阻塞等待,导致系统吞吐量急剧下降,响应时间变长。
-
-
死锁风险: 需要小心处理锁的获取顺序,否则容易发生死锁(两个或多个线程互相等待对方释放锁)。
-
降低并发度: 锁定的资源在持有期间无法被其他操作利用。
适用场景:
-
冲突频率高: 预期多个操作会频繁修改同一数据的场景。
-
临界区操作耗时: 数据操作本身比较耗时,持有锁的时间较长。
-
需要强一致性: 对数据一致性要求极其严格,不允许任何中间状态被读取或覆盖。
-
示例: 银行核心系统的账户转账(金额大、要求绝对准确)、库存系统中唯一商品的抢购。
☀️ 2. 乐观锁 (Optimistic Locking)
核心思想: "先操作,再检查冲突"。 它乐观地认为冲突很少发生 。因此,它不预先加锁 ,允许多个操作并发地读取数据。只有在提交修改时,才检查数据是否被其他操作修改过。如果没被修改,则提交成功;如果被修改过,则提交失败(通常需要重试或放弃)。
工作流程:
-
读取数据 & 记录版本: 线程/进程 A 读取共享数据,并记录下数据的当前状态或版本号(如时间戳、递增计数器、数据哈希值)。
-
本地计算/修改: A 在本地基于读取到的数据进行计算或修改(此过程不加锁,其他操作可并发访问该数据)。
-
提交时验证: 当 A 准备将修改写回数据源时:
-
检查版本/状态: 再次读取数据的当前状态或版本号。
-
比较: 将当前状态/版本号 与步骤1记录的状态/版本号进行比较。
-
-
决定提交或失败:
-
未修改 (验证通过): 如果两者一致,说明数据在 A 操作期间没有被其他操作修改过。A 成功提交修改,并更新数据的版本号(通常递增)。
-
已修改 (验证失败): 如果两者不一致,说明数据在 A 操作期间已被其他操作修改过。A 的提交失败 。常见的处理方式是回滚本地修改并重试整个操作(从步骤1重新开始)或向用户报告错误。
-
典型实现方式:
-
数据库:
-
版本号字段: 在数据表中增加一个
version
字段(整数)。每次更新时SET value = new_value, version = version + 1 WHERE id = ? AND version = old_version
。 -
时间戳字段: 使用
last_modified
时间戳字段代替版本号。 -
所有字段比较: 在
WHERE
子句中包含所有字段的旧值进行校验(效率低,不常用)。
-
-
Redis:
WATCH
+MULTI
/EXEC
机制(基于键的版本号)。 -
版本控制系统 (如 Git): 提交时检查文件是否已被他人修改。
-
内存数据结构: CAS (Compare-And-Swap) 原子操作(CPU 指令级别)。
优点:
-
高并发性能:
-
无锁读取: 读取操作完全不加锁,性能极高。
-
无阻塞: 操作失败通常发生在提交时,不会长时间阻塞其他线程(失败后重试是另一回事)。
-
-
避免死锁: 由于不持有锁,从根本上避免了死锁问题。
-
高吞吐量: 在读多写少 或冲突概率低的场景下,系统吞吐量远高于悲观锁。
缺点:
-
冲突处理开销: 如果冲突真的频繁发生,提交失败率高,导致重试次数增多,整体性能可能反而下降(不断重试的开销)。
-
实现更复杂: 需要维护版本号/状态信息,处理提交失败(重试策略、错误处理)的逻辑比简单的加锁复杂。
-
无法保证绝对成功: 在高冲突场景下,某些操作可能多次重试后仍然失败。
-
ABA 问题: (主要在 CAS 实现中) 数据版本号可能从 A 变到 B 又变回 A,单纯的版本号比较会误认为数据未变。通常需要通过增加标记位(如修改计数)来解决。
适用场景:
-
冲突频率低: 预期多个操作同时修改同一数据的概率较小的场景(读多写少是典型)。
-
临界区操作快速: 数据操作本身很快,提交失败重试的成本相对较低。
-
允许重试/失败: 业务逻辑能容忍提交失败,并支持重试机制。
-
示例:
-
购物车商品数量增减: 用户修改自己购物车里的商品数量,冲突概率低。
-
文章点赞/阅读计数: 少量计数丢失通常可接受。
-
用户信息更新(非核心字段): 如更新昵称、头像(冲突概率低,冲突后重试即可)。
-
Redis 秒杀库存扣减: 结合
WATCH
(版本号) 或 Lua 脚本实现乐观锁。
-
3.2.2.watch实现的过程
WATCH
命令允许客户端在开启事务 (MULTI
) 之前 ,监控一个或多个指定的键 (key) 。其本质是实现了一种乐观并发控制 (Optimistic Concurrency Control, OCC)。
工作流程详解:
-
监控与记录版本号:
-
当客户端执行
WATCH key1 key2 ...
时,Redis 服务器会记录下这些被监控键当前的版本号。 -
版本号 (Version Number) :Redis 内部为每个键维护一个简单的整数版本号计数器。每次对这个键进行成功的修改操作(如
SET
,INCR
,LPUSH
,HSET
等)时,其版本号都会自动递增 (变大)。服务器负责维护所有键的版本号状态。
-
-
开启事务与命令入队:
-
客户端执行
MULTI
开启事务。 -
客户端发送后续操作命令(如
GET
,SET
,DECR
等)。这些命令不会立即执行 ,而是被顺序缓存在该客户端关联的事务队列 (Queue) 中,并收到QUEUED
响应。
-
-
提交事务与版本校验:
-
当客户端执行
EXEC
命令提交事务时,Redis 服务器会进行关键检查:-
它会重新检查 所有被
WATCH
监控的键当前的版本号。 -
将当前版本号与
WATCH
命令执行时记录的原始版本号进行比较。
-
-
检查结果:
-
所有键版本号未变: 如果所有被监控键的当前版本号 都等于或小于
WATCH
时记录的版本号(即没有被其他客户端修改过),则服务器会连续、原子性地执行事务队列中的所有命令,并返回每个命令的执行结果。事务成功完成。 -
任一键版本号已变: 如果任何一个 被
WATCH
监控的键的当前版本号 大于其在WATCH
时记录的版本号(表明该键在事务排队期间已被其他客户端修改 ),则服务器会拒绝执行整个事务队列中的所有命令 。EXEC
命令返回(nil)
表示事务执行失败。事务中的所有操作都不会被执行。
-
-