Логическая репликация в PostgreSQL. Репликационные идентификаторы и популярные ошибки

image

Начиная с 10 версии, перенести данные с одной базы PostgreSQL на другую несложно, с обновлением, без обновления — неважно. Об этом немало сказано и сказанное сводится к следующему: на мастере, 10 версии и выше, устанавливаем параметр конфигурации wal_level="logical". В pg_hba.conf добавляем такую строку:
host db_name postgres 192.168.1.3/32 trust
Затем рестартуем на мастере postgres и выполняем на реплике из-под пользователя postgres:

pg_dumpall --database=postgres --host=192.168.1.2 --no-password --globals-only --no-privileges | psql pg_dump --dbname db_name --host=192.168.1.2 --no-password --create --schema-only | psql

Теперь подключаемся на мастере пользователем postgres к базе db_name и создаём публикацию:

CREATE PUBLICATION db_pub FOR ALL TABLES;

а на реплике создаём подписку:

CREATE SUBSCRIPTION db_sub CONNECTION 'host=192.168.1.2 dbname=db_name' PUBLICATION db_pub;

По завершении репликации переключаем приложение или балансировщик на новую базу.

Теперь вы знаете постгрес (и с какой стороны доить слонеску) и можете идти устраиваться ДБА.
Для любознательных есть пара небольших деталей под катом.

Задачи, решаемые логической репликацией

Для чего может быть полезна логическая репликация, написано в документации:

  • Передача подписчикам инкрементальных изменений в одной базе данных или подмножестве базы данных, когда они происходят.
  • Срабатывание триггеров для отдельных изменений, когда их получает подписчик.
  • Объединение нескольких баз данных в одну (например, для целей анализа).
  • Репликация между разными основными версиями PostgreSQL.
  • Репликация между экземплярами PostgreSQL на разных платформах (например, с Linux на Windows).
  • Предоставление доступа к реплицированным данным другим группам пользователей.
  • Разделение подмножества базы данных между несколькими базами данных.

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

Это тот момент, когда можно сказать — «А помните, мы хотели уменьшить объём базы за счёт выравнивания? Давайте сейчас столбцы и перераспределим!». Также на стороне реплики возможно, например, провести перераспределение данных из одного столбца типа JSON в несколько других столбцов, или даже таблиц, либо наоборот, после чего заполнять уже доработанную и протестированную базу, при необходимости обрабатывая данные напильником триггерами. Можно какие-то поля отправить в TOAST, а какие-то наоборот — достать. В некоторых пределах можно поменять типы значений в столбцах. Также причиной выбора является возможность провести практически бесшовное обновление, одновременно с котором допустимо некоторое изменение схемы данных, а при некотором усердии — кардинальное перекраивание схемы данных. В общем, к списку добавляется один пункт:

  • Трансформация схемы данных, в определённых пределах, практически без перерыва в обслуживании.

Но, как и любой другой инструмент, логическая репликация имеет, помимо преимуществ, ещё и ограничения и недостатки. Знать их не помешает, ведь приведённый над катом пример будет работать без проблем только в сферической учебной БД.

Документация и примечания к выпускам

Надо помнить, что поведение СУБД в различных мажорных версиях может заметно разниться. Поэтому перед обновлением, да и вообще, важно ознакомиться с документацией и списком изменений (10, 11, 12, 13) и определить, какие из них могут изменить поведение вашей БД.

Здесь не будет рассказываться о создании публикаций и подписок, всё это есть в документации. Статья носит обзорный характер и не служит заменой документации.

Примечание

В статье приводится много примеров для воспроизведения которых лучше использовать psql, так как pgcli работает немного по-другому и перетащенный туда скрипт работает с ущербом для наглядности. Также создайте базу test, а в ней схему ts:

CREATE DATABASE test; \c test CREATE SCHEMA ts;  REVOKE ALL PRIVILEGES    ON DATABASE test   FROM public; -- ведь у вас на проде всё точно так же? ---

Не все скрипты можно перетаскивать как есть, в некоторых придётся поменять IP-адреса.
Приведённые примеры кода ни в коем случае не выполняйте на экземплярах СУБД, которые кем-либо используются. Некоторые примеры приведены для демонстрации падения СУБД и могут принести немало неприятностей. Лучше всего создать пару ВМ и экспериментировать на них.

Обновление до последней корректирующей (минорной) версии

Первое, что нужно запланировать и сделать при подготовке к использованию логической репликации — обновиться на последнюю минорную версию, особенно если в создаваемых публикациях планируется использовать предложение FOR ALL TABLES.
Почему стоит обновиться? Например, поэтому:

  1. В версиях 10.8 и 11.3 был исправлен баг с обработкой изменений, вносящихся во временные и нежурналируемые таблицы. Данные в таких таблицах в логической репликации не участвуют, поэтому им не требуется настройка репликационных идентификаторов, но, при попытке обновить в таких таблицах данные, сервер выдавал сообщение об ошибке: ERROR: cannot update table "logical_replication_test" because it does not have a replica identity and publishes updates и отменял транзакцию. Хорошего в этом мало, поэтому, если ваше приложение использует временные или нежурналируемые таблицы, то обновление обязательно;
  2. В версиях 10.11 и 11.6 был устранён вывод ошибки в случае, когда состав столбцов идентификации на мастере и на реплике различался. Правда и репликация изменения или удаления строк в таком случае прекращается;
  3. В версиях 10.12, 11.7, 12.2 был устранено несколько багов, которые приводили к невозможности значительно изменять схему таблиц на реплике по сравнению со схемой таблиц на мастере. Например, на реплике нельзя было создавать дополнительные столбцы с функцией в качестве значения по умолчанию ("… clmname numeric DEFAULT random() …").
  4. В версиях 10.16, 11.11, 12.6 13.2 устранили утечки памяти в процессах walsender при передаче новых снимков для логического декодирования

Как видите, для снижения километража истрёпанных нервов, предпочтительнее обновиться, пусть это и займёт некоторое время.

Создание ролей и строк аутентификации в pg_hba.conf

После минорного обновления следует создать те роли, которые планируется использовать для создания публикации и подписки. При этом требования к ролям на мастере и реплике немного различаются.

В целом на реплике можно вообще не создавать отдельную роль, так как создание подписки разрешается только суперпользователям и применение полученных изменений происходит с правами суперпользователя. Поэтому на реплике можно пользоваться ролью postgres.

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

Создать роль можно такой командой:

