从零手写简易 Redis

Lirous.
12/10/2025
18 min read
Build Your Own Redis Challenge 的一次摸索

上周花了差不多一周的时间从零手写了一个简易 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 规范的细节,特别是理解了:

  1. 必须 \r\n 结尾,不能偷懒
  2. 长度是字节数,字符编码有差异时要小心
  3. 缓冲区要能处理粘包,不是一次性解析一条

...之后,一切都清楚了。我删掉了一堆乱七八糟的 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)和集群,但那得另外开坑了。


资源推荐

评论区