RussellLuo

让思想在文字间徜徉

一、自动化测试的重要性

公理:要正确交付软件代码,测试是必不可少的。

1. 测试分类

  • 手动测试
  • 自动化测试

2. 何时采用何种测试?

  • 一次性使用的代码
    • 示例:临时脚本或工具
    • 采用手工测试
  • 需要维护的代码
    • 示例:业务代码
      • 警惕陷阱:这个需求后面不会改,先手动测试吧
    • 采用自动化测试
      • 手工测试低效、重复、乏味
      • 自动化测试可以充当代码变更的保护网

二、测试金字塔

Test-Pyramid

1. 核心原则

  • 编写不同粒度的测试
  • 层次越高,编写的测试应该越少

2. 反模式

三、单元测试

1. Why

为什么要多写单元测试?

  • 规模小(关注点聚焦,更容易编写)
  • 执行快(最小化依赖,运行成本低)

2. What

何为一个单元?

  • 面向过程/函数式:一个函数
  • 面向对象:一个方法、一个类

群居和独居

  • 独居:隔离所有外部依赖
  • 群居:只隔离那些执行慢或者副作用大的依赖(比如数据库、外部服务等)

3. How

测试什么?

  • 通常只测试一个类的公共方法
  • 如果一个类复杂到需要测试它的私有方法
    • 考虑从设计上拆分这个类,把这些私有方法变成另一个类的公共方法

