字节一面（亲测没做出算法题也能过）
先做一下自我介绍。
实习项目拷打
## Redis ZSet底层怎么实现？跳表结构讲一下。
ZSet 底层有两种编码，按数据量自动切换：

**listpack（原 ziplist）**

- 元素数 < 128 且所有值长度 < 64 字节时用
- 紧凑内存布局，连续存储，查询是 O(n) 但数据少无所谓

**skiplist + dict 组合**

- 超过阈值后转换，两个结构同时维护
- dict 提供 O(1) 的 score 查找
- skiplist 支持 O(log n) 的范围查询（ZRANGE、ZRANGEBYSCORE）

**跳表核心结构**

```
level 3: head → [20] ----------------------→ tail
level 2: head → [20] → [50] --------------→ tail  
level 1: head → [20] → [50] → [70] → [90] → tail
```

- 每个节点随机生成层高（概率 p=0.25，最大 64 层）
- 查找时从最高层开始，逐层下降，跳过不必要节点
- 每个节点还有 `span` 记录跨度，支持 O(log n) 的 rank 查询

**为什么不用红黑树？**

跳表实现简单、范围查询更直观，锁粒度也更容易控制。Redis 作者 antirez 自己说过"skiplist 在实践中和红黑树性能差不多，但代码简单多了"。

有个细节值得注意：ZSet 的 `span` 字段是我觉得设计比较精巧的地方，ZRANK 能做到 O(log n) 就靠它。

## ZSet按分数范围查找的时间复杂度？范围过大会不会退化？
**时间复杂度：O(log n + k)**

- log n：跳表定位到起始节点
- k：遍历范围内的 k 个元素

**会不会退化？**

严格来说不叫"退化"，k 是结果集大小，线性遍历是不可避免的——你要返回 10w 个元素，任何数据结构都得输出 10w 个。

真正的问题是**大范围查询的实际风险**：

- **网络传输瓶颈**：结果集太大，序列化 + 传输才是性能杀手，跳表本身反而不是瓶颈
- **Redis 单线程阻塞**：ZRANGEBYSCORE 一次返回 100w 条，会长时间占用主线程，影响其他命令
- **内存分配压力**：结果集构建需要一次性分配大块内存

**实践中的应对方案**

用 `ZRANGEBYSCORE key min max LIMIT offset count` 做分页，或者 Redis 6.2+ 的 `ZRANGEBYLEX` + cursor 方式。

我们之前有个排行榜场景，有人直接 ZRANGE 0 -1 拉全量，数据涨到 50w 之后开始出现慢查询告警。后来改成按 score 分段拉取，每段 1000 条，问题解决了。

所以跳表结构本身没问题，**大范围是工程问题，不是算法问题**。

## 只查某个key是否在ZSet 里，时间复杂度是多少?
用 `ZSCORE key member` 或 `ZRANK key member`。

**ZSCORE：O(1)**

ZSet 内部同时维护了一个 dict（member → score 的哈希表），判断存在性直接查 dict，跟普通 HashMap 一样。

**ZRANK：O(log n)**

要返回排名，必须走跳表，所以是 log n。

---

如果只是判断"在不在"，用 ZSCORE 就够了，返回 nil 即不存在。没必要用 ZRANK，多一个数量级的开销。

这也是 ZSet 同时维护 skiplist + dict 两个结构的意义所在——空间换时间，两种查询各走各的最优路径。

## 按key 查询慢怎么优化？（Hash+ZSet方案）
这个问题我理解是：**业务上需要同时支持"按属性过滤"和"按分数排序"，单用 ZSet 不够用**的场景。

**典型痛点**

ZSet 只能按 score 排序，无法按 member 的其他字段过滤。比如"查年龄在 20-30 之间、按积分排名的用户"，ZSet 单独做不了。

**Hash + ZSet 组合方案**

```
Hash: user:{uid} → {name, age, score, ...}  # 存完整对象
ZSet: rank:global → uid (score=积分)         # 存排序关系
ZSet: rank:age:25 → uid (score=积分)         # 按维度分桶
```

