项目拷打
i.你为什么要在商品查询链路里加本地缓存?
ii.本地缓存怎么实现？Redis 里又存了什么?
ii.这是单机方案还是多机方案？
iv.如果是多机，本地缓存的一致性怎么保证？
v.如果用消息通知每个节点删除本地缓存，怎么确认每台机器都收到了通知？
vi.热点key是怎么形成的？
vii.热点key怎么解决？除了打到数据库，打到Redis本身会不会也有问题？
vii.什么是大key？会带来什么问题?
## ix.Redis的过期删除机制是怎么实现的？
**Redis 用两种策略结合：惰性删除 + 定期删除。**

**惰性删除：**

访问 key 时才检查是否过期，过期就删除返回空。实现简单，但不访问就不删，过期 key 会一直占内存。

**定期删除：**

Redis 后台定时任务（默认每 100ms 一次）随机抽取一批设了过期时间的 key 检查删除。不是全量扫描，是随机抽样——每次抽 20 个，如果过期比例超过 25% 就继续抽，直到比例降下来或者达到时间上限。

为什么不全量扫描？key 多的时候全扫一遍会阻塞主线程，Redis 单线程模型扛不住。

**两种策略的互补：**

- 惰性删除兜底：定期删除漏掉的，访问时补删
- 定期删除主动回收：不访问的冷数据也能清理掉

**内存不够时还有第三道：淘汰策略**

过期删除不够用时触发 maxmemory-policy，常见策略：

- `allkeys-lru`：全量 key 中淘汰最近最少使用
- `volatile-lru`：只在有过期时间的 key 里做 LRU
- `volatile-ttl`：优先淘汰剩余 TTL 最短的

**踩过的坑：**

大量 key 在同一时间点过期，定期删除压力集中，会有短暂延迟毛刺。解决方案是给过期时间加随机抖动，把删除压力分散开。缓存雪崩本质也是这个问题。

基础/八股
## i.一致性哈希怎么实现？主要解决什么问题?
前面聊扩缩容时提过一致性哈希的原理，这里补充一下**具体实现**。

**解决的核心问题：**

普通取模哈希（`hash(key) % N`），节点数 N 变化时几乎所有 key 都要重新映射，扩缩容代价极高。一致性哈希让扩缩容只影响相邻节点，迁移量从 O(全量) 降到 O(1/N)。

**实现关键点：**

```go
type ConsistentHash struct {
    replicas int               // 虚拟节点倍数
    ring     map[uint32]string // 哈希环
    sorted   []uint32          // 有序哈希值，便于二分查找
}

// 查找：找顺时针最近的虚拟节点
func (c *ConsistentHash) Get(key string) string {
    h := hash(key)
    // 二分查找第一个 >= h 的位置
    idx := sort.Search(len(c.sorted), func(i int) bool {
        return c.sorted[i] >= h
    })
    if idx == len(c.sorted) {
        idx = 0 // 环形，回到起点
    }
    return c.ring[c.sorted[idx]]
}
```

核心数据结构是 **有序数组 + map**，查找用二分，O(logN)。

**虚拟节点的实现：**

```go
// 添加节点时，生成 replicas 个虚拟节点
for i := 0; i < c.replicas; i++ {
    h := hash(fmt.Sprintf("%s#%d", node, i))
    c.ring[h] = node
}
```

replicas 一般设 100-200，太少分布不均，太多内存浪费。

**实际选型：**

自己实现容易踩坑，生产上直接用成熟库，Go 里 `github.com/buraksezer/consistent` 实现比较完善，支持虚拟节点和自定义哈希函数。

## ii. Redis/HashMap这类结构本身是不是线程安全的？如果想找线程安全的map，可以用什么替代?
**Redis 和 HashMap 都不是线程安全的，但原因不同。**

**Redis：**

单线程处理命令，单个命令执行是原子的，所以单命令操作线程安全。但**多命令组合不是原子的**，比如 GET + 判断 + SET 之间可能被其他客户端插入，需要用 Lua 脚本或 WATCH + MULTI/EXEC 来保证原子性。

