Создайте приложение с популярными темами, похожее на Twitter, с помощью Redis (на примере PHP)

Redis — это популярная база данных «ключ-значение» в памяти с поддержкой различных типов данных и структур, в основном используемая для кэширования краткосрочных данных и выполнения операций в реальном времени в масштабе.

Если вам интересен пример PHP, перейдите к концу этой статьи (якорь).

Предпосылки

  • redis — вам нужно установить redis на свой компьютер.

Сортированные наборы

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

top-dinosaurs
└── Brachiosaurus
└── Velociraptor
└── T-Rex
└── Canada_Goose

Теперь давайте добавим оценку каждому члену нашего набора top-dinosaurs, например, оценку жестокости:

top-dinosaurs
└── Brachiosaurus 1
└── Velociraptor 3
└── T-Rex 5
└── Canada_Goose 10

Давайте загрузим приведенный выше пример в хранилище Redis. В своем любимом терминале запустите redis-cli, чтобы подключиться к уже работающему серверу Redis, и следуйте инструкциям:

# we'll add each member with its score to our set
127.0.0.1:6379> zadd top-dinosaurs 1 Brachiosaurus
(integer) 1
127.0.0.1:6379> zadd top-dinosaurs 3 Velociraptor
(integer) 1
127.0.0.1:6379> zadd top-dinosaurs 5 T-Rex
(integer) 1
127.0.0.1:6379> zadd top-dinosaurs 10 Canada_Goose
(integer) 1
# verify that we have all our set members present
127.0.0.1:6379> zrange top-dinosaurs 0 -1 withscores
1) "Brachiosaurus"
2) "1"
3) "Velociraptor"
4) "3"
5) "T-Rex"
6) "5"
7) "Canada_Goose"
8) "10"

Теперь из приведенного выше примера мы явно указываем оценку для членов, и это фактически создает член, если он не существует, в противном случае перезаписывает его существующую оценку.

Не очень практично для приложения трендов, не так ли?

Нет, мы будем использовать ZINCRBY API для увеличения оценки участника в наборе, что позволит нам повысить его присутствие в наборе:

# increment score
127.0.0.1:6379> zincrby top-dinosaurs 1 Canada_Goose
"11"
# read the set
127.0.0.1:6379> zrange top-dinosaurs 0 -1 withscores
1) "Brachiosaurus"
2) "1"
3) "Velociraptor"
4) "3"
5) "T-Rex"
6) "5"
7) "Canada_Goose"
8) "11" 

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

127.0.0.1:6379> zrevrangebyscore top-dinosaurs +inf -inf withscores
 1) "Canada_Goose"
 2) "11"
 3) "T-Rex"
 4) "5"
 5) "Velociraptor"
 6) "3"
 7) "Test"
 8) "1"
 9) "Brachiosaurus"
10) "1"

zrevrangebyscore принимает следующие параметры:

  • установить имя (top-dinosaurs)
  • максимальный балл (+inf сделает его безграничным)
  • минимальная оценка (-inf работает так же, хотя для нас подойдет и простая 0, поскольку мы ожидаем, что наши оценки будут беззнаковыми и превышают 0)
  • withscores — конечно, мы хотим, чтобы баллы возвращались вместе с членами набора.

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

Использование отсортированных наборов Redis для обнаружения популярного контента

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

# user input
Happy #Caturday! Hope you're all #feline good.
 
# topics extracted, based on hashtags used
Caturday: 1
feline: 1

Как только сообщение будет создано, мы поднимем 2 вышеуказанные темы на 1 балла:

127.0.0.1:6379> zincrby hot-topics 1 Caturday
"1"
127.0.0.1:6379> zincrby hot-topics 1 feline
"1"

И это создает для нас набор: hot-topics . Мы можем продолжать добавлять темы того же набора, чтобы создать полноценный магазин для наших трендовых тем.

Мы качаем это до сих пор.

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

127.0.0.1:6379> ttl hot-topics
(integer) -1

Не беспокойтесь, наборы можно истечь, как и ключи, используя ту же команду EXPIRE:

# this will expire the set in 1 hour
127.0.0.1:6379> expire hot-topics 3600
(integer) 1
# read the TTL (time-to-live) again
127.0.0.1:6379> ttl hot-topics
(integer) 3584

Итак, мы планируем вызвать команду EXPIRE только один раз, иначе мы просто будем продолжать продлевать TTL набора. В вашем приложении просто проверьте, равен ли TTL -1 , только тогда вы сможете сделать вызов expire:

if redis.ttl(key) == -1: redis.expire(key, 3600)

Повышение эффективности

Как мы видели до сих пор, мы создаем только один набор и назначаем ему определенный TTL (час в нашем примере). Это будет означать, что мы будем продолжать добавлять в набор до тех пор, пока часы TTL не сбрасываются и набор не удаляется. На данный момент у нас осталось 0 популярных тем.

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

