1. 问题与目标

我们的后端服务同时承载几类流量:

  • 面向用户的自助接口:提交申请、查询状态、查看我的记录;
  • 外部系统的回调;
  • 运维接口;
  • 一个单页应用(SPA)前端。

部分用户请求会触发耗时较长、成本较高的外部操作,例如创建 Merge Request 和运行流水线。

在这样的服务里,横切关注点(限流、功能开关、鉴权、幂等、指标)本身也会出问题:配置缺失、配置写错、依赖服务异常、埋点代码抛出异常。我们的目标是:

  • 为每个横切关注点明确定义它在”依赖缺失、配置错误、依赖失败”时的行为;
  • 配置错误不会开放本应关闭的功能,也不会拦截本应放行的流量;
  • 指标采集永远不改变业务结果;
  • 错误响应对客户端有用,同时不泄露内部细节。

2. 约束与考量

  • 服务多实例部署,没有专门用于限流的共享缓存。
  • 配置来自配置中心并支持热更新。 这意味着配置可能缺失、为空或拼写错误,而且会在运行中发生变化。
  • 调用方身份来自统一登录 SDK,字段形态不统一。 有时是邮箱形式的账号,有时是工号,需要统一解析成同一个身份。
  • 不修改既有接口的返回结构。 部分历史接口直接返回 Map,横切逻辑不能依赖统一的返回类型。
  • 前端发布后存在旧页面。 已打开的旧页面会请求新版本中已被删除的带 hash 静态资源。
  • 团队约定可观测性代码只读不写。 埋点只能读取业务已经解析出的值,不能反过来影响业务逻辑。

3. 方案

3.1 拦截器的职责划分与顺序

拦截器按以下顺序注册,Spring 按注册顺序执行:

  1. 限流:仅作用于标注了 @RateLimit 的处理方法。
  2. 特定路径的鉴权。
  3. API 延迟与状态码指标。
  4. 业务活动指标:只作用于一个明确列出的路径白名单。

限流排在第一位有两个原因。其一,被拒绝的请求不应消耗任何后续处理。其二,业务活动计数衡量的是”用户完成了多少次业务动作”,被 429 拒绝的请求不应计入其中;它们由单独的”被限流次数”指标统计。

业务活动指标使用路径白名单而不是通配符,这样与该业务无关的流量根本不会进入这个拦截器。

限流和鉴权都放在拦截器里实现,在参数绑定之前执行,有两个好处:

  • 格式错误的请求体无法先触发 400、绕开或抢在鉴权之前返回;
  • 拦截器自己写出响应,不需要修改各个接口的返回类型。

3.2 注解驱动、在参数绑定前执行的限流

@PostMapping("/apply")
@RateLimit(bucket = "apply", limitKey = "APPLY_RATE_LIMIT", defaultLimit = 10,
           message = "提交过于频繁,请稍后再试")
public ResponseEntity<?> apply(@RequestBody Map<String, String> body) { ... }
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
    RateLimit rl = annotationOf(handler);
    if (rl == null) return true;

    int limit = config.getInt(rl.limitKey(), rl.defaultLimit());   // 每次请求读取,热更新即时生效
    if (limit <= 0) return true;                                    // 清空或非正数:停止限流,而不是全部拦截

    String subject = resolveSubject(req);
    if (limiter.tryAcquire(rl.bucket() + ":" + subject, limit, windowMillis())) return true;

    metrics.recordRateLimited(subject, rl.bucket());
    writeJson(resp, 429, ApiResponse.error("RATE_LIMITED", rl.message()));
    return false;
}

限流的主体优先使用登录身份。 这样同一个人换浏览器或开多个标签页,消耗的是同一份额度。只有在读不到身份时,才退回到客户端地址,并加上 ip: 前缀与身份区分。之所以退回到地址而不是归入一个共享桶,是因为共享桶会被任意一个调用方耗尽,进而影响所有无法识别身份的调用方。

限流器是滑动窗口日志。 每个桶保存一个时间戳队列,在 ConcurrentHashMap.compute 中完成”清理过期时间戳、判断、追加”,整个过程对单个桶是原子的。桶清空后返回 null,该键即被移除:

buckets.compute(key, (k, q) -> {
    Deque<Long> d = (q == null) ? new ArrayDeque<>() : q;
    while (!d.isEmpty() && now - d.peekFirst() >= windowMillis) d.pollFirst();
    if (d.size() < limit) { d.addLast(now); allowed[0] = true; }
    return d.isEmpty() ? null : d;
});

