Skip to content

离线数据库草案 #148

Description

@LUPENGHAN

一、方案概要

服务端是唯一权威:什么时候该提醒、日程数据是什么,只有服务端说了算,任何终端接入都不需要懂业务规则。客户端的本地能力是"镜像/冗余",执行的是服务端下发的计划,不是自己算出来的结论。

核心机制:下发「提醒计划」,而不是下发「提醒」

客户端判断(方案 A) 服务端算好,下发计划(本方案)
谁算"什么时候该响" 客户端自己算 服务端
客户端做什么 实现完整判定逻辑 按计划注册 OS 定时器 / 围栏,到点执行
新终端接入成本 重写一整套判定 只需"能连、能播"
规则一致性 各端可能不同 服务端统一保证

地理围栏同理:服务端下发围栏规则(圆心、半径、是否已布防),客户端注册给 OS。客户端不发明规则,只执行规则。

这能同时满足"服务端权威"和"离线可用",靠的是产品设计 §6.11 已经要求的触发判定与触达执行分离:判定由服务端从日程数据确定性推导,任何时刻可重算;触达是客户端上报的遥测,不是判断。

终端能力分级

分级 终端 能力 离线
Tier A 手机 缓存日程 + 注册 OS 定时器/围栏 + 上报触达 ✅ 支持
Tier B 硬件、Web、未来终端 仅接收服务端推送并播出 ❌ 不支持,可接受

服务端对两级终端的判定逻辑完全相同,差别只在投递方式。客户端注册失败时可自动降级为 Tier B。

谁说了算
日程内容 服务端(客户端本地缓存,离线编辑入 outbox)
触发判定 服务端 —— 客户端执行下发的计划;离线创建时用降级规则临时兜底
geofence_armed 服务端;离线期间客户端按固定迁移表本地推进,联网后以服务端重算结果为准

