Redis 源码中的巧妙设计

Redis 作为一个高性能的内存数据库,其源码中蕴含了众多巧妙的设计,这些设计不仅体现了工程上的优雅,还在性能、内存效率和可维护性上达到了极高的平衡。本文将深入剖析 Redis 源码中的 5 个巧妙设计,结合生活化例子、Go 代码示例和教学风格,带你领略 Redis 的技术魅力。这些设计包括:事件循环机制、动态字符串(SDS)、渐进式 rehash、跳表实现和内存编码优化。每个设计都将详细讲解其原理、实现细节和实际价值。

1. 事件循环机制(ae.c)

设计概述

Redis 使用单线程事件循环(Event Loop)处理客户端请求、定时任务和 I/O 操作,核心实现在 ae.c 文件中。这种设计通过非阻塞 I/O 和多路复用技术(如 epoll/kqueue/select),在单线程下实现高并发,极大地简化了并发控制。

为什么巧妙?

  • 单线程避免锁竞争:相比多线程模型,单线程无需加锁,减少了上下文切换和同步开销。
  • 事件驱动高效:通过监听文件描述符(FD)的事件(如读写就绪),Redis 能同时处理多个客户端连接。
  • 跨平台兼容:Redis 抽象了事件循环接口(aeApi),支持 epoll、kqueue 和 select,适配不同操作系统。

生活化例子

想象你在咖啡店当服务员(Redis 主线程),同时服务多个顾客(客户端)。你不一次只服务一个顾客,而是用一个笔记本(事件循环)记录每个顾客的订单状态(事件)。当咖啡机响铃(I/O 就绪)或顾客喊你(定时任务),你立刻处理对应任务。这种“监听+响应”的方式让你高效服务多人,而无需分身术(多线程)。

实现原理

Redis 的事件循环基于以下组件:

  • 事件类型:文件事件(AE_READABLEAE_WRITABLE)和时间事件(定时任务)。
  • 事件循环核心aeMain 函数不断调用 aeProcessEvents,处理就绪的事件。
  • 多路复用:使用 aeApiPoll(如 epoll_wait)获取就绪的文件描述符。
  • 时间事件:维护一个优先队列,处理定时任务(如过期键清理)。

Go 代码示例(模拟简化的 Redis 事件循环):

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package main

import (
	"fmt"
	"time"
)

type EventLoop struct {
	fileEvents  map[int]func() // 模拟文件事件:fd -> 回调
	timeEvents  []func()       // 模拟时间事件
	stop        bool
}

// NewEventLoop 初始化事件循环
func NewEventLoop() *EventLoop {
	return &EventLoop{
		fileEvents: make(map[int]func()),
	}
}

// RegisterFileEvent 注册文件事件
func (el *EventLoop) RegisterFileEvent(fd int, handler func()) {
	el.fileEvents[fd] = handler
}

// RegisterTimeEvent 注册时间事件
func (el *EventLoop) RegisterTimeEvent(handler func()) {
	el.timeEvents = append(el.timeEvents, handler)
}

// ProcessEvents 处理事件
func (el *EventLoop) ProcessEvents() {
	// 模拟多路复用,检查文件事件
	for fd, handler := range el.fileEvents {
		fmt.Printf("处理文件事件,fd: %d\n", fd)
		handler()
	}

	// 处理时间事件
	for _, handler := range el.timeEvents {
		fmt.Println("处理时间事件")
		handler()
	}
}

// Run 运行事件循环
func (el *EventLoop) Run() {
	for !el.stop {
		el.ProcessEvents()
		time.Sleep(100 * time.Millisecond) // 模拟阻塞等待
	}
}

func main() {
	el := NewEventLoop()

	// 注册文件事件
	el.RegisterFileEvent(1, func() {
		fmt.Println("处理客户端请求")
	})

	// 注册时间事件
	el.RegisterTimeEvent(func() {
		fmt.Println("清理过期键")
	})

	// 运行事件循环
	fmt.Println("启动事件循环")
	el.Run()
}

实际价值

  • 高性能:单线程事件循环在高并发场景下(如 Web 服务器)能处理数万连接。
  • 简洁性:避免多线程的复杂同步逻辑,代码易于维护。
  • 可扩展性:事件循环模型为后续模块化设计(如 Redis Module)提供了基础。

2. 动态字符串(SDS,sds.c)

