Всем привет! Когда речь заходит о разработке высоконагруженных систем, многие предлагают: «python, сделать проще и быстрее». Но есть проблема: Скорость разработки!= скорость и качество работы сервиса. Когда мы делаем любой продукт, важны: Масштабируемость, стабильность работы под большой нагрузкой, предсказуемость поведения системы — особенно когда речь идет о тысячах одновременных пользователей, лентах новостей в реальном времени, уведомлениях и сложных связях между разными сущностями.

Именно поэтому и выбрана Java и Spring Boot как основа. Банки и видеосервисы работают именно на Java и Spring.

Одна из особенностей: строгая типизация, качественные инструменты для многопоточности, мощная экосистема и предсказуемое потребление памяти делают Java идеальным выбором для систем, где падение = полная потеря пользователей.

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

Итак, начну, среда разработки Intellij Idea. Кстати, весь проект будет доступен из gitLab. Нам необходимо создать файлы с конфигурацией. можно сказать что их обычно 3. Да, я про application.yml. один из них общий, который так и называется: application.yml, и еще два: application-dev.yml и application-prod.yml

spring:
  main:
    allow-bean-definition-overriding: false
  servlet:
    multipart:
      max-file-size: 200MB
      max-request-size: 200MB
  recaptcha:
    secret: ${RECAPTCHA_SECRET_SOCIAL}
    verify-url: ${VERIFY_URL}
  flyway:
    enabled: true
    locations: classpath:db/migration
    baseline-on-migrate: true
  datasource:
    url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME_ROOT_CORE_SOCIAL}
    username: ${SQL_LOGIN_ROOT_CORE_SOCIAL}
    password: ${SQL_PASSWORD_ROOT_CORE_SOCIAL}
    driver-class-name: org.postgresql.Driver
  mail:
    host: smtp.mail.ru
    port: 587
    username: ${MAIL_SOCIAL}
    password: ${MAIL_PASSWORD_SOCIAL}
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
  jpa:
    properties:
      hibernate:
        default_schema: root_core_social
    database-platform: org.hibernate.dialect.PostgreSQLDialect

logging:
  level:
    org.springframework.security: INFO

jwt:
  secret: ${SECRET_KEY_ROOT_CORE_SOCIAL}
  access-expiration: 900000
  refresh-expiration: 2592000000

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

Два других файла определяют некоторые параметры, в частности, в dev версии мы в логи будем писать sql запросы в БД, чтобы видеть что у нас происходит на сервере.
Далее нам необходимо создать схему БД, и для этого мы воспользуемся миграцией, миграции БД нам осуществляет flyway. И первая "миграция" это определение схемы БД.

create schema IF NOT EXISTS root_core_social;

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

Многие знают, что необходимо указывать определенные поля в таблице. Обязательный пункт: id пользователя. Во многих таблицах, в учебниках, указывают id тип Long (BIGINT), Это большая цифра, в ней «поместятся» все пользователи по количеству, но это не самый лучший вариант, и объяснение тянет на отдельную статью. Причем, такой элемент будет находиться в каждой таблице, то есть это общий элемент. а значит создавать в каждой таблице параметр нет необходимости. И, кроме него, также необходимо создать еще два обязательных пункта: время создания и время изменения.

@MappedSuperclass
@Getter
@Setter
public abstract class AbstractEntityUuid {


    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(name = "id", nullable = false, updatable = false)
    private UUID uuid;

    @Override
    public boolean equals(Object o){
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        AbstractEntityUuid that = (AbstractEntityUuid) o;
        return uuid != null && uuid.equals(that.uuid);
    }

    @Override
    public int hashCode(){
        return Objects.hash(uuid);
    }
}

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

Следующий пункт, нам нужно два поля в таблице, это время создания и время изменения.

@MappedSuperclass
@Getter
@Setter
@RequiredArgsConstructor
@AllArgsConstructor
public abstract class DatedEntity extends AbstractEntityUuid {

    @CreationTimestamp
    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @UpdateTimestamp
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
}

Это у нас абстрактный базовый класс для сущностей с автоматически управляемыми временными метками: createdAt - время создания, и updatedAt время последнего обновления. Класс наследует предыдущий класс, и, следовательно, наследует id. Класс используется для единоразового аудита изменений всех сущностей в системе. Заполняет их Hebernate.

Итак, база готова, теперь при создании таблиц мы будем просто наследоваться от класса DatedEntity и у нас будут подтягиваться поля БД от них.
Создаем теперь пользователя:

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user_root_core")
public class UserEntity extends DatedEntity {

