数据库与缓存 — 小白讲解 + 面试题精解

数据库与缓存 — 小白讲解 + 面试题精解

定位:假设你只会写 SQL CRUD,所有概念从零讲起,配代码示例 + 面试题。 覆盖:MySQL → Redis → Oracle/GaussDB 迁移 → PostgreSQL+pgvector → TDengine → Doris 六大模块。 用法:先读讲解理解概念 → 跑代码验证 → 再看面试题自测。 特色:每个知识点都结合简历真实项目案例,面试时能直接用。


目录


第一章:MySQL 深入(面试 100% 会问)

简历最核心的亮点就是 SQL 性能调优,面试官会拿执行计划问你。 简历直接写了:最左前缀原则、@BatchSize 解决 N+1、核心接口秒级→毫秒级、分库分表+冷热分离+历史归档。


1.1 InnoDB 存储引擎

小白讲解

一句话:InnoDB 是 MySQL 5.5+ 的默认存储引擎,支持事务、行锁、外键,适合高并发读写场景。

InnoDB 的核心特性:
  1. 事务支持(ACID): redo log 保证持久性,undo log 保证原子性和 MVCC
  2. 行级锁:只在修改的行上加锁,不阻塞其他行(MyISAM 是表锁)
  3. 聚簇索引:数据和主键索引存在一起(B+ 树的叶子节点就是数据行)
  4. 外键约束:保证引用完整性
  5. 崩溃恢复:redo log 实现 crash-safe

InnoDB vs MyISAM

对比项 InnoDB MyISAM
事务 支持 不支持
锁粒度 行锁(支持并发写) 表锁(写时全表锁)
索引类型 聚簇索引 非聚簇索引
外键 支持 不支持
崩溃恢复 支持(redo log) 不支持
全文索引 5.6+ 支持 支持
适用场景 高并发读写 只读/统计(已基本淘汰)

面试题

Q1:InnoDB 和 MyISAM 的区别?(高频 ⭐⭐⭐⭐)

答:核心区别三点:

  1. 事务:InnoDB 支持事务,MyISAM 不支持
  2. :InnoDB 行锁(高并发写性能好),MyISAM 表锁(写时全表阻塞)
  3. 索引:InnoDB 聚簇索引(数据存叶子节点),MyISAM 非聚簇(数据和索引分离)
  4. MySQL 5.5 后默认 InnoDB,MyISAM 基本不用了

Q2:InnoDB 为什么用聚簇索引?(中频 ⭐⭐⭐)

答:

  • 聚簇索引:B+ 树叶子节点直接存储数据行,主键查询只需一次 IO 就能拿到数据
  • 非聚簇索引:叶子节点存储主键值,需要回表到聚簇索引查数据
  • 好处:主键查询极快;坏处:二级索引需要回表,数据移动代价大(更新主键时整行数据要移动)

1.2 B+ 树索引原理

小白讲解

一句话:MySQL InnoDB 的索引使用 B+ 树,就像一本带目录的书——目录告诉你内容在第几页。

B+ 树的结构:

         [非叶子节点:只存索引键,不存数据]
              ┌──────┬──────┐
              │  10  │  20  │
              └──┬───┴──┬───┘
         ┌──────┘      └──────┐
    ┌────┴────┐        ┌────┴────┐
    │ 5 │ 10  │        │ 15│ 20  │   ← 叶子节点之间有指针连接
    └─┬─┴──┬──┘        └─┬─┴──┬──┘
     ↓     ↓              ↓     ↓
    数据   数据          数据   数据

关键特性:
  1. 非叶子节点只存键值,不存数据 → 一个节点能存更多键 → 树更矮 → IO 次数少
  2. 叶子节点存全部数据,且通过双向链表连接 → 范围查询极快
  3. 树的高度通常 3~4 层 → 查找任意数据只需 3~4 次磁盘 IO

B+ 树 vs B 树

对比项 B 树 B+ 树
非叶子节点 存键 + 数据 只存键
叶子节点 不存储全部数据 存储全部数据
范围查询 需要中序遍历整棵树 叶子节点链表,顺序扫描
树高度 较高 较低(非叶子节点能存更多键)
MySQL 用哪个 不用 InnoDB 索引使用 B+ 树

为什么不用红黑树 / 二叉树?

假设有 1000 万条数据:
  B+ 树(每个节点存 100~200 个键):树高 3~4 层 → 3~4 次磁盘 IO
  红黑树:树高约 24 层(log2(10000000) ≈ 23.25)→ 24 次磁盘 IO

磁盘 IO 是最慢的操作(比内存慢 10 万倍)
所以树越矮越好 → B+ 树完胜

聚簇索引 vs 二级索引

聚簇索引(Clustered Index):
  - 数据按主键顺序存储在 B+ 树叶子节点
  - 一张表只有一个聚簇索引(主键)
  - 主键查询:一次 IO 拿到数据

二级索引(Secondary Index / 非聚簇索引):
  - 叶子节点存的是「索引列值 + 主键值」
  - 需要回表:先查二级索引拿到主键 → 再去聚簇索引查数据
  - 一张表可以有多个二级索引

回表(Bookmark Lookup):
  SELECT * FROM instruction WHERE status = 'APPROVED';
  → status 列有二级索引
  → 先查 status 索引树,拿到主键 ID 列表
  → 再用 ID 去聚簇索引查完整数据行
  → 这就是"回表",多一次 IO

覆盖索引(Covering Index):
  SELECT id, status FROM instruction WHERE status = 'APPROVED';
  → 二级索引的叶子节点已经有 id 和 status
  → 不需要回表!直接返回
  → 这就是"覆盖索引",性能最优

代码验证

-- 创建表和索引
CREATE TABLE instruction (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    instruction_no VARCHAR(32) NOT NULL,
    biz_date DATE NOT NULL,
    status VARCHAR(20) NOT NULL,
    amount DECIMAL(18,2) NOT NULL,
    custodian_id BIGINT,
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    -- 简历中的复合索引:遵循最左前缀原则
    INDEX idx_bizdate_status (biz_date, status),
    INDEX idx_status (status),
    INDEX idx_instruction_no (instruction_no)
);

-- 覆盖索引演示:只查 status 和 id(主键),不用回表
EXPLAIN SELECT id, status FROM instruction WHERE status = 'APPROVED';
-- Extra 列显示 "Using index" → 覆盖索引,不回表

-- 需要回表的查询
EXPLAIN SELECT * FROM instruction WHERE status = 'APPROVED';
-- Extra 列没有 "Using index" → 需要回表

面试题

Q1:为什么 MySQL 用 B+ 树而不是 B 树或红黑树?(超高频 ⭐⭐⭐⭐⭐)

答:

  1. B+ 树比 B 树矮:非叶子节点不存数据,一个节点能存更多键,树更矮,IO 更少
  2. B+ 树范围查询快:叶子节点双向链表连接,范围查询只需顺序扫描叶子节点;B 树要中序遍历
  3. 比红黑树矮得多:1000 万数据,B+ 树 3~4 层,红黑树 24 层。磁盘 IO 是瓶颈,树越矮 IO 越少

Q2:什么是聚簇索引和回表?(高频 ⭐⭐⭐⭐⭐)

答:

  • 聚簇索引:数据按主键顺序存储在 B+ 树叶子节点,一张表只有一个
  • 二级索引:叶子节点存索引列值 + 主键值,查询非索引列需要回表
  • 回表:通过二级索引拿到主键 → 再去聚簇索引查完整数据行,多一次 IO
  • 覆盖索引:查询的列都在二级索引中,不需要回表,性能最优

Q3:什么是覆盖索引?有什么优势?(高频 ⭐⭐⭐⭐)

答:

  • 覆盖索引:查询的列全部包含在索引中,不需要回表
  • 优势:减少 IO 次数(不用回表查聚簇索引),是 SQL 调优的首选手段
  • 实际经验:简历项目一中 SELECT id, status FROM instruction WHERE status = 'APPROVED',status 索引覆盖了查询列,直接 Using index

1.3 最左前缀原则(简历直接写了)

小白讲解

一句话:联合索引 (a, b, c) 就像电话簿按“姓+名+手机号”排序,你必须先提供姓才能查找。

联合索引 (biz_date, status, custodian_id):

B+ 树按 biz_date → status → custodian_id 的顺序排列:

biz_date=2026-01-01, status=APPROVED, custodian_id=1
biz_date=2026-01-01, status=APPROVED, custodian_id=2
biz_date=2026-01-01, status=PENDING,   custodian_id=1
biz_date=2026-01-02, status=APPROVED, custodian_id=1
...

能走索引的查询:
  WHERE biz_date = '2026-01-01'                              ✅ 用到 a
  WHERE biz_date = '2026-01-01' AND status = 'APPROVED'      ✅ 用到 a, b
  WHERE biz_date = '2026-01-01' AND status = 'APPROVED' 
    AND custodian_id = 1                                     ✅ 用到 a, b, c
  WHERE biz_date = '2026-01-01' AND custodian_id = 1         ⚠️ 只用到 a(跳过 b,c 不能用索引)

不能走索引的查询:
  WHERE status = 'APPROVED'                                  ❌ 跳过 a
  WHERE custodian_id = 1                                     ❌ 跳过 a, b
  WHERE status = 'APPROVED' AND custodian_id = 1             ❌ 跳过 a

范围查询会导致后面的列失效

-- biz_date 用了范围(BETWEEN),后面的 status 和 custodian_id 不能走索引
WHERE biz_date BETWEEN '2026-01-01' AND '2026-01-31' AND status = 'APPROVED'
-- 只有 biz_date 走索引,status 需要在索引内回表过滤

-- 改为等值查询则全部走索引
WHERE biz_date = '2026-01-01' AND status = 'APPROVED'

结合简历案例

简历原文:
  "遵循最左前缀原则重构复合索引,核心接口响应时间从秒级优化至毫秒级"

面试话术:
  "项目一的指令查询接口原来慢,我分析发现开发人员建了单列索引 idx_bizdate、idx_status,
   但查询条件是 biz_date + status + custodian_id 三个字段。
   我重构为联合索引 idx_bizdate_status_custodian,遵循最左前缀原则,
   把高频查询字段放前面,让查询走覆盖索引不用回表。
   同时把多余的冗余单列索引 idx_bizdate 删掉(联合索引的最左列已经覆盖),
   减少索引维护开销。最终核心接口从秒级降到毫秒级。"

代码验证

-- 建表
CREATE TABLE instruction (
    id BIGINT PRIMARY KEY,
    biz_date DATE NOT NULL,
    status VARCHAR(20),
    custodian_id BIGINT,
    amount DECIMAL(18,2),
    INDEX idx_biz_status_cust (biz_date, status, custodian_id)
);

-- 验证最左前缀:走索引
EXPLAIN SELECT * FROM instruction 
WHERE biz_date = '2026-01-01' AND status = 'APPROVED' AND custodian_id = 1;
-- type: ref,key: idx_biz_status_cust ✅

-- 跳过中间列:只有 biz_date 走索引
EXPLAIN SELECT * FROM instruction 
WHERE biz_date = '2026-01-01' AND custodian_id = 1;
-- key: idx_biz_status_cust 但只用到 biz_date 列 ⚠️

-- 跳过第一列:不走索引,全表扫描
EXPLAIN SELECT * FROM instruction 
WHERE status = 'APPROVED' AND custodian_id = 1;
-- type: ALL,key: NULL ❌

面试题

Q1:什么是最左前缀原则?(超高频 ⭐⭐⭐⭐⭐)

简历直接写了,必须会答!

答:联合索引 (a, b, c) 的 B+ 树按 a→b→c 的顺序排序。查询时必须从最左列开始使用索引:

  • WHERE a = 1 AND b = 2 AND c = 3 → 全部走索引
  • WHERE a = 1 AND b = 2 → a, b 走索引
  • WHERE a = 1 → a 走索引
  • WHERE b = 2 AND c = 3 → 不走索引(跳过了 a)
  • WHERE a = 1 AND c = 3 → 只有 a 走索引(跳过了 b,c 不走索引)
  • 范围查询(>, <, BETWEEN, LIKE)后面的列不走索引

Q2:联合索引 (a, b, c),WHERE a = 1 AND c = 3 能走索引吗?(高频 ⭐⭐⭐⭐)

答:部分走索引。只有 a 走索引,c 不能走索引。因为 B+ 树先按 a 排序,a 相同再按 b 排序,b 相同再按 c 排序。跳过 b 后,c 在索引中不是全局有序的,所以不能走索引。MySQL 5.6+ 的 Index Condition Pushdown(ICP)优化会在索引内过滤 c,但不是“走索引”。

Q3:联合索引的字段顺序怎么设计?(高频 ⭐⭐⭐⭐)

