数据库与缓存 — 小白讲解 + 面试题精解
数据库与缓存 — 小白讲解 + 面试题精解
定位:假设你只会写 SQL CRUD,所有概念从零讲起,配代码示例 + 面试题。 覆盖:MySQL → Redis → Oracle/GaussDB 迁移 → PostgreSQL+pgvector → TDengine → Doris 六大模块。 用法:先读讲解理解概念 → 跑代码验证 → 再看面试题自测。 特色:每个知识点都结合简历真实项目案例,面试时能直接用。
目录
- 第一章:MySQL 深入(面试 100% 会问)
- 第二章:Redis 实战(简历核心亮点)
- 第三章:Oracle → GaussDB 信创迁移
- 第四章:PostgreSQL + pgvector(RAG 向量存储)
- 第五章:TDengine 时序数据库
- 第六章:Apache Doris OLAP 引擎
第一章: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 的区别?(高频 ⭐⭐⭐⭐)
答:核心区别三点:
- 事务:InnoDB 支持事务,MyISAM 不支持
- 锁:InnoDB 行锁(高并发写性能好),MyISAM 表锁(写时全表阻塞)
- 索引:InnoDB 聚簇索引(数据存叶子节点),MyISAM 非聚簇(数据和索引分离)
- 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 树或红黑树?(超高频 ⭐⭐⭐⭐⭐)
答:
- B+ 树比 B 树矮:非叶子节点不存数据,一个节点能存更多键,树更矮,IO 更少
- B+ 树范围查询快:叶子节点双向链表连接,范围查询只需顺序扫描叶子节点;B 树要中序遍历
- 比红黑树矮得多: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:联合索引的字段顺序怎么设计?(高频 ⭐⭐⭐⭐)
答:三个原则:
- 等值查询放前面,范围查询放后面(范围查询会截断后面的索引列)
- 区分度高的放前面(过滤更多数据,减少回表)
- 高频查询字段放前面(覆盖更多查询场景)
简历案例: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 中哪些列最重要?(高频 ⭐⭐⭐⭐)
答:重点关注三列:
- type:访问类型,ALL 表示全表扫描必须优化,ref 及以上可以接受
- key:实际使用的索引,NULL 表示没走索引
- Extra:Using index 表示覆盖索引(最好),Using filesort 或 Using temporary 表示需要优化
Q2:type 为 ALL 怎么优化?(高频 ⭐⭐⭐⭐)
答:
- 检查 WHERE 条件是否有索引覆盖
- 如果没有索引,创建合适的索引(联合索引遵循最左前缀原则)
- 如果有索引但没走,检查是否违反最左前缀、是否有函数操作、是否有隐式类型转换
- 尽量用覆盖索引避免回表
Q3:Using filesort 是什么?怎么优化?(中频 ⭐⭐⭐)
答:MySQL 在排序时无法利用索引的有序性,需要额外排序。优化方法:
- 给 ORDER BY 的列加索引
- 让 WHERE 和 ORDER BY 使用同一个联合索引
- 减少 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:分库分表后有哪些问题?(高频 ⭐⭐⭐⭐)
答:
- 跨库 JOIN:不能直接 JOIN,需要应用层组装或冗余字段
- 分布式事务:跨库操作需要分布式事务(Seata / 消息事务)
- 全局唯一 ID:不能用自增 ID,用雪花算法或号段模式
- 跨库分页:limit 10, 20 需要每个分表查 30 条再合并,性能差
- 路由复杂:查询条件必须包含分片键,否则全表扫描
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?(高频 ⭐⭐⭐⭐⭐)
答:四步:
- 开启慢查询日志,收集慢 SQL
- EXPLAIN 分析执行计划,看 type(是否全表扫描)、key(是否走索引)、rows(扫描行数)、Extra(是否回表/排序/临时表)
- 对症优化:全表扫描→加索引;回表→覆盖索引;排序→索引排序;N+1→@BatchSize
- 简历经验:重构复合索引 + @BatchSize + 覆盖索引,核心接口秒级→毫秒级
Q2:SQL 语句哪些写法会导致索引失效?(高频 ⭐⭐⭐⭐⭐)
答:
- 函数操作索引列:
WHERE YEAR(create_time) = 2026→ 改为WHERE create_time >= '2026-01-01' AND create_time < '2026-01-02' - 隐式类型转换:
WHERE instruction_no = 123456(字段是 VARCHAR,传了 INT)→ MySQL 会转成数字比较,不走索引 - LIKE 以 % 开头:
WHERE instruction_no LIKE '%ABC'→ 不走索引;LIKE 'ABC%'→ 走索引 - OR 连接非索引列:
WHERE indexed_col = 1 OR non_indexed_col = 2→ 全表扫描 - NOT IN / != / <>:通常不走索引(取决于数据量)
- 计算操作:
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 底层为什么用跳表不用红黑树?(高频 ⭐⭐⭐⭐⭐)
答:
- 实现简单:跳表约 200 行,红黑树 500+ 行且旋转染色复杂
- 范围查询天然高效:跳表第 0 层是有序链表,找到起点直接遍历;红黑树要中序遍历
- 插入删除无平衡调整:跳表只改局部指针;红黑树可能触发整条路径旋转
- 并发友好:跳表可局部加锁;红黑树旋转影响范围大
- 配合哈希表:跳表管“按 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 字符串有什么优势?(中频 ⭐⭐⭐)
答:
- O(1) 取长度(len 字段),C 字符串要 O(N) 遍历
- 二进制安全(用 len 而非 \0 判断结束),能存二进制数据
- 预分配 + 惰性释放,减少频繁扩容缩容的内存重分配
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:预减库存后数据库怎么保证最终一致性?(高频 ⭐⭐⭐⭐)
答:
- Redis 扣减成功 → 发 MQ 消息(包含 activityId + userId)
- 消费者收到消息 → 在数据库中创建订单 + 扣减数据库库存
- 如果 MQ 发送失败 → 回补 Redis 库存(INCR)
- 消费失败 → 重试机制,死信队列兜底
- 对账机制:定时任务对比 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 流?(中频 ⭐⭐⭐)
答:
- 自动排序:ZSet 按 score(时间戳)排序,不需要应用层排序
- 范围查询快:ZREVRANGE O(logN + M),取最新 N 条极快
- 去重:同一帖子不会被重复推入(元素唯一)
- 淘汰旧数据: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 分布式锁的原理?(高频 ⭐⭐⭐⭐⭐)
答:
- 加锁:Lua 脚本原子操作。如果是重入锁(同一线程),Hash 结构的 key 记录重入次数 +1;如果是新锁,SET NX EX
- 看门狗续期:如果未指定 leaseTime,启动定时任务每 10 秒续期到 30 秒。JVM 崩溃后看门狗停止,锁自动过期释放
- 释放锁:Lua 脚本,重入次数 -1,减到 0 才删除 key 并发布解锁消息
Q2:Redis 分布式锁有什么隐患?(中频 ⭐⭐⭐⭐)
答:
- 主从切换丢锁:主节点加锁后还没同步到从节点就宕机,从节点提升为主后没有锁信息 → 并发
- 超时自动释放:业务执行超过锁过期时间,锁自动释放 → 需要看门狗续期
- 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:多级缓存的一致性怎么保证?(高频 ⭐⭐⭐⭐)
答:
- 更新时删除缓存(Cache Aside Pattern):先更新数据库 → 删 L1 → 删 L2。下次读时从 DB 重新加载
- L1 TTL 短(10 秒):即使某个实例没收到删除通知,10 秒后自动过期
- 消息通知:更新时发 MQ 消息,所有实例收到后删本地 L1(适用于强一致性要求高的场景)
- 最终一致性:多级缓存不追求强一致,通过 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 的主要区别?(中频 ⭐⭐⭐)
答:
- 存储过程:Oracle PL/SQL 功能强大,MySQL 较弱
- 序列:Oracle 用 SEQUENCE,MySQL 用 AUTO_INCREMENT
- 分页:Oracle 用 ROWNUM,MySQL 用 LIMIT
- 事务隔离级别:Oracle 默认 RC,MySQL 默认 RR
- 数据类型: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
- 分布键选择原则:
- 高基数列(值多,如 instruction_no)→ 数据均匀分布
- 避免低基数列(值少,如 status 只有 10 个值)→ 数据倾斜
- 常用 JOIN 列 → 减少跨节点 JOIN(同一分布键的数据在同一 DN)
- 避免用自增 ID(如果 ID 连续,可能集中在某个 DN)
Q2:数据倾斜怎么发现和解决?(中频 ⭐⭐⭐)
答:
- 发现:查
pg_catalog.pgxc_class看各 DN 的数据分布,或用SELECT table_name, node_name, count(*)统计 - 解决:
- 更换分布键(选高基数列)
- 加盐(在低基数列后面加随机数)
- 分区表(按时间分区,分散到不同物理文件)
第四章: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 的余弦相似度检索怎么实现?(高频 ⭐⭐⭐⭐)
简历直接写了,必须会答!
答:
- 文档分块后用 Embedding API 转为 1536 维向量,存入 pgvector
- 用户问题也转为向量
- 用
<=>操作符计算余弦距离,ORDER BY 升序排序 - LIMIT 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)?(中频 ⭐⭐⭐)
答:
- PostgreSQL 已有基础设施,不需要额外部署维护向量数据库
- pgvector 支持向量 + 结构化数据混合查询(WHERE + 向量检索)
- 数据量不大(万级文档)时 pgvector 性能足够
- 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 存设备数据?(高频 ⭐⭐⭐⭐)
简历直接写了,必须会答!
答:
- 写入性能:设备高频上报(每秒一次),MySQL 行锁竞争严重;TDengine 追加写入无锁,性能 10 倍
- 存储成本:TDengine 列式存储 + 时序压缩,存储成本降 60%
- 时序查询:TDengine 按时间分区 + 设备子表,时间范围查询直接定位;MySQL 全表扫描
- 降采样: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 做大屏统计?(高频 ⭐⭐⭐⭐)
简历直接写了,必须会答!
答:
- 列式存储:Doris 只读需要的列,MySQL 行式存储要读整行
- MPP 并行:Doris 多 BE 节点并行扫描和聚合,MySQL 单机串行
- 物化视图:Doris 预计算聚合结果,查询时直接返回;MySQL 每次都要实时计算
- 向量化执行: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