Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 48

Designing Resilient Systems

Recommended resources

Introduction

In our last lesson, we focused on building a resilient client, equipping it with intelligent retries, exponential backoff, and circuit breakers. These client-side patterns are crucial for preventing a misbehaving client from amplifying a small problem into a large one. However, a truly robust distributed system cannot rely solely on the good behavior of its clients. The server must have its own defenses. As a principal engineer, your responsibility extends to designing systems that are inherently stable and can protect themselves from overload, regardless of client behavior.

This lesson shifts our focus to the server side of the fault-tolerance equation. We will design strategies to contain failures and manage overload directly within the service itself. We'll explore two powerful patterns: bulkheading, a strategy for isolating failures to prevent them from spreading, and adaptive rate limiting, a dynamic approach to controlling load based on the system's real-time health. Mastering these server-side protections is essential for building systems that can withstand the cascading failures common at Google's scale.


Bulkheading: Containing the Blast Radius

The term "bulkhead" comes from naval architecture, where partitions within a ship's hull prevent a single breach from flooding the entire vessel. In distributed systems, the principle is identical: we partition system resources to ensure that a failure in one area does not cascade and take down the entire system.

Imagine a service that handles requests for multiple functions: Auth, Analytics, and Recommendations. If the Analytics function becomes slow due to a bug or a dependency issue, and all incoming requests share a single, common thread pool, the slow analytics requests can quickly consume all available threads. This starves the critical Auth and Recommendations functions, rendering the entire service unresponsive. This is a classic cascading failure trigger.

The Bulkhead pattern prevents this by isolating resources. Instead of one large thread pool, we can create separate pools for each function or category of work.

This diagram illustrates the Bulkhead Pattern. Service A routes requests to different functionalities, each backed by a dedicated thread pool. Pool C, handling a slow `Analytics` service, becomes exhausted. However, because Pools B and D are isolated, the critical `Auth` and `Recommendations` services continue to operate normally, containing the failure.

As the diagram shows, even when all threads for Service C are blocked, Service B and Service D remain healthy. The "blast radius" of the failure is contained within the analytics component. This principle of resource isolation can be applied at multiple levels:

  • Thread Pools: The most common implementation, isolating concurrent executions.
  • Connection Pools: Separate connection pools to a database for different types of queries prevent a slow, long-running query from hogging all connections and blocking critical, fast queries.
  • Process/Container Isolation: In a microservices architecture, running different services in separate containers with their own memory and CPU limits is a form of bulkheading at a coarser granularity.
  • Per-Tenant Isolation: In a multi-tenant SaaS environment, you might use bulkheads to isolate resources on a per-customer basis, preventing a "noisy neighbor" from impacting other customers.

The primary trade-off of the bulkhead pattern is efficiency versus resilience. Maintaining separate, partitioned resources means you might have idle threads in one pool while another pool is completely saturated. A single, large pool would be more efficient in utilizing resources, but it lacks the fault isolation of the bulkhead approach. As a system architect, you must make a conscious decision to sacrifice some degree of raw efficiency to gain significant improvements in reliability. Sizing these pools also becomes a critical design challenge, often requiring careful monitoring and dynamic adjustment.


Rate Limiting: Enforcing Usage Policies at the Edge

While bulkheading contains failures within a service, rate limiting is a primary defense against being overwhelmed by external traffic in the first place. It is a server-side mechanism for controlling the rate of requests sent or received from clients.

A crucial architectural decision is where to place the rate limiter. While it could be implemented within each microservice, this introduces redundant logic and complicates maintaining a global view of a client's request rate.

Design a Distributed Rate Limiter w/ a Ex-Meta Staff Engineer: System Design Breakdown

This video from a former Meta staff engineer provides an excellent breakdown of designing a distributed rate limiter, starting with the fundamental question of its placement.

First, watch the segment on placement. The speaker analyzes the trade-offs of embedding the limiter in microservices, running it as a separate service, or placing it at the edge. Pay attention to why the edge (in an API Gateway or load balancer) is the superior choice. Then, to solidify the core concept, briefly watch the introduction defining what a rate limiter is and its core features.

As the video argues, placing the rate limiter at the edge is the most effective strategy. It acts as a bouncer at the door, rejecting excessive traffic before it can consume any resources from your backend services.

Designing a Distributed Rate Limiter

Implementing a rate limiter in a large-scale, distributed environment is a non-trivial system design problem, one you would be expected to navigate as a Principal Engineer. Let's explore the core components.

Design a Distributed Rate Limiter w/ a Ex-Meta Staff Engineer: System Design Breakdown

Now, let's dive into the implementation details using the same video.

Please watch the following sections: Algorithms: The video covers several algorithms. Focus your attention on the Token Bucket algorithm. Understand its mechanics: the bucket capacity handles bursts, and the refill rate handles the sustained load. This is the most common and versatile algorithm. State Management: This section addresses the core challenge: how do multiple gateway instances share state? It walks through using Redis and, critically, how to handle the race condition of reading and updating token counts atomically using Lua scripting. Scalability: A single Redis instance won't scale to millions of requests per second. This segment explains the need for sharding and how a system like Redis Cluster manages data distribution.

