说实话,第一次看到生产环境的 MySQL CPU 直接飙到 100%,然后报警群炸开的时候,我后背都是凉的。那是个普通的周三下午,距离“双十一”预热活动还有两天,我们的秒杀系统突然慢得让人想摔键盘。用户反馈:页面加载转圈,点击购买没反应,甚至有时候还报错。
作为负责后端架构的工程师,我当时的第一反应不是去改代码,而是去查监控。QPS(每秒查询率)从平时的几千瞬间跳到了几万,数据库连接池瞬间打满,锁等待时间(Lock Wait Time)呈指数级增长。那一刻我才深刻意识到:教科书上的 SQL 语句在高并发面前,脆弱得像张纸。
这篇文章,我不打算给你讲什么是行锁、什么是表锁,那些概念百度一搜一大把。我想跟你聊聊,当你的业务从“偶尔有人抢”变成“几万人同时抢”,从“随便写写”变成“每一笔钱都不能错”时,我们是如何在 MySQL 的刀尖上跳舞,又是如何踩坑、填坑、最终建立起一套稳健的高并发架构的。
一、 秒杀场景:当乐观锁变成“乐 pessimistic”锁
1.1 那个经典的 UPDATE ... WHERE 陷阱
很多初级工程师,包括当年的我,在处理库存扣减时,第一反应写的是这样的代码:
UPDATE products
SET stock = stock - 1
WHERE id = 10086 AND stock > 0;
看起来很美,对吧?一行 SQL 搞定,原子性,高效。但在高并发秒杀场景下,这句话其实是性能杀手。
为什么?因为每次更新都需要加行锁。如果 1 万个请求同时到达,它们会排队等待这把锁。前一个请求提交后,后一个请求才能拿到锁。这导致数据库的 innodb_lock_wait_timeout 经常报警,大量请求超时失败。更糟糕的是,这种写操作会触发主从同步压力,甚至导致主库 IOPS 爆表。
1.2 实战方案:本地缓存 + 消息队列削峰
我们最终的方案,是把“写数据库”这个动作彻底推迟。
第一步:Redis 预扣减
在秒杀开始前,我们将库存加载到 Redis 中。利用 Redis 的 DECR 原子操作来扣减库存。DECR 是单线程执行的,天然支持高并发,而且速度比 MySQL 快几个数量级。
// 伪代码示例
Long stock = redisTemplate.opsForValue().decrement("stock:10086");
if (stock < 0) {
// 库存不足,回滚并返回失败
redisTemplate.opsForValue().increment("stock:10086");
return Result.fail("库存不足");
}
// 库存扣减成功,继续后续流程
第二步:消息队列异步落库
扣减成功后,我们不再同步写入 MySQL,而是发送一条消息到 Kafka 或 RocketMQ。用户立刻就能得到“抢购成功”的反馈,体验极其流畅。
// 发送秒杀订单消息
kafkaTemplate.send("seckill_order_topic", JSON.toJSONString(order));
第三步:消费者慢速消费
后台有一个独立的消费者服务,从 Kafka 中拉取消息,以可控的速度批量写入 MySQL。这样,数据库的压力被极大地平滑了。即使后端数据库崩了,前端也不会出现大面积超卖或系统崩溃,因为第一道关卡在 Redis。
避坑指南:
- 库存超卖问题:Redis 扣减成功不代表 MySQL 一定成功。如果消费者写入失败怎么办?我们需要设计对账机制,定期比对 Redis 和 MySQL 的库存数据,发现不一致时进行补偿。
- Redis 宕机风险:Redis 集群虽然高可用,但依然存在故障转移时间。对于极重要的场景,可以考虑在 Redis 中做双重校验,或者使用 Redis Cluster 的多主多从架构。
二、 锁优化:别让锁成为你的瓶颈
在秒杀解决了“写多读少”的问题后,我们转向了更普遍的金融交易场景——既有高频读取,又有高频写入,且对数据一致性要求极高。
2.1 行锁 vs 间隙锁:看不见的性能杀手
在一次数据库慢查询排查中,我们发现一个简单的 SELECT * FROM orders WHERE user_id = 123 竟然执行了 2 秒。
SELECT * FROM orders WHERE user_id = 123;
表里有几千万条数据,user_id 上有索引。按理说应该很快。我们检查了执行计划,发现走了索引,但 rows 扫描数却是几十万。
真相是什么?是间隙锁(Gap Lock)。
当时有一笔大额转账正在处理,事务未提交,对 user_id = 123 的范围加了锁。我们的 SELECT 语句因为开启了 REPEATABLE READ 隔离级别(MySQL 默认),触发了 next-key lock,锁住了索引记录之间的间隙。结果就是,我们的查询在等待锁释放。
解决方案:
- 评估是否真的需要
REPEATABLE READ:对于大多数查询,READ COMMITTED隔离级别足以保证业务逻辑,且能避免间隙锁,只加行锁,性能更好。 - 优化业务逻辑,避免长事务:那笔大额转账的事务代码里,竟然夹杂着一个远程调用!这是大忌。远程调用耗时几秒,锁就持有几秒。必须将远程调用移到事务之外。
// 错误示范:长事务
@Transactional
public void transferMoney(Long orderId) {
Order order = orderMapper.selectById(orderId);
// 远程调用,耗时 3 秒,锁持有 3 秒!
PaymentResult result = paymentService.callRemote(order.getAmount());
order.setStatus("PAID");
orderMapper.update(order);
}
// 正确示范:事务只包裹核心数据库操作
public void transferMoney(Long orderId) {
// 先调用远程服务
PaymentResult result = paymentService.callRemote(order.getAmount());
// 只有在这里开启事务,快速完成 DB 操作
try {
orderMapper.updateStatus(orderId, "PAID");
} catch (Exception e) {
// 补偿逻辑
}
}
2.2 乐观锁的巧妙运用
在金融交易中,我们大量使用了乐观锁。它的核心思想是:假设冲突很少发生,所以在更新时不加锁,而是通过版本号(version)来判断数据是否被修改过。
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 10;
如果 version 不对,说明数据已被别人修改,当前请求失败,应用层可以决定重试或报错。这种方式在高并发读、低并发写(或冲突率低)的场景下,性能远超悲观锁。
但要注意: 如果冲突率很高,乐观锁会频繁重试,反而消耗 CPU。这时需要权衡,或者结合业务特点,对热点数据进行分片。
三、 读写分离:爽一时,乱一世
读写分离是最常见的优化手段,原理很简单:主库写,从库读。
3.1 主从延迟的噩梦
我们曾经遇到过这样一个 bug:用户刚提交了一笔订单,立刻刷新页面查看,结果显示“订单不存在”。
这是什么原因?是主从同步延迟。
虽然 MySQL 的 binlog 同步很快,通常在毫秒级,但在高负载下,从库同步可能会延迟几百毫秒甚至几秒。用户刚才的写入在主库,读取却在从库,自然读不到最新数据。
解决方案:
- 强制读主库:对于写后读的场景,在代码中明确指定读取主库。
// 伪代码:使用路由注解 @DataSource(DataSourceType.MASTER) public Order getOrder(Long orderId) { return orderMapper.selectById(orderId); } - 开启强同步模式:通过配置
sync_binlog=1和innodb_flush_log_at_trx_commit=1,并启用半同步复制(Semi-sync Replication),保证主库提交时至少有一个从库接收了 binlog。这会牺牲一点写入性能,但能最大程度减少延迟。 - 业务层补偿:如果无法保证实时一致,可以在写入成功后,主动触发一次主库查询,或者给前端一个短暂的等待提示。
3.2 读写分离的另一个坑:配置错误
有段时间,我们的从库因为负载过高自动挂了,但应用层的读写分离中间件(如 ShardingSphere)没有及时感知,依然把读流量打到从库,导致大量查询失败。
教训: 必须配置健康检查,并且做好从库故障时的降级策略,比如自动切换到主库读,或者直接抛出友好提示。
四、 分库分表:拆还是不拆,这是个问题
当单表数据量超过 1000 万,或者 QPS 超过 5000 时,读写分离也不够用了,必须考虑分库分表。
4.1 水平分表的抉择
我们以订单表为例,表数据量达到了 2 亿。每次查询都需要全表扫描,性能极差。
方案 A:按 user_id 取模分表
-- 创建 16 张表
CREATE TABLE order_00 ...
CREATE TABLE order_01 ...
...
CREATE TABLE order_15 ...
-- 写入时
INSERT INTO order_${user_id % 16} VALUES ...
-- 查询时
SELECT * FROM order_${user_id % 16} WHERE id = 123;
这个方案对于“查询某个用户的所有订单”非常高效,因为数据都在同一张表里。
但是! 如果业务需要“查询某段时间内的所有订单”,这就麻烦了。你需要扫描 16 张表,然后合并结果。这在分布式系统中叫跨分片查询,性能极差,甚至不可接受。
方案 B:引入 Elasticsearch
对于复杂的查询需求,我们将数据同步到 Elasticsearch。ES 擅长全文检索和聚合查询,可以在毫秒级返回结果。MySQL 只作为最终一致性的存储和精准查询的数据源。
// 使用 Canal 监听 MySQL binlog,同步数据到 ES
canalConnector.subscribe("database.orders");
while (canalConnector.isConnected()) {
Message message = canalConnector.getWithoutAck(100);
List<CanalEntry.Entry> entries = message.getEntries();
for (CanalEntry.Entry entry : entries) {
esClient.index(entry.getData());
}
}
4.2 分库分表的痛点:全局 ID
分库分表后,原来的自增主键(Auto Increment)就不够用了,因为不同分片的 ID 会冲突。
我们最初使用了 UUID 作为主键,但很快发现性能很差。UUID 是无序的,导致 InnoDB 的聚簇索引频繁分裂,产生大量的碎片,写入性能下降明显。
最终方案:雪花算法(Snowflake)
雪花算法生成的是一个 64 位的 long 型 ID,包含时间戳、机器 ID 和序列号,整体有序且唯一。
public class SnowflakeIdGenerator {
private long workerId;
private long datacenterId;
private long sequence = 0L;
private long lastTimestamp = -1L;
public synchronized long nextId() {
long timestamp = timeGen();
if (timestamp < lastTimestamp) {
throw new RuntimeException("Clock moved backwards");
}
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & 0xFFF;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - START_EPOCH) << TIMESTAMP_LEFT_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
// ... 省略其他细节
}
雪花算法生成的 ID 在分片时可以直接用于取模路由,既保证了全局唯一,又避免了数据库自增锁的竞争。
4.3 跨分片查询的终极解决方案:ES + MySQL 混合架构
我们最终确定了这样的架构:
- 写入路径:应用 -> 消息队列 -> 消费者 -> 分片 MySQL(写主库) + ES(同步写入)。
- 查询路径:
- 精准查询(如:根据订单号查订单):直接路由到对应的 MySQL 分片。
- 复杂查询(如:按时间范围、商品品类、状态筛选):查询 Elasticsearch,返回 ID 列表,再批量从 MySQL 获取详情(如果需要展示详细信息)。
这种架构既保证了写性能,又满足了读的多样性需求。
五、 避坑总结:那些用血泪换来的经验
回顾整个高并发架构演进之路,以下几点是我认为最宝贵的经验:
- 不要过早优化:在数据量还小的时候,不要盲目上分库分表。单库单表加好索引、做好读写分离,往往能扛住很多流量。分库分表的复杂度呈指数级上升,维护成本极高。
- 监控是眼睛:没有监控的系统就是盲人摸象。务必监控 QPS、RT(响应时间)、慢查询、锁等待、连接数等关键指标。设置合理的告警阈值,才能在问题爆发前发现隐患。
- 降级与熔断:高并发场景下,系统可能会过载。要有应急预案,比如暂时关闭非核心功能(如推荐、评论),只保留核心交易流程;或者使用熔断器(如 Hystrix、Resilience4j)保护后端服务不被拖垮。
- 一致性 vs 可用性:在分布式系统中,CAP 理论告诉我们不能兼得。对于秒杀场景,我们选择了 AP(可用性),允许短暂的最终一致性;对于金融交易,我们选择了 CP(一致性),宁可牺牲一点性能,也要保证数据绝对准确。明确业务需求,才能做出正确的架构选择。
- 压测是试金石:任何架构设计,都必须经过严格的压测。使用 JMeter、Wrk 等工具,模拟真实流量,找出系统的瓶颈。不要等到上线了才发现数据库扛不住。
结语
从电商秒杀到金融交易,MySQL 的高并发优化是一场永无止境的修行。没有银弹,只有适合当前业务场景的最优解。
我希望通过这篇文章,能让你在面对高并发挑战时,不再手足无措。记住,理解业务,理解数据,理解锁,理解架构,这才是解决一切问题的根本。
如果你正在经历类似的压力,不妨停下来,仔细分析一下你的系统瓶颈在哪里。有时候,一个小小的索引优化,或者一次事务的重构,就能带来巨大的性能提升。
加油,未来的架构师们!
