深蓝黑背景封面:左侧大标题「读百万 QPS、写千级的配置服务」,副标题「存储、下发与容灾,读极多写极少时怎么取舍」;右侧原创示意图——一根细细的紫色写入管道注入配置仓库,仓库向右扇出一大片青色读流量箭头,落进层层缓存;读箭头的密度与写管道形成强烈反差;左上标注「面试经典 · 系统设计」,右上标注「Day 06 · 11-03」 图 1:本文封面,写是一根管,读是一片扇。

先说明属性:这是一篇历史题目复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面:业务方要存服务参数、功能开关这类键值,配置文件逐机同步的老办法越来越难管,要求设计一个统一配置服务。约束给得很具体:总存储量 1GB 以内、每秒写入千级、每秒读取百万级以上、用尽量少的节点、任何时刻可读写、允许故障期间读到旧值、恢复后数据保持同步。

先把约束翻译成设计语言。

逐条翻译就能看见这道题的骨架。1GB 内存装得下——存储不需要分片,单机全量加副本即可。读百万写千级——读路径必须和写路径彻底解耦,否则读的规模会拖着写一起扩容。「允许故障期读旧值」是最值钱的一条:它把取舍直接给了你——不要强一致,要可用性,最终一致即可。「恢复后保持同步」则要求有版本概念,落后的副本和客户端能补齐错过的变更。

四层结构:存储、下发、读取、容灾。

存储层:配置落库,每条带版本号(或全局单调递增的版本序列),写入只走管理后台——审批、审计、灰度都挂在管理面上。写 QPS 千级对任何单机存储都是闲庭信步,写侧不需要分布式花哨设计,主备复制保可用就够。

下发层:这是设计的核心。客户端带着本地版本号对服务端做长轮询:版本没变,请求挂起等待(不产生读流量);版本一变,立刻返回差异或全量快照。服务端因此只在「有变更」的瞬间才有真实流量。推送(服务端主动发)是等价选项,长轮询胜在客户端实现简单、穿透性好。

读取层:百万 QPS 的真正答案在客户端——业务代码读的是本地内存快照,压根不打到服务端。配置中心面对的「读」只是版本比对,量极小;接入层再加一层缓存做第二道保险。中心服务的负载与客户端数量线性相关、与业务读 QPS 解耦。

容灾层:每一层都允许读旧值。服务端整体挂了,客户端用本地快照继续跑(重启也能从磁盘快照冷启动);长轮询断了,退化为定时兜底轮询;存储主节点挂了备节点顶上,期间写入可暂停——写暂停几分钟对配置业务通常无感,读全程不断。

用代码验证「读旧值不中断、恢复后同步」。

下面这段模拟实现版本化存储、长轮询与本地快照,注入服务端故障验证容灾语义,用 Node.js 22.15.0 直接运行(node configcenter.js):

'use strict';

// 统一配置服务核心语义:版本化存储 + 长轮询下发 + 本地快照兜底
class ConfigStore { // 存储层:版本号 + 全量数据,单机内存放得下(题设 <= 1GB)
  constructor() { this.version = 0; this.data = new Map(); }
  write(key, value) { // 写走管理面,每次写入版本 +1
    this.data.set(key, value);
    this.version++;
    return this.version;
  }
  readSince(v) { return v < this.version ? { version: this.version, data: Object.fromEntries(this.data) } : null; }
}

class ConfigClient { // 客户端:长轮询 + 本地快照
  constructor(store, { serverAlive = true } = {}) {
    this.store = store;
    this.serverAlive = serverAlive;
    this.localVersion = 0;
    this.snapshot = new Map(); // 落盘的本地快照,最后一道兜底
  }
  longPoll() { // 带本地版本号长轮询:有变更立刻返回,没变更挂起等待
    if (!this.serverAlive) return null; // 服务端故障:本轮拿不到
    return this.store.readSince(this.localVersion);
  }
  tick() {
    const res = this.longPoll();
    if (res) {
      this.localVersion = res.version;
      this.snapshot = new Map(Object.entries(res.data)); // 刷新本地快照
      return `拉到新版本 v${res.version},快照已更新`;
    }
    return null;
  }
  get(key) { // 业务读取永远走本地快照:读 QPS 与中心解耦
    return this.snapshot.get(key);
  }
}

