并发问题与线程研究

同房间玩家操作的并发问题

现有问题(丢失更新/不可重复读)

  • 现象: 玩家并发加入/离开时,前端显示玩家没有加入/离开。数据库中Game->Players没有被更新
  • 现象: 玩家并发投票时,有的投票操作失败或投票结果没有出现。数据库中的voteHistory没有更新。
  • 原因: GameService没有做并发处理。服务处理每个用户请求都会从数据库读取完整的Game+Players+votes,处理完后再写回数据。发生并发处理时,服务可能同时操作Game对象,回写时只有最后一个操作会生效,前面的回写会被覆盖。

并发处理的方法

  • 线程锁
    • 方法锁:@Syncronized
      • 必须一个方法一个方法的加锁,且无法适用于响应式调用
    • 对象锁:ReentrantLock
      • 引入缓存,同一个Game share同一个对象,为这个对象加锁
      • 无缓存的情况下,每个请求处理的Game都不是同一个对象,需要针对GameId设计锁
  • 单线程
    • 所有Game command都使用单线程处理,每个处理都要排队
    • 为每个Game分配一个线程,同一GameId的command处理要排队
  • 数据库锁
    • 表锁? vs 条目锁? vs 字段锁?
    • 悲观锁 vs 乐观锁(失败需要retry机制)
  • All about lock
  • kotlinx-coroutines-reactor ??? coroutines挂起避免线程阻塞
  • 性能分析以及测试

有锁方案分析

目前的应用中,对Game数据的操作主要由玩家加入(Join),离开(Leave)和游戏命令(Command)引发。这些操作都由读数据库开始,写数据库结束,如果操作失败则不会写数据库,读写操作数量基本持平。此外,由于只有同房间的游戏操作才会出现数据问题,所以频度较低。
综上,我们可以采用乐观锁。

  • MongoDB乐观锁: 在Game中加入versionId并在写入数据时使用原子操作findAndModify。
  • java方法锁/对象锁 (syncronized): 响应式调用中难以使用
  • 对象锁+缓存: 实现比较复杂

在加锁以后如果发生并发修改的时候,只有第一个写入操作会成功,后续写入都会失败。所以需要为整个游戏操作加入重试机制。此逻辑可以用Reactive的retry机制方便的实现。但重试会带来多次数据库读写增加的问题。如并发数为n,则最高重试次数为(1+n)*n/2(例:12个玩家同时加入游戏,最多可能发生78次加入游戏处理)。

ReactiveMongoRepository的save方法实现自带乐观锁。只要实体定义了带有@Version注解的字段,在调用save时就会自动用version进行乐观锁校验,并在发现版本错误时抛出OptimisticLockingFailureException。同时也会在save时自增version。

无锁方案分析

在Game操作中按照GameId分配线程,同一个GameId的操作都使用同一个线程。
此实现的难点主要在于根据GameId创建线程,以及将MongoDB的读写切换到GameId对应的线程上。

PublisherMapping的问题

我们在PublisherMapping中使用了mutableMapOf()生成了一个用来存储游戏房间广播器(WebSocketPublisher)的map。玩家加入房间时,会调用createPublisherIfNotExist()来创建或获取本房间的广播器。当所有玩家离开房间时,会调用removePublisher()来释放广播器。然而,当玩家并发加入或离开房间时,广播器的管理可能会有问题,因为这两个方法都不是线程安全的。
改造方案:

  1. (有锁方案)将PublisherMapping中对map进行读写的方法都加上锁-syncronized(msgPublisherMap)
  2. (无锁方案)将PublisherMapping中对map进行读写的方法改造成支持响应式调用,并限制在固定线程执行。

你可能感兴趣的:(并发问题与线程研究)