    @Column(name = "mail", nullable = false, unique = true)
    private String mail;

}

Наш пользователь наследовался от DatedEntity и получил 3 поля из суперклассов. Есть такое правило: Все что может быть отдельным объектом, должно быть отдельным объектом, НО! тут главное не переуседствовать, иначе у нас все будет работать очень и очень медленно! Поэтому таблица UserEntity имеет так мало полей. Захотим создать строки с "паспортом" пользователя? мы не будем делать отдельно миграцию, которая будет у нас в эту таблицу прописывать дополнительные поля. мы просто сделаем еще одну таблицу.

Следующие два класса будут наследоваться так же от DatedEntity, и они будут в себе содержать по одному полю: private UserEntity userUuid; но с разными типами связей.
@OneToOne и @ManyToOne, думаю, смысл понятен: все классы, у которых родитель UserEntity в зависимости от типа связи будет подключаться нужный из них и таким образом мы избежим дублирования кода.

@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserOwnerOneToOne extends DatedEntity {

    @OneToOne
    @JoinColumn(name = "user_uuid", nullable = false)
    private UserEntity userUuid;
}
@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserOwnerManyToOne extends DatedEntity {

    @ManyToOne
    @JoinColumn(name = "user_uuid", nullable = false)
    private UserEntity userUuid;
}

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

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

  • Почта

  • Номер телефона

  • Пол

  • Имя

  • Фамилию

  • Яндекс ID

Полученные данные мы поместим в таблицу:

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "yandex_user_info")
public class YandexUserInfoEntity extends UserOwnerOneToOne {

    @Column(name = "yandex_id", nullable = false, unique = true)
    private String yandexId;

    @Column(name = "first_name")
    private String firstName;

    @Column(name = "last_name")
    private String lastName;

    @Column(name = "gender")
    private String gender;

    @Column(name = "email")
    private String email;

    @Column(name = "phone")
    private String phone;
}

Стоит заметить что никакие параметры, кроме "name" мы не ставим. По умолчанию без дополнительных параметров у нас будет тип VARCHAR(255) и, как можно понять, будет 255 символов, нам этого более чем достаточно. Еще можно заметить что значения могут быть null, потому что мы не указали обратного, пол пользователя или имя, например, могут быть не указаны.

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

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "user_profile")
public class UserProfileEntity extends UserOwnerOneToOne {

    @Column(name = "about_user", length = 1000)
    private String aboutUser;

    @Column(name = "nickname", unique = true, nullable = false)
    private String nickName;

    @Column(name = "status", nullable = false)
    @Enumerated(EnumType.STRING)
    private StatusUserEnum status;

    @Column(name = "public_id", unique = true, nullable = false)
    private UUID publicId;

    @Column(name = "role", nullable = false)
    @Enumerated(EnumType.STRING)
    private RoleUserEnum role;

    @Column(name = "avatar_icon")
    private String avatarIcon;

}

Тут можно указать что статус роль у нас вынесены отдельно, в enum. Нужно, естественно, чтобы не ошибиться при назначении:

@Getter
public enum StatusUserEnum {
    ACTIVE("active"),
    BLOCKED("blocked"),
    PENDING("pending"),
    SUSPENDED("suspended"),
    DEACTIVATED("deactivated");

    private final String code;

    StatusUserEnum(String code) {
        this.code = code;
    }

    public static StatusUserEnum fromCode(String code) {
        for (StatusUserEnum r : values()) {
            if (r.code.equals(code)) return r;
        }
        throw new IllegalArgumentException("Unknown role: " + code);
    }
}
@Getter
public enum RoleUserEnum {
    USER("user"),
    MODERATOR("moderator"),
    ADMIN("admin"),
    SUPER_ADMIN("super_admin");

    private final String code;

    RoleUserEnum(String code) {
        this.code = code;
    }

    public static RoleUserEnum fromCode(String code) {
        for (RoleUserEnum r : values()) {
            if (r.code.equals(code)) return r;
        }
        throw new IllegalArgumentException("Unknown role: " + code);
    }
}

С одной стороны, возникает вопрос: а почему бы все эти параметры не поместить в таблицу UserEntity? Все очень просто, в этой таблице нет никаких личных персональных данных, она состоит из данных пользователя, которые имеют открытый доступ и будут представлены на сайте. В то время как UserEntity хранит личные персональные данные, в частности почту.

Еще одна важная таблица:

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "nickname_old")
public class NicknameOldEntity extends UserProfileOwnerManyToOne {

