Генерация данных — творчество или рутина?

от автора

Кадр из фильма "1+1" (2011)

Кадр из фильма «1+1» (2011)

Долгие годы люди стремились к всё более реалистичному изображению окружающих их вещей. Много лет прошло от симпатичных наскальных мамонтов до шедевров эпохи Ренессанса и Просвещения. Однако где-то в 19-м веке (примерно, когда стала появляться первая фототехника, ага), что-то пошло не так, и живопись сменила своё направление от реализма к абстракции. Дальше больше; и все «скатилось» до клякс, пятен и потёков, размазанных по холсту или любой другой поверхности стоимостью в миллионы долларов… И при этом зачастую совершенно было непонятно, кто автор «шедевра»: 3-х летний ребенок, маститый художник, нейросеть или кот, опрокинувший банку варенья.

Похожие процессы происходят и в мире данных, синтетические, сгенерированные, абстрактные данные обретают всё большую ценность на рынке. Такие данные являются более безопасными, а также позволяют тестировать системы качественнее и воспроизводить проблемы до их появления в продакшене… А еще делать прогнозы, анализ, безопасно обмениваться и многое другое.

В этом посте мы рассмотрим основные моменты генерации данных с нуля (на основе схемы БД), а так же на основе уже существующих данных. Рассмотрим способы, методы, особенности и инструменты. А каждый шаг будем иллюстрировать примерами живых и настоящих SQL-запросов (в основном PostgreSQL-flavour, но постараемся и не только). И в итоге убедимся, что SQL позволяет нам не только эффективно работать с уже существующими данными (на минуточку, уже почти на протяжении 50 лет), но с помощью него их можно еще и довольно эффектно придумывать.

Представим архетипическую ситуацию: на этапе разработки у нас уже есть схема данных, и даже само приложение, но абсолютно нет данных. Разве что несколько пользователей вида «Иванов Иван Иванович» (он же John Doe) и несколько товаров вида «test1», «test2» в корзине. Это усложняет тестирование приложения (интеграционное, нагрузочное и даже приемочное) и понимание того, что вообще это приложение делает и как им пользоваться. Поэтому мы уверены, что еще с ранних этапов разработки необходимо задаться вопросом генерации качественных тестовых данных в достаточном количестве и, конечно же, максимально автоматизировать этот процесс.

И, конечно, в нынешних условиях у нас нет иного выбора, кроме того, как начать с ChatGPT. Вежливо попросим бота сгенерировать данные для тестовой схемы Pagila (о ней мы поговорим подробнее далее):

ChaGPT уважает ссылочную целостность и предусмотрительно начинает со справочников

ChaGPT уважает ссылочную целостность и предусмотрительно начинает со справочников

В итоге мы получим несколько настоящих валидных sql-скриптов в нужном порядке, но будет необходимо еще дополнительно запросить несколько деталей (как минимум проследить, что учтены все таблицы). С одной стороны, это, конечно, очень круто и уже может быть достаточным для некоторых кейсов; но с другой — существует огромное количество нюансов при генерации (например, определенное распределение данных, близость к предметной области и многое другое), которое будет довольно сложно и трудозатратно объяснить боту. Плюс, наверняка нам понадобится генерировать большой объем данных, причем за очень ограниченное время и для приватных корпоративных схем… Поэтому давайте засучим рукава и пройдемся по всем основным этапам генерации данных с нуля, используя старый добрый SQL (мы же все-таки в SQL-хабе).

Генерация строк

Как мы знаем, SQL был задуман как язык для работы с реальными данными, хранящимися в таблицах. Однако в SQL-стандарте далекого 99-го года были представлены рекурсивные запросы, которые позволяют (помимо множества других полезных вещей) сгенерировать произвольное количество строк без обращения к какой-либо конкретной таблице. В то же время различные СУБД могут иметь собственные (зачастую более удобные) конструкции для генерации строк:

