为实时应用而生的数据库:DiceDB 的愿景与四大核心设计决策
【免费下载链接】dicedbOpen-source, low-latency key/value engine built on Valkey with query subscriptions and hierarchical storage tiers.项目地址: https://gitcode.com/GitHub_Trending/dic/dicedb
在实时系统的世界里,前端与后端都在以前所未有的速度演进,支撑它们的底层基础设施也必须随之进化。传统的键值数据库擅长缓存与存储,却难以满足现代实时应用"数据一变、结果即达"的诉求。DiceDB 正是带着这样一项使命而生的开源键值数据库:专门为构建与规模化实时应用而设计,将"查询订阅"(reactive)作为一等公民。本文以 hello-world.md 为起点,结合当前仓库的源码与文档,深入拆解 DiceDB 的核心设计决策,并通过真实示例展示如何用它对数据变化作出实时响应。
DiceDB 的使命:重新思考实时应用的存储层
"DiceDB is being revived with a bold new mission: to optimize and build a database that truly meets the demands of real-time applications."
这是 DiceDB 启动博客 hello-world.md 的开篇宣言。它传达的核心理念是:实时系统(无论是前端还是后端)的需求正在快速演化,而数据库作为应用的地基,应当提供"健壮且高性能"的解决方案,专门为实时场景而工程化。
DiceDB 并非要成为又一个"通用型"数据库,而是把焦点收敛到实时应用的核心痛点上:当数据发生变化时,客户端如何以最低延迟、最少轮询地感知到这种变化?围绕这一痛点,项目确定了四项关键设计决策(key design decisions),它们共同构成了 DiceDB 的架构灵魂:
- 响应式(reactivity)——订阅一个查询,让结果集被推送到客户端;
- 原生、一等公民的 JSON 支持——面向读写与查询;
- 纯内存引擎——无磁盘开销,网络往返延迟极低;
- 为现代硬件而构建——榨取硬件的每一分性能。
下面我们逐一展开,并用仓库中的真实实现加以印证。
设计决策一:响应式查询订阅(Reactivity)
"subscribe to a SQL query and get results pushed to clients"——这是 DiceDB 最与众不同的特性。传统数据库中,客户端需要不断轮询(poll)才能拿到最新数据;而 DiceDB 希望客户端订阅一个查询,当底层数据发生影响查询结果的变更时,新的结果集会被主动推送给订阅方。
从源码看订阅机制的实现
响应式的落地依赖一个专门的 Watch Manager 组件,其实现位于 internal/watchmanager/watch_manager.go。它维护了三张核心映射表:
querySubscriptionMap:从 Key 到一组"指纹"(fingerprint)的映射,记录哪些查询正关注哪些键;tcpSubscriptionMap:从指纹到一组客户端通道的映射,记录每个查询订阅被哪些客户端订阅;fingerprintCmdMap:从指纹到原始命令(DiceDBCmd)的映射,用于在事件触发时重新执行查询。
事件驱动逻辑体现在handleWatchEvent中:当某个键被修改时,Watch Manager 先检查是否有查询订阅关注该键,再通过affectedCmdMap判断"哪个写命令会影响哪个读命令":
var affectedCmdMap = map[string]map[string]struct{}{ dstore.Set: {dstore.Get: struct{}{}}, dstore.Del: {dstore.Get: struct{}{}}, dstore.Rename: {dstore.Get: struct{}{}}, dstore.ZAdd: {dstore.ZRange: struct{}{}}, dstore.PFADD: {dstore.PFCOUNT: struct{}{}}, }这段代码的含义是:例如当执行SET时,只有GET类查询的订阅者需要被通知;当执行ZADD时,只有ZRANGE类查询的订阅者需要被通知。这种"命令影响映射"机制避免了无关的查询重算,是响应式引擎高效的关键设计。随后notifyClients会把重新执行后的命令推送到所有订阅该指纹的客户端通道,由客户端完成查询执行与结果展示。
Q.WATCH:SQL 风格的订阅命令
响应式能力通过Q.WATCH命令对外暴露,其完整规范见 QWATCH.md。它的定位是"基于底层数据变化,向客户端提供实时更新的新特性"——工作方式类似SUBSCRIBE,但订阅的是 SQL 风格的查询(DSQL),而非单纯的频道名。
Q.WATCH支持 TCP-RESP、HTTP 与 WebSocket 三种协议,语法为:
Q.WATCH <dsql-query>DSQL 查询语法如下:
SELECT $key, $value WHERE condition ORDER BY field [ASC | DESC] LIMIT n各子句的要点:
| 子句 | 说明 |
|---|---|
SELECT | 指定返回字段,支持$key(键名)、$value(键值)及$value.<attr>(值的属性) |
WHERE | 可选过滤条件,支持比较运算符= < > <= >= !=、逻辑运算符AND/OR、括号分组,以及LIKE 'pattern'键模式匹配 |
ORDER BY | 可选,按字段排序(ASC/DESC) |
LIMIT | 可选,限制返回条数 |
从 internal/watchmanager/watch_manager.go 的源码结构看,DiceDB 为每个订阅计算一个"指纹"(fingerprint)作为唯一标识,并在订阅/退订时对上述三张映射表进行增删——这正是实现"初始结果集下发 + 持续监控数据源 + 数据变化时重新评估查询"这一行为模型的底层支撑。
实战示例:实时排行榜
QWATCH.md 给出了一个非常直观的场景:游戏匹配的实时排行榜。假设我们要监控比赛 100 中所有玩家的得分,只关心得分大于 10 的玩家,并按分数降序取前三名:
127.0.0.1:7379> Q.WATCH "SELECT $key, $value WHERE $key like 'match:100:*' AND $value > 10 ORDER BY $value DESC LIMIT 3"接下来模拟几轮数据更新(每个玩家的分数存储在match:100:user:<userID>这样的键中):
SET match:100:user:0 5——得分为 5,不满足> 10,订阅无响应;SET match:100:user:1 15——推送结果:[["match:100:user:1", "15"]];SET match:100:user:2 20——推送结果:[["match:100:user:2", "20"], ["match:100:user:1", "15"]];SET match:100:user:3 12——推送结果:[["match:100:user:2", "20"], ["match:100:user:1", "15"], ["match:100:user:3", "12"]];SET match:100:user:4 25——推送结果:[["match:100:user:4", "25"], ["match:100:user:2", "20"], ["match:100:user:1", "15"]];SET match:100:user:0 30——推送结果:[["match:100:user:0", "30"], ["match:100:user:4", "25"], ["match:100:user:2", "20"]]。
可以看到,客户端完全不需要轮询,每一次写入都会触发结果集的实时重算与推送,排行榜始终与最新数据保持一致。仓库中还提供了完整的教程 realtime-leaderboard.mdx 与 Go 示例代码 examples/leaderboard-go/main.go,供想动手实践的读者参考。
使用建议(来自 QWATCH.md 的最佳实践):
- 尽可能在
LIKE子句中使用精确的键模式,缩小查询监控范围; - 保持
WHERE条件尽量简单,以获得更好的性能; - 在应用层保证比较操作的类型一致性,避免运行时错误。
设计决策二:原生、一等公民的 JSON 支持
"native, first-class JSON support for reads, writes, and queries"——JSON 不是被当作普通字符串塞进键值,而是作为一种一等公民的数据类型被原生支持,可以针对 JSON 内部的路径(path)进行细粒度的读写与查询。
在命令注册中心 internal/eval/commands.go 中,可以看到一整套完整的JSON.*命令族,均已完成向新评估机制(NewEval)的迁移:
- 基础读写:
JSON.SET、JSON.GET、JSON.DEL、JSON.FORGET; - 类型与结构:
JSON.TYPE、JSON.STRLEN、JSON.OBJLEN、JSON.OBJKEYS、JSON.RESP; - 数组操作:
JSON.ARRAPPEND、JSON.ARRINSERT、JSON.ARRPOP、JSON.ARRTRIM、JSON.ARRLEN、JSON.ARRINDEX; - 数值操作:
JSON.NUMINCRBY、JSON.NUMMULTBY; - 其他:
JSON.TOGGLE、JSON.CLEAR、JSON.DEBUG、JSON.INGEST。
例如JSON.SET的元信息定义(internal/eval/commands.go):
jsonsetCmdMeta = DiceCmdMeta{ Name: "JSON.SET", Info: `JSON.SET key path json-string Sets a JSON value at the specified key. Returns OK if successful.`, NewEval: evalJSONSET, Arity: -3, KeySpecs: KeySpecs{BeginIndex: 1}, IsMigrated: true, }KeySpecs{BeginIndex: 1}表明第一个参数是键名,这与所有标准命令保持一致。每个JSON.*命令都接受key [path]形式的参数,这意味着你可以只更新一个 JSON 文档中的某个嵌套字段,而无需读取整个文档——这在实时应用中是至关重要的性能特性。
此外,仓库中还提供了大量 JSON 相关测试用例(如 tests0/json_test.go)以及每一条 JSON 命令的独立文档(位于 docs/src/content/docs/commands 与 docs/src/_skipped_commands),足见该项目对 JSON 支持的重视程度。
设计决策三:纯内存、低延迟的引擎
"in-memory, no disk overhead and every operation completes in 8 ms over the network"——DiceDB 是一个内存优先的键值引擎,核心数据结构全部驻留内存,从而消除了磁盘 I/O 带来的开销。这一点从项目描述 "low-latency key/value engine" 也能得到印证。
DiceDB 的核心存储抽象是Store,其实现位于 internal/store/store.go,配套的过期管理、淘汰策略与 AOF 持久化逻辑分别位于 internal/store/expire.go、internal/store/eviction.go 与 internal/store/aof.go。内存中的对象通过 internal/object/object.go 统一封装,并附带类型编码(type/encoding)信息,见 internal/object/typeencoding.go。
值得一提的是,"无磁盘开销"并不意味着完全没有持久化能力——仓库中的 WAL(Write-Ahead Log)模块 internal/wal/wal.go 与 AOF 模块表明,DiceDB 在追求内存极致性能的同时,也为可靠性保留了持久化路径,用户可以按需启用。
设计决策四:为现代硬件而构建
"built for modern hardware and squeezes every single ounce of performance"——要支撑亚毫秒级的实时推送,仅仅"快"还不够,还必须充分利用现代硬件的全部潜力。这一点在 DiceDB 的网络与并发架构中体现得淋漓尽致:
- 原生系统调用:服务器在 internal/server/ironhawk/main.go 中直接使用
syscall.Socket、syscall.Bind、syscall.Listen、syscall.Accept等系统调用,并将监听套接字设置为非阻塞模式(syscall.SetNonblock),避免了高层网络库带来的额外抽象开销; - 多路复用 I/O:仓库内置了
iomultiplexer模块,在 Linux 上基于epoll(internal/iomultiplexer/epoll_linux.go),在 macOS 上基于kqueue(internal/iomultiplexer/kqueue_darwin.go),通过事件驱动的方式让单个线程高效管理海量连接; - IO 线程模型:internal/server/ironhawk/iothread.go 中每个客户端连接对应一个独立的 IO 线程,配合 IO 线程管理器(iothread_manager.go)与分片(shard)架构(internal/shard、internal/shardmanager),实现了连接的并行处理与数据的水平切分,让多核硬件被充分利用。
这种"为现代硬件而构建"的思路,正是 DiceDB 敢于宣称低延迟、高吞吐的底气所在。
三种连接方式:TCP-RESP、HTTP 与 WebSocket
作为"为实时应用而构建"的数据库,DiceDB 提供了三种连接协议(详见 supported-protocols.mdx):
| 协议 | 默认端口 | 适用场景 |
|---|---|---|
| TCP-RESP | 7379 | 默认通信模式,面向服务端与 CLI 客户端 |
| HTTP | 8082 | 前端直连、Webhook 集成、无原始 TCP 连接的环境 |
| WebSocket | 8379 | 高频实时推送、协同编辑、实时游戏等全双工场景 |
- TCP-RESP是 DiceDB 的默认协议,适合服务端到服务端的高性能通信;
- HTTP让浏览器前端可以直接访问 DiceDB,将数据库能力延伸到 Web 栈;
- WebSocket是构建高频实时应用的利器:它是全双工协议,客户端与服务器可以同时收发消息,弥补了 HTTP 轮询与 SSE 在推送能力上的不足,适合协同编辑、实时对战等场景。
从 internal/cmd/cmds.go 的命令定义可以看出,每个命令的元信息(DiceCmdMeta)统一承载了命令名、参数数量(Arity)、键位置(KeySpecs)、评估函数等信息,使得同一套命令逻辑可以无缝地服务于 RESP、HTTP 与 WebSocket 三种协议入口——这是多协议支持能够保持行为一致的架构基础。
快速上手
在体验 DiceDB 之前,你可以先了解其安装方式(见 installation.mdx)。DiceDB 以 Go 编写,仓库根目录下的 main.go 是程序入口:
package main import "github.com/dicedb/dice/cmd" func main() { cmd.Execute() }启动服务器后,即可通过redis-cli兼容的 RESP 客户端连接到127.0.0.1:7379,先执行SET/GET等基础命令,再尝试Q.WATCH体验实时订阅。对于 Go 开发者,仓库还提供了 hello-world-go 与 leaderboard-go 等示例工程,可以直接作为 SDK 接入的起点。
结语
从 hello-world.md 的宣言到仓库中的每一行实现,DiceDB 的目标始终清晰:不是再造一个"又一个数据库",而是为实时应用时代打造一个专用平台。响应式查询订阅让客户端从轮询的枷锁中解放出来,一等公民的 JSON 支持让复杂数据的读写与查询变得自然,纯内存引擎与面向现代硬件的 I/O 架构则保证了实时推送所需的极致性能。对于想要构建排行榜、实时仪表盘、协同编辑或任何"数据一变、界面即新"的应用的开发者来说,DiceDB 提供了一个值得关注的全新选择——正如其博客所言:"We are just getting started."
【免费下载链接】dicedbOpen-source, low-latency key/value engine built on Valkey with query subscriptions and hierarchical storage tiers.项目地址: https://gitcode.com/GitHub_Trending/dic/dicedb
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考