场景设计面试
场景设计面试
【中等】如何在 10 亿个数据中找到最大的 1 万个?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 海量数据 TopK
💎 关键结论
维护一个容量为 1 万的最小堆:读入前 1 万条建堆,之后每条数据与堆顶比较,比堆顶大才替换堆顶并下沉调整。遍历一遍 10 亿数据后,堆中即最大的 1 万个。时间复杂度 O(n log K),堆仅占约 40KB,适合数据无法全量装入内存的场景。
⚡记忆卡片
- 口诀:求 Top K 大,建最小堆;堆顶是门槛,够格才进门
- 关键词:最小堆 / O(n log K) / 40KB / 流式单遍
- 链路:读 1 万条建堆 → 逐条比堆顶 → 大则替换下沉 → 堆中即 Top K
📖 核心知识
1. 量化推导
- 数据量 n = 10 亿 = 10^9,目标 K = 1 万 = 10^4。
- 堆内存:1 万个 int ≈ 40KB,可忽略不计;堆无法容纳全量数据,天然适合磁盘 / 流式数据。
- 复杂度对比:
- 全排序后取前一万:O(n log n) ≈ 10^9 × 30 ≈ 300 亿次比较,且全量数据装不进内存;
- 最小堆:O(n log K) ≈ 10^9 × 13 ≈ 130 亿次比较,约为全排序的 43%。
2. 算法步骤
- 读入前 1 万条数据,构建最小堆(堆顶是这 1 万条中的最小值,即当前第 1 万大的候选门槛);
- 循环读取剩余数据,每条与堆顶比较:
- 小于等于堆顶 → 直接丢弃(不可能进入前一万);
- 大于堆顶 → 替换堆顶并执行下沉调整(sift-down),O(log K);
- 读完全部数据后,堆中留存的即 TOP 10000。
3. 为什么是最小堆而不是最大堆
堆中存的是"当前 Top K 候选",需要快速淘汰候选中的最小者——最小堆的堆顶恰好是候选最小值,一次 O(1) 比较即可决定是否入围。若建最大堆则需知道全量最大值,逻辑不成立。
🔬 扩展知识
详情
- 【L3】K 与 n 的相对大小决定算法选择:K 远小于 n 用堆;K 接近 n 时全排序或 QuickSelect 更优。QuickSelect 基于快排分区,平均 O(n) 可找到第 K 大,但要求数据可放入内存,且最坏 O(n²)(随机化pivot 规避)。
- 【L3】分布式版本:数据分布在多台机器时,每台机器本地求 Top K(各自最小堆),汇总 m × K 个候选到协调节点再求一次全局 Top K,两轮即可,网络传输量极小。
- 【L4】数据以流的形式持续到达时,最小堆方案依然成立(单遍、增量维护),这正是实时热点统计、监控 Top N 指标的基础结构。
🔀 发散问题
- Q:如果数据是搜索日志关键词而不是数字,流程怎么变? → 先哈希分桶统计词频,再对词频用最小堆取 Top,见本文档「几亿条淘宝搜索日志取热度前十关键词」。
- Q:如果问题从 TopK 变成海量去重,方案如何切换? → 堆不再适用,整数域用 BitMap、允许误差用 BloomFilter,见本文档「40 亿个 ID 去重」。
【中等】有几台机器存储着几亿的淘宝搜索日志,假设你只有一台 2g 的电脑,如何选出搜索热度最高的十个关键词?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 海量数据 TopK
💎 关键结论
核心思想是分而治之 + 哈希分桶 + 堆排序:先按哈希取模把几亿条日志打散成若干小桶(同词必进同桶),桶内用 HashMap 统计词频,最后用大小为 10 的最小堆对所有桶的词频结果做一次全局筛选,即得 Top 10 热词。
⚡记忆卡片
- 口诀:哈希取模分文件,同词同桶不乱窜;小文件全读入,哈希表里计次数;最小堆里比大小,遍历一遍取前十
- 关键词:哈希分桶 / 桶内计数 / 最小堆 Top10 / 2G 内存
- 链路:逐行哈希取模分桶 → 桶内 HashMap 计数 → 最小堆全局归并 → 输出 Top 10
📖 核心知识
1. 内存估算与分桶数选择
- 几亿条日志约 3GB 量级,2G 内存无法整体载入,且全量词频 HashMap 也放不下,必须分桶。
- 取 N = 200 个桶:每桶原始数据约 15MB;设每桶去重后约 150 万个不同词,Java HashMap 每条目约 50 字节,约 75MB——2G 内存完全容纳。
- 关键约束:相同关键词必须落入同一桶,否则词频会被拆散统计错误;哈希取模天然满足。
第一步:哈希分桶(分散数据)
- 逐行读取日志,对每个搜索词计算哈希值后取模 N(N = 200),追加写入对应临时文件(
part_0.txt~part_199.txt)。 - 相同关键词一定进入同一临时文件,且每个文件大小可控(几十 MB 级)。
第二步:内存统计(桶内计数)
- 依次读取每个临时文件,用
HashMap统计词频,生成结果文件(result_0.txt,每行关键词 次数)。 - 单桶可完全载入内存,处理完一个释放一个,内存峰值只与单桶相关。
第三步:堆排取 Top(全局筛选)
- 维护大小为 10 的最小堆(堆顶是当前第 10 大的词频),遍历所有结果文件的每一行:
次数 > 堆顶→ 弹出堆顶、插入该词;- 否则跳过。
- 内存中始终只有 10 个元素的堆,一次遍历完成全局 Top 10。
第四步:输出结果
- 堆中剩下的 10 个词即为搜索热度最高的关键词。
🔬 扩展知识
详情
- 【L3】若某桶去重词数过多导致内存吃紧:可对该桶做二次哈希细分;或改用"桶内排序 + 归并计数"代替 HashMap——把桶内所有词排序后线性扫描计数,内存开销只剩外排缓冲。
- 【L3】多机版本即 MapReduce 模型:Map 端输出
(关键词, 1),Reduce 端按键聚合计数,本质与单机分桶一致。 - 【L4】词频统计也可用 Trie 树压缩公共前缀降低内存,适合关键词前缀重合度高的场景;但实现复杂度高于 HashMap,一般面试给出即可。
🔀 发散问题
- Q:如果数据量涨到内存连单桶都装不下怎么办? → 增加分桶数(N 调大),或对单桶二次分治,原则是"分而治之"递归到底。
- Q:Top10 的最小堆筛选和「10 亿数据找最大 1 万」是同一招吗? → 是,都是"容量为 K 的最小堆",只是 K 从 10000 变成 10,见本文档对应题目。
【中等】一张表里有三个字段(id,开始时间,结束时间),表中数据量为 5000W,如何统计流量最大的时候有多少条数据?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 区间统计
💎 关键结论
用差分数组 + 扫描线:把每条记录的"开始"记为 +1、"结束"记为 -1,按时间排序后线性扫描做前缀累加,任意时刻的累加值即该时刻的并发条数,最大值即所求。时间复杂度 O(n log n),远优于逐秒遍历或逐条比对。
⚡记忆卡片
- 口诀:起点 +1、终点 -1,排序扫描求前缀,峰值即答案
- 关键词:差分数组 / 扫描线 / 前缀和 / O(n log n)
- 链路:拆 +1/-1 事件 → 按时间排序 → 线性累加 → 取最大值
📖 核心知识
1. 量化推导
- 5000 万条记录 → 1 亿个事件点(每条拆成开始、结束两个)。
- 事件点用
long[1 亿]存放约 800MB(每点 8 字节:时刻编码 + 增量位);若内存紧张,可对事件点分批外排后再扫描。 - 复杂度:排序 O(n log n) ≈ 1 亿 × 27 ≈ 27 亿次比较;扫描 O(n)。若用"逐秒遍历每秒查覆盖数"的朴素方案是 O(时间跨度 × n),完全不可行。
2. 方案一:时间跨度可控时用差分数组
- 若时间范围不大(如一天 86400 秒、一年约 3150 万秒),直接开
diff[]数组:diff[start] += 1,diff[end+1] -= 1(闭区间约定),再对 diff 求前缀和,最大值即峰值流量。 - 数组方式无需排序,O(时间跨度 + n)。
3. 方案二:时间跨度大时用事件点排序
- 将每条
(开始时间, 结束时间)拆为两个事件:(start, +1)、(end, -1); - 全部事件按时间排序(同一时刻 +1 事件排在 -1 之前);
- 顺序扫描累加,维护最大值——累加值即"此刻有多少条数据在活跃"。
4. 边界约定
- 若"结束时间"表示该时刻仍在占用(闭区间),-1 事件应记在
end + 1;若表示该时刻已释放,直接记在end。面试中先与面试官确认口径。
🔬 扩展知识
详情
- 【L3】同一秒既有开始又有结束时,排序必须让 +1 排在 -1 前,否则瞬时峰值会被低估;且要在"处理完该时刻全部 +1 后"再更新最大值。
- 【L3】本题是"区间最大重叠数"经典问题,同类应用:服务器并发连接峰值、带宽峰值统计、会议室最少数量(贪心解法)。
- 【L4】数据无法全量载入内存时,可对事件点做外排后再扫描,扫描阶段内存只需 O(1)。
🔀 发散问题
- Q:如果只要峰值出现的时刻怎么办? → 扫描时同时记录累加值首次达到最大值的时间点即可,算法不变。
- Q:这类"扫描线 + 堆"的思路还能解什么? → 与外排归并一样依赖堆做有序选择,见本文档「500G 数据排序」中的多路归并。
【中等】有 40 亿个 ID,在 1G 内存中进行去重,如何实现?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 海量去重
💎 关键结论
用 BitMap(位图):若 ID 是 32 位无符号整数,全值域 2^32 个 bit 仅需 512MB,1G 内存完全够用。每个 ID 映射为位图上的 1 个 bit,出现过置 1,扫描完成后位为 1 的位置即去重结果。时间 O(n),空间仅 n/8 字节,精确无误差。
⚡记忆卡片
- 口诀:整数去重用位图,一个 bit 一个数,40 亿 ID 占 512M
- 关键词:BitMap / 2^32 bit=512MB / O(n) / 精确去重
- 链路:ID 定位 bit 位 → 置 1 标记 → 扫描位为 1 的位置 → 输出去重结果
📖 核心知识
1. 量化推导
- 前提:ID 为 32 位无符号整数,值域
0 ~ 2^32-1(约 42.9 亿),40 亿个 ID 落在该值域内。 - 位图空间:2^32 bit = 2^32 / 8 = 512MB,小于 1G 内存限制,方案可行。
- 对比 HashSet:40 亿条装箱 Integer + 哈希表开销约 40 亿 × 32 字节 ≈ 128GB,完全不可行;BitMap 将空间压缩了 2.5 亿倍。
2. 实现步骤
- 初始化:申请 512MB 的
byte[](2^29 个字节),全部置 0; - 标记:逐个读入 ID,定位并置位:
byteIndex = id / 8,bitIndex = id % 8;arr[byteIndex] |= (1 << bitIndex);
- 输出:扫描位图,所有为 1 的位对应的 ID 即去重后的集合;重复 ID 不会改变已置 1 的位,天然幂等。
3. 适用条件与局限
- 只适合整数且值域已知的场景;若 ID 是 64 位 long,全值域位图需 2^64 bit = 2EB,不可能实现;
- 若只关心"是否存在"且允许少量误判,可用 BloomFilter(空间远小于位图);若要查"缺失的数",位图同样适用(扫 0 位)。
🔬 扩展知识
详情
- 【L3】Java 中可直接用
java.util.BitSet,内部即 long 数组 + 位运算,自动扩容;set/get/clear均为 O(1)。 - 【L3】ID 值域大但实际出现稀疏时,RoaringBitmap 将 32 位值域按高 16 位分桶、桶内按基数自适应选择数组/位图/区间容器,稀疏场景比稠密位图省几个数量级内存,广泛用于 OLAP 引擎。
- 【L4】分布式去重:按 ID 哈希分片到多台机器,各节点维护子位图 / HashSet 本地去重,最后汇总——本质仍是"分而治之",与搜索日志分桶同构。
- 【L4】BloomFilter 的误判率公式
(1 - e^(-kn/m))^k:给定 n 与可接受误判率 p,可反推所需位数 m 与哈希函数个数 k,这是它优于位图的定量依据。
🔀 发散问题
- Q:如果 ID 是字符串而不是整数怎么办? → 位图失效,改用哈希分桶后桶内 HashSet 去重,见本文档「几亿条淘宝搜索日志」的分桶思想。
- Q:去重之外还要排序输出怎么办? → 位图按下标顺序扫描天然有序;数据量大时结合外排,见本文档「500G 数据排序」。
【中等】如果有 500G 数据需要排序,但是只有 4G 内存,如何实现?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 外排序
💎 关键结论
用外部排序:分块内排 + 多路归并。先把 500G 切成约 3.5G 的小块逐块内存排序写回磁盘,再用最小堆对约 143 路有序文件做 K 路归并,配合输入/输出缓冲区流式读写。全程磁盘 I/O 两读一写,归并比较复杂度 O(n log K),K 为归并路数。
⚡记忆卡片
- 口诀:切块内排,多路归并,堆选最小,缓冲读写
- 关键词:外排分治 / 3.5G 块 ×143 路 / 最小堆归并 / 缓冲区
- 链路:分块载入内存排序 → 写回有序临时文件 → 最小堆 K 路归并 → 输出最终结果
📖 核心知识
1. 量化推导
- 500G / 3.5G ≈ 143 块;留约 0.5G 给归并阶段的缓冲区与堆。
- 归并路数 K = 143:若同时打开 143 路,每路输入缓冲 2MB 仅需约 286MB,可行;每元素比较次数 log₂143 ≈ 7.2 次。
- 若内存更小导致路数过大(如上万路),文件句柄与堆开销不可接受,改用多阶段归并:先 16 路归并成中间文件,再归并一轮。
第一步:分块排序
- 将 500G 数据分成若干小块,每块小于 4G(如 3.5G),保证能载入内存;
- 每块在内存中内部排序(如快速排序),写回磁盘成为有序的临时文件。
第二步:多路归并
- 同时打开所有有序临时文件,每个文件分配一个输入缓冲区(几十 MB 级);
- 用最小堆从各路缓冲区队首中选出全局最小值,写入输出缓冲区;某路缓冲区读空则从对应文件续读,输出缓冲区满则刷盘;
- 堆维护 K 个元素,每次取最小后下沉调整 O(log K)。
第三步:清理
- 归并完成、结果校验后删除临时文件。
关键优化点
- 缓冲区分配:输入缓冲越大越少 I/O 次数,但要给堆和输出缓冲留余地;
- 堆优化:最小堆比逐路两两比较减少比较次数;
- 并行 I/O:异步预读下一批、多线程重叠计算与 I/O;
- 压缩:临时文件压缩存储,降低磁盘占用与 I/O 量。
🔬 扩展知识
详情
- 【L3】初始归并段可用**置换-选择排序(replacement selection)**生成平均长度约 2 倍内存的有序段,减少归并轮数;若数据"近乎有序"效果更显著。
- 【L3】工程实例:MySQL 的
sort_buffer_size不足时即走磁盘归并排序(filesort),Hadoop MapReduce 的 Shuffle 阶段本质也是外排归并。 - 【L4】只需全局有序前 N 条时,不必完整归并:对有序块建堆做"多路归并取 TopN",提前终止,与本文 TopK 题同构。
🔀 发散问题
- Q:归并阶段只想取前 1 万条怎么办? → K 路归并 + 提前终止即可,取够 N 条即停,见本文档「10 亿数据找最大 1 万」。
- Q:排序前需要先对 40 亿 ID 去重怎么办? → 先 BitMap 去重再外排,两阶段串联,见本文档「40 亿个 ID 去重」。
【中等】如何导入百万数据量级的 Excel 到数据库?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 批量导入
💎 关键结论
核心是流式读取 + 分批处理 + 批量插入 + 异步化:用 EasyExcel 等流式库逐行读避免 OOM,逐行校验转换,每 1000 条一批走 JDBC batch 插入并小事务提交,整个过程异步执行、进度可查、错误行可回溯可续传。
⚡记忆卡片
- 口诀:流式读、逐行校、千条一批批量插,异步任务加反馈
- 关键词:EasyExcel 流式读 / 1000 条/批 / rewriteBatchedStatements / 断点续传
- 链路:流式读取 → 逐行校验转换 → 分批批量插入 → 异步进度反馈 → 错误记录与续传
📖 核心知识
第一步:流式读取 Excel
- 痛点:一次性读入百万行必 OOM;POI 的 UserModel(XSSF)会把全表对象化驻留内存。
- 方案:使用流式读取库,逐行回调处理:
- EasyExcel:基于 SAX 事件模型解析,内存占用与行数无关,是读大文件的主流选择;
- POI 原生读大文件同样用 SAX 事件 API(
XSSFReader+ SheetHandler)。 - ⚠️ 注意:POI 的
SXSSFWorkbook是流式写入(导出方向)的 API,不能用于流式读取,容易混淆。
第二步:数据校验与转换
- 逐行校验格式与合法性(必填、类型、长度、枚举),转换为数据库实体;校验失败的行收集进错误集,不阻断主流程。
第三步:分批缓存与批量插入
- 批大小:每 1000 条攒一批,达到后执行批量插入;
- 批量插入:JDBC
addBatch()+executeBatch()或 MyBatis batch 模式;MySQL 需开启rewriteBatchedStatements=true,才能把多条 INSERT 重写为一条多值 INSERT,吞吐提升可达一个数量级; - 事务控制:一批一事务提交,避免长事务占用 undo/redo、加剧锁竞争。
第四步:异步化与进度反馈
- 前端提交后立即返回任务 ID,后端异步处理;定期更新已处理行数,前端轮询展示进度。
第五步:错误处理与补偿
- 校验/插入失败的行记录到错误表或错误文件,提供下载;
- 断点续传:记录已成功行数/游标,中断后从断点继续。
性能优化要点
- 连接池:按批处理并发度配置合适连接数;
- 索引策略:导入前禁用非必要二级索引,导入后重建(批量写入时索引维护是主要开销之一);
- 极限吞吐:MySQL 可用
LOAD DATA INFILE,绕过 SQL 解析层,比 INSERT 快数倍; - 内存控制:流式读 + 分批处理,内存占用恒定在几十 MB 级。
🔬 扩展知识
详情
- 【L3】量化:百万行 × 每行 1KB ≈ 1GB 原始数据,XSSF 全量对象化后内存膨胀数倍必 OOM;EasyExcel SAX 模式常驻内存仅几十 MB。
- 【L3】超大文件可分片并行导入:按 Sheet 或行区间切分,多线程各持连接并发批插;注意目标表主键/唯一键冲突与锁竞争。
- 【L4】任务可靠性:导入任务表记录状态机(待处理/处理中/成功/失败),失败自动重试;结合分片游标实现幂等续传,避免重复插入。
🔀 发散问题
- Q:反过来,导出 Excel 很慢怎么优化? → 同样是"查询 + 组装 + 写文件"三段瓶颈,见本文档「Excel 导出场景很慢,如何优化?」。
- Q:导入的数据量涨到 500G 级别怎么办? → 退化为外排分治思想,见本文档「500G 数据排序」。
【中等】Excel 导出场景很慢,如何优化?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 批量导出
💎 关键结论
导出慢先定位瓶颈,一般就三段:数据库查询慢、业务组装慢、文件写入慢。分别用游标分页替换深翻页、批量 RPC 替换循环单次调用、EasyExcel 流式写替换全量内存组装,三段同时优化才能根治。
⚡记忆卡片
- 口诀:查询用游标,组装走批量,写文件流式,超大分片打包
- 关键词:游标分页 / 批量 RPC / EasyExcel 流式写 / 分片 ZIP
- 链路:定位三段瓶颈 → 游标分页取数 → 批量组装 → 流式写文件 → 超大分片并行
📖 核心知识
Excel 导出慢一般有三种情况:
- 数据库查询慢
- 业务逻辑处理慢
- Excel 文件写入慢
数据库查询优化
- 痛点:
LIMIT offset, size分页在 offset 很大时性能极差,需扫描并丢弃大量前缀行;导出百万行时 offset 线性增长,总耗时接近 O(n²)。 - 方案:游标分页(基于主键 ID)
- 记录上一批最后一条的 ID,下次查询从该 ID 之后开始;
- SQL 示例:
SELECT * FROM orders WHERE status = 1 AND id > ? ORDER BY id ASC LIMIT 1000。
- 前提:ID 字段有索引,且必须带
ORDER BY id ASC,让 MySQL 走索引顺序扫描。 - 优势:每批只扫描固定行数,性能稳定,深翻页场景速度提升可达数百倍。
业务逻辑优化
- 痛点:循环中逐条调用 RPC / 缓存,网络 IO 次数爆炸,耗时剧增。
- 方案:批量查询
- 先从列表提取所有查询条件(如用户 ID),一次性批量调用
batchGetUserByIds; - 再用结果 Map 回填数据,将 N 次 RPC 降为 1 次。
- 先从列表提取所有查询条件(如用户 ID),一次性批量调用
- 优势:大幅减少网络交互,显著提升接口响应速度。
Excel 生成优化
- 痛点:POI 的 XSSF 等传统方式将全量数据组装到内存,大数据量导出易 OOM。
- 方案:EasyExcel 流式写入 + 分批查询
- 流式写入:边查边写,每批刷入磁盘,内存占用稳定在 100-200MB;
- 分批查询:结合游标分页,每次查 1000 行,避免全量加载。
- 进阶:超大数据量可多线程分片导出,按地区/时间分片并发生成多个 Sheet 或文件,最后打包 ZIP。
- 优势:避免 OOM,支持超大数据量导出,内存占用可控。
🔬 扩展知识
详情
- 【L3】量化:百万行导出若用
LIMIT offset翻页,第 100 万页需扫描丢弃约 100 万行,总扫描量 ≈ n²/(2×批大小);游标分页恒为 n,差距随数据量平方放大。 - 【L3】EasyExcel 写大文件时的内存模型:SXSSF 类流式写会保留窗口内行、其余刷临时文件,单 Sheet 行数上限 104 万(xlsx 规范),超限需拆多 Sheet 或多文件。
- 【L4】异步导出 + 文件中心:大导出任务异步化,结果文件上传 OSS,完成后站内信/邮件通知下载,彻底避免接口超时与并发导出拖垮应用内存。
🔀 发散问题
- Q:导入和导出能复用同一套分批框架吗? → 能:流式 IO + 分批处理 + 异步任务是同一套骨架,见本文档「导入百万 Excel 到数据库」。
- Q:导出数据源查询本身就很慢,除了分页还能怎么办? → 可改从从库/数仓读、或预先跑批落中间表,导出只扫中间表,思路与消息推送的人群圈选分片一致。
【中等】假设生产者-消费者模型中有 200 万个生产者,只有 1 个消费者,如何实现?⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 并发模型
💎 关键结论
关键在于消除写端锁竞争 + 撑高单消费者吞吐:生产侧用 Disruptor 式无锁环形队列(CAS 抢序号写入,缓存行填充防伪共享);消费侧批量拉取 + 线程池并行业务处理;再配背压机制防堆积。同时要想清:200 万生产者不是 200 万线程,需用 NIO/Reactor 收敛为少量 IO 线程。
⚡记忆卡片
- 口诀:写端无锁 CAS,读端批量拉取,满了背压,连接靠 Reactor
- 关键词:Disruptor / RingBuffer / 批量消费 / 背压 / Reactor
- 链路:NIO 收敛连接 → CAS 抢序号写 RingBuffer → 消费者批量拉取 → 线程池处理 → 背压保护
📖 核心知识
核心挑战
- 200 万并发写:直接竞争同一个队列会导致严重锁冲突、CPU 飙升;
- 单消费者:消费速度可能成为瓶颈,需最大化消费者效率;
- 内存压力:海量数据堆积可能撑爆内存。
设计目标:高吞吐、低延迟、背压保护、数据不丢失(按需)。
生产者优化:无锁环形队列
- 问题:200 万生产者直接写同一队列,竞争激烈;
- 方案:采用无锁环形队列 + 多生产者序号分配,典型实现是 Disruptor:
- 预先分配固定大小的环形缓冲区(RingBuffer);
- 每个生产者通过 CAS 竞争获取下一个可写入的序号,然后写入数据;
- 消费者通过序号屏障读取;
- 优点:无锁、预分配内存、缓存行填充避免伪共享,性能极高。
消费者优化:批量 + 异步
- 问题:单消费者处理速度必须跟上写入速度;
- 方案:批量拉取,每次从环形缓冲区拉一批数据(如 1000 条),减少调用次数;收到一批后提交线程池并行处理业务(如需保序则按序号分段提交)。
背压机制
- 问题:生产速度超过消费速度,数据堆积;
- 方案:RingBuffer 满时生产者按策略处理——阻塞、自旋或抛异常;堆积超阈值则降级(丢弃非关键数据、记日志);实时监控缓冲区使用率,触发扩容或限流。
生产者线程管理
- 问题:200 万个生产者若是独立线程,系统无法支撑(线程栈内存 + 调度开销);
- 方案:用 NIO/Reactor 模型(如 Netty)处理海量连接,由少量 IO 线程承接 200 万连接的事件并作为生产者写队列;若生产者是业务线程,用线程池限制并发,由有限线程代表 200 万任务写入。
🔬 扩展知识
详情
- 【L3】Disruptor 高性能的根源:环形数组预分配避免 GC;序号 CAS 无锁;缓存行填充(padding)防伪共享;消费侧序号屏障避免逐条通知。
- 【L3】量化:若单条处理 1ms,单消费者串行吞吐上限约 1000 条/秒;批量拉取 + 8 线程池可提升至数千条/秒——先算消费上限再决定批量与线程数。
- 【L4】若业务允许拆分,可将"1 个消费者"改为按 key 哈希分组的多消费者分区消费(MQ 分区思想),本题的约束下则需在单消费者内做并行。
🔀 发散问题
- Q:如果消费端也要削峰怎么办? → 引入 MQ 缓冲,生产-消费两端解耦,见本文档「消息推送系统」的削峰设计。
- Q:队列堆积导致内存爆了怎么办? → 背压 + 降级丢弃,与推送系统的频控/限流思想同构。
【中等】如何对 500 万会员提前 7 天进行过期提醒?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:12 min | 🏷 标签:场景设计 / 定时任务
💎 关键结论
核心思路:定时任务 + 索引优化 + 异步通知。在 expire_date 上建索引,每天定时扫描"7 天后到期"的会员(范围查询命中索引),对命中者异步发送提醒并记录已提醒状态防重复。关键是把"扫全表"变成"扫一天的到期量"。
⚡记忆卡片
- 口诀:索引建在到期日,定时只扫那一天,异步发提醒,发过打标记
- 关键词:expire_date 索引 / 滚动扫描 / 异步通知 / 防重标记
- 链路:定时任务触发 → 索引范围查当日到期 → 异步发送提醒 → 标记已提醒
📖 核心知识
1. 量化推导
- 500 万会员,若到期日分布均匀,单天到期量 ≈ 500 万 / 365 ≈ 1.4 万人;任务每天只需处理这个量级,而非 500 万。
expire_date建索引后,查询WHERE expire_date = 当前日+7是范围扫描,只读命中行;若无索引则全表扫 500 万行。
2. 方案设计
- 在会员表的
expire_date字段上建立索引; - 设置定时任务(如每天凌晨),滚动翻页扫描索引,筛选 7 天后过期的数据;翻页用主键游标而非
LIMIT offset,避免深翻页; - 对命中会员发送提醒(短信/推送/站内信),走异步队列 + 批量发送,不阻塞扫描主流程;
- 在记录表中标记状态为已提醒(或用"提醒记录表 + 唯一索引"),任务重跑不重复发。
3. 健壮性要点
- 任务失败可重跑,幂等靠已提醒标记/唯一索引保障;
- 发送失败进重试队列;监控当日提醒量,骤降告警(防索引失效/任务未调度)。
🔬 扩展知识
详情
- 【L3】若到期分布极不均匀(如促销日集中到期),单日量可能几十万,扫描分批 + MQ 削峰是必要组合,与推送系统同构。
- 【L3】更精确的定时可用延时消息(Redis ZSet score=提醒时间戳、RocketMQ 延时消息)代替轮询;但 500 万量级一次性写入延时队列成本高,"日级扫描 + 异步发送"更稳。
- 【L4】提醒渠道抽象为通道层(短信/推送/邮件),按用户偏好与免打扰时段过滤,避免骚扰——与推送系统的频控设计一致。
🔀 发散问题
- Q:如果提醒渠道扩容成多渠道、千万级用户怎么办? → 直接演进为消息推送系统,见本文档「如何设计一个消息推送系统」。
- Q:扫描翻页为什么不能用 LIMIT offset? → 深翻页扫描丢弃行,性能 O(n²),应改主键游标,见本文档「Excel 导出优化」的同类优化。
【困难】如何设计一个消息推送系统(千万级用户)?⭐⭐⭐
🎯 目标等级:L2 | ⏱ 建议用时:15 min | 🏷 标签:场景设计 / 系统设计
💎 关键结论
整体链路:任务调度 → 人群圈选 → 消息生产 → MQ 削峰 → 分通道发送集群 → 状态回传与统计。关键四件事:分片并行提吞吐、幂等键防重发、限流与重试保通道、频控免打扰保合规。千万用户 5 分钟触达 ≈ 3.3 万条/秒,必须批量接口 + 多通道并行。
⚡记忆卡片
- 口诀:圈人分片投 MQ,分道限速批量发,幂等防重退避重,频控免扰状态收
- 关键词:人群圈选 / MQ 削峰 / 幂等键 / 限流重试 / 频控
- 链路:任务调度 → 圈选分片 → MQ 削峰 → 分通道发送 → 回执统计
📖 核心知识
需求拆解:千万级用户在指定时间收到 Push/短信/站内信,分钟级触达,不重不漏。
整体架构:任务调度 → 人群圈选 → 消息生产 → MQ 削峰 → 发送集群(分通道)→ 状态回传与统计。
核心设计
- 人群圈选:定时任务扫描(索引 expire_date)或用圈人服务(标签/位图)产出目标用户列表,分片存储(如按 userId hash 切 1000 片)。
- 削峰与并发:目标用户 ID 分片写入 MQ,发送集群多实例并行消费;按通道(iOS/Android/短信)分队列,限速保护第三方通道配额。
- 防重与幂等:任务 ID + 用户 ID 作为幂等键(Redis SETNX 或 DB 唯一索引),任务重跑不重复推送。
- 失败重试:发送失败进重试队列,指数退避重试 3 次,仍失败进死信队列人工介入;短信通道失败可自动切换备用通道。
- 防骚扰与合规:全局频控(单用户单日推送上限)、免打扰时段过滤、退订名单过滤。
- 状态闭环:记录送达/点击回执,实时统计触达率,异常骤降触发告警熔断。
量化:千万用户 / 5 分钟送达 ≈ 3.3 万条/秒,需多通道并行 + 批量接口(如每批 100 条);发送集群按通道配额反推实例数。
🏭 实战场景
详情
营销推送实战:晚 8 点大促开抢前给 1000 万目标用户发 Push。圈选服务按标签位图产出人群,切 1000 片投 MQ;发送集群 20 实例、按 APNs/厂商通道分队列,单通道按其 QPS 配额限速(如每通道 5000 QPS,4 通道即 2 万/秒,约 8 分钟触达千万级,需提前拉长窗口或多通道扩容)。发送前统一过频控/免打扰/退订三层过滤,实际发送量通常降 10%-30%。上线后以"回执触达率"为北极星指标,某通道骤降即自动切备用通道并告警。
⚠️ 常见误区
详情
常见误区:
- ❌ "发 MQ 就天然不重不漏" → MQ 默认至少一次,去重必须靠业务幂等键(任务 ID + 用户 ID),不重靠发送前校验、不漏靠重试 + 死信。
- ❌ "单队列单消费者慢慢发也能发完" → 3.3 万条/秒的吞吐下,单消费者串行发送需几十分钟到小时级,必须分片 + 多实例并行。
- ❌ "失败就无限重试" → 无限重试会雪崩压垮通道,应指数退避有限重试 + 死信人工介入。
🔬 扩展知识
详情
- 【L3】圈人服务用位图(RoaringBitmap)交并差集运算,亿级用户多标签圈人可在秒级完成,比 SQL 多表 JOIN 快几个数量级。
- 【L3】分片数与消费者数的配比:分片数 ≥ 消费者实例数才能吃满并行度;分片按 userId hash 还能保证同一用户的消息顺序。
- 【L4】多租户与优先级:营销(可延迟)与交易(验证码/订单)分队列隔离资源,避免大促营销洪峰拖垮关键通知。
🔀 发散问题
- Q:会员过期提醒能复用这套系统吗? → 能,它就是本系统的特例:圈选条件换成"7 天后到期",见本文档「500 万会员过期提醒」。
- Q:发送日志也是海量数据,后续要统计热词/热点怎么设计? → 哈希分桶 + 堆取 Top,见本文档「几亿条淘宝搜索日志」的同类方案。