-- Standard SQL:1999 way with recursive tmp (r) as (   select 0 union all   select r+1 from tmp    where r < 365) select r from tmp  -- PostgreSQL select generate_series   from generate_series(1, 365)  -- Oracle select level   from dual connect by level <= 365

Генерация значений

Итак, мы уже умеем генерировать собственно строки, теперь нам нужно чем-то их наполнить. Насколько я знаю, SQL стандарты не предусматривают каких-либо конструкций для генерации случайных данных (почему-то). Однако большинство СУБД имеют свои собственные инструменты для этого. Немного поковырявшись с конкретной СУБД, мы сможем получить любое количество случайных данных, отдаленно похожих на имена, мэйлы, даты, адреса и пр., не выходя из тёплой ламповой консоли. Давайте «нагенерим» тысячу (можем и больше) сотрудников для таблицы employee:

-- PostgreSQL insert into employee(id, first_name, last_name,                       years_of_experience, email, order_date, is_student) select generate_series      , md5(random()::text)      , md5(random()::text)      , floor(random() * 99)::int      , md5(random()::text) || '@gmail.com'      , now() - (random() * (interval '90 days'))       , case when random() > 0.5 then true else false end   from generate_series(1, 1000)

«Не верю!» (с)

Теперь мы можем генерировать тонны каких-то данных, вставлять их в наши таблицы и, во многих случаях, этого уже будет вполне достаточно. Но классик бы нам не поверил, поэтому давайте попробуем сделать что-то более похожее на реальные данные, и способов для этого существует огромное количество.

Начнем, пожалуй, с самой популярной задачи — нам нужны настоящие имена и фамилии для наших сотрудников и клиентов. Одно из самых простых и довольно эффективных решений — это заготовить 2 множества (одно с фамилиями, другое с именами) и выполнить декартово произведение (я вдохновлялся вот этим «гистом» на GitHub’е):

-- PostgreSQL select first_name      , last_name   from (select unnest(array['Adam',/*...*/'Susan']) as first_name) as f  cross join        (select unnest(array['Matthews',/*...*/'Hancock']) as last_name) as l  order by random()

В итоге из 48-ми имен и 29-ти фамилий мы получим целых 1392 уникальных сочетаний, что вполне неплохо для начала. Похожую технику мы можем применять для генерации других данных, таких, как почтовые ящики и адреса, завернув в хранимую функцию или генератор шаблонов (а-ля Jinja), для простоты использования и дальнейшей модификации.

Существует также огромное количество сторонних сервисов для генерации реалистичных данных, которые мы можем попробовать затащить в свою базу данных (например, Fake Name Generator сервис позволяет выгружать данные в csv формате). А некоторые из них даже могут выгрузить полученные данные в виде уже готового SQL-скрипта (как,например, Generatedata сервис):

insert into persons (name, company, address, email) values   ('Berk Cotton','Tempus Eu Ligula Incorporated','Ap #633-4301 Tempus, St.','interdum.libero.dui@icloud.ca'),   ('Ahmed Sandoval','Nullam Lobortis Foundation','P.O. Box 902, 9630 Convallis Rd.','magna.suspendisse@google.edu'),   ('Hedy Mcbride','Risus Nulla Limited','5235 Lacinia Avenue','donec.felis@icloud.com'),   ('Kermit Mcintosh','Erat Associates','278-141 Pellentesque St.','vel.faucibus@icloud.ca'),   ('Susan Berg','Mauris Institute','Ap #876-781 Vehicula Street','ipsum.nunc@protonmail.ca');

Учитывая, что проблема генерации данных далеко не нова, существует множество программных библиотек для генерации случайных данных высокого качества для различных языков (Java, Python, JS, Ruby и пр.). К счастью, некоторые СУБД позволяют подключать такие библиотеки (с помощью расширений, например) и использовать их прямо в наших SQL-запросах. Например, расширение PostgreSQL Faker позволяет написать вот такой запрос:

