08-数据库面试高频题
本章导读
数据库是后端开发的核心技术之一,面试中几乎必问。本章涵盖MySQL、Redis、Kafka等主流数据库技术,包含60+道高频面试题,从基础原理到实战场景,帮助读者系统掌握数据库知识体系。
主要内容:
- MySQL索引原理与优化
- 事务与并发控制
- Redis缓存设计与实战
- Kafka消息队列架构
第一部分:MySQL索引专题
1. MySQL索引的底层数据结构是什么?为什么使用B+树?
考察点: 数据结构原理、索引设计思想
详细解答:
MySQL InnoDB引擎使用B+树作为索引的底层数据结构。
B+树的特点:
所有数据存储在叶子节点
- 非叶子节点只存储键值用于索引
- 叶子节点包含完整的数据记录
- 叶子节点通过双向链表连接
多路平衡树结构
- 每个节点可以有多个子节点(通常几百到上千)
- 树的高度很低,一般3-4层可存储千万级数据
- 所有叶子节点在同一层
为什么选择B+树:
| 对比项 | B+树 | 二叉树 | 哈希表 | B树 |
|---|---|---|---|---|
| 查询复杂度 | O(logn) | O(logn)~O(n) | O(1) | O(logn) |
| 范围查询 | 优秀 | 差 | 不支持 | 较好 |
| 磁盘IO次数 | 少(3-4次) | 多 | 1次 | 中等 |
| 有序性 | 支持 | 支持 | 不支持 | 支持 |
具体原因:
1. 磁盘IO优化
- B+树节点可存储更多键值(非叶子节点不存数据)
- 树高度降低,减少磁盘IO次数
- 假设节点16KB,每个键值8B,可存储约2000个键
- 3层B+树可索引:2000 × 2000 × 2000 = 80亿条记录
2. 范围查询优化
- 叶子节点链表结构,范围查询只需遍历链表
- B树需要多次回溯到父节点
3. 查询性能稳定
- 所有查询都到达叶子节点,性能稳定
- B树可能在非叶子节点就找到数据,性能不稳定
4. 支持顺序访问
- 叶子节点双向链表支持正序和倒序遍历
- 适合ORDER BY查询
实际案例:
-- 假设有1000万用户数据
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
INDEX idx_age (age)
);
-- 查询某个年龄段的用户
SELECT * FROM users WHERE age BETWEEN 20 AND 30;
-- B+树优势:
-- 1. 通过索引快速定位到age=20的叶子节点
-- 2. 沿着叶子节点链表顺序扫描到age=30
-- 3. 如果使用B树,需要多次回溯父节点,效率低
2. 聚簇索引和非聚簇索引的区别?
考察点: InnoDB存储引擎特性
详细解答:
聚簇索引(Clustered Index):
定义:索引和数据存储在一起,叶子节点存储完整的行数据
特点:
1. InnoDB中主键索引就是聚簇索引
2. 每个表只能有一个聚簇索引
3. 数据行的物理顺序与索引顺序一致
4. 查询时不需要回表,直接返回数据
非聚簇索引(Secondary Index):
定义:索引和数据分离存储,叶子节点存储主键值
特点:
1. 除主键外的其他索引都是非聚簇索引
2. 一个表可以有多个非聚簇索引
3. 查询时需要回表(先查到主键,再通过主键查数据)
4. 叶子节点存储:索引列值 + 主键值
示例对比:
CREATE TABLE employees (
id INT PRIMARY KEY, -- 聚簇索引
name VARCHAR(50),
email VARCHAR(100),
dept_id INT,
INDEX idx_email (email), -- 非聚簇索引
INDEX idx_dept (dept_id) -- 非聚簇索引
);
INSERT INTO employees VALUES
(1, 'Alice', 'alice@example.com', 10),
(2, 'Bob', 'bob@example.com', 20),
(3, 'Charlie', 'charlie@example.com', 10);
查询过程对比:
-- 场景1:通过主键查询(使用聚簇索引)
SELECT * FROM employees WHERE id = 2;
-- 执行过程:
-- 1. 在聚簇索引B+树中查找id=2
-- 2. 在叶子节点直接获取完整行数据
-- 磁盘IO:1次(只访问聚簇索引)
-- 场景2:通过普通索引查询(使用非聚簇索引)
SELECT * FROM employees WHERE email = 'bob@example.com';
-- 执行过程:
-- 1. 在idx_email索引树中查找email='bob@example.com'
-- 2. 在叶子节点获取主键id=2
-- 3. 拿着id=2回到聚簇索引树中查找完整数据
-- 磁盘IO:2次(访问非聚簇索引 + 访问聚簇索引)
-- 这个过程称为"回表"
-- 场景3:覆盖索引(无需回表)
SELECT id, email FROM employees WHERE email = 'bob@example.com';
-- 执行过程:
-- 1. 在idx_email索引树中查找email='bob@example.com'
-- 2. 叶子节点包含了id和email,直接返回
-- 磁盘IO:1次(只访问非聚簇索引)
-- 这个过程称为"覆盖索引"
存储结构对比:
聚簇索引(主键id)存储结构:
节点内容:[id, name, email, dept_id]
1 → [1, 'Alice', 'alice@example.com', 10]
2 → [2, 'Bob', 'bob@example.com', 20]
3 → [3, 'Charlie', 'charlie@example.com', 10]
非聚簇索引(idx_email)存储结构:
节点内容:[email, id]
'alice@example.com' → 1
'bob@example.com' → 2
'charlie@example.com' → 3
性能影响:
-- 优化建议1:使用覆盖索引避免回表
-- 不好的写法
SELECT * FROM employees WHERE dept_id = 10;
-- 改进写法(如果只需要部分字段)
ALTER TABLE employees ADD INDEX idx_dept_name (dept_id, name);
SELECT id, name FROM employees WHERE dept_id = 10;
-- 索引idx_dept_name包含了dept_id、name、id(自动包含主键)
-- 无需回表
-- 优化建议2:主键尽量使用自增ID
-- 不好的设计
CREATE TABLE orders (
order_no VARCHAR(50) PRIMARY KEY, -- UUID或业务编号
...
);
-- 问题:
-- 1. 字符串比整数占用空间大
-- 2. 非聚簇索引叶子节点存储的主键值变大
-- 3. 页分裂频繁(UUID无序)
-- 好的设计
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(50) UNIQUE,
...
);
-- 优势:
-- 1. 整数主键占用空间小
-- 2. 自增保证数据插入顺序,减少页分裂
-- 3. 非聚簇索引更紧凑
3. 联合索引的最左前缀原则是什么?
考察点: 索引优化、查询优化
详细解答:
最左前缀原则定义:
联合索引遵循最左前缀匹配原则,即查询条件必须从索引的最左列开始,并且不能跳过中间的列。
示例说明:
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
city VARCHAR(50),
INDEX idx_name_age_city (name, age, city)
);
-- 联合索引(name, age, city)的B+树结构:
-- 先按name排序,name相同再按age排序,age相同再按city排序
能使用索引的情况:
-- 1. 使用name(最左列)
SELECT * FROM users WHERE name = 'Alice';
-- 2. 使用name, age(从最左开始的连续列)
SELECT * FROM users WHERE name = 'Alice' AND age = 25;
-- 3. 使用name, age, city(完整索引)
SELECT * FROM users WHERE name = 'Alice' AND age = 25 AND city = 'Beijing';
-- 4. 使用name, city(跳过age但name是最左列)
-- 注意:只有name列会走索引,city不会
SELECT * FROM users WHERE name = 'Alice' AND city = 'Beijing';
-- 5. 顺序无关(优化器会调整)
SELECT * FROM users WHERE age = 25 AND name = 'Alice';
-- MySQL优化器会调整为:name = 'Alice' AND age = 25
不能使用索引的情况:
-- 1. 只使用age(不是最左列) ✗
SELECT * FROM users WHERE age = 25;
-- 2. 只使用city(不是最左列) ✗
SELECT * FROM users WHERE city = 'Beijing';
-- 3. 使用age, city(跳过了最左列name) ✗
SELECT * FROM users WHERE age = 25 AND city = 'Beijing';
-- 4. name使用范围查询后,后面的列无法使用索引 ✗(部分)
SELECT * FROM users WHERE name LIKE 'A%' AND age = 25;
-- name可以使用索引,但age无法使用索引
特殊情况分析:
-- 情况1:范围查询的影响
-- 索引:(name, age, city)
-- 查询1:name精确匹配,age范围查询
SELECT * FROM users WHERE name = 'Alice' AND age > 20 AND city = 'Beijing';
-- 索引使用情况:
-- name = 'Alice' 使用索引
-- age > 20 使用索引
-- ✗ city = 'Beijing' 不使用索引(age是范围查询,中断了后续列)
-- 查询2:name范围查询
SELECT * FROM users WHERE name > 'Alice' AND age = 25;
-- 索引使用情况:
-- name > 'Alice' 使用索引
-- ✗ age = 25 不使用索引
-- 情况2:函数操作
-- 索引:(name, age, city)
-- 不使用索引
SELECT * FROM users WHERE UPPER(name) = 'ALICE';
-- 使用索引
SELECT * FROM users WHERE name = 'Alice';
-- 情况3:隐式类型转换
-- 假设name是VARCHAR类型
-- 不使用索引(字符串列与数字比较)
SELECT * FROM users WHERE name = 123;
-- MySQL会将name转为数字:CAST(name AS SIGNED) = 123
-- 使用索引
SELECT * FROM users WHERE name = '123';
实际优化案例:
-- 场景:用户查询接口,支持多种筛选条件
-- 原始查询(性能差)
SELECT * FROM users
WHERE city = 'Beijing'
AND age BETWEEN 20 AND 30
AND name LIKE 'A%';
-- 分析:
-- 如果索引是(name, age, city),查询条件顺序不匹配
-- 优化方案1:调整索引顺序
-- 根据业务场景,假设city筛选度最高,age次之,name最低
ALTER TABLE users DROP INDEX idx_name_age_city;
ALTER TABLE users ADD INDEX idx_city_age_name (city, age, name);
-- 优化后的查询
SELECT * FROM users
WHERE city = 'Beijing'
AND age BETWEEN 20 AND 30
AND name LIKE 'A%';
-- 索引使用:city精确匹配 , age范围查询 , name无法使用 ✗
-- 优化方案2:创建多个索引(适合不同查询场景)
ALTER TABLE users ADD INDEX idx_city_age (city, age);
ALTER TABLE users ADD INDEX idx_name (name);
ALTER TABLE users ADD INDEX idx_age (age);
-- 不同查询场景使用不同索引
-- 场景1:按城市和年龄查询
SELECT * FROM users WHERE city = 'Beijing' AND age = 25;
-- 使用idx_city_age
-- 场景2:按姓名查询
SELECT * FROM users WHERE name = 'Alice';
-- 使用idx_name
-- 场景3:按年龄查询
SELECT * FROM users WHERE age = 25;
-- 使用idx_age
最左前缀原则的本质:
B+树索引的排序方式决定了最左前缀原则
假设索引(name, age, city),数据如下:
('Alice', 20, 'Beijing')
('Alice', 25, 'Shanghai')
('Alice', 30, 'Beijing')
('Bob', 20, 'Beijing')
('Bob', 25, 'Shanghai')
('Charlie', 30, 'Beijing')
在B+树中的排序:
1. 首先按name排序:Alice < Bob < Charlie
2. name相同时按age排序:20 < 25 < 30
3. name和age都相同时按city排序
因此:
- 查询name='Alice':可以快速定位到Alice开头的区间
- 查询age=25:无法利用索引,因为age在全局不是有序的
- 查询name='Alice' AND age=25:先定位name='Alice'区间,再在该区间找age=25
总结建议:
索引列顺序设计原则:
- 区分度高的列放前面
- 常用查询条件的列放前面
- 范围查询的列放后面
查询优化技巧:
- 尽量使用索引的完整列
- 避免在索引列上使用函数
- 注意隐式类型转换
- 范围查询会中断后续列的索引使用
4. 如何定位和优化慢查询?
考察点: 性能调优实战能力
详细解答:
步骤1:开启慢查询日志
-- 查看慢查询配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志(临时生效)
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 2; -- 超过2秒的查询记录
-- 永久配置(修改my.cnf)
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 2
log_queries_not_using_indexes = 1 -- 记录未使用索引的查询
步骤2:分析慢查询日志
# 使用mysqldumpslow工具分析
# 按查询时间排序,显示前10条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 按查询次数排序
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log
# 按平均查询时间排序
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
步骤3:使用EXPLAIN分析查询
-- 示例慢查询
EXPLAIN SELECT * FROM orders
WHERE user_id = 12345
AND status = 'completed'
AND create_time > '2024-01-01';
-- 关键字段分析:
-- type: ALL(全表扫描)- 最差的情况
-- type: index(索引扫描)- 扫描整个索引树
-- type: range(范围扫描)- 使用索引范围查询
-- type: ref(索引查找)- 非唯一索引查找
-- type: eq_ref(唯一索引查找)- 唯一索引或主键查找
-- type: const(常量查找)- 主键或唯一索引的常量查询
-- key: NULL 表示未使用索引
-- rows: 500000 表示扫描了50万行
-- Extra: Using where 表示在Server层进行过滤
步骤4:优化策略
优化1:添加索引
-- 问题查询
SELECT * FROM orders
WHERE user_id = 12345
AND status = 'completed'
AND create_time > '2024-01-01';
-- EXPLAIN显示全表扫描,添加联合索引
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
-- 性能提升:
-- 扫描行数:500000 → 100
-- 查询时间:5秒 → 0.01秒
**优化2:避免SELECT ***
-- 不好的写法
SELECT * FROM orders WHERE user_id = 12345;
-- 优化写法(只查询需要的字段)
SELECT id, order_no, amount, status FROM orders WHERE user_id = 12345;
-- 进一步优化(使用覆盖索引)
CREATE INDEX idx_user_cover ON orders(user_id, id, order_no, amount, status);
SELECT id, order_no, amount, status FROM orders WHERE user_id = 12345;
-- 使用覆盖索引,避免回表
优化3:优化JOIN查询
-- 慢查询示例
SELECT o.*, u.name, u.email
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE o.status = 'completed'
AND o.create_time > '2024-01-01';
-- 优化1:添加索引
CREATE INDEX idx_status_time ON orders(status, create_time);
CREATE INDEX idx_id ON users(id); -- 通常主键已有索引
-- 优化2:调整JOIN顺序(小表驱动大表)
-- 如果users表较小,可以改为
SELECT o.*, u.name, u.email
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'completed'
AND o.create_time > '2024-01-01';
-- 优化3:分步查询(如果关联数据量小)
-- Step 1: 查询订单ID
SELECT id, user_id FROM orders
WHERE status = 'completed'
AND create_time > '2024-01-01';
-- Step 2: 应用层批量查询用户信息
SELECT id, name, email FROM users WHERE id IN (1, 2, 3, ...);
优化4:分页查询优化
-- 慢查询:深分页
SELECT * FROM orders ORDER BY create_time DESC LIMIT 100000, 20;
-- 问题:MySQL需要扫描100020行,然后丢弃前100000行
-- 优化方案1:使用子查询(延迟关联)
SELECT * FROM orders
WHERE id >= (
SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 1
)
ORDER BY create_time DESC
LIMIT 20;
-- 优化方案2:使用上次查询的最后一条记录
-- 假设上次查询最后一条记录的create_time和id
SELECT * FROM orders
WHERE create_time < '2024-01-15 10:00:00'
OR (create_time = '2024-01-15 10:00:00' AND id < 123456)
ORDER BY create_time DESC, id DESC
LIMIT 20;
-- 优化方案3:使用覆盖索引 + 回表
SELECT * FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY create_time DESC LIMIT 100000, 20
) t ON o.id = t.id;
优化5:避免隐式转换
-- 假设user_id是VARCHAR类型
-- 慢查询(隐式转换)
SELECT * FROM orders WHERE user_id = 12345;
-- MySQL会执行:CAST(user_id AS SIGNED) = 12345
-- 导致索引失效
-- 优化
SELECT * FROM orders WHERE user_id = '12345';
-- 建议:统一数据类型
ALTER TABLE orders MODIFY user_id INT;
优化6:避免在索引列上使用函数
-- 慢查询
SELECT * FROM orders WHERE DATE(create_time) = '2024-01-15';
-- 索引失效
-- 优化
SELECT * FROM orders
WHERE create_time >= '2024-01-15 00:00:00'
AND create_time < '2024-01-16 00:00:00';
-- 另一个例子
-- 慢查询
SELECT * FROM users WHERE UPPER(name) = 'ALICE';
-- 优化(如果确实需要不区分大小写)
-- 方案1:使用ci排序规则
ALTER TABLE users MODIFY name VARCHAR(50) COLLATE utf8mb4_general_ci;
SELECT * FROM users WHERE name = 'alice';
-- 方案2:创建函数索引(MySQL 8.0+)
CREATE INDEX idx_upper_name ON users((UPPER(name)));
SELECT * FROM users WHERE UPPER(name) = 'ALICE';
第二部分:MySQL事务与并发控制
5. 事务的ACID特性是什么?InnoDB如何实现?
考察点: 数据库基础理论、存储引擎原理
详细解答:
ACID定义:
- A - Atomicity(原子性): 事务中的所有操作要么全部成功,要么全部失败
- C - Consistency(一致性): 事务执行前后,数据保持一致性状态
- I - Isolation(隔离性): 多个事务并发执行时,互不干扰
- D - Durability(持久性): 事务提交后,数据永久保存
InnoDB实现机制:
1. 原子性(Atomicity)- 通过undo log实现
-- 示例事务
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- InnoDB实现原理:
-- Step 1: 执行UPDATE前,记录undo log
-- undo log记录:
-- - 表空间ID、页号、记录位置
-- - 旧值:账户1余额=1000,账户2余额=500
-- Step 2: 执行UPDATE操作
-- 新值:账户1余额=900,账户2余额=600
-- Step 3: 如果COMMIT,标记事务完成,undo log保留(用于MVCC)
-- Step 4: 如果ROLLBACK,使用undo log恢复数据
ROLLBACK;
-- 恢复操作:
-- UPDATE accounts SET balance = 1000 WHERE id = 1;
-- UPDATE accounts SET balance = 500 WHERE id = 2;
2. 持久性(Durability)- 通过redo log实现
-- 示例事务
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- InnoDB实现原理(WAL - Write-Ahead Logging):
-- Step 1: 修改数据页(在内存的Buffer Pool中)
-- 账户1余额:1000 → 900(仅在内存中)
-- Step 2: 写入redo log(磁盘)
-- redo log记录:
-- - 表空间ID=5, 页号=100, 偏移量=50
-- - 修改内容:balance字段从1000改为900
-- 注意:redo log是物理日志,记录"在某个数据页的某个偏移量做了什么修改"
-- Step 3: 提交事务
COMMIT;
-- 此时只需确保redo log刷盘,数据页可以稍后刷盘
-- Step 4: 后台线程异步将脏页刷入磁盘
-- 如果在刷盘前数据库崩溃,重启后通过redo log恢复
3. 隔离性(Isolation)- 通过锁和MVCC实现
详见后续题目的MVCC和隔离级别讲解。
4. 一致性(Consistency)- 通过原子性、隔离性、持久性保证
一致性是目标,原子性、隔离性、持久性是手段。
6. MySQL的事务隔离级别有哪些?分别解决什么问题?
考察点: 并发控制、事务隔离机制
详细解答:
四种隔离级别:
- READ UNCOMMITTED(读未提交)
- READ COMMITTED(读已提交)
- REPEATABLE READ(可重复读) - MySQL默认
- SERIALIZABLE(串行化)
并发问题类型:
- 脏读(Dirty Read): 读取到其他事务未提交的数据
- 不可重复读(Non-Repeatable Read): 同一事务内多次读取同一数据,结果不同
- 幻读(Phantom Read): 同一事务内多次查询,返回的记录数不同
隔离级别对比表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 | 性能 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | ✗ | ✗ | ✗ | 无锁 | 最高 |
| READ COMMITTED | ✗ | ✗ | MVCC(每次生成ReadView) | 较高 | |
| REPEATABLE READ | MVCC + Next-Key Lock | 中等 | |||
| SERIALIZABLE | 锁 | 最低 |
7. 什么是MVCC?它是如何实现的?
考察点: InnoDB核心机制
详细解答:
MVCC定义:
MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制机制,通过保存数据的多个版本,实现读写不阻塞,提高并发性能。
核心思想:
传统锁机制:
- 读读:不阻塞
- 读写:阻塞
- 写写:阻塞
MVCC机制:
- 读读:不阻塞
- 读写:不阻塞(读取旧版本)
- 写写:阻塞(通过锁控制)
优势:提高了读写并发度
实现机制:
1. 隐藏字段
每行记录包含:
- DB_TRX_ID: 最后修改该行的事务ID(6字节)
- DB_ROLL_PTR: 回滚指针,指向undo log中的上一个版本(7字节)
- DB_ROW_ID: 行ID,如果表没有主键则生成(6字节)
2. undo log版本链
每次修改记录时,会将旧版本记录到undo log中,形成版本链。
3. ReadView(读视图)
ReadView结构:
- m_ids: [活跃事务ID列表]
- min_trx_id: 最小活跃事务ID
- max_trx_id: 下一个将要分配的事务ID
- creator_trx_id: 创建ReadView的事务ID
4. 可见性判断算法
1. 如果record_trx_id == creator_trx_id:可见(当前事务自己的修改)
2. 如果record_trx_id < min_trx_id:可见(在ReadView创建前已提交)
3. 如果record_trx_id >= max_trx_id:不可见(在ReadView创建后才生成)
4. 如果min_trx_id <= record_trx_id < max_trx_id:
- 如果record_trx_id在m_ids中:不可见(未提交)
- 否则:可见(已提交)
第三部分:Redis缓存专题
8. Redis的数据类型有哪些?各有什么应用场景?
考察点: Redis基础、实战应用
详细解答:
基本数据类型:
String(字符串)
- 应用:缓存对象、计数器、分布式锁、Session共享
Hash(哈希)
- 应用:存储对象、购物车、统计信息
List(列表)
- 应用:消息队列、最新动态、粉丝列表
Set(集合)
- 应用:标签系统、共同好友、去重、抽奖系统
Sorted Set(有序集合)
- 应用:排行榜、延迟队列、热门文章、时间线
特殊数据类型:
Bitmap(位图)
- 应用:用户签到、在线用户统计、布隆过滤器
HyperLogLog(基数统计)
- 应用:UV统计(占用空间小,适合大数据量)
Geospatial(地理位置)
- 应用:附近的人/商家、LBS应用
9. Redis缓存穿透、缓存击穿、缓存雪崩是什么?如何解决?
考察点: 缓存设计、高并发场景处理
详细解答:
1. 缓存穿透(Cache Penetration)
定义: 查询一个不存在的数据,缓存和数据库都没有,导致每次请求都打到数据库。
解决方案:
- 布隆过滤器(Bloom Filter)
- 缓存空对象
- 参数校验
2. 缓存击穿(Cache Breakdown)
定义: 某个热点key突然失效,导致大量并发请求直接打到数据库。
解决方案:
- 互斥锁(Mutex Lock)
- 逻辑过期(永不过期)
- 热点数据永不过期
3. 缓存雪崩(Cache Avalanche)
定义: 大量缓存同时失效,或Redis宕机,导致大量请求打到数据库。
解决方案:
- 过期时间加随机值
- Redis高可用(哨兵/集群)
- 服务降级和限流
- 多级缓存
- 提前预热
10. Redis持久化机制有哪些?
考察点: Redis数据可靠性
详细解答:
1. RDB(Redis Database Backup)
原理: 在指定时间间隔内,将内存中的数据集快照写入磁盘。
配置:
# redis.conf
save 900 1 # 900秒内至少1次修改,触发bgsave
save 300 10 # 300秒内至少10次修改
save 60 10000 # 60秒内至少10000次修改
dbfilename dump.rdb
dir /var/lib/redis
执行方式:
# 手动触发
SAVE # 阻塞式保存(主进程执行,阻塞所有客户端请求)
BGSAVE # 后台保存(fork子进程执行,不阻塞主进程)
# BGSAVE流程:
# 1. Redis主进程fork一个子进程
# 2. 子进程将内存数据写入临时RDB文件
# 3. 写入完成后,替换旧的RDB文件
优点:
- 文件紧凑,适合备份和灾难恢复
- 恢复速度快(直接加载到内存)
- 对性能影响小(子进程执行)
缺点:
- 数据可能丢失(最后一次快照后的数据)
- fork子进程时,内存占用翻倍(写时复制)
适用场景:
- 对数据完整性要求不高
- 需要定期备份
- 容忍分钟级数据丢失
2. AOF(Append Only File)
原理: 记录每一个写操作命令,通过重放命令恢复数据。
配置:
# redis.conf
appendonly yes
appendfilename "appendonly.aof"
# 同步策略
appendfsync always # 每个命令都同步,最安全,性能最差
appendfsync everysec # 每秒同步一次,默认推荐
appendfsync no # 由操作系统决定,性能最好,可能丢失数据
AOF重写:
# AOF文件会越来越大,需要定期重写压缩
# 手动触发
BGREWRITEAOF
# 自动触发配置
auto-aof-rewrite-percentage 100 # 当前AOF文件大小是上次重写后的100%时触发
auto-aof-rewrite-min-size 64mb # AOF文件至少达到64MB才触发
# 重写流程:
# 1. fork子进程
# 2. 子进程遍历内存数据,生成新的AOF文件
# 3. 主进程继续处理命令,新命令写入AOF重写缓冲区
# 4. 子进程完成后,主进程将缓冲区命令追加到新AOF文件
# 5. 替换旧的AOF文件
AOF文件示例:
*3
$3
SET
$3
key
$5
value
*2
$3
DEL
$3
key
*3
$4
INCR
$7
counter
优点:
- 数据更安全(最多丢失1秒数据,如果使用everysec)
- 可读性好,可手动修复
- 自动重写,控制文件大小
缺点:
- 文件体积大
- 恢复速度慢(需要重放所有命令)
- 写入性能略低于RDB
适用场景:
- 对数据安全性要求高
- 可以容忍较大的文件体积
- 容忍秒级数据丢失
3. 混合持久化(RDB + AOF)
原理: Redis 4.0引入,结合RDB和AOF的优点。
配置:
aof-use-rdb-preamble yes
工作方式:
AOF重写时:
1. 将当前内存数据以RDB格式写入AOF文件开头
2. 将重写期间的新命令以AOF格式追加到文件末尾
AOF文件结构:
+-------------------+
| RDB格式的数据快照 | ← 快速恢复
+-------------------+
| AOF格式的增量命令 | ← 保证数据完整性
+-------------------+
优点:
- 恢复速度快(RDB部分)
- 数据更安全(AOF部分)
缺点:
- 兼容性问题(旧版本Redis无法识别)
适用场景:
- 对数据安全性和恢复速度都有要求
- 使用Redis 4.0+
对比总结:
| 特性 | RDB | AOF | 混合持久化 |
|---|---|---|---|
| 文件大小 | 小 | 大 | 中等 |
| 恢复速度 | 快 | 慢 | 快 |
| 数据安全性 | 低(分钟级丢失) | 高(秒级丢失) | 高 |
| 性能影响 | 低 | 中 | 中 |
| 适用场景 | 备份恢复 | 数据安全 | 综合场景 |
实战建议:
# 生产环境推荐配置
# 1. 同时开启RDB和AOF
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes
# 2. 定期备份RDB文件到其他机器
# 3. 监控AOF文件大小,及时重写
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
# 4. 根据业务特点选择:
# - 缓存场景:可以只用RDB或不持久化
# - 数据存储场景:必须开启AOF
# - 关键业务:使用混合持久化 + 主从复制
第四部分:Redis进阶专题
11. Redis的过期策略和内存淘汰机制?
考察点: Redis内存管理
详细解答:
一、过期策略(Key如何被删除)
Redis使用惰性删除 + 定期删除的组合策略。
1. 惰性删除(Lazy Expiration)
# 设置过期时间
SET key value EX 10
# 原理:
# 访问key时,检查是否过期
# 如果过期,立即删除并返回nil
GET key
# 内部逻辑:
# 1. 检查key是否存在
# 2. 检查key是否过期
# 3. 如果过期,删除key,返回nil
# 4. 如果未过期,返回value
优点: 对CPU友好,只有访问时才检查 缺点: 如果key永不被访问,会一直占用内存
2. 定期删除(Periodic Expiration)
// Redis内部定期执行(默认每秒10次)
void activeExpireCycle() {
// 遍历所有数据库
for (db in all_databases) {
// 随机抽取20个带过期时间的key
for (i = 0; i < 20; i++) {
key = db.random_key_with_expire();
if (is_expired(key)) {
delete(key);
}
}
// 如果过期key超过25%,继续检查
if (expired_ratio > 0.25) {
continue_checking();
}
}
}
优点: 定期清理过期key,减少内存占用 缺点: 对CPU不友好,需要定期扫描
3. 过期策略总结
时间线:
T1: SET key1 value EX 10 # 设置10秒过期
T2: ... (10秒后)
T3: key1已过期,但未被删除(等待惰性删除或定期删除)
场景1:GET key1(惰性删除触发)
-> 检查到过期,删除key1,返回nil
场景2:定期删除触发
-> 随机抽取key,发现key1过期,删除
二、内存淘汰机制(内存不足时如何处理)
当Redis内存达到maxmemory限制时,触发内存淘汰。
配置:
# redis.conf
maxmemory 2gb # 最大内存限制
maxmemory-policy allkeys-lru # 淘汰策略
8种淘汰策略:
1. noeviction(默认)
# 配置
maxmemory-policy noeviction
# 行为:
# 内存不足时,拒绝写入操作(返回错误)
# 读操作、DEL操作仍可执行
SET key value
# 如果内存不足,返回:
# (error) OOM command not allowed when used memory > 'maxmemory'.
# 适用场景:
# - 数据绝对不能丢失
# - 由应用层处理内存不足的情况
2. allkeys-lru
# 配置
maxmemory-policy allkeys-lru
# 行为:
# 从所有key中,删除最近最少使用(LRU)的key
# LRU算法原理:
# Redis使用近似LRU算法(采样)
# 1. 随机采样5个key(默认)
# 2. 删除其中最久未访问的key
# 3. 重复直到内存足够
# 适用场景:
# - 所有key的重要性相同
# - 适合纯缓存场景
3. volatile-lru
# 配置
maxmemory-policy volatile-lru
# 行为:
# 从设置了过期时间的key中,删除最近最少使用的keySET key1 value1 EX 100 # 有过期时间,可能被淘汰
SET key2 value2 # 无过期时间,不会被淘汰
# 适用场景:
# - 部分数据永久保存(未设置过期时间)
# - 部分数据可以淘汰(设置了过期时间)
4. allkeys-random
# 配置
maxmemory-policy allkeys-random
# 行为:
# 从所有key中,随机删除key
# 适用场景:
# - 所有key的重要性相同
# - 访问模式完全随机
5. volatile-random
# 配置
maxmemory-policy volatile-random
# 行为:
# 从设置了过期时间的key中,随机删除key
# 适用场景:
# - 部分数据可以淘汰
# - 访问模式随机
6. allkeys-lfu
# 配置
maxmemory-policy allkeys-lfu
# 行为:
# 从所有key中,删除最不经常使用(LFU)的key
# LFU vs LRU:
# LRU:最近最少使用(时间维度)
# LFU:最不经常使用(频率维度)# key1: 1小时前访问过100次
# key2: 刚才访问过1次
# LRU会删除key1(最久未访问)
# LFU会删除key2(访问频率低)
# 适用场景:
# - 访问模式稳定
# - 热点数据明显
7. volatile-lfu
# 配置
maxmemory-policy volatile-lfu
# 行为:
# 从设置了过期时间的key中,删除最不经常使用的key
# 适用场景:
# - 部分数据可以淘汰
# - 访问模式稳定
8. volatile-ttl
# 配置
maxmemory-policy volatile-ttl
# 行为:
# 从设置了过期时间的key中,删除即将过期的key(TTL最小)SET key1 value1 EX 10 # 10秒后过期
SET key2 value2 EX 100 # 100秒后过期
# 内存不足时,优先删除key1
# 适用场景:
# - 希望按过期时间优先级淘汰
# - 即将过期的数据优先删除
淘汰策略选择指南:
| 场景 | 推荐策略 | 理由 |
|---|---|---|
| 纯缓存 | allkeys-lru | 所有数据可淘汰,保留热点数据 |
| 缓存+持久数据 | volatile-lru | 只淘汰设置过期的缓存数据 |
| 访问频率明显 | allkeys-lfu | 基于访问频率淘汰 |
| 数据不能丢失 | noeviction | 拒绝写入,应用层处理 |
| 按过期时间淘汰 | volatile-ttl | 优先删除快过期的数据 |
实战案例:
# 案例1:电商商品缓存
# 配置
maxmemory 4gb
maxmemory-policy allkeys-lru
# 原因:
# 1. 商品信息纯缓存,可以从数据库重新加载
# 2. 热门商品访问频繁,LRU可以保留热点数据
# 3. 冷门商品可以淘汰,需要时再加载
# 案例2:用户Session + 缓存数据
# 配置
maxmemory 2gb
maxmemory-policy volatile-lru
# Key设计:
SET session:user:1000 "session_data" # 无过期时间(永久保存)
SET cache:product:100 "product_data" EX 3600 # 有过期时间(可淘汰)
# 原因:
# 1. Session数据不能丢失,不设置过期时间
# 2. 缓存数据可以淘汰,设置过期时间
# 3. volatile-lru只淘汰有过期时间的key
# 案例3:秒杀系统
# 配置
maxmemory 1gb
maxmemory-policy volatile-ttl
# Key设计:
SET seckill:stock:100 "1000" EX 3600 # 库存,1小时后过期
SET seckill:order:123 "order" EX 300 # 订单,5分钟后过期
# 原因:
# 1. 秒杀活动有时间限制
# 2. 优先淘汰即将过期的数据
# 3. 保留还在活动期间的数据
监控和优化:
# 查看内存使用情况
INFO memory
# 关键指标:
used_memory: 1024000000 # 已使用内存(字节)
used_memory_human: 976.56M # 已使用内存(人类可读)
used_memory_peak: 2048000000 # 内存使用峰值
maxmemory: 2147483648 # 最大内存限制
maxmemory_policy: allkeys-lru # 淘汰策略
evicted_keys: 12345 # 已淘汰的key数量
# 优化建议:
# 1. 合理设置maxmemory(物理内存的70-80%)
# 2. 监控evicted_keys,如果频繁淘汰,考虑扩容
# 3. 根据业务场景选择合适的淘汰策略
# 4. 定期清理无用数据,不要依赖自动淘汰
12. Redis主从复制的原理?
考察点: Redis高可用架构
详细解答:
一、主从复制架构
+--------+
| Master | ← 写操作
+--------+
/ \
/ \
+-------+ +-------+
| Slave | | Slave | ← 读操作
+-------+ +-------+
配置:
# 主节点(Master)
# redis.conf
port 6379
bind 0.0.0.0
# 从节点(Slave)
# redis.conf
port 6380
bind 0.0.0.0
replicaof 127.0.0.1 6379 # 指定主节点
# 或运行时配置:
# REPLICAOF 127.0.0.1 6379
二、复制流程
1. 全量复制(Full Resynchronization)
初次连接或断线重连时,执行全量复制
时间线:
T1: 从节点连接主节点
T2: 从节点发送 PSYNC ? -1(请求全量复制)
T3: 主节点返回 +FULLRESYNC <runid> <offset>
T4: 主节点执行 BGSAVE,生成RDB文件
T5: 主节点发送RDB文件给从节点
T6: 从节点清空旧数据,加载RDB文件
T7: 主节点发送RDB生成期间的写命令(复制缓冲区)
T8: 从节点执行这些写命令
T9: 全量复制完成
详细步骤:
# Step 1: 从节点发送PSYNC命令
PSYNC ? -1
# ?:runid未知(首次复制)
# -1:偏移量未知
# Step 2: 主节点响应
+FULLRESYNC 8371e1c... 0
# 8371e1c...:主节点的runid
# 0:当前复制偏移量
# Step 3: 主节点BGSAVE
# fork子进程,生成RDB文件
# 同时将新的写命令写入复制缓冲区(replication buffer)
# Step 4: 发送RDB文件
# 主节点通过socket发送RDB文件
# Step 5: 从节点加载RDB
# 从节点清空所有数据(FLUSHALL)
# 加载RDB文件到内存
# Step 6: 发送缓冲区命令
# 主节点发送RDB生成期间的写命令
# 从节点执行这些命令
# 完成后,主从数据一致
2. 增量复制(Partial Resynchronization)
断线重连后,执行增量复制(Redis 2.8+)
原理:
主节点维护一个复制积压缓冲区(replication backlog)
记录最近的写命令(默认1MB,环形缓冲区)
时间线:
T1: 从节点断线
T2: 主节点继续接收写命令,写入复制积压缓冲区
T3: 从节点重连
T4: 从节点发送 PSYNC <runid> <offset>
T5: 主节点检查offset是否在积压缓冲区内
T6: 如果在,返回 +CONTINUE,发送offset之后的命令
T7: 从节点执行这些命令
T8: 增量复制完成
详细步骤:
# Step 1: 从节点重连后发送PSYNC
PSYNC 8371e1c... 1000
# 8371e1c...:上次连接的主节点runid
# 1000:从节点当前的复制偏移量
# Step 2: 主节点判断
# 如果runid匹配 && offset在复制积压缓冲区内:
+CONTINUE
# 然后发送offset之后的命令
# 如果runid不匹配 || offset已不在缓冲区:
+FULLRESYNC <runid> <offset>
# 重新执行全量复制
# Step 3: 从节点执行增量命令
# 应用offset之后的写命令
三、复制积压缓冲区(Replication Backlog)
# redis.conf
repl-backlog-size 1mb # 缓冲区大小
repl-backlog-ttl 3600 # 无从节点时,缓冲区保留时间(秒)
工作原理:
复制积压缓冲区是一个环形缓冲区
+---+---+---+---+---+---+---+---+
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | ← 缓冲区(1MB)
+---+---+---+---+---+---+---+---+
^ ^
| |
offset=1000 offset=1007
如果从节点offset=1002,可以增量复制
如果从节点offset=500(已被覆盖),需要全量复制
缓冲区大小计算:
repl-backlog-size >= 2 * 平均断线时间 * 平均写入速度
示例:
平均断线时间:60秒
平均写入速度:1MB/s
推荐缓冲区大小:2 * 60 * 1MB = 120MB
四、心跳检测
# 从节点定期向主节点发送心跳
# 默认每秒1次
REPLCONF ACK <offset>
# offset:从节点当前的复制偏移量
# 作用:
# 1. 检测主从网络连接状态
# 2. 上报复制偏移量,用于min-slaves配置
# 3. 检测命令丢失
# 主节点配置
min-replicas-to-write 1 # 至少1个从节点
min-replicas-max-lag 10 # 从节点延迟不超过10秒
# 如果不满足条件,主节点拒绝写入
五、主从复制的特点
优点:
- 读写分离:主节点写,从节点读,提高并发能力
- 数据冗余:多个副本,防止数据丢失
- 故障恢复:主节点故障,可切换从节点为主节点
缺点:
- 复制延迟:主从数据可能不一致(最终一致性)
- 故障转移需手动:主节点故障,需要手动切换(Redis Sentinel可自动切换)
- 全量复制开销大:网络带宽、CPU、磁盘IO
六、实战问题
问题1:主从复制延迟如何监控?
# 在从节点执行
INFO replication
# 关键指标:
master_link_status: up # 主从连接状态
master_last_io_seconds_ago: 0 # 上次IO时间(秒)
master_sync_in_progress: 0 # 是否正在同步
slave_repl_offset: 12345 # 从节点复制偏移量
# 在主节点执行
INFO replication
# 关键指标:
connected_slaves: 2 # 从节点数量
slave0:ip=127.0.0.1,port=6380,state=online,offset=12345,lag=0
# lag:从节点延迟(秒)
# 监控延迟
# 如果master_last_io_seconds_ago > 10,说明延迟严重
# 如果lag > 3,说明从节点处理较慢
问题2:如何优化全量复制的性能?
# 1. 使用无盘复制(Diskless Replication)
# 主节点直接通过socket发送数据,不生成RDB文件
repl-diskless-sync yes
repl-diskless-sync-delay 5 # 延迟5秒,等待更多从节点连接
# 2. 调整RDB压缩
rdbcompression yes # 启用压缩(节省网络带宽,但增加CPU)
# 3. 增大复制积压缓冲区
repl-backlog-size 100mb # 减少全量复制的触发
# 4. 避免高峰期全量复制
# 在业务低峰期手动触发复制
问题3:主从切换如何进行?
# 手动切换(不推荐)
# Step 1: 在从节点执行
REPLICAOF NO ONE
# 从节点变为主节点
# Step 2: 在其他从节点执行
REPLICAOF <new_master_ip> <new_master_port>
# 指向新的主节点
# Step 3: 修改应用连接
# 应用层修改Redis连接地址
# 推荐方案:使用Redis Sentinel自动切换
# 见下一题
13. Redis Sentinel哨兵模式的原理?
考察点: Redis高可用方案
详细解答:
一、Sentinel架构
+----------+ +----------+ +----------+
|Sentinel 1| |Sentinel 2| |Sentinel 3|
+----------+ +----------+ +----------+
| | |
+-------------+-------------+
|
+---------+---------+
| |
+--------+ +-------+
| Master | | Slave |
+--------+ +-------+
作用:
- 监控(Monitoring):检测主从节点是否正常
- 通知(Notification):故障时通知管理员或应用
- 自动故障转移(Automatic Failover):主节点故障,自动提升从节点为主节点
- 配置提供者(Configuration Provider):客户端连接Sentinel获取主节点地址
二、Sentinel配置
# sentinel.conf
# 监控主节点
# sentinel monitor <master-name> <ip> <port> <quorum>
sentinel monitor mymaster 127.0.0.1 6379 2
# mymaster:主节点名称
# 127.0.0.1 6379:主节点地址
# 2:quorum,判定主节点下线需要的Sentinel数量
# 主观下线时间
sentinel down-after-milliseconds mymaster 5000
# 5000ms内无响应,判定为主观下线
# 故障转移超时时间
sentinel failover-timeout mymaster 60000
# 60秒
# 并行同步的从节点数量
sentinel parallel-syncs mymaster 1
# 故障转移时,同时向新主节点同步的从节点数量
# 1:一次只有1个从节点同步,避免全部从节点同时不可用
# 通知脚本
sentinel notification-script mymaster /var/redis/notify.sh
# 故障时执行脚本通知
# 故障转移脚本
sentinel client-reconfig-script mymaster /var/redis/reconfig.sh
# 故障转移完成后执行脚本
三、故障检测机制
1. 主观下线(Subjectively Down, SDOWN)
单个Sentinel认为主节点下线
判断条件:
在down-after-milliseconds时间内,主节点未响应PING命令
流程:
T1: Sentinel向Master发送PING
T2: 等待响应
T3: 如果5000ms内无响应 → 标记为SDOWN
2. 客观下线(Objectively Down, ODOWN)
足够数量的Sentinel认为主节点下线
判断条件:
至少quorum个Sentinel认为主节点SDOWN
流程:
T1: Sentinel1发现Master SDOWN
T2: Sentinel1向其他Sentinel发送 SENTINEL is-master-down-by-addr
T3: 收集其他Sentinel的回复
T4: 如果>=quorum个Sentinel认为Master SDOWN → 标记为ODOWN
T5: 触发故障转移
四、故障转移流程
完整故障转移流程:
Step 1: 选举Leader Sentinel
- 使用Raft算法选举
- 先发起选举的Sentinel更容易当选
- 需要超过半数Sentinel投票
Step 2: Leader选择新的Master
- 从从节点中选择一个作为新Master
- 选择规则:
1. 排除下线的从节点
2. 排除5秒内未回复INFO的从节点
3. 排除与旧Master断线时间>down-after-milliseconds*10的从节点
4. 优先级最高的(slave-priority)
5. 优先级相同,选择复制偏移量最大的(数据最新)
6. 如果还相同,选择runid最小的
Step 3: 提升新Master
- Leader向选中的从节点发送 SLAVEOF NO ONE
- 从节点变为Master
Step 4: 更新其他从节点
- Leader向其他从节点发送 SLAVEOF <new_master_ip> <new_master_port>
- 按照parallel-syncs设置,分批更新
Step 5: 通知客户端
- Sentinel更新Master地址
- 客户端通过订阅+switch-master频道获取新Master地址
Step 6: 监控旧Master
- 如果旧Master恢复,将其降级为Slave
- 发送 SLAVEOF <new_master_ip> <new_master_port>
五、Sentinel通信机制
1. 发现机制
# Sentinel通过发布/订阅发现彼此
# 每个Sentinel向主节点的 __sentinel__:hello 频道发送消息
PUBLISH __sentinel__:hello "sentinel_info"
# 消息内容:
<sentinel_ip>,<sentinel_port>,<sentinel_runid>,<sentinel_epoch>,
<master_name>,<master_ip>,<master_port>,<master_epoch>
# 其他Sentinel订阅该频道,获取Sentinel列表
SUBSCRIBE __sentinel__:hello
2. 心跳检测
# Sentinel定期向主从节点和其他Sentinel发送PING
PING
# 期待响应:
+PONG
# 或
-LOADING
# 或
-MASTERDOWN
3. 获取信息
# Sentinel定期向主从节点发送INFO命令
INFO replication
# 从主节点获取:
# - 从节点列表
# - 从节点状态
# 从从节点获取:
# - 主节点信息
# - 复制偏移量
六、客户端连接Sentinel
# Python示例
from redis.sentinel import Sentinel
# 连接Sentinel集群
sentinel = Sentinel([
('localhost', 26379),
('localhost', 26380),
('localhost', 26381)
], socket_timeout=0.1)
# 获取主节点连接
master = sentinel.master_for('mymaster', socket_timeout=0.1)
master.set('key', 'value')
# 获取从节点连接(读操作)
slave = sentinel.slave_for('mymaster', socket_timeout=0.1)
value = slave.get('key')
# 订阅主节点切换事件
pubsub = sentinel.pubsub()
pubsub.subscribe('+switch-master')
for message in pubsub.listen():
print(message)
# 示例输出:
# {'type': 'message', 'channel': '+switch-master',
# 'data': 'mymaster 127.0.0.1 6379 127.0.0.1 6380'}
Java示例:
import redis.clients.jedis.JedisSentinelPool;
import java.util.HashSet;
import java.util.Set;
Set<String> sentinels = new HashSet<>();
sentinels.add("localhost:26379");
sentinels.add("localhost:26380");
sentinels.add("localhost:26381");
JedisSentinelPool pool = new JedisSentinelPool("mymaster", sentinels);
// 使用连接池
try (Jedis jedis = pool.getResource()) {
jedis.set("key", "value");
String value = jedis.get("key");
}
pool.close();
七、实战问题
问题1:Sentinel部署需要多少个节点?
推荐:3个或5个Sentinel节点(奇数)
原因:
1. 选举Leader需要超过半数投票
2. 判定ODOWN需要quorum个Sentinel
3. 奇数避免脑裂
示例:
3个Sentinel:quorum=2,最多允许1个Sentinel故障
5个Sentinel:quorum=3,最多允许2个Sentinel故障
不推荐2个Sentinel:
- 如果1个故障,无法达到quorum,无法故障转移
- 如果设置quorum=1,可能误判(网络分区)
不推荐4个Sentinel:
- 4个和5个容错能力相同(都允许2个故障)
- 但4个选举效率低于5个
问题2:如何避免Sentinel误判?
# 1. 调整主观下线时间
sentinel down-after-milliseconds mymaster 30000
# 增加到30秒,避免网络抖动误判
# 2. 调整quorum值
sentinel monitor mymaster 127.0.0.1 6379 2
# quorum=2,需要至少2个Sentinel确认才判定ODOWN
# 如果有3个Sentinel,quorum=2可以避免单点误判
# 3. 部署在不同机房/网络
# 避免网络分区导致所有Sentinel同时误判
# 4. 监控网络质量
# 如果网络经常抖动,优化网络或增加down-after-milliseconds
问题3:Sentinel故障转移期间,数据会丢失吗?
会,可能丢失少量数据
原因:
1. 主节点异步复制到从节点
2. 主节点故障前,部分数据可能未复制到从节点
3. 新Master可能缺少这部分数据
示例:
T1: 客户端写入Master:SET key1 value1
T2: Master异步复制到Slave(未完成)
T3: Master宕机
T4: Sentinel将Slave提升为新Master
T5: 新Master没有key1的数据 → 数据丢失
减少数据丢失的方法:
1. 配置min-replicas-to-write和min-replicas-max-lag
min-replicas-to-write 1
min-replicas-max-lag 10
# 至少1个从节点延迟<10秒,否则拒绝写入
2. 使用Redis Cluster(多主节点,减少单点故障影响)
3. 应用层做好重试和补偿机制
第五部分:Kafka消息队列专题
14. Kafka的架构和核心概念?
考察点: Kafka基础架构
详细解答:
一、核心概念
1. Producer(生产者)
- 向Topic发送消息的客户端
2. Consumer(消费者)
- 从Topic读取消息的客户端
3. Broker(代理)
- Kafka服务器节点,存储和转发消息
4. Topic(主题)
- 消息的分类,类似数据库的表
5. Partition(分区)
- Topic的物理分组,实现并行处理和负载均衡
6. Replica(副本)
- 分区的备份,用于容错
7. Consumer Group(消费者组)
- 多个消费者协作消费同一个Topic
8. Offset(偏移量)
- 消息在分区中的位置标识
9. Zookeeper / KRaft
- 集群协调服务(Kafka 3.x后可用KRaft替代Zookeeper)
二、Kafka架构图
Zookeeper Cluster
(集群协调、元数据管理)
|
+----------------+----------------+
| | |
+--------+ +--------+ +--------+
| Broker1| | Broker2| | Broker3|
+--------+ +--------+ +--------+
| | |
+----------------+----------------+
|
Topic: orders (3个Partition)
|
+----------------+----------------+
| | |
Partition 0 Partition 1 Partition 2
(Leader: B1) (Leader: B2) (Leader: B3)
(Replica: B2) (Replica: B3) (Replica: B1)
(Replica: B3) (Replica: B1) (Replica: B2)
Producer1 -----> Partition 0
Producer2 -----> Partition 1
Producer3 -----> Partition 2
Consumer Group 1:
Consumer1 -----> Partition 0
Consumer2 -----> Partition 1
Consumer3 -----> Partition 2
Consumer Group 2:
Consumer1 -----> Partition 0, 1, 2
三、Topic和Partition
Topic分区示例:
Topic: orders (3个Partition,2个副本)
Partition 0:
Offset 0: {"order_id": 1, "amount": 100}
Offset 1: {"order_id": 2, "amount": 200}
Offset 2: {"order_id": 3, "amount": 150}
Partition 1:
Offset 0: {"order_id": 4, "amount": 300}
Offset 1: {"order_id": 5, "amount": 250}
Partition 2:
Offset 0: {"order_id": 6, "amount": 180}
Offset 1: {"order_id": 7, "amount": 220}
Offset 2: {"order_id": 8, "amount": 190}
分区的作用:
- 并行处理:多个消费者并行消费不同分区
- 负载均衡:消息分散到不同Broker
- 水平扩展:增加分区数可提高吞吐量
分区策略:
// 1. 指定分区
producer.send(new ProducerRecord<>("orders", 0, "key", "value"));
// 发送到分区0
// 2. 指定Key(根据Key的hash选择分区)
producer.send(new ProducerRecord<>("orders", "user123", "value"));
// hash(user123) % partition_count
// 3. 轮询(Round-Robin)
producer.send(new ProducerRecord<>("orders", "value"));
// 依次发送到分区0, 1, 2, 0, 1, 2...
// 4. 自定义分区器
public class CustomPartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes, Cluster cluster) {
// 自定义分区逻辑
if (key.equals("VIP")) {
return 0; // VIP用户发送到分区0
}
return 1;
}
}
四、副本机制(Replication)
Leader和Follower:
Partition 0:
Leader Replica (Broker 1) ← 处理读写请求
Follower Replica (Broker 2) ← 同步数据
Follower Replica (Broker 3) ← 同步数据
工作流程:
1. 生产者写入Leader
2. Leader写入本地日志
3. Follower主动拉取Leader的数据
4. Follower写入本地日志
5. Follower向Leader发送ACK
6. Leader收到足够ACK后,向生产者返回成功
ISR(In-Sync Replicas):
定义:与Leader保持同步的副本集合
ISR = {Leader, Follower1, Follower2}
如果Follower落后太多(replica.lag.time.max.ms,默认10秒),
会被移出ISR:
ISR = {Leader, Follower1}
只有ISR中的副本有资格成为新Leader
五、消费者组(Consumer Group)
消费模式:
场景1:单个Consumer Group,多个Consumer
Topic: orders (3个Partition)
Consumer Group: group1
Consumer1 → Partition 0
Consumer2 → Partition 1
Consumer3 → Partition 2
特点:每个分区只被组内一个消费者消费(负载均衡)
---
场景2:多个Consumer Group
Consumer Group: group1
Consumer1 → Partition 0, 1, 2
Consumer Group: group2
Consumer1 → Partition 0
Consumer2 → Partition 1
Consumer3 → Partition 2
特点:不同组独立消费(广播模式)
---
场景3:Consumer数量 > Partition数量
Topic: orders (3个Partition)
Consumer Group: group1
Consumer1 → Partition 0
Consumer2 → Partition 1
Consumer3 → Partition 2
Consumer4 → 空闲(无分区分配)
特点:多余的消费者空闲
Rebalance(重平衡):
触发条件:
1. Consumer加入或退出Consumer Group
2. Partition数量变化
3. Consumer崩溃
流程:
1. Consumer停止消费
2. 重新分配Partition
3. Consumer恢复消费
问题:
- Rebalance期间,整个Consumer Group停止消费
- 如果频繁Rebalance,影响性能
优化:
- 调整session.timeout.ms和heartbeat.interval.ms
- 减少Consumer启停频率
- 使用StickyAssignor策略(尽量保持原分配)
六、Offset管理
Offset类型:
1. Last Committed Offset(最后提交的偏移量)
消费者已确认处理的消息位置
2. Current Position(当前位置)
消费者正在读取的消息位置
3. Log End Offset(LEO)
分区中最新消息的位置
4. High Watermark(HW)
ISR中所有副本都已复制的最大偏移量
消费者只能读取HW之前的消息
Offset提交:
// 1. 自动提交(默认)
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "5000"); // 每5秒提交一次
// 问题:可能重复消费或丢失消息
// 重复消费:提交offset前Consumer崩溃,重启后重新消费
// 丢失消息:提交offset后、处理消息前Consumer崩溃
// 2. 手动提交(同步)
props.put("enable.auto.commit", "false");
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
// 处理消息
processRecord(record);
}
// 处理完成后提交offset
consumer.commitSync();
}
// 3. 手动提交(异步)
consumer.commitAsync((offsets, exception) -> {
if (exception != null) {
// 处理提交失败
}
});
// 4. 指定offset提交
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
offsets.put(
new TopicPartition("orders", 0),
new OffsetAndMetadata(100)
);
consumer.commitSync(offsets);
七、Kafka性能特点
高吞吐量的原因:
顺序写磁盘
- Kafka将消息顺序追加到日志文件
- 顺序写性能接近内存(600MB/s)
零拷贝(Zero-Copy)
- 使用sendfile系统调用
- 避免数据在内核态和用户态之间复制
批量发送
- 生产者批量发送消息,减少网络开销
- 配置:batch.size、linger.ms
页缓存(Page Cache)
- 利用操作系统的页缓存
- 读取时直接从内存读取
分区并行
- 多个分区并行读写
实战配置:
# Producer配置
bootstrap.servers=localhost:9092
key.serializer=org.apache.kafka.common.serialization.StringSerializer
value.serializer=org.apache.kafka.common.serialization.StringSerializer
# 性能调优
batch.size=16384 # 批量大小16KB
linger.ms=10 # 等待10ms凑够一批
compression.type=lz4 # 启用压缩
acks=1 # Leader确认即可(高吞吐)
# acks=all # 所有ISR确认(高可靠)
# Consumer配置
bootstrap.servers=localhost:9092
group.id=mygroup
key.deserializer=org.apache.kafka.common.serialization.StringDeserializer
value.deserializer=org.apache.kafka.common.serialization.StringDeserializer
# 性能调优
fetch.min.bytes=1024 # 至少1KB才返回
fetch.max.wait.ms=500 # 最多等待500ms
max.poll.records=500 # 每次最多拉取500条
15. Kafka如何保证消息不丢失、不重复?
考察点: 消息可靠性保证
详细解答:
一、消息丢失的场景和解决方案
场景1:生产者发送失败
// 问题:网络故障导致消息未成功发送
// 解决方案1:同步发送 + 重试
props.put("acks", "all"); // 等待所有ISR副本确认
props.put("retries", 3); // 失败重试3次
props.put("max.in.flight.requests.per.connection", 1); // 保证顺序
ProducerRecord<String, String> record = new ProducerRecord<>("orders", "key", "value");
try {
RecordMetadata metadata = producer.send(record).get(); // 同步等待
System.out.println("Sent to partition " + metadata.partition() + ", offset " + metadata.offset());
} catch (Exception e) {
// 发送失败,记录日志或重试
e.printStackTrace();
}
// 解决方案2:异步发送 + 回调
producer.send(record, (metadata, exception) -> {
if (exception != null) {
// 发送失败,记录日志或重试
exception.printStackTrace();
} else {
System.out.println("Sent successfully");
}
});
场景2:Broker故障导致数据丢失
# 1. 设置副本数
replication.factor=3 # 每个分区3个副本
# 2. 设置最小ISR数量
min.insync.replicas=2 # 至少2个副本在ISR中
# 3. 生产者配置
acks=all # 等待所有ISR副本确认
# 原理:
# - 只有Leader和至少1个Follower都写入成功,才算成功
# - 即使Leader宕机,Follower中仍有数据
场景3:消费者处理失败
// 问题:自动提交offset,消息处理失败但offset已提交
// 解决方案:手动提交offset
props.put("enable.auto.commit", "false");
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, String> record : records) {
try {
// 处理消息
processRecord(record);
// 处理成功后才提交offset
consumer.commitSync(Collections.singletonMap(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
));
} catch (Exception e) {
// 处理失败,不提交offset,下次重新消费
e.printStackTrace();
break;
}
}
}
二、消息重复的场景和解决方案
场景1:生产者重试导致重复
// 问题:
// T1: Producer发送消息到Broker
// T2: Broker写入成功,返回ACK
// T3: 网络故障,Producer未收到ACK
// T4: Producer重试,再次发送消息
// T5: Broker写入重复消息
// 解决方案1:启用幂等性(Idempotence)
props.put("enable.idempotence", "true");
// 原理:
// 1. Producer为每条消息分配序列号(Sequence Number)
// 2. Broker检测到重复序列号,丢弃重复消息
// 3. 保证单分区、单会话的幂等性
// 解决方案2:启用事务(跨分区幂等性)
props.put("transactional.id", "my-transactional-id");
producer.initTransactions();
try {
producer.beginTransaction();
producer.send(new ProducerRecord<>("topic1", "key", "value1"));
producer.send(new ProducerRecord<>("topic2", "key", "value2"));
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
}
场景2:消费者重复消费
// 问题:
// T1: Consumer拉取消息,offset=100
// T2: 处理消息
// T3: 提交offset失败(网络故障)
// T4: Consumer重启,从offset=100再次拉取
// T5: 重复处理消息
// 解决方案1:消费端幂等性(业务层去重)
// 方法1:数据库唯一索引
CREATE TABLE orders (
order_id VARCHAR(50) PRIMARY KEY, -- 唯一索引
amount DECIMAL(10, 2),
status VARCHAR(20)
);
// 重复消息insert时会失败,忽略错误即可
try {
db.insert("INSERT INTO orders VALUES (?, ?, ?)", order_id, amount, status);
} catch (DuplicateKeyException e) {
// 忽略重复消息
}
// 方法2:Redis去重
String dedupeKey = "dedupe:order:" + order_id;
if (redis.setnx(dedupeKey, "1", "EX", 86400)) { // 24小时过期
// 首次处理
processOrder(order);
} else {
// 重复消息,跳过
return;
}
// 方法3:版本号/时间戳
UPDATE orders
SET amount = ?, status = ?, version = version + 1
WHERE order_id = ? AND version = ?;
// 如果version不匹配,更新失败,说明已处理过
// 解决方案2:精确一次语义(Exactly Once Semantics, EOS)
// 启用事务
props.put("isolation.level", "read_committed");
consumer.subscribe(Arrays.asList("orders"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
producer.beginTransaction();
try {
for (ConsumerRecord<String, String> record : records) {
// 处理消息
processRecord(record);
// 发送结果到另一个topic
producer.send(new ProducerRecord<>("results", "result"));
}
// 提交offset和消息发送,原子性操作
producer.sendOffsetsToTransaction(
currentOffsets(records),
consumer.groupMetadata()
);
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
}
}
三、可靠性级别对比
| 级别 | 配置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 至少一次 | acks=1, 手动提交offset | 简单,不丢消息 | 可能重复 | 日志收集 |
| 至多一次 | acks=0, 自动提交offset | 性能高 | 可能丢消息 | 指标监控 |
| 精确一次 | idempotence + transaction | 不丢不重 | 性能略低,复杂 | 金融交易 |
四、实战建议
高可靠配置(金融、交易):
# Producer
acks=all
retries=Integer.MAX_VALUE
max.in.flight.requests.per.connection=1
enable.idempotence=true
transactional.id=my-tx-id
# Broker
replication.factor=3
min.insync.replicas=2
unclean.leader.election.enable=false # 禁止非ISR副本成为Leader
# Consumer
enable.auto.commit=false
isolation.level=read_committed
高吞吐配置(日志、监控):
# Producer
acks=1
batch.size=32768
linger.ms=10
compression.type=lz4
# Consumer
enable.auto.commit=true
auto.commit.interval.ms=5000
max.poll.records=1000