0%

插入数据

插入多条数据的优化方式:

  1. 批量插入

一次插入数据不建议超过 1000 条,如果要插入的数据太多,可以分批插入

1
insert into tbl values (a, b), (c, d);
  1. 手动提交事务

MySQL 里面的事务提交方式默认是自动提交的,也就是执行一条语句会提交一次,这样效率很低。

1
2
3
4
start transaction;
insert into tbl values (a, b), (c, d);
insert into tbl values (a1, b1), (c1, d1);
commit;
  1. 主键顺序插入

主键顺序插入的性能要高于乱序插入的性能。见下一节。

  1. 大批量插入数据(如 100w 条)

如果一次性需要插入大批量数据,使用 insert 语句插入性能较低,此时可以使用 MySQL 数据库提供的 load 指令进行插入。

通过 load 指令,我们可以一次性将本地磁盘文件中的数据全部加载进数据库表结构当中。

操作如下:

1
2
1,a,b,2022-10-27
2,c,d,2022-10-28

磁盘文件为 csv 格式,通过 load 命令可以将这个文件的全部内容加载到数据库中。

1
2
3
4
5
6
7
8
# 客户端连接服务端时,加上参数 --local-infile
mysql --local-infile -u root -p

# 设置全局参数 local_infile 为 1,开启从本地加载文件导入数据的开关
set global local_infile=1;

# 执行 load 指令将准备好的数据,加载到表结构中
load data local infile "/Users/ruby/a.csv" into table tbl fields terminated by "," lines terminated by "\n";

100w 的数据通过 load data 耗时十几秒,但是通过读取然后 insert 的方式需要 10 分钟。

主键顺序插入性能高于乱序插入。

主键优化

  • InnoDB 数据组织方式

在 InnoDB 存储引擎中,表数据都是根据主键顺序组织存放的,这种存储方式的表称为索引组织表(index organized table IOT)。

  • 页分裂

页可以为空,也可以填充一半,也可以填充 100%。每个页包含了 2-N 行数据(如果一行数据过大,会行溢出),根据主键排列。

在前后两个页满的时候,如果插入的主键也要插入这其中的一页,那么就会导致页分裂。

  • 页合并

当删除一行记录时,实际上记录并没有被物理删除,只是记录被标记为删除并且它的空间变得允许被其他记录声明使用。

当页中删除的记录达到 MERGE_THRESHOLD(默认为页的 50%),InnoDB 会开始寻找最靠近的页(前或后)看看是否可以将两个页合并以优化空间使用。

MERGE_THRESHOLD 参考文档

MERGE_THRESHOLD:合并页的阈值,可以自己设置,在创建表或者创建索引时指定。

  • 主键设计原则
  1. 满足业务需求的情况下,尽量降低主键的长度。(减少空间占用,除了主键索引会使用主键,二级索引的叶子结点存的也是主键的值)
  2. 插入数据时,尽量选择顺序插入,选择使用 AUTO_INCREMENT 自增主键。(乱序插入可能会导致页分裂)
  3. 尽量不要使用 UUID 做主键或者是其他自然主键,如身份证号。(因为这些数据插入的时候其实就等于是乱序插入,而且占用空间也会比整型自增主键,int 是固定的 4 字节,身份证号十几个字节了)
  4. 业务操作时,避免对主键的修改。(会导致主键索引的调整)

order by 优化

目的:通过建立合适的索引,优化去掉 filesort。

MySQL 里面的排序有哪些?

  • Using filesort: 通过表的索引或全表扫描,读取满足条件的数据行,然后在排序缓冲区 sort buffer 中完成排序操作,所有不是通过索引直接返回排序结果的排序都叫 filesort 排序。
  • Using index: 通过有序索引顺序扫描直接返回有序数据,这种情况即为 using index,不需要额外排序,操作效率高。

优化 order by 语句的时候,尽量优化为 using index。

如果知道 order by 使用了哪种方式?

使用 explain,extra 会显示排序使用了哪种方式。

如何优化?

  1. order by 的字段加索引。(多个字段的话,建立联合索引)
  2. 如果是多个字段的排序,则创建索引的时候,不同字段的顺序要跟排序时候的顺序一致。如 order by a asc, b desc 语句,则建立索引的时候就需要是 a 顺序,b 逆序。

show index from tbl; 结果里面的 collation 的 A 表示是升序,D 表示是降序。

示例

前提:覆盖索引。如果是 select * 则又是 using filesort 了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
-- 没有创建索引时,根据 age,phone 进行排序。排序的方式显示是 using filesort
explain select id,age,phone from user order by age,phone;

