缓存与数据库场景
开篇:缓存和数据库是分布式系统里最容易"打架"的两个组件
做后端开发,迟早要面对一个现实:缓存和数据库之间的协作,远比教科书上画的箭头复杂得多。一个看似简单的"先更新数据库再删缓存"的操作,在高并发、主从延迟、网络抖动面前,都可能翻车。
这篇文章不重复 Redis 和 MySQL 的基础原理(那些内容在 Redis 模块 和 MySQL 模块 里有详细展开),而是聚焦于真实场景:分布式锁的主从切换陷阱、缓存一致性方案的取舍、穿透雪崩击穿的实战处理、大表 DDL 的生产操作、数据迁移的完整链路,以及面试中最爱问的那些"如果...怎么办"类问题。
一、分布式锁:主从切换的单点陷阱
场景还原
使用 Redis 集群部署分布式锁时,客户端 A 在 master 节点加锁成功。但在锁信息同步到 slave 之前,master 挂了,slave 被提升为新 master。这时客户端 B 来加锁——新 master 上没有这把锁的记录,于是 B 也加锁成功了。

所以结论很清晰:主从同步已完成,B 无法加锁;同步未完成,B 可以加锁成功。 这就是 Redis 分布式锁的单点问题。
RedLock 方案与争议
Redis 作者 Antirez 提出过 RedLock 方案:在 N 个独立的 Redis 实例上同时加锁,超过半数成功才算获取锁成功。理论上可以解决单点问题,但分布式系统领域的专家 Martin Kleppmann 对此提出了强烈质疑——时钟漂移、GC 暂停等问题都可能让 RedLock 失效。
实际工程中,大多数团队选择的路线是:Redis 集群做高可用 + 业务层面做幂等兜底,而不是去实现 RedLock。因为 Redis 挂掉的概率本身就很低,为极低概率事件引入复杂方案的 ROI 并不划算。
Redis 挂了,分布式锁怎么降级?
面试官经常会追问。答案不是"如何恢复 Redis"(那是运维的事),而是作为开发你有没有做好降级预案。
两种降级思路:
降级为本地锁:Redis 不可用时,切换到 ReentrantLock 或 synchronized。缺点是无法保证全局互斥,但如果底层有乐观锁、唯一索引等兜底,可以接受。
降级为其他分布式锁:比如 ZooKeeper 或数据库实现的分布式锁。优点是仍然全局互斥,缺点是已有的 Redis 锁无法同步过来,方案也比较复杂。
如何实现降级?代码中预留开关,通过配置中心动态切换:
if (useRedisLock) {
// Redis 分布式锁
} else {
// 降级锁(本地锁 or ZK 锁)
}也可以做自动降级——重试几次 Redis 加锁失败后自动切到本地锁:
boolean lockAcquired = false;
for (int i = 0; i < RETRY_COUNT; i++) {
lockAcquired = tryRedisLock();
if (lockAcquired) break;
Thread.sleep(RETRY_DELAY);
}
if (!lockAcquired) {
localLock.lock(); // 降级
try { doBusiness(); } finally { localLock.unlock(); }
} else {
try { doBusiness(); } finally { releaseRedisLock(); }
}Redis 整体挂了,业务怎么办?
不只是分布式锁,如果 Redis 承担了缓存、排行榜、计数器等多种角色,全面不可用时需要更完整的应急:
- 监控发现:Redis 调用成功率监控 + 告警
- 限流降级:核心场景降级到数据库 + 限流挡流量,保证不把数据库也打垮
- 本地缓存兜底:Caffeine/Guava 做一层本地缓存,虽然不一致但至少能扛住
- 热备切换:主备 Redis,故障时快速切到备份实例
这些都需要提前设计好,配置好预案开关。线上出故障后再做这些,一定来不及。
二、缓存一致性:没有银弹,只有取舍
缓存和数据库的双写一致性问题,本质上是一个分布式数据同步问题。先说结论:在不引入分布式事务的前提下,无法做到强一致,只能做到最终一致。
常见方案对比
| 方案 | 思路 | 优点 | 缺点 |
|---|---|---|---|
| 先更新DB,再删缓存 | Cache-Aside 经典模式 | 简单,大多数场景够用 | 极端并发下有短暂不一致窗口 |
| 延迟双删 | 删缓存→更新DB→延迟再删缓存 | 降低不一致窗口 | 延迟时间难以精确控制 |
| Canal 监听 binlog | DB变更通过 Canal 异步更新缓存 | 解耦,不侵入业务代码 | 有延迟,架构复杂度高 |
| 先更新DB,再更新缓存 | 直接更新缓存值 | 看似简单 | 并发下可能出现旧值覆盖新值,不推荐 |
实际工程中最常用的就是 Cache-Aside + 监控告警。 出现不一致时通过对账机制发现并修复,比追求理论上的零不一致要务实得多。
事务中不要做外部调用
这是一个常被忽视的坑。在 @Transactional 方法里调 Redis、发 MQ、调 RPC,都是在自找麻烦:
@Transactional
public void order(OrderDTO orderDTO){
orderService.createOrder(orderDTO); // 本地DB操作
mqService.send(orderDTO); // 远程调用 ← 问题在这
}带来的问题:
- 拖长事务:外部调用耗时不可控,事务持续时间增加,占用数据库连接
- 增加死锁概率:长时间持有锁,和其他事务冲突的概率变大
- 原子性无法保证:调外部接口超时了,本地事务回滚了,但远程操作可能已经成功
- 非核心链路拖垮核心:发 MQ 失败导致订单回滚,明显不合理
正确做法:事务和外部调用分离——事务提交后再异步发消息,或者使用补偿事务/事件驱动架构。
三、缓存穿透、雪崩、击穿:从理论到实战
这三个概念大家都背得出来,但面试官想听的是你在生产环境中怎么处理的。
穿透:查不存在的数据
恶意请求不断查询数据库中不存在的 key,缓存永远查不到,请求全部打到数据库。
实战方案:
- 布隆过滤器做前置拦截,不存在的 key 直接返回
- 空值也缓存,但设置较短的过期时间(比如 30 秒)
- 对请求参数做合法性校验,明显不合法的直接拒绝
雪崩:大量 key 同时过期
大量缓存在同一时刻过期,请求瞬间全部打到数据库。
实战方案:
- 过期时间加随机值,避免同时失效
- 热点数据永不过期,通过后台任务定期刷新
- 多级缓存:本地缓存 + 分布式缓存,分散压力
击穿:单个热点 key 过期
某个热点 key 过期的瞬间,大量并发请求同时查询这个 key。
实战方案:
- 互斥锁:只让一个线程去查数据库重建缓存,其他线程等待
- 热点 key 永不过期,逻辑过期 + 异步刷新
关于 Redis 的数据结构、过期策略、淘汰策略等基础内容,参见 Redis 模块。
四、大表 DDL 与数据清理
千万级大表加字段
核心问题是加字段过程中可能锁表,影响线上业务读写。
方案一:Online DDL(MySQL 5.6+ InnoDB)
ALTER TABLE order_table
ADD COLUMN hollis_test INT DEFAULT 0,
ALGORITHM=INPLACE, LOCK=NONE;Online DDL 允许在 DDL 操作期间继续执行 DML,但仍会占用 CPU/内存/IO,所以必须选择非业务高峰期。某些涉及索引重建的复杂操作仍可能出现短暂锁表。
方案二:pt-online-schema-change(Percona 工具)
适用于不能使用 Online DDL 的场景。核心原理:创建带新字段的临时表 → 通过触发器增量复制数据 → 切换表名。
pt-online-schema-change \
--alter="ADD COLUMN new_column INT DEFAULT 0 COMMENT '新字段'" \
D=database_name,t=order_table \
--execute --no-drop-old-table --max-load="Threads_running=50"--no-drop-old-table 保留旧表便于回滚,--max-load 在负载超阈值时自动暂停。
方案三:gh-ost(GitHub 开源),思路类似但不依赖触发器,而是通过 binlog 同步增量数据,对生产环境更友好。
千万级大表数据清理
直接执行 DELETE FROM table WHERE gmt_create < SUBDATE(CURDATE(), INTERVAL 300 DAY) 在大表上跑,问题一箩筐:
- 没有索引就全表扫描 + 大范围加锁,效果相当于锁表
- 单条 SQL 产生的 binlog 超过
max_binlog_cache_size会直接报错 - 大量删除触发频繁的索引重建和磁盘 IO
正确做法是按主键分批删除(阿里云 DMS 数据清理的实现思路):
- 获取主键的最大值和最小值:
SELECT MIN(id), MAX(id) FROM table - 按主键范围分段(比如每段 1000 条 ID)
- 在每个分段内判断是否有需要删除的数据
- 按主键批量删除,走主键索引,不会大范围加锁
可以进一步设置执行窗口(比如只在凌晨 2-6 点执行),超出窗口就暂停,避免影响白天的线上业务。
五、InnoDB 与 Redis 底层对比:B+树 vs 跳表
这是一道经典题,核心不是数据结构本身,而是为什么这么选——存储介质决定了数据结构的选择。
B+树是磁盘 IO 友好型的数据结构:
- 叶子节点形成有序链表,范围查询只需顺序扫描,对磁盘预读友好
- 节点大小固定为一页(16KB),一次磁盘 IO 读一个节点
- 非叶子节点只存 key+指针,一个节点能存约 1170 个指针(bigint 主键 + 6 字节指针),3 层高度可存 1170 x 1170 x 16 ≈ 2000 万条记录
- 2000 万数据只需 3 次磁盘 IO
跳表是内存友好型的数据结构:
- 实现简单,不需要像 B+树那样考虑节点分裂合并
- 插入删除性能好(随机层高,O(logN)),Redis 的有序集合经常做 ZADD/ZREM
- 不需要考虑磁盘页对齐和预读问题
所以 MySQL 选 B+树是因为在磁盘上做范围查询的效率极高,Redis 选跳表是因为在内存中跳表足够简单且性能好。
另外纠正一个常见误区:MongoDB 在 3.2 之后默认存储引擎 WiredTiger 用的也是 B+树了,不再是旧版的 B 树。
关于 B+树层高与数据量的计算推导,参见 MySQL 模块。
六、数据迁移与对账
亿级数据平滑迁移
假设场景:技术架构变化,旧表要迁到新表,表结构不同,数据量一亿,每天增量几十万,不能停机。
迁移的核心流程分为五个阶段:
增量双写是整个迁移的基石。建议用代码实现(而非 Canal),因为代码方便做灰度开关切换、控制读写优先级。双写分两个阶段——第一阶段旧表必须成功(因为读在旧表上),第二阶段新表必须成功。通过配置中心的开关做切换。
存量迁移有几个关键约束:
- 旧表加标记位,支持断点续传
- insert 前检查新表是否已存在(避免覆盖增量数据)
- 分批执行 + 多线程提速
数据核对要贯穿全程。推荐旁路验证:正常读旧表时,异步读一次新表做字段级对比,不一致就告警。
切流先切读、再切写,每步都灰度(10%→30%→50%→100%)。切写是唯一不可回滚的步骤,切之前务必做充分核对。
数据对账怎么做
在分布式系统中,最终一致性方案总有可能出现不一致。对账就是用来发现这些问题的。
技术实现上主要两种:写代码核对和写 SQL 核对。代码核对(定时任务扫表比较)在数据量大时效率差,不推荐。SQL 核对效率更高:
SELECT out_biz_no, bill_no FROM bill_item
WHERE out_biz_no IN (
SELECT biz_id FROM case_detail WHERE state = "COLLECTING" AND amount > 0
) AND charge_on - charge_off = 0;核对跑在哪里?按推荐程度排序:
- 准实时数据库(如 ADB):通过 binlog 同步数据,秒级延迟,最推荐
- 在线数据库备库:直接跑 SQL,但注意跨库 join 的限制
- 离线数仓:D+1 核对,发现问题慢但稳定
- Flink 流式核对:实时性最好,但开发成本高
日切点的数据误报问题也值得注意:A 系统用业务发生时间,B 系统用处理完成时间。23:59:59 发起的支付 00:00:01 才处理成功,两边归属不同的日期。解决方案是定义日切窗口(如 23:55-00:05),窗口内的不一致数据不告警,第二天二次核对。
七、常见场景面试题精选
场景一:MySQL 突然断电,数据会丢吗?
结论:配置得当的 InnoDB 不会丢已提交事务的数据。
关键参数是 innodb_flush_log_at_trx_commit:
- =1(默认):每次事务提交都写 redo log 并刷盘。断电后通过 redo log 恢复,已提交数据不丢
- =0:每秒刷一次盘,最多丢 1 秒数据
- =2:提交时写文件但不刷盘,依赖 OS 缓存,有风险
未提交的事务会在重启后被回滚——这是正常行为,不算数据丢失。MyISAM 没有事务日志机制,断电可能真的丢数据甚至表损坏。
此外要注意操作系统层面的磁盘缓存:即使 MySQL 写了 fsync,数据也可能停留在磁盘控制器缓存中。生产环境建议关闭磁盘缓存或使用带电池保护的 RAID 卡。很多公司还会部署 UPS(不间断电源),确保断电时有足够时间完成刷盘。
场景二:热点数据更新的连锁反应
大促期间某个爆品的库存扣减,就是典型的热点行更新场景。大量并发 UPDATE products SET stock = stock - 1 WHERE id = xxx 会引发一系列问题:
- 锁竞争:InnoDB 自动对 update 行加排他锁,大量请求被阻塞
- 连接占满:被锁阻塞的线程持有数据库连接不释放,连接数耗尽
- CPU 飙高:大量自旋等待 + 死锁检测消耗 CPU
- 索引维护开销大:频繁更新触发索引重建
- 主从延迟放大:主库频繁更新,binlog 同步跟不上
解决思路多管齐下:库存预扣减到 Redis、前端请求批量合并、数据库层面做库存分桶拆分。
场景三:Redis 内存满了会挂吗?
不会。Redis 有内存淘汰策略(通过 maxmemory-policy 配置),即使内存满了也不会崩溃:
| 策略 | 行为 |
|---|---|
| noeviction | 拒绝写入,返回 OOM 错误(但进程不挂) |
| allkeys-lru | 淘汰所有 key 中最近最少使用的 |
| volatile-lru | 只淘汰设了过期时间的 key 中的 LRU |
| allkeys-lfu | 淘汰所有 key 中访问频率最低的 |
| volatile-ttl | 淘汰 TTL 最短的 key |
Redis 本身设计上不会因内存满而崩溃。但如果选了 noeviction 又不断写入,所有写操作都会报错——虽然不挂但也不可用了。所以生产环境一定要根据业务选合适的淘汰策略。
场景四:分布式锁加在事务外面还是里面?
建议:先加锁,再开事务。 即锁的粒度大于事务粒度。
lock.lock();
try {
doTransactionalWork(); // 内部有 @Transactional
} finally {
lock.unlock();
}如果反过来(事务包锁),有两个致命问题:
- 在事务中做了外部调用(Redis 加锁是远程操作),拖长事务、占用连接
- 锁释放后事务还没提交——线程 B 拿到锁后读到的是旧数据,因为线程 A 的事务还没 commit
虽然"锁在外"会让锁粒度稍大,但 Redis 资源比数据库连接便宜得多,锁时间长一点的开销完全可以接受。而且分布式锁本来就有续期机制,锁的持有时间本身就不短。
场景五:ZSET 排行榜,分数相同按时间排序
ZSET 按 score 降序排列,分数相同时默认按 value 字典序排——不符合"先得分的排前面"的需求。
解决思路:把 score 设计成浮点数,整数部分是分数,小数部分编码时间信息:
score = 分数 + (1 - 时间戳 / 1e13)时间戳有 13 位,除以 1e13 变成小数。用 1 - 时间戳/1e13 保证时间戳越小(越早)得到的小数部分越大,排名越靠前。
public static void addMember(String member, int score, long timestamp, Jedis jedis) {
double finalScore = score + 1 - timestamp / 1e13;
jedis.zadd("ranking", finalScore, member);
}场景六:如何保证 Redis 中都是热点数据?
MySQL 有 2000 万数据,Redis 只存 20 万——怎么保证这 20 万都是热的?四个层面组合使用:
- 数据预热:上线前根据业务预判,提前把当前或即将变热的数据加载到缓存
- 实时热点检测:使用京东开源的 hotkey 框架 实时收集热 key,动态加载
- 淘汰策略:配置 LRU 或 LFU,自动淘汰不常用的 key。LRU 适合短期高频访问,LFU 适合长期稳定的热点
- 缓存过期:给 key 设合理的 TTL,结合
volatile-lru策略自动腾空间
场景七:SQL 调优的完整排查路径
发现慢 SQL 后的排查路径:
一个真实案例:线上定时任务扫表 SQL 执行 16 秒。执行计划显示走了 PRIMARY 索引,扫描 800 万行。
SELECT * FROM table_name
WHERE DELETED = 0 AND STATE = "INIT" AND ID >= 474968311 AND event_type = ""
ORDER BY id LIMIT 100原因是 ORDER BY id 导致优化器选了主键索引。MySQL 为了避免 filesort,倾向于使用与 ORDER BY 字段一致的索引。但 id >= 474968311 之后有 1700 多万条数据要扫描。
解决方案:
-- 方案一:FORCE INDEX 强制走业务索引
SELECT * FROM table_name FORCE INDEX(idx_state_event_deleted) ...
-- 方案二:换 ORDER BY 字段为 gmt_create,避免优化器选主键索引
SELECT * FROM table_name ... ORDER BY gmt_create LIMIT 100方案一效果最好:扫描行数从 800 万降到 20 万,RT 从 16 秒降到 100 毫秒,性能提升 160 倍。
索引失效的常见原因(通过 EXPLAIN 发现 type=ALL, key=NULL):
- 索引列参与计算:
WHERE age + 1 = 12失效,但WHERE age = 12 - 1不失效 - 索引列使用函数:
WHERE YEAR(create_time) = 2022失效 - 隐式类型转换:varchar 字段
WHERE name = 1用 int 查询会失效 - LIKE 左模糊:
WHERE name LIKE '%Hollis'失效,'Hollis%'不失效 - OR 中混合范围条件:
WHERE name = 'x' OR age > 18可能失效
场景八:长事务的危害
长事务不是一个"最好避免"的建议,而是一个"必须避免"的要求。它的危害是全方位的:
- 长时间占用数据库连接:连接数是有限资源,占一个少一个
- 锁持有时间长:导致更多锁冲突甚至死锁
- MVCC 旧版本堆积:长事务导致 undo log 无法被 purge,影响整体性能
- 其他事务的回表代价增加:需要做更多的版本重建
- 可能导致覆盖索引失效:长事务修改表时,其他查询可能无法用覆盖索引
所以要用编程式事务代替声明式事务(@Transactional 是方法级粒度,太粗了),把非数据库操作(内存计算、远程调用)移到事务外。
场景九:分库分表的数据倾斜
按城市分表时,北京、上海的数据量远大于其他城市,导致某些分表特别大。
方案一:换分表键。 如果业务没有按城市查询的硬性要求,改用更均匀的字段(如用户 ID)取模。
方案二:人工干预分表算法。 把人口密集型城市单独建表,其他城市共用:
if (isDenseCity(cityId)) {
// 北京→table_0, 上海→table_1, 广州→table_2, 深圳→table_3
return "table_" + denseIndex(cityId);
} else {
return "table_" + (cityId % 4 + 4); // 其他城市共用 table_4~7
}方案三:对密集城市二次拆分。 比如北京数据太多,就用 城市ID + 区ID 取模,让朝阳区和海淀区的数据分散到不同表。
注意:不要用轮询。分表算法在插入和查询时都要用,轮询插入后查询时无法定位到正确的表。
场景十:百万级 Excel 导入数据库
百万级数据的 Excel 文件不能一次性加载到内存,否则 OOM。整体方案:
- 流式读取:用 EasyExcel(基于 SAX 的流式解析,不把整个 Excel 加载到内存)
- 多线程并发:把数据分到不同 sheet,线程池并发读取不同 sheet
- 分批写入:ReadListener 中每积累 1000 条执行一次 MyBatis 批量 insert
- 错误处理:插入失败重试 3 次,仍失败则记录日志跳过,不阻塞整体流程
- 唯一性校验:插入前检查是否已存在,冲突时跳过 + 记录日志
经过验证,100 万条数据的 Excel 读取 + 插入,耗时在 100 秒左右。
八、缓存设计与多级缓存
设计一个缓存需要考虑什么?
本地缓存需要考虑:
| 方面 | 要点 |
|---|---|
| 数据结构 | Key-Value 结构,ConcurrentHashMap 为基础 |
| 线程安全 | 全局变量,多线程并发访问,必须线程安全 |
| 对象上限 | JVM 堆内存有限,不能无限存储 |
| 清除策略 | LRU / LFU / FIFO 等,防止 OOM |
| 过期时间 | 保证数据不会永远驻留,兜底清除策略 |
Caffeine 是本地缓存的标杆实现:ConcurrentHashMap + CAS 保证线程安全,Window TinyLFU 算法结合 LRU 和 LFU 的优点做淘汰,时间轮管理过期。
分布式缓存在此基础上还要考虑持久化(AOF+RDB)和集群模式。
多级缓存:秒杀场景的五层缓存
一个真实的秒杀业务,从前端到后端可能有五层缓存:

