• 中间件 | Redis - [分布式锁 & 事务]


    §1 分布式锁

    redis 是线程安全的

    • redis 是单线程的
      因为作为内存数据库,CPU 很难成为它的性能瓶颈
      这里说的单线程是它执行指令的线程,IO 部分是支持多线程的
    • redis-server 是线程安全的
      因为 redis 是单线程执行指令的,所以线程安全
    • 但线程安全不等于业务上线程安全
      这是因为可能出现多个客户端对 redis 的同一个 key 进行并发竞争
      这可能导致业务上应该先执行的操作延后,或者并发修改同一个 key 但其中一个结果被另一个覆盖
      上述场景与 ConurrentHashMap 线程不安全原理一致
    • 可以使用分布式锁解决

    redis 主从复制的集群是 AP 的

    • redis 单节点是 CP 的
    • 但是当 ridis 组成了主从复制的集群时,就是 AP 的了
      这是因为主从复制存在数据的异步复制,复制过程中主节点挂了,会重新选主,从节点升级为 master,但这个新 master 可能不带有分布式锁信息

    分布式锁思路梳理

    • 检查业务处理中缓存,如果存在说明是过期的,业务可能还在处理,会生成自动或人工工单,当确认成功或失败后重启业务
      为防止主从复制时宕机导致的数据丢失,可以使用其他的持久化工具
    • 查询业务完成缓存,只有缓存中没有,才尝试创建锁
      防止多消费时,刚刚消费完成解锁,就又消费一次
    • 根据业务信息生成锁的 bizLockKey
      示例:String bizLockKey = "RLK:" + biz_prefix + biz_id
      biz_prefix 是业务前缀,比如 so:wms: 这是一个销售单流转到 wms 系统的前缀
      biz_id 是业务号,对应上面例子,可以是销售单的单号,也可以是 wms 系统的工单号
    • 生成带有主机线程信息的随机值 bizLockKey
      示例:String bizLockValue = ip_short + UUID + thread
    • try
    • 尝试通过 setnx 获取分布式锁,需要携带值、超时时间
      超时时间是防止业务出错或宕机导致的不能释放锁
      携带值是为了保证以后解的是现在加的锁
      redisTemplate.opsForValue.setIfAbsent(bizLockKey,bizLockValue,time,timeUnit);
    • 未抢到锁返回,抢到了处理业务
    • finally 释放锁,释放时需要判断是否是自己的锁
      防止 A 加锁、A 超时释放锁、B 加锁、A 释放 B 的锁
      同时,可能出现判断通过后,锁突然变更了(A 的锁过期了,现在是 B 的锁了)
      官网推荐使用 LUA 脚本,使判断和删除原子性
      Jedis jedis = RedisUtils.getJedis();
      String lua = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
              "then\n" +
              "    return redis.call(\"del\",KEYS[1])\n" +
              "else\n" +
              "    return 0\n" +
              "end";
      
      try{
          if("1".equals(jedis.eval(lua, Arrays.asList(key),Arrays.asList(vlue)))){
              // do sth
          }
      }finally {
          if(null != jedis) jedis.close();
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      • 12
      • 13
      • 14
      • 15
      也可以使 bizLockKey 携带随机信息(保证不会误删除)
      也可以使用 redis 的事务解决
      while(true){
          redis.watch(key);
          if(Value.equals(redis.opsForValue().get(key))){
              redis.setEnableTransactionSupport(true);
              redis.multi();
              if(!CollectionUtils.isEmpty(redis.delete(Key))){
                  redis.unwatch();
                  break;
              }
          }
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8
      • 9
      • 10
      • 11
      也可以通过锁续期解决,见下面
    • 锁续期
      一种是直接给一个很大的实际,比如 300 秒
      或者通过业务侵入,每执行到一定的步骤,检查一下时间,如果感觉时间不够用,续命
      或者通过另一个线程或者守护服务完成,但不可能是全自动的(自动感知业务执行时间是否够用)
      或者直接通过 Redisson 实现
      RLock lock = redisson.getLock(key,value);
      try {
         lock.lock();
         // ...
      }finally {
         if(lock.isLocked() && lock.isHeldByCurrentThread())
             lock.unlock();
      }
      
      • 1
      • 2
      • 3
      • 4
      • 5
      • 6
      • 7
      • 8

    分布式锁坑总结

    • 业务处理到一半卡死,没走到放锁的逻辑,ridis 里始终有锁
      增加过期时间
    • 上锁后,指定过期时间前宕机,导致 ridis 里始终有锁
      通过 setnx ,实际是 set key value nx px 毫秒数 上锁,使上锁和超时设置原子化
    • 业务没有完成但超时了,锁会消失,若另一个线程加锁,此时原线程可能误删新锁
      设置锁的时候添加一个随机信息作为值,删除前核对这个值,相当于乐观锁
    • 在上面的比较中,判断和删锁不是原子的,所以可能导致先进判断,但锁失效了并变成新锁,被误删
      可以使用 lua 脚本、key 中添加随机值、redis 事务、redisson
    • redis 主从复制造成的所丢失
      增加业务处理缓存,记录一定周期内正在处理的业务,比如三天
      加锁前判断业务是否在缓存,如果在即处理中、丢失锁等情况,需要等待其完成或失败,然后结合自动、人工工单处理
    • 重复消费时,线程刚刚处理完释放锁,另一个线程就加上锁l ,刚刚处理完的业务又处理一次
      通过幂等解决,或添加业务完成缓存,存放一定周期内处理完的业务

    §2 redis 事务

    特性

    • 有序隔离
      事务可以串联多个指令,与其他指令隔离,执行过程中不允许其他指令打断
    • 无隔离级别
      redis 是单线程的,没有 exec 之前事务中的指令都未被执行, exec 之后各个指令都不会被打断
      因此 redis 的事务无所谓隔离级别,或可将其视为 串行化
    • 不保证原子性
      事务中某指令执行失败后,不会回滚,其他命令依然执行

    流程
    redis 事务由下面流程组成

    • MULTI
      标记事务开始
      将后面的指令放入指令队列,当 EXEC 时执行指令序列
    • EXEC
      执行
    • DISCARD
      清除指令队列中的指令并回复正常的连接状态
    • WATCH / UNWATCH
      事务需要按条件执行时,此命令可以为设定 key 的受监控状态,相当于给 key 加了一个监控
      若被监控的 key 的值发生变化,则后面的事务不会执行
      WATCH key1 key2

    事务示例

    # 组织指令并执行
    multi
    set k1 v1
    set k2 v2
    exec
    # 组织指令并撤销
    multi
    set k1 v1
    set k2 v2
    set k3 v3
    discard
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    事务中的指令错误
    指令错误分为 MULTI 阶段错误EXEC 阶段错误

    • MULTI 阶段错误,导致整个 MULTI 无法执行(不能 exec
    • EXEC 阶段错误,只导致出错误的指令执行失败

    指令错误示例

    multi
    set k1 v1
    set k2
    # 下面的指令会拒绝执行,因为 multi 中 set k2 指令错误
    exec
    
    multi
    set k1 v1
    incr k1
    set k2 v2
    # exec 成功执行,上面命令中只有第二条执行失败,因为 k1 的值 v1 无法自增
    exec
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    事务冲突
    成因说明

    • redis 没有严格意义上的事务冲突
      即,redis 是单线程的,redis 的事务不能并行进行
    • redis 在现象上存在事务冲突
      • 这是因为 redis 不支持条件指令,即不支持 if(condition) then... 逻辑
      • 因此,当业务逻辑中出现上述情况时,不能将这样的逻辑片段加入事务
      • 故,只能通过其他逻辑补充,比如 java
      • 在这个过程中
        • 不同的 java 线程可能同时获取了同一个 key
        • 不同的 java 线程可能同时对 key 的当前值进行判断
        • 不同的 java 线程可能同时调用 redis 事务,并在 redis-server 端先后执行
        • 但一条 java 线程的事务成功后,另一台 java 线程先前做的判断其实已经失效了,但依然去执行了自己的事务
        • 因此出现事务冲突现象

    解决方式
    redis 提供了 基于乐观锁 的监视指令 watch,使用流程如下

    • 执行事务之前,对事务中需要操作的 key 加监视,可以同时监视多个 key
      WATCH key...
    • 正常执行 redis 事务
    • 释放被监视的 key
      UNWATCH key...
  • 相关阅读:
    java爬虫
    天软特色因子看板 (2023.11 第12期)
    nginx出现504 Gateway Time-out错误的原因分析及解决
    HTTP相关知识
    winform入门篇 第13章 菜单栏
    命令行中引导用户指定选择文档
    前端基础建设与架构03 CI 环境上的 npm 优化及更多工程化问题解析
    unity 渲染性能分析工具
    freertos简单串口
    MySQL表的操作
  • 原文地址:https://blog.csdn.net/ZEUS00456/article/details/126947232