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

分布式锁的过期时间设置策略

2021-12-142.4k 阅读

分布式锁过期时间的重要性

在分布式系统中,分布式锁是一种重要的同步机制,用于确保在分布式环境下,同一时间只有一个进程能够访问共享资源,避免数据不一致和并发冲突等问题。而分布式锁过期时间的设置,是使用分布式锁时必须要仔细考虑的关键因素。

过期时间设置不合理,可能会引发各种问题。如果过期时间设置过短,可能会导致在业务逻辑还未执行完成时,锁就已经过期,其他进程获取到锁并开始访问共享资源,从而破坏数据的一致性。例如,在一个电商系统的库存扣减操作中,如果分布式锁过期时间过短,当一个订单正在进行库存扣减操作时,锁过期了,另一个订单也获取到锁并进行库存扣减,就可能导致超卖现象。

相反,如果过期时间设置过长,虽然可以保证业务逻辑能够完整执行,但会影响系统的并发性能。比如在高并发的抢购场景中,过长的锁过期时间会导致其他抢购请求长时间等待,降低系统的吞吐量,影响用户体验。

过期时间设置策略分类

固定过期时间策略

这是最简单直接的一种策略,为分布式锁设置一个固定的过期时间。在许多情况下,这种策略是可行的。例如,对于一些执行时间相对稳定且较短的任务,我们可以根据任务的最大执行时间来设置一个略长的固定过期时间。

以基于 Redis 的分布式锁为例,使用 Jedis 客户端进行实现:

import redis.clients.jedis.Jedis;

public class DistributedLock {
    private Jedis jedis;
    private String lockKey;
    private String requestId;
    private int expireTime;

    public DistributedLock(Jedis jedis, String lockKey, int expireTime) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.expireTime = expireTime;
        this.requestId = java.util.UUID.randomUUID().toString();
    }

    public boolean lock() {
        String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime);
        return "OK".equals(result);
    }

    public void unlock() {
        if (requestId.equals(jedis.get(lockKey))) {
            jedis.del(lockKey);
        }
    }
}

在上述代码中,expireTime 就是固定设置的过期时间(单位为秒)。当调用 lock 方法时,会尝试以指定的过期时间设置锁。如果设置成功,返回 true,表示获取到锁;否则返回 falseunlock 方法在确认当前持有锁的是本进程时,删除锁。

这种策略的优点是简单易懂,实现方便。但缺点也很明显,它没有考虑到实际业务执行时间的动态变化。如果业务执行时间因为某些原因(如网络延迟、资源竞争等)突然变长,就可能导致锁过期而业务未完成的情况。

动态过期时间策略

为了应对业务执行时间不确定的情况,动态过期时间策略应运而生。这种策略会根据业务的执行情况,动态调整锁的过期时间。

一种常见的实现方式是在获取锁时设置一个初始过期时间,然后在业务执行过程中,定时检查业务是否完成,如果未完成,则延长锁的过期时间。

还是以 Redis 分布式锁为例,下面是使用 Java 和 Jedis 实现动态延长过期时间的代码示例:

import redis.clients.jedis.Jedis;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class DynamicDistributedLock {
    private Jedis jedis;
    private String lockKey;
    private String requestId;
    private int initialExpireTime;
    private int extendInterval;
    private ScheduledExecutorService executorService;

    public DynamicDistributedLock(Jedis jedis, String lockKey, int initialExpireTime, int extendInterval) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.initialExpireTime = initialExpireTime;
        this.extendInterval = extendInterval;
        this.requestId = java.util.UUID.randomUUID().toString();
        this.executorService = Executors.newSingleThreadScheduledExecutor();
    }

    public boolean lock() {
        String result = jedis.set(lockKey, requestId, "NX", "EX", initialExpireTime);
        if ("OK".equals(result)) {
            startAutoExtend();
            return true;
        }
        return false;
    }

    private void startAutoExtend() {
        executorService.scheduleAtFixedRate(() -> {
            if (requestId.equals(jedis.get(lockKey))) {
                jedis.expire(lockKey, initialExpireTime);
            }
        }, 0, extendInterval, TimeUnit.SECONDS);
    }

    public void unlock() {
        executorService.shutdown();
        if (requestId.equals(jedis.get(lockKey))) {
            jedis.del(lockKey);
        }
    }
}

