离线优先应用在没有网络时需要继续完成数据录入和查询,SQLite作为嵌入式数据库自然成为端侧存储的首选。而中心侧往往采用企业级数据库保证事务一致性和高并发写入,浪潮K-DB凭借对Oracle语法的高度兼容,在国产化替代项目中常用于承担这一角色。本文以一个巡检数据采集项目为例,端侧App使用SQLite保存离线记录,中心服务使用浪潮K-DB汇总所有终端数据。下面展开表结构映射、增量同步、冲突处理和性能优化四个部分。

一、表结构与数据类型映射
SQLite采用的是动态类型系统,每一列可以存储任意类型的数据,而浪潮K-DB作为关系型数据库,要求列具备明确的类型约束。直接使用SQLite的原始建表语句拿到K-DB中执行通常会因为类型不兼容而失败。因此同步方案的第一步就是建立一套清晰的类型映射规则。对于整数主键,SQLite中的INTEGER可以映射为K-DB的NUMBER(19),文本字段TEXT映射为VARCHAR2(4000),浮点数REAL映射为NUMBER(18,6),二进制内容BLOB保持不变。下表列出了常用类型的对应关系。
| SQLite类型 | 浪潮K-DB类型 | 适用场景 |
|---|---|---|
| INTEGER | NUMBER(19) | 主键、计数、时间戳毫秒值 |
| TEXT | VARCHAR2(4000) | 短文本、状态码、JSON字符串 |
| REAL | NUMBER(18,6) | 金额、测量值、坐标 |
| BLOB | BLOB | 图片、附件、加密数据 |
| NUMERIC | NUMBER | 动态精度数值 |
除了类型映射,主键生成策略也需要提前统一。SQLite习惯使用INTEGER PRIMARY KEY AUTOINCREMENT生成自增ID,但这种ID在多个终端上会重复。一旦把多个离线端的数据合并到K-DB,主键冲突几乎不可避免。推荐的做法是使用全局唯一标识,例如UUID字符串或雪花算法生成的64位长整型。如果已经存在自增主键,可以在同步层增加一个device_id加local_id的复合键来保证唯一性。K-DB建表时可以为UUID主键创建对应的序列或使用应用侧生成的ID直接插入。
SQL方言差异同样不可忽视。SQLite的日期函数写作datetime('now'),而K-DB使用SYSDATE;分页时SQLite用LIMIT 20 OFFSET 40,K-DB既支持ROWNUM也支持OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY。建议将差异封装在数据访问层,避免业务代码到处写方言判断。
-- SQLite端建表
CREATE TABLE IF NOT EXISTS user_profile (
id TEXT PRIMARY KEY,
user_name TEXT NOT NULL,
age INTEGER,
balance REAL,
updated_at TEXT DEFAULT (datetime('now')),
version INTEGER DEFAULT 0
);
-- 浪潮K-DB端建表
CREATE TABLE user_profile (
id VARCHAR2(64) PRIMARY KEY,
user_name VARCHAR2(200) NOT NULL,
age NUMBER(3),
balance NUMBER(18,2),
updated_at DATE DEFAULT SYSDATE,
version NUMBER(10) DEFAULT 0
);
二、增量变更捕获与同步队列
离线端的数据会不断增删改,同步时不可能每次都全量扫描本地库并推送到K-DB,那样既浪费流量又容易覆盖中心端的新数据。增量同步的核心在于记录每一次本地变更。比较轻量的做法是在每张业务表上增加updated_at和version字段,并在应用代码中维护一张sync_log表。每次执行INSERT、UPDATE或DELETE时,同时向sync_log写入一条记录,包含表名、行ID、操作类型和时间信息。
如果不想侵入业务代码,也可以使用SQLite的触发器自动生成变更日志。触发器方式的好处是应用层无需关心日志写入,只要专注业务SQL即可。下面的触发器示例展示了在user_profile表发生插入或更新后,自动往sync_log写入待同步记录。DELETE操作需要额外定义触发器,并把被删除行的ID保存下来。
CREATE TABLE sync_log (
log_id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
row_id TEXT NOT NULL,
op_type TEXT NOT NULL,
change_time TEXT DEFAULT (datetime('now')),
synced INTEGER DEFAULT 0
);
CREATE TRIGGER trg_user_profile_insert AFTER INSERT ON user_profile
BEGIN
INSERT INTO sync_log(table_name, row_id, op_type)
VALUES ('user_profile', NEW.id, 'INSERT');
END;
CREATE TRIGGER trg_user_profile_update AFTER UPDATE ON user_profile
BEGIN
INSERT INTO sync_log(table_name, row_id, op_type)
VALUES ('user_profile', NEW.id, 'UPDATE');
END;
同步任务启动后,从sync_log中查询synced = 0的记录,按log_id升序处理。每处理完一条,就把synced置为1。注意处理批次必须保持顺序,尤其当同一行连续出现INSERT、UPDATE、DELETE时,K-DB端应该按相同顺序执行,否则最终状态会出错。建议在K-DB端使用预编译语句和批量提交,减少网络往返和事务开销。
public void pushPendingChanges(Connection sqliteConn, Connection kdbConn) throws SQLException {
String selectSql = "SELECT log_id, table_name, row_id, op_type, change_time FROM sync_log WHERE synced = 0 ORDER BY log_id";
kdbConn.setAutoCommit(false);
int processed = 0;
try (Statement stmt = sqliteConn.createStatement();
ResultSet rs = stmt.executeQuery(selectSql)) {
while (rs.next()) {
long logId = rs.getLong("log_id");
String table = rs.getString("table_name");
String rowId = rs.getString("row_id");
String opType = rs.getString("op_type");
applyChange(kdbConn, table, rowId, opType);
markLogSynced(sqliteConn, logId);
processed++;
if (processed % 200 == 0) {
kdbConn.commit();
}
}
kdbConn.commit();
} finally {
kdbConn.setAutoCommit(true);
}
}
三、双向同步与冲突解决策略
仅仅把离线端的数据推送到K-DB只是单向同步。实际项目中,中心端也可能由其他用户或后台任务修改数据,这些变更同样需要下发到各个终端。双向同步会带来数据冲突:同一个字段在离线端和中心端都被修改,到底保留哪个版本?最常用的策略包括最后写入获胜、版本号比较和字段级合并。对于大多数表单类应用,版本号加时间戳比较已经足够解决绝大部分冲突。
具体做法是业务表保留一个整数版本号version,每次更新时将版本号加1。同步时终端把本地版本号与K-DB中的当前版本号进行比较。如果本地版本更高,说明离线修改较新,允许覆盖中心端;如果中心端版本更高,则放弃本地修改并拉取中心端数据。如果两个版本相同但内容不同,说明发生了并发修改,可以按照业务规则选择保留一方或者提示用户手动合并。
private void applyIncomingChange(Connection kdbConn, String rowId, int incomingVersion, String incomingName) throws SQLException {
String selectSql = "SELECT version FROM user_profile WHERE id = ?";
try (PreparedStatement ps = kdbConn.prepareStatement(selectSql)) {
ps.setString(1, rowId);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
int currentVersion = rs.getInt("version");
if (incomingVersion > currentVersion) {
String updateSql = "UPDATE user_profile SET user_name = ?, version = ? WHERE id = ?";
try (PreparedStatement ups = kdbConn.prepareStatement(updateSql)) {
ups.setString(1, incomingName);
ups.setInt(2, incomingVersion);
ups.setString(3, rowId);
ups.executeUpdate();
}
}
} else {
String insertSql = "INSERT INTO user_profile (id, user_name, version) VALUES (?, ?, ?)";
try (PreparedStatement ips = kdbConn.prepareStatement(insertSql)) {
ips.setString(1, rowId);
ips.setString(2, incomingName);
ips.setInt(3, incomingVersion);
ips.executeUpdate();
}
}
}
}
}
删除操作需要特别处理。SQLite端删除一行后,本地已经不存在该行,无法再拿到版本号。因此删除同步通常采用逻辑删除,即增加deleted标记,而不是物理删除。同步时把deleted = 1和最新版本号传给K-DB,由中心端决定是否物理删除或保留归档。这样即使断网期间中心端又更新了该行,同步时也能比较版本号,避免误删新数据。
在K-DB端,可以利用事务把冲突判断和写入包在一起,保证并发同步时的原子性。例如先SELECT ... FOR UPDATE锁定行,再比较版本号,最后执行更新。如果并发量很高,建议把冲突解决逻辑下沉到存储过程中,减少应用与数据库之间的多次往返。
四、事务边界与性能调优
SQLite是基于文件的嵌入式数据库,写入时默认会锁定整个数据库文件。如果每次同步一条记录就提交一次事务,磁盘I/O会非常频繁,同步大量数据时性能会急剧下降。建议在SQLite端开启WAL模式,并设置synchronous = NORMAL,这样读写可以并发进行,写入吞吐量也能明显提升。K-DB端则要利用批量提交,每处理200到500条记录执行一次commit,避免单个事务过大导致回滚段压力。
-- SQLite端优化设置 PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA cache_size = -64000;
索引设计同样影响同步效率。sync_log表的synced和log_id字段应该建立联合索引,加快待同步记录的查询速度。K-DB端的业务表主键和版本号字段也要有合适的索引,否则冲突检测的SELECT语句会退化为全表扫描。对于大表,可以考虑按时间范围分区,将历史数据与近期活跃数据分离。
预编译语句和绑定变量在K-DB上能显著降低SQL解析开销。Java代码中的PreparedStatement已经自动使用绑定变量,但要注意不要在循环中拼接SQL字符串。网络传输方面,如果同步的数据量较大,可以在应用层对变更记录做批量JSON序列化后压缩上传,中心端解压后批量写入。这样比逐条远程调用高效得多。
综合来看,SQLite与浪潮K-DB的协同并不复杂,关键在于把类型映射、变更日志、版本号冲突处理和事务边界这四个环节做扎实。本文给出的方案已经在多个离线采集项目中稳定运行,断网恢复后能够自动完成数据合并,且不会出现主键冲突或数据回退。读者可以根据自己的业务场景调整冲突策略和批量大小,快速搭建一套可靠的离线优先数据同步组件。