-- 创建索引
create index idx_user_age_phone_aa on user(age,phone);

-- 创建索引后,根据 age,phone 进行升序排序。排序的方式显示是 using index
explain select id,age,phone from user order by age,phone;

-- 创建索引后,根据 age,phone 进行降序排序。排序的方式依然是 using index,这是因为索引是双向的链表结构。
explain select id,age,phone from user order by age desc, phone desc;

-- 根据 age,phone 一个升序,一个降序。排序的方式也出现了 using filesort
explain select id,age,phone from user order by age asc, phone desc;

-- 创建索引。一个字段升序,一个字段降序。
create index idx_user_age_phone_ad on user(age asc,phone desc);

-- 根据 age,phone 进行一个字段升序,一个字段降序的排序。排序方式显示 using index。
explain select id,age,phone from user order by age asc, phone desc;

using index 意味着从索引返回的数据已经是有序的了,所以不需要再进行排序。

order by 优化总结

  • 根据排序字段建立合适的索引,多字段排序时,也遵循最左前缀法则。
  • 尽量使用覆盖索引。
  • 多字段排序,一个升序一个降序,此时需要注意联合索引在创建时的规则(ASC/DESC)。
  • 如果不可避免的出现 filesort,大数据量排序时,可以适当增大排序缓冲区大小 sort_buffer_size(默认 256K)。(如果要排序的数据太多可能会用到磁盘文件来排序)

group by 优化

索引对于分组操作的影响。

关键:联合索引、覆盖索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 执行分组操作,根据 profession 字段进行分组。显示没有用到索引,extra 显示 using temporary。
explain select profession, count(*) from user group by profession;

-- 创建索引。
create index idx_user_pro_age_sta on user(profession, age, status);

-- 执行分组操作,根据 profession 字段进行分组。用到了索引 idx_user_pro_age_sta,extra 显示 using index。
explain select profession, count(*) from user group by profession;

-- 执行分组操作,根据 profession,age 字段进行分组。用到了索引 idx_user_pro_age_sta,extra 显示 using index。
explain select profession, age, count(*) from user group by profession,age;

-- 用到了索引 idx_user_pro_age_sta,extra 显示 using index。
explain select age,count(*) from user where profession='软件工程' group by age;

总结

  • 在分组操作时,可以通过索引来提高效率。
  • 分组操作时,索引的使用也是满足最左前缀法则的。

limit 优化

一个常见又非常头疼的问题就是 limit 2000000,10,此时需要 MySQL 排序前 2000010 条记录,然后仅仅返回 2000000-2000010 的记录,其他记录丢弃,查询排序的代价非常大。

优化思路

覆盖索引 + 子查询。

一般分页查询时,通过创建覆盖索引能够比较好地提高性能,可以通过覆盖索引加子查询形式进行优化。

实例

1
2
3
4
5
6
7
-- 5s
select * from sku limit 100000,10

-- 优化,0.133 秒
SELECT * from sku a, (SELECT id from sku LIMIT 100000,10) as b WHERE a.id = b.id

-- limit 500000,10 的时候,第一种方式 22s,第二种方式 0.35s

分析

  • 在上面第一个语句中,因为是 select * 所以这个查询没有用到索引,是全表扫描。
  • 而在第二个查询中,我们先是在子查询里面查询出了 id,而这个查询因为用到了索引,所以会快很多。
  • 然后拿到 id 后再去匹配 sku 表,这个过程也能用到索引,所以就会快很多。

count 优化

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高。(前提:没有 where)
  • InnoDB 执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。

优化思路:自己计数。如借助 redis,执行插入的时候 +1,删除的时候 -1。

count 的几种方法

  • count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加,最后返回累加值。

  • 用法:count(*)count(主键)count(字段)count(1)count(字段) 会判断 NULL

  • count(主键): InnoDB 引擎会遍历整张表,把每一行的主键 id 值都取出来,返回给服务层。服务层拿到主键后,直接按行进行累加(主键不可能为 null)。

  • count(字段): 没有 not null 约束,InnoDB 引擎会遍历整张表把每一行的字段值都取出来,返回给服务层,服务层判断是否为 null,不为 null,计数累加。有 not null 约束,InnoDB 引擎会遍历整张表把每一行的字段都取出来,返回给服务层,直接按行进行累加。

  • count(*): InnoDB 引擎并不会把全部字段取出来,而是专门做了优化,不取值,服务层直接按行进行累加。

  • count(1): InnoDB 引擎遍历整张表,但不取值。服务层对于返回的每一行,放一个数字 1 进去,直接按行进行累加。

按照效率排序的话,count(字段) < count(主键) < count(1) ≈ count(*),所以尽量使用 count(*)

update 优化

事务不提交,锁不会释放。

InnoDB 的行锁是针对索引加的锁,不是针对记录加的锁,并且该索引不能失效,否则会从行锁升级为表锁。

前言

在 hyperf 里,支持 Consul 和 Nacos 作为服务中心的组件,本文使用 Consul 作为服务中心演示 hyperf 里面的服务注册与服务发现。

依赖的组件

  • hyperf/json-rpc
  • hyperf/rpc-client
  • hyperf/rpc-server
  • hyperf/service-governance
  • hyperf/service-governance-consul

docker 网络

在本文的实验环境下,服务提供者、服务消费者以及服务中心都在同一个 docker 网络下,下面的命令是创建 docker 网络的:

1
docker network create hyperf

启动 consul

下面的命令启动了一个 consul 容器,并且加入了名为 hyperf 的 docker 网络,这样就可以方便跟服务提供者、服务消费者进行网络通信:

1
docker run --rm --network="hyperf" -p 8500:8500 -p 8400:8400 -p 8600:8600 --name="consul" consul

服务提供者

新建项目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 启动容器
docker run --name provider \
-v /workspace/skeleton:/data/project \
-p 9501:9501 -it \
--privileged -u root \
--entrypoint /bin/sh \
--network="hyperf" \
hyperf/hyperf:7.4-alpine-v3.11-swoole

# 在容器里面进行安装 hyperf,安装的时候需要选择的话全部回车,使用默认选项
composer create-project hyperf/hyperf-skeleton

cd hyperf-skeleton

# 安装依赖
composer require hyperf/json-rpc
composer require hyperf/rpc-server

# 安装服务注册与发现的依赖
composer require hyperf/service-governance
composer require hyperf/service-governance-consul

配置服务提供者

打开文件 config/autoload/server.php,在 servers 块里面添加:

1
2
3
4
5
6
7
8
9
10
[
'name' => 'jsonrpc-http',
'type' => Server::SERVER_HTTP,
'host' => '0.0.0.0',
'port' => 9503,
'sock_type' => SWOOLE_SOCK_TCP,
'callbacks' => [
Event::ON_REQUEST => [Hyperf\JsonRpc\HttpServer::class, 'onRequest'],
],
],

定义服务接口类

创建接口类:app/Contracts/CalculatorServiceInterface.php:

1
2
3
4
5
6
7
8
<?php

namespace App\Contracts;

interface CalculatorServiceInterface
{
public function add(int $a, int $b): int;
}

定义接口实现类

创建实现类:app/JsonRpc/CalculatorService.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace App\JsonRpc;

use App\Contracts\CalculatorServiceInterface;
use Hyperf\RpcServer\Annotation\RpcService; // 这个不能去掉

/**
* 注意,如希望通过服务中心来管理服务,需在注解内增加 publishTo 属性
* @RpcService(name="CalculatorService", protocol="jsonrpc-http", server="jsonrpc-http", publishTo="consul")
*/
class CalculatorService implements CalculatorServiceInterface
{
// 实现一个加法方法,这里简单的认为参数都是 int 类型
public function add(int $a, int $b): int
{
// 这里是服务方法的具体实现
return $a + $b;
}
}

@RpcService 有四个参数:

  • name 属性为定义该服务的名称,这里定义一个全局唯一的名字即可,Hyperf 会根据该属性生成对应的 ID 注册到服务中心去;
  • protocol 属性为定义该服务暴露的协议,目前仅支持 jsonrpc-httpjsonrpcjsonrpc-tcp-length-check,分别对应于 HTTP 协议和 TCP 协议下的两种协议,默认值为 jsonrpc-http,这里的值对应在 Hyperf\Rpc\ProtocolManager 里面注册的协议的 key, 它们本质上都是 JSON RPC 协议,区别在于数据格式化、数据打包、数据传输器等不同;
  • server 属性为绑定该服务类发布所要承载的 Server,默认值为 jsonrpc-http,该属性对应 config/autoload/server.php 文件内 servers 下所对应的 name,这里也就意味着我们需要定义一个对应的 Server
  • publishTo 属性为定义该服务所要发布的服务中心,目前仅支持 consulnacos 或为空,为空时代表不发布该服务到服务中心去,但也就意味着您需要手动处理服务发现的问题,要使用此功能需安装 hyperf/service-governance 组件及对应的 驱动依赖,比如,如果使用 consul,还需要安装 hyperf/service-governance-consul

配置服务驱动

修改文件 config/autoload/services.php,修改为如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
return [
'enable' => [
'discovery' => true,
'register' => true,
],
'consumers' => [],
'providers' => [],
'drivers' => [
'consul' => [
'uri' => 'http://consul:8500', // consul 的地址
'token' => '',
],
],
];

启动服务提供者

1
php bin/hyperf.php start

消费者

启动消费者服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 启动容器
docker run --name consumer \
-v /workspace/skeleton:/data/project \
-p 9501:9501 -it \
--privileged -u root \
--entrypoint /bin/sh \
--network="hyperf" \
hyperf/hyperf:7.4-alpine-v3.11-swoole

# 在容器里面进行安装 hyperf,安装的时候需要选择的话全部回车,使用默认选项
composer create-project hyperf/hyperf-skeleton

cd hyperf-skeleton

# 安装依赖
composer require hyperf/json-rpc
composer require hyperf/rpc-client

# 安装服务注册与发现的依赖
composer require hyperf/service-governance
composer require hyperf/service-governance-consul

配置服务提供者地址

新建文件 config/autoload/services.php,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

return [
'enable' => [
'discovery' => true,
'register' => true,
],
'consumers' => [
[
'name' => 'CalculatorService',
'registry' => [
'protocol' => 'consul',
'address' => 'http://consul:8500',
],
]
],
'drivers' => [
'consul' => [
'uri' => 'http://consul:8500',
'token' => '',
],
],
];

定义和服务端相同的接口类

app/Contracts/CalculatorServiceInterface.php:

1
2
3
4
5
6
7
8
<?php

namespace App\Contracts;

interface CalculatorServiceInterface
{
public function add(int $a, int $b): int;
}

定义消费者实现类

app/JsonRpc/CalculatorServiceConsumer.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?php

namespace App\JsonRpc;

use App\Contracts\CalculatorServiceInterface;
use Hyperf\RpcClient\AbstractServiceClient;

class CalculatorServiceConsumer extends AbstractServiceClient implements CalculatorServiceInterface
{
protected $serviceName = 'CalculatorService';

protected $protocol = 'jsonrpc-http';

public function add(int $a, int $b): int
{
return $this->__request(__FUNCTION__, compact('a', 'b'));
}
}

在控制器中使用服务消费者

app/Controller/IndexController.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

declare(strict_types=1);
/**
* This file is part of Hyperf.
*
* @link https://www.hyperf.io
* @document https://hyperf.wiki
* @contact group@hyperf.io
* @license https://github.com/hyperf/hyperf/blob/master/LICENSE
*/
namespace App\Controller;

use App\JsonRpc\CalculatorServiceConsumer;
use Hyperf\Di\Annotation\Inject;

class IndexController extends AbstractController
{
/**
* 依赖注入
* @Inject
* @var CalculatorServiceConsumer
*/
protected $service;

public function index()
{
$user = $this->request->input('user', 'Hyperf');
$method = $this->request->getMethod();

return [
'sum' => $this->service->add(1, 2), // 调用消费者,最终是会通过 rpc 调用服务提供者的方法
'method' => $method,
'message' => "Hello {$user}.",
];
}
}

启动服务消费者

1
php bin/hyperf.php start

检验

  1. 打开 http://localhost:8500,在里面可以看到注册的 CalculatorService 服务
  2. 访问 http://localhost:9501,可以看到输出的结果里面有 sum

参考文档

  1. 安装文档 https://hyperf.wiki/2.2/#/zh-cn/quick-start/install
  2. json-rpc 文档 https://hyperf.wiki/2.2/#/zh-cn/json-rpc

定义

1
2
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

__builtin_expect 是编译器内建函数,原型为 long __builtin_expect (long exp, long c)。该函数并不会改变 exp 的值,但是可以对 if-else 分支或者 if 分支结构进行优化。 likely 表示 if 分支大概率会发生,unlikely 代表 if 分支大概率不会发生。

!! 是 C 语言中处理逻辑表达式的一个技巧。因为 C 语言中没有布尔变量,所以布尔值是用整型来代替的,0 为假,非 0 为真。 当 x 为 0 时,!(x) 为 1,!!(x) 为 0,!! 的运算没有什么意义;但当 x 为非 0 时(比如 100),!(x) 为 0,!!(x) 为 1, 这样就达到了将非 0 值全部映射为 1 的效果。

应用场景

总的来说,对代码运行效率有要求的 if-elseif 分支就应该使用 likelyunlikely 优化选项。

注意事项

  • likelyunlikely 的概率判断务必准确,不要写反了,否则非但不能提升运行效率,反而会起到反作用。
  • 选择表达式时要选择编译阶段编译器无法推测出真假的表达式,否则优化不起作用。
  • 编译时需要至少使用 -O2 选项,否则优化不起作用。

作用原理

理论

使用 likelyunlikely 为什么会起到提升代码运行效率的优化效果呢?

主要的作用机理有以下 2 点:

  • gcc 编译器在编译生成汇编代码时会在编译选项的引导下调整 if 分支内代码的位置,如果是 likely 修饰过的就调整到前面,如果是 unlikely 修饰过的就调整到后面。 放到前面的代码可以节省跳转指令带来的时间开销,从而达到提升效率的目的。
  • 当代 CPU 都有 ICache 和流水线机制,在运行当前这条指令时,ICache 会预读取后面的指令,以提升运行效率。但是如果条件分支的结果是跳转到了其他指令,那取的下一条指令(有的 CPU 设置的是 4 级流水,也就是 4 条指令)就没用了, 这样就降低了流水线的效率。如果使用 likelyunlikely 来指导编译器总是将大概率执行的代码放在靠前的位置,就可以大大提高预取值的命中率,从而达到提升效率的目的。

实践

1
2
3
4
# 编译生成a.out,注意使用-O2选项,否则不生效
gcc -O2 test.c
# 根据生成的a.out生成反汇编代码
objdump -CS a.out > objdump.txt
  • objdump 命令是用来查看目标文件或者可执行的目标文件的构成的 gcc 工具。
  • -d 反汇编目标文件中包含的可执行指令。
  • -S 混合显示源码和汇编代码,前提是在编译目标文件时加上 -g,否则相当于 -d
  • -C 一般针对 C++ 语言,用来更友好地显示符号名。

不使用 likelyunlikely 选项

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdlib.h>

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

int main(int argc, char *argv[]) {
int i = atoi(argv[1]);

if (i > 0) i--;
else i++;

return i;
}

objdump 里面的 main

没有跳转指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
0000000000400440 <main>:
400440: 48 83 ec 08 sub $0x8,%rsp
400444: 48 8b 7e 08 mov 0x8(%rsi),%rdi
400448: ba 0a 00 00 00 mov $0xa,%edx
40044d: 31 f6 xor %esi,%esi
40044f: e8 dc ff ff ff callq 400430 <strtol@plt>
400454: 8d 50 ff lea -0x1(%rax),%edx // %rax 是返回值寄存器,i-1
400457: 8d 48 01 lea 0x1(%rax),%ecx // i+1
40045a: 85 c0 test %eax,%eax // 判断 %eax 是否为 0
40045c: 0f 4e d1 cmovle %ecx,%edx // if <=, edx = ecx
40045f: 48 83 c4 08 add $0x8,%rsp
400463: 89 d0 mov %edx,%eax // %edx 是最终返回值
400465: c3 retq
  • -0x1(%rax) 表示 %rax 的值减 1,lea -0x1(%rax),%edx 则表示 %edx 保存的是 i-1
  • 0x1(%rax) 表示 %rax 的值加 1,lea 0x1(%rax),%ecx 则表示 %ecx 保存的是 i+1

x86 寄存器参考文档:https://www.cs.uaf.edu/2017/fall/cs301/lecture/09_11_registers.html

其他参考文档 https://web.stanford.edu/class/archive/cs/cs107/cs107.1186/lectures/14-slides.pdf

使用 likely

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdlib.h>

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

int main(int argc, char *argv[]) {
int i = atoi(argv[1]);

if (likely(i > 0)) i--;
else i++;

return i;
}

objdump 里面的 main

1
2
3
4
5
6
7
8
9
10
11
12
13
0000000000400440 <main>:
400440: 48 83 ec 08 sub $0x8,%rsp
400444: 48 8b 7e 08 mov 0x8(%rsi),%rdi
400448: ba 0a 00 00 00 mov $0xa,%edx
40044d: 31 f6 xor %esi,%esi
40044f: e8 dc ff ff ff callq 400430 <strtol@plt>
400454: 85 c0 test %eax,%eax
400456: 7e 08 jle 400460 <main+0x20> // 小于等于的时候跳转,if 语句块在前面
400458: 83 e8 01 sub $0x1,%eax
40045b: 48 83 c4 08 add $0x8,%rsp
40045f: c3 retq
400460: 83 c0 01 add $0x1,%eax // else 语句块在后面
400463: eb f6 jmp 40045b <main+0x1b>

使用 likely 的时候,编译器就知道,大部分情况下,if 判断的结果会是 true,所以只有 i <= 0 的时候才会去跳转,这样一来大部分的情况下都不用跳转。

使用 unlikely

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdlib.h>

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

