第六章 经典系统设计(第123-162题)
本章导览(元问题与表面主题逐字取自 《核心考点与元理论》§六)
- 本章真正考的那一个问题: 把领域语义翻译成读写路径与状态机,代价花在哪?
- 表面主题: Feed、IM、支付、网关…
- 题号与题量: 第 123–162 题,共 40 题;刷题时不要按知识点记,要按上面那一个元问题记。
123. 短信营销链接太长被截断,要生成短链接(短链系统)
【考察内容】短链是系统设计入门必考题
【题目】运营要在短信里放推广链接,但原始 URL 太长(带一堆参数),短信会被截断。需要做短链服务:长 URL 生成短码、访问时跳转还原。要求:短码不能重复、支持高并发跳转(读多写少)、要能统计点击。怎么设计?
【参考答案】
- 需求分析:读(跳转)远高于写(生成),要求低延迟(毫秒级)、高可用、防冲突;
- 短码生成方案:
- 哈希截断:MD5/MurmurHash 长链取前 6~8 位(62 进制:大小写字母+数字),冲突检测+追加/重哈希;简单但需查重;
- 发号器(推荐):分布式 ID(号段/雪花)转 62 进制得到短码——唯一、无冲突、趋势递增;
- 自定义短码(运营指定):数据库唯一索引兜底;
- 存储:MySQL 表(short_code 唯一索引、long_url、过期时间、创建时间);读写比例高——Redis 缓存短码→长链映射(TTL 设长);
- 跳转流程:用户访问短链 → 网关/服务查 Redis → 命中 302 跳转;未命中查 DB → 回填缓存 → 302 跳转;
- 扩展:过期清理(惰性删除+定时任务)、访问统计(异步记录)、防恶意生成(限流+黑白名单)、长链去重(同一长链复用短码);
- 容量:短码 6 位=568 亿组合,足够;单表千万级可分表(按短码哈希)——但去重会跨片失效:
long_url_hash的唯一索引只在单个分片内生效,主表按短码哈希后,同一条长链的两次请求会落到不同分片各生成一个短码,去重方案的「生成前先查」还要广播全部 64 张表。正解是另建一张以long_url_hash为分片键的映射表(或独立去重表/Redis Set)专门承担去重,主表才按短码哈希分片; - 容量估算(步进):假设日点击 5000 万(营销短信),峰值系数 10,读峰值 ≈ 5000万/86400×10 ≈ 6000 QPS;写(生成)日 50 万条、峰值约 50 QPS,读写约 100:1。存储:5 亿条映射 × 200B ≈ 100GB(按同题“单表千万级”口径,5 亿÷1e7 ≈ 50 张表,实践取 64 张;只分 8 表会让单表到 6000 万行);Redis 热映射按 20% 热点 ≈ 1 亿个 key,只按 value 算是 1亿×200B ≈ 20GB;但 Redis 每个 key 还有 SDS 头、dictEntry、过期字典项等约 60B 量级开销,实占要按 26~35GB 起估,需按热点比例分片或单实例扛。带宽:每次跳转响应极小(302+Location),6000 QPS × 1KB ≈ 6MB/s,网关层无压力;
- 失败与降级:Redis 故障时读 DB(限流+本地缓存兜底);发号器不可用则该实例短时只读,或切备用号段;统计 MQ 挂了不影响 302 主路径(统计异步可丢);恶意刷生成触发限流/黑名单;过期短链返回 410 或引导页,不静默跳到错误目标。
【原理溯源】
- 为什么推荐发号器而不是哈希截断? 哈希是“输入空间→输出空间”的压缩映射,生日悖论决定冲突概率随样本数平方上升——6 位 62 进制约 568 亿空间,生成几亿条时冲突已不可忽略。发号器(号段/雪花)本质是在单调递增整数上做进制转换,每个号只出现一次,冲突在生成端就被消除了,不需要“查重→重试”回路。代价是号段服务要高可用,但这比“全表查重”便宜得多。
- 为什么短码读路径必须打 Redis 缓存? 跳转是超高频读(短信点击瞬间涌入),而映射关系一旦生成几乎不变(读多写少、读远大于写)。MySQL 单点查询毫秒级尚可,但热点短链会形成单行热点;Redis 单机 10 万+ QPS 且 O(1) 查找,把 99% 流量挡在 DB 之外。缓存 TTL 要长(甚至永不过期+惰性删除),因为映射关系几乎不变更。
- 为什么用 302 而不是 301? 301 是永久重定向,浏览器/中间层会缓存,统计点击会丢(后续请求不再打到短链服务);302 是临时重定向,每次访问都回到服务端,才能记 UV/PV、做 A/B 跳转、随时改目标 URL。若某条短链要永久固定且无需统计,才考虑 301。
- 为什么同一长链要去重复用短码? 不去重会让同一条营销链接生成成千上万个短码,表膨胀、缓存击穿风险上升、统计口径被拆散。去重后同一目标共享一个短码,点击可聚合统计。实现:长链做哈希索引(
long_url_hash唯一),生成前先查,命中则直接返回已有短码——这是单库/未分表口径;主表一旦按短码哈希分片,这条查询会跨片失效,要另建以long_url_hash为分片键的去重表(见第 6 条)。 - 为什么要过期清理? 营销短链有生命周期(活动结束),不过期会让表无限膨胀、陈旧链接指向失效活动。清理用“惰性删除(访问时发现过期即删)+ 定时扫描”,避免大事务批量删。
【选型判断树】
短码怎么生成?
├─ 能接受“查重+重试”吗?
│ ├─ 不能(高并发生成、强唯一)→ 发号器:号段/雪花 → 62 进制(推荐)
│ └─ 能(低 QPS 生成)→ 哈希截断 6~8 位 + 冲突检测重哈希
└─ 运营要自定义短码?
└─ 是 → 用户输入 + 唯一索引兜底(占用冲突则提示换)
读路径:
短链访问 → 查 Redis → 命中 302
└─ 未命中 → 查 MySQL → 回填缓存 → 302
统计:异步 MQ 记 click 日志,不阻塞跳转判断口诀: 生成端用发号器保唯一,读端用缓存扛量,统计走异步不拖跳转。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “短链是读多写少的映射服务,核心是短码唯一性 + 跳转低延迟 + 点击统计” |
| 0:30–1:30 | 短码生成 | 对比哈希截断(有冲突需检测)与发号器转 62 进制(天然无冲突,推荐) |
| 1:30–2:30 | 存储与读路径 | MySQL 存映射;Redis 缓存扛读;访问 → Redis → 命中 302,未命中回填 |
| 2:30–3:30 | 统计与扩展 | 异步记 click;长链去重;过期惰性+定时清理;防刷限流 |
| 3:30–4:30 | 容量与细节 | 6 位 62 进制 568 亿组合;主表按短码哈希分表、去重另建 long_url_hash 分片表;302 而非 301(保统计) |
| 4:30–5:00 | 收尾 | “一句话:发号器保唯一,缓存扛读,异步统计,302 保链路可运营” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 短码长度 | 6~8 位(62 进制) | 6 位 ≈ 568 亿,够用 |
| 跳转 RT | < 10ms(缓存命中) | 短信点击场景用户无感 |
| 读写比 | 读 : 写 ≈ 100 : 1 以上 | 决定缓存优先架构 |
| 缓存 TTL(短链映射,几乎不变) | 长 TTL 或永不过期+惰性删 | 映射几乎不变 |
| 302 vs 301 | 统计场景必须 302 | 301 会被浏览器缓存导致丢统计 |
| 单表容量 | 千万级考虑分表 | 主表按 short_code 哈希;去重表要按 long_url_hash 分片,否则唯一索引跨片失效 |
| 点击统计 | 异步 MQ,允许秒级延迟 | 不阻塞跳转主路径 |
| 读峰值估算 | 日点击×峰值系数/86400 | 例:5000万日点击→约6000QPS |
| 写峰值 | 日生成量/日秒数×峰值 | 日50万生成→约50QPS |
| 映射存储 | 条数×单行约200B | 5亿条约100GB,可分表 |
【追问链】(三层)
L1|“短码撞了怎么办?” → 发号器方案天然不撞:每个整数只转出一个 62 进制串。哈希方案则必须查库/查缓存,撞了就重哈希或在尾部追加盐再哈希,直到唯一;生产上用唯一索引做最后兜底,插入冲突则重试。
L2|“同一个长链接生成一百万次短码,表会不会爆?” → 会,所以要做长链去重:对 long_url 算哈希并建唯一索引,生成前先查,命中直接复用已有短码。但主表按短码哈希分片后,这个唯一索引只在单个分片内生效——同一条长链的两次请求会落到不同分片各生成一码,「生成前先查」还要广播 64 张表;所以去重要靠一张以 long_url_hash 为分片键的映射表(或独立去重表/Redis Set),主表才按短码哈希分片。这样同一营销链接只占一行,统计也能聚合成“这条活动链接总点击”。
L3|“热点短链把 Redis 打爆了怎么办?” → 三层:① 本地缓存(进程内 Caffeine)挡最热的几条映射;② Redis 多副本/读写分离;③ 极端时网关直接返回 302(映射可预热进网关配置)。本质是“不可变映射 + 极高读”,本地缓存几乎零代价。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出“生成短码 + 存映射 + 跳转”,知道用 Redis 缓存 |
| 80 分 | 对比发号器 vs 哈希截断;讲清读写分离与 302 选择;提到去重与过期清理 |
| 95 分 | 能解释生日悖论下哈希冲突必然性;301/302 对统计的影响;热点本地缓存;分表策略;防刷限流 |
【关联题】
- 同簇: 第 121 题(分布式 ID)——发号器是短码生成的上游依赖
- 同构: 第 162 题(计数系统)——同为「高频写先落 Redis+缓存层挡读+异步落库+对账」,不是同一套读写比
- 下游延伸: 第 137 题(爬虫 URL 去重)——海量 URL 去重的另一解(布隆)
【自测】
- 判断对错:短链跳转用 301 更好,因为永久重定向更快。 参考答案: 错。301 会被客户端缓存,后续访问不再打到服务端,点击统计会丢;需要统计的场景必须 302。
- 6 位 62 进制短码大约有多少组合?哈希生成时要注意什么? 参考答案: 62⁶ ≈ 568 亿。哈希压缩有冲突概率,必须做冲突检测与重试,或改用发号器。
- 为什么短链映射缓存 TTL 可以很长? 参考答案: 映射一旦生成几乎不变(读多写少),变更极少;长 TTL 可最大化命中率,过期用惰性删除+定时任务兜底。
124. 视频点赞量级千万,点赞/取消/计数怎么做(点赞系统)
【考察内容】点赞系统是高并发设计最高频题
【题目】视频 App 要做点赞功能:用户点赞/取消、展示总点赞数、判断我是否点过赞、查看点赞用户列表。单条视频可能被点几百万赞,热点视频点赞瞬时并发高。存储与计数怎么设计?
【参考答案】
- 需求拆解:①用户点赞/取消(写);②作品点赞总数(高并发读);③某用户是否点赞过(查状态);④用户点赞列表;
- 存储设计:
- Redis:作品点赞数用 String INCR/DECR(或 Hash 字段);“谁点过赞”用 Set(sadd/srem,支持交集计算);用户维度点赞列表用 ZSet(score=时间,可排序);
- DB 落库:点赞明细表(user_id, target_id, create_time,唯一索引 user_id+target_id 防重复点赞);点赞计数表(target_id, count);
- 一致性:点赞先写 Redis(实时反映)→ 异步落库(MQ 消费,批量合并更新计数,DB 为准)→ 对账兜底(Redis 计数与 DB 计数核对);
- 热点处理:大 V 作品点赞量巨大——计数拆分(多个计数 key 分片,读时求和);状态查询走缓存(Set)避免 DB 压力;但题干预设「单条视频几百万赞」,作品维度的 Set/ZSet 本身就是 5e6 成员级大 Key(默认配置下这个量级只能是 hashtable 编码,按 ~48–68B/元素估 ≈0.25–0.35GB;只有显式把
set-max-intset-entries从默认 512 调大且成员全为整数,才可能压到 ~40MB),SMEMBERS/DEL/主从同步都会阻塞——状态层必须同样治理:只保留近 N 天、或按判定维度分片成liked:{vid}:{uid%K}(保证「同一用户+同一视频」恒落一片),全量「谁点过赞」以 DB 明细表为准,Redis 只作热态加速; - 防重复/防刷:唯一索引防重复点赞;频控限流(同用户短时间内重复操作合并);
- 扩展:评论/转发/浏览计数同架构复用(通用计数服务);
- 容量估算(步进):假设 DAU 1000 万,约 20% 产生点赞动作、人均 10 次/日 → 日点赞事件约 2000 万;高峰 4 小时占 70%(2000万×0.7÷14400 ≈ 970/s 的时段均值),再乘瞬时系数 5~10 → 写峰值约 5000~1 万 QPS,Redis INCR+Set 可扛,分片后再乘 8~32 倍余量。存储:2000 万/日 × 明细 50B ≈ 1GB/日,一年约 300~400GB(明细可按月冷热分层)。“我是否赞过”列表页:一页 20 视频 = 20 次 SISMEMBER,可 pipeline 合并为 1 次 RTT。读总数接口通常被 CDN/本地缓存挡一层,回源 QPS 远低于展示 QPS;
- 失败与降级:Redis 计数/状态不可用时降级读 DB 计数表(限流),写侧本地缓冲重试;MQ 持续积压则暂停非核心计数、优先保点赞明细;对账发现 Redis 与 DB 偏差超阈值时以明细重算为准并告警;展示允许秒级不一致,结算/风控场景走权威路径。
【原理溯源】
- 为什么点赞写路径必须走 Redis 而不是直接写 DB? 热点视频瞬时点赞可到数千 QPS(单视频不可能高于第 7 条算出的全站写峰值 5000~1 万),MySQL 单行
UPDATE count=count+1会形成行锁热点,RT 飙升甚至拖垮实例。RedisINCR是单线程内存原子操作,10 万+ QPS 无压力。DB 只承担“最终落账”,用 MQ 削峰后批量合并写入,把同步热点变成异步平滑流量。 - 为什么要“Redis 实时 + DB 最终”的双层结构? 用户点赞后要立刻看到“已赞”和数字变化(体验强需求),但点赞明细是审计与对账的真源(业务强需求)。两者 SLA 不同:展示允许秒级延迟,账目必须不丢不错。双层结构用“实时层扛体验、权威层保正确”,中间靠 MQ + 幂等消费 + 对账收敛。
- 热点计数为什么要分片? 单个
count:{videoId}key 所有写打到同一 Redis 节点的同一 slot,单线程下形成串行瓶颈。拆成count:{videoId}:0~N,写入时随机/按用户哈希选分片,读时 SUM。分片把单点串行变成多 key 并行,代价是读要聚合——所以只对超热 key 分片,普通 key 不拆。 - 为什么“谁点过赞”用 Set 而不是每次查 DB? 判断“我是否赞过”是列表页批量操作(一页 20 条视频都要标红心),查 DB 会放大 20 倍查询。Set 的
SISMEMBERO(1),且天然支持“共同好友点赞”这类交集运算。DB 侧仍保留唯一索引作为最终防线。 - 取消点赞怎么保证不把计数减成负数? 先
SISMEMBER判断是否已赞,再SREM+DECR;DB 侧用“明细存在才删+计数减”的条件更新。更稳的做法是把“赞/取消”都当成事件,消费端按明细对账重算计数,而不是盲目 INCR/DECR。
【选型判断树】
点赞功能怎么拆?
├─ 写(赞/取消)
│ → Redis:SADD/SREM 状态 + INCR/DECR 计数(实时)
│ → MQ 异步 → DB 明细+计数(唯一索引兜底)
├─ 读总数
│ ├─ 普通视频 → 单 Redis key 直接 GET
│ └─ 热点视频 → 计数分片 0~N,读时 SUM;状态 Set 也要治理(几百万成员=大 Key,只留近 N 天或按 liked:{vid}:{uid%K} 分片,全量以 DB 明细为准)
├─ 读“我是否赞过”
│ → Redis Set SISMEMBER(列表页批量;单条几百万赞时该 Set 本身是大 Key,要分片或只留热态)
└─ 读“谁赞的”
→ ZSet(score=时间)分页;超大列表走 DB 游标判断口诀: 状态用 Set、计数用 String/Hash、列表用 ZSet;写走 Redis,账走 DB,中间 MQ + 对账。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “点赞是高并发读写 + 最终一致计数题,核心是 Redis 数据结构选型与异步落库” |
| 0:30–1:30 | 需求拆解 | 写赞/取消、读总数、读是否赞过、读点赞列表——四类访问模式不同 |
| 1:30–2:30 | 存储设计 | Set 存状态、String 存计数、ZSet 存用户列表;DB 明细+计数表 |
| 2:30–3:30 | 一致性 | Redis 实时 → MQ 异步落库 → 对账兜底;唯一索引防重 |
| 3:30–4:30 | 热点治理 | 计数分片抗单 key;状态层也是待治理对象(百万成员 Set 分片/只留近 N 天,全量走 DB);频控合并 |
| 4:30–5:00 | 收尾 | “一句话:结构按访问模式选,写实时账最终,热点靠分片” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| Redis 单 key INCR | 10 万+ QPS | 热点仍需分片 |
| 热点分片数 | 8~32 | 按实际热点程度调 |
| MQ 落库批量 | 数百~数千条/批 | 降低 DB 写压力 |
| 对账周期 | 分钟级~小时级 | 差异告警+自动修正 |
| 列表缓存分页 | 每页 10~20 | 按业务 UI 定 |
| 唯一索引 | (user_id, target_id) | 防重复点赞的最后防线 |
| 日点赞事件 | DAU×点赞率×人均次数 | 例:1000万×20%×10≈2000万/日 |
| 写峰值 | 日事件按高峰小时+瞬时系数折算 | 约 0.5~1 万 QPS(970/s × 5~10) |
| 明细存储增速 | 日事件×单行约50B | 约1GB/日,可按月归档 |
【追问链】(三层)
L1|“取消点赞怎么处理?” → Redis:SREM 移除状态 + DECR 计数(先判断是否已赞);DB:异步删明细 + 计数减一。消费端幂等,用明细状态保证不多减不少减。
L2|“Redis 和 DB 计数不一致怎么办?” → 定时对账:从点赞明细重算 count,与 Redis/DB 计数比对,差异则修正并告警。对账是最终一致的最后防线,不能省。
L3|“大 V 视频点赞 key 成了单点热点,分片后读变慢了怎么办?” → 分片只对超热 key 做;读侧用“本地缓存 1~5 秒的汇总值”挡展示路径;真正需要精确值的场景(运营后台)再实时 SUM。展示允许秒级延迟,这是产品可接受的取舍。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道用 Redis 计数 + DB 落库 |
| 80 分 | 能按访问模式选 Set/String/ZSet;讲清异步落库与唯一索引 |
| 95 分 | 热点分片、对账闭环、取消点赞的状态机、防刷频控、通用计数服务抽象 |
【关联题】
- 同簇: 第 162 题(计数系统)——点赞是计数的业务版
- 上游: 第 13 题(热 Key)、第 30 题(大 Key)
- 同构: 第 126 题(关注粉丝)——同样用 Set 做关系+计数
- 综合题: 第 152 题(社区系统)——点赞是其中子模块
【自测】
- 判断对错:点赞数可以直接
UPDATE like_count = like_count + 1写 MySQL,热点也没问题。 参考答案: 错。热点行锁会成为瓶颈,必须 Redis 扛写、DB 异步落账。 - “谁点过赞”用什么结构?为什么不用每次查 DB? 参考答案: 中小体量用 Redis Set(SISMEMBER O(1)),列表页批量判断查 DB 会放大 N 倍压力;但题干预设单条几百万赞时,作品维 Set 本身就是 5e6 成员的大 Key(SMEMBERS/DEL/主从同步都会阻塞),要按
liked:{vid}:{uid%K}分片或只保留近 N 天热态,全量「谁点过赞」以 DB 明细表为准。 - 热点计数为什么要拆成多个 key? 参考答案: 避免单 key/单 slot 串行瓶颈;写随机分片、读 SUM,用聚合换并行。
125. 评论区一级/二级评论、热评、分页,怎么支撑(评论系统)
【考察内容】内容社区核心模块设计
【题目】内容平台要上线评论功能:支持一级/二级评论(楼中楼)、评论分页(按时间/热度)、热门评论置顶、评论计数。日活千万,热点内容评论区瞬时流量大。表结构、分页方案、热评排序怎么设计?
【参考答案】
- 需求:发表评论、评论列表分页(按时间/热度)、楼中楼(二级评论)、评论数统计、删除/审核;
- 存储:
MySQL:评论表(comment_id、parent_id、root_id、target_id、user_id、content、like_count、is_top、status、create_time),索引(target_id+create_time)查列表、(root_id)查楼中楼; 置顶怎么与分页拼:置顶项极少(运营/作者各设一条),单独按
is_top=1查出后拼在首页非置顶项之前,游标分页只作用于非置顶列表——把置顶混进游标里翻页会错位或重复。热评:评论表加 like_count 排序字段或单独热评缓存;
- 列表读取:评论列表先查一级评论(分页),再批量查二级评论(按 root_id IN 查询,组装树)——避免 N+1;或用冗余字段(一级评论的二级评论缓存成 JSON 片段);
- 缓存:热帖评论列表缓存 Redis(分页缓存/整页缓存+失效);评论计数缓存(INCR);
- 写路径:评论先写 DB(事务),再更新缓存/计数(异步);审核状态(先审后发或先发后审);
- 高并发(热帖):评论写入用 MQ 削峰;列表读走缓存;评论按页拆分 key(comments:postId:page),避免单 key 过大;
- 扩展:敏感词过滤(写前校验)、排序策略(热度=点赞+时间衰减)、楼中楼深度限制(如最多两级);
- 容量估算(步进):假设 DAU 1000 万,约 5% 发评论、人均 2 条 → 日评论约 100 万;热帖可占全日 30% 流量(≈30 万条);全天均值只有 ≈11.6 条/s,爆款窗口把 30 万压在 2–3 分钟内才是 数千 QPS(MQ 削峰后 DB 按批消费数百 TPS 即可)。存储:单帖评论上限产品约束(如 1 万条),全量日增 100 万 × 300B ≈ 300MB/日。读:热帖列表 QPS 可达数万,必须整页/分页缓存,DB 只兜底冷帖。接口示例:
GET /comments?target_id=&tab=hot|new&cursor=、GET /comments/{root_id}/replies?cursor=、POST /comments; - 失败与降级:Redis 故障时列表降级直查 DB(限流);MQ 积压则评论“提交成功、稍后展示”,展示侧用本地缓存旧页;审核服务超时可降级为先发后审+异步复审;计数与列表短暂不一致以明细表为准,定时对账。
【原理溯源】
- 为什么要冗余 root_id 而不是递归 parent_id? 纯 parent_id 链要递归向上找根,深度不确定时查询次数不可控,且无法“按根批量取子树”。冗余 root_id 后,一次
WHERE root_id IN (...)就能取出所有楼中楼,把树查询变成两次平铺查询(一级分页 + 二级批量),复杂度从“每层一次”变成“固定两次”。 - 为什么楼中楼深度要限制(通常两级)? 无限深度会带来三个问题:① 展示层无法分页折叠;② 查询必须递归或多次往返;③ 存储与索引难以优化。微信/知乎/抖音评论区都只做两级(一级评论 + 其回复),更深层回复扁平化到二级并 @用户,用产品约束换工程简单。
- 为什么热帖评论要按页拆缓存 key? 单个
comments:postId存全量列表会变成大 Key(百万评论的帖子),内存与网络传输都扛不住,且任何一条新评论都要失效整键。按comments:postId:page拆分后,新评论只失效相关页,热读页可独立缓存,大 Key 风险消失。 - 为什么热评和最新评论要两套视图? 排序维度冲突:热度是全局相对量(随时变),时间是单调序。混在一个索引里要么放弃实时热度,要么放弃高效时间分页。工程上拆成“热评缓存(ZSet/物化列表,定期刷新)+ 最新评论索引(target_id+create_time)”,各走各的最优路径。
- 为什么要 MQ 削峰写入? 明星发帖后评论区秒级涌入数千写请求,直接打 DB 会打爆连接池并拖垮读路径。MQ 把写解耦成“接收极快、消费按节奏”,配合敏感词过滤在消费端做,用户侧可先返回“提交成功、稍后展示”。
【选型判断树】
评论列表怎么读?
├─ 普通帖
│ → MySQL 索引分页(target_id + create_time / like_count)
│ → 可加短 TTL 缓存
└─ 热帖
→ Redis 整页/分页缓存
→ 按 page 拆 key,避免大 Key
→ 写走 MQ 削峰
楼中楼怎么查?
├─ 先查一级(分页)
└─ 再 root_id IN 批量查二级(禁止 N+1 逐条查)
排序:
├─ 最新 → create_time 索引
└─ 热评 → like_count + 时间衰减,单独缓存视图判断口诀: 一级分页、二级批量、热帖缓存、写削峰、深度限两级。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “评论系统是树形内容+双排序+热帖高并发的综合题” |
| 0:30–1:30 | 表结构 | parent_id + root_id 双字段;索引 target_id+time / root_id |
| 1:30–2:30 | 读路径 | 一级分页 → 二级批量组装;防 N+1;热帖分页缓存 |
| 2:30–3:30 | 写路径 | 先审后发/先发后审;MQ 削峰;计数 INCR 异步 |
| 3:30–4:30 | 热评与治理 | 热度=点赞+时间衰减;敏感词;深度限两级 |
| 4:30–5:00 | 收尾 | “一句话:冗余 root 扁平化树,热帖缓存按页拆,双视图解决排序冲突” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 楼中楼深度 | 2 级 | 主流产品均限深 |
| 一级分页 | 10~20 条/页 | 按 UI |
| 二级预取 | 每个一级带 3~5 条回复 | 其余点开再拉 |
| 热帖缓存 TTL | 10s~1min | 允许短暂旧数据 |
| 热度公式 | 点赞×w1 + 评论×w2,再时间衰减 | score/(1+age^1.2) 一类 |
| 写削峰 | MQ,消费批量 | 热点帖必须 |
| 敏感词 | 写前同步过滤 + 异步复审 | 合规底线 |
| 日评论量估算 | DAU×发帖评论率×人均 | 例:1000万×5%×2=100万/日 |
| 热帖读峰值 | 首页强曝光×刷新次数 | 数万QPS,必须缓存 |
【追问链】(三层)
L1|“为什么不用递归查所有子评论?” → 深度不可控、每层一次往返,热点帖会拖垮 DB。冗余 root_id 后固定两次查询(一级+二级批量),性能可预期。
L2|“热评和最新评论怎么共存?” → 两套视图:最新走 target_id+create_time 索引;热评走缓存的排序列表(定期按热度重算)。前端 Tab 切换,互不影响。
L3|“帖子被删了,缓存和热榜里的评论怎么办?” → 删除事件走 MQ:失效评论缓存页、从热榜 ZSet 移除、ES 标记删除。或读时校验帖子状态兜底。事件驱动清理 + 状态过滤双保险。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出评论表 + 分页 + 点赞数 |
| 80 分 | root_id 扁平化、批量防 N+1、热帖缓存、写削峰 |
| 95 分 | 双排序视图、按页拆 key 防大 Key、审核策略、深度限制原因、删除事件联动 |
【关联题】
【自测】
- 判断对错:二级评论用 parent_id 递归查询即可,不需要 root_id。 参考答案: 错。递归深度不可控且无法批量取子树;冗余 root_id 才能固定两次平铺查询。
- 热帖评论缓存为什么要按页拆 key? 参考答案: 避免单 key 过大(大 Key)和整键失效;新评论只失效相关页。
- 热度排序为什么不能只用 like_count? 参考答案: 旧评论会永久霸榜;必须叠加时间衰减,让新内容有机会上来。
126. 关注列表/粉丝列表/互相关注判断,关系链怎么做(关注粉丝系统)
【考察内容】关系链数据建模
【题目】社交 App 需要关注关系:用户关注/取关别人、查看我的关注列表/粉丝列表、判断 A 和 B 是否互相关注。大 V 粉丝千万级。存储模型(关注关系怎么存)、分页、互相关注判断怎么设计?
【参考答案】
- 数据结构:两套 Set——关注表(user:{uid}:following 存关注的人)、粉丝表(user:{uid}:follower 存粉丝);Redis Set 天然支持交集/并集(共同关注、互相关注);
- 操作:关注=A 的 following 加 B + B 的 follower 加 A(两步,可放 Lua/事务);取关反向;判断 A、B 是否互相关注:
SINTER user:A:following user:A:follower后看 B 在不在结果里,等价于两次 SISMEMBER(B∈A.following 且 A∈B.following); - 存储分层:Redis Set 承担高频读写(关注列表、粉丝数 SADD/SCARD);DB 落库关注关系表(user_id, follow_id, create_time,唯一索引,分表按 user_id);
- 粉丝数:Redis 计数(SCARD/单独计数 key)+ 异步落库(对账);
- 热点:大 V 粉丝千万级——Set 元素多是大 key:粉丝列表拆 hash 分片(follower:{uid}:{0~N})或只存“粉丝数+精选列表”,全量粉丝走 DB;
- 扩展:关注 Feed 联动(发帖推送给粉丝——见 Feed 流题);僵尸粉清洗、批量关注限制(频控);
- 容量估算(步进):假设 DAU 500 万,人均关注/取关 5 次/日 → 日关系写约 2500 万次(双写 Set 则 ×2);峰值约 2000~3000 QPS 关系写。存储:关注明细 10 亿行 × 40B ≈ 40GB;分表数按「单表千万级开始评估」这一线取:1e9÷1e7=100 为下限,取 2 的幂就是 128 张(每表约 781 万行)或 256 张(391 万行);只分 16~64 张则每表仍有 1562 万~6250 万行:16 张每表 6250 万、64 张每表 1562 万,两档都越过「单表千万级」这条评估线,只有读多写少且有大段冷数据可归档时才勉强可接受——分片数后期难改,宁多勿少;普通用户 Set 元素少,大 V 不进全量 Set。读:列表页“是否互关”批量 SINTER/SISMEMBER,一页 20 人可 pipeline;粉丝数展示被前端短缓存,回源可控。
- 失败与降级:双写一半失败时以 DB 明细为准,后台对账重建 Set;Redis 大 V 粉丝 key 预警时立刻降级为“计数+最近 N 个粉丝”,全量列表走 DB 游标;关注接口超时返回明确错误,禁止静默半成功;Feed 推送失败与关注关系解耦,漏推靠 Feed 对账补偿。
【原理溯源】
- 为什么要同时维护 following 和 follower 两套结构? 只存单向会导致另一侧查询退化成全表扫描:只有 following 时查“我的粉丝”要
SELECT * WHERE follow_id=me,大 V 会扫出千万行;只有 follower 时查“我关注了谁”同样惨。双写冗余把双向查询都变成 O(1) 集合访问,代价是写路径要维护两份一致——用 Lua/本地事务保证同一次关注的原子性。 - 为什么互相关注用集合运算而不是查 DB?
SINTER following:A follower:A(取 A 自己两个集合的交集)是 Redis 原生集合操作,毫秒级返回;更省的是两次 SISMEMBER。注意不能取A.following ∩ B.following——那是「共同关注」,只说明两人关注了同样的人,判不出互关。若走 DB,要么多次查询要么 JOIN,在社交图谱的高频“是否互关”判断(列表页每条都要标)场景下完全不可接受。 - 为什么大 V 粉丝 Set 必须治理? 千万级元素的 Set 是典型大 Key:① 内存单点占用过高;②
SMEMBERS/全量传输会打爆网卡;③ 主从同步阻塞。治理手段:分片存储(按粉丝 uid 哈希到 N 个 key)、只在 Redis 存“计数 + 前 N 个精选/最近粉丝”,全量列表走 DB 游标分页。大 V 的粉丝列表几乎从不全量展示,产品形态天然允许降级。 - 为什么关注要频控? 关注是关系写入,脚本可批量关注/取关制造关系污染(刷粉、爬虫)。频控(如每分钟最多 20 次)+ 设备指纹 + 异常检测,是社交图谱干净度的必要成本。
- 为什么粉丝数可以“不准但要快”? 展示场景允许秒级延迟甚至短暂误差,所以用 Redis 计数实时展示,DB 异步落账 + 对账。真正需要精确的是结算/风控场景,那些再走权威路径。
【选型判断树】
关系怎么存?
├─ 双向都要高频查 → Redis 双 Set(following + follower)+ DB 明细
└─ 只单向业务(如订阅)→ 单 Set 即可
大 V 粉丝怎么处理?
├─ 千万级 → 不存全量 Set
│ ├─ 计数 key + 精选/最近 N 个
│ └─ 全量走 DB 游标
└─ 普通用户 → Set 直接存
互关/共同关注:
→ 互关、共同关注都用 SINTER(取的集合不同);SUNION 只用于并集推荐;不查 DB判断口诀: 双写保双向,大 V 拆分片或降级,集合运算做关系判断。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “关系链核心是双向查询与大 V 大 Key 治理” |
| 0:30–1:30 | 双 Set 模型 | following + follower;Lua 保证双写原子 |
| 1:30–2:30 | 集合运算 | 互关与共同关注都是 SINTER(互关取自己两集合、共同关注取两人 following 交集);SUNION 用在「朋友关注的人」这类并集推荐;列表页批量判断 |
| 2:30–3:30 | 存储分层 | Redis 扛读写,DB 唯一索引落明细,对账 |
| 3:30–4:30 | 大 V 治理 | 分片 / 计数+精选 / DB 游标;不全量进 Redis |
| 4:30–5:00 | 收尾 | “一句话:双写冗余换双向 O(1),大 V 用降级换稳定” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 大 V 阈值 | 粉丝 > 10 万~100 万 | 开始治理大 Key |
| 精选粉丝缓存 | 最近 100~1000 | 展示够用 |
| 关注频控 | 10~30 次/分钟 | 防刷 |
| Set 分片 | 8~64 个 key | 按粉丝量级 |
| Feed 推模式阈值 | 粉丝 < 1 万~10 万 | 超过转拉/混合 |
| 对账 | 小时级 | 计数 vs 明细 |
| 日关系写 | DAU×人均关注动作 | 例:500万×5=2500万/日(双写×2) |
| 关系明细存储 | 行数×约40B | 10亿行约40GB,按uid分表 |
【追问链】(三层)
L1|“只存关注表不行吗?粉丝列表现查。” → 大 V 查粉丝会全表扫描,千万级不可接受。必须冗余 follower 侧,用双写一致性换查询性能。
L2|“关注的两步写失败了一半怎么办?” → Lua 脚本保证原子;仍失败则对账任务修复(从 DB 明细重建 Set)。DB 唯一索引是最终真相。
L3|“大 V 粉丝 Set 太大,主从同步卡住了怎么办?” → 立刻拆分片或降级为“计数+精选列表”;全量粉丝迁 DB。监控大 Key(redis-cli --bigkeys),建立大 Key 治理规范,禁止业务无脑 SADD 千万级。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道用 Set 存关注关系 |
| 80 分 | 双 Set + 集合运算 + DB 落库分层 |
| 95 分 | 大 V 大 Key 分片/降级、双写一致性与对账、频控防刷、与 Feed 的联动 |
【关联题】
- 下游: 第 128 题(Feed)——关注关系是推模式的 fan-out 依据(126 是 128 的上游,故此处站在 126 视角是下游)
- 同构: 第 124 题(点赞)——同样 Set+计数+异步落库
- 治理: 第 30 题(大 Key)
【自测】
- 判断对错:只存 following,粉丝列表用 SQL 查 follow_id 即可。 参考答案: 错。大 V 会全表扫,必须冗余 follower 或接受慢查询。
- 互相关注怎么判断? 参考答案:
SINTER user:A:following user:A:follower看 B 是否命中,或直接两次 SISMEMBER(B∈A.following 且 A∈B.following)。A.following ∩ B.following得到的是共同关注,不是互关。 - 大 V 粉丝为什么不能全量放一个 Set? 参考答案: 大 Key:内存、网络、主从同步风险;应分片或只存计数+精选,全量走 DB。
127. 热搜榜单实时更新、去重、排序,怎么算出来的(热榜系统)
【考察内容】实时统计与 TopN 设计
【题目】平台要上线热榜:根据用户行为(搜索/点击/讨论量)实时计算热门话题,榜单分钟级更新、要去重合并(同一事件不同词条要归并)、要防刷。数据怎么采集、热度分怎么算、榜单怎么存储与推送?
【参考答案】
- 需求:统计单位时间(分钟/小时)内关键词/视频的热度,实时 TopN,防刷;
- 热度计算:score = 权重组合(搜索量×系数+讨论量×系数+原创加权),时间衰减(score 随时间衰减,如 score/(1+age^1.2)),用 ZSet 的 score 表达;
- 架构:
- 埋点:搜索/点击/转发行为上报(MQ 异步);
- 聚合:Flink/Spark 流式计算或定时任务(分钟级)聚合关键词计数 → 写 Redis ZSet(key=hot:{date}:{hour});
- 读取与推送(题干问「怎么推送」,拉与推要分开答):拉=ZREVRANGE 取 TopN,缓存榜单 JSON(短 TTL,如 10s),读多写少用缓存兜底;推=榜单是「整体快照」而不是逐条事件,所以不做逐条下发——榜更新后只推一个「榜单已更新,请拉一次」的轻量 diff 信号(长连接只给小范围活跃用户),其余客户端按「版本号+轮询(周期≈更新间隔)」获取;把整榜逐条推给百万连接是最常见的错法;
- 去重与合并:同义词/近义词合并(“王宝强”与“王宝强 电影”合并);重复条目按实体归一;
- 防刷:同一 IP/用户加权降权、频控、机器识别(无行为特征的刷量剔除);
- 扩展:地域榜/分类榜(分 key)、新旧榜切换(榜单 key 按时间滚动)、历史榜单归档查询;
- 容量估算(步进):假设 DAU 2000 万,搜索/点击行为约 30 次/人日 → 日行为事件约 6 亿,埋点峰值按 20% 在高峰小时 → 上报峰值约 3~5 万条/s(MQ 削峰,流式聚合消费)。热榜存储:ZSet 只保留 Top 200~500 条,单 key 内存极小;历史榜按
{date}:{hour}滚动,30 天 × 24 key 可归档到 DB/对象存储。读:热榜接口首页强曝光,峰值可 数万 QPS,10s JSON 缓存 + 本地缓存后回源 Redis 可忽略。 - 数据与接口(口述可带):榜单项字段 =
{term_id, display_name, score, rank, snapshot_count, category};核心接口GET /hot/list?scene=all|tech&n=50、GET /hot/detail?term_id=;后台POST /hot/ban(人工下架/置顶)。失败与降级:流计算延迟时读旧榜(允许滞后 1~2 个刷新周期);Redis/聚合任务失败则切换到上一小时快照;防刷规则引擎超时可先放行再异步复核,但人工置顶/下架开关必须始终可用。
【原理溯源】
- 为什么热度必须加时间衰减? 不衰减时,历史累积量永远压过新事件,榜单变成“历史总榜”而非“当前热榜”。衰减函数让旧条目分数随时间下降,新事件有机会上榜。常见形式:线性衰减
score/(1+age)、指数衰减score·e^(-λt)、或滑动窗口内重新计数(只统计最近 N 分钟)。 - 为什么用 ZSet 存榜? ZSet 天然按 score 排序,
ZADD更新分数 O(log N),ZREVRANGE取 TopN O(log N + M)。热榜本质是“带分数的有序集合 TopN”,ZSet 是该抽象的原生实现,比“每次全量排序”便宜几个数量级。 - 为什么要去重合并词条? 同一事件会被用户用不同词搜索/讨论(“某明星 恋情” vs “某明星官宣”),不合并会把热度拆散,榜上出现多个条目稀释权重,真实热度被低估。归一化靠同义词典 + 实体识别 + 编辑距离/向量相似度,离线词典 + 在线匹配结合。
- 为什么要防刷? 热榜是舆论场,有动机被刷。不防刷会导致假热点占据公共资源,伤害平台公信力。手段:同 IP/设备/账号降权、异常增速检测(突然 100 倍增长告警)、无互动行为的纯点击剔除。
- 为什么榜单要短 TTL 缓存而不是每次现算?
ZREVRANGE本身不慢,但热榜接口 QPS 极高(首页强曝光)。缓存 10s 的 JSON 榜单,把 Redis 查询也挡掉,读路径变成纯内存/本地。代价是最多 10s 延迟,对热榜可接受。
【选型判断树】
热度怎么算?
├─ 实时性要求高(秒级)→ Flink 滑动窗口 → ZSet
└─ 分钟级可接受 → 定时任务批量聚合 → ZSet
榜单怎么读、怎么推?
├─ TopN 接口 → 缓存 JSON(TTL 10s)
└─ 历史榜 → 按 {date}:{hour} 归档 key
去重:
同义词典 + 实体归一 → 合并到主词条再计热度
防刷:
IP/设备/账号降权 + 增速异常检测判断口诀: 权重组合 + 时间衰减得分数,ZSet 存 TopN,缓存挡读,词典去重,风控防刷。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “热榜是实时 TopN + 去重 + 防刷的统计系统” |
| 0:30–1:30 | 热度公式 | 多信号加权 + 时间衰减;举例说明衰减必要性 |
| 1:30–2:30 | 计算链路 | 埋点 → MQ → 流式/定时聚合 → Redis ZSet |
| 2:30–3:30 | 拉与推 | 拉=ZREVRANGE TopN → JSON 缓存短 TTL;推=只发「榜已更新」的轻量 diff 信号+客户端按版本号轮询(整榜逐条推是错法) |
| 3:30–4:30 | 去重防刷 | 同义词合并;IP/设备降权;增速异常 |
| 4:30–5:00 | 收尾 | “一句话:衰减保新鲜,ZSet 保排序效率,缓存保读性能” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 榜单更新 | 10s~1min | 产品可接受延迟 |
| 缓存 TTL(热榜 TopN 结果) | 5~30s | 挡 TopN 接口 |
| 时间衰减 | score/(1+age^1.2) 或 e^(-λt) | 防旧词霸榜 |
| 接口展示条数 | Top 50~100 | 首页展示够用(接口示例 n=50) |
| 滑动窗口 | 最近 10min~1h | 按业务热点半衰期 |
| 防刷降权 | 同 IP/设备计数封顶 | 异常增速告警 |
| 行为上报峰值 | 日事件×高峰占比/秒 | 例:6亿×20%/3600≈3万+/s |
| ZSet 保留条数 | Top200~500 | 多留一档供分类榜/去重回填;内存可忽略 |
| 热榜读峰值 | 首页曝光×DAU刷新 | 数万QPS,靠短TTL缓存 |
【追问链】(三层)
L1|“为什么不直接用数据库 ORDER BY 计数?” → 每次请求全量排序,热榜 QPS 下 DB 必挂;且难以表达时间衰减。ZSet 把排序成本摊到写入时的 O(log N),读 TopN 极快。
L2|“同一事件多个词条怎么合并?” → 同义词典 + 实体识别归一到主词条;离线挖掘近义词对,在线匹配后统一计分。合并后再入 ZSet,避免热度被拆散。
L3|“有人恶意刷榜怎么发现和处置?” → 实时:增速异常检测(单位时间涨幅超阈值告警+降权);特征:无点击/无停留的纯搜索、设备农场。处置:剔除刷量、封禁账号、榜单回滚。事后:样本回流模型。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道用 Redis ZSet 做 TopN |
| 80 分 | 热度公式含时间衰减;流式聚合链路;缓存读;并答到「推送」这一问(diff 信号/版本号轮询) |
| 95 分 | 去重归一、防刷策略、滑动窗口 vs 累计、历史榜归档、异常增速处置 |
【关联题】
- 上游: 第 124 题(计数/点赞)——热度信号来源
- 同构: 第 146 题(BI 看板)——实时聚合架构相似
- 综合: 第 152 题(社区)——热榜是子模块
- 对比: 第 151 题(弹幕)——可丢失消息 vs 必须准确的榜
【自测】
- 判断对错:热榜只需累计搜索次数,不用时间衰减。 参考答案: 错。不衰减会变历史总榜,旧词永久霸榜。
- 热榜存储为什么选 ZSet? 参考答案: 天然按分数排序,更新 O(log N),取 TopN 高效,匹配“带分数的有序 TopN”抽象。
- 去重合并失败会怎样? 参考答案: 热度被拆散到多个近义词条,真实热度被低估,榜上出现重复条目。
128. 关注了 1000 人,刷新要看到他们的最新动态(Feed 流)
【考察内容】Feed 流是系统设计最高频大题之一
【题目】用户关注了 1000 个账号,每次刷新都要看到这些账号的最新动态,而且要求秒开。大 V 一条动态有千万粉丝要看到。推(写扩散)、拉(读扩散)、推拉结合三种模式各自怎么实现?你们的场景选哪种?
【参考答案】
- 两种模式:
- 推模式(Fan-out on write):发帖时把帖子 ID 写入所有粉丝的 feed 缓存(list);读时直接取自己列表——读快;但大 V 千万粉丝=写千万次(写放大);
- 拉模式(Fan-out on read):发帖只写自己的帖子表;读时合并所有关注人的帖子(查关注列表→查各自最新帖→合并排序)——写简单;但读慢(多查询+合并);
- 混合模式(生产主流):普通用户推(粉丝少,写成本低),大 V(粉丝超阈值如 10 万)拉——粉丝读时把“自己关注的大 V 帖子”与“推的普通帖子”合并;
- 存储:feed 缓存用 Redis List(每用户一个 key,存帖子 ID,LRANGE 分页,容量限制如 1000 条,超出淘汰);帖子详情放 Redis/DB;发帖表(user_id, create_time 索引);
- 排序:按时间倒序(ID 有序)或热度;分页用游标(避免深分页);
- 一致性:发帖先落 DB(事务)→ 提交后再异步推送给粉丝(MQ)——Feed 推送属可补偿旁路(漏推靠对账补推,用户不依赖它);资金/状态这类不可丢事件必须走本地消息表或 Outbox(见第 99 题),不能这样裸发;推送失败重试+对账(漏推补推);
- 扩展:feed 缓存降级(拉模式兜底)、僵尸粉清理、分组/不看某人等过滤规则;
- 容量估算(步进):假设 DAU 1000 万,约 2% 发帖 → 日新帖 20 万;读刷新人均 20 次/日 → 日 Feed 读 2 亿次,高峰折算读峰值约 2~3 万 QPS。推模式写放大:普通用户均粉 200,则日推写 = 20万×200 = 4000 万次 list 写,峰值数百~数千 Redis OPS,可接受;若某大 V 1000 万粉丝纯推,单次发帖 = 1000 万写,必须走拉/混合。存储:每用户 Feed 缓存 500~1000 个帖子 ID × 8B ≈ 4~8KB/用户,活跃用户 1000 万 × 8KB ≈ 40~80GB(1e7×8KB=8e10B),计入 listpack/quicklist 每元素开销约 100~150GB。这个量级 Redis Cluster 装得下,约束在单实例内存上限、成本与大 Key/热 Key,所以要按 user_id 分片到多节点,并对成本敏感的部分把每用户 Feed 截断到最近 100~200 条、或只给活跃用户预推。
- 失败与降级:推失败重试+对账补推;Redis Feed 列表故障时自动降级为拉模式现算(限流+只取最近 N 关注人);帖子详情缓存击穿用互斥锁/空值缓存;深分页禁止 offset,统一游标。
【原理溯源】
- 为什么推模式读快写爆? 推把计算成本挪到写入时:发帖一次,写入 N 个粉丝的 feed 列表。读路径 O(1)(只取自己列表)。当 N=千万(大 V),一次发帖产生千万次写,MQ 消费链路被放大,缓存与带宽都扛不住。推的本质是“预计算读结果”,用写放大换读性能。
- 为什么拉模式写简读慢? 拉只写一份帖子,读时要:查关注列表(1000 人)→ 批量取各人最新帖 → 归并排序。关注人数多时读放大严重,且每次刷新都重复计算。拉的本质是“读时现算”,用读成本换写简单。
- 为什么混合是生产主流? 普通用户粉丝少(几十~几百),推的成本可忽略且读体验最好;大 V 粉丝千万,推会打爆写入。混合按“粉丝数阈值”分流:小 V 推、大 V 拉。读时合并“推来的列表”与“关注的大 V 最新帖”,两边都取 TopN 归并。阈值由压测定(常见 1 万~10 万)。
- 为什么 feed 缓存只存帖子 ID 而不是整帖内容? 帖子可能被编辑/删除/审核下架,内容冗余进 feed 会导致不一致与存储膨胀。存 ID、读时查详情缓存,删除只需失效详情,feed 列表读时过滤即可。容量也小一个数量级。
- 为什么要游标分页? Feed 是无限流,深分页(offset 10000)要扫描大量元素。游标(上一页最后一条的 ID/时间戳)让每次只取“比游标更旧的 N 条”,复杂度 O(N),与页深无关。
【选型判断树】
选推/拉/混合?
├─ 粉丝数少(< 1 万)且读 QPS 高 → 推
├─ 粉丝数巨大(> 10 万)或写频高 → 拉
└─ 社交产品(大 V + 普通人并存)→ 混合(阈值分流)
feed 缓存:
├─ 存帖子 ID(推荐)+ 详情独立缓存
├─ 容量限制 500~1000 条,LRU/截断
└─ key 按用户,大 V 粉丝不预推
分页:游标(max_id / 时间戳),禁止 offset 深分页判断口诀: 小 V 推、大 V 拉、混合合并;存 ID 不存内容;游标翻页。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “Feed 是推拉取舍题,核心是写放大与读放大的交换” |
| 0:30–1:30 | 推模式 | 发帖 fan-out 写粉丝列表;读 O(1);大 V 写爆 |
| 1:30–2:30 | 拉模式 | 只写一份;读时合并关注人最新帖;读慢 |
| 2:30–3:30 | 混合(推荐) | 阈值分流;读时归并推列表与大 V 帖 |
| 3:30–4:30 | 存储细节 | Redis List 存 ID;容量截断;游标分页;MQ 异步+对账 |
| 4:30–5:00 | 收尾 | “一句话:用混合在写放大和读放大之间取平衡” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 推/拉阈值 | 粉丝 1 万~10 万 | 压测定 |
| feed 缓存容量 | 500~1000 条 | 超出截断 |
| 分页大小 | 10~20 条 | 游标 |
| 推送延迟 | 秒级(MQ) | 可接受 |
| 大 V 发帖写放大 | 千万级 | 必须拉模式 |
| 对账补推 | 分钟级扫描漏推 | 最终一致 |
| Feed读峰值 | 日读总次数/86400×峰均比 | 例:2亿/日÷86400≈2315,×10≈2.3万QPS |
| 推写放大 | 发帖数×平均粉丝数 | 20万×200=4000万list写/日 |
| 单用户Feed内存 | 500~1000个ID×8B | 约4~8KB/用户 |
【追问链】(三层)
L1|“混合模式阈值怎么定?” → 看“单次发帖的 fan-out 成本”与“读合并成本”的交叉点,结合发帖频率压测。经验值 1 万~10 万粉丝,还要考虑该用户发帖频次(发帖狂魔阈值要更低)。
L2|“feed 缓存丢了怎么办?” → 降级到拉模式:按关注列表现算最新帖。缓存是优化不是权威,DB 帖子表才是真相。也可只对活跃用户预推缓存。
L3|“关注了 1000 人,其中 800 个是大 V,读时合并会不会很慢?” → 限制同时合并的大 V 数量(取最近活跃的 Top 50~100);对大 V 帖子做“热门摘要缓存”;或产品上限制关注大 V 数量。工程上大 V 帖子详情缓存命中率高,合并主要是 ID 归并,可接受。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能说出推和拉的区别 |
| 80 分 | 讲清写放大/读放大;给出混合模式与阈值 |
| 95 分 | feed 存 ID 策略、游标分页、MQ 异步+对账、降级拉模式、大 V 合并优化 |
【关联题】
- 上游: 第 126 题(关注粉丝)——fan-out 的关系依据
- 同构: 第 152 题(社区信息流)
- 对比: 第 127 题(热榜)——另一种内容组织方式
- 存储: 第 35 题(集群分片)——feed key 按用户分片
【自测】
- 判断对错:Feed 用推模式总比拉模式好,因为读更快。 参考答案: 错。大 V 千万粉丝写放大会打爆系统,必须混合或拉。
- feed 缓存为什么存帖子 ID 而不存正文? 参考答案: 正文可能变更/删除,存 ID 避免不一致与存储膨胀,详情独立缓存。
- 关注 1000 人时拉模式读路径是什么? 参考答案: 查关注列表 → 批量取各人最新帖 → 归并排序 → 游标分页返回。
129. 单聊/群聊/多端同步/消息不丢,IM 怎么设计(IM 系统)
【考察内容】IM 是系统设计经典大题
【题目】公司要做即时通讯(类钉钉):单聊、群聊(万人群)、在线状态展示、消息不丢不重、手机/电脑多端同步已读未读。长连接怎么管理?消息存储与同步(多端)怎么做?群消息扩散怎么写?
【参考答案】
- 架构:客户端 → 接入层(WebSocket 长连接集群,保持在线会话)→ 消息服务(路由/存储)→ 推送服务(离线推送 APNs/FCM);
- 在线状态:Redis 维护(userId→连接节点+心跳),在线列表/最后在线时间;
- 消息流:发送方 → 消息服务(写消息存储)→ 路由到接收方连接节点 → 长连接推送;接收方离线 → 离线消息存储 + 通知推送;
- 存储:消息表(msg_id 全局唯一(雪花)、会话 ID(单聊=双方 id 有序拼接;群聊=groupId)、from/to、content、时间),按会话分表;消息序号(seq):每会话递增,客户端按 seq 拉取增量(多端同步基础);
- 可靠性:消息必达——客户端 ack 机制(收到回执),超时重传(幂等:msg_id 去重);服务端持久化+客户端本地缓存;
- 多端同步:每端维护“已同步 seq”,登录后从服务端拉取缺失消息(增量同步);
- 群聊:群成员列表(Redis/DB)、消息写一份存储+广播在线成员(推送风暴:大群用“拉模式/分片推送”);
- 扩展:已读回执、消息撤回(发送“撤回指令”消息)、敏感词、端到端加密(可选);
- 容量估算(步进):假设同时在线 100 万连接,单机 WebSocket 承载 2~5 万连接 → 接入层约 20~50 台(100万÷5万=20,100万÷2万=50;按保守的 2 万/台取 50 台);消息峰值:日消息 2 亿条,高峰小时 30% → 约 1.5~2 万条/s 写入+路由。存储:2 亿/日 × 200B ≈ 40GB/日,按会话分表并冷热分层(热 7 天 Redis/SSD,冷进归档)。群聊广播:500 人普通群可推;万人超大群改拉模式,推送只发摘要。
- 失败与降级:接入节点宕机,客户端重连到其他节点并按本地 seq 补拉;消息服务写超时则本地持久化重发(客户端 outbox);离线推送通道(APNs/FCM)失败重试+降级为站内信;多端同步以服务端 seq 为准,冲突以服务端顺序覆盖;存储抖动时先保证在线投递,异步补持久化并告警。
【原理溯源】
- 为什么要 WebSocket 长连接而不是 HTTP 轮询? IM 要求消息秒级到达。HTTP 轮询延迟高(等于轮询间隔)、浪费带宽(大部分请求无新消息)、服务端无法主动推送。WebSocket 全双工、一条连接持续复用,服务端可在消息产生瞬间推送,延迟降到百毫秒级。代价是连接有状态,需要接入层会话路由。
- 为什么每会话要维护 seq(序号)? 多端同步、乱序重传、断线补拉都需要“消息全序”。没有 seq 时,客户端只能靠时间戳(不可靠,时钟漂移)或“最后一条 ID”(断线期间多条无法定位缺口)。每会话递增 seq 让客户端知道“我同步到哪了”,缺口一目了然,增量拉取天然幂等。
- 为什么消息必达要 ack + 幂等重传? 网络不可靠:推送可能丢。客户端收到后必须 ack;服务端超时未收到 ack 则重传。重传会导致重复,所以消费端按 msg_id 去重。这是“至少一次投递 + 消费端幂等”的经典组合,保证最终必达且不重复上屏。
- 万人群为什么要特殊处理? 一条消息写 N 份离线 + 广播 N 个在线连接,N=1 万时写放大与推送风暴都严重。手段:消息只存一份,群成员按需拉取(拉模式);或分片推送(按连接节点分批);在线广播合并、抽样(如只推最近活跃成员的摘要)。产品上万人群通常降级为“进入会话才拉历史”。
- 为什么单聊会话 ID 用“双方 ID 有序拼接”? 保证 A↔B 的会话 ID 唯一且与方向无关(
min(idA,idB)_max(idA,idB)),避免“我发给你”和“你发给我”变成两个会话,也便于按会话分表路由。
【选型判断树】
消息通道:
├─ 在线 → WebSocket 长连接直推
└─ 离线 → 离线消息存储 + APNs/FCM 推送
同步:
├─ 多端 → 每端记已同步 seq,登录增量拉
└─ 断线重连 → 从本地 seq+1 拉缺口
群聊:
├─ 小群(< 500)→ 写一份 + 广播在线
└─ 大群(> 1000)→ 拉模式 / 分片推送 / 抽样
可靠性:
ack + 超时重传 + msg_id 幂等判断口诀: 长连接管在线,seq 管同步,ack 管必达,大群拉模式。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “IM 核心是长连接、seq 同步、必达、大群扩散四件事” |
| 0:30–1:30 | 接入架构 | WebSocket 集群 + 会话路由 + 在线状态 Redis |
| 1:30–2:30 | 消息流与存储 | 发送→存储→路由推送;seq 递增;按会话分表 |
| 2:30–3:30 | 必达与多端 | ack+重传+幂等;每端已同步 seq 增量拉 |
| 3:30–4:30 | 群聊与扩展 | 大群拉/分片;已读回执;撤回指令消息 |
| 4:30–5:00 | 收尾 | “一句话:长连接保实时,seq 保有序可补,ack 保必达” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 心跳 | 30~60s | 掉线检测 |
| 消息 ack 超时 | 5~30s | 超时重传 |
| 单机长连接 | 2~5 万(保守规划值;压测好可到十万级) | 看内存与推送能力——按 10 万/台算只得 10 台,与第 9 条的 20~50 台对不上 |
| 大群阈值 | > 1000 人 | 转拉/分片 |
| 离线消息保留 | 7~30 天 | 按产品 |
| seq | 每会话单调递增 | 多端同步基石 |
| 分页拉历史 | 20~50 条/页 | 游标 seq |
| 在线连接估算 | 同时在线/单机承载 | 例:100万÷2~5万≈20~50 台 |
| 消息写入峰值 | 日消息×高峰占比 | 例:2亿×30%/3600≈1.7万/s |
| 消息存储增速 | 日消息×单条约200B | 约40GB/日,需冷热分层 |
【追问链】(三层)
L1|“消息乱序怎么处理?” → 服务端保证同会话 seq 递增;客户端按 seq 排序展示,缺口触发补拉。网络层乱序不影响上屏顺序。
L2|“用户手机在线、电脑也在线,消息怎么不重复不丢失?” → 服务端推给所有在线端;每端独立 ack 与已同步 seq。一端已读可同步已读位点到其他端(多端已读同步靠服务端统一的 read_seq)。
L3|“接入节点挂了,上面的长连接全断,怎么办?” → 客户端自动重连(退避)并路由到健康节点;重连后按 seq 补拉。服务端会话路由要能快速摘除故障节点;可配合多活接入。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道 WebSocket 长连接 + 消息表 |
| 80 分 | seq 增量同步、ack 必达、在线状态存储 |
| 95 分 | 大群拉/分片、多端已读同步、撤回实现、节点故障重连补拉、幂等重传 |
【关联题】
- 对比: 第 151 题(弹幕)——可丢失 vs 必达的架构取向
- 上游: 第 126 题(关注粉丝关系)——互关判断与关系列表的 Set/分片可直接复用;群成员列表与群扩散在本题第 7 条自管
- 存储: 第 153 题(延迟任务)——消息重试可复用
- 安全: 第 130 题(扫码登录)——IM 常配合登录体系
【自测】
- 判断对错:IM 用 HTTP 轮询完全可以,实现更简单。 参考答案: 错。轮询延迟高、浪费大,IM 必须长连接才能秒级推送。
- seq 的作用是什么? 参考答案: 会话内全序,支撑多端增量同步、断线补拉、乱序重排。
- 万人群一条消息为什么不能简单广播? 参考答案: 写放大与推送风暴;应存一份、拉模式或分片推送、必要时抽样。
130. 网页版要扫码登录,整个流程怎么闭环(扫码登录)
【考察内容】登录态与状态机设计
【题目】产品要求 PC 网页支持扫码登录:手机扫码→确认→网页自动进入登录态。二维码怎么生成与轮询?手机确认后如何通知网页?安全上怎么防二维码被冒用?请设计完整流程与状态机。
【参考答案】
- 核心流程(二维码承载的是“登录票据”):
- PC 生成二维码:服务端创建 login_ticket(唯一 ID+状态=待扫描+过期时间,存 Redis),二维码内容=票据 ID;
- PC 端轮询/长连接查询票据状态(或服务端推送到 PC 的长连接);
- 手机端扫码:App 读取票据 ID → 唤起确认页(显示设备信息)→ 用户确认 → App 调服务端“确认登录”接口(携带用户身份+票据 ID)→ 服务端更新票据状态=已确认,生成登录态(Token/Session)写入;
- PC 端轮询到“已确认”→ 拿到登录态 → 跳转登录完成;
- 状态机:待扫描 → 已扫描待确认 → 已确认/已取消/已过期;Redis key=login:{ticket},状态+手机端会话信息;
- 安全:
- 票据一次性(确认后即失效)、短过期(2~5 分钟);
- 防劫持:二维码携带随机数,手机确认时校验设备指纹/登录态;HTTPS;
- 轮询频控(如 1 秒 1 次);
- 替代方案:WebSocket 长连接推送状态(减少轮询);
- 扩展:扫码后免确认(可信设备直接登录)、扫码登录统计;
- 容量估算(步进):扫码登录是低频写、中频读——假设日登录 100 万次(扫码占 30%)→ 日票据约 30 万(全天均值仅 3.5/s),登录集中在上班时段按 5~10 倍峰均比 → 峰值约 18~35 QPS 生成(3.5×5~10);PC 轮询按 1s/次、人均扫码确认约 20s → 并发轮询可达 数百~数千 QPS,Redis 状态查询无压力。存储:票据 TTL 2~5 分钟,Redis 常驻量 = QPS×TTL ≈ 30×180 ≈ 5000~1 万 key,可忽略。
- 失败与降级:Redis 票据丢失/过期 → PC 提示二维码失效,引导刷新;手机确认接口超时 → 幂等重试(ticket+status 条件更新);轮询风暴时服务端对 ticket 维度限流;确认服务故障可降级为“短信验证码登录”兜底入口;Token 签发失败必须回滚票据状态或置为失败,避免 PC 卡死在轮询。
【原理溯源】
- 为什么二维码里只能放“票据 ID”而不能放用户信息? 二维码会被截图转发。若含用户凭证,拿到图的人即可登录。票据是服务端签发的一次性、短时、待确认的占位符,本身不携带权限;权限只在“手机端已登录用户确认”后才注入。这是把身份验证与凭证传递解耦。
- 为什么要状态机而不是直接写 Token? 扫码登录是异步多方协作(PC 生成、手机确认、PC 感知)。状态机让每一方只关心“当前状态能否迁移到我需要的态”,避免竞态:手机确认时必须检查仍是“已扫描”,PC 只在“已确认”时取 Token。没有状态机就会出现“过期码被确认”“二次确认”等问题。
- PC 怎么知道手机确认了? 两种:① 轮询票据状态(简单,1s 一次可接受);② PC 也持有一条长连接,确认后服务端推送。小规模用轮询足够,大规模/体验敏感用长连接。本质是“状态变更的通知通道”选择。
- 为什么要短过期 + 一次性? 限制攻击窗口:二维码被偷拍后,攻击者只有几分钟且必须在用户确认前使用;一旦确认,票据立即作废,重放无效。一次性是防重放的核心。
- 为什么要校验设备指纹/已登录态? 防止“把二维码贴到钓鱼页让用户扫”。手机 App 扫码后应展示目标站点信息,并要求用户已在 App 登录;服务端确认时校验手机会话有效,降低“未登录设备确认”的风险。
【选型判断树】
PC 如何感知确认?
├─ 规模小/体验可接受 → 轮询票据状态(1s)
└─ 规模大/要秒级 → WebSocket 推送
状态机:
待扫描 → 已扫描待确认 → 已确认 | 已取消 | 已过期
(非法迁移一律拒绝)
安全:
票据一次性 + 2~5 分钟过期 + 绑定用户 + HTTPS判断口诀: 码里只放票据,状态机管流转,确认才发 Token,一次性防重放。
【口述骨架】(5 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “扫码登录是票据状态机 + 异步通知问题” |
| 0:30–1:30 | 主流程 | 生成票据→二维码→手机确认→服务端发态→PC 感知 |
| 1:30–2:30 | 状态机 | 五态与非法迁移拒绝 |
| 2:30–3:30 | 通知机制 | 轮询 vs 长连接推送 |
| 3:30–4:30 | 安全 | 一次性、短过期、设备校验、防劫持 |
| 4:30–5:00 | 收尾 | “一句话:票据是占位符,确认才赋权,状态机管竞态” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 票据过期 | 2~5 分钟 | 过短体验差,过长风险高 |
| 轮询间隔 | 1s | 频控防刷 |
| 票据一次性 | 确认后即毁 | 防重放 |
| 状态数 | 5(待扫/已扫/确认/取消/过期) | 标准状态机 |
| Token 有效期 | 小时~天级 | 与普通登录一致 |
| 票据生成峰值 | 日扫码量×峰均比/86400 | 例:30万/日≈3.5/s,×5~10→约 18~35 QPS |
| 票据Redis常驻 | QPS×TTL秒数 | 数千~1万key,可忽略 |
| PC轮询频次 | 约1次/秒/票据 | 需按ticket限流防风暴 |
【追问链】(三层)
L1|“二维码被别人扫了怎么办?” → 票据未确认时只是占位;别人扫了若确认,需要该手机已登录且用户点确认。展示设备信息让用户知情;异常可风控。真正危险是“诱导用户扫攻击者的码”,所以要展示目标域名。
L2|“轮询和长连接怎么选?” → 轮询实现简单,1s 间隔对登录场景可接受;长连接实时省流量,适合高 QPS。可按规模升级。
L3|“用户扫了但一直不点确认,PC 端怎么办?” → 等待超时后票据过期,PC 提示刷新二维码。服务端定时清理过期票据。可展示“已扫描待确认”状态提升体验。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能描述扫码→确认→登录的大致流程 |
| 80 分 | 票据状态机、轮询/推送、一次性过期 |
| 95 分 | 安全细节(防劫持、设备指纹、重放)、票据一次性消费与短 TTL 依据、免确认扩展 |
【关联题】
【自测】
- 判断对错:二维码内容可以直接放用户 Token,扫码即登录。 参考答案: 错。二维码可被转发,必须放一次性票据,确认后才发 Token。
- 扫码登录的状态机有哪几个态? 参考答案: 待扫描 → 已扫描待确认 → 已确认/已取消/已过期。
- 为什么要短过期? 参考答案: 缩小攻击窗口,过期码无法被利用。
131. 电商下单到支付到发货,订单系统全貌(订单系统)
【考察内容】订单系统是电商领域最高频设计题
【题目】电商平台要做订单系统:用户下单→支付→发货→完成,中间可能取消/退款/超时关闭。订单状态机怎么设计?下单幂等怎么防重?超时未支付怎么自动关单?订单数据量大了怎么存储?
【参考答案】
- 下单流程:校验(用户/商品/价格)→ 生成订单(状态=待支付)→ 扣库存(预占/扣减)→ 返回订单号;支付回调更新状态;
- 状态机:待支付 → 已支付 → 已发货 → 已完成;取消/退款分支(已取消、已退款);非法流转禁止(已支付不能直接完成);
- 关键设计:
- 订单号:分布式 ID(雪花),订单表分库分表(按 user_id);
- 幂等:请求级幂等键(客户端幂等号或服务端预生成订单号)建唯一索引,重复请求返回首单;不能在订单表按「用户+商品」建唯一索引——那会挡住正常复购,它只适用于限购活动(user_id+sku+活动期);支付回调幂等(支付流水号唯一);
- 超时关单:MQ 延迟消息 + 定时扫表兜底(见延时任务题);
- 库存:下单预占(Redis 预扣+DB 条件更新),支付成功转正式扣减,超时释放;
- 金额:分存储,快照商品信息;
- 并发保障:下单走乐观锁/Redis 预扣防超卖;「一人一单」只在限购活动成立(唯一键=user_id+sku+活动期),不能当通用并发保障——本条第 3 项刚论证过按「用户+商品」建唯一索引会挡住正常复购;
- 一致性:订单-支付-库存跨服务用“事务消息+对账”(最终一致);支付成功事件驱动发货;
- 扩展:拆单(多仓)、订单列表分页(user_id+create_time 索引+游标)、售后流程;
- 容量估算(步进):假设日订单 50 万单,大促峰值系数 20~50 → 下单峰值 100~300 TPS(极端秒杀另走独立链路);订单表 50 万/日 × 500B ≈ 250MB/日,一年约 100GB,按 user_id 分 16~64 库/表。支付回调峰值与下单同量级;超时关单扫描量 = 活跃待支付订单 × 扫描频率,MQ 延迟消息承担主路径,扫表仅兜底(分钟级、分页)。
- 失败与降级:创建订单时库存服务超时 → 快速失败,禁止“无库存快照落单”;支付成功但订单更新失败 → 状态对账补偿任务扫描;DB 主库故障读从库可能读到旧状态,支付回调必须写主库;大促时订单列表可降级(只展示近 N 个月),详情仍保证可查。
【原理溯源】
- 为什么订单必须是状态机而不是随意 UPDATE? 订单生命周期有多条合法路径(正常、取消、退款、超时)。没有状态机约束时,非法更新(如已取消直接变已发货)会导致资损与脏数据。状态机把“允许的迁移”编码成规则,每次更新校验
current_status与目标态,从模型层杜绝非法流转。 - 为什么要库存预占而不是支付时才扣? 支付有延迟(用户可能不付),若支付时才扣,会出现“超卖后支付失败”的补偿复杂问题。预占(下单锁库存)→ 支付确认扣减 / 超时释放,把冲突前置到下单瞬间,用条件更新保证不超卖。Redis 预扣扛并发,DB 条件更新兜底。
- 超时关单为什么要“延迟消息 + 扫表”双保险? 延迟消息(RocketMQ/RabbitMQ)到点触发,精度高;但消息可能丢。扫表兜底扫描“超时仍待支付”的订单,保证最终关闭。双保险是“高精度通道 + 最终一致兜底”的标准组合。
- 为什么支付回调必须幂等? 渠道可能重发回调(网络超时重试)。不幂等会重复入账、重复发货。用渠道流水号唯一索引 + 状态机(已支付则直接返回成功)保证只处理一次。
- 为什么订单要按 user_id 分库分表? 订单查询绝大多数是“我的订单列表”,按 user_id 路由让单用户订单在同一分片,列表查询无需跨库。全局查(运营)走离线/ES。
【选型判断树】
下单链路:
校验 → 生成订单(待支付)→ 库存预占 → 返回订单号
支付回调(幂等)→ 已支付 → 发货 → 完成
超时:
MQ 延迟消息关单 + 定时扫表兜底
库存:
Redis 预扣(并发)+ DB 条件更新(正确)
支付成功转正式扣减;超时释放
分表:按 user_id
幂等:下单唯一索引 + 回调流水号唯一判断口诀: 状态机管流转,预占管库存,双保险管超时,幂等管重复。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “订单核心是状态机 + 幂等 + 库存预占 + 超时关单” |
| 0:30–1:30 | 状态机 | 主路径与分支;非法迁移禁止 |
| 1:30–2:30 | 下单与库存 | 预占/正式扣;Redis+DB 双层 |
| 2:30–3:30 | 幂等与超时 | 唯一索引;延迟消息+扫表 |
| 3:30–4:30 | 一致性与分表 | 最终一致+对账;user_id 分片;游标分页 |
| 4:30–5:00 | 收尾 | “一句话:状态机保正确,预占保不超卖,双保险保超时,幂等保不重复” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 支付超时 | 15~30 分钟 | 电商常见 |
| 库存预占超时 | 与支付超时一致 | 超时释放 |
| 延迟消息精度 | 秒级 | 主通道 |
| 扫表兜底 | 分钟级 | 最终一致 |
| 分表键 | user_id | 列表查询 |
| 分页 | 游标 | 避免深分页 |
| 金额 | 整数分 | 避免浮点 |
| 下单峰值 | 日订单×大促系数/折算 | 例:50万×20~50→约100~300TPS |
| 订单存储增速 | 日订单×单行约500B | 约250MB/日,按uid分表 |
【追问链】(三层)
L1|“为什么支付回调要幂等?” → 渠道重发是常态;不幂等会重复入账。用渠道流水号唯一索引 + 状态机短路。
L2|“库存预占和 DB 扣减怎么配合?” → 下单 Redis 预扣扛并发,DB 条件更新保正确;支付成功转正式扣减;超时/取消释放预占。对账修正偏差。
L3|“订单表上亿行,列表页怎么保证快?” → 按 user_id 分库分表,单用户订单集中;联合索引 (user_id, create_time);游标分页;热用户订单可缓存。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 能描述下单支付发货流程 |
| 80 分 | 状态机、幂等、超时关单、库存预占 |
| 95 分 | 双保险超时、分表与游标、跨服务最终一致+对账、金额快照、拆单 |
【关联题】
- 上游: 第 153 题(延迟任务)——超时关单依赖
- 同簇: 第 132 题(支付)、第 133 题(对账)
- 库存: 第 134 题(优惠券)、第 157 题(预约资格)
- 分布式: 第 99 题(分布式事务)
【自测】
- 判断对错:订单状态可以直接从“待支付”改成“已完成”。 参考答案: 错。必须经过已支付、已发货等合法迁移,状态机拦截非法流转。
- 为什么库存要预占? 参考答案: 把超卖冲突前置到下单,支付失败/超时再释放,避免支付时才发现库存不足。
- 超时关单为什么用双保险? 参考答案: 延迟消息精度高但可能丢;扫表保证最终关闭。
132. 余额支付,钱不能多一分少一分(支付系统)
【考察内容】支付系统是金融/电商最高频设计题
【题目】产品要做钱包余额支付:用户充值、消费、退款。要求:钱一分不能多一分不能少,并发扣款不能超扣,每笔钱都有流水可查,账要平。账户模型、流水设计、并发控制、日终对账怎么设计?
【参考答案】
核心原则:账务系统不能“改余额”,只能“记流水”(流水驱动:余额是流水的物化结果。注意本方案是单账户流水,严格的复式记账需为平台/渠道建对手账户并要求 Σ借=Σ贷,单侧流水只能靠日终对账兜住不平);
核心表:
- 账户表(user_id、balance,供查询);余额 = Σ流水(可定期对账);
- 流水表(流水号唯一、user_id、方向(+/-)、金额(分)、关联业务单号、状态、create_time)——追加写,不更新;
- 支付单表(支付单号、订单号、金额、渠道、状态机:待支付→支付中→成功/失败/退款);
支付流程:创建支付单 → 调渠道(微信/支付宝)→ 异步回调(幂等:渠道流水号唯一索引)→ 记账(加流水+更新余额,同一本地事务)→ 通知业务方(MQ 事件); 充值(题干三个资金动作之一):充值单 → 渠道支付 → 回调验签+金额比对 → 加一条方向为「+」的流水与更新余额放同一本地事务,幂等键=渠道流水号;未到账走查单补偿;禁止前端直接加余额。
一致性保障:
- 幂等:支付单号/渠道流水号唯一约束,重复回调只处理一次;
- 金额校验:回调金额与支付单金额比对(防止篡改);签名验证;
- 对账:每日与渠道对账文件比对(我方支付单 vs 渠道流水),差异人工/自动处理;
- 退款:原路退回(复用原支付单)、退款单独立+幂等;
安全:金额用分(整数)、并发扣款用行锁/乐观锁(余额>=金额 条件更新)、审计日志、敏感操作风控;
高可用:账务是核心链路——限流、幂等、数据库高可用、对账兜底;账户分片(按 user_id);
容量估算(步进):假设电商日支付成功 30 万笔,峰值系数 10 → 支付回调/记账峰值约 30~50 TPS,账务库完全可扛;若含红包/余额高频场景可达数百 TPS,仍远低于互联网 C 端峰值,重点是正确性而非纯吞吐。存储:流水 30 万/日 × 200B ≈ 60MB/日,账户表按 user_id 分片;渠道对账文件日增 MB 级。带宽与 RT 敏感点在第三方渠道(外部 RT 数百 ms),必须异步化回调,不能同步等渠道。
失败与降级:渠道超时 → 支付单置“支付中”,靠回调/查单收敛,禁止本地直接判失败后用户重复付;回调处理失败 → 渠道重试+我方查单补偿;DB 记账失败但回调已收 → 本地消息表/事务消息保证最终入账;风控误杀可人工放行;限流优先保支付与回调,营销扣款类可排队。
【原理溯源】
- 为什么不能直接 UPDATE balance? 直接改余额会丢历史、无法审计、并发下易超扣,且出错后无法回溯。流水驱动模型把余额降级为“流水的物化视图”,任何变动都有不可变流水记录,对账可从流水重算。这是会计学「有借必有贷」思路的简化落地:本方案是单账户流水,严格复式记账要为平台/渠道建对手账户并要求 Σ借=Σ贷,单侧流水只能靠日终对账兜住不平(见第 1 条)。
- 为什么金额要用整数分? 浮点数有精度误差(0.1+0.2≠0.3),资金场景一分都不能差。用分作单位,全部整数运算,杜绝精度问题。展示时再除 100。
- 并发扣款为什么要条件更新而不是先查后改? 先查后改在并发下会超扣:两请求同时读到余额 100,各扣 80。条件更新
UPDATE ... SET balance=balance-80 WHERE user_id=? AND balance>=80让数据库原子判断+更新,影响行数为 0 即余额不足。 - 支付回调为什么要验签+金额比对? 回调来自公网,可能被伪造或篡改。验签保证来自渠道;金额比对保证与我方支付单一致,防止“付 1 元回调说付 1000”类攻击。
- 为什么要每日对账? 网络、回调、重试都可能造成最终不一致(掉单、重复、金额差)。对账是发现这些问题的最后防线,把“运行时不确定”收敛到“日终确定”。资金场景对账不是可选项。
【选型判断树】
账户怎么建?
流水表(追加写)+ 账户表(物化余额)+ 支付单(状态机)
扣款:
条件更新 balance>=amount,禁止先查后改
回调:
验签 + 金额比对 + 流水号幂等 → 记账(流水+余额同一事务)
对账:
日终我方流水 vs 渠道账单 → 差异分类 → 人工/自动判断口诀: 流水驱动、余额只读、条件更新、回调幂等、日终对账。
【口述骨架】(5 分钟;框架 B,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “支付核心是流水驱动的账务模型 + 幂等 + 对账” |
| 0:30–1:30 | 账户模型 | 流水表追加写;账户余额物化;支付单状态机 |
| 1:30–2:30 | 支付流程 | 创建→调渠道→回调→记账→通知 |
| 2:30–3:30 | 并发与安全 | 条件更新;金额分;验签;幂等 |
| 3:30–4:30 | 对账与退款 | 日终对账;原路退回;差异处理 |
| 4:30–5:00 | 收尾 | “一句话:钱不可直接改,只能记账,账平靠对账” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 金额单位 | 分(整数) | 杜绝浮点误差 |
| 回调幂等键 | 渠道流水号唯一 | 防重复入账 |
| 对账周期 | T+1 日终 | 资金类可 T+0 |
| 退款 | 原路、独立退款单 | 幂等 |
| 账户分片 | user_id | 水平扩展 |
| 审计 | 全流水留痕 | 可追溯 |
| 支付峰值 | 日支付×峰值系数/折算 | 例:30万×10→约30~50TPS |
| 流水存储增速 | 日支付×单行约200B | 约60MB/日 |
| 渠道外部RT | 数百ms级 | 必须异步回调,勿同步等待 |
【追问链】(三层)
L1|“为什么余额不能直接改?” → 丢历史、无法审计、并发不安全。流水驱动让每笔变动可追溯,余额可重算。
L2|“并发扣款怎么防超扣?” → WHERE balance >= amount 条件更新,数据库原子保证;或行锁/乐观锁。禁止先 SELECT 再 UPDATE。
L3|“对账发现差 1 分钱怎么办?” → 绝不自动改账。进差异池:分类(掉单/重复/金额差),查日志与渠道核对,人工或规则化补偿。资金安全优先于自动修复。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道要记流水、防重复 |
| 80 分 | 流水驱动模型、条件更新、回调幂等 |
| 95 分 | 验签与金额校验、日终对账闭环、退款设计、审计与风控 |
【关联题】
【自测】
- 判断对错:余额支付可以直接
UPDATE balance = balance - 100。 参考答案: 错。必须流水驱动,条件更新防超扣,且可审计。 - 金额为什么用分? 参考答案: 浮点精度问题,资金场景必须整数。
- 支付回调重复怎么办? 参考答案: 渠道流水号唯一索引 + 状态机短路,只处理一次。
133. 我方支付记录和银行结算单对不上(对账系统)
【考察内容】对账系统是支付/金融岗高频题
【题目】每天凌晨银行会回传结算文件,和系统内的支付记录经常对不上(掉单、金额差、状态不一致)。对账系统怎么设计?对账流程(文件拉取→核对→差异分类→自动处理)怎么走?差异怎么处理?
【参考答案】
- 对账流程(日终批量):
数据准备:渠道侧下载结算文件(对账单:交易流水、金额、手续费);我方导出当日支付流水;
比对:按“唯一键(渠道交易号/我方支付单号)+金额+状态”逐笔匹配,分为:双方一致(通过)、我方有渠道无(渠道漏单/在途)、渠道有我方无(我方漏记/回调丢失)、金额不一致(差异);
差异处理: 差错处理完成后要回到账务层:新增流水与更新余额放同一本地事务,使「账户余额恒等于 Σ流水」始终成立——这一步与第 132 题的账务模型闭环,否则对账只做到渠道↔支付单,账户层仍然可能是错的。
- 在途/延迟(23:55~00:05 边缘交易):入缓冲池,次日(T+2)再核对;
- 渠道有我方无:主动向渠道查单(查单接口),确认后补记/入账;
- 我方有渠道无:人工核查(可能渠道退款/撤销);
- 金额不一致:告警人工介入,绝不自动调整(资金安全);
对账报告:差异分类汇总、金额核对(总额=明细和)、生成报表;
- 架构:定时任务(日终)→ 拉取文件 → 解析 → 流式/分批比对 → 差异库 → 查单补偿 → 人工处理平台;
- 进阶:实时对账(流式比对:每笔支付完成即与渠道结果比对,提前发现异常);
- 保障:对账是“最终一致的最后防线”,必须自动化+告警+人工工单闭环;
- 容量估算(步进):假设日支付 30 万笔,对账文件约 30 万行 × 200B ≈ 60MB/日,解析+比对单机分钟级可完成;差异率经验通常 0.01%~0.1%(数十~数百笔/日),必须有人工工单容量。存储:差异记录+报告按会计档案与交易资料口径保留——银行对账单类按《会计档案管理办法》至少 10 年,客户交易信息按《反洗钱法》第三十四条自交易结束后至少 10 年;《网络安全法》的「不少于六个月」只针对网络日志,别当对账档案的下限,日增很小。实时对账则每笔支付完成后订阅 MQ 与渠道结果比对,峰值与支付 TPS 同量级。
- 数据与接口(口述可带):差异单字段 =
{biz_no, channel_no, diff_type, our_amount, channel_amount, status, owner};接口POST /recon/run?date=、GET /recon/diffs?date=&type=、POST /recon/diff/{id}/handle。失败与降级:渠道文件拉取失败自动重试并告警,T+1 文件缺失则挂起当日对账而非空跑;解析坏行进死信人工;查单接口超时任务延后重试;对账任务自身失败必须告警到值班,禁止静默跳过。
【原理溯源】
为什么对账是必须的而不是可选的? 分布式系统中,我方状态与渠道状态通过不可靠网络同步,掉单、延迟、重复、篡改都可能发生。运行时机制(幂等、重试)降低概率但无法归零。对账用“双方独立记录事后核对”发现残余不一致,是最终一致的最后防线。没有对账的支付系统等于没有安全网。
为什么要有“缓冲池”处理跨日交易? 日切点附近的交易(23:59 下单,00:01 回调)在我方记在 T 日、渠道记在 T+1 日,直接比对会误报。缓冲池让边缘交易再等一天,T+2 仍不一致才算真差异。这是用时间换准确率。
为什么金额不一致绝不自动调整? 资金差异可能来自欺诈、篡改、严重 bug。自动调账会掩盖问题甚至扩大资损。正确做法是告警+人工,查清根因后再修复。资金场景的自动化边界必须保守。
为什么要“渠道有我方无”主动查单? 可能是回调丢失导致我方漏记。查单接口向渠道要真相,确认支付成功则补记入账。这比等用户投诉快得多。
为什么还要实时对账? 日终对账延迟 24h,大额异常发现太晚。实时对账(支付完成即比对)可把发现时间从 T+1 压到分钟级,适合核心资金链路。日终仍是全量兜底。
【选型判断树】
对不上怎么分类?
├─ 双方一致 → 通过
├─ 我方有渠道无 → 人工核查(可能渠道撤销)
├─ 渠道有我方无 → 查单接口确认 → 补记
├─ 金额不一致 → 告警人工,绝不自动改
└─ 跨日边缘 → 缓冲池 T+2 再对
架构:
定时拉文件 → 解析 → 分批比对 → 差异库 → 补偿/人工
进阶:实时流式比对判断口诀: 四类差异、缓冲池跨日、查单补偿、金额差人工。
【口述骨架】(5 分钟;框架 A,骨架共性见文首第二节,本题只写特定内容)
| 时间 | 任务 | 内容 |
|---|---|---|
| 0:00–0:30 | 定性 | “对账是支付系统的最终一致最后防线” |
| 0:30–1:30 | 流程 | 拉文件→导流水→逐笔比对→差异分类 |
| 1:30–2:30 | 差异处理 | 四类差异的不同策略;缓冲池;查单 |
| 2:30–3:30 | 架构 | 定时任务+差异库+人工平台 |
| 3:30–4:30 | 实时对账与原则 | 分钟级发现;金额差绝不自动改 |
| 4:30–5:00 | 收尾 | “一句话:对账发现残余不一致,处理要保守” |
【关键数字】
| 参数 | 经验值 | 说明 |
|---|---|---|
| 对账周期 | 日终 T+1 | 可加实时 |
| 缓冲池 | 跨日 1~2 天 | 防误报 |
| 差异类型 | 4 类 | 一致/我方多/渠道多/金额差 |
| 查单 | 渠道 API | 补记依据 |
| 金额差 | 人工 | 绝不自动 |
| 对账文件规模 | 日支付笔数×约200B | 例:30万行≈60MB/日 |
| 差异率经验 | 0.01%~0.1% | 日数十~数百笔,需人工容量 |
| 对账档案保留 | ≥10 年(会计档案/客户交易信息口径),网络日志另按 ≥6 个月 | 差异单与报告长期可查 |
【追问链】(三层)
L1|“为什么需要对账?” → 网络/回调不可靠,运行时机制无法归零不一致;对账用双方独立记录事后核对,是最后防线。
L2|“跨日交易怎么避免误报?” → 缓冲池,T+2 再核对;或按时间窗模糊匹配。
L3|“对账发现大量掉单,系统性问题怎么处理?” → 立即告警,查回调链路/查单接口/消息积压;批量补记走人工审核;根因修复后补跑对账。建立差异率监控,超过阈值熔断相关业务。
【评分标准】
| 档位 | 答案特征 |
|---|---|
| 60 分 | 知道要和渠道对账 |
| 80 分 | 四类差异、缓冲池、查单补偿 |
| 95 分 | 金额差人工原则、实时对账、差异率监控与熔断、与账务模型呼应 |
【关联题】
【自测】
- 判断对错:对账发现金额差,应自动调整账目保证平。 参考答案: 错。资金差异必须人工核查,自动调整会掩盖欺诈与严重 bug。
- 缓冲池解决什么问题? 参考答案: 跨日边缘交易的时间归属差,避免误报。
- “渠道有我方无”怎么处理? 参考答案: 调渠道查单接口确认,成功则补记入账。