【Bug已解决】New updates remove pinned chats 解决方案
【Bug已解决】New updates remove pinned chats 解决方案
原始报错:New updates remove pinned chats 场景:桌面/移动客户端在自动或手动更新到新版本后,用户此前"置顶/固定"(pin)的会话列表被清空,所有固定项丢失。 关键词:本地持久化、存储迁移、schema 版本化、用户偏好保护、升级不丢数据。
一、现象长什么样
用户在前一天把 5 个常用会话固定(pin)在列表顶部,第二天客户端推送了一次小版本更新,重启后发现:
- 固定区(pinned section)整个消失;
- 设置里"固定会话"数量为 0;
- 本地数据库/配置文件里对应的
pinned字段或表不存在; - 重新固定可以工作,但一旦再次更新又会被清掉。 这是一个典型的"升级破坏本地用户数据"问题,而不是网络同步问题——因为离线环境下复现率一样高,而且新固定的数据在下次更新前一直稳定。
二、背景:为什么一次"正常更新"会动到本地数据
现代客户端通常把用户偏好(主题、窗口布局、固定会话、快捷键)和领域数据(聊天记录、草稿)混在同一个本地存储里。存储形态可能是:
- 一个 SQLite 数据库文件(如
app.db); - 一个 JSON 配置文件(如
state.json、preferences.json); - 一个平台特定目录(如
~/Library/Application Support/App/prefs.sqlite)。 更新有两种常见做法:
- 覆盖式更新:新版本直接把可执行文件和内置的"初始数据库"复制到安装目录。如果初始数据库里没有用户的固定项,且迁移逻辑没跑,用户数据就被"空白初始库"覆盖。
- 懒初始化更新:新版本启动后发现本地没有"符合新 schema 的表",于是执行
CREATE TABLE或重置整个存储。如果旧数据的表名/字段名和新的不一致,且代码用"不存在就重建"的策略,旧表被丢弃。 第二种最隐蔽:代码逻辑是"为了兼容新版本,我重建一张干净的表",但它没有先尝试迁移旧表,于是旧表连同里面的固定项一起被DROP后重建。
三、为什么固定项会丢(根因分析)
把问题拆开看,通常有四类根因:
- 存储无版本号:本地文件/库没有记录"我是哪个 schema 版本创建的"。新代码无法判断"这是旧数据,需要迁移"还是"这是全新安装,可以直接初始化"。
- 迁移逻辑缺失或顺序错误:新版本新增了
pinned_chats表,但onUpgrade里只写了DROP + CREATE,没有ALTER TABLE或数据搬运。 - 固定项存错位置:固定项被存在了"缓存目录"或"临时目录",而更新过程会清理这些目录(例如安装器把
Cache/当可丢弃物清掉)。 - 并发写覆盖:更新后首次启动,迁移线程和默认初始化线程竞争,默认初始化先写完空文件,迁移线程读到的是已经被清空的存储。 下面用一个最小可运行模型复现第 2 类根因,并给出正确的迁移实现。
四、最小可运行复现
下面这段脚本模拟"没有版本号 + 重建即丢数据"的错误写法。运行后会看到固定项在"更新"后变成空。
import json import os import tempfile # ---------- 模拟本地存储 ---------- def store_path(): return os.path.join(tempfile.gettempdir(), "bad_app_state.json") def first_launch_seed(): """旧版本首次启动:写入用户固定项。""" state = { # 注意:没有 schema_version 字段 "pinned_chats": ["chat_8821", "chat_1043", "chat_5572"], "theme": "dark", } with open(store_path(), "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) def bad_upgrade(): """错误的新版本启动逻辑: 发现 'pinned_chats_v2' 不存在,就当作全新安装,重置整个 state。""" path = store_path() try: with open(path, "r", encoding="utf-8") as f: state = json.load(f) except FileNotFoundError: state = {} # 新版本希望用新的结构 pinned_chats_v2 if "pinned_chats_v2" not in state: # 错误:直接覆盖整个 state,旧 pinned_chats 被丢弃 state = {"pinned_chats_v2": [], "theme": "dark"} with open(path, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) if __name__ == "__main__": first_launch_seed() with open(store_path(), "r", encoding="utf-8") as f: before = json.load(f) print("更新前固定项:", before.get("pinned_chats")) bad_upgrade() with open(store_path(), "r", encoding="utf-8") as f: after = json.load(f) print("更新后固定项(v2):", after.get("pinned_chats_v2")) # 输出:更新后固定项(v2): [] —— 用户的固定项没了跑这段你会看到:更新前还有 3 个固定项,更新后变成[]。根因就是bad_upgrade把整个 state 重置了,而不是迁移。
五、方案:给存储加上 schema 版本号
第一步也是最关键的一步:给本地存储一个明确的版本字段,让新代码能判断"这是旧数据,需要迁移"。
import json import os import tempfile from typing import Any, Dict CURRENT_SCHEMA_VERSION = 3 def store_path() -> str: return os.path.join(tempfile.gettempdir(), "good_app_state.json") def load_state() -> Dict[str, Any]: try: with open(store_path(), "r", encoding="utf-8") as f: return json.load(f) except FileNotFoundError: return {"schema_version": CURRENT_SCHEMA_VERSION}版本号是迁移的"锚点":每次结构变化,CURRENT_SCHEMA_VERSION加一,并写对应的迁移函数migrate_from_1_to_2、migrate_from_2_to_3等。
六、方案:幂等迁移函数
迁移必须是幂等的——重复执行不能破坏数据,也不能重复搬运。下面是正确写法:
def migrate(state: Dict[str, Any]) -> Dict[str, Any]: """把任意旧版本 state 逐步升级到当前版本。幂等。""" version = state.get("schema_version", 1) # v1 -> v2:引入 pinned_chats_v2,并把旧的 pinned_chats 搬过去 if version < 2: old_pinned = state.get("pinned_chats", []) # 新结构用 list[dict],保留原始 id 与顺序 state["pinned_chats_v2"] = [ {"id": cid, "order": i} for i, cid in enumerate(old_pinned) ] # 注意:不要删除旧字段,避免回滚时数据丢失(可选保留一段时间) version = 2 state["schema_version"] = version # v2 -> v3:固定项支持分组,新增 group 字段,缺省为 "default" if version < 3: for item in state.get("pinned_chats_v2", []): item.setdefault("group", "default") version = 3 state["schema_version"] = version return state def save_state(state: Dict[str, Any]) -> None: state["schema_version"] = CURRENT_SCHEMA_VERSION tmp = store_path() + ".tmp" with open(tmp, "w", encoding="utf-8") as f: json.dump(state, f, ensure_ascii=False, indent=2) # 原子替换:先写临时文件再 rename,避免写到一半崩溃导致文件损坏 os.replace(tmp, store_path())要点:
- 逐步升级:从
version当前值一步步走到最新,而不是"不是最新就重置"。 - 不丢旧字段:迁移时保留
pinned_chats一段时间,方便回滚。 - 原子写:用临时文件 +
os.replace,防止写入中途崩溃把存储写成半截 JSON。
七、方案:升级流程加保护(备份 + 回滚)
真正的产品级更新还要再加两层保险:
import shutil from datetime import datetime def backup_before_upgrade() -> str: """升级前对本地存储做一次时间戳备份。""" path = store_path() if not os.path.exists(path): return "" stamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup = path + f".bak.{stamp}" shutil.copy2(path, backup) return backup def restore_from_backup(backup: str) -> None: if backup and os.path.exists(backup): shutil.copy2(backup, store_path()) def safe_upgrade(): """带迁移 + 备份 + 异常回滚的启动流程。""" backup = backup_before_upgrade() try: state = load_state() state = migrate(state) # 幂等迁移 save_state(state) # 原子写 except Exception as exc: # 任何迁移失败都要回滚 print(f"[warn] 迁移失败,回滚: {exc}") restore_from_backup(backup) raise这样即使迁移脚本在某台机器上因奇怪数据抛异常,用户也只是"回到更新前状态",而不是"数据没了"。
八、验证:写一个迁移回归测试
把迁移逻辑用测试锁死,避免未来某个版本又把它写坏:
def test_migrate_preserves_pinned(): old = { "schema_version": 1, "pinned_chats": ["a", "b", "c"], "theme": "dark", } new = migrate(old) ids = [it["id"] for it in new["pinned_chats_v2"]] assert ids == ["a", "b", "c"], ids assert all(it["group"] == "default" for it in new["pinned_chats_v2"]) assert new["schema_version"] == 3 # 幂等:对已是最新的 state 再跑一次不应改变结果 again = migrate(new) assert again == new if __name__ == "__main__": test_migrate_preserves_pinned() print("迁移回归测试通过:固定项在升级后被完整保留。")把这类测试放进 CI,每次改存储结构都必须更新对应迁移函数和测试,从流程上杜绝"更新丢固定项"。
九、排查清单(遇到"更新后固定项没了"按顺序查)
- 看本地存储文件是否还在:路径是否被安装器当缓存清掉?固定项是否误存进
Cache/目录? - 看存储是否有
schema_version字段:没有就说明迁移逻辑无法判断新旧,极易被重置。 - 看
onUpgrade/migrate是否执行了DROP或整体覆盖:应该改成"读旧数据→转换→写新结构"。 - 看写入是否原子:非原子写中途崩溃会留下半截文件,下次启动被当作"损坏/空"从而重置。
- 看是否有并发:首次启动的默认初始化线程和迁移线程是否抢同一文件,导致空初始化覆盖迁移结果。
- 看备份机制:更新前是否对本地存储做了时间戳备份,便于回滚验证。
- 看平台差异:macOS 的
~/Library/Containers沙盒、Windows 的AppData\Local\Temp、Linux 的$XDG_CACHE_HOME是否被当临时目录清理。
十、小结
"更新后固定项丢失"表面是产品 bug,根因几乎都在本地存储的版本化与迁移缺失:没有 schema 版本号、迁移用覆盖代替搬运、写入不原子、位置选错。修复路径很清晰:
- 给存储加
schema_version; - 用逐步、幂等的
migrate()代替整体重置; - 用临时文件 +
os.replace做原子写; - 升级前做时间戳备份,失败即回滚;
- 用回归测试把迁移行为锁死在 CI。 做到这四点,无论发多少个版本,用户的置顶会话、收藏、固定项都不会再被一次"正常更新"悄悄抹掉
