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

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

2.8 Senior🔥 141 комментариев
#Базы данных и SQL#Многопоточность

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

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

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

Race Condition при увеличении счётчика просмотров

Да, сталкивался с этой проблемой. Это классический пример race condition при одновременном доступе к одной записи. Хочу подробно разобрать проблему и показать решения.

Суть проблемы

Когда несколько пользователей одновременно открывают одну статью, счётчик просмотров может увеличиться некорректно:

Основное состояние: views = 100

Поток 1 читает: 100
Поток 2 читает: 100
Поток 1 пишет: 101
Поток 2 пишет: 101

Результат: views = 101 (вместо ожидаемых 102)

Это происходит потому, что операция "прочитать-изменить-записать" — не атомарна.

Как это выглядит в коде

Неправильный подход (ненадёжный):

@Service
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;
    
    public Article getArticle(Long id) {
        Article article = articleRepository.findById(id).orElseThrow();
        
        // ПРОБЛЕМА: Race condition!
        article.setViews(article.getViews() + 1);  // Читаем
        articleRepository.save(article);             // Пишем
        
        return article;
    }
}

Решение 1: Прямое обновление в БД (рекомендуемое)

Используем native query для атомарного увеличения:

@Service
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;
    
    @Transactional
    public Article getArticle(Long id) {
        // Сначала увеличиваем счётчик atomically в БД
        articleRepository.incrementViews(id);
        
        // Затем загружаем обновленную статью
        return articleRepository.findById(id).orElseThrow();
    }
}

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
    
    @Modifying
    @Transactional
    @Query(value = 
        "UPDATE articles SET views = views + 1 WHERE id = :id", 
        nativeQuery = true)
    void incrementViews(@Param("id") Long id);
}

Это работает потому, что БД гарантирует атомарность SQL операции на уровне одной записи.

Решение 2: Пессимистичное блокирование

Блокируем строку до завершения операции:

@Repository
public interface ArticleRepository extends JpaRepository<Article, Long> {
    
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Article a WHERE a.id = :id")
    Optional<Article> findByIdWithLock(@Param("id") Long id);
}

@Service
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;
    
    @Transactional
    public Article getArticle(Long id) {
        Article article = articleRepository.findByIdWithLock(id)
            .orElseThrow();
        
        // Теперь безопасно, строка заблокирована
        article.setViews(article.getViews() + 1);
        return articleRepository.save(article);
    }
}

Плюсы: просто, безопасно
Минусы: может быть медленно при высокой нагрузке

Решение 3: Оптимистичное блокирование (@Version)

Используем поле версии для обнаружения конфликтов:

@Entity
@Table(name = "articles")
public class Article {
    @Id
    private Long id;
    
    private String title;
    private Integer views;
    
    @Version  // Используем версию для обнаружения конфликтов
    private Long version;
}

@Service
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;
    
    public Article getArticle(Long id) {
        while (true) {
            try {
                Article article = articleRepository.findById(id)
                    .orElseThrow();
                article.setViews(article.getViews() + 1);
                return articleRepository.save(article);
            } catch (OptimisticLockingFailureException e) {
                // Конфликт версии, повторяем попытку
                continue;
            }
        }
    }
}

Плюсы: нет блокировок, лучше для high-concurrency
Минусы: может требовать retry логику

Решение 4: Асинхронное обновление с очередью

Для высоконагруженных систем лучше отделить读取 от обновления счётчика:

@Service
public class ArticleService {
    @Autowired
    private ArticleRepository articleRepository;
    
    @Autowired
    private ViewEventPublisher eventPublisher;
    
    @Transactional(readOnly = true)
    public Article getArticle(Long id) {
        Article article = articleRepository.findById(id)
            .orElseThrow();
        
        // Публикуем событие асинхронно
        eventPublisher.publishViewEvent(id);
        
        return article;  // Возвращаем без изменения views
    }
}

@Service
public class ViewEventProcessor {
    @Autowired
    private ArticleRepository articleRepository;
    
    @Async
    public void processViewEvent(Long articleId) {
        // Обновляем счётчик асинхронно (например, в batch)
        articleRepository.incrementViews(articleId);
    }
}

Плюсы: высокая производительность, не блокирует main request
Минусы: счётчик может быть не совсем точным в real-time

Мой выбор и рекомендация

Для большинства приложений рекомендую Решение 1 (прямое UPDATE в БД):

  • Простое и надёжное
  • Не требует retry логики
  • Производительное

Для очень высоконагруженных систем (миллионы просмотров в день) — Решение 4 (асинхронное обновление), так как счётчик просмотров обычно не требует 100% точности в реальном времени.

Важные моменты

  • Всегда используйте @Transactional для целостности данных
  • Добавьте индекс на поле просмотров если нужна сортировка по популярности
  • Мониторьте query performance, особенно при высокой нагрузке
  • Рассмотрите Redis для кеширования счётчиков просмотров перед синхронизацией в БД
Сталкивался ли с некорректным увеличением счетчика поля просмотров в таблице со статьями при одновременном открытии конкретной статьи двумя или более пользователями | PrepBro