Spring Boot+SQL/JPA борется с пессимистичной блокировкой и оптимистичной блокировкой

Spring Boot

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

Бизнес компании это самая распространенная проблема "заказ+аккаунт".После решения проблемы компании я повернул голову и подумал,мой проект блогаFameТак же есть такая же проблема в (хотя в трафике вообще не нужно учитывать проблему параллелизма...), то возьму это как пример.

восстановление бизнеса

Первая среда: Spring Boot 2.1.0 + data-jpa + mysql + lombok

Дизайн базы данных

В системе блогов с функцией комментариев обычно есть две таблицы: 1. Таблица статей 2. Таблица комментариев. В таблице статей, помимо сохранения некоторой информации о статье и т.д., также есть поле для сохранения количества комментариев. Мы разрабатываем минимальную структуру таблицы для восстановления этого бизнес-сценария.

статья статья таблица

поле тип Примечание
id INT Auto-Accrepretion Первичный ключ ID
title VARCHAR название статьи
comment_count INT Количество комментариев к статье

форма комментариев

поле тип Примечание
id INT автоматическое увеличение идентификатора первичного ключа
article_id INT Комментарий к статье ID.
content VARCHAR Комментарии

Когда пользователь комментирует, 1. Получить статью в соответствии с идентификатором статьи 2. Вставить запись комментария 3. Количество комментариев к статье увеличивается и сохраняется

Код

Сначала введем соответствующие зависимости в maven

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.0.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Затем напишите класс сущности, соответствующий базе данных

@Data
@Entity
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private Long commentCount;
}
@Data
@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long articleId;

    private String content;
}

Затем создайте репозиторий, соответствующий этим двум классам сущностей, благодаря Spring-jpa-dataCrudRepositoryДля нас были реализованы наиболее распространенные CRUD-операции, поэтому нашему репозиторию нужно только наследоватьCrudRepositoryИнтерфейс больше ничего не делает.

public interface ArticleRepository extends CrudRepository<Article, Long> {
}
public interface CommentRepository extends CrudRepository<Comment, Long> {
}

Затем мы просто реализуем интерфейс Controller и класс реализации Service.

@Slf4j
@RestController
public class CommentController {

    @Autowired
    private CommentService commentService;

    @PostMapping("comment")
    public String comment(Long articleId, String content) {
        try {
            commentService.postComment(articleId, content);
        } catch (Exception e) {
            log.error("{}", e);
            return "error: " + e.getMessage();
        }
        return "success";
    }
}
@Slf4j
@Service
public class CommentService {
    @Autowired
    private ArticleRepository articleRepository;

    @Autowired
    private CommentRepository commentRepository;

    public void postComment(Long articleId, String content) {
        Optional<Article> articleOptional = articleRepository.findById(articleId);
        if (!articleOptional.isPresent()) {
            throw new RuntimeException("没有对应的文章");
        }
        Article article = articleOptional.get();

        Comment comment = new Comment();
        comment.setArticleId(articleId);
        comment.setContent(content);
        commentRepository.save(comment);

        article.setCommentCount(article.getCommentCount() + 1);
        articleRepository.save(article);
    }
}

Анализ проблем параллелизма

Процесс этой простой функции комментирования можно увидеть из реализации кода только что: когда пользователь инициирует запрос комментария, класс сущности соответствующей статьи находится в базе данных.Article, а затем создайте соответствующий класс объекта комментария в соответствии с информацией о статье.Comment, и вставить в базу, затем увеличить количество комментариев к статье, а затем обновить исправленную статью в базу.Весь процесс выглядит следующим образом.

顺序流程

В этом процессе есть проблема: когда несколько пользователей одновременно оставляют комментарии, они одновременно переходят на шаг 1, чтобы получить статью, затем вставляют соответствующий комментарий и, наконец, обновляют количество комментариев на шаге 3 в базе данных. Просто потому, что они являются статьями одновременно, значение их article.commentcount одинаково, тогда значение Article.commentCount + 1, сохраненное на шаге 3, такое же, тогда количество комментариев, которое должно быть +3, должно быть добавлено только 1.

Давайте попробуем это с кодом тестового примера

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LockAndTransactionApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CommentControllerTests {
    @Autowired
    private TestRestTemplate testRestTemplate;

    @Test
    public void concurrentComment() {
        String url = "http://localhost:9090/comment";
        for (int i = 0; i < 100; i++) {
            int finalI = i;
            new Thread(() -> {
                MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
                params.add("articleId", "1");
                params.add("content", "测试内容" + finalI);
                String result = testRestTemplate.postForObject(url, params, String.class);
            }).start();
        }

    }
}

Здесь мы открываем 100 тем и одновременно отправляем запросы на комментарии, а соответствующий идентификатор статьи равен 1.

Перед отправкой запроса данные базы данных

select * from article

article-0

select count(*) comment_count from comment

comment-count-0

После отправки запроса данные базы данных

select * from article

article-1

select count(*) comment_count from comment

comment-count-1

Очевидно, что значение comment_count в таблице article не равно 100. Это значение не обязательно равно 14 на моей картинке, но оно должно быть не больше 100, а количество таблиц комментариев должно быть равно 100.

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

Ниже приведен пример, показывающий, как предотвратить параллельные проблемы с данными с помощью пессимистической блокировки и оптимистической блокировки. В то же время дано решение SQL и собственное решение JPA. Решение SQL можно использовать в «любой системе», даже без языка ограничений, в то время как решение JPA очень быстрое.Если вы тоже используете JPA, вы можете просто использовать оптимистическую или пессимистическую блокировку. Наконец, мы сравним некоторые различия между оптимистичными и пессимистичными блокировками в зависимости от бизнеса.

Пессимистические блокировки решают проблемы параллелизма

Пессимистические блокировки, как следует из названия, пессимистичны в отношении того, что данные, которыми они оперируют, будут управляться другими потоками, поэтому они должны монополизировать сами данные, что можно понимать как «исключительные блокировки». в явеsynchronizedиReentrantLockОжидающие блокировки — это пессимистичные блокировки, а также блокировки таблиц, строк и чтения-записи в базе данных.

Использование SQL для решения проблем параллелизма

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

Теперь измените его на основе исходного кода, сначала вArticleRepositoryДобавьте ручной метод запроса sql.

public interface ArticleRepository extends CrudRepository<Article, Long> {
    @Query(value = "select * from article a where a.id = :id for update", nativeQuery = true)
    Optional<Article> findArticleForUpdate(Long id);
}

тогда поставьCommentServiceМетод запроса, используемый в оригиналеfindByIdИзменить на наш пользовательский метод

public class CommentService {
    ...
    
    public void postComment(Long articleId, String content) {
        // Optional<Article> articleOptional = articleRepository.findById(articleId);
        Optional<Article> articleOptional = articleRepository.findArticleForUpdate(articleId);
    
    	...
    }
}

Итак, мы узналиArticle,Никакой другой поток не может получить модификацию, пока мы не зафиксируем ее в транзакции., что гарантирует, что только один поток может обрабатывать соответствующие данные одновременно.

Теперь проверьте это с помощью тестового примера,article.comment_countдолжно быть 100.

Использование JPA для решения проблем параллелизма блокировки строк

За только что упомянутое увеличение задней части sqlfor update, JPA имеет более элегантный способ, то есть@LockAnnotation, параметру этой аннотации можно передать желаемый уровень блокировки.

сейчас наArticleRepositoryДобавьте метод блокировки JPA вLockModeType.PESSIMISTIC_WRITEПараметр является блокировкой строки.

public interface ArticleRepository extends CrudRepository<Article, Long> {
    ...
    
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Article a where a.id = :id")
    Optional<Article> findArticleWithPessimisticLock(Long id);
}

так жеCommentServiceИзмените метод запроса наfindArticleWithPessimisticLock(), а затем протестируйте тестовый пример, проблем с параллелизмом не будет. И посмотрите на информацию о печати консоли в это время и обнаружите, что фактически запрошенный sql все еще добавленfor update, но JPA добавила его для нас.

sql-for-update

Оптимистическая блокировка решает проблемы параллелизма

Оптимистическая блокировка, как следует из названия, очень оптимистична. Она думает, что ресурсы, полученные ею самой, не будут управляться другими потоками, поэтому она не блокируется. Она предназначена только для того, чтобы судить о том, были ли изменены данные при вставке в базу данных. . Таким образом, пессимистическая блокировка ограничивает другие потоки, а оптимистическая блокировка ограничивает себя.Хотя его имя имеет блокировку, на самом деле она не заблокирована.Это только для того, чтобы определить, как действовать в конечной операции.

Оптимистичная блокировка обычно является механизмом номера версии или алгоритм CAS

Используйте SQL для достижения номера версии для решения проблем параллелизма

Механизм номера версии заключается в том, чтобы добавить поле в базу данных в качестве номера версии, например, мы добавляем версию поля. Тогда получите этоArticleЭто принесет номер версии, например, полученная версия 1, а затем выArticleОдна операция прохода, после завершения операции ее необходимо вставить в базу данных. Обнаружил упс, что произошло в базе данныхArticleВерсия 2, которая отличается от версии в моей руке, указывая на то, что версия в моей рукеArticleЕсли он не актуален, он не может быть помещен в базу данных. Это позволяет избежать проблемы конфликта данных во время параллелизма.

Итак, теперь мы добавляем версию поля в таблицу статей.

статья статья таблица

поле тип Примечание
version INT DEFAULT 0 номер версии

Затем соответствующий класс сущности также добавляет поле версии

@Data
@Entity
public class Article {
	...
    
    private Long version;
}

затем вArticleRepositoryДобавьте метод обновления. Обратите внимание, что это метод обновления, который отличается от добавления метода запроса при использовании пессимистических блокировок.

public interface ArticleRepository extends CrudRepository<Article, Long> {
    @Modifying
    @Query(value = "update article set comment_count = :commentCount, version = version + 1 where id = :id and version = :version", nativeQuery = true)
    int updateArticleWithVersion(Long id, Long commentCount, Long version);
}

Вы можете увидеть обновление, где есть критерий, который определяет версию и устанавливает версию = версия + 1. Это гарантирует, что данные обновляются только тогда, когда обновляются номер версии базы данных и номер версии класса сущностей.

затем вCommentServiceНемного измените код.

// CommentService
public void postComment(Long articleId, String content) {
    Optional<Article> articleOptional = articleRepository.findById(articleId);

    ...	

    int count = articleRepository.updateArticleWithVersion(article.getId(), article.getCommentCount() + 1, article.getVersion());
    if (count == 0) {
        throw new RuntimeException("服务器繁忙,更新数据失败");
    }
    // articleRepository.save(article);
}

Первый дляArticleМетод запроса требует только обычногоfindById()Метод работает без блокировок.

затем обновитеArticleиспользовать только что добавленныйupdateArticleWithVersion()метод. Вы можете видеть, что этот метод имеет возвращаемое значение. Это возвращаемое значение представляет количество обновленных строк базы данных. Если значение равно 0, это означает, что нет строк, удовлетворяющих условиям, которые можно обновить.

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

Теперь используйте тестовый пример для проверки

select * from article

article-2

select count(*) comment_count from comment

comment-count-2

смотри сейчасArticlecomment_count в иCommentЧисло уже не 100.Но два значения должны быть одинаковыми. Потому что, когда мы имели дело с этим только что, еслиArticleЕсли данные таблицы конфликтуют, то они не будут обновлены в базе данных.В это время выдается исключение, чтобы заставить транзакцию откатиться, чтобы гарантировать отсутствие обновленияArticleкогдаCommentНе вставляется, и проблема данных не однородна.

Этот метод прямого отката имеет плохой пользовательский опыт.ArticleКогда количество обновлений равно 0, он попытается снова запросить информацию из базы данных и снова изменить ее, попытаться снова обновить данные и, если это не удастся, снова запросить, пока она не будет обновлена. Конечно, это не будет операция, такая как беспроводная петля.Будет установлена ​​онлайн-линия.Например, если невозможно запросить, изменить и обновить петлю три раза, в это время будет выдано исключение .

Используйте JPA для реализации версии сейчас, чтобы решить проблемы параллелизма.

В JPA есть способ реализовать пессимистичные блокировки, и, естественно, есть и оптимистичные блокировки.Теперь мы используем методы, поставляемые с JPA, для реализации оптимистичных блокировок.

первый вArticleДобавить в поле версии класса сущности@VersionАннотация, давайте посмотрим на аннотации исходного кода в аннотациях, и мы видим, что некоторые из них написаны:

The following types are supported for version properties: int, Integer, short, Short, long, Long, java.sql.Timestamp.

В примечании говорится, что тип номера версии поддерживает три основных типа данных: int, short, long и их классы-оболочки и Timestamp, сейчас мы используем тип Long.

@Data
@Entity
public class Article {
    ...
    
    @Version
    private Long version;
}

Тогда просто нужноCommentServiceПросто измените процесс комментирования в этом обратно на бизнес-код, который «вызовет проблемы параллелизма» в начале. Это показывает, что эта оптимистичная реализация блокировки JPA не является навязчивой.

// CommentService
public void postComment(Long articleId, String content) {
    Optional<Article> articleOptional = articleRepository.findById(articleId);
    ...

    article.setCommentCount(article.getCommentCount() + 1);
    articleRepository.save(article);
}

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

select * from article

article-3

select count(*) comment_count from comment

comment-count-3

такой жеArticlecomment_count в иCommentЧисло тоже не 100, но эти два числа определенно одинаковы. Взгляните на консоль IDEA, и вы обнаружите, что система выдаетObjectOptimisticLockingFailureExceptionисключение.

exception

Это похоже на нашу собственную реализацию оптимистической блокировки только что: если данные не будут успешно обновлены, будет выдано исключение, и данные будут отброшены для обеспечения согласованности данных. Если вы хотите реализовать процесс повторной попытки, вы можете захватитьObjectOptimisticLockingFailureExceptionДля этого исключения обычно используются AOP + пользовательские аннотации для реализации глобального общего механизма повторных попыток. Это должно быть расширено в соответствии с конкретной бизнес-ситуацией. Если вы хотите узнать больше, вы можете найти решение самостоятельно.

Сравнение пессимистической блокировки и оптимистичной блокировки

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

Оптимистичная блокировка подходит для сценариев, где пишут меньше и Подробнее.由于乐观锁在发生冲突的时候会回滚或者重试,如果写的请求量很大的话,就经常发生冲突,经常的回滚和重试,这样对系统资源消耗也是非常大。

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

Если вероятность каждого конфликта доступа меньше 20%, рекомендуется оптимистическая блокировка, в противном случае используется пессимистическая блокировка. Оптимистичные попытки блокировки Число должно быть не менее 3 раз.

Alibaba рекомендует использовать значение вероятности конфликта 20% в качестве разделительной линии для принятия решения об использовании оптимистичных и пессимистичных блокировок.Хотя это значение не является абсолютным, оно также является хорошим эталоном, как подытожили большие шишки Alibaba.


Оригинальный адрес:Spring Boot+SQL/JPA борется с пессимистичной блокировкой и оптимистичной блокировкой