字节后端一面，65 分钟，全程八股拷打。 结果是面完半小时就过了，HR 直接说我这个状态继续保持，后面大概率没问题。 更意外的是，这次居然是我面过这么多场字节里，体验最好的一次——面试官不压人，但问题一点不水，MySQL、Redis、索引、锁，全是能把人问懵的东西。
(附面经) ----------------------------------------------------------------- 
1. 自我介绍 
2. 问学生经历、未来规划。（后续全程都是直接八股 
## 3. 可重复读部分解决幻读，如何理解？结合具体场景 
**先区分两种读**

- **快照读**：普通 `SELECT`，基于 MVCC 读历史版本
- **当前读**：`SELECT FOR UPDATE`、`UPDATE`、`DELETE`，读最新已提交数据并加锁

RR 级别下，**纯快照读不会幻读**，MVCC 保证事务内始终读同一个版本快照。

**但混用时会出问题**

```sql
-- 事务A
SELECT * FROM orders WHERE amount > 1000;  -- 快照读，返回 0 条

-- 事务B（此时插入并提交）
INSERT INTO orders(amount) VALUES(2000);

-- 事务A
UPDATE orders SET status=1 WHERE amount > 1000;  -- 当前读，影响 1 行！
SELECT * FROM orders WHERE amount > 1000;         -- 能看到刚才被UPDATE的行了
```

事务 A 前后两次查询结果不一致，幻读出现了。

**根本原因**

UPDATE 是当前读，看到了事务 B 新插入的行，并且修改了它。这一修改让该行的 `trx_id` 变成了事务 A 自己的，后续快照读就能看见它了——MVCC 的可见性规则导致的。

**InnoDB 的补救：Next-Key Lock**

当前读时加 Gap Lock，锁住范围间隙，阻止其他事务插入。

```sql
SELECT * FROM orders WHERE amount > 1000 FOR UPDATE;
-- 锁住 (1000, +∞) 的间隙，事务B的INSERT会被阻塞
```

所以说"部分解决"：

- 纯快照读 → MVCC 完全解决
- 当前读 → Next-Key Lock 解决，但有死锁风险、并发度下降
- **混用快照读和当前读 → 可能幻读**，这是 RR 没覆盖到的盲区

## 4. 事务a查没发现记录，b插入记录并提交，a查询加入写锁，能查到b的记录吗？ 
**能查到。**

加写锁（`SELECT FOR UPDATE`）是**当前读**，直接读最新已提交数据，绕过 MVCC 快照，所以能看到事务 B 插入并提交的记录。

这正是上面说的幻读场景：

```sql
-- 事务A（RR级别）
SELECT * FROM t WHERE id = 5;            -- 快照读，不存在
-- 事务B：INSERT id=5 并提交
SELECT * FROM t WHERE id = 5 FOR UPDATE; -- 当前读，能查到！
```

前后两次查询结果不一致，幻读发生。

**如果想阻止这种情况**

第一次查询就用 `FOR UPDATE`：

```sql
SELECT * FROM t WHERE id = 5 FOR UPDATE; -- 加 Gap Lock
```

此时 InnoDB 对 id=5 这个不存在的记录加 **Gap Lock**，事务 B 的 INSERT 会被阻塞，直到事务 A 提交。

**本质**

> 当前读不走 MVCC，看的是实时数据；快照读走 MVCC，看的是事务开始时的版本。两者混用就会出现"看见了本不该看见的数据"的幻读。

## 5. 假设一个MySQL InnoDB表，有个三个字段A B C建立了联合索引(A, B, C)，当执行SQL的WHERE B >10 AND A = 1 AND C <20时，这个索引生效了哪几个字段？ 
**A 和 B 生效，C 不生效。**

**原因**

联合索引遵循**最左前缀原则**，MySQL 优化器会自动调整条件顺序，实际执行顺序是：

```
A = 1（等值）→ B > 10（范围）→ C < 20（失效）
```

- `A = 1`：等值匹配，索引有效
- `B > 10`：范围查询，索引有效，但**到此为止**
- `C < 20`：B 是范围条件后，索引的有序性已经不能保证 C 的连续性，C 无法走索引

**底层原因**

联合索引存储上按 (A, B, C) 依次排序。A 确定后 B 有序，B 确定后 C 才有序。B 是范围，B 的值不固定，C 的顺序就乱了，没法用索引跳跃查找。

**补充：ICP 优化**

虽然 C 不参与索引范围扫描，但 MySQL 5.6+ 有 **Index Condition Pushdown**——C < 20 的过滤会下推到存储引擎层，在回表前用索引数据直接过滤掉不满足 C 的行，减少回表次数。

严格说 C 没有用于**索引查找**，但用于了**索引过滤**，`EXPLAIN` 里会看到 `Using index condition`。

## 6. 上述语句执行的时候，Server层和存储引擎层分别做了哪些查询动作？ 
**存储引擎层**

1. 通过索引 (A, B) 定位满足 `A=1 AND B>10` 的索引范围，找到起始位置
2. 沿叶子节点顺序扫描，**ICP 在这里生效**：每条索引记录直接判断 `C<20`，不满足直接跳过，不回表
3. 满足条件的记录根据主键**回表**，拿完整行数据返回给 Server 层

**Server 层**

1. 收到存储引擎返回的行数据
2. 理论上还会再过滤一次 WHERE 条件，但 ICP 已经在引擎层把 C 过滤了，这里基本是空转
3. 做投影（SELECT 指定字段）、排序、聚合等后续操作
4. 返回结果集给客户端

**整体流程**

```
Server层 发起查询
    ↓
存储引擎层 索引扫描(A+B) → ICP过滤C → 回表拿完整行
    ↓
Server层 接收行数据 → 最终过滤 → 返回
```

**关键点**

没有 ICP 时：引擎层扫描满足 A+B 的所有记录全部回表，回表后 Server 层再过滤 C，**回表次数多**。

有 ICP 时：C 的过滤提前到引擎层，用索引数据判断，**减少了不必要的回表**，IO 次数下降明显。

`EXPLAIN` 里 `Using index condition` 就是 ICP 生效的标志，`Using where` 说明 Server 层还有额外过滤。

## 7. Redis集群中 锁在故障切换时可靠吗？为什么？ 
**不可靠，这是 Redis 分布式锁的经典缺陷。**

**故障场景**

```
1. 客户端A在Master获取锁成功
2. Master还没来得及同步锁数据到Slave，Master宕机
3. Slave晋升为新Master
4. 客户端B在新Master上获取同一把锁，成功
5. 此时A和B同时持有锁，互斥失效
```

根本原因：**主从复制是异步的**，锁数据可能丢失。

**Redis官方方案：Redlock**

在 N 个（通常5个）**独立** Redis 节点上加锁：

```
1. 记录开始时间
2. 依次向5个节点请求加锁
3. 超过半数（3个）成功，且总耗时 < 锁过期时间，则认为加锁成功
4. 否则向所有节点释放锁
```

即使一个节点故障，只要多数节点正常，锁依然有效。

**但 Redlock 也有争议**

Martin Kleppmann 指出：

- 时钟漂移可能导致锁提前过期，业务还在执行时锁已失效
- GC Stop-The-World 期间锁可能已过期，进程恢复后继续执行，产生并发问题

**工程上怎么选**

- 对一致性要求不是极高：单 Redis + 合理超时 + 业务幂等兜底，够用
- 强一致要求：上 ZooKeeper 或 etcd，基于 CP 系统的分布式锁更可靠
- Redlock 理论上更强，但运维5个独立节点成本高，实际用的不多

我们生产上大多数场景用单 Redis 锁，核心交易用 ZooKeeper。

## 8. 软连接是什么？ 
**软链接（Symbolic Link）**

类似 Windows 的快捷方式，本质是一个特殊文件，**存储的是目标文件的路径字符串**。

```bash
ln -s /path/to/target link_name
```

**和硬链接的区别**

||软链接|硬链接|
|---|---|---|
|本质|存路径字符串|指向同一个 inode|
|跨文件系统|支持|不支持|
|目标删除后|变成悬空链接|文件依然可访问|
|可链接目录|支持|不支持|
|inode|独立的 inode|共享 inode|

**工程中常见用途**

- 多版本切换：`/usr/local/go -> /usr/local/go1.21`，升级只需改软链
- 配置文件统一管理：把配置软链到统一目录
- 部署时不停服切换：`current -> release_20240501`，新版本准备好直接改软链

**一个细节**

软链接失效（目标被删）时，`ls -la` 会显示红色或提示 broken，`stat` 命令能看到软链接本身的 inode 和目标路径。

```bash
ls -la link_name
# lrwxrwxrwx  link_name -> /path/to/target
```

权限位开头的 `l` 就是软链接的标志。

## 9. 为什么建立软连接之后，命令行里面仍不能直接输入命令运行？ 
因为**命令能否直接运行，取决于它是否在 `PATH` 里**，软链接本身不解决这个问题。

**具体原因**

Shell 执行命令时，按 `PATH` 环境变量里的目录顺序逐个查找可执行文件：

```bash
echo $PATH
# /usr/local/bin:/usr/bin:/bin:/usr/local/go/bin ...
```

软链接建在哪里很关键：

```bash
# 建在 PATH 目录内 → 可以直接运行
ln -s /usr/local/go1.21/bin/go /usr/local/bin/go  ✓

# 建在随意目录 → 不在 PATH，还是找不到
ln -s /usr/local/go1.21/bin/go ~/mylinks/go  ✗
```

**解决办法**

两种方式二选一：

```bash
# 方式1：软链建到 PATH 已有目录
ln -s /path/to/binary /usr/local/bin/mybin

# 方式2：把软链所在目录加入 PATH
export PATH=$PATH:/path/to/linkdir
# 写入 ~/.bashrc 或 ~/.zshrc 永久生效
```

**工程上常见做法**

