任务进度为什么会倒退

有些 Bug 很反直觉。

比如一个任务进度,明明已经在数据库里变成了完成状态,退出游戏再重新登录以后,又变回未完成。

更诡异的是,更新时间是新的,进度却是旧的。

这类问题最麻烦的地方在于:单看某一条链路,它都是对的;把几条链路放在一起,它就错了。

现象

这次问题的表现大概是这样:

  1. 玩家在游戏内做了一次兑换。
  2. 数据库里的任务记录一度变成了完成状态:progress=1status=CAN_CLAIM
  3. 玩家退出游戏后重新登录。
  4. 同一条任务又变回了未完成:progress=0status=INIT

如果只看兑换流程,会觉得它没有问题。兑换完成后,平台服务通知任务服务更新进度,数据库也确实写成功了。

真正的问题发生在退出游戏的时候。

三个模块各自都很合理

这个任务系统被拆在三个模块里:

模块 职责
平台服务 平台事件入口,比如支付、兑换这类不直接发生在游戏循环里的操作
任务服务 任务系统核心,负责 DB 读写、进度计算、任务刷新
游戏服务 游戏内实时任务进度,负责内存缓存、客户端推送、退出时 flush

它们的职责单独看都很自然:

  • 平台事件发生在平台服务,所以兑换后由平台服务通知任务服务。
  • 任务数据落在任务服务,所以最终由任务服务写数据库。
  • 游戏中需要实时显示任务进度,所以游戏服务会在玩家进游戏时加载任务进度到内存。

问题就出在最后一点:游戏服务里有一份任务进度缓存。

两条写路径

这个系统里,任务进度至少有两条更新路径。

第一条是游戏内事件。

例如射击、开宝箱、赚金币这种行为,本来就发生在游戏服里:

1
2
3
4
5
游戏内事件
-> 更新游戏服务内存缓存
-> 推送客户端任务进度变化
-> 退出或阶段性 flush
-> 任务服务写 DB

这条路径是自洽的。内存先更新,DB 后更新,客户端也能收到推送。

第二条是平台事件。

兑换不是游戏服自己完成的,而是平台服务处理的:

1
2
3
4
平台服务完成兑换
-> 通知任务服务更新进度
-> 任务服务按条件更新任务
-> 写 DB

这条路径也自洽。兑换成功后,任务服务把 DB 里的进度加上去。

但它少了一件事:没有通知游戏服务里的任务缓存。

脏缓存是怎么覆盖正确数据的

把时间线连起来,就能看到问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
玩家进入游戏
-> 游戏服务从任务服务加载任务进度
-> 缓存在游戏服务内存中
-> 此时 finishNum=0, status=INIT

玩家在游戏内兑换
-> 平台服务完成兑换
-> 任务服务写 DB
-> DB 变为 progress=1, status=CAN_CLAIM
-> 游戏服务缓存仍然是 finishNum=0, status=INIT

玩家退出游戏
-> 游戏服务 flush 玩家任务缓存
-> 把内存里的旧进度 flush 到任务服务
-> 任务服务用旧 progress/status 覆盖 DB

所以,数据库不是没有更新成功。

它是先被正确更新了,然后又被旧缓存覆盖了。

这就是这个问题最容易误判的地方:如果只查兑换那一刻的数据库,会看到正确结果;如果查退出后的数据库,会看到错误结果。中间缺的不是一次写入,而是一次来自旧状态的覆盖写入。这个形态和并发控制里常说的 lost update 很接近:不是没有写入,而是后来的旧快照把已经成立的新事实覆盖掉了。[1]

为什么更新时间是对的

这个现象也很迷惑:upd_time 看起来是对的,但 progressstatus 是错的。

原因在 SQL 写法里。

任务更新时,progressupd_timestatus 是一起 SET 的:

1
2
3
4
5
6
update task_progress
set progress = ?,
upd_time = ?,
status = ?
where role_id = ?
and task_id = ?

退出 flush 那条路径在写之前会先查一次 DB。此时 DB 里已经有新的 upd_time,但内存缓存里还是旧的进度和状态。

于是它写回去的是:

  • progress: 来自旧缓存,变回 0
  • status: 来自旧缓存,变回 INIT
  • upd_time: 来自刚查出来的 DB 记录,看起来还是新的

这就造成了一个很有迷惑性的现场:时间像是更新过,数据却像是回滚了。

它不是事务回滚,而是旧状态覆盖。

根因不是缓存,而是缓存的所有权不清楚

看到这里,很容易得出一个简单结论:缓存有问题。

但更准确地说,是这份缓存的所有权和写入边界不够清楚。

游戏服务里的任务缓存适合处理游戏内事件,因为这些事件天然发生在游戏服务,并且需要实时推送客户端。

但兑换任务不是游戏内事件。它的事实来源在平台服务,最终状态在任务服务,游戏服务只是曾经加载过一份旧快照。

