分布式锁及其实现
在单机部署的项目中,多线程间的并发控制可以由Java相关的并发处理API来控制线程间的通信和互斥。但是在分布式集群的系统中,单机部署情况下的并发控制策略就会失效了,单纯的Java API是不具备分布式环境下的并发控制能力的;所以这就需要一种跨JVM的互斥机制来控制对共享资源的访问,这就是分布式锁要解决的问题了
在分布式场景下,CAP理论已经证明了任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项;所以为了保证在分布式环境下的数据最终一致性,需要很多的技术方案来支持,比如分布式事务、分布式锁等
数据库实现分布式锁主要是依赖唯一索引
(唯一索引:不允许具有索引值相同的行,从而禁止重复的索引或键值。数据库会在创建该索引时检查是否有重复的键值,并在每次使用 INSERT 或 UPDATE 语句时进行检查)
实现的思路:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,因为做了唯一索引,所以即使多个请求同时提交到数据库,都只会保证只有一个操作能够成功,插入成功则获取到该方法的锁,执行完成后删除对应的行数据释放锁
CREATE TABLE `distributed_lock` (
`id` int(11) NOT NULL COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '方法名(需要锁住的方法名)',
`desc` varchar(255) NOT NULL COMMENT '备注信息',
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `index_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;
虽然我们对method_name 使用了唯一索引,并且显示使用for update来使用行级锁。
但是,MySql会对查询进行优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更高,比如对一些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,而不是行锁
实现思路:
/** * redis实现分布式锁 */
@Component
public class DistributedLock {
@Autowired
private JedisPool jedisPool;
/** * 加锁 * @param lockName 存放redis中的key * @param acquireTimeOut 分布式锁的过期时间 * @param timeout 获取锁的超时时间 * @return */
public String lockWithTimeOut(String lockName, int acquireTimeOut, long timeout) {
/** * 先setnx key是否成功; * 成功则设置随机值(UUID),然后设置过期时间,返回随机值给释放锁用 * * 失败则计算获取锁的超时时间,时间未到则自旋获取锁直到成功或者达到超时时间 */
String identifier = UUID.randomUUID().toString().replaceAll("-", "");
timeout = System.currentTimeMillis() + timeout;
String reIdentifier = "";
Jedis jedis = null;
try {
jedis = jedisPool.getResource();
jedis.select(0);
//带超时时间的循环获取锁实现锁阻塞特性
while(System.currentTimeMillis() < timeout){
Long setnx = jedis.setnx(lockName, identifier);
if (setnx != null && setnx == 1){
//设置过期时间
jedis.expire(lockName, acquireTimeOut);
reIdentifier = identifier;
break;
}else {
//这一步很重要
//如果key已经存在,查看过期时间,如果该key无过期时间则重新设置过期时间,以免发生死锁
Long ttl = jedis.ttl(lockName);
if (ttl == -1){
jedis.expire(lockName, acquireTimeOut);
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
System.out.println("线程中断");
Thread.currentThread().interrupt();
}
}
}
} catch (Exception e) {
//TODO 处理异常
e.printStackTrace();
} finally {
if (jedis != null)
jedis.close();
}
return reIdentifier;
}
/** * 释放锁 * @param lockName 锁的名称 * @param identifier 锁的标识(用来验证锁中的val是否一致) * @return */
public boolean releaseLock(String lockName, String identifier){
Jedis jedis = null;
boolean flag = false;
try {
jedis = jedisPool.getResource();
jedis.select(0);
jedis.watch(lockName);
String result = jedis.get(lockName);
if (result != null && identifier.equals(result)){
Transaction multi = jedis.multi();
multi.del(lockName);
List<Object> exec = multi.exec();
if (exec != null && exec.size() > 0){
flag = true;
}
}
jedis.unwatch();
return flag;
} catch (Exception e) {
//TODO 处理异常
e.printStackTrace();
return false;
} finally {
if (jedis != null)
jedis.close();
}
}
}
这类最大的缺点就是它加锁时只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:
实现思路:
每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点(EPHEMERAL_SEQUENTIAL)
使用Zookeeper可以实现的分布式锁是阻塞的,客户端可以通过在ZK中创建瞬时有序节点,并且在节点上绑定监听器,一旦节点发生变化,ZK会通知客户端,客户端可以检查自己创建的节点是不是**当前所有节点中序号最小的**,如果是那么自己就获取到锁,反之则继续等待
当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题,因为瞬时节点在会话断开后就会自动删除
/** * Zookeeper 实现分布式锁 */
public class ZooKeeperLock implements Watcher {
// ZK对象
private ZooKeeper zk = null;
// 分布式锁的根节点
private String rootLockNode;
// 竞争资源,用来生成子节点名称
private String lockName;
// 当前锁
private String currentLock;
// 等待的锁(前一个锁)
private String waitLock;
// 计数器(用来在加锁失败时阻塞加锁线程)
private CountDownLatch countDownLatch;
// 超时时间
private int sessionTimeout = 30000;
/** * 构造器中创建ZK链接,创建锁的根节点 * * @param zkAddress ZK的地址 * @param rootLockNode 根节点名称 * @param lockName 子节点名称 */
public ZooKeeperLock(String zkAddress, String rootLockNode, String lockName) {
this.rootLockNode = rootLockNode;
this.lockName = lockName;
try {
/** * 创建连接,zkAddress格式为:IP:PORT * watcher监听器为自身 */
zk = new ZooKeeper(zkAddress, this.sessionTimeout, this);
/** * 检测锁的根节点是否存在,不存在则创建 */
Stat stat = zk.exists(rootLockNode, false);
if (null == stat) {
zk.create(rootLockNode, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.PERSISTENT);
}
} catch (Exception e) {
e.printStackTrace();
}
}
/** * 加锁方法,先尝试加锁,不能加锁则等待上一个锁的释放 * * @return */
public boolean lock() {
if (this.tryLock()) {
System.out.println("线程【" + Thread.currentThread().getName() + "】加锁(" + this.currentLock + ")成功!");
return true;
} else {
return waitOtherLock(this.waitLock, this.sessionTimeout);
}
}
public boolean tryLock() {
// 分隔符
String split = "_lock_";
if (this.lockName.contains("_lock_")) {
throw new RuntimeException("lockName can't contains '_lock_' ");
}
try {
/** * 创建锁节点(临时有序节点)并且得到节点名称 * * path: 根节点/子锁名称+分隔符 */
this.currentLock = zk.create(this.rootLockNode + "/" + this.lockName + split, new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL);
System.out.println("线程【" + Thread.currentThread().getName()
+ "】创建锁节点(" + this.currentLock + ")成功,开始竞争...");
/** * 获取所有子节点 */
List<String> nodes = zk.getChildren(this.rootLockNode, false);
/** * 获取所有正在竞争lockName的锁 */
List<String> lockNodes = new ArrayList<String>();
for (String nodeName : nodes) {
if (nodeName.split(split)[0].equals(this.lockName)) {
lockNodes.add(nodeName);
}
}
Collections.sort(lockNodes);
/** * 获取最小节点与当前锁节点比对加锁 * * 比对最小节点的名称是否跟刚才创建的临时节点名称一致 * 一致则证明当前加锁成功 */
String currentLockPath = this.rootLockNode + "/" + lockNodes.get(0);
if (this.currentLock.equals(currentLockPath)) {
return true;
}
/** * 加锁失败,设置前一节点为等待锁节点 */
String currentLockNode = this.currentLock.substring(this.currentLock.lastIndexOf("/") + 1);
int preNodeIndex = Collections.binarySearch(lockNodes, currentLockNode) - 1;
this.waitLock = lockNodes.get(preNodeIndex);
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
/** * 等待获取锁,带超时时间 * * @param waitLock 当前节点的前一个锁 * @param sessionTimeout 等待获取锁的超时时间 * @return */
private boolean waitOtherLock(String waitLock, int sessionTimeout) {
boolean islock = false;
try {
// 监听等待锁节点
String waitLockNode = this.rootLockNode + "/" + waitLock;
Stat stat = zk.exists(waitLockNode, true);
if (null != stat) {
System.out.println("线程【" + Thread.currentThread().getName()
+ "】锁(" + this.currentLock + ")加锁失败,等待锁(" + waitLockNode + ")释放...");
/** * 设置计数器,使用计数器阻塞线程,带超时时间 */
this.countDownLatch = new CountDownLatch(1);
islock = this.countDownLatch.await(sessionTimeout, TimeUnit.MILLISECONDS);
this.countDownLatch = null;
if (islock) {
System.out.println("线程【" + Thread.currentThread().getName() + "】锁("
+ this.currentLock + ")加锁成功,锁(" + waitLockNode + ")已经释放");
} else {
System.out.println("线程【" + Thread.currentThread().getName() + "】锁("
+ this.currentLock + ")加锁失败...");
}
} else {
islock = true;
}
} catch (KeeperException e) {
e.printStackTrace();
} catch (InterruptedException e) {
e.printStackTrace();
}
return islock;
}
/** * 释放分布式锁 * * @throws InterruptedException */
public void unlock() throws InterruptedException {
try {
Stat stat = zk.exists(this.currentLock, false);
if (null != stat) {
System.out.println("线程【" + Thread.currentThread().getName() + "】释放锁 " + this.currentLock);
zk.delete(this.currentLock, -1);
this.currentLock = null;
}
} catch (InterruptedException e) {
e.printStackTrace();
} catch (KeeperException e) {
e.printStackTrace();
} finally {
zk.close();
}
}
/** * 节点监听器回调 * * @param watchedEvent */
@Override
public void process(WatchedEvent watchedEvent) {
/** * 监听节点删除的事件 * 计数器减一,恢复线程操作 */
if (null != this.countDownLatch && watchedEvent.getType() == Event.EventType.NodeDeleted) {
this.countDownLatch.countDown();
}
}
}
/** * Curator 实现的分布式锁: * InterProcessMutex: 分布式可重入排它锁 * InterProcessSemaphoreMutex: 分布式排它锁 * InterProcessReadWriteLock: 分布式读写锁 * InterProcessMultiLock: 将多个锁作为单个实体管理的容器 */
public class CuratorLock {
public static void main(String[] args) {
/** * 设置重试策略,创建zk客户端 * curator链接zookeeper的策略:ExponentialBackoffRetry * baseSleepTimeMs:初始sleep的时间 * maxRetries:最大重试次数 * maxSleepMs:最大重试时间 */
RetryPolicy retryPolicy = new ExponentialBackoffRetry(1000, 3);
CuratorFramework client =
CuratorFrameworkFactory.newClient("127.0.0.1:2181", retryPolicy);
// 启动客户端
client.start();
/** * 创建分布式可重入排他锁,监听客户端为client,锁的根节点为/locks */
InterProcessMutex mutex = new InterProcessMutex(client, "/locks");
try {
/** * 加锁操作 * public boolean acquire(long time, TimeUnit unit) * 第一个参数是超时时间 * 第二个参数是时间的单位 */
mutex.acquire(3, TimeUnit.SECONDS);
/** * 释放锁 */
mutex.release();
} catch (Exception e) {
e.printStackTrace();
} finally {
client.close();
}
}
}
**使用Zookeeper也有可能带来并发问题:**由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了,就可能产生并发问题。
这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点(所以选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡)