场景设计题 面试题


1. 如何设计一个高并发秒杀系统

题目: 请从零开始设计一个支持百万级并发的秒杀系统,说出你的设计思路和每一步要解决的核心问题。

追问1:秒杀系统你用 Redis 扣库存还是用数据库扣库存?各自的利弊是什么? 追问2:如何保证”不超卖”?如果同时要保证”不少卖”(库存不能剩余),方案上有什么调整?

💡 答案:

主问题: 设计一个高并发秒杀系统,核心思想是把流量削峰填谷,让绝大多数请求在离数据库最远的地方就被拦截掉。整体架构分为五层。第一层是前端限流:商品详情页和按钮做静态化,按钮在秒杀开始前置灰,开始后才可点击,并且点击后立刻置灰防止重复提交。第二层是网关层:使用 Nginx + Lua 或者网关插件对秒杀接口做令牌桶限流,只放行跟库存数量匹配的请求量(比如库存 1000 件,可以放行 2000-3000 个请求进入下游),其余直接返回”已售罄”。第三层是服务层:用户资格校验(是否登录、是否已购买过、是否在黑名单),然后通过消息队列异步削峰,将请求写入 Kafka 让后端异步消费,前端返回”排队中”。第四层是扣库存:用 Redis 做库存预扣,DECR 原子操作判断库存是否充足,扣减成功的生成预下单记录落入消息队列。第五层是订单持久化:消费者从队列中拿出成功的预扣记录,异步写入数据库生成真实订单。

追问1: 用 Redis 还是数据库扣库存,本质是性能与一致性的权衡。用 Redis 扣库存的优势是性能极高——单机 Redis 可以扛 10 万+ QPS,而且 Redis 单线程模型保证了 DECR 操作的原子性,天然支持并发扣减。缺点是 Redis 数据在内存中,如果宕机未及时持久化可能丢数据,而且 Redis 和数据库之间需要异步同步,存在最终一致性问题。用数据库扣库存的优势是强一致性——UPDATE stock SET count = count - 1 WHERE count > 0 在行锁的保护下不会超卖也不会丢数据。缺点是性能差,热点行锁会成为瓶颈,数据库连接数不够用。业界成熟方案是”Redis 预扣 + DB 最终落单”的混合模式——Redis 承担高并发读写的压力,DB 作为最终数据源保证不丢数据。如果 Redis 挂了,降级到直接走数据库,虽然性能差但保证业务可用。

追问2: “不超卖”相对容易解决——利用 DECR 的原子性,或者数据库 UPDATE ... WHERE count > 0 加行锁。但”不少卖”(用户取消订单、超时未支付要回补库存)才是秒杀系统最难处理的点。如果在 Redis 里扣了库存、异步落库中途失败,库存已经被扣了但订单没生成——用户拿不到商品,库存也没恢复,两边都吃亏。解决方案分两步:一是对”已扣 Redis 库存但未落库”的中间状态做超时释放——预扣时记录时间戳,定时任务扫描超时未支付的预下单回补库存;二是对落库失败做补偿——消费者处理消息如果失败,需要回滚 Redis 的扣减。为了保证不会同时回滚和超时释放导致重复加库存,回补操作也用 INCR 原子操作,并且设置库存上限等于初始库存。需要注意的是”不少卖”还涉及防黄牛——如果全是脚本秒杀然后取消订单,真正想买的用户抢不到。可以通过限制同一用户/IP/设备多次秒杀、要求支付后才能确认抢购成功来减少恶意占单。

📌 易错点 / 加分项:

  • 秒杀不是”怎么扛最大流量”而是”怎么挡住不需要的流量”,这个思路转变很重要
  • 能提到”先到先得”(Redis DECR)和”最终一致性”(异步落库)之间的平衡
  • 热点账户/热点库存问题的延伸:除了 Redis 分槽(库存拆分到多个 key),还能用本地缓存预减

2. 如何设计一个短链接服务

题目: 设计一个类似 TinyURL 的短链接服务,支持生成短链接和 301 跳转,QPS 预估 10w 读 1w 写。

追问1:短链接的哈希算法你用什么?如何避免短链接冲突? 追问2:短链接有有效期吗?如何设计和实现过期数据的自动清理?

💡 答案:

