🏗️ 系统设计与场景题
秒杀 · 短链 · 排行榜 · 订单超时 · 分布式 ID · 限流 · 答题框架
🎯 系统设计题怎么答?(答题框架)
系统设计题没有标准答案,面试官看的是你有没有框架、会不会权衡。千万不要一上来就讲细节。
- 第一步:问清楚需求——功能边界是什么?QPS/DAU 量级?读多还是写多?能接受最终一致吗?主动问需求本身就是加分项
- 第二步:给量级估算——日活 × 人均请求 = 日均 QPS,峰值按 3~5 倍算;单机 MySQL 大约扛几千 QPS、Redis 单机约 10 万 QPS。用数量级说明「为什么要加缓存/分片」
- 第三步:画整体架构——接入层(DNS/CDN/网关)→ 应用层(无状态、可水平扩)→ 缓存层 → 存储层(分库分表/读写分离)→ 异步层(MQ)
- 第四步:逐个解决难点——通常是这几个:高并发读(缓存)、高并发写(异步削峰 + 分片)、数据一致性、幂等、超时与降级
- 第五步:说清权衡——每个方案都要说「代价是什么」。只讲优点不讲代价,是初级和高级的分水岭
🎯 通用答题顺序(背下来)
- 需求 → 估算 → 架构图 → 存储设计 → 核心流程 → 瓶颈与优化 → 权衡
- 任何一步被追问,都回到「量级」和「权衡」这两个词上
⚡ 场景一:秒杀系统怎么设计?
核心矛盾:瞬时几十万请求抢几百件库存。目标不是「让所有人买到」,而是别把系统打垮 + 不超卖。
- ① 前端限流——按钮置灰、答题防脚本、同一用户 N 秒只能提交一次
- ② 网关/CDN 层拦截——静态资源走 CDN;网关做限流(令牌桶),把绝大部分无效流量挡在应用外
- ③ 库存预热到 Redis——活动开始前把库存写入 Redis,用
DECR或 Lua 脚本原子扣减,扣到负数直接返回「已售罄」。这一步挡住了 99% 的流量,DB 完全无感 - ④ 异步下单——Redis 扣减成功的请求发到 MQ,由消费者慢慢写订单、扣 DB 库存,实现削峰
- ⑤ 数据库兜底——
UPDATE stock SET count = count - 1 WHERE id = ? AND count > 0,影响行数为 0 说明卖完了。绝不能先 SELECT 再 UPDATE - ⑥ 幂等——同一用户重复提交要用唯一索引或 Redis SETNX 拦住
Redis + Lua 原子扣库存(防超卖的关键)
-- 返回 1 表示扣减成功,0 表示库存不足,-1 表示重复下单
local stock = redis.call('GET', KEYS[1])
if not stock then return -1 -- 库存没预热
if tonumber(stock) <= 0 then return 0 end
-- 用 setnx 记录该用户已下单,防止重复
if redis.call('SETNX', KEYS[2], 1) == 0 then return -1 end
redis.call('DECR', KEYS[1])
return 1
🎯 面试官爱追问
- 怎么防超卖?——Redis 原子扣减(Lua 保证「查+扣」原子)+ DB 层
WHERE count > 0兜底,双保险 - Redis 扣了但下单失败怎么办?——MQ 消费失败重试;最终失败要回补库存(另发一条补偿消息)
- 为什么不用数据库行锁扛?——行锁会把并发压到 DB 上,几千 QPS 就打满了;Redis 单机可扛十万级
- 怎么防止超卖又保证不少卖?——超卖必须杜绝(唯一约束/条件更新);少卖可以靠「超时未支付回补库存」补救
🔗 场景二:短链系统怎么设计?
- 核心需求——长链接 → 短链接(如
t.cn/abc123),访问短链 302 跳转回长链。读远多于写(100:1 以上) - 短码怎么生成?三种方案:
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 哈希取模 | MD5/MurmurHash 长链 → 取前 N 位 | 简单,但有冲突,要额外查重 |
| 自增 ID + 进制转换 | DB/Redis 自增 ID → 62 进制(0-9a-zA-Z) | 无冲突、短码短;但 ID 连续可被遍历爬取(可加随机扰动) |
| 雪花算法 + 62 进制 | 分布式 ID 转 62 进制 | 分布式友好,长度略长 |
- 存储设计——MySQL 存映射(短码做唯一索引);热点映射放 Redis(读多写少,缓存命中率极高)
- 跳转用 301 还是 302?——302。301 是永久重定向,浏览器会缓存,之后就绕过你的服务器了——那样就统计不到点击量、也改不了目标地址
- 怎么防遍历?——短码加随机位、限流、对不存在的短码直接返回 404 不打 DB
🎯 面试官爱追问
- QPS 估算?——假设每天 1 亿次点击,日均约 1160 QPS,峰值按 5 倍约 6000 QPS,单机 Redis 完全够,主要压力在带宽
- 同一个长链要不要复用短码?——可以(用长链的 hash 查重),但会让不同用户共享统计口径,看业务需求
- 数据量太大怎么办?——短码做分片键分库分表;冷数据归档;过期短链定时清理
🏆 场景三:实时排行榜怎么做?
- 核心方案:Redis ZSet——
ZADD更新分数、ZREVRANGE取 TopN、ZREVRANK查我的排名。底层是跳表 + 哈希表,增删改查都是 O(log n)
常用命令组合
# 增加分数(用户 1001 加 10 分)
ZINCRBY rank:daily 10 user:1001
# 取今日 Top 10(带分数)
ZREVRANGE rank:daily 0 9 WITHSCORES
# 查某人的排名(从 0 开始,所以要 +1)
ZREVRANK rank:daily user:1001
# 取分数区间的人数(如 100 分以上有多少人)
ZCOUNT rank:daily 100 +inf
- 数据量很大的问题——百万级 ZSet 内存占用高、排序变慢。方案:分层榜单(只保留 Top 1000 在 Redis,其余落库)+ 分片 ZSet(按用户 ID 哈希分片,再合并各片 TopN)
- 日榜/周榜/月榜怎么做?——多个 ZSet + 定时任务归并(
ZUNIONSTORE);过期榜单直接设 TTL 自动清理 - 分数相同时的排序?——ZSet 按 score 排序,score 相同则按 member 字典序。若要求「先达到者靠前」,可以把时间戳编码进 score 的低位(如
score * 10^10 - timestamp) - 怎么防刷分?——业务层校验 + 频率限制 + 异步对账;关键榜单定期从 DB 重算校准
⏰ 场景四:订单超时未支付自动关闭怎么做?
这是延迟任务的经典题,四种方案各有取舍:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 定时任务轮询 | 每分钟扫一次「未支付且超 30 分钟」的订单 | 实现最简单 | 扫全表压力大、精度差(最多延迟 1 分钟) |
| JDK DelayQueue | 本地延迟队列 | 精度高、无外部依赖 | 单机内存、重启丢失,不能分布式 |
| RocketMQ 延迟消息 | 下单时发一条 30 分钟延迟消息 | 可靠、解耦、天然分布式 | 只支持固定延迟级别(4.x 18 级;5.x 支持任意精度) |
| Redis 过期 + 监听 | key 设 TTL,监听过期事件 | 精度高 | Redis 过期事件不保证送达(惰性+定期删除),可能丢 |
| 时间轮 | Netty/Kafka 的时间轮 | 高性能 | 要自己实现,复杂度高 |
- 生产推荐——RocketMQ 延迟消息(可靠)+ 定时任务兜底对账(防漏)。「延迟消息 + 定时补偿」是标准答案
- 为什么不能只用 Redis 过期监听?——Redis 的 key 过期事件是不可靠投递:如果那一刻没有客户端订阅、或者 key 一直没被访问,事件可能丢失。用它处理钱相关业务会出事
- 关单时要注意什么?——必须幂等(用户可能正好在关单瞬间支付了):
UPDATE order SET status='CLOSED' WHERE id=? AND status='UNPAID',用状态条件保证只关一次
🔢 场景五:分布式 ID 怎么生成?
| 方案 | 原理 | 适用 |
|---|---|---|
| UUID | 随机生成 128 位 | 本地生成、无依赖;但无序,做 MySQL 主键会导致页分裂 |
| 数据库自增 | 单表/多表设置不同步长 | 简单;但依赖 DB,性能与可用性受限 |
| Redis INCR | 原子自增 | 性能好;但依赖 Redis,重启要考虑持久化 |
| 雪花算法 | 1 符号 + 41 时间戳 + 5 数据中心 + 5 机器 + 12 序列 | 主流:本地生成、趋势递增、每毫秒 4096 个 |
| 号段模式 | 每次从 DB 取一批 ID 缓存到内存(Leaf-segment) | 对 DB 压力小;ID 连续可被推算 |
雪花算法的位分配(64 bit)
0 | 0000000000 0000000000 0000000000 0000000000 0 | 00000 | 00000 | 000000000000
符号位(1) | 时间戳(41 bit, 毫秒) | 机房(5) | 机器(5) | 序列(12)
// 41 位毫秒时间戳 ≈ 69 年
// 每毫秒最多 2^12 = 4096 个 ID → 单机约 409 万 ID/秒
// 趋势递增 → 对 MySQL 聚簇索引友好
- 最大的坑:时钟回拨——服务器 NTP 校时可能让时间倒退,此时生成的 ID 会重复。处理方式:
🎯 时钟回拨的三种处理
- 回拨很小(< 几毫秒):等待追平再生成
- 回拨较大:直接抛异常,让上层重试(宁可失败也不能生成重复 ID)
- 彻底方案:备用 workerId(回拨时切换到另一个 workerId 继续生成)
- workerId 怎么分配?——配置文件写死(简单但易冲突)、Redis INCR 分配、ZooKeeper 顺序节点、或启动时向 DB 注册
- 为什么不用 UUID 做 MySQL 主键?——UUID 无序,插入时 B+ 树要频繁分裂和移动,性能差、页利用率低。业务主键可以用 UUID 保证唯一,但要另设自增/雪花列做聚簇索引
🚦 场景六:限流与降级怎么做?
限流控制请求速率,熔断应对下游故障,降级是主动砍非核心功能。三者常一起出现。
| 算法 | 思路 | 特点 |
|---|---|---|
| 固定窗口 | 每分钟计数,到点清零 | 简单;临界问题:窗口交界处可能放行 2 倍流量 |
| 滑动窗口 | 把窗口切成小格滚动统计 | 平滑;Sentinel 用它(LeapArray) |
| 漏桶 | 请求先入桶,匀速流出 | 严格削峰,不允许突发 |
| 令牌桶 | 按速率往桶里放令牌,有令牌才放行 | 允许突发(桶可攒令牌),最常用 |
单机限流:Guava RateLimiter
// 每秒放 100 个令牌(令牌桶)
RateLimiter limiter = RateLimiter.create(100.0);
if (limiter.tryAcquire()) {
// 放行
} else {
// 限流:返回 429 或走降级逻辑
}
分布式限流:Redis + Lua(令牌桶)
-- KEYS[1]=桶 key ARGV[1]=速率 ARGV[2]=容量 ARGV[3]=当前毫秒时间戳
local rate, cap, now = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or cap
local ts = tonumber(bucket[2]) or now
-- 按流逝时间补充令牌,上限为容量
tokens = math.min(cap, tokens + (now - ts) / 1000 * rate)
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000))
return allowed
🎯 面试官爱追问
- 令牌桶和漏桶的区别?——漏桶匀速流出(严格削峰,无突发);令牌桶按速率放令牌、桶可以攒(允许突发)。秒杀更适合令牌桶
- 限流放哪里?——网关层做全局兜底(Sentinel/Gateway),应用层做接口级精细控制(@SentinelResource),两层配合
- 熔断三态?——关闭(正常)→ 打开(快速失败,不请求下游)→ 半开(放少量请求试探,成功则关闭)
- 降级怎么做?——返回兜底数据(缓存/默认值)、关闭非核心功能(推荐位、评论)、异步化(先落队列后处理)。降级要有开关,能人工干预
🎤 系统设计通用追问(10 条)
- QPS 怎么估算?——日请求量 ÷ 86400 = 日均 QPS,峰值按 3~5 倍。例:日活 100 万、人均 20 次请求 → 日均约 230 QPS,峰值约 1000 QPS
- 单机能力大概多少?——MySQL 单机约几千 QPS(含索引查询);Redis 单机约 10 万 QPS;Nginx 单机可到几万。这些是数量级,用来判断要不要加缓存/分片
- 什么时候分库分表?——先试:索引优化 → 冷数据归档 → 缓存扛读。还不行再分(单表千万~亿级、写入瓶颈)。分片是最后手段,成本极高
- 缓存和 DB 怎么保持一致?——Cache Aside(先更新 DB 再删缓存)+ 失败重试 + 延迟双删 / canal 订阅 binlog。强一致就别用缓存
- 怎么保证幂等?——唯一索引(最可靠)、Redis SETNX、状态机、乐观锁。用业务键而不是消息 ID
- 怎么防雪崩?——缓存:TTL 随机化、多级缓存、集群高可用;服务:限流、熔断、降级、隔离(线程池/信号量)
- 怎么排查线上慢?——先看监控定位层次(DB?缓存?下游?),再上工具:
top/iostat看资源、jstack看线程、慢 SQL 日志看 DB、链路追踪看耗时分布 - 怎么设计一个分布式锁?——Redis SETNX EX + 唯一 value + Lua 校验删除 + 续期(看门狗)+ fencing token;或 ZooKeeper 临时顺序节点
- 大流量下怎么做灰度发布?——按用户 ID 哈希 / 请求头 / 百分比路由,网关层做;新老版本共存,出问题快速回滚
- 如果让我加一个新功能,怎么不改坏老系统?——开关(配置中心动态生效)、旁路(新逻辑异步跑,对账一致后再切主路)、限流保护、可回滚
🎯 最后的提醒
- 系统设计题没有唯一答案,敢说「我选 A,因为……代价是……」就赢了一半
- 被问到不会的:先复述问题 → 说思路 → 承认边界。别硬编
- 主动提监控、告警、回滚,这是「工程素养」的体现