Hotdry.
web-architecture

健身日志应用的离线优先架构:Stronk.app 的数据同步工程实践

基于开源健身应用 Stronk.app,探讨离线优先架构在健身日志场景下的数据同步策略、冲突解决机制与工程实现参数。

在健身房信号不佳的地下室,你刚刚完成了一组深蹲,掏出手机记录训练数据,却发现网络连接中断 —— 这正是离线优先架构需要解决的典型场景。Stronk.app 作为一个开源的 5/3/1 健身日志应用,采用本地优先的设计理念,为用户提供了无缝的训练记录体验。本文将深入探讨这种架构在健身日志应用中的具体实现,并提供可落地的工程参数。

健身日志应用的数据同步挑战

健身日志应用面临独特的数据同步需求。与社交媒体或即时通讯应用不同,健身数据具有以下特点:

  1. 时间敏感性:训练记录需要准确的时间戳,用于追踪进度和恢复周期
  2. 数据完整性:每组次数、重量、休息时间等数据必须完整保存
  3. 多设备使用:用户可能在手机、平板、电脑等多个设备上记录训练
  4. 离线场景:健身房通常网络信号不佳,需要可靠的离线记录能力

Stronk.app 采用 Go(83.1%)和 Svelte(11.5%)构建,后端使用 SQLite 数据库存储训练数据。这种技术栈选择体现了对本地存储的重视 ——SQLite 作为嵌入式数据库,天然适合离线优先场景。

离线优先架构的核心组件设计

1. 本地数据存储层

Stronk.app 的核心设计理念是 "本地优先,云端同步"。应用启动时首先检查本地 SQLite 数据库,确保基础数据可用。训练记录、训练最大重量等关键数据都存储在本地,即使完全断网也能正常使用。

工程参数建议

  • 本地数据库初始大小:≤ 5MB(包含基础训练模板)
  • 单次训练记录大小:约 2-5KB(包含时间戳、动作、组数、重量、次数、备注)
  • 本地存储上限:建议 100MB,可存储约 20,000 次训练记录

2. 同步队列机制

当用户在网络不佳的环境下记录训练时,数据首先进入本地数据库,同时被标记为 "待同步"。Stronk.app 采用类似 MindStick 文章中描述的队列模式:

// 伪代码示例
async function recordWorkoutOffline(workoutData) {
  // 1. 存储到本地数据库
  await db.workouts.add(workoutData);
  
  // 2. 添加到同步队列
  await db.syncQueue.add({
    operation: 'create',
    table: 'workouts',
    payload: workoutData,
    timestamp: Date.now(),
    deviceId: getDeviceId()
  });
}

同步队列设计要点

  • 每个队列项包含操作类型(create/update/delete)、目标表、数据负载、时间戳和设备 ID
  • 队列按时间戳排序,确保操作顺序正确
  • 设备 ID 用于冲突检测和解决

3. 网络状态检测与自动同步

Stronk.app 通过监听浏览器的在线状态事件实现自动同步:

// 网络恢复时触发同步
window.addEventListener('online', () => {
  if (shouldSync()) {
    syncWithServer();
  }
});

// 定期检查(每5分钟)
setInterval(() => {
  if (navigator.onLine && hasPendingSync()) {
    syncWithServer();
  }
}, 5 * 60 * 1000);

网络检测参数

  • 在线检测频率:实时监听 + 5 分钟轮询
  • 同步触发条件:网络恢复 + 有待同步数据
  • 同步超时时间:30 秒
  • 重试策略:指数退避,最多重试 3 次

数据冲突解决策略

多设备使用是健身日志应用的常见场景,也是数据同步的最大挑战。假设用户在手机上记录了早上的训练,下午在电脑上修改了训练计划,晚上在平板上又添加了新的训练记录 —— 如何保证数据一致性?

1. 基于时间戳的冲突解决

Stronk.app 采用 "最后写入获胜" 策略,但增加了设备权重因子:

function resolveConflict(localData, serverData) {
  // 计算时间差(毫秒)
  const timeDiff = serverData.updatedAt - localData.updatedAt;
  
  // 如果时间差小于冲突窗口(5分钟),需要更复杂的解决
  if (Math.abs(timeDiff) < 5 * 60 * 1000) {
    // 基于设备类型加权(手机 > 平板 > 电脑)
    const deviceWeight = {
      'mobile': 3,
      'tablet': 2, 
      'desktop': 1
    };
    
    const localWeight = deviceWeight[localData.deviceType] || 1;
    const serverWeight = deviceWeight[serverData.deviceType] || 1;
    
    return localWeight >= serverWeight ? localData : serverData;
  }
  
  // 时间差明显,采用最后写入获胜
  return timeDiff > 0 ? serverData : localData;
}

2. 训练数据的特殊处理

健身数据有其特殊性,不能简单合并或覆盖:

  • 训练记录:通常不可修改,只能添加新的记录
  • 训练最大重量:取最大值,因为这是用户的进步指标
  • 训练计划:需要用户确认合并,或采用版本控制

