分布式锁实现方案

并发是很多系统都需要面对的问题,在Java语言中提供了很多并发处理相关的API,但是这些API仅适用于在单节点环境中,在分布式环境中就无能为力了,因此我们要寻找解决方案来解决分布式系统中的并发问题,而并发问题的实质就是数据的一致性问题,为此我们引入分布式锁。

概念

分布式锁是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。

分布式锁要满足哪些要求呢?

  • 排他性: 在同一时间只会有一个客户端能获取到锁,其它客户端无法同时获取
  • 避免死锁: 这把锁在一段有限的时间之后,一定会被释放(正常释放或异常释放)
  • 高可用: 获取或释放锁的机制必须高可用且性能佳

实现方案

目前主流的实现方案有三种:

  • 基于Mysql数据库实现
  • 基于Redis实现
  • 基于ZooKeeper实现

上面的三种方式各有优缺点,无论哪种都需要根据实际的业务场景来选择和完善。下面来分别介绍下每种解决方案的思路。

基于Mysql数据库实现

基于Mysql的InnoDB引擎,使用for update来实现。使用该方案实现的锁又称悲观锁或排它锁。

解决思路:
创建一个数据表distributed_lock,字段id(主键)、lock_name。当我们想要获取锁时,就在该表中增加一条记录,想要释放锁的时候就删除这条记录。

在查询语句后面增加for update,数据库会在查询过程中给数据库表增加排他锁。当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。我们可以认为获得排它锁的线程即可获得分布式锁。

需要注意的是,在InnoDB中只有字段加了索引才会使用行级锁,所以这个lock_name字段要加索引。另外由于Mysql本身会优化sql,最终的查询可能会不走索引,(比如说表中数据量较少的时候这种情况就经常发生)也就是说不会使用行级锁,从而带来并发问题。

以下代码仅用于展示实现思路:

// 获取锁
public boolean lock() {
	connection.setAutoCommit(false)
	while (true) {
		try {
			int result = select count(1) from distributed_lock where lock_name = #{lockName} for update
			if(result == 0) {
				return true;
			}
			Thread.sleep(1000);
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

// 释放锁
public void unlock() {
	connection.commit();
}

该种方案存在的问题:

  1. 数据库单点,出现故障则将导致系统不可用。
  2. 锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。
  3. mysql优化所带来的行级锁失效问题
基于Redis实现

基于Redis实现的锁机制,主要是依赖redis自身的原子操作,例如:

SET user_key user_value NX PX 3000

NX: 只在在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value
PX millisecond: 设置键的过期时间为millisecond毫秒,当超过这个时间后,设置的键会自动失效。

解决思路:
将某一任务标识名,例如REQUEST_ID作为Key存到redis里,并为其设个过期时间。请求过来时,先是通过setnx()看是否能将REQUEST_ID插入到redis里,能插入则获取到锁,无法插入则获取锁失败。

以下代码仅用于展示实现思路:

// 加锁
public void lock() {
	while (true) {
		// 当某个key不存在时才会设置成功,多个进程同时访问只会有一个成功
		String value = jedis.get().set(LOCK_NAME, REQUEST_ID, "NX", "PX", 3000);
		if ("OK".equals(value)) {
			return true;
		}
		return false;
	}
}

// 释放锁
public void unlock() {
	// 使用lua脚本保证原子性,只有获取锁的线程才可以释放锁
	String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
	jedis.get().eval(script, Collections.singletonList(LOCK_NAME), Collections.singletonList(REQUEST_ID));
}

该种方案存在的问题:

  1. 通过超时时间来控制锁的失效时间并不稳定。
基于Zookeeper实现

基于Zookeeper的临时有序节点以及watcher特性来实现

解决方案:
针对某个共享资源锁定义一个根基点,例如:/LOCK,当有进程过来想获取锁的时候就在该根节点下生成一个唯一的临时有序节点。(我们订一个规则,比如序号最小的节点获取锁)如果本身序号最小则获取到锁,如果不是最小就在序列中找到比自己小的那个节点,然后调用exists()方法并对其注册事件监听。如果监听到这个节点被删除了,就再去判断一次当前这个节点是否变成了序列中最小的。如果是,则获取锁,如果不是,则重复上述步骤。

释放锁的时候手动删除这个节点即可。虽然临时节点在session断开后会自动删除,但是为了性能肯定是要手动去删除。而临时节点的这个特点可用与当程序出现异常无法主动释放锁时,锁可以自动释放掉。

下图展现了上面的解决思路:
zk分布式锁实现
以下代码仅用于展示实现思路:

	private static final String LOCK_NAME = "/LOCK";
	private ThreadLocal<ZooKeeper> zk = new ThreadLocal<>();
	private ThreadLocal<String> CURRENT_LOCK = new ThreadLocal<>();

	// 加锁
	public boolean lock() {
 		String nodeName = LOCK_NAME + "/zk_";
		try {
            CURRENT_LOCK.set(zk.get().create(nodeName, new byte[0], ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL));

            List<String> nodeList = zk.get().getChildren(LOCK_NAME, false);
            Collections.sort(nodeList);

            String minNode = nodeList.get(0);
     		// 是最小节点则获取到锁
            if (CURRENT_LOCK.get().equals(LOCK_NAME + "/" + minNode)) {
                return true;
            } else {
               // 监听比自己序号小的前一个节点
                int prevNodeIndex = nodeList.indexOf(CURRENT_LOCK.get().replace(LOCK_NAME + "/", "")) - 1;
                String prevNode = nodeList.get(prevNodeIndex);
                final CountDownLatch countDownLatch = new CountDownLatch(1);
                // 增加监听事件
                Stat prevExist = zk.get().exists(LOCK_NAME + "/" + prevNode, new Watcher() {
                    @Override
                    public void process(WatchedEvent event) {
                        if (Event.EventType.NodeDeleted.equals(event.getType())) {
                            System.out.println(Thread.currentThread().getName() + "唤醒锁");
                            countDownLatch.countDown();
                        }
                    }
                });

                if (null != prevExist) {
                    countDownLatch.await();
                }
            }
        } catch (KeeperException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return false;
    }

	// 释放锁
    public void unlock() {
        try {
            zk.get().delete(CURRENT_LOCK.get(), -1);
            CURRENT_LOCK.remove();
            zk.get().close();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (KeeperException e) {
            e.printStackTrace();
        }
    }
三种方案的比较

按理解的难易程度(从低到高)
数据库 > 缓存 > Zookeeper

按性能(从高到低)
Redis > Zookeeper > Mysql

按综合实力(从高到低)
Zookeeper > Redis > Mysql

参考资料:


------------本文结束感谢您的阅读------------
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值