    @Column(name = "nickname")
    private String nickName;

}

Ранее мы уже создавали суперклассы для UserEntity, но, при работе с будущим приложением с UserEntity мы взаимодействовать практически не будем, но дополнения еще будем вносить. Все взаимодействие мы обеспечим с таблицей UserProfile. А, значит, нам необходимо создать суперкласс и для него.

@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserProfileOwnerManyToOne extends DatedEntity {

    @ManyToOne
    @JoinColumn(name = "user_uuid", nullable = false)
    private UserProfileEntity userUuid;

}

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

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

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

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

ссылка на gitLab

Всем спасибо за внимание! Это мой первый пост, не будьте слишком строги. Мог что то забыть или упустить.

Комментарии (16)


  1. mgis
    02.02.2026 16:29

    А мне почему то казалось, что если High-Load то лучше в 2026 стартовать проекты на Rust.
    Сам я, Rust пока еще, не использовал, но планирую в ближайшее время, какой нибудь из микросервисов на нем написать. Очень уж восторженные отзывы получаю от коллег.


    1. rootCore Автор
      02.02.2026 16:29

      У меня high-load в контексте масштабируемости и надёжности, а не микросекундных задержек - для этого Java/Spring Boot (особенно с реактивным стеком или современным GC) более чем достаточно. Rust пока не пробовал, но держу в поле зрения.


  1. aleksandy
    02.02.2026 16:29

    AbstractEntityUuid#equals, AbstractEntityUuid#hashCode

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

    Определять equals()/hashCode() в 145% случаев не требуется. А где требуется надо быть очень осторожным и завязывать реализацию этих методов только на идентификатор неправильно. А уж ваша реализация equals() вообще неправильная, т.к. не учитывает прокси.

    private LocalDateTime createdAt;

    Время создания - это точка на временной оси, какой-то конкретный момент, при чём тут локальное время? А если учесть, что это время меняется одним параметром запуска jvm, то это вообще полный звиздец. Для таких полей надо использовать java.time.Instant.


    1. rootCore Автор
      02.02.2026 16:29

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

      Использование insnanceof вместо getClass() это справедливое замечание для работы с Hibernate Proxy. В данном случае реализация намеренно оставлена лаконичной, так как на начальном этапе структура связей прозрачна и не использует сложные сценарии ленивой загрузки в коллекциях. При усложнении объектного графа рефакторинг этих методов под специфику проксирования это стандартный запланированный шаг.

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

      Наличие конструкторов по умолчанию и сеттеров это необходимый компромисс при использовании JPA/Hibernate. Обеспечение полной неизменяемости сущностей (Immutability) зачастую вступает в конфликт с требованиями фреймворков и производительностью маппинга.

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


      1. aleksandy
        02.02.2026 16:29

        Выбор localDateTime обусловлен тем, что на старте разработки важнее наглядность данных в БД

        Как одно связано с другим? В БД тип колонки что для LocalDateTime, что для Instant будет одинаковым.

        упрощение отладки логики, завязанной на локальное время.

        У java.time.Instant вполне задокументированный человекочитаемый toString(). Т.ч. что в sout, что в лог, что просто под дебагером понять какая там именно дата можно без проблем.

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

        Это не компромисс, а вполне себе требование стандарта JPA. Который, кстати, хибер вполне себе допускает нарушать.

        Проектирование это всегда баланс между академической чистотой и прагматизмом.

        Как раз с точки зрения прогматизма equals() и hashCode() вообще не надо реализовывать, пока не упёрся в проблему, решить которую без реализации этих методов вообще не получается. У вас же буквально первая сущность UserEntity уже имеет натуральный идентификатор (unique = true) и вот как раз с точки зрения бизнес-логики равенство должно бы проверяться по этому значению.

        ведет к оверинжинирингу

        Это именно то, что мы наблюдаем на описываемом проекте. Берём простую задачу: найти NicknameOldEntity по идентификатору. Казалось бы, чего проще, и проблем быть не должно, но вместо тупейшего select * from nickname_old where id = ? у нас появится монстр с двумя join-ами на ровном месте.

        Какая разработка высоконагруженной системы? Вам бы для начала "How to..." прочесть, посмотреть на аналогичные решения, доступные в сети, обдумать используемые там решения, а потом уже, если останется желание, можно и статью на Хабр написать, которая будет реально полезной.


        1. rootCore Автор
          02.02.2026 16:29

          По существу ваших замечаний:
          Про Instant и логи: Согласен, toString() у Instant информативен. Однако выбор в пользу LocalDateTime на данном этапе сделан для упрощения визуального контроля данных в БД при ручных запросах в процессе прототипирования, чтобы банально было просто открыть БД и посмотреть когда и что создалось, а не высчитывать. Безусловно, Instant — более строгий стандарт для продакшена, и этот переход заложен в логику развития проекта.
          Про реализацию equals/hashCode: Позиция "не реализовывать, пока нет проблемы" вполне жизнеспособна. Мой подход это создание базового контракта сущности сразу. Что касается использования бизнес-ключей (unique полей) в проверках это дискуссионный вопрос архитектурных паттернов, который заслуживает отдельного разбора.
          Про join-ы и структуру: Вы абсолютно правы, такая иерархия связей увеличивает количество join-ов. Это осознанный архитектурный компромисс (trade-off). Я приношу производительность простых запросов в жертву строгой типизации и единообразной структуре отношений на уровне кода. На текущем объеме данных и в рамках задач первой итерации этот приоритет кажется мне более оправданным для поддержки проекта.


  1. 9lLLLepuLLa
    02.02.2026 16:29

    Использовать @Getter и не использовать @RequiredArgsConstructor.

    Код не сами писали ?


    1. rootCore Автор
      02.02.2026 16:29

      Код, разумеется, авторский. Что касается выбора аннотаций: @RequiredArgsConstructor из Lombok чаще всего используется для генерации конструктора под final поля. Например: при внедрении зависимостей в сервисах.

      В JPA-сущностях поля не помечаются как final, так как их состояние управляется Hibernate. Кроме того, спецификация JPA требует наличия конструктора без параметров, поэтому здесь используются @NoArgsConstructor и @AllArgsConstructor. В данной ситуации я счел, что @Getter, @Setter и стандартных конструкторов более чем достаточно для текущих задач модели. Всегда стараюсь придерживаться принципа минимальной достаточности в использовании аннотаций, чтобы не перегружать код там, где в этом нет прямой необходимости


  1. 9lLLLepuLLa
    02.02.2026 16:29

    Странное решение выносить связи в именованные классы с наследованием. Чем коллекции как поля плохи ?

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


    1. rootCore Автор
      02.02.2026 16:29

      Что касается структуры наследования и выноса связей в отдельные классы:

      Безопасность и типизация: Основная цель здесь это не просто экономия строк кода, а создание жесткого каркаса. Когда проект разрастается до десятков таблиц, наличие базовых классов, вроде UserOwnerOneToOne, гарантирует, что разработчик не забудет прописать нужные связи, индексы или правила удаления. Это минимизирует риск "накосячить" при создании новых сущностей, связанных с пользователем.

      Использование коллекций внутри UserEntity, например, List<Post>, удобно, но в высоконагруженных системах это часто ведет к проблемам с производительностью: избыточные Select запросы, раздувание объекта пользователя. Подход с выделением связей в отдельные сущности позволяет работать с данными более точечно и изолированно.

      Такая иерархия делает структуру БД и кода предсказуемой. Глядя на определение класса, сразу понятно, какую роль он играет в системе и какие базовые поля: ID, время создания в нем гарантированно есть.

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


  1. gr00shik
    02.02.2026 16:29

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


    1. rootCore Автор
      02.02.2026 16:29

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

      Метка стоит скорее как индикатор того, что это не просто "hello world", а начало работы над комплексной системой. Подкапотные разборы и более тонкие нюансы производительности как раз пойдут дальше по мере усложнения проекта


  1. remindscope
    02.02.2026 16:29

    Я бы добавил, что стоит в пропертях включить валидацию хибером

    И не совсем понял, почему не отключили open-in-view. Забыли?


    1. rootCore Автор
      02.02.2026 16:29

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

      Что касается open-in-view, решение оставить его включенным осознанное. Для первой итерации и простых запросов в рамках обучения это упрощает работу со связями. Безусловно, при переходе к реальному Highload и тонкой настройке производительности эта настройка отключается в первую очередь вместе с переходом на Fetch Joins или Entity Graphs.

      Просто не хотелось валить всё в одну кучу в первой же статье - настройки производительности и строгой валидации планировал вынести в отдельный блок конфигурации позже


    1. rootCore Автор
      02.02.2026 16:29

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

      Метка стоит скорее как индикатор того, что это не просто "hello world", а начало работы над комплексной системой. Подкапотные разборы и более тонкие нюансы производительности как раз пойдут дальше по мере усложнения проекта


  1. rootCore Автор
    02.02.2026 16:29

    не туда написал,