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个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功
      • 如果获取锁失败,解锁

posted @ 2022-04-12 09:23  郭祺迦  阅读(80)  评论(0)    收藏  举报