"为避免抓包,理论上还是所有接口都用 POST 吧。"——这句话我在设计秒杀接口时认真纠结过。直觉上 POST 把参数藏进了 body,好像比 GET 安全;但真去翻抓包工具的输出,会发现 Wireshark 抓 HTTP 明文时,URL 和 body 是一个 TCP 流里的前后两段,POST 一点便宜都占不到。

这个误区如果不澄清,接口设计会跑偏成"全 POST"——然后为这个错误的理由付出真实的代价:读接口失去缓存能力、日志里看不到请求参数排障变难、REST 语义崩坏。本文把秒杀系统的接口清单、GET/POST 的取舍逻辑、以及"防抓包"真正的解法一次性讲透。

本文是「秒杀系统(C++ / Drogon)」系列的接口设计篇。配套仓库 seckill-cpp(GitHub: https://github.com/Hespethorn/seckill-cpp),当前 v0.1.x 已落地 /api/health/api/seckill 两个接口,路由挂在 src/main.cc

一、接口设计:是什么、坑在哪、本质一句话

  • 是什么:秒杀系统的接口设计,本质是把"读操作"和"写操作"梳理清楚,让幂等的读非幂等的写各走各的路——这决定了缓存、限流、日志三个维度的策略能否生效。
  • 坑在哪:两个高频误区。一是**"全用 POST 防抓包"——POST 防不了主动抓包,只防被动泄露(历史记录、日志、Referer),把安全期望寄托在错误的手段上;二是读接口滥用 GET 的参数设计**——把 userId 塞进 URL 当查询参数,虽然方便但会在日志里裸奔,且失去"参数不进日志"的红利。
  • 本质一句话接口方法的选择标准是"读/写语义 + 幂等性",安全靠 HTTPS、签名、Token、一次性令牌,而不是靠 POST 这层窗户纸

二、秒杀系统接口清单(当前 + 规划)

先看阶段一已经落地的两个接口,再对照规划的完整清单:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 当前 v0.1.x(已实现)
GET /api/health 健康检查(无业务参数)
POST /api/seckill 秒杀下单(body: {"userId","skuId"})

# 规划中的接口(阶段一 ~ 阶段四)
GET /api/sku/list 秒杀商品列表
GET /api/sku/{id} 秒杀商品详情
POST /api/auth/register 用户注册
POST /api/auth/login 用户登录
POST /api/auth/sms 短信验证码
POST /api/seckill 秒杀下单
GET /api/order/{id} 秒杀结果查询
GET /api/order/result 异步下单后查询结果

一眼看过去,读接口全是 GET、写接口全是 POST——这不是巧合,是下面这套规则推出来的。

三、GET vs POST:判断标准是语义,不是安全

接口方法决策树:读用 GET,写用 POST 接到一个接口需求 读操作 / 幂等 / 可缓存 → GET 列表 / 详情 / 查询 可进 CDN / 浏览器 / Redis 缓存 参数可选:路径 / 查询串 写操作 / 非幂等 / 敏感 → POST 下单 / 登录 / 注册 / 验证码 参数放 body,不进 URL 日志 / 历史 / Referer 不泄露参数 安全不在这里决策——防抓包靠 HTTPS + 签名 + Token + 一次性令牌(见第五节)

判断一个接口用 GET 还是 POST,只需要回答两个问题:

  1. 它会改数据吗?(写 → POST;读 → 继续下一步)
  2. 它是幂等、可缓存的吗?(是 → GET;否 → POST)

秒杀下单为什么必须是 POST?因为它改库存、建订单、非幂等(同一请求发两次会产生两条副作用,全靠 uk_user_sku 兜底)。登录为什么 POST?因为它创建会话。商品列表为什么 GET?因为它只读且幂等,GET 让 CDN、浏览器、Redis 全部可以参与缓存。

四、POST 防的不是抓包,是泄露

把"防抓包"这层窗户纸捅破——POST 和 GET 在主动抓包面前是平等的

泄露途径 GET(参数在 URL) POST(参数在 body)
浏览器历史记录 ❌ 完整暴露 ✅ 不记录
服务器访问日志 ❌ URL 全量入日志 ✅ body 通常不进日志
反向代理 / 网关日志 ❌ 暴露 ✅ 不暴露
Referer 头(跳转外站) ❌ 带参数泄露 ✅ 只带 URL 不带 body
书签 / 分享链接 ❌ 参数跟着走 ✅ 无法通过链接传递
主动抓包(Wireshark / Fiddler / Charles) ❌ 明文全抓 照样全抓

最后一行才是"抓包"的字面意思,而这一行 POST 毫无优势——抓包抓的是整个 TCP 流,URL 和 body 只是流里先后两段。所以:

POST 防的是"被动泄露"(日志、历史、Referer),防不了"主动抓包"。

五、防抓包/防重放:真正的四件套

攻击者抓到包之后,真正危险的是重放——拿到一个成功的秒杀请求,脚本反复发。这需要一套和 HTTP 方法无关的防护:

手段 防什么 实现要点
HTTPS(TLS) 被动抓包看明文 Wireshark 只能看到密文;中间人需客户端信任其证书
签名(timestamp + nonce + HMAC) 篡改 + 重放 客户端对参数做 HMAC,服务端校验签名 + 时间窗(如 ±5min)+ nonce 去重
Token 鉴权 越权调用 登录后发 Token,请求头 Authorization: Bearer <token>,服务端校验
一次性秒杀令牌 脚本刷单 秒杀前先领令牌(限量),下单时校验,用完即焚

签名流程示意:

1
2
3
4
5
6
7
8
客户端: params + timestamp + nonce
→ HMAC-SHA256(secret, params+timestamp+nonce) = sign
→ 请求体: {params, timestamp, nonce, sign}

服务端: 1. |now - timestamp| > 300s → 拒绝(时间窗)
2. nonce 已用过 → 拒绝(防重放,Redis SETNX + TTL)
3. HMAC 重新计算 ≠ sign → 拒绝(防篡改)
4. 通过 → 进入业务逻辑

这套东西在阶段四(规划 8.x:防刷、限流、秒杀资格校验)会是主角,到时候 GET 接口同样要带签名。安全从来不依赖 GET/POST 的选择。

六、阶段一接口落地:当前怎么写的

src/main.cc 里的路由注册,已经按"读 GET / 写 POST"划分:

1
2
3
4
5
6
7
8
9
10
11
// 健康检查:只读,GET
// (HealthController 是无依赖的 HttpController,由 Drogon 自动注册 /api/health)

// 秒杀下单:写 + 敏感,POST
drogon::app().registerHandler(
"/api/seckill",
[](const drogon::HttpRequestPtr &req,
std::function<void(const drogon::HttpResponsePtr &)> &&callback) {
getSeckillController()->seckill(req, std::move(callback)); // run() 后取客户端
},
{drogon::Post}); // ← 明确限定 POST

秒杀下单的 body 协议(SeckillController.cc):

1
2
3
4
5
6
7
8
9
10
11
12
// 请求: POST /api/seckill
// body : {"userId": <int64>, "skuId": <int64>}
const auto &json = req->getJsonObject();
if (!json || !json->isMember("userId") || !json->isMember("skuId")) {
// 400: missing or invalid userId/skuId
}

// 响应统一 JSON 协议
// 200 {"code":0,"msg":"success"} 成功
// 409 {"code":1,"msg":"SOLD_OUT"} 售罄(业务拒绝,不重试)
// 409 {"code":1,"msg":"DUPLICATE_ORDER"} 重复下单(业务拒绝,不重试)
// 500 {"code":1,"msg":"DB_ERROR: ..."} 系统错误(值得重试)

关键设计点:userId / skuId 放在 body 而不是 URL——这既符合 POST 语义,也顺便获得了"日志不记录参数"的被动泄露防护。但注意,这不是安全措施,阶段四给下单接口加签名 + 一次性令牌时,body 里还要追加 timestamp/nonce/sign

七、可运行验证步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 启动服务(WSL)
cd /mnt/d/GitHub/seckill-cpp && ./build/src/seckill-cpp

# 2. 健康检查(GET)
curl -s http://127.0.0.1:8080/api/health
# 期望: {"code":0,"msg":"ok"} 之类

# 3. 秒杀下单(POST,参数在 body)
curl -s -X POST http://127.0.0.1:8080/api/seckill \
-H 'Content-Type: application/json' \
-d '{"userId":1001,"skuId":1}'
# 期望: {"code":0,"msg":"success"}

# 4. 验证 GET 对写接口无效(方法不允许)
curl -s http://127.0.0.1:8080/api/seckill
# 期望: 405 Method Not Allowed(Drogon 对未注册方法返回 405)

# 5. 验证 POST 对读接口无效
curl -s -X POST http://127.0.0.1:8080/api/health
# 期望: 405

第 4、5 步是"方法即契约"的直接体现——接口声明了 POST,GET 就不该能调到它,这也是防"误用接口"的第一道防线。

八、设计取舍总结

决策点 选定方案 放弃方案 放弃的代价
读接口方法 GET 全 POST 无——GET 白拿缓存/日志/语义
写接口方法 POST 全 GET 无——body 藏参数 + 非幂等语义正确
敏感参数位置 body URL 查询串 排障时得看请求体(但日志也更干净)
防抓包 HTTPS + 签名 + Token + 一次性令牌 靠 POST 需实现签名/令牌(阶段四做)
方法越权 Drogon 自动 405 应用层手动判断 无(框架白送)

一句话收尾:接口方法是语义决策,不是安全决策——读用 GET 拿缓存红利,写用 POST 藏好参数,防抓包交给 HTTPS 和签名这套"真家伙"。把"全用 POST 防抓包"这个念头放下,接口设计才算真正想明白了。

配套仓库:https://github.com/Hespethorn/seckill-cpp(当前接口见 src/main.cc 路由与 src/controllers/SeckillController.cc 协议转换)。本系列是作者个人的 C++ 秒杀系统实战记录,所有方案、代码与压测数据均为原创。