从最简秒杀服务压测看秒杀服务的技术选型和架构演进

0 阅读5分钟

背景

秒杀服务是典型的高并发场景之一,最初我从微服务入手搭建秒杀服务,可盲目搭建微服务,从大而全入手容易让人陷入迷茫。于是我决定回归单体,聚焦“高并发下库存扣减”这一核心命题。本文通过记录压测一个简易秒杀接口的过程,展现出架构演进的必要性,并记录一些有价值的思考。

环境搭建与基础配置

本文从最简设计开始,只包含idstock的商品表,以及一个纯粹的UPDATE接口。

  • 开发环境
    JDK17、SpringBoot3.5.9、mysql8.0.25

  • 建表语句


create table `product_seckill_test` (

`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,

`name` varchar(128) DEFAULT '测试商品',

`stock` int(11) UNSIGNED NOT NULL COMMENT '库存(核心字段)',

PRIMARY KEY (`id`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

思考stock字段定义为unsigned有什么好处?

  • 接口设计 接口非常简单,请求一次就扣减一次库存,如下:
@PostMapping("/seckill_test")
public String seckillTest(@RequestParam Long productId) {
    int updatedCount = productMapper.decreaseStock(productId);
    if (updatedCount == 1) {
        log.info("秒杀成功");
        return "SUCCESS";
    } else {
        log.info("库存不足,秒杀失败");
        return "FAIL";
    }
}

扣减库存的sql如下:

update product_seckill_test set stock= stock - 1 where id = #{productId} and stock > 0

思考上述设计能解决超卖问题吗?

压测记录:

本文使用Jmeter对接口进行四次压测,每次压测都是不同的条件下进行,通过观察、对比分析压测结果得出结论。

第一次压测

第一次压测采用50个线程同时请求,库存量充足,压测数据如下,我将在第二次压测结果对比中说明。

1.png

在mysql端使用SHOW PROCESSLIST查看正在执行得sql,可以看到大概10个左右的updating查询。这是因为我没有调整线程池配置,而SpringBoot默认使用的连接池是HikariCP,其默认大小为10。结果如下图:

image.png

还可以使用以下命令观察数据库资源使用情况。

SHOW GLOBAL STATUS LIKE 'Threads_connected'; -- 当前连接数

SHOW GLOBAL STATUS LIKE 'Threads_running'; -- 正在执行的连接数(关键!)

SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%'; -- 行锁争用情况

由于本文是单服务、单连接池,以上数据都无较大变化。

第二次压测

第二次压测采用200个线程同时请求,库存充足,结果如下:

image.png

首先观察Throughput指标。Throughput即吞吐量,也是系统的qps。对比第一次压测的数据,可以看到,我们线程数增加了4倍,qps仅仅增加了100左右。大量的线程时间都花在了等待数据库行锁。连接池(10个)成为缓冲区,请求在应用层排队等待获取数据库连接去争抢行锁。QPS已达到当前架构的极限(约700-800)。想要提升,必须改变架构。
另外一个要观察指标是Avrage,这个指标是系统的平均响应时间。对比第一次压测的数据,可以看到响应响应时间增长了3倍左右,这反映出等待行锁的线程竞争加剧拖慢了响应时间。

第三次压测

第三次压测采用200个线程同时请求,这次使用小库存,也就是库存马上被消耗完,观察数据库的表现和压测结果。压测结果如下图:

第三次压测.png 对比第二次压测结果,可以看到响应时间和吞吐量都有提高。这是在库存不足的情况下的数据,不是成功处理的QPS,业务价值是0。但在测试条件下,我们可以思考为什么库存为0时qps提高了呢?实际上正是因为在sql中stock > 0这个乐观锁条件,它使得在stock=0时,数据库快速释放了行锁。顺便一提,该测试展现了“快速失败路径”的价值:在一个高并发系统中,让无效请求以最低成本、最快速度失败,是保护系统整体吞吐的关键设计原则。这正是一些系统会做“前置校验”(如在网关层或Redis中检查库存)的原因。
另外我们观察商品库存,可以看到库存始终是0,如果统计秒杀成功的请求数可以看到系统没有超卖发生。正是stock > 0这个乐观锁保证了不超卖,而库存扣减的原子性是mysql的行锁保证的。将stock字段定义为unsigned,这样即使没有stock > 0这个乐观锁条件,在STRICT_TRANS_TABLES模式下,库存为0时扣减会发生异常,也不会发生超卖,但这和数据库sql mode相关。思考一下stock > 0条件为什么叫做乐观锁呢?

第四次压测

第四次压测采用200个线程同时请求,但是我让每个update延迟0.1秒执行,即人为制造慢sql,我将库存更新的sql做如下修改:

update product_seckill_test set stock= stock - 1 where id = #{productId} and stock > 0 AND SLEEP(0.1) = 0

压测结果如下图:

image.png 可以看到qps降到了10,符合预期(1000/100),清楚地说明了在串行化瓶颈下,系统吞吐量取决于最慢的操作单元。平均响应时间在不断变大,是因为系统积累的请求越来越多。

结论

以上测试观察都指向同一个结论:UPDATE语句的行锁竞争是当前系统的绝对瓶颈。 优化的核心思路就是将“库存扣减”这个需要强一致性的操作,从数据库的行锁竞争中剥离出来,用一个更快的、无锁的原子操作来完成。下一步,我将引入Redis进行库存扣减,看看redis如何提高系统的qps和响应时间的。

思考延伸

stock > 0条件为什么叫做乐观锁呢?而mysql的行锁(InnoDB存储引擎)明明是一种悲观锁实现。实际上,乐观锁是从应用层的角度来看的,我们在应用中没有对任何线程显示加锁。悲观锁是一种 “防御性编程”,代码结构上就显式地包含了“加锁-操作-释放”的流程。乐观锁是一种 "无锁编程思想” ,它不关心底层怎么同步,它只通过状态比对来检测冲突。WHERE stock > 0就是一次状态比对。