为什么我要用 C# 构建数据库引擎

为什么我要用 C# 构建数据库引擎
构建了 30 年实时 3D 引擎和系统软件之后我还是选择了 C#。这个项目名为Typhon一个嵌入式 ACID 数据库引擎目标是实现 1–2 微秒的事务提交。选择它的原因可能会改变你对 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 提供Vector256、Sse42.Crc32、BitOperations.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, ReadOnlySpanbyte 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, ReadOnlySpanbyte 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.ReadUnalignedulong(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()会编译成单条 x86crc32指令。运行时检测 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 ReadT(CompT comp) where T : unmanaged { byte slot _archetype.GetSlot(comp._componentTypeId); int chunkId _locations[slot]; var table _engineState.SlotToComponentTable[slot]; return ref _tx.ReadEcsComponentDataT(table, chunkId); } }这个ReadT调用经历了方法调用 → 插槽查找 → 数据块 ID → 页面缓存 → 指针算术 → 指向固定内存页面的 ref readonly T。零拷贝零分配零 GC 介入。由于泛型约束是where T : unmanagedJIT 知道确切布局最终会编译成指针运算。JIT 特化的哈希函数Typhon 的哈希函数也利用了 JIT。由于受约束泛型下的sizeof(TKey)是编译期常量不需要的分支会被消除。[MethodImpl(MethodImplOptions.AggressiveInlining)] internal static uint ComputeHashTKey(TKey key) where TKey : unmanaged { if (sizeof(TKey) 4) return FastHash32(Unsafe.AsTKey, uint(ref key)); if (sizeof(TKey) 8) return XxHash32_8Bytes(Unsafe.AsTKey, long(ref key)); return XxHash32_Bytes((byte*)Unsafe.AsPointer(ref key), sizeof(TKey)); }例如调用ComputeHashint(42)时JIT 只会生成 4 字节路径其余分支会被完全移除。这是真正的单态化Monomorphization而非运行时分发Runtime Dispatch。生产力论据一个数据库引擎不仅仅只有热路径。在核心引擎周围包裹着庞大的基础设施配置管理、结构化日志、遥测、依赖注入、测试、基准测试。在 C 或 Rust 中你需要自己构建其中的大部分或者缝合质量参差不齐的库。到了 .NET 里这些能力已经是生产级且免费的ILogger和 OpenTelemetry 用于可观测性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 MVCCspawn、read、update、destroy、commit1.2 微秒830K ops/sec90 读 / 10 更新负载每个事务 100 次操作MVCC22 微秒约 4.5M entity-ops/secBTree 查找命中267 纳秒3.7M ops/secBTree 顺序扫描每个 key2.1 纳秒479M keys/sec无竞争锁获取7.8 纳秒128M ops/sec页缓存命中5.3 纳秒-作为参考Zen4 上一次无竞争 CAS 约 1.4 纳秒一次 DRAM 往返约 61 到 73 纳秒。Typhon 的锁获取是 7.8 纳秒大约相当于 5 次 CAS考虑到它还处理共享/独占仲裁和等待者跟踪这个结果已经很紧凑。267 纳秒的 BTree 查找意味着大约 6 到 7 次内存访问符合穿过 L2/L3 缓存进行树遍历的预期。这些仍然是早期 Alpha 版本的数据还有优化空间。但它们验证了核心论点C# 不是瓶颈。权衡任何选择都有成本。以下是我对考虑走同样道路的人的建议。内存安全要由开发者自己负责。在unsafe代码块里你可以破坏内存、解引用错误指针、写爆缓冲区编译器不会拯救你。SpanT 是稍慢但完全安全的替代方案。GC 目前还不是问题但它可能成为问题。通过固定页缓存并在热路径使用ref structGen2 回收既少又便宜。但这不保证任何场景都如此。如果某个工作负载在事务之间大量分配托管对象暂停仍然可能出现。解决办法是纪律不要在热路径上分配。语言允许你分配但不会强迫你分配。“但 Rust 会给你编译时安全。”没错借用检查器可以捕获unsafeC# 捕获不了的所有权和生命周期错误。但 C# 也有 Rust 没有的工具Roslyn analyzers。我编写了一套自定义分析器TYPHON001–007将特定领域的安全规则强制转化为编译器错误[NoCopy]特效和分析器像ChunkAccessor这样的性能关键结构不能按值传递。如果忘记使用ref编译器会报错。它给关键类型提供了类似 Rust move 语义的保证但范围只覆盖真正需要的类型。所有权跟踪如果创建了ChunkAccessor或Transaction却没有释放那就是编译错误而不是运行时泄漏。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。下一步