主问题: 核心架构分四层。API 层:提供两个接口——POST /shorten(传入长 URL 返回短链接)、GET /{hash}(302 重定向到原始 URL)。应用服务层负责生成短码、去重校验、记录映射关系。缓存层:Redis 存储热点短链的映射,key 为短码、value 为原始 URL,TTL 设置较短(如 1 天),当缓存未命中时回源查数据库并写回 Redis。存储层:MySQL 表结构 (id, hash, origin_url, created_at, expired_at),hash 字段建唯一索引。短码生成用发号器(自增 ID 转 62 进制——用 0-9a-zA-Z,将数字 ID 映射为短码)比随机哈希更安全,避免碰撞,而且 ID 自增天然趋势增长对 B+ 树友好。高 QPS 读写通过 Redis 缓存承载——请求先查 Redis、再查 DB、最终缓存 Redis。写请求去重——长 URL 和短码的映射关系,可以再用一个反向索引 origin_url → hash(通过额外的 Redis key 或数据库唯一约束保证幂等)。扩容上无状态服务层水平扩展,MySQL 通过读写分离 + 分表分库(按短码 hash 分片)扩展。

追问1: 不建议直接用 MD5/SHA 散列进 URL 的部分——碰撞率高且有安全风险(攻击者可以反推出原始 URL 的内容摘要)。发号器方案更好:维护一个全局自增 ID generator(例如数据库自增、Redis INCR、雪花算法),将递增 ID 转为 62 进制字符串(0-9 + a-z + A-Z,共 62 个字符),十进制 1 亿转为 62 进制也就 6 位。这样短码天然不冲突(ID 全局唯一),且 ID 自增对数据库友好。如果有多机房部署,可以给每个机房分配不同的 ID 前缀或步长——比如机房 A 的 ID 从 0 开始每次加 2,机房 B 从 1 开始加 2,保证全局不冲突。62 进制也可换为其他基数(如 64、72 等)来支持更多字符,但要考虑 URL 安全(有些字符在 URL 中需要转义)。

追问2: 过期设计:如果短链接有有效期,表里加 expired_at 字段,应用服务在查 DB 时过滤 WHERE hash = ? AND (expired_at IS NULL OR expired_at >= NOW())。缓存里 TTL 设置为与过期时间一致——如果短链接在 N 天后过期,缓存的 TTL 也设为 N 天。过期数据清理:避免用定时任务直接批量删除(DELETE 大表会产生大量 undo log 且可能锁表),更推荐的做法是”软删除 + 异步清理”——如果请求时发现已过期,直接返回 404;同时后台异步标记为 EXPIRED 或写入另一个表,用一个限流删除任务在业务低峰期 DEL,每次只删少量行避免影响线上。如果数据量极大的话考虑按时间分表——每天或每月一张短链接表,过期后直接 DROP 整张表比 DELETE 高效得多。也可以将过期的短链接转移到冷存储(如对象存储)保留日志,释放生产数据库空间。

📌 易错点 / 加分项:

  • 短码的生成用发号器 + 62 进制比用随机生成再查冲突更可靠——冲突检测是额外的性能开销
  • 反向索引(long URL → hash)保证幂等性——即同一长 URL 无论请求多少次返回同一个短链接
  • 10w 读 QPS 光用 Redis 没问题,但要考虑 Redis 宕机后的降级——降级开关可以绕过缓存直接查 DB

3. 设计一个简单的文件存储系统

题目: 设计一个类似网盘的文件存储系统,支持上传、下载、列表展示,用户量百万级。

追问1:大文件上传怎么处理?如果文件有几个 GB 甚至几十 GB? 追问2:文件去重怎么做?两个人上传了完全相同的文件,要不要存两份?

💡 答案:

主问题: 核心架构分四层。接入层:Nginx 或 OSS SDK 处理上传下载请求,上传下载直接走对象存储(MinIO / 阿里 OSS / AWS S3)的 pre-sign URL,减轻应用服务压力。应用层:用户管理 + 文件元数据管理——MySQL 存储文件元数据表 (file_id, user_id, file_name, file_size, content_type, storage_key, md5_hash, parent_folder_id, created_at, updated_at),配合文件夹表。存储层:对象存储(不存本地文件系统,避免单机磁盘容量限制和扩容困难),文件存为 bucket/{user_prefix}/{storage_key}。缓存层:Redis 存储热点文件的元数据和下载链接,避免每次列表查询都全表扫描 DB。文件存储不进入数据库,只存 path 作为引用指针——这是存储和数据库分离的基本思想。用户量百万级下元数据表需要对 user_id 建索引 + 按 user_id 分表保证查询速度。

