什么是性能测试这一点可以想象一下大学一道选课的时候系统非常卡网页打不开刷新半小时。那么性能测试就是通过模拟多用户并发访问对被测系统施加特定负载监控其响应时间吞吐量资源利用率等指标以验证系统是否在预期负载下稳定运行并找出性能瓶颈的测试活动核心机制并发模拟一台机器上开很多线程同时申请向服务器发送请求模拟真实场景下很多人同时操作负载生成按照预设的测试计划控制请求的发送频率数量和节奏指标采集工具记录每个请求的响应时间成功/失败状态同时监控服务器的CPU、内存磁盘网络瓶颈定位当响应时间变长或者错误率上升时结合服务器资源监控判断是代码问题、数据库慢查询、内存泄漏还是网络带宽不足三大指标用户体验维度用户直观感受到的快慢响应时间1.基础定义响应时间 网络传输时间 服务器处理时间 前端渲染时间指从用户发起请求到客户端完整收到并展示结果的总耗时2.行业通用体验标准C 端互联网产品2 秒以内优秀用户几乎无等待感知2-5 秒合格用户有轻微等待感但可接受5 秒以上较差用户流失风险显著上升3.百分位数P50 / P95 / P99不用平均时间是因为因为一些少数极端慢请求会拉高掩盖大多数普通用户的真实体验P50中位数50% 的请求耗时不超过该值代表普通用户体验P9595% 的请求耗时不超过该值大厂核心接口的核心考核指标P9999% 的请求耗时不超过该值代表极端用户的体验上限系统容量维度衡量系统的承载能力1.并发数同一时间点同时向系统发起有效请求的用户数量是真正给服务器造成压力的用户2.吞吐量服务器资源维度定位性能瓶颈的依据CPU 使用率服务器计算资源的占用比例持续超过 70% 属于高风险区间内存使用率服务器内存占用比例持续上涨不回落大概率是内存泄漏超过 80% 有风险磁盘 IO磁盘的读写速度数据库、大量日志写入的场景容易出现 IO 瓶颈网络带宽服务器出入口的流量大小大文件上传、视频类场景容易出现带宽瓶颈术语大白话解释含义并发Concurrency同一时刻有多少人在同时操作同时在线操作的用户数TPSTransactions Per Second系统每秒能成功处理多少笔业务收银员每秒能结几单账响应时间Response Time从你点按钮到页面出来等了多久从发请求到收到响应的总时间吞吐量Throughput单位时间内系统能传输多少数据/处理多少请求每秒处理的事务数/请求数指标英文含义通俗理解响应时间Response Time从发请求到收到响应的总时间你点完单到拿到奶茶等了多久吞吐量Throughput (TPS/QPS)每秒处理的事务数/请求数奶茶店一分钟能做多少杯并发数Concurrency同时在线操作的用户数店里同时站着多少顾客错误率Error Rate失败请求占总请求的百分比10杯里有1杯做失败了CPU利用率CPU Usage服务器CPU被占用的比例员工有多忙内存占用Memory Usage服务器内存使用情况原料仓库用了多少空间磁盘I/ODisk I/O读写硬盘的频率和速度记账本翻得多快应关注的核心逻辑随着并发数的增加吞吐量应先线性上升然后趋于平稳响应时间应该抱持低位直到某个临界点才陡增。如果响应时间提前飙升吞吐量上不去说明存在瓶颈响应时间Response Time从发送请求到服务器处理完数据传回来所需要的时间标准普通接口0.5秒算优秀2秒用户开始烦躁5秒直接关闭页面吞吐量Throughput两个单位TPS每秒处理的事务数QPS每秒查询数标准TPS越高系统处理能力越强并发数两个概念在线用户数挂着网页不操作并发用户数真实同时在操作错误率100个人下单有3个人看到系统繁忙请稍后再试错误率就是3%标准一般要求 0.1%核心业务要求 0%资源利用率CPU/内存/磁盘/网络关注阈值CPU 80%危险随时可能卡死内存 85%危险可能开始杀进程磁盘I/O 100%读写堵死数据库查询变慢性能测试3中常见类型负载测试模拟真实用户正常并发量持续访问系统观察系统在标准业务压力下的性能指标验证系统能否稳定支撑日常业务流量目的验证系统在预期负载下的表现简单一句话测系统在正常、常规访问量下跑得稳不稳、速度快不快压力测试持续施加超出系统承载能力的负载直到系统崩溃目的找到系统的崩溃临界点和恢复能力稳定性测试在预期负载下持续运行较长时间通常 8h~72h目的发现内存泄漏、连接池耗尽等慢性问题性能测试流程阶段1需求分析与指标分析核心工作1.明确测试范围2.确定量化指标3.确认业务模型明确真实用户的行为比例阶段2测试计划制定Jmeter 初步学习右键测试计划 -添加 -线程用户- 线程组设置线程数5模拟5个虚拟用户 Ramp-up5 (5个虚拟用户在5秒内陆续上线相当于每秒启动1个) 循环次数10每个用户执行10次请求总请求量5*1050添加一个HTTP请求右键线程组→添加→取样器→HTTP请求协议https服务器或者IPhttpbin.org安全的对外公开测试站点端口号443https默认端口HTTP请求方法GET路径/get添加监听器用来查看结果没有监听器看不到任何结果右键线程组→添加→监听器→ 选择以下三个多选添加查看结果数查看每一次请求 详细请求/响应数据集合报告看平均响应时间吞吐量等统计数据图形结果可选更直观地看曲线保存并运行压测GET参数 CSV参数化 响应断言为什么需要参数化测一个商品列表的接口如果每一次请求page1size12,服务器会会把结果缓存起来Redis缓存第二次请求直接返回缓存测出来的性能是假的。真实场景下用户会翻页会换页大小所以每一次请求的参数应该是不一样的。参数化让jemter每一次请求带不同的参数值模仿真实用户行为这里讲解一下为什么要参数化这个问题讲的性能测试里面最大的陷阱之一缓存欺骗这里我们举一个在京东查商品列表的例子用第一性原理看任何商品查询都需要经过三层手机——API网关——缓存层Redis——数据库MySQL在缓存层Redis中如果缓存命中则直接返回结果不去查数据库速度极快通常几毫秒如果缓存未命中则去数据库查耗时几十毫秒到几百毫秒然后再把结果塞进缓存2. 网关是什么网关 京东机房的大门保安。你的手机发请求到京东不是直接冲进数据库的。先到网关网关做几件事检查你有没有登录验证Token检查你是不是恶意攻击限流、防刷把请求转发给正确的内部服务搜索服务、订单服务、商品服务3. Redis是什么Redis 京东机房的速查笔记本或快递柜。特点数据存在内存里查起来极快几毫秒缺点断电就丢所以只存临时数据作用最近查过的结果先记在这里下次有人查同样的内容直接翻笔记本不用去翻仓库账本4. MySQL数据库是什么MySQL 京东机房的仓库总账本。特点数据存在硬盘里永久保存查起来慢一些几十到几百毫秒作用所有商品的真实信息名称、价格、库存、图片链接、详情页内容都永久存在这里关系Redis是MySQL的加速器MySQL是Redis的后备仓库所以如果测试脚本每次发的参数都一致服务器只会查一次数据库后面全走缓存。那么实则测出来的是“缓存性能”而不是“用户真实的性能”这里我们举一个场景你打开京东APP搜索手机服务器返回第1页10条数据。你的手指动作实际发出去的请求参数服务器怎么处理是否走数据库刚搜手机看第1页keyword手机page1size10sort综合第一次查缓存里没有去数据库查✅ 走数据库往下滑看第2页keyword手机page2size10sort综合新参数组合缓存里没有去数据库查✅ 走数据库继续滑看第3页keyword手机page3size10sort综合新参数组合缓存里没有去数据库查✅ 走数据库点价格从低到高keyword手机page1size10sortprice_asc排序变了缓存里没有去数据库查✅ 走数据库筛选华为5Gkeyword手机brand华为network5Gpage1size10筛选条件变了缓存里没有去数据库查✅ 走数据库返回去看第1页综合排序keyword手机page1size10sort综合这个参数之前查过直接读缓存❌ 不走数据库多用户并发场景100个人同时逛京东用户操作请求参数用户A搜手机看第1页keyword手机page1size10用户B搜手机看第2页keyword手机page2size10用户C搜手机按销量排序看第1页keyword手机page1size10sortsales用户D搜耳机看第1页keyword耳机page1size10用户E搜手机筛选华为看第1页keyword手机brand华为page1size10用户F搜手机看第3页keyword手机page3size10.........用户100搜充电宝看第2页keyword充电宝page2size10时间谁搜了什么Redis有没有走哪条路耗时09:00:01用户A北京keyword手机page1❌ 没有查MySQL → 写入Redis80ms09:00:03用户B上海keyword手机page1✅ 有用户A刚写的直接读Redis3ms09:00:05用户C广州keyword手机page1✅ 有直接读Redis3ms09:00:10用户D河北keyword华为手机page1❌ 没有参数不同查MySQL → 写入Redis80ms数据库连接池连接池原理不用 VS 用没有连接池每一次用户请求都需要新建TCP连接然后数据库认证查数据然后断开连接。由此可以看到查数据只有5ms但是你建立连接和断开连接竟然还比查数据的时间还长高并发时1000个请求同时来要新建1000条连接数据库直接崩。所以需要连接池的原因原因大白话解释后果建连耗时TCP握手数据库认证比查数据还慢用户等半天响应时间虚高连接数上限MySQL本身有最大连接数限制默认151超了就直接拒绝连接报错资源浪费频繁创建/销毁连接CPU和内存吃不消数据库服务器卡死整个系统瘫痪为什么需要断言状态码200服务器已成功接收理解并处理了请求并且正常返回了响应体。只代表网络是通的至于回应的内容对不对业务操作有没有成功HTTP状态码完全不关心也就是说只表示【HTTP传输层面成功】不代表【业务逻辑成功】业务逻辑层由响应体里自定义的业务状态码通常是code字段标识管【业务操作有没有做对】是业务层面的状态。这个时候就需要断言来告诉Jemter返回的 JSON 里必须包含code: 0和list字段否则算失败。学习代码 性能测试待测服务 - PerfTestServer 用途专门给JMeter压测练习用的本地Web服务 技术Python Flask SQLite零依赖开箱即用 包含接口 1. GET /api/health - 健康检查最快练手用 2. POST /api/login - 用户登录返回Token 3. GET /api/products - 商品列表分页查询读数据库 4. POST /api/order - 创建订单需Token写数据库 5. GET /api/order/id - 查询订单单条查询 6. POST /api/slow-query - 慢查询故意延迟模拟瓶颈 启动方式 python perf_test_server.py 默认监听http://127.0.0.1:5000 from flask import Flask, request, jsonify import sqlite3 import time import uuid import os app Flask(__name__) # 数据库初始化 # 使用文件数据库这样重启后数据还在 DB_PATH os.path.join(os.path.dirname(__file__), perf_test.db) def get_db(): 获取数据库连接 conn sqlite3.connect(DB_PATH, check_same_threadFalse) conn.row_factory sqlite3.Row return conn def init_db(): 初始化数据库建表 插入测试数据 conn get_db() cursor conn.cursor() # 用户表 cursor.execute( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL ) ) # 商品表 cursor.execute( CREATE TABLE IF NOT EXISTS products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL, stock INTEGER NOT NULL DEFAULT 100 ) ) # 订单表 cursor.execute( CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, total_price REAL NOT NULL, create_time REAL NOT NULL ) ) # 插入测试用户如果表为空 cursor.execute(SELECT COUNT(*) FROM users) if cursor.fetchone()[0] 0: test_users [ (admin, admin123), (user001, pass001), (user002, pass002), (user003, pass003), (user004, pass004), (user005, pass005), ] cursor.executemany(INSERT INTO users (username, password) VALUES (?, ?), test_users) print([DB] 已插入 6 个测试用户) # 插入测试商品如果表为空 cursor.execute(SELECT COUNT(*) FROM products) if cursor.fetchone()[0] 0: test_products [ (iPhone 15, 5999.00, 50), (MacBook Pro, 14999.00, 30), (AirPods Pro, 1899.00, 100), (iPad Air, 4799.00, 40), (小米14, 3999.00, 80), (华为Mate60, 6999.00, 60), (机械键盘, 399.00, 200), (显示器27寸, 1299.00, 45), (鼠标, 199.00, 300), (充电宝, 99.00, 500), ] cursor.executemany(INSERT INTO products (name, price, stock) VALUES (?, ?, ?), test_products) print([DB] 已插入 10 个测试商品) conn.commit() conn.close() print(f[DB] 数据库初始化完成路径: {DB_PATH}) # 接口定义 app.route(/api/health, methods[GET]) def health(): 健康检查接口 用途JMeter练手第一步验证服务是否存活 特点无数据库操作响应极快 return jsonify({ code: 0, msg: ok, data: {status: running, timestamp: time.time()} }) app.route(/api/login, methods[POST]) def login(): 登录接口 请求体{username: admin, password: admin123} 响应{code: 0, data: {token: xxx}} 用途 - 练习POST请求 - 获取Token供后续接口使用JMeter参数关联 data request.get_json(silentTrue) or {} username data.get(username, ) password data.get(password, ) if not username or not password: return jsonify({code: 400, msg: 用户名或密码不能为空}), 400 conn get_db() cursor conn.cursor() cursor.execute( SELECT id FROM users WHERE username ? AND password ?, (username, password) ) user cursor.fetchone() conn.close() if user: # 生成简单token真实项目会用JWT这里简化 token ftoken_{username}_{uuid.uuid4().hex[:8]} return jsonify({ code: 0, msg: 登录成功, data: {token: token, user_id: user[0]} }) else: return jsonify({code: 401, msg: 用户名或密码错误}), 401 app.route(/api/products, methods[GET]) def products(): 商品列表接口 查询参数?page1size10 响应商品列表分页 用途 - 练习GET请求带参数 - 练习分页参数化JMeter CSV Data Set Config - 读数据库操作可观察查询性能 page request.args.get(page, 1, typeint) size request.args.get(size, 10, typeint) # 限制最大页大小防止恶意请求 if size 100: size 100 offset (page - 1) * size conn get_db() cursor conn.cursor() # 查询总数 cursor.execute(SELECT COUNT(*) FROM products) total cursor.fetchone()[0] # 分页查询 cursor.execute( SELECT id, name, price, stock FROM products LIMIT ? OFFSET ?, (size, offset) ) rows cursor.fetchall() conn.close() product_list [{ id: row[0], name: row[1], price: row[2], stock: row[3] } for row in rows] return jsonify({ code: 0, msg: ok, data: { list: product_list, total: total, page: page, size: size } }) app.route(/api/order, methods[POST]) def create_order(): 创建订单接口 请求头X-Token: token 请求体{product_id: 1, quantity: 2} 响应订单详情 用途 - 练习Header传参Token关联 - 练习POST JSON Body - 写数据库操作可观察写入性能 token request.headers.get(X-Token, ) if not token.startswith(token_): return jsonify({code: 403, msg: 缺少有效Token}), 403 # 从token中提取用户名简化版真实项目会解析JWT username token.split(_)[1] if len(token.split(_)) 1 else unknown data request.get_json(silentTrue) or {} product_id data.get(product_id, 0) quantity data.get(quantity, 1) if not product_id or quantity 0: return jsonify({code: 400, msg: 参数错误}), 400 conn get_db() cursor conn.cursor() # 查询用户ID cursor.execute(SELECT id FROM users WHERE username ?, (username,)) user_row cursor.fetchone() if not user_row: conn.close() return jsonify({code: 404, msg: 用户不存在}), 404 user_id user_row[0] # 查询商品信息 cursor.execute(SELECT price, stock FROM products WHERE id ?, (product_id,)) product_row cursor.fetchone() if not product_row: conn.close() return jsonify({code: 404, msg: 商品不存在}), 404 price, stock product_row[0], product_row[1] # 库存检查 if quantity stock: conn.close() return jsonify({code: 400, msg: 库存不足}), 400 # 计算总价 total_price round(price * quantity, 2) create_time time.time() # 创建订单 cursor.execute( INSERT INTO orders (user_id, product_id, quantity, total_price, create_time) VALUES (?, ?, ?, ?, ?), (user_id, product_id, quantity, total_price, create_time) ) order_id cursor.lastrowid # 扣减库存 cursor.execute( UPDATE products SET stock stock - ? WHERE id ?, (quantity, product_id) ) conn.commit() conn.close() return jsonify({ code: 0, msg: 订单创建成功, data: { order_id: order_id, product_id: product_id, quantity: quantity, total_price: total_price, create_time: create_time } }) app.route(/api/order/int:order_id, methods[GET]) def get_order(order_id): 查询订单接口 用途单条查询练习路径参数 conn get_db() cursor conn.cursor() cursor.execute( SELECT id, user_id, product_id, quantity, total_price, create_time FROM orders WHERE id ?, (order_id,) ) row cursor.fetchone() conn.close() if not row: return jsonify({code: 404, msg: 订单不存在}), 404 return jsonify({ code: 0, msg: ok, data: { order_id: row[0], user_id: row[1], product_id: row[2], quantity: row[3], total_price: row[4], create_time: row[5] } }) app.route(/api/slow-query, methods[GET, POST]) def slow_query(): 慢查询接口故意制造瓶颈 查询参数?delay2 延迟秒数默认2秒最大10秒 用途 - 模拟生产环境中的慢SQL、慢接口 - 在JMeter中观察当并发上来时响应时间是否线性增长 - 自己改代码里的延迟时间制造不同级别的瓶颈 扩展玩法 - 把 delay 改成随机数模拟不稳定的第三方接口 - 把 time.sleep 改成复杂的数据库全表扫描 delay request.args.get(delay, 2, typefloat) if delay 10: delay 10 # 安全限制防止恶意请求 # 模拟耗时操作可以是 sleep也可以是复杂计算 time.sleep(delay) # 顺便做一次数据库查询增加负载 conn get_db() cursor conn.cursor() cursor.execute(SELECT COUNT(*) FROM products) count cursor.fetchone()[0] conn.close() return jsonify({ code: 0, msg: 慢查询完成, data: { delay: delay, product_count: count, timestamp: time.time() } }) # 启动入口 if __name__ __main__: init_db() print(\n * 50) print( 性能测试待测服务已启动) print( * 50) print( 访问地址: http://127.0.0.1:5000) print( 接口列表:) print( GET /api/health) print( POST /api/login) print( GET /api/products?page1size10) print( POST /api/order (Header: X-Token)) print( GET /api/order/id) print( GET /api/slow-query?delay2) print( * 50 \n) # threadedTrue 支持多线程并发处理请求 app.run(host0.0.0.0, port5000, threadedTrue, debugFalse)Token关联与业务流程串联场景模拟真实用户——先登录获取Token再带着Token去创建订单为什么学这个错误做法问题直接压测/api/order不带 Token服务器返回 403测的是拒绝访问的性能不是创建订单的性能手动写死 Token 到 HeaderToken 会过期而且每个用户 Token 不同不能模拟多用户正确做法1.线程启动后先执行登录-拿到响应里的Token2.把Token提取出来到变量里3.下一个请求从变量里提取Token放到请求头里4.这样每一个用户都有独立的TokenJemter实现这个流程的核心组件组件作用放在哪里JSON Extractor从 JSON 响应中提取指定字段的值放在登录请求下面后置处理器HTTP Header Manager给请求添加自定义 Header放在创建订单请求下面配置元件事务控制器把多个请求打包成一个业务操作统计放在线程组下面逻辑控制器性能瓶颈判断性能拐点的4个标准1.TPS停滞并发数继续增加但 TPS 不再同步增长甚至出现下降2.响应陡增P95/P99 响应时间出现阶跃式上升比如从 10ms 涨到 100ms3.错误率飙升错误率从接近 0 开始快速上升出现大量超时、服务报错4.资源打满被测服务器的 CPU 使用率持续超过 85%或内存、磁盘 IO 出现瓶颈排查思路发现拐点只是第一步还要能定位瓶颈出在哪一层。大厂通用的排查思路是从外到内、从上到下逐层排除第 1 层施压端瓶颈优先排除现象并发加不上去TPS 上不去但被测服务器资源使用率很低排查查看运行 JMeter 的电脑 CPU、内存占用情况结论如果施压端资源占满测出来的是本地电脑的上限不是服务器的上限需要增加施压机器做分布式压测第 2 层网络瓶颈现象响应时间里大部分是网络耗时服务器实际处理时间很短排查本地服务基本不存在网络问题跨机房、公网压测需要关注带宽、延迟、丢包率第 3 层应用服务端瓶颈CPU 瓶颈业务逻辑复杂、大量循环计算、对象频繁创建销毁CPU 使用率持续高位内存瓶颈内存泄漏、大对象过多内存占用持续上涨不释放线程池瓶颈服务端工作线程数不足请求排队等待CPU 空闲但响应慢第 4 层数据库瓶颈线上最常见慢 SQL无索引、全表扫描、多表关联复杂单条 SQL 耗时极高锁等待高并发写操作行锁 / 表锁竞争大量请求阻塞等待锁释放连接池瓶颈数据库连接数打满新请求拿不到连接