21

老板问我分布式锁,结果悲剧了......

 4 years ago
source link: http://developer.51cto.com/art/202004/614180.htm
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

公司交给了萌新小猿一个光荣而艰巨的项目,该项目需要使用分布式锁,这可难道了小猿。

mMfIB3m.jpg!web

图片来自 Pexels

只是听说过分布式锁很牛掰,其他就一概不知了,唉,不懂就问呗,遂向老板请教。

老板:我们每天不都在经历分布式锁吗,我来给你回忆回忆。

小猿:好勒,瓜子板凳已备好。

本文结构:

  • 为什么要使用分布式锁
  • 分布式锁有哪些特点
  • 分布式锁流行算法及其优缺点
  • 总结

为什么要使用分布式锁

这个问题应该拆分成以下 2 个问题回答。

①为什么使用锁

保证在同一时刻共享资源只能被一个客户端访问;根据锁用途分为以下两种:

  • 共享资源只允许一个客户端操作
  • 共享资源允许多个客户端操作

仅允许一个客户端访问:共享资源的操作不具备幂等性。常见于数据的修改、删除操作。

RniIniR.jpg!web

在上面的例子中:

7rqQRjV.jpg!web

允许多个客户端操作:主要应用场景是共享资源的操作具有幂等性;如数据的查询。

既然都具有幂等性了,为什么还需要分布式锁呢,通常是为了效率或性能,避免重复操作(尤其是消耗资源的操作)。

例如我们常见的缓存方案:

zEzuyqn.jpg!web

在上面的例子中:

7zMrQnB.jpg!web

由于此处的资源是幂等的,通常会将这类资源做缓存,这就是常见的锁+缓存架构。

常适用于获取较为消耗资源(时间、内存、CPU 等)的幂等资源,如:

  • 查询用户信息
  • 查询历史订单

当然,如果资源仅在一段时间范围内具有幂等性,这时候,架构就应该升级了:

锁+缓存+缓存失效/失效重新获取/缓存定时更新。

②锁为什么需要分布式的?

还是以上面的缓存方案为例,此处略作变化:

yyYnAv3.jpg!web

在上面的例子中:

RFFV3ym.jpg!web

分布式锁有哪些特点?

①互斥性

在任意时刻,仅允许有一个客户端获得锁。

PS:如果多个客户端都能同时获得锁,那锁就没意义了,共享资源的安全性也就无法保证了。

老板:当我在会议室接待客户 A 时,其他客户只有等待,你需要等到我空闲了才能把其他人带到我办公室。

小猿:明白。

接待客户(非幂等共享资源);等到老板空闲(获取锁)。

②可重入性

客户端 A 获得了锁,只要锁没有过期,客户端 A 可以继续获得该锁。锁在我这里,我还要继续使用,其他人不准抢。

这种特性可以很好的支持【锁续约】功能。例如:客户端 A 获取锁,锁释放时间为 10S,即将到达 10S 时,客户端 A 未完成任务,需要再申请 5S。若锁没有可重入性,客户端 A 将无法续约,导致锁可能被其他客户端抢走。

小猿:受教了,老板 3 分钟后你还有一场面试。

老板:小猿啊,难得你这么好学,我很欣慰,我们的交流时间延10分钟吧,其他会议延后。

③高性能

获取锁的效率应该足够高;总不能让业务阻塞在获取锁上面吧?

小猿:好的,我已在钉钉申请将会议延长 10 分钟了。

老板:嗯,我已经接受会议邀请了;

小猿:老板你真高效。

④高可用

分布式、微服务环境下,必须保证服务的高可用,否则轻则影响其他业务模块,重则引发服务雪崩。

老板:我手机 24 小时开机,有会议时联系不上我也可以联系我秘书。

⑤支持阻塞和非阻塞式锁

获取锁失败,是直接返回失败,还是一直阻塞知道获取成功?不同的业务场景有不同的答案。

例如:

YZFZ7r3.jpg!web

⑥解锁权限

客户端仅能释放(解锁)自己加的锁。常见的解决方案是,给锁加随机数(或 ThreadID)。

