在现代的 web 框架里面,基本都有实现了依赖注入的功能,可以让我们很方便地对应用的依赖进行管理,同时免去在各个地方 new 对象的麻烦。比如 Laravel 里面的 Application,又或者 Java 的 Spring 框架也自带依赖注入功能。

今天我们来看看 go 里面实现依赖注入的一种方式,以 inject 库为例子(https://github.com/flamego/flamego/tree/main/inject)。

我们要了解一个软件的设计,先要看它定义了一个什么样的模型,但是在了解模型之前,我们更应该清楚了解,为什么会出现这个模型,也就是我们构建出了这个模型到底是为了解决什么问题。

依赖注入要解决的问题

我们先来看看,在没有依赖注入之前,我们需要的依赖是如何构建出来的,假设有如下 struct 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type A struct {
}

type B struct {
a A
}

type C struct {
b B
}

func test(c C) {
println("c called")
}

假设我们要调用 test,就需要创建一个 C 的实例,而创建 C 的实例需要创建一个 B 的实例,而创建 B 的实例需要一个 A 的实例。如下是一个例子:

1
2
3
4
a := A{}
b := B{a: a}
c := C{b: b}
test(c)

我们可以看到,这个过程非常的繁琐,只有一个地方需要这样调用 test 还好,如果有多个地方都需要调用 test,那我们就要做很多创建实例的操作,而且一旦实例的构建过程发生变化,我们就需要改动很多地方

所以现在的 web 框架里面一般都将这个实例化的过程固化下来,在框架的某个地方注册一些实例化的函数,在我们需要的时候就调用之前注册的实例化的函数,实例化之后,再根据需要看看是否需要将这个实例保留在内存里面,从而在免去了手动实例化的过程之外,节省我们资源的开销(不用每次使用的时候都实例化一次)。

而这里说到的固化的实例化过程,其实就是我们本文所说的依赖注入。在 Laravel 里面我们可以通过 ServiceProviderapp()->register() 或者 app()->bind() 等函数来做依赖注入的一些操作。

inject 依赖注入模型/设计

以下是 Injector 的大概模型,Injector 接口里面嵌套了 ApplicatorInvokerTypeMapper 接口,之所以这样做是出于接口隔离原则考虑,因为这三者代表了细化的三种不同功能,分离出不同的接口可以让我们的代码更加的清晰,也会更利于代码的后续演进。

  • Injector:依赖注入容器
  • Applicator:结构体注入的接口
  • Invoker:使用注入的依赖来调用函数
  • TypeMapper:类型映射,需要特别注意的是,在 Injector 里面,是通过类型来绑定依赖(不同于 Laravel 的依赖注入容器可以通过字符串命名的方式来绑定依赖,当然将 Injector 稍微改改也是可以实现的,就看有没有这种需求罢了)。
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
// 依赖注入容器
type Injector interface {
Applicator
Invoker
TypeMapper
// 上一级 Injector
SetParent(Injector)
}

// 给结构体字段注入依赖
type Applicator interface {
Apply(interface{}) error
}

// 调用函数,Invoke 的参数是被调用的函数,
// 这个函数的参数事先通过 Injector 注入,
// 调用的时候从 Injector 里面获取依赖
type Invoker interface {
Invoke(interface{}) ([]reflect.Value, error)
}

// 往 Injector 注入依赖
type TypeMapper interface {
Map(...interface{}) TypeMapper
MapTo(interface{}, interface{}) TypeMapper
Set(reflect.Type, reflect.Value) TypeMapper
Value(reflect.Type) reflect.Value
}

表示成图像大概如下:

injector

我们可以通过 InjectorTypeMapper 来往依赖注入容器里面注入依赖,然后在我们需要为结构体的字段注入依赖,又或者为函数参数注入依赖的时候,可以通过 Applicator 或者 Invoker 来实现注入依赖。

SetParent 这个方法比较有意思,它其实将 Injector 这个模型拓展了,形成了一个有父子关系的模型。在其他语言里面可能作用不是很明显,但是在 go 里面,这个父子模型恰好和 go 的协程的父子模型一致。在 go 里面,我们可以在一个协程里面再创建一个 Injector,然后在这里面定义一些在当前协程以及当前协程子协程可以用到的一些依赖,而不用影响外部的 Injector

当然上面说到的协程只是 Injector 里面 SetParent 的一种用法,另外一种用法是,我们的 web 应用往往会根据路由前缀来划分为不同的组,而这种路由组的结构组织方式其实也是一种父子结构,在这种场景下,我们就可以针对全局注入一些依赖的情况下,再针对某个路由组来注入路由组特定的依赖。

injector 的依赖注入实现

我们来看看 injector 的结构体:

1
2
3
4
5
6
type injector struct {
// 注入的依赖
values map[reflect.Type]reflect.Value
// 上级 Injector
parent Injector
}

这个结构体定义很简单,就只有两个字段,valuesparent,我们通过 TypeMapper 注入的依赖都保存在 values 里面,values 是通过反射来记录我们注入的参数类型和值的。

那我们是如何注入依赖的呢?再来看看 TypeMapperMap 方法:

1
2
3
4
5
6
func (inj *injector) Map(values ...interface{}) TypeMapper {
for _, val := range values {
inj.values[reflect.TypeOf(val)] = reflect.ValueOf(val)
}
return inj
}

我们可以看到,对于传入给 Map 的参数,这里获取了它的反射类型作为 values map 的 key,而获取了传入参数的反射值作为 values 里面 map 的值。其他的两个方法 MapToSet 也是类似的功能,最终的效果都是获取依赖的类型作为 values 的 key,依赖的值作为 values 的值

到此为止,我们知道 Injector 是如何注入依赖的了。

那么它又是如何去从依赖注入容器里面拿到我们注入的数据的呢?又是如何使用这些数据的呢?

我们再来看看 callInvoke 方法(也就是 InjectorInvoke 实现):

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
func (inj *injector) callInvoke(f interface{}, t reflect.Type, numIn int) ([]reflect.Value, error) {
// 参数切片,用来保存从 Injector 里面获取的依赖
var in []reflect.Value
// 只有 f 有参数的时候,才需要从 Injector 获取依赖
if numIn > 0 {
// 初始化切片
in = make([]reflect.Value, numIn)
var argType reflect.Type
var val reflect.Value
// 遍历 f 参数
for i := 0; i < numIn; i++ {
// 获取 f 参数类型
argType = t.In(i)
// 从 Injector 获取该类型对应的依赖
val = inj.Value(argType)
// 如果函数参数未注入,则调用出错
if !val.IsValid() {
return nil, fmt.Errorf("value not found for type %v", argType)
}

// 保存从 Injector 获取到的值
in[i] = val
}
}
// 通过反射调用 f 函数,in 是参数切片
return reflect.ValueOf(f).Call(in), nil
}

参数和返回值说明:

  • 第一个参数是我们 Invoke 的函数,这个函数的参数,都会通过 Injector 根据函数参数类型获取
  • 第二个参数 f 的反射类型,也就是 reflect.TypeOf(f)
  • 第三个参数是 f 的参数个数
  • 返回值是 reflect.Value 切片,如果我们在调用过程出错,返回 error

在这个函数中,会通过反射来获取 f 的参数类型(reflect.Type),拿到这个类型之后,从 Injector 里面获取我们之前注入的依赖,这样我们就可以拿到所有参数对应的值。最后,通过 reflect.ValueOf(f) 来调用 f 函数,参数是我们从 Injector 获取到的值的切片。调用之后,返回函数调用结果,一个 reflect.Value 切片。

当然,这只是其中一种使用依赖的方式,另外一种方式也比较常见,就是为结构体注入依赖,这跟 hyperf 里面通过注释注解又或者 Spring 里面的注入方式有点类似。在 Injector 里面是通过 Apply 来为结构体字段注入依赖的:

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
38
39
40
41
42
43
44
45
// 参数 val 是待注入依赖的结构体
func (inj *injector) Apply(val interface{}) error {
v := reflect.ValueOf(val)

// 获取底层元素
for v.Kind() == reflect.Ptr {
v = v.Elem()
}

// 底层类型不是结构体则返回
if v.Kind() != reflect.Struct {
return nil // Should not panic here ?
}

// v 的反射类型
t := v.Type()

// 遍历结构体的字段
for i := 0; i < v.NumField(); i++ {
// 获取第 i 个结构体字段
// v 的类型是 reflect.Value
// v.Field 返回的是结构体字段的值
f := v.Field(i)
// t 的类型是 *reflect.rtype
// t.Field 返回的是 reflect.Type,是类型信息
structField := t.Field(i)
// 检查是否有 inject tag,有这个 tag 才会进行依赖注入
_, ok := structField.Tag.Lookup("inject")
// 字段支持反射设置,并且存在 inject tag 才会进行注入
if f.CanSet() && ok {
// 通过反射类型从 Injector 中获取对应的值
ft := f.Type()
v := inj.Value(ft)
// 获取不到注入的依赖,则返回错误
if !v.IsValid() {
return fmt.Errorf("value not found for type %v", ft)
}

// 设置结构体字段值
f.Set(v)
}

}
return nil
}

简单来说,Injector 里面,通过 TypeMapper 来注入依赖,然后通过 Apply 或者 Invoke 来使用注入的依赖。

例子

还是以一开始的例子为例,通过依赖注入的方式来改造一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
a := A{}
b := B{a: a}
c := C{b: b}

// 新建依赖注入容器
inj := injector{
values: make(map[reflect.Type]reflect.Value),
}
// 注入依赖 c
inj.Map(c)
// 调用函数 test,test 的参数 `C` 会通过依赖注入容器获取
_, _ = inj.Invoke(test)
// 输出 "c called"

这个例子中,我们通过 inj.Map 来注入了依赖,在后续通过 inj.Invoke 来调用 test 函数的时候,将会从依赖注入容器里面获取 test 的参数,然后将这些参数传入 test 来调用。

这个例子也许比较简单,但是如果我们很多地方都需要用到 C 这个参数的话,我们通过 inj.Invoke 的方式来调用函数就可以避免每一次调用都要实例化 C 的繁琐操作了。

在看一些源码的过程中,发现一些如下的写法:

1
2
// var _ Interface = (*Type)(nil)
var _ FastInvoker = (*LoggerInvoker)(nil)

其中 FastInvoker 是一个 interface,而 LoggerInvoker 实现了 FastInvoker 的方法。

这样看起来没什么作用,但是却可以帮助我们在编译期就发现 LoggerInvoker 是否实现了 FastInvoker 接口, 因为我们这样写了之后,编译器会去检查,如果 LoggerInvoker 没有实现 FastInvoker 就会报错了。

这样我们就可以在编译的时候发现代码潜在的问题。

类型和接口

Go 是静态类型语言。每一个变量都有一个静态的类型,即在编译时类型已知且固定:比如 intfloat32

接口类型

接口类型是类型的一个重要类别,它表示固定的方法集。接口变量可以存储任何具体值(非接口),只要该值实现接口的方法即可。如:

1
2
3
4
5
6
7
8
9
// Reader 是封装基本 Read 方法的接口
type Reader interface {
Read(p []byte) (n int, err error)
}

// Writer 是封装基本 Write 方法的接口
type Writer interface {
Write(p []byte) (n int, err error)
}

任何实现了 Read(p []byte) (n int, err error) 方法的类型都被称为实现了 Reader 接口(Writer 同理)。这意味着 Reader 可以保存实现了 Read 方法的任何值:

1
2
3
4
var r io.Reader
r = os.Stdin
r = bufio.NewReader(r)
r = new(bytes.Buffer)

需要明确的是,不管 r 可能包含什么具体值,r 的类型始终是 io.Reader:Go 是静态类型的语言,而 r 的静态类型是 io.Reader

空接口

接口类型的一个非常重要的示例是空接口:

1
interface{}

它表示空的方法集,并且任何值都满足空接口,因为任何值都有零个或者多个方法。

有人说 Go 的接口是动态类型的,但这会产生误导。接口是静态类型的:接口类型的变量始终具有相同的类型,即使在运行时存储在接口变量中的值可能会更改类型,该值也始终满足接口的要求。

接口的表示形式

接口类型的变量存储了一对值:分配给该变量的具体值,以及该值的类型描述。更确切地说,该值是实现接口的基础具体数据项,而类型描述了该数据项的完整类型。例如:

1
2
3
4
5
6
var r io.Reader
tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
if err != nil {
return nil, err
}
r = tty

r 中包含了 (value, type) 对,即 (tty, *os.File)。请注意,类型 *os.File 实现的方法不只有 Read; 尽管接口仅提供对 Read 方法的访问,但是其内部的值仍包含有关该值的所有类型信息。这就是为什么我们可以做下面的事情:

1
2
var w io.Writer
w = r.(io.Writer)

因为 r 的具体类型里面包含了 Write 方法,而 r 里面包含的值依然持有它原来的值,所以这个断言是没有问题的。

一个重要的细节是,接口内始终保存 (值, 具体类型) 形式的元素对,而不会有 (值, 接口类型) 的形式。接口内部不持有接口值。

反射

反射第一定律:从接口值反射出反射对象

反射对象主要有两类:reflect.Typereflect.Value

从底层讲,反射只是一种检查存储在接口变量中的值和类型对的机制。首先,我们需要了解反射包的两个类型:TypeValue, 通过这两个类型可以访问接口变量的内容。还有两个函数 reflect.TypeOfreflect.ValueOf,它们可以从接口值中取出 reflect.Typereflect.Value。(另外,从 reflect.Value 可以很容易地获取到 reflect.Type,但是让我们暂时将 ValueType 的概念分开。)

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"fmt"
"reflect"
)

