HTTP handler中不应直接go run业务逻辑,而应校验签名、读取body、写入持久化队列后立即返回200;异步任务用Redis Stream实现ACK重试机制;结果传递优先使用channel而非回调函数。

HTTP handler 里直接 go run 业务逻辑是错的
很多开发者一看到“异步回调”,就在 http.HandlerFunc 里写 go doSomething(),以为这就完成了。实际这是危险的速食方案:goroutine 可能访问已释放的请求上下文(比如 r.Body 已被关闭)、无法控制并发数、panic 会静默丢失、进程重启时所有待执行任务彻底消失。
真正该做的是在 handler 中只做三件事:
- 校验签名(如
X-Hub-Signature-256),必须用hmac.Equal做常数时间比对,防止时序攻击 - 调用
io.ReadAll(r.Body)一次性读完原始 body,否则后续读取会失败或阻塞 - 将结构化任务数据同步写入持久化队列(如 Redis Stream 或 PostgreSQL 表),检查
redis.Client.XAdd返回值,出错则记录日志并返回400 Bad Request
做完这三步,立刻调用 w.WriteHeader(http.StatusOK),不写任何响应体。
用 Redis Stream 实现带 ACK 和重试的消费者
Redis Stream 是轻量级生产环境异步任务的事实标准,它天然支持消费者组、消息确认、超时重分配和死信归档,不需要额外部署 Kafka 或 RabbitMQ。
关键点不在“怎么起 goroutine”,而在于“怎么保证每条消息至少处理一次”:
- 消费者启动时需先确保组存在:
redis.XGROUP CREATE order_events orders $ MKSTREAM - 拉取消息必须用
XREADGROUP GROUP orders worker-01 COUNT 10 BLOCK 5000 STREAMS order_events >,其中>表示只读新消息,避免重复消费历史积压 - 业务逻辑执行成功后,再调用
XACK;提前ACK= 消息丢失 - 若处理失败(如第三方 API 超时),不要
XACK,让 Redis 在TIMEOUT(默认 60s)后通过XCLAIM重新分配给其他 worker - 为防幂等问题,建议在业务逻辑开头用
SETNX task_id_ttl 3600做去重锁,失败则直接 return
Go 里优先用 channel 而非回调函数传结果
显式传入 func(result string, err error) 类型的回调函数,只适合极简场景(如单元测试模拟、命令行工具内部链式调用)。它会让错误传播不可控、调试困难、无法跨 goroutine 安全传递上下文(比如 context.Context)。
更符合 Go 风格的做法是:
- 用
chan+struct封装结果,例如type TaskResult { Data string; Err error } - 启动 goroutine 执行任务,完成后往 channel 发送结果
- 主流程用
select处理超时与取消,比如case res := - 避免无缓冲 channel 导致 goroutine 泄漏,建议用带缓冲的
make(chan TaskResult, 1)
回调地址不可控,Webhook 接收必须防御性编程
第三方系统发来的 Webhook 不是你的 REST API,不能按常规 JSON 解析逻辑来处理。常见崩溃点包括:字段类型错、嵌套 map 混入、null 值未判空、时间格式不统一。
安全解析的关键实践:
- 永远用指针接收结构体:
var payload *GitHubPushEvent,而不是var payload GitHubPushEvent - 关键字段加
json:"field_name,omitempty",非关键字段用json.RawMessage接住 - 检查
err并区分类型:json.SyntaxError表示格式错,json.UnmarshalTypeError表示类型不匹配 - 对时间字段,优先用
time.Time+ 自定义UnmarshalJSON方法处理字符串/数字混输 - 不要在 handler 里直接调用第三方回调地址——那是下游服务的事,你的职责是可靠入队
真正的难点不在“怎么发回调”,而在“怎么确保它只发一次、发得准、发得稳”。消息中心的可靠性,80% 取决于入队前的校验和入队后的 ACK 策略,剩下 20% 才是通知通道本身。











