3. 幂等性原理:数学定义、分布式应用与核心设计原则
聊到 Exactly Once,幂等性是个绕不开的话题。我经常跟团队说,幂等性就是 Exactly Once 的「地基」。地基不稳,上层做得再花哨也是白搭。今天我们就来把这个地基彻底搞明白。
3.1 幂等性的数学定义
先看数学上的定义。幂等性(Idempotence)这个概念最早来自抽象代数。一个操作如果被多次执行,产生的结果跟执行一次完全一样,那它就是幂等的。
用数学公式表达就是:
f(f(x)) = f(x)
什么意思?你想想看,对一个值 x 反复应用函数 f,第二次及以后的结果跟第一次一样。这就是幂等。
举个简单的例子:
- 非幂等操作:
count = count + 1。执行一次 count 是 1,执行两次是 2,结果不一样。 - 幂等操作:
set flag = true。执行一次是 true,执行一百次还是 true。
我个人习惯把幂等性理解成「重复执行无害」。你按一次电梯按钮,电梯来了。你狂按十次,电梯还是那一个。电梯按钮就是幂等的。
核心要点:幂等性关注的是「结果的一致性」,而不是「过程的不重复」。系统可以重复处理消息,但最终状态必须跟只处理一次相同。
3.2 幂等性在分布式系统中的应用
分布式系统里,网络是不可靠的。消息可能重复投递,请求可能超时重试,消费者可能崩溃重启。这些场景下,幂等性就是救命稻草。
我遇到过最典型的场景是支付系统。用户下单支付,结果网络超时了。前端自动重试了一次。如果支付接口不做幂等处理,用户就被扣了两次钱。这问题可就大了。
分布式系统中幂等性的应用场景主要有以下几类:
| 场景 | 问题 | 幂等方案 |
|---|---|---|
| 消息队列消费 | 消费者收到重复消息 | 业务主键去重 |
| HTTP API 调用 | 客户端超时重试 | 请求 ID + 状态机 |
| 数据库写入 | 主键冲突或重复插入 | 唯一索引 + INSERT ON DUPLICATE |
| 分布式事务 | 参与者重复提交 | 事务 ID 幂等表 |
说白了,只要存在「重试」机制的地方,就必须考虑幂等性。而分布式系统里,重试是家常便饭。
3.3 幂等性设计的核心原则
做了这么多年中间件,我总结出幂等性设计的四个核心原则。这些原则不是理论推导出来的,是踩坑踩出来的。
原则一:唯一标识是前提
没有唯一 ID,幂等性就是空谈。每个请求、每条消息、每个操作,都必须有一个全局唯一的标识。
这个标识怎么生成?我建议用业务自带的主键,比如订单号、支付流水号。如果业务没有天然主键,那就自己生成 UUID 或者雪花 ID。
我的习惯:消息队列里,每条消息都带一个 message_id。消费者处理前先查这个 ID 是否处理过。处理过就直接跳过,没处理过才执行逻辑。
原则二:状态机驱动
很多业务操作是有状态的。比如订单状态:待支付 → 已支付 → 已发货 → 已完成。这种场景下,幂等性设计要跟状态机结合。
举个例子:
// 幂等的支付处理逻辑
if (order.status == PAID) {
// 已经支付过了,直接返回成功
return success;
}
if (order.status == PENDING_PAY) {
// 执行支付逻辑
order.status = PAID;
save(order);
return success;
}
// 其他状态,报错
return error;
你看,状态机天然就是幂等的。每个状态只允许特定的流转,重复执行不会导致状态错乱。
原则三:存储层兜底
应用层的幂等逻辑再完善,也防不住所有情况。我建议在存储层也做一层兜底。
最常用的手段就是唯一索引:
CREATE TABLE payment_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
payment_id VARCHAR(64) NOT NULL UNIQUE, -- 唯一索引
order_id VARCHAR(64) NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL,
created_at DATETIME NOT NULL
);
-- 插入时使用 INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO payment_record (payment_id, order_id, amount, status, created_at)
VALUES ('PAY20240101001', 'ORD20240101001', 99.99, 1, NOW())
ON DUPLICATE KEY UPDATE status = VALUES(status);
这样即使应用层重复调用了,数据库也会帮你挡住重复数据。
我曾经踩过的坑:只依赖应用层缓存做去重,结果缓存过期了,重复消息就穿透了。后来我学乖了,缓存 + 数据库唯一索引双保险,才真正稳下来。
原则四:结果可重入
这个原则比较抽象。简单说就是:幂等操作返回的结果,必须跟第一次执行时一致。
比如查询操作本身就是幂等的。但有些操作,第一次返回「处理中」,第二次返回「处理成功」,这就不是幂等的。客户端收到不同的结果,可能会产生歧义。
我建议的做法是:
- 幂等操作统一返回「最终状态」
- 不要根据执行次数返回不同结果
- 如果状态有变化,用状态机保证一致性
3.4 幂等性设计全景图
说了这么多,我们用一个图来总结幂等性设计的全貌。
3.5 避坑指南
最后分享几个我实际项目中踩过的坑,希望能帮你少走弯路。
坑一:依赖数据库自增主键做去重
我曾经在一个项目里,用自增 ID 作为幂等键。结果并发场景下,两条消息同时插入,自增 ID 不同,导致重复处理。后来改成业务唯一 ID + 唯一索引才解决。
坑二:忽略缓存穿透
用 Redis 做幂等去重时,如果缓存过期了,重复请求就会穿透到数据库。我建议缓存 TTL 设置得比业务重试窗口长,或者干脆用数据库做最终兜底。
坑三:状态机设计不完整
有些状态流转是单向的,比如「已支付」不能回到「待支付」。但如果你没处理好异常状态,比如「支付中」超时了,状态机可能卡死。设计状态机时,一定要考虑所有可能的路径和异常情况。
嗯,幂等性就讲到这里。记住四个原则:唯一标识、状态机驱动、存储层兜底、结果可重入。把这四点做到位,你的系统离 Exactly Once 就更近一步了。