DEV Community

Cover image for Learning Rust by Building a High-Performance Key-Value Database: A C Developer's Honest Take
Mehran
Mehran

Posted on

Learning Rust by Building a High-Performance Key-Value Database: A C Developer's Honest Take

I spent the past two months learning Rust by building FeOx, a key-value database that achieves 3.7M SET/s and 5M GET/s through a Redis-compatible server (2.5x faster than Redis on the same benchmark).

Coming from C, here's what I actually learned, both good and frustrating.

Memory Management: Same Problems, Different Compiler

In C, I'd use reference counting with atomic operations. In Rust, Arc<T> does the same thing. The generated assembly is nearly identical. The difference? In C, if I forget an increment or decrement, I find out in production when something crashes. In Rust, I can't even compile.

The ownership model does change how you structure shared data. In my code, records are Arc<Record> because they're shared between the hash table and skip list. Each Record contains RwLock<Option<Bytes>> for the value (since it can be cleared from memory after being written to disk), and AtomicU64 for various fields. In C, I used atomic_t, spinlock_t, or RCU based on the access pattern. The difference is that Rust encodes these synchronization choices directly in the type system, so you can't accidentally access an Arc<RwLock<T>> without proper locking. This catches bugs at compile time that would be runtime races in C.

Concurrency: Same Algorithms, Different Errors

The concurrency story is similar. RCU in C means calling rcu_read_lock(), doing your work, calling rcu_read_unlock(), and deferring deletion with call_rcu(). Crossbeam in Rust follows the same pattern: epoch::pin(), do work, let the guard drop automatically, and defer with guard.defer().

It's the same algorithm. The performance is identical. The difference is that in Rust, if I try to access data after moving it, the compiler stops me. In C, I'd find out when it crashes, or worse, when it corrupts memory silently. Both require understanding epoch-based reclamation. Rust doesn't make the concept easier, just safer.

Where Rust Genuinely Shined

Pattern matching for protocol parsing was legitimately cleaner than nested switch statements. Parsing Redis commands became almost elegant instead of the usual maze of conditionals. Error propagation with ? beats goto cleanup patterns any day. The code flows naturally instead of jumping around.

Enums with associated data are genuinely useful. My Operation enum can be Insert, Update, or Delete, each carrying different data. My error type is an enum where each variant includes specific context about what failed. In C, enums are just integer constants; you need separate structs and manual plumbing to achieve the same thing.

The Result<T, E> and Option<T> types make error handling composable. Instead of error codes that break function composition, I can chain operations with map, and_then, and ?. The real win isn't preventing forgotten checks; it's being able to transform and propagate errors through a pipeline of operations without manual boilerplate at every step.

The built-in testing framework made a real difference. I'll be honest: my C projects often skip tests because setting up a testing framework is friction. In Rust, cargo test just works, so I actually wrote tests. Traits are nice for generic code too. In C, I'd use function pointers in structs, which works but gets messy fast. Rust's approach generates better code through monomorphization, though you pay for it with longer compile times.

Where Rust's Tooling Truly Excels

This is where the Rust ecosystem shines. In C, I'd write comments and hope they stay accurate. Maybe set up Doxygen if I'm feeling motivated. In Rust, cargo doc generates functional HTML documentation from doc comments, with examples that are compile-tested. The examples in my docs actually run as tests; they literally cannot go stale without breaking the build.

Benchmarking was transformative. In C, I'd write custom timing loops, worry about compiler optimizations invalidating my measurements, and manually handle warm-up effects. With Criterion.rs, I just write:

b.iter(|| store.get(black_box(&key)))
Enter fullscreen mode Exit fullscreen mode

It handles warmup, statistical analysis, outlier detection, and generates HTML reports with graphs showing the distribution of timings. It even detects performance regressions between runs automatically. My C projects never had this level of rigor. Too much friction to set up.

Where I Fought the Language

Self-referential structures remain a pain point. In C, you just store a pointer. In Rust, you need Pin, PhantomData, or unsafe. I ended up redesigning around indices instead of pointers. Probably better architecture, but it was forced by the language, not chosen for its merits.

The borrow checker doesn't understand some valid patterns. Here's a concrete example: I know a HashMap entry exists because I just checked it, but I can't prove it to the compiler without either unwrap() (which could panic) or unsafe (which defeats the purpose). These situations are frustrating because you know the code is correct, but you can't express that knowledge in the type system.

Async is still rough around the edges. Can't easily mix sync and async code. The ecosystem is fragmented between tokio, async-std, and smol. When I did try tokio for networking, I found it painfully slow for my use case. The overhead of the async runtime and tokio-util's codecs added measurable latency compared to raw mio with manual buffer management. For a database that needs predictable sub-millisecond latencies, I just used threads and mio's event loop directly.

Perhaps most frustrating: ownership forces unnecessary clones. In C, I'd store a pointer to the same record in both the hash table and RB tree. Same memory, multiple references. In Rust, I had to clone keys because both the hash table and skip list want to own them. With Bytes, the clone is cheap (just an Arc increment), but it's still an atomic operation that C doesn't need. The C version just stored pointers everywhere. Yes, it's more dangerous, but it's also more efficient.

Performance: No Magic Bullets

Let's be clear: same algorithms give same performance. Lock-free structures still need careful design. Cache-line alignment still matters; it's just #[repr(align(64))] instead of __attribute__((aligned(64))). SIMD still requires unsafe. When you look at the hot path in assembly, it's remarkably similar between both languages once you strip away the syntax.

The performance I achieved didn't come from Rust being faster. It came from implementing the same optimizations I would in C. The difference is that Rust caught my mistakes at compile time instead of runtime.

Build System and Dependencies

Cargo is genuinely better than Makefiles. No contest there. Dependencies just work, cross-compilation is straightforward, and the tooling is consistent across platforms. But there's a trade-off: you end up with more transitive dependencies. What would be a focused 50-file C project pulls in about 160 dependencies in Rust. Compile times suffer accordingly.

My Impressions After Two Months

Rust caught real bugs that would've been subtle crashes in C. That's valuable. But it also forced rewrites of valid code just to satisfy the borrow checker. Sometimes I knew my code was correct, but I spent an hour restructuring it to prove that to the compiler.

The language didn't make me a better programmer or magically improve performance. It just moved errors from runtime to compile time. Whether that's worth the learning curve depends on your project. For a database where correctness matters? Probably yes. For a weekend prototype? Probably no.

The most honest assessment I can give: Rust is a trade-off, not a pure upgrade. You trade development speed for correctness. You trade simplicity for safety. You trade compile time for runtime reliability. Whether those trades are worth it depends entirely on what you're building and what keeps you up at night.

FeOx is on GitHub if you want to see the code. I'm curious: those who've done similar ports from C to Rust, did you find the same friction points? Or did I miss some idiom that would have made things smoother?


FeOx DB: https://github.com/mehrantsi/feoxdb

FeOx Server: https://github.com/mehrantsi/feox-server

Top comments (0)