深夜三点,报警电话炸了。监控大屏上,MySQL的CPU直接撞满100%,连接数飙到极限,慢查询日志像瀑布一样刷屏。DBA老张盯着屏幕,额头冒汗。用户投诉排队,订单卡顿,直播间的礼物特效卡成PPT。这时候,普通的重启已经救不了场,因为问题不是“卡了一下”,而是架构在极端压力下彻底失效。
你是不是也遇到过这种场景?单表数据量轻松破千万,查询越来越慢,每次加索引都像在裸奔,稍微搞个促销活动,数据库就直接躺平。别慌,这不仅是技术问题,更是架构设计的生死考验。今天咱们不整那些虚头巴脑的理论,直接上干货,把分库分表、缓存、加锁、读写分离这套组合拳拆解得明明白白,让你在实际项目中能直接上手,避免踩坑。
为什么单表超千万性能会暴跌?先看懂底层逻辑
很多人第一反应是:数据多了,查询自然就慢。这没错,但太浅了。MySQL性能暴跌的根源在于B+树的高度增加和I/O开销的非线性增长。
MySQL的InnoDB引擎默认页大小是16KB。当一个页存满后,会分裂成两个页,形成树状结构。随着数据量增加,B+树的高度会从2层变成3层,甚至4层。每多一层,就意味着一次额外的磁盘I/O。你以为查一条数据只读一次磁盘,实际上可能读了三次。
更可怕的是临界效应。当单表超过500万到1000万行时,索引维护成本急剧上升。INSERT、UPDATE、DELETE操作不仅要改数据,还要重构索引,导致写性能断崖式下跌。同时,全表扫描的代价变得不可接受,即使有索引,范围查询也可能因为索引失效而退化成全表扫描。
举个例子,假设你有一张订单表,存储了5000万条记录。执行一个SELECT * FROM orders WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31',如果create_time字段没有复合索引,或者索引选择性不够高,MySQL可能被迫扫描数百万行数据,耗时从毫秒级飙升至秒级甚至分钟级。
这时候,靠加机器、升配置已经无济于事,因为瓶颈在逻辑结构,不在硬件性能。必须从架构层面解耦,也就是我们接下来要讲的分库分表。
分库分表:把大山拆成碎石子
分库分表不是魔法,而是空间换时间的经典策略。核心思想是把一个大表拆分成多个小表,分布在不同数据库实例上,从而分散压力。
垂直拆分 vs 水平拆分
垂直拆分是按列拆。比如把订单表中的description(大文本字段)拆分到扩展表,主表只保留常用字段。这样能减少单行数据大小,提高缓存命中率。
水平拆分是按行拆。把一张大表按照某个规则(如用户ID取模)分散到多个表或多个库中。这才是解决千万级单表问题的主流方案。
分片策略:怎么选分片键?
分片键的选择是分库分表成败的关键。常见的策略有:
- 取模分片:如
user_id % 16,分到16个库。优点是实现简单,数据分布均匀;缺点是扩容困难,新增库需要重新计算分片。 - 范围分片:如
user_id在1-100万分到库1,100-200万分到库2。优点是查询范围数据时效率高;缺点是数据热点可能导致某库压力过大。 - 哈希分片:对分片键做哈希,如
hash(user_id) % 16。优点是分布均匀;缺点同样是扩容麻烦。
实际项目中,我们通常结合业务场景选择。比如电商系统,订单查询多以user_id为主,那就按user_id分片;如果是后台统计,可能需要按create_time分片,方便按时间段查询。
中间件选型:自己写还是用现成的?
当然可以用ShardingSphere、MyCAT、Cobar等中间件。ShardingSphere功能强大,支持标准SQL解析,但配置复杂;MyCAT轻量级,但社区活跃度一般。如果团队技术力强,也可以自研简单分片逻辑,但务必做好事务管理和跨库查询的支持。
举个例子,用ShardingSphere配置一个简单的分库分表:
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: ds_${0..1}.t_order_${0..3}
tableStrategy:
standard:
shardingColumn: order_id
shardingAlgorithmName: order-table-sharding
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: user-db-sharding
shardingAlgorithms:
order-table-sharding:
type: MOD
props:
sharding-count: 4
user-db-sharding:
type: MOD
props:
sharding-count: 2
这里user_id决定分到哪个库,order_id决定分到哪张表。看起来简单,但背后涉及的分布式事务、ID生成、跨库Join等问题,才是真正的挑战。
缓存:给热点数据开绿色通道
分库分表解决了存储和查询的压力,但高频访问的数据依然可能打爆数据库。这时候,缓存就是救命稻草。
缓存选型:Redis还是Memcached?
Redis功能丰富,支持多种数据结构,持久化机制完善,适合复杂场景;Memcached简单轻量,纯内存操作,速度快但功能单一。大多数项目选择Redis,尤其是需要数据结构扩展的场景。
缓存与数据库的双写一致性:最难的一关
缓存最怕的就是数据不一致。比如数据库更新了,缓存没更新,用户看到的还是旧数据;或者缓存更新了,数据库更新失败,导致脏数据。
常见的解决方案有:
- 先更新数据库,再删除缓存:因为缓存是数据的一个副本,删除缓存后,下次查询会重新从数据库加载,保证一致性。这是最推荐的方案,因为“更新”操作是同步的,而“删除”是异步的,出错概率低。
- 先删除缓存,再更新数据库:可能导致缓存重建时读到旧数据,不推荐。
- 延迟双删:先删缓存,更新数据库,再延迟一段时间删缓存,防止缓存重建读到旧数据。但延迟时间难以把控。
代码示例(先更库后删缓存):
public void updateOrder(Order order) {
// 1. 更新数据库
orderMapper.updateById(order);
// 2. 删除缓存
String cacheKey = "order_" + order.getId();
redisTemplate.delete(cacheKey);
// 3. 可选:发送消息队列,异步确保缓存删除成功
rabbitTemplate.convertAndSend("cache.delete.exchange", "order", cacheKey);
}
缓存穿透、击穿、雪崩:三大杀手
- 缓存穿透:查询不存在的数据,每次都打到数据库。解决方案:布隆过滤器、缓存空值。
- 缓存击穿:热点key过期,大量请求瞬间打到数据库。解决方案:互斥锁、永不过期。
- 缓存雪崩:大量key同时过期,或Redis宕机。解决方案:随机过期时间、集群部署。
记住,缓存不是银弹,要用对场景。对于强一致性的金融数据,慎用缓存,或者采用本地缓存+异步同步的策略。
加锁:高并发下的秩序维护者
分库分表和缓存解决了数据存储和访问问题,但并发写冲突依然存在。比如两个用户同时抢购同一件商品,库存可能变成负数。这时候,锁就是必要的秩序维护者。
数据库锁 vs 应用锁 vs 分布式锁
数据库锁(如行锁、间隙锁)依赖InnoDB,性能开销大,且跨服务无法协调。应用锁(如synchronized)只在单机有效。分布式锁(如Redis的SETNX、ZooKeeper)适合集群环境。
Redis分布式锁的正确姿势
用Redis做分布式锁,最经典的是SET key value NX PX milliseconds。但要注意原子性、过期时间、可重入性等问题。
代码示例:
public boolean tryLock(String lockKey, String value, long expireMs) {
String result = redisTemplate.opsForValue().set(lockKey, value, expireMs, TimeUnit.MILLISECONDS);
return "OK".equals(result);
}
public void unlock(String lockKey, String value) {
// 必须用Lua脚本保证删除的原子性,防止误删其他线程的锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), value);
}
锁粒度:宁粗勿细
锁粒度越大,并发度越低;锁粒度越小,逻辑越复杂,容易死锁。一般建议按业务逻辑分片加锁,比如商品ID加锁,而不是整个库存表加锁。
读写分离:主从架构的性能杠杆
读写分离是最常见的优化手段,主库负责写,从库负责读,分散压力。但这里有个大坑:主从延迟。
主从延迟是怎么产生的?
主库写入后,通过binlog异步同步到从库。如果网络延迟、从库压力大、或事务过大,从库数据就会滞后。用户刚写完数据,立刻去读,可能读到旧数据。
如何避免主从延迟带来的问题?
- 强制读主库:对于强一致性的场景,如支付、库存扣减后查询,指定读主库。可以通过路由策略实现,如根据会话ID或特定标记。
- 缩短同步间隔:优化网络、调整从库配置,如加大
relay_log大小,减少从库IO压力。 - 业务容忍:对于非强一致场景,允许短暂延迟,如评论、点赞数等。
- 半同步复制:MySQL5.7+支持半同步复制,至少一个从库确认接收binlog后才返回成功,提高一致性,但性能略有下降。
代码示例(Spring Boot中根据注解路由到主库):
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Master {
}
@Aspect
@Component
public class DataSourceAspect {
@Before("@annotation(master)")
public void setDataSource(Master master) {
DynamicDataSourceContextHolder.push("master");
}
@After("@annotation(master)")
public void restoreDataSource(Master master) {
DynamicDataSourceContextHolder.poll();
}
}
使用时,在写操作或需要强制读主的方法上加@Master注解。
实战组合拳:当崩溃来临时,如何一步步排查和解决
回到开头的场景:MySQL崩溃。作为专家,我的第一反应不是重启,而是排查。
第一步:监控与日志分析
查看慢查询日志,找出耗时最长的SQL。通常使用pt-query-digest工具分析。检查MySQL的错误日志,看是否有死锁、连接数溢出等报错。
第二步:即时止损
如果查询过多,临时限制非核心业务的访问,或降级功能。比如直播间的礼物特效暂时关闭,优先保证下单流程。
第三步:架构优化
- 短期:增加从库,读写分离,缓解读压力;添加缓存,过滤热点数据。
- 中期:分库分表,将大表拆分,降低单表数据量。
- 长期:引入分布式缓存、消息队列,解耦系统,提高弹性。
第四步:压力测试与演练
优化后,务必进行全链路压测,模拟高并发场景,验证系统稳定性。不要等线上出问题再补救。
写给开发者的小贴士:如何像真人一样思考
写代码时,别只盯着功能实现,要多问几个“如果”。如果用户量翻10倍,这个表还能扛住吗?如果Redis挂了,系统会怎样?如果网络抖动,主从延迟会是多少?
我见过太多项目,上线时风风光光,大促时哭爹喊娘。原因很简单:没有预估规模,没有架构冗余,没有应急预案。
分库分表不是越多越好,缓存不是越厚越好,锁不是越严越好。平衡点在于业务场景。比如,如果查询量远大于写量,读写分离+缓存是首选;如果写量极大,分库分表+分布式锁更合适。
最后,记住一句话:架构设计没有最好,只有最合适。多测试,多观察,多思考,你的系统才能经得起风暴。
现在,再去看看你的监控大屏,是不是心里有底多了?