系统设计面试题
如何设计一个高并发的秒杀系统?超卖问题如何解决?
面试官您好!关于高并发秒杀系统的设计,我会从核心挑战、整体架构、高并发优化和超卖问题根治四个维度来回答,确保方案既落地又能扛住百万级 QPS。
设计秒杀系统,核心就三个字:快、稳、准。
- 快:响应要快,流量要在上游就被挡住。
- 稳:系统不能崩,要层层削峰。
- 准:库存不能多扣,绝对不能超卖。
咱们直接看一个全局架构图,心里先有个底 👇
这个链路,就是我们今天要手撕的全部。
秒杀系统的三大核心挑战 ⚠️
| 挑战 | 本质问题 | 后果 |
|---|---|---|
| 瞬时高并发 | 短时间内流量暴增 100-1000 倍 | 服务器雪崩、数据库宕机 |
| 超卖问题 | 并发读写导致库存不一致 | 订单量 > 库存量,资损 |
| 恶意请求 | 脚本刷单、黄牛抢购 | 普通用户抢不到,体验差 |
整体架构设计 🏗️
秒杀系统的核心思想是:分层限流 + 异步削峰 + 最终一致性,把流量层层拦截,最终只有极少部分请求能到达数据库。
设计原则:
- 能在前端拦截的,绝不放到后端
- 能在缓存拦截的,绝不放到数据库
- 能异步处理的,绝不同步处理
高并发解决方案 🚄
1. 前端层限流 ✋
- 按钮置灰 + 倒计时,防止重复点击
- 点击抢购前,先弹出个滑块验证码或者简单的数学题。
- 别小看这一步,它能直接把脚本党和手速慢的人分流出去,还顺带把瞬时峰值的尖峰削平了。
- 验证码 / 滑块验证,拦截机器请求
- 随机延迟请求,分散流量峰值
- 动静分离 & CDN 🗂️
- 把秒杀页面做成纯静态 HTML,直接甩到 CDN 上。
- 页面里秒杀按钮开始时是灰的,千万不要提前暴露真正的下单 URL。
- 到点了,才通过一个额外请求动态获取秒杀地址(
/getSeckillPath),这个 URL 还可以做成一次性的、加盐动态生成的。
秒杀最怕的就是海量请求直接把服务器打崩。所以,要将 99% 的不必要流量,拦截在上游。
2. 网关层限流 🚦
- Nginx 配置limit_req_zone,按 IP 限流
- 黑名单机制,封禁恶意 IP 和用户
- 网关层令牌桶算法,限制总 QPS
- 在网关层,给秒杀接口加上令牌桶或漏桶限流器。
- 比如:这个接口每秒只能放行 5000 个请求。多出来的直接快速失败,返回“挤不进去啦,请稍后再试~ 😅”。宁可把流量扼杀在此,也别让它冲垮下游。
3. 服务层限流 🛡️
- 分布式限流:Redis+Lua 脚本实现令牌桶
- 熔断降级:秒杀服务过载时,直接返回 "活动火爆"
- 业务隔离:秒杀服务独立部署,不影响核心业务
4. 缓存层优化 💾
- 商品详情页静态化,CDN 加速
- 活动开始前,将库存预热到 Redis
- 使用 Redis Cluster 分片,提高并发能力
5. 异步削峰 📥
- 库存扣减成功后,发送消息到 MQ
- 订单服务异步消费消息,创建订单
- 前端轮询 /websocket 通知用户抢购结果
超卖问题的终极解决方案 ✅
超卖的根本原因是:多个线程同时读取到相同的库存值,然后各自扣减,导致最终库存为负。
方案对比 📊
| 方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库行锁 | UPDATE goods SET stock=stock-1 WHERE id=? AND stock>0 | 简单可靠 | 数据库压力大 | 低并发秒杀 |
| 悲观锁 | SELECT ... FOR UPDATE | 绝对不会超卖 | 性能极差,死锁风险 | 几乎不用 |
| 乐观锁 | 版本号机制 | 性能较好 | 高并发下成功率低 | 中低并发 |
| Redis+Lua | 原子脚本扣减库存 | 性能极高,原子性 | 依赖 Redis 可靠性 | 高并发秒杀 ✅ |
| 分布式锁 | Redisson 分布式锁 | 通用性强 | 性能一般,复杂度高 | 复杂业务场景 |
面试官插话:你说了那么多,如果流量真的冲进来了,超卖问题到底怎么解决?
我: 这正是秒杀的灵魂所在。我们绝对不能在数据库层面直接排队扣减,那样 MySQL 会直接死给你看。
核心方案:Redis 预减库存 + 数据库最终一致性扣减
推荐方案:Redis+Lua 原子脚本 🌟
这是目前互联网大厂最主流的解决方案,性能最高且绝对不会超卖。
我们把秒杀库存提前预热到 Redis 里。关键是,扣减操作必须原子化。我们用的不是多条 Redis 命令,而是一段 Lua 脚本:
-- 原子扣减库存脚本
local stockKey = KEYS[1]
local userKey = KEYS[2]
local userId = ARGV[1]
-- 1. 判断用户是否已经抢购过
if redis.call('sismember', userKey, userId) == 1 then
return -1 -- 已抢购
end
-- 2. 判断库存是否充足
local stock = tonumber(redis.call('get', stockKey))
if stock <= 0 then
return 0 -- 库存不足
end
-- 3. 扣减库存并标记用户已抢购
redis.call('decr', stockKey)
redis.call('sadd', userKey, userId)
return 1 -- 抢购成功为什么这样能防止超卖? 因为 Redis 是单线程执行命令的,Lua 脚本在执行时会把整个脚本当成一个原子操作。绝对不会出现“读到还有库存,但去扣的时候被别人扣光了”的读后写竞态条件。🛡️
快返回、异步下单:MQ 削峰 📬
- Redis 扣减成功后,我们不立刻操作数据库。
- 而是马上给用户返回“抢购成功,正在创建订单...”。
- 同时,把“用户ID、商品ID”等必要信息,闪电般地扔进 RocketMQ 或 Kafka 里。服务端就直接结束了。
- 这一步,把瞬时的百万级并发,变成了后端消费服务可以慢慢处理的流式消息,削峰填谷。
为什么 Lua 脚本能保证原子性?
- Redis 是单线程执行的
- 整个 Lua 脚本会作为一个整体执行
- 执行过程中不会被其他命令打断
最终一致性保证 🤝
Redis 扣减成功后,通过 MQ 异步创建订单。如果订单创建失败,需要进行库存回补:
- 订单服务消费消息失败,发送死信消息
- 库存回补服务消费死信消息,将库存加回 Redis
- 定时任务扫描超时未支付订单,自动取消并回补库存
🛡️ 数据库——最后的兜底防线
面试官追问:那如果消息队列出错了,或者 Redis 有什么问题,数据库层面怎么兜底?
我: 问得好。MySQL 是最后一道闸门,我们必须做最坏的打算。
方案:乐观锁防超卖
消费服务慢慢从 MQ 拉消息,真正去创建订单、扣减数据库库存时,必须用乐观锁。
-- 不用版本号,直接用库存数当条件,更简洁
UPDATE seckill_product
SET stock = stock - 1
WHERE id = #{productId}
AND stock >= 1; -- 核心兜底条件- 执行完后,看返回值。如果这个 SQL 影响行数为 0,说明库存其实已经没了(可能 Redis 那边因为主从延迟或异常,多扣了一点点)。
- 此时,进行补偿操作:把 Redis 里多扣的库存加回去,然后标记该订单失败或回滚。整个链路最终一致。
🔥 极限挑战:热点数据问题怎么解?
如果是不限量的茅台,谁也救不了,只能用更大的集群硬扛。但我们可以:
- 业务隔离:把秒杀系统单独部署,不和日常业务抢资源。
- JVM 本地缓存:如果某个商品真的是绝对热点,可以把它的部分库存前置到服务本地的内存里,用
AtomicInteger自旋扣减,再定时异步同步给 Redis。这样连 Redis 的网络开销都省了。💨
其他关键问题处理 🛠️
- 防黄牛:一人一单限制、实名认证、收货地址校验
- 数据一致性:最终一致性模型,定时对账
- 容灾备份:Redis 主从 + 哨兵,数据库主从复制
- 监控告警:实时监控 QPS、库存、订单量,异常告警
总结 📝
同学,我们回顾下这场秒杀设计的整个逻辑闭环,一张图记牢:
| 层次 | 核心组件/策略 | 解决的核心问题 |
|---|---|---|
| 👆 前端/网关 | CDN、验证码、动态URL、网关令牌桶 | 挡住垃圾流量,削减尖峰 |
| ⚡ 核心业务 | Redis + Lua 原子扣减 | 彻底杜绝超卖 |
| 🔄 异步处理 | 消息队列 RocketMQ/Kafka | 削峰填谷,将并发变流式 |
| 🗄️ 最终兜底 | 数据库乐观锁(库存 ≥ 1) | 防止极端情况下数据不一致 |
| 📈 可观测性 | 监控、告警、限流动态调整 | 随时知道系统还能撑多久 |
高并发秒杀系统的设计精髓就是:"挡"、"削"、"异" 三个字
- 挡:在前端、网关、缓存层挡住 99.9% 的流量
- 削:用消息队列削平流量峰值
- 异:将非核心流程异步化
超卖问题的最优解是Redis+Lua 原子脚本,它在保证原子性的同时,提供了极高的性能,完全能扛住百万级 QPS 的秒杀场景。
如何设计一个短链接生成服务(TinyURL)?
面试官您好!我会按照 "需求→估算→架构→核心模块→优化→扩展" 的思路来设计这个短链接服务。
先明确需求边界 🎯
功能需求(必做)
- 长 URL → 短 URL 转换
- 用户给定长URL,返回一个唯一短链。
- 短 URL → 长 URL 重定向
- 访问短链,重定向到原始长URL。
- 短链接过期时间设置
- 可选:支持自定义短链、设置有效期、访问统计。
- 访问统计(可选加分项)
非功能需求(核心)
- 高可用:不能挂,挂了所有链接都访问不了
- 低延迟:重定向要快,用户感知不到
- 唯一性:同一个长 URL 可以生成不同短 URL(或可选相同)
- 防滥用:防止恶意生成大量短链接
快速容量估算 🧮
假设日活 100 万用户,平均每人生成 2 个短链接,每个链接平均被访问 10 次:
| 指标 | 日量级 | 年级量 |
|---|---|---|
| 写入请求 | 200 万 | 7.3 亿 |
| 读取请求 | 2000 万 | 73 亿 |
| 存储需求 | 每条记录≈100 字节 | 7.3 亿 ×100B≈73GB / 年 |
结论:读远大于写(10:1),是典型的读密集型系统。
整体系统架构 🏗️
核心模块设计 ✨
1. 短链接生成算法(最关键!)
我推荐使用「分布式 ID+Base62 编码」方案,而不是哈希算法:
- ❌ 哈希算法(MD5/SHA1):会有碰撞风险,需要处理冲突,性能差
- ✅ 分布式 ID+Base62:绝对唯一,无碰撞,性能极高
Base62 编码原理:
- 字符集:
0-9a-zA-Z共 62 个字符 - 6 位短链接:62^6 ≈ 568 亿个唯一 ID,完全够用
- 7 位短链接:62^7 ≈ 3.5 万亿个,能用到天荒地老
示例:分布式 ID=123456 → Base62 编码 → w7E
字符集: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789
长度7 → 62^7 ≈ 3,521,614,606,208 (3.5万亿)把长URL转成7位短码,两种主流方案,对比一下。
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 哈希法 | 对长URL做MD5/SHA-1,截取前N位,Base62编码 | 不用依赖ID生成器 | 哈希冲突需要处理(加随机盐、重试),而且短码不连续,存储碎片 |
| 发号器 + Base62 | 自增ID(64位整数) → Base62编码成短码 | 无冲突,短码连续,支持水平扩展 | 依赖发号器,需处理跳号、ID耗尽的问题 |
2. 分布式 ID 生成器
使用雪花算法(Snowflake)或美团 Leaf:
- 生成 64 位自增 ID
- 保证分布式环境下全局唯一
- 趋势递增,有利于数据库索引
3. 重定向逻辑
注意:使用 302 而不是 301 重定向!
- 301 是永久重定向,浏览器会缓存,无法统计访问量
- 302 是临时重定向,每次都会访问服务器,可以统计访问量
⚙️ 核心流程与API设计
整体流程图:
RESTful API 设计:
POST /v1/shorten- 请求体:
{ "long_url": "...", "expire_time": ... } - 响应:
{ "short_url": "https://t.cn/abc123" }
- 请求体:
GET /{shortCode}- 返回:
302 Location: {long_url}(一般用302方便统计,301永久对SEO好但不易调整)
- 返回:
数据库设计 🗄️
CREATE TABLE `short_url` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`short_code` varchar(10) NOT NULL COMMENT '短链接码',
`long_url` varchar(2048) NOT NULL COMMENT '原始长URL',
`expire_time` datetime DEFAULT NULL COMMENT '过期时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_short_code` (`short_code`),
KEY `idx_expire_time` (`expire_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;- 分片策略:对
short_code的前1~2位(Base62)取模分片,比如共256个分片,读写均匀分布。 - 去重:同一个长URL是否要返回同一短码?可以,但加长URL索引消耗大,可以引入布隆过滤器快速判断长URL是否可能已存在,再走DB精确查。
- 缓存策略:Redis 缓存
shortCode → longUrl,TTL可以根据热度设置,同时冷热分离。
优化点:
- 短链接码建唯一索引
- 过期时间建索引,方便定时清理过期数据
- 长 URL 长度限制 2048 字节(浏览器普遍支持的最大长度)
缓存策略 🚀
- 缓存键:
short_url:{short_code} - 缓存值:长 URL
- 过期时间:与短链接本身的过期时间一致
- 缓存穿透:布隆过滤器过滤不存在的短链接码
- 缓存击穿:热点 key 互斥锁
- 缓存雪崩:缓存过期时间加随机值
分布式与高可用保障 🛡️
- 服务集群化:无状态服务,水平扩展
- 数据库主从:主库写,从库读
- Redis 集群:主从 + 哨兵模式
- 限流熔断:防止恶意请求打垮服务
- 降级策略:极端情况下关闭统计功能,保证核心重定向功能可用
面试官: “发号器怎么保证全局唯一,还要高性能?”
候选人: “可以用Snowflake改良版,或者直接用美团的 Leaf、滴滴的 Tinyid 这种开源方案。”
- Snowflake 64bit 结构:
[1 bit 保留] [41 bit 时间戳] [10 bit 机器ID] [12 bit 序列号]- 每秒每台机器最多生成 4096 个ID,多机器扩展没问题。
- 转换成 Base62 短码大致7位,美观好记。
高并发瓶颈处理:
- 短链跳转是读多写少,可以大量用CDN、边缘节点做302重定向。
- API层无状态,方便横向扩展,前面挂Nginx/网关。
- 应对突发流量,可以对热门短链预热到本地缓存或Nginx共享内存。
安全与风控
- 非法URL过滤:黑名单域名、钓鱼、恶意网站检测,可调用安全API。
- 限流:同一IP/用户限制生成频率,防刷。
- 短链遍历防护:短码不要完全连续递增,可加随机扰动或加密。比如在ID上异或一个随机种子再Base62。或者直接采用哈希 + 随机盐方案,降低被猜测概率。
进阶优化点(加分项)🌟
- 自定义短链接:允许用户指定短链接码;直接在映射表里插入,但要做冲突检测(唯一索引)。
- 访问统计:PV、UV、地域、设备等
- 批量生成:支持批量生成短链接
- 防盗链:限制某些域名的访问
- HTTPS 支持:短链接也支持 HTTPS
- 数据归档:定期归档过期数据到冷存储
- 过期清理:定时任务扫表,删除过期数据,也可懒删除——访问时判断是否过期,若过期返回410 Gone,异步清理。
可能的坑与解决方案 ⚠️
- 短链接码冲突:使用分布式 ID + 唯一索引,绝对不会冲突
- 长 URL 重复:可以加一个长 URL 的哈希索引,相同长 URL 返回相同短链接(可选)
- 恶意生成:IP 限流、用户认证、验证码
- 过期数据清理:定时任务 + 惰性删除
📊 容量预估(数字要脱口而出)
| 指标 | 计算 | 估算值 |
|---|---|---|
| 日生成短链 | 假设10亿/天 | ~ 11.5K QPS |
| 日访问量 | 读写比 10:1 | ~ 115K QPS |
| 存储量(1年) | 10亿×365×1KB ≈ 365TB | 需压缩+分库 |
| 缓存热点 | 20%短链占80%访问 | 内存够存热门Key |
实际大厂比这还大,所以要分层缓存、异地多活。
如何设计一个分布式 ID 生成器?
面试官您好!关于分布式 ID 生成器的设计,我会从核心需求、方案选型、主流实现、生产优化四个层面来回答,这也是我在实际项目中落地过的思路。
先明确分布式 ID 的核心要求 📋
一个合格的分布式 ID 必须满足这 6 个硬性指标:
| 指标 | 说明 | 重要性 |
|---|---|---|
| 🎯 全局唯一性 | 绝对不能出现重复 ID | ⭐⭐⭐⭐⭐ |
| ⏱️ 趋势递增 | 便于数据库索引优化,提升写入性能 | ⭐⭐⭐⭐ |
| ⚡ 高可用 | 不能有单点故障,服务宕机影响要最小 | ⭐⭐⭐⭐⭐ |
| 🔢 低延迟 | 生成 ID 要快,不能成为系统瓶颈 | ⭐⭐⭐⭐ |
| 信息安全 | 不能泄露业务量、时序等敏感信息 | ⭐⭐⭐ |
| 可扩展性 | 支持未来业务量增长 | ⭐⭐⭐ |
| 📏 长度适中 | 64bit 为佳,存储友好,太长浪费内存 | ⭐⭐⭐ |
其实除了这几点,还有一些隐性要求:无三方依赖(越少越好)、接入简单、容易运维。
常见方案对比与选型 🧐
我整理了目前业界主流的 5 种方案,各有优劣:
面试加分点:我会根据业务场景选择方案。如果是内部系统且并发不高,数据库自增足够;如果是高并发互联网业务,雪花算法和号段模式是首选。
为什么最后选了 Snowflake 路线?
我做了一个决策矩阵,直观对比一下 📊
结论很明确:Snowflake 方案最均衡。缺点就是时钟强依赖,但我们可以用工程手段解决。
主流实现:雪花算法详解 ❄️
雪花算法是 Twitter 开源的经典实现,也是面试必考点。它生成一个 64 位的 long 型 ID,结构如下:
- 1bit:保留恒为0,保证 ID 为正数
- 41bit 时间戳:可支持 69 年
(2^41-1)/1000/3600/24/365 ≈ 69 - 10bit 机器 ID:支持 1024 个节点
- 12bit 序列号:每毫秒可生成 4096 个 ID,单机 QPS 上限 409.6w
在实际落地时,我一般会根据公司规模调整位段,比如 10bit 机器 ID 拆成 5bit 机房 + 5bit 机器,运维粒度更细。
核心流程与时钟回拨处理 ⏰
生成流程用下图表达,重点是时钟回拨的处理逻辑:
处理方法:
- 短回拨 (<5ms):原地自旋等待,因为很多时候 NTP 校准也就回拨几毫秒,等待后时间追上即可
- 长回拨:直接抛业务异常,或者触发预留备用 workerId 自动切换,并立即告警通知运维。
- 更彻底的办法:像美团 Leaf 的方案,周期性上报时间戳到存储,启动时校验,避免历史回拨。
核心计算逻辑(Java 代码片段):
// 核心参数
private final long twepoch = 1672502400000L; // 2023-01-01 00:00:00
private final long workerIdBits = 5L;
private final long datacenterIdBits = 5L;
private final long sequenceBits = 12L;
// 生成ID的核心方法
public synchronized long nextId() {
long timestamp = timeGen();
// 处理时钟回拨
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成ID");
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) |
sequence;
}高可用部署:机器 ID 如何分配?
1024 个机器 ID 不能写死在配置文件里,要用自动注册机制。
架构上我倾向这样:
- 启动时向 ZK 创建临时顺序节点,节点序号作为 workerId。
- 定时心跳,临时节点删除后自动被回收,workerId 不会冲突。
- 如果不想引入 ZK,也可以用 DB + 乐观锁的方式:维护一张
worker_node表,每个节点 INSERT 一条记录,用自增 ID 作为 workerId,定期 UPDATElast_heartbeat,超时未更新则节点被视为下线,可回收。
这种设计让整个 ID 生成层无状态(指逻辑无状态),只是 WorkerId 需要一次注册。
性能再优化:号段模式 (Segment) 🌱
Snowflake 单机延迟虽然极低,但毕竟每次生成 ID 都要做位运算和时钟判断。如果追求极致性能(比如应对秒杀),可以用双 Buffer 号段模式,即 美团 Leaf-segment 思路:
- Proxy 从 DB 批量取号段(例如一次拿 1000 个号码),放到内存 Buffer 里
- 客户端通过 RPC 直接从 Buffer 取,无锁、纯内存分发,QPS 可达数百万
- 双 Buffer 保证切换时无缝衔接,不会阻塞
这种方案彻底摆脱了时钟依赖,缺点是严格递增变趋势递增,且需引入 DB 存储当前最大 ID,但 DB 压力极小(一次取号段撑很久)。
生产环境必须解决的问题 ⚠️
这部分是区分普通开发和资深开发的关键!
1. 时钟回拨问题(最致命)
- 轻度回拨(
<5ms):直接等待时钟追上 - 中度回拨(
<1s):使用备用机器 ID - 重度回拨(
>1s):抛出异常,人工介入 - 终极方案:引入时间同步服务,禁止服务器自动同步时间
2. 机器 ID 分配问题
- 手动配置:适合机器少的场景
- 基于 IP 哈希:自动分配,但有冲突风险
- 基于 ZooKeeper/etcd:分布式自动分配,推荐方案
3. 性能优化
- 去掉 synchronized,使用 CAS 操作
- 预生成一批 ID 缓存起来
- 调整时间戳和序列号的位数分配
进阶方案:美团 Leaf 🍃
如果业务要求更高,可以参考美团开源的 Leaf 方案,它结合了号段模式和雪花算法的优点:
- Leaf-segment:数据库号段模式,一次获取一批 ID,性能极高
- Leaf-snowflake:优化版雪花算法,解决了时钟回拨和机器 ID 分配问题
面试总结与加分话术 ✨
" 面试官,以上就是我对分布式 ID 生成器的理解。总结一下:
- 首先要明确业务的核心需求,不要过度设计
- 雪花算法是最通用的方案,但一定要解决时钟回拨问题
- 生产环境建议使用成熟的开源实现,如美团 Leaf、百度 UidGenerator
- 如果并发量特别大,可以考虑号段模式,性能比雪花算法更好 "
最终设计总结 🎯
我会给候选人画这样一张决策路径图:
通用场景我更推荐 改良版 Snowflake,因为它足够轻量,无外部存储依赖,只要把时钟回拨和 WorkerId 注册这两个“坑”填好,就能稳定运行 💪。
极端高并发或绝对递增场景,则上 Leaf-Segment 做兜底。
如何设计一个延迟任务系统?订单超时自动取消
面试官您好!关于订单超时自动取消的延迟任务系统设计,我会从需求拆解→方案选型→架构设计→核心问题解决→优化演进这 5 个步骤来阐述,确保系统高可用、可扩展且易维护。
🎯 核心问题:用户下单后30分钟未支付,系统要可靠地自动取消订单,释放库存。
如果只是写个定时任务每分钟扫表,小项目确实够用,但大厂场景下,扫表带来的数据库压力和时效性问题是不能接受的。所以我们要设计一个通用的延迟任务系统。
先明确核心需求与边界 📋
- 核心功能:订单创建后,若 30 分钟未支付,自动取消订单并释放库存
- 非功能需求:
- 延迟精度:±1 分钟内可接受(订单场景对精度要求不高)
- 高可用:不能因为系统故障导致订单无法取消
- 可扩展性:支持百万级订单 / 天,未来可扩展到千万级
- 幂等性:同一订单只能取消一次
- 可观测性:任务执行状态可追踪、可重试
先定清楚我们要解决的问题边界:
| 维度 | 需求 |
|---|---|
| 触发时机 | 订单创建后延迟30分钟执行取消逻辑 |
| 任务特点 | 海量、单次、非周期性 |
| 可靠性 | 至少一次执行,丢任务不可接受 |
| 实时性 | 分钟级精度即可,不需要毫秒级 |
| 可管理 | 支持取消任务(用户付款了,取消任务要删除) |
| 高性能 | 支撑10万+/秒的任务提交 |
💡 所以,我们需要的是一个高可靠、高性能、可取消的延迟任务投递系统。
常见延迟任务方案对比 📊
我先对比一下业界主流的几种实现方案,再给出最终选型:
| 方案 | 实现原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 定时任务轮询 | 每分钟扫数据库create_time < now()-30min且状态为待支付的订单 | 实现简单,无额外依赖 | 数据库压力大,延迟高,数据量大时性能差 | 小流量、低并发场景 |
| JDK DelayQueue | 基于优先级队列实现,元素到期才能出队 | 纯 Java 实现,延迟精度高 | 内存存储,重启丢失,无法分布式,OOM 风险 | 单机、短延迟、不重要任务 |
| Redis ZSet | 用score存储到期时间戳,定时轮询zrangebyscore获取到期任务 | 性能好,支持分布式,实现简单 | 轮询有延迟,数据量大时 Redis 压力大 | 中流量、中等延迟场景 |
| RabbitMQ 死信队列 | 消息 TTL + 死信交换机,消息到期后转发到死信队列 | 天然支持分布式,可靠性高,无轮询 | 队列级 TTL 不精确,消息堆积问题,不支持动态修改延迟时间 | 固定延迟、高可靠场景 |
| 时间轮算法 | 基于环形数组 + 指针,按时间槽存储任务 | 性能极高,O (1) 插入和删除,支持海量任务 | 实现复杂,需要考虑分布式和高可用 | 高并发、海量延迟任务场景 |
最终选型结论:
- 中小公司 / 初期:Redis ZSet(开发快,运维简单),30分钟足够,天级几百万任务轻松扛。
- 大厂 / 高并发:时间轮 + 消息队列(性能最优,可扩展性最强),比如饿了么的“Hermes”、美团的“CTDelayQueue”。
- 本次我会重点讲解Redis ZSet 方案(面试最常考,落地性最强),最后补充时间轮的优化方向。
Redis ZSet 方案核心架构 🏗️
核心流程详解 🔄
1. 订单创建阶段 ✅
- 订单服务创建订单,状态设为
WAIT_PAY - 向 Redis ZSet 添加任务:
ZADD delay:order:cancel {当前时间+30分钟} {订单ID} - 这里要注意原子性:订单入库和添加 Redis 任务必须在同一个事务中,避免订单创建成功但任务丢失
// 伪代码
String taskId = "order:cancel:" + orderId;
long executeTime = System.currentTimeMillis() + 30 * 60 * 1000;
redis.zadd("delay_queue", executeTime, taskId);简单到一个命令,score就是未来的执行时间,member里包含业务标识。
2. 任务调度阶段 ⏰
- 启动一个独立的调度服务(可以是多实例),每分钟执行一次:
long now = System.currentTimeMillis();
Set<String> expiredOrders = redisTemplate.opsForZSet().rangeByScore("delay:order:cancel", 0, now, 0, 100);- 每次最多取 100 条,避免一次性拉取太多导致 OOM
- 多实例调度时要加分布式锁,防止同一任务被多个实例处理:
String lockKey = "lock:delay:order:" + orderId;
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
// 处理任务
}用Lua把“查询+删除”打包成原子操作,避免多Worker取到重复任务。
// Lua脚本保证原子性:取出并删除
String lua =
"local items = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 100) " +
"if #items > 0 then redis.call('ZREM', KEYS[1], unpack(items)) end " +
"return items";
List<String> tasks = redis.eval(lua, ...);3. 任务执行阶段 🚀
- 调度器获取到期订单后,发送
order_cancel消息到 MQ - 订单消费服务消费消息,执行以下操作:
- 查询订单状态,若已支付则直接返回(幂等性校验)
- 开启数据库事务,更新订单状态为
CANCELED - 调用库存服务释放库存
- 从 Redis ZSet 中删除该任务:
ZREM delay:order:cancel {订单ID} - 提交事务
4. 任务取消
用户付款成功,直接从ZSet中删除:
redis.zrem("delay_queue", "order:cancel:12345");这是ZSet方案的一大优势:O(1)级别主动取消,时间轮和RocketMQ都很难做到这么直接。
关键问题与解决方案 🛠️
这部分是面试的加分项,一定要说清楚!
1. 任务丢失问题 ❌
- 问题:调度器获取任务后,在发送 MQ 前宕机,任务就丢失了
- 解决方案:采用 "先标记后删除" 的两阶段处理:
- 调度器获取任务后,先将任务 score 改为
now+5分钟(临时标记) - 发送 MQ 成功后,再真正删除任务
- 若调度器宕机,5 分钟后任务会再次被其他实例处理
- 调度器获取任务后,先将任务 score 改为
2. 幂等性问题 🔄
- 问题:同一订单可能被多次取消,导致库存重复释放
- 解决方案:
- 数据库层面:订单状态机约束(只有
WAIT_PAY才能变为CANCELED) - MQ 层面:使用消息 ID 做幂等,消费成功后记录消费日志
- 接口层面:库存释放接口设计成幂等接口
- 数据库层面:订单状态机约束(只有
3. 任务堆积问题 📈
- 问题:大促期间订单量暴增,调度器处理不过来
- 解决方案:
- 分片处理:将 ZSet 按订单 ID 哈希分片到多个 key,多个调度器实例分别处理不同分片
- 增加消费服务实例数,提高消费能力
- 限流降级:极端情况下可以适当延长轮询间隔
4. 动态修改延迟时间 ⏱️
- 问题:用户支付时需要立即取消延迟任务,或者运营需要调整超时时间
- 解决方案:
- 支付成功时,直接从 ZSet 中删除对应的任务:
ZREM delay:order:cancel {订单ID} - 调整超时时间时,更新 ZSet 中任务的 score:
ZADD delay:order:cancel {新的到期时间} {订单ID}
- 支付成功时,直接从 ZSet 中删除对应的任务:
高可用与可观测性 📈
高可用:
- Redis 集群部署,主从 + 哨兵,保证 Redis 不宕机
- 调度服务多实例部署,避免单点故障
- MQ 集群部署,消息持久化,保证消息不丢失
可观测性:
- 监控 Redis ZSet 中任务数量和堆积情况
- 监控 MQ 消息堆积量和消费速度
- 监控任务执行成功率和平均延迟
- 记录所有任务的执行日志,方便排查问题
进阶优化:时间轮方案 🚀
如果公司规模很大,订单量达到千万级 / 天,Redis ZSet 方案会遇到性能瓶颈,这时候可以升级为时间轮 + 消息队列方案:
时间轮的优势:
- 插入和删除任务都是 O (1) 时间复杂度
- 支持海量任务,性能远高于 Redis ZSet
- 支持更复杂的任务调度(如重复执行、 cron 表达式)
推荐实现:
- 开源方案:XXL-Job(简单易用)、Quartz(功能强大)
- 自研方案:基于 Netty 的 HashedWheelTimer 实现分布式时间轮
关键细节深挖
🌟 Q1:如果任务量太大,单Redis ZSet扛不住怎么办?
我会按 orderId 哈希分片到多个 Redis 实例/集群,每个 Worker 负责几个分片。大厂里通常是一个ZSet对应一个Redis Cluster的Slot,或者直接多套。
🌟 Q2:Worker取走任务后还没来得及投递到MQ就宕机了,任务岂不丢失?
这就是“至少一次”的保障。改进做法是:
- ZSET取出后,先
hset到另一个pending_taskshash中,记录状态。 - 投递MQ成功后再删除 pending 记录。
- 一个定时扫描线程处理超时的 pending 任务,重新放回ZSet或直接重试。
这样保证了 At-Least-Once。
🌟 Q3:时间同步问题
不同机器时钟可能不一致,ZSet方案基于服务器当前时间,只要Redis和Worker都使用NTP同步,差距在秒级,对30分钟延迟完全可接受。
🌟 Q4:如果未来要升级成时间轮方案,怎么设计?
我会把架构分层:
- Delay-Server:分布式部署,内存使用Netty的HashedWheelTimer。
- 持久化:任务提交时先写入Redis/RocksDB,然后加载到内存时间轮。
- 分片:一致性哈希把任务打散到不同节点。
- 可靠性:节点宕机时,其负责的slot由其他节点接管,从持久化存储中恢复任务。
这个方案也是饿了么Hermes延迟任务平台的核心思路,支撑了千万级延迟任务。
总结 📝
面试官,以上就是我对订单超时自动取消系统的设计思路。总结一下:
- 初期优先选择Redis ZSet方案,开发快,运维简单,能满足大部分场景
- 重点解决任务丢失、幂等性、任务堆积这三个核心问题
- 做好高可用和可观测性设计,保证系统稳定运行
- 当业务量增长到一定规模时,可以平滑升级为时间轮方案
如何设计一个排行榜系统?
面试官您好,我会从需求拆解→核心设计→架构实现→优化方案四个维度来回答这个问题,这也是大厂系统设计面试的标准答题思路。
先对齐需求(面试第一步,避免答非所问)✅
首先我会和面试官确认核心需求,避免过度设计:
现实中的排行榜,比如游戏战力榜、直播间礼物榜、微博热搜。
- 功能需求:支持实时 / 定时更新、按不同维度排行(积分、战力、点赞)、查询个人排名、查询 TopN、支持分页
- 非功能需求:
- 高并发:支持百万级 QPS 查询
- 低延迟:查询响应
< 50ms - 一致性:最终一致性即可,允许 1-5 秒延迟
- 可扩展:支持千万级用户量
抓住这几个核心问题:
- 排行维度:按什么排序?(分数/时间/热力值)
- 刷新频率:实时?每秒?每分钟?
- 数据规模:全量玩家(亿级)还是 Top 100?
- 查询模式:查自己的名次、Top N、附近的人
- 持久化与过期:历史周榜、月榜要不要?
一开始就明确这些,避免过度设计。
核心数据结构选型(这是得分关键点)💡
排行榜的本质是有序集合,不同数据结构的性能差异巨大:
| 数据结构 | 插入时间 | 查询排名 | 查询 TopN | 适用场景 |
|---|---|---|---|---|
| 关系型数据库 (ORDER BY) | O(1) | O(NlogN) | O(NlogN) | 小数据量 (万级以下) |
| 数组 + 排序 | O(N) | O(logN) | O(1) | 静态数据 |
| 跳表 (SkipList) | O(logN) | O(logN) | O(K) | 动态数据 |
| Redis ZSet | O(logN) | O(logN) | O(K) | 互联网高并发场景 |
结论:互联网场景下Redis ZSet 是最优解,它底层就是跳表实现,完美匹配排行榜的所有操作需求。
🧱 方案演进:由小到大,逐步推演
1️⃣ 小规模时代:MySQL 硬扛
| 规模 | 方案 |
|---|---|
几十万用户,QPS < 100 | SELECT * FROM user_score ORDER BY score DESC LIMIT 100 |
缺点显而易见:数据量大时,ORDER BY 会导致全表扫描、排序慢、扛不住并发。可加索引 score,但实时更新频繁时索引维护代价高。此时用 Redis 缓存 Top N 是自然选择。
2️⃣ 中大规模:Redis Sorted Set (ZSET) 👑
核心数据结构:ZADD leaderboard score member
它底层是跳表+哈希,ZADD、ZRANK、ZREVRANGE 都是 O(log N),完美支撑实时榜单。
举个游戏总榜的例子:
ZADD game:rank:total 9850 player_1001
ZADD game:rank:total 10020 player_1005
# 查 Top 10
ZREVRANGE game:rank:total 0 9 WITHSCORES
# 查 player_1005 排名(从0开始)
ZREVRANK game:rank:total player_1005同分问题 ⚡:分数相同时,ZSET 按 member 字典序排序。更好的做法是把时间戳编码进 score:
score = 实际分数 * 10^13 - 达成时间戳这样同分时先达到的排前面。
3️⃣ 大规模/海量数据:分区 + 异步 + 近似榜单
当用户过亿,一个 ZSET 内存扛不住(单个 key 内存过 GB),必须水平拆分。
📊 分区策略:
- 按用户 ID 哈希分桶,例如 100 个桶
rank:bucket:0 ~ rank:bucket:99 - 每个桶内部维护一个 ZSET,存放该分区的用户分数
- 要查全服 Top N,需从每个桶取 Top N,然后归并:
100个桶各取 Top 100 → 归并 10000 条 → 再排序取最终 Top 100这牺牲了一点实时全局的精确性(如果是取 Top 100,结果是精确的),查询复杂度 O(桶数 * log(桶内数))。
写路径异步化 📬:用户每次分数变化不直接写 Redis,先发 Kafka,由消费者批量聚合写入,削峰填谷。
🏗️ 完整架构设计(附图)
┌─────────────┐
玩家动作/打赏 │ API 网关 │
└──────┬──────┘
│ 写入消息
┌──────▼──────┐
│ Kafka │ (分区按 user_id)
└──────┬──────┘
│ 批量消费
┌─────────▼─────────┐
│ 排行更新服务 │
│ (计算score,更新 │
│ 分区ZSET) │
└────────┬─────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Redis │ │ Redis │ │ Redis │ ... 100 个分区实例/分片
│ Bucket 0 │ │ Bucket 1 │ │ Bucket N │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└────────────┼────────────┘
│ Top N 聚合查询
┌──────▼──────┐
│ 排行查询服务 │ (合并、缓存结果)
└──────┬──────┘
│
┌──────▼──────┐
│ 客户端 │
└─────────────┘查询服务可以做本地缓存(Caffeine)几秒,挡住高频穿透。
基础版架构设计(能满足 90% 场景)🏗️
核心操作实现
1. 更新用户分数
// Redis命令:ZADD key score member
redisTemplate.opsForZSet().add("game:rank:1001", userId, score);2. 查询用户排名
// Redis命令:ZREVRANK key member(从高到低)
Long rank = redisTemplate.opsForZSet().reverseRank("game:rank:1001", userId);
// 注意:Redis排名从0开始,返回给前端要+1
return rank == null ? -1 : rank + 1;3. 查询 TopN
// Redis命令:ZREVRANGE key 0 N-1 WITHSCORES
Set<ZSetOperations.TypedTuple<Object>> topN =
redisTemplate.opsForZSet().reverseRangeWithScores("game:rank:1001", 0, 99);进阶优化方案(拉开差距的加分项)🚀
1. 解决 Redis 单 Key 热点问题
- 分片策略:按用户 ID 哈希分片到多个 ZSet,查询 TopN 时合并结果
- 读写分离:主节点写,从节点读,分担查询压力
2. 支持多维度排行榜
- 每个维度一个独立的 ZSet,如:
game:rank:score、game:rank:kill - 复合维度:将多个分数拼接成一个大分数,如:
总分数*1000000 + 击杀数
3. 定时排行榜(日榜 / 周榜 / 月榜)
4. 百万级用户优化
- 分层排行榜:前 1000 名实时更新,1000 名以后按小时更新
- 缓存预热:提前将 Top1000 加载到本地缓存
- 分页优化:禁止跳页查询,只允许上一页 / 下一页
5. 📆 历史榜单:日榜/周榜怎么搞?
- 实时榜:用 ZSET,当天数据
rank:day:2026-05-29。 - 定时归档:凌晨 cron 将上一周期的 ZSET
RENAME或DUMP成冷数据,存入 MySQL/对象存储。 - 查询历史榜:直接去 MySQL 查,或者 Redis 保留近 7 天的 key,过期删除。
6. 🔒 一致性 & 高可用
- Redis 持久化:开 AOF + RDB 混合,避免重启丢数据。
- 故障转移:用 Redis Cluster 或哨兵,每个分片一主多从。
- 幂等性:更新服务消费消息时,用
ZADD直接覆盖,天然幂等。 - 数据补偿:定时从 DB 全量扫描分数与 Redis 对比,修复差异(低频率,如每天一次)。
常见问题与解决方案 ⚠️
1. 分数相同怎么办?
- 方案:分数相同按时间排序,将时间戳拼接到分数末尾
- 示例:
score = 原始分数*1000000000 + (Long.MAX_VALUE - 时间戳)
2. Redis 宕机怎么办?
- 主从 + 哨兵架构保证高可用
- 定时将 ZSet 数据持久化到 MySQL,宕机后快速恢复
3. 数据一致性问题?
- 采用 "先写数据库,再更新 Redis" 的最终一致性方案
- 关键业务增加消息队列异步重试机制
总结 📝
一个优秀的排行榜系统应该是:
- 简单高效:优先使用 Redis ZSet,不要重复造轮子
- 分层设计:热点数据放 Redis,冷数据放数据库
- 可扩展:支持分片和多维度,方便后续业务扩展
- 容错性:做好降级和备份,保证系统可用性
你要答出这几点才稳
| 关键点 | 一句话说明 |
|---|---|
| 🔢 数据结构选择 | Redis ZSET 是首选,O(log N) 操作,支撑实时榜 |
| 🧩 扩展性 | 分桶策略,牺牲少量实时全服精确性换水平扩展 |
| ⚖️ 同分处理 | 时间戳编入 score 或复合排序规则 |
| 📬 异步削峰 | Kafka 解耦写入,批量更新 ZSET |
| 📅 历史归档 | 定时 dump 冷榜单到 MySQL,Redis 只留热数据 |
| 🏥 高可用 | 哨兵/集群 + 定时对账修复 |
如何设计一个实时消息推送系统?
面试官您好!我会从需求拆解→架构设计→核心模块→难点解决→扩展优化这 5 个步骤来设计这个系统。
先明确需求边界 🎯
功能需求
- ✅ 单用户推送(给指定用户发消息)
- ✅ 批量推送(给一批用户发消息)
- ✅ 全量推送(广播给所有在线用户)
- ✅ 消息持久化与离线拉取
- ✅ 消息状态追踪(已发送 / 已送达 / 已读)
非功能需求(核心指标)
- 低延迟:端到端延迟
< 1秒 - 高可用:SLA 99.99%,无单点故障
- 高并发:支持百万级同时在线连接
- 可靠性:消息不丢失、不重复、有序性保证
- 平滑扩容: 下线不影响业务。
整体系统架构 🏗️
核心设计思想:解耦 + 异步 + 分层,将业务系统与推送系统完全解耦,通过消息队列削峰填谷。
数据流图
- 客户端:通过 WebSocket 与网关保持长连接。
- 推送网关:核心骨头,负责连接管理、鉴权、收发消息。无状态,可水平扩展。
- 路由服务(基于 Redis):记录 userId → 网关节点 的映射,解决“该往哪个网关发消息”的问题。
- 消息队列:解耦业务与推送,削峰填谷,保证消息顺序和可靠性。
- 存储层:热数据在 Redis(离线消息暂存),冷数据落地 DB。
核心模块详解 🔧
1. 消息推送网关
- 统一接收所有业务系统的推送请求
- 做鉴权、限流、参数校验
- 按消息类型(单推 / 群推 / 全推)路由到不同 Kafka Topic
- 提供 HTTP/GRPC 接口,方便业务接入
2. 连接管理器(最核心)
- 维护所有客户端的长连接映射:
userId -> [connectionId1, connectionId2...] - 支持多端同时在线(手机 + 电脑 + 平板)
- 心跳检测:客户端每 30 秒发心跳,服务端 90 秒未收到则断开连接
- 连接负载均衡:一致性哈希算法,用户固定连接到同一台推送服务器
3. 推送服务
- 消费 Kafka 消息,根据 userId 查询在线状态
- 在线用户:直接通过长连接推送
- 离线用户:写入离线消息库,等用户上线时拉取
- 消息重试机制:推送失败后指数退避重试,最多 3 次
消息流转:一条消息的“极速之旅” 🏎️
标准在线推送链路:
🔹 为什么走 MQ 绕一下?
业务直接调网关接口会耦合高、流量尖峰易打挂。MQ 既能削峰,又能让网关专心做推送,还能通过消费者组实现广播或单播(按 userId 哈希分区)。
🔹 消息必达机制
- 每条消息带全局唯一
msgId(雪花算法)。 - 客户端收到后回
ACK,网关超时未收到则重推(最多 3 次)。 - 重推仍失败转离线存储,等用户下次上线主动拉取。
- 网关重启?启动时扫 Redis 离线队列,重新加载未 ACK 的消息。
连接管理:怎么扛住千万长连接 🚀
技术选型:Netty + WebSocket,基于事件驱动的 NIO 模型,单机几十万连接完全可行。
关键细节:
- 自定义应用层协议(如 Protobuf),在 WebSocket 帧上加
msgId、ack、业务类型,解决分包粘包。 - 心跳保活:客户端每 30 秒发 Ping,网关回复 Pong,三次无响应主动断开,释放资源。
- 连接状态机:
INIT → AUTH → ONLINE → OFFLINE,配合 Redis 记录在线状态(user:123:status)。 - Session 存储:每个连接绑定 userId,放在内存 Map 里,保证极致推送速度,但需注意内存估算(千万用户 × 单连接 2KB ≈ 20GB,可分片)。
📡 断线重连 + 补偿:客户端断线后指数退避重连,网关重新绑定 session;重连成功后,从离线队列拉取遗漏消息。
路由难题:消息怎么精准投递到那台网关 🗺️
问题:网关集群几十台,推送时怎么知道该连哪台网关?
我采用 两层路由:
- 接入层:客户端连到哪台网关,由 SLB/负载均衡随机分配,网关在连接认证完成后,立即向 Redis 注册:
SET push:route:uid gateway-ip:port EX 120(带过期时间,靠心跳续约)。 - 发送层:推送网关消费 MQ 消息时,根据
uid_to查询 Redis 路由表,如果命中,直接通过内部 gRPC 或 Netty 长连接,把消息扔给目标网关,再由其本地 session 推送出去;若未命中,则直接存离线。
🔄 优化点:为避免 Redis 热 Key,可用 一致性哈希 将不同用户路由请求映射到固定网关,这样可以直接按哈希算目标网关,省去查 Redis。但缺点是网关扩缩容时路由会漂移,需要配合虚拟节点和客户端重连策略,一般大厂会做路由中间层来屏蔽变化。
离线 & 消息可靠存储 🗄️
- Redis 离线队列:
list结构,key 按用户分桶(如offline:uid%1000),消息体 JSON/Protobuf。设置 TTL 7 天,控制内存。 - 持久化:异步批量写入 MySQL 或 HBase,用于长期历史消息查询。
- 消息拉取:客户端上线后,先拉离线消息(分页),再切到实时推送,避免消息顺序错乱。
高可用与平滑扩容 ⚙️
- 网关无状态:所有状态外移到 Redis(路由表、在线状态、离线队列),网关宕机只损失瞬时连接,客户端重连到其他网关后,重新注册路由即可。
- 滚动发布:摘掉待下线网关的 SLB 权重 → 等旧连接自然断开 → 重启 → 加回权重。
- 容量监控:网关节点内存、GC 情况、连接数、推送延迟百分位,提前水位线告警,结合 K8s HPA 自动扩容。
补充:一些容易被忽略的细节点 💡
- 消息时序:全局递增 ID 或向量时钟,客户端根据 seq 去重和排序。
- 多端同步:同一用户多设备在线,网关内部维护设备列表,推送时往所有设备广播,但给每条消息带
deviceId和ack状态,实现已读漫游。 - 安全:连接时校验 Token,消息体加密(TLS 传输),网关防刷(单连接限流)。
关键技术难点与解决方案 💡
| 难点 | 解决方案 |
|---|---|
| 百万级长连接支撑 | 使用 Netty 框架,基于 Reactor 模型,单机可支撑 10 万 + 连接;集群水平扩展 |
| 消息不丢失 | 1. Kafka 开启 ack=all + 副本机制 2. 推送成功才删除 Redis 中的消息 3. 客户端 ACK 确认机制 |
| 消息重复问题 | 客户端生成唯一消息 ID,做幂等性处理 |
| 消息有序性 | 同一用户的消息发送到同一个 Kafka 分区,保证顺序消费 |
| 全量推送风暴 | 分批次推送,控制 QPS;使用广播 Topic,避免重复发送 |
高可用与扩展性设计 🛡️
- 集群无状态:所有推送服务和连接管理器都是无状态的,可随时扩缩容
- 异地多活:部署多个地域的集群,用户就近接入
- 降级策略:高峰期可暂时关闭非核心功能(如消息已读状态)
- 熔断机制:某个推送节点故障时,自动将连接迁移到其他节点
🧩 总结一句话
实时推送系统 = 高性能长连接网关 + 路由表 + 可靠消息队列 + 分级存储 + 无状态设计
抓住这五个点,再根据业务体量做取舍,基本就能设计出一个生产可用的推送系统。
如何设计一个支持千万级用户的短视频 Feed 流?
这道题其实在短视频公司是核心中的核心,本质是 “为一个海量用户、实时更新的关注或推荐列表,快速生成信息流” 的问题。我先理一下需求,再给方案。
先明确需求边界(面试第一步,避免答非所问 ✅)
- 用户量:千万级 DAU,每天可能有几亿次 Feed 刷新。
- Feed 内容:短视频,文件本身走 CDN,我们主要负责 视频元信息和 Feed 列表。
- 延迟要求:刷新延迟
<200ms,新发布的视频最好能在 秒级 分发到粉丝 Feed。 - 关注流 & 推荐流:这里先聚焦「关注 Feed 流」,推荐流可复用类似架构 + 推荐引擎打分。
- 存储:用户关注关系、Feed 历史、视频元数据。
1.1 功能性需求
- 核心:用户上下滑动刷个性化 Feed 流
- 基础:视频发布、点赞 / 评论 / 转发、关注 / 粉丝
- 扩展:搜索、直播、私信通知
1.2 非功能性需求(千万级用户核心指标)
| 指标 | 要求 |
|---|---|
| 日活用户 (DAU) | 1000 万 + |
| 首屏加载延迟 | <200ms |
| 滑动加载延迟 | <100ms |
| 系统可用性 | 99.99% |
| 数据一致性 | 最终一致性(允许秒级延迟) |
整体核心架构(🚀 分层分布式架构)
核心技术点(🔥 面试得分关键)
3.1 Feed 流生成模式:混合模式(推 + 拉结合)
一个经典的取舍:
- 纯推:大 V 发视频,直接写到千万粉丝的收件箱,读很快,但写扩散爆炸。
- 纯拉:粉丝刷新时,实时去拉取所有关注人的最新视频再合并排序,读很重,大 V 粉丝多时拉取量巨大。
因此我们采用 推拉结合,用“用户分级”来平衡。
纯推 / 纯拉都无法支撑千万级用户,必须采用混合模式:
| 模式 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 推模式(写扩散) | 作者发布视频时,异步写入所有粉丝的 Feed 索引 | 读速度极快,直接取自己的 Feed | 写扩散严重,大 V 发视频会产生百万级写请求 | 普通用户(粉丝数 < 1 万) |
| 拉模式(读扩散) | 用户刷 Feed 时,实时拉取所有关注作者的最新视频 | 写简单,大 V 发视频无压力 | 读速度慢,关注多的用户需要合并大量数据 | 大 V / 明星(粉丝数 > 100 万) |
| 混合模式 | 普通用户用推,大 V 用拉;用户刷 Feed 时合并两者结果 | 平衡读写压力,完美支撑千万级用户 | 实现稍复杂 | 所有场景 |
💡 关键优化:设置粉丝数阈值(比如 10 万),超过阈值自动切换为拉模式;大 V 视频单独缓存,热点隔离。
📱 简单说就是:普通用户发视频用「推」,大 V 发视频用「拉」。
3.2 存储设计(💡 不同数据选最合适的存储)
| 数据类型 | 存储选型 | 存储内容 | 核心原因 |
|---|---|---|---|
| 核心元数据 | MySQL | 用户信息、视频基本信息、关注关系 | 强一致性需求,支持事务 |
| Feed 流索引 | Redis ZSet | 每个用户的 Feed 视频 ID 列表 | ZSet 天然支持按时间 / 权重排序,读写性能极高 |
| 历史数据 | HBase | 历史 Feed、用户行为日志 | 支持 PB 级数据存储,随机读写性能好 |
| 视频文件 | OSS + CDN | 视频原文件、转码后的不同清晰度版本 | 无限容量,CDN 加速降低延迟 |
| 搜索数据 | Elasticsearch | 视频标题、标签、作者信息 | 全文检索能力强 |
⚠️ 重要细节:Redis ZSet 中只存视频 ID 和 score(时间戳 + 推荐分),不存视频详情,避免内存浪费;MySQL 按用户 ID / 视频 ID 哈希分库分表,支撑千万级数据量。
- 关注关系存储与冷热分离
- 用 图数据库或 Redis Sorted Set + MySQL 持久化。
- 用户关注列表:
follow:user:{uid}用 Set 存所有关注的 UID。 - 热度高的用户(粉丝
>10w)标记为大 V,放入一个 Bloom Filter 或单独的 set,发布时快速判断是否采用拉模式。
3.3 个性化推荐集成
采用离线 + 实时混合推荐架构:
- 离线层:Spark 每日计算用户画像、视频特征,生成基础推荐池
- 实时层:Flink 实时处理用户点赞、评论、完播等行为,动态更新推荐权重
- 召回层:多路召回(关注、热门、兴趣、相似用户)
- 排序层:深度学习模型(如 Wide&Deep)进行精排
Feed 收件箱设计
每个用户有一个 Feed 缓存队列(Redis List 或 ZSet),只存最近 N 条(比如 500 条),按时间倒序。
- 推模式写入:普通用户发布 → 找到其所有粉丝 → 把视频 ID、时间戳写入粉丝的
feed:{fans_id}ZSet。 - 拉模式补充:大 V 发布 → 只写入自己的
outbox:{v_uid}ZSet,粉丝刷新时去拉。
刷新时的 Feed 合并逻辑
用户刷新请求到来:
- 从
feed:{uid}取最近 N 条(快速翻页用ZREVRANGE)。 - 从用户关注的 大 V 列表,并发拉取他们各自的
outbox(取最近几条)。 - 在内存中 多路归并排序 后分页返回。
- 对大 V 的拉取结果可以做 短时间本地缓存(比如 3 秒),防止重复刷新压力。
✨ 这样一来,99% 普通用户的写扩散很小,大 V 只多一步实时拉取,刷新的整体延迟可控制在百毫秒内。
千万级并发性能优化(🚀 面试官必问)
1. 多级缓存策略
- 客户端缓存:缓存最近 10 条视频,滑动时预加载下 3 条
- CDN 缓存:视频文件全量缓存,热点视频边缘节点加速
- 服务端缓存:Redis 缓存热点视频详情、用户 Feed 索引;本地缓存热点大 V 视频
- 缓存防护:布隆过滤器防穿透,互斥锁防击穿,过期时间随机防雪崩
2. 全链路异步化
- 所有非核心操作(点赞、评论、通知、统计)全部异步化,通过 Kafka 解耦
- 视频发布流程:上传 OSS → 异步转码 → 异步生成 Feed 索引 → 异步通知粉丝
3. 冷热数据分离
- 热数据(最近 7 天的 Feed)存在 Redis
- 温数据(7 天 - 3 个月)存在 HBase
- 冷数据(3 个月以上)归档到对象存储,按需加载
4. 限流熔断降级
- 接入层限流:Nginx 限制单 IP 请求频率
- 服务层限流:Sentinel 限制每个服务的 QPS
- 降级策略:高峰时关闭非核心功能(如评论加载),只返回基础 Feed 流
千万级用户的性能保障
| 挑战 | 方案 |
|---|---|
| 热点大 V 发视频打崩系统 | 大 V 不打扩散,只拉取;MQ 削峰填谷,限流发布 |
| 关注关系变更时 Feed 维护 | 取关时只需从 Feed 队列中剔除该用户视频(延迟清理) |
| Feed 无限增长 | 收件箱只保留 500 条快照,老数据归档到 HBase/Cassandra |
| 缓存雪崩/穿透 | Feed 缓存多副本 + 过期时间分散;大 V outbox 用本地缓存兜底 |
| 视频元信息高并发读 | Redis Cluster + 本地热点缓存,视频流走 CDN,与 Feed 列表解耦 |
整体架构大概这样:
一些加分细节
- 分页:使用时间戳作为游标,避免传统 offset 在实时插入场景下的数据错位。
- 活跃度分级:根据用户最近 7 天活跃度动态调整推拉策略,僵尸粉不推送,节省资源。
- 异步非核心路径:点赞数、评论数更新不影响 Feed 主链路,单独用计数器服务异步更新。
- 降级策略:拉取大 V outbox 超时时,直接返回已缓存的 Feed,保证用户体验不白屏。
高可用与容灾设计(✅ 大厂必备)
- 服务集群化:所有服务无状态化,多实例部署,自动扩缩容
- 数据多副本:MySQL 主从复制 + 读写分离,Redis 主从 + 哨兵 + 集群,HBase 三副本
- 异地多活:部署多个地域的机房,单机房故障自动切换
- 监控告警:全链路监控(Prometheus+Grafana),关键指标异常实时告警
面试总结(💡 加分项)
“推拉结合 + 用户分级 + Redis 收件箱 + 大 V 拉取” 这套组合拳,能很好地撑起千万级用户短视频 Feed 流,保证核心场景延迟低、系统不易被热点击穿,同时成本可控。😊📱
这个系统设计的核心思路是 "读写分离、分层缓存、混合 Feed、最终一致"。通过推 + 拉混合模式解决了千万级用户的读写压力问题,通过多级缓存和异步化保证了低延迟,通过分库分表和冷热分离支撑了海量数据存储,最终满足了千万级日活用户的短视频 Feed 流需求。
千万级数据量下的分库分表方案设计
面试官您好,我会从为什么分、怎么分、分完怎么办、踩过哪些坑四个维度来回答这个问题,这也是我实际项目中落地千万级订单系统分库分表的完整思路。
🤔 先问自己三个问题
- 真的需要分库分表吗? 千万级数据,如果单表索引设计合理、读写比例悬殊不大,MySQL 在 SSD + 大内存的机器上其实还能撑。但当单表突破 2000 万行,或者写入 QPS 超过 1000,就该拆了。
- 拆分的核心目标是什么? 让数据散列,提升写入吞吐,避免单表 B+ 树过高导致查询抖动。
- 未来要扩容吗? 方案不能只解决眼前,要预留平滑扩容能力。
先搞清楚:为什么必须分库分表?🤔
当单表数据量达到千万级时,MySQL 的性能会出现断崖式下降:
- 索引树高度增加(从 3 层变 4 层),一次查询多一次磁盘 IO
- 写操作锁竞争加剧,TPS 急剧下降
- 备份恢复时间呈指数级增长
- DDL 操作会锁表数小时,业务无法接受
经验值:InnoDB 单表建议不超过 500 万行,单库不超过 1000 万行,单库容量不超过 100GB
📐 方案全景图(先看一眼)
设计思想:垂直拆分业务,水平拆分数据,把库和表当作“格子”,保证数据均匀分布。
核心方案:分库分表的四种方式 📊
1. 垂直拆分(先做,成本最低)
- 垂直分库:将一个大库按业务拆分为多个独立小库,解决连接数和资源竞争问题
- 垂直分表:将大表按字段访问频率拆分,冷热数据分离,提高缓存命中率
2. 水平拆分(千万级核心方案)
- 水平分表:同一个库内,将一张表拆分为多张结构相同的表,解决单表数据量问题
- 水平分库:将数据分散到多个数据库实例,解决单库性能瓶颈和容量问题
最关键:分片策略选择 ✅
这是分库分表成败的核心,我会根据业务场景选择不同策略:
| 分片策略 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 范围分片 | 按时间 / ID 区间 | 扩容简单,数据连续 | 数据倾斜严重,热点集中 | 日志、流水、历史数据 |
| 哈希分片 | 分片键 % 分片数 | 数据分布均匀 | 扩容麻烦,需要数据迁移 | 订单、用户等核心业务 |
| 一致性哈希 | 虚拟节点哈希环 | 扩容影响小,仅迁移 1/N 数据 | 实现复杂,仍有轻微数据倾斜 | 缓存、分布式存储 |
🔥 我在项目中用的是哈希分片 + 范围分片的组合方案:
- 按
用户ID哈希分库分表(保证同一个用户的所有订单在同一个库表) - 同时按
创建时间做范围分区(方便历史数据归档)
🧱 分片策略落地细节
| 维度 | 选型 | 说明 |
|---|---|---|
| 分片键 | user_id (下单用户) | 订单场景下 90% 的查询都带着 user_id,避免跨库 Join |
| 分库算法 | user_id % 4 | 4 个库,初期按 2 的幂次方来,扩容时可做 2 倍拆分 |
| 分表算法 | (user_id / 4) % 16 | 每库 16 张表,单表数据量控制在 500W 以内 💡 |
| 基因注入 | 订单号末几位嵌入用户分片基因 | 例如 order_id = 雪花id + user_id % 64,让订单号自身就能路由到具体库表 |
这样 绝大部分查询 直接命中单库单表,性能跟单机无差异。👍
分库分表后的四大难题及解决方案 🧩
1. 分布式 ID 生成
- 不能用数据库自增 ID(会重复)
- 首选雪花算法(64 位 ID,包含时间戳 + 机器 ID + 序列号)
ID结构: 1bit - 41bit时间戳 - 10bit工作机器 - 12bit序列号- 一般会借助 美团 Leaf 或 滴滴 Tinyid,避免每次取 ID 都调接口。
- 我在项目里会封装一个 本地号段缓存,比如一次拉取 1000 个 ID,用完了再请求,TPS 轻松上几万。
- 注意:解决时钟回拨问题(我用的是百度 UidGenerator)
- 🎯 要点:务必保证 时钟回拨时的容错,要么抛异常人工介入,要么等待。
2. 分布式事务
- 强一致性事务性能太差,互联网项目几乎不用
- 首选最终一致性:本地消息表 + MQ
- 复杂场景用Seata-TCC 模式(注意幂等性和空回滚)
3. 跨库操作
- 跨库 JOIN:绝对禁止!通过字段冗余或业务层两次查询解决
- 分页排序:先在各分片查询,再在业务层聚合排序
- 全局查询:建立 ES 索引,所有查询先走 ES,再回表查详情
4. 平滑扩容
- 采用双倍扩容法:分片数永远是 2 的幂次
- 扩容时先写双份,再迁移数据,最后切流量
- 全程不停止服务,对业务无感知
🚚 5. 数据迁移方案(最容易被问倒的环节)
千万数据量的单表,停服迁移不现实,一般采用 双写 + 增量迁移:
- 存量数据:用脚本分批
select ... limit批量拉取,通过路由中间件插入新集群。 - 增量数据:开启 Canal 或 DTS 监听原表 binlog,同步至新分片表。
- 校验与切换:
- 校:全量对比行数、抽样对比关键字段 md5。
- 切:找一个低峰期,停写 1~2 分钟,切换配置重启,或是通过灰度发版切换数据源。
- 兜底:老旧数据保留一周,能一键回滚。
⚠️ 6. 跨分片查询与事务权衡
分库分表后要牺牲很多:
- 禁止跨库 Join:通过 异构索引 到 ES 或用 宽表(如 order_wide),把用户、订单信息提前合到一张表。
- 非分片键查询:
- 第一次查 ES 拿
user_id,再路由查库——“基因法”支撑按订单号查。 - 或者使用 异构数据同步,比如按商家 ID 再同步一套 ES 索引。
- 第一次查 ES 拿
- 分布式事务:绝大多数场景用 本地事务表 + 消息队列 最终一致。强一致性的场景(如扣库存、支付)才考虑 Seata AT 模式,但性能损耗严重,必须谨慎。
💬 真实项目里,我一般会跟产品约定:订单列表必须传 user_id,后台管理查询走 ES。
📈 7. 扩容怎么搞?(一致性哈希 + 预分片)
不要等到库又满了才想扩容,分库时我们预留 虚拟节点:
- 初期用
user_id % 64(逻辑分片数)定位,然后物理映射到 4 个库各 16 张表。 - 扩容时,比如加 4 个新库,只需修改映射:
逻辑分片 0-15 -> db_0...将部分逻辑表迁移到新库。 - 数据迁移依然用 Canal 增量同步,在线平滑完成。
这样 应用代码 0 改动,只改中间件路由配置,运维和研发都轻松 🤝。
中间件选择 🛠️
- ShardingSphere-JDBC:客户端分片,无中间层,性能最好,我项目中用的就是这个
- Jar 包引入
- 对代码侵入小,轻量,多语言支持弱的 Java 项目首选 ✅
- MyCat:服务端分片,对业务代码零侵入,但有单点风险
- 代理
- 老项目沿用,但性能与官方维护不如前者
- 推荐:中小团队直接用 ShardingSphere-JDBC,生态最完善
我的推荐:新项目直接 ShardingSphere-JDBC,配置写在 application-sharding.yml,绑定数据源即可,分片算法自定义实现 PreciseShardingAlgorithm。
千万级数据最佳实践 💡
- 先做垂直拆分,再做水平拆分,不要上来就水平分
- 分片键选择查询最频繁的字段(如用户 ID、订单 ID)
- 提前规划好扩容方案,分片数建议设为 16 或 32
- 建立数据归档机制,历史数据定期迁移到冷库
- 所有查询必须带分片键,禁止全表扫描
总结 📝
千万级数据量的分库分表,核心是 "提前规划,分步实施"。先通过垂直拆分解决业务耦合问题,再通过水平拆分解决容量问题。分片策略选择哈希分片为主,范围分片为辅,同时解决好分布式 ID、事务、跨库操作和扩容这四大难题。
最后提醒:分库分表是最后手段,如果能通过优化索引、读写分离、缓存解决问题,就不要轻易分库分表,它会大大增加系统复杂度。
如何设计一个接口防刷/限流系统?
面试官您好,我会从需求拆解→架构设计→核心算法→落地实现→进阶优化这 5 个维度来回答这个问题,这也是大厂系统设计面试的标准答题思路。
🧠 先理清需求:防刷 vs 限流
很多时候候选人上来就聊算法,但没分清这两者的侧重点,容易跑偏。
| 维度 | 🔒 防刷(Anti-fraud) | 🚦 限流(Rate Limiting) |
|---|---|---|
| 目标 | 防止恶意用户脚本攻击、薅羊毛 | 保护系统过载,平滑流量 |
| 维度 | 用户ID、IP、设备指纹、业务动作 | 接口、服务、集群整体QPS |
| 策略 | 频次 + 行为特征 + 黑名单 | 令牌桶/漏桶/滑动窗口 |
| 响应 | 拦截 + 验证码/封禁 | 排队、降级、熔断 |
真实系统中,两者往往是组合拳:限流是基础防御,防刷是业务防御。
先明确核心需求 ✅
功能需求
- 接口级限流:控制单个接口的 QPS 上限
- 多维度防刷:支持 IP、用户 ID、设备 ID、接口路径等组合维度
- 灵活配置:支持动态调整限流规则,无需重启服务
- 友好降级:限流后返回统一的错误码和提示信息
非功能需求
- 高性能:限流本身不能成为系统瓶颈(耗时
<1ms) - 高可用:限流系统故障不能影响主业务
- 准确性:限流误差控制在可接受范围内(
<5%) - 可扩展:支持集群环境和水平扩展
整体架构设计 🏗️
我会设计一个分层限流架构,从网关层到服务层层层防护,避免单点失效。
分层设计
限流漏斗三层过滤:
- Nginx 层:基于 IP + 接口的 Nginx
limit_req或lua-resty-limit-traffic模块,快速丢弃异常流量,减少下游压力。 - 网关层(Spring Cloud Gateway / Zuul):使用 Redis 做分布式令牌桶,实现用户/接口维度精细化控制。比如
RequestRateLimiter过滤器。 - 应用层:针对核心交易接口,做最终兜底 + 业务防刷逻辑。
核心代码骨架(以 Spring Cloud Gateway + Redis 为例)
@Configuration
public class RateLimiterConfig {
@Bean
public KeyResolver userKeyResolver() {
// 按用户ID限流,未登录按IP
return exchange -> {
String userId = exchange.getRequest().getHeaders().getFirst("X-User-Id");
String key = userId != null ? "user:" + userId
: "ip:" + exchange.getRequest().getRemoteAddress().getHostString();
return Mono.just(key);
};
}
}配置:
spring:
cloud:
gateway:
routes:
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/order/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100 # 每秒填充令牌
redis-rate-limiter.burstCapacity: 200 # 桶容量,允许突发
key-resolver: "#{@userKeyResolver}"设计思路:
- 网关层做全局粗粒度限流(如单 IP 每秒 100 次),拦截大部分恶意请求
- 服务层做接口细粒度限流(如用户下单接口每秒 10 次),精准控制核心接口
- 所有限流规则统一存放在配置中心,支持热更新
- 全链路监控限流指标,异常时及时告警
核心限流算法对比 📊
这是面试的必考点,我会对比 4 种主流算法的优缺点和适用场景。
| 算法 | 原理 | 优点 | 缺点 | 适用场景 | 表情包 |
|---|---|---|---|---|---|
| 固定窗口计数器 | 将时间划分为固定窗口,每个窗口内计数,超过阈值则限流 | 实现简单,内存占用小 | 存在 "临界问题"(两个窗口交界处流量翻倍) | 要求不高的简单场景 | ⏱️ |
| 滑动窗口计数器 | 将固定窗口拆分为多个小格子,随时间滑动更新计数 | 解决了临界问题,精度较高 | 实现稍复杂,内存占用略大 | 大多数业务场景 | 🪟 |
| 漏桶算法 | 请求像水一样流入漏桶,漏桶以固定速率流出,桶满则丢弃 | 强制控制请求速率,平滑突发流量 | 无法应对突发流量,桶大小难调 | 消息队列削峰填谷 | 🪣 |
| 令牌桶算法 | 以固定速率生成令牌放入桶中,请求需要获取令牌才能通过 | 既平滑流量,又允许一定程度的突发 | 实现复杂 | 互联网接口限流(推荐) | 🎫 |
面试加分项:我会优先选择令牌桶算法,因为它最符合互联网业务特点 —— 平时流量平稳,但允许短时间内的突发流量(如秒杀活动)。
1. 固定窗口计数器
时间轴:|---|---|---|---|---|---|
窗口1秒: [允许100次]
问题:临界突发,00:00:00.9 - 00:00:01.1 可能通过200次 ❌2. 滑动窗口日志
记录每次请求时间戳,滑动清除窗口外记录。
- ✅ 精确
- ❌ 内存占用大,时间复杂度高
3. 滑动窗口计数器(推荐)
结合固定窗口的简单和滑动窗口的平滑。
把1秒窗口切成10个格子,每格100ms
时间轴:[0-100ms] [100-200ms] ... [900-1000ms]
count=15 count=10 count=5
当前窗口总计数 = sum(所有格子)Redis 实现非常优雅:
-- 用 sorted set,score=时间戳,member=唯一请求ID(或直接 count)
ZADD rate:user:123 {now} {now}
ZREMRANGEBYSCORE rate:user:123 0 {now - window}
ZCARD rate:user:123
-- 若计数 > 阈值,拒绝4. 令牌桶 / 漏桶
- 令牌桶:允许突发流量,桶满丢弃令牌。适合秒杀瞬时放量。
- 漏桶:强制匀速,绝对平滑,但突发会等待。
首选令牌桶,生产环境最常用,比如 Google Guava RateLimiter。
分布式环境下的实现方案 🌐
单机限流只能控制单个实例的流量,集群环境下必须使用分布式限流。
方案 1:Redis + Lua 脚本(主流方案)
-- 令牌桶算法Lua脚本(核心逻辑)
local key = KEYS[1]
local capacity = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 令牌生成速率(个/秒)
local now = tonumber(ARGV[3]) -- 当前时间戳
-- 获取桶的当前状态
local bucket = redis.call('HMGET', key, 'tokens', 'last_time')
local tokens = tonumber(bucket[1]) or capacity
local last_time = tonumber(bucket[2]) or now
-- 计算从上次更新到现在生成的令牌数
local delta = math.max(0, now - last_time) * rate / 1000
tokens = math.min(capacity, tokens + delta)
-- 判断是否有足够的令牌
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', key, 60) -- 设置过期时间,避免冷key占用内存
return 1 -- 允许通过
else
return 0 -- 限流拒绝
end优点:
- Redis 单线程执行 Lua 脚本,保证原子性
- 性能高,支持高并发
- 实现简单,易于扩展
方案 2:Sentinel(阿里开源)
- 开箱即用,支持多种限流规则
- 集成 Spring Cloud 生态
- 提供可视化控制台,方便监控和配置
- 支持熔断、降级等高级功能
进阶防刷策略 🛡️
除了基础限流,还需要针对恶意刷接口的场景增加额外防护:
- IP + 用户 ID + 设备 ID 组合限流:防止单一用户换 IP 或换设备刷接口
- 验证码机制:对高频请求强制要求输入验证码 🤖
- 黑名单机制:对恶意 IP / 用户加入黑名单,禁止访问一段时间
- 接口签名验证:请求参数加签名,防止篡改和重放攻击
- 动态限流阈值:根据系统负载自动调整限流阈值 📈
⚙️ 工程细节,防刷的狠活儿
这才是拉分项,面试官想听这些。
1. 多维度组合防刷
单一IP容易误伤 NAT 出口,单一用户ID防不了换号。
- 指纹风控:前端采集 Canvas/WebGL 指纹,生成 deviceId,结合 IP+UID,形成三位一体 key。
- 限制动作序列:登录 -> 领券 -> 下单,规定时间内连续动作频率异常,直接拉黑。
2. 柔性与硬性策略
- 触发一级限流:弹出验证码(滑块),通过后放行并加白临时。
- 触发二级限流:直接返回
429 Too Many Requests,但提示友好“当前排队人数较多,请稍后再试”😔。 - 恶意流量:动态下发黑名单到 Nginx 层,通过
ngx.shared.DICT秒级生效。
3. 性能考量
Redis 命令原子化:滑动窗口的多个 Redis 操作必须用 Lua 脚本打包执行,避免竞争。
-- 滑动窗口 Lua 脚本
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
local count = redis.call('ZCARD', key)
if count < limit then
redis.call('ZADD', key, now, now)
redis.call('EXPIRE', key, window)
return 1 -- 通过
else
return 0 -- 限流
end异步计数:对不需要极致精确的统计,可以本地累加再批量刷入 Redis,降低网络开销,比如本地计数器 100ms 同步一次。
4. 监控大盘 🎯
必须可视化:
Prometheus埋点:rate_limited_total{uri,userId}计数器。- Grafana 面板展示实时拒绝率、Top10 被限 IP/用户。
- 触发阈值短信告警。
常见问题与优化 ⚡
1. Redis 限流的性能瓶颈:
- 优化:使用 Redis 集群,将不同 key 分散到不同节点
- 本地缓存限流规则,减少 Redis 访问次数
2. 限流误杀问题:
- 优化:设置合理的限流阈值,预留足够的缓冲空间
- 增加白名单机制,对内部服务和可信用户放行
3. 限流系统本身的高可用:
- 优化:采用 "降级策略",当 Redis 故障时,自动切换为单机限流
- 限流逻辑放在 try-catch 块中,异常时直接放行
Q: 令牌桶和滑动窗口到底怎么选?
A: 对突发有容忍度(如秒杀)选令牌桶;对平滑度要求极高(如开放平台 API 调用)选滑动窗口。
Q: 如果 Redis 挂了怎么办?
A: 必须降级。方案:本地内存做单机限流 + 熔断,或直接放行(根据业务重要程度)。绝不能因为限流把主链路搞挂。
Q: 被限的请求怎么处理更优雅?
A: 结合消息队列做“排队”机制,返回一个 ticketId,客户端轮询结果,提升体验。🙋♂️
面试总结 🎯
最后我会这样收尾:回答框架就是:场景区分 → 算法选型 → 分布式架构 → 工程魔鬼细节。
一个好的接口防刷 / 限流系统,应该是分层防护、算法合理、配置灵活、监控完善的。在实际项目中,我会优先使用Spring Cloud Gateway + Redis Lua 令牌桶的方案,因为它性能高、易扩展,并且能够满足大多数互联网业务的需求。同时,我会结合业务特点,增加验证码、黑名单等进阶防刷策略,确保系统的安全性和稳定性。
如何设计一个配置中心?
面试官您好,我会从需求拆解→整体架构→核心模块→关键技术→高可用→扩展这几个维度来设计一个生产级配置中心。
🎯 先抓住本质问题
面试官内心OS:不要一上来就Spring Cloud Config、Nacos,我想听“为什么”,而不是“用什么”。
我的理解,配置中心最核心就三个字:管、推、稳。
- 管:统一管理散落在各个微服务里的配置,有版本、能回溯、能灰度。
- 推:配置变更后,能实时通知到应用,不能靠重启。
- 稳:配置中心自己挂了,客户端不能跟着一起挂,要有本地兜底。
画个简单的 4 层逻辑架构 就更清楚了 👇
重点在于那个 本地快照文件,这是生存之本。🧐
明确核心需求 📋
功能需求
- 配置的 CRUD、版本管理、灰度发布
- 配置变更实时推送到客户端
- 多环境、多集群、多租户隔离
- 权限控制(读写权限、审批流程)
- 配置审计日志
非功能需求
- 高可用:配置中心挂了不影响业务运行
- 高性能:配置查询毫秒级响应
- 一致性:最终一致性,允许短暂延迟
- 可扩展性:支持百万级客户端连接
🗄️ 存储怎么设计?(很关键)
这时候该抛一些表结构了,显得你确实落地过。
我会分三张核心表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
app | 应用/租户 | app_id, app_name |
config | 配置主体 | config_id, app_id, key, value, env, version, status |
config_history | 历史版本 | history_id, config_id, value, version, operator, created_at |
👉 注意:config表里一定有一列 version(自增或时间戳),推送的时候客户端要带上本地版本号,服务端比对后只返回变更的配置——这叫增量推送,极大减少网络开销。💡
-- 增量查询关键逻辑(伪代码)
SELECT key, value, version FROM config
WHERE app_id = ? AND env = ? AND version > ?存储层的技术组合我一般会这样选:
- 中小规模:MySQL 单表足够,配好索引。
- 高可用&大规模:可以用
etcd/TiKV这种分布式 KV,天然带 Watch 机制,做推送会更爽。
🔔 实时推送怎么搞?这才是难点
面试官肯定会追问:“配置变更后怎么让应用瞬间感知?”
三种常见方式,我列个对比:
| 方式 | 实时性 | 复杂度 | 长连接压力 | 推荐场景 |
|---|---|---|---|---|
| 客户端定时轮询 | 秒级延迟 | ⭐ | 低 | 不推荐,浪费资源 |
| 长轮询 | 毫秒级 | ⭐⭐ | 一般 | 普适方案,Apollo 就是 |
| 长连接(WebSocket/gRPC) | 毫秒级 | ⭐⭐⭐ | 高 | 推送特别频繁时考虑 |
💬 面试官:“那长轮询具体怎么实现?”
我会在客户端 SDK 里用一个无限循环,拿着本地最大的 version 去请求服务端接口 /config/v3/poll,服务端用 DeferredResult(Spring) 或 AsyncContext 挂起,30 秒超时。
- 超时前如果有新版本 → 立刻返回变更数据。
- 如果超时还没变更 → 返回空,客户端再立刻发起下一次长轮询。
这样连接不断复用,做到准实时,也兼容各种防火墙。🛡️
用个时序图就一目了然:
整体架构设计 🏗️
采用分层架构 + 推拉结合的设计模式:
核心设计思想:
- 服务端负责配置存储和管理
- 客户端负责本地缓存和拉取
- 推拉结合保证实时性和可靠性
核心模块详解 🔧
1. 客户端 SDK 设计 ✨
这是配置中心最关键的部分,直接影响业务体验:
| 功能点 | 实现方案 | 优势 |
|---|---|---|
| 本地缓存 | 内存 + 磁盘双缓存 | 服务端挂了也能正常运行 |
| 拉取机制 | 长轮询 + 定时兜底 | 实时性好,资源占用低 |
| 推送机制 | 基于 Netty 的 TCP 长连接 | 毫秒级推送延迟 |
| 配置监听 | 观察者模式 | 变更自动回调业务代码 |
绝对不能因为配置中心挂了,业务应用就启动不了。
客户端 SDK 的启动流程我会设计成:
- 启动时:先读本地快照文件(
snapshot/{app_id}_{env}.cache),有就用它快速启动。 - 后台异步:连接配置中心,长轮询拉最新配置,拉到后更新内存并覆写快照。
- 容灾降级:如果连不上配置中心,就一直用本地快照,后台无限重连,业务零感知。
- 配置回滚:客户端内存里永远保留
currentConfig和lastStableConfig,如果新配置导致应用异常,能一键恢复上一版稳定配置(JMX 或管理接口触发)。
这部分特别体现你对稳定性的思考。🏆
关键代码示例:
// 配置变更监听器
ConfigService configService = ConfigServiceFactory.getConfigService();
configService.addListener("application.properties", new ConfigChangeListener() {
@Override
public void onChange(ConfigChangeEvent event) {
// 业务逻辑处理配置变更
String newValue = event.getNewValue("db.url");
updateDataSource(newValue);
}
});2. 服务端设计 🖥️
- 存储层:MySQL 存配置元数据,Redis 做热点缓存
- 推送服务:独立部署,与配置服务解耦
- 灰度发布:按 IP、应用名、标签等维度灰度
- 版本管理:每次配置变更生成新版本,支持回滚
3. 数据一致性保证 🤝
采用最终一致性模型:
- 配置写入数据库成功
- 发送变更消息到 MQ
- 推送服务收到消息,推送给客户端
- 未收到推送的客户端通过长轮询拉取
高可用设计 🛡️
服务端高可用
- 多实例集群部署,无状态设计
- 数据库主从复制 + 读写分离
- Redis 集群 + 哨兵模式
客户端高可用
- 本地缓存兜底:这是最重要的高可用手段
- 服务端地址列表自动切换
- 熔断机制:服务端不可用时,停止拉取请求
容灾设计
- 多机房部署
- 配置数据定期备份
- 一键回滚到历史版本
性能优化 ⚡
- 热点配置缓存:Redis 缓存热门配置
- 批量拉取:客户端批量拉取多个配置
- 增量推送:只推送变更的配置项
- 连接复用:同一个应用多个实例复用连接
扩展功能 🚀
- 配置加密:敏感配置(如数据库密码)加密存储
- 配置校验:配置变更时进行语法和格式校验
- 配置依赖:配置之间的依赖关系管理
- 配置模板:常用配置模板快速创建
🧪 灰度 & 权限 —— 加分项来了
聊到这儿如果面试官还在听,就可以往深了说:
- 灰度发布:给配置打标签,比如
gray_percent=10%或gray_ip_list,客户端拉取时带上自身 IP,服务端按规则返回不同配置,实现 IP 级 / 比例级 灰度。 - 审批流:高危险配置(数据库密码、Redis 地址)改动必须走工单,提交 → TL 审批 → 回调发布。可以用状态机驱动。
- 变更审计:
config_history表记录一切,配上简单的 ELK 或 SLF4J 日志输出谁、什么时间、把什么从旧值改成了新值。 - 权限模型:RBAC,开发只能看 dev 环境,运维能看 prod,管理员能改审批流。
📦 技术选型一句话总结
如果现在让我带团队落地,我会这么选,务实又够用:
| 组件 | 选型 | 理由 |
|---|---|---|
| 服务框架 | Spring Boot 3 | 团队熟,生态好 |
| 存储 | MySQL 8 + Redis | 历史版本 + 缓存热点配置 |
| 推送 | Spring MVC DeferredResult | 长轮询实现简单,无外部依赖 |
| 客户端 | 自研轻量 SDK + Spring Cloud 适配 | 本地缓存 + 热更新 |
| 监控 | Micrometer + Prometheus | 暴露长轮询连接数、推送延迟 |
当然,如果公司已经有云原生基建,直接用 Nacos/Apollo 封装一层也是务实的做法,自研的部分只写一个薄薄的适配层统一接入规范即可。😎
面试官可能的追问 🔍
Q:配置中心和注册中心有什么区别?
A:配置中心存静态配置,注册中心存动态服务地址;配置中心是推拉结合,注册中心是纯推送。
Q:如何解决配置推送风暴问题?
A:分批推送 + 客户端随机延迟 + 服务端限流。
Q:如何保证配置变更的原子性?
A:同一批次的配置变更作为一个事务,要么全部成功,要么全部失败。
总结 📝
一个好的配置中心应该做到:业务无感知、高可用、高性能、易扩展。真正的功力体现在:实时推送的机制选择、客户端怎么防雪崩、灰度发布的策略引擎。核心是客户端的本地缓存兜底和推拉结合的更新机制,这两点决定了配置中心的可靠性和实时性。