答:三个原则:

  1. 等值查询放前面,范围查询放后面(范围查询会截断后面的索引列)
  2. 区分度高的放前面(过滤更多数据,减少回表)
  3. 高频查询字段放前面(覆盖更多查询场景)

简历案例:idx(biz_date, status, custodian_id),biz_date 区分度最高(每天都有数据)且是等值查询,放最前面。

Q4:有了联合索引 (a, b, c),还需要单列索引 (a) 吗?(中频 ⭐⭐⭐)

答:不需要。联合索引的最左前缀已经覆盖了 WHERE a = ? 的查询。多余的索引会增加写入开销(每次 insert/update 都要维护所有索引)。


1.4 EXPLAIN 执行计划

小白讲解

一句话:EXPLAIN 是 MySQL 的“X光片”,告诉你 SQL 到底怎么执行的,是调优的第一步。

EXPLAIN SELECT * FROM instruction WHERE status = 'APPROVED';
EXPLAIN 输出的重要列:

┌──┬──────────┬─────────────────────┬──────────────────────────────────┐
│列│ 名称      │ 含义                 │ 关注点                           │
├──┼──────────┼─────────────────────┼──────────────────────────────────┤
│ 1│ id       │ 查询序号             │ 数字越大越先执行                  │
│ 2│ select_type│ 查询类型           │ SIMPLE/PRIMARY/SUBQUERY 等       │
│ 3│ table    │ 表名                 │                                  │
│ 4│ type     │ 访问类型             │ ★ 最重要!见下方                 │
│ 5│ possible_keys│ 可能用的索引     │ 哪些索引可用                     │
│ 6│ key      │ 实际用的索引         │ NULL = 没走索引                  │
│ 7│ key_len  │ 索引使用长度         │ 判断联合索引用了几列             │
│ 8│ ref      │ 索引比较的列         │ const/列名                       │
│ 9│ rows     │ 预估扫描行数         │ ★ 越小越好                       │
│10│ Extra    │ 额外信息             │ ★ 见下方                         │
└──┴──────────┴─────────────────────┴──────────────────────────────────┘

type 列(从好到差)

system  > const  > eq_ref  > ref  > range  > index  > ALL
 │         │        │        │       │        │       │
 │         │        │        │       │        │       └─ 全表扫描(最差,必须优化)
 │         │        │        │       │        └─ 扫描整个索引树
 │         │        │        │       └─ 范围扫描(BETWEEN, >, <, IN)
 │         │        │        └─ 非唯一索引等值查询
 │         │        └─ 唯一索引等值连接(JOIN 时用)
 │         └─ 主键或唯一索引等值查询
 └─ 表只有一行

面试红线:
  ALL(全表扫描)→ 必须加索引优化
  index(全索引扫描)→ 可能是索引设计有问题
  range 及以上 → 可以接受
  ref 及以上 → 良好
  const → 最优

Extra 列重点关注

含义 好坏
Using index 覆盖索引,不回表 ✅ 最好
Using where 在存储引擎返回后用 WHERE 过滤 一般
Using index condition ICP 优化,索引内过滤 ✅ 较好
Using filesort 额外排序(没有走索引排序) ❌ 需优化
Using temporary 使用临时表 ❌ 需优化
Using join buffer JOIN 没有用索引 ❌ 需优化

结合简历案例

-- 简历案例:核心接口响应从秒级→毫秒级
-- 优化前:全表扫描
EXPLAIN SELECT * FROM instruction WHERE biz_date = '2026-01-01' AND status = 'APPROVED';
-- type: ALL,key: NULL,rows: 10000000 → 全表扫描秒级

-- 优化后:联合索引 + 覆盖索引
EXPLAIN SELECT id, instruction_no, status FROM instruction 
WHERE biz_date = '2026-01-01' AND status = 'APPROVED';
-- type: ref,key: idx_biz_status_cust,rows: 50,Extra: Using index → 毫秒级

面试题

Q1:EXPLAIN 中哪些列最重要?(高频 ⭐⭐⭐⭐)

答:重点关注三列:

  1. type:访问类型,ALL 表示全表扫描必须优化,ref 及以上可以接受
  2. key:实际使用的索引,NULL 表示没走索引
  3. Extra:Using index 表示覆盖索引(最好),Using filesort 或 Using temporary 表示需要优化

Q2:type 为 ALL 怎么优化?(高频 ⭐⭐⭐⭐)

答:

  1. 检查 WHERE 条件是否有索引覆盖
  2. 如果没有索引,创建合适的索引(联合索引遵循最左前缀原则)
  3. 如果有索引但没走,检查是否违反最左前缀、是否有函数操作、是否有隐式类型转换
  4. 尽量用覆盖索引避免回表

Q3:Using filesort 是什么?怎么优化?(中频 ⭐⭐⭐)

答:MySQL 在排序时无法利用索引的有序性,需要额外排序。优化方法:

  1. 给 ORDER BY 的列加索引
  2. 让 WHERE 和 ORDER BY 使用同一个联合索引
  3. 减少 SELECT 的列数,尽量覆盖索引

1.5 MVCC 多版本并发控制

小白讲解

一句话:MVCC 让读写不互相阻塞——读操作读的是历史版本(快照),写操作创建新版本,互不干扰。

没有 MVCC 的问题:
  线程 A 在修改一行数据(加了行锁)
  线程 B 想读这行数据 → 被阻塞,等 A 释放锁 → 读写互相阻塞

有了 MVCC:
  线程 A 修改一行 → 创建新版本,旧版本保留在 undo log 中
  线程 B 读这行 → 读旧版本(快照),不需要加锁 → 读写不阻塞

MVCC 的三个组件

1. 隐藏列(每行数据都有):
   - DB_TRX_ID:最后修改这行的事务 ID
   - DB_ROLL_PTR:指向 undo log 中上一个版本的指针

2. Undo Log(回滚日志):
   存储数据的历史版本,形成版本链

   当前数据(事务5修改) → undo log(v4) → undo log(v3) → undo log(v2) → undo log(v1)
   
   每个版本记录事务ID和旧数据

3. Read View(读视图):
   事务在读取数据时,生成一个"快照",记录当时哪些事务活跃

   Read View 包含:
   - m_ids:当前活跃(未提交)的事务 ID 列表
   - min_trx_id:m_ids 中最小的
   - max_trx_id:下一个要分配的事务 ID
   - creator_trx_id:当前事务自己的 ID

可见性判断规则

对于一行数据的某个版本,事务能看到它吗?

1. 版本的 trx_id == creator_trx_id → 自己修改的,可见 ✅
2. 版本的 trx_id < min_trx_id → 修改它的事务已提交,可见 ✅
3. 版本的 trx_id >= max_trx_id → 修改它的事务在 Read View 生成后才开始,不可见 ❌
4. 版本的 trx_id 在 m_ids 中 → 修改它的事务还未提交,不可见 ❌
   版本的 trx_id 不在 m_ids 中 → 修改它的事务已提交,可见 ✅

如果当前版本不可见 → 沿着 DB_ROLL_PTR 找上一个版本,继续判断
直到找到一个可见的版本,或者版本链到底

结合简历案例

简历技能清单写了"MVCC",面试官会问"你在项目中怎么用到 MVCC 的"

面试话术:
  "项目一的指令管理系统中,清算模块需要7×24小时运行。
   日终清算批量处理大量指令时(写操作),前端还要实时查询指令状态(读操作)。
   如果用表锁或行锁,读会被写阻塞。
   InnoDB 的 MVCC 机制让读操作读快照版本,不加锁不阻塞写操作,
   实现了读写并发。我们用的默认隔离级别 RR(可重复读),
   一个事务内多次读同一行结果一致,靠的就是 MVCC 的 Read View。"

面试题

Q1:什么是 MVCC?解决了什么问题?(高频 ⭐⭐⭐⭐⭐)

答:

  • MVCC(Multi-Version Concurrency Control)通过保存数据的历史版本,让读操作读快照不加锁,写操作创建新版本
  • 解决的问题:读写互相阻塞。没有 MVCC 时,读需要等写释放锁;有了 MVCC,读读不阻塞、读写不阻塞、只有写写才阻塞
  • 三个组件:隐藏列(DB_TRX_ID + DB_ROLL_PTR)、Undo Log(版本链)、Read View(可见性判断)

Q2:MVCC 在 RC 和 RR 下的区别?(高频 ⭐⭐⭐⭐⭐)

答:

对比项 RC(读已提交) RR(可重复读)
Read View 生成时机 每次 SELECT 都生成新的 事务内第一次 SELECT 时生成,后续复用
效果 可能读到别人提交的新数据(不可重复读) 事务内多次读结果一致
幻读 有幻读 基本解决(MVCC + Gap Lock)

Q3:MVCC 能完全解决幻读吗?(中频 ⭐⭐⭐⭐)

答:在 RR 隔离级别下基本解决,但不是完全。

  • 快照读(普通 SELECT):通过 MVCC 读快照,不会幻读
  • 当前读(SELECT … FOR UPDATE / UPDATE / DELETE):需要加 Gap Lock(间隙锁)或 Next-Key Lock 防止幻读
  • 漏洞场景:事务 A 先快照读 → 事务 B 插入新行并提交 → 事务 A 当前读 → 看到了新行 → 幻读

1.6 事务隔离级别

小白讲解

四个隔离级别(从低到高):

1. 读未提交(Read Uncommitted)
   → 能读到别人还没提交的数据(脏读)
   → 几乎不用

2. 读已提交(Read Committed,RC)
   → 只能读到别人已提交的数据
   → 但同一事务内两次读可能不同(不可重复读)
   → Oracle 默认这个

3. 可重复读(Repeatable Read,RR)
   → 同一事务内多次读同一行结果一致
   → InnoDB 默认这个
   → 配合 MVCC + Gap Lock 基本解决幻读

4. 串行化(Serializable)
   → 所有事务串行执行,完全隔离
   → 性能极差,几乎不用

并发问题对应关系:
  脏读     → 读未提交能发生,RC 以上解决
  不可重复读 → RC 能发生,RR 以上解决
  幻读     → RR 基本解决(MVCC + 间隙锁),Serializable 完全解决

面试题

Q1:MySQL 的四个事务隔离级别?(高频 ⭐⭐⭐⭐⭐)

答:

隔离级别 脏读 不可重复读 幻读 性能
读未提交 ✅有 ✅有 ✅有 最高
读已提交(RC) ❌无 ✅有 ✅有
可重复读(RR) ❌无 ❌无 基本无
串行化 ❌无 ❌无 ❌无 最低

MySQL InnoDB 默认 RR,Oracle 默认 RC。

Q2:为什么 MySQL 默认 RR 而 Oracle 默认 RC?(中频 ⭐⭐⭐)

答:

  • MySQL 早期 binlog 只有 statement 格式,RC 隔离级别下主从复制会有数据不一致问题,RR 更安全
  • MySQL 5.7+ 支持 row 格式 binlog 后,RC 也安全了,但默认还是 RR 保持兼容
  • Oracle 做数据仓库/统计多,RC 的“读最新提交数据”更符合需求
  • 项目实际:简历项目一从 Oracle 迁移到 GaussDB,需要注意隔离级别的默认值差异

Q3:什么是脏读、不可重复读、幻读?(高频 ⭐⭐⭐⭐)

答:

  • 脏读:事务 A 读到了事务 B 未提交的数据,B 回滚后 A 读到的是“脏”数据
  • 不可重复读:事务 A 两次读同一行,中间 B 修改并提交了,A 两次读到的值不同
  • 幻读:事务 A 两次范围查询,中间 B 插入了新行,A 第二次查到多了几行“幻影”行

1.7 N+1 查询问题(简历直接写了)

小白讲解

一句话:N+1 问题就是“查 1 次列表 + N 次详情”,本该 1 次查询搞定的事变成了 N+1 次。

场景:查 10 条指令,每条指令关联一个托管人信息

❌ N+1 问题(1 + N 次查询):
  第 1 次:SELECT * FROM instruction WHERE biz_date = '2026-01-01';  -- 10 条
  第 2 次:SELECT * FROM custodian WHERE id = 1;  -- 查第1条指令的托管人
  第 3 次:SELECT * FROM custodian WHERE id = 2;  -- 查第2条指令的托管人
  ...
  第 11 次:SELECT * FROM custodian WHERE id = 10; -- 查第10条指令的托管人
  → 共 11 次查询!10 条数据 11 次,1000 条就是 1001 次

✅ 优化方案 1:JOIN(1 次查询)
  SELECT i.*, c.* FROM instruction i
  LEFT JOIN custodian c ON i.custodian_id = c.id
  WHERE i.biz_date = '2026-01-01';

