Рейтинг с миллионами записей

Я работаю над сервером для онлайн-игры, которая должна поддерживать миллионы игроков. Теперь игре нужны таблицы лидеров, и она хочет иметь возможность показывать текущую позицию игроков и, возможно, других игроков рядом с текущей позицией игроков, а также позиции друзей игроков.

Теперь я делал это раньше в MySQL, и я знаю, как это технически возможно, однако я решил, что, поскольку это обычная практика для многих онлайн-игр, для этой цели должны быть существующие библиотеки или базы данных?

Может ли кто-нибудь посоветовать мне, какая база данных лучше всего подходит для этих типов запросов и, возможно, для любых уже существующих библиотек, которые уже выполняют большую часть этой работы? Сторонний сервис с доступом к API тоже подойдет.

Надеюсь на дельный совет, спасибо!

Редактировать:

Чтобы уточнить, мне нужна база данных, которая может содержать миллионы записей (пока MySQL подходит для этого), с помощью которой я могу легко получить ранжированные результаты. Например, если я получаю определенную строку из таблицы «таблицы лидеров», мне нужно знать, какой ранг имеет эта строка. Этот запрос должен быть менее 500 мс независимо от размера БД.

В качестве альтернативы способ обновления таблицы с текущей информацией о ранжировании был бы уместным, если этот запрос на обновление не блокирует всю таблицу, а запрос на обновление выполняется менее чем за 30 секунд.

Любые идеи относительно того, какую базу данных/механизм или стороннюю службу использовать, будут высоко оценены!


person Naatan    schedule 25.03.2011    source источник
comment
можете ли вы опубликовать свою схему и как работает ваша текущая система подсчета очков ?? ТИА   -  person Jon Black    schedule 30.03.2011
comment
также можете ли вы сказать мне, сколько одновременных игроков вы ожидаете иметь максимальное использование?   -  person Jon Black    schedule 30.03.2011
comment
Привет, f00, схема не имеет значения и служит конечной цели — получить определенный рейтинг между миллионами записей менее чем за 500 мс. Что касается одновременных пользователей, это будет как бы масштабироваться с количеством записей, 1 миллион записей будет равняться (очень приблизительная оценка) примерно 10 000 одновременных пользователей.   -  person Naatan    schedule 31.03.2011
comment
Так что вы не возражаете, если у меня есть (game_id, round_id, player_id) в качестве составного первичного ключа. Я предполагаю, что у вас может быть более одного раунда в жизненном цикле игры — но, эй, поскольку это не имеет значения (смеется), я думаю, это не имеет значения.   -  person Jon Black    schedule 01.04.2011
comment
привет ф00. Не уверен, что вы имеете в виду под раундом, я думаю, вы говорите о какой-то механике уровней? В любом случае это не имеет значения, важны только 3 поля: id_game, id_user и score.   -  person Naatan    schedule 01.04.2011


Ответы (6)


Поиск одного диска занимает около 15 мс, может быть, немного меньше с дисками серверного уровня. Время отклика менее 500 мс ограничивает вас примерно 30 произвольными обращениями к диску. Это немного.

На моем крошечном ноутбуке у меня есть база данных разработки с

root@localhost [kris]> select @@innodb_buffer_pool_size/1024/1024 as pool_mb;
+--------------+
| pool_mb      |
+--------------+
| 128.00000000 |
+--------------+
1 row in set (0.00 sec)

и медленный диск ноутбука. Я создал таблицу результатов с

root@localhost [kris]> show create table score\G
*************************** 1. row ***************************
       Table: score
Create Table: CREATE TABLE `score` (
  `player_id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `score` int(11) NOT NULL,
  PRIMARY KEY (`player_id`),
  KEY `score` (`score`)
) ENGINE=InnoDB AUTO_INCREMENT=2490316 DEFAULT CHARSET=latin1
1 row in set (0.00 sec)

со случайными целыми числами и последовательными значениями player_id. У нас есть

root@localhost [kris]> select count(*)/1000/1000 as mrows from score\G
*************************** 1. row ***************************
mrows: 2.09715200
1 row in set (0.39 sec)

База данных поддерживает пару (score, player_id) в порядке score в индексе score, поскольку данные в индексе InnoDB хранятся в BTREE, а указатель строки (указатель данных) является значением первичного ключа, так что определение KEY (score) оказывается KEY(score, player_id) внутренне . Мы можем доказать это, взглянув на план запроса для получения оценки:

root@localhost [kris]> explain select * from score where score = 17\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: score
         type: ref
possible_keys: score
          key: score
      key_len: 4
          ref: const
         rows: 29
        Extra: Using index
1 row in set (0.00 sec)

Как видите, key: score используется с Using index, а это означает, что доступ к данным не требуется.

Ранжирующий запрос для заданной константы player_id на моем ноутбуке занимает ровно 500 мс:

root@localhost [kris]>  select p.*, count(*) as rank 
    from score as p join score as s on p.score < s.score 
   where p.player_id = 479269\G
*************************** 1. row ***************************
player_id: 479269
    score: 99901
     rank: 2074
1 row in set (0.50 sec)

С большим объемом памяти и на более быстром компьютере это может быть быстрее, но это все еще сравнительно дорогая операция, потому что план отстой:

root@localhost [kris]> explain select p.*, count(*) as rank from score as p join score as s on p.score < s.score where p.player_id = 479269;
+----+-------------+-------+-------+---------------+---------+---------+-------+---------+--------------------------+
| id | select_type | table | type  | possible_keys | key     | key_len | ref   | rows    | Extra                    |
+----+-------------+-------+-------+---------------+---------+---------+-------+---------+--------------------------+
|  1 | SIMPLE      | p     | const | PRIMARY,score | PRIMARY | 4       | const |       1 |                          |
|  1 | SIMPLE      | s     | index | score         | score   | 4       | NULL  | 2097979 | Using where; Using index |
+----+-------------+-------+-------+---------------+---------+---------+-------+---------+--------------------------+
2 rows in set (0.00 sec)

Как видите, вторая таблица в плане — это сканирование индекса, поэтому запрос замедляется линейно с количеством игроков.

Если вам нужна полная таблица лидеров, вам нужно убрать предложение where, и тогда вы получите два сканирования и квадратичное время выполнения. Так что этот план полностью рушится.

Время перейти к процедуре здесь:

root@localhost [kris]> set @count = 0; 
    select *, @count := @count + 1 as rank from score where score >= 99901 order by score desc ;
...
|   2353218 | 99901 | 2075 |
|   2279992 | 99901 | 2076 |
|   2264334 | 99901 | 2077 |
|   2239927 | 99901 | 2078 |
|   2158161 | 99901 | 2079 |
|   2076159 | 99901 | 2080 |
|   2027538 | 99901 | 2081 |
|   1908971 | 99901 | 2082 |
|   1887127 | 99901 | 2083 |
|   1848119 | 99901 | 2084 |
|   1692727 | 99901 | 2085 |
|   1658223 | 99901 | 2086 |
|   1581427 | 99901 | 2087 |
|   1469315 | 99901 | 2088 |
|   1466122 | 99901 | 2089 |
|   1387171 | 99901 | 2090 |
|   1286378 | 99901 | 2091 |
|    666050 | 99901 | 2092 |
|    633419 | 99901 | 2093 |
|    479269 | 99901 | 2094 |
|    329168 | 99901 | 2095 |
|    299189 | 99901 | 2096 |
|    290436 | 99901 | 2097 |
...

Поскольку это процедурный план, он нестабилен:

  • Вы не можете использовать LIMIT, потому что это приведет к смещению счетчика. Вместо этого вы должны загрузить все эти данные.
  • Вы действительно не можете сортировать. Это предложение ORDER BY работает, потому что оно не сортирует, а использует индекс. Как только вы увидите using filesort, значения счетчика резко снизятся.

Однако это решение ближе всего к тому, что будет делать база данных NoSQL (читай: процедурная) в качестве плана выполнения.

Однако мы можем стабилизировать NoSQL внутри подзапроса, а затем вырезать интересующую нас часть:

root@localhost [kris]> set @count = 0; 
    select * from ( 
        select *, @count := @count + 1 as rank 
          from score 
         where score >= 99901 
      order by score desc 
    ) as t 
    where player_id = 479269;
Query OK, 0 rows affected (0.00 sec)
+-----------+-------+------+
| player_id | score | rank |
+-----------+-------+------+
|    479269 | 99901 | 2094 |
+-----------+-------+------+
1 row in set (0.00 sec)

root@localhost [kris]> set @count = 0; 
    select * from ( 
        select *, @count := @count + 1 as rank 
          from score 
         where score >= 99901 
      order by score desc 
    ) as t 
    where rank between 2090 and 2100;
Query OK, 0 rows affected (0.00 sec)
+-----------+-------+------+
| player_id | score | rank |
+-----------+-------+------+
|   1387171 | 99901 | 2090 |
|   1286378 | 99901 | 2091 |
|    666050 | 99901 | 2092 |
|    633419 | 99901 | 2093 |
|    479269 | 99901 | 2094 |
|    329168 | 99901 | 2095 |
|    299189 | 99901 | 2096 |
|    290436 | 99901 | 2097 |
+-----------+-------+------+
8 rows in set (0.01 sec)

Подзапрос материализует прежний результирующий набор в виде специальной таблицы с именем t, к которой мы затем сможем получить доступ во внешнем запросе. Поскольку это специальная таблица, в MySQL у нее не будет индекса. Это ограничивает то, что возможно эффективно во внешнем запросе.

Однако обратите внимание, как оба запроса удовлетворяют вашему временному ограничению. Вот план:

root@localhost [kris]> set @count = 0; explain select * from ( select *, @count := @count + 1 as rank from score where score >= 99901 order by score desc ) as t where rank between 2090 and 2100\G
Query OK, 0 rows affected (0.00 sec)

*************************** 1. row ***************************
           id: 1
  select_type: PRIMARY
        table: <derived2>
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 2097
        Extra: Using where
*************************** 2. row ***************************
           id: 2
  select_type: DERIVED
        table: score
         type: range
possible_keys: score
          key: score
      key_len: 4
          ref: NULL
         rows: 3750
        Extra: Using where; Using index
2 rows in set (0.00 sec)

Однако оба компонента запроса (внутренний запрос DERIVED и внешнее ограничение BETWEEN) будут работать медленнее для игроков с плохим рейтингом, а затем грубо нарушат ваши временные ограничения.

root@localhost [kris]> set @count = 0; select * from ( select *, @count := @count + 1 as rank from score where score >= 0 order by score desc ) as t;
...
2097152 rows in set (3.56 sec)

Время выполнения описательного подхода стабильно (зависит только от размера таблицы):

root@localhost [kris]> select p.*, count(*) as rank 
   from score as p join score as s on p.score < s.score 
   where p.player_id = 1134026;
+-----------+-------+---------+
| player_id | score | rank    |
+-----------+-------+---------+
|   1134026 |     0 | 2097135 |
+-----------+-------+---------+
1 row in set (0.53 sec)

Ваш звонок.

person Isotopp    schedule 30.03.2011
comment
Спасибо за отличный ответ lsotopp! Ваши цифры, похоже, очень хорошо совпадают с моими. Я проходил через это много раз и искал способы оптимизации, но, похоже, это просто не обойти. Что я собираюсь сделать, так это в основном использовать предложенный вами метод, но отступить от оценки ранга, если оценка ниже определенного числа, что, очевидно, не идеально, но если ваш ранг # 5000, не имеет большого значения, если он немного отклоняется. В любом случае, большое спасибо за развернутый ответ! Невероятно полезно! - person Naatan; 31.03.2011
comment
Пожалуйста. Статья представляет собой вариант темы, которую я впервые затронул с помощью двух коллег по MySQL AB, Кая Фойгта (теперь Cloudera) и Яна Кнешке (теперь Oracle). См. mysqldump .azundris.com/archives/ для получения исходной статьи. - person Isotopp; 31.03.2011
comment
Это немного сбивает с толку: вы использовали счет для имени таблицы и имени столбца, поэтому их трудно различить в (относительно сложных) запросах. - person aberaud; 31.10.2012
comment
Когда у вас есть 50 миллионов строк и вы запрашиваете последнего игрока, чей счет равен нулю, будет ли подзапрос таким же быстрым, как запрос 1000 лучших игроков? - person Daniel; 24.12.2014
comment
вместо того, чтобы задавать диапазон вручную, можно ли указать диапазон, используя текущий ранг игрока, например player_rank -2 и player_rank + 2 - person Kumar KS; 05.12.2017

Я знаю, что это старый вопрос, но мне нравится смотреть на такие проблемы. Учитывая соотношение данных и требуемой скорости запроса, можно использовать некоторые нетрадиционные приемы, которые требуют больше работы по кодированию, но могут реально повысить производительность запросов.

Подсчет очков

Для начала мы должны отслеживать оценки с помощью ведер. Мы хотим, чтобы список корзин (какое прекрасное имя!) был достаточно мал, чтобы его можно было легко хранить в памяти, и достаточно велик, чтобы корзины не часто (относительно говоря) подвергались влиянию. Это дает нам больший параллелизм, чтобы избежать проблем с блокировкой.

Вам придется решить, как разделить эти сегменты в зависимости от вашей нагрузки, но я думаю, что вы хотите сосредоточиться на том, чтобы иметь как можно больше сегментов, которые легко помещаются в память и быстро добавляются.

Чтобы учесть это, моя таблица score_buckets будет иметь следующую структуру:

minscore, maxscore, usercount; PK(minscore, maxscore)

Пользовательская таблица

Мы должны отслеживать наших пользователей, и, вероятно, это будет сделано с помощью:

userid, score, timestamp
#(etc., etc. that we don't care about for this part of the problem)

Чтобы эффективно перебрать это, чтобы получить счет по счету, нам нужен индекс для счета. Временная метка — это просто то, что я добавил в своем примере для разрешения конфликтов, чтобы у меня был окончательный порядок. Если он вам не нужен, выбросьте его — он использует пространство, и это повлияет на время запроса. На данный момент: index(счет, временная метка).

Добавление/обновление/удаление пользователей и их оценок

Добавьте триггеры в пользовательскую таблицу. При вставке:

update score_buckets sb
    set sb.usercount = sb.usercount + 1
    where sb.minscore <= NEW.score
    and sb.maxscore >= NEW.score

При обновлении

update score_buckets sb
    set sb.usercount = sb.usercount - 1
    where sb.minscore <= OLD.score
    and sb.maxscore >= OLD.score
update score_buckets sb
    set sb.usercount = sb.usercount + 1
    where sb.minscore <= NEW.score
    and sb.maxscore >= NEW.score

При удалении

update score_buckets sb
    set sb.usercount = sb.usercount - 1
    where sb.minscore <= OLD.score
    and sb.maxscore >= OLD.score

Определение ранга

$usersBefore = select sum(usercount)
    from score_buckets
    where maxscore < $userscore;
$countFrom = select max(maxscore)
    from score_buckets
    where maxscore < $userscore;
$rank = select count(*) from user
    where score > $countFrom
    and score <= $userscore
    and timestamp <= $userTimestamp

Заключительные примечания

Сравните с различным количеством сегментов, каждый раз удваивая или уменьшая их вдвое. Вы можете быстро написать сценарий удвоения / деления пополам ведра, чтобы вы могли провести нагрузочное тестирование. Чем больше сегментов, тем меньше сканирование индекса оценок пользователей и меньше конфликтов между блокировками и транзакциями при обновлении оценок. Чем больше сегментов, тем больше памяти. Чтобы выбрать число для начала, используйте 10 000 ведер. В идеале ваши сегменты будут охватывать весь диапазон оценок, и в каждом сегменте будет примерно одинаковое количество пользователей. Если ваш график распределения очков следует какой-то кривой, сделайте так, чтобы распределение корзины соответствовало этой кривой.

Теория этого рода связана с двухуровневым списком пропуска.

person Jeff Ferland    schedule 20.10.2011
comment
Мне нравится этот подход. - person Sorter; 10.04.2014
comment
Мои два цента, если слишком много пользователей имеют одинаковую оценку, даже если они превышают порог, но вы все равно не можете разделить корзину. Это происходит, когда игра не очень популярна, и многие игроки уходят в течение 10 минут, но оставляют начальный счет, например, 100. Таким образом, лучше строить корзины со счетом и ts. score_buckets будет иметь три столбца: score_ts_from, score_ts_to и count. - person Daniel; 24.12.2014

Недавно я прочитал статью о решении такой проблемы с помощью Redis. Вы по-прежнему можете использовать MySQL в качестве основного хранилища, но вы будете кэшировать несортированные результаты в Redis и обновлять рейтинг в режиме реального времени. Ссылку можно найти здесь. Последняя треть статьи посвящена сортировке по ключу, как в случае со списком ранжирования.

person David Richards    schedule 25.03.2011
comment
Спасибо, Дэвид, к сожалению, на самом деле это не работает, так как мои таблицы лидеров могут содержать миллионы записей. Немного много, чтобы кэшировать это. Я могу получить рейтинг с помощью запросов sql, но эти запросы будут выполняться дольше, чем больше становится таблица. Мне нужно что-то, что будет масштабироваться с большими БД без слишком большого увеличения времени выполнения запроса. - person Naatan; 28.03.2011
comment
@Naatan: Я поддерживаю ответ Дэвида, NoSQL - хороший способ справиться с этим без проблем. Это то, что будет масштабироваться с большими БД без слишком большого увеличения времени выполнения запроса. Если у вас есть база данных, которая масштабируется для миллионов пользователей, наличие параллельного сервера Redis для некоторых пользовательских параметров не должно быть проблемой. - person w.k; 31.03.2011
comment
@Naatan: вам, вероятно, следует прочитать эту статью. Обсуждение касается использования Redis для набора проблем из миллионов, которые вы описываете. Возможно, можно подумать, что MySQL оптимизирован для операций над наборами, а Redis оптимизирован для быстрого кэширования очень больших наборов данных. - person David Richards; 01.04.2011
comment
Спасибо, Дэвид, я попробую, как только у меня будет запущена моя первоначальная пригодная для использования версия, которая должна работать примерно до 5 миллионов записей, которых мы не достигнем в ближайшие несколько месяцев. - person Naatan; 01.04.2011

Сортировка миллионов записей может показаться большой работой, но это явно не так. Сортировка 10 ^ 6 совершенно случайных записей на моем компьютере занимает около 3 секунд (просто старый EeePC с процессором Atom (я думаю, первого поколения), 1,6 ГГц).

И с хорошим алгоритмом сортировки сортировка имеет O (n * log (n)) в худшем случае, поэтому не имеет большого значения, если у вас есть 10 ^ 9 или более записей. И большую часть времени ранговый список будет уже почти отсортирован (из предыдущего ранжирования), что приведет к времени выполнения, которое, скорее всего, будет O (n).

Итак, перестаньте беспокоиться об этом! Единственная реальная проблема заключается в том, что большинство СУБД не могут напрямую обращаться к 1000-й записи. Таким образом, такой запрос, как SELECT ... LIMIT 1000, 5, должен будет запросить не менее 1005 записей и пропустить первые 1000. Но решение здесь тоже простое. Просто сохраните rank как избыточный столбец каждой строки, добавьте к нему индекс и вычисляйте его каждые 15 минут (или каждые 5 минут, 30 минут, 1 час или что-то еще, что имеет смысл для вашего приложения). При этом все запросы по рангу представляют собой просто поиск по вторичному индексу (около O(log(N))), который чрезвычайно быстр и занимает всего несколько миллисекунд на запрос (узким местом здесь является сеть, а не база данных).

PS: Вы прокомментировали другой ответ, что вы не можете кэшировать отсортированные записи, потому что они слишком велики для вашей памяти. Если предположить, что вы просто кэшируете кортежи (user_id, rank) с двумя 64-битными целыми числами (32-битных тоже будет более чем достаточно!), вам потребуется менее 8 МБ памяти для хранения 10 ^ 6 записей. Вы уверены, что у вас недостаточно оперативной памяти для этого?

Поэтому, пожалуйста, не пытайтесь оптимизировать то, что явно не является узким местом (пока)...

person tux21b    schedule 28.03.2011
comment
Спасибо за ответ tux21b! Очень информативно! К сожалению, если делать то, что вы говорите, и обновлять каждые 15, 30 или 60 минут, запрос все равно будет выполняться около 1 минуты, чтобы обновить все строки с их новыми рангами, которые все равно будут постоянно увеличиваться по мере роста базы данных. Возможно, мне придется прибегнуть к этому, но я бы предпочел не запускать такие тяжелые запросы в задании cron. Однако я посмотрю на кеширование результатов, это может быть хорошим решением. - person Naatan; 29.03.2011
comment
Как насчет хранения рейтингов в их собственной таблице (user_id, rank, index on rank)? Каждые N минут опорожняйте и перезагружайте. - person Philip Kelley; 30.03.2011

Вы можете избыточно хранить ранг каждого игрока в таблице игроков, чтобы вам не приходилось выполнять какие-либо операции соединения. Каждый раз, когда таблицы лидеров пересчитываются, таблицы игроков тоже должны обновляться.

person Alp    schedule 28.03.2011
comment
Я знаю, что могу, но это обновление выполняется вечно с миллионами записей, если вы знаете механизм базы данных/обновления, который значительно сокращает время выполнения? - person Naatan; 28.03.2011
comment
Вы можете попробовать TRIGGER. - person Alp; 28.03.2011
comment
Как триггер может сократить время выполнения запроса, который будет запущен? Также я не могу просто обновить ранг строки, которая была добавлена/обновлена, так как этот ранг сместит все ранги строк, следующих за ним. - person Naatan; 28.03.2011

Я могу придумать два способа решения этой проблемы:

Первый подход: обновление партиями:

  • Сортируйте баллы, получайте рейтинг
  • Разделите игроков по рангу на группы, такие как игрок0-игрок999, игрок1000-игрок1999 и т. д.
  • Для каждого пакета удалите записи в существующей таблице, которые конфликтуют с новыми данными. Это означает удаление существующих записей, принадлежащих игрокам в текущем пакете или тем, кто в настоящее время находится в диапазоне рангов, обновляемых в текущем пакете. Затем вы загружаете данные ранжирования для пакета в базу данных и переходите к следующему пакету, скажем, через 0,1 с.

Второй подход: новая таблица

  • Создайте (или сократите) новую таблицу точно так же, как ваша существующая таблица ранжирования.
  • вычислить новый рейтинг и вставить свои данные
  • Поменяйте столы местами (предпочтительно заблокировав их). Это должно занять меньше секунды.
person Seun Osewa    schedule 30.11.2011