Skip to content

模块导航: 总入口 · 总纲与方法 · 后端场景 · AI与算法工程 · 国企与金融 · 网络问答


第六章 经典系统设计(第123-162题) ​

本章导览(元问题与表面主题逐字取自 《核心考点与元理论》§六)

  • 本章真正考的那一个问题: 把领域语义翻译成读写路径与状态机,代价花在哪?
  • 表面主题: Feed、IM、支付、网关…
  • 题号与题量: 第 123–162 题,共 40 题;刷题时不要按知识点记,要按上面那一个元问题记。

123. 短信营销链接太长被截断,要生成短链接(短链系统) ​

【考察内容】短链是系统设计入门必考题

【题目】运营要在短信里放推广链接,但原始 URL 太长(带一堆参数),短信会被截断。需要做短链服务:长 URL 生成短码、访问时跳转还原。要求:短码不能重复、支持高并发跳转(读多写少)、要能统计点击。怎么设计?

【参考答案】

  1. 需求分析:读(跳转)远高于写(生成),要求低延迟(毫秒级)、高可用、防冲突;
  2. 短码生成方案:
    • 哈希截断:MD5/MurmurHash 长链取前 6~8 位(62 进制:大小写字母+数字),冲突检测+追加/重哈希;简单但需查重;
    • 发号器(推荐):分布式 ID(号段/雪花)转 62 进制得到短码——唯一、无冲突、趋势递增;
    • 自定义短码(运营指定):数据库唯一索引兜底;
  3. 存储:MySQL 表(short_code 唯一索引、long_url、过期时间、创建时间);读写比例高——Redis 缓存短码→长链映射(TTL 设长);
  4. 跳转流程:用户访问短链 → 网关/服务查 Redis → 命中 302 跳转;未命中查 DB → 回填缓存 → 302 跳转;
  5. 扩展:过期清理(惰性删除+定时任务)、访问统计(异步记录)、防恶意生成(限流+黑白名单)、长链去重(同一长链复用短码);
  6. 容量:短码 6 位=568 亿组合,足够;单表千万级可分表(按短码哈希)——但去重会跨片失效:long_url_hash 的唯一索引只在单个分片内生效,主表按短码哈希后,同一条长链的两次请求会落到不同分片各生成一个短码,去重方案的「生成前先查」还要广播全部 64 张表。正解是另建一张以 long_url_hash 为分片键的映射表(或独立去重表/Redis Set)专门承担去重,主表才按短码哈希分片;
  7. 容量估算(步进):假设日点击 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,网关层无压力;
  8. 失败与降级: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 条)。
  • 为什么要过期清理? 营销短链有生命周期(活动结束),不过期会让表无限膨胀、陈旧链接指向失效活动。清理用“惰性删除(访问时发现过期即删)+ 定时扫描”,避免大事务批量删。

【选型判断树】

text
短码怎么生成?
├─ 能接受“查重+重试”吗?
│   ├─ 不能(高并发生成、强唯一)→ 发号器:号段/雪花 → 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统计场景必须 302301 会被浏览器缓存导致丢统计
单表容量千万级考虑分表主表按 short_code 哈希;去重表要按 long_url_hash 分片,否则唯一索引跨片失效
点击统计异步 MQ,允许秒级延迟不阻塞跳转主路径
读峰值估算日点击×峰值系数/86400例:5000万日点击→约6000QPS
写峰值日生成量/日秒数×峰值日50万生成→约50QPS
映射存储条数×单行约200B5亿条约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 去重的另一解(布隆)

【自测】

  1. 判断对错:短链跳转用 301 更好,因为永久重定向更快。 参考答案: 错。301 会被客户端缓存,后续访问不再打到服务端,点击统计会丢;需要统计的场景必须 302。
  2. 6 位 62 进制短码大约有多少组合?哈希生成时要注意什么? 参考答案: 62⁶ ≈ 568 亿。哈希压缩有冲突概率,必须做冲突检测与重试,或改用发号器。
  3. 为什么短链映射缓存 TTL 可以很长? 参考答案: 映射一旦生成几乎不变(读多写少),变更极少;长 TTL 可最大化命中率,过期用惰性删除+定时任务兜底。

124. 视频点赞量级千万,点赞/取消/计数怎么做(点赞系统) ​

【考察内容】点赞系统是高并发设计最高频题

【题目】视频 App 要做点赞功能:用户点赞/取消、展示总点赞数、判断我是否点过赞、查看点赞用户列表。单条视频可能被点几百万赞,热点视频点赞瞬时并发高。存储与计数怎么设计?