取舍需要写进注释。 限流先于幂等检查执行,所以超出额度的重复提交得到的是 429,而不是”重复提交”。我们接受这一点,并在接口注释中写明,避免后来的维护者误以为这是缺陷。

3.3 配置的默认值:按误配置的后果决定方向

热更新配置随时可能缺失或写错。我们为每个配置项明确规定缺省时的行为,规则是:看误配置的主要后果是什么,朝后果较轻的方向缺省。

配置项 误配置的主要后果 缺省行为
限流阈值 误伤正常流量 不限流(阈值 ≤ 0 视为关闭限流)
受限资源的开放模式 资源被意外开放 未知值视为”关闭”
受限资源的开放时间窗口 资源被意外开放 任一字段缺失或格式错误即视为不在窗口内
鉴权所需的规则码 越权访问 缺失即拒绝
新流程开关 启用未经验证的路径 走旧流程
重试次数、退避时间 负值导致跳过调用或负数休眠 下限截断为 0

开放模式的解析如下:

AccessMode resolve(String raw) {
    if (raw == null || raw.isBlank()) return TEST_WINDOW;        // 未配置:受限开放(默认)
    return switch (raw.trim().toUpperCase(Locale.ROOT)) {
        case "OPEN"        -> OPEN;
        case "TEST_WINDOW" -> TEST_WINDOW;
        default            -> CLOSED;                            // 拼写错误等任何未知值:关闭
    };
}

开放时间窗口按固定时区解析,而不是使用 JVM 默认时区,保证同一份配置在每台主机上对应同一段时间。

3.4 鉴权:失败即拒绝,拒绝时不泄露信息

  • 权限服务调用的任何异常都按”无权限”处理,规则码缺失同样按”无权限”处理。
  • 一次往返校验多个规则码,用于”管理员也可以访问”这类组合判断。
  • 区分 401 与 403。 读不到登录身份,或登录 SDK 抛出异常,返回 401;身份有效但无权限,或权限服务异常,返回 403。
  • 403 的响应消息固定为 “Access denied”,被拒绝的调用方无法得知需要哪一个权限。
  • 运维控制台的操作者身份由服务端写入。 服务端先复制客户端提交的参数,再用登录身份覆盖保留的 __authenticatedOperator 参数。顺序不能颠倒,否则客户端可以通过提交同名参数伪造操作者。审计日志只记录经过脱敏和长度截断的参数。

3.5 错误响应:对客户端提供可操作的信息,细节留在日志

我们在接口层区分两类错误:

// 客户端错误:返回一条可操作的提示,具体细节只写日志
ResponseEntity<?> clientFacingError(String code, String userMessage, String logDetail) {
    log.warn("rejected code={} detail={}", code, logDetail);
    return ApiResponse.error(code, userMessage);
}

// 服务端错误:日志记录原因和堆栈,客户端只收到通用提示
ResponseEntity<?> internalError(String code, String logDetail, Throwable cause) {
    log.error("internal code={} detail={}", code, logDetail, cause);
    return ApiResponse.error(code, GENERIC_ERROR_MESSAGE);
}

状态码按”客户端接下来应该做什么”来设计,而不是按”服务端内部发生了什么”:

  • DUPLICATE_SUBMIT 与 ACCEPTED 分开。 两者的 HTTP 状态都是 202,但前端收到 DUPLICATE_SUBMIT 时会保持表单锁定,而不是把它当作一次新的受理。
  • “等待人工处理”按能否自行恢复拆成两种提示。 如果请求停在一个可以对原 MR 重试的步骤上,提示”可以继续等待”;如果停在确定性的外部失败上(例如流水线失败),提示”需要联系维护团队,继续等待不会生效”。哪些步骤属于后者,由一个常量集合集中定义。
  • 只读的预览接口失败时返回空数据,不阻塞用户继续提交。

3.6 幂等与长操作

幂等。 写接口以客户端提供的 operationId 作为幂等键,依赖数据库唯一约束完成”查询、插入、冲突后重读”(详见数据库篇 3.4 节)。并发的重复请求中,只有一个会继续执行,其余的返回胜出方的结果。

长操作。 长操作在完成同步校验后返回 HTTP 202,后续在有界线程池中执行。线程池拒绝任务时,失败状态会写入数据库(详见 MR 生命周期篇 3.2 节)。客户端始终能通过轮询看到一个明确的状态。

3.7 可观测性不干扰业务

