← Назад к вопросам

Что такое денормализация базы данных?

2.8 Senior🔥 81 комментариев
#Базы данных и SQL

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

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Денормализация базы данных

Денормализация — это процесс преднамеренного снижения нормализации базы данных путём внесения избыточности для повышения производительности при чтении данных.

Нормализация и её проблемы

Нормализация (разбиение данных на таблицы, устранение дублирования) обеспечивает целостность и минимизирует дублирование, но может привести к медленным запросам с множеством JOIN'ов.

Полностью нормализованная структура:

-- Таблица пользователей
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    email VARCHAR(100)
);

-- Таблица профилей
CREATE TABLE profiles (
    id SERIAL PRIMARY KEY,
    user_id INT REFERENCES users(id),
    age INT,
    city VARCHAR(100)
);

-- Таблица постов
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    user_id INT REFERENCES users(id),
    title VARCHAR(255),
    content TEXT
);

-- Запрос для получения пользователя с профилем и постами
SELECT u.name, u.email, p.age, p.city, posts.title
FROM users u
JOIN profiles p ON u.id = p.user_id
JOIN posts posts ON u.id = posts.user_id
WHERE u.id = 1;

Проблема: при большом количестве данных много JOIN'ов = медленно.

Денормализация — решение

Мы добавляем избыточные данные в таблицы, чтобы избежать JOIN'ов:

-- Денормализованная таблица пользователей
CREATE TABLE users_denorm (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    email VARCHAR(100),
    age INT,                    -- избыток (из profiles)
    city VARCHAR(100),          -- избыток (из profiles)
    post_count INT,             -- избыток (счётчик)
    last_post_title VARCHAR(255) -- избыток (кеш)
);

-- Простой запрос БЕЗ JOIN'ов
SELECT name, email, age, city, post_count
FROM users_denorm
WHERE id = 1;

Примеры денормализации

1. Кеширование счётчиков

Полностью нормализовано:

@Entity
public class User {
    @Id
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "user")
    private List<Post> posts;  // много постов может быть
}

// Получить количество постов
Long postCount = userRepository.findById(id)
    .map(user -> user.getPosts().size())
    .orElse(0L);
// Проблема: загрузит ВСЕ посты в память!

Денормализовано:

@Entity
public class User {
    @Id
    private Long id;
    private String name;
    private Long postCount;  // кешированный счётчик
}

// Быстро получить количество
Long postCount = userRepository.findById(id)
    .map(User::getPostCount)
    .orElse(0L);

Возникает проблема: при добавлении поста нужно синхронизировать счётчик:

@Service
public class PostService {
    @Autowired
    private PostRepository postRepository;
    @Autowired
    private UserRepository userRepository;
    
    public void createPost(Long userId, String title) {
        // 1. Создать пост
        Post post = new Post();
        post.setTitle(title);
        post.setUserId(userId);
        postRepository.save(post);
        
        // 2. Обновить счётчик в User
        User user = userRepository.findById(userId).orElseThrow();
        user.setPostCount(user.getPostCount() + 1);
        userRepository.save(user);
    }
}

2. Дублирование данных профиля

Без денормализации:

SELECT u.id, u.name, u.email, p.age, p.city, p.phone
FROM users u
JOIN profiles p ON u.id = p.user_id
WHERE u.id = 1;

С денормализацией:

CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    email VARCHAR(100),
    age INT,              -- дублирование
    city VARCHAR(100),    -- дублирование
    phone VARCHAR(20)     -- дублирование
);

SELECT * FROM users WHERE id = 1;  -- Нет JOIN'а!

3. Кеширование суммы

-- Нормализовано
SELECT SUM(amount) FROM orders WHERE user_id = 1;
-- Проблема: считает каждый раз

-- Денормализовано
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    total_spent DECIMAL(10, 2)  -- кешированная сумма
);

SELECT total_spent FROM users WHERE id = 1;
-- Быстро, но нужно обновлять при каждом заказе

Когда использовать денормализацию

ДА, если:

  • Много операций чтения, мало операций записи (OLAP системы)
  • Критична скорость чтения
  • Данные редко изменяются
  • JOIN'ы очень затратны

НЕТ, если:

  • Много операций записи (синхронизировать сложно)
  • Данные часто обновляются
  • Нужна строгая целостность
  • Место в памяти критично

Проблемы денормализации

1. Синхронизация данных

При обновлении нужно обновить везде:

// Если изменим email пользователя
public void updateUserEmail(Long userId, String newEmail) {
    // Обновить в таблице users
    User user = userRepository.findById(userId).orElseThrow();
    user.setEmail(newEmail);
    userRepository.save(user);
    
    // Обновить в таблице user_profiles (если дублировано)
    UserProfile profile = profileRepository.findByUserId(userId);
    profile.setEmail(newEmail);
    profileRepository.save(profile);
    
    // Обновить в таблице user_analytics (если дублировано)
    UserAnalytics analytics = analyticsRepository.findByUserId(userId);
    analytics.setUserEmail(newEmail);
    analyticsRepository.save(analytics);
}

Ошибка синхронизации приводит к несогласованности данных.

2. Потребление памяти

Дублирование данных требует больше дискового пространства и памяти.

3. Сложность транзакций

Атомарно обновить несколько таблиц сложнее:

@Transactional
public void updateUserWithDenorm(Long userId, String newName) {
    User user = userRepository.findById(userId).orElseThrow();
    user.setName(newName);
    
    UserProfile profile = profileRepository.findByUserId(userId);
    profile.setUserName(newName);
    
    // Если одно из обновлений упадёт, состояние будет несогласованным
    userRepository.save(user);
    profileRepository.save(profile);
}

Лучшие практики

1. Используйте триггеры БД для синхронизации:

CREATE TRIGGER update_user_email_trigger
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
    UPDATE user_profiles
    SET email = NEW.email
    WHERE user_id = NEW.id;
END;

2. Кешируйте на уровне приложения:

@Service
public class UserService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private CacheManager cacheManager;
    
    @Cacheable("users")
    public User getUserById(Long id) {
        return userRepository.findById(id).orElseThrow();
    }
    
    public void updateUser(User user) {
        userRepository.save(user);
        cacheManager.getCache("users").evict(user.getId());
    }
}

3. Используйте материализованные представления (в некоторых БД):

CREATE MATERIALIZED VIEW user_stats AS
SELECT 
    u.id,
    u.name,
    COUNT(p.id) as post_count,
    MAX(p.created_at) as last_post_date
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.id, u.name;

-- Регулярно обновляем
REFRESH MATERIALIZED VIEW user_stats;

Заключение

Денормализация — это обоюдоострый меч:

  • Повышает производительность чтения
  • Усложняет синхронизацию данных
  • Требует тщательного планирования и мониторинга

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

Что такое денормализация базы данных? | PrepBro