本文 git 分支:https://github.com/yupaiLy/twopair-miniredis/tree/stage/01-core-foundation

摘要

最近我开始尝试从 0 手敲一个 Mini Redis。第一轮我没有急着写网络层、协议层和命令分发,而是先沉下心把 Redis 最核心的内存存储层搭起来。
这一轮我完成并测试通过了 BytesWrapperRedisDataRedisStringRedisCoreRedisCoreImpl 这 5 个核心部分,也第一次真正理解了 Redis 项目里“协议层、命令层、核心层”之间的职责划分。
这篇文章记录我第一轮手敲 Mini Redis 的思路、类设计原因,以及我在这个过程中获得的理解。


一、为什么我第一轮没有直接写服务端

一开始学这种项目时,最容易走偏的一条路就是:

  • 一上来就写服务端
  • 想着尽快连上 redis-cli
  • 然后再慢慢补协议和存储逻辑

但实际做下来我发现,这样很容易把自己绕晕。

因为一个 Redis 类项目,至少可以拆成下面几层:

  • 网络层:监听端口、接收连接、读写 TCP 数据
  • 协议层:负责 RESP 编解码
  • 命令层:负责把请求分发到 SETGET 等命令
  • 核心存储层:真正管理内存里的 key-value 数据

如果底层存储能力都没有建立起来,那么前面无论是连接、协议还是命令分发,都只是“外壳跑起来了”,还没有真正的数据库核心。

所以我第一轮只做一件事:

先把 RedisCore 这一层做出来。


二、第一轮的目标到底是什么

这一轮我的目标非常明确,就是先做出一个最小可用的内存数据库内核。

具体来说,我希望它至少具备以下能力:

  • 能把一个 key 对应的 value 存进内存
  • 能根据 key 把 value 再取出来
  • 能判断某个 key 是否存在
  • 为未来的 TTL 功能预留结构

换句话说,这一轮完成之后,我的程序虽然还不是一个完整的 Redis 服务,但它已经具备了 Redis 最基础的“数据本体”。


三、BytesWrapper:我为什么没有直接用 String

第一轮里,我最先实现的类是 BytesWrapper

1. 这个类是干嘛的

它的作用是对 byte[] 做一层包装,让字节数组能够安全地作为 Map 的 key 使用。

2. 为什么要有它

一开始我也想过,直接用 String 不就行了吗?

但很快我就意识到,这样会把协议层、字符编码层和存储层过早地耦合在一起。

Redis 的底层其实更偏向“字节语义”:

  • 网络上传输的是字节流
  • RESP 协议本质上处理的是字节
  • Value 并不一定只是普通文本,也可能是二进制安全内容

更关键的一点是,Java 原生的 byte[] 并不适合作为 HashMap 的 key,因为它默认比较的是引用地址,而不是内容本身。

所以如果我要在后面使用这样的结构:

Map<BytesWrapper, RedisData>

那我就必须先让 key 支持:

  • 基于内容的 equals
  • 基于内容的 hashCode

这也是 BytesWrapper 存在的根本原因。

3. 我在这个类里学到的东西

这个类虽然很小,但它让我第一次真正意识到:

Redis 更像一个“二进制安全的数据系统”,而不是一个单纯处理 Java 字符串的系统。

这个认知,对我后面理解 RESP 协议和数据结构非常重要。


四、RedisData:我先抽出了统一的数据抽象

第二个实现的是 RedisData

1. 这个类是干嘛的

准确说它不是一个类,而是一个接口。它代表“Redis 中的一种数据类型”。

2. 为什么要有它

虽然第一轮我只准备实现 string 类型,但从一开始我就知道,Redis 不可能只有字符串。

后面还会有:

  • list
  • hash
  • set
  • zset

所以如果我一开始就把底层存储写死成:

Map<BytesWrapper, BytesWrapper>

那后面扩展其它数据结构时,整个底层都要推翻重来。

因此,从设计上我更应该写成:

Map<BytesWrapper, RedisData>

也就是说,所有 Redis 的 value 统一用 RedisData 来抽象。

3. 为什么接口里目前只保留 timeout 相关方法

一开始我也有点困惑:既然是 Redis 的统一数据抽象,为什么方法这么少?

后来我想明白了,接口里放的应该是“所有数据类型都共享的能力”,而不是某一种类型特有的行为。

当前最明显的公共能力就是:

  • 每种数据都可能绑定过期时间

所以第一轮里我只保留了:

  • timeout()
  • setTimeout(long timeout)

4. 我在这里学到的东西

这一层让我第一次比较清晰地感受到:

抽象的意义不在于“高级”,而在于“为未来扩展预留空间”。


五、RedisString:我的第一种 Redis 数据类型

接下来我实现的是 RedisString

1. 这个类是干嘛的

它表示 Redis 里的字符串类型数据。

2. 为什么先实现它

在 Redis 的几种核心数据类型里,string 是最适合起步的。

因为它天然能支撑两个最经典、最基础的命令:

  • SET
  • GET

如果连 string 都没实现,后面的 list、hash、set 更没有必要提前碰。

3. 它的结构应该怎么理解

我最终把 RedisString 理解成一个非常简单的结构:

  • BytesWrapper value
  • long timeout

也就是说,一个字符串类型的 Redis 数据,本质上就是:

一个值 + 一个过期时间

其中:

  • value 用来存真正的数据
  • timeout 用来表示过期时间
  • -1 表示不过期

4. 我在这里学到的东西

这一步让我开始真正进入 Redis 的“值对象”思维,而不是普通 Java 项目的“字段对象”思维。

也就是说,Redis 里的每一个 value,不是散落的原始数据,而是带有完整语义的数据实体。


六、RedisCore:我第一次把“数据库核心层”独立出来了

第四个文件是 RedisCore

1. 这个类是干嘛的

它定义了 Redis 核心存储层对外提供的能力。

在第一轮里,我只给它保留了最小的三个方法:

  • put
  • get
  • exist

2. 为什么要有它

一开始我也想过,后面的 SetGet 命令是不是直接操作 Map 就行了?

但如果这样做,会有几个明显问题:

  • 命令层和底层存储实现耦合死
  • 公共逻辑无处安放
  • 层次会越来越混乱

从职责上来说:

  • 命令层负责“业务语义”
  • 核心层负责“数据管理”

所以我需要一个独立的 RedisCore 层,把这些操作统一抽出来。

3. 我在这里学到的东西

做到这里时,我第一次比较明确地建立起了这个项目的层次感:

  • Server 是网络外壳
  • Decoder 是协议翻译
  • Command 是命令语义
  • Core 才是真正的数据库核心

这对后面继续手敲整个项目帮助很大。


七、RedisCoreImpl:第一版内存数据库终于跑起来了

这一轮最后一个文件,也是最关键的一个文件,是 RedisCoreImpl

1. 这个类是干嘛的

它是 RedisCore 的具体实现,负责真正管理:

key -> RedisData

也就是 Redis 最核心的内存数据库能力。

2. 为什么它能先用 ConcurrentHashMap

在第一轮里,我没有追求复杂实现,而是采用了最直接的内存方案:

ConcurrentHashMap<BytesWrapper, RedisData>

这个设计足够支撑:

  • 插入 key-value
  • 读取 key-value
  • 判断 key 是否存在
  • 在读取时做过期判断

对第一轮来说,已经足够。

3. get() 为什么不是简单的 map.get()

这是这一轮里我感触最深的一点。

get()其实还承担了一个非常重要的职责:

惰性过期删除。

它的逻辑大致是:

  • 如果 key 不存在,返回 null
  • 如果 key 存在且没过期,直接返回数据
  • 如果 key 已经过期,先从 map 中删掉,再返回 null

所以 get() 并不是纯查询,而是顺便维护了数据的有效性。

