2026-07-05-JAVA面试场景题训练

日期:2026-07-03

共 14 道经典后端/系统设计场景题,按难度从简单到难排序

难度递进:Spring 基础 → 单组件排查 → 缓存模式 → 系统设计 → 分布式基础 → 微服务架构 → 复杂分布式


目录(由简单到难)

  1. [Spring @Transactional 失效的 6 种场景](#Spring @Transactional 失效的 6 种场景)
  2. [Spring 循环依赖与三级缓存](#Spring 循环依赖与三级缓存)
  3. [Redis 大 Key 问题排查与解决](#Redis 大 Key 问题排查与解决)
  4. [MySQL 死锁排查------并发下单场景](#MySQL 死锁排查——并发下单场景)
  5. [Redis 缓存一致性------项目实战](#Redis 缓存一致性——项目实战)
  6. 限流器设计
  7. 短链接系统设计
  8. [分布式 ID 生成器](#分布式 ID 生成器)
  9. 分布式锁
  10. [SpringBoot 接口幂等设计------项目实战](#SpringBoot 接口幂等设计——项目实战)
  11. [Spring Cloud Gateway 统一鉴权设计](#Spring Cloud Gateway 统一鉴权设计)
  12. 实时排行榜
  13. 消息队列四大经典问题
  14. [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 幂等标记
相关推荐
豆瓣鸡2 小时前
封装 API 请求日志切面——从注解定义到 Starter 自动装配
java·开发语言·spring boot
掉鱼的猫3 小时前
把 OpenAPI 接入 Agent Harness:零代码让 Agent 听懂你的 REST API
java·llm·agent
lzhcoder3 小时前
Spring Boot 集成 Kafka:先跑通 Demo,再避开那 80% 的人踩过的坑
java·kafka
plainGeekDev4 小时前
ProGuard → R8
android·java·kotlin
带刺的坐椅4 小时前
把 OpenAPI 接入 Agent Harness:零代码让 Agent 听懂你的 REST API
java·ai·llm·agent·solon·restapi·openapi
༄沐࿆风࿆࿆4 小时前
JavaScript函数完全指南:从基础语法到闭包原理与应用实战
开发语言·javascript
Geek-Chow4 小时前
微服务认证与授权:01 — 概念
java·前端·数据库
KobeSacre4 小时前
DelayQueue 源码
java·开发语言
Wang's Blog4 小时前
JavaWeb快速入门: Filter与Listener核心详解
java·filter·listener