卷四 · 系统设计 · 高频面试题
通用软件工程师 / FDE 高频面试题库 · 进阶卷四 题源:牛客网公开面经(
notes/corpus.jsonl,85 份独立面试经历帖)+ 系统设计通用方法论 题目统计:普通问答 22 · 手撕 4 · N 备选 0 · 合计 26 个条目。
难度分布(全卷,含手撕):L1 2 · L2 14 · L3 10
频次口径:
🔥×N= N = 该题在独立面经帖中的命中数(同义题合并后计),检索键写在每题正文的<!-- src: ... -->里,由tools/fill_freq.py机械回填、CI 用--check校验。低频实录·1源= 只有 1 份独立面经支撑;补充= 非面经的整理题,不伪造频次。
使用方式:题目默认折叠,点开看答案。建议按 L1 → L2 → L3 顺序刷;同级内按频次从高到低。手机端原生支持。项目类 / 场景类问题的背景已用自然语言写进题干;「追问」列出常见后续考点。
与其他卷的关系:本卷是把组件拼成一套能扛住量的系统——容量估算、扩展性迁移、稳定性机制、交付与运维。刻意不重复卷 B:卷 B 讲"单个组件怎么实现"(索引怎么建、锁怎么用、MQ 怎么选),本卷讲"整套系统怎么设计、怎么迁、怎么证明扛住了"。两卷的分工见卷末《与卷 B 的分工对照表》。
适合岗位:后端 / 架构 / FDE(客户现场方案设计面)。
01 系统设计方法论(4 题)
L1 🔥×10 Q01 · 系统设计面试怎么答?标准框架是什么?
答:四步框架(先说清楚再动手,不要一上来就画架构图):
- 澄清需求(占 20% 时间):功能与非功能需求(QPS、延迟、数据量、可用性、一致性)。最关键的问题是"多少 QPS、多少数据量"——没有量级就没有设计。
- 容量估算:数量级估算(日活 × 人均请求 → 峰值 QPS = 均值 × 3~10;存储 = 条数 × 单条大小 × 副本数)。
- 接口与数据模型:核心 API 出入参、核心表结构与索引(数据模型往往比架构图更能体现功底)。
- 架构设计 + 取舍:分层、组件、通信方式;每个关键决策说明为什么这么选、代价是什么。
再补:⑤ 容错与降级;⑥ 监控与可观测;⑦ 迭代路径(MVP 是什么、先做什么)。
易错:上来就画图不讲数字——面试官无法判断你的设计是否成立;只讲 happy path 不讲故障;需求没澄清就开始设计。
追问:① 需求不清楚时怎么问?② 估算不确定时怎么表达?③ 面试官说"就按你说的做"时怎么继续?
L1 🔥×12 Q02 · 容量规划怎么做?QPS、数据量、带宽怎么估算?
答:自上而下:
- DAU → 请求量:
总请求 = DAU × 人均请求数/天。 - 平均 QPS = 总请求 / 86400;峰值 QPS ≈ 平均 × 3~10(核心接口更高,活动期更高)。
- 存储:
数据量 = 条数 × 单条大小 × 副本数;按增长曲线外推 1~2 年。 - 带宽:
QPS × 单请求大小(注意出向流量常是入向的数倍)。
自下而上(有历史数据更准):从监控取 P99 QPS,而非平均值。
转成资源:
- 计算:
核数 ≈ QPS × 平均处理耗时(ms) / 1000 ÷ 利用率(利用率取 0.5~0.7,留出突发与故障余量)。 - 存储:算上索引、副本、备份,实际占用通常是原始数据的 2~3 倍。
易错:用平均 QPS 规划容量(峰值才是瓶颈);忘了副本与备份的存储开销;忽略长连接 / 心跳这类"小流量高连接数"的资源——连接数、文件描述符常比 CPU 更早成为瓶颈。
追问:① 突发流量怎么预留?② 容量和成本怎么权衡?③ 扩容阈值定在哪(CPU?队列长度?)?
L2 🔥×2 Q03 · 高并发场景下怎么保护系统?限流、削峰、熔断、降级的顺序是什么?
答:自上而下四层:
- 限流(入口):按 QPS / 并发数 / 用户维度限,超了直接拒绝或排队——目的是别把下游打死。
- 排队削峰:请求进 MQ 异步处理,把瞬时峰值摊平(秒杀场景的标准做法)。
- 熔断(依赖):下游错误率 / 慢调用率超阈值就快速失败,不再拖垮自己。
- 降级(功能):关掉非核心功能、返回兜底数据、返回缓存结果。
顺序很关键:先限流(少进来),再削峰(缓处理),再熔断(别被拖死),最后降级(保体验)。
关键细节:① 限流要在最外层(网关 / LB),不要等到业务层才发现;② 熔断要有半开探测否则永不恢复;③ 降级要预先设计(提前准备兜底方案与开关),不是出事后现写。
易错:把限流做成"直接报错"却没给用户任何提示或降级路径;熔断阈值太敏感(下游抖动一次就全断);只在服务端限流,客户端仍狂刷。
追问:① 限流维度怎么选(全局 / 用户 / 接口 / IP)?② 秒杀还有什么特殊做法?③ 怎么防止限流被绕过?
L2 🔥×2 Q04 · 设计一个高并发秒杀系统,你会怎么做?
答:核心思路:把"读"放大、把"写"收敛。
- 前端:点击后禁用 + 倒计时(服务端仍须自己限)。
- 网关限流:按用户 / IP / 商品维度限,超了直接拒绝。
- 提前预热:商品信息与库存提前加载到本地缓存,避免开卖瞬间打 DB。
- 库存扣减:Redis 原子扣减(Lua 脚本保证 check-and-decr 原子),成功才写 DB。
- 异步下单:MQ 削峰,DB 异步写;订单状态机。
- 一致性:以 Redis 为准,DB 异步落库,用对账任务修正差异。
- 防超卖:Lua 里
if stock > 0 then stock = stock - 1;DB 侧订单唯一约束兜底。 - 防重复下单:用户 ID + 商品 ID 做唯一索引。
易错:用分布式锁串行化扣库存;DB 直接扣减(打爆 DB);只做前端防重。
追问:① Redis 挂了怎么办?② 怎么保证 Redis 与 DB 库存最终一致?③ 大促前要压测什么?
02 扩展性与数据迁移(5 题)
L3 🔥×3 Q05 · 单库单表要拆分了,怎么做在线数据迁移(不停服)?
答:四阶段(双写 → 回填 → 校验 → 切读 → 切写 → 清理):
- 双写:新表先建好(schema 与旧表兼容),应用层同时写新旧两张表。用 MQ 异步双写 + 本地消息表保证不丢;双写失败必须告警。
- 回填历史数据:按主键分片并发回填(限速),记录回填位点以便断点续跑;用幂等 upsert。
- 校验:对比两表行数 + 抽样字段 + 定期全量 checksum,不一致要能定位到分片并重刷。
- 切读:按用户 / 请求比例灰度切到新表,观察后逐步放大;出问题立刻切回旧表。
- 切写:读切稳后再把写切到新表。
- 清理:观察期内保留旧表可回滚,稳定后再下线。
关键前提:schema 变更必须向后兼容(先加列不删列),否则回滚也救不回来。
易错:直接停机迁移;双写没有失败补偿;校验只对行数不做内容校验。
追问:① 双写不一致怎么发现?② 回填要跑几天怎么办?③ 怎么保证回滚可用?
L3 补充 Q06 · 读写分离后主从延迟怎么办?哪些场景不能读从库?
答:延迟来源:主库写入 → binlog 传输 → 从库重放;大事务、从库单线程回放、网络与磁盘 IO都会放大延迟,通常毫秒级、慢查询时可达秒级。
必须读主库的场景:① 写后立即读(下单后查订单)——最常见的"读不到自己刚写的数据";② 强一致要求的业务(余额、库存扣减结果);③ 刚写完就基于结果做决策。
缓解手段:
- 强制读主:写后短时间窗内(如 1 秒)或同一会话走主库。
- 位点等待:带上主库返回的位点,从库等待追平再读。
- 半同步复制:至少一个从库确认收到才返回(降低丢数据风险,不是降延迟)。
- 业务兜底:关键路径直接读主或读缓存。
易错:以为"读从库性能更好所以全走从库"——写后立即读会大面积失败;用异步复制还宣称"不丢数据";把延迟当"偶发抖动"而不做写后读主。
追问:① 半同步和异步怎么选?② 从库数量怎么定?③ 主从切换怎么做?
L2 低频实录·1源 Q07 · 数据量太大,热点分片和冷数据怎么处理?
答:热点分片:某个分片的 QPS 远超其他分片(典型是某个大客户 / 爆款商品)。① 拆热点:把热点 key 单独拆成独立分片("热点单独一片,其余按规则分");② 打散:把一个逻辑分片再按 hash 拆成 N 个物理片,查询时并行查再合并;③ 读扩散 / 写扩散:写多个片、读任一片;④ 缓存前置:热点数据常驻缓存,请求根本不到分片层。
冷数据:① 按时间归档——超过保留期的数据从热库迁出到历史库 / 对象存储 / 列存;② 分层存储——热数据在 SSD / 内存,冷数据在低成本存储;③ 查询路由——查询时先判时间范围,老的走历史库;④ 保留期按业务定——日志类 30~90 天,订单 / 账务类长期甚至永久。
易错:只做归档不做查询路由;把"热点"当偶发问题不设兜底(热点 key 过期会瞬间打穿缓存)。
追问:① 归档期间还能查历史吗?② 热点怎么自动识别?③ 归档任务失败怎么补?
L2 低频实录·1源 Q08 · 多租户系统怎么设计?隔离要做到什么程度?
答:三种隔离度(成本递增):① 共享库共享表(加 tenant_id 列)——最省,但必须所有查询带 tenant_id,否则数据越权;② 共享库独立 schema——隔离更好,跨租户查询要跨库;③ 独立库独立实例——隔离最强,成本最高,适合大客户 / 合规要求。
必须做到的:① 数据隔离:所有查询强制注入 tenant_id(ORM 拦截器 / 底层 DAO 统一加,而不是靠每个开发记得加);② 资源隔离:单租户限流与配额(防止一个租户打满共享资源),必要时独立线程池 / 连接池;③ 缓存隔离:缓存 key 必须包含 tenant_id,否则串数据;④ 运维隔离:限流、日志、备份策略按租户配置。
易错:只在应用层过滤租户(一条漏写的 SQL 就越权)——应在数据访问层统一拦截;缓存 key 不带租户导致串号;共享实例下没有租户级限流,一个大客户就能拖垮所有租户。
追问:① 怎么防止漏写 tenant_id?② 租户级限流怎么实现?③ 什么时候需要独立实例?
L3 🔥×12 Q09 · 大促前要做扩容预案,具体准备什么?
答:预案要能"照着做",不是"到时候再想":
- 容量目标:把峰值 QPS 目标写下来(峰值 × 安全系数),反推实例数、数据库分片数、缓存容量、带宽。
- 扩容路径:提前做压测(全链路压测,含突发与热点),找到瓶颈点与扩容触发线(CPU / 队列长度 / 连接池)。
- 预案文档:每一步写清操作、执行人、预计耗时、回滚方式;能自动化的写脚本(加机器、扩分片、预热缓存)。
- 预热:活动前把数据与缓存预热到位(否则开卖瞬间冷启动打 DB)。
- 降级开关:提前把降级方案准备好并演练(关推荐、关评论、返回静态页)。
- 值守与预案:活动期间有人盯大盘,明确异常时的决策人。
易错:只准备了"加机器"这一条路径——DB / 缓存 / 带宽 往往才是真瓶颈;没有演练(第一次在活动当天执行);降级开关没提前验证,真要降时发现降不动。
追问:① 压测怎么造出真实峰值?② 预案没演练的风险怎么控?③ 活动后怎么复盘?
03 特定场景设计(5 题)
L3 补充 Q10 · 设计一个短链服务,怎么保证高可用和性能?
答:写路径:① 生成短码——用发号器(DB 号段批量取号)或哈希 + 冲突检测(base62)。base62 下 62^6 ≈ 5.7×10^10、62^8 ≈ 2.2×10^14,6~8 位空间足够;② 落库存映射;301 vs 302 是关键取舍——301 被浏览器长期缓存(省流量,但改目标即失效),302 每次回源(可统计、可改);③ 写路径尽量短,可用布隆过滤 + 异步落库。
读路径(性能关键):① 302 重定向,由 Nginx / 网关直接处理(不进业务应用);② 热点短码缓存在 Nginx(proxy_cache)或应用本地;KV 分离便于扩容;③ 统计异步化(MQ 批量落库)。
高可用:Redis 多副本、DB 主从、服务无状态可水平扩展;短码唯一性靠发号器或唯一索引兜底。
易错:用 301 导致换目标后老链接全失效;统计同步写 DB 拖慢读路径;不防恶意短码枚举。
追问:① 301 还是 302?② 短码空间用完了怎么办?③ 怎么做热点短链的缓存预热?
L3 🔥×3 Q11 · 设计一个消息推送 / IM 系统,架构怎么搭?
答:连接层:长连接网关(WebSocket / 自研协议)无状态(连接信息放 Redis),可水平扩展;客户端有心跳与重连 + 消息补拉(按 message_id 游标)。
投递路径:① 业务侧写消息存储(按会话分片、按 seq 有序);② MQ 异步分发到各网关实例;③ 网关查在线状态(Redis 存会话 → 网关映射):在线直接推送,离线存离线消息;④ 多端同步:每端维护 last_ack_seq,客户端上报已读位点,服务端补推。
关键设计点:顺序性——同一会话的消息必须进同一分区,否则乱序;可靠投递——客户端 ack + 服务端重试,离线消息有保留期;存储分层——只存"会话最近 N 条",冷数据转对象存储。
易错:网关有状态导致扩缩容困难;分区键选错导致同会话乱序;不做离线消息导致上线看不到历史;把"已读回执"同步写 DB。
追问:① 怎么保证多端消息一致?② 消息量突增怎么办?③ 怎么统计未读数?
L3 🔥×5 Q12 · 设计一个文件分片上传系统,怎么保证完整性?
答:流程:① 预检(/init):客户端报文件名、大小、哈希 → 服务端校验(大小、类型白名单、是否重复文件——按 hash 查是否已存在可做秒传);② 分片:按固定大小(5~10MB)切片,并发 3~5 个上传,每片带 fileId + chunkIndex;③ 断点续传:服务端提供 query 返回已上传分片列表(或用 Redis 记进度);④ 合并(/merge):校验分片数与大小后合并。
完整性保障:每片由客户端随片上传该片的 hash,服务端校验;整文件客户端算全文件 hash,服务端合并后重新计算比对;合并前确认分片完整且无重复。
易错:只信客户端报的大小;合并时不校验单片 hash;不做整文件校验(分片损坏无法发现);并发上传不限流把存储打爆。
追问:① 怎么支持秒传?② 分片大小怎么定?③ 合并时某片失败怎么办?
L3 🔥×2 Q13 · 搜索相关性问题怎么设计?倒排索引和正排索引分别解决什么?
答:倒排索引:词 → 包含该词的文档列表(含词频、位置),支撑按词检索;正排索引:文档 → 字段值,支撑按文档取详情与聚合分析。
检索链路:分词 / 查询改写 → 倒排召回(BM25 打分,兼顾词频与逆文档频率)→ 相关性精排(Learning to Rank)→ 业务规则重排(时效、权威度、个性化)→ 去重与多样性。
工程要点:① 分词是中文搜索的关键(同义词、词典、拼音);② 倒排索引的构建与更新成本高(增量更新 vs 重建);③ 查询扩展(纠错、同义词、意图识别)常比调分值更能提升效果。
易错:只做倒排不纠错(用户搜错字直接零结果);忽略长尾查询(大部分查询无结果,需要推荐 / 兜底);把"召回多"当"效果好"(精度要靠精排与重排)。
追问:① BM25 相比 TF-IDF 改进了什么?② 倒排索引怎么增量更新?③ 怎么评估搜索质量(NDCG / MRR)?
L3 补充 Q14 · 设计一个订单 / 支付链路,怎么保证钱和单不错?
答:核心是"状态机 + 幂等 + 对账"三件套。
- 状态机:订单
待支付 → 已支付 → 已发货 → 已完成,每次跃迁都是条件更新(UPDATE ... SET status='已支付' WHERE id=? AND status='待支付'),靠影响行数判断是否跃迁成功——天然防重复。 - 幂等:支付回调带交易流水号,服务端唯一索引去重。
- 金额用整数(分)存储;前端传的单价不可信,一律以服务端价格为准。
- 对账:① 实时对账(网关流水与本地订单比对,不一致告警);② 日终对账(逐笔核对渠道账单,差异进补偿任务);③ 主动查单(结果未知的订单定时向渠道查询)。
- 超时:未支付自动关单并释放库存(定时任务 + 状态条件更新保证不重复释放)。
易错:相信前端传来的金额;只靠回调不做主动查单;库存释放不幂等导致超卖。
追问:① 回调与主动查单结果冲突怎么办?② 对账差异怎么处理?③ 怎么保证库存不超卖?
04 稳定性与容灾(4 题)
L2 🔥×7 Q15 · 怎么设计一套灰度发布与回滚机制?
答:发布方式:
- 滚动更新:逐批替换实例——资源开销小,但新旧版本共存期间行为可能不一致。
- 蓝绿:两套环境整体切换——回滚最快(切流量),但资源成本 2 倍。
- 金丝雀:先放 1% / 5% / 20% 流量,观察指标后逐步放大——风险最低。
必备护栏:① 自动熔断(错误率 / 延迟 / 业务指标越阈值自动回滚);② 流量隔离(按 user_id / header 分流,保证同一用户版本一致);③ 影子模式(新版本只算不返回,用于对比质量,零风险)。
回滚前提:镜像不可变 + 版本可追溯(tag 带 git sha)、数据库变更向后兼容(先加不删、双写、分步迁移)、配置可独立回滚。
易错:只回滚代码不回滚配置 / DB schema——不兼容变更导致回滚也起不来;金丝雀流量按请求随机(同一用户两个版本);没有自动熔断,靠人盯看板。
追问:① 蓝绿与金丝雀怎么选?② DB schema 怎么平滑迁移?③ 灰度期间怎么对比新旧版本?
L2 🔥×4 Q16 · 容灾怎么做?同机房双活和异地多活怎么选?
答:按数据同步分级:① 冷备(备份异地存,RTO 小时级、RPO 天级);② 温备(备库同步但不对外服务,RTO 小时级、RPO 秒级);③ 热备 / 双活(两端都可服务,RTO 分钟级、RPO 接近 0)。
同机房双活:延迟低(< 1ms),但抗不了机房级故障。异地多活:抗机房级故障,但跨城延迟几十毫秒——带来数据冲突与一致性两个难题。
多活的数据处理:
- 单元化(Cell):按用户维度把数据固定归属到某单元,写只写本单元、读就近——最常用、最简单,天然避免冲突。
- 异步复制 + 冲突解决:接受最终一致,靠业务规则处理(复杂、慎用)。
配套:流量路由(GSLB 按地域与健康度切换)、脑裂防护(同一时间只允许一个单元写)、定期演练(没演练过的容灾等于没有容灾)。
易错:只做备份不做切换演练;宣称"RPO=0"却用异步复制。
追问:① RTO / RPO 怎么定?② 跨城延迟怎么对用户透明?③ 切流演练怎么做?
L2 补充 Q17 · 数据备份怎么设计?恢复演练怎么做?
答:备份三要素:RPO(能容忍丢多少数据 → 决定备份频率)、RTO(多久能恢复 → 决定类型与冗余)、成本。
类型:全量(可独立恢复,慢、贵)+ 增量 / 日志(快、省空间);逻辑备份(跨版本兼容、可按对象恢复)与物理备份(快、精确、跨版本兼容性差)——通常逻辑 + 物理组合。
关键设计:① 备份要异地(同机房故障时备份一起没了),且跨账号 / 跨区域;② 备份不等于恢复——必须定期做恢复演练;③ 备份加密 + 访问控制(备份里往往是最全的数据);④ 保留策略按合规要求 + 分级存储;⑤ 防误删除——备份防不住"逻辑误删",要有软删除 / 回收站 / 延迟删除。
易错:只备份不演练——没验证过的备份不能算备份;备份与生产同账号同区域;忽略逻辑损坏。
追问:① 误删怎么防?② 恢复演练要验证什么?③ 备份怎么加密与审计?
L2 🔥×2 Q18 · 成本优化从哪里入手?怎么做"降本"而不牺牲可靠性?
答:先量后砍——把成本拆开:计算、存储、网络(出向流量常是隐形大头)、托管服务、许可证。
常见降本手段(按性价比排序):
- 资源利用率:设置合理的 request / limit,避免过度申请;加 HPA / 弹性伸缩。
- 存储分层:日志类按保留期分层(热 SSD → 温 → 冷对象存储)+ 压缩(Parquet + ZSTD)。
- 缓存命中率:命中率低说明缓存键或 TTL 有问题——加缓存通常比降配更划算。
- 网络:出向流量常是大头 → CDN、压缩、减少回源、日志采样。
- 异步化:非必要工作移出主链路(MQ + 消费者按需扩缩)。
- 实例规格:用实测数据选型,避免"大规格跑低负载"。
不能省的:备份、监控、限流、容量余量——省下的钱会以故障的形式还回去。
易错:直接降配不测压;只砍成本不看可靠性指标;忽略数据增长趋势。
追问:① 怎么量化降本效果?② 成本和可用性冲突时怎么决策?③ 大促临时扩容后怎么缩容?
05 FDE 交付与项目落地(4 题)
L2 🔥×13 Q19 · 项目里数据规模有多大?做过分片或分布式存储吗?容量怎么规划?
答:先给量级,再讲方案:
- 数据规模:单表行数、单条大小、总量、日增量、保留期 → 推出存储量与增长曲线。
- 读写特征:读多还是写多?峰值 QPS?单条查询还是范围扫描?→ 决定索引与分片键。
- 分片判断:单表过千万 / 写入触顶 / 索引放不下内存 → 才分片。分片键要满足均匀 + 查询带得上 + 跨片少。
- 扩容路径:现在不拆,但表设计预留(用逻辑分表、从一开始就带分片键、避免自增主键)。
答这类题的加分点:给出具体数字和为什么这么定的推理,而不是"我们用了分库分表"。
易错:只说"用了什么中间件"不讲为什么和代价;容量数字含糊("千万级");忽略增长与保留期(清理策略)。
追问:① 冷热数据怎么分层?② 备份怎么做、怎么演练恢复?③ 分片后跨片查询怎么优化?
L2 🔥×17 Q20 · 项目里怎么保证并发的正确性?遇到过多线程相关的挑战吗?
答:先讲"并发模型"再讲"具体问题":
- 模型:线程池隔离(不同业务用不同池,避免相互拖垮)、或逻辑隔离(Actor / 消息驱动),关键是资源隔离而非单纯加锁。
- 共享状态:用不可变数据 + 无共享设计(消息传递)优先;必须共享时用并发容器或细粒度锁。
- 一致性:跨线程共享的状态变更要保证可见性与顺序(
volatile/ 原子类 / 锁);先写 DB 后删缓存的顺序不能反。
典型挑战与解法:缓存击穿 → singleflight 互斥回源;连接池耗尽 → 池隔离 + 慢查询治理;线程池饥饿 → 任务不提交到池内阻塞,改异步 IO;重复消费 → 幂等。
易错:用一个全局线程池跑所有业务(相互影响);synchronized 粗粒度锁住整个流程;把阻塞操作放进线程池导致池被占满。
追问:① 线程池怎么隔离?② 怎么定位死锁?③ 什么场景该用异步而非同步?
L3 补充 Q21 · 面向客户交付一套系统,你怎么规划?客户环境和你开发环境不一致怎么办?
答:FDE 的核心是"让系统在客户环境里跑起来并可运维":
- 交付前:明确验收标准(功能清单、性能指标、SLA、故障响应)与环境清单(OS、网络策略、依赖版本、账号、证书),做环境差异清单逐项对齐。
- 可移植性:容器化 + 配置外置,同一制品在各环境运行、只换配置——消除环境差异最有效的手段。
- 依赖管控:版本锁定、内网镜像仓库 / 私有源、离线安装包(客户内网常无外网)。
- 可运维:交付监控与日志采集(一键接入)、回滚方案、运维手册、告警接收人;备份与恢复演练。
- 灰度:先在客户测试环境跑通关键链路再切生产,保留回退窗口。
- 知识转移:培训 + 文档 + 上线初期驻场。
易错:把开发环境的绝对路径 / 本地依赖 / 硬编码 IP带进客户环境;只交付代码不交付监控与回滚。
追问:① 客户环境不能装依赖怎么办?② 怎么让客户自己完成部署?③ 现场出故障,你的支持流程是什么?
L3 低频实录·1源 Q22 · 客户需求模糊("要个智能客服"),你怎么拆解并落地?
答:FDE 的日常就是需求模糊化。四步:
- 问清目标而非功能:客户说的往往是解法,要挖出业务目标与成功指标(要提升什么指标?当前基线多少?)。
- 划边界:明确范围内 / 范围外,把"顺手做一下"的部分单独立项并报价——这是保护项目与团队的关键动作。
- 找最小可用路径:MVP = 打通一条端到端链路(真实数据、真实用户、真实指标),而不是做完所有功能。先证明价值再扩范围。
- 定验收:每个需求配可判定的验收标准(输入 → 期望输出 / 指标阈值),双方签字。
技术选型:优先选团队熟、能离线跑、客户运维成本低的方案;给出方案对比(成本、时延、可维护性、风险)而不是单一推荐。
易错:直接把客户的"功能清单"当需求做(做出来没人用);不划范围导致无休止的需求膨胀;先选技术再想场景。
追问:① 客户坚持要某个方案但有明显风险怎么办?② 怎么排需求优先级?③ MVP 之后怎么规划二期?
06 系统设计手撕代码(4 题)
L2 补充 ✍ H01 · 手撕滑动窗口限流器(环形缓冲 + 时间片轮转)
考察点:滑动窗口把时间切成小格滚动统计,比固定窗口精度高、无临界突刺;请求要写入当前时间片,轮转时被覆盖的旧片必须清零。
实现:
import threading
import time
class SlidingWindowLimiter:
"""滑动窗口计数限流:窗口切成 n 个时间片,环形缓冲记录每片请求数。"""
def __init__(self, limit, window_s=1.0, buckets=10):
self.limit = limit
self.window = window_s
self.n = buckets
self.slot = window_s / buckets # 每个时间片的时长
self.counts = [0] * buckets
self.idx = 0 # 当前时间片下标
self.lock = threading.Lock()
self.last = time.monotonic()
def _rotate(self, now):
"""把时间片指针推进到现在,清零被覆盖的旧片。"""
shift = int((now - self.last) / self.slot)
if shift <= 0:
return
if shift >= self.n: # 整个窗口都过期了
self.counts = [0] * self.n
else:
for _ in range(shift):
self.idx = (self.idx + 1) % self.n
self.counts[self.idx] = 0 # 覆盖掉的旧片必须清零
self.last += shift * self.slot
def allow(self):
with self.lock:
self._rotate(time.monotonic())
if sum(self.counts) >= self.limit:
return False
self.counts[self.idx] += 1 # 写入当前片
return True
易错:请求固定写同一个格子而不做环形轮转——旧计数永不过期,限流会越来越紧;轮转时只清当前格不清被覆盖的旧片,同样会误拒;未加锁导致并发超发。
L2 补充 ✍ H02 · 手撕一致性哈希分片路由(含热点治理的正确做法)
考察点:路由要稳定(同一 key 永远落同一节点);扩节点数时尽量少搬数据(这正是一致性哈希的意义);热点治理不能靠改路由。
实现:
import bisect
import hashlib
class ShardRouter:
"""一致性哈希(虚拟节点环)。
扩片时只有环上相邻的一段 key 需要迁移;用 hash % n 取模会导致几乎全部 key 迁移。
"""
def __init__(self, nodes=None, replicas=150):
self.replicas = replicas
self.nodes = []
self.ring = {} # 虚拟节点哈希 -> 真实节点
self.keys = [] # 排好序的虚拟节点哈希(二分用)
for n in nodes or []:
self.add_node(n)
@staticmethod
def _hash(text):
return int(hashlib.md5(text.encode()).hexdigest(), 16)
def add_node(self, node):
self.nodes.append(node)
for i in range(self.replicas):
h = self._hash(f"{node}#{i}")
self.ring[h] = node
self.keys.append(h)
self.keys.sort()
def route(self, key):
"""返回 key 所属的节点。"""
if not self.keys:
return None
h = self._hash(str(key))
i = bisect.bisect_left(self.keys, h)
if i == len(self.keys):
i = 0 # 环上回绕
return self.ring[self.keys[i]]
热点治理的做法:不要在路由层改写既有 key 的归属——那会让已写入的数据读不到。正确做法是① 提前预判:给大客户单独一个节点(在加节点时就把它的 key 从环上摘出来);② 读扩散:同一 key 存多份副本,读时任选一份;③ 缓存前置:热点数据常驻缓存,请求不到分片层。
L2 补充 ✍ H03 · 手撕短码生成器(base62 编码 + 号段取号)
考察点:base62 编码要去掉前导零(否则短码长度不固定);号段取号要批量预取避免每次都打 DB。
实现:
ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
BASE = len(ALPHABET)
def encode(num):
"""数字 → base62 短码(定长补零由调用方决定)。"""
if num == 0:
return ALPHABET[0]
out = []
while num:
num, rem = divmod(num, BASE)
out.append(ALPHABET[rem])
return "".join(reversed(out))
class SegmentIdGenerator:
"""号段模式:一次从 DB 取一段 ID,本地自增分配,用完再取。
号段区间约定为**闭区间 [start, end]**。
"""
def __init__(self, fetch_segment):
self.fetch_segment = fetch_segment # (start, end) -> 分配一个闭区间号段
self.next_id = 0
self.end = -1 # 闭区间上界(含)
def next(self):
if self.next_id > self.end: # 号段用完 → 取新段
start, end = self.fetch_segment()
self.next_id, self.end = start, end
cur = self.next_id
self.next_id += 1
return cur
def short_code(self):
return encode(self.next())
易错:base62 编码不处理 num == 0(会返回空串);号段用尽的判断写成 >= 会跳过每段最后一个 ID(闭区间语义应为 >);不同实例共用同一段起点导致重复(起点必须由 DB 唯一分配)。
L2 🔥×5 ✍ H04 · 手撕熔断器状态机(Closed / Open / Half-Open)
考察点:三状态转换;失败率阈值与最小调用数(样本太少时不能判定);半开态只放少量探测请求;成功则关闭、失败则重新打开并延长熔断时间。
实现:
import threading
import time
class CircuitBreaker:
CLOSED, OPEN, HALF_OPEN = "closed", "open", "half_open"
def __init__(self, fail_threshold=0.5, min_calls=20, window=10.0,
open_seconds=30.0, half_open_probes=3):
self.fail_threshold = fail_threshold
self.min_calls = min_calls
self.window = window
self.open_seconds = open_seconds
self.half_open_probes = half_open_probes
self.state = self.CLOSED
self.calls = [] # (timestamp, ok)
self.opened_at = 0.0
self.probe_ok = 0
self.lock = threading.Lock()
def _prune(self, now):
self.calls = [(t, ok) for t, ok in self.calls if now - t < self.window]
def allow(self):
with self.lock:
now = time.monotonic()
if self.state == self.OPEN:
if now - self.opened_at >= self.open_seconds:
self.state = self.HALF_OPEN # 放少量请求试探
self.probe_ok = 0
self.calls = []
else:
return False # 熔断中:直接失败
if self.state == self.HALF_OPEN:
return self.probe_ok < self.half_open_probes
return True
def record(self, ok):
with self.lock:
now = time.monotonic()
if self.state == self.HALF_OPEN:
if ok:
self.probe_ok += 1
if self.probe_ok >= self.half_open_probes:
self.state = self.CLOSED
self.calls = []
else:
self.state = self.OPEN # 试探失败 → 重新熔断
self.opened_at = now
return
self._prune(now)
self.calls.append((now, ok))
if len(self.calls) >= self.min_calls:
failed = sum(1 for _, good in self.calls if not good)
if failed / len(self.calls) >= self.fail_threshold:
self.state = self.OPEN
self.opened_at = now
易错:没有最小调用数门槛——只失败 1 次就熔断(样本太少,误判);没有半开态导致熔断后永不恢复;半开态放太多请求(等于没有熔断);失败率统计不按时间窗口(历史失败永久拉高失败率)。
与卷 B 的分工对照表
避免两卷重复,明确各自边界。
| 主题 | 卷 B(后端与分布式) | 卷四(系统设计) |
|---|---|---|
| 分库分表 | 怎么分:分片键怎么选、跨片查询怎么做 | 什么时候分 + 怎么迁:在线迁移、冷热分层、多租户 |
| 缓存一致性 | 一致性机制:先写库还是先删缓存、延迟双删 | —— (卷四不重复) |
| 分布式锁 | 怎么实现:Redis SETNX + Lua、主从丢锁 | —— |
| 幂等 | 怎么保证:唯一 ID、去重表、状态机 | 端到端怎么不丢单:订单支付链路的幂等 + 对账 |
| 消息队列 | 选型与可靠性原理:Kafka vs RabbitMQ、不丢不重有序 | 在系统里怎么用:IM 推送、异步下单削峰 |
| 故障排查 | 怎么查:接口不可用排查、日志与监控 | 怎么防:熔断、灰度回滚、容灾、备份演练 |
| 手撕代码 | LRU、一致性哈希、雪花 ID、令牌桶 | 滑动窗口限流、分片路由、短码生成、熔断器 |
低频备选(未入主表)
- 对账 / 账务系统:多见于支付、账务专项岗位,通用后端岗频率低(卷四 Q14 已覆盖核心链路)。
- Feed 流 / 信息流架构:多见于内容社区专项。
- 搜索引擎底层(倒排构建、分词):多见于搜索岗专项,卷四只收了设计层面的题。
- CDN / 边缘计算:多见于大厂基础架构专项,通用岗样本少。
- 容器与 K8s 编排细节:属运维 / SRE 方向,卷 B 已有 Docker/k8s 核心题。