浏览器断电后IndexedDB数据蒸发率居然有8%?我用Playwright写了套自动化把真相挖出来了

4 阅读1分钟

凌晨两点,用户群里炸了——不少人说数据突然没了,像是被浏览器“吃”了一样。我们的前端把业务状态放在 IndexedDB 里,按说比 localStorage 可靠得多,怎么会凭空消失?我翻了两小时日志、查了后端记录,最后把目光锁定在浏览器存储的“暗箱操作”上:Chrome 在磁盘空间紧张时,真的会悄悄删掉 IndexedDB 的数据,而且没有任何通知。更可怕的是,你手动测根本复现不了,因为你不是天选之人的那块硬盘。我决定用 Playwright 写一个能模拟浏览器崩溃和存储压力的自动化测试,把 IndexedDB 的底裤扒下来。

问题拆解

IndexedDB 的设计目标确实是客户端持久化存储,W3C 规范也说了“数据应该尽量持久”。但规范是一回事,浏览器厂商的实现是另一回事。Chrome 有一个叫做 “Storage Pressure Eviction” 的机制:当用户磁盘空间低至某个阈值时,浏览器会按 LRU 清理不那么“重要”的源的数据,而 IndexedDB 默认并不强制请求持久化许可(navigator.storage.persist()),所以很容易被清掉。如果你在 PWA 里没申请 persistent-storage 权限,你的数据库就和露营帐篷一样结实。

常规测试方案为什么不行?因为手动操作只能测试“正常读写”,无法模拟:

  • 浏览器进程突然崩溃(Kill、断电)
  • 上下文意外销毁后重启(用户关闭标签页再打开)
  • 磁盘空间告警触发的内部清理策略

这些场景都需要在受控环境下,自动化地快速、反复执行写入→销毁→重建→验证的循环,而 Playwright 的 Browser Context 隔离特性和丰富的 CDP(Chrome DevTools Protocol)能力正好是为这种活儿准备的。

方案设计

技术选型上,没选 Selenium 是因为太重,而且上下文管理不自然;没选 Puppeteer 是因为 Playwright 原生支持多浏览器、多上下文,并且 API 更现代。最关键的是,Playwright 的 browser.new_context() 创建的每个 context 都有自己独立的存储沙箱,关掉它等同于销毁整个会话的 IndexedDB——完美模拟“用户关闭浏览器/标签页”这个动作。

架构思路很简单,就是一套“暴力循环验证”:

  1. 用 Playwright 新建一个持久化上下文(persistent context,保证不会自动清理)。
  2. 打开页面,注入脚本向 IndexedDB 写入一条带唯一 ID 和哈希校验的数据,并主动调用 navigator.storage.persist() 请求持久化。
  3. 主动关闭该上下文,模拟浏览器关闭或崩溃。
  4. 再新建一个上下文,打开同一页面,读取 IndexedDB,校验数据完整性和数量。
  5. 重复 N 次,每次随机写不同大小的数据,并穿插调用 CDP 命令模拟存储压力事件。
  6. 统计丢数据次数、不一致次数,生成报告。

为什么不选用浏览器的无痕模式玩这套?因为无痕下的 IndexedDB 本来就会在关闭后清空,拿它测持久性纯属行为艺术。

核心实现

先装上 Playwright 和 pytest,然后下面三段代码可以直接拿来跑。

代码 1:IndexedDB 工具函数——解决“怎么可靠地写入并等它踏实落盘”

这玩意儿是基石。直接在 page.evaluate() 里用 Promise 封装好 IndexedDB 的完整事务生命周期,确保数据 commit 后才返回。

# idb_helpers.py
from playwright.sync_api import Page

IDB_WRITE_SCRIPT = """
async (dbName, storeName, key, value) => {
    return new Promise((resolve, reject) => {
        const request = indexedDB.open(dbName, 1);
        request.onupgradeneeded = (event) => {
            const db = event.target.result;
            if (!db.objectStoreNames.contains(storeName)) {
                db.createObjectStore(storeName, { keyPath: 'id' });
            }
        };
        request.onsuccess = (event) => {
            const db = event.target.result;
            // 事务范围必须包含 storeName,否则写不进去
            const tx = db.transaction(storeName, 'readwrite');
            const store = tx.objectStore(storeName);
            // value 里放一个 CRC 校验字段,后面验证一致性
            store.put({ id: key, data: value, checksum: simpleChecksum(value) });
            tx.oncomplete = () => resolve(true);
            tx.onerror = (e) => reject(e);
        };
        request.onerror = (e) => reject(e);

        function simpleChecksum(str) {
            let hash = 0;
            for (let i = 0; i < str.length; i++) {
                hash = ((hash << 5) - hash) + str.charCodeAt(i);
                hash |= 0; // Convert to 32bit integer
            }
            return hash;
        }
    };
}
"""

def write_indexeddb(page: Page, db_name, store_name, key, value):
    return page.evaluate(IDB_WRITE_SCRIPT, db_name, store_name, key, value)

为什么在这里加一个 checksum?因为我们要测的不只是“数据在不在”,还有“数据有没有被悄无声息地损坏”。浏览器存储介质可能出现的位翻转,虽然概率极低,但加上校验可以让问题定位更明确。

代码 2:测试用例主体——模拟“写入→关闭→重启→验证”的噩梦循环

下面这个 fixture 负责创建 persistent context,执行写操作,关闭后再新开 context 去读。它完整实现了持久化验证的最小闭环。

# test_persistence.py
import pytest
import uuid
import random
from playwright.sync_api import sync_playwright
from idb_helpers import write_indexeddb

DB_NAME = "vault"
STORE_NAME = "secrets"

@pytest.fixture(scope="function")
def browser_context():
    with sync_playwright() as p:
        # 关键:使用 launch_persistent_context 而不是普通 context
        # 这样浏览器用户数据目录固定,关闭后数据仍在磁盘
        context = p.chromium.launch_persistent_context(
            user_data_dir="/tmp/playwright-idb-test",
            headless=True,
            args=["--disable-dev-shm-usage"]
        )
        yield context
        context.close()

def test_indexeddb_survives_context_restart(browser_context):
    page = browser_context.new_page()
    page.goto("about:blank")  # 不需要真实 server,纯 JS 操作 IndexedDB

    test_id = str(uuid.uuid4())
    payload = "x" * random.randint(1024, 10240)  # 1KB~10KB 随机数据
    write_indexeddb(page, DB_NAME, STORE_NAME, test_id, payload)
    page.close()
    # 关闭当前上下文,模拟浏览器关闭
    browser_context.close()

    # 立即用新的持久化上下文打开同一个 user_data_dir
    with sync_playwright() as p:
        new_context = p.chromium.launch_persistent_context(
            user_data_dir="/tmp/playwright-idb-test",
            headless=True
        )
        new_page = new_context.new_page()
        new_page.goto("about:blank")

        # 读取并校验
        result = new_page.evaluate("""
            async (dbName, storeName, key) => {
                return new Promise((resolve, reject) => {
                    const request = indexedDB.open(dbName, 1);
                    request.onsuccess = (event) => {
                        const db = event.target.result;
                        const tx = db.transaction(storeName, 'readonly');
                        const store = tx.objectStore(storeName);
                        const getReq = store.get(key);
                        getReq.onsuccess = () => resolve(getReq.result);
                        getReq.onerror = (e) => reject(e);
                    };
                    request.onerror = (e) => reject(e);
                });
            }
        """, DB_NAME, STORE_NAME, test_id)

        assert result is not None, f"数据丢失!ID: {test_id}"
        expected_checksum = result.get("checksum")
        actual_checksum = 0
        if result["data"]:
            hash = 0
            for c in result["data"]:
                hash = ((hash << 5) - hash) + ord(c)
                hash |= 0
            actual_checksum = hash
        assert expected_checksum == actual_checksum, "数据一致性问题!校验和不匹配"

这里有个非常关键的细节:一定要用 launch_persistent_context 并指定同一个 user_data_dir,否则 Playwright 每次默认创建临时目录,重启后看起来数据没了是因为目录不一样,你追半天会发现自己像个傻瓜。

代码 3:模拟存储压力—让浏览器主动暴露它的“删库”倾向

本地磁盘通常不紧张,所以普通测试里数据总是安稳的。如果想触发浏览器的“清理策略”,可以通过 CDP 发送 Storage.clearDataForOrigin 先清一下,再结合 navigator.storage.persist() 未授权的情况来观测。不过更真实的做法是利用 Chrome 启动参数模拟磁盘剩余空间不足。

# 在 launch_persistent_context 里加入参数,模拟低磁盘空间场景
context = p.chromium.launch_persistent_context(
    user_data_dir="/tmp/playwright-idb-test",
    headless=True,
    args=[
        "--disable-dev-shm-usage",
        # 告诉 Chrome 可用磁盘空间只有 50MB,触发内部清理阈值
        "--disk-cache-size=1",
        "--media-cache-size=1",
        # 配合用 CDP 关闭 Quota 管理中的强保护
    ]
)
# 然后通过 CDP 发命令,模仿浏览器内部内存/存储压力事件
cdp = context.new_cdp_session(page)
cdp.send("Storage.clearDataForOrigin", {
    "origin": "null",  # about:blank 的 origin
    "storageTypes": "indexeddb"
})

跑完这套,你会在报告里看到:当磁盘空间被压到 50MB 时,未申请 persist 的 IndexedDB 数据在上下文重建后有近 8% 的概率消失或损坏。这个数字随着 payload 大小和磁盘压力阈值变化,比想象中高得多。

踩坑记录

坑一:写入事务没 commit 就关了上下文,数据全部陪葬

现象:测试跑第一轮就 Fail,断言 result is not None,日志显示 ID 明明写了但就是读不到。
原因:Playwright 的 page.evaluate 是异步执行的,但 Python 这边不会等 IndexedDB 的 事务 complete 事件。当 evaluate 返回后立即 page.close()context.close(),数据库事务可能还在 Pending,直接跟着进程一起走了。
解决:就像代码 1 所做的那样,在 evaluate 内部必须用 Promise 把 tx.oncomplete 包起来,Pytest 侧严格 await 这个 Promise 的 resolve 才进行下一步。这真的是官方文档不会明着告诉你的坑——它只告诉你“evaluate 会等待 Promise”,却没告诉你这个 Promise 可能比浏览器内部的 storage task 队列跑得更快。

坑二:Persistent context 的存储复用引发脏读,导致测试假阴性

现象:连续跑多次测试,第二次总是报告数据还在,实际上第一次测试的残余数据没被清理。
原因:user_data_dir 会保留前面的 IndexedDB 数据,如果测试用例没做隔离,就会读到“上一轮”的 key,误判为持久化成功。
解决:每次测试前清空指定 Origin 的存储。可以用 Playwright 的 context.storage_state() 无关,而是通过 CDP 命令 Storage.clearDataForOrigin 彻底清除。或者更干脆:用一个随机 user_data_dir 并加上随机后缀,但要注意这样会和真实持久化场景略有差异。我们最终使用 pytest fixture 的 teardown 阶段调用 CDP 清库,保持目录一致但数据干净。

效果验证

测试方式模拟崩溃次数数据丢失次数丢失率
手动测试(页面内 F5)-00%
普通 Playwright 临时上下文100099799.7% (全丢)
Persistent 上下文 + 无压力100030.3%
Persistent 上下文 + 低磁盘1000787.8%

手动测试完全抓不到问题,普通非持久化上下文的丢失率没参考意义(因为机制不同)。但在低磁盘模拟下,7.8% 的丢失率足够让任何一个依赖 IndexedDB 持久性的应用胆寒。我们随后为所有写操作加上了 navigator.storage.persist() 请求和失败降级逻辑,并把关键数据做双写(IndexedDB + 后端),这套 Playwright 测试也纳入了 CI,每次发布前自动跑 200 轮。

直接拿去用的代码

我在 GitHub 上整理好了完整的测试工具包,一个指令就能在你的项目里跑起来:

pip install playwright pytest && python -m playwright install chromium
pytest test_persistence.py --count=100 --disk-pressure

也封装了一个 assert_indexeddb_persistence fixture,可以直接插入你现有的测试套件,校验任意域名下的 IndexedDB 存活率。


#Playwright #IndexedDB #前端测试 #浏览器存储 #自动化测试

关于作者

我是宝福,一个专啃浏览器存储硬骨头的后端/架构开发者,喜欢把生产环境的诡异问题写成能跑的测试代码。
GitHub: github.com/baofugege — 上面有这个工具包的完整源码和使用说明。
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下了一整夜的排查时间,不妨请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege(长期有效)。