Spring Boot+SQL/JPA实战悲观锁和乐观锁

简介: 通过悲观锁和乐观锁防止出现并发数据问题,同时给出SQL方案和JPA自带方案,SQL方案可以通用“任何系统”,甚至不限语言,而JPA方案十分快捷,如果你恰好用的也是JPA,那就可以简单的使用上乐观锁或悲观锁。

【转载请注明出处】:https://developer.aliyun.com/article/758432

业务还原

首先环境是:Spring Boot 2.1.0 + data-jpa + mysql + lombok

数据库设计

对于一个有评论功能的博客系统来说,通常会有两个表:1.文章表 2.评论表。其中文章表除了保存一些文章信息等,还有个字段保存评论数量。我们设计一个最精简的表结构来还原该业务场景。

article 文章表

字段 类型 备注
id INT 自增主键id
title VARCHAR 文章标题
comment_count INT 文章的评论数量

comment 评论表

字段 类型 备注
id INT 自增主键id
article_id INT 评论的文章id
content VARCHAR 评论内容

当一个用户评论的时候,1. 根据文章id获取到文章 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;
}

接着创建这两个实体类对应的Repository,由于spring-jpa-data的CrudRepository已经帮我们实现了最常见的CRUD操作,所以我们的Repository只需要继承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,并且插入到数据库中,接着增加该文章的评论数量,再把修改后的文章更新到数据库中,整个流程如下流程图。

image.png

在这个流程中有个问题,当有多个用户同时并发评论时,他们同时进入步骤1中拿到Article,然后插入对应的Comment,最后在步骤3中更新评论数量保存到数据库。只是由于他们是同时在步骤1拿到的Article,所以他们的Article.commentCount的值相同,那么在步骤3中保存的Article.commentCount+1也相同,那么原来应该+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个线程,同时发送评论请求,对应的文章id为1。

在发送请求前,数据库数据为

select * from article

image.png

select count(*) comment_count from comment

image.png

发送请求后,数据库数据为

select * from article

image.png

select count(*) comment_count from comment

image.png

明显的看到在article表里的comment_count的值不是100,这个值不一定是我图里的14,但是必然是不大于100的,而comment表的数量肯定等于100。

这就展示了在文章开头里提到的并发问题,这种问题其实十分的常见,只要有类似上面这样评论功能的流程的系统,都要小心避免出现这种问题。

下面就用实例展示展示如何通过悲观锁和乐观锁防止出现并发数据问题,同时给出SQL方案和JPA自带方案,SQL方案可以通用“任何系统”,甚至不限语言,而JPA方案十分快捷,如果你恰好用的也是JPA,那就可以简单的使用上乐观锁或悲观锁。最后也会根据业务比较一下乐观锁和悲观锁的一些区别

悲观锁解决并发问题

悲观锁顾名思义就是悲观的认为自己操作的数据都会被其他线程操作,所以就必须自己独占这个数据,可以理解为”独占锁“。在java中synchronizedReentrantLock等锁就是悲观锁,数据库中表锁、行锁、读写锁等也是悲观锁。

利用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自带行锁解决并发问题

对于刚才提到的在sql后面增加for update,JPA有提供一个更优雅的方式,就是@Lock注解,这个注解的参数可以传入想要的锁级别。

现在在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帮我们加了而已。

image.png

乐观锁解决并发问题

乐观锁顾名思义就是特别乐观,认为自己拿到的资源不会被其他线程操作所以不上锁,只是在插入数据库的时候再判断一下数据有没有被修改。所以悲观锁是限制其他线程,而乐观锁是限制自己,虽然他的名字有锁,但是实际上不算上锁,只是在最后操作的时候再判断具体怎么操作。

乐观锁通常为版本号机制或者CAS算法

利用SQL实现版本号解决并发问题

版本号机制就是在数据库中加一个字段当作版本号,比如我们加个字段version。那么这时候拿到Article的时候就会带一个版本号,比如拿到的版本是1,然后你对这个Article一通操作,操作完之后要插入到数据库了。发现哎呀,怎么数据库里的Article版本是2,和我手里的版本不一样啊,说明我手里的Article不是最新的了,那么就不能放到数据库了。这样就避免了并发时数据冲突的问题。

所以我们现在给article表加一个字段version

article 文章表

字段 类型 备注
version INT DEFAULT 0 版本号

然后对应的实体类也增加version字段

@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);
}

可以看到update的where有一个判断version的条件,并且会set version = 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

image.png

select count(*) comment_count from comment

image.png

现在看到Article里的comment_count和Comment的数量都不是100了,但是这两个的值必定是一样的了。因为刚才我们处理的时候假如Article表的数据发生了冲突,那么就不会更新到数据库里,这时抛出异常使其事务回滚,这样就能保证没有更新Article的时候Comment也不会插入,就解决了数据不统一的问题。

这种直接回滚的处理方式用户体验比较差,通常来说如果判断Article更新条数为0时,会尝试重新从数据库里查询信息并重新修改,再次尝试更新数据,如果不行就再查询,直到能够更新为止。当然也不会是无线的循环这样的操作,会设置一个上线,比如循环3次查询修改更新都不行,这时候才会抛出异常。

利用JPA实现版本现解决并发问题

JPA对悲观锁有实现方式,乐观锁自然也是有的,现在就用JPA自带的方法实现乐观锁。

首先在Article实体类的version字段上加上@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

image.png

select count(*) comment_count from comment

image.png

同样的Article里的comment_count和Comment的数量也不是100,但是这两个数值肯定是一样的。看一下IDEA的控制台会发现系统抛出了ObjectOptimisticLockingFailureException的异常。

image.png

这和刚才我们自己实现乐观锁类似,如果没有成功更新数据则抛出异常回滚保证数据的一致性。如果想要实现重试流程可以捕获ObjectOptimisticLockingFailureException这个异常,通常会利用AOP+自定义注解来实现一个全局通用的重试机制,这里就是要根据具体的业务情况来拓展了,想要了解的可以自行搜索一下方案。

悲观锁和乐观锁比较

悲观锁适合写多读少的场景。因为在使用的时候该线程会独占这个资源,在本文的例子来说就是某个id的文章,如果有大量的评论操作的时候,就适合用悲观锁,否则用户只是浏览文章而没什么评论的话,用悲观锁就会经常加锁,增加了加锁解锁的资源消耗。

乐观锁适合写少读多的场景。由于乐观锁在发生冲突的时候会回滚或者重试,如果写的请求量很大的话,就经常发生冲突,经常的回滚和重试,这样对系统资源消耗也是非常大。

所以悲观锁和乐观锁没有绝对的好坏,必须结合具体的业务情况来决定使用哪一种方式。另外在阿里巴巴开发手册里也有提到:

如果每次访问冲突概率小于 20%,推荐使用乐观锁,否则使用悲观锁。乐观锁的重试次
数不得小于 3 次。

阿里巴巴建议以冲突概率20%这个数值作为分界线来决定使用乐观锁和悲观锁,虽然说这个数值不是绝对的,但是作为阿里巴巴各个大佬总结出来的也是一个很好的参考。

【转载请注明出处】:https://developer.aliyun.com/article/758432

相关文章
|
23天前
|
SQL 安全 Java
揭秘Spring Boot安全防线:如何巧妙抵御XSS与SQL注入的双重威胁?
【8月更文挑战第29天】随着互联网技术的发展,Web应用已成为社会不可或缺的一部分。Spring Boot作为高效构建Web应用的框架备受青睐,但同时也面临安全挑战,如XSS攻击和SQL注入。本文介绍如何在Spring Boot应用中防范这两种常见安全漏洞。针对XSS攻击,可通过输入验证、输出编码及使用安全API来加强防护;对于SQL注入,则应利用预编译语句、参数化查询及最小权限原则来确保数据库安全。示例代码展示了具体实现方法,帮助开发者提升应用安全性。
69 2
|
8天前
|
Java 数据库连接 API
【Java笔记+踩坑】Spring Data JPA
从常用注解、实体类和各层编写方法入手,详细介绍JPA框架在增删改查等方面的基本用法,以及填充用户名日期、分页查询等高级用法。
【Java笔记+踩坑】Spring Data JPA
|
21天前
|
SQL 存储 数据处理
"SQL触发器实战大揭秘:一键解锁数据自动化校验与更新魔法,让数据库管理从此告别繁琐,精准高效不再是梦!"
【8月更文挑战第31天】在数据库管理中,确保数据准确性和一致性至关重要。SQL触发器能自动执行数据校验与更新,显著提升工作效率。本文通过一个员工信息表的例子,详细介绍了如何利用触发器自动设定和校验薪资,确保其符合业务规则。提供的示例代码展示了在插入新记录时如何自动检查并调整薪资,以满足最低标准。这不仅减轻了数据库管理员的负担,还提高了数据处理的准确性和效率。触发器虽强大,但也需谨慎使用,以避免复杂性和性能问题。
28 1
|
17天前
|
SQL 安全 数据库
基于SQL Server事务日志的数据库恢复技术及实战代码详解
基于事务日志的数据库恢复技术是SQL Server中一个非常强大的功能,它能够帮助数据库管理员在数据丢失或损坏的情况下,有效地恢复数据。通过定期备份数据库和事务日志,并在需要时按照正确的步骤恢复,可以最大限度地减少数据丢失的风险。需要注意的是,恢复数据是一个需要谨慎操作的过程,建议在执行恢复操作之前,详细了解相关的操作步骤和注意事项,以确保数据的安全和完整。
36 0
|
20天前
|
Java Spring 数据库
怎样动动手指就能实现数据操作?Spring Data JPA背后的魔法揭秘
【8月更文挑战第31天】在Java开发中,数据库交互至关重要。传统的JDBC操作繁琐且难维护,而Spring Data JPA作为集成JPA的数据访问层解决方案,提供了CRUD等通用操作接口,显著减少代码量。通过继承`JpaRepository`,开发者能轻松实现数据的增删改查,甚至复杂查询和分页也不再困难。本文将通过示例详细介绍如何利用Spring Data JPA简化数据访问层的开发,提升代码质量和可维护性。
26 0
|
20天前
|
测试技术 Java
全面保障Struts 2应用质量:掌握单元测试与集成测试的关键策略
【8月更文挑战第31天】Struts 2 的测试策略结合了单元测试与集成测试。单元测试聚焦于单个组件(如 Action 类)的功能验证,常用 Mockito 模拟依赖项;集成测试则关注组件间的交互,利用 Cactus 等框架确保框架拦截器和 Action 映射等按预期工作。通过确保高测试覆盖率并定期更新测试用例,可以提升应用的整体稳定性和质量。
40 0
|
20天前
|
数据库 Java 监控
Struts 2 日志管理化身神秘魔法师,洞察应用运行乾坤,演绎奇幻篇章!
【8月更文挑战第31天】在软件开发中,了解应用运行状况至关重要。日志管理作为 Struts 2 应用的关键组件,记录着每个动作和决策,如同监控摄像头,帮助我们迅速定位问题、分析性能和使用情况,为优化提供依据。Struts 2 支持多种日志框架(如 Log4j、Logback),便于配置日志级别、格式和输出位置。通过在 Action 类中添加日志记录,我们能在开发过程中获取详细信息,及时发现并解决问题。合理配置日志不仅有助于调试,还能分析用户行为,提升应用性能和稳定性。
36 0
|
20天前
|
前端开发 Java JSON
Struts 2携手AngularJS与React:探索企业级后端与现代前端框架的完美融合之道
【8月更文挑战第31天】随着Web应用复杂性的提升,前端技术日新月异。AngularJS和React作为主流前端框架,凭借强大的数据绑定和组件化能力,显著提升了开发动态及交互式Web应用的效率。同时,Struts 2 以其出色的性能和丰富的功能,成为众多Java开发者构建企业级应用的首选后端框架。本文探讨了如何将 Struts 2 与 AngularJS 和 React 整合,以充分发挥前后端各自优势,构建更强大、灵活的 Web 应用。
35 0
|
21天前
|
SQL 数据采集 算法
【电商数据分析利器】SQL实战项目大揭秘:手把手教你构建用户行为分析系统,从数据建模到精准营销的全方位指南!
【8月更文挑战第31天】随着电商行业的快速发展,用户行为分析的重要性日益凸显。本实战项目将指导你使用 SQL 构建电商平台用户行为分析系统,涵盖数据建模、采集、处理与分析等环节。文章详细介绍了数据库设计、测试数据插入及多种行为分析方法,如购买频次统计、商品销售排名、用户活跃时间段分析和留存率计算,帮助电商企业深入了解用户行为并优化业务策略。通过这些步骤,你将掌握利用 SQL 进行大数据分析的关键技术。
37 0
|
21天前
|
SQL 安全 测试技术
【数据守护者必备】SQL数据备份与恢复策略全解析:从全量到日志备份,手把手教你确保企业信息万无一失的实战技巧!
【8月更文挑战第31天】数据库是企业核心业务数据的基石,为防止硬件故障、软件错误或人为失误导致的数据丢失,制定可靠的备份与恢复策略至关重要。本文通过一个在线购物平台的案例,详细介绍了使用 SQL Server 进行全量备份、差异备份及事务日志备份的方法,并演示了如何利用 SQL Server Agent 实现自动化备份任务。此外,还提供了数据恢复的具体步骤和测试建议,确保数据安全与业务连续性。
21 0