说到数据库的高并发瓶颈,很多刚入行的同学第一反应就是:“加索引”或者“换更快的硬件”。这没错,但这就像是给一辆正在高速公路上抛锚的赛车换个更好的轮胎——治标不治本。当QPS(每秒查询率)从几千飙升到几万甚至几十万时,单机的MySQL就像是一个在早高峰地铁里试图背起所有乘客的壮汉,迟早会累趴下。
今天我们要聊的,不是枯燥的理论,而是一场真实的“救火行动”。我会带你走进一个典型的电商大促场景,看看我们是如何通过多级缓存架构和分库分表策略,把原本濒临崩溃的系统拉回正轨的。这里没有晦涩难懂的公式,只有实打实的代码和血泪教训。
第一阶段:危机降临——为什么单机扛不住了?
想象一下,你的电商平台正在搞“双11”预热活动。流量突然激增,订单表(orders)的数据量瞬间爆炸。
现象描述
- CPU飙升:数据库服务器的CPU使用率长期维持在95%以上。
- 连接数打满:MySQL的最大连接数(
max_connections)被占满,新请求直接报错Too many connections。 - 响应延迟:普通查询从5ms变成了500ms,甚至超时。
- 锁竞争严重:热点商品库存扣减时,行锁冲突导致大量事务等待。
核心问题分析
经过排查,我们发现主要瓶颈在于:
- 读多写少:80%的请求是查询商品详情和库存,但每次都要去磁盘IO读取数据。
- 热点数据集中:几个爆款商品的访问频率极高,导致单行记录锁竞争剧烈。
- 数据量过大:订单表突破5000万行,全表扫描和索引失效的风险增加。
这时候,我们需要两把利器:缓存(Cache)来挡掉大部分读请求,分库分表(Sharding)来分散写入压力和存储压力。
第二阶段:缓存优化——构建多级防护盾
缓存的核心思想是:用空间换时间。既然大部分用户查的是同一批数据,那就把这些数据存到内存里,下次直接取内存,根本不用碰数据库。
1. 为什么只用Redis不够?
很多人喜欢一上来就部署Redis集群,觉得万事大吉。但在高并发场景下,单一Redis也可能成为瓶颈,或者出现缓存穿透、击穿、雪崩等问题。因此,我们采用本地缓存 + 分布式缓存的多级架构。
- L1:Caffeine/Guava Cache(JVM堆内):速度最快(纳秒级),但受限于单机内存,适合存极热点、小体积的数据(如配置信息、极少变动的字典)。
- L2:Redis(分布式):速度次之(毫秒级),容量大,支持集群,适合存主要的业务数据(如商品详情、库存)。
2. 实战代码:带过期时间和互斥锁的缓存更新
假设我们要优化“获取商品详情”这个接口。如果直接用get-if-miss-then-db-set模式,在高并发下会出现“缓存击穿”问题:当某个热点Key过期瞬间,成千上万个请求同时打到数据库,数据库瞬间宕机。
错误示范(伪代码):
// 危险!高并发下会导致数据库被打死
if (cache.get(key) == null) {
data = db.query(key);
cache.put(key, data);
}
正确姿势:互斥锁 + 逻辑过期
我们使用Redis的SETNX命令实现分布式锁,确保同一时刻只有一个线程去查数据库并回填缓存,其他线程等待或返回旧值。
以下是基于Spring Boot + Redisson的完整实现示例:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import redisson.api.RLock;
import redisson.api.RedissonClient;
import java.util.concurrent.TimeUnit;
@Service
public class ProductService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper; // MyBatis Mapper
/**
* 获取商品详情,防止缓存击穿
*/
public ProductDTO getProductDetail(Long productId) {
String key = "product:" + productId;
// 1. 尝试从Redis获取
Object cachedData = redissonClient.getMap("product_cache").get(key);
if (cachedData != null) {
return (ProductDTO) cachedData;
}
// 2. 如果缓存为空,尝试获取分布式锁
RLock lock = redissonClient.getLock("lock:product:" + productId);
try {
// 尝试加锁,最多等10秒,锁自动释放时间30秒
if (lock.tryLock(10, 30, TimeUnit.SECONDS)) {
// 双重检查:因为等待锁的过程中,可能其他线程已经写入缓存了
cachedData = redissonClient.getMap("product_cache").get(key);
if (cachedData != null) {
return (ProductDTO) cachedData;
}
// 3. 真正查询数据库
System.out.println("Cache miss, querying DB for product: " + productId);
ProductDTO product = queryFromDB(productId);
// 4. 写入缓存,设置过期时间(比如2小时),防止内存溢出
redissonClient.getMap("product_cache").put(key, product, 2, TimeUnit.HOURS);
return product;
} else {
// 获取锁失败,休眠后重试,或者返回兜底数据
Thread.sleep(50);
return getProductDetail(productId); // 简单重试
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting for lock", e);
} finally {
// 5. 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
System.out.println("Released lock for product: " + productId);
}
}
}
private ProductDTO queryFromDB(Long productId) {
// 模拟数据库查询
return productMapper.selectById(productId);
}
}
3. 缓存一致性怎么保证?
这是面试必问,也是实战中最头疼的问题。用户修改了商品名称,数据库更新了,但Redis里的旧数据还没删,怎么办?
方案A:先删缓存,再更新数据库(推荐)
- 优点:实现简单。
- 缺点:极端情况下(更新DB慢于删除缓存),可能会短暂读到旧数据。
方案B:先更新数据库,再删缓存(更稳妥)
- 优点:保证最终一致性。
- 缺点:需要处理删除失败的情况。
方案C:延时双删(终极方案)
- 先删缓存。
- 更新数据库。
- 休眠一小段时间(比如500ms)。
- 再次删缓存。 这样可以确保在第2步更新完成后,如果有其他线程在读取并重建缓存,第4步能将其清除。
在实际生产中,我们通常结合Canal监听MySQL Binlog,异步发送消息到MQ,由消费者统一删除或更新Redis缓存。这样解耦了业务代码和缓存操作,提高了系统的稳定性。
第三阶段:分库分表——拆解单体巨兽
当数据量达到千万级,即使加了缓存,写入性能依然会成为瓶颈。此时,我们需要对数据库进行垂直拆分(按业务模块)和水平拆分(按数据量)。
1. 分片键(Sharding Key)的选择
分库分表最难的不是技术选型,而是选哪个字段做分片键。
- 原则:分片键必须是高频查询条件,且数据分布均匀。
- 案例:对于订单表,
user_id是一个很好的分片键,因为查询用户历史订单很频繁,且每个用户的订单量相对均匀。但如果用order_status(如“已支付”)做分片键,会导致数据倾斜,某些分片负载极高。
2. 中间件选型:ShardingSphere vs MyCat
目前业界主流是 Apache ShardingSphere。它轻量级、无侵入(基于JDBC协议),社区活跃。MyCat虽然老牌,但维护力度不如前者。我们这次选用 ShardingSphere-JDBC。
3. 实战配置:订单表分库分表
假设我们将订单表 orders 按照 user_id 进行分片。规则如下:
- 分库策略:根据
user_id % 4路由到4个数据库节点。 - 分表策略:在每个数据库中,根据
order_id % 16路由到16张表(orders_0到orders_15)。 - 全局ID生成:避免ID冲突,使用雪花算法(Snowflake)生成唯一ID。
application.yml 配置示例
spring:
shardingsphere:
datasource:
names: ds0,ds1,ds2,ds3
ds0:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/db_order_0?useSSL=false&serverTimezone=UTC
username: root
password: 123456
ds1:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/db_order_1?useSSL=false&serverTimezone=UTC
username: root
password: 123456
ds2:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/db_order_2?useSSL=false&serverTimezone=UTC
username: root
password: 123456
ds3:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/db_order_3?useSSL=false&serverTimezone=UTC
username: root
password: 123456
rules:
sharding:
tables:
orders:
actual-data-nodes: ds$->{0..3}.orders_$->{0..15}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: inline-user-id
sharding-algorithms:
inline-user-id:
type: INLINE
props:
algorithm-expression: ds$->{user_id % 4}
注意:上面的配置简化了分表逻辑,实际生产中分表算法也需要配置。ShardingSphere支持复杂的表达式,例如 orders_$->{user_id % 16}。
4. 跨分片查询难题:分页与排序
分库分表后,最大的痛点是非分片键的查询以及跨分片的分页排序。
场景:后台管理员要查看所有状态为“待付款”的订单,并按创建时间倒序排列,每页10条。
问题分析:
- “待付款”不是分片键,数据分散在所有16x4=64个表中。
- 如果要分页,ShardingSphere需要在内存中合并所有分片的结果,排序后再截取。如果总数据量很大(比如几百万条待付款订单),内存会爆,性能极差。
解决方案:
- ES同步:将订单数据实时同步到 Elasticsearch。利用ES强大的聚合和搜索能力处理复杂查询。后台管理查询走ES,核心交易查询走MySQL。
- 宽表设计:在订单主表中冗余一些常用查询字段(如
status,create_time),建立普通索引。虽然增加了写入开销,但能解决大部分单表内的范围查询。 - 游标分页:避免使用
LIMIT offset, size,改用WHERE id > last_max_id LIMIT size,减少内存排序压力。
第四阶段:儿童视角的通俗解释——图书馆的故事
为了让家里的小朋友也能听懂,我们可以把这套系统比作一个巨大的图书馆。
- MySQL数据库:就是图书馆的书库,书都放在那里,很安全,但是找书很慢,因为要跑很远去书架拿。
- 高并发:就是开学第一天,全校几千人同时冲进图书馆借书,书库门口挤爆了,管理员(CPU)忙得晕头转向,根本来不及找书。
- 缓存(Redis):就是在图书馆大厅设了一个快速借阅台。大家都想借《哈利波特》,管理员提前把这本书复印好放在大厅桌上。学生来了直接拿复印本,不用进书库。这样书库的压力就小多了。
- 注意:如果《哈利波特》被借走了,管理员要及时从桌上撤下来,不然别人拿到的就是“空壳”,这就是缓存一致性。
- 分库分表:如果图书馆的书实在太多了,一个书库放不下,或者找书太慢,我们就把图书馆拆分成好几个分馆。
- 按学生年级分:一年级去A馆,二年级去B馆。这样每个馆的人少了,找书就快了。
- 这就叫“分库分表”。但是,如果我想找“所有喜欢看科幻书的学生”,我就得跑遍所有分馆去查,这就很麻烦,所以我们要想办法让喜欢科幻书的人都集中在一个馆,或者建一个总索引目录(ES)。
第五阶段:避坑指南与最佳实践
在实际落地过程中,有几个坑是必须绕开的:
1. 不要过度分片
分片越多,运维复杂度呈指数级上升。初期建议先垂直拆分(订单库、用户库分离),数据量大了再水平拆分。一般单表控制在500万-1000万行为宜。
2. 预加载热点数据
对于秒杀活动,不要等到用户点进来才查数据库。在活动开始前,就将秒杀商品信息、库存预加载到Redis中,并将库存扣减逻辑放在Redis中执行(Lua脚本保证原子性),成功后再异步发送消息到MQ,最终更新MySQL。
3. 监控与告警
没有监控的分库分表就是盲人摸象。务必接入Prometheus + Grafana,监控:
- 各分片节点的QPS、TPS、慢查询。
- 缓存命中率。
- 连接池使用情况。 一旦某个分片异常,能迅速定位。
4. 数据迁移方案
如果线上已经有数据,如何平滑迁移到分库分表?
- 双写阶段:新老系统同时写,以新为主。
- 历史数据搬迁:使用工具(如DataX、ShardingSphere-Proxy的迁移功能)将老数据导入新库。
- 校验阶段:比对新旧数据一致性。
- 切换流量:逐步将读流量切到新库,最后切断老库写入。
结语
优化MySQL高并发瓶颈,从来不是一蹴而就的魔法,而是一套组合拳。缓存解决了读的瓶颈,分库分表解决了写的瓶颈和存储瓶颈。它们相辅相成,缺一不可。
记住,没有最好的架构,只有最合适的架构。在业务初期,简单的单库单表加上合理的索引和少量缓存,往往就能支撑数百万用户。只有当真正的流量洪峰到来时,才需要考虑复杂的分片策略。
希望这篇实战案例能为你提供一些思路。如果你在具体的代码实现或架构设计中遇到难题,欢迎随时交流。毕竟,技术是在不断的折腾和复盘中成长起来的。