严格说 Redis 的"线程安全"是单进程串行执行带来的，不是数据结构本身的保证。

**Go 的 map：**

明确非线程安全，并发读写会直接 panic（Go 运行时会检测并发 map 写）。

**线程安全替代方案：**

`sync.Mutex` + map，最简单，适合读写比例均衡的场景：

```go
type SafeMap struct {
    mu sync.RWMutex
    m  map[string]any
}
```

`sync.RWMutex` + map，读多写少场景用读写锁，读并发度更高。

`sync.Map`，Go 标准库提供，内部两层结构——read map（无锁读）+ dirty map（加锁写），适合**读多写少、key 相对稳定**的场景。写多的场景性能反而不如加锁 map，因为 dirty 提升到 read 有额外开销。

**Java 对比：**

`ConcurrentHashMap` 分段锁设计，并发度比全局锁高很多，比 Go 的 `sync.Map` 适用场景更广。Go 这块坦白说没 Java 成熟。

**实践选型：**

默认用 `sync.RWMutex` + map，够用且行为可预期。`sync.Map` 只在 profile 确认是瓶颈且符合其适用场景时才换。

## iii.MySQL建表时为什么大家经常加一个自增id字段？这样做的好处是什么？
**核心原因：InnoDB 用主键组织数据，主键的选择直接影响写入性能。**

InnoDB 是索引组织表，数据按主键顺序存在 B+ 树叶子节点上。自增 ID 的关键优势是**顺序写入**。

**顺序写入 vs 随机写入：**

自增 ID 每次插入都追加到 B+ 树最右边，页分裂极少，磁盘写入接近顺序 IO。

用 UUID 或业务字段做主键，插入位置随机，频繁触发页分裂和数据移动，写入性能差很多，索引碎片化也严重。

**其他好处：**

- **join 和关联简单**：整型比较比字符串快，外键关联用自增 ID 性能好
- **唯一性保证**：不用担心业务字段重复或 null 的问题
- **空间小**：bigint 8 字节，UUID 36 字节，主键出现在每个二级索引里，体积差异乘以索引数量很可观
- **排序语义**：自增 ID 天然反映插入顺序，分页查询可以用 `WHERE id > last_id` 替代 `OFFSET`，深翻页性能好很多

**什么时候不适合用自增 ID：**

- 分布式场景，单库自增 ID 跨库会冲突，需要雪花算法或分布式 ID 生成器
- 安全敏感场景，自增 ID 暴露数据量和增长速度，容易被爬接口

**我们的实践：**

内部服务表标配自增 bigint 主键，对外暴露的资源 ID 用雪花算法生成的字符串，两者并存——内部用自增主键保证写入性能，对外用业务 ID 避免信息泄露。

## iv.如果一张表没有这个自增id，会发生什么问题？
**分几种情况讨论：**

**有显式主键但不是自增：**

用 UUID 做主键，写入性能下降，页分裂频繁，索引碎片化，前面聊过了。

用业务字段做主键（比如手机号、身份证），风险是业务字段可能变更，主键变更代价极高——所有二级索引都存了主键值，一改全改。

**没有主键：**

InnoDB 不是真的没主键，会按这个顺序自动选：

1. 找第一个非空唯一索引作为主键
2. 没有就隐式生成一个 6 字节的 `rowid`

隐式 rowid 的问题：

- 对业务不可见，无法用主键查询，只能走全表扫或二级索引
- rowid 全局递增，多表共享一个计数器，高并发写入时有锁竞争
- Binlog 复制时，row 格式下没有主键的表每行变更都要全表扫描定位，**主从复制性能极差**，这是线上最实际的问题

**实际踩过的坑：**

接手过一个历史遗留表，没有主键，只有几个普通索引。业务量上来之后主从延迟越来越高，排查发现就是没主键导致 slave 回放 binlog 效率低，加了自增主键之后延迟立刻降下来了。

**结论：**

没有自增主键不一定立刻崩，但在写入性能、主从复制、分页查询等场景都会有隐患，是典型的技术债。

## v.主键和唯一索引有什么区别?
**相同点：都保证字段值唯一，底层都是 B+ 树索引。**

