如何 mock _changes feed 以测试同步逻辑?
解读
在国内一线互联网与金融科技公司的面试中,“_changes feed 的 mock” 是验证候选人是否真正落地过 CouchDB 离线同步、断点续传与冲突解决的关键题。
面试官想听到的不只是“用 Nock 拦截 HTTP”,而是:
- 你是否理解 seq 有序、心跳、longpoll/continuous 三种模式 的差异;
- 能否在 Node.js 单测环境 里稳定复现“网络抖动→重连→断点续传”全过程;
- 是否能把 last_seq 的持久化、retry 退避、变更顺序校验 一并纳入断言。
回答若只停留在“返回假 JSON”,会被直接判定为“没写过生产级同步”。
知识点
-
CouchDB _changes 语义
- 三种 feed 类型:normal、longpoll、continuous
- 返回结构:{results:[{seq:…,id:…,changes:[…],doc:…}], last_seq:…}
- 心跳:continuous 模式下每 60 s 发送空行维持 TCP
-
last_seq 的因果序
seq 可以是整数或 uuid:integer 复合形式(集群版),mock 时必须保证 单调递增且可持久化,否则下游“断点续传”逻辑会误判为数据丢失。 -
国内主流单测技术栈
- Nock(HTTP 拦截)+ Jest(断言)
- Sinon(计时器/重试退避伪造)
- TDD 三阶段:正常流 → 异常重试 → 乱序/重复变更校验
-
网络异常场景
- TCP 半开:返回 200 后 30 s 无心跳
- chunked 中断:连续模式只发一半 JSON
- 412 回退:last_seq 对应的节点已压缩,需返回 {error:"unknown_seq",restart:true}
-
性能与稳定性
- 一次单测需 >500 条变更+3 次重连 在 2 s 内跑完,内存占用 <100 MB,避免泄漏。
答案
以下给出可直接落地到 GitLab CI(国内镜像源) 的完整方案,覆盖 continuous feed + 断点续传 + 乱序校验 三大维度。
- 搭建 mock 骨架
// test/doubles/couchdb-changes.mock.js
import nock from 'nock';
import { EventEmitter } from 'events';
export class ChangesFeedMock extends EventEmitter {
constructor(baseURL, dbName) {
super();
this.base = baseURL;
this.db = dbName;
this.seq = 0; // 内部单调计数器
this.pending = []; // 待下发变更
this.scope = nock(baseURL)
.persist()
.get(uri => uri.includes('/_changes'))
.query(true)
.reply(200, () => this._handler(), {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked'
});
}
_handler() {
const stream = new (require('stream').PassThrough)();
const heartbeat = setInterval(() => stream.write('\n'), 60000);
this.on('enqueue', doc => {
const change = {
seq: ++this.seq,
id: doc._id,
changes: [{ rev: doc._rev }],
doc
};
stream.write(JSON.stringify(change) + '\n');
});
this.on('close', () => {
clearInterval(heartbeat);
stream.end();
});
return stream;
}
push(doc) { this.emit('enqueue', doc); }
close() { this.emit('close'); }
}
- 在单测里注入“网络抖动”
// test/sync.spec.js
import { ChangesFeedMock } from './doubles/couchdb-changes.mock';
import SyncAgent from '../src/sync-agent';
let mock, agent;
beforeEach(() => {
mock = new ChangesFeedMock('http://couch.internal', 'shop');
agent = new SyncAgent('http://couch.internal/shop');
});
afterEach(() => {
mock.close();
nock.cleanAll();
});
test('断点续传:TCP 中断后从 last_seq 重启', async () => {
// 1. 先灌 100 条
for (let i = 0; i < 100; i++) {
mock.push({_id:`doc${i}`,_rev:`1-abc${i}`});
}
// 2. 启动同步
const syncPromise = agent.start({batchLimit:50});
await agent.untilSeq(50); // 等待前 50 条落库
// 3. 模拟网络掉线:直接销毁 socket
mock.scope.socketDelay(10).socketDestroy();
// 4. 重连后灌剩余 50 条
mock.push({_id:'doc100',_rev:'1-abc100'}); // 确保 seq 继续递增
await agent.untilSeq(101);
expect(agent.persistedSeq).toBe(101);
});
- 关键断言
- seq 无跳跃:
expect(agent.persistedSeq).toBeGreaterThanOrEqual(lastSeq); - 幂等写入:重复下发同 seq 时 DB 层 _rev 不变
- 退避策略:用 Sinon 伪造
setTimeout,验证 重试间隔 1 s→2 s→4 s
- CI 集成要点
- 在
.gitlab-ci.yml中把 couchdb:3.3 镜像 作为 service,但单测阶段 完全禁用真实 CouchDB,确保 mock 可离线运行,提升国内 Runner 拉取速度。 - 加入 --runInBand 防止 Jest 并发导致端口抢占。
拓展思考
-
多主冲突场景
在 mock 中主动下发 同一 id、不同 rev 树 的变更,验证同步逻辑能否正确触发 _revs_diff + _bulk_docs 二次协商,并统计 冲突解决耗时 <200 ms。 -
浏览器 PWA 环境
用 ServiceWorker + ReadableStream 模拟 chunked feed,检验 IndexedDB 批量写入 是否阻塞 UI;同时利用 Chrome DevTools 的 “Offline” 按钮 做半开连接测试,比 Node 层更接近国内移动端弱网。 -
混沌工程
把 mock 接入 阿里云 PTS 或 腾讯云 Chaos,随机注入 200 ms~5 s 延迟、5 % 丢包、偶发 412/502,跑 12 h 长压,观察 last_seq 是否出现 回退或跳号,从而量化同步链路 RPO <1 min 的 SLA 是否达标。