抢红包案例

  抢红包案例

  主要分以下几大部分:

  1. 环境搭建
  2. 模拟超量发送的场景-DataBase(MySql5.7)
  3. 悲观锁的实现版本-DataBase(MySql5.7)
  4. 乐观锁的实现版本-DataBase(MySql5.7)
  5. Redis实现抢红包

  模拟 20 万元的红包,共分为 2 万个可抢的小红包,有 3 万人同时抢夺的场景 ,模拟出现超发和如何保证数据一致性的问题。一般而言 , 时间太长用户体验就会很差,所以要测试数据一致性和系统的性能 。

  库表设计

/*==============================================================*/
/* 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)
);
/**
* 插入一个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;

  建好了两个表,并且将一个 20 万元金额,2 万个小红包,每个 10 元的红包信息插入到了红包表中,用作模拟数据。

  POJO 为 RedPacket 和 UserRedPacket,实现类序列化接口。

public class RedPacket implements Serializable {

    private static final long serialVersionUID = 9036484563091364939L;
    /**
     * 红包编号
     */
    private Long id;
    /***
     * 发红包的用户id
     */
    private Long userId;
    /***
     * 发红包金额
     */
    private Double amount;
    /**
     * 发红包日期
     */
    private Date sendDate;
    /**
     * 红包总数
     */
    private Integer total;
    /**
     * 单个红包金额
     */
    private Double unitAmount;
    /**
     * 红包剩余个数
     */
    private Integer stock;
    /**
     * 版本
     */
    private Integer version;
    /**
     * 备注
     */
    private String note;
    ....
}

  抢红包信息

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 Date grabTime;
    /**
     * 备注
     */
    private String note;
.....

}

  mapper接口与对应的文件

   一个是查询红包,另一个是扣减红包库存。抢红包的逻辑是,先查询红包的信息,看其是否拥有存量可以扣减。如果有存量,那么可以扣减它,否则就不扣减。

@Mapper
public interface RedPacketMapper {
    /***
     * 获取红包信息
     */
    RedPacket getRedPacket(Long id);
    /**
     * 减扣红包数
     */
    int decreaseRedPacket(Long id);
}
<?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.smart.mapper.RedPacketMapper">

    <!--查询红包具体信息-->
    <select id="getRedPacket" parameterType="long" resultType="com.smart.domain.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映射文件

