某电商网站MySQL数据库故障恢复实战与经验总结
那天是周五下午3点27分,我正在摸鱼刷社交媒体,手机突然开始疯狂震动。是监控告警群——订单数据库CPU打满,响应时间飙到8秒以上。
说实话,那一刻我后背一凉。
事情是怎么发生的
我们是一家做生鲜电商的网站,日均订单量大概8万单。系统用的是MySQL 8.0主从架构,一主两从,业务全部走主库读写。
那天下午,用户开始反映下单失败、支付成功后查不到订单。技术群里瞬间炸锅,我第一个冲进去看grafana监控面板。
故障现场数据:
| 指标 | 正常值 | 故障时 |
|---|---|---|
| QPS | 1200 | 15600 |
| CPU使用率 | 35% | 98% |
| 慢查询数/秒 | <5 | 3400 |
| 连接数 | 150 | 520(接近上限) |
| 主从延迟 | 0s | 23秒 |
第一反应:肯定有SQL出了大问题。
排查过程
第一步:看慢查询日志
马上ssh上数据库服务器,执行:
-- 查看当前正在执行的SQL
SHOW PROCESSLIST;
结果让人心惊——有将近80个线程都在跑同一个查询,平均耗时超过15秒:
SELECT
o.order_id,
o.user_id,
o.total_amount,
o.status,
oi.product_id,
oi.product_name,
oi.quantity,
oi.unit_price
FROM order_main o
JOIN order_item oi ON o.order_id = oi.order_id
WHERE o.create_time >= '2024-05-01 00:00:00'
AND o.status IN (1,2,3,4,5)
ORDER BY o.create_time DESC
LIMIT 1000;
第二步:分析SQL为什么慢
这条SQL是运营小妹在做月度订单报表导出的。问题出在哪?
我先看了表结构:
SHOW CREATE TABLE order_main\G
order_main表结构:
- order_id BIGINT PK
- user_id BIGINT
- total_amount DECIMAL(10,2)
- status TINYINT
- create_time DATETIME
- 表总行数:约1.2亿行
- 索引:PRIMARY(order_id), idx_user_id(user_id), idx_status(status)
order_item表结构:
- item_id BIGINT PK
- order_id BIGINT
- product_id BIGINT
- product_name VARCHAR(100)
- quantity INT
- unit_price DECIMAL(10,2)
- 表总行数:约3.6亿行
- 索引:PRIMARY(item_id), idx_order_id(order_id)
关键问题找到了——这张表没有一个针对 create_time 的索引!
运营要查5月1日之后的订单,MySQL被迫对1.2亿行数据做全表扫描,再关联3.6亿行的明细表。这哪是查数据,这简直是在做数据考古。
-- 用EXPLAIN看执行计划,结果触目惊心
EXPLAIN SELECT ...上述SQL...
+----+-------------+-------+------------+--------+---------------+---------+---------+------+--------+----------+------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+--------+---------------+---------+---------+------+--------+----------+------------------------------------+
| 1 | PRIMARY | o | NULL | ALL | NULL | NULL | NULL | NULL | 118000000| 25.00 | Using where; Using filesort |
| 2 | DERIVED | oi | NULL | ALL | NULL | NULL | NULL | NULL | 356000000| 10.00 | Using where |
+----+-------------+-------+------------+--------+---------------+---------+---------+------+--------+----------+------------------------------------+
看到那个 rows 列了吗?1.18亿行和3.56亿行——MySQL估计要扫描将近5亿行数据,这能不慢吗?
紧急处理
这时候不能慌,我做了三个动作:
1. 先把那颗定时炸弹踢下线
-- 找到那批慢查询的thread_id
SELECT ID,USER,HOST,DB,COMMAND,TIME,STATE,INFO
FROM information_schema.PROCESSLIST
WHERE INFO LIKE '%order_main%';
拿到thread_id后,直接KILL掉:
KILL 3847291;
KILL 3847292;
-- ... 一口气KILL了78个
2. 检查主从复制状态
SHOW SLAVE STATUS\G
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 23
Relay_Log_Space: 89234567
Last_Error: (none)
万幸,从库还在跟着主库同步,只是延迟了23秒。这意味着主库虽然扛不住,但数据没有丢失风险。
3. 临时开放只读权限
联系DBA同事,把主库暂时改成只读模式,防止新写入业务继续压垮它:
-- 设置全局只读(需要super权限)
SET GLOBAL read_only = ON;
-- 同时拒绝非管理员连接
FLUSH TABLES WITH READ LOCK;
当然,这只是应急措施,运营很快配合我们做了其他安排。
根因修复
等流量降下来后,我们做了彻底的修复。
1. 给create_time加索引
-- 在线DDL,尽量不影响业务
ALTER TABLE order_main
ADD INDEX idx_create_time_status (create_time, status);
这里用了联合索引,因为后续类似查询通常会带上status过滤条件。MySQL的索引最左匹配原则,把高频过滤字段放在前面,查询效率会更高。
2. 优化查询SQL
原来的SQL改成这样:
-- 优化后的报表查询
SELECT
o.order_id,
o.user_id,
o.total_amount,
o.status,
oi.product_id,
oi.product_name,
oi.quantity,
oi.unit_price
FROM order_main o
INNER JOIN order_item oi ON o.order_id = oi.order_id
WHERE o.create_time >= '2024-05-01 00:00:00'
AND o.create_time < '2024-06-01 00:00:00' -- 加了结束时间,缩小范围
AND o.status IN (1,2,3,4,5)
ORDER BY o.create_time DESC
LIMIT 1000;
关键改动就两个:
- 加上了时间范围的上界,不让MySQL扫描到无限远
- 把
INNER JOIN写明确,避免隐式连接带来的优化器困惑
3. 长期方案:报表业务迁出主库
这次事故的真正教训是——报表查询不应该打到主库上。
我们后续做了两件事:
第一,把主从架构升级成了读写分离,通过中间件(ProxySQL)把所有读请求分流到从库:
# ProxySQL配置示例
mysql-users:
- username: report_user
password: "xxx"
default_hostgroup: 2 # 报表查询走hostgroup 2(从库)
mysql-hostgroups:
- writer_hostgroup: 1 # 写操作走hostgroup 1(主库)
reader_hostgroup: 2 # 读操作走hostgroup 2(从库)
第二,对于这种大型报表,我们迁移到了ClickHouse——专为分析型查询设计的列式数据库:
-- 在ClickHouse中执行同样的查询,毫秒级响应
SELECT
o.order_id,
o.user_id,
o.total_amount,
o.status,
oi.product_id,
oi.product_name,
oi.quantity,
oi.unit_price
FROM order_main o
INNER JOIN order_item oi ON o.order_id = oi.order_id
WHERE o.create_time >= '2024-05-01'
AND o.create_time < '2024-06-01'
AND o.status IN (1,2,3,4,5)
ORDER BY o.create_time DESC
LIMIT 1000;
执行时间从15秒降到了0.03秒。
数据安全性验证
故障恢复后,我做了全面的数据校验:
-- 1. 检查主从数据一致性
-- 在主库执行
SELECT COUNT(*) FROM order_main;
SELECT COUNT(*) FROM order_item;
-- 在从库执行同样语句,对比结果是否一致
-- 2. 抽查最近24小时的数据
-- 验证主从复制链路完整
SELECT order_id, create_time
FROM order_main
WHERE create_time >= '2024-05-10 00:00:00'
ORDER BY create_time DESC
LIMIT 20;
-- 3. 检查binlog完整性
SHOW BINARY LOG STATUS;
-- 确认没有异常的position跳跃
-- 4. 验证事务完整性
-- 随机抽取几个订单,检查主表和明细表的数量关系
SELECT
o.order_id,
COUNT(oi.item_id) AS item_count
FROM order_main o
LEFT JOIN order_item oi ON o.order_id = oi.order_id
WHERE o.create_time >= '2024-05-01 00:00:00'
GROUP BY o.order_id
HAVING item_count = 0;
-- 结果应该为空,不为空说明有订单丢了明细
所有检查全部通过,数据完好无损。
事故总结与经验沉淀
这次故障虽然只持续了47分钟,但给我们的教训非常深刻。
1. 没有索引的时间范围查询,就是一颗定时炸弹
-- 每次表结构变更时,务必检查查询模式是否匹配索引
-- 建索引前,先看慢查询日志
SELECT
DIGEST_TEXT,
COUNT_STAR AS exec_count,
ROUND(AVG_TIMER_WAIT/1000000000, 2) AS avg_ms,
ROUND(MAX_TIMER_WAIT/1000000000, 2) AS max_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY avg_timer_wait DESC
LIMIT 20;
2. 报表查询必须与核心业务隔离
这不是技术问题,这是架构问题。核心交易库和数据分析库必须分开,哪怕只是简单的读写分离,也能避免90%的同类事故。
3. 监控告警要够”智能”
我们之后的监控增加了:
- 慢查询数量阈值告警(>10条/秒触发)
- 大事务监控
- 连接数使用率告警
- 主从延迟超过5秒告警
# 一个简单的慢查询监控脚本思路
import pymysql
import time
def check_slow_queries():
conn = pymysql.connect(host='primary-db', user='monitor', password='xxx')
cursor = conn.cursor()
# 查询过去60秒内执行时间>2秒的SQL
cursor.execute("""
SELECT COUNT(*)
FROM performance_schema.events_statements_history
WHERE TIMER_WAIT > 2000000000000
AND STARTED > DATE_SUB(NOW(), INTERVAL 60 SECOND)
""")
slow_count = cursor.fetchone()[0]
if slow_count > 10:
send_alert(f"检测到 {slow_count} 条慢查询,请及时处理")
conn.close()
# 每分钟执行一次
while True:
check_slow_queries()
time.sleep(60)
4. 建立SQL审核机制
这次事故的运营同事,其实只是想导出一个报表。她完全不知道自己在做什么操作——这是大部分非技术同事的真实状态。
所以我们上线了SQL审核平台,所有查询必须经过预审:
提交查询 → 自动分析执行计划 → 标记风险SQL → 人工审核 → 通过后方可执行
高风险操作(比如没有WHERE条件的UPDATE/DELETE、全表扫描的查询)直接拦截。
写在最后
数据库故障就像飞机失事调查——没人希望经历,但每一次都必须认真对待。
47分钟的故障,我们花了一周时间做复盘、整改、优化。但正是这个过程,让我们对MySQL的理解又深了一层。
你现在看到的那个”稳定运行”的系统,背后是无数次的排雷和加固。运维工作没有终点,只有不断前进。
如果你也在做电商或者任何涉及交易数据的系统,请记住一句话:永远不要让一个没有索引的WHERE条件,运行在亿级数据的表上。
这句话值一千万。
