高并发攻击本质上不是单纯靠加机器能解决的。流量洪峰打过来的时候,瓶颈往往卡在后端服务最脆弱的那几个节点上。数据库连接池耗尽、线程池被打满、下游服务响应超时导致上游资源堆积,这些连锁反应一旦触发,整个集群可能像多米诺骨牌一样倒下。限流和熔断就是要在这些脆弱环节前面筑起两道防线,一道控制流量进入的速率,一道在故障扩散前切断依赖。两者配合使用,才能在高并发场景下保住核心服务的可用性。

用令牌桶算法实现精准限流

限流不是简单粗暴地拒绝请求,而是要让系统以自己能够承受的节奏处理流量。令牌桶算法是目前生产环境中最常用的方案,它的核心思想是系统以一个恒定的速率往桶里放入令牌,请求到来时必须先从桶里获取一个令牌,获取成功才能被处理,否则直接拒绝或者排队等待。这种机制天然允许一定程度的突发流量,因为桶本身有容量上限,空闲时积攒的令牌可以应对短时间的流量尖峰。

实现令牌桶不需要引入复杂的外部中间件,用Redis就能轻松搞定。下面这段代码展示了基于Redis的令牌桶限流器核心逻辑:

import redis
import time

class TokenBucket:
    def __init__(self, redis_client, key, capacity, refill_rate):
        self.redis = redis_client
        self.key = key
        self.capacity = capacity
        self.refill_rate = refill_rate  # 每秒补充的令牌数

    def acquire(self, tokens=1):
        now = time.time()
        # 使用Lua脚本保证原子性
        lua_script = """
        local key = KEYS[1]
        local capacity = tonumber(ARGV[1])
        local refill_rate = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        local requested = tonumber(ARGV[4])
        
        local last_refill = tonumber(redis.call('hget', key, 'last_refill')) or now
        local tokens = tonumber(redis.call('hget', key, 'tokens')) or capacity
        
        local elapsed = math.max(0, now - last_refill)
        local refill_amount = elapsed * refill_rate
        tokens = math.min(capacity, tokens + refill_amount)
        
        if tokens >= requested then
            tokens = tokens - requested
            redis.call('hset', key, 'tokens', tokens)
            redis.call('hset', key, 'last_refill', now)
            redis.call('expire', key, 60)
            return 1
        else
            redis.call('hset', key, 'last_refill', now)
            redis.call('expire', key, 60)
            return 0
        """
        result = self.redis.eval(lua_script, 1, self.key, 
                                 self.capacity, self.refill_rate, now, tokens)
        return result == 1

这段代码的关键在于Lua脚本的原子性执行。在高并发环境下,如果先读取再判断再写入,必然产生竞态条件。Redis的单线程模型配合Lua脚本,确保整个判断和扣减过程一气呵成。capacity参数控制桶的容量,决定了系统能承受的最大突发流量,refill_rate则决定了稳态处理能力。这两个参数需要根据实际压测结果来调优,而不是拍脑袋定。

滑动窗口计数器的适用场景与局限

令牌桶虽然灵活,但在某些对时间窗口精确度要求极高的场景下,滑动窗口计数器更直观。比如一个API接口限制每分钟最多调用100次,用滑动窗口可以精确统计最近60秒内的请求次数。实现方式通常是在Redis里维护一个有序集合,每个请求的时间戳作为score存入,判断时删除窗口外的记录再统计剩余数量。

滑动窗口的优势在于精确,但代价是存储开销较大。如果限流粒度很细,比如针对每个用户每个接口都做限流,那Redis的内存占用会迅速膨胀。这时候可以考虑用固定窗口加计数的方式做近似限流,虽然边界上可能存在两倍流量的突刺,但实现简单、性能极高,适合对精度要求不那么苛刻的粗粒度限流场景。

熔断器的三种状态转换机制

限流解决的是流量控制问题,但后端服务如果已经出现故障,光靠限流远远不够。假设订单服务依赖库存服务,库存服务响应突然变慢,每次调用都要等5秒才超时,订单服务的线程池很快就会被这些慢请求占满,导致整个订单服务不可用。熔断器的作用就是在检测到下游服务异常时,主动切断调用链路,让请求快速失败,避免资源被无效消耗。

熔断器的工作模式遵循三种状态之间的自动转换。闭合状态下,请求正常通过,同时统计失败率。当失败率超过阈值,熔断器切换到打开状态,此时所有请求直接返回失败,不再调用下游。打开状态持续一段时间后,熔断器进入半开状态,允许少量请求试探性通过,如果这些请求成功,说明下游已经恢复,熔断器切回闭合状态;如果仍然失败,则重新回到打开状态。

下面是一个简化版的熔断器实现,展示了状态转换的核心逻辑:

import time
from enum import Enum

class CircuitState(Enum):
    CLOSED = 1
    OPEN = 2
    HALF_OPEN = 3

