首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >离线优先的门店收银架构:本地写队列、操作幂等与同步冲突解决

离线优先的门店收银架构:本地写队列、操作幂等与同步冲突解决

原创
作者头像
用户5598620
发布2026-09-10 16:14:58
发布2026-09-10 16:14:58
320
举报

门店收银和普通互联网业务最大的不同,是网络不可靠属于常态:商场地下层信号弱、沿街门店晚高峰拥塞、连锁品牌偶尔专线中断。如果收银端把每一步操作都同步等待服务端返回,一旦断网,前台就只能停摆、顾客排队。离线优先(Offline-First)架构的目标,是让门店在断网时依然能正常开单、收银、退单,网络恢复后再把本地操作可靠地同步到中心,并把多端并发产生的冲突收敛到确定结果。本文拆解这套架构的三个核心:本地写队列、操作幂等、冲突解决。

一、先确立原则:本地是事实来源,中心是最终裁决

离线优先不是"断网时先缓存、联网后直接覆盖",而是要先想清楚数据谁说了算。一个可落地的划分是:

  • 交易事实类(订单、支付流水、退款记录):产生于收银端,本地先落库即为事实,中心不反向改写;
  • 主数据类(商品、售价、会员等级规则):以中心为唯一可写源,本地只缓存只读副本,断网期间沿用最近一次缓存并标注"可能过期";
  • 强一致类(储值余额、积分、券核销):不允许离线写,断网时降级为"挂起待确认",恢复后补校验。

划清这条边界,才能避免最危险的情况:离线期间门店各自改了主数据,联网后互相覆盖。交易可以离线产生,规则必须中心裁决。

二、本地写队列:把每一步操作变成可重放的日志

离线能继续操作的关键,是收银端不直接"改最终状态",而是把用户的每个动作追加进一条只增不改的本地操作日志(Write-Ahead Queue),状态由日志回放得到。这样断网时操作照常入队,联网后按顺序重放即可。

一条操作记录至少包含这些字段:

代码语言:json
复制
{
  "opId": "S018-T03-1725955200001-0007",
  "type": "ORDER_CREATE",
  "payload": { "items": [], "payChannel": "cash" },
  "baseVersion": 4012,
  "status": "PENDING",
  "createdAt": "2026-09-10T14:00:00+08:00",
  "retryCount": 0
}

设计要点:

  1. opId 全局唯一且本地可生成:用"门店号-终端号-本地毫秒序列-当日序号"拼接,断网也不依赖中心发号;
  2. 只追加、不修改:操作一旦入队不可变,状态流转(PENDING/ACKED/REJECTED)单独标记,保证日志可审计、可重放;
  3. 携带 baseVersion:记录该操作基于哪个版本的数据做出,为后续冲突检测留依据;
  4. 持久化优先于响应:先写本地存储(IndexedDB / SQLite / 本地文件)并确认落盘,再向用户反馈成功,避免应用闪退丢单。

同步调度器在网络恢复后扫描 PENDING 操作,严格按 createdAt 顺序逐条上报,单条失败不阻塞无关操作:

代码语言:javascript
复制
async function drainQueue() {
  const pending = await queue.getByStatus("PENDING", { order: "createdAt asc" });
  for (const op of pending) {
    try {
      const res = await api.post("/sync/op", op, {
        headers: { "Idempotency-Key": op.opId },
        timeout: 8000
      });
      await queue.mark(op.opId, res.accepted ? "ACKED" : "REJECTED", res);
    } catch (e) {
      if (isNetworkError(e)) break;          // 网络仍不通,保留队列下次再传
      await queue.mark(op.opId, "REJECTED", { reason: String(e) }); // 业务拒绝转人工
    }
  }
}

三、操作幂等:重传任意次,结果只生效一次

弱网下"请求其实成功了、但响应丢了"非常普遍,客户端会重试,因此服务端必须对同一 opId 幂等。幂等不能只靠数据库唯一键,还要区分"首次处理"和"重复回放":

代码语言:sql
复制
-- 幂等表记录每个操作的处理结果
INSERT INTO op_idempotent(op_id, op_type, state, result_json, created_at)
VALUES (:opId, :type, 'DONE', :result, NOW())
ON CONFLICT(op_id) DO NOTHING;
-- 影响行数为 1 才执行业务;为 0 说明处理过,直接返回上次结果

几个容易忽略的点:

  • 幂等键用业务 opId 而非请求 ID:同一操作的每次 HTTP 重试请求 ID 不同,但 opId 不变,才能识别为同一笔;
  • "查询 + 写入"不是幂等:先查是否存在再插入存在并发窗口,必须靠唯一约束或原子语句;
  • 复合操作要整体幂等:一笔订单同时扣库存、加流水、累加营业款,要么在一个本地事务里提交,要么用同一 opId 串联所有子操作,避免重试时只回滚了一半;
  • 返回首次结果:重复请求拿到的应是第一次处理的结论,而不是再算一遍,避免金额类操作出现偏差。

四、冲突解决:按数据类型选策略,而不是一刀切

离线多端并发必然产生冲突,关键是为不同数据预设确定性的解决规则,而不是弹出"谁覆盖谁"让人现场决定。常见三类策略:

冲突类型

推荐策略

说明

主数据多版本

中心版本优先(Last-Writer-Wins by version)

本地只读,冲突时直接采用更高版本,无歧义

库存并发扣减

原子条件裁决

available >= qty AND version = base 的条件更新,失败方重新读取

同一单据被两端修改

操作变换 / 字段级合并

收银端改了支付方式、后台改了备注,按字段合并;同字段冲突转人工

库存是最典型的场景。离线时本地按缓存余量做"软预占",同步时由中心用一条条件语句做最终裁决:

代码语言:sql
复制
UPDATE stock
SET available = available - :qty, version = version + 1
WHERE sku_id = :skuId AND store_id = :storeId
  AND version = :baseVersion AND available >= :qty;

影响行数为 0 表示版本已变或余量不足,这笔离线预占不能成立,需要挂起并提示处理(换货、退款或调货),而不是静默成功。绝不能让两个离线终端各自把最后一件商品都卖出去,再在对账时才发现超卖。

对于金额、退款这类高风险冲突,原则是"宁可挂起转人工,也不自动合并":自动合并只适用于无歧义的主数据,涉及钱的冲突必须留痕、冻结、人工裁决。

五、踩坑清单

  • 直接改状态而非追加操作日志:断网期间的操作无法可靠重放,恢复后状态错乱;
  • opId 依赖服务端发放:断网时拿不到 ID,离线开单直接失效;
  • 重试不带幂等键或用随机请求号:同一笔单被入账多次;
  • 主数据允许离线可写:恢复后旧值覆盖新值,价格规则混乱;
  • 库存离线直接扣最终值:多端并发超卖,且要到日终对账才暴露;
  • 金额冲突自动"取最新"覆盖:把真实丢单或错账永久掩盖;
  • 队列只在前台同步:应用被杀或终端重启后待传操作丢失,必须持久化并在启动时恢复。

六、工程落地建议

离线优先没有银弹,通用骨架是"操作日志化、传输幂等化、冲突规则化":第一版就应把本地持久化队列、业务幂等键、库存原子裁决、冲突分级这四件事做进去,它们事后补做的代价远高于一开始就规划。技术选型上,Web 收银端可用 IndexedDB 配合 Service Worker 做本地持久化与请求代理,桌面端可内嵌 SQLite;同步通道建议区分"实时增量"与"补偿全量校验"两条,前者保及时、后者保正确。验证阶段务必做故障注入:主动拔网连续开单、两台终端同时抢最后一件商品、同步中途杀进程再重启,确认队列不丢、不重、冲突都能收敛到确定结果。

七、上线前复盘清单

  1. 断网状态下开单、收银、退单是否全程可用,操作是否先持久化再响应;
  2. opId 是否本地可生成、全局唯一,重试是否严格幂等;
  3. 网络恢复后队列是否按序重放,业务拒绝与网络失败是否被区分处理;
  4. 主数据是否只读不可离线改,恢复后是否以中心版本为准;
  5. 库存是否由中心原子裁决,是否压测过离线并发超卖;
  6. 金额类冲突是否只挂起不自动合并,全程是否留痕可追溯;
  7. 终端重启、应用闪退之后,待传队列能否无损恢复。

结语

门店系统的可靠性,很多时候不取决于网络好时跑得多快,而取决于网络差时能不能不丢单、不超卖、恢复后能自动对齐。把用户动作沉淀成可重放的操作日志,用业务幂等键兜住不可靠的网络,再用分级的冲突策略把并发收敛到确定结果,离线优先的收银体系就能在最糟糕的网络环境下依然稳得住。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、先确立原则:本地是事实来源,中心是最终裁决
  • 二、本地写队列:把每一步操作变成可重放的日志
  • 三、操作幂等:重传任意次,结果只生效一次
  • 四、冲突解决:按数据类型选策略,而不是一刀切
  • 五、踩坑清单
  • 六、工程落地建议
  • 七、上线前复盘清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档