美文网首页
9、SSM项目-抢红包案例

9、SSM项目-抢红包案例

作者: 俊果果 | 来源:发表于2019-07-21 12:29 被阅读0次

    在上期的 SSM 初始框架下开发SSM 初始项目实例

    一、表和数据准备-mysql

    1、t_red_packet 存储发红包信息

    DROP TABLE IF EXISTS `t_red_packet`;
    CREATE TABLE `t_red_packet`  (
      `id` int(12) NOT NULL AUTO_INCREMENT COMMENT '红包编号',
      `user_id` int(12) NOT NULL COMMENT '发红包用户',
      `amount` decimal(16, 2) NOT NULL COMMENT '红包总金额',
      `send_date` timestamp(0) NOT NULL COMMENT '发红包时间',
      `total` int(12) NOT NULL COMMENT '小红包总数',
      `unit_amount` decimal(12, 2) NOT NULL COMMENT '单个小红包金额',
      `stock` int(12) NOT NULL COMMENT '剩余小红包个数',
      `version` int(12) NOT NULL DEFAULT 0 COMMENT '版本',
      `note` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '备注',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
    

    表结构


    image.png

    2、t_user_red_packet 存储每个用户抢红包的信息

    DROP TABLE IF EXISTS `t_user_red_packet`;
    CREATE TABLE `t_user_red_packet`  (
      `id` int(12) NOT NULL AUTO_INCREMENT COMMENT '编号',
      `red_packet_id` int(12) NOT NULL COMMENT '红包编号',
      `user_id` int(12) NOT NULL COMMENT '用户编号',
      `amount` decimal(16, 2) NOT NULL COMMENT '抢到的金额',
      `grab_time` timestamp(0) NOT NULL COMMENT '抢红包时间',
      `note` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '备注',
      PRIMARY KEY (`id`) USING BTREE
    ) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;
    

    表结构


    image.png

    3、插入一条红包信息

    INSERT INTO t_red_packet(id,user_id,amount,send_date,total,unit_amount,stock,note)
    VALUES(1,1, 2000.00, now(), 2000, 1, 2000, '2000总额,分为2000个,每个1块钱');
    

    一个红包,等额分为2000份发出。

    二、生成 pojomapper

    修改generatorConfig.xml, 添加配置

           <table tableName="t_red_packet" domainObjectName="RedPacket"  enableCountByExample="false" enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="false" selectByExampleQueryId="false">
                <property name="useActualColumnNames" value="false" />
                <generatedKey column="id" sqlStatement="MySql" identity="true" />
            </table>
            <table tableName="t_user_red_packet" domainObjectName="UserRedPacket" enableCountByExample="false" enableUpdateByExample="false" enableDeleteByExample="false" enableSelectByExample="false" selectByExampleQueryId="false">
                <property name="useActualColumnNames" value="false" />
                <generatedKey column="id" sqlStatement="MySql" identity="true" />
            </table>
    

    运行mvn mybatis-generator:generate命令,自动生成文件

    image.png
    代码更改:
    Github Commit

    三、编写 Service

    1、新增接口IRedPacketService

    package com.wishuok.service;
    import com.wishuok.pojo.RedPacket;
    public interface IRedPacketService {
        public RedPacket getRedPacket(int id);
        public int decreaseRedPacket(int id);
    }
    

    实现类

    package com.wishuok.service.impl;
    
    import com.wishuok.mapper.RedPacketMapper;
    import com.wishuok.pojo.RedPacket;
    import com.wishuok.service.IRedPacketService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.transaction.annotation.Isolation;
    import org.springframework.transaction.annotation.Propagation;
    import org.springframework.transaction.annotation.Transactional;
    
    public class RedPacketService implements IRedPacketService {
        @Autowired
        private RedPacketMapper redPacketMapper = null;
    
        // 事物隔离级别: 读/写提交
        // 传播行为:调用方法时,若没有事物,则创建事物,否则沿用当前事物
        @Override
        @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
        public RedPacket getRedPacket(int id) {
            return redPacketMapper.selectByPrimaryKey(id);
        }
    
        @Override
        @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
        public int decreaseRedPacket(int id) {
            return redPacketMapper.decreaseRedPacket(id); // 对红包进行剩余个数-1操作
        }
    }
    

    2、新增接口 IUserRedPacketService

    package com.wishuok.service;
    
    public interface IUserRedPacketService {
        int grabRedPacket(int redPacketId, int userId);
    }
    

    实现类

    package com.wishuok.service.impl;
    
    import com.wishuok.mapper.RedPacketMapper;
    import com.wishuok.mapper.UserRedPacketMapper;
    import com.wishuok.pojo.RedPacket;
    import com.wishuok.pojo.UserRedPacket;
    import com.wishuok.service.IUserRedPacketService;
    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 java.util.Date;
    
    @Service("userRedPacketService")
    public class UserRedPacketService implements IUserRedPacketService {
        @Autowired
        private UserRedPacketMapper userRedPacketMapper = null;
    
        @Autowired
        private RedPacketMapper redPacketMapper = null;
    
        private static final int FAILED = -1;
    
        // 事物隔离级别: 读/写提交
        // 传播行为:调用方法时,若没有事物,则创建事物,否则沿用当前事物
        @Override
        @Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED)
        public int grabRedPacket(int redPacketId, int userId) {
            RedPacket redPacket = redPacketMapper.selectByPrimaryKey(redPacketId);
            if(redPacket.getStock() > 0){
                redPacketMapper.decreaseRedPacket(redPacketId);
                UserRedPacket userRedPacket = new UserRedPacket();
                userRedPacket.setRedPacketId(redPacketId);
                userRedPacket.setAmount(redPacket.getUnitAmount());
                userRedPacket.setUserId(userId);
                userRedPacket.setGrabTime(new Date());
                userRedPacket.setNote("抢红包" + redPacketId);
    
                int result = userRedPacketMapper.insert(userRedPacket);
                return result;
            }
            return FAILED;
        }
    }
    

    四、编写 Controller

    1、返回数据处理器使用MappingJackson2HttpMessageConverter

    spring-mvc.xml修改项:

    <!-- 配置springmvc返回数据的数据格式,注意必须放在<mvc:annotation-driven>之前 -->
        <bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter">
            <property name="messageConverters">
                <list>
                    <bean class="org.springframework.http.converter.json.MappingJackson2HttpMessageConverter">
                        <property name="supportedMediaTypes">
                            <list>
                                <!--
                                <value>text/plain;charset=UTF-8</value>
                                <value>text/html;charset=UTF-8</value> -->
                                <value>application/json;charset=UTF-8</value>
                            </list>
                        </property>
                    </bean>
                </list>
            </property>
        </bean>
    

    2、新增 UserRedPacketController

    package com.wishuok.controller;
    
    import com.wishuok.service.IUserRedPacketService;
    import com.wishuok.service.impl.UserRedPacketService;
    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 java.util.HashMap;
    import java.util.Map;
    
    @Controller
    @RequestMapping("/userRedPacket")
    public class UserRedPacketController {
    
        @Autowired
        private IUserRedPacketService userRedPacketService = null;
    
        @RequestMapping(value = "/grab")
        @ResponseBody // 使用Json转换器处理返回值
        public Map<String, Object> grabRedPacket(int redPacketId, int userId)    {
            int result = userRedPacketService.grabRedPacket(redPacketId, userId);
            Map<String, Object> retMap = new HashMap<String, Object>();
            boolean flag = result > 0;
            retMap.put("success", flag);
            retMap.put("message", flag ? "抢红包成功" : "抢红包失败");
            return retMap;
        }
    
        // 调用url 示例:http://localhost/userRedPacket/doTest
        // 直接返回 `redPacketTest.jsp`
        @RequestMapping(value = "/doTest")
        public String doTest(){
            return "redPacketTest";
        }
    
    }
    

    3、新增页面 redPacketTest.jsp

    <%--
      Created by IntelliJ IDEA.
      User: junguoguo
      Date: 2019/7/21
      Time: 10:43
      To change this template use File | Settings | File Templates.
    --%>
    <%@ page contentType="text/html;charset=UTF-8" language="java" %>
    <html>
    <head>
        <title>模拟抢红包</title>
        <script type="text/javascript"  src="https://code.jquery.com/jquery-3.2.0.js"></script>
        <script type="text/javascript">
            $(document).ready(function(){
                // 模拟 2500 个异步请求并发
                var max = 2500;
                for (var i=1; i <= max; i++){
                    $.post({
                        url:"./grab?redPacketId=1&userId=" + i,
                        success:function(result){
    
                        }
                    });
                }
            })
        </script>
    </head>
    <body>
    
    </body>
    </html>
    

    代码更改:
    Github Commit

    五、测试

    调试后,访问网页http://localhost/userRedPacket/doTest
    jsp加载后会并发发起2500个请求

    image.png
    可以看到存在红包超发的情况
    select * from t_red_packet;
    
    select * from t_user_red_packet order by user_id desc;
    
    select max(grab_time) - min(grab_time) costtime from t_user_red_packet
    
    image.png
    一共有 2400 个人抢到了红包,多发了4 个人,数据记录插入总耗时大概23

    六、使用悲观锁解决超发问题

    1、RedPacketMapper 新增加行锁的方法

    <select id="selectByPrimariKeyForUpdate" parameterType="java.lang.Integer" resultMap="BaseResultMap">
        select
        <include refid="Base_Column_List" />
        from t_red_packet
        where id = #{id,jdbcType=INTEGER}  for update
      </select>
    

    相比原来的selectByPrimariKey,多了一个 select 语句后的 for update

    2、修改 service

    使用新的selectForUpdate方法获取数据,其余不变

    3、测试

    执行sql,清除数据

    DELETE FROM t_red_packet;
    DELETE FROM t_user_red_packet;
    INSERT INTO t_red_packet(id,user_id,amount,send_date,total,unit_amount,stock,note)
    VALUES(1,1, 2000.00, now(), 2000, 1, 2000, '2000总额,分为2000个,每个1块钱');
    

    再调用,会发现没有超发的情况,但是这种方法耗时比较长
    代码更改:
    Github Commit

    七、使用乐观锁

    上面的方式,在多个请求到达服务器以后,每个线程都会阻塞在获取行锁的地方,从而导致同一时间会有很多的线程堵塞,同时仅能有一个线程运行,非常消耗服务器资源。

    CAS原理

    对于多个线程共同的资源,先保存一个旧值( Old Value ),然后经过一定的逻辑处理,当需要修改数据库时,先比较数据库当前的值和旧值是否一致,如果一致则进行更新,否则不再进行操作


    image.png

    CAS 原理并不排斥并发,也不独占资源,只是在线程开始阶段就读入线程共享数据,
    保存为旧值。当处理完逻辑,需要更新数据的时候,会进行一次 比较,即比较各个线程当前共享的数据是否和旧值保持一致。如果一致,就开始更新数据 ;如果不一致,则认为该数据已经被其他线程修改了,那么就不再更新数据 ,可以考虑重试或者放弃。有时候可 以重试,这样就是一个可重入锁,但是 CAS 原理会有一个问题,那就是 ABA 问题。ABA 问题的发生 , 是因为业务逻辑存在回退的可能性。在一个数据中加入版本号( version ),对于版本号有一个约定 ,就是只要修改 X变量 的数据,强制版本号( version )只能递增,而不会回退,即使是其他业务数据回退,它也会递增,那么 ABA 问题就解决了。但是CAS往往会导致调用请求过多时失败率过高,所以需要加入失败重试机制

    乐观锁重入机制

    因为乐观锁造成大量更新失败的问题,使用时间戳执行乐观锁重入,是一种提高成功率的方法,比如考虑在 100 毫秒内允许重入;或者使用重试次数限制,比如失败三次以内自动重试。

    代码修改

    • 主要修改在 update 红包表时增加了对 version 字段的新旧比较
      mapper 配置文件
     <update id="decreaseRedPackWithVersion">
        update t_red_packet
        set
          stock = stock - 1,
          version = version + 1
        where id = #{id,jdbcType=INTEGER}
        and version = #{version,jdbcType=INTEGER}
      </update>
    

    mapper

       // 使用 version 而不是 stock 字段判断,是因为防止ABA问题
        // 此处使用 stock 和 version 都行,因为只有对 stock 的 +1 操作
        int decreaseRedPackWithVersion(@Param("id") int id, @Param("version") int version, @Param("stock") int stock);
    

    注意要在 配置文件中使用#{id,jdbcType=INTEGER}的话,方法参数必须用@Param("id")修饰(Param 类为 【org.apache.ibatis.annotations.Param】)

    使用乐观锁的弊端在于

    导致大量的 SQL 被执行,对于数据库的性能要求较高,容易引起数据库性能的瓶颈,而且对于开发还要考虑重入机制,从而导致开发难度加大。

    八、使用 redis

    对于使用 Redis 实现抢红包 ,首先需要知道的是 Redis 的功能不如数据库强大,不完整,因此要保证数据的正确性,数据的正确性可以通过严格的验证得以保证。Lua 语言是原子性的,且功能更为强大,所以优先选择使用 Lua 语言来实现抢红包。此外,Redis 并非一个长久储存数据的地方,它存储的数据是非严格和安全的环境,更多的时候只是为了提供更为快速的缓存,所以当红包金额为 0 或者红包超时的时候,将红包数据保存到数据库中,能够保证数据的安全性和严格性。

    1、redis 基础使用

    参考上一篇文章8、SSM项目使用redis

    2、新增一个stringRedisTemplate

    spring-service.xml 配置

       <bean id= "stringRedisTemplate" class= "org.springframework.data.redis.core.RedisTemplate" >
            <property name="connectionFactory" ref="jedisConnectionFactory" />
            <property name="keySerializer" ref="stringRedisSerializer"/>
            <property name="valueSerializer" ref="stringRedisSerializer" />
            <property name="defaultSerializer" ref="stringRedisSerializer"/>
        </bean>
    

    3、新增用于redis抢红包后保存记录到数据库的service

    接口 IRedisRedPacketService

    package com.wishuok.service;
    
    public interface IRedisRedPacketService {
    
        /** 保存 redis 抢红包列表
         * @param redPacketId   --红包编号
         * @param unitAmout     --红包金额
         */
        void saveUserRedPacketByRedis(int redPacketId, double unitAmout);
    }
    

    实现类 RedisRedPacketService

    package com.wishuok.service.impl;
    
    import com.wishuok.pojo.UserRedPacket;
    import com.wishuok.service.IRedPacketService;
    import com.wishuok.service.IRedisRedPacketService;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.BoundListOperations;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.scheduling.annotation.Async;
    import org.springframework.stereotype.Service;
    
    import javax.sql.DataSource;
    import java.math.BigDecimal;
    import java.math.MathContext;
    import java.sql.Connection;
    import java.sql.SQLException;
    import java.sql.Statement;
    import java.sql.Timestamp;
    import java.text.DateFormat;
    import java.text.SimpleDateFormat;
    import java.util.ArrayList;
    import java.util.List;
    
    @Service
    public class RedisRedPacketService implements IRedisRedPacketService {
        //  每次取出 800 条 ,避免一次取出消耗太多内存
        private static final int TIME_SIZE = 800;
        // redis中每个用户抢红包结果的list key
        private static final String REDIS_PREFIX = "red_packet_list_";
        @Autowired
        private RedisTemplate stringRedisTemplate = null;
    
        @Autowired
        private DataSource dataSource = null;
    
        @Override
        @Async // 开启新线程运行
        public void saveUserRedPacketByRedis(int redPacketId, double unitAmout) {
            System.err.println("开始保存数据 " + "thread_name = " + Thread.currentThread().getName());
            String redisKey =  REDIS_PREFIX + redPacketId;
            Long startTime = System.currentTimeMillis();
            BoundListOperations ops = stringRedisTemplate.boundListOps(redisKey);
            Long size = ops.size();
            Long times = size % TIME_SIZE == 0 ? size / TIME_SIZE : size / TIME_SIZE + 1;
            int count = 0;
            List<UserRedPacket> userRedPacketList = new ArrayList<UserRedPacket>(TIME_SIZE);
            for (int i = 0; i < times; i++) {
                // 每次获取 TIME_SIZE 个信息
                List useridList = null;
                if (i == 0) {
                    useridList = ops.range(0, TIME_SIZE);
                } else {
                    useridList = ops.range(i * TIME_SIZE + 1, (i + 1) * TIME_SIZE);
                }
                userRedPacketList.clear();
                for (int j=0;j<useridList.size();j++){
                    String args = useridList.get(j).toString();  // 存储的值为 userId + "-" + System.currentTimeMillis();
                    String[] arr = args.split("-");
                    String userIdStr = arr[0];
                    String timeStr = arr[1];
                    Integer userId = Integer.parseInt(userIdStr);
                    Long time = Long.parseLong(timeStr);
    
                    UserRedPacket userRedPacket = new UserRedPacket();
                    userRedPacket.setRedPacketId(redPacketId);
                    userRedPacket.setUserId(userId);
                    userRedPacket.setAmount(new BigDecimal(unitAmout, MathContext.DECIMAL32));
                    userRedPacket.setGrabTime(new Timestamp(time));
                    userRedPacket.setNote("抢红包" + redPacketId);
                    userRedPacketList.add(userRedPacket);
                }
                // 插入数据库
                count += executeBatch(userRedPacketList);
            }
            // 删除 redis 列表
            stringRedisTemplate.delete(redisKey) ;
            Long end = System.currentTimeMillis();
            System.err.println("保存数据结束,耗时" + (end-startTime) +"毫秒,共" + count +"条记录被保存。");
        }
    
        /** 使用 JDBC 批量处理redis数据
         * @param userRedPacketList
         * @return 插入数量
         */
        private int executeBatch(List<UserRedPacket> userRedPacketList) {
            DateFormat dt = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            Connection conn = null;
            Statement stmt = null;
            int[] count = null;
            try {
                conn = dataSource.getConnection();
                conn.setAutoCommit(false);
                stmt = conn.createStatement();
                for (UserRedPacket packet: userRedPacketList) {
                    String sql1 = "update t_red_packet set stock=stock-1 where id=" + packet.getRedPacketId();
                    String sql2 = "insert into t_user_red_packet(red_packet_id,user_id,amount,grab_time,note)" +
                            " values(" +packet.getRedPacketId()+","+packet.getUserId()+","+packet.getAmount()+","
                            +"'"+dt.format(packet.getGrabTime()) +"','" +packet.getNote()+ "')";
                    stmt.addBatch(sql1);
                    stmt.addBatch(sql2);
                }
                count = stmt.executeBatch();    // 执行批量脚本
                conn.commit();                  // 提交事物
            } catch (SQLException e) {
                e.printStackTrace();
                throw new RuntimeException("抢红包批量执行程序错误:"+ e.getMessage());
            }finally {
                try {
                    if(conn != null && !conn.isClosed()){
                        conn.close();
                    }
                } catch (SQLException e) {
                    e.printStackTrace();
                }
            }
            // 返回插入的记录数
            return count.length / 2;
        }
    }
    

    4、修改UserRedPacketService

    添加使用 redis 抢红包的方法

        @Autowired
        private RedisTemplate stringRedisTemplate = null;
        @Autowired
        private IRedisRedPacketService redisRedPacketService = null;
        //抢红包的LUA脚本
        private static final String RedisRedPacketLuaScript = "local listKey = 'red_packet_list_'..KEYS[1] \n"
                + " local redPacket = 'red_packet_'.. KEYS[1] \n"
                + " local stock= tonumber(redis.call('hget', redPacket,'stock'))"
                + " if stock <= 0 then return 0 end \n"
                + " stock = stock - 1 \n "
                + " redis.call('hset', redPacket,'stock', tostring(stock)) \n"
                + " redis.call('rpush', listKey, ARGV[1]) \n"
                + " if stock == 0 then return 2 end \n"
                + " return 1 \n";
        // 缓存lua脚本后得到的 sha1 值,用来调用脚本
        private static String RedisLuaScriptSha1 = null;
    
        /**
         * 通过 redis 实现抢红包
         *
         * @param redPacketId
         * @param userId
         * @return 0-没有库存,失败;1-成功,且不是最后一个红包;2-成功且是最后一个红包
         */
        @Override
        public int grabRedPacketByRedis(int redPacketId, int userId) {
            String args = userId + "-" + System.currentTimeMillis();
            int result = 0;
            Jedis jedis = (Jedis)stringRedisTemplate.getConnectionFactory().getConnection().getNativeConnection();
            try{
                if(RedisLuaScriptSha1 == null) {// 加载脚本
                    RedisLuaScriptSha1 = jedis.scriptLoad(RedisRedPacketLuaScript);
                }
                long res = (Long)jedis.evalsha(RedisLuaScriptSha1, 1, redPacketId + "", args);
                result =  (int)res;
                if(result == 2){
                    // 最后一个红包
                    String unitAmout = jedis.hget("red_packet_"+redPacketId, "unit_amount");
                    double unitAmount = Double.parseDouble(unitAmout);
                    System.err.println("thread_name = " + Thread.currentThread().getName());
                    redisRedPacketService.saveUserRedPacketByRedis(redPacketId, unitAmount); // 调用保存记录到数据库的service方法
                }
            } finally {
                if(jedis != null && jedis.isConnected())
                    jedis.close();
            }
            return result;
        }
    

    5、Controller新增接口

        @RequestMapping(value = "/grabWithRedis")
        @ResponseBody
        public Map<String, Object> grabWithRedis(int redPacketId, int userId)    {
            int result = userRedPacketService.grabRedPacketByRedis(redPacketId, userId);
            Map<String, Object> retMap = new HashMap<String, Object>();
            boolean flag = result > 0;
            retMap.put("success", flag);
            String msg = flag ? "抢红包成功 " : "抢红包失败 ";
            msg += result;
            retMap.put("message",msg);
            return retMap;
        }
    

    6、测试

    • 清除数据库数据,重新插入红包记录【SQL前面有】
    INSERT INTO t_red_packet(id,user_id,amount,send_date,total,unit_amount,stock,note)
    VALUES(1,1, 3000.00, now(), 3000, 1, 3000, '2000总额,分为2000个,每个1块钱');
    
    • redis 设置初始值


      image.png
    • 调试运行
      image.png
      红包扣减正确
      image.png
      redis耗时 6 秒
      记录落地到数据库log如下:
    thread_name = http-nio-80-exec-9
    开始保存数据 thread_name = SimpleAsyncTaskExecutor-1
    ..........................something else...............................
    保存数据结束,耗时3814毫秒,共3000条记录被保存。
    

    可以看到,因为方法启用了@Async注解,所以调用时系统另外开启了一个线程,这样不会阻塞最后一个成功抢到的用户

    7、 Github Commit

    相关文章

      网友评论

          本文标题:9、SSM项目-抢红包案例

          本文链接:https://www.haomeiwen.com/subject/hrcmlctx.html