or 导致的全表扫描

explain select id from tradelog where tradeid = '12' or operator = 10 limit 10;
+------+---------------+------+---------+------+--------+----------+-------------+
| type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+------+---------------+------+---------+------+--------+----------+-------------+
| ALL  | tradeid       | NULL | NULL    | NULL | 389107 |    10.00 | Using where |
+------+---------------+------+---------+------+--------+----------+-------------+

查询条件中有 or 时,mysql 会分别查询每一个条件,然后再把结果合并在一起。

先对 tradeid = ‘12’ 进行判断,发现可以使用索引,再对 operator = 10 进行判断,发现不能使用索引,只好全表扫描,即然都全表扫描了,顺带也就可以判断当前记录的 tradeid 是否等于 12,就不需要在单独再搜索 tradeid 索引树了。

所以当 or 条件中出现没有索引的字段会导致使用全表扫描。如果出现的字段都有索引,mysql会尽量使用每个列的索引

explain select id from tradelog where tradeid = '12' or id =10  limit 10;
+----+-------------+----------+-------------+-----------------+-----------------+
| id | select_type | table    | type        | possible_keys   | key             |
+----+-------------+----------+-------------+-----------------+-----------------+
|  1 | SIMPLE      | tradelog | index_merge | PRIMARY,tradeid | tradeid,PRIMARY |
+----+-------------+----------+-------------+-----------------+-----------------+

+---------+------+------+----------+-------------------------------------------+
| key_len | ref  | rows | filtered | Extra                                     |
+---------+------+------+----------+-------------------------------------------+
| 131,4   | NULL |    2 |   100.00 | Using union(tradeid,PRIMARY); Using where |
+---------+------+------+----------+-------------------------------------------+

虽然条件中也有 or ,但 tradeid 和 id 都有索引,mysql 分别单独对这两个索引扫描,然后把找到的结果再合并到一起。执行计划显示 type:index_mergekey:tradeid,PRIMARYExtra:Using union

同样的条件,把 or 换成 and 时是可以走索引的,因为找的是要满足所有条件的记录,那么就可以在最小的索引树先找到一条满足条件的记录,再检查这条记录是否还满足其它条件

explain select id from tradelog where tradeid = '12' and  operator = 10 limit 10;
+------+---------------+---------+---------+-------+------+----------+-------------+
| type | possible_keys | key     | key_len | ref   | rows | filtered | Extra       |
+------+---------------+---------+---------+-------+------+----------+-------------+
| ref  | tradeid       | tradeid | 131     | const |    1 |    10.00 | Using where |
+------+---------------+---------+---------+-------+------+----------+-------------+

key:tradeid 显示使用了 tradeid 索引。通过索引树先找到一条 tradeid = ‘12’的记录,再回表查询 operator 是否等于 10。

对于一般长度的字符串,用整个字符串直接作为索引即可,但对于比较长的字符串,比如email,身份证号如果直接作为索引,会占用较大的磁盘空间

前缀索引

可以为较长的字符串设置前缀索引,缩短索引字段的长度,减少占用磁盘的空间

alter table users add index index1(email);

alter table users add index index2(email(6));

索引 index1 包含了整个字符串,而 index2 只包含了前6个字符串,比 index1 占用更少的空间。那么如何定义前缀长度呢?如果太短,索引的区分度就会不高,增加额外的扫描次数,查询效率下降,太长又不能节省空间。可以用下面的方法进行判断,选择最接近1的,同时长度最短的来作为前缀。

前缀索引的长度

首先计算出这个列上有多少个不同的值(计为total)

select count(distinct email) as total from users;

然后取不同长度的前缀,计算有多少个不同的值,分别除以 total,选择最接近1的且前缀较短的

