说实话,去年我在一家头部电商平台负责核心交易链路的数据库稳定性时,真的被“千万级写入”这个词逼得通宵了三次。那时候我们自认为架构已经很稳了,结果大促前夕流量峰值一上来,主库CPU直接飙到95%, replication lag(主从延迟)几分钟内拉到几十秒,线上告警响到手机发烫。
很多同行一听到“性能优化”,第一反应是“加索引”或者“换个更快的服务器”。但在我看来,真正的瓶颈往往不在硬件,而在你对数据流向的认知盲区。今天这篇文章,我不谈空洞的理论,就把那次排查的全过程、踩过的坑、最后落地的架构改造,掰开了揉碎了讲清楚。希望能帮正在经历类似痛苦的 DBA 或后端同学少走弯路。
一、 危机现场:那个让人窒息的周五晚上
先还原一下当时的场景。
我们的核心业务是“即时秒杀”场景,数据库选型是 MySQL 8.0,主从架构,单机配置是 64核 256G,SSD 存储。平时 QPS 在 5万左右波动,看着挺健康。
但在那次压力测试中,当并发写入达到 1.2万 QPS(相当于高峰期每分钟处理70万+订单创建请求)时,现象出现了:
- 主库响应时间(RT)从平均 5ms 陡增至 800ms+。
- 慢查询日志(slow log)爆炸,每秒产生数千条记录。
- 从库 replication lag 达到 30秒,导致大量读请求读到过期数据,前端报错率飙升。
作为 DBA,我的第一反应不是看 CPU,而是看 iostat 和 vmstat。
# 当时的关键监控指标
iostat -x 1 5
# 结果:avgqu-sz 超过 2.0,await 高达 50ms+,说明磁盘IO已经饱和
# 注意:CPU 并没有完全跑满,只有 70% 左右,这说明瓶颈在 IO,不在计算
vmstat 1
# si/so (swap in/out) 为 0,排除内存交换问题
# wa (iowait) 高达 40%,确认 IO 等待是主要瓶颈
这里有个误区:很多新人看到 CPU 没满,就觉得不是数据库的问题。其实对于写入密集型业务,CPU 往往不是瓶颈,磁盘 IO 延迟和 redo log 刷盘策略才是真正的“隐形杀手”。
二、 抽丝剥茧:慢查询排查的三层逻辑
定位问题不能靠猜,我按照“由外及内、由浅入深”的三层逻辑进行了排查。
第一层:表面现象——是不是 SQL 写得烂?
我导出了当时 TOP 10 的慢查询,发现大部分是以下这种写法:
-- 问题 SQL 示例:看似正常,实则隐藏杀机
INSERT INTO order_main (user_id, product_id, amount, create_time)
VALUES (10086, 9527, 99.00, NOW());
-- 以及伴随的大量查询
SELECT * FROM order_main WHERE user_id = 10086 AND status = 1 LIMIT 10;
乍一看,INSERT 语句没什么问题,SELECT 也有 user_id 索引。但为什么还是慢?
我注意到一个细节:插入量巨大,但更新操作更多。在秒杀场景下,用户下单后会频繁查询订单状态,而我们的订单表是单表设计,数据量在短时间内突破了 50亿行。
关键洞察:这不是单条 SQL 写得烂,而是表设计在海量数据下的结构性缺陷。
第二层:引擎层面——Redo Log 和 Binlog 的博弈
MySQL 写入的核心路径是:
Client -> Buffer Pool -> Redo Log -> (Checkpoint) -> Data File
在高并发写入时,Redo Log 的刷盘策略决定了性能上限。
我们当时的配置是:
innodb_flush_log_at_trx_commit = 1 # 每次事务提交都刷盘,最安全但最慢
sync_binlog = 1 # 每次事务提交都同步 binlog
这意味着,每有一条 INSERT,磁盘要写 2次 IO(1次 redo + 1次 binlog)。在 1.2万 QPS 的写入压力下,光这两次 IO 就足以让磁盘队列堵死。
我做了个实验,将 innodb_flush_log_at_trx_commit 改为 2:
-- 测试环境调整
SET GLOBAL innodb_flush_log_at_trx_commit = 2;
结果:吞吐量提升了 40%,但风险是数据库宕机可能丢失 1秒内的数据。对于非金融核心场景,这个取舍是可以接受的;但对于交易核心,我们不能妥协。
所以,单纯调整参数解决不了根本问题,必须从架构入手。
第三层:结构层面——锁竞争与碎片化
这是最隐蔽的瓶颈。
随着表数据量达到亿级,聚簇索引的分裂和 间隙锁(Gap Lock) 成为性能杀手。
在秒杀场景下,所有用户都往同一张表插入数据,且 user_id 和 order_id 是递增的。这看似有利于写入,但实际上:
- 索引页分裂:B+树节点不断分裂,产生大量碎片,导致随机 IO 增加。
- 锁竞争:虽然我们是插入操作,但后续的状态更新(
UPDATE order SET status=1)会涉及大量行锁和间隙锁,导致锁等待时间飙升。
我用 performance_schema 查看了锁等待情况:
-- 查看当前锁等待详情
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
结果显示,大量的 UPDATE 语句在等待锁,而持有锁的往往是之前的 INSERT 事务(因为自动提交时间过长)。
结论:我们面临的不是一个单纯的“慢查询”问题,而是一个高并发写入 + 大表锁竞争 + 磁盘 IO 瓶颈的复合型故障。
三、 架构优化:从“单点承压”到“分流削峰”
既然找到了病根,接下来的方案就不是“调优”而是“重构”。我们采取了“写入分离 + 分库分表 + 异步化”的组合拳。
1. 写入前置:消息队列削峰填谷
最直接的改变是:不让数据库直接承受用户的瞬时写入压力。
我们引入了 Kafka 作为缓冲层:
// 伪代码:订单服务接入 Kafka
@PostMapping("/createOrder")
public Result createOrder(@RequestBody OrderRequest request) {
// 1. 参数校验
if (!validate(request)) {
return Result.fail("参数错误");
}
// 2. 将订单信息发送到 Kafka,立即返回“排队中”
OrderMessage message = new OrderMessage(request.getUserId(), request.getProductId(), System.currentTimeMillis());
kafkaTemplate.send("order-create-topic", message);
// 3. 返回消息ID,前端轮询状态
return Result.success(message.getMessageId());
}
这样做的好处是:
- 削峰:Kafka 可以承受每秒几十万甚至上百万的写入,而数据库只需按自己的节奏消费。
- 解耦:订单创建和后续的处理(如扣库存、发通知)可以异步进行。
2. 分库分表:ShardingSphere 实战
对于已有的 50亿数据,我们不能停机重建。我们采用了 ShardingSphere 进行透明化的分库分表。
分片策略:
- 库层面:按
user_id % 16分16个库。 - 表层面:按
order_id % 64分64张表。
# ShardingSphere 配置示例 (application.yml)
spring:
shardingsphere:
datasource:
names: ds0,ds1,...,ds15
ds0:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://host1:3306/order_db_0
# ... 其他库配置
rules:
sharding:
tables:
order_main:
actual-data-nodes: ds$->{0..15}.order_main_$->{0..63}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: order-table-inline
key-generate-strategy:
column: order_id
key-generator-name: snowflake
sharding-algorithms:
order-table-inline:
type: INLINE
props:
algorithm-expression: order_main_$->{order_id % 64}
key-generators:
snowflake:
type: SNOWFLAKE
效果:
- 写入压力被分散到 16 个库、1024 张表(16*64)上。
- 单表数据量控制在千万级以内,索引效率大幅提升。
- 查询时通过
order_id可以直接定位到具体的库和表,避免全库扫描。
3. 冷热数据分离:架构分层
并非所有订单都需要实时查询。我们将订单数据分为“热数据”和“冷数据”:
- 热数据:最近 3 个月的订单,保留在 MySQL 中,支持高并发读写。
- 冷数据:3 个月前的订单,迁移到 ClickHouse 或 HBase 中,用于统计分析和小部分历史查询。
-- 使用 ETL 工具(如 DataX)定期同步冷数据
-- 定时任务:每天凌晨将 3 个月前的订单迁移
INSERT INTO clickhouse_db.order_archive
SELECT * FROM mysql_ds.order_main
WHERE create_time < DATE_SUB(NOW(), INTERVAL 3 MONTH);
-- 然后在 MySQL 中删除这部分数据
DELETE FROM mysql_ds.order_main
WHERE create_time < DATE_SUB(NOW(), INTERVAL 3 MONTH);
这样,MySQL 中的活跃表大小始终保持在可控范围内,性能自然稳定。
4. 读库优化:强制走索引
对于查询侧,我们做了两件事:
- 读写分离:所有
SELECT请求强制路由到从库,主库只承担写入。 - 强制索引:在 SQL 中加上
FORCE INDEX,防止优化器走错索引。
-- 优化后的查询 SQL
SELECT order_id, user_id, status, create_time
FROM order_main FORCE INDEX (idx_user_id_status)
WHERE user_id = 10086 AND status = 1
ORDER BY create_time DESC
LIMIT 10;
同时,我们建立了覆盖索引,避免回表:
-- 创建覆盖索引,包含查询所需的所有字段
CREATE INDEX idx_user_status_cover ON order_main (user_id, status, order_id, create_time);
四、 实测效果:数据说话
经过上述改造,我们在同样的硬件配置下,重新进行了压力测试:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| 最大写入 QPS | 1.2万 | 8万+ | 6.6倍 |
| 主库平均 RT | 800ms | 15ms | 降低 98% |
| 主从延迟 | 30秒+ | < 1秒 | 基本消除 |
| P99 延迟 | 2秒 | 50ms | 40倍 |
| 错误率 | 15% | 0.01% | 几乎为零 |
特别是 P99 延迟从 2秒 降到 50ms,这对用户体验来说是质的飞跃。
五、 给同行的几点血泪建议
回顾这次“灾难”,我有几个心得想分享给你:
- 不要迷信监控平均值:CPU 70% 可能意味着 IO 已经饱和,要看
await和iowait,而不是只看%usr。 - 大表是性能的毒药:单表超过 500万行,就应开始考虑分表。不要等到 5亿行再后悔。
- 写入瓶颈通常是锁瓶颈:在高并发下,锁等待时间往往比 IO 等待时间更长。优化事务粒度、缩短持有锁的时间至关重要。
- 架构优于调优:当数据量达到千万级,靠调 SQL 和参数已经无济于事,必须通过分库分表、读写分离、缓存等手段从架构层面分流。
- 压测要贴近真实:我们的压测数据之所以和线上差距大,是因为压测时没有模拟真实的“查询负载”。实际业务中,读写比例往往是 7:3 甚至 9:1,而不是纯粹的写入。
结语
千万级写入的瓶颈,从来不是单一技术问题,而是数据规模、架构设计和业务场景共同作用的结果。
那次经历让我明白,DBA 的价值不仅仅在于“救火”,更在于“防火”——通过合理的架构设计,让系统在面对流量洪峰时,能够从容不迫,甚至优雅地降级。
如果你正在经历类似的痛苦,不妨先停下来,问问自己:我的数据库,是不是承担了它不该承担的重量? 有时候,退一步(引入 MQ、分库分表),反而能进十步。
希望这篇实战分享能给你一些启发。如果有具体的问题,欢迎在评论区交流,我们一起探讨。毕竟,在这个行业里,没有孤军奋战的 DBA。