冲突解决参数

  • 冲突检测窗口:5 分钟(同一训练时段内的修改视为冲突)
  • 设备权重:手机 (3) > 平板 (2) > 电脑 (1)
  • 数据保留策略:训练记录永久保存,训练计划保留最近 10 个版本

可落地的工程实现

1. 数据库架构设计

Stronk.app 的 SQLite 数据库表结构示例:

-- 训练记录表
CREATE TABLE workouts (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id TEXT NOT NULL,
  exercise TEXT NOT NULL,
  sets INTEGER NOT NULL,
  reps INTEGER NOT NULL,
  weight REAL NOT NULL,
  notes TEXT,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  device_id TEXT,
  sync_status INTEGER DEFAULT 0 -- 0: 未同步, 1: 同步中, 2: 已同步
);

-- 同步队列表
CREATE TABLE sync_queue (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  operation TEXT NOT NULL, -- 'create', 'update', 'delete'
  table_name TEXT NOT NULL,
  record_id INTEGER,
  payload TEXT NOT NULL, -- JSON 格式的数据
  timestamp INTEGER NOT NULL,
  device_id TEXT NOT NULL,
  retry_count INTEGER DEFAULT 0
);

2. 同步引擎实现

同步引擎的核心逻辑:

// Go 语言示例(Stronk.app 后端)
func (s *SyncService) ProcessSyncQueue() error {
  // 获取待同步的队列项
  queueItems, err := s.db.GetPendingSyncItems()
  if err != nil {
    return err
  }
  
  for _, item := range queueItems {
    // 检查重试次数
    if item.RetryCount >= 3 {
      s.handleSyncFailure(item)
      continue
    }
    
    // 执行同步
    err := s.syncItem(item)
    if err != nil {
      // 记录失败,增加重试计数
      item.RetryCount++
      s.db.UpdateSyncItem(item)
      continue
    }
    
    // 同步成功,从队列中移除
    s.db.DeleteSyncItem(item.ID)
    
    // 更新主记录的同步状态
    s.db.UpdateRecordSyncStatus(item.TableName, item.RecordID, 2)
  }
  
  return nil
}

3. 监控与告警

离线优先架构需要完善的监控体系:

关键监控指标

  • 同步队列长度:超过 100 条需要告警
  • 同步成功率:低于 95% 需要调查
  • 平均同步延迟:超过 10 分钟需要优化
  • 冲突发生率:超过 5% 需要改进冲突解决策略

告警阈值

  • 紧急:同步队列积压 > 500 条,或同步完全失败
  • 警告:同步成功率 < 90%,持续 30 分钟
  • 提醒:冲突发生率 > 10%,需要人工干预

部署与安全考虑

Stronk.app 的部署文档明确指出:"应用没有内置认证,确保引入基础认证或将应用部署在类似 Tailscale 的服务后面。" 这是离线优先架构的重要安全考虑。

部署建议

  1. 容器化部署:使用 Docker 分离前端和后端
  2. 反向代理:通过 Caddy 或 Nginx 提供 HTTPS 和基础认证
  3. 数据备份:定期备份 SQLite 数据库
  4. 访问控制:限制只有授权用户可访问

安全参数

  • HTTPS 强制启用
  • 会话超时:30 分钟
  • 密码策略:最小长度 8 位,包含数字和字母
  • API 速率限制:每分钟 60 次请求

性能优化建议

1. 本地存储优化

  • 数据分片:按年月分表存储训练记录
  • 索引优化:为 user_id、created_at、sync_status 创建复合索引
  • 定期清理:删除已同步超过 30 天的队列项

2. 同步性能优化

  • 批量同步:一次请求处理最多 50 条记录
  • 增量同步:只同步变更部分,而非全量数据
  • 压缩传输:对同步数据使用 gzip 压缩

3. 前端优化

  • 懒加载:训练历史记录分页加载
  • 缓存策略:静态资源缓存 1 年,API 响应缓存 5 分钟
  • 预加载:预测用户可能查看的数据并提前加载

总结

Stronk.app 的离线优先架构为健身日志应用提供了一个优秀的参考实现。通过本地数据存储、智能同步队列和冲突解决机制,确保了用户在任何网络环境下都能可靠地记录训练数据。

关键要点总结:

  1. 本地优先:确保核心功能在离线状态下完全可用
  2. 智能同步:基于网络状态自动触发,支持断点续传
  3. 冲突解决:考虑健身数据的特殊性,采用加权时间戳策略
  4. 监控告警:建立完善的监控体系,及时发现和解决问题

随着 Progressive Web App 技术的成熟和 5G 网络的普及,离线优先架构将在更多场景中得到应用。健身日志应用作为对可靠性要求极高的领域,其架构设计经验值得其他离线敏感型应用借鉴。

资料来源

查看归档