Здравствуйте! Меня зовут Максим Матюхин, я PHP-программист Badoo. В своей работе мы активно используем MySQL. Но иногда нам не хватает её производительности, поэтому мы постоянно ищем возможности ускорить её работу.
В 2010 году Yoshinori Matsunobu представил NoSQL-плагин для MySQL под названием HandlerSocket. Заявлялось, что этот плагин позволяет выполнять более 750 000 запросов в секунду. Нам стало любопытно, и мы почти сразу же стали использовать это решение. Результат нам настолько понравился, что мы начали выступать с докладами и писать статьи, рекламируя HandlerSocket.
По-видимому, мы были одними из немногих пользователей этого плагина — начиная с версии MySQL 5.7 он перестал работать. Зато в этой версии появился другой плагин от Oracle — InnoDB memcached plugin, который обещал похожий функционал.
Несмотря на то, что memcached-плагин появился ещё в MySQL 5.6 в 2013 году, статей о нём не так много и в большинстве своём они повторяют документацию: создаётся простая табличка, и к ней делаются запросы через memcached-клиент.
Мы имеем большой опыт работы с Memcached и привыкли к простоте взаимодействия с ним. От InnoDB memcached plugin мы ожидали такой же простоты. Но на деле оказалось, что если паттерны использования плагина хотя бы немного отличаются от описанных в документации и статьях, то всплывает масса нюансов и ограничений, которые определённо стоит учитывать, если вы собираетесь пользоваться плагином.
MySQL HandlerSocket
В этой статье мы так или иначе будем сравнивать новый memcached-плагин со старым HandlerSocket. Поэтому напомню, что представлял собой последний.
После установки плагина HandlerSocket, MySQL начинала слушать два дополнительных порта:
- Первый порт принимал клиентские запросы для чтения данных.
- Второй порт принимал клиентские запросы для записи данных.
Клиент должен был установить обычное TCP-соединение на один из этих портов (никакой аутентификации не поддерживалось), и после этого нужно было отправить команду «open index» (специальная команда, с помощью которой клиент сообщал, из какой таблицы какого индекса какие поля мы собираемся читать (или писать)).
Если команда «open index» срабатывала успешно, то потом можно было отправлять GET-ы или INSERT/UPDATE/DELETE-команды в зависимости от порта, на который было установлено соединение.
HandlerSocket позволял выполнять не только GET-ы по первичному ключу, но и простые выборки из неуникального индекса, выборки по диапазону, поддерживал multiget-ы и LIMIT. При этом с таблицей можно было работать как из обычного SQL, так и через плагин. Это, например, позволяло делать какие-то изменения в транзакциях через SQL, а потом читать эти данные через HandlerSocket.
Важно, что HandlerSocket обрабатывал все коннекты ограниченным пулом потоков через epoll, поэтому легко можно было поддерживать десятки тысяч соединений, в то время как в самой MySQL на каждое соединение создаётся поток (thread) и их количество сильно ограничено.
В то же время это всё ещё обычный MySQL сервер — знакомая нам технология. Мы знаем, как его реплицировать и мониторить. Мониторить HandlerSocket сложно, так как он не предоставляет каких-либо специфических метрик; тем не менее некоторые стандартные метрики MySQL и InnoDB оказываются полезными.
Были, конечно, и неудобства, в частности данный плагин не поддерживал работу с типом timestamp. Ну и HandlerSocket протокол сложнее читать и потому сложнее отлаживать.
Подробнее о HandlerSocket можно почитать здесь. Также вы можете посмотреть одну из наших презентаций.
InnoDB memcached plugin
Что же нам предлагает новый memcached plugin?
Как следует из названия, его идея в том, чтобы использовать memcached-клиент для работы с MySQL и через memcached-команды получать и сохранять данные.
Об основных преимуществах плагина можно прочитать здесь.
Нас больше всего заинтересовали следующие:
- Низкое потребление CPU.
- Данные хранятся в InnoDB, что даёт определённые гарантии.
- С данными можно работать как через Memcached, так и через SQL; их можно реплицировать встроенными в MySQL средствами.
К этому списку можно добавить ещё такие плюсы, как:
- Быстрый и дешёвый коннект. Обычное MySQL-соединение обрабатывается одним thread-ом, и количество thread-ов ограничено, а в memcached-плагине один thread обрабатывает все соединения в event loop-е.
- Возможность одним GET-запросом запросить сразу несколько ключей.
- Если сравнивать с MySQL HandlerSocket, то в memcached-плагине не надо использовать команду «Open Table» и все операции чтения и записи происходят на одном порте.
Больше деталей о работе плагина можно найти в официальной документации. Для нас самыми полезными показались страницы:
После установки плагина MySQL начинает принимать соединения на порте 11211 (стандартный memcached-порт). Также появляется специальная база данных (схема) innodb_memcache, в которой вы будете конфигурировать доступ к своим таблицам.
Простой пример
Допустим, у вас уже есть таблица, с которой вы хотите работать через memcached-протокол:
CREATE TABLE `auth` (
`email` varchar(96) NOT NULL,
`password` varchar(64) NOT NULL,
`type` varchar(32) NOT NULL DEFAULT '',
PRIMARY KEY (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
и вы хотите получать и изменять данные по первичному ключу.
Вам нужно сначала описать соответствие между memcached-ключом и SQL-таблицей в таблице innodb_memcache.containers. Эта таблица выглядит примерно так (я убрал описание кодировок, чтобы было проще читать):
CREATE TABLE `containers` (
`name` varchar(50) NOT NULL,
`db_schema` varchar(250) NOT NULL,
`db_table` varchar(250) NOT NULL,
`key_columns` varchar(250) NOT NULL,
`value_columns` varchar(250) DEFAULT NULL,
`flags` varchar(250) NOT NULL DEFAULT '0',
`cas_column` varchar(250) DEFAULT NULL,
`expire_time_column` varchar(250) DEFAULT NULL,
`unique_idx_name_on_key` varchar(250) NOT NULL,
PRIMARY KEY (`name`)
) ENGINE=InnoDB DEFAULT
Самые важные поля:
- name — префикс вашего Memcached-ключа;
- db_schema — название базы (схемы);
- db_table — ваша таблица;
- key_columns — название поля в таблице, по которому будем искать (обычно это ваш primary key);
- value_columns — список полей из таблицы, которые будут доступны memcached-плагину;
- unique_idx_name_on_key — индекс, по которому нужно искать (несмотря на то, что вы уже указали key_columns, они могут входить в разные индексы и нужно явно указать индекс).
Остальные поля для начала не очень важны.
Добавим описание нашей таблицы в innodb_memcache.containers:
INSERT INTO innodb_memcache.containers SET
name='auth',
db_schema='test',
db_table='auth',
key_columns='email',
value_columns='password|type',
flags='0',
cas_column='0',
expire_time_column='0',
unique_idx_name_on_key='PRIMARY';
В этом примере name=’auth’ — это префикс нашего memcached-ключа. В документации он зачастую называется table_id, и далее в статье я буду использовать этот термин.
Теперь TELNET-ом подключимся к memcached-плагину и попробуем сохранить и получить данные:
[21:26:22] maxm@localhost: ~> telnet memchached-mysql.dev 11211
Trying 127.0.0.1...
Connected to memchached-mysql.dev.
Escape character is '^]'.
get @@auth.max@example.com
END
set @@auth.max@example.com 0 0 10
1234567|89
STORED
get @@auth.max@example.com
VALUE @@auth.max@example.com 0 10
1234567|89
END
Сначала мы отправили GET-запрос, он нам ничего не вернул. Потом мы сохранили данные SET-запросом, после чего получили их обратно GET-ом.
GET вернул такую строку: 1234567|89. Это значения полей «password» и «type», разделённые символом "|". Поля возвращаются в том порядке, в каком они были описаны в innodb_memcache.containers.value_columns.
Возможно, вы сейчас задались вопросом: «А что будет, если в «password» встретится символ "|"?» Об этом я расскажу ниже.
Через SQL эти данные тоже доступны:
MySQL [(none)]> select * from auth where email='max@example.com';
+-----------------+----------+------+
| email
| password | type |
+-----------------+----------+------+
| max@example.com | 1234567 | 89 |
+-----------------+----------+------+
1 row in set (0.00 sec)
Дефолтный table_id
Есть ещё такой режим работы:
get @@auth
VALUE @@auth 0 21
test/auth
END
get max@example.com
VALUE max@example.com 0 10
1234567|99
END
set ivan@example.com 0 0 10
qwerty|xxx
STORED
get ivan@example.com
VALUE ivan@example.com 0 10
qwerty|xxx
END
В этом примере запросом get @@auth мы делаем table_id auth префиксом по умолчанию для данного соединения. После этого все последующие запросы можно делать без указания table_id.
Пока всё просто и логично. Но если начать разбираться, то обнаруживается много нюансов. Расскажу о том, что нашли мы.
Нюансы
Кеширование таблицы innodb_memcache.containers
Memcached-плагин читает таблицу innodb_memcache.containers один раз при старте. Далее, если по Memcached-протоколу приходит неизвестный table_id, плагин ищет его в таблице. Поэтому вы легко можете добавлять новые ключи (table_id), но если захотите изменить настройки существующего table_id, придётся перезапускать memcached-плагин:
mysql> UNINSTALL PLUGIN daemon_memcached;
mysql> INSTALL PLUGIN daemon_memcached soname "libmemcached.so";
В промежутке между этими двумя запросами Memcached-интерфейс работать не будет. Из-за этого зачастую проще создать новый table_id, чем менять существующий и перезапускать плагин.
Для нас было неожиданностью, что такой важный нюанс работы плагина описан на странице Adapting a memcached Application for the InnoDB memcached Plugin, — не очень логичное место для такой информации.
Flags, cas_column, expire_time_column
Данные поля нужны для имитации некоторых особенностей Memcached. Документация по ним противоречива. Большинство примеров в ней иллюстрируют работу с таблицами, в которых эти поля есть. Может возникнуть опасение, что вам понадобится добавлять их в ваши таблицы (а это как минимум три INT-поля). Но нет. Если у вас в таблицах нет таких полей и вы не собираетесь использовать такой функционал Memcached, как CAS, expiration или флаги, то вам не надо добавлять эти поля в таблицы.
При конфигурации таблицы в innodb_memcache.containers нужно в эти поля вписать ‘0’, сделать именно строку с нулём:
INSERT INTO innodb_memcache.containers SET
name='auth',
db_schema='test',
db_table='auth',
key_columns='email',
value_columns='password|type',
flags='0',
cas_column='0',
expire_time_column='0',
unique_idx_name_on_key='PRIMARY';
Досадно, что у cas_column и expire_time_column значение по умолчанию — NULL, и, если вы выполните INSERT INTO innodb_memcache.containers, не указав значение ‘0’ для этих полей, в них сохранится NULL и этот memcache-префикс попросту не будет работать.
Типы данных
Из документации не очень понятно, какие типы данных можно использовать при работе с плагином. В нескольких местах сказано, что плагин может работать только с текстовыми полями (CHAR, VARCHAR, BLOB). Вот здесь: Adapting an Existing MySQL Schema for the InnoDB memcached Plugin предлагают числа хранить в строковых полях, и если вам потом из SQL надо работать с этими числовыми полями, то создавать VIEW, в котором VARCHAR-поля с числами будут конвертироваться в INTEGER-поля:
CREATE VIEW numbers AS SELECT c1 KEY, CAST(c2 AS UNSIGNED INTEGER) val
FROM demo_test WHERE c2 BETWEEN '0' and '9999999999';
Однако кое-где в документации всё же написано, что можно работать с числами. У нас пока имеется только реальный продакшен-опыт с текстовыми полями, но результаты экспериментов показывают, что с числами плагин тоже работает:
CREATE TABLE `numbers` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`counter` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB
INSERT INTO innodb_memcache.containers SET name='numbers', db_schema='test', db_table='numbers', key_columns='id', value_columns='counter', flags='0', cas_column='0',expire_time_column='0',unique_idx_name_on_key='PRIMARY';
После этого через Memcached-протокол:
get @@numbers.1
END
set @@numbers.1 0 0 2
12
STORED
get @@numbers.1
VALUE @@numbers.1 0 2
12
END
Мы видим, что memcached-плагин может вернуть любые типы данных. Но он их возвращает в том виде, в котором они лежат в InnoDB, поэтому, например, в случае с timestamp/datetime/float/decimal/JSON возвращается бинарная строка. Но целые числа возвращаются такими, какими мы видим их через SQL.
Multiget
Memcached-протокол позволяет запрашивать несколько ключей одним запросом:
get @@numbers.2 @@numbers.1
VALUE @@numbers.2 0 2
12
VALUE @@numbers.1 0 2
13
END
То, что multiget работает, — уже хорошо. Но он работает в рамках одного table_id:
get @@auth.ivan@example.com @@numbers.2
VALUE @@auth.ivan@example.com 0 10
qwerty|xxx
END
В документации этот момент описан здесь: https://dev.mysql.com/doc/refman/8.0/en/innodb-memcached-multiple-get-range-query.html. Оказывается, в multiget-е можно указать table_id только для первого ключа, если все остальные ключи берутся из дефолтного table_id (пример из документации):
get @@aaa.AA BB
VALUE @@aaa.AA 8 12
HELLO, HELLO
VALUE BB 10 16
GOODBYE, GOODBYE
END
В этом примере второй ключ берётся из дефолтного table_id. Мы могли бы указать намного больше ключей из дефолтного table_id, а для первого ключа мы указали отдельный table_id, и такая возможность есть только в случае с первым ключом.
Можно сказать, что multiget работает в рамках одной таблицы, потому что полагаться на такую логику в продакшен-коде совсем не хочется: она неочевидна, об этом легко забыть, ошибиться.
Если сравнивать с HandlerSocket, то там тоже multiget работал в рамках одной таблицы. Но это ограничение выглядело естественным: клиент открывает индекс в таблице и запрашивает из него одно или несколько значений. А вот при работе c memcached-плагином multiget по нескольким ключам с разными префиксами — нормальная практика. И от MySQL memcached-плагина ожидаешь того же. Но нет :(
INCR, DEL
Я уже приводил примеры GET-/SET-запросов. У INCR- и DEL-запросов есть особенность. Она заключается в том, что они работают только при использовании дефолтного table_id:
DELETE @@numbers.1
ERROR
get @@numbers
VALUE @@numbers 0 24
test/numbers
END
delete 1
DELETED
Ограничения memcached-протокола
Memcached имеет текстовый протокол, что накладывает некоторые ограничения. Например, memcached-ключи не должны содержать пробельные символы (пробел, перевод строки). Если посмотреть ещё раз на описание таблицы из нашего примера:
CREATE TABLE `auth` (
`email` varchar(96) NOT NULL,
`password` varchar(64) NOT NULL,
`type` varchar(32) NOT NULL DEFAULT '',
PRIMARY KEY (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci
то это значит, что в поле «email» не должно быть таких символов.
Также длина memcached-ключей должна быть менее 250 байт (байт, а не символов). Если отправить больше, получите ошибку:
"CLIENT_ERROR bad command line format"
Помимо этого, надо учитывать тот факт, что memcached-плагин добавляет в memcached-протокол свой синтаксис. Например, он использует символ "|" в качестве разделителя полей в ответе. Вам нужно следить за тем, чтобы в вашей таблице не использовался этот символ. Разделитель можно настроить, но параметры настройки будут распространяться на все таблицы на всём MySQL-сервере.
Разделитель полей value_columns
Если через memcached-протокол необходимо вернуть несколько колонок, как в нашем первом примере:
get @@auth.max@example.com
VALUE @@auth.max@example.com 0 10
1234567|89
END
то значения колонок разделяются стандартным разделителем "|". Возникает вопрос: «А что будет, если, например, в первом поле в строке будет символ "|"»? Memcached-плагин в этом случае вернёт строку как есть, примерно так: 1234|567|89. В общем случае нельзя понять, где здесь какое поле.
Поэтому важно сразу выбирать правильный разделитель. А поскольку он будет использоваться для всех ключей всех таблиц, это должен быть универсальный символ, который не будет встречаться ни в одном поле, с которым которым вы будете работать через memcached-протокол.
Резюме
Нельзя сказать, что memcached-плагин плох. Но складывается впечатление, что он был написан для определённой схемы работы: MySQL-сервер с одной таблицей, к которой есть доступ по memcached-протоколу, и этот table_id сделан дефолтным. Клиенты устанавливают длительное (persistent) соединение с Memcached-плагином и делают запросы к дефолтному table_id. Наверное, в такой схеме всё будет работать без нареканий. Если же отойти от неё, натыкаешься на различные неудобства.
Возможно, вы ожидали увидеть какие-нибудь отчёты о производительности плагина. Но мы пока не решились использовать его в высоконагруженных местах. Мы использовали его только в нескольких не очень нагруженных системах и там он работает примерно с такой же скоростью, как HandlerSocket, но честных бенчмарков мы не делали. Но всё же плагин предоставляет такой интерфейс, с которым программист может легко допустить ошибку, — нужно много нюансов держать в голове. Поэтому массово использовать этот плагин мы пока не готовы.
Мы завели несколько feature requests в баг-трекере MySQL:
https://bugs.mysql.com/bug.php?id=95091
https://bugs.mysql.com/bug.php?id=95092
https://bugs.mysql.com/bug.php?id=95093
https://bugs.mysql.com/bug.php?id=95094
Будем надеяться, команда разработчиков memcached-плагина будет совершенствовать свой продукт.