查询流程：

1. 从 ZSet 拿到排序后的 uid 列表
2. pipeline/mget 批量从 Hash 取详情
3. 业务层做二次过滤

**关键优化点**

- **pipeline 批量取 Hash**，避免 N 次网络往返，这个收益很明显
- **ZSet 按维度分桶**，比如 `rank:city:beijing`，缩小查询集合
- **写扩散**：写入时同时更新多个 ZSet，读性能换写复杂度

**踩过的坑**

分桶粒度不好把握——桶太细，ZSet 数量爆炸；桶太粗，范围查询结果集还是大。我们当时最终在热门维度上做分桶，长尾维度降级到业务层过滤。

本质上这是**用冗余索引换查询性能**，和 MySQL 加联合索引思路一样。

## 写入时怎么保证Hash和ZSet的原子性？
**几种方案，各有 trade-off**

**1. MULTI/EXEC 事务**

```redis
MULTI
HSET user:123 name "xx" score 100
ZADD rank:global 100 123
EXEC
```

- 保证命令原子执行，不会被其他命令插入
- **但**：不支持回滚，中间某条命令语法错误其他照常执行
- 不是真正的 ACID 事务，更像是"批量提交"

**2. Lua 脚本（推荐）**

```lua
redis.call('HSET', KEYS[1], 'score', ARGV[1])
redis.call('ZADD', KEYS[2], ARGV[1], ARGV[2])
```

- Redis 单线程执行 Lua，天然原子
- 比 MULTI/EXEC 更灵活，可以加条件判断
- 缺点：脚本调试麻烦，出问题难排查

**3. 接受最终一致性**

老实说，很多业务场景其实不需要强原子性。

- 先写 Hash，再写 ZSet，加重试保证最终一致
- 或者引入消息队列，异步补偿
- Hash 和 ZSet 短暂不一致，业务能否容忍？大部分排行榜场景其实可以

**我的判断**

Lua 脚本是最常用的强一致方案，但要评估必要性。如果是积分排行榜这种场景，1秒内最终一致完全够用，别过度设计。

真正的原子性需求更多出现在**扣减库存、资金操作**这类场景，排行榜用 Lua 保险但不一定必须。

## 为什么ZSet不直接设计成Hash+链表?
好问题，直接说核心矛盾：

**链表的致命问题是范围查询**

链表查找第 k 个元素是 O(n)，无法二分。ZSet 最核心的操作是 `ZRANGEBYSCORE`、`ZRANK` 这类**有序范围查询**，链表完全撑不住。

**对比一下**

|操作|链表|跳表|
|---|---|---|
|按 score 范围查|O(n)|O(log n + k)|
|插入|O(1)（知道位置）|O(log n)|
|ZRANK|O(n)|O(log n)|

链表唯一的优势是插入 O(1)，但**前提是你已经知道插入位置**——而维护有序链表的插入位置本身就要 O(n) 查找，优势消失。

**为什么不用数组 + 二分？**

数组二分查找是 O(log n)，但插入需要移动元素，O(n) 的写代价在高频写场景不可接受。

**跳表的本质**

跳表是在链表上加了多级索引，用空间换时间，把链表的 O(n) 查询拉到 O(log n)，同时保留了链表插入灵活的特性。可以理解为**穷人版的平衡树**，但实现复杂度低得多。

所以 Hash + 链表根本无法满足 ZSet 的核心使用场景，这不是优化问题，是结构性缺陷。

## Redis有哪些持久化方式？
**两种：RDB 和 AOF，以及混合模式**

**RDB（快照）**

- 定时 fork 子进程，把内存数据序列化成二进制文件
- 优点：文件紧凑、恢复快、对主进程影响小（COW 机制）
- 缺点：两次快照之间的数据会丢，宕机最多丢几分钟数据

**AOF（追加日志）**

- 每条写命令追加到日志文件，重启时重放
- 三种刷盘策略：`always` / `everysec` / `no`
- `everysec` 是默认，最多丢 1 秒数据，性能和安全的折中
- 缺点：文件比 RDB 大，恢复慢；AOF rewrite 会有短暂 CPU 压力