**几个核心区别：**

**NULL 处理不同**

主键不允许 NULL，唯一索引允许多个 NULL。原因是 NULL != NULL，所以多个 NULL 不违反唯一约束。这个细节容易忽略。

**数据组织方式不同**

InnoDB 是索引组织表，数据直接存在主键 B+ 树的叶子节点上。唯一索引是二级索引，叶子节点存的是主键值，查询时需要**回表**再去主键索引取完整数据。

所以主键查询是一次 B+ 树查找，唯一索引查询是两次。

**只能有一个 vs 可以有多个**

主键只能有一个，唯一索引可以建多个。

**Change Buffer 的差异**

普通索引的写入可以利用 Change Buffer 延迟合并，减少随机 IO。唯一索引因为要实时校验唯一性，**不能用 Change Buffer**，每次写入都要把数据页读到内存检查，写入性能比普通索引差。主键同理。

这个差异在写多场景影响明显，我们有个日志表把不必要的唯一索引改成普通索引之后写入 TPS 提升不少。

**实践选型：**

业务上确实需要唯一性约束就加唯一索引，但要清楚它的写入代价。如果唯一性在应用层已经保证，数据库层可以只建普通索引，性能更好。主键尽量用自增 ID，不要把唯一索引当主键用。

## vi.一条SQL有没有命中索引，通常怎么判断？
**相同点：都保证字段值唯一，底层都是 B+ 树索引。**

**几个核心区别：**

**NULL 处理不同**

主键不允许 NULL，唯一索引允许多个 NULL。原因是 NULL != NULL，所以多个 NULL 不违反唯一约束。这个细节容易忽略。

**数据组织方式不同**

InnoDB 是索引组织表，数据直接存在主键 B+ 树的叶子节点上。唯一索引是二级索引，叶子节点存的是主键值，查询时需要**回表**再去主键索引取完整数据。

所以主键查询是一次 B+ 树查找，唯一索引查询是两次。

**只能有一个 vs 可以有多个**

主键只能有一个，唯一索引可以建多个。

**Change Buffer 的差异**

普通索引的写入可以利用 Change Buffer 延迟合并，减少随机 IO。唯一索引因为要实时校验唯一性，**不能用 Change Buffer**，每次写入都要把数据页读到内存检查，写入性能比普通索引差。主键同理。

这个差异在写多场景影响明显，我们有个日志表把不必要的唯一索引改成普通索引之后写入 TPS 提升不少。

**实践选型：**

业务上确实需要唯一性约束就加唯一索引，但要清楚它的写入代价。如果唯一性在应用层已经保证，数据库层可以只建普通索引，性能更好。主键尽量用自增 ID，不要把唯一索引当主键用。

EXPLAIN
里看哪些字段?
## vii.EXPLAIN里的type常见取值有哪些?
**type 表示访问类型，性能从好到差：**

`system` > `const` > `eq_ref` > `ref` > `range` > `index` > `ALL`

**常见几个：**

**const** 主键或唯一索引等值查询，最多返回一行，查询优化器会把这个表当常量处理，最快。

```sql
SELECT * FROM user WHERE id = 1
```

**eq_ref** join 时，被驱动表用主键或唯一索引关联，每次最多匹配一行。多表 join 里能看到这个是好事。

**ref** 非唯一索引等值查询，可能返回多行。普通索引的等值查询一般是这个。

**range** 索引范围扫描，`BETWEEN`、`>`、`<`、`IN` 这类。能用到索引但不是等值，可以接受。

**index** 全索引扫描，遍历整棵索引树。比 ALL 强一点（索引比数据小），但数据量大时一样慢，要注意。

**ALL** 全表扫描，最差，通常意味着没走索引。线上慢查询大多数是这个，必须优化。

**实践中关注点：**

- 线上查询至少要到 `range`，`ref` 更好
- 看到 `ALL` 或 `index` 配合 rows 字段，rows 很大就是问题
- `type` 要结合 `Extra` 字段看，`Using filesort`、`Using temporary` 出现就要警惕
- `IN` 条件值多的时候可能从 `range` 退化成 `ALL`，这个坑踩过，IN 列表超过一定数量优化器会放弃索引

