1.1 需求拆解:鉴权、限流与监控

场景:四十多个接口,四十多种对接方式

我们的 SaaS 后端最早只有三个对外接口:查订单、查库存、推物流单。那时候对接就是拉个群,发一份 PDF 文档,告诉客户在头里加个固定 token 就行,连限流是什么都没想过。三年过去,接口数量滚到四十多个,业务方也从小客户变成了几家月调用量上千万的大户,对接方式分化成好几种流派,问题开始集中爆发。

第一类事故是鉴权混乱。有的客户把 token 放 Header,有的放 Query 参数,还有一家图省事直接拼在 URL 路径里。我们的接口为了兼容历史,每种都做了判断,鉴权逻辑像补丁一样摞了四五层,新来的同事看一眼 auth.php 就想辞职。更麻烦的是 token 没有过期机制,客户离职三个月了他的 token 还能用。

第二类事故是无限流。那个周五晚上,客户的定时任务脚本因为时区 bug 每秒重试两百次,库存接口的数据库连接池瞬间被打满,连带同机其他业务全部超时。我们从晚上十点排查到凌晨两点,最后靠临时把客户 IP 拉黑才止住血,事后复盘发现整整二十分钟里没有任何系统告警——全靠客户在群里骂我们才知道。

还有一家大客户的做法让我们开了眼:他们把对接工作外包给第三方团队,第三方写完跑通就撤了,token 留在没人维护的脚本里,一年后还在调我们已经下线的接口。这件事促使我们把接口生命周期管理也写进了需求:下线前先在响应头里加警告,再进灰度拒绝名单,最后彻底关闭,每一步都给客户留足缓冲期。接口慢了不知道,挂了靠客户投诉,排查全靠登上服务器 grep 日志。有一次两家客户互相指责对方刷接口导致自己变慢,我们拿不出任何数据,只能各打五十大板。从那天起团队达成共识:没有度量就没有管理,网关必须把每次调用都记下来、算清楚。

痛定思痛,我们列了一张需求清单,这就是后来网关的第一份契约。清单写在白板上整整保留了一个月,每加一条就问一次:这条是为了堵哪个漏洞?回答不上来的删掉。这种克制的习惯贯穿了整个项目,让网关始终保持在薄薄一层,而不是越长越胖。

方案:把口头约定变成三张表

梳理需求时我们坚持一个原则:网关只做横切的事,不碰业务逻辑。横切需求一共三项:谁能调(鉴权)、能调多快(限流)、调得怎么样(监控)。其余如路由分组、版本管理、配额账单,都是这三项的延伸。

需求项 口头约定现状 网关目标 优先级
鉴权 各客户自定义,补丁摞补丁 统一 API Key + 签名校验 P0
限流 完全没有 按调用方分级的令牌桶 P0
监控 事后 grep 日志 CLS 全量日志 + 云监控告警 P1
版本管理 v1 与 v2 路径混用 路由表统一分发,灰度分组 P1
配额 人工记 Excel 按调用方计次,月度账单 P2

光有需求还不够,还得知道谁在调。我们把调用方也做了分级,这张表后来直接决定了限流参数怎么配。分级的核心思想是按商业价值分配算力,付费客户的桶大、试用客户的桶小、未知来源直接拒绝:

调用方类型 代表业务 鉴权方式 限流档位
内部系统 公司后台、定时任务 IP 白名单 + 内部 Key 高
付费客户 ERP 对接、小程序后端 API Key + 签名 按合同
免费试用 个人开发者 API Key 低
未知来源 扫描器、爬虫 拒绝并记录 拉黑

配置:先写接口契约,再写代码

动手前我们先用 PHP 写了一份契约文件,把每个调用方的档位固定下来。这份契约是后面所有模块的单一来源,改限流改鉴权只改这里,review 时一行对一行,杜绝口头传达:

<?php
// appkey 契约:全项目唯一来源,改动需评审
return [
    'ak_demo' => [
        'app_id'    => 1001,
        'secret'    => '******',      // 密钥只存配置中心,不入库
        'level'     => 'paid',        // paid / free / internal
        'rate'      => 500,           // 每秒令牌数
        'burst'     => 1000,          // 桶容量
        'quota_day' => 200000,        // 日配额
        'status'    => 'active',
    ],
    'ak_trial' => [
        'app_id'    => 1002,
        'secret'    => '******',
        'level'     => 'free',
        'rate'      => 20,
        'burst'     => 40,
        'quota_day' => 5000,
        'status'    => 'active',      // 欠费时置为 paused
    ],
];