版本管理工具（nvm、gvm、pyenv）本质上就是在做这件事——安装完把对应版本的 bin 目录软链或写入 PATH，所以装完要 `source ~/.bashrc` 才生效。

## 10. 计算机的内存分配是怎样的？使用算法的时候运行内存要耗费多少？ 
**进程内存布局**

从低地址到高地址：

```
┌─────────────┐ 高地址
│    栈 Stack  │ ← 函数调用帧、局部变量，向下增长
├─────────────┤
│      ↓      │
│    （空闲）  │
│      ↑      │
├─────────────┤
│   堆 Heap   │ ← 动态分配，malloc/new，向上增长
├─────────────┤
│  BSS 段     │ ← 未初始化全局变量
├─────────────┤
│  Data 段    │ ← 已初始化全局变量、静态变量
├─────────────┤
│  Text 段    │ ← 代码（只读）
└─────────────┘ 低地址
```

**算法运行内存怎么算**

就是分析**额外空间复杂度**，几个常见场景：

|场景|空间复杂度|举例|
|---|---|---|
|固定变量|O(1)|双指针、原地排序|
|线性额外空间|O(n)|HashMap、辅助数组|
|递归调用栈|O(h)|树的 DFS，h 为深度|
|完全二叉树递归|O(log n)|栈帧最多 log n 层|
|链表递归|O(n)|退化时栈帧 n 层|

**实际刷题中容易忽略的**

递归的栈空间经常被忽略：

```go
// 看起来没用额外空间，但递归栈是 O(n)
func sum(node *TreeNode) int {
    if node == nil { return 0 }
    return node.Val + sum(node.Left) + sum(node.Right)
}
```

面试时说空间 O(1) 却用了递归，会被追问。

Go 里 goroutine 初始栈 2~8KB，会动态扩容，深递归要注意栈溢出风险，生产上遇到过。

## 11. 消息队列与阻塞队列的区别？ 
**核心区别一句话**

阻塞队列是**进程内**的数据结构，消息队列是**跨进程/跨服务**的中间件。

**阻塞队列**

- 内存级别，同一进程内线程间通信
- 队列满时 put 阻塞，队列空时 take 阻塞
- 典型：Java 的 `ArrayBlockingQueue`、Go 的 `channel`
- 无持久化，进程挂了数据就没了

```go
ch := make(chan int, 100)  // Go 的 buffered channel 就是阻塞队列
ch <- msg   // 满了阻塞
<-ch        // 空了阻塞
```

**消息队列**

- 网络级别，跨进程、跨服务、跨机器
- 消息持久化，服务重启不丢数据
- 支持多消费者、消费确认、重试、死信队列
- 典型：Kafka、RocketMQ、RabbitMQ

**对比**

||阻塞队列|消息队列|
|---|---|---|
|范围|进程内|跨服务|
|持久化|无|有|
|可靠性|进程崩溃丢失|ACK机制保证|
|吞吐|极高（内存操作）|相对低（网络IO）|
|运维成本|无|需要维护中间件|

**联系**

消息队列内部实现往往用到阻塞队列——比如 Kafka consumer 拉到消息后先放本地 `BlockingQueue`，业务线程再消费，做了一层缓冲解耦。

线程池的任务队列也是阻塞队列，本质和消息队列思路一样，只是作用域不同。

## 12. 当注册中心不可用时，客户端如何合理地从本地缓存中选择服务实例，保证可用性和正确性？
这是注册中心高可用的经典问题，核心矛盾是：**缓存可能过期，但总比无法服务强**。

**本地缓存策略**

注册中心正常时，客户端拉取服务列表**持久化到本地**（内存 + 磁盘），不只放内存：

```
~/.cache/service_discovery/user-service.json
```

注册中心挂了，直接读本地文件兜底，重启也能恢复。

**实例选择的可用性保障**

缓存里的实例未必都活着，需要客户端侧自己做健康判断：

- **主动探活**：客户端定期对缓存实例做心跳检测，标记不可达的实例
- **被动摘除**：调用失败（连接超时、错误率超阈值）触发熔断，临时从候选列表移除
- **重试切换**：一个实例失败，自动换下一个，结合负载均衡策略

**正确性保障**

缓存可能过期，几个处理原则：

- 优先用**最近一次成功**调用过的实例，存活概率更高
- 对缓存数据打**时间戳**，超过阈值（比如 5 分钟）降级告警，超过更长时间（比如 30 分钟）可以拒绝服务而不是盲目调用
- 新实例注册中心挂了不会出现在缓存里，这段时间流量无法路由过去——这个是无解的，只能接受

**工程实践**

Nacos、Eureka 客户端都内置了这个机制：

- Eureka 叫 **Self-Preservation Mode**，注册中心异常时保留全量缓存不清理
- Nacos 客户端本地有 `failover` 文件，手动指定兜底实例列表，极端情况人工干预

本质思路：**AP 优先，宁可用旧数据提供有损服务，也不直接返回不可用**——具体阈值根据业务容忍度调整。