func main() {
var x float64 = 3.4
// 打印 type: float64
fmt.Println("type:", reflect.TypeOf(x))
}

上面的代码看起来像将 float64 类型的变量 x 传递给了 reflect.TypeOf,而不是传递的接口值。但实际上,传递的是接口;

1
2
// TypeOf 返回 interface{} 中值的反射类型
func TypeOf(i interface{}) Type

当我们调用 reflect.TypeOf(x) 时,x 先被存在一个空接口中,然后再作为参数传递;reflect.TypeOf 从该空接口中恢复类型信息。

相应的,reflect.ValueOf 函数会恢复值信息。

1
2
3
var x float64 = 3.4
// value: <float64 Value>
fmt.Println("value:", reflect.ValueOf(x).String())

reflect.Typereflect.Value 都有许多方法可以让我们执行检查和操作: * Value 具有 Type 方法,该方法返回 reflect.ValueType 类型。 * TypeValue 都有一个 Kind 方法,该方法返回 go 的类型(语言本身的类型,而不是自定义的类型) * Value 的很多方法,名字类似于 IntFloat64,可以让我们获取存储在里面的值。 * 还有诸如 SetIntSetFloat 之类的方法,可以修改接口的值。

反射第二定律:从反射对象到接口值

给定 reflect.Value,我们可以使用 Interface() 方法恢复接口值;

