随着互联网技术的飞速发展,分布式已经成为一个绕不开的话题,分布式环境下,“高并发访问共享资源”的场景并不少见,带来的问题也显⽽易见: 共享资源在访问前后出现了数据不一致或非预期结果!!!
单体时代可以⽤JVM提供的ReentrantLock或者Synchronized解决,分布式环境下,JVM就有点力不不从心了。于是乎,“分布式锁”便出现了。
01
什么是分布式锁?
在计算机科学中,锁 (lock) 与互斥 (mutex) 是一种同步机制,用于在许多线程执行时对资源的限制。
分布式锁可以理解为, 控制分布式系统有序的去对共享资源进行操作,通过互斥来保持一致性。
1 、分布式锁应具备哪些特性?
分布式锁是多服务共享锁, 在分布式的 部署环境下,通过锁机制来让客户端互斥的对共享资源进行访问,应该具备以下特性。
互斥性: 同一时间,保证共享资源只能被一个客户端的一个线程能访问,具有排他性。
防死锁: 锁在一段时间后,一定会被释放(正常释放或异常释放)。
高可用: 获取锁的机制必须高可用,性能佳。
阻塞锁(可选): 当前资源已被加锁,其他客户端或者线程是阻塞等待,还是立即返回。
可重入(可选): 当前锁的持有者是否能再次进入。
公平性(可选): 加锁的顺序和请求加锁的顺序是一致,还是随机抢锁。
2 、分布式锁可以解决哪些场景的问题?
分布式锁就是用来 解决高并发访问导致数据不一致的问题 ,这里列举几种常见的场景。
多用户修改数据,造成数据不准确: 多个请求对同一条数据同时进行修改,导致数据不准确。比如 “ 下单减库存 ” 、 “ 互联网秒杀 ” 、 “ 抢红包 ” 、 “ 抢票 ” 、 “ 抢优惠券 ” 、 “ 互联网选号 ” 、“ 转账 ” 等。
多次请求,数据重复: 请求结果暂未返回时, 进行多次操作或重试, 产生多个相同的请求,不加锁的情况下成功,会产生很多重复记录。
分布式协调: 分布式环境下,多台机器都可以执行任务,每次只能一台机器执行,也可以用分布式锁来做标记,只有获取到锁的机器可以执行。
3 、分布式锁有哪些实现方式?
关于锁, Java 提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。
“ 分布式锁 ” 其实是一种解决方案,并非专有组件或者类,实现这一解决方案仍旧需要额外的组件或者中间件来辅助,甚至某些情况下,需要借助数据库级别的方式来实现。
关于分布式锁的实现方案,在业界流行的有三种:
**基于数据库: **借助数据库锁实现,实现简单,性能是最大问题。( 不推荐 )
**基于 Redis : **CAP 模型属于 AP ,无一致性算法,速度快。( 高性能场景推荐 )
**基于 Zookeeper : **CAP 模型属于 CP ,可靠性高,性能比 Redis 差一些。( 高可靠场景推荐)
另外,还有使用 etcd 、 consul 来实现的。
到这里,我们已经对分布式锁的特点、使用场景、实现方式有了大致的了解。 那么,一款高性能分布式锁到底应该如何设计?请继续往下看。
02
高并发场景下分布式锁如何设计?
因为Redis出色的性能,在高并发环境中 ,使用最多的是Redis方案 , 实现最复杂,最容易出问题的也是Redis方案。
接下来, 用Redis来实现一个库存加分锁的列子,对分布式锁的设计原理和思路进行阐述。
需求场景:假设库存有100件商品,通过互联网秒杀下单,要求抢完的同时不能超卖。
分布式模拟: 启用2个服务,来模拟分布式环境,前端用Nginx分发请求。
并发工具: 使用JMeter并发模拟多个用户并发请求。
1 、无锁减库存
我们先来看一下无锁的情况,下单减库存会存在什么问题?具体代码如下:
并发请求模拟 :
测试计划 -> 添加线程组(配置线程属性)
线程组 -> 添加 ->Sampler ->HTTP 请求(配置 http 请求地址)
HTTP 请求 -> 添加监听器(图形结果、查看结果树)
选项 -> Log Viewer (打开日志)
执行结果如下:
问题很明显,当库存为 1 时,还成功了 3 个订单,这结果并不是我们所期望的。
这是因为,分布式环境下,当只有 1 个库存时候,同时有 3 个线程读取到了该库存,完成了下单。这种多用户访问导致数据不准确的问题,就可以用分布式锁来解决。
接下来,我们看看用 Redis 怎么实现分布式锁。
2 、分布式锁实现(初级版)
根据前面介绍的,分布式锁,必须具备下面三个特性:
互斥性: 只有获取到锁的线程才能访问。
防死锁: 设置过期自动删除来实现解释失败导致的死锁。
高可用: 通过 Redis Cluster 的高可用来保证。
实现思路很简单: 访问库存前,往 Redis 写入一个锁标志,访问结束删除锁,只有拿到锁的才可以访问。
设置过期时间来清理未被成功删除的锁。
设置加锁人的身份标识,防止被他人误删。
Redis 提供了丰富的命令操作功能, JAVA 可以用 RedisTemplate 操作,代码如下:
再看⼀下结果:
执行结果正常,到这里,一个简单分布式锁就完成了。 作为一个思路严谨的程序员,你可能还有诸多疑问: 如果设置锁成功,设置过期时间失败了怎么办? 如果过期时间到了,业务没执行完怎么办?如果没获取到锁,想等待锁空闲再获取,该怎么实现?如果加锁方法调用了其他方法,其他方法又调用加锁方法,需多次进入该锁,怎么办?
生产级使用,还需要实现: 原子操作、续期、阻塞获取、支持重入 。
具体实现方法,请接着往下看。
3 、分布式锁实现(高级版)
基于上面的问题,你也许想到了解决方案,比如:
原子操作: 可以通过 Redis 提供的 Lua 脚本功能来实现。
续期: 可以用异步线程自动续期,或者显示调用续期方法。
阻塞获取: 获取锁时设置等待时间,内部用循环自旋获取锁,直到超时。
重入: 可以通过 Redis Hash 结构存储,同时记录 key 和 value ,每次进入 value+1 。
简单介绍一下Lua脚本:
Redis Lua脚本
从redis 2.6.0推出了脚本功能,允许开发者用Lua语言编写脚本,传到Redis中执行。使用脚本好处:
减少网络开销
原子操作
替代Redis的事物功能
接下来,我们分析一下加锁、重入、解锁的完整流程。
加锁(续期)原理
重入原理
数据结构类似Java的Map
数据结构设计:<工程名称+keyName,
每重入一次,value就+1。
解锁原理
解锁时,先判断线程信息(只能操作当前线程的锁),再将加锁次数减1,当次数为0就删除锁。
加锁和重入的 Lua 脚本:
Redis 命令解释:
**EXISTS key : **检查给定 key 是否存在,存在返回 1 ,否则返回0 。
**HSET key field value **: 将哈希表 key 中的域 field 的值设为 value 。
**PEXPIRE key milliseconds **: 以毫秒为单位设置 key 的生存时间。
**HEXISTS key field : **查看哈希表 key 中,给定域 field 是否存在。
**HINCRBY key field increment : **为哈希表 key 中的域 field 的值加上增量 increment 。
**PTTL key **: 以毫秒为单位返回 key 的剩余生存时间。
解锁 Lua 脚本:
脚本执行:
执行 Lua 脚本,可以通过下面两个方法(一次加载,多次执行)。
String hash = redisCluster .scriptLoad( script , key);
Object result = redisCluster .evalsha( hash , keys, args);
实现了上面这些功能,一个企业级高可用分布式锁基本就完成了。
当然,在实现过程,还需要考虑很多细节问题,比如:脚本加载失败重试、 Redis 集群路由、脚本执行失败重试等等。
顺便说一句,完整版“lock-sdk”已发布在公司maven仓库,可以直接使用。Redis高性能版内部实现了CashCloud接入,注解方式使用锁,后期也会实现Zookeeper高可靠版本。
写在最后的话
本文介绍了分布式锁特性、应用场景、以及实现方式,并以一个基于 Redis 设计分布式锁的例子,介绍了分布式锁的设计原理和思路,希望帮助大家对分布式锁有一个更新的认识。
Redis 实现分布式锁只是其中一种方案,也不能保证 100% 的一致性,比如 Redis 集群 Master 加锁成功,还没来得及同步到 Slave 节点, Master 就挂了,这种场景也会出现数据不一致的问题。如果对可靠性有更高要求,可以选择 Zookeeper 实现方案。 再比如,互联网秒杀场景仅仅基于一个分布式锁也不能完全扛得住,可能需要引入分段库存锁机制来实现。
任何技术都不是万能的,没有哪一种技术方案能解决所有业务场景的问题,希望大家根据业务场景选择合适的技术方案!
希望以上内容能对有需要的人有所帮助