构建基于 Lit 的 ISR 渲染服务以解决读写分离架构下的会话一致性难题


项目上线后,我们收到了第一个紧急工单:“我刚刚更新了商品描述,刷新页面后看到的还是旧内容,要等一分多钟才能看到变化!”。这个反馈并不意外,它精准地击中了我们新架构的阿喀琉斯之踵。为了应对日益增长的读取压力,我们将商品详情页从传统的服务端渲染(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)

问题有两个层面:

  1. 客户端或 CDN 边缘节点的浏览器缓存。
  2. ISR 服务端自身的缓存,在后台再生时因为读取了尚未同步的从库而导致缓存被“污染”。

第一个问题相对容易解决,可以通过设置合理的 Cache-Control 头来处理。第二个问题则棘手得多,它是系统架构层面的固有矛盾。单纯地让 ISR 再生时去读主库,会违背读写分离的初衷,让主库承担大量本该由从库处理的读取压力。

初步构想与技术选型

我们的解决方案必须是外科手术式的,只为“刚刚写入数据的用户”开辟一条特殊通道,而其他所有用户依然享受 ISR 带来的极致性能。这意味着 ISR 服务需要一种机制来识别出这些特殊用户,并为他们提供临时的强一致性读取。

核心思路:

  1. 版本化数据: 每次对核心数据的写操作,不仅更新业务数据,还要更新一个版本标识。
  2. 颁发“一致性令牌”: 写操作成功后,向用户的客户端(浏览器)颁发一个有时效性的、包含新版本标识的“令牌”。
  3. 智能路由: 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 理论下的最终一致性作为系统常态,然后像做外科手术一样,为最影响用户体验的那个特定场景——“读己之写”——提供了临时的、有状态的一致性保障。


  目录