#WorkBuddy# 双端同步为什么总是“偶发”出错?PC(C#) ↔ 安卓(Java) 数据链路的 5 处硬伤与修复
## 一、"偶发"是个危险的错觉 一个 PC 端(C# WinForms)与安卓端(Java 原生)通过局域网直连做数据同步的功能,上线后的故障报告长这样: - "偶尔连不上,多等一会儿就好了" - "偶尔数据对不上,重发一次又好了" - "偶尔文件是空的 / 坏的一半" - "偶尔删了单,Excel 里还有这行" 共同点是都带"偶尔"。但把这几类问题全部查完之后,我的结论是:**这些故障没有一个是偶发的,全部是确定性缺陷,只是触发条件不常见**(时机、并发、文件大小、网络抖动、操作顺序)。 这篇文章拆 5 处硬伤,每一处都有明确的触发条件和确定性修法。涉及的两端代码是 C# 和 Java,但问题本身和语言无关——任何做"两个端之间搬运数据"的系统都会遇到同一批坑。 ## 二、硬伤 1:C# 和 Java 的加密"看起来一样,实际对不上" 这是最典型的一类。两端各自实现加密解密,单端自测全过,一连起来就解不开。原因是**两端库函数的默认行为不一样**,而所有默认值差异在单端测试里都不可见。 要对齐,必须把下面每一项**显式写死**,不允许任何一端依赖默认值。以一套对称加密信封为例: | 环节 | 必须显式约定的内容 | 常见的默认值陷阱 | |---|---|---| | 字符串 → 字节 | 统一 UTF-8,且**不带 BOM** | .NET 的 `Encoding.UTF8` 与文件读写是否写 BOM 不一致;Java 侧 `String.getBytes()` 不带字符集时吃平台默认编码 | | 密钥派生 | 算法、迭代次数、盐、输出长度 | 盐里拼了设备 id,两端拼法或大小写不一致 → 派生出不同密钥 | | 分组模式与填充 | AES-256-CBC + PKCS7 | **Java 里写的是 `AES/CBC/PKCS5Padding`**——名字叫 PKCS5,但对 16 字节块的 AES 而言实际按 PKCS7 语义处理,这不是 bug,是历史命名;不要用 `"AES"` 裸算法(会退化成 ECB 或平台默认模式) | | IV | 16 字节随机,**每次加密新生成,随文传输,绝不复用** | 有人图省事把 IV 写死成常量,等于把 CBC 降级成 ECB,相同明文出相同密文 | | 完整性校验 | HMAC-SHA256,签的内容、字段顺序、拼接分隔符 | 签"密文"还是签"IV+密文"、分隔符是 `\|` 还是拼接无分隔,两端差一个字符就永远验不过 | | Base64 | **标准字母表、不换行** | Java 的 `Base64.getMimeEncoder()` 会按 76 字符插入换行,`getEncoder()` 不会;.NET 的 `Convert.ToBase64String` 不换行。一端用 MIME 编码器,另一端解出来就是坏的 | | 信封结构 | JSON 字段与顺序固定 | 字段顺序不固定的话,HMAC 签的字符串就对不上 | 最终落地的契约长这样(可直接抄): ```text salt = "OrderSync|v1|" + pcId key = PBKDF2WithHmacSHA1(password, salt, 20000 iters, 256 bit) enc = AES-256-CBC + PKCS7Padding, IV = 16 字节随机, 随文传输 mac = HMAC-SHA256(ivBase64 + "|" + ciphertextBase64) base64 = 标准编码,不换行 envelope = {"type":"secure","v":"v1","iv":...,"ct":...,"mac":...} ``` 两个容易被忽略的正确性问题: 1. **顺序必须是"先验签,后解密"**。先解密再验签,等于把未认证的密文喂给解密器,是 padding oracle 的经典土壤。正确流程:拿到信封 → 用约定格式重算 HMAC → 常量时间比较 → 通过后才解密。 2. **比较 MAC 用常量时间比较**,不要用字符串 `==` 或 `String.Equals` 短路比较,避免时序侧信道。 最后一条工程纪律:**这套契约必须配一组固定的测试向量**(同一组明文/密码/盐/IV 的期望密文与 MAC),两端各自跑一遍。任何一端改了实现,测试立刻红。否则"改了一端忘了另一端"是迟早的事。 ## 三、硬伤 2:文件落盘不是原子的 症状:偶尔出现**空文件**或**内容只有一半**的配置文件、数据文件。 根因很直接:写文件用的是"打开目标文件 → 逐段写入 → 关闭"。在这个过程中的任何时刻进程被杀、断电、异常抛出,磁盘上留下的就是**半截文件**。而读的一方不会知道这个文件是坏的,它只会读到一个能打开、但内容残缺的文件——这种故障最难查,因为它不报错。 正确做法:**原子写**——先写临时文件,落盘(fsync),再原子替换目标文件。 ```java // 安卓端(minSdk 26+ 才有 java.nio.file.Files) Files.write(tmp, bytes); // 写临时文件 try (FileChannel ch = FileChannel.open(tmp, WRITE)) { ch.force(true); } // fsync Files.move(tmp, target, ATOMIC_MOVE, REPLACE_EXISTING); // 原子替换 // 不支持 ATOMIC_MOVE 的文件系统,退化为普通替换(并记日志) ``` ```csharp // PC 端 File.WriteAllBytes(tmp, bytes); File.Replace(tmp, target, backupPath); // 原子替换,可选留备份 // 或用 File.Move(tmp, target, overwrite: true) 作为兜底 ``` 三条纪律: 1. **禁止 `new FileOutputStream(target)` 直接覆盖写目标文件**。这是半截文件的主要来源。 2. **小文件也要走原子写**。文件小只是降低了触发概率,没消除故障。 3. **临时文件要清理**:接收大文件时边收边写 `.recv` 临时文件,收齐才替换;断线、页面销毁、新一轮开始这几个时机都必须清掉残留临时文件。 ## 四、硬伤 3:大文件整份读进内存 症状:同步照片/附件时,小图正常,**大图直接 OOM 崩溃**;不崩的时候速度也慢得离谱。 两个独立的问题: **问题一:整份读进内存。** 一张十几 MB 的照片,按 `byte[]` 全读,几张就爆。 **问题二:O(n²) 的 I/O。** 这是更隐蔽的性能杀手——如果要分块传输,每取第 k 块都从头 `skip` 一遍文件,那么传 N 块就要读 N 遍文件,总 I/O 是 O(n²)。块数越多越慢,而且这种慢在测试时用小图根本发现不了。 正确做法:**打开一次文件描述符,按块定位读取**。 ```java // 1) 先拿大小:优先用 stat,失败才退回流式计数 long size = ParcelFileDescriptor.getStatSize(); // O(1) // 2) 按块读:开 fd → skip 定位 → 只读这一块 ParcelFileDescriptor pfd = resolver.openFileDescriptor(uri, "r"); FileInputStream in = new FileInputStream(pfd.getFileDescriptor()); in.skip(chunkIndex * chunkSize); in.read(buf, 0, Math.min(chunkSize, remaining)); ``` 协议侧配套一个约定:**`chunkIndex = -1` 的请求只返回元信息**(`success: true` + `totalChunks`),不返回数据。这样接收方可以先用一次轻量请求拿到总块数,再按序取块,还能做断点续传。 ## 五、硬伤 4:连接没有自愈能力 症状:"偶尔连不上,等一会儿又好了"——通常是长连接 socket 在对方断开、网络切换、系统休眠后进入了不可用状态,而代码没有检测和恢复机制。 四件事必须做: **1. 空闲期也要保活。** 安卓端在"等待 PC 连接"阶段就要 `startForeground` + 持 WiFi 锁。否则服务退回后台,系统休眠时进程被回收,PC 端永远连不回来。Android 8+ 需要 `FOREGROUND_SERVICE_DATA_SYNC` 权限,并在 manifest 里声明 `android:foregroundServiceType="dataSync"`。断开时只释放屏幕锁、切回"等待连接"通知,不要停前台服务。 **2. `SO_REUSEADDR` 必须在 `bind()` 之前设置。** 顺序写反(先 bind 再 setReuseAddress)这个选项就完全不生效,表现为"重启服务后端口占用,要等一两分钟才能重新监听"。 ```java ServerSocket ss = new ServerSocket(); ss.setReuseAddress(true); // 必须在 bind 前 ss.bind(new InetSocketAddress(port)); ``` **3. `accept()` / `receive()` 失败后必须 sleep。** 这是很多人写错的地方:socket 坏了以后 `accept()` 会立刻返回失败,如果没有 sleep,就变成一个烧满一个 CPU 核心的死循环,同时把日志文件刷爆,还会让系统更慢、更难恢复。 **4. 连续失败 N 次就重建套接字 + 指数退避。** 不要无限重试同一个坏 socket: ```text 失败 → sleep(backoff) → 重试 连续失败 N 次 → 关闭 socket → 销毁重建 → backoff 翻倍(设上限) 成功一次 → backoff 复位 ``` ## 六、硬伤 5:副作用顺序反了 这一处不产生崩溃,但产生**不可逆的数据损失**,是最该修的一处。 原始流程是: ```text 删除订单 → 删除磁盘上的订单文件夹 → 保存 Excel ``` 问题出在"保存 Excel"这一步**可能失败**(文件被占用、权限等)。于是最坏情况变成: > 文件夹已经删了,但 Excel 里那行还在 → 下次同步/加载时,程序看到一个"数据还在、文件没了"的订单,且**无法恢复**(文件夹不可逆)。 把顺序反过来: ```text 删除订单 → 保存 Excel → 成功后 → 删除磁盘文件夹 ``` 最坏情况变成: > Excel 行删了,但磁盘上残留一个空文件夹 → **良性**,清理即可。 **两种顺序的最坏后果不对称**,这就是必须排序的理由:让失败停在可恢复的一侧。 配套的三条实现纪律: 1. **保存结果必须是三态,不是布尔**:`Saved`(已写入)/ `Deferred`(目标被占用,已落盘缓存,稍后重试)/ `Failed`(未保存,且不会自愈)。调用方必须区分这三种,不能把 `Deferred` 当失败处理,也不能当成功处理。 2. **`Deferred` 时不要重新加载数据**。此时文件里还是旧内容,一刷新就把刚删掉的行又读回来了,等于操作被静默回滚。 3. **回滚要用原位插入,不要用 Upsert**。回滚数据行用 `Rows.InsertAt(dataRow, 原下标)`;`Upsert` 会重新分配主键(OrderId),导致关联数据对不上。 顺带一条同类原则:本项目里所有"删除"都不该直接 `Directory.Delete`,而应该先 `Move` 到 `.trash//`,确认后续步骤全部成功后再清理,任一步失败就移回。 ## 七、把"偶发"翻译成条件 回过头看,这五处硬伤没有一处是真正的随机故障。把每一条"偶发"翻译成触发条件,是排查这类问题最有效的动作: | 报告里的说法 | 真实的触发条件 | |---|---| | 偶尔连不上 | socket 无自愈 + 服务未保活,休眠后进程被回收 | | 偶尔数据对不上 | 两端加密默认值不一致(Base64 换行 / 填充名 / 编码) | | 偶尔文件是空的 | 非原子写,进程在写入中途被杀 | | 偶尔大图崩 | 整份读进内存(小图测不出来) | | 偶尔删了还在 | 副作用顺序:先删文件,后保存,保存失败且不可逆 | 排查"偶发"问题的通用动作是**给它找条件**:并发、时序、资源上限(内存/句柄/磁盘)、操作顺序。这四样里几乎总能找到确定性的那一根刺。 第二条经验是**契约先行**:两端协作的东西(加密格式、块协议、字段含义)先定死契约和测试向量,再各自实现。契约写在代码里、测试里,而不是写在某个人的记忆里——因为"改了一端忘了另一端"这件事,迟早会发生。 ## 小结 - 加密跨语言对齐:**逐项写死,不依赖任何默认值**,先验签后解密,配固定测试向量; - 文件落盘:**临时文件 → fsync → 原子替换**,禁止直接覆盖写; - 大文件:**一次开 fd,按块定位读**,先取元信息再取块; - 连接:**保活 + 失败必 sleep + 连续失败重建 + 指数退避**,`SO_REUSEADDR` 在 bind 前; - 副作用:**先保存再删**,让失败停在可恢复的一侧,保存结果用三态。 这五条都不难,难的是在"功能已经能跑"的时候,还愿意回头把这些不确定性一个一个钉死。