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 幂等性设计全景图

说了这么多,我们用一个图来总结幂等性设计的全貌。

幂等性设计全景图 幂等性设计 唯一标识 状态机驱动 存储层兜底 结果可重入 消息队列消费 HTTP API 调用 数据库写入 分布式事务 业务主键去重 请求 ID + 状态机 唯一索引 + UPSERT 事务 ID 幂等表 核心思想:重复执行无害,结果始终一致 唯一标识是前提,状态机保证流程,存储层兜底防穿透,结果可重入保一致

3.5 避坑指南

最后分享几个我实际项目中踩过的坑,希望能帮你少走弯路。

坑一:依赖数据库自增主键做去重

我曾经在一个项目里,用自增 ID 作为幂等键。结果并发场景下,两条消息同时插入,自增 ID 不同,导致重复处理。后来改成业务唯一 ID + 唯一索引才解决。

坑二:忽略缓存穿透

用 Redis 做幂等去重时,如果缓存过期了,重复请求就会穿透到数据库。我建议缓存 TTL 设置得比业务重试窗口长,或者干脆用数据库做最终兜底。

坑三:状态机设计不完整

有些状态流转是单向的,比如「已支付」不能回到「待支付」。但如果你没处理好异常状态,比如「支付中」超时了,状态机可能卡死。设计状态机时,一定要考虑所有可能的路径和异常情况。

嗯,幂等性就讲到这里。记住四个原则:唯一标识、状态机驱动、存储层兜底、结果可重入。把这四点做到位,你的系统离 Exactly Once 就更近一步了。