Go
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions and Methods
-
Section 4: Concurrency
-
Section 5: Packages and Tooling
-
Section 6: More Standard Library
-
Section 7: Building Services
-
Section 8: Advanced Go
-
Section 9: Go in the Cloud-Native Ecosystem
-
Section 10: Data Structures and Algorithms in Go
-
Section 11: Testing and Deployment
-
Section 12: Practical Projects
-
Section 13: More Standard Library Practice
-
Section 14: More Practice Projects
-
Section 15: Design Patterns in Go
-
Section 16: Interview Practice
-
Section 17: Package fmt In Depth
-
Section 18: Package strings and strconv
-
Section 19: Package os and io
-
Section 20: Package time
-
Section 21: Package sort and container
-
Section 22: Package encoding
-
Section 23: Package net/http In Depth
-
Section 24: Package context
-
Section 25: Package regexp and bytes
-
Section 26: Package errors In Depth
-
Section 27: Package crypto and hash
-
Section 28: Package flag and log
-
Section 29: Package sync In Depth
-
Section 30: More Practice Exercises
-
Section 31: Go Modules and Workspaces In Depth
-
Section 32: Generics Deep Dive (Go 1.18+)
-
Section 33: Testing Package In Depth
-
Section 34: More Interview and Whiteboard Practice
-
Section 35: Package math and unicode
-
Section 36: Package path and filepath
-
Section 37: Package database/sql
-
Section 38: Package text/template and html/template
-
Section 39: Package archive and compress
-
Section 40: Lower-Level net Package
-
Section 41: Package runtime and reflect
-
Section 42: Package embed and io/fs
-
Section 43: Go Assembly and CGO Basics
-
Section 44: Building CLIs and TUIs
-
Section 45: Go Performance Tuning
-
Section 46: More Real-World Projects
-
Section 47: Go in Production
-
Section 48: Go Design Patterns
-
Section 49: Go Interfaces Deep Dive
-
Section 50: Final Practice and Review
94: Building a Simple Key-Value Store with Persistence
When I first started building persistence layers in Go, I thought I was being clever by offloading the "slow" disk I/O to a background goroutine. I figured why make the user wait for a file write when the data is already updated in memory? It seemed like a free performance win.
Here is a snippet of the kind of code I wrote back then. It looks clean, but it's a landmine:
type Store struct {
data map[string]string
}
func (s *Store) Set(key, value string) {
s.data[key] = value
// "Optimize" by saving in the background
go s.saveToFile()
}
func (s *Store) saveToFile() {
bytes, _ := json.Marshal(s.data)
os.WriteFile("store.json", bytes, 0644)
}
The Dreaded Concurrent Map Panic
If you run this in a high-traffic environment, it won't just be slow—it will crash. You'll see this terrifying message: fatal error: concurrent map read and map write.
Here is what's happening: Go's maps are not thread-safe. While your Set method is updating the map on the main thread, your saveToFile goroutine is reading that same map to marshal it into JSON. If those two actions happen at the exact same microsecond, the Go runtime panics and kills the process. I learned the hard way that "backgrounding" a task doesn't magically make the data access safe; it actually creates the race condition in the first place.
Guarding the Store with a Mutex
To fix this, we need to ensure that only one goroutine is touching the map at any given time. A sync.RWMutex is the right tool here because it allows multiple readers (which is fine) but only one writer.
type Store struct {
mu sync.RWMutex
data map[string]string
}
func (s *Store) Set(key, value string) {
s.mu.Lock()
s.data[key] = value
s.mu.Unlock()
// We still want persistence, but we can't just pass the map
s.saveToFile()
}
func (s *Store) saveToFile() {
s.mu.RLock()
// We create a snapshot copy so we can unlock the map quickly
// and do the slow disk I/O without blocking other requests.
snapshot := make(map[string]string, len(s.data))
for k, v := range s.data {
snapshot[k] = v
}
s.mu.RUnlock()
bytes, _ := json.Marshal(snapshot)
os.WriteFile("store.json", bytes, 0644)
}
Notice how I used RLock() in the save method. This tells Go, "I'm just reading, so other readers can join in, but no one can write until I'm done." More importantly, I copied the map into a snapshot. If I held the lock during the os.WriteFile call, my entire application would freeze every time it saved to disk. Never hold a lock during I/O if you can avoid it.
Handling State Recovery on Startup
Persistence is useless if you can't get the data back. The standard pattern is to attempt a load during the initialization of your store. If the file doesn't exist (like the very first time the app runs), we should treat that as a normal state, not a fatal error.
func NewStore(filename string) (*Store, error) {
s := &Store{
data: make(map[string]string),
}
file, err := os.ReadFile(filename)
if err != nil {
if os.IsNotExist(err) {
return s, nil // Fine, first run
}
return nil, err // Actual disk error
}
if err := json.Unmarshal(file, &s.data); err != nil {
return nil, err
}
return s, nil
}
I prefer this approach because it separates the "empty state" from the "broken state." If the file is corrupted JSON, the app should fail to start; if the file simply isn't there, the app should start with a fresh map.
Preventing Data Corruption with Atomic Renames
There is one last "pro" tip I want to give you. os.WriteFile truncates the file and writes the new data. If your computer crashes or the power goes out exactly halfway through that write, your store.json is now half-written garbage. You've lost everything.
To make this "atomic," write the data to a temporary file first, then rename that file to the final destination. On most operating systems, os.Rename is an atomic operation. Either the new file completely replaces the old one, or the old one stays intact. No in-between.
func (s *Store) saveToFile() {
s.mu.RLock()
snapshot := make(map[string]string, len(s.data))
for k, v := range s.data {
snapshot[k] = v
}
s.mu.RUnlock()
bytes, _ := json.Marshal(snapshot)
tmpFile := "store.json.tmp"
if err := os.WriteFile(tmpFile, bytes, 0644); err != nil {
log.Printf("failed to write tmp file: %v", err)
return
}
if err := os.Rename(tmpFile, "store.json"); err != nil {
log.Printf("failed to rename file: %v", err)
}
}📋 Practical Task
Exercise: Implementing a TTL (Time-to-Live) for the Key-Value Store
In a real key-value store, you rarely want data to stay forever. Your task is to modify the Store implementation to support expiring keys.
- Modify the internal map to store a struct containing both the
valueand anexpirytime (usingtime.Time). - Update the
Setmethod to accept attl time.Duration. If the duration is 0, the key should never expire. - Update the
Getmethod so that if a key is accessed after its expiry time, it returns a "not found" error (and ideally deletes the expired key from the map). - Ensure your
saveToFileandNewStorefunctions are updated to handle the new struct instead of a simple string. - Bonus: Implement a "Cleanup" method that runs in a background goroutine every 60 seconds to purge all expired keys from the map to prevent memory leaks.
There are no comments for now.