golang

Building a Distributed Rate Limiter with Redis and Go: Production-Ready Patterns for High-Scale Apps

Learn to build a production-ready distributed rate limiter using Redis and Go. Master sliding window, token bucket algorithms, Lua scripting & middleware integration for high-scale apps.

Building a Distributed Rate Limiter with Redis and Go: Production-Ready Patterns for High-Scale Apps

Recently, while scaling a Go-based API that handles millions of requests daily, I encountered a critical challenge: managing traffic spikes without degrading service quality. This pushed me to explore robust distributed rate limiting solutions. If you’re building systems that require precise control over request rates across multiple instances, this guide is for you.

Rate limiting is essential for maintaining system stability, preventing abuse, and ensuring fair resource allocation. But implementing it in a distributed environment introduces unique complexities. How do you ensure consistency when multiple servers are processing requests simultaneously?

Redis, with its atomic operations and speed, is an ideal choice for coordinating rate limits across services. Combined with Go’s concurrency features, it becomes a powerful tool for building scalable systems.

Let’s start with a simple fixed-window rate limiter in Go using Redis. This approach counts requests in fixed time intervals, like per second or minute.

package main

import (
    "context"
    "fmt"
    "time"

    "github.com/go-redis/redis/v8"
)

func allowRequest(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
    current, err := rdb.Incr(ctx, key).Result()
    if err != nil {
        return false, err
    }

    if current == 1 {
        rdb.Expire(ctx, key, window)
    }

    return current <= limit, nil
}

This code increments a counter in Redis for a given key. If it’s the first request in the window, it sets an expiration. The function returns true if the request is within the limit.

But fixed windows can allow bursts at boundaries. What if you need smoother, more accurate control?

A sliding window algorithm offers better fairness. It tracks requests in a rolling window, providing a more consistent experience.

func slidingWindowAllow(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
    now := time.Now().UnixMilli()
    windowMs := window.Milliseconds()
    clearBefore := now - windowMs

    pipe := rdb.TxPipeline()
    pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", clearBefore))
    countCmd := pipe.ZCard(ctx, key)
    pipe.ZAdd(ctx, key, &redis.Z{Score: float64(now), Member: now})
    pipe.Expire(ctx, key, window)
    
    _, err := pipe.Exec(ctx)
    if err != nil {
        return false, err
    }

    return countCmd.Val() < limit, nil
}

Here, we use a Redis sorted set to track timestamps of recent requests. We remove old entries and check the current count before adding a new request. This ensures we’re always evaluating the most recent window.

For even more flexibility, consider a token bucket algorithm. It allows bursts up to a certain capacity while maintaining a steady average rate.

func tokenBucketAllow(ctx context.Context, rdb *redis.Client, key string, rate float64, capacity int64) (bool, error) {
    now := time.Now().Unix()
    script := redis.NewScript(`
        local key = KEYS[1]
        local rate = tonumber(ARGV[1])
        local capacity = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        local tokens = redis.call("get", key)
        local last_update = redis.call("get", key .. ":last")
        
        if not tokens then
            tokens = capacity
            last_update = now
        else
            tokens = tonumber(tokens)
            last_update = tonumber(last_update)
        end
        
        local elapsed = now - last_update
        local new_tokens = elapsed * rate
        
        if new_tokens > 0 then
            tokens = math.min(capacity, tokens + new_tokens)
            last_update = now
        end
        
        if tokens >= 1 then
            tokens = tokens - 1
            redis.call("set", key, tokens)
            redis.call("set", key .. ":last", last_update)
            return 1
        else
            return 0
        end
    `)
    
    result, err := script.Run(ctx, rdb, []string{key}, rate, capacity, now).Int()
    return result == 1, err
}

This Lua script runs atomically in Redis, managing token replenishment and consumption. It’s efficient and avoids race conditions.

Integrating these limiters into your Go HTTP server is straightforward with middleware. Here’s an example using Gin:

func RateLimitMiddleware(limiter RateLimiter, keyFunc func(*gin.Context) string) gin.HandlerFunc {
    return func(c *gin.Context) {
        key := keyFunc(c)
        allowed, err := limiter.Allow(c.Request.Context(), key)
        if err != nil {
            c.AbortWithStatus(http.StatusInternalServerError)
            return
        }
        if !allowed {
            c.AbortWithStatus(http.StatusTooManyRequests)
            return
        }
        c.Next()
    }
}

This middleware checks the rate limit before processing each request. If exceeded, it returns a 429 status code.

Handling failures gracefully is crucial. What happens if Redis becomes unavailable? Implementing a fallback mechanism, like an in-memory limiter, can maintain basic protection during outages.

Monitoring and metrics are also vital. Track allowed and rejected requests to understand traffic patterns and adjust limits as needed.

Building a distributed rate limiter requires careful consideration of algorithms, atomicity, and failure scenarios. With Redis and Go, you can create a solution that scales horizontally while maintaining accuracy and reliability.

I hope this exploration helps you in your projects. If you found this useful, feel free to share your thoughts or experiences in the comments. Let’s keep the conversation going.

Keywords: distributed rate limiter, Redis rate limiting, Go rate limiter, sliding window algorithm, token bucket implementation, Redis Lua scripting, high-scale applications, distributed systems Go, rate limiting middleware, Redis connection pooling



Similar Posts
Blog Image
How to Build a Production-Ready Worker Pool System with Graceful Shutdown in Go

Learn to build production-grade worker pools in Go with graceful shutdown, retry logic, and metrics. Master goroutines, channels, and concurrent patterns.

Blog Image
Complete Event-Driven Microservices in Go: NATS, gRPC, and Advanced Patterns Tutorial

Learn to build scalable event-driven microservices with Go, NATS, and gRPC. Master async messaging, distributed tracing, and robust error handling patterns.

Blog Image
Master Go Worker Pools: Production-Ready Implementation Guide with Graceful Shutdown and Error Handling

Learn to build scalable Go worker pools with graceful shutdown, error handling, and backpressure management for production-ready concurrent systems.

Blog Image
Master CLI Development: Cobra + Viper Integration for Advanced Go Configuration Management

Learn to integrate Cobra with Viper for powerful CLI configuration management in Go. Build flexible command-line tools with hierarchical config support.

Blog Image
Boost Performance: Integrating Echo with Redis for Lightning-Fast Go Web Applications

Learn to integrate Echo with Redis for lightning-fast web apps. Boost performance with caching, session management & real-time features. Build scalable Go APIs today!

Blog Image
Production-Ready gRPC Microservices in Go: Authentication, Observability, and Kubernetes Deployment Guide

Master gRPC microservices with Go: authentication, observability, deployment. Complete production-ready guide with JWT, TLS, Docker & Kubernetes. Start building now!