Сталкивался ли с некорректным увеличением счетчика поля просмотров в таблице со статьями при одновременном открытии конкретной статьи двумя или более пользователями
Комментарии (1)
Ответ сгенерирован нейросетью и может содержать ошибки
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 для кеширования счётчиков просмотров перед синхронизацией в БД