class CircuitBreaker:
    def __init__(self, failure_threshold=5, timeout=30, half_open_max=3):
        self.failure_threshold = failure_threshold
        self.timeout = timeout
        self.half_open_max = half_open_max
        self.state = CircuitState.CLOSED
        self.failure_count = 0
        self.last_failure_time = 0
        self.half_open_count = 0

    def call(self, func, *args, kwargs):
        if self.state == CircuitState.OPEN:
            if time.time() - self.last_failure_time > self.timeout:
                self.state = CircuitState.HALF_OPEN
                self.half_open_count = 0
            else:
                raise Exception("熔断器已打开,请求被拒绝")

        if self.state == CircuitState.HALF_OPEN:
            if self.half_open_count >= self.half_open_max:
                raise Exception("半开状态下探测请求已达上限,请求被拒绝")
            self.half_open_count += 1

        try:
            result = func(*args, kwargs)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise e

    def _on_success(self):
        self.state = CircuitState.CLOSED
        self.failure_count = 0

    def _on_failure(self):
        self.failure_count += 1
        self.last_failure_time = time.time()
        if self.state == CircuitState.HALF_OPEN:
            self.state = CircuitState.OPEN
        elif self.failure_count >= self.failure_threshold:
            self.state = CircuitState.OPEN

实际生产环境中,失败率的统计不能只看绝对次数,还要结合时间窗口内的请求总量。如果总共就发了3个请求,失败了2个,失败率66%看起来很吓人,但实际上样本太小不具备统计意义。成熟的熔断器实现通常会设置一个最小请求量的门槛,只有请求量达到这个门槛后才开始计算失败率。另外,失败的定义也不仅仅是抛异常,超时、返回特定错误码都应该算作失败。

限流和熔断的协同防御策略

很多人把限流和熔断当成两件独立的事情来做,实际上它们应该是一套组合拳。限流部署在服务入口,控制进入系统的总流量,属于第一道防线。熔断部署在服务之间的调用链路上,防止故障扩散,属于第二道防线。两者配合使用时,限流的阈值应该根据下游熔断状态动态调整。当下游熔断器打开时,说明依赖服务已经不可用,这时候上游继续放行请求毫无意义,反而白白消耗自己的资源去等待必然失败的结果。

一个更聪明的做法是在网关层实现自适应限流。网关实时监控各个下游服务的熔断状态,一旦某个服务被熔断,立即降低对应路由的限流阈值,甚至直接拒绝所有指向该服务的请求。等到熔断器进入半开状态并最终恢复后,再逐步放开限流阈值。这种联动机制可以大幅减少无效请求对系统资源的占用,把宝贵的处理能力留给那些依赖健康的业务链路。

分布式环境下的一致性问题

单机限流和熔断实现起来相对简单,但分布式系统里情况复杂得多。如果限流器部署在每个服务实例内部,用的是本地内存计数器,那全局的流量控制就失去了准确性。比如你希望整个集群每秒处理1000个请求,部署了10个实例,每个实例本地限流100次,看起来总量对得上,但实际上流量分布往往不均匀,有的实例可能被打满限流,有的还很空闲,整体处理能力被浪费了。

解决这个问题需要把限流状态集中存储到Redis这样的共享存储里。但集中存储又引入了新的性能瓶颈和单点故障风险。折中方案是采用本地加远程两级限流,本地先做一次粗粒度的限流,拦截掉大部分超量请求,减少对Redis的访问压力,然后再用Redis做精确的全局限流。这样即使Redis偶尔出现延迟抖动,本地限流也能保证系统不会瞬间被打垮。

熔断器的分布式一致性相对好处理一些,因为熔断状态本身就是基于统计数据的,各个实例独立统计自己的调用结果,然后各自判断是否熔断。虽然不同实例之间熔断状态可能存在短暂的不一致,但最终都会收敛到相同的结论。如果需要更快的收敛速度,可以通过消息广播或者配置中心来同步熔断状态变更事件。

生产环境落地时的关键配置参数

限流和熔断的效果严重依赖参数配置,配得太松起不到保护作用,配得太紧又会影响正常业务。限流阈值应该基于压测得出的系统容量来设定,通常取极限QPS的70%到80%作为安全线,留出一定的缓冲空间应对突发流量。令牌桶的容量设置要考虑业务特点,如果是秒杀场景,容量可以设大一些,允许短时间内处理大量请求;如果是稳定型的后台任务,容量设小一些,让流量更平滑。

熔断器的失败率阈值一般设在50%左右比较合理,但具体数值需要结合下游服务的容错能力来调整。超时时间的设置尤为关键,太短会导致正常请求被误判为失败,太长又会让故障检测变得迟钝。一个实用的经验是把超时时间设为下游服务P99响应时间的1.5倍到2倍,这样既能容忍正常的性能波动,又能在真正出问题时快速发现。半开状态的探测请求数量不宜太多,通常3到5个就够了,探测间隔可以逐渐拉长,避免对刚恢复的下游服务造成二次冲击。

监控和告警是这些防御机制落地的最后一块拼图。限流器每拒绝一个请求,都应该记录下时间、接口、用户标识等上下文信息,方便后续分析流量来源。熔断器的每次状态变更都必须触发告警通知,因为熔断意味着业务链路已经受损,需要立即介入排查。没有监控的限流熔断就像没有仪表的飞机,你根本不知道系统当前处于什么状态,也不知道防御措施是否真的在起作用。