说实话,刚入行那会儿,我也以为 MySQL 是万能的。直到有一次,我们的活动页面突然爆火,QPS(每秒查询率)从平时的几百瞬间飙升到几万,数据库 CPU 直接飙到 100%,连接池报错 Too many connections,整个服务像瘫痪了一样。那一刻我才明白,单机 MySQL 是有极限的,而互联网的高并发是没有上限的。
今天我不跟你讲那些枯燥的理论定义,咱们就聊聊在真实的生产环境中,当流量真的像洪水一样涌来时,我们是如何一步步把 MySQL “救”回来的。这不仅仅是一篇技术文章,更是一份带着血泪教训的实战笔记。
第一道防线:缓存架构,别让请求直接打到数据库
在谈论分库分表之前,我们必须先解决一个最基础的问题:重复查询。
想象一下,如果一秒钟有 1 万次请求都要查同一个用户的信息,难道我们要让 MySQL 执行 1 万次完全相同的 SQL 吗?显然不可能。这时候,缓存(Cache) 就是那道最坚固的护城河。
为什么选 Redis?
在缓存领域,Redis 几乎是垄断地位。它基于内存操作,速度极快,而且支持丰富的数据结构。对于大多数高并发场景,Redis 是首选。
实战案例:热点数据缓存策略
假设我们有一个电商商品详情页,某个爆款商品的访问量极大。
// 伪代码示例:获取商品信息
public ProductDTO getProductDetail(Long productId) {
// 1. 先从缓存中获取
String cacheKey = "product:" + productId;
String jsonStr = redisTemplate.opsForValue().get(cacheKey);
if (jsonStr != null) {
// 命中缓存,直接返回
return JSON.parseObject(jsonStr, ProductDTO.class);
}
// 2. 缓存未命中,查询数据库
ProductDTO product = productMapper.selectById(productId);
if (product == null) {
// 防止缓存穿透:如果数据库中也没有,也写入一个空值,设置短过期时间
redisTemplate.opsForValue().set(cacheKey, "", 60, TimeUnit.SECONDS);
return null;
}
// 3. 写入缓存,设置合理过期时间
// 注意:这里可以设置随机过期时间,防止缓存雪崩
long expireTime = 3600 + new Random().nextInt(300);
redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), expireTime, TimeUnit.SECONDS);
return product;
}
常见坑点解析
缓存穿透:
- 现象:黑客恶意查询不存在的 ID,导致请求直达数据库。
- 对策:如上代码所示,查询结果为空时,也存入一个短时间的空对象;或者使用布隆过滤器(Bloom Filter)在缓存层拦截非法请求。
缓存击穿:
- 现象:某个热点 Key 突然过期,大量并发请求同时打到数据库。
- 对策:
- 互斥锁:只允许一个线程去查数据库并重建缓存,其他线程等待。
- 逻辑过期:不设置物理过期时间,而是在 Value 中包含一个逻辑过期字段,后台异步刷新缓存。
缓存雪崩:
- 现象:大量 Key 在同一时间过期,或者 Redis 宕机。
- 对策:给过期时间加上随机值(如上面代码中的
new Random().nextInt(300));搭建 Redis 集群保证高可用。
第二道防线:读写分离,分担数据库压力
有了缓存,大部分读请求被拦截了,但剩下的读请求以及所有的写请求,依然压在 MySQL 身上。如果我们的业务是“读多写少”(比如新闻、博客),我们可以引入读写分离。
架构原理
- 主库(Master):负责处理所有的写操作(Insert, Update, Delete)和部分读操作。
- 从库(Slave):通过 Binlog 同步主库的数据,专门负责处理读操作(Select)。
如何配置?
在 MySQL 层面,你需要开启 Binlog,并在从库配置 CHANGE MASTER TO。但在应用层,你需要通过中间件(如 ShardingSphere、MyCat)或者代码层面的动态数据源切换来实现。
# application.yml 示例:多数据源配置片段
spring:
shardingsphere:
datasource:
names: master,slave1,slave2
master:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://master-host:3306/db
username: root
password: secret
slave1:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://slave1-host:3306/db
username: root
password: secret
rules:
readwrite-splitting:
data-sources:
ds:
type: RRS
props:
write-data-source-name: master
read-data-source-names: slave1,slave2
load-balancer-type: ROUND_ROBIN # 负载均衡策略
常见坑点解析
主从延迟:
- 问题:数据从主库同步到从库需要时间。如果用户在主库写完数据后立即去从库查询,可能查不到最新数据。
- 对策:
- 强制读主:对于强一致性要求的业务(如支付余额查询),在事务结束后,强制路由到主库读取。
- 缩短同步间隔:优化网络环境和 MySQL 配置(如
sync_binlog=1,innodb_flush_log_at_trx_commit=1),但这会影响写入性能。
从库负载过高:
- 问题:如果从库太多或查询太复杂,从库可能扛不住,反而拖慢主库同步。
- 对策:监控从库状态,增加从库数量,或者对复杂查询进行限流。
第三道防线:分库分表,打破单机瓶颈
如果读写分离还不够,流量继续增长,单台数据库服务器的磁盘空间、CPU、内存都达到了极限,这时候就必须上分库分表了。这是最后的大招,也是复杂度最高的方案。
什么是分库分表?
- 垂直拆分:按业务模块拆分数据库。例如,订单库、用户库、商品库分开。
- 水平拆分:按数据量拆分。例如,将一张 1 亿行的订单表,拆分成 100 张表,每张表存 100 万行。
水平拆分实战:ShardingSphere-JDBC
目前业界最常用的方案是 Apache ShardingSphere。它轻量级,嵌入在应用中,无需额外部署中间件。
// 配置分片策略
@Bean
public DataSource dataSource() throws SQLException {
ShardingSphereDataSourceFactory factory = new ShardingSphereDataSourceFactory();
Map<String, Object> props = new HashMap<>();
props.put("sql.show", true); // 打印 SQL 方便调试
return factory.createDataSource(getDataSourceMap(), getRuleConfiguration(), props);
}
private ShardingRuleConfiguration getRuleConfiguration() {
ShardingRuleConfiguration shardingRuleConfig = new ShardingRuleConfiguration();
// 配置订单表的分片策略
TableRuleConfiguration orderTableRuleConfig = new TableRuleConfiguration("t_order");
orderTableRuleConfig.setTableShardingStrategy(new StandardShardingStrategyConfiguration(
"user_id", // 分片键
new OnlineShardingAlgorithm() // 自定义分片算法
));
shardingRuleConfig.getTableRuleConfigs().add(orderTableRuleConfig);
// 配置数据库分片
shardingRuleConfig.getBindingTableGroups().add("t_order"); // 绑定表,避免跨库 Join
return shardingRuleConfig;
}
自定义分片算法示例
public class OnlineShardingAlgorithm implements PreciseShardingAlgorithm<Long> {
@Override
public String doSharding(Collection<String> availableTargetNames, PreciseShardingParameter<Long> shardingParameter) {
Long userId = shardingParameter.getValue();
// 简单取模算法
int index = userId % availableTargetNames.size();
// 假设 availableTargetNames 是 [db0, db1, db2]
List<String> list = new ArrayList<>(availableTargetNames);
return list.get(index);
}
}
常见坑点解析
跨库 Join(Sharding-Join):
- 问题:当数据分散在不同的库/表中,普通的 SQL Join 无法工作。
- 对策:
- 避免 Join:在微服务架构下,尽量通过接口聚合数据,而不是在数据库层 Join。
- 冗余字段:在订单表中冗余用户姓名、地址等信息,避免关联用户表。
- 全局表:对于字典表等小表,在所有分片中同步一份副本。
分布式 ID 生成:
- 问题:分库分表后,自增主键会冲突。
- 对策:使用雪花算法(Snowflake)、Leaf 或 UUID。推荐使用雪花算法,它能生成有序且唯一的 Long 类型 ID。
扩容困难:
- 问题:一旦数据已经按照取模算法分布,后期想增加节点,需要迁移数据,非常痛苦。
- 对策:
- 预留空间:初期规划更多的分片数。
- 哈希取模扩展:使用一致性哈希或区间范围分片,便于平滑扩容。
- 双写迁移:在过渡期,同时向旧库和新库写入,然后进行数据比对和迁移。
分页查询难题:
- 问题:
LIMIT 1000000, 10在分库分表中效率极低,因为每个分片都要查 1000010 条数据再排序。 - 对策:
- 深分页优化:记录上次查询的最大 ID,下次查询
WHERE id > last_max_id LIMIT 10。 - 搜索引擎辅助:将数据同步到 Elasticsearch,由 ES 处理复杂分页和搜索。
- 深分页优化:记录上次查询的最大 ID,下次查询
- 问题:
第四道防线:SQL 优化与索引艺术
无论架构多么完美,如果 SQL 写得烂,一切都白搭。在高并发下,一条错误的 SQL 足以拖垮整个系统。
1. 索引优化原则
- 最左前缀法则:联合索引
(a, b, c),查询条件必须包含a才能用到索引。 - 避免索引失效:
- 不要在索引列上做计算:
WHERE year(create_time) = 2023-> 改为WHERE create_time >= '2023-01-01' AND create_time < '2024-01-01'。 - 不要使用函数:
WHERE SUBSTR(name, 1, 1) = 'A'-> 改为前缀匹配或改变存储方式。 - 隐式类型转换:如果
phone是字符串类型,查询时不要传数字WHERE phone = 13800000000,而要用'13800000000'。
- 不要在索引列上做计算:
2. 大事务问题
- 现象:一个事务执行时间过长,持有锁的时间久,导致其他事务排队,甚至死锁。
- 对策:
- 缩小事务范围:只将真正需要一致性的操作放入事务。
- 避免长查询:不要在事务中进行复杂的计算或远程 RPC 调用。
- 设置超时时间:配置
innodb_lock_wait_timeout。
3. 慢查询日志分析
定期分析慢查询日志(Slow Query Log),找出执行时间超过阈值的 SQL。
# 使用 mysqldumpslow 分析慢查询日志
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
给小朋友也能听懂的比喻
为了让你更直观地理解,我把这套架构比作一家超级繁忙的图书馆:
- MySQL 主库:就是图书馆的档案室,里面放着最原始、最权威的书。只有管理员(写操作)能进去修改书籍。
- 缓存 Redis:就像图书馆门口的自助复印机或者热门书架。大家最常借的书,管理员会提前复印好多份放在门口。读者来借书,先看门口有没有,有就直接拿走,不用进档案室。这就挡住了 90% 的客流。
- 读写分离:如果门口复印机不够用了,图书馆开了几个阅览室(从库)。读者看书(读操作)可以去阅览室,但只有管理员能进档案室修改书(写操作)。阅览室的书是从档案室复印过去的,可能会有几分钟的延迟(主从延迟)。
- 分库分表:如果书实在太多了,一个档案室放不下,图书馆就把书按编号(分片键)分散到好几个不同的仓库里。比如,1-100 号书的档案在 A 仓库,101-200 号的在 B 仓库。找书的时候,得先算一下编号属于哪个仓库,再去那里找。
总结与心态建设
面对百万级流量,没有银弹。
- 缓存解决了大部分读压力,但要注意一致性。
- 读写分离平衡了读写比例,但要忍受主从延迟。
- 分库分表解决了存储和单点性能瓶颈,但带来了开发复杂度和运维成本。
在实际工作中,我建议遵循“先垂直拆分,再水平拆分”,“先加缓存,再加读写分离,最后才考虑分库分表”的原则。不要一开始就搞复杂的架构,过度设计往往是灾难的开始。
最重要的是,监控!监控!监控! 你需要知道系统的每一个瓶颈在哪里。使用 Prometheus + Grafana 监控 MySQL 的连接数、QPS、TPS、慢查询、CPU、IO 等指标。只有看到了数据,才能做出正确的决策。
希望这篇实战经验能帮你理清思路。如果在具体实施过程中遇到坑,欢迎随时交流,我们一起填坑。毕竟,在这个行业里,解决问题的经验,才是我们最宝贵的财富。