日期:2026-07-03
共 14 道经典后端/系统设计场景题,按难度从简单到难排序
难度递进:Spring 基础 → 单组件排查 → 缓存模式 → 系统设计 → 分布式基础 → 微服务架构 → 复杂分布式
目录(由简单到难)
- [Spring @Transactional 失效的 6 种场景](#Spring @Transactional 失效的 6 种场景)
- [Spring 循环依赖与三级缓存](#Spring 循环依赖与三级缓存)
- [Redis 大 Key 问题排查与解决](#Redis 大 Key 问题排查与解决)
- [MySQL 死锁排查------并发下单场景](#MySQL 死锁排查——并发下单场景)
- [Redis 缓存一致性------项目实战](#Redis 缓存一致性——项目实战)
- 限流器设计
- 短链接系统设计
- [分布式 ID 生成器](#分布式 ID 生成器)
- 分布式锁
- [SpringBoot 接口幂等设计------项目实战](#SpringBoot 接口幂等设计——项目实战)
- [Spring Cloud Gateway 统一鉴权设计](#Spring Cloud Gateway 统一鉴权设计)
- 实时排行榜
- 消息队列四大经典问题
- [MySQL 分库分表后跨分片查询](#MySQL 分库分表后跨分片查询)
题1:Spring @Transactional 失效的 6 种场景
业务场景
订单创建方法需要同时写订单表、扣库存、写流水,希望一个事务保证原子性,但加了 @Transactional 发现事务没生效。
痛点
- 加注解 ≠ 事务生效,Spring 中有大量"静默失效"陷阱
- 数据库数据不一致,但代码没报错,排查困难
- 核心矛盾:AOP 代理机制导致某些场景下事务注解被绕过
方案
| 场景 | 原因 | 是否回滚 | 解法 |
|---|---|---|---|
| 非 public 方法 | AOP 代理只能劫持 public 方法 | ❌ 不生效 | 改为 public |
| 同类内部调用 | this.method() 不走代理 |
❌ 不生效 | 注入自己或拆到另一个 Service |
| 异常被 catch 吞掉 | Spring 感知不到异常 | ❌ 不回滚 | catch 里手动 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() |
| 非默认 checked 异常 | Spring 默认只回滚 RuntimeException | ❌ 不回滚 | @Transactional(rollbackFor = Exception.class) |
| 非 Spring 管理的 Bean | new 出来的对象没有代理 | ❌ 不生效 | 必须用 @Autowired 注入 |
| 多线程 | 事务绑定 ThreadLocal,子线程拿不到 | ❌ 不生效 | 使用分布式事务方案 |
实现
场景1:同类内部调用(最常见的坑)
java
// ❌ 事务不生效
@Service
public class OrderService {
// 外部调用 createOrder() → 有代理,事务生效
// 内部调用 doSave() → this.doSave() 不走代理,事务不生效!
@Transactional
public void createOrder(Order order) {
this.doSave(order); // ← 这里事务不生效!
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void doSave(Order order) {
orderMapper.insert(order);
}
}
java
// ✅ 解法1:注入自己
@Service
public class OrderService {
@Autowired
private OrderService self; // 注入代理对象
@Transactional
public void createOrder(Order order) {
self.doSave(order); // ← 走代理,事务生效
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void doSave(Order order) {
orderMapper.insert(order);
}
}
// ✅ 解法2:拆到另一个 Service(推荐)
@Service
public class OrderService {
@Autowired
private OrderSaveService orderSaveService;
@Transactional
public void createOrder(Order order) {
orderSaveService.doSave(order); // 走代理
}
}
场景2:异常被 catch 吞掉
java
// ❌ 不回滚
@Transactional
public void createOrder(Order order) {
try {
orderMapper.insert(order);
stockService.deduct(order.getProductId(), 1); // 抛异常
} catch (Exception e) {
log.error("扣库存失败", e);
// 吞了异常,Spring 不知道 → 不回滚!
}
}
java
// ✅ 解法:手动标记回滚
@Transactional
public void createOrder(Order order) {
try {
orderMapper.insert(order);
stockService.deduct(order.getProductId(), 1);
} catch (Exception e) {
log.error("扣库存失败", e);
// 手动设置回滚标记
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw new BizException("扣库存失败"); // 或者重新抛
}
}
事务传播行为速查:
| 传播行为 | 含义 |
|---|---|
| REQUIRED(默认) | 有事务则加入,没有则新建 |
| REQUIRES_NEW | 总是新建事务,挂起当前事务 |
| NESTED | 嵌套事务,创建 savepoint |
| SUPPORTS | 有事务则加入,没有则非事务运行 |
| MANDATORY | 必须在事务中,否则抛异常 |
扩展
- 事务隔离级别怎么配? →
@Transactional(isolation = Isolation.READ_COMMITTED) - 事务超时? →
@Transactional(timeout = 5)单位秒 - 只读事务的作用? →
@Transactional(readOnly = true)给 MySQL 优化器提示,可能路由到只读节点
题2:Spring 循环依赖与三级缓存
业务场景
订单服务 OrderService 依赖用户服务 UserService 查用户信息,UserService 依赖 OrderService 查订单统计。两个 Bean 互相依赖,项目启动报错或自动解决。
痛点
- Spring Boot 默认能解决单例 Bean 的循环依赖,但不是所有场景都能解决
- 理解背后的"三级缓存"机制才能排查复杂问题
- 核心矛盾:A 需要 B,B 需要 A → 鸡生蛋蛋生鸡 → 先创建谁?
方案
Spring 解决循环依赖的核心:提前暴露半成品 Bean(三级缓存)
plain
一级缓存:singletonObjects → 完全创建好的 Bean(成品)
二级缓存:earlySingletonObjects → 提前暴露的纯对象(半成品,属性还没注入)
三级缓存:singletonFactories → Bean 工厂(可以生成代理对象)
实现
Spring 解决循环依赖的流程:
plain
A 创建 → 实例化 A(构造器)→ 放入三级缓存 → 填充 B
↓
B 创建 → 实例化 B → 放入三级缓存 → 填充 A
↓
从三级缓存拿到 A 的引用(半成品)← 填充成功
↓
B 初始化完成 → 放入一级缓存
↓
A 拿到完整的 B → A 初始化完成 → 放入一级缓存
只用二级缓存行不行?
plain
不行!因为 AOP 代理对象的特殊性:
1. 原始对象 A 被创建 → 放入二级缓存(半成品)
2. B 拿到 A 的引用填充自己 → B 拿到的是原始对象,不是 AOP 代理对象!
3. 后续 A 需要被代理(AOP)→ 生成了一个新的代理对象 A'
4. B 里存的还是原始 A,不是 A' → 不一致!
三级缓存的奥妙:
- 三级缓存存的是 ObjectFactory,不是对象本身
- 调用 getObject() 时触发 AOP 后置处理器 → 判断是否需要代理
- 如果需要代理,返回代理对象;否则返回原始对象
- 确保 B 拿到的引用和最终的一级缓存相同
什么场景 Spring 无法解决循环依赖?
java
// ❌ 构造器注入循环依赖 → Spring 无法解决,启动报错
@Service
public class A {
private final B b;
public A(B b) { this.b = b; } // 构造器注入
}
@Service
public class B {
private final A a;
public B(A a) { this.a = a; } // 构造器注入
}
// ✅ 改成 setter 注入 / @Autowired 字段注入(Spring 可解决)
@Service
public class A {
@Autowired
private B b; // 字段注入,先创建 A 的半成品再填充 B
}
plain
为什么构造器注入无法解决?
→ 构造器是创建 Bean 的第一步
→ A 的构造器需要 B(完整 Bean)→ 但 B 还没创建 → 死锁
→ 字段注入:A 先 new 出来(构造器无参)→ 暴露到三级缓存 → 再填充 B
扩展
- 原型(prototype)Bean 循环依赖? → Spring 不解决,直接报错。原型 Bean 不缓存。
- @Lazy 注解也可以破局 →
@Lazy @Autowired private B b注入一个代理对象,延迟初始化 - 最佳实践? → 设计上避免循环依赖,这是代码坏味道;重构提取第三个类 C
题3:Redis 大 Key 问题排查与解决
业务场景
线上 Redis 突然出现响应延迟,slowlog 发现某些 Key 的读写操作耗时几百毫秒。排查发现一个 Hash 类型的购物车 Key 有 200 万字段,一个 ZSet 排行榜 Key 有几千万 member。
痛点
- 大 Key 的删除是 Big O 操作,DEL 一个几百万字段的 Hash 会导致 Redis 阻塞
- 高并发下对大 Key 做 HGETALL/ZRANGE 同样耗时巨大
- 核心矛盾:单 Key 数据量膨胀 + Redis 单线程处理 → 阻塞其他请求
大 Key 的定义
| 类型 | 大 Key 阈值 |
|---|---|
| String | > 10KB(如一个 JSON 存了几十 K) |
| List/Hash/Set/ZSet | > 5000 个元素 |
| 整体 | > 10MB |
方案
| 步骤 | 手段 | 说明 |
|---|---|---|
| ① 排查 | redis-cli --bigkeys / MEMORY USAGE key |
找到大 Key |
| ② 分析 | 业务确认这个 Key 的用途 | 能不能拆?能不能清理? |
| ③ 删除 | UNLINK(异步)替代 DEL |
避免阻塞 |
| ④ 拆分 | 将单个大 Key 拆为多个小 Key | 根治 |
| ⑤ 优化 | 用其他存储替代 Redis | 不适合存在 Redis 里 |
实现
(1) 排查命令
bash
# 方式1:redis-cli 自带的 bigkeys 扫描(生产慎用 O(N))
redis-cli --bigkeys
# 方式2:查看单个 key 的内存占用
redis-cli MEMORY USAGE cart:user:12345
# 方式3:查看 key 的 encoding 和元素数
redis-cli OBJECT FREQ cart:user:12345
redis-cli DEBUG OBJECT cart:user:12345
# 方式4:对 Hash,用 HLEN 看字段数
redis-cli HLEN cart:user:12345
# 方式5:slowlog 查看慢操作
redis-cli SLOWLOG GET 10
(2) 如何安全删除大 Key(面试重点)
bash
# ❌ DEL 大 Key:Redis 是单线程,删除过程中会阻塞所有其他请求
# 一个几百万元素的 Set,DEL 可能耗时 0.5s~2s
# ✅ 用 UNLINK(Redis 4.0+):异步删除,不阻塞主线程
redis-cli UNLINK big:hash:key
# 原理:UNLINK 只在主线程断开 key 在字典里的引用(O(1))
# 真正的内存回收在后台线程异步完成
# 内存不会立即释放,但 key 立即不可见
(3) 大 Key 拆分(根治方案)
java
// ❌ 错误设计:一个 Hash 存所有用户购物车
// hash:cart → { user1: [...], user2: [...], ..., user100w: [...] }
// 一个 Hash 字段百万级 → 大 Key!
// ✅ 正确设计:每个用户一个独立的 Hash Key
// cart:user:10001 → { sku1: 2, sku2: 1 }
// cart:user:10002 → { sku3: 5 }
// 对于 ZSet 排行榜(见排行榜题)
// ❌ leaderboard:all → 全部用户存一个 ZSet
// ✅ leaderboard:1, leaderboard:2, ..., leaderboard:10 → 按用户 ID hash 分 10 个桶
// 查 Top 100 时:每个桶取 ZREVRANGE 0 99 → 10 × 100 = 1000 条 → 内存归并
(4) 购物车拆分的完整示例
java
// ❌ 原设计(大 Key)
// Hash: cart → { "10001": "[{...}, {...}]", "10002": "[{...}]", ... }
// ✅ 改造后(小 Key)
@Service
public class CartService {
private static final String CART_PREFIX = "cart:user:";
// 每个用户的购物车独立 Key
public void addItem(Long userId, CartItem item) {
String key = CART_PREFIX + userId;
redisTemplate.opsForHash().put(key, item.getSkuId().toString(), JSON.toJSONString(item));
redisTemplate.expire(key, 7, TimeUnit.DAYS);
}
// 批量删除过期购物车时也要小心
public void cleanExpiredCarts() {
// 扫描过期购物车 key,用 UNLINK 异步删除
// 注意:keys * 命令线上禁用,用 SCAN 游标分批
Set<String> keys = redisTemplate.scan(ScanOptions.scanOptions()
.match("cart:user:*")
.count(100)
.build());
for (String key : keys) {
redisTemplate.unlink(key); // 异步删除
}
}
}
(5) String 大 Key 优化
java
// ❌ 一个 String 存整个配置(100KB)
// config:app → "{... 几十个微服务的全部配置 ...}"
// ✅ 拆成多个 String
// config:service:order → "{ order服务配置 }"
// config:service:user → "{ user服务配置 }"
// config:service:payment → "{ payment 服务配置 }"
排查流程总结
plain
监控发现 Redis 延迟升高
↓
SLOWLOG GET 10 → 找到慢命令
↓
--bigkeys 扫描 → 定位大 Key
↓
分析业务场景:
├── 购物车 Hash 过大 → 按用户拆分
├── ZSet 排行榜过大 → 分桶
├── String 过大 → 拆多个 String 或压到本地文件
└── 历史数据 → 过期策略 or 冷热分离
↓
先 UNLINK 清理 → 再上线拆分代码
扩展
- 生产环境如何预先发现大 Key? → 定时扫描脚本 + 告警;腾讯云/阿里云 Redis 自带大 Key 监控
- Redis 4.0 之前没有 UNLINK 怎么删? → 分批删:HSCAN 每次取 100 个 field + HDEL;或 rename + 后台线程逐批删除
- 大 Value 压缩? → Gzip 压缩后存,但要权衡压缩/解压的时间消耗
- 什么数据不适合放 Redis? → 大文件、大 JSON、全量列表(存 MySQL + 缓存 ID 列表即可)
题4:MySQL 死锁排查------并发下单场景
业务场景
秒杀活动期间,两个用户同时下单,分别购买商品 A 和商品 B,但两个订单都需要更新"活动库存表"和"商品销量表",偶尔出现死锁,订单创建失败。
痛点
- 死锁不是每次必现,线上偶发,排查困难
- 应用层看到的是
Deadlock found when trying to get lock,不知道为什么发生 - 核心矛盾:两个事务以不同顺序锁定资源,互相等待对方释放 → 循环等待
方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 统一加锁顺序 | 所有事务按固定顺序(如 id 升序)访问资源 | 预防死锁(最推荐) |
| 缩短事务时间 | 减少锁持有时间,降低死锁概率 | 通用优化 |
| 合理使用索引 | 避免行锁升级为间隙锁/表锁 | 预防死锁 |
| 死锁重试 | 捕获死锁异常 → 等待随机时间 → 重试 | 兜底方案 |
| 乐观锁替代 | 不用悲观锁 | 冲突概率低时 |
实现
死锁现场还原:
sql
-- 事务1:先锁 A 再锁 B
BEGIN;
UPDATE product SET stock = stock - 1 WHERE id = 1; -- 锁住 product.id=1
-- 这里事务2开始执行...
UPDATE product SET stock = stock - 1 WHERE id = 2; -- 等待事务2释放 product.id=2 → 死锁!
-- 事务2:先锁 B 再锁 A(顺序相反!)
BEGIN;
UPDATE product SET stock = stock - 1 WHERE id = 2; -- 锁住 product.id=2
UPDATE product SET stock = stock - 1 WHERE id = 1; -- 等待事务1释放 product.id=1 → 死锁!
-- MySQL 检测到死锁 → 回滚其中一个事务 → 报 Deadlock found
java
// ✅ 解法1:统一加锁顺序(根本解法)
@Transactional
public boolean placeOrder(Long productId1, Long productId2) {
// ✅ 始终按 ID 升序处理
Long first = Math.min(productId1, productId2);
Long second = Math.max(productId1, productId2);
productMapper.deductStock(first, 1);
productMapper.deductStock(second, 1);
// 并发事务也按同顺序加锁 → 不会死锁
}
java
// ✅ 解法2:死锁重试(兜底方案)
@Retryable(value = DeadlockLoserDataAccessException.class, maxAttempts = 3, backoff = @Backoff(delay = 200))
@Transactional
public void placeOrder(Order order) {
productMapper.deductStock(order.getProductId(), 1);
salesMapper.increaseSales(order.getProductId(), 1);
}
sql
-- 排查死锁信息
SHOW ENGINE INNODB STATUS;
-- 找到 LATEST DETECTED DEADLOCK 段落
-- 能看到两个事务持有和等待的锁、具体 SQL
扩展
- 间隙锁(Gap Lock)导致的死锁? → RR 隔离级别下
WHERE id > ? FOR UPDATE会锁间隙,容易跟插入冲突 - 唯一索引插入死锁? → 两个事务同时 INSERT 相同的唯一键,MySQL 内部用共享锁+排他锁,特殊死锁场景
- 死锁预防口诀 → 同顺序、短事务、走索引、备重试
题5:Redis 缓存一致性------项目实战
业务场景
商品详情页数据(价格、库存、描述)缓存到 Redis。运营后台修改了商品价格,但用户看到的仍然是旧价格,需要保证缓存与 DB 的一致性。
痛点
- 并发下 "删缓存 → 读 DB → 回写缓存" 和 "更新 DB → 删缓存" 存在时间窗口
- 先更新 DB 还是先删缓存?顺序不对会有脏数据
- 核心矛盾:DB 和 Redis 是两个独立的数据源,无法做到真正的 ACID 事务
方案
| 方案 | 流程 | 一致性 | 适用场景 |
|---|---|---|---|
| Cache Aside(旁路缓存) | 读:缓存→DB→回填;写:更新DB→删缓存 | 最终一致 | 最常用 |
| Read/Write Through | 缓存层代理读写,应用不感知 | 强一致 | 一致性要求高 |
| Write Behind | 只写缓存→异步批量刷DB | 弱一致 | 写 QPS 极高 |
| 延时双删 | 更新DB→删缓存→等500ms→再删 | 最终一致(更可靠) | Cache Aside 加强版 |
| Canal + MQ | 监听 binlog → MQ → 删缓存 | 最终一致(最可靠) | 高并发 + 高一致性要求 |
实现
为什么先更新 DB,再删缓存?(不是反过来)
plain
【先删缓存,再更新 DB(❌ 有问题)】
时序:
T1: 线程A删缓存
T2: 线程B读缓存(miss) → 读DB(旧值) → 回填缓存(旧值)
T3: 线程A更新DB(新值)
结果:缓存 = 旧值,DB = 新值 → 缓存脏了!直到下次更新/过期
─────────────────────────────────────
【先更新 DB,再删缓存(✅ 推荐)】
时序:
T1: 线程A更新DB(新值)
T2: 线程B读缓存(miss) → 读DB(新值) → 回填缓存(新值)
T3: 线程A删缓存
最差情况:线程B在读DB时还没更新完(但写锁保证了读提交)
实际上:写DB的时间 > 删缓存的时间,窗口极小,概率极低
Cache Aside 完整实现:
java
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
@Autowired
private RedisTemplate<String, Product> redisTemplate;
// 读:缓存 → DB → 回填
public Product getProduct(Long id) {
String key = "product:" + id;
Product p = redisTemplate.opsForValue().get(key);
if (p != null) {
return p;
}
// 缓存未命中 → 查 DB
p = productMapper.selectById(id);
if (p != null) {
// 回填缓存,TTL 带随机值防雪崩
redisTemplate.opsForValue().set(key, p,
3600 + ThreadLocalRandom.current().nextInt(300), TimeUnit.SECONDS);
}
return p;
}
// 写:更新 DB → 删缓存
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product); // ① 先更新 DB
redisTemplate.delete("product:" + product.getId()); // ② 再删缓存
}
}
延时双删(加强版):
java
@Transactional
public void updateProduct(Product product) {
productMapper.updateById(product); // ① 更新 DB
redisTemplate.delete("product:" + product.getId()); // ② 第一次删缓存
// ③ 异步延时再删一次(兜底并发读取造成的脏数据)
CompletableFuture.runAsync(() -> {
try { Thread.sleep(500); } catch (InterruptedException e) {}
redisTemplate.delete("product:" + product.getId());
});
}
Canal + MQ 方案(终极):
plain
应用更新 DB
↓
MySQL binlog 记录变更
↓
Canal 监听 binlog → 解析出变更记录
↓
投递到 RocketMQ
↓
消费者:删除/更新 Redis 缓存
优点:跟业务代码解耦,缓存操作不占用业务线程
缺点:引入额外组件,延迟增加 ~100ms
扩展
- 缓存访问不到时起一个分布式锁只让一个线程回填? → 对,防止缓存击穿(Day1 题6 有详细内容)
- 更新 DB 成功但删缓存失败? → 删缓存重试 + 重试失败落 MQ;或者用 Canal 方案彻底规避
- 热点数据怎么处理? → Day1 题6:CDN → Nginx → Caffeine → Redis → 限流 → DB
题6:限流器设计
痛点
- API 网关需要对下游接口做限流:每用户每秒 100 次、每 IP 每秒 10 次
- 限流不准 → 下游被打挂;限流太严格 → 误杀正常流量
- 核心矛盾:精确限流 vs 高并发下的低延迟
四种限流算法
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 固定窗口 | 统计 [00:00, 00:01) 内的请求数,超阈值拒绝 |
实现最简单 | 临界点双倍流量 |
| 滑动窗口 | 统计 [now-1s, now] 内的请求数 |
精确 | 实现复杂,代价高 |
| 令牌桶 | 固定速率放令牌,请求取令牌,取不到拒绝 | 允许一定突发 | 需要全局状态 |
| 漏桶 | 请求进桶,恒定速率流出 | 平滑流量 | 不能处理突发 |
固定窗口的临界问题(面试重点)
plain
窗口1: [00:00:00, 00:00:59] → 限流 100 次
窗口2: [00:01:00, 00:01:59] → 限流 100 次
真实情况:
00:00:59.900 ~ 00:01:00.100(实际 0.2 秒内)
→ 窗口 1 的最后一个时刻 + 窗口 2 的第一个时刻
→ 两个窗口各放 100 次
→ 实际 0.2 秒放了 200 次!→ 下游被打挂
解法:滑动窗口,每次看 [now-1s, now] 的真实请求数
Redis 实现
固定窗口(简单但有问题)
java
// 限流:每秒 100 次
String key = "rate_limit:" + userId + ":" + LocalDateTime.now().getSecond();
Long count = redisTemplate.opsForValue().increment(key);
redisTemplate.expire(key, Duration.ofSeconds(2));
if (count > 100) throw new RateLimitException();
滑动窗口(ZSet 实现)
lua
-- KEYS[1] = rate_limit:{userId}
-- ARGV[1] = 窗口大小(ms), ARGV[2] = 当前时间戳, ARGV[3] = 阈值
local now = tonumber(ARGV[2])
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[3])
-- 删除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
-- 统计窗口内的数量
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
-- 用毫秒 + 随机数作为 score/member,避免覆盖
redis.call('ZADD', KEYS[1], now, ARGV[2] .. '_' .. count)
redis.call('PEXPIRE', KEYS[1], window)
return 1 -- 放行
else
return 0 -- 限流
end
令牌桶(Lua)
lua
-- 核心:存 (令牌数, 上次填充时间)
-- 请求时:先按时间差补充令牌 → 判断是否 ≥1 → 扣减
local key = KEYS[1]
local rate = tonumber(ARGV[1]) -- 每秒产生的令牌数
local capacity = tonumber(ARGV[2]) -- 桶容量
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
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)
tokens = math.min(capacity, tokens + delta * rate / 1000)
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_time', now)
redis.call('EXPIRE', key, 60)
return 1
else
return 0
end
分布式限流 vs 单机限流
plain
单机限流:每台机器独立限流(如 Caffeine 本地计数)
优点:零网络开销,延迟 < 0.01ms
缺点:N 台机器 = 实际放行 N × 限额
分布式限流:基于 Redis 的全局限流
优点:精确控制全局限额
缺点:每次请求一次 Redis RTT(~1ms)
最佳实践:多级限流
Nginx 层:IP 单机限流(扛住 90%)
↓
网关层:Redis 滑动窗口(精确分布式限流)
↓
服务层:信号量(并发线程数限制,保证服务本身不被打死)
选型
plain
简单 API → 固定窗口(Redis INCR),容忍临界问题
支付/交易 → 滑动窗口(Redis ZSet),必须精确
允许突发 → 令牌桶(网关层限流常用)
严格平滑 → 漏桶(较少使用)
题7:短链接系统设计
痛点
- 设计一个类似 t.cn / bit.ly 的短链接服务
- 将长 URL(如
https://www.example.com/product/detail?id=123&utm_source=xxx&...)映射为短链https://t.cn/A3x9Km - 核心矛盾:短码全局唯一 + 高并发读写 + 短码尽量短
方案
| 方案 | 原理 | 优缺点 |
|---|---|---|
| Hash 法(MD5/SHA256 截取) | 长 URL → MD5 → 取前 7 位 | 简单,但碰撞需解决(加随机盐、重试) |
| 发号器 + Base62 | 自增 ID → Base62 编码为短码 | 无碰撞,但需要全局发号器 |
| 预生成短码池 | 提前批量生成短码放入池中,来请求直接取 | 延迟最低,但短码浪费管理 |
核心流程
plain
【生成短链】
长 URL → 检查是否已存在 → 不存在则生成唯一短码 → 存映射 → 返回短链
【访问短链】
用户访问短链 → 查缓存(Redis) → 命中? 直接302跳转
→ 未命中 → 查DB → 302跳转
核心实现细节
短码生成(发号器 + Base62)
plain
发号器给一个自增 ID(如 1000000000)
↓
Base62 编码:1000000000 → "BFMh5S"
↓
短链 = "https://t.cn/BFMh5S"
Base62 字符集:0-9 A-Z a-z(共 62 个字符,去掉易混淆的 I/l/O/0 等可以再精简)
为什么是 302 而不是 301?
| 状态码 | 行为 | 适合短链? |
|---|---|---|
| 301(永久) | 浏览器缓存重定向,下次直接跳,不过服务器 | ❌ 无法统计点击数 |
| 302(临时) | 每次请求都打到服务器 | ✅ 可统计、可频控、可封禁 |
读写设计
plain
读写比 > 999:1(生成一次,访问千万次)
读路径:CDN(对302效果有限) → Nginx本地缓存 → Redis → DB
写路径:发号器取号 → Base62 → 写DB → 预热到Redis
缓存策略:Redis 缓存 "短码 → 长URL" 映射,TTL 设 7 天
热点短链:Caffeine 本地缓存 + 逻辑过期
7 位 Base62 能存多少?
plain
62^7 ≈ 3.5 万亿 > 3500 亿
足够支撑到天荒地老
如果每天生成 1 亿条,能用约 96 年
扩展追问
- 长 URL 已存在怎么办? → 先查是否已有映射,有则直接返回,避免重复生成
- 分布式发号器怎么保证不重复? → 雪花算法 / Leaf-Segment / Redis INCR
- 恶意刷短链? → 验证码 + 频率限制(同 IP 同用户限制每天生成数量)
- 短码被猜出来遍历? → 不要用纯自增 ID 的 Base62 暴露到外网,可以在中间加一层"用户维度隔离"
题8:分布式 ID 生成器
痛点
- 订单表每天千万级写入,需要一个全局唯一的订单号
- UUID 无序 → 插入 B+Tree 页分裂严重,性能差
- 数据库自增 ID → 单点瓶颈,分库后重复
- 核心矛盾:全局唯一 + 趋势递增(不一定要严格递增)+ 高性能 + 高可用
方案
| 方案 | 原理 | 长度 | 趋势递增 | 性能 |
|---|---|---|---|---|
| UUID v4 | 随机生成 | 128 bit (36 字符) | ❌ 完全无序 | 本地生成,最高 |
| UUID v7 | 时间戳前缀 + 随机后缀 | 128 bit (36 字符) | ✅ 时间趋势递增 | 本地生成,最高 |
| 雪花算法(Snowflake) | 时间戳 + 机器ID + 序列号 |
64 bit (19 位数字) | ✅ 时间趋势递增 | 本地生成,10w+/s |
| 号段模式(Leaf-Segment) | 批量号段预分配,内存内发放 | 可变 | ✅ 严格递增 | 本地内存,百万+/s |
| Redis INCR | Redis 原子自增 | 可变 | ✅ 严格递增 | ~10w/s,依赖 Redis |
雪花算法 64 位结构
plain
0 | 0000000000 0000000000 0000000000 00000000000 | 00000 | 00000 | 000000000000
│ │ 41 bit 时间戳 │10bit │12bit │
│ │ (从自定义起点开始) │机器ID │序列号 │
1bit
保留位
plain
单机每毫秒最大并发量:2^12 = 4096 个
理论 QPS:4096 × 1000 = 409.6 万/s
时钟回拨(雪花算法的核心难点)
问题:服务器时间被 NTP 回调了,时间戳变小 → 可能生成重复 ID
三种解法:
| 方案 | 做法 | 优缺点 |
|---|---|---|
| 抛异常等待 | 检测到回拨,sleep 等时钟追上 | 简单,服务短暂不可用 |
| 备用机器 ID | 每个节点储备 2~3 个 machineId,回拨时切换 | 复杂,需要协调 |
| 不信任系统时钟 | 用 Redis 维护的逻辑时钟替代系统时钟 | 依赖 Redis |
号段模式(Leaf-Segment)
plain
【核心思想】一次从 DB 拿一批号,放内存慢慢发
Service → DB: "给我一个号段" → DB 更新 maxId = 1000 → 返回 (1, 1000]
Service: 内存发放 1, 2, 3, ..., 1000
Service: 快发完了 → 再拿号段 (1001, 2000]
并发优化:双 Buffer
当前段: [1, 1000] ← 正在发放中
备用段: [1001, 2000] ← 预加载好,当前段用完无缝切换
不同场景的 ID 选型
plain
场景 推荐方案 原因
─────────────────────────────────────────────────────────────
订单号(用户可见) 雪花 / 号段 趋势递增,不要太长
内部流水号(机器间通信) UUID v7 性能最好
安全敏感(API Key、Token) UUID v4 + 随机 不可预测
账单号(财务,严格递增) 号段模式 必须严格递增
分布式爬虫任务 ID 雪花 + 业务前缀 一眼看出数据类型
扩展追问
- 雪花算法序列号溢出了怎么办? → 等下一毫秒,同一毫秒 4096 个满了只能等
- 号段用完 DB 挂了怎么办? → 本地剩余号段缓冲,撑到 DB 恢复;极端情况降级到雪花算法
- ID 生成器多机房部署? → 雪花算法预留机房 ID 位;号段模式每机房独立号段范围
- 改了机器 ID 后重启,会跟旧 ID 冲突吗? → 号段模式不会(DB 记录最大 ID);雪花算法用 ZK 注册 machineId
题9:分布式锁
痛点
- 定时任务多实例部署,同一时刻只允许一个实例执行
- 简单的
SETNX EX 30锁------如果任务执行超过 30 秒 → 锁过期 → 别人抢到 → 两个实例并行执行 - 核心矛盾:锁过期时间和任务执行时间无法预知 + 怎么处理锁自动续期
方案
| 方案 | 原理 | 可靠性 | 性能 |
|---|---|---|---|
Redis SET NX EX |
SET lock:xxx 1 EX 30 NX |
低(无续期) | 高 |
| Redisson(看门狗) | 自动续期 + Lua 原子解锁 | 中 | 高 |
| ZooKeeper 临时节点 | 创建临时节点,断开自动删除 | 高(CP) | 低 |
数据库 SELECT FOR UPDATE |
数据库行锁 | 最高 | 最低 |
Redis 锁的进化
版本 1:原始 SETNX(❌ 有严重问题)
java
// ❌ 别这么写
boolean locked = redisTemplate.opsForValue()
.setIfAbsent("lock:task", "1", Duration.ofSeconds(30));
if (locked) {
doWork(); // 如果超过 30s → 锁过期 → 别人也拿到锁
redisTemplate.delete("lock:task"); // 删的是别人的锁!
}
版本 2:加唯一标识(解决删错锁)
java
String lockKey = "lock:task";
String lockValue = UUID.randomUUID().toString(); // 唯一标识
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30));
if (locked) {
try {
doWork();
} finally {
// Lua 原子判断 + 删除:只能删自己的锁
String script = "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
"return redis.call('DEL', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), lockValue);
}
}
// 问题依然存在:如果 doWork() 超过 30s,锁还是过期了
版本 3:Redisson 看门狗(✅ 生产可用)
java
// RLock 自动续期,不需要手动设过期时间
RLock lock = redisson.getLock("lock:task");
// lock() 默认 30s 过期,启用看门狗:每 10s 续期到 30s
lock.lock();
try {
doWork(); // 即使执行 5 分钟,看门狗一直在续期
} finally {
lock.unlock();
}
看门狗原理:
plain
lock() 执行
↓
设置过期时间 30s
↓
每 internalLockLeaseTime/3 (即 10s) 检查:
→ 本线程还持有锁?→ 重新设过期时间为 30s(续期)
→ 本线程已释放?→ 停止续期
↓
unlock() → 停止续期 → Lua 原子删除锁
Redlock 争议(面试加分项)
plain
Redis 主从模式下:
1. Client 在 Master 拿到锁
2. Master 宕机,锁数据没同步到 Slave
3. Slave 升为 Master,锁丢失
4. Client 2 从新 Master 拿到同一把锁
→ 两个 Client 同时持有锁!
Redlock 方案(Antirez 提出):
向 N 个独立的 Redis 实例(不是主从)依次拿锁
拿到 ≥ N/2+1 个 → 拿锁成功
牺牲性能换可靠性
Martin Kleppmann 的批评:
GC 暂停导致锁过期的问题,Redlock 也解决不了
建议用 fencing token(递增序号):被 GC 暂停的 Client
恢复后拿着过期 token 写数据,存储层拒绝旧 token
结论:
大部分场景 Redis + Redisson 看门狗足够
真正不能并发的场景(如金融扣款),用 DB 唯一约束兜底
选型决策
plain
场景 推荐方案
─────────────────────────────────────────
定时任务互斥(可容忍极小概率并发) Redisson(看门狗)
秒杀扣库存(有 DB 兜底) Redis SETNX EX + Lua 解锁
分布式调度(绝对不能并行) ZooKeeper 临时节点
金融场景(强一致) DB 唯一约束 + 乐观锁,不用分布式锁
ZooKeeper 锁 vs Redis 锁
plain
Redis 锁 ZooKeeper 锁
────────────────────────────────────────────────────
实现原理 SETNX + 续期 临时顺序节点 + Watch
释放方式 主动删除 / 到期 连接断开 → 临时节点自动删除
一致性 最终一致(AP) 强一致(CP)
性能 高(~0.5ms) 低(~5-10ms,多次 ZK 通信)
死锁风险 续期失败会死锁 连接断开自动释放,不死锁
大脑裂 可能(主从切换) 不会(ZK 选主有保护)
题10:SpringBoot 接口幂等设计------项目实战
业务场景
支付接口收到重复的支付请求(网络抖动导致前端重试 + MQ 重试),导致同一个订单被扣了两次钱。需要设计一套通用的幂等方案。
痛点
- 支付、下单、发券等关键接口不能重复执行
- 不同接口幂等 key 不同(订单号、交易号、业务流水号)
- 核心矛盾:每个接口都要幂等,但幂等逻辑重复 → 需要一套通用方案
方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 唯一索引/唯一约束 | DB 唯一约束兜底 | 创建类操作 |
| 状态机 + WHERE | UPDATE WHERE status = ? |
状态流转 |
| Redis + 注解 + AOP | 通用幂等框架,注解驱动 | 最有价值的方案 |
实现
自定义幂等注解 + Redis + AOP:
① 定义注解:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/**
* 幂等 key 的前缀
*/
String prefix() default "";
/**
* 幂等 key 的组成部分,支持 SpEL 表达式
* 如: "#request.orderId"、"#request.userId + ':' + #request.type"
*/
String key();
/**
* 幂等有效期(秒)
*/
long expireSeconds() default 3600;
/**
* 重复请求时的提示信息
*/
String message() default "请勿重复提交";
}
② AOP 切面:
java
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
// ① 拼装幂等 key(解析 SpEL)
String bizKey = parseSpel(idempotent.key(), joinPoint);
String lockKey = "idempotent:" + idempotent.prefix() + ":" + bizKey;
// ② 检查是否已处理
String status = redisTemplate.opsForValue().get(lockKey);
if ("DONE".equals(status)) {
// ③ 已处理 → 返回缓存的结果(幂等:结果也一致)
String resultJson = redisTemplate.opsForValue().get(lockKey + ":result");
if (resultJson != null) {
log.warn("重复请求: {}", lockKey);
return JSON.parseObject(resultJson, getReturnType(joinPoint));
}
throw new BizException(idempotent.message());
}
if ("PROCESSING".equals(status)) {
// ④ 正在处理中(并发请求)→ 等待或拒绝
throw new BizException("正在处理中,请稍后查询结果");
}
try {
// ⑤ 标记为 PROCESSING
redisTemplate.opsForValue().set(lockKey, "PROCESSING",
idempotent.expireSeconds(), TimeUnit.SECONDS);
// ⑥ 执行业务
Object result = joinPoint.proceed();
// ⑦ 标记为 DONE + 缓存结果
redisTemplate.opsForValue().set(lockKey, "DONE",
idempotent.expireSeconds(), TimeUnit.SECONDS);
redisTemplate.opsForValue().set(lockKey + ":result",
JSON.toJSONString(result),
idempotent.expireSeconds(), TimeUnit.SECONDS);
return result;
} catch (Exception e) {
// ⑧ 异常 → 删除标记,允许重试
redisTemplate.delete(lockKey);
redisTemplate.delete(lockKey + ":result");
throw e;
}
}
// SpEL 解析:将 #request.orderId 等表达式算出实际值
private String parseSpel(String key, ProceedingJoinPoint joinPoint) {
// 获取方法参数
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String[] paramNames = signature.getParameterNames();
Object[] args = joinPoint.getArgs();
StandardEvaluationContext context = new StandardEvaluationContext();
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
ExpressionParser parser = new SpelExpressionParser();
return parser.parseExpression(key).getValue(context, String.class);
}
}
③ 使用方式:
java
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
// 使用幂等注解------一行搞定
@Idempotent(
prefix = "payment",
key = "#request.orderId", // SpEL:取请求体里的 orderId
expireSeconds = 600,
message = "该订单正在支付中,请勿重复提交"
)
@PostMapping("/pay")
@Transactional
public Result<PaymentVO> pay(@RequestBody @Valid PayRequest request) {
// 实际支付逻辑...
PaymentVO vo = paymentService.pay(request);
return Result.ok(vo);
}
}
幂等三种状态机:
plain
新请求 → Redis Key 不存在 → 设置 PROCESSING → 执行业务 → 成功后设 DONE
重复请求 → Redis Key = DONE → 返回缓存结果(幂等)
重复请求 → Redis Key = PROCESSING → 拒绝(并发保护)
异常 → 业务抛异常 → 删除 Redis Key → 允许重试(下次请求重新执行)
扩展
- 为什么要同时返回缓存结果? → 幂等不仅是"不重复执行",还要"返回相同结果"(Day1 附录)
- Redis 挂了怎么兜底? → DB 唯一约束兜底(如订单号唯一索引),确保数据层面不会重复
- 幂等 Key 的粒度取舍? → 太粗:一天只能支付一次;太细:同一个支付请求两个 TCP 包算不同 key。建议:业务唯一 ID + 操作类型
- PROCESSING 状态一直不释放? → 设 TTL 兜底自动释放;或在 finally 里重置
题11:Spring Cloud Gateway 统一鉴权设计
业务场景
微服务架构中,前端请求经过网关到达各个微服务。需要在网关层统一做 JWT 鉴权 + 白名单 + 黑名单。部分接口(如登录、注册、公开 API)不需要鉴权。
痛点
- 每个微服务各做各的鉴权 → 代码重复、安全漏洞
- 白名单配置需要灵活,硬编码不优雅
- 核心矛盾:统一鉴权的灵活性 vs 性能(每个请求都要过一遍过滤器)
方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| GlobalFilter + WebFlux | Gateway 自定义全局过滤器,鉴权逻辑写在 filter 里 | 通用方案 |
| Spring Security + OAuth2 | 集成 Security 的认证授权体系 | 需要 RBAC 的复杂场景 |
| 自定义 GatewayFilter Factory | 可复用、可配置的过滤器 | 不同路由不同鉴权策略 |
实现
整体架构:
plain
浏览器 → Nginx → Gateway → 微服务
Gateway 层:
① IP 黑名单过滤
② 路径白名单匹配(登录/注册/公开 API → 直接放行)
③ JWT 解析 + 校验(过期、签名)
④ 用户信息透传到下游(Header:X-User-Id、X-User-Name)
核心代码:
java
@Component
public class AuthFilter implements GlobalFilter, Ordered {
// 白名单路径(也可放 Nacos/Apollo 动态配置)
private static final List<String> WHITE_LIST = Arrays.asList(
"/api/user/login",
"/api/user/register",
"/api/public/**",
"/api/health"
);
@Autowired
private JwtUtil jwtUtil;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// ① 白名单 → 直接放行
if (isWhitePath(path)) {
return chain.filter(exchange);
}
// ② 获取 Token
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (StringUtils.isBlank(token) || !token.startsWith("Bearer ")) {
return unauthorized(exchange, "缺少 Token");
}
token = token.substring(7);
// ③ 校验 JWT
try {
Claims claims = jwtUtil.parseToken(token);
// ④ 如果 Token 快过期(< 5 分钟),刷新并写回响应头
if (claims.getExpiration().getTime() - System.currentTimeMillis() < 5 * 60 * 1000) {
String newToken = jwtUtil.refreshToken(token);
exchange.getResponse().getHeaders().set("X-New-Token", newToken);
}
// ⑤ 用户信息透传到下游
ServerHttpRequest newRequest = exchange.getRequest().mutate()
.header("X-User-Id", claims.getSubject())
.header("X-User-Name", claims.get("username", String.class))
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
} catch (ExpiredJwtException e) {
return unauthorized(exchange, "Token 已过期");
} catch (Exception e) {
return unauthorized(exchange, "Token 无效");
}
}
private boolean isWhitePath(String path) {
return WHITE_LIST.stream().anyMatch(pattern ->
new AntPathMatcher().match(pattern, path));
}
private Mono<Void> unauthorized(ServerWebExchange exchange, String msg) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
String body = String.format("{\"code\":401,\"msg\":\"%s\"}", msg);
DataBuffer buffer = exchange.getResponse().bufferFactory()
.wrap(body.getBytes(StandardCharsets.UTF_8));
return exchange.getResponse().writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return -100; // 优先级最高
}
}
IP 黑名单过滤器:
java
@Component
public class IpBlacklistFilter implements GlobalFilter, Ordered {
// 生产环境用 Redis Set 存储黑名单,支持动态增删
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String ip = getClientIp(exchange);
// 查 Redis 黑名单
Boolean isBlack = redisTemplate.opsForSet().isMember("ip:blacklist", ip);
if (Boolean.TRUE.equals(isBlack)) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
private String getClientIp(ServerWebExchange exchange) {
// Nginx 转发的真实 IP
String ip = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For");
if (StringUtils.isBlank(ip)) {
ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
}
return ip != null ? ip.split(",")[0].trim() : "unknown";
}
@Override
public int getOrder() { return -200; } // 比鉴权更靠前
}
扩展
- JWT 无状态登出? → JWT 无法撤销,需 Redis 维护黑名单(过期时间 = JWT 剩余有效期)
- Token 泄漏怎么办? → 短有效期(15min)+ Refresh Token(7天,可撤销),双 Token 机制
- 微服务内部调用怎么鉴权? → 服务间用内部 JWT(Service Token),不同密钥签发;或走 mTLS
- 怎么防止 Token 被重放? → Token 加 nonce(一次性随机数)服务端存 Redis 去重
题12:实时排行榜
痛点
- 1000 万用户的游戏,实时显示积分排行榜 Top 100 + 当前用户排名
- 全量排序 → TPS 撑不住;内存算一次几百 MB
- 核心矛盾:千万数据实时排序 + 高并发更新 + 延迟尽可能低
为什么 Redis ZSet 是最佳选择
plain
跳表(SkipList):多层索引加速查找
ZADD adds(pId, score) → O(log N)
ZREVRANGE 0 99 WITHSCORES → O(log N + 100),取 Top 100
ZREVRANK key member → O(log N),查某用户排名
ZSCORE key member → O(1),查某用户分数
Redis ZSet 关键操作
java
// 更新积分
redisTemplate.opsForZSet().add("leaderboard:global", userId, score);
// 获取 Top 100
Set<ZSetOperations.TypedTuple<String>> top100 =
redisTemplate.opsForZSet().reverseRangeWithScores("leaderboard:global", 0, 99);
// 查询用户排名(第几名)
Long rank = redisTemplate.opsForZSet().reverseRank("leaderboard:global", userId);
// 返回从 0 开始的排名,+1 才是第几名
// 查询用户附近排名:自己 + 前后各 5 名
// 先查自己的排名 → 再 range: [rank-5, rank+5]
内存估算
plain
1000 万用户 × (userId 20 字节 + score 8 字节 + 跳表指针 ~30 字节)
≈ 1000 万 × 58 字节 ≈ 580 MB
可以接受,一台 8G Redis 足够存储
如果不想占太多内存,可以制定阈值(如积分 > 0 才入榜)
分段排行榜
plain
如果 1000 万用户全在一个 ZSet,高并发更新时跳表调整代价大
分段思路:
王者段: 积分 > 5000 ~1 万人
钻石段: 3000 ~ 5000 ~5 万人
黄金段: 1500 ~ 3000 ~30 万人
白银段: 500 ~ 1500 ~100 万人
青铜段: < 500 ~800 万人
每个段一个 ZSet → 任何操作 O(log 段内人数),而不是 O(log 1000 万)
用户只能看到自己段内的排名
近似排名
plain
精确排名:ZREVRANK,O(log N)
如果需要 Top 100 精确 + 后面的用户排名"约第 XX 名",可以:
用分桶:每 1000 名一个桶
用户属于哪个桶 = 大致排名区间
桶的边界用 ZREVRANGEBYSCORE 动态维护
架构分层
plain
┌──────────────┐
│ 游戏服务器 │ 积分变更
│ 业务逻辑 │
└──────┬───────┘
│ ZADD 更新积分
▼
┌──────────────┐
│ Redis │ ZSet 主存储
│ (排行榜数据) │
└──────┬───────┘
│ 定时 snapshot
▼
┌──────────────┐
│ MySQL │ 每小时/每天存一份排行榜快照
│ (历史快照) │ 方便回溯查看"昨天 Top 100"
└──────────────┘
扩展追问
- Redis 挂了怎么办? → MySQL 存积分明细,Redis 挂了可以从 DB 重建 ZSet;但实时排名不可用,降级显示"排行暂不可用"
- 月底结算要取前 1000 名 → 直接 ZREVRANGE 0 999,O(log N + 1000),秒级完成
- 历史排行榜(上个月排名) → 定期快照到 MySQL;或者每个月一个独立 ZSet key(
rank:2026-06) - 多个维度排行榜? → 每个维度独立 ZSet:总积分榜、等级榜、战力榜;member 相同,score 不同
题13:消息队列四大经典问题
痛点
- 订单系统 → MQ → 积分系统:下单成功后异步发积分
- 面试官会连续追问四个问题,步步深入
- 核心矛盾:MQ 解耦了服务,但引入了新的可靠性挑战
四大问题总览
plain
① 消息丢失 → 三种丢失点,全链路解决
② 重复消费 → 幂等(跟 Day1 附录联动)
③ 消息顺序 → 三种方案,各有取舍
④ 消息积压 → 止损 + 根治
问题①:怎么保证消息不丢?
三个丢失点:
plain
【生产端 → Broker】 【Broker 内部】 【Broker → 消费端】
Producer 发出去但没到达 → Broker 内存还没刷盘 → Consumer 自动 ACK 后
或副本没同步就崩了 处理失败,消息丢了
各端解决:
plain
生产端:
- 同步发送 + Confirm 回调(RabbitMQ:publisher confirm;Kafka:acks=all)
- 失败重试 3 次 → 仍然失败 → 落本地消息表,定时补发
Broker 端:
- 同步刷盘(性能差,核心业务才开)
- 多副本(Kafka:replication.factor=3,min.insync.replicas=2)
- RabbitMQ:队列持久化 + 消息持久化
消费端:
- 手动 ACK,处理成功才 ACK
- ❌ 自动 ACK:消息刚收到就 ACK,处理失败 → 消息丢了
- ✅ 手动 ACK:处理完业务 + 写完 DB → 再 ACK
问题②:消费端重复消费怎么处理?
plain
正常:Producer → MQ → Consumer → ACK
异常:Consumer 处理成功,但 ACK 超时 → MQ 重发 → 同一条消息消费两次
解法:消费端幂等(见 Day1 附录详细内容)
最常用方案:
1. 每条消息带唯一 businessId(如 orderId + eventType)
2. 消费端:先查 Redis "msg:{businessId}" 是否已处理
- 已处理 → 直接 ACK,不执行业务
- 未处理 → 执行业务 → 标记已处理 → ACK
3. 去重记录定期清理(如只保留 7 天)
问题③:怎么保证消息顺序?
方案对比:
| 方案 | 做法 | 吞吐量 | 场景 |
|---|---|---|---|
| 全局单 Partition + 单 Consumer | 所有消息进一个 partition | ⭐ 最低 | 严格全局有序 |
| 局部有序(key hash) | 同一订单/用户的消息路由到同一 partition | ⭐⭐⭐ 高 | 最常用 |
| 消费端排序 | Consumer 用 seqNo 重排 | ⭐⭐ 中 | 复杂,少用 |
plain
【局部有序(key hash)------最实用】
同一订单的 "创建→支付→发货" 三个消息:
发送时指定 key = orderId → Kafka 按 orderId hash 到同一个 partition
partition 内消息天然有序(FIFO)
消费端按 partition 顺序消费
关键:一个 partition 只对应一个 consumer,消费有序
不同 orderId 走不同 partition → 全局无序,但每条订单内部有序
问题④:消息积压了怎么办?
根因:生产速度 > 消费速度
场景分类:
| 情况 | 方案 |
|---|---|
| Consumer 挂了 / 代码 bug | 修复 bug → 重新上线 → 正常消费追赶 |
| Consumer 性能不足(正常消费,但太慢) | 临时扩容 Consumer 数量(前提 Partition 数 ≥ Consumer 数) |
| 大促瞬时流量(攒了 1 亿条) | 紧急申请机器,把 Consumer 扩到 partition 数量,然后慢慢消化 |
| 消费逻辑太重(数据库写入慢) | 优化 SQL/批量写入;临时降级(跳过非核心逻辑) |
| 实在来不及消费 | 消息丢弃 → 离线补数据(从 DB 对账恢复) |
plain
【面试万能回答】
紧急止损(1 分钟内):
- 扩容 Consumer 到和 Partition 数一样多(上限)
- 如果消费慢是因为 DB,先切换为批量插入
短期缓解(10 分钟):
- 临时降级:非核心消费逻辑先关掉(如发通知、记日志)
- 新开临时 Topic,紧急分流一部分
长期根治(上线前就应该做的):
- 压测确认:正常消费速度 ≥ 峰值生产速度 × 1.5
- 监控告警:积压超过阈值(如 10 万条)→ 自动扩容
- 降级开关:大促期间可以一键降级非核心消费
题14:MySQL 分库分表后跨分片查询
业务场景
订单表按 user_id 分了 16 个库(每个库 32 张表),现在需要实现一个后台管理功能:按 create_time 范围查询所有订单,支持分页。
痛点
- 按 user_id 分库,但查询条件是 create_time → 需要扫所有分片
- 分页的 Offset 在分片场景下失效(各分片的第 N 条不是全局的第 N 条)
- 核心矛盾:分片键与查询条件不匹配 → 查询需要广播到所有分片再汇聚
方案
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 索引表(倒排索引) | 建一张索引表:create_time → (user_id, order_id),先在索引表查 id,再查实际订单 |
按非分片键查询频繁 |
| 异构到 ES | 订单数据同步到 ES,查询走 ES,订单详情回表 | 复杂搜索场景 |
| CQRS + 宽表 | 查询侧维护一张非分库的宽表,专门给后台查询 | 后台报表 |
| 业务层面限制 | 强制要求提供 user_id 才能查订单 | 最简单但用户体验差 |
| 广播 + 内存归并 | 每个分片执行查询 + 排序 → 应用层归并结果 | 低频后台操作 |
实现
方案一:索引表(倒排索引)
sql
-- 订单表(user_id 分库)
-- 额外建一张索引表(不分库或按 create_time 分库)
CREATE TABLE order_index (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
order_id BIGINT NOT NULL,
create_time DATETIME NOT NULL,
INDEX idx_create_time (create_time)
);
-- 查询流程:
-- ① SELECT user_id, order_id FROM order_index WHERE create_time BETWEEN ? AND ? ORDER BY create_time DESC LIMIT 0, 20
-- ② 拿到 (user_id, order_id) 列表 → 按 user_id 路由到对应库 → 查订单详情
-- ③ 应用层组装结果
java
public PageResult<Order> queryByTime(LocalDateTime start, LocalDateTime end, int page, int size) {
// ① 从索引表查 ID 列表
List<IdPair> ids = orderIndexMapper.listByTime(start, end, (page - 1) * size, size);
int total = orderIndexMapper.countByTime(start, end);
if (ids.isEmpty()) return PageResult.empty();
// ② 按 user_id 分组(同一库的放一起)
Map<String, List<Long>> groupByDb = ids.stream()
.collect(Collectors.groupingBy(
id -> "ds_" + (id.getUserId() % 16), // 分库路由
Collectors.mapping(IdPair::getOrderId, Collectors.toList())));
// ③ 每个分库并发查
List<CompletableFuture<List<Order>>> futures = groupByDb.entrySet().stream()
.map(entry -> CompletableFuture.supplyAsync(() ->
orderMapper.listByIds(entry.getValue(), entry.getKey())))
.collect(Collectors.toList());
List<Order> orders = futures.stream()
.flatMap(f -> f.join().stream())
.collect(Collectors.toList());
// ④ 按 create_time 重新排序(各分片返回的是各自排序后的,需归并)
orders.sort(Comparator.comparing(Order::getCreateTime).reversed());
return new PageResult<>(orders, total);
}
方案二:异构到 ES
plain
写入链路:
订单生成 → DB (ShardingSphere 分库) → Canal → RocketMQ → ES
查询链路:
后台管理 → ES 搜索(条件+排序+分页)→ 拿到 orderId 列表
→ 根据 orderId 反查各个库的订单详情(批量并发)
ES 中只存搜索需要的字段:id, user_id, product_name, amount, status, create_time
方案三:广播 + 归并(临时救急)
java
// 每个分库查 topN,应用层归并排序
// 如果取第 1 页:每个分片取 topN 条,汇总排序后取前 N
// 如果取第 100 页:每个分片取 top(offset+size) 条,汇总排序后取对应页
// 缺点:越往后翻,每个分片需要的 offset 越大,性能越来越差
// 只适合后台低频使用
选型
plain
高频搜索 + 多条件组合 → ES 方案
后台管理偶发查询 → 索引表方案(成本低)
简单用户查询 → 强制提供 user_id
临时紧急需求 → 广播 + 归并
扩展
- 分片分页的公式 → 全局第 N 页 = 每个分片取
ORDER BY x LIMIT 0, offset+size,全部汇总排序后 skip(offset) take(size) - 分页越往后越慢怎么破? → 用
create_time > lastTime LIMIT size游标翻页替代 OFFSET 翻页 - ShardingSphere 能自动处理吗? → 可以配置广播表 + 绑定表,但复杂排序和分页有限制
知识体系速查
plain
分布式基础设施
├── 分布式 ID:雪花算法(时间戳+机器ID+序列号) / 号段模式(双Buffer) / UUID v7
├── 分布式锁:Redisson 看门狗(自动续期) / ZK 临时节点 / DB 唯一约束兜底
├── 限流:固定窗口(临界问题)→ 滑动窗口(ZSet)→ 令牌桶(允许突发)→ 漏桶(恒定)
└── 短链接:发号器 + Base62 → DB 存映射 → Redis 热点缓存 → 302 重定向
消息队列可靠性
├── 不丢:生产端 Confirm / Broker 多副本+持久化 / 消费端手动 ACK
├── 不重:幂等 → 唯一 businessId + Redis 去重记录
├── 不乱:key hash 局部有序(同一业务 → 同一 partition)
└── 积压:扩容 Consumer / 批量消费 / 降级非核心 / 离线补数据
Redis 进阶应用
├── 分布式锁:SETNX + Lua + 看门狗
├── 排行榜:ZSet → ZADD / ZREVRANGE / ZREVRANK
├── 限流:ZSet 滑动窗口 / Hash 令牌桶
└── 去重:SETNX 幂等标记