Node 后端实战 · D1 那些坑:100 参数上限逼出的批量写入重构

Node 后端实战 · D1 那些坑:100 参数上限逼出的批量写入重构

各位看官,这一篇我不打算按"踩坑清单"的写法来罗列 D1 的毛病。原因很简单:清单看着热闹,但读完记不住。我更想讲一个真实的场景------我那个用 Cloudflare D1 扛多租户 SaaS 后端的项目里,有一张业务主表(leads)整整 27 列,当我第一次要往里批量导数据的时候,D1 的三类硬限制被这张宽表一次性全逼了出来:参数上限、没有真正的事务、取数方法返回结构不一致。下面说的每一个坑,都是那次重构里实打实撞上的。

先说清楚背景,免得有人说我"没见过世面非要黑 SQLite"。D1 是 Cloudflare 边缘网络上的 SQLite,挂在 Workers 运行时里跑。它和你在服务器上 apt install sqlite3 后本地用的那个,底层确实都是 SQLite,但运行模型完全不同 :你写的代码在 V8 isolate 里跑,每一次数据库访问都是一次对边缘节点的远程子请求,不是进程内函数调用。这个运行模型,就是后面所有坑的总根。

一、100 参数墙:批量导入时撞上的硬上限

故事从批量导入开始。前端把解析好的数组直接 POST 给后端,后端拼成多条 INSERT 一次性写库。代码大概是这样:

ts 复制代码
// 直觉写法:一条 INSERT 塞一堆行
await db.insert(leads).values(rows); // rows 是 N 条对象

rows 一多,D1 直接甩脸:

复制代码
D1_ERROR: too many SQL variables (max 100)

我第一反应是"SQLite 不是默认上限 999 吗,怎么才 100"。查下来才明白:D1 单条 SQL 的绑定参数上限就是 100 ,不是 SQLite 桌面版的 999。而且这里是"绑定参数",不是"行数"------INSERT INTO leads (c1..c27) VALUES (?,..?),(?,..?) 里每个 ? 都算一个参数。

leads 有 27 列,那一条 INSERT 能塞几行就由这个公式决定:

列数(LEAD_COLS) 单条上限(MAX_PARAMS,留 10 余量取 90) 每行参数 单条最多行数
27 90 27 floor(90/27) = 3
15 90 15 floor(90/15) = 6
10 90 10 floor(90/10) = 9
5 90 5 floor(90/5) = 18

也就是说,一张 27 列的宽表,一条 INSERT 最多只能 3 行 。如果一次导入 500 条,就得拆成 167 条 INSERT 语句。于是那段直觉代码变成了:

ts 复制代码
const MAX_PARAMS = 90;          // D1 限制 100,留 10 余量
const LEAD_COLS = 27;           // leads 表实际列数
const rowsPerStmt = Math.max(1, Math.floor(MAX_PARAMS / LEAD_COLS)); // = 3

for (let i = 0; i < rows.length; i += rowsPerStmt) {
  const chunk = rows.slice(i, i + rowsPerStmt);
  await db.insert(leads).values(chunk);
}

这里有个容易忽略的点:这个上限是按"变量数"算,不是按"行数"算 。如果你的表只有 5 列,单条能塞 18 行;表宽了,能塞的行数就指数级塌缩。所以分批大小不能用一个写死的常量,得跟着表结构走。我们后来把 rowsPerStmt 抽成了按目标表列数动态计算的函数,谁加列谁自动生效,不用回头改批处理逻辑。

顺带提一句这个 100 参数墙不只坑写入。凡是"一个值对应一个 ?"的写法都得算账:批量 UPDATE ... WHERE id IN (?, ?, ...) 一次塞 100 个 id 就到顶,得把步长也压在 90 以内用游标分批;聚合里 json_each 展开、动态 WHERE 拼多条件也是同理。换句话说,任何产生 ? 的地方,都得在心里过一遍"这里会不会超过 100"。它不像桌面 SQLite 那样给你 999 的余地,100 这个数字在边缘编译参数下是钉死的,别指望哪天能调大。

二、分批了,原子性怎么办:db.batch 模拟事务 + 零物理外键

分成一百多条 INSERT 之后,新问题来了:这一百多条里任何一条失败,前面的已经写进去了,怎么办? 在普通 PostgreSQL 里你会包一个事务 BEGIN/COMMIT,但 D1 这里有个根本性的限制------

D1 不支持跨语句的真正事务。更准确地说,你在 Workers 里每次 db.run/insert/update 的调用,底层是独立的子请求,而 SQLite 的 PRAGMA foreign_keys=ON 只在同一个连接内生效,跨连接它根本不认。所以外键约束在 D1 里是声明了也不靠谱的 ,这也是为什么我在 schema 设计阶段就决定:零物理外键,引用完整性全部交给应用层。

那"一批写入要么全成要么全败"怎么办?D1 给的替代是 db.batch()------它把一批语句打包成一个原子单元提交,里面任何一条失败,整批都不写入(D1 保证 batch 的原子性)。我那个"一次请求原子建通话记录 + 跟进 + 更新线索状态"的复合端点,就是这样写的:

ts 复制代码
await db.batch([
  db.insert(callRecords).values({ id, leadId, externalCallId, ... }),
  db.insert(leadFollowups).values({ id, leadId, content, ... }), // content 非空才加这一条
  db.update(leads).set({ status: 'following' }).where(eq(leads.id, leadId)),
]);

db.batch 有两个坑是文档不显眼、踩了才知道的:

坑 1:batch 内的语句读不到前序结果。 你在 batch 里第一条 SELECT 出来的东西,第二条语句是读不到的------因为它是"包好一起发",不是"逐条串行"。所以任何"先查再写"的校验,必须在 batch 外面、之前 做完。我那个并发分配防护就是这样:先独立 SELECT 复核线索当前 owner_id 和状态,校验通过,再在 batch 里写归属 + 流水。把复核塞进 batch 里就会读到脏的快照。

坑 2:batch 不能无限大。 D1 有隐性的约 30 秒单批超时,加上 Worker 单次调用最多 1000 个子请求,所以单批语句/行数必须压在 1000 以内。我把导入、分配、对账的上限都卡死,超量的拆成多段独立 batch 顺序提交:

操作 单批上限 超量处理 备注
批量导入线索 ≤ 500 条/次 前端切多个 ≤500 调用 500 行 ≈ 167 条 INSERT,远低于 1000 子请求
批量分配线索 ≤ 100 条/次 前端切分多次 事务轻量,100 为舒适上限
禁拨对账 ≤ 1000 条/批 created_at 游标循环至扫完 每日 Cron 跑,不堵主链路
单 batch 语句/行 ≤ 1000 拆成多段顺序提交 超 30s 隐性超时整批回滚

零物理外键 + db.batch 模拟事务,这套组合是我这个 D1 后端能跑起来的底座。它不优雅,但边界清晰:凡是涉及多表一致性的写操作,要么进 batch,要么在 batch 外先做存在性校验,绝不依赖数据库层的约束兜底。

三、取数也藏雷:run / get / all 返回结构不一样

如果说参数上限和事务是"写"的坑,那"读"也有一个我记一辈子的坑。有段时间,列表接口的关键字搜索 total 永远返回 0,但列表数据本身是对的------用户能搜出结果,分页却显示"共 0 条"。

排查下来,是 Drizzle 在 D1 上的三个原生方法返回结构根本不同:

方法 返回结构 取数方式 误用后果
db.run(sql) D1Result { success, results: [...] } .results[0] 直接当下标取会拿到 undefined
db.all(sql) 行数组 unknown[] 直接当数组用 单值查询还要取 [0] 多此一举
db.get(sql) 首行对象 {...} / 无结果 undefined 直接解构成对象 run 去取下标恒 undefined

原来的列表代码是这么写的:

ts 复制代码
// 错误写法:run 返回的不是行数组
const r = await db.run(sql`SELECT count(*) as n FROM leads WHERE ...`);
const total = r.results[0]?.n; // 实际 r 是 D1Result,r.results 才有行

r.results 这一步在有些 Drizzle 版本里是存在的,但取数约定和我以为的不一样,导致 total 恒为 undefined。改成 db.get 取首行对象就对了:

ts 复制代码
// 正确写法:get 返回首行对象
const { n } = await db.get<{ n: number }>(
  sql`SELECT count(*) as n FROM leads WHERE ...`
);
// 多行用 all
const rows = await db.all<Lead>(sql`SELECT * FROM leads LIMIT 10`);

这个坑教会我一件事:凡是用 Drizzle 原生 sql 模板取数,先想清楚你要"一行 / 多行 / 执行结果"中的哪一种,再选 get / all / run,别拿到手就当下标用。CSDN 上搜"Drizzle D1 count 返回 undefined"的人不少,说明这不是我一个人的错觉。

四、本地调试的幽灵:workerd 端口占用

前面三节都是线上运行模型的坑,最后一个坑发生在本地------但它专坑 D1 的本地开发,所以放这节。

wrangler dev --port 8787 退出后,我再起一个同端口的 dev,发现:代码改了,请求响应却是旧的 。排查半天才定位------真正跑你代码的不是 wrangler 进程,而是它 fork 出来的 workerd 子进程 ,进程名里不带 "wrangler"。wrangler dev 的退出信号没把 workerd 带走,旧 workerd 还占着 8787 端口响应请求,新进程绑定失败、默默当备胎。

pkill -f wrangler 杀不掉它,得连子进程一起清:

bash 复制代码
pkill -9 -f workerd   # 重点:杀 workerd,不是 wrangler
pkill -9 -f wrangler
sleep 2
# 再起服务

我们本地冒烟脚本开头现在都固定先 pkill -9 -f workerd; pkill -9 -f wrangler; sleep 2 再起服,再没出现过"改动不生效"的灵异事件。这条经验看着小,但一个新人在这上面能卡一下午。

五、容量与子请求预算:分批时别忘了算第二本账

讲完四个坑,再补一个容易被忽略的维度------分批除了算"参数数",还得算"子请求数"。D1 单库上限 10 GB、单表行数不限,但 Worker 单次调用最多 1000 个读子请求 (付费套餐)。一次批量导入 500 条 leads,拆成 167 条 INSERT,就是 167 个子请求;再算上每条 INSERT 触发的索引维护、审计流水的写入,实际子请求数会更高。所以分批上限不能只盯着参数墙,还得给单次调用的 1000 子请求留足余量------这也就是为什么我把导入卡在 500、分配卡在 100:它们换算成子请求后仍远在 1000 以内,但已足够贴近"一次调用能干完"的舒适区,不会卡在 30 秒隐性超时上。

把"参数数"和"子请求数"这两本账一起算,才是 D1 批量写入真正稳妥的姿势。

六、小结:D1 用法的几条铁律

把上面四个坑收一下,落到几条可复用的铁律,给打算上 D1 的同学省时间:

铁律 来源坑 做法
批量写按"变量数"算上限,不是行数 100 参数墙 rowsPerStmt = floor(MAX_PARAMS / 列数),跟着表结构走
多表一致性靠 db.batch,不靠外键 无真事务 + 零外键 复合写进 batch;存在性校验前置到 batch 外
原生取数先选 get/all/run 再取 count 恒 0 单值用 get、多行用 all、别拿 run 当下标
本地改代码不生效先查 workerd 幽灵端口 冒烟脚本开头 pkill -9 -f workerd
单批压在 1000 子请求 / 30s 内 隐性超时 导入/分配/对账各自卡死上限,超量拆段

回过头看,D1 这套边缘 SQLite 不是"不行的 SQLite",而是"另一套运行模型下的 SQLite"。它的限制几乎都来自"每次访问都是子请求"这个事实:参数上限、无跨语句事务、取数要分包、本地有 workerd 幽灵。把这些运行模型层面的限制想明白,上面那些坑就不再是坑,而是你写代码时自然会绕开的形状。

相关阅读:


如果这篇对你上手 D1 有点用,发财的小手点个小赞,咱们下一篇聊 Workers 上的认证与安全(JWT 双密钥轮转、PBKDF2 的 10 万迭代上限那些事)。

本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!

相关推荐
烂蜻蜓3 小时前
Node.js入门教程(十八):Stream(流)
node.js
东方小月21 小时前
从零开发一个 Coding Agent(九):实现 Agent 的工具调用闭环
人工智能·前端框架·node.js
Yolanda_20221 天前
nvm安装Node卡顿解决方案:两行配置切换国内镜像源
node.js
王八八。1 天前
nvm 安装与配置完全指南:从零到多版本 Node.js 自由切换
node.js
紫禁玄科2 天前
Shai-Hulud:npm生态的自我复制蠕虫风暴
前端·npm·node.js
烂蜻蜓2 天前
Node.js入门教程(十四):async/await
node.js
谢小飞4 天前
大文件上传很难?学会这招,GB文件秒传不是梦
前端·node.js
周小董4 天前
[1367]npm 安装 canvas 报错 node-gyp ERR
前端·npm·node.js
张龙6875 天前
Node.js 内存泄漏排查实战:从内存曲线到堆快照,一条可复用的定位路径
性能优化·node.js