redis事务机制

redis中的事务是一组命令的集合。使用MULTI命令来开启事务,这期间的所有命令都会被放到一个命令队列里,使用EXEC命令来触发事务,将队列中的所有命令执行。

127.0.0.1:6379> multi
OK
127.0.0.1:6379> lpush nums 1
QUEUED
127.0.0.1:6379> lpush nums 3
QUEUED
127.0.0.1:6379> blpop num 0
QUEUED
127.0.0.1:6379> exec
1) (integer) 1
2) (integer) 2
3) (nil)
127.0.0.1:6379> lrange nums 0 -1
1) "3"
2) "1"

几个需要注意的地方:

  1. redis的事务没有像mysql一样提供回滚(rollback),比较简洁,开发者需要自行做事务执行失败后处理;
  2. 如果在一个事务中的命令执行出现错误那么所有命令都不会执行
  3. 如果在一个事务中运行出现错误那么正确的命令会被执行!!
  4. MULTI和EXEC命令是一起执行的,所以并不能将一个命令的执行结果作为另一个命令的参数

WATCH、UNWATCH、DISCARD命令

redis提供了WATCH命令来保证原子性的操作,WATCH可以监控一个或多个键,如果在一个事务开启之前,一旦监控的键被修改或删除,之后的事务就不会再执行,监控会持续到EXEC命令执行完,被监控的键会自动UNWATCH。

127.0.0.1:6379> watch nums
OK
127.0.0.1:6379> lpush nums 6
(integer) 5
127.0.0.1:6379> multi
OK
127.0.0.1:6379> lpush nums 7
QUEUED
127.0.0.1:6379> exec
(nil)  //执行事务失败

需要注意的是,WATCH监控开启后,监控的键没被修改或删除,在事务中是可以对监控的键进行修改的。

127.0.0.1:6379> watch mykey
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set mykey 4
QUEUED
127.0.0.1:6379> exec
1) OK

UNWATCH命令可以在WATCH命令执行之后、MULTI命令执行之前取消对某个键的监控

DISCARD命令可以在MULTI命令执行之后,EXEC命令执行之前,取消WATCH监控的键和情况命令队列,然后从事务状态中退出

分布式锁

redis的事务只会在数据被其他客户端抢先修改的情况,通知执行MULTI、EXEC这些命令的客户端,让它撤销对数据的修改操作,但是,并不能阻止其他客户端对数据进行修改,只能称之为乐观锁(optimistic locking)。所以其他客户端因为事务执行失败而进行不断的重试,当负载变大时,乐观锁就变得不那么完美了,这时需要使用redis实现分布式锁。

可以基于zookeeper来实现分布式锁,每个系统都通过zookeeper来获取分布式锁,保证同一时间只有一个系统实例在操作某个key,本文不详细介绍。

使用redis来实现分布式锁

使用setnx命令来争抢锁,使用del可以释放锁

127.0.0.1:6379> setnx lock-key 123456
(integer) 1  // 返回1上锁成功
127.0.0.1:6379> setnx lock-key 1234567
(integer) 0  // 返回0上锁失败,说明已被上锁
127.0.0.1:6379> get lock-key
"123456"
127.0.0.1:6379> del lock-key
(integer) 1
死锁

如果一个持有锁的客户端或进程崩溃或重启了,那么锁就成了死锁,解决办法是使用expire设置锁的超时时间,让redis自动去删除锁

127.0.0.1:6379> expire lock-key 3
(integer) 1

或者直接使用set命令来完成上锁和超时设置

127.0.0.1:6379> set lock-key 123456 EX 3
OK

语言层面的代码demo就不写了,原理一样,按照实际业务来写

使用到缓存,数据库双读双写,就有数据一致性的问题,如何解决呢?

对于大部分系统来说,是允许一小段时间出现缓存和数据库数据不一致的情况。如果系统是严格要求缓存和数据库数据必须一致,那么可以采用:读请求和写请求串行化,放到同一个队列中执行。

串行化可以保证数据一致性,但是同样系统的吞吐量就会降低,串行的队列并发量高,必然会有堵塞,反而会成为整个系统的瓶颈。所以不是必须,一般不推荐这么做。

经典的缓存和数据库读写模式

  • 读的时候,先读缓存,未命中缓存,再读数据库,取出数据,更新缓存,并返回响应
  • 更新缓存时,先更新数据库,再删除缓存

为什么是删除缓存,而不是更新缓存?

因为在很多复杂的缓存场景下,缓存不仅仅是直接从数据库里取出来的值,而是结合多个表的数据进行复杂计算后得到的值。所以只要这几个表相关字段只要有更新,难道就要更新一次缓存吗?而这个缓存还不一定会被用到,直接更新的意义不大,还会浪费大量开销。

实际的做法是,在用到这个缓存时,再去更新。而这个时候,缓存可能已经被删除了,从删除到实际用到这个缓存,这期间不需要更新,所以只要用到缓存计算一次更新就好。这也是一种懒加载的思想。