【参考答案】

  1. 需求拆解:①用户点赞/取消(写);②作品点赞总数(高并发读);③某用户是否点赞过(查状态);④用户点赞列表;
  2. 存储设计:
    • Redis:作品点赞数用 String INCR/DECR(或 Hash 字段);“谁点过赞”用 Set(sadd/srem,支持交集计算);用户维度点赞列表用 ZSet(score=时间,可排序);
    • DB 落库:点赞明细表(user_id, target_id, create_time,唯一索引 user_id+target_id 防重复点赞);点赞计数表(target_id, count);
  3. 一致性:点赞先写 Redis(实时反映)→ 异步落库(MQ 消费,批量合并更新计数,DB 为准)→ 对账兜底(Redis 计数与 DB 计数核对);
  4. 热点处理:大 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 只作热态加速;
  5. 防重复/防刷:唯一索引防重复点赞;频控限流(同用户短时间内重复操作合并);
  6. 扩展:评论/转发/浏览计数同架构复用(通用计数服务);
  7. 容量估算(步进):假设 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;
  8. 失败与降级:Redis 计数/状态不可用时降级读 DB 计数表(限流),写侧本地缓冲重试;MQ 持续积压则暂停非核心计数、优先保点赞明细;对账发现 Redis 与 DB 偏差超阈值时以明细重算为准并告警;展示允许秒级不一致,结算/风控场景走权威路径。

【原理溯源】

  • 为什么点赞写路径必须走 Redis 而不是直接写 DB? 热点视频瞬时点赞可到数千 QPS(单视频不可能高于第 7 条算出的全站写峰值 5000~1 万),MySQL 单行 UPDATE count=count+1 会形成行锁热点,RT 飙升甚至拖垮实例。Redis INCR 是单线程内存原子操作,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 的 SISMEMBER O(1),且天然支持“共同好友点赞”这类交集运算。DB 侧仍保留唯一索引作为最终防线。
  • 取消点赞怎么保证不把计数减成负数? 先 SISMEMBER 判断是否已赞,再 SREM+DECR;DB 侧用“明细存在才删+计数减”的条件更新。更稳的做法是把“赞/取消”都当成事件,消费端按明细对账重算计数,而不是盲目 INCR/DECR。

【选型判断树】

text
点赞功能怎么拆?
├─ 写(赞/取消)
│   → 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 INCR10 万+ 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 题(社区系统)——点赞是其中子模块

【自测】

  1. 判断对错:点赞数可以直接 UPDATE like_count = like_count + 1 写 MySQL,热点也没问题。 参考答案: 错。热点行锁会成为瓶颈,必须 Redis 扛写、DB 异步落账。
  2. “谁点过赞”用什么结构?为什么不用每次查 DB? 参考答案: 中小体量用 Redis Set(SISMEMBER O(1)),列表页批量判断查 DB 会放大 N 倍压力;但题干预设单条几百万赞时,作品维 Set 本身就是 5e6 成员的大 Key(SMEMBERS/DEL/主从同步都会阻塞),要按 liked:{vid}:{uid%K} 分片或只保留近 N 天热态,全量「谁点过赞」以 DB 明细表为准。
  3. 热点计数为什么要拆成多个 key? 参考答案: 避免单 key/单 slot 串行瓶颈;写随机分片、读 SUM,用聚合换并行。

125. 评论区一级/二级评论、热评、分页,怎么支撑(评论系统) ​

【考察内容】内容社区核心模块设计

【题目】内容平台要上线评论功能:支持一级/二级评论(楼中楼)、评论分页(按时间/热度)、热门评论置顶、评论计数。日活千万,热点内容评论区瞬时流量大。表结构、分页方案、热评排序怎么设计?

【参考答案】

  1. 需求:发表评论、评论列表分页(按时间/热度)、楼中楼(二级评论)、评论数统计、删除/审核;
  2. 存储:
    • 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 排序字段或单独热评缓存;

  3. 列表读取:评论列表先查一级评论(分页),再批量查二级评论(按 root_id IN 查询,组装树)——避免 N+1;或用冗余字段(一级评论的二级评论缓存成 JSON 片段);
  4. 缓存:热帖评论列表缓存 Redis(分页缓存/整页缓存+失效);评论计数缓存(INCR);
  5. 写路径:评论先写 DB(事务),再更新缓存/计数(异步);审核状态(先审后发或先发后审);
  6. 高并发(热帖):评论写入用 MQ 削峰;列表读走缓存;评论按页拆分 key(comments:postId:page),避免单 key 过大;
  7. 扩展:敏感词过滤(写前校验)、排序策略(热度=点赞+时间衰减)、楼中楼深度限制(如最多两级);
  8. 容量估算(步进):假设 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;
  9. 失败与降级: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 把写解耦成“接收极快、消费按节奏”,配合敏感词过滤在消费端做,用户侧可先返回“提交成功、稍后展示”。

【选型判断树】

text
评论列表怎么读?
├─ 普通帖
│   → 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 条回复其余点开再拉
热帖缓存 TTL10s~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、审核策略、深度限制原因、删除事件联动

【关联题】

  • 上游: 第 124 题(点赞)——评论点赞复用计数架构
  • 综合: 第 152 题(社区系统)——评论是子模块
  • 同构: 第 128 题(Feed)——列表分页与缓存策略相似

【自测】

  1. 判断对错:二级评论用 parent_id 递归查询即可,不需要 root_id。 参考答案: 错。递归深度不可控且无法批量取子树;冗余 root_id 才能固定两次平铺查询。
  2. 热帖评论缓存为什么要按页拆 key? 参考答案: 避免单 key 过大(大 Key)和整键失效;新评论只失效相关页。
  3. 热度排序为什么不能只用 like_count? 参考答案: 旧评论会永久霸榜;必须叠加时间衰减,让新内容有机会上来。

126. 关注列表/粉丝列表/互相关注判断,关系链怎么做(关注粉丝系统) ​

【考察内容】关系链数据建模

【题目】社交 App 需要关注关系:用户关注/取关别人、查看我的关注列表/粉丝列表、判断 A 和 B 是否互相关注。大 V 粉丝千万级。存储模型(关注关系怎么存)、分页、互相关注判断怎么设计?

【参考答案】

  1. 数据结构:两套 Set——关注表(user:{uid}:following 存关注的人)、粉丝表(user:{uid}:follower 存粉丝);Redis Set 天然支持交集/并集(共同关注、互相关注);
  2. 操作:关注=A 的 following 加 B + B 的 follower 加 A(两步,可放 Lua/事务);取关反向;判断 A、B 是否互相关注:SINTER user:A:following user:A:follower 后看 B 在不在结果里,等价于两次 SISMEMBER(B∈A.following 且 A∈B.following);
  3. 存储分层:Redis Set 承担高频读写(关注列表、粉丝数 SADD/SCARD);DB 落库关注关系表(user_id, follow_id, create_time,唯一索引,分表按 user_id);
  4. 粉丝数:Redis 计数(SCARD/单独计数 key)+ 异步落库(对账);
  5. 热点:大 V 粉丝千万级——Set 元素多是大 key:粉丝列表拆 hash 分片(follower:{uid}:{0~N})或只存“粉丝数+精选列表”,全量粉丝走 DB;
  6. 扩展:关注 Feed 联动(发帖推送给粉丝——见 Feed 流题);僵尸粉清洗、批量关注限制(频控);
  7. 容量估算(步进):假设 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;粉丝数展示被前端短缓存,回源可控。
  8. 失败与降级:双写一半失败时以 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 异步落账 + 对账。真正需要精确的是结算/风控场景,那些再走权威路径。

【选型判断树】

text
关系怎么存?
├─ 双向都要高频查 → 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)
关系明细存储行数×约40B10亿行约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)

【自测】

  1. 判断对错:只存 following,粉丝列表用 SQL 查 follow_id 即可。 参考答案: 错。大 V 会全表扫,必须冗余 follower 或接受慢查询。
  2. 互相关注怎么判断? 参考答案: SINTER user:A:following user:A:follower 看 B 是否命中,或直接两次 SISMEMBER(B∈A.following 且 A∈B.following)。A.following ∩ B.following 得到的是共同关注,不是互关。
  3. 大 V 粉丝为什么不能全量放一个 Set? 参考答案: 大 Key:内存、网络、主从同步风险;应分片或只存计数+精选,全量走 DB。

127. 热搜榜单实时更新、去重、排序,怎么算出来的(热榜系统) ​

【考察内容】实时统计与 TopN 设计

【题目】平台要上线热榜:根据用户行为(搜索/点击/讨论量)实时计算热门话题,榜单分钟级更新、要去重合并(同一事件不同词条要归并)、要防刷。数据怎么采集、热度分怎么算、榜单怎么存储与推送?