设计概述

Redis 使用自定义的简单动态字符串(SDS,Simple Dynamic String)替代 C 的原生字符串,实现在 sds.c 中。SDS 是一种高效的字符串管理结构,优化了内存分配和操作性能。

为什么巧妙?

  • O(1) 长度获取:SDS 记录字符串长度,无需遍历计算。
  • 预分配空间:插入时预留额外空间,减少频繁 realloc。
  • 二进制安全:支持任意二进制数据,不依赖 \0 终止符。
  • 内存对齐:通过不同类型的 SDS 结构(如 sdshdr5sdshdr8),减少内存碎片。

生活化例子

想象你在写日记(字符串),用一个活页笔记本(SDS)。笔记本封面记录了已写页数(长度),无需翻到最后数页数。每次写满时,你预留几页空白(空闲空间),避免频繁换新笔记本。无论写文字还是贴照片(二进制数据),笔记本都能存,且封面大小(元数据)根据内容量优化(内存对齐)。

实现原理

SDS 的核心结构(sdshdr)包含:

  • len:字符串长度。
  • alloc:分配的总空间。
  • flags:类型标志(如 SDS_TYPE_8)。
  • buf:实际字符数组。

SDS 类型

  • sdshdr5:短字符串(< 32 字节),无显式元数据。
  • sdshdr8sdshdr16sdshdr32sdshdr64:支持不同长度,元数据大小递增。

Go 代码示例(模拟 SDS 结构):

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
	"fmt"
)

type SDS struct {
	len   uint32 // 字符串长度
	alloc uint32 // 分配空间
	buf   []byte // 字符数组
}

// NewSDS 创建 SDS
func NewSDS(s string) *SDS {
	data := []byte(s)
	length := uint32(len(data))
	// 预分配空间(简单翻倍策略)
	alloc := length * 2
	if alloc < length {
		alloc = length
	}
	return &SDS{
		len:   length,
		alloc: alloc,
		buf:   append(make([]byte, 0, alloc), data...),
	}
}

// Append 追加字符串
func (sds *SDS) Append(s string) {
	data := []byte(s)
	addLen := uint32(len(data))

	// 检查是否需要扩容
	if sds.len+addLen > sds.alloc {
		newAlloc := (sds.len + addLen) * 2
		newBuf := make([]byte, sds.len, newAlloc)
		copy(newBuf, sds.buf)
		sds.buf = newBuf
		sds.alloc = newAlloc
	}

	// 追加数据
	sds.buf = append(sds.buf, data...)
	sds.len += addLen
}

// String 获取字符串
func (sds *SDS) String() string {
	return string(sds.buf[:sds.len])
}

func main() {
	sds := NewSDS("Hello")
	fmt.Printf("初始 SDS: %s, len: %d, alloc: %d\n", sds.String(), sds.len, sds.alloc)

	sds.Append(", Redis!")
	fmt.Printf("追加后 SDS: %s, len: %d, alloc: %d\n", sds.String(), sds.len, sds.alloc)
}

实际价值

  • 高效操作:O(1) 获取长度,预分配减少内存分配次数。
  • 安全性:二进制安全支持任意数据,防止缓冲区溢出。
  • 内存优化:多种类型适配不同场景,减少浪费。

3. 渐进式 rehash(dict.c)

设计概述

Redis 的字典(dict)用于存储键值对(如哈希表),实现在 dict.c 中。当哈希表需要扩容或缩容时,Redis 使用渐进式 rehash 而非一次性完成,避免阻塞主线程。

为什么巧妙?

  • 非阻塞:每次只 rehash 一小部分数据,平摊开销。
  • 动态调整:根据负载因子(load factor)自动触发扩容/缩容。
  • 内存效率:维护两个哈希表(旧表和新表),逐步迁移数据,释放旧表。

生活化例子

想象你搬家(rehash),旧房子(旧哈希表)的东西要搬到新房子(新哈希表)。你不一次性搬完,而是每天搬几件(渐进式),一边搬一边还能接待客人(处理请求)。新房子更大(扩容),旧房子逐渐清空(释放)。

