思路
本篇会针对单机环境的mysql进行模拟插入测试, 最终得到性能指标, 据此进行架构的设计与优化.
原先我的写作思路是写一步代码写一步要点, 让大家更好理解. 后来想既然我踩了那么多坑, 为啥让大家再踩一次... 所以直接先把程序放出, 根据代码进行讲解.
代码的地址如下:
git clone https://gitee.com/xiaofeipapa/springcloud-adv -b high-concurrency
工程是这个:
image.png
工程要点
用 hikari 数据源代替druid
网上很多springcloud的教程都写着用druid数据源, 其实这已经是很落后的信息了. druid在并发数稍大的时候就会出现连接丢失, 用hikari就不会有这个问题.
<!-- 主要增加 HikariCP 依赖 -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<exclusions>
<!-- 排除 tomcat-jdbc 以使用 HikariCP -->
<exclusion>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</exclusion>
</exclusions>
</dependency>
sql与模拟程序
我们的目标是模拟创建用户. 脚本如下:
# -- mysql 性能测试
DROP TABLE IF EXISTS `mock_user`;
CREATE TABLE `mock_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(500) NOT NULL comment '用户名',
`mobile` varchar(500) NOT NULL comment '手机号',
`create_time` datetime NOT NULL COMMENT '创建时间',
`mock_1` varchar(500) NOT NULL,
`mock_2` varchar(500) NOT NULL,
`mock_3` varchar(500) NOT NULL,
`mock_4` varchar(500) NOT NULL,
`mock_5` varchar(500) NOT NULL,
`mock_6` varchar(500) NOT NULL,
`mock_7` varchar(500) NOT NULL,
`mock_8` varchar(500) NOT NULL,
`mock_9` varchar(500) NOT NULL,
`mock_10` varchar(500) NOT NULL,
`mock_11` varchar(500) NOT NULL,
`mock_12` varchar(500) NOT NULL,
`mock_13` varchar(500) NOT NULL,
`mock_14` varchar(500) NOT NULL,
`mock_15` varchar(500) NOT NULL,
`mock_16` varchar(500) NOT NULL,
`mock_17` varchar(500) NOT NULL,
`mock_18` varchar(500) NOT NULL,
`mock_19` varchar(500) NOT NULL,
`mock_20` varchar(500) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8;
程序如下:
// --------------- service 程序
@Override
public String saveUser(String phone) {
// 生成随机姓名
String name = PersonInfoSource.getInstance().randomChineseName();
MockUser mockUser = new MockUser();
mockUser.setName(name);
mockUser.setMobile(phone);
mockUser.setCreateTime(LocalDateTime.now());
// 反射设置属性
for (int k=1; k <= 20;k++){
String key = "mock"+k;
//生成个随机汉字
String xx = OtherSource.getInstance().randomChinese(200);
ReflectUtil.setFieldValue(mockUser, key, xx);
}
try{
Long start = System.nanoTime();
this.save(mockUser);
log.info("---- 线程id: {} , 运行总时间: {}", Thread.currentThread().getId(), TimeUtils.catSecond(start));
return "成功, 新用户: " + mockUser.getName();
}catch (Exception e){
log.error("#### 新增用户失败. ", e);
return "失败";
}
}
// ---------------------- controller
@Resource
IMockUserService mockUserService;
@GetMapping("/test/mock")
public String mockInsert(@RequestParam String phone, @RequestParam Integer actId, @RequestParam Integer num) {
return mockUserService.saveUser(phone);
}
为了尽量真实模拟业务场景, 我在用户表增加了20个比较大的字段, 这些字段内容用循环生成.
mysql 服务端配置
如果在本机装好mysql(8的版本), 并且没有任何优化, 那么插入数据很很难看. 所以要修改mysql的配置. 我这里是ubuntu系统, 其他系统请搜索相关帖子, 看看配置文件在哪...
sudo vim /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
#
# * Basic Settings
#
user = mysql
# pid-file = /var/run/mysqld/mysqld.pid
# socket = /var/run/mysqld/mysqld.sock
# port = 3306
# datadir = /var/lib/mysql
# If MySQL is running as a replication slave, this should be
# changed. Ref https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_tmpdir
# tmpdir = /tmp
#
# Instead of skip-networking the default is now to listen only on
# localhost which is more compatible and is not less secure.
bind-address = 127.0.0.1
mysqlx-bind-address = 127.0.0.1
#
# * Fine Tuning
#
# key_buffer_size = 16M
# max_allowed_packet = 64M
# thread_stack = 256K
# thread_cache_size = -1
# This replaces the startup script and checks MyISAM tables if needed
# the first time they are touched
myisam-recover-options = BACKUP
# max_connections = 2000
# table_open_cache = 4000
#
# * Logging and Replication
#
# Both location gets rotated by the cronjob.
#
# Log all queries
# Be aware that this log type is a performance killer.
# general_log_file = /var/log/mysql/query.log
# general_log = 1
#
# Error log - should be very few entries.
#
# log_error = /var/log/mysql/error.log
#
# Here you can see queries with especially long duration
# slow_query_log = 1
# slow_query_log_file = /var/log/mysql/mysql-slow.log
# long_query_time = 5
# log-queries-not-using-indexes
#
# The following can be used as easy to replay backup logs or for replication.
# note: if you are setting up a replication slave, see README.Debian about
# other settings you may need to change.
# server-id = 1
# log_bin = /var/log/mysql/mysql-bin.log
# binlog_expire_logs_seconds = 2592000
# max_binlog_size = 100M
# binlog_do_db = include_database_name
# binlog_ignore_db = include_database_name
# ---- mysql 性能调整
key_buffer_size = 256M
max_allowed_packet = 64M
thread_stack = 256K
open_files_limit = 65535
back_log = 1024
max_connections = 3000
max_connect_errors = 1000000
table_open_cache = 1024
table_definition_cache = 1024
thread_stack = 512K
sort_buffer_size = 4M
join_buffer_size = 4M
read_buffer_size = 8M
read_rnd_buffer_size = 4M
bulk_insert_buffer_size = 128M
thread_cache_size = 768
interactive_timeout = 600
wait_timeout = 600
tmp_table_size = 32M
max_heap_table_size = 32M
# ---- log settings
log_timestamps = SYSTEM
log_error = /var/lib/mysql/error.log
log_error_verbosity = 3
slow_query_log = 1
log_slow_extra = 1
slow_query_log_file = /var/lib/mysql/slow.log
long_query_time = 0.1
log_queries_not_using_indexes = 1
log_throttle_queries_not_using_indexes = 60
min_examined_row_limit = 100
log_slow_admin_statements = 1
log_slow_slave_statements = 1
log_bin = /var/lib/mysql/mybinlog
binlog_format = ROW
sync_binlog = 1 #MGR环境中由其他节点提供容错性,可不设置双1以提高本地节点性能
binlog_cache_size = 4M
max_binlog_cache_size = 2G
max_binlog_size = 1G
binlog_rows_query_log_events = 1
binlog_expire_logs_seconds = 604800
#MySQL 8.0.22前,想启用MGR的话,需要设置binlog_checksum=NONE才行
# binlog_checksum = CRC32
# gtid_mode = ON
# enforce_gtid_consistency = TRUE
# ---- innodb settings
transaction_isolation = REPEATABLE-READ
innodb_buffer_pool_size = 6144M
innodb_buffer_pool_instances = 4
innodb_data_file_path = ibdata1:12M:autoextend
innodb_flush_log_at_trx_commit = 1 #MGR环境中由其他节点提供容错性,可不设置双1以提高本地节点性能
innodb_log_buffer_size = 32M
innodb_log_file_size = 1G #如果线上环境的TPS较高,建议加大至1G以上,如果压力不大可以调小
innodb_log_files_in_group = 3
innodb_max_undo_log_size = 4G
# 根据您的服务器IOPS能力适当调整
# 一般配普通SSD盘的话,可以调整到 10000 - 20000
# 配置高端PCIe SSD卡的话,则可以调整的更高,比如 50000 - 80000
innodb_io_capacity = 4000
innodb_io_capacity_max = 8000
innodb_open_files = 65535
innodb_flush_method = O_DIRECT
innodb_lru_scan_depth = 4000
innodb_lock_wait_timeout = 10
innodb_rollback_on_timeout = 1
innodb_print_all_deadlocks = 1
innodb_online_alter_log_max_size = 4G
innodb_print_ddl_logs = 1
innodb_status_file = 1
#注意: 开启 innodb_status_output & innodb_status_output_locks 后, 可能会导致log_error文件增长较快
innodb_status_output = 0
innodb_status_output_locks = 1
innodb_sort_buffer_size = 67108864
innodb_adaptive_hash_index = OFF
innodb_autoextend_increment=100
sudo service mysql restart
网上一些文章写mysql配置的时候, 只写改变 max_connections 这个参数, 其实是不对的, 需要调整的参数非常多. 建议读一下<高性能MySql> 这本书.
事实上多数公司的生产环境数据库会购买阿里云/aws的产品,不需要程序员自己调优. 所以本篇不作过多mysql的深入介绍, 让这个配置足以满足本机测试即可.
测试与结果分析
这个工程只需要独立测试, 因此不需要再启动nacos. 启动工程, 然后把上一章的jmeter测试用例修改修改, 改成这样:
链接地址: /test/mock
端口: 16000
线程数: 从500-2000逐个测试.
并且, 增加一个 Aggregate Report
image.png
这个报表能够看到中位数数据, 会更精准.
最终我的本机测试数据如下(循环次数10次, 每次测试均执行5次,取平均值):
请求数 | 线程数 | 平均完成时间(ms) | 中位数(ms) | 90%请求时间(ms) | 95%请求时间(ms) | 99%请求时间(ms) | 请求最长时间(ms) |
---|---|---|---|---|---|---|---|
5000 | 500 | 352 | 192 | 879 | 1118 | 1663 | 2637 |
7500 | 750 | 774 | 454 | 1964 | 2536 | 3821 | 5902 |
10000 | 1000 | 1039 | 653 | 2638 | 3408 | 5096 | 9111 |
15000 | 1500 | 1887 | 1347 | 4515 | 5748 | 8380 | 16567 |
可以看到, 随着线程数的增加, 数据逐渐高得离谱起来. 为什么会这样呢? 简单说就是mysql的事务机制(红黑树/binlog.. 等) 会很频繁地创建IO, 性能高不到哪去. 那么怎么调整呢? 可以从硬件优化, 代码级别优化和架构优化层面去进行.
我们来逐步分析.
硬件优化
购买更高配的mysql服务器是主要解决手段之一. 但不能只买硬件而不做架构和代码优化. 就算新的服务器能抗住4000个并发插入, 那么当并发数变成 8000个的时候怎么办? 再次买高配服务器?
当然我不得不说这也是一个思路, 事实上一些没有架构师的小公司就是这么做的. 又或者经过一段时期的观察发现公司实际上没有多大的并发量, 那么硬件优化足以解决问题.
牢记架构师的原则: 观察真实业务情况, 不要过度设计/开发
代码层面优化
当然我写这篇博客的目的, 是针对那些真的有不错流量的公司/程序员. 假设我们就真的要做优化. 代码层面优化有两个主要思路. 一个是减少事务数, 把单条插入语句合并到多条.
INSERT INTO
[表名]([列名],[列名])
VALUES
([列值],[列值])),
([列值],[列值])),
([列值],[列值]));
然后数据库配置设置 auto_commit = false , 每次都主动开启/关闭事务.
这种代码的写法能极大提高mysql的性能. 问题是对应用代码的侵入性非常大. 批量语句每批多少条数据? 如果事务失败了, 这些数据怎么重新插入? 这些都把问题扔给了应用开发程序员.
所以代码层面的优化要做, 不过优先级应该排于架构优化之后. 通过设计一套能利用上代码优点, 又尽量对应用开发人员透明的架构, 才是好的设计. (不过通常情况下, 架构和代码写法需要互相适应)
架构优化与业务再次思考
如上分析, 最终我们简单的controller->service->mysql的架构, 是没办法满足真实的高并发场景的, 我们要做出优化. 我们先来重新思考业务和指标: 什么叫做tps, 什么叫做用户同时访问.
业务的再次分析
从我多年的工作经验来看, "支持1万个用户抢单, 其中2000个能抢成功" (上文定的单机测试指标) 这个需求, 对于业务人员来说就是 2000并发, 很多程序员由此推导出系统需要2000 tps, 甚至直接推导数据库需要2000 tps ..... 其实不是的, 我们来从http请求的角度来分析这个需求
何谓支持1万个用户?
我们来看看这个图, 假设有1万个用户访问应用集群如下:
image.png
那么tomcat是怎么支持这些请求的呢? tomcat的配置有几个核心参数:
tomcat:
threads:
max: 200
# ---------- springboot 旧版本配置
# # 最大工作线程数, 默认200
# max-threads: 2000
# # 最大连接数 从 100 变成 200
max-connections: 2000
# # 等待队列
accept-count: 100
# min-spare-threads: 500
max-connections: 最大连接数. (不同版本的tomcat, yaml配置不同)
accept-count: 等待队列.
可以这样去理解, 假设只有1个tomcat, 它的最大连接数是 2000, 那么其实剩下的8000个请求都在 accept-count 这个队列等待. 如果等到指定超时时间都没有连上, 客户端最终会得到连接失败这个应答.
所以首先来说, 不能出现有用户连接失败这个情况. 然后我们上篇文章说过, 用户的通用软件体验是单个请求完成时间最好在1-4秒. 也就是, 所有用户(注意是所有)的请求时间都要处于1-4秒之间, 当然越快越好.
计算架构tps
电商的购物和支付功能背后都会调用一系列内部服务和外部服务(如库存子系统, 消息子系统, 物流子系统, 积分子系统, 第三方支付系统... ) , 这些子系统都各有自己的tps, 我们可以先推导出整个架构的tps, 再推导子系统的tps.
假设请求是一个写操作(如上面的创建用户), 那么可以近似理解成一个请求就是一个架构 tps, 那么"支持1万个用户抢单", 就可以整理成如下变量表格:
请求完成时间(ms) | tomcat 每秒可处理请求数 | 近似架构tps |
---|---|---|
50 | 40000 | 40 000 tps |
100 | 20000 | 20 000 tps |
200 | 10000 | 10 000 tps |
300 | 6600 | 6 667 tps |
400 | 5000 | 5000 tps |
(以上按照单台tomcat 2000连接数设置)
根据这个表格, 我们只要把 nacos和两三台tomcat 连起来, 就轻松满足 1万tps 的目标了.
为什么请求完成时间是400ms
之前说过用户能够接受的请求完成体验是1-4秒, 但别忘了这1-4秒是整个系统共享的时间, 包括前端应用(网站/app) 和后端接口时间. 从经验来看, 400ms是比较标准的答案, 最大不要超过800ms. 否则整个请求完成时间很可能超过4秒.
优化架构的理由找到了
现在我们最终完成了从口头需求 -> tps数据的量化分析和整理, 总结下了变量指标. 那么优化架构的理由也找到了. 从之前的测试来看, 无数据库操作的请求处理, 单机能轻松超过1万tps, 每个请求小于200ms. 但是mysql 无论怎么优化都不能达到这个数据. 为此架构要做调整, 从同步架构变成异步架构. 这是下一章的内容了.
再次提醒, 代码在这:
git clone https://gitee.com/xiaofeipapa/springcloud-adv -b high-concurrency
网友评论