在这段代码中,initialExpireTime 是初始设置的过期时间,extendInterval 是每次延长过期时间的间隔。当成功获取锁后,启动一个定时任务 startAutoExtend,每隔 extendInterval 秒检查一次,如果当前进程仍然持有锁,则延长锁的过期时间。

动态过期时间策略的优点是能够更好地适应业务执行时间的变化,避免因锁过期导致的数据一致性问题。但它也增加了系统的复杂性,定时任务的调度和锁的延长操作都需要额外的资源和处理逻辑。

基于业务预估的过期时间策略

这种策略是在对业务执行时间有一定预估能力的情况下使用的。通过分析业务的历史数据、业务逻辑的复杂度等因素,对每个具体的业务操作预估一个合理的执行时间,并以此来设置分布式锁的过期时间。

例如,在一个数据分析任务中,根据以往的运行记录,该任务在数据量正常的情况下,执行时间一般在 1 - 3 分钟之间。那么在使用分布式锁时,可以将过期时间设置为 5 分钟,以确保任务能够完整执行,同时又不会设置过长的过期时间影响并发性能。

实现代码与固定过期时间策略类似,只是过期时间的设置是基于业务预估:

import redis.clients.jedis.Jedis;

public class EstimatedDistributedLock {
    private Jedis jedis;
    private String lockKey;
    private String requestId;
    private int estimatedExpireTime;

    public EstimatedDistributedLock(Jedis jedis, String lockKey, int estimatedExpireTime) {
        this.jedis = jedis;
        this.lockKey = lockKey;
        this.estimatedExpireTime = estimatedExpireTime;
        this.requestId = java.util.UUID.randomUUID().toString();
    }

    public boolean lock() {
        String result = jedis.set(lockKey, requestId, "NX", "EX", estimatedExpireTime);
        return "OK".equals(result);
    }

    public void unlock() {
        if (requestId.equals(jedis.get(lockKey))) {
            jedis.del(lockKey);
        }
    }
}

这种策略的关键在于准确的业务预估。如果预估不准确,可能会出现与固定过期时间策略类似的问题,即过期时间过长或过短。所以,在实际应用中,需要不断收集业务执行时间的统计数据,优化预估模型,以提高过期时间设置的合理性。

过期时间设置与系统架构的关系

与微服务架构的结合

在微服务架构中,各个微服务之间通过网络进行通信,分布式锁的使用非常普遍。由于微服务的独立性和分布式特性,锁的过期时间设置需要考虑到不同微服务之间的交互和依赖关系。

例如,一个订单微服务依赖库存微服务进行库存扣减操作。在订单创建过程中,需要获取库存微服务的分布式锁来保证库存数据的一致性。此时,锁的过期时间不仅要考虑库存扣减操作本身的执行时间,还要考虑订单微服务与库存微服务之间的网络延迟、微服务自身的负载等因素。

假设库存扣减操作在正常情况下执行时间为 100 - 200 毫秒,但由于网络波动等原因,网络延迟可能会达到 500 毫秒甚至更高。那么在设置库存微服务分布式锁的过期时间时,就需要综合考虑这些因素,适当延长过期时间,以确保在各种情况下库存扣减操作都能安全完成。

同时,在微服务架构中,不同的微服务可能有不同的性能要求和业务特点。对于一些对并发性能要求极高的微服务,如商品展示微服务,在使用分布式锁时,过期时间应尽量设置得短一些,以提高系统的并发处理能力;而对于一些涉及关键数据操作的微服务,如支付微服务,过期时间则需要设置得足够长,以保证支付流程的完整性和数据一致性。

