You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

同步JS代码中浏览器多窗口是否引发竞态条件?如何实现跨窗口线程安全?

你的假设完全正确

现代浏览器的每个标签页/窗口都是独立的进程(或至少是独立的JS执行线程),它们共享localStorage但各自的JS运行环境相互隔离。单窗口里的单线程特性在多窗口场景下完全失效——多个窗口会同时执行你的同步逻辑,导致localStorage锁被多个窗口同时获取,最终触发重复累加的问题。

多窗口下的线程安全实现方案

1. 强化localStorage锁机制(结合时间戳与storage事件)

你的现有锁只处理了单窗口的情况,多窗口下需要通过时间戳竞争+跨窗口事件通知来确保只有一个窗口持有有效锁:

  • 锁的时间戳不仅用来超时释放,还要作为竞争依据:当多个窗口同时尝试获取锁时,只有写入最早时间戳的窗口才保留锁,其他窗口检测到锁的时间戳比自己写入的更早时,主动放弃。
  • 利用storage事件监听锁的变化:当其他窗口修改了锁的内容,当前窗口可以立即感知并判断自己是否还持有有效锁。

修改后的核心逻辑示例:

export async function mergeCachedGameStats(supabaseClient: SupabaseClient) {
    const lockKey = "merge_cached_game_stats_lock";
    let currentLockTimestamp: string | null = null;

    // 清理过期锁
    const existingLock = localStorage.getItem(lockKey);
    if (existingLock && Date.now() - parseInt(existingLock) > 15_000) {
        localStorage.removeItem(lockKey);
    }

    // 尝试获取锁:写入当前时间戳
    currentLockTimestamp = Date.now().toString();
    localStorage.setItem(lockKey, currentLockTimestamp);

    // 检查是否真的拿到锁(可能被其他窗口覆盖)
    const latestLock = localStorage.getItem(lockKey);
    if (latestLock !== currentLockTimestamp) {
        return;
    }

    // 监听storage事件,防止锁被其他窗口抢占
    function handleStorageChange(e: StorageEvent) {
        if (e.key === lockKey && e.newValue !== currentLockTimestamp) {
            throw new Error("Lock preempted by another window");
        }
    }
    window.addEventListener('storage', handleStorageChange);

    try {
        // 原有业务逻辑
        const { data, error } = await supabaseClient?.auth.getSession();
        if (error) throw error;
        const loggedInUser = data.session?.user;

        for (const key of Object.values(gameStatsKeys)) {
            const cachedKey = key + "_cached";
            if (!localStorage.getItem(cachedKey)) continue;

            const gameStats = await loadCombinedGameStats(key, supabaseClient);

            if (loggedInUser) {
                const { error } = await supabaseClient
                    .from("user_profiles")
                    .update({ [key]: JSON.stringify(gameStats) })
                    .eq("id", loggedInUser.id);
                if (error) throw error;
            } else {
                localStorage.setItem(key, JSON.stringify(gameStats));
            }
            localStorage.removeItem(cachedKey);
        }
    } catch (error) {
        console.error(error);
    } finally {
        // 只有当前锁有效时才释放
        if (localStorage.getItem(lockKey) === currentLockTimestamp) {
            localStorage.removeItem(lockKey);
        }
        window.removeEventListener('storage', handleStorageChange);
    }
}

2. 数据库层面加乐观锁(终极保障)

前端锁只能解决大部分场景,但网络延迟、窗口意外崩溃等极端情况仍可能导致并发问题。最可靠的方式是在数据库层添加乐观锁:

  • 给user_profiles表添加一个version字段(整数类型,默认0)。
  • 更新时,带上当前记录的version值,只有当数据库中的version与传入值匹配时才执行更新,同时version自增:
    // 假设loadCombinedGameStats返回当前数据及对应version
    const { gameStats, version } = await loadCombinedGameStats(key, supabaseClient);
    
    const { error } = await supabaseClient
      .from("user_profiles")
      .update({ 
        [key]: JSON.stringify(gameStats),
        version: version + 1 
      })
      .eq("id", loggedInUser.id)
      .eq("version", version); // 仅version匹配时更新
    
    if (error) {
        // 更新失败说明有并发操作,重试整个流程
        await mergeCachedGameStats(supabaseClient);
        return;
    }
    
    这样即使多个窗口同时发起请求,只有第一个能成功更新,其他会失败,通过重试即可保证数据一致性。

3. 可选:限制同步操作频率

可以在localStorage中记录上次同步的时间,短时间内重复调用直接返回,减少竞争概率:

const lastSyncKey = "last_merge_sync_time";
const lastSync = localStorage.getItem(lastSyncKey);
if (lastSync && Date.now() - parseInt(lastSync) < 5000) {
    // 5秒内已同步过,直接返回
    return;
}
// 业务逻辑执行完后更新同步时间
localStorage.setItem(lastSyncKey, Date.now().toString());

内容的提问来源于stack exchange,提问作者Florian Walther

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.06 13:01:22