✅ 优化方案 2:@BatchSize 批量加载(2 次查询)
  -- MyBatis 配置:
  @Entity
  public class Instruction {
      @ManyToOne
      @BatchSize(size = 50)  -- 一次批量加载 50 个关联对象
      private Custodian custodian;
  }
  -- 第 1 次:查 10 条指令
  -- 第 2 次:SELECT * FROM custodian WHERE id IN (1,2,3,...,10); -- 一次批量查
  → 共 2 次查询!

结合简历案例

简历原文:
  "利用 @BatchSize 解决 N+1 查询,核心接口响应时间从秒级优化至毫秒级"

面试话术:
  "项目一有个指令列表接口,查 50 条指令 + 每条指令的托管人信息。
   原来用 MyBatis-Plus 默认的懒加载,先查 50 条指令,再逐条查托管人,
   50 条数据 51 次查询,接口耗时 2~3 秒。
   我加上了 @BatchSize(size = 50) 注解,把 N+1 变成 1+1,
   第二次查询用 IN 一次性批量加载所有托管人。
   接口耗时从 2~3 秒降到 50 毫秒以内。"

代码验证

// MyBatis-Plus + JPA 注解配置
@Entity
@Table(name = "instruction")
public class Instruction {
    @Id
    private Long id;
    
    private String instructionNo;
    private BigDecimal amount;
    private String status;
    
    // @BatchSize 解决 N+1:Hibernate 会把 N 次查询合并为 1 次 IN 查询
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "custodian_id")
    @BatchSize(size = 50)  // 批量大小 50,超过 50 再分批
    private Custodian custodian;
    
    // 集合关联也能用 @BatchSize
    @OneToMany(mappedBy = "instruction", fetch = FetchType.LAZY)
    @BatchSize(size = 100)
    private List<InstructionDetail> details;
}

// MyBatis XML 方案:用 resultMap + 联合查询
// 方案 1:直接 JOIN(1 次查询,适合数据量小)
<select id="selectInstructionList" resultMap="instructionMap">
    SELECT i.*, c.* 
    FROM instruction i
    LEFT JOIN custodian c ON i.custodian_id = c.id
    WHERE i.biz_date = #{bizDate}
</select>

// 方案 2:分步查询 + 批量加载(2 次查询,适合关联表大)
// MyBatis 的 lazyLoadingEnabled + aggressiveLazyLoading 配合
// 或在 collection 标签中用 fetchType="lazy"

面试题

Q1:什么是 N+1 问题?怎么解决?(高频 ⭐⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

  • N+1 问题:查询 N 条主表数据时,每条主数据再发 1 次查询获取关联数据,共 N+1 次 SQL
  • 解决方案 1:JOIN 查询,一次搞定(适合关联数据量小)
  • 解决方案 2@BatchSize 注解,把 N 次关联查询合并为 1 次 IN 查询,共 2 次 SQL(适合关联数据量大或需要懒加载)
  • 解决方案 3:MyBatis 的 fetchType="lazy" + lazyLoadingEnabled=true 配合,延迟加载 + 批量查询
  • 简历经验:@BatchSize(size=50) 把 51 次查询减为 2 次,接口从秒级→毫秒级

Q2:@BatchSize 和 JOIN 哪个更好?(中频 ⭐⭐⭐)

答:看场景:

  • JOIN:一次查询,性能最好。但如果关联表字段多或数据量大,会返回大量冗余数据,且分页困难
  • @BatchSize:两次查询,灵活控制。适合需要懒加载、关联表数据量大、或一个实体有多个关联的场景
  • 简历中选了 @BatchSize 是因为指令实体关联了多个表(托管人、产品、交易对手),全 JOIN 会导致笛卡尔积爆炸

1.8 分库分表(简历直接写了)

小白讲解

一句话:当单表数据量太大(千万级),查询性能急剧下降,需要把数据拆分到多个表/多个库中。

什么时候需要分库分表?
  单表数据量 > 1000 万 → B+ 树变高,索引维护变慢
  单库数据量 > 500GB → 备份恢复慢、DDL 耗时长
  单库 QPS > 5000 → 连接数不够、锁竞争激烈

分库分表的两种方式:

1. 垂直拆分(按字段拆):
   instruction 表(50 个字段)
   → instruction_base(基础信息:id, no, date, status)
   → instruction_finance(资金信息:amount, fee, currency)
   → instruction_risk(风控信息:risk_level, alert_flag)

2. 水平拆分(按行拆):
   instruction 表(5000 万行)
   → instruction_2025_01(2025年1月的指令)
   → instruction_2025_02(2025年2月的指令)
   → instruction_2026_01(2026年1月的指令)

结合简历案例

简历原文:
  "参与制定分库分表+冷热数据分离+历史归档策略,
   千万级大表按时间维度物理拆分,单表查询效率提升40%+"

面试话术:
  "项目一的指令表数据量达到了千万级,查询性能下降严重。
   我们制定了三层策略:
   1. 分库分表:按业务日期水平拆分,每个月一张物理表(instruction_yyyy_mm)
      → 通过 ShardingSphere 配置路由规则,业务层透明
   2. 冷热数据分离:近 3 个月数据在热表(MySQL),3 个月以上在温表(PostgreSQL)
      → 热表数据量从千万级降到百万级
   3. 历史归档:1 年以上的数据归档到 HBase
      → HBase 的 LSM 树适合大批量写入和范围查询
   最终单表查询效率提升 40%+。"

代码示例

# ShardingSphere 分表配置
spring:
  shardingsphere:
    datasource:
      names: ds0
      ds0: # 数据源配置
        type: com.zaxxer.hikari.HikariDataSource
        # ...
    rules:
      sharding:
        tables:
          instruction:
            actualDataNodes: ds0.instruction_$->{2025..2026}_$->{(1..12).collect{it.toString().padLeft(2,'0')}}
            # 实际表名:instruction_2025_01, instruction_2025_02, ..., instruction_2026_12
            
            tableStrategy:
              standard:
                shardingColumn: biz_date
                preciseAlgorithmClassName: com.xxx.sharding.DateShardingAlgorithm
                # 按日期分表路由算法
// 自定义分表算法
public class DateShardingAlgorithm implements PreciseShardingAlgorithm<Date> {
    @Override
    public String doSharding(Collection<String> tables, 
                             PreciseShardingValue<Date> value) {
        Date bizDate = value.getValue();
        String suffix = formatDate(bizDate, "yyyy_MM");
        // biz_date = 2026-01-15 → 表名 instruction_2026_01
        return "instruction_" + suffix;
    }
}

面试题

Q1:什么时候需要分库分表?怎么选垂直还是水平?(高频 ⭐⭐⭐⭐)

答:

  • 单表 > 1000 万行或单库 > 500GB → 需要拆分
  • 垂直拆分:字段太多(50+),把不常用字段拆到扩展表。解决“表太宽”问题
  • 水平拆分:行数太多,按某个维度(时间/用户ID/哈希)拆分到多个表。解决“表太长”问题
  • 简历案例:按时间维度水平拆分(每个月一张表),因为查询都带 biz_date 条件,路由清晰

Q2:分库分表后有哪些问题?(高频 ⭐⭐⭐⭐)

答:

  1. 跨库 JOIN:不能直接 JOIN,需要应用层组装或冗余字段
  2. 分布式事务:跨库操作需要分布式事务(Seata / 消息事务)
  3. 全局唯一 ID:不能用自增 ID,用雪花算法或号段模式
  4. 跨库分页:limit 10, 20 需要每个分表查 30 条再合并,性能差
  5. 路由复杂:查询条件必须包含分片键,否则全表扫描

Q3:分库分表方案有哪些?(中频 ⭐⭐⭐)

答:

  • ShardingSphere(Apache 顶级项目):Java 生态最成熟,支持分库分表 + 读写分离
  • MyCat:中间件方案,独立部署,支持多语言
  • 简历项目用的是 ShardingSphere,因为和 Spring Boot 集成好,配置简单

1.9 慢查询治理

小白讲解

慢查询治理四步法:

1. 开启慢查询日志
   SET global slow_query_log = ON;
   SET global long_query_time = 1;  -- 超过 1 秒记录

2. 分析慢查询日志
   mysqldumpslow -s t -t 10 slow.log  -- 按耗时排前 10

3. EXPLAIN 分析执行计划
   EXPLAIN SELECT ... → 看 type、key、rows、Extra

4. 优化方案(优先级从高到低):
   a. 加索引/优化联合索引(最有效)
   b. 改写 SQL(避免 SELECT *,避免函数操作索引列)
   c. 覆盖索引避免回表
   d. 分页优化(游标分页代替 LIMIT OFFSET)
   e. 分库分表(终极手段)

面试题

Q1:怎么排查和优化慢 SQL?(高频 ⭐⭐⭐⭐⭐)

答:四步:

  1. 开启慢查询日志,收集慢 SQL
  2. EXPLAIN 分析执行计划,看 type(是否全表扫描)、key(是否走索引)、rows(扫描行数)、Extra(是否回表/排序/临时表)
  3. 对症优化:全表扫描→加索引;回表→覆盖索引;排序→索引排序;N+1→@BatchSize
  4. 简历经验:重构复合索引 + @BatchSize + 覆盖索引,核心接口秒级→毫秒级

Q2:SQL 语句哪些写法会导致索引失效?(高频 ⭐⭐⭐⭐⭐)

答:

  1. 函数操作索引列WHERE YEAR(create_time) = 2026 → 改为 WHERE create_time >= '2026-01-01' AND create_time < '2026-01-02'
  2. 隐式类型转换WHERE instruction_no = 123456(字段是 VARCHAR,传了 INT)→ MySQL 会转成数字比较,不走索引
  3. LIKE 以 % 开头WHERE instruction_no LIKE '%ABC' → 不走索引;LIKE 'ABC%' → 走索引
  4. OR 连接非索引列WHERE indexed_col = 1 OR non_indexed_col = 2 → 全表扫描
  5. NOT IN / != / <>:通常不走索引(取决于数据量)
  6. 计算操作WHERE id + 1 = 100 → 不走索引;改为 WHERE id = 99

第二章:Redis 实战(简历核心亮点)

Redis 是简历中使用最密集的技术,涉及:多级缓存、Lua 预减库存、Sorted Set Feed 流、Bitmap 去重、分布式锁、缓存击穿。


2.1 Redis 基础数据结构

小白讲解

类型 底层结构 简历哪里用到了 典型场景
String SDS(简单动态字符串) 缓存对象、分布式锁 缓存用户信息、SETNX
List QuickList(双向链表+压缩列表) - 消息队列、最新动态
Hash ziplist + hashtable 缓存指令详情 存对象,部分更新
Set intset + hashtable 去重 标签、共同好友
Sorted Set ziplist + skiplist(跳表) Feed 流时间排序 排行榜、时间线
Bitmap String 的位操作 点赞/收藏去重 签到、活跃统计
Lua 脚本 - 预减库存防超卖 原子操作

2.1.1 String 底层(SDS 简单动态字符串)

Redis 的 String 不是直接复用 C 语言的 char*,而是自己封装了 SDS(Simple Dynamic String)。

SDS 结构(Redis 3.2+ 分 5 种类型,以 sdshdr8 为例):

┌──────────┬──────────┬───────┬──────────────────────┬─────┐
│  len     │  alloc   │ flags │        buf[]         │ \0  │
│ 已用长度 │ 分配长度 │ 类型  │  字节数组(存数据)   │结尾 │
└──────────┴──────────┴───────┴──────────────────────┴─────┘

对比 C 语言的 char*,SDS 的三大优势:

1. O(1) 获取长度
   C 字符串:strlen() 要遍历到 \0,O(N)
   SDS:直接读 len 字段,O(1)
   → 场景:STRLEN 命令,或者频繁判断长度的地方

2. 二进制安全
   C 字符串:遇到 \0 就认为字符串结束,不能存二进制数据
   SDS:用 len 记录长度,buf 里可以存任意字节(包括 \0)
   → 场景:缓存序列化的对象、图片、protobuf 等二进制数据

3. 预分配 + 惰性释放,减少内存重分配
   扩容:需要 10 字节时,实际分配 10 + 10(预留一倍),
         len < 1MB 时翻倍,≥ 1MB 时每次 +1MB
   缩容:删除数据时不立即回收,留着下次用(惰性释放)
   → 场景:频繁 append 的场景(如 INCRBY 累加、AOF 缓冲)

2.1.2 Hash 底层与渐进式 rehash

Hash 底层是 dict(字典),每个 dict 内部有两个哈希表:

dict
 └── ht[0]:正在使用的哈希表
 └── ht[1]:rehash 时的新表(平时为空)

小数据时用 ziplist(连续内存,省空间)
大数据时用 hashtable(数组 + 链表)

什么是渐进式 rehash?
  哈希表扩容(或缩容)时,不是一次性把 ht[0] 全部搬到 ht[1],
  而是"边干活边搬":

  每次对 dict 做增删改查时,顺手搬一部分桶(rehash 一个索引)
  → 避免一次性 rehash 导致 Redis 单线程卡顿(大量数据时可能卡几百毫秒)
  → 这就是"渐进式"

  期间查找:先查 ht[0],找不到再查 ht[1]
  期间新增:直接写入 ht[1]
  搬完后:ht[1] 变成 ht[0],ht[1] 清空

为什么要关心 rehash?
  → Redis 是单线程,一次性 rehash 千万级 key 会阻塞主线程
  → 渐进式 rehash 把一次性的大操作拆成很多小操作,保证每次命令都很快

2.1.3 List 底层(QuickList / ListPack)

Redis List 底层结构的三次演进:

Redis 3.2 之前:
  元素少 → ziplist(压缩列表,连续内存)
  元素多 → linkedlist(双向链表,每个节点独立内存)

  问题:ziplist 和 linkedlist 二选一,中间态切换有阈值,不灵活

Redis 3.2 引入 QuickList:
  quicklist = 双向链表,但每个节点是一个 ziplist(压缩列表)

  quicklist
   ┌───┐     ┌──────┐     ┌──────┐     ┌───┐
   │节点│ ←→ │ziplist│ ←→ │ziplist│ ←→ │节点│
   └───┘     └──────┘     └──────┘     └───┘

  好处:结合了链表的灵活(节点可增删)和 ziplist 的紧凑(连续内存省空间)
  每个 ziplist 节点默认最多 8KB,超过就新开一个节点

Redis 7.0 引入 ListPack:
  ziplist 的升级版,解决了 ziplist 的"连锁更新"问题
  (ziplist 中改一个元素长度可能导致后续所有元素都要重算偏移量)
  ListPack 用"记录自己的长度"代替"记录前一个的长度",避免连锁更新

2.1.4 Set 底层(intset)

Set 底层有两种:

1. intset(整数集合)
   条件:元素全是整数 && 元素数量 ≤ 512
   结构:连续内存的有序数组,用二分查找 O(logN)
   → 省内存:存 100 个 int,intset 只要几百字节;hashtable 要几 KB

2. hashtable(哈希表)
   条件:有非整数元素,或元素数量 > 512
   → 通用,但内存开销大

intset 的编码升级:
  int16 → int32 → int64
  存 1~100(int16 够用)时用 int16 编码
  突然存一个 100000(超出 int16)→ 整个 intset 升级到 int32
  → 升级是不可逆的(升级后不会降级)

2.1.5 ZSet 底层(跳表详解 ★ 面试重点)

什么是跳表(Skip List)

一句话:跳表 = 给有序链表加"多层索引",用空间换时间,把查找从 O(N) 降到 O(logN)。

先看普通有序链表的痛点:
  查 25,必须从头一个个往后走:5→10→15→20→25,O(N)

跳表的做法:在链表上面建索引层
  第 3 层(最稀疏):
    head ────────────────────────────────→ 30 ─────→ NULL

  第 2 层:
    head ──────→ 10 ──────────→ 20 ───────→ 30 ─────→ NULL

  第 1 层:
    head ──→ 5 ──→ 10 ──→ 15 ──→ 20 ──→ 25 ──→ 30 ─→ NULL

  第 0 层(原始链表,包含全部元素):
    head → 5 → 10 → 15 → 20 → 25 → 30 → 35 → 40 → NULL

  查找 25 的过程(从最高层往下跳):
    ① 从第 3 层开始:head 的下一个是 30,30 > 25,不能跳,下沉到第 2 层
    ② 第 2 层:head→10→20,20 的下一个是 30 > 25,停在 20,下沉到第 1 层
    ③ 第 1 层:从 20 继续,下一个就是 25,找到!✅

  关键点:
    - 高层索引让查找"大步跳跃",跳过大部分元素
    - 每下沉一层,候选范围缩小,直到精确定位
    - 索引层数足够时,查找复杂度 O(logN),和平衡树一个量级

查找过程(ZSCORE / ZRANK 底层)

// 伪代码:跳表查找 score = target
Node* zslSearch(zskiplist* zsl, double target) {
    Node* x = zsl->header;
    // 从最高层往下遍历
    for (int level = zsl->level - 1; level >= 0; level--) {
        // 当前层:只要下一个节点的 score < target,就一直往前走
        while (x->level[level].forward != NULL &&
               x->level[level].forward->score < target) {
            x = x->level[level].forward;  // 前进一步
        }
        // 当前层走不动了(下一个 >= target),下沉到下一层
    }
    // 走到第 0 层后,x 的后继就是第一个 score >= target 的节点
    x = x->level[0].forward;
    if (x != NULL && x->score == target) return x;
    return NULL;
}
关键理解:
  每层都在做"尽量往前走"的动作
  高层能跨越大步,低层做精细定位
  最后在第 0 层(完整链表)上精确命中

插入过程(ZADD 底层)

插入分 3 步:

第 1 步:随机生成新节点的层高(这是跳表的核心!)
  Redis 用 zslRandomLevel() 生成:
    level 从 1 开始
    每轮有 25% 的概率(p = 0.25)继续加一层
    直到随机失败,或达到最高 64 层

    // Redis 源码逻辑:
    int zslRandomLevel(void) {
        int level = 1;
        while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))  // p = 0.25
            level += 1;
        return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;  // 64
    }

    期望层高 = 1/(1-p) = 1/0.75 ≈ 1.33 层
    → 大部分节点只有 1~2 层,极少数节点很高
    → 空间复杂度期望 O(N)