SET password_encryption = 'scram-sha-256'; CREATE ROLE log_rep_rolename    WITH SUPERUSER         LOGIN        PASSWORD 'sadp!'; -- sadp! = StrongAndDifficultPassword!  -- По возможности избегайте кавычек (', ") в пароле, -- его предстоит в строке подключения указывать. 

В pg_hba.conf на мастере нужно добавить две записи: одну для локального подключения, другую для подключения с реплики. Предпочтительно указывать точные адреса реплик — лучше сто записей в pg_hba, чем одна дыра в безопасности.
host test log_rep_rolename 127.0.0.1/32 scram-sha-256
host test log_rep_rolename 192.168.122.95 255.255.255.255 scram-sha-256

Обратите внимание что, несмотря на то что логическая репликация основана в значительной мере на потоковой, указывается не специальная запись replication, а имя базы или all — указание на все базы. Если указать replication, то создать подписку на реплике не получится из-за ошибки аутентификации.

После этого перезагружаем настройки из-под суперпользователя:

SELECT pg_reload_conf(); ---

Репликационные идентификаторы

Весь процесс логической репликации в принципе строится на идее репликационных идентификаторов. Поэтому дальнейшая подготовка состоит в проверке наличия во всех реплицируемых таблицах либо первичного ключа, либо индекса, соответствующего некоторым минимальным требованиям и задействованного в REPLICA IDENTITY USING INDEX, либо назначении REPLICA IDENTITY FULL. То есть проверка наличия в таблицах репликационных идентификаторов. Они нужны для однозначной идентификации изменяемых или удаляемых строк при репликации команд UPDATE и DELETE и передаются на реплику в специальном поле для каждой записи.

Репликационные идентификаторы можно не настраивать, или даже отключить, если планируется реплицировать только команды INSERT. Главное не забыть правильно создать публикацию — исключить из неё команды UPDATE и DELETE. Но если вам на реплике нужны актуальные данные из активно изменяющихся таблиц, а первичные ключи или уникальные NOT NULL индексы в таблицах отсутствуют, то репликационные идентификаторы придётся настраивать с нуля. Не выполнив это условие, можно добиться того, что UPDATE и DELETE будут приводить к отмене транзакций на мастере, малоприятный факт на рабочей базе.

Поиск таблиц, не имеющих репликационных идентификаторов

SELECT pgn.nspname || '.' || pgc.relname AS "Таблицы без репликационных идентификаторов"   FROM pg_class AS pgc,        pg_namespace AS pgn   WHERE pgn.nspname !~ '^(?:pg_.*|information_schema)$'     AND pgc.relreplident IN ('n', 'd')     AND pgc.relkind IN ('r', 'p')      AND pgc.oid NOT IN (SELECT pgi.indrelid FROM pg_index AS pgi WHERE pgi.indisprimary)     AND pgc.relnamespace = pgn.oid     ORDER BY 1; ---

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

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

Если столбцов пригодных к роли репликационных идентификаторов нет, что очень странно, то придётся их создавать. Каждое значение в таком столбце должно быть уникальным. В качестве источника уникальности могут выступать как естественные, так и искусственные ключи и их комбинации — зависит от архитектуры базы данных. При этом неважно что именно использовать в качестве естественных ключей, главное, чтоб они выполняли роль однозначного идентификатора. В качестве искусственных ключей используются, как правило, различные последовательности и типы UUID.

Последовательности

Использовать последовательности при создании репликационных идентификаторов можно двумя с половиной способами: ручное указание вызова функции получения следующего значения последовательности, например nextval; назначение столбцу последовательного псевдотипа serial; использование столбцов идентификации в соответствии со стандартом SQL. Вполне рабочим вариантом может быть отсутствие значения по умолчанию, ведь можно возложить эту обязанность на приложение, но столбец должен быть NOT NULL.

SEQUENCE и serial
Наиболее гибким и мощным является использование различных SEQUENCE. В таком случае, после создания последовательности, значения по умолчанию для столбцов необходимо прописывать самостоятельно. При необходимости назначить таблицу-владельца последовательности также придётся поработать руками.

Тип serial это синтаксический сахар для обычного способа создания последовательностей, этакий шаблон. Всё что нужно сделать, это назначить столбцу тип smallserial/serial/bigserial.

Использование типа serial менее гибко, но его использование позволяет избавиться от необходимости создавать последовательности вручную. Также такой последовательности автоматически назначается свойство OWNED BY. Это указание на столбец таблицы, при создании которого была создана последовательность.

Такую последовательность в дальнейшем нельзя удалить, не удалив это указание. И здесь кроется опасность — удалив такую последовательность с указанием ключевого слова CASCADE можно устроить локальный армагеддон. В привязанном к удалённой последовательности столбце останется включенным свойство NOT NULL, а вот свойство DEFAULT обнулится.

У полей serial есть ещё одна неприятная особенность — пользователь без права на использование автоматически созданной последовательности, и с правами на INSERT в таблицу, практически вставку выполнять не сможет, если только не укажет значение поля serial вручную. Если не укажет, то получит ошибку доступа к соответствующей последовательности. В принципе это не проблема, нужно не забывать давать права на использование последовательности вместе с правами на вставку в таблицу.

Несколько таблиц на одной последовательности
Если нужно подключить к одной последовательности несколько таблиц, — делать это нужно самостоятельно. Для этого задаётся получение nextval(нужная_последовательность) в свойстве DEFAULT интересующего вас столбца. Воспользоваться можно и той последовательностью, что была создана с использованием serial — никто не мешает вручную прописать её для других таблиц, разве что потом будут некоторые проблемы с удалением самой первой таблицы: нужно будет поменять или удалить ссылку на таблицу-владельца такой последовательности: ALTER SEQUENCE name_of_your_seq OWNED BY NONE.

Воспроизведение

CREATE TABLE t1 (i serial, t text); CREATE TABLE t2 (i int NOT NULL DEFAULT nextval('t1_i_seq'), t text); INSERT INTO t2 (t) VALUES('The first value in table t2'); INSERT INTO t1 (t) VALUES('The first value in table t1'); SELECT * FROM t1; SELECT * FROM t2; \d t2 DROP TABLE t1; DROP SEQUENCE t1_i_seq; DROP TABLE t1 CASCADE; INSERT INTO t2 (t) VALUES('Maybe the second value in table t2'); \d t2 DROP TABLE t2; ---

Столбцы идентификации
Пришедшие из стандарта SQL cтолбцы идентификации задаются либо при создании таблицы, либо ими могут стать имеющиеся столбцы, либо можно добавить такие столбцы отдельно.

Последовательность, созданную для столбца идентификации, в отличие от первых полутора вариантов, не стоит использовать в других таблицах. В дальнейшем это помешает удалить исходную таблицу, а если удалить её с предложением CASCADE, то свойство DEFAULT у таблиц использовавших эту последовательность обнулится. При этом свойство NOT NULL никуда не денется.

В результате появится шанс наблюдать на мастере орды null value in column "i" violates not-null constraint. С последовательностями, созданными с помощью serial тоже такое бывает, но для них это исправимо — поменяйте принадлежность последовательности либо на нужный столбец нужной таблицы, либо сделайте её «бесхозяйной». С последовательностями столбцов идентификации это не работает.

Первичными ключами столбцы идентификации автоматически не становятся, это просто синтаксис назначения столбцу особых свойств, несколько отличающихся от обычных последовательностей. В частности, при типе serial вы можете спокойно проводить вставку любых произвольных значений в ключевые поля, за исключением имеющихся конечно (если на столбце включен PRIMARY KEY).

Понятно, что это приведёт к тому, что однажды последовательность выдаст вставленные ранее произвольные значения и получившая их транзакция прервётся с ошибкой duplicate key. Использование столбцов идентификации позволит не беспокоиться о таком развитии событий — в столбцы идентификации, созданные с ключом ALWAYS, вставить произвольное число не так просто, нужно использовать специальную форму команды INSERT. При создании таких столбцов поддерживаются те же параметры, что и при создании обычной последовательности.

Universally Unique IDentifiers
Если назначить столбцу тип UUID, то значение для такого столбца не будет генерироваться автоматически. Для получения нового значения UUID необходимо пользоваться одним из двух дополнительных модулей: uuid-ossp или pgcrypto

В отличие от последовательностей UUID имеет длину не 16/32/64 бита, а 128 бит, — что нужно учитывать при расчётах нагрузки на сетевую подсистему. Зато у него есть то преимущество, что UUID генерирует такие строки, содержимое которых не повторяется в распределённых системах.

К слову, при использовании последовательностей можно использовать независимые последовательности с добавлением префикса, уникального для каждого участвующего во взаимообмене данными сервера. Столбец в таком случае придётся сделать текстовым, но даже так получится экономичнее.

В простейшем случае получать значения UUID можно через расширение pg_crypto. В нём есть только одна функция получения UUID:

CREATE EXTENSION pgcrypto; SELECT gen_random_uuid();

Модуль uuid-ossp, в отличие от pg_crypto предоставляет больше возможностей по части выбора типа UUID. Если PostgreSQL установлен из пакетов, то можно сразу устанавливать расширение, только его имя обязательно нужно заключить в двойные кавычки, так как оно содержит дефис. Если собираете PostgreSQL из исходников, то нужно воспользоваться ключом —with-uuid=ossp (работает в Debian, как в RHEL — не знаю). Для этого, в дополнение к уже установленному постгресу, понадобится поставить несколько пакетов:

# Debian Buster #    пакет postgresql-10 можно установить до  #    или после компиляции и установки расширения. sudo apt-get install gcc libossp-uuid-dev libreadline-dev make zlib1g-dev wget https://ftp.postgresql.org/pub/source/v10.16/postgresql-10.16.tar.gz gunzip postgresql-10.16.tar.gz tar xf postgresql-10.16.tar cd postgresql-10.16 ./configure --with-uuid=ossp make make install
CREATE EXTENSION "uuid-ossp"; SELECT uuid_generate_v1(); SELECT uuid_generate_v1mc(); SELECT uuid_generate_v3(uuid_ns_url(), 'https://postgrespro.ru/'); SELECT uuid_generate_v4(); SELECT uuid_generate_v5(uuid_ns_dns(), 'postgrespro.ru');

Первичные ключи и уникальные индексы

Определившись с источниками уникальности нужно указать какой столбец, или какие столбцы, надлежит использовать как репликационные идентификаторы. По умолчанию ими являются первичные ключи. Они, как и уникальные индексы, позволяют добавлять на стороне реплики произвольное число столбцов, не опасаясь приостановки репликации при операциях UPDATE и DELETE.

Главное, чтобы у добавленных столбцов не было назначено свойство NOT NULL с отсутствующим значением по умолчанию — при начальной репликации и репликации команды INSERT возникнет ошибка на стороне реплики, устранять которую возможно только на стороне реплики. На стороне мастера тут уже ничего не поделаешь, разве что удалить слот репликации — чтоб журнал предзаписи не переполнялся, и мастер не создавал каждые пять секунд процесс декодирования. Также можно запретить доступ через pg_hba.conf или на балансировщике, затем исправить неполадки на реплике и снова разрешить доступ.

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

При непосредственном создании первичных ключей таблица блокируется на запись блокировкой SHARE ROW EXCLUSIVE. Поэтому предпочтительно использовать предварительное создание уникального индекса в режиме CONCURRENTLY и затем уже привязку его в качестве первичного ключа. Да — это ресурсоёмкая операция, зато доступность БД на запись не страдает.

Обратите внимание на то, что если вы создаёте первичный ключ на столбце, не имеющем свойства NOT NULL, то такое свойство будет создано автоматически, но после после удаления ограничения автоматически не удалится.

Демонстрация появления ограничения NOT NULL

CREATE TABLE ts.testt (i serial, clmn_1 text, clmn_2 int); \d ts.testt /*                          Table "ts.testt"  Column |  Type   | Nullable |             Default              --------+---------+----------+---------------------------------  i      | integer | not null | nextval('testt_i_seq'::regclass)  clmn_1 | text    |          |   clmn_2 | integer |          |                                   */ ALTER TABLE ts.testt ADD PRIMARY KEY (i, clmn_1); \d ts.testt /*                          Table "ts.testt"  Column |  Type   | Nullable |             Default              --------+---------+----------+---------------------------------  i      | integer | not null | nextval('testt_i_seq'::regclass)  clmn_1 | text    | not null |   clmn_2 | integer |          |  Indexes:     "testt_pkey" PRIMARY KEY, btree (i, clmn_1)                  */ ALTER TABLE ts.testt DROP CONSTRAINT testt_pkey; \d ts.testt /*                          Table "ts.testt"  Column |  Type   | Nullable |             Default              --------+---------+----------+---------------------------------  i      | integer | not null | nextval('testt_i_seq'::regclass)  clmn_1 | text    | not null |   clmn_2 | integer |          |                                   */ ALTER TABLE ts.testt ADD PRIMARY KEY (i); \d ts.testt /*                          Table "ts.testt"  Column |  Type   | Nullable |             Default              --------+---------+----------+---------------------------------  i      | integer | not null | nextval('testt_i_seq'::regclass)  clmn_1 | text    | not null |   clmn_2 | integer |          |  Indexes:     "testt_pkey" PRIMARY KEY, btree (i)                          */ ALTER TABLE ts.testt ALTER COLUMN clmn_1 DROP NOT NULL; \d ts.testt /*                          Table "ts.testt"  Column |  Type   | Nullable |             Default              --------+---------+----------+---------------------------------  i      | integer | not null | nextval('testt_i_seq'::regclass)  clmn_1 | text    |          |   clmn_2 | integer |          |  Indexes:     "testt_pkey" PRIMARY KEY, btree (i)                          */ DROP TABLE ts.testt; ---
Пример работы с первичными ключами и последовательностями

-- ПОСЛЕДОВАТЕЛЬНОСТИ -- Сначала создаём последовательность CREATE SEQUENCE ts.testt_sequence AS bigint    INCREMENT BY 1    MINVALUE 1    NO MAXVALUE    START WITH 1    OWNED BY NONE; \ds+ ts.testt_sequence -- Затем создаём таблицу -- Либо сразу с последовательностью CREATE TABLE ts.testt (   i bigint      PRIMARY KEY      NOT NULL      DEFAULT nextval('ts.testt_sequence'::regclass),    ac text); \d+ ts.testt DROP TABLE ts.testt; -- Либо, если таблица и колонка уже существовали, добавляем недостающее CREATE TABLE ts.testt (i bigint, ac text); INSERT INTO ts.testt (i, ac) VALUES (1, '1'), (1, '2'), (2, '3'), (3, '4'); SELECT * FROM ts.testt; ALTER TABLE ts.testt ALTER COLUMN i SET DEFAULT nextval('ts.testt_sequence'); INSERT INTO ts.testt (ac) VALUES ('5'), ('6'), ('7'), ('8'); SELECT * FROM ts.testt; UPDATE ts.testt SET i = nextval('ts.testt_sequence'::regclass); SELECT * FROM ts.testt; ALTER TABLE ts.testt ALTER COLUMN i SET NOT NULL; ALTER TABLE ts.testt ADD PRIMARY KEY (i); \d+ ts.testt DROP TABLE ts.testt; DROP SEQUENCE ts.testt_sequence; -- -- C использованием типа serial можно не создавать последовательность, -- она будет создана автоматически CREATE TABLE ts.testt (   i bigserial PRIMARY KEY,    ac text); \d+ ts.testt -- В качестве первичного ключа можно задать несколько колонок -- Сначала удалим старый первичный ключ ALTER TABLE ts.testt DROP CONSTRAINT testt_pkey; \d+ ts.testt -- Затем создадим новый, двухколоночный. ALTER TABLE ts.testt ADD PRIMARY KEY (i, ac); \d+ ts.testt DROP TABLE ts.testt; -- -- СТОЛБЦЫ ИДЕНТИФИКАЦИИ -- Можно сразу создать таблицу с соответствующими столбцами CREATE TABLE ts.testt (   i bigint     GENERATED ALWAYS AS IDENTITY (       INCREMENT BY 1        MINVALUE 1        START WITH 1)      PRIMARY KEY,   ac text); \d+ ts.testt \d ts.testt_i_seq -- Меняем ALWAYS на BY DEFAULT ALTER TABLE ts.testt ALTER COLUMN i SET GENERATED BY DEFAULT; \d+ ts.testt \d ts.testt_i_seq -- Удаляем IDENTITY со столбца ALTER TABLE ts.testt ALTER COLUMN i DROP IDENTITY; \d+ ts.testt \d ts.testt_i_seq -- Можно добавить IDENTITY имеющемуся столбцу  ALTER TABLE ts.testt    ALTER COLUMN i ADD GENERATED ALWAYS AS IDENTITY (START WITH 1001); \d+ ts.testt \d ts.testt_i_seq SELECT * FROM ts.testt_i_seq; -- Можно добавить новый столбец с IDENTITY ALTER TABLE ts.testt    ADD COLUMN impk bigint GENERATED ALWAYS AS IDENTITY; \d+ ts.testt \d ts.testt_impk_seq -- В этот раз создадим первичный ключ через -- предварительное создание уникального индекса -- который создадим неблокирующим способом: CREATE UNIQUE INDEX CONCURRENTLY testt_hm_idx ON ts.testt (i, impk); ALTER TABLE ts.testt DROP CONSTRAINT testt_pkey,     ADD CONSTRAINT hm_pkey PRIMARY KEY USING INDEX testt_hm_idx; \d+ ts.testt DROP TABLE ts.testt; ---

Уникальные индексы
Наравне с первичными ключами можно использовать уникальные индексы, но их использование в качестве идентификатора репликации необходимо указывать явно с помощью предложения ... REPLICA IDENTITY USING INDEX name_of_index ... и с ними нужно быть поосторожнее. В отличие от первичных ключей столбцы, на которых строится индекс, нужно самостоятельно снабдить ограничением NOT NULL.

Требования к индексам перечислены в документации: индекс должен быть уникальным, не частичным, не отложенным и включать только столбцы, помеченные NOT NULL. Состав столбцов, по которым построен индекс на мастере, должен совпадать с их составом на реплике. Если в нужных таблицах в реплицируемой схеме нет первичных ключей — можно на мастере создать уникальный индекс, назначить его репликационным идентификатором, а после переноса схемы на реплику — создать там идентичный по составу и порядку столбцов первичный ключ. Можно и наоборот.

Пример репликации, где на мастере индекс, а на реплике ПК

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial, t text NOT NULL DEFAULT random(), d text DEFAULT random()); CREATE UNIQUE INDEX CONCURRENTLY testt_idx ON ts.testt USING btree (i, t); ALTER TABLE ts.testt REPLICA IDENTITY USING INDEX testt_idx; INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i serial, t text NOT NULL DEFAULT random(), d text DEFAULT random()); ALTER TABLE ts.testt ADD PRIMARY KEY (t, i); CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Выполняем на мастере вставку строки INSERT INTO ts.testt (t, d) VALUES ('004', 'fourth'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- Выполняем на мастере UPDATE UPDATE ts.testt SET t = t || ' upd' WHERE random() >= 0.5; INSERT INTO ts.testt (t, d) VALUES ('005', 'AFTER UPDATE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt;  -- Выполняем на мастере DELETE DELETE FROM ts.testt WHERE random() >= 0.5; INSERT INTO ts.testt (t, d) VALUES ('006', 'AFTER DELETE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

REPLICA IDENTITY FULL
В отличие от первичных ключей и индексов при REPLICA IDENTITY FULL идентификатором служит вся строка. В этом случае в журнал предзаписи попадает вся старая строка и по протоколу репликации также передаётся вся старая строка. Не передаются только значения полей TOAST — если изменения их не коснулись. Исходя из этого можно представить, насколько разрастается объём хранимых и передаваемых данных.

Поэтому от применения REPLICA IDENTITY FULL нужно максимально воздерживаться. Мало того — ошибки в его использовании могут привести к необходимости рестарта репликации. Когда используется REPLICA IDENTITY FULL, то состав столбцов в таблице на мастере и реплике преимущественно должен совпадать (порядок столбцов значения не имеет), иначе изменение и удаление данных реплицироваться не будет и последствия будут различаться в зависимости от того, где есть лишние столбцы — на мастере или на реплике.

Если лишние столбцы будут на мастере и публикация будет создана для команд UPDATE и DELETE — репликация приостановится до тех пор, пока на реплике будут отсутствовать нужные столбцы.

Вставка данных и начальная синхронизация не так строго ограничены по части состава столбцов — на реплике могут быть дополнительные столбцы, и они даже могут содержать какие-то данные. Но репликация будет идти благополучно для всех команд только если добавленные на реплике столбцы не содержат данных. Это связано с тем, что поля со значением NULL в идентификации строк не участвуют.

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

Если же нужно менять данные на реплике, но на мастере нет возможности создать первичный ключ — его можно создать на реплике, тогда репликация всех команд будет происходить благополучно, хоть в дополнительных столбцах реплики и будут данные. Это работает, потому что в сообщениях протокола логической репликации, при REPLICA IDENTITY FULL на мастере, в качестве идентификатора отправляется вся строка старых данных и процесс применения сообщений выбирает из него значение того поля, которое на реплике является полем первичного ключа.

REPLICA IDENTITY FULL удобно использовать для трансляции небольших, редко изменяемых таблиц или для таблиц с небольшим размером строк — им не нужны индексы на мастере и данные очень быстро пишутся и отправляются. На реплике же можно и индексы строить, и первичные ключи создавать — вполне удобно. Но для обновляемых данных в масштабных таблицах применять его очень опрометчиво.

Включить для таблицы этот идентификатор крайне просто:

ALTER TABLE ts.testt REPLICA IDENTITY FULL;
Работа с REPLICA IDENTITY FULL

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial, t text, d text DEFAULT now()); ALTER TABLE ts.testt REPLICA IDENTITY FULL; INSERT INTO ts.testt (t) VALUES ('first'), ('second'), ('third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i serial, t text, d text); ALTER TABLE ts.testt REPLICA IDENTITY FULL; CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Выполняем на мастере вставку строки INSERT INTO ts.testt (t) VALUES ('INSERTED'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- Выполняем на мастере UPDATE UPDATE ts.testt SET t = t || ' upd' WHERE i = 1; INSERT INTO ts.testt (t) VALUES ('AFTER UPDATE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt;  -- Выполняем на мастере DELETE DELETE FROM ts.testt WHERE i = 1; INSERT INTO ts.testt (t) VALUES ('AFTER DELETE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

Если на мастере REPLICA IDENTITY FULL задана, то на реплике её наличие роли не играет — но только при условии, что состав столбцов таблицы на реплике идентичен таковому на мастере. Иначе на реплике будут применяться только команды INSERT, а если у вас 11 версия мастера — то и TRUNCATE. Команды UPDATE и DELETE будут применяться только если дополнительные столбцы в изменяемой/удаляемой строке будут равны NULL.

Пример реплики с REPLICA IDENTITY NOTHING

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial,                         t text,                         d text); ALTER TABLE ts.testt REPLICA IDENTITY FULL; INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику SELECT pg_reload_conf(); CREATE TABLE ts.testt (d text,                         t text,                        i serial); ALTER TABLE ts.testt REPLICA IDENTITY NOTHING; CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename               password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Выполняем на мастере INSERT, UPDATE, DELETE INSERT INTO ts.testt (t, d) VALUES ('004', 'fourth'); UPDATE ts.testt SET t = t || ' upd' WHERE i = 4; DELETE FROM ts.testt WHERE i = 1; SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

Если нужно реплицировать исторические данные или провести только начальную синхронизацию — можно вообще убрать идентификаторы репликации:

ALTER TABLE ts.testt REPLICA IDENTITY NOTHING;

это снизит нагрузку на журнал предзаписи, декодирование и сеть. Но нужно обязательно создавать публикацию только для команд INSERT, иначе обновление и удаление в этой таблице работать перестанут, независимо от того — есть подписка или нет.

Состав и порядок столбцов

Вне зависимости от выбранных репликационных идентификаторов — порядок столбцов, даже ключевых — значения практически не имеет. Начальная синхронизация и репликация команд INSERT проходят без проблем, независимо от настройки репликационных идентификаторов — идентификаторы строк в этом случае не передаются.

Если же бездумно добавлять столбцы к таблице с той или другой стороны, то, например, при REPLICA IDENTITY FULL, придётся перезапускать репликацию для такой таблицы. Причём, в зависимости от способа прицеливания в ногу, можно либо добиться неконсистентности на реплике, либо ещё и раздуть на мастере журнал предзаписи до невероятных размеров. Что, в одном случае, не даст возможности восстановить согласованность данных в проблемной таблице на реплике и приведёт к необходимости перезаливки данных. Во втором случае такая возможность останется (условно) и реализуется автоматически — после выявления и устранения причины такой ситуации, однако целостность данных после этого всё равно останется под сомнением.

С первичными ключами и индексами ситуация значительно лучше. Хоть на реплике и нельзя удалять столбцы если такие есть на мастере, зато появляется возможность добавлять новые. И не просто добавлять, а различными способами заполнять их данными, без опаски получить остановку репликации или неконсистентность.

Пример репликации с различным порядком столбцов

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial,                         t varchar NOT NULL DEFAULT random(),                         d varchar DEFAULT random()); CREATE UNIQUE INDEX CONCURRENTLY testt_idx ON ts.testt USING btree (i, t); ALTER TABLE ts.testt REPLICA IDENTITY USING INDEX testt_idx; INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику (Обратите внимание на типы) CREATE TABLE ts.testt (d text DEFAULT random(),                         s text DEFAULT now(),                        t text NOT NULL DEFAULT random(),                         i bigserial); CREATE UNIQUE INDEX CONCURRENTLY testt_idx ON ts.testt USING btree (t, i); ALTER TABLE ts.testt REPLICA IDENTITY USING INDEX testt_idx; CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Выполняем на мастере вставку строки INSERT INTO ts.testt (t, d) VALUES ('004', 'fourth'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- Выполняем на мастере UPDATE UPDATE ts.testt SET t = t || ' upd' WHERE random() >= 0.5; INSERT INTO ts.testt (t, d) VALUES ('005', 'AFTER UPDATE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt;  -- Выполняем на мастере DELETE DELETE FROM ts.testt WHERE random() >= 0.5; INSERT INTO ts.testt (t, d) VALUES ('006', 'AFTER DELETE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

Склад грабель горизонтального хранения

Предупреждён — значит вооружен. Ниже приведены несколько самых распространённых сообщений об ошибках и просто общих рассуждений. Лучше прочитать эти сообщения здесь, чем на рабочих серверах — читаем и вооружаемся. Если у вас есть что-нибудь интересное на эту тему — расскажите в комментариях.

Если на мастере репликационные идентификаторы не заложены в бюджет

По умолчанию публикация создаётся для команд INSERT, UPDATE и DELETETRUNCATE, начиная с 11 версии). При этом проверки идентификаторов репликации в целевых таблицах не происходит, от этого может получиться так, что они будут не у всех таблиц. Мало того — после создания публикации допускается сброс или удаление репликационного идентификатора:

ALTER TABLE tablename REPLICA IDENTITY DEFAULT; ALTER TABLE tablename REPLICA IDENTITY NOTHING;

Первое — сброс на значение по умолчанию, то есть на использование первичного ключа таблицы и если он есть — жить можно. Второе — отключение идентификаторов на таблице. Но если нет первичного ключа или идентификаторов вообще, то, при попытке выполнить на мастере обновление или удаление строк, будут получены соответствующие ошибки:

ERROR:  cannot update table "tablename" because it does not have a replica identity and publishes updates HINT:  To enable updating the table, set REPLICA IDENTITY using ALTER TABLE.  ERROR:  cannot delete from table "tablename" because it does not have a replica identity and publishes deletes HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.

Так будет в случае, когда публикация не создана исключительно для команд INSERT и, для одиннадцатой версии — TRUNCATE. Вариантов исправления такой ситуации два — изменить подписку на publish='insert' или добавить репликационные идентификаторы.

Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial,                         t varchar,                         d varchar); ALTER TABLE ts.testt REPLICA IDENTITY NOTHING; INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Проверяем INSERT INSERT INTO ts.testt (t, d) VALUES ('004', 'fourth'); -- Проверяем UPDATE UPDATE ts.testt SET t = t || ' upd' WHERE i = 3; -- Проверяем DELETE DELETE FROM ts.testt WHERE i = 4; SELECT * FROM ts.testt; -- Исправляем публикацию ALTER PUBLICATION testt_pub SET (publish = 'insert'); -- v.10 --ALTER PUBLICATION testt_pub SET (publish = 'insert, truncate'); -- v. >=11 -- Проверяем UPDATE UPDATE ts.testt SET t = t || ' upd' WHERE i = 3; -- Проверяем DELETE DELETE FROM ts.testt WHERE i = 4; SELECT * FROM ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

… not find row.. .

DEBUG:  logical replication did not find row for update in replication target relation "your_table_name" DEBUG:  logical replication could not find row for delete in replication target relation "your_table_name"

Пример сообщений которые можно увидеть когда:
⁍ на реплике добавили столбец и его значение не NULL;
⁍ на реплике по какой-либо причине строки отсутствуют, а на мастере они есть;
⁍ на реплике были изменены строки;
⁍ на реплике нет строки с переданным значением репликационного идентификатора
Если на реплике сложилась одна из приведённых ситуаций, то, при обычном значении параметра log_min_messages=warning, этих сообщений в логах реплики не появится. От того и о пропавших данных можно узнать, когда будет уже очень поздно. А можно и не узнать. С точки зрения СУБД в таком поведении нет ничего предосудительного, возможно таков был план. Но если это не был план, то для приложения это катастрофа, так что тут нужно быть предусмотрительным и внимательным.

Наблюдать эти сообщения в логах возможно при log_min_messages=debug1. Однако так увеличится объём логов, но консистентности данных не прибавится — реплика, получив сообщение об изменениях, не смогла найти изменяемую/удаляемую строку и благополучно выкинула сообщение на свалку истории, а второго шанса ей не предоставится. Мастер не будет уведомлен об этом и в свои логи писать ничего не станет. Следует учесть, что в этом режиме в лог будет записываться строка подключения, вместе с именем пользователя и его паролем — сомнительное преимущество использования такого уровня сообщений журнала.

В данном случае можно радоваться хотя бы тому, что на мастере такое поведение почти никак не скажется. В этом случае реплика получает сообщения репликации, затем, не применив их, отчитывается мастеру, что всё хорошо и журнал предзаписи не разрастается. Конечно, после такого инцидента придётся выправлять сложившееся положение дел: 1. На мастере исключать таблицу из публикации;
2. На реплике обновлять подписку и вычищать таблицу;
3. Привести состав столбцов к единому виду;
4. Включать на мастере таблицу в публикацию;
5. Обновлять подписку на реплике и ждать перезаливки данных.
А всего нужно было — не добавлять столбцы в таблицу на стороне реплики если используется REPLICA IDENTITY FULL или не изменять, бездумно, данные на реплике.

В отличие от UPDATE и DELETE, операция INSERT и начальная синхронизация будут нормально обрабатываться, даже если на реплике есть столбцы отсутствующие на мастере.

Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial,                         t varchar,                         d varchar); ALTER TABLE ts.testt REPLICA IDENTITY FULL; INSERT INTO ts.testt (t, d)    VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику SELECT setting AS lmm FROM pg_settings WHERE name = 'log_min_messages' \gset ALTER SYSTEM SET log_min_messages=debug1; SELECT pg_reload_conf(); CREATE TABLE ts.testt (i serial,                         t varchar,                         d varchar,                        x numeric); CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Выполняем на мастере UPDATE и DELETE UPDATE ts.testt SET t = t || ' upd' WHERE i = 2; DELETE FROM ts.testt WHERE i = 3; SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет -- Заодно меняем одну строчку и удаляем другую SELECT * FROM ts.testt; UPDATE ts.testt SET t = 'updated on the replica' WHERE i = 1; DELETE FROM ts.testt WHERE i = 2; SELECT * FROM ts.testt; -- На мастере обновляем строку которой уже нет на реплике -- а удаляем ту, которая есть, но уже отличается -- от версии на мастере. Для того чтоб убедиться в  -- том, что репликация прошла - после изменений вставим строки -- если репликация приостановится, то вставленные строки -- не появятся на реплике. UPDATE ts.testt SET t = 'updated on the master' WHERE i = 2; DELETE FROM ts.testt WHERE i = 1; INSERT INTO ts.testt (t, d)    VALUES ('004', 'fourth'), ('005', 'fifth element'), ('006', 'sixth'); SELECT * FROM ts.testt;  -- Проверяем на реплике - произошла репликация или нет SELECT * FROM ts.testt; -- полимеры утрачены, смотрим логи -- Добавляем первичные ключи через предварительное создание -- уникального индекса. -- Cначала на реплике, иначе ошибки приостановят репликацию CREATE UNIQUE INDEX CONCURRENTLY testt_hm_idx ON ts.testt (i); ALTER TABLE ts.testt   ADD CONSTRAINT hm_pkey PRIMARY KEY USING INDEX testt_hm_idx; -- Затем на мастере CREATE UNIQUE INDEX CONCURRENTLY testt_hm_idx ON ts.testt (i); ALTER TABLE ts.testt   ADD CONSTRAINT hm_pkey PRIMARY KEY USING INDEX testt_hm_idx; -- Изменим значения полей на реплике UPDATE ts.testt SET t = 'updated on the replica again' WHERE i <> 1; SELECT * FROM ts.testt;  -- Обновляем и удаляем пару строк на мастере. -- Для того чтоб убедиться в том, что репликация -- прошла - после изменений вставим строку. -- Если репликация приостановится, то вставленная строка -- не появится на реплике. UPDATE ts.testt SET t = 'recently updated on the master' WHERE i = 6; DELETE FROM ts.testt WHERE i = 4; INSERT INTO ts.testt (t, d)    VALUES ('007', 'seventh'); SELECT * FROM ts.testt;  -- Проверяем на реплике - произошла репликация или нет -- i=4 удалён, i=6 обновлён, новая строка появилась -- вот что PK животворящий делает! SELECT * FROM ts.testt;  -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; ALTER SYSTEM SET log_min_messages = :lmm; SELECT pg_reload_conf(); -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