@Mapper
public interface UserRedPacketMapper {
    /**
     * 插入抢红包信息
     */
    int grabRedPacket(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.smart.mapper.UserRedPacketMapper">
    <insert id="grabRedPacket" useGeneratedKeys="true" keyProperty="id"
            parameterType="com.smart.domain.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 和 keyProperty,这就意味着会返回数据库生成的主键信息,这样就可以拿到插入记录的主键

Service层实现

  定义两个 Service 层接口,分别是 UserRedPacketService和RedPacketService

public interface RedPacketService {
    /**
     * 获取红包
     */
    RedPacket getRedPacket(Long id);
    /**
     * 扣减红包
     */
    int decreaseRedPacket(Long id);
}
public interface UserRedPacketService {
    /**
     * 保存抢红包的信息
     */
    int grapRedPacket(Long redPacketId,Long userId);
}

  实现类如下:

@Service
public class RedPacketServiceImpl  implements RedPacketService{

    @Autowired
    private RedPacketMapper redPacketMapper;

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public RedPacket getRedPacket(Long id) {
        return redPacketMapper.getRedPacket(id);
    }

    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int decreaseRedPacket(Long id) {
        return redPacketMapper.decreaseRedPacket(id);
    }
}

  配置了事务注解@Transactional , 让程序能够在事务中运行,以保证数据的一致性 , 这里采用的是读/写提交的隔离级别 , 之所以不采用更高的级别, 主要是提高数据库的并发能力,而对于传播行为则采用 Propagation.REQUIRED,这样调用这个方法的时候,如果没有事务则会创建事务, 如果有事务则沿用当前事务。

  实现 UserRedPacketService 接口的方法 grapRedPacket,它是核心的接口方法

@Service
public class UserRedPacketServiceImpl implements UserRedPacketService {

    @Autowired
    private UserRedPacketMapper userRedPacketMapper;
    @Autowired
    private RedPacketMapper redPacketMapper;

    private static final int FAILED=0;
    @Override
    @Transactional(isolation = Isolation.READ_COMMITTED,propagation = Propagation.REQUIRED)
    public int grapRedPacket(Long redPacketId, Long userId) {
        RedPacket redPacket = redPacketMapper.getRedPacket(redPacketId);
        Integer leftRedPacket = redPacket.getStock();
        if(leftRedPacket > 0){
            redPacketMapper.decreaseRedPacket(redPacketId);
            /**
             * 生成抢红包信息
             */
            UserRedPacket userRedPacket = new UserRedPacket();
            userRedPacket.setRedPacketId(redPacketId);
            userRedPacket.setUserId(userId);
            userRedPacket.setAmount(redPacket.getUnitAmount());
            int result = userRedPacketMapper.grabRedPacket(userRedPacket);
            return result;
        }
        return FAILED;
    }
}

  grapRedPacket 方法的逻辑是首先获取红包信息,如果发现红包库存大于 0,则说明还有红包可抢,抢夺红包并生成抢红包的信息将其保存到数据库中。要注意的是,数据库事务方面的设置,代码中使用注解@Transactional , 说明它会在一个事务中运行,这样就能够保证所有的操作都是在一个事务中完成的。在高并发中会发生超发的现象,后面会看到超发的实际测试。

  全注解搭建SSM 开发环境

  通过继承 AbstractAnnotationConfigDispatcherServletlnitfal izer 去配置其他内 容,因此首先来配置 WebApplnitializer

public class WebAppInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    /**
     * 依赖注入环境
     * @return
     */
    @Override
    protected Class<?>[] getRootConfigClasses() {
        return new Class[]{ RootConfig.class };
    }

    /**
     * DispatcherServlet环境配置
     * @return
     */
    @Override
    protected Class<?>[] getServletConfigClasses() {
        return new Class[]{WebConfig.class};
    }

    /**
     * DispatchServlet拦截请求配置
     * @return
     */
    @Override
    protected String[] getServletMappings() {
        return new String[]{"*.html"};
    }

    @Override
    public void customizeRegistration(ServletRegistration.Dynamic dynamic){
        String filepath="d:/";
        Long singleMax=(long)(5*Math.pow(2,20));
        Long totalMax=(long)(10*Math.pow(2,10));
        dynamic.setMultipartConfig(new MultipartConfigElement(filepath,singleMax,totalMax,0));
    }
}

  WebAppInitializer继承AbstractAnnotationConfigDispatcherServletlnitializer, 重写 3 个抽象方法 , 并且覆盖了父类的 customizeRegistration 方法 , 作为上传文件的配置。

  getRootConfigClasses 是一个配置 Spring IoC 容器的上下文配置 , 此配置在代码中将会由类 RootConfig 完成
  getServletConfigClasses 配置 DispatcherServlet 上下文配置,将会由WebConfig完成
  getServletMappings 配置 DispatcherServlet 拦截 内 容 , 这里设置的是拦截所有以 .html 结尾的请求
  通过这 3 个方法就可以配置 Web 工程中 的 Spring IoC 资源和 DispatcherServlet 的配置内容 , 首先是配置 Spring IoC 容器,配置类 RootConfig

@Configuration
@ComponentScan(value="com.smart.service",includeFilters = {@ComponentScan.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;
        }
        Properties props = new Properties();
        try(InputStream in=RootConfig.class.getClassLoader().getResourceAsStream("jdbc.properties")){
            props.load(in);
            DruidDataSource druidDataSource = new DruidDataSource();
            druidDataSource.setDriverClassName(props.getProperty("jdbc.driver"));
            druidDataSource.setUrl(props.getProperty("jdbc.url"));
            druidDataSource.setUsername(props.getProperty("jdbc.username"));
            druidDataSource.setPassword(props.getProperty("jdbc.password"));
            return druidDataSource;
        }catch (IOException e) {
            e.printStackTrace();
        }
        return dataSource;
    }

