ArkData + HarmonyOS 离线巡检:请求幂等与重试恢复(连载04/06)【共创稿事节】
离线巡检最让人犹豫的提示,往往不是“保存失败”,而是“提交后页面没有收到成功反馈”。例如巡检员在地下机房保存一组检查记录,系统恰好在提交完成后退出。重新进入页面,这一组记录究竟已经存进数据库,还是仍停留在编辑状态?如果直接再点一次,前一篇的串行写队列虽然能把两次调用排好队,却不能阻止两组内容被依次插入。单次事务原子性已经解决了“只保存半组”,但重复提交的身份识别仍是一道独立的工程题。
前三篇沿用同一个 inspection_draft.db,第 2 版表 inspection_draft 有 id、title、body、updated_at、priority 五列。第三篇通过 writeSerial() 保障同实例写入按序进行,通过事务保障整组提交或回滚。本篇不更改这五个字段的含义,也不把已有的 add()、addBatch() 重新定义成另一种返回类型;只在数据库旁边新增提交凭据表,为同一组业务提交提供跨进程重启仍可查询的身份。

唯一键不是防抖:先明确一次请求的含义
页面按钮禁用能挡住很多重复点击,但系统重启、调用链超时、用户退回重试时,这个布尔值已经失效。一个好用的幂等键不是“每次请求时随机生成一个新值”,而应在一组草稿开始成为待提交任务时创建,随后跟着草稿一起保存。第一次发出、超时重试、关闭应用再恢复,必须传同一个 requestKey。用户确实新建了另一组巡检,则产生新的键。若把键绑定到设备、登录用户或当天日期,多个真正不同的提交会被错误合并。
本例约定 requestKey 为上层产生并持久化的短文本标识,允许英文字母、数字、下划线和连字符,长度 8~80。它是业务提交身份,不是应用内部任务号,也不需要出现在任何文章目录或图片文件名中。只有同一 key 与同一规范化内容重复出现,才返回“已提交”的历史结果;同一 key 配了不同内容必须拒绝,防止偶然覆盖。在生产工程中可以用系统安全随机能力生成并持久保存唯一键,本篇的仓储不承担生成键的职责。

