【问题标题】:Leaderboard design using SQL Server使用 SQL Server 设计排行榜
【发布时间】:2013-11-12 05:49:57
【问题描述】:

我正在为我的一些在线游戏建立排行榜。以下是我需要对数据执行的操作:

  • 获取给定游戏在多个时间范围内(今天、上周、所有时间等)的玩家排名
  • 获取分页排名(例如,最近 24 小时的最高分,获取排名 25 到 50 之间的玩家,获取排名或单个用户)

我使用下表定义和索引进行了定义,我有几个问题。

考虑到我的场景,我是否有一个好的主键? 我之所以在 gameId、playerName 和 score 中使用集群键,只是因为我想确保给定的所有数据游戏在同一区域,并且该分数已经排序。大多数情况下,我将显示给定 gameId 的得分降序排列(+updateDateTime 表示平局)。这是一个正确的策略吗?换句话说,我想确保我可以运行查询以尽快获得我的玩家的排名。

CREATE TABLE score (
    [gameId]            [smallint] NOT NULL,
    [playerName]        [nvarchar](50) NOT NULL,
    [score]             [int] NOT NULL,
    [createdDateTime]   [datetime2](3) NOT NULL,
    [updatedDateTime]   [datetime2](3) NOT NULL,
PRIMARY KEY CLUSTERED ([gameId] ASC, [playerName] ASC, [score] DESC, [updatedDateTime] ASC)

CREATE NONCLUSTERED INDEX [Score_Idx] ON score ([gameId] ASC, [score] DESC, [updatedDateTime] ASC) INCLUDE ([playerName])

下面是我将用来获取玩家排名的查询的第一次迭代。但是,我对执行计划有点失望(见下文)。 为什么 SQL 需要排序? 额外的排序似乎来自 RANK 函数。但是我的数据不是已经按降序排序了吗(基于分数表的聚集键)?我还想知道是否应该对我的表进行更多规范化并移出 Player 表中的 PlayerName 列。我最初决定将所有内容都放在同一个表中以尽量减少连接数。

DECLARE @GameId AS INT = 0
DECLARE @From AS DATETIME2(3) = '2013-10-01'

SELECT DENSE_RANK() OVER (ORDER BY Score DESC), s.PlayerName, s.Score, s.CountryCode, s.updatedDateTime
FROM [mrgleaderboard].[score] s
WHERE s.GameId = @GameId 
  AND (s.UpdatedDateTime >= @From OR @From IS NULL)

感谢您的帮助!

【问题讨论】:

  • 您使用的是什么版本的 SQL Server?
  • 在设计主键时,请记住插入新值时可能会占用大量资源。至于排序:您的主键首先按游戏和玩家排序,然后按分数排序。基本上,您正在对每场比赛的球员得分进行排序,这(如果您只对球员每场比赛的最高得分感兴趣)非常没有意义。但是,在您的查询中,您正在对游戏中所有玩家的得分进行排名(即排序)。
  • 你好@BrettSchneider。我没有任何数据来支持这一点,但我的印象是插入/更新分数的频率会低于查询玩家排名或获取顶级玩家列表的频率。这就是为什么我试图拥有一个包含玩家得分的集群键。这样一来,数据几乎就被排序了。
  • dense_rank 恕我直言需要索引[gameid], [score], [updatedtime] 才能在不排序的情况下工作。在[gameid] 之后通过[playername] 聚类的动机是什么?如果您要查询玩家在游戏中的排名,这将无济于事,因为您想按分数对玩家进行排名。
  • @Martin 我更新了我的帖子,请检查

标签: sql sql-server database database-design azure-sql-database


【解决方案1】:

[更新]

主键不好

您有一个独特的实体,即 [GameID] + [PlayerName]。并且复合聚集索引 > 120 字节与 nvarchar。在相关主题SQL Server - Clustered index design for dictionary中寻找@marc_s的答案

您的表架构与时间段的要求不匹配

例如:我在星期三获得了 300 分,这个分数存储在排行榜上。第二天我获得了 250 分,但它不会记录在排行榜上,如果我对周二排行榜运行查询,您也不会得到结果

有关完整信息,您可以从历史桌上游戏的得分中获得,但它可能非常昂贵

CREATE TABLE GameLog (
  [id]                int NOT NULL IDENTITY
                      CONSTRAINT [PK_GameLog] PRIMARY KEY CLUSTERED,
  [gameId]            smallint NOT NULL,
  [playerId]          int NOT NULL,
  [score]             int NOT NULL,
  [createdDateTime]   datetime2(3) NOT NULL)

以下是与聚合相关的加速它的解决方案:

  • 历史表的索引视图(参见@Twinkles 的post)。

您需要 3 个索引视图用于 3 个时间段。可能巨大的历史表和 3 个索引视图。无法删除表格的“旧”时段。保存分数的性能问题。

  • 异步排行榜

保存在历史表中的分数。 SQL 作业/“工人”(或多个)根据计划(每分钟 1 个?)对历史表进行排序,并使用预先计算的用户排名填充排行榜表(3 个时间段的 3 个表或一个带时间段键的表)。该表也可以非规范化(具有分数、日期时间、玩家名称和...)。优点:快速阅读(无需排序),快速保存分数,任何时间段,灵活的逻辑和灵活的时间表。缺点:用户已完成游戏,但没有立即在排行榜上找到自己

  • 预聚合排行榜

在记录游戏会话的结果时进行预处理。在您的情况下,类似于UPDATE [Leaderboard] SET score = @CurrentScore WHERE @CurrentScore > MAX (score) AND ... 的玩家/游戏ID,但您只为“所有时间”排行榜这样做。该方案可能如下所示:

CREATE TABLE [Leaderboard] (
    [id]                int NOT NULL IDENTITY
                             CONSTRAINT [PK_Leaderboard] PRIMARY KEY CLUSTERED,
    [gameId]            smallint NOT NULL,
    [playerId]          int NOT NULL,
    [timePeriod]        tinyint NOT NULL,   -- 0 -all time, 1-monthly, 2 -weekly, 3 -daily
    [timePeriodFrom]    date NOT NULL,  -- '1900-01-01' for all time, '2013-11-01' for monthly, etc.
    [score]             int NOT NULL,
    [createdDateTime]   datetime2(3) NOT NULL
    )
playerId timePeriod timePeriodFrom 分数 ---------------------------------------------- 1 0 1900-01-01 300 ... 1 1 2013-10-01 150 1 1 2013-11-01 300 ... 1 2 2013-10-07 150 1 2 2013-11-18 300 ... 1 3 2013-11-19 300 1 3 2013-11-20 250 ...

因此,您必须更新所有时间段的所有 3 个分数。此外,您可以看到排行榜将包含“旧”时期,例如十月的每月。如果您不需要此统计信息,也许您必须将其删除。优点:不需要历史表。缺点:存储结果的过程复杂。需要维护排行榜。查询需要排序和JOIN

CREATE TABLE [Player] (
    [id]    int NOT NULL IDENTITY CONSTRAINT [PK_Player] PRIMARY KEY CLUSTERED,
    [playerName]        nvarchar(50) NOT NULL CONSTRAINT [UQ_Player_playerName] UNIQUE NONCLUSTERED)

CREATE TABLE [Leaderboard] (
    [id]                int NOT NULL IDENTITY CONSTRAINT [PK_Leaderboard] PRIMARY KEY CLUSTERED,
    [gameId]            smallint NOT NULL,
    [playerId]          int NOT NULL,
    [timePeriod]        tinyint NOT NULL,   -- 0 -all time, 1-monthly, 2 -weekly, 3 -daily
    [timePeriodFrom]    date NOT NULL,  -- '1900-01-01' for all time, '2013-11-01' for monthly, etc.
    [score]             int NOT NULL,
    [createdDateTime]   datetime2(3) 
)

CREATE UNIQUE NONCLUSTERED INDEX [UQ_Leaderboard_gameId_playerId_timePeriod_timePeriodFrom] ON [Leaderboard] ([gameId] ASC, [playerId] ASC, [timePeriod]  ASC,  [timePeriodFrom] ASC)
CREATE NONCLUSTERED INDEX [IX_Leaderboard_gameId_timePeriod_timePeriodFrom_Score] ON [Leaderboard] ([gameId] ASC, [timePeriod]  ASC,  [timePeriodFrom] ASC, [score] ASC)
GO

-- Generate test data
-- Generate 500K unique players
;WITH digits (d) AS (SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION
   SELECT 4 UNION SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION SELECT 0)

INSERT INTO Player (playerName)
SELECT TOP (500000) LEFT(CAST(NEWID() as nvarchar(50)), 20 + (ABS(CHECKSUM(NEWID())) & 15)) as Name
FROM   digits CROSS JOIN digits ii CROSS  JOIN digits iii CROSS  JOIN digits iv CROSS  JOIN digits v CROSS  JOIN digits vi

-- Random score 500K players * 4 games = 2M rows
INSERT INTO [Leaderboard] (
    [gameId],[playerId],[timePeriod],[timePeriodFrom],[score],[createdDateTime])
SELECT  GameID, Player.id,ABS(CHECKSUM(NEWID())) & 3 as [timePeriod], DATEADD(MILLISECOND, CHECKSUM(NEWID()),GETDATE()) as Updated, ABS(CHECKSUM(NEWID())) & 65535 as score
    , DATEADD(MILLISECOND, CHECKSUM(NEWID()),GETDATE()) as Created
FROM (  SELECT 1 as GameID  UNION ALL SELECT 2  UNION ALL SELECT 3  UNION ALL SELECT 4) as Game
    CROSS JOIN Player
ORDER BY NEWID()
UPDATE [Leaderboard] SET [timePeriodFrom]='19000101' WHERE [timePeriod] = 0
GO

DECLARE @From date = '19000101'--'20131108'
    ,@GameID int = 3
    ,@timePeriod tinyint = 0

-- Get paginated ranking 
;With Lb as (
SELECT 
    DENSE_RANK() OVER (ORDER BY Score DESC) as Rnk
    ,Score, createdDateTime, playerId
FROM [Leaderboard]
WHERE GameId = @GameId
  AND [timePeriod] = @timePeriod
  AND [timePeriodFrom] = @From)

SELECT lb.rnk,lb.Score, lb.createdDateTime, lb.playerId, Player.playerName
FROM Lb INNER JOIN Player ON lb.playerId = Player.id
ORDER BY rnk OFFSET 75 ROWS FETCH NEXT 25 ROWS ONLY;

-- Get rank of a player for a given game 
SELECT (SELECT COUNT(DISTINCT rnk.score) 
        FROM [Leaderboard] as rnk 
        WHERE rnk.GameId = @GameId 
            AND rnk.[timePeriod] = @timePeriod
            AND rnk.[timePeriodFrom] = @From
            AND rnk.score >= [Leaderboard].score) as rnk
    ,[Leaderboard].Score, [Leaderboard].createdDateTime, [Leaderboard].playerId, Player.playerName
FROM [Leaderboard]  INNER JOIN Player ON [Leaderboard].playerId = Player.id
where [Leaderboard].GameId = @GameId
    AND [Leaderboard].[timePeriod] = @timePeriod
    AND [Leaderboard].[timePeriodFrom] = @From
    and Player.playerName = N'785DDBBB-3000-4730-B'
GO

这只是一个展示想法的例子。它可以被优化。例如,通过字典表将 GameID、TimePeriod、TimePeriodDate 列合并为一列。该指标的有效性会更高。

附:对不起我的英语不好。随意修正语法或拼写错误

【讨论】:

  • 我理解你的观点,但是现在我所有的查询都需要一个连接,这对性能不是很好。我需要这个排行榜能够快速获得超过 500,000 的分数。
  • @Martin 我有社交游戏排行榜的经验。并查看您的描述,我已经完成了类似的功能。在 nvarchar(50) 的情况下,索引的大小将大 5 倍,这意味着 I/O 上的负载将增加 5 倍。关于 JOIN - 在大多数情况下,玩家的名字只需要少量已经排序的数据 - 例如。 TOP 100。通过主键加入是相当便宜的操作。请详细描述其中一项查询,或许能找到最优解。
  • 你有一个我想要在问题中做的查询。
  • 如果一个玩家可以在一个游戏中获得多个分数,则必须将索引调整为CREATE UNIQUE NONCLUSTERED INDEX [UQ_Score_gameId_playerId] ON score ([gameId] ASC, [playerId] ASC, [createdDateTime] ASC)
  • @Twinkles 我看到一列 [createdDateTime] 和 [UpdatedDateTime] 并意识到该表仅存储玩家在此游戏中的最高分数。并且存储在单独的历史表中的所有游戏会话可能更大,甚至可以不时备份/截断。在我看来,它将提供更好的性能。
【解决方案2】:

您可以查看indexed views 为常见时间范围(今天、本周/月/年、所有时间)创建记分牌。

【讨论】:

  • 在某些情况下,这是一个很好的解决方案,但我们需要查看 INSERT/UPDATE 性能。取决于读取排行榜的请求数量和添加/更新分数的数量。
  • 从您的链接中引用:“受益于索引视图实施的应用程序包括: •决策支持工作负载。 •数据集市。 •数据仓库。 •在线分析处理 (OLAP) 存储和源。 •数据挖掘工作负载。相反,具有大量写入的在线事务处理 (OLTP) 系统或具有频繁更新的数据库应用程序可能无法利用索引视图,因为与更新视图和底层相关的维护成本增加基表。”
【解决方案3】:

要获得给定游戏在多个时间范围内的排名,您将选择游戏并在多个时间范围内按得分排名(即排序)。为此,您的非聚集索引可以像这样更改,因为这是您的选择似乎查询的方式。

CREATE NONCLUSTERED INDEX [Score_Idx] 
ON score ([gameId] ASC, [updatedDateTime] ASC, [score] DESC) 
INCLUDE ([playerName])

分页排名:

对于 24 小时最高分,我想您会想要一个用户在过去 24 小时内所有游戏中的所有最高分。为此,您将使用[gameid] 查询[playername], [updateddatetime]

对于排名 25-50 之间的玩家,我假设您谈论的是单个游戏,并且排名很长,您可以翻页。然后查询将基于[gameid], [score] 和一点[updateddatetime] 的关系。

单用户排名,可能对于每个游戏来说,都比较困难。您需要查询所有游戏的排行榜,以获得玩家在其中的排名,然后过滤玩家。你需要[gameid], [score], [updateddatetime],然后按玩家过滤。

结束这一切,我建议您保留非聚集索引并将主键更改为:

PRIMARY KEY CLUSTERED ([gameId] ASC, [score] DESC, [updatedDateTime] ASC)

对于 24 小时最高分,我认为这可能会有所帮助:

CREATE NONCLUSTERED INDEX [player_Idx] 
ON score ([playerName] ASC) 
INCLUDE ([gameId], [score])

dense_rank 查询排序是因为它选择了[gameId], [updatedDateTime], [score]。请参阅我对上述非聚集索引的评论。

我也会三思而后行,将[updatedDateTime] 包含在您的查询中,然后再包含在您的索引中。也许有时两个玩家获得相同的排名,为什么不呢? [updatedDateTime] 会让你的指数显着膨胀。

您也可以考虑使用[gameid] 对表进行分区。

【讨论】:

    【解决方案4】:

    有点偏题:

    问问自己,排行榜中的分数实际上需要多准确和最新?

    作为一名球员,我不在乎我是世界第 142134 号还是第 142133 号。我确实在乎我是否超过了朋友的确切分数(但我只需要将我的分数与其他几个分数进行比较) 我想知道我的新高分让我从 142000 左右到 90000 左右。(耶!)

    因此,如果您想要真正快速的排行榜,您实际上并不需要所有数据都是最新的。您可以每天或每小时计算排行榜的静态排序副本,并在显示玩家 X 的分数时,显示它在静态副本中的排名。

    与朋友比较时,最后一分钟的更新确实很重要,但您只处理几百个分数,因此您可以在最新的排行榜中查看他们的实际分数。

    哦,我当然关心前 10 名。仅根据他们得分如此之高这一事实将他们视为我的“朋友”,并显示这些值是最新的。

    【讨论】:

      【解决方案5】:

      您的聚集索引是复合索引,因此这意味着该顺序由多个列定义。您请求ORDER BY Score,它是聚集索引中的第二列。因此,索引中的条目不一定按Score 的顺序排列,例如条目

      1, 2, some date
      2, 1, some other date
      

      如果您只选择Score,则顺序为

      2
      1
      

      需要排序。

      【讨论】:

        【解决方案6】:

        我不会将“score”列放入聚集索引中,因为它可能会一直在变化......并且作为聚集索引一部分的列的更新会很昂贵

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 2022-01-21
          • 1970-01-01
          • 2023-03-30
          • 2013-04-12
          • 1970-01-01
          • 2019-07-26
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多