Preface
在现代互联网, 通常都是伴随着分布式、高并发等, 在某些业务中例如下订单扣减库存, 如果不对库存资源做临界处理, 在并发量大的时候会出现库存不准确的情况. 在单个服务的情况下可以通过Java自带的一些锁对临界资源进行处理, 例如
synchronized
、Reentrantlock
, 甚至是通过无锁技术(比如RangeBuffer
)都可以实现同一个JVM内的锁. But, 在能够弹性伸缩的分布式环境下, Java内置的锁显然不能够满足需求, 需要借助外部进程实现分布式锁.
几种实现方式
分布式环境下, 数据一致性问题一直是一个比较重要的话题, 而又不同于单进程的情况. 分布式与单机情况下最大的不同在于其不是多线程而是多进程. 多线程由于可以共享堆内存, 因此可以简单的采取内存作为标记存储位置. 而进程之间甚至可能都不在同一台物理机上, 因此需要将标记存储在一个所有进程都能看到的地方.
常见的是秒杀场景, 订单服务部署了多个实例. 如秒杀商品有4个, 第一个用户购买3个, 第二个用户购买2个, 理想状态下第一个用户能购买成功, 第二个用户提示购买失败, 反之亦可. 而实际可能出现的情况是, 两个用户都得到库存为4, 第一个用户买到了3个, 更新库存之前, 第二个用户下了2个商品的订单, 更新库存为2, 导致出错.
在上面的场景中, 商品的库存是共享变量, 面对高并发情形, 需要保证对资源的访问互斥. 在单机环境中, Java中其实提供了很多并发处理相关的API, 但是这些API在分布式场景中就无能为力了. 也就是说单纯的Java API并不能提供分布式锁的能力. 分布式系统中, 由于分布式系统的分布性, 即多线程和多进程并且分布在不同机器中, synchronized
和lock
这两种锁将失去原有锁的效果, 需要我们自己实现分布式锁.
常见的锁方案如下:
- 基于数据库实现分布式锁(基本用来玩的)
- 基于缓存, 实现分布式锁, 如
Redis
(业界常用方式) - 基于
Zookeeper
实现分布式锁(性能低)
下面我们简单介绍下这几种锁的实现.
基于数据库
虽然这种方式基本上不会被用于生产环境
基于数据库的锁实现也有两种方式, 一是基于数据库表, 另一种是基于数据库排他锁.
基于数据库表的增删
基于数据库表增删是最简单的方式, 首先创建一张锁的表主要包含下列字段: 方法名, 时间戳等字段.
具体使用的方法, 当需要锁住某个方法时, 往该表中插入一条相关的记录. 这边需要注意, 方法名是有唯一性约束的, 如果有多个请求同时提交到数据库的话, 数据库会保证只有一个操作可以成功, 那么我们就可以认为操作成功的那个线程获得了该方法的锁, 可以执行方法体内容.
执行完毕, 需要delete
该记录.
当然, 这边只是简单介绍一下. 对于上述方案可以进行优化, 如应用主从数据库, 数据之间双向同步. 一旦挂掉快速切换到备库上;做一个定时任务, 每隔一定时间把数据库中的超时数据清理一遍;使用while
循环, 直到insert
成功再返回成功, 虽然并不推荐这样做;还可以记录当前获得锁的机器的主机信息和线程信息, 那么下次再获取锁的时候先查询数据库, 如果当前机器的主机信息和线程信息在数据库可以查到的话, 直接把锁分配给他就可以了, 实现可重入锁.
- 可重入锁: 可以再次进入方法A, 就是说在释放锁前此线程可以再次进入方法A(方法A递归).
- 不可重入锁(自旋锁): 不可以再次进入方法A, 也就是说获得锁进入方法A是此线程在释放锁钱唯一的一次进入方法A.
基于数据库排他锁
我们还可以通过数据库的排他锁来实现分布式锁. 基于MySql的InnoDB引擎, 可以使用以下方法来实现加锁操作:
1 | public void lock(){ |
在查询语句后面增加for update
, 数据库会在查询过程中给数据库表增加排他锁. 当某条记录被加上排他锁之后, 其他线程无法再在该行记录上增加排他锁. 其他没有获取到锁的就会阻塞在上述select
语句上, 可能的结果有2种, 在超时之前获取到了锁, 在超时之前仍未获取到锁.
获得排它锁的线程即可获得分布式锁, 当获取到锁之后, 可以执行方法的业务逻辑, 执行完方法之后, 释放锁connection.commit()
.
存在的问题主要是性能不高和sql超时的异常.
基于数据库锁的优缺点
上面两种方式都是依赖数据库的一张表, 一种是通过表中的记录的存在情况确定当前是否有锁存在, 另外一种是通过数据库的排他锁来实现分布式锁.
- 优点是直接借助数据库, 简单容易理解.
- 缺点是操作数据库需要一定的开销, 性能问题需要考虑.
基于Zookeeper
基于Zookeeper临时有序节点可以实现的分布式锁. 每个客户端对某个方法加锁时, 在Zookeeper上的与该方法对应的指定节点的目录下, 生成一个唯一的瞬时有序节点. 判断是否获取锁的方式很简单, 只需要判断有序节点中序号最小的一个. 当释放锁的时候, 只需将这个瞬时节点删除即可. 同时, 其可以避免服务宕机导致的锁无法释放, 而产生的死锁问题.
提供的第三方库有curator, 具体使用读者可以自行去看一下. Curator提供的InterProcessMutex
是分布式锁的实现. acquire
方法获取锁, release方法释放锁. 另外, 锁释放、阻塞锁、可重入锁等问题都可以有有效解决. 讲下阻塞锁的实现, 客户端可以通过在ZK中创建顺序节点, 并且在节点上绑定监听器, 一旦节点有变化, Zookeeper会通知客户端, 客户端可以检查自己创建的节点是不是当前所有节点中序号最小的, 如果是就获取到锁, 便可以执行业务逻辑.
根据Zookeeper的这些特性, 我们来看看如何利用这些特性来实现分布式锁:
- 创建一个锁目录
lock
- 线程A获取锁会在
lock
目录下, 创建临时顺序节点 - 获取锁目录下所有的子节点, 然后获取比自己小的兄弟节点, 如果不存在, 则说明当前线程顺序号最小, 获得锁
- 线程B创建临时节点并获取所有兄弟节点, 判断自己不是最小节点, 设置监听(
watcher
)比自己次小的节点 - 线程A处理完, 删除自己的节点, 线程B监听到变更事件, 判断自己是最小的节点, 获得锁
最后, Zookeeper实现的分布式锁其实存在一个缺点, 那就是性能上可能并没有缓存服务那么高. 因为每次在创建锁和释放锁的过程中, 都要动态创建、销毁瞬时节点来实现锁功能. ZK中创建和删除节点只能通过Leader服务器来执行, 然后将数据同不到所有的Follower机器上. 并发问题, 可能存在网络抖动, 客户端和ZK集群的session连接断了, zk集群以为客户端挂了, 就会删除临时节点, 这时候其他客户端就可以获取到分布式锁了.
下面是简单例子:
1 | public class CuratorTest { |
基于缓存
相对于基于数据库实现分布式锁的方案来说, 基于缓存来实现在性能方面会表现的更好一点, 存取速度快很多. 而且很多缓存是可以集群部署的, 可以解决单点问题. 基于缓存的锁有好几种, 如Memcached、Redis, 下面主要讲解基于Redis的分布式实现.
基于Redis的分布式锁实现
首先, 为了确保分布式锁可用, 我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性. 在任意时刻, 只有一个客户端能持有锁.
- 不会发生死锁. 即使有一个客户端在持有锁的期间崩溃而没有主动解锁, 也能保证后续其他客户端能加锁.
- 具有容错性. 只要大部分的Redis节点正常运行, 客户端就可以加锁和解锁.
- 解铃还须系铃人. 加锁和解锁必须是同一个客户端, 客户端自己不能把别人加的锁给解了.
基于Spring Data Redis
下面是正确的实现姿势. (使用Spring Data Redis)
依赖
1 | <dependency> |
加锁姿势
1 | @Autowired |
执行上面的setNxEx()
方法就只会导致两种结果:
- 当前没有锁(key不存在), 那么就进行加锁操作, 并对锁设置个有效期, 同时value表示加锁的客户端.
- 已有锁存在, 不做任何操作.
网上有许多教程在加锁的步骤都不是原子性的, 有些是先加锁, 成功后再设置过期时间;有些将过期时间设置为value, 获取锁失败会判断value是否小于当前时间, 是则删除在设置新的值. 这些方法由于不是原子性, 在极端情况(比如多线程, 或者代码执行到某一行就宕机了等等)必然会导致锁失效或死锁等情况…
在上面stringRedisConn.set(...)
方法中, 确保了上锁与设置过期时间的原子性.
解锁姿势
配置类:
1 | @Bean |
Lua脚本:
1 | if redis.call('GET', KEYS[1]) == ARGV[1] then |
核心代码:
1 | @Resource |
除了配置, 解锁就一行代码搞定, 虽然简洁, 里面也是有很多学问滴. . .
为什么要用Lua脚本?确保原子性, 如何保证, 请看官网对eval
命令的相关解释. 上面脚本表达的意思很简单, 对比传进来的value是否相等, 是则删除锁. value可使用UUID作为当前线程的标识符, 只有但前线程才能解锁.
网上的错误姿势一般都是执行完业务代码直接删除锁, 这样会导致删除了其他线程获的锁.
上面实现的分布式锁是不支持可重入的, 需要额外的编码, 业界当然早就开源了类似的框架, 比如下面介绍的Redisson.
基于Redisson
Redisson 是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid). 它不仅提供了一系列的分布式的Java常用对象, 还提供了许多分布式服务. 其中包括(
BitSet
,Set
,Multimap
,SortedSet
,Map
,List
,Queue
,BlockingQueue
,Deque
,BlockingDeque
,Semaphore
,Lock
,AtomicLong
,CountDownLatch
,Publish / Subscribe
,Bloom filter
,Remote service
,Spring cache
,Executor service
,Live Object service
,Scheduler service
) Redisson提供了使用Redis的最简单和最便捷的方法. Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern), 从而让使用者能够将精力更集中地放在处理业务逻辑上.
Redisson提供的众多功能中有一项就是可重入锁(Reentrant Lock), 具体用法可参考 文档
依赖
1 | <dependency> |
核心代码
1 | @Data |
- 一般服务器都是Linux系统, 引入
io.netty.channel.epoll.Epoll
采用Epoll方式有助于提升性能 - 使用
try-with-resource
方式提高代码优雅性…
注解驱动
Lock注解:
1 | @Target(ElementType.METHOD) |
切面类:
1 | @Slf4j |
使用了 SpEL 解析锁的Key:
1 | public final class SpelHelper { |
使用:
1 | // @Lock(prefixClass = TestService.class, key = "#id") |
如果锁被早被别的线程使用, 一般我们使用线程Sleep的方式等待锁释放, 但Redisson的底层采用了更优雅的等待策略, 通过发布订阅通知其他线程, 所以性能也会有所提高.
Finally
Redisson官方文档: https://github.com/redisson/redisson/wiki/%E7%9B%AE%E5%BD%95
示例代码: https://github.com/masteranthoneyd/starter/tree/master/dlock