понедельник, октября 06, 2014

Безопасное выполнение пакетных(bulk) операций в Redis с помощью Lua скриптов

Если бы было одно золотое правило при работе с Redis на бою, это должно было быть
Никогда не используйте KEYS
Команда KEYS блокирует event-loop Redis сервера, пока команда не будет выполнена. То есть пока сервер сканирует всё пространство ключей, он не будет обрабатывать команды и подключения от новых клиентов.

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

250Mb не так много. Правда в нашем случае это порядка миллиона ключей в хранилище к концу дня. И явно имело смысл убрать лишнее, учитывая, что наш нормальный суточный "расход" был в районе 30-50Mb.

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

Очевидно, мы предпочли второй вариант.

Зная, что мы не должны использовать KEYS (я же говорил, что вы никогда не должны использовать KEYS?), моя первая попытка использовать команду SCAN, чтобы получить список ключей и выставить им TTL. Я начал со скрипта из этого топика на StackOverflow и немного изменил его, потому что результат бы не таким, как я ожидал.

Мой скрипт:
#!/bin/bash

if [ $# -ne 3 ]
then
  echo "Expire keys from Redis matching a pattern using SCAN & EXPIRE"
  echo "Usage: $0 <host> <port> <pattern>"
  exit 1
fi

cursor=-1
keys=""

while [ $cursor -ne 0 ]; do
  if [ $cursor -eq -1 ]
  then
    cursor=0
  fi

  reply=$(redis-cli -h $1 -p $2 SCAN $cursor MATCH $3)
  cursor=$(expr "$reply" : '\([0-9]*[0-9 ]\)')

  keys=$(echo $reply | awk '{for (i=2; i<NF; i++) print $i}')
  [ -z "$keys" ] && continue

  for key in $keys; do
    redis-cli -h $1 -p $2 EXPIRE $key 60
  done
done
SCAN возвращает курсор и список ключей. А может и не вернуться ключей вообще. Нужно вытащить ключи и курсор (строки 19-22) и для каждого ключа выполнить expire (строки 25-27).
Обрабатываем первый набор ключей и возвращаемся к началу цикла. Снова вызываем команду SCAN, на этот раз с помощью курсора, который был возвращен в предыдущий раз. Таким образом, Redis знает, где мы были и на чём закончили.

Redis возвращает курсор 0(ноль) , если она мы прошлись по всем ключам. И когда это произойдет мы выйдем из цикла.

Это немного медленно ...

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

Redis поддерживаем Lua скрипты. Мы не использовали Lua раньше, но его синтаксис выглядит достаточно простым.

Чтобы вызвать скрипт вы просто передаёте его как аргумент в команду EVAL вместе с количеством ключей, самими ключами и любыми другими аргументами. Простой пример (из документации):
> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second
1) "key1"
2) "key2"
3) "first"
4) "second"
И если в myscript.lua лежит
return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}
Его можно вызвать вот так
redis-cli EVAL "$(cat ./myscript.lua)" 2 key1 key2 first second
(кавычки вокруг cat необходимы)

Так что я переписал Bash скрипт из моей первой попытки на вызов Lua скрипта:
#!/bin/bash

if [ $# -ne 3 ]
then
  echo "Expire keys from Redis matching a pattern using SCAN & EXPIRE"
  echo "Usage: $0 <host> <port> <pattern>"
  exit 1
fi

cursor=-1
keys=""

while [[ $cursor -ne 0 ]]; do
  if [[ $cursor -eq -1 ]]
  then
    cursor=0
  fi

  reply=$(redis-cli -h $1 -p $2 SCAN $cursor MATCH $3 COUNT 100)
  cursor=$(expr "$reply" : '\([0-9]*[0-9 ]\)')
  echo "Cursor: $cursor"

  keys=$(echo $reply | awk '{for (i=2; i<NF; i++) print $i}')
  [ -z "$keys" ] && continue

  keya=( $keys )
  count=$(echo ${#keya[@]})
  redis-cli -h $1 -p $2 EVAL "$(cat expire.lua)" $count $keys
done
(ссылка на github)

Нам потребуется немного дополнительной логики, т.к. мы должны знать количество ключей, которое мы планируем передать в команду EVAL. И SCAN не возвращает постоянное количество ключей. Т.е. нужно преобразовать ключи в массив, и подсчитать количество элементов. Я также выставил параметр COUNT в команде SCAN, чтобы увеличить количество ключей, которое нам должны вернуть за раз. По умолчанию значение COUNT равно 10 и это не имеет значения, когда вы вызываете Redis-CLI для каждого полученного ключа. Когда вы собираетесь вызывать EVAL на каждый SCAN, увеличение этого значения в 10 раз означает, что вы сократите количество вызовов в цикле так же в 10 раз.

Lua скрипт

Скрипт пробегает по всем переданным ключам и если TTL не -1 то выполняет для такого ключа EXPIRE. Можно просто выполнить EXPIRE для всех ключей, если вам не нужно заботиться о верном значении TTL.

Переданные ключи доступны в переменной KEYS, а аргументы в ARGS. В нашем случае нам ARGS не нужны и мы просто пробегаем по всем KEYS:
local modified={};

for i,k in ipairs(KEYS) do
    local ttl=redis.call('ttl', k);
    if ttl == -1 then
        redis.call('EXPIRE', k, 60)
        modified[#modified + 1] = k;
    end
end

return modified;
(ссылка на github)
Вызываем bash скрипт (убедитесь что lua скрипт в той же директории):
bash ./expire-lua.sh 127.0.0.1 6379 'flashMap_*'
Где flashMap_* - префикс по которому мы ищем ключи.

С помощью этого простого Lua скрипта, который работает на блоках ключей из SCAN, мы значительно сократили количество вызовов в Redis и смогли очистить пространство ключей гораздо быстрее, чем раньше (в данном случае порядка 3500 ключей в секунду). А на практике - вместо 3-х часов потребовалось меньше минуты.

Можно изменить "размер блока" (количество ключей обрабатываемых с каждым EVAL) изменив значение аргумента COUNT команды SCAN. Например выставить его в 500:
reply=$(redis-cli -h $1 -p $2 SCAN $cursor MATCH $3 COUNT 500)
Правда не факт, что первые несколько вызовов вернут вам столько данных, сколько вы ждёте. Я заметил, что со значением 100 redis требуется всего пара итераций, чтобы начать возвращать блоки большого размера.

Можно кое что усовершенствовать. Мы могли бы перенести почти всё в Lua скрипт и просто передать размер блока и TTL как параметры. Мне показалось это немного громоздким.

В заключении я хотел бы посоветовать не ставить большие значения для COUNT. Во-первых, вы можете столкнуться с ограничением на количества аргументов, которые можно передать в LUA за раз. Во-вторых, скрипты Lua в Redis - это атомарные операции и работа redis будет блокирована во время их работы. Т.е. стоит использовать только очень быстрые Lua скрипты. В моем случае, размер блока 100 показал отличную производительность c приемлемой  блокировкой.

Оригинал статьи на английском языке http://www.gumtree.com/devteam/2014-08-19-safely-running-bulk-operations-on-redis-with-lua-scripts.html
Отправить комментарий