select 
  count(distinct left(email,4)/ total as L4,
  count(distinct left(email,5)/ total as L5,
  count(distinct left(email,6)/ total as L6,
  count(distinct left(email,7)/ total as L7,
from users;

假设结果为: L4 40%,L5 90%, L6 96% ,L7 96%。而你只接受大于95%的区分度,L6/L7等符合,那么选择前缀为6的最为适合。

增加扫描次数

假设users表有如下记录,分别建立索引 index1(email),index2(email,5)

id,  email,              ,username, adders
id1, xiaoming123@xyz.com ,省略
id2, xiaoming456@xyz.com ,省略
id3, xiaoming789@xyz.com ,省略
id4, xiaomingABC@xyz.com ,省略
id5, zhanghao123@xyz.com    ,省略
id6, zhanghao456@xyz.com    ,省略

索引 index1的页节点数据

(xiaoming123@xyz.com,id1),(xiaoming456@xyz.com,id2),(xiaoming789@xyz.com,id3),(xiaomingABC@xyz.com,id4),(zhang123@xyz.com,id5),(zhang456@xyz.com,id6)

索引 index2的页节点数据

(xiaom,id1),(xiaom,id2),(xiaom,id3),(xiaom,id4),(zhang,id5),(zhang,id6)

可以看到,前缀索引占用更少的数据空间

索引查询过程

现查询email为xiaoming456@xyz.com'的用户信息

select * from users where email='xiaoming456@xyz.com';

index1索引查询过程

  1. 搜索index1索引树,找到等于 xiaoming456@xyz.com 的记录,取出主键的值 id2
  2. 根据主键值 id2 回表查询,把记录放入返回结果集中
  3. 再接着向右移动,发现记录不符,返回,查询结束,把结果集返回客户端

index2索引查询过程

  1. 搜索index2索引树,找到等于 xiaom 的记录(取前5位进行查询),取出主键的值 id1
  2. 根据主键值 id1 回表查询,发现email值不等于xiaoming456@xyz.com
  3. 再接着向右移动,发现等于xiaom,取出主键的值 id2
  4. 根据主键值 id2 回表查询,发现email值等于xiaoming456@xyz.com,放入返回结果集
  5. 重复以上过程,直到遇到zhang时,查询结束

使用前缀索引 index2 一共需要查找4次,增加了扫描的次数。但如果把前缀索引设置为email(9),也只需要查找一次,因为等于xiaoming4只有一条,找到后查询也结束了。

通过选择适当的前缀索引的长度,即节省空间,查询成本也不会太高

覆盖索引无效

同样是查询select id, email from users where email='xiaoming456@xyz.com';

  • 如果在 email 上建立普通索引,在找到记录后,由于索引包含了id的值,不用回表,直接返回结果即可
  • 如果在 email 上建立前缀索引,在找到记录后,由于索引信息不完整,即使包含了id的值,也需要回表查询是否与email的完整值相匹配

这样就导致了在前缀索引上,无法使用覆盖索引对查询性能的优化

Hash字段

比如对身份证进行查询,其长度为18位,直接建立索引会占用较多的空间,如果使用前缀索引只取前几位但相同的概率很大,取太长又不能节省空间,此时可以添加一个字段,用来存放身份证的 hash 值,并为这个hash字段建立索引

alter table t add idcard_hash int unsigned, add index(idcard_hash);

索引的长度变成了 4 个字节,就算算上hash字段本身占用的空间,也要比原来小了很多。由于hash值存在冲突,在查询时还要加上身份证字段,确保精确匹配以取到正确的记录,这样就可以即节省空间,又高效的查询了

select * from t where idcard_hash=hash_algorithm('input_id_card_string') and id_card='input_id_card_string'

hash字段的缺点

  • 不能进行范围查询
  • 使用hash函数会消耗CPU

这里其实还有一个思路,把身份证倒序存储,这样就不用额外再建立字段,同时可以使用前缀索引,但至少要用前8位来建立前缀索引,即8字节,其占用空间和使用hash字段就差不多了,因为是前缀索引,必定要回表查询,增加了扫描次数,查询性能也有没hash字段稳定,况且8位应该是不够的,占用空间肯定要比使用hash字段要大。

小结

  1. 直接创建完整索引,这样可能比较占用空间
  2. 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引
  3. 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题
  4. 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描

Auto_increment

表结构如下,且假设当前系统中只有一个连接

CREATE TABLE `t` (
  `id` int NOT NULL AUTO_INCREMENT,
  `a` int DEFAULT NULL,
  `b` int DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a` (`a`),
  KEY `b` (`b`)
) ENGINE=InnoDB;

建表时把主键id设置为id int(11) NOT NULL AUTO_INCREMEN,在插入数据时,如果不指定id,系统会为id自动设置值 ,并把Auto_increment的值,作为下一条记录id的值

insert into t(a,b) values(1,1);
select * from t;
+--------+------+------+
| id     | a    | b    |
+--------+------+------+
| 200001 |    1 |    1 |
+--------+------+------+

show table status like 't'\G
*************************** 1. row ***************************
   Index_length: 32768
      Data_free: 10485760
 Auto_increment: 200002

再插入时如果没有指定id,则使用200002作为其值,Auto_increment的值更新为200003

insert into t(a,b) values(1,1);
+--------+------+------+
| id     | a    | b    |
+--------+------+------+
| 200001 |    1 |    1 |
| 200002 |    1 |    1 |
+--------+------+------+

show table status like 't'\G
*************************** 1. row ***************************
   Index_length: 32768
      Data_free: 10485760
 Auto_increment: 200003

如果把id=200001记录删除,然后指定id的值为200001,插入这样一条记录,那么Auto_increment的值不增长,依然为200003。同时可以看到,由于默认是主键索引排序,即使id=200001被删除后,再次插入,也排在了200002的前面

delete from t where id = 200001;
insert into t(id,a,b) values(200001,200001,200001);
select * from t;
+--------+--------+--------+
| id     | a      | b      |
+--------+--------+--------+
| 200001 | 200001 | 200001 |
| 200002 |      1 |      1 |
+--------+--------+--------+

show table status like 't'\G
*************************** 1. row ***************************
   Index_length: 32768
      Data_free: 10485760
 Auto_increment: 200003

再插入一条记录也可以验证,新记录的id值为200003。

insert into t(a,b) values(1,1);
select * from t;
+--------+--------+--------+
| id     | a      | b      |
+--------+--------+--------+
| 200001 | 200001 | 200001 |
| 200002 |      1 |      1 |
| 200003 |      1 |      1 |
+--------+--------+--------+

还可以手动插入一个不连续的id:200006

insert into t(id,a,b) values(200006,1,1);

此时 Auto_increment的值为 200007,如果插入id比它小的值,则 Auto_incrementr的值不增长,依然为 200007

insert into t(id,a,b) values(200005,1,1);

select * from t;
+--------+--------+--------+
| id     | a      | b      |
+--------+--------+--------+
| 200001 | 200001 | 200001 |
| 200002 |      1 |      1 |
| 200003 |      1 |      1 |
| 200005 |      1 |      1 |
| 200006 |      1 |      1 |
+--------+--------+--------+

show table status like 't'\G
*************************** 1. row ***************************
   Index_length: 32768
      Data_free: 10485760
 Auto_increment: 200007

在以上操作中,如果通过show table status like查看Auto_increment的值没有变化,运行analyze table t手动更新表的统计信息后再查看

更新无索引的列,会导致全表被锁,其它线程无法更新表中的数据,如果加入了limit N关键字,可以减少被锁定的行,在一定程度上可以提高并发

表结构及数据如下

create table `t` (
  `id` int not null primary key,
  `name` varchar(32) default null,
  `age` int default null,
  `ismale` tinyint default null
) engine=innodb;

insert into t values(1,'d001',30,1), (3,'d003',30,1);
insert into t values(5,'d005',30,1), (7,'d007',30,1);
insert into t values(9,'d009',30,1), (11,'d011',30,1);

select * from t;
+----+------+------+--------+
| id | name | age  | ismale |
+----+------+------+--------+
|  1 | d001 |   30 |      1 |
|  3 | d003 |   30 |      1 |
|  5 | d005 |   30 |      1 |
|  7 | d007 |   30 |      1 |
|  9 | d009 |   30 |      1 |
| 11 | d011 |   30 |      1 |
+----+------+------+--------+

不加入limit锁全表

时刻 Session A Session B
T1 begin;
update t set age=31 where name=’d003’;
T2 Begin;
update t set age=31 where name=’d005’;(blocked)
update t set age=31 where id=5;(blocked)
insert into t values(2,’d002’,30,1);(blocked)
insert into t values(12,’d012’,30,1);(blocked)

SessionA的加锁范围

主键索引id锁的范围是[infimum,supremum]

由于name列没有索引,where name='d003'查询时走主键索引,全表扫描。先找到id=1的行,加next-key锁,发现name的值不匹配,继续向右查找,给id=3的行上锁,name匹配,返回结果给server层,然后继续向右查找直到最后一行id=11,查找过程中涉及到的行都被加了next-key锁(id=5,7,9,11的行),由于事务没有提交,这些行锁没有被释放。

SessionB被阻塞在哪里

更新条件 where name='d005',由于name列没有索引,查询时也要走主键索引。先找到id=1的行,加next-key锁,由于SessionA已经加了next-key锁,SessionB只能等待,它在此时发生了阻塞

update t set age=31 where name='d005'
# 被SessionA上的 id=1的行的 next-key锁阻塞
RECORD LOCKS space id 5 page no 4 n bits 80 index PRIMARY of table `t`
trx id 4366 lock_mode X waiting
 0: len 4; hex 80000001; asc
 1: len 6; hex 000000000d98; asc
 2: len 7; hex 81000000a70110; asc
 3: len 4; hex 64303031; asc d001
 4: len 4; hex 8000001e; asc
 5: len 1; hex 81; asc

通过Id=5更新也会被SessinA的next-key锁阻塞,虽然SessionB加要的是行锁,这也从则面说明了next-key是由行锁间隙锁组成

update t set age=31 where id=5
# 被id=5的记录上行锁阻塞
RECORD LOCKS space id 5 page no 4 n bits 80 index PRIMARY of table `t`
trx id 4367 lock_mode X locks rec but not gap waiting
 0: len 4; hex 80000005; asc
 1: len 6; hex 000000000d9e; asc
 2: len 7; hex 82000000a80110; asc
 3: len 4; hex 64303034; asc d004
 4: len 4; hex 8000001e; asc
 5: len 1; hex 81; asc

插入数据也被阻塞,因为插入Id的值都落在了间隙锁[infimum,supremum]内,无法插入任何的值。

insert into t values(2,'d002',30,1);
# 插入id=2的值,被间隙锁(1,3)阻塞
RECORD LOCKS space id 5 page no 4 n bits 80 index PRIMARY of table `t`
trx id 4365 lock_mode X locks gap before rec insert intention waiting
 0: len 4; hex 80000003; asc
 1: len 6; hex 000000001106; asc
 2: len 7; hex 01000001120488; asc
 3: len 4; hex 64303033; asc d003
 4: len 4; hex 8000001f; asc
 5: len 1; hex 81; asc

加入limit减少被锁的行

时刻 Session A Session B
T1 begin;
T2 update t set age=31 where name=’d003’ limit 1;
T3 begin;
T4 update t set age=31 where name=’d005’;(blocked)
insert into t values(2,’d002’,30,1);(blocked)
T5 insert into t values(4,’d004’,30,1);(Query Ok)

SessionA加锁的范围是怎么样的

主键索引id锁的范围是[infimum,3]

由于name列没有索引,查询时走主键索引,全表扫描。先找到id=1的行,加next-key锁,发现name的值不匹配,继续向右查找,给id=3的行上锁,name匹配,返回结果给server层,由于使用了limit 1,满足条件,不再向后查找,查询结束。记录(id:1),(id:3)的行上了next-key锁,之后的记录没有上锁。

SessionB被阻塞在哪里

更新条件 where name='d005',由于name列没有索引,查询时也要走主键索引。先找到id=1的行,加next-key锁,由于SessionA已经加了next-key锁,SessionB只能等待,它在此时发生了阻塞

update t set age=31 where name='d005'
# 被SessionA上的 id=1的行的 next-key锁阻塞
RECORD LOCKS space id 5 page no 4 n bits 80 index PRIMARY of table `t` 
trx id 4871 lock_mode X waiting
 0: len 4; hex 80000001; asc
 1: len 6; hex 000000000d98; asc
 2: len 7; hex 81000000a70110; asc
 3: len 4; hex 64303031; asc d001
 4: len 4; hex 8000001e; asc
 5: len 1; hex 81; asc

因为id锁的范围是[infimum,3],(id=5,7,9,11)的行没有被上锁,可以更新及插入id > 3的数据。

# Session B
insert into t values(4,'d004',30,1);
(Query Ok)

# 虽然不能通过 where name='d005' 修改
# 但可以通过where id=5 修改,也说明了id=5的行没有被锁
# 同时也绕过了需要等待id=1的锁的限制
update t set age=31 where id=5;
(Query Ok)

select * from t;
+----+------+------+--------+
| id | name | age  | ismale |
+----+------+------+--------+
|  1 | d001 |   30 |      1 |
|  3 | d003 |   30 |      1 |
|  4 | d004 |   30 |      1 |
|  5 | d005 |   31 |      1 |
|  7 | d007 |   30 |      1 |
|  9 | d009 |   30 |      1 |
| 11 | d011 |   30 |      1 |
+----+------+------+--------+

死锁的发生

不同线程出现资源的循环依赖,都在等待对方释放自己所需要的资源,就会导致这几个线程进行无限等待的状态,发生死锁。

事务A 事务B
Begin; Begin;
update t set k=k+1 where id = 1;
update t set k=k+1 where id = 2;
update t set k=k+1 where id = 2;(block)
update t set k=k+1 where id = 1;

<ERROR 1213 (40001):
Deadlock found when trying to get lock;
try restarting transaction
Query OK, 1 row affected (6.99 sec)
Rows matched: 1 Changed: 1 Warnings: 0

事务A在等待事物B释放 id=2的行锁,而事务B在等待事物A释放id=1的行锁,双方都在等待对方释放资源,就发生了死锁。由于MySQL有死锁检测,会马上发现这个死锁,并对事务B进行回滚。

发生死锁的线程都是要锁至少2行(参与的有2个资源,一个资源是自己已经加锁,但别人也要加,另一个资源是别人已经加锁,但自己也要加)。如果一个事务只锁一行是不会发生死锁的,只会发生锁阻塞。

应对策略

  • 什么都不做,直接等到超时

    上面的事务B,会发生超时。

    通过设置innodb_lock_wait_timeout来指定超时时间,默认值是50s

    show variables like '%innodb_lock_wait_timeout%';
    +--------------------------+-------+
    | Variable_name            | Value |
    +--------------------------+-------+
    | innodb_lock_wait_timeout | 50    |
    +--------------------------+-------+
  • 进行死锁检测

    开启死锁检测功能,检测到死锁后,对回滚成本比较低的事务进行回滚,让其它事务继续执行。设置参数innodb_deadlock_detect为on,开启此功能(默认为开启)

    +------------------------+-------+
    | Variable_name          | Value |
    +------------------------+-------+
    | innodb_deadlock_detect | ON    |
    +------------------------+-------+

哪种策略更好

  • 缩短等待的超时时间

    innodb_lock_wait_timeout的默认等待为50秒,对于生产环境,这显然是无法接受的。如果设置为1秒呢,虽然等待的时间变短,但也会误伤那些只是等待锁,而不是陷入死锁的线程。比如2秒以后就可以拿到锁的那些线程。

  • 启用死锁检测

    MySql默认启用死锁检测,当发现加入进来的线程会产生死锁时,会回滚成本较低的事务。MySQL发现死锁的速度很快,所以推荐使用死锁检测

  • 关闭死锁检测

    如果可以确定所有的SQL不会产生死锁问题,可以关闭死锁检测。死锁检测虽然好使,但也是有代价的,会占用CPU的资源。

死锁检测的成本

当一个线程新加入到某个资源的阻塞队列时,会检测它的加入是否与其它正在发生阻塞的线程存在资源的相互依赖,从而导致死锁的发生。如果这是一个高并发的资源,阻塞队列里有大量排队的线程,那么每个线程都要把其它线程检查一遍,每个线程要检查的时间复杂度就是O(N)

比如有1000个并发线程,那么要总共要检测的数量就是 1000 * 1000 = 100W,即O(N^2),这种数量级的检测就会导致消耗大量的CPU资源,你看到的现象就是CPU占用率很高,却处理不了多少事务,或是你发现理处的事务很少,但CPU占用率却很高。

控制并发度

要想从根本上减少死锁及锁等待,就要降低对同一资源的并发访问数量

可以使用的方法

  • 分摊热点资源的访问量

    比如参加秒杀的商品,它的库存如果存放在一条记录中,那么在高并发下,比如有1000个请求,就会同时更新,这样就会导致线程的阻塞或发生死锁。

    可以把保存库存的记录一条拆成N条,让请求随机访问这N条记录,比如分别放在100个记录中,那么每个记录最多只有10个更新请求,这样可以把并发量降为原来的1/N,大大减小了死锁的发生和锁的等待, 以及死锁检测的成本

  • 把并发请求放入队列

    数据库中间件可以把请求放入队列,使并发请求变为顺序访问

场景:用户购买商品A,对应的SQL如下

  1. 商品A库存减1 (SQL1)
  2. 用户购买商品A,扣减用户金额 (SQL2)
  3. 插入一条交易日志 (SQL3)

这三个操作为原子操作,所以要写在一个事务中。如果有大量用户购买商品A,则商品A库存减1 为热点数据,被频繁更新。假设每条SQL的执行时间为5秒,则整个事务的执行时间为15秒,由于有大量用户购买,那么不同的执行顺序将会影响最终的执行时间,从而影响并发

商品A库存减1 (SQL1)放在首行(情况1)

时刻 事务A 事务B 耗时
T1 商品A库存减1 (SQL1) 商品A库存减1 (SQL1) 发生等待,不能执行,直到 T4 时刻 5s
T2 用户购买商品A,扣减用户金额 (SQL2) 5s
T3 插入一条交易日志 (SQL3) 5s
T4 商品A库存减1 (SQL1) 5s
T5 用户购买商品A,扣减用户金额 (SQL2) 5s
T6 插入一条交易日志 (SQL3) 5s
  • T1时刻,事务A执行商品A库存减1,给其上锁,直到事务提交(15秒以后)
  • 同时事务B也执行商品A库存减1,被阻塞,发生锁等待,这使得后面的Sql2,sql2语句不能执行,直到15s后,事务A提交数据。 即T4时刻才开始执行。
  • 事务B总共耗时30秒才完成。等待事务A的15s(T1到T3) + 自身的15s

如果把商品A库存减1 (SQL1)放在最后,可以减少等待的时间(情况2)

时刻 事务A 事务B 耗时
T1 用户购买商品A,扣减用户金额 (SQL2) 用户购买商品A,扣减用户金额 (SQL2) 5s
T2 插入一条交易日志 (SQL3) 插入一条交易日志 (SQL3) 5s
T3 商品A库存减1 (SQL1) 商品A库存减1 (SQL1) 发生等待,不能执行,直到 T4 时刻 5s
T4 商品A库存减1 (SQL1) 5s
  • 扣减用户金额是针对单个用户操作,在同一时刻更新这些记录不太容易发生锁等待,所以事务A与事务B在T1时刻可以同时进行
  • 插入一条交易日志 也是可以同时进行的,所以事务A与事务B在T2时刻同时进行
  • 事务A T3 时刻执行完成,耗时15s,事务B 在此时被阻塞,因为要更新同一条记录,发生了锁等待,需要等待5秒
  • 事务B在T4时刻,执行完成,耗时 20s(10s + 等待 5s + 5s)

当把商品A库存减1 (SQL1)放在最后时,事务B的执行时间缩短到了20s,节省了10s,大大提高了并发度。

可以看出,锁等待时间是正在执行的事物引起锁的语句到提交的时间间隔,如果放在事务最后,那这个时间间隔会变为最少。对照上面的例子,情况1事务A从T1到T3时刻,持有锁总共15s。情况2,事务A只在T3时刻持有锁,总共5s,可见把商品A库存减1 (SQL1)放到最后时,大大减少了时间间隔。

通过减少事务持有锁的时间,大程度的减少了事务之间的锁等待,提高了并发度。所以通常的做法是把热点更新语句放到事务的最后,这样当事务结束后,热点语句的锁可以被马上释放,减少事务锁持有的时间,其它事务等待锁释放的时间就会变短,从而使并发度得到了提高。

用途

对程序性能进行剖析,可以找到性能的瓶颈、bug、以及对程序进行有目标的优化,从而提高程序的性能,解决程序中存在的问题。

如何对程序进行剖析

  1. 设置要分析的指标,利用工具 (go test)性能指标API对指标进行剖析,生成对应的概要文件(二进制文件)
  2. 使用分析工具(go tool pprof/ go tool trace)对概要文件进行解析,得到性能指标值的具体信息,这些信息可以以文本,图形的形式展示。
  3. 根据得到性能指标值的具体信息,分析程序性能,查找程序瓶颈或对程序进行优化

要对一个程序进行剖析,必须先生成对应指标的概要文件,然后再用分析工具去解析这个概要文件

概要文件

是Go程序在某一段时间内,对相关指标采样后,得到的概要信息。剖析时,会在程序执行期间进行一些自动抽样,在结束时进行推断,最后把统计结果保存为概要文件,供分析工具使用。

概要文件的格式

概要文件其实就是 由 protocol buffers 生成的二进制数据流,protocol buffers 是一种数据序列化协议,它定义了程序对象如 map,结构体,数组等与字节之间如何相互转化。同时 protocol buffers 不仅仅是协议,也可以作为转化工具来使用,它可以把字节流转化为对象,也可以把对象转化为字节流。protocol buffers 会对生成的字节流进行压缩,它的体积比(JSON,XML)都要更小,所以也更适合用于数据在网上传输

生成概要文件的方法

Go 语言支持多种类型的性能分析,可以通过

  • 使用 go test 配合对应的标识符
  • 使用性能指标API

这两种方式,都可以生成概要文件

有关性能的概要文件

CPU概要文件,内存概要文件,阻塞概要文件,它们可以通过 go test 配合对应的标识符生成,对CPU,内存,阻塞这三个指标进行分析。

Go test 标识生成概要文件

可以使用 go test工具提供的剖析标识对程序进行分析,生成概要文件。不要让多个剖析标识同时运行,它们之间会相互影响,导致分析结果不准确

Cpu 剖析标识

记录占用CPU时间最长的函数,每个运行在CPU上的函数每几毫秒都会遇到系统中断事件,每次中断时,都会记录一个剖析数据

go test gott/fib -cpuprofile=cpu.log -run=None -bench=FibWithMap

堆剖析标识

记录最耗内容的语句,平均每申请512K的内存,就会记录一个剖析数据

go test gott/fib -memprofile=mem.log -run=None -bench=FibWithMap

阻塞剖解标识

记录阻塞goroutine最久的操作,如系统调用,等待锁,管道收发等,每当这些操作阻塞goroutine时,就会记录一个剖析数据

go test gott/fib -blockprofile=block.log -run=None -bench=FibWithMap

解析概要文件

可执行文件

使用以上标识符,除了生成了剖析数据日志(cpu.log/mem.log/block.log),go test 还会生成对应的可执行程序(fib.test),以包名做为前缀,后面为.test的文件。为了减少剖析数据日志占用的空间和提高分析效率,分析日志本身并没有记录函数的名称,而是函数的地址,所以需要与之对应的可执行文件,才可以对剖析日志进行数据分析

pprof 分析工具

go tool pprof命令可以用来分析剖析日志,它需要两个基本的参数

  • 剖析文件日志
  • 测试生成的可执行文件
go tool pprof -text -nodecount=10 ./fib.test cpu.log

File: fib.test
Type: cpu
Time: Aug 9, 2021 at 11:30am (CST)
Duration: 1.43s, Total samples = 1.09s (76.02%)
Showing nodes accounting for 1.09s, 100% of 1.09s total
Showing top 10 nodes out of 17
      flat  flat%   sum%        cum   cum%
     0.49s 44.95% 44.95%      0.94s 86.24%  runtime.mapaccess2_fast64
     0.36s 33.03% 77.98%      0.36s 33.03%  runtime.memhash64
     0.12s 11.01% 88.99%      1.06s 97.25%  gott/fib.fibWithMap
     0.06s  5.50% 94.50%      0.06s  5.50%  runtime.add (partial-inline)
     0.03s  2.75% 97.25%      0.03s  2.75%  runtime.bucketShift (inline)
     0.02s  1.83% 99.08%      1.08s 99.08%  gott/fib.BenchmarkFibWithMap
     0.01s  0.92%   100%      0.01s  0.92%  runtime.wbBufFlush1
         0     0%   100%      0.01s  0.92%  runtime.(*bmap).overflow (inline)
         0     0%   100%      0.03s  2.75%  runtime.bucketMask (inline)
         0     0%   100%      0.01s  0.92%  runtime.findrunnable
  • ./fib.test:测试生成的可执行文件,在一般的测试中,当测试完成后,文件就会被丢弃,但在启用剖析标识后,这个文件会被保留,供之后的分析使用
  • -nodecount:限制分析结果输出的行数
  • -text:指定输出的格式

还可以使用-web选项,用于生成函数的有向图,标注有CPU的使用和最热点的函数等信息

go tool pprof -web -nodecount=10 ./fib.test cpu.log

// 使用-web需要安装 GraphViz
brew install graphviz

还可以不加任何选项进入交互界面

go tool pprof cpu.log
Type: cpu
Time: Aug 9, 2021 at 11:30am (CST)
Duration: 1.43s, Total samples = 1.09s (76.02%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

这里只用到了概要文件,没有用到测试生成的可执行文件

性能指标API生成概要文件

除了使用 go test 标识对程序进行性能分析,还可以使用标准库中的 runtime/pprofruntime/tracenet/http/pprof这三个包中提供的API来对Go程序进行性能分析,生成概要文件。

生成CPU概要文件

StartCPUProfile函数

StartCPUProfile()函数对CPU信息进行定时采样生成概要文件,默认采样频率是100Hz,即每秒采样100次,调用pprof.StartCPUProfile()函数开始进行采样。

StopCPUProfile函数

调用pprof.StopCPUProfile()停止采样。当调用pprof.StartCPUProfile()时,会启用一个新的goroutine,并在其中进行CPU信息的收集

func StopCPUProfile() {
	cpu.Lock()
	defer cpu.Unlock()

	if !cpu.profiling {
		return
	}
	cpu.profiling = false
	runtime.SetCPUProfileRate(0)
	<-cpu.done
}

StopCPUProfile是通过设置SetCPUProfileRate(0)为0,来停止采样的,而pprof.StartCPUProfile 是把runtime.SetCPUProfileRate(100)设置为100来开始采样的

func main() {
	profileName := "cpu_api.log"
	f, err := common.CreateFile("", profileName)
	if err != nil {
		fmt.Printf("CPU profile creation error: %v\n", err)
		return
	}
	defer f.Close()

	pprof.StartCPUProfile(f)
	op.CPULoad() // 耗时的CPU操作
	pprof.StopCPUProfile()
}

pprof.StartCPUProfile(f):把概要文件写入 cpu_api.log 文件

生成概要文件

go run pprof/cpu/cpu.go

go tool pprof 查看概要文件内容

go tool pprof -text -nodecount=15  cpu_api.log
Type: cpu
Time: Aug 17, 2021 at 3:56pm (CST)
Duration: 1.62s, Total samples = 1.47s (90.66%)
Showing nodes accounting for 1.47s, 100% of 1.47s total
Showing top 15 nodes out of 49
      flat  flat%   sum%        cum   cum%
     1.29s 87.76% 87.76%      1.29s 87.76%  runtime.memmove
     0.06s  4.08% 91.84%      0.06s  4.08%  runtime.usleep
     0.02s  1.36% 93.20%      0.02s  1.36%  runtime.madvise
     0.02s  1.36% 94.56%      0.02s  1.36%  runtime.pthread_kill
     0.02s  1.36% 95.92%      0.10s  6.80%  runtime.slicebytetostring
     0.01s  0.68% 96.60%      1.25s 85.03%  bytes.(*Buffer).WriteString
     0.01s  0.68% 97.28%      0.01s  0.68%  runtime.(*mspan).init (inline)
     0.01s  0.68% 97.96%      0.01s  0.68%  runtime.memclrNoHeapPointers
     0.01s  0.68% 98.64%      0.01s  0.68%  runtime.newArenaMayUnlock
     0.01s  0.68% 99.32%      0.01s  0.68%  runtime.pthread_cond_wait
     0.01s  0.68%   100%      0.05s  3.40%  strconv.FormatInt
         0     0%   100%      0.06s  4.08%  bytes.(*Buffer).String (inline)
         0     0%   100%      0.10s  6.80%  bytes.(*Buffer).grow
         0     0%   100%      0.01s  0.68%  bytes.makeSlice
         0     0%   100%      1.36s 92.52%  gott/common/op.CPULoad

生成内存概要文件

对堆内存的使用进行采样,会按照平均每分配多少个字节(默认为512B),就对堆内存的使用情况进行一次采样。

MemProfileRate

为 runtime.MemProfileRate 设置采样频率(默认值是512KB),对其赋0值表示停止采样。

WriteHeapProfile 函数

调用WriteHeapProfile 函数,根据采样频率进行采样,并把收集到的采样信息写入指定文件。

// 源码文件
// WriteHeapProfile is shorthand for Lookup("heap").WriteTo(w, 0).
// It is preserved for backwards compatibility.
func WriteHeapProfile(w io.Writer) error {
	return writeHeap(w, 0)
}

ReadMemStats 函数

WriteHeapProfile(f)函数记录的并不是实时的内存概要信息,而是最近一次内存垃圾工作完成后产生的。要得到实时信息可以使用runtime.ReadMemStats()函数

func main() {
	profileName := "mem_api.log"
	f, err := common.CreateFile("", profileName)
	if err != nil {
		fmt.Printf("memory profile creation error: %v\n", err)
		return
	}
	defer f.Close()
	startMemProfile()
	fib.FibMap(60)
	endMemProfile(f)
}

func startMemProfile() {
	runtime.MemProfileRate = 1
}

func endMemProfile(f *os.File) {
	pprof.WriteHeapProfile(f)
}

调用runtime.MemProfileRate设置每申请1B的内存,就进行采样,调用pprof.WriteHeapProfile(f)把采样信息写入指定的文件mem_api.log

生成阻塞概要文件

SetBlockProfileRate函数

它在runtime包中,用来设置采样频率,其参数rate的值表示,当阻塞持续多少纳秒后对其进行进行采样。如果这个值小于等于0,则停止采样。

blockprofilerate变量

参数rate的值会被转换为CPU的时钟周期,然后赋值给blockprofilerate,即:实际采样频率为当一个阻塞持续了多少个CPU时钟周期,就对这个事件进行采样

// go 源码文件
func SetBlockProfileRate(rate int) {
	var r int64
	if rate <= 0 {
		r = 0 // disable profiling
	} else if rate == 1 {
		r = 1 // profile everything
	} else {
		// convert ns to cycles, use float64 to prevent overflow during multiplication
		r = int64(float64(rate) * float64(tickspersecond()) / (1000 * 1000 * 1000))
		if r == 0 {
			r = 1
		}
	}

	atomic.Store64(&blockprofilerate, uint64(r))
}

pprof.Lookup(“block”)

用来获取阻塞概要信息,获取信息要调用pprof.Lookup("block")block做为参数传入,函数会返回一个*pprof.Profile类型的值,对这个值调用WriteTo(w io.Writer, debug int)方法,可以把概要信息写入文件。这个方法的第一个参数传入要写入概要信息的文件,第二个参数debug表示概要信息详细程度

func main() {
	profileName := "block_api.log"
	f, err := common.CreateFile("", profileName)
	if err != nil {
		fmt.Printf("block profile creation error: %v\n", err)
		return
	}
	defer f.Close()
	startBlockProfile()
	fib.FibMap(45)
	stopBlockProfile(f)
}

func startBlockProfile() {
	runtime.SetBlockProfileRate(blockProfileRate)
}

func stopBlockProfile(f *os.File) {
	pprof.Lookup("block").WriteTo(f, debug)
}

debug参数的值

  • 0:生成protocol buffers字节流
  • 1:生成内容可读的普通文本的概要文件,且函数名,包名,源码文件等信息会被做为注释加入进概要文件
  • 2:生成内容可读的普通文本的概要文件,且包括更详细的信息

对于使用参数值1、2生成的概要文件,不能使用 go tool pprof 查看,因为文件不是 protocol格式

pprof.Lookup函数的使用

Lookup(name string)通过给定的name 的值,返回对应的概要信息。如果返回值是 nil,表示不存在与给定名称对应的概要信息。预定义了6个概要名称,分别是goroutine, threadcreate, heap, allocs, block, mutex

函数的返回值是*Profile类型的值,可以通过调用WriteTo(w io.Writer, debug int)方法,把采样的概要信息写入指定的文件中(通过第一个参数设置),第二个参数表示了写入信息的详细细节程序,值可以是0,1,2(具体代表的内容就是上面小节讲的)。

预定义概要名称的使用

  • goroutine

    此指标可以收集正在使用的所有 goroutine 的堆栈跟踪信息,在调用WriteTo方法时,如果debug的值大于等于2,会把这些信息写入概要文件,文件可能会非常大

  • heap、allocs

    此指标会收集与堆内存的分配和释放有关的采样信息,可以看成是内存概要信息,heap 与 allocs 仅在debug为0的时候会有区别,heap统计的是已经分配但还没有释放的内存空间,allocs展示的是已分配的内存空间。当debug的值大于0时,这两个指标值输出的内容是相同的

  • threadcreate

    此指标会收集堆栈跟踪信息。这些堆栈跟踪信息中的每一个都会描绘出一个代码调用链,这些调用链上的代码都导致新的操作系统线程产生

  • block

    此指标会收集因争用同步原语而被阻塞的那些代码的堆栈跟踪信息

  • mutex

    此指标会收集曾经作为同步原语持有者的那些代码,它们的堆栈跟踪信息

    同步原语可以理解为:通道、互斥锁、条件变量、”WaitGroup”

对于除了 CPU 概要信息之外的其他概要信息,我们都可以通过调用这个函数获取到。

为基于 HTTP 协议的网络服务添加性能分析接口

在我们编写网络服务程序的时候,使用net/http/pprof包要比直接使用runtime/pprof包方便和实用很多,这个代码包可以为网络服务的监测提供有力的支撑

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	log.Println(http.ListenAndServe("localhost:8082", nil))
}

直接访问http://localhost:8082/debug/pprof 可以看到goroutine,threadcreate,heap, allocs,block,mutex这6个指标的概要信息。它们都配有debug参数,默认值为0,可以通过改变debug的值改变概要信息的详细程度,如 gotroutine的URL是http://localhost:8082/debug/pprof/goroutine?debug=1

当访问http://localhost:8082/debug/pprof/profile 时,程序会执行对 CPU 概要信息的采样,可以通过加入参数seconds来控制对cpu的访问时间(默认是30秒),当采样结束后,会提示你下载概要文件。你也可以执行下面命令,直接读取概要文件

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=60

要回答的问题

先写结论,便于接下来的阅读与理解

基准测试与剖析的区别

基准测试可以用来衡量一个程序的性能,如果想让程序运行的更快,或对性能不理想的程序进行提升,基准测试无法给出从哪里可以进行优化。

通过剖析,可以找出程序性能瓶颈所在,从而有针对性的对程序进行优化,提高性能。不要过早的进行优化,97%的场景,都不需要过早优化或根本就不需要优化,我们要做的仅是让程序可以正常运行即可。

剖析是如何进行的

剖析就是在程序执行期间进行一些自动抽样,在结束时进行推断,最后把统计结果保存为剖析数据文件,供剖析工具使用。Go 语言支持多种类型的剖析性能分析,可以通过go test 工具或调用Go的runtime 性能分析API(启用运行时剖析),对程序进行剖析。

需要对哪些代码进行剖析

要对程序的主要功能,关键部分进行基准测试,然后对其进行剖析,功能测试不应该参与进来,使用 -run=None,禁止功能测试的运行

示例函数

Example开头,后面加对应的函数名,示例没有参数列表与返回值列表

func ExampleFibMap() {...}

后缀FibMap的首字母是大写,因为这个函数在源码中就是大写,必须与源码保持一致,如果写为fibMap,那么会提示 ExampleFibWithMap refers an unknown identifier,即无法与对应的函数相关联。

要注意的是,因为FibMap方法是可导出的,它的示例函数要写为ExampleFibMap,如果方法是不可导出的,那么它的示例函数要写为Example_xxx的形式,如fibWithMap方法的示例函数可以命名为Example_fibWithMap

命名约定

Go 语言通过大量的命名约定来简化工具的复杂度,规范代码的风格。对示例函数的命名有如下约定:

  • 包级别的示例函数,直接命名为 func Example() { ... }
  • 函数 F 的示例,命名为 func ExampleF() { ... }
  • 类型 T 的示例,命名为 func ExampleT() { ... }
  • 类型 T 上的 方法 M 的示例,命名为 func ExampleT_M() { ... }

如果同一个方法需要提供多个示例,可以在示例函数名称后附加一个不同的后缀来实现,但这种后缀必须以小写字母开头,大写也是可以的

func Example_suffix() { ... }
func ExampleF_suffix() { ... }
func ExampleT_suffix() { ... }
func ExampleT_M_suffix() { ... }

示例代码会放在单独的示例文件中,如example_test.go

https://github.com/golang/go/blob/master/src/bytes/example_test.go

测试示例函数

// 测试通过
go test gott/fib -run=ExampleFibMap
ok      gott/fib        0.008s

// 测试不通过
go test gott/fib -run=ExampleFibMap
--- FAIL: ExampleFibMap (0.00s)
got:
102334155
want: // 这里就是output 后面期待的值
102334156
FAIL
FAIL    gott/fib        0.012s
FAIL

有时候,输出顺序可能不确定,比如循环输出 map 的值,那么可以使用 Unordered output 开头的注释

示例函数的作用

  1. 作为文档使用

    它可以直观的展示一个函数的用法与功能。根据Example的后缀部分,godoc文档服务会把示例函数关联到函数本身,在查询在线文档的时候,可以看到函数的具体用法及示例函数

  2. 验证函数是否正确

    如果示例函数中包含// Output注释,那么在执行 go test 的时候,示例函数也会被运行,然后检查标准输出是否与注释匹配,不匹配则做为测试失败处理。如果示例函数中没有包含// Output注释,那么 go test 的时候,它不会被运行

  3. 可以在 godoc 提供的在线文档上显示,包括函数信息,示例函数

源码文件

// fib_test.go
func ExampleFibMap() {
	num := fibWithMap(40)
	fmt.Println(num)
	//Output:
	//102334155
	//hello
	fmt.Println("hello")
}

func Example_fibWithMap() {
	num := fibWithMap(40)
	fmt.Println(num)
	//Output:
	//102334155
}

可以有多个标准输出,与之对应的output也要有多个注释

func FibMap(x int) int {...}
func fibWithMap(x int) int {...}

运行某个包的测试

不指定包名:go test 命令如果没有参数指定包,默认使用当前目录对应的包运行测试,且测试结果不会被缓存。第二次运行测试,测试结果中没有包含(cached)标示,说明测试结果没有被缓存,每次执行都会重新构建测试

# gott/hello 当前目录为包名为 hello的目录
go test
PASS
ok  	gott/hello	1.025s

go test
ok  	gott/hello	1.025s

指定包名:测试结果会被缓存

go test gott/hello
ok  	gott/hello	(cached)

运行所有测试

使用go test ./...标记运行所有包的测试,测试结果会被缓存

go test ./...
ok  	gott/hello	1.026s
ok  	gott/hi	0.040s
ok  	gott/pprint	0.017s
ok  	gott/prime	0.014s

go test ./...
ok  	gott/hello	(cached)
ok  	gott/hi	(cached)
ok  	gott/pprint	(cached)
ok  	gott/prime	(cached)

有效的记录测试失败信息

测试失败的信息一般的形式是“f(x) = y, want z”,其中f(x)解释了失败的操作和对应的输入,y是实际的运行结果,z是期望的正确的结果。比起那些失败就打印满屏的堆栈信息的错误日志,这种记录格式使得测试人员很容易定位到问题所在,甚至都不需要去看源码文件。

got := sum(x1, x2)
if got != want {
	t.Errorf("sum(%d, %d) = %d, want %d", x1, x2, got, want)
}

随机测试如何预判结果

一种方法是编写另一个对照函数,使用简单和清晰的算法,虽然效率较低但是行为和要测试的函数是一致的,然后针对相同的随机输入检查两者的输出结果。

第二种是生成的随机输入的数据遵循特定的模式,这样我们就可以知道期望的输出的模式。比如基于随机种子生成需要的大量数据,测试日志中不用去记录这些大量的数据,只需要记录这个随机种子即可,之后可以根据这个种子重现失败的测试用例,查找代码问题所在

测试中的异常

在测试代码中不要调用 log.Fatalos.Exit ,调用这类函数会导致整个测试提前退出,后面的测试都将无法运行。调用这些函数的特权应该放在 main 函数中。如果真的有意外发生导致测试过程中发生 panic 异常,那么在测试中应该尝试用 recover 捕获异常,并记录下来,然后将当前测试当作失败处理(即调用t.Effor/t.Fail/t.FailNow之类的方法)。

在运行测试的时候,应该确保所有测试都得到运行,这样当测试运行结束后,就可以得到所有失败的测试用例的信息,而不是在某个测试失败后,就停止运行其后面的测试。

func TestLogFatal(t *testing.T) {
	log.Fatal("Test encounter a fatal")
	// os.Exit(2)
}

func TestPrint(t *testing.T) {
	t.Log("just Print")
	t.Log(os.Getenv("GOROOT"))
}

由于调用了log.Fatalos.Exit,在TestLogFatal()后面的测试用例都不会被运行

go test -v  gott/hello
=== RUN   TestLogFatal
2021/07/24 17:34:02 Test encounter a fatal
FAIL    gott/hello      0.007s
FAIL
# 没有打印 TestPrint()的测试日志

# 把 TestPrint()放到 TestLogFatal()的前面
 go test -v  gott/hello
=== RUN   TestPrint
    hello_test.go:24: just Print
    hello_test.go:25: /Users/ga/m/opt/go/go_root
--- PASS: TestPrint (0.00s)
=== RUN   TestLogFatal
2021/07/24 17:37:04 Test encounter a fatal
FAIL    gott/hello      0.013s
FAIL

TestPrint 可以运行,但 TestLogFatal 之后的测试用例都不会得到运行,在调用 log.Fatal 函数后,整个测试程序就退出了

mock 测试中的敏感对象

  • 对外部环境的依赖

    数据库的连接,第三方接口的调用

  • 导致生产代码产生一些调试信息的钩子函数

  • 诱导生产代码进入某些重要状态的改变

    超时、错误,甚至是一些刻意制造的并发行为等因素

应该对以上这些对象进行仿造(mock),从而得到一个纯净的测试环境。有一个需要注意的地方,如果在某个测试用例中mock了某些对象,在这个测试用例运行完成后,要对这些mock的对象进行还原,以避免影响其它测试用例。

func TestCheckQuotaNotifiesUser(t *testing.T) {
    // Save and restore original notifyUser.
  	// 保存 及 恢复 mock的原始对象
    saved := notifyUser
    defer func() { notifyUser = saved }()

    // Install the test's fake notifyUser.
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {
        notifiedUser, notifiedMsg = user, msg
    }
    // ...rest of test...
}

先把要mock的对象保存起来,等测试完成后,再对其进行恢复,可以使用defer语句来延后执行处理恢复的代码

避免脆弱的测试

  • 一个好的测试不应该在程序仅仅只是做了微小变化就失败

  • 一个好的测试不应该在遇到一点小错误时就立刻退出测试,它应该尝试报告更多的相关的错误信息,因为我们可能从多个失败测试的模式中发现错误产生的规律

  • 一个好的测试的关键是首先实现你期望的具体行为,然后才是考虑简化测试代码、避免重复。如果直接从抽象、通用的测试库着手,很难取得良好结果。

  • 保持测试代码的简洁和内部结构的稳定,特别是对断言部分要有所选择,比如不要对字符串进行全字匹配,而是针对那些在项目的发展中是比较稳定不变的子串

  • 测试时涉及到对全局变量产生修改的那些测试,要以串行的方式运行,不能并行运行

计时器

用来记录性能测试函数在每次运行时消耗的时间,同时它也记录了此次运行对内存分配的字节数和分配的次数。与之相关的有三个方法StartTimerStopTimerResetTimer

运行go test 时就用到了计时器,命令会启用这个函数的计时器,当函数执行完成,停止计时器,记录下此次的运行时间,然后与默认执行时间上限(默认为1秒)做比较,如果没有超过,则增大b.N的值,再次执行该函数,如此反复,直到函数的运行时间大于或等于执行时间上限,从而得到b.N的值,即函数的最大执行次数及对应的时间,从而得出最终的测试结果

通过对 StartTimer、StopTimer的调用,我们可以在测试中去除那些本不应该计入测试时间的代码的执行时间,比如一些测试前的准备代码

func BenchmarkGetPrimes(b *testing.B) {
	b.StopTimer()
	// 模拟某个耗时但与被测程序关系不大的操作
	time.Sleep(time.Millisecond * 500)
	max := 10000
	b.StartTimer()

	for i := 0; i < b.N; i++ {
		GetPrimes(max)
	}
}

time.Sleep用来模拟得到max的值所要进行的耗时操作,而我们要测试的是GetPrimes()的性能,不应计入计算max值所用的时间,所以要把开始时间设置在得到max之后。

通过 b.StopTimer()b.StartTimer()的配合使用,就可以去除任何一段代码的执行时间,b.ResetTimer()是去除它之前代码的执行时间。通过对计时器的调用,可以让测试函数的执行时间更加准确。