1.引言
网上有很多关于秒杀的文章,这些文章都是将涉及的技术蜻蜓点水式的简述一下,很少有实战场景的演练分析。秒杀所涉及的技术较多,如果我们把这些技术讨论都放在一篇文章。这必然会是一篇很长的文章,长得让人望而却步。因此笔者会将内容按照技术相关性分为多篇相关的系列文章。
本文将尝试探讨提交订单这一单一场景在单台服务器上的并发能力和 TPS(Transaction Per Second)。
2.提交订单
我们从一个简化的订单场景展开讨论。如图,订单相关有三张简化的表,分别为商品表(goods)、库存表(inventory)和订单表(order,一个订单只能一个商品)。本文的主要目的是探讨秒杀涉及的技术,而不是探讨优雅的订单模型,所以用了非常简化的订单模型。我们就基于这个简化的订单模型来看看单机能支持多大并发,tps 峰值有多高。
提交订单的后台逻辑是:
- 执行一次商品查询
- 扣减库存(暂时不考虑库存够不够,就是傻傻地用当前库存余额减去订单中的数量。
- 构造订单,并保存
@Transactional
public Long submitOrder(Long userId, Long goodsId, Long count){
Goods goods = goodsRepository.findGoodsById(goodsId);
Long balance = inventoryRepository.getGoodsBalanceById(goodsId);
inventoryRepository.deductBalance(goodsId, count);
Order order = Order.of(userId, goods, count);
int result = orderRepository.insert(order);
//log.info("order is created, result:{}, id:{}", result, order.getId());
return order.getId();
}
我们把一次提交订单定义为一个事务。因此 TPS 就是一秒内可以创建多少个订单。我们知道随着并发数的增加,TPS 会增加,当并发数超过服务器负载后,TPS 会开始下降。响应时间随着并发数的增加,也会慢慢增加。我们的目标就是在响应时间超过 QoS(用户可接受的响应时间)要求之前,找到能够实现最大 TPS 的并发数
。
可以运行的代码工程 github.com/donghbcn/bm… 。如何运行请参考工程的 README.md
3.测试
3.1 测试环境准备
- PC1: 运行订单服务和数据库。一台普通台式电脑 i5-7500,4c16g,windows 11。
- PC2: 运行jmeter。普通笔记本电脑 i7-10510U, 4c16g,windows 11。
3.1.1 服务器(PC1)
笔者是在一台普通 PC 上同时运行服务和数据库,如果有富余的电脑,可以将服务和数据库分开。
初始化数据库
命令行登录数据库,执行代码工程中的 init-database.sql 初始数据库。脚本会重建数据库 bmall,并在库中创建商品表 goods、库存表 inventory 和订单表 order。并初始化一些测试数据:1002 条商品,以及对应的 1002 条库存。
DROP DATABASE IF EXISTS bmall;
CREATE DATABASE bmall;
USE bmall;
DROP TABLE IF EXISTS goods;
DROP TABLE IF EXISTS inventory;
DROP TABLE IF EXISTS `order`;
CREATE TABLE goods
(
id BIGINT(20) NOT NULL COMMENT '主键ID',
name VARCHAR(30) NULL DEFAULT NULL COMMENT '商品名',
price INT(11) NOT NULL DEFAULT 0 COMMENT '商品单价',
PRIMARY KEY (id)
);
CREATE TABLE inventory
(
goods_id BIGINT(20) NOT NULL COMMENT '商品id',
balance INT(11) NOT NULL DEFAULT 0 COMMENT '库存余额',
PRIMARY KEY (goods_id)
);
CREATE TABLE `order`
(
id BIGINT(20) NOT NULL COMMENT '主键ID',
user_id BIGINT(20) NOT NULL COMMENT '用户id',
goods_id BIGINT(20) NOT NULL COMMENT '商品id',
count INT(11) NOT NULL DEFAULT 0 COMMENT '购买数量',
amount INT(11) NOT NULL DEFAULT 0 COMMENT '订单金额',
PRIMARY KEY (id)
);
INSERT INTO goods (id, name, price) VALUES
(1, 'iphone 13', 650000),
(2, 'xiaomi 12', 349900);
INSERT INTO inventory (goods_id, balance) VALUES
(1, 10),
(2, 10);
DELIMITER $$
DROP PROCEDURE IF EXISTS proc_initData$$
CREATE PROCEDURE proc_initData()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i<=1000 DO
INSERT INTO goods (`id`, `name`, `price`) VALUES(i+100, CONCAT('goods_', i+100), 200000+i*100);
INSERT INTO inventory (`goods_id`, `balance`) VALUES(i+100, 10);
SET i = i+1;
END WHILE;
END$$
DELIMITER ;
CALL proc_initData();
运行订单服务 bmall
将打包好的 bmall-0.0.1-SNAPSHOT.jar 复制到服务器后,执行命令:
java -jar ./bmall-0.0.1-SNAPSHOT.jar
3.1.2 测试机(PC2)
我们用 JMeter 来模拟并发用户。为了避免测试用户影响服务的性能,我们在另外一台电脑(PC2)上运行 JMeter。笔者使用的版本是 5.4.3。读者可以去官网下载最新的版本做测试,官网下载地址:jmeter.apache.org/download_jm… JMeter 是以压缩包的形式发行的。解压后,在 bin 目录下执行 jmeter.bat(windows,如果是linux 系统,执行 JMeter.sh 命令)。
制定执行计划
执行计划工程文件 bmall.jmx 已经放在 github 工程根目录下。
- 新建一个测试计划(运行 JMeter 会默认新建一个空的测试计划),命名为bmall-plan,其他保持默认
- 右键测试计划,add/Threads/Thread Group,添加一个线程组模拟并发用户,命名为submit_order,其他设置如图:
- Number of Thread (users): 500
- Ramp-up period (seconds): 250
- Loop Count :800 (不要选中 Infinite 选项)
- 其余部分保持缺省不变
- 右键新建的线程组,add/Sampler/Http Request,添加一个 http request,命名为 submit_order,其他设置:
- Server Name or IP: 服务器地址,Port Number: 8900
- Http request: POST,Path: order/submit
- 添加三个参数:
- name: user_id,value: ${__Random(101,1100,user_id)}
- name: goods_id,value: ${__Random(101,1100,goods_id)}
- name: count,value: 1
- 右键线程组,add/Listener/View Results Tree,添加一个查看结果树,保持默认配置
- 右键线程组,add/Assersions/Response Assersion,添加一个响应断言,判断请求是否被正常处理。按照下图配置一个 response code = 200 的响应断言
- 右键线程组,add/Listener/Aggregate Graph,添加一个聚合视图,保持默认配置
- 保存测试计划到 bmall.jmx
${__Random(101,1100,user_id)} 是 JMeter 的随机函数,用于生成 101,到 1100 之间的一个随机整数。我们在准备数据库测试数据的时候,自动生成的 1000 个商品ID 区间是[101,1100]。user_id 在我们的测试场景中关系不大,随机生成就可以了,这里为了简单,就复用了随机生成商品的函数了。测试计划中添加的 http request 模拟一次提交创建订单,订单提交的用户是随机产生的一个 user_id,订单中添加了一个随机的商品,数量固定是 1.
3.2 运行执行计划
3.2.1 MySQL 缺省配置
设置好执行计划后,我们就可以开始测试了。我们通常应该用命令行的方式来运行执行计划。JMeter GUI 主要是用来配置执行计划。
./jmeter -n -t C:\code\test\jmeter\bmall.jmx -l C:\code\test\jmeter\report\bmall.csv -e -o C:\code\test\jmeter\report\bmall-report-500
这个命令执行 bmall.jmx 中的执行计划,将每次请求信息记录在-l
参数指定的文件中,最后将生成的统计报告信息保存至 -o
指定的目录中。
执行完成后,到统计报告目录打开 index.html 文件可以看到丰富的性能报告。这里放两个有代表性的图表。
- 并发用户数:就是 JMeter 中的线程数。我们设置的线程数是 500,攀爬阶段是 250 秒,循环 800 次。当 JMeter 运行这个测试计划的时候,线程数从0 开始在 250 秒内匀速上升到 500。每个线程会执行 800 次请求。如图所示线程数会经历爬升、平台、下降三个阶段。
- TPS 折线图:从图中我们可以看出 TPS 是个 M 型。在上面设置线程组的时候,我这正好与 TPS 的折线图对应。我们可以打开 bmall.csv(JMeter 命令 -l 参数指定的文件)文件找到 TPS 折线图 M 的两个尖峰的时间区域对应的线程数。笔者图中这两个区域对应的线程数大约在280-300 之间。也就是说当并发用户在300左右的时候,TPS 能够达到最高。减少或者增加并发用户都会导致TPS 下降。因此当并发用户增长超过这个数的时候,我们应该采取手段,限制并发用户数继续增长。这个在后续的文章中会讨论。
-
平均响应时间折线图:从图中我们可以发现当线程数处于最大的平台期的时候,响应时间是最长的。
-
服务器性能监视图:图中黄线是 CPU 性能折线图,CPU 最忙的负载也才 60%,继续增加并发用户数,并不能让CPU 变得更忙。这是为什么呢?图中的绿色折线是 MySQL 所在的硬盘的写入曲线,这个曲线是个
几
字型。这个硬盘是机械硬盘,这条曲线的平台应该就是硬盘的随机写入的极限吞吐量了。超过这个极限,增加并发用户数,并不能增加硬盘的吞吐量。只会让更多的并发请求处于 I/O Wait。
3.2.2 MySQL 写入优化相关参数
innodb_flush_log_at_trx_commit
innodb_flush_log_at_trx_commit定义数据库写log buffer到磁盘的频率以及方式 ,磁盘写入会影响性能,故而此参数让您可以在性能和持久性之间做出选择。
- 如果innodb_flush_log_at_trx_commit设置为0,每秒一次会将log buffer写入log file(这时数据只是从innodb内存到了操作系统的cache,依然没有进入磁盘), log file的flush(刷到磁盘)操作也同时进行。该模式下,在事务提交的时候,不会主动触发写入磁盘的操作。
- 如果innodb_flush_log_at_trx_commit设置为 1(MySQL 的缺省值),每次事务提交时MySQL都会把log buffer的数据写入log file,并且flush(刷到磁盘)中去。
- 如果innodb_flush_log_at_trx_commit设置为2,每次事务提交时MySQL都会把log buffer的数据写入log file.但是flush(刷到磁盘)操作并不会同时进行。该模式下,MySQL会每秒执行一次 flush(刷到磁盘)操作。
当设为0和2的时候,flush操作不能100%保证,所以在崩溃或断电的时候可能会丢失最后一秒的数据,而1是默认也是最安全的设置,保证commit的数据不会丢失。MYSQL RDS默认 innodb_flush_log_at_trx_commit也是1,这会对性能有消极影响但除非您可以接受丢失数据,否则不建议修改。
sync_binlog
sync_binlog 控制 MySQL server 将 binary log 同步到磁盘的频率,MYSQL RDS默认设置为最安全的1,但是同样会对性能有消极影响,除非您可以接受丢失数据,否则不建议修改。
- sync_binlog=0: MySQL不控制binlog的刷新,由文件系统自己控制它的缓存的刷新。这时候的性能是最好的,但是风险也是最大的。因为一旦系统崩溃,在binlog_cache中的所有binlog信息都会被丢失。
- sync_binlog=1: 这是最安全的设置,表示每次事务提交,MySQL都会把binlog刷下去,不过最安全也是性能损耗最大的设置,尤其对高并发的场景sync_binlog设置为0和1之间写入性能相差会很大,甚至达到5倍之多。
- sync_binlog=N, N 是 0 和1以外的正整数: binary log 会在收集N 个binary log commit groups 之后同步,同样在系统崩溃的时候 ,可能会丢失数据,N越大性能越好但同时丢失数据的风险也越大。
3.2.3 机械硬盘优化 MySQL 写盘参数性能
我们将这两个变量都设置为 0,尽量减少写入磁盘,再跑一次测试当作对比。
MySQL 参数调整之后,性能会提升,所以将JMeter 线程组做了如图调整,线程数增加到800,爬坡阶段调整到300 秒,循环次数调整到 1000
- 并发用户数:就是 JMeter 中的线程数。与前面类似,由于爬坡阶段设置长了点,活动线程数还没有达到设置的 800,最先创建的线程就开始达到了设置的1000 循环次数,开始退出了。
- TPS 折线图:从图中我们可以看出 TPS 是个不明显的 M 型。TPS 相比较上面的测试,有了明细的提升。同样我们发现,并发用户数仍然是在300 左右,TPS 达到最大。继续增加并发用户数,只会让响应时间增加。
平均响应时间折线图:与上面的测试类似,我们可以发现当线程数处于最大的期间,响应时间是最长的。
服务器性能监视图:图中黄线是 CPU 性能折线图,CPU 负载提升到了80%以上,由于硬盘的随机写入达到了瓶颈,继续增加并发用户数,并不能让CPU 变得更忙。
3.2.4 SSD 硬盘测试
如果使用SSD 硬盘,性能会是怎么样的呢?我们把数据库挪到服务器(PC1)的C 盘,这是个SSD 硬盘。 将下载的MySQL 压缩包解压到 C 盘:
- 停止 MySQL 服务,并卸载服务。
net stop MySQL8
mysqld --remove MySQL8
- 修改 MySQL 目录下 mysql.ini(主要是将配置文件中的涉及路径的参数做对应的调整)
- 重新安装 MySQL 服务,并启动服务。
mysqld --install MySQL8 --defaults-file="C:\infrastructure\mysql-8.0.29-winx64\my.ini"
net start MySQL8
- 线程组配置:
3.2.4.1 保持MySQL缺省参数
-
MySQL 配置保持缺省 innodb_flush_log_at_trx_comm 为 1. sync_binlog 为 1
-
并发用户数:就是 JMeter 中的线程数。与前面类似,这次缩短爬坡阶段,活动线程数达到设置的 800开始下降。
-
TPS 折线图:TPS 在7:48 分左右上升到了1000 左右,当时的并发用户数大概在280,也就是说还是300左右,不过由于CPU 提前达到了 100%,TPS 很难继续提升。
平均响应时间折线图:与上面的测试类似,我们可以发现当线程数处于最大的期间,响应时间是最长的,不过仍然比前面的平均响应时间要好一些。
服务器性能监视图:图中黄线是 CPU 性能折线图,CPU 负载提升到了100%,CPU 成了瓶颈,如果服务器的CPU 性能更好的话,应该可以继续提升 TPS。
3.2.4.2 MySQL 写入优化
- MySQL 配置保持缺省 innodb_flush_log_at_trx_comm 为 0 sync_binlog 为 0
- 并发用户数:就是 JMeter 中的线程数。与前面类似,活动线程数基本达到设置的 800开始下降。
- TPS 折线图:从图中我们可以看出 TPS 是个不明显的 M 型。TPS 相比较上面的测试,有了明显的提升。这次我们发现,并发用户数在100 左右,TPS 达到最大。然后略微下降,最终呈现 M 型。
平均响应时间折线图:与上面的测试类似,我们可以发现当线程数处于最大的期间,响应时间是最长的,不过仍然比前面的平均响应时间要好很多。
服务器性能监视图:图中黄线是 CPU 性能折线图,CPU 负载很快就提升到了100%,到达了性能瓶颈。
4 结论
单机性能与 CPU 和 硬盘更加相关。本文主要讨论的是随机写的 TPS,没有尝试调整内存的大小。机械硬盘和SSD 硬盘相差还是很大的。机械硬盘在随机读写的时候,需要有个硬盘转动和磁头移动的机械寻址时间,对性能影响非常大。对于IO 密集型操作,CPU 帮不上太多忙,大多处于 I/O Wait。SSD 的随机读写 IOPS 要比机械硬盘高很多。需要搭配更好的 CPU 才能把 SSD 的优势发挥出来。
另外由于本文的测试将应用服务与数据库放在了一台服务器上,应用服务消耗了想当多的CPU 性能。如果要找到 MySQL 在SSD 硬盘上 TPS 瓶颈,最好是独立部署。