Go后端做分布式限流,核心不是单机计数器能解决的,而是要处理流量在多个实例之间如何协同计数、如何保证限流阈值在全局生效、以及如何防止恶意请求绕过限流节点。真正落地时,通常会遇到三个问题:限流状态存哪里、计数操作如何保证原子性、限流失败后如何快速拒绝而不拖垮下游。解决这三个问题,方案基本就成型了。
分布式限流的基础模型
在Go服务里,分布式限流最常见的模型有两种:令牌桶和滑动窗口。令牌桶适合平滑突发流量,滑动窗口适合精确控制时间窗口内的请求量。分布式环境下,令牌桶需要把桶的状态放到Redis或Etcd里,滑动窗口则需要把每次请求的时间戳记录下来。无论哪种模型,存储的选型直接决定了限流的精度和性能。
Redis是首选,原因是它的原子操作和Lua脚本能力。Go后端通过Redis实现分布式限流,通常会把计数逻辑写成Lua脚本,一次性完成“判断-计数-设置过期时间”三步操作,避免并发下出现超卖。比如一个固定窗口计数器,Lua脚本可以这样写:
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
end
return 1
这个脚本保证了每次请求都会原子地增加计数,第一次设置过期时间,超过阈值直接返回0,Go代码里拿到0就拒绝请求。固定窗口的问题在于窗口边界会瞬间放行两倍流量,所以更严谨的做法是滑动窗口或者滑动日志。
滑动窗口的分布式实现
滑动窗口在Redis里可以用ZSET实现。每个请求进来,用当前时间戳作为score,成员用唯一ID,然后删除窗口外的记录,再统计窗口内的数量。Lua脚本如下:
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local member = ARGV[4]
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count >= limit then
return 0
end
redis.call('ZADD', KEYS[1], now, member)
redis.call('EXPIRE', KEYS[1], window / 1000 + 1)
return 1
这种方式精度高,但ZSET的内存占用和操作复杂度比计数器高。如果QPS很高,每个请求都产生一个唯一成员,Redis内存会增长很快。折中方案是用分桶滑动窗口,把时间窗口切成多个小桶,每个桶用计数器,窗口滑动时只聚合最近几个桶的计数。这样既避免了边界突刺,又控制了内存和操作复杂度。
防攻击的限流维度
单纯按接口限流远远不够。攻击者可以换IP、换账号、换设备指纹来绕过。所以分布式限流要支持多维度组合:IP、用户ID、设备ID、接口路径、甚至业务参数。每个维度独立计数,但可以设置不同的阈值。比如同一IP每分钟最多100次,同一用户每分钟最多200次,同一设备每分钟最多50次。Go代码里可以把这些维度拼成Redis的key,例如:
key := fmt.Sprintf("ratelimit:%s:%s:%s", path, ip, userID)
多维度限流会放大Redis的key数量,需要合理设置过期时间,避免key堆积。另外,恶意请求往往来自代理池或者肉鸡,单纯IP限流可能误伤正常用户。所以更有效的做法是结合行为分析,比如短时间内大量404请求、异常User-Agent、请求频率突变等,把这些信号作为限流的触发条件。
令牌桶的分布式变体
令牌桶在分布式下有两种做法。一种是把桶状态放到Redis,用Lua脚本原子地取令牌和放令牌。另一种是本地令牌桶加Redis做全局协调。第一种实现简单但每次请求都要访问Redis,性能受限于Redis的吞吐。第二种可以减少Redis访问频率,但需要处理多实例之间的令牌分配不公平问题。
实际工程中,更常用的是“本地预取+远程校准”模式。每个Go实例从Redis批量预取一部分令牌,本地消耗完后再去取。这样大部分请求只走本地内存,性能极高。缺点是如果某个实例流量突增,它可能快速耗尽预取的令牌,而其他实例还有剩余,造成全局不公平。解决方法是设置合理的预取量和过期时间,并在Redis里维护总令牌数。
local tokens = redis.call('GET', KEYS[1])
if tokens == false then
tokens = ARGV[1]
redis.call('SET', KEYS[1], tokens, 'EX', ARGV[2])
end
if tonumber(tokens) >= tonumber(ARGV[3]) then
redis.call('DECRBY', KEYS[1], ARGV[3])
return ARGV[3]
end
return -1
这段脚本实现的是从Redis预取令牌,如果剩余令牌足够就扣减并返回预取数量,否则返回-1。Go实例拿到-1就进入本地等待或者直接拒绝。
限流与熔断降级的配合
限流只是防攻击的第一道闸门。当流量超过阈值被拒绝时,如果下游服务已经过载,单纯拒绝请求还不够,需要配合熔断和降级。Go生态里可以用hystrix-go或者sentinel-go。熔断器在错误率超过阈值时打开,快速失败,避免请求堆积。降级则是在限流触发后返回兜底数据或者静态页面,减少对核心服务的冲击。
一个完整的防攻击链路是:网关层做IP黑名单和基础限流,服务层做多维度分布式限流,业务层做熔断降级。每一层都有独立的阈值和策略,避免单点失效。Go的中间件模式很适合做这件事,比如用gin或echo的中间件,在请求进入handler之前完成限流判断。
性能优化与高可用
分布式限流对Redis的依赖很强,Redis一旦不可用,限流就会失效。所以要做降级方案:当Redis连接失败时,切换到本地限流。本地限流可以用uber的ratelimit库,基于令牌桶,性能极高。虽然本地限流无法全局精确控制,但至少能保证单实例不被冲垮。
另外,Redis的key数量要控制。可以用Hash结构把同一接口的多个维度聚合到一个Hash里,减少key数量。比如:
HSET ratelimit:api:/login ip:1.2.3.4 1 HINCRBY ratelimit:api:/login ip:1.2.3.4 1
这样每个接口一个Hash,字段是维度值,值是计数。过期时间可以设置在Hash上,但Hash不支持整体过期,需要定期清理或者用Lua脚本在操作时清理过期字段。
防攻击的额外手段
限流不是万能的,攻击者可以用慢速攻击、CC攻击、HTTP Flood等方式消耗资源。Go后端需要配合以下措施:限制请求体大小、设置读写超时、限制并发连接数、使用http.Server的MaxHeaderBytes、开启TCP keepalive调优。对于慢速攻击,可以在反向代理层设置最小传输速率,比如nginx的client_body_timeout和client_header_timeout。
在Go代码里,http.Server的几个参数很关键:
server := &http.Server{
Addr: ":8080",
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
这些参数能防止慢速读写占用连接,减少攻击面。另外,Go的net/http默认会为每个连接开一个goroutine,如果攻击者建立大量空闲连接,goroutine数量会暴涨。可以配合连接数限制中间件,或者用fasthttp这类高性能库。
监控与告警
分布式限流的效果需要通过监控来验证。关键指标包括:限流触发次数、被拒绝请求的维度分布、Redis操作延迟、限流脚本执行时间、本地降级次数。Go可以用prometheus client暴露这些指标,配合Grafana做可视化。当限流触发率突然升高,可能意味着攻击或者业务异常,需要告警。
告警规则可以设置为:某接口限流触发率超过10%持续5分钟,或者Redis操作P99延迟超过50ms。这些阈值需要根据业务实际情况调整。监控数据还能帮助优化限流阈值,避免误杀正常用户。
总结
Go后端分布式限流防攻击的核心是:用Redis做全局计数,用Lua脚本保证原子性,用多维度组合提高绕过成本,用本地降级保证高可用,用监控告警及时发现异常。没有一种方案能包打天下,需要根据业务场景选择滑动窗口还是令牌桶,根据攻击特征调整限流维度,根据Redis性能决定是否引入本地预取。真正落地的方案一定是分层的、可降级的、可观测的。