## viii.如果对年龄列建了索引，where age>18这类范围查询会不会走索引?
**会走索引，但不一定全程走。**

范围查询对应 EXPLAIN 里的 `range` 类型，索引能定位到 age=18 的位置，然后顺序扫描后续节点，比全表扫描省很多。

**但有个关键问题：选择性。**

优化器会估算走索引的代价，如果范围太大、命中行数太多，优化器可能判断"回表代价 > 全表扫描"，直接放弃索引走 `ALL`。

比如一张千万行的表，`age > 18` 可能命中 90% 的数据，回表次数极多，优化器大概率选全表扫描。`age > 60` 命中少，就会走索引。

**验证方式：**

```sql
EXPLAIN SELECT * FROM user WHERE age > 18;
-- 看 type 和 rows，rows 接近总行数说明优化器放弃索引了
```

**联合索引时的截断问题：**

范围查询会导致后续索引列失效。比如联合索引 `(age, name)`：

```sql
WHERE age > 18 AND name = 'foo'
-- age 用了 range，name 索引失效，只走了 age 部分
```

这是联合索引设计时要考虑的，范围查询字段尽量放联合索引最后一列。

**覆盖索引能缓解回表问题：**

如果 SELECT 的字段都在索引里，不需要回表，范围查询走索引的收益就大很多，优化器也更倾向于用索引。

实践上遇到范围查询不走索引，先用 `FORCE INDEX` 验证是不是优化器误判，再决定是调整索引还是改查询逻辑。

## ix.说几个不稳定排序，并解释它们为什么不稳定。
**不稳定排序：相等元素的相对顺序在排序后可能改变。**

**堆排序**

建堆过程中，heapify 会把堆顶元素和末尾元素交换，这个交换是跨距离的，完全不考虑相等元素的原始顺序。比如两个相同的值，建堆后可能因为交换路径不同导致顺序颠倒。

**快速排序**

partition 阶段把 pivot 和数组中某个元素交换，可能把相等元素的顺序打乱。比如 `[3a, 3b, 1]`，pivot 选 3a，1 和 3a 交换后变成 `[1, 3b, 3a]`，3b 跑到 3a 前面了。

**选择排序**

每次找最小值和当前位置交换，这个交换是跳跃性的，会破坏相等元素的相对顺序。比如 `[3a, 2, 3b]`，第一轮把 2 和 3a 交换，变成 `[2, 3a, 3b]`，但如果是 `[3a, 3b, 2]`，第一轮把 2 和 3a 交换，变成 `[2, 3b, 3a]`，3a 和 3b 顺序颠倒了。

**对比稳定排序：**

归并排序在 merge 阶段，左右两边相等时优先取左边元素，保证了相对顺序。这个"优先取左"是稳定性的关键，不稳定排序都缺乏这种约束。

**实际意义：**

大多数场景不在乎，但多字段排序时稳定性很重要——先按年龄排，再按姓名排，如果姓名排序不稳定，年龄的顺序就被破坏了。Go 的 `sort.Slice` 是不稳定的，需要稳定排序要用 `sort.SliceStable`。

## x.vim打开文件后怎么显示行号？怎么向后翻页?
**显示行号：**

在普通模式下输入 `:set number` 或简写 `:set nu`，临时生效。

永久生效加到 `~/.vimrc`：

```
set number
```

相对行号用 `:set relativenumber`，配合 `number` 一起用可以显示当前行绝对行号 + 其他行相对行号，跳转很方便。

**翻页：**

- `Ctrl + f`：向后翻一页（forward）
- `Ctrl + b`：向前翻一页（backward）
- `Ctrl + d`：向后翻半页
- `Ctrl + u`：向前翻半页

这不太像是面试题，是遇到什么问题了么？

算法/其他
## i.代码题：给定一个0~9的排列，求下一个排列。
**思路：**