我们为指标代码制定了几条规则,并在实现中逐条落实:

  • 指标拦截器的 preHandle 永远返回 true,从不写响应。
  • 指标代码只读取已经解析好的值。 例如读取请求参数时使用 request.getParameter,而不是自己读请求体,避免消耗输入流、影响后续的参数绑定。
  • 指标使用独立的身份解析逻辑。 身份不可信时(例如只能拿到一个可能是工号的裸 ID),直接跳过本次计数,不做猜测,解析结果也不回流到业务逻辑。
  • 计数器写入包裹在 try/catch 中,异常只记日志。标签值经过规范化,并截断到 128 个字符;维度为空或非法时跳过发送。
  • 按语义选择计数时机。 “收到请求”在请求到达时计数;”成功访问”在 afterCompletion 中计数,并且只统计 2xx 响应。
  • 业务结果指标在事务提交后才发送,并且只在状态真正发生翻转时发送,避免回滚或并发导致多计(详见 MR 生命周期篇 3.7 节)。
  • 指标代码放在失败处理逻辑之外。 例如批次移交完成后才记录耗时,这样指标代码即使抛出异常,也不会触发批次的失败结算。
public void incrementUserCounter(String name, Map<String, String> tags) {
    try {
        Map<String, String> safe = normalizeAndTruncate(tags, 128);
        if (safe == null) { log.warn("skip metric {}: invalid tags", name); return; }
        registry.counter(name, safe).inc();
    } catch (RuntimeException e) {
        log.error("metric {} failed; business request continues", name, e);   // 指标失败不影响业务
    }
}

3.8 前端发布期间的静态资源回退

SPA 发布之后,已打开的旧页面会请求新版本中已被删除的带 hash 资源。如果不做处理,这类请求会落到全局异常处理器,返回 500。我们从服务端和客户端两侧处理:

服务端:

  • index.html 设置 Cache-Control: no-store,保证每次都拿到最新的资源清单;
  • 带 hash 的资源设置 max-age=365d, public, immutable;
  • 资源不存在时按请求类型分别响应:页面导航请求(Accept: text/html 且路径不含扩展名)返回一个回退页面,静态资源或 API 请求返回 404 JSON。两种响应都设置 no-store。

客户端:

  • 在捕获阶段监听脚本和样式的加载错误;核心资源加载失败时,带上缓存破坏参数重新加载页面;
  • 用 sessionStorage 记录上次自动重载的时间。30 秒内不再自动重载,改为显示一个”刷新页面”按钮,避免陷入重载循环。

我们还为这些响应头和状态码编写了冒烟脚本,在预发布环境验证。

4. 成果

  • 每个横切关注点的缺省行为都有明确定义,并写在代码注释和配置说明中。配置被清空或写错时,限流退化为不限流,开放类开关退化为关闭,鉴权退化为拒绝。
  • 限流和鉴权在参数绑定之前完成,不需要修改任何既有接口的返回结构。
  • 被限流的请求不会污染业务指标,它们由独立的指标统计。
  • 指标代码中的异常不影响业务请求。 指标不读取请求体,不写响应,也不参与业务分支判断。
  • 错误响应按”下一步行动”区分。 用户能区分”重复提交”“可以继续等待”和”需要联系团队”,内部细节只出现在服务端日志里。
  • 前端发布期间,旧页面能自行恢复,不再出现由缺失资源引起的 500。

5. 已知限制与后续计划

  • 限流按实例生效。 实际的全局额度约等于”单实例阈值 × 实例数”。另外,退回到地址限流时取的是 X-Forwarded-For 的第一跳,而这个值客户端可以伪造。后续计划改为取可信代理追加的那一跳,或在网关层统一限流。
  • 身份解析逻辑有三份副本,规则存在细微差异。 其中一份对邮箱形式的账号不做截断,可能导致同一个人在指标中出现两个标签值。后续计划收敛为一个共享的解析组件。
  • 异常与 HTTP 状态码的映射不一致。 部分业务异常返回 500;有一处鉴权拒绝经由通用服务异常返回 500 而不是 403;兜底处理器把异常消息原样返回给客户端。后续计划统一错误模型,兜底处理器只返回通用提示。
  • 部分外部 SDK 调用没有设置超时,也没有熔断。 外部依赖变慢时会长时间占用请求线程。后续计划为所有外部调用设置显式超时,并为关键依赖增加隔离。
  • 幂等只记录 operationId,不重放原始响应。 如果客户端没有提供 operationId,服务端生成的 ID 含有时间戳,重试请求无法去重。
  • API 指标拦截器自身没有 try/catch。 它能否做到失败不影响业务,取决于追踪和指标库本身不抛异常。另外,部分用户接口不在 API 指标的覆盖范围内。