**混合持久化（Redis 4.0+，推荐）**

AOF rewrite 时，先写 RDB 格式的全量快照，后面追加增量 AOF 日志。恢复速度接近 RDB，数据完整性接近 AOF。

**实践判断**

- 纯缓存场景：RDB 够用，甚至可以关持久化
- 对数据丢失敏感：混合模式
- 我们线上用的混合模式 + `everysec`，重启恢复速度比纯 AOF 快很多，丢数据窗口控制在 1 秒内

有一个坑：RDB fork 时内存占用会瞬间翻倍（COW 最坏情况），内存打满过的，要给系统留足余量。

## 讲一下RDB的bgsave写时复制原理。
**bgsave 流程**

1. 主进程调用 `fork()`，产生子进程
2. 子进程负责将内存数据写入临时 RDB 文件，完成后替换旧文件
3. 主进程继续处理客户端请求，**不阻塞**（fork 本身有短暂阻塞）

**COW 核心原理**

fork 之后，父子进程共享同一份物理内存页，页表都指向同一块物理地址。

```
fork 后：
父进程页表 ──┐
             ├──→ 同一块物理内存页（只读标记）
子进程页表 ──┘

父进程写入时：
OS 触发 COW → 复制该内存页 → 父进程写副本，子进程读原页
```

- **只有被修改的页才会复制**，不是全量复制
- 子进程始终看到 fork 时刻的内存快照，数据一致性有保证

**实际踩过的坑**

写入压力大时，COW 复制的页很多，内存增量可能达到原来的 50%~100%。我们有台机器 Redis 内存用了 12G，bgsave 期间物理内存飙到接近 20G，触发了 OOM killer 把 Redis 干掉了。

后来的应对：

- 给宿主机预留 30%+ 的内存 buffer
- 业务低峰期触发 bgsave，减少写入量，降低 COW 触发频率
- 监控 `latest_fork_usec`，fork 耗时过长就告警

**另一个细节**

fork 本身是同步的，会阻塞主进程。内存越大，fork 耗时越长——我们遇到过 10G 内存的实例 fork 耗时 200ms+，对延迟敏感的业务是个隐患。

## MySQL索引有哪些类型？聚簇索引怎么实现?
**索引类型**

按数据结构：

- **B+ Tree**：主流，InnoDB 默认，支持范围查询
- **Hash**：Memory 引擎，精确查找 O(1)，不支持范围
- **全文索引**：FULLTEXT，针对文本搜索
- **R-Tree**：空间索引，GIS 场景

按逻辑角色：

- 聚簇索引（主键）
- 二级索引（普通、唯一、联合、前缀）

**聚簇索引实现**

InnoDB 里，**数据本身就是按主键组织的 B+ Tree**，叶子节点存的是完整行数据。

```
非叶子节点：[主键值 | 子节点指针]
叶子节点：  [主键值 | 整行数据] ←→ [主键值 | 整行数据]
                                  （双向链表，支持范围扫描）
```

- 一张表只能有一个聚簇索引
- 没有显式主键时，InnoDB 会用第一个非空唯一索引，再没有就隐式生成 rowid

**二级索引的关键细节**

叶子节点存的是**主键值**，不是行数据。查询时需要**回表**——先走二级索引找到主键，再去聚簇索引查完整行。

覆盖索引就是为了避免回表，把查询所需字段都放进联合索引的叶子节点里。

**一个常见坑**

主键建议用自增整数，不要用 UUID。UUID 随机写入会导致 B+ Tree 页频繁分裂，写性能差，且索引文件更大。我们之前有个表用 UUID 做主键，写入 TPS 上来后页分裂严重，改成雪花 ID 之后好多了。

## 为什么用B+树而不是B树？
**核心差异就两点**

**1. 数据只存在叶子节点**

B 树的非叶子节点也存数据，B+ 树非叶子节点只存键值和指针。

直接结果：**B+ 树非叶子节点能放更多键值**，树更矮，同样数据量下 IO 次数更少。

