悲观锁原理、使用场景和常见实现
原理:认为数据一定会有冲突则需要先锁资源,再处理业务同时阻塞其他线程访问资源
使用场景:符合所有的 多线程竞争共享资源 的业务场景 ,如 同金额相关的业务
实现: 常见实现有 Synchronized、ReentrantLock、分布式锁 等
悲观锁常见问题?
乐观锁原理、使用场景和常见实现
原理:认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做(常见有抛错或重试)
使用场景: 线程竞争不激烈情况(即没有并发量)下去访问共享资源 (使用场景相对较少,如 库存兜底、自旋重试等
实现: 常见实现有CAS、MVCC
乐观锁常见问题?
乐观锁和悲观锁区别?
锁资源:悲观锁会先锁资源,而乐观锁不会锁资源而是在提交时才会对冲突做校验;
锁开销:在线程竞争不激烈时,乐观锁开销更小,性能更好;在线程竞争激烈时,悲观锁相对开销小,性能好
阻塞:悲观锁会阻塞其他线程;乐观锁不会阻塞其他线程。
什么是线程锁?
在同一个JVM中线程是通过JMM规范来实现在共享内存中的通信,为了解决多线程的线程安全问题,则需要使锁来锁住共享资源来确保共享资源安全,这个锁就是线程锁。
为什么要用分布式锁?
常见线程锁( synchronized和Reenlock)只有在同一JVM中才有效果,因为线程锁的实现在根本上是依靠线程之间共享内存实现的,比如synchronized是共享对象头,显示锁Lock是共享某个变量(state)。
则在分布式环境,线程去竞争共享资源时这些线程可以是分布在多个节点上即不同的JVM下,则线程锁失效,此时就需要分布式锁来解决分布式环境下资源安全问题。
- DB实现分布式锁
强依赖于数据库,性能瓶颈上限非常低(等同于DB性能 ),流量稍大就会打垮数据库,只适用于小流量且没有其他中间件资源时,否则不推荐此方案(具体实现本篇不做介绍)。
- redis实现分布式锁
redis主流有2种实现方式一种是基于客户端RedisTemplate实现,还有一种是客户端redission实现,由于redis超高性能优势,是互联网实现分布式锁的主流。
- zookeeper实现分布式锁
zk的客户端Curator也有现成的轮子,且zk在分布式环境下有超高的可靠性,是金融领域实现分布式锁的主流,但是目前对于可靠性高场景,会优先考虑ETCD(本篇先不做介绍)。
zookeeper是一个开源的分布式应用程序协调服务,可以把它看做为支持事件监听的文件服务
先给结论zookeeper 就是基于临时顺序节点以及 Watcher 监听器机制实现分布式锁,具体的细节如下:
在每一个节点下面创建临时顺序节点(EPHEMERAL_SEQUENTIAL)类型,新的子节点后面会加上一个次序编号,而这个生成的次序编号是上一个生成的次序编号加一。
每个线程在尝试占用锁之前,首先判断自己是序号是不是当前最小,如果是则获取锁。
ZooKeeper 这种首尾相接、后面监听前面的方式,可以保障占有锁的传递有序而且高效,并且可以解决羊群效应。
zk在高并发下创建和删除节点性能非常差,则不适合于互联网高并发场景下,但是它的CP架构和临时顺序节点特性能够保证高可靠(至少比redis实现分布式锁的可靠性高),综合以上则它更适合于对可靠性要求高且流量小业务场景。
但是由于成本高和性能差现在基本上在技术选型上抛弃zk了,取代zk的是ETCD,性能至少是zk 10+倍,在k8s作为核心组件已经经历过各种大流量的洗礼,则对于高可靠的业务场景ETCD是主流了。
详细介绍 redistemplate和redission实现分布式锁细节
redistemplate是spring框架对jedis和lettuce的封装,且 redis2.6后 SET指令已经支持nx/px/expire,实现加锁非常简单且保证了加锁的原子性(redis 2.6之前加锁过程需要分2步,在客户端不能保证原子性)。
SET(key,value,NX,PX,time)
key 就是要锁定的资源,和NX一起来保证锁的互斥性;
value 可以是客户端唯一标识,保证锁的可重入性;
NX代表只在键不存在时,才对键进行设置操作,保证了锁的互斥性;
PX 表示对这个key要设置过期时间,保证了锁的安全性,避免异常情况下死锁问题;
time 表示key过期的具体时间,可以根据实际业务配置合适时间。
解锁时可重入性问题
解锁时要保证锁的可重入性,即只有当前持有锁的客户端才能够解锁,否则会在特殊情况下导致失效,如 线程v1拿到了锁,此时v1锁刚好过期正在进行删除锁的操作,同时v2刚好拿到锁,如果不校验当前锁的客户端则会出现v1误删了v2的锁,导致锁失效。
解锁时原子性问题
上面谈到了解锁时的可重入性问题需要校验锁持有者,则 需要保证 校验锁持有者和删除锁 这个2个动作要保证原子性,否则也会出现误删锁从而导致锁失效问题。
如果在redis客户端实现则如下:
//查询到当前锁的客户端标识
String v = redistemplate.get(key);
//判断客户端是否一致
if(requestId.equals(v)){
redistemplate.delete(key);
}
// 以上存在一个问题,这个2个操作不能保证原子性,会存在在查询时当前线程所持锁存在,
但是在删除时自己所持锁过期了,则也会出现误删问题,即在redis客户端不能保证以上2个动作的原子性
基于以上分析,解锁的原子性只能在redis服务端实现,redis支持lua基本,它可以保证在redis中多个操作的原子性(严格意思上来说 是隔离性,lua不支持回滚),具体如下:
----lua脚本实现------
if redis.call('get',KEYS[1]) == ARGV[1] then
return redis.call('del',KEYS[1])
else
return 0
end
锁过期时间评估问题
这里有2种方案:
其中方案2适合于有较强的测试团队,如果测试团队受限建议选取方案1。
高可用问题
单机redis分布式锁存在单机故障问题,这个可以使用哨兵集群来解决单点故障问题,但是哨兵集群在极端情况下(master异常时key没有同步到slave节点,发生了主从切换导致数据丢失)会导致锁失效,但是这种情况概率非常小。做为锁的数据类型是string ,key-value都非常小理论上同步时间是微妙级别。
成本较低,只需要单机redis或哨兵redis集群都可以,且redis几乎是现在项目的标配中间件不会额外增加成本;
性能较高,相对于zk和redission实现分布式锁,这种方式的性能是最高的(单机redis都号称可以抗住10wQPS)。
适用场景,适合于所有中小型互联网项目,也是个人比较推荐的一种方式(具体原因下面会详细说明)。
Reddissin 它提供的功能远远超出了一个 Redis 客户端的范畴,使用它来替换默认的 Lettuce。在可以使用基本 Redis 功能的同时,也能使用它提供的一些高级服务:
也是redis官方推荐的客户端。
redission支持的分布式锁
Redisson还实现多种业务场景下的分布式锁,包括 可重入锁(ReentrantLock),多锁(MultiLock),读写锁(ReadWriteLock),公平锁(Fair Lock),红锁(RedLock),信号量(Semaphore),可过期性信号量(PermitExpirableSemaphore)和闭锁(CountDownLatch)这些实际当中对多线程高并发应用至关重要的基本部件.
看门狗机制自动续期机制
Redisson中客户端v1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程(守护线程),会每隔10秒检查一下,如果客户端v1还持有锁key,那么就会不断的延长锁key的生存时间(默认30s)。
RedLock官方推荐使用的前提
则如果单机redis去使用红锁则会破坏红锁高可用的设计。
RedLock使用示例
//官网demo如下
//------------------------------------------------------------
//redis服务 1
RLock lock1 = redissonInstance1.getLock("lock1");
//redis服务 2
RLock lock2 = redissonInstance2.getLock("lock2");
//redis服务 3
RLock lock3 = redissonInstance3.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
// 同时加锁:lock1 lock2 lock3
// 红锁在大部分节点上加锁成功就算成功。
lock.lock();
try{
//处理业务
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
知识扩展点:大佬对RedLock可靠性的互撕
2位国际大佬对于RedLock是否安全问题从NPC(网络异常、GC系统停顿、时钟漂移)方面进行了激烈讨论:
Martin Kleppmann(《数据密集型应用系统设计》作者,国际资深分布式问题解决专家) 质疑帖子:https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html
Antirez(Redis开源的作者,国际资深分布式问题解决专家) 反击帖子:
http://antirez.com/news/101
强烈推荐去看看大佬是怎么考虑分布式问题的方向和思路。
redission锁的实现在客户端通过lua脚本实现的,具体实现如下源码:
RLock lock = redisson.getLock("myLock");
-------------------------------------------------------------
//判断key不存在 则直接创建一个hash类型数据 过期时间为30s 并返回
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
//判断客户端是重入,则值自增+1 且 自动续期30s
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
//否则 返回当前锁的剩余的过期时间
return redis.call('pttl', KEYS[1]);
//字段解释
KEYS[1]:表示你加锁的那个key,代指要加锁的资源;
ARGV[1]:表示锁的有效期,默认30s;
ARGV[2]:表示表示加锁的客户端ID 线程id。
HASH数据结构 :
myLock:
{
6c719a6b4586:1 1
}
其中 myLock为资源简称;6c719a6b4586为客户端ID
解锁时lua的源码:
lock.unlock();
----------------------------------------------------
//key 不存在则直接 返回 -- 访问资源不对 无法解锁
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
//key 和 客户端ID 查询都不存在 则返回 -- 访问资源对 但是客户端ID不对 无法解锁
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
//退出时则直接递减 -1 (同 java ReentrantLock逻辑一致)
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
//如果次数>0 表示还持有锁
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
//如果次数等于0 则删除key
else redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
redission客户端的分布式锁其实是对redistemplate客户端实现分布式锁的补充,它解决是后者过期时间不好评估和可靠性这2个问题:
由于redLock设计时兼顾了可靠性,则代价就是牺牲部分的性能(一致性算法-投票)。
基于以上对于分布式锁学习来做一下总结(方便后续遇到相关业务的技术选型):
基于以上3种分布式锁实现方式,在互联网领域我比较推荐选择使用的是jedis分布式锁,理由如下:
不推荐使用ZK分布式锁原因
ZK分布式锁 成本高、性能差,而且还会增加相应的架构复杂性和运维成本(目前国内中小型项目使用ZK相对较少),而且对于可靠性要求比较高场景现在也会优化考虑ETCD。
事实上ZK现在已经慢慢掉队了。
不推荐使用RedLock原因
redission分布式锁,看起来兼顾了高性能和高可靠,但是使用成本非常高(5台单机redis),对于主从集群或哨兵集群的redis只能使用master节点,而且也没有解决分布式NPC的三座大山问题。
单机版的RedLock舍弃高可靠的设计,可靠性同单机版jedis分布式锁一致都存在单点故障问题,且不如哨兵集群下的jedis分布式锁的可靠性。
关于它自动续期(默认30s,每10s探活续期),但是在互联网应用中接口普遍最大超时时间不会超过6s,则这个设计在实际应用中有些鸡肋,和jedis分布式锁的保守策略没有太大区别。
RedLock的自动续期功能,实际上全靠客户端的守护线程,这个守护线程是有可能被业务系统破坏的可能,则自动续期功能也没有那么可靠。
推荐使用jedis分布式锁的原因
jedis分布式锁从上面对比可以看出,除了安全方面外在性能和成本上远超其他2种方式,那么在实际生产环境中jedis分布式锁是否真的那么不安全呢?下面会简单分析:
一般中小型项目,项目初期可能由于成本预算可能只会使用单机redis,这时使用jedis分布式锁和RedLock都会存在单点故障问题,但是jedis性能更好。
随着项目上线,用户和数据量都在递增,一般都会升级redis的部署架构,对于99%中小型项目redis哨兵集群就够用了(一般中型项目都用不到Cluster集群,因为缓存数据量级不够则没有必要),哨兵集群(一主两从 使用RedLock 相当于只使用了一个主节点)下的jedis分布式锁是解决了单点故障问题,只有在主从切换才会出现数据丢失导致锁不安全这种情况。
现在来分析一下实际生产环境中哨兵集群主从切换丢数据的可能性:
通过以上优化和分析,在实际生产环境中哨兵集群在加锁时出现主从切换导致锁失效的情况非常非常少,即哨兵集群下的jedis分布式锁也是可以很安全的。
最后,本次技术分享都是基于个人对分布式锁理解和看法,有什么不对请帮忙指出。