Привет всем, меня зовут Дмитрий Шитиков, я – бэкенд-разработчик в ПСБ.

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

Напомню кратко основные типы данных и то, как их создать и получить

Скрытый текст


1. Строка
127.0.0.1:6379> set k v OK 127.0.0.1:6379> get k "v" 2.

2. Список
127.0.0.1:6379> rpush l a b c (integer) 3 127.0.0.1:6379> lrange l 0 -1

  1. "a"

  2. "b"

  3. "c"

    3. Множество (уникальный список)
    127.0.0.1:6379> sadd s a a b b c c (integer) 3 127.0.0.1:6379> smembers s

  1. "a"

  2. "b"

  3. "c"

    4. Хеш – ассоциативный массив 127.0.0.1:6379> hset h a 3 b 2 c 1 # похоже на структуру {a:3; b:2; c:1} (integer) 3 127.0.0.1:6379> hgetall h

  1. "a"

  2. "3"

  3. "b"

  4. "2"

  5. "c"

  6. "1"

    5. Сортированный набор
    127.0.0.1:6379> zadd z 77 a 55 b 55 c # 'a' с весом 77, 'b' с весом 55 и 'c' с весом 55 (integer) 3 127.0.0.1:6379> zrange z 0 -1

  1. "b" # 'b' первая, т. к. вес ниже, чем у 'a', и лексикографически идет до 'c' (вес совпадает - 55)

  2. "c"

  3. "a"


Теперь об оптимизации.
Первая оптимизация — поиск медленных запросов.

В этом поможет slowlog — лог медленных запросов.
Посмотреть лог:
127.0.0.1:6379> SLOWLOG GET

  1. (integer) 42 # ID записи в slowlog

    1. (integer) 1754771482 # UNIX-время выполнения

    2. (integer) 78545 # Время выполнения (в микросекундах → 78.545 мс)

    3. "lrange" # Команда и её аргументы:

      1. "list"

      2. "0"

      3. "-1"

    4. "127.0.0.1:49578"

    5. ""

Какие запросы попадают в slowlog? Изменить их можно в файле конфигурации /etc/redis/redis.conf.

Посмотреть текущие настройки лога:
`127.0.0.1:6379> CONFIG GET slowlog-*

  1. "slowlog-max-len"

  2. "128" # сохранять 128 записей

  3. "slowlog-log-slower-than"

  4. "10000" # логировать команды медленнее 10 000 микросекунд (10 мс)`

Redis работает в ОДНОМ потоке. Это значит, что пока выполняется долгая команда, все остальные запросы ждут своей очереди.

Отлично, мы нашли команды, которые выполняются подозрительно долго. Что это означает? Всегда ли это плохо?

Не всегда. Долгое выполнение может быть оправдано, если:

  • Команда действительно требует обработки большого объема данных;

  • Операция выполняется редко (например, в фоновых задачах);

  • Альтернативы с лучшей производительностью нет.

Но в большинстве случаев это сигнал к оптимизации.

Вторая оптимизация, к которой стоит обратиться, — это изучение документации.

Redis предоставляет удобную документацию, где для каждой команды указаны:

  • Временная сложность (Big O notation);

  • Рекомендуемые сценарии использования;

  • Особенности работы.

Сложность обозначается через О (читается «О большое» ). Например: O(n), O(log n), O(n²).
Чем меньше значение после O, тем ниже сложность по времени и требуется меньше памяти для выполнения.

К примеру, для команды INCR (увеличить значение на 1) сложность составляет O1 (см. рис. 1), т. е. очень быстро.

Рис. 1. Сложность команды INCR уже указана в документации
Рис. 1. Сложность команды INCR уже указана в документации

А для команды KEYS (найти ключ по паттерну среди всех ключей) уже O(n) (см. рис. 2). Это значит, что надо перебрать все ключи n, чтобы найти нужные. Если у вас хранится 1 млн ключей, то надо сделать перебор по всему 1 млн.

Рис. 2. Сложность команды KEYS уже указана в документации
Рис. 2. Сложность команды KEYS уже указана в документации

Для команды KEYS прямо в документации указано, что лучше использовать вместо нее команду SCAN или множество:

Don't use KEYS in your regular application code. If you're looking for a way to find keys in a subset of your keyspace, consider using SCAN or sets.