老板:小猿啊,给你讲了这么多,都明白了吗?

笼子里的鹦鹉:明白啦,明白啦。

老板:闭嘴,我问的是小猿,只有小猿自己有资格回答。

⑦避免死锁

加锁方异常终止无法主动释放锁;常规做法是 加锁时设置超时时间,如果未主动释放锁,则利用 Redis 的自动过期被动释放锁。

秘书破门而入:老板,你们 10 分钟的会议已经到点了,隔壁的李总已经等不及了。

老板:一不留神就忘记时间了,我得去见李总了。

小猿:老板,我们还没聊完呢...

⑧异常处理

常见的异常情况有 Redis 宕机、时钟跳跃、网络故障等。

小猿:不管出现哪种情况,我获取锁都会失败啊,这可怎么办呢?

PS:这就复杂了,需要根据具体的业务场景分析。对于必须同步处理的业务,则必须失败告警,对于允许延迟处理的业务可以考虑记录失败信息待其他系统处理。

分布式锁流行算法

基本方案 SETNX

基于 Redis 的 SETNX 指令完成锁的获取。

①获取锁 SET lock:resource_name random_value NX PX 30000

lock:resource_name:资源名字,加锁对象的唯一标记。

random_value:通常存储加锁方的唯一标记,如“UUID+ThreadID”。

NX:Key 不存在才设置,即锁未被其他人加锁才能加锁。

PX:锁超时时间。

当然,此种加锁方式是不支持“锁重入性”的。

②释放锁(LUA 脚本)

checkValueThenDelete:检查解锁方是否是加锁方,是则允许解锁,否则不允许解锁。

伪代码是:

public class RedisTool { 
    // 释放锁成功标记 
    private static final Long RELEASE_LOCK_SUCCESS = 1L; 
 
    /** 
     * 释放分布式锁 
     * 
     * @param jedis     Redis客户端 
     * @param lockKey   锁标记 
     * @param lockValue 加锁方标记 
     * @return 是否释放成功 
     */ 
    public static boolean releaseDistributedLock(Jedis jedis, String lockKey, String lockValue) { 
        String script = "" + 
                "if redis.call('get', KEYS[1]) == ARGV[1] then" + 
                "    return redis.call('del', KEYS[1]) " + 
                "else" + 
                "    return 0 " + 
                "end"; 
        // Collections.singletonList():用于只有一个元素的场景,减少内存分配 
        Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); 
        if (RELEASE_LOCK_SUCCESS.equals(result)) { 
            return true; 
        } 
        return false; 
    } 
} 

Redlock 算法

此算法由 Redis 作者 antirez 提出,作为一种分布式场景下的锁实现方案。

Redlock 算法原理:【核心】大多数节点获取锁成功且锁依旧有效。

Step 1:获取当前时间(毫秒数)。

Step 2:按序想 N 个 Redis 节点获取锁。设置随机字符串 random_value;设置锁过期时间:

  • Note 1:获取锁需设置超时时间(防止某个节点不可用),且 timeout 应远小于锁有效时间(几十毫秒级)。
  • Note 2:某节点获取锁失败后,立即向下一个节点获取锁(任何类型失败,包含该节点上的锁已被其他客户端持有)。

Step 3:计算获取锁的总耗时 totalTime。

Step 4:获取锁成功。

获取锁成功:客户端从大多数节点(>=N/2+1)成功获取锁,且 totalTime 不超过锁的有效时间。

重新计算锁有效时间:最初锁有效时间减 3.1 计算的获取锁消耗的时间。

Step 5:获取锁失败。

获取失败后应立即向【所有】客户端发起释放锁(Lua 脚本)。

Step 6:释放锁。

业务完成后应立即向【所有】客户端发起释放锁(Lua 脚本)。

7fq2Y3q.png!web

Redlock 算法优点:

  • 可用性高,大多数节点正常即可。
  • 单 Redis 节点的分布式锁在 failover 时锁失效问题不复存在。

Redlock 算法问题点:

  • Redis 节点崩溃将影响锁安全性:节点崩溃前锁未持久化,节点重启后锁将丢失;Redis 默认 AOF 持久化是每秒刷盘(fsync)一次,最坏情况将丢失 1 秒的数据。
  • 需避免始终跳跃:管理员手动修改时钟;使用[不会跳跃调整系统时钟]的 ntpd(时钟同步)程序,对时钟修改通过多次微调实现。
  • 客户端阻塞导致锁过期,导致共享资源不安全。
  • 如果获取锁消耗时间较长,导致效时间很短,是否应该立即释放锁?多段才算短?

带 fencing token 的实现

分布式系统专家 Martin Kleppmann 讨论提出 RedLock 存在安全性问题。

神仙之战:Martin Kleppmann 认为 Redis 作者 antirez 提出的 RedLock 算法有安全性问题,双方在网络上多轮探讨交锋。

Martin 指出 RedLock 算法的核心问题点如下:

  • 锁过期或者网络延迟将导致锁冲突:客户端 A 进程 pause→锁过期→客户端 B 持有锁→客户端 A 恢复并向共享资源发起写请求;网络延迟也会产生类似效果。
  • RedLock 安全性对系统时钟有强依赖。

fencing token 算法原理:

  • fencing token 是一个单调递增的数字,当客户端成功获取锁时随同锁一起返回给客户端。
  • 客户端访问共享资源时带上 token。
  • 共享资源服务检查 token,拒绝延迟到来的请求。

fencing token 算法问题点:

  • 需要改造共享资源服务。
  • 如果资源服务也是分布式,如何保证 token 在多个资源服务节点递增。
  • 2 个 fencing token 到达资源服务的顺序颠倒,服务检查将异常。
  • 【antirez】既然存在 fencing 机制保持资源互斥访问,为什么还需要分布式锁且要求強安全性呢。

其他分布式锁

数据库排它锁:

  • 获取锁(select for update ,悲观锁)。
  • 处理业务逻辑。
  • 释放锁(connection.commit())。

注意:InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。So 必须给 lock_name 加索引。

ZooKeeper 分布式锁:

  • 客户端创建 znode 节点,创建成功则获取锁成功。
  • 持有锁的客户端访问共享资源完成后删除 znode。
  • znode 创建成 ephemeral(znode 特性),保证创建 znode 的客户端崩溃后,znode 会被自动删除。
  • 【问题】Zookeeper 基于客户端与 Zookeeper 某台服务器维护 Session,Session 依赖定期心跳(heartbeat)维持。

Zookeeper 长时间收不到客户端心跳,就任务 Session 过期,这个 Session 所创建的所有 ephemeral 类型的 znode 节点都将被删除。

Google 的 Chubby 分布式锁:

  • sequencer 机制(类似 fencing token)缓解延迟导致的问题。
  • 锁持有者可随时请求一个 sequencer。
  • 客户端操作资源时将 sequencer 传给资源服务器。
  • 资源服务器检查 sequencer 有效性:①调用 Chubby 的 API(CheckSequencer)检查。②对比检查客户端、资源服务器当前观察到的 sequencer(类似 fencing token)。③lock-delay:允许客户端为持有锁指定一个 lock-delay 延迟时间,Chubby 发现客户端失去联系时,在 lock-delay 时间内组织其他客户端获取锁;

总结

我们该使用怎样的分布式锁算法?

  • 技术都是为业务服务的,避免选择“高大上”的炫技;
  • 依托业务场景,尽可能选择最简单的做法;
  • 最简单的分布式锁导致偶发性异常如何处理呢?建议增加额外的机制甚至人工介入保证业务准确性,通常这部分成本低于复杂的分布式锁的开发、运维成本。

分布式锁的另类玩法,“分而治之”经久不衰:

  • 如果共享资源本身可以拆分,那就分开处理吧。
  • 比如电商系统防止超卖,假设有 10000 个口罩将被秒杀,常规做法是一个锁控制所有资源。另类玩法就是将 10000 个口罩交由 20 个锁控制,整体性能瞬间提升几十倍。

PS:此处超卖仅是举例,真实场景下的秒杀超卖有更加复杂的场景,慎重。

RZ7rInU.gif


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK