This is an extension to my previous post on Circuit Breakers.
It only decides:
"Should I even try calling Redis?"
When the circuit is open, your application executes the fallback.
For many systems, that fallback is the database.
Request
│
Circuit Breaker (Open)
│
Database
And that's where the real problem begins.
The Database Becomes the New Bottleneck
Imagine your system normally handles:
- 100K requests/sec
- Redis serves 99K
- Database serves 1K
Redis suddenly goes down.
Without any protection:
100K Requests
↓
Database 💥
Your database was never designed to handle the entire production workload.
A circuit breaker saved you from Redis timeouts—but it didn't save your database.
The Solution: A DB Rate Limiter
Instead of letting every request reach the database, the application enforces a limit.
if circuitBreaker.IsOpen() {
if !dbRateLimiter.Allow() {
return 503
}
return db.Get(key)
}
If your database can safely handle 5,000 QPS, only those 5,000 requests are allowed through.
The rest fail fast (or can be served from a local cache or stale data if available).
The goal isn't to serve every request.
The goal is to keep the database alive.
Where Does the Rate Limiter Live?
One question I had while learning this was:
"Can't the API Gateway do this?"
Not really.
The gateway only sees:
GET /users/123
It has no idea:
- Is Redis down?
- Is this endpoint going to Postgres?
- Is it calling Elasticsearch?
- Is it reading Kafka?
- Does this endpoint even touch the database?
Only the service knows when it's about to hit the database.
That's why the rate limiter is typically implemented inside the application, immediately before the database call.
HTTP Handler
│
Business Logic
│
Circuit Breaker
│
DB Rate Limiter ← Here
│
Repository
│
Database
A Simple Implementation
At its core, it's just a token bucket.
Suppose the database can safely handle 500 QPS.
func GetUser(id string) User {
if !redisCircuitBreaker.IsOpen() {
return redis.Get(id)
}
if localCache.Has(id) {
return localCache.Get(id)
}
if !dbRateLimiter.Allow() {
return Error503()
}
return db.GetUser(id)
}
If there are no tokens left, the request is rejected immediately.
No waiting.
No overwhelming the database.
But What About Multiple Application Servers?
This was another question that came to mind.
Suppose you have:
10 App Servers
Each server allows:
500 QPS
Now the database receives:
10 × 500 = 5,000 QPS
Perfect.
In practice, each instance is usually allocated a portion of the database's total capacity.
Larger systems may use distributed rate limiters or adaptive concurrency limits, but the idea remains the same:
Never allow your fallback path to exceed what your database can safely handle.
Key Takeaways
- A Circuit Breaker prevents wasted calls to a failing dependency.
- It does not protect your database.
- A DB Rate Limiter sits inside the application, just before the database call.
- It intentionally rejects excess requests to keep the database healthy.
- During outages, preserving the system is often more important than serving every request.
A resilient system isn't one that never fails—it's one that fails without taking everything else down.
Top comments (0)