评估标准

问题

  • 高可用问题:无论何时都要保证锁服务的可用性(这是系统正常执行锁操作的基础)。集群 + 自动故障转移
  • 死锁问题:客户端一定可以获得锁,即使锁住某个资源的客户端在释放锁之前崩溃或者网络不可达(这是避免死锁的设计原则)。 redis: 超时自动解锁 + 守护进程自动延长锁超时时间
    zk: 连接断开,自动释放
  • 脑裂问题:集群同步时产生的数据不一致,导致新的进程有可能拿到锁,但之前的进程以为自己还有锁,那么就出现两个进程拿到了同一个锁的问题。 (大部分节点成功才算成功)
    • redis使用realock算法,
    • zk天然支持
  • 可重入问题: 在客户端或者锁的时候,记录锁次数,与当前线程绑定,如TreadLocal实现,或者将线程上下文信息,报错在数据库的字段。

设计原则

  • 互斥性:即在分布式系统环境下,对于某一共享资源,需要保证在同一时间只能一个线程或进程对该资源进行操作。
  • 高可用:也就是可靠性,锁服务不能有单点风险,要保证分布式锁系统是集群的,并且某一台机器锁不能提供服务了,其他机器仍然可以提供锁服务。
  • 锁释放:具备锁失效机制,防止死锁。即使出现进程在持有锁的期间崩溃或者解锁失败的情况,也能被动解锁,保证后续其他进程可以获得锁。
  • 可重入:一个节点获取了锁之后,还可以再次获取整个锁资源。

解决方案

基于关系型数据库实现分布式锁(悲观锁)

先查询数据库是否存在记录,为了防止幻读取(幻读取:事务 A 按照一定条件进行数据读取,这期间事务 B 插入了相同搜索条件的新数据,事务 A 再次按照原先条件进行读取时,发现了事务 B 新插入的数据 )通过数据库行锁 select for update 锁住这行数据,然后将查询和插入的 SQL 在同一个事务中提交。

**select id from order where order_id = xxx for update**

延伸问题,事务隔离级别

基于乐观锁的方式实现分布式锁.( 一般需重试)。

select for update 是悲观锁,会一直阻塞直到事务提交,所以为了不产生锁等待而消耗资源,你可以基于乐观锁的方式来实现分布式锁,比如基于版本号的方式,首先在数据库增加一个 int 型字段 ver,然后在 SELECT 同时获取 ver 值,最后在 UPDATE 的时候检查 ver 值是否为与第 2 步或得到的版本值相同

## SELECT 同时获取 ver 值
select amount, old_ver from order where order_id = xxx
## UPDATE 的时候检查 ver 值是否与第 2 步获取到的值相同
update order set ver = old_ver + 1, amount = yyy where order_id = xxx and ver = old_ver;

基于唯一索引创建锁

通过创建数据库唯一索引,插入成功,获得锁。失败,则未获得

基于分布式缓存实现分布式锁(AP模型)

因为数据库的性能限制了业务的并发量,所以针对“ 618 和双 11 大促”等请求量剧增的场景,你要引入基于缓存的分布式锁,这个方案可以避免大量请求直接访问数据库,提高系统的响应能力。

SET lock_key unique_value NX PX 10000

  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识;释放时,需要判断,是否是同一个客户端
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁
    主要以下2个方案。
  • setNX + Lua脚本
  • redisson + RLock可重入锁(推荐使用)

redis普通实现

技巧💡

setnx 指令 + expire 指令。

  1. 业务执行太长,需要增加守护进程。
  2. 集群同步未及时问题

加锁: setnx 指令 + expire 指令。

2.6.12之前,需要使用lua。保证原子性。 setnx不支持expire设置失效时间,所以只能用lua保证原子性。

2.6.12之后,支持原子性。set命令,支持 NX 和 EXPIRE参数。

解锁:

判断加锁和解锁是同一个客户端,需要使用lua保证原子性

// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

优点:

  1. 性能高效
  2. 实现方便,使用redis,很多项目都会使用redis作为缓存
  3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)

缺点:

  1. 不合理设置超时时间,

    1. 如A锁未处理完,但是已经超时了。这是B锁又来申请
    2. 方案: 增加守护进程,定时刷新过期时间,增加了复杂度
  2. Redis 集群的数据同步机制。

    由于 Redis 集群数据同步到各个节点时是异步的,如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁
    redis官方处理,Redlock算法

   @Resource
    private StringRedisTemplate stringRedisTemplate;
 
    @SuppressWarnings("all")
    public boolean lock(String key, String value, long second) {
        return stringRedisTemplate.execute((RedisCallback<Boolean>) connection -> {
            return connection.set(key.getBytes(StandardCharsets.UTF_8),
                    value.getBytes(StandardCharsets.UTF_8),
                    Expiration.seconds(second), RedisStringCommands.SetOption.ifAbsent());
        });
    }
    public boolean release(String key, Object requestId) {
        RedisScript<Long> script = new DefaultRedisScript<>(
                "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", Long.class);
        final Long result = stringRedisTemplate.execute(script, Collections.singletonList(key), requestId);
        return Objects.equals(result, 1L);
    }

redis Redlock算法

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求申请加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。

我们假设目前有 N 个独立的 Redis 实例, 客户端先按顺序依次向 N 个 Redis 实例执行加锁操作。这里的加锁操作和在单实例上执行的加锁操作一样,但是需要注意的是,Redlock 算法设置了加锁的超时时间,为了避免因为某个 Redis 实例发生故障而一直等待的情况。

当客户端完成了和所有 Redis 实例的加锁操作之后,如果有超过半数的 Redis 实例成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功

Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
        .setMasterName("masterName")
        .setPassword("password").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);
// 还可以getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
    isLock = redLock.tryLock();
    // 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
    isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
    if (isLock) {
        //TODO if get lock success, do something;
    }
} catch (Exception e) {
} finally {
    // 无论如何, 最后都要解锁
    redLock.unlock();
}

可重入: 客户端记录锁次数。

高可用: 守护线程,延迟线程。 大部分redis加解锁成功,才算成功

基于 Zookeeper 实现分布式锁(AP模型)

技巧💡

临时顺序节点,监听watch。将最小的节点进行加锁。

  1. 在客户端端口后,临时节点自动释放。其它节点就可以获取到锁
  2. create临时节点时,如果已经存在会报错。(唯一性)
  3. zk的集群是高可用的,只要半数以上的或者,就可以对外提供服务了
public void sharedReentrantLock() throws Exception {
    // 创建可重入锁
    InterProcessLock lock = new InterProcessMutex(client, lockPath);
    // lock2 用于模拟其他客户端
    InterProcessLock lock2 = new InterProcessMutex(client2, lockPath);
    // lock 获取锁
    lock.acquire();
    try {
        // lock 第二次获取锁
        lock.acquire();
        try {
            // lock2 超时获取锁, 因为锁已经被 lock 客户端占用, 所以获取失败, 需要等 lock 释放
            Assert.assertFalse(lock2.acquire(2, TimeUnit.SECONDS));
        } finally {
            lock.release();
        }
    } finally {
        // 重入锁获取与释放需要一一对应, 如果获取 2 次, 释放 1 次, 那么该锁依然是被占用, 如果将下面这行代码注释, 那么会发现下面的 lock2 获取锁失败
        lock.release();
    }
    // 在 lock 释放后, lock2 能够获取锁
    Assert.assertTrue(lock2.acquire(2, TimeUnit.SECONDS));
    lock2.release();
}

资料

  • 06 | 分布式系统中,如何回答锁的实现原理?.html