在健身房信号不佳的地下室,你刚刚完成了一组深蹲,掏出手机记录训练数据,却发现网络连接中断 —— 这正是离线优先架构需要解决的典型场景。Stronk.app 作为一个开源的 5/3/1 健身日志应用,采用本地优先的设计理念,为用户提供了无缝的训练记录体验。本文将深入探讨这种架构在健身日志应用中的具体实现,并提供可落地的工程参数。
健身日志应用的数据同步挑战
健身日志应用面临独特的数据同步需求。与社交媒体或即时通讯应用不同,健身数据具有以下特点:
- 时间敏感性:训练记录需要准确的时间戳,用于追踪进度和恢复周期
- 数据完整性:每组次数、重量、休息时间等数据必须完整保存
- 多设备使用:用户可能在手机、平板、电脑等多个设备上记录训练
- 离线场景:健身房通常网络信号不佳,需要可靠的离线记录能力
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 的服务后面。" 这是离线优先架构的重要安全考虑。
部署建议:
- 容器化部署:使用 Docker 分离前端和后端
- 反向代理:通过 Caddy 或 Nginx 提供 HTTPS 和基础认证
- 数据备份:定期备份 SQLite 数据库
- 访问控制:限制只有授权用户可访问
安全参数:
- HTTPS 强制启用
- 会话超时:30 分钟
- 密码策略:最小长度 8 位,包含数字和字母
- API 速率限制:每分钟 60 次请求
性能优化建议
1. 本地存储优化
- 数据分片:按年月分表存储训练记录
- 索引优化:为 user_id、created_at、sync_status 创建复合索引
- 定期清理:删除已同步超过 30 天的队列项
2. 同步性能优化
- 批量同步:一次请求处理最多 50 条记录
- 增量同步:只同步变更部分,而非全量数据
- 压缩传输:对同步数据使用 gzip 压缩
3. 前端优化
- 懒加载:训练历史记录分页加载
- 缓存策略:静态资源缓存 1 年,API 响应缓存 5 分钟
- 预加载:预测用户可能查看的数据并提前加载
总结
Stronk.app 的离线优先架构为健身日志应用提供了一个优秀的参考实现。通过本地数据存储、智能同步队列和冲突解决机制,确保了用户在任何网络环境下都能可靠地记录训练数据。
关键要点总结:
- 本地优先:确保核心功能在离线状态下完全可用
- 智能同步:基于网络状态自动触发,支持断点续传
- 冲突解决:考虑健身数据的特殊性,采用加权时间戳策略
- 监控告警:建立完善的监控体系,及时发现和解决问题
随着 Progressive Web App 技术的成熟和 5G 网络的普及,离线优先架构将在更多场景中得到应用。健身日志应用作为对可靠性要求极高的领域,其架构设计经验值得其他离线敏感型应用借鉴。
资料来源:
- Stronk.app GitHub 仓库:https://github.com/bcspragu/stronk
- 离线优先数据同步模式参考:MindStick 技术文章