从右往左找第一个"下降点" `i`（即 `arr[i] < arr[i+1]`），然后在 `i` 右边找比 `arr[i]` 大的最小值交换，再把 `i` 右边的部分反转。

**为什么这样做：**

- 右边是降序，说明右边已经是最大排列，动右边没意义，要动 `i`
- 换最小的比它大的值，保证增量最小
- 换完后右边反转成升序，得到右边最小排列

```go
func nextPermutation(nums []int) {
    n := len(nums)
    
    // 1. 从右找第一个下降点
    i := n - 2
    for i >= 0 && nums[i] >= nums[i+1] {
        i--
    }
    
    // 2. 如果找到了，找右边比它大的最小值交换
    if i >= 0 {
        j := n - 1
        for nums[j] <= nums[i] {
            j--
        }
        nums[i], nums[j] = nums[j], nums[i]
    }
    
    // 3. 反转 i 右边部分
    left, right := i+1, n-1
    for left < right {
        nums[left], nums[right] = nums[right], nums[left]
        left++
        right--
    }
}
```

**边界：**

- 如果整个数组降序（如 `9,8,7...`），`i` 最终是 -1，跳过交换，直接反转整个数组，得到最小排列 `0,1,2...`，符合"循环"语义

**复杂度：** O(n) 时间，O(1) 空间。

## ii.你怎么评价自己的优点和缺点？
**优点：**

技术判断力还可以，能比较快地识别方案的 trade-off，不容易被某个技术方向带偏。前面聊的这些系统设计问题，我习惯从"为什么这样设计"入手，而不是背结论。

落地能力比较强，做过从 0 到 1 的系统，也接手过历史债严重的老系统，两种情况都处理过，不只停留在设计层面。

对 AI 工程化这块投入比较早，不是风口来了才跟，有真实踩坑经验，不是 PPT 工程师。

**缺点：**

有时候对方案完美度要求偏高，容易在技术细节上花太多时间，业务压力大的时候这个习惯会和交付节奏产生摩擦，这点我在有意识地调整——学会在"够用"和"完美"之间找平衡点。

跨团队沟通上，早期比较直，有时候表达技术意见的方式让对方不太舒服。后来意识到技术判断力要配上沟通方式才能真正推动事情，这块成长空间还有。

另外 Java 生态这两年跟得没有 Go 深，Spring 体系比较熟但没有 Go 那种底层的掌握感，如果贵司 Java 体系为主，需要一段时间补齐。

---

说实话这类问题我不太擅长往"完美候选人"方向包装，就是实话实说。

## i如果在语言上做选择，Go和Java你更偏哪一个？用Go的过程中遇到过什么问题?
**更偏 Go，但不是无脑推崇。**
Go 的优势在我的场景里很实际：
- 编译快、部署简单，单二进制没有 JVM 依赖，容器镜像小很多
- goroutine 心智负担比 Java 线程池低，并发代码写起来更自然
- 语言本身简单，团队新人上手快，代码风格统一（gofmt 直接解决风格争论）
- 内存占用比 Java 服务低不少，同等配置能跑更多实例
**但 Go 用下来有几个真实的痛点：**
**错误处理很啰嗦** `if err != nil` 写到手软，复杂业务逻辑里错误处理代码占比很高，可读性受影响。Java 的 exception 体系在这点上更优雅，虽然也有争议。
**泛型来得太晚** 1.18 才加，生态里很多库还没跟上，早期写通用数据结构全靠 `interface{}`，类型断言满天飞，踩过不少运行时 panic。
**GC 不够可预测** 大多数场景够用，但做低延迟服务时 GC 抖动还是会出现，Java 的 ZGC/G1 在可调性上更成熟，调优手段更多。
**包管理历史包袱** Go modules 之前的 GOPATH 是噩梦，现在好多了，但和 Maven/Gradle 比生态成熟度还是差一截。
**Java 更适合的场景：**
大型企业应用、复杂业务系统，Spring 生态太成熟了，很多轮子不用自己造。还有对 GC 调优有强需求的场景。
总体来说选语言看场景，基础设施、高并发网关、工具链用 Go 很爽，复杂业务中台 Java 更稳。