🔗 MySQL → Redis 因果链

因为…所以… — 从业务增长到分布式存储的完整因果推导

因果推导 MySQL主从 缓存引入 Redis 缓存一致性 分库分表 分布式事务

📊 因果链总览图

❓ 业务增长
数据量↑ 并发↑
💡 索引优化 ⚡ 索引也救不了
全表扫描
💡 读写分离
⚡ 主从延迟
读到旧数据
💡 引入缓存(Redis) ⚡ 缓存穿透
缓存击穿
缓存雪崩
💡 布隆/互斥锁/随机过期
⚡ 缓存与DB
不一致
💡 延迟双删/Canal ⚡ 数据量继续↑
单库装不下
💡 分库分表
⚡ 跨库事务 💡 分布式事务
类比:一家店越做越大
数据量↑ = 客人越来越多 → 索引 = 给商品贴标签 → 读写分离 = 前台负责卖,后台负责进货 → 缓存 = 在前台放个样品架,不用每次去仓库 → 分库分表 = 开分店 → 分布式事务 = 多家分店之间要保证账目一致

① 因为:数据量增长 → 查询越来越慢

❓ 问题:全表扫描

当一张表只有几百行时,SELECT * FROM orders WHERE user_id = 123 瞬间返回。但当表增长到1000万行,MySQL必须从第一行扫到最后一行(全表扫描),查询从10ms变成10秒。

-- 没有索引:1000万行全部扫描 EXPLAIN SELECT * FROM orders WHERE user_id = 123; -- type: ALL → 全表扫描! -- rows: 10,000,000 → 扫描了1000万行
💡 所以:引入索引

索引就像字典的部首检字表——不认识字时,先查部首,再翻到对应页码,而不用从第一页翻到最后一页。

-- 添加索引:B+树快速定位 ALTER TABLE orders ADD INDEX idx_user_id (user_id); EXPLAIN SELECT * FROM orders WHERE user_id = 123; -- type: ref → 索引查找! -- rows: 50 → 只扫描了50行 -- 为什么快?B+树结构: -- 根节点 → [user_id < 500] → 左子树 -- [user_id < 1000] → 右子树 -- 高度通常3-4层,1000万行 → 只需3-4次磁盘IO
⚡ 但是:索引也救不了所有问题

1. 写入变慢:每次INSERT/UPDATE/DELETE都要同步更新B+树,索引越多,写入越慢

2. 索引失效:用了函数、隐式类型转换、OR条件、LIKE '%abc'等,索引直接不生效

3. 数据量继续增长:索引只是让"找"变快,但单表的存储上限并没有解决

-- 索引失效的常见场景 -- 1. 函数计算 SELECT * FROM orders WHERE YEAR(create_time) = 2026; -- ❌ 索引失效 SELECT * FROM orders WHERE create_time >= '2026-01-01'; -- ✅ 走索引 -- 2. 隐式类型转换 SELECT * FROM orders WHERE user_id = '123'; -- ❌ user_id是int,字符串转int导致失效 SELECT * FROM orders WHERE user_id = 123; -- ✅ 类型匹配 -- 3. OR条件 SELECT * FROM orders WHERE user_id = 123 OR status = 1; -- ❌ status没索引,全表扫描
🔗 链接:索引解决了"查"的问题,但数据量持续增长时,单表承受不住读写压力 → 引出第②环:读写分离

② 因为:并发增长 → 单机扛不住读写压力

❓ 问题:读多写少,一台MySQL扛不住

大多数业务是读多写少(电商:浏览商品100次才买1次)。一台MySQL既要处理读请求又要处理写请求,并发到5000 QPS就顶不住了。

客户端1 读 客户端2 读 客户端3 读 客户端4 写
💥 单台MySQL 压力爆了
💡 所以:读写分离(主从复制)

写入只走主库,读取分散到从库。主库写入后通过binlog同步给从库。