Проверим разницу в скорости работы KEYS и SCAN:

1. Добавим 1 млн. ключей

$ for i in {1..1000000}; do    redis-cli SET "key:$i" "value-$i" > /dev/null; done

2. Найдем все ключи через KEYS и зафиксируем скорость выполнения команды:

$ time redis-cli KEYS "*" | wc -l

real 0m0.686s

3. Найдем все ключи через SCAN и зафиксируем скорость выполнения команды:

$ time redis-cli --scan --pattern "*" | wc -l

real 0m3.849s

Результаты показывают... что SCAN выполнялся примерно в 5 раз дольше. А в чем тогда смысл? Зачем его посоветовали вместо KEYS?

Ответ также в документации: SCAN разбивается на много итераций, в каждой из которых обрабатывает по 10 ключей. Между этими итерациями Redis может обрабатывать другие команды.

Это значит, что если вы запустили KEYS, то все ваши подключения к серверу Redis будут ждать, пока KEYS отработает 0.686s. А SCAN хоть и работает 3.849s, но он будет пропускать другие ваши запросы на выполнение.

Проведём ещё эксперимент. Допустим, нам нужно хранить значения, относящиеся к одной сущности, и периодически их удалять.

Попробуем использовать список:

1 Добавим 1 млн. записей в список:

$ for i in {1..1000000}; do echo "RPUSH list $i"; done | redis-cli –pipe

2. Удалим значение 1 000 000. Оно будет последним в списке:

127.0.0.1:6379> time redis-cli LREM list 1 1000000 // 1 – читаем список слева направо

(integer) 1

real 0m0.060s

Попробуем для этой задачи множество:

1. Добавим 1 млн записей в множество:

$ for i in {1..1000000}; do     echo "SADD set $i";   done | redis-cli --pipe;

2. Удалим значение 1 000 000:

127.0.0.1:6379> time redis-cli SREM set 1000000

(integer) 1

real 0m0.022s

Наши результаты: поиск и удаление элемента в set отработали более чем в 2 раза быстрее, чем в list. Разница обусловлена структурой данных:

— Set использует хеш‑таблицу (O(1) для поиска/удаления)

— List требует линейного поиска (O(N) в худшем случае)

В документации уже указано, что List — это связный список, который подходит для:

— Реализации стеков и очередей;

— Организации систем управления очередями для фоновых задач.

А вот про Set явно говорится, что он подходит для учёта уникальных элементов.

Третья оптимизация — использование конвейера.

Допустим, вам надо для прогрева кеша выполнить большое количество одинаковых операций SET. Каждый запрос проходит долгий путь к серверу Redis и обратно, чтобы вернуть ответ.

Пример без конвейера:

$ redis-benchmark -c 1 -n 100000 -q -t SET,GET

SET: 44444.45 requests per second, p50=0.023 msec                   

GET: 39463.30 requests per second, p50=0.023 msec    

Видим, что смогли обработать в среднем 40 тыс. запросов в секунду.

Добавим конвейeр. Будем отправлять не по одной команде в Redis, а целые пачки команд. По 10 за один раз (‑P 10):

$ redis-benchmark -c 1 -n 100000 -q -t SET,GET -P 10

SET: 595238.12 requests per second, p50=0.015 msec              

GET: 363636.34 requests per second, p50=0.023 msec  

Производительность резко выросла в 10 раз!

Можем попробовать передавать за раз больше команд. Возьмем 100 (‑P 100):

redis-benchmark -c 1 -n 100000 -q -t SET,GET -P 100

SET: 1785714.25 requests per second, p50=0.039 msec              

GET: 2500000.00 requests per second, p50=0.031 msec

Рост существенный, но уже не на порядок.

Напишем программу, которая будет добавлять 100 тыс. записей двумя способами:

1. по одной записи — метод setCache

2. через конвейeр — метод setCacheWithPipeline

Скрытый текст

package main

import (

"context"

"fmt"

"math/rand"

"time"

"github.com/redis/go-redis/v9"

)

func main() {

setCache(context.Background())

}

