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

Что идёт не так, когда два человека создают записи одновременно
Представьте загружённый офис в 16:55. Двое заканчивают счёт и нажимают «Сохранить» с разницей в секунды. На обоих экранах на мгновение появляется «Счёт №1042». Одна запись побеждает, другая терпит неудачу, или что хуже — обе сохраняются с одним и тем же номером. Это самая распространённая реальная проблема: дублирующиеся номера, которые проявляются только под нагрузкой.
Тикеты ведут себя так же. Два агента одновременно создают тикет для одного клиента, а система «берёт следующий номер», глядя на последний сохранённый запись. Если оба запроса читают одно и то же «последнее» значение до того, как кто-то запишет, оба могут выбрать одинаковый следующий номер.
Второй симптом — более тонкий: пропуски в нумерации. Вы можете увидеть #1042, затем #1044, где #1043 отсутствует. Это часто происходит после ошибки или повторной попытки. Один запрос резервирует номер, затем сохранение падает из‑за ошибки валидации, таймаута или пользователь закрыл вкладку. Или фоновая задача повторяет попытку после сбоя сети и захватывает новый номер, хотя первая попытка уже «съела» один.
Для счётов это важно, потому что нумерация — часть вашей аудиторской истории. Бухгалтеры ожидают, что каждый счёт будет однозначно идентифицирован, а клиенты могут ссылаться на номера в платежах или письмах в поддержку. Для тикетов номер — это идентификатор, которым пользуются в разговорах, отчётах и экспортируемых данных. Дубли порождают путаницу. Пропущенные номера вызывают вопросы при проверках, даже если ничего подозрительного не происходило.
Здесь важный момент: не каждый метод нумерации может одновременно быть устойчивым к параллельным запросам и без пропусков. Защищённая от конкуренции нумерация (без дубликатов при множестве пользователей) достижима и должна быть обязательной. Нумерация без пропусков тоже возможна, но требует дополнительных правил и часто меняет работу с черновиками, ошибками и отменами.
Полезно определить, какие гарантии вам нужны от номеров:
- Никогда не повторяться (всегда уникальны)
- Быть в основном возрастающими (желательно)
- Никогда не пропускать (только если вы это спроектировали)
Когда вы определились с правилом, техническое решение становится проще выбрать.
Почему появляются дубликаты и пропуски
Большинство приложений следует простой схеме: пользователь нажимает «Сохранить», приложение запрашивает следующий номер, затем вставляет новую запись с этим номером. Кажется безопасным, потому что это идеально работает, когда делает один человек.
Проблемы начинаются, когда два сохранения происходят почти одновременно. Оба запроса могут дойти до шага «получить следующий номер», прежде чем кто‑то выполнит вставку. Если оба чтения видят одно и то же значение, они оба попытаются записать один и тот же номер. Это гонка: результат зависит от тайминга, а не от логики.
Типичный таймлайн выглядит так:
- Запрос A читает следующий номер: 1042
- Запрос B читает следующий номер: 1042
- Запрос A вставляет счёт 1042
- Запрос B вставляет счёт 1042 (или падает, если уникальность запрещает)
Дубликаты случаются, когда в базе ничего не мешает второй вставке. Если вы проверяете «занят ли номер?» только в коде приложения, вы всё равно можете проиграть гонку между проверкой и вставкой.
Пропуски — другая проблема. Они появляются, когда система «резервирует» номер, но запись так и не становится реальным, зафиксированным счётом или тикетом. Распространённые причины: неудачные платежи, поздние ошибки валидации, таймауты или пользователь закрыл вкладку после присвоения номера. Даже если вставка падает и ничего не сохраняется, номер может уже быть использован.
Скрытая конкуренция усугубляет ситуацию, потому что это редко просто «два человека кликают». У вас также могут быть:
- API‑клиенты, создающие записи параллельно
- Импорты батчами
- Фоновые задачи, генерирующие счета ночью
- Повторы из мобильных приложений при нестабильной связи
Корневые причины: (1) конфликт по таймингу, когда несколько запросов читают одно и то же значение счётчика, и (2) номера выделяются до того, как вы уверены, что транзакция завершится успешно. Любой план по устойчивой нумерации должен решить, что вам допустимо: отсутствие дубликатов, отсутствие пропусков или оба требования, и в каких конкретно сценариях (черновики, повторы, отмены).
Решите правило нумерации до выбора решения
Прежде чем проектировать устойчивую нумерацию, зафиксируйте, что номер должен означать в вашем бизнесе. Самая распространённая ошибка — сначала выбрать технический метод, а потом выяснить, что бухгалтерские или юридические правила требуют иного.
Начните с разделения двух целей, которые часто путают:
- Уникальность: ни два документа не должны иметь одинаковый номер.
- Отсутствие пропусков: номера уникальны и строго подряд идут без пропусков.
Многие системы стремятся только к уникальности и принимают пропуски. Пропуски могут появляться по нормальным причинам: пользователь открыл черновик и бросил его, платёж не прошёл после резервирования номера, запись была создана и затем аннулирована. Для тикетов пропуски чаще всего не имеют значения. Даже для счетов многие команды принимают пропуски, если могут объяснить их по аудиту (аннулировано, отменено, тест и т.д.). Нумерация без пропусков возможна, но требует дополнительных правил и часто снижает удобство работы.
Далее решите область действия счётчика. Небольшие различия в формулировке сильно меняют дизайн:
- Одна глобальная последовательность для всего или отдельные последовательности по компании/арендатору?
- Сбрасывать каждый год (2026-000123) или никогда не сбрасывать?
- Разные серии для счетов, кредит‑нотов и тикетов?
- Нужен ли человекочитаемый формат (префиксы, разделители) или достаточно внутреннего числа?
Конкретный пример: SaaS‑сервис с несколькими клиентскими компаниями может требовать номера счетов, уникальных на компанию и сбрасывающихся по календарному году, в то время как тикеты уникальны глобально и никогда не сбрасываются. Это два разных счётчика с разными правилами, даже если интерфейс похож.
Если вам действительно нужна нумерация без пропусков, явно пропишите, какие события допустимы после присвоения номера. Например: можно ли удалять счёт, или только аннулировать? Можно ли сохранять черновики без номера и присваивать номер только при окончательном утверждении? Эти решения часто важнее, чем техника БД.
Запишите правило в короткой спецификации перед реализацией:
- Какие типы записей используют последовательность?
- Что делает номер «использованным» (черновик, отправлен, оплачен)?
- Какой охват (глобально, по компании, по году, по серии)?
- Как обрабатываются аннулирования и исправления?
В AppMaster такое правило должно жить рядом с моделью данных и бизнес‑процессом, чтобы команда везде (API, веб, мобильное) соблюдала одинаковое поведение.
Распространённые подходы и что каждый гарантирует
Когда говорят о «нумерации счетов», часто путают две цели: (1) никогда не генерировать один и тот же номер дважды и (2) никогда не иметь пропусков. Большинство систем легко обеспечивают первое. Второе гораздо сложнее: пропуски могут появиться при любой ошибке транзакции, брошенном черновике или аннулировании.
Ниже популярные подходы и их гарантии:
Подход 1: последовательность в базе данных (быстрая уникальность)
Последовательность PostgreSQL — самый простой способ получить уникальные, возрастающие числа под нагрузкой. БД быстро раздаёт значения последовательности, даже при множестве параллельных вставок.
Что вы получаете: уникальность и преимущественную возрастаемость. Что не получаете: отсутствие пропусков. Если вставка падает после выдачи значения, этот номер «сгорает», и вы получите пропуск.
Подход 2: уникальный индекс плюс повторные попытки (пусть БД решает)
Генерируете кандидат‑номер в приложении, сохраняете и полагаетесь на UNIQUE‑ограничение, которое отклонит дубликат. При конфликте — повторяете с новым номером.
Это работает, но при высокой конкуренции даёт много неудач и повторов, что делает систему шумной и трудной для отладки. Это также не гарантирует отсутствие пропусков, если не добавить строгие правила резервирования.
Подход 3: строка счётчика с блокировкой (стремится к отсутствию пропусков)
Если вам действительно нужно отсутствие пропусков, обычный паттерн — отдельная таблица счётчиков (по одному ряду на область, например по году или компании). Блокируете этот ряд в транзакции, инкрементируете и используете новое значение.
Это ближе всего к нумерации без пропусков в реляционной БД, но есть стоимость: это горячая точка, из‑за которой все записи вынуждены ждать. Также повышается риск из‑за длинных транзакций, таймаутов и взаимоблокировок.
Подход 4: отдельный сервис резерва номеров (для специальных случаев)
Выделенный сервис нумерации централизует правила между несколькими приложениями или базами. Это оправдано, когда несколько систем должны выдавать согласованные номера.
Цена — операционный риск: ещё один компонент, который должен быть корректен, доступен и согласован.
Краткая сводка гарантий:
- Sequence: уникальность, быстро, допускает пропуски
- Unique + retry: уникальность, прост в низкой нагрузке, при высокой нагрузке может «трешить»
- Locked counter row: может обеспечивать отсутствие пропусков, медленнее при большой конкуренции
- Separate service: гибкость между системами, высокая сложность и дополнительные точки отказа
В no-code инструменте вроде AppMaster выборы те же: правильность достигается в базе. Логика приложения помогает с повторами и понятными ошибками, но окончательная гарантия должна опираться на ограничения и транзакции БД.
Пошагово: предотвратить дубликаты с помощью sequence и уникального ограничения
Если ваша основная цель — предотвратить дубликаты (а не гарантировать отсутствие пропусков), самый простой надёжный паттерн такой: используйте внутренний ID, сгенерированный БД, и обеспечьте уникальность для внешнего номера.
Начните с разделения понятий. Используйте значение, сгенерированное базой (identity/sequence), как первичный ключ для связей, правок и экспортов. Сохраняйте invoice_no или ticket_no в отдельном столбце, который показываете пользователям.
Практическая схема в PostgreSQL
Ниже распространённый подход: держать логику «следующего номера» в базе, где конкуренция обрабатывается корректно.
-- Internal, never-shown primary key
create table invoices (
id bigint generated always as identity primary key,
invoice_no text not null,
created_at timestamptz not null default now()
);
-- Business-facing uniqueness guarantee
create unique index invoices_invoice_no_uniq on invoices (invoice_no);
-- Sequence for the visible number
create sequence invoice_no_seq;
Теперь генерируйте отображаемый номер при вставке (не делая "select max(invoice_no) + 1"). Один простой паттерн — форматировать значение последовательности прямо в INSERT:
insert into invoices (invoice_no)
values (
'INV-' || lpad(nextval('invoice_no_seq')::text, 8, '0')
)
returning id, invoice_no;
Даже если 50 пользователей одновременно нажмут «Создать счёт», каждому вставке достанется своё значение последовательности, а уникальный индекс заблокирует любые случайные дубликаты.
Что делать при коллизии
С простой sequence коллизии редки. Они чаще возникают, когда добавляются дополнительные правила вроде «сброс по году», «по арендатору» или возможность редактировать номер вручную. Поэтому уникальное ограничение всё равно важно.
На стороне приложения обрабатывайте ошибку уникальности простым циклом повтора:
- Попробовать вставку
- Если получили ошибку уникального ограничения по invoice_no — попробовать снова
- Остановиться после нескольких попытов и показать понятную ошибку
Это работает, потому что повторы случаются редко — обычно только в необычных сценариях, когда два пути кода сгенерировали одинаковый отформатированный номер.
Сократите окно гонки
Не вычисляйте номер в UI и не резервируйте номера, сначала читая, а потом вставляя. Генерируйте номер как можно ближе к действительной записи в базе.
Если вы используете AppMaster с PostgreSQL, моделируйте id как identity‑первичный ключ в Data Designer, добавьте уникальное ограничение для invoice_no и генерируйте invoice_no в create‑флоу, чтобы вставка и присвоение номера происходили вместе. Так база остаётся источником правды, а проблемы конкурентности локализуются в том месте, где PostgreSQL сильнее всего.
Пошагово: построить нумерацию без пропусков с блокировкой строки
Если вам действительно нужны номера без пропусков, можно использовать транзакционную таблицу счётчиков и блокировку строк. Идея простая: только одна транзакция за раз может взять следующий номер для заданной области, значит номера выдаются подряд.
Сначала определите область. Многие команды требуют отдельные последовательности по компании, по году или по серии (INV vs CRN). Таблица счётчиков хранит последний использованный номер для каждой области.
Практический паттерн с блокировкой строки в PostgreSQL:
- Создайте таблицу, например
number_counters, с колонкамиcompany_id,year,series,last_numberи уникальным ключом по(company_id, year, series). - Начните транзакцию.
- Заблокируйте нужную строку счётчика с
SELECT last_number FROM number_counters WHERE ... FOR UPDATE. - Вычислите
next_number = last_number + 1, обновите строку доlast_number = next_number. - Вставьте счёт или тикет с
next_number, затем зафиксируйте транзакцию.
Ключ — FOR UPDATE. Под нагрузкой вы не получите дубликатов и не получите пропусков из‑за «двух пользователей получили один номер», потому что вторая транзакция будет ждать, пока первая не закоммитит (или не откатит). Эта задержка — цена за отсутствие пропусков.
Инициализация новой области
Нужно решить, что делать при появлении новой области (новая компания, новый год, новая серия):
- Предсоздавать строки счётчиков заранее (например, создать строки на следующий год в декабре).
- Создавать по требованию: попытаться вставить строку с
last_number = 0, а при конфликте перейти к обычному потоку блокировки и инкремента.
Если вы строите это в no-code среде вроде AppMaster, держите весь «заблокировать, инкремент, вставить» в одной транзакции бизнес‑процесса, чтобы всё либо произошло, либо не произошло.
Пограничные случаи: черновики, неудачные сохранения, отмены и правки
Большинство багов с нумерацией проявляются в тёмных местах: черновики, которые не были опубликованы, сохранения, которые упали, счета, которые аннулированы, и записи, которые редактируют после того, как кто‑то уже видел номер. Если вы хотите надёжную нумерацию, нужна чёткая политика, когда номер становится «реальным».
Ключевое решение — время присвоения. Если вы назначаете номер в момент создания черновика, вы получите пропуски из‑за брошенных черновиков. Если присваиваете только при финализации (posted/issued), номера будут более «плотными» и проще объяснимыми.
Неудачные сохранения и откаты — место, где ожидания часто расходятся с поведением БД. С типичной последовательностью, как только номер выдан, он считается использованным, даже если транзакция потом откатится. Это нормально и безопасно, но порождает пропуски. Если политика требует отсутствия пропусков, номер должен присваиваться только на финальном шаге и только при фиксации транзакции. Это обычно означает блокировку одной строки счётчика, запись номера и коммит как единой операции. При любой ошибке ничего не присваивается.
Отмены и аннулирования почти никогда не должны переиспользовать номер. Сохраняйте номер и меняйте статус. Аудиторы и клиенты ожидают непротиворечивой истории, даже при исправлениях.
Правки проще: как только номер стал видим вне системы, считайте его постоянным. Никогда не перенумеровывайте счёт или тикет после того, как он был отправлен, экспортирован или распечатан. Для исправлений создавайте новый документ и ссылайтесь на старый (кредит‑нота, замена), но не переписывайте историю.
Практический набор правил, который многие команды принимают:
- Черновики не имеют финального номера (используйте внутренний ID или «DRAFT»).
- Присваивайте номер только при «Post/Issue», внутри той же транзакции, что и смена статуса.
- Аннулирования и отмены сохраняют номер, но получают явный статус и причину.
- Напечатанные/отосланные номера не меняются.
- Импорт сохраняет оригинальные номера и двигает счётчик до безопасного следующего значения.
Миграции и импорты требуют внимания. При переносе из другой системы перенесите существующие номера как есть, затем установите счётчик, начиная после максимального импортированного значения. Решите, что делать с конфликтующими форматами (разные префиксы по годам). Обычно лучше хранить отображаемый номер точно как был и держать отдельный внутренний PK.
Пример: helpdesk быстро создаёт тикеты, но многие остаются черновиками. Назначайте номер только когда агент нажимает «Отправить клиенту». Это избегает расходования номеров на брошенные черновики и выравнивает видимую последовательность с реальной коммуникацией. В AppMaster та же идея: держите черновики без публичного номера и генерируйте финальный номер в шаге «submit», который коммитит транзакцию.
Частые ошибки, ведущие к дубликатам или неожиданным пропускам
Большинство проблем с нумерацией происходит из одной простой идеи: трактовать номер как отображаемое значение, а не как общую состояние. Когда несколько человек сохраняют одновременно, системе нужно одно понятное место, где решается следующий номер, и одно чёткое правило на случай неудачи.
Классическая ошибка — использовать SELECT MAX(number) + 1 в коде приложения. Это выглядит нормально при одиночном пользователе, но два запроса могут прочитать одно и то же MAX до того, как кто‑то зафиксирует запись. Оба сгенерируют один и тот же следующий номер — и вы получите дубликат. Даже с «проверить, потом повторить» вы создадите лишнюю нагрузку и странные пики под высокой нагрузкой.
Ещё частый источник дубликатов — генерация номера на клиенте (браузер или мобильное) до сохранения. Клиент не знает, что делают другие пользователи, и не может безопасно резервировать номер, если сохранение упадёт. Клиентские номера годятся для временных ярлыков вроде «Черновик 12», но не для официальных идентификаторов.
Пропуски удивляют команды, которые ожидают, что последовательности бесшовны. В PostgreSQL последовательности проектировались для уникальности, а не для непрерывности. Номера могут пропускаться при откате транзакции, при предварительном получении ID или при перезапуске БД. Это нормальное поведение. Если ваша реальная потребность — «никаких дубликатов», то sequence плюс уникальное ограничение — обычно правильное решение. Если нужна строго безпропусковая нумерация, нужен другой паттерн (обычно блокировка строки) и придётся смириться с потерей пропускной способности.
Блокировки тоже могут навредить, если они слишком широки. Одна глобальная блокировка для всей нумерации заставляет все операции создания встать в очередь, даже когда можно было бы разделить счётчики по компаниям, локациям или типам документов. Это замедляет систему и создаёт ощущение «случайных зависаний» при сохранении.
Проверьте следующие ошибки при реализации устойчивой нумерации:
- Использование
MAX + 1(или «найти последний номер») без уникального ограничения на уровне БД. - Генерация финальных номеров на клиенте с попыткой «потом исправить конфликты».
- Ожидание, что PostgreSQL sequences будут без пропусков.
- Блокировка одного общего счётчика для всего вместо партиционирования, где это имеет смысл.
- Тестирование только с одним пользователем, из‑за чего гонки обнаруживаются лишь в продакшене.
Практический совет по тестированию: запустите параллельное создание 100–1000 записей и проверьте дубликаты и неожиданные пропуски. В AppMaster то же самое: убедитесь, что финальный номер присваивается внутри одной серверной транзакции, а не в UI.
Быстрая проверка перед релизом
Перед выпуском пройдитесь по частям, которые обычно ломаются под реальной нагрузкой. Цель простая: каждая запись получает ровно один бизнес‑номер, и правила выполняются даже когда 50 человек нажимают «Создать» одновременно.
Контрольный список перед релизом:
- Убедитесь, что поле бизнес‑номера имеет уникальное ограничение в базе данных (не только проверку в UI). Это ваша последняя линия защиты при столкновениях.
- Убедитесь, что номер присваивается внутри той же транзакции, что и сохранение записи. Если присвоение и сохранение разделены между запросами, рано или поздно увидите дубликаты.
- Если требуются номера без пропусков, присваивайте их только при финализации (например, при выпуске счёта), а не при создании черновика. Черновики, брошенные формы и упавшие платежи — самые частые источники пропусков.
- Добавьте стратегию повторных попыток для редких конфликтов. Даже с блокировкой строк или sequence можно получить ошибки сериализации, взаимоблокировки или нарушения уникальности в пограничных таймингах. Простая повторная попытка с небольшим бэкофом часто решает проблему.
- Проведите стресс‑тест с 20–100 одновременными созданными запросами через все пути: UI, публичный API и массовые импорты. Тестируйте реалистичные сценарии: всплески, медленные сети и двойные отправки.
Быстрая валидация: смоделируйте момент наплыва в службу поддержки: два агента открыли форму «Новый тикет», один отправляет через веб‑приложение, в то же время импорт вставляет тикеты из почтового ящика. После теста проверьте, что все номера уникальны, в правильном формате и не осталось полусохранённых записей.
В AppMaster те же принципы: держите присвоение номера в транзакции БД, полагайтесь на ограничения PostgreSQL и тестируйте как UI, так и API‑эндоинты.
Пример: загруженная служба поддержки и дальнейшие шаги
Представьте службу поддержки, где агенты создают тикеты в веб‑приложении, а интеграция одновременно создаёт тикеты из чата и почты. Всем нужны номера вида T-2026-000123, и каждый номер должен указывать на один тикет.
Наивный подход: прочитать «последний номер», прибавить 1 и сохранить. Под нагрузкой два запроса могут прочитать один и тот же «последний номер» до сохранения, оба посчитают один и тот же следующий номер и вы получите дубликаты. Если пытаться исправить это повторными попытками, часто получите пропуски.
База может остановить дубликаты даже при наивном коде. Добавьте уникальное ограничение на столбец ticket_number. Тогда при попытке вставить одинаковый номер одна вставка упадёт, и вы сможете корректно повторить. Это и есть суть устойчивой нумерации: пусть БД обеспечивает уникальность, а не UI.
Нумерация без пропусков меняет рабочий процесс. Если вы требуете отсутствия пропусков, обычно нельзя присваивать финальный номер при создании (черновике). Вместо этого создавайте тикет со статусом Draft и пустым ticket_number. Присваивайте номер только при финализации, чтобы брошенные черновики и упавшие сохранения не «сжигали» номера.
Пример структуры таблиц:
- tickets: id, created_at, status (Draft, Open, Closed), ticket_number (nullable), finalized_at
- ticket_counters: key (например "tickets_2026"), next_number
В AppMaster это можно смоделировать в Data Designer и реализовать логику в Business Process Editor:
- Create Ticket: вставить ticket со status=Draft и без ticket_number
- Finalize Ticket: начать транзакцию, заблокировать строку счётчика, установить ticket_number, инкрементировать next_number, коммит
- Test: запустить две параллельные «Finalize» и убедиться, что дубликатов нет
Что делать дальше: начните с правила (только уникальность или строго без пропусков). Если пропуски допустимы, sequence + уникальное ограничение — обычно достаточно и держит поток простой. Если нужно отсутствие пропусков, перенесите присвоение номера в шаг финализации и рассматривайте «черновик» как первый класс состояния. Затем нагрузочно протестируйте с несколькими агентами и интеграциями, чтобы увидеть поведение до прихода реальных пользователей.