写请求 主库 Master
(负责写)
从库 Slave1 binlog同步 主库 Master
从库 Slave2 binlog同步 主库 Master
读请求 从库(负责读)
// Spring Boot + MyBatis 读写分离配置 @Configuration public class DataSourceConfig { @Bean @Primary public DataSource masterDataSource() { // 主库:处理写操作 HikariDataSource ds = new HikariDataSource(); ds.setJdbcUrl("jdbc:mysql://master:3306/db"); ds.setUsername("root"); ds.setPassword("xxx"); return ds; } @Bean public DataSource slaveDataSource() { // 从库:处理读操作 HikariDataSource ds = new HikariDataSource(); ds.setJdbcUrl("jdbc:mysql://slave:3306/db"); ds.setUsername("root"); ds.setPassword("xxx"); return ds; } @Bean public RoutingDataSource routingDataSource() { Map<Object, Object> targetDataSources = new HashMap<>(); targetDataSources.put("master", masterDataSource()); targetDataSources.put("slave", slaveDataSource()); RoutingDataSource routing = new RoutingDataSource(); routing.setDefaultTargetDataSource(masterDataSource()); routing.setTargetDataSources(targetDataSources); return routing; } } // 自定义注解:标记读操作走从库 @Target({ElementType.METHOD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME) public @interface Slave {} // AOP 切面:根据注解切换数据源 @Aspect @Component public class DataSourceAspect { @Before("@annotation(slave)") public void switchToSlave(Slave slave) { DynamicDataSourceContextHolder.set("slave"); } @After("@annotation(slave)") public void restoreToMaster(Slave slave) { DynamicDataSourceContextHolder.clear(); } }
⚡ 但是:主从延迟 → 读到旧数据

主库写入后,binlog异步同步到从库,这个同步需要时间(通常1-100ms)。在这段时间内,读从库会读到旧数据

用户下单 主库写入成功 ⏳ binlog同步中...
用户立刻查订单 从库还没同步 ❌ "您的订单不存在"
类比:总部改了商品价格,分店还没收到通知
你在总部(主库)改了iPhone价格从8999→7999,但分店(从库)还没收到通知,顾客在分店看到的还是8999。这就是主从延迟。
🔗 链接:主从延迟导致"写完立刻读"读到旧数据 → 需要一种更快的读取方式 → 引出第③环:引入缓存

③ 因为:主从延迟 → 写完立刻读不到最新数据

❓ 问题:写入后读不到 + DB读压力还是大

即使解决了主从延迟(强制走主库读),DB的读压力依然很大。如果所有热点数据都从DB读,DB还是扛不住。

💡 所以:引入缓存(Redis)

把热点数据放内存(Redis),读取时先查Redis,没有再查DB,然后回写Redis。内存读取速度是磁盘的100倍+。

