任务进度为什么会倒退
有些 Bug 很反直觉。
比如一个任务进度,明明已经在数据库里变成了完成状态,退出游戏再重新登录以后,又变回未完成。
更诡异的是,更新时间是新的,进度却是旧的。
这类问题最麻烦的地方在于:单看某一条链路,它都是对的;把几条链路放在一起,它就错了。
现象
这次问题的表现大概是这样:
- 玩家在游戏内做了一次兑换。
- 数据库里的任务记录一度变成了完成状态:
progress=1,status=CAN_CLAIM。 - 玩家退出游戏后重新登录。
- 同一条任务又变回了未完成:
progress=0,status=INIT。
如果只看兑换流程,会觉得它没有问题。兑换完成后,平台服务通知任务服务更新进度,数据库也确实写成功了。
真正的问题发生在退出游戏的时候。
三个模块各自都很合理
这个任务系统被拆在三个模块里:
| 模块 | 职责 |
|---|---|
| 平台服务 | 平台事件入口,比如支付、兑换这类不直接发生在游戏循环里的操作 |
| 任务服务 | 任务系统核心,负责 DB 读写、进度计算、任务刷新 |
| 游戏服务 | 游戏内实时任务进度,负责内存缓存、客户端推送、退出时 flush |
它们的职责单独看都很自然:
- 平台事件发生在平台服务,所以兑换后由平台服务通知任务服务。
- 任务数据落在任务服务,所以最终由任务服务写数据库。
- 游戏中需要实时显示任务进度,所以游戏服务会在玩家进游戏时加载任务进度到内存。
问题就出在最后一点:游戏服务里有一份任务进度缓存。
两条写路径
这个系统里,任务进度至少有两条更新路径。
第一条是游戏内事件。
例如射击、开宝箱、赚金币这种行为,本来就发生在游戏服里:
1 | 游戏内事件 |
这条路径是自洽的。内存先更新,DB 后更新,客户端也能收到推送。
第二条是平台事件。
兑换不是游戏服自己完成的,而是平台服务处理的:
1 | 平台服务完成兑换 |
这条路径也自洽。兑换成功后,任务服务把 DB 里的进度加上去。
但它少了一件事:没有通知游戏服务里的任务缓存。
脏缓存是怎么覆盖正确数据的
把时间线连起来,就能看到问题:
1 | 玩家进入游戏 |
所以,数据库不是没有更新成功。
它是先被正确更新了,然后又被旧缓存覆盖了。
这就是这个问题最容易误判的地方:如果只查兑换那一刻的数据库,会看到正确结果;如果查退出后的数据库,会看到错误结果。中间缺的不是一次写入,而是一次来自旧状态的覆盖写入。这个形态和并发控制里常说的 lost update 很接近:不是没有写入,而是后来的旧快照把已经成立的新事实覆盖掉了。[1]
为什么更新时间是对的
这个现象也很迷惑:upd_time 看起来是对的,但 progress 和 status 是错的。
原因在 SQL 写法里。
任务更新时,progress、upd_time、status 是一起 SET 的:
1 | update task_progress |
退出 flush 那条路径在写之前会先查一次 DB。此时 DB 里已经有新的 upd_time,但内存缓存里还是旧的进度和状态。
于是它写回去的是:
progress: 来自旧缓存,变回 0status: 来自旧缓存,变回 INITupd_time: 来自刚查出来的 DB 记录,看起来还是新的
这就造成了一个很有迷惑性的现场:时间像是更新过,数据却像是回滚了。
它不是事务回滚,而是旧状态覆盖。
根因不是缓存,而是缓存的所有权不清楚
看到这里,很容易得出一个简单结论:缓存有问题。
但更准确地说,是这份缓存的所有权和写入边界不够清楚。
游戏服务里的任务缓存适合处理游戏内事件,因为这些事件天然发生在游戏服务,并且需要实时推送客户端。
但兑换任务不是游戏内事件。它的事实来源在平台服务,最终状态在任务服务,游戏服务只是曾经加载过一份旧快照。
如果一份缓存满足下面两个条件,它就很危险:
- 它不是事实来源。
- 它可以在退出、定时任务、补偿流程里反向覆盖事实来源。
这时候缓存就不只是缓存了,它变成了另一个写入者。Microsoft 的 Cache-Aside pattern 文档也明确提醒:cache-aside 本身并不保证缓存和数据源一致,外部进程更新数据源时,缓存要等重新加载或失效后才会反映变化。[2]
而多个写入者最怕的,就是每个人都以为自己手上的状态是最新的。
修复为什么不是加一次同步
直觉上的修复是:兑换后通知游戏服务,把任务缓存也更新掉。
这当然是一种方案,但它会引入新的问题:
- 要给游戏服务增加更新任务进度的 RPC 能力。
- 要考虑玩家是否在线、在哪个游戏服、通知失败怎么办。
- 要保证 DB 更新和游戏服务缓存更新之间的失败补偿。
- 以后所有平台任务都要想清楚要不要同步游戏服务缓存。
这不是不能做,而是成本更高。AWS 对 cache-aside 和 write-through 的区分可以解释这里的取舍:如果选择 write-through,应用就要在更新主数据库后同步维护缓存;如果选择 cache-aside,就要承认缓存是按需加载的副本,并明确它的失效和回源机制。[3]
这次采用的是更小的修复:不要把兑换任务加载到游戏服务缓存里。
原因很简单:兑换任务不需要游戏内实时追踪。
玩家做兑换时,事实发生在平台服务;任务完成状态也由任务服务写 DB。它不需要由游戏服内存来参与生命周期。
所以把兑换任务加入一个“非游戏内追踪任务”的排除列表:
1 | private static final int EXCHANGE_TASK_ID = 12; |
这样玩家进入游戏时,这类任务不会进入游戏服务的内存缓存:
1 | 不加载到游戏服务 |
这个修复的关键不是“少写一段同步代码”,而是重新划清了任务的归属。
游戏内任务交给游戏服务缓存追踪。
平台任务不要进入游戏服务缓存。
什么时候应该让缓存参与写入
这次问题给我的提醒是:不要只问“要不要缓存”,还要问“缓存有没有资格写回”。
如果缓存只是读优化,它失效或过期最多影响性能。
如果缓存会写回数据库,它就进入了 缓存一致性 的核心路径。此时至少要回答几个问题:
- 缓存里的数据是谁更新的?
- 还有没有其他路径会更新同一份 DB 数据?
- 缓存写回时,是覆盖写、增量写,还是带版本判断的写?
- 如果 DB 已经被其他路径更新,缓存写回应该跳过、合并,还是报错?
- 哪些业务根本不应该进入这份缓存?
这几个问题里,最后一个经常被忽略。乐观并发控制给出的思路是:保存前比较“我当初读到的版本”和“数据库当前版本”,如果已经变化,就不要静默覆盖,而是进入冲突处理。EF Core 的 concurrency token 和 Fowler 的 Optimistic Offline Lock 都是在解决这类“旧副本写回”问题。[4]
很多时候,最稳的修复不是让所有状态同步到所有地方,而是减少不该拥有状态的地方。
小结
这个 Bug 表面上是“任务进度倒退”,本质上是两条写路径竞争同一行数据:
1 | 平台服务路径:写入正确进度 |
它提醒我一件事:
缓存不是只要命中率高就算设计完成。只要缓存能写回,它就是一个数据作者。
一个系统里可以有多个读者,但最好不要有太多自以为拥有事实的作者。
参考与延伸阅读
- Microsoft Azure Architecture Center, Cache-Aside pattern
- AWS, Caching patterns - Database Caching Strategies Using Redis
- Martin Fowler, Optimistic Offline Lock
- Microsoft Learn, Handling Concurrency Conflicts - EF Core
Martin Fowler, Optimistic Offline Lock。这里借用的是“检测并阻止并发业务事务互相覆盖”的思路,不是说这个事故一定发生在 ORM 或长事务场景。 ↩︎
Microsoft Azure Architecture Center, Cache-Aside pattern。这里引用它来支撑“cache-aside 不自动保证缓存和数据源一致,外部写路径会让缓存变旧”的判断。 ↩︎
AWS, Caching patterns - Database Caching Strategies Using Redis。这里引用它来区分 cache-aside / lazy loading 和 write-through:前者按需填充,后者要求应用在主库更新后主动维护缓存。 ↩︎
Microsoft Learn, Handling Concurrency Conflicts - EF Core;Martin Fowler, Optimistic Offline Lock。这里引用它们来支撑“旧快照写回应带版本判断或冲突处理,而不是无条件覆盖”的设计原则。 ↩︎