func setCache(ctx context.Context) {

client := redis.NewClient(&redis.Options{

Addr: "127.0.0.1:6379",

})

key := rand.Int()

for i := 0; i < 100_000; i++ {

_, err := client.Set(ctx, fmt.Sprintf("key:%d:%d", key, i), time.Now(), 0).Result()

if err != nil {

panic("Unable to set key: " + err.Error())

}

}

}

func setCacheWithPipeline(ctx context.Context) {

client := redis.NewClient(&redis.Options{

Addr: "127.0.0.1:6379",

})

key := rand.Int()

pipeline := client.Pipeline()

for i := 0; i < 100_000; i++ {

pipeline.Set(ctx, fmt.Sprintf("key:%d:%d", key, i), time.Now(), 0)

}

_, err := pipeline.Exec(ctx)

if err != nil {

panic("Unable to set key: " + err.Error())

}

}

И тесты на эти 2 метода

Скрытый текст

package main

import (

"context"

"testing"

)

func BenchmarkSetCache(b *testing.B) {

for i := 0; i < b.N; i++ {

setCache(context.Background())

}

}

func BenchmarkSetCacheWithPipeline(b *testing.B) {

for i := 0; i < b.N; i++ {

setCacheWithPipeline(context.Background())

}

}

Запустим тесты:

Скрытый текст

$ go test -bench=. -benchmem -benchtime=10s

...

BenchmarkSetCache-22                           4        3056136444 ns/op        32849686 B/op    1199939 allocs/op

BenchmarkSetCacheWithPipeline-22              92         122466863 ns/op        37149145 B/op     799906 allocs/op

С конвейeром получили ускорение в 25 раз.

Четвёртая оптимизация — блокирующие операции.

Рассмотрим пример работы с очередью (в виде списка) в Redis:

— Первый сервис добавляет значения в конец очереди:

127.0.0.1:6379> rpush queue 1 2 3 4 5

(integer) 5

— Второй сервис в цикле обрабатывает значения из начала очереди:

127.0.0.1:6379> lpop queue

"1"

127.0.0.1:6379> lpop queue

"2"

127.0.0.1:6379> lpop queue

"3"

127.0.0.1:6379> lpop queue

"4"

127.0.0.1:6379> lpop queue

"5"

Проблема возникает когда очередь пуста, но сервис продолжает опрашивать её:

127.0.0.1:6379> lpop queue

(nil)

127.0.0.1:6379> lpop queue

(nil)

127.0.0.1:6379> lpop queue

(nil)

Это приводит к бесполезной нагрузке на Redis.

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

Для многих команд блокирующие операции называются аналогично, только в начале добавляется "b".

Пример: Ждем 30 секунд, пока в списке queue не появится значение. Как только значение появится, то операция сразу завершится.

127.0.0.1:6379> blpop queue 30

И главное, блокирующая операция не блокирует весь Redis. Другие клиенты могут свободно выполнять любые команды.

Но есть нюанс. Блокирующая операция держит активное соединение с сервером Redis. Надо мониторить число клиентов и максимальное число клиентов (maxclients):

$ redis-cli INFO clients

# Clients

connected_clients:2

cluster_connections:0

maxclients:10000

client_recent_max_input_buffer:20523

client_recent_max_output_buffer:0

blocked_clients:1

Пятая оптимизация — масштабирование и кластер.

Можем добавить ноды для распределения нагрузки. Это поможет:

1. Операциям чтения (GET, LRANGE и т. д.)

— Добавление реплик (read replicas) позволяет распределить нагрузку чтения.

2. Операциям записи (SET, LPUSH и т. д.)

— Шардирование (разделение данных по узлам) увеличивает общую пропускную способность записи.

Мы можем создавать:

— Мастер‑ноды. В них будем записывать и читать значения.

— Реплики. Копируют значения из мастера.

Чтобы обеспечить минимальную надежность системы потребуется минимум 3 сервера и 6 экземпляров Redis, запущенных на них — то есть по два экземпляра на сервер (рис. 3).

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

Рис. 3. Отказоустойчивый кластер
Рис. 3. Отказоустойчивый кластер

Давайте настроим кластер Redis:

1. У нас будет 6 серверов в одной подсети:

172.28.2.11, 172.28.2.12, 172.28.2.13, 172.28.2.14, 172.28.2.15, 172.28.2.16

Запустим их через docker. Пример docker‑compose.yml

Скрытый текст

version: '3.8'

services:

  redis-node-1:

    image: redis:7.2

    container_name: redis-node-1

    volumes:

      - ./redis.conf:/usr/local/etc/redis/redis.conf

      - redis-data-1:/data

    command: redis-server /usr/local/etc/redis/redis.conf

    ports:

      - "7001:6379"

    networks:

      redis-cluster-net:

        ipv4_address: 172.28.2.11

    healthcheck:

      test: ["CMD", "redis-cli", "ping"]

      interval: 1s

      timeout: 3s

  redis-node-2:

    image: redis:7.2

    container_name: redis-node-2

    volumes:

      - ./redis.conf:/usr/local/etc/redis/redis.conf

      - redis-data-2:/data

    command: redis-server /usr/local/etc/redis/redis.conf

    ports:

      - "7002:6379"

    networks:

      redis-cluster-net:

        ipv4_address: 172.28.2.12

    healthcheck:

      test: ["CMD", "redis-cli", "ping"]

  redis-node-3:

    image: redis:7.2

    container_name: redis-node-3

    volumes:

      - ./redis.conf:/usr/local/etc/redis/redis.conf

      - redis-data-3:/data

    command: redis-server /usr/local/etc/redis/redis.conf

    ports:

      - "7003:6379"

    networks:

      redis-cluster-net:

        ipv4_address: 172.28.2.13

    healthcheck:

      test: ["CMD", "redis-cli", "ping"]

  redis-node-4:

    image: redis:7.2

    container_name: redis-node-4

    volumes:

      - ./redis.conf:/usr/local/etc/redis/redis.conf

      - redis-data-4:/data

    command: redis-server /usr/local/etc/redis/redis.conf

    ports:

      - "7004:6379"

    networks:

      redis-cluster-net:

        ipv4_address: 172.28.2.14

    healthcheck:

      test: ["CMD", "redis-cli", "ping"]

  redis-node-5:

    image: redis:7.2

    container_name: redis-node-5

    volumes:

      - ./redis.conf:/usr/local/etc/redis/redis.conf

      - redis-data-5:/data

    command: redis-server /usr/local/etc/redis/redis.conf

    ports:

      - "7005:6379"

    networks:

      redis-cluster-net:

        ipv4_address: 172.28.2.15

    healthcheck:

      test: ["CMD", "redis-cli", "ping"]

  redis-node-6:

    image: redis:7.2

    container_name: redis-node-6

    volumes:

      - ./redis.conf:/usr/local/etc/redis/redis.conf

      - redis-data-6:/data

    command: redis-server /usr/local/etc/redis/redis.conf

    ports:

      - "7006:6379"

    networks:

      redis-cluster-net:

        ipv4_address: 172.28.2.16

    healthcheck:

      test: ["CMD", "redis-cli", "ping"]

networks:

  redis-cluster-net:

    driver: bridge

    ipam:

      config:

        - subnet: 172.28.2.0/24

volumes:

  redis-data-1:

  redis-data-2:

  redis-data-3:

  redis-data-4:

  redis-data-5:

  redis-data-6:

2. Настройки redis.conf (файл положим рядом с docker‑compose.yml), которые прокидываем на каждый сервер:

port 6379

bind 0.0.0.0

cluster-enabled yes

cluster-config-file /data/nodes.conf

cluster-node-timeout 5000

Пояснение параметров:

port — порт, на котором Redis будет принимать подключения;

bind 0.0.0.0 — разрешили подключаться с любых интерфейсов. Опасная настройка! По‑хорошему надо указать только нужные ip;

cluster‑enabled — режим кластера;

cluster‑config‑file — Redis самостоятельно сохраняет сюда данные о кластере;

cluster‑node‑timeout — время (в миллисекундах), после которого узел считается недоступным.

3. Создаем кластер. Подключаемся к любой ноде и выполняем команду (перечисляем все 6 серверов кластера). Параметр ‑cluster‑replicas 1 создаст 3 мастера и 3 реплики (по 1 на каждый мастер):
# redis-cli --cluster create 172.28.2.11:6379 172.28.2.12:6379 172.28.2.13:6379 172.28.2.14:6379 172.28.2.15:6379 172.28.2.16:6379 --cluster-replicas 1