客户端读请求 Redis
(内存,0.1ms)
未命中 → MySQL
(磁盘,10ms)
回写Redis
@Service public class ProductService { @Autowired private StringRedisTemplate redisTemplate; @Autowired private ProductMapper productMapper; public Product getProduct(Long id) { // 1. 先查Redis String key = "product:" + id; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, Product.class); // 缓存命中 } // 2. Redis没有,查DB Product product = productMapper.selectById(id); if (product != null) { // 3. 回写Redis,设置过期时间 redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES); } return product; } }
⚡ 但是:缓存引入了三大新问题

缓存穿透:查询一个根本不存在的数据,Redis没有→DB也没有→每次都穿透到DB

缓存击穿:一个热点key突然过期,大量请求瞬间打到DB

缓存雪崩大量key同时过期,或者Redis宕机,所有请求瞬间打到DB

穿透:查-1的商品 Redis没有 DB也没有 每次都穿透
击穿:iPhone过期 1000个请求 同时打DB
雪崩:Redis挂了 所有请求 全打DB→DB也挂

④ 因为:缓存穿透 → 恶意请求打穿缓存到DB

❓ 问题:查询根本不存在的数据

黑客发送SELECT * FROM user WHERE id = -1,Redis没有→DB也没有→返回null。黑客用脚本发10万个这样的请求,DB被打满。

💡 所以:布隆过滤器 + 缓存空值

方案1:布隆过滤器——在Redis前面加一层布隆过滤器,所有可能存在的数据hash位都标记为1。查询时先过布隆过滤器,如果bit位有0,说明数据一定不存在。

// Redisson 布隆过滤器 @Configuration public class BloomFilterConfig { @Bean public RBloomFilter<Long> userBloomFilter(RedissonClient redisson) { RBloomFilter<Long> bloomFilter = redisson.getBloomFilter("userBloomFilter"); // 预期插入100万,误判率0.01 bloomFilter.tryInit(1000000L, 0.01); return bloomFilter; } } @Service public class UserService { @Autowired private RBloomFilter<Long> bloomFilter; public User getUser(Long id) { // 1. 先过布隆过滤器 if (!bloomFilter.contains(id)) { return null; // 一定不存在,直接返回 } // 2. 可能存在,查Redis // 3. Redis没有,查DB // ... } }

方案2:缓存空值——即使DB查不到,也往Redis写一个空值(带短过期时间),避免下次穿透。

// 缓存空值方案 if (product == null) { // 即使没有,也缓存一个空值,5分钟过期 redisTemplate.opsForValue().set(key, "NULL", 5, TimeUnit.MINUTES); return null; }
类比:假钞 vs 验钞机
穿透就像有人拿假钞来买东西——每次都要验(查DB),验完发现是假的。布隆过滤器就是验钞机——大部分假钞在验钞机就被拦住了。缓存空值就是记下"这张编号是假钞"——下次直接拒收。

⑤ 因为:缓存击穿 → 热点key过期瞬间DB被压垮

❓ 问题:热点key过期

iPhone首发、双11秒杀——这些商品页面访问量巨大。如果缓存key在高峰期过期,瞬间几千个请求同时打到DB。

💡 所以:互斥锁 / 逻辑过期

方案1:互斥锁——只让一个线程查DB并重建缓存,其他线程等待。

public Product getProductWithLock(Long id) { String key = "product:" + id; String json = redisTemplate.opsForValue().get(key); if (json != null) { return JSON.parseObject(json, Product.class); } // 缓存未命中 → 抢锁 String lockKey = "lock:product:" + id; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 拿到锁 → 查DB → 重建缓存 Product product = productMapper.selectById(id); redisTemplate.opsForValue().set(key, JSON.toJSONString(product), 30, TimeUnit.MINUTES); return product; } finally { redisTemplate.delete(lockKey); } } else { // 没拿到锁 → 短暂等待后重试 Thread.sleep(100); return getProductWithLock(id); // 递归重试 } }

方案2:逻辑过期——不设TTL,在value里存一个过期时间字段。发现逻辑过期时,异步线程更新缓存,当前请求先返回旧数据。

// 逻辑过期方案:缓存永不过期,但value中有逻辑过期时间 @Data public class CacheData<T> { private T data; private LocalDateTime expireTime; // 逻辑过期时间 } public Product getProductLogicExpire(Long id) { String key = "product:logic:" + id; String json = redisTemplate.opsForValue().get(key); if (json == null) return null; CacheData<Product> cacheData = JSON.parseObject(json, new TypeReference<CacheData<Product>>(){}); if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) { // 逻辑未过期 → 直接返回 return cacheData.getData(); } // 逻辑已过期 → 异步更新缓存 REENTRANT_LOCK.lock(); // 双重检查 try { // 再次检查,防止重复更新 if (cacheData.getExpireTime().isAfter(LocalDateTime.now())) { return cacheData.getData(); } // 开启异步线程重建缓存 CompletableFuture.runAsync(() -> { Product fresh = productMapper.selectById(id); CacheData<Product> freshCache = new CacheData<>(); freshCache.setData(fresh); freshCache.setExpireTime(LocalDateTime.now().plusMinutes(30)); redisTemplate.opsForValue().set(key, JSON.toJSONString(freshCache)); }); } finally { REENTRANT_LOCK.unlock(); } // 先返回旧数据(保证可用性) return cacheData.getData(); }
类比:保镖不在
击穿就像商场里的VIP保镖(缓存)突然不在了,所有人都挤进VIP室(DB)。互斥锁 = 只让一个人进去找保镖,其他人在外面等。逻辑过期 = 保镖不在但门还开着,先进去坐着,等新保镖来。

⑥ 因为:缓存雪崩 → 大量key同时过期或Redis宕机

❓ 问题:大量key同时过期 / Redis挂了

双11零点,很多商品缓存同时过期(都是1小时前预热设的),瞬间所有请求打到DB → DB也挂了 → 整个系统雪崩。

💡 所以:随机过期时间 + Redis集群 + 降级
// 随机过期时间:打散过期时间 public void setWithRandomExpire(String key, Object value, long baseMinutes) { // 基础时间 + 随机0~10分钟 long randomMinutes = baseMinutes + ThreadLocalRandom.current().nextLong(10); redisTemplate.opsForValue().set(key, JSON.toJSONString(value), randomMinutes, TimeUnit.MINUTES); } // 降级方案:Redis挂了怎么办? @Service public class ProductServiceWithFallback { @Autowired private StringRedisTemplate redisTemplate; @HystrixCommand( fallbackMethod = "getProductFallback", commandProperties = { @HystrixProperty(name="circuitBreaker.requestVolumeThreshold", value="10"), @HystrixProperty(name="circuitBreaker.errorThresholdPercentage", value="50") } ) public Product getProduct(Long id) { // 正常流程:Redis → DB // ... } // 降级:返回默认商品信息或本地缓存 public Product getProductFallback(Long id) { return Product.defaultProduct(); // 返回兜底数据 } }
类比:挤电梯
雪崩就像一栋楼所有人都同时按电梯下楼——电梯超载停运。随机过期 = 错峰下楼,有的人6:30下,有的人6:45下。降级 = 电梯坏了走楼梯,虽然慢但能到。

⑦ 因为:缓存与DB不一致 → 数据错乱

❓ 问题:更新DB后缓存还是旧数据

先更新DB还是先更新缓存?不管哪种顺序,并发时都可能不一致。

方案A:先更新DB,再更新缓存
线程1 更新DB: price=7999 线程2 更新DB: price=6999 线程2 更新缓存: 6999 线程1 更新缓存: 7999 ← 覆盖!
方案B:先更新DB,再删缓存(推荐)
线程1 更新DB 线程1 删缓存 下次读请求 → 缓存miss → 查DB → 回写
💡 所以:延迟双删 + Canal监听binlog

方案1:延迟双删——删缓存 → 更新DB → 延迟一段时间 → 再删一次缓存

public void updateProduct(Product product) { String key = "product:" + product.getId(); // 1. 先删缓存 redisTemplate.delete(key); // 2. 更新DB productMapper.updateById(product); // 3. 延迟再删一次(防止并发读请求回写了旧缓存) CompletableFuture.runAsync(() -> { try { Thread.sleep(500); // 延迟500ms redisTemplate.delete(key); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); }

方案2:Canal监听binlog——不侵入业务代码,Canal伪装成MySQL从库,监听binlog变更,自动同步到Redis

业务代码更新DB MySQL binlog Canal监听 自动删除/更新Redis
// Canal + RocketMQ 异步同步缓存 @Component @RocketMQMessageListener(topic = "canal-topic", consumerGroup = "cache-group") public class CanalCacheListener implements RocketMQListener<CanalMessage> { @Autowired private StringRedisTemplate redisTemplate; @Override public void onMessage(CanalMessage msg) { for (CanalRowData row : msg.getData()) { String key = "product:" + row.get("id"); if ("DELETE".equals(msg.getType())) { redisTemplate.delete(key); } else { // 更新缓存 redisTemplate.opsForValue().set(key, JSON.toJSONString(row), 30, TimeUnit.MINUTES); } } } }
⚠️ 一致性方案对比
方案一致性复杂度适用场景
先更新DB再删缓存最终一致大多数场景
延迟双删较强一致一致性要求高
Canal监听binlog最终一致大型系统,解耦需求
读写串行化强一致极高金融场景(极少数)

⑧ 因为:数据量继续增长 → 单库装不下

❓ 问题:单表数据量太大

即使有索引、读写分离、缓存,当一张表到10亿行时:

  • B+树高度增加到5-6层,查询变慢
  • 单表文件太大(几百GB),备份恢复耗时数小时
  • DDL操作(加字段、加索引)锁表时间太长
💡 所以:分库分表
单库单表 10亿行 分库分表 4个库 × 8张表 = 32个分片
db0.orders_0 ~ orders_7 db1.orders_0 ~ orders_7 db2.orders_0 ~ orders_7 db3.orders_0 ~ orders_7
// ShardingSphere-JDBC 分库分表配置 @Configuration public class ShardingConfig { @Bean public DataSource shardingDataSource() throws SQLException { Map<String, DataSource> dataSourceMap = new HashMap<>(); dataSourceMap.put("ds0", createDataSource("db0")); dataSourceMap.put("ds1", createDataSource("db1")); // 分表规则 TableRuleConfiguration orderTableRule = new TableRuleConfiguration( "orders", "ds${0..1}.orders_${0..7}" ); // 分库策略:按user_id % 2 orderTableRule.setDatabaseShardingStrategy( new StandardShardingStrategyConfiguration( "user_id", "db-mod")); // 分表策略:按order_id % 8 orderTableRule.setTableShardingStrategy( new StandardShardingStrategyConfiguration( "order_id", "table-mod")); // ... 省略算法配置 ShardingRuleConfiguration shardingRule = new ShardingRuleConfiguration(); shardingRule.getTableRuleConfigs().add(orderTableRule); return ShardingSphereDataSourceFactory.createDataSource(dataSourceMap, shardingRule, new Properties()); } }
⚡ 但是:分库分表引入了跨库事务问题

用户下单涉及扣减库存和创建订单,但库存和订单可能在不同的库中——传统事务@Transactional只能管一个库,跨库就管不了了。

下单操作
DB1: 创建订单 ✅ + DB2: 扣减库存 ❌
如何保证两个库要么都成功,要么都失败?

→ 这引出了分布式事务的需求(详见分布式因果链页面)

// Seata AT 模式解决分布式事务 @Service public class OrderService { @Autowired private OrderMapper orderMapper; @Autowired private StockClient stockClient; // Feign调用库存服务 @GlobalTransactional // Seata全局事务注解 public void createOrder(OrderDTO orderDTO) { // 1. 创建订单(DB1) orderMapper.insert(orderDTO); // 2. 扣减库存(DB2,远程调用) stockClient.deduct(orderDTO.getProductId(), orderDTO.getQty()); // 如果扣库存失败 → Seata自动回滚订单 } }
类比:开分店
分库分表就像连锁店开分店。但分店之间账目怎么对?这就是分布式事务——总店(订单库)和分店(库存库)要做一笔"要么都成功要么都回滚"的生意。

🎯 面试点映射:因果链视角

面试题不是孤立的,它们都在这条因果链上。理解了因果关系,面试时可以从根因出发推导,而不是死记硬背。

面试题在因果链的位置因为…所以…
MySQL索引原理 第①环 因为全表扫描太慢 → 所以B+树索引,3-4层即可定位百万行
索引失效场景 第①环 因为用了函数/类型转换/左模糊 → 优化器无法用索引 → 所以要避免
主从复制原理 第②环 因为单机扛不住读写压力 → 所以主从复制,通过binlog异步同步
主从延迟怎么处理 第②环 因为异步复制有延迟 → 所以写后读走主库/引入缓存
Redis为什么快 第③环 因为DB磁盘IO慢 → 所以用内存缓存,0.1ms vs 10ms
缓存穿透/击穿/雪崩 ④⑤⑥环 因为缓存引入了"不存在数据"和"过期"的问题 → 所以需要布隆/互斥锁/随机过期
缓存与DB一致性 第⑦环 因为缓存和DB是两个系统 → 所以存在不一致窗口 → 延迟双删/Canal
分库分表策略 第⑧环 因为单表数据量太大 → 所以水平拆分 → 但引入了跨库事务问题
分布式事务 第⑧环延伸 因为分库后本地事务管不了跨库 → 所以需要Seata/TCC/Saga
面试答题模板(因果推导法):
❌ "Redis缓存穿透可以用布隆过滤器"(只说方案)
✅ "因为查询不存在的数据时缓存无法命中,每次都会穿透到DB,所以需要布隆过滤器在缓存前做一层拦截,把一定不存在的请求挡住"(从问题到方案)

📌 因果链总结

数据量↑
查询慢
索引优化 并发↑
单机扛不住
读写分离 主从延迟 引入缓存 穿透/击穿
/雪崩
布隆/锁
/随机过期
缓存与DB
不一致
延迟双删
/Canal
数据量继续↑
单库装不下
分库分表 跨库事务 分布式事务
全栈资料库 · 因果链板块 · MySQL→Redis因果链