IoT 打印系统的幂等设计与防重方案

------宁可"多拦一点",绝不"多打一张"

作者: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 打印系统里最不敢偷懒的地方

因为我知道:

**多打一张纸,用户骂的是你;

少打一张纸,用户找的还是你。**