4.2 数据导出:API、CSV 与 webhook

场景:交付方式各自为政

清洗跑通之后,业务方拿数据的方式还是三种并存的:早的时候我们手动导 CSV 发邮件,后来写了几个定时脚本把结果推到对方邮箱,再后来有些系统要直接对接。交付方式散落在各处,每周都有人来问同一个问题:这批数据怎么又没收到?

复盘下来发现问题的核心不是数据质量,而是交付没有当成一个产品来做。邮件附件丢过、脚本错过了时间没人发现、对接的系统各自要不同格式。这些都是小问题,但加起来每周吃掉团队大半天时间。

更意外的压力来自老板:他想要一份每周自动更新的数据摘要,最好打开浏览器就能看。这让我们意识到导出不能只服务技术人员,必须覆盖从机器到人到系统的三类消费者。

于是我们规划了三条标准出口:API 给系统对接、CSV 给人下载与表格工具、webhook 给下游订阅。三条出口共用同一份清洗结果,差别只在包装方式。定下三条出口之后还有一件事很重要:把每条出口的文档写字。API 要有接口文档与示例请求,CSV 要有列说明与编码声明,webhook 要有事件体结构以及重试与幂等约定。文档化是导出层从临时活计变成长期资产的分水岭,业务方拿到文档之后自助率明显上升,每周来找我们问数据的人少了一半。

方案:三条出口的分工

三条出口对应的消费场景不同,设计取向也不同:

出口 消费者 触发方式 设计要点
API 业务系统 按需拉取 分页、鉴权、缓存
CSV 人、表格工具 手动或定时 编码、转义、可下载
webhook 下游服务 事件推送 重试、顺序、幂等

API 是最重的一条出口,因为它面对的是各种各样的系统。鉴权用签名密钥,每个调用方一个 key,既能区分用量也能一键吊销。查询参数统一支持站点、时间范围、分页,返回 JSON。为了避免重复查询打穿 MySQL,API 层加了 Redis 缓存,热数据五秒内复用。

CSV 看起来简单,坑却最多。我们的 CSV 导出做了两个形态:控制台手动下载(任意时间范围、任意站点),以及定时任务生成后上传到 COS 的导出桶,业务方拿链接自取。后者解决了大文件邮件塞不下的问题。

webhook 是最贴合实时场景的出口。清洗出一批结果就推一批,订阅方按站点订阅。推送失败按指数退避重试三次,超过次数进入死信队列并在控制台标红。这里的关键设计是推送体里带事件 ID,下游可以靠它去重——我们只保证至少送达一次,不保证恰好一次,系统设计的诚实表述比花哨承诺可靠。

配置与代码:API 分页与 webhook 推送

API 的分页与缓存:

func (h *Handler) listItems(w http.ResponseWriter, r *http.Request) {
    siteID := r.URL.Query().Get("site_id")
    page, _ := strconv.Atoi(r.URL.Query().Get("page"))
    key := fmt.Sprintf("api:items:%s:%d", siteID, page)
    if cached, err := h.rdb.Get(ctx, key).Bytes(); err == nil {
        w.Write(cached)
        return
    }
    items, total, err := h.store.List(ctx, siteID, page, 50)
    if err != nil {
        http.Error(w, err.Error(), 500)
        return
    }
    body, _ := json.Marshal(map[string]any{
        "data": items, "total": total, "page": page,
    })
    h.rdb.Set(ctx, key, body, 5*time.Second)
    w.Write(body)
}

webhook 推送的核心是重试与幂等标记:

func (p *Pusher) send(ctx context.Context, sub *Sub, ev *Event) error {
    body, _ := json.Marshal(ev)
    for attempt := 0; attempt < 3; attempt++ {
        resp, err := p.client.Post(sub.URL, "application/json", bytes.NewReader(body))
        if err == nil && resp.StatusCode < 300 {
            resp.Body.Close()
            return nil
        }
        if resp != nil {
            resp.Body.Close()
        }
        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(time.Duration(1<<attempt) * time.Second):
        }
    }
    return p.deadLetter(ctx, sub, ev)
}

每个事件带唯一事件 ID 与快照键,下游可凭事件 ID 去重、凭快照键回查原始内容。这两个字段是导出与采集之间保持可追溯的锚点,所有出口都统一携带。

顺带分享一个出口设计的通用原则:出口的数据形状应该由消费者的使用场景决定,而不是由采集的存储结构决定。最初我们的 API 响应几乎就是结果表的直接映射,连内部主键都暴露出去了,业务方看得一头雾水。重做时按使用场景重新设计响应体:列表页给摘要、详情页给全文、统计页给聚合,三套视图共用底层数据但各自贴合消费姿势。在这一层做适配,比让每个消费端各自理解我们的存储模型要合理得多。

踩坑:导出层的五个坑

第一个坑是 CSV 的编码。业务方用表格软件打开,中文乱码。原因是表格软件默认按 GBK 解码 UTF-8 文件。解法:UTF-8 文件开头加 BOM,表格软件就能正确识别。一个字节解决的问题,排查花了一个下午。

第二个坑是大 CSV 的生成方式。最初一次性查全量再拼接,挖过百万行直接内存爆掉。改成流式写法:查询用游标分批、写入直接用 csv.Writer 落到缓冲文件,内存占用恒定在几兆。

第三个坑是 webhook 推送顺序。早期按结果产生的先后推送,但不同 worker 之间没有全局顺序,到达时间偶尔颠倒。下游做时间序列分析时数据错位。解法:推送体带采集时间戳并按站点分频道串行推送,同站点内保证有序,跨站点并发。完全的全局有序成本过高,业务场景也不需要。

第四个坑是 API 被当数据库使。某业务系统写了个循环翻页拉全量,每秒几十次请求。加了限流之后该系统转而提工单要批量导出。这说明 API 与批量导出的边界要主动规划,不然总会有人用错工具。后来我们明确:实时按页查询走 API,全量快照走 COS 导出桶,各走各的路。这种取舍背后的逻辑是每一种出口都有它最自然的消费姿势:API 的姿势是查询参数与分页游标,CSV 的姿势是文件下载链接,webhook 的姿势是事件订阅,混用任何一种都会平白制造返工。

第五个坑是导出内容的脱敏。某次给第三方系统对接,测试环境直接用了真实数据,被安全团队指出来。导出层加了一道配置:导给外部系统的结果默认走脱敏规则(手机号、邮箱、身份证类字段打码),需要明文走单独审批通道。脱敏规则清单见 5.4 节护栏一节。

小结

导出层的心法是把消费者分群:系统要 API,人要 CSV,下游要 webhook。三种形态共用一份清洗结果,用缓存、分页、重试、脱敏这四个组件打底。导出设计里最诚实的一条经验是放弃恰好一次送达的幻想,用事件 ID 把幂等交给下游,系统的复杂度反而下来了。下一节把任务管理搬上浏览器,做控制台,继续看 4.3。

再补充一个交付细节:事件 ID 的生成规则。我们用的是站点编号加快照键加清洗规则版本号的组合,而不是随机字符串。这样的 ID 天然编码了来源与版本,下游去重时捎带就能判断这条数据出自哪个版本的规则;一旦发现某版本规则产出有误,按 ID 前缀批量删除比任何条件查询都快。标识符的设计回报往往不在写入时,在出事时。同一条经验也适用于导出文件名:带日期与范围,而不是只写 export.csv,下载者一眼就能对账。文件名规范定下之后还有个配套小动作:导出完成的同时生成一份清单文件,记录行数、起止主键与生成时间,与数据文件放在同一个目录。业务方对账时先看清单再决定要不要下载,几次上百 MB 的无效下载就这么省下了。交付的完整度,往往就差这一个随手的伴随文件。

延伸阅读