Решение, которое, кажется, работает лучше всего, — это объединение наборов.

Мы будем следовать тому же подходу, что и выше, однако создадим определенное количество наборов вместо одного. Таким образом, временное окно в 1 час приведет к тому, что подход будет выполняться каждую минуту (что позволит нам удерживать не более 60 подходов), или подход каждые 2 минуты (максимум 30 подходов), или каждые 5 минут и т. д.

Вот пример, который объясняет вышеприведенные сценарии — имеет смысл использовать текущую метку времени для суффикса наших наборов:

# this will create 60 sets every hour
hot-topics-{hour}:{minute}
# this will create 13 sets every hour
hot-topics-{hour}-{round(minute/5)}

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

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

Объединение наборов

Объединить наши последние наборы — простая задача:

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

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

# don't do this
127.0.0.1:6379> keys *
1) "hot-topics"
2) "top-dinosaurs"
3) "hot-topics-22:8"
# do this, being more precise
127.0.0.1:6379> keys hot-topics-*
1) "hot-topics-22:8"

Затем список возвращаемых наборов должен быть повторен, при этом каждый элемент набора читается с использованием zrevrangebyscore, как мы видели ранее.

В идеале используйте MULTI и EXEC, если вы храните данные на одном сервере, чтобы отправлять все свои команды в одной транзакции вместо N=keys.length:

127.0.0.1:6379> keys hot-topics-*
1) "hot-topics-22:8"
2) "hot-topics-22:9"
127.0.0.1:6379> multi
OK
127.0.0.1:6379> zrevrangebyscore hot-topics-22:8 +inf -inf withscores
QUEUED
127.0.0.1:6379> zrevrangebyscore hot-topics-22:9 +inf -inf withscores
QUEUED
127.0.0.1:6379> exec
1)  1) "Canada_Goose"
    2) "11"
    3) "T-Rex"
    4) "5"
    5) "Velociraptor"
    6) "3"
    7) "Test"
    8) "1"
    9) "Brachiosaurus"
   10) "1"
2) 1) "feline"
   2) "1"
   3) "Caturday"
   4) "1"

Еще одно улучшение: если вы знаете максимальное количество тем, которые вас интересуют, вы можете добавить смещение и ограничение до zrevrangebyscore, чтобы избежать включения каких-либо избыточных ключей, только первых N ключей, например:

# get top 3 members
127.0.0.1:6379> zrevrangebyscore hot-topics-22:8 +inf -inf withscores limit 0 3
 1) "Canada_Goose"
 2) "11"
 3) "T-Rex"
 4) "5"
 5) "Velociraptor"
 6) "3"

Создание приложения «Тенденции» в стиле Twitter — пример PHP

В этом примере используется класс Redis, предоставляемый расширением php-redis, вы можете установить его или немного изменить код, чтобы использовать другой клиент, такой как predis/predis .

<?php

function get_redis() : \Redis {
    static $redis;

    if ( null === $redis ) {
        $redis = new \Redis();
        $redis->connect('0.0.0.0', 6379);
        register_shutdown_function([$redis, 'close']);
    }

    return $redis;
}

function boost_topic(string $setId, string $member, int $increment_by=1) : void
{
    // create a set for 5 minutes, with a TTL of 1 hour
    // reason: cannot expire set members without expiring the whole set
    $setId .= ':' . date('H.') . round(date('i')/5);

    // increment set member score
    get_redis()->zincrby($setId, $increment_by, $member);

    if ( get_redis()->ttl($setId) == -1 ) // is this a new set? if so, set TTL to 60min to we don't keep stale data
        get_redis()->expire($setId, 3600);
}

function get_top_topics(string $setId, int $limit=10) : array
{
    $items = [];

    // query all sets created in the past 30min
    if ( $sets = get_redis()->keys("{$setId}:*") ) {
        $meta = [ 'withscores' => true, 'limit' => [0, $limit] ];

        // begin transaction
        get_redis()->multi();

        foreach ( $sets as $set ) {
            // get top N (=$limit) winners of each set and merge them together
            get_redis()->zrevrangebyscore($set, '+inf', '-inf', $meta);
        }

        // commit transaction
        $result = get_redis()->exec();

        foreach ( $sets as $i => $set ) {
            if ( $list = array_map('intval', $result[$i] ?? []) ) {
                foreach ( $list as $k=>$v ) {
                    $items[$k] = ($items[$k] ?? 0) + $v;
                }
            }
        }

        arsort($items);

        // return top winners
        $items = array_slice($items, 0, $limit);
    }

    return $items;
}

// boost a topic's score
boost_topic('hot-topics', 'Caturday');
boost_topic('hot-topics', 'Feline');
boost_topic('hot-topics', 'Thursday');

// boost a topic's score by 2 points
boost_topic('hot-topics', 'Feline', 2);


// get a list of top 2 trends
get_top_topics('hot-topics', 2); // [ 'Feline' => 3, 'Caturday' => 1 ]