秒杀场景的核心特征是短时间内的极高并发请求,大量用户同时抢购少量商品,这些请求最终都会转化为对MySQL中库存行记录的更新操作,而行锁是保证库存扣减原子性的关键机制,不当的锁使用方式会直接导致性能雪崩。应用层的削峰填谷则可以从源头减少同时到达数据库的请求量,和数据库优化形成互补。

一、秒杀场景下行锁性能问题的根源
MySQL的InnoDB引擎默认采用行级锁,当执行更新库存的SQL时,会对对应的商品行加排他锁,直到事务提交才释放。在秒杀场景下,成百上千的请求同时尝试更新同一行记录,就会产生大量的锁等待,甚至出现死锁。常见的不合理操作包括:
- 事务范围过大,把非必要的查询操作也放在事务中,延长锁持有时间
- 更新语句没有命中索引,导致行锁升级为表锁,锁冲突范围扩大
- 库存扣减逻辑放在查询之后,先查库存再更新,产生不可重复读问题,增加重试次数
二、MySQL行锁层面的优化方案
1. 缩小事务范围,减少锁持有时间
尽量把事务中只保留必要的更新操作,把商品信息查询、用户资格校验等操作放到事务外执行。例如原本的事务逻辑是:
-- 不合理的事务写法 START TRANSACTION; -- 查询商品信息 SELECT * FROM product WHERE id = 1; -- 校验库存 SELECT stock FROM product WHERE id = 1; -- 扣减库存 UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0; COMMIT;
优化后把查询操作移到事务外,事务内只保留更新语句:
-- 优化后的事务写法 -- 事务外完成查询和校验 SELECT stock FROM product WHERE id = 1; START TRANSACTION; -- 直接执行带条件的更新,利用乐观锁思路 UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0; COMMIT;
2. 确保更新语句命中索引,避免锁升级
行锁是基于索引实现的,如果UPDATE语句的WHERE条件没有命中索引,InnoDB会扫描全表,对所有扫描到的行加锁,相当于表锁。因此需要给商品ID字段建立唯一索引,同时更新语句的WHERE条件必须包含索引字段。可以通过EXPLAIN命令验证语句是否走索引:
-- 查看更新语句的执行计划 EXPLAIN UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
如果结果中的type列是range或者ref,说明命中了索引,不会产生锁升级。
3. 调整行锁相关参数
可以通过调整InnoDB的行锁参数减少锁冲突:
innodb_lock_wait_timeout:设置行锁等待超时时间,默认50秒,秒杀场景可以调整为5-10秒,避免单个请求长时间占用连接innodb_rollback_on_timeout:设置为ON,锁等待超时时自动回滚事务,避免事务挂起占用资源
三、应用层削峰填谷的实现方案
1. 前端限流,减少无效请求
在秒杀按钮点击后,前端立即置灰按钮,限制单个用户1秒内只能发送1次请求,避免用户重复点击产生无效请求。同时可以在前端做一个简单的随机等待,分散请求到达时间。
2. 接入层限流,控制请求进入量
在Nginx或者网关层做限流,使用令牌桶算法限制每秒进入系统的请求数,超过阈值的请求直接返回秒杀失败,避免所有请求都打到应用服务。Nginx的限流配置示例:
http {
# 定义限流区域,每秒处理100个请求
limit_req_zone $binary_remote_addr zone=seckill:10m rate=100r/s;
server {
location /seckill {
# 应用限流规则,突发请求最多允许20个
limit_req zone=seckill burst=20 nodelay;
proxy_pass http://backend;
}
}
}
3. 消息队列异步处理,削峰填谷
把秒杀请求先放入消息队列,应用服务按照数据库能承受的速率从队列中拉取请求处理,把瞬时的高并发转化为平稳的串行处理。以RabbitMQ为例,生产者发送秒杀请求:
// 发送秒杀请求到队列
public void sendSeckillRequest(SeckillRequest request) {
// 获取RabbitTemplate实例
rabbitTemplate.convertAndSend("seckill_queue", request);
}
消费者按照固定速率处理请求,每次只处理一个,处理完成后再拉取下一个:
// 消费秒杀请求
@RabbitListener(queues = "seckill_queue")
public void handleSeckillRequest(SeckillRequest request) {
// 执行库存扣减逻辑,调用数据库更新方法
boolean success = productService.reduceStock(request.getProductId());
if (success) {
// 记录秒杀成功结果
log.info("用户{}秒杀商品{}成功", request.getUserId(), request.getProductId());
} else {
log.info("用户{}秒杀商品{}失败,库存不足", request.getUserId(), request.getProductId());
}
}
四、优化后的效果验证
通过JMeter模拟1000个并发用户同时秒杀1个商品,优化前的场景会出现大量请求超时,数据库CPU占用率达到90%以上,行锁等待时间超过30秒。优化后,应用层限流把每秒进入的请求控制在100个,消息队列平缓处理请求,数据库更新语句平均耗时从200ms降到20ms,CPU占用率稳定在30%以下,没有出现锁等待超时的情况,秒杀成功率符合预期。
需要注意的是,削峰填谷的方案需要根据实际业务量调整参数,比如队列的长度、消费者的数量,避免队列堆积过多请求导致用户等待时间过长。同时数据库优化和应用层优化需要配合使用,单独优化某一方都无法达到最佳效果。