- 客户端缓存:秒杀倒计时只请求一次开始时间,客户端本地计时
- CDN 缓存:商品图片、JS 等静态资源就近返回
- Nginx 缓存:用户鉴权信息(是否黄牛、IP 是否被封)在 Nginx 层拦截
- 本地缓存:秒杀活动时间、用户等级等变化不频繁的数据
- Redis 分布式缓存:库存扣减在 Redis 中完成,保证一致性
每一层都在减少下一层的压力。层级越靠前,能拦截的无效请求越多。
缓存预热方案
缓存刚上线或应用重启后,缓存是空的,所有请求都打到数据库——这就需要预热。
| 方案 | 适用场景 |
|---|---|
| 启动时预热(ApplicationReadyEvent / @PostConstruct) | 本地缓存,应用重启时必须预热 |
| 定时任务刷新 | 运行过程中持续保持缓存新鲜 |
| 用时加载(Lazy Loading) | 按需加载,灵活但首次请求慢 |
| 缓存加载器(如 Caffeine 的 LoadingCache) | 自动加载 + 自动刷新,最省心 |
生产环境中通常是组合使用:启动时预热核心数据 + 定时任务刷新 + 用时加载兜底。
九、实战设计题
点赞系统设计
点赞最大的难点不是功能本身,而是高并发下的热点更新。
低并发场景(大部分业务):直接数据库 UPDATE SET like = like + 1 就行了。
高并发场景(直播间点赞):本质上就是热点行更新问题。解决方案:
- 前端批量合并:用户连续点赞不要每次都发请求,攒一批再发
- Redis 做累加:利用 Redis 原子操作做实时累加,定期同步到数据库
- 数据库拆分:如果直接用数据库,把计数分桶存储降低并发
查询时先查 Redis,Redis 没有再查数据库。对于直播间点赞这种场景,10086 还是 11111 次点赞其实差别不大,对一致性要求很低。
百万级排行榜设计
单个 ZSET 存百万用户,成员数远超 10000,是典型的 BigKey 问题。
核心解法:数据分片。 按省份、按用户 ID 范围等维度拆分到多个 ZSET。需要全国排名时,从每个分片取 Top N,再做一次综合排序。
甚至可以维护一个汇总 ZSET(比如 3400 人 = 34 个省 x 100),数据来源是各省份 ZSET 的定时同步。
配合异步批量更新(不是每次分数变化都立即写入 ZSET,而是通过 MQ 或定时任务批量更新)降低写入压力,以及 Redis Cluster 分布式部署提升容灾能力。
数据归档方案
数据归档就是把不再活跃的历史数据从主表移到低成本存储,降低主表数据量,提升查询性能。
| 方案 | 适用场景 | 是否支持查询 |
|---|---|---|
| 分库分表归档(主表→历史表) | 最常见,简单直接 | 是(查历史表) |
| 分区表归档 | 按时间分区,旧分区自然冷却 | 是 |
| 离线数仓归档 | 数据分析、合规留存 | 是(查询慢) |
| 分布式存储(ClickHouse/ES) | 需要对历史数据做复杂查询 | 是(性能好) |
| 文件/对象存储备份 | 纯备份,不需要查询 | 否 |
最常见的做法就是历史表 + 离线数仓,兼顾了查询需求和存储成本。
小结
缓存和数据库的场景题,考察的不是你能不能背出"先更新 DB 再删缓存"这种教科书答案,而是你在真实系统中有没有思考过以下问题:
- 一致性和可用性的取舍:你的业务到底需要强一致还是最终一致?
- 异常情况的预案:Redis 挂了、数据库锁超时、大表 DDL 执行中途失败,你准备好了吗?
- 成本和收益的平衡:RedLock 理论上更安全,但值得为此增加架构复杂度吗?
没有银弹,只有适合业务场景的方案。面试中能清晰说出方案的优缺点和你的选择理由,比背出一个"正确答案"有价值得多。
附录:更多高频场景题
以下是一些面试中频繁出现、且具有较强实战参考价值的场景补充。
A1:千万级数据的查询优化思路
面试官说"千万级数据,查询如何优化?"的时候,大多数人第一反应是分库分表——但其实 MySQL 单表 2000 万甚至 5000 万以内的数据,只要做好以下几点,性能完全够用:
索引优化(最立竿见影):
- 联合索引比单字段索引效果更好,前缀长度越长效果越好
- 能用到索引覆盖就用,避免回表(
SELECT a, b FROM t WHERE a = 1,如果 (a,b) 是联合索引就不需要回表) - 避免索引失效:函数、类型不一致、不遵守最左前缀、模糊查询左 % 等
- 选择区分度高的字段建索引,但也不绝对——要看过滤效果
- 一定要通过 EXPLAIN 确认走了索引、走对了索引
避免多表 JOIN:通过适当的字段冗余减少 join。如果必须 join,用小表驱动大表,关联条件走索引。
避免 SELECT *:没法用索引覆盖,还会因为查询字段多导致更多磁盘 IO。
避免深分页:LIMIT 100000, 100 会扫描 10 万条无效数据。用"记录上一页最大 ID"方案替代:
-- 第 N 页(上一页最后 id 为 500)
SELECT * FROM large_table WHERE id > 500 ORDER BY id ASC LIMIT 100;或者子查询方式:
SELECT * FROM large_table
WHERE id >= (SELECT id FROM large_table ORDER BY id ASC LIMIT 1000, 1)
ORDER BY id ASC LIMIT 100;降低事务粒度:长事务占用数据库连接,连接数有限。
使用缓存:千万级数据量的系统,上 Redis 缓存是标配。但不建议用 MySQL 自带缓存或 MyBatis 缓存——直接用 Redis,行为透明可控。
数据归档:6 个月前的数据很少查了,移到历史表,主表保持精简。
以上都做了还不行?再考虑分库分表或上搜索引擎。
A2:扫表任务的分页 SQL 怎么写才不跳页?
最简单的分页:SELECT * FROM t WHERE state = 'INIT' ORDER BY id LIMIT 0, 100
查完第一页处理后,状态变成 SUCCESS,这时候查 LIMIT 100, 200 的 INIT 数据——实际上已经跳过了原来的第二页(因为第一页已经变了)。
用游标方案解决:记录上一页处理过的最大 ID,下次查询用 id > ${last_max_id}:
SELECT * FROM t WHERE state = 'INIT' AND id > ${last_max_id} ORDER BY id LIMIT 100每次都记录最大 ID,就能保证不重复不丢失。
A3:在 for 循环中调数据库的问题
这是代码审查中经常看到的反模式。问题在于:
- 每次循环都创建和关闭数据库连接(或至少一次网络交互),网络开销累积很大
- 如果是写操作,事务管理困难——整个 for 包大事务?风险极大;每条单独事务?回滚困难
- 方法整体耗时随循环次数线性增长
替代方案:
- 读操作用
IN查询代替循环:SELECT * FROM t WHERE id IN (1, 2, 3, 4) - 写操作用 MyBatis 批量操作:
batchInsert/batchUpdate - 考虑异步化:通过扫表 + 线程池批量处理
A4:数据库逻辑删除后的唯一性约束
场景:用户开通记录表,同一用户同一产品只能有一条有效记录。用 is_deleted=0/1 做逻辑删除后,重新开通就违反了 (user_id, product_code, is_deleted) 的唯一约束(多次退出 is_deleted 都是 1)。
四种方案:
方案一:物理删除 + 归档。 删除时同时 insert 到历史表,数据分析基于历史表。或者依赖离线数仓(每天凌晨全量同步,delete 操作不同步)。
方案二:复用记录 + 流水表。 同一用户只保留一条记录,状态在 ACTIVE 和 QUIT 之间切换。所有操作都记录流水,数据分析基于流水表。
方案三:is_deleted 用递增值。 is_deleted=0 表示未删除,退出时设置为主键 ID(自增的,天然唯一)。联合唯一索引 (user_id, product_code, is_deleted) 就能保证唯一了。
方案四:加 deleted_time 字段。 退出时记录删除时间戳,联合唯一索引加上这个字段。或者去掉 is_deleted,通过 deleted_time 是否为空判断是否删除。
实际项目中这些方案常组合使用。
A5:for update 加锁和 Redis 分布式锁怎么选?
结论很简单:
- 单体应用、纯数据库操作 → 直接
SELECT FOR UPDATE - 分布式应用 → Redis 分布式锁
- 不想引入 Redis → 数据库
SELECT FOR UPDATE也能当分布式锁用(但性能差)
除了上面两种特殊情况,推荐 Redis 分布式锁,原因:
- Redis 更快,响应时间短
- 支持可重入、续期等高级特性
- 数据库连接比 Redis 连接珍贵得多——锁这种非业务逻辑操作让 Redis 抗
SELECT FOR UPDATE可能锁表影响其他查询
另外注意乐观锁和悲观锁的选择:高并发写场景用悲观锁(先加锁再干活),不要用乐观锁(先干一堆活最后更新发现版本变了,做了无用功)。
A6:为什么不允许物理删除数据?
很多公司直接禁止 DELETE 操作,只允许逻辑删除,原因包括:
- 数据留痕:删了就没了,数据分析、历史排查都做不了
- 合规要求:金融、医疗等行业有数据保留法规
- 数据完整性:外键引用的数据被删了,其他表就不一致了
- 性能影响:大量物理删除触发索引重建,影响正常读写
- 内存碎片:InnoDB 的 delete 只是标记,不立即释放空间,造成数据页碎片
但也有例外:数据归档场景需要物理删除(从主表移到历史表),但删除后通常要做碎片清理(OPTIMIZE TABLE)。
A7:分库分表不是万能药
单表数据量大的时候,很多人第一反应就是分库分表。但分库分表会引入跨库事务、分页查询、路由规则维护等一系列复杂问题。不到万不得已,不建议直接上分库分表。
优先级更高的方案:
- 索引优化 + SQL 优化:大多数情况下这就够了
- 缓存:减少数据库查询压力
- 数据归档:冷热分离,主表保持精简
- 分区表:按时间分区,逻辑上仍是一张表
- 硬件提升:4C8G 和 64C512G 差距巨大
- 分布式数据库(OceanBase/TiDB):比分库分表改造收益更大
A8:各类数据库的适用场景
一图胜千言——不同存储各有所长,通常混着用:
| 数据库 | 定位 | 适用场景 | 不适用场景 |
|---|---|---|---|
| MySQL/PostgreSQL | 关系型,强事务 | 订单、支付、账户 | 全文搜索、海量数据分析 |
| Redis | 内存 KV,高性能 | 缓存、排行榜、Session | 持久化主存储(内存贵) |
| Elasticsearch | 搜索引擎,倒排索引 | 全文搜索、日志分析 | 事务操作、精确查询 |
| MongoDB | 文档型,灵活 Schema | 日志存储、快速原型 | 复杂事务、强一致 |
| HBase | 列式存储,基于 HDFS | 离线统计、数据归档 | 实时查询 |
典型的混合架构:
- MySQL 做事务:保障数据持久化和一致性
- Redis 做缓存:热点数据快速返回
- ES 做搜索:文档检索和模糊查询
- HBase/数仓做离线:对账、报表、数据分析
A9:用主键索引反而查询很慢的案例
线上定时任务扫表超时,SQL 如下:
SELECT * FROM table_name
WHERE DELETED = 0 AND STATE = "INIT" AND ID >= 474968311 AND event_type = ""
ORDER BY id LIMIT 100执行计划:type=range, key=PRIMARY, rows=8121269。走了主键索引但扫描 800 万行。
为什么会这样? MySQL 的 ORDER BY 优化机制(参见官方文档 Order By Optimization)会倾向于使用与 ORDER BY 字段一致的索引来避免 filesort。ORDER BY id 导致优化器选了主键索引。
但 id >= 474968311 之后有 1700 多万条记录,这些记录要逐条读取后再做 WHERE 过滤(DELETED、STATE、event_type),效率极低。
解法:让优化器不选主键索引。
方法一:FORCE INDEX(idx_state_event_deleted) 强制走业务索引。扫描行数从 800 万降到 20 万,RT 从 16 秒降到 100 毫秒。
方法二:把 ORDER BY id 改成 ORDER BY gmt_create。优化器不再倾向主键索引。RT 降到 500 毫秒。如果把 gmt_create 也加到联合索引中效果更好。
A10:服务器多节点,用户访问缓慢,CPU 和缓存正常
这是一道排查思路题,关键信息是"多节点"、"缓慢"、"CPU 和缓存无压力"。
首先确认是个例还是普遍问题。 如果只有个别用户慢,可能是用户自己的网络、浏览器问题。
排除个人问题后,按以下路径排查:
面试官提到"多个节点"是有意的——需要检查负载均衡是否把请求分到了不健康的节点上。
CPU 正常但 RT 长的高频原因:
- 网络问题:带宽打满、网络延迟、丢包、CDN 节点故障
- 磁盘 IO 瓶颈:大量日志写入是最常见的原因
- 外部依赖超时:调下游接口等待返回
- 数据库性能:慢 SQL、连接数不够、锁阻塞
- GC 暂停:虽然 CPU 整体不高,但 GC 期间线程停顿
- JIT 未预热:刚重启的节点解释执行很慢(参见 CPU 与负载场景)
实际排查路径(以内部监控完善为前提):
- 查看监控是否误报,异常是否在持续
- 检查是否正在发布(代码问题 or 未预热)
- 按机房/分组对比,确定影响范围
- 查看 QPS 是否突增(流量问题就扩容)
- 查看 GC 情况
- 定位到具体的慢接口和下游依赖
- 检查 Redis/MySQL 的慢查询
- 检查宿主机网络重传等底层问题
A11:如何实现缓存预热?
缓存预热是在系统启动或业务高峰期之前,提前把数据加载到缓存中。看似简单的需求,实际落地有多种方案,需要根据场景选择。
启动时预热:本地缓存最常用的方案。利用 Spring 的扩展点,在应用启动完成后自动加载数据:
@Component
public class CacheWarmer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
// 加载热点数据到本地缓存
List<Product> hotProducts = productService.getHotProducts();
hotProducts.forEach(p -> localCache.put(p.getId(), p));
}
}也可以用 @PostConstruct、InitializingBean、CommandLineRunner 等,效果类似。
定时任务刷新:启动时加载只能解决初始问题,运行过程中数据会变化。通过定时任务定期刷新缓存,保持数据新鲜:
@Scheduled(cron = "0 0 1 * * ?") // 每天凌晨1点
public void refreshCache() {
// 重新加载热点数据
}用时加载(Lazy Loading):用户请求时才加载,最灵活但首次请求会慢:
public Data getData(String key) {
Data cached = cache.get(key);
if (cached == null) {
cached = db.query(key);
cache.put(key, cached);
}
return cached;
}缓存加载器:Caffeine 的 LoadingCache 支持自动加载 + 自动刷新,把加载逻辑封装在 build 时:
LoadingCache<String, String> cache = Caffeine.newBuilder()
.refreshAfterWrite(1, TimeUnit.MINUTES)
.build(key -> loadFromDB(key)); // 自动加载读取时直接 cache.get(key),没有就自动触发加载;过期后有读请求就自动触发刷新。
生产环境通常组合使用:启动预热核心数据 + 定时刷新保持新鲜 + 用时加载兜底。
A12:如何实现朋友圈点赞?
需求分析:记录每条朋友圈的点赞用户列表,支持点赞/取消点赞,按时间倒序展示点赞的人。
数据结构选择:ZSET。KEY 是朋友圈 ID,value 是点赞用户 ID,score 是点赞时间戳。
// 点赞
public void like(String postId, String userId, Jedis jedis) {
jedis.zadd("like:" + postId, System.currentTimeMillis(), userId);
}
// 取消点赞
public void unlike(String postId, String userId, Jedis jedis) {
jedis.zrem("like:" + postId, userId);
}
// 查看点赞列表(按时间倒序)
public Set<String> getLikes(String postId, Jedis jedis) {
return jedis.zrevrange("like:" + postId, 0, -1);
}ZSET 的优势:
- 自动去重(同一个用户只能点赞一次)
- 按 score(时间戳)排序,天然支持按时间倒序查询
- 支持高效的增删改查
A13:单表数据量大,分库分表之前还能做什么?
很多开发者一听到"千万级数据"就想到分库分表,但分库分表是有很大代价的。在此之前,至少要考虑以下方案:
数据库层面:
- 索引优化、表结构设计、SQL 改写——这是投入产出比最高的手段
- 数据归档/冷热分离——把历史数据移出去,主表常年保持精简
- 分区表——按时间、范围等分区,逻辑上仍是一张表,运维简单
架构层面:
- 上缓存——把大量读流量挡在 Redis 层
- 读写分离——写主库,读从库,分担读压力
- 搜索引擎——复杂查询场景同步到 ES 处理
硬件层面:
- 升级实例规格——4C8G 和 64C512G 的差距是巨大的
- 使用 SSD 存储——磁盘 IO 提升明显
分布式数据库:
- OceanBase、TiDB 等——兼容 MySQL 协议,水平扩展能力强,比手动分库分表收益更大
只有以上方案都不能满足需求时,才考虑分库分表。分库提升吞吐量,分表提升查询效率——要分清楚解决的是哪个问题。
A14:跨库 JOIN 的五种解法
分库分表或微服务拆分后,数据散在不同库中,直接 JOIN 不了。按实用程度排序:
1. 数据冗余(最常用)
在 orders 表冗余 user_name 字段,避免 join。适合不常修改的字段(用户真实姓名、产品名称等)。缺点是一旦源数据改了,冗余字段需要级联更新,但很多业务场景可以容忍历史数据不更新。
2. 代码中做 JOIN
先查 orders,再根据 user_id 查 users,代码中拼装。简单场景够用。
List<OrderDO> orders = getOrders();
for (OrderDO order : orders) {
String userName = getUserName(order.getUserId());
orderDTO.setUserName(userName);
}缺点是不适合复杂多表 join,且大数据量时内存压力大。
3. 同实例跨库指定库名
如果在同一个 MySQL 实例下的不同数据库,可以直接跨库 join:
SELECT o.order_id, u.user_name
FROM trade.orders o JOIN customer.users u ON o.user_id = u.id4. 宽表/ES
提前通过 ETL 把多表数据打成宽表,或同步到 ES。适合报表、搜索等读密集场景。
5. 第三方分析数据库
把数据同步到 AnalyticDB、ClickHouse 等,统一做 join 和分析。有数据延迟,适合对账、报表、监控场景。
A15:Redis 和 MySQL 的 RT 在什么范围合理?
Redis 命令执行耗时(不含网络):
- 简单命令(GET/SET):0.1ms - 1ms
- 复杂命令(ZADD/HSET):1ms - 5ms
- P99 基本在 1ms 以内
MySQL SQL 执行耗时(不含网络):
- 命中 buffer pool + 主键查询:2ms 以内
- 普通索引查询:10ms - 200ms 算正常
- 超过 1 秒算慢 SQL
加上网络耗时后:
- 局域网同机房 RTT:0.1ms - 2ms
- 同城跨机房 RTT:1ms - 5ms
- 跨省 RTT:20ms - 30ms
所以同机房内 Redis 请求 RT 在 1-3ms 是正常的,MySQL 在 10-500ms 是正常的。超过这个范围就需要排查了。