19 KiB
19 KiB
发送模块排队串行下发与失败重排设计方案
版本:v1.0
适用模块:ruoyi-iot门锁命令下发(MQTT 下行)
说明:本文为设计文档,描述目标架构与关键决策,不涉及代码实现。
1. 方案概述与目标
1.1 背景与现状
当前人脸 / 门锁授权命令的下发链路为:
/student/face → 新建 LockAuth → insertAuth → enqueue 建 LockCommand → send 落库 → afterCommit 提交线程池 → dispatch → publishCommand(Paho MQTT,QoS=0)。
其并发特征是:
- 派发经
threadPoolTaskExecutor(core=50 / max=200 / queue=1000)并行执行; dispatch仅对单条命令做行锁(selectCommandByIdForUpdate(commandId)),不同命令互不阻塞;publishCommand直接调用 Pahopublish,无队列、无串行、无“成功后再发下一条”;- QoS=0 为“至多一次”,仅代表已交给 broker,不代表设备生效;
- 每 2s 的
LockCommandRetryTask负责超时判定与重试补偿。
结果:并发人脸命令无序、并行下发,同一设备/同一主题上的消息可能交错或乱序,且失败与后续互不阻塞。
1.2 目标
- 排队:命令按先进先出(FIFO)进入队列;
- 串行下发:同一目标(Lane)内严格串行,前一条到达“完成门”后才发下一条;
- 失败重排:按策略退避重试,耗尽后进入死信/人工;
- 可恢复:进程重启后队列与状态可续;
- 可观测:排队、重试、失败、时延全程可追踪与告警。
1.3 范围与非目标
- 范围:仅改造“命令生成之后的下发调度层”,叠加在现有
LockCommand模型与 MQTT 通道之上。 - 非目标:不改变 Ydlink 协议、不改变命令与授权业务模型、不引入 broker 级 QoS 语义变更(仍以 QoS=0 为传输基础)。
1.4 术语表
| 术语 | 含义 |
|---|---|
| Lane(通道 / 泳道) | 串行化单元,通常为 deviceSn(或 groupCode / topic)。同一 Lane 内串行,不同 Lane 可并行 |
| Task(队列任务) | 指向一条 command_id 的排队项,携带 Lane、优先级、序号、可见时间等 |
| 完成门(Completion Gate) | 判定“本条算发送完成、可推进下一条”的条件(传输成功 / 设备 ACK 确认) |
| 队头(Head-of-Lane) | 某 Lane 中当前应被取出执行的最小 (priority, seq) 任务 |
| 租约(Lease) | 多实例下某实例对某 Lane/任务的临时独占权,带过期时间 |
| 死信(DEAD) | 重试耗尽或不可重试后进入的终态,保留现场供人工处理 |
| 结果未知 | QoS=0 下 publish 抛异常但无法确认是否已达设备 |
2. 系统架构与模块划分
2.1 总体架构
┌──────────────────────────────────────────────────────────┐
│ 业务入口(如 /student/face) │
└───────────────────────────┬──────────────────────────────┘
│ ① 事务内落库 LockCommand(READY) + 入队
▼
┌──────────────────────────────────────────────────────────┐
│ 队列协调器 QueueCoordinator │
│ 去重 / 优先级 / 容量控制 / Lane 内 seq 分配 │
└───────────────────────────┬──────────────────────────────┘
│ ② 写入持久队列
▼
┌──────────────────────────────────────────────────────────┐
│ 持久队列 lock_dispatch_task(MySQL) │
└───────────────────────────┬──────────────────────────────┘
│ ③ 取队头(SKIP LOCKED + 租约)
▼
┌──────────────────────────────────────────────────────────┐
│ 通道调度器 LaneScheduler │
│ 每 Lane 单执行者(串行)/ 全局或每目标策略 │
└───────────────────────────┬──────────────────────────────┘
│ ④ 单条执行
▼
┌──────────────────────────────────────────────────────────┐
│ 下发执行器 DispatchExecutor │
│ dispatch → publishCommand / 完成门判定 │
└───────┬───────────────────────────────────┬──────────────┘
成功 │ │ 失败 / 超时
▼ ▼
┌───────────────┐ ┌───────────────────────┐
│ DONE(成功) │ │ 重试与死信 RetryManager │
└───────────────┘ └───────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 可观测模块 Metrics / Logging / Tracing │
└──────────────────────────────────────────────────────────┘
2.2 模块划分与职责
| 模块 | 职责 | 关键点 |
|---|---|---|
| 队列协调器 QueueCoordinator | 入队、去重、优先级、容量控制、Lane 内 seq 分配 |
与业务落库同事务,保证“有命令必有任务” |
| 持久队列 lock_dispatch_task | 持久化 FIFO,支撑重启恢复与多实例 | 状态与顺序以 DB 为准,内存队列仅作唤醒加速 |
| 通道调度器 LaneScheduler | 按 Lane 取队头、租约、串行执行者管理 | 每 Lane 同时仅一个执行者;不同 Lane 并行 |
| 下发执行器 DispatchExecutor | 调用现有 dispatch/publishCommand,执行完成门判定 |
复用现有派发与安全校验逻辑,不重复实现 |
| 重试与死信 RetryManager | 失败分类、退避重排、死信沉淀 | 可重试 vs 不可重试;结果未知不自动重放 |
| 状态存储 | 任务状态与命令状态持久化、乐观锁 | 复用 LockCommand,扩展队列表 |
| 补偿任务 | 超时判定、僵尸租约回收、重试唤醒 | 改造/替代现有 LockCommandRetryTask(每 2s) |
| 可观测模块 | 日志、指标、告警、链路追踪 | 以 intentKey/commandId 为关联 ID |
2.3 部署拓扑
- 单实例:每 Lane 使用进程内单线程执行器(
SerialExecutor)或Semaphore(1)保证串行;持久队列用于重启恢复。 - 多实例:在 DB 层用
SELECT ... FOR UPDATE SKIP LOCKED+ 租约(lease_owner/lease_expire_at)保证同一 Lane 同一时刻只有一个实例持有并执行;备选方案为按 Lane 的 Redis 分布式锁。 - 决策:优先 DB 租约,避免额外中间件依赖,且与持久队列同源;多依赖 Redis 时再切换。
2.4 可观测性设计
- 日志(结构化):在
enqueue / dequeue / start / publish / ack / retry / dead / drop节点各打一条,统一携带laneKey, taskId, commandId, intentKey, attempt, at;记录时间轴enqueueAt→startAt→publishAt→ackAt→retryAt。 - 指标:
- 队列:
queue_depth{lane}、global_depth、in_flight_count、enqueue_rate、dequeue_rate; - 质量:
success_rate、failure_rate{reason}、retry_count分布、retry_success_rate、dead_letter_count; - 时延:
time_in_queue、dispatch_latency、end_to_end_latency; - 健康:
lane_busy_time、head_of_line_blocked_ms、lease_expired_count。
- 队列:
- 告警:队列积压(超阈值/持续增长)、失败率/重试率/死信增长、端到端或排队时延超阈值、单实例长期持锁(疑似僵死)、连接断开。
- 追踪:以
intentKey/commandId为 correlation id,贯通“请求→入队→派发→MQTT→设备 ACK→补偿重试”。
3. 关键流程与数据说明
3.1 数据结构(lock_dispatch_task)
| 字段 | 说明 |
|---|---|
task_id |
任务主键 |
lane_key |
目标标识(deviceSn / groupCode / topic) |
command_id |
关联命令(FK),一条命令至多一个在途任务 |
intent_key |
幂等键,用于去重 |
priority |
越小越优先(撤销/删除=0,新增=1) |
seq |
Lane 内单调递增序号(决定 FIFO) |
state |
任务状态(见 §3.2) |
attempts |
已尝试次数 |
next_visible_at |
可见时间(退避用),此前不被取走 |
lease_owner / lease_expire_at |
多实例租约 |
version |
乐观锁 |
last_error |
最近错误摘要(截断存储) |
created_at / updated_at |
审计与排障 |
索引建议:(lane_key, state, priority, seq)、(state, next_visible_at)、唯一 (command_id)。
3.2 状态机
PENDING(待发送) ──取队头──▶ IN_FLIGHT(发送中) ──完成门──▶ DONE(成功)
▲ │
│ ├─可重试失败/超时─▶ RETRY_WAIT(失败待重试) ──next_visible_at──▶ PENDING
│ ├─不可重试/超 maxRetry─▶ DEAD(丢弃/死信)
│ └─结果未知(QoS0)─▶ MANUAL_REVIEW(人工对账, 不自动重放)
└──────────────── 重启回收:IN_FLIGHT 且租约过期 ─▶ PENDING / MANUAL_REVIEW
与现有 executionState / commandStatus 的映射:
| 本方案状态 | 现有映射 |
|---|---|
| PENDING | executionState=READY,commandStatus=pending |
| IN_FLIGHT | executionState=DISPATCHING,commandStatus=pending |
| RETRY_WAIT | executionState=READY + nextVisibleTime 未来 |
| DONE | AWAITING_ACK(模式 A)/ CONFIRMED(模式 B) |
| DEAD / 丢弃 | MANUAL_REVIEW / QUARANTINED,commandStatus=fail/timeout |
3.3 入队流程
- 命令在同一事务内落库为
READY,同时写入lock_dispatch_task; - 计算
lane_key、priority、seq(Lane 内MAX(seq)+1); - 唯一约束
(command_id)与intent_key保证幂等,重复入队被忽略; - 可选“合并”策略:同一设备同一学员重复上传时,旧任务置
DEAD/drop,新任务入队(以最新为准)。
3.4 出队与串行下发流程
- 调度器按
(priority, seq)选出各 Lane 队头; SELECT ... FOR UPDATE SKIP LOCKED抢占,state→IN_FLIGHT,写lease_owner/expire;- 投递到该 Lane 的单执行者串行执行;Lane 在任务到终态前不再取下一个;
- 执行内容复用现有
dispatch → publishCommand(含安全校验与requirePersistedDispatch)。
3.5 完成门与推进
- 模式 A(默认,QoS=0):
publish未抛异常即视为传输成功,推进下一条。低延迟,但仅表示已交 broker。 - 模式 B(强一致,可配置):命令进入
AWAITING_ACK后,等待设备业务 ACK →CONFIRMED才推进;ACK 超时按失败重排或转人工。 - 决策:默认 A;对“增删互斥 / 强顺序敏感”的 Lane 启用 B。文档需明确“发送成功 ≠ 设备生效”的语义。
3.6 失败重排流程
- 失败分类:可重试(连接断开、
MqttException、ACK 超时)与不可重试(策略拦截POLICY_BLOCKED、载荷/身份不一致、安全拒绝、方向非法)。 - 不可重试:直接
DEAD/MANUAL_REVIEW,不重排。 - 可重试:
attempts++,next_visible_at = now + delay,state→RETRY_WAIT;退避采用min(cap, base·2^attempts)·(1±jitter)(base≈2s,cap≈30min)。 - 重排位置(关键决策):
- 严格(默认):任务留在队头(
RETRY_WAIT),同 Lane 后续等待;达maxRetry/超时限后转DEAD/MANUAL_REVIEW并释放队头,推进下一条。 - 宽松(可配置):失败任务让出队头、移入延迟/队尾,后续先发;吞吐好但可能改变 Lane 内相对顺序。
- 严格(默认):任务留在队头(
- 结果未知(QoS=0):不自动重放,转
MANUAL_REVIEW人工对账,避免设备重复执行。
3.7 重启恢复流程
- 启动扫描
lock_dispatch_task与命令状态; IN_FLIGHT且租约过期 → 视为结果未知:转MANUAL_REVIEW(或按配置重排);PENDING/RETRY_WAIT→ 依据next_visible_at恢复为队头候选;- 幂等键防止重复入队。
3.8 去重、优先级与幂等
- 去重:唯一
(command_id);intent_key作为跨重复上传的幂等键。 - 优先级:
(priority ASC, seq ASC)决定 Lane 内先后(控制类优先于新增);优先级不跨 Lane 抢占;可加“优先级老化”防低优先级饥饿。 - 幂等:重试复用同一
command_id,不产生新业务副作用。
4. 接口与依赖
4.1 内部接口(设计契约)
| 接口 | 说明 |
|---|---|
enqueue(commandId, laneKey, priority, intentKey) |
事务内写入队列,返回 taskId;幂等 |
claimHead(laneKey, leaseMs) |
抢占队头(SKIP LOCKED + 租约),返回任务或空 |
markInflight(taskId, leaseOwner) |
状态置 IN_FLIGHT,续租 |
complete(taskId, outcome) |
置 DONE / RETRY_WAIT / DEAD,推进队头 |
reschedule(taskId, delayMs, error) |
退避重排,写 next_visible_at |
recoverOnStartup() |
启动恢复:回收僵尸 IN_FLIGHT、恢复可见任务 |
4.2 外部依赖
- MySQL:持久队列与状态存储;
FOR UPDATE SKIP LOCKED需 MySQL 8.0+(不满足时退回乐观锁 + 租约轮询)。 - Paho MQTT(QoS=0):现有传输通道,无 broker 级确认,完成门语义受其限制。
- 调度器:Spring
@Scheduled(改造现有LockCommandRetryTask)。 - 可选 Redis:按 Lane 的分布式锁(DB 租约的替代方案)。
4.3 与现有实现的对接点
- 复用
LockCommand(intentKey、executionState、commandStatus、retryCount、maxRetry、nextVisibleTime、attemptStartedAt)。 - 复用
LockExecutionTransactions(required/committed)处理事务边界。 - 复用
dispatch内的安全校验(requirePayloadAndIdentity、requireDispatchAllowed、requirePersistedDispatch)。 - 用“入队”替代现有
afterCommit → taskExecutor.execute的并行派发;由 Lane 串行执行器驱动。
5. 约束与风险
5.1 约束
- 串行粒度上限为 Lane(
deviceSn/topic),跨 Lane 不保证顺序。 - QoS=0 无 broker 确认,“传输成功”不等于“设备生效”。
SKIP LOCKED依赖 MySQL 8.0+;旧版本需降级实现。- 必须保持
intentKey幂等与现有安全校验链不变。 - 授权类命令为安全敏感操作,不允许静默丢弃。
5.2 风险与应对
| 风险 | 影响 | 应对 |
|---|---|---|
| 队头阻塞(严格策略) | 单条失败拖住同 Lane 后续 | 设最大重试/最大滞留时限强推;或切宽松策略 |
| 吞吐下降(串行) | 单 Lane 吞吐受限于一条在发 | 每目标 Lane 并行;仅必要时全局单 Lane |
| 多实例双消费 | 同任务被重复执行 | SKIP LOCKED + 租约心跳 + 幂等 |
| 结果未知重复发送 | 设备重复增删 | QoS=0 结果未知不自动重放,转人工 |
| 队列满背压 | 影响业务受理 | 授权类显式报错;可选降级直存待重试 |
| 时钟漂移/惊群 | 大量任务同刻复活 | next_visible_at 用 DB 时间 + 退避抖动 |
重启遗留 IN_FLIGHT |
僵尸任务 | 租约过期回收 + 启动扫描 |
5.3 异常与边界处理
| 场景 | 处理设计 |
|---|---|
| 队列满 | 有界队列(全局 + 单 Lane);授权类拒绝并显式报错(背压),不静默丢;非关键可配丢最旧 |
| 发送超时 | IN_FLIGHT 超阈值(如 30s)标记超时;QoS=0 结果未知转 MANUAL_REVIEW;模式 B 按 ACK 超时重排 |
| 重复失败 | 达 maxRetry → DEAD + 告警;死信保留现场;禁止无限重试 |
| 进程重启 | 按 §3.7 扫描恢复;僵尸 IN_FLIGHT 转人工或重排 |
| 多实例 | SKIP LOCKED + 租约;处理幂等 |
| 设备离线 | 连接/ACK 失败按可重试退避;超上限转 DEAD 并告警 |
6. 待确认事项
- Lane 粒度:以
deviceSn、groupCode还是topic作为串行单元?(影响并发度与顺序语义) - 完成门选型:默认“传输成功即推进”,还是“设备 ACK 确认才推进”?哪些 Lane/命令需强一致?
- 重排策略:默认严格(阻塞队头)还是宽松(让出队头)?是否按命令类型区分?
- 队列满策略:拒绝(背压)、降级直存,还是丢最旧?哪些命令允许丢弃?
- 是否需全局串行:跨设备全局有序是否为硬需求(若否,保持每 Lane 串行)?
- 多实例部署:是否多实例部署?租约时长、续租心跳、
SKIP LOCKED的 MySQL 版本支持情况? - 重试参数:
maxRetry、base、cap、最大滞留时限、IN_FLIGHT超时阈值的具体取值。 - 死信处理流程:
DEAD的人工对账/重推入口与 SLA。 - 重复上传语义:同一学员同一设备重复上传是“以最新为准合并”还是“各自排队”?
- 数据保留:
DONE/DEAD的保留期与归档清理策略。
附录:落地阶段建议
- 阶段一(正确性):DB 持久队列 + 每 Lane 串行 + 严格重排(完成门模式 A),打通排队与顺序。
- 阶段二(强一致):按需开启模式 B(ACK 门控)与失败分类精细化。
- 阶段三(吞吐与治理):宽松重排、优先级老化、死信流程、可观测看板与告警完善。
全方案不改变 Ydlink 协议与现有命令模型,仅在命令之上叠加调度层。