Redis 事务
Redis中的事务能够保证一批命令原子性的执行,即所有命令或者 都执行或者都不执行。并且在事务执行过程中不会为任何其他命令提供服务,当Redis重新启动加载AOF文件时也会保证事务命令的完整性
Redis 事务命令
| 命令 | 说明 |
|---|---|
| multi | 显示开启事务 |
| exec | 执行事务 |
| discard | 取消事务 |
| watch key [key ...] | 监听可能被修改的key,如果监视的key在exec之前修改,事务会被取消 |
| unwatch | 取消监听可能被修改的key |
Redis 事务举例
场景一:xm(小明)有1000元 xh(小红)有1000元,xm需要给小红转账100元,执行完成后 xm有900元,xh有1100元
| 时间 | 客户端1 |
|---|---|
| T1 | 127.0.0.1:6379> flushall OK |
| T2 | 127.0.0.1:6379> set xm 1000 OK |
| T3 | 127.0.0.1:6379> set xh 1000 OK |
| T4 | 7.0.0.1:6379> multi OK |
| T5 | 27.0.0.1:6379> decrby xm 100 QUEUED |
| T6 | 127.0.0.1:6379> incrby xh 100 QUEUED |
| T7 | 127.0.0.1:6379> exec 1) (integer) 900 2) (integer) 1100 |
| T8 | 127.0.0.1:6379> get xm "900" |
| T9 | 127.0.0.1:6379> get xh "1100" |
Redis 事务取消
场景一:xm准备支出300元(原来900元减去300元为600元),但是最后取消了(结果还是原来的900元)
| 时间 | 客户端1 |
|---|---|
| T1 | 27.0.0.1:6379> multi OK |
| T2 | 7.0.0.1:6379> decrby xm 300 QUEUED |
| T3 | 7.0.0.1:6379> discard OK |
| T4 | 27.0.0.1:6379> get xm "900" |
场景二:xm已被watch监视,此时xm准备支出300元,此时另一个客户端给xm加了(转账)100元
| 时间 | 客户端1 | 客户端2 |
|---|---|---|
| T1 | 127.0.0.1:6379> flushall OK | |
| T2 | 127.0.0.1:6379> set xm 1000 OK | |
| T3 | 27.0.0.1:6379> watch xm OK | |
| T4 | 127.0.0.1:6379> multi OK | |
| T5 | 127.0.0.1:6379> decrby xm 300 QUEUED | |
| T6 | 127.0.0.1:6379> get xm "1000" | |
| T7 | 127.0.0.1:6379> incrby xm 100 (integer) 1100 | |
| T8 | 7.0.0.1:6379> exec (nil) | |
| T9 | 7.0.0.1:6379> get xm "1100" |
Redis事务异常
执行exec之前出现错误
| 时间 | 客户端1 |
|---|---|
| T1 | 127.0.0.1:6379> flushall OK |
| T2 | 127.0.0.1:6379> multi OK |
| T3 | 127.0.0.1:6379> set xm 1000 QUEUED |
| T4 | 127.0.0.1:6379> set xh 3000 QUEUED |
| T5 | 127.0.0.1:6379> hset xl age (error) ERR wrong number of arguments for 'hset' command //这里出现了语法错误 |
| T6 | 127.0.0.1:6379> exec (error) EXECABORT Transaction discarded because of previous errors. //执行事务,发现事务已被取消,错误指令之前的指令也随之无效 |
| T7 | 127.0.0.1:6379> get xm (nil) |
| T8 | 127.0.0.1:6379> get xh (nil) |
执行exec之后出现错误(运行时异常)
| 时间 | 客户端1 |
|---|---|
| T1 | 127.0.0.1:6379> flushall OK |
| T2 | 127.0.0.1:6379> multi OK |
| T3 | 127.0.0.1:6379> set xm 1000 QUEUED |
| T4 | 127.0.0.1:6379> hset xm age 19 QUEUED |
| T5 | 127.0.0.1:6379> exec 1) OK1) 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value |
| T6 | 127.0.0.1:6379> get xm "1000" //此时发现,由于类型操作异常,导致出现了error,但是事务确实执行了即:OK,让一部分成功了 |
此中情况是否与Redis事务的原子性自相矛盾了?是为什么不支持回滚?
官方文档:Why Redis does not support roll backs?
If you have a relational databases background, the fact that Redis commands can fail during a transaction,==but still Redis will execute the rest of the transaction instead of rolling back==, may look odd to you.
However there are good opinions for this behavior:
1.Redis commands can fail only if called with a wrong syntax (and the problem is not detectable during the command queueing), or against keys holding the wrong data type: this means that in practical terms a failing command is the result of a programming errors, and a kind of error that is very likely to be detected during development, and not in production. 2.Redis is internally simplified and faster because it does not need the ability to roll back.
An argument against Redis point of view is that bugs happen, however it should be noted that in general the roll back does not save you from programming errors. For instance if a query increments a key by 2 instead of 1, or increments the wrong key, there is no way for a rollback mechanism to help. Given that no one can save the programmer from his or her errors, and that the kind of errors required for a Redis command to fail are unlikely to enter in production, we selected the simpler and faster approach of not supporting roll backs on errors.
有两点理由来解释为什么Redis事务不支持回滚。
实际中Redis错误一般不会出现在生产环境中,Redis错误只会是传递了错误的参数类型,而这其中错误会在开发环境中就被发现现。简言之就是:开发者不会故意将错误运行在生产环境,在程序写完之后理应当自检
Redis事务源码分析
事务开始
multi命令只是给代表该命令连接的client结构体置一个CLIENT_MULTI标志位,并且Redis的事务不能嵌套,即不能在一个开启的事务内再次调用multi命令开启一个新事务;
- 源码如下:
typedef struct redisClient{
//......
multiState mstate; /*multi/exec state*/
//......
}redisClient;
void multiCommand(client *c) {
if (c->flags & CLIENT_MULTI) {//如果已经执行过multi命令,则不能再次执行
addReplyError(c,"MULTI calls can not be nested");
return;
}
c->flags |= CLIENT_MULTI; //client结构体置CLIENT_MULTI标志
addReply(c,shared.ok);
}
命令入队
如果客户端发送的命令为exec、discard、watch、multi四个命令其中一个,那么服务器立即执行这个命令
如果客户端发送的命令为exec、discard、watch、multi四个命令以外的其他命令,那么服务器不会立即执行这个命令,而是将这个命令放在一个事务队里面,然后返回复客户端QUEUED回复
其实在processCommand函数中还会进行一系列的校验,例如命令是否存在,命令参数个数是否符合要求,以及如果开启了密码校验,检验是否通过,等等。如果这些校验未通过,Redis仍然是将client结构体中置一个CLIENT_DIRTY_EXEC标志。exec命令执行时会检测client端的标志来决定执 行流程
- 源码如下:
int processCommand(client *c) {
...
//如果client有CLIENT_MULTI标志并且不是exec,discard, multi和watch命令,则将该命令放入队列
if (c->flags & CLIENT_MULTI &&
c->cmd->proc != execCommand &&
c->cmd->proc != discardCommand &&
c->cmd->proc != multiCommand &&
c->cmd->proc != watchCommand)
//放入队列
queueMultiCommand(c);
addReply(c,shared.queued);
} else {
//否则调用call命令
call(c,CMD_CALL_FULL);
...
} ...
}
- 执行流程图如下:
事务队列
每一个客户端都有自己的事务状态,这个事务状态保存在客户端状态的mstate属性里面
typedef struct redisClient{
//......
multiState mstate; /*multi/exec state*/
//......
}redisClient;
事务状态包含一个事务队列,以及一个已入队命令的计数器(也可以说是事务队列的长度)
typedef struct multiState{
// 事务队列,FIFO顺序
multiCmd *commands;
//已入队命令计数
int cout;
}multiState;
事务队列是一个multiCmd类型的数组,数组中的每一个multiCmd结构都保存了一个已入队命令的相关信息,包含指向命令实现函数的指针,命令参数,已及参数数
typedef struct multiCmd{
// 参数
robj **argv;
//参数数量
int argv;
// 命令指针
}multiCmd;
事务队列以先进先出(FIFO)的方式保存入队的命令,较先入队的命令会被放到数组的前面,而较后入队的命令则会被放在数组的后面
执行事务
当一个处于事务状态的客户端向服务器发送exec命令时,这个exec命令将立即被服务器执行。服务器会遍历这个客户端的事务队列,执行队列中保存的所有命令,最后将执行命令所得的结果全部返回给客户端
void execCommand(client *c) {
...
if (!(c->flags & CLIENT_MULTI)) { //检测是否有CLIENT_MULTI标志
addReplyError(c,"EXEC without MULTI"); //事务未开启,直接返回错误
return;
}
if (c->flags & (CLIENT_DIRTY_CAS|CLIENT_DIRTY_EXEC)) {
//是否有CLIENT_DIRTY_CAS和CLIENT_DIRTY_EXEC标志
discardTransaction(c); //放弃事务 ...
}unwatchAllKeys(c); //unwatch所有的key
...
//依次调用每条入队的命令
call(c,server.loading ? CMD_CALL_NONE : CMD_CALL_FULL);
...
}
放弃事务
redis中使用discard命令显式放弃一个事务。discard命令会让Redis放弃以multi开启的事务。返回值为ok
放弃一个事务时首先会将所有入队命令清空,然后将client上事务相关的flags清空,最后将所有监听的keys取消监听
void discardCommand(client *c) {
if (!(c->flags & CLIENT_MULTI)) { //是否已经开启事务
addReplyError(c,"DISCARD without MULTI");
return;
}
}
discardTransaction(c); //放弃事务
addReply(c,shared.ok);//返回ok
WATCH命令监视数据库键
整个Redis服务默认是0-15个数据库,每个Redis数据库都保存着一个watch_keys字典,这个字典的键是某个被WATCH命令监视的数据库键,而字典的值则是一个链表,链表中记录了所有监视相应的数据键的客户端
typedef struct redisDb {
dict *dict;
dict *expires;
dict *blocking_keys;
dict *ready_keys;
dict *watched_keys; //watch的键和对应的client,主要用于事务
int id;
long long avg_ttl;
list *defrag_later;
} redisDb;
WATCH命令监视机制触发
所有对数据进行修改的命令,如:set、lpush、sadd、zrem、del、flushall等等,在执行之后都会调用touchWatchedKey函数对watch_keys字典进行检验,查看是否有客户端正在监视刚刚被命令修改过的数据库键,如果有的话,那么touchWatchedKey函数会将监视被修改键的客户端的REDIS_DIRTY_CAS标识打开,表示该客户端的事务安全性已被破坏
void touchWatchedKey(redisDb *db, robj *key) {
...
//如果客户端没有监视的key
if (dictSize(db->watched_keys) == 0) return;
//查看被修改的key是否处于监听状态
clients = dictFetchValue(db->watched_keys, key);
if (!clients) return;
listRewind(clients,&li);
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
c->flags |= CLIENT_DIRTY_CAS; //否则依次遍历链表,设置标志CLIENT_DIRTY_CAS
}
...
}
Redis 发布与订阅
Redis的发布-订阅功能解耦了生产者和消费者,生产者可以向指定的channel发送消息而无须关心是否有消费者以及消费者是谁,而消费者订阅指定的channel之后可以接收发送给该channel的消息,也无须关心由谁发送
Redis发布订阅命令
| 命令 | 说明 |
|---|---|
| subscribe channel [channel ...] | 订阅频道,channel并非需要单独创建,而知随着subscribe channel 命令创建 |
| unsubscribe [channel [channel ...]] | 取消订阅 |
| publish | 推送消息 |
Redis发布订阅举例
场景一:客户端1 订阅channel-1 channel-2 channel-3,客户端2 向channel-1发布消息
| 时间 | 客户端1 | 客户端2 |
|---|---|---|
| T1 | 127.0.0.1:6379>subscribe channel-1 channel-2 channel-3 Reading messages... (press Ctrl-C to quit) 1) "subscribe" 2) "channel-1" 3) (integer) 1 1) "subscribe" 2) "channel-2" 3) (integer) 2 1) "subscribe" 2) "channel-3" 3) (integer) 3 | |
| T2 | 127.0.0.1:6379> publish channel-1 hello (integer) 1 | |
| T3 | 1) "message" 2) "channel-1" //消息来自哪个频道 3) "hello" //消息内容 | |
| T4 | 127.0.0.1:6379> publish channel-2 nihao (integer) 1 | |
| T5 | 1)"message" 2) "channel-2" 3) "nihao" | |
| T6 | 127.0.0.1:6379> publish channel-3 java (integer) 1 | |
| T7 | 1) "message" 2) "channel-3" 3) "java" |
按照规则(pattern)订阅频道
场景二:
频道: news-sport ;news-music; news-weather
客户端1,订阅运动信息(*sport),客户端2,订阅所有新闻( *news),客户端3,订阅天气新闻(new-weather),客户端4,发布信息
| 时间 | 客户端1 | 客户端2 | 客户端3 | 客户端4 |
|---|---|---|---|---|
| T1 | 127.0.0.1:6379> psubscribe **sport Reading messages... (press Ctrl-C to quit) 1) "psubscribe" 2) "*sport" 3) (integer) 1 | 127.0.0.1:6379> psubscribe *news Reading messages... (press Ctrl-C to quit) 1) "psubscribe" 2) "news" 3) (integer) 1 | 127.0.0.1:6379> psubscribe news-weather Reading messages... (press Ctrl-C to quit) 1) "psubscribe" 2) "news-weather" 3) (integer) 1 | |
| T2 | 127.0.0.1:6379> publish news-sport nike (integer) 2 | |||
| T3 | 1) "pmessage" 2) "*sport" 3) "news-sport" 4) "nike" | 1) "pmessage" 2) "news*" 3) "news-sport" 4) "nike" | ||
| T4 | 1) "pmessage" 2) "news*" 3) "news-music" 4) "liangbo" | 127.0.0.1:6379> publish news-music liangbo (integer) 1 | ||
| T5 | 127.0.0.1:6379> publish news-weather sunny (integer) 2 | |||
| T6 | 1) "pmessage" 2) "news*" 3) "news-weather" 4) "sunny | 1) "pmessage" 2) "news-weather" 3) "news-weather" 4) "sunny" |
Redis Lua脚本
Redis嵌入的Lua脚本功能非常强大,它不仅可以运行Lua代码,还能保证脚本以原子方式执行:在执行脚本时不会执行其他脚本或Redis命令。这种语义类似于MULTI/EXEC,也就是说我们 可以编写带逻辑操作的Lua代码让Redis服务端执行,这解决了Redis命令执行之间非原子性的问题
Redis Lua脚本命令
- eval script numkeys key [key ...] arg [arg ...]
| 属性 | 说明 |
|---|---|
| eval | 代表执行lua语言的命令 |
| lua-script | 代表所执行lua脚本的内容 |
| key-num | 表示参数中有多少个key,需要注意的是Redis中的key是从1开始的,如果没有key的参数,写0 |
| key [key ...] | 是key作为参数传递给lua语言,也可以不填,但是需要和key-num的个数对应起来 |
| arg [arg ...] | 传递给lua语言的参数,可以不填写 |
Redis Lua脚本举例
| 时间 | 客户端1 |
|---|---|
| T1 | 127.0.0.1:6379> eval "return 'hello world'" 0 //简单输出hello world |
| 时间 | 客户端1 |
|---|---|
| T1 | 127.0.0.1:6379> set k1 10 OK |
| T2 | 127.0.0.1:6379> set k2 3 OK |
| T3 | 127.0.0.1:6379> eval "local v1 = redis.call('get',KEYS[1]);return redis.call('incrby',KEYS[2],v1)" 2 k1 k2 3 (integer) 13 |
| T4 | 127.0.0.1:6379> get k2 "13" |
场景一:创建xxx.lua文件,执行lua脚本
- 创建lua文件,不带参数
- redis-cli --eval /usr/local/soft/lua-redis/lua1.lua
//lua 文件内容
redis.call('set','java','100');
return redis.call('get','java');
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua1.lua
"100"
场景二:创建xxx.lua文件,带参数,执行lua脚本
- 创建lua文件,对IP限流,6s内访问是否超过10次
- redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua
//对IP限流 lua脚本内容
//计数+1(key若不存在可创建)
local num = redis.call('incr',KEYS[1]);
if tonumber(num)==1 then
//第一次访问,用第一个参数,设置过期时间
redis.call('expire',KEYS[1],ARGV[1])
return 1
//不是第一次访问,跟第二个参数比较是否超出限制
elseif tonumber(num) >tonumber(ARGV[2])then
return 0 //超限了
else
return 1 //未超限
end
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 1
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 0
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 0
[root@localhost ~]# redis-cli --eval /usr/local/soft/lua-redis/lua-arg.lua app:127.0.0.1 , 6 10
(integer) 0
Redis Lua脚本缓存
| 命令 | 说明 |
|---|---|
| script load | 执行此命令后Redis会返回一个,缓存当前指令的ID |
| evalsha sha1 numkeys key [key ...] arg [arg ...] | 与eval命令执行方式相同,只是前面的脚本换成缓存的ID |
127.0.0.1:6379> script load "return 'hello wolrd'"
"bd98d8093f7488b91dd2650ec646e0c127eeed29"
127.0.0.1:6379> evalsha bd98d8093f7488b91dd2650ec646e0c127eeed29 0
"hello wolrd"
场景一:自乘案例
local curVal = redis.call("get",KEYS[1]);
if cuVal == false then
curVal=0
else
curVal = tonumber(curVal)
end;
curVal = curVal*tonumber(ARGV[1]);
redis.call("set",KEYS[1],curVal);
return curVal
127.0.0.1:6379> script load 'local curVal = redis.call("get",KEYS[1]);if curVal == false then curVal=0 else curVal = tonumber(curVal) end;curVal = curVal*tonumber(ARGV[1]);redis.call("set",KEYS[1],curVal);return curVal'
"138375d6d6b9a8dbe94c0c36fd37ac1b896c8669"
127.0.0.1:6379> set num 2
OK
127.0.0.1:6379> evalsha 138375d6d6b9a8dbe94c0c36fd37ac1b896c8669 1 num 6
(integer) 12
127.0.0.1:6379> get num
"12"
Redis Lua脚本中断
Redis执行lua脚本具有排他性,也就是说:在执行脚本时不会执行其他脚本或Redis命令;
Redis lua脚本中断可提供两个命令:
- SCRIPT KILL
- SHUTDOWN NOSAVE
SCRIPT KILL
场景一: 客户端1:执行lua脚本,客户端2:执行redis命令
| 时间 | 客户端1 | 客户端2 |
|---|---|---|
| T1 | 127.0.0.1:6379> eval 'while(true) do end' 0 //模拟客户端1一直执行,未退出 | |
| T2 | 127.0.0.1:6379> set name yy (error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE. | |
| T3 | // 然后按照提示 执行SCRIPT KILL 127.0.0.1:6379> SCRIPT KILL OK | |
| T4 | (error) ERR Error running script (call to f_eec1f08dafc6bfdf256e3820d971514a3a24267e): @user_script:1: Script killed by user with SCRIPT KILL... (103.37s) |
SHUTDOWN NOSAVE
场景二:客户端1:执行lua脚本,客户端2:执行redis命令
| 时间 | 客户端1 | 客户端2 |
|---|---|---|
| T1 | 127.0.0.1:6379> eval " redis.call('set','name','tom') while(true) do end " 0 //模拟客户端1一直执行,未退出,但是该批量命令里有修改指令存在 | |
| T2 | 127.0.0.1:6379> set name jack (error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE. | |
| T3 | 127.0.0.1:6379> SCRIPT KILL (error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command. //此时发现SCRIPT KILL 不能停止脚本运行;因为之前还修改了key:name 的值,lua脚本为了保证批量命令的原子性,只能通过SHUTDOWN NOSAVE命令来结束脚本 | |
| T4 | 127.0.0.1:6379> SHUTDOWN NOSAVE //当执行完SHUTDOWN NOSAVE也就也未知 redis服务关闭了,所以这个是一个具有破坏性的命令对于线上环境 not connected> exit | |
| T5 | //客户端执行完SHUTDOWN NOSAVE Could not connect to Redis at 127.0.0.1:6379: Connection refused (41.40s) |
SCRIPT KILL 与 SHUTDOWN NOSAVE执行情况有何不同?为何如此设计?
SCRIPT KILL:在lua脚本中,该组命令中若不存在使key发生变化的命令,可通过其结束,并且不会对Redis服务产生影响
SHUTDOWN NOSAVE:在lua脚本中,该组命令中若存在使key发生变化的命令,可通过其结束,而且是关闭Redis服务
lua脚本如此设计是为了保证批量命令的原子性