Лишний столбец на мастере

Если же случилось так, что кто-то удосужился создать лишний столбец на мастере, то на реплике начнёт появляться множество таких сообщений:

DEBUG:  starting logical replication worker for subscription "your_subscription_name" DEBUG:  registering background worker "logical replication worker for subscription 16766" DEBUG:  starting background worker process "logical replication worker for subscription 16766" LOG:  logical replication apply worker for subscription "your_subscription_name" has started DEBUG:  connecting to publisher using connection string "host=192.168.122.182 dbname=your_db_name" ERROR:  logical replication target relation "public.your_table_name" is missing some replicated columns DEBUG:  unregistering background worker "logical replication worker for subscription 16766" LOG:  background worker "logical replication worker" (PID 10708) exited with exit code 1

А на мастере множество таких:

DEBUG:  received replication command: IDENTIFY_SYSTEM DEBUG:  received replication command: START_REPLICATION SLOT "your_subscription_name" LOGICAL 0/0 (proto_version '1', publication_names '"your_publication_name"') LOG:  starting logical decoding for slot "your_subscription_name" DETAIL:  streaming transactions committing after 0/1AC5FAD0, reading WAL from 0/1AC5FA98 LOG:  logical decoding found consistent point at 0/1AC5FA98 DETAIL:  There are no running transactions. DEBUG:  got new restart lsn 0/1AC5FC40 at 0/1AC5FC40 DEBUG:  "your_subscription_name" has now caught up with upstream server

То есть вставка, изменение и удаление строк не принимается репликой, ведь на стороне реплики нет столбцов для поступивших данных. О сложившейся ситуации мастер «информируется». В этом случае на реплике постоянно создаётся и уничтожается новый процесс репликации для сбойной подписки.

В результате на мастере начнёт раздувать журнал предзаписи независимо от типа операции. Так можно довести и до останова сервера, не заметив вовремя разбухание журнала. Удаление столбца на стороне мастера не спасёт — логическое декодирование будет использовать записи из журнала предзаписи, а там добавленный столбец есть и никуда не денется. Также не спасёт и удаление таблицы из публикации — это связано с тем, что публикацией может пользоваться несколько подписок и такое лекарство окажется опаснее болезни, потому и возможность такую не реализовали.

Лично я считаю, что для публикаций с одним подписчиком не помешает добавить такую возможность — но пока что разработчики так не думают. В общем имеется три варианта:

  • Добавить на реплику недостающий столбец и надеяться, что на обоих серверах их содержимое совпадает (\x6c6f6c), иначе все операции UPDATE и DELETE по несовпадающим строкам пропадут и оставят после себя уже знакомые записи в логе "... not find row for ..." — это относится к REPLICA IDENTITY FULL.

    Отсутствие в добавленном столбце данных, имеющихся на мастере, приведет к тому, что пробка, конечно, рассосётся — только вот накопившиеся операции INSERT данных попадут в таблицу на реплике, а UPDATE и DELETE — нет. Потому что идентификатором строки будет вся строка, а в одном из столбцов данные не совпадают.

    Выбор так себе, но так можно относительно безопасно разгрести журнал предзаписи на мастере и не доводить до второго способа. Ну а далее — чистим публикацию от проблемной таблицы, обновляем подписку, добавляем таблицу обратно в публикацию, добавляем в таблицу недостающие столбцы, обновляем подписку, ждём. Если же на обоих серверах нормальные репликационные идентификаторы — первичный ключ или уникальный индекс — репликация восстановится без потерь данных.

  • Удалить подписку целиком и создать вновь, не забыв очистить таблицу перед пересозданием, ну и столбцы в неё добавить. Сработает потому, что удалится слот на стороне мастера.
  • Можно, на основе общего с мастером столбца, создать на реплике первичный ключ, или уникальный индекс в качестве REPLICA IDENTITY. После этого добавить недостающий столбец. Однако, при этом нужно быть уверенным, что значения в выбранном для репликационного идентификатора столбце — уникальны для всех строк таблицы. Тут возникает вопрос — почему первичный ключ не был создан сразу?
Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial,                         t varchar,                         d varchar); ALTER TABLE ts.testt REPLICA IDENTITY FULL; INSERT INTO ts.testt (t, d)    VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i serial,                         t text); CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Смотрим логи, потом добавляем на реплике недостающий столбец ALTER TABLE ts.testt ADD COLUMN d text; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

Дубликаты значений в столбцах репликационных идентификаторов или «Раньше думать надо было!»

При начальной синхронизации ERROR:  duplicate key value violates unique constraint "testt_pkey" DETAIL:  Key (i)=(3) already exists. CONTEXT:  COPY testt, line 3  -- Заодно узнали какой командой переносятся данные при ничальной синхронизации  После перехода в режим репликации ERROR:  duplicate key value violates unique constraint "testt_pkey" DETAIL:  Key (i)=(4) already exists.

Возникают такие ошибки потому, что с мастера на реплику приходит новая строка с имеющимся на реплике значением репликационного идентификатора на основе первичного ключа или уникального индекса. При обновлении логической репликацией или простом переносе данных такие ошибки не появляются. Они появляются если база находится в доступе на запись для пользователей или приложений и те этим пользуются. По сути, это не ошибка PostgreSQL, это ошибка проектирования и вопросы нужно задавать себе.

Исправлять такие инциденты не так уж и просто — сначала необходимо определить какая из двух конфликтующих строк неправильная, и насколько. Если неправильным является содержимое строки на реплике, то репликация продолжится после удаления с реплики такой строки.

Совсем другое дело, когда неправильной является строка, пришедшая с мастера. В этом случае всё равно придётся либо удалять строку на реплике, либо назначать ей заведомо большой идентификатор; ждать завершения загрузки с мастера отставших транзакций; заменять пришедшую с мастера строку старой строкой с реплики.

Ситуация в обоих случаях усугубляется тем, что лишних строк может быть очень много. Настолько, что новые транзакции будут выполняться быстрее, чем будут разбираться конфликтные ситуации. И всё время, пока расследуется инцидент, размер журнала предзаписи будет разрастаться, а пользователи реплики не будут иметь возможности получать актуальные данные.

Если предполагается использовать логическую репликацию не для обновления, а для обмена данными между различными базами — следует очень внимательно проработать вопрос об идентификации строк. Так как для идентификации, обычно, используются последовательности, то можно посоветовать пробежаться по слайдам доклада CTO Stickeroid Ai, Камиля Исламова о способах применения последовательностей в PostgreSQL и документацию по последовательностям, затем перепроектировать их в сбойной системе баз данных.

Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial PRIMARY KEY,                         t varchar); ALTER TABLE ts.testt REPLICA IDENTITY FULL; INSERT INTO ts.testt (t)    VALUES ('001'), ('002'), ('003'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i bigserial PRIMARY KEY,                         t text); INSERT INTO ts.testt (i, t)    VALUES (3, '003 only replica value'),          (4, '004 only replica value'); CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Смотрим логи, потом удаляем лишнюю строчку на реплике -- на мастере удалять не поможет - порядок применения -- сообщений репликации - транзакционный. Удаленная на мастере -- строка удалится на реплике только после разрешения инцидента. DELETE FROM ts.testt WHERE i = 3; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

Столбец NOT NULL без DEFAULT на реплике

ERROR:  null value in column "column_name" violates not-null constraint

Для любых столбцов на реплике не должно быть установлено свойство NOT NULL без указания значения по умолчанию. Особенно это касается тех столбцов, которые есть на мастере, потому что на реплике для них значение DEFAULT подставляться не будет — как пришел NULL, так и будет записываться.

Если в дополнительные NOT NULL столбцы значение по умолчанию не поставить, то репликация приостановится с приведённой выше ошибкой. Как только удалите такой столбец, зададите ему значение по умолчанию или удалите ограничение NOT NULL — репликация возобновится без потери данных.

Если в реплицируемой таблице на стороне мастера изначально есть значения NULL — сначала необходимо заполнить такие поля, либо удалить ограничение NOT NULL на реплике.

При появлении такой ошибки в начале репликации можно узнать из логов название проблемной таблицы, но не схему в которой она расположена:

ERROR:  null value in column "d" violates not-null constraint DETAIL:  Failing row contains (3, 003, null). CONTEXT:  COPY testt, line 3: "3    003     \N"

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

Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial PRIMARY KEY, t text, d text); INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', NULL); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i serial PRIMARY KEY, t text, d text NOT NULL DEFAULT 'null from master'); CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Начальная синхронизация не сработала - смотрите логи -- Исправляем проблему со столбцом d -- Добавляем новый столбец, которого нет на мастере ALTER TABLE ts.testt ALTER COLUMN d DROP NOT NULL,                      ALTER COLUMN d DROP DEFAULT; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; ALTER TABLE ts.testt ADD COLUMN x text NOT NULL DEFAULT 'start default'; ALTER TABLE ts.testt ALTER COLUMN x DROP DEFAULT; SELECT * FROM ts.testt; -- Выполняем на мастере вставку INSERT INTO ts.testt (t) VALUES ('004'); SELECT * FROM ts.testt; -- Проверяем логи реплике - там уже куча ошибок -- Затем исправляем на реплике проблему -- Либо убираем NOT NULL --   ALTER TABLE ts.testt ALTER COLUMN x DROP NOT NULL; -- Либо добавляем DEFAULT --   ALTER TABLE ts.testt ALTER COLUMN x SET DEFAULT 'second default'; -- Либо удаляем столбец --   ALTER TABLE ts.testt DROP COLUMN x; -- Проверяем на реплике - произошла репликация или нет SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

На мастере есть первичный ключ, на реплике он отсутствует

ERROR:  logical replication target relation "ts.testt" has neither REPLICA IDENTITY index nor PRIMARY KEY and published relation does not have REPLICA IDENTITY FULL

Ошибка наблюдается при репликации команд UPDATE и DELETE, если в таблице на реплике отсутствует первичный ключ, который есть на мастере. Повторяется до тех пор, пока на реплике не будет создан соответствующий первичный ключ. Репликация при этом приостанавливается и продолжается после устранения причины ошибки. Состав столбцов на обоих серверах либо идентичен, либо на реплике могут быть дополнительные столбцы; порядок столбцов может различаться.

Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial PRIMARY KEY, t text, d text); INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i serial, t text, d text);  CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Выполняем на мастере обновление UPDATE ts.testt SET t = t || ' updated' WHERE i = 1; INSERT INTO ts.testt (t, d) VALUES ('004', 'AFTER UPDATE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- репликация не произошла -- Исправляем на реплике отсутствие первичного ключа, ждём, проверяем репликацию, удаляем первичный ключ для воспроизведения ошибки при удалении строки. ALTER TABLE ts.testt ADD PRIMARY KEY (i); SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- репликация возобновилась ALTER TABLE ts.testt DROP CONSTRAINT testt_pkey; -- Выполняем на мастере удаление DELETE FROM ts.testt WHERE i = 1; INSERT INTO ts.testt (t, d) VALUES ('005', 'AFTER DELETE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- репликация не произошла -- Исправляем на реплике отсутствие первичного ключа, ждём, проверяем репликацию ALTER TABLE ts.testt ADD PRIMARY KEY (i); SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- репликация возобновилась -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE IF EXISTS ts.testt; ---

На мастере и реплике первичные ключи или индексы построены на разных столбцах

ERROR:  publisher did not send replica identity column expected by the logical replication target relation "ts.testt"

Ошибка появляется при репликации команд UPDATE и DELETE, если в таблице на реплике первичный ключ (индекс) построен не на том столбце, на котором построен первичный ключ (индекс) на мастере. Повторяется до тех пор, пока на реплике не будет удалён неправильный и не будет создан правильный первичный ключ (индекс). Репликация при этом приостанавливается и продолжается после устранения причины ошибки. На реплике могут быть дополнительные столбцы, порядок столбцов может различаться.

Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial PRIMARY KEY, t text, d text); INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); CREATE PUBLICATION testt_pub for TABLE ts.testt; SELECT * FROM ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i serial, t text PRIMARY KEY, d text);  CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Выполняем на мастере обновление UPDATE ts.testt SET t = t || ' upd' WHERE i = 1; INSERT INTO ts.testt (t, d) VALUES ('004', 'AFTER UPDATE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- посмотреть ошибки -- Исправляем на реплике неправильный первичный ключ, ждём, проверяем репликацию, возвращаем неправильный первичный ключ для воспроизведения ошибки при удалении строки. ALTER TABLE ts.testt DROP CONSTRAINT testt_pkey; ALTER TABLE ts.testt ADD PRIMARY KEY (i); SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- репликация возобновилась ALTER TABLE ts.testt DROP CONSTRAINT testt_pkey; ALTER TABLE ts.testt ADD PRIMARY KEY (t); -- Выполняем на мастере удаление DELETE FROM ts.testt WHERE i = 1; INSERT INTO ts.testt (t, d) VALUES ('005', 'AFTER DELETE'); SELECT * FROM ts.testt; -- Проверяем на реплике - произошла репликация или нет SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- репликация не произошла -- Исправляем на реплике неправильный первичный ключ, ждём, проверяем репликацию. ALTER TABLE ts.testt DROP CONSTRAINT testt_pkey; ALTER TABLE ts.testt ADD PRIMARY KEY (i); SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- репликация возобновилась -- Чистим реплику DROP SUBSCRIPTION IF EXISTS testt_sub; DROP TABLE IF EXISTS ts.testt; -- Чистим мастер DROP PUBLICATION IF EXISTS testt_pub; DROP TABLE ts.testt; ---

Фантомного индекса боль

ERROR:  cannot delete from table "test" because it does not have a replica identity and publishes deletes HINT:  To enable deleting from the table, set REPLICA IDENTITY using ALTER TABLE.

Ошибка очень интересная и наблюдается на мастере при выполнении команд UPDATE и DELETE, если в таблице на мастере в качестве репликационного идентификатора использовался уникальный индекс, который потом удалили, например потому, что создали взамен первичный ключ, или просто так удалили (всяко быват, уж мы их ругам-ругам, ничо не помогат).

Ошибку эту можно получить только на мастере и только на таблицах, включенных в публикации, поддерживающие UPDATE и DELETE. Вообще эта ошибка означает именно отсутствие любого репликационного идентификатора, но в данном случае всё немного интереснее — идентификатором остаётся индекс, который удалили.

Чтоб исправить ошибку нужно вернуть REPLICA IDENTITY DEFAULT, перенацелив тем самым поиcк идентификатора туда, где он есть — на первичный ключ. Также можно воссоздать индекс и снова перенацелиться на него, так как oid у него будет уже другой, и система не будет его видеть.

Если же нет времени строить индексы и первичные ключи — включаем REPLICA IDENTITY FULL, а уж затем восстанавливаем индексы и так далее. В рабочих системах начинать изменения нужно с реплики, иначе вылезут другие ошибки.

Идентифицировать именно такой вариант развития событий не так уж и просто, хотя бы потому, что никому такое в голову не придёт. И, не приведи вселенная, если ещё до вас кто-то удалил и снова создал индекс — выявить причину происходящего будет практически невозможно.

Воспроизведение

-- Воспроизводить на мастере -- Создаём таблицу CREATE TABLE ts.testt (i serial, t text, d text); -- Создаём индекс CREATE UNIQUE INDEX CONCURRENTLY testt_idx ON ts.testt USING btree (i); -- Назначаем индекс в качестве репликационного идентификатора ALTER TABLE ts.testt REPLICA IDENTITY USING INDEX testt_idx; -- Информация о таблице отображает индекс как REPLICA IDENTITY \d ts.testt -- Смотрим что назначено репликационным идентификатором -- relreplident = i, т.е. индекс SELECT relname, relreplident -- i-index, d-default   FROM pg_class    WHERE oid = 'ts.testt'::regclass; -- Без следующей строки ошибка воспроизводится, но не так наглядно. ALTER TABLE ts.testt ADD CONSTRAINT pkey PRIMARY KEY (i);  -- Добавился первичный ключ в информации по таблице \d ts.testt -- Смотрим что назначено репликационным идентификатором -- всё ещё relreplident = i, т.е. индекс -- так и должно быть, ведь ПК как РИ не назначали SELECT relname, relreplident -- i-index, d-default   FROM pg_class    WHERE oid = 'ts.testt'::regclass; CREATE PUBLICATION testt_pub for TABLE ts.testt; INSERT INTO ts.testt (t, d) VALUES ('001', 'first'), ('002', 'second'), ('003', 'third'); DROP INDEX ts.testt_idx; -- После удаления индекса - информации про него нет \d ts.testt -- Смотрим что назначено репликационным идентификатором -- всё ещё relreplident = i, т.е. индекс -- несмотря на то, что индекса уже нет, он всё ещё РИ SELECT relname, relreplident -- i-index, d-default   FROM pg_class    WHERE oid = 'ts.testt'::regclass; -- Сейчас будут ошибки, а посмотришь на \d testt выполненный выше -- то непонятно, как же так - первичный ключ ведь есть. -- есть-то он есть, да вот только запрос к pg_class -- показывает что идентификатором служит индекс - i, а не d - default -- а индекс-то этот уже удалён UPDATE ts.testt SET t = t || ' upd' WHERE i = 1; DELETE FROM ts.testt WHERE i = 3; -- Cоздаём одноимённый индекс обратно и попробуйте -- найти в чем причина ошибки - индекс-то существует, идентификатором -- он вроде как назначен... CREATE UNIQUE INDEX CONCURRENTLY testt_idx ON ts.testt USING btree (i); ALTER TABLE ts.testt REPLICA IDENTITY USING INDEX testt_idx; -- Смотрим есть ли индексы на таблице со столбцом indisreplident=t  -- и такой индекс есть, это воссозданный исходный индекс SELECT pgc.oid, pgc.relname, pgi.indisreplident   FROM pg_class AS pgc,      pg_index AS pgi   WHERE pgc.oid = pgi.indexrelid     AND pgi.indrelid IN (SELECT oid FROM pg_class WHERE relname = 'testt'); -- а тут написано что идентификатором служит индекс: \d ts.testt  -- Однако UPDATE и DELETE так и не работают UPDATE ts.testt SET t = t || ' upd' WHERE i = 1; DELETE FROM ts.testt WHERE i = 3; -- Пустим в ход дефибриллятор: ALTER TABLE ts.testt REPLICA IDENTITY DEFAULT; ALTER TABLE ts.testt REPLICA IDENTITY USING INDEX testt_idx; \d ts.testt SELECT relname, relreplident -- i-index, d-default   FROM pg_class    WHERE oid = 'ts.testt'::regclass; -- Визуально в свойствах таблицы всё то же самое, но теперь всё работает UPDATE ts.testt SET t = t || ' upd' WHERE i = 1; DELETE FROM ts.testt WHERE i = 3; DROP TABLE ts.testt; DROP PUBLICATION testt_pub; -- Не полагайтесь только на /d, всегда ищите и в системных каталогах! -- Если индекс есть, то не факт, что он используется. ---

Непреобразуемые типы

При начальной синхронизации ERROR:  invalid input syntax for type bigint: "\xaabbcc" CONTEXT:  COPY testt, line 1, column t: "\xaabbcc"  После перехода в режим репликации ERROR:  invalid input syntax for type bigint: "\xdeadbeef" CONTEXT:  processing remote data for replication target relation "ts.testt" column "t", remote type bytea, local type bigint

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

И здесь снова неполная информация о том, в какой таблице произошла ошибка. То есть при начальной синхронизации в строке с контекстом есть информация о таблице, но не о схеме. Но, если уже после начальной синхронизации столбец был изменён, то информация будет полноценной.

Воспроизведение

-- Подготавливаем мастер CREATE TABLE ts.testt (i serial PRIMARY KEY, t bytea); INSERT INTO ts.testt (t) VALUES('\xaabbcc'::bytea); SELECT * FROM ts.testt; CREATE PUBLICATION testt_pub FOR TABLE ts.testt; -- Подготавливаем реплику CREATE TABLE ts.testt (i bigserial PRIMARY KEY, t bigint); CREATE SUBSCRIPTION testt_sub    CONNECTION 'host=192.168.122.182                dbname=test                user=log_rep_rolename                password=sadp!'   PUBLICATION testt_pub; SELECT pg_sleep(5.5); SELECT * FROM ts.testt;  -- Начальная синхронизация не прошла -- Всё - ошибка в логах, теперь надо её исправить. -- Вот это вот не поможет: ALTER TABLE ts.testt ALTER COLUMN t SET DATA TYPE bytea USING t::bytea; -- Только так: ALTER TABLE ts.testt DROP COLUMN t, ADD COLUMN t bytea; SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Убеждаемся в завершении начальной синхронизации -- Начальная синхронизаация завершилась и на реплике -- снова поменяем столбец на неправильный ALTER TABLE ts.testt DROP COLUMN t, ADD COLUMN t bigint; -- Вставим данные на мастере INSERT INTO ts.testt (t) VALUES('\xdeadbeef'::bytea); SELECT * FROM ts.testt; -- Всё - репликация снова на паузе SELECT * FROM ts.testt; -- Снова ремонтируем, но делаем такой тип данных, -- в который можно преобразовать bytea ALTER TABLE ts.testt DROP COLUMN t, ADD COLUMN t text;  SELECT pg_sleep(5.5); SELECT * FROM ts.testt; -- Убеждаемся в работе репликации -- Чистим реплику DROP SUBSCRIPTION testt_sub; DROP TABLE ts.testt; -- Чистим мастер DROP PUBLICATION testt_pub; DROP TABLE ts.testt; ---

И такое может пригодиться, если что-то натворили непонятное

-- Удаление слота на мастере SELECT pg_drop_replication_slot('test_sub'); -- Удаление подписки на реплике при отсутствующем слоте ALTER SUBSCRIPTION testt_sub DISABLE; ALTER SUBSCRIPTION testt_sub SET (slot_name=NONE); DROP SUBSCRIPTION testt_sub;

Общие замечания

Лучше первичных ключей нет на свете репликационных идентификаторов. В случае, когда в таблице отсутствует PRIMARY KEY и его добавление невозможно, то всегда имеется возможность создать его используя неблокирующее построение индекса с последующим назначением его ограничением первичного ключа.

Выполнять создание индексов желательно до переноса схемы данных, с тем, чтоб они перенеслись на реплику в составе схемы БД, ну или не забыть создать их и на реплике. К тому же на реплике возможно сразу создать первичный ключ с аналогичным составом столбцов ещё до начала репликации.

Использовать REPLICA IDENTITY FULL стоит только если строки в таблице незначительного размера и редко изменяются. При использовании такого типа идентификатора желательно воздержаться от изменения структуры принимающей таблицы.

Если после переноса схемы вам необходимо поменять в ней некоторые таблицы, то возможно проверять безопасность изменений в новой базе используя специально восстановленную резервную копию мастера как источник данных и устанавливая log_min_messages=debug1 для проверки правильности работы репликации по части UPDATE и DELETE. Изменение этого параметра не требует перезапуска сервера, поэтому возможно его переключать в любое время, например на время проверки внесенных изменений.

После начала репликации на мастере нельзя добавлять столбцы, а если добавлять, то начинать надо с реплики.

Новые столбцы с NOT NULL на реплике хороши только если в комплекте идёт DEFAULT.

Ошибки могут случаться, но в большинстве случаев они некритичные и легко устраняются, только нужно с умом подойти к выбору репликационных идентификаторов. Если речь идёт не об обновлении, а о более сложных схемах применения логической репликации, то к проектированию системы в целом нужно подойти очень ответственно, иначе можно отправить всё в нокдаун или потерять данные.

И на сладкое: можно добиться того, что системные каталоги на реплике сохранят информацию о подписках, которые, как казалось, были удалены. Тут может помочь только реинициализация кластера БД. В таком случае команда \dRs показывает наличие подписки, а SELECT * FROM pg_subscription; — нет. При этом на реплике все необходимые для обслуживания процессы запускаются, но ничего полезного не делают, кроме множества ошибок в логах (не можем подключиться, слота нет…), даже при специально повторно созданных объектах и слотах. То есть было утеряно некоторое количество внутренней информации о подписке. За месяц экспериментов такое положение дел было достигнуто только два раза, и оба раза это произошло после отправки хоста тестовых виртуалок в ждущий или спящий режим. Хоть и получалось так не всегда — не делайте так. Но случиться такое с хост-системой всё-таки может, так что про такую вероятность нужно знать.

ссылка на оригинал статьи https://habr.com/ru/company/postgrespro/blog/489308/

Еще раз о регекспах, бэктрекинге и том, как можно положить на лопатки JVM двумя строками «безобидного» кода

Раннее утро, десятая чашка кофе, безуспешные попытки понять почему ваше клиентское (или еще хуже – серверное) java-приложение намертво зависло при вычислении простого регекспа на небольшой строке… Если подобная ситуация уже возникала в вашей жизни, вы уже наверняка знаете про бэктрекинг и темную сторону регулярных выражений. Остальным – добро пожаловать под кат!

Бэктрекинг, или вечное ожидание результата

Проблема бэктрекинга при матчинге регулярных выражений уже неоднократно поднималась в различных статьях на хабре (раз, два, три), поэтому опишем ее суть без погружения в детали. Рассмотрим простой синтетический пример – типичный экземпляр из так называемых «evil regexes» (аналог изначально представлен тут):

@Test public void testRegexJDK8Only() {   final Pattern pattern = Pattern.compile("(0*)*1");   Assert.assertFalse(pattern.matcher("0000000000000000000000000000000000000000").matches()); }

Напомню: символ * в регулярных выражениях («ноль или несколько символов») называется квантификатором. Им же являются такие символы, как ?, +, {n} (n – количество повторений группы или символа).

Если запустить код на JDK8 (почему на более актуальных версиях воспроизводиться не будет – опишем далее), то JVM будет очень долго вычислять результат работы метода matches(). Едва ли вам удастся его дождаться, не состарившись на несколько месяцев или даже лет.

Что же пошло не так? Стандартная реализация Pattern/Matcher из пакета java.util.regex будет искать решение из теста следующим образом:

  1. * жадный квантификатор, поскольку всегда будет пытаться захватить в себе как можно большее количество символов. Так, в нашем случае группа (0) захватит полностью всю строку, звезда снаружи группы увидит, что может повториться ровно один раз, а единицы в строке не окажется. Первая проверка на соответствие провалилась.

  2. Произведем откат (backtrack) к начальному состоянию. Мы попытались захватить максимальную группу из нулей и нас ждал провал; давайте теперь возьмём на один нолик меньше. Тогда группа (0) захватит все нули без одного, снаружи группы укажет на наличие единственной группы, а оставшийся ноль не равен единице. Снова провал.

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

Легко догадаться, что по мере уменьшения «начальной» жадной группы будет появляться множество вариаций соседних групп (0), которые также придется откатывать и проверять все большее количество комбинаций. Сложность будет расти экспоненциально; при наличии достаточного количества нулей в строке – прямо как в нашем примере – возникнет так называемый катастрофический бэктрекинг, который и приведет к печальным последствиям.

«Но ведь пример абсолютно синтетический! А в жизни так бывает?» — резонно спросите вы. Ответ вполне ожидаем: бывает, и очень часто. Например, для решения бизнес-задачи программисту необходимо составить регулярку, проверяющую, что в строке есть не более 10 слов, разделенных пробелами и знаками препинания. Не заморачиваясь с аккуратным созданием регекспа, на свет может появиться следующий код:

@Test public void testRegexAnyJDK() { 	final Pattern pattern = Pattern.compile("([A-Za-z,.!?]+( |\\-|\\')?){1,10}");   Assert.assertFalse(pattern.matcher("scUojcUOWpBImlSBLxoCTfWxGPvaNhczGpvxsiqagxdHPNTTeqkoOeL3FlxKROMrMzJDf7rvgvSc72kQ").matches()); }

Представленная тестовая строка имеет длину 80 символов и сгенерирована случайным образом. Она не заставит JVM на JDK8+ работать вечно – всего лишь около 30 минут – но этого уже достаточно, чтобы нанести вашему приложению существенный вред. В случае разработки серверных приложений риск многократно увеличивается из-за возможности проведения ReDoS-атак. Причиной же подобного поведения, как и в первом примере, является бэктрекинг, а именно – сочетание квантификаторов «+» внутри группы и «{1,10}» – снаружи.

Война с бэктрекингом или с разработчиками Java SDK?

Чем запутаннее паттерн, тем сложнее для неопытного человека увидеть проблемы в регулярном выражении. Причем речь сейчас идет вовсе не о внешних пользователях, использующих ваше ПО, а о разработчиках. Так, с конца нулевых было создано значительное количество тикетов с жалобой на бесконечно работающий матчинг. Несколько примеров: JDK-5026912, JDK-7006761, JDK-8139263. Реже можно встретить жалобы на StackOverflowError, который тоже типичен при проведении матчинга (JDK-5050507). Все подобные баги закрывались с одними и теми же формулировками: «неэффективный регексп», «катастрофический бектрекинг», «не является багом». 

Альтернативным предложением сообщества в ответ на невозможность «починить» алгоритм было внедрение таймаута при применении регулярного выражения или другого способа остановить матчинг, если тот выполняется слишком долго. Подобный подход можно относительно легко реализовать самостоятельно (например, часто его можно встретить на сервисах для проверки регулярных выражений — таких как этот), но предложение реализации таймаута в API java.util.regex также неоднократно выдвигалось и к разработчикам JDK (тикеты JDK-8234713, JDK-8054028, JDK-7178072). Первый тикет все еще не имеет исполнителя; два остальных были закрыты, поскольку «правильным решением будет улучшить реализацию, которая лучше справляется с кейсами, в которых наблюдается деградация» (пруф). 

Доработки алгоритма действительно происходили. Так, в JDK9 реализовано следующее улучшение: каждый раз, когда применяется жадный квантификатор, не соответствующий данным для проверки, выставляется курсор, указывающий на позицию в проверяемом выражении. При повторной проверке после отката достаточно убедиться, что если для текущей позиции проверка уже была провалена, продолжение текущего подхода лишено смысла и проводиться не будет (JDK-6328855, пояснение). Это позволило исключить бесконечный матчинг в тесте testRegexJDK8Only() начиная с версии jdk9-b119, однако второй тест вызывает задержки вне зависимости от версии JDK. Более того, при наличии обратных ссылок в регулярных выражениях оптимизация не используется.

Опасный враг: внешнее управление

Публикации, упомянутые в самом начале статьи, отлично раскрывают варианты оптимизации регулярных выражений, в результате чего катастрофический бектрекинг перестает быть опасным врагом; для сложных случаев потребуется дополнительная экспертиза, но в целом регулярное выражение можно почти всегда записать «безопасным» образом. Да и проверить себя тоже можно, например, на npmjs.com. Проблема остается при составлении очень сложных регулярок, а также в тех сценариях, когда регулярное выражение задается не программистом, а аналитиком, заказчиком после передачи решения, или же пользователем. В последнем случае управление сложностью регулярного выражения оказывается снаружи – и вам придется позаботиться о том, чтобы при любых условиях приложение продолжало корректную работу.

Использование стандартной библиотеки в таком случае, очевидно, не лучший выбор. К счастью, существуют сторонние фреймворки, позволяющие производить матчинг за линейное время. Одним из таких фреймворков является RE2, под капотом которого используется DFA — подход. Не будем вдаваться в детали реализации и подробно описывать разницу подходов; отметим лишь его растущую популярность – RE2 используется как дефолтный движок в Rust, а также активно применяется во множестве продуктов экосистемы Google. Для JDK7+ существует RE2/J, которая является прямым портом из C++ — версии библиотеки.

В конце января 2021 года был опубликован драфт JEP-а, в котором предлагается создать движок для регулярных выражений с матчингом за линейное время, и одним из основных подходов в реализации является именно RE2.

RE2/J — серебряная пуля?

Может показаться, что переход на RE2/J – отличный выбор практически для каждого проекта. Какова цена линейного времени выполнения?

  • У RE2/J отсутствует ряд методов API у Matcher;

  • Синтаксис регулярных выражений совпадает не полностью (у RE2/J отствутет часть конструкций, в том числе – обратные ссылки, backreferences). Вполне вероятно, после замены импорта ваша регулярка перестанет корректно распознаваться;

  • Несмотря на то, что формально код принадлежит Google, библиотека не является официальной, а основным ее мейнтейнером является единственный разработчик – Джеймс Ринг.

  • Разработчик фреймворка подчеркивает: «Основная задача RE2/J заключается в обеспечении линейного времени выполнения матчинга при наличии регулярных выражений от внешних источников. Если все регулярные выражения находятся под контролем разработчика, вероятно, использование java.util.regex является лучшим выбором«.

Надеюсь, этих пунктов достаточно, чтобы убедиться: RE2/J – не серебряная пуля; фреймворк не является бескомпромиссным решением для проверки на соответствие регулярным выражениям. Реализация при создании кода повлечет ограниченный функционал, а прямая замена импорта в уже существующем коде может негативно сказаться на стабильности работы приложения.

Итоги

  1. Даже простые регулярные выражения при невнимательном написании могут сделать ваш продукт уязвимым для ReDoS.

  2. Движков регулярных выражений, которые были бы одновременно максимально функциональны, быстры и стабильны, не существует.

  3. Если все регулярные выражения в приложении находятся под контролем разработчика – обязательно тестируйте их, чтобы убедиться в отсутствии риска падения.

Если возможность самостоятельно задавать регулярное выражение есть не только у разработчика, то стоит реализовать защиту от вечной проверки с помощью создания таймаута или использования сторонних решений, позволяющих решать задачу матчинга за линейное время – например, таких, как RE2/J.

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

Дайджест свежих материалов из мира фронтенда за последнюю неделю №456 (22 — 28 февраля 2021)

Предлагаем вашему вниманию подборку с ссылками на новые материалы из области фронтенда и около него.

Медиа    |    Веб-разработка    |    CSS    |    JavaScript    |    Браузеры

Медиа

podcast «Новости 512» от CSSSR: Firefox 86, TypeScript 4.2, рендеры и мемоизация, «Код» Петцольда, Babel 7.13.0, 30-летие Python
podcast Подкаст «Фронтенд Юность» #174: Самый типизированный
video Pro Conf #88: OpenJS World 2020
video Как сделать презентацию на движке Shower: быстрый старт, шаблон, элементы и устройство темы

Веб-разработка

en Будущее веб-приложений — это HTML-over-WebSockets
en Как веб-компоненты используются в GitHub и Salesforce
en 10 самых популярных методов взлома веба в 2020 году
en Сколько времени нужно SEO, чтобы показать результаты?
en Новости веб-платформы: Reduced Motion, CORS, WhiteHouse.gov, popups, and 100vw
en Настигнет ли Deno в 2021 году NodeJS?

CSS

en Создавайте адаптивные эффекты изображения с помощью градиентов CSS и aspect-ratio
en Стилизация символов CSS с помощью Background-Clip
en Houdini: JavaScript API для расширения CSS
en Погружение в псевдоэлементы ::before и ::after
en Как имитировать прозрачность с помощью CSS Background
en Обеспечение правильного вертикального положения для крупного текста
en Погружение в тени
en Поддержка CSS-in-JS в DevTools
en Будущее CSS: анимация с прокруткой и @scroll-timeline (часть 1)
en Отладка repaint-проблем, вызванных CSS Transition
en Красота крошечных улучшений в CSS
en Как стилизовать битые изображения с помощью css
en DRY подход к цветовым темам в CSS

JavaScript

habr Мир JavaScript в 2021 году
habr Неудачный опыт миграции Electron приложения на ECMAScript модули
habr JavaScript нанобенчмарки и преждевременные тормоза
en Анонс TypeScript 4.2
en Как работать с датой в простом Javascript — библиотеки не нужны
en 7 вопросов для интервью о ключевом слове «this» в JavaScript.
video Redux vs Mobx: плюсы, минусы, область применения

Браузеры

Релиз Firefox 86
В десктопной версии Google Chrome появилась поддержка автоматических субтитров
«Яндекс.Браузер» ограничит передачу данных пользователей сторонним трекерам
Компания Mozilla опровергла ложную информацию об удалении лисы с логотипа Firefox

Дайджест за прошлую неделю.
Материал подготовили dersmoll и alekskorovin.

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

CQS (CQRS) со своим блэкджеком

Command-query separation (CQS) — это разделение методов на read и write.

Command Query Responsibility Segregation (CQRS) — это разделение модели на read и write. Предполагается в одну пишем, с нескольких можем читать. М — масштабирование.

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

Размышления навеяны этой статьей Паттерн CQRS: теория и практика в рамках ASP.Net Core 5 и актуальны для анемичной модели. Для DDD все по-другому.

Историческая справка

Начать пожалуй стоит с исторической справки. Сначала было как-то так:

public interface IEntityService {     EntityModel[] GetAll();     EntityModel Get(int id);     int Add(EntityModel model);     void Update(EntityModel model);     void Delete(int id); }  public interface IEntityRepository {     Entity[] GetAll();     Entity Get(int id);     int Add(Entity entity);     void Update(Entity entity);     void Delete(int id); } 

С появлением CQS стало так:

public class GetEntitiesQuery {      public EntityModel[] Execute() { ... } }  public class GetEntityQuery {      public EntityModel Execute(int id) { ... } }  public class AddEntityCommand {      public int Execute(EntityModel model) { ... } }  public class UpdateEntityCommand {      public void Execute(EntityModel model) { ... } }  public class DeleteEntityCommand {      public void Execute(int id) { ... } } 

Эволюция

Как видим, два потенциальных god-объекта разделяются на много маленьких и каждый делает одну простую вещь — либо читает данные, либо обновляет. Это у нас CQS. Если еще и разделить на два хранилища (одно для чтения и одно для записи) — это будет уже CQRS. Собственно что из себя представляет например GetEntityQuery и UpdateEntityCommand (здесь и далее условный псевдокод):

public class GetEntityQuery {     public EntityModel Execute(int id)     {         var sql = "SELECT * FROM Table WHERE Id = :id";         using (var connection = new SqlConnection(...connStr...))         {              var command = connection.CreateCommand(sql, id);              return command.Read();         }     } }  public class UpdateEntityCommand {     public void Execute(EntityModel model)     {         var sql = "UPDATE Table SET ... WHERE Id = :id";         using (var connection = new SqlConnection(...connStr...))         {              var command = connection.CreateCommand(sql, model);              return command.Execute();         }     } } 

Теперь к нам приходит ORM. И вот тут начинаются проблемы. Чаще всего сущность сначала достается из контекста и только затем обновляется. Выглядит это так:

public class UpdateEntityCommand {     public void Execute(EntityModel model)     {         var entity = db.Entities.First(e => e.Id == model.Id); // <-- опа, а что это? query?         entity.Field1 = model.Field1;          db.SaveChanges();     } } 

Да, если ORM позволяет обновлять сущности сразу, то все будет хорошо:

public class UpdateEntityCommand {     public void Execute(EntityModel model)     {         var entity = new Entity { Id = model.Id, Field1 = model.Field1 };         db.Attach(entity);          db.SaveChanges();     } } 

Так а что делать, когда надо достать сущность из базы? Куда девать query из command? На ум приходит сделать так:

public class GetEntityQuery {     public Entity Execute(int id)     {         return db.Entities.First(e => e.Id == model.Id);     } }  public class UpdateEntityCommand {     public void Execute(Entity entity, EntityModel model)     {         entity.Field1 = model.Field1;          db.SaveChanges();     } } 

Хотя я встречал еще такой вариант:

public class UpdateEntityCommand {     public void Execute(EntityModel model)     {         var entity = _entityService.Get(model.Id); // )))          entity.Field1 = model.Field1;          db.SaveChanges();     } }  public class EntityService {     public Entity Get(int id)     {         return db.Entities.First(e => e.Id == model.Id);     } } 

Просто перекладываем проблему из одного места в другое. Эта строчка не перестает от этого быть query.

Ладно, допустим остановились на варианте с GetEntityQuery и UpdateEntityCommand. Там хотя бы query не пытается быть чем-то другим. Но куда это все сложить и откуда вызывать? Пока что есть одно место — это контроллер, выглядеть это будет примерно так:

public class EntityController {     [HttpPost]     public EntityModel Update(EntityModel model)     {         var entity = new GetEntityQuery().Execute(model.Id);                  new UpdateEntityCommand().Execute(entity, model);          return model;     } } 

Да и через некоторое время нам понадобилось, например, отправлять уведомления:

public class EntityController {     [HttpPost]     public EntityModel Update(EntityModel model)     {         var entity = new GetEntityQuery().Execute(model.Id);                  new UpdateEntityCommand().Execute(entity, model);                  _notifyService.Notify(NotifyType.UpdateEntity, entity); // <-- А это query или command?          return model;     } } 

В итоге контроллер у нас начинает толстеть.

Лирическое отступление IDEF0 и BPMN

Мало того, реальные бизнес-процессы сложные. Если взглянуть на диаграммы IDEF0 или BPMN можно увидеть несколько блоков, за каждым из которых может скрываться код наподобие нашего кода из контроллера или вложенная серия блоков.

image

И приведу пример одной реальной задачи: по гео-координатам получить погоду в заданной точке. Есть внешний условно-бесплатный сервис. Поэтому требуется оптимизация в виде кэша. Кэш не простой. Хранится в базе данных. Алгоритм выборки: сначала идем в кэш, если там есть точка в радиусе 10 км от заданной и в пределах 1 часа по времени, то возвращаем погоду из кэша. Иначе идем во внешний сервис. Здесь и query, и command, и обращение к внешнему сервису — все-в-одном.

Решение

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

Как видим искомый CQS изначально создан для абстрагирования на уровне доступа к данным. Там с ним проблем нет. Код, который расположился у нас в контроллере — это бизнес-код, еще один уровень абстракции. И именно для этого уровня выделим еще одно понятие — бизнес-история. Или Story.

Одна бизнес-история — это один из блоков на диаграмме IDEF0. Она может иметь вложенные бизнес-истории, как блок IDEF0 может иметь вложенные блоки. И она может обращаться к искомым понятиям CQS — это к Query и Command.

Таким образом, код из контроллера мы переносим в Story:

public class EntityController {     [HttpPost]     public EntityModel Update(EntityModel model)     {         return new UpdateEntityStory().Execute(model);     } }  public class UpdateEntityStory {     public EntityModel Execute(EntityModel model)     {         var entity = new GetEntityQuery().Execute(model.Id);                  new UpdateEntityCommand().Execute(entity, model);                  _notifyService.Notify(NotifyType.UpdateEntity, entity);          return model;     } } 

И контроллер остается тонким.

Данная UpdateEntityStory инкапсулирует в себе законченный конкретный бизнес-процесс. Ее можно целиком использовать в разных местах (например в вызовах API). Она легко подвергается тестированию и никоим образом не ограничивает использование моков/фейк-объектов.

Диаграмму IDEF0/BPMN можно разбросать по таким Story, что даст более легкий вход в проект. Все изменения можно будет уложить в следующий процесс: сначала меняем документацию (диаграмму IDEF0) — затем дописываем тесты — а уже в конце дописываем бизнес-код. Можно наоборот, по этим Story автоматически построить документацию в виде IDEF0/BPMN диаграмм.

Но чтобы получить более стройный подход, необходимо соблюдать некоторые правила:
1. Story — входная точка бизнес-логики. Именно на нее ссылается контроллер.
2. Но внутрь Story не должны попадать такие вещи как HttpContext и тому подобное. Потому что тогда Story нельзя будет легко вызывать в другом контексте (например в hangfire background job или обработчике сообщения из очереди — там не будет никаких HttpContext).
3. Входящие параметры Story опциональны. Story может возвращать что-либо или не возвращать ничего (хотя для сохранения тестируемости хорошо бы она что-нибудь возвращала).
4. Story может работать как с бизнес-сущностями, так и с моделями и DTO. Может внутри вызывать соответствующие мапперы и валидаторы.
5. Story может вызывать другие Story.
6. Story может вызывать внешние сервисы. Хотя внешний вызов можно тоже оформить как Story. Об этом ниже с нашим сервисом погоды.
7. Story не может напрямую обращаться к контексту базы данных. Это область ответственности Query и Command. Если нарушить это правило, все запросы и команды вытекут наружу и размажутся по всему проекту.
8. На Story можно навешивать декораторы. Об этом тоже ниже.
9. Story может вызывать Query и Command.
10. Разные Story могут переиспользовать одни и те же Query и Command.
11. Query и Command не могут вызывать другие Story, Query и Command.
12. Только Query и Command могут обращаться к контексту базы данных.
13. В простых случаях можно обойтись без Story и из контроллеров вызывать сразу Query или Command.

Теперь тот самый пример с сервисом погоды:

public class GetWeatherStory {     public WeatherModel Execute(double lat, double lon)     {         var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);          if (weather == null)         {              weather = _weatherService.GetWeather(lat, lon);              new AddWeatherCommand().Execute(weather);         }          return weather;     } }  public class GetWeatherQuery {     public WeatherModel Execute(double lat, double lon, DateTime currentDateTime)     {         // Нативный SQL запрос поиска записи в таблице по условиям:         // * в радиусе 10 км от точки lat/lon         // * в пределах 1 часа от currentDateTime         // С использованием расширений PostGis или аналогичных          return result;     } }  public class AddWeatherCommand {     public void Execute(WeatherModel model)     {         var entity = new Weather { ...поля из model... };         db.Weathers.Add(entity);         db.SaveChanges();     } }  public class WeatherService {     public WeatherModel GetWeather(double lat, double lon)     {         var client = new Client();         var result = client.GetWeather(lat, lon);         return result.ToWeatherModel(); // маппер из dto в нашу модель     } } 

Декораторы

И в заключении о декораторах. Чтобы Story стали более гибкими необходимо cложить их в DI контейнер / mediator. И добавить возможность декорировать их вызов.

Сценарии:

1. Запускать Story внутри транзакции scoped контекста базы данных:

public class EntityController {     [HttpPost]     public EntityModel Update(EntityModel model)     {         return _mediator.Resolve<UpdateEntityStory>().WithTransaction().Execute(model);     } }  // или  [Transaction] public class UpdateEntityStory {     ... } 

2. Кэшировать вызов

public class EntityController {     [HttpPost]     public ResultModel GetAccessRights()     {         return _mediator             .Resolve<GetAccessRightsStory>()             .WithCache("key", 60)             .Execute();     } }  // или  [Cache("key", 60)] public class GetAccessRightsStory {     ... } 

3. Политика повторов

public class GetWeatherStory {     public WeatherModel Execute(double lat, double lon)     {         var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);          if (weather == null)         {              weather = _mediator                  .Resolve<GetWeatherFromExternalServiceStory>()                  .WithRetryAttempt(5)                  .Execute(lat, lon);               _mediator.Resolve<AddWeatherCommand>().Execute(weather);         }          return weather;     } }  // или  [RetryAttempt(5)] public class GetWeatherFromExternalServiceStory {     ... } 

4. Распределенная блокировка

public class GetWeatherStory {     public WeatherModel Execute(double lat, double lon)     {         var weather = new GetWeatherQuery().Execute(lat, lon, DateTime.NowUtc);          if (weather == null)         {              weather = _mediator                  .Resolve<GetWeatherFromExternalServiceStory>()                  .WithRetryAttempt(5).                  .Execute(lat, lon);               _mediator.Resolve<AddWeatherStory>()                  .WithDistributedLock(LockType.RedLock, "key", 60)                  .Execute(weather);         }          return weather;     } }  // или  [DistributedLock(LockType.RedLock, "key", 60)] public class AddWeatherStory {     ... } 

И тому подобное.

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

Модернизация токарного станка под работу с ЧПУ

В нашей домашней мастерской есть токарный станок по металлу Jet GBH-1340A с устройством цифровой индикации (УЦИ). Мы давненько обсуждали возможность добавить к нему ЧПУ, потому что без компьютерного контроля некоторые виды деталей чрезвычайно сложно изготавливать с высокой точностью. Статья повествует о полученном в этом процессе опыте, включая допущенные ошибки и рекомендации по их избежанию, а также детально раскрывает весь процесс от начальной комплектации до получения готового результата.

Подготовка

Тем не менее к проекту мы подошли с некоторой долей прокрастинации. С самого начала мы выбрали контроллер частотно-регулируемого электропривода для шпинделя, шаговые моторы NEMA 34 и драйверы для осей станка на основе того, что обнаружили в нашем фрезерном станке Tormach 770. Мы также нашли в интернете интерфейсную плату с параллельным портом для управления ЧПУ. Одним из основных критериев выбора всех запчастей была их дешевизна, хотя в конечном счете пришлось переплачивать. Как говорится, скупой платит дважды.

Детали прибыли и примерно на год были отложены в сторону ввиду большого количества других активных проектов. Мы лишь изредка обращались к этому, чтобы сделать кое-какие измерения и продумать конкретное размещение шаговых двигателей. Полноценно же переключиться обратно к реализации задуманного нас заставил внезапный отказ двигателя шпинделя нашего Jet. Вот тогда уже мы достали все заготовленные детали и всерьез занялись переделкой.

Общая сводка по проекту

Затраченное время: множество выходных
Сложность: продвинутая
Стоимость: $2,500–$2,800

Материалы

  • Станок металлообрабатывающий с устройством цифровой индикацией (УЦИ);
  • 3-фазный асинхронный двигатель Marathon #145THFR5329 / $500, встал на замену сгоревшего двигателя шпинделя;
  • Контроллер двигателя частотно-регулируемого электропривода Emerson Commander SK / $450;
  • Плата управления ЧПУ для LPT-порта, а именно многофункциональная плата ЧПУ C11G с сайта CNC4PC.com / $68;
  • Шаговый двигатель NEMA 34 (2 шт.) для X- и Z-осей, Model 34HS38-3008S / $110 каждый;
  • Плата драйвера шагового двигателя (2 шт.) GeckoDrive G213V / $150 каждая;
  • Компьютер с ПО Linux для ЧПУ (доступно на linuxcnc.org). Мы использовали древний Pentium 4;
  • Фильтр (электромагнитной совместимости) ЭМС Roxburgh для подавления сетевых помех;
  • Шарико-винтовая пара 40″ с шариковой гайкой / $225;
  • Упорные подшипники (4 шт.);
  • Опора двигателя (2 шт.), изготовленная из нержавеющей стали и алюминия на Tormach 770 с ЧПУ;
  • Соединительная втулка (2 шт.), она же гибкая муфта вала, на Amazon от $5 до $50 в зависимости от размера;
  • Корпус блока управления, сталь, размер 24″×16″×10″;
  • Выключатели для питания, защитного отключения и т.д.;
  • Провода: 12ga, 14ga и 22ga;
  • Реле, выключатели и т.д. из разобранных частей станка;

Инструменты

  • Фрезерный станок ЧПУ, концевые фрезы, расточные оправки, токарные инструменты для обработки креплений двигателя.
  • Дрель, отвертки, гаечные ключи, инструменты для зачистки проводов, обжимные устройства и т.д.
  • Паяльник и олово.

Весь процесс реконструкции был поделен на три этапа:

  1. Модификация самого механизма.
  2. Сборка блока управления.
  3. Установка и настройка управляющего ПК.


Плата управления для LPT-порта/интерфейсная плата

Модификация станка. Часть 1


Наш 40″ станок по металлу до апгрейда

Этот станок имеет следующие характеристики: расстояние между центрами 40 дюймов и максимально возможный диаметр заготовки 13 дюймов. По умолчанию скорость шпинделя контролируется через редуктор, расположенный за шпинделем и приводимый в действие однофазным двигателем 230В. Редуктор изменять не потребовалось; мы просто выбрали оптимальные настройки передачи, и далее при использовании ЧПУ управление скоростью уже будет осуществляться контроллером частотного преобразователя. Выход из строя оригинального однофазного двигателя, фактически, только сыграл нам на руку, так как его замена трехфазным аналогом давала большую степень контроля и позволяла удвоить максимально возможную скорость вращения, которая для умершего мотора составляла 1 750 Об/мин. Самое же удачное, что частотный преобразователь был способен преобразовать 220В из одной в три фазы. Оригинальный блок управления был снят с задней части станка, и некоторые из его контрольных реле вместе с другими деталями мигрировали в новый.


Фрезеровка первой опоры двигателя оси Z

Каретка, удерживающая режущие инструменты, предполагала два варианта управления своим движением по оси Z. (На токарном станке ось Z идет слева направо, а ось X является осью поперечной подачи). Есть основной ходовой винт для общего резания и второй ходовой винт, который вращается синхронно со шпинделем для нарезания резьбы. Оба винта приводятся в движение одним редуктором и задействуются для перемещения каретки с помощью рычагов управления на самой каретке. Мы решили убрать винт нарезания резьбы и стержень, управляющий первичным ходовым винтом. Это позволило нам приводить в движение основной ходовой винт с помощью шагового двигателя (ШД), размещенного на противоположном конце и закрепленного ремнем и шкивами. Основному винту требовалось всего чуть более 50 вращений для перемещения каретки на 1”, и мы рассчитывали, что это даст некоторую степень контроля точности.


Первый вариант привода двигателя оси Z

При помощи фрезерного станка ЧПУ мы изготовили опору двигателя, которая крепится болтами к токарному станку на вертлюге, почти также как генератор в автомобиле устанавливается для натяжения ремня.


Замена поперечной подачи: основная рукоятка оси X

Для оси X, иначе говоря, поперечной подачи, очевидным выбором стал прямой привод от шагового двигателя. Мы сняли рукоятки со станка и фрезеровали еще одну алюминиевую опору. Для снятия напряжения ходовой винт был присоединен к шаговому двигателю через соединительную муфту.


Мотор с поперечной подачей в сборе: новый шаговый двигатель оси X

В задней бабке токарного станка доработок мы не делали. Она останется под ручным управлением, в то время как компьютер будет выполнять всю тяжелую работу по осям X и Z.

Сборка блока управления

Оригинальный блок был слишком мал и просто не вместил бы все компоненты, необходимые для управления станком. Поэтому мы заказали корпус размером 24″×16″×10″, в который бы точно все поместилось. Глубина 10” была, возможно, излишней, но зато обеспечила достаточно пространства для крепления вентиляторов охлаждения и переключателей на боковой стенке. В то же время корпус оказался достаточно жестким, чтобы, будучи установленным на задней стенке станка, удерживать на себе монитор и при этом не падать.


Расположение элементов управления

Мы закрепили все компоненты на листе алюминия толщиной 1/8”, который можно было вынимать из корпуса для удобства доступа. При этом он также выступал в качестве отводчика тепла. Для элементов ручного управления шпинделем мы вырезали в листе и корпусе соответствующие отверстия.


Прокладка проводов

Во избежание спутывания проводов мы добавили кабельные каналы с открытыми пазами.


Тщательно промаркированное соединение

В течение всего процесса схема обрабатывалась в Visio, где все соединения тщательно пронумеровывались, а провода для соответствия помечались с обоих концов.


Собранный блок управления (с головой Стэна внутри)


Блок управления в сборе. Первое тестирование

Весь процесс сборки блока управления занял около 60 часов.

Настройка управляющего ПК

Хоть во многих проектах ЧПУ для управления устройством и используют параллельный порт, в них зачастую не используется новейшее наиболее производительное аппаратное обеспечение. Во-первых, многие современные ПК не оборудованы параллельными портами, к тому же многие из современных процессоров оптимизированы таким образом, что хорошо работают с ПО, но малоэффективны в прямой реализации портов ввода/вывода по технологии bit-banging для чувствительного ко времени управления аппаратной частью. Это не проблема для ПК, управляющего принтером, потому что USB снижает степень нагрузки, но в нашем случае с фрезером на ЧПУ неверная конфигурация оборудования/ПО может привести к тому, что рез будет сделан в десятках тысячных долей от места, куда указывал G-code. (Например из-за пропуска шагов, прим. переводчика).

К счастью, для основных возможностей программного обеспечения ЧПУ есть списки протестированных компьютеров, так что подбирать было уже куда легче. Мы выбрали старый Dell Optiplex с процессором Pentium 4 и ОС LinuxCNC. Два таких ПК (один на запчасти) мы удачно приобрели в местном магазине подержанных компьютеров по $30 за каждый.

LinuxCNC предлагает очень широкий набор вариантов управления и отлично поддерживается сообществом компьютерных энтузиастов. Следуя инструкциям с сайта, установить LinuxCNC оказалось достаточно просто, и эта ОС отлично запустилась на нашем древнем ПК. С помощью программы StepConf мы смогли настраивать отдельные контакты параллельного порта любым нужным нам образом. Однако, как оказалось, лучше было настроить LinuxCNC до покупки каких-либо управляющих устройств, так как эта ОС предлагала предустановленные конфигурации для нескольких типов оборудования, о некоторых из которых во время начальных закупок мы попросту не знали.

Все это не заняло много времени, и наша плата для LPT-порта при нажатии клавиш уже светилась как новогодняя елка, разве что магический дым не появлялся. И вроде бы все должно было быть четко, но… ничего не работало.

Скупой платит дважды

Все-таки нечестно говорить, что не работало ничего. Были намеки на практически правильное функционирование некоторых компонентов. Один из ШД реагировал на команду повернуться единственным глухим звуком. На драйвере этого двигателя даже светился светодиод зелёным вплоть до этого момента, после чего переключался на красный. Драйвер другого ШД демонстративно горел красным сразу при подаче питания и продолжал пялиться на нас, словно глаз Саурона.

Мы просмотрели всю проводку. Мы сравнили свой вариант ее прокладки с вариантом в Tormach. Здесь не было проблем. И только позже проверив с помощью позаимствованного осциллографа выход платы управления ЧПУ мы нашли первую неполадку: напряжение выходного сигнала поднималось только до половины от необходимого драйверам ШД уровня. Купленная нами за $20 плата оказалась просто мусором. Мы решили на этот раз не скупиться и нашли на другом сайте еще одну плату стоимостью уже в $99. По ее прибытии выяснилось, что маркирована она другим сайтом: CNC4PC.com. При этом она также на 6 ревизий отставала от последней предлагаемой версии. Напряжение эта плата обеспечила достаточное, и мы рассчитывали, что двигатели заработают лучше. Но они молчали…

Я уже упоминал, что многое из купленного нами для собственного блока управления было выбрано по образцам из имеющегося фрезерного станка. Эти драйверы ШД были той же модели MA860H, что и в нем. Так что, рисуя в воображении счета на ремонт этого фрезера, мы начали заменять подозреваемые детали, устанавливая их в него. Шаговые двигатели были первыми, и к нашему облегчению оба заработали отлично. Следующими на проверку отправились их драйверы, и вот из них уже ни один не функционировал. Глаз Саурона продолжал насмехаться над нами. Заподозрив, что это был наш косяк, мы заказали еще пару драйверов той же модели. Оба оказались недееспособны сразу по прибытии. Один вообще отказался работать во фрезерном станке, а второй обеспечивал вращение, но только в одном направлении. Очевидно, что эти драйверы не являлись надежным решением.


Франкенштейн-драйвер двигателя: новые GeckoDrives, установленные в каркас нерабочего драйвера

Получше изучив информацию в интернете, мы вышли на модели GeckoDrive, которые полноценно оправдали наши ожидания. Эти платы отлично заработали сразу же после подключения, и обе прекрасно вписались в одно посадочное место, где ранее стоял один дешевый драйвер. При должном напряжении этим платам требовался отвод тепла, которым по умолчанию они оборудованы не были. Оставшиеся же нерабочие аналоги как раз оснащались теплоотводами и вентиляторами, которые оказались единственной рабочей их частью. В итоге мы просто встроили платы GeckoDrive в опустошенный каркас мертвого драйвера, решив таким образом сразу две проблемы.


Детали управления в сборе, но пока без корпуса

Новая интерфейсная плата оказалась намного крупнее изначальной, и теперь мы могли установить ее на место одного из оригинальных драйверов шаговых двигателей.

Небольшая заметка по поводу драйверов: они бывают аналоговыми и цифровыми. Вы можете найти на YouTube видео сравнения их в работе. На видео демонстрируется, и мы это подтверждаем, что цифровые экземпляры обеспечивают намного более плавную и тихую работу двигателя. Так что их повышенная стоимость себя оправдывает.

Сборка всего воедино

Все было на своих местах. Мы могли управлять ШД при помощи кнопок UI или инструкций G-code, а с помощью элементарного крепления двигателей к ходовым винтам можно было перемещать каретку вдоль обеих осей.

Мы не знали точного отношения скорости вращения ходовых винтов к боковому смещению, так что правильные установки для программы StepConf искали методом проб и ошибок. Эта программа запрашивает несколько значений: количество шагов двигателя на оборот, микрошаг драйверов, соотношение зубьев шкивов и шаг ходового винта. Если вы не уверены в этих значениях, имейте ввиду, что они перемножаются в одно значение, которое означает «шаги на дюйм». Если все эти значения кроме одного (не важно какого) установить на 1, то в итоге оставшееся значение будет большим числом, которое можно подстроить с отличной точностью.

Для этого мы следовали такому алгоритму:

  • Двигаясь слева направо, переместить каретку на приблизительную известную позицию. В UI ЧПУ сбросить смещения, установив значение позиции как 0.
  • Измерить расположение каретки.
  • С помощью G-code передвинуть каретку на 1” дальше вправо, то есть к Z1.
  • Измерить новое положение каретки и посчитать разницу в дюймах.
  • Разделить значение «шаги на дюйм» на пройденное кареткой расстояние, получив новое значение «шагов на дюйм». Например, если количество шагов на дюйм равно 20 000, и вы производите смещение на 1.015”, то новое значение будет 20 000/1.015 или 19 704 шагов на дюйм.
  • Повторять процесс, пока команда выполнить смещение на 1” не будет давать конкретно смещение на 1”.

Очень важно делать измерения после перемещения каретки только в одном направлении, потому что ходовой винт будет наверняка иметь некоторый люфт. Если проводить измерение после перемещения в противоположном направлении, то результат будет отклоняться вплоть до величины люфта.

Цифровой индикатор по-прежнему был прикреплен к токарному станку, что сильно упрощало процесс сравнения вводимых на ПК инструкций с фактическим перемещением каретки. Следуя разработанному нами алгоритму, мы должны были получить значение шагов на дюйм, которое бы давало согласованные результаты независимо от оси, на которой проводились измерения. Этот подход отлично работал для оси X, но при измерении оси Z результаты варьировались в диапазоне до 0.012”, что зависело от места проведения измерений. В чем-то крылась серьезная ошибка.

Модификация станка. Часть 2

Ходовые винты могут быть неточными, но винт должен быть совсем плох, чтобы отклонение то возрастало, то опять уменьшалось вдоль всех 40”. Проблема была в том, что помимо ходового винта в движении по оси Z были задействованы и другие шестерни, а также червячные передачи. Нам нужно было учесть неточность во всем этом связанном механизме. Люфт оси Z был таким же ужасным. В LinuxCNC есть способы компенсировать это, но для этого потребовалось бы вычислить ошибку в каждой точки вдоль всех 40” оси. Достичь желаемой точности было практически невозможно. Передаточный механизм нужно было менять.


Замеры места для расположения двигателя оси Z

Прецизионная шарико-винтовая пара (ШВП) может практически полностью устранить люфт, вопрос лишь в цене. В одной компании ШВП предлагалась аж за $3 500. В конечном итоге мы приобрели ШВП и гайку за $225 в Roton Products, расположенной в Миссури. Дополнительно потребовалась ее подгонка под купленные ранее подшипники, что в местной шлифовальной мастерской обошлось еще в $336. У данной ШВП люфт составлял уже всего 0.007”, но он хотя бы не изменялся по ходу длины винта, что позволило легко это компенсировать в LinuxCNC.


Вторая опора двигателя оси Z: середина вырезана фрезой, обработана и смонтирована вместе с ШВП

Мы также решили убрать ремень со шкивом и сделать новое крепление для ШВП, чтобы можно было реализовать прямой привод от ШД.


Крепление бабки ШВП оси Z

Каждый край вала удерживается парой упорных подшипников, закрепленных один к одному для блокировки подвижности с одновременным сохранением вращения. Сам вал закреплен между этими двумя опорами подшипников под некоторым натягом.


Модифицированный токарный станок с новыми опорами, подготовленными для ШД

Любому ЧПУ требуются конечные выключатели, чтобы машина могла находить базовую позицию на каждой оси.


Монтирование конечного выключателя

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

Прокладка проводов обычно осуществляется через кабельные каналы, но для оси X мы этого не стали делать, просто позволив проводам, идущим от блока управления сзади к оси X, запитываемой спереди, свободно свисать под станком.

Последние штрихи


Токарный станок с ЧПУ в действии. Тестовый запуск

Теперь у нас был полностью функционирующий токарный станок с ЧПУ. LinuxCNC работала отлично, хоть ее UI и напоминал приложение для старой Windows 98.


Скриншот LinuxCNC (Ни одна программа не загружалась, пока я не выяснил, как заставить ее игнорировать то, что она не подключена к станку)

К счастью, немного пошерстив форумы мы нашли два альтернативных UI, которые выглядели и работали на порядок лучше.

И как это обычно бывает с Linux, готовьтесь к чтению множества форумов и документации, а также редактированию текстовых файлов для получения нужной вам конфигурации.


Приспособление для заточки ножей. Рукоятки изготавливаются на токарном станке ЧПУ!


Готовое приспособление для заточки

В дальнейшем мы планируем кое-какие доработки:

  • В результате этого проекта станок лишился возможности нарезать резьбу. Тем не менее LinuxCNC поддерживает эту возможность, если удастся реализовать обратную связь от оптического датчика скорости шпинделя.
  • Будет очень кстати добавить жидкостное охлаждение СОЖ (смазочно-охлаждающей жидкостью), пусть даже для открытого станка, работающего на низких оборотах.
  • Можно ограничить люфт, заказав новые шариковые гайки, у которых каждый четвертый или пятый шарик имеет другой размер, что позволяет уменьшить погрешность между шарико-винтовой парой и гайкой.
  • ШВП необходимо защитить. Для этого нужно изготовить подходящие чехлы или хотя бы кисти для ее очистки.

ссылка на оригинал статьи https://habr.com/ru/company/ruvds/blog/544448/