Redis 的事务和 MySQL 的事务概念上是类似的,都是把一系列操作绑定成一组,让这一组能够批量执行。
一、Redis 的事务和 MySQL 事务的区别
1、MySQL 事务
- 原子性:把多个操作打包成一个整体。(要么全都做,要么都不做)
- ⼀致性:事务执行前和执行后,数据得保持相同。
- 隔离性:事务并发执行涉及到的一些问题(脏读、幻读等)。
- 持久性:事务中做出的修改都会存储到硬盘中。
2、Redis 的事务
- 弱化的原子性:redis 没有 “回滚机制”,只能做到这些操作 “批量执行”,不能做到 “一个失败就恢复到初始状态”,也就是无法保证执行成功。(网上有的说 Redis 事务有原子性(只是打包一起执行),有的说没有原子性(打包一起执行 + 带有回滚 —— 打包一起正确执行))
- 不保证⼀致性:不涉及 “约束”,也没有回滚(MySQL 的一致性体现的是运行事务前和运行后,结果都是合理有效的,不会出现中间非法状态)。事务在执行过程中如果某个修改操作出现失败,就可能引起不一致的情况。
- 不需要隔离性:也没有隔离级别,因为不会并发执行事务(Redis 是一个单线程模型的服务器程序,所有的请求 / 事务都是 “串行” 执行的)。
- 不需要持久性:Redis 本身就是内存数据库,数据是存储在内存中的。虽然 Redis 也有持久化机制,但是否开启持久化是 redis-server 自己的事情,和事务无关。
Redis 事务本质上是在服务器上搞了⼀个 “事务队列”,每次客户端在事务中进行一个操作,都会把命令先发给服务器,放到 “事务队列” 中,但并不会立即执行,而是在收到 EXEC 命令后,才按照顺序依次执行队列中的所有操作(在 Redis 主线程中完成的,主线程会把事务中的操作都执行完,再处理别的客户端)。
因此,Redis 的事务的功能相比于 MySQL 来说,是弱化很多的。只能保证事务中的这几个操作是 “连续的”,不会被别的客户端 “加塞”,仅此而已。
为什么 Redis 不设计成和 MySQL 一样强大呢?
MySQL 的事务付出了很大的代价:
- 在空间上,需要花费更多的空间来存储更多的数据。
- 在时间上,也要有更大的执行开销。
正是因为 Redis 简单、高效的特点,才能够在分布式系统中弥补一些 MySQL 不擅长的场景。
什么时候需要使用到 Redis 事务呢?
如果我们需要把多个操作打包进行,使用事务是比较合适的。之前在多线程中是通过加锁的方式来避免 “插队” 的,而在 Redis 中直接使用事务即可。
在上面这个场景没有加锁也能解决问题。
Redis 命令里能够进行类似上图中的条件判断吗?
Redis 原生命令中确实没有这种条件判断,但是 Redis 支持 lua 脚本。通过 lua 脚本就可以实现上述的条件判定,并且也和事务一样是打包批量执行的。
lua 脚本的实现方式是 Redis 事务的进阶版本,此处对 lua 脚本不做过多的讨论。
注意:如果 Redis 是按照集群模式部署的话,是不支持事务的。
二、事务操作
1、MULTI
开启一个事务,执行成功返回 OK。
2、EXEC
真正执行事务。
每次添加一个操作,都会提示 "QUEUED",说明命令已经进入客户端的事务队列中。此时如果另外开一个客户端,再尝试查询这几个 key 对应的数据,是没有结果的:
只有当真正执行 EXEC 的时候,客户端才会真正把上述操作发送给服务器,此时就可以获取到上述 key 的值了。
此时,另一个客户端再次查询结果也是如此。
3、DISCARD
放弃当前事务,此时直接清空事务队列,之前的操作都不会真正执行到。
当开启事务并给服务器发送若干个命令之后,服务器重启,那么此时这个事务怎么办呢?
此时的效果就等同于 discard。
4、WATCH
在执行事务的时候,如果某个事务中修改的值被别的客户端修改了,此时就容易出现数据不一致的问题。
客户端 1 先执行:
客户端 2 再执行:
客户端 1 最后执行:
此时 key 的值是多少呢?
从输入命令的时间看,是客户端 1 先执行的 set key 222,客户端 2 后执行的 set key 333。但是从实际的执行时间来看,是客户端 2 先执行的,客户端 1 后执行的。
由于客户端 1 得是 exec 执行了,才会真正执行 set key 222,所以这个操作实际上更晚执行,最终值就是 222.
这个时候其实就容易引起歧义。因此,即使不保证严格的隔离性,至少也要告诉用户,当前的操作可能存在风险。watch 命令就是用来解决上述这个问题的,watch 在该客户端上监控一组具体的 key,看看这个 key 在事务的 multi 和 exec 之间,set key 之后,是否在外部被其他客户端修改了。
- 当开启事务的时候,如果对 watch 的 key 进行修改,就会记录当前 key 的 “版本号”(版本号可以理解成一个整数,每次修改都会使版本变大,服务器来维护每个 key 的版本号情况)
- 在真正提交事务的时候,如果发现当前服务器上的 key 的版本号已经超过了事务开始时的版本号,就会让事务执行失败(事务中的所有操作都不执行)。
客户端 1 先执行:
watch 本质上是给 exec 加一个判定条件。
key 进行修改,从服务器获取 key 的版本号是 0,记录 key 的版本号(还没真的修改,版本号不变)
这里只是入队列,但是不提交事务执行。
客户端 2 再执行:
修改成功,使服务器端的 key 的版本号 0 -> 1
客户端 1 最后执行:
exec 在执行上述事务中的命令时,此处就会做出判定。对比版本发现客户端的 key 的版本号是 0,服务器上的版本号是 1,版本不一致,说明有其他客户端在事务中间修改了 key,说明事务被取消了,于是真正执行 set key 222 的时候就没有真正执行。
客户端 2 执行:
(1)watch 的实现原理
watch 的实现类似于一个 “乐观锁”(不是指某个具体的锁,而指的是某一类锁的特性)。
- 乐观锁(成本低):加锁之前就有一个心理预期,预期接下来锁冲突的概率比较低。
- 悲观锁(成本高):加锁之前就有一个心理预期,预期接下来锁冲突(两个线程针对同一个锁加锁,一个能加锁成功,另一个就得阻塞等待)的概率比较高。
锁冲突概率高和冲突概率低,意味着接下来要做的工作是不一样的。
C++ Linux 中涉及到的锁 mutex / std::mutex 都是悲观锁,Java synchronized 则是可以在悲观和乐观之间自适应。
5、UNWATCH
取消对 key 的监控,相当于 WATCH 的逆操作。