又是项目拷打全程，只有几个八股 
## 1.rocket，mq延迟队列怎么设计的 
**RocketMQ 延迟消息的设计**

RocketMQ 不支持任意时间延迟，只支持**固定18个延迟级别**：

```
1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
```

**核心实现**

发送延迟消息时，Broker 不直接投递到目标 Topic，而是：

```
1. 消息写入内置 Topic：SCHEDULE_TOPIC_XXXX
   对应 18 个 Queue，每个 Queue 对应一个延迟级别
2. 定时任务每 100ms 扫描每个 Queue
3. 到期的消息重新投递到原始 Topic
4. 消费者正常消费原始 Topic
```

**为什么不支持任意时间**

任意时间延迟需要按时间排序，本质是优先队列，大量消息下性能和存储压力都很大。固定级别用固定 Queue 顺序消费，实现简单，性能可控。

**如果业务需要任意延迟怎么办**

几个方案：

- **时间轮**：自己实现，内存级别，精度高但不持久化
- **Redis ZSet**：score 存执行时间戳，定时轮询 `ZRANGEBYSCORE`，简单实用
- **RocketMQ 5.0**：新版本支持任意时间延迟消息，底层用时间轮 + CommitLog 实现，解决了这个痛点

```
Redis方案：
ZADD delay_queue <timestamp> <message>
// 定时任务
ZRANGEBYSCORE delay_queue 0 <now>  → 取出到期消息投递
```

生产上我们用 Redis ZSet 方案做过订单超时取消，精度 1 秒够用，实现成本低。RocketMQ 5.0 的任意延迟我调研过但还没上生产。

## 2.延迟队列怎么存储的 
**RocketMQ 延迟消息存储**

消息最终都走 **CommitLog**，延迟消息也不例外，只是多了一层转发。

**写入流程**

```
Producer发送延迟消息
    ↓
Broker写入 CommitLog（所有消息统一存这里）
    ↓
ReputMessageService 异步转发
    ↓
不写原始Topic的ConsumeQueue
而是写 SCHEDULE_TOPIC_XXXX 的ConsumeQueue
（18个Queue对应18个延迟级别）
```

**ConsumeQueue 存什么**

每条索引记录固定 20 字节：

```
CommitLog Offset（8字节）+ Size（4字节）+ TagHashCode（8字节）
```

定时任务通过 ConsumeQueue 找到消息在 CommitLog 的位置，读出来投递。

**定时调度**

```
ScheduleMessageService：
- 每个延迟级别对应一个 Timer 任务
- 记录每个 Queue 的消费进度 offset
- 到期消息从 CommitLog 读出，重新投递到原始 Topic 的 ConsumeQueue
- 更新 offset，持久化到磁盘
```

**关键点**

- 消息**只写一次** CommitLog，不做物理拷贝，转发只是写新的 ConsumeQueue 索引，IO 开销小
- 定时任务挂了重启后，从持久化的 offset 继续消费，不丢消息
- 这也解释了为什么只有固定级别——每个级别一个 Queue，顺序写顺序读，性能最优

RocketMQ 5.0 任意延迟底层加了个**时间轮**，按秒级 bucket 组织消息，到期再捞出来走同样的投递流程。

## 3.Redis和本地缓存的更新策略 
**两级缓存架构**

```
请求 → 本地缓存(L1) → Redis(L2) → DB
```

本地缓存用 Guava Cache 或 Caffeine，Redis 作二级。

**各自的更新策略**

**Redis 更新** 主流用 Cache Aside：

- 读：先读 Redis，miss 再查 DB，回填 Redis
- 写：更新 DB，删除 Redis，依赖下次读重建

也可以用 Canal 监听 binlog 异步更新，业务代码无感知。

**本地缓存更新**

难点在这里——本地缓存在每个节点内存里，DB 或 Redis 更新后**无法主动推送**。

几个方案：

**方案1：短 TTL 自然过期**

- 本地缓存设很短的过期时间（几秒到几十秒）
- 简单，接受短暂不一致
- 适合变更不频繁的数据

**方案2：MQ 广播通知**

```
更新DB → 发MQ广播消息 → 所有节点消费 → 删除本地缓存
```

- 一致性好，延迟低
- 节点多时 MQ 消息量大，需要注意消费积压

**方案3：Redis Pub/Sub**

```
更新DB → Redis publish失效通知 → 所有订阅节点删除本地缓存
```

- 比 MQ 轻量，适合缓存失效通知这种场景
- 缺点：Pub/Sub 不持久化，订阅者不在线会丢消息

**踩过的坑**

本地缓存 + Redis 双写时，要注意**更新顺序**：先删本地缓存，再删 Redis，顺序反了可能导致本地缓存读到旧值回填 Redis。

另外本地缓存容量要控制好，Caffeine 设最大条数 + 弱引用，避免 OOM。

## 4.mysql慢查询怎么处理 
**定位阶段**

开启慢查询日志：

