数据库并发读写问题:多用户同时加入游戏房间冲突如何解决
问题本质
这是典型的并发读写竞态条件:多个请求在几乎同一时间查询到User2为0的可用房间,都执行了赋值更新操作,因此所有请求都判定自己更新成功,导致多个用户被分配到同一个房间位置。改成同步方法没用是因为多线程服务器下,还是会有多个线程同时进入查询逻辑,未加锁的同步逻辑解决不了并发冲突问题。
可行解决方案
方案1:单条原子SQL更新(性能最优,推荐)
直接把查询和更新合并为一条原子SQL执行,利用数据库的原子操作特性,保证同一时间只有一个请求能更新成功,不需要额外加锁或事务,性能最高。
public async Task<string> JoinRoom(int leaguePosition, int userId) { // 以下SQL以PostgreSQL为例,其他数据库调整对应语法即可 var updateSql = @" UPDATE ClassicGames SET User2 = @userId, User2State = @newState WHERE Id = ( SELECT cg.Id FROM ClassicGames cg INNER JOIN Leagues l ON cg.LeagueId = l.Id WHERE l.Position = @leaguePosition AND cg.User2 = 0 AND cg.User2State = @observerState LIMIT 1 ) RETURNING Id, User1; "; var result = await context.Database.SqlQueryRaw<(int Id, int User1)>( updateSql, new SqlParameter("@userId", userId), new SqlParameter("@newState", (int)EClassicGameUserState.STAGNATION), new SqlParameter("@leaguePosition", leaguePosition), new SqlParameter("@observerState", (int)EClassicGameUserState.OBSERVER) ).FirstOrDefaultAsync(); return result == default ? "" : $"{result.Id},{result.User1}"; }
方案2:乐观锁(适合并发量不高的场景)
给ClassicGames表添加行版本字段,EF Core更新时会自动校验版本,版本不匹配就抛出并发异常,说明房间已经被其他用户占用。
- 实体类添加版本字段
public class ClassicGame { // 原有字段省略 [Timestamp] public byte[] RowVersion { get; set; } }
- 修改Service逻辑
public async Task<string> JoinRoom(int leaguePosition, int userId) { try { var gameRoom = await context.ClassicGames.Include(x => x.League) .FirstOrDefaultAsync(x => x.League.Position == leaguePosition && x.User2 == 0 && x.User2State == (int)EClassicGameUserState.OBSERVER); if (gameRoom is null) return ""; gameRoom.User2 = userId; gameRoom.User2State = (int)EClassicGameUserState.STAGNATION; var affectedRows = await context.SaveChangesAsync(); return affectedRows == 0 ? "" : $"{gameRoom.Id},{gameRoom.User1}"; } catch (DbUpdateConcurrencyException) { // 并发冲突,说明房间已被占用 return ""; } }
方案3:分布式锁(适合多实例部署场景)
如果你的服务是多实例部署,本地锁无效,可以用Redis分布式锁;如果是单实例部署,用静态SemaphoreSlim即可:
// 全局静态锁,同一时间只允许一个请求进入加入逻辑 private static readonly SemaphoreSlim _joinRoomLock = new SemaphoreSlim(1, 1); public async Task<string> JoinRoom(int leaguePosition, int userId) { await _joinRoomLock.WaitAsync(); try { var gameRoom = await context.ClassicGames.Include(x => x.League) .FirstOrDefaultAsync(x => x.League.Position == leaguePosition && x.User2 == 0 && x.User2State == (int)EClassicGameUserState.OBSERVER); if (gameRoom is null) return ""; gameRoom.User2 = userId; gameRoom.User2State = (int)EClassicGameUserState.STAGNATION; await context.SaveChangesAsync(); return $"{gameRoom.Id},{gameRoom.User1}"; } finally { _joinRoomLock.Release(); } }
内容的提问来源于stack exchange,提问作者Ertryw
相关产品推荐
相关产品推荐