契约里每一项都对应一次事故:rate 与 burst 来自那个周五晚上的重试风暴,quota_day 来自一家试用客户把我们的接口当免费数据库刷,status 则是因为某次客户密钥泄露后我们花了两个小时才在各处改完。

契约之外我们还约定了每个接口必须声明的元数据,用来生成给客户看的在线文档,也方便网关自动生成监控大盘:

<?php
// 接口元数据:网关自动渲染文档与监控面板
$spec = [
    'path'    => '/v2/inventory/query',
    'method'  => 'POST',
    'auth'    => 'sign',          // sign / key / none
    'cache'   => 30,              // 缓存秒数,0 为不缓存
    'timeout' => 3000,            // 后端超时(毫秒)
    'desc'    => '查询库存余量',
];

元数据上线后有个意外收获:产品同学自己就能在文档站上加接口说明,再也不用求后端改 README,文档过期的投诉降了九成。

需求验收:什么叫做完了

需求拆解最怕写完就忘,我们给三项核心需求各定了一条验收线,达不到就不算完事,后来这三条线成了整个项目的及格线:

需求 验收标准 验证方式
鉴权 任何无 Key 请求 5 分钟内被拦截并告警 每周模拟攻击演练
限流 超档位调用秒级劝退,后端零感知 压测脚本灌超量流量
监控 接口错误率超阈值 1 分钟内告警到值班人 人为注入故障

踩坑:URL 里传密钥的代价

最早图省事,我们允许客户把 token 放在 Query 参数里,文档里甚至还给了示例。代价在两个月后兑现:客户把带 token 的链接贴到了自家监控面板的回调地址里,密钥直接出现在了他们的访问日志和我们的 Nginx access log 里,又被第三方扫描器批量扫到。我们不得不全员加班轮换全部密钥,挨个通知客户重新对接,被骂得很惨。

自此立下三条规矩:密钥只走自定义 Header(如 X-Api-Key),传输全程 HTTPS,任何 Query 参数里出现密钥的请求一律拒绝。另外别用 Base64 之类编码糊弄自己,编码不是加密,浏览器历史、网关日志、CDN 日志都会留下它。第二个月我们在网关里加了扫描规则,只要在 Query 里看到疑似密钥格式的字符串就告警,到现在已经抓到三次客户违规操作。

还有一个早期没考虑到的点:token 轮换机制。密钥泄露后最痛的不是发现,是轮换慢。有了契约文件后,轮换变成改一行配置再灰度生效,十分钟搞定。再后来我们给 token 加了过期与轮换提醒:契约里 status 字段配合过期时间,到期前两周文档站自动给客户飘黄条提示,把历史遗留的永不过期 token 分批清理了一遍。

顺带说一个管理层面的教训。需求清单初稿是我一个人在小黑屋里写的,拿给运维看时被怼回来——我漏掉了最关键的一条:网关自己挂了怎么办。运维的同学加上了网关存活探活与降级开关两项需求,这才有了后面的熔断与高可用篇。需求拆解务必要拉上运维、客服、客户成功一起过一遍,每个角色看到的面都不一样,一个人闭门造车必然漏项。

小结

需求拆解的核心是把口头约定写下来:三张表(需求表、调用方表、契约文件)让后面所有设计有据可依。网关只做横切的事——鉴权、限流、监控,业务逻辑一行不往里塞。密钥必须走 Header 加 HTTPS,这是用一次事故换来的铁律。需求阶段多花一周写清楚,开发阶段能省三周返工。需求文档不需要文采,需要的是每一条都能回答三个问题:谁会受影响、做不到会怎样、做到了怎么验证。

最后送一个可视化技巧:把三张表贴在 wiki 首页固定区,任何新需求先去对表,表外需求必须评审才能加。这个小机制让网关在接下来的两年里只长了二十个字段,没长出一个多余的模块。

延伸阅读