发布于 

使用 Golang 实现的简易 Redis 🔨

使用 Go 语言基于 Redis serialization protocol (RESP) 实现简易的 Redis

开源地址: https://github.com/hcjjj/redis-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
#                _ _                       
# | (_)
# _ __ ___ __| |_ ___ ______ __ _ ___
# | '__/ _ \/ _` | / __|______/ _` |/ _ \
# | | | __/ (_| | \__ \ | (_| | (_) |
# |_| \___|\__,_|_|___/ \__, |\___/
# __/ |
# |___/

# git配置
git config --global user.name "hcjjj"
git config --global user.email "hcjjj@foxmail.com"
# 生成密钥SSH key
ssh-keygen -t rsa -C "hcjjj@foxmail.com"
# 填入 github 设置的 SSH and GPG keys
$ cat C:\Users\hcjjj/.ssh/id_rsa.pub
# 验证
$ ssh -T git@github.com
# Hi hcjjj! You've successfully authenticated, but GitHub does not provide shell access.
# 初始化本地仓库
git init
touch .gitignore
git add .
git commit -m "first commit"
# 推送到 github
git remote add origin git@github.com:hcjjj/redis-go.git
git push -u origin master
# 查看提交记录
git reflog show master

编译运行:

1
2
3
4
5
6
# redis.conf 设置服务器信息、数据库核心数、aof 持久化相关、集群相关
# 配置 peer 信息既开启集群模式,每个节点需要分别设置好各自配置文件的 self 和 peers
go build && ./redis-go
# 客户端: redis-cli/telnet/网络调试助手(开启转义符指令解析)
redis-cli -h 127.0.0.1 -p 6379
# telnet 127.0.0.1 6379

实现逻辑

TCP 服务器:

协议解析器:

内存数据库:

持久化流程:

集群架构:

集群指令执行流程:

目录结构

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
├── aof # AOF 持久化
├── cluster # 集群层
├── config # 解析配置文件 redis.conf
├── database # 内存数据库
├── datastruct # 支持的数据结构
│ └── dict
├── interface # 接口定义
│ ├── database
│ ├── resp
│ └── tcp
├── lib # 基础工具
│ ├── consistenthash # 一致性哈希
│ ├── logger # 日志记录
│ ├── sync # 同步工具
│ │ ├── atomic
│ │ └── wait
│ ├── utils # 格式转换
│ └── wildcard # 通配符
├── resp # RESP 协议解析器
│ ├── client # 客户端
│ ├── connection
│ ├── handler
│ ├── parser # 解析客户端发来的数据
│ └── reply # 封装服务器对客户端的回复
└── tcp # TCP 服务器实现

RESP

Redis 序列化协议规范,**Redis serialization protocol specification**

RESP 是一个二进制安全的文本协议,以行作为单位,客户端和服务器发送的命令或数据一律以 \r\n(CRLF)作为换行符,RESP 的二进制安全性允许在 key 或者 value 中包含 \r 或者 \n 这样的特殊字符。

二进制安全是指允许协议中出现任意字符而不会导致故障

  • 正确回复(Redis → Client)
    • + 开头,以 “\r\n” 结尾的字符串形式
    • 如:+OK\r\n
  • 错误回复(Redis → Client)
    • - 开头,以 “\r\n” 结尾的字符串形式
    • 如:-Error message\r\n
  • 整数(Redis ⇄ Client)
    • : 开头,以 “\r\n” 结尾的字符串形式
    • 如::123456\r\n
  • 单行字符串(Redis ⇄ Client)
    • $ 开头,后跟实际发送字节数,以 “\r\n “ 结尾
    • “Redis”:$5\r\nRedis\r\n
    • “”:$0\r\n\r\n
    • “Redis\r\ngo”:$11\r\nRedis\r\ngo\r\n
  • 多行字符串(数组)(Redis ⇄ Client)
    • * 开头,后跟成员个数
    • 有 3 个成员的数组 [SET, key, value]:*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n

支持命令

  • PING
  • SELECT
  • Key 命令集
    • DEL
    • EXISTS
    • FlushDB
    • TYPE
    • RENAME
    • RENAMENX
    • KEYS
  • String 命令集
    • GET
    • SET
    • SETNX
    • GETSET
    • STRLEN

测试命令:

  • ping $4\r\nping\r\n
  • set key value *3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
  • set ke1 value *3\r\n$3\r\nSET\r\n$3\r\nke1\r\n$5\r\nvalue\r\n
  • select 1 *2\r\n$6\r\nselect\r\n$1\r\n1\r\n
  • get key *2\r\n$3\r\nGET\r\n$3\r\nkey\r\n
  • select 2 *2\r\n$6\r\nselect\r\n$1\r\n1\r\n

telnet 需要逐条发送如 $4↩︎ping↩︎

性能测试

1
2
3
4
❯ neofetch
OS: Windows 11 💻 / Ubuntu 22.04.4 LTS on Windows 10 x86_64 🐧
CPU: AMD Ryzen 7 6800H with Radeon Graphics (16) @ 3.200GHz
Memory: 61159MiB

redis-go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
❯ redis-benchmark -h 127.0.0.1 -p 6379 -t set,get -n 10000 -q
ERROR: ERR unknown command config
ERROR: failed to fetch CONFIG from 127.0.0.1:6379
WARN: could not fetch server CONFIG
====== SET ======
10000 requests completed in 0.17 seconds
50 parallel clients
3 bytes payload
keep alive: 1
multi-thread: no
0.01% <= 0.1 milliseconds
...
SET: 58823.53 requests per second
...
GET: 62500.00 requests per second

redis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
❯ redis-benchmark -h 127.0.0.1 -p 6379 -t set,get -n 10000
====== SET ======
10000 requests completed in 0.15 seconds
50 parallel clients
3 bytes payload
keep alive: 1
host configuration "save": 900 1 300 10 60 10000
host configuration "appendonly": no
multi-thread: no
0.01% <= 0.1 milliseconds
...
66666.66 requests per second
...
71942.45 requests per second

排错记录

  • 当客户端主动断开连接的时候服务器报错,panic: sync: negative WaitGroup counter

    • waitDone.Add(1) 不小心写成 waitDone.Add(0),导致后续的 waitDone.Done() 出现 panic
  • imports redis-go/database: import cycle not allowed

    • aof.go 文件导包错误,需要的是 “redis-go/interface/database”,而不是 “redis-go/database”
  • [ERROR][database.go:76] runtime error: index out of range [1] with length 1

    • execSelect 方法中的 strconv.Atoi(string(args[0])) 写成了 1
  • 语法层面的 “坑”

    • Go 的 for 循环的迭代变量都是共享地址
      • go1.22 版本之后解决了 for 循环变量共享的问题 ⚠️
    • Go 的数组只能用常量来初始化
    • Go 的切片有着共享内存的特性
    • Go 有类型推断,但是没有自动类型转换

参考资料