用Go从零实现一个高性能KV存储引擎:B+Tree索引、WAL持久化、LRU缓存的工程实践

用Go从零实现一个高性能KV存储引擎:B+Tree索引、WAL持久化、LRU缓存的工程实践

摘要:本文记录了从零用Go实现一个类Redis的高性能KV存储引擎的完整过程。涵盖B+Tree索引实现(O(logN)读写)、WAL预写日志(崩溃恢复保障)、LRU缓存(热数据加速)、RESP协议解析(兼容redis-cli)、以及Web实时监控面板。最终实现单机18.5万ops/s的吞吐量,P99延迟3.8μs,支持redis-cli直连。
源码地址https://github.com/lcy-tree/mini-redis-go


一、为什么要做这个项目

Redis是最常用的内存数据库,但它有65000行C代码,想要理解它的内部机制,光读源码就要几周。

我想做一个"麻雀虽小、五脏俱全"的Mini版,目标是:

  1. 核心存储引擎:B+Tree索引 + LRU缓存,读写O(logN)
  2. 数据持久化:WAL预写日志,崩溃后自动恢复
  3. 协议兼容:实现RESP协议,redis-cli可以直接连
  4. 可观测性:Web监控面板,实时查看QPS、命中率、内存

最终写了约1700行Go代码,零第三方依赖,实现了上述所有功能。本文详细讲解每个模块的设计思路和关键实现。


二、系统架构

整个系统分为4层:

层级 职责 核心组件
Client Layer 用户接入 redis-cli、应用程序
Server Layer 协议解析、命令路由 TCP Server (RESP)、Web Dashboard
Storage Engine 数据存储、索引、缓存 B+Tree、LRU Cache
Persistence Layer 崩溃恢复 WAL (Write-Ahead Log)、RDB Snapshot
OS Layer 系统调用 File I/O、TCP Stack、Memory Allocator

数据流向:Client → RESP解析 → 命令路由 → B+Tree读写 → WAL日志 → LRU缓存更新


三、B+Tree 索引实现

3.1 为什么选B+Tree而不是HashMap?

Redis的主存储用的是Hashtable(字典),查找O(1)。但B+Tree有一个Hashtable做不到的优势:天然支持范围扫描

特性 HashMap B+Tree SkipList
点查询 O(1) O(logN) O(logN)
范围扫描 O(N) O(logN + K) O(logN + K)
有序性 有序 有序
内存效率 中等 中等
实现复杂度

B+Tree的叶子节点通过链表串联,范围扫描时只需沿着链表遍历,不需要回溯父节点。这对SCAN命令和前缀查询至关重要。

3.2 B+Tree 核心数据结构

go 复制代码
const (
    order   = 64          // 阶数
    maxKeys = 2*order - 1 // 每个节点最多127个key
    minKeys = order - 1   // 每个节点最少63个key
)

type btreeNode struct {
    isLeaf   bool
    keys     [][]byte       // key列表
    values   []Entry        // 仅叶子节点:存储键值对
    children []*btreeNode   // 仅内部节点:子节点指针
    next     *btreeNode     // 叶子节点链表指针(支持范围扫描)
    parent   *btreeNode     // 父节点指针(分裂时需要)
}

type Entry struct {
    Key       []byte
    Value     []byte
    ExpiredAt int64    // Unix时间戳,0表示不过期
    Size      int      // 内存占用(用于LRU淘汰计算)
}

选择阶数64 的原因:每个节点的key数量在63~127之间。一个节点的大小约 127 × 24B ≈ 3KB,刚好在一个L1 cache line(64B)的几十倍范围内,对CPU缓存友好。

3.3 查找操作

go 复制代码
func (t *BTree) findLeaf(key []byte) *btreeNode {
    node := t.root
    for !node.isLeaf {
        i := 0
        for i < len(node.keys) && bytes.Compare(key, node.keys[i]) >= 0 {
            i++
        }
        node = node.children[i]
    }
    return node
}

从根节点开始,每层用二分查找定位子节点指针,直到叶子节点。树高约 log_64(N)

  • 1万个key → 树高2
  • 100万个key → 树高3
  • 10亿个key → 树高5

查找路径非常短,主要瓶颈在内存访问延迟而非比较次数。

3.4 插入与节点分裂