【参考答案】

  1. 需求:统计单位时间(分钟/小时)内关键词/视频的热度,实时 TopN,防刷;
  2. 热度计算:score = 权重组合(搜索量×系数+讨论量×系数+原创加权),时间衰减(score 随时间衰减,如 score/(1+age^1.2)),用 ZSet 的 score 表达;
  3. 架构:
    • 埋点:搜索/点击/转发行为上报(MQ 异步);
    • 聚合:Flink/Spark 流式计算或定时任务(分钟级)聚合关键词计数 → 写 Redis ZSet(key=hot:{date}:{hour});
    • 读取与推送(题干问「怎么推送」,拉与推要分开答):拉=ZREVRANGE 取 TopN,缓存榜单 JSON(短 TTL,如 10s),读多写少用缓存兜底;推=榜单是「整体快照」而不是逐条事件,所以不做逐条下发——榜更新后只推一个「榜单已更新,请拉一次」的轻量 diff 信号(长连接只给小范围活跃用户),其余客户端按「版本号+轮询(周期≈更新间隔)」获取;把整榜逐条推给百万连接是最常见的错法;
  4. 去重与合并:同义词/近义词合并(“王宝强”与“王宝强 电影”合并);重复条目按实体归一;
  5. 防刷:同一 IP/用户加权降权、频控、机器识别(无行为特征的刷量剔除);
  6. 扩展:地域榜/分类榜(分 key)、新旧榜切换(榜单 key 按时间滚动)、历史榜单归档查询;
  7. 容量估算(步进):假设 DAU 2000 万,搜索/点击行为约 30 次/人日 → 日行为事件约 6 亿,埋点峰值按 20% 在高峰小时 → 上报峰值约 3~5 万条/s(MQ 削峰,流式聚合消费)。热榜存储:ZSet 只保留 Top 200~500 条,单 key 内存极小;历史榜按 {date}:{hour} 滚动,30 天 × 24 key 可归档到 DB/对象存储。读:热榜接口首页强曝光,峰值可 数万 QPS,10s JSON 缓存 + 本地缓存后回源 Redis 可忽略。
  8. 数据与接口(口述可带):榜单项字段 = {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 延迟,对热榜可接受。

【选型判断树】

text
热度怎么算?
├─ 实时性要求高(秒级)→ 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 必须准确的榜

【自测】

  1. 判断对错:热榜只需累计搜索次数,不用时间衰减。 参考答案: 错。不衰减会变历史总榜,旧词永久霸榜。
  2. 热榜存储为什么选 ZSet? 参考答案: 天然按分数排序,更新 O(log N),取 TopN 高效,匹配“带分数的有序 TopN”抽象。
  3. 去重合并失败会怎样? 参考答案: 热度被拆散到多个近义词条,真实热度被低估,榜上出现重复条目。

128. 关注了 1000 人,刷新要看到他们的最新动态(Feed 流) ​

【考察内容】Feed 流是系统设计最高频大题之一

【题目】用户关注了 1000 个账号,每次刷新都要看到这些账号的最新动态,而且要求秒开。大 V 一条动态有千万粉丝要看到。推(写扩散)、拉(读扩散)、推拉结合三种模式各自怎么实现?你们的场景选哪种?

【参考答案】

  1. 两种模式:
    • 推模式(Fan-out on write):发帖时把帖子 ID 写入所有粉丝的 feed 缓存(list);读时直接取自己列表——读快;但大 V 千万粉丝=写千万次(写放大);
    • 拉模式(Fan-out on read):发帖只写自己的帖子表;读时合并所有关注人的帖子(查关注列表→查各自最新帖→合并排序)——写简单;但读慢(多查询+合并);
    • 混合模式(生产主流):普通用户推(粉丝少,写成本低),大 V(粉丝超阈值如 10 万)拉——粉丝读时把“自己关注的大 V 帖子”与“推的普通帖子”合并;
  2. 存储:feed 缓存用 Redis List(每用户一个 key,存帖子 ID,LRANGE 分页,容量限制如 1000 条,超出淘汰);帖子详情放 Redis/DB;发帖表(user_id, create_time 索引);
  3. 排序:按时间倒序(ID 有序)或热度;分页用游标(避免深分页);
  4. 一致性:发帖先落 DB(事务)→ 提交后再异步推送给粉丝(MQ)——Feed 推送属可补偿旁路(漏推靠对账补推,用户不依赖它);资金/状态这类不可丢事件必须走本地消息表或 Outbox(见第 99 题),不能这样裸发;推送失败重试+对账(漏推补推);
  5. 扩展:feed 缓存降级(拉模式兜底)、僵尸粉清理、分组/不看某人等过滤规则;
  6. 容量估算(步进):假设 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 条、或只给活跃用户预推。
  7. 失败与降级:推失败重试+对账补推;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),与页深无关。

【选型判断树】

