11 недель назад я начал делать социальную сеть, в которой гражданство зависело только от имени.
Если тебя зовут Егор, Георгий или одной из форм имени, происходящего от Georgios, то это место для тебя, 100%! Остальные становятся Наблюдателями: могут читать, реагировать, писать в личку, жить в своих районах — что‑то вроде ВНЖ.
На этом месте проект вполне мог закончиться хорошей шуткой, которой и являлся ИЗНАЧАЛЬНО. Но пользователи начали спрашивать, кто здесь главный.
Так в Егороде появился мэр. Потом люди спросили, кто выбирает мэра. Появились выборы.
Затем — партии, депутаты, кабинет, указы, вето и импичмент, а социальная сеть начала подозрительно напоминать государство, состоящее преимущественно из Node.js, SQLite и людей по имени Егор.
Сейчас в проекте около 11,9 тысячи регистраций, обычный DAU — примерно 30–35 человек, кодовая база выросла примерно до 210 тысяч строк, 176 таблиц и 2625 тестов.
И именно небольшой ежедневный онлайн в итоге сильнее всего повлиял на архитектуру, потому что город на тридцать человек может позволить себе довольно простую инфраструктуру.
Но он не может позволить себе переставать существовать, когда эти тридцать человек уходят спать.
Всё стоит на одном Node‑процессе и SQLite
Архитектура Егорода довольно скучная, и я считаю это достоинством.
Один процесс Node.js обслуживает REST, Socket.io и фронтенд. Backend — Express 4. База — better-sqlite3 в WAL‑режиме. Frontend — vanilla JS SPA; локально скрипты подключаются напрямую, а на production собираются через esbuild.
В грубом виде:
Браузер │ ├── REST └── Socket.io │ ▼ Node.js + Express │ ▼ better-sqlite3 │ ▼ один .db-файл
Когда всё начиналось, логика была простой: зачем мне отдельный PostgreSQL‑сервер для проекта, у которого ещё толком нет пользователей?
Через несколько месяцев вопрос стал интереснее:
а когда мне действительно понадобится PostgreSQL?
Пока я думаю, что не сейчас.
SQLite работает в WAL, на соединении включены внешние ключи, synchronous=NORMAL, около 16 МБ page cache, mmap_size=256MB, временные структуры в памяти и busy_timeout=5000.
db.pragma('foreign_keys = ON'); db.pragma('synchronous = NORMAL'); db.pragma('cache_size = -16000'); db.pragma('mmap_size = 268435456'); db.pragma('temp_store = MEMORY'); db.pragma('busy_timeout = 5000');
И главное — я не пытаюсь писать в SQLite абсолютно всё.
Авторитетное состояние Огорода живёт в памяти процесса и периодически снапшотится. Tower Defense держит сетку и юнитов в RAM, а в БД пишет уже прогресс, энергию и результат. Real‑time комнаты тоже отделяют эфемерное состояние от долговременного.
То есть SQLite — это государственный архив в прямом смысле.
Но государственный архив не обязан запоминать координату каждого гнома шестьдесят раз в секунду.
Самая странная ошибка: время двигалось только при посещении сайта
До недавнего времени у Егорода была архитектурная особенность, которая красиво выглядела бы в учебнике по квантовой механике и значительно хуже — в production.
Время в государстве двигалось, только когда кто‑нибудь на него смотрел.
Выборные фазы переключались через elections.maybeAutoAdvance(), а вызывался этот код из HTTP‑роутов — страниц выборов, Мэрии и правой колонки Площади.
Если дедлайн голосования наступал ночью и никто не открывал сайт, голосование продолжалось. Утром гражданин заходил посмотреть ленту и, сам того не подозревая, запускал государственную машину.
Человек пришёл посмотреть мем.
Республика получила новое правительство.
В итоге появился services/cityClock.js: первый прогон через 30 секунд после старта процесса, затем тик раз в минуту.
Но важнее самого таймера оказалось другое правило:
таймер не является состоянием. Состояние лежит в БД.
Дедлайн хранится как timestamp. Если процесс перезапустился, не нужно восстанавливать сотню setTimeout. Следующий тик просто смотрит, что уже должно было произойти.
Сейчас городские часы двигают выборы, закрывают просроченные общественные дела, ведут государственные подряды, переводят угрозы районам в нападения, запускают восстановление, оркестрируют осады и выпускают городские вести. Причём порядок важен.
Нападение должно обрабатываться раньше логики, которая снимает просроченную угрозу. Иначе в ту минуту, когда крысы наконец должны перейти от угроз к делу, система могла бы решить:
срок угрозы закончился, значит всё хорошо.
С точки зрения крыс архитектура отличная. С точки зрения города — говно.
Идемпотентность оказалась важнее «ровно одного вызова»
Когда появляется собственный ход времени, быстро выясняется ещё одна вещь. Не надо слишком надеяться, что код будет вызван ровно один раз.
Лучше сделать так, чтобы второй вызов ничего не сломал. Повторный maybeAutoAdvance() безопасен. Закрытие уже закрытого дела должно быть пустой операцией. Рестарт сервера не должен публиковать вторую хронику за тот же день. Двойной тик не должен создать две крысиные армии. Для меня это стало одним из главных выводов проекта:
в persistent‑системе полезнее делать операции идемпотентными, чем строить архитектуру вокруг мечты о perfectly‑once execution.
Perfectly once прекрасно живёт на диаграмме. И только потом deploy.
Как сервер решает, когда городу пора получить крыс
Крысиная осада в Егороде теперь тоже начинается сама. И это не просто случайное число раз в N часов.
Есть отдельная оркестровка citySiege.js, которая сознательно не владеет состоянием районов, наградами и вкладом граждан — это уже ответственность других модулей. Осада знает только собственную историю: когда была объявлена, куда направлена и чем закончилась.
Частота бедствий считается от количества живых граждан за последние 14 дней. В коде даже есть формулировка, которую я теперь считаю почти архитектурным документом:
осада раз в неделю в городе на пятерых — травля; раз в месяц в городе на двести — скука.
Базовая модель использует примерно 280 «человеко‑суток» на одно бедствие и ограничивает результат минимальным и максимальным интервалом. После разрушения следующая беда откладывается дольше. К рассчитанной дате добавляется случайный разброс, чтобы расписание нашествий нельзя было занести в календарь.
Причём jitter вычисляется один раз и сохраняется.
Если пересчитывать его каждый тик, фраза «до осады три дня» превращается в философскую категорию. В результате сервер теперь умеет вычислять допустимую частоту крысиных нашествий на душу населения.
И нет, этого не было в первоначальном ТЗ. Ведь и не было никакого ТЗ))
После ботнета я перестал любить аварийные деплои
На 7-й день после запуска пришла не игровая, а настоящая атака. За примерно 5,5 часа ботнет создал около 2300 аккаунтов и оставил тысячи комментариев.
CAPTCHA была.
Rate limits были.
Я думал, что защищён, как город Тир, но оказалось, что это не так...
Проблема оказалась простой: ограничение на одного пользователя почти бесполезно, если пользователей у злодея 2к и больше. После этого защита регистрации стала многослойной: honeypot, блокировка одноразовых email‑доменов, отдельный limiter на регистрацию, Yandex SmartCaptcha и собственный список IP‑блокировок. В текущей конфигурации registration limiter ограничивает регистрацию до 15 попыток в час на IP.
Но главный урок был не про CAPTCHA. Он был про управление системой:
любой аварийный рубильник должен быть настройкой, а не деплоем.
Сейчас feature flags, лимиты, разрешения и многие режимы поведения лежат в реестре app_settings и меняются из Канцелярии.
То есть отключить критическую возможность теперь можно без цепочки:
исправить код → commit → CI → deploy → ждать → надеяться
Когда ботнет уже оставил в вашей собственной ленте сообщение «разработчик, напиши мне в Telegram, иначе не остановлюсь», полчаса CI начинают ощущаться особенно длинными.
Чтение наконец стало чтением
После появления живого города на Площади появился блок «Егород сейчас»: кто у власти, что происходит в городе, какое общественное дело идёт прямо сейчас. И здесь пришлось специально ввести правило:
GET должен действительно читать.
GET /api/city/now не должен двигать выборы, создавать сезоны, отмечать посещение или менять состояние мира.
Внутренняя документация даже перечисляет API, которые нельзя использовать при построении сводки, потому что у них есть side effects.
Там же обнаружилась полезная оптимизация.
В Огороде есть большой журнал действий garden_harvests. Вклад игрока можно вычислить напрямую из него.
Но на миллионе строк такой расчёт занимал около 16 мс, тогда как чтение денормализованного garden_player_season_totals, который поддерживается триггерами, — около 50 микросекунд.
16 миллисекунд сами по себе никого не убивают. Но 16 миллисекунд на каждом рендере Площади из таблицы, которая постоянно растёт, — уже начало традиции, которую лучше прервать рано.
Тесты и деплой тоже постепенно обзавелись бюрократией
Сейчас тестов больше 2600.
Причём npm test не просто запускает node --test test/.
У проекта есть собственный runner, который передаёт Node явный список *.test.js, потому что поведение test runner различается между версиями Node, а общий singleton SQLite‑подключения приводит к тому, что тесты в одном процессе начинают топтать друг другу БД.
Поэтому процессная изоляция победила красивую команду.
Деплой тоже со временем приобрёл чувство самосохранения.
Перед выкладкой делается online‑backup SQLite через better-sqlite3 backup API, совместимый с WAL и не требующий остановки сервиса. Затем сохраняется rollback‑снапшот старого кода, ставятся зависимости, собирается frontend, перезапускается сервис и выполняется health check. Если один из этапов падает — код автоматически откатывается.
Примерно так:
git push ↓ tests ↓ DB backup ↓ old code snapshot ↓ npm ci ↓ build ↓ restart ↓ health check ↓ либо жизнь, либо rollback
БД назад автоматически не откатывается. Потому что автоматически восстановить старую базу поверх уже совершившихся действий пользователей — отличный способ починить релиз и одновременно изобрести машину времени.
Что я понял из всей этой архитектуры
Самое интересное — почти ни одно крупное техническое решение не появилось потому, что «так правильно проектировать scalable systems».
Они появлялись вслед за поведением людей.
Пользователи начали серьёзно относиться к власти — пришлось делать честные выборы.
Город стоял без посетителей — появились часы.
Люди молчали — появилась автоматическая хроника Канцелярии. В активный день она публикует сводку реальных событий, а в тихий — легенду из городского фонда, чтобы не выпускать великолепный официальный отчёт «постов: 0».
Ботнет пришёл — появился аварийный runtime‑control.
Игроки накопили ресурсы — появился общий городской резерв.
И постепенно я перестал проектировать только функции.
Я стал проектировать последствия.
Что я бы сделал иначе
Если бы начинал сегодня, раньше ввёл бы нормальную систему миграций.
schema.sql + ensureColumn + ручные пересборки таблиц работает, но в какой‑то момент файл инициализации БД начинает выглядеть как археологический каталог цивилизации.
Раньше разделил бы чтение и команды. GET, который где‑то в глубине меняет фазу выборов, очень удобен ровно до того момента, когда просмотр ленты становится административным действием.
С самого начала сделал бы аварийные переключатели runtime‑настройками.
И раньше разделил бы persistent и ephemeral state.
Не каждому объекту нужна таблица.
Но и не всякому объекту можно доверить жить только в памяти.
Хороший вопрос здесь оказался простым:
если процесс перезапустится сейчас, человеку будет важно, что именно исчезло?
Если исчезли результаты выборов — плохо.
Если мяч в виртуальной комнате прыгнул на метр в сторону — республика, вероятно, выстоит.
Что дальше
Сейчас задача уже не в том, чтобы добавить Егороду ещё десять функций. Я думаю, что их и так сейчас много — может добавлю видео (если разорюсь на сервак), может ещё пару игр. Но...
Гораздо интереснее связать существующие системы.
Чтобы железо из шахты было действительно нужно городу.
Чтобы плохой урожай имел последствия.
Чтобы разрушенный район требовал ресурсов на восстановление.
Чтобы политические решения меняли не только текст на экране, но и механику мира.
И при этом у меня постепенно появилось правило, которое раньше казалось бы странным.
Если государственный контракт провалился из‑за моей ошибки, я могу открыть базу и поправить цифру.
Но стараюсь этого не делать.
Потому что:
если разработчик способен исправить реальность через админку, это ещё не означает, что он должен её исправлять.
В persistent‑мире у разработчика и так слишком много божественных полномочий.
Необязательно пользоваться всеми.
11 недель назад Егород был соцсетью, построенной вокруг одной глупой шутки про имя.
Теперь внутри Node‑процесса живёт город, который самостоятельно двигает выборы, закрывает контракты, ведёт хронику, восстанавливает районы и время от времени вычисляет, не пора ли очередной крысе выполнить свою историческую миссию.
Я пока не уверен, чем именно он является — социальной сетью, игрой, симуляцией государства или чрезвычайно сложным способом познакомить Егоров друг с другом.
Но с технической точки зрения самое интересное произошло тогда, когда система начала приобретать свойства, которых никто изначально не проектировал.
Потому что люди начали жить внутри неё немного не так, как я ожидал, хотя я вообще ничего не ожидал.
Но мне интересно, и надеюсь будет интересно вам в egorod.online

