8、事务和锁
1、什么是事务
- redis事务就是一个命令执行的队列,将一系列预定义命令包装成一个整体(一个队列)。当执行时,一次性按照添加顺序依次执行,中间不会被打断或者干扰。
2、事务的基本操作
- 开启事务
multi- 作用:设定事务的开启位置,此指令执行后,后续的所有指令均加入到事务中
- 执行事务
exec- 作用:设定事务的结束位置,同时执行事务。与multi成对出现,成对使用
- 注意:加入事务的命令暂时进入到任务队列中,并没有立即执行,只有执行exec命令才开始执行
- 取消事务
discard- 作用:终止当前事务的定义,发生在multi之后,exec之前
- 举例:
- 正常执行

127.0.0.1:6380> multi
OK
127.0.0.1:6380> set k1 v1
QUEUED
127.0.0.1:6380> set k2 v2
QUEUED
127.0.0.1:6380> get k1
QUEUED
127.0.0.1:6380> get k2
QUEUED
127.0.0.1:6380> set k3 v3
QUEUED
127.0.0.1:6380> get k3
QUEUED
127.0.0.1:6380> exec
1) OK
2) OK
3) "v1"
4) "v2"
5) OK
6) "v3"
127.0.0.1:6380> mget k1 k2 k3
1) "v1"
2) "v2"
3) "v3"- 放弃事务:

- 若在事务队列中存在命令性错误(类似与Java编译性错误),则执行exec命令时,所有命令都不会执行

127.0.0.1:6380> multi
OK
127.0.0.1:6380> set n1 v1
QUEUED
127.0.0.1:6380> set n2 v2
QUEUED
127.0.0.1:6380> set n3 v3
QUEUED
127.0.0.1:6380> getset k3
(error) ERR wrong number of arguments for 'getset' command
127.0.0.1:6380> set n4 k4
QUEUED
127.0.0.1:6380> set n5 k5
QUEUED
127.0.0.1:6380> exec
(error) EXECABORT Transaction discarded because of previous errors.
127.0.0.1:6380> mget n1 n2 n3 n4 n5
1) (nil)
2) (nil)
3) (nil)
4) (nil)
5) (nil)- 若在事务队列中存在语法行错误(类似于Java的1/0的运行时异常),则执行exec命令时,其他正确命令会被执行,错误命令抛出异常。

127.0.0.1:6380> multi
OK
127.0.0.1:6380> incr k1
QUEUED
127.0.0.1:6380> set k2 22
QUEUED
127.0.0.1:6380> set k3 33
QUEUED
127.0.0.1:6380> set k4 v4
QUEUED
127.0.0.1:6380> mget k1 k2 k3 k4
QUEUED
127.0.0.1:6380> exec
1) (error) ERR value is not an integer or out of range
2) OK
3) OK
4) OK
5) 1) "v1"
2) "22"
3) "33"
4) "v4"
127.0.0.1:6380>3、事务的工作流程

4、事务的注意事项
- 在定义事务的过程中,,命令格式输入错误怎么办?
- 语法错误:
- 指命令书写错误,比如getset name
- 处理结果:
- 如果定义的事务中所包含的命令存在语法错误,整体事务中所有命令均不会执行。包括那些语法正确的命令(示例参考2的第三个)
- 在定义事务中,命令执行出现错误怎么办?
- 运行错误:
- 指命令格式正确,但是无法正确的执行,例如对list进行incr操作
- 处理结果:
- 能够正确运行的命令会执行,运行错误的命令不会被执行
- 注意:已经执行完毕的命令对应的数据不会自动回滚,需要程序员自己在代码中实现回滚
- 手动进行事务回滚:
- 记录操作过程中被影响的数据之前的状态
- 单数据:string
- 多数据:hash、list、set、zset
- 设置指令恢复所有的被修改的项
- 单数据:直接set(注意周边属性,例如时效)
- 多数据:修改对应值或整体克隆复制
5、redis不保证原子性()
- redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。
- 原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
6、锁(watch)
- 业务场景
- 天猫双11热卖过程中,对已经售罄的货物追加补货,4个业务员都有权限进行补货。补货的操作可能是一系列的操作,牵扯到多个连续操作,如何保障不会重复操作?
- 业务分析
- 多个客户端有可能同时操作同一组数据,并且该数据一旦被操作修改后,将不适用于继续操作
- 在操作之前锁定要操作的数据,一旦发生变化,终止当前操作。
- 解决方案:
- 对key添加监视锁,在执行exec前如果key发生了变化,终止事务执行
watch key1 [key2...]- 取消对所有key的监视
unwatch- 举例:
- 使用watch检测balance,事务期间balance数据未变动,事务执行成功

- 使用watch检测balance,在开启事务后(标注1处),在新窗口执行标注2中的操作,更改balance的值,模拟其他客户端在事务执行期间更改watch监控的数据,然后再执行标注1后命令,执行exec后,事务未成功执行


