Что реально лежит под системой
Боевое хранилище — файлы JSON. У бота это один объект с пользователями, заказами, лидами и обновлениями, который переписывается целиком на каждое событие и вызывается из десятков асинхронных обработчиков и фоновых задач одновременно. Ни временного файла с переименованием, ни блокировок.
Что из этого следует, если назвать вещи как в учебнике:
- Гонка «последний записавший побеждает». Два одновременных обращения — и одно затирает другое. Это не гипотеза: у нас так уже обнулилась база заявок.
- Повреждение файла при сбое в момент записи. Пишется весь объект целиком.
- Линейная деградация. Сериализуется всё хранилище на каждое событие, а не изменённая запись.
- Стихийная эволюция полей. Схемы нет — значит легаси-поле спокойно живёт рядом с действующим, а читатели угадывают, какое из них истинное. У нас так и было: старое поле менеджера соседствовало с новым.
Как это выглядит в коде. Имена обезличены, механика настоящая
// ── Было: read-modify-write всего хранилища на каждое событие ─────────────
const db = JSON.parse(fs.readFileSync(DB)); // читаем весь файл
db.orders.push(order); // меняем одну запись
fs.writeFileSync(DB, JSON.stringify(db)); // пишем весь файл обратно
// два одновременных вызова — один затирает другой;
// сбой в момент записи — на диске остаётся обрезанный JSON.
// ── Стало: временный файл + атомарное переименование ──────────────────────
const tmp = `${DB}.${process.pid}.tmp`;
const fh = await fs.promises.open(tmp, 'w');
await fh.writeFile(JSON.stringify(db));
await fh.sync(); // на диск, а не в буфер ОС
await fh.close();
await fs.promises.rename(tmp, DB); // подмена — одна операция ФСПереименование внутри одной файловой системы атомарно: читатель видит либо старый файл целиком, либо новый целиком, но никогда не половину. Это не устраняет гонку двух писателей — для неё нужна блокировка или очередь, — но убирает самый неприятный исход: битый файл после сбоя.
Повтор запроса не должен удваивать заявку, а идентификатор — рождаться счётчиком по файлу
// Ключ идемпотентности приходит от клиента и переживает ретрай сети.
const key = body.idempotency_key;
if (db.leads.some(l => l.key === key)) {
return { ok: true, duplicate: true }; // повтор — не новая заявка
}
// Идентификатор из времени и метки писателя: общего счётчика нет,
// значит нет и гонки за него при нескольких пишущих процессах.
const makeId = () => `${Date.now().toString(36)}-${WRITER}-${rand4()}`;
db.leads.push({ ...lead, key, id: makeId() });Самое дорогое следствие — не порча данных, а невозможность договориться. Когда у структуры нет схемы, каждый новый инструмент читает её по-своему. Наш замер: три инструмента на одной и той же странице насчитали 6, 7 и 3 цены — и все трое были по-своему правы, потому что «цена» нигде не определена формально.
Что при этом спроектировано
Реляционная схема существует как документ и разбирает текущее хранилище по таблицам: клиенты, водители, заказы и связанные сущности. Несколько решений оттуда, чтобы было видно, что это не набросок:
- Единый ключ профиля клиента — последние 10 цифр телефона, уникальные. Именно так человек и опознаётся в реальности: один и тот же клиент приходит с сайта, из мессенджера и из телефонного звонка.
- Деньги — целые тенге, без копеек, как уже сделано в коде. Не число с плавающей точкой.
- Время — с часовым поясом; свободные и полуструктурные данные — отдельным типом, а не россыпью колонок.
- Статусная модель не выдумывается, а берётся из работающего кода: операционные статусы и клиентский жизненный цикл формализуются такими, какие они есть.
Парный документ фиксирует контракт бэкенда: один источник правды на все каналы (панель диспетчера, бот, сайт, мини-приложение), разграничение видимости (клиент никогда не получает телефон водителя, ставки и служебные флаги) и правило «не ломать существующее» — работающие пути сохраняются один в один, новое помечается отдельно.
Связь «многие ко многим» уже есть — и это главный сигнал
Документная модель хороша, пока запись самодостаточна. Наша перестала быть такой: клиент связан с рейсами, рейсы со счетами, счета с водителями — и счёт не равен рейсу. Это выяснилось не в теории, а на сверке счетов: один счёт закрывает несколько рейсов, один рейс попадает в несколько документов.
Кусок спроектированной схемы — та часть, где документная модель ломается. Имена таблиц обезличены
-- Ключ профиля клиента — последние 10 цифр телефона: один и тот же человек
-- приходит с сайта, из мессенджера и телефонным звонком.
CREATE TABLE client (
phone10 CHAR(10) PRIMARY KEY,
name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE trip (
id BIGSERIAL PRIMARY KEY,
phone10 CHAR(10) REFERENCES client,
driver_id BIGINT REFERENCES driver,
price_tenge BIGINT NOT NULL, -- целые тенге, не число с плавающей точкой
happened_at TIMESTAMPTZ NOT NULL -- время всегда с поясом
);
-- Вот здесь документная модель и кончается: счёт не равен рейсу.
-- Один счёт закрывает несколько рейсов, один рейс попадает в несколько счетов.
CREATE TABLE invoice_trip (
invoice_id BIGINT REFERENCES invoice,
trip_id BIGINT REFERENCES trip,
PRIMARY KEY (invoice_id, trip_id)
);Последняя таблица — это и есть ответ на вопрос, зачем вообще реляционная схема, если файлы работают. Связь «многие ко многим» в документной модели выражается дублированием: список рейсов внутри счёта и список счетов внутри рейса, которые обязаны сходиться. Сходиться они будут ровно до первой ошибки, а поймать расхождение нечем — ограничения целостности существуют в СУБД, а не в файле.
Критерий перехода на СУБД мы зафиксировали: регулярные запросы вида «кто из водителей возил этого клиента в июне и по каким счетам». Пока такие запросы разовые — файлы дешевле. Как только они становятся ежедневными, файлы перестают быть решением, и это будет видно по критерию, а не по ощущению.
«Это эволюция Excel, а раньше — Access»
Точное описание, и спорить с ним нечем. Автоматизации на таблицах десятилетиями решали реальные задачи и упирались в один и тот же потолок: дальше развивать тяжело, ошибки и нестыковки копятся. То, что мы делаем, действительно стоит в этом ряду.
Разница в одном месте, и она не в технологиях. У Excel-автоматизации нет способа узнать, что она врёт, — кроме жалобы пользователя. У нас на этом месте стоят проверки и реестр классов ошибок, и именно они показали дыры в модели данных: не архитектор на ревью, а гейт, который покраснел. Это не делает нашу модель лучше — это делает её измеренной.
И про слово ERP — честно. Набор инструментов поверх общего хранилища — это ещё не ERP, как бы много инструментов в нём ни было. ERP — это единая транзакционная модель, где проводка в одном месте согласованно меняет состояние в остальных. У нас такой модели нет, и называть наш контур ERP мы не будем. Он решает свои задачи, и границу мы обозначаем до разговора о цене, а не после.
Что мы с этим делаем — по пунктам
| Дыра | Чем уже стреляла | Что делаем |
|---|---|---|
| Нет схемы и версии формата у данных | маркер кодировки сломал 5 инструментов; легаси-поле жило рядом с действующим | описание схемы + поле версии + проверка структуры при записи, в том же гейте, что и проверка кода |
| Запись без атомарности и блокировок | POST обнулил базу заявок, дефект прожил 2 дня | запись через временный файл с переименованием; идемпотентная вставка, чтобы повтор не удваивал запись |
| Идентификатор записи рождается счётчиком по файлу | при нескольких писателях коллизия гарантирована по построению | идентификатор из времени и метки писателя — без общего счётчика |
| Термины разбросаны, единого глоссария нет | расхождения канона между документами | единый словарь как отдельный артефакт, доступный и коду, и человеку |
Список получен не самокритикой ради красоты, а наложением профильной инженерной литературы на наши реальные системы. Мы предпочитаем, чтобы он был написан у нас на странице, а не найден заказчиком в середине проекта.