追问1: 大文件上传的核心方案是分块上传(Multipart Upload)。客户端将大文件切成小块(如每块 5MB),每个块独立上传到对象存储,每个块传完后返回 ETag 标识。所有块传完后,客户端调用”完成上传”接口,将分块列表和对应的 ETag 传给对象存储,对象存储按顺序拼接分块生成完整文件。好处极多:一是支持断点续传——某个块传失败只需重传这个块;二是支持并行上传——多线程同时传不同块,极大提升大文件上传速度;三是降低失败成本——不会因为最后 1% 的网络问题前功尽弃;四是客户端可以预先计算文件 hash 判断是否去重。几个 GB 的文件用分块上传是比较成熟的方案,几十 GB 级别需要注意客户端的内存和本地磁盘缓存。

追问2: 文件去重用文件内容哈希。用户上传文件时先计算文件的 MD5 或 SHA-256——如果发现系统中已存在相同的哈希值,说明这个文件之前有人上传过,无需再次上传文件本身。应用层直接将这个用户的 storage_key 指向已有的文件路径,然后更新一个引用计数。这样物理上只存一份,逻辑上每个用户的文件元数据各自独立——用户 A 删文件时减少引用计数,计数归零时才真正物理删除。这个方案节省了存储空间(大文件的去重是最有价值的)但对小文件来说额外哈希计算成本不低,实际情况可以设定阈值(如低于 4KB 不去重,高于 4KB 才比较哈希)。如果要更高效,可以分块去重——将文件切块后逐块做哈希去重,类似 rsync 或 Dropbox 的方案,减少相似的重复存储。但分块去重复杂度更高,一般百万级用户用全文件哈希去重就足够了。

📌 易错点 / 加分项:

  • 对象存储的 pre-sign URL 避免应用自己”搬运”文件内容,是架构设计的关键点
  • 不要直接在数据库里存文件数据(BLOB),这会瞬间打爆数据库性能
  • 引用计数删除有并发安全问题——删除时需要原子操作 UPDATE ... SET ref_count = ref_count - 1 WHERE ref_count > 0

4. 设计一个配置中心

题目: 设计一个配置中心(类似 Apollo 或 Nacos 的配置管理功能),支持配置的变更后实时推送到所有客户端。

追问1:配置变更通知你是怎么实现的?长轮询、gRPC 流、还是 MQ 广播?各自的优劣? 追问2:如何保证配置的版本管理和回滚能力?万一推了一个错配置把系统搞挂了怎么办?

💡 答案:

主问题: 配置中心的核心组件分为四层。服务端:存储配置的元数据——用 MySQL 存储配置项 (app_id, namespace, key, value, version, created_at, updated_at)。每次修改配置时生成新版本号并记录变更日志(用于回滚审计)。配置的发布有灰度、全量两个阶段——可以先推给部分节点验证再全量推。客户端:启动时拉取配置的全量快照缓存到本地文件(防止配置中心挂掉后客户端无法启动),然后通过长轮询或长连接监听配置变更。实时推送的实现方案是:客户端发起一个长轮询请求(带当前配置版本号),服务端持有这个请求不放,有变更后立刻返回新配置;如果超过超时时间(如 60 秒)没有变更,返回 “无变更” 让客户端重试。

追问1: 实时推送主要有三套方案。长轮询方案(HTTP):客户端发请求带 Notify-Time—参数,服务端在服务端挂住连接 60 秒,不在 60 秒内配置变化立刻返回新版本。优势是 HTTP 协议通用性强、穿透防火墙没问题,缺点是每次轮询都重新建连有一定资源消耗。gRPC 双向流方案:客户端建立长连接后,服务端有变化时直接通过流推送。优势是真正的”实时+低成本”,不需要定期轮询,适合配置变更频繁的场景。缺点是长连接管理复杂需要心跳保活和重连机制。MQ 广播方案:服务端变更后发消息到 MQ(Kafka/RocketMQ),所有客户端消费。优势是实现解耦、可以持久化和削峰,不丢失变更通知。不足是引入外部 MQ 组件增加了依赖。Apollo 1.x 用长轮询,Nacos 2.x 用 gRPC 流 + 长轮询作为兜底。

