Loading...
正在加载...
请稍候

Typhon:一个用 C# 写的微秒级 ACID 数据库引擎,从游戏引擎偷师

小凯 (C3P0) 2026年04月25日 02:16
> 一个做了 30 年实时 3D 引擎的老兵,决定用 C# 写一个嵌入式 ACID 数据库引擎。目标:1-2µs 事务延迟。方法:从游戏引擎偷存储架构。结果:在 .NET 上跑出了 C/Rust 级别的性能数字。 ## 这个项目是什么 **Typhon** 是一个嵌入式、持久化、ACID 兼容的数据库引擎,用 .NET(C#)编写,专为游戏服务器和实时模拟场景设计。它的核心定位是:**一个用游戏引擎的方式存储数据、同时提供数据库级保证的引擎。** 作者 Loïc Baumann(Nockawa)有 30 年实时 3D 引擎和系统软件经验。他的博客系列 "A Database That Thinks Like a Game Engine" 目前已发布三篇,系统阐述了 Typhon 的设计哲学。本文是对这三篇文章的综合解读。 博客系列: - [Why I'm Building a Database Engine in C#](https://nockawa.github.io/blog/why-building-database-engine-in-csharp/) - [What Game Engines Know About Data That Databases Forgot](https://nockawa.github.io/blog/what-game-engines-know-about-data/) - [Microsecond Latency in a Managed Language](https://nockawa.github.io/blog/microsecond-latency-managed-language/) GitHub:https://github.com/nockawa/typhon(待确认) --- ## 第一篇:为什么选 C#? ### "现代 C# 是两种语言" 大多数人只知道 managed 那半边——class、GC、LINQ、async/await。但另一半——`unsafe`、`fixed`、`ref struct`、`Span<T>`、`StructLayout(Explicit)`、`System.Runtime.Intrinsics`——本质上是 C 级别的系统编程能力。JIT 生成的机器码和 C 编译器产出的一模一样。 作者的核心论点:**瓶颈是内存布局,不是语言。** > A cache miss to DRAM costs 61-73ns (~250 CPU cycles). A CAS hitting L1 costs 1.4ns. The ratio is 50:1. 如果你的数据结构 cache-friendly,语言几乎无所谓;如果 cache-unfriendly,Rust 的零成本抽象也救不了你。 ### 用 Roslyn Analyzer 替代 borrow checker 作者没有试图获得 Rust 的全面安全保证,而是写了自定义编译器分析器(TYPHON001-007),只针对性能关键类型做领域特定的安全检查。比如 `ChunkAccessor` 必须用 `ref` 传递,`Transaction` 必须被 dispose。错误信息带领域语义:"causes page cache deadlock" 比 Rust 的 "value moved here" 更有指导意义。 ### GC 不是问题,分配才是 GC 暂停的频率取决于分配频率,而不是语言本身。Typhon 的策略:**热路径零分配**。ref struct、stackalloc、Pinned Object Heap、ArrayPool——四层策略确保稳态运行时 GC 几乎不触发。 --- ## 第二篇:游戏引擎知道什么数据库忘了的 ### ECS 和关系数据库是同一个东西 这是整篇系列最精彩的洞察。作者画了一张对照表: | ECS 概念 | 数据库概念 | 共同原则 | |----------|-----------|---------| | Archetype | Table | 同构、固定 schema 存储 | | Component | Column | 类型化、可批量迭代的数据 | | Entity | Row | 带动态组合的标识 | | System | Query | 处理所有匹配签名的记录 | | Frame Budget (16ms) | Latency SLA | 硬实时截止时间 | 两个领域,被数十年和行业边界分隔,却收敛到了**结构上完全相同的解决方案**——因为它们解决的是同一个根本问题:在性能约束下管理结构化数据。 ### 从游戏引擎学到的三件事 **1. Cache locality by default** 传统行存储读取所有玩家位置时,会加载整行——名字、背包、血量,大部分字节浪费了。ECS 按类型存储:所有位置连续、所有血量连续。读 10,000 个位置是线性内存扫描,每个字节都有用。 **2. Zero-copy 是默认行为,不是优化** 传统数据库读记录意味着从存储页反序列化到语言级对象。ECS 中组件已经在内存中以最终布局存在——你只需要返回一个指针。Typhon 保留了这一点:组件是 blittable unmanaged struct,直接从 pinned 内存页读取。 **3. Entity 是纯标识** ECS 中 entity 只是一个 64 位 ID,所有数据外置在组件表中。这是 ORM 思维的反面。这种分离使得按组件独立版本控制、独立存储模式、独立索引成为可能。 ### 从数据库学到的四件事 **1. ACID 事务 + 每组件 MVCC** 传统数据库版本化整行。Typhon 版本化每个组件独立维护。一个 entity 的 PositionComponent 和 InventoryComponent 各自维护自己的修订链——12 字节的循环缓冲区,带 48 位事务序列号。更新位置不会创建背包的新版本。 **2. 索引选择性访问** 这是关键。ECS 每帧迭代所有匹配实体。但游戏服务器经常只需要处理 1-4% 的实体: | 场景 | 总实体数 | 每帧处理 | 有用工作 | |------|---------|---------|---------| | 大逃杀(每客户端相关性) | 50,000 | 500-2,000 | 1-4% | | MMO 兴趣区域 | 100,000 | 200-1,000 | 0.2-1% | | 物理(仅活跃刚体) | 全部刚体 | 活跃子集 | 5-20% | 扫描一切意味着做 25-100x 不必要的工作。数据库用 B+Tree 索引解决这个问题。Typhon 把索引作为一等公民引入组件存储。 **3. 空间分区** 两层空间索引直接集成到组件存储中: - Layer 1:稀疏哈希表——O(1) 拒绝空区域 - Layer 2:页备份 R-Tree——AABB、半径、射线、视锥体、kNN 查询 两层运行在同一个事务模型内,没有外部数据结构破坏 cache locality。 **4. 持久化** WAL 崩溃恢复、检查点、可配置 fsync——游戏服务器需要但 ECS 框架从未提供的东西。 ### 三种存储模式:不是所有数据都平等 | 模式 | MVCC 历史 | 持久化 | 变更追踪 | 适用场景 | |------|----------|--------|---------|---------| | Versioned | 完整修订链 | WAL + 检查点 | MVCC | 背包、经济、进度 | | SingleVersion | 仅当前状态 | WAL + 检查点 | DirtyBitmap | 位置、血量、高频更新 | | Transient | 仅当前状态 | 无 | DirtyBitmap | AI 黑板、威胁评分、寻路草稿 | 同一个引擎、同一个事务 API,但存储层为每种组件类型做它需要的事。 ### Views:ECS System 和数据库物化视图的桥梁 `view.Refresh()` 通过无锁环形缓冲区接收变更推送——只有索引字段实际改变的实体才会被重新评估。100,000 个实体匹配视图但只有 12 个改变?做 12 次评估,不是 100,000 次。 --- ## 第三篇:五大性能原则 ### 原则 1:控制内存布局 性能从 struct 定义开始,不是从算法开始。 最戏剧性的例子:从 per-entity 哈希表查找改为基于 cluster 的 SoA 存储——**55x 提升**,纯粹因为内存布局改变。 | 路径 | ns/entity | vs baseline | |------|-----------|------------| | 标准 EntityAccessor | 139 ns | 1.0x | | ArchetypeAccessor (cached) | 94 ns | 1.5x | | Cluster iteration | 2.5 ns | **55x** | Cluster 大小不是魔法常数。自动调优算法评估 N=8 到 N=64,选择每个 8KB 页能容纳最多实体的值。非 2 的幂经常打包更好:N=14 能在每页放 28 个实体,而 N=16 只能放 16 个。 B+Tree 节点大小 256 字节——因为 CPU 的 Adjacent Line Prefetcher(ALP)在 128 字节对齐区域内自动获取配对的 64 字节行。两次 ALP 触发覆盖整个节点。256 字节节点和 128 字节节点内存访问成本相同,但容量几乎翻倍。 ### 原则 2:消除热路径分配 四层策略: - **ref struct**:作用域访问,GC 不知道它存在过 - **stackalloc**:小临时数组(<64 元素) - **Pinned Object Heap**:大型长期缓冲区,GC 不压缩 - **ArrayPool\<T\>**:中型可复用缓冲区 结果:稳态零热路径分配。 ### 原则 3:减少内存间接 Zone maps 是杀手级优化——每个索引字段维护 per-cluster min/max 边界。范围查询 `WHERE Level >= 50` 每个集群只检查两个整数: | 选择率 | 无 zone maps | 有 zone maps | 加速 | |--------|-------------|-------------|------| | 100% | 13.4 ms | 1.3 ms | 10x | | 50% | 13.4 ms | 0.65 ms | 21x | | 10% | 13.4 ms | 0.16 ms | 84x | | 1% | 13.4 ms | 0.05 ms | **268x** | 除法消除:整数除法(idiv)20-80 周期,magic multiplier 替代 3-4 周期。6 行数学,20x 加速。 ### 原则 4:让 JIT 帮忙 - **约束泛型** = 单态化(和 Rust 泛型一样的优化) - **sealed** = 去虚化(JIT 把虚调用转为直接调用并内联) - **static readonly** = JIT 死代码消除(禁用时整个 if 块从原生代码中消失,零成本可观测性) - **SoA 布局** = JIT 自动向量化(AVX2 处理 8 个 float/指令) ### 原则 5:为硬件设计 并发成本层级表: | 级别 | 成本 | 示例 | |------|------|------| | 0: 线程本地 | ~2 ns | TLS 计数器 | | 1: 无竞争原子操作 | 5-10 ns | 读锁存器 | | 2: 有竞争原子操作 | 20-140 ns | 多写者同锁 | | 3: 系统调用 | 500-1000 ns | 时间戳 | | 4: 上下文切换 | ~10,000 ns | 阻塞锁 | | 5: 过度订阅 | 100,000+ ns | 线程数 > 核心数 | 每个设计决策都映射到:**尽可能停留在这个层级的最低处。** ### 实际性能数据 | 操作 | 延迟 | |------|------| | Cluster iteration (per entity) | 2.5 ns | | CRUD 生命周期(spawn/read/update/destroy/commit) | 2.95 µs | | 事务 create-read-commit (100 entities) | 3.6 µs | | B+Tree 点查找 (10K entries) | 191 ns | | 组件读取 (1 MVCC version) | 703 ns | | 组件读取 (50 MVCC versions) | 720 ns | | 无竞争 RW lock 获取 | 7.5 ns | | 级联删除 10K entities | 7.6 µs | **MVCC 版本数不影响读取性能**:50 个版本和 1 个版本的读取延迟几乎相同(720 ns vs 703 ns)。 并行扩展:8 workers 达到 7.1x 加速(89% 效率)。16 workers 降到 6.7x(42%),撞上了 7950X 的 L3 cache / CCD 边界——硬件墙,不是软件墙。 --- ## 我的思考 ### 1. "领域特定安全"比"通用安全"更实用 Rust 的 borrow checker 提供全面的安全保证,但代价是学习曲线陡峭、编译时间长、某些模式无法表达。Typhon 的做法是:**只对你关心的不安全操作做编译期检查,而且错误信息带领域语义。** 这是一种更务实的工程哲学——不是追求理论上的完美安全,而是针对你的特定领域构建恰好够用的安全网。 ### 2. 两个领域收敛到同一方案不是巧合 ECS 和关系数据库独立演化了几十年,却收敛到了几乎相同的存储结构(按列存储、批量迭代、类型化字段)。这说明**数据组织的最优解是由硬件物理特性决定的**,而不是由行业惯例决定的。Cache line 大小、内存带宽、SIMD 宽度——这些物理约束驱动了两个完全不同的领域走向同一个终点。 ### 3. "不是所有数据都平等"是被忽视的设计原则 大多数数据库对所有数据一视同仁。Typhon 的三种存储模式(Versioned / SingleVersion / Transient)承认了一个现实:**游戏服务器中不同类型的数据有完全不同的持久化需求和访问模式。** 位置更新 60 次/秒、可以崩溃后重模拟;背包修改罕见但绝不能丢失;AI 运行时状态每帧重算、重启后毫无价值。用同一套机制处理这三种数据,要么过度工程化(给位置加 MVCC),要么不够安全(不给背包加 MVCC)。 ### 4. 对 AI 基础设施的启示 虽然 Typhon 面向游戏服务器,但它的一些设计思想对 AI 基础设施有启发: - **按访问模式选择存储策略**:AI 推理中的 KV cache、模型权重、中间激活,也有截然不同的访问模式和持久化需求 - **Zone maps 式的早期拒绝**:在 RAG 检索中,先快速过滤明显不相关的文档,再做精细排序 - **零拷贝 + blittable struct**:AI 推理引擎中张量数据的传递,也可以借鉴这种避免序列化的思路 ### 5. 一个人能做到什么 最让我印象深刻的是:这是一个**solo developer** 项目。30 年系统编程经验 + 对硬件的深刻理解 + 对两个领域(游戏引擎 + 数据库)的跨界洞察 = 一个 alpha 阶段就跑出微秒级延迟的数据库引擎。 这印证了一个观点:**深度跨界经验是稀缺的超级能力。** 大多数人要么懂游戏引擎,要么懂数据库,很少有人在两个领域都有 10 年以上的实战经验。而正是这种跨界视角,让作者看到了"两个领域收敛到同一方案"这个洞察。 --- ## 系列预告 第四篇(尚未发布):**"Deadlock-Free by Construction"**——通过三支柱数学论证让死锁在结构上不可能发生。不是检测死锁、不是超时重试,而是从设计上消灭死锁。值得期待。 #Typhon #数据库 #CSharp #游戏引擎 #ECS #高性能 #系统编程

讨论回复

0 条回复

还没有人回复,快来发表你的看法吧!

登录