文章目录
CAS + 指数退避算法优化独占锁
在现代的分布式系统中,如何应对高并发请求,是我们不得不面对的一个问题。在这样的环境下,缓存穿透、数据一致性问题以及锁竞争等问题频繁发生。为了有效应对这些挑战,我们往往使用 分布式锁 来避免并发操作引发的数据问题,同时结合 指数退避算法 来优化锁竞争的性能,减少资源浪费。今天我们将深入探讨 CAS(Compare And Swap) 与 指数退避算法 在分布式系统中的应用,结合实际代码,展示如何实现高并发优化。
指数退避算法
指数退避算法(Exponential Backoff) 是一种常用于处理重试机制的算法,特别是在分布式系统中,它能有效地帮助系统减少因为重复请求引发的网络拥塞或资源过度占用。常见的应用场景包括网络请求重试、API请求限流、数据库连接重试、消息队列的消费等。
指数退避的核心思想是:每次失败后,等待的时间呈指数增长,即失败次数越多,重试的间隔时间越长,避免过于频繁的重试造成系统的雪崩效应。该算法的基本目标是避免服务间的资源竞争,并增加系统的恢复能力。
下面是关于 指数退避算法 的博文大纲,包含其概念、实现方式、应用场景等内容。
引言:什么是指数退避算法?
指数退避算法是一种渐进式重试算法,广泛应用于分布式系统中的故障恢复。它的核心原理是:在请求失败后,每次重试时会增加一个逐渐增长的时间间隔。这个时间间隔通常按指数增长,也就是每次失败后的重试间隔为上一次的 2^n
倍,其中 n
为失败次数。这样可以有效避免多个系统或服务间的资源竞争,提高系统的稳定性和可靠性。
1. 指数退避算法的工作原理
指数退避算法通常遵循以下流程:
- 第一次失败:等待一个初始的重试间隔(通常是一个固定的时间,比如100毫秒)。
- 第二次失败:重试间隔时间会加倍,比如200毫秒。
- 第三次失败:再次加倍重试间隔,变成400毫秒。
- 重复此过程:随着失败次数的增加,重试时间将呈指数增长,直到达到设定的最大重试次数或最大等待时间。
具体来说,指数退避算法的重试时间可以通过以下公式来计算:
T
n
=
T
0
×
2
n
T_{n} = T_{0} \times 2^n
Tn=T0×2n
其中:
- ( T n T_n Tn) 为第n次重试的等待时间。
- ( T 0 T_0 T0 ) 为初始等待时间。
- ( n n n ) 为重试次数。
可以看出,随着重试次数的增加,等待时间呈指数级增长。
2. 为什么需要指数退避?
在分布式系统中,系统的负载和并发请求数可能会快速增加。如果在系统负载过高时不对重试进行控制,多个客户端频繁地进行请求可能会加重系统负担,甚至导致系统崩溃。指数退避算法的主要优势在于:
- 减少系统负担:每次重试等待时间的增加可以有效避免过度并发请求,降低系统负担。
- 提高恢复性:通过增加重试间隔,系统可以有更多的时间进行自我恢复,减少“雪崩效应”。
- 提高成功概率:当请求失败时,增加等待时间能增加请求成功的概率,因为系统可能已经有了足够的时间恢复。
3. 指数退避的变种与优化
- 带有最大重试次数限制:为了避免无限制的重试,可以设置最大重试次数。如果达到最大次数后仍然无法成功,则放弃重试,并返回错误信息给调用者。
T n = min ( T 0 × 2 n , max_backoff_time ) \ T_n = \min(T_0 \times 2^n, \text{max\_backoff\_time}) \ Tn=min(T0×2n,max_backoff_time)
- 随机抖动(Jitter):为避免多个请求在同一时间点重试,通常会在指数退避算法中加入随机抖动,即在计算重试间隔时,加入一个随机因素,使得每个重试的间隔时间略有不同。这样可以进一步减少并发请求的碰撞,避免系统过载。
T n = min ( T 0 × 2 n , max_backoff_time ) + random_jitter T_n = \min(T_0 \times 2^n, \text{max\_backoff\_time}) + \text{random\_jitter} Tn=min(T0×2n,max_backoff_time)+random_jitter
- 分段退避(Segmented Backoff):在某些场景下,指数退避可能过于激进,特别是在网络波动较大的情况下,退避时间可能过长。为了解决这个问题,分段退避将时间划分为多个段,每个段内的退避时间是固定的,只有跨越到新的段时才会触发指数增长。
4. 指数退避算法的应用场景
-
网络请求重试:比如在处理分布式系统中的网络请求时,当某个服务临时不可用或网络出现抖动时,可以使用指数退避算法来减少请求重试的频率,避免重复的请求进一步加重系统负担。
-
数据库连接重试:数据库系统在高并发情况下可能出现连接池耗尽的现象,使用指数退避来重试数据库连接请求,避免长时间的阻塞。
-
消息队列消费:当消费者从消息队列中拉取消息时,可能会遇到队列为空的情况。此时,可以使用指数退避来控制消费频率,减少不必要的轮询。
-
API限流与重试:在调用第三方API时,如果遭遇API请求限制或故障,通过指数退避来控制重试间隔,可以避免对API服务器造成过大的负载。
5. 实现指数退避算法
以下是一个简单的 Java 代码示例,展示了如何实现一个带有指数退避和随机抖动的重试机制:
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class ExponentialBackoff {
private static final int INITIAL_WAIT_TIME = 100; // 初始等待时间(毫秒)
private static final int MAX_WAIT_TIME = 10000; // 最大等待时间(毫秒)
private static final int MAX_RETRIES = 5; // 最大重试次数
public static void main(String[] args) {
int attempt = 0;
boolean success = false;
Random random = new Random();
while (attempt < MAX_RETRIES && !success) {
try {
attempt++;
// 模拟操作
if (attempt == MAX_RETRIES) {
throw new Exception("最终失败");
}
System.out.println("尝试第 " + attempt + " 次");
success = true; // 模拟操作成功
} catch (Exception e) {
int waitTime = Math.min(INITIAL_WAIT_TIME * (int) Math.pow(2, attempt), MAX_WAIT_TIME);
int jitter = random.nextInt(100); // 添加抖动
waitTime += jitter;
System.out.println("失败,第 " + attempt + " 次重试,等待 " + waitTime + " 毫秒");
try {
TimeUnit.MILLISECONDS.sleep(waitTime);
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
if (!success) {
System.out.println("操作失败,已达到最大重试次数。");
}
}
}
实际问题
一、问题背景
假设我们有一个分布式系统,系统中有多个应用需要读取和写入数据库的共享资源(例如系统参数),每次操作都需要从缓存中获取数据,如果缓存没有数据,则需要查询数据库。为了避免数据库的多次查询,我们使用 缓存优先策略,同时为了防止多个请求同时修改数据库中的数据,我们引入了 分布式锁 来保证数据一致性。然而,在高并发环境下,如何管理锁的获取、减少资源浪费,仍然是我们需要解决的问题。
二、分布式锁的设计
1. 锁竞争的基本原理
分布式锁的核心是通过某个共享的、全局唯一的资源来实现同步,在多个节点之间协调访问。例如,我们可以通过 Redis 来实现分布式锁。假设每个请求都通过 Redis 锁来确保同一时刻只有一个请求可以操作数据库,其他请求会等待锁的释放。
Redis 锁的获取逻辑:
- SETNX(SET if Not Exists)操作是获取锁的关键,如果指定的键不存在,则设置该键的值并返回成功,否则返回失败。
- 如果获取锁失败,可以采用自旋的方式不断尝试获取锁,直到成功或达到最大重试次数。
2. 引入 CAS 实现原子性操作
在分布式锁的基础上,我们可以通过 CAS(Compare And Swap) 来确保锁的操作是原子性的。CAS 本质上是一个比较并交换的操作,它保证在并发的情况下只有一个线程能够成功更新共享资源,从而避免了脏数据的产生。
通过 CAS,我们可以在锁持有者修改数据时,确保数据一致性。如果发现其他线程已经修改了数据,当前线程会放弃操作,重新获取锁或退出。
三、指数退避算法(Exponential Backoff)
1. 为什么需要指数退避算法?
在高并发的环境中,多个请求可能会同时竞争同一个分布式锁。如果每个线程都以固定的间隔时间进行重试,会导致所有线程同时发起请求,造成资源的浪费以及系统的过度负担。因此,指数退避算法应运而生,它通过让每个线程在失败后等待一个逐渐增加的时间间隔来减少系统负担,并避免资源争用过于激烈。
指数退避的工作原理:
- 初始时,等待时间较短,随着重试次数的增加,等待时间呈指数级增长。
- 这样可以减少系统的竞争压力,让锁竞争逐渐趋于平衡。
2. 指数退避算法的实现
指数退避算法的核心公式是:
waitTime = baseWaitTime * (2 ^ (retryCount - 1))
其中,baseWaitTime
是初始的等待时间,retryCount
是当前的重试次数,随着重试次数的增加,等待时间会指数增长。
四、代码实现:CAS + 指数退避算法
接下来我们来看一段实际的代码实现,它展示了如何在高并发场景下使用 CAS + 指数退避算法 来优化分布式锁的使用。
public BaseSysParamResponseDTO getConcurrentSysParam(BaseSysParamSearchDTO request) {
BaseSysParam querySystemParam = new BaseSysParam();
mapper(request, querySystemParam);
// 第一次检查缓存
BaseSysParam sp = getParamValueFirstCache(querySystemParam);
if (Objects.nonNull(sp)) {
return sp.convertTo(BaseSysParamResponseDTO.class);
}
// 当 setsOfBooksId 不为 null 时,使用参数查询不到,修改成 -1 再次查询
querySystemParam.setSetsOfBooksId(Constants.DEFAULT_SETS_OF_BOOKS_ID);
querySystemParam.setTenantId(Constants.DEFAULT_TENANT_ID);
BaseSysParam sp1 = this.getOne(new QueryWrapper<>(querySystemParam));
if (Objects.nonNull(sp1)) {
boolean lockAcquired = false;
int retryCount = 5; // 重试次数
int baseWaitTime = 100; // 基础等待时间 100 毫秒
while (retryCount-- > 0 && !lockAcquired) {
lockAcquired = RedisLockUtil.executeSynchOperate(locked -> {
if (locked) {
// 第二次检查缓存
BaseSysParam spCheck = getParamValueFirstCache(querySystemParam);
if (Objects.nonNull(spCheck)) {
return true; // 缓存命中,不需要再保存新的参数
}
sp1.setSetsOfBooksId(request.getSetsOfBooksId());
sp1.setTenantId(request.getTenantId());
sp1.setCreationDate(LocalDateTime.now());
sp1.setLastUpdateDate(LocalDateTime.now());
sp1.setCreatedBy(request.getCurrUserLoginId());
sp1.setLastUpdatedBy(request.getCurrUserLoginId());
sp1.setIsGlobalUse(TrueOrFalseEnum.FALSE.getValue());
super.save(sp1);
saveCache(sp1);
return true;
} else {
log.error("获取账套系统参数,竞争锁失败,请稍后刷新数据后重试,重试次数: {}", 5 - retryCount);
return false;
}
}, StringUtil.connectStr(BaseCenterConstants.LOCK_KEY_PREFIX_SYSTEM_PARAM,
request.getSetsOfBooksId() + "",
querySystemParam.getParamCode()),
30000);
if (!lockAcquired) {
// 指数退避算法,加入随机抖动,减少锁争用
int spinWaitTime = (int) (baseWaitTime * Math.pow(2, 5 - retryCount));
int jitter = new Random().nextInt(100); // 加入随机抖动
spinWaitTime += jitter;
long end = System.currentTimeMillis() + spinWaitTime;
while (System.currentTimeMillis() < end) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApplicationException(CommonErrorCode.LOCK_FAIL);
}
}
}
}
if (!lockAcquired) {
throw new ApplicationException(CommonErrorCode.LOCK_FAIL);
}
} else {
throw new ApplicationException(BaseCenterErrorCode.SYSTEM_PARAM_NOT_FIND.getCode(),
String.format(BaseCenterErrorCode.SYSTEM_PARAM_NOT_FIND.getMessage(), request.getParamCode()));
}
// 返回参数数据
return sp1.convertTo(BaseSysParamResponseDTO.class);
}