To summarize the design of a scalable, distributed rate limiter:

  • Placement: At the edge (API Gateway).
  • Algorithm: Token Bucket, for its ability to handle both bursts and sustained traffic rates.
  • State Store: A centralized, low-latency, in-memory store like Redis to hold the token counts and timestamps for each client.
  • Atomicity: Use transactions or server-side scripting (like Redis Lua scripts) to ensure the read-modify-write cycle for a token bucket is atomic, preventing race conditions between gateways.
  • Scalability & Availability: Shard the state store across multiple instances (e.g., using Redis Cluster) and use replication to ensure high availability.

When a request is rate-limited, the server should return an HTTP 429 Too Many Requests status code. It's also best practice to include headers like Retry-After to guide the client on when it's safe to try again.


Adaptive Rate Limiting & Load Shedding

Static rate limits are a powerful tool, but they are fundamentally brittle. You might set a limit of 100 requests per second (RPS) for a client, but what if your service is currently degraded and can only handle 50 RPS? Or what if it's overprovisioned and could easily handle 200 RPS? Static limits don't adapt to the system's real-time health.

This is where we move from policy-based control (rate limiting) to state-based control (load shedding).

  • Rate Limiting: Controls client traffic based on a predefined policy (e.g., "User X gets 100 RPS"). The server's current health is not a factor.
  • Load Shedding: Actively discards traffic to prevent overload, based on the server's current state (e.g., "My CPU is at 95%, I will start dropping requests").

Rate Limiting vs Load Shedding | Microservices Architecture Pattern | Tech Primers

This video from Tech Primers provides a clear, high-level distinction between these two important concepts.

Watch the segments that define rate limiting and load shedding. Note how the former is client-focused and policy-driven (returning HTTP 429), while the latter is service-focused and state-driven (returning HTTP 503). The final summary reinforces this distinction.

A sophisticated server doesn't just have a binary "on/off" switch for load shedding. It uses an adaptive approach, continuously monitoring its own health and dynamically adjusting the amount of traffic it's willing to accept. This is a form of closed-loop feedback control.

Designing an Adaptive System

To build an adaptive system, you first need reliable signals that indicate the system's health and level of utilization. Simple "queries per second" is a poor metric, as different queries have different costs.

Load Balancing with Client Side Throttling

The Google SRE book chapter on handling overload provides excellent insights into what signals to use for measuring utilization.

First, read the section The Pitfalls of "Queries per Second", which explains why direct resource measurement is superior. Then, jump down to the section on Utilization Signals. Focus on the concept of "executor load average" as a more robust signal than raw CPU, as it measures queueing and contention.

As the SRE book describes, robust signals for overload include:

  • CPU Utilization: A direct measure of compute load.
  • Memory Pressure: Indicates strain on memory resources.
  • Executor Load Average: Measures thread contention and queueing, capturing how many requests are waiting for processing. This is often a leading indicator of overload before CPU maxes out.
  • Request Latency: Increasing response times are a classic symptom of an overloaded system.

With these signals, we can build a control system that dynamically adjusts behavior. A great real-world example is Uber's "Cinnamon" middleware.

This diagram shows the architecture for Uber's Cinnamon middleware, a system for adaptive rate limiting and load shedding. It uses control theory principles to maintain system stability under load.

Let's break down this architecture, which embodies the principles we've discussed:

  1. An incoming request first passes through an Ensure Priority stage (a topic for our next lesson).
  2. It then hits a Rejector. This component makes the load shedding decision. Its threshold is not static; it's dynamically updated by a PID Controller.
  3. The PID Controller (Proportional-Integral-Derivative Controller, a classic concept from control theory) reads counters (like number of accepted vs. rejected requests) and adjusts the rejection threshold to maintain a target state. This is adaptive admission control.
  4. If accepted, the request goes to a Priority Queue and then a Scheduler. The scheduler limits the number of concurrently executing requests.
  5. This concurrency limit is not static. An Auto-tuner monitors the response times of handled requests and adjusts the scheduler's "inflight limit." If latencies start to increase, it reduces concurrency. This is adaptive concurrency control.

This architecture demonstrates a mature approach to server-side stability. It doesn't rely on fixed limits but instead uses feedback loops to allow the service to serve as much traffic as it can safely handle, and no more.


Conclusion

In this lesson, we have designed the server-side counterpart to the client-side resilience patterns from our previous session. A truly reliable system requires both.

Here are your key takeaways:

  • Bulkheading is a static partitioning strategy that isolates resources (like thread pools) to contain failures and prevent them from cascading across an entire service. Its main trade-off is sacrificing some resource efficiency for a large gain in fault tolerance.
  • Rate Limiting is a policy-driven control, typically implemented at the edge, to enforce usage quotas on clients. Designing a distributed rate limiter requires careful consideration of state management, atomicity, and scalability.
  • Load Shedding and Adaptive Rate Limiting are dynamic, state-driven controls where the server protects itself based on real-time health signals like latency, queue depth, or CPU load. This allows a system to gracefully handle overload by shedding traffic it cannot serve.

You have now built a comprehensive toolkit for mitigating cascading failures, from client-side retries and circuit breakers to server-side bulkheads and adaptive load shedding. In our next lesson, we will refine the concept of load management by exploring graceful degradation patterns. Instead of simply dropping a request under load, we'll see how a service can choose to do less work and provide a lower-quality, but still successful, response.