int main(int argc, char *argv[]) {
int i = atoi(argv[1]);

if (unlikely(i > 0)) i--;
else i++;

return i;
}

objdump 里面的 main

1
2
3
4
5
6
7
8
9
10
11
12
13
0000000000400440 <main>:
400440: 48 83 ec 08 sub $0x8,%rsp
400444: 48 8b 7e 08 mov 0x8(%rsi),%rdi
400448: ba 0a 00 00 00 mov $0xa,%edx
40044d: 31 f6 xor %esi,%esi
40044f: e8 dc ff ff ff callq 400430 <strtol@plt>
400454: 85 c0 test %eax,%eax
400456: 7f 08 jg 400460 <main+0x20> // 大于的时候跳转,else 语句块在前面
400458: 83 c0 01 add $0x1,%eax
40045b: 48 83 c4 08 add $0x8,%rsp
40045f: c3 retq
400460: 83 e8 01 sub $0x1,%eax // if 语句块在后面
400463: eb f6 jmp 40045b <main+0x1b>

使用 unlikely 的时候,编译器就知道,大部分情况下,if 判断的结果会是 false,所以只有 i > 0 的时候才会去跳转,这样一来大部分的情况下都不用跳转。

问题

1
ls *.pdf | xargs ls -lh

这个命令的作用是 ls -lh 当前目录下的 pdf 文件,但是有个问题是,如果 pdf 文件的路径有空格,这会被视作多个不同的路径,从而导致报错:

1
2
3
4
➜  Downloads ls *.pdf | xargs ls -lh
ls: 01.pdf: No such file or directory
ls: 12.46.55.pdf: No such file or directory
ls: 696390: No such file or directory

解决方法

使用 -I 参数:

1
ls *.pdf | xargs -I {} ls -lh "{}"

最近在实现一个 go 云存储库的过程中,思考了比较多关于软件设计的问题,这里就简单记录一下思考的东西吧。

对于很多人来说,我也一样,工作多年了,对于实现需求这一点上没啥问题,但是写出来的代码大部分情况都是难以维护的。毕竟很多情况下,在要求的时间内把功能实现已经是一件比较艰难的事了。

这样的结果便是,代码改起来往往比较痛苦,本文就聊聊我们可以做点什么,让我们在开发的过程中不那么痛苦(当然,最简单直接的办法当然是加入某团、某滴。)。

设计原则

设计原则,也就是 SOLID(单一职责原则、开闭原则、里氏替换原则、接口隔离原则、依赖倒置原则)。我就不说里氏替换原则和接口隔离原则了,还没有深入地去实践过,没有发言权。个人观点,也许你可以不那么懂设计模式,但是这几个设计原则最好还是可以了解一下,因为这些原则离我们更加近,而设计模式往往在特定场景下才需要。

  • 单一职责原则:这里举一些反例吧
    • 最常见的不好的写法是,service 里面一个方法做完了一个请求的全部事情,比如参数校验、业务逻辑、数据库查询。 => 更好的做法也许是将数据库访问的代码从业务逻辑中抽离出去。
    • 又或者在一个方法里面混杂了不同抽象层级的代码,比如一个方法要做两件事,A、B,A 有方法封装起来了,但是 B 没有,然后我们可能会看到这样的代码,调用 A 方法,然后在当前的方法内去实现 B 的逻辑,包含了全部 B 的细节部分的内容。=> 也许更好的处理方式是,将 B 也重构为一个方法,我们在阅读的时候,可以看到清晰的逻辑 A->B->...,而不是在看完 A 之后,迷失在 B 的细节之中。
    • 做了不该做的事情,比如在处理当前业务的代码中,却中途穿插了其他业务模块的逻辑代码。 => 更好的方法也许将其他业务模块的代码从中抽取出去。
  • 开闭原则:要做到这一点,我们要在开发的时候就预留可以扩展点,主要目的是为了让后续增加功能的时候,可以尽量少改或者不改旧的代码,通过增加代码就实现新的功能。
    • 比如,Laravel 框架里面,我们可以很容易地通过 ServiceProvider 来扩展框架的功能,但是不需要对框架本身做任何修改(也就是说 Laravel 对扩展是开放的,对修改是封闭的)。
  • 依赖倒置原则:简单来说就是高层模块跟底层模块不相互依赖,两者共同依赖于抽象出来的接口。
    • 比如在 Laravel 中,我们使用 Cache 的时候,并不关心具体使用的是哪一种缓存(有可能是 redis、memcached、file),我们需要只知道 Cache 给我们提供了哪些接口就可以使用缓存这个功能了。同样的,Laravel 框架在实现缓存的 redisfile 驱动的时候,也不需要关心应用开发者是怎么使用缓存这个功能的,因为 Cache 接口已经规定了有哪些接口,缓存驱动之需要实现这些接口就足够了。
    • 因为抽象是基本不会变的,会变的是底层细节部分。这样一来,底层细节发生变动的时候,对应用开发者是完全透明的,因为应用开发者依赖的抽象接口并没有发生变动。

软件结构

窃以为,好的软件结构应该是自顶向下、逐步细化的,我们去阅读一个软件的时候,应该先看到的是其顶层设计结构里面有哪些功能模块,然后从某一个功能模块进去,我们可以看到这个功能模块里面有哪些子模块,然后到子模块的实现细节等。

在今天我们很多项目中,也许从某一个角度来说,符合这种结构。从哪个角度呢?从业务需求的角度。但现实是,业务需求之间往往有很多关联,每一个业务之间肯定不会是完全独立的,每一个业务的子模块也不是完全独立的。如果只是仅仅按照业务功能来进行软件模块的设计的话,不同业务模块之间的相互依赖最终会导致非常高的耦合产生。

好像 DDD 是关于这方面的,但是没有过太深入的了解,读者感兴趣可以自行阅读。

关于这一点,个人没有想到太多具体可行的办法,但是在编码过程中,多思考一下我们这个模块大概是什么样子的会有利于我们设计出一个结构良好的模块。

简单的接口

虽然子标题是简单,但是并不意味着要写出简单的接口也是一件简单的事情,恰恰相反,把代码写复杂是一件很简单的事,但是把代码写简单却是一件非常困难的事。

这里的简单指的是,更好的封装,这样别人使用你提供的类/接口的时候,更容易使用。

这一点可以通过一个更具体的例子来说明,就是我们的类(OOP 里的类)。

有很多人在定义一个类的时候,好像习惯性地把所有的属性、方法都设置为 public,这样写可能只有自己去使用自己开发的类的时候,问题不是很大。但是如果将这样的类提供给其他开发者使用的时候,别人很大可能没有办法在短时间内知道这个类该如何使用,因为当他们想去使用这个类的时候,发现一堆的 public 方法,根本搞不懂哪一个才能满足自己的需求。

而且,都设置为 public,当你需要去改动这一个类的时候,往往不知所措,因为有可能开发的时候,开发者并不是打算将那个方法提供给外部使用的,但是后面发现很多地方却用了。这样就会给后续维护带来很大的困难,尤其是某些弱类型语言。就算在其他地方调用了,你也可能发现不了。

更好的方法也许是,只将类中需要对外提供的方法设置为 public 就可以了,这样使用者也就只需要在少数几个方法中选出适合的那一个方法即可。不过将不该公开的东西设置为 private 算是 OOD 的基础要求了吧。

分层

一些常见的不那么好的做法是:

  • controller 里面做了数据库查询、业务逻辑处理
  • service 里面做了数据库查询
  • 除了 controller 就是 service,一个 service 做完所有的事情

窃以为,一个更好的做法也许是,controller 跟 service 分开,service 跟 db 分开等,如果尝试这么去做的话,我们会发现,有一些代码变得可以复用了。

事实就是这样,如果我们将我们一些大的类拆分成一个个功能单一的小的类或者方法之后,我们就会发现原来完全不能复用的代码,在很多地方都可以复用,而不再需要将一些重复的逻辑写在 repo 的各个地方了。

分层的例子

  • 协议栈:最常见的分层模型就是网络协议栈了,分了应用层、传输层、网络层、链路层。每一层负责不同的功能,底层模块在使用更低层模块提供的服务的同时,向上层提供新的功能。同时上层不需要关心下层的实现细节,可以专注于本层功能的实现。这样带来的好处是,我们今天网络通信中用的网络协议栈依然是几十年前设计出来的,非常的稳定。
  • web 应用:mvc 分层,但是窃以为如果在我们日常开发中,将对 db 的访问也拆分出去也许会更好,当然业内也有很多人尝试过了,就是 repository 模式。通过这种模式,我们在做单元测试的时候,也会更加方便,因为可以直接 mock 掉 db 的访问。只需要专注于业务逻辑处理。(因为 db 是非常稳定的,基本不会发生变化,我们在单元测试也要把其考虑进去的话,这会让我们变得不堪重负。)
  • MySQL:MySQL 中主要分了 Server 层和 engine 层,Server 层处理的是数据库的一些通用的逻辑,存储引擎层处理的是不同的数据存储方式,比如,MyISAM 跟 InnoDB 的存取数据的过程是不一样的,但是取出数据后如果要做一些运算,做判断等这些就一样了。

