如何 mock _changes feed 以测试同步逻辑?

解读

在国内一线互联网与金融科技公司的面试中,“_changes feed 的 mock” 是验证候选人是否真正落地过 CouchDB 离线同步、断点续传与冲突解决的关键题。
面试官想听到的不只是“用 Nock 拦截 HTTP”,而是:

  1. 你是否理解 seq 有序、心跳、longpoll/continuous 三种模式 的差异;
  2. 能否在 Node.js 单测环境 里稳定复现“网络抖动→重连→断点续传”全过程;
  3. 是否能把 last_seq 的持久化、retry 退避、变更顺序校验 一并纳入断言。
    回答若只停留在“返回假 JSON”,会被直接判定为“没写过生产级同步”。

知识点

  1. CouchDB _changes 语义

    • 三种 feed 类型:normal、longpoll、continuous
    • 返回结构:{results:[{seq:…,id:…,changes:[…],doc:…}], last_seq:…}
    • 心跳:continuous 模式下每 60 s 发送空行维持 TCP
  2. last_seq 的因果序
    seq 可以是整数或 uuid:integer 复合形式(集群版),mock 时必须保证 单调递增且可持久化,否则下游“断点续传”逻辑会误判为数据丢失。

  3. 国内主流单测技术栈

    • Nock(HTTP 拦截)+ Jest(断言)
    • Sinon(计时器/重试退避伪造)
    • TDD 三阶段:正常流 → 异常重试 → 乱序/重复变更校验
  4. 网络异常场景

    • TCP 半开:返回 200 后 30 s 无心跳
    • chunked 中断:连续模式只发一半 JSON
    • 412 回退:last_seq 对应的节点已压缩,需返回 {error:"unknown_seq",restart:true}
  5. 性能与稳定性

    • 一次单测需 >500 条变更+3 次重连 在 2 s 内跑完,内存占用 <100 MB,避免泄漏。

答案

以下给出可直接落地到 GitLab CI(国内镜像源) 的完整方案,覆盖 continuous feed + 断点续传 + 乱序校验 三大维度。

  1. 搭建 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'); }
}
  1. 在单测里注入“网络抖动”
// 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);
});
  1. 关键断言
  • seq 无跳跃expect(agent.persistedSeq).toBeGreaterThanOrEqual(lastSeq);
  • 幂等写入:重复下发同 seq 时 DB 层 _rev 不变
  • 退避策略:用 Sinon 伪造 setTimeout,验证 重试间隔 1 s→2 s→4 s
  1. CI 集成要点
  • .gitlab-ci.yml 中把 couchdb:3.3 镜像 作为 service,但单测阶段 完全禁用真实 CouchDB,确保 mock 可离线运行,提升国内 Runner 拉取速度
  • 加入 --runInBand 防止 Jest 并发导致端口抢占。

拓展思考

  1. 多主冲突场景
    在 mock 中主动下发 同一 id、不同 rev 树 的变更,验证同步逻辑能否正确触发 _revs_diff + _bulk_docs 二次协商,并统计 冲突解决耗时 <200 ms

  2. 浏览器 PWA 环境
    ServiceWorker + ReadableStream 模拟 chunked feed,检验 IndexedDB 批量写入 是否阻塞 UI;同时利用 Chrome DevTools 的 “Offline” 按钮 做半开连接测试,比 Node 层更接近国内移动端弱网。

  3. 混沌工程
    把 mock 接入 阿里云 PTS腾讯云 Chaos,随机注入 200 ms~5 s 延迟、5 % 丢包、偶发 412/502,跑 12 h 长压,观察 last_seq 是否出现 回退或跳号,从而量化同步链路 RPO <1 min 的 SLA 是否达标。