从 0 手敲一个 Mini Redis:第一轮,我先把内存核心跑通了
本文 git 分支:https://github.com/yupaiLy/twopair-miniredis/tree/stage/01-core-foundation
摘要
最近我开始尝试从 0 手敲一个 Mini Redis。第一轮我没有急着写网络层、协议层和命令分发,而是先沉下心把 Redis 最核心的内存存储层搭起来。
这一轮我完成并测试通过了 BytesWrapper、RedisData、RedisString、RedisCore、RedisCoreImpl 这 5 个核心部分,也第一次真正理解了 Redis 项目里“协议层、命令层、核心层”之间的职责划分。
这篇文章记录我第一轮手敲 Mini Redis 的思路、类设计原因,以及我在这个过程中获得的理解。
一、为什么我第一轮没有直接写服务端
一开始学这种项目时,最容易走偏的一条路就是:
- 一上来就写服务端
- 想着尽快连上
redis-cli - 然后再慢慢补协议和存储逻辑
但实际做下来我发现,这样很容易把自己绕晕。
因为一个 Redis 类项目,至少可以拆成下面几层:
- 网络层:监听端口、接收连接、读写 TCP 数据
- 协议层:负责 RESP 编解码
- 命令层:负责把请求分发到
SET、GET等命令 - 核心存储层:真正管理内存里的 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 是最适合起步的。
因为它天然能支撑两个最经典、最基础的命令:
SETGET
如果连 string 都没实现,后面的 list、hash、set 更没有必要提前碰。
3. 它的结构应该怎么理解
我最终把 RedisString 理解成一个非常简单的结构:
BytesWrapper valuelong timeout
也就是说,一个字符串类型的 Redis 数据,本质上就是:
一个值 + 一个过期时间
其中:
value用来存真正的数据timeout用来表示过期时间-1表示不过期
4. 我在这里学到的东西
这一步让我开始真正进入 Redis 的“值对象”思维,而不是普通 Java 项目的“字段对象”思维。
也就是说,Redis 里的每一个 value,不是散落的原始数据,而是带有完整语义的数据实体。
六、RedisCore:我第一次把“数据库核心层”独立出来了
第四个文件是 RedisCore。
1. 这个类是干嘛的
它定义了 Redis 核心存储层对外提供的能力。
在第一轮里,我只给它保留了最小的三个方法:
putgetexist
2. 为什么要有它
一开始我也想过,后面的 Set、Get 命令是不是直接操作 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,而是先用最直接的本地方式做验证:
- 创建
RedisCoreImpl - 构造一个 key,比如
name - 构造一个
RedisString值,比如twopair - 调用
put - 再调用
get - 最后调用
exist
当我成功取回 twopair,并确认 exist(name) 返回 true 时,我第一次明显感觉到:
这个 Mini Redis 虽然还只是第一轮,但它的“数据库核心”已经真正工作起来了。
十、第一轮我最大的收获
如果只看代码量,这一轮并不算多。
但如果从理解层面看,这一轮非常重要,因为它帮我理清了一个过去经常混淆的点:
Redis 并不首先是一个网络服务,它首先是一个数据系统。
以前我看类似项目时,注意力总会被这些东西吸走:
- 服务端怎么监听端口
redis-cli怎么连接- RESP 协议怎么解析
但现在我更清楚了:
如果没有核心存储层,前面的协议和网络层都是空转;
只有先把“数据库本体”搭出来,后面的协议、命令和网络才真正有意义。
十一、下一轮我要做什么
第一轮完成后,下一轮我准备开始进入 RESP 协议层。
下一轮我预计会实现这些内容:
RespTypeSimpleStringBulkStringRespArrayRespIntErrorsResp.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 手敲源码项目”的过程感兴趣,可以继续关注我的更新。