批量操作

一个电商客户的请求

那天下午,我收到了一封加急邮件。

Inbox
From:电商技术负责人
To:我
Time:周二 14:20
Subject: 【紧急】双十一促销需要批量短链接

你好,我是某电商的技术负责人。我们马上要做双十一大促,有 5000 个产品链接需要缩短,用于短信营销。每条短信按字符计费,长链接太贵了。

能不能今天搞定?我们明天就要开始发短信了。

5000 个链接。我的 API 一次只能处理一个。


第一版:同步批量接口

最快的方案

客户催得紧,我决定先出一个最快的方案。

落地思路

  • 接口接收一个链接数组,逐条创建短链接,每条都走一遍正常的创建流程:生成短码、写 urls、返回短码。
  • 短码要逐个分配,不能批量预先取号:批量取号会占着一段 ID 不释放,失败时那些 ID 就浪费了。
  • 一批内某条失败不能中断整批,失败的条目要单独收集,最后一起返回给调用方。
  • 兜底是整批加一个数量上限:一次最多接受多少条,超出的直接拒绝,避免一个请求把连接池和事务日志撑爆。

问题很快暴露

客户分 5 批提交了 5000 个链接。每批 1000 个,平均耗时 15 秒。

“15 秒?太慢了!我们点提交后页面一直在转圈,还以为挂了。”

我查了一下瓶颈:1000 次数据库 INSERT,每次约 15ms,总计 15 秒。


优化:事务批量插入

把 1000 次提交合并为 1 次

“原来每次 INSERT 都是一次磁盘写入。如果把 1000 条打包成一次事务呢?”

落地思路

  • 把一批链接塞进同一个事务:先全部走一遍校验(URL 合法、短码不重复),再统一 INSERT,最后一起提交。
  • 校验和写入必须都在事务里完成,否则中途失败会留下「校验过但没写进去」和「写了一半」的中间状态。
  • INSERT 用多值批量语法一次提交,1000 条只产生一次磁盘写入——这就是 15 秒降到 1.5 秒的原因。
  • 兜底是事务回滚:任何一条不合法就整体回滚并告诉调用方是哪一条,不留半截数据。

效果

方案1000 条耗时原因
逐条 INSERT15 秒1000 次磁盘写入
事务批量1.5 秒1 次磁盘写入

“10 倍提升,客户满意了。”


CSV 导入

客户的第二个需求

“能不能支持 CSV 文件导入?我们运营团队只会用 Excel。”

落地思路

  • 上传的是 CSV 文件,先整文件读进来解析,按行取出必需列 url 和可选列 title、tag。
  • 表头要校验:必需列 url 缺失就直接报错,并把期望的列名列出来,不要逐行报错让用户自己猜。
  • 解析出来的每一行都要过 URL 校验,非法行不中断整批,记下行号和原因放进失败清单。
  • 兜底是文件大小和行数上限:超限直接拒绝并提示改用异步接口,别让一次上传把服务压垮。

CSV 格式说明

我写了个简单的文档给客户:

# CSV 文件格式

## 必需列
- url(或 URL、链接)

## 可选列
- title(链接标题)
- tag(标签,用于分组管理)

## 示例
url,title,tag
https://example.com/product/1,iPhone 15,手机
https://example.com/product/2,MacBook Pro,电脑
https://example.com/product/3,AirPods,耳机

更大的客户来了

新问题:10 万条链接

一个月后,一个更大的客户找上门。

“我们有 10 万条 链接需要缩短,用于全渠道营销推广。”

10 万条。即使事务优化后,也需要 150 秒。HTTP 请求早就超时了。

“我意识到,同步方案的天花板到了。”


异步处理:Celery 任务队列

架构改造

“我花了一个周末引入了 Celery 异步任务队列。”

┌──────────┐     ┌──────────┐     ┌──────────┐
│  客户端   │────▶│  API 服务 │────▶│  Redis   │  ← 任务队列
└──────────┘     └──────────┘     └──────────┘
                                       │
                                       ▼
                                 ┌──────────┐
                                 │  Worker  │  ← 后台处理
                                 └──────────┘
                                       │
                                       ▼
                                 ┌──────────┐
                                 │  MySQL   │
                                 └──────────┘

提交任务

落地思路

  • 接口不直接处理数据:把链接清单写进任务记录,生成一个任务 ID 立刻返回,客户端拿这个 ID 去查进度。
  • 任务记录里要存下原始清单和创建者,异步 Worker 后面只靠任务 ID 就能把活干完,不用再回调接口。
  • 提交成功不等于创建成功,最终结果要通过进度查询接口拿,这两件事必须分清楚。
  • 兜底是任务本身也要有状态:待处理 / 处理中 / 已完成 / 部分失败,提交完成后状态就是待处理。

Worker 处理

落地思路

  • Worker 从 Celery 队列里取出任务,按批次读出链接清单,逐条走创建流程写进 urls。
  • 每处理一条就更新一次进度(已处理数 / 总数),进度是 Worker 写的,不是查询接口现算的。
  • 失败的条目不能中断整个任务:收集失败原因继续下一条,任务最终状态是「部分失败」并带上失败清单。
  • 兜底是任务重试:Worker 中途挂掉时任务重新入队,已处理的部分靠去重跳过,不会重复创建。

效果

方案10 万条耗时客户体验
同步接口150 秒(超时)❌ 页面卡死
异步任务~30 秒(后台)✅ 秒级响应,后台处理

进度追踪

查询接口

“客户提交后总想知道进度。我加了一个查询接口。”

落地思路

  • 接口按任务 ID 返回状态、已处理数、总数、成功数、失败数和最近一条错误原因。
  • 进度要从任务记录里直接读出来,不要每次查询都去数一遍数据库,那是把查询变成全量扫描。
  • 状态是终态(已完成 / 部分失败 / 失败)时直接返回最终结果,并附上结果文件的下载入口。
  • 兜底是任务记录本身要能查到:任务 ID 丢了也要能按创建者和时间范围找回任务。

客户端轮询

交互要点

  • 前端逻辑重点是展示任务状态、错误原因和下一步操作。
  • 轮询或进度刷新要有节制,避免给后端制造新的压力。

批量删除和更新

批量删除

“后来客户又提出:大促结束了,那些活动链接能不能批量删除?”

落地思路

  • 删除走软删除:把这一批短链接的状态改成已删除,而不是物理 DELETE,这样误删还能恢复。
  • 批量操作要放在一个事务里:要么这批全部标记成功,要么全部回滚,不能删一半。
  • 涉及大量记录时按 ID 范围分段提交,避免一条 UPDATE 长时间锁住太多行。
  • 兜底是删除要留痕:谁在什么时候删的哪一批、删的是哪些短码,都要有记录。

批量更新

落地思路

  • 批量更新支持改标题、标签、目标 URL 这几类字段,先校验每条的更新内容是否合法,再统一落库。
  • 改目标 URL 的条目要额外失效缓存:数据库改了但 Redis 里还是旧值,用户会继续跳到旧地址。
  • 部分失败要按条记录:哪些成功了、哪些失败了、失败原因是什么,最后一起返回给调用方。
  • 兜底是更新和删除一样走软标记,真正的物理清理交给定时任务,不在用户请求里做。

成本分析

“引入 Celery 后,我算了一笔账。”

组件月成本说明
额外 Redis 实例¥40/月Celery broker
Worker 服务器¥60/月2 核 4G
合计¥100/月—

“多花了 ¥100/月,但换来了企业客户的满意度。这个投入值不值?看收入就知道了——批量功能上线后,企业客户付费转化提升了 30%。”

发现内容有问题?

告诉我们哪里需要改进。