当叶子节点的key数量超过maxKeys(127)时,需要分裂:

go 复制代码
func (t *BTree) splitLeaf(leaf *btreeNode) {
    mid := len(leaf.keys) / 2

    // 创建右节点,复制右半部分
    right := &btreeNode{
        isLeaf: true,
        keys:   make([][]byte, len(leaf.keys)-mid),
        values: make([]Entry, len(leaf.keys)-mid),
        next:   leaf.next,  // 继承原链表指针
    }
    copy(right.keys, leaf.keys[mid:])
    copy(right.values, leaf.values[mid:])

    // 左节点保留左半部分
    leaf.keys = leaf.keys[:mid]
    leaf.values = leaf.values[:mid]
    leaf.next = right  // 左节点指向右节点

    // 向父节点插入分裂key
    t.insertIntoParent(leaf, right.keys[0], right)
}

分裂的关键是维护叶子节点链表:左节点的next指向新创建的右节点,右节点继承原左节点的next。这样范围扫描时链表不会断裂。

3.5 B+Tree vs 其他数据结构的实测对比

B+Tree在范围扫描上优势明显(95K ops/s vs HashMap的12K ops/s),随机读取性能接近HashMap,内存效率也不错。这就是为什么大部分数据库(MySQL InnoDB、PostgreSQL)都用B+Tree做索引。


四、WAL 预写日志

4.1 为什么需要WAL

B+Tree存在内存中,进程崩溃就丢了。WAL(Write-Ahead Log)的核心思想是:写数据之前,先把操作日志写到磁盘

崩溃恢复流程:

  1. 启动时读取WAL文件
  2. 按顺序重放每条记录
  3. B+Tree恢复到崩溃前的状态

4.2 WAL 记录格式

每条WAL记录由Header(8字节)+ 数据区组成:

字段 大小 说明
length 4B 数据区总长度,用于定位记录边界
crc32 4B 数据区校验和,检测磁盘损坏
op 1B 操作类型:1=PUT, 2=DELETE
key_len 4B 键长度
key NB 键内容
value_len 4B 值长度
value MB 值内容(DELETE操作无此字段)

4.3 CRC32 校验 --- 防止数据损坏

go 复制代码
// 写入时计算CRC
data := w.encode(record)
header := make([]byte, 8)
binary.LittleEndian.PutUint32(header[0:4], uint32(len(data)))
binary.LittleEndian.PutUint32(header[4:8], crc32.ChecksumIEEE(data))

// 读取时校验CRC
if crc32.ChecksumIEEE(data) != expectedCRC {
    return fmt.Errorf("WAL CRC mismatch at offset %d", w.offset)
}

CRC32是硬件加速的(crc32.ChecksumIEEE在x86上用crc32指令),单条记录校验开销约10ns,几乎可以忽略。

4.4 批量写入优化

每条WAL记录都调用一次file.Sync()会非常慢(磁盘fsync约1ms/次)。解决方案是批量写入 + 定期刷盘

go 复制代码
// 使用 64KB 写缓冲区
w.writer = bufio.NewWriterSize(f, 64*1024)

// 后台每5秒刷盘一次
go func() {
    ticker := time.NewTicker(5 * time.Second)
    for range ticker.C {
        w.writer.Flush()
        w.file.Sync()
    }
}()

这样每秒可以写入数万条记录,只有每5秒的一次fsync是真正的磁盘I/O。代价是崩溃时可能丢失最近5秒的数据------这对大多数场景是可以接受的。

4.5 WAL 恢复性能

100万条记录的WAL文件约120MB,恢复时间约8.2秒。恢复速度约12万条/秒,瓶颈在磁盘I/O。如果用SSD,恢复速度可以达到50万条/秒。


五、LRU 缓存

5.1 LRU 的实现原理

LRU(Least Recently Used)淘汰最久未使用的数据。实现方式:双向链表 + HashMap

HashMap负责O(1)查找,双向链表负责O(1)排序。命中时把节点移到链表头部,满了就淘汰尾部。

  • Get(key):从HashMap找到元素,移到链表头部 → O(1)
  • Put(key, value):插入链表头部,满了就淘汰尾部 → O(1)
  • Delete(key):从HashMap和链表中同时删除 → O(1)
go 复制代码
type LRUCache struct {
    capacity int
    size     int
    cache    map[string]*list.Element  // O(1) 查找
    list     *list.List                 // O(1) 排序
}

