网络打印机多设备并发冲突:一道被低估的分布式竞争题

8 阅读5分钟

网络打印机多设备并发冲突:一道被低估的分布式竞争题

背景

我们有一个食堂点餐系统,两台自助点餐机共享同一台佳博网络打印机(TCP 9100 端口),采用即连即打模式:下单时建连、发送打印数据、立即断连,全程 100-200ms。

听起来很简单,但客户反复反馈"后厨打印机没响应"。排查日志后发现:两台设备同时下单时,总会有一台连不上打印机。


问题建模

打印机 TCP 9100 的特性

佳博网络打印机的 9100 端口同时只能接受一个 TCP 连接。这是一个典型的互斥资源

设备A: connectNet → printData → disconnectNet  (占用100~200ms)
设备B:                    connectNet → ❌ 端口被A占用,失败

多设备碰撞时间线

时间轴 →
A: [======= 连接+打印 150ms =======]
B:          [==== 连接失败 ====]
                          ↓ 报错弹窗:后厨打印机连接失败

两台设备只要在 150ms 窗口内同时触发,必然有一台失败。


原始方案:固定延迟重试(失败)

代码

const MAX_NET_RETRIES = 2;
for (let attempt = 0; attempt < MAX_NET_RETRIES; attempt++) {
  try {
    await gprinterManager.connectNet(ip, 9100, type);
    await gprinterManager.printImageBase64(data, 0, 0, size);
    break; // 成功
  } catch (e) {
    // 失败后等 1000ms 重试
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
}

为什么 100% 失败

设备A: connect失败 → 等1000ms → 重试 ← 同时到达
设备B: connect失败 → 等1000ms → 重试 ← 同时到达
                                       ↓
                                  又撞了,再次失败

两台设备等待的时间一模一样,形成了同步重试。不是延迟不够长,是两端在同一个节拍上共振。重试多少次都没用——第一次撞,后面次次撞。

数学解释

固定延迟本质是确定性系统——相同的输入总是产生相同的输出。在分布式系统中,确定性协调如果没有中心节点,必须依赖随机性来打破对称。

这是分布式系统里的经典教训:无协调的竞争必须引入随机性。以太网的 CSMA/CD、WiFi 的退避算法、数据库的乐观锁重试,都是同样的原理。


新方案:固定窗口随机退避

核心改动

const MAX_NET_RETRIES = 2;
for (let attempt = 0; attempt < MAX_NET_RETRIES; attempt++) {
  try {
    await gprinterManager.connectNet(ip, 9100, type);
    await gprinterManager.printImageBase64(data, 0, 0, size);
    break;
  } catch (e) {
    if (attempt < MAX_NET_RETRIES - 1) {
      // 随机退避 150-650ms,多台设备自然错开连接时机
      const backoff = 150 + Math.random() * 500;
      await new Promise(resolve => setTimeout(resolve, backoff));
    }
  }
}

只改了一行:setTimeout(resolve, 1000)setTimeout(resolve, 150 + Math.random() * 500)

效果

设备A: connect失败 → 随机退避320ms → 重试成功 ✅
设备B: connect失败 → 随机退避580ms → 重试成功 ✅
                            ↑
              自然错开260ms,打印机端口已空闲

参数选型与数学分析

退避窗口为什么选 150-650ms

约束条件考虑
下限 150ms打印机一次完整打印周期约 100-200ms。如果等太短(<100ms),对方还没断连,重试必败
上限 650ms用户等待容忍上限。如果等超过 800ms,下单体验明显变慢
窗口宽度 500ms两台设备落在同一 100ms"冲突窗"内的概率为 100/500 = 20%

碰撞概率计算(两台设备)

一次退避碰撞概率:   P₁ = 冲突窗宽度 / 退避窗口 = 100ms/500ms20%
两次退避都碰撞:     P₂ = P₁ × P₁ = 0.2² = 4%

单次重试成功率 80%,两次重试成功率 96%。 对两台设备来说完全是工程上可接受的。

为什么不用指数退避

这是一个反直觉的决策。指数退避(窗口随重试次数扩大)在设计上更强,但有前提:竞争方足够多时才有优势。

固定窗口 (150-650ms)指数退避 (100-300 → 200-600 → 400-1200)
2台设备首轮碰撞率20%50%(窗口太小)
2台设备两轮碰撞率4%~12%
最坏延迟~800ms~2200ms
适用场景低竞争(2-5台)高竞争(10+台)

固定窗口在低竞争下反而更优:窗口够宽、速度够快、概率够低。指数退避是给高并发准备的,两台设备用它属于过度设计。

MAX_NET_RETRIES 为什么选 2 而不是 3

2次重试3次重试
成功率96%99.2%
多赚的成功率+3.2%
失败时多等的耗时+650ms

从 96% 到 99.2% 只提升 3 个百分点。而且两次都撞的时候大概率是网络或打印机本身出了问题,第三次大概率还是失败。2 次是性价比最优解。


方案对比总览

维度旧方案(固定1000ms)新方案(随机退避)
退避策略固定延迟固定窗口内随机
单次碰撞后耗时~1150ms~550ms(均)
两次碰撞后耗时~2300ms(且必败)~1100ms(极少发生)
最终成功率0%(同步共振)96%
代码改动1行
是否需后端

核心洞察

确定性系统在无协调竞争下必败,随机性是打破对称的唯一武器。

以太网靠它活了几十年,你的三台点餐机也一样。

这个问题的本质不是"延迟不够长",而是"两端等得一样长"。分布式竞争的破解之道从来不是加长等待时间,而是在时间轴上散开。


实现代码

完整改动在两个项目的 src/pages/food/OnlineOrder.js 中,打印逻辑部分:

// ========== 打印机:网络打印机 —— 即连即打,打完即断,失败自动重试 ==========
if (netConfig?.ip) {
  const MAX_NET_RETRIES = 2;
  let netPrinted = false;
  for (let attempt = 0; attempt < MAX_NET_RETRIES; attempt++) {
    try {
      try { await gprinterManager.disconnectNet(); } catch {}
      try { await gprinterManager.disconnect(); } catch {}

      await gprinterManager.connectNet(
        netConfig.ip,
        netConfig.port || 9100,
        netConfig.printerType,
      );
      console.log('[OnlineOrder] 网络打印机已连接:', netConfig.ip);

      await gprinterManager.printImageBase64(uri, 0, 0, labelSize);
      console.log('[OnlineOrder] 网络打印机打印成功');
      netPrinted = true;
      break;
    } catch (e) {
      console.log(`[OnlineOrder] 打印失败 (${attempt + 1}/${MAX_NET_RETRIES}):`, e?.message);
      try { await gprinterManager.disconnectNet(); } catch {}
      if (attempt < MAX_NET_RETRIES - 1) {
        // 随机退避 150-650ms,多台设备自然错开连接时机,降低 TCP 端口冲突概率
        const backoff = 150 + Math.random() * 500;
        console.log(`[OnlineOrder] 随机退避 ${Math.round(backoff)}ms 后重试`);
        await new Promise(resolve => setTimeout(resolve, backoff));
      }
    }
  }

  if (!netPrinted) {
    Alert.alert('打印失败', '后厨打印机连接失败,请联系前台服务员通知后厨,以免错过用餐');
  }

  try { await gprinterManager.disconnectNet(); } catch (e) {}
}