C端网关一般都会有路由转发、鉴权、限流等这些功能,但是还得考虑一个事情,就是稳定性的,比如:
- Redis挂了,请求是放行还是拒绝?
- 用户退出登录了,之前签发的token还能不能用?
- 下游服务抛了个500,用户看到的是什么?
- 系统要停机升级半小时,几百万打开小程序的用户看到的是什么?
- 。。。。各种异常场景。。
一个C端网关靠不靠谱,看的就是这些异常场景怎么处理。
下面是我真实打造过的一个生产环境级别的网关实战内容,应付的量级用户是上亿的。技术栈是Spring Cloud Gateway。这次分享出来,希望你读完后,对如何做一个靠谱的C端网关,心里有个底。
先给一个全局视角
一个请求从进到出,要过的关卡大致是这些:

后面按这个顺序逐个展开,另外顺序不是随便排的,要遵守一个规则的:越省资源的检查越靠前。
- 验签和身份认证只读请求头,代价最小;
- 限流要查一次Redis;
- 权限校验可能一查就是好几次。
请求如果需要要被拦下的,那在靠前的关卡里拦住,浪费的资源就少。
身份认证:不只是验token
C端的身份认证一般用JWT,客户端登录后拿到token,之后每个请求带上,网关验签通过后放行。这个大家应该都很熟悉了,网络有大把的介绍,但是正如我前面说的,要处理异常场景的。
第一个问题:用户退出登录了,token还没过期,怎么办?
JWT的特性是签发之后无法收回,到期才失效。用户点了退出登录,或者账号在另一台设备登录被挤掉,旧token从密码学上讲仍然有效。解决方式是黑名单:登出时把这个token写进Redis,网关验签之前先查一次黑名单,命中就直接拒绝。
这里还有一个要注意的点:查黑名单依赖Redis,Redis万一抖动了一下,是不是所有请求都过不了?我们当时的选择是查黑名单失败时放行:
java
// 查黑名单出错时当成未登出,不影响主链路
.onErrorResume(throwable -> {
log.warn("load logout token from redis fail", throwable);
return Mono.just(false);
});
这个取舍的依据是概率:登出黑名单本身拦截的是小概率场景,绝大多数请求既没有登出也不会伪造token。为一个小概率防护牺牲全站可用性,不划算。当然前提是你的业务能接受Redis故障的那几十秒里,已登出的token还能访问,如果是金融级场景,这个取舍就要反过来做。
第二个问题:验完身份,怎么把用户身份传给下游?
常见做法是把解析出的用户id、渠道这些信息塞进请求头,下游服务从请求头拿。这里有一个我认为最值得讲的一个细节:
写入之前,必须先把客户端传上来的同名头删掉。
java
ServerHttpRequest.Builder builder = originRequest.mutate().headers(headers -> {
headers.remove(HEADER_USER_ID);
headers.remove(HEADER_CHANNEL);
headers.remove(HEADER_SOURCE);
});
builder.header(HEADER_USER_ID, token.getUserId());
不删会发生什么?客户端可以自己构造一个请求,在请求头里直接写上用户id。如果网关只往里塞不先清,一个请求里就有两个同名字段,下游用 getFirst 拿到的可能是客户端伪造的那个。等于任何人在请求头里写个管理员id,就能以管理员身份调接口。这是当年找人帮我们做渗透性安全测试时发现的。因此先删后写,就几行代码,把伪造身份这条路彻底给堵死。
第三个问题:token不合法,返回什么?
是返回401吗? 我们这边返回的是HTTP 200加业务错误码,只有对非客户端来源才返回401。原因在于客户端的处理方式:小程序端有一个统一的响应拦截器,识别到「未登录」业务码就清空本地登录态、跳登录页,用户无感知。如果走401,这个逻辑就要分散到每个请求的异常分支里处理,而且C端链路上401还可能被某些中间代理改写成自己的错误页,行为不可控。
这个做法确实有违HTTP语义,但网关的下游是自己的客户端团队,约定大于规范,两边约定好用业务码驱动登录跳转就可以了。对第三方开放平台那种调用方不受控的场景,才轮到401出场。
开放接口验签:没有token的如何处理?
不是所有请求都带用户token。有一类接口是开放给合作方或者内部App的,调用方没有用户身份,只有应用身份。这类接口的应付方式是签名。
具体机制:调用方有一个client-id和一个只有双方知道的secret,请求参数里带上client-id、当前时间戳timestamp和签名sign。网关收到请求后做三件事:
- 查client-id找到对应secret;
- 检查时间戳和当前时间的差值,超过TTL就拒绝;
- 用同样的算法重算签名做比对。
java
// 参与签名的参数按key升序排序,拼接value后追加secret,做SHA256
paramList.sort(Comparator.comparing(Entry::getKey));
paramList.forEach(e -> sb.append(e.getValue()));
sb.append(secret);
return DigestUtils.sha256Hex(sb.toString().getBytes(StandardCharsets.UTF_8));
签名和时间戳解决的是两个不同的问题,少一个都不行。签名防篡改,参数被中间人改一个字节,重算的签名就对不上。时间戳防重放,有人把合法请求原样截获下来,过一小时再发一遍,签名依然合法,但时间戳已经过期。一定要做到:
防得住篡改同时也防得住重放。
接口级别限流
限流这块要注意三件事:按什么维度限、配置怎么改、出错了怎么办。
按什么维度限:全局限一个总QPS意义不大,因为接口和接口的抗压能力完全不同。下单接口要落库、调库存、调支付,50QPS可能就顶不住;查商品列表走了缓存,500QPS也没事。一刀切的阈值,对重的接口太松,对轻的接口太紧。所以可以按「HTTP方法+路径」的维度配置阈值,路径支持pattern匹配,先查精确匹配,查不到再走 /orders/{id} 这种模式:
java
// 先按完整路径精确匹配,匹配不到再逐个尝试路径模式
Config config = rateLimitTable.get(method, path);
if (config == null) {
config = matchByPathPattern(rateLimitTable.row(method), path);
}
配置怎么改:限流阈值是典型的运行期配置,大促前要把核心接口限流参数设置的小一些,发现误伤要马上放宽,不需要发版重启。具体的办法是配置存Redis的hash里,运营在后台改完立即生效到Redis;网关侧用一个本地缓存,后台线程每5秒刷新一次。每个请求读的是本地缓存,不走Redis。
按照「改配置只写Redis,读配置只走本地缓存,后台线程定时同步」这个结构,在后面的维护模式、权限数据里反复出现,是这个网关配置体系的通用做法。
出错了怎么办:两个方法。
- 第一个是全局限流开关,一个Redis key控制限流功能整体启停,发现限流规则配置失误大面积误伤时,先把开关关掉止血,再慢慢改规则,不用回滚代码;
- 第二个是限流器本身故障时的策略,Redis执行Lua脚本出错,直接放行:
java
// 限流计算出错时放行,不让Redis成为流量的硬依赖
.onErrorResume(throwable -> {
log.error("rate limiter redis lua exception", throwable);
return Flux.just(Arrays.asList(1L, -1L));
});
这个fail-open的做法是有必要的,因为:限流代码的bug或者Redis宕机都不应该影响正常请求,要在所有层级兜住异常让限流器失效时开放而不是关闭,并且一定要留一个能人工关停限流的开关。限流是保护措施,保护措施本身不能成为故障源。
权限校验:可以用插件的形式
身份认证解决「你是谁」,权限校验解决「你能干什么」。C端网关的权限校验需求杂而且会变:管理端接口要按角色鉴权,AB测试中的接口要对灰度外的用户隐藏,某些接口要限制请求方法。如果把这些揉在一个大过滤器里,加一种校验就改主干代码,会很乱的。
把每种校验做成一个独立的 AccessValidator 实现,过滤器里注入整个列表逐个执行:
java
// 所有校验器组成列表,任一校验器拦下请求就终止
return Flux.fromIterable(accessValidators)
.flatMap(validator -> validator.validate(context))
.next()
.switchIfEmpty(chain.filter(exchange));
新增一种访问控制,只需要新写一个实现类注册成Bean,主干代码一行不动。这是这个网关几年里不断有新校验需求而代码确不腐烂的关键。
维护模式:停机升级时用户看到什么
像我们之前遇到了一个超级大故障,数据库压力太大了,但是用户请求会不断进入,这个时候,要做的事情是,不是立马去调整限流参数,来不及了。
而是应该立刻丢掉流量。
你可以这么干,马上切到维护模式:让所有C端接口返回一个约定的业务码和维护信息,客户端识别后渲染成一张友好的维护页,告诉用户「系统升级中,预计xxxx后恢复」。
java
// 命中维护范围且维护开关打开,直接返回维护信息,不再往后转发
MaintenanceInfo maintenance = maintenanceRepository.getMaintenance();
if (maintenance != null) {
setResponseStatus(exchange, HttpStatus.OK);
setAlreadyRouted(exchange);
return writeJsonResponse(exchange, BaseResult.error(MAINTENANCE_CODE, maintenance));
}
实现就是百来行代码,一个过滤器加一个配置项。
用户看到后,无法再进行任何操作,请求到网关这一层就直接返回了,不会再打到后面的业务服务和数据库,后端压力瞬间就能降下来。开发和运维这个时候,就可以加紧处理故障了。
熔断降级处理
下游服务超时或错误率飙升,熔断器需要打开,请求会被短路到fallback上。Spring Cloud Gateway集成了熔断,配一个fallback地址就行,这部分框架已经包办了。你要注意的是fallback返回什么。
统一的提示「系统繁忙」够吗?不够的。
C端接口有核心和非核心之分:下单、支付是核心,而像猜你喜欢、积分查询是非核心,出问题就出问题。熔断时如果所有接口返回同一个错误码,客户端只能给同一个提示,那就会导致很差的用户体验。
正确的做法是fallback接口里拿到原始请求路径,按配置的正则区分:命中核心接口清单的,返回一个专门的业务码,客户端对应展示「活动太火爆了,稍后再试」并保留重试入口;没命中的返回通用错误。
熔断降级不是让所有请求都失败,而是资源紧张的时候,优先保住核心业务。
值得收藏的清单
| 能力 | 解决什么问题 | 实现要点 | 配置怎么热更 |
|---|---|---|---|
| 身份认证 | 确认用户是谁,处理登出失效 | JWT验签+token登出黑名单,黑名单查询故障时放行 | 登出状态实时写Redis |
| 身份透传 | 把身份安全地交给下游 | 请求头先删后写,防客户端伪造身份头 | 无配置,代码保证 |
| 开放接口验签 | 无用户token的应用级准入 | client-id配secret参数签名,时间戳TTL防重放 | 密钥走配置中心 |
| 接口级限流 | 突发流量不打垮重接口 | Redis+Lua令牌桶,按方法+路径配阈值,出错放行 | Redis存配置+本地缓存5秒刷新+总开关 |
| 权限校验 | 管理端鉴权、AB灰度保护 | 校验器插件链,RBAC用户-角色-接口三层 | MQ广播失效+本地缓存5分钟 |
| 维护模式 | 停服升级时的用户体验 | 返回约定业务码+维护信息,客户端渲染维护页,支持按路由圈定范围 | Redis配置+本地缓存15秒 |
| 熔断降级 | 下游故障时保住核心 | 按路径正则区分核心与非核心,返回不同业务码 | 正则清单走配置中心 |
小结
这套网关的技术栈是好几年前的了,Hystrix、Eureka、Sleuth放到今天都已经是上一代组件,现在如果重写一遍,肯定是用新技术了。但不管用什么技术,要解决的问题不会变。
如果你的团队正在搭或者重构网关,可以根据实际情况,参考我这篇文章。