第 2 步:从最高层往下,记录每层的前驱节点
  用 update[] 数组记录"每层要插在哪个节点后面"

第 3 步:逐层插入(改指针)
  类似链表的插入,只是要改多层指针

删除过程(ZREM 底层)

删除分 2 步:

第 1 步:像查找一样,从最高层往下找到目标节点,记录每层前驱

第 2 步:逐层删除(改指针)
  把每层前驱的 forward 指针直接跨过目标节点
  如果某层删完后最高层空了,就降低跳表的总层高

复杂度分析(面试要能算)

设 n 个节点,p = 0.25(每层有 25% 概率继续):

时间复杂度:
  查找/插入/删除:O(logN)
    → 因为期望层高是 log 级别的,每层走常数步

  范围查询(ZRANGE):O(logN + M)
    → O(logN) 定位到起点 + O(M) 遍历 M 个结果
    → 这就是 ZRANGE 取排行榜前 10 名为什么快

空间复杂度:
  期望 O(N)(每个节点平均 1.33 层指针)

对比平衡树:
  跳表 O(logN) 查找 + O(N) 空间,和红黑树/AVL 同一个量级
  但跳表实现简单得多,且范围查询更友好

为什么 Redis 用跳表不用红黑树(面试高频)

红黑树和跳表都能做到 O(logN) 查找,但 Redis 选跳表,5 个理由:

1. 实现简单
   跳表:核心就一个"随机层高 + 多指针链表",代码约 200 行
   红黑树:插入删除都要处理旋转 + 染色 + 各种 case,代码 500+ 行
   → Redis 作者 antirez 明确说过:跳表实现简单,好维护

2. 范围查询天然高效
   跳表:第 0 层就是有序链表,找到起点后直接向后遍历
         ZRANGE / ZRANGEBYSCORE 这些命令直接受益
   红黑树:范围查询要中序遍历,或用 parent 指针回溯,不直接

3. 插入删除无需平衡调整
   跳表:插入删除只改局部指针,不影响其他节点
   红黑树:插入删除后可能触发旋转,影响从根到叶的一整条路径

4. 并发友好(局部锁)
   跳表:只锁修改路径上的节点,其他部分不受影响
   红黑树:旋转可能影响树的大部分,加锁范围大
   (Redis 本身单线程,但这个特性对后续的并发扩展有利)

5. ZSet 需要同时支持"按 score 排序"和"按 member 查 score"
   跳表负责前者(有序),哈希表负责后者(O(1) 查 score)
   → ZSet 是 跳表 + 哈希表 双结构,两者共享元素节点

ZSet 的双结构(跳表 + 哈希表)

ZSet 内部有两个结构,指向同一批元素:

  跳表(skiplist):
    按 score 从小到大排序
    → 服务 ZRANGE(排名)、ZRANGEBYSCORE(按分数范围)、ZRANK(排名)

  哈希表(dict):
    member → score 的映射
    → 服务 ZSCORE(查某元素的分数,O(1))
    → 如果只用跳表,ZSCORE 要 O(logN);加哈希表后 O(1)

  ZADD member score 时:
    ① 哈希表:member → score 存进去
    ② 跳表:按 score 插入节点
  → 两个结构同时更新,保证一致

代码验证

// 用 Java 手动实现一个简易跳表,理解核心逻辑
class SkipList {
    static final int MAX_LEVEL = 16;
    static final double P = 0.5;
    
    class Node {
        int value;
        Node[] forward;  // forward[i] = 第 i 层的下一个节点
        
        Node(int value, int level) {
            this.value = value;
            this.forward = new Node[level + 1];
        }
    }
    
    Node head = new Node(Integer.MIN_VALUE, MAX_LEVEL);
    int currentLevel = 0;
    
    // 随机生成层高
    int randomLevel() {
        int level = 0;
        while (Math.random() < P && level < MAX_LEVEL) {
            level++;
        }
        return level;
    }
    
    // 查找
    boolean search(int target) {
        Node x = head;
        for (int i = currentLevel; i >= 0; i--) {
            while (x.forward[i] != null && x.forward[i].value < target) {
                x = x.forward[i];  // 前进
            }
            // 下沉到下一层
        }
        x = x.forward[0];
        return x != null && x.value == target;
    }
    
    // 插入
    void insert(int value) {
        Node[] update = new Node[MAX_LEVEL + 1];  // 每层前驱
        Node x = head;
        for (int i = currentLevel; i >= 0; i--) {
            while (x.forward[i] != null && x.forward[i].value < value) {
                x = x.forward[i];
            }
            update[i] = x;
        }
        
        int level = randomLevel();
        if (level > currentLevel) {
            for (int i = currentLevel + 1; i <= level; i++) {
                update[i] = head;
            }
            currentLevel = level;
        }
        
        Node newNode = new Node(value, level);
        for (int i = 0; i <= level; i++) {
            newNode.forward[i] = update[i].forward[i];
            update[i].forward[i] = newNode;
        }
    }
    
    // 删除
    void remove(int value) {
        Node[] update = new Node[MAX_LEVEL + 1];
        Node x = head;
        for (int i = currentLevel; i >= 0; i--) {
            while (x.forward[i] != null && x.forward[i].value < value) {
                x = x.forward[i];
            }
            update[i] = x;
        }
        x = x.forward[0];
        if (x != null && x.value == value) {
            for (int i = 0; i <= currentLevel; i++) {
                if (update[i].forward[i] != x) break;
                update[i].forward[i] = x.forward[i];
            }
            // 降低总层高
            while (currentLevel > 0 && head.forward[currentLevel] == null) {
                currentLevel--;
            }
        }
    }
}

面试题

Q1:Redis 有哪些数据结构?底层怎么实现的?(高频 ⭐⭐⭐⭐⭐)

答:

  • String:SDS(二进制安全、O(1) 取长度、预分配+惰性释放)
  • List:QuickList(3.2+ 双向链表节点 + ziplist/ListPack)
  • Hash:ziplist(小数据)+ hashtable(大数据),渐进式 rehash
  • Set:intset(纯整数且少)+ hashtable(大数据或含非整数)
  • ZSet:ziplist(小数据)+ skiplist + hashtable(大数据,双结构)
  • Bitmap:基于 String 的位操作
  • HyperLogLog:基数统计(去重计数)

Q2:跳表是什么?怎么做到 O(logN) 查找?(超高频 ⭐⭐⭐⭐⭐)

答:

  • 跳表是给有序链表加多层索引的结构,用空间换时间
  • 高层索引稀疏,能“大步跳跃”跳过大部分元素;低层索引密集,做精确定位
  • 查找从最高层开始,每层尽量往前走,走不动就下沉到下一层,最后在第 0 层命中
  • 层高随机生成(Redis p=0.25),期望层数 O(logN),所以查找 O(logN)

Q3:跳表的插入和删除怎么做?层高怎么定?(高频 ⭐⭐⭐⭐)

答:

  • 插入:①随机生成层高(每层 25% 概率继续加,最高 64 层)→ ②从高层到低层记录每层前驱 → ③逐层改指针插入
  • 删除:①找到节点并记录每层前驱 → ②逐层把前驱指针跨过目标节点 → ③若最高层空了就降层
  • 层高:随机生成,期望 1.33 层,保证跳表整体平衡(避免退化成链表)