实现原理

  • 字典结构dict 包含两个哈希表(ht[0]ht[1]),ht[1] 用于 rehash。
  • 触发条件:负载因子过高(如 > 1)或过低(如 < 0.1)时触发 rehash。
  • 渐进式迁移:每次操作(如增删改查)时迁移一个桶(bucket),通过 dictRehash 函数控制进度。
  • 完成标志:当 ht[0] 所有桶迁移到 ht[1],释放 ht[0],将 ht[1] 设为新表。

Go 代码示例(模拟渐进式 rehash):

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
package main

import (
	"fmt"
)

type Dict struct {
	ht       [2][]map[string]string // 两个哈希表
	rehashIdx int                    // rehash 进度
}

func NewDict() *Dict {
	return &Dict{
		ht:        [2][]map[string]string{{}, {}},
		rehashIdx: -1,
	}
}

// Add 添加键值对
func (d *Dict) Add(key, value string) {
	if d.rehashIdx != -1 {
		d.rehashStep() // 每次操作执行一步 rehash
	}
	// 添加到当前表
	ht := d.ht[0]
	if d.rehashIdx != -1 {
		ht = d.ht[1] // rehash 中写入新表
	}
	if ht[0] == nil {
		ht[0] = make(map[string]string)
	}
	ht[0][key] = value
}

// StartRehash 开始 rehash
func (d *Dict) StartRehash() {
	if d.rehashIdx != -1 {
		return
	}
	d.ht[1] = make([]map[string]string, len(d.ht[0])*2) // 新表容量翻倍
	d.rehashIdx = 0
}

// rehashStep 执行一步 rehash
func (d *Dict) rehashStep() {
	if d.rehashIdx == -1 || d.rehashIdx >= len(d.ht[0]) {
		d.rehashIdx = -1 // rehash 完成
		d.ht[0] = d.ht[1]
		d.ht[1] = nil
		return
	}

	// 迁移一个桶
	if d.ht[0][d.rehashIdx] != nil {
		if d.ht[1][d.rehashIdx] == nil {
			d.ht[1][d.rehashIdx] = make(map[string]string)
		}
		for k, v := range d.ht[0][d.rehashIdx] {
			d.ht[1][d.rehashIdx][k] = v
		}
		d.ht[0][d.rehashIdx] = nil
	}
	d.rehashIdx++
}

func main() {
	dict := NewDict()
	dict.ht[0] = []map[string]string{{"key1": "value1"}, {"key2": "value2"}}

	fmt.Println("初始字典:", dict.ht[0])
	dict.StartRehash()
	dict.rehashStep()
	fmt.Println("rehash 一步后:", dict.ht[0], dict.ht[1])
	dict.rehashStep()
	fmt.Println("rehash 完成后:", dict.ht[0])
}

实际价值

  • 低延迟:避免一次性 rehash 导致的请求阻塞。
  • 平滑扩容:支持动态增长的键值对。
  • 内存管理:渐进式释放旧表,优化内存使用。

4. 跳表实现(t_zset.c)

设计概述

Redis 的有序集合(ZSET)使用跳表(Skip List)作为底层数据结构之一,实现在 t_zset.c 中。跳表通过随机层数和多层索引实现高效的范围查询和排序。

为什么巧妙?

  • 简单高效:跳表比平衡树(如红黑树)实现简单,但平均 O(log N) 性能接近。
  • 范围查询友好:多层索引天然支持范围扫描(如 ZRANGE)。
  • 随机层数:通过概率算法平衡层高,简化维护。

生活化例子

想象你在图书馆找书(元素),书架排成一长列(底层链表)。管理员每隔 10 本书设一个标记(Level 1),每隔 50 本设一个大标记(Level 2)。你从最高层标记开始,快速跳到目标区域(跳跃),再逐层细化查找。标记数量随机(随机层数),但整体高效。

实现原理

  • 节点结构zskiplistNode):包含分值(score)、值(value)、后向指针和多层前向指针(带跨度)。
  • 跳表结构zskiplist):包含头尾节点、长度和最大层数。
  • 随机层数:以概率 p=0.25 决定层数,最大 32 层。
  • 操作:查找、插入、删除通过多层索引实现 O(log N) 复杂度。

Go 代码示例(简化的跳表实现):

 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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package main

import (
	"fmt"
	"math/rand"
	"time"
)

const (
	MaxLevel = 32
	P        = 0.25
)

type SkipListNode struct {
	score   float64
	value   string
	forward []*SkipListNode
}

type SkipList struct {
	header   *SkipListNode
	maxLevel int
}