func (c *LRUCache) Get(key string) (Entry, bool) {
    if elem, ok := c.cache[key]; ok {
        c.list.MoveToFront(elem)  // 命中,移到队首
        return elem.Value.(*lruEntry).value, true
    }
    return Entry{}, false
}

func (c *LRUCache) evict() {
    elem := c.list.Back()  // 取队尾(最久未使用)
    if elem != nil {
        entry := elem.Value.(*lruEntry)
        c.size -= entry.value.Size
        c.list.Remove(elem)
        delete(c.cache, entry.key)
    }
}

5.2 读取流程:缓存 + 存储引擎的二级查找

go 复制代码
func (e *Engine) Get(key []byte) ([]byte, error) {
    // 1. 先查 LRU 缓存(热数据)
    if entry, ok := e.lru.Get(string(key)); ok {
        atomic.AddInt64(&e.stats.Hits, 1)
        return entry.Value, nil
    }

    // 2. 缓存未命中,查 B+Tree
    if entry, ok := e.btree.Get(key); ok {
        atomic.AddInt64(&e.stats.Misses, 1)
        e.lru.Put(string(key), entry)  // 回填缓存
        return entry.Value, nil
    }

    return nil, nil  // key 不存在
}

典型的**读穿透(Read-Through)**模式:缓存未命中时自动从底层加载并回填。热数据会常驻缓存,冷数据被淘汰后下次访问再从B+Tree加载。


六、RESP 协议解析

6.1 RESP 协议格式

RESP(Redis Serialization Protocol)是Redis的线协议,格式简单但高效:

复制代码
+OK\r\n              → 简单字符串
-ERR message\r\n     → 错误
:1000\r\n            → 整数
$5\r\nhello\r\n      → Bulk String(带长度前缀)
*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n → 数组

关键设计:Bulk String用长度前缀而不是\0结尾,所以可以安全地传输二进制数据(包括\0)。

6.2 流式解码器

go 复制代码
type Decoder struct {
    reader *bufio.Reader
}

func (d *Decoder) Decode() (RESPValue, error) {
    typeByte, err := d.reader.ReadByte()
    switch RESPType(typeByte) {
    case '+': return d.readSimpleString()
    case '-': return d.readError()
    case ':': return d.readInteger()
    case '$': return d.readBulkString()
    case '*': return d.readArray()
    }
}

bufio.Reader做缓冲读取,避免频繁的系统调用。Bulk String的读取是关键:先读长度,再用io.ReadFull精确读取指定字节数,避免粘包问题。


七、TCP 服务器与并发安全

7.1 连接管理

go 复制代码
func (s *Server) handleConn(conn net.Conn) {
    atomic.AddInt64(&s.clientsCount, 1)
    defer func() {
        atomic.AddInt64(&s.clientsCount, -1)
        conn.Close()
    }()

    if tcpConn, ok := conn.(*net.TCPConn); ok {
        tcpConn.SetKeepAlive(true)
        tcpConn.SetKeepAlivePeriod(30 * time.Second)
    }

    decoder := protocol.NewDecoder(conn)
    for {
        conn.SetReadDeadline(time.Now().Add(5 * time.Minute))
        value, err := decoder.Decode()
        // ...
    }
}

每个连接一个goroutine,Go的goroutine调度器会自动管理数万个并发连接。SetKeepAlive检测死连接,SetReadDeadline防止空闲连接占用资源。

7.2 锁策略

B+Tree用sync.RWMutex保护:读操作用RLock()(共享锁),写操作用Lock()(排他锁)。LRU缓存用独立的sync.Mutex保护。WAL用sync.Mutex保证日志顺序写入。


八、Web 监控面板

用Go标准库的net/http做Web服务器,前端用原生HTML + Canvas画图表,不依赖任何第三方库。

面板采用深色主题(GitHub Dark风格),卡片式布局,4个实时图表:

  • Commands/s:每秒命令数
  • Hit Rate:缓存命中率趋势
  • Memory:内存占用趋势
  • Keys/Clients:key数量和连接数



九、性能测试

9.1 测试环境

  • CPU: Intel i7-12700H (14核20线程)
  • RAM: 16GB DDR5
  • OS: Windows 11
  • Go: 1.21

