如何设计一款高性能分布式锁,实现数据的安全访问?

语言: CN / TW / HK

点击关注“八戒技术团队”,阅读更多技术干货

随着互联网技术的飞速发展,分布式已经成为一个绕不开的话题,分布式环境下,“高并发访问共享资源”的场景并不少见,带来的问题也显⽽易见: 共享资源在访问前后出现了数据不一致或非预期结果!!!

单体时代可以⽤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  <key,Map<key1,value> > 类型,这里key为锁名称,key1为客户端信息,value为重入次数。

  数据结构设计:<工程名称+keyName,<hostaddress+uuid: 线程ID ,重入次数>>

每重入一次,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 实现方案。 再比如,互联网秒杀场景仅仅基于一个分布式锁也不能完全扛得住,可能需要引入分段库存锁机制来实现。  

任何技术都不是万能的,没有哪一种技术方案能解决所有业务场景的问题,希望大家根据业务场景选择合适的技术方案!

希望以上内容能对有需要的人有所帮助

欢迎大家一起探讨交流

请点击下方名片关注我们

文末福利

朋友圈分享本文并关注“八戒技术团队”

扫描下方二维码进行抽奖

开奖后转发/开奖前删除/分组转发无效