4. exist() 为什么不能直接写成 containsKey()

这个细节也很有意思。

如果直接调用:

map.containsKey(key)

那么一个已经过期但尚未被清理的 key,仍然会返回 true

这显然不符合 Redis 的语义。

所以更合理的做法是:

return get(key) != null;

这样 exist() 就会自动复用过期判断逻辑。

5. 我在这里学到的东西

这一层让我第一次真正理解:

数据库里的“存在”不是单纯的物理存在,而是语义上的有效存在。

这一点对后面理解 TTL、Expire、惰性删除都很重要。


八、这一轮完成后,我脑子里的结构终于清晰了

当这 5 个类全部写完并测试通过之后,我对这个项目的第一层结构有了比较明确的认识。

我把它总结成这样:

BytesWrapper:负责表示原始字节值
RedisData:抽象 Redis 中的统一数据类型
RedisString:第一种具体数据类型
RedisCore:定义数据库核心能力
RedisCoreImpl:用内存 Map 实现数据库核心能力

也就是说,这一轮虽然还没有协议、没有网络、没有命令分发,但我已经把一个 Mini Redis 最重要的“内核部分”搭出来了。


九、怎么测试

这一轮我没有急着接入 Netty,也没有接 redis-cli,而是先用最直接的本地方式做验证:

  1. 创建 RedisCoreImpl
  2. 构造一个 key,比如 name
  3. 构造一个 RedisString 值,比如 twopair
  4. 调用 put
  5. 再调用 get
  6. 最后调用 exist

当我成功取回 twopair,并确认 exist(name) 返回 true 时,我第一次明显感觉到:

这个 Mini Redis 虽然还只是第一轮,但它的“数据库核心”已经真正工作起来了。


十、第一轮我最大的收获

如果只看代码量,这一轮并不算多。

但如果从理解层面看,这一轮非常重要,因为它帮我理清了一个过去经常混淆的点:

Redis 并不首先是一个网络服务,它首先是一个数据系统。

以前我看类似项目时,注意力总会被这些东西吸走:

  • 服务端怎么监听端口
  • redis-cli 怎么连接
  • RESP 协议怎么解析

但现在我更清楚了:

如果没有核心存储层,前面的协议和网络层都是空转;
只有先把“数据库本体”搭出来,后面的协议、命令和网络才真正有意义。


十一、下一轮我要做什么

第一轮完成后,下一轮我准备开始进入 RESP 协议层。

下一轮我预计会实现这些内容:

  • RespType
  • SimpleString
  • BulkString
  • RespArray
  • RespInt
  • Errors
  • Resp.getString()
  • Resp.getNumber()
  • Resp.decode()
  • Resp.write()

下一轮的目标会变成:

把网络上的字节流,解析成结构化的 RESP 对象。

也就是开始打通:

TCP 字节流 -> RESP 对象

等 RESP 这一层打通后,后面就可以自然衔接到:

RESP 对象 -> Command 对象 -> RedisCore

十二、结语

回头看,第一轮虽然只实现了 5 个类,但我觉得它非常值。

因为这一轮不是“看懂了一个别人已经写好的项目”,而是:

我亲手把一个 Mini Redis 的内存核心从 0 敲了出来。

这和单纯看源码的感觉完全不一样。

它让我真正开始理解:

  • 为什么 Redis 更接近字节语义
  • 为什么要有统一的数据抽象
  • 为什么要把 Core 层独立出来
  • 为什么 get() 不只是查询,而还承担过期语义

接下来我会继续推进第二轮,把 RESP 协议层补上。


如果你也在学习 Redis、Netty,或者也想尝试从 0 手敲一个中间件类项目,欢迎一起交流。

这是我这个 Mini Redis 手敲系列的第 1 篇,下一篇我会继续写:

《从 0 手敲一个 Mini Redis:第二轮,我开始实现 RESP 协议层》

如果你对这类“从 0 手敲源码项目”的过程感兴趣,可以继续关注我的更新。