PAYATHON 2026

Next.js 15 缓存 LinkedIn images BATCH-GET 的方案

排障赵师傅

结论

不要让页面请求直接执行 posts FINDER → images BATCH-GET。建议改为定时同步:

  1. 由定时任务调用 LinkedIn API。
  2. 把完整 feed 和图片映射写入数据库、Redis 或持久化 KV。
  3. 页面只读取最近一次成功生成的快照。
  4. 同步失败时继续返回旧快照,不要用空结果覆盖。
  5. 通过分布式锁防止多个实例同时刷新。
  6. 每个刷新周期最多调用一次 images BATCH-GET,遇到 429 或 5xx 后不要立即连续重试。

next: { revalidate } 可以减少请求,但无法独自实现严格的 API 配额控制。重新部署、清理缓存、改变请求参数或出现并发冷启动,都可能造成新的缓存未命中。

当前方案为什么仍会超限

两次 fetch 对应不同的缓存项。images 请求的 URL 由所有图片 URN 组成:

/rest/images?ids=List(...)

帖子新增、图片顺序改变或 URN 集合发生变化时,URL 也会改变,Next.js 会将其视为新的请求。对 URN 排序只能避免因顺序不同产生多余的缓存键,解决不了新帖子带来的缓存未命中。

还要考虑以下情况:

  • revalidate 只表示缓存可在指定时间后重新验证,不是全局限流器。
  • 多个 Serverless 实例可能分别发起一次冷启动请求。
  • 重新部署或清理缓存后,Next.js Data Cache 未必还保留旧数据。
  • 请求失败并抛出异常时,当前代码没有上一版结果可供返回。
  • 外层代码捕获异常后再次调用,可能在短时间内多次消耗配额。
  • Request Memoization 主要作用于单次 React 渲染,不能替代跨请求、跨实例的持久缓存。

是否调用 LinkedIn 不应取决于缓存有没有命中,而应由受控的同步任务决定。

推荐的数据结构

持久化存储至少需要保留以下内容:

interface StoredLinkedInSnapshot {
  feed: LinkedInFeed;
  imageMap: Record<string, { downloadUrl?: string }>;
  updatedAt: string;
}

保存 imageMap 后,新帖子出现时可以继续复用已有 URN 的图片地址。如果 images 请求失败,旧帖子也能沿用上一轮获取的地址。

LinkedIn 返回的下载地址可能有有效期。是否包含过期时间字段、字段名称是什么、有效期有多长,都应以当前 LinkedIn API 版本的文档和实际响应为准。如果地址会过期,同步间隔必须短于地址的有效期。

把 LinkedIn 请求移到同步任务

以下代码不绑定具体的存储产品,只定义快照存储和分布式锁接口。实际项目可以使用现有数据库、Redis 或支持原子写入的 KV。

import 'server-only';

export interface MediaItem {
  id: string;
  altText?: string | null;
}

export interface LinkedInPost {
  id: string;
  author: string;
  createdAt: number;
  publishedAt: number;
  commentary?: string;
  content?: {
    media?: {
      id: string;
      altText?: string;
    };
    multiImage?: {
      images: Array<{
        id: string;
        altText?: string;
      }>;
    };
  };
  media: MediaItem[];
}

interface LinkedInFeed {
  elements: LinkedInPost[];
}

interface LinkedInImageInfo {
  downloadUrl?: string;
}

interface StoredLinkedInSnapshot {
  feed: LinkedInFeed;
  imageMap: Record<string, LinkedInImageInfo>;
  updatedAt: string;
}

/*
 * 需要用数据库、Redis 或持久化 KV 实现。
 * put() 应采用原子写入,避免读到半份数据。
 */
declare const snapshotStore: {
  get(): Promise<StoredLinkedInSnapshot | null>;
  put(snapshot: StoredLinkedInSnapshot): Promise<void>;
};

/*
 * acquire() 必须是跨实例的分布式锁。
 */
declare const refreshLock: {
  acquire(
    key: string,
    ttlMs: number
  ): Promise<{ release(): Promise<void> } | null>;
};

const TOKEN = process.env.LINKEDIN_ACCESS_TOKEN;
const ORGANIZATION_URN = process.env.LINKEDIN_ORGANIZATION_URN;

function linkedinHeaders(): HeadersInit {
  if (!TOKEN) {
    throw new Error('LINKEDIN_ACCESS_TOKEN is not configured');
  }

  return {
    Authorization: `Bearer ${TOKEN}`,
    'X-Restli-Protocol-Version': '2.0.0',
    'LinkedIn-Version': '202511',
  };
}

async function fetchJson<T>(
  url: string,
  extraHeaders: HeadersInit = {}
): Promise<T> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  try {
    const response = await fetch(url, {
      headers: {
        ...linkedinHeaders(),
        ...extraHeaders,
      },
      cache: 'no-store',
      signal: controller.signal,
    });

    if (!response.ok) {
      const responseBody = (await response.text()).slice(0, 500);

      throw new Error(
        `LinkedIn API returned ${response.status}: ${responseBody}`
      );
    }

    return (await response.json()) as T;
  } finally {
    clearTimeout(timeout);
  }
}

async function fetchFeedRaw(): Promise<LinkedInFeed> {
  if (!ORGANIZATION_URN) {
    throw new Error('LINKEDIN_ORGANIZATION_URN is not configured');
  }

  const author = encodeURIComponent(ORGANIZATION_URN);
  const url =
    `https://api.linkedin.com/rest/posts` +
    `?q=author&author=${author}&count=100&sortBy=CREATED`;

  return fetchJson<LinkedInFeed>(url, {
    'X-RestLi-Method': 'FINDER',
  });
}

async function fetchImagesMap(
  imageUrns: string[]
): Promise<Record<string, LinkedInImageInfo>> {
  if (imageUrns.length === 0) {
    return {};
  }

  // 排序可避免同一组 URN 因顺序不同产生不同请求。
  const sortedUrns = [...new Set(imageUrns)].sort();
  const ids = `List(${sortedUrns.map(encodeURIComponent).join(',')})`;
  const url = `https://api.linkedin.com/rest/images?ids=${ids}`;

  const response = await fetchJson<{
    results?: Record<string, LinkedInImageInfo>;
  }>(url);

  return response.results ?? {};
}

function collectImageUrns(feed: LinkedInFeed): string[] {
  const urns = new Set<string>();

  for (const post of feed.elements ?? []) {
    const singleImage = post.content?.media?.id;

    if (singleImage?.startsWith('urn:li:image:')) {
      urns.add(singleImage);
    }

    for (const image of post.content?.multiImage?.images ?? []) {
      if (image.id?.startsWith('urn:li:image:')) {
        urns.add(image.id);
      }
    }
  }

  return [...urns].sort();
}

function hydrateFeed(
  feed: LinkedInFeed,
  imageMap: Record<string, LinkedInImageInfo>
): LinkedInFeed {
  return {
    ...feed,
    elements: (feed.elements ?? []).map((post) => {
      const media: MediaItem[] = [];
      const singleImage = post.content?.media;

      if (singleImage?.id) {
        const downloadUrl = imageMap[singleImage.id]?.downloadUrl;

        if (downloadUrl) {
          media.push({
            id: downloadUrl,
            altText: singleImage.altText ?? null,
          });
        }
      }

      for (const image of post.content?.multiImage?.images ?? []) {
        const downloadUrl = imageMap[image.id]?.downloadUrl;

        if (downloadUrl) {
          media.push({
            id: downloadUrl,
            altText: image.altText ?? null,
          });
        }
      }

      return {
        ...post,
        media,
      };
    }),
  };
}

代码对 LinkedIn 请求使用 cache: 'no-store',因为请求频率已经由同步任务控制。这样只需管理持久化快照,不必同时依赖 Next.js Data Cache,也能避免两层缓存状态不一致。

刷新时保留上一版数据

刷新时,先在内存中组装完整结果。全部成功后,再将结果原子写入存储。任何一步失败,都继续使用旧快照。

export async function refreshLinkedInFeed(): Promise<LinkedInFeed> {
  const lock = await refreshLock.acquire(
    'linkedin:feed:refresh',
    5 * 60 * 1000
  );

  if (!lock) {
    const current = await snapshotStore.get();

    if (current) {
      return current.feed;
    }

    throw new Error('LinkedIn feed refresh is already running');
  }

  try {
    const previous = await snapshotStore.get();
    const rawFeed = await fetchFeedRaw();
    const imageUrns = collectImageUrns(rawFeed);

    /*
     * 每轮同步最多调用一次 images BATCH-GET。
     * 不在这里拆成多个并行请求,也不自动重试。
     */
    const freshImageMap = await fetchImagesMap(imageUrns);
    const mergedImageMap = {
      ...(previous?.imageMap ?? {}),
      ...freshImageMap,
    };

    const missingUrns = imageUrns.filter(
      (urn) => !mergedImageMap[urn]?.downloadUrl
    );

    if (missingUrns.length > 0) {
      throw new Error(
        `LinkedIn image response is incomplete; missing ${missingUrns.length} image(s)`
      );
    }

    const feed = hydrateFeed(rawFeed, mergedImageMap);

    await snapshotStore.put({
      feed,
      imageMap: mergedImageMap,
      updatedAt: new Date().toISOString(),
    });

    return feed;
  } catch (error) {
    const previous = await snapshotStore.get();

    console.error('LinkedIn feed refresh failed', error);

    if (previous) {
      return previous.feed;
    }

    throw error;
  } finally {
    await lock.release();
  }
}

如果允许部分帖子暂时不显示图片,可以去掉 missingUrns 检查并保存新 feed。这样新帖子能及时显示,但部分图片可能缺失。展示型网站通常更适合继续返回上一份完整快照。

页面请求只读取快照

正常的页面流量不应触发 LinkedIn API:

import mockData from '@/data/response.json';

export async function fetchLinkedInFeed(): Promise<LinkedInFeed> {
  if (process.env.MOCK_DATA === 'true') {
    return mockData as LinkedInFeed;
  }

  const snapshot = await snapshotStore.get();

  if (!snapshot) {
    throw new Error(
      'LinkedIn feed is unavailable because no successful snapshot exists'
    );
  }

  return snapshot.feed;
}

首次上线前,应主动执行一次同步任务。如果第一次同步就失败,系统将没有旧数据可供降级。Route Handler 可以捕获这个错误,返回 503 Service Unavailable 并设置合理的 Retry-After。不要在一次页面请求中循环调用 LinkedIn。

刷新频率怎么安排

如果 images 接口的限制确实是每 24 小时两次,可以先每天执行一次完整同步,把另一次调用留给人工恢复或异常处理。配额究竟按应用、成员、组织还是滚动 24 小时计算,应以 LinkedIn 开发者后台和当前产品权限为准。

如果帖子需要更快更新,可以拆成两个任务:

  • posts FINDER 每隔数小时执行一次。
  • images BATCH-GET 每天执行一次。
  • 新帖子在下一次图片同步前暂时不显示图片,或者暂不写入公开快照。

发现新 URN 时不要立即请求 images 接口。流量较高时,这种做法会再次让 API 调用受访客请求驱动。

错误处理建议

各类状态码可以这样处理:

  • 400:通常表示参数、URN 格式或 BATCH-GET 编码有问题。记录错误后停止重试。
  • 401、403:检查 token、权限及 LinkedIn API 产品授权。自动重试通常解决不了这类问题。
  • 429:读取 Retry-After,停止本次同步并继续返回旧快照。
  • 5xx 或网络超时:保留旧快照,等下一次计划任务再处理。images 配额很低,不建议在当前任务中连续重试。
  • 响应格式不完整:保留已经可用的快照,不要覆盖。

日志可以记录状态码、同步时间、缺失 URN 数量及请求阶段,但不能记录访问令牌。响应正文也要先截断并清理,再写入日志。

部署时还需要确认 LinkedIn-Version: '202511' 仍受支持。LinkedIn API 的版本有效期和返回字段可能变化,不能长期写死后不再检查。

只使用 Next.js 缓存是否可行

如果流量较小、应用只运行在单个实例上,而且可以接受部署后重新请求 LinkedIn,可以用 unstable_cache 缓存完整的聚合结果,不再分别缓存 posts 和 images:

import { unstable_cache } from 'next/cache';

export const getCachedLinkedInFeed = unstable_cache(
  async () => {
    const feed = await fetchFeedRaw();
    const imageUrns = collectImageUrns(feed);
    const imageMap = await fetchImagesMap(imageUrns);

    return hydrateFeed(feed, imageMap);
  },
  ['linkedin-feed-v1'],
  {
    revalidate: 24 * 60 * 60,
    tags: ['linkedin-feed'],
  }
);

这种写法可以把两个 LinkedIn 请求作为一个整体缓存,但仍无法严格保证调用次数。缓存被清除、部署环境之间不共享 Data Cache,或首次生成失败时,系统都可能再次请求 LinkedIn。对于每天只有两次调用额度的接口,持久化快照配合定时同步和分布式锁更合适。

备注:内容仅供参考。