MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

etcd 分布式锁与领导选举实践

2024-01-164.6k 阅读

1. 分布式系统中的锁与领导选举概述

在分布式系统中,多个节点可能同时竞争共享资源或者需要协调某些操作。例如,在微服务架构中,可能存在多个服务实例需要对同一个数据库记录进行修改,这时就需要一种机制来保证同一时间只有一个实例能够进行修改操作,这就是分布式锁的作用。而领导选举则是在分布式集群中,从多个节点中选出一个作为领导节点,领导节点通常负责协调一些关键任务,比如资源分配、任务调度等。

分布式锁和领导选举面临的挑战主要有网络分区、节点故障以及时钟不同步等。网络分区可能导致部分节点与其他节点失去联系,节点故障可能使正在执行的操作中断,时钟不同步可能影响基于时间的锁机制和选举算法的正确性。

2. etcd 简介

etcd 是一个分布式键值存储系统,它提供了可靠的分布式存储,用于共享配置和服务发现。etcd 具有以下特点:

  • 高可用性:etcd 通过 Raft 一致性算法来保证数据的一致性和可用性。Raft 算法将集群中的节点分为领导者(Leader)、跟随者(Follower)和候选者(Candidate)。领导者负责处理客户端的写请求,并将日志复制到跟随者节点。如果领导者节点出现故障,候选者节点会发起选举,选出新的领导者。
  • 简单易用:etcd 提供了简洁的 HTTP API,方便开发者进行操作。通过这些 API,可以轻松地进行键值对的读写、监控等操作。
  • 安全可靠:etcd 支持 SSL/TLS 加密,保证数据在传输过程中的安全性。同时,etcd 提供了访问控制列表(ACL),可以对不同用户或角色进行权限管理。

3. etcd 分布式锁实践

3.1 基本原理

etcd 分布式锁的实现基于其原子性的 Compare-And-Swap(CAS)操作。每个锁对应一个唯一的键,客户端尝试创建这个键,如果创建成功,则表示获取到了锁;如果键已经存在,则表示锁已被其他客户端持有。为了避免死锁,每个客户端在获取锁时会设置一个租约(Lease),租约到期后,键会自动被删除,相当于释放了锁。

3.2 代码示例(Go 语言)

package main

import (
    "context"
    "fmt"
    "go.etcd.io/etcd/clientv3"
    "time"
)

func main() {
    // 连接 etcd 集群
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"127.0.0.1:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        fmt.Println("connect etcd failed, err:", err)
        return
    }
    defer cli.Close()

    // 创建一个租约
    lease := clientv3.NewLease(cli)
    leaseResp, err := lease.Grant(context.TODO(), 5) // 租约时间 5 秒
    if err != nil {
        fmt.Println("grant lease failed, err:", err)
        return
    }
    defer lease.Revoke(context.TODO(), leaseResp.ID)

    // 获取锁
    key := "/my-lock"
    putResp, err := cli.Put(context.TODO(), key, "locked", clientv3.WithLease(leaseResp.ID), clientv3.WithPrevExist(false))
    if err != nil {
        fmt.Println("put key failed, err:", err)
        return
    }
    if putResp.Header.GetRevision() != 0 {
        fmt.Println("acquire lock success")
        // 模拟业务操作
        time.Sleep(3 * time.Second)
    } else {
        fmt.Println("lock already held by others")
    }
}

在上述代码中:

  • 首先通过 clientv3.New 方法连接到 etcd 集群。
  • 然后使用 lease.Grant 方法创建一个 5 秒的租约。
  • 接着使用 cli.Put 方法尝试创建锁对应的键,并将租约 ID 关联到该键上。WithPrevExist(false) 表示只有当键不存在时才会创建成功,从而实现了获取锁的操作。如果创建成功,则表示获取到了锁,此时可以执行相应的业务逻辑;如果创建失败,则表示锁已被其他客户端持有。

3.3 续租机制

为了防止在业务逻辑执行过程中租约到期导致锁被意外释放,需要实现续租机制。续租机制可以确保只要客户端还在使用锁,租约就不会过期。

// 续租
keepAlive, err := lease.KeepAlive(context.TODO(), leaseResp.ID)
if err != nil {
    fmt.Println("keep alive failed, err:", err)
    return
}
go func() {
    for {
        select {
        case <-keepAlive:
            // 续租成功
        }
    }
}()

在上述代码中,通过 lease.KeepAlive 方法启动续租,keepAlive 是一个通道,当续租成功时,通道会收到相应的消息。通过一个 goroutine 持续监听这个通道,确保续租操作的持续进行。

4. etcd 领导选举实践

4.1 基本原理

etcd 领导选举可以通过创建一个特殊的键来实现,例如 /leader。每个节点尝试创建这个键,第一个创建成功的节点即为领导者。如果领导者节点出现故障,其对应的键会因为租约到期或节点主动删除而消失,此时其他节点可以再次尝试创建键,从而选出新的领导者。

4.2 代码示例(Go 语言)

package main