数据库约束要提供最后一道防线。我们增加 inspection_submission(request_key TEXT PRIMARY KEY, payload_json TEXT NOT NULL, row_count INTEGER NOT NULL, created_at INTEGER NOT NULL)。request_key 的唯一索引由主键约束提供。提交凭据和实际草稿写入必须同一事务,否则会出现“凭据写了,草稿没写”或“草稿写了,凭据没写”的分裂状态。示例为了便于复现实验,使用规范化 JSON 直接比对内容;这是受限示例的明确设计取舍,若保存内容含敏感字段,应改用经过评审的强摘要及适当的数据保留和加密策略,不应长期重复存放业务正文。
先升级 Schema,再给提交凭据独立事务边界
从上一阶段的版本 2 升到版本 3 时,只新增一个凭据表,老表五列完全不动。旧版本 0 或 1 仍需要执行前面两篇已经定义的建表、补列逻辑。下面给出 entry/src/main/ets/model/DraftRepository.ets 的完整关键实现,沿用 @kit.ArkData 的 relationalStore.RdbStore 及既有 getRdbStore()、beginTransaction()、batchInsert()、commit()、rollBack() 等接口。保持单例仓储实例供页面和后台任务共同调用,比每次保存都创建新仓储更容易统一队列和打开过程。
import { relationalStore } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
export interface DraftInput {
title: string;
body: string;
priority: number;
}
export interface DraftRecord {
id: number;
title: string;
body: string;
updatedAt: number;
priority: number;
}
export interface SaveOutcome {
count: number;
replayed: boolean;
}
interface Receipt {
payloadJson: string;
count: number;
}
const CONFIG: relationalStore.StoreConfig = {
name: 'inspection_draft.db',
securityLevel: relationalStore.SecurityLevel.S1
};
const CREATE_V1: string =
'CREATE TABLE IF NOT EXISTS inspection_draft (' +
'id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, ' +
'body TEXT NOT NULL, updated_at INTEGER NOT NULL)';
const ADD_PRIORITY: string =
'ALTER TABLE inspection_draft ADD COLUMN priority INTEGER NOT NULL DEFAULT 0';
const CREATE_RECEIPT: string =
'CREATE TABLE IF NOT EXISTS inspection_submission (' +
'request_key TEXT PRIMARY KEY, payload_json TEXT NOT NULL, ' +
'row_count INTEGER NOT NULL, created_at INTEGER NOT NULL)';
export class DraftRepository {
private store: relationalStore.RdbStore | null = null;
private opening: Promise<relationalStore.RdbStore> | null = null;
private writeTail: Promise<void> = Promise.resolve();
private writeBlocked: boolean = false;
private async migrate(db: relationalStore.RdbStore): Promise<void> {
if (db.version > 3) throw new Error('当前数据库版本高于应用支持范围');
if (db.version === 3) return;
db.beginTransaction();
try {
if (db.version === 0) await db.executeSql(CREATE_V1);
if (db.version < 2) await db.executeSql(ADD_PRIORITY);
if (db.version < 3) await db.executeSql(CREATE_RECEIPT);
db.version = 3;
db.commit();
} catch (error) {
try { db.rollBack(); } catch (_) { this.writeBlocked = true; }
throw error;
}
}
private async ready(context: common.UIAbilityContext):
Promise<relationalStore.RdbStore> {
if (this.store !== null) return this.store;
if (this.opening === null) {
this.opening = (async (): Promise<relationalStore.RdbStore> => {
const db = await relationalStore.getRdbStore(context, CONFIG);
await this.migrate(db);
this.store = db;
return db;
})();
}
try { return await this.opening; }
finally { this.opening = null; }
}
private async writeSerial<T>(work: () => Promise<T>): Promise<T> {
const prior: Promise<void> = this.writeTail;
let release: () => void = () => {};
this.writeTail = new Promise<void>((resolve: () => void) => {
release = resolve;
});
await prior;
try { return await work(); }
finally { release(); }
}
private normalize(inputs: DraftInput[]): DraftInput[] {
if (inputs.length === 0 || inputs.length > 200) {
throw new Error('每组必须有1至200条草稿');
}
return inputs.map((item: DraftInput): DraftInput => {
const title: string = item.title.trim();
const body: string = item.body.trim();
if (!title || !body) throw new Error('标题与正文不能为空');
if (!Number.isInteger(item.priority) ||
item.priority < 0 || item.priority > 3) {
throw new Error('priority只接受0至3');
}
return { title, body, priority: item.priority };
});
}
private async receipt(db: relationalStore.RdbStore,
key: string): Promise<Receipt | null> {
const predicate = new relationalStore.RdbPredicates('inspection_submission');
predicate.equalTo('request_key', key);
const cursor = await db.query(predicate, ['payload_json', 'row_count']);
try {
if (!cursor.goToFirstRow()) return null;
return {
payloadJson: cursor.getString(cursor.getColumnIndex('payload_json')),
count: cursor.getLong(cursor.getColumnIndex('row_count'))
};
} finally {
cursor.close();
}
}
async saveBatchOnce(context: common.UIAbilityContext,
requestKey: string, inputs: DraftInput[]): Promise<SaveOutcome> {
if (!/^[A-Za-z0-9_-]{8,80}$/.test(requestKey)) {
throw new Error('提交标识不符合格式要求');
}
const normalized: DraftInput[] = this.normalize(inputs);
const payloadJson: string = JSON.stringify(normalized);
return await this.writeSerial(async (): Promise<SaveOutcome> => {
if (this.writeBlocked) throw new Error('数据库需要检查,写入口暂不可用');
const db: relationalStore.RdbStore = await this.ready(context);
db.beginTransaction();
try {
const existing: Receipt | null = await this.receipt(db, requestKey);
if (existing !== null) {
if (existing.payloadJson !== payloadJson) {
throw new Error('同一提交标识对应了不同内容');
}
db.commit();
return { count: existing.count, replayed: true };
}
const time: number = Date.now();
const values: relationalStore.ValuesBucket[] = normalized.map(
(item: DraftInput): relationalStore.ValuesBucket => ({
title: item.title, body: item.body,
priority: item.priority, updated_at: time
})
);
const count: number = await db.batchInsert('inspection_draft', values);
if (count !== values.length) throw new Error('数据库写入条数不匹配');
await db.insert('inspection_submission', {
request_key: requestKey,
payload_json: payloadJson,
row_count: count,
created_at: time
});
db.commit();
return { count, replayed: false };
} catch (error) {
try { db.rollBack(); }
catch (_) {
this.writeBlocked = true;
throw new Error('回滚结果未知,请暂停提交并检查数据库');
}
throw error;
}
});
}
// 上一阶段接口继续保留:addBatch返回本次插入条数。
async addBatch(context: common.UIAbilityContext,
inputs: DraftInput[]): Promise<number> {
if (inputs.length === 0) return 0;
const normalized: DraftInput[] = this.normalize(inputs);
return await this.writeSerial(async (): Promise<number> => {
const db = await this.ready(context);
if (this.writeBlocked) throw new Error('数据库暂不可写');
db.beginTransaction();
try {
const now: number = Date.now();
const buckets: relationalStore.ValuesBucket[] = normalized.map(
(v: DraftInput): relationalStore.ValuesBucket => ({
title: v.title, body: v.body, priority: v.priority, updated_at: now
})
);
const count: number = await db.batchInsert('inspection_draft', buckets);
if (count !== buckets.length) throw new Error('批量写入数量错误');
db.commit();
return count;
} catch (error) {
try { db.rollBack(); }
catch (_) { this.writeBlocked = true; throw new Error('回滚失败'); }
throw error;
}
});
}
// 上一阶段单条入口保留新增行主键的返回语义。
async add(context: common.UIAbilityContext,
title: string, body: string): Promise<number> {
const item: DraftInput = this.normalize([{ title, body, priority: 0 }])[0];
return await this.writeSerial(async (): Promise<number> => {
if (this.writeBlocked) throw new Error('数据库暂不可写');
const db = await this.ready(context);
return await db.insert('inspection_draft', {
title: item.title, body: item.body,
priority: item.priority, updated_at: Date.now()
});
});
}
async all(context: common.UIAbilityContext): Promise<DraftRecord[]> {
const db = await this.ready(context);
const predicate = new relationalStore.RdbPredicates('inspection_draft');
const cursor = await db.query(predicate,
['id', 'title', 'body', 'updated_at', 'priority']);
const rows: DraftRecord[] = [];
try {
const idCol = cursor.getColumnIndex('id');
const titleCol = cursor.getColumnIndex('title');
const bodyCol = cursor.getColumnIndex('body');
const timeCol = cursor.getColumnIndex('updated_at');
const priorityCol = cursor.getColumnIndex('priority');
while (cursor.goToNextRow()) {
rows.push({
id: cursor.getLong(idCol),
title: cursor.getString(titleCol),
body: cursor.getString(bodyCol),
updatedAt: cursor.getLong(timeCol),
priority: cursor.getLong(priorityCol)
});
}
} finally { cursor.close(); }
return rows.sort((a: DraftRecord, b: DraftRecord) => b.updatedAt - a.updatedAt);
}
}
代码把“有没有这个请求”放在事务内部查询,再写草稿和凭据。若提交成功但页面没有收到返回值,同一个 requestKey 会读到凭据并返回 { replayed: true },不会再次插入本组内容。如果第一次事务还未提交,另一个仓储实例发起相同 Key 的并行写入,会受到数据库事务及主键约束限制;发生冲突时本例仍向调用方抛异常,不能把所有唯一约束错误都当作成功。业务应等待本次未知提交结束后,用同一 Key 发起明确的重新查询或重试。

