[翻译] 为什么我要用 C# 构建数据库引擎 - V2EX
liuliuliuliu
5.51D

[翻译] 为什么我要用 C# 构建数据库引擎

  •  
  •   liuliuliuliu
    PRO
    4h 1m ago 540 views

    原文: Why I'm Building a Database Engine in C#

    作者: Loc Baumann

    译者: liuliuliuliu

    译注:本文翻译使用 AI+人工校对的方式完成

    Typhon 是一个用 .NET 编写的嵌入式、持久化、支持 ACID 的数据库引擎,原生支持游戏服务器与实时仿真所熟悉的实体-组件-系统( ECS )范式。

    它通过 MVCC 快照隔离提供完整事务安全,并以缓存行感知的存储、零拷贝访问和可配置持久化能力为基础,目标是达到亚微秒级延迟。

    这将是一个系列文章:A Database That Thinks Like a Game Engine

    1. Why I'm Building a Database Engine in C#(本文)
    2. What Game Engines Know About Data That Databases Forgot
    3. Microsecond Latency in a Managed Language
    4. Deadlock-Free by Construction
    5. Three Durability Modes, One WAL
    6. Epoch-Based Page Cache (即将发布)

    当我告诉别人我正在用 C# 构建一个 ACID 数据库引擎时,第一反应总是如出一辙:“那 GC (垃圾回收)停顿怎么办?”

    这是一个合情合理的问题。几乎没有人会在 .NET 中构建高性能数据库引擎。人们普遍认为,这类软件必须使用 C 、C++ 或 Rust 编写托管语言基本上被排除在“微秒级延迟俱乐部”之外。

    但在构建了 30 年实时 3D 引擎和系统软件之后,我还是选择了 C#。这个项目名为 strong>Typhon:一个嵌入式 ACID 数据库引擎,目标是实现 12 微秒的事务提交。

    选择它的原因可能会改变你对 C# 能力的看法。


    反对 C# 的理由(让我们直面它吧)

    在我阐述理由之前,让我诚实地列出所有反对选择 C# 的理由。这些是现实存在的担忧,而非“稻草人”谬论。

    • GC 是非确定性的:它可以在任何时候暂停你的所有线程。对于一个承诺微秒级延迟的数据库引擎来说,一次 10 毫秒的 Gen2 (第 2 代)回收是灾难性的这超出了你延迟预算的 10,000 倍。
    • 你无法控制内存布局:托管堆决定了对象的存放位置。GC 可以在压缩过程中移动它们。你无法保证 B+ 树节点位于缓存行边界上,也无法保证你的页面缓存缓冲区不会在事务中途被重新定位。
    • JIT (即时编译)预热是真实存在的:对任何方法的第一次调用都要支付编译成本。在数据库引擎中,启动后的第一个事务不应该比稳定状态慢 100 倍。
    • 虚函数分发和边界检查会增加开销:每次数组访问都有隐藏的边界检查。每次接口调用都要经过虚函数表( vtable )。在处理数百万实体的热点循环中,这些纳秒级的开销会不断累积。

    这些都是合理的问题。我不会假装它们不存在。但这就是大多数人忽略的一点:现代 C# 对每一个问题都有对应的解决方案。


    大多数人不知道的 C# 另一面

    很多开发者熟悉的 C# 是类、垃圾回收和 LINQ 。但这只是语言的一半。.NET Runtime 团队在过去十年里逐步建立了另一面能力,而这部分看起来和许多人想象中的 C# 很不一样。

    unsafe 提供接近 C 级别的控制:原始指针、指针运算、栈上缓冲区的 stackalloc、固定大小数组等。JIT 可以生成与 C 中相同类型的底层指令。

    GCHandle.Alloc(Pinned) 可以让 GC 在关键位置变得不再相关。开发者可以固定字节数组,让 GC 不移动它们。Typhon 的整个页缓存都是固定内存; GC 不触碰、不扫描、不移动它。对引擎核心而言,它就是一个固定地址上的原始字节区。

    ref struct 可以消除热路径上的堆分配ref struct 不能逃逸到堆,只能存在于栈上,并在作用域结束时消失。Typhon 的实体访问器 EntityRef 是一个 96 字节的 ref struct,目标是零分配、零 GC 压力。

    受约束泛型可以提供真正的单态化( Monomorphization )。当代码写成 where T : unmanaged 时,JIT 能为每个类型参数生成单独的原生代码路径。sizeof(T) 会成为常量,无用分支会被消除。这接近 Rust 泛型带来的优化效果:不是运行时分派,而是编译期专门化。

    硬件原语( Hardware intrinsics )是一等公民System.Runtime.Intrinsics 提供 Vector256Sse42.Crc32BitOperations.TrailingZeroCount 等能力。它们对应 C/C++ 中可用的 SIMD 指令,并且带有运行时特性检测,可以在不支持相关指令的平台上回退。

    [StructLayout(Explicit)] 则可以精确控制内存布局:字段偏移、填充、大小等,你可以控制每一个字节。缓存行对齐、防止伪共享( False-sharing )、位打包( Bit-packing )这些功能一应俱全。

    这不是“C# 试图成为 C”,而是 C# 在其顶级的托管生态系统之上,提供了一个真正的系统级编程层。

    Typhon 实际是什么样

    硬件加速的 WAL 校验和

    写入预写日志( WAL )的每个页面都需要 CRC32C 校验和。这是它在 C# 中的样子直接按名字调用 CPU 指令:

    private static uint ComputePartial(uint crc, ReadOnlySpan<byte> data) { if (Sse42.X64.IsSupported) return ComputeSse42X64(crc, data); if (Sse42.IsSupported) return ComputeSse42X32(crc, data); if (ArmCrc32.Arm64.IsSupported) return ComputeArm64(crc, data); return ComputeSoftware(crc, data); } private static uint ComputeSse42X64(uint crc, ReadOnlySpan<byte> data) { ulong crc64 = crc; ref byte ptr = ref MemoryMarshal.GetReference(data); int offset = 0; int aligned = data.Length & ~7; while (offset < aligned) { // 编译为单条 x86 crc32 指令 crc64 = Sse42.X64.Crc32(crc64, Unsafe.ReadUnaligned<ulong>(ref Unsafe.Add(ref ptr, offset))); offset += 8; } uint crc32 = (uint)crc64; while (offset < data.Length) { crc32 = Sse42.Crc32(crc32, Unsafe.Add(ref ptr, offset)); offset++; } return crc32; } 

    Sse42.X64.Crc32() 会编译成单条 x86 crc32 指令。运行时检测 CPU 能力,JIT 消除无用分支,最后执行的是 C 程序员会写出的同类机器代码,同时还保留了不支持 SSE4.2 时的自动回退。

    结果:每 8 KB 页面约 1.3 微秒

    SIMD 数据块访问器

    Typhon 的页缓存热路径使用一个 16 槽缓存,通过三层路径查找数据。

    // === 超快路径:MRU (最近最常使用)检查 === var mru = _mruSlot; if (_pageIndices[mru] == pageIndex) { var headerOffset = pageIndex == 0 ? _rootHeaderOffset : _otherHeaderOffset; return (byte*)_baseAddresses[mru] + headerOffset + offset * _stride; } // === 快路径:通过 SIMD 搜索所有 16 个缓存插槽 === fixed (int* indices = _pageIndices) { var target = Vector256.Create(pageIndex); var v0 = Vector256.Load(indices); var mask0 = Vector256.Equals(v0, target).ExtractMostSignificantBits(); if (mask0 != 0) { var slot = BitOperations.TrailingZeroCount(mask0); return GetFromSlot(slot, pageIndex, offset, dirty); } var v1 = Vector256.Load(indices + 8); var mask1 = Vector256.Equals(v1, target).ExtractMostSignificantBits(); if (mask1 != 0) { var slot = 8 + BitOperations.TrailingZeroCount(mask1); return GetFromSlot(slot, pageIndex, offset, dirty); } } 

    _pageIndices 是一个 fixed int[16],共 64 字节,刚好一个缓存行,并且以适合 SIMD 的方式排列。一次 Vector256.Equals 可以比较 8 个页索引。MRU 快路径覆盖“重复访问同一页”这个常见场景,对分支预测友好,成本接近于零。

    零拷贝实体读取

    EntityRef 是一个只存在于栈上的 ref struct,大小为 96 字节,其中包含一个内联固定数组,用来缓存组件位置。

    public unsafe ref struct EntityRef { internal readonly EntityId _id; // ... 其他元数据 private fixed int _locations[16]; // 内联组件数据块 ID [MethodImpl(MethodImplOptions.AggressiveInlining)] public ref readonly T Read<T>(Comp<T> comp) where T : unmanaged { byte slot = _archetype.GetSlot(comp._componentTypeId); int chunkId = _locations[slot]; var table = _engineState.SlotToComponentTable[slot]; return ref _tx.ReadEcsComponentData<T>(table, chunkId); } } 

    这个 Read<T> 调用经历了:方法调用 → 插槽查找 → 数据块 ID → 页面缓存 → 指针算术 → 指向固定内存页面的 ref readonly T 。零拷贝,零分配,零 GC 介入。由于泛型约束是 where T : unmanaged,JIT 知道确切布局,最终会编译成指针运算。

    JIT 特化的哈希函数

    Typhon 的哈希函数也利用了 JIT 。由于受约束泛型下的 sizeof(TKey) 是编译期常量,不需要的分支会被消除。

    [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static uint ComputeHash<TKey>(TKey key) where TKey : unmanaged { if (sizeof(TKey) == 4) return FastHash32(Unsafe.As<TKey, uint>(ref key)); if (sizeof(TKey) == 8) return XxHash32_8Bytes(Unsafe.As<TKey, long>(ref key)); return XxHash32_Bytes((byte*)Unsafe.AsPointer(ref key), sizeof(TKey)); } 

    例如调用 ComputeHash<int>(42) 时,JIT 只会生成 4 字节路径,其余分支会被完全移除。这是真正的单态化( Monomorphization ),而非运行时分发( Runtime Dispatch )。

    生产力论据

    一个数据库引擎不仅仅只有热路径。在核心引擎周围包裹着庞大的基础设施:配置管理、结构化日志、遥测、依赖注入、测试、基准测试。

    在 C 或 Rust 中,你需要自己构建其中的大部分,或者缝合质量参差不齐的库。

    到了 .NET 里,这些能力已经是生产级且免费的:ILoggerOpenTelemetry 用于可观测性,BenchmarkDotNet 用于严谨的微基准测试,NUnit 用于测试,IConfiguration 用于配置。它们文档充分、互相兼容,并由 Microsoft 或经受过检验的开源社区维护。

    对一个独立开发者来说,这是实际的竞争优势。我可以把时间花在并发原语和页缓存淘汰上,而不是重新发明日志框架。

    核心在于内存布局,而非语言

    这是我多年实时 3D 引擎开发经验教给我的洞察:数据库引擎的瓶颈在于内存访问模式,而非指令吞吐量。

    在 Ryzen 7950X 上,一次 DRAM 缓存未命中大约需要 61 到 73 纳秒。这相当于 CPU 空等约 250 个周期。一次命中 L1 的 CAS 操作约 1.4 纳秒,两者比例约为 50:1 。

    如果你的数据结构导致缓存未命中,无论你的语言有多少“零成本抽象”都救不了你。反之,如果你的数据布局对缓存友好连续、对齐、访问模式可预测那么语言几乎无关紧要。带有 unsafe 的 C# 在热点路径上生成的机器码与 C 语言相同。JIT 就是这么强大。

    真正重要的是:

    • 缓存行感知:Typhon 的 B+ 树节点是 128 字节两个缓存行。Zen4 上的步进预取器会自动覆盖第二行。仅此一项就比 64 字节节点减少了 53% 的插入延迟和 30% 的查找延迟。
    • 数据导向设计( DoD ): 采用 SOA (结构数组)而非 AOS (数组结构)。SIMD 友好的布局。仅使用可直接复制( Blittable )的类型。
    • 最小化间接引用: 每次指针追踪都是一次潜在的缓存未命中。SIMD 数据块访问器的 MRU 命中完全避免了追踪。

    因此,写什么语言远不如设计什么样的内存布局重要。

    数据表现

    所有测量均在 Ryzen 9 7950X 、.NET 10.0 、BenchmarkDotNet 、Release 配置下进行。

    操作 延迟 吞吐
    CRUD lifecycle MVCC ( spawn 、read 、update 、destroy 、commit ) 1.2 微秒 830K ops/sec
    90 读 / 10 更新负载(每个事务 100 次操作,MVCC ) 22 微秒 约 4.5M entity-ops/sec
    B+Tree 查找(命中) 267 纳秒 3.7M ops/sec
    B+Tree 顺序扫描(每个 key ) 2.1 纳秒 479M keys/sec
    无竞争锁获取 7.8 纳秒 128M os/sec
    页缓存命中 5.3 纳秒 -

    作为参考,Zen4 上一次无竞争 CAS 约 1.4 纳秒;一次 DRAM 往返约 61 到 73 纳秒。Typhon 的锁获取是 7.8 纳秒,大约相当于 5 次 CAS ;考虑到它还处理共享/独占仲裁和等待者跟踪,这个结果已经很紧凑。267 纳秒的 B+Tree 查找意味着大约 6 到 7 次内存访问,符合穿过 L2/L3 缓存进行树遍历的预期。

    这些仍然是早期 Alpha 版本的数据,还有优化空间。但它们验证了核心论点:C# 不是瓶颈

    权衡

    任何选择都有成本。以下是我对考虑走同样道路的人的建议。

    内存安全要由开发者自己负责。在 unsafe 代码块里,你可以破坏内存、解引用错误指针、写爆缓冲区,编译器不会拯救你。Span<T> 是稍慢但完全安全的替代方案。

    GC 目前还不是问题,但它可能成为问题。通过固定页缓存,并在热路径使用 ref struct,Gen2 回收既少又便宜。但这不保证任何场景都如此。如果某个工作负载在事务之间大量分配托管对象,暂停仍然可能出现。解决办法是纪律:不要在热路径上分配。语言允许你分配,但不会强迫你分配。

    “但 Rust 会给你编译时安全。”,没错,借用检查器可以捕获 unsafe C# 捕获不了的所有权和生命周期错误。但 C# 也有 Rust 没有的工具:Roslyn analyzers。我编写了一套自定义分析器( TYPHON001007 ),将特定领域的安全规则强制转化为编译器错误:

    • [NoCopy] 特效和分析器:像 ChunkAccessor 这样的性能关键结构不能按值传递。如果忘记使用 ref,编译器会报错。它给关键类型提供了类似 Rust move 语义的保证,但范围只覆盖真正需要的类型。
    • 所有权跟踪:如果创建了 ChunkAccessorTransaction 却没有释放,那就是编译错误,而不是运行时泄漏。analyzer 会通过赋值、返回值、ref / out 参数追踪所有权转移,也可以用 [return: TransfersOwnership] 表达返回值所有权转移。
    • 释放完整性:如果类型持有关键 disposable 字段,而 Dispose() 漏掉了它,或者存在提前返回导致字段没有释放,也会成为编译错误。
    // 这在 Typhon 中是一个编译错误 TYPHON001 void Process(ChunkAccessor accessor) { ... } // 错误:必须通过 ref 传递 void Process(ref ChunkAccessor accessor) { ... } // OK 

    也就是说,C# 不会免费得到 Rust 的安全性。但可以构建精确适合领域的一组编译期规则。与 Rust 的借用检查器不同,这些规则在诊断中带有领域上下文:“会导致页面缓存死锁”比“值已在此处移动”更具可操作性。

    此外,Rust 在周边基础设施(日志、DI 、配置、测试)方面的生态系统不如 .NET 成熟,作为独立开发者,我的开发速度至关重要。我选择了能让我发布更快的语言。

    JIT 预热是真问题,但可以管理。冷启动后的前几笔事务会更慢。对嵌入式引擎来说,这是可以接受的,因为宿主应用通常有自己的预热阶段。如果是服务端数据库,则应考虑分层编译或 AOT 。

    下一步

    在下一篇文章中,我将解释为什么 ACID 数据库引擎会从游戏引擎中借鉴存储架构特别是实体-组件-系统( ECS )模式。游戏引擎和数据库在解决同一个基本问题:管理具有极端性能约束的结构化数据。它们只是演化出了完全不同的解决方案。

    6 replies    2026-05-28 15:07:06 +08:00
    uni
        1
    uni  
       2h 30m ago   1
    我现在仍然坚持我的看法,dotnet 是现在写应用最好的语言
    roclong
        2
    roclong  
       1h 46m ago
    zsj1029
        3
    zsj1029  
       34 mins ago
    dotnet 效率一直很高,虽然积累的历史包袱很重,用起来跟 java 似的繁琐。但是执行效率可以媲美 c++
    craftsmanship
        4
    craftsmanship  
       26 mins ago via Android
    @uni 可惜拿这个混不了饭吃
    corcre
        5
    corcre  
       19 mins ago
    @craftsmanship 还是能的, 赚不了大钱, 混口饭吃应该没啥问题, 要不去做游戏, 要不进厂搞采集/上位机
    penisulaS
        6
    penisulaS  
       17 mins ago
    c#很好,但我没机会用
    About     Help     Advertise     Blog     API     FAQ     Solana     5337 Online   Highest 6679       Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 66ms UTC 07:24 PVG 15:24 LAX 00:24 JFK 03:24
    Do have faith in what you're doing.
    ubao msn snddm index pchome yahoo rakuten mypaper meadowduck bidyahoo youbao zxmzxm asda bnvcg cvbfg dfscv mmhjk xxddc yybgb zznbn ccubao uaitu acv GXCV ET GDG YH FG BCVB FJFH CBRE CBC GDG ET54 WRWR RWER WREW WRWER RWER SDG EW SF DSFSF fbbs ubao fhd dfg ewr dg df ewwr ewwr et ruyut utut dfg fgd gdfgt etg dfgt dfgd ert4 gd fgg wr 235 wer3 we vsdf sdf gdf ert xcv sdf rwer hfd dfg cvb rwf afb dfh jgh bmn lgh rty gfds cxv xcv xcs vdas fdf fgd cv sdf tert sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf sdf shasha9178 shasha9178 shasha9178 shasha9178 shasha9178 liflif2 liflif2 liflif2 liflif2 liflif2 liblib3 liblib3 liblib3 liblib3 liblib3 zhazha444 zhazha444 zhazha444 zhazha444 zhazha444 dende5 dende denden denden2 denden21 fenfen9 fenf619 fen619 fenfe9 fe619 sdf sdf sdf sdf sdf zhazh90 zhazh0 zhaa50 zha90 zh590 zho zhoz zhozh zhozho zhozho2 lislis lls95 lili95 lils5 liss9 sdf0ty987 sdft876 sdft9876 sdf09876 sd0t9876 sdf0ty98 sdf0976 sdf0ty986 sdf0ty96 sdf0t76 sdf0876 df0ty98 sf0t876 sd0ty76 sdy76 sdf76 sdf0t76 sdf0ty9 sdf0ty98 sdf0ty987 sdf0ty98 sdf6676 sdf876 sd876 sd876 sdf6 sdf6 sdf9876 sdf0t sdf06 sdf0ty9776 sdf0ty9776 sdf0ty76 sdf8876 sdf0t sd6 sdf06 s688876 sd688 sdf86