去年有段时间,我在排查一个线上缓存抖动的问题,随手用抓包工具看了下 redis-cli 和服务端的交互,那一瞬间有点懵。我以为 Redis 协议是什么高深莫测的东西,结果抓出来就是 *3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n 这种朴素到极点的文本串。顺着这个好奇心,我做了件有点轴的事:用 Go 从零写了一个只认 RESP 协议的 KV Server,不依赖任何 Redis 源码和第三方库,最后让 redis-cli 和 redis-benchmark 都能直接连上来跑。这篇文章就是那次折腾的完整复盘,从协议拆解、服务端骨架、存储引擎到兼容性测试的整套思路,适合那些用过 Redis 但没看过协议层、想动手造一个轮子验证理解的人。花一个周末写一遍,你对 Redis 的认知深度会比读十篇面试题都管用。
1. 为什么要自己造一个 RESP 协议的轮子
1.1 这个轮子到底解决什么问题
RESP 全称 REdis Serialization Protocol,是 Redis 客户端和服务端之间的通信协议。它不复杂,但它是理解 Redis 一切行为的钥匙。比如你问"Redis 为什么快",有人会说是内存快、单线程快、IO 多路复用快,但这些都属于执行环境。真正落到一次命令上,客户端发出去的字节流长什么样、服务端怎么知道一条命令从哪儿开始到哪儿结束、怎么保证中间有回车换行和二进制数据都不出错,这些都是 RESP 协议层负责的事。
动手写一个 RESP Server,最直接的价值是强迫你把"客户端发来一段字节流"到"服务端返回一段字节流"的完整路径走一遍。你会遇到很多文档里不会写但实际跑起来一定会撞上的细节:空批量字符串和不存在键的区别、超时时间参数解析时越界、客户端 pipeline 一批命令时服务端能不能连续解析、redis-cli 启动时发来的 HELLO 和 CLIENT 命令不认识怎么办。这些问题只有真写过一遍才能留下肌肉记忆。
另外这个轮子也不是完全没用,很多场景确实需要"协议兼容但不实现完整 Redis"的服务端:内网接口 mock、给测试环境造的假 Redis、嵌入到小工具里的轻量缓存、教学用的协议分析环境。哪怕只是为了理解 Redis 官方源码里 networking.c 的 readQueryFromClient 到底在做什么,手写一遍也是最好的铺垫。
1.2 先给自己定一个验收标准
没有验收标准的轮子很容易烂尾。我当时的验收标准写得很具体:
- redis-cli 通过
redis-cli -p 6380直连,PING、SET、GET、DEL、EXPIRE、TTL、INCR这些命令能正确返回。 - 不存在的键
GET返回(nil),而不是空字符串。 - 用 redis-benchmark 跑
SET/GET,吞吐量能到几万 QPS 就算及格。 - 客户端断开时服务端不能 panic。
- 写满二进制内容(比如
\x00\x01)的 value 能原样读回。
这个标准不高,但足够把协议解析、命令分发、存储删改、过期清理这几条主干全部覆盖到。想一步到位实现所有数据结构和所有命令,那不叫学习,那叫重新发明 Redis,完全不必要。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. RESP 协议到底在传输什么:拆掉那层文本外衣
2.1 RESP 的五种基本类型
RESP 在 Redis 6.0 之前的实际版本里只有五种基础类型,客户端请求最常用的是批量字符串和数组,服务端响应则五种都会用。每种类型用第一个字节区分:
| 类型 | 首字节 | 格式示例 | 说明 |
|---|---|---|---|
| 简单字符串 | + |
+OK\r\n |
适合状态报告,如 SET 成功 |
| 错误 | - |
-ERR unknown command\r\n |
首字母是 -,Redis 客户端靠这个辨认出错 |
| 整数 | : |
:100\r\n |
适合 INCR、TTL、DEL 返回值 |
| 批量字符串 | $ |
$5\r\nhello\r\n |
带长度前缀的字符串,值是二进制安全的 |
| 数组 | * |
*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n |
多个 RESP 值的组合,客户端请求就是这种 |
关键规则是:每一条 RESP 数据都以 \r\n 结尾,批量字符串和数组必须带长度前缀,服务端先读长度,再精确读指定字节数。这种"长度前缀 + CRLF"的设计是 RESP 二进制安全的根基。
2.2 一个完整请求的字节级拆解
拿 SET foo bar 这条命令来说,redis-cli 发送的原始字节流是:
code复制*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
拆开看是这样的:
code复制*3\r\n -> 这个请求包含 3 个元素
$3\r\nSET\r\n -> 第一个元素,长度 3,内容是 "SET"
$3\r\nfoo\r\n -> 第二个元素,长度 3,内容是 "foo"
$3\r\nbar\r\n -> 第三个元素,长度 3,内容是 "bar"
服务端的解析流程就是:先读首字节判断类型,数组类型读出元素个数,然后循环读元素;元素是批量字符串,就先读长度再读正文。整个解析过程是递归的,遇到数组可以继续嵌套。
Redis 官方文档里说,客户端向服务端发命令时,请求只能是数组的批量字符串。这个设计比纯文本行协议好在两个地方:一是命令和参数不需要转义,空格、回车、换行都能放进参数里;二是天然支持 pipeline,多个命令可以连着写在一个 TCP 包里,服务端按序解析即可。
2.3 长度前缀与二进制安全
很多人第一次写 RESP 解析器容易踩一个坑,就是用 ReadString('\n') 按行读,然后拿行内容当字符串。这在读简单字符串、错误、整数时没问题,但读批量字符串就不行了。因为 value 本身可能包含 \r\n,比如一个值是 line1\r\nline2,如果按行读,会在中途截断。正确做法是:
- 先读一行拿到
$<length>。 - 根据长度,一次性精确读
length字节。 - 再读 2 个字节,校验一定是
\r\n。
这也是为什么 Redis 的字符串可以存图片、序列化对象、压缩数据,因为协议层根本不解析内容,长度说了算。我在实现时特意写了一个测试用例,set 一个包含 \x00\x01\x02\r\n 的 value,再 get 出来逐字节比对,确认值没有被任何解析逻辑破坏。
2.4 解析器实现里最容易翻车的细节
下面是我实现的 ReadRESP 函数核心逻辑,直接贴出来供参考:
go复制func ReadRESP(r *bufio.Reader) (any, error) {
prefix, err := r.ReadByte()
if err != nil {
return nil, err
}
// 除批量字符串和数组之外,其他类型都是一行。
// 批量字符串和数组需要再读长度和内容。
switch prefix {
case '+':
return readLine(r)
case '-':
return &RESPError{Msg: string(readLine(r))}, nil
case ':':
line, _ := readLine(r)
return strconv.ParseInt(string(line), 10, 64)
case '$':
line, _ := readLine(r)
n, _ := strconv.Atoi(string(line))
if n == -1 {
return nil, nil // RESP 里 null 批量字符串
}
buf := make([]byte, n+2)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
return buf[:n], nil // buf[n] 和 buf[n+1] 应该是 \r\n
case '*':
line, _ := readLine(r)
n, _ := strconv.Atoi(string(line))
arr := make([]any, 0, n)
for i := 0; i < n; i++ {
v, err := ReadRESP(r)
if err != nil {
return nil, err
}
arr = append(arr, v)
}
return arr, nil
default:
return nil, fmt.Errorf("unknown RESP type: %c", prefix)
}
}
func readLine(r *bufio.Reader) ([]byte, error) {
line, err := r.ReadBytes('\n')
if err != nil {
return nil, err
}
if len(line) < 2 || line[len(line)-2] != '\r' {
return nil, fmt.Errorf("protocol error: missing CRLF")
}
return line[:len(line)-2], nil
}
这里有几个坑必须说。第一,io.ReadFull 不是 Read,Read 不保证读满指定长度,必须用 ReadFull。第二,读出来的 buf 我直接做了 buf[:n],因为在 make 时多分配了 2 字节用来存结尾的 \r\n,如果不做切片,value 尾部就会带一个多余的 \r,客户端拿到会懵。第三,readLine 里对 \r 的判断很重要,有些客户端如果不按协议发 \n 结尾,立刻返回协议错误而不是无限等待。
注意:解析器是服务端的第一道防线,不要信任客户端发来的长度。如果客户端声明
$999999999,你直接 make 这么大一块内存,等于被人打内存耗尽攻击。实际项目至少要对长度做个上限判断,Redis 本身也有proto-max-bulk-len配置。学习项目可以简单写死一个上限,比如 512MB。
3. 最小可运行版本:连接、循环、命令分发
3.1 为什么选 Go 这种并发方案
Redis 官方是单线程事件循环,也就是一个进程里用 epoll 监听所有连接,谁有数据就处理谁。这种模型的好处是命令执行天然串行,不需要加锁。但在我这个学习项目里,我选了 Go,理由是 goroutine per connection 的模型写起来最直白:每个连接一个 goroutine,里面跑一个 for 循环,反复 read -> dispatch -> write。这样代码顺序读下来就能理解全貌,不会被事件循环的回调逻辑绕晕。
但必须有意识:这个选择带来一个和真实 Redis 的显著差异,就是并发。Redis 单线程不需要锁;Go 的多 goroutine 访问同一个 map 必须加锁,否则就是 data race。我在代码里用的是 sync.RWMutex,读多写少场景性能可以接受。代价是 GET 这种读操作也带着锁开销,在高并发压测时,锁竞争会成为主要瓶颈之一。这正好反过来帮你想明白一件事:Redis 的快,一部分来自协议解析和内存操作本身,但"单线程避免了锁竞争"也是个不可忽略的隐藏加成。
3.2 Server 主循环与连接管理
主程序结构非常简单,监听端口、接受连接、交给 handler:
go复制func main() {
srv := NewServer()
ln, err := net.Listen("tcp", ":6380")
if err != nil {
log.Fatal(err)
}
log.Println("RESP KV Server listening on :6380")
for {
conn, err := ln.Accept()
if err != nil {
log.Println("accept error:", err)
continue
}
go srv.handleConn(conn)
}
}
handleConn 里每个连接一个 bufio.Reader 和一个 bufio.Writer,然后进入读命令循环:
go复制func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
r := bufio.NewReader(conn)
w := bufio.NewWriter(conn)
for {
v, err := ReadRESP(r)
if err != nil {
if err != io.EOF {
log.Printf("read error from %s: %v", conn.RemoteAddr(), err)
}
return
}
argv, ok := v.([]any)
if !ok || len(argv) == 0 {
writeError(w, "ERR protocol error: expected array")
w.Flush()
continue
}
s.dispatch(w, argv)
w.Flush()
}
}
defer conn.Close() 保证了客户端断开时连接资源能回收。ReadRESP 返回 io.EOF 就是客户端正常关闭,直接退出循环;如果是其他错误,说明协议解析失败或网络异常,也退出。
这里值得一提是 w.Flush() 的位置。bufio.Writer 默认写进内存缓冲区,如果你写完不 Flush(),客户端永远等不到响应。我在第一版犯过这个错,服务端日志显示命令处理了,客户端却卡死。这种 bug 靠调试很难一眼看出来,因为代码逻辑没毛病,纯粹是缓冲区没刷出去。后来我的习惯是:每处理完一条命令立即 Flush(),虽然牺牲一点吞吐,但语义上和 Redis 的同步请求-响应模型对齐。
3.3 命令分发:一个大 switch 的代价
命令分发我用了最朴实的大 switch,命令名统一转大写:
go复制func (s *Server) dispatch(w *bufio.Writer, argv []any) {
cmdName, ok := argv[0].([]byte)
if !ok {
writeError(w, "ERR protocol error: command name must be bulk string")
return
}
name := strings.ToUpper(string(cmdName))
args := make([][]byte, 0, len(argv)-1)
for _, a := range argv[1:] {
b, ok := a.([]byte)
if !ok {
writeError(w, "ERR protocol error: command args must be bulk strings")
return
}
args = append(args, b)
}
switch name {
case "PING":
if len(args) == 0 {
writeSimpleString(w, "PONG")
} else {
writeBulk(w, args[0])
}
case "ECHO":
requireArgs(w, name, args, 1)
writeBulk(w, args[0])
case "SET":
s.doSet(w, args)
case "GET":
s.doGet(w, args)
case "DEL":
s.doDel(w, args)
case "EXISTS":
s.doExists(w, args)
case "EXPIRE", "PEXPIRE":
s.doExpire(w, args)
case "TTL", "PTTL":
s.doTTL(w, args)
case "INCR", "DECR":
s.doIncrDecr(w, name, args)
case "HELLO":
s.doHello(w, args)
case "CLIENT":
writeSimpleString(w, "OK")
default:
writeError(w, "ERR unknown command '"+name+"'")
}
}
参数个数校验可以用一个小 helper requireArgs,参数不足时返回 ERR wrong number of arguments for 'xxx' command。这个错误文案虽然不参与协议解析,但最好和 Redis 官方一致,因为有些客户端会解析错误信息文本。比如重试逻辑可能检查字符串里是否包含 wrong number of arguments。
注意:命令名和参数的类型断言不能省。RESP 请求协议规定是"数组 + 批量字符串",但解析器拿到的是
any,不能假设它一定就是[]byte。如果有人发*2\r\n:1\r\n:2\r\n这种数组里带整数的畸形请求,没有类型断言直接 panic,一个连接就能打崩整个服务端。
4. 存储引擎和命令语义:从 map 到像模像样的 KV
4.1 数据结构:为什么 value 用 []byte 而不是 string
存储部分最基础的结构是这个:
go复制type entry struct {
val []byte
expireAt time.Time
}
type Server struct {
mu sync.RWMutex
data map[string]entry
}
data 的 key 用 string,value 用 []byte。这里有个语言层面的细节:Go 的 map[string] 在查询时需要把 []byte 转 string,会产生一次分配;但胜在语义简单。真正做超高性能 KV 会用 string 到 []byte 的自定义哈希表,学习项目没必要。
value 类型我特意选了 []byte 而不是 string,原因是 RESP 批量字符串是二进制安全的,value 中间可以有任意字节,包括 \x00。Go 的 string 也能存任意字节,但 []byte 更符合网络数据处理的直觉,序列化和反序列化都少一层转换。
4.2 核心命令实现:SET GET DEL EXISTS
SET 命令的完整实现我会带一点可选参数,因为 SET key value EX 10 这种带过期时间的写法太常见了:
go复制func (s *Server) doSet(w *bufio.Writer, args [][]byte) {
if len(args) < 2 {
writeError(w, "ERR wrong number of arguments for 'set' command")
return
}
key := string(args[0])
val := args[1]
expireAt := time.Time{}
for i := 2; i < len(args); i++ {
switch strings.ToUpper(string(args[i])) {
case "EX":
if i+1 >= len(args) {
writeError(w, "ERR syntax error")
return
}
sec, err := strconv.Atoi(string(args[i+1]))
if err != nil || sec <= 0 {
writeError(w, "ERR invalid expire time")
return
}
expireAt = time.Now().Add(time.Duration(sec) * time.Second)
i++
case "PX":
if i+1 >= len(args) {
writeError(w, "ERR syntax error")
return
}
ms, err := strconv.Atoi(string(args[i+1]))
if err != nil || ms <= 0 {
writeError(w, "ERR invalid expire time")
return
}
expireAt = time.Now().Add(time.Duration(ms) * time.Millisecond)
i++
}
}
s.mu.Lock()
s.data[key] = entry{val: val, expireAt: expireAt}
s.mu.Unlock()
writeSimpleString(w, "OK")
}
这里必须检查 i+1 >= len(args),否则客户端发一个不完整的 SET foo bar EX,你会直接数组越界 panic。Redis 在这类场景返回 ERR syntax error,照抄就行。
GET 实现要解决这个关键问题:键不存在和键已过期,返回值都是 null 批量字符串,也就是 $-1\r\n。这跟返回空字符串 $0\r\n\r\n 有本质区别,很多协议实现的新手在这翻车:
go复制func (s *Server) doGet(w *bufio.Writer, args [][]byte) {
if len(args) != 1 {
writeError(w, "ERR wrong number of arguments for 'get' command")
return
}
s.mu.RLock()
e, ok := s.data[string(args[0])]
s.mu.RUnlock()
if !ok || (!e.expireAt.IsZero() && time.Now().After(e.expireAt)) {
writeNullBulk(w)
return
}
writeBulk(w, e.val)
}
DEL 和 EXISTS 都是返回整数,逻辑不复杂。DEL 返回删除了几个键,EXISTS 返回存在几个键,都是 :1\r\n 或 :0\r\n 这种格式。
4.3 TTL 与过期策略
过期时间是 KV 存储里最容易出细节问题的点。真实 Redis 的过期删除是惰性删除 + 定期删除合体:惰性删除是访问键时顺手检查是否过期,定期删除是后台每 100ms 抽出部分过期键删掉。
我这个学习项目只实现了惰性删除,也就是 GET、TTL 这些命令遇到键时检查 expireAt。逻辑上有个 bug 隐患:GET 发现键过期了,我是直接返回 nil,但没有删掉 map 里的 key。这个键会一直占着内存,如果有大量带过期时间的键且一直没人读,内存会持续涨。解决很简单,检查到过期时加个写锁把 key 删掉:
go复制if !ok || isExpired(e) {
if ok {
s.mu.Lock()
delete(s.data, string(args[0]))
s.mu.Unlock()
}
writeNullBulk(w)
return
}
TTL 的返回值语义也容易记混,我写在这里以防有人忘了:
- 键存在且没有设置过期时间:返回
-1 - 键不存在:返回
-2 - 键存在且有过期时间:返回剩余秒数,向下取整
go复制func (s *Server) doTTL(w *bufio.Writer, args [][]byte) {
if len(args) != 1 {
writeError(w, "ERR wrong number of arguments for 'ttl' command")
return
}
s.mu.RLock()
e, ok := s.data[string(args[0])]
s.mu.RUnlock()
if !ok {
writeInt(w, -2)
return
}
if e.expireAt.IsZero() {
writeInt(w, -1)
return
}
remain := time.Until(e.expireAt)
if remain <= 0 {
writeInt(w, -2)
return
}
writeInt(w, int64(remain/time.Second))
}
这里 int64(remain/time.Second) 是向下取整。time.Duration 的秒数直接 Seconds() 返回浮点数,再转 int 也是向下,但会有浮点数精度问题,用整数除法更稳。
4.4 INCR 背后的原子性和错误语义
INCR 是很有表现力的一个命令,它同时涉及原子性、类型错误和字符串数字互转三件事。我先说结论:INCR 是把字符串当十进制整数,加一后再写回,返回新值。如果当前值不存在,则当成 0 处理。如果字符串不是合法的整数,就返回错误。
go复制func (s *Server) doIncrDecr(w *bufio.Writer, name string, args [][]byte) {
if len(args) != 1 {
writeError(w, "ERR wrong number of arguments for '"+strings.ToLower(name)+"' command")
return
}
s.mu.Lock()
defer s.mu.Unlock()
key := string(args[0])
e, ok := s.data[key]
if !ok || isExpired(e) {
e = entry{val: []byte("0")}
}
n, err := strconv.ParseInt(string(e.val), 10, 64)
if err != nil {
writeError(w, "ERR value is not an integer or out of range")
return
}
if name == "INCR" {
n++
} else {
n--
}
s.data[key] = entry{val: []byte(strconv.FormatInt(n, 10))}
writeInt(w, n)
}
注意 strconv.ParseInt 只认开头的正负号,0123 这种带前导零的字符串也能解析,Redis 实际行为也差不多。网上很多人问"为什么 increment() 报错 not integer or out of range",其实就是因为 Redis 里存的字符串不是合法整数,INCR 命令在服务端直接拒绝了。你要是自己写过一遍这个命令,看到那个报错就秒懂,根本不用查。
原子性方面,我在这个版本里直接用全局锁保证读-改-写三步不被打断。真实 Redis 是单线程命令队列,天然原子。但如果你把服务端改成多线程并行处理命令,就必须锁了,否则两个连接同时 INCR 同一个 key,可能互相覆盖。
5. 拿 redis-cli 和 redis-benchmark 实测兼容性
5.1 先用 redis-cli 过一个冒烟测试
代码写完,第一个需要打败的对手就是最常用的客户端 redis-cli。我启动服务端后,直接开连:
bash复制redis-cli -p 6380
127.0.0.1:6380> PING
PONG
127.0.0.1:6380> SET foo bar
OK
127.0.0.1:6380> GET foo
"bar"
127.0.0.1:6380> GET not_exist
(nil)
127.0.0.1:6380> EXPIRE foo 5
(integer) 1
127.0.0.1:6380> TTL foo
(integer) 5
127.0.0.1:6380> INCR counter
(integer) 1
127.0.0.1:6380> INCR counter
(integer) 2
127.0.0.1:6380> SET counter abc
OK
127.0.0.1:6380> INCR counter
(error) ERR value is not an integer or out of range
看到 (nil)、(integer)、(error) 这些 redis-cli 的不同渲染方式,说明我编码 RESP 响应类型的路子是对的。尤其是 (nil),它对应的是 $-1\r\n 这个 null 批量字符串,不是返回空字符串。这个点不实测,光读文档很难记住。
5.2 redis-benchmark 压测与 pipeline 验证
冒烟过了,上压测。redis-benchmark 是 Redis 自带的性能测试工具,可以直接指定端口:
bash复制redis-benchmark -p 6380 -t set,get -n 100000 -P 10
在我的机器上,这个简单版服务端大概能跑出 4-6 万 QPS,当然这数据受机器和网络影响很大,重点不是刷分,而是确认两件事:
第一,-P 10 是 pipeline 模式,一次往 socket 里写 10 条命令。我的 handleConn 是 for 循环连续读,天然支持这种批量请求,不需要额外处理。这也说明 RESP 协议的 pipeline 能力是设计出来的,不是靠事后叠加。
第二,压测中如果出现大量超时,大概率问题出在 Flush() 频率或锁竞争,跟协议解析无关。排查方案是先用单连接不带 pipeline 压,把问题范围切小。
5.3 遇到的那些兼容性坑
这部分是全文最想让你看到的内容,因为全是文档不会写、但连上真实客户端一定会踩的坑。
第一个坑:redis-cli 一连接就会发 CLIENT SETINFO。新版 redis-cli(7.x)启动时会自动发送 CLIENT SETINFO lib-name 和 CLIENT SETINFO lib-ver。如果服务端不认识 CLIENT 命令,redis-cli 会在屏幕上打一行 Warning: client replied with unknown command,虽然不死,但很掉价。解法就是我在 dispatch 里写了个 case "CLIENT": writeSimpleString(w, "OK"),让它闭嘴。
第二个坑:HELLO 命令不能瞎处理。Redis 6 之后很多客户端默认尝试 RESP3 协议,会发 HELLO 3。如果服务端不支持 RESP3,应该返回一个 NOPROTO 错误,客户端会回退到 RESP2。如果服务端直接返回 +OK\r\n,某些客户端反而会困惑,因为它期望 HELLO 返回一个数组或 map 来描述服务器信息。我的实现是:
go复制case "HELLO":
if len(args) > 0 && string(args[0]) == "3" {
writeError(w, "NOPROTO this server only supports RESP2")
return
}
writeSimpleString(w, "OK")
redis-cli 默认用 RESP2,不发 HELLO,所以这个能跑通。但如果你用 go-redis v9(默认 RESP3)去连,会先发 HELLO 3,收 NOPROTO 后有的版本会直接报错。想兼容就得完整实现 RESP3 的 HELLO 响应,返回一个 map 包含 server、version、proto 等字段,这就是"从 RESP2 到 RESP3"的进阶课题了。
第三个坑:$-1\r\n 和 $0\r\n\r\n 的区分。这是我反复强调的。GET 一个不存在的 key,协议层一定是 $-1\r\n,表示 null 批量字符串。如果错误地返回 $0\r\n\r\n,redis-cli 会显示空字符串,go-redis 会认为值存在但为空字符串,这在缓存穿透场景会引发完全不同的行为。
第四个坑:编译语言里的类型断言。客户端发的请求数组元素可能不是批量字符串,协议解析器返回的是 any,必须做类型断言。如果直接 argv[0].([]byte) 不校验 ok,遇到畸形请求就 panic。生产级服务端不会让客户端任何输入导致崩溃。
第五个坑:空命令。有些监控工具会发空数组 *0\r\n 来探测连接。处理方法是遇到 len(argv) == 0 直接跳过,不要发错误响应,否则可能干扰客户端的心跳判断。
6. 可以继续深挖的方向和一点个人建议
6.1 持久化:AOF 其实是个日志重放
做完基本功能后,最自然的下一步是持久化。Redis 的 AOF(Append Only File)机制说起来特别朴素:把执行的写命令以 RESP 格式追加到文件末尾,重启时逐条重放。因为你已经实现了 RESP 编码器,这个功能天然好做:
- 每次
SET、DEL、EXPIRE、INCR执行成功后,把原始命令重新编码成 RESP 数组写进文件。 - 重启时打开文件,用现成的
ReadRESP循环读取,逐条执行。 - 文件太大时,先写一条
SELECT之类的全量快照命令,再清空追加日志。
理解 AOF 的关键在于:日志里存的不是值,是命令本身。这就是为什么叫"写前日志"还是"写后日志"都不准确,准确的说法是"命令日志"。我自己在实现时最大的收获是,AOF 根本没用到任何黑魔法,它就是"把客户端发来的东西再存一份"。
6.2 更多数据类型与 RESP3
目前的实现只支持 string 类型,这离"像 Redis"还很远。继续扩展的方向很清晰:hash、list、set、zset 在协议层的差别其实不大,最终还是落到批量字符串和数组的组合。比如 HGETALL 返回的是一个扁平数组,key1 value1 key2 value2 这样交替排列;LRANGE 返回一个数组;SADD 返回整数。命令语义比协议格式麻烦得多,zset 的跳表实现又是另一座山。
RESP3 的扩展也值得研究,它新增了 map、set、push、bool、double 这些类型,第一个字节分别是 %、~、>、#、, 。协议难度不大,但客户端库的兼容策略五花八门,把 HELLO 握手时序搞清楚,比写解析器更考验人。
6.3 性能方向与读源码的入口
如果你想在这个项目上继续榨性能,方向有两个。一是把连接模型改成单线程 epoll 事件循环,去掉 goroutine 和锁,贴近真实 Redis 的架构,观察 QPS 和延迟的变化。二是优化响应编码,把 writeBulk 的多次 Write 调用合并成一次 Write 或 Writev,减少系统调用次数。压测后你会发现,协议解析通常不是瓶颈,网络读写和锁竞争才是。
哪天想读官方源码,建议从 networking.c 的 readQueryFromClient 读起,再配合你手写的解析器对照看。你会发现核心流程思路惊人地一致:读缓冲 -> 解析命令 -> 查命令表 -> 执行 -> 写缓冲。区别只在于 Redis 用了一个 multi bulk 解析器的状态机,而你用的是递归下降。看到那里,你就明白自己写的这个轮子和真正的工业级实现之间的差距在哪,也明白为什么 Redis 能把单线程做到那么快。
我自己做完这整个项目的体会是:别小看这种"重复造轮子"的活。很多技术你用了三五年,以为懂了,实际是"会用",离"懂得"还差着一个协议的距离。RESP 协议不复杂,一个星期足够搞定,但这一周里踩过的每个坑,都会在你下次排查线上 Redis 问题时变成直觉。如果你也想动手写一个,别追求功能全,先定一个小目标:让 redis-cli 和 redis-benchmark 跑通,你就算真正掌握 RESP 了。
