每个清晨应该都从思考开始,从思考开始会让我的脑袋更清晰一些。这是一个系列,先是幂等然后是限流。
从一个下单场景出发
让我们想象这么一个场景,现在我有一个表单,也就是我们要创建某条数据,这个表单可以是创建一条订单, 像下面这样:
一般来说我们下单结束之后可以选择让页面跳转到订单详情页面上,但是呢,不巧的是由于客户的网络比较慢,第一个请求发出去的时候,大致上需要等待两秒的时间。这个时候用户显然有点不耐心了,于是他重复点击了立即下单这个按钮,然后发出去了两个下单请求,于是就有两条相同的数据。如果是在秒杀的场景下面,这就占据了两个库存,显然这是需要避免的情况。
于是我们祭出来第一个补丁,用户点击立即下单之后,立即下单这个按钮进入loading这个状态,也就是暂时禁用这个按钮, 像下面这样:
这看起来问题解决了一部分,但是不幸的是还是出现了相同的数据,占用了一段时间的库存,给我们造成了一点困扰。那这是为什么呢? 首先在网页上,我们知道JavaScript是单线程的,但同样实现了并发, 也就是说代码的逻辑进入到了一个队列里面被交错执行,这意味着你的loading可能会稍微延迟一点生效。这就为用户重复点击产生了第一种条件。
那在移动端上呢? 其实面临的是一个问题,也是一样的,绘制UI负责页面渲染的其实也是一个线程,这意味着也是交错处理UI事件,也会面临手快的客户。UI线程也会卡顿,想想这个场景也是蛮自然的,移动端的场景更加极端一点,有时候手机在发热的时候会手机会降频,你就会观察到UI线程的卡顿。
第二个问题来自于不可测的网络,移动端设备通常设置了超时时间,也就是说限制响应在指定的时间回复,超出这个时间就不选择等待,无限制的等待下去这个页面处于不可用,用户体验也不良好。然后在弹出网络异常重试之后,用户接着点击这个立即下单这个按钮。其实服务端已经在处理数据了只是返回的数据在网络中被运输的比较慢,那么这个时候用户接着点击了提交的按钮,接着相同的数据第二次到达服务端,但他其实只想下一单,这次下单成功了,于是到详情页面惊讶的发现自己下了两个单。
如果在网络持续不好的情况下,用户可能频繁点击这个下单按钮,这就导致大量库存被占用,显然这也是我们需要避免的一个点。那么怎么办呢? 我们注意到用户当前一直停留在这个页面,那么我们能否在用户进入这个下单页面的时候,前端就向后端请求一个下单令牌,提交的时候携带这个令牌进行提交:
也就是说我们在订单创建的时候先让前端请求令牌,创建订单的时候携带这个令牌创建订单,如果这个令牌是未使用状态,则原子的更新token的状态,注意这里可能是并发的更新token的状态,更新失败的可以给出提示当前订单正在创建请稍后,也或者我们可以对赋予token多个属性,一个是订单已创建,一个是订单是创建中,如果处于创建中,则给出当前订单正在创建中,如果是完成就返回创建成功让前端跳转到对应的订单详情页面。代码如下:
import org.redisson.api.RMap;
import org.redisson.api.RedissonClient;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class OrderToken {
public enum TokenState {
UNUSED, // 还未使用
CREATING, // 正在创建订单
COMPLETED // 订单已创建
}
private static final String FIELD_STATE = "state";
private static final String FIELD_ORDERID = "orderId";
private final RMap<String, String> dataMap;
private final long expirationSeconds;
private final RedissonClient redissonClient = SpringContextUtils.getBean(RedissonClient.class);
/**
* 初始化一个token
* @param token
* @param expireSeconds
*/
public OrderToken(String token, long expireSeconds) {
this.dataMap = redissonClient.getMap("order:token:" + token);
this.expirationSeconds = expireSeconds;
}
/**
* 创建订单的token,设置失效时间
* @param expireSeconds
* @return
*/
public static String createOrderToken(long expireSeconds) {
String tokenVal = UUID.randomUUID().toString();
new OrderToken(tokenVal, expireSeconds)
.initializeAsUnused();
return tokenVal;
}
public void initializeAsUnused() {
dataMap.put(FIELD_STATE, TokenState.UNUSED.name());
dataMap.expire(expirationSeconds, TimeUnit.SECONDS);
}
//这里刷新订单的过期时间是为了防止在业务处理过程中,令牌因为超时而意外失效。
//比如我们假定token的失效时间是三分钟, 然后用户在当前页面停留了两份五十五秒
//到我们这里开始使用的时候,发现token不存在了。其实这里也没什么,完全可以让用户刷新页面。
//但这里其实有个问题在于如果用UUID,没法验证这个格式是否是伪造的,
//就会识别不清楚这个token是否是第三方伪造,因此我们颁发token的时候不能随便生成
//我们应当的token应当是时间+用户ID+其他有意义的业务字符串来颁发
//获取token的时候应当首先检验是否是合法的,如果是合法的,我们可以让这个token复活。
//来避免token在传输过程中过期的这种现象
// 所以这里只是简单演示,不能做生产用途
public boolean tryClaimForCreation() {
boolean ok = dataMap.replace(FIELD_STATE,
TokenState.UNUSED.name(),
TokenState.CREATING.name());
if (ok) dataMap.expire(expirationSeconds, TimeUnit.SECONDS);
return ok;
}
// 标记订单的状态
// 注意可能有同学会考虑这里的过期问题,
// 但是我们需要为我们的订单创建服务,设置一个最长的时间
public void markAsCompleted(String orderId) {
dataMap.put(FIELD_STATE, TokenState.COMPLETED.name());
dataMap.put(FIELD_ORDERID, orderId);
}
/**
* 获取当前token的状态
* @return
*/
public TokenState getState() {
String raw = dataMap.get(FIELD_STATE);
if (raw == null) return null;
try {
return TokenState.valueOf(raw);
} catch (IllegalArgumentException e) {
System.err.println("无效状态字符串: " + raw);
return null;
}
}
}
如果创建数据的接口给第三方调用
有时候你的创建订单的接口可能给第三方调用,或者第三方通过接口来调用你的系统创建数据,一般来说我们会提供两个接口,一个是查询接口,一个是创建数据的接口。这个时候token的方案就可能不那么好用,考虑下面这种场景,调用创建数据的接口超时而抛出异常,我们假定遇到了这样一种状况,发起调用的某个时刻网络处于拥挤的状态,客户端出现了超时异常。
但这个时候我们已经在处理这个创建数据的请求了,但是还没创建完毕。对于客户端来说,这个时候需要查询这条数据是否创建完成,但是由于我们的接口还在处理当中,第三方的一个操作是重新生成token来创建数据,这也造成了重复的数据。对于这种模式,我们同样要求第三方系统给一个唯一键,在创建数据的时候同样先查询对应的数据是否创建完毕来避免由于网络抖动带来的重复生成数据。
注意这个业务唯一键应当要求第三方持久化,用于后面的对照数据。
订单创建之后
现在我们的订单已经成功创建成功了,一个场景是给用户发消息或者是发积分,或者是通知下游。我们通常借助消息队列来完成这个动作,而一般的消息队列也有重试,比如RocketMQ、RabbitMQ、Kafka都有重试。在订单重复发出两条消息的情况下,下游会做重复的操作,比如两条短信,虽然短信费很便宜,但是量大了一样是不必要的开销。
显然我们也需要避免这一点,那我们该怎么办呢? 我们可以从消息中选取唯一键,当作分布式锁的key,第二次重试的时候直接返回处理成功, 代码如下面所示:
@Component
public class RocketMQTemplateListenerDemo implements RocketMQListener<RocketMQTemplateListenerDemo.MQDomainMsg> {
@Autowired
private RedissonClient redissonClient;
private static final String ORDER_MSG_LOCK_PREFIX = "ORDER_MSG_LOCK_PREFIX";
@Override
public void onMessage(MQDomainMsg mqDomainMsg) {
String orderCode = mqDomainMsg.orderCode();
RLock lock = redissonClient.getLock(ORDER_MSG_LOCK_PREFIX + orderCode);
if (lock.tryLock()) {
try {
sendMsgService();
}finally {
lock.unlock();
}
}else{
System.out.println("重复消费");
}
}
private void sendMsgService() {}
public record MQDomainMsg(Long orderId, String orderCode){};
}
上面的代码解决的场景是当我们的业务逻辑处理时间超过了MQ的确认消费时间,被MQ认为超时,因此MQ会发起重试,我们为了避免这种状况,于是从消息里面选取了唯一键做为分布式锁的key,锁定这段业务逻辑。 看起来解决了一种场景的问题,但是很快我们还是发现了重复消费,这是为什么呢? 原因在于我们在写代码的时候,先天认为网络是可靠的。
现在让我们考虑下面这样一个场景,假设我们正常消费,确认消费成功的消息也发出去了,但是网络具备不确定性,在某段时间网络拥塞,导致这个回传消息超过了MQ的消费超时时间,于是MQ接着发起重试。于是我们只能引入消息表,在处理业务逻辑之前,先查消息ID是否存在,如果存在就认为是重复消费。我们同样也可以为消费记录引入一个消费状态的字段,来标识是否消费成功。原因在于代码逻辑出现问题是不可知的,有时候因为网络的问题,MQ的重试会重试成功,有时候我们的MQ还处理了其他逻辑无法重试成功。这个都根据真实的需要进行设计,根据情况做出分析,值得注意的是,你的系统不能只考虑成功。
然后我们的逻辑就变成了,先尝试用分布式锁加锁,如果加锁成功在消费记录表里面,根据消息记录ID查询该记录是否存在,如果存在的情况,就直接返回。为了实现这段逻辑我们需要引入一个消费记录表:
CREATE TABLE `consumption_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`message_id` VARCHAR(255) NOT NULL COMMENT '消息的唯一标识符,核心幂等键',
`status` VARCHAR(20) NOT NULL COMMENT '消息处理状态: PROCESSING, SUCCESS, FAILURE',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '当前重试次数',
`error_details` TEXT NULL COMMENT '当状态为FAILURE时,记录错误详情或堆栈',
`created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间',
`updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录最后更新时间',
PRIMARY KEY (`id`),
-- 核心约束:为 message_id 创建唯一索引,这是防止重复处理的最终防线
UNIQUE KEY `uk_message_id` (`message_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='消息消费记录表';
请注意重试次数不是必要的,请根据自己的业务需求判断失败之后是否要重试,这是个冗余的字段,我们的核心诉求在于借助消息中的唯一键防止重复消费。
然后我们的代码逻辑变型为:
@Component
public class RocketMQTemplateListenerDemo implements RocketMQListener<RocketMQTemplateListenerDemo.MQDomainMsg> {
@Autowired
private RedissonClient redissonClient;
private static final String ORDER_MSG_LOCK_PREFIX = "ORDER_MSG_LOCK_PREFIX";
@Autowired
private MQRecordService mqRecordService;
@Override
public void onMessage(MQDomainMsg mqDomainMsg) {
String orderCode = mqDomainMsg.orderCode();
RLock lock = redissonClient.getLock(ORDER_MSG_LOCK_PREFIX + orderCode);
if (lock.tryLock()) {
try {
// 这里只是演示代码逻辑
// MQConsumptionRecord 就是建表的字段
MQRecordService.MQConsumptionRecord mqConsumptionRecord =
new MQRecordService.MQConsumptionRecord();
// 根据唯一键查询
mqConsumptionRecord = mqRecordService.findByMessageId(mqConsumptionRecord);
if (Objects.isNull(mqConsumptionRecord)){
// 创建消息成功
mqConsumptionRecord = mqRecordService.createMQMsgRecord(mqConsumptionRecord);
sendMsgService();
}else{
// 重复消费
}
sendMsgService();
}finally {
lock.unlock();
}
}else{
System.out.println("重复消费");
}
}
private void sendMsgService() {}
public record MQDomainMsg(Long orderId, String orderCode){};
}
同样的我们在对某个数据进行进行修改的时候,也需要避免客户的重复点击,比如关闭订单请求点击重复,这个时候也是一样的思路,用分布式锁保证同时只有一个线程在更新订单状态的相关操作。现在分布式锁用于防止的是我们的业务逻辑超过MQ的消费超时时间的重试,这是由于我们代码的问题,导致我们的确认消费成功消息在指定时间里面没有回传给MQ。我们的消费记录防止的是我们的确认消费由于网络问题导致确认消费消息没在指定时间给到消息队列的场景。
现在创建订单相关的问题解决了一部分,我们来看付款。
付款之后
现在用户发起了付款,但是我们注意付款的流程是我们将钱付给了我们在第三方开设的账户,其实拉起的页面和付款界面已经不在我们程序里面了,然后用户付款成功之后。支付宝和微信会回调通知我们,我们收到这个通知之后修改订单的状态:
注意支付服务和订单服务之间的通信方式可以是通过消息队列,也可以是通过HTTP/RPC方式来进行通知, 用消息队列的好处是消息队列封装了重试,会有更高的可靠性。当然我们也可以用HTTP/RPC做到这一点,但是设计微服务的时候,这个支付服务可以尽可能的通用,不跟下游绑定在一起,如果是消息队列的话,支付服务接收到回调之后发出去消息即可。如果是HTTP/RPC, 如果其他业务线要用到微服务可能就要改动支付微服务。这都取决于你的系统设计要求,没有放之四海而皆准的方案。
但是为了保证通知到位,微信和支付宝同样会进行重试, 在参考文档里面可以看到这一点:
若商户应答回调接收失败,或超时(5s)未应答时,微信支付会按照(0s/15s/15s/30s/180s/1800s/1800s/1800s/1800s/3600s)的频次重复发送回调通知,直至微信支付接收到商户应答成功,或达到最大发送次数(10次)
在这种情况下我们可以靠分布式锁嘛,比如选中支付流水ID,作为分布式锁的key,想想这显然是不够的,原因在于我们的回调订单服务的时候其实已经回调成功了,分布式锁解锁了,只是应答的报文传输的比较慢。然后微信这个时候再次进行回调通知,我们这个时候再改订单的状态,其实是有严重的副作用的,我们应当避免这一点,以淘宝为例,淘宝的订单状态有待付款、待发货、待收货、退款或者售后。
我们可以认为在付款之后,支付平台回调我们的服务,让订单进入待发货状态,但是这个时候由于客户想退款了,于是进入了退款状态,然后第二次重试通知被支付中心接收,接着订单被进入到退款状态。这就造成了强烈的副作用,又或者用户订单退款完成,支付通知接着重试,让用户订单进入到待发货状态。这就意味着损失。
因此我们这里为了避免产生强烈的副作用,在回调下游的时候,先引入分布式锁,避免两个回调同时处理一笔流水,然后在处理的时候可以用查数据库,如下代码所示:
private static final String LOCK_PREFIX = "lock:payment:callback:";
@Autowired
private RedissonClient redissonClient;
@Autowired
private ConsumptionRecordService consumptionRecordService;
@Autowired
private RabbitTemplate rabbitTemplate;
@PostMapping("/wechat")
public String handleWechatCallback(@RequestBody WechatPayCallbackData callbackData) {
String transactionId = callbackData.getTransactionId();
RLock lock = redissonClient.getLock(LOCK_PREFIX + transactionId);
if (lock.tryLock()) {
try {
// 核心逻辑1: 双重检查幂等性
if (consumptionRecordService.isProcessed(transactionId)) {
log.info("回调已处理 (锁内双重检查), transactionId: {}", transactionId);
return buildWechatResponse("SUCCESS", "OK");
}
// 核心逻辑2: 构造并发布领域事件
PaymentSucceededEvent event = new PaymentSucceededEvent(
transactionId,
callbackData.getOutTradeNo(),
callbackData.getTotalFee(),
callbackData.getPayTime()
);
rabbitTemplate.convertAndSend("ex.payment", "rk.payment.succeeded", event);
log.info("已成功发布支付成功事件到MQ, transactionId: {}", transactionId);
// 核心逻辑3: 标记为已处理
consumptionRecordService.markAsProcessed(transactionId, "PUBLISHED");
// 正常处理完成,返回成功
return buildWechatResponse("SUCCESS", "OK");
} catch (Exception e) {
// 如果在处理过程中发生任何异常,记录日志并通知微信重试
log.error("在持有锁期间,处理支付回调时发生异常, transactionId: {}", transactionId, e);
// 这里根据业务逻辑来做,发邮件还是发提醒
return buildWechatResponse("SUCCESS", "OK");
} finally {
// 关键步骤: 无论成功还是异常,都必须释放锁
lock.unlock();
}
} else {
return buildWechatResponse("SUCCESS", "OK");
}
}
作为订单微服务,我们同样也要防止上游的重复通知,因为上游可能为了可靠性可能重复发送消息,所以我们在更新订单状态的时候也可以加上状态限制:
update order_et set order_status = '待发货' order_code = '' and order_status = '待付款';
这里引申出幂等的另一种设计,当涉及变更订单的状态类似操作的时候,我们加入限制,状态的流转只能由某些状态流转到某些状态。
上面是一个非常粗糙的状态流转图,描述了状态流转方向,只能由指定的状态进入到若干状态,我们可以在更新状态的时候加上状态限制。
那如果没有业务状态字段该怎么办
有时候我们的数据没有业务状态该怎么办,我只是对这个数据进行了修改,注意,我们分析这个问题,问题的核心要义在于比较并交换这个思想。于是我们可以引入一个版本号的概念,也就是version, 在发起更新操作的时候,可以请求前端传递这个版本号像下面这样:
update s set xx = '' , version = version + 1 where id = '11' and version = #{version}
这个SQL当且仅当数据库的version字段和传递的字段相等的时候才会发挥作用,这同样也能保证重复处理相同的字段。如果是以API的方式暴露给第三方调用,那么我们可以先根据id查出来版本号,传递给后面的更新操作,这样也能保证处理业务的时候,只有一条处理成功。
总结一下
为了避免第三方重复操作产生副作用,我们采取的手段被称为幂等性设计。我们注意到避免第三方重复操作的核心在于识别出来是重复操作,那么如何判断是重复呢? 核心就要找到一个业务键,我们在业务系统里面暂时缓存,如果再次出现我们就能感知到,来避免重复操作。对于新增操作来说,可以在进入对应页面的时候,让前端请求生成一个token,注意到这个token为了防止第三方伪造,我们可以采取加密算法来生成,这样我们同样也获得了唯一键。
除此之外,如果我们这个系统暴露给外部,也就是通过API的方式给第三方调用,这个token的方式就有些不通用,原因在于如果调用超时,第三方第三方会重新请求获取token的接口,但是由于我们还在处理这条创建数据的请求,第三方调用查询不到这条数据,因此第三方大概率会进行重试。处理这种对接第三方的创建数据请求,可靠的做法是强制要求第三方给出唯一键,系统会根据唯一键来判断这条数据是否处理过。
对于更新操作,我们选中的更新单位的ID就是唯一键,同样也可以采取分布式锁来避免用户快速点击,这是处理短期内重复请求的一个手段。但如果用户悬停在某个页面,以订单为例,假定支付服务处于某种原因重复发送了支付回调通知,但是订单已进入了退款完成状态,这个时候如果不加上订单状态限制,那么同样会造成问题。也就是涉及状态流转的时候,要名学状态流转的方向,举例,只能由待付款进入待发货, 代码如下所示:
update et_order set order_status = '待发货' where order_code = '' and order_status = '';
而不是下面这样:
update et_order set order_status = '待发货' where order_code = '';
所以到这里我们已经明白了在哪些场景需要幂等,如果作为被调用方或者下游不确定上游或者调用方是否会发起重复操作,且重复操作会带来额外的副作用。我们就需要引入额外的代码设计来做幂等。
在上面的场景中我们可以观察到,对于创建操作幂等的核心要义在于寻找唯一键和将唯一键进行持久化,唯一键的存在可以让我们在超过处理时间,第三方发起重试的时候,用做分布式锁的key,来避免这类重试。而持久化的动机则在于避免我们的确认消费消息在网络堵塞的情况下,超过调用方限制的超时时间,调用方发起重试。
对于更新操作,我们同样要选取唯一键来避免业务处理时间超时,用户重复点击产生的重复操作,那么对于有生命周期的数据,大多数数据都是有状态,我们在做状态流转的时候就要加上状态限制。但是如果你没有显示的状态业务字段,那我们不妨将状态变型为比较操作,也就是版本号,在更新的时候判断是否等于目标的版本号。注意这个版本号可以是自己查,也可以是前端传递。注意体会思想,在不同的场景选取对应的工具去实现。
到现在为止我们没有引入任何数学幂等的概念,因为我认为这个引入数学的幂等性无助于我们对这个词的理解,虽然幂等性这个术语是从数学领域借过来的一个词,但这个词在计算机领域有不同的意义。我选取的都是实际场景,讲实际场景会发生什么,然后给出对应的方案,我认为在真实的具体中,能够体会抽象出来的概念。