const store = new ConfigStore();
const c1 = new ConfigClient(store);
const c2 = new ConfigClient(store);

store.write('feature.inference.v2', 'on');
console.log(`c1 首次同步:${c1.tick()}`);
console.log(`c2 首次同步:${c2.tick()}`);
console.log(`c2 无变更再长轮询:${c2.tick() === null ? 'null(挂起等待,不占读流量)' : '有变更'}`);

store.write('limit.qps', '5000'); // 管理面再次写入
console.log(`管理面写入后 c2 再拉:${c2.tick()}`);
console.log(`两个客户端读到的限流阈值:c1=${c1.get('limit.qps') ?? '尚未同步'},c2=${c2.get('limit.qps')}`);

// 故障注入:服务端整体不可用
c1.serverAlive = false; c2.serverAlive = false;
console.log(`服务端故障后 c1 拉取结果:${c1.tick()}`);
console.log(`故障期业务读配置:feature.inference.v2=${c1.get('feature.inference.v2')}(读旧快照,服务不中断)`);

// 故障恢复:版本比对补齐错过的变更
c1.serverAlive = true;
store.write('feature.inference.v2', 'off');
console.log(`服务端恢复并继续写入后:${c1.tick()}`);
console.log(`恢复后 c1 读到最新值:feature.inference.v2=${c1.get('feature.inference.v2')}`);

实测输出:

c1 首次同步:拉到新版本 v1,快照已更新
c2 首次同步:拉到新版本 v1,快照已更新
c2 无变更再长轮询:null(挂起等待,不占读流量)
管理面写入后 c2 再拉:拉到新版本 v2,快照已更新
两个客户端读到的限流阈值:c1=尚未同步,c2=5000
服务端故障后 c1 拉取结果:null
故障期业务读配置:feature.inference.v2=on(读旧快照,服务不中断)
服务端恢复并继续写入后:拉到新版本 v3,快照已更新
恢复后 c1 读到最新值:feature.inference.v2=off

注意中间那行「c1=尚未同步」:同一时刻两个客户端读到不同版本——这正是「允许读旧值」约束买来的可用性。故障注入后业务读照常返回 on,恢复后靠版本比对自动补齐到 v3,题面六条约束逐条对上。

分层架构图:自上而下四层。存储层:配置落库带版本号与审计,写走管理后台加审批流;下发层:客户端长轮询或服务端推送,带版本比对只传变更;读取层:接入层缓存扛住百万 QPS,客户端本地快照做最后一道兜底;容灾层:存储挂了读旧快照继续服务,推送挂了靠兜底轮询,每一层都允许短暂读旧值。右侧标注各环节的关键取舍:为什么读可以无限扩、为什么写必须走单点审批。底部提示:这类系统的核心思想是把「读到旧值」当常态设计,把「读到错值」当事故防范 图 2:四层各管一段,「读旧值」是贯穿全局的设计前提。

可能的追问。

「变更要在期望时间内下发怎么办?」长轮询挂起的请求在版本变化时立刻被唤醒返回,秒级可达;再快就加推送通道,长轮询兜底。「为什么写必须走管理面单点?」写千级 QPS 没有规模压力,而审批、审计、灰度都需要集中的控制点——典型的「读无限扩、写收拢」结构。「本地快照会不会读到错的值?」会读到旧值,但不会读到错值:版本号单调递增保证同步单向向前。一句话概括:把「读到旧值」当常态设计,把「读到错值」当事故防范。

模型路由表、特征开关、限流阈值——AI 平台的配置面就是这道题的考场。回到复盘现场,建议的答题节奏:先把六条约束翻译成设计前提,再给四层结构并讲清取舍,最后主动讲容灾矩阵。你维护过配置下发链路吗?「读旧值」这个约束在你们系统里怎么落地的?欢迎交流。