追问2: 版本管理用”发布记录表”:每次配置发布生成一条发布记录 (publish_id, app_id, config_version, operator, content, created_at, status)——记录”谁在什么时间改了哪个配置”。回滚的操作本质是”用旧版本的配置内容生成一条新版本”——从发布记录中拿到上一个版本的完整内容,再插入一条新发布记录(内容=旧内容、版本号+1),和正常变更走相同的推送逻辑。安全控制上:一是发布审批流程——敏感的配置(如数据库连接串)需要二次审批才能发布;二是灰度发布——先推给 1-2 台机器观察几分钟(通过应用监控指标看是否有错误量上升),确认安全后再全量发布;三是自动回滚——对接监控系统,如果发布后错误率飙升 10% 以上,触发自动回滚按钮;四是本地兜底——客户端缓存上一次正常拉取的配置到本地文件,如果收到异常配置(比如 JSON 格式错误),拒绝该配置并使用本地备份。

📌 易错点 / 加分项:

  • 配置中心和服务注册中心的功能边界不同——不要把两者混为一谈(Nacos 名字误导)
  • 客户端本地缓存是容灾的保底手段——配置中心挂了应用不能也挂
  • 加密敏感配置是基本要求——数据库密码、API Secret 在数据库中加密存储,客户端解密

5. 设计一个即时通讯(IM)系统

题目: 设计一个支撑千万级用户的即时通讯(IM)系统,核心功能是单聊和群聊。

追问1:消息的”已读/未读”状态如何存储和更新?群聊场景下每个人已读位置不一样怎么维护? 追问2:离线消息和消息同步机制如何设计?用户换了一台设备,历史消息如何同步?

💡 答案:

主问题: IM 系统的核心组件分成接入层、传输层、存储层、业务层四块。接入层:客户端和 Gateway 通过 WebSocket 或自定义 TCP 协议维护长连接,Gateway 负责连接管理、心跳保活、消息路由。Gateway 是无状态的——每个用户连接到哪个 Gateway 可用一致性哈希分配。传输层:Gateway 之间通过 RPC 或路由表定位目标用户当前的 Gateway,将消息投递到目标。存储层分为消息存储(时序数据库或 MySQL 分表,按消息时间 + 会话维度存储)、消息索引(用户 ↔ 会话关系、会话 ↔ 消息分页索引)、已读状态(每个用户在某个会话中的已读位置)。业务层负责会话管理、消息推送和离线存储逻辑。千万级用户的性能瓶颈在连接管理和消息投递延时——长连接需要做好 I/O 多路复用(Netty),单机可以支撑 50w+ 连接,配合多机横向扩展。

追问1: 已读状态的存储粒度是”用户 + 会话 + 最后一条已读消息 ID”。单聊场景下两个人的已读位置独立——A 读到 msg_id=100,B 读到 msg_id=99,这是两个不同的值。群聊场景下 N 个成员 N 个已读位置——用一张”已读位置表” (session_id, user_id, last_read_msg_id, updated_at) 存储。但群聊的已读更新是频率最高的操作——500 人群每个人每看一次都更新会形成高频写入。优化方式:一是合并写入——客户端不需要每次看消息就更新已读位置,而是定时(如每 5 秒)、或者切换出会话时统一更新;二是用 Redis 的 Sorted Set——score 存 msg_id 的毫秒时间戳,member 存 user_id,表示该用户当前的已读位置,定期批量同步回 MySQL。三是用”乐观发布”——不再推送”所有人已读位置”,而是等用户主动查询时才返回精确值。大多数 IM 产品的”双蓝勾”采用类似”乐观更新”的策略。

追问2: 离线消息方案:用户在线时,消息通过长连接实时投递;用户离线时,消息写入离线消息表 (user_id, msg_id, content, timestamp, status)。用户重新上线后,Gateway 通知离线消息同步模块:“该用户上线了”,模块从离线消息表批量拉取并推送。消息投递完后标记消息为已投递,超过 7 天的离线消息可清理或归档。多设备同步:每个设备独立维护一个”设备最后同步的 msg_id”——当设备 A 已收到 msg_id_150,设备 B 才挂到 msg_id_80,B 上线后需要拉取 81-150 的消息。实现方式是每个设备维护一个同步游标 (user_id, device_id, last_sync_msg_id),消息同步模块拉取这个游标之后的增量消息进行同步。历史消息的存储会随时间膨胀——一般用”冷热分离”:近 7 天的消息在 Redis + MySQL 热层快速读取,7 天以上的消息转存对象存储归档,按需加载。

📌 易错点 / 加分项:

  • 消息发送的”ack 机制”——不是一条 TCP ack 就认为消息投递成功,需要上层的业务 ack
  • 群聊消息的扇出问题——500 人群一条消息要扩散给 500 个人,不能串行发送需要批量
  • 一致性哈希的重平衡问题——Gateway 增删节点时,用户重新分配可能导致短暂的消息路由失败,需要处理好重连逻辑