import (
    "context"
    "fmt"
    "go.etcd.io/etcd/clientv3"
    "time"
)

func main() {
    // 连接 etcd 集群
    cli, err := clientv3.New(clientv3.Config{
        Endpoints:   []string{"127.0.0.1:2379"},
        DialTimeout: 5 * time.Second,
    })
    if err != nil {
        fmt.Println("connect etcd failed, err:", err)
        return
    }
    defer cli.Close()

    // 创建一个租约
    lease := clientv3.NewLease(cli)
    leaseResp, err := lease.Grant(context.TODO(), 10) // 租约时间 10 秒
    if err != nil {
        fmt.Println("grant lease failed, err:", err)
        return
    }
    defer lease.Revoke(context.TODO(), leaseResp.ID)

    // 尝试成为领导者
    key := "/leader"
    putResp, err := cli.Put(context.TODO(), key, "leader", clientv3.WithLease(leaseResp.ID), clientv3.WithPrevExist(false))
    if err != nil {
        fmt.Println("put key failed, err:", err)
        return
    }
    if putResp.Header.GetRevision() != 0 {
        fmt.Println("become leader success")
        // 领导者执行的业务逻辑
        for {
            fmt.Println("leader is working...")
            time.Sleep(2 * time.Second)
        }
    } else {
        fmt.Println("already has a leader")
        // 非领导者执行的业务逻辑
        for {
            fmt.Println("follower is waiting...")
            time.Sleep(2 * time.Second)
        }
    }
}

在上述代码中:

  • 同样先连接到 etcd 集群并创建一个 10 秒的租约。
  • 然后尝试创建 /leader 键,如果创建成功,则该节点成为领导者,可以执行领导者相关的业务逻辑;如果创建失败,则表示已经有其他节点成为领导者,当前节点作为非领导者执行相应的业务逻辑。

4.3 监听领导者变化

为了让非领导者节点能够及时感知到领导者的变化,需要对 /leader 键进行监听。当领导者节点出现故障,键被删除时,非领导者节点可以收到通知并尝试重新选举。

// 监听领导者变化
go func() {
    watchChan := cli.Watch(context.TODO(), key, clientv3.WithPrefix())
    for watchResp := range watchChan {
        for _, ev := range watchResp.Events {
            if ev.Type == clientv3.EventTypeDelete {
                fmt.Println("leader gone, start re - election")
                // 重新尝试成为领导者的逻辑
            }
        }
    }
}()

在上述代码中,通过 cli.Watch 方法对 /leader 键进行监听。当监听到键被删除的事件时,非领导者节点可以得知领导者已经失效,从而开始重新选举的逻辑。

5. 应用场景

5.1 分布式任务调度

在分布式任务调度系统中,可能存在多个调度节点。通过 etcd 分布式锁,可以保证同一时间只有一个调度节点执行关键的调度任务,避免任务重复调度。同时,利用领导选举机制,可以选出一个主调度节点,负责协调和分配任务给其他从调度节点。

5.2 数据一致性维护

在分布式数据库中,多个节点可能需要对数据进行更新操作。通过分布式锁可以保证同一时间只有一个节点对数据进行写操作,避免数据冲突。而领导选举可以用于选出一个主节点,负责协调数据的同步和一致性维护。

5.3 服务发现与注册

在微服务架构中,服务实例需要向注册中心注册自己的信息,同时从注册中心获取其他服务的信息。etcd 可以作为注册中心,通过领导选举可以选出一个主注册节点,负责处理服务注册和发现的核心逻辑,其他节点作为备份节点,保证系统的高可用性。

6. 性能优化与注意事项

6.1 性能优化

  • 批量操作:尽量减少与 etcd 的交互次数,将多个操作合并为批量操作。例如,在需要创建多个键值对时,可以使用 Txn 方法进行事务操作。
  • 合理设置租约时间:租约时间不宜过长也不宜过短。过长可能导致锁长时间被占用,影响其他客户端的使用;过短可能导致频繁续租,增加系统开销。需要根据具体业务场景进行合理调整。
  • 使用缓存:对于一些不经常变化的数据,可以在客户端使用缓存,减少对 etcd 的读取压力。

6.2 注意事项

  • 网络问题:由于 etcd 是分布式系统,网络问题可能导致部分节点与集群失去联系。在设计应用时,需要考虑网络分区的情况,例如可以设置适当的重试机制。
  • 资源释放:在使用完锁或完成领导角色后,一定要及时释放资源,例如删除键或撤销租约,避免资源浪费和死锁。
  • 版本兼容性:etcd 可能会进行版本更新,不同版本的 API 和特性可能有所差异。在使用时要注意版本兼容性,避免因为版本问题导致应用出现异常。

通过以上对 etcd 分布式锁与领导选举的实践介绍,希望开发者能够在分布式系统开发中更好地利用 etcd 提供的功能,构建稳定、高效的分布式应用。在实际应用中,还需要根据具体业务场景进行深入的分析和优化,以满足系统的性能和可靠性要求。