select faker.name()      , faker.company()      , faker.address()      , faker.email()   from generate_series(1, 5)

И это еще не все (голосом из «магазина на диване»)! Расширение faker_fdw предоставляет нам настоящий реляционный способ сгенерировать что-нибудь реальное, используя таблицы, «джойны» и вот это все:

select p.name      , c.company      , a.address      , i.ascii_email    from (select row_number() over() as id, p.* from person p limit 5) p   join (select row_number() over() as id, a.* from address a limit 5) a on a.id = p.id   join (select row_number() over() as id, c.* from company c limit 5) c on c.id = p.id   join (select row_number() over() as id, i.* from internet i limit 5) i on i.id = p.id

Уникальные значения

Хотя наши данные и случайные, это не значит, что к ним вообще нет никаких требований. Например, нам могут понадобиться только уникальные данные для некоторых колонок. Существует достаточно много способов добиться этого. Например, многие СУБД в той или иной степени поддерживают концепцию upsert’а на основании значений одной или нескольких колонок (merge, on conflict и пр.):

-- PostgreSQL create table orders (code varchar(4) primary key, operation_date date);  insert into orders select substr(md5(random()::text), 1, 4) as code      , now() - (random() * (interval '90 days')) as operation_date    from generate_series(1, 1000)     on conflict (code) do nothing

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

-- PostgreSQL select code      , operation_date   from (select code              , operation_date              , row_number() over (partition by code order by operation_date) as rn       from (select substr(md5(random()::text), 1, 4) as code                  , now() - (random() * (interval '90 days')) as operation_date                from generate_series(1, 1000)) s) s  where rn=1

Минус двух предыдущих решений состоит в том, что в итоге мы получим меньше строк, чем задано (что может быть и не сильно страшно). С другой стороны, для большей точности можем обратиться к встроенным в СУБД средствам генерации уникальных данных, таким, как последовательности, «сиквенсы», UUID и пр.:

-- PostgreSQL select gen_random_uuid() select nextval('film_film_id_seq')  -- PostgreSQL Faker select faker.unique_name()      , faker.unique_address()   from generate_series(1, 10)

Запланированная случайность

Случайность — это, конечно, хорошо, но как быть, если мы захотим написать какие-нибудь тесты для наших данных? Т.е., нам нужна какая-то повторяемость, предсказуемость данных. Для этого большинство СУБД предоставляет возможность установить начальное значение (seed) для генератора случайных значений:

-- PostgreSQL select setseed(0.5);  -- Oracle exec dbms_random.seed(42);  -- PostgreSQL Faker select faker.seed(4321);

Аватары

До этого момента мы оперировали только текстовыми данными. Однако порой нам понадобится «придумать» какое-нибудь изображение, например, аватарку для профиля пользователя. К счастью, существует огромное количество сервисов с собственными API (часто http), которые могут генерировать различного вида аватарки, — от забавных мультяшек (dicebear.com, api.multiavatar.com) до вполне себе реальных фото людей (randomusers). Давайте придумаем несколько пользователей, используя «залипательный» сервис robohash.org:

select name      , format('https://robohash.org/%s?set=set%s',               replace(name, ' ', '_'),               set_number) as avatar   from (select trunc(random() * 4 + 1) as set_number              , faker.name()           from generate_series(1, 3)) s

Время для серьёзных вещей

Пришло время сделать что-то полезное. В качестве подопытной схемы возьмем замечательную тестовую базу данных Pagila (на которую мы уже натравливали ChatGPT в самом начале поста). На самом деле, в ней уже есть данные, но их достаточно мало (максимум 16к в паре таблиц), поэтому мы займемся тем, что для пустой схемы (скрипт pagila-schema.sql) сгенерируем много данных самостоятельно:

Основная часть схемы Pagila, с который будем работать

Основная часть схемы Pagila, с который будем работать

