编程 当 Redis 走向闭源:手搓生产级内存数据库内核,从 RESP 协议到无锁事件循环(附 Go 完整代码)

2026-07-10 07:46:51 +0800 CST views 8

当 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 多路复用面前,单线程反而是最优解之一

原因有三:

  1. 没有锁开销。所有命令串行执行,不存在并发修改同一份数据的问题,根本不需要加锁。锁带来的上下文切换、cache line 颠簸,在高频小命令场景下很致命。
  2. CPU 缓存命中率高。同一个线程反复操作同一批热数据,L1/L2 缓存始终是暖的。
  3. 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),远小于节点数。insertupdate[] 数组记录每层的前驱,删除时把前驱的 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 对 hashlistzset双编码:元素少、体积小的时候用 listpack(一段连续内存,省掉每个节点的指针和元数据开销);超过阈值才升级成 hashtable / 真正的链表 / 跳表。对一个只有 3 个字段的小 hash,用跳表是巨大的浪费。这套"按需升级"的思想,值得在任何内存敏感的系统里复用。

5.4 网络层细节

  • Pipelining:客户端一次发 N 条命令、服务端一次回 N 个结果,把 RTT 摊薄。高吞吐场景必开。
  • TCP_NODELAY:关掉 Nagle 算法,避免小包等待凑批带来的延迟。
  • epoll 边缘触发(ET):一次性把就绪事件捞干净,减少系统调用次数。

5.5 持久化策略

  • AOF appendfsyncalways(每条刷盘,最安全最慢)/ everysec(每秒一批,默认折中)/ no(交给 OS)。生产多用 everysec
  • AOF 重写(rewrite):日志会无限膨胀,后台子进程基于当前内存状态生成一份精简后的新 AOF,再无缝切换。
  • RDB fork + 写时复制(COW)fork() 出子进程做全量快照,父子共享内存页,只有被改的页才复制,开销极低。

六、选型清单:Valkey 之外还有谁

项目协议架构亮点
ValkeyBSD-3IO 多线程 + 串行命令Redis 100% 兼容,社区+大厂背书,9.0 强集群太新,少数云厂商跟进滞后
Dragonfly自有(近兼容)多线程 Shared-Nothing宣称比 Redis 快 25 倍,内存效率极高兼容性有损,迁移要测
GarnetMIT多线程微软出品,.NET 生态友好,TS 存储层生态偏小
KeyDBBSD-3多线程(较早分支)上车早,活跃度一般社区热度被 Valkey 吸走
Redict纯社区分叉接近原 Redis完全社区治理资源有限

我的建议:新项目默认上 Valkey(兼容 + 性能 + 治理都稳);如果瓶颈在单机吞吐且能接受兼容性验证,可以试 Dragonfly;.NET 技术栈且要纯 MIT,Garnet 值得看。


七、总结与展望:内存 KV 的 2026 与之后

回到开头的那场地震。Redis 改协议没有杀死开源生态,反而逼出了一个更健康的分叉——这大概是"开源生命力"最生动的注脚。Valkey 9.0 的原子槽位迁移 + 多数据库集群,意味着它已经从"Redis 替代品"进化成"能独立扛大规模生产负载的内存数据平台"。

对写业务的我们,三点带走:

  1. 内存 KV 不是缓存的代名词。排行榜、限流、会话、实时去重、消息队列、分布式锁——它的本职是"用内存换确定性延迟"。
  2. 读一个生产级引擎,比背八股有用。RESP、事件循环、跳表、AOF,这四个概念串起来,你能看懂一半的高性能中间件设计。
  3. 手搓一次,恐惧就消失了。本文这份代码不到 400 行,却跑通了网络、协议、数据结构、持久化的完整闭环。下次面试官问"Redis 为什么快",你能从 epoll 讲到 skip list。

2026 年之后,内存数据库的战场会从"单机吞吐"转向"分布式一致性 + 多模(KV/文档/时序一体)+ 与 AI 推理链路的无缝衔接"。Valkey 已经把槽位迁移做成原子级,下一步大概率是把向量检索、流式处理原生塞进内核。到时候,今天的这份手搓代码,就是你理解那个更复杂世界的地基。

MiniValkey 跑起来,改改看:加上 EXPIRE 的定时主动清理、给 zsetZREM、用 epoll 替换 goroutine 模型——等你做到这些,你写的就不只是缓存,而是一个真正属于你的内存数据库内核。

推荐文章

pip安装到指定目录上
2024-11-17 16:17:25 +0800 CST
如何在Rust中使用UUID?
2024-11-19 06:10:59 +0800 CST
Go 单元测试
2024-11-18 19:21:56 +0800 CST
nginx反向代理
2024-11-18 20:44:14 +0800 CST
阿里云免sdk发送短信代码
2025-01-01 12:22:14 +0800 CST
程序员茄子在线接单