n8n进阶:多维表格数据变多时流程怎么优化

之前写过一篇教程,介绍了如何用 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
  • URLhttps://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
  • URLhttp://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
  • Pathdouyin-batch-worker(和主流程里调用的地址对应)
  • Response ModeonReceived(收到请求就立即响应 200,然后继续后台处理)

💡 重要细节Response Mode 设成 onReceived 而不是 lastNode,是因为子流程处理时间可能很长(100 条视频要跑几分钟),如果等到最后才响应,主流程那边会超时。设成 onReceived 就是「先告诉主流程我收到了,你不用等我」。

节点二:展开批次记录

Code 节点,把主流程传进来的 records 数组展开成多个 item,每个 item 是一条视频记录,同时把 tenant_access_tokenrunTime 带上:

// 主流程传入:{ 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
  • URLhttps://api.tikhub.io/api/v1/douyin/app/v3/fetch_one_video_v2
  • Query 参数aweme_id = {{ $json.视频id }}
  • HeadersAuthorization: Bearer 你的TikHub_API_Key
  • Timeout:建议 15000ms,避免单条请求卡太久

节点六:解析 TikHub 结果

Code 节点,从 TikHub 返回的数据里提取点赞、评论、分享、收藏数,并判断视频是否有效。逻辑要点:

  • aweme_detail.statistics 存在:取 digg_countcomment_countshare_countcollect_count,并标记「有效」。
  • 若被过滤且 filter_list 有原因:把原因写入「视频是否有效」。
  • 同时把 tenant_access_token 继续带下去,后面两个写入节点都要用到。

(具体解析代码与原流程里「解析TikHub结果」节点一致,这里就不重复贴了)

节点七:更新表(最新数据)

HTTP Request 节点,PUT 请求飞书多维表格更新接口,用 record_id 定位到具体那行,更新点赞等字段。

  • Method:PUT
  • URLhttps://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/{{ $json.record_id }}
  • HeadersAuthorization: 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_idapp_secret
  • 分页拉取全量记录并切组 节点代码里的:appToken(多维表格 app_token)、tableId(表1的 table_id)
  • 并发触发子流程 节点:URL 中的 localhost:5678 替换成你的 n8n 实际地址

子流程:

  • 调用TikHub获取作品数据 节点:TikHub API Key
  • 更新表节点:URL 中的 app_tokentable_id

六、写在最后

这次优化本质上解决的是一个很常见的工程化问题:当数据量从小变大,原来够用的方案开始暴露短板。

串行变并发、单次查询变分页全量、Token 从全局取改成随 batch 传——每一个改动单独看都不复杂,但组合在一起,流程的健壮性就上了一个台阶。

如果你目前的 n8n 流程也有类似的「数据量一大就慢、一多就查不到」的问题,可以用同样的思路拆一下:主流程管调度、子流程管执行,中间用 Webhook 串起来。


📋 这套优化相关模板,我整理成模板了

跟着上面步骤搭要花点时间。想直接拿现成的改,加我微信备注「优化」,我发你。

🤔 卡在某一步了?

搭建过程里如果哪一步卡住了,如果你照着做没跑通,或者你们公司情况不太一样,可以找我聊聊,我免费帮你看看怎么调。不合适我也会直说。

加吨师傅微信咨询飞书多维表格搭建

扫码加我,备注「优化」领模板

想看更多这类方案,我都整理在 吨师傅工具箱 里了:客户管理、进销存、工单报修……按场景分好类,可以直接抄。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注

滚动至顶部