之前写过一篇教程,介绍了如何用 n8n + TikHub + 飞书多维表格,搭建一套抖音视频数据同步工作流——每天定时跑,把多维表格里每条视频的点赞、评论、分享、收藏数自动更新进去。
流程上线之后用了一段时间,整体没什么问题。
但数据量慢慢多起来之后,我发现这套流程开始有点「撑不住」了。

*(表里面数据越来越多,n8n跑数据时就会遇到一些实际的问题)*
问题一:超过 500 条之后,数据就「查不到」了
原来的流程在查询飞书多维表格时,page_size 固定设的是 500——这是飞书 API 单次查询的上限,超过了就要加分页的逻辑。
超过 500 条之后,第 501 条开始的视频根本就取不到,流程也不会报错,只会悄悄地跳过它们。也就是说,你的表里可能有 800 条视频,但每次实际只更新了前 500 条,后面 300 条的数据一直是旧的——而你完全不会发现。
问题二:数据量一大,更新速度慢得难受
原来的循环是串行的:取一条 → 调 TikHub API → 解析结果 → 更新表 → 等 1 秒 → 再取下一条。
每条视频走完这一圈大概要 2-3 秒,100 条就是 3-5 分钟,300 条接近 15 分钟,500 条半小时打底。
如果你对数据及时性有要求(比如需要每天定时跑完、早上起来看最新数据),这个速度就开始有点难受了。
问题三:Token 跑到一半过期
飞书的 tenant_access_token 有效期是 2 小时。原来的流程在最开始取一次 Token,然后一直用到结束。
数据量少的时候没问题,但如果视频多、循环跑了超过 2 小时,Token 就过期了——后面所有的写入请求全部返回 401,而且当前流程没有处理这个异常,它会一直报错但继续跑,产生大量脏数据。
所以在实战使用的时候,原来的流程就会发现有些局限了。这时候我们就需要来优化下。
二、优化思路:主流程 + 子流程,分页取数 + 并发处理
我们可以优化的思路是这样的:
把「取数」和「处理」拆开,让多组数据同时跑,而不是一条一条排队。
举个例子,比如有500条数据,我们就把这500条数据拆开成5组,每组100条,这5组并行来跑流程。这样运行的话,跑数据的效率就会提升很多。
具体分成两个流程:
- 主流程:负责「取全量数据 + 分组 + 并发触发」
- 子流程:负责「接收一组数据 + 串行处理每条 + 写入表格」
主流程用分页把所有视频都取回来(不管多少条),然后每 100 条切成一组,同时触发多个子流程并行跑。子流程之间互相独立,一组失败不影响其他组。
同时,Token 问题也在这次一起解决掉:Token 在主流程里取好之后,作为参数传给每个子流程,子流程直接用传进来的 Token 去调 API,不需要再单独请求。

三、详细配置
(一)主流程:分页拉取 + 切组 + 并发触发

主流程共 5 个节点,整体链路很短,核心工作都在「分页拉取全量记录并切组」这个 Code 节点里完成。
节点一:触发器(手动触发 / 定时触发)
两个触发器并联,都指向下一个节点「获取飞书Token」:
- 手动触发:测试或临时补跑时用
- 定时触发:设置每天 21:00 自动执行,按你的实际需求调整
配置上没有特别的,两个触发器同时连到「获取飞书Token」即可。
节点二:获取飞书 Token
HTTP Request 节点,POST 请求飞书鉴权接口,获取 tenant_access_token。
- Method:POST
- URL:
https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal - Body(示例):
{
"app_id": "你的 app_id",
"app_secret": "你的 app_secret"
}
返回结果里的 tenant_access_token 就是后续所有 API 请求的身份凭证,有效期 2 小时。
节点三:记录运行时间
Code 节点,把当前执行时间格式化成可读的字符串(例如 2026/03/24 21:00:00),后面写入日志表时会用到。
同时把 tenant_access_token 透传下去,保证后续节点都能取到。
const item = $input.item.json;
const runTime = new Date().toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
}).replace(/\//g, '-');
return [{ json: { ...item, runTime } }];
节点四:分页拉取全量记录并切组(核心节点)
这是整个主流程最关键的节点
完整的js如下
// 分页拉取飞书多维表格所有记录
// 飞书 API 单次最多 500 条,has_more=true 时继续翻页
const token = $input.item.json.tenant_access_token;
const runTime = $input.item.json.runTime;
const appToken = 'QjppbX85bansCLsdUu5cCm0cnpd';
const tableId = 'tblWBZiWGjagCFwG';
const baseUrl = `https://open.feishu.cn/open-apis/bitable/v1/apps/${appToken}/tables/${tableId}/records`;
let allItems = [];
let pageToken = null;
let hasMore = true;
let page = 0;
while (hasMore) {
page++;
const params = new URLSearchParams({ page_size: '500' });
if (pageToken) params.append('page_token', pageToken);
const url = `${baseUrl}?${params.toString()}`;
const resp = await $helpers.httpRequest({
method: 'GET',
url,
headers: { Authorization: `Bearer ${token}` },
returnFullResponse: false
});
const data = resp.data || {};
const items = data.items || [];
allItems = allItems.concat(items);
hasMore = !!data.has_more;
pageToken = data.page_token || null;
// 安全阀:防止死循环,最多取 20 页(10000 条)
if (page >= 20) break;
}
// 过滤掉没有视频id的记录
function getVideoId(v) {
if (v == null || v === '') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v) && v[0] && v[0].text != null) return String(v[0].text);
return '';
}
const validItems = allItems.filter(r => !!getVideoId(r.fields && r.fields['视频id']));
// 按每 100 条切成一组,输出多个 item,每个 item 是一个批次
const BATCH_SIZE = 100;
const batches = [];
for (let i = 0; i < validItems.length; i += BATCH_SIZE) {
batches.push({
json: {
batchIndex: Math.floor(i / BATCH_SIZE) + 1,
totalBatches: Math.ceil(validItems.length / BATCH_SIZE),
totalRecords: validItems.length,
records: validItems.slice(i, i + BATCH_SIZE),
tenant_access_token: token,
runTime
}
});
}
return batches;
这段js主要做了三件事:
① 循环分页取全量数据
飞书多维表格查询 API 每次最多返回 500 条,超出部分需要通过 page_token 翻页。这里用一个 while 循环,每次带上上一次返回的 page_token,直到 has_more = false 为止:
let allItems = [];
let pageToken = null;
let hasMore = true;
let page = 0;
while (hasMore) {
page++;
const params = new URLSearchParams({ page_size: '500' });
if (pageToken) params.append('page_token', pageToken);
const resp = await $helpers.httpRequest({
method: 'GET',
url: `${baseUrl}?${params.toString()}`,
headers: { Authorization: `Bearer ${token}` }
});
const data = resp.data || {};
allItems = allItems.concat(data.items || []);
hasMore = !!data.has_more;
pageToken = data.page_token || null;
if (page >= 20) break; // 安全阀,防止死循环,最多取约 10000 条
}
这样不管表里有多少条视频,都能完整取到。
② 过滤无效记录
取完之后过滤掉没有填写”视频id”的行——这些是无效数据,不需要调 TikHub。
③ 按每 100 条切成一组
把过滤后的有效记录,每 100 条打包成一个 batch,同时把 tenant_access_token 和 runTime 一起放进去传给子流程:
const BATCH_SIZE = 100;
const batches = [];
for (let i = 0; i < validItems.length; i += BATCH_SIZE) {
batches.push({
json: {
batchIndex: Math.floor(i / BATCH_SIZE) + 1,
records: validItems.slice(i, i + BATCH_SIZE),
tenant_access_token: token,
runTime
}
});
}
return batches;
如果你有 350 条有效视频,这里就会输出 4 个 item(100 + 100 + 100 + 50),后面的节点会对每个 item 各自触发一个子流程。
节点五:并发触发子流程
HTTP Request 节点,POST 请求子流程的 Webhook 地址,把每个 batch 的数据传过去。
- Method:POST
- URL:
http://localhost:5678/webhook/douyin-batch-worker(替换成你的 n8n 实际地址) - Body:在 n8n 里用表达式,例如
={{ JSON.stringify($json) }} - Timeout:建议设成
3600000(1 小时),确保子流程有足够时间跑完
n8n 默认对同一个节点的多个 item 是并行处理的,所以多个 batch 会同时触发多个子流程实例,互不等待,真正做到并发。
(二)子流程:接收批次 + 逐条处理 + 写入表格

子流程的逻辑和原来的单条处理链路基本一致,只是入口从手动触发改成了 Webhook,数据来源变成了主流程传入的 batch。
节点一:接收批次数据(Webhook)
Webhook 节点,等待主流程的 HTTP 请求。
- HTTP Method:POST
- Path:
douyin-batch-worker(和主流程里调用的地址对应) - Response Mode:
onReceived(收到请求就立即响应 200,然后继续后台处理)
💡 重要细节:Response Mode 设成 onReceived 而不是 lastNode,是因为子流程处理时间可能很长(100 条视频要跑几分钟),如果等到最后才响应,主流程那边会超时。设成 onReceived 就是「先告诉主流程我收到了,你不用等我」。
节点二:展开批次记录
Code 节点,把主流程传进来的 records 数组展开成多个 item,每个 item 是一条视频记录,同时把 tenant_access_token 和 runTime 带上:
// 主流程传入:{ records: [...], tenant_access_token: '...', runTime: '...' }
const body = $input.item.json.body || $input.item.json;
const records = body.records || [];
const token = body.tenant_access_token || '';
const runTime = body.runTime || '';
if (!records.length) return [];
return records.map(r => ({
json: { ...r, tenant_access_token: token, runTime }
}));
节点三:逐条循环(SplitInBatches)
SplitInBatches节点,batch size 设为 1,对展开后的每条视频逐一处理。 和原来的流程一样,main[0](Done)出口表示所有 item 处理完毕、流程结束;main[1](Loop)出口进入每条视频的处理链路。
节点四:取视频 id
Code 节点,从当前 item 的 fields 里提取 视频id、抖音链接 以及透传下来的 tenant_access_token 和 runTime:
function getVideoId(v) {
if (v == null || v === '') return '';
if (typeof v === 'string' || typeof v === 'number') return String(v);
if (Array.isArray(v) && v[0] && v[0].text != null) return String(v[0].text);
return '';
}
const r = $input.item.json;
const fields = r.fields || {};
return [{
json: {
record_id: r.record_id,
视频id: getVideoId(fields['视频id']),
抖音链接: fields['抖音链接'] || '',
tenant_access_token: r.tenant_access_token,
runTime: r.runTime
}
}];
getVideoId 做了兼容处理,因为飞书多维表格返回的文本字段可能是字符串、数字或 [{text: '...'}] 数组,这里统一转成字符串。
节点五:调用 TikHub 获取作品数据
HTTP Request 节点,调用 TikHub 的抖音视频接口,传入视频 id,获取最新的统计数据。
- Method:GET
- URL:
https://api.tikhub.io/api/v1/douyin/app/v3/fetch_one_video_v2 - Query 参数:
aweme_id = {{ $json.视频id }} - Headers:
Authorization: Bearer 你的TikHub_API_Key - Timeout:建议 15000ms,避免单条请求卡太久
节点六:解析 TikHub 结果
Code 节点,从 TikHub 返回的数据里提取点赞、评论、分享、收藏数,并判断视频是否有效。逻辑要点:
- 若
aweme_detail.statistics存在:取digg_count、comment_count、share_count、collect_count,并标记「有效」。 - 若被过滤且
filter_list有原因:把原因写入「视频是否有效」。 - 同时把
tenant_access_token继续带下去,后面两个写入节点都要用到。
(具体解析代码与原流程里「解析TikHub结果」节点一致,这里就不重复贴了)
节点七:更新表(最新数据)
HTTP Request 节点,PUT 请求飞书多维表格更新接口,用 record_id 定位到具体那行,更新点赞等字段。
- Method:PUT
- URL:
https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{{ $json.record_id }} - Headers:
Authorization: Bearer {{ $json.tenant_access_token }}(用传进来的 Token,不是从开头节点取) - Body:在 n8n 里用 Raw,并用
JSON.stringify拼字段,避免特殊字符导致 JSON 非法,例如:
={{ JSON.stringify({
fields: {
'点赞数': $json.点赞数,
'分享数': $json.分享数,
'评论数': $json.评论数,
'收藏数': $json.收藏数,
'视频是否有效': $json.视频是否有效,
'数据更新时间': $json.更新时间,
'视频发布时间': $json.视频发布时间
}
}) }}
节点八:等待 1 秒
Wait 节点,每处理完一条视频等待 1 秒,再回到「逐条循环」处理下一条。
这个等待的目的是控制 API 调用频率——TikHub 和飞书都有 QPS 限制,多个子流程并发时,如果每条都不等待,并发请求量会很大,可能触发限速。
等待 1 秒之后,回到「逐条循环」的入口,处理下一条,直到这个 batch 的 100 条全部跑完。
四、整体运行效果对比
| 维度 | 优化前 | 优化后 |
|---|---|---|
| 数据完整性 | 超过 500 条静默丢失 | 全量取数,不丢数据 |
| 处理速度(300条) | 约 15 分钟(串行) | 约 5 分钟(3组并发) |
| Token 过期风险 | 有(长时间运行可能过期) | 无(Token 随 batch 传入,2小时内够用) |
| 单组失败影响 | 整条流程中断 | 仅影响当前 batch,其他组继续 |
五、使用前需要替换的配置项
两个流程 JSON 导入后,需要替换以下几处:
主流程:
获取飞书Token节点:app_id、app_secret分页拉取全量记录并切组节点代码里的:appToken(多维表格 app_token)、tableId(表1的 table_id)并发触发子流程节点:URL 中的localhost:5678替换成你的 n8n 实际地址
子流程:
调用TikHub获取作品数据节点:TikHub API Key更新表节点:URL 中的app_token和table_id
六、写在最后
这次优化本质上解决的是一个很常见的工程化问题:当数据量从小变大,原来够用的方案开始暴露短板。
串行变并发、单次查询变分页全量、Token 从全局取改成随 batch 传——每一个改动单独看都不复杂,但组合在一起,流程的健壮性就上了一个台阶。
如果你目前的 n8n 流程也有类似的「数据量一大就慢、一多就查不到」的问题,可以用同样的思路拆一下:主流程管调度、子流程管执行,中间用 Webhook 串起来。
📋 这套优化相关模板,我整理成模板了
跟着上面步骤搭要花点时间。想直接拿现成的改,加我微信备注「优化」,我发你。
🤔 卡在某一步了?
搭建过程里如果哪一步卡住了,如果你照着做没跑通,或者你们公司情况不太一样,可以找我聊聊,我免费帮你看看怎么调。不合适我也会直说。
扫码加我,备注「优化」领模板
想看更多这类方案,我都整理在 吨师傅工具箱 里了:客户管理、进销存、工单报修……按场景分好类,可以直接抄。


