Изменение схемы данных с помощью space:format()
В этом руководстве рассказано, как разработать типовое приложение в Tarantool DB и изменить в нем схему данных, используя метод space:format(). В качестве примера используется база данных для системы управления проектами. Для работы используются модули migrations, CRUD и vshard.
Руководство включает следующие шаги:
- Пререквизиты
- Схема данных
- Запуск стенда
- Загрузка и проверка данных
- Проверка пользовательских функций базы данных
- Изменение схемы данных
- Остановка стенда
Для выполнения примера требуются:
-
установленный Docker-образ Tarantool DB;
-
приложение Docker compose;
-
утилита TT CLI;
-
исходные файлы примера
migrations.
В качестве примера приведена база данных для системы управления проектами, которая состоит из трех спейсов: projects (проекты), tasks (задачи),
users (пользователи).
Схема этой базы данных выглядит так:
Здесь:
- Спейсы
projectsиtasksимеют одинаковый ключ шардированияproject_idи находятся на одном экземпляре. - Спейс
usersимеет ключ шардированияuser_id.
Особенности базы данных:
- Задач на проекте больше, чем пользователей.
- При удалении проекта нужно удалить и связанные с ним задачи. Это удобно делать, если все записи находятся на одном экземпляре.
Для работы с данными в примере используются методы модуля CRUD. Дополнительно будет реализовано следующее API:
app.delete_user(user_id)— удаление пользователя. У всех задач, связанных с этим пользователем, в полеassigned_user_idдолжен быть выставленbox.NULL;app.delete_project(project_id)— удаление проекта и всех связанных с ним задач;app.get_project_data(project_id)— получение проекта и всех связанных с ним задач и пользователей.
Для запуска и настройки кластера используются файлы из папки migrations:
docker-compose.yml— описание узлов кластера;bootstrap/topology.json— топология кластера.
Для успешного старта должны быть свободны следующие порты:
- 3300 .. 3304
- 8080 .. 8084
Перейдите в директорию примера migrations:
cd ./docs/docs/ru/assets/examples/migrations/
Запустите стенд:
docker compose up -d
В запущенном кластере созданы спейсы projects, tasks и users, а также
функции app.delete_user(user_id) и app.get_project_data(project_id).
Подключитесь к роутеру с помощью команды tt connect.
Команда открывает интерактивную консоль Tarantool, позволяющую работать с базой данных:
tt connect admin:secret-cluster-cookie@localhost:3300
Исходный код миграции приведен в файле 001_test.lua в директории ./bootstrap/migrations/source/ примера migrations.
Загрузить тестовые данные можно с помощью функции __create_example_data.
Функция очищает кластер и заполняет его данными из примера:
localhost:3300> box.schema.func.call('__create_example_data')
Чтобы проверить загруженные данные, выполните несколько базовых операций в спейсе users, используя модуль CRUD:
localhost:3300> crud.select('users')---- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]rows:- [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com']- [1e63739a-dad0-4c5d-80e4-cd39594fe302, 1985, 'jane_smith', 'jane.smith@example.com']- null...localhost:3300> crud.select('users', {{"==", "name", "john_doe"}})---- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]rows:- [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com']- nulllocalhost:3300> crud.update('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'), {{'=', 'name', "John Doe"}})---- rows:- [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]- nulllocalhost:3300> crud.get('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))---- rows:- [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]- nulllocalhost:3300> crud.delete('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))---- rows:- [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'John Doe', 'john.doe@example.com']metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]- null...
Чтобы вернуть данные в прежнее состояние, вызовите функцию __create_example_data еще раз:
localhost:3300> box.schema.func.call('__create_example_data')
Модуль CRUD упрощает работу с шардированными данными — выполнение простых операций
чтения и записи таких данных прозрачно для пользователя.
Тем не менее, для задач, реализующих функции базы данных (app.get_project_data(id), app.delete_user(id) и app.delete_project(id)),
модуля CRUD недостаточно.
Это связано с тем, что эти функции работают с несколькими спейсами и нестандартными операциями чтения и записи.
Для проверки функции app.get_project_data(project_id) верните данные в первоначальное состояние:
localhost:3300> box.schema.func.call('__create_example_data')
Вызовите функцию и оцените результат:
localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))---- res:tasks:- status: In Progressuser:name: john_doeemail: john.doe@example.comname: Optimize Databasedescription: Optimize the database to improve performance.name: Task Managementdescription: Development of a task management systemerr: null
Функция app.get_project_data(project_id) выполняет join из всех спейсов, используются crud.get, crud.pairs.
Для проверки функции app.delete_user(user_id) верните данные в первоначальное состояние:
localhost:3300> box.schema.func.call('__create_example_data')
Удалите пользователя:
localhost:3300> box.schema.func.call('app.delete_user', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))---- res: trueerr: null...localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))---- res:tasks:- status: In Progressname: Optimize Databasedescription: Optimize the database to improve performance.name: Task Managementdescription: Development of a task management systemerr: nulllocalhost:3300> crud.get('users', require('uuid').fromstr('04e7f6a2-2979-46e4-8d71-e80217e3aac3'))---- rows: []metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true}]- null...
В выводе функции видно, что информации о пользователе нет — пользователь успешно удален.
Теперь во всех связанных с этим пользователем задачах нужно присвоить полю assigned_user_id значение box.NULL.
Для этого на всех хранилищах была объявлена функция tasks.set_box_NULL_for_user_id.
Функция задает box.NULL в поле assigned_user_id для всех задач, у которых assigned_user_id == user_id, где
user_id — аргумент функции.
Код функции:
function(user_id)local fiber = require('fiber')local every_100 = 0for _, t in box.space.tasks.index.assigned_user_id:pairs({user_id}, 'EQ') dobox.space.tasks:update(t.id, {{'=', 'assigned_user_id', box.NULL}})every_100 = every_100 + 1if every_100 == 100 thenevery_100 = 0fiber.yield() — выполняем fiber.yield(), чтобы не занимать полностью TX тред в случае, когда задач у пользователя очень многоendendreturn trueend
Особенность app.delete_user(id) состоит в том, что спейсы tasks и users шардируются по разным ключам.
В общем случае связанные задачи и пользователи будут находиться на разных шардах. Это значит, что нет узла, на котором бы было известно,
на каких шардах будут задачи, связанные с удаляемым пользователем.
Вызывать функцию tasks.set_box_NULL_for_user_id нужно на каждом мастере шарда, потому что такие задачи будут на всех
шардах.
Для вызова функции на всех шардах используется модуль для горизонтального масштабирования vshard.
Вызов функции tasks.set_box_NULL_for_user_id выглядит так:
local _, err, uuid = vshard_router.map_callrw('tasks.set_box_NULL_for_user_id', {user_id})
Полный исходный код приведен в файле миграции ./bootstrap/migrations/source/001_test.lua примера migrations.
Для проверки функции app.delete_project(project_id) верните данные в первоначальное состояние:
localhost:3300> box.schema.func.call('__create_example_data')
Просмотрите содержимое спейса projects. Видно, что проект Website Update был удален вместе со всеми задачами:
localhost:3300> crud.select('projects')---- metadata: [{'name': 'id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description','is_nullable': true}]rows:- [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development ofa task management system']- [f53392af-30e3-4bfc-bde8-37043951159a, 21589, 'Website Update', 'Making changesto the website design and functionality']- nulllocalhost:3300> crud.select('tasks')---- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description','is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id','type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true}]rows:- [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize thedatabase to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,04e7f6a2-2979-46e4-8d71-e80217e3aac3]- [c57d56ef-33fc-453b-880f-5d3ba4dc9d10, 21589, 'Create New Logo', 'Design a newlogo for the website.', 'Not Started', f53392af-30e3-4bfc-bde8-37043951159a,1e63739a-dad0-4c5d-80e4-cd39594fe302]- nulllocalhost:3300> box.schema.func.call('app.delete_project', require('uuid').fromstr('f53392af-30e3-4bfc-bde8-37043951159a'))---- res: trueerr: null...localhost:3300> crud.select('projects')---- metadata: [{'name': 'project_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description','is_nullable': true}]rows:- [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development ofa task management system']- nulllocalhost:3300> crud.select('tasks')---- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description','is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id','type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true}]rows:- [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize thedatabase to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,04e7f6a2-2979-46e4-8d71-e80217e3aac3]- null...
Спейсы projects и tasks шардируются по одинаковым значениям.
Это означает, что связанные между собой проект и задача находятся на одном экземпляре.
Такой подход позволяет транзакционно удалить данные из projects и tasks.
Для этого на хранилищах реализована API-функция projects.delete_project.
Код функции:
function(project_id)local proj = box.space.projects:get(project_id)if proj == nil thenreturn falseendbox.atomic(function() — атомарно удаляем и из projects и из tasksbox.space.projects:delete(project_id)for _, t in box.space.tasks.index.project_id:pairs({project_id}, 'EQ') dobox.space.tasks:delete(t.id)endend)return trueend
Для вызова функции на конкретном мастере используется модуль vshard.
Вызов функции projects.delete_project выглядит так:
local vshard_router = require('vshard.router')local bucket_id = vshard_router.bucket_id_strcrc32(id)local _, err = vshard_router.callrw(bucket_id, 'projects.delete_project', {id})
Полный исходный код приведен в файле миграции ./bootstrap/migrations/source/001_test.lua примера migrations.
Предположим, что теперь нужно изменить схему данных, добавив в нее новые поля:
deadline (datetime)в спейсprojects;due_date (datetime)в спейсtasks;role (string)в спейсusers.
По умолчанию в полях projects.deadline и due_date(datetime) должно быть значение 2999-12-31T00:00:00Z, а в поле
users.role — значение not set.
Функцию app.get_project_data нужно также переписать, чтобы отображались новые поля.
Новая схема данных будет выглядеть так:
Код миграции приведен в файле ./migrations/002_test.lua002_test.lua примера migrations.
Миграции выполняются в лексикографическом порядке, поэтому им даны нумерованные названия: (0001_my_migr.lua, 2023_12_24_migr.lua).
Подготовьте данные для миграции:
localhost:3300> box.schema.func.call('__create_example_data')
Есть два способа выполнить миграцию:
- в веб-интерфейсе Tarantool DB;
- с помощью GraphQL.
Веб-интерфейс
- Откройте вкладку Code в меню слева.
- Добавьте в
migrations/sourceфайл002_test.lua. - Скопируйте код из файла
002_test.luaв этот файл. - Нажмите кнопку
Apply. - Конфигурация успешно применена.
Graphql API
Для выполнения миграции запустите следующий запрос на изменение (мутацию):
curl -v --raw 'http://localhost:8081/admin/api' -X POST --data '{"query":"mutation($sections: [ConfigSectionInput!]) {cluster {config(sections: $sections) {filenamecontent}}}","variables": {"sections": [{"filename":"migrations/source/002_test.lua","content":"'"$(cat 002_test.lua | sed 's/"/\\"/g' )"'"}]}}'
Чтобы выполнить старт миграции, запустите в консоли следующую команду:
curl -X POST localhost:8081/migrations/up
Дождитесь ответа:
{"applied":["002_test.lua"]}
Проверьте, что миграция прошла успешно. Видно, что добавлены новые поля со значениями по умолчанию:
localhost:3300> crud.select('projects')---- metadata: [{'name': 'project_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description','is_nullable': true}, {'type': 'datetime', 'name': 'deadline', 'is_nullable': true}]rows:- [46f8e628-d2c2-42ba-984f-29a459a3d0fc, 1033, 'Task Management', 'Development ofa task management system', '2999-12-31T00:00:00Z']- [f53392af-30e3-4bfc-bde8-37043951159a, 21589, 'Website Update', 'Making changesto the website design and functionality', '2999-12-31T00:00:00Z']- null...localhost:3300> crud.select('tasks')---- metadata: [{'name': 'task_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'description','is_nullable': true}, {'name': 'status', 'type': 'string'}, {'name': 'project_id','type': 'uuid'}, {'type': 'uuid', 'name': 'assigned_user_id', 'is_nullable': true},{'type': 'datetime', 'name': 'due_date', 'is_nullable': true}]rows:- [5043a3f6-6ffa-4d90-8b66-4fb623878f8e, 1033, 'Optimize Database', 'Optimize thedatabase to improve performance.', 'In Progress', 46f8e628-d2c2-42ba-984f-29a459a3d0fc,04e7f6a2-2979-46e4-8d71-e80217e3aac3, '2999-12-31T00:00:00Z']- [c57d56ef-33fc-453b-880f-5d3ba4dc9d10, 21589, 'Create New Logo', 'Design a newlogo for the website.', 'Not Started', f53392af-30e3-4bfc-bde8-37043951159a,1e63739a-dad0-4c5d-80e4-cd39594fe302, '2999-12-31T00:00:00Z']- null...localhost:3300> crud.select('users')---- metadata: [{'name': 'user_id', 'type': 'uuid'}, {'name': 'bucket_id', 'type': 'unsigned'},{'name': 'name', 'type': 'string'}, {'type': 'string', 'name': 'email', 'is_nullable': true},{'type': 'string', 'name': 'role', 'is_nullable': true}]rows:- [04e7f6a2-2979-46e4-8d71-e80217e3aac3, 23464, 'john_doe', 'john.doe@example.com','not set']- [1e63739a-dad0-4c5d-80e4-cd39594fe302, 1985, 'jane_smith', 'jane.smith@example.com','not set']- null...
Теперь проверьте функцию app.get_project_data.
В функции должны появиться новые поля в ответе:
localhost:3300> box.schema.func.call('app.get_project_data', require('uuid').fromstr('46f8e628-d2c2-42ba-984f-29a459a3d0fc'))---- res:tasks:- due_date: 2999-12-31T00:00:00Zstatus: In Progressuser:email: john.doe@example.comname: john_doerole: not setname: Optimize Databasedescription: Optimize the database to improve performance.deadline: 2999-12-31T00:00:00Zname: Task Managementdescription: Development of a task management systemerr: null...
Теперь нужно изменить функцию app.get_project_data.
Для этого транзакционно удалите старую функцию и добавьте новую.
Такой подход гарантирует, что не произойдет ситуации, когда функции app.get_project_data не существует.
box.atomic(function()box.schema.func.drop('app.get_project_data') -- удаляется старый вариантbox.schema.func.create('app.get_project_data', {language = 'LUA',if_not_exists = true,body = [[ ... ]]}) -- добавляем новый вариантend)
Узнать подробнее о том, как хранятся персистентные функции, можно в спейсе box.space._func.
Чтобы остановить стенд, выполните следующую команду:
docker compose down