系列数据架构篇·第31篇 。有金融行业的读者问:"之前的分布式数据对象(DDO)虽然好用,但那是内存级的,断电就没了。如果我要做一个跨设备的记账App,数据怎么持久化?怎么保证手机记的账和手表看到的一致?" 这触及了鸿蒙分布式架构的核心难点。今天我们将电商Demo的后台数据层升级,引入分布式数据库(Distributed Data Store, DDS) ,实现一个支持CRDT(无冲突复制数据类型) 的跨设备账本。我们将解决网络分区、数据冲突、离线写入三大分布式难题。全程基于API23,含官方文档未涉及的"最终一致性"调优参数。
一、前言:为什么DDO不够用,必须用DDS?
在之前的分布式流转篇中,我们使用了DistributedDataObject(DDO)。它适合临时状态同步 (如购物车、播放进度),但不适合持久化数据存储。
| 特性 | DistributedDataObject (DDO) | Distributed Data Store (DDS) |
|---|---|---|
| 生命周期 | 随Ability销毁而消失 | 持久化存储,重启不丢失 |
| 数据模型 | JS对象,简单直观 | 数据库表,支持复杂查询 |
| 一致性 | 最终一致,无冲突解决机制 | CRDT/LastWriteWin,可控性强 |
| 适用场景 | 购物车、游戏状态 | 记账本、备忘录、联系人 |
核心痛点 :用户在手表上离线记了一笔账,手机没开机。等手机联网后,这笔账必须能自动同步过来,且不能覆盖手机后来产生的数据。这就是离线写入与冲突解决。
二、核心概念:CRDT与SyncMode
HarmonyOS DDS底层采用了**CRDT(Conflict-free Replicated Data Types)**技术。简单来说,它定义了一套数学规则,让不同设备上的数据即使同时修改,也能自动合并,不需要中央服务器裁决。
关键配置:
-
SyncMode(同步模式):
-
SYNC_MODE_PUSH_ONLY:只推不改(适合日志上报)。 -
SYNC_MODE_PULL_ONLY:只拉不改(适合数据备份)。 -
SYNC_MODE_FULL_SYNC:全量同步(默认,记账本必选)。
-
-
ConflictResolutionPolicy(冲突解决策略):
-
LAST_WRITE_WIN:最后修改时间胜出(简单粗暴,适合单用户多设备)。 -
CUSTOM:自定义策略(复杂,但最安全,适合金融场景)。
-
三、代码实现:构建分布式记账本
3.1 定义数据库Schema
首先,定义账本的数据结构。在entry/src/main/ets/db/schema.ets中:
import { distributedKVStore } from '@kit.ArkData'
// 账本条目结构
export interface BillEntry {
id: string; // 唯一ID (UUID)
amount: number; // 金额
category: string; // 分类
timestamp: number; // 创建时间戳
deviceId: string; // 创建设备ID
version: number; // 版本号(用于CRDT)
}
// 数据库配置
export const BILL_DB_CONFIG: distributedKVStore.Options = {
createIfMissing: true,
encrypt: true, // 金融数据必须加密
backup: false, // 分布式数据库由系统自动备份
kvStoreType: distributedKVStore.KVStoreType.DEVICE_COLLABORATION, // 设备协同型
securityLevel: distributedKVStore.SecurityLevel.S3, // 最高安全等级
syncPolicy: {
syncMode: distributedKVStore.SyncMode.SYNC_MODE_FULL_SYNC,
conflictResolutionPolicy: distributedKVStore.ConflictResolutionPolicy.LAST_WRITE_WIN
}
}
3.2 封装分布式数据库管理器
创建entry/src/main/ets/common/BillDBManager.ets:
import { distributedKVStore } from '@kit.ArkData'
import { BusinessError } from '@kit.BasicServicesKit'
import { schema, BillEntry, BILL_DB_CONFIG } from '../db/schema'
export class BillDBManager {
private static instance: BillDBManager | null = null
private kvStore: distributedKVStore.KVStore | null = null
private context: Context = null!
private constructor() {}
static getInstance(): BillDBManager {
if (!BillDBManager.instance) {
BillDBManager.instance = new BillDBManager()
}
return BillDBManager.instance
}
// 初始化数据库
async init(context: Context): Promise<void> {
this.context = context
try {
const kvManagerConfig: distributedKVStore.KVManagerConfig = {
context: context,
bundleName: 'com.example.shop'
}
const kvManager = distributedKVStore.createKVManager(kvManagerConfig)
// 获取/创建数据库
this.kvStore = await kvManager.getKVStore<distributedKVStore.KVStore>(
'bill_db',
BILL_DB_CONFIG
)
// 注册数据变更监听(跨设备同步时会触发)
this.kvStore.on('dataChange', distributedKVStore.SubscribeType.SUBSCRIBE_TYPE_ALL, (data) => {
console.log('分布式数据变更:', data.insertEntries, data.updateEntries)
// 这里可以通知UI刷新
})
console.log('分布式数据库初始化成功')
} catch (err) {
const e = err as BusinessError
console.error(`数据库初始化失败: ${e.code}, ${e.message}`)
}
}
// 新增账单(离线也可写入)
async addBill(entry: BillEntry): Promise<void> {
if (!this.kvStore) return
try {
// Key使用UUID,Value是序列化后的对象
const key = `bill_${entry.id}`
const value = JSON.stringify(entry)
await this.kvStore.put(key, value)
console.log('账单写入成功,等待同步...')
} catch (err) {
console.error('写入失败:', err)
}
}
// 手动触发同步(通常在网络恢复或应用前台时调用)
async syncData(): Promise<void> {
if (!this.kvStore) return
try {
// 获取所有在线设备ID
const devices = await distributedKVStore.getOnlineDevices()
if (devices.length > 0) {
await this.kvStore.sync(devices, distributedKVStore.SyncMode.SYNC_MODE_FULL_SYNC)
console.log('数据同步请求已发送')
}
} catch (err) {
console.error('同步失败:', err)
}
}
// 查询所有账单
async queryAllBills(): Promise<BillEntry[]> {
if (!this.kvStore) return []
try {
const keys = await this.kvStore.getEntries('bill_')
const bills: BillEntry[] = []
for (const key of keys) {
const value = await this.kvStore.get(key)
bills.push(JSON.parse(value as string))
}
// 按时间戳排序
return bills.sort((a, b) => b.timestamp - a.timestamp)
} catch (err) {
console.error('查询失败:', err)
return []
}
}
}
3.3 在UI中集成(记账页面)
在穿戴设备或手机端的记账页面中调用:
import { BillDBManager } from '../common/BillDBManager'
@Entry
@Component
struct BillPage {
private dbManager: BillDBManager = BillDBManager.getInstance()
@State billList: BillEntry[] = []
aboutToAppear(): void {
this.dbManager.init(getContext(this))
this.loadBills()
}
async loadBills(): Promise<void> {
this.billList = await this.dbManager.queryAllBills()
}
// 新增一笔账
async addNewBill(): Promise<void> {
const newBill: BillEntry = {
id: `uuid_${Date.now()}`, // 实际应使用系统UUID工具
amount: 99.8,
category: '餐饮',
timestamp: Date.now(),
deviceId: 'watch_01', // 实际应从系统获取
version: 1
}
await this.dbManager.addBill(newBill)
// 写入后立即刷新列表(本地已写入,远端正在同步)
this.loadBills()
// 提示用户"已保存,正在同步"
promptAction.showToast({ message: '账单已保存,将在联网后同步' })
}
build() {
Column() {
Button('记一笔:早餐 99.8元')
.onClick(() => this.addNewBill())
List() {
ForEach(this.billList, (item: BillEntry) => {
ListItem() {
Row() {
Text(item.category)
Blank()
Text(`¥${item.amount}`)
}
}
})
}
}
}
}
四、踩坑记录(官方文档没写的分布式细节)
-
UUID生成 :分布式环境下,绝对不能用时间戳或自增ID作为主键,否则必然冲突。必须使用
util.generateUUID()生成全局唯一ID。 -
网络分区处理 :当设备长时间离线后重新联网,DDS会自动同步,但可能会触发
dataChange事件风暴。建议在UI层做防抖处理,避免列表频繁刷新导致卡顿。 -
加密性能损耗 :开启
encrypt: true后,读写性能会下降约20%。对于非敏感数据(如商品浏览历史),可以不加密。但对于账本,必须加密。 -
同步时机 :系统会在设备上线、网络恢复、应用前台时自动触发同步,无需手动调用
sync。但为了确保关键数据(如刚记的账)尽快同步,建议在put操作后手动调用一次sync。 -
数据大小限制 :单条KV记录的大小不能超过4MB。对于账单这种小数据完全足够,但如果是图片或文件,需要存在文件系统,数据库中只存路径。