如果一份缓存满足下面两个条件,它就很危险:

  1. 它不是事实来源。
  2. 它可以在退出、定时任务、补偿流程里反向覆盖事实来源。

这时候缓存就不只是缓存了,它变成了另一个写入者。Microsoft 的 Cache-Aside pattern 文档也明确提醒:cache-aside 本身并不保证缓存和数据源一致,外部进程更新数据源时,缓存要等重新加载或失效后才会反映变化。[2]

而多个写入者最怕的,就是每个人都以为自己手上的状态是最新的。

修复为什么不是加一次同步

直觉上的修复是:兑换后通知游戏服务,把任务缓存也更新掉。

这当然是一种方案,但它会引入新的问题:

  • 要给游戏服务增加更新任务进度的 RPC 能力。
  • 要考虑玩家是否在线、在哪个游戏服、通知失败怎么办。
  • 要保证 DB 更新和游戏服务缓存更新之间的失败补偿。
  • 以后所有平台任务都要想清楚要不要同步游戏服务缓存。

这不是不能做,而是成本更高。AWS 对 cache-aside 和 write-through 的区分可以解释这里的取舍:如果选择 write-through,应用就要在更新主数据库后同步维护缓存;如果选择 cache-aside,就要承认缓存是按需加载的副本,并明确它的失效和回源机制。[3]

这次采用的是更小的修复:不要把兑换任务加载到游戏服务缓存里。

原因很简单:兑换任务不需要游戏内实时追踪。

玩家做兑换时,事实发生在平台服务;任务完成状态也由任务服务写 DB。它不需要由游戏服内存来参与生命周期。

所以把兑换任务加入一个“非游戏内追踪任务”的排除列表:

1
2
3
4
5
6
7
private static final int EXCHANGE_TASK_ID = 12;

public static final Set<Integer> NON_GAMEPLAY_TASK_IDS = set(
BIND_PHONE_TASK_ID,
DAILY_RECHARGE_TASK_ID,
EXCHANGE_TASK_ID
);

这样玩家进入游戏时,这类任务不会进入游戏服务的内存缓存:

1
2
3
4
不加载到游戏服务
-> 不产生游戏服务缓存
-> 退出时不 flush
-> 不会用旧缓存覆盖 DB

这个修复的关键不是“少写一段同步代码”,而是重新划清了任务的归属。

游戏内任务交给游戏服务缓存追踪。

平台任务不要进入游戏服务缓存。

什么时候应该让缓存参与写入

这次问题给我的提醒是:不要只问“要不要缓存”,还要问“缓存有没有资格写回”。

如果缓存只是读优化,它失效或过期最多影响性能。

如果缓存会写回数据库,它就进入了 缓存一致性 的核心路径。此时至少要回答几个问题:

  1. 缓存里的数据是谁更新的?
  2. 还有没有其他路径会更新同一份 DB 数据?
  3. 缓存写回时,是覆盖写、增量写,还是带版本判断的写?
  4. 如果 DB 已经被其他路径更新,缓存写回应该跳过、合并,还是报错?
  5. 哪些业务根本不应该进入这份缓存?

这几个问题里,最后一个经常被忽略。乐观并发控制给出的思路是:保存前比较“我当初读到的版本”和“数据库当前版本”,如果已经变化,就不要静默覆盖,而是进入冲突处理。EF Core 的 concurrency token 和 Fowler 的 Optimistic Offline Lock 都是在解决这类“旧副本写回”问题。[4]

很多时候,最稳的修复不是让所有状态同步到所有地方,而是减少不该拥有状态的地方。

小结

这个 Bug 表面上是“任务进度倒退”,本质上是两条写路径竞争同一行数据:

1
2
3
平台服务路径:写入正确进度
游戏服务路径:退出时 flush 旧缓存
最终结果:旧缓存覆盖新 DB

它提醒我一件事:

缓存不是只要命中率高就算设计完成。只要缓存能写回,它就是一个数据作者。

一个系统里可以有多个读者,但最好不要有太多自以为拥有事实的作者。

参考与延伸阅读


  1. Martin Fowler, Optimistic Offline Lock。这里借用的是“检测并阻止并发业务事务互相覆盖”的思路,不是说这个事故一定发生在 ORM 或长事务场景。 ↩︎

  2. Microsoft Azure Architecture Center, Cache-Aside pattern。这里引用它来支撑“cache-aside 不自动保证缓存和数据源一致,外部写路径会让缓存变旧”的判断。 ↩︎

  3. AWS, Caching patterns - Database Caching Strategies Using Redis。这里引用它来区分 cache-aside / lazy loading 和 write-through:前者按需填充,后者要求应用在主库更新后主动维护缓存。 ↩︎

  4. Microsoft Learn, Handling Concurrency Conflicts - EF Core;Martin Fowler, Optimistic Offline Lock。这里引用它们来支撑“旧快照写回应带版本判断或冲突处理,而不是无条件覆盖”的设计原则。 ↩︎