黑马程序员技术交流社区

标题: 【成都校区*精品*redis令牌机制实现秒杀】 [打印本页]

作者: 小蜀哥哥    时间: 2018-11-29 13:30
标题: 【成都校区*精品*redis令牌机制实现秒杀】
本帖最后由 小蜀哥哥 于 2018-11-29 14:11 编辑

一、前言1. 秒杀介绍
秒杀是电商系统非常常见的功能模块,是商家进行相关促销推广的常用方式。主要特点是商品库存有限,抢购时间有限。那么在系统设计之初就应该考虑在数量和时间有限的情况下导致的一个高并发以及高并发所带来的库存超卖的问题。
秒杀需要解决的问题:
1) 库存超卖
解决方案:
1) 悲观锁:synchronize 、 Lock
2) 乐观锁:数据库乐观锁版本号控制
2) 高并发情况下系统压力以及用户体验
解决方案: redis
本教程采用:redis中list类型达到令牌机制完成秒杀。用户抢redis中的令牌,抢到令牌的用户才能进行支付,支付成功之后可以生成订单,如果一定时间之内没有支付那么就由定时任务来归还令牌
2. 开发介绍
1) 开发工具: IntelliJ IDEA2017.3.5
2) JDK版本:1.7+
3) 数据库: mysql5.7 、 Redis
4) 技术:Spring、Spring Data Redis、mybatis
二、环境搭建1. 数据库表创建
[SQL] 纯文本查看 复制代码
/*商品表 */
CREATE TABLE `goods` (
  `goods_id` int(11) NOT NULL AUTO_INCREMENT,
  `num` int(11) DEFAULT NULL,
  `goods_name` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`goods_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

insert  into `goods`(`goods_id`,`num`,`goods_name`) values (1,100,'iphone X');

/*订单表 */
CREATE TABLE `orders` (
  `order_id` int(11) NOT NULL AUTO_INCREMENT,
  `good_id` int(11) DEFAULT NULL,
  `user` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`order_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1163 DEFAULT CHARSET=utf8;
2. redis安装 ( 略 )
3. 创建mavne项目,打包方式jar,pom.xml如下
[XML] 纯文本查看 复制代码
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <junit.version>4.12</junit.version>
    <spring.version>4.2.4.RELEASE</spring.version>
    <pagehelper.version>4.0.0</pagehelper.version>
    <mybatis.version>3.2.8</mybatis.version>
    <mybatis.spring.version>1.2.2</mybatis.spring.version>
    <mybatis.paginator.version>1.2.15</mybatis.paginator.version>
    <mysql.version>5.1.32</mysql.version>
    <druid.version>1.0.9</druid.version>
</properties>

<dependencies>
    <!-- Spring -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-beans</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jdbc</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-aspects</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-jms</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context-support</artifactId>
        <version>${spring.version}</version>
    </dependency>
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-test</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.9</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.28</version>
    </dependency>
    <dependency>
        <groupId>javassist</groupId>
        <artifactId>javassist</artifactId>
        <version>3.11.0.GA</version>
    </dependency>
    <dependency>
        <groupId>commons-codec</groupId>
        <artifactId>commons-codec</artifactId>
        <version>1.10</version>
    </dependency>

    <dependency>
        <groupId>com.github.pagehelper</groupId>
        <artifactId>pagehelper</artifactId>
        <version>${pagehelper.version}</version>
    </dependency>
    <!-- Mybatis -->
    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis</artifactId>
        <version>${mybatis.version}</version>
    </dependency>
    <dependency>
        <groupId>org.mybatis</groupId>
        <artifactId>mybatis-spring</artifactId>
        <version>${mybatis.spring.version}</version>
    </dependency>
    <dependency>
        <groupId>com.github.miemiedev</groupId>
        <artifactId>mybatis-paginator</artifactId>
        <version>${mybatis.paginator.version}</version>
    </dependency>
    <!-- MySql -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>${mysql.version}</version>
    </dependency>
    <!-- 连接池 -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>${druid.version}</version>
    </dependency>

    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>2.8.1</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-redis</artifactId>
        <version>1.7.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>dom4j</groupId>
        <artifactId>dom4j</artifactId>
        <version>1.6.1</version>
    </dependency>
    <dependency>
        <groupId>xml-apis</groupId>
        <artifactId>xml-apis</artifactId>
        <version>1.4.01</version>
    </dependency>
</dependencies>

4. 数据访问层
利用mybatis逆向工程生成POJO,以及mapper接口和mapper映射文件。该部分自行操作
mybatis核心配置文件SqlMapConfig.xml
[XML] 纯文本查看 复制代码
<?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>
        <plugins>
                <!-- com.github.pagehelper 为 PageHelper 类所在包名 -->
                <plugin interceptor="com.github.pagehelper.PageHelper">
                        <!-- 设置数据库类型 Oracle,Mysql,MariaDB,SQLite,Hsqldb,PostgreSQL 六种数据库-->
                        <property name="dialect" value="mysql"/>
                </plugin>
        </plugins>
</configuration>
数据访问 db.properties
[C] 纯文本查看 复制代码
jdbc.driver=com.mysql.jdbc.Driver
jdbc.url=jdbc:mysql://localhost:3306/miaosha?characterEncoding=utf-8
jdbc.username=root
jdbc.password=root

redis配置属性文件redis-config.propertiesproperties
[C] 纯文本查看 复制代码
  # Redis settings 
        # server IP
        redis.host=127.0.0.1
        # server port
        redis.port=6379
        # server pass
        redis.pass=
        # use dbIndex
        redis.database=0
        redis.maxIdle=1000
        redis.maxWait=3000


5. spring配置文件
applicationContext-dao.xml
[XML] 纯文本查看 复制代码
<!-- 加载配置文件 -->
                <context:property-placeholder location="classpath*:properties/*.properties" />
                <!-- 数据库连接池 -->
                <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource"
                          destroy-method="close">
                        <property name="url" value="${jdbc.url}" />
                        <property name="username" value="${jdbc.username}" />
                        <property name="password" value="${jdbc.password}" />
                        <property name="driverClassName" value="${jdbc.driver}" />
                        <property name="maxActive" value="10" />
                        <property name="minIdle" value="5" />
                </bean>
                        <!-- 让spring管理sqlsessionfactory 使用mybatis和spring整合包中的 -->
                <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
                        <!-- 数据库连接池 -->
                        <property name="dataSource" ref="dataSource" />
                        <!-- 加载mybatis的全局配置文件 -->
                        <property name="configLocation" value="classpath:mybatis/SqlMapConfig.xml" />
                </bean>
                <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
                        <property name="basePackage" value="com.miaosha.demo.mapper" />
                </bean>
applicationContext-redis.xml
[XML] 纯文本查看 复制代码
<context:property-placeholder location="classpath*:properties/*.properties" ignore-unresolvable="true" />   
  
   <!-- redis 相关配置 -->
   <bean id="poolConfig" class="redis.clients.jedis.JedisPoolConfig">  
     <property name="maxIdle" value="${redis.maxIdle}" />
       <property name="maxTotal" value="2000" />

       <property name="maxWaitMillis" value="${redis.maxWait}" />
     <property name="testOnBorrow" value="true" />

   </bean>  
  
   <bean id="JedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory"
       p:host-name="${redis.host}" p:port="${redis.port}" p:password="${redis.pass}" p:pool-config-ref="poolConfig"/>
   
   <bean id="redisTemplate" class="org.springframework.data.redis.core.RedisTemplate">
       <!--<!–开启事务支持–>-->
       <!--<property name="enableTransactionSupport" value="true"/>-->

       <property name="connectionFactory" ref="JedisConnectionFactory" />
       <!-- 序列化方式 建议key/hashKey采用StringRedisSerializer。 -->
       <property name="keySerializer">
           <bean class="org.springframework.data.redis.serializer.StringRedisSerializer"/>
       </property>
       <property name="hashKeySerializer">
           <bean class="org.springframework.data.redis.serializer.StringRedisSerializer"/>
       </property>
       <property name="valueSerializer">
           <bean class="org.springframework.data.redis.serializer.StringRedisSerializer"/>
       </property>
       <property name="hashValueSerializer">
           <bean class="org.springframework.data.redis.serializer.JdkSerializationRedisSerializer"/>
       </property>
</bean>
applicationContext-service.xml
[AppleScript] 纯文本查看 复制代码
<context:component-scan base-package="com.miaosha.demo.service" ></context:component-scan><context:component-scan base-package="com.miaosha.demo.service" ></context:component-scan>
三、代码实现
1.定义秒杀业务接口

[Java] 纯文本查看 复制代码
 /**
         * 秒杀的接口
         */
        public interface MiaoShaService {
       
            /**
             * 初始化所有商品的令牌
             * @return
             */
            public boolean initTokenToRedis();
       
            /**
             * 抢购令牌
             * @param goodsId
             * @param user
             * @param num
             * @return
             */
            public boolean miaoshaTokenFromRedis(Integer goodsId,String user,Integer num);
       
            /**
             * 用户未支付 退还令牌
             * @param user
             * @return
             */
            public boolean returnToken(String user);
       
            /**
             * 支付生成订单保存到数据库
             * @param user
             * @return
             */
            public boolean payTokenToOrder(String user);
        }
2.秒杀业务实现类
[AppleScript] 纯文本查看 复制代码
 @Service
        public class MiaoShaServiceImpl implements MiaoShaService{

            @Autowired
            private GoodsMapper goodsMapper;
            @Autowired
            private OrderMapper orderMapper;
       
       
            @Autowired
            RedisTemplate redisTemplate;
       
       
            @Override
            public boolean initTokenToRedis() {
                //查询所有商品
                //根据时间   startTime<=now<=endTime
                //根据状态   已审核状态
               List<Goods> goodsList= goodsMapper.selectByExample(null);
               for(Goods goods : goodsList){
                   for(int i=0;i<goods.getNum();i++) {
                       //token_goods_1:[token_1_0,token_1_1,token_1_2 ... token_1_99]
                       //token_goods_2:[token_2_0,token_2_1,token_2_2 ... token_2_99]
                       redisTemplate.boundListOps("token_goods_" + goods.getGoodsId()).leftPush("token_" + goods.getGoodsId() + "_" +i);
                   }
               }
                return false;
            }
       
            @Override
            public boolean miaoshaTokenFromRedis(Integer goodsId, String user, Integer num) {
                // 获取令牌
                String token = (String)redisTemplate.boundListOps("token_goods_"+goodsId).rightPop();
                if(token == null || token.equals("")){
                    return false;
                }else{
                    //记录当前用户已经抢购到令牌,证明当前这个用户可以取支付
                    //用redis记录
                    String yes  = (String)redisTemplate.boundValueOps(token).get();
                    if(yes != null && yes.equals("yes")) {
                        System.out.println("当前token已经被支付,不能再抢购");
                        redisTemplate.boundListOps("token_goods_"+goodsId).remove(1,token);
                        return false;
                    }
                    System.out.println(user);
                    redisTemplate.boundHashOps("user_token").put(user,token);
                    return true;
                }
            }
       
            @Override
            public boolean returnToken(String user) {
                //获得当前用户的令牌
                String token =  (String) redisTemplate.boundHashOps("user_token").get(user);
                if(token == null || token.equals("")){
                    return false;
                }else {
                    //得到商品id
                    String goodsId = token.split("_")[1];
                    redisTemplate.boundListOps("token_goods_"+goodsId).leftPush(token);
                    return true;
                }
            }
       
       
            @Override
            public boolean payTokenToOrder(String user) {
                //获得当前用户的令牌
                String token =  (String) redisTemplate.boundHashOps("user_token").get(user);
                if(token == null || token.equals("")){
                    return false;
                }else {
                    //如果在当前token已经被购买过,那么别人就不能抢当前的token或者不能再对该token进行支付
                    //采用redis记录当前token已经被支付
                    //redisTemplate.boundValueOps("key").setIfAbsent("value")
                    //如果当前这个key有值,该方法就会返回false,如果没有值,就会s设置为对应value,同时返回true
                    boolean flag = redisTemplate.boundValueOps(token).setIfAbsent("yes");
                    //当前token第一次被支付 flag=true
                    if(flag) {
                        //得到商品id
                        String goodsId = token.split("_")[1];
                        Order order = new Order();
                        order.setUser(user);
                        order.setGoodId(Integer.parseInt(goodsId));
                        orderMapper.insert(order);
                        //用户刚好在支付时,定时任务执行
                        redisTemplate.boundHashOps("user_token").delete(user);//有可能已经归还token
       
                        redisTemplate.boundListOps("token_goods_" + goodsId).remove(1, token);//移除token, 有可能被别人抢到
                    }
                    return true;
                }
            }
        }

3.秒杀测试类
[Java] 纯文本查看 复制代码
package com.miaosha.test;
       
        import com.miaosha.demo.service.MiaoShaService;
        import org.junit.Test;
        import org.junit.runner.RunWith;
        import org.springframework.beans.factory.annotation.Autowired;
        import org.springframework.data.redis.core.RedisTemplate;
        import org.springframework.test.context.ContextConfiguration;
        import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
       
        import java.util.ArrayList;
        import java.util.List;
        import java.util.concurrent.CountDownLatch;
       
        @RunWith(SpringJUnit4ClassRunner.class)
        @ContextConfiguration(locations = {"classpath:spring/applicationContext-*.xml"})
        public class MiaoShaTest {
       
            @Autowired
            private MiaoShaService miaoShaService;
            //并发数量
            public static int bfCount = 1000;
            //记录成功个数
            public int count =0;
                //多线程辅助类,控制并发访问数量
            CountDownLatch countDownLatch = new CountDownLatch(bfCount);
       
            @Test
            public void miaoshaToOrder() throws  Exception{
                boolean flag =  miaoShaService.initTokenToRedis();
                if(flag){
                   System.out.println("初始化token成功");
                }
                long startTime = System.currentTimeMillis();
                List<Thread> tList = new ArrayList<>();
                for(int i=0;i<bfCount;i++){
                    MiaoShaThread miaoShaThread = new MiaoShaThread(1,"user_"+i,1);
                    Thread thread = new Thread(miaoShaThread);
                    thread.start();
                    tList.add(thread);
                    countDownLatch.countDown();
                }
                for (Thread t : tList){
                    t.join();
                }
                long endTime = System.currentTimeMillis();
                System.out.println("执行时间:"+(endTime-startTime) );
                System.out.println("成功的个数:"+count);
            }
       
       
            @Autowired
            RedisTemplate redisTemplate;
       
            @Test
            public void getTokenFromRefis(){
                Long count = redisTemplate.boundListOps("token_goods_" + 1).size();
                System.out.println("令牌数量:"+count);
            }
       
            @Test
            public void payToOrder(){
                int count_ = 1;
                for(int i=0;i<200;i++){
                   boolean b = miaoShaService.payTokenToOrder("user_"+i);
                   if(b){
                       count_++;
                   }
                }
                System.out.println("支付成功的人数:"+count_);
            }
            //定时任务每秒执行退还令牌的操作
            // 如果在规定时间5分钟之内没有支付就需要退还
            @Test
            public void returnToken(){
                for(int i=0;i<1000;i++) {
                    boolean flag = miaoShaService.returnToken("user_"+i);
                    if(flag){
                        System.out.println("退还令牌成功");
                    }
                }
       
       
            }
       
       
            class MiaoShaThread implements Runnable{
                private Integer goodsId;
                private String user;
                private Integer num;
                public MiaoShaThread(Integer goodsId,String user,Integer num){
                    this.goodsId=goodsId;
                    this.user=user;
                    this.num=num;
                }
                public void run() {
                    try {
                        countDownLatch.await();
                        //操作redis 抢购token
                        boolean flag = miaoShaService.miaoshaTokenFromRedis(goodsId, user, num);
                        if(flag){
                            synchronized (this){
                                count++;
                            }
                        }
       
                    }catch (Exception e){
                        e.printStackTrace();
                    }
                }
            }
        }
注意:随着CountDownLatch设置并发数量越高,需要调整redis-config.properties属性中的redis.maxIdle属性
四、总结
本文介绍了利用redis的list数据类型模拟令牌队列来完成秒杀,主要解决库存超卖、高并发降低系统压力提高用户体验、解决乐观锁不能先到先得的问题。在单机上运行能够构建上万的请求利用redis抢购100个商品在几秒之内处理完成。本文并不是真是的秒杀业务场景,至少提供一种秒杀的解决思路,如果业务存在某些不确切的地方,欢迎留言交流,相互学习。希望本文能够对您有所帮助





欢迎光临 黑马程序员技术交流社区 (http://bbs.itheima.com/) 黑马程序员IT技术论坛 X3.2