I encountered an error when using the literal type to change columns.
use PhinxDbAdapterMysqlAdapter;
use PhinxUtilLiteral;
class ContactsGenerated2 extends PhinxMigrationAbstractMigration
{
public function change()
{
$this->execute('SET unique_checks=0; SET foreign_key_checks=0;');
$this->table('t_contacts', [
'id' => false,
'primary_key' => ['c_uid'],
'engine' => 'InnoDB',
'encoding' => 'utf8mb4',
'collation' => 'utf8mb4_0900_ai_ci',
'row_format' => 'DYNAMIC',
])
->changeColumn('c_fn', Literal::from("varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (`c_json`->>'$.fn') VIRTUAL"), [
'null' => true,
])
->update();
$this->execute('SET unique_checks=1; SET foreign_key_checks=1;');
}
}
As long as I change only the json path c_json->>'$.to.something.else'
, all works well, but changing VIRTUAL
to STORED
or vice-versa fails with
PDOException: SQLSTATE[HY000]: General error: 3106 'Changing the STORED status' is not supported for generated columns. in /srv/varwwwhtml/glued-skeleton/vendor/robmorgan/phinx/src/Phinx/Db/Adapter/PdoAdapter.php:194
…
I think that when the STORED status is changed, the changed column needs to get dropped and recreated.
Perhaps you have columns in your table that work together to provide some meaningful results. During table creation, you have the option to CREATE
a GENERATED COLUMN
in MySQL. What is that? And, what can you use it for? Read on to learn with me and find out…
Note: All data, names or naming found within the database presented in this post, are strictly used for practice, learning, instruction, and testing purposes. It by no means depicts actual data belonging to or being used by any party or organization.
OS and DB used:
- Xubuntu Linux 16.04.5 LTS (Xenial Xerus)
- MySQL 8.0.15
Let’s look at a simple example and this CREATE TABLE
statement:
1 |
mysql> CREATE TABLE company_emails( |
Next, I will populate the table with some mock data:
1 |
mysql> INSERT INTO company_emails(f_name, l_name) |
Notice in the above INSERT
, the email
column was not part of the listed columns, nor was any data supplied in the VALUES
clause for it.
Let’s check the table data now:
1 |
mysql> SELECT * FROM company_emails; |
You can see that column email
returns the definition we set during table creation, concatenating the f_name
and l_name
with the company email address. Hence, this is the GENERATED COLUMN
.
Let’s see the underlying details for table company_emails
:
1 |
mysql> DESC company_emails; mysql> SHOW CREATE TABLE company_emailsG |
The Extra
column provides an important specifier or definition, VIRTUAL
, which is the default should one be omitted in the definition (as was the case in the example here). However, there is also a STORED
option for GENERATED COLUMN
‘s.
The naming helps (me at least!) with clearing up any distinction between the two but, in order to solidify the concept, we will have an example.
Since I am not changing the column name, but only the definition, I use the MODIFY
clause in this ALTER TABLE
statement:
1 |
mysql> ALTER TABLE company_emails MODIFY COLUMN email TEXT GENERATED ALWAYS AS (CONCAT(f_name,‘.’,l_name,‘@some_company.com’)) STORED; |
(Told you I was learning!!!)
Hmm…
Can’t do that it seems.
Why?
See this passage from the ALTER TABLE and Generated Columns official documentation for better understanding:
“Virtual generated columns cannot be altered to stored generated columns, or vice versa. To work around this, drop the column, then add it with the new definition.”
Probably a good idea to keep this in mind during the design phase. Dropping a column and reimplementing it has its drawbacks (in my mind at least) due to table locking and the fact you are essentially discarding data.
Yet, for our arbitrary example, we will proceed forward:
1 |
mysql> ALTER TABLE company_emails DROP COLUMN email; |
1 |
mysql> ALTER TABLE company_emails |
Let’s run another INSERT
then check the data:
1 |
mysql> INSERT INTO company_emails(f_name, l_name) mysql> SELECT * FROM company_emails; |
So what’s the difference between them?
The crux of it is:
STORED
columns take up storage space and can have indexes. (I think of them as real).VIRTUAL
columns are just essentially generated on the fly as rows are returned in query results.
While the example presented here is mostly arbitrary, I am sure you can find a use for GENERATED COLUMN
‘s in your tables… Let me know all about them in the comments below!!!
Like what you have read? See anything incorrect? Please comment below and thanks for reading!!!
Explore the official MySQL 8.0 Online Manual for more information.
A Call To Action!
Thank you for taking the time to read this post. I truly hope you discovered something interesting and enlightening. Please share your findings here, with someone else you know who would get the same value out of it as well.
Visit the Portfolio-Projects page to see blog post/technical writing I have completed for clients.
Have I mentioned how much I love a cup of coffee?!?!
To receive email notifications (Never Spam) from this blog (“Digital Owl’s Prose”) for the latest blog posts as they are published, please subscribe (of your own volition) by clicking the ‘Click To Subscribe!’ button in the sidebar on the homepage! (Feel free at any time to review the Digital Owl’s Prose Privacy Policy Page for any questions you may have about: email updates, opt-in, opt-out, contact forms, etc…)
Be sure and visit the “Best Of” page for a collection of my best blog posts.
Josh Otwell has a passion to study and grow as a SQL Developer and blogger. Other favorite activities find him with his nose buried in a good book, article, or the Linux command line. Among those, he shares a love of tabletop RPG games, reading fantasy novels, and spending time with his wife and two daughters.
Disclaimer: The examples presented in this post are hypothetical ideas of how to achieve similar types of results. They are not the utmost best solution(s). The majority, if not all, of the examples provided, is performed on a personal development/learning workstation-environment and should not be considered production quality or ready. Your particular goals and needs may vary. Use those practices that best benefit your needs and goals. Opinions are my own.
-
Type:
Bug
-
Priority:
Major
-
Resolution:
Unresolved
-
Affects Version/s:
5.5, 10.0, 10.1, 10.2, 10.3, 10.4
-
Fix Version/s:
10.4
MariaDB [test]> CREATE TABLE t1 (i INT, f INT AS (i) VIRTUAL); |
Query OK, 0 rows affected (0.44 sec) |
|
MariaDB [test]> ALTER TABLE t1 MODIFY COLUMN f INT; |
ERROR 1907 (HY000): This is not yet supported for generated columns |
«This» is really vague.
MySQL is somewhat better at it:
3106: 'Changing the STORED status' is not supported for generated columns
- relates to
-
MDEV-15956
Strange ER_UNSUPPORTED_ACTION_ON_GENERATED_COLUMN upon ALTER on versioning column
-
- Closed
-
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.
У MySQL нет возможности напрямую индексировать документы JSON, но есть альтернатива: генерируемые столбцы.
С момента введения поддержки типа данных JSON в MySQL 5.7.8 не хватает одной вещи: способности индексировать значения JSON. Для того, чтобы обойти это ограничение, можно использовать генерируемые столбцы. Эта возможность, представленная в MySQL 5.7.5, позволяет разработчикам создавать столбцы, содержащие информацию, полученную из других столбцов, предопределенных выражений или вычислений. Генерируя столбец из значений JSON, а затем индексируя его, можно практически индексировать поле с JSON.
Набор данных в формате JSON, используемый в данной статье, можно скачать на Гитхабе. Он содержит список игроков со следующими элементами: идентификатор игрока, его имя и игры, в которые он играл (Battlefield, Crazy Tennis и Puzzler).
{
"id":1,
"name":"Sally",
"games_played":{
"Battlefield":{
"weapon":"sniper rifle",
"rank":"Sergeant V",
"level":20
},
"Crazy Tennis":{
"won":4,
"lost":1
},
"Puzzler":{
"time":7
}
}
},
…
Поле Battlefield содержит любимое оружие игрока, его текущий ранг и уровень этого ранга. Crazy Tennis включает в себя количество выигранных и проигранных игр, а Puzzler содержит время, затраченное игроком на прохождение игры. Создадим начальную таблицу:
CREATE TABLE `players` (
`id` INT UNSIGNED NOT NULL,
`player_and_games` JSON NOT NULL,
PRIMARY KEY (`id`)
);
Этот запрос создает таблицу players
, состоящую из идентификатора и JSON-данных, а также устанавливает в поле id
первичный ключ.
Нужно построить индекс по полю с JSON. Давайте посмотрим, что нужно добавить в команду CREATE TABLE
.
Генерация столбцов
Для создания генерируемых столбцов в операторе CREATE TABLE
используется следующий синтаксис:
`column_name` datatype GENERATED ALWAYS AS (expression)
Ключевыми словами здесь являются GENERATED ALWAYS
и AS
. Фраза GENERATED ALWAYS
необязательна. Она необходима только в том случае, если вы хотите явно указать, что этот столбец таблицы — генерируемый. Необходимо, чтобы слово AS
сопровождалось выражением, которое вернет значение для генерируемого столбца.
Начнем с этого:
`names_virtual` VARCHAR(20) GENERATED ALWAYS AS ...
Cоздаем столбец с именем names_virtual
длиной до 20 символов, в котором будем хранить значение поля «name» из объекта JSON. Обращаться к полю «name» в JSON будем с использованием MySQL-оператора ->>
, который эквивалентен написанию JSON_UNQUOTE (JSON_EXTRACT (...))
. Эта конструкция вернет значение поля «name» из объекта JSON в качестве результата.
`names_virtual` VARCHAR(20) GENERATED ALWAYS AS (`player_and_games` ->> '$.name')
Этот код означает, что мы берём поле c JSON player_and_games
и извлекаем значение из JSON по ключу «name» — дочернее по отношению к корню.
Как и в большинстве определений столбцов, существует ряд ограничений и параметров, которые можно применить к столбцу.
[VIRTUAL|STORED] [UNIQUE [KEY]] [[NOT] NULL] [[PRIMARY] KEY]
Уникальные для генерируемых столбцов ключевые слова VIRTUAL
и STORED
указывают на то, будут ли значения сохраняться в таблице.
Ключевое слово VIRTUAL
используется по умолчанию. Оно означает, что значения столбца не сохраняются и не занимают место для хранения. Они вычисляются при каждом чтении строки. Если вы создаете индекс с виртуальным столбцом, значение всё же сохраняется — в индексе.
Ключевое слово STORED
указывает, что значения вычисляются при записи данных в таблицу: при вставке или обновлении. В этом случае индексу не нужно сохранять значение.
Другие параметры — необязательные ограничения, которые гарантируют, что значения поля будут NULL
или NOT NULL
, а также добавления ограничений на индекс, например, UNIQUE
или PRIMARY KEY
. Для гарантии существования значения следует использовать NOT NULL
при создании столбца, однако ограничения зависят от варианта использования. В примере будет использоваться NOT NULL
, так как у игроков обязательно есть имя.
Запрос, создающий таблицу:
CREATE TABLE `players` (
`id` INT UNSIGNED NOT NULL,
`player_and_games` JSON NOT NULL,
`names_virtual` VARCHAR(20) GENERATED ALWAYS AS (`player_and_games` ->> '$.name') NOT NULL,
PRIMARY KEY (`id`)
);
Заполнение таблицы тестовыми данными:
INSERT INTO `players` (`id`, `player_and_games`) VALUES (1, '{
"id": 1,
"name": "Sally",
"games_played":{
"Battlefield": {
"weapon": "sniper rifle",
"rank": "Sergeant V",
"level": 20
},
"Crazy Tennis": {
"won": 4,
"lost": 1
},
"Puzzler": {
"time": 7
}
}
}'
);
...
Содержимое таблицы players
на Гисте или…
С поехавшим форматированием
SELECT * FROM `players`;
+----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+
| id | player_and_games | names_virtual |
+----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+
| 1 | {"id": 1, "name": "Sally", "games_played": {"Puzzler": {"time": 7}, "Battlefield": {"rank": "Sergeant V", "level": 20, "weapon": "sniper rifle"}, "Crazy Tennis": {"won": 4, "lost": 1}}} | Sally |
| 2 | {"id": 2, "name": "Thom", "games_played": {"Puzzler": {"time": 25}, "Battlefield": {"rank": "Major General VIII", "level": 127, "weapon": "carbine"}, "Crazy Tennis": {"won": 10, "lost": 30}}} | Thom |
| 3 | {"id": 3, "name": "Ali", "games_played": {"Puzzler": {"time": 12}, "Battlefield": {"rank": "First Sergeant II", "level": 37, "weapon": "machine gun"}, "Crazy Tennis": {"won": 30, "lost": 21}}} | Ali |
| 4 | {"id": 4, "name": "Alfred", "games_played": {"Puzzler": {"time": 10}, "Battlefield": {"rank": "Chief Warrant Officer Five III", "level": 73, "weapon": "pistol"}, "Crazy Tennis": {"won": 47, "lost": 2}}} | Alfred |
| 5 | {"id": 5, "name": "Phil", "games_played": {"Puzzler": {"time": 7}, "Battlefield": {"rank": "Lt. Colonel III", "level": 98, "weapon": "assault rifle"}, "Crazy Tennis": {"won": 130, "lost": 75}}} | Phil |
| 6 | {"id": 6, "name": "Henry", "games_played": {"Puzzler": {"time": 17}, "Battlefield": {"rank": "Captain II", "level": 87, "weapon": "assault rifle"}, "Crazy Tennis": {"won": 68, "lost": 149}}} | Henry |
+----+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------------+
Таблица включает столбец names_virtual
, в который вставлены все имена игроков. Структура таблицы players
:
SHOW COLUMNS FROM `players`;
+------------------+------------------+------+-----+---------+-------------------+
| Field | Type | Null | Key | Default | Extra |
+------------------+------------------+------+-----+---------+-------------------+
| id | int(10) unsigned | NO | PRI | NULL | |
| player_and_games | json | NO | | NULL | |
| names_virtual | varchar(20) | NO | | NULL | VIRTUAL GENERATED |
+------------------+------------------+------+-----+---------+-------------------+
Поскольку мы не указали, является ли генерируемый столбец VIRTUAL
или STORED
, по умолчанию MySQL автоматически сделал столбец VIRTUAL
. Чтобы проверить, являются ли столбцы VIRTUAL
или STORED
, просто запустите вышеуказанный запрос SHOW COLUMNS
, и он покажет либо VIRTUAL GENERATED
, либо STORED GENERATED
.
Теперь, когда мы настроили таблицу и виртуальный столбец, добавим еще четыре столбца, используя операции ALTER TABLE
и ADD COLUMN
. Они будут содержать уровни Battlefield, выигранные и проигранные игры в теннис и время в Puzzler.
ALTER TABLE `players` ADD COLUMN `battlefield_level_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played.Battlefield.level') NOT NULL AFTER `names_virtual`;
ALTER TABLE `players` ADD COLUMN `tennis_won_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played."Crazy Tennis".won') NOT NULL AFTER `battlefield_level_virtual`;
ALTER TABLE `players` ADD COLUMN `tennis_lost_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played."Crazy Tennis".lost') NOT NULL AFTER `tennis_won_virtual`;
ALTER TABLE `players` ADD COLUMN `times_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played.Puzzler.time') NOT NULL AFTER `tennis_lost_virtual`;
Опять же, запустив запрос SHOW COLUMNS FROM players;
, мы видим, что рядом с ними все столбцы указаны как VIRTUAL GENERATED
. Это означает, что мы успешно настроили новые созданные VIRTUAL
столбцы.
Код Гисте или…
С поехавшим форматированием
+---------------------------+------------------+------+-----+---------+-------------------+
| Field | Type | Null | Key | Default | Extra |
+---------------------------+------------------+------+-----+---------+-------------------+
| id | int(10) unsigned | NO | PRI | NULL | |
| player_and_games | json | NO | | NULL | |
| names_virtual | varchar(20) | NO | | NULL | VIRTUAL GENERATED |
| battlefield_level_virtual | int(11) | NO | | NULL | VIRTUAL GENERATED |
| tennis_won_virtual | int(11) | NO | | NULL | VIRTUAL GENERATED |
| tennis_lost_virtual | int(11) | NO | | NULL | VIRTUAL GENERATED |
| times_virtual | int(11) | NO | | NULL | VIRTUAL GENERATED |
+---------------------------+------------------+------+-----+---------+-------------------+
Выполнение запроса SELECT
показывает нам все значения из VIRTUAL COLUMNS
, которые должны выглядеть так:
Код Гисте или…
С поехавшим форматированием
SELECT `names_virtual`, `battlefield_level_virtual`, `tennis_won_virtual`, `tennis_lost_virtual`, `times_virtual` FROM `players`;
+---------------+---------------------------+--------------------+---------------------+---------------+
| names_virtual | battlefield_level_virtual | tennis_won_virtual | tennis_lost_virtual | times_virtual |
+---------------+---------------------------+--------------------+---------------------+---------------+
| Sally | 20 | 4 | 1 | 7 |
| Thom | 127 | 10 | 30 | 25 |
| Ali | 37 | 30 | 21 | 12 |
| Alfred | 73 | 47 | 2 | 10 |
| Phil | 98 | 130 | 75 | 7 |
| Henry | 87 | 68 | 149 | 17 |
+---------------+---------------------------+--------------------+---------------------+---------------+
После добавления данные и создания генерируемых столбцов, мы можем создавать индекс для каждого из них, чтобы оптимизировать поиск…
Индексирование генерируемых столбцов
При установке вторичных индексов на значения генерируемых столбцов VIRTUAL
значения сохраняются в индексе. Это дает преимущества: размер таблицы не увеличивается, появляется возможность использования индексов в MySQL.
Давайте сделаем простой запрос к генерируемому столбцу, чтобы увидеть, как он выглядит, прежде чем индексировать его. Изучив детали запроса при выборе names_virtual
и имени «Sally», получим следующее:
EXPLAIN SELECT * FROM `players` WHERE `names_virtual` = "Sally"G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: players
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 6
filtered: 16.67
Extra: Using where
Для этого запроса MySQL просматривает каждую строку, чтобы найти «Sally». Однако, можно получить совершенно другой результат, добавив индекс к столбцу:
CREATE INDEX `names_idx` ON `players`(`names_virtual`);
Теперь, выполняя тот же запрос, получаем:
EXPLAIN SELECT * FROM `players` WHERE `names_virtual` = "Sally"G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: players
partitions: NULL
type: ref
possible_keys: names_idx
key: names_idx
key_len: 22
ref: const
rows: 1
filtered: 100.00
Extra: NULL
Как видно, индекс в столбце ускорил запрос, просматривая только одну строку вместо шести, используя индекс names_idx
. Давайте создадим индексы для остальных виртуальных столбцов, следуя тому же синтаксису, что и names_idx
:
CREATE INDEX `times_idx` ON `players`(`times_virtual`);
CREATE INDEX `won_idx` ON `players`(`tennis_won_virtual`);
CREATE INDEX `lost_idx` ON `players`(`tennis_lost_virtual`);
CREATE INDEX `level_idx` ON `players`(`battlefield_level_virtual`);
Можно проверить, были ли проиндексированы все наши столбцы, запустив:
Код Гисте или…
С поехавшим форматированием
SHOW INDEX ON `players`;
+---------+------------+-----------+--------------+---------------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment |
+---------+------------+-----------+--------------+---------------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
| players | 0 | PRIMARY | 1 | id | A | 6 | NULL | NULL | | BTREE | | |
| players | 1 | names_idx | 1 | names_virtual | A | 6 | NULL | NULL | | BTREE | | |
| players | 1 | times_idx | 1 | times_virtual | A | 5 | NULL | NULL | | BTREE | | |
| players | 1 | won_idx | 1 | tennis_won_virtual | A | 6 | NULL | NULL | | BTREE | | |
| players | 1 | lost_idx | 1 | tennis_lost_virtual | A | 6 | NULL | NULL | | BTREE | | |
| players | 1 | level_idx | 1 | battlefield_level_virtual | A | 6 | NULL | NULL | | BTREE | | |
+---------+------------+-----------+--------------+---------------------------+-----------+-------------+----------+--------+------+------------+---------+---------------+
Теперь, когда созданы несколько индексов в генерируемых столбцах, давайте усложним поиск. В этом примере выбираются идентификаторы, имена, выигранные теннисные игры, уровень Battlefield и время Puzzler для игроков, которые имеют уровень выше 50, а также выигравших 50 теннисных игр. Все результаты будут упорядочены по возрастанию в соответствии с временем в Puzzler. Команда SQL и результаты будут выглядеть так:
SELECT `id`, `names_virtual`, `tennis_won_virtual`, `battlefield_level_virtual`, `times_virtual` FROM `players` WHERE (`battlefield_level_virtual` > 50 AND `tennis_won_virtual` > 50) ORDER BY `times_virtual` ASC;
+----+---------------+--------------------+---------------------------+---------------+
| id | names_virtual | tennis_won_virtual | battlefield_level_virtual | times_virtual |
+----+---------------+--------------------+---------------------------+---------------+
| 5 | Phil | 130 | 98 | 7 |
| 6 | Henry | 68 | 87 | 17 |
+----+---------------+--------------------+---------------------------+---------------+
Давайте посмотрим, как MySQL выполнял этот запрос:
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: players
partitions: NULL
type: range
possible_keys: won_idx,level_idx
key: won_idx
key_len: 4
ref: NULL
rows: 2
filtered: 66.67
Extra: Using where; Using filesort
При использовании индексов win_idx
и level_idx
MySQL приходилось обращаться к двум столбцам, чтобы вернуть желаемый результат. Если запрос должен выполнить полный просмотр таблицы с миллионом записей, это займёт очень много времени. Однако, с помощью генерируемых столбцов и их индексированием, MySQL показал очень быстрый результат и удобный способ поиска элементов в JSON-данных.
Тем не менее остается один вопрос: для чего нужны STORED
генерируемые столбцц? Как их использовать и как они работают?
Хранение значений в генерируемых столбцах
Использование ключевого слова STORED
при настройке генерируемого столбца обычно не предпочтительно, поскольку в основном значения в таблице сохраняются дважды: поле с JSON и в STORED
столбце. Тем не менее, существует три сценария, когда в MySQL нужно использовать столбец STORED
:
- индексирование первичных ключей,
- полнотекстовый индекс/индекс R-tree,
- столбец, который часто выбирается.
Синтаксис добавления генерируемого STORED
столбца, совпадает с созданием генерируемых столбцов VIRTUAL
, за исключением того, что нужно добавить ключевое слово STORED
:
`id` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.id') STORED NOT NULL,
Чтобы посмотреть как использовать STORED
, создадим еще одну таблицу. Она будет брать id
из данных JSON и хранить его в STORED
столбце. Установим PRIMARY KEY
для столбца id
:
CREATE TABLE `players_two` (
`id` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.id') STORED NOT NULL,
`player_and_games` JSON NOT NULL,
`names_virtual` VARCHAR(20) GENERATED ALWAYS AS (`player_and_games` ->> '$.name') NOT NULL,
`times_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played.Puzzler.time') NOT NULL,
`tennis_won_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played."Crazy Tennis".won') NOT NULL,
`tennis_lost_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played."Crazy Tennis".lost') NOT NULL,
`battlefield_level_virtual` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.games_played.Battlefield.level') NOT NULL,
PRIMARY KEY (`id`),
INDEX `times_index` (`times_virtual`),
INDEX `names_index` (`names_virtual`),
INDEX `won_index` (`tennis_won_virtual`),
INDEX `lost_index` (`tennis_lost_virtual`),
INDEX `level_index` (`battlefield_level_virtual`)
);
Добавим тот же набор данных в player_two
, за исключением того, что удалим id
, который ранее добавили в операцию INSERT
:
INSERT INTO `players_two` (`player_and_games`) VALUES ('{
"id": 1,
"name": "Sally",
"games_played":{
...
);
После того, как данные были вставлены в таблицу, запустим SHOW COLUMNS
в новой таблице, чтобы узнать, как MySQL создал столбцы. Обратите внимание, что поле id
теперь — STORED GENERATED
и содержит индекс PRIMARY KEY
.
SHOW COLUMNS FROM `players_two`;
+---------------------------+-------------+------+-----+---------+-------------------+
| Field | Type | Null | Key | Default | Extra |
+---------------------------+-------------+------+-----+---------+-------------------+
| id | int(11) | NO | PRI | NULL | STORED GENERATED |
| player_and_games | json | NO | | NULL | |
| names_virtual | varchar(20) | NO | MUL | NULL | VIRTUAL GENERATED |
| times_virtual | int(11) | NO | MUL | NULL | VIRTUAL GENERATED |
| tennis_won_virtual | int(11) | NO | MUL | NULL | VIRTUAL GENERATED |
| tennis_lost_virtual | int(11) | NO | MUL | NULL | VIRTUAL GENERATED |
| battlefield_level_virtual | int(11) | NO | MUL | NULL | VIRTUAL GENERATED |
+---------------------------+-------------+------+-----+---------+-------------------+
Замечание об использовании PRIMARY KEY
с генерируемыми столбцами: MySQL не позволит создавать первичные ключи для генерируемых VIRTUAL
столбцов. На самом деле, если не указать STORED
в поле id
, MySQL выдает следующую ошибку:
ERROR 3106 (HY000): 'Defining a virtual generated column as primary key' is not supported for generated columns.
В то же время, если не устанавливать индекс первичного ключа и попытаться вставить данные, MySQL выдает сообщение об ошибке:
ERROR 3098 (HY000): The table does not comply with the requirements by an external plugin.
Это означает, что у таблицы нет первичного ключа. Поэтому нужно вернуться и пересоздать таблицу, либо удалить столбец id
и добавить генерируемый STORED
столбец с первичным ключом, например:
ALTER TABLE `players_two` ADD COLUMN `id` INT GENERATED ALWAYS AS (`player_and_games` ->> '$.id') STORED PRIMARY KEY;
Вывод
В статье показано как эффективно хранить данные JSON в MySQL, а так же как создавать индексы благодаря генерируемым столбцам. Использование генерируемых столбцов позволит размещать индексы по определенным элементам данных JSON. Именно эта гибкость делает MySQL очень привлекательной для использования JSON.
Failure on generated columns
Hi Daniel,
I got a table created as
CREATE TABLE t_assets_items (
c_uid int(11) NOT NULL,
c_data json NOT NULL,
stor_name varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (json_unquote(json_extract(c_data,'$.data.item'))) VIRTUAL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
running the migrations generator, I end up with
$this->table('t_assets_items', [
'id' => false,
'engine' => 'InnoDB',
'encoding' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'comment' => '',
'row_format' => 'DYNAMIC',
])
->addColumn('c_data', 'json', [
'null' => false,
])
->addColumn('c_uid', 'integer', [
'null' => false,
'limit' => MysqlAdapter::INT_REGULAR,
'after' => 'c_data',
])
->addColumn('stor_name', 'string', [
'null' => true,
'limit' => 255,
'collation' => 'utf8mb4_unicode_ci',
'encoding' => 'utf8mb4',
'after' => 'c_uid',
])
->create();
with the stor_name
column ending up with some wrong code. Would you please look into this one? I’m ready to help with tests etc., just let me know.
Hi @killua-eu
The column stor_name
is a so called «generated column», which is not fully supported by Phinx.
GENERATED ALWAYS AS (json_unquote(json_extract(c_data,'$.data.item'))) VIRTUAL
I think this PR will allow us to add those columns, if it gets merged.
- cakephp/phinx#1311
- cakephp/phinx#1276
Ahh. So the cakephp/phinx#1175 is not enough here to get this done right? Btw, can the phinx-migration-generator generate raw sql changes too? That might be a workaround.
At the moment it’s not clear for me how to use this new feature to generate an output like GENERATED ALWAYS AS (json_unquote(json_extract(c_data,'$.data.item'))) VIRTUAL
Do you have a working example?
Btw, can the phinx-migration-generator generate raw sql changes too? That might be a workaround.
This would be a nice feature. I’ve thought about it quite often, when I reached the limits of phinx.
I’ll do some more tests in the week (with respect to the new feature) & let you know. Raw sql would be imho awesome.
Did some trial and error. Couldn’t really progress on using the phinx’s literal class towards the wanted result. I asked the phinx devs now. What naturally works is plain sql … after the create() statement, I just added an execute()
// generated code here
->create();
$this->execute("ALTER TABLE `t_calendar_sources` DROP `c_flag_deleted`; ALTER TABLE `t_calendar_sources` ADD `c_flag_deleted` boolean GENERATED ALWAYS AS (IF((json_unquote(json_extract(`c_attr`,'$.deleted'))) = 'true', 1, 0)) VIRTUAL NOT NULL;");
So maybe #80 is a solution unless I have some progress here
I tried some more and failed beautifully. I guess supporting plain SQL is the best way around this then.
Ok, thank you very much for exploring the possibilities. So I think the next step is to implement a SQL generator for such specific use cases.
As a workaround you can use the $this->execute(...)
method. So for now I close this issue.
Hey @odan , I’d like to reopen this issue. I have a working example on how to add a generated column properly with phinx — its been supported, only not too obviously documented. Here the code:
<?php
use PhinxDbAdapterMysqlAdapter;
use PhinxUtilLiteral;
class Asset extends PhinxMigrationAbstractMigration
{
public function change()
{
$this->execute('SET unique_checks=0; SET foreign_key_checks=0;');
$this->table('t_assets_items', [
'id' => false,
'primary_key' => ['c_uid'],
'engine' => 'InnoDB',
'encoding' => 'utf8mb4',
'collation' => 'utf8mb4_0900_ai_ci',
'comment' => 'Assets items',
'row_format' => 'DYNAMIC',
])
->addColumn('c_ref1', Literal::from("varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (`c_json`->>'$.uid') VIRTUAL"), [
'null' => false,
'comment' => 'Unique row id',
])
->update();
$this->execute('SET unique_checks=1; SET foreign_key_checks=1;');
}
}
this does actually the correct thing (—dry-run excerpt)
php vendor/bin/phinx migrate -e production --configuration=phinx.yml --dry-run
Phinx by CakePHP - https://phinx.org.
using config file ./phinx.yml
using config parser yml
using migration paths
- /srv/varwwwhtml/migrate-test/glued/Core/Install/Migrations
using seed paths
- /srv/varwwwhtml/migrate-test/glued/Core/Install/Seeds
using environment production
using adapter mysql
using database glued
ordering by creation time
== 20201204120000 Asset: migrating
START TRANSACTION;
SET unique_checks=0; SET foreign_key_checks=0;
ALTER TABLE `t_assets_items` ADD `c_ref1` varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (`c_json`->>'$.uid') VIRTUAL NOT NULL COMMENT 'Unique row id';
SET unique_checks=1; SET foreign_key_checks=1;
COMMIT;
INSERT INTO `phinxlog` (`version`, `migration_name`, `start_time`, `end_time`, `breakpoint`) VALUES ('20201204120000', 'Asset', '2020-12-04 10:19:15', '2020-12-04 10:19:15', 0);
== 20201204120000 Asset: migrated 0.0164s
would you mind including supporting this on your side yourself?
That’s nice. I will take a look at it in the next few days.
Hi Daniel, any progress on this? I’m asking because this issue is closed and could be forgotten easily.
I have it on my to-do list. At the moment I am very busy.
Currently I’m trying to add this feature. Your suggested workaround with Literal::from(...)
is tricky but it works on my dev machine. The «up» direction works so far, but there are some other problems now:
- Changing a generated column would not work with the current logic of the migration generator because a generated column must be dropped and then created to change it. All other fields can be changed directly with phinx, but not this kind of field and with the current logic the generator is internally build.
- Another option would be to generate raw SQL for generated columns, but this would also break a lot of existing internal logic. It would also require adding support for the phinx
up
anddown
method, instead of using thechange
method. So a lot of changes. - Phinx does not support generated columns. The PR to support it was closed. So I guess it will «never» be supported directly because phinx tries to abstract multiple databases systems.
So you see I’ve tried it, but this feature needs a lot of more changes under the hood then expected.
Hey @odan , sorry for my late reaction, I’ve been really busy. I personally wouldn’t mind a partial support for this (meaning at least creating the generated columns). Or would maybe $table->getColumn('column1')->getType();
help to work with the Literal type differently? I’m aware this would feel hackish to have a specific way how to deal with this, but it feels like an easier way then going raw SQL. Is there anything I could help with to move this on?
Just a remark: I noticed that dropping the column is required only when changing the STORED
status of the column. Changing existing normal columns into generated stored columns with
->changeColumn('c_vatid', Literal::from("varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (`c_json`->>'$.some[0].thing') STORED"), [
'null' => true,
will work, but trying to change
->changeColumn('c_vatid', Literal::from("varchar(255) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS (`c_json`->>'$.some[0].thingelse') VIRTUAL"), [
'null' => true,
will fail with
PDOException: SQLSTATE[HY000]: General error: 3106 'Changing the STORED status' is not supported for generated columns. in /srv/varwwwhtml/glued-skeleton/vendor/robmorgan/phinx/src/Phinx/Db/Adapter/PdoAdapter.php:194
Similarly, changing a VIRTUAL column into a VIRTUAL column with a different json path, it will work just fine . Basicall only changing STORED/VIRTUAL status seems to fail on me.
I’ll report this specific issue to phinx, as this would be nicer to deal with upstream, but I’m hoping this could be relatively simple to implement in the generator.
Phinx is a very good tool, but it also has its limitations.
I just noticed that the requirements of the people and the features of Phinx are getting further and further apart.
Therefore I play with the idea to generate only pure SQL in the next major version. This SQL would then be generated for an up and down method. What do you think about this idea?
Absolutely makes sense. I’ve been using phinx mainly because there wasn’t any other maintained database migration tool in the php ecosystem and it was still quite a pain to use until your project appeared.
The things I appreciated about phinx is the change() method while I was still writing migrations manually. Many other things feel unnecessarily abstracted away towards making phinx hard to use in real world.
I have come to the conclusion that this tool should only offer features that are also supported by Phinx. So as long as Phinx does not support generated columns, this migration generator will not support it. I tried to generate SQL diffs, but that doesn’t make sense since Phinx should be responsible for that task. So I’m sorry to close this request, but the work and time involved would be too much for me.
We have had a look at how MySQL 8 handles JSON
recently, but with all those JSON functions and expressions it is clear that many JSON accesses cannot be fast. To grab data from a JSON column, you will use a lot of $->>field
expressions and similar, and without indexes nothing of this will be fast.
JSON cannot be indexed.
But MySQL 8 offers another feature that comes in handy: Generated columns and indexes on those. Let’s look at the parts, step by step, and how to make them work, because they are useful even outside the context of JSON.
An example table
For the following example we are going to define a table t1
with an integer id and two integer data fields, a
and b
. We will be filling it with random integers up to 999 for the data values:
mysql]> create table t1 (
-> id integer not null primary key auto_increment,
-> a integer,
-> b integer
-> );
Query OK, 0 rows affected (0.07 sec)
mysql> insert into t1 ( id, a, b) values (NULL, ceil(rand()*1000), ceil(rand()*1000));
Query OK, 1 row affected (0.01 sec)
mysql> insert into t1 (id, a, b) select NULL, ceil(rand()*1000), ceil(rand()*1000) from t1;
Query OK, 1 row affected (0.01 sec)
Records: 1 Duplicates: 0 Warnings: 0
...
mysql> insert into t1 (id, a, b) select NULL, ceil(rand()*1000), ceil(rand()*1000) from t1;
Query OK, 524288 rows affected (6.83 sec)
Records: 524288 Duplicates: 0 Warnings: 0
mysql> select count(*) from t1;
+----------+
| count(*) |
+----------+
| 1048576 |
+----------+
1 row in set (0.04 sec)
Generated columns
A generated column is a column with values that are calculated from a deterministic expression provided in the column definition. It has the usual name and type, and then a GENERATED ALWAYS AS ()
term. The parentheses are part of the syntax and cannot be left off. The GENERATED ALWAYS
is optional, and we are going to leave it off, because we are lazy.
The column can be VIRTUAL
, in which case the expression is evaluated when reading every time a value is needed, or STORED
, in which case the value is materialized and stored on write.
It may also contain inline index definitions and a column comment.
VIRTUAL generated columns
So we get our trivial example:
mysql> alter table t1 add column c integer as ( a+b ) virtual;
Query OK, 0 rows affected (0.11 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> select * from t1 limit 3;
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 1 | 997 | 808 | 1805 |
| 2 | 51 | 831 | 882 |
| 3 | 998 | 499 | 1497 |
+----+------+------+------+
3 rows in set (0.00 sec)
That was fast — the table definition is changed, but because the column is VIRTUAL
, no data values need to be changed. Instead, the data is calculated on read access. We could have written our sample read as SELECT id, a, b, a+b AS c FROM t1 LIMIT 3
for the same effect, because that is what happened.
We may even store that statement in a view and then call it, and that’s effectively the same:
mysql> create view v1 as select id, a, b, a+b as c from t1;
Query OK, 0 rows affected (0.03 sec)
mysql> select * from v1 limit 3;
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 1 | 997 | 808 | 1805 |
| 2 | 51 | 831 | 882 |
| 3 | 998 | 499 | 1497 |
+----+------+------+------+
3 rows in set (0.00 sec)
Well, not quite. Let’s explain the same query on t1
and v1
and see what the optimizer has to say:
mysql> explain select * from t1 where c<50G
id: 1
select_type: SIMPLE
table: t1
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1046904
filtered: 33.33
Extra: Using where
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select `kris`.`t1`.`id` AS `id`,`kris`.`t1`.`a` AS `a`,`kris`.`t1`.`b` AS `b`,`kris`.`t1`.`c` AS `c` from `kris`.`t1` where (`kris`.`t1`.`c` < 50)
mysql> explain select * from v1 where c<50G
id: 1
select_type: SIMPLE
table: t1
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1046904
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select `kris`.`t1`.`id` AS `id`,`kris`.`t1`.`a` AS `a`,`kris`.`t1`.`b` AS `b`,(`kris`.`t1`.`a` + `kris`.`t1`.`b`) AS `c` from `kris`.`t1` where ((`kris`.`t1`.`a` + `kris`.`t1`.`b`) < 50)
The output differs slightly in two places: the estimate given for filtered is different, and the view “sees” and exposes the definition for c
as a+b
in the reparsed statement in the “Note” section.
Further down we will also see how the generated column can be indexed, while the statement expression can not — and that in the end what makes the key difference in performance.
STORED generated columns
Let’s flip from VIRTUAL
to STORED
and see what happens. We drop the old definition of c
, and re-add the same one, but with a STORED
attribute.
mysql> alter table t1 drop column c, add column c integer as (a+b) stored;
Query OK, 1048576 rows affected (6.27 sec)
Records: 1048576 Duplicates: 0 Warnings: 0
If we looked at the average row length in INFORMATION_SCHEMA.TABLES
, we would see it as a bit longer (but as is usual with I_S.TABLES output for small and narrow tables, the values are a bit off).
We also see the ALTER TABLE
now takes actual time, proportional to the table size. What happened is that the values for c
now get materialized on write, as if we defined a BEFORE INSERT
trigger maintaining the values in c
.
Trying to write to a generated column fails (except when it doesn’t)
VIRTUAL
and STORED
don’t matter: you can’t write to generated columns:
mysql> update t1 set c = 17 where id = 3;
ERROR 3105 (HY000): The value specified for generated column 'c' in table 't1' is not allowed.
mysql> replace into t1 set id=3, c=17;
ERROR 3105 (HY000): The value specified for generated column 'c' in table 't1' is not allowed.
With one exception:
mysql> update t1 set c=default where id = 3;
Query OK, 0 rows affected (0.00 sec)
Rows matched: 1 Changed: 0 Warnings: 0
So if you aren’t actually writing to c
, you are allowed to write to c
. That sounds stupid until you define a view on t1
that includes c
and is considered updatable — by allowing this construct, it stays updatable, even if it includes c
.
Filling in the correct value is not the same as default
and does not work:
mysql> select * from t1 limit 1;
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 1 | 997 | 808 | 1805 |
+----+------+------+------+
1 row in set (0.00 sec)
mysql> update t1 set c=1805 where id=1;
ERROR 3105 (HY000): The value specified for generated column 'c' in table 't1' is not allowed.
Caution: CREATE TABLE … AS SELECT vs. generated columns
We already know (I hope) that CREATE TABLE ... AS SELECT
is of the devil and should not be used to copy table definitions: It creates a table from the result set of the select statement, which is most definitively not the definition of the original table.
We have seen this fail already with indexes and foreign key definitions, and in case you didn’t, here is what I mean:
mysql> create table sane ( id integer not null primary key auto_increment, t1id integer, foreign key (t1id) references t1(i
d) );
Query OK, 0 rows affected (0.06 sec)
mysql> show create table saneG
Table: sane
Create Table: CREATE TABLE `sane` (
`id` int NOT NULL AUTO_INCREMENT,
`t1id` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `t1id` (`t1id`),
CONSTRAINT `sane_ibfk_1` FOREIGN KEY (`t1id`) REFERENCES `t1` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
mysql> insert into sane values (1, 1), (2, 2), (3, 3), (4, 4);
Query OK, 4 rows affected (0.01 sec)
Records: 4 Duplicates: 0 Warnings: 0
mysql> create table broken as select * from sane;
Query OK, 4 rows affected (0.07 sec)
Records: 4 Duplicates: 0 Warnings: 0
mysql> show create table brokenG
Table: broken
Create Table: CREATE TABLE `broken` (
`id` int NOT NULL DEFAULT '0',
`t1id` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.01 sec)
broken
is most decidedly not the same table as sane
. The definition of broken
has been inferred from the format of the result set, which may or may not have the same types as the base table(s). It also has no indexes and no constraints.
The correct way to copy a table definition is CREATE TABLE ... LIKE ...
and then move the data with INSERT ... SELECT ...
. You still have to move the foreign key constraints manually, though:
mysql> create table unbroken like sane;
Query OK, 0 rows affected (0.10 sec)
mysql> insert into unbroken select * from sane;
Query OK, 4 rows affected (0.01 sec)
Records: 4 Duplicates: 0 Warnings: 0
mysql> show create table unbrokenG
Table: unbroken
Create Table: CREATE TABLE `unbroken` (
`id` int NOT NULL AUTO_INCREMENT,
`t1id` int DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `t1id` (`t1id`)
) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
And here is how it works with generated columns:
mysql> create table t2 as select * from t1;
Query OK, 1048576 rows affected (14.89 sec)
Records: 1048576 Duplicates: 0 Warnings: 0
mysql> show create table t2G
Table: t2
Create Table: CREATE TABLE `t2` (
`id` int NOT NULL DEFAULT '0',
`a` int DEFAULT NULL,
`b` int DEFAULT NULL,
`c` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
CREATE TABLE ... AS SELECT ...
defined a table from the result set of the select clause, and the fact that c
is generated is completely lost. So we now have a normal 4-column table.
So, how about CREATE TABLE ... LIKE ...
?
mysql> drop table t2;
Query OK, 0 rows affected (0.08 sec)
mysql> create table t2 like t1;
Query OK, 0 rows affected (0.10 sec)
mysql> show create table t2G
Table: t2
Create Table: CREATE TABLE `t2` (
`id` int NOT NULL AUTO_INCREMENT,
`a` int DEFAULT NULL,
`b` int DEFAULT NULL,
`c` int GENERATED ALWAYS AS ((`a` + `b`)) STORED,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
Yes! Success! Ok, now the data:
mysql> insert into t2 select * from t1;
ERROR 3105 (HY000): The value specified for generated column 'c' in table 't2' is not allowed.
Oh, right.
mysql> insert into t2 select id, a, b from t1;
ERROR 1136 (21S01): Column count doesn't match value count at row 1
Aww, yes. Okay, the full monty:
mysql> insert into t2 (id, a, b) select id, a, b from t1;
Finally.
Ok, copying data between tables with generated columns requires a bit more engineering than a mindless INSERT ... SELECT *
. The rules are not unexpected, we have explored them right above, still…
The wrong data type
Ok, let’s get a bit mean. What happens when we define c tinyint as (a+b) virtual
so that the values exceed the range possible in a signed single bit value?
mysql> select * from t1 limit 3;
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 1 | 997 | 808 | 1805 |
| 2 | 51 | 831 | 882 |
| 3 | 998 | 499 | 1497 |
+----+------+------+------+
3 rows in set (0.00 sec)
mysql> alter table t1 drop column c, add column c tinyint as (a+b) virtual;
ERROR 1264 (22003): Out of range value for column 'c' at row 1
Oh, they are on to us!?!? Are they?
They are not when we do it in two steps:
mysql> alter table t1 drop column c;
Query OK, 0 rows affected (9.24 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> alter table t1 add column c tinyint as (a+b) virtual;
Query OK, 0 rows affected (0.08 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> select * from t1 limit 3;
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 1 | 997 | 808 | 127 |
| 2 | 51 | 831 | 127 |
| 3 | 998 | 499 | 127 |
+----+------+------+------+
3 rows in set (0.00 sec)
It clips the values according to the rules that MySQL always had, and that ate so much data.
Now, let’s CREATE TABLE ... AS SELECT
again:
mysql> drop table broken;
Query OK, 0 rows affected (0.07 sec)
mysql> create table broken as select * from t1;
ERROR 1264 (22003): Out of range value for column 'c' at row 2
Error (Code 1264): Out of range value for column 'c' at row 2
Error (Code 1030): Got error 1 - 'Operation not permitted' from storage engine
Wow. No less than three error messages. At least they mention the column c
and the word “range”, so we kind of can have an idea what goes on. Still, this is only medium helpful and initially confusing.
What happens, and why?
mysql> select @@sql_mode;
+--------------------------------------------+
| @@sql_mode |
+--------------------------------------------+
| STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION |
+--------------------------------------------+
1 row in set (0.00 sec)
mysql> set sql_mode = "";
Query OK, 0 rows affected (0.00 sec)
mysql> create table broken as select * from t1;
...
Warning (Code 1264): Out of range value for column 'c' at row 1028
Warning (Code 1264): Out of range value for column 'c' at row 1029
Warning (Code 1264): Out of range value for column 'c' at row 1030
SQL_MODE
helpfully detected the problem and prevented data loss. As usual, SQL_MODE
was as useless as it was helpful — while it prevented data loss, it did not directly point us into the right direction with its error messages.
By turning off SQL_MODE
we get the clipped values copied and a bunch of warnings that everybody ignores all the time, anyway, so I guess it’s an improvement.
Allowed and disallowed functions
For generated columns to work it is a requirement that the functions are deterministic, idempotent and side effect free. All user defined functions and stored functions are disallowed, and the usual suspects from the set of builtins are also out:
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (sleep(2)));
ERROR 3763 (HY000): Expression of generated column 'c' contains a disallowed function: sleep.
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (uuid()));
ERROR 3763 (HY000): Expression of generated column 'c' contains a disallowed function: uuid.
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (rand()));
ERROR 3763 (HY000): Expression of generated column 'c' contains a disallowed function: rand.
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (now()));
ERROR 3763 (HY000): Expression of generated column 'c' contains a disallowed function: now.
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (connection_id()));
ERROR 3763 (HY000): Expression of generated column 'c' contains a disallowed function: connection_id.
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (last_insert_id()));
ERROR 3763 (HY000): Expression of generated column 'c' contains a disallowed function: last_insert_id.
mysql> set @c := 1;
Query OK, 0 rows affected (0.00 sec)
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (@c));
ERROR 3772 (HY000): Default value expression of column 'c' cannot refer user or system variables.
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (id));
ERROR 3109 (HY000): Generated column 'c' cannot refer to auto-increment column.
mysql> create table testme (id integer not null primary key auto_increment, a integer, b integer, c integer as (a));
Query OK, 0 rows affected (0.09 sec)
mysql> alter table testme change column a x integer;
ERROR 3108 (HY000): Column 'a' has a generated column dependency.
mysql> alter table testme drop column c, change column a x integer, add column c integer as (x);
Query OK, 0 rows affected (0.21 sec)
Records: 0 Duplicates: 0 Warnings: 0
From the final example above we learn that it is also impossible to change the existing definition of any column that is used by a generated column definition. We need to drop the generated column, change the definition of the base columns and then recreate the generated column.
For VIRTUAL
columns that is cheap, for STORED
— less so.
Secondary indexes and generated columns
So far, so nice. Now let’s cash in on this: Indexes, we have them. At least secondary indexes:
mysql> create table wtf ( b integer not null, id integer as (b) not null primary key);
ERROR 3106 (HY000): 'Defining a virtual generated column as primary key' is not supported for generated columns.
mysql> show create table t1G
Table: t1
Create Table: CREATE TABLE `t1` (
`id` int NOT NULL AUTO_INCREMENT,
`a` int DEFAULT NULL,
`b` int DEFAULT NULL,
`c` int GENERATED ALWAYS AS ((`a` + `b`)) VIRTUAL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1376221 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
mysql> alter table t1 add index(c);
Query OK, 0 rows affected (5.62 sec)
Records: 0 Duplicates: 0 Warnings: 0
As expected, adding the index takes time, even if the column c
is VIRTUAL
: For an index we extract the indexed values from the table, sort them and store them together with pointers to the base row in the (secondary) index tree. In InnoDB, the pointer to the base row is always the primary key, so what we get in the index is actually pairs of (c, id)
.
We can prove that:
- Queries for
c
can be answered from the index. The index is called covering, it saves us chasing the row pointer and access to the actual row. In anEXPLAIN
we see this being indicated withusing index
. - Queries for
c
andid
should also be covering: the queried values are all present in the index so that going to the base row is unnecessary. - Querying for
c
anda
is not covering, so theusing index
should be gone.
And indeed:
mysql> explain select c from t1 where c < 50G
...
possible_keys: c
key: c
...
rows: 1257
Extra: Using where; Using index
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select `kris`.`t1`.`c` AS `c` from `kris`.`t1` where (`kris`.`t1`.`c` < 50)
mysql> explain select c, id from t1 where c < 50G
key: c
...
Extra: Using where; Using index
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select `kris`.`t1`.`c` AS `c`,`kris`.`t1`.`id` AS `id` from `kris`.`t1` where (`kris`.`t1`.`c` < 50)
mysql> explain select c, a from t1 where c < 50G
...
possible_keys: c
key: c
...
Extra: Using where
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select `kris`.`t1`.`id` AS `id`,`kris`.`t1`.`a` AS `a`,`kris`.`t1`.`b` AS `b`,`kris`.`t1`.`c` AS `c` from `kris`.`t1` where (`kris`.`t1`.`c` < 50)
As predicted, the final query for c
, a
cannot be covering and is missing the using index
notice in the Extra column. This is still a good query: it is considering and using the index on c
— the index alone is just not sufficient to resolve the query.
This should give us an idea about how to design:
In almost all cases STORED
columns will not be paying off. They use disk space, and still need to evaluate the expression at least once for storage. If indexed, they will use disk space in the index a second time — the column is actually materialized twice, in the table in primary key order and the index in index order.
STORED
generated columns make sense only if the expression is complicated and slow to calculate. But with the set of functions available to us that is hardly going to be the case, ever. So unless the expression is being evaluated really often the cost for the storage is not ever amortized.
Even then, for generated columns STORED
and VIRTUAL
, many queries can probably be answered leveraging an index on the generated column so that we might try to get away with VIRTUAL
columns all the time.
Generated columns and the Optimizer
The optimizer is aware of the generated column definitions, and can leverage them, as long as they match:
mysql> show create table t1G
Table: t1
Create Table: CREATE TABLE `t1` (
`id` int NOT NULL AUTO_INCREMENT,
`a` int DEFAULT NULL,
`b` int DEFAULT NULL,
`c` int GENERATED ALWAYS AS ((`a` + `b`)) VIRTUAL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB AUTO_INCREMENT=1376221 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
mysql> explain select a+b from t1 where a+b<50G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
partitions: NULL
type: range
possible_keys: c
key: c
key_len: 5
ref: NULL
rows: 1257
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select (`kris`.`t1`.`a` + `kris`.`t1`.`b`) AS `a+b` from `kris`.`t1` where (`kris`.`t1`.`c` < 50)
The optimizer is still the MySQL optimizer we all love to hate, so you have to be pretty literal for the match:
mysql> explain select b+a from t1 where b+a<50G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t1
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 1046422
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select (`kris`.`t1`.`b` + `kris`.`t1`.`a`) AS `b+a` from `kris`.`t1` where ((`kris`.`t1`.`b` + `kris`.`t1`.`a`) < 50)
Yup, no canonicalization, for reasons.
Making it work with JSON
That’s a long article. Do you still remember how we started?
JSON cannot be indexed.
Well, now it can and you know how.
mysql> show create table tG
Table: t
Create Table: CREATE TABLE `t` (
`id` int NOT NULL AUTO_INCREMENT,
`j` json DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
mysql> select * from t;
+----+-------------------------------------------------------+
| id | j |
+----+-------------------------------------------------------+
| 1 | {"home": "/home/kris", "paid": false, "user": "kris"} |
| 2 | {"home": "/home/sven", "paid": false, "user": "sven"} |
| 3 | false |
+----+-------------------------------------------------------+
3 rows in set (0.00 sec)
mysql> alter table t add column user varchar(80) as (j->'$.user') virtual, add index (user);
Query OK, 0 rows affected (0.10 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> select user, id from t;
+--------+----+
| user | id |
+--------+----+
| NULL | 3 |
| "kris" | 1 |
| "sven" | 2 |
+--------+----+
3 rows in set (0.00 sec)
mysql> explain select id, j from t where id = 1G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: t
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)
Note (Code 1003): /* select#1 */ select '1' AS `id`,'{"home": "/home/kris", "paid": false, "user": "kris"}' AS `j` from `kris`.`t` where true
Yay, ref: const
, primary key lookup in the optimizer, and we did not even have a query to run.
Summary
We have been looking at the two flavors of generated columns, and how they can make our life easier in many ways. We have been looking at various pitfalls with respect to copying data and table definitions around. We have been learning about indexing generated columns, and how the optimizer can leverage indexes even against the expressions defined in generated columns.
Finally, we put the parts together and made JSON data lookups fast.
This should give us a number of ideas in terms of sensible table design around JSON. Often we use JSON for variable-ish data while we explore a data model. Then a JSON schema solidifies, and we can leverage values we require and rely on by putting them into generated columns and index these, then use these for search and access.
Eventually we may extract the columns from the variable JSON part of the schema completely and turn them into actually statically typed columns of the SQL schema, because we require them all the time.
These open up a pathway to incremental schema design while at the same time being flexible enough to have bag style soft and denormalized data types where we need them.
The Fine Manual
There is a lot more to all of this than I can show here. This means you have homework. Read the following manual pages:
-
CREATE TABLE and Generated Columns
The basics in a single page.
-
Secondary Indexes and Generated Columns
Indexing generated columns, with special considerations on indexing JSON
-
Optimizer Use of Generated Column Indexes
We all love to hate the optimizer, but it has learned a lot of new tricks. Here’s what it does understand.
-
The CREATE INDEX statement and multi valued indexes
The entire page is useful, because it speaks about functional indexes and how they are implemented as hidden virtual columns and indexes on these (which has implications). But within the discussion of JSON, the interesting part are Multi-Valued Indexes, which are indexes on non-scalar values such as JSON arrays, and how they are being used to speed up certain JSON functions that deal with array membership and overlaps.
-
Type:
Bug
-
Status:
Done -
Priority:
Medium
-
Resolution:
Fixed
-
Affects Version/s:
8.0.x
While adding checks to pt-online-schema-change I tried to create a table as follows:
CREATE TABLE `test`.`t1` ( `ID` int(11) NOT NULL, `Column2` int(11) DEFAULT NULL, `Column3` int(11) GENERATED ALWAYS AS ((`Column2` + 1)) STORED, PRIMARY KEY (`ID`) ) ENGINE=RocksDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
and I am getting this error:
ERROR 3106 (HY000): 'Specified storage engine' is not supported for generated columns.
Even if generated columns is a MySQL 5.7+/InnoDB specific feature, I think it worth mentioning it in the MyRocks limitations page at https://www.percona.com/doc/percona-server/LATEST/myrocks/limitations.html
Александр Рубин работает в компании Percona и не единожды выступал на HighLoad++, знаком участникам как эксперт в MySQL. Логично предположить, что и сегодня речь пойдет про что-то, связанное с MySQL. Это так, но лишь отчасти, потому что еще мы поговорим про интернет вещей. Рассказ будет наполовину развлекательный, особенно первая его часть, в которой посмотрим на девайс, который Александр создал, чтобы собрать урожай абрикосов. Такова уж натура настоящего инженера — хочешь фруктов, а покупаешь плату.
Предыстория
Началось все с простого желания посадить фруктовое дерево на своем участке. Сделать это, казалось бы, очень просто — приходишь в магазин и покупаешь саженец. Но в Америке первый вопрос, который задают продавцы, это сколько дерево получит солнечного света. Для Александра это оказалось гигантской загадкой — совершенно неизвестно, сколько солнечного света на участке.
Чтобы это узнать, школьник мог бы каждый день выходить во двор, смотреть, сколько солнечного света, и записывать это в блокнотик. Но это не дело — надо все оснастить оборудованием и автоматизировать.
В ходе выступления многие примеры запускались и воспроизводились в прямом эфире. Хотите более полную картину, чем в тексте, переключайтесь на просмотр видео.
Итак, чтобы не записывать наблюдения о погоде в блокнотик, есть большое количество устройств для интернет-вещей — Raspberry Pi, новый Raspberry Pi, Arduino — тысячи разных платформ. Но я выбрал для этого проекта устройство, которое называется Particle Photon. Оно очень просто в использовании, стоит 19 $ на официальном сайте.
В Particle Photon хорошо то, что это:
- 100% облачное решение;
- подходят любые датчики, например, для Arduino. Они все стоят меньше доллара.
Я сделал такой девайс и положил его в траву на участке. В нем есть Particle Device Cloud и консоль. Этот приборчик подключается через Wi-Fi hotspot и посылает данные: освещенность, температуру и влажность. Приборчик продержался 24 часа на маленькой батарейке, что достаточно неплохо.
Дальше мне нужно не только измерять освещенность и прочее и передавать их на телефон (что на самом деле хорошо — я могу в режиме реального времени видеть, какая у меня освещенность), но и хранить данные. Для этого, естественно, как ветеран MySQL, я выбрал MySQL.
Как мы записываем данные в MySQL
Я выбрал достаточно сложную схему:
- получаю данные из Particle-консоли;
- использую Node.js, чтобы записать их в MySQL.
Я использую Particle API JS, который можно скачать с сайта Particle. Устанавливаю соединение с MySQL и записываю, то есть просто делаю INSERT INTO values. Такой вот pipeline.
Таким образом, девайс лежит во дворе, подсоединяется по Wi-Fi к домашнему роутеру и с помощью протокола MQTT передает данные в Particle. Дальше та самая схема: на виртуальной машине работает программка на Node.js, которая получает данные от Particle и записывает их в MySQL.
Для начала я построил графики из сырых данных в R. На графиках видно, что температура и освещенность днем поднимаются, к ночи падают, а влажность повышается — это естественно. Но также на графике есть шум, это типично для приборов интернета вещей. Например, когда на устройство залез жучок и закрыл его, датчик может передать совершенно нерелевантные данные. Это будет важно при дальнейшем рассмотрении.
Сейчас поговорим про MySQL и JSON, что изменилось в работе с JSON с MySQL 5.7 в MySQL 8. Потом я покажу демо, для которого использую MySQL 8 (на момент доклада эта версия еще не была готова для продакшена, сейчас уже выпущен стабильный релиз).
Хранение данных в MySQL
Когда мы пытаемся хранить данные, полученные с датчиков, наша первая мысль — создать таблицу в MySQL:
CREATE TABLE 'sensor_wide' (
'id' int (11) NOT NULL AUTO_INCREMENT,
'light' int (11) DEFAULT NULL,
'temp' double DEFAULT NULL,
'humidity' double DEFAULT NULL,
PRIMARY KEY ('id')
) ENGINE=InnoDB
Здесь для каждого датчика и для каждого типа данных есть своя колонка: light, temperature, humidity.
Это достаточно логично, но есть проблема — это не гибко. Допустим, мы захотим добавить еще один датчик и измерять что-то еще. Например, некоторые люди измеряют остаток пива в кеге. Что делать в этом случае?
alter table sensor_wide
add water level double ...;
Как извратиться, чтобы в таблицу что-то добавить? Нужно сделать alter table, но если вы делали alter table в MySQL, то знаете, о чем я говорю, — это совершенно непросто. Alter table в MySQL 8 и в MariaDB реализовано намного проще, но исторически это большая проблема. Так что если нам нужно добавить колонку, например, с названием пива, то это будет не так-то просто.
Опять же датчики появляются, исчезают, что нам делать со старыми данными? Например, мы прекращаем получать информацию про освещенность. Или мы создаем новую колонку — как хранить то, чего там до этого не было? Стандартный подход — это null, но для анализа это будет не очень удобно.
Еще один вариант — это key/value store.
Хранение данных в MySQL: key/value
Это будет более гибко: в key/value будет название, например, temperature и соответственно данные.
CREATE TABLE 'cloud_data' (
'id' int (11) NOT NULL AUTO_INCREMENT,
'name' varchar(255) DEFAULT NULL,
'data' text DEFAULT NULL,
'updated_at' timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
PRIMARY KEY ('id')
) ENGINE=InnoDB
В этом случае появляется другая проблема — нет типов. Мы не знаем, что мы храним в поле ‘data’. Нам придётся его объявить полем text. Когда я создаю свой девайс интернета вещей, я знаю, какой там датчик и соответственно тип, но если понадобится хранить в той же таблице еще чьи-то данные, то я не буду знать, какие данные собираются.
Можно хранить много таблиц, но создавать одну целую новую таблицу на каждый датчик — не очень-то хорошо.
Что можно сделать? — Использовать JSON.
Хранение данных в MySQL: JSON
Хорошая новость в том, что в MySQL 5.7 можно хранить JSON как поле.
CREATE TABLE 'cloud_data_json' (
'id' int (11) NOT NULL AUTO_INCREMENT,
'name' varchar(255) DEFAULT NULL,
'data' JSON,
'updated_at' timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
PRIMARY KEY ('id')
) ENGINE=InnoDB;
До того, как появился MySQL 5.7, люди тоже хранили JSON, но как поле text. Поле JSON в MySQL позволяет хранить сам JSON наиболее эффективно. Кроме того, на основе JSON можно создать виртуальные колонки и на их основе индексы.
Единственная небольшая проблема — при хранении таблица возрастет в размере. Но зато мы получаем намного большую гибкость.
Поле JSON лучше для хранения JSON, чем поле text, потому что:
- Предоставляет автоматическую валидация документа. То есть если мы попытаемся туда записать что-то не валидное, выпадет ошибка.
- Это оптимизированный формат хранения. JSON хранится в бинарном формате, что позволяет переходить от одного документа JSON к другому — то, что называется skip.
Чтобы хранить данные в JSON, мы можем просто использовать SQL: сделать INSERT, поместить туда ‘data’ и получить данные с девайса.
…
stream.on('event', function(data) {
var query = connection.query(
'INSERT INTO cloud_data_json (client_name, data)
VALUES (?, ?)',
['particle', JSON.stringify(data)]
)
…
(demo)
Демо
Для демонстрации (здесь её начало на видео) примера используется виртуальная машина, в которой есть SQL.
Ниже фрагмент программы.
Я делаю INSERT INTO cloud_data (name, data)
, получаю данные уже в формате JSON, и могу их прямо записать в MySQL, как есть, совершенно не думая о том, что там внутри.
Как выяснилось, с помощью этого cloud можно получать доступ не только к данным моего устройства, но вообще ко всем данным, которые использует этот самый Particle. Кажется, это работает до сих пор. Люди, которые по всему миру используют Particle Photon, посылают какие-то данные: открыта дверь в гараже, или остаток пива такой-то, или что-то еще. Неизвестно, где эти девайсы находятся, но можно получить такие данные. Разница только в том, что, когда я получаю свои данные, я пишу что-то типа: deviceId: 'mine'
.
При запуске кода мы получаем поток каких-то данных от чьих-то девайсов, которые что-то делают.
Мы совершенно не знаем, что это за данные: TTL, published_at, coreid, door status (дверь открыта), relay on.
Это прекрасный пример. Допустим, я попытаюсь положить это в MySQL в нормальную структуру данных. Я должен знать, что там за дверь, почему она открыта и какие вообще параметры может принимать. Если у меня есть JSON, то я записываю это прямо в MySQL в виде JSON-поля.
Пожалуйста, все записалось.
Document store
Document store — это попытка в MySQL сделать хранилище для JSON. Я очень люблю SQL, хорошо с ним знаком, могу сделать любой SQL-запрос и т.д. Но многие не любят SQL по разным причинам, и Document store может стать для них решением, потому с его помощью можно абстрагироваться от SQL, подключиться к MySQL и прямо туда записывать JSON.
Есть еще одна возможность, которая появилась в MySQL 5.7: использовать другой протокол, другой порт, также нужен и другой драйвер. Для Node.js (на самом деле для любых языков программирования — PHP, Java и пр.) мы подключаемся к MySQL по другому протоколу и можем передавать данные в формате JSON. Опять же я не знаю, что у меня в этом JSON — информация про двери или что-то еще, просто данные в MySQL сбрасываю, а что там, разберемся потом.
const mysqlx = require('@mysql/xdevapi*);
// MySQL Connection
var mySession = mysqlx.gctSession({
host: 'localhost', port: 33060, dbUser: 'photon*
});
…
session.getSchema("particle").getCollection("cloud_data_docstore")
.add( data )
.execute(function (row) {
}).catch(err => {
console.log(err);
})
.then( -Function (notices) {
console.log("Wrote to MySQL")
});
...https://dev.mysql.com/doc/dev/connector-nodejs/
Если хотите с этим поэкспериментировать, можно сконфигурировать MySQL 5.7 на то, чтобы он понимал и слушал на соответствующем порту Document store или X DevAPI. Я использовал connector-nodejs.
Это пример того, что я туда записываю: пиво и пр. Я совершенно не знаю, что там. Сейчас просто запишем, а проанализируем потом.
Следующий пункт нашей программы — как посмотреть, что там?
Хранение данных в MySQL: JSON + индексы
В JSON и MySQL 5.7 есть отличная функция, которая может вытащить поля из JSON. Это такой синтаксический сахар на функцию JSON_EXTRACT. Мне кажется, это очень удобно.
Data в нашем случае — название колонки, в которой хранится JSON, а name — это наше поле. Name, data, published_at — это все мы таким образом можем вытащить.
select data->>'$.name' as data_name,
data->>'$.data' as data,
data->>'$.published_at' as published
from cloud_data_json
order by data->'$.published_at' desc
limit 10;
В этом примере я хочу посмотреть, что у меня записалось в таблицу MySQL, и 10 последних записей. Я делаю такой запрос и пытаюсь его выполнить. К сожалению, это будет работать очень долго.
Логичным образом MySQL в данном случае не будет использовать никаких индексов. Мы вытаскиваем данные из JSON и пытаемся применить какие-то фильтры и сортировку. В этом случае у нас получится Using filesort.
EXPLAIN select data->>'$.name' as data_name ...
order by data->>'$.published_at' desc limit 10
select_type: SIMPLE
table: cloud_data_json
possible_keys: NULL
key: NULL
…
rows: 101589
filtered: 100.00
Extra: Using filesort
Using filesort — это очень плохо, это внешняя сортировка.
Хорошая новость в том, что можно сделать 2 шага, чтобы это ускорить.
Шаг 1. Создание виртуальной колонки
mysql> ALTER TABLE cloud_data_json
-> ADD published_at DATETIME(6)
-> GENERATED ALWAYS AS
(STR_TO_DATE(data->>'$.published_at',"%Y-%m-%dT%T.%fZ")) VIRTUAL;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
Я делаю EXTRACT, то есть вытаскиваю данные из JSON и на его основе создаю виртуальную колонку. Виртуальная колонка в MySQL 5.7 и в MySQL 8 не хранится — это просто возможность создать отдельную колонку.
Вы спросите, как же так, ты же говорил, что ALTER TABLE — это такая долгая операция. Но здесь все не так плохо. Создание виртуальной колонки происходит быстро. Там есть loсk, но на самом деле в MySQL есть lock на всех DDL-операциях. ALTER TABLE — достаточно быстрая операция, и она не перестраивает всю таблицу.
Мы здесь создали виртуальную колонку. Мне пришлось сконвертировать дату, потому что в JSON она хранится в формате iso, а здесь MySQL использует совершенно другой формат. Для создания колонки я назвал ее, дал ей тип и сказал, что буду туда записывать.
Для оптимизации исходного запроса нужно вытащить published_at и name. Published_at уже есть, name проще — просто делаем виртуальную колонку.
mysql> ALTER TABLE cloud_data_json
-> ADD data_name VARCHAR(255)
-> GENERATED ALWAYS AS (data->>'$.name') VIRTUAL;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
Шаг 2. Создание индекса
В коде ниже я создаю индекс на published_at и выполняю запрос:
mysql> alter table cloud_data_json add key (published_at);
Query OK, 0 rows affected (0.31 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> explain select data_name, published_at, data->>'$.data' as data from
cloud_data_json order by published_at desc limit 10G
table: cloud_data_json
type: index
possible_keys: NULL
key: published_at
key_len: 9
rows: 10
filtered: 100.00
Extra: Backward index scan
Видно, что на самом деле MySQL использует индекс. Это оптимизация order by. В данном примере data и name не индексируются. MySQL использует order by data, и так как у нас есть индекс на published_at, то он его и использует.
Более того, я бы мог в order by вместо published_at использовать тот же самый синтаксический сахар STR_TO_DATE(data->>'$.published_at',"%Y-%m-%dT%T.%fZ")
. MySQL бы все равно понял, что есть индекс на эту колонку и начал бы его использовать.
С этим на самом деле есть небольшая проблемка. Допустим, я хочу отсортировать данные не только по published_at, но еще и по названию.
mysql> explain select data_name, published_at, data->>'$.data' as data from
cloud_data_json order by published_at desc, data_name asc limit 10G
select_type: SIMPLE
table: cloud_data_json
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 101589
filtered: 100.00
Extra: Using filesort
Если ваше устройство обрабатывает десятки тысяч событий в секунду, published_at не даст хорошей сортировки, так как будут дубликаты. Поэтому мы добавляем еще одну сортировку по data_name. Это типичный запрос не только для интернета вещей: дайте мне 10 последних событий, но отсортируйте их по дате, а потом, например, по фамилии человека по возрастанию. Для этого в примере выше есть два поля и указаны два ключа сортировки: descending и ascending.
Прежде всего в этом случае MySQL не будет использовать индексы. В данном конкретном случае MySQL решает, что полный скан таблицы будет выгоднее, чем использование индекса, и опять используется очень медленная операция filesort.
New in MySQL 8.0
descending/ascending
В MySQL 5.7 такой запрос оптимизировать нельзя, если только за счет других вещей. В MySQL 8 появилась реальная возможность указывать сортировку для каждого поля.
mysql> alter table cloud_data_json
add key published_at_data_name
(published_at desc, data_name asc);
Query OK, 0 rows affected (0.44 sec)
Records: 0 Duplicates: 0 Warnings: 0
Самое интересное, что ключ descending/ascending после названия индекса давно был в SQL. Даже в самой первой версии MySQL 3.23 можно было указать published_at descending или published_at ascending. MySQL это принимал, но ничего не делал, то есть сортировал всегда в одном направлении.
В MySQL 8 это поправили и теперь такая фича есть. Можно создать поле с сортировкой по убыванию и с сортировкой по умолчанию.
Вернемся на секунду назад и посмотрим на пример из шага 2 еще раз.
Почему это работает, а то — нет? Это работает потому, что в MySQL-индексы — это B-tree, а индексы B-tree можно читать и с начала, и с конца. В данном случае MySQL читает индекс с конца и все хорошо. Но если мы делаем descending и ascending, то прочитать нельзя. Можно прочитать в одном порядке, но совместить две сортировки нельзя — нужно пересортировать.
Так как мы оптимизируем совершенно конкретный случай, то можем для него создать индекс и указать конкретную сортировку: здесь published_at — descending, data_name — ascending. MySQL использует этот индекс, и все будет хорошо и быстро.
mysql> explain select data_name, published_at, data->>'$.data' as data from
cloud_data_json order by published_at desc limit 10G
select_type: SIMPLE
table: cloud_data_json
partitions: NULL
type: index
possible_keys: NULL
key: published_at_data_name
key_len: 267
ref: NULL
rows: 10
filtered: 100.00
Extra: NULL
Это фича MySQL 8, который сейчас, на момент публикации уже доступен и готов для использования в продакшене.
Вывод результатов
Еще есть две интересные штуки, которые я хочу показать:
1. Pretty print, то есть красивый вывод данных на экран. При обычном SELECT JSON будет не форматирован.
mysql> select json_pretty(data) from cloud_data_json
where data->>'$.data' like '%beer%' limit 1G
…
json_pretty(data): {
"ttl": 60,
"data": "FvGav,tagkey=beer-store spFridge=7.00,pvFridge=7.44",
"name": "LOG_DATA_DEBUG",
"coreid": "3600....",
"published_at": "2017-09-28T18:21:16.517Z"
}
2. Можно сказать, чтобы MySQL вывел результат в виде JSON array или JSON object, указать поля, и тогда вывод будет форматирован в виде JSON.
Полнотекстовый поиск внутри документов JSON
Если мы используем гибкую систему хранения и не знаем, что внутри нашего JSON, то было бы логично использовать полнотекстовый поиск.
К сожалению, полнотекстовый поиск имеет свои ограничения. Первое, что я попробовал — это просто создать полнотекстовый ключ. Я попытался сделать такую штуку:
mysql> alter table cloud_data_json_indexes add fulltext key (data);
ERROR 3152 (42000): JSON column ’data’ supports indexing only via generated columns on a specified ISON path.
К сожалению, это не работает. Даже в MySQL 8 создать полнотекстовый индекс просто по полю JSON, к сожалению, невозможно. Я бы конечно хотел иметь такую функцию — возможность поиска хотя бы по ключам JSON была бы очень полезна.
Но если это пока невозможно, давайте создадим виртуальную колонку. В нашем случае есть поле data, и нам интересно было бы посмотреть, что там внутри.
mysql> ALTER TABLE cloud_data_json_indexes
-> ADD data_data VARCHAR(255)
-> GENERATED ALWAYS AS (data->>'$.data') VIRTUAL;
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> alter table cloud_data_json_indexes add fulltext key ft_json(data_name, data_data);
ERROR 3106 (HY000): 'Fulltext index on virtual generated column' is not supported for generated columns.
К сожалению, это тоже не работает — на виртуальной колонке нельзя создать полнотекстовый индекс.
Раз так, давайте создадим хранимую колонку. MySQL 5.7 позволяет объявить колонку хранимым полем.
mysql> ALTER TABLE cloud_data_json_indexes
-> ADD data_name VARCHAR(255) CHARACTER SET UTF8MB4
-> GENERATED ALWAYS AS (data->>'$.name') STORED;
Query OK, 123518 rows affected (1.75 sec)
Records: 123518 Duplicates: 0 Warnings: 0
mysql> alter table cloud_data_json_indexes add fulltext key ft_json(data_name);
Query OK, 0 rows affected, 1 warning (3.78 sec)
Records: 0 Duplicates: 0 Warnings: 1
mysql> show warnings;
+------------+--------+---------------------------------------------------+
| Level | Code | Message |
+------------+--------+---------------------------------------------------+
| Warning | 124 | InnoDB rebuilding table to add column FTS_DOC_ID |
+------------+--------+---------------------------------------------------+
В предыдущих примерах мы создавали виртуальные колонки, которые не хранятся, но индексы создаются и хранятся. В данном случае мне пришлось сказать MySQL, что это колонка STORED, то есть она будет создана и данные в нее будут скопированы. После этого MySQL создал полнотекстовый индекс, для этого пришлось пересоздать таблицу. Но это ограничение на самом деле InnoDB и InnoDB fulltext search: приходится пересоздавать таблицу, чтобы добавить специальный идентификатор полнотекстового поиска.
Интересно, что в MySQL 8 появилась новая кодировка UTF8MB4 для смайликов. Конечно, не совсем для них, а потому что в UTF8MB3 есть некоторые проблемы с русским, китайским, японским и другими языками.
mysql> ALTER TABLE cloud_data_json_indexes
-> ADD data_data TEXT CHARACTER SET UTF8MB4
-> GENERATED ALWAYS AS ( CONVERT(data->>'$.data' USING UTF8MB4) ) STORED
Query OK, 123518 rows affected (3.14 sec)
Records: 123518 Duplicates: 0 Warnings: 0
Соответственно MySQL 8 должен хранить данные JSON в UTF8MB4. Но то ли из-за того, что Node.js коннектится к Device Cloud, и там записано что-то не так, или это баг бета-версии, этого не произошло. Поэтому мне пришлось сконвертировать данные, перед тем как записать их в хранимую колонку.
mysql> ALTER TABLE cloud_data_json_indexes DROP KEY ft_json,
ADD FULLTEXT KEY ft_json(data_name, data_data);
Query OK, 0 rows affected (1.85 sec)
Records: 0 Duplicates: 0 Warnings: 0
После этого я смог создать полнотекстовый поиск на двух полях: на названии JSON и на данных JSON.
Not only IoT
JSON — это не только интернет вещей. Он может использоваться для других интересных штук:
- Custom fields (CMS);
- Complex structures и т.д.;
Некоторые вещи могут быть намного удобнее реализованы с помощью гибкой схемы хранения данных. На Oracle OpenWorld приводился отличный пример: резервирование мест в кинотеатре. Реализовать это в реляционной модели очень сложно — получается много зависимых таблиц, джойнов и т.д. С другой стороны, мы можем хранить весь зал как структуру JSON, соответственно, записывать его в MySQL в другие таблицы и использовать обычным образом: создать индексы на основе JSON и т.д. Сложные структуры удобно хранить в формате JSON.
Это дерево, которое было успешно посажено. К сожалению, через несколько лет его съели олени, но это уже совсем другая история.
Этот доклад — отличный пример того, как из одной темы на большой конференции, вырастает целая секция, а потом и обособленное отдельное мероприятие. В случае интернета вещей у нас получилась InoThings++ — конференция для профессионалов рынка интернета вещей, которая 4 апреля пройдет уже во второй раз.
Центральным событием конференции, похоже, станет круглый стол «Нужны ли нам национальные стандарты в Интернете Вещей?», который органично дополнят всесторонние прикладные доклады. Приходите, если и ваши высоконагруженные системы верно двигаются к IIoT.
Автор: olegbunin
Источник