第二次运行 SpreadJS 单文档 1000 人压测时,客户端出现 502。排查发现 4 个协同节点中有 3 个进程退出,而压测代理仍将新请求发往这些失效端口。
这套 demo 有两类存储:OT 存储负责协同文档与操作历史,应用业务库保存用户、文档附加信息和角色。本次故障的锁错误来自多进程共享的 SQLite 业务库——我们沿节点退出日志逐层排查,分别处理了写入冲突和错误传播两层问题。
从锁错误到进程退出
节点日志包含以下信息,下面摘录了错误文本,省略调用栈:
SQLITE_BUSY: database is locked
UnhandledPromiseRejection: ... internal error: insert doc error
Node.js v24.13.0
把数据库错误和进程退出记录对应起来后,故障路径是:多进程争用同一 SQLite 文件,等待写锁超时,业务写入返回错误;调用路径没有处理 Promise rejection,Node.js 进程退出;入口继续转发请求,客户端收到 502。
SQLite 的 WAL 模式可以改善读写并发,但不会让多个进程同时持有写锁。本次此前处理过启动时的 PRAGMA 竞争,运行期业务写入仍缺少完整的锁错误处理。
记录能够确认“第二次运行时暴露”,但没有足够的同条件日志说明为什么第一次没有触发,也没有给出三个节点依次退出的精确时间差。我们不据此判断是数据量增长、残留连接还是运行时长导致第二次更容易失败;要回答这一点,需要在固定负载和干净环境下重放。

图示:故障链与迁移方向。SQLite 是该 demo 的业务库,迁移 PostgreSQL 为本项目选择,非通用要求。
为什么入口没有摘除失效节点
本次故障使用的是压测目录中的最小 Node HTTP/WebSocket 代理,不是前面部署实验里的 nginx。
我们核对代理代码后看到,它使用静态 upstream 列表,分别轮询 HTTP 和 WebSocket 新连接。HTTP 上游连接失败时返回 502,WebSocket 上游失败时关闭连接;代码中没有周期健康探测,也没有在失败后将节点移出后续轮询。
因此,本次现象可以解释为代理继续选择失效端口,不应概括为“负载均衡器都会这样”。生产入口需要另外设计健康检查、失败摘除和重新加入。它们减少新请求进入失效节点,已有长连接仍需要客户端重连。
SQLite 写入路径具体改了什么
我们先保留 SQLite,补上有界重试和幂等,再将业务表迁移到 PostgreSQL。以下是核对当前源码得到的设置:
| 修改项 | 本 demo 的处理 |
|---|---|
| SQLite 锁等待 | busyTimeout 从 10 秒调至 30 秒 |
| 可重试错误 | 仅 SQLITE_BUSY、SQLITE_LOCKED |
| 重试次数 | 初次执行失败后,最多再重试 25 次 |
| 应用层等待 | 50 ms、100 ms、150 ms……按 50 ms 递增,公式上限 1500 ms |
| 可重复写入 | 角色写入使用 upsert;附加操作记录冲突时跳过重复项 |
对应的重试核心如下,已省略数据库封装:
for (let n = 0; ; n++) {
try {
return await db.run(sql, params);
} catch (e) {
if (!isBusyError(e) || n >= 25) throw e;
await sleep(Math.min(50 * (n + 1), 1500));
}
}
按这组参数,第 25 次等待为 1250 ms,尚未达到公式中的 1500 ms 上限。修复报告曾将其称为“指数退避”,但当前实现是线性递增,这里以源码为准。
30 秒是每次数据库调用内部的锁等待,不能理解为整个请求最多等待 30 秒。叠加重试后,总等待可能更长。我们将这些参数作为该 demo 的修复记录;实际业务还应设置请求总截止时间,并按幂等条件、并发量和用户可接受延迟重新设计。
哪些写入允许降级,哪些不能套用
当前中间件对两项 demo 附加写入捕获错误并记录 warn:操作附加记录 insertOpEx,以及最后修改信息 updateLastModifyInfo。失败后继续调用后续处理,使这类附加记录失败不再直接中断协同提交。
代价是附加操作记录可能不完整,最后修改人和时间也可能未更新。它们不能再作为完整审计依据。如果业务要求这些信息与文档提交原子一致,就不能照搬这种降级方式,需采用可靠记录与补偿机制。
回归代码还加入了进程级异常捕获后继续运行的保护。这是当时的实验措施。生产环境发生未处理异常后,进程状态可能不再可靠,我们更倾向于记录诊断、摘除实例,再由进程管理器重启,而非仅靠全局捕获长期维持运行。
两轮回归分别验证了什么
先修复 SQLite 写入,再把业务表迁到 PostgreSQL 的独立 app schema。两轮均为同机 1000 人、4 节点、每人每 30 秒编辑一次,逐步加入 30 秒,稳态 90 秒。
| 指标 | SQLite 修复后 | 业务库迁移 PostgreSQL 后 |
|---|---|---|
| 实际加入用户 | 1000 | 1000 |
| 编辑 ACK / 失败 | 2998 / 0 | 3001 / 0 |
| 实际编辑速率 | 33.3 op/s | 33.3 op/s |
| 编辑 ACK p95 | 345.9 ms | 419 ms |
| 最终内容校验 | 通过 | 通过 |
| 4 个节点是否全程存活 | 是 | 是 |
ACK 是服务端的提交确认,p95 表示约 95% 的记录不超过该延迟。修复后的 SQLite 回归中,没有新增 SQLITE_BUSY、未处理 rejection 或全局异常保护触发记录。迁移后的回归也保持节点存活、内容校验通过。
这两轮说明在所测窗口内没有再出现该故障,不能替代长期稳定性验证。它们与最终分机有线性能测试也不是同一条件,不应用来宣称迁移 PostgreSQL 后延迟必然提高或降低。
这次排查涉及业务库、调用路径和代理三层问题。其他采用多进程共享本地数据库的项目,建议依次检查写锁处理、失败后的错误传播,以及节点退出后的入口摘除机制。换数据库能改变并发写入方式,但重试边界、写入幂等性和健康检查仍需单独设计。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/powertoolsteam/article/details/170614517