text
选推/拉/混合?
├─ 粉丝数少(< 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 按用户分片

【自测】

  1. 判断对错:Feed 用推模式总比拉模式好,因为读更快。 参考答案: 错。大 V 千万粉丝写放大会打爆系统,必须混合或拉。
  2. feed 缓存为什么存帖子 ID 而不存正文? 参考答案: 正文可能变更/删除,存 ID 避免不一致与存储膨胀,详情独立缓存。
  3. 关注 1000 人时拉模式读路径是什么? 参考答案: 查关注列表 → 批量取各人最新帖 → 归并排序 → 游标分页返回。

129. 单聊/群聊/多端同步/消息不丢,IM 怎么设计(IM 系统) ​

【考察内容】IM 是系统设计经典大题

【题目】公司要做即时通讯(类钉钉):单聊、群聊(万人群)、在线状态展示、消息不丢不重、手机/电脑多端同步已读未读。长连接怎么管理?消息存储与同步(多端)怎么做?群消息扩散怎么写?

【参考答案】

  1. 架构:客户端 → 接入层(WebSocket 长连接集群,保持在线会话)→ 消息服务(路由/存储)→ 推送服务(离线推送 APNs/FCM);
  2. 在线状态:Redis 维护(userId→连接节点+心跳),在线列表/最后在线时间;
  3. 消息流:发送方 → 消息服务(写消息存储)→ 路由到接收方连接节点 → 长连接推送;接收方离线 → 离线消息存储 + 通知推送;
  4. 存储:消息表(msg_id 全局唯一(雪花)、会话 ID(单聊=双方 id 有序拼接;群聊=groupId)、from/to、content、时间),按会话分表;消息序号(seq):每会话递增,客户端按 seq 拉取增量(多端同步基础);
  5. 可靠性:消息必达——客户端 ack 机制(收到回执),超时重传(幂等:msg_id 去重);服务端持久化+客户端本地缓存;
  6. 多端同步:每端维护“已同步 seq”,登录后从服务端拉取缺失消息(增量同步);
  7. 群聊:群成员列表(Redis/DB)、消息写一份存储+广播在线成员(推送风暴:大群用“拉模式/分片推送”);
  8. 扩展:已读回执、消息撤回(发送“撤回指令”消息)、敏感词、端到端加密(可选);
  9. 容量估算(步进):假设同时在线 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 人普通群可推;万人超大群改拉模式,推送只发摘要。
  10. 失败与降级:接入节点宕机,客户端重连到其他节点并按本地 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)),避免“我发给你”和“你发给我”变成两个会话,也便于按会话分表路由。

【选型判断树】

text
消息通道:
├─ 在线 → 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 常配合登录体系

【自测】

  1. 判断对错:IM 用 HTTP 轮询完全可以,实现更简单。 参考答案: 错。轮询延迟高、浪费大,IM 必须长连接才能秒级推送。
  2. seq 的作用是什么? 参考答案: 会话内全序,支撑多端增量同步、断线补拉、乱序重排。
  3. 万人群一条消息为什么不能简单广播? 参考答案: 写放大与推送风暴;应存一份、拉模式或分片推送、必要时抽样。

130. 网页版要扫码登录,整个流程怎么闭环(扫码登录) ​

【考察内容】登录态与状态机设计

【题目】产品要求 PC 网页支持扫码登录:手机扫码→确认→网页自动进入登录态。二维码怎么生成与轮询?手机确认后如何通知网页?安全上怎么防二维码被冒用?请设计完整流程与状态机。

【参考答案】

  1. 核心流程(二维码承载的是“登录票据”):
    • PC 生成二维码:服务端创建 login_ticket(唯一 ID+状态=待扫描+过期时间,存 Redis),二维码内容=票据 ID;
    • PC 端轮询/长连接查询票据状态(或服务端推送到 PC 的长连接);
    • 手机端扫码:App 读取票据 ID → 唤起确认页(显示设备信息)→ 用户确认 → App 调服务端“确认登录”接口(携带用户身份+票据 ID)→ 服务端更新票据状态=已确认,生成登录态(Token/Session)写入;
    • PC 端轮询到“已确认”→ 拿到登录态 → 跳转登录完成;
  2. 状态机:待扫描 → 已扫描待确认 → 已确认/已取消/已过期;Redis key=login:{ticket},状态+手机端会话信息;
  3. 安全:
    • 票据一次性(确认后即失效)、短过期(2~5 分钟);
    • 防劫持:二维码携带随机数,手机确认时校验设备指纹/登录态;HTTPS;
    • 轮询频控(如 1 秒 1 次);
  4. 替代方案:WebSocket 长连接推送状态(减少轮询);
  5. 扩展:扫码后免确认(可信设备直接登录)、扫码登录统计;
  6. 容量估算(步进):扫码登录是低频写、中频读——假设日登录 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,可忽略。
  7. 失败与降级:Redis 票据丢失/过期 → PC 提示二维码失效,引导刷新;手机确认接口超时 → 幂等重试(ticket+status 条件更新);轮询风暴时服务端对 ticket 维度限流;确认服务故障可降级为“短信验证码登录”兜底入口;Token 签发失败必须回滚票据状态或置为失败,避免 PC 卡死在轮询。

