凌晨两点,用户群里炸了——不少人说数据突然没了,像是被浏览器“吃”了一样。我们的前端把业务状态放在 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——完美模拟“用户关闭浏览器/标签页”这个动作。
架构思路很简单,就是一套“暴力循环验证”:
- 用 Playwright 新建一个持久化上下文(
persistentcontext,保证不会自动清理)。 - 打开页面,注入脚本向 IndexedDB 写入一条带唯一 ID 和哈希校验的数据,并主动调用
navigator.storage.persist()请求持久化。 - 主动关闭该上下文,模拟浏览器关闭或崩溃。
- 再新建一个上下文,打开同一页面,读取 IndexedDB,校验数据完整性和数量。
- 重复 N 次,每次随机写不同大小的数据,并穿插调用 CDP 命令模拟存储压力事件。
- 统计丢数据次数、不一致次数,生成报告。
为什么不选用浏览器的无痕模式玩这套?因为无痕下的 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) | - | 0 | 0% |
| 普通 Playwright 临时上下文 | 1000 | 997 | 99.7% (全丢) |
| Persistent 上下文 + 无压力 | 1000 | 3 | 0.3% |
| Persistent 上下文 + 低磁盘 | 1000 | 78 | 7.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(长期有效)。