InnoDB 页大小 16KB，B+ 树一个非叶子节点能放几百个键，三层树就能索引千万级数据，通常只需 2~3 次磁盘 IO。

**2. 叶子节点双向链表**

```
叶子层：[10|data] ↔ [20|data] ↔ [30|data] ↔ [40|data]
```

范围查询 `WHERE id BETWEEN 20 AND 40`，找到起始节点后**顺序遍历链表**即可，不需要回到根节点重新查找。

B 树没有这个链表，范围查询要反复中序遍历，IO 跳跃严重。

**总结对比**

||B 树|B+ 树|
|---|---|---|
|非叶子节点|存数据|只存键|
|树高|相对高|更矮|
|范围查询|差|链表顺序扫，好|
|单点查询|理论略快（中间节点可能命中）|必须到叶子|

单点查询 B 树理论上有优势，但概率低，收益不稳定。数据库范围查询是核心场景，B+ 树的链表设计完胜。

这个选择本质上是**为磁盘 IO 模式优化的**——顺序读远比随机跳跃读便宜。

## 树高更少为什么查询更快？数据从哪里读到Buffer Pool?
**树高更矮 → IO 次数更少**

根本原因是**磁盘 IO 是数据库性能瓶颈**，不是 CPU。

B+ 树每个节点对应一个磁盘页（InnoDB 16KB），查找一个节点 = 一次磁盘 IO。树高 3 层最多 3 次 IO，树高 5 层就是 5 次——磁盘随机 IO 延迟在 ms 级，差 2 次 IO 就是几毫秒的差距。

内存访问是 ns 级，所以**节点一旦在 Buffer Pool 里，树高几乎不重要**，IO 才是命门。

**数据怎么进 Buffer Pool**

```
查询请求
  → 检查 Buffer Pool 中是否有该页（page_id 查哈希表）
  → 命中：直接读内存返回
  → 未命中：
      1. 从 Buffer Pool 分配空闲页（没有则 LRU 淘汰）
      2. 读取 .ibd 文件中对应的 16KB 数据页
         （OS 层：文件系统 → page cache → read() 系统调用）
      3. 载入 Buffer Pool，更新页表
      4. 返回数据
```

**几个关键细节**

- Buffer Pool 用**改进版 LRU**，分冷热区（3/8 冷区），防止全表扫描把热数据挤走
- 预读机制：线性预读会提前把相邻页加载进来，范围查询受益明显
- 脏页不立刻写磁盘，由后台线程异步刷盘（WAL 保证崩溃安全）

**一个实际问题**

Buffer Pool 命中率低才是慢查询的真正元凶。我们排查过一个慢查询，explain 看索引都走对了，但响应时间抖动很大——最后发现是 Buffer Pool 太小，热数据频繁被淘汰，每次都要重新从磁盘读。调大 `innodb_buffer_pool_size` 到物理内存 70% 后稳定了。

## B树和B+树还有什么区别？（叶子节点双向链表、范围查询）
这个问题我在上面"为什么用 B+ 树"那个回答里基本都覆盖了，要不你看看还有哪个点没讲清楚？

如果要补充的话，有一个点之前没展开：

**叶子节点链表对写入的影响**

B+ 树叶子层维护双向链表，意味着插入新节点时不只是树结构调整，还要**维护前后指针**。页分裂时需要同时更新相邻页的指针，涉及多个页的修改，这也是为什么大量随机插入会触发频繁页分裂、性能下降的原因之一。

自增主键的优势就在这里——顺序插入只在链表尾部追加，分裂少。

其他区别基本就是上面说的：

- 非叶子节点存不存数据
- 树高和 IO 次数
- 范围查询链表遍历 vs B 树中序遍历

如果有具体想深挖的点可以直接问。

## Java HashMap底层结构？
**JDK 8 之后的结构：数组 + 链表 + 红黑树**

```
table[]
 [0] → null
 [1] → Node{key,value,hash,next} → Node → ...
 [2] → TreeNode（红黑树，链表长度 > 8 时转换）
 ...
 [n]
```

