项目上线后,我们收到了第一个紧急工单:“我刚刚更新了商品描述,刷新页面后看到的还是旧内容,要等一分多钟才能看到变化!”。这个反馈并不意外,它精准地击中了我们新架构的阿喀琉斯之踵。为了应对日益增长的读取压力,我们将商品详情页从传统的服务端渲染(SSR)迁移到了增量静态再生(Incremental Static Regeneration, ISR)。前端技术栈选用了轻量、标准的 Lit Web Components,后端则毫无悬念地采用了主从读写分离的数据库架构。
性能数据非常漂亮:99% 的请求响应时间都在 50ms 以内,因为它们直接命中了缓存的静态页面。数据库从库的 CPU 负载平稳。但用户的抱怨是真实的——在读写分离带来的复制延迟窗口(replication lag)内,ISR 的后台再生进程很可能读到的是从库的旧数据,然后将这份陈旧的内容缓存为“最新”版本,呈现给所有用户。
我们面对的不是一个简单的 bug,而是一个典型的分布式系统权衡问题。我们选择了 BASE 理论(Basically Available, Soft state, Eventually consistent)来换取系统的可用性和扩展性,但现在必须处理它带来的副作用:最终一致性与用户期望的“即时一致性”之间的矛盾。目标很明确:在不牺牲 ISR 和读写分离带来的性能优势的前提下,为执行写操作的用户提供“读己之写”(Read-Your-Writes)的会话一致性保证。
架构痛点:ISR 与数据库复制延迟的冲突
让我们用一个简化的流程图来可视化这个问题。
sequenceDiagram participant User as 用户 participant Edge as CDN/缓存 participant Server as ISR 服务 participant DBPrimary as 主数据库 participant DBReplica as 从数据库 User->>Edge: GET /product/123 (首次) Edge->>Server: GET /product/123 Server->>DBReplica: SELECT * FROM products WHERE id=123 DBReplica-->>Server: 返回商品数据 (v1) Server-->>Edge: 返回 HTML (v1),并缓存 Edge-->>User: 返回 HTML (v1) Note over User, DBPrimary: 用户发起更新操作 User->>Server: POST /product/123/update Server->>DBPrimary: UPDATE products SET ... WHERE id=123 (数据变为 v2) DBPrimary-->>Server: 更新成功 Server-->>User: 返回成功响应 Note over DBPrimary, DBReplica: 数据库主从复制,存在延迟 (e.g., 500ms) User->>Edge: GET /product/123 (立即刷新) Edge-->>User: 返回缓存的 HTML (v1) - **问题点1:命中旧缓存** Note over Server, DBReplica: 假设 ISR 缓存过期 (stale-while-revalidate) Server->>DBReplica: (后台) SELECT * FROM products WHERE id=123 DBReplica-->>Server: 返回商品数据 (v1) - **问题点2:读到旧数据** Server->>Edge: (后台) 重新生成页面并更新缓存为 HTML (v1)
问题有两个层面:
- 客户端或 CDN 边缘节点的浏览器缓存。
- ISR 服务端自身的缓存,在后台再生时因为读取了尚未同步的从库而导致缓存被“污染”。
第一个问题相对容易解决,可以通过设置合理的 Cache-Control
头来处理。第二个问题则棘手得多,它是系统架构层面的固有矛盾。单纯地让 ISR 再生时去读主库,会违背读写分离的初衷,让主库承担大量本该由从库处理的读取压力。
初步构想与技术选型
我们的解决方案必须是外科手术式的,只为“刚刚写入数据的用户”开辟一条特殊通道,而其他所有用户依然享受 ISR 带来的极致性能。这意味着 ISR 服务需要一种机制来识别出这些特殊用户,并为他们提供临时的强一致性读取。
核心思路:
- 版本化数据: 每次对核心数据的写操作,不仅更新业务数据,还要更新一个版本标识。
- 颁发“一致性令牌”: 写操作成功后,向用户的客户端(浏览器)颁发一个有时效性的、包含新版本标识的“令牌”。
- 智能路由: ISR 服务在处理页面请求时,检查是否存在此令牌。
- 无令牌或令牌无效: 走标准 ISR 流程,服务缓存的静态内容。
- 有令牌且令牌版本高于缓存版本: 绕过 ISR 缓存和从库,直接请求主库数据,为该用户进行一次性的动态渲染(SSR),并将结果返回。
- 有令牌但令牌版本不高于缓存版本: 说明 ISR 缓存已经追上了用户的写入,走标准流程即可。
这个方案本质上是在一个最终一致的系统上,为特定会话开辟了一个短暂的强一致性窗口。
支撑该方案的技术组件:
- Web 服务: Node.js + Express.js,轻量且适合 I/O 密集型任务。
- 前端组件: Lit。其
@lit-labs/ssr
包提供了成熟的服务端渲染能力。 - 缓存与版本存储: Redis。速度极快,适合存储 ISR 页面缓存和数据版本号。
- 一致性令牌: 使用
cookie
承载一个简单的、签名的 JSON 对象,避免引入 JWT 库的复杂性。
步骤化实现:构建一个感知一致性的 ISR 服务
我们从零开始构建这个服务,一步步加入解决问题的逻辑。
1. 基础环境与数据库模拟
首先,我们需要一个能模拟读写分离和复制延迟的数据库模块。在真实项目中,这会由一个复杂的数据库连接池管理器实现,但这里我们用一个简化的模型来聚焦问题。
./src/database.js
import { setTimeout } from 'timers/promises';
// 模拟一个内存数据库
const primaryStore = new Map();
const replicaStore = new Map();
// 初始化一些数据
primaryStore.set('product:123', {
id: '123',
name: '高端人体工学椅',
description: '初始版本描述,支撑你的脊椎。',
version: 1,
});
replicaStore.set('product:123', { ...primaryStore.get('product:123') });
const REPLICATION_LAG_MS = 1500; // 模拟 1.5 秒的复制延迟
/**
* 写入主库。
* @param {string} key
* @param {object} value
*/
async function writeToPrimary(key, value) {
console.log(`[DB_PRIMARY] WRITE: key=${key}, version=${value.version}`);
primaryStore.set(key, value);
// 模拟异步复制
setTimeout(REPLICATION_LAG_MS).then(() => {
console.log(`[DB_REPLICA] REPLICATE: key=${key} after ${REPLICATION_LAG_MS}ms lag.`);
replicaStore.set(key, { ...value });
});
}
/**
* 从主库读取,用于需要强一致性的场景。
* @param {string} key
* @returns {Promise<object|undefined>}
*/
async function readFromPrimary(key) {
console.log(`[DB_PRIMARY] READ: key=${key}`);
return primaryStore.get(key);
}
/**
* 从从库读取,用于常规读取,可能存在延迟。
* @param {string} key
* @returns {Promise<object|undefined>}
*/
async function readFromReplica(key) {
console.log(`[DB_REPLICA] READ: key=${key}`);
return replicaStore.get(key);
}
export const db = {
write: writeToPrimary,
readPrimary: readFromPrimary,
readReplica: readFromReplica,
};
这个模块清晰地模拟了写主、读从以及它们之间的延迟。这是我们后续所有逻辑的基础。
2. Lit 组件与服务端渲染
我们需要一个简单的 Lit 组件来展示商品信息。
./src/components/product-page.js
import { LitElement, html } from 'lit';
import { unsafeHTML } from 'lit/directives/unsafe-html.js';
export class ProductPage extends LitElement {
static properties = {
product: { type: Object },
};
constructor() {
super();
this.product = { name: 'Loading...', description: '' };
}
// 在服务端渲染时,我们不希望生成 Shadow DOM,以利于 SEO 和首屏渲染
createRenderRoot() {
return this;
}
render() {
return html`
<article>
<h1>${this.product.name}</h1>
<p>版本号: ${this.product.version}</p>
<div>${unsafeHTML(this.product.description.replace(/\n/g, '<br>'))}</div>
</article>
`;
}
}
customElements.define('product-page', ProductPage);
接着是服务端渲染的逻辑。@lit-labs/ssr
让我们能够将 Lit 组件实例渲染成 HTML 字符串。
./src/renderer.js
import { render } from '@lit-labs/ssr';
import { collectResult } from '@lit-labs/ssr/lib/render-result.js';
import './components/product-page.js';
/**
* 渲染 Lit 组件为 HTML 字符串
* @param {object} productData 商品数据
* @returns {Promise<string>}
*/
export async function renderProductPage(productData) {
const pageComponent = html`<product-page .product=${productData}></product-page>`;
const renderedResult = render(pageComponent);
const htmlContent = await collectResult(renderedResult);
// 构建一个完整的 HTML 文档
return `
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>${productData.name}</title>
<style>
body { font-family: sans-serif; line-height: 1.6; max-width: 800px; margin: 2rem auto; padding: 0 1rem; background: #f9f9f9; }
article { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { color: #333; }
</style>
</head>
<body>
${htmlContent}
</body>
</html>
`;
}
3. 核心:带会话一致性的 ISR 中间件
这是整个方案的核心。我们将创建一个 Express 中间件,它封装了缓存、后台再生、以及我们新设计的会话一致性检查逻辑。
./src/isr-middleware.js
import { createClient } from 'redis';
import crypto from 'crypto';
import { db } from './database.js';
import { renderProductPage } from './renderer.js';
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.on('error', (err) => console.error('Redis Client Error', err));
await redisClient.connect();
const ISR_TTL_SECONDS = 10; // 缓存有效期 10 秒
const CONSISTENCY_COOKIE_NAME = 'x-consistency-token';
const COOKIE_SECRET = 'a-very-strong-secret-key-for-hmac'; // 生产环境应使用环境变量
function sign(data) {
const hmac = crypto.createHmac('sha256', COOKIE_SECRET);
hmac.update(data);
return hmac.digest('hex');
}
export function createIsrMiddleware({ getProductId }) {
return async (req, res, next) => {
const productId = getProductId(req);
if (!productId) return next();
const cacheKey = `isr:product:${productId}`;
// --- 会话一致性检查 ---
const consistencyToken = req.cookies[CONSISTENCY_COOKIE_NAME];
let userConsistencyState = null;
if (consistencyToken) {
try {
const [payloadB64, signature] = consistencyToken.split('.');
if (sign(payloadB64) === signature) {
const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString());
// 检查 token 是否对应当前商品且未过期
if (payload.pid === productId && payload.exp > Date.now()) {
userConsistencyState = { version: payload.v };
console.log(`[ISR] Valid consistency token found for user. Required version: ${payload.v}`);
}
}
} catch (e) {
console.error('[ISR] Error parsing consistency token:', e);
}
}
// --- 检查结束 ---
try {
const cachedData = await redisClient.get(cacheKey);
if (cachedData) {
const { html, version, timestamp } = JSON.parse(cachedData);
const isStale = (Date.now() - timestamp) / 1000 > ISR_TTL_SECONDS;
// 检查是否需要为特定用户提供强一致性视图
if (userConsistencyState && userConsistencyState.version > version) {
console.log(`[ISR] User requires version ${userConsistencyState.version}, cache has ${version}. Forcing live render from PRIMARY DB.`);
const product = await db.readPrimary(`product:${productId}`);
const liveHtml = await renderProductPage(product);
return res.send(liveHtml);
}
// 对于其他用户,正常提供缓存
res.send(html);
if (isStale) {
console.log(`[ISR] Cache is stale for ${cacheKey}. Triggering background regeneration.`);
// 标记正在重新生成,防止并发的再生请求。这里的锁实现可以更健壮。
redisClient.set(`${cacheKey}:lock`, '1', { EX: 20 });
// 注意:此处不 await,后台执行
regenerate(productId, cacheKey).finally(() => {
redisClient.del(`${cacheKey}:lock`);
});
}
return;
}
// 缓存未命中,首次渲染
console.log(`[ISR] Cache miss for ${cacheKey}. Rendering for the first time.`);
const product = await db.readPrimary(`product:${productId}`); // 首次加载从主库获取,确保初始数据准确
const html = await renderProductPage(product);
const cachePayload = JSON.stringify({
html,
version: product.version,
timestamp: Date.now(),
});
await redisClient.set(cacheKey, cachePayload);
res.send(html);
} catch (error) {
console.error(`[ISR] Error processing request for product ${productId}:`, error);
res.status(500).send('Server Error');
}
};
}
async function regenerate(productId, cacheKey) {
try {
console.log(`[ISR_BG] Regenerating ${cacheKey} from REPLICA DB.`);
// 后台再生时,我们从从库读取,这是读写分离的核心价值
const product = await db.readReplica(`product:${productId}`);
if (!product) {
console.warn(`[ISR_BG] Product ${productId} not found during regeneration.`);
return;
}
const html = await renderProductPage(product);
const cachePayload = JSON.stringify({
html,
version: product.version,
timestamp: Date.now(),
});
await redisClient.set(cacheKey, cachePayload);
console.log(`[ISR_BG] Successfully regenerated and updated cache for ${cacheKey} with version ${product.version}.`);
} catch (error) {
console.error(`[ISR_BG] Failed to regenerate cache for ${productId}:`, error);
}
}
这个中间件的逻辑很长,但每一步都至关重要:
- 令牌解析: 安全地解析和验证签名 cookie。
- 版本比较: 这是决策的核心。如果用户令牌的版本号大于缓存页面的版本号,就触发“强一致性路径”。
- 强一致性路径: 直接调用
db.readPrimary()
并进行实时 SSR。这是一个逃生通道。 - 标准路径: 服务缓存,并检查是否
stale
。 - 后台再生: 如果
stale
,则启动一个非阻塞的regenerate
函数,它从db.readReplica()
读取数据,这确保了常规流量不会冲击主库。
4. 组装 Express 服务
最后,我们将所有部分组合成一个完整的 Web 服务。
./src/server.js
import express from 'express';
import cookieParser from 'cookie-parser';
import crypto from 'crypto';
import { db } from './database.js';
import { createIsrMiddleware } from './isr-middleware.js';
const app = express();
const PORT = 3000;
app.use(express.json());
app.use(cookieParser());
// 首页,用于引导
app.get('/', (req, res) => {
res.send('<h1>ISR 会话一致性演示</h1><p><a href="/product/123">查看商品 123</a></p>');
});
// 商品详情页,应用我们的 ISR 中间件
app.get(
'/product/:id',
createIsrMiddleware({
getProductId: (req) => req.params.id,
})
);
// 更新商品的 API 接口
app.post('/api/product/:id/update', async (req, res) => {
const { id } = req.params;
const { description } = req.body;
if (!description) {
return res.status(400).json({ error: 'Description is required.' });
}
try {
const currentProduct = await db.readPrimary(`product:${id}`);
if (!currentProduct) {
return res.status(404).json({ error: 'Product not found.' });
}
const newVersion = currentProduct.version + 1;
const updatedProduct = {
...currentProduct,
description,
version: newVersion,
};
await db.write(`product:${id}`, updatedProduct);
// --- 关键:颁发会话一致性令牌 ---
const COOKIE_SECRET = 'a-very-strong-secret-key-for-hmac'; // 与中间件保持一致
const tokenPayload = {
pid: id,
v: newVersion,
exp: Date.now() + 60 * 1000, // 令牌 60 秒后过期
};
const payloadB64 = Buffer.from(JSON.stringify(tokenPayload)).toString('base64');
const signature = crypto.createHmac('sha256', COOKIE_SECRET).update(payloadB64).digest('hex');
res.cookie('x-consistency-token', `${payloadB64}.${signature}`, {
httpOnly: true,
path: `/product/${id}`,
maxAge: 60 * 1000,
});
res.json({ success: true, newVersion });
} catch (error) {
console.error(`[API] Error updating product ${id}:`, error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
console.log('---');
console.log('场景测试指南:');
console.log('1. 打开 http://localhost:3000/product/123 查看初始页面 (v1)。');
console.log('2. 在另一个终端执行: curl -X POST -H "Content-Type: application/json" -d \'{"description":"这是第一次更新!"}\' http://localhost:3000/api/product/123/update');
console.log('3. curl 会收到一个 Set-Cookie 头。立即用带有该 cookie 的浏览器刷新页面,你会看到更新后的内容 (v2)。');
console.log('4. 如果用无痕模式或另一个没有 cookie 的浏览器访问,在 1.5 秒复制延迟内,你可能会看到旧内容 (v1)。');
console.log('---');
});
在更新接口 /api/product/:id/update
中,完成主库写入后,我们精心构造并下发了 x-consistency-token
这个 httpOnly
cookie。它的作用域被严格限制在被修改的商品路径下,且生命周期很短,确保了安全性和最小化影响。
方案的局限性与未来迭代路径
这个方案有效地解决了在 ISR 和读写分离架构下的会话一致性问题,但它并非银弹。在真实项目中,我们必须清楚地认识到它的边界和代价。
首先,系统的复杂性显著增加了。我们引入了 Redis 作为缓存和版本存储,增加了令牌的生成和验证逻辑,并且让请求处理路径变得分支化。维护这份复杂性是有成本的。
其次,对于写操作异常频繁的“热点”数据,这种模式可能会导致大量请求绕过 ISR 缓存,直接冲击主数据库,从而削弱了读写分离的收益。一个潜在的优化是在“强一致性路径”上增加一个极短生命周期(如1-2秒)的缓存层,以应对用户在更新后短时间内的连续刷新行为。
最后,令牌的实现方式可以进一步加固。虽然签名的 cookie 已经能防止篡改,但在更复杂的微服务环境中,可能会考虑使用 JWT,并结合 API 网关进行统一验证,以更好地解耦身份验证与业务逻辑。
此方案的核心价值在于它体现了一种架构上的妥协艺术:我们没有试图去构建一个处处都强一致的系统,那样的代价是不可接受的。相反,我们拥抱了 BASE 理论下的最终一致性作为系统常态,然后像做外科手术一样,为最影响用户体验的那个特定场景——“读己之写”——提供了临时的、有状态的一致性保障。