梳理幂等控制的相关知识,方便后续的业务开发。注意,针对幂等控制,可以类比处理数据库的并发与事务。
幂等性简介
幂等的概念来源于数学领域,即满足f(f(x)) = f(x),则称f(x)具备幂等性。在软件开发领域,幂等性则表示对同一个服务方,使用同样的请求参数,调用方的一次请求和重复多次请求对服务方的影响是一致的。简单来说,幂等性就是一个操作一次执行与多次执行产生的结果是一致的。如调用方请求扣费,那么服务方最多只进行一次扣费,即使调用方重复请求了多次。
幂等控制的重要性
幂等控制是构建稳定、可靠、可扩展的系统不可或缺的一部分,尤其是在分布式系统日益普及的今天,其重要性更加凸显。在分布式系统下,由于网络不稳定、网络抖动、用户误操作、请求超时重试、请求错误重试等情况下,会出现请求重复发送的问题。幂等性可以确保即使请求被重复发送,系统也不会产生副作用(如多次扣款、多次插入相同记录等)。
如果将服务方的操作设计为支持幂等,那么在遇到上述问题时,调用方就可以简单地重试请求而不必担心造成数据不一致或服务异常,大大简化了错误处理逻辑。对于用户交互的操作,如支付、提交订单等,幂等性可以确保用户即使误操作或因网络问题点击多次,也不会产生多余的交易或操作,保护了用户的利益,提升了用户体验。此外,在分布式系统扩展或升级过程中,可能会因为任务重分配、节点故障转移等原因导致请求被重发,幂等控制则可以保证调用方能够更容易地实现横向扩展和故障恢复,而不必担心操作的重复执行问题。
幂等控制的方案设计
幂等控制的设计方案多种多样,具体策略需根据业务场景和系统架构来选择。实现幂等控制,要根据不同的业务场景,选择合适的方案,没有银弹。且随着需求的变化,实现幂等控制的方案有时也需要同步更换。以下是一些常见的幂等控制设计方案:
业务字段+唯一性约束
可以在数据库中对业务字段添加唯一性约束。对业务系统来说,仅需处理数据唯一性异常即可。示例代码如下:
try {
// 尝试插入数据,这里以userId + dataCode + dataName组成唯一标识(唯一索引)
insertData(userId, dataCode, dataName);
} catch (DuplicateKeyException e) {
// 处理键重复异常
log.error(e, "insert failed: DuplicateKeyException");
throw new RuntimeException(e, "插入失败:违反唯一约束");
} catch(Exception e) {
log.error(e, "inner exception");
throw new RuntimeException(e, "内部异常");
}
这里主要借助数据库的唯一索引来保证数据不出现重复,并在业务代码中特别处理键重复的问题。
去重表+唯一性约束
使用业务字段+唯一性约束的方式可以解决幂等性问题,但是对业务表来说,该唯一性约束并不是业务关注点,所以一种更好的方式是独立出一张去重表,也将其称为排重表或者令牌表。该表仅有一个字段(unique_key_str)组成,并将该字段设置为唯一索引。示例代码如下:
int token = 0;
try {
token = insertToken(userId + "" + dataCode + " " + dataName);
} catch (DuplicateKeyException e) {
// 处理键重复异常
log.error(e, "insert failed: DuplicateKeyException");
throw new RuntimeException(e, "插入失败:违反唯一约束");
} catch(Throwable e) {
log.error(e, "inner exception");
throw new RuntimeException(e, "内部异常");
}
if (token > 0) {
try {
// 尝试插入数据,这里以userId + dataCode + dataName组成唯一标识(唯一索引)
insertData(userId, dataCode, dataName);
} catch (DuplicateKeyException e) {
// 处理键重复异常
log.error(e, "insert failed: DuplicateKeyException");
throw new RuntimeException(e, "插入失败:违反唯一约束");
} catch(Throwable e) {
log.error(e, "inner exception");
throw new RuntimeException(e, "内部异常");
}
insertOrder(userId, orderCode, orderName, System.currentTimeMillis());
} else {
// 处理键重复异常
log.error(e, "insert failed: DuplicateKeyException");
throw new RuntimeException(e, "插入失败:违反唯一约束");
}
注意,insertToken和insertData必须放在一个事务里执行,否则会出现事务并发问题,引入程序错误问题。此外,独立出去重表后,去重表还能供其他业务使用,提高了功能的复用性。需要说明的是,即使引入了去重表,也还是需要在业务表里引入唯一性索引的,因为无法保证写入业务表的入口只有一个或者存在手工插入的问题,等等。可以将其作为一种兜底的保证。
全局唯一性ID
针对幂等控制,一种通用的方案是使用全局唯一性ID。具体实现方案是:业务系统根据操作和内容生成一个全局ID,在执行插入操作前,先查询全局唯一ID是否存在,如果存在,则表示数据已经插入。如果不存在,则把全局ID存储到业务系统中。对于分布式系统来说,这种全局唯一性ID就是一个分布式ID。
多版本控制
无论是唯一性约束,还是全局唯一性ID,都是一种悲观锁的实现,且主要适用于插入场景的实现。对于需要更新的场景,可以考虑一种乐观的实现,即多版本控制。从数据库层面来说,可以在业务表中增加一个版本号,来做幂等。示例SQL如下:
update biz_data set dataName=#{newDataName},version=#{version} where id=#{id} and version<${version}
状态机控制
对于更新的场景,如果有状态流转,则可以根据状态字段来做幂等。示例SQL如下:
update order set status=#{status} where id=#{id} and status<#{status}
其他方案
除了以上方案,还可以使用分布式锁的方式,由于技术角度来说,分布式锁建立了业务服务对分布式锁的强依赖,如果分布式锁失效,业务服务将无法正常工作。分布式锁增加了业务服务的复杂度,不是一种推荐的方案。
幂等控制的实现(一锁二判三更新)
在实现幂等控制时,基本上已形成"一锁二判三更新"的标准解决方案。业务服务在进行幂等控制时,首先对业务进行加锁,为了避免分离业务的关注点,可以使用单独的"去重表"记录业务的"幂等号"。然后,根据业务状态(如果有)或其他前置条件判断是否可以执行更新操作。最后执行具体的更新操作(数据插入或数据更新)。注意,以上操作需要在一个事务内完成。伪代码实现如下:
transactionTemplate.execute((TransactionStatus status) -> {
// 1. 锁定业务数据
bizData = xxxRepository.loadWithLock(dataId);
// 2. 更新前检查
bizData.check();
// 3. 更新业务数据
xxxRepository.update(bizOrder);
return true;
});
可以看到,所有的操作都是在一个事务里执行,通过事务的commit或rollback来确保锁的释放。在锁定业务数据时,可以使用select...for update这种行锁来减少锁的粒度,降低并发冲突。伪代码实现如下:
transactionTemplate.execute((TransactionStatus status) -> {
// 1. 使用行锁锁定业务数据
bizData = xxxRepository.selectForUpdate(dataId);
// 2. 更新前检查
bizData.check();
// 3. 更新业务数据
xxxRepository.update(bizOrder);
return true;
});
幂等控制实现规范
在实现幂等控制时,除了考虑服务方的实现,还有一些注意事项,主要从调用方、服务方、前端页面三个层面规范。
调用方
调用方必须保证幂等号的唯一性、不变性
● 说明:调用方需保证幂等号不重复,且对同一单据的同一次操作不论请求多少次,幂等号不变
● 反例:
○ 幂等号重复,常见问题:
■ 幂等号cycle问题,未评估好业务量同sequence增长速度,导致幂等号重复;
■ 幂等号步长、分段设置问题,导致跨区域/单元/库/表幂等号重复;
○ 幂等号变化:事务中生成幂等号,并发起远程调用,调用超时本地事务回滚,下次又会生成新的幂等号。具体见"幂等号生成在事务内,事务中禁止包含远程调用"章节
调用方必须保证关键业务请求参数的不变性
● 说明:当服务方返回未决结果时,调用方关键业务请求参数不允许变更。
● 反例:第一次请求,客户端read timeout,服务端受理成功。客户端修改单据金额,请求信息变化,客户端与服务端处理出错。
调用方禁止幂等号纯内存拼接,不做持久化
● 说明:幂等号不做持久化,对于异步回执处理,两两稽核带来困难。幂等号持久化是一个基本要求。
● 反例:RPC调用,调用方的幂等号,是内存中根据业务映射拼接得来,不做持久化。
调用方幂等号生成事务内禁止包含远程调用
正例:
transactionTemplate.execute((TransactionStatus status) -> {
// 生成流水号xxxId
AAADO aaaDO = buildAAADO();
// 插入aaa表
aaaDAO.insert(aaaDO);
// 更新bbb表
bbbDAO.update(bbbDO);
return true;
});
// 远程调用ccc,流水号xxxId作为幂等号
requestCCC(request);
服务方
服务方必须保证受理结果一致性
● 说明:针对相同请求,不论调用方请求多少次,服务方仅受理一次,且受理结果相同。
● 反例:售中退款场景,第一次调用服务方正常受理,调用方read timeout;第二次调用方重试,服务方可退金额校验不足,返回受理失败,引发问题。
服务方不能单纯依赖查询做幂等
● 说明:分布式下并发场景,查询无法做到插入幂等。常见唯一性保障方式:
○ DB约束:对插入流水的幂等号建DB唯一索引约束。一定要使用DB约束进行兜底,如果数据存储在DB。
○ 分布式锁:如redis、tair、zookeeper等。若持久层在DB,不推荐使用(依赖外部存储做幂等控制,与DB的强一致性无法保证),涉及资金等强一致性场景不推荐。
● 反例:出账场景,服务方根据幂等号查询时,由于并发请求的事务未提交查询不到流水,继续操作,引发资损。
前端页面
前端页面与服务端幂等实现规约
● 说明:当使用前端页面作为调用方,幂等性要求本质上没有任何区别。鉴于页面操作过程中,用户误操作、网络卡顿、黑客恶意攻击情况下,可能会导致表单被重复提交,造成资金被多次操作产生资损。
● 最佳实践
A)框架层面CSRF token防表单重复提交
B)自己实现(优先选用A方案,原理类似):
○ 页面渲染时,服务端返回唯一单号(uuid)
○ 页面表单提交时,服务端返回的唯一单号作为幂等号
○ 页面表单提交时,在等待服务方返回时,使用遮罩层减少用户重复提交。
幂等控制实践
在实际的落地幂等控制时,仅做了查询判断,如果查询不到,则插入数据。这里没有在判断前进行加锁(性能损耗),主要还是通过数据库的唯一键来保证不会重复存储。伪代码实现如下:
public boolean checkIfIdempotent(string dataId) {
object data = queryByIdempotentNo(dataId);
return data == null;
}
public void handle() {
// 幂等校验
boolean isIdempotent = checkIfIdempotent(dataId);
if (!isIdempotent) {
// 如果之前处理过,则直接返回
return;
}
// 执行真正的处理(通过数据库唯一键来保证幂等性)
doHandle();
}