**核心参数**

- 初始容量：16，负载因子：0.75
- 扩容触发：元素数 > 容量 × 0.75，**2 倍扩容**
- 链表 → 红黑树：链表长度 > 8 **且**数组长度 ≥ 64
- 红黑树 → 链表：节点数 < 6

**hash 计算细节**

```java
hash = (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)
```

高 16 位异或低 16 位，让高位参与散列，减少碰撞。数组下标：`hash & (n-1)`，等价于取模但更快。

**为什么阈值是 8？**

链表长度符合泊松分布，长度达到 8 的概率约 0.00000006，正常散列下几乎不会触发红黑树转换。真触发了说明 hashCode 实现有问题或者被恶意攻击（hash 碰撞攻击）。

**扩容的关键优化**

JDK 8 扩容时不重新计算 hash，利用 `hash & oldCap` 判断新位置：结果为 0 留在原位，为 1 移到 `原位 + oldCap`。比 JDK 7 的重 hash 高效很多。

线程安全问题：JDK 7 头插法扩容会形成环形链表死循环，JDK 8 改成尾插法解决了这个问题，但并发写仍然不安全，该用 ConcurrentHashMap 的场景别用 HashMap。

## HashMap什么时候从链表转红黑树？为什么要满足数组长度>64?
**转换条件是两个同时满足**

1. 链表长度 > 8
2. 数组长度 ≥ 64

**为什么要加数组长度 ≥ 64 这个条件？**

核心逻辑：**链表长，不一定是 hash 碰撞严重，可能只是数组太小**。

假设数组长度只有 16，元素落到同一个桶的概率天然就高，链表变长是正常现象。这时候正确的解法是**扩容**，把元素重新散列到更多桶里，而不是转红黑树。

转红黑树的代价：

- TreeNode 占用空间是普通 Node 的 **2 倍**
- 维护红黑树平衡有额外开销

如果数组小就无脑转红黑树，既浪费内存，又没解决根本问题（桶太少）。

**实际触发流程**

```
链表长度超过 8
  → 数组长度 < 64？
      → 触发扩容（resize），重新散列
      → 扩容后链表自然变短，大概率不需要转树
  → 数组长度 ≥ 64？
      → 真正转红黑树（treeifyBin）
```

**所以本质是**

数组长度 ≥ 64 是一个**前置保障**——确认扩容已经无法缓解碰撞问题，链表长是真的 hash 分布差，这时候才值得付出红黑树的空间和维护代价。

实践中如果 key 的 hashCode 实现不好，或者初始容量设太小又不预估扩容，容易触发这个路径。

## 退化回链表的阈值是多少？
**阈值是 6**

红黑树节点数 ≤ 6 时退化回链表。

**为什么不是 8，要留 2 的缓冲？**

防止在阈值附近频繁横跳。如果转换和退化都用 8，元素在 8 附近反复增删，就会不断触发链表 → 红黑树 → 链表的转换，每次都有开销。

6 和 8 之间留了 2 个元素的缓冲区，避免这种抖动。

这个设计思路在很多系统里都能看到，比如 TCP 拥塞控制的阈值也会类似处理——**状态切换有滞后性，是刻意为之**。

## 浏览器输入URL到页面展示全过程？
**网络层**

1. DNS 解析 → 本地缓存 → 递归查询，TTL 设置不合理会有缓存穿透问题
2. TCP 三次握手，HTTPS 还要 TLS 握手（1.2 需 2-RTT，1.3 优化到 1-RTT）
3. 发送 HTTP 请求，服务端处理后返回响应

**浏览器渲染层**

1. 解析 HTML → DOM Tree，解析 CSS → CSSOM
2. 合并成 Render Tree → Layout（计算位置）→ Paint → Composite
3. JS 会阻塞 DOM 解析，所以 `<script>` 推荐放底部或加 `defer`

**几个容易忽略的点**

