Typhon:一个用 C# 写的微秒级 ACID 数据库引擎,从游戏引擎偷师
> 一个做了 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#
- What Game Engines Know About Data That Databases Forgot
- Microsecond Latency in a Managed Language
---
第一篇:为什么选 C#?
"现代 C# 是两种语言"
大多数人只知道 managed 那半边——class、GC、LINQ、async/await。但另一半——unsafe、fixed、ref struct、Span、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% |
3. 空间分区
两层空间索引直接集成到组件存储中:
- Layer 1:稀疏哈希表——O(1) 拒绝空区域
- Layer 2:页备份 R-Tree——AABB、半径、射线、视锥体、kNN 查询
4. 持久化
WAL 崩溃恢复、检查点、可配置 fsync——游戏服务器需要但 ECS 框架从未提供的东西。
三种存储模式:不是所有数据都平等
| 模式 | MVCC 历史 | 持久化 | 变更追踪 | 适用场景 |
|---|---|---|---|---|
| Versioned | 完整修订链 | WAL + 检查点 | MVCC | 背包、经济、进度 |
| SingleVersion | 仅当前状态 | WAL + 检查点 | DirtyBitmap | 位置、血量、高频更新 |
| Transient | 仅当前状态 | 无 | DirtyBitmap | AI 黑板、威胁评分、寻路草稿 |
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 |
B+Tree 节点大小 256 字节——因为 CPU 的 Adjacent Line Prefetcher(ALP)在 128 字节对齐区域内自动获取配对的 64 字节行。两次 ALP 触发覆盖整个节点。256 字节节点和 128 字节节点内存访问成本相同,但容量几乎翻倍。
原则 2:消除热路径分配
四层策略:
- ref struct:作用域访问,GC 不知道它存在过
- stackalloc:小临时数组(<64 元素)
- Pinned Object Heap:大型长期缓冲区,GC 不压缩
- ArrayPool\
:中型可复用缓冲区
原则 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 |
原则 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 |
并行扩展: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 #高性能 #系统编程
🌟 智谱 GLM-5 已上线
我正在智谱大模型开放平台 BigModel.cn 上打造 AI 应用,智谱新一代旗舰模型 GLM-5 已上线,在推理、代码、智能体综合能力达到开源模型 SOTA 水平。
🎁 领取 2000万 Tokens