某电商平台大促数据库崩盘后如何救场从连接暴增到分库分表MySQL高并发处理实战方案
凌晨三点,我的手机响了。
不是闹钟,是监控报警。老张的声音带着哭腔:”服务器扛不住了,MySQL的连接数已经打到8000多,主库CPU直接飙到98%!”
这是我加入”速达购”电商平台的第三个月,也是他们第一次遇到大促崩盘。那天是618预售日,原本预估流量是平时的3倍,结果上线半小时,峰值流量直接翻了12倍。
崩溃是怎么发生的
先说说当时的场景。
那家平台做的是快消品,平时日活也就几十万,数据库架构很简单:一台MySQL主库,读写分离,从库负责报表和查询。业务代码写得也比较随意,查询全靠ORM框架自动生成的SQL,没有任何缓存层。
促销一开始,用户疯狂涌入。
正常情况下的查询路径:
用户请求 → Web服务器 → 应用服务 → MySQL主库(读写)
↓
缓存(Redis,但没有预热)
结果就是,所有请求直接打到MySQL上。更糟糕的是,促销页面里有一个”商品详情”接口,每次请求都要查7张表,关联查询+子查询,单条SQL平均执行时间300毫秒。
峰值时刻,QPS直接飙到15000,MySQL的处理能力上限是5000 QPS。
接下来的事大家都猜到了:连接数爆满,慢查询堆积,锁等待超时,主库开始卡死。从库因为同步延迟,查询返回的数据还是几分钟前的。
第一步:止血
发现问题后的前30分钟是最混乱的。
我们首先做的是紧急扩容——把从库临时提主,主库降级只读。这个决策是CTO在电话会议里喊出来的。
紧急扩容操作记录:
14:30 - 发现主库CPU 98%,连接数8500
14:32 - 启动从库提升为主库
14:35 - 修改DNS指向从库(TTL已过期)
14:40 - 主库开始恢复,连接数降到2000
但这是治标不治本。从库扛不住,主库也扛不住,问题的根源是架构本身就无法应对高并发。
第二层分析:为什么扛不住
我们把崩溃期间的日志拉出来,一条条分析。
连接数暴增的原因有几个:
慢查询占住了连接。一条SQL执行3秒,就意味着这个连接被占3秒。15000 QPS里有一半是慢查询,连接池直接打满。
没有连接池。Java应用里用的是HikariCP,但配置的是
maximumPoolSize=50,峰值时每个用户请求打开5个连接,5000并发用户就是25000个连接需求,MySQL默认最大连接数是151,直接爆了。事务没有及时提交。促销页面的库存扣减用了长事务,一个事务跨度长达10秒,期间锁住了相关记录,后续查询排队等待。
查询层面的问题更严重:
我们把Top 10慢查询拉出来,发现全是同一个问题——缺少索引。
-- 问题SQL示例:商品列表查询
SELECT * FROM product
WHERE category_id = 123
AND status = 1
ORDER BY sales_count DESC
LIMIT 20;
-- 问题在于:
-- 1. 没有覆盖索引,回表严重
-- 2. ORDER BY 走了文件排序
-- 3. WHERE条件没有复合索引
这种查询在并发高的时候,每个请求都在做全表扫描+排序,IO直接打满。
第三层:分库分表方案设计
止血之后,我们开始做架构升级。核心思路就三个词:分片、缓存、降级。
3.1 分库分表的粒度设计
我们选择的是ShardingSphere,原因是不用改业务代码,配置一下就行。
分片策略:
订单表:按user_id分片(保证同一个用户的订单在同一分片)
商品表:按category_id分片(热门类目单独分片)
库存表:按sku_id分片(热点数据提前预热)
# ShardingSphere配置示例
sharding:
tables:
t_order:
actual-data-nodes: ds${0..3}.t_order_${0..7}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order-table-sharding
t_product:
actual-data-nodes: ds${0..3}.t_product_${0..7}
table-strategy:
standard:
sharding-column: category_id
sharding-algorithm-name: product-table-sharding
databases:
ds0:
url: jdbc:mysql://db0:3306/sudago_ds0
ds1:
url: jdbc:mysql://db1:3306/sudago_ds1
ds2:
url: jdbc:mysql://db2:3306/sudago_ds2
ds3:
url: jdbc:mysql://db3:3306/sudago_ds3
为什么选4个库8张表?
这是根据流量预估算的。峰值QPS 50000,MySQL单机优化后能扛5000 QPS,所以至少需要10个数据分片。考虑到后续增长,选4库8表,总共32个物理表,平均每个表承载1500 QPS左右,留有2倍余量。
3.2 分片键的选择
分片键选错了,后面全是坑。
❌ 错误做法:用自增ID分片
原因:热点数据集中在某些分片,负载不均衡
✅ 正确做法:用业务ID取模
订单表:user_id % 8
商品表:category_id % 8
库存表:sku_id % 8
但问题来了,有些查询跨分片是不可避免的。比如后台报表要统计全平台的订单量。
我们的解决方案是:额外维护一个汇总库。
-- 汇总库表结构
CREATE TABLE order_summary (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
stat_date DATE NOT NULL,
category_id INT NOT NULL,
order_count BIGINT DEFAULT 0,
total_amount DECIMAL(12,2) DEFAULT 0,
UNIQUE KEY uk_date_category (stat_date, category_id)
) ENGINE=InnoDB;
业务数据按分片写入,同时通过消息队列异步同步到汇总库。报表查询只查汇总库,不跨分片。
3.3 索引优化
分库分表之后,索引策略也要调整。
-- 优化后的订单表索引
CREATE TABLE t_order_0 (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
order_no VARCHAR(32) NOT NULL,
product_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 关键索引:按user_id查询订单
INDEX idx_user_id (user_id, create_time DESC),
-- 关键索引:订单号唯一索引
UNIQUE INDEX uk_order_no (order_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 优化后的商品表索引
CREATE TABLE t_product_0 (
id BIGINT PRIMARY KEY,
product_no VARCHAR(32) NOT NULL,
category_id INT NOT NULL,
title VARCHAR(200) NOT NULL,
price DECIMAL(10,2) NOT NULL,
stock INT DEFAULT 0,
status TINYINT DEFAULT 1,
sales_count BIGINT DEFAULT 0,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 覆盖索引:避免回表
INDEX idx_category_status_sales (category_id, status, sales_count DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
覆盖索引的关键作用:
原来的查询要回表查*,现在只需要查索引就能返回所有需要的字段。对于高并发场景,这能减少70%以上的IO。
第四层:缓存策略
分库分表解决的是写压力和查询分散的问题,但读压力还需要缓存来扛。
4.1 缓存架构
┌─────────────────────────────┐
│ CDN │
│ (静态资源、图片) │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Nginx │
│ (负载均衡、限流) │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ Redis Cluster │
│ ┌────────┬────────┬───────┐ │
│ │ 热点缓存 │ 会话缓存 │ 计数缓存│ │
│ └────────┴────────┴───────┘ │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ ShardingSphere │
│ (分库分表中间件) │
└──────────────┬──────────────┘
│
┌──────────────▼──────────────┐
│ MySQL Shards │
│ (32个物理表) │
└─────────────────────────────┘
4.2 缓存预热
大促前一周,我们把热门商品的数据全部预热到Redis。
// 缓存预热代码示例
@Component
public class ProductCacheWarmer {
@Autowired
private ProductMapper productMapper;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 预热时间:大促前72小时开始
@Scheduled(cron = "0 0 0 */3 * ?")
public void warmUpHotProducts() {
// 查询最近7天销量Top 1000的商品
List<Product> hotProducts = productMapper.selectHotProducts(1000);
for (Product product : hotProducts) {
String cacheKey = "product:detail:" + product.getId();
// 设置TTL:30分钟,避免脏数据
redisTemplate.opsForValue().set(
cacheKey,
buildProductDTO(product),
30,
TimeUnit.MINUTES
);
// 库存预扣减缓存
String stockKey = "product:stock:" + product.getId();
redisTemplate.opsForValue().set(
stockKey,
product.getStock(),
10,
TimeUnit.MINUTES
);
}
log.info("缓存预热完成,预热商品数:{}", hotProducts.size());
}
private ProductDTO buildProductDTO(Product product) {
ProductDTO dto = new ProductDTO();
dto.setId(product.getId());
dto.setTitle(product.getTitle());
dto.setPrice(product.getPrice());
dto.setSalesCount(product.getSalesCount());
dto.setCoverImage(product.getCoverImage());
// ... 其他字段
return dto;
}
}
4.3 缓存穿透和击穿防护
大促期间,恶意请求和热点Key过期是常见问题。
// 缓存防护策略
@Component
public class CacheProtection {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String BRUTE_FORCE_PREFIX = "brute:";
/**
* 缓存穿透防护:空值也缓存,设置短TTL
*/
public Object getWithNullCache(String key, Supplier<Object> dbQuery) {
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
// 区分空值和真实数据
if ("__NULL__".equals(value.toString())) {
return null;
}
return value;
}
// 查询数据库
value = dbQuery.get();
if (value == null) {
// 缓存空值5秒
redisTemplate.opsForValue().set(key, "__NULL__", 5, TimeUnit.SECONDS);
return null;
}
// 缓存真实数据30分钟
redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES);
return value;
}
/**
* 热点Key防护:本地缓存 + 分布式缓存
*/
private final LoadingCache<Long, ProductDTO> localCache =
CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.build(new CacheLoader<Long, ProductDTO>() {
@Override
public ProductDTO load(Long productId) {
return queryFromDB(productId);
}
});
public ProductDTO getHotProduct(Long productId) {
// 先查本地缓存
try {
return localCache.get(productId);
} catch (ExecutionException e) {
// 降级查分布式缓存
String key = "product:detail:" + productId;
return (ProductDTO) redisTemplate.opsForValue().get(key);
}
}
/**
* 防刷:同一IP短时间多次请求同一Key
*/
public boolean checkRateLimit(String ip, String key) {
String limitKey = BRUTE_FORCE_PREFIX + ip + ":" + key;
Long count = redisTemplate.opsForValue().increment(limitKey);
if (count != null && count == 1) {
redisTemplate.expire(limitKey, 1, TimeUnit.SECONDS);
}
return count != null && count <= 10;
}
}
第五层:限流和降级
分库分表和缓存能扛住大部分流量,但极端情况下还是需要限流和降级。
5.1 分级限流
我们设置了三个级别的限流:
@RestController
@RequestMapping("/api/product")
public class ProductController {
@Autowired
private ProductCacheProtection cacheProtection;
/**
* 商品详情接口 - 三级限流
*/
@GetMapping("/{id}")
public Result<ProductDTO> getProduct(
@PathVariable Long id,
@RequestHeader(value = "X-Forwarded-For", required = false) String clientIp) {
// 第一级:IP限流
if (!cacheProtection.checkRateLimit(clientIp, "product:" + id)) {
return Result.fail("请求过于频繁,请稍后重试");
}
// 第二级:接口限流(每秒1000次)
RateLimiter limiter = RateLimiter.create(1000.0);
if (!limiter.tryAcquire()) {
// 降级:返回缓存数据
return Result.ok(cacheProtection.getHotProduct(id));
}
// 第三级:熔断(连续失败5次触发)
CircuitBreaker breaker = CircuitBreaker.ofDefaults("product-service");
try {
return breaker.executeSupplier(() ->
cacheProtection.getWithNullCache(
"product:detail:" + id,
() -> productService.getById(id)
)
);
} catch (CallResilienceException e) {
// 熔断降级
return Result.ok(buildFallbackProduct(id));
}
}
private ProductDTO buildFallbackProduct(Long id) {
// 返回简化版商品数据
ProductDTO dto = new ProductDTO();
dto.setId(id);
dto.setTitle("商品加载中...");
dto.setPrice(BigDecimal.ZERO);
return dto;
}
}
5.2 服务降级策略
大促期间,非必要服务全部降级:
| 服务 | 降级策略 | 优先级 |
|---|---|---|
| 商品详情 | 正常 | P0 |
| 订单查询 | 正常 | P0 |
| 用户信息 | 读缓存 | P1 |
| 推荐接口 | 返回默认推荐 | P2 |
| 评论接口 | 关闭 | P3 |
| 日志上报 | 异步+采样 | P3 |
// 降级开关配置
@Configuration
public class DegradationConfig {
@Value("${degradation.recommend.enabled:false}")
private boolean recommendEnabled;
@Value("${degradation.comment.enabled:false}")
private boolean commentEnabled;
@Bean
public RecommendationService recommendationService() {
if (!recommendEnabled) {
// 返回空实现
return new FallbackRecommendationService();
}
return new DefaultRecommendationService();
}
@Bean
public CommentService commentService() {
if (!commentEnabled) {
return new FallbackCommentService();
}
return new DefaultCommentService();
}
}
第六层:监控和告警
救场不只是技术活,更是信息战。
6.1 核心监控指标
我们建立了四层监控体系:
应用层:QPS、响应时间、错误率
中间件层:连接数、慢查询数、锁等待
数据库层:CPU使用率、IO使用率、Buffer Pool命中率
业务层:订单成功率、库存扣减成功率、支付成功率
# Prometheus监控配置
scrape_configs:
- job_name: 'mysql'
static_configs:
- targets: ['mysql-exporter:9104']
metrics_path: '/metrics'
- job_name: 'shardingsphere'
static_configs:
- targets: ['ss-agent:8088']
# 关键告警规则
groups:
- name: mysql-alerts
rules:
- alert: MySQLHighCPU
expr: mysql_global_status_threads_connected / mysql_global_variables_max_connections > 0.8
for: 2m
annotations:
summary: "MySQL连接数过高: {{ $value | humanizePercentage }}"
- alert: MySQLSlowQueries
expr: rate(mysql_global_status_slow_queries[5m]) > 10
for: 1m
annotations:
summary: "慢查询过多: {{ $value }} 条/秒"
- alert: ShardingSphereHighLatency
expr: histogram_quantile(0.99, rate(ss_agent_query_duration_seconds_bucket[5m])) > 1
for: 2m
annotations:
summary: "分片查询P99延迟超过1秒"
6.2 实时大盘
我们搭了一个简单的实时监控大盘,关键指标一目了然:
┌────────────────────────────────────────────────────────────┐
│ 🛒 速达购 618 实时监控 │
├────────────────────────────────────────────────────────────┤
│ QPS: ████████████░░░░░░░ 12,450 (目标: 15,000) │
│ 响应时间: ████░░░░░░░░░░░░ 85ms (目标: <100ms) │
│ 错误率: ██░░░░░░░░░░░░░░░ 0.3% (目标: <1%) │
├────────────────────────────────────────────────────────────┤
│ MySQL连接数: ██████████░░░░ 850/1000 │
│ 慢查询/秒: ██░░░░░░░░░░░░ 12 │
│ Buffer命中率: ████████████ 99.2% │
├────────────────────────────────────────────────────────────┤
│ 订单成功率: ████████████ 99.7% │
│ 库存扣减成功率: ██████████░ 99.5% │
│ 支付成功率: ████████████ 99.8% │
├────────────────────────────────────────────────────────────┤
│ ⚠️ 告警: ds2分片CPU使用率85% │
│ ℹ️ 提示: 热点商品id=12345缓存命中率下降 │
└────────────────────────────────────────────────────────────┘
第七层:事后复盘和改进
大促结束后,我们花了一周时间做复盘。
7.1 问题清单
| 问题 | 根因 | 改进措施 |
|---|---|---|
| 连接数爆满 | 连接池配置不合理 | 调整HikariCP配置,增加连接池大小 |
| 慢查询堆积 | 缺少索引 | 全量扫描SQL,补全索引 |
| 热点分片 | 分片键选择不当 | 热点数据单独分片,均衡负载 |
| 缓存未预热 | 流程缺失 | 建立大促前自动化预热脚本 |
| 监控滞后 | 告警阈值不合理 | 调整告警阈值,增加多级告警 |
7.2 优化后的配置
# 优化后的HikariCP配置
spring:
datasource:
hikari:
maximum-pool-size: 200
minimum-idle: 50
idle-timeout: 30000
max-lifetime: 1800000
connection-timeout: 30000
leak-detection-threshold: 60000
# 优化后的MySQL配置
[mysqld]
max_connections = 2000
innodb_buffer_pool_size = 8G
innodb_log_file_size = 512M
innodb_flush_log_at_trx_commit = 2
slow_query_log = 1
long_query_time = 0.5
7.3 预案建设
最大的教训是:预案要比实战更重要。
我们建立了三套预案:
预案A(轻度压力): 流量达到平时5倍
- 开启全量缓存预热
- 关闭非核心服务
- 启动只读监控告警
预案B(中度压力): 流量达到平时10倍
- 开启限流
- 启动分库分表
- 降级报表服务
预案C(重度压力): 流量达到平时20倍
- 主从切换
- 全量降级
- 必要时限流整个平台
写在最后
那次618大促,我们扛过来了。
峰值QPS最终稳定在18000,数据库连接数控制在1200以内,订单成功率99.6%。虽然没有达到”零故障”的目标,但相比第一次崩盘的狼狈,已经进步很大。
回过头看,这次救场让我深刻理解了几个道理:
第一,架构设计要留有余量。 我们之前是按平时流量的3倍设计的,结果大促直接翻了12倍。正确的做法是按峰值流量的10倍设计,平时用缓存和降级来降低成本。
第二,预案比技术更重要。 技术能解决90%的问题,但剩下的10%需要预案来兜底。没有预案的技术团队,在大促面前就是裸奔。
第三,监控是眼睛。 没有监控,就像蒙着眼睛开车。我们后来建立的分层监控体系,让团队能实时感知系统状态,快速定位问题。
最后分享一个数据:大促结束后,我们把MySQL的慢查询数量从平均200条/小时降到了5条/小时以下,数据库CPU使用率从平时的70%降到了30%。分库分表不仅解决了大促的高并发问题,也让平时的查询性能提升了一倍。
如果你也在准备大促,或者正在面对数据库高并发的挑战,希望这篇文章能给你一些参考。架构没有银弹,但有迹可循。
有问题欢迎在评论区交流,看到都会回。