与集群架构的适配

在集群架构中,分布式锁通常用于协调集群内各个节点的操作。过期时间的设置要考虑到集群的规模、节点之间的同步机制等因素。

如果集群规模较大,节点之间的网络通信延迟可能会增加,这就需要适当延长分布式锁的过期时间,以防止因网络延迟导致锁过期而出现数据不一致的情况。例如,在一个包含数百个节点的大数据集群中,节点之间的数据同步可能需要几秒钟甚至更长时间,那么在设置分布式锁过期时间时,就不能只考虑单个节点上业务操作的执行时间,还需要把节点间数据同步的时间纳入考虑范围。

另外,集群的同步机制也会影响过期时间的设置。如果集群采用的是异步同步机制,那么在设置锁的过期时间时,要充分考虑到数据同步的延迟。比如,当一个节点获取锁并修改了共享数据后,其他节点可能需要一定时间才能同步到最新的数据。在这种情况下,如果锁的过期时间设置过短,可能会导致其他节点在未同步到最新数据时就获取到锁,从而引发数据冲突。

过期时间设置与故障处理

锁过期引发的数据不一致问题及处理

当分布式锁过期时,如果业务逻辑尚未完成,就可能导致数据不一致。例如,在一个银行转账操作中,假设 A 向 B 转账 100 元,在扣除 A 的账户余额后,锁过期了,此时另一个进程获取到锁并开始执行转账操作,可能会导致 A 的账户余额被重复扣除。

为了处理这种情况,可以采用一些补偿机制。以银行转账为例,在转账操作完成后,可以增加一个检查步骤,检查 A 和 B 的账户余额是否符合预期。如果发现数据不一致,可以通过反向操作(如将多扣除的金额加回到 A 的账户)来恢复数据的一致性。

在代码层面,可以在业务逻辑执行完成后,增加如下的检查和补偿逻辑:

// 假设这里是银行转账的业务逻辑
public void transferMoney(String fromAccount, String toAccount, double amount) {
    DistributedLock lock = new DistributedLock(jedis, "transfer_lock", 60);
    if (lock.lock()) {
        try {
            // 扣除 A 的账户余额
            AccountService.decreaseBalance(fromAccount, amount);
            // 增加 B 的账户余额
            AccountService.increaseBalance(toAccount, amount);
        } finally {
            lock.unlock();
        }
        // 检查数据一致性
        if (!AccountService.isBalanceCorrect(fromAccount, toAccount, amount)) {
            // 进行补偿操作
            AccountService.increaseBalance(fromAccount, amount);
            AccountService.decreaseBalance(toAccount, amount);
        }
    } else {
        // 未获取到锁,处理相应的业务逻辑
    }
}

过期时间与锁重试机制的协同

在分布式系统中,当获取分布式锁失败时,通常会采用重试机制。过期时间的设置与重试机制密切相关。

如果过期时间设置过短,可能会导致在重试过程中,锁被其他进程获取并释放,而本进程在重试获取锁后,可能会访问到不一致的数据。因此,在设置过期时间时,需要考虑重试机制的重试次数、重试间隔等因素。

例如,设置重试次数为 3 次,重试间隔为 1 秒。如果业务操作执行时间加上重试总时间(3 秒)可能会超过锁的过期时间,那么就需要适当延长过期时间,以确保在重试过程中业务能够安全执行。

以下是结合重试机制的分布式锁获取代码示例:

public boolean acquireLockWithRetry(DistributedLock lock, int retryCount, int retryInterval) {
    for (int i = 0; i < retryCount; i++) {
        if (lock.lock()) {
            return true;
        }
        try {
            Thread.sleep(retryInterval * 1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    return false;
}

在使用时,可以根据业务需求和过期时间设置合理的 retryCountretryInterval,以保证在锁获取失败时能够安全重试。

不同分布式锁实现的过期时间设置特点

Redis 分布式锁的过期时间设置

Redis 分布式锁是通过设置键值对并利用其过期机制来实现过期时间的。如前面的代码示例所示,使用 SET key value NX EX seconds 命令可以在设置锁的同时指定过期时间(seconds 为过期时间,单位为秒)。

Redis 的过期时间设置有以下特点:

  1. 简单高效:通过简单的命令即可完成锁的设置和过期时间的指定,操作非常便捷,性能也较高,适合大多数分布式锁场景。
  2. 基于内存:由于 Redis 是基于内存的数据库,过期时间的处理依赖于内存的管理和定时任务。虽然 Redis 有自己的过期策略(如定期删除和惰性删除),但在高并发场景下,可能会出现过期时间处理不及时的情况,导致锁的过期时间与预期略有偏差。
  3. 单点问题:如果使用单节点的 Redis 作为分布式锁服务,存在单点故障问题。一旦 Redis 节点宕机,分布式锁将无法正常工作。为了解决这个问题,可以采用 Redis 集群或者 Sentinel 模式,但这会增加系统的复杂性,同时也会对过期时间的设置和管理带来一些挑战。例如,在 Redis 集群中,由于数据是分片存储的,锁的过期时间可能会因为节点之间的同步延迟等因素而产生一些不确定性。

ZooKeeper 分布式锁的过期时间设置

ZooKeeper 分布式锁是通过创建临时节点来实现的。当客户端创建一个临时节点作为锁时,如果客户端与 ZooKeeper 服务器的连接断开,临时节点会自动删除,相当于锁自动释放,这也间接实现了过期时间的效果。

ZooKeeper 的过期时间设置有以下特点:

  1. 可靠性高:ZooKeeper 采用了 Paxos 等一致性协议,保证了数据的一致性和可靠性。在分布式锁场景下,即使某个 ZooKeeper 节点出现故障,其他节点仍然可以继续提供服务,确保锁的机制正常运行。这使得锁的过期时间能够得到更可靠的保障,不会因为单点故障而出现锁无法释放的情况。
  2. 基于会话:ZooKeeper 的临时节点生命周期与客户端会话绑定。客户端通过心跳机制保持与 ZooKeeper 服务器的会话,如果一段时间内没有收到客户端的心跳,ZooKeeper 服务器会认为会话过期,从而删除临时节点(即释放锁)。因此,会话超时时间间接决定了锁的过期时间。这种基于会话的过期机制相对灵活,可以根据不同的业务需求设置不同的会话超时时间。
  3. 性能开销:与 Redis 相比,ZooKeeper 的写操作性能相对较低,因为每次创建或删除临时节点都需要进行一致性协议的同步操作。在高并发场景下,这可能会对系统性能产生一定的影响。而且,由于 ZooKeeper 的过期时间依赖于会话管理,需要额外的资源来维护会话状态,这也增加了系统的开销。

基于数据库的分布式锁过期时间设置

基于数据库的分布式锁通常是通过在数据库中创建一个表,使用唯一索引或排他锁来实现。过期时间的设置可以通过在表中增加一个时间戳字段,并结合定时任务来实现。

例如,创建一个 distributed_lock 表:

CREATE TABLE distributed_lock (
    lock_key VARCHAR(255) PRIMARY KEY,
    owner VARCHAR(255),
    expire_time TIMESTAMP
);

当获取锁时,插入一条记录并设置 expire_time

INSERT INTO distributed_lock (lock_key, owner, expire_time) VALUES ('my_lock', 'client1', NOW() + INTERVAL 5 MINUTE) ON DUPLICATE KEY UPDATE owner = 'client1', expire_time = NOW() + INTERVAL 5 MINUTE;

定时任务负责清理过期的锁记录:

DELETE FROM distributed_lock WHERE expire_time < NOW();

基于数据库的分布式锁过期时间设置有以下特点:

  1. 数据一致性强:数据库本身具有强大的数据一致性保障机制,通过数据库事务和索引等技术,可以确保分布式锁的操作具有较高的一致性。这使得过期时间的管理相对准确,不会出现像 Redis 那样在高并发下可能的过期时间偏差问题。
  2. 性能瓶颈:数据库的读写性能相对内存数据库(如 Redis)较低,尤其在高并发场景下,数据库的压力会很大。每次获取和释放锁都需要进行数据库的读写操作,这可能会成为系统的性能瓶颈。而且,定时任务清理过期锁记录也会对数据库性能产生一定的影响,需要合理设置定时任务的执行频率和清理策略。
  3. 维护成本:使用数据库作为分布式锁服务,需要额外的数据库维护工作,包括数据库的备份、恢复、性能优化等。而且,数据库的架构和配置也会影响分布式锁的性能和可靠性,如数据库的主从架构可能会带来数据同步延迟,影响锁的过期时间处理。

过期时间设置的最佳实践

前期调研与测试

在实际应用中,首先要对业务进行详细的调研和分析。了解业务的执行流程、执行时间范围、并发量等关键信息。对于一些复杂的业务,可能需要收集历史数据进行统计分析,以准确预估业务执行时间。

例如,对于一个电商促销活动的抢购业务,通过分析以往类似活动的抢购数据,了解到抢购操作在高并发情况下,从用户下单到库存扣减完成,90% 的操作能够在 2 - 5 秒内完成,但偶尔会因为网络拥堵等原因,出现执行时间长达 10 秒的情况。基于这些数据,在设置分布式锁过期时间时,可以将过期时间设置为 15 秒左右,以确保在绝大多数情况下业务能够正常执行,同时也不会设置过长的过期时间影响并发性能。

在设置过期时间之前,还需要进行充分的测试。通过模拟不同的并发场景、网络延迟、业务负载等情况,对设置的过期时间进行验证。可以使用工具如 JMeter 来模拟高并发请求,观察在不同过期时间设置下,业务的执行情况和系统的性能指标(如吞吐量、响应时间等)。通过不断调整过期时间并进行测试,找到一个最适合业务场景的过期时间值。

监控与动态调整

在系统上线后,要建立完善的监控机制,实时监控分布式锁的使用情况,包括锁的获取次数、持有时间、过期情况等。通过监控数据,可以及时发现过期时间设置不合理的情况。

例如,如果发现某个分布式锁经常在业务执行过程中过期,导致数据不一致的报警频繁出现,就说明过期时间设置过短,需要适当延长;反之,如果发现锁的持有时间普遍远小于设置的过期时间,且系统的并发性能受到影响,就可能需要缩短过期时间。

基于监控数据,可以实现过期时间的动态调整。一种简单的方式是根据业务的实时负载情况来调整过期时间。例如,当系统负载较低时,适当缩短过期时间,以提高系统的并发性能;当系统负载较高时,适当延长过期时间,以确保业务能够正常执行。可以通过编写脚本或者使用一些自动化工具来实现过期时间的动态调整,以适应业务场景的变化。

结合多种策略

在实际应用中,单一的过期时间设置策略可能无法满足所有的业务需求。因此,可以结合多种策略来设置分布式锁的过期时间。

例如,对于一些执行时间相对稳定的常规业务操作,可以采用基于业务预估的过期时间策略,并结合固定过期时间策略进行微调。在预估业务执行时间的基础上,设置一个略长的固定过期时间,以应对可能出现的一些意外情况。

对于执行时间不确定的复杂业务操作,可以先采用动态过期时间策略,在获取锁时设置一个初始过期时间,并在业务执行过程中根据实际情况动态延长过期时间。同时,结合基于业务预估的策略,对初始过期时间进行合理的设置,避免初始过期时间设置过长或过短。

通过结合多种策略,可以充分发挥不同策略的优势,提高分布式锁过期时间设置的合理性和适应性,确保分布式系统在各种业务场景下都能稳定、高效地运行。