springboot生产环境synchronized同步方法阻塞,疑似产生死锁导致调用此方法的线程一直等待,最后数据库连接池溢出服务挂掉,请教大家如何解决呢?
公司去年上线了一个扫码就餐的业务,每天中午大概会有300人左右扫码就餐,但上个月扫码支付的方法隔三岔五地会卡死,我最终排查发现是方法中获取id值和订单编号的同步方法卡死,但按理说只存在一个同步锁不会产生死锁啊
下面是业务代码,代码中我用注释描述产生问题的过程
支付方法代码
@Slf4j
@Service("orderService")
@Transactional(rollbackFor = Exception.class)
public class OrderService {
@Autowired
private GenaralBillNoService billNoService;
/**
* 用户消费支付
*
* @param request
* @return
* @throws IOException
*/
public Result<?> paySaleInfo(HttpServletRequest request, PaymentOrder order) throws IOException {
User user = ContextUtils.getLoginUser();
log.info("消费支付:{},操作用户:{}", JSONObject.toJSONString(order), user);
//**只有第一个人执行完了这行代码,其余所有线程都卡在这一行,直到最后连接池溢出服务挂断**
order.setId(billNoService.getMaxIntegerID("PaymentOrder"));
//**第一个人获取到id之后卡在了这行没能往下执行**
order.setBillNo(billNoService.getBillNo("CTXF", "PaymentOrder"));
//以下业务代码省略
}
}
获取id值和编号的接口
@Transactional
@Service("genaralBillNoService")
public class GenaralBillNoService {
/**
* 获取主键id
* @param 前缀
* @param 表名
* @return
*/
public synchronized Integer getMaxIntegerID(String tableName) {
int no = 1;
BillNo entity;
if(JpaUtil.linq(BillNo.class).equal("tableName", tableName).equal("prefix", "ID").exists()) {
//**第二个人顺利执行完上一行判断,但不知道为什么卡在此行未能往下执行。所以第二个人一直占有同步锁导致其它线程都被阻塞,第一个人也在获取到id值后被卡在了后面获取编号的方法上不能执行**
entity = JpaUtil.linq(BillNo.class).equal("tableName", tableName).equal("prefix", "ID").findOne();
no = entity.getCount();
no = no + 1;
entity.setCount(no);
//**从日志上看,第一个人还未执行这行代码时,第二个人就扫码进入支付方法等待第一个人执行完此方法释放锁,然后第一个人顺利执行完此行代码**
JpaUtil.mergeAndFlush(entity); //刷新主键表
}else {
entity = new BillNo();
entity.setTableName(tableName);
entity.setPrefix("ID");
entity.setCount(no);
JpaUtil.persistAndFlush(entity);
}
return entity.getCount();
}
/**
* 自动生成流水号
* @param 前缀 YQ
* @param 表名 YAOQINGMA
* @return
*/
public synchronized String getBillNo(String prefix, String tableName) {
String billNo = "";
SimpleDateFormat dateFormat = new SimpleDateFormat("yyMMdd");
String format = dateFormat.format(new Date());
BillNo entity;
int no = 1;
if(JpaUtil.linq(BillNo.class).equal("tableName", tableName).equal("prefix", prefix).equal("format", format).exists()) {
entity = JpaUtil.linq(BillNo.class).equal("tableName", tableName).equal("prefix", prefix).equal("format", format).findOne();
no = entity.getCount();
no = no + 1;
entity.setCount(no);
JpaUtil.mergeAndFlush(entity);
}else {
entity = new BillNo();
entity.setTableName(tableName);
entity.setPrefix(prefix);
entity.setFormat(format);
entity.setCount(no);
JpaUtil.persistAndFlush(entity);
}
billNo = String.valueOf(prefix) +format + String.format("%03d", new Object[] {Integer.valueOf(no)});
return billNo;
}
}
数据库配置如下:
##MSSQL
spring.datasource.url=jdbc:sqlserver://127.0.0.1:1433;DatabaseName=ttmanager;useLOBs=false
spring.datasource.username=sa
spring.datasource.password=123456
spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver
#连接池属性
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
#druid standard config
spring.datasource.maxActive=30
spring.datasource.initialSize=5
spring.datasource.minIdle=5
spring.datasource.maxWait=120000
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.minEvictableIdleTimeMillis=300000
spring.datasource.validationQuery=select 1
spring.datasource.testWhileIdle=true
spring.datasource.testOnBorrow=false
spring.datasource.testOnReturn=false
#druid extends config
spring.datasource.filters=stat,wall,log4j
spring.datasource.poolPreparedStatements=true
spring.datasource.maxOpenPreparedStatements=20
spring.datasource.logSlowSql=true
下面是日志:
我对并发编程理解不深刻,我还是没有理解明明各个线程竞争是同一实例的同步锁,但还是出现死锁问题。如果用synchronized代码块将获取id和编号的两行代码包起来是否有效呢,大家有什么建议呢
不要滥用同步方法
只有多线程操作同一个线程不安全的内存对象的时候才应该用到同步
操作IO的时候不应该使用同步
如果确实有需要操作IO,比如写文件,也应该使用读写者(生产者消费者)模型,只让其中一个线程去写文件而不是所有线程都阻塞等待写文件
根据您的描述,问题可能是由于同步方法阻塞导致死锁,进而导致数据库连接池溢出。为了解决这个问题,可以考虑以下几点:
检查代码逻辑:确保同步方法的使用是必要的,并且同步锁的范围是否合理。尽量缩小同步锁的范围,以减少阻塞的可能性。
考虑使用更细粒度的同步机制:如果同步锁的范围过大,可以考虑使用更细粒度的同步机制,例如使用对象级别的锁或者局部锁,而不是整个方法级别的锁。
使用并发容器代替同步方法:如果可能的话,可以考虑使用Java的并发容器(如ConcurrentHashMap)来代替同步方法,以提高并发性能。
检查数据库连接池配置:确保数据库连接池的配置与实际需求相符。检查连接池的最大连接数、空闲连接超时时间等参数,确保连接池能够适应高并发场景。
引入分布式锁:如果同步方法的并发性要求较高,并且单机无法满足需求,可以考虑引入分布式锁机制,例如使用Redisson等工具来实现分布式锁,以提高并发性能。
需要注意的是,解决死锁问题可能需要对代码进行仔细的调查和分析,并可能需要进行一些性能测试和优化。建议您根据具体情况逐步尝试以上建议,并在实施之前备份好代码和数据,以防止意外情况发生。如果问题仍然存在,建议请教专业的开发人员或咨询相关的技术支持团队,他们可以更深入地分析和解决您的问题。
确实先看资源冲突,虽然表面上是一把锁但是里面使用的资源可能冲突,比如jpautil里面使用的连接是不是和应用属于同一个连接池,如果没配置超时可能造成jpautil获取不到连接堵塞逆向卡住synchronized锁
getMaxIntegerID方法中的synchronized关键字:这个方法是同步方法,使用synchronized关键字确保同一时间只有一个线程可以执行该方法。当多个线程同时尝试获取该方法的锁时,只有一个线程能够获取锁并执行方法,其他线程会被阻塞等待。这可能导致死锁的情况,如果第一个线程在获取锁后执行到getBillNo方法之前被阻塞,其他线程将一直等待获取锁。
如果第一个线程获取到getMaxIntegerID方法的锁之后,有其他线程已经获取到了getBillNo方法的锁,并且没有释放锁,那么第一个线程就会被阻塞等待获取getBillNo方法的锁。如果getBillNo方法中的数据库操作比较耗时,例如查询、更新等操作需要花费较长的时间,那么第一个线程在获取到getMaxIntegerID方法的锁之后,在执行getBillNo方法时可能会被阻塞等待数据库操作完成。
而此时,其他所有线程都将被阻塞在getMaxIntegerID方法等待锁释放
源于chatGPT仅供参考
根据您提供的代码和描述,可能存在以下问题和解决方案:
1. `synchronized`关键字导致死锁:在`GenaralBillNoService`中,`getMaxIntegerID`方法和`getBillNo`方法都被声明为`synchronized`,这会使得同一时间只有一个线程能够访问这两个方法。然而,在`OrderService`中,先调用了`getMaxIntegerID`方法获取id值,然后再调用`getBillNo`方法获取编号。如果多个线程同时进入`paySaleInfo`方法,它们会竞争获取`GenaralBillNoService`上的同步锁,从而导致死锁。
解决方案:
- 重新评估是否需要使用`synchronized`关键字。如果确实需要对共享资源进行同步访问,请确保同步的粒度合理,避免死锁的发生。
- 可以考虑将同步锁的粒度缩小到更小的代码块,减少竞争并发生死锁的风险。例如,可以将同步锁应用在读写数据库的具体操作上,而不是整个方法。
2. 数据库连接池溢出:当每个线程在等待同步锁时,它们会占用连接池中的数据库连接资源。如果等待的线程过多,就会导致连接池耗尽,进而造成数据库连接池溢出。
解决方案:
- 调整数据库连接池的配置,增加最大连接数或者超时时间,以适应高并发情况。这样可以减少连接池溢出的可能性。
- 评估代码中对数据库连接的使用方式,确保及时释放和关闭数据库连接,避免长时间占用连接资源。
需要注意的是,以上解决方案仅为参考,请根据实际情况进行调整和优化。同时,也建议进行更详细的线程调试和日志分析,以便更准确地定位问题所在。
spring作为开发平台,对数据库的访问支持的很完善,对事务的支持也比较完善,主要分为两种编程式事务与声明式事务,编程式事务能够更细的控制事务回滚与提交的粒度,但是需要在代码中编写,耦合性更高,并且与业务代码混合在一起,这与spring的无入侵的特性相违背,因此一般会采用声明式事务的方式进行事务管理,因为在很多需要操作数据的逻辑都需要进行事务管理,抛开不同开发者写的代码差异性不谈,单从重复性的代码导致系统难以维护,内聚性很差这点就足以证明这种方式不可取,但是这不正是AOP的使用场景吗,因此使用切面编程技术生成动态代理完成事务管理,业务代码更加的单纯,。主要分为三大部分,数据源,事务管理器,代理机制。其中处于核心地位的接口为PlatformTransactionManager,它的定义比较简单:
// 获取spring配置的事务传播机制
TransactionStatus getTransaction(TransactionDefinition definition) throws TransactionException;
// 提交
void commit(TransactionStatus status) throws TransactionException;
// 或则回滚
void rollback(TransactionStatus status) throws TransactionException;
getTransaction定义了事务的开启范围,这个可以在网上找到更详细的讲解,这里不累述。commit与rollback就是基本的事务控制机制。
AbstractPlatformTransactionManager实现了该接口,这个抽象类定义一些基本的对事务支持的操作,但是抽象了不同的orm框架对事务管理的细节。jdbc是通过dataSource Connection来进行事务管理,而hibernate是通过SqlSession进行事务管理。使用jdbc进行事务管理,就注入DataSourceTransactionManager即可。并制定对应的数据源,然后只需要制定需要代理的类即可。
数据源的配置:
<bean id="driver" class="com.mysql.jdbc.Driver"></bean>
<bean id="dataSource"
class="org.springframework.jdbc.datasource.SimpleDriverDataSource">
<property name="username" value="${username}" />
<property name="password" value="${password}" />
<property name="url" value="${url}" />
<property name="driver" ref="driver" />
</bean>
首先我们制定事务管理器:
<bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource" ref="dataSource" />
</bean>
然后我们定义需要事务传播机制,我们来创建一个常见,也可以采用其他的方式创建advice,这里的advice通知就是定义了需要做什么,这里就是 如果有事务加入事务,没有的话创建新事务:
<tx:advice id="advice" transaction-manager="transactionManager">
<tx:attributes>
<!-- 如果有事务加入事务,没有的话创建新事务 -->
<tx:method name="*" propagation="REQUIRED" timeout="3"/>
</tx:attributes>
</tx:advice>
做什么定义完了,就需要指定在哪里做(pointcut)即切点,代理的方式有很多种,可以使用默认的代理DefaultAdvisorAutoProxyCreator。也可以使用注解的方式,很灵活,主要就是明白定义好切点(一般spring支持较好的是方法级别的拦截)即可。这里我们使用比较方便的schema标签支持aop代理:
<aop:config>
<aop:pointcut id="txPointcut" expression="execution(* micro.test.spring.service..*Service*.*(..))" />
<aop:advisor advice-ref="advice" pointcut-ref="txPointcut"/>
</aop:config>
很多时候我们引入新的xml标签会报错,这个时候检查xml文件头是否引入了相关的schema即可,这点是初学者很容易犯错的地方。
<!-- 扫描到advice -->
<context:component-scan base-package="micro.test.spring" />
<!-- mybatis中sqlSession由这个类来完成 -->
<bean id="sqlSessionFactoryBean" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
<property name="mapperLocations">
<list>
<value>classpath:/sqlmap/ComputerMapper.xml</value>
</list>
</property>
</bean>
其他必要的配置跟本文不太相关就不贴了。
前面execution(* micro.test.spring.service…Service.*(…))就表示在这个包下面的所有方法都是切点,会被代理。这是service的一个方法,执行它:
public void testTransaction2() {
Computer computer = new Computer();
computer.setBrand("test");
computerMapper.insert(computer);//事务会回滚
ComputerExample computerExample = new ComputerExample();
computerExample.or().andBrandEqualTo(computer.getBrand());
int a = 1;
a /= 0; // 制造异常
computerMapper.insert(computer);
}
如果没事务管理机制,会插入一条记录。引入了事务管理机制,抛出异常(spring要求运行时异常)就不会插入记录,因为遇到了异常,当然如果去掉异常,两条都会插入进去,值得一提的是我数据库设置的默认应该不是读未提交,因为在调试到还没抛异常的时候也不会看到一条记录,如果是读未提交应该是看到出现了一条记录然后又消失了(被回滚),测试了几次,要么有0条,要么有两条(正常执行)。没有造成脏读的情况至少应该是读已提交。查询了一下:
果然可重复读,是能够不免脏读和不可重复度两个问题的。但是无法避免幻读。
看了此篇文章是不是感觉收获蛮大