【HarmonyOS学习笔记】2026-09-24 | 冷启动 DB 竞态与假成功陷阱
【HarmonyOS学习笔记】2026-09-24 | 冷启动 DB 竞态与假成功陷阱
date: 2026-09-24
tags: [HarmonyOS, 关系型数据库, relationalStore, 冷启动, 竞态, fire-and-forget, 假成功, InsightIntent, LLM, 故障复盘]
type: 实战笔记
一、系统背景与竞态的结构性根源
这是一个个人助理应用,架构是云侧 LLM 编排 + 端侧 Insight Intent 插件:
- 云侧:LLM 编排节点(分析模型、存储分析模型等),负责推理与决策。
- 端侧:HarmonyOS 设备上的 Insight Intent 插件,负责真实读写——历史记录、信息库、目标/任务库,底层是关系型数据库
relationalStore(RDB)。云侧每次要读写数据就调用端侧意图(onExecute),拿到结构化返回{code, result}。
关键机制,也是这次竞态的结构性根源:
用户对小艺(语音助手)说话 → 云侧决定写历史 → 调用端侧
HistoryAppend意图 → 该意图以UI_ABILITY_BACKGROUND模式拉起应用进程。
也就是说,冷启动的第一件事就是执行数据写入,而进程刚拉起时数据库还没初始化完。用素材里的话说——意图既是"客人",也是"开门的原因"。这一点是整个问题"无法靠抢跑解决"的根,后面会反复回到它。
辅助机制(理解损害链需要):
- 每条历史带
turnId标识对话轮次;current_turn_id存在 KV 中,同一轮的 user/think/assistant 消息应共用一个 turnId。 - 存储流程靠"历史行是否存在"推进水印。
二、问题现象(用户视角)
- 2026-09-22 21:19,应用进程彻底杀死后(冷启动),通过小艺向智能体提问一个新问题:云侧分析模型没拉到该问题,存储流程也没存它。
- 紧接着的第二次提问(进程已温、DB 已就绪)一切正常——只有冷启动后第一轮中招。
- 备选假设"云侧没收到消息"已排除:端侧日志显示
HistoryAppend于21:19:48.316到达,云侧明明发了。 - 自己复盘的感受是:问题是"丢单"而不是"报错"——没有任何报错,问题无声消失,历史里留下一轮没有问题的"孤儿回复"。
这里最反直觉的地方是:没有错误,却丢了数据。这个"无声"贯穿了整个排查——它既是现象的特征,也是根因的一部分(见第四节"假成功")。
三、日志取证与时间线
失败轮原始日志(进程 19813,2026-09-22 21:19:48 起)
09-22 21:19:48.307 RouseConfig init OK
09-22 21:19:48.311 testTag Ability onCreate
09-22 21:19:48.316 HistoryAppend onExecute START: stream=chat_history role=user
09-22 21:19:48.319 RouseHistoryStore E appendHistory: store not initialized
09-22 21:19:48.319 HistoryAppend append DONE: turnId=155259f0-0c36-4937-a2bc-9743c63a9747 needsStore=false isFrozen=false thinkLoop=false storeLoop=false
09-22 21:19:48.320 testTag Ability onBackground
09-22 21:19:48.372 RouseDatabase init: getRdbStore OK
09-22 21:19:48.373 RouseDatabase init: PRAGMA foreign_keys ON
09-22 21:19:48.373 RouseDatabase init: current version = 4
09-22 21:19:48.373 RouseDatabase init: DONE
09-22 21:19:48.373 testTag RouseDatabase init OK
09-22 21:19:48.503 CurrentTime onExecute START
09-22 21:19:48.504 CurrentTime currentTime DONE: 2026-09-22 21:19:48
09-22 21:19:48.756 HistoryRead onExecute START: action=chatWindow
09-22 21:19:48.784 RouseHistoryStore readChatWindow: 39 items from 20 turns
09-22 21:19:48.784 HistoryRead chatWindow DONE: 39 results
09-22 21:19:49.054 HistoryRead onExecute START: action=thinkSegment
09-22 21:19:49.058 RouseHistoryStore readThinkSegment: 3 items
09-22 21:19:49.058 HistoryRead thinkSegment DONE: 3 results
09-22 21:19:49.232 OutlineRead onExecute START
09-22 21:19:49.234 OutlineRead outline read (no save)
09-22 21:19:58.236 HistoryAppend onExecute START: stream=think_history role=analyst
09-22 21:19:58.265 RouseHistoryStore appendHistory: think_history 2d0ee4e0-c772-4986-9935-3531115b589c OK
09-22 21:19:58.273 RouseKVStore getStoreState: unmarked=4 needsStore=false isFrozen=false thinkLoop=false storeLoop=false
09-22 21:19:58.273 HistoryAppend append DONE: turnId=2d0ee4e0-c772-4986-9935-3531115b589c ...
09-22 21:19:58.847 HistoryRead onExecute START: action=chatWindow
09-22 21:19:58.872 RouseHistoryStore readChatWindow: 39 items from 20 turns
09-22 21:19:58.872 HistoryRead chatWindow DONE: 39 results
09-22 21:19:59.335 HistoryRead onExecute START: action=thinkSegment
09-22 21:19:59.337 RouseHistoryStore readThinkSegment: 4 items
09-22 21:19:59.337 HistoryRead thinkSegment DONE: 4 results
09-22 21:20:07.806 HistoryAppend onExecute START: stream=chat_history role=assistant
09-22 21:20:07.811 RouseHistoryStore appendHistory: chat_history 2d0ee4e0-c772-4986-9935-3531115b589c OK
09-22 21:20:07.817 RouseKVStore getStoreState: unmarked=4 needsStore=false ...
09-22 21:20:07.817 HistoryAppend append DONE: turnId=2d0ee4e0-c772-4986-9935-3531115b589c ...
09-22 21:20:15.033 testTag Ability onWindowStageCreate
09-22 21:20:15.039 testTag Ability onForeground
09-22 21:20:15.216 RouseHistoryStore readChatWindow: 40 items from 20 turns
时间线拆解
| 时刻 | 事件 | 判定 |
|---|---|---|
| 48.311 | Ability onCreate,发起异步 DB 初始化(fire-and-forget) | 竞态起点 |
| 48.316 | HistoryAppend(user) 到达 | onCreate 后仅 5ms,意图先于 DB 就绪到达 |
| 48.319 | appendHistory: store not initialized | 决定性证据:store undefined,静默放弃写入 |
| 48.319 | append DONE + turnId=155259f0 + flags 全 false | 假成功:未写入却报成功,turnId 现编,needsStore 等全为默认值 |
| 48.373 | RouseDatabase init DONE | DB 就绪比意图到达晚 57ms,竞态窗口实测值 |
| 48.784 | HistoryRead chatWindow 返回 39 items | 拉取历史不含新问题 → 分析模型"没拉到新问题" |
| 21:19:58.265 | think_history 写入成功,turnId 却为 2d0ee4e0 | current_turn_id KV 写入同窗口丢失 → 分析师思考另起新 turnId → 轮次碎片化 |
| 21:20:07.811 | assistant 回复写入成功(2d0ee4e0) | 回复入库、问题永久缺失 → 历史中留下"孤儿回复" |
| 21:20:15.216 | readChatWindow 40 items | 39+回复,问题仍缺;水印未推进(问题行不存在),存储流程无感 |
一句话概括这份日志:48.311 到 48.373 之间的 57ms,决定了这一整轮的命运。
四、根因:三连缺陷叠加
丢单不是单一原因,而是三连缺陷叠加,缺一都不会丢单:
- fire-and-forget 的 init:
EntryAbility.onCreate里rouseDatabase.init(context)发起后不保存 promise(.then()只打日志)——初始化在飞,但没有任何人能等它。 - 静默失败:意图的
onExecute紧随 onCreate 执行(实测仅 5ms 后到达),getStore()为 undefined 时appendHistory只打一行 error 日志就 return——原签名Promise<void>,失败传导不出去,调用方无从得知。 - 假成功:
HistoryAppendImpl不检查写入结果,无条件返回 code=20 成功,还现编了一个 turnId、flags 全填默认值——云侧被欺骗,连重试的机会都没有。
具体数据:
- 竞态窗口实测 57ms(意图到达 48.316 → init DONE 48.373)。
- 核心认知(修复设计的立足点):这个竞态无法消灭——意图就是拉起进程的原因,init 不可能早于拉起它的意图完成。能做的不是"消灭竞态",而是让意图在门口等门开。
五、损害链:为什么丢的是"整轮"
一条 user 记录写失败,为什么会演变成"整轮对话丢失 + 孤儿回复"?串起来看:
- 用户问题永久丢失(48.319 静默放弃写入)。
- 同窗口
current_turn_id占用/写入也出问题——问题写入失败,但后续流程继续,turnId 状态已经错乱。 - 分析师思考、助手回复另起新 turnId(2d0ee4e0),而不是与问题同轮。
- 历史里留下"没有问题的孤儿回复"(39 → 40 items,多出的那条是回复)。
- 水印无增量——存储流程靠"历史行是否存在"推进,问题行不存在,于是存储流程对整轮无感。
所以"丢一条"和"丢一轮"之间隔着的,正是 turnId 这条主线:问题丢失 → turnId 碎片化 → 整轮断裂 → 存储无感。
关联发现:此前搁置的"思考历史为空"误判问题(R3)同根因——读路径遇 store undefined 返回空数组的"成功",云侧无法区分"真没数据"和"还没就绪"。读路径的"空成功"与写路径的"假成功",是一对孪生问题。
六、修复方案(五点)与设计取舍
修复五点
ensureReady()幂等门:RouseDatabase缓存initPromise,新增ensureReady(): Promise<boolean>——已初始化短路返回;init 在飞就 await 它;init 失败清空 promise(下次可重试);init 根本没发起过则返回 false。- 7 个 DB 依赖意图首行 await:
HistoryAppend/HistoryRead/StoreAdvance/InfoManage/GoalManage/ActionManage/OutlineRead的onExecute首行await rouseDatabase.ensureReady(),未就绪返回 code=0 真失败(CurrentTime不涉 DB 不改)。 - 写结果可传导:
appendHistory签名从Promise<void>改为Promise<boolean>,Impl 检查返回值,写失败返回 code=0 并在 message 里说明原因。 - UI 同源防护:
AIPage.refreshAll/TurnLogPage.load加载前也 await ensureReady——UI 冷启动空白页是同一个竞态的另一张脸。 - turnId 占用时机后移:
setCurrentTurnId移到appendHistory返回 true 之后(写入成功才占用)——消除"问题写入失败但 turnId 已被占用"的孤儿 ID 残余。
设计取舍:为什么是"意图等门",不是"提前 init"
因为 init 不可能早于拉起进程的意图——onCreate 里能做的最早动作,意图已经等在 onExecute 了。方向不是抢跑,而是让后到者等先到者。这一点是第一节"意图就是开门的原因"的直接推论:当资源初始化本身就是被消费方触发时,抢跑在逻辑上不存在空间。
云侧配套(重要补充)
修复后意图可能返回 code=0 真失败(原来是假成功)。只改端侧不改云侧,只是把"静默丢单"换成"显式中止"。所以云侧要按失败点分级决策:
| 失败点 | 处理 | 理由 |
|---|---|---|
| 问题写入 / 历史读取失败 | 中止本轮 + 兜底话术 | 防盲答、防孤儿轮 |
| 中途写入(think/store/回复)失败 | 重试一次,不中止 | 用户在等回复,中止 = 丢回复,且不产生碎片 |
| 工具意图失败 | 现有对账机制照旧 | — |
七、关键代码片段(原文截选)
① EntryAbility.onCreate——init 的发起处
(entry/src/main/ets/entryability/EntryAbility.ets line 20-34)
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
AppStorage.setOrCreate<common.UIAbilityContext>('uiAbilityContext', this.context);
rouseConfig.init(this.context);
rouseDatabase.init(this.context).then(() => {
hilog.info(DOMAIN, 'testTag', 'RouseDatabase init OK');
}).catch((e: Object) => {
hilog.error(DOMAIN, 'testTag', 'RouseDatabase init FAILED: %{public}s', JSON.stringify(e));
});
// ……(colorMode 设置等省略)
hilog.info(DOMAIN, 'testTag', '%{public}s', 'Ability onCreate');
}
注意这里 init 的返回值没有被保存——.then() / .catch() 只打日志,没有任何人能 await 它,这就是"fire-and-forget 的 init"。
② RouseDatabase——幂等 init + 缓存 promise + ensureReady
(entry/src/main/ets/service/RouseDatabase.ets line 109-169)
export class RouseDatabase {
private store: relationalStore.RdbStore | undefined = undefined
private initialized: boolean = false
private initPromise: Promise<void> | undefined = undefined
async init(context: common.UIAbilityContext): Promise<void> {
if (this.initialized) {
hilog.info(this.logDomain, this.logTag, 'init: already initialized')
return
}
if (this.initPromise !== undefined) {
await this.initPromise
return
}
this.initPromise = this.doInit(context)
await this.initPromise
}
private async doInit(context: common.UIAbilityContext): Promise<void> {
try {
this.store = await relationalStore.getRdbStore(context, STORE_CONFIG)
// ……(PRAGMA、版本迁移、建表等省略)
this.initialized = true
hilog.info(this.logDomain, this.logTag, 'init: DONE')
} catch (e) {
this.initPromise = undefined
hilog.error(this.logDomain, this.logTag, 'init FAILED: %{public}s', JSON.stringify(e))
}
}
async ensureReady(): Promise<boolean> {
if (this.initialized) {
return true
}
const pending: Promise<void> | undefined = this.initPromise
if (pending !== undefined) {
await pending
return this.initialized
}
hilog.error(this.logDomain, this.logTag, 'ensureReady: init not started yet')
return false
}
}
要点:doInit 失败时清空 initPromise——下次 init() / ensureReady() 可重试,不把失败状态焊死。
③ RouseHistoryStore.appendHistory——失败可传导
(entry/src/main/ets/service/RouseHistoryStore.ets line 18-43)
async appendHistory(stream: string, turnId: string, role: string, content: string): Promise<boolean> {
const s = this.getStore()
if (s === undefined) {
hilog.error(this.logDomain, this.logTag, 'appendHistory: store not initialized')
return false
}
if (stream !== STREAM_CHAT && stream !== STREAM_THINK && stream !== STREAM_STORE) {
hilog.error(this.logDomain, this.logTag, 'appendHistory: invalid stream %{public}s', stream)
return false
}
try {
const now: number = Date.now()
const values: relationalStore.ValuesBucket = {
'turn_id': turnId,
'role': role,
'content': content,
'ts': now
}
await s.insert(stream, values)
hilog.info(this.logDomain, this.logTag, 'appendHistory: %{public}s %{public}s OK', stream, turnId)
return true
} catch (e) {
hilog.error(this.logDomain, this.logTag, 'appendHistory FAILED: %{public}s', JSON.stringify(e))
return false
}
}
store not initialized 那行 error 日志还在,但返回值从 void 变成了明确的 boolean——失败终于传得出去。
④ HistoryAppendImpl.onExecute——等门 + 查结果 + 成功才占用 turnId
(entry/src/main/ets/insightintents/HistoryAppendImpl.ets line 82-132,错误返回的 result 字段较多,此处保留主干)
const ready: boolean = await rouseDatabase.ensureReady()
if (!ready) {
return {
code: 0,
result: {
turnId: '',
stream: this.stream,
role: this.role,
summary: '',
message: '追加历史失败:数据库未就绪(详见端侧 RouseDatabase 日志)',
needsStore: false,
isFrozen: false,
thinkLoop: false,
storeLoop: false,
_source: 'Rouse_HistoryAppend_EndPlugin',
_timestamp: Date.now()
} as HistoryAppendResultDef
}
}
// turnId 决策:chat_history+user 是新一轮的开始,生成新 UUID;其余取 current_turn_id
// ……(省略)
const appended: boolean =
await rouseHistoryStore.appendHistory(this.stream, turnId, this.role, this.content)
if (!appended) {
return {
code: 0,
result: { /* 同上结构,message: '追加历史失败:数据库写入失败(详见端侧 RouseHistoryStore 日志)' */ }
}
}
await rouseKVStore.setCurrentTurnId(turnId) // 写入成功才占用 turnId(后移)
// ……(getStoreState、组装成功返回 code=20)
三处关键改动都在这段里:首行 await ensureReady()(等门)→ 检查 appendHistory 返回值(查结果)→ setCurrentTurnId 移到写入成功之后(占用后移)。
八、复测验证(修前修后对照)
复测日志(2026-09-22 23:32:53,杀进程冷启动,进程 16842)
09-22 23:32:53.255 RouseConfig init OK
09-22 23:32:53.258 testTag Ability onCreate
09-22 23:32:53.263 HistoryAppend onExecute START: stream=chat_history role=user
09-22 23:32:53.278 RouseDatabase init: getRdbStore OK
09-22 23:32:53.278 RouseDatabase init: DONE
09-22 23:32:53.278 testTag RouseDatabase init OK
09-22 23:32:53.290 RouseHistoryStore appendHistory: chat_history 7a56a49b-916b-4936-9d0d-b2de8a7ba60b OK
09-22 23:32:53.294 RouseKVStore setKV: current_turn_id=7a56a49b-916b-4936-9d0d-b2de8a7ba60b
09-22 23:32:53.299 HistoryAppend append DONE: turnId=7a56a49b ... needsStore=false ...
09-22 23:32:53.637 HistoryRead onExecute START: action=chatWindow
09-22 23:32:53.659 RouseHistoryStore readChatWindow: 39 items from 20 turns
09-22 23:32:59.334 RouseHistoryStore appendHistory: think_history 7a56a49b OK
09-22 23:33:04.397 RouseHistoryStore appendHistory: chat_history 7a56a49b OK
修前修后对比
| 时刻 | 故障轮(21:19,修前) | 复测轮(23:32,修后) |
|---|---|---|
| onCreate 后 5ms | 意图到达 | 意图到达(竞态依然存在,无法消灭——意图就是拉起进程的原因) |
| 意图到达后 | 摸门→没开→静默放弃 | ensureReady 原地等待 15ms |
| init DONE | 晚于意图 57ms,为时已晚 | 意图等到此刻,随后写入 |
| 写入结果 | store not initialized + 假成功 | appendHistory ... OK 真成功 |
| turnId | 碎片化(user 155259f0 / 回复 2d0ee4e0) | 全轮连续(7a56a49b:user/think/assistant) |
| current_turn_id 占用 | 写失败仍占用(先占后写) | append OK 在前、setKV 在后(写入成功才占用)✓ |
四项核对
- 时序 ✓:START .263 → init DONE .278 → append OK .290——意图等门开,写入在门开之后。
- 假成功消失 ✓:无
store not initialized;append DONE 背后有真实 insert。 - 新问题入库 ✓:.290 写入、.659 HistoryRead 拉到(39 items 含本轮)。
- turnId 连续 ✓:user/think/assistant 同为
7a56a49b,完整一轮。
性能代价:冷启动首意图原地等待 ~15ms,用户无感。
九、与旧笔记的串联:同一个 fire-and-forget,两层实锤
这次的问题不是凭空冒出来的——它和我在关系型数据库章节里追过的 fire-and-forget 隐患是同一条线,但要分清两层,别混为一谈:
第一层:时序层——7-14 已实锤
- 7-13 提出,标注"待验证":当天发现教材
RDBStoreUtil的createSportTable/initSportTable/createPlanTable内部调异步 API 但不 return Promise,调用方无法 await,推论"建表还没完成就可能插数据/查询"的时序隐患,但当时只写了"待实机验证"。 - 7-14 实锤:新增
queryAllSports()直接查 SPORTS 表(时序正确应返回 3 条:晨跑/瑜伽/游泳),实测 T+1ms 返回 0 条,而batchInsert的 3 条要到 T+23ms 才完成——时序隐患坐实。但结论是"碰巧没问题":正常用户路径里异步(15~23ms)早已完成,用户手动操作是秒级。而且这是读路径(查到空数据)。
第二层:真隐患层——7-14 只是提出,P14 才实锤
7-14 实验里还写了一段"fire-and-forget 的真正隐患",但当时只是提出、没有验证:
不是"当前代码会出错",而是**“调用方无法知道异步操作是否成功”**:建表失败 → 调用方不知道 → 后续代码基于"表已存在"的假设继续执行;插入失败 → 调用方不知道 → 用户看到的列表是空的,以为"没有数据"。
P14 实锤的正是这一层,而且更严重:
| 维度 | 7-14(读路径,时序层) | P14(写路径,真隐患层) |
|---|---|---|
| 方向 | 读(queryAllSports) | 写(HistoryAppend) |
| 表现 | 查到空数据 | 写入丢失 + 假成功 |
| 后果 | “碰巧没问题”(用户路径异步早已完成) | 数据永久丢失,且云侧被 code=20 欺骗、无感 |
| 场景 | 教学 demo 的页面加载 | 生产冷启动 + 意图拉起进程的竞态 |
一句话:7-14 实锤"读到空",P14 实锤"写入丢 + 假成功"——7-14 提出但没验证的"真隐患",在生产的冷启动场景里坐实了。
关联文章
- 【HarmonyOS学习笔记】2026-07-13 关系型数据库(第八节:fire-and-forget 时序隐患,待验证)
- 【HarmonyOS学习笔记】2026-07-14 路由架构与异步实验(含 fire-and-forget 第二轮实验:时序层实锤;6.3:真隐患提出;Day 3 遗留问题 #13:✅已验证)
十、延伸讨论
1. 假成功比失败更危险
失败会触发重试/降级,假成功什么都不会触发——云侧拿着现编的 turnId 和默认 flags 继续往下走,污染后续整轮。分布式系统里"报错是礼物"的老话,在端云协作里同样成立。这次如果不是靠端侧日志取证,根本无法发现"成功"背后没有写入。
2. 空结果的成功歧义
读路径返回空数组的"成功",让"真没数据"和"还没就绪"不可区分——这与写路径的假成功是一对孪生问题(正是第五节提到的 R3)。本修复顺带覆盖:ensureReady 之后,读路径不会再出现 store undefined 却返回空数组的"成功"。
3. 竞态修复的两种哲学:抢跑 vs 等待
- 抢跑:让 init 更早。
- 等待:让消费者等就绪。
当资源初始化本身就是被消费方触发时,等待是唯一正解——"意图就是拉起进程的原因"意味着抢跑在逻辑上不存在空间。这一条是这次修复最底层的判断。
4. 失败语义要与调用方协商
端侧从"永远报成功"改为"可能报真失败"后,云侧必须配套决策表,否则只是把"静默丢单"换成"显式中止"。真失败怎么用(中止 vs 重试 vs 兜底话术),要按失败点对用户体验的影响分级——第六节的云侧配套表就是这个分级。
学习小结:这次是一次"无声丢单"的故障复盘。冷启动时,
HistoryAppend意图以UI_ABILITY_BACKGROUND拉起进程,冷启动第一件事就是写数据,而 DB 还没初始化完——竞态窗口实测 57ms。根因是三连缺陷叠加:fire-and-forget 的 init(没人能 await)+ 静默失败(Promise<void>传导不出去)+ 假成功(无条件返回 code=20、turnId 现编)。三者缺一都不会丢单。修复的核心认知是:这个竞态无法消灭(意图就是拉起进程的原因),只能让意图在门口等门开——ensureReady()幂等门 + 7 个意图首行 await + 写结果可传导(Promise<void>→Promise<boolean>)+ UI 同源防护 + turnId 占用后移。复测证明:意图原地等 ~15ms 后写入成功,turnId 全轮连续,性能代价用户无感。和旧笔记的串联要分清两层:7-14 已实锤"时序层"(读路径,碰巧没问题),P14 实锤的是 7-14 提出却未验证的"真隐患层"(写路径,静默失败 + 假成功 + 数据永久丢失)。最值得记住的一条:假成功比失败更危险——它不触发任何重试或告警,只是安静地把数据弄丢。
懿路向前 · AI辅助整理
2026-09-24
更多推荐



所有评论(0)