分布式锁实战-偶遇 etcd 后就想抛弃 Redis ?

4,044 阅读13分钟

​篇幅太长看着也累,尝试分成多个小章节,每天进步一点点。

前情回顾

分布式锁系列内容规划如下,本篇是第 3 篇:

  1. 《分布式锁上-初探
  2. 《分布式锁中-基于 Zookeeper 的实现是怎样》
  3. 《分布式锁中-偶遇 etcd 后就想抛弃 Redis ?》(本篇)
  4. 《分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇》
  5. 《分布式锁中-基于 Redis 的实现很多样 - Redission 篇》(写作中)
  6. 《分布式锁中-基于 MySQL 的实现 - 有得用就好》(写作中)
  7. 《分布式锁中-多维度的对比各种分布式锁实现》(写作中)
  8. 《分布式锁下-分布式锁客户端的抽象、适配与加固》(写作中)

一、etcd 简介

1. etcd 的背景

虽然Kubernetes 给云原生时代带来了颠覆性的新气象,但却很少人了解被钦定作为其后端存储的 etcd ,本篇从分布式锁视角梳理etcd的各种机制,探索基于etcd的锁实现是怎样。

etcd 能被Kubernetes 如此青睐,是因为它一直在聆听社区的声音并快速改进,积极配合 Kubernetes 项目向前推进,解决社区反馈的痛点;发起 V2 到 V3 的重大版本更新,尤其是 19 年由 Google、Alibaba 等公司联合打造的 3.4 版本,满足了 Kubernetes 在超大型公司大规模使用中严苛的可用性、扩展性和性能等要求。

图片

上图描述了 etcd 名字的由来,其寓意是为大规模分布式系统提供存储配置信息;华为 Kubernetes 专家杜军老师专门为 etcd 著书《云原生分布式存储基石-etcd》,称它基石足见何等重要,另一位大咖etcd 的作者李响老师曾讲:“etcd 就是用来存储云上最重要的数据的”;相信随着云原生架构的演进,etcd 会担任越来越多重要的角色;技术人员在云原生时代,除了拥抱 Kubernetes,也要拥抱 etcd。

2.etcd vs ZooKeeper

很长一段时间 ZooKeeper(后文简称 ZK) 被作为默认首选项,用于解决分布式系统的协同和元数据存储,但其太复杂、迭代慢、难维护、性能缺陷等问题逐渐成为槽点;而 etcd 吸取了 ZK 的教训,从设计和实现上具有后见之明,提供了更好的工程和运维体验,其主要改进在于如下几个方面:

  • 动态的集群节点关系重配置
  • 高负载条件下的稳定读写
  • 多版本并发控制的数据模型
  • 持久、稳定的监听机制
  • 租约 (lease) 原语实现了连接与会话的解耦
  • 安全的分布式共享锁 API
  • 普适的 HTTP 、GRPC 通信协议

据说在一个由 3 台 8 核节点组成的云服务器上, etcd v3 版本可以做到每秒数万次的写操作和数十万次的读操作(ZK:😲);后续会有其他篇章结合QA的测试结果来探讨etcd服务端的设计和性能情况。

3.etcd 特性介绍

为满足本篇目标所需,也考虑到对 etcd 熟悉的读者不多,这里着重介绍以下几个关键特性:

  • 数据组织:etcd 在 V2 版本时跟 ZK 一样以树形目录结构来组织数据,V3 版本则通过半开区间( Key range)取代 V2 中的目录结构,优化成扁平的 Key-Value 结构,Key 仍采用之前的格式如/lock/lock1/uuid1、/lock/lock1/uuid2​,感官跟树形目录形式保持一致,但实际是一个完整独立的 key;可以基于 前缀(Prefix)机制通过/lock/lock1/​查询一批拥有相同前缀的数据,但其语义是前缀匹配查询而非目录子节点查询;这个改造也是为了支撑更多的特性和达到更好的性能。

