【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 已就绪)一切正常——只有冷启动后第一轮中招
  • 备选假设"云侧没收到消息"已排除:端侧日志显示 HistoryAppend21: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.311Ability onCreate,发起异步 DB 初始化(fire-and-forget)竞态起点
48.316HistoryAppend(user) 到达onCreate 后仅 5ms,意图先于 DB 就绪到达
48.319appendHistory: store not initialized决定性证据:store undefined,静默放弃写入
48.319append DONE + turnId=155259f0 + flags 全 false假成功:未写入却报成功,turnId 现编,needsStore 等全为默认值
48.373RouseDatabase init DONEDB 就绪比意图到达晚 57ms,竞态窗口实测值
48.784HistoryRead chatWindow 返回 39 items拉取历史不含新问题 → 分析模型"没拉到新问题"
21:19:58.265think_history 写入成功,turnId 却为 2d0ee4e0current_turn_id KV 写入同窗口丢失 → 分析师思考另起新 turnId → 轮次碎片化
21:20:07.811assistant 回复写入成功(2d0ee4e0)回复入库、问题永久缺失 → 历史中留下"孤儿回复"
21:20:15.216readChatWindow 40 items39+回复,问题仍缺;水印未推进(问题行不存在),存储流程无感

一句话概括这份日志:48.311 到 48.373 之间的 57ms,决定了这一整轮的命运。


四、根因:三连缺陷叠加

丢单不是单一原因,而是三连缺陷叠加,缺一都不会丢单

  1. fire-and-forget 的 initEntryAbility.onCreaterouseDatabase.init(context) 发起后不保存 promise(.then() 只打日志)——初始化在飞,但没有任何人能等它
  2. 静默失败:意图的 onExecute 紧随 onCreate 执行(实测仅 5ms 后到达),getStore() 为 undefined 时 appendHistory 只打一行 error 日志就 return——原签名 Promise<void>,失败传导不出去,调用方无从得知。
  3. 假成功HistoryAppendImpl 不检查写入结果,无条件返回 code=20 成功,还现编了一个 turnId、flags 全填默认值——云侧被欺骗,连重试的机会都没有

具体数据:

  • 竞态窗口实测 57ms(意图到达 48.316 → init DONE 48.373)。
  • 核心认知(修复设计的立足点):这个竞态无法消灭——意图就是拉起进程的原因,init 不可能早于拉起它的意图完成。能做的不是"消灭竞态",而是让意图在门口等门开

五、损害链:为什么丢的是"整轮"

一条 user 记录写失败,为什么会演变成"整轮对话丢失 + 孤儿回复"?串起来看:

  1. 用户问题永久丢失(48.319 静默放弃写入)。
  2. 同窗口 current_turn_id 占用/写入也出问题——问题写入失败,但后续流程继续,turnId 状态已经错乱。
  3. 分析师思考、助手回复另起新 turnId(2d0ee4e0),而不是与问题同轮。
  4. 历史里留下"没有问题的孤儿回复"(39 → 40 items,多出的那条是回复)。
  5. 水印无增量——存储流程靠"历史行是否存在"推进,问题行不存在,于是存储流程对整轮无感

所以"丢一条"和"丢一轮"之间隔着的,正是 turnId 这条主线:问题丢失 → turnId 碎片化 → 整轮断裂 → 存储无感

关联发现:此前搁置的"思考历史为空"误判问题(R3)同根因——读路径遇 store undefined 返回空数组的"成功",云侧无法区分"真没数据"和"还没就绪"。读路径的"空成功"与写路径的"假成功",是一对孪生问题。


六、修复方案(五点)与设计取舍

修复五点

  1. ensureReady() 幂等门RouseDatabase 缓存 initPromise,新增 ensureReady(): Promise<boolean>——已初始化短路返回;init 在飞就 await 它;init 失败清空 promise(下次可重试);init 根本没发起过则返回 false。
  2. 7 个 DB 依赖意图首行 awaitHistoryAppend / HistoryRead / StoreAdvance / InfoManage / GoalManage / ActionManage / OutlineReadonExecute 首行 await rouseDatabase.ensureReady(),未就绪返回 code=0 真失败CurrentTime 不涉 DB 不改)。
  3. 写结果可传导appendHistory 签名从 Promise<void> 改为 Promise<boolean>,Impl 检查返回值,写失败返回 code=0 并在 message 里说明原因。
  4. UI 同源防护AIPage.refreshAll / TurnLogPage.load 加载前也 await ensureReady——UI 冷启动空白页是同一个竞态的另一张脸
  5. 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 在后(写入成功才占用)✓

四项核对

  1. 时序 ✓:START .263 → init DONE .278 → append OK .290——意图等门开,写入在门开之后。
  2. 假成功消失 ✓:无 store not initialized;append DONE 背后有真实 insert。
  3. 新问题入库 ✓:.290 写入、.659 HistoryRead 拉到(39 items 含本轮)。
  4. turnId 连续 ✓:user/think/assistant 同为 7a56a49b,完整一轮。

性能代价:冷启动首意图原地等待 ~15ms,用户无感。


九、与旧笔记的串联:同一个 fire-and-forget,两层实锤

这次的问题不是凭空冒出来的——它和我在关系型数据库章节里追过的 fire-and-forget 隐患是同一条线,但要分清两层,别混为一谈

第一层:时序层——7-14 已实锤

  • 7-13 提出,标注"待验证":当天发现教材 RDBStoreUtilcreateSportTable / 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 提出但没验证的"真隐患",在生产的冷启动场景里坐实了。

关联文章


十、延伸讨论

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

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