咱们做软件测试的,最离不开的一个东西就是数据库。尤其是当你需要验证一条数据有没有写对、接口测试的落库结果是否符合预期、线上问题排查要捞数据的时候,SQL就是测试手里那把最趁手的工具。之前写了这个系列的上篇,把select基础查询、where条件过滤、模糊查询、order by排序、limit分页这些常规操作都过了一遍,这篇接着往下补,讲测试日常里更常用的进阶用法:聚合分组、多表连接、子查询、增删改造数、去重、case when条件判断、视图和存储过程。每一段我都会把SQL语句和实际运行结果贴出来,方便你直接拷贝去自己环境里试。
这篇内容同样适合准备软件测试面试的同学。面试官的SQL题越来越喜欢往场景上靠,比如“查一下每个用户的下单数”“找出去重后的城市列表”“统计金额超过平均值的订单”,这些恰好是今天要讲的核心场景。建议看完之后自己动手在本地建两张表复现一遍,比死记硬背强太多。
1. 聚合分组:统计报表一样的数据验证
1.1 聚合函数怎么用才不出错
测试里最常碰到的需求就是“数数”和“算总额”。比如你测完一个下单接口,得确认库里到底插了几条订单、订单总金额对不对,或者要统计每个用户的充值总额是不是和页面展示一致。这时候SQL的聚合函数就派上用场了。
常用的聚合函数就五个:COUNT、SUM、AVG、MAX、MIN。COUNT是数行数,SUM是求和,AVG是求平均,MAX和MIN分别取最大值和最小值。它们通常配合SELECT使用,但要注意的是,聚合函数处理的是“整列数据”,而不是单行数据,所以一旦SELECT列里除了聚合函数还有其他普通字段,基本都会报错,或者在某些SQL模式下产生不可控结果。
举个具体例子,假设我们有一张员工表emp,结构如下:
| id | name | dept | salary | age |
|---|---|---|---|---|
| 1 | 张三 | 技术部 | 10000 | 26 |
| 2 | 李四 | 技术部 | 9000 | 28 |
| 3 | 王五 | 技术部 | 9500 | 25 |
| 4 | 赵六 | 测试部 | 8000 | 27 |
| 5 | 钱七 | 测试部 | 8500 | 29 |
| 6 | 孙八 | 产品部 | 12000 | 30 |
| 7 | 周九 | 产品部 | 9800 | 32 |
现在想统计公司总人数、平均薪资、最高薪资、最低薪资和薪资总和:
sql复制SELECT
COUNT(*) AS total_num,
AVG(salary) AS avg_salary,
MAX(salary) AS max_salary,
MIN(salary) AS min_salary,
SUM(salary) AS sum_salary
FROM emp;
运行结果:
| total_num | avg_salary | max_salary | min_salary | sum_salary |
|---|---|---|---|---|
| 7 | 9571.4286 | 12000 | 8000 | 67000 |
这里有个特别容易忽略的坑:如果表里某行的salary是NULL,AVG和SUM的计算结果会自动忽略NULL行,但COUNT(具体字段)也会忽略NULL。比如你想数一下“有薪资记录的员工人数”,用COUNT(salary)和COUNT()结果可能不一样,因为COUNT()会把所有行算进去,而COUNT(salary)只算salary不为NULL的行。在实际测试断言中,我一般都会先确认字段是否有NULL,再决定用哪个,不然统计结果可能和业务预期对不上。
另外还要留意SUM函数的返回类型。如果求和结果是整数,MySQL返回的是DECIMAL或INT,但如果你在Java代码里用Long去接,得提前转好类型。而AVG计算出的平均值往往是小数,格式化时要保留足够精度,避免测试断言时因为小数点差异导致误报。
1.2 GROUP BY与HAVING的边界
聚合函数单独用没啥难度,但一旦和GROUP BY组合在一起,才是测试面试题里的常客。GROUP BY的作用是把数据按一个或多个字段分组,然后对每一组分别做聚合。
比如我们要统计每个部门的平均薪资:
sql复制SELECT dept, AVG(salary) AS avg_salary
FROM emp
GROUP BY dept;
运行结果:
| dept | avg_salary |
|---|---|
| 技术部 | 9500 |
| 测试部 | 8250 |
| 产品部 | 10900 |
这个结果就相当于把员工表按部门切成了三组,然后对每一组单独求平均。测试里这个场景很常见,比如验证某报表按城市分组的统计数据是否和页面一致。
GROUP BY之后如果还想继续过滤,就必须用HAVING,不能用WHERE。WHERE是分组之前把原始数据过滤掉,而HAVING是分组之后对聚合结果做过滤。这两者的区别我每次面试都爱问,也是新手最容易写错的地方。
比如我们想找出平均薪资大于9000的部门:
sql复制SELECT dept, AVG(salary) AS avg_salary
FROM emp
GROUP BY dept
HAVING AVG(salary) > 9000;
运行结果:
| dept | avg_salary |
|---|---|
| 技术部 | 9500 |
| 产品部 | 10900 |
注意这里的过滤条件是聚合结果AVG(salary) > 9000,所以它必须放在HAVING后面。如果你把HAVING误写成WHERE,MySQL会直接报错。而如果你只是想在分组前排除某些部门数据,比如只看技术部和测试部,那就可以在WHERE里先过滤,效率更高。
还有一个容易被坑的点:在MySQL的ONLY_FULL_GROUP_BY模式下,SELECT后面的非聚合字段必须出现在GROUP BY中。比如下面这句SQL在某些MySQL版本中会报错:
sql复制SELECT name, dept, AVG(salary) FROM emp GROUP BY dept;
因为name没有包含在GROUP BY里,系统不知道一个部门里到底该取哪个name,逻辑上就不成立。遇到这种报错,要么把name也加进GROUP BY,要么改成聚合函数处理,或者干脆别查这个字段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多表连接与子查询:从单表思维升级到关联分析
2.1 JOIN的三种连接到底啥区别
测试做多了你会发现,业务数据很少只存在一张表里。订单表记录订单信息,用户表记录用户信息,你要查“每个用户的订单明细”,就必须把两张表关联起来。JOIN就是干这个的。
JOIN有三种最基础的连接方式:INNER JOIN、LEFT JOIN、RIGHT JOIN。INNER JOIN只保留两表匹配上的记录,没匹配上的数据不显示;LEFT JOIN以左表为主,左表所有记录都保留,右表没匹配上的地方填NULL;RIGHT JOIN则反过来,以右表为主。
举个例子,用户表users:
| user_id | user_name | city |
|---|---|---|
| 101 | 张小明 | 北京 |
| 102 | 李小红 | 上海 |
| 103 | 王大力 | 广州 |
| 104 | 赵小敏 | 深圳 |
| 105 | 陈小飞 | 北京 |
| 106 | 刘大壮 | 成都 |
订单表orders,假设每个用户最多有两笔订单:
| order_id | user_id | amount | status |
|---|---|---|---|
| 1 | 101 | 299 | 已支付 |
| 2 | 102 | 899 | 待付款 |
| 3 | 103 | 150 | 已支付 |
| 4 | 101 | 688 | 已退款 |
| 5 | 104 | 99 | 待付款 |
| 6 | 105 | 1200 | 已支付 |
| 7 | 102 | 540 | 已支付 |
用INNER JOIN查每个用户下的订单:
sql复制SELECT u.user_name, o.order_id, o.amount
FROM users u
INNER JOIN orders o ON u.user_id = o.user_id
ORDER BY u.user_id;
运行结果:
| user_name | order_id | amount |
|---|---|---|
| 张小明 | 1 | 299 |
| 张小明 | 4 | 688 |
| 李小红 | 2 | 899 |
| 李小红 | 7 | 540 |
| 王大力 | 3 | 150 |
| 赵小敏 | 5 | 99 |
| 陈小飞 | 6 | 1200 |
看到没,INNER JOIN的结果只保留了有订单的用户,而用户“刘大壮”因为一笔订单都没有,直接没出现在结果里。这在测试里有时候不好用,比如你想查“所有用户以及他们有没有下单”,没下单的也要展示出来,那就要用LEFT JOIN:
sql复制SELECT u.user_name, o.order_id, o.amount
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
ORDER BY u.user_id;
运行结果:
| user_name | order_id | amount |
|---|---|---|
| 张小明 | 1 | 299 |
| 张小明 | 4 | 688 |
| 李小红 | 2 | 899 |
| 李小红 | 7 | 540 |
| 王大力 | 3 | 150 |
| 赵小敏 | 5 | 99 |
| 陈小飞 | 6 | 1200 |
| 刘大壮 | NULL | NULL |
这样“刘大壮”也出现了,order_id和amount都是NULL,说明他没下过单。测试中这种“左表全保留”的查询非常适合做数据完整性校验,比如验证所有用户是否都在报表中展示。
JOIN使用时我一般会提前确认关联字段有没有索引。两张表数据量一大,没索引的JOIN会直接把数据库跑挂。另外还要小心“一对多”关联导致的数据重复。比如一个用户本来就该有多个订单,结果JOIN完后行数翻倍,那就是关联条件写错了,出现了笛卡尔积。测试断言时一定要先数一下两个表的关联字段是否有重复值,再决定结果的期望行数。
2.2 子查询的三种常见位置
子查询就是SQL里面套SQL,把一条查询的结果作为另一条查询的输入。它在测试里的典型场景是“求超过平均值的记录”“查某张表的排名第几”这类问题。
最常见的子查询放在WHERE后面,比如查订单金额大于所有订单平均金额的订单:
sql复制SELECT order_id, amount
FROM orders
WHERE amount > (SELECT AVG(amount) FROM orders);
运行结果:
| order_id | amount |
|---|---|
| 2 | 899 |
| 4 | 688 |
| 6 | 1200 |
这个SQL的思路是:先用子查询算出平均值,把平均值当做一个标量,再拿每一行的amount去跟这个值比较。如果子查询返回多行,就报错,这种标量子查询只能返回一行一列。
还有一种常见用法是把子查询放在FROM后面,当临时表用。比如想查每个用户订单数和总金额,但只显示订单数大于等于2的用户:
sql复制SELECT t.user_id, t.cnt, t.total
FROM (
SELECT user_id, COUNT(*) AS cnt, SUM(amount) AS total
FROM orders
GROUP BY user_id
) t
WHERE t.cnt >= 2;
运行结果:
| user_id | cnt | total |
|---|---|---|
| 101 | 2 | 987 |
| 102 | 2 | 1439 |
这里子查询先按user_id分组统计,得到每个用户的订单数和总金额,然后再从这段结果里过滤。从逻辑上看,FROM后面的子查询就像是临时生成了一张表,你可以直接把它当成一张表来操作。这种写法在测试中特别好用,尤其是当你需要“先汇总再过滤”的时候。
SELECT后面的子查询也很有用,它会在每一行上执行一次,用来补充额外字段。比如查用户姓名和每个用户的订单数:
sql复制SELECT u.user_name,
(SELECT COUNT(*) FROM orders o WHERE o.user_id = u.user_id) AS order_cnt
FROM users u;
运行结果:
| user_name | order_cnt |
|---|---|
| 张小明 | 2 |
| 李小红 | 2 |
| 王大力 | 1 |
| 赵小敏 | 1 |
| 陈小飞 | 1 |
| 刘大壮 | 0 |
这种相关子查询在数据量大时会比较慢,因为每查一个用户就要执行一次内部的COUNT,所以只适合数据量小的场景。在测试造数后做临时分析可以这么用,但如果是线上大表查询,还是建议用JOIN替代。
3. 数据变更操作:测试数据的造数、修改与清理
3.1 INSERT/UPDATE/DELETE的常规姿势
前面讲的多是查询,但测试人员日常同样离不开增删改。造测试数据、清理脏数据、临时修改某个字段的状态来复现bug,这些都是高频操作。这也是面试里常被问到的一个知识点:能不能写出正确的INSERT、UPDATE、DELETE。
INSERT的常规写法是:
sql复制INSERT INTO orders(user_id, amount, status, create_time)
VALUES (103, 329, '已支付', '2025-01-15');
运行结果:
text复制Affected rows: 1
如果字段很多,也可以一次插入多条,逗号分隔,效率更高:
sql复制INSERT INTO orders(user_id, amount, status, create_time) VALUES
(101, 199, '待付款', '2025-01-16'),
(102, 299, '待付款', '2025-01-17');
UPDATE要注意的点就多了。我最常提醒新人的一句话是:写UPDATE之前先写SELECT,确认WHERE条件到底会影响多少行。千万不要直接拿UPDATE去改全表数据。比如我只想给测试部的员工加500块工资:
sql复制UPDATE emp SET salary = salary + 500
WHERE dept = '测试部';
运行结果:
text复制Affected rows: 2
这里的salary = salary + 500就是一个字段算术运算,MySQL支持这种直接在SET里对字段做加减乘除的写法。实际测试中,要调整某个状态字段、修改订单金额、给特定用户增加积分,全都可以用这种方式。但你要是忘了WHERE条件,那后果就是全表所有人的工资都加了500。这种操作在测试环境还好,一旦在预发或生产环境手滑,就是事故。
DELETE的写法同样要带WHERE:
sql复制DELETE FROM orders WHERE order_id = 999;
如果这句SQL影响行数是0,那大概率是数据本来就不存在。所以我在执行DELETE之前,会先跑一条SELECT看数据是否存在:
sql复制SELECT * FROM orders WHERE order_id = 999;
确认存在后再删,稳得很。
另外补充一个细节:UPDATE和DELETE在MySQL里如果开启了safe update模式,直接执行不带主键条件的UPDATE/DELETE会被拦截,报错“You are using safe update mode”。Workbench默认在部分版本里会开启这个安全限制。遇到这种情况,要么在WHERE里加主键条件,要么在Preferences里临时关掉安全模式。但从团队规范角度,我更建议保留这个限制,逼自己养成带条件操作的好习惯。
3.2 事务与TRUNCATE:别让测试数据失控
测试环境的数据往往要反复使用。比如你在页面上操作改了一笔订单的状态,用例跑完后希望把数据恢复到操作前,这时候事务就是最好的保护伞。
MySQL里可以用BEGIN开启一个事务,之后的所有INSERT、UPDATE、DELETE都不会立即生效,只有COMMIT才会真正提交。如果中途发现问题,可以ROLLBACK回滚到事务开始前的状态。
举个测试场景:我这个用例要把用户102的订单金额从899改成1000,但只是想验证改完之后页面上展示是否正确,验证完就恢复。流程可以这样:
sql复制BEGIN;
UPDATE orders SET amount = 1000 WHERE order_id = 2;
SELECT * FROM orders WHERE order_id = 2;
ROLLBACK;
运行结果中,SELECT查出来的金额是1000,但ROLLBACK之后你再查一次,金额还是899。这样就不需要手动写一条反向UPDATE去恢复数据,干净利落。
这个技巧在接口自动化测试中特别有价值。前置准备数据可以放在事务里,步骤跑完后ROLLBACK,生产环境也能安全地跑“只读型”验证脚本。
TRUNCATE和DELETE的区别也值得一提。TRUNCATE TABLE是清空整张表的所有数据,而且执行速度快、不走事务、不能回滚,同时会把自增主键重置回1。DELETE FROM可以带WHERE条件,逐行删除,走事务、可回滚,但表结构还在,自增主键不会重置。测试中最常见的用法是:造了海量压测数据,用TRUNCATE一次性清空;而如果是删掉几条脏数据,用DELETE就好。
我实际遇到过这么个场景:造了一大批订单数据做性能测试,测完后想快速清空,但又要保留表结构。第一次我用DELETE FROM orders跑了大半天都没结束,后来换成TRUNCATE,秒清。从那以后我就记住了:清空整表用TRUNCATE,条件删除用DELETE。
4. 条件判断、去重与排序:写查询时的高频技巧
4.1 DISTINCT去重:统计真实用户数
测试里经常要回答这类问题:“今天到底有多少个不同用户下了单?”如果不加DISTINCT,统计出来的数会把同一个用户的多笔订单每条都算一遍,明显不对。这时候用DISTINCT去重就是最简单的解法。
比如我要统计users表里有哪些城市:
sql复制SELECT DISTINCT city FROM users;
运行结果:
| city |
|---|
| 北京 |
| 上海 |
| 广州 |
| 深圳 |
| 成都 |
如果只是想知道城市数量呢:
sql复制SELECT COUNT(DISTINCT city) FROM users;
运行结果:
text复制5
DISTINCT还可以作用在多个列上,它的规则是“几个字段组合起来相同才算重复”。比如:
sql复制SELECT DISTINCT user_id, status FROM orders;
如果两个订单的user_id相同、status也相同,那它们只会出现一次;如果status不同,就算user_id相同,也会分别出现。
有个细节需要注意:DISTINCT会忽略NULL值,也就是说如果有多个NULL,去重后只保留一个。如果你统计一个字段,而该字段有很多NULL,COUNT(DISTINCT 字段)的结果可能会比你预期的小。在很多报表断言场景里,这个坑会让测试结果出现“莫名少了一条”的情况。
通常DISTINCT和GROUP BY都可以实现去重,GROUP BY更灵活,还能配合聚合函数做统计。但单列去重时DISTINCT更直观,代码也更简洁。
4.2 CASE WHEN实现条件翻译
CASE WHEN在SQL里就是写if-else逻辑。测试人员经常用它把数据库里那些难懂的数字状态翻译成人话,或者直接在SQL里做分档判断,省得把数据拉出来再用代码处理一遍。
基本语法是这样的:
sql复制SELECT order_id, amount,
CASE
WHEN amount >= 1000 THEN '大额'
WHEN amount >= 500 THEN '中等'
ELSE '小额'
END AS amount_level
FROM orders;
运行结果:
| order_id | amount | amount_level |
|---|---|---|
| 1 | 299 | 小额 |
| 2 | 899 | 中等 |
| 3 | 150 | 小额 |
| 4 | 688 | 中等 |
| 5 | 99 | 小额 |
| 6 | 1200 | 大额 |
| 7 | 540 | 中等 |
这实际上就是在查询结果里新增了一列,根据每行的amount值做判断。这种写法在测试断言里非常实用。比如开发告诉你订单状态字段的含义:1是待付款,2是已支付,3是已退款,那你直接就能把状态翻译出来看:
sql复制SELECT order_id,
CASE status
WHEN '待付款' THEN '待付款'
WHEN '已支付' THEN '已支付'
ELSE '未知'
END AS status_text
FROM orders;
注意这里的CASE后面跟了具体字段,就直接等于比较,语法上是一个简化写法。
CASE WHEN还可以配合聚合函数做“按条件统计”。比如统计不同订单状态的数量:
sql复制SELECT
SUM(CASE WHEN status = '已支付' THEN 1 ELSE 0 END) AS paid_cnt,
SUM(CASE WHEN status = '待付款' THEN 1 ELSE 0 END) AS unpaid_cnt,
SUM(CASE WHEN status = '已退款' THEN 1 ELSE 0 END) AS refund_cnt
FROM orders;
运行结果:
| paid_cnt | unpaid_cnt | refund_cnt |
|---|---|---|
| 3 | 2 | 1 |
这种写法就是“用SUM来数数”,每一行满足条件就加1,不满足就加0。在测试里验证报表里的分类计数时,这个技巧几乎是必用的。
4.3 ORDER BY与LIMIT的排序陷阱
排序和分页看着简单,但实际测试中踩过的坑也不少。ORDER BY默认按升序排,DESC是降序,多个排序字段时用逗号隔开,先按第一个字段排,相同再按第二个字段排。
比如查订单金额最高的三笔订单:
sql复制SELECT order_id, amount
FROM orders
ORDER BY amount DESC
LIMIT 3;
运行结果:
| order_id | amount |
|---|---|
| 6 | 1200 |
| 2 | 899 |
| 4 | 688 |
LIMIT 3表示只取前三行。如果要做分页,比如每页2条取第2页,就是跳过前面2条,取第3、4条:
sql复制SELECT order_id, amount
FROM orders
ORDER BY order_id
LIMIT 2 OFFSET 2;
运行结果:
| order_id | amount |
|---|---|
| 3 | 150 |
| 4 | 688 |
LIMIT 2 OFFSET 2也可以写成LIMIT 2, 2,前面的数字是偏移量,后面的数字是行数,两种写法等价,但前者更易读。分页条数计算就是:当前页页码减1,乘以每页条数,就是偏移量。
排序最容易被忽略的问题是:如果排序字段有重复值,分页查询的结果可能不稳定。比如按amount排序,100这条数据可能同时出现在第1页和第2页,因为如果数据库按不确定的顺序返回相同amount的行,LIMIT切出来就不稳定。解决办法是给ORDER BY加一个唯一字段作为第二排序,比如:
sql复制ORDER BY amount DESC, order_id DESC
这样即使金额相同,order_id也能保证顺序固定。
测试中验证列表页排序时,我一直建议先查一遍数据库里所有数据,手动按规则排好序,再跟页面展示逐条比对,别只看前几条没问题就通过。分页边界值也要重点测,第一页、最后一页、超过最大页这些情况,SQL的LIMIT行为必须清楚。
5. 视图与存储过程:测试环境提效利器
5.1 视图:把复杂查询一键封装
视图就是一个虚表,它不存数据,只是把一条查好的SQL保存起来,方便以后反复使用。它的好处是:测试人员在日常工作中经常要查同一类数据,比如“每个用户的订单数和总金额”,每次都写一遍GROUP BY很烦,干脆存成视图,以后直接SELECT就行。
创建视图的SQL:
sql复制CREATE VIEW v_order_summary AS
SELECT user_id, COUNT(*) AS order_cnt, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id;
创建成功后再查询:
sql复制SELECT * FROM v_order_summary WHERE order_cnt >= 2;
运行结果:
| user_id | order_cnt | total_amount |
|---|---|---|
| 101 | 2 | 987 |
| 102 | 2 | 1439 |
你看,这个查询就变得很清爽。视图在测试报告、数据抽查、回归验证里都很好用,你甚至可以把一段复杂的多表关联查询定义为视图,后续排查问题时只要查视图就行,不用每次回忆那段长SQL。
不过视图也不是完全没有限制。它不支持传参,不像函数那样能带输入条件。如果你想“查某个用户的汇总数据”,还得在视图外面加WHERE过滤:
sql复制SELECT * FROM v_order_summary WHERE user_id = 101;
另外,如果基础表结构变了,比如用户表删了user_id字段,视图可能直接失效或查询报错。所以在表结构频繁变动的测试环境中,视图要定期维护。
5.2 存储过程:批量造数神器
存储过程是数据库里的一段可执行程序,可以写循环、判断、变量。测试里最大的价值是批量造数。比如你要做并发压测,需要往orders表里插入10万条订单,手工写INSERT显然不现实,用存储过程就能一条命令搞定。
一个简单的批量插入存储过程示例:
sql复制DELIMITER //
CREATE PROCEDURE batch_insert_orders(IN p_num INT)
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= p_num DO
INSERT INTO orders(user_id, amount, status)
VALUES (106, 88, '待付款');
SET i = i + 1;
END WHILE;
END //
DELIMITER ;
注意DELIMITER的作用是把结束符临时改成//,否则MySQL默认以分号结束,存储过程内部那么多分号会被拆散执行,直接语法报错。执行完存储过程定义后,再把结束符改回分号。
调用存储过程:
sql复制CALL batch_insert_orders(5);
运行结果:
text复制Affected rows: 5
验证一下到底插入了多少条:
sql复制SELECT COUNT(*) FROM orders WHERE user_id = 106 AND amount = 88;
运行结果:
text复制5
这就是批量造数的直观感受。实际做性能测试时,我一般会把存储过程参数改成随机数,或者结合日期字段生成各种不同形态的数据,比如不同状态、不同金额范围、不同时间分布,让测试数据更接近真实业务。
造完数后,清理工作也要及时跟上。如果表本身数据量巨大,不要用DELETE一条条删,直接TRUNCATE重置更高效。但如果只是删掉刚才插入的那批特定数据,用DELETE加WHERE条件就好。
6. 测试现场高频问题与面试经验
6.1 排查现场:int+5、字符串带引号、Workbench小技巧
做测试时总会碰到一些零碎但特别实际的SQL问题。这里挑几个高频出现的说说。
第一个是数值字段的算术运算,比如“mysql中int+5”这种说法。实际场景就是你把某个整数字段在UPDATE时做加减,像前面提到的salary = salary + 500。但如果你把VARCHAR类型的字段拿来加数字,MySQL会做隐式类型转换,可能把非数字字符串转成0,导致计算结果完全不对。比如用户的积分字段如果建成了VARCHAR,执行SET points = points + 5,某行points是“abc”,那结果大概率变成5或者报错。所以测试中发现数据越算越不对,第一步就去查一下字段类型,别让数据库默默做类型转换。
第二个是往数据库里保存带引号的字符串。比如你要插入一条用户备注“他说:'今天天气不错'”,直接写单引号会冲突,因为SQL里的字符串本来就以单引号包裹。处理方式有两种:用两个单引号转义,或者用反斜杠转义:
sql复制INSERT INTO comments(content) VALUES ('他说:''今天天气不错''');
INSERT INTO comments(content) VALUES ('他说:\'今天天气不错\'');
两种写法在MySQL里都有效。测试造数时如果涉及复杂文本,最好先在一行上试通再批量执行,不然整条INSERT会因为一个引号问题而报语法错误,排查起来还挺费神。
第三个是MySQL Workbench的使用小技巧,测试同学常用它来跑查询。执行SQL时,光标放在某条语句上按Ctrl+Enter,就只执行当前这条;按Ctrl+Shift+Enter会执行整个脚本。查询结果网格上方有导出按钮,可以把结果导出成CSV或JSON,方便做线下数据比对。如果遇到safe update mode拦截UPDATE/DELETE,给WHERE加上主键条件就能正常执行,不建议直接去设置里关掉安全模式。
6.2 软件测试面试里SQL最高频的几道题
面试题我大概总结几道,都是这段时间反复出现的。
第一道,按部门统计人数,找出人数大于2的部门:
sql复制SELECT dept, COUNT(*) AS cnt
FROM emp
GROUP BY dept
HAVING COUNT(*) > 2;
运行结果:
| dept | cnt |
|---|---|
| 技术部 | 3 |
这里考察的就是GROUP BY和HAVING的结合使用。
第二道,查订单金额第二高的订单:
sql复制SELECT * FROM orders
ORDER BY amount DESC
LIMIT 1, 1;
运行结果:
| order_id | user_id | amount | status |
|---|---|---|---|
| 2 | 102 | 899 | 待付款 |
如果要查第N高,把LIMIT后面的偏移量改成N-1即可。但注意这个方法在有相同金额时会跳过重复值,如果公司要求“按金额排名,金额相同算并列”,那就要先去重金额再排序,或者用窗口函数DENSE_RANK。MySQL 8.0及以上版本直接用窗口函数更合适。
第三道,删掉重复数据只保留最小ID。这种题在业务系统里非常经典,通常是某张表因为历史原因产生了重复数据,需要清理。假设有张表t,id自增,name有重复,删掉重复name只保留id最小的那条:
sql复制DELETE FROM t
WHERE id NOT IN (
SELECT * FROM (
SELECT MIN(id) FROM t GROUP BY name
) tmp
);
注意这里为什么要套一层子查询?因为MySQL不允许在删除同一张表时直接基于该表的子查询操作,会报错“You can't specify target table 't' for update in FROM clause”。包一层临时表别名tmp就能绕过这个限制。这个细节面试时能讲出来,比背答案更有说服力。
还有一道CASE WHEN相关的题:统计已支付、待付款、已退款三个状态的订单数量。前面已经写过,思路就是SUM(CASE WHEN ... THEN 1 ELSE 0 END)的写法,面试官主要考察你能否用SQL做条件聚合。
面试时SQL题一般要求手写,没法真的跑库,所以平时一定要多练,把语感练出来。最怕的就是脑子明白、手写不出来,单词拼错、标点用错、关键字顺序写反,这些都是真实的失分点。我的建议是,把这篇里的每个示例都自己在本地执行一遍,跑通后换个表再写一遍,比如把员工表换成商品表,把订单表换成库存表,稍微变一变,你才能真正掌握而不是记住答案。
最后再分享一个小经验:测试人员写SQL,大多数时候不是为了炫技,而是为了快速、准确地拿到数据去完成验证。所以当你发现一条SQL写得很复杂、很绕时,先停下来想想是不是可以拆成两步,或者用表关联换一种思路。能简单就别复杂,能用索引就别全表扫描,这是测试环境里写SQL最该记住的两个原则。
