INDEX
§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(); }
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:是直接给一个很大的实际,比如 300 秒,实际等于没有续期
方案2:加锁成功后,启动守护线程,每隔一定时间刷新超时时间,若业务处理线程存活,则自动进行续期否则超时时间开始真实的流逝
方案3:推荐,直接通过 Redisson 实现,此方式其实就是方案2RLock lock = redisson.getLock(key,value); try { lock.lock(); // ... }finally { if(lock.isLocked() && lock.isHeldByCurrentThread()) lock.unlock(); }
分布式锁坑总结
- 业务处理到一半卡死,没走到放锁的逻辑,ridis 里始终有锁
增加过期时间 - 上锁后,指定过期时间前宕机,导致 ridis 里始终有锁
通过setnx
,实际是set key value nx px 毫秒数
上锁,使上锁和超时设置原子化 - 业务没有完成但超时了,锁会消失,若另一个线程加锁,此时原线程可能误删新锁
设置锁的时候添加一个随机信息作为值,删除前核对这个值,相当于乐观锁 - 在上面的比较中,判断和删锁不是原子的,所以可能导致先进判断,但锁失效了并变成新锁,被误删
可以使用 lua 脚本、key 中添加随机值、redis 事务、redisson - redis 主从复制造成的所丢失
增加业务处理缓存,记录一定周期内正在处理的业务,比如三天
加锁前判断业务是否在缓存,如果在即处理中、丢失锁等情况,需要等待其完成或失败,然后结合自动、人工工单处理 - 重复消费时,线程刚刚处理完释放锁,另一个线程就加上锁l ,刚刚处理完的业务又处理一次
通过幂等解决,或添加业务完成缓存,存放一定周期内处理完的业务
红锁
- 5+ 单独部署的 redis 实例(不是一个集群)
- 全局加锁成功条件
- 半数以上实例加锁成功即全局加锁成功
- 从开始加锁到全局加锁成功的时间 < 锁过期时间
实际工作中红锁使用场景较少
- 部署成本较高
- 服务器时间可能不同步
§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
事务中的指令错误
指令错误分为 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
事务冲突
成因说明
- redis 没有严格意义上的事务冲突
即,redis 是单线程的,redis 的事务不能并行进行 - redis 在现象上存在事务冲突
- 这是因为 redis 不支持条件指令,即不支持
if(condition) then...
逻辑 - 因此,当业务逻辑中出现上述情况时,不能将这样的逻辑片段加入事务
- 故,只能通过其他逻辑补充,比如 java
- 在这个过程中
- 不同的 java 线程可能同时获取了同一个 key
- 不同的 java 线程可能同时对 key 的当前值进行判断
- 不同的 java 线程可能同时调用 redis 事务,并在 redis-server 端先后执行
- 但一条 java 线程的事务成功后,另一台 java 线程先前做的判断其实已经失效了,但依然去执行了自己的事务
- 因此出现事务冲突现象
- 这是因为 redis 不支持条件指令,即不支持
解决方式
redis 提供了 基于乐观锁 的监视指令 watch
,使用流程如下
- 执行事务之前,对事务中需要操作的 key 加监视,可以同时监视多个 key
WATCH key...
- 正常执行 redis 事务
- 释放被监视的 key
UNWATCH key...