提交结果要分“首次写入”和“此前已完成”
返回类型之所以增加 replayed,不是为了在页面展示更多技术术语,而是让调用方可以正确恢复状态。首次成功时,页面可把草稿组标记为已提交;重放命中凭据时,也可以把上次不确定的提交视为已经确认完成。两种场景都应该关闭草稿编辑入口,但文案可以不同:前者“已保存 3 条”,后者“已确认此前保存的 3 条”。若同一 Key 对应内容发生变化,说明用户更改了待提交组,应拒绝后让业务创建新的提交身份,不能悄悄复用旧的凭据。
在正式页面中,requestKey 应与待提交的 DraftInput[] 作为同一条“待提交组”持久保存。页面创建待提交组时生成并落盘 Key,然后才允许尝试调用 saveBatchOnce();即使调用返回错误,也不要在捕获异常时重新生成 Key。对“页面已经显示成功但数据库未保存”的担忧,最直接的办法是只在仓储确认提交或凭据重放后,才更新 UI 成功状态。持久化组可以使用本地草稿表或另一个受管理的数据仓储,不能只把它放进一个进程内 @State 变量并宣称跨重启可靠。
还有一个重要的业务条件:本示例是端侧单数据库幂等,不是分布式端云“恰好一次”语义。将来若引入服务器同步,服务端仍需建立自身的请求去重与提交记录,不能凭端上唯一键就保证远程系统不重复处理。多实例共享写入也要遵守数据库约束和应用统一初始化策略,不能把单实例 writeSerial() 扩展解释成所有进程之间的锁。
用故障注入验证边界,再讨论重试策略
建议按五个维度准备验证。首次使用新 Key 提交两条合法数据,观察 replayed=false、新增行数为二、凭据表存在对应 Key。第二次使用相同 Key 和相同内容,应该返回 replayed=true,草稿行数不变。第三次用相同 Key、改变一条正文,应当拒绝且不修改旧提交。第四次在持久化成功后页面没有收到响应,再以原 Key 启动新进程重试,重点核对数据库最终行数;这一步要实际控制故障注入条件,不可仅靠代码注释假装已验证。
第五种是事务内部的真实错误:在测试构建里通过可控约束让草稿插入失败,检查凭据表和草稿表是否同步回滚;再单独让 rollBack() 失败,检查 writeBlocked 是否禁止继续写入。对生产构建来说,遇到无法判断数据库是否已提交的错误,不宜自动立刻循环提交。更稳妥的策略是记录 Key、保留草稿、提供可重试状态,并限制重试次数与时间间隔。离线环境里,取消请求、页面切换和系统回收都不必解释为“持久化已经撤销”。
有人会问:既然做了唯一约束,为什么还要比较 payload_json?因为数据库主键只能证明 Key 已出现,不能证明本次传入的是同一批业务内容。另一个疑问是为什么不直接对每条 inspection_draft 设置唯一标题:标题并不是唯一业务身份,同一设备可能多次执行同名巡检。以人为定义的标题去重会丢掉合法历史。真正的重放凭据应绑定一次明确的提交意图。
现阶段形成的写入链路是:表单组及稳定 Key 落盘、输入规范化、写队列、事务内查凭据、首次插入或重放确认、返回可区分的结果。它和上一阶段的原子性、顺序性相互补足。下一篇再将“按 ID 精准获取”和“按更新时间分页”的查询方案接进来,避免重试恢复阶段为了确认一组记录而把所有历史巡检一次性加载到界面。
参考资料:华为开发者:关系型数据库持久化、relationalStore 接口参考、ArkData Transaction API 变更参考。
更多推荐

所有评论(0)