- 一旦执行exec开启事务的执行后,无论事务是否执行成功,watch对变量的监控都将被取消。
- 故当事务执行时候后,需要重新执行watch命令对变量进行监控,并开启新的事务进行操作。
- watch的流程

- watch总结
- watch指令类似于乐观锁,在事务提交时,如果watch监控的多个key中任何key的值已经被其他客户端更改,则使用exec执行事务时,事务队列将不会被执行,同事返回Nullmulti-bulk应答以通知调用者事务执行失败。
7、分布式锁
- 天猫双11热卖过程中,对已经售罄的货物追加补货,且补货完成。客户购买热情高涨,3秒内将所有商品购买完毕。本次补货已经将库存全部清空,如何避免最后一件商品不被多人同时购买?【超卖问题】
- 业务分析:
- 使用watch监控一个key有没有改变已经不能解决问题,此处要监控的是具体数据
- 虽然redis是单线程的,但是多个客户端对同一数据同时进行操作时,如何避免不被同时修改?
- 解决方案:
- 使用setnx设置一个公共锁
setnx lock-key value- 利用setnx命令的返回值特征,有值则返回设置失败,无值则返回设置成功
- 对于返回设置成功的,拥有控制权,进行下一步的具体业务操作
- 对于返回设置失败的,不具有控制权,排队或等待
- 操作完毕后通过del操作释放锁。
- 上述解决方案是一种设计概念,依赖规范保障,具有风险性。
- redis应用具有分布式锁对应的场景控制
- 什么是分布式锁?
- 分布式锁其实就是:控制分布式系统不同进程共同访问共同资源的一种锁的实现。如果不同的系统或同一个系统的不同主机之间共享了某个临界资源,往往需要互斥来防止彼此干扰,以保证一致性。
- 分布式锁的特征:
- 互斥性:任意时刻,只有一个客户端能持有锁
- 锁超时释放:持有锁超时,可以释放,防止不必要的资源浪费,也可以防止死锁
- 可重入性:一个线程如果获取了锁之后,可以再次对其请求加锁
- 高性能和高可用:加锁和解锁需要开销尽可能低,同时也要保证高可用,避免分布式锁失效。
- 安全性:锁只能被持有的客户端删除,不能被其他客户端删除
- 分布式锁方案一:seynx+expire
- 提到redis的分布式锁,可能会想到setnx + expire命令。即先用setnx来抢锁,如果抢到之后,在用expire给锁设置一个过期时间,防止锁忘记了释放
- 假设某电商网站的某商品做秒杀活动,key可以设置为key_resource_id,value设置为任意值。伪代码如下:
if(jedis.setnx(key_resource_id,lock_value) == 1){ # 加锁
expire(key_resource_id,100); # 设置过期时间
try {
do something # 业务请求
}catch(){
}
finally {
jedis.del(key_resource_id); # 释放锁
}
}
- 但是这个方案中,setnx和expire两个命令分开了,不是原子操作。如果执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者重启维护了,那么这个锁就"长生不老"le1,别的线程永远获取不到锁啦。
- 分布式锁方案二:setnx+value值是(系统时间+过期时间)
- 为了解决方案一,发生异常后,锁得不到释放的场景。我们可以把过期时间放到setnx的value值里面。如果加锁失败,再拿出value值校验一下即可。伪代码如下:
long expires = System.currentTimeMillis() + expireTime; # 系统时间+设置的过期时间
String expiresStr = String.valueOf(expires);
# 如果当前锁不存在,返回加锁成功
if (jedis.setnx(key_resource_id, expiresStr) == 1) {
return true;
}
# 如果锁已经存在,获取锁的过期时间
String currentValueStr = jedis.get(key_resource_id);
# 如果获取到的过期时间,小于系统当前时间,表示已经过期
if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
# 锁已过期,获取上一个锁的过期时间,并设置现在锁的过期时间(getset:返回给定 key 的旧值。 当 key 没有旧值时,即 key 不存在时,返回 nil。当 key 存在但不是字符串类型时,返回一个错误)
String oldValueStr = jedis.getSet(key_resource_id, expiresStr);
if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
# 考虑多线程并发的情况,只有一个线程的设置值和当前值相同,它才可以加锁
return true;
}
}
# 其他情况,均返回加锁失败
return false;
}- 这个方案的优点是:巧妙移除expire单独设置过期时间的操作,把过期时间放到setnx的value值里面来;解决了方案一发生异常,锁得不到释放的问题。但是这个方案还有别的问题:
- 过期时间是客户端自己生成System.currentTimeMillis()是当前系统的时间,必须要求分布式环境下,每个客户端的时间必须同步
- 如果锁过期的时候,并发多个客户端同时请求过来,都执行jedis.getSet(),最终只能有一个客户端加锁成功,但是该客户端锁的过期时间,可能被别的客户端覆盖
- 该锁没有保存持有者的唯一标识,可能被别的客户端释放/解锁
- redis分布式锁方案三:使用Lua脚本(包含setnx+expire两条指令)
- 实际上,我们可以使用Lua脚本来保证原子性(包含setnx和expire两条指令),Lua脚本如下:
if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
redis.call('expire',KEYS[1],ARGV[2])
else
return 0
end;- 加锁代码如下:
String lua_scripts = "if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then" +
" redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
Object result = jedis.eval(lua_scripts, Collections.singletonList(key_resource_id), Collections.singletonList(values));
//判断是否成功
return result.equals(1L);- redis分布式锁方案四:set的扩展命令(set ex px nx)
- 除了使用Lua脚本,保证setnx+expire两条指令的原子性,我们还可以巧用Redis的set指令扩展参数(),它也是原子性的
SET key value[EX seconds][PX milliseconds][NX|XX]
# NX :表示key不存在的时候,才能set成功,也即保证只有第一个客户端请求才能获得锁,而其他客户端请求只能等其释放锁,才能获取。
# EX seconds :设定key的过期时间,时间单位是秒。
# PX milliseconds: 设定key的过期时间,单位为毫秒
# XX: 仅当key存在时设置值- 伪代码如下:
if(jedis.set(key_resource_id, lock_value, "NX", "EX", 100s) == 1){ //加锁
try {
do something //业务处理
}catch(){
}
finally {
jedis.del(key_resource_id); //释放锁
}
}
- 这个方案还是可能存在问题:
- 问题1:锁过期释放了,业务还没有执行完。假设线程a获取锁成功,一直在执行临界区的代码。但是100s过去后,它还没执行完。但是,这时候锁已经过期了,此时线程b又请求过来,显然线程b就可以获得锁成功,也可以执行临界区的代码。那么问题就来了,临界区的业务代码都不是严格穿鞋执行的啦
- 问题2:锁被别的线程误删。假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没有执行完呢
- 方案5:set ex px nx + 校验唯一随机值,再删除。
- 既然锁可能被别的线程误删,那我们给value值设置一个标记当前线程唯一的随机数,在删除的时候,校验一下,不就OK了嘛。伪代码如下:
if(jedis.set(key_resource_id, uni_request_id, "NX", "EX", 100s) == 1){ # 加锁
try {
do something # 业务处理
}catch(){
}
finally {
# 判断是不是当前线程加的锁,是才释放
if (uni_request_id.equals(jedis.get(key_resource_id))) {
jedis.del(lockKey); # 释放锁
}
}
}
- 在这里,判断是不是当前线程加的锁和释放锁不是一个原子操作。如果调用jedis.del()释放锁的时候,可能这把锁已经不属于当前客户端,会解除他人加的锁。

- 为了更严谨,一般也是用lua脚本代替。lua脚本如下:
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end;- redis分布式锁方案六:redisson框架
- 方案五还是可能存在锁过期释放,业务没执行完的问题。有人说,稍微把锁过期时间设置长一些就可以了。其实我们设想一下,是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
- 当前开源框架Redisson解决了这个问题。我们看一下底层原理图:

- 只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。
- redis分布式锁方案七:多机实现的分布式锁Redlock+Redisson
- 前面六种方案都只是基于单机版的讨论,还不是很完美。其实redis一般都是集群部署的:

- 如果线程一在redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级到master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
- 为了解决这个问题,Redis作者提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
# 搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。- 我们假设当前有5个Redis master节点,在5台服务器上面运行这些Redis实例:

- RedLock的实现步骤:如下:
- 获取当前时间,以毫秒为单位。
- 按顺序向5个master节点请求加锁。客户端设置网络连接和响应超时时间,并且超时时间要小于锁的失效时间。(假设锁自动失效时间为10秒,则超时时间一般在5-50毫秒之间,我们就假设超时时间是50ms吧)。如果超时,跳过该master节点,尽快去尝试下一个master节点。
- 客户端使用当前时间减去开始获取锁时间(即步骤1记录的时间),得到获取锁使用的时间。当且仅当超过一半(N/2+1,这里是5/2+1=3个节点)的Redis master节点都获得锁,并且使用的时间小于锁失效时间时,锁才算获取成功。(如上图,10s> 30ms+40ms+50ms+4m0s+50ms)
- 如果取到了锁,key的真正有效时间就变啦,需要减去获取锁所使用的时间。
- 如果获取锁失败(没有在至少N/2+1个master实例取到锁,有或者获取锁时间已经超过了有效时间),客户端要在所有的master节点上解锁(即便有些master节点根本就没有加锁成功,也需要解锁,以防止有些漏网之鱼)。
- 简化下步骤就是:
- 按顺序向5个master节点请求加锁
- 根据设置的超时时间来判断,是不是要跳过该master节点
- 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功
- 如果获取锁失败,解锁
本文来自博客园,作者:郭祺迦,转载请注明原文链接:https://www.cnblogs.com/guojie-guojie/p/16133605.html

浙公网安备 33010602011771号