Рейтинг с миллионами записей - PullRequest
26 голосов
/ 25 марта 2011

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

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

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

Надеюсь получить хороший совет, спасибо!

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

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

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

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

Ответы [ 6 ]

31 голосов
/ 30 марта 2011

Время поиска одного диска составляет около 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)

Ваш звонок.

16 голосов
/ 20 октября 2011

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

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

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

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

Для этого моя таблица 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)

Для того, чтобы эффективно выполнить итерацию, чтобы получить счет за счет, нам нужен индекс на счет. Отметка времени - это просто то, что я добавила для разрыва связи в моем примере, чтобы у меня был определенный порядок. Если вам это не нужно, откажитесь от этого - он использует пробел, и это повлияет на время запроса. На данный момент: индекс (оценка, отметка времени).

Вставка / Обновление / Удаление пользователей и их оценок

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

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 ведер. В идеале ваши сегменты будут охватывать весь диапазон баллов, и в каждом блоке будет примерно одинаковое количество пользователей. Если ваш график распределения баллов соответствует некоторой кривой, сделайте так, чтобы распределение ваших сегментов соответствовало этой кривой.

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

3 голосов
/ 25 марта 2011

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

2 голосов
/ 29 марта 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 записей. Вы уверены, что вам не хватает оперативной памяти для этого?

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

1 голос
/ 28 марта 2011

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

0 голосов
/ 30 ноября 2011

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

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

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

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

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