测试结构(Arrange-Act-Assert 或者 Given-When-Then

  • 准备测试数据
  • 调用被测方法
  • 断言返回的是期待的结果

四、集成测试

测试应用和所有外部依赖的集成。

常见的外部依赖有:

  • 数据库(如 MySQL、Redis、Elasticsearch)
  • 消息队列(如 Kafka)
  • 外部服务(如 SendCloud、S3)
    • 进行契约测试
      • 编写消费方测试
      • 同时为外部服务编写提供方测试?
        • 通常不需要。因为成熟的服务提供方往往会对 API 做版本控制;而且在废弃旧版本 API 之前,也会通知到服务消费方。

五、契约测试

1. 不适用场景

公共服务的提供方和消费方之间。

2. 适用场景

内部微服务的提供方和消费方之间。

提供方和消费方之间的通信方式,常见的有:

  • RPC 接口
    • HTTP/JSON
    • gRPC
  • 异步事件

3. 契约测试的特征

  • 消费方编写消费方测试,并生成一个协议文件(PactJSON 示例
  • 提供方根据协议文件,编写提供方测试

六、UI 测试

前后端分离的架构下,UI 测试可以是:

  • 纯前端 UI 测试
  • 后端 API 集成测试(cURL 和 Postman 测试的自动化版本)

七、避免重复测试

1. 测试成本

  • 编写和维护测试要花时间
  • 阅读和理解他人的测试也要花时间
  • 执行这些测试也要花时间

2. 基本法则

  • 如果一个更高层级的测试发现了一个错误,并且底层测试全都通过了,那么应该编写一个低层级测试去覆盖这个错误
  • 竭尽所能把测试往金字塔下层赶
  • 删掉那些已经被低层级测试覆盖完全的高层级测试
    • 警惕陷阱沉没成本(不忍删除花了时间精力编写的测试)

八、相关阅读

一、由 iter 包引发的疑问

最近在 GitHub 上偶然发现了 Brad Fitzpatrick 的 iter 包,整个包只有 一个函数(一行代码):

1
2
3
func N(n int) []struct{} {
return make([]struct{}, n)
}

但其中的一行注释令人费解:

It does not cause any allocations.

1. 空结构体

我们知道,struct{} 是空结构体(empty struct)。关于空结构体,Dave Cheney 在 The empty struct 中作了很好地阐述:

  • 空结构体不占用空间(The empty struct consumes no storage)。
  • 空结构体的切片只占用切片头的空间(Slices of struct{}s consume only the space for their slice header)。

2. Go 切片

按照官方博客 Go Slices: usage and internals 的说法:

A slice is a descriptor of an array segment. It consists of a pointer to the array, the length of the segment, and its capacity (the maximum length of the segment).

因为切片总是指向一个底层数组的,所以所谓的 “切片头” 其实就是切片本身。一个切片包括:指向数组片段的指针、数组片段的长度和最大长度,总共 3 个字长(在 64 位机器上,就是 24 个字节)。

3. 疑问

按照上面的分析,在 64 位机器上,不管 n 是多少,make([]struct{}, n) 得到的切片一定会占用 24 个字节,reddit 上的讨论 也证实了我们的分析。

那为什么 Brad Fitzpatrick 声称函数 N 不会引发分配呢?

为了解决这个疑惑,我们需要先弄清楚两个问题:

  1. 一个 Go 变量可能会被分配在哪里?
  2. 如何确定一个 Go 变量最终会被分配在哪里?

二、Go 变量可能的分配位置

1. 进程的内存布局

在 Linux/x86-32 系统中,一个进程的典型的内存布局如下图所示(图片来自 The Linux Programming Interface 图 6-1):

typical-memory-layout-of-a-process

结合维基百科对 Data segment 的描述,我们得知:

  • 初始化的全局变量或静态变量,会被分配在 Data 段。
  • 未初始化的全局变量或静态变量,会被分配在 BSS 段。
  • 在函数中定义的局部变量,会被分配在堆(Heap 段)或栈(Stack 段)。

2. Go 内存分配

对于 Go 而言,有两个地方可以用于分配:

  • 堆(heap)
    • GC 负责回收。
    • 对应于进程地址空间的堆。
  • 栈(stack)
    • 不涉及 GC 操作。
    • 每个 goroutine 都有自己的栈,初始时被分配在进程地址空间的栈上,扩容时被分配在进程地址空间的堆上。

Go 变量主要分为两种:

  • 全局变量
    • 会被 Go 编译器标记为一些特殊的 符号类型,分配在堆上还是栈上目前尚不清楚,不过不是本文讨论的重点。
  • 局部变量

所以综上,对于在函数中定义的 Go 局部变量:要么被分配在堆上,要么被分配在栈上

三、确定 Go 变量最终的分配位置

至此,我们还剩下一个问题:对于一个 Go 局部变量,如何确定它被分配在堆上还是栈上?

按照官方 FAQ How do I know whether a variable is allocated on the heap or the stack? 的解释:

  • Go 编译器会尽可能将变量分配在栈上
  • 以下两种情况,Go 编译器会将变量分配在堆上
    • 如果一个变量被取地址(has its address taken),并且被逃逸分析(escape analysis)识别为 “逃逸到堆”(escapes to heap)
    • 如果一个变量很大(very large)

1. 逃逸分析

以使用 iter 包的这段代码为例:

1
2
3
4
5
6
7
package main

import "github.com/bradfitz/iter"

func main() {
for range iter.N(4) {}
}

下列演示中,我将使用 Go 1.11.4:

1
2
$ go version
go version go1.11.4 darwin/amd64

下面我们对这段代码作逃逸分析:

1
2
3
4
5
6
$ go build -gcflags='-m -m' examples/go_mem/main.go
# command-line-arguments
examples/go_mem/main.go:5:6: cannot inline main: unhandled op RANGE
examples/go_mem/main.go:6:18: inlining call to iter.N func(int) []struct {} { return make([]struct {}, iter.n) }
examples/go_mem/main.go:6:18: make([]struct {}, iter.n) escapes to heap
examples/go_mem/main.go:6:18: from make([]struct {}, iter.n) (non-constant size) at ./main.go:6:18

按照前面的分析,从 “make([]struct {}, iter.n) escapes to heap” 的信息,我们推断:make([]struct {}, iter.n) 会被分配在堆上。

到这里,我们最初的疑惑似乎已经有了答案:make([]struct {}, iter.n) 一定会引发堆分配,那是 Brad Fitzpatrick 的注释写错了吗?

2. 内存分配器追踪

除了逃逸分析,Go 还提供了一种叫内存分配器追踪(Memory Allocator Trace)的方法,用于细粒度地分析由程序引发的所有堆分配(和释放)操作:

1
$ GODEBUG=allocfreetrace=1 go run examples/go_mem/main.go 2>&1 | grep -C 10 examples/go_mem

因为进行内存分配器追踪时,很多由 runtime 引发的分配信息也会被打印出来,所以我们用 grep 进行过滤,只显示由用户代码(user code)引发的分配信息。然而这里的输出结果为空,表明 make([]struct {}, iter.n) 没有引发任何堆分配。

内存分配器追踪的结论与逃逸分析的结论截然相反!那到底哪个结论是对的呢?

3. 汇编分析

黔驴技穷之际,Go’s Memory Allocator - Overview 这篇文章给了我提示:

So, we know that i is going to be allocated on the heap. But how does the runtime set that up? With the compiler’s help! We can get an idea from reading the generated assembly.

关于 Go 汇编(assembly),推荐大家阅读 Go internals, Chapter 1: Go assembly

下面我们来看看示例代码对应的汇编:

1
2
3
4
5
6
7
8
9
$ go tool compile -I $GOPATH/pkg/darwin_amd64 -S examples/go_mem/main.go
...
0x001d 00029 (examples/go_mem/main.go:6) LEAQ type.struct {}(SB), AX
0x0024 00036 (examples/go_mem/main.go:6) PCDATA $2, $0
0x0024 00036 (examples/go_mem/main.go:6) MOVQ AX, (SP)
0x0028 00040 (examples/go_mem/main.go:6) MOVQ $4, 8(SP)
0x0031 00049 (examples/go_mem/main.go:6) MOVQ $4, 16(SP)
0x003a 00058 (examples/go_mem/main.go:6) CALL runtime.makeslice(SB)
...

可以看到,其中有一处对 runtime.makeslice(SB) 的调用,显然是由 make([]struct{}, n) 引发的。

查看 runtime.makeslice 的源码:

1
2
3
4
5
func makeslice(et *_type, len, cap int) slice {
...
p := mallocgc(et.size*uintptr(cap), et, true)
return slice{p, len, cap}
}

其中,mallocgc 的源码如下:

1
2
3
4
5
6
7
8
9
10
11
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size == 0 {
return unsafe.Pointer(&zerobase)
}
...
if debug.allocfreetrace != 0 {
tracealloc(x, size, typ)
}
...
}

slice 对应的结构体如下:

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

结合上述几段源码,我们可以看出:

  • makeslice 函数中:slice 结构体正是我们在第一节提到的 Go 切片 —— array 是指向数组片段的指针,len 是数组片段的长度,cap 是数组片段的最大长度。
  • makeslice 函数中:array 的值来自 p,而 p 则是一个指针,它指向由 mallocgc 分配得到的底层数组。
  • mallocgc 函数中:因为空结构体的 size 为 0,所以 mallocgc 并没有实际进行堆分配;由于没有执行到 tracealloc 的地方,所以进行内存分配器追踪时,不会采集到相关的分配信息。
  • makeslice 函数中:切片 slice 本身是以结构体的形式返回的,所以只会被分配在栈上。

四、总结

经过一系列的探索和分析,至此,我们可以得出以下结论:

  • make([]struct{}, n) 只会被分配在栈上,而不会被分配在堆上。
  • Brad Fitzpatrick 的注释是对的,并且他的意思是 “不会引发堆分配”。
  • 逃逸分析识别出 escapes to heap,并不一定就是堆分配,也可能是栈分配。
  • 进行内存分配器追踪时,如果采集不到堆分配信息,那一定只有栈分配。

最后,我们来解答文章标题提出的疑问 —— 如何确定一个 Go 变量会被分配在哪里?对此,我们的答案是:

  1. 先对代码作逃逸分析
    • 如果该变量被识别为 escapes to heap,那么它十有八九是被分配在堆上。
    • 如果该变量被识别为 does not escape,或者没有与之相关的分析结果,那么它一定是被分配在栈上。
  2. 如果对 escapes to heap 心存疑惑,就对代码作内存分配器追踪
    • 如果有采集到与该变量相关的分配信息,那么它一定是被分配在堆上。
    • 否则,该变量一定是被分配在栈上。
  3. 此外,如果想知道 Go 编译器是如何将变量分配在堆上或者栈上的,可以去分析 Go 汇编(以及 runtime 源码)

五、思考题

  • 如果换成 make([]int, n),结果还会是栈分配吗?
  • 如果换成 make([]int, 4) 呢?
  • 除了空结构体 make([]struct{}, n) 的特例,还有哪些 “被逃逸分析识别为 escapes to heap,但其实是栈分配” 的案例?
  • Go 支持闭包(closure),那么闭包中的变量,又是分配在哪里的?(Where are variables in a closure stored - stack or heap? 说是分配在栈上,对于 Go 也是成立的吗?)

六、相关阅读

一、引言

最近在工作中负责制定重构计划,需要将部分业务代码从 Python 迁移到 Golang。其中一些功能涉及到 Celery 延时任务,所以一直在思考 Golang 中处理延时任务的有效方案。

其实在软件系统中,“在一段时间后执行一个任务” 的需求比比皆是。比如:

  • 客户端发起 HTTP 请求后,如果在指定时间内没有收到服务器的响应,则自动断开连接。

为了实现上述功能,通常我们会使用定时器 Timer:

  1. 客户端发起请求后,立即创建(启动)一个 Timer:到期间隔为 d,到期后执行 “断开连接” 的操作。
  2. 如果到期间隔 d 以内收到了服务器的响应,客户端就删除(停止)这个 Timer。
  3. 如果一直没有收到响应,则 Timer 最终会到期,然后执行 “断开连接” 的操作。

Golang 内置的 Timer 是采用最小堆来实现的,创建和删除的时间复杂度都为 O(log n)。现代的 Web 服务动辄管理 100w+ 的连接,每个连接都会有很多超时任务(比如发送超时、心跳检测等),如果每个超时任务都对应一个 Timer,性能会比较低下。

论文 Hashed and Hierarchical Timing Wheels 提出了一种用于实现 Timer 的高效数据结构:时间轮。采用时间轮实现的 Timer,创建和删除的时间复杂度为 O(1)。

常见的时间轮实现有两种:

  • 简单时间轮(Simple Timing Wheel)—— 比如 Netty4 的 HashedWheelTimer
  • 层级时间轮(Hierarchical Timing Wheels)—— 比如 Kafka 的 Purgatory

参考 Kafka 的层级时间轮实现(基于 Java/Scala 语言),我依葫芦画瓢实现了一个 Golang 版本的层级时间轮,实现源码作为个人项目放到了 GitHub

下面我们来看看简单时间轮、层级时间轮、Kafka 的层级时间轮变体的实现原理,以及 Golang 实现中的一些要点。

二、简单时间轮

一个 简单时间轮 就是一个循环列表,列表中的每一格包含一个定时任务列表(双向链表)。一个时间单位为 u、大小为 n 的简单时间轮,可以包含的定时任务的最大到期间隔为 n*u。

以 u 为 1ms、n 为 3 的简单时间轮为例,可以包含的定时任务的最大到期间隔为 3ms。

simple-timing-wheel

如上图所示,该简单时间轮的运行原理如下:

  1. 初始时,假设当前时间(蓝色箭头)指向第 1 格(此时:到期间隔为 [0ms, 1ms) 的定时任务放第 1 格,[1ms, 2ms) 的放第 2 格,[2ms, 3ms) 的放第 3 格)。
  2. 此时我们创建一个到期间隔为 1ms 的定时任务 task1,按规则该任务会被插入到第 2 格。
  3. 随着时间的流逝,过了 1ms 后当前时间指向第 2 格,这一格包含的定时任务 task1 会被删除并执行。
  4. 当前时间指向第 2 格(此时:到期间隔为 [0ms, 1ms) 的定时任务放第 2 格,[1ms, 2ms) 的放第 3 格,[2ms, 3ms) 的放第 1 格),我们继续创建一个到期间隔为 2ms 的定时任务 task2,按规则该任务被插入到第 1 格。

简单时间轮的优点是实现简单,缺点是:

  • 一旦选定 n,就不能包含到期间隔超过 n*u 的定时任务。
  • 如果定时任务的到期时间跨度较大,就会选择较大的 n,在定时任务较少时会造成很大的空间浪费。

有一些简单时间轮的 变体实现,它们通过在定时任务中增加记录 round 轮次信息,可以有效弥补上述两个缺点。同样以上面 u 为 1ms、n 为 3 的简单时间轮为例,初始时间指向第 1 格;此时如果要创建到期时间为 4ms 的定时任务,可以在该任务中设置 round 为 1(4/3 取整),剩余到期时间用 4ms 减去 round*3ms 等于 1ms,因此放到第 2 格;等到当前时间指向第 2 格时,判断任务中的 round 大于 0,所以不会删除并执行该任务,而是对其 round 减一(于是 round 变为 0);等到再过 3ms 后,当前时间再次指向第 2 格,判断任务中的 round 为 0,进而删除并执行该任务。

然而,这些变体实现因为只使用了一个时间轮,所以仍然存在一个缺点:处理每一格的定时任务列表的时间复杂度是 O(n),如果定时任务数量很大,分摊到每一格的定时任务列表就会很长,这样的处理性能显然是让人无法接受的。

三、层级时间轮

层级时间轮 通过使用多个时间轮,并且对每个时间轮采用不同的 u,可以有效地解决简单时间轮及其变体实现的问题。

参考 Kafka 的 Purgatory 中的层级时间轮实现:

  • 每一层时间轮的大小都固定为 n,第一层时间轮的时间单位为 u,那么第二层时间轮(我们称之为第一层时间轮的溢出时间轮 Overflow Wheel)的时间单位就为 n*u,以此类推。
  • 除了第一层时间轮是固定创建的,其他层的时间轮(均为溢出时间轮)都是按需创建的。
  • 原先插入到高层时间轮(溢出时间轮)的定时任务,随着时间的流逝,会被降级重新插入到低层时间轮中。

以 u 为 1ms、n 为 3 的层级时间轮为例,第一层时间轮的时间单位为 1ms、大小为 3,第二层时间轮的时间单位为 3ms、大小为 3,以此类推。

hierarchical-timing-wheels

如上图所示,该层级时间轮的运行原理如下:

  1. 初始时,只有第一层(Level 1)时间轮,假设当前时间(蓝色箭头)指向第 1 格(此时:到期间隔为 [0ms, 1ms) 的定时任务放第 1 格,[1ms, 2ms) 的放第 2 格,[2ms, 3ms) 的放第 3 格)。
  2. 此时我们创建一个到期间隔为 2ms 的定时任务 task1,按规则该任务会被插入到第一层时间轮的第 3 格。
  3. 同一时刻,我们再次创建一个到期间隔为 4ms 的定时任务 task2,因为到期间隔超过了第一层时间轮的间隔范围,所以会创建第二层(Level 2)时间轮;第二层时间轮中的当前时间(蓝色箭头)也指向第 1 格,按规则该任务会被插入到第二层时间轮的第 2 格。
  4. 随着时间的流逝,过了 2ms 后,第一层时间轮中的当前时间指向第 3 格,这一格包含的任务 task1 会被删除并执行;此时,第二层时间轮的当前时间没有变化,依然指向第 1 格。
  5. 随着时间的流逝,又过了 1ms 后,第一层时间轮中的当期时间指向第 1 格,这一格中没有任务;此时,第二层当前时间指向第 2 格,这一格包含的任务 task2 会被删除并重新插入时间轮,因为剩余到期时间为 1ms,所以 task2 会被插入到第一层时间轮的第 2 格。
  6. 随着时间的流逝,又过了 1ms 后,第一层时间轮中的当前时间指向第 2 格,这一格包含的定时任务 task2 会被删除并执行;此时,第二层时间轮的当前时间没有变化,依然指向第 2 格。

四、Kafka 的变体实现

在具体实现层面(参考 Kafka Timer 实现源码),Kafka 的层级时间轮与上面描述的原理有一些差别。

1. 时间轮表示

kafka-implementation-timing-wheel-representation

如上图所示,在时间轮的表示上面:

  • 使用大小为 wheelSize 的数组来表示一层时间轮,其中每一格是一个 bucket,每个 bucket 的时间单位为 tick。
  • 这个时间轮数组并没有模拟循环列表的行为(如图左所示),而是模拟了哈希表的行为。具体而言(如图右所示),这个时间轮数组会随着 currentTime 的流逝而移动,也就是说 currentTime 永远是指向第一个 bucket 的,每个落到该时间轮的定时任务,都会根据哈希函数 (expiration/tick)%wheelSize 散列到对应的 bucket 中(参考 源码)。

2. 时钟驱动方式

常规的时间轮实现中,会在一个线程中每隔一个时间单位 tick 就醒来一次,并驱动时钟走向下一格,然后检查这一格中是否包含定时任务。如果时间单位 tick 很小(比如 Kafka 中 tick 为 1ms)并且(在最低层时间轮的)定时任务很少,那么这种驱动方式将会非常低效。

Kafka 的层级时间轮实现中,利用了 Java 内置的 DelayQueue 结构,将每一层时间轮中所有 “包含有定时任务的 bucket” 都加入到同一个 DelayQueue 中(参考 源码),然后 等到有 bucket 到期后再驱动时钟往前走(参考 源码),并逐个处理该 bucket 中的定时任务(参考 源码)。

kafka-implementation-clock-driving-method

如上图所示:

  1. 往层级时间轮中添加一个定时任务 task1 后,会将该任务所属的 bucket2 的到期时间设置为 task1 的到期时间 expiration(= 当前时间 currentTime + 定时任务到期间隔 duration),并将这个 bucket2 添加(Offer)到 DelayQueue 中。
  2. DelayQueue(内部有一个线程)会等待 “到期时间最早(earliest)的 bucket” 到期,图中等到的是排在队首的 bucket2,于是经由 poll 返回并删除这个 bucket2;随后,时间轮会将当前时间 currentTime 往前移动到 bucket2 的 expiration 所指向的时间(图中是 1ms 所在的位置);最后,bucket2 中包含的 task1 会被删除并执行。

上述 Kafka 层级时间轮的驱动方式是非常高效的。虽然 DelayQueue 中 offer(添加)和 poll(获取并删除)操作的时间复杂度为 O(log n),但是相比定时任务的个数而言,bucket 的个数其实是非常小的(也就是 O(log n) 中的 n 很小),因此性能也是没有问题的。

五、Golang 实现要点

timingwheel 中的 Golang 实现,基本上都是参考 Kafka 的层级时间轮的原理来实现的。

因为 Golang 中没有现成的 DelayQueue 结构,所以自己实现了一个 DelayQueue,其中:

  • PriorityQueue —— 从 NSQ 借用过来的 优先级队列(基于最小堆实现)。
  • DelayQueue —— Offer(添加 bucket)和 Poll(获取并删除 bucket)的运作方式,跟 Golang Timer 运行时中 addtimerLockedtimerproc 的运作方式如出一辙,因此参考了其中的实现方式(参考 原理介绍)。

六、相关阅读

一、标准 Timer 的问题

以下讨论只针对由 NewTimer 创建的 Timer,因为这种 Timer 会使用 channel 来传递到期事件,而正确操作 channel 并非易事。

Timer.Stop

按照 Timer.Stop 文档 的说法,每次调用 Stop 后需要判断返回值,如果返回 false(表示 Stop 失败,Timer 已经在 Stop 前到期)则需要排掉(drain)channel 中的事件:

1
2
3
if !t.Stop() {
<-t.C
}

但是如果之前程序已经从 channel 中接收过事件,那么上述 <-t.C 就会发生阻塞。可能的解决办法是借助 select 进行 非阻塞 排放(draining):

1
2
3
4
5
6
if !t.Stop() {
select {
case <-t.C: // try to drain the channel
default:
}
}

但是因为 channel 的发送和接收发生在不同的 goroutine,所以 存在竞争条件(race condition),最终可能导致 channel 中的事件未被排掉。

以下就是一种有问题的场景,按时间先后顺序发生:

  • goroutine A:Go 运行时判断 Timer 已经到期,于是从最小堆中删除该 Timer
  • goroutine B:应用程序执行 Timer.Stop,发现 Timer 已经到期,进而返回 false
  • goroutine B:应用程序继续执行 select...case <-t.C,因为 channel 中并没有事件,所以会立即返回
  • goroutine A:Go 运行时将到期事件发送到该 Timer 的 channel 中

Timer.Reset

按照 Timer.Reset 文档 的说法,要正确地 Reset Timer,首先需要正确地 Stop Timer。因此 Reset 的问题跟 Stop 基本相同。

二、使用 Timer 的正确方式

参考 Russ Cox 的回复(这里这里),目前 Timer 唯一合理的使用方式是:

  • 程序始终在同一个 goroutine 中进行 Timer 的 Stop、Reset 和 receive/drain channel 操作
  • 程序需要维护一个状态变量,用于记录它是否已经从 channel 中接收过事件,进而作为 Stop 中 draining 操作的判断依据

如果每次使用 Timer 都要按照上述方式来处理,无疑是一件很费神的事。为此,我专门写了一个 Go 库 goodtimer 来解决标准 Timer 的问题。懒是一种美德 :-)

