上周花了差不多一周的时间从零手写了一个简易 Redis 服务器。没用任何 Redis 相关的库,就纯写的 TCP 服务器 +
RESP 协议解析 + 内存存储。当第一次用 redis-cli
连上自己写的服务器跑通 GET/SET 命令时,那种感觉真的爽到不行。
这篇记录一下整个过程,特别是 RESP 协议解析这块的坑。
什么是 RESP,为什么 Redis 需要它
Redis 的客户端和服务器之间用 TCP 通信。但 TCP 是一个二进制流,你怎么知道一个命令的开始和结束?怎么区分不同的参数?
答案就是协议。RESP(Redis Serialization Protocol)就是 Redis 定义的这套规则。
简单来说,RESP 把所有东西都分类成几种基本类型:
+— 简单字符串(Simple String)-— 错误(Error):— 整数(Integer)$— 批量字符串(Bulk String)*— 数组(Array)
每种类型都有固定的格式,最后用 \r\n 结尾。
举个例子,当你在 redis-cli 里敲 SET foo bar
时,客户端会把这个命令编码成 RESP 格式,发送给服务器:
*3\r\n
$3\r\n
SET\r\n
$3\r\n
foo\r\n
$3\r\n
bar\r\n
分解一下:
*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"
看起来很简单,对吧?但实际手写的时候,坑特别多。
踩的坑:RESP 解析没那么直白
坑 1:\r\n 和 \n 的区别
最开始我特别不理解为什么一定要 \r\n 而不是就 \n。我觉得 \n
不就够了吗?
结果特别惨。我自己写的客户端用 \n
测试没问题(因为两个都能跑),但一连上正儿八经的 redis-cli,直接炸裂。我花了两个小时 Debug 才发现——redis-cli 发送的数据里,\r
没有被正确处理。
记住:RESP 协议的每行结尾必须是
\r\n(回车+换行)。这不是建议,是强制的。
我的解决方案:
// 不要直接用 strings.Split("\n")
// 要这样做
lines := strings.Split(data, "\r\n")
// 更好的做法:用 bufio.Scanner 但要自定义分割函数
scanner := bufio.NewScanner(conn)
scanner.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) {
// 找到 \r\n 的位置
for i := 0; i < len(data)-1; i++ {
if data[i] == '\r' && data[i+1] == '\n' {
return i + 2, data[:i], nil
}
}
return 0, nil, nil
})
坑 2:批量字符串的长度陷阱
$ 开头的批量字符串,第一行是 $<length>\r\n,然后才是真正的数据。
我一开始理解错了,以为 <length>
是字符数。结果当遇到包含特殊字符的字符串时,直接出问题。
比如,一个 key 是 foo\r\n 这样的(虽然很奇葩,但技术上可能):
*2\r\n
$7\r\n
GET\r\n
$7\r\n
foo\r\n\r\n
这里 foo\r\n
是一个 7 字节的字符串(f、o、o、\r、\n、还有两个字符... 等等,我数不对)。
关键理解:长度是字节数,不是字符数,也不是"有多少个真实的可见字符"。
// 正确的做法
func parseRESP(data []byte) (parsed interface{}, remaining []byte) {
if len(data) < 2 {
return nil, data // 还没读够一行
}
switch data[0] {
case '$': // 批量字符串
// 找到第一个 \r\n
idx := bytes.Index(data, []byte("\r\n"))
if idx == -1 {
return nil, data // 还没读够
}
lengthStr := string(data[1:idx])
length, _ := strconv.Atoi(lengthStr)
// 数据应该在 idx+2 开始,长度为 length,然后是 \r\n
dataStart := idx + 2
dataEnd := dataStart + length
// 检查是否有完整的 \r\n
if dataEnd+2 > len(data) {
return nil, data // 还没读够
}
actualData := data[dataStart:dataEnd]
remaining := data[dataEnd+2:] // 跳过 \r\n
return string(actualData), remaining
// ... 其他类型
}
}
坑 3:缓冲区粘包问题
TCP 是流式传输,没有消息边界。如果客户端一次发了多条命令,你可能会一口气收到一堆数据。
比如:
*1\r\n$4\r\nPING\r\n*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
这里一次发了 PING 和 SET 两条命令。
我最初的代码就傻等一条完整的命令,结果卡住了。解决方法是:解析一条命令后,继续解析剩余的数据。
// 错误做法
for {
data, _ := readFromClient()
cmd := parseRESP(data) // 假设这能完整解析一条
execute(cmd)
}
// 正确做法
buffer := make([]byte, 0)
for {
newData, _ := readFromClient()
buffer = append(buffer, newData...)
for {
cmd, remaining := parseRESP(buffer)
if cmd == nil {
break // 数据不完整,等下次读
}
execute(cmd)
buffer = remaining
}
}
坑 4:response 的格式
服务器返回给客户端的数据也要是 RESP 格式。最常见的就是简单字符串和错误。
// 返回 OK
+OK\r\n
// 返回错误
-ERR unknown command\r\n
// 返回整数
:42\r\n
// 返回 nil
$-1\r\n
// 返回字符串
$3\r\nbar\r\n
我一开始随意返回字符串,结果 redis-cli 显示得特别奇怪。后来才意识到客户端也在解析 RESP,所以服务器的 response 也得是标准格式。
核心命令的实现
等搞定了 RESP 协议以后,实现命令就相对直白了。
SET 和 GET
最基础的两个命令:
type RedisServer struct {
data map[string]string
mu sync.RWMutex
}
func (r *RedisServer) handleCommand(cmd []string) (response string) {
if len(cmd) == 0 {
return "-ERR empty command\r\n"
}
command := strings.ToUpper(cmd[0])
switch command {
case "SET":
if len(cmd) < 3 {
return "-ERR wrong number of arguments for 'set' command\r\n"
}
key := cmd[1]
value := cmd[2]
r.mu.Lock()
r.data[key] = value
r.mu.Unlock()
return "+OK\r\n"
case "GET":
if len(cmd) < 2 {
return "-ERR wrong number of arguments for 'get' command\r\n"
}
key := cmd[1]
r.mu.RLock()
value, exists := r.data[key]
r.mu.RUnlock()
if !exists {
return "$-1\r\n" // nil
}
return fmt.Sprintf("$%d\r\n%s\r\n", len(value), value)
case "PING":
if len(cmd) == 1 {
return "+PONG\r\n"
}
// PING 可以带参数,原样返回
return fmt.Sprintf("$%d\r\n%s\r\n", len(cmd[1]), cmd[1])
default:
return fmt.Sprintf("-ERR unknown command '%s'\r\n", command)
}
}
DEL 和 EXISTS
再加上删除和存在检查:
case "DEL":
if len(cmd) < 2 {
return "-ERR wrong number of arguments for 'del' command\r\n"
}
count := 0
r.mu.Lock()
for _, key := range cmd[1:] {
if _, exists := r.data[key]; exists {
delete(r.data, key)
count++
}
}
r.mu.Unlock()
return fmt.Sprintf(":%d\r\n", count)
case "EXISTS":
if len(cmd) < 2 {
return "-ERR wrong number of arguments for 'exists' command\r\n"
}
count := 0
r.mu.RLock()
for _, key := range cmd[1:] {
if _, exists := r.data[key]; exists {
count++
}
}
r.mu.RUnlock()
return fmt.Sprintf(":%d\r\n", count)
那一刻,RESP 突然开窍了
我最头疼的时候是协议部分。每次写完一个功能,连上 redis-cli 就出各种奇怪的问题——要么命令解析不对,要么 response 格式错误。我在本地写了一堆 debug 代码,还一度想放弃改用现成的 RESP 库。
但当我花时间仔细读了一遍 RESP 规范的细节,特别是理解了:
- 必须
\r\n结尾,不能偷懒 - 长度是字节数,字符编码有差异时要小心
- 缓冲区要能处理粘包,不是一次性解析一条
...之后,一切都清楚了。我删掉了一堆乱七八糟的 workaround,代码反而变得更简洁。
然后有一天下午,我启动自己写的服务器,连上 redis-cli,直接跑了几条命令:
$ redis-cli -p 6379
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> SET mykey "Hello, Redis!"
OK
127.0.0.1:6379> GET mykey
"Hello, Redis!"
127.0.0.1:6379> DEL mykey
(integer) 1
127.0.0.1:6379> GET mykey
(nil)
一条条都成功了。
那一刻真的有种"啊,原来如此"的感觉。不是那种"哦 nice",而是从骨子里理解了"为什么 Redis 要用这样的协议,每个细节都有意义"。
继续扩展
之后我又加了一些常用命令:
- INCR/DECR — 数字自增自减
- LPUSH/RPUSH/LPOP/RPOP — List 操作
- HSET/HGET/HGETALL — Hash 操作
- SADD/SMEMBERS — Set 操作
- EXPIRE/TTL — 过期时间
每加一个新命令,我都更熟悉 RESP 协议了。特别是 HGETALL 这种返回数组的命令,要正确编码每个元素的长度和换行符。
case "HGETALL":
// 返回格式:*<count*2>\r\n$<len1>\r\n<field1>\r\n$<len2>\r\n<value1>\r\n...
// 因为 hash 的 field 和 value 交替出现
hash := r.getHash(key)
response := fmt.Sprintf("*%d\r\n", len(hash)*2)
for field, value := range hash {
response += fmt.Sprintf("$%d\r\n%s\r\n", len(field), field)
response += fmt.Sprintf("$%d\r\n%s\r\n", len(value), value)
}
return response
心得
从零手写 Redis,最大的收获不是"我会写 Redis 了"(肯定不会真的写出生产级的),而是理解了为什么协议设计要这样。
RESP 看起来很琐碎,一堆 \r\n 一堆长度,但每个设计都在解决问题:
- 明确的类型标识(+、-、:、$、*)让解析器能区分不同的数据
- 前置长度让接收端不用等到 delimiter 才知道数据到哪里,能正确处理二进制数据
\r\n分隔符虽然老土,但在网络协议里还是最稳健的
以前只知道"Redis 很快",现在理解了一部分原因——设计得好的协议,协议解析的开销就很低。
下一步想试试实现持久化(RDB 或 AOF)和集群,但那得另外开坑了。
资源推荐:
- https://www.build-redis-from-scratch.dev/ — 详细的 Redis 从零实现教程
- https://codecrafters.io/challenges/redis — 边做边学的实战挑战
- Redis 官方文档 RESP 部分 — https://redis.io/docs/reference/protocol-spec/