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

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

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


📖 本文档名词速查(看到不认识的缩写,先查这里)

完整的白话解释、生活类比、面试话术见 00-名词速查手册.md。 下面只列本文档用到的名词,按出现频率排序。

缩写 / 术语 英文全称 一句话说明 出处
回表 — 二级索引没查全,得再回主键索引查一次 第5章
MVCC Multi-Version Concurrency Control 多版本并发控制:同一行数据保留多个版本,读不加锁 第5章
向量 Vector 一组数字,表示文本在语义空间中的位置 第11章
覆盖索引 Covering Index 索引里就有要查的所有字段,不用回表 第5章
TTL Time To Live 数据的“保质期”,到期自动删除 第3章
Caffeine Caffeine Java 里性能最好的本地缓存库(Spring 默认用它) 第3章
多级缓存 Multi-Level Cache 本地缓存(Caffeine)+ 分布式缓存(Redis)两层 第3章
分布式锁 Distributed Lock 跨 JVM 的锁,保证多个实例间互斥 第7章
分库分表 Sharding 把数据拆到多个库/表,突破单机瓶颈 第5章
最左前缀 Leftmost Prefix 联合索引必须从最左边开始匹配才生效 第5章
幻读 Phantom Read 同一事务两次范围查询,行数变了(别人插了新行) 第5章
看门狗 Watchdog Redisson 的锁自动续期机制 第7章
MQ Message Queue 消息队列,存消息的“中转站”,让两个服务不必同时在线 第2章
Redisson Redisson Redis 的 Java 客户端,封装了分布式锁等高级功能 第7章
JVM Java Virtual Machine Java 虚拟机,让 Java 代码“一次编译到处运行”的那个东西 第4章
undo log Undo Log(回滚日志) 记录修改前的值,用于回滚和 MVCC 读历史版本 第5章
缓存击穿 Cache Breakdown 一个热点 key 恰好过期,瞬间大量请求打到库 第3章
布隆过滤器 Bloom Filter 极省内存的“可能存在”判断器:能确定“一定没有”,但“有”可能是误判 第15章
Spark Apache Spark 分布式计算引擎,把数据切成小块分给多台机器同时算 第15章
LRU Least Recently Used 淘汰最久没被用过的数据 第3章
Embedding Embedding(嵌入/向量化) 把文本转成一组数字(向量),让计算机能算“相似度” 第11章
W-TinyLFU Window Tiny Least Frequently Used Caffeine 用的算法,LRU 和 LFU 的混合增强版 第3章
redo log Redo Log(重做日志) InnoDB 的日志,崩溃后用它恢复已提交的数据 第5章
QPS Queries Per Second 每秒请求数(系统能扛多少流量) 第8章
RAG Retrieval-Augmented Generation 检索增强生成:先查资料,再让 AI 基于资料回答 第11章
binlog Binary Log(二进制日志) MySQL Server 层的日志,用于主从复制和数据恢复 第5章
MyBatis-Plus MyBatis-Plus MyBatis 的增强工具,不用写简单 SQL 第5章
JSON JavaScript Object Notation 轻量级数据交换格式({} 和 []) 第13章
XML eXtensible Markup Language 可扩展标记语言(老式配置/数据格式) 第13章
HBase Hadoop Database 分布式的列族数据库,适合海量数据的随机读写 第15章
💡 为什么要有这张表?

技术文档最大的阅读障碍不是原理难,而是缩写不认识 —— 看到“2PC 在数据库层加锁” 这句话,如果不知道 2PC 是什么,整段就废了。

这张表保证你在任何位置遇到不认识的词,都能当场查到,不用跳出文档。 想知道“为什么是这样”“面试怎么答”,再点进手册看完整版。


目录


第一章: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