三、相关阅读

一、要解决的问题

一直以来,Redis 都是单线程的(参考 FAQ)。这种模型使得 Redis 简单、高效,但缺点也很明显:如果执行一个比较耗时的命令,那么在该命令执行期间,整个 Redis 服务都将被阻塞(无法并发地执行其他命令)。

大部分 Redis 命令的执行速度都很快,所以不是问题;但也有一些命令,比如 ZUNIONSTORELRANGESINTER,以及臭名昭著的 KEYS,根据处理数据集大小的不同,可能会阻塞 Redis 数秒或几分钟。

DEL 命令为例,当被删除的 key 是 list、set、sorted set 或 hash 类型时,时间复杂度为 O(M),其中 M 是 key 中包含的元素的个数。

二、非阻塞删除

Redis 的作者 Salvatore Sanfilippo 自己也意识到了上述问题,并提出了对应的解决方案:非阻塞删除(参考 Lazy Redis is better Redis)。简而言之,「非阻塞删除」就是将删除操作放到另外一个线程(而非 Redis 主线程)去处理。

最终「非阻塞删除」在 Redis 4.0 中得以实现(参考 Redis 4.0 release notes),从此 Redis 开启了 “多线程” 时代。

新增实现的「非阻塞删除」包括以下命令:

命令 (原来的)阻塞版本
UNLINK DEL
FLUSHALL ASYNC FLUSHALL
FLUSHDB ASYNC FLUSHDB

