Redis通过MULTI、EXEC、WATCH等命令来实现事务功能。事务提供了一种将多个命令请求打包,然后一次性、按顺序的执行多个命令的机制,并且在事务执行期间,服务器不会中断事务而改去执行其他客户端的命令请求,它会将事务中的所有命令都执行完毕,然后才会处理其他客户端的命令请求。
基本用法
每个事务的操作指令都有begin、commit和rollback:
- begin表示事务的开始
- commit表示事务的提交
- rollback表示事务的回滚
redis同理,对应指令为multi、exec、discard
- multi表示事务的开始
- exec表示事务的执行
- discard表示事务的丢弃
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set "name" "Practical Common Lisp"
QUEUED
127.0.0.1:6379> get "name"
QUEUED
127.0.0.1:6379> set "author" "Peter Seibel"
QUEUED
127.0.0.1:6379> get "author"
QUEUED
127.0.0.1:6379> exec
1) OK
2) "Practical Common Lisp"
3) OK
4) "Peter Seibel"
discard(丢弃)
redis为事务提供了一个discard指令,用于丢弃事务缓存队列中的所有指令,在exec执行之前
我们可以看到,在discard之后,队列中的所有指令都没有执行,就好像multi和discard中间的所有指令从未发生过一样。
优化:事务与pipeline
上面的redis事务在发送每个指令到事务缓存队列时都要经过一次网络读写,当一个事务内部的指令较多时,需要的网络IO事件也会线性增长
所以通常redis的客户端在执行事务时都会结合pipeline一起使用,这样可以将多次IO操作压缩为单次IO操作。
watch
- watch命令是一个乐观锁,它可以在exec命令执行之前,监视任意数量的数据库键,并在exec命令执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复。
看个例子:
- 终端A
127.0.0.1:6379> watch "name"
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set "name" "peter"
QUEUED
- 终端B
127.0.0.1:6379> set "name" "join"
OK
- 终端A
127.0.0.1:6379> exec
(nil)
在时间T4,客户端B修改了“name”键的值,当客户端A在T5执行EXEC命令的时候,服务器会发现WATCH监视的"name"已经被修改,因此服务器拒绝执行客户端A的事务,并向客户端A返回nil
悲观锁: 当要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。这种借助数据库锁机制,在修改数据之前先锁定,再修改的方式被称之为悲观并发控制
乐观锁: 在提交结果的时候发现自己修改失败了,会通知执行这个修改的客户端,不会阻止其他数据进行修改
事务的ACID性质
原子性
所谓原子性,就是服务器要么执行事务中的所有操作,要么一个操作也不执行
看个例子
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set books iamastring
QUEUED
127.0.0.1:6379> incr books
QUEUED
127.0.0.1:6379> set poorman inadeepsyi
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
127.0.0.1:6379> get books
"iamastring"
127.0.0.1:6379> get poorman
"inadeepsyi"
127.0.0.1:6379>
上面例子中,事务执行到中间时识别了,因为我们不能对一个字符串进行数学运送,但是事务在遇到指令失败后,后面的指令还会继续执行。
从上面可以看出,redis的事务不是“原子性”的,而是仅仅满足了事务的“隔离性”中的串行化-----当前执行的事务有着不被其他事务打断的权利
但是,其实redis的事务是满足原子性。
成功的事务:
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set msg "hello"
QUEUED
127.0.0.1:6379> get msg
QUEUED
127.0.0.1:6379> exec
1) OK
2) "hello"
失败的事务:某个事务因为命令入队出错而被服务器拒绝执行,因此整个事务都不执行了
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set msg "hello"
QUEUED
127.0.0.1:6379> get
(error) ERR wrong number of arguments for 'get' command
127.0.0.1:6379> get msg
QUEUED
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
Redis事务和其他关系型数据库最大的区别是:Redis不支持事务回滚机制,即使某个命令出错,整个事务也会继续执行下去(一个失败,接下来全部失败,而且之前的设置也失败),直到事务结束
127.0.0.1:6379> get msg
(nil)
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set msg "hello"
QUEUED
127.0.0.1:6379> ;
(error) ERR unknown command `;`, with args beginning with:
127.0.0.1:6379> get msg
QUEUED
127.0.0.1:6379> set msg "world"
QUEUED
127.0.0.1:6379> get msg
QUEUED
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6379> get msg
(nil)
一致性
入队错误
如果一个事务在入队命令的过程中,出现了命令不存在,或者命令的格式不正确等,那么redis将拒绝执行这个事务
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set msg 'okkkk'
QUEUED
127.0.0.1:6379> qqqqq
(error) ERR unknown command `qqqqq`, with args beginning with:
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors. #服务器拒绝执行这个错误
执行错误
- 执行过程中发生的错误都是一些不能在入队时发现的错误,这些错误只会在命令实际执行时被触发
- 即使事务执行过程中出错,事务也会继续执行其他命令,只是整个事务会失败而已
127.0.0.1:6379> set msg 'ok'
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> rpush msg 'good' 'bad'
QUEUED
127.0.0.1:6379> exec
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get msg
"ok"
服务器停止
如果redis服务器在执行事务的过程中停机,那么根据服务器所使用的持久化模式,可能有以下情况出现:
- 如果服务器允许在无持久化内存模式下,那么重启之后的数据库将是空白的,因此数据总是一致的
- 如果服务器运行在RDB模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的RDB文件来恢复数据,从而将数据库还原到一致状态。如果服务器找不到可供使用的RDB文件,那么重启数据库是空白的,空白数据库总是一致的
- 如果服务器运行在AOF模式下,那么在事务中途停机不会导致不一致性,因为服务器可以根据现有的AOF文件来恢复数据,从而将数据库还原到一致状态。如果服务器找不到可供使用的AOF文件,那么重启数据库是空白的,空白数据库总是一致的
隔离性
事务的隔离性指的是,即使数据库有多个事务并发的执行,各个事务之间也不会相互影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同
一个完整的事务过程:
- 所有的指令在exec之前不执行,而是缓存在服务器中的一个事务队列中
- 服务器一旦收到exec指令,才开始执行整个事务队列
- 执行完毕后一次性返回所有指令的运行结果。
因为redis使用单线程的方式来执行事物,并且服务器保证在执行事务期间不会对事务进行中断,它们不用担心自己在执行队列的识别被其他指令打扰,因此,Redis的事务总是以串行的方式运行的,事务具有隔离性
下图显示了以上事务过程的完整交互结果。QUEUED是一个简单字符串,同OK是一个形式,表示指令已经被服务器缓存到队列里面了
耐久性
事务的耐久性指的是:当一个事务执行完毕时,执行这个事务所得的结果已经被保存到永久性存储介质(比如硬盘)里面了,即使服务器在事务执行完毕之后停机,执行事务所得的结果也不会丢失
Redis事务的耐久性由Redis所使用的持久性决定:
- 如果服务器在无持久性的内存模式下运行,无耐久性
- 如果服务器在RDB持久化模式下运行时,服务器只有在特定的保存条件被满足时,才会执行
BGSAVE
命令,对数据库进行保存操作,并且异步执行BGSAVE
不能保证事务数据被第一时间保存在硬盘里面,因此RDB模式下的事务也不具有耐久性
事务实现(multi
开始,exec
结束)的原理
该事务首先以一个MULTI命令开始,接着将多个命令放入事务中,最后有Exec命令将这个事务提交给服务器执行
事务开始
- multi命令的执行标志事务的开始
127.0.0.1:6379> multi
OK
- multi可以将执行该命令的客户端从非事务状态切换到事务状态, 这一切换是通过在客户端状态的
flags
属性中打开REDIS_MULTI
标志完成的,MULTI
命令的实现可以用以下伪代码来表示
命令入队
- 当一个客户端处于非事务状态时,这个客户端发送的命令会立刻被服务器执行
- 当一个客户端处于事务状态时:
- 如果客户端发送的命令是exec、discard、watch、multi,服务器会立刻执行命令
- 如果客户端发送的命令不是exec、discard、watch、multi,服务器会将命令放入事务队列中,然后向客户端返回queued回复
执行事务
当一个处于事务状态的客户端向服务端发送exec命令时,这个exec命令将立即被服务器执行。
服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端
番外:事务队列
每个Redis客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面
typedef struct redisClient{
// ...
multiState mstate // 事务状态: MULTI/EXEC state
}
事务状态包含一个事务队列,以及一个已入队命令的计数器(也就是事务队列的长度)
typedef struct multiState{
multiCmd *commands; // 事务队列,FIFO顺序
int count; // 已入队命令计数
}
事务队列是一个multiCmd类型的数组,数组中每个multiCmd结果都保存了一个已入队命令的相关信息,包括指向命令的实现函数的指针、命令的参数,以及参数的数量
typedef truct multiCmd{
robj **argv; // 参数
int argc; // 参数数量
struct redisCommand *cmd; //命令指针
}
事务队列以先进先出FIFO的方式入队的命令,先入队的命令会被放到数组的前面,后入对的命令会被放到数组的后面
watch实现机制
使用watch命令监视数据库键
每个redis数据库都保存着一个watched_keys字典,
这个字典的键是某个被WATCH
命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应数据库键的客户端。
tyedef struct redisDb{
//---
dict *watched_keys; //正在被WATCH命令监视的键
}
通过执行watch
命令,客户端可以在watched_keys
字典中与被监视的键进行关联。
如图: 从watched_key字典可以看出:
- 客户端c1和c2正在监视键“name’”
- 客户端c3正在监视键“age”
- 客户端c2和c4正在监视键"address"
如图:当前客户端c10086执行WATCH "name" "age"
之后,字典更新
监视机制的触发
所有对数据库进行修改的命令,在执行之后都会调用multi,c/touchWatchKey
函数对watched_keys
字典进行检查,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchKey
函数会将监视被修改键的客户端的REDIS_DIRTY_CAS
标记打开,表示该客户端的事务安全性已经被破坏
判断事务是否安全
当服务器收到一个客户端发来的EXEC
命令时,服务器会根据这个客户端是否打开了REDIS_DIRTTY_CAS
标识来决定是否执行事务
、