目录
什么是事务
实现事务的方式
Redis 事务与 MySQL 事务的对比
应用场景:防止超卖
Lua 脚本增强
事务操作
MULTI & EXEC
DISCARD
WATCH
WATCH 的实现原理
什么是事务
[MySQL#12] 事务(1) | ACID | commit | 回滚 | 常见操作
Redis 的事务和 MySQL 的事务概念上是类似的,都是把一系列操作绑定成一组,让这一组能够批量执行。然而,需要注意的是 Redis 事务与 MySQL 事务存在以下几点区别:
- 弱化的原子性: Redis 没有 "回滚机制"。它只能保证这些操作 "批量执行",但不能做到 "一个失败就恢复到初始状态"。
- 不保证一致性: Redis 不涉及 "约束",也没有回滚机制。MySQL 的一致性体现在运行事务前后结果的合理性,不会出现中间非法状态。
- 不需要隔离性: Redis 事务没有隔离级别,因为 Redis 是单线程处理请求,不会并发执行事务。
- 不需要持久性: Redis 数据保存在内存中,是否开启持久化由 redis-server 自行决定,这与事务无关。
Redis 事务本质上是在服务器上创建了一个 "事务队列"。
每次客户端在事务中进行一个操作,都会先将命令发送给服务器并放入 "事务队列"(但并不会立即执行),而是在收到 EXEC 命令后,才真正执行队列中的所有操作。
- 这么多特性Redis都不具备,那么Redis的事务到底有什么意义呢?
Redis的事务最主要的意义就是为了"打包",避免其他客户端的命令插队到中间.
最开始官网还说原子性,后来就把这句话给删了,官方也是有点虚的
实现事务的方式
- Redis 在实现事务时引入了队列机制。每个客户端都有一个独立的队列。
- 当开启事务时,客户端输入的命令会发送给服务器并进入这个队列中,而不是立即执行。
- 只有当遇到 "执行事务" 的命令(如 EXEC)时,才会把队列中的任务按照录入顺序依次执行。
- 这些事务操作是在 Redis 主线程中完成的。
- 主线程会确保将事务中的所有操作执行完毕后,再处理其他客户端的请求。
Redis 事务与 MySQL 事务的对比
虽然 Redis 事务的功能没有 MySQL 强大,但 MySQL 为了实现强大的事务功能也付出了不小的代价:
- 空间上:MySQL 需要花费更多空间来存储额外的数据,例如用于回滚段和多版本控制。
- 时间上:MySQL 的事务处理需要更大的执行开销,包括锁管理、日志记录等。
正因为 MySQL 事务存在上述问题,Redis 提供了一种轻量级的解决方案,适用于某些特定场景。
应用场景:防止超卖
在购物网站秒杀场景中,可能会出现超卖的情况,即商家放了5000个商品,但由于并发问题导致下单成功了5001个。为避免这种情况,在多线程环境中我们曾通过加锁的方式来解决。
如果引入 Redis,则可以直接使用其事务机制解决问题。
例如,考虑两个客户端几乎同时下单的情况:
- 客户端1开启一个事务,该事务被放入 Redis 的事务队列中;
- 客户端2同样开启一个事务,并被放入队列;
- 当事务1收到执行命令被执行时,它会减少库存计数(
count--
); - 当轮到事务2执行时,由于此时
count>0
的条件不再满足,所以事务2将不会改变数据库状态,从而避免了超卖问题。
Lua 脚本增强
值得注意的是,尽管 Redis 本身不支持像编程语言那样的条件判断(如 if),但它可以引入 Lua 脚本来实现复杂的逻辑判断。通过 Lua 脚本,我们可以实现上述条件的判断逻辑,进一步强化 Redis 事务的应用能力。
事务操作
MULTI & EXEC
multi
:开启事务exec
:提交事务
示例:
开启事务后,不论输入什么指令,都返回QUEUE
,此时会在客户端维护一个队列,将所有指令入队列。最后执行exec
提交事务,所有的指令再同时返回。
DISCARD
discard
:取消事务
示例:
开启事务后,输入两个值,再通过discard
取消事务,最后查询key1
,发现插入失败,因为这个请求没有提交给客户端,而是直接被取消了。
另外的,如果在事务执行的过程中,Redis
崩溃,恢复后效果和discard
一样,就是事务内的操作都取消了。
WATCH
现有以下场景:
- 从时间上来看,客户端1 是先发送了 set key 222,客户端2 是后发送了 set key 333
- 由于 客户端1 中,得是 exec 执行了,才会真正执行 set key 222 这个操作
- set key 222 变成了实际上更晚执行的操作!! 最终值就是 222
在事务执行过程中,客户端1
无法感知外部的变化,客户端2
的命令被 客户端 1 事务覆盖了。
watch
:让事务可以监听外部的变化,如果监听的数据被外部改变,操作失效
语法:
左侧的终端,在执行事务前开启了watch key
,开启事务后set key 222
。在事务提交前,右侧终端修改了key 333
,随后左侧终端提交事务时,返回了nil
表示事务操作无效。
因为watch
监听到了key
被外部修改,此时自己的事务提交可能会影响其它客户端,于是取消该操作。
如果想要取消watch
,可以使用unwatch
指令:
unwatch
这个指令没有参数,一次性取消所有key
的监听。
WATCH 的实现原理
WATCH 的实现原理类似于我们在并发编程中学习的乐观锁机制,即解决 CAS(Compare-And-Swap)中的 ABA 问题的策略。乐观锁假设冲突很少发生,并在检测到冲突时采取相应的措施。与 ABA 问题中的实现策略相似,Redis 的 WATCH 基于版本号机制实现了乐观锁。
watch使用了一种版本号的机制,每个数据都有一个版本号,每次修改key的值时,都会修改其版本号。
- 在watch key时,会记录当前的版本号。
- 在事务提交时,检测当前的版本号是否与之前的版本号相同,如果相同那么提交成功,如果版本号不同,说明有别的用户修改了数据,导致版本号修改,当前事务将不会执行并返回失败。
WATCH 本质上是给 EXEC 加上了一个对特定 key 的判定条件:只有当所有被 WATCH 的 key 自从 WATCH 开始以来没有被修改过的情况下,事务才会被执行;否则,事务将被取消。
举例说明
可以将 WATCH 比喻为判断老公是否出轨的情景:
- 离开家之前:把枕头放到一个特定的位置,并拍下一张照片作为记录。
- 回家之后:检查枕头的位置是否发生了改变。如果位置变了,则可以推测出在这段时间内有人移动了枕头(在这个比喻中意味着老公可能出轨了)。
同样的逻辑应用于 Redis 中:
- WATCH key3:开始监视 key,此时记录下 key 的状态(版本号)。
- 尝试执行事务:如果在此期间 key 被其他客户端修改过,那么提交事务时就会发现 key 的状态已经改变,从而导致当前事务执行失败。如下就返回了 Nil
乐观锁与悲观锁
- 乐观锁:乐观锁假设在加锁之前,锁冲突的概率较低。因此,在加锁之前不会进行任何检查,而是直接进行操作。如果在操作完成后发现冲突,则会进行重试。
- 悲观锁:悲观锁假设在加锁之前,锁冲突的概率较高。因此,在加锁之前会进行检查,确保没有其他线程正在操作该资源。如果发现冲突,则会阻塞等待。
实现示例
- 在 C++ 和 Linux 中涉及的锁,如 mutex 和 std::mutex,都是悲观锁。
- Java 中的 synchronized 关键字则可以在悲观/乐观之间自适应。
事务命令 sum:
- MULTI:开启一个事务,执行成功返回 OK。
- EXEC:真正执行事务。每个操作加入事务时会提示 "QUEUED",表示命令已经进入客户端的队列,直到 EXEC 执行时才会真正发送给服务器。
- DISCARD:放弃当前事务,清空事务队列,之前的操作都不会真正执行。
- WATCH:用于监控一组具体的 key,在提交事务时如果发现 key 被其他客户端修改过,则事务执行失败。
- UNWATCH:取消对 key 的监控,相当于 WATCH 的逆操作。