
做外卖平台对接这几年最让人头疼的就是第三方渠道那边的接口稳定性。霸王餐这类活动平台一天到晚订单量跟心电图一样午餐高峰前几分钟突然涌进来几千单没等商家反应过来下游第三方接口先给你返回一堆超时和限流。我在网关层折腾了大半年最终用Spring Cloud Gateway Redis限流这套组合把问题按住了。这篇文章把我踩过的坑、验证过的配置、压测数据全部摊开讲希望能帮到正在做外卖API对接、又被接口网关和限流折磨的同学。先说清楚这里说的“霸王餐”不是真去白吃白喝而是外卖行业里常见的优惠活动聚合模式平台通过补贴、优惠券、商家联合活动吸引用户下单同时对接饿了么、美团、拼多多外卖等上游渠道在中间做订单聚合与履约调度。这种业务的流量曲线极其不稳定一个爆款活动上线几秒钟内QPS就可能翻几倍如果没有一层像样的接口网关挡在前面下游服务和第三方API都会被冲垮。1. 霸王餐平台为什么需要API网关1.1 业务架构是什么样霸王餐平台本质上是外卖优惠聚合与订单分发系统。一边对接饿了么、美团、拼多多外卖这类上游渠道另一边承接商家侧、用户侧、运营后台的流量。上游渠道可能有十几个每个渠道的接口签名规则、回调机制、报文格式都不一样。如果每个业务模块都直接去对接上游服务治理会变得极其混乱。我这边典型的链路是用户端APP下单 - 网关 - 订单中心 - 渠道适配器 - 第三方外卖平台API - 回调通知 - 状态回写。当时接入了11个上游渠道每个渠道有4到6个核心接口总数超过50个。如果不把这层调用关系收敛到一个网关一个渠道改签名格式可能牵动四五个服务跟着改。而且用户端是直接暴露在公网上的如果没有统一的鉴权、限流、校验入口等于把整个微服务后门敞开。网关层解决的正是这类问题。它位于客户端和微服务之间把路由转发、公共参数校验、签名验证、流量控制、熔断降级这些横切关注点收拢到一起让下游业务服务专注于业务逻辑本身。在霸王餐这个场景网关的必要性约等于一个服务器机房的配电柜你看不见它做了多少事但它一旦下线整个系统都转不了。1.2 网关选型为什么用Spring Cloud Gateway我们最初也纠结过Zuul 1.x、Kong、APISIX。选Spring Cloud Gateway有几个实际考量技术栈统一团队主栈是Spring CloudSCG基于Spring WebFlux的响应式模型天然能融入现有Spring Cloud生态学习成本低。过滤器机制灵活在Java里写过滤器比在Nginx上写Lua顺手得多尤其是处理渠道签名校验、请求头改写、异常统一处理这类复杂逻辑时Java的调试和排错能力优势很明显。与Nacos、Sentinel整合顺畅SCG的路由定义支持从Nacos动态刷新Sentinel官方也提供了Gateway适配模块集成简单。性能在业务场景内够用虽然响应式编程上手有点门槛但Netty WebFlux在每秒几百到几千请求量级下表现完全够用不比Kong差太多。如果你是完全独立的网关团队没有历史包袱APISIX也可以考虑。但如果你想和现有Spring Cloud微服务体系统一无缝衔接SCG还是更省事运维成本更低。1.3 网关层要解决的核心矛盾统一鉴权对用户端请求做JWT校验对服务间调用做Token校验杜绝绕过网关直接打下游。动态路由上游渠道变更、灰度发布时快速切换路由而不重启网关。报文适配把前端JSON转换为特定渠道要求的格式或者反方向的字段映射。限流熔断核心中的核心。在上游渠道QPS配额有限的前提下防止突发流量把渠道接口和订单服务打死。在霸王餐场景下限流尤其关键。外卖渠道API都有明确的QPS配额一旦超限轻则接口报错重则被限制调用一段时间。商品详情、下单、退款这些接口暴露给用户端高峰期流量很大必须先在网关层做第一道保护宁可挡掉一部分请求也不能让下游被打挂。2. 路由与过滤器配置实战2.1 路由规则怎么写SCG的核心配置是路由一个路由由id、predicate、filter、uri四部分构成。以霸王餐平台为例我的路由表大致这么分/api/user/** - 用户中心服务/api/order/** - 订单中心服务/api/merchant/** - 商家中心服务/api/channel/** - 渠道适配中心服务路径设计上我建议遵循“业务域 动作”的层级关系不要在网关做太精细的路径匹配。粗粒度路由放网关细粒度权限校验放下游服务职责更清晰。下面是核心配置示例spring: cloud: gateway: routes: - id: user-center uri: lb://USER-CENTER predicates: - Path/api/user/** filters: - StripPrefix2这里要注意StripPrefix2表示去掉URL路径中的前两级也就是/api/user转发到下游时只剩后面部分。这个值不设对的话下游服务接收到的路径带了多余的/api/user前缀Spring MVC Controller匹配不上直接404。我自己踩过这个坑排查了半天才发现是StripPrefix层级算错了。2.2 过滤器链的编排思路SCG过滤器分全局过滤器、网关过滤器和默认过滤器三类。我在霸王餐网关实际编排了一套过滤器链顺序是全局签名过滤器校验请求签名和时间戳防篡改、防重放。全局Token过滤器校验登录态解析用户身份。业务过滤器按渠道ID改写某些请求头补充链路追踪标识。限流过滤器RequestRateLimiter或Sentinel网关限流。过滤器顺序非常关键。SCG通过order字段控制执行顺序数值越小越先执行。签名校验要放在限流之前因为限流丢弃请求前必须先把非法请求清出去。否则攻击者可以伪造大量假请求来消耗限流配额导致正常用户的请求被误伤。全局过滤器的自定义代码大致长这样Component public class SignFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String sign exchange.getRequest().getHeaders().getFirst(sign); boolean valid checkSign(exchange.getRequest(), sign); if (!valid) { exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED); return exchange.getResponse().setComplete(); } return chain.filter(exchange); } Override public int getOrder() { return -100; } }自定义全局过滤器时一定要注意getOrder的值。数值越小越先执行。我在这个过滤器里做了简单的时间戳防重放校验时间戳与服务器当前时间误差超过60秒直接拒绝同一个签名和时间戳在5分钟内只能使用一次。这个逻辑对防止下单接口被刷量很有效。2.3 Nacos动态路由配置路由写死在配置文件里肯定不行。外卖渠道经常要做临时下线、切流、灰度我得能在不重启网关的情况下更新路由。我的做法是把路由定义放到Nacos配置中心通过配置监听拿到新的RouteDefinition列表后用RouteDefinitionWriter更新内存中的路由。核心思路是Configuration public class DynamicRouteConfig { private final RouteDefinitionWriter writer; NacosConfigListener(dataId gateway-routes.json, groupId DEFAULT_GROUP) public void updateRoutes(String config) { ListRouteDefinition definitions parseJson(config); definitions.forEach(def - { writer.delete(Mono.just(def.getId())).subscribe(); writer.save(Mono.just(def)).subscribe(); }); publisher.publishEvent(new RefreshRoutesEvent(this)); } }这里有个坑writer.save不能重复保存相同routeId的路由否则会报RouteDefinition already exists。我最开始只调saveNacos配置一刷新就报错后来改成先delete再save实测就没再出问题。另外JSON格式要严格一个字段大小写错了整个路由列表解析失败网关就变成空路由了。我在Nacos上配置了JSON Schema校验从源头避免格式问题。2.4 跨域、重试和网关层细节用户端在H5里跨域调用网关层需要统一配置CORS。但注意CORS配置不要和限流过滤器冲突。我遇到过一次因为预检请求OPTIONS被限流拦截前端页面直接报跨域错误排查了很久才发现是KeyResolver没有放行OPTIONS请求。后来在限流过滤器的KeyResolver实现里遇到OPTIONS请求直接返回一个独立的preflight键让预检请求走独立的限流池问题解决。重试方面对于GET请求可以配置重试但POST下单、支付回调不能盲目重试否则可能造成重复下单。我配置的重试过滤器只匹配GET方法且限定重试次数为2次间隔500毫秒。这一条在压测时验证过重试率在1%以下对接口最终一致性影响很小。3. 限流方案选型与实现原理3.1 限流算法怎么选限流的本质是控制单位时间内的请求量算法本身并不复杂难点在于适配业务场景。固定窗口实现简单但临界问题明显窗口切换瞬间可能放行两倍流量。滑动窗口比固定窗口平滑但需要存储每个请求的时间点内存开销大。漏桶算法恒定速率处理请求适合保护下游数据库但无法利用突发资源。令牌桶算法允许一定程度的突发流量桶里攒了多少令牌就能瞬时消化多少请求外卖高峰突发的场景正好适合。外卖订单流量的特点就是波峰明显平时几十QPS午高峰可能冲到几百甚至上千。用令牌桶可以让突发流量在桶容量范围内被缓冲不至于直接把下游打垮。SCG内置的RequestRateLimiter就是令牌桶实现底层依赖Redis执行Lua脚本整体性能非常稳定。3.2 网关限流和应用限流怎么配合限流这件事网关做是入口层的粗粒度保护应用做是业务层的关键接口细粒度保护。最好的方式是两层配合各司其职。网关层负责挡住大流量统一按渠道、AppID、用户维度做限流。优点是集中管理一处配置全局生效缺点是不够精细例如无法针对某个商家的订单量、某个渠道的退款频率做细粒度控制。应用层负责精耕细作用Sentinel在服务内部控制关键接口的QPS和线程数。比如订单中心的createOrder接口限流阈值100 QPS超过之后快速失败返回降级结果。我实际验证下来的结论是网关限流 应用限流结合起来才能兼顾整体保护和局部精准。网关限流设置在80%的应用预估容量上限应用限流设置120%的峰值保护线这样既不会误伤正常流量又能在极端情况下兜底。3.3 SCG内置的RequestRateLimiter原理SCG内置的限流过滤器叫RequestRateLimiter底层依赖RedisRateLimiter核心是一个在Redis里执行的Lua脚本。这个脚本维护了两个key一个是当前令牌桶一个是上次补充时间。每次请求进来脚本先计算从上次补充到现在应该加入多少令牌再判断桶里有没有足够的令牌决定放行还是拒绝。关键参数redis-rate-limiter.replenishRate令牌补充速率也就是每秒新增多少个令牌。redis-rate-limiter.burstCapacity令牌桶最大容量决定瞬间能放行多少突发请求。redis-rate-limiter.requestedTokens每次请求消耗的令牌数一般默认1。三个参数的关系要理解透彻。假设设置replenishRate10burstCapacity20意思是系统每秒钟补充10个令牌但桶里最多攒20个。如果前面100毫秒内没有请求桶是满的那么这100毫秒内突然来20个请求全部放行。再后面的请求如果补充速度跟不上消耗就会被拒绝。这正好符合外卖午高峰的突发特性。3.4 Sentinel网关限流的优势SCG内置的RedisRateLimiter够用但规则是写死的动态调整不方便。后来我引入了Sentinel官方提供了spring-cloud-alibaba-sentinel-gateway模块支持路由维度和API分组维度的限流配合Nacos可以动态推送规则实时生效。两者的选型建议是如果只是简单网关限流SCG内置的够用压测数据很稳定不依赖额外组件。如果需要配置多个限流维度、不同渠道不同规则、随时调整阈值优先选Sentinel它的控制台和规则下发链路更成熟。我的最终方案是两张牌都打核心的全局保护用SCG内置的RedisRateLimiter不做太复杂渠道和商家维度的动态规则交给Sentinel通过Nacos下发运营活动上线前调整阈值不用重启服务。4. 实操从零配置霸王餐接口网关限流4.1 环境准备我本地这套环境是JDK 17Spring Boot 2.7.xSpring Cloud 2021.0.5Spring Cloud Alibaba 2021.0.5.0Nacos 2.1.1Redis 6.2版本匹配很关键。Spring Boot 2.7.x需要Spring Cloud 2021.0.xSpring Cloud Alibaba的版本也要和Nacos客户端兼容。版本配错经常出现“类找不到”或者“方法签名不对”这类莫名其妙的问题排查起来非常费时。4.2 关键依赖引入dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-gateway/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis-reactive/artifactId /dependency dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-alibaba-sentinel-gateway/artifactId /dependency注意必须使用reactive版本的Redis客户端。SCG是响应式模型使用阻塞式redis客户端会阻塞Netty事件循环线程导致整个网关性能急剧下降这个问题在压测时现象非常明显所有请求都卡在等待Redis响应上。4.3 YAML核心配置spring: redis: host: 192.168.1.10 port: 6379 password: xxxxx cloud: gateway: routes: - id: order-route uri: lb://ORDER-SERVICE predicates: - Path/api/order/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 10 redis-rate-limiter.burstCapacity: 20 key-resolver: #{channelKeyResolver}这里key-resolver的值是一个Bean的名称用SPEL表达式引用。它决定限流器按什么维度来计数。4.4 自定义KeyResolver的实现按渠道维度限流是最常用的。霸王餐平台对接多个外卖渠道每个渠道的QPS配额不一样必须分开统计。Bean public KeyResolver channelKeyResolver() { return exchange - { ServerHttpRequest request exchange.getRequest(); String channelId request.getHeaders().getFirst(X-Channel-Id); if (channelId null) { channelId request.getQueryParams().getFirst(channelId); } if (channelId null) { channelId default; } return Mono.just(channelId); }; }实际使用中KeyResolver有三个注意事项第一不能返回null否则网关会报空指针异常。所有分支都要保证有一个兜底值。第二返回的字符串要稳定。因为Redis的key是基于它拼接出来的如果字符串忽长忽短容易导致Redis内存碎片。建议直接用渠道ID不要拼接随机值。第三限流维度不宜过细。我最初是按请求URL维度限流结果几十个接口对应几十个Redis key内存消耗大而且规则难以管理。后来改成“渠道 接口大类”的维度比如channel_11_order、channel_11_refund数量可控语义也清晰。4.5 动态限流规则下发结合Sentinel网关限流实现在Nacos上动态调整规则。在Nacos中创建一个配置文件例如sentinel-gateway-flow.json[ { resource: api/order/**, grade: 1, count: 100, intervalSec: 1, controlBehavior: 0, maxQueueingTimeMs: 500 } ]grade1表示按QPS限流count100表示每秒允许100个请求controlBehavior0是直接拒绝controlBehavior2是匀速排队配合maxQueueingTimeMs500可以让部分超限请求排队等待而不是直接拒绝。业务上线活动前运营会预估流量我在Nacos里改一下count值网关在秒级内就生效不需要重启。这个能力在接入新渠道时也很有用渠道方给的QPS配额是50那就直接把count设成50多一个请求都不放进去。4.6 压测验证压测工具用的JMeter模拟下单接口场景并发200线程循环次数50次单次迭代包含一次下单和一次商品详情查询压测结果如下配置项总请求量被限流量平均响应时间下游最大RT无限流2800001500ms(逐渐恶化)3200ms网关限流100QPS100001800045ms800msSentinel限流匀速排队1000013000(排队)80ms600ms数据说明无限流时下游服务在持续高负载下响应时间急剧恶化部分请求超时。加了网关限流后上游快速返回429下游服务负载稳定在安全范围。虽然被拦截的请求量很大但保护住了核心订单链路。我后来专门优化了被限流时的响应体{ code: 429001, message: 当前请求量过大请稍后重试, data: null }前端识别到429001后自动弹窗提示而不是显示空白页。这一层用户体感的优化有时候比技术本身更重要。5. 踩坑实录与问题排查5.1 限流不生效排查最常遇到的就是配了RequestRateLimiter但请求都没走限流逻辑。检查顺序是是否在配置类中声明了Bean指向的KeyResolver类Bean是否存在。YAML里key-resolver: #{channelKeyResolver}的SPEL表达式Bean名是否大小写一致。网关启动日志里有没有出现RequestRateLimiter相关告警。用Redis客户端直接查看限流的key是否存在如果key存在说明限流器在执行只是参数不合理。我遇到过Spring容器里有两个同名Bean导致SPEL表达不明确的情况系统直接报了BeanNotOfRequiredTypeException。排查时把自定义的Bean加上了Primary注解问题解决。5.2 Redis故障导致网关不可用这是比较坑的一点。RequestRateLimiter在Redis不可用时会直接抛异常如果没做降级处理网关会拒绝所有请求。当时Redis集群做了一次主从切换瞬间恢复正常但切换期间下单接口全线超时。后来我给网关加了一层兜底保护Sentinel限流配置里设置了一个自定义的fallback处理器当Redis不可用时网关限流自动降级到本地Guava令牌桶虽然多实例情况下限流不够精确但能保证网关不至于全挂。这个兜底逻辑放在网关的全局异常处理器里如果获取令牌时捕获到Redis连接异常就返回一个特殊响应码让本地限流器接管本次请求的判定。5.3 StripPrefix转发路径不一致这个问题我在2.1节提过但值得再说一次。StripPrefix1和StripPrefix2的区别很多新手搞混。Path/api/order/**配合StripPrefix2会把/api/order/create转成/create发给下游如果StripPrefix3就会变成/直接把路径弄丢下游Controller映射上必然404。一个稳妥的做法是在网关日志里临时开启logging.level.org.springframework.cloud.gatewayDEBUG请求转发时的原始路径和重写后路径都会打印出来对照日志去调StripPrefix的层级比自己瞎猜快得多。5.4 Header丢失问题转发到下游服务时自定义Header默认会传递但需要确保过滤器链前面没有把Header覆盖掉。还有一个坑下游服务如果想获取真实用户IP依赖的是X-Forwarded-For而SCG默认不会自动生成这个Header需要加入ForwardedHeaderFilter。否则下游接到的IP永远是网关的IP风控系统会把所有请求判定为同一个来源误杀率极高。我在网关里加了一个自定义全局过滤器把所有链路信息封装成标准Header统一传递到下游。包括X-Request-Id、X-User-Id、X-Channel-Id、X-Forwarded-For。统一了Header标准之后下游各服务做日志追踪和风控都省了很多事。5.5 429响应体不一致导致前端解析崩溃官网文档默认的限流响应体是短文本加上某些异常过滤器返回的内容结构不一样前端解析时就容易出问题。后来我在网关配置了一个自定义的ErrorWebExceptionHandler把所有限流、超时、路由404、网关错误全部格式化成为统一JSON结构。统一响应结构这个动作看起来不起眼但对前端和下游联调的帮助非常大。之前前端要对不同错误码写多个解析逻辑经常漏判统一之后错误提示逻辑收敛到前端一个函数里bug少了很多。5.6 监控告警比压测更重要最后说一个不算踩坑但也值得记录的点。我后来给网关加了监控Prometheus Grafana重点看四个指标——网关请求总量、限流拦截量、下游服务RT、Redis命中率。一旦发现某个渠道持续触达限流阈值大概率是上游渠道异常或者有恶意流量在刷接口早发现比什么都重要。做了这么久网关我最大的体会是限流不是单纯的QPS数字游戏而是要在业务波峰、用户体验和下游稳定性之间找平衡。霸王餐这类活动平台的流量特征决定了你很难用死板的固定阈值守住一切只有把内置限流、动态规则、排队降级和监控告警结合起来才能真正做到既保护下游、又不误伤用户。最后再分享一个小技巧网关层的限流阈值不要一拍脑袋定先压测拿到下游服务的实际承载能力再按80%的比例回推网关阈值。我们当时把订单中心压测测出的峰值1200 QPS回推到网关设了1000的阈值留了200的余量实测大促期间没出过一次服务雪崩。