时序图角色:App 客户端逻辑 · Mem App 内存(去重 MapLocalDB 客户端 SQLite(schedules + sync_outboxOS 系统闹钟 / 围栏 + 原生存储 · Server / CloudDB 云端。

图例:alt / else = 二选一;🟥 红底块 = 最容易漏、漏了就出 bug 的地方;【磁盘】/【内存】/【原生】 = 这次写入落在哪 —— 状态落点统一见 数据库设计.md 第一节,本文不重复


二、时序图

2.1 创建日程

目标:把提醒规则注册进 OS。 在线用服务端权威计划,离线用降级规则兜底。

sequenceDiagram
    autonumber
    participant U as 用户
    participant App
    participant DB as LocalDB
    participant OS
    participant S as Server
    participant C as CloudDB

    U->>App: 填表 / 语音创建
    App->>App: 生成 UUID(sch_9f2a)

    rect rgb(255, 248, 240)
        Note over App,DB: 【磁盘】本地先落地 —— 一个事务写两张表
        App->>DB: INSERT schedules(18 列, version=1)
        App->>DB: INSERT sync_outbox(op_3c81, create)
        Note over DB: 「待同步」= outbox 里有它的 entity_id<br/>不另存 sync_status 列
    end
    App-->>U: 创建成功(不等服务端)

    alt 在线
        App->>S: POST /sync/push [op_3c81]
        S->>C: 【磁盘】INSERT sync_operations(op_3c81) 幂等
        S->>C: 【磁盘】INSERT schedules version=1
        S->>S: 算权威计划 T<br/>TimeWindowTrigger / evaluate_geofence
        S-->>App: applied + plan(T)
        App->>OS: 【原生】register(alarmId=sch_9f2a, T+N)
        Note over OS: SharedPreferences 存 (sch_9f2a, T+N)<br/>= 唯一的计划记录,SQLite 不再存一份
        App->>DB: 【磁盘】DELETE sync_outbox WHERE op_3c81
    else 离线
        rect rgb(255, 250, 235)
            Note over App,OS: 降级兜底 —— 服务端够不着时的临时方案
            App->>App: 时间:start_time - offset<br/>围栏:用日程字段,armed 默认 true
            App->>OS: 【原生】register(alarmId=sch_9f2a, 降级时刻)
        end
        Note over App,DB: 【磁盘】outbox 行保持不动,等联网(见 2.3)
    end
Loading

要点

  • 本地事务不可拆:日程和 outbox 必须同一事务
  • 降级计划一定会被覆盖:联网后服务端权威计划到达,无条件 cancel 旧注册、重新注册。正因为无条件,不需要 plan_source 去区分当前注册的是降级还是权威
  • 围栏和闹钟同一性质:都是"注册给 OS 的规则",注册后断网照常触发

2.2 提醒触发

规则:服务端通知优先,本地闹钟只做兜底。

实现关键是本地闹钟延后 N 秒注册——两个通道不能注册在同一时刻,否则谁先到全看运气:

服务端计划触发时刻     T
本地兜底闹钟注册在     T + N      (N = 10~15 秒)

时间轴上天然分先后,并发变成串行,竞态从根源消失:

sequenceDiagram
    autonumber
    participant S as Server
    participant C as CloudDB
    participant OS as OS + 原生存储
    participant App
    participant Mem as App 内存
    participant DB as LocalDB
    participant U as 用户

    Note over S,OS: 服务端计划 T,本地兜底闹钟注册在 T+N

    alt 在线 —— 服务端通道生效
        Note over S: T 时刻
        S->>App: reminder.control + TTS 音频
        S->>S: 【内存】_pending[sch_9f2a] = 已发,等 ack
        App->>Mem: 【内存】claim(sch_9f2a)
        Note over Mem: Map 里没有 → 写入,返回 true
        App->>U: 展示提醒 + 播放服务端音频
        App->>S: ack (ok=true)
        S->>C: 【磁盘】UPDATE time_triggered_at = now<br/>WHERE time_triggered_at IS NULL
        S->>S: 【内存】_pending.delete(sch_9f2a)
        Note over App,DB: 在线路径**不写 outbox** —— ack 就是上报

        Note over OS,Mem: T+N 时刻 —— 必定同一进程(见要点)
        OS->>App: 本地兜底闹钟响
        App->>Mem: 【内存】claim(sch_9f2a)
        Note over Mem: 已存在 → 返回 false<br/>静默丢弃,用户无感知

    else 离线 / 推送未达 —— 本地兜底生效
        Note over S: T 时刻:推送发不出去<br/>或发出但客户端没收到
        Note over S: 【内存】_pending 里没有这条
        Note over OS: T+N 时刻
        OS->>App: 本地兜底闹钟响
        App->>Mem: 【内存】claim(sch_9f2a)
        Note over Mem: 没有 → 写入,返回 true
        App->>U: 展示提醒 + 本地 TTS
        rect rgb(255, 235, 235)
            App->>DB: 【磁盘】INSERT sync_outbox(delivered,<br/>{sch_9f2a, shown_at, 'local'})
            Note over DB: 必须落磁盘!只放内存的话<br/>App 被杀 → 联网后服务端补推 → 重复提醒
        end
        Note over App,S: 联网后按 outbox 顺序补报(见 2.3)
    end

    rect rgb(255, 235, 235)
        Note over App,Mem: 服务端 ack 超时重发时:claim 返回 false<br/>→ 丢弃展示,但仍必须回 ack
    end
Loading

要点

  • N 的取值:太短(2 秒)服务端推送来不及到,兜底误触发导致重复;太长(60 秒)离线时延迟明显。建议 10~15 秒,覆盖网络往返 + TTS 音频传输(150~220KB)

  • 为什么去重能用内存:服务端推送只走 WS,WS 活着 ⟺ 进程活着,而兜底闹钟 15 秒后才响 —— 两个通道只可能在同一个进程里相遇。这是结构性保证不是概率,推导见数据库设计.md 第五节

  • 不靠"判断是否在线"来决策。连接状态不可靠——socket 可能刚断还没检测到,或显示已连接但服务端挂了。这套设计只看结果:到了 T+N 服务端推送有没有真的送达并呈现

  • 三种情况都能正确处理:

    情况 T 时刻 T+N 时刻 用户体验
    在线正常 服务端推送 → 呈现 查到已呈现 → 静默丢弃 准时,有高质量音频
    完全离线 推不到 兜底呈现 延迟 N 秒,本地 TTS
    在线但推送丢了 发出但没到 兜底呈现 延迟 N 秒,兜底生效 ✅
  • 去重丢弃 ≠ 不回 ack。服务端因 ack 超时重发时,客户端查到已呈现会丢弃展示,但仍必须回 ack,否则服务端重发满 3 次,期间白传三次 TTS 音频

  • 去重用内存,上报用磁盘 —— 两件事别混在一起:

    async function onReminder(scheduleId: string, channel: 'server' | 'local') {
      if (claim(scheduleId)) {                    // 【内存】JS 单线程,天然原子
        await presentReminder(scheduleId);
        if (channel === 'local') {
          // 【磁盘】离线兜底才入队。在线时 ack 就是上报,不用写
          await enqueueOutbox('delivered', { scheduleId, shownAt: Date.now() });
        }
      }
      await sendAck(scheduleId);   // ← 两条路都要回
    }

    claim() 的实现和"为什么这两件事要分开"见数据库设计.md §3.2 / §5.1

2.3 网络恢复:上传与对账

sequenceDiagram
    autonumber
    participant App
    participant Mem as App 内存
    participant DB as LocalDB
    participant OS as OS + 原生存储
    participant S as Server
    participant C as CloudDB

    rect rgb(240, 255, 240)
        Note over App,C: 第一步 —— 按 outbox 顺序推,create 天然排在 delivered 前面
        App->>DB: 【磁盘】SELECT * FROM sync_outbox ORDER BY created_at
        App->>S: ① POST /sync/push [create / update / delete]
        Note over App,S: 同一 entity_id 的操作严格保序
        S->>C: 【磁盘】INSERT sync_operations(幂等)
        S->>C: 【磁盘】落库 schedules
        S->>S: 算权威计划
        S-->>App: 逐条结果 + plan(支持部分成功)
        App->>DB: 【磁盘】DELETE 已成功的 outbox 行

        App->>OS: 【原生】cancel(sch_9f2a)
        App->>OS: 【原生】register(sch_9f2a, 权威 T+N)
        Note over OS: 无条件覆盖,不判断原来是不是降级计划<br/>→ 所以不需要 plan_source
    end

    rect rgb(235, 250, 255)
        Note over App,C: 第二步 —— 同一队列里的 delivered 行(顺序自动满足)
        App->>S: ② POST /sync/push [delivered]
        S->>C: 【磁盘】UPDATE time_triggered_at = shown_at<br/>WHERE time_triggered_at IS NULL
        App->>DB: 【磁盘】DELETE 该 outbox 行
    end

    rect rgb(255, 240, 245)
        Note over S,App: 第三步 —— 对账补发
        S->>C: 【磁盘】查:应触发但 *_triggered_at 仍为 NULL 的日程
        alt 有遗漏且在时效内(5~10 分钟)
            S->>App: 补推 reminder.control
            App->>Mem: 【内存】claim(schedule_id)
            Note over Mem: 若这次会话里已展示过 → false → 只回 ack
            App->>S: ack
            S->>C: 【磁盘】写 time_triggered_at
        else 已送达 或 已超时
            Note over S: 不补推<br/>超时的标记 expired 并记日志
        end
    end

    App->>S: GET /sync/pull
    S-->>App: 全量日程 + 最新计划
    App->>DB: 【磁盘】覆盖本地 schedules
    App->>OS: 【原生】按最新计划重新注册
Loading

要点

  • ①② 的顺序不用额外控制了:create 入队在前、delivered 在后,按队列顺序推即可
  • 第三步的 claim 是第二道防线。同一次会话内已展示过的,补推来了也只回 ack 不展示
  • 先 push 再 pull,避免本地未上传的修改被云端旧数据覆盖
  • 补发要有时效。两小时后再补一句"该开会了"只会让人烦,超时的直接标 expired

2.4 日程变更(修改 / 删除)

sequenceDiagram
    autonumber
    participant U as 用户
    participant App
    participant Mem as App 内存
    participant DB as LocalDB
    participant OS as OS + 原生存储
    participant S as Server
    participant C as CloudDB

    alt 修改
        U->>App: 把会议 15:00 改到 16:00
        App->>DB: 【磁盘】事务:UPDATE schedules(version+1)<br/>+ INSERT sync_outbox(update, baseVersion=1)
        App-->>U: 修改成功
        App->>S: POST /sync/push

        alt 版本匹配
            S->>C: 【磁盘】UPDATE schedules version=2
            rect rgb(255, 235, 235)
                S->>C: 【磁盘】触发条件变了?<br/>→ 重置 time_triggered_at / geo_triggered_at = NULL
                Note over S,C: 判定依据:start_time / offset /<br/>经纬度 / 半径 任一改变<br/>只改标题备注则不重置
            end
            S->>S: 重算权威计划
            S-->>App: applied v2 + 新计划
            App->>DB: 【磁盘】DELETE 该 outbox 行
            App->>OS: 【原生】cancel(sch_9f2a)
            App->>OS: 【原生】register(sch_9f2a, 新 T+N)
            rect rgb(255, 235, 235)
                App->>Mem: 【内存】shown.delete(sch_9f2a)
                Note over Mem: 漏了这步:改期后新提醒会被<br/>旧的展示记录挡掉,静默不响
            end
        else 版本冲突
            S-->>App: conflict + 服务端数据
            App->>DB: 【磁盘】以服务端为准覆盖 + DELETE outbox 行
            App->>OS: 【原生】按服务端计划重新注册
            App->>Mem: 【内存】shown.delete(sch_9f2a)
        end
    else 删除
        U->>App: 删除日程
        App->>DB: 【磁盘】事务:status='deleted'<br/>+ INSERT sync_outbox(delete)
        App->>OS: 【原生】cancel(sch_9f2a) + 注销围栏
        App->>Mem: 【内存】shown.delete(sch_9f2a)
        App->>S: POST /sync/push
        S->>C: 【磁盘】status='deleted'(软删除墓碑,不物理删)
        S->>S: 清理 TTS 音频文件
    end
Loading

shown.delete() 这一步在旧设计里是"清除 shown_at 列",现在变成一行内存操作 —— 但仍然不能漏,它是这张图里最容易忘的一步。

必须做对的五件事

场景 必须做的 漏了会怎样
改期(触发条件变) 重置 *_triggered_at + 重算计划 + cancel 旧注册 + 注册新的 + 清内存 shown 提醒永久丢失(当前已存在的 bug)
只改标题/备注 重置触发状态 已提醒过的日程会重复响
删除 cancel 闹钟 + 注销围栏 + 清理 TTS 音频 已删日程仍响;孤儿音频堆积
离线改期后联网 服务端计划到达后覆盖本地临时计划 按旧时间响
冲突(服务端优先) 以服务端版本为准,重新注册 按已被拒绝的修改响

⚠️ 当前 business/schedules.py 原样保留 time_triggered_at=existing.time_triggered_at,导致改期后服务端算计划时会因该字段非空而跳过这条日程这个 bug 必须先修,否则再完善的计划下发也没用。


三、离线创建的同步细节

第一步 —— 一个事务写两张表(缺一不可):

BEGIN;
  INSERT INTO schedules (id, title, start_time, ..., version)
  VALUES ('sch_9f2a...', '开会', '2026-08-04T15:00:00+00:00', ..., 1);
  -- 没有 sync_status 列:「待同步」= 下面这行 outbox 存在

  INSERT INTO sync_outbox (operation_id, entity_id, operation, payload, base_version, status)
  VALUES ('op_3c81...', 'sch_9f2a...', 'create', '{...完整日程...}', NULL, 'pending');
COMMIT;

第二步 —— 联网后批量推送,取 status='pending'created_at 排序,一次 50~100 条:

POST /api/v1/sync/push
{ "operations": [
    { "operationId": "op_3c81...", "entity": "schedule", "entityId": "sch_9f2a...",
      "operation": "create", "baseVersion": null, "payload": { } }
] }

第三步 —— 服务端幂等落库并返回计划:

1. operation_id 已在 sync_operations 里? → 返回 duplicate,不重复插入
2. create 且 entityId 已存在?               → 上次成功但响应丢了,返回 duplicate
3. 落库,version=1
4. 算权威提醒计划
5. 记录 operation_id 到幂等表
{ "results": [
    { "operationId": "op_3c81...", "status": "applied", "version": 1,
      "plan": { "triggerAt": "2026-08-04T14:45:00+00:00", "geofence": null } }
] }

第四步 —— 客户端收尾:

返回 处理
applied / duplicate 删除 outbox 行(删掉即代表已同步);cancel 降级注册 → 按权威计划重新注册
conflict 以服务端为准(create 基本不会冲突)
5xx / 网络失败 retry_count++,指数退避,operation_id 保持不变

三个边界情况

① 离线期间创建后又改了好几次 —— outbox 里是 create + 若干 update,必须按序推送。乱序的话 update 可能先于 create 到达,服务端找不到实体。初版做法:同一 entity_id 严格串行,不同实体可并行。

② 离线创建后又删除了 —— 照常推 create + delete,服务端先建再软删。不要在客户端提前抵消,否则服务端永远不知道这条数据存在过。

③ 离线创建 + 离线已触发提醒 —— 两条记录都在同一个 outbox 里:create 先入队、delivered 后入队。按 created_at 顺序推送,顺序依赖自动满足,不需要维护两个队列。见 2.3 图。


四、去重机制

四处去重,键各不相同:

场景 存放位置 目的
同步操作重试 operation_id **【磁盘】**服务端 sync_operations 重复推送不产生重复数据
触达上报重试 schedule_id **【磁盘】**服务端 schedules.*_triggered_at 重复上报不重复写
服务端推送 vs 本地兜底 schedule_id **【内存】**客户端 shown Map 兜底闹钟发现已呈现则静默丢弃
ack 超时重发 schedule_id **【内存】**服务端 _pending 已确认的不再重发

只有 sync_operations 是新表。 两处磁盘去重落在 schedules 已有的列上(一条日程一个提醒,状态与日程 1:1);两处内存去重只需跨越十几秒,不上磁盘。详见 数据库设计.md 第一节。

磁盘上的"检查+写入"必须原子

不要读出来再决定写不写,让数据库的原子性替你判断:

位置 原子写法 判断依据
服务端标记已送达 UPDATE schedules SET time_triggered_at=? WHERE id=? AND time_triggered_at IS NULL rowcount > 0
服务端同步幂等 INSERT INTO sync_operations ... ON CONFLICT DO NOTHING 冲突即为重复请求

第一条是现有代码里已经这么做的(data/schedule_dispatch.pymark_time_triggered),可直接参照;唯一要改的是写入时机——从"推送前写"改成"收到 ack 后写",详见 数据库设计.md §7.1。

客户端去重不在这张表里 —— 它在内存中,JS 单线程,claim() 的"检查+写入"之间不会被抢占,天然原子,不需要数据库语句。

周期日程会让现在的去重键失效(未来)

现在用 schedule_id 够用,因为每条日程只触发一次。一旦支持周期日程,同一 schedule_id 触发多次,第二次、第三次会被全部误判为重复而丢弃。届时要改两处:

  • 内存 shown Map 的键从 schedule_id 变成 schedule_id + plan_id
  • 云端 *_triggered_at 不能再挂在 schedules 列上,要拆成以 schedule_id + plan_id 为键的独立表

不要用触发时刻当键:离线降级规则算出的时刻和服务端权威计划可能不一致,两边对不上就去重失败了。plan_id 由服务端下发计划时生成。


五、优缺点

优点

缺点

  • 需要设计并维护"提醒计划"的下发协议和刷新机制(日程改了要重新下发)
  • 手机端要维护"服务端权威"与"本地镜像"两份状态的一致性
  • 计划有时效性,下发窗口多长、多久刷新一次需要调优
  • 离线时用降级规则,若服务端计划以后加入更复杂因素(免打扰、多维感知)不会体现

六、对现有代码的影响

现有代码 变化
TimeWindowTriggerService / evaluate_geofence 保留并升格为权威判定,产出从"立即推送"扩展为"下发计划 + 在线推送"
ReminderDispatcher 保留,增加"计划下发"通道;_pending 逻辑不变
data/schedule_dispatch.pymark_time_triggered 写法不变,调用时机改到收到 ack 之后
前端 syncScheduleAlarm.ts 改造:从"自己算触发时刻"改为"执行服务端下发的计划",computeScheduleAlarmTriggerMillis 退役
原生 AlarmScheduler.java schedule() 改成接收外部传入的 alarmId(= schedule_id),不再内部 UUID.randomUUID()
原生 AndroidManifest RECEIVE_BOOT_COMPLETED + 开机 receiver 调 loadAlarms() 重建 —— 现在缺这个,重启后闹钟全没

七、适用条件

以后要接入多种终端(硬件、Web),且要求行为统一。 这是本项目的既定方向,因此本方案为推荐架构。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions