9、SSM项目-抢红包案例

在上期的 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, 添加配置

       
            
        

运行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修改项:


    
        
            
                
                    
                        
                            
                            application/json;charset=UTF-8
                        
                    
                
            
        
    

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 grabRedPacket(int redPacketId, int userId)    {
        int result = userRedPacketService.grabRedPacket(redPacketId, userId);
        Map retMap = new HashMap();
        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" %>


    模拟抢红包
    
    





代码更改:
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 新增加行锁的方法


相比原来的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 t_red_packet
    set
      stock = stock - 1,
      version = version + 1
    where id = #{id,jdbcType=INTEGER}
    and version = #{version,jdbcType=INTEGER}
  

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】)

  • Github Commit

使用乐观锁的弊端在于

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

八、使用 redis

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

1、redis 基础使用

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

2、新增一个stringRedisTemplate

spring-service.xml 配置

   
        
        
        
        
    

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 userRedPacketList = new ArrayList(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 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 grabWithRedis(int redPacketId, int userId)    {
        int result = userRedPacketService.grabRedPacketByRedis(redPacketId, userId);
        Map retMap = new HashMap();
        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项目-抢红包案例)