- `connection: keep-alive` 复用 TCP，减少握手开销；HTTP/2 更进一步，多路复用解决队头阻塞
- 浏览器对同域名有并发连接数限制（HTTP/1.1 一般 6 个），这是雪碧图存在的历史原因
- CDN 会在 DNS 解析阶段介入，把请求调度到最近节点

**从后端视角补充**：nginx 处理请求时，`upstream` 配置的 keepalive 池大小经常被忽视，连接频繁建断在高并发下影响很大，这个我们之前优化过。

## TCP为什么需要三次握手、四次挥手？
**三次握手**

本质是双方互相确认收发能力，最少需要三次：

1. Client → SYN：我要连
2. Server → SYN+ACK：收到，我也要连
3. Client → ACK：收到

两次不够——Server 无法确认自己的发送能力是否正常；四次多余——第2步可以合并。

一个实际问题：**SYN Flood 攻击**就是利用半连接队列打满，服务端一直等第三步。应对方式是开启 `tcp_syncookies`，不占队列直接编码验证。

**四次挥手**

因为 TCP 是全双工，两个方向需要独立关闭：

1. Client → FIN：我不发了
2. Server → ACK：知道了（但我可能还有数据没发完）
3. Server → FIN：我也发完了
4. Client → ACK：好的

2、3 步不能合并——Server 收到 FIN 后可能还有数据在传，要等业务处理完才能发自己的 FIN。

**TIME_WAIT 是个绕不开的坑**

Client 发完最后 ACK 后要等 2MSL，原因是防止对方没收到重传。高并发短连接场景下 TIME_WAIT 堆积会耗尽端口，我们当时的处理方式：

- 开启 `tcp_tw_reuse`（客户端侧）
- 业务层改用连接池复用长连接，治本

## 三次握手除了校验可用性，还能解决什么问题？两次握手
好问题，这个角度很多人没想到。

**三次握手还解决了"历史连接"问题**

考虑这个场景：网络抖动导致一个**旧的 SYN 包**延迟到达 Server，Server 回了 SYN+ACK，如果只有两次握手，连接就建立了——但 Client 压根不知道这个连接，会直接发 RST。

三次握手下 Client 收到这个"意外的" SYN+ACK，可以识别出这是历史连接，发 RST 终止，Server 正常处理。

**两次握手的根本缺陷**

- Server 发完 SYN+ACK 就认为连接建立，单方面进入 ESTABLISHED
- Client 没有机会"确认这是我想要的连接"
- 历史重复报文会导致 Server 建立无效连接，浪费资源

**一句话总结**

三次握手的核心不只是"验证双向通路"，更是让**双方都有机会拒绝历史连接**，保证连接的唯一性和有效性。

TCP 设计在网络不可靠的前提下工作，这个场景是必须考虑的。

## 为什么会有误连？
你问的是"历史 SYN 包导致误连"的根源吧。

**根本原因：网络包没有"过期"机制**

IP 包在网络里可能因为路由绕道、链路拥塞被缓存滞留，TCP 本身无法区分"刚发的包"和"网络里游荡了很久的旧包"。

**具体场景**

```
Client 第一次连接 → SYN 包在网络中滞留
Client 超时重连 → 新连接正常建立、使用、关闭
...一段时间后...
滞留的旧 SYN 包姗姗来迟到达 Server
```

Server 看到这个 SYN，完全合法，无从判断新旧。

**为什么不能在包里加时间戳判断？**

TCP 有 Timestamp 选项，但：

- 它解决的是 RTT 估算和 PAWS（防回绕），不是绝对时间校验
- 网络设备时钟不同步，绝对时间不可信
- ISN（初始序列号）随机化一定程度缓解了问题，但没完全解决

**所以三次握手是在"不可靠网络"前提下的工程折中**——用最少的交互让双方对连接状态达成一致，Client 的第三次 ACK 本质是在说"我确认这是我发起的连接"。

这也是为什么 QUIC 要重新设计连接建立，基于 UDP 自己控制，规避了这些历史包袱。

## TCP建连时还会沟通哪些信息？
## 算法题：求每片岛屿的面积（DFS/BFS遍历网格）
