当 Redis 走向闭源:手搓生产级内存数据库内核,从 RESP 协议到无锁事件循环(附 Go 完整代码)
2024 年 Redis 一纸双协议把社区推到了悬崖边,2025 年 Valkey 9.0 已经能跑到每秒 10 亿次请求。本文不堆名词,而是从协议、线程模型、跳表、持久化四个维度把内存 KV 引擎拆开,并用一份可以
go run跑起来的代码,从零手搓一个生产级内核。
一、背景:一场许可证地震,和一个分叉的江湖
如果你 2024 年之后才入行,可能很难体会那一波地震的杀伤力。
Redis 从诞生起就是 BSD-3 协议,宽松到几乎等于"随便用"。但 2024 年 3 月,Redis 母公司宣布所有版本改为双协议:Redis Source Available License v2(RSALv2)+ Server Side Public License v1(SSPLv1)。这两个协议的核心约束是:你不能把它包装成托管的数据库服务拿去卖。对个人自建、私有云内部使用影响有限,但对 AWS ElastiCache、阿里云 Tair、Google Memorystore 这类云厂商,等于被抽掉了地基。
社区的反应很快。Linux 基金会牵头,由 Redis 原核心贡献者从 Redis 7.2.4 拉出了分支 Valkey,沿用 BSD-3 协议,保持 100% 命令与数据格式兼容。Google、AWS、Oracle、Snap 第一时间站台。也就是说,你今天把 redis-cli 连到 Valkey 上,几乎无感。
但"兼容"只是起点,真正的戏肉在性能演进上:
- Valkey 8.0(2024-09):引入异步 IO 线程、内存预取(Prefetch)、内存访问分摊(MAA),把单节点吞吐从 Redis 时代的 ~20W/s 拉到 ~100W/s。
- Valkey 9.0(2025-12):带来原子级槽位迁移、哈希字段级过期、集群模式下的多编号数据库支持,宣称可扩展到 2000 个节点、每秒 10 亿+ 请求。
同一赛道里还站着几个"备胎":Dragonfly(多线程 Shared-Nothing,宣称比 Redis 快 25 倍但兼容性有损)、Microsoft Garnet(MIT 协议,.NET 友好)、KeyDB(Redis 较早的多线程分支)、Redict(另一个纯社区分叉)。选型清单我们放在第五节。
为什么一个写业务 CRUD 的程序员要关心这些? 因为内存 KV 是"把计算机体系结构知识变成钱"的最直接载体:它逼你同时理解网络 IO、内存布局、数据结构、并发模型、持久化与操作系统。读懂一个 Redis/Valkey,比读十本《系统设计》都实在。
二、核心概念:快,到底快在哪
2.1 内存随机访问 vs 磁盘寻道
KV 引擎"快"的底层答案只有一句话:数据在内存里,而且访问路径上没有阻塞系统调用。
- 一次内存随机访问约 50~100 ns;
- 一次 SSD 随机读约 100 µs(10 万 ns);
- 一次机械盘寻道约 10 ms(1000 万 ns)。
差了 3~6 个数量级。所以内存数据库的本质不是"算法多牛",而是"它根本不跟磁盘打交道"。代价是容量受限于内存,且重启即失——这正是后面持久化章节要补的窟窿。
2.2 RESP:一个"人能肉眼读"的协议
Redis 用的是自己设计的 RESP(REdis Serialization Protocol)。它最大的优点是文本协议、可读、易调试,而不是为了极致紧凑。每条消息以 \r\n(CRLF)分隔,首字节标识类型:
| 首字节 | 类型 | 示例 |
|---|---|---|
+ | 简单字符串(Simple String) | +OK\r\n |
- | 错误(Error) | -ERR wrong number of arguments\r\n |
: | 整数(Integer) | :42\r\n |
$ | 批量字符串(Bulk String) | $5\r\nhello\r\n |
* | 数组(Array,命令就是这个) | *2\r\n$3\r\nGET\r\n$4\r\nname\r\n |
客户端发给服务端的命令,永远是一个"批量字符串数组"。比如 SET name qiuge 在网络上长这样:
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nqiuge\r\n
首字节 *3 表示后面有 3 个批量字符串;每个 $N 声明接下来 N 个字节是内容,再跟一个 \r\n。这种"长度前缀 + 定界符"的结构,让解析器可以流式、无回溯地读取,不需要提前知道整条命令多大——这是高性能协议设计的基本功。
2.3 单线程事件循环:为什么单线程还能这么猛
很多人第一次听说"Redis 是单线程处理命令"都觉得是缺陷。但真相是:在纯内存操作 + IO 多路复用面前,单线程反而是最优解之一。
原因有三:
- 没有锁开销。所有命令串行执行,不存在并发修改同一份数据的问题,根本不需要加锁。锁带来的上下文切换、cache line 颠簸,在高频小命令场景下很致命。
- CPU 缓存命中率高。同一个线程反复操作同一批热数据,L1/L2 缓存始终是暖的。
- IO 多路复用。用
epoll(Linux)/kqueue(BSD/macOS)同时盯成百上千个连接,哪个 socket 可读了就处理哪个,一个线程撑起高并发。
那为什么后来又引入了多线程?因为瓶颈转移到了网络上。当单节点要扛 100W/s 请求时,光是把字节从网卡拷贝到用户态、解析 RESP、再把回复写回 socket,就把一个 CPU 核跑满了——而此时真正执行命令的 CPU 其实还很闲。Valkey 8 的做法是:把"网络读写 + 协议解析"卸载到一组 IO 线程,命令执行仍由主线程串行,既突破了网络瓶颈,又保住了无锁语义。这是第四节性能优化的核心。
三、架构分析:一个生产级 KV 引擎由什么组成
把 Valkey/Redis 拆开,主干就这几块:
┌─────────────────────────────────────────────┐
│ 网络层 (socket/epoll) │
├─────────────────────────────────────────────┤
│ 协议层 (RESP 解析 / 编码) │
├─────────────────────────────────────────────┤
│ 命令分发 (SET/GET/HSET/ZADD...) │
├──────────┬──────────┬─────────┬──────────────┤
│ dict │ hash │ list │ zset(skiplist)│ ← 内存数据结构
├──────────┴──────────┴─────────┴──────────────┤
│ 过期 (惰性 + 定时) │ 持久化 (RDB/AOF) │
├─────────────────────────────────────────────┤
│ 复制 / 哨兵 / 集群 (槽位 16384) │
└─────────────────────────────────────────────┘
- 网络层:监听端口,accept 连接,用事件循环驱动。
- 协议层:把字节流解析成命令参数数组,把执行结果编码回 RESP。
- 数据模型:
string:最朴素的键值,底层是 SDS(简单动态字符串)。hash:字段-值映射,小数据用listpack(紧凑连续存储),大了转hashtable。list:双向链表 /listpack+ 紧凑编码。zset(有序集合):跳表(skip list)+ 字典 双结构,跳表保序,字典做 O(1) 查分。
- 过期策略:访问时惰性删除 + 定时随机抽样清理(主动过期),避免全量扫描卡顿。
- 持久化:RDB 快照(某一刻的全量二进制)+ AOF 日志(每条写命令追加,可重放)。
- 高可用:主从复制(异步流)、哨兵(自动故障转移)、集群(16384 槽位分片)。
Valkey 9 的关键演进——原子槽位迁移:老 Redis 集群做再均衡是"逐步迁移",迁移过程中某个 key 的槽位归属可能在两节点间漂移,容易出短暂错误。Valkey 9 改成原子级槽位迁移:交接是一次性、一致性可预测的,在线重分片不再提心吊胆。配合"集群内多编号数据库",微服务可以共用一个集群、逻辑上切成多个库,部署模型直接瘦身。
四、代码实战:用 Go 手搓一个 MiniValkey
下面这份代码完整、可运行:go mod init minivalkey && go run .,然后用 redis-cli -p 6399 连上去,SET/GET/HSET/ZADD/ZRANGE 全都能用,重启后数据还在(AOF 重放)。
设计取舍先说清楚:
- 为了可运行且易读,每个连接用一个 goroutine 处理(而非手写 epoll 事件循环)。但我们用一把全局锁把命令执行串行化,这就等价于 Redis 的"单线程命令语义"——任何时刻只有一个命令在改数据。生产级 Valkey 用 IO 多线程 + 串行命令执行,思路一致,只是网络部分更激进。
zset我用真正的跳表实现,而不是"map 排序"糊弄,因为跳表才是这里最有嚼劲的数据结构。- AOF 我直接把命令以 RESP 格式追加落盘,重启时复用同一个 RESP 解析器重放,零额外代码。
4.1 完整源码
package main
import (
"bufio"
"fmt"
"io"
"math/rand"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
)
const (
listenAddr = ":6399"
aofFile = "minivalkey.aof"
)
// ─────────────────────────────────────────────
// 内存数据模型
// ─────────────────────────────────────────────
type Store struct {
mu sync.Mutex
strs map[string]string
expires map[string]time.Time
hashes map[string]map[string]string
lists map[string][]string
zsets map[string]*zset
}
func NewStore() *Store {
return &Store{
strs: make(map[string]string),
expires: make(map[string]time.Time),
hashes: make(map[string]map[string]string),
lists: make(map[string][]string),
zsets: make(map[string]*zset),
}
}
func (s *Store) isExpired(key string) bool {
if t, ok := s.expires[key]; ok {
return time.Now().After(t)
}
return false
}
func (s *Store) del(key string) {
delete(s.strs, key)
delete(s.expires, key)
delete(s.hashes, key)
delete(s.lists, key)
delete(s.zsets, key)
}
// ─────────────────────────────────────────────
// 跳表:zset 的保序内核
// ─────────────────────────────────────────────
const (
skiplistMaxLevel = 32
skiplistP = 0.25
)
type zNode struct {
member string
score float64
forward []*zNode
}
type skipList struct {
header *zNode
level int
length int
}
func newSkipList() *skipList {
return &skipList{header: &zNode{forward: make([]*zNode, skiplistMaxLevel)}, level: 1}
}
func randomLevel() int {
lvl := 1
for rand.Float64() < skiplistP && lvl < skiplistMaxLevel {
lvl++
}
return lvl
}
// insert 插入或更新成员;相同 member 视为更新分数
func (sl *skipList) insert(member string, score float64) {
update := make([]*zNode, skiplistMaxLevel)
x := sl.header
for i := sl.level - 1; i >= 0; i-- {
for x.forward[i] != nil &&
(x.forward[i].score < score ||
(x.forward[i].score == score && x.forward[i].member < member)) {
x = x.forward[i]
}
update[i] = x
}
next := x.forward[0]
if next != nil && next.member == member {
next.score = score // score 未变时原地更新即可
return
}
lvl := randomLevel()
if lvl > sl.level {
for i := sl.level; i < lvl; i++ {
update[i] = sl.header
}
sl.level = lvl
}
node := &zNode{member: member, score: score, forward: make([]*zNode, lvl)}
for i := 0; i < lvl; i++ {
node.forward[i] = update[i].forward[i]
update[i].forward[i] = node
}
sl.length++
}
func (sl *skipList) remove(member string, score float64) bool {
update := make([]*zNode, skiplistMaxLevel)
x := sl.header
for i := sl.level - 1; i >= 0; i-- {
for x.forward[i] != nil &&
(x.forward[i].score < score ||
(x.forward[i].score == score && x.forward[i].member < member)) {
x = x.forward[i]
}
update[i] = x
}
x = x.forward[0]
if x != nil && x.member == member {
for i := 0; i < sl.level; i++ {
if update[i].forward[i] != x {
break
}
update[i].forward[i] = x.forward[i]
}
for sl.level > 1 && sl.header.forward[sl.level-1] == nil {
sl.level--
}
sl.length--
return true
}
return false
}
// inorder 返回按 (score, member) 升序排列的成员
func (sl *skipList) inorder() []zElem {
out := make([]zElem, 0, sl.length)
x := sl.header.forward[0]
for x != nil {
out = append(out, zElem{member: x.member, score: x.score})
x = x.forward[0]
}
return out
}
type zElem struct {
member string
score float64
}
type zset struct {
sl *skipList
score map[string]float64
}
func newZSet() *zset {
return &zset{sl: newSkipList(), score: make(map[string]float64)}
}
// ─────────────────────────────────────────────
// RESP 编解码
// ─────────────────────────────────────────────
func replySimple(s string) string { return "+" + s + "\r\n" }
func replyError(s string) string { return "-" + s + "\r\n" }
func replyInteger(n int64) string { return ":" + strconv.FormatInt(n, 10) + "\r\n" }
func replyNull() string { return "$-1\r\n" }
func replyBulk(s string) string {
if s == "" {
return "$-1\r\n"
}
return "$" + strconv.Itoa(len(s)) + "\r\n" + s + "\r\n"
}
func replyArray(items []string) string {
var b strings.Builder
b.WriteString("*" + strconv.Itoa(len(items)) + "\r\n")
for _, it := range items {
b.WriteString(replyBulk(it))
}
return b.String()
}
func encodeRESP(args []string) []byte {
var b strings.Builder
b.WriteString("*" + strconv.Itoa(len(args)) + "\r\n")
for _, a := range args {
b.WriteString("$" + strconv.Itoa(len(a)) + "\r\n" + a + "\r\n")
}
return []byte(b.String())
}
// readLine 读取到 CRLF 为止的一行(去掉结尾 \r\n)
func readLine(r *bufio.Reader) (string, error) {
line, err := r.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// readCommand 解析一个 RESP 数组(批量字符串数组)
func readCommand(r *bufio.Reader) ([]string, error) {
line, err := readLine(r)
if err != nil {
return nil, err
}
if line == "" {
return nil, fmt.Errorf("empty line")
}
if line[0] != '*' {
return nil, fmt.Errorf("expected array")
}
n, err := strconv.Atoi(line[1:])
if err != nil {
return nil, err
}
args := make([]string, 0, n)
for i := 0; i < n; i++ {
h, err := readLine(r)
if err != nil {
return nil, err
}
if len(h) == 0 || h[0] != '$' {
return nil, fmt.Errorf("expected bulk string")
}
blen, err := strconv.Atoi(h[1:])
if err != nil {
return nil, err
}
if blen < 0 {
args = append(args, "")
continue
}
buf := make([]byte, blen+2) // 含结尾 \r\n
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
args = append(args, string(buf[:blen]))
}
return args, nil
}
// ─────────────────────────────────────────────
// 命令执行(在锁内串行,等价于单线程语义)
// ─────────────────────────────────────────────
func formatScore(f float64) string { return strconv.FormatFloat(f, 'g', -1, 64) }
func appendAOF(aof *os.File, args []string, replay bool) {
if replay || aof == nil {
return
}
if _, err := aof.Write(encodeRESP(args)); err != nil {
fmt.Println("aof append err:", err)
}
}
func (s *Store) exec(args []string, aof *os.File, replay bool) string {
s.mu.Lock()
defer s.mu.Unlock()
if len(args) == 0 {
return replyError("ERR no command")
}
cmd := strings.ToUpper(args[0])
switch cmd {
case "PING":
if len(args) > 1 {
return replyBulk(args[1])
}
return replySimple("PONG")
case "ECHO":
if len(args) < 2 {
return replyError("ERR wrong number of arguments")
}
return replyBulk(args[1])
case "SET":
if len(args) < 3 {
return replyError("ERR wrong number of arguments")
}
key, val := args[1], args[2]
ttl := 0
for i := 3; i < len(args); i++ {
if strings.ToUpper(args[i]) == "EX" && i+1 < len(args) {
secs, err := strconv.Atoi(args[i+1])
if err != nil {
return replyError("ERR not an integer")
}
ttl = secs
i++
}
}
s.strs[key] = val
if ttl > 0 {
s.expires[key] = time.Now().Add(time.Duration(ttl) * time.Second)
} else {
delete(s.expires, key)
}
appendAOF(aof, args, replay)
return replySimple("OK")
case "GET":
if len(args) != 2 {
return replyError("ERR wrong number of arguments")
}
key := args[1]
if s.isExpired(key) {
s.del(key)
return replyNull()
}
if v, ok := s.strs[key]; ok {
return replyBulk(v)
}
return replyNull()
case "DEL":
if len(args) < 2 {
return replyError("ERR wrong number of arguments")
}
cnt := 0
for _, k := range args[1:] {
if s.isExpired(k) {
s.del(k)
}
if _, ok := s.strs[k]; ok {
s.del(k)
cnt++
continue
}
if _, ok := s.hashes[k]; ok {
s.del(k)
cnt++
continue
}
if _, ok := s.lists[k]; ok {
s.del(k)
cnt++
continue
}
if _, ok := s.zsets[k]; ok {
s.del(k)
cnt++
}
}
appendAOF(aof, args, replay)
return replyInteger(int64(cnt))
case "EXISTS":
if len(args) != 2 {
return replyError("ERR wrong number of arguments")
}
if _, ok := s.strs[args[1]]; ok {
return replyInteger(1)
}
return replyInteger(0)
case "INCR":
if len(args) != 2 {
return replyError("ERR wrong number of arguments")
}
key := args[1]
if s.isExpired(key) {
s.del(key)
}
cur := int64(0)
if v, ok := s.strs[key]; ok {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return replyError("ERR value is not an integer or out of range")
}
cur = n
}
cur++
s.strs[key] = strconv.FormatInt(cur, 10)
delete(s.expires, key)
appendAOF(aof, args, replay)
return replyInteger(cur)
case "EXPIRE":
if len(args) != 3 {
return replyError("ERR wrong number of arguments")
}
secs, err := strconv.Atoi(args[2])
if err != nil {
return replyError("ERR not an integer")
}
if _, ok := s.strs[args[1]]; !ok {
return replyInteger(0)
}
s.expires[args[1]] = time.Now().Add(time.Duration(secs) * time.Second)
return replyInteger(1)
case "TTL":
if len(args) != 2 {
return replyError("ERR wrong number of arguments")
}
t, ok := s.expires[args[1]]
if !ok {
return replyInteger(-1)
}
if s.isExpired(args[1]) {
return replyInteger(-2)
}
return replyInteger(int64(time.Until(t).Seconds()))
case "HSET":
if len(args) < 4 || (len(args)-2)%2 != 0 {
return replyError("ERR wrong number of arguments")
}
key := args[1]
h, ok := s.hashes[key]
if !ok {
h = make(map[string]string)
s.hashes[key] = h
}
added := 0
for i := 2; i < len(args); i += 2 {
if _, exists := h[args[i]]; !exists {
added++
}
h[args[i]] = args[i+1]
}
appendAOF(aof, args, replay)
return replyInteger(int64(added))
case "HGET":
if len(args) != 3 {
return replyError("ERR wrong number of arguments")
}
h, ok := s.hashes[args[1]]
if !ok {
return replyNull()
}
if v, ok := h[args[2]]; ok {
return replyBulk(v)
}
return replyNull()
case "HGETALL":
if len(args) != 2 {
return replyError("ERR wrong number of arguments")
}
h, ok := s.hashes[args[1]]
if !ok {
return replyArray(nil)
}
items := make([]string, 0, len(h)*2)
for k, v := range h {
items = append(items, k, v)
}
return replyArray(items)
case "LPUSH":
if len(args) < 3 {
return replyError("ERR wrong number of arguments")
}
key := args[1]
list := s.lists[key]
for i := 2; i < len(args); i++ {
list = append([]string{args[i]}, list...)
}
s.lists[key] = list
appendAOF(aof, args, replay)
return replyInteger(int64(len(list)))
case "LRANGE":
if len(args) != 4 {
return replyError("ERR wrong number of arguments")
}
list := s.lists[args[1]]
n := len(list)
start, err1 := strconv.Atoi(args[2])
stop, err2 := strconv.Atoi(args[3])
if err1 != nil || err2 != nil {
return replyError("ERR value is not an integer or out of range")
}
if start < 0 {
start = n + start
}
if stop < 0 {
stop = n + stop
}
if start < 0 {
start = 0
}
if stop >= n {
stop = n - 1
}
if start > stop || start >= n {
return replyArray(nil)
}
return replyArray(list[start : stop+1])
case "ZADD":
if len(args) < 4 || (len(args)-2)%2 != 0 {
return replyError("ERR wrong number of arguments")
}
key := args[1]
zs, ok := s.zsets[key]
if !ok {
zs = newZSet()
s.zsets[key] = zs
}
added := 0
for i := 2; i < len(args); i += 2 {
score, err := strconv.ParseFloat(args[i], 64)
if err != nil {
return replyError("ERR not a float")
}
member := args[i+1]
if _, exists := zs.score[member]; exists {
zs.sl.remove(member, zs.score[member])
} else {
added++
}
zs.score[member] = score
zs.sl.insert(member, score)
}
appendAOF(aof, args, replay)
return replyInteger(int64(added))
case "ZRANGE":
if len(args) < 4 {
return replyError("ERR wrong number of arguments")
}
zs, ok := s.zsets[args[1]]
if !ok {
return replyArray(nil)
}
elems := zs.sl.inorder()
n := len(elems)
if n == 0 {
return replyArray(nil)
}
start, err1 := strconv.Atoi(args[2])
stop, err2 := strconv.Atoi(args[3])
if err1 != nil || err2 != nil {
return replyError("ERR value is not an integer or out of range")
}
withScores := len(args) > 4 && strings.ToUpper(args[4]) == "WITHSCORES"
if start < 0 {
start = n + start
}
if stop < 0 {
stop = n + stop
}
if start < 0 {
start = 0
}
if stop >= n {
stop = n - 1
}
if start > stop || start >= n {
return replyArray(nil)
}
items := make([]string, 0)
for i := start; i <= stop; i++ {
items = append(items, elems[i].member)
if withScores {
items = append(items, formatScore(elems[i].score))
}
}
return replyArray(items)
case "ZSCORE":
if len(args) != 3 {
return replyError("ERR wrong number of arguments")
}
zs, ok := s.zsets[args[1]]
if !ok {
return replyNull()
}
if sc, ok := zs.score[args[2]]; ok {
return replyBulk(formatScore(sc))
}
return replyNull()
case "KEYS":
if len(args) != 2 {
return replyError("ERR wrong number of arguments")
}
var items []string
for k := range s.strs {
items = append(items, k)
}
for k := range s.hashes {
items = append(items, k)
}
for k := range s.lists {
items = append(items, k)
}
for k := range s.zsets {
items = append(items, k)
}
return replyArray(items)
case "FLUSHALL":
s.strs = make(map[string]string)
s.expires = make(map[string]time.Time)
s.hashes = make(map[string]map[string]string)
s.lists = make(map[string][]string)
s.zsets = make(map[string]*zset)
appendAOF(aof, args, replay)
return replySimple("OK")
default:
return replyError("ERR unknown command '" + args[0] + "'")
}
}
// ─────────────────────────────────────────────
// 连接处理 + 启动
// ─────────────────────────────────────────────
func handle(conn net.Conn, store *Store, aof *os.File) {
defer conn.Close()
r := bufio.NewReader(conn)
for {
args, err := readCommand(r)
if err != nil {
return
}
if _, err := conn.Write([]byte(store.exec(args, aof, false))); err != nil {
return
}
}
}
func loadAOF(aof *os.File, store *Store) {
info, err := aof.Stat()
if err != nil || info.Size() == 0 {
return
}
if _, err := aof.Seek(0, io.SeekStart); err != nil {
return
}
r := bufio.NewReader(aof)
for {
args, err := readCommand(r)
if err == io.EOF {
break
}
if err != nil {
break
}
store.exec(args, aof, true) // replay:不重复写 AOF
}
}
func main() {
store := NewStore()
aof, err := os.OpenFile(aofFile, os.O_CREATE|os.O_RDWR|os.O_APPEND, 0644)
if err != nil {
fmt.Println("open aof err:", err)
os.Exit(1)
}
defer aof.Close()
loadAOF(aof, store)
ln, err := net.Listen("tcp", listenAddr)
if err != nil {
fmt.Println("listen err:", err)
os.Exit(1)
}
fmt.Println("MiniValkey listening on", listenAddr)
for {
conn, err := ln.Accept()
if err != nil {
continue
}
go handle(conn, store, aof)
}
}
4.2 跑起来看看
$ go mod init minivalkey
$ go run .
# 另开一个终端
$ redis-cli -p 6399
127.0.0.1:6399> SET user:1 "qiuge" EX 100
OK
127.0.0.1:6399> GET user:1
"qiuge"
127.0.0.1:6399> ZADD leaderboard 100 alice 200 bob 150 carol
(integer) 3
127.0.0.1:6399> ZRANGE leaderboard 0 -1 WITHSCORES
1) "alice"
2) "100"
3) "carol"
4) "150"
5) "bob"
6) "200"
ZRANGE ... WITHSCORES 出来的顺序正是跳表按 (score, member) 升序遍历的结果。把进程杀掉再启动,数据会从 minivalkey.aof 重放回来——这就是持久化的最小可用形态。
4.3 跳表:为什么 ZSET 不用平衡树
zset 要同时支持三件事:按分排序、按分范围取、按成员查分。红黑树能保序,但按成员查分要 O(log n) 遍历;数组插入是 O(n)。跳表的妙处是用"概率化的多层链表"同时拿到:
- 插入 / 删除 / 查找:平均 O(log n),且实现比红黑树简单得多(没有旋转)。
- 范围遍历:从 level-0 链表顺序走,天然有序,ZRANGE 复杂度 O(log n + k)。
- 查分:配合一个
map[member]score,O(1)。
randomLevel() 以概率 P=0.25 每层进阶,期望层高 ~log_{1/P}(n),远小于节点数。insert 用 update[] 数组记录每层的前驱,删除时把前驱的 forward 链改掉即可。inorder() 直接沿底层链表走一遍就是有序序列。Redis 原版也用跳表,不是因为它最快,而是最快且最好写对——工程取舍的经典案例。
五、性能优化:从玩具到生产还差什么
我们的 MiniValkey 能跑通原理,但离生产级 Valkey 还有几道硬坎:
5.1 把网络从主线程手里抢出来(IO 多线程)
我们的全局锁把"网络读写 + 命令执行"全串起来了,单核瓶颈明显。Valkey 8 的路线是:
- IO 线程负责
read()收字节、RESP 解析、write()回包; - 主线程只负责命令执行(仍串行,无锁);
- 通过无锁队列在 IO 线程与主线程间传递解析好的命令。
这样网络密集时多核分摊收发包,命令执行仍保持单线程语义——既突破瓶颈,又不引入数据竞争。
5.2 哈希表的渐进式 rehash
当字典元素从 4 个涨到 400 万个,底层哈希表要扩容。dict 用两张表 + rehashidx 指针做渐进式 rehash:不一次性搬迁,而是每次增删查改时顺手搬几个桶,后台定时任务也帮忙搬。这样扩容的代价被摊薄到每次操作里,不会出现"一次性卡 100ms"的毛刺。我们的 map 由 Go runtime 托管,享受不到这种可控性——这也是自研引擎的难点之一。
5.3 编码优化:小数据不配用指针
Redis/Valkey 对 hash、list、zset 有双编码:元素少、体积小的时候用 listpack(一段连续内存,省掉每个节点的指针和元数据开销);超过阈值才升级成 hashtable / 真正的链表 / 跳表。对一个只有 3 个字段的小 hash,用跳表是巨大的浪费。这套"按需升级"的思想,值得在任何内存敏感的系统里复用。
5.4 网络层细节
- Pipelining:客户端一次发 N 条命令、服务端一次回 N 个结果,把 RTT 摊薄。高吞吐场景必开。
- TCP_NODELAY:关掉 Nagle 算法,避免小包等待凑批带来的延迟。
- epoll 边缘触发(ET):一次性把就绪事件捞干净,减少系统调用次数。
5.5 持久化策略
- AOF
appendfsync:always(每条刷盘,最安全最慢)/everysec(每秒一批,默认折中)/no(交给 OS)。生产多用everysec。 - AOF 重写(rewrite):日志会无限膨胀,后台子进程基于当前内存状态生成一份精简后的新 AOF,再无缝切换。
- RDB fork + 写时复制(COW):
fork()出子进程做全量快照,父子共享内存页,只有被改的页才复制,开销极低。
六、选型清单:Valkey 之外还有谁
| 项目 | 协议 | 架构 | 亮点 | 坑 |
|---|---|---|---|---|
| Valkey | BSD-3 | IO 多线程 + 串行命令 | Redis 100% 兼容,社区+大厂背书,9.0 强集群 | 太新,少数云厂商跟进滞后 |
| Dragonfly | 自有(近兼容) | 多线程 Shared-Nothing | 宣称比 Redis 快 25 倍,内存效率极高 | 兼容性有损,迁移要测 |
| Garnet | MIT | 多线程 | 微软出品,.NET 生态友好,TS 存储层 | 生态偏小 |
| KeyDB | BSD-3 | 多线程(较早分支) | 上车早,活跃度一般 | 社区热度被 Valkey 吸走 |
| Redict | 纯社区分叉 | 接近原 Redis | 完全社区治理 | 资源有限 |
我的建议:新项目默认上 Valkey(兼容 + 性能 + 治理都稳);如果瓶颈在单机吞吐且能接受兼容性验证,可以试 Dragonfly;.NET 技术栈且要纯 MIT,Garnet 值得看。
七、总结与展望:内存 KV 的 2026 与之后
回到开头的那场地震。Redis 改协议没有杀死开源生态,反而逼出了一个更健康的分叉——这大概是"开源生命力"最生动的注脚。Valkey 9.0 的原子槽位迁移 + 多数据库集群,意味着它已经从"Redis 替代品"进化成"能独立扛大规模生产负载的内存数据平台"。
对写业务的我们,三点带走:
- 内存 KV 不是缓存的代名词。排行榜、限流、会话、实时去重、消息队列、分布式锁——它的本职是"用内存换确定性延迟"。
- 读一个生产级引擎,比背八股有用。RESP、事件循环、跳表、AOF,这四个概念串起来,你能看懂一半的高性能中间件设计。
- 手搓一次,恐惧就消失了。本文这份代码不到 400 行,却跑通了网络、协议、数据结构、持久化的完整闭环。下次面试官问"Redis 为什么快",你能从 epoll 讲到 skip list。
2026 年之后,内存数据库的战场会从"单机吞吐"转向"分布式一致性 + 多模(KV/文档/时序一体)+ 与 AI 推理链路的无缝衔接"。Valkey 已经把槽位迁移做成原子级,下一步大概率是把向量检索、流式处理原生塞进内核。到时候,今天的这份手搓代码,就是你理解那个更复杂世界的地基。
把
MiniValkey跑起来,改改看:加上EXPIRE的定时主动清理、给zset加ZREM、用epoll替换 goroutine 模型——等你做到这些,你写的就不只是缓存,而是一个真正属于你的内存数据库内核。