抢红包高并发demo2

开发控制器和超发现象测试

 首先完成的是抢红包控制器

//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包,下面是链接

jackson

在运行起来后,进行请求

请求路径: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次抢红包后,就判定请求失效,下面讨论如何重入。

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容