HiHuo
首页
博客
手册
工具
关于
首页
博客
手册
工具
关于
  • 技术面试完全指南

    • 技术面试完全指南
    • 8年面试官告诉你:90%的简历在第一轮就被刷掉了
    • 刷了500道LeetCode,终于明白大厂算法面试到底考什么
    • 高频算法题精讲-双指针与滑动窗口
    • 03-高频算法题精讲-二分查找与排序
    • 04-高频算法题精讲-树与递归
    • 05-高频算法题精讲-图与拓扑排序
    • 06-高频算法题精讲-动态规划
    • Go面试必问:一道GMP问题,干掉90%的候选人
    • 08-数据库面试高频题
    • 09-分布式系统面试题
    • 10-Kubernetes与云原生面试题
    • 11-系统设计面试方法论
    • 前端面试高频题
    • AI 与机器学习面试题
    • 行为面试与软技能

08-数据库面试高频题

本章导读

数据库是后端开发的核心技术之一,面试中几乎必问。本章涵盖MySQL、Redis、Kafka等主流数据库技术,包含60+道高频面试题,从基础原理到实战场景,帮助读者系统掌握数据库知识体系。

主要内容:

  • MySQL索引原理与优化
  • 事务与并发控制
  • Redis缓存设计与实战
  • Kafka消息队列架构

第一部分:MySQL索引专题

1. MySQL索引的底层数据结构是什么?为什么使用B+树?

考察点: 数据结构原理、索引设计思想

详细解答:

MySQL InnoDB引擎使用B+树作为索引的底层数据结构。

B+树的特点:

  1. 所有数据存储在叶子节点

    • 非叶子节点只存储键值用于索引
    • 叶子节点包含完整的数据记录
    • 叶子节点通过双向链表连接
  2. 多路平衡树结构

    • 每个节点可以有多个子节点(通常几百到上千)
    • 树的高度很低,一般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

总结建议:

  1. 索引列顺序设计原则:

    • 区分度高的列放前面
    • 常用查询条件的列放前面
    • 范围查询的列放后面
  2. 查询优化技巧:

    • 尽量使用索引的完整列
    • 避免在索引列上使用函数
    • 注意隐式类型转换
    • 范围查询会中断后续列的索引使用

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的事务隔离级别有哪些?分别解决什么问题?

考察点: 并发控制、事务隔离机制

详细解答:

四种隔离级别:

  1. READ UNCOMMITTED(读未提交)
  2. READ COMMITTED(读已提交)
  3. REPEATABLE READ(可重复读) - MySQL默认
  4. SERIALIZABLE(串行化)

并发问题类型:

  • 脏读(Dirty Read): 读取到其他事务未提交的数据
  • 不可重复读(Non-Repeatable Read): 同一事务内多次读取同一数据,结果不同
  • 幻读(Phantom Read): 同一事务内多次查询,返回的记录数不同

隔离级别对比表:

隔离级别脏读不可重复读幻读实现方式性能
READ UNCOMMITTED✗✗✗无锁最高
READ COMMITTED✗✗MVCC(每次生成ReadView)较高
REPEATABLE READMVCC + 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基础、实战应用

详细解答:

基本数据类型:

  1. String(字符串)

    • 应用:缓存对象、计数器、分布式锁、Session共享
  2. Hash(哈希)

    • 应用:存储对象、购物车、统计信息
  3. List(列表)

    • 应用:消息队列、最新动态、粉丝列表
  4. Set(集合)

    • 应用:标签系统、共同好友、去重、抽奖系统
  5. Sorted Set(有序集合)

    • 应用:排行榜、延迟队列、热门文章、时间线

特殊数据类型:

  1. Bitmap(位图)

    • 应用:用户签到、在线用户统计、布隆过滤器
  2. HyperLogLog(基数统计)

    • 应用:UV统计(占用空间小,适合大数据量)
  3. 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+

对比总结:

特性RDBAOF混合持久化
文件大小小大中等
恢复速度快慢快
数据安全性低(分钟级丢失)高(秒级丢失)高
性能影响低中中
适用场景备份恢复数据安全综合场景

实战建议:

# 生产环境推荐配置

# 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秒
# 如果不满足条件,主节点拒绝写入

五、主从复制的特点

优点:

  1. 读写分离:主节点写,从节点读,提高并发能力
  2. 数据冗余:多个副本,防止数据丢失
  3. 故障恢复:主节点故障,可切换从节点为主节点

缺点:

  1. 复制延迟:主从数据可能不一致(最终一致性)
  2. 故障转移需手动:主节点故障,需要手动切换(Redis Sentinel可自动切换)
  3. 全量复制开销大:网络带宽、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 |
        +--------+          +-------+

作用:

  1. 监控(Monitoring):检测主从节点是否正常
  2. 通知(Notification):故障时通知管理员或应用
  3. 自动故障转移(Automatic Failover):主节点故障,自动提升从节点为主节点
  4. 配置提供者(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}

分区的作用:

  1. 并行处理:多个消费者并行消费不同分区
  2. 负载均衡:消息分散到不同Broker
  3. 水平扩展:增加分区数可提高吞吐量

分区策略:

// 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性能特点

高吞吐量的原因:

  1. 顺序写磁盘

    • Kafka将消息顺序追加到日志文件
    • 顺序写性能接近内存(600MB/s)
  2. 零拷贝(Zero-Copy)

    • 使用sendfile系统调用
    • 避免数据在内核态和用户态之间复制
  3. 批量发送

    • 生产者批量发送消息,减少网络开销
    • 配置:batch.size、linger.ms
  4. 页缓存(Page Cache)

    • 利用操作系统的页缓存
    • 读取时直接从内存读取
  5. 分区并行

    • 多个分区并行读写

实战配置:

# 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

Prev
Go面试必问:一道GMP问题,干掉90%的候选人
Next
09-分布式系统面试题