【原理溯源】

  • 为什么二维码里只能放“票据 ID”而不能放用户信息? 二维码会被截图转发。若含用户凭证,拿到图的人即可登录。票据是服务端签发的一次性、短时、待确认的占位符,本身不携带权限;权限只在“手机端已登录用户确认”后才注入。这是把身份验证与凭证传递解耦。
  • 为什么要状态机而不是直接写 Token? 扫码登录是异步多方协作(PC 生成、手机确认、PC 感知)。状态机让每一方只关心“当前状态能否迁移到我需要的态”,避免竞态:手机确认时必须检查仍是“已扫描”,PC 只在“已确认”时取 Token。没有状态机就会出现“过期码被确认”“二次确认”等问题。
  • PC 怎么知道手机确认了? 两种:① 轮询票据状态(简单,1s 一次可接受);② PC 也持有一条长连接,确认后服务端推送。小规模用轮询足够,大规模/体验敏感用长连接。本质是“状态变更的通知通道”选择。
  • 为什么要短过期 + 一次性? 限制攻击窗口:二维码被偷拍后,攻击者只有几分钟且必须在用户确认前使用;一旦确认,票据立即作废,重放无效。一次性是防重放的核心。
  • 为什么要校验设备指纹/已登录态? 防止“把二维码贴到钓鱼页让用户扫”。手机 App 扫码后应展示目标站点信息,并要求用户已在 App 登录;服务端确认时校验手机会话有效,降低“未登录设备确认”的风险。

【选型判断树】

text
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 依据、免确认扩展

【关联题】

  • 同簇: 第 155 题(SSO)——票据机制同源
  • 上游: 登录态体系(Token/Session)
  • 安全: 第 147 题(风控)——异常扫码检测

【自测】

  1. 判断对错:二维码内容可以直接放用户 Token,扫码即登录。 参考答案: 错。二维码可被转发,必须放一次性票据,确认后才发 Token。
  2. 扫码登录的状态机有哪几个态? 参考答案: 待扫描 → 已扫描待确认 → 已确认/已取消/已过期。
  3. 为什么要短过期? 参考答案: 缩小攻击窗口,过期码无法被利用。

131. 电商下单到支付到发货,订单系统全貌(订单系统) ​

【考察内容】订单系统是电商领域最高频设计题

【题目】电商平台要做订单系统:用户下单→支付→发货→完成,中间可能取消/退款/超时关闭。订单状态机怎么设计?下单幂等怎么防重?超时未支付怎么自动关单?订单数据量大了怎么存储?

【参考答案】

  1. 下单流程:校验(用户/商品/价格)→ 生成订单(状态=待支付)→ 扣库存(预占/扣减)→ 返回订单号;支付回调更新状态;
  2. 状态机:待支付 → 已支付 → 已发货 → 已完成;取消/退款分支(已取消、已退款);非法流转禁止(已支付不能直接完成);
  3. 关键设计:
    • 订单号:分布式 ID(雪花),订单表分库分表(按 user_id);
    • 幂等:请求级幂等键(客户端幂等号或服务端预生成订单号)建唯一索引,重复请求返回首单;不能在订单表按「用户+商品」建唯一索引——那会挡住正常复购,它只适用于限购活动(user_id+sku+活动期);支付回调幂等(支付流水号唯一);
    • 超时关单:MQ 延迟消息 + 定时扫表兜底(见延时任务题);
    • 库存:下单预占(Redis 预扣+DB 条件更新),支付成功转正式扣减,超时释放;
    • 金额:分存储,快照商品信息;
  4. 并发保障:下单走乐观锁/Redis 预扣防超卖;「一人一单」只在限购活动成立(唯一键=user_id+sku+活动期),不能当通用并发保障——本条第 3 项刚论证过按「用户+商品」建唯一索引会挡住正常复购;
  5. 一致性:订单-支付-库存跨服务用“事务消息+对账”(最终一致);支付成功事件驱动发货;
  6. 扩展:拆单(多仓)、订单列表分页(user_id+create_time 索引+游标)、售后流程;
  7. 容量估算(步进):假设日订单 50 万单,大促峰值系数 20~50 → 下单峰值 100~300 TPS(极端秒杀另走独立链路);订单表 50 万/日 × 500B ≈ 250MB/日,一年约 100GB,按 user_id 分 16~64 库/表。支付回调峰值与下单同量级;超时关单扫描量 = 活跃待支付订单 × 扫描频率,MQ 延迟消息承担主路径,扫表仅兜底(分钟级、分页)。
  8. 失败与降级:创建订单时库存服务超时 → 快速失败,禁止“无库存快照落单”;支付成功但订单更新失败 → 状态对账补偿任务扫描;DB 主库故障读从库可能读到旧状态,支付回调必须写主库;大促时订单列表可降级(只展示近 N 个月),详情仍保证可查。