外部依赖

我们也许有时候会用到一些自己完全不了解的外部模块,而且有可能我们还很频繁地去使用,这种情况下,一旦依赖的这个外部的方法发生了改变,我们就要改动很多不同的地方。

对于这种情况,有一种方法是将这些外部的依赖都放到一个地方,这样即使外部的依赖发生了一些变更,我们只需要在一个地方修改即可。

关于这种做法,有一个更专业的名词叫做 “防腐层”。这种做法背后的逻辑其实也是很常见的一种软件设计方式,就是关注点分离,我们可以将变与不变的分离开,将不可控的部分控制在一个小范围内。

相互依赖

这里说的依赖大概是下图这种:

这个图中,不同模块相互依赖,有着复杂的依赖关系。对于这种情况,我们可能可以考虑将各模块都需要的东西抽取出来,新建一个类或者其他什么的,通过这个类来作为不同模块之间的沟通的桥梁,如下图这样:

CI - 持续集成

我们最常用的 gitlab、github 都提供了 ci 的功能,我们可以利用 ci 来帮助我们做很多工作:

  • lint:代码规范检查修正、代码格式化等。
  • 单元测试:让 ci 来完成单元测试的工作,可以让我们及时发现代码中存在的问题,及时得到反馈。同时通过提高测试覆盖率,我们可以很好地提升我们交付的软件的可靠性。而不是等到测试人员测试这一个环节再去发现问题。
  • 也可以做一些集成测试、系统测试等。

错误处理

在软件开发过程中,不可避免地会有错误的产生,不管是哪里产生的,总会有错误,如果我们在错误产生的时候,完全不知道错误是从哪一行代码产生的话,会给我们修复错误的过程带来极大的困难。

对于错误处理,个人觉得有以下几点可以做好的地方: 1. 错误定义:在类似 Java、PHP 这种语言中可以通过异常来定义一些可预期的异常,然后直接在外部捕获这些异常进行处理。 2. 错误处理:捕获到异常之后,我们可能需要通过日志来记录、然后再记录一下上下文信息,只是记录错误可能很多人都能做到,但是异常抛出的上下文对我们排查错误更有帮助。 3. 错误堆栈:这个其实也算是必需的一项了,但是有些人可能会习惯性地 catch 异常之后,只记录一下错误信息就完事了,直接不管异常堆栈,这样也是会给排查错误带来很大的困难。

重构

软件这一词本身就透露出了,它是可以修改的,而硬件不一样,想修改是不大可能的,一般都是直接换掉。

在我们开发完之后,我们依然有机会来对软件的结构等做一些调整,让其性能更好、可维护性更好等。

当然重构的前提是要保证功能不受影响,如果因为我们的重构导致程序崩溃了,或者行为不一致了,那就不叫重构了,那叫搞破坏了。

要如何保证重构之后的代码依然可以维持以前的行为呢?那就是单元测试了。这个可能让很多人会觉得有点失望,因为可能我们的代码一行测试代码也没有,因为测试也是一件比较困难的事情。

但是有时候是不得不做这件事,因为原有代码可能可读性非常差、逻辑非常混乱,仿佛你从代码中就能想象到开发者在写代码时候的那种痛苦的状态。在这种情况下,我们只有通过重构来让旧的代码变得更加易读,让其变得像是可以被修改的代码,而不是那种永远不要动的代码。不管怎样,这是我们很多人必须要面对的事实。

关于重构的,没有什么别的建议,可以看看《重构》这本书。

日志级别

其实,在我能了解的编程语言里面,都有日志库,而且都会定义不同的日志级别,但是可能我们往往就是只用到其中一个日志级别,这对于开发者来说可能并不是一件好事。

也许我们可以考虑使用一下不同的日志级别,然后在不同的情况下启用不同的日志级别,一来可以减少生产环境无效日志的数量,另外也可以针对不同情况设置不同的日志级别。

比如,我们本地就直接 debug 级别,生产就 warn 级别。

后记

好了,目前能想到的就这些了,总结下来比较重要的就三点:

  1. 关于设计其实大多都能跟 SOLID 沾点边
  2. 单元测试,但实际上要写好测试,就要先设计好代码,设计不好的代码是非常难写测试的。
  3. ci,可以通过 ci 脚本可以做一些 lint、format、test 等工作

当然,说起来简单,做起来很难。但正是在这些一次次艰难的关于如何开发高质量软件的思考与实践中,才能慢慢体会到什么情况该如何去做,才能及时发现很多不好的设计。