从混乱到清晰:后端如何为 Local First 任务管理提供可靠的增量同步 API!
大家好,我是小明他不是名。今天咱们聊点实在的。 很多做任务管理工具的朋友,一开始都觉得“离线也能用”这个需求挺简单。但真上手后就发现:用户在手机上改了任务,平板上也改了,网页端又改了一次——这些数据怎么合并?怎么保证不丢?怎么让同步又快又稳? 这篇文章,我就用大白话,把“增量同步 API”这件事拆开揉碎讲清楚。保证你看完就能落地。 一、先搞清楚什么是“增量同步” 全量同步很好理解:每次请求都把全部数据拉一遍。数据量小的时候没问题,但任务管理做到后面,几百个任务、几十个标签、再加上备注和附件,每次都拉全部,流量和速度都受不了。 增量同步的意思是:只同步“上一次同步之后发生变化的数据”。 举个例子:你上次同步是上午10点,10点到11点之间,你改了3个任务、删了1个标签、新增了2个备注。那么11点这次同步,后端只把这5条变化发给前端。 关键点:前端必须知道自己上次同步到了哪个时间点(或者哪个版本号)。 二、后端需要提供哪些核心能力? 我们拆成四个模块来说。 1. 一个“变更日志表” 后端不能只存业务数据,还得记下谁、什么时候、改了哪个实体、改了什么内容。
CREATE TABLE change_log (
id BIGSERIAL PRIMARY KEY,
entity_type VARCHAR(50) NOT NULL, -- 例如 'task', 'tag'
entity_id VARCHAR(100) NOT NULL,
operation VARCHAR(20) NOT NULL, -- 'create', 'update', 'delete'
data JSONB, -- 变更后的完整数据或增量字段
user_id VARCHAR(50) NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_change_log_user_time ON change_log(user_id, created_at);
2. 一个“同步凭证”机制
每次同步成功,给前端返回一个凭证(通常是时间戳或递增ID)。前端下次带着这个凭证来问:“从这个凭证之后,有没有我的数据变化?”
type SyncRequest struct {
UserID string
Since int64 // 上次同步的凭证,比如 1734567890123
Limit int // 每页条数
}
type SyncResponse struct {
Changes []Change `json:"changes"`
NewSince int64 `json:"new_since"` // 本次同步的新凭证
HasMore bool `json:"has_more"`
}
3. 一个“冲突检测与合并”策略
Local First 场景下,冲突是常态。我的建议是:后端不自动覆盖,而是记录冲突版本,让前端或用户决定。
// 后端返回冲突信息示例
{
"entity_type": "task",
"entity_id": "task_123",
"server_version": 5,
"client_version": 3,
"server_data": { "title": "开会讨论", "status": "done" },
"client_data": { "title": "开会讨论(推迟)", "status": "pending" }
}
4. 一个“拉取增量”的 API 入口
这是最核心的接口。前端带着凭证来,后端从变更日志里找出该用户的所有新变更。
def get_incremental_changes(user_id, since_ts, limit=100):
query = """
SELECT id, entity_type, entity_id, operation, data, created_at
FROM change_log
WHERE user_id = %s AND created_at > %s
ORDER BY created_at ASC
LIMIT %s
"""
rows = db.execute(query, user_id, since_ts, limit)
# 注意:要把每条变更还原成“操作指令”
changes = []
for row in rows:
changes.append({
"op": row.operation, # create / update / delete
"type": row.entity_type,
"id": row.entity_id,
"data": row.data,
"ts": row.created_at
})
new_since = rows[-1].created_at if rows else since_ts
has_more = len(rows) == limit
return changes, new_since, has_more
三、给前端一个“推送变更”的能力(可选但推荐)
增量拉取是“拉模式”。如果希望实时性更好,可以加上 WebSocket 推送。
// 服务端推送变更示例
socket.on('task_updated', (payload) => {
// 前端收到后,更新本地数据库
localDB.tasks.upsert(payload.task);
});
不过注意:推送只是辅助,核心同步逻辑还是要靠拉取 API 来兜底。
四、八段核心代码模块
下面我把这个方案里最关键的 8 个代码模块拆出来,方便你直接拿去用。
模块1:记录变更的通用函数
def record_change(user_id, entity_type, entity_id, operation, data):
db.execute("""
INSERT INTO change_log (user_id, entity_type, entity_id, operation, data, created_at)
VALUES (%s, %s, %s, %s, %s, NOW())
""", user_id, entity_type, entity_id, operation, json.dumps(data))
模块2:创建任务时自动记录
def create_task(user_id, task_data):
task_id = generate_id()
save_to_tasks_table(user_id, task_id, task_data)
record_change(user_id, 'task', task_id, 'create', task_data)
return task_id
模块3:更新任务时记录
def update_task(user_id, task_id, updates):
old_data = get_task(user_id, task_id)
new_data = {**old_data, **updates}
update_tasks_table(user_id, task_id, new_data)
record_change(user_id, 'task', task_id, 'update', new_data)
模块4:删除任务(软删除)
def delete_task(user_id, task_id):
mark_task_deleted(user_id, task_id)
record_change(user_id, 'task', task_id, 'delete', {"deleted_at": now()})
模块5:增量同步主接口
@app.get("/sync/pull")
def sync_pull(user_id: str, since: int, limit: int = 100):
changes, new_since, has_more = get_incremental_changes(user_id, since, limit)
return {
"changes": changes,
"new_since": new_since,
"has_more": has_more
}
模块6:前端处理返回的增量数据
async function pullUpdates(since) {
const res = await fetch(`/sync/pull?since=${since}&limit=50`);
const { changes, new_since, has_more } = await res.json();
for (const change of changes) {
if (change.op === 'create' || change.op === 'update') {
await localDB.upsert(change.type, change.id, change.data);
} else if (change.op === 'delete') {
await localDB.delete(change.type, change.id);
}
}
return { new_since, has_more };
}
模块7:前端断点续传逻辑
async function fullSync() {
let since = localStorage.getItem('sync_token') || 0;
let hasMore = true;
while (hasMore) {
const { new_since, has_more } = await pullUpdates(since);
since = new_since;
hasMore = has_more;
localStorage.setItem('sync_token', since);
}
}
模块8:简单的时间戳冲突检测
def push_changes(user_id, client_changes):
conflicts = []
for change in client_changes:
server_version = get_task_version(user_id, change['id'])
if server_version > change['client_version']:
conflicts.append({
"id": change['id'],
"server_version": server_version
})
return {"conflicts": conflicts}
五、两个问答环节
问答1:问:本地存储的数据如果被用户手动清空了怎么办?
答:这个问题很常见。我们的 API 设计里,后端始终是最终数据源。用户清空本地数据后,前端可以执行一次完整重建同步:把 since=0 传进来,后端会返回该用户所有变更日志的“重放结果”。注意,这不是返回全量原始数据,而是返回“从空白状态应用所有变更日志后得到的最终状态”。实现方式有两种:
后端单独提供一个 /sync/full_state 接口返回全量快照。
前端不断用 since=0,1,2... 拉取直到 hasMore=false,相当于重放历史。
推荐第一种,对服务器更友好。
问答2:问:用户有几千个任务,每次都扫描 change_log 大表会不会很慢?
答:会的。所以必须做好三点:
给 change_log 加复合索引:(user_id, created_at) 联合索引。
定期清理旧变更日志:比如只保留最近 30 天的变更记录。更久的变更用“快照 + 归档”替代。
使用游标分页:不要用 OFFSET,而是用 WHERE created_at > last_ts ORDER BY created_at LIMIT N。
另外,如果某用户长期未同步(比如断网三个月),重新同步时后端可以自动判断:如果变更记录条数超过阈值(比如 5000 条),直接生成一份该用户的“快照数据”一次性下发,而不是逐条发变更。七、总结
增量同步这件事,说白了就是三个东西:
一个可靠的变更日志表
一个不断推进的同步凭证
一套清晰的操作类型(增、改、删)
不用想得太复杂。先从最简单的“时间戳+拉取”开始,跑通之后再逐步加入冲突检测、推送能力。你的任务管理工具会越来越稳。
希望这篇文章能帮你少踩一些坑。我是小明他不是名,下次见。

浙公网安备 33010602011771号