时长凑不出来——阅读统计的跨端合并难题
旦阅诞生记 · 12。上一篇把"读到哪"的冲突拆成了水位、位置、时间三层。但进度表里还躺着另一个不起眼的字段:阅读时长。它没有位置那种"谁对谁错"的冲突,看起来只是做个加法——手机读了 10 小时,电脑读了 5 小时,合计 15 小时,这有什么难的?难。这个 15 我凑了很久才凑出来,而且凑出来之后,它又自己膨胀了。
一行字背后的账本
书架顶部有一行统计:
在读书 3 本 · 今日 42 分钟 · 累计 15 小时
"今日"好办——它是一个纯本机概念:今天这台设备上的会话计时器加了多少,就显示多少。手机上的"今日"和电脑上的"今日"本来就不该相加:你在两台设备上各读了半小时,任何一台上的"今日 30 分钟"都是真实且正确的,没必要也不应该跨端合并(时区、时钟、"今天"到底是哪天的边界问题,全是为一个展示数字不值得付的成本)。
麻烦的是"累计"。它是一个跨设备概念:这本书我总共花了多少时间。这个数字只有服务端能给出权威答案,因为只有它看得见所有设备。于是问题变成:各客户端手里都有一份"本机累计",服务端怎么把它们合并成"合计"?
第一版:单字段 + 后写覆盖
最初的实现和所有字段一样朴素:BookProgress 表里一个 ReadTime 列,客户端把本机累计值整个上报,服务端按 ClientUpdatedAt 后写覆盖——和进度走的是同一条 LWW 路径。
推演一下就知道结局。手机读了 10 小时上报 ReadTime = 10h,电脑读了 5 小时上报 ReadTime = 5h:
| 时刻 | 动作 | 服务端 ReadTime | 用户视角的真值 |
|---|---|---|---|
| t1 | 手机同步 | 10h | 10h ✓ |
| t2 | 电脑同步 | 5h | 15h ✗ 被覆盖 |
| t3 | 手机再同步 | 10h | 15h ✗ 又被覆盖 |
这个数字永远在 10 和 5 之间来回横跳,15 这个正确答案在整张表里根本无处安放。LWW 的适用前提是"后写的值包含先写的值的信息"——位置字段勉强满足(最新位置就是权威位置),累计时长完全不满足:电脑的 5 小时里不包含手机的 10 小时,覆盖即丢失。
这其实是分布式系统里一个经典分类:进度是"寄存器"语义,时长是"计数器"语义。寄存器可以后写覆盖,计数器的正确合并方式只有一个——求和。而求和的前提是:每个参与者只上交自己的增量,不能上交总和。
第二版:按设备拆表,服务端求和
修复思路因此非常确定:把"每本书的时长"从一行拆成"每台设备对每本书一行",服务端负责加总。这就是 DeviceBookStat:
/// <summary>
/// 单台设备对某本书的累计阅读时长。
///
/// 为什么按设备拆开存:如果只存一个总量,手机读了 10 小时、电脑读了 5 小时,
/// 两端各自上报「本机累计 10 / 5」,后写覆盖的结果只能是其中一个,永远凑不出 15。
/// 按设备各存一份、服务端求和,才能得到正确的跨端总时长;且每台设备的累计值只增不减,
/// 重复上报也只是取 max,天然幂等。
/// </summary>
public class DeviceBookStat
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid UserId { get; set; }
public string BookKey { get; set; } = string.Empty;
/// <summary>设备标识,由客户端生成并持久化(换设备前保持不变)。</summary>
public string DeviceId { get; set; } = string.Empty;
/// <summary>该设备对这本书的累计阅读时长(毫秒),单调递增。</summary>
public long ReadTimeMs { get; set; }
public long UpdatedAt { get; set; }
}
合并逻辑刻意写得非常保守——没有这条记录就插入,有且新值更大才更新:
private async Task MergeDeviceReadTimeAsync(
Guid userId, string bookKey, string deviceId, long readTimeMs, long now, CancellationToken ct)
{
if (readTimeMs <= 0 || deviceId.Length == 0) return;
var stat = await db.DeviceStats
.FirstOrDefaultAsync(x => x.UserId == userId && x.BookKey == bookKey && x.DeviceId == deviceId, ct);
if (stat is null)
{
db.DeviceStats.Add(new DeviceBookStat
{
UserId = userId, BookKey = bookKey, DeviceId = deviceId,
ReadTimeMs = readTimeMs, UpdatedAt = now,
});
}
else if (readTimeMs > stat.ReadTimeMs)
{
stat.ReadTimeMs = readTimeMs; // 每台设备的累计值只增不减 → 幂等
stat.UpdatedAt = now;
}
}
两个细节值得展开。
为什么是取 max,不是 +=? 因为客户端上报的"本机累计"是全量快照,不是增量——网络重试、同步风暴、乱序到达,同一条 10h 会上报很多次。取 max 让合并变成幂等操作:上报一万次,结果和上报一次完全一样。而 += 会把每一次重试都计成新阅读。这是"计数器语义"落地时最容易走偏的一步:合并要幂等,前提是上交的是快照、合并用 max;如果上交的是增量,就必须带请求号去重,复杂得多。 我选了前者。
合计什么时候算? 不在查询时实时 SUM(每次拉书架都要聚合一次),也不在写入时顺手加(设备删除、数据修正时会漂移),而是每次有写入就重算一遍并只在变化时落库:
private async Task RecomputeTotalsAsync(Guid userId, HashSet<string> keys, CancellationToken ct)
{
var sums = await db.DeviceStats.AsNoTracking()
.Where(x => x.UserId == userId && keys.Contains(x.BookKey))
.GroupBy(x => x.BookKey)
.Select(g => new { BookKey = g.Key, Total = g.Sum(s => s.ReadTimeMs) })
.ToListAsync(ct);
var map = sums.ToDictionary(x => x.BookKey, x => x.Total);
var rows = await db.Progresses
.Where(x => x.UserId == userId && keys.Contains(x.BookKey))
.ToListAsync(ct);
var dirty = false;
foreach (var row in rows)
{
var total = map.TryGetValue(row.BookKey, out var t) ? t : 0;
if (row.TotalReadTimeMs != total) { row.TotalReadTimeMs = total; dirty = true; }
}
if (dirty) await db.SaveChangesAsync(ct);
}
重算而不是增量加,换来一个重要性质:TotalReadTimeMs 永远等于明细行的和,不需要事务保证一致——哪怕某次重算漏了、崩了,下一次写入会自动把它修对。自愈,比正确更值钱。
协议层的两个字段
这些语义最终沉淀在 ProgressDto 的两个长得几乎一样的字段上,区别全在注释里:
// ── 同步 / 统计 ──
/// <summary>本设备对这本书的累计阅读时长(毫秒),单调递增。</summary>
public long ReadTimeMs { get; init; }
/// <summary>跨设备累计阅读时长(毫秒),服务端汇总。</summary>
public long TotalReadTimeMs { get; init; }
规则有三条:
- 客户端上报时只填
ReadTimeMs,填的是本机计数器,语义是"这台设备在这本书上读了多久"; - 服务端不回传本机值——
ToDto里显式写死ReadTimeMs = 0,注释就一句"本机值,不回传"。本机计数器自己会增长,回传它没有任何用途,反而给了客户端"拿它覆盖本地"的错误诱惑; - 服务端只回传
TotalReadTimeMs,客户端拿它做展示,且只允许单调递增地采纳。
到这里,服务端这一侧的设计是完整的:拆表、max 合并、重算合计、隔离两个字段。15 小时终于凑出来了。
计时口径:两台设备的秒表长得不一样
还有一个前面绕开的朴素问题:时长本身是怎么数出来的? 两端答案完全不同。
手机端按"会话"计:进阅读页开始计时,退到后台或离开页面结算,短于 3 秒的误触直接丢弃:
export function beginReading() : void {
if (sessionStart == 0) sessionStart = Date.now()
}
export function endReading() : number {
if (sessionStart == 0) return 0
const delta = Date.now() - sessionStart
sessionStart = 0
// 过滤掉误触(小于 3 秒不计)
if (delta < 3000) return 0
stats.totalMs = stats.totalMs + delta
// ...写进按天的统计,返回本次计入的毫秒数
return delta
}
桌面端是 5 秒一个 tick 的定时器,且只有窗口处于激活状态才累计——挂着窗口去干别的,不算阅读:
private readonly DispatcherTimer _readTimer = new() { Interval = TimeSpan.FromSeconds(5) };
private void TickReadTime()
{
// 只有窗口处于激活状态才计时,避免挂着窗口刷阅读时长
if (Application.Current?.MainWindow?.IsActive == true) _sessionReadMs += 5000;
}
两个秒表精度不同、判定不同、丢的边角料也不同,同一小时的真实阅读在两端数出来的毫秒数必然对不上。如果时长合并走的是 LWW,这种口径差会直接变成"进度冲突";而按设备拆存之后,它只是两个互不干扰的计数器——口径不同不再有害,这正是第二版设计的意外红利。
"今日"和近 30 天的柱状图同理,各自是本机的 DayStat,不同步。跨端合并它们需要"按天 × 按设备"的二级表,成本陡增而收益只是书架上一行字——不做。
第三层坑:合计写回了本机
故事到这里应该结束了,但写这篇复盘时我把两端的代码重新读了一遍,发现一个没堵住的洞,而且它就是我自己在 v1.0.3 修"同步下来的书显示未读过"时亲手埋的。
当时的改动是对的:同步回填时要写回 UpdatedAt,否则拉下来的书永远显示"未读过"、也永远不再参与上报。顺手,我把时长也补了一行(两端同款):
// 累计阅读时长取「本机已有值」与「服务端回传的全设备合计」的较大者(与电脑端一致)
if (dto.totalReadTimeMs > book.readTime) {
book.readTime = dto.totalReadTimeMs
}
动机看起来无懈可击:服务端的合计是全设备的权威值,比本机值新、比本机值全,取大者展示,用户立刻能在手机上看到"累计 15 小时"。
问题在于 book.readTime 不只是展示值——它同时也是下次上报的 ReadTimeMs,也就是"本机计数器"。把合计写进它,等于把别人的阅读时间记到了自己账上。推演一遍,设手机本机 10h、电脑本机 5h:
| 轮次 | 动作 | 手机本机值 | 电脑本机值 | 服务端合计 |
|---|---|---|---|---|
| 0 | 初始 | 10h | 5h | 15h ✓ |
| 1 | 手机同步 | 10h → 15h(写回合计) | 5h | 20h ✗ |
| 2 | 电脑同步 | 15h | 5h → 20h(写回合计) | 35h ✗ |
| 3 | 再各同步一轮 | 35h | … | 70h ✗ |
真值 15h,三轮同步后变成 70h,而且每多同步一轮就再翻近一倍——每台设备都在把上一轮的合计记成自己的累计,服务端求和时全部重复计入。单调递增的"幂等保护"(取 max)在这里完全失效,因为被污染的值确实单调递增,只是涨的原因不是阅读。
有意思的是对照:服务端为了防污染,特意在 ToDto 里写死 ReadTimeMs = 0 不回传本机值,把两条字段隔离得干干净净;客户端却在回填时把合计写进了本机计数器,一行的 if 就把服务端全部的隔离设计抵消了。隔离从来不是单方面的声明,而是两端同时守约。
修复方向是明确的:Book 模型里拆成两个字段——readTime(本机计数器,只由本地计时器增长,只用于上报)和 totalReadTime(纯展示,只从服务端回填,只增不减);书架与统计页的"累计"一律读后者。历史数据里已被污染的本机值无法反推真值,只能随修复一并校正。这个修复会连着它的验证过程单开一篇,编号顺延在后面。
小结
时长的合并,三层教训各占一行:
- 先识别字段语义再选合并规则——进度是寄存器(覆盖),时长是计数器(求和)。合并规则选错,写多少防御代码都救不回来;
- 计数器要幂等,就上交全量快照、按设备存、合并取 max——单调递增换来的 immunity(重试无害)比省那几行存储划算;
- 上报值与展示值必须是两个字段——任何一个"顺便写回去"的便利改动,都可能把服务端的隔离设计一夜清零。
下一篇回到进度协议:增量同步的盲区——since 拉不回来的那些记录。