工程实践 架构心得

限流方案全解析:从单机到分布式银行系统

详解限流的四种算法(固定窗口、滑动窗口、令牌桶、漏桶),以及银行分布式系统中基于 Redis 的精准限流实现与踩坑。

发布于 2026/03/20 更新于 2026/03/20 3 分钟

“一个没有限流的转账接口,就像一个没有闸口的水库——迟早会决堤。“

前言

限流(Rate Limiting)是保护系统的第一道防线,尤其在银行系统里:

  • 资金安全:防止恶意高频转账套利
  • 服务保护:下游服务(支付核心、账务系统)资源有限,超载直接导致交易失败
  • 成本控制:云服务 API 调用按次计费,失控的高频调用等于直接烧钱
  • 公平性:所有客户都有合理响应时间,不能被少数高频用户占满资源

1. 限流算法:四种方案的对比与选择

1.1 固定窗口(Fixed Window)

将时间划分为固定窗口,每窗口独立计数。

public class FixedWindowRateLimiter {
    private final long windowSizeMs;
    private final long maxRequests;
    private final ConcurrentHashMap<String, WindowState> windows = new ConcurrentHashMap<>();

    public boolean tryAcquire(String userId) {
        long now = System.currentTimeMillis();
        long windowStart = now - (now % windowSizeMs);

        windows.compute(userId, (k, window) -> {
            if (window == null || window.windowStart < windowStart) {
                return new WindowState(1, windowStart);
            }
            if (window.count >= maxRequests) {
                return window;
            }
            return new WindowState(window.count + 1, window.windowStart);
        });

        return windows.get(userId).count <= maxRequests;
    }

    record WindowState(long count, long windowStart) {}
}

致命缺陷:窗口边界处有双倍流量穿透:

时间轴:  [ 0s ──────── 10s ] [ 10s ──────── 20s ]
窗口1:   1,2,3,4,5,6,7,8,9,10 →  限流10个

在9s-11s之间(跨越边界):
9s:  窗口1第10个请求 ✅
10s: 窗口1结束
11s: 窗口2开始 → 立刻又可以发10个
→ 实际 9s~11s 处理了 20 个请求,是限流上限的 2 倍!

结论:仅适用于对精确度要求极低的场景,生产不用。

1.2 滑动窗口日志(Sliding Window Log)

每个请求记录精确时间戳,统计滑动窗口内的请求数。

public class SlidingWindowLogLimiter {
    private final long windowMs;
    private final long maxRequests;
    private final ConcurrentHashMap<String, LinkedList<Long>> logs = new ConcurrentHashMap<>();

    public synchronized boolean tryAcquire(String userId) {
        long now = System.currentTimeMillis();
        long windowStart = now - windowMs;

        LinkedList<Long> log = logs.computeIfAbsent(userId, k -> new LinkedList<>());

        while (!log.isEmpty() && log.peekFirst() < windowStart) {
            log.removeFirst();
        }

        if (log.size() >= maxRequests) {
            return false;
        }

        log.addLast(now);
        return true;
    }
}

优点:精确,无边界穿透问题。 缺点:内存占用随并发用户数线性增长。百万用户 × 每用户 N 个时间戳 → 内存爆炸。

结论:适合低并发、低用户量场景。

1.3 令牌桶(Token Bucket)— 生产首选

以固定速率向桶中添加令牌,请求消耗令牌,桶满则丢弃。

令牌补充速率:每秒10个
桶容量:20个

时间轴:
t=0s:  桶满 (20个) → 可一次处理20个并发请求
t=1s:  桶补充10个,消耗X个,剩余 20-X+10
t=2s:  继续补充...

→ 允许突发流量(桶里有积累),同时限制长期平均速率
public class TokenBucketLimiter {
    private final double refillRate;
    private final long capacity;
    private final ConcurrentHashMap<String, BucketState> buckets = new ConcurrentHashMap<>();

    public boolean tryAcquire(String userId, long tokens) {
        long now = System.currentTimeMillis();

        buckets.compute(userId, (k, state) -> {
            if (state == null) {
                return new BucketState(now, capacity - tokens, capacity);
            }

            long elapsedMs = now - state.lastRefillMs();
            double tokensToAdd = (elapsedMs / 1000.0) * refillRate;
            double newTokens = Math.min(capacity, state.tokens() + tokensToAdd);

            if (newTokens < tokens) {
                return state;
            }

            return new BucketState(now, newTokens - tokens, capacity);
        });

        return buckets.get(userId).tokens() >= 0;
    }

    record BucketState(long lastRefillMs, double tokens, long capacity) {}
}

优点:允许突发流量、精确控制速率、内存占用恒定。 结论:生产环境首选,Guava 的 RateLimiter 就是这个算法。

1.4 漏桶(Leaky Bucket)

请求以任意速率进入桶,以固定速率漏出。超过桶容量则丢弃。

public class LeakyBucketLimiter {
    private final long capacity;
    private final long leakRateMs;
    private final ConcurrentHashMap<String, BucketState> buckets = new ConcurrentHashMap<>();

    public boolean tryAcquire(String userId) {
        long now = System.currentTimeMillis();

        buckets.compute(userId, (k, state) -> {
            if (state == null) {
                return new BucketState(0, now);
            }

            long leaked = (now - state.lastLeakMs()) / leakRateMs;
            long newLevel = Math.max(0, state.level() - leaked);

            if (newLevel >= capacity) {
                return state;
            }

            return new BucketState(newLevel + 1, now);
        });

        return buckets.get(userId).level() < capacity;
    }

    record BucketState(long level, long lastLeakMs) {}
}

特点:输出速率恒定,不允许突发。适合限速输出端(如 API 下游调用)。

特性固定窗口滑动日志令牌桶漏桶
精确度低(边界穿透)
突发支持不支持支持支持不支持
内存占用
生产推荐小规模✅✅ 大规模✅ 下游限速

2. 分布式限流:Redis 原子实现

单机限流在多实例部署下失效——必须用 Redis 集中计数。

2.1 Redis 滑动窗口(精确版)

-- sliding_window.lua
-- KEYS[1] = rate limit key (e.g., "rl:user:123")
-- ARGV[1] = window size in ms
-- ARGV[2] = max requests
-- ARGV[3] = current timestamp (ms)
-- ARGV[4] = request id (for ZADD)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local windowStart = now - window

-- 删除窗口外的记录
redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)

-- 统计当前窗口内请求数
local count = redis.call('ZCARD', key)

if count >= limit then
    return {0, count}
end

-- 记录本次请求
redis.call('ZADD', key, now, ARGV[4])
redis.call('PEXPIRE', key, window)

return {1, count + 1}
public class RedisSlidingWindowLimiter {
    private final RedisTemplate<String, String> redis;

    public RateLimitResult tryAcquire(String key, long windowMs, int limit) {
        DefaultRedisScript<ArrayList> script = new DefaultRedisScript<>();
        script.setScriptText(loadScript("sliding_window.lua"));
        script.setResultType(ArrayList.class);

        ArrayList result = redis.execute(script,
            List.of(key),
            String.valueOf(windowMs),
            String.valueOf(limit),
            String.valueOf(System.currentTimeMillis()),
            UUID.randomUUID().toString()
        );

        return new RateLimitResult(
            ((Long) result.get(0)) == 1,
            ((Long) result.get(1)).intValue()
        );
    }

    private String loadScript(String name) {
        try (InputStream is = getClass().getClassLoader().getResourceAsStream(name)) {
            return new String(is.readAllBytes());
        } catch (IOException e) {
            throw new RuntimeException("Failed to load Lua script", e);
        }
    }
}

2.2 Redis 令牌桶(推荐生产方案)

-- token_bucket.lua
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local tokens = tonumber(ARGV[2])
local refillRate = tonumber(ARGV[3])
local now = tonumber(ARGV[4])

local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
local storedTokens = tonumber(bucket[1]) or capacity
local lastRefill = tonumber(bucket[2]) or now

local elapsedSeconds = (now - lastRefill) / 1000.0
local newTokens = math.min(capacity, storedTokens + (elapsedSeconds * refillRate))

if newTokens < tokens then
    redis.call('HMSET', key, 'tokens', newTokens, 'lastRefill', now)
    redis.call('PEXPIRE', key, 60000)
    return {0, newTokens}
end

redis.call('HMSET', key, 'tokens', newTokens - tokens, 'lastRefill', now)
redis.call('PEXPIRE', key, 60000)
return {1, newTokens - tokens}

3. 银行系统限流分层架构

银行系统的限流不是单一方案,而是分层部署

前端 → API Gateway → 业务服务 → 数据库/缓存
  │        │              │           │
  │        ↓              ↓           ↓
  │    入口限流       接口限流     资源限流
  │  (防 DDoS)    (按用户/账户)  (连接池)

  └─ 防爬虫限流(UA/IP 黑名单 + 验证码)