Q4:ZSet 底层为什么用跳表不用红黑树?(高频 ⭐⭐⭐⭐⭐)

答:

  1. 实现简单:跳表约 200 行,红黑树 500+ 行且旋转染色复杂
  2. 范围查询天然高效:跳表第 0 层是有序链表,找到起点直接遍历;红黑树要中序遍历
  3. 插入删除无平衡调整:跳表只改局部指针;红黑树可能触发整条路径旋转
  4. 并发友好:跳表可局部加锁;红黑树旋转影响范围大
  5. 配合哈希表:跳表管“按 score 排序”,哈希表管“按 member O(1) 查 score”,双结构互补

Q5:什么是渐进式 rehash?为什么要渐进式?(高频 ⭐⭐⭐⭐)

答:

  • Redis 的 dict 有两个哈希表 ht[0] 和 ht[1],扩容时把 ht[0] 迁到 ht[1]
  • 渐进式:不是一次性搬完,而是每次增删改查时顺手搬一部分桶
  • 原因:Redis 单线程,一次性 rehash 千万级 key 会阻塞主线程几百毫秒,导致所有命令卡顿
  • 期间查找先查 ht[0] 再查 ht[1],新增直接写 ht[1]

Q6:SDS 相比 C 字符串有什么优势?(中频 ⭐⭐⭐)

答:

  1. O(1) 取长度(len 字段),C 字符串要 O(N) 遍历
  2. 二进制安全(用 len 而非 \0 判断结束),能存二进制数据
  3. 预分配 + 惰性释放,减少频繁扩容缩容的内存重分配

2.2 缓存雪崩 / 穿透 / 击穿

小白讲解

缓存雪崩(Avalanche):
  大量 key 同时过期 或 Redis 宕机
  → 请求全部打到数据库 → 数据库崩溃

  解决:
  1. 过期时间加随机值(避免同时过期)
  2. 多级缓存(Caffeine + Redis,Redis 挂了还有 Caffeine)
  3. Redis 集群高可用(哨兵/集群模式)
  4. 限流降级(Sentinel 限流保护数据库)

缓存穿透(Penetration):
  查询一个根本不存在的数据
  → 缓存没有 → 数据库也没有 → 每次都打数据库
  → 恶意攻击(查 id = -1 或不存在的 id)

  解决:
  1. 缓存空值(null 也缓存,设短 TTL)
  2. 布隆过滤器(BloomFilter)拦截不存在的 key

缓存击穿(Breakdown):
  一个热点 key 突然过期
  → 大量并发请求同时查这个 key
  → 都没命中缓存 → 全部打数据库

  解决:
  1. 热点 key 永不过期(或 TTL 很长)
  2. DCL(双重检查锁):查缓存→没有→加锁查DB→写入缓存→释放锁
  3. 热点 Key 打散:把一个 key 拆成多个子 key(库存拆成 10 份)

结合简历案例

简历原文:
  "采用 DCL + 热点 Key 打散解决缓存击穿"
  "Redis + Lua 预减库存解决秒杀超卖问题"

面试话术(缓存击穿):
  "项目四秒杀场景下,商品库存是热点 key,如果缓存过期瞬间大量请求涌入会击穿缓存。
   我用了两个手段:
   1. DCL(双重检查锁):
      先查 Redis 缓存 → 没命中 → 获取 Redisson 分布式锁 → 再查一次缓存(防重复)
      → 还是没有 → 查数据库 → 写入缓存(设 TTL + 随机值)→ 释放锁
   2. 热点 Key 打散:
      把 stock:1001 拆成 stock:1001:0 ~ stock:1001:9 共 10 个子 key
      请求随机路由到某个子 key,分散热点压力
      减库存时用 Lua 脚本一次性扣减所有子 key 的库存"

代码示例

// DCL 解决缓存击穿
public Product getProductWithDCL(Long productId) {
    // 1. 先查缓存
    String key = "product:" + productId;
    Product product = (Product) redisTemplate.opsForValue().get(key);
    if (product != null) {
        return product;  // 缓存命中,直接返回
    }
    
    // 2. 缓存未命中,获取分布式锁
    RLock lock = redissonClient.getLock("lock:product:" + productId);
    try {
        // 尝试加锁,等待 3 秒,锁自动释放 10 秒
        if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
            // 3. 双重检查:再查一次缓存(可能在等锁期间别人已经写入)
            product = (Product) redisTemplate.opsForValue().get(key);
            if (product != null) {
                return product;
            }
            
            // 4. 查数据库
            product = productMapper.selectById(productId);
            
            // 5. 写入缓存(TTL + 随机值防止雪崩)
            if (product != null) {
                int ttl = 30 * 60 + new Random().nextInt(300); // 30分钟 ± 5分钟随机
                redisTemplate.opsForValue().set(key, product, ttl, TimeUnit.SECONDS);
            } else {
                // 缓存空值防止穿透
                redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES);
            }
            return product;
        }
        // 获取锁失败,短暂等待后重试或返回降级数据
        return getDegradedProduct(productId);
    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
// 热点 Key 打散
public boolean deductStock(Long productId, int quantity) {
    // 把 stock:1001 拆成 10 份
    int shardCount = 10;
    int shard = ThreadLocalRandom.current().nextInt(shardCount);
    String shardKey = "stock:" + productId + ":" + shard;
    
    // Lua 脚本原子操作:判断 + 扣减
    String luaScript = 
        "if tonumber(redis.call('GET', KEYS[1]) or '0') >= tonumber(ARGV[1]) then " +
        "  return redis.call('DECRBY', KEYS[1], ARGV[1]) " +
        "else " +
        "  return -1 " +
        "end";
    
    Long result = redisTemplate.execute(
        new DefaultRedisScript<>(luaScript, Long.class),
        Collections.singletonList(shardKey),
        String.valueOf(quantity)
    );
    
    if (result != null && result >= 0) {
        return true;  // 扣减成功
    }
    // 当前分片不足,尝试其他分片(或汇总所有分片判断)
    return tryDeductFromOtherShards(productId, quantity, shard);
}

面试题

Q1:缓存雪崩、穿透、击穿的区别和解决方案?(超高频 ⭐⭐⭐⭐⭐)

简历直接写了,必须会答!

问题 触发条件 解决方案
雪崩 大量 key 同时过期 / Redis 宕机 随机 TTL + 多级缓存 + 限流降级
穿透 查不存在的数据 缓存空值 + 布隆过滤器
击穿 热点 key 过期瞬间大量请求 DCL + 热点 key 永不过期 + Key 打散

Q2:布隆过滤器是什么?怎么解决缓存穿透?(中频 ⭐⭐⭐⭐)

答:

  • 布隆过滤器:一个位数组 + 多个哈希函数。插入时对 key 做多次哈希,把对应位置 1;查询时检查所有位是否为 1
  • 特点:可能有误判(说存在不一定真存在),但不会漏判(说不存在一定不存在)
  • 缓存穿透场景:请求来时先查布隆过滤器,不存在直接返回,不查缓存和数据库

Q3:DCL 解决缓存击穿的双重检查为什么要查两次?(高频 ⭐⭐⭐⭐)

答:

  • 第一次查:缓存命中直接返回,避免不必要的加锁
  • 第二次查(获取锁后):在等锁期间可能有其他线程已经把数据写入缓存了,再查一次避免重复查库
  • 这和 DCL 单例模式的原理一样:第一次检查避免加锁开销,第二次检查防止重复创建

2.3 Redis + Lua 预减库存(简历直接写了)

小白讲解

秒杀场景的核心问题:
  1000 个用户同时抢 100 件商品
  → 如果先 GET 再 DECR,中间有时间差 → 超卖(卖了 105 件)

Redis 单线程的保证:
  命令本身是原子的(GET 和 DECR 各自原子)
  但 GET + 判断 + DECR 三步不是原子的!

Lua 脚本 = 把多步操作合并为一个原子操作
  → Redis 执行 Lua 脚本时不会被其他命令打断
  → 判断库存 + 扣减库存一步搞定

预减库存:
  活动开始前把库存写入 Redis
  用户下单 → Lua 脚本原子扣减 → 成功才放行
  → 数据库不直接参与秒杀,只处理成功的订单
  → 这就是"预减库存"——在 Redis 中提前扣减

结合简历案例

简历原文:
  "设计 Redis + Lua 预减库存方案解决秒杀超卖问题"

面试话术:
  "项目四秒杀场景,高峰期 1000 QPS。
   如果直接查数据库扣减库存,一是性能扛不住,二是 GET+判断+UPDATE 非原子导致超卖。
   我的方案:
   1. 活动前把库存预热到 Redis(stock:activityId = 100)
   2. 用户请求进来,用 Lua 脚本原子操作:判断库存 >= 1 → DECR → 返回剩余库存
   3. Lua 返回 >= 0 表示成功 → 发送 RocketMQ 消息异步下单
   4. Lua 返回 < 0 表示库存不足 → 直接拒绝
   5. 最终一致性:消费 MQ 消息时扣减数据库库存
   
   效果:Redis 扛住了全部并发,数据库压力降到正常水平,彻底解决超卖。"

代码示例

@Service
public class SeckillService {
    
    private static final String LUA_DEDUCT_STOCK = 
        // KEYS[1] = 库存key, ARGV[1] = 扣减数量
        "local stock = tonumber(redis.call('GET', KEYS[1]) or '0') " +
        "if stock >= tonumber(ARGV[1]) then " +
        "  redis.call('DECRBY', KEYS[1], ARGV[1]) " +
        "  return stock - tonumber(ARGV[1]) " +  // 返回剩余库存
        "else " +
        "  return -1 " +  // 库存不足
        "end";
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
    private RocketMQTemplate rocketMQTemplate;
    
    public SeckillResult seckill(Long activityId, Long userId) {
        String stockKey = "stock:" + activityId;
        
        // 1. Lua 原子预减库存
        Long remaining = redisTemplate.execute(
            new DefaultRedisScript<>(LUA_DEDUCT_STOCK, Long.class),
            Collections.singletonList(stockKey),
            "1"
        );
        
        if (remaining == null || remaining < 0) {
            return SeckillResult.fail("库存不足");
        }
        
        // 2. 扣减成功,发送 MQ 异步下单(最终一致性)
        SeckillOrder order = new SeckillOrder(activityId, userId, System.currentTimeMillis());
        rocketMQTemplate.asyncSend(
            "seckill-order-topic",
            MessageBuilder.withPayload(order).build(),
            new SendCallback() {
                @Override
                public void onSuccess(SendResult result) { /* 发送成功 */ }
                @Override
                public void onException(Throwable e) {
                    // MQ 发送失败,回补 Redis 库存
                    redisTemplate.opsForValue().increment(stockKey, 1);
                }
            }
        );
        
        return SeckillResult.success("抢购成功,正在创建订单");
    }
}

面试题

