楠风测开 · 测试工程师复习日记 Day18
给一次数据库改动建立实验契约:负载、正确性、并发和撤回
前言:一条SQL的收益,怎样变成可接受的改动
朋友们好,我是楠风。
Day16讨论索引候选,Day17沿证据定位慢查询。今天关心的是工程验收:候选改动合并前,怎样证明它没有把成本转移到写入、锁等待或错误处理上?
用一个教学问题串起来:订单列表查询加了联合索引,单线程执行看起来更快。现在引入状态更新,再增加并发,是否仍能满足目标?
本文参照MySQL 8.4 LTS与InnoDB。给出可执行的实验步骤与记录规范,当前未提供本机实测性能数据;没有“优化后快了多少”的预设答案。
一、把待验证的假设写成一个小契约
本次只改变 nf_order_bench 上的 idx_user_status_created(user_id,status,created_at)。不同时修改SQL、连接池和隔离级别。
问题:固定订单列表在读写并发下是否受益?
基线:10万行合成数据,只有主键。
候选:相同数据,增加(user_id,status,created_at)索引。
固定项:SQL、硬件、网络、提交边界、数据分布、采样方式。
对照项:读写各自的成功吞吐、P95、错误、资源与等待。
否决项:结果不一致、业务不变量失败、不可接受的写入退化。
撤回项:显式删除本次候选索引,恢复测试数据,复测基线。
验收值由业务目标填写,没有目标就先做基线调查。不能先挑最快的一轮,再反过来定义“快这么多就算通过”。也不要用“没报错”代替正确性验收,结果为空、重复记录和状态写错都可能没有SQL异常。
二、把夹具分成性能面和业务面
性能面沿用Day16的 nanfeng_mysql_lab.nf_order_bench:id、user_id、status、total_amount、created_at,共100000行。业务面沿用Day15的商品、订单、明细、支付四表。
第一套数据回答查询与更新在负载下怎样变化。第二套数据回答金额、状态和库存是否符合规则。两套验收互相补充,不能用“压测表返回成功”证明真实下单正确。
先保存以下输出,并记录客户端与数据库各自的资源配额:
USE nanfeng_mysql_lab;
SELECT VERSION(), @@session.transaction_isolation,
@@session.autocommit;
SELECT COUNT(*) FROM nf_order_bench;
SHOW CREATE TABLE nf_order_bench;
SHOW INDEX FROM nf_order_bench;
如果此前已建立候选索引,先识别当前处于哪种状态,再恢复约定基线。不要把实验初始化写成一个每次先删库的按钮;不明来源的同名表应该先核对。
测试数据版本也属于实验配置。 10万行相同,不代表用户分布、状态比例与时间范围相同。至少保存生成规则、初始化日期,以及本次实际修改过的行。
三、为读写指定固定输入,避免结果相互干扰
本轮读取固定为:
SELECT id,user_id,status,created_at
FROM nf_order_bench
WHERE user_id=42 AND status='PAID'
AND created_at>='2026-10-01'
AND created_at<'2026-11-01'
ORDER BY created_at,id
LIMIT 20;
保存完整结果,候选与撤回后逐行比较。第二排序键 id 用来固定同一时间内的顺序。只比较“20行”会漏掉返回了错误订单的问题。
写入则使用 id=100000,按统一生成规则它属于用户1000,初态为 CANCELLED。每轮计时前将它改为 CREATED,正式阶段循环执行:
UPDATE nf_order_bench
SET status=CASE WHEN status='PAID' THEN 'CREATED' ELSE 'PAID' END
WHERE id=100000;
每次更新应真实提交。这条语句修改了候选索引中的字段;若换成 status=status,就改变了待测操作。实验结束后,在停止负载的情况下恢复原始状态。
这里刻意使用单行热点,并让它与读取用户分开:一方面容易观察写锁竞争,另一方面不会让正常写入改变固定查询结果。这是一种诊断负载,不能代表分散订单的访问性能,更不能作为支付状态流转实现。
下一轮需要验证均匀访问时,可以为写入连接分配不同记录;那是新的负载条件,应单独建立基线,不能和这轮热点结果拼接比较。
四、压测输入一致,测量口径也要一致
建立两组工作负载:只读,以及四读一写。每组依次测试1、4、8个并发连接,预热后正式采样,前后使用相同轮数、相同每连接工作量或相同时长。
先用小并发确认结果与错误处理,再提升档位。并发提高后吞吐不再增加,或者尾延迟明显上升时,先保存证据;不能为了得到更大的QPS继续堆连接,直到数据库失去响应。
没有现成框架时,mysqlslap可以做首轮筛查。把SELECT保存为 day18-read.sql;混合文件则写四条相同SELECT,再写一条上面的UPDATE,均用分号结束,不加注释。
mysqlslap --host=127.0.0.1 --port=3306 --user=lab_user --password --create-schema=nanfeng_mysql_lab --no-drop --query=day18-read.sql --delimiter=";" --concurrency=4 --iterations=3 --number-of-queries=500 --commit=1
按实际环境替换连接信息,密码通过交互输入。只读与混合均保持 --commit=1,表示每条语句之后提交,五条语句不是一个下单事务。切换文件、并发数前保存运行编号与实际输出。mysqlslap参数说明
工具给出的整轮耗时只能用于初步比较。如果要声称“P95达标”,还需要应用或客户端逐操作明细,不能把整轮最大值改名成P95。
建议固定以下字段:
| 字段 | 约定 |
|---|---|
| run_id / variant | 区分轮次与基线、候选、撤回复核 |
| operation | read、write或完整业务事务 |
| elapsed_ms | 从开始执行到结果取完,写入计到提交返回 |
| success / error_code | 保留成功、超时、死锁、连接失败 |
| retry_count | 业务请求内部重试次数 |
| concurrency | 本轮并发连接或业务请求数,写清含义 |
读、写分别计算成功吞吐与成功样本P95,同时展示失败数及超时配置。业务端到端耗时应包含连接池排队和内部重试;数据库执行耗时则是另一层测量,两者不能互相替代。
不要平均几个轮次的P95来冒充整体P95。要么保留每轮分位数和样本数,要么合并符合相同口径的原始耗时后重新计算。没有原始记录时,结论只能停留在已有的统计层级。
五、正确性断言必须在性能数字旁边
Day15业务样例中,订单1004有两条明细和两条成功支付。如果直接连两张明细表再求和,会产生四行。性能优化前后,成功支付金额都应根据独立聚合核对,不能因为查询变快就放松口径。
可先核对未修改的样例总额:
SELECT SUM(amount) AS paid_total
FROM payments WHERE status='SUCCESS';
SELECT sku,stock FROM products WHERE stock<0;
前者按教学数据应为359.60,后者应无记录。除此之外还要逐订单核对金额;全局总额相等可能掩盖两笔订单一多一少。库存是快照,历史订单并不提供这张表的完整出入库账,事务测试要比较本次前后增量。
下单路径的测试控制流应明确成下面这样。这是业务处理伪代码,不是可粘贴执行的SQL:
开始事务
按业务约定处理同一个订单号的重复请求
条件扣库存;受影响行数不是1,则回滚并返回库存不足
写订单与明细;必要时记录单库内的支付状态
核对必要条件后提交
任一步出现错误:明确回滚并结束当前尝试
允许重试时:按幂等规则、有上限地重试完整业务事务
orders.order_no与 payments.payment_no 的唯一约束能约束重复键,但唯一键冲突之后返回什么、是否再次扣库存,仍由应用逻辑决定。错误分支必须参与验收,不能只跑成功路径。
六、用双会话把等待和回滚验出来
在四表独立教学环境中,保存KEYBOARD原库存。A、B是两个独立连接,均选中实验库。A先执行:
START TRANSACTION;
UPDATE products SET stock=stock-1
WHERE sku='KEYBOARD' AND stock>=1;
SELECT ROW_COUNT() AS changed_rows;
A只有更新一行才保持事务打开,否则回滚结束。B再执行:
SET @day18_old_lock_wait_timeout=@@SESSION.innodb_lock_wait_timeout;
SET SESSION innodb_lock_wait_timeout=5;
START TRANSACTION;
UPDATE products SET stock=stock-1
WHERE sku='KEYBOARD' AND stock>=1;
A保持未结束,B应出现等待并最终超时。会话级5秒仅用于演练,结束时在保存旧值的同一个B连接内主动回滚并恢复设置,不要中途重连:
ROLLBACK;
SET SESSION innodb_lock_wait_timeout=@day18_old_lock_wait_timeout;
A也 ROLLBACK,重新查询库存,确认回到初态。即使B意外立即完成,也要执行上述清理。
这一步有两个价值:确认竞争路径确实存在,以及确认失败分支会清理事务。默认情况下,锁等待超时通常只回滚当前语句;死锁则会回滚被选择的事务,不能把它们写成同一种处理。InnoDB错误处理
再补一次多步回滚:条件扣库存后,用未占用的新ID写教学订单和明细,在提交前主动中止,回滚后查新订单、明细不存在,库存恢复。插入发生错误时同样立即回滚,不带错继续。
这些数据库演练不能取代应用并发验收。对库存1的可重置夹具,同时提交多个完整下单请求,检查成功数、拒绝结果、订单数和库存;同订单号重复提交时,检查是否只出现一次业务效果。涉及外部扣款时,要另验支付确认与补偿,数据库回滚不能撤回外部动作。事务说明
七、只改一个因素,但观察整个结果
按“基线A→候选B→撤回A”完成负载对照,记录每轮日期和资源曲线。三个阶段都先停止负载、结束测试事务,确认索引与数据状态,再统一执行 ANALYZE TABLE nf_order_bench,最后采用相同的预热与测量流程。基线也要更新统计信息,不能只给候选刷新;索引创建、统计信息更新、恢复数据都放在计时之外。每轮混合负载重置为 CREATED 时,也先提交并更新统计信息,再预热与测量。
在本实验表确认索引名称后,候选变更为:
CREATE INDEX idx_user_status_created
ON nf_order_bench(user_id,status,created_at);
ANALYZE TABLE nf_order_bench;
撤回时停止负载、结束事务,再执行:
ALTER TABLE nf_order_bench DROP INDEX idx_user_status_created;
UPDATE nf_order_bench SET status='CANCELLED' WHERE id=100000;
COMMIT;
ANALYZE TABLE nf_order_bench;
DDL存在隐式提交,不能把建索引塞进事务后依赖ROLLBACK撤回;撤回完成还要核对实际索引与数据状态。隐式提交说明
假如B变快,撤回后的A也变快,就先检查缓存和时间漂移;假如读取变快而写入P95退化,就回到预设目标判断,而不是只报告读取数字。可重复并不要求每轮完全一样,但要解释波动范围,避免挑选最好的一轮。
八、AI的输入应该是证据包
把表结构、计划、负载定义、各轮指标、失败样本与正确性检查一起提供。对请求量、错误和延迟统一单位;对没有采集的P95明确标记缺失。
请审阅这次MySQL8.4性能实验。
唯一变化:[填写实际变化]
环境、输入、原始结果、错误与验收记录:[粘贴]
请给出:
已观察到的变化;尚不能确定的原因;缺失证据;
下一轮只改变一个因素的实验;接受与撤回条件。
不要根据数据量或日活推算数据库固定QPS上限。
不要给没有测量的指标填写成绩。
AI建议如果涉及隔离级别、持久性或事务拆分,先重新检查正确性约束。它提出的“更快”可能来自减少了原本要求的工作,而不是在同一要求下更高效。
九、让发布结论可以被复核
| 验收项 | 记录 |
|---|---|
| 查询结果、排序与逐订单金额 | 待验证 |
| 基线/候选/撤回的读写吞吐 | 待测 |
| 读写P95、样本数与失败记录 | 待采集 |
| 锁等待、死锁、重试和重复提交 | 待验证 |
| 索引、数据及配置恢复结果 | 待验证 |
| 数据规模、热点、并发范围与遗漏项 | 待填写 |
结果不一致直接拒绝;正确但关键指标缺失,继续采样;正确性通过且目标操作改善、其他操作仍满足约定,才考虑推进。结论应写明数据规模和负载条件,不从单机教学表直接推到生产容量。
💬 你会优先补哪类验收:读写混合、失败回滚,还是重复提交?可以先从自己系统最容易遗漏的一项开始。
下期预告
Day19 · Python学习路线:搭好环境,跑通第一个脚本
接下来进入新的90天安排中的Python阶段,先搭环境、跑通脚本,再逐步自动整理这些实验记录。
关注「楠风测开」,把每一次调优都变成能够解释和复核的改动。