3.1 银行常见限流维度

维度粒度场景
用户维度每个用户每秒 N 次通用接口保护
账户维度每个账户每秒 N 笔交易资金安全
IP 维度每个 IP 每秒 N 次防爬虫/防 DDoS
接口维度每个接口每秒 N 次保护特定接口
商户维度每个商户每秒 N 次B2B 接口

3.2 账户维度限流:防止资金套利

这是银行最特殊的限流需求——按账户而不是按用户:

@Service
public class AccountRateLimitService {
    private final RedissonClient redisson;

    /**
     * 账户维度限流:同一账户每秒最多转账 N 次
     * 防止高频小额转账套取积分/返现
     */
    public boolean tryAccountRateLimit(String accountId, String transactionType) {
        String key = String.format("account:rl:%s:%s", accountId, transactionType);
        RRateLimiter limiter = redisson.getRateLimiter(key);
        limiter.setRate(RateType.OVERALL, 5, 1, RateIntervalUnit.SECONDS);
        return limiter.tryAcquire();
    }

    /**
     * 复合限流:账户维度 + 用户维度
     */
    public RateLimitDecision checkPayment(String userId, String accountId) {
        boolean accountOk = tryAccountRateLimit(accountId, "TRANSFER");
        boolean userOk = tryUserRateLimit(userId);

        if (accountOk && userOk) return RateLimitDecision.ALLOWED;
        else if (!accountOk) return RateLimitDecision.ACCOUNT_LIMITED;
        else return RateLimitDecision.USER_LIMITED;
    }
}

enum RateLimitDecision {
    ALLOWED,
    ACCOUNT_LIMITED,  // 账户超限(更严重)
    USER_LIMITED      // 用户超限(稍后再试)
}

3.3 限流触发后的用户体验设计

银行不能简单返回 429,需要考虑用户旅程:

@PostMapping("/api/payment/transfer")
public ResponseEntity<?> transfer(@RequestBody TransferRequest request,
                                   HttpServletRequest httpRequest) {
    String userId = httpRequest.getHeader("X-User-Id");
    RateLimitDecision decision = rateLimitService.checkPayment(userId, request.getFromAccountId());

    return switch (decision) {
        case ALLOWED -> processTransfer(request);
        case ACCOUNT_LIMITED -> ResponseEntity.status(429)
            .header("Retry-After", "60")
            .body(Map.of("code", "ACCOUNT_LIMITED",
                         "message", "账户操作频率超限,请1分钟后重试或联系客服"));
        case USER_LIMITED -> ResponseEntity.status(429)
            .header("Retry-After", "5")
            .body(Map.of("code", "USER_LIMITED",
                         "message", "操作过于频繁,请5秒后重试"));
    };
}

3.4 降级策略

网关层限流触发后,有多种降级策略:

@Service
public class DegradationService {

    // 策略1:队列缓冲(适合非实时场景)
    public void enqueueForLaterProcessing(PaymentRequest request) {
        redisTemplate.opsForList().rightPush("payment:queue:delayed",
            JsonUtil.toJson(request));
    }

    // 策略2:降级服务(适合查询类接口)
    public AccountBalance getAccountBalance(String accountId) {
        if (rateLimitService.tryAcquire(accountId)) {
            return accountService.queryBalance(accountId);
        } else {
            return cacheService.getCachedBalance(accountId)
                .orElseThrow(() -> new ServiceDegradedException("系统繁忙,请稍后重试"));
        }
    }

    // 策略3:分时处理(适合批量场景)
    public void scheduleForOffPeak(PaymentRequest request) {
        int hour = LocalTime.now().getHour();
        if (hour >= 9 && hour <= 11) {
            // 高峰期,延迟到下午2点处理
            scheduleService.schedule(request, LocalDateTime.now().plusHours(3));
        } else {
            processTransfer(request);
        }
    }
}

4. 总结:银行限流配置参考

限流场景维度阈值算法
转账接口账户每账户 5次/秒令牌桶
登录接口IP每IP 10次/秒滑动窗口
账户查询用户每用户 100次/秒令牌桶
支付接口商户每商户 1000次/秒令牌桶
内部 API服务每服务 500次/秒漏桶

限流的核心不是”堵死”,而是在系统过载时优雅降级,让真正重要的交易还能走通。


相关阅读:Spring Cloud Gateway 银行网关实战 · [Redis 实现分布式锁](/coding/Redis/Redis 实现分布式锁) · [Redis 实现消息队列](/coding/Redis/Redis 实现消息队列)