1 Redis 网络事件处理
redis 是单线程处理逻辑,网络事件处理以及命令处理都是在这个线程当中进行的。如果是只存在一个连接,那么不管你输入多少命令,操作都是具有原子性。
但是如果多个连接下,某个连接想按照原子性的执行多条redis命令,那么可能会被其它连接的命令插队,从而使这多条连接无法原子性执行。
简述Redis网络事件处理:
我们知道,网络双方连接是全双工的,每个连接下,双方各自有一对读写缓冲区(即下图的r、w)。连接1可能是我们程序的某个线程1的连接,连接2可能是另一个线程的连接,也有可能是其它程序的连接。
当连接1想要原子的执行多条命令,此时连接2刚好发送一条命令,redis服务器是个单线程的,会轮询这些连接去读取命令保存到redis服务器的队列当中,然后再执行。如果刚好轮询到这条命令,而连接1的命令未读取完,那么就不具备原子操作。
所以多条连接时,某个连接想原子的执行多条命令是不安全的,这里是针对执行多条命令,如果是执行单条命令,那么即使存在多条redis连接,也是原子性的,因为redis本身就是单线程。
如果想确保多条连接下,某个连接执行多条命令的原子性,那么就需要使用到redis事务。
对于redis具体如何实现原子性,可自行查看源码理解,不过个人猜测做法应该是:当检查到连接开启事务的操作,那么这多条命令先入队列,把这些命令全部入队完毕后,再轮询其它连接的命令。 当然中间可能出现入队错误或者入队超时等,但这些是redis的处理,反正大致猜想,具体有兴趣的可自行观看源码找答案(不过不建议,太浪费时间)。
2 Redis 事务
MULTI 开启事务,事务执行过程中,单个命令是入队列操作,直到调用 EXEC 才会一起执行。
下面来看一下事务的四个关键命令。
- 1)MULTI:开启事务。
- 2)EXEC:提交事务。
- 3)DISCARD:取消事务。
- 4)WATCH:检测key的变动,若在事务执行中,key变动则取消事务,若事务被取消则返回 nil 。在事务开启前调用,乐观锁实现(cas)。
3 应用
该应用主要说明,当在事务开启前使用watch观察某个key,开启事务后,当该key的值发生变化,那么事务会被取消并返回nil。
下面看到,tyy这个key在事务执行过程中并未用到,但是只要在事务执行过程中,有watch的key的值被改变(本人验证过,多个watch时,只要其中有一个值改变也会取消事务),事务就被取消,那么这个事务就有点冤枉了。所以以后写代码过程中需要注意。
# 若存在这两个key,先删除,防止影响下面的测试。
192.168.1.9:6379> del tyy lqq
(integer) 2
# 1. 首先设置一个key
192.168.1.9:6379> set tyy 1000
OK
# 2. 然后检测key的值是否有变动。
192.168.1.9:6379> watch tyy
OK
# 3. 确定一遍执行事务前的key的值。
192.168.1.9:6379> get tyy
"1000"
# 3. 开启事务。
192.168.1.9:6379> MULTI
OK
# 4. 执行事务。可以看到打印的QUEUED,代表入队列而为执行该命令。
192.168.1.9:6379(TX)> set lqq 2000
QUEUED
# 5. 开启另一个新的redis连接,输入命令,以改变watch观察的key的值有变动。
# 注意,即使本命令与第4步顺序换过来,结果也是一样,事务同样被取消。因为你已经开启了事务。
192.168.1.9:6379> set tyy 100
OK
# 6. 回到原来的连接,提交事务。
# 观察到事务中的命令并未成功执行,而是被取消了,因为EXEC返回nil了。也可以看到lqq这个key并不存在。
192.168.1.9:6379(TX)> EXEC
(nil)
192.168.1.9:6379> EXISTS lqq
(integer) 0
192.168.1.9:6379>
# 7. 此时看tyy这个key肯定是被改变的。
192.168.1.9:6379> get tyy
"100"
192.168.1.9:6379>
4 代码演示
package main
import (
"fmt"
"time"
"github.com/garyburd/redigo/redis"
)
func main() {
// 1. 连接到redis服务器。
//c, err := redis.Dial("tcp", fmt.Sprintf("%s:%d", "127.0.0.1", 6379))
c, err := redis.Dial("tcp", "192.168.1.9:6379", redis.DialPassword("123456"))
if err != nil {
panic(err)
}
defer (func() {
fmt.Println("connection close")
c.Close()
})()
// 2. 事务的模拟。
// 2.1)Send和Do里面的set和watch两条命令,只需要发送一次就能到达redis的缓冲区。具体看第08篇--管道使用技巧。
c.Send("set", "score", 1000) // 这个只会先发送到redigo的缓冲区,Flush完才发送到redis的缓冲区。
c.Do("watch", "score")
// 2.2)先保存值,用于判断事务是否被取消。
score, _ := redis.Int(c.Do("get", "score"))
scoreCpy := score
//这个睡眠主要是让我们有时间收到开启另一条连接改变watch score的值。
if false {
time.Sleep(time.Second * 20)
// 如果开启事务,先开启上面的开关,把 false 修改为 true
// 同时在另外一条连接修改 score 的值
}
// 2.3)开启事务。和2.1)同理,只需要发送一次就到redis的缓冲区。
c.Send("multi")
c.Send("set", "score", score*2)
c.Do("exec")
// 2.4)获取开启事务后的值。
score, _ = redis.Int(c.Do("get", "score"))
fmt.Println("score-old:", scoreCpy, "\tscore-new:", score)
}
// 上面看到,管道pipeline(2.1,2.3体现了管道) + 事务可以实现我们在go中执行多条命令的原子性。
// 但是管道本身不具有原子操作,只是单纯提高传输性能,而原子操作还是由事务和下节讲的lua脚本实现。
-
1)首先,我们不睡眠,直接运行上面代码:
可以看到事务是执行成功的,因为值变成两倍了,这符合我们的预期。但这里我想强调一点,就是在本连接的事务中改变watch的值,不算key的值被改变,事务仍然会成功执行。 如果是在其它连接中改变了这个watch的值,那么事务会被取消。
-
2)将上面睡眠打开即false改成true,然后再xshell开启一条新的连接,改变score的值。例如:
set score 100
打印结果看到,事务开启后,假设事务执行成功,那么score的值应变为1000的两倍或者100的两倍(假设事务不被取消,那么redis官方肯定会选择这两种结果之一进行返回),但是结果是100,说明事务被取消了。