【原理溯源】

  • 为什么订单必须是状态机而不是随意 UPDATE? 订单生命周期有多条合法路径(正常、取消、退款、超时)。没有状态机约束时,非法更新(如已取消直接变已发货)会导致资损与脏数据。状态机把“允许的迁移”编码成规则,每次更新校验 current_status 与目标态,从模型层杜绝非法流转。
  • 为什么要库存预占而不是支付时才扣? 支付有延迟(用户可能不付),若支付时才扣,会出现“超卖后支付失败”的补偿复杂问题。预占(下单锁库存)→ 支付确认扣减 / 超时释放,把冲突前置到下单瞬间,用条件更新保证不超卖。Redis 预扣扛并发,DB 条件更新兜底。
  • 超时关单为什么要“延迟消息 + 扫表”双保险? 延迟消息(RocketMQ/RabbitMQ)到点触发,精度高;但消息可能丢。扫表兜底扫描“超时仍待支付”的订单,保证最终关闭。双保险是“高精度通道 + 最终一致兜底”的标准组合。
  • 为什么支付回调必须幂等? 渠道可能重发回调(网络超时重试)。不幂等会重复入账、重复发货。用渠道流水号唯一索引 + 状态机(已支付则直接返回成功)保证只处理一次。
  • 为什么订单要按 user_id 分库分表? 订单查询绝大多数是“我的订单列表”,按 user_id 路由让单用户订单在同一分片,列表查询无需跨库。全局查(运营)走离线/ES。

【选型判断树】

text
下单链路:
校验 → 生成订单(待支付)→ 库存预占 → 返回订单号
支付回调(幂等)→ 已支付 → 发货 → 完成

超时:
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 分双保险超时、分表与游标、跨服务最终一致+对账、金额快照、拆单

【关联题】

【自测】

  1. 判断对错:订单状态可以直接从“待支付”改成“已完成”。 参考答案: 错。必须经过已支付、已发货等合法迁移,状态机拦截非法流转。
  2. 为什么库存要预占? 参考答案: 把超卖冲突前置到下单,支付失败/超时再释放,避免支付时才发现库存不足。
  3. 超时关单为什么用双保险? 参考答案: 延迟消息精度高但可能丢;扫表保证最终关闭。

132. 余额支付,钱不能多一分少一分(支付系统) ​

【考察内容】支付系统是金融/电商最高频设计题

【题目】产品要做钱包余额支付:用户充值、消费、退款。要求:钱一分不能多一分不能少,并发扣款不能超扣,每笔钱都有流水可查,账要平。账户模型、流水设计、并发控制、日终对账怎么设计?

【参考答案】

  1. 核心原则:账务系统不能“改余额”,只能“记流水”(流水驱动:余额是流水的物化结果。注意本方案是单账户流水,严格的复式记账需为平台/渠道建对手账户并要求 Σ借=Σ贷,单侧流水只能靠日终对账兜住不平);

  2. 核心表:

    • 账户表(user_id、balance,供查询);余额 = Σ流水(可定期对账);
    • 流水表(流水号唯一、user_id、方向(+/-)、金额(分)、关联业务单号、状态、create_time)——追加写,不更新;
    • 支付单表(支付单号、订单号、金额、渠道、状态机:待支付→支付中→成功/失败/退款);
  3. 支付流程:创建支付单 → 调渠道(微信/支付宝)→ 异步回调(幂等:渠道流水号唯一索引)→ 记账(加流水+更新余额,同一本地事务)→ 通知业务方(MQ 事件); 充值(题干三个资金动作之一):充值单 → 渠道支付 → 回调验签+金额比对 → 加一条方向为「+」的流水与更新余额放同一本地事务,幂等键=渠道流水号;未到账走查单补偿;禁止前端直接加余额。

  4. 一致性保障:

    • 幂等:支付单号/渠道流水号唯一约束,重复回调只处理一次;
    • 金额校验:回调金额与支付单金额比对(防止篡改);签名验证;
    • 对账:每日与渠道对账文件比对(我方支付单 vs 渠道流水),差异人工/自动处理;
    • 退款:原路退回(复用原支付单)、退款单独立+幂等;
  5. 安全:金额用分(整数)、并发扣款用行锁/乐观锁(余额>=金额 条件更新)、审计日志、敏感操作风控;

  6. 高可用:账务是核心链路——限流、幂等、数据库高可用、对账兜底;账户分片(按 user_id);

  7. 容量估算(步进):假设电商日支付成功 30 万笔,峰值系数 10 → 支付回调/记账峰值约 30~50 TPS,账务库完全可扛;若含红包/余额高频场景可达数百 TPS,仍远低于互联网 C 端峰值,重点是正确性而非纯吞吐。存储:流水 30 万/日 × 200B ≈ 60MB/日,账户表按 user_id 分片;渠道对账文件日增 MB 级。带宽与 RT 敏感点在第三方渠道(外部 RT 数百 ms),必须异步化回调,不能同步等渠道。

  8. 失败与降级:渠道超时 → 支付单置“支付中”,靠回调/查单收敛,禁止本地直接判失败后用户重复付;回调处理失败 → 渠道重试+我方查单补偿;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”类攻击。
  • 为什么要每日对账? 网络、回调、重试都可能造成最终不一致(掉单、重复、金额差)。对账是发现这些问题的最后防线,把“运行时不确定”收敛到“日终确定”。资金场景对账不是可选项。

