资讯动态

离线优先架构实战:请求队列、本地缓存与增量同步

发布时间:2026/9/20 3:02:07 来源:尧图企业网站定制
1. 弱网场景为什么离线优先不只是加个缓存在 HarmonyOS 应用开发里弱网和离线是两个经常被混淆、但本质上完全不同的状态。离线是断网网络连接彻底不可用所有请求发出去就是失败弱网是网络连接还在但质量极差——延迟从几十毫秒飙到几秒、丢包率居高不下、带宽被压缩到只能传纯文本甚至经常出现连接建立了却收不到响应的假死状态。很多开发者会把弱网当成偶尔的网络波动来处理结果就是在用户真正身处地铁、电梯、地下车库、山区基站覆盖边缘时应用的表现完全失控。我见过不少应用的做法是检测到网络异常就给用户弹一个网络不给力的 toast然后把请求直接丢弃。这样做在 4G/5G 覆盖良好的城市环境里或许够用但在真实的弱网场景下用户会频繁看到请求失败、数据丢失操作到一半被中断体验支离破碎。有一个更反直觉的现象弱网状态下用户反而更频繁地尝试操作因为每一次失败都会让人怀疑是不是没点中于是重复点击、重复提交后端收到一堆重复请求数据被覆盖用户看到的结果和预期完全不一致。离线优先架构要解决的就是这件事它不假设网络永远可用而是把网络可用当成一种加分项而不是必需品。用户的所有操作都先落在本地进入一个可控的队列网络恢复后再异步地同步到服务端。这套架构的核心环节就是标题里的三件事——请求队列、本地缓存、增量同步。请求队列解决的是操作不能丢、不能乱的问题本地缓存解决的是数据有地方可读、可写的问题增量同步解决的是和服务端收敛一致的问题。三者是递进关系没有队列操作在弱网下会丢失没有缓存队列里的任务没有数据基础没有增量同步每次恢复联网都全量拉取流量和时间成本完全不可接受。不过离线优先并不适合所有业务。需要严格实时一致性的场景比如在线支付、实时音视频、协同编辑器里的光标级协作这些对时效性要求极高离线优先反而会引入复杂性。它更适合的是以个人数据为中心、允许最终一致的业务即时笔记、任务清单、订单记录、收藏夹、草稿箱、阅读进度同步这类。判断标准很简单用户在这个场景下是否接受稍后生效如果可以那离线优先架构就有发挥空间。2. 请求队列把网络请求变成一条可控的流水线2.1 为什么不能发出去就完事大多数应用请求网络的方式是即发即弃用户点击按钮代码发一个 http 请求回调里处理成功或失败。这种模式在稳定网络下没问题但在弱网环境下有几个硬伤。第一请求失败后没有上下文。http 请求失败一次你只知道失败了但不知道这个请求代表的是用户的哪个操作。如果用户在离线状态下修改了一条笔记的标题这个修改动作对应哪个服务端接口、哪条记录、期望什么结果这些信息都散落在调用栈里请求一失败就全丢了。第二无法控制并发。弱网状态下用户连续编辑了 10 条数据如果 10 个请求同时发出去本来就不稳定的网络会被瞬间打爆而且服务端处理顺序无法保证——如果这 10 条数据之间有依赖关系结果就乱了。第三没有重试机制。请求失败后你会重试但通常就是过几秒再试一次。如果服务端正在经历短暂的不可用你的重试只会加剧问题而不是解决问题。请求队列解决的就是这三个问题。它把发一个请求变成了提交一个任务。任务包含完整的操作信息有状态、有优先级、有重试策略队列调度器负责决定什么时候发、怎么发、失败了怎么办。2.2 队列的最小可用设计任务定义、状态机、顺序与并发我在 HarmonyOS 侧实现请求队列时没有引入任何第三方框架直接用 ArkTS 的类模型和 Promise 就够用了。核心是三个部分任务结构、任务状态、队列调度器。先看任务结构。一个典型的队列任务至少包含以下字段enum TaskStatus { Pending, // 等待执行 Running, // 执行中 Succeeded, // 已成功 Failed, // 已失败可重试 Cancelled // 已取消 } interface QueueTask { id: string; // 任务唯一标识建议用 UUID type: string; // 任务类型如 note.update payload: object; // 操作数据如 { noteId: xxx, title: 新标题 } createTime: number; // 创建时间戳 retryCount: number; // 已重试次数 maxRetry: number; // 最大重试次数 status: TaskStatus; idempotencyKey: string; // 幂等键防止重复提交 }任务状态机很简单Pending 进入 Running成功变为 Succeeded失败则看重试次数是否耗尽没耗尽回到 Pending 等待下次调度耗尽了就标成 Failed 并通知用户。队列调度器的核心逻辑是控制并发和顺序。我采用的策略是全局并发数限制为 1 到 3 之间的可配置值任务按创建时间排序同一业务对象的操作必须按顺序执行不同业务对象之间可以并行。这个设计的好处是即使用户连续编辑了同一条笔记 5 次5 个任务也是串行发送的服务端收到的是有序的更新而用户同时改笔记 A 和笔记 B这两个任务可以并行发出不互相阻塞。调度器的骨架代码大致是这样的export class RequestQueue { private taskQueue: QueueTask[] []; private runningCount 0; private maxConcurrent 2; private listeners: Mapstring, QueueTask[] new Map(); enqueue(task: QueueTask): void { task.status TaskStatus.Pending; this.taskQueue.push(task); this.dispatch(); } private dispatch(): void { if (this.runningCount this.maxConcurrent) { return; } const nextTask this.pickNextTask(); if (!nextTask) { return; } this.runningCount; this.execute(nextTask).finally(() { this.runningCount--; this.dispatch(); }); } private pickNextTask(): QueueTask | undefined { // 这里按业务对象分组同一组内按 createTime 升序 // 不同组之间用简单的轮转策略避免某个业务对象饿死 return this.taskQueue.shift(); } private async execute(task: QueueTask): Promisevoid { task.status TaskStatus.Running; try { const response await HttpClient.post(/api/note/update, task.payload, { headers: { X-Idempotency-Key: task.idempotencyKey } }); if (response.code 0) { task.status TaskStatus.Succeeded; } else { throw new Error(response.message); } } catch (error) { task.retryCount; if (task.retryCount task.maxRetry) { task.status TaskStatus.Pending; // 按指数退避重新入队 const delay this.calcBackoff(task.retryCount); setTimeout(() { this.enqueue(task); }, delay); } else { task.status TaskStatus.Failed; this.notifyTaskFailed(task); } } } }2.3 重试与指数退避给网络一点恢复时间弱网环境下的一个常见错误是失败后立即重试这会把一次网络拥塞放大成多次并发拥塞。正确的做法是指数退避第一次失败等 1 秒第二次等 2 秒第三次等 4 秒再加上一个随机抖动防止多个客户端在同一时刻同时重试。指数退避的计算方式calcBackoff(retryCount: number): number { const baseDelay 1000; // 基础延迟 1 秒 const maxDelay 30000; // 最大延迟 30 秒 const exponential Math.min(maxDelay, baseDelay * Math.pow(2, retryCount - 1)); const jitter Math.random() * 0.3 * exponential; // 增加 30% 随机抖动 return exponential jitter; }这里有两个细节值得注意第一抖动jitter不是可选的。如果 100 个设备同时断网又同时恢复没有抖动的重试策略会让它们在同一秒内全部请求服务端造成惊群效应。第二重试次数要有上限我一般设为 5 次左右。超过上限后任务进入 Failed 状态但要保留在本地等待用户主动触发重试或网络恢复事件触发补发。2.4 HarmonyOS 网络状态监听与队列联动队列本身可以独立工作但和网络状态联动后效果更好。HarmonyOS 提供了网络状态监听能力通常在ohos.net.connection模块下。当网络恢复时我们可以主动触发队列的立即调度而不用等下一个任务进入队列才触发。import { connection } from kit.NetworkKit; // 注册网络状态监听 connection.createNetConnection().then((netConnection) { netConnection.register((data) { const isConnected data.networkState.isConnected; if (isConnected this.hasPendingTasks()) { this.dispatchAll(); } }); });还有一个细节网络连接状态不代表网络可用性。在很多弱网环境下Wi-Fi 信号满格但出口带宽极低网络状态显示已连接但实际请求依然超时。所以我的做法是网络状态监听只作为唤醒信号真正判断网络是否可用还是以请求是否成功为准。没有请求成功就继续按退避策略排队不要因为网络已连接就盲目高频重试。3. 本地缓存HarmonyOS 侧的数据落地与读写策略3.1 缓存介质选型Preferences、RDB、文件缓存怎么选请求队列解决了操作不丢的问题但队列任务依赖的数据本身也需要在本地落地。HarmonyOS 提供多种本地存储能力选型直接影响后续的开发复杂度。我按数据形态把它们分成四类。第一类是 Preferences首选项适合存少量键值对比如用户配置、同步游标、上次同步时间。它的特点是读写快、接口简单但不适合存大量结构化数据。Weak ref、并发写入场景下要小心Preferences 在 HarmonyOS 上的写入是全量落盘的频繁写会影响性能。第二类是关系型数据库 RDB适合存结构化业务数据。HarmonyOS 的ohos.data.relationalStore提供了完整的 SQL 能力如果你有笔记、任务、订单这类需要按条件查询的数据RDB 是最自然的选择。它支持事务这是离线优先架构里的刚需——一个任务涉及多条数据的更新时比如删除文件夹里所有笔记事务能保证要么全成功要么全失败。第三类是文件缓存适合存图片、音视频这类大对象。弱网环境下用户打开一张大图如果每次都从网络拉体验会很糟糕。HarmonyOS 的ohos.file.fs提供了文件读写接口配合沙箱路径管理可以做简单的文件级缓存。文件缓存的淘汰策略通常和配套数据库里的元数据索引联系在一起单纯按文件名管理无法支持按修改时间淘汰这类需求。第四类是分布式数据服务KV Store它面向多设备场景可以做到同账号多设备间的数据同步。但要注意KV Store 的同步本身也依赖网络在弱网环境下它只会保证最终一致不会解决你的队列和同步问题。我建议不要把 KV Store 当成离线优先的银弹它的定位是多设备间数据自动同步的基础设施而不是离线场景的万能解。在我的实际项目里最常见的组合是Preferences 存同步游标和配置项RDB 存业务数据文件目录存大对象。三者配合覆盖了离线优先架构里所有的数据形态。3.2 缓存数据结构设计区分元数据、列表、详情本地缓存的数据结构不能照搬服务端接口的返回结构需要围绕离线可用这个目标重新设计。我通常会把数据分成三层元数据层、列表层、详情层。元数据层记录的是本地数据和服务端数据的映射关系。比如本地有一条笔记的草稿它对应服务端的哪条记录、创建时间是什么、上次同步时间是什么、本地是否还有未同步的变更。这些信息存放在独立的表里供队列任务和同步引擎使用。列表层是用户界面上展示用的数据。列表通常包含摘要字段比如笔记标题、更新时间、封面图 URL。列表数据的特点是数量可能很大但单条数据体量小。列表层需要设计分页和增量更新的机制不能每次全量替换。详情层是完整的数据体。比如笔记的正文、任务的子步骤、订单的全部字段。详情层字段多、体量大通常是在用户真正打开详情页时才加载而且弱网下要优先展示本地已有内容。这三层之间有明确的读写路径列表页读列表层详情页读详情层写操作更新列表层和详情层并创建队列任务同步引擎负责把变更推送到服务端。分层的好处是职责清晰如果不分层一个note表里又要存列表摘要又要存完整正文更新时很难判断哪些字段该同步、哪些不需要。3.3 缓存过期与淘汰TTL、LRU、版本决定本地缓存不是存了就永远有效。弱网环境下缓存数据可能和服务端长时间不一致所以必须有过期和淘汰策略。我常用三种策略组合。第一种是 TTL生存时间每条缓存记录记录一个expireAt时间戳读取时判断是否过期。这个策略适合时效性要求高的数据比如库存信息、价格、天气数据。笔记类数据不适合短 TTL因为用户可能一个月都不联网离线状态下仍然应该能查看自己的笔记。第二种是 LRU最近最少使用缓存数量达到上限时淘汰最久没被访问的记录。这个策略适合大对象缓存比如图片和音视频文件。HarmonyOS 的沙箱空间有限需要给缓存目录设置一个最大大小超限后按文件的最后访问时间清理。第三种是版本决定服务端数据维护版本号客户端缓存也记录版本号同步时如果发现服务端版本比本地高就用服务端数据覆盖本地如果本地有未同步的修改就不能简单地用服务端版本覆盖需要走冲突处理流程。版本策略是增量同步的基础也是保证最终一致性的关键。3.4 写入策略先写本地还是先写远端先写本地还是先写远端是我在架构评审时最常被问到的问题答案很明确离线优先架构里写操作一律先写本地再通过队列异步同步到远端。先写本地的好处是用户操作不依赖网络界面上立即看到结果体验是即时的。这在交互层面有决定性优势。但要注意先写本地不等于本地改完就万事大吉你需要处理后续所有可能失败的情况队列任务失败、本地数据和服务端数据冲突、用户在多设备上同时修改同一份数据。具体的写入流程我建议这样设计用户发起修改操作比如编辑标题。本地事务里同时完成两件事更新缓存表中的数据创建一条队列任务。如果本地事务成功界面立即刷新队列调度器在后面异步处理任务。如果队列任务最终失败且超过了最大重试次数标记该数据为同步失败在界面上提示用户并保留本地修改让用户可以手动重试。这个设计有一个隐含要求本地缓存表要持久化存储未同步标记。如果只把任务放在内存队列里应用进程被杀或者设备重启未同步的数据就丢了。所以队列任务本身也要落盘通常存在 RDB 的一张 task 表里启动时从这张表恢复队列。这是离线优先架构最容易遗漏的地方但它恰恰是操作不丢的最后一道保险。4. 增量同步从全量拉取到只传变化4.1 增量同步的本质服务端状态与客户端状态的差集离线优先架构里客户端和服务端各自维护一份数据状态增量同步的目标就是用最小的传输代价让两端状态最终一致。全量拉取的坏处很明显数据量一大每次同步都要下载全部数据流量、时间、服务端压力都受不了。增量同步本质上是在计算两个状态集合的差集。差集的方向有两个服务端有而客户端没有的需要拉取客户端有而服务端没有的需要推送。弱网环境下差集的计算方式直接决定同步效率——不能在客户端加载全量数据然后逐条比对那样和全量拉取没有本质区别。正确的方式是基于时间戳或版本号的增量查询。4.2 服务端配合updated_at、版本号、变更日志增量同步的关键在于服务端要能回答这样一个问题从时间点 T 到现在有哪些数据发生了变化为此服务端的数据表需要有一个辅助字段最常用的是updated_at上次更新时间复杂一点的还有一个version版本号。updated_at的同步逻辑是客户端在本地保存lastSyncTime上次同步时间同步时请求服务端返回所有updated_at lastSyncTime的数据。服务端执行 SQL 查询返回增量数据客户端合并这些数据并更新lastSyncTime。但updated_at有一个软肋服务器时间与客户端时间可能不一致而且如果一条记录在同步过程中被重复更新它的updated_at变化次数多但客户端不一定能感知每一次变化。在要求更高的场景里我会使用版本号加变更日志服务端为每条记录维护自增版本号同时在change_log表里记录每次变更的操作类型insert/update/delete、目标记录 ID、变更版本号。客户端每次同步时只需要请求大于本地版本号的所有变更日志然后逐个应用到本地。变更日志方案还有一个额外好处它能可靠地处理删除的同步。只用updated_at做增量同步时删除记录会让记录从表中消失客户端就无法通过拉取增量感知到删除除非服务端额外做软删除标记。而变更日志天然包含 delete 操作不会出现服务端删了但客户端一直留着的脏数据。我的实践是对需要可靠双向同步的业务数据统一用软删除 变更日志对只读的配置类数据用updated_at就足够了。4.3 客户端的同步状态机lastSyncTime、pendingSet客户端的同步引擎需要维护两个核心状态lastSyncTime或lastSyncVersion和pendingSet待推送变更集。lastSyncTime的更新要放在拉取流程的末尾而且必须先合并数据后推进游标。如果先更新了游标再合并数据中途失败会导致数据丢失。更稳妥的做法是把合并数据和推进游标放在同一个事务里事务内执行数据更新 SQL 和游标更新 SQL要么都成功要么都回滚。pendingSet记录的是本地产生了但还未成功推送到服务端的变更它本质上就是请求队列里那些未完成任务的数据视图。设计时要注意pendingSet里的每一条变更都需要携带一个本地生成的变更 ID这个 ID 在建行时生成推送成功后服务端记录它客户端收到确认后把这个变更从pendingSet移除。同步状态机的完整流程是这样的应用启动或收到网络恢复事件后先检查pendingSet是否为空。不为空就先将本地变更推送到服务端。推送成功后再请求服务端返回lastSyncTime之后的增量变更。将增量变更合并到本地数据库。事务性更新lastSyncTime。如果第 2 步推送失败中止同步保持本地状态不变等待下一次同步时机。这里有一个容易被忽视的细节先推后拉。为什么要先推送本地变更再拉取远端增量因为服务端的updated_at或版本号在接收到推送后会变化如果先拉取后推送拉取到的增量里不包含当前设备刚推送的数据推送之后还要再拉一次才能拿到自己刚才的改动浪费一次同步周期。先推后拉可以让每个周期内两端数据刚好收敛一次。4.4 冲突处理最后写入覆盖、服务端权威、字段级合并增量同步迟早会遇到冲突用户在设备 A 离线修改了笔记标题同时另一台设备 B 也修改了同一篇笔记的标题两者都基于同一个旧版本。同步时到底保留哪个常见方案有三种。第一种是最后写入覆盖Last-Write-Wins。这是实现成本最低的方案比较两条变更的本地时间戳或服务端接收时间时间晚的覆盖时间早的。问题在于设备 A 和 B 的时间很可能不一致用户手动改过系统时间就会导致完全错误的结果。如果服务端接收时间可靠我会优先在服务端打时间戳而不是用客户端传入的时间。第二种是服务端权威。服务端以自己存储的版本号为准如果客户端提交的变更基于一个旧版本服务端直接拒绝让客户端重新拉取最新数据并合并。这个方案对客户端要求较低但用户体验较差用户离线改的东西可能被直接丢弃需要重新编辑。第三种是字段级合并。把冲突检测细化到字段级别比如设备 A 改了标题、设备 B 改了正文合并后两个字段都保留。字段级合并实现较复杂但体验最好。HarmonyOS 应用里如果同步的是结构化数据可以给每个字段加一个lastModifiedTime合并时逐字段比较。我在实际项目中会根据业务字段的重要程度混合使用对用户明确有强一致要求的字段如任务是否完成用服务端权威 冲突提示对内容型字段如笔记正文用字段级合并。还有一个兜底策略无论采用哪种方案在发生冲突覆盖前把被覆盖的旧版本数据以历史版本的形式保存一份到本地。用户如果发现数据不对还可以从历史版本里找回。这个兜底在离线优先架构里是性价比极高的设计。5. 完整链路串联一个离线优先模块的落地示例5.1 场景设定即时笔记应用的单条笔记编辑为了让前面这些设计更具体我以一个即时笔记应用为例演示离线优先的完整链路。业务需求是用户可以在弱网或离线状态下编辑笔记的标题和正文也可以新建笔记所有修改在本地立即生效网络恢复后自动同步到服务端服务端可能同时存在其他设备对同一笔记的修改同步时要做增量拉取。在这个场景里需要的数据结构如下RDB 里存note表字段包括localId本地主键、remoteId服务端主键、title、content、updatedAt客户端本地修改时间、serverVersion服务端版本号、dirtyFlag0 表示已同步1 表示待推送另外一张sync_cursor表存lastSyncTime和lastSyncVersiontask_queue表存储请求队列中尚未完成的任务。5.2 整体流程拼接修改、落队列、同步、增量拉取当用户在离线状态下修改笔记标题业务流程是这样走的第一步用户在前端页面输入新标题点击保存。前端调用本地数据库事务更新note表中的title字段和updatedAt时间戳同时改dirtyFlag 1。协变地往task_queue表插入一条任务任务类型是note.updatepayload 里记录localId、title新值等。这一步像一个内存屏障所有操作在同一事务中完成要么全成功要么全失败。第二步如果请求队列调度器正在运行它会立即感知到新任务如果应用完全离线调度器会周期性地检查网络状态。当网络恢复后调度器把任务发送到服务端接口PUT /api/note/{remoteId}请求体里带上title和serverVersion同时带上一幂等键。服务端校验serverVersion如果和当前服务端版本不一致返回冲突。如果一致服务端更新数据把serverVersion加 1返回成功。第三步客户端收到成功后在本地事务中将该任务的dirtyFlag置为 0从task_queue移除任务并更新note表的serverVersion。第四步客户端发起增量拉取请求GET /api/note/changes?since12345服务端返回所有版本号大于 12345 的变更。客户端把返回的变更合并到本地数据库然后推进lastSyncVersion到服务端返回的最新版本号。整个流程结束后服务和客户端的数据达到一致。用户全程无感没有看到任何网络错误的提示。5.3 关键代码结构ArkTS 的数据库事务与队列任务下面给出核心代码片段帮助你理解本地事务和任务落库的具体写法。注意这里使用的是 HarmonyOS 关系型数据库的接口import { relationalStore } from kit.ArkData; async function updateNoteTitle(noteId: string, newTitle: string): Promisevoid { const store await getRdbStore(); await store.beginTransaction(); try { // 1. 更新笔记表 const updateValues new relationalStore.ValuesBucket(); updateValues.title newTitle; updateValues.updatedAt Date.now(); updateValues.dirtyFlag 1; const predicates new relationalStore.RdbPredicates(note); predicates.equalTo(localId, noteId); await store.update(updateValues, predicates); // 2. 插入队列任务 const taskValues new relationalStore.ValuesBucket(); taskValues.id generateUuid(); taskValues.type note.update; taskValues.payload JSON.stringify({ noteId: noteId, title: newTitle, changedAt: Date.now() }); taskValues.createTime Date.now(); taskValues.retryCount 0; taskValues.status 0; // Pending await store.insert(task_queue, taskValues); // 3. 提交事务 await store.commit(); } catch (error) { await store.rollback(); throw error; } }注意上面的dirtyFlag和task_queue是两套标记系统为什么需要两份因为dirtyFlag是给合并冲突检测用的它代表这条数据在当前设备上有未推送的本地修改而task_queue是给请求调度用的它代表有一条具体的操作任务待发送。两者在大多数时候保持一致但在任务失败、重试、冲突等场景下会临时出现差异保留两份标记可以在排查问题时提供更完整的信息。我在实际项目中曾经只保留task_queue后来发现有些数据改了但任务已经成功发送task_queue清空了而服务端因为某种原因没有生效这种状态下本地和服务端不一致但没有任何标记排查起来非常被动。6. 踩坑清单弱网环境下我实际遇到的问题6.1 超时时间不能一刀切我最初给所有网络请求设置统一的超时时间比如 10 秒。后来发现这是个坑有些接口比如图片上传在弱网下传输大文件10 秒根本不够用而有些轻量接口比如拉取用户信息在弱网下本来就不应该等 10 秒用户感知非常差。我的调整是按接口类型分级设置超时时间核心基础接口登录鉴权、数据同步超时 15 秒常规数据接口列表、详情超时 8 秒大文件上传接口超时 60 秒以上。另外超时不能只算连接时间还要包含读取响应的时间。Http 库通常提供connectTimeout和readTimeout两个参数都要设置。HarmonyOS 的ohos.net.http请求可以在请求参数里配置超时但要注意弱网下超时触发后底层 socket 可能还处于半开状态短时间内相同的请求可能会被底层的 TCP 重传机制拖住。所以我在超时后立即销毁该请求的上下文并让队列调度器切换到指数退避状态而不是马上重试。6.2 重复提交幂等键不是可选项弱网场景下最常见的重复提交事故是这样发生的用户点击发布按钮请求发出去服务端已经成功处理了但响应在弱网上超时丢失了。客户端认为请求失败进入重试逻辑于是同一个发布动作被服务端执行了两次。解决这个问题必须依赖幂等键。客户端在创建任务时就生成一个全局唯一的idempotencyKeyUUID 即可每次请求都带上它。服务端在处理请求时先检查这个键是否已经处理过如果处理过就直接返回之前的结果不再重复执行。需要注意的是幂等键的检查必须和业务操作的写入在同一个事务里否则并发请求下依然会重复处理。HarmonyOS 的 ArkTS 里生成 UUID 可以用ohos.util里的util.generateRandomUUID()但它生成的是随机字符串要确保它不包含特殊字符需要在请求头传递时做 URL 编码。我遇到过 debug 模式正常、release 模式偶发失败的情况就是因为 UUID 里带了大写字母服务端严格区分大小写而某些网关在传递时把大写转成了小写。后来统一改成小写 UUID问题消失。6.3 缓存穿透与缓存击穿在离线场景下的表现缓存穿透和击穿这两个概念大多出现在高并发服务端但在离线优先的客户端同样存在类似的坑。缓存的穿透现象是本地缓存里没有数据请求队列里也没有相关任务用户完全离线时打开一个从未打开过的详情页——比如一个从未缓存过的笔记详情——页面就会白屏因为没有数据可展示。解决方案是在详情页设计占位状态如果本地没有数据但网络不可用至少展示笔记标题如果列表里有摘要和一个内容暂未下载的提示而不要展示一个空白页面。更进一步的做法是在列表数据进入本地时把详情页的关键字段标题、摘要、更新时间一并冗余存储到详情表里这样用户离线打开详情页时至少能看到大部分内容差的只是富文本正文的图片等资源。缓存的击穿现象是缓存条目过期时间一到下一次访问恰好没有网络导致数据完全不可用。我之前把笔记本地缓存的 TTL 设置成 7 天结果用户出差 8 天没联网第 8 天打开应用发现所有笔记都打不开了。后来我把笔记这类用户私有内容的 TTL 设置为无限期——用户自己的数据只要本地有就永远可以查看。TTL 只用于公共配置和资源类数据。判断标准很简单这份数据会不会因为长期保存而产生法律或业务风险如果不会就倾向于保留。6.4 多设备同步时的分布式一致性问题离线优先架构天然是分布式的多个设备在离线状态下各自修改同一份数据最终需要通过同步收敛。这里最麻烦的问题不是技术而是产品预期。我踩过的具体坑是设备 A 离线修改了笔记标题设备 B 也离线修改了同一篇笔记的标题两边同时联机同步后根据最后写入覆盖策略其中一方的修改被覆盖了。用户完全不能接受自己的修改无声无息地消失。解决方案是引入冲突历史机制每次发生冲突覆盖时把被覆盖的旧值写入单独的conflict_history表并记录冲突时间、设备来源、新旧值。同步完成后如果检测到本次同步有冲突发生在界面上通过通知中心或列表页的待处理冲突入口提示用户。用户可以点开看详细对比手动选择保留哪个版本。虽然大多数用户不会主动去解决冲突但有记录、可找回、不静默丢弃这三个特性把不可接受的数据丢失转化为可接受的数据待确认。6.5 测试环境用模拟弱网工具而不是真机我见过很多团队在开发时用真机测弱网但真机模拟弱网的精度太差——在电梯里复现的弱网和我们想要的高延迟、低带宽、随机丢包组合完全不是一回事。后来我改用可控的弱网模拟工具在 PC 端用 Charles 或 mitmproxy 这类代理工具做带宽限制、延迟注入、丢包率设置手机连接代理来测试 HarmonyOS 应用。具体的模拟参数我建议这样配置三档第一档一般弱网延迟 300ms、丢包率 5%、带宽 1Mbps第二档很差弱网延迟 800ms、丢包率 15%、带宽 300Kbps第三档极端弱网延迟 1500ms、丢包率 30%、带宽 50Kbps。每一档都要跑一遍核心流程离线编辑、队列重试、恢复联网同步、冲突检测。另外测试时要关注一个容易漏掉的场景反复横跳。用户在两分钟内反复断开和恢复网络请求队列里的任务可能处于各种中间状态。我在测试中发现如果网络在请求发出后、响应返回前断开我的调度器会把这次请求视为失败并重试但服务端可能已经处理成功了等到断点恢复后重试请求带着同一幂等键过去服务端直接返回之前的成功结果这是正确的行为但如果服务端没有实现幂等键就会出现重复数据。这个案例再次印证幂等键不仅是一个设计选项它是离线优先架构的必选项。还有一个隐蔽的坑HarmonyOS 的远程模拟器和部分真机的超时行为与 PC 端 HTTP 库的默认行为并不完全一致。在真机上网络断开时 TCP 连接会进入一个很长的等待状态可能长达 1 分钟才抛出错误。所以我在请求封装层故意设置了readTimeout防止 UI 被一个迟迟不返回的请求卡住。如果发现某个请求在弱网断开场景下迟迟不回调优先检查你的 HTTP 客户端是否真的应用了超时配置而不是检查业务代码。写在最后一次真实项目里的取舍体会这套离线优先架构我落地过不止一次每次做技术取舍时都绕不开一个核心问题复杂度和收益的边界在哪里。如果你做的应用只在信号良好的城区使用用户对断网后能否继续编辑没有强预期那我建议不要引入整套离线优先架构——请求队列加本地缓存已经是性价比很高的组合增量同步可以等服务端 API 支持后再加。但如果你的用户场景里有地铁通勤、地下停车场、田野户外、跨国网络这些弱网环境离线优先带来的体验提升是巨大的用户会在评论区里明确感谢你没让我在地铁上丢文案。在 HarmonyOS 上实现这套架构难度不在于某个单独技术点而在于把队列、缓存、同步三者的状态机串起来让它们在各种异常情况下仍然保持一致。我给自己的检查清单只有三条用户操作能不能在本地立即生效未同步的数据在进程被杀后能否恢复同步冲突能不能被用户感知和解决。这三条做好了弱网体验就垮不了。最后分享一个我后来一直保留的习惯在开发阶段就在task_queue表里加一个debugInfo字段每次创建任务时把触发场景、当前网络状态、前后端版本号都写进去。排查线上问题时这个字段往往比任何日志都好用因为它记录了任务从诞生到结束的完整上下文。也许你觉得这只是个小技巧但在我处理过的多次用户数据不同步工单里debugInfo节省的排查时间是以小时计的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价