点击上方[全栈开发者社区]→右上角[...]→[设为星标⭐]
♪ 点击上方绿标 收听抢红包案例
概述
电商的秒杀、抢购,春运抢票,微信QQ抢红包,从技术的角度来说,这对于Web 系统是一个很大的考验. 高并发场景下,系统的优化和稳定是至关重要的.
互联网的开发包括 Java 后台、 NoSQL、数据库、限流、CDN、负载均衡等内容, 目前并没有权威性的技术和设计,有的只是长期经验的总结,但是使用这些经验可以有效优化系统,提高系统的并发能力.
我们接下来的几篇博文主要讨论 Java 后台、 NoSQL ( Redis )和数据库部分技术.
抢红包案例
主要分以下几大部分:
环境搭建
模拟超量发送的场景-DataBase(MySql5.7)
悲观锁的实现版本-DataBase(MySql5.7)
乐观锁的实现版本-DataBase(MySql5.7)
Redis实现抢红包
案例关注点
模拟 20 万元的红包,共分为 2 万个可抢的小红包,有 3 万人同时抢夺的场景 ,模拟出现超发和如何保证数据一致性的问题。
在高并发的场景下,除了数据的一致性外,还要关注性能的问题 , 因为一般而言 , 时间太长用户体验就会很差,所以要测试数据一致性和系统的性能 。
工程结构
库表设计
MySql5.7
/*==============================================================*//* Table: 红包表 *//*==============================================================*/create table T_RED_PACKET( id int(12) not null auto_increment COMMENT '红包编号', user_id int(12) not null COMMENT '发红包的用户id', amount decimal(16,2) not null COMMENT '红包金额', send_date timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '发红包日期', total int(12) not null COMMENT '红包总数', unit_amount decimal(12) not null COMMENT '单个红包的金额', stock int(12) not null COMMENT '红包剩余个数', version int(12) default 0 not null COMMENT '版本(为后续扩展用)', note varchar(256) null COMMENT '备注',, primary key clustered (id));
/* Table: 红包表 */
/*==============================================================*/
create table T_RED_PACKET
(
id int(12) not null auto_increment COMMENT '红包编号',
user_id int(12) not null COMMENT '发红包的用户id',
amount decimal(16,2) not null COMMENT '红包金额',
send_date timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '发红包日期',
total int(12) not null COMMENT '红包总数',
unit_amount decimal(12) not null COMMENT '单个红包的金额',
stock int(12) not null COMMENT '红包剩余个数',
version int(12) default 0 not null COMMENT '版本(为后续扩展用)',
note varchar(256) null COMMENT '备注',,
primary key clustered (id)
);
红包表表示存放红包的是一个大红包的信息,它会分为若干个小红包,为了业务简单,假设每一个红包是等额的。而对于抢红包而言,就是从大红包中抢夺那些剩余的小红包,剩余红包数会被记录在红包表中。两个表有外键关联 T_RED_PACKET.id = T_USER_RED_PACKET.red_packet_id
/*==============================================================*//* Table: 用户抢红包表 *//*==============================================================*/create table T_USER_RED_PACKET( id int(12) not null auto_increment COMMENT '用户抢到的红包id', red_packet_id int(12) not null COMMENT '红包id', user_id int(12) not null COMMENT '抢红包用户的id', amount decimal(16,2) not null COMMENT '抢到的红包金额', grab_time timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '抢红包时间', note varchar(256) null COMMENT '备注', primary key clustered (id));
/* Table: 用户抢红包表 */
/*==============================================================*/
create table T_USER_RED_PACKET
(
id int(12) not null auto_increment COMMENT '用户抢到的红包id',
red_packet_id int(12) not null COMMENT '红包id',
user_id int(12) not null COMMENT '抢红包用户的id',
amount decimal(16,2) not null COMMENT '抢到的红包金额',
grab_time timestamp not null DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '抢红包时间',
note varchar(256) null COMMENT '备注',
primary key clustered (id)
);
/*** 插入一个20万元金额,2万个小红包,每个10元的红包数据*/insert into T_RED_PACKET(user_id, amount, send_date, total, unit_amount, stock, note) values(1, 200000.00, now(), 20000, 10.00, 20000,'20万元金额,2万个小红包,每个10元');commit;
insert into T_RED_PACKET(user_id, amount, send_date, total, unit_amount, stock, note)
values(1, 200000.00, now(), 20000, 10.00, 20000,'20万元金额,2万个小红包,每个10元');
commit;
这样就建好了两个表,并且将一个 20 万元金额,2 万个小红包,每个 10 元的红包信息插入到了红包表中,用作模拟数据。
Domain
有了这两个表,我们就可以为这两个表建两个 POJO 了,让这两个表和 POJO 对应起来,这两个 POJO 为 RedPacket 和 UserRedPacket,实现类序列化接口。
红包信息
package com.artisan.redpacket.pojo;import java.io.Serializable;import java.sql.Timestamp;/** * * * @ClassName: RedPacket * * @Description: 红包表对应的实体类,可序列化 * * @author: Mr.Yang * * @date: 2018年10月8日 下午3:42:58 */public class RedPacket implements Serializable { private static final long serialVersionUID = 9036484563091364939L; // 红包编号 private Long id; // 发红包的用户id private Long userId; // 红包金额 private Double amount; // 发红包日期 private Timestamp sendDate; // 红包总数 private Integer total; // 单个红包的金额 private Double unitAmount; // 红包剩余个数 private Integer stock; // 版本(为后续扩展用) private Integer version; // 备注 private String note; // 省略set/get}
import java.io.Serializable;
import java.sql.Timestamp;
/**
*
*
* @ClassName: RedPacket
*
* @Description: 红包表对应的实体类,可序列化
*
* @author: Mr.Yang
*
* @date: 2018年10月8日 下午3:42:58
*/
public class RedPacket implements Serializable {
private static final long serialVersionUID = 9036484563091364939L;
// 红包编号
private Long id;
// 发红包的用户id
private Long userId;
// 红包金额
private Double amount;
// 发红包日期
private Timestamp sendDate;
// 红包总数
private Integer total;
// 单个红包的金额
private Double unitAmount;
// 红包剩余个数
private Integer stock;
// 版本(为后续扩展用)
private Integer version;
// 备注
private String note;
// 省略set/get
}
抢红包信息
package com.artisan.redpacket.pojo;import java.io.Serializable;import java.sql.Timestamp;/** * * * @ClassName: UserRedPacket * * @Description: 用户抢红包表 * * @author: Mr.Yang * * @date: 2018年10月8日 下午3:47:40 */public class UserRedPacket implements Serializable { private static final long serialVersionUID = 7049215937937620886L; // 用户红包id private Long id; // 红包id private Long redPacketId; // 抢红包的用户的id private Long userId; // 抢红包金额 private Double amount; // 抢红包时间 private Timestamp grabTime; // 备注 private String note; // 省略set/get}
import java.io.Serializable;
import java.sql.Timestamp;
/**
*
*
* @ClassName: UserRedPacket
*
* @Description: 用户抢红包表
*
* @author: Mr.Yang
*
* @date: 2018年10月8日 下午3:47:40
*/
public class UserRedPacket implements Serializable {
private static final long serialVersionUID = 7049215937937620886L;
// 用户红包id
private Long id;
// 红包id
private Long redPacketId;
// 抢红包的用户的id
private Long userId;
// 抢红包金额
private Double amount;
// 抢红包时间
private Timestamp grabTime;
// 备注
private String note;
// 省略set/get
}
Dao层实现
MyBatis Dao接口类及对应的Mapper文件
使用 MyBatis 开发,先来完成大红包信息的查询先来定义一个 DAO 对象
package com.artisan.redpacket.dao;import org.springframework.stereotype.Repository;import com.artisan.redpacket.pojo.RedPacket;@Repositorypublic interface RedPacketDao { /** * 获取红包信息. * @param id --红包id * @return 红包具体信息 */ public RedPacket getRedPacket(Long id); /** * 扣减抢红包数. * @param id -- 红包id * @return 更新记录条数 */ public int decreaseRedPacket(Long id); }
import org.springframework.stereotype.Repository;
import com.artisan.redpacket.pojo.RedPacket;
@Repository
public interface RedPacketDao {
/**
* 获取红包信息.
* @param id --红包id
* @return 红包具体信息
*/
public RedPacket getRedPacket(Long id);
/**
* 扣减抢红包数.
* @param id -- 红包id
* @return 更新记录条数
*/
public int decreaseRedPacket(Long id);
}
其中的两个方法 , 一个是查询红包,另一个是扣减红包库存。
抢红包的逻辑是,先查询红包的信息,看其是否拥有存量可以扣减。如果有存量,那么可以扣减它,否则就不扣减。
接着将对应的Mapper映射文件编写一下
<?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"><mapper namespace="com.artisan.redpacket.dao.RedPacketDao"> <!-- 查询红包具体信息 --> <select id="getRedPacket" parameterType="long" resultType="com.artisan.redpacket.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} </select> <!-- 扣减抢红包库存 --> <update id="decreaseRedPacket"> update T_RED_PACKET set stock = stock - 1 where id = #{id} </update></mapper>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.artisan.redpacket.dao.RedPacketDao">
<!-- 查询红包具体信息 -->
<select id="getRedPacket" parameterType="long"
resultType="com.artisan.redpacket.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}
</select>
<!-- 扣减抢红包库存 -->
<update id="decreaseRedPacket">
update T_RED_PACKET set stock = stock - 1 where id =
#{id}
</update>
</mapper>
这里getRedPacket并没有加锁这类动作,目的是为了演示超发红包的情况。
然后是抢红包的设计了 ,先来定义插入抢红包的 DAO ,紧接着是Mapper映射文件
package com.artisan.redpacket.dao;import org.springframework.stereotype.Repository;import com.artisan.redpacket.pojo.UserRedPacket;@Repositorypublic interface UserRedPacketDao { /** * 插入抢红包信息. * @param userRedPacket ——抢红包信息 * @return 影响记录数. */ public int grapRedPacket(UserRedPacket userRedPacket);}com.artisan.redpacket.dao;
import org.springframework.stereotype.Repository;
import com.artisan.redpacket.pojo.UserRedPacket;
@Repository
public interface UserRedPacketDao {
/**
* 插入抢红包信息.
* @param userRedPacket ——抢红包信息
* @return 影响记录数.
*/
public int grapRedPacket(UserRedPacket userRedPacket);
}
<?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"><mapper namespace="com.artisan.redpacket.dao.UserRedPacketDao"> <!-- 插入抢红包信息 --> <insert id="grapRedPacket" useGeneratedKeys="true" keyProperty="id" parameterType="com.artisan.redpacket.pojo.UserRedPacket"> insert into T_USER_RED_PACKET( red_packet_id, user_id, amount, grab_time, note) values (#{redPacketId}, #{userId}, #{amount}, now(), #{note}) </insert></mapper>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.artisan.redpacket.dao.UserRedPacketDao">
<!-- 插入抢红包信息 -->
<insert id="grapRedPacket" useGeneratedKeys="true"
keyProperty="id" parameterType="com.artisan.redpacket.pojo.UserRedPacket">
insert into T_USER_RED_PACKET( red_packet_id, user_id, amount, grab_time, note)
values (#{redPacketId}, #{userId}, #{amount}, now(), #{note})
</insert>
</mapper>
这里使用了 useGeneratedKeys 和 keyPrope,这就意味着会返回数据库生成的主键信息,这样就可以拿到插入记录的主键了 , 关于 DAO 层就基本完成了。
Service层实现
接下来定义两个 Service 层接口,分别是 UserRedPacketService和RedPacketService
package com.artisan.redpacket.service;import com.artisan.redpacket.pojo.RedPacket;public interface RedPacketService { /** * 获取红包 * @param id ——编号 * @return 红包信息 */ public RedPacket getRedPacket(Long id); /** * 扣减红包 * @param id——编号 * @return 影响条数. */ public int decreaseRedPacket(Long id); }
import com.artisan.redpacket.pojo.RedPacket;
public interface RedPacketService {
/**
* 获取红包
* @param id ——编号
* @return 红包信息
*/
public RedPacket getRedPacket(Long id);
/**
* 扣减红包
* @param id——编号
* @return 影响条数.
*/
public int decreaseRedPacket(Long id);
}
package com.artisan.redpacket.service;public interface UserRedPacketService { /** * 保存抢红包信息. * @param redPacketId 红包编号 * @param userId 抢红包用户编号 * @return 影响记录数. */ public int grapRedPacket(Long redPacketId, Long userId); }
public interface UserRedPacketService {
/**
* 保存抢红包信息.
* @param redPacketId 红包编号
* @param userId 抢红包用户编号
* @return 影响记录数.
*/
public int grapRedPacket(Long redPacketId, Long userId);
}
实现类如下:
package com.artisan.redpacket.service.impl;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Isolation;import org.springframework.transaction.annotation.Propagation;import org.springframework.transaction.annotation.Transactional;import com.artisan.redpacket.dao.RedPacketDao;import com.artisan.redpacket.pojo.RedPacket;import com.artisan.redpacket.service.RedPacketService;@Servicepublic class RedPacketServiceImpl implements RedPacketService { @Autowired private RedPacketDao redPacketDao; @Override @Transactional(isolation=Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) public RedPacket getRedPacket(Long id) { return redPacketDao.getRedPacket(id); } @Override @Transactional(isolation=Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) public int decreaseRedPacket(Long id) { return redPacketDao.decreaseRedPacket(id); }}com.artisan.redpacket.service.impl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.artisan.redpacket.dao.RedPacketDao;
import com.artisan.redpacket.pojo.RedPacket;
import com.artisan.redpacket.service.RedPacketService;
@Service
public class RedPacketServiceImpl implements RedPacketService {
@Autowired
private RedPacketDao redPacketDao;
@Override
@Transactional(isolation=Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public RedPacket getRedPacket(Long id) {
return redPacketDao.getRedPacket(id);
}
@Override
@Transactional(isolation=Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int decreaseRedPacket(Long id) {
return redPacketDao.decreaseRedPacket(id);
}
}
配置了事务注解@Transactional , 让程序能够在事务中运行,以保证数据的一致性 , 这里采用的是读/写提交的隔离级别 , 之所以不采用更高的级别, 主要是提高数据库的并发能力,而对于传播行为则采用 Propagation.REQUIRED,这样调用这个方法的时候,如果没有事务则会创建事务, 如果有事务则沿用当前事务。
实现 UserRedPacketService 接口的方法 grapRedPacket,它是核心的接口方法
package com.artisan.redpacket.service.impl;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Isolation;import org.springframework.transaction.annotation.Propagation;import org.springframework.transaction.annotation.Transactional;import com.artisan.redpacket.dao.RedPacketDao;import com.artisan.redpacket.dao.UserRedPacketDao;import com.artisan.redpacket.pojo.RedPacket;import com.artisan.redpacket.pojo.UserRedPacket;import com.artisan.redpacket.service.UserRedPacketService;@Servicepublic class UserRedPacketServiceImpl implements UserRedPacketService { // private Logger logger = // LoggerFactory.getLogger(UserRedPacketServiceImpl.class); @Autowired private UserRedPacketDao userRedPacketDao; @Autowired private RedPacketDao redPacketDao; // 失败 private static final int FAILED = 0; @Override @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED) public int grapRedPacket(Long redPacketId, Long userId) { // 获取红包信息 RedPacket redPacket = redPacketDao.getRedPacket(redPacketId); int leftRedPacket = redPacket.getStock(); // 当前小红包库存大于0 if (leftRedPacket > 0) { redPacketDao.decreaseRedPacket(redPacketId); // logger.info("剩余Stock数量:{}", leftRedPacket); // 生成抢红包信息 UserRedPacket userRedPacket = new UserRedPacket(); userRedPacket.setRedPacketId(redPacketId); userRedPacket.setUserId(userId); userRedPacket.setAmount(redPacket.getUnitAmount()); userRedPacket.setNote("redpacket- " + redPacketId); // 插入抢红包信息 int result = userRedPacketDao.grapRedPacket(userRedPacket); return result; } // logger.info("没有红包啦.....剩余Stock数量:{}", leftRedPacket); // 失败返回 return FAILED; }}
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.artisan.redpacket.dao.RedPacketDao;
import com.artisan.redpacket.dao.UserRedPacketDao;
import com.artisan.redpacket.pojo.RedPacket;
import com.artisan.redpacket.pojo.UserRedPacket;
import com.artisan.redpacket.service.UserRedPacketService;
@Service
public class UserRedPacketServiceImpl implements UserRedPacketService {
// private Logger logger =
// LoggerFactory.getLogger(UserRedPacketServiceImpl.class);
@Autowired
private UserRedPacketDao userRedPacketDao;
@Autowired
private RedPacketDao redPacketDao;
// 失败
private static final int FAILED = 0;
@Override
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
public int grapRedPacket(Long redPacketId, Long userId) {
// 获取红包信息
RedPacket redPacket = redPacketDao.getRedPacket(redPacketId);
int leftRedPacket = redPacket.getStock();
// 当前小红包库存大于0
if (leftRedPacket > 0) {
redPacketDao.decreaseRedPacket(redPacketId);
// logger.info("剩余Stock数量:{}", leftRedPacket);
// 生成抢红包信息
UserRedPacket userRedPacket = new UserRedPacket();
userRedPacket.setRedPacketId(redPacketId);
userRedPacket.setUserId(userId);
userRedPacket.setAmount(redPacket.getUnitAmount());
userRedPacket.setNote("redpacket- " + redPacketId);
// 插入抢红包信息
int result = userRedPacketDao.grapRedPacket(userRedPacket);
return result;
}
// logger.info("没有红包啦.....剩余Stock数量:{}", leftRedPacket);
// 失败返回
return FAILED;
}
}
grapRedPacket 方法的逻辑是首先获取红包信息,如果发现红包库存大于 0,则说明还有红包可抢,抢夺红包并生成抢红包的信息将其保存到数据库中。要注意的是,数据库事务方面的设置,代码中使用注解@Transactional , 说明它会在一个事务中运行,这样就能够保证所有的操作都是在一个事务中完成的。在高并发中会发生超发的现象,后面会看到超发的实际测试。
使用全注解搭建SSM 开发环境
我们这里将使用注解的方式来完成 SSM 开发的环境,可以通过继承 AbstractAnnotationConfigDispatcherServletlnitfal izer 去配置其他内 容,因此首先来配置 WebApplnitialize
package com.artisan.redpacket.config;import javax.servlet.MultipartConfigElement;import javax.servlet.ServletRegistration.Dynamic;import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer { // Spring IoC环境配置 @Override protected Class<?>[] getRootConfigClasses() { // 配置Spring IoC资源 return new Class<?>[] { RootConfig.class }; } // DispatcherServlet环境配置 @Override protected Class<?>[] getServletConfigClasses() { // 加载Java配置类 return new Class<?>[] { WebConfig.class }; } // DispatchServlet拦截请求配置 @Override protected String[] getServletMappings() { return new String[] { "*.do" }; } /** * @param dynamic * Servlet上传文件配置. */ @Override protected void customizeRegistration(Dynamic dynamic) { // 配置上传文件路径 String filepath = "D:/"; // 5MB Long singleMax = (long) (5 * Math.pow(2, 20)); // 10MB Long totalMax = (long) (10 * Math.pow(2, 20)); // 设置上传文件配置 dynamic.setMultipartConfig(new MultipartConfigElement(filepath, singleMax, totalMax, 0)); }}
import javax.servlet.MultipartConfigElement;
import javax.servlet.ServletRegistration.Dynamic;
import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer;
public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
// Spring IoC环境配置
@Override
protected Class<?>[] getRootConfigClasses() {
// 配置Spring IoC资源
return new Class<?>[] { RootConfig.class };
}
// DispatcherServlet环境配置
@Override
protected Class<?>[] getServletConfigClasses() {
// 加载Java配置类
return new Class<?>[] { WebConfig.class };
}
// DispatchServlet拦截请求配置
@Override
protected String[] getServletMappings() {
return new String[] { "*.do" };
}
/**
* @param dynamic
* Servlet上传文件配置.
*/
@Override
protected void customizeRegistration(Dynamic dynamic) {
// 配置上传文件路径
String filepath = "D:/";
// 5MB
Long singleMax = (long) (5 * Math.pow(2, 20));
// 10MB
Long totalMax = (long) (10 * Math.pow(2, 20));
// 设置上传文件配置
dynamic.setMultipartConfig(new MultipartConfigElement(filepath, singleMax, totalMax, 0));
}
}
WebAppInitializer继承了 AbstractAnnotationConfigDispatcherServletlnitializer, 重写了 3 个抽象方法 , 并且覆盖了父类的 customizeRegistration 方法 , 作为上传文件的配置。
getRootConfigClasses 是一个配置 Spring IoC 容器的上下文配置 , 此配置在代码中将会由类 RootConfig 完成
getServletConfigClasses 配置 DispatcherServlet 上下文配置,将会由WebConfig完成
getServletMappings 配置 DispatcherServlet 拦截 内 容 , 这里设置的是拦截所有以 .do 结尾的请求
通过这 3 个方法就可以配置 Web 工程中 的 Spring IoC 资源和 DispatcherServlet 的配置内容 , 首先是配置 Spring IoC 容器,配置类 RootConfig
package com.artisan.redpacket.config;import java.util.Properties;import javax.sql.DataSource;import org.apache.commons.dbcp2.BasicDataSourceFactory;import org.mybatis.spring.SqlSessionFactoryBean;import org.mybatis.spring.mapper.MapperScannerConfigurer;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.ComponentScan;import org.springframework.context.annotation.ComponentScan.Filter;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.FilterType;import org.springframework.core.io.ClassPathResource;import org.springframework.core.io.Resource;import org.springframework.jdbc.datasource.DataSourceTransactionManager;import org.springframework.stereotype.Repository;import org.springframework.stereotype.Service;import org.springframework.transaction.PlatformTransactionManager;import org.springframework.transaction.annotation.EnableTransactionManagement;import org.springframework.transaction.annotation.TransactionManagementConfigurer;@Configuration//定义Spring 扫描的包@ComponentScan(value= "com.*", includeFilters= {@Filter(type = FilterType.ANNOTATION, value ={Service.class})})//使用事务驱动管理器@EnableTransactionManagement//实现接口TransactionManagementConfigurer,这样可以配置注解驱动事务public class RootConfig implements TransactionManagementConfigurer { private DataSource dataSource = null; /** * 配置数据库. * @return 数据连接池 */ @Bean(name = "dataSource") public DataSource initDataSource() { if (dataSource != null) { return dataSource; } try { Properties props = new Properties(); props.load(RootConfig.class.getClassLoader().getResourceAsStream("jdbc.properties")); props.setProperty("driverClassName", props.getProperty("jdbc.driver")); props.setProperty("url", props.getProperty("jdbc.url")); props.setProperty("username", props.getProperty("jdbc.username")); props.setProperty("password", props.getProperty("jdbc.password")); dataSource = BasicDataSourceFactory.createDataSource(props); } catch (Exception e) { e.printStackTrace(); } return dataSource; } /*** * 配置SqlSessionFactoryBean * @return SqlSessionFactoryBean */ @Bean(name="sqlSessionFactory") public SqlSessionFactoryBean initSqlSessionFactory() { SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean(); sqlSessionFactory.setDataSource(initDataSource()); //配置MyBatis配置文件 Resource resource = new ClassPathResource("mybatis/mybatis-config.xml"); sqlSessionFactory.setConfigLocation(resource); return sqlSessionFactory; } /*** * 通过自动扫描,发现MyBatis Mapper接口 * @return Mapper扫描器 */ @Bean public MapperScannerConfigurer initMapperScannerConfigurer() { MapperScannerConfigurer msc = new MapperScannerConfigurer(); msc.setBasePackage("com.*"); msc.setSqlSessionFactoryBeanName("sqlSessionFactory"); msc.setAnnotationClass(Repository.class); return msc; } /** * 实现接口方法,注册注解事务,当@Transactional 使用的时候产生数据库事务 */ @Override @Bean(name="annotationDrivenTransactionManager") public PlatformTransactionManager annotationDrivenTransactionManager() { DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(); transactionManager.setDataSource(initDataSource()); return transactionManager; } }
import java.util.Properties;
import javax.sql.DataSource;
import org.apache.commons.dbcp2.BasicDataSourceFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.mapper.MapperScannerConfigurer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
import org.springframework.transaction.annotation.TransactionManagementConfigurer;
@Configuration
//定义Spring 扫描的包
@ComponentScan(value= "com.*", includeFilters= {@Filter(type = FilterType.ANNOTATION, value ={Service.class})})
//使用事务驱动管理器
@EnableTransactionManagement
//实现接口TransactionManagementConfigurer,这样可以配置注解驱动事务
public class RootConfig implements TransactionManagementConfigurer {
private DataSource dataSource = null;
/**
* 配置数据库.
* @return 数据连接池
*/
@Bean(name = "dataSource")
public DataSource initDataSource() {
if (dataSource != null) {
return dataSource;
}
try {
Properties props = new Properties();
props.load(RootConfig.class.getClassLoader().getResourceAsStream("jdbc.properties"));
props.setProperty("driverClassName", props.getProperty("jdbc.driver"));
props.setProperty("url", props.getProperty("jdbc.url"));
props.setProperty("username", props.getProperty("jdbc.username"));
props.setProperty("password", props.getProperty("jdbc.password"));
dataSource = BasicDataSourceFactory.createDataSource(props);
} catch (Exception e) {
e.printStackTrace();
}
return dataSource;
}
/***
* 配置SqlSessionFactoryBean
* @return SqlSessionFactoryBean
*/
@Bean(name="sqlSessionFactory")
public SqlSessionFactoryBean initSqlSessionFactory() {
SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
sqlSessionFactory.setDataSource(initDataSource());
//配置MyBatis配置文件
Resource resource = new ClassPathResource("mybatis/mybatis-config.xml");
sqlSessionFactory.setConfigLocation(resource);
return sqlSessionFactory;
}
/***
* 通过自动扫描,发现MyBatis Mapper接口
* @return Mapper扫描器
*/
@Bean
public MapperScannerConfigurer initMapperScannerConfigurer() {
MapperScannerConfigurer msc = new MapperScannerConfigurer();
msc.setBasePackage("com.*");
msc.setSqlSessionFactoryBeanName("sqlSessionFactory");
msc.setAnnotationClass(Repository.class);
return msc;
}
/**
* 实现接口方法,注册注解事务,当@Transactional 使用的时候产生数据库事务
*/
@Override
@Bean(name="annotationDrivenTransactionManager")
public PlatformTransactionManager annotationDrivenTransactionManager() {
DataSourceTransactionManager transactionManager =
new DataSourceTransactionManager();
transactionManager.setDataSource(initDataSource());
return transactionManager;
}
}
这个类和之前论述的有所不同 , 它标注了注解@EnableTransactionManagement
, 实现了接口 TransactionManagementConfigurer
, 这样的配置是为了实现注解式的事务 , 将来可以通过注解@Transactional 配 置数据库事务。
它有一 个方法annotationDrivenTransactionManager
这需要将一个事务管理器返回给它就可以了
除了配置数据库事务外 ,还配置了数据源 SqISessionFactoryBean 和 MyBatis 的扫描类 , 并把 MyBatis的扫描类通过注解@Repository
和包名"com.*"
限定。这样 MyBatis 就会通过 Spring 的机制找到对应的接 口和配置 , Spring 会自动把对应的接口装配到 IoC 容器中 。
有了 Spring IoC 容器后 , 还需要配置 DispatcherServlet 上下文
package com.artisan.redpacket.config;import java.util.ArrayList;import java.util.List;import java.util.concurrent.Executor;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.ComponentScan;import org.springframework.context.annotation.ComponentScan.Filter;import org.springframework.context.annotation.Configuration;import org.springframework.context.annotation.FilterType;import org.springframework.http.MediaType;import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;import org.springframework.scheduling.annotation.AsyncConfigurerSupport;import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;import org.springframework.stereotype.Controller;import org.springframework.web.servlet.HandlerAdapter;import org.springframework.web.servlet.ViewResolver;import org.springframework.web.servlet.config.annotation.EnableWebMvc;import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;import org.springframework.web.servlet.view.InternalResourceViewResolver;@Configuration//定义Spring MVC扫描的包@ComponentScan(value="com.*", includeFilters= {@Filter(type = FilterType.ANNOTATION, value = Controller.class)})//启动Spring MVC配置@EnableWebMvcpublic class WebConfig{ /*** * 通过注解 @Bean 初始化视图解析器 * @return ViewResolver 视图解析器 */ @Bean(name="internalResourceViewResolver") public ViewResolver initViewResolver() { InternalResourceViewResolver viewResolver =new InternalResourceViewResolver(); viewResolver.setPrefix("/WEB-INF/jsp/"); viewResolver.setSuffix(".jsp"); return viewResolver; } /** * 初始化RequestMappingHandlerAdapter,并加载Http的Json转换器 * @return RequestMappingHandlerAdapter 对象 */ @Bean(name="requestMappingHandlerAdapter") public HandlerAdapter initRequestMappingHandlerAdapter() { //创建RequestMappingHandlerAdapter适配器 RequestMappingHandlerAdapter rmhd = new RequestMappingHandlerAdapter(); //HTTP JSON转换器 MappingJackson2HttpMessageConverter jsonConverter = new MappingJackson2HttpMessageConverter(); //MappingJackson2HttpMessageConverter接收JSON类型消息的转换 MediaType mediaType = MediaType.APPLICATION_JSON_UTF8; List<MediaType> mediaTypes = new ArrayList<MediaType>(); mediaTypes.add(mediaType); //加入转换器的支持类型 jsonConverter.setSupportedMediaTypes(mediaTypes); //往适配器加入json转换器 rmhd.getMessageConverters().add(jsonConverter); return rmhd; }}
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.ComponentScan.Filter;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.scheduling.annotation.AsyncConfigurerSupport;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Controller;
import org.springframework.web.servlet.HandlerAdapter;
import org.springframework.web.servlet.ViewResolver;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.servlet.view.InternalResourceViewResolver;
@Configuration
//定义Spring MVC扫描的包
@ComponentScan(value="com.*", includeFilters= {@Filter(type = FilterType.ANNOTATION, value = Controller.class)})
//启动Spring MVC配置
@EnableWebMvc
public class WebConfig{
/***
* 通过注解 @Bean 初始化视图解析器
* @return ViewResolver 视图解析器
*/
@Bean(name="internalResourceViewResolver")
public ViewResolver initViewResolver() {
InternalResourceViewResolver viewResolver =new InternalResourceViewResolver();
viewResolver.setPrefix("/WEB-INF/jsp/");
viewResolver.setSuffix(".jsp");
return viewResolver;
}
/**
* 初始化RequestMappingHandlerAdapter,并加载Http的Json转换器
* @return RequestMappingHandlerAdapter 对象
*/
@Bean(name="requestMappingHandlerAdapter")
public HandlerAdapter initRequestMappingHandlerAdapter() {
//创建RequestMappingHandlerAdapter适配器
RequestMappingHandlerAdapter rmhd = new RequestMappingHandlerAdapter();
//HTTP JSON转换器
MappingJackson2HttpMessageConverter jsonConverter
= new MappingJackson2HttpMessageConverter();
//MappingJackson2HttpMessageConverter接收JSON类型消息的转换
MediaType mediaType = MediaType.APPLICATION_JSON_UTF8;
List<MediaType> mediaTypes = new ArrayList<MediaType>();
mediaTypes.add(mediaType);
//加入转换器的支持类型
jsonConverter.setSupportedMediaTypes(mediaTypes);
//往适配器加入json转换器
rmhd.getMessageConverters().add(jsonConverter);
return rmhd;
}
}
这里配置了一个视图解析器 , 通过它找到对应 JSP 文件,然后使用数据模型进行渲染,采用自定义 创 建 RequestMappingHandlerAdapter
, 为了让它能够支持 JSON 格式(@ResponseBody ) 的转换,所以需要创建一个关于对象和 JSON 的转换消息类MappingJackson2HttpMessageConverter
创建它之后,把它注册给 RequestMappingHandlerAdapter
对象 , 这样当控制器遇到注解@ResponseBody
的时候就知道采用 JSON 消息类型进行应答 , 那么在控制器完成逻辑后 , 由处理器将其和消息转换类型做匹配,找到MappingJackson2HttpMessageConverter
类对象,从而转变为 JSON 数据。
通过上面的 3 个类就搭建好了 Spring MVC 和 Spring 的开发环境,但是没有完成对MyBatis 配置文件. 从RootConfig#initSqlSessionFactory()
方法中看到加载的MyBatis 的配置文件为"mybatis/mybatis-config.xml"
,该配置文件主要是加载mapper映射文件
<?xml version="1.0" encoding="UTF-8" ?><!DOCTYPE configuration PUBLIC "-//mybatis.org//DTD Config 3.0//EN" "http://mybatis.org/dtd/mybatis-3-config.dtd"><configuration> <mappers> <mapper resource="mapper/UserRedPacket.xml"/> <mapper resource="mapper/RedPacket.xml"/> </mappers></configuration>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<mappers>
<mapper resource="mapper/UserRedPacket.xml"/>
<mapper resource="mapper/RedPacket.xml"/>
</mappers>
</configuration>
记得进行Service层的单元测试, 关于后台的逻辑就已经完成 , 接下来继续将Controller层实现,进行页面测试吧。
Controller层
package com.artisan.redpacket.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.artisan.redpacket.service.UserRedPacketService;@Controller@RequestMapping("/userRedPacket")public class UserRedPacketController { @Autowired private UserRedPacketService userRedPacketService; @RequestMapping(value = "/grapRedPacket") @ResponseBody public Map<String, Object> grapRedPacket(Long redPacketId, Long userId) { // 抢红包 int result = userRedPacketService.grapRedPacket(redPacketId, userId); Map<String, Object> retMap = new HashMap<String, Object>(); boolean flag = result > 0; retMap.put("success", flag); retMap.put("message", flag ? "抢红包成功" : "抢红包失败"); return retMap; } }
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.artisan.redpacket.service.UserRedPacketService;
@Controller
@RequestMapping("/userRedPacket")
public class UserRedPacketController {
@Autowired
private UserRedPacketService userRedPacketService;
@RequestMapping(value = "/grapRedPacket")
@ResponseBody
public Map<String, Object> grapRedPacket(Long redPacketId, Long userId) {
// 抢红包
int result = userRedPacketService.grapRedPacket(redPacketId, userId);
Map<String, Object> retMap = new HashMap<String, Object>();
boolean flag = result > 0;
retMap.put("success", flag);
retMap.put("message", flag ? "抢红包成功" : "抢红包失败");
return retMap;
}
}
对于控制器而言 , 它将抢夺一个红包 , 并且将一个 Map返回,由于使用了注解@ResponseBody 标注方法,所以最后它会转变为一个 JSON 返回给前端请求,编写 JSP 对其进行测试
View层
grap.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"><html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"> <title>参数</title> <!-- 加载Query文件--> <script type="text/javascript" src="https://code.jquery.com/jquery-3.2.0.js"> </script> <script type="text/javascript"> $(document).ready(function () { //模拟30000个异步请求,进行并发 var max = 30000; for (var i = 1; i <= max; i++) { //jQuery的post请求,请注意这是异步请求 $.post({ //请求抢id为1的红包 //根据自己请求修改对应的url和大红包编号 url: "./userRedPacket/grapRedPacket.do?redPacketId=1&userId=" + i, //成功后的方法 success: function (result) { } }); } }); </script> </head> <body> </body></html>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>参数</title>
<!-- 加载Query文件-->
<script type="text/javascript" src="https://code.jquery.com/jquery-3.2.0.js">
</script>
<script type="text/javascript">
$(document).ready(function () {
//模拟30000个异步请求,进行并发
var max = 30000;
for (var i = 1; i <= max; i++) {
//jQuery的post请求,请注意这是异步请求
$.post({
//请求抢id为1的红包
//根据自己请求修改对应的url和大红包编号
url: "./userRedPacket/grapRedPacket.do?redPacketId=1&userId=" + i,
//成功后的方法
success: function (result) {
}
});
}
});
</script>
</head>
<body>
</body>
</html>
这里我们使用了 JavaScript 去模拟 3 万人同时抢红包的场景。
JavaScript 的 post 请求是一个异步请求,所以这是一个高并发的场景,它将抢夺 id 为1的红包 , 依据之前 SQL 的插入 , 这是一个 20 万元的红包 , 一共有两万个,那么在这样高并发场景下会有什么问题发生呢?
注意两个点 :一个是数据的一致性,另外一个是性能问题。
运行测试
启动tomcat,前端访问 http://localhost:8080/ssm_redpacket/grap.jsp
如果有日志,记得调成error级别,或者不打印日志。
我这里的mysql是部署在虚拟机中,CPU和内存的配置都不高。内存1G。
超量发送的BUG验证
模拟高并发场景的抢红包后,两个维度进行统计
1:数据一致性
2:性能
抢红包一致性统计:
SELECT a.id, a.amount, a.stockFROM T_RED_PACKET aWHERE a.id = 1UNION ALL SELECT max(b.user_id), sum(b.amount), count(*) FROM T_USER_RED_PACKET b WHERE b.red_packet_id = 1;
a.id,
a.amount,
a.stock
FROM
T_RED_PACKET a
WHERE
a.id = 1
UNION ALL
SELECT
max(b.user_id),
sum(b.amount),
count(*)
FROM
T_USER_RED_PACKET b
WHERE
b.red_packet_id = 1;
使用 SQL 去查询红包的库存、发放红包的总个数、总金额,我们发现了错误,红包总额为 20 万元,两万个小红包,结果发放了 200020元的红包, 20002 个红包。现有库存为-2,超出了之前的限定,这就是高并发的超发现象,这是一个错误的逻辑 。
抢红包性能统计:
SELECT ( UNIX_TIMESTAMP(max(a.grab_time)) - UNIX_TIMESTAMP(min(a.grab_time)) ) AS lastTimeFROM T_USER_RED_PACKET a;
(
UNIX_TIMESTAMP(max(a.grab_time)) - UNIX_TIMESTAMP(min(a.grab_time))
) AS lastTime
FROM
T_USER_RED_PACKET a;
一共使用了 190 秒的时间,完成 20002 个红包的抢夺,性能一般。。。但是逻辑上存在超发错误,还需要解决超发问题 。
超发问题解决思路
超发现象是由多线程下数据不一致造成的,对于此类问题,如果采用数据库方案的话,主要通过悲观锁和乐观锁来处理,这两种方法的性能是不一样的。
接下来我们分别使用悲观锁、乐观锁、Redis+lua的方式来解决这个超发问题。
代码
https://github.com/yangshangwei/ssm_redpacket
觉得本文对你有帮助?请分享给更多人
关注「全栈开发者社区」加星标,提升全栈技能
本公众号会不定期给大家发福利,包括送书、学习资源等,敬请期待吧!
如果感觉推送内容不错,不妨右下角点个在看转发朋友圈或收藏,感谢支持。
好文章,我在看❤️