1. 源码实现

参考 Redis 源码 可以发现,DELUNLINK 分别对应不同的处理函数:

命令 处理函数
DEL dbSyncDelete
UNLINK dbAsyncDelete

具体的实现细节请自行研读源码。

2. 耗时对比

下面我们来实际对比一下 DELUNLINK 的耗时差异。

开启 Slow log

设置 Slow log 记录每条命令的耗时(参考 SLOWLOG):

1
2
3
4
5
127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 0
OK
127.0.0.1:6379> CONFIG GET slowlog-log-slower-than
1) "slowlog-log-slower-than"
2) "0"

创建两个大 hash

准备一个 Lua 脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
local bulk = 1000
local fvs = {}
local j
for i = 1, ARGV[1] do
j = i % bulk
if j == 0 then
fvs[2 * bulk - 1] = "field" .. i
fvs[2 * bulk] = "value" .. i
redis.call("HMSET", KEYS[1], unpack(fvs))
fvs = {}
else
fvs[2 * j - 1] = "field" .. i
fvs[2 * j] = "value" .. i
end
end
if #fvs > 0 then
redis.call("HMSET", KEYS[1], unpack(fvs))
end
return "OK"

将上述脚本保存为 huge_hmset.lua,然后借助该脚本创建两个大 hash(参考 how to load lua script from file for redis),分别为 hash1hash2,它们各自拥有 100 万个 field:

1
2
3
4
$ redis-cli --eval huge_hmset.lua hash1 , 1000000
"OK"
$ redis-cli --eval huge_hmset.lua hash2 , 1000000
"OK"

上述操作会在 Slow log 中产生大量 HMSET 命令,这里先清除掉:

1
2
127.0.0.1:6379> SLOWLOG RESET
OK

DEL hash1

1
2
3
127.0.0.1:6379> DEL hash1
(integer) 1
(0.63s)
1
2
127.0.0.1:6379> UNLINK hash2
(integer) 1

查看 Slow log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
127.0.0.1:6379> SLOWLOG GET 2
1) 1) (integer) 5089
2) (integer) 1534653951
3) (integer) 17
4) 1) "UNLINK"
2) "hash2"
5) "127.0.0.1:56560"
6) ""
2) 1) (integer) 5088
2) (integer) 1534653948
3) (integer) 630305
4) 1) "DEL"
2) "hash1"
5) "127.0.0.1:56560"
6) ""

耗时对比结果:

命令 耗时
DEL hash1 630305 us
UNLINK hash2 17 us

值得注意的是:UNLINK 执行如此之快,并非使用了什么快速算法,而是因为它将真正的删除操作异步化了。

四、相关阅读

0%