Чтобы наши SQL-скрипты генерации получились более простыми и более читаемыми, а сгенерированные данные более реалистичными, мы возьмем за основу Postgres-расширение PostgreSQL Faker. Тем более, что создатели расширения уже подготовили готовый Docker-образ, из которого мы и поднимем БД для дальнейших экспериментов:

docker run \   --name pagila-faker \   -p 5432:5432 \   --env POSTGRES_PASSWORD=postgres \   registry.gitlab.com/dalibo/postgresql_faker

Остается только не забыть подключиться к свежеподнятой БД и зарегистрировать расширение:

docker exec \   -it pagila-faker sh -c \   "psql -U postgres -d postgres \     -c \"create schema faker;\" \     -c \"create extension faker schema faker cascade;\" \   "

И накатить схему Pagila БД:

git clone https://github.com/devrimgunduz/pagila.git cd pagila docker cp ./pagila-schema.sql pagila-faker:/docker-entrypoint-initdb.d/pagila-schema.sql  docker exec -it pagila-faker sh -c \   "psql -U postgres -d postgres -f /docker-entrypoint-initdb.d/pagila-schema.sql"

Справочники

Итак, начнем с самой простой задачи — таблицы-справочники верхнего уровня (те, которые сами не зависят от других справочников). В нашем случае, это таблицы стран и языков. Тут все довольно просто — синтезируем нужное количество строк (например 20) посредством generate_series и для каждой вызываем готовую функцию unique_country (или unique_language_name для справочника языков, или любую другую подходящую функцию из огромного множества, которое предоставляет расширение PostgreSQL Faker):

insert into country(country) select faker.unique_country()   from generate_series(1, 20) as id

Теперь чуть более сложный случай — таблица-справочник городов, т.к. она зависит от таблицы стран. Мы, конечно, можем вставить какое-то фиксированное количество городов для каждой страны и успокоиться, но так не интересно, — мы все знаем, что есть маленькие страны, есть большие, и поэтому нам тоже нужен какой-то разброс по количеству городов в каждой стране. Один из вариантов — делаем cross join между таблицей стран и пустой последовательностью из 1000 строк (промежуточно получим 20к строк и одинаковое количество городов в каждой стране), а потом «рандомно» отбросим часть данных от общего множества:

insert into city(city, country_id) select faker.unique_city()      , country_id   from country  cross join generate_series(1, 1000)  where random() > 0.9
С помощью этого нехитрого запроса убеждаемся, что в итоге получили ~2000 городов со случайным распределением по странам:
select с.country_id      , cnt.country       , с.cities_per_country_count      , floor(cities_per_country_count / cities_count * 100) as percent   from (select country_id              , count(1) as cities_per_country_count              , sum(count(1)) over () as cities_count           from city          group by country_id) с   join country cnt on cnt.country_id = с.country_id  order by cities_per_country_count desc  limit 5

country_id

country

cities_per_country_count

cities_count

27

Guinea

118

1943

32

Suriname

108

1943

40

Marshall Islands

107

1943

25

Romania

103

1943

36

Micronesia

103

1943

Связанные данные

Пришло время для чего-то поинтереснее — генерация персонала (staff таблица). Эта таблица имеет 2 родительских таблицы — store и address, и нам необходимо случайным образом их скомбинировать. Первое, что приходит в голову — это выполнить cross join (как мы делали это уже выше с небольшими справочными таблицами). Но в данном случае, мы получим очень медленный запрос, т.к. декартово произведение двух больших множеств ожидаемо породит чудовищное количество строк, которое нужно будет еще случайно отсортировать, и только потом отсечь нужное количество строк. К счастью, стандарт SQL:2003 вводит выражение tablesample для select-запросов, которое позволяет читать не всю таблицу целиком, а только ее часть (указанную в качестве параметра в процентном отношении). А метод bernoulli позволяет просканировать все блоки таблицы (а не только несколько случайных, как в методе system), но вычитать только часть случайных строк, что приведет к достаточно равномерному распределению. Т.о., мы сможем прочитать только часть строк из таблиц store и address (скажем, по 1%) и выполнить cross join между полученными небольшими наборами:

-- PostgreSQL insert into staff(first_name, ..., password) select faker.first_name()      , ...      , faker.password()   from (select a.address_id              , s.store_id           from store s tablesample bernoulli(1)          cross join address a tablesample bernoulli(1)          limit 50000) s

Таблицы временных рядов

На тему генерации time series данных (также посредством чистого SQL-я, всё как мы любим) уже есть 3 просто шикарнейших статьи (раз, два, три) от компании TimescaleDB. Мы же в рамках нашего поста ограничимся простеньким решением для таблицы rental, в котором будем вычитать из текущей даты количество минут, соответствующее номеру случайной строки:

insert into rental(rental_date, inventory_id, customer_id, staff_id) select current_date - (((row_number() over())::text) || ' minute')::interval      , inventory_id      , customer_id      , staff_id   from (select i.inventory_id              , c.customer_id              , s.staff_id            from inventory i tablesample bernoulli(1)           cross join customer c tablesample bernoulli(1)          cross join staff s tablesample bernoulli(1)          limit 1000000) s

Размножение данных

Все это время мы занимались с вами генерацией данных так сказать «from scratch». Но, в некоторых случаях, нам может понадобиться увеличить количество уже существующих данных, сохранив текущее распределение данных. Давайте попробуем увеличить количество городов в справочнике city в 4 раза, сохранив старое распределение количества городов по странам. Идея простая, считаем количество городов по странам, умножаем на 3 и генерируем такое количество новых городов по каждой стране:

insert into city(city, country_id) select unnest(array(select faker.unique_city()                       from generate_series(1, c.cities_count*3) as id)) as city      , country_id   from (select country_id              , count(1) as cities_count           from city          group by country_id          order by country_id) c

Теперь делаем выборку топ 5 стран по количеству городов и убеждаемся, что она точно такая же, как при заполнении справочника с нуля (см. соответствующую главу выше), но количество строк заметно увеличилось:

country_id

country

cities_per_country_count

cities_count

27

Guinea

472

7772

32

Suriname

432

7772

40

Marshall Islands

428

7772

25

Romania

412

7772

36

Micronesia

412

7772

Собираем все вместе

Все упомянутые SQL-скрипты (и те, которые не были упомянуты, для промежуточных таблиц) доступны в этом GitHub репозитории. Их достаточно легко увидеть в действии, запустив генерацию с помощью docker-compose:

git clone https://github.com/synthesized-io/pagila-data-generation.git cd pagila-data-generation docker-compose up

К чему я вас и приглашаю. Так же буду рад звёздочкам, PR’ам, issues’ам и пр.

Впереди еще много работы

Как мы видим, чтобы получить первые плоды генерации данных, было проделано достаточно много работы. Но, на самом деле, это только начало, впереди нас ожидает еще много новых вызовов, например:

  • Схема БД — сейчас наши скрипты жестко завязаны на конкретную схему БД. Если мы захотим проделать что-то похожее с другой схемой, то основную часть работы придется выполнить снова.

  • Миграции схемы — также придется все время «рихтовать» наши скрипты при миграции схемы БД.

  • Много объектов — в демонстрационной БД Pagila чуть больше десятка таблиц, но в реальной жизни мы обычно сталкиваемся со схемами в сотни и тысячи таблиц, и сложными зависимостями между ними.

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

  • Различные типы данных — помимо текста, дат и числовых значений (с которыми мы работали в этом посте), различные СУБД поддерживают множество других более сложных типов данных. Например, объектные типы, домены, json и пр., которые тоже может понадобиться обрабатывать во время генерации.

  • Производительность.

На этом все, спасибо за внимание, буду рад любому фидбэку 😉


ссылка на оригинал статьи https://habr.com/ru/post/723202/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *