关键词:clientId 幂等 · updatedAt LWW · 墓碑软删 · 增量 pull 游标 · 离线队列 · 游客数据合并
目标读者:后端 / 前端 / 架构
一、业务目标
三期让用户「能登录」,四期要让数据「在哪台设备上都一样 」,同时坚守一期的承诺:断网也能写。
三个业务场景定义了全部需求:
| 场景 | 期望行为 |
|---|---|
| 手机 A 记录 → 手机 B 打开 | B 能看到 A 的记录(增量 pull) |
| 地铁里打卡(无网) | 立即写入本地/内存并提示「已加入同步队列」,联网后自动补传(offline queue) |
| 两台手机同时改同一条记录 | 不丢数据、不产生重复,结果可预期(冲突仲裁) |
以及最关键的转化场景:游客 → 登录,本机历史记录必须无损上云(游客合并)。
二、核心功能范围
| 能力 | 端 | 说明 |
|---|---|---|
| 批量推送 | 双端 | POST /sync/push,单次 ≤500 条,逐条幂等 |
| 增量拉取 | 双端 | GET /sync/pull?since=,游标分页,上限 200/页 |
| 幂等写入 | 后端 | (userId, clientId) 唯一索引 |
| 冲突仲裁 | 后端 | updatedAt 新者胜(LWW),服务端胜则拒绝并回执原因 |
| 软删传播 | 双端 | deleted 墓碑,pull 时以 action=remove 下发 |
| 离线队列 | 前端 | em_sync.pendingOps,联网/回前台自动重放 |
| 游客合并 | 前端 | utils/merge.js:备份 → 拉快照 → 按 id+updatedAt 合并 → 回传 |
| 存储模式切换 | 前端 | 设置页 local / remote,Api.init(true) 重建适配器 |
三、涉及的技术模块
mood-backend/
├── controller/SyncController.java /sync/push · /sync/pull
├── service/SyncService.java ★ 仲裁核心:applyMood / applyPeriod
├── document/ MoodRecord · PeriodRecord(uk_user_client + idx_user_updated)
├── repository/ findByUserIdAndClientId · findByUserIdAndUpdatedAtGreaterThanOrderByUpdatedAtAsc
├── common/util/RecordMapper.java 实体 ⇄ 端上 JSON(id ⇄ clientId 互转 + 容错解析)
└── service/DataService.java export / import / clear(与端上同构)
emotion-monster/
├── utils/adapters/remote-adapter.js ★ buildRecord / writeRecord / flushPending
├── utils/merge.js ★ 游客合并
├── utils/http.js 401 单飞刷新(保障重放可用)
└── app.js 网络恢复监听、onShow 重放
四、关键技术选型与实现要点
4.1 同步模型对比
| 模型 | 一致性 | 实现成本 | 冲突处理 | 结论 |
|---|---|---|---|---|
| 全量覆盖(客户端胜) | 弱,多端互撕 | 极低 | 后写覆盖先写 | ❌ 丢数据 |
| 全量覆盖(服务端胜) | 弱 | 低 | 本地改动丢失 | ❌ 体验差 |
| 增量 push/pull + LWW + 墓碑(本项目) | 最终一致 | 中 | 时间戳新者胜 | ✅ 匹配业务 |
| 版本向量(Vector Clock) | 强(可检测并发) | 高 | 需人工/合并函数 | 过度设计 |
| CRDT(如 Yjs / Automerge) | 强,自动合并 | 很高 | 字段级自动合并 | 双人协作编辑才需要 |
| OT(操作变换) | 强 | 很高 | 文档协同 | ❌ 场景不符 |
为什么 LWW 够用?
情绪记录是「单人、多设备、低并发」场景:同一个用户几乎不可能在两台设备上同时改同一条记录。真正的并发窗口是「离线改 + 在线改」,量级极小。用 CRDT 换来的自动合并能力,在这个场景下收益远低于复杂度成本。
但 LWW 有前提 :时间戳必须可信。本项目采取「服务端写入时强制用服务端当前时间 覆盖 updatedAt」(SyncService.java:140),避免客户端时钟漂移导致旧数据覆盖新数据。副作用:客户端 push 时携带的时间戳不参与最终存储,仅用于服务端比对。
4.2 幂等键:clientId
java
// document/MoodRecord.java:17
@CompoundIndex(name="uk_user_client", def="{'userId':1,'clientId':1}", unique=true)
clientId由客户端生成 (utils/id.js),记录创建时确定,永不改变;- 端云主键统一:
RecordMapper.moodToMap输出的id就是clientId,所以 pull 下来的记录可以直接被端上按 id 索引; - push 时
clientId缺失直接 reject(SyncService.java:52-55)------没有幂等键的变更是灾难源头。
js
// utils/adapters/remote-adapter.js:80-107 buildRecord
const id = p.id || p.clientId || genId();
// create → { id, clientId: id, createdAt, updatedAt, ...fields }
// update → { ...p.fields, id, clientId: id, updatedAt: now }
// remove → { id, clientId: id, deleted: true, updatedAt: now } ← 墓碑
4.3 冲突仲裁:服务端 updatedAt 新者胜
java
// service/SyncService.java:108-142 applyMood
Optional<MoodRecord> existing = moodRepository.findByUserIdAndClientId(userId, clientId);
if (existing.isEmpty()) {
incoming.setDeleted("remove".equalsIgnoreCase(change.action())); // 首次即墓碑
moodRepository.save(incoming);
return;
}
if (existing.getUpdatedAt() != null
&& incoming.getUpdatedAt().isBefore(existing.getUpdatedAt())) {
throw new ConflictException("服务端版本更新"); // → reject(SYNC_CONFLICT 50001)
}
if ("remove".equalsIgnoreCase(action)) existing.setDeleted(true); // 墓碑
else {
// 逐字段:null 保留旧值;tagIds 非空才覆盖;deleted = false
}
existing.setUpdatedAt(now); // ★ 强制服务端时间
moodRepository.save(existing);
设计要点:
| 决策 | 理由 |
|---|---|
| 拒绝而非覆盖 | 让客户端知道「你的这次修改没生效」,可以提示或重新拉取 |
| 局部字段更新(null 保留旧值) | 支持「只改备注」这类局部变更,不会误清空其他字段 |
remove 走墓碑而非物理删 |
物理删会导致其他端永远拉不到删除事件,记录复活 |
updatedAt 用服务端时间 |
杜绝客户端时钟漂移 |
⚠️ 边界:
SyncService.java:115-117,updatedAt缺失时填now,意味着无时间戳的变更总是能写入。若端上出现序列化异常导致时间戳丢失,会覆盖正常数据。建议改为「缺失则 reject」。
4.4 增量 pull:游标与分页
java
// service/SyncService.java:70-104
int safeSize = Math.min(200, Math.max(1, pageSize)); // 上限 200
Instant cursor = (since == null) ? Instant.EPOCH : since;
List<MoodRecord> moods = repo.findByUserIdAndUpdatedAtGreaterThanOrderByUpdatedAtAsc(userId, cursor, pageable);
List<PeriodRecord> periods = repo.findByUserIdAndUpdatedAtGreaterThanOrderByUpdatedAtAsc(userId, cursor, pageable);
// 每条 → RecordMapper 转 Map(id = clientId)+ module + action(deleted ? "remove" : "upsert")
// 新游标 = max(updatedAt),空则沿用入参
// hasMore = moods.size() == safeSize || periods.size() == safeSize
索引支撑:idx_user_updated {'userId':1,'updatedAt':1}(两个 document 均有)。
已知问题 :两个模块各查一次同 pageable 再合并,返回条数最多 2 × pageSize,且 hasMore 取二者任一满页------会多一轮空拉取。可接受,但第五期可改为「单集合统一变更流」或「按 module 分别维护游标」。
4.5 前端写路径:离线优先
js
// utils/adapters/remote-adapter.js:109-129 writeRecord
return http.post('/sync/push', { changes: [{ module, action, record: built }] })
.then(() => {
storage.updateSync({ status: 'idle', lastSyncAt: DateUtil.nowISO() });
storage.emit(module === 'period' ? 'period:changed' : 'mood:changed');
return Result.ok({ id: built.id, synced: true });
})
.catch(() => {
const pending = (storage.getSync().pendingOps || []).concat([{
opId: built.id + '-' + Date.now(), module, action,
record: built, queuedAt: DateUtil.nowISO(), attempts: 0
}]);
storage.updateSync({ pendingOps: pending, status: 'error' });
return Result.ok({ id: built.id, synced: false }); // ★ 对页面仍返回 ok
});
为什么失败也返回 ok? 因为离线写入对用户是成功的------数据已经在队列里,联网就会同步。如果返回 fail,页面会弹「保存失败」,用户会重复提交。
代价:页面多数只判 res.ok,导致离线入队是静默 的。首页用 res.synced === false 提示「已加入同步队列」(pages/index/index.js),但其余页面未覆盖 ------ 建议统一。
4.6 重放
js
// utils/adapters/remote-adapter.js:202-214
function flushPending() {
const pending = storage.getSync().pendingOps || [];
if (!pending.length) return Promise.resolve({ flushed: 0 });
return http.post('/sync/push', { changes: pending.map(o => ({ module:o.module, action:o.action, record:o.record })) })
.then(() => { storage.updateSync({ pendingOps: [], status:'idle', lastSyncAt: nowISO() });
storage.emit('mood:changed'); storage.emit('period:changed');
return { flushed: pending.length }; })
.catch(() => Promise.resolve({ flushed: 0 })); // 失败整批保留
}
触发点:wx.onNetworkStatusChange(app.js:60)、onShow(app.js:68)、设置页手动同步(settings.js:63)。
4.7 游客合并:转化链路的临门一脚
js
// utils/merge.js:83-115 mergeToServer()
storage.backupGuest(); // ① 快照到 em_guest_backup
return http.get('/data/export').then(snapshot => { // ② 拉服务端全量快照
const moodRes = mergeRecords(local.mood.records, snapshot.mood.records);
const periodRes = mergeRecords(local.period.records, snapshot.period.records);
const changes = [].concat(
moodRes.toUpload.map(r => ({ module:'mood', action:'update', record:r })),
periodRes.toUpload.map(r => ({ module:'period', action:'update', record:r })));
return (changes.length ? http.post('/sync/push', { changes }) : Promise.resolve())
.then(() => {
storage.setDomain('mood', { records:[], streak:0, lastCheckInDate:'', totalRecords:0 }, true);
storage.setDomain('period', { records:[], cache:null }, true);
storage.updateSettings(mergeSettings(local.settings, snapshot.settings), true);
storage.updateSync({ lastSyncAt: nowISO(), status:'idle', pendingOps:[] });
storage.clearGuestBackup(); // ④ 成功后清备份
return { merged, uploaded };
});
});
仲裁(mergeRecords,L16-48):
以 id(clientId)为索引做并集:
仅本地 → 保留并上传
仅云端 → 采用云端
本地 updatedAt 更新 → 保留本地并上传
相等或云端更新 → 采用云端
设置合并(L62-77):服务端为基线(主题/提醒/周期默认值),自定义字典(customTags customTriggers customSymptoms)走并集去重。
时序保障 :app.js:124-125 注释明确 ------ 适配器延迟到合并完成后再重建 (Api.init(true)),否则合并期间页面会误读到空的服务端数据。
五、数据流转与接口设计
5.1 写路径(在线)
page → Api.request → RemoteAdapter.buildRecord(生成 clientId + updatedAt)
→ http.post('/sync/push', { changes: [ ... ] })
→ SyncService 逐条:
校验 clientId → 查 (userId, clientId)
无 → 保存(create / 首次墓碑)
有 → updatedAt 比对 → 旧:reject(50001)
→ 新:局部覆盖 + 软删标记 + updatedAt = now
→ 回执 { accepted, rejected[], serverTime }
→ 更新 em_sync.lastSyncAt,emit 事件 → 页面刷新
5.2 写路径(离线)
同上,但 http 失败
→ 入队 em_sync.pendingOps(attempts=0)
→ 返回 Result.ok({ synced: false }),页面提示「已加入同步队列」
联网 / onShow / 手动 → flushPending() → 整批重放
→ 成功:清空队列;失败:整批保留(无退避、无死信)
5.3 读路径
page → Api.request('mood','list') → GET /api/v1/moods?date=&tagId=&page=&pageSize=
(服务端查,客户端不落缓存)
→ 或 'stats' → GET /api/v1/stats/**(服务端实时计算)
增量同步:GET /api/v1/sync/pull?since=<ISO>&page=1&pageSize=100
5.4 接口清单
| 方法 | 路径 | 说明 |
|---|---|---|
| POST | /api/v1/sync/push |
{changes:[{module, action, record}]},`module=mood |
| GET | /api/v1/sync/pull |
?since=&page=1&pageSize=100,返回 {changes, hasMore, serverTime, cursor} |
| GET | /api/v1/moods / /api/v1/periods |
分页列表 |
| GET/POST | /api/v1/moods[/{clientId}] |
详情 / 新增(clientId 幂等) |
| PUT/DELETE | /api/v1/moods/{clientId} |
更新 / 软删 |
| GET | /api/v1/periods/predict |
读时计算 |
| GET | /api/v1/data/export |
导出(与端上 JSON 同构,含 period.cache 预测快照) |
| POST | /api/v1/data/import |
{json, mode}(merge / overwrite) |
| DELETE | /api/v1/data?scope= |
all / mood / period(软删) |
5.5 DTO 约束
java
// dto/sync/SyncPushReq.java:10
List<@Valid SyncChange> changes; @NotEmpty @Size(max = 500)
// dto/sync/SyncPushResp.java:5
int accepted; List<SyncReject> rejected; String serverTime;
// dto/sync/SyncReject.java:3
String clientId; int code; String reason;
since 解析(SyncController.java:45-58):先 ISO_OFFSET_DATE_TIME,再 Instant.parse,都失败回退 Instant.EPOCH(全量)。
六、技术难点分析
难点 1:离线队列的健壮性(当前最大短板)
现状盘点:
| 问题 | 位置 | 影响 |
|---|---|---|
attempts 恒为 0,从不递增 |
remote-adapter.js:118 |
无法做死信判定 |
| 无退避重放 | flushPending |
弱网下高频重试,耗电且易失败 |
| 整批重放,无部分成功 | flushPending |
一条脏数据阻塞全部 |
未消费 accepted / rejected |
writeRecord / flushPending |
冲突悄无声息 |
SYNC_THROTTLE=2000 零引用 |
config.js:17 |
节流未实现 |
em_guest_backup 只写不读 |
merge.js / app.js |
合并失败无法自动回滚 |
可落地改造方案:
js
// ① 指数退避 + 死信
function scheduleFlush() {
const s = storage.getSync();
const delay = Math.min(30000, 1000 * Math.pow(2, s.failCount || 0));
clearTimeout(flushTimer);
flushTimer = setTimeout(() => flushPending(), delay);
}
// ② 逐条 ACK:按 accepted/rejected 剔除成功项,rejected 中 code=50001 的转「拉取后用服务端版本」,
// 其余失败项 attempts++,超过 5 次移入 deadLetters 并在设置页展示
function flushPending() {
return http.post('/sync/push', { changes })
.then(resp => {
const okIds = new Set(resp.acceptedIds || []);
const rejected = new Map((resp.rejected || []).map(r => [r.clientId, r]));
const remain = pending.filter(op => {
const r = rejected.get(op.record.clientId);
if (!r) return !okIds.has(op.record.clientId); // 成功不在队列
if (r.code === 50001) return false; // 冲突:丢弃本地,等 pull 覆盖
op.attempts = (op.attempts || 0) + 1;
return op.attempts < 5; // 超次数入死信
});
...
})
.catch(() => scheduleFlush()); // 网络失败退避重试
}
// ③ 节流合批:写操作先入队,2000ms(复用 SYNC_THROTTLE)窗口内合并成一次 push
// ④ 合并回滚:设置页暴露「恢复合并前备份」,调用 storage.getGuestBackup() 还原
配套后端改造:SyncPushResp 增加 acceptedIds(当前只有计数),并在 SyncService.push 中捕获所有异常 而不仅是 ConflictException(现状:单条脏数据会导致整批 500)。
难点 2:墓碑无限增长
软删记录永久保留,mood_records 只增不减。
应对:
- 为
deleted=true且updatedAt超过 N 天(如 180 天)的记录做物理清理; - 清理前确保
pull游标不会回退到被删区间------即只对「所有活跃设备的游标都已越过」的墓碑清理。简化做法:墓碑保留期 > 最长可能的离线跨度(如 1 年),且清理任务记录日志。
难点 3:多端删除的语义
「在 A 手机删除」必须传播到 B 手机。墓碑机制已解决,但要注意:
- 端上 pull 到
action=remove时本地也要软删(不能真删,否则下次 pull 又拉回来); - 端上所有查询过滤
deleted(后端用DeletedFalse系列方法,端上LocalAdapter同理); - 第五期管理端「下架」必须走同一套墓碑 (
PATCH visibility只置deleted=true),物理删会导致用户端残留 ------ 管理端方案 §10.4 已明确。
难点 4:导入与同步的口径不一致
DataService.importData 不做业务校验(moodLevel 1~5、日期格式、补记天数),与 MoodService 写入口径不同 ------ 导入的脏数据会进入同步链路,成为「死信」来源。
另外 DataController.java:37-39 接收了 @RequestParam mode,但调用时只传 req,实际取 ImportReq.mode()(请求体)。端上恰好把 mode 放 body 所以可用,但两种传参行为不一致,属隐患。
修复 :① importData 复用 MoodService / PeriodService 的校验;② controller 显式校验 query 与 body 的 mode 一致性,或统一只认 body。
难点 5:pull 游标与 pull 本身的缺失
RemoteAdapter 目前没有调用 /sync/pull(只有 push)。多端同步依赖「每次进页面重新拉取列表」,而非增量。
影响:不能做「后台静默增量同步」,弱网下流量与耗时都偏高。
落地建议:
js
// app.js onShow / 网络恢复时
function backgroundSync() {
const s = storage.getSync();
return http.get('/sync/pull', { since: s.pullCursor || '1970-01-01T00:00:00Z', pageSize: 200 })
.then(resp => {
applyChanges(resp.changes); // 端上按 clientId 应用 upsert / remove
storage.updateSync({ pullCursor: resp.cursor, lastSyncAt: resp.serverTime });
return resp.hasMore ? backgroundSync() : null; // 循环直到拉完
});
}
em_sync.pullCursor 字段已预留,只需补实现。
七、性能与安全考量
性能
| 项 | 现状 | 优化 |
|---|---|---|
| push 单条 | 1 次查询 + 1 次更新,走 uk_user_client |
✅ |
| push 批量 | 逐条串行 DB 往返 | 500 条时耗时长;可按 clientId 批量 findAll 后一次 bulkOps |
| pull | 2 次查询(mood + period),走 idx_user_updated |
可合并为统一变更集合 |
| payload | 单次 ≤500 条 | 建议单条记录 <2KB 时批次 ≤200,避免超时(15s) |
安全
| 措施 | 状态 |
|---|---|
/sync/** 强制 JWT |
✅ 白名单无 sync |
所有查询按 CurrentUser.userId() 隔离 |
✅ |
索引均以 userId 为前缀 |
✅ |
| push 条数上限 500 | ✅ |
| 单用户 push 频率限制 | ❌ 建议按 userId 限流(Nginx 目前只有 IP 级 10r/s) |
| record 字段白名单 | ❌ Map<String,Object> record 自由映射,建议改为强类型 DTO + @Valid |
| 敏感字段过滤 | 目前 record 只映射到已知字段(RecordMapper 逐字段取),风险可控 |
八、风险与应对
| 风险 | 等级 | 应对 |
|---|---|---|
| 离线队列无退避/无死信,脏数据永久阻塞 | 高 | 落地第六节难点 1 的改造 |
未消费 rejected,冲突静默丢失 |
高 | 消费回执;50001 时提示「已在其他设备更新」并主动 pull |
| 墓碑无限增长 | 中 | 180 天保留期 + 定时清理(游标安全后) |
| push 单条异常中断整批 | 中 | SyncService 捕获所有异常逐条 reject |
updatedAt 缺失仍可写 |
中 | 改为缺失即 reject |
| 未实现 pull 增量同步 | 中 | 补 backgroundSync(),pullCursor 已预留 |
| 导入无校验 | 中 | 复用 Service 校验;统一 mode 取值来源 |
| 时钟依赖 | 低 | 已用服务端时间覆盖,风险可控 |
九、验收标准与交付物
验收标准
- 幂等 :同一
clientId连续 push 10 次,库中只有 1 条,accepted计数正确; - 冲突 :服务端
updatedAt更新时 push 被 reject,返回code=50001且reason明确; - 离线 :断网写入 5 条 →
pendingOps有 5 条 → 恢复网络后自动重放成功,队列清空; - 重放回执 :重放后成功项出队,冲突项按服务端版本,失败项
attempts递增且达上限入死信; - 多端删除 :A 端删除 → B 端 pull 收到
action=remove→ B 端列表不再显示,且再次 pull 不复活; - 游客合并 :游客期 20 条记录 → 登录后全部上云,本地清空,
em_guest_backup清除; - 合并失败可回滚:合并过程中断网 → 本地数据仍在,设置页可恢复备份;
- 导出导入同构 :
GET /data/export的 JSON 可被端上data.import完整还原; - 批量上限:push 501 条返回参数错误;pull pageSize=500 时被钳制为 200;
- 隔离性 :用 A 的 token push 一条
userId为 B 的记录,结果写入 A 名下(服务端强制覆盖 userId)。
交付物
| 类型 | 内容 |
|---|---|
| 后端 | SyncController / SyncService / RecordMapper / DataService + 5 个集合与索引 |
| 前端 | remote-adapter.js(写路径 + 队列 + 重放)、merge.js(游客合并)、app.js(网络监听) |
| 契约 | SyncPushReq/Resp SyncChange SyncReject SyncPullResp |
| 文档 | 详细设计文档 v6.0、本篇 |
十、后续优化方向
- 离线队列健壮化(最高优先级):退避 + 部分 ACK + 死信 + 节流合批 + 合并回滚;
- 增量 pull 落地 :
backgroundSync()+pullCursor,配合onShow与网络恢复; - 批量写优化 :
findAll(clientIds)+bulkOps一次往返; - 强类型 record DTO :把
Map<String,Object>换成MoodSyncDto/PeriodSyncDto+ Bean Validation,从源头杜绝脏数据; - 同步状态可视化 :设置页展示
lastSyncAt/ 待同步条数 / 死信条数,把「静默」变成「可见」; - 冲突升级策略:当同一记录冲突次数超过阈值,改为「保留双版本 + 让用户选择」而不是静默 LWW;
- 墓碑 TTL :与
refresh_tokens一样用 TTL 索引或定时任务回收。