Next.js 15 缓存 LinkedIn images BATCH-GET 的方案
结论
不要让页面请求直接执行 posts FINDER → images BATCH-GET。建议改为定时同步:
- 由定时任务调用 LinkedIn API。
- 把完整 feed 和图片映射写入数据库、Redis 或持久化 KV。
- 页面只读取最近一次成功生成的快照。
- 同步失败时继续返回旧快照,不要用空结果覆盖。
- 通过分布式锁防止多个实例同时刷新。
- 每个刷新周期最多调用一次
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。对于每天只有两次调用额度的接口,持久化快照配合定时同步和分布式锁更合适。
备注:内容仅供参考。