实际上,该方法将类型和值信息打包回接口表示形式并返回结果:

1
2
//接口返回v的值作为接口{}。
func (v Value) Interface() interface{}

结果,我们可以说

1
2
y := v.Interface().(float64) // y的类型为float64
fmt.Println(y)

打印反射对象 v 表示的 float64 值。一种更简洁的写法是:

1
2
// fmt.Println 本身就接受 interface{} 参数
fmt.Println(y)

反射第三定律:要修改反射对象,该值必须可设置

不可设置的例子:

1
2
3
4
var x float64 = 3.4
v:= reflect.ValueOf(x)
// panic: reflect.Value.SetFloat using unaddressable value
v.SetFloat(7.1)//错误:会panic错误。

因为调用 reflect.ValueOf(x) 的时候,函数只拿到了 x 的副本,而不是 x 变量本身,如果我们在函数内部修改了 x 那也只是修改了副本而已。

ValueCanSet 方法报告 Value 的可设置性:

1
2
3
4
var x float64 = 3.4
v:= reflect.ValueOf(x)
// false
fmt.Println("settability of v:", v.CanSet())

如果我们想修改它,可以在反射的时候,直接使用 x 的指针:

1
2
3
4
5
6
var x float64 = 3.4
p := reflect.ValueOf(&x) // 注意:取 x 的地址
// type of p: *float64
// settability of p: false
fmt.Println("type of p:", p.Type())
fmt.Println("settability of p:", p.CanSet())

我们注意到,这里我们使用了指针,但依然是不能设置其值。这是因为反射对象 p 是不可设置的,实际上我们想要设置的不是 p,而是 *p。为了获取 p 指向的内容,我们调用 Value 值的 Elem 方法,该方法指向指针:

1
2
3
v := p.Elem()
// settability of v: true
fmt.Println("settability of v:", v.CanSet())

现在,v 是一个可设置的反射对象了,我们可以使用 v.SetFloat 来修改 x 的值了:

1
2
3
4
5
v.SetFloat(7.1)
// 7.1
fmt.Println(v.Interface())
// 7.1
fmt.Println(x)

反射值需要变量的地址才能修改其表示的值。

结构体

在下面的例子中,我们使用结构体的地址创建反射对象,因为稍后将要对其进行修改。然后我们将 typeOfT 设置为其反射类型, 并使用简单的方法调用对字段进行迭代。

1
2
3
4
5
6
7
8
9
10
11
12
type T struct {
A int
B string
}
t := T{23, "skidoo"}
s := reflect.ValueOf(&t).Elem()
typeOfT := s.Type()
for i := 0; i < s.NumField(); i++ {
f := s.Field(i)
fmt.Printf("%d: %s %s = %v.", i,
typeOfT.Field(i).Name, f.Type(), f.Interface())
}
1
2
0: A int = 23
1: B string = skidoo

此处传递的内容还涉及可设置性的另一点:T 的字段名是大写(已导出),因为只能设置结构体的导出字段。

因为 s 包含可设置的反射对象,所以我们可以修改结构的字段:

1
2
3
s.Field(0).SetInt(77)
s.Field(1).SetString("Sunset Strip")
fmt.Println("t is now", t)

如果我们修改代码从 t 而不是 &t 创建 s,则对 SeteIntSetString 的调用将失败,因为无法设置 t 的字段。

结论

反射定律:

  • 反射可以从接口值到反射对象
  • 反射可以从反射对象到接口值
  • 要修改反射对象,该值必须可设置。

参考文档

环境:MySQL 5.7

现象

假设有一张表,结构如下:

1
2
3
CREATE TABLE `test` (
`a` varchar(255) CHARACTER SET utf8 COLLATE utf8_unicode_ci DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

然后插入两条数据(一条是中文括号,一条是英文括号):

1
2
INSERT INTO `test` (`a`) VALUES ('(甲)');
INSERT INTO `test` (`a`) VALUES ('(甲)');

然后大家觉得下面的语句结果如何?

1
SELECT * FROM test GROUP BY a

是不是觉得应该有两条,像下面的一样:

1
2
3
4
5
6
7
8
mysql> SELECT * FROM test;
+-----------+
| a |
+-----------+
| (甲) |
| (甲) |
+-----------+
2 rows in set (0.01 sec)

但实际上只有一条:

1
2
3
4
5
6
+-----------+
| a |
+-----------+
| (甲) |
+-----------+
1 row in set (0.01 sec)

也就是说,对于 MySQL 来说,"(甲)""(甲)" 是一样的。

但是我们明确知道,这里其中一个是中文的括号,另一个是英文的括号。

原因

utf8_unicode_ci 下,MySQL 在比较的时候,"(""(" 是一样的:

参考链接:https://stackoverflow.com/a/6602382/6048782

1
2
3
4
5
6
7
mysql> select _utf8"(" collate utf8_unicode_ci = _utf8"(" collate utf8_unicode_ci;
+-----------------------------------------------------------------------+
| _utf8"(" collate utf8_unicode_ci = _utf8"(" collate utf8_unicode_ci |
+-----------------------------------------------------------------------+
| 1 |
+-----------------------------------------------------------------------+
1 row in set (0.06 sec)

解决办法

使用 utf8mb4 字符集,因为:

1
2
3
4
5
6
7
mysql> select _utf8mb4"(" collate utf8mb4_general_ci = _utf8mb4"(" collate utf8mb4_general_ci;
+-----------------------------------------------------------------------------------+
| _utf8mb4"(" collate utf8mb4_general_ci = _utf8mb4"(" collate utf8mb4_general_ci |
+-----------------------------------------------------------------------------------+
| 0 |
+-----------------------------------------------------------------------------------+
1 row in set (0.01 sec)

也就是说,在 utf8mb4_general_ci 这种字符集下,MySQL 在进行比较的时候才能正确判断中英文括号。

修改字段的 SQL 语句

1
alter table tbl modify col varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci

插入数据

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

  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 的行锁是针对索引加的锁,不是针对记录加的锁,并且该索引不能失效,否则会从行锁升级为表锁。

0%