9.2 吞吐量对比

操作 Mini-Redis-Go Redis 7.0 比值
GET 185K ops/s 210K ops/s 88%
SET 142K ops/s 180K ops/s 79%
DEL 138K ops/s 175K ops/s 79%
PING 250K ops/s 280K ops/s 89%

Mini-Redis-Go的吞吐量约为Redis的80-90%,考虑到代码量只有Redis的1/50,这个结果相当不错。

9.3 延迟分布

操作 P50 P99 P999
GET 1.2μs 3.8μs 8.2μs
SET 2.1μs 6.5μs 15.0μs

GET的P99延迟只有3.8μs,接近纯内存操作的理论极限。


十、关键设计决策

B+Tree阶数的选择

阶数太小(如4)→ 树高增大,查找路径变长

阶数太大(如1024)→ 单节点过大,cache miss增加

64阶是经验值:单节点约3KB,3-4层树高可支撑百万级key,对L1 cache友好。

WAL刷盘策略

每条记录都fsync → 数据最安全,但性能极差(1000 ops/s)

从不fsync → 性能最好,但崩溃丢数据

折中方案:5秒定时fsync。崩溃最多丢5秒数据,吞吐量不受影响。这是MySQL innodb_flush_log_at_trx_commit=2 的策略。

LRU + B+Tree 二级架构

B+Tree保证数据完整性和有序性,LRU缓存热数据加速读取。两者各司其职:

  • B+Tree:所有数据,支持持久化、范围扫描
  • LRU:热数据子集,加速高频访问

十一、支持的命令

命令 说明
PING 健康检查
SET key value [EX seconds] 写入键值,支持TTL
GET key 读取值
DEL key [key ...] 删除一个或多个key
DBSIZE 返回key数量
SCAN count 列出key(最多count个)
FLUSHDB 清空所有数据
SAVE 触发RDB快照
INFO 服务器统计信息

十二、快速上手

bash 复制代码
# 克隆仓库
git clone https://github.com/lcy-tree/mini-redis-go.git
cd mini-redis-go

# 编译
go build -o mini-redis.exe .

# 启动
./mini-redis.exe -port 6380 -web-port 8080

# 用redis-cli连接
redis-cli -p 6380
> SET hello world
OK
> GET hello
"world"

浏览器打开 http://localhost:8080 查看实时监控面板。


十三、总结

这个项目让我对Redis的内部机制有了深入理解:

B+Tree 不只是教科书上的数据结构,它的叶子节点链表设计是范围扫描性能的关键。

WAL 的CRC32校验和批量刷盘策略,是工程上安全性和性能的经典权衡。

LRU 的双向链表+HashMap实现,是O(1)缓存的标准范式。

RESP 协议的设计简洁但实用,长度前缀避免了转义问题,流式解码避免了粘包。

1700行Go代码,实现了Redis的核心存储引擎。如果你对数据库内部实现感兴趣,这个项目是一个不错的起点。


源码地址https://github.com/lcy-tree/mini-redis-go

运行环境 :Go 1.21+
兼容工具 :redis-cli 6.0+、redis-benchmark
性能参考:单机 18.5万 ops/s,P99 延迟 3.8μs


相关推荐
TDengine (老段)5 小时前
TDengine 支持数据类型深度解析 — 类型体系、存储编码与选型指南
java·大数据·数据库·系统架构·时序数据库·tdengine·涛思数据
weelinking14 小时前
【产品】00_产品经理用Claude实现产品系列介绍
数据库·人工智能·sql·数据挖掘·github·产品经理
kkeeper~14 小时前
0基础C语言积跬步之深入理解指针(5下)
c语言·开发语言
一直不明飞行14 小时前
Java的equals(),hashCode()应该在什么时候重写
java·开发语言·jvm
2301_8039346114 小时前
Go语言如何做网络爬虫_Go语言爬虫开发教程【指南】
jvm·数据库·python
盲敲代码的阿豪14 小时前
Python 入门基础教程(爬虫前置版)
开发语言·爬虫·python
basketball61615 小时前
C++ 构造函数完全指南:从入门到进阶
java·开发语言·c++
互联科技报15 小时前
2026超融合选型:Top5品牌与市场格局解读
开发语言·perl
秋915 小时前
windows中安装redis
数据库·redis·缓存