【选型判断树】

text
账户怎么建?
流水表(追加写)+ 账户表(物化余额)+ 支付单(状态机)

扣款:
条件更新 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 分验签与金额校验、日终对账闭环、退款设计、审计与风控

【关联题】

【自测】

  1. 判断对错:余额支付可以直接 UPDATE balance = balance - 100。 参考答案: 错。必须流水驱动,条件更新防超扣,且可审计。
  2. 金额为什么用分? 参考答案: 浮点精度问题,资金场景必须整数。
  3. 支付回调重复怎么办? 参考答案: 渠道流水号唯一索引 + 状态机短路,只处理一次。

133. 我方支付记录和银行结算单对不上(对账系统) ​

【考察内容】对账系统是支付/金融岗高频题

【题目】每天凌晨银行会回传结算文件,和系统内的支付记录经常对不上(掉单、金额差、状态不一致)。对账系统怎么设计?对账流程(文件拉取→核对→差异分类→自动处理)怎么走?差异怎么处理?

【参考答案】

  1. 对账流程(日终批量):
    • 数据准备:渠道侧下载结算文件(对账单:交易流水、金额、手续费);我方导出当日支付流水;

    • 比对:按“唯一键(渠道交易号/我方支付单号)+金额+状态”逐笔匹配,分为:双方一致(通过)、我方有渠道无(渠道漏单/在途)、渠道有我方无(我方漏记/回调丢失)、金额不一致(差异);

    • 差异处理: 差错处理完成后要回到账务层:新增流水与更新余额放同一本地事务,使「账户余额恒等于 Σ流水」始终成立——这一步与第 132 题的账务模型闭环,否则对账只做到渠道↔支付单,账户层仍然可能是错的。

      • 在途/延迟(23:55~00:05 边缘交易):入缓冲池,次日(T+2)再核对;
      • 渠道有我方无:主动向渠道查单(查单接口),确认后补记/入账;
      • 我方有渠道无:人工核查(可能渠道退款/撤销);
      • 金额不一致:告警人工介入,绝不自动调整(资金安全);
    • 对账报告:差异分类汇总、金额核对(总额=明细和)、生成报表;

  2. 架构:定时任务(日终)→ 拉取文件 → 解析 → 流式/分批比对 → 差异库 → 查单补偿 → 人工处理平台;
  3. 进阶:实时对账(流式比对:每笔支付完成即与渠道结果比对,提前发现异常);
  4. 保障:对账是“最终一致的最后防线”,必须自动化+告警+人工工单闭环;
  5. 容量估算(步进):假设日支付 30 万笔,对账文件约 30 万行 × 200B ≈ 60MB/日,解析+比对单机分钟级可完成;差异率经验通常 0.01%~0.1%(数十~数百笔/日),必须有人工工单容量。存储:差异记录+报告按会计档案与交易资料口径保留——银行对账单类按《会计档案管理办法》至少 10 年,客户交易信息按《反洗钱法》第三十四条自交易结束后至少 10 年;《网络安全法》的「不少于六个月」只针对网络日志,别当对账档案的下限,日增很小。实时对账则每笔支付完成后订阅 MQ 与渠道结果比对,峰值与支付 TPS 同量级。
  6. 数据与接口(口述可带):差异单字段 = {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 压到分钟级,适合核心资金链路。日终仍是全量兜底。

  • 元理论对应: 原理 5「不可靠信道上的正确性协议」(题群见《核心考点与元理论》§5.7)

【选型判断树】

text
对不上怎么分类?
├─ 双方一致 → 通过
├─ 我方有渠道无 → 人工核查(可能渠道撤销)
├─ 渠道有我方无 → 查单接口确认 → 补记
├─ 金额不一致 → 告警人工,绝不自动改
└─ 跨日边缘 → 缓冲池 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 分金额差人工原则、实时对账、差异率监控与熔断、与账务模型呼应

【关联题】

【自测】

  1. 判断对错:对账发现金额差,应自动调整账目保证平。 参考答案: 错。资金差异必须人工核查,自动调整会掩盖欺诈与严重 bug。
  2. 缓冲池解决什么问题? 参考答案: 跨日边缘交易的时间归属差,避免误报。
  3. “渠道有我方无”怎么处理? 参考答案: 调渠道查单接口确认,成功则补记入账。

持续学习,持续积累。