Q1:为什么用 Lua 脚本解决超卖?(超高频 ⭐⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

  • 问题:GET 库存 → 判断 > 0 → DECR 三步不是原子的,并发下会超卖
  • 方案:Lua 脚本把三步合并为一个原子操作。Redis 执行 Lua 时不会被其他命令打断
  • 优势:不需要加锁(无锁化),性能极高;不需要数据库参与秒杀,Redis 扛住全部并发
  • 补充:扣减成功后通过 MQ 异步下单,数据库只处理成功的订单,压力降到正常水平

Q2:Redis 执行 Lua 脚本为什么是原子的?(中频 ⭐⭐⭐⭐)

答:Redis 是单线程的(6.0 前整个命令处理单线程,6.0 后 IO 多线程但命令执行仍单线程)。Lua 脚本作为一个整体被 Redis 顺序执行,执行期间不会插入其他命令,所以是原子的。

Q3:预减库存后数据库怎么保证最终一致性?(高频 ⭐⭐⭐⭐)

答:

  1. Redis 扣减成功 → 发 MQ 消息(包含 activityId + userId)
  2. 消费者收到消息 → 在数据库中创建订单 + 扣减数据库库存
  3. 如果 MQ 发送失败 → 回补 Redis 库存(INCR)
  4. 消费失败 → 重试机制,死信队列兜底
  5. 对账机制:定时任务对比 Redis 库存和数据库库存

2.4 Redis Sorted Set Feed 流(简历直接写了)

小白讲解

Feed 流:用户主页看到的内容流(类似微博/朋友圈)

两种模型:
  1. 写扩散(Fan-out on Write):发布时推到所有粉丝的收件箱
     → 发布慢,读取快(直接查自己的收件箱)
     → 适合粉丝少、读频繁的场景
  
  2. 读扩散(Fan-out on Read):读取时实时拉取关注人的最新内容
     → 发布快,读取慢(要查多个关注人的内容再合并排序)
     → 适合粉丝多、读不频繁的场景

简历方案:写扩散 + 读扩散混合模型
  → 活跃用户用写扩散(推送),非活跃用户用读扩散(拉取)
  → 兼顾性能和存储

Sorted Set 在 Feed 流中的作用:
  ZADD user:1:inbox timestamp postId   → 存入动态,score = 时间戳
  ZREVRANGE user:1:inbox 0 19          → 按时间倒序取最新 20 条
  ZREMRANGEBYRANK user:1:inbox 0 -1001 → 只保留最新 1000 条(控制内存)

结合简历案例

简历原文:
  "设计 Feed 流写扩散+读扩散混合模型,基于 Redis Sorted Set 
   实现时间序排列,Feed 接口平均耗时从 200ms 降至 40ms"

面试话术:
  "项目四社区平台,用户发动态后粉丝要能看到。
   纯读扩散方案:用户刷新 Feed 时要查询所有关注人的最新动态,
   如果关注了 500 人,要发 500 次查询再合并排序,慢且占数据库连接。
   
   我设计了混合方案:
   1. 写扩散:用户发动态时,推到所有粉丝的 Redis ZSet 收件箱
      ZADD fanout:{userId} timestamp postId
   2. 读扩散兜底:对于关注了大量用户的场景(大V的粉丝),不做写扩散
      读时实时拉取大V最新动态合并到结果中
   3. ZSet 按 timestamp 排序,ZREVRANGE 取最新 N 条
   4. 每个用户收件箱限制 1000 条,超出自动淘汰
   
   效果:Feed 接口只需 1 次 ZREVRANGE,200ms → 40ms。"

代码示例

@Service
public class FeedService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private static final int MAX_FEED_SIZE = 1000;
    private static final int FANOUT_THRESHOLD = 1000; // 粉丝超过1000不做写扩散
    
    // 发布动态 → 写扩散
    public void publishPost(Post post) {
        // 1. 保存动态到数据库
        postMapper.insert(post);
        
        // 2. 获取粉丝列表
        List<Long> followers = followMapper.getFollowers(post.getUserId());
        
        // 3. 写扩散:推到每个粉丝的收件箱
        String postKey = "feed:inbox:" + post.getUserId(); // 自己也收一份
        redisTemplate.opsForZSet().add(postKey, post.getId().toString(), 
            post.getCreateTime().getTime());
        
        for (Long followerId : followers) {
            if (isInfluencer(followerId)) {
                continue; // 大V的粉丝不做写扩散,走读扩散
            }
            String inboxKey = "feed:inbox:" + followerId;
            redisTemplate.opsForZSet().add(inboxKey, post.getId().toString(),
                post.getCreateTime().getTime());
            
            // 控制收件箱大小,保留最新 1000 条
            Long size = redisTemplate.opsForZSet().size(inboxKey);
            if (size != null && size > MAX_FEED_SIZE) {
                redisTemplate.opsForZSet().removeRange(inboxKey, 0, 
                    (int)(size - MAX_FEED_SIZE - 1));
            }
        }
    }
    
    // 拉取 Feed 流
    public List<Post> getFeed(Long userId, int page, int size) {
        String inboxKey = "feed:inbox:" + userId;
        int start = (page - 1) * size;
        int end = start + size - 1;
        
        // 1. 从 ZSet 按时间倒序取
        Set<String> postIds = redisTemplate.opsForZSet()
            .reverseRange(inboxKey, start, end);
        
        if (postIds == null || postIds.isEmpty()) {
            return Collections.emptyList();
        }
        
        // 2. 批量查帖子详情
        return postMapper.selectByIds(postIds);
    }
    
    // 读扩散兜底:拉取大V的最新动态
    public List<Post> getInfluencerFeed(Long userId, int size) {
        List<Long> followingInfluencers = followMapper.getFollowingInfluencers(userId);
        if (followingInfluencers.isEmpty()) return Collections.emptyList();
        
        // 用 ZUNIONSTORE 合并多个大V的动态
        String[] keys = followingInfluencers.stream()
            .map(id -> "user:posts:" + id)
            .toArray(String[]::new);
        String tempKey = "feed:temp:" + userId;
        redisTemplate.opsForZSet().unionAndStore(tempKey, Arrays.asList(keys), tempKey);
        
        Set<String> postIds = redisTemplate.opsForZSet()
            .reverseRange(tempKey, 0, size - 1);
        return postMapper.selectByIds(postIds);
    }
}

面试题

