第四期 · 云端同步与冲突仲裁:离线可写、多端一致的工程解法

关键词: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 / remoteApi.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-117updatedAt 缺失时填 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.onNetworkStatusChangeapp.js:60)、onShowapp.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=trueupdatedAt 超过 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=50001reason 明确;
  • 离线 :断网写入 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、本篇

十、后续优化方向

  1. 离线队列健壮化(最高优先级):退避 + 部分 ACK + 死信 + 节流合批 + 合并回滚;
  2. 增量 pull 落地backgroundSync() + pullCursor,配合 onShow 与网络恢复;
  3. 批量写优化findAll(clientIds) + bulkOps 一次往返;
  4. 强类型 record DTO :把 Map<String,Object> 换成 MoodSyncDto / PeriodSyncDto + Bean Validation,从源头杜绝脏数据;
  5. 同步状态可视化 :设置页展示 lastSyncAt / 待同步条数 / 死信条数,把「静默」变成「可见」;
  6. 冲突升级策略:当同一记录冲突次数超过阈值,改为「保留双版本 + 让用户选择」而不是静默 LWW;
  7. 墓碑 TTL :与 refresh_tokens 一样用 TTL 索引或定时任务回收。

相关推荐
事已至此先睡覺吧1 小时前
第三篇:Java 流程控制详解:条件判断、循环与跳转语句
java·开发语言
毕业设计7031 小时前
(免费领源码) SpringBoot 游戏交易平台17600-java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
java·spring boot·mysql·决策树·mybatis·idea·推荐算法
一 乐1 小时前
自习室座位预约管理系统|基于springboot + vue自习室座位预约管理系统(源码+数据库+文档)
java·vue.js·spring boot
此时不提桶,更待何时2 小时前
02-02-B-AQS与JUC锁面试与生产事故实战
java
音符犹如代码2 小时前
Kafka 接入 AI 的三条路线:MCP 提案、会话记忆与实时上下文
java·大数据·ai·kafka
vx_BS813302 小时前
【项目编号:project51629】Spring Boot 乡村自来水缴费系统:把抄表、水费生成与在线缴费做成一条数字化闭环
java·spring boot·eclipse·tomcat·maven
qq_269506752 小时前
第29课-Servlet基础
java
毅炼2 小时前
不写多语言 SDK,怎么让 Python、Go、Node 服务接入注册中心?
java·后端·系统架构·gateway
2501_937860942 小时前
上篇:网络编程基础与UDP套接字编程
java·网络·计算机网络