image.png

  • 集群模式:通常是由 3、5 个基数实例组成集群,当超过半数服务实例正常工作就能对外提供服务,既能避免单点故障,又尽量高可用,每个服务实例都有一个数据备份,通过 raft 协议实现数据全局一致。

image.png

  • 顺序更新:每个 etcd 节点都可接收读写请求,但变更类(增删改)请求会在集群内转给 leader 执行,来所有客户端的变更请求将按照 leader 接收的顺序被处理,在 ZK 中插入同名节点,ZK 会自动为同名节点名后添加递增序号,即避免冲突又具有顺序管控的意义;但 etcd 中不能插入同名 Key,它是采用 revision 机制来管控更新类请求的顺序,使用一个全局计数器,单调递增,每当有数据变更,revision 就会加一(具有全局唯一性),而每个 revision 都关联对应修改的数据,可以通过 revision 的大小推断数据变更的顺序,利用这个特性可以实现高级协调服务。

image.png

  • 租约(Lease)机制:常见的 TTL(Time To Live)机制是给单个 Key-Value 设置存活时间,超过时间后自动删除这个 Key-Value,V3 中加入的租约机制更高级一些,针对每个 Lease 设置了一个 TTL 时间,存储 Key-Value 时可指定一个 Lease,多个拥有相同 TTL 的 key 绑定到同一个 Lease,实现 Lease 的复用。同时也支持续约,可以通过客户端在 Lease 到期之前续约,以避免 Key-Value 过期后失效,也可以通过客户端主动解约,这比 ZK 中基于 session 的状态保持更灵活。

图片

  • 监听机制:客户端可以注册对某一个 Key 或一批相同前缀(前缀机制,似 ZK 中的父节点)Key 的监听,当被监听的这些 Key 发生变更,客户端将收到通知,感知到变更。

image.png

etcd 的分布式锁正是基于以上特性来实现的,简单来说是:

  • 租约机制:用于支撑异常情况下的锁自动释放能力
  • 前缀和 Revision 机制:用于支撑公平获取锁和排队等待的能力
  • 监听机制:用于支撑抢锁能力
  • 集群模式:用于支撑锁服务的高可用

二、加解锁的流程描述

图片

1.准备客户端和 Key
  • 客户端连接 Etcd,获取 LockClient,获取 LeaseClient

  • 以/lock/lock1​ 为前缀 + / + uuid 创建全局唯一的 key,如:

    • client-a 线程 1 的 key 为"/lock/lock1/uuid1",申请 lock1
    • client-a 线程 2 的 key 为"/lock/lock1/uuid2",申请 lock1
    • client-c 线程 1 的 key 为"/lock/lock2/uuid3",申请 lock2
    • client-b 线程 1 的 key 为"/lock/lock2/uuid4",申请 lock2
  • 客户端 a、b、c 分别创建租约,租约的时长由业务输入确定

2.创建租约并保持续租
  • 方案 1-创建定时任务定时续租,无论客户端是持锁状态还是等待锁状态 Key 都必须存在,而 Key 是否释放由租约管控,所以都需要保持租约的活性

    • 持锁状态:当业务未完成时,不能让租约到期,需定时续租;当业务完成时可主动解除租约,持锁 Key 会被删除;若客户端异常,租约到期后持锁 Key 也会被删除;等锁的客户端监听到持锁 Key 被删除后,可开始抢锁。

    • 等锁状态:等锁超时会主动解除租约,或客户端异常时等锁 key 被删除,后边排队的就前进一步,尝试抢锁。

  • 方案 2-使用自动续约

3.绑定租约写 key
  • 每个客户端在执行 put 操作时,将第 1 步中准备的具有唯一性的 Key 绑定租约写入 etcd
4.获取竞争锁的 key-Value 列表
  • 以 lock1 为例,客户端以前缀"/lock/lock1" 读取所有匹配此前缀的的 key-Value 列表(key-Value 中带有 key 对应的 Revision)
5.对所获取的 Key-Value 列表按 revision 从小到大排序
6.判断自己是不是第一个(revision 最小),若是,则成功获取锁
7.若不是,则监听自己的前一个 Key-Value 的删除事件
  • 删除事件由主动释放租约或者因租约过期失效而触发
  • 这种只监听前一个 Key-Value 的方式避免了惊群效应问题
8.若是阻塞申请锁,则申请锁的操作可增加阻塞等待
9.若监听事件生效,则回到第 4 步重新进行判断,直到获取到锁
10解锁时,将第一个 Key-Value 的租约释放

三、etcd 分布式锁的能力

可能读者是单篇阅读,这里引入第一篇《分布式锁上-初探》中的一些内容,一个分布式锁应具备这样一些功能特点:

  • 互斥性:在同一时刻,只有一个客户端能持有锁
  • 安全性:避免死锁,如果某个客户端获得锁之后处理时间超过最大约定时间,或者持锁期间发生了故障导致无法主动释放锁,其持有的锁也能够被其他机制正确释放,并保证后续其它客户端也能加锁,整个处理流程继续正常执行
  • 可用性:也被称作容错性,分布式锁需要有高可用能力,避免单点故障,当提供锁的服务节点故障(宕机)时不影响服务运行,这里有两种模式:一种是分布式锁服务自身具备集群模式,遇到故障能自动切换恢复工作;另一种是客户端向多个独立的锁服务发起请求,当某个锁服务故障时仍然可以从其他锁服务读取到锁信息(Redlock)
  • 可重入性:对同一个锁,加锁和解锁必须是同一个线程,即不能把其他线程持有的锁给释放了
  • 高效灵活:加锁、解锁的速度要快;支持阻塞和非阻塞;支持公平锁和非公平锁

基于上边对 etcd 分布式锁的介绍,这里简单总结一下 etcd 的能力矩阵,ZK 的情况请看《分布式锁中-基于 Zookeeper 的实现》,redis锁的情况会在后续文章中补充

能力ZKetcdRedis 原生Redlock
互斥
安全链接异常时,session 丢失自动释放锁基于租约,租约过期后自动释放锁,不用像ZK那样释放链接
可用性相对可用性还好
可重入服务端非可重入,本地线程可重入服务端非可重入,本地线程可重入需自研
加解锁速度速度不算快速度快,如GRPC 协议优势、服务端性能的优势
阻塞非阻塞客户端两种能力都提供jetcd-core 中,阻塞非阻塞由 Future#get 的超时控制能力支撑
公平非公平公平锁公平锁
可续租天然支持,基于session天然支持,基于Lease

四、jetcd 库实现分布式锁

etcd 针对 java 语言的客户端有官方的 jetcd-core 还有 IBM 的 etcd-java,下文使用 jetcd-core 做示例。

jetcd-core 中提供了高阶的 Lock API(无需使用者准备唯一 key、前缀查询 Key-Value 列表、排序判断自己是否 revision 最小的、监听前一个 Key-Value 的删除),使用者只需关注最原始的诉求:申请的锁是什么名称、用多久,申请不到就等多久;使用高阶 API 实现分布式锁的流程会比第 2 部分原生的流程要简单许多,流程如下:

  • 连接 etcd 集群

  • 获取 LockClient,获取 LeaseClient,不需要准备 key

  • 创建租约并保持续租, 你觉得哪一种方案会更好呢?

    • 方案 1-创建定时任务定时续租
    • 方案 2-使用自动续约
  • 绑定租约抢锁

  • 若抢到锁,则返回锁路径信息

  • 若没抢到锁,则阻塞等待(可设置超时返回)

  • 解锁时,传入锁信息解锁;根据业务场景决定如何解约

1.pom 依赖

<dependency>
  <groupId>io.etcd</groupId>
  <artifactId>jetcd-core</artifactId>
  <version>0.7.3</version>
</dependency>

2.Lease 相关的 API 介绍

public interface Lease extends CloseableClient {

     //创建一个租约,过期时间是ttl,单位秒;没有请求超时控制
    CompletableFuture<LeaseGrantResponse> grant(long ttl);

