一、为什么需要跨设备数据同步

想象一下这样的场景:你正在用手机记录今天的开支,晚上回到家想用电脑继续整理账单,却发现数据根本没同步过来。这种多设备间的数据不一致问题,在移动互联网时代越来越常见。

SQLite作为轻量级数据库,被广泛应用在移动端和桌面端。但它本身只是个单机数据库,没有内置的同步功能。当我们需要在多个设备间保持数据一致时,就得自己动手实现同步方案。

二、SQLite同步的核心挑战

实现SQLite跨设备同步,主要面临三个技术难点:

  1. 冲突解决:当两个设备同时修改同一条记录时,该听谁的?
  2. 网络不可靠:移动设备经常断网,如何保证数据最终一致?
  3. 性能考量:同步过程不能影响主线程,尤其是移动端要保持流畅体验。

下面我们用一个实际的例子来演示解决方案。技术栈选择Node.js + SQLite,因为这个组合在服务端和客户端都能运行,非常适合演示跨平台场景。

三、基于时间戳的同步方案实现

这里介绍一种简单可靠的同步策略:时间戳标记法。每个记录都带有两个时间戳:

// Node.js + SQLite示例
const sqlite3 = require('sqlite3').verbose();

// 创建带有时间戳的表
const db = new sqlite3.Database(':memory:');
db.serialize(() => {
  db.run(`
    CREATE TABLE expenses (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      amount REAL NOT NULL,
      category TEXT,
      created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
      sync_flag INTEGER DEFAULT 0  // 0-未同步 1-已同步
    )
  `);
  
  // 添加触发器自动更新updated_at
  db.run(`
    CREATE TRIGGER update_timestamp
    AFTER UPDATE ON expenses
    FOR EACH ROW
    BEGIN
      UPDATE expenses SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
    END
  `);
});

同步逻辑的关键代码:

// 同步函数示例
async function syncData(lastSyncTime) {
  return new Promise((resolve, reject) => {
    // 获取需要同步的数据
    db.all(
      `SELECT * FROM expenses WHERE updated_at > ? OR sync_flag = 0`,
      [lastSyncTime],
      (err, localChanges) => {
        if (err) return reject(err);
        
        // 这里应该是发送到服务器的网络请求
        console.log('需要上传的变更:', localChanges);
        
        // 模拟服务器返回远程变更
        const remoteChanges = [
          {id: 5, amount: 99.9, category: '餐饮', updated_at: '2023-05-20 15:30:00'}
        ];
        
        // 处理冲突
        remoteChanges.forEach(remote => {
          db.get(
            `SELECT * FROM expenses WHERE id = ?`, 
            [remote.id],
            (err, local) => {
              if (!local || new Date(remote.updated_at) > new Date(local.updated_at)) {
                // 远程数据更新则覆盖本地
                db.run(
                  `INSERT OR REPLACE INTO expenses VALUES (?, ?, ?, ?, ?, 1)`,
                  [remote.id, remote.amount, remote.category, remote.created_at, remote.updated_at]
                );
              }
            }
          );
        });
        
        // 更新已同步标记
        db.run(
          `UPDATE expenses SET sync_flag = 1 WHERE updated_at <= ?`,
          [new Date().toISOString()]
        );
        
        resolve();
      }
    );
  });
}

四、进阶方案:操作日志同步

对于更复杂的场景,可以考虑**操作日志(Operation Log)**方案。每次数据变更都记录操作指令,通过重放指令实现同步:

// 操作日志表
db.run(`
  CREATE TABLE IF NOT EXISTS operation_log (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    table_name TEXT NOT NULL,
    operation_type TEXT CHECK(operation_type IN ('INSERT', 'UPDATE', 'DELETE')),
    record_id INTEGER,
    operation_content TEXT,  // JSON格式记录变更内容
    timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  )
`);

// 示例:记录插入操作
function logInsert(table, id, content) {
  db.run(
    `INSERT INTO operation_log (table_name, operation_type, record_id, operation_content) 
     VALUES (?, 'INSERT', ?, ?)`,
    [table, id, JSON.stringify(content)]
  );
}

五、技术选型与注意事项

适用场景

  • 个人记账/笔记类应用
  • 小型团队的任务管理系统
  • 物联网设备数据采集

方案对比

方案 优点 缺点
时间戳 实现简单 冲突解决不够精细
操作日志 可追溯所有变更 存储开销较大

注意事项

  1. 时区问题:所有设备必须使用UTC时间
  2. 数据加密:敏感数据在传输时需要TLS加密
  3. 性能优化:首次同步大数据量时分批传输

六、完整实现示例

下面给出一个更完整的同步服务示例,包含客户端和服务端代码:

// 服务端代码 (Node.js)
const express = require('express');
const bodyParser = require('body-parser');
const app = express();

app.use(bodyParser.json());

// 模拟服务端数据库
let serverData = {
  expenses: [
    {id: 1, amount: 100, category: '交通', updated_at: '2023-05-18T10:00:00Z'}
  ]
};

// 同步接口
app.post('/sync', (req, res) => {
  const {lastSyncTime, changes} = req.body;
  
  // 1. 接收客户端变更
  changes.forEach(change => {
    const existing = serverData.expenses.find(x => x.id === change.id);
    if (!existing || new Date(change.updated_at) > new Date(existing.updated_at)) {
      serverData.expenses = serverData.expenses.filter(x => x.id !== change.id);
      serverData.expenses.push(change);
    }
  });
  
  // 2. 返回服务端变更
  const serverChanges = serverData.expenses.filter(
    x => new Date(x.updated_at) > new Date(lastSyncTime)
  );
  
  res.json({changes: serverChanges, newSyncTime: new Date().toISOString()});
});

app.listen(3000);
// 客户端代码
async function fullSync() {
  const lastSyncTime = localStorage.getItem('lastSync') || '1970-01-01T00:00:00Z';
  
  // 获取本地变更
  const localChanges = await new Promise(resolve => {
    db.all(
      `SELECT * FROM expenses WHERE updated_at > ? OR sync_flag = 0`,
      [lastSyncTime],
      (err, rows) => resolve(rows)
    );
  });
  
  // 发送到服务端
  const response = await fetch('http://localhost:3000/sync', {
    method: 'POST',
    headers: {'Content-Type': 'application/json'},
    body: JSON.stringify({
      lastSyncTime,
      changes: localChanges
    })
  });
  
  const {changes: serverChanges, newSyncTime} = await response.json();
  
  // 应用服务端变更
  await applyRemoteChanges(serverChanges);
  
  // 更新同步时间
  localStorage.setItem('lastSync', newSyncTime);
}

async function applyRemoteChanges(changes) {
  changes.forEach(item => {
    db.run(
      `INSERT OR REPLACE INTO expenses VALUES (?, ?, ?, ?, ?, 1)`,
      [item.id, item.amount, item.category, item.created_at, item.updated_at]
    );
  });
}

七、总结与展望

实现SQLite的跨设备同步,关键在于选择适合业务场景的同步策略。对于个人开发者,时间戳方案简单易实现;而企业级应用可能需要更复杂的操作日志方案。

未来可以考虑:

  1. 集成WebSocket实现实时同步
  2. 使用CRDT数据结构解决复杂冲突
  3. 结合IndexedDB实现浏览器端存储

无论选择哪种方案,都要记得做好数据备份。同步过程中的错误处理也很重要,建议实现断点续传功能,避免网络中断导致重新传输大量数据。