Redis сам сконфигурирует кластер оптимальным образом и попросит нас согласиться с этим.

Скрытый текст

>>> Performing hash slots allocation on 6 nodes...

Master[0] -> Slots 0 - 5460

Master[1] -> Slots 5461 - 10922

Master[2] -> Slots 10923 - 16383

Adding replica 172.28.2.15:6379 to 172.28.2.11:6379

Adding replica 172.28.2.16:6379 to 172.28.2.12:6379

Adding replica 172.28.2.14:6379 to 172.28.2.13:6379

M: 682a377f37d1fcae4a88187adc885396884639b5 172.28.2.11:6379

   slots:[0-5460] (5461 slots) master

M: c66c78becf2ed82bce0bb31557426138246cec66 172.28.2.12:6379

   slots:[5461-10922] (5462 slots) master

M: b7670e33abd7fa8150d732c6579ebc14d241bf09 172.28.2.13:6379

   slots:[10923-16383] (5461 slots) master

S: 972163dcf808fa97a66303e8659c2ea34883e867 172.28.2.14:6379

   replicates b7670e33abd7fa8150d732c6579ebc14d241bf09

S: 05a6f904987d06a1e6330fcdc9dbea9417806014 172.28.2.15:6379

   replicates 682a377f37d1fcae4a88187adc885396884639b5

S: dd7de192219903c5639cea47043b922e6ebeecff 172.28.2.16:6379

   replicates c66c78becf2ed82bce0bb31557426138246cec66

Can I set the above configuration? (type 'yes' to accept): yes

>>> Nodes configuration updated

>>> Assign a different config epoch to each node

>>> Sending CLUSTER MEET messages to join the cluster

Waiting for the cluster to join

Всё! Кластер поднят! На рис. 4 показана структура кластера.

Рис. 4. Конфигурация кластера на 6 серверах
Рис. 4. Конфигурация кластера на 6 серверах

Теперь попробуем записать значение в ключ 'x' явно в мастер 172.28.2.11. Получим ошибку:

# redis-cli -h 172.28.2.11 set x 5

(error) MOVED 16287 172.28.2.13:6379

Попробуем сделать запись в мастер на 172.28.2.13. Тут всё хорошо:

# redis-cli -h 172.28.2.13 set x 5

OK

Почему так происходит?

Это связано с распределением слотов между мастерами. Вспоминаем конфигурацию от Redis:

Master[0] -> Slots 0 - 5460

Master[1] -> Slots 5461 - 10922

Master[2] -> Slots 10923 - 16383

Всего 16 384 слотов.

Ключ 'x' имеет хеш‑слот 16287. Он относится к Master[2] что в нашей конфигурации соответствует 172.28.2.13.

Ключ ‑c позволяет работать в режиме перенаправлений через любую ноду:

# redis-cli -c -h 172.28.2.11 set x 5

OK

Теперь прочитаем значение 'x' из мастера 172.28.2.13. Все получилось:

root@e25869d27fdd:/data# redis-cli -h 172.28.2.13 get x 

"5"

Его реплика по конфигурации от Redis — 172.28.2.14. Вспоминаем конфигурацию выше:
Adding replica 172.28.2.14:6379 to 172.28.2.13:6379

Все что пишем в мастер, попадает в реплику. Попробуем прочитать из реплики и получим ошибку:

root@e25869d27fdd:/data# redis-cli -h 172.28.2.14 get x 

(error) MOVED 16287 172.28.2.13:6379

Не смогли прочитать, так как в реплику происходит только копирование из мастера. И по умолчанию чтение отключено. Можем включить чтение и читать из реплики. НО! Вы должны учитывать, что значения попадают в реплику не моментально. т. е. они могут быть в мастере, но ещё не быть скопированы в реплику.

Включим чтение в реплике и прочитаем значение по ключу:

root@e25869d27fdd:/data# redis-cli -h 172.28.2.14

172.28.2.14:6379> READONLY

OK

172.28.2.14:6379> GET x

"5"

Вуаля! Вот такими несложными манипуляциями вы сможете оптимизировать и ускорить свой Redis. Рассказывайте в комментариях о своём опыте и делитесь своими советами, задавайте вопросы.

Спасибо всем дочитавшим, удачи в работе!


Комментарии (0)