西贝的代码宇宙

Back

上一篇文章介绍了 Godis 中 RESP2 协议的实现。通过 RESP Parser,Godis 已经能够把客户端发送的字节流解析成 resp.Value;通过 RESP Writer,也能够把执行结果重新编码成 RESP 回复。

不过,协议层只解决了客户端与服务端之间如何交换数据的问题。当 Parser 解析出下面这条命令后:

SET name MrSibe
text

Godis 还需要回答两个问题:

  1. name 和 MrSibe 最终应该保存在哪里?
  2. Godis 如何知道应该执行 SET,而不是 GET 或其他命令?

这篇文章将实现 Godis 的内存数据库,并通过一个简单的命令注册表,把 RESP 协议层、命令层和数据库层连接起来。

Github仓库:MrSibe/godis: A Redis-compatible in-memory key-value store written in Go. ↗

1. 从一个简单的 map 开始#

Godis 是一个内存键值数据库,因此最直接的存储方式就是使用 Go 内置的 map。如果当前只考虑 String 类型,可以先写成:

type DB struct {
    data map[string]string
}
go

例如 db.data["name"] = "MrSibe"。在这个基础上,实现 SET、GET 和 DEL 并不复杂:

不过,map[string]string 只适合最初的 String 类型。随着项目继续开发,Godis 还需要支持 List、Hash、Set 和 ZSet 等不同的数据类型,因此需要一个更通用的数据表示方式。

2. 用 Object 统一表示不同类型的数据#

在 Godis 中,我定义了一个 ObjectType 表示对象类型:

package database

type ObjectType uint8

const (
    StringType ObjectType = iota
    ListType
    HashType
    SetType
    ZSetType
)
go

然后定义一个 Object,同时保存对象类型和实际数据:

type Object struct {
    Type  ObjectType
    Value any
}
go

这样,数据库就不再使用map[string]string,而是使用:map[string]*Object

3. 封装数据库结构与操作#

最终,Godis 的数据库结构如下:

type DB struct {
    mu   sync.RWMutex
    data map[string]*Object
}
go

Godis Server 可以接受多个TCP连接,但 Go 内置 map 并不保证无保护的并发读写安全。因此,Godis 使用 sync.RWMutex 保护底层 map。

RWMutex 是一种读写锁:

  • 可以同时被多个读操作持有;
  • 写操作需要独占整个锁;
  • 有写操作时,其他读写操作都需要等待。

4. 为什么没有使用 sync.Map#

Go 标准库还提供了并发安全的 sync.Map,那么为什么 Godis 仍然选择普通 map 加 RWMutex?

一个原因是 sync.Map 是面向特定访问模式设计的,而不是普通 map 的通用替代品。Go 官方文档 ↗也建议,大多数代码使用普通 map 配合单独的锁,这样能够获得更好的类型安全,也更容易维护与 map 内容相关的其他约束。等到将来通过 Benchmark 确定锁确实成为性能瓶颈后,再考虑分片锁或者其他并发方案会更加合理。

5. 从 RESP 到数据库操作#

到这里,Godis 已经有了两个独立的模块:RESP 负责字节流与 resp.Value 之间的转换,数据库层负责 key 与对象的存储。但二者还没有被连接起来。

以 SET name MrSibe 为例,RESP Parser 会把它解析成:

resp.Value{
    Type: resp.Array,
    Array: []resp.Value{
        {Type: resp.BulkString, String: "SET"},
        {Type: resp.BulkString, String: "name"},
        {Type: resp.BulkString, String: "MrSibe"},
    },
}
go

要让这条命令真正写入数据库,中间还需要一个模块完成下面的转换:

["SET", "name", "MrSibe"]
            ↓
找到 SET 对应的执行函数
            ↓
调用 db.Set("name", "MrSibe")
            ↓
返回 +OK
text

最直接的做法是在一个函数里用巨大的 switch 分发命令,但命令越来越多时,switch 会不断膨胀。因此 Godis 使用命令注册表:用 map 把命令名映射到执行函数,新增命令时只需要注册一条映射。

