线上网关一抖,下游服务往往会一起陪葬。真正有效的限流,不是简单地把请求挡在门外,而是让系统在流量突刺、爬虫刷接口和下游变慢时,仍然能够保住核心链路、削平峰值,并且给数据库留出恢复空间。
本文参考《高可用 API 网关限流与防刷实战:Spring Cloud Gateway + Sentinel 动态阈值与智能封禁》的主线,重新整理成一套更完整的工程方案:网关负责快速准入,服务负责熔断和降级,数据库连接池负责最后一道舱壁。示例以 Spring Cloud Gateway、Spring Cloud Alibaba Sentinel、Nacos、Resilience4j 和 HikariCP 为主,版本号请以项目 BOM 为准。
一、先建立完整的过载保护模型
单点限流经常翻车,原因是它只回答了"放不放行",没有回答下面三个问题:
- 下游 RT 已经升高时,入口阈值是否应该自动收紧?
- 非核心请求被拒绝后,能否返回缓存、进入队列或走只读降级?
- 请求已经通过网关,但数据库连接池快耗尽时,谁负责阻止继续排队?
生产环境更适合采用"采集---决策---执行---反馈"的闭环。网关节点只做短路径判断,规则和封禁状态放到配置中心、Redis 或风控服务中;服务节点按接口重要性选择熔断、隔离、缓存和异步化;数据库侧用连接池上限、超时和并发舱壁限制在途请求。
#mermaid-svg-LAHh5g6XmOBu6LFu{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-LAHh5g6XmOBu6LFu .error-icon{fill:#552222;}#mermaid-svg-LAHh5g6XmOBu6LFu .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-LAHh5g6XmOBu6LFu .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-LAHh5g6XmOBu6LFu .marker{fill:#333333;stroke:#333333;}#mermaid-svg-LAHh5g6XmOBu6LFu .marker.cross{stroke:#333333;}#mermaid-svg-LAHh5g6XmOBu6LFu svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-LAHh5g6XmOBu6LFu p{margin:0;}#mermaid-svg-LAHh5g6XmOBu6LFu .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-LAHh5g6XmOBu6LFu .cluster-label text{fill:#333;}#mermaid-svg-LAHh5g6XmOBu6LFu .cluster-label span{color:#333;}#mermaid-svg-LAHh5g6XmOBu6LFu .cluster-label span p{background-color:transparent;}#mermaid-svg-LAHh5g6XmOBu6LFu .label text,#mermaid-svg-LAHh5g6XmOBu6LFu span{fill:#333;color:#333;}#mermaid-svg-LAHh5g6XmOBu6LFu .node rect,#mermaid-svg-LAHh5g6XmOBu6LFu .node circle,#mermaid-svg-LAHh5g6XmOBu6LFu .node ellipse,#mermaid-svg-LAHh5g6XmOBu6LFu .node polygon,#mermaid-svg-LAHh5g6XmOBu6LFu .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-LAHh5g6XmOBu6LFu .rough-node .label text,#mermaid-svg-LAHh5g6XmOBu6LFu .node .label text,#mermaid-svg-LAHh5g6XmOBu6LFu .image-shape .label,#mermaid-svg-LAHh5g6XmOBu6LFu .icon-shape .label{text-anchor:middle;}#mermaid-svg-LAHh5g6XmOBu6LFu .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-LAHh5g6XmOBu6LFu .rough-node .label,#mermaid-svg-LAHh5g6XmOBu6LFu .node .label,#mermaid-svg-LAHh5g6XmOBu6LFu .image-shape .label,#mermaid-svg-LAHh5g6XmOBu6LFu .icon-shape .label{text-align:center;}#mermaid-svg-LAHh5g6XmOBu6LFu .node.clickable{cursor:pointer;}#mermaid-svg-LAHh5g6XmOBu6LFu .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-LAHh5g6XmOBu6LFu .arrowheadPath{fill:#333333;}#mermaid-svg-LAHh5g6XmOBu6LFu .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-LAHh5g6XmOBu6LFu .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-LAHh5g6XmOBu6LFu .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LAHh5g6XmOBu6LFu .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-LAHh5g6XmOBu6LFu .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LAHh5g6XmOBu6LFu .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-LAHh5g6XmOBu6LFu .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-LAHh5g6XmOBu6LFu .cluster text{fill:#333;}#mermaid-svg-LAHh5g6XmOBu6LFu .cluster span{color:#333;}#mermaid-svg-LAHh5g6XmOBu6LFu div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-LAHh5g6XmOBu6LFu .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-LAHh5g6XmOBu6LFu rect.text{fill:none;stroke-width:0;}#mermaid-svg-LAHh5g6XmOBu6LFu .icon-shape,#mermaid-svg-LAHh5g6XmOBu6LFu .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-LAHh5g6XmOBu6LFu .icon-shape p,#mermaid-svg-LAHh5g6XmOBu6LFu .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-LAHh5g6XmOBu6LFu .icon-shape .label rect,#mermaid-svg-LAHh5g6XmOBu6LFu .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-LAHh5g6XmOBu6LFu .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-LAHh5g6XmOBu6LFu .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-LAHh5g6XmOBu6LFu :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过
429 + Retry-After
采集 QPS/RT/拦截率
异常来源
核心接口
非核心
客户端/爬虫/活动流量
CDN/WAF
Spring Cloud Gateway 集群
Sentinel Gateway Filter
路由限流 + 热点参数限流
微服务集群
客户端退避/排队页
Prometheus + Grafana
规则决策
Nacos 动态阈值 + 风控策略
封禁服务
Redis 黑名单/计数器 TTL
服务熔断/限时/舱壁
MySQL/读写库
缓存/降级数据/消息队列
HikariCP
连接上限 + 获取超时
!\[01-过载保护闭环.png\|网关限流、服务降级与数据库连接池保护闭环]
图中最重要的不是组件数量,而是边界:网关不能把请求无限排队,服务不能把所有异常都重试,数据库不能让每个业务实例随意占满连接。
二、第一道防线:Gateway + Sentinel 做快速准入
1. 依赖和规则数据源
依赖要跟 Spring Cloud Alibaba BOM 对齐,不要单独拼一组互相不兼容的版本。
xml
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
网关节点通过 Nacos 订阅规则,gw-flow 是 Gateway 规则类型,不能误写成普通接口资源的 flow:
yaml
spring:
cloud:
sentinel:
transport:
dashboard: ${SENTINEL_DASHBOARD:sentinel-dashboard:8080}
port: 8719
datasource:
gw-flow:
nacos:
server-addr: ${NACOS_ADDR:nacos:8848}
data-id: sentinel-gateway-rules
group-id: DEFAULT_GROUP
rule-type: gw-flow
data-type: json
例如,对订单创建路由按来源 IP 做 1 秒 500 次的基础保护:
json
[
{
"resource": "api-order-create",
"count": 500,
"intervalSec": 1,
"controlBehavior": 0,
"burst": 50,
"paramItem": {
"parseStrategy": 0,
"fieldName": null,
"pattern": null,
"matchStrategy": 0
}
}
]
这里的 GatewayFlowRule 和方法级的 ParamFlowRule 不是一回事。网关规则必须绑定路由资源,否则规则能在配置中心保存,却不会在 Gateway 过滤器上生效。
2. 来源解析要建立信任边界
X-Forwarded-For 只有在请求确实来自可信代理时才能使用。直接信任客户端传来的这个 Header,攻击者可以任意伪造 IP,导致限流和封禁全部失效。生产代码应只读取经过负载均衡器重写的来源,或者使用 RemoteAddress:
java
@Configuration
public class SentinelGatewayConfig {
@PostConstruct
public void init() {
SentinelGatewayFilter.setRequestOriginParser(request -> {
// 仅在网关前置代理已清洗 Header 时使用 X-Real-IP。
String ip = request.getHeaders().getFirst("X-Real-IP");
if (StringUtils.hasText(ip)) {
return ip;
}
InetSocketAddress remote = request.getRemoteAddress();
return remote == null ? "unknown" : remote.getAddress().getHostAddress();
});
}
}
真正的防刷通常不只看 IP,还会组合 User-Agent、设备指纹、用户 ID 和请求参数熵值。维度越多,越应该把计算拆成轻量的本地判断,复杂画像交给异步风控服务。
3. 被限流后的响应和渐进式封禁
限流处理器要返回明确的 429,并告诉客户端什么时候重试。封禁评估可以异步提交,但不能在 EventLoop 上等待 Redis 或数据库;更不能对所有 429 直接永久封 IP。
java
@Component
public class CustomBlockRequestHandler implements BlockRequestHandler {
private final BanEvaluationService banService;
public CustomBlockRequestHandler(BanEvaluationService banService) {
this.banService = banService;
}
@Override
public Mono<ServerResponse> handleRequest(ServerWebExchange exchange,
Throwable ex) {
String origin = exchange.getRequest().getHeaders().getFirst("X-Real-IP");
banService.trySubmit(origin, ex.getClass().getSimpleName());
return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
.contentType(MediaType.APPLICATION_JSON)
.header("Retry-After", "1")
.bodyValue(Map.of("code", 42901,
"message", "请求过于频繁,请稍后重试"));
}
}
注册时使用 GatewayCallbackManager.setBlockHandler(...)。BlockRequestHandler 返回 Mono<ServerResponse>,由 Sentinel 的 WebExceptionHandler 负责写回响应,不要在业务代码里手动 subscribe();这能避免重复提交和响应未完成的问题。封禁评估可以异步提交,但不能在 EventLoop 上等待 Redis 或数据库;更不能对所有 429 直接永久封 IP。封禁建议分级:首次超限只返回 429,短时间连续超限再写入带 TTL 的 Redis 黑名单,命中多次后才延长封禁;内部系统、CDN 回源和活动白名单必须在网关最前面跳过封禁。
三、第二道防线:服务侧熔断、隔离和降级
网关放行并不代表服务一定有能力处理。下游平均 RT 从 50 ms 变成 500 ms 时,即使入口 QPS 不变,在途请求也会按 Little's Law 近似放大十倍。服务侧必须在自己的边界内再次限制并发和等待时间。
1. Resilience4j 的保守配置
下面是一组适合读接口的起始值,不是通用答案。生产上应使用压测和历史指标重新校准:
yaml
resilience4j:
circuitbreaker:
instances:
inventory:
slidingWindowType: COUNT_BASED
slidingWindowSize: 100
minimumNumberOfCalls: 40
failureRateThreshold: 50
slowCallDurationThreshold: 500ms
slowCallRateThreshold: 60
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 3
timelimiter:
instances:
inventory:
timeoutDuration: 800ms
bulkhead:
instances:
inventory:
maxConcurrentCalls: 200
maxWaitDuration: 0
核心原则是:超时要短于上游整体超时,maxWaitDuration 尽量为 0,避免请求在舱壁里排队;重试只允许幂等读请求,并且最多一次,不能把故障流量放大。
2. Reactive 调用示例
java
public Mono<InventoryView> query(String sku) {
return inventoryClient.query(sku)
.timeout(Duration.ofMillis(300))
.transformDeferred(CircuitBreakerOperator.of(inventoryBreaker))
.transformDeferred(BulkheadOperator.of(inventoryBulkhead))
.onErrorResume(this::isOverloaded,
ex -> cache.get("inventory:" + sku)
.map(InventoryView::stale)
.switchIfEmpty(Mono.just(InventoryView.unknown())));
}
private boolean isOverloaded(Throwable ex) {
return ex instanceof TimeoutException
|| ex instanceof CallNotPermittedException
|| ex instanceof BulkheadFullException;
}
降级不是把所有异常都吞掉。库存、支付、扣款等核心写请求宁可快速失败并让客户端按业务协议重试;商品详情、推荐、排行榜等非核心读请求可以使用带时间标记的缓存;耗时计算可以返回 202 Accepted 和任务 ID,但前提是请求已经可靠写入队列,不能拿 202 掩盖实际没有接收成功。
3. 动态阈值要有回滚开关
Sentinel 的 SystemRule 可以结合 CPU、Load、平均 RT 和并发线程数收紧入口,但自动调参必须带上下限、白名单和版本号。配置中心故障时,节点应继续使用最近一次成功规则和一套很保守的 fallback,而不是把所有请求放开。规则发布建议走 Git 版本库或配置中心版本历史,出现核心指标断崖时支持自动回滚 30%~50% 的阈值。
四、第三道防线:DB 连接池如何保护
数据库通常不是第一个报警的组件,却是最容易把整个集群拖死的组件。连接池一旦长时间 pending,应用线程、请求上下文和响应缓冲区都会继续占用资源,最后表现为网关 P99 飙升、服务线程池耗尽和数据库连接数打满。
1. 先算总预算,再填 Hikari 参数
连接池不能按"机器越多,连接越多"配置。一个简单的预算公式是:
text
单实例最大连接数 <= floor((DB max_connections - 预留连接) / 应用实例数)
例如 MySQL max_connections=300,为运维和复制预留 60 个连接,4 个服务实例平均分配,理论上限为 60;考虑突发和其他作业,应用可以先设为 48,再通过压测调整。若数据库平均查询耗时 20 ms,48 条连接的理论数据库并发大约是 48,不能因为网关能接收几万 QPS 就把池子改成 500。
2. HikariCP 起始配置
yaml
spring:
datasource:
hikari:
maximum-pool-size: 48
minimum-idle: 8
connection-timeout: 150ms
validation-timeout: 1s
idle-timeout: 10m
max-lifetime: 25m
keepalive-time: 5m
initialization-fail-timeout: 1
register-mbeans: true
connection-timeout 是获取连接最多等多久,不是 SQL 执行超时。两者都要设置:连接获取超时用于快速失败,事务/查询超时用于释放已经借出的连接。leak-detection-threshold 适合预发排查连接泄漏,生产开启要评估日志开销。
3. JDBC 阻塞调用要隔离,并提前设置业务舱壁
如果服务仍使用 JDBC,不能把查询直接放到 WebFlux EventLoop。下面的 Semaphore 是应用层的最后一道并发闸门,数量应小于连接池上限,给健康检查和其他轻量 SQL 留出余量:
java
private final Semaphore dbSlots = new Semaphore(40);
public Mono<Order> findOrder(long id) {
return Mono.defer(() -> {
if (!dbSlots.tryAcquire()) {
return Mono.error(new ServiceBusyException("db bulkhead is full"));
}
return Mono.fromCallable(() -> orderRepository.findById(id))
.subscribeOn(Schedulers.boundedElastic())
.timeout(Duration.ofMillis(200))
.doFinally(signal -> dbSlots.release());
});
}
更理想的方案是迁移到 R2DBC,并用响应式连接池;但无论 JDBC 还是 R2DBC,都要保留连接获取超时、SQL 超时和并发上限。只换客户端而没有边界,仍然会把数据库打满。
4. 必须监控的连接池指标
active:正在使用的连接数;idle:空闲连接数;pending:等待连接的请求数;- 获取连接耗时、SQL 执行耗时和超时次数;
- 数据库端
Threads_running、慢查询、锁等待和拒绝连接数。
当 pending > 0 持续 30 秒,或连接获取 P99 超过 100 ms,应先降低入口和非核心接口并发,再排查慢 SQL;不要第一反应就是继续增大连接池。
五、智能封禁和误封治理
限流是防守,封禁是风控动作。黑名单状态可以放入 Redis 并设置 TTL:
java
public Mono<Boolean> ban(String origin, Duration ttl) {
String key = "risk:gateway:ban:" + origin;
return redis.opsForValue().set(key, "1", ttl);
}
封禁策略建议分三档:
| 行为 | 处理 | 说明 |
|---|---|---|
| 单次超限 | 429 + Retry-After |
给客户端退避机会 |
| 10 分钟内多次超限 | 临时黑名单 15~120 分钟 | Redis TTL,支持人工缩短 |
| 明确恶意特征 | WAF/风控联动封禁 | 不让业务网关承担复杂画像 |
对疑似爬虫可以采用"影子封禁":请求仍然透传,但返回缓存数据或低价值数据,同时打上风险标签。这样可以降低误杀真实用户的概率,也能观察策略是否有效。内部系统、回源 IP、压测账号和活动白名单要在策略最前面生效。
六、监控、告警与压测验收
Prometheus 看板至少覆盖:
- Gateway 路由 QPS、P95/P99、429/5xx、规则同步状态;
- Sentinel
passQps、blockQps、RT、热点参数 TopN; - 熔断器状态、慢调用率、Bulkhead 拒绝数和降级命中率;
- Hikari active/idle/pending、连接获取耗时、慢 SQL 和锁等待;
- EventLoop pending task、CPU、堆/直接内存、GC、网络带宽。
告警要组合条件,避免"拦截率高就狼来了":例如拦截率连续 2 分钟超过 60%,且下游 P99 同时升高,才升级为服务级告警;单节点 Nacos 断连属于规则级告警;单 IP 突增属于风控事件,不应直接把运维电话打爆。
压测至少做三轮:
- 单实例基线:固定报文大小、路由比例和下游延迟,找出 P99 开始恶化的安全 QPS。
- 端到端容量:加入真实数据库、缓存、队列和故障响应,验证扩容是否接近线性。
- 故障与耐久:模拟下游变慢、Nacos 断连、Redis 延迟、单实例退出和长时间运行,观察规则 fallback、连接池 pending 与内存是否恢复。
结语:把"保护"做成层层递进的边界
一套可靠的流量治理不是 Sentinel 的单点能力,而是四层边界共同作用:CDN/WAF 过滤明显恶意流量,Gateway 快速限流和封禁,服务侧熔断/隔离/降级,数据库连接池限制最后的并发预算。
最容易犯的错误有三个:把固定阈值当成永久答案,把所有异常都重试,把数据库连接池当成吞吐开关。正确做法是用真实压测得到安全容量,用动态规则跟随系统负载,用明确的降级协议保护核心链路,再用连接池 pending 和慢 SQL 指标决定是否继续放行。
原文参考:高可用 API 网关限流与防刷实战:Spring Cloud Gateway + Sentinel 动态阈值与智能封禁