SpringMVC并发请求线程安全问题案例分析

时间:2022-07-03 17:59:23

背景:一个人的成长在于你经历了多少,正如古语有云“读万卷书,不如行万里路。”做技术尤其如此,要想快速成长,必须先多写代码,多思考,多总结,当然还可以通过帮助别人解决问题来验证或者激励自己的成长。今天这篇文章主要是基于一个朋友在实际开发中出现的一个并发案例,在帮助其解决的过程中,发现自己也有很多的知识误区,遂写此篇以作记录,同时也分享给大家。

一,案例描述

①,业务需求

用户通过系统生成邀请二维码图片分享给朋友,朋友通过扫描二维码关注公众号,记录扫码关注人数,当邀请关注人数达到十个时,用户将获得平台免费提供的学习电子书。

②,原代码实现逻辑

每当用户扫码关注公众号后,系统获得消息通知,这是先查询数据库做判断,如果目前通过扫码关注的人数少于10人,则进行++自增操作,否者提示用户,扫码关注已经达到十人,赠送电子书给用户。

③,问题描述

当出现多个人同时扫码关注的时候,出现并发问题,系统累计关注人数统计出现误差,实际关注人数大于10人,但是系统提示还是差一个人。如下是案例截图:

SpringMVC并发请求线程安全问题案例分析

④,问题分析

当多个用户同时扫码时,向服务发送请求,此时tomcat容器默认是NIO的运行模式,通过线程池创建了多个线程,并开始执行业务代码,朋友的业务服务是基于SpringBoot实现的,当然SpringBoot Web就是基于SpringMVC来做的,通常在默认的情况下,SpringMVC的Controller控制器是单例模式,也就是说多线程并发的时候,控制器的成员变量都是共享的,此时如果产生并发的时候,业务代码是数据库的非原子操作,或者Java代码里面有非线程安全的代码块,没有考虑线程安全问题的话就可能会出现如上Bug。(在此,我也反省一下,当时我第一反应就是,这个线程安全问题应该是 出现在++自增操作运算符,因为在Java里面自增运算符是非线程安全的,哎,发现自己还是 too young too simple ! )。

二,技术延伸

多线程并发安全问题一直困扰着广大的程序员,但是其实如果,我们搞清楚了“背后的故事”其实也就那么回事,所以在讲解决方案之前,我还是抛砖迎玉给大家稍微科普一下基础知识 。

名词解释

多线程:多线程是指,对于一个进程,同一时刻上下文环境中存在大于1的线程数量,通常我们就称之为多线程。

并发:并发是指,在同一时间区间内,有多个任务在同时进行,通常称之为并发。(当然这个代表个人观点,参考资料源于黄文海老师的《多线程编程实战指南》)。

并行:并行是指,在同一时刻,有多个任务同时进行,通常称之为并行。(当然这个代表个人观点,参考资料源于黄文海老师的《多线程编程实战指南》)。

线程安全:当多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完成,其他线程才可以使用。不会出现数据不一致或者数据污染。

原子操作:如果这个操作所处的层的更高层不能发现其内部实现与结构,那么这个操作是一个原子操作。原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序不可以被打乱,也不可以被切割而只是执行其中一部分。将整个操作视作一个整体是原子性的核心特征。

知识科普:

SpringMVC:大家都知道依赖注入(IOC)和AOP是Spring的两大核心特性,对于SpringMVC也是基于Spring的对象管理,SpringMVC是基于方法的拦截,当然这就可能存在非线程安全情况,通常默认情况下,控制器生成的是单例,如果要声明多例可以通过注解 @Scope("prototype") 声明。

数据库事物的隔离级别:数据库的隔离级别常常分为四个层级,这个是SQL标准规定。分别如下:

SQL标准定义了四类隔离级别,包括了一些具体规则,用来限定事物内外的哪些改变是可见的,哪些事不可见的。低级别的隔离级一般支持更高的并发处理,并拥有更低的系统开销。

Read Uncommited (读取未提交内容)

  在该隔离级别,所有事物都可以看到其他未提交事物的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)。

Read Commited (读取提交内容)

这是大多数数据库系统的默认隔离级别(但不是Mysql默认的)。它满足了隔离的简单定义:一个事物只能看见已经提交事物所做的改变。这种隔离级别也支持所谓的不可重复读(Nonrepeatable Read),因为同一事物的其他实例在该实例处理期间可能会有新的commit,所以同一select可能返回不同结果。

  Repeatable Read(可重复读)

这是MySQL的默认事物隔离级别,它确保同一事物的多个实例在并发读取数据时,会看到同样的数据行。不过理论上这会导致另一个棘手的问题:幻读(Phantom Read)。简单的说,幻读指当用户读取某一范围的数据行时,另一个事物又在该范围内插入了新行,当用户再次读取该范围的数据行时,会发现有新的"幻影"行。InnoDB 储存引擎和Falcon储存引擎通过多版本并发控制(MVCC,Multiversion Concurrency Control)机制解决了改问题。


Serialiable(可串行化)

这是最高的隔离级别,他通过强制事物排序,使之不可能相互冲突,从而解决幻读问题。简言之,他是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。

(更多内容可以参考:http://xm-king.iteye.com/blog/770721)


悲观锁:

在关系数据库管理系统里,悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”),是一种并发控制的方法。它可以阻止一个事务以影响其他用户的方式来修改数据。如果一个事务执行的操作对某行数据应用了锁,那只有当这个事务把锁释放。其他事务才能够执行与该锁冲突的操作。悲观并发控制主要用于数据竞争激烈的环境,以及发生并发冲突时使用锁保护数据的成本要低于回滚事务的成本环境中。


乐观锁:

在关系型数据库管理系统里,乐观锁并发控制(又名“乐观锁”,Optimistic Concurrency Control,缩写“OCC”)是一种并发控制的方法。它架设多用户并发的事物在处理时不会彼此相互影响,各事物能够在不产生锁的情况下处理各自影响的那部分数据。在提交数据更新前,每个事物都会先检查在该事务读取数据后,有没有其他事物修改了该数据。如果其他事物有更新的话,正在提交的事务会进行回滚。


三,解决方案


针对本文的并发案例,本质是因为事物非原子操作先读再判断再读再修改,并且SpringMVC控制器的单例模式,所以当多线程并发产生上线文切换的时候导致每个线程出现脏读的情况,最终造成Bug的出现。解决办法有如下三个:


①,把数据库统计字段自增操作放到sql语句里面,这样本次事务遍成了原子操作。

②,采用jvm层面同步锁,先读再判断,对之后的读和修改用synchronized 关键字封装成原子操作,进而保证数据安全。

③,建立缓冲阻塞队列,封装多个请求放入队列,用单线程去处理队列里面的任务,最终达到多线程转单线程的目的。

④,采用悲观锁,对于改操作,采用数据库的悲观锁机制加上 for update 语句,通过数据库锁来保证数据的一致。

⑤,采用乐观锁,对操作表加上版本号,每次修改验证版本号的一致性。


写在最后,路漫漫其修远兮,在知识的海洋我们就像大海中的浮萍,微不足道,但是只要我们心存信仰,朝着我们梦想的方向 坚持前行,那么至少我们在一天一天的成长,一天一天的变好,也希望大家在新的一年里梦想成真。