6. 定义统一的 Handler 与命令注册表#

为了让所有命令都能放进同一个 map,先定义统一的函数签名:

type Handler func(
    db *database.DB,
    args []resp.Value,
) resp.Value
go

其中 db 是所有命令共享的数据库实例;args 是除命令名以外的参数,命令名已经由分发器取出。例如 SET name MrSibe 对应的 args 是 ["name", "MrSibe"]。

命令层不直接向 TCP 连接写入字节,而是返回一个 resp.Value,交给 Writer 序列化。即使命令不需要访问数据库,也保持相同的签名,例如 PING 用 _ 忽略 db 参数:

func ping(_ *database.DB, args []resp.Value) resp.Value {
    // ...
}
go

这样,命令注册表就是一张普通的映射表:

var handlers = map[string]Handler{
    "PING":   ping,
    "ECHO":   echo,
    "SET":    set,
    "GET":    get,
    "DEL":    del,
    "EXISTS": exists,
}
go

添加新命令只需要两步:实现一个 Handler,然后在 handlers 中注册,分发器完全不需要了解命令内部的实现。Godis 当前注册了 PING、ECHO、SET、GET、DEL 和 EXISTS 六个命令。

7. 实现命令分发#

分发由 Execute 完成,它校验请求格式,取出并规范化命令名,最后从注册表中找到 Handler 并调用:

它依次完成四件事:

  1. 校验请求格式:一条标准命令请求必须是非空 Array。既不是 Array、或者 Array 为空都会返回 ERR invalid command;
  2. 取出命令名:Array 的第一个元素;
  3. 规范化并查找:Redis 命令不区分大小写,所以先用 strings.ToUpper 转成大写再查表,GET、get、Get 都能命中同一个 Handler;查不到则返回 ERR unknown command 'xxx';
  4. 执行:把 req.Array[1:]——去掉命令名后的参数——连同数据库实例一起传给 Handler。

8. 实现 SET 与 GET#

8.1 SET#

SET 需要两个参数。参数数量不对时直接返回错误;正确时把值包装成 StringType 的 Object 写入数据库:

例如 SET name 或 SET name MrSibe extra 都会因为参数数量不等于 2 而报错。执行成功返回的 SimpleString("OK") 会被 Writer 编码为 +OK\r\n。

8.2 GET#

GET 只接收一个 key,读取后要区分三种情况:

  • key 不存在:返回 Null Bulk String,被序列化为 $-1\r\n。注意不能返回空字符串,因为空字符串本身也可能是合法值;
  • 类型不匹配:key 存在但不是 String 时返回 WRONGTYPE 错误。类型检查必须先于类型断言 obj.Value.(string),否则断言会 panic;
  • 正常情况:把 String 内容包装成 Bulk String 返回,例如 $6\r\nMrSibe\r\n。

9. 实现 DEL 与 EXISTS#

9.1 DEL#

当前 DEL 只支持一个 key,删除成功与 key 不存在时分别返回 :1 与 :0:

9.2 EXISTS#

EXISTS 接收一个或多个 key,统计其中存在的个数:

例如数据库中存在 a 和 b 时,EXISTS a b c 返回 :2——三个 key 里只有两个存在。

10. 一条 SET 命令的完整执行过程#

现在把两篇文章中的模块连接起来:

flowchart LR
    Client[Redis Client]
    Parser[RESP Parser]
    Request[RESP Value]
    Execute[Command Execute]
    Registry[Handler Registry]
    Handler[SET Handler]
    DB[(In-memory DB)]
    Reply[RESP Value]
    Writer[RESP Writer]

    Client -->|RESP 字节流| Parser
    Parser --> Request
    Request --> Execute
    Execute --> Registry
    Registry --> Handler
    Handler --> DB
    Handler --> Reply
    Reply --> Writer
    Writer -->|RESP 字节流| Client

客户端发送的 SET name MrSibe 在网络上被编码成:

*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$6\r\nMrSibe\r\n
text

