ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/13 11:22:58 拓冰建站 浏览量
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一次性写库。代码大概是这样// 直觉写法一条 INSERT 塞一堆行awaitdb.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每行参数单条最多行数279027floor(90/27) 3159015floor(90/15) 6109010floor(90/10) 95905floor(90/5) 18也就是说一张 27 列的宽表一条INSERT最多只能 3 行。如果一次导入 500 条就得拆成 167 条INSERT语句。于是那段直觉代码变成了constMAX_PARAMS90;// D1 限制 100留 10 余量constLEAD_COLS27;// leads 表实际列数constrowsPerStmtMath.max(1,Math.floor(MAX_PARAMS/LEAD_COLS));// 3for(leti0;irows.length;irowsPerStmt){constchunkrows.slice(i,irowsPerStmt);awaitdb.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_keysON只在同一个连接内生效跨连接它根本不认。所以外键约束在 D1 里是声明了也不靠谱的这也是为什么我在 schema 设计阶段就决定零物理外键引用完整性全部交给应用层。那一批写入要么全成要么全败怎么办D1 给的替代是db.batch()——它把一批语句打包成一个原子单元提交里面任何一条失败整批都不写入D1 保证 batch 的原子性。我那个一次请求原子建通话记录 跟进 更新线索状态的复合端点就是这样写的awaitdb.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有两个坑是文档不显眼、踩了才知道的坑 1batch 内的语句读不到前序结果。你在 batch 里第一条SELECT出来的东西第二条语句是读不到的——因为它是包好一起发不是逐条串行。所以任何先查再写的校验必须在 batch 外面、之前做完。我那个并发分配防护就是这样先独立SELECT复核线索当前owner_id和状态校验通过再在 batch 里写归属 流水。把复核塞进 batch 里就会读到脏的快照。坑 2batch 不能无限大。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]直接当下标取会拿到undefineddb.all(sql)行数组unknown[]直接当数组用单值查询还要取[0]多此一举db.get(sql)首行对象{...}/ 无结果undefined直接解构成对象用run去取下标恒undefined原来的列表代码是这么写的// 错误写法run 返回的不是行数组constrawaitdb.run(sqlSELECT count(*) as n FROM leads WHERE ...);consttotalr.results[0]?.n;// 实际 r 是 D1Resultr.results 才有行r.results这一步在有些 Drizzle 版本里是存在的但取数约定和我以为的不一样导致total恒为undefined。改成db.get取首行对象就对了// 正确写法get 返回首行对象const{n}awaitdb.get{n:number}(sqlSELECT count(*) as n FROM leads WHERE ...);// 多行用 allconstrowsawaitdb.allLead(sqlSELECT * 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杀不掉它得连子进程一起清pkill-9-fworkerd# 重点杀 workerd不是 wranglerpkill-9-fwranglersleep2# 再起服务我们本地冒烟脚本开头现在都固定先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 幽灵。把这些运行模型层面的限制想明白上面那些坑就不再是坑而是你写代码时自然会绕开的形状。相关阅读Node 后端实战 · 为什么用 Cloudflare Workers D1 扛起了整个多租户 SaaS 后端架构决策全景复盘Node 后端实战 · Cloudflare Workers 踩坑实录Nodejs Mysql 全量备份以及通过备份恢复数据实战NodeJS Koa 后端用户会话管理JWT, Session长短Token本文一次性讲明白node 后端 RSA 非对称加密的一个非常简单的实现方式如果这篇对你上手 D1 有点用发财的小手点个小赞咱们下一篇聊 Workers 上的认证与安全JWT 双密钥轮转、PBKDF2 的 10 万迭代上限那些事。本文由 FungLeo 主导Deepseek 优化校阅转发请注明首发地址谢谢大家