    /**
     * 配置sqlSessionFactoryBean
     * @return
     */
    @Bean(name="sqlSessionFactory")
    public SqlSessionFactoryBean initSqlSessionFactory(){
        SqlSessionFactoryBean sqlSessionFactory = new SqlSessionFactoryBean();
        sqlSessionFactory.setDataSource(initDataSource());
        Resource resource = new ClassPathResource("mybatis/mybatis-config.xml");
        sqlSessionFactory.setConfigLocation(resource);
        return sqlSessionFactory;
    }

    /**
     * 通过自动扫描,发现Mapper的接口
     * @return
     */
    @Bean
    public MapperScannerConfigurer initMapperScannerConfigurer(){
        MapperScannerConfigurer msc = new MapperScannerConfigurer();
        msc.setBasePackage("com.smart.mapper");
        msc.setSqlSessionFactoryBeanName("sqlSessionFactory");
        msc.setAnnotationClass(Mapper.class);
        return msc;
    }

    /**
     * 实现接口方法,注册注解事务,当@Transactional 使用的时候产生数据库事务
     */
    @Override
    public PlatformTransactionManager annotationDrivenTransactionManager() {
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
        transactionManager.setDataSource(initDataSource());
        return transactionManager;
    }
}

  标注了注解@EnableTransactionManagement , 实现了接口 TransactionManagementConfigurer, 这样的配置是为了实现注解式的事务 , 将来可以通过注解@Transactional 配 置数据库事务。

  它有一 个方法annotationDrivenTransactionManager这需要将一个事务管理器返回给它就可以了
  Spring IoC 容器后 , 还需要配置 DispatcherServlet 上下文

@Configuration
@ComponentScan(value="com.smart.*",includeFilters = {
        @ComponentScan.Filter(type= FilterType.ANNOTATION,value=Controller.class)
})
@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 = new RequestMappingHandlerAdapter();
        //HTTP JSON转换器
        MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
        //MappingJackson2HttpMessageConverter接收JSON类型消息的转换
        MediaType mediaType=MediaType.APPLICATION_JSON_UTF8;
        List<MediaType> mediaTypes = new ArrayList<>();
        mediaTypes.add(mediaType);
        //加入转换器的支持类型
        converter.setSupportedMediaTypes(mediaTypes);
        //往适配器加入json转换器
        requestMappingHandlerAdapter.getMessageConverters().add(converter);
        return requestMappingHandlerAdapter;
    }
}

  这里配置了一个视图解析器 , 通过它找到对应 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>

  继续将Controller层实现

@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;
    }
}

  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>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.2.0.js">
    </script>
    <script type="text/javascript">
        $(document).ready(function(){
            var max=30000;
            for(var i=1;i<=max;i++){
                $.post({
                    //根据自己请求修改对应的url和大红包编号
                   url: "./userRedPacket/grapRedPacket.html?redPacketId=1&userId="+i,
                   success: function (result) {

                   }
                });
            }
        });
    </script>
</head>
<body>

</body>
</html>

  JavaScript 去模拟 3 万人同时抢红包的场景 . 请使用 Firefox进行测试(Chrome老是丢失请求,IE慢)

  JavaScript 的 post 请求是一个异步请求,所以这是一个高并发的场景,它将抢夺 id 为1的红包 , 依据之前 SQL 的插入 , 这是一个 20 万元的红包 , 一共有两万个,那么在这样高并发场景下会有什么问题发生呢?

  运行测试

  启动tomcat,前端访问 http://localhost:8080/ssm_redpacket/grap.jsp

  如果有日志,记得调成error级别,或者不打印日志。

  模拟高并发场景的抢红包后,两个维度进行统计

  • 1:数据一致性
  • 2: 性能

  抢红包一致性统计:

SELECT
    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;

  抢红包性能统计:

SELECT
    (
        UNIX_TIMESTAMP(max(a.grab_time)) - UNIX_TIMESTAMP(min(a.grab_time)) 
    )  AS lastTime
FROM
    T_USER_RED_PACKET a;

  超发问题解决思路

  超发现象是由多线程下数据不一致造成的,对于此类问题,如果采用数据库方案的话,主要通过悲观锁和乐观锁来处理,这两种方法的性能是不一样的。

接下来我们分别使用悲观锁、乐观锁、Redis+lua的方式来解决这个超发问题。

posted on 2019-06-26 21:29  溪水静幽  阅读(212)  评论(0)    收藏  举报