func NewSkipList() *SkipList {
	return &SkipList{
		header:   &SkipListNode{forward: make([]*SkipListNode, MaxLevel)},
		maxLevel: 1,
	}
}

func randomLevel() int {
	rand.Seed(time.Now().UnixNano())
	level := 1
	for rand.Float64() < P && level < MaxLevel {
		level++
	}
	return level
}

func (sl *SkipList) Insert(score float64, value string) {
	update := make([]*SkipListNode, MaxLevel)
	current := sl.header

	// 查找插入位置
	for level := sl.maxLevel - 1; level >= 0; level-- {
		for current.forward[level] != nil && current.forward[level].score < score {
			current = current.forward[level]
		}
		update[level] = current
	}

	// 创建新节点
	level := randomLevel()
	if level > sl.maxLevel {
		sl.maxLevel = level
	}
	newNode := &SkipListNode{
		score:   score,
		value:   value,
		forward: make([]*SkipListNode, level),
	}

	// 更新指针
	for i := 0; i < level; i++ {
		newNode.forward[i] = update[i].forward[i]
		update[i].forward[i] = newNode
	}
}

func main() {
	sl := NewSkipList()
	sl.Insert(42.0, "answer")
	sl.Insert(100.0, "score")
	fmt.Println("插入节点: score=42.0, value=answer")
	fmt.Println("插入节点: score=100.0, value=score")
}

实际价值

  • 高效查询:支持快速查找和范围操作,适合排行榜等场景。
  • 简单实现:无需复杂旋转,代码逻辑清晰。
  • 并发友好:局部更新适合高并发环境。

5. 内存编码优化(object.c)

设计概述

Redis 通过内存编码优化(如 ziplistintset)减少数据结构的内存占用,实现在 object.c 和相关文件中。这种设计在小数据量场景下显著降低内存开销。

为什么巧妙?

  • 小数据优化:对小型列表、集合等使用紧凑编码(如 ziplist),节省内存。
  • 动态切换:当数据量增大时,自动转换为标准结构(如链表、哈希表)。
  • 透明操作:客户端无需感知底层编码,接口一致。

生活化例子

想象你用一个小笔记本(ziplist)记购物清单,适合几件物品,省空间。当清单变长,你换成大文件夹(链表),但对外还是叫“清单”。Redis 的编码优化就像这样:小数据用紧凑结构,大数据用标准结构,用户只管用。

实现原理

  • ziplist:用于小型列表(LIST)、哈希(HASH)和有序集合(ZSET),存储连续字节,压缩元数据。
  • intset:用于小型整数集合(SET),存储有序整数数组。
  • 切换逻辑:当元素数量或大小超过阈值(如 list-max-ziplist-size),转换为标准结构。

Go 代码示例(模拟 ziplist 简化版):

 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
package main

import (
	"fmt"
	"strings"
)

type ZipList struct {
	data []string // 模拟连续存储
}

func NewZipList() *ZipList {
	return &ZipList{}
}

func (zl *ZipList) Add(value string) {
	zl.data = append(zl.data, value)
}

func (zl *ZipList) Get(index int) (string, error) {
	if index < 0 || index >= len(zl.data) {
		return "", fmt.Errorf("索引越界")
	}
	return zl.data[index], nil
}

func main() {
	zl := NewZipList()
	zl.Add("apple")
	zl.Add("banana")
	fmt.Println("ziplist 内容:", strings.Join(zl.data, ", "))

	val, err := zl.Get(1)
	if err != nil {
		fmt.Printf("获取失败: %v\n", err)
		return
	}
	fmt.Printf("获取索引 1: %s\n", val)
}

实际价值

  • 内存节省:小型数据结构占用空间减少数倍。
  • 性能平衡:紧凑结构牺牲部分性能,换取内存效率。
  • 灵活性:动态切换适应不同数据规模。

总结

Redis 源码中的巧妙设计体现了性能、内存和简洁性的完美平衡:

  1. 事件循环机制:单线程高效处理高并发,简化并发控制。
  2. 动态字符串(SDS):优化字符串操作,二进制安全且内存高效。
  3. 渐进式 rehash:平滑扩容,降低延迟。
  4. 跳表实现:简单高效支持范围查询。
  5. 内存编码优化:小数据量场景下显著节省内存。

评论 0