随后依次发生:

  1. Parser 把它解析成 Array 形式的 resp.Value;
  2. Execute 校验请求、取出命令名 SET,并从注册表中找到 set;
  3. set 检查参数数量,构造一个 StringType 的 Object;
  4. db.Set 在写锁保护下把对象写入 map;
  5. 返回 SimpleString("OK"),Writer 编码为 +OK\r\n 写回客户端。

服务端每个连接的处理循环中,核心调用只有一行 command.Execute(s.db, value):

当前 Godis 的 Server 为所有连接共享同一个数据库实例,并在每个连接的处理循环中依次完成解析、执行、序列化和刷新。GitHub ↗

12. 当前实现还存在哪些不足#

目前的实现已经能支撑基础命令,但仍然是早期版本,存在几个明显的局限。

12.1 map 并发安全不等于命令原子性#

DB.Get、DB.Set 和 DB.Del 的锁只覆盖单次方法调用。例如 EXISTS 循环调用多次 Get,每次 Get 返回都会释放读锁,两次检查之间其他客户端可能修改数据库。也就是说“每一次 map 访问并发安全”并不等于“整条命令原子”。以后实现多 key 命令和事务时,需要让锁覆盖整条命令,或引入更明确的读写事务接口。

12.2 Object 暴露了可变数据#

Get 直接返回 *Object,而 Object.Value 是公开的 any。String 本身不可变,暂时没有风险;将来加入 List、Hash 这类可变结构后,命令层可能在数据库锁之外修改它们。届时可以让字段私有、为不同类型提供构造函数、把复合对象的修改收敛到数据库锁内,或引入 View、Update 之类的访问接口。

12.3 注册表只有 Handler#

参数数量检查由每个 Handler 自己重复编写。以后可以把注册信息扩展成带元数据的结构:

type Command struct {
    Name    string
    Arity   int
    Flags   CommandFlags
    Handler Handler
}
go

分发器就可以统一检查参数,也能为后续的锁、AOF 和权限系统提供命令元数据。

12.4 请求参数缺少严格的类型检查#

Execute 默认请求是 Array 且元素都是 Bulk String,直接读取 req.Array[0].String。标准客户端确实符合这个结构,但服务端仍应防御格式错误的请求。后续可以把请求先转换成明确的结构,例如 Request{Command string; Args [][]byte},在转换时统一校验请求类型与参数类型。

12.5 命令兼容性有限#

当前只实现了最基础的形式:DEL 只支持一个 key,SET 也还没有过期时间和条件写入等选项,与真实 Redis 的行为仍有差距,需要通过兼容性测试逐步补齐。

结语#

到这里,Godis 已经拥有了一条完整的基础命令执行链路:

RESP Parser
    ↓
Command Dispatcher
    ↓
Command Handler
    ↓
In-memory Database
    ↓
RESP Writer
text

数据库层用 map[string]*Object 保存 key 与对象之间的映射,用 sync.RWMutex 保护多个客户端的并发访问;命令层用 map[string]Handler 建立命令名与执行函数之间的映射。分发器只负责识别命令,参数检查和具体的数据库操作由对应的 Handler 完成。

这一部分的代码量并不大,但实现之后,我对几个问题有了更具体的认识:

  • 一个普通 map 如何被封装成数据库接口;
  • key 不存在和空字符串为什么必须区分;
  • 多个连接共享数据库时为什么需要进行并发控制;
  • 命令逻辑为什么不应该堆在一个巨大的 switch 中;
  • RESP Value 如何经过命令分发,最终变成一次数据库操作。

目前 Godis 已经可以通过 redis-cli 执行 PING、ECHO、SET、GET、DEL 和 EXISTS,但它仍然只是一个不会保存过期时间、重启后会丢失全部数据的内存数据库。

用 Go 实现 Redis(二):用 map 实现内存数据库与命令分发
https://mrsibe.top/blog/godis/2-in-memory-db-and-command-dispatch
Author 啊啊啊啊西贝
Published at 2026年9月1日