给出 Node.js 使用 axios 发送 5 k 文档批量写入的代码并处理 413 错误。

解读

国内线上环境常把 Nginx/Caddy 放在 CouchDB 前面,默认 client_max_body_size 仅 1 MB,5 k 文档 × 单条 2 kB 就能触发 413 Payload Too Large。面试想考察:

  1. 是否知道 CouchDB 的 /_bulk_docs 接口及 new_edits=false 语义
  2. 能否把超大 payload 拆分为可配置批次并在 413 时自动降容
  3. 是否熟悉 axios 的 validateStatusretry 策略
  4. 是否兼顾 内存流控(避免一次性读 5 k 文档进内存)与 并发安全(防止同一条文档并发写冲突)

知识点

  • CouchDB 批量写入:POST /{db}/_bulk_docs,请求体 {"docs":[...]},返回数组带 id/rev/error
  • 413 触发链:客户端 → 反向代理 → CouchDB httpd/max_http_request_size(64 MB 默认)
  • axios 错误拦截:error.response.status === 413
  • 批次策略:二进制搜索法(batch/=2)比固定折半更快收敛到可接受体积
  • 并发控制:p-limit 或 p-queue 做 maxConcurrency 限流,防止瞬间打满文件描述符
  • 重试退避:结合 Exponential Back-off 与 jitter,避免惊群
  • 事务补偿:对 413 拆分后需 幂等写入(带 _rev)或 new_edits=false 去重
  • 监控埋点:记录 实际写入批次大小、耗时、重试次数,方便后续调优

答案

// bulk5k.js
const axios = require('axios').default;
const pLimit = require('p-limit');
const { Readable } = require('stream');

const DB = 'http://couchdb:5984/products';
const AUTH = { username: 'admin', password: 'admin' };
const MAX_RETRIES = 4;
const CONCURRENCY = 6;

// 模拟 5 k 条文档的可读流,避免一次性占内存
function* docGenerator() {
  for (let i = 0; i < 5000; i++) {
    yield {
      _id: `item_${i}`,
      type: 'product',
      name: `商品${i}`,
      price: Math.floor(Math.random() * 1000),
      // 故意把单条体积撑到 ~2 kB
      desc: 'x'.repeat(1800)
    };
  }
}

// 把流切成指定大小的批次
async function* batchStream(iter, size) {
  let batch = [];
  for (const doc of iter) {
    batch.push(doc);
    if (batch.length === size) {
      yield batch;
      batch = [];
    }
  }
  if (batch.length) yield batch;
}

// 带 413 退避的写入函数
async function bulkInsert(docs, retry = 0) {
  try {
    const { data } = await axios.post(
      `${DB}/_bulk_docs`,
      { docs },
      {
        auth: AUTH,
        headers: { 'Content-Type': 'application/json' },
        // 把 413 当成可重试状态,不让 axios 抛错
        validateStatus: s => s < 500 || s === 413
      }
    );
    if (data.error === 'too_large' || data.status === 413) throw new Error('413');
    return data;          // 返回 [{id,rev,error},...]
  } catch (e) {
    if (e.message.includes('413') && retry < MAX_RETRIES) {
      const nextSize = Math.ceil(docs.length / 2);
      console.warn(`[413] 降容至 ${nextSize} 条`);
      const limit = pLimit(CONCURRENCY);
      const chunks = Array.from({ length: 2 }, (_, i) =>
        docs.slice(i * nextSize, (i + 1) * nextSize)
      ).filter(Boolean);
      const results = await Promise.all(
        chunks.map(c => limit(() => bulkInsert(c, retry + 1)))
      );
      return results.flat();
    }
    throw e;
  }
}

// 主流程
(async () => {
  const limit = pLimit(CONCURRENCY);
  const gen = docGenerator();
  let ok = 0, fail = 0;

  for await (const batch of batchStream(gen, 800)) {   // 先按 800 条试探
    const res = await bulkInsert(batch);
    res.forEach(r => (r.error ? fail++ : ok++));
  }

  console.log(`写入完成:成功 ${ok},失败 ${fail}`);
})();

运行前务必:

  1. 调大反向代理 client_max_body_size 20m
  2. 确认 CouchDB 本地 [httpd] max_http_request_size = 67108864
  3. 若使用 new_edits=false,需提前准备 _rev 避免冲突

拓展思考

  • 如果 5 k 文档里 单条就超过 4 MB,413 无法通过拆批次解决,需要 GridFS 或附件外链方案
  • 生产环境可改用 couchdb-bulk-follow 官方库,内部已集成 413 退避与流控
  • 对实时性要求高的场景,可把大拆小后写入 Redis 队列,再由消费者异步刷盘,降低前端延迟
  • 面试可继续追问:“如何验证写入一致性?” —— 通过 /{db}/_all_docs?keys=... 批量回查,比对 rev 与数量;或开启 /_ensure_full_commit 做 fsync 落盘确认