```sql
slow_query_log = ON
long_query_time = 1  -- 超过1s记录
```

用 `mysqldumpslow` 或 `pt-query-digest` 分析日志，找出高频慢 SQL。

**分析阶段**

`EXPLAIN` 是核心工具，重点看几个字段：

```sql
EXPLAIN SELECT * FROM orders WHERE user_id=1 AND status=0;
```

- **type**：`ALL` 全表扫描要警惕，理想是 `ref`/`range`/`const`
- **key**：实际用到的索引，NULL 说明没走索引
- **rows**：预估扫描行数，越大越危险
- **Extra**：`Using filesort`、`Using temporary` 出现就要优化

**常见原因和解法**

**索引问题**

- 没建索引 → 补索引
- 索引失效：函数操作、隐式类型转换、`%`前缀模糊查询 → 改写 SQL
- 索引选择错误 → `FORCE INDEX` 或调整统计信息

**SQL 写法问题**

```sql
-- 差：对索引列用函数
WHERE DATE(created_at) = '2024-01-01'
-- 好：范围查询
WHERE created_at >= '2024-01-01' AND created_at < '2024-01-02'
```

**数据量问题**

- 深分页：`LIMIT 100000, 20` 扫描大量数据 → 改用游标分页

```sql
WHERE id > last_id LIMIT 20
```

- 大表 JOIN → 小表驱动大表，或拆成多次查询

**其他方向**

- 读写分离：慢查询打到从库
- 分库分表：数据量级到了就得拆
- 加缓存：热点查询不打 DB

生产上遇到最多的是索引失效和深分页，`EXPLAIN` 基本能定位 80% 的问题。

## 5.索引未命中有什么可能，怎么处理 
**常见索引未命中原因**

**1. 对索引列做函数或运算**

```sql
-- 未命中
WHERE YEAR(created_at) = 2024
WHERE age + 1 = 18
-- 改写
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'
WHERE age = 17
```

**2. 隐式类型转换**

```sql
-- phone 是 varchar，传了 int，触发隐式转换
WHERE phone = 13800138000   -- 未命中
WHERE phone = '13800138000' -- 命中
```

这个坑很隐蔽，`EXPLAIN` 看到 type=ALL 但明明有索引，大概率是这个问题。

**3. 模糊查询前缀 %**

```sql
WHERE name LIKE '%张'   -- 未命中
WHERE name LIKE '张%'   -- 命中
```

必须前缀匹配才能走索引，实在需要全模糊考虑 ES。

**4. 联合索引不满足最左前缀**

```sql
-- 索引 (A, B, C)
WHERE B=1 AND C=1   -- 未命中，跳过了A
WHERE A=1 AND C=1   -- A命中，C未命中
```

**5. OR 连接非索引列**

```sql
WHERE id=1 OR name='张三'  -- name无索引，整体退化全表扫
-- 改写成 UNION
SELECT * FROM t WHERE id=1
UNION
SELECT * FROM t WHERE name='张三'
```

**6. NOT IN / NOT EXISTS** 优化器认为范围太大，可能放弃索引，改写成 JOIN 或 EXISTS。

**7. 数据区分度太低**

```sql
WHERE status = 1  -- status只有0/1，50%数据，优化器认为全表扫更快
```

优化器基于成本选择，区分度低的列索引意义不大。

**排查方法**

```sql
EXPLAIN SELECT ...
-- 看 type、key、Extra
-- type=ALL + key=NULL → 未命中
-- Extra有 Using where 但 key 有值 → 命中索引但有回表过滤
```

生产踩过最多的是隐式类型转换，代码里 ORM 参数类型没对齐，`EXPLAIN` 一看立刻定位。

## 手撕:给你x元，用1元，5元，10元，20元，50元100元的无限硬币，能有多少种组合方式
经典完全背包的变种，组合数问题。

**思路**

dp[i] 表示凑成金额 i 的组合数，coins 无限用。

```go
func coinCombinations(amount int) int {
    coins := []int{1, 5, 10, 20, 50, 100}
    dp := make([]int, amount+1)
    dp[0] = 1  // 金额为0，有1种方案（什么都不选）
    
    // 外层遍历硬币，内层遍历金额
    // 保证组合数（不考虑顺序）
    for _, coin := range coins {
        for j := coin; j <= amount; j++ {
            dp[j] += dp[j-coin]
        }
    }
    return dp[amount]
}
```

**关键点：循环顺序**

外层枚举硬币，内层枚举金额——这样每种硬币作为"新加入"的选择，保证不重复计排列。

反过来外层枚举金额，内层枚举硬币，求的是**排列数**（1+5 和 5+1 算两种），不是组合数。

**验证**

```
amount=10：
1+1+...+1, 1+4+5, 5+5, 10 ...
dp[10] = 4
```

**复杂度**

- 时间：O(n * amount)，n 是硬币种数
- 空间：O(amount)

这道题面试时容易在循环顺序上翻车，本质是完全背包求方案数，记住**组合数外层coin内层amount**就不会错。