Q1:Feed 流的写扩散和读扩散有什么区别?怎么选?(高频 ⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

对比项 写扩散(推) 读扩散(拉)
发布时 慢(要推到所有粉丝) 快(只存一份)
读取时 快(直接查收件箱) 慢(要拉取多个关注人的再合并)
存储 大(N 份副本) 小(1 份)
适用 粉丝少、读频繁 粉丝多、读不频繁

简历方案:混合模型。普通用户写扩散,大V读扩散兜底。

Q2:Redis ZSet 为什么适合做 Feed 流?(中频 ⭐⭐⭐)

答:

  1. 自动排序:ZSet 按 score(时间戳)排序,不需要应用层排序
  2. 范围查询快:ZREVRANGE O(logN + M),取最新 N 条极快
  3. 去重:同一帖子不会被重复推入(元素唯一)
  4. 淘汰旧数据:ZREMRANGEBYRANK 方便控制收件箱大小

2.5 Redis Bitmap 去重(简历直接写了)

小白讲解

Bitmap = 位图,用 String 的每个 bit 表示一个布尔值

1 个 bit = 1 个布尔值
1 个字节 = 8 个 bit
1MB = 8388608 个 bit = 838 万个布尔值
10MB = 8388 万个布尔值 ≈ 8000 万用户的去重

优点:极其省内存(1 亿用户去重只需 12MB)

简历场景:点赞/收藏去重
  用户 12345 点赞了帖子 67890
  → SETBIT post:67890:likes 12345 1
  → 判断是否点过赞:GETBIT post:67890:likes 12345 → 1 表示已赞
  → 统计点赞数:BITCOUNT post:67890:likes

结合简历案例

简历原文:
  "基于 Redis Bitmap 实现点赞/收藏去重统计"

面试话术:
  "项目四社区平台有点赞和收藏功能。
   原来用 SET 存储点赞用户列表,一个帖子 10 万点赞就需要存 10 万个 userId,
   内存占用大。
   
   我改用 Bitmap:用户 ID 作为 bit 偏移量,点赞就 SETBIT 置 1。
   1 亿用户只需 12MB 内存,是 SET 方案的几十分之一。
   
   判断是否点过赞用 GETBIT(O(1)),统计点赞数用 BITCOUNT,
   多个帖子的点赞合并用 BITOP AND/OR。"

代码示例

@Service
public class LikeService {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    // 点赞
    public boolean like(Long postId, Long userId) {
        String key = "post:" + postId + ":likes";
        // SETBIT key userId 1
        Boolean previouslyLiked = redisTemplate.opsForValue()
            .setBit(key, userId);
        // 返回旧值:true 表示之前已点赞(重复点赞),false 表示首次点赞
        return !previouslyLiked;  // true = 点赞成功
    }
    
    // 取消点赞
    public void unlike(Long postId, Long userId) {
        String key = "post:" + postId + ":likes";
        redisTemplate.opsForValue().setBit(key, userId, false);
    }
    
    // 是否点过赞
    public boolean hasLiked(Long postId, Long userId) {
        String key = "post:" + postId + ":likes";
        return Boolean.TRUE.equals(
            redisTemplate.opsForValue().getBit(key, userId));
    }
    
    // 统计点赞数
    public long getLikeCount(Long postId) {
        String key = "post:" + postId + ":likes";
        return redisTemplate.opsForValue().bitCount(key);
    }
    
    // 找出同时点赞了两个帖子的人(BITOP AND)
    public long getCommonLikes(Long postId1, Long postId2) {
        String key1 = "post:" + postId1 + ":likes";
        String key2 = "post:" + postId2 + ":likes";
        String destKey = "temp:common_likes:" + postId1 + "_" + postId2;
        
        // BITOP AND dest key1 key2
        redisTemplate.opsForValue().bitAnd(destKey, key1, key2);
        Long count = redisTemplate.opsForValue().bitCount(destKey);
        redisTemplate.delete(destKey);  // 用完删除临时 key
        return count != null ? count : 0;
    }
}

面试题

Q1:Bitmap 的原理和适用场景?(高频 ⭐⭐⭐⭐)

答:

  • 原理:用 String 的每个 bit 表示一个布尔值,bit 偏移量作为用户 ID
  • 内存优势:1 亿用户去重只需 12MB,比 SET(每个元素 8~64 字节)省几十倍
  • 适用场景:活跃用户统计、签到打卡、点赞/收藏去重、布隆过滤器
  • 局限:用户 ID 必须是数字且不能太大(ID = 10 亿 → 需要 119MB);不支持非数字 ID

Q2:BITCOUNT 统计 1 的个数,底层怎么实现?(低频 ⭐⭐)

答:Redis 使用查表法 + variable-precision SWAR 算法(汉明重量),比逐 bit 统计快 8~64 倍。


2.6 Redisson 分布式锁

小白讲解

分布式锁的需求:
  多个服务实例(JVM)需要互斥访问共享资源
  → JVM 内部的 synchronized/ReentrantLock 不行(只锁一个 JVM)
  → 需要一个所有 JVM 都能看到的"全局锁"

Redis 实现分布式锁的演进:

1. SETNX(最原始):
   SETNX key value  → 设置成功表示获取锁
   问题:不会自动过期 → 忘记删除就死锁
   
2. SETNX + EXPIRE(两步):
   SETNX key value
   EXPIRE key 30
   问题:两步非原子,SETNX 后崩溃就没设过期 → 死锁
   
3. SET key value NX EX 30(原子):
   一条命令搞定设置 + 过期
   问题:锁被别人误删(A 超时自动释放 → B 获取 → A 回来删了 B 的锁)
   
4. value 设为唯一 ID,删锁时判断(Lua 保证原子):
   SET key requestId NX EX 30
   删除时:if GET key == requestId then DEL key  ← Lua 脚本保证原子
   问题:锁不会自动续期 → 业务执行超过 30 秒,锁自动释放 → 并发
   
5. Redisson(看门狗续期):
   ✅ 自动续期:看门狗每 10 秒检查持有锁的线程是否存活,存活就续期到 30 秒
   ✅ 可重入:同一线程可以多次获取锁(Hash 结构记录重入次数)
   ✅ 公平锁、读写锁、信号量等高级功能

代码示例

@Service
public class DistributedLockDemo {
    
    @Autowired
    private RedissonClient redissonClient;
    
    // 基本用法
    public void processWithLock(String resourceId) {
        RLock lock = redissonClient.getLock("lock:resource:" + resourceId);
        try {
            // 尝试加锁,等待 5 秒,锁自动过期 30 秒
            boolean acquired = lock.tryLock(5, 30, TimeUnit.SECONDS);
            if (acquired) {
                // 执行业务逻辑
                doBusinessLogic(resourceId);
            } else {
                // 获取锁失败,降级处理
                handleLockFailure(resourceId);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 必须在 finally 中释放锁
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
    
    // 看门狗机制:
    // 1. 如果不指定 leaseTime(或设为 -1),看门狗启动
    // 2. 看门狗每 10 秒(watchdogTimeout / 3)检查一次
    // 3. 如果持有锁的线程还活着,续期到 30 秒
    // 4. 如果 JVM 崩溃,看门狗不再续期,锁 30 秒后自动释放
    public void processWithWatchdog(String resourceId) {
        RLock lock = redissonClient.getLock("lock:resource:" + resourceId);
        try {
            // 不传 leaseTime → 启动看门狗
            lock.lock();  // 一直等到获取锁
            doBusinessLogic(resourceId);
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

面试题

Q1:Redisson 分布式锁的原理?(高频 ⭐⭐⭐⭐⭐)

答:

  1. 加锁:Lua 脚本原子操作。如果是重入锁(同一线程),Hash 结构的 key 记录重入次数 +1;如果是新锁,SET NX EX
  2. 看门狗续期:如果未指定 leaseTime,启动定时任务每 10 秒续期到 30 秒。JVM 崩溃后看门狗停止,锁自动过期释放
  3. 释放锁:Lua 脚本,重入次数 -1,减到 0 才删除 key 并发布解锁消息

Q2:Redis 分布式锁有什么隐患?(中频 ⭐⭐⭐⭐)

答:

  1. 主从切换丢锁:主节点加锁后还没同步到从节点就宕机,从节点提升为主后没有锁信息 → 并发
  2. 超时自动释放:业务执行超过锁过期时间,锁自动释放 → 需要看门狗续期
  3. Redlock 方案:向多个独立 Redis 实例同时加锁,多数成功才算获取锁(Redisson 支持,但争议较大)

Q3:简历中哪里用到了分布式锁?(高频 ⭐⭐⭐⭐)

答:

  • 项目四:秒杀扣减库存时用 Redisson 分布式锁防止超卖(配合 Lua 预减库存)
  • 项目一:资金清算操作加分布式锁(Skills 规则文件约束“资金类操作必须加锁”)
  • 缓存击穿:DCL 中用 Redisson 锁防止大量请求同时打数据库

2.7 多级缓存 Caffeine + Redis(简历直接写了)

小白讲解

多级缓存 = 本地缓存(L1)+ 分布式缓存(L2)+ 数据库

┌─────────────────────────────────────────────────┐
│                  请求进来                        │
│                    ↓                             │
│  L1: Caffeine(本地内存缓存)                     │
│  → 命中?返回 ✅(纳秒级,最快)                  │
│  → 未命中 ↓                                      │
│  L2: Redis(分布式缓存)                         │
│  → 命中?回填 L1 并返回 ✅(微秒级)              │
│  → 未命中 ↓                                      │
│  DB: MySQL                                      │
│  → 查到数据 → 回填 L2 + L1 并返回(毫秒级)      │
└─────────────────────────────────────────────────┘

为什么要多级?
  1. Caffeine 比 Redis 快 100 倍(纳秒 vs 微秒),减少网络 IO
  2. 减少 Redis 压力(L1 拦截大部分请求)
  3. Redis 挂了 L1 还能顶一会(降级)

Caffeine 的优势:
  - W-TinyLFU 算法:命中率比 LRU 高(解决了稀疏访问的缓存污染问题)
  - 异步刷新:不阻塞读请求
  - 支持大小限制(按容量/权重)

结合简历案例

简历原文:
  "设计 Caffeine L1 + Redis L2 多级缓存"

面试话术:
  "项目三零碳平台,设备拓扑图查询频繁(大屏每 3 秒刷新一次)。
   只用 Redis 的话每次都要网络 IO,大屏接口响应在 200ms 左右。
   
   我设计了 Caffeine + Redis 多级缓存:
   1. Caffeine L1:本地缓存设备拓扑数据,TTL 10 秒
      → 大屏刷新直接命中 L1,纳秒级返回
   2. Redis L2:L1 未命中查 Redis,TTL 5 分钟
      → 多个实例共享
   3. 数据库兜底:L2 未命中查 MySQL
   
   效果:大屏接口从 200ms 降到 20ms 以内(90% 请求命中 L1)。
   注意 L1 的 TTL 要短(10秒),否则多实例间数据不一致。"

代码示例

@Configuration
public class MultiLevelCacheConfig {
    
    // L1: Caffeine 本地缓存
    @Bean
    public Cache<String, DeviceTopology> caffeineCache() {
        return Caffeine.newBuilder()
            .maximumSize(1000)                    // 最多 1000 个条目
            .expireAfterWrite(10, TimeUnit.SECONDS) // 写入后 10 秒过期
            .recordStats()                         // 开启统计
            .build();
    }
}

@Service
public class DeviceTopologyService {
    
    @Autowired
    private Cache<String, DeviceTopology> caffeineCache;
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Autowired
    private DeviceTopologyMapper deviceTopologyMapper;
    
    private static final long REDIS_TTL = 5 * 60; // 5 分钟
    
    public DeviceTopology getTopology(Long deviceId) {
        String key = "device:topology:" + deviceId;
        
        // 1. L1: Caffeine
        DeviceTopology topology = caffeineCache.getIfPresent(key);
        if (topology != null) {
            return topology;  // 命中 L1,纳秒级
        }
        
        // 2. L2: Redis
        String json = redisTemplate.opsForValue().get(key);
        if (json != null) {
            topology = JSON.parseObject(json, DeviceTopology.class);
            caffeineCache.put(key, topology);  // 回填 L1
            return topology;  // 命中 L2,微秒级
        }
        
        // 3. DB
        topology = deviceTopologyMapper.selectTopologyWithChildren(deviceId);
        if (topology != null) {
            // 回填 L2 + L1
            redisTemplate.opsForValue().set(key, JSON.toJSONString(topology),
                REDIS_TTL + ThreadLocalRandom.current().nextInt(60), // 随机 TTL 防雪崩
                TimeUnit.SECONDS);
            caffeineCache.put(key, topology);
        }
        return topology;  // 毫秒级
    }
    
    // 缓存一致性:更新数据时删除 L1 + L2
    public void updateTopology(Long deviceId, DeviceTopology topology) {
        deviceTopologyMapper.updateById(deviceId, topology);
        String key = "device:topology:" + deviceId;
        caffeineCache.invalidate(key);     // 删 L1
        redisTemplate.delete(key);        // 删 L2
        // 下次读时从 DB 重新加载
    }
}

面试题

Q1:多级缓存的一致性怎么保证?(高频 ⭐⭐⭐⭐)

答:

  1. 更新时删除缓存(Cache Aside Pattern):先更新数据库 → 删 L1 → 删 L2。下次读时从 DB 重新加载
  2. L1 TTL 短(10 秒):即使某个实例没收到删除通知,10 秒后自动过期
  3. 消息通知:更新时发 MQ 消息,所有实例收到后删本地 L1(适用于强一致性要求高的场景)
  4. 最终一致性:多级缓存不追求强一致,通过 TTL + 删除策略实现最终一致

Q2:Caffeine 的 W-TinyLFU 算法比 LRU 好在哪?(中频 ⭐⭐⭐)

答:

  • LRU 的问题:偶尔访问一次的冷数据会把热数据挤出去(缓存污染)
  • W-TinyLFU:用 Count-Min Sketch 频率草图记录访问频率,结合 LRU → 新来的冷数据频率低,不会挤掉高频热数据 → 命中率比 LRU 高 20%+

Q3:为什么不直接用 Spring Cache?(中频 ⭐⭐⭐)

答:Spring Cache 是抽象接口,底层可以接 Caffeine/Redis。但多级缓存需要自定义逻辑(L1 未命中查 L2,L2 未命中查 DB,还要回填),Spring Cache 的 @Cacheable 不够灵活。手写多级缓存可以精确控制回填顺序和 TTL。


2.8 Redis 持久化

小白讲解

RDB(Redis Database):
  原理:定期把内存中的全部数据 dump 到磁盘(快照)
  触发:SAVE/BGSAVE 命令,或配置自动触发(如每 5 分钟有 100 个变更)
  优点:文件小,恢复快
  缺点:可能丢失最后一次快照后的数据

AOF(Append Only File):
  原理:把每条写命令追加到日志文件
  触发:always(每条)/ everysec(每秒,默认)/ no(由 OS)
  优点:数据丢失少(everysec 最多丢 1 秒)
  缺点:文件大,恢复慢

Redis 4.0+ 混合持久化:
  RDB 做全量 + AOF 做增量
  → 恢复时先加载 RDB(快),再重放 AOF 增量(补齐)
  → 兼顾恢复速度和数据安全

面试题

Q1:RDB 和 AOF 的区别?怎么选?(高频 ⭐⭐⭐⭐)

对比项 RDB AOF
原理 全量快照 命令日志
文件大小 小(压缩二进制) 大(文本命令)
恢复速度
数据安全 可能丢数据 最多丢 1 秒(everysec)
性能影响 BGSAVE 时 fork 子进程 每条写命令追加日志

答:生产推荐混合持久化(Redis 4.0+):RDB 全量 + AOF 增量。

Q2:AOF 文件太大了怎么办?(中频 ⭐⭐⭐)

答:AOF 重写(Rewrite)。Redis 遍历当前内存中的数据,重新生成最小命令集。比如对同一 key 操作了 100 次,重写后只保留最后一次的值。触发方式:auto-aof-rewrite-percentage 100(文件翻倍时触发)。


第三章:Oracle → GaussDB 信创迁移

简历项目一直接写了 Oracle→GaussDB 迁移,面试官会问“迁移遇到了什么坑”。


3.1 Oracle 基础

小白讲解

Oracle 和 MySQL 的主要差异:

1. 存储过程(PL/SQL)
   Oracle:PL/SQL 功能强大,常把业务逻辑写在存储过程中
   MySQL:存储过程功能弱,业务逻辑通常在应用层
   
   简历案例:项目一原 Oracle 存储过程中的资金拆分规则
   → 迁移时把 PL/SQL 转义为 Spark SQL

2. 数据类型差异
   Oracle NUMBER(18,2) → MySQL DECIMAL(18,2) / GaussDB NUMERIC(18,2)
   Oracle VARCHAR2 → MySQL VARCHAR / GaussDB VARCHAR
   Oracle DATE(含时间) → MySQL DATETIME / GaussDB TIMESTAMP
   Oracle CLOB → MySQL TEXT / GaussDB TEXT
   Oracle SEQUENCE → MySQL AUTO_INCREMENT / GaussDB SEQUENCE

3. 分页语法
   Oracle: SELECT * FROM (SELECT ROWNUM r, t.* FROM t WHERE ROWNUM <= 20) WHERE r > 10
   MySQL: SELECT * FROM t LIMIT 10, 10
   
4. 空值处理
   Oracle: '' 等价于 NULL
   MySQL: '' 和 NULL 是不同的
   → 迁移时 WHERE col = '' 在 Oracle 不匹配任何行,在 MySQL 可能匹配

面试题

Q1:Oracle 和 MySQL 的主要区别?(中频 ⭐⭐⭐)

答:

  1. 存储过程:Oracle PL/SQL 功能强大,MySQL 较弱
  2. 序列:Oracle 用 SEQUENCE,MySQL 用 AUTO_INCREMENT
  3. 分页:Oracle 用 ROWNUM,MySQL 用 LIMIT
  4. 事务隔离级别:Oracle 默认 RC,MySQL 默认 RR
  5. 数据类型:NUMBER/VARCHAR2/DATE vs DECIMAL/VARCHAR/DATETIME

Q2:简历中 Oracle 存储过程迁移遇到了什么问题?(高频 ⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

  • 原系统在 Oracle 存储过程中写了复杂的资金拆分规则(游标循环 + 临时表 + 条件分支)
  • 迁移到 GaussDB 时存储过程语法不完全兼容
  • 解决方案:不直接迁移存储过程,而是用 Spark SQL 重写拆分逻辑
    • 把 PL/SQL 的游标循环改为 Spark SQL 的窗口函数
    • 临时表改为 Spark DataFrame
    • 条件分支改为 CASE WHEN
  • 效果:从串行的存储过程变为并行的 Spark 计算,拆分耗时从 30+ 分钟降至 3 分钟

3.2 GaussDB 迁移实战

小白讲解

GaussDB(华为高斯数据库):
  分布式关系型数据库,支持 Oracle 兼容模式
  架构:CN(协调节点)+ DN(数据节点)
  
迁移中的三大坑:

1. 数据类型映射
   Oracle NUMBER(p,s) → GaussDB NUMERIC(p,s)
   Oracle VARCHAR2(n) → GaussDB VARCHAR(n)
   Oracle DATE → GaussDB TIMESTAMP
   → 需要逐表排查,特别是精度和长度

2. 隐式转换差异
   Oracle:'123' + 1 = 124(字符串自动转数字)
   GaussDB:'123' + 1 可能报错(取决于兼容模式)
   → 需要在应用层显式类型转换

3. 分布键设置
   GaussDB 是分布式数据库,数据按分布键散列到各 DN
   → 分布键选不好会导致数据倾斜(一个 DN 存 80% 数据)
   → 选择高基数列(如 instruction_no)做分布键
   → 避免用 status 这种低基数列(只有几个值)

结合简历案例

简历原文:
  "参与 Oracle→GaussDB 迁移,针对数据类型映射、隐式转换等差异
   专项排查,降低代码改造成本30%+;
   针对GaussDB分布式架构优化SQL分发与聚合逻辑,
   合理设置分布键避免数据倾斜"

面试话术:
  "信创改造要求 Oracle→GaussDB 迁移。我做了三件事:
   1. 数据类型映射:写了扫描脚本自动检测代码中的 Oracle 特有类型,
      生成映射清单(NUMBER→NUMERIC、VARCHAR2→VARCHAR 等),
      改造成本降低 30%+
   2. 隐式转换:Oracle 对字符串和数字混合运算有隐式转换,
      GaussDB 更严格。我在 MyBatis XML 中统一用 #{} 参数化,
      避免隐式转换问题
   3. 分布键:GaussDB 按分布键散列到各数据节点。
      我选 instruction_no(高基数列)做分布键,
      避免用 status(只有几个值)导致数据倾斜。
      同时对跨节点聚合的 SQL 用 ANALYZE 收集统计信息 + Hint 干预执行计划"

面试题

Q1:GaussDB 分布式架构是什么?分布键怎么选?(高频 ⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

  • GaussDB 用 CN(协调节点)+ DN(数据节点)架构
  • 数据按分布键的哈希值散列到各个 DN
  • 分布键选择原则
    1. 高基数列(值多,如 instruction_no)→ 数据均匀分布
    2. 避免低基数列(值少,如 status 只有 10 个值)→ 数据倾斜
    3. 常用 JOIN 列 → 减少跨节点 JOIN(同一分布键的数据在同一 DN)
    4. 避免用自增 ID(如果 ID 连续,可能集中在某个 DN)

Q2:数据倾斜怎么发现和解决?(中频 ⭐⭐⭐)

答:

  • 发现:查 pg_catalog.pgxc_class 看各 DN 的数据分布,或用 SELECT table_name, node_name, count(*) 统计
  • 解决
    1. 更换分布键(选高基数列)
    2. 加盐(在低基数列后面加随机数)
    3. 分区表(按时间分区,分散到不同物理文件)

第四章:PostgreSQL + pgvector(RAG 向量存储)

简历项目二直接写了 pgvector 余弦相似度检索,面试官会问“怎么存的、怎么查的”。


4.1 pgvector 基础

小白讲解

pgvector = PostgreSQL 的向量扩展

用途:存储文本的 Embedding 向量,支持相似度检索

RAG 流程中的位置:
  文档 → 分块 → Embedding(转成 1536 维向量)→ 存入 pgvector
  用户问题 → Embedding → 在 pgvector 中做余弦相似度检索 → Top-5 最相关分块

三种相似度计算:
  L2 距离(欧几里得):  ORDER BY embedding <-> '[...]'
  内积:               ORDER BY embedding <#> '[...]'
  余弦距离:            ORDER BY embedding <=> '[...]'  ← 简历用的这个

余弦相似度 vs 欧几里得距离:
  余弦:只看方向,不看长度 → 适合文本语义相似
  欧几里得:看绝对距离 → 适合数值特征

结合简历案例

-- 简历项目二的向量检索 SQL
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    content TEXT,                    -- 文档分块内容
    embedding VECTOR(1536),         -- Qwen Embedding 1536 维
    source VARCHAR(255),            -- 来源文件名
    chunk_index INT                 -- 分块序号
);

-- 创建 IVFFlat 向量索引(加速近似最近邻搜索)
CREATE INDEX idx_documents_embedding 
ON documents USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);  -- lists = sqrt(行数) 是经验值

-- 简历原文:"使用 pgvector 设计余弦相似度检索 SQL,Top-5 召回准确率达 85%+"
SELECT id, content, 1 - (embedding <=> '[0.1, 0.2, ...]') AS similarity
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'  -- 余弦距离升序
LIMIT 5;  -- Top-5

面试题

Q1:pgvector 的余弦相似度检索怎么实现?(高频 ⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

  1. 文档分块后用 Embedding API 转为 1536 维向量,存入 pgvector
  2. 用户问题也转为向量
  3. <=> 操作符计算余弦距离,ORDER BY 升序排序
  4. LIMIT 5 取最相似的 5 个分块
  5. 创建 IVFFlat 索引加速检索(近似最近邻,不精确但快)

Q2:IVFFlat 索引是什么?为什么不用精确检索?(中频 ⭐⭐⭐)

答:

  • IVFFlat = Inverted File with Flat Compression,近似最近邻(ANN)索引
  • 把向量空间划分为 N 个聚类(lists),查询时只搜索最近的几个聚类
  • 不用精确检索的原因:1536 维向量的精确最近邻是 O(N) 暴力扫描,数据量大时太慢
  • IVFFlat 用空间换时间,牺牲一点精度换取几十倍速度
  • 简历经验:Top-5 召回准确率 85%+,用 IVFFlat 索引

Q3:为什么选 pgvector 不用专门的向量数据库(Milvus/Pinecone)?(中频 ⭐⭐⭐)

答:

  1. PostgreSQL 已有基础设施,不需要额外部署维护向量数据库
  2. pgvector 支持向量 + 结构化数据混合查询(WHERE + 向量检索)
  3. 数据量不大(万级文档)时 pgvector 性能足够
  4. Milvus 适合百万/亿级向量场景,项目数据量没到那个量级

第五章:TDengine 时序数据库

简历项目三直接写了“TDengine 替代 MySQL 存储设备数据,写入性能提升10倍,存储成本降低60%”。


5.1 TDengine 基础

小白讲解

TDengine = 专为物联网时序数据设计的数据库

时序数据的特点:
  - 写多读少(设备每秒上报数据)
  - 数据按时间排序
  - 很少更新/删除
  - 过期数据可以降采样

TDengine 为什么比 MySQL 快?
  1. 列式存储:只读需要的列,减少 IO
  2. 时序优化的存储引擎:按时间分区,旧数据压缩率高
  3. 零 JVM 开销:C 语言实现
  4. 自动建表:写入时自动创建表(超级表 + 子表)

超级表和子表:
  超级表(STable):模板,定义列和标签
  子表(Table):每个设备一张子表,继承超级表结构
  标签(Tag):设备的静态属性(如设备ID、型号),用于过滤

结合简历案例

简历原文:
  "主导引入 TDengine 替代 MySQL 存储设备高频上报数据,
   写入性能提升10倍,存储成本降低60%"

面试话术:
  "项目三零碳平台有 50+ 新能源站点,每个站点几百台设备,
   每秒上报一次运行数据(电压、电流、功率等 20+ 指标)。
   
   原来用 MySQL 存:
   - 每天产生 5000 万行数据,MySQL 单表很快撑不住
   - 写入 QPS 高(5000+),MySQL 行锁竞争严重
   - 存储成本高(一行 20 个指标,大量冗余)
   
   迁移到 TDengine 后:
   1. 每个设备一张子表(超级表 device_metrics + 标签 device_id)
   2. 列式存储 + 压缩:只存数值,20 个指标的存储成本降 60%
   3. 写入 10 倍提升:TDengine 的追加写入没有行锁开销
   4. 时序查询快:按设备 ID + 时间范围查询,直接定位到子表"

面试题

Q1:为什么用 TDengine 不用 MySQL 存设备数据?(高频 ⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

  1. 写入性能:设备高频上报(每秒一次),MySQL 行锁竞争严重;TDengine 追加写入无锁,性能 10 倍
  2. 存储成本:TDengine 列式存储 + 时序压缩,存储成本降 60%
  3. 时序查询:TDengine 按时间分区 + 设备子表,时间范围查询直接定位;MySQL 全表扫描
  4. 降采样:TDengine 原生支持连续查询(Continuous Query),自动聚合历史数据

Q2:TDengine 的超级表和子表是什么?(中频 ⭐⭐⭐)

答:

  • 超级表(STable):模板,定义数据列 + 标签列(Tag)
  • 子表:每个设备创建一张子表,继承超级表结构,标签值不同
  • 好处:表结构统一管理;查询时按标签过滤(WHERE device_id = ‘xxx’)自动路由到子表

第六章:Apache Doris OLAP 引擎

简历项目三直接写了“针对 Doris OLAP 设计指标聚合预计算,大屏接口响应从秒级降至 200ms”。


6.1 Doris 基础

小白讲解

Doris = MPP 架构的 OLAP 分析型数据库

OLTP vs OLAP:
  OLTP(MySQL):事务处理,高并发读写少量行,适合业务系统
  OLAP(Doris):分析处理,低并发批量扫描大量行,适合报表统计

Doris 的核心概念:
  1. FE(Frontend):负责查询解析、规划、调度
  2. BE(Backend):负责数据存储和查询执行
  3. 物化视图(Materialized View):预计算聚合结果

为什么用 Doris 做指标聚合?
  MySQL 做大屏统计:SELECT COUNT(*), SUM(power) FROM device_metrics WHERE biz_date = '2026-01-01'
  → 5000 万行扫描,秒级返回
  
  Doris 做同样查询:
  → 列式存储只读需要的列
  → 物化视图已经预聚合了 COUNT 和 SUM
  → 直接返回预计算结果,200ms 以内

结合简历案例

简历原文:
  "针对 Doris OLAP 设计指标聚合预计算,
   大屏接口响应从秒级降至 200ms 以内"

面试话术:
  "项目三大屏要展示当日发电量、设备在线数、告警数等指标。
   原来直接查 MySQL,5000 万行数据做 COUNT + SUM 聚合,接口耗时 3~5 秒。
   
   我把指标数据同步到 Doris,设计了两层优化:
   1. 物化视图:按 biz_date + site_id 预聚合 COUNT/SUM
      CREATE MATERIALIZED VIEW mv_daily_metrics AS
      SELECT biz_date, site_id, COUNT(*) as device_count, SUM(power) as total_power
      FROM device_metrics GROUP BY biz_date, site_id;
   2. 查询时 Doris 自动路由到物化视图,直接返回预计算结果
   
   效果:大屏接口从 3~5 秒降到 200ms 以内。"

面试题

Q1:为什么用 Doris 不用 MySQL 做大屏统计?(高频 ⭐⭐⭐⭐)

简历直接写了,必须会答!

答:

  1. 列式存储:Doris 只读需要的列,MySQL 行式存储要读整行
  2. MPP 并行:Doris 多 BE 节点并行扫描和聚合,MySQL 单机串行
  3. 物化视图:Doris 预计算聚合结果,查询时直接返回;MySQL 每次都要实时计算
  4. 向量化执行:Doris 用 SIMD 指令批量处理数据,CPU 利用率高

Q2:物化视图是什么?和普通视图有什么区别?(中频 ⭐⭐⭐)

答:

  • 普通视图:虚拟表,只存 SQL,查询时实时执行
  • 物化视图:实体表,预计算并存储聚合结果,查询时自动路由到物化视图
  • 好处:查询快(直接返回预计算结果),代价是写入时需要维护物化视图(增量更新)

面试速查表(数据库与缓存)

MySQL(100% 会问)

考点 一句话答案
B+ 树 vs B 树 B+ 树非叶子不存数据,范围查询快,树更矮
聚簇索引 数据按主键存叶子节点,一张表只有一个
回表 二级索引拿到主键→再查聚簇索引,多一次 IO
覆盖索引 查询列全在索引中,不回表
最左前缀 联合索引必须从最左列开始用
MVCC 读快照不加锁,读写不阻塞
隔离级别 读未提交/读已提交/可重复读/串行化
N+1 问题 1+N 次查询→@BatchSize 或 JOIN
分库分表 单表千万级→水平拆分

Redis(100% 会问)

考点 一句话答案
跳表 有序链表+多层索引,空间换时间,查找 O(logN)
跳表层高 随机生成(p=0.25,最高64层),期望 1.33 层
ZSet 双结构 跳表管排序 + 哈希表管 O(1) 查 score
为什么不用红黑树 实现简单/范围查询天然/无需旋转/并发友好
SDS 二进制安全 + O(1) 长度 + 预分配
渐进式 rehash 边干活边搬桶,避免单线程阻塞
雪崩 大量 key 同时过期→随机 TTL+多级缓存
穿透 查不存在数据→缓存空值+布隆过滤器
击穿 热点 key 过期→DCL+热点 key 打散
Lua 预减库存 原子操作防超卖,MQ 异步下单
Sorted Set Feed ZADD 存入 ZREVRANGE 按时间取
Bitmap 去重 1 亿用户 12MB,GETBIT O(1)
分布式锁 Redisson 看门狗续期
多级缓存 Caffeine L1 + Redis L2
持久化 RDB 快照/AOF 日志,混合最优

Oracle/GaussDB 迁移(简历项目一必问)

考点 一句话答案
存储过程迁移 PL/SQL → Spark SQL 并行计算
数据类型映射 NUMBER→NUMERIC, VARCHAR2→VARCHAR
隐式转换差异 Oracle 自动转,GaussDB 严格
分布键 选高基数列(instruction_no)避免倾斜

pgvector(简历项目二必问)

考点 一句话答案
余弦相似度 embedding <=> 操作符,只看方向不看长度
IVFFlat 索引 近似最近邻,牺牲精度换速度
Top-5 检索 ORDER BY 余弦距离 LIMIT 5

TDengine(简历项目三必问)

考点 一句话答案
为什么不用 MySQL 写入 10 倍 + 存储降 60% + 时序优化
超级表/子表 模板+每设备一张子表,标签过滤路由

Doris(简历项目三必问)

考点 一句话答案
为什么不用 MySQL 列存+MPP+物化视图,大屏 200ms
物化视图 预聚合实体表,查询自动路由

最后更新:2026-08-19