     //创建一个租约,过期时间是ttl,单位秒;后两个参数是请求超时控制
    CompletableFuture<LeaseGrantResponse> grant(long ttl, long timeout, TimeUnit unit);

     //解约
    CompletableFuture<LeaseRevokeResponse> revoke(long leaseId);

    //主动续约1次
    CompletableFuture<LeaseKeepAliveResponse> keepAliveOnce(long leaseId);

    //查询租约信息
    CompletableFuture<LeaseTimeToLiveResponse> timeToLive(long leaseId, LeaseOption leaseOption);

    //自动续约
    CloseableClient keepAlive(long leaseId, StreamObserver<LeaseKeepAliveResponse> observer);
}

3.lock 相关的 API 介绍

public interface Lock extends CloseableClient {

    //绑定租约申请指定名称的锁,抢锁成功返回锁秘钥,
    CompletableFuture<LockResponse> lock(ByteSequence name, long leaseId);

    //持锁秘钥解锁
    CompletableFuture<UnlockResponse> unlock(ByteSequence lockKey);
}

4.分布式锁示例

package com.rock.dlock;

import io.etcd.jetcd.ByteSequence;
import io.etcd.jetcd.Client;
import io.etcd.jetcd.Lease;
import io.etcd.jetcd.Lock;
import io.etcd.jetcd.lease.LeaseKeepAliveResponse;
import io.etcd.jetcd.lock.LockResponse;
import io.grpc.stub.StreamObserver;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/6 12:23 PM
 */
public class DemoEtcdLock {
    private final static Logger log = LoggerFactory.getLogger(DemoEtcdLock.class);
    private Client client;
    private Lock lockClient;
    private Lease leaseClient;

    private LockState lockState;

    class LockState{
        private String lockKey;
        private String lockPath;
        private String errorMsg;
        private long leaseTTL;
        private long leaseId;
        private boolean lockSuccess;

        public LockState(String lockKey, long leaseTTL) {
            this.lockKey = lockKey;
            this.leaseTTL = leaseTTL;
        }

        public String getLockKey() {
            return lockKey;
        }

        public void setLockKey(String lockKey) {
            this.lockKey = lockKey;
        }

        public String getLockPath() {
            return lockPath;
        }

        public void setLockPath(String lockPath) {
            this.lockPath = lockPath;
        }

        public String getErrorMsg() {
            return errorMsg;
        }

        public void setErrorMsg(String errorMsg) {
            this.errorMsg = errorMsg;
        }

        public long getLeaseId() {
            return leaseId;
        }

        public void setLeaseId(long leaseId) {
            this.leaseId = leaseId;
        }

        public boolean isLockSuccess() {
            return lockSuccess;
        }

        public void setLockSuccess(boolean lockSuccess) {
            this.lockSuccess = lockSuccess;
        }

        public long getLeaseTTL() {
            return leaseTTL;
        }

        public void setLeaseTTL(long leaseTTL) {
            this.leaseTTL = leaseTTL;
        }
    }


    public DemoEtcdLock(Client client, String lockKey, Long leaseTTL, TimeUnit unit) {
        this.client = client;
        //1.准备客户端
        this.lockClient = client.getLockClient();
        this.leaseClient = client.getLeaseClient();
        this.lockState = new LockState(lockKey,unit.toSeconds(leaseTTL));
    }


    public boolean lock()  {
        try {
            //2.创建租约,并自动续约
            createLease();

            //3.执行加锁,并为锁对应的Key绑定租约
            createLock();
        }catch (InterruptedException | ExecutionException e) {
            //todo:异常处理
        }
        return lockState.isLockSuccess();
    }

    public void unlock()  {
        try {
        //正常释放锁
            if (this.lockState.getLockPath() != null) {
                lockClient.unlock(ByteSequence.from(lockState.getLockPath().getBytes())).get();
            }
            //如果是主动续约,则关闭续约的定时任务

            //删除租约
            if (lockState.getLeaseId() != 0L) {
                leaseClient.revoke(lockState.getLeaseId());
            }
        } catch (InterruptedException | ExecutionException e) {
            //todo:异常处理
        }
        log.info("线程:{} 释放锁", Thread.currentThread().getName());
    }

    // 创建一个租约
    private void createLease() throws ExecutionException, InterruptedException {
        log.debug("[etcd-lock]: start to createLease." + this.lockState.getLockKey() + Thread.currentThread().getName());
        try {
            long leaseId = leaseClient.grant(this.lockState.getLeaseTTL()).get().getID();
            lockState.setLeaseId(leaseId);
            //自动续约
            StreamObserver<LeaseKeepAliveResponse> observer = new StreamObserver<LeaseKeepAliveResponse>() {
                @Override
                public void onNext(LeaseKeepAliveResponse value) {
                    log.trace("cluster node lease remaining ttl: {}, lease id: {}", value.getTTL(), value.getID());
                }

                @Override
                public void onError(Throwable t) {
                    log.error("cluster node lease keep alive failed. exception info: {}", t);
                }

                @Override
                public void onCompleted() {
                    log.trace("cluster node lease completed");
                }
            };
            // 设置自动续约
            leaseClient.keepAlive(leaseId, observer);
        }catch (InterruptedException | ExecutionException e) {
            log.error("[etcd-lock] Create lease failed:" + e);
            lockState.setErrorMsg("Create lease failed:" + e);
            throw e;
        }
    }

    private void createLock() throws ExecutionException, InterruptedException {
        String lockKey = this.lockState.getLockKey();
        log.debug("[etcd-lock]: start to createLock." + lockKey + Thread.currentThread().getName());
        try {
            LockResponse lockResponse = lockClient.lock(ByteSequence.from(lockKey.getBytes()), lockState.getLeaseId()).get();
            if (lockResponse != null) {
                String lockPath = lockResponse.getKey().toString(StandardCharsets.UTF_8);
                this.lockState.setLockPath(lockPath);
                log.info("线程:{} 加锁成功,锁路径:{}", Thread.currentThread().getName(), lockPath);
                this.lockState.setLockSuccess(true);
            }
        }
        catch (InterruptedException | ExecutionException e) {
            log.error("[etcd-lock] lock failed:" + e);
            lockState.setErrorMsg("[etcd-lock] lock failed:" + e);
            leaseClient.revoke(this.lockState.getLeaseId());
            throw e;
        }
    }
}

5.测试锁

package com.rock.dlock;

import io.etcd.jetcd.Client;

import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/6 12:23 PM
 */
public class TestEtcdLock {
    public static void main(String[] args) {
        Client client = Client.builder().endpoints("http://localhost:2379").build();

        DemoEtcdLock demoEtcdLock1 = new DemoEtcdLock(client,"rock",30L, TimeUnit.SECONDS);
        DemoEtcdLock demoEtcdLock2 = new DemoEtcdLock(client,"rock",30L, TimeUnit.SECONDS);

        boolean lock1 = demoEtcdLock1.lock();
        if(lock1) {
            try {
                System.out.printf("do something");
            } finally {
                demoEtcdLock1.unlock();
            }
        }
        demoEtcdLock1.lock();//demoEtcdLock1 持锁未释放
        demoEtcdLock2.lock();//demoEtcdLock2 客户端无可重入设计,这里将会阻塞等待demoEtcdLock1释放锁
    }
}

五、总结

本篇从 etcd V3 版本的 Lease、Prefix 、Watch 等关键特性切入,介绍了如何基于这些特性来实现一个分布式锁,并基于jetcd-core库提供了一个分布式锁的示例,呈现了其关键API的用法;此示例尚未达到生产级可用,如异常、可重入、可重试、超时控制等功能都未补全,计划在下一篇介绍完redis之后,再介绍一个健壮的分布式锁客户端要如何抽象设计,如何适配 ZK 、Redis 、etcd 。

六、最后说一句(请关注,莫错过)

如果这篇文章对您有所帮助,或者有所启发的话,可以关注公众号:【 架构染色 】,进行交流和学习。您的支持是我坚持写作最大的动力。

求一键三连:点赞、收藏、转发。

Reference: