------宁可"多拦一点",绝不"多打一张"
作者:magixie
场景:IoT 远程打印(类似百步印社)
痛点:接口重试 = 真金白银损失
原则:任何情况下,都不能重复扣费、重复打印
前言
在普通互联网系统里,重复请求最多是:
-
多扣一次积分
-
多发一条短信
-
多记一条日志
但在 IoT 远程打印 场景里,重复请求意味着:
纸打出来了,墨用掉了,钱扣过了,还退不了。
这也是为什么我在前几篇一直强调一句话:
IoT 打印系统,幂等不是"高级特性",而是"生存底线"。
这篇文章,我会从设计思想 → 技术方案 → 实战细节 → 兜底机制四个层次,把这件事讲透。
一、为什么 IoT 打印系统"特别容易重复"
1️⃣ 设备侧天然不可靠
-
打印机断网
-
Wi-Fi 信号抖动
-
4G / NB-IoT 弱网
-
硬件看门狗重启
👉 结果:设备认为"失败了",其实服务端已经成功
2️⃣ 协议层重试不可避免
-
HTTP 超时重试
-
MQTT QoS ≥ 1
-
SDK 自动重试
-
运营人员手动"重新打印"
👉 结果:同一条业务请求,被多次送达
3️⃣ 业务语义模糊
很多系统设计成:
javascript
POST /print
但没有回答一个关键问题:
"同样的请求,第二次来,算什么?"
二、幂等设计的三个核心原则
✅ 原则 1:业务定义幂等,而不是技术猜幂等
错误示例:
"我们用 Redis SETNX 就算幂等了。"
正确做法:
"同一个商户 + 同一个设备 + 同一个业务单据 = 一次打印。"
✅ 原则 2:越早拦截越好
幂等校验应该在:
-
Controller 之前
-
下单逻辑之前
-
扣费逻辑之前
顺序必须是:
防重 → 验参 → 下单 → 扣费 → 打印
✅ 原则 3:幂等 ≠ 不处理
| 情况 | 返回结果 |
|---|---|
| 第一次请求 | 正常处理 |
| 重复请求(未完) | 返回"处理中" |
| 重复请求(已完成) | 返回"已成功" |
👉 绝不能返回错误让用户重试
三、幂等模型设计(核心)
✅ 1. 幂等 Key 的设计(非常关键)
推荐标准格式:
idempotentKey = merchantId + deviceId + bizNo
| 字段 | 说明 |
|---|---|
| merchantId | 商户维度 |
| deviceId | 设备维度 |
| bizNo | 商户侧业务单号(必须) |
📌 bizNo 是灵魂
-
商户订单号
-
业务流水号
-
打印批次号
如果商户没传 bizNo,直接拒绝(这是红线)
✅ 2. 幂等状态机
UNKNOWN
↓
PROCESSING
↓
SUCCESS / FAILED
| 状态 | 含义 |
|---|---|
| UNKNOWN | 刚进来,还没落库 |
| PROCESSING | 已受理,未打印完成 |
| SUCCESS | 已打印 |
| FAILED | 明确失败(可重试) |
四、技术实现方案(生产级)
✅ 方案一:Redis + 数据库双保险(强烈推荐)
1️⃣ Redis 做"第一道门"
java
SET idempotent:{key} PROCESSING NX EX 300
-
NX:保证只进一次
-
EX:防止死锁
返回结果:
-
OK → 第一次请求
-
NULL → 重复请求
2️⃣ 数据库做"最终裁判"
sql
CREATE TABLE print_order (
id BIGINT PRIMARY KEY,
merchant_id VARCHAR(32),
device_id VARCHAR(32),
biz_no VARCHAR(64),
status VARCHAR(20),
created_at DATETIME,
UNIQUE KEY uk_merchant_device_biz (merchant_id, device_id, biz_no)
);
✅ 唯一索引是最后的防线
✅ 请求处理流程(完整)
1. 校验参数
2. 生成幂等 Key
3. Redis SETNX
├─ 失败 → 查询 DB
│ ├─ SUCCESS → 返回已成功
│ ├─ PROCESSING → 返回排队中
│ └─ FAILED → 返回失败原因
└─ 成功 → 创建订单
↓
扣费
↓
发送 MQ
↓
更新状态
✅ 3. 返回结果必须"语义清晰"
// 第一次
{
"orderId": "P123",
"status": "QUEUED"
}
// 重复请求(未完)
{
"orderId": "P123",
"status": "QUEUED",
"message": "任务已在队列中"
}
// 重复请求(已完成)
{
"orderId": "P123",
"status": "SUCCESS",
"message": "任务已打印完成"
}
📌 千万不要返回 500 或 409 让用户"再试一次"
五、MQ 消费端的幂等(非常容易被忽略)
✅ 问题本质
MQ 可能:
-
重复投递
-
消费失败重试
-
消费者重启重放
✅ 解决方案
1️⃣ 消费前校验
java
if (order.status != PROCESSING) {
return; // 已处理过,直接 ACK
}
2️⃣ 打印动作幂等
-
打印机端支持
taskId -
同一
taskId只执行一次 -
或打印前查询"是否已打印"
✅ 3️⃣ 消费成功才 ACK
顺序必须是:
执行业务
↓
更新状态
↓
ACK
而不是反过来。
六、设备侧的防重配合(很重要)
✅ 1. 设备必须带 bizNo
-
打印机 SDK 强制要求
-
不传 bizNo 直接拒绝
✅ 2. 设备重试策略
| 场景 | 策略 |
|---|---|
| 网络超时 | 指数退避 |
| 5xx 错误 | 不重试 |
| 429 限流 | 排队等待 |
| 业务返回"已成功" | 不再打印 |
✅ 3. 本地去重缓存
打印机本地缓存最近 N 条 taskId
防止:
-
设备重启
-
网络闪断
-
重复执行
七、兜底机制(架构师的底线思维)
✅ 1. 定时任务补偿
-
扫描
PROCESSING超过阈值的订单 -
主动查询打印机状态
-
修复状态不一致
✅ 2. 人工干预通道
-
后台"标记为已打印"
-
后台"强制重打"
-
后台"退款"
系统解决 99%,人解决 1%
✅ 3. 对账系统(终极防线)
-
商户账单
-
打印日志
-
设备日志
-
财务对账
八、小结:一句话记住这套方案
IoT 打印系统的幂等设计,不是"防止重复请求",而是"允许重复请求,但只生效一次"。
九、架构师的经验之谈
| 错误认知 | 正确认知 |
|---|---|
| 幂等是技术细节 | 幂等是业务契约 |
| Redis 就够了 | DB 唯一索引才是底线 |
| 返回错误让用户重试 | 返回状态让用户安心 |
| 系统 100% 不出错 | 出错也能兜底 |
后记
幂等设计,是我在 IoT 打印系统里最不敢偷懒的地方。
因为我知道:
**多打一张纸,用户骂的是你;
少打一张纸,用户找的还是你。**