同步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
相关产品推荐
相关产品推荐

