开发控制器和超发现象测试
首先完成的是抢红包控制器
//UserRedPacketController.java
package com.ssm.wdz.controller;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import com.ssm.wdz.service.UserRedPacketService;
@Controller
@RequestMapping("/userRedPacket")
public class UserRedPacketController {
@Autowired
private UserRedPacketService userRedPacketService=null;
@RequestMapping(value="/grapRedPacket")
@ResponseBody
public Map grapRedPacket(Long redPacketId,Long userId){
//抢红包
int result=userRedPacketService.grapRedPacket(redPacketId, userId);
Map retMap=new HashMap();
boolean flag=result>0;
retMap.put("success", flag);
retMap.put("message", flag?"抢红包成功":"抢红包失败");
return retMap;
}
}
这样就完成了控制器的开发,对于控制器而言,它将抢夺一个红包,并且将一个Map返回,由于使用了注解@ResponseBody标注了方法,所以它最后会变成一个JSON返回前端,编写JSP对其进行测试,如下:
//test.jsp
< %@ page language="java" contentType="text/html; charset=UTF-8"
pageEncoding="UTF-8"%>
< !DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
< !-- 加载Query文件 -->
< /script>
$(document).ready(function (){
//模拟30000个异步请求,进行并发
var max=30000;
for(var i=1;i<=max;i++){
//jQuery的post请求,请注意这是异步请求
$.post({
//请求抢红包id为1的红包
url:
"./userRedPacket/grapRedPacket.do?redPacketId=1&userId"+i,
//成功后的方法
success:function(result){
}
});
}
});
< /script>
< /head>
< /body>
< /html>
这里我们使用了javascript去模拟3万人同时抢红包的场景,在实际的测试中,使用FireFox浏览器测试。javascript的post请求是一个异步请求,所以这是一个高并发的场景,它将抢夺一个id为1的红包,依据之前的sql插入,这是一个20万元的红包,一共有2万个,那么这样的场景会有什么问题发生呢?要注意两个点:一个是数据的一致性,另一个就是性能问题。
在项目放到tomcat运行,报出了错误,如下:
org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userRedPacketController': Injection of autowired dependencies failed; nested exception is org.springframework.beans.factory.BeanCreationException: Could not autowire field: private com.ssm.wdz.service.UserRedPacketService com.ssm.wdz.controller.UserRedPacketController.userRedPacketService; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userRedPacketServiceImpl': Injection of autowired dependencies failed; nested exception is org.springframework.beans.factory.BeanCreationException: Could not autowire field: private com.ssm.wdz.dao.UserRedPacketDao com.ssm.wdz.service.impl.UserRedPacketServiceImpl.userRedPacketDao; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type [com.ssm.wdz.dao.UserRedPacketDao] found for dependency: expected at least 1 bean which qualifies as autowire candidate for this dependency. Dependency annotations: {@org.springframework.beans.factory.annotation.Autowired(required=true)}
Related cause: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'redPacketDao' defined in file [D:\PowerNet\apache-tomcat-7.0.85\webapps\redpacket\WEB-INF\classes\com\ssm\wdz\dao\RedPacketDao.class]: Cannot resolve reference to bean 'sqlSessionFactory' while setting bean property 'sqlSessionFactory'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'sqlSessionFactory' defined in class com.ssm.wdz.config.RootConfig: Invocation of init method failed; nested exception is org.apache.ibatis.builder.BuilderException: Error creating document instance.Cause: org.xml.sax.SAXParseException; lineNumber: 5; columnNumber: 16; 文档根元素 "configuration" 必须匹配 DOCTYPE 根 "mapper"。
Related cause: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userRedPacketDao' defined in file [D:\PowerNet\apache-tomcat-7.0.85\webapps\redpacket\WEB-INF\classes\com\ssm\wdz\dao\UserRedPacketDao.class]: Cannot resolve reference to bean 'sqlSessionFactory' while setting bean property 'sqlSessionFactory'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'sqlSessionFactory' defined in class com.ssm.wdz.config.RootConfig: Invocation of init method failed; nested exception is org.apache.ibatis.builder.BuilderException: Error creating document instance.Cause: org.xml.sax.SAXParseException; lineNumber: 5; columnNumber: 16; 文档根元素 "configuration" 必须匹配 DOCTYPE 根 "mapper"。
Related cause: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'redPacketDao' defined in file [D:\PowerNet\apache-tomcat-7.0.85\webapps\redpacket\WEB-INF\classes\com\ssm\wdz\dao\RedPacketDao.class]: Cannot resolve reference to bean 'sqlSessionFactory' while setting bean property 'sqlSessionFactory'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'sqlSessionFactory' defined in class com.ssm.wdz.config.RootConfig: Invocation of init method failed; nested exception is org.apache.ibatis.builder.BuilderException: Error creating document instance.Cause: org.xml.sax.SAXParseException; lineNumber: 5; columnNumber: 16; 文档根元素 "configuration" 必须匹配 DOCTYPE 根 "mapper"。
Related cause: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'userRedPacketDao' defined in file [D:\PowerNet\apache-tomcat-7.0.85\webapps\redpacket\WEB-INF\classes\com\ssm\wdz\dao\UserRedPacketDao.class]: Cannot resolve reference to bean 'sqlSessionFactory' while setting bean property 'sqlSessionFactory'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'sqlSessionFactory' defined in class com.ssm.wdz.config.RootConfig: Invocation of init method failed; nested exception is org.apache.ibatis.builder.BuilderException: Error creating document instance.Cause: org.xml.sax.SAXParseException; lineNumber: 5; columnNumber: 16; 文档根元素 "configuration" 必须匹配 DOCTYPE 根 "mapper"。
第一句写的大概就是自动注入失败,在网上找了好多,有说注解加错的,也有说要配置文件的。最后发现原来是,xml文档的头文件错了,这个还有区别。。。
在配置mybatis-config.xml的时候直接把mapper的xml头给拿过来了。。。下面贴出两个头:
//mapper.xml
< ?xml version="1.0" encoding="UTF-8"?>
< !DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
//mybatis-config.xml
< ?xml version="1.0" encoding="UTF-8" ?>
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
< configuration>
< mappers>
< mapper resource="com/ssm/wdz/mapper/UserRedPacket.xml"/>
< mapper resource="com/ssm/wdz/mapper/RedPacket.xml"/>
< /mappers>
< /configuration>
超发现象测试
直接贴出bug
Error creating bean with name 'requestMappingHandlerAdapter' defined in class path resource [com/ssm/wdz/config/WebConfig.class]: Instantiation of bean failed; nested exception is org.springframework.beans.factory.BeanDefinitionStoreException: Factory method [public org.springframework.web.servlet.HandlerAdapter com.ssm.wdz.config.WebConfig.initRequestMappingHandlerAdapter()] threw exception; nested exception is java.lang.NoClassDefFoundError: com/fasterxml/jackson/core/JsonProcessingException
这里大约说的是在创建RequestMappingHandlerAdapter的Bean的时候失败,请注意这个ava.lang.NoClassDefFoundError: com/fasterxml/jackson/core/JsonProcessingException,
这个是指没有jar包或者是jar版本冲突,在网上找到了关于jackson的三个jar包,下面是链接
在运行起来后,进行请求
请求路径:http://localhost:8080/redpacket/userRedPacket/grapRedPacket.do?redPacketId=1&userId=1
报了一个错误
Cannot create JDBC driver of class 'com.mysql.jdbc.Driver' for connect URL 'jdbc://localhost:3306/redpacket'
这里说是不能创建jdbc驱动,仔细看了下连接数据库出的代码及RootConfig.java
发现 props.setProperty("url", "jdbc://localhost:3306/redpacket");
这一句漏了个mysql:
正确的如下:
props.setProperty("url", "jdbc:mysql://localhost:3306/redpacket");
重新进行测试
编写的jsp文件没有进行配置,我们在web.xml中加入如下代码:
< welcome-file-list>
< welcome-file>/WEB-INF/jsp/test.jsp
< /welcome-file-list>
访问路径为:http://localhost:8080/redpacket/
由于电脑响应问题,最后把红包改为5000个,
最后控制台输出:
抢红包程序运行时间:150886ms,也就是150秒抢了5000个包。
但是刷新数据库表发现,在t_red_packet表中,stock减到了-1,也就是多发了一个红包,这就是高并发的超发现象,这是一个错误的逻辑。如果是较好的设备运行高并发可能会多发更多,现在看一下性能:
抢红包程序运行时间:521687ms,也就是抢20000个红包用了521秒,。。。该换电脑了啊。
超发现象是由多线程下数据不一致造成的,对于这类问题,当前互联网采用悲观锁和乐观锁来解决。
设计进阶
对于之前的测试,产生了超发现象,超发现象是多线程数据不一致造成的,对于这种情况,目前只要采用悲观锁和乐观锁来处理。
悲观锁
悲观锁是一种利用数据库内部机制提供锁的方法,也就是对更新的数据加锁。这样在并发期间一旦有一个事务持有了数据库记录的锁,其他的线程就不能在对数据库进行更新了,这就是悲观锁。
首先在RedPacket.xml中修改代码:
< !-- 悲观锁实现查询红包具体信息 -->
< select id="getRedPacketForUpdate" parameterType="long" resultType="com.ssm.wdz.pojo.RedPacket">
select id,user_id as userId,amount,send_date as sendDate,total,unit_amount as unitAmount,stock,version,note
from t_red_packet where id={id} for update
< /select>
注意在sql中加入的 for update语句,意味着将持有对数据库记录的行更新锁(因为这里使用的是对主键查询,所以只会对行加锁,如果使用的是非主键查询,要考虑是否要对全表加锁的问题,加锁后可能会引发其他查询的阻塞),这就意味着在高并发的场景下,当一条事务持有了这个更新锁才能往下操作,其他的线程如果更新这条记录,都需要等待,这样就可以解决超发现象引发的数据一致性问题了。
同时在RedPacketDao中加入对应的查询方法。
/ **
*使用for update语句加锁
* @param id 红包id
* @return 红包信息
* /
public RedPacket getRedPacketForUpdate(Long id);
接下来将UserRedPacketImpl代码中加入
// 使用悲观锁方式获取红包信息
RedPacket redPacket=redPacketDao.getRedPacketForUpdate(redPacketId);
再次进行测试:
发现数据库中的数据已经不会减到0以下了,也就是说不会发生多发的现象。结果是正确的这点让人很欣喜,
这是之前运行的时间:
抢红包程序运行时间:369231ms
加了悲观锁之后的运行时间:
抢红包程序运行时间:364252ms
下面是数据库的区别:
很明显可以看出来,我的电脑进行测试这两种性能好像差不多,但是加了悲观锁之后,数据库的多发现象明显解决了,但是这个速度还是有点慢。现在可能是对数据库只是加了一个锁,当加的锁比较多的时候,数据库的性能还会持续下降,下面讨论一下性能下降的原因。
对于悲观锁来说,当一条线程抢占了资源后,其他的线程将得不到资源,那么这个时候,CPU就会将这些得不到资源的线程挂起,挂起的资源会消耗CPU的资源,尤其是在高并发的请求中。
由于频繁挂起,等待持有锁线程释放资源,一旦释放资源后就开始抢夺,恢复线程,周而复始的将所有红包的资源抢完。在高并发的情况下,使用悲观锁就会造成大量的线程被挂起恢复,这将十分消耗资源,这就是为什么不怎么用悲观锁的原因。有些时候,我们也会把悲观锁称为独占锁,毕竟只有一个线程可以独占这个资源,或者称为阻塞锁,因为它会造成其他线程的阻塞。为了克服这个问题,程序大师们提出了乐观锁机制。
乐观锁
乐观锁是一种不会阻塞其他线程并发的机制,它不会使用数据库的锁实现它的设计里面由于不会阻塞其他线程,所以不会引发频繁的挂起和恢复,这样便能够提高并发性能,所以也称为非阻塞锁,乐观锁使用的是CAS原理。
CAS原理
在CAS原理中,对于多个线程共同的资源,先保存一个旧值,需要操作数据的时候,先比较数据库当前的值和旧值是否一致,如果一直则进行操作数据的操作,否则就认为已经修改过了,不在进行操作。CAS原理并不排斥高并发同时也不独占资源,只是在线程开始阶段读入线程共享数据,保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次比较。一致则更新,不一致则认为被其他线程更改过了,不再进行更新数据,可以考虑重试或者放弃。如果重试那么这就是一个重入锁,但是CAS会有一个问题,那就是ABA问题。
ABA
ABA问题主要是指,假设有两个线程,线程1先读入x=a,紧接着线程2读入x=a,在线程1处理业务逻辑的同时,线程2进行x=b操作,在处理自己的业务逻辑,紧接着又x=a,在下一个时间段线程1执行完业务逻辑进行判断,发现x=a,所以更新数据。由于业务逻辑存在回退的可能性,所以在线程2的值得改变为a-b-a,这就是ABA问题。
ABA问题的发生,是因为业务逻辑存在回退的可能性。如果加入一个非业务的属性,比如在数据库中加入版本号(version),对于版本号有一个约定,就是只要修改x的值,强制版本号只能递增,而不会回退,即便是其他业务回退,它也会递增,这个问题就解决了。
接下来,就通过乐观锁来完成抢红包业务。
乐观锁实现抢红包业务
首先在RedPacket.xml中加入如下代码:
< !-- 通过版本号扣减抢红包
每进行一次更新,版本增1
其次增加对版本号的判断
-- >
< update id="decreaseRedPacketForVersion">
update t_red_packet
set stock=stock-1,
version=version+1
where id=#{id}
and version=#{version}
< /update>
注意红色的代码,在扣减红包的时候,增加了对版本号的判断,每次扣减都会使版本号加1,以避免ABA问题,对于查询也不使用for update语句,避免锁的产生,这就没有线程阻塞的问题了,这里在对应的UserRedPackerDao接口上加入对应方法,然后就可以在类UserRedPacketServiceImpl中新增方法grapRedPacketForVersion(需要在UserRedPacketService加上相同的方法),完成对应的逻辑即可,代码如下:
//RedPacketDao.java
/ **
* 使用乐观锁实现扣减红包数
* @param id 红包id
* @param version 扣减版本
* @return 更新记录条数
* /
public int decreaseRedPacketForVersion(Long id,Integer version);
//UserRedPacketService.java
/ **
* 使用乐观锁保存红包信息
* @param redPacketId 红包编号
* @param userId 抢红包用户编号
* @return 影像记录条数
* /
public int grapRedPacketForVersion(Long redPacketId,Long userId);
//UserRedPacketServiceImpl.java
@Override
@Transactional(isolation=Isolation.READ_COMMITTED,propagation=Propagation.REQUIRED)
public int grapRedPacketForVersion(Long redPacketId,Long userId) {
long startTime = System.currentTimeMillis();
//获取红包信息,注意version值
RedPacket redPacket=redPacketDao.getRedPacket(redPacketId);
//当前小红包库存大于0
if(redPacket.getStock()>0) {
//再次传入线程保存的version旧值给sql判断,是否有其他线程更改过数据
int update=redPacketDao.decreaseRedPacketForVersion(redPacketId, redPacket.getVersion());
//如果没有数据更新,则说明其他线程已经修改过数据,本次抢红包失败
if(update==0) {
return FAILED;
}
//生成抢红包信息
UserRedPacket userRedPacket=new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote("redPacketId:"+redPacketId);
//插入抢红包信息
int result=userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}else {
long endTime = System.currentTimeMillis();
//输出程序运行时间
System.out.println("抢红包程序运行时间:" + (endTime - startTime) + "ms");
//失败返回
return FAILED;
}
}
version值一开始就保存到了对象中,当扣减的时候,再次传递给sql,让sql对数据库的version和当前线程的旧值version进行比较。如果一直则插入抢红包数据,否则不进行操作。为了进行测试,在控制器UserRedPacketController内新建方法,代码如下:
@RequestMapping(value="/grapRedPacketForVersion")
@ResponseBody
public Map< String,Object > grapRedPakcetForVersion(Long redPacketId,Long userId){
//抢红包
int result=userRedPacketService.grapRedPacketForVersion(redPacketId, userId);
Map< String,Object > retMap=new HashMap();
boolean flag=result>0;
retMap.put("success", flag);
retMap.put("message", flag?"SUCCESS!!!":"FAILED");
return retMap;
}
将test.jsp的请求更改
" . / userRedPacket/grapRedPacketForVersion.do?redPacketId=3&userId="+i,
进行测试,报错:
严重: Servlet.service() for servlet [dispatcher] in context with path [/redpacket] threw exception [Request processing failed; nested exception is org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.binding.BindingException: Parameter 'id' not found. Available parameters are [0, 1, param1, param2]] with root cause
org.apache.ibatis.binding.BindingException: Parameter 'id' not found. Available parameters are [0, 1, param1, param2]
大概就说传入的数据没找到,果断看了一眼含有参数version的RedPacketDaojava,果然是mybatis传入多个参数的时候不会自动映射,一个参数好像可以,代码更改如下,加入注解@Param
public int decreaseRedPacketForVersion(@Param("id") Long id,@Param("version") Integer version);
但是同样也产生了一个问题,在这里程序就不运行了
从图中可以看到,还剩509个红包没有抢完,但是程序已经停止了。这是红包因为版本高并发的原因没有抢到,而且概率还相当高,接下来解决这个问题。
为了克服这个问题,还会考虑使用重入机制。也就是一旦因为版本原因没有抢到红包,则重新尝试抢红包,但是过多的重入,会导致大量的sql运行,所以目前重入机制会加入两种限制,一种是按照时间戳的重入,也就是在一定时间戳内,不成功会循环到成功为止,直到超过时间戳,不成功才会退出,返回失败。另外一种是按次数,比如限定3次,程序尝试3次抢红包后,就判定请求失效,下面讨论如何重入。