Redis秒杀案例


在商品限量秒杀抢购的场景,一定会遇到抢购成功数超过限量的问题和高并发的情况影响系统性能

1、虽然能用数据库的锁避免超过限量的问题。但是在高并发的情况下,大大影响数据库性能

2、为了避免并发操作数据库,我们可以使用队列来限制,但是并发量会让队列内存瞬间升高

3、可以用悲观锁来实现,但是这样会造成用户等待,响应慢体验不好

一. 相关名词

  • 悲观锁:即实际对某个商品的购买api,同时只允许一个用户访问,“查询该商品数量”、“商品数量减1”是在同一个事务中,保证数据的完整性。缺点是性能,通常无法满足抢购的场景,因为只允许一个用户访问,既当前用户操作完,下一个用户才能进来。
  • FIFO: 客户端的抢购指令,只是插入一个交易表,由另外一个统一的线程来处理交易表,标记交易的成功或失败(如商品已售完)。缺点是客户端无法立即得到反馈,需要等待统一的线程处理完自己的交易后才知道抢购是否成功。此种方法也是可行的。
  • 乐观锁:即每个抢购指令前:step 1. 首先做个特殊标记; step 2. 然后正常执行指令; step 3. 在指令提交时,根据标记判断step 1至step 3之间商品数据是否有变化,如果有,则失败;否则,则抢购成功。

二. 秒杀问题

秒杀场景中,客户端对服务器的访问可以抽象为两个:

  1. 访问静态页面(列出静态商品页面) 2. 访问后台接口(抢购)

静态页面可以使用DNS实现,压力不大;

而后台接口是重点要解决的问题: (1) 响应一定要快 (2) 不要直接访问传统数据库,太慢。建议使用内存数据库技术,如redis等 (3) 防止同一账号短时间内的多次请求 (4) 防止超发(即本来限量只有100件商品,却最终成交了101件)

三. 解决案例

案例一:单线程秒杀抢购

    <!--导入依赖-->
	<dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>2.9.0</version>
    </dependency>

代码实现:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

import java.util.List;
import java.util.UUID;

/**
 * @author : xsh
 * @describe: 单线程抢购
 */
public class seckillDemo1 {
    public static void main(String[] arg) throws Exception {
        Jedis jedis = null;
        try {
            jedis = new Jedis("127.0.0.1",6379);// 获取jedis连接
            String key_s = "user_name";// 抢到的用户
            String key = "count";// 商品数量
            String clientName = UUID.randomUUID().toString().replace("-", "");// 用户名字
            jedis.set(key,"20");//设置抢购商品数量
            while (true) {
                try {
                    jedis.watch(key);// 监听key,为key加上乐观锁
                    System.out.println("用户:" + clientName + "开始抢商品");
                    System.out.println("当前商品的个数:" + jedis.get(key));
                    int prdNum = Integer.parseInt(jedis.get(key));// 当前商品个数
                    if (prdNum > 0) {
                        Transaction transaction = jedis.multi();// 标记一个事务块的开始
                        transaction.set(key, String.valueOf(prdNum - 1));
                        List<Object> result = transaction.exec();// 原子性提交事物
                        if (result == null || result.isEmpty()) {
                            // watch-key被外部修改,或者是数据操作被驳回
                            System.out.println("用户:" + clientName + "没有抢到商品");
                        } else {
                            jedis.sadd(key_s, clientName);// 将抢到的用户存起来
                            System.out.println("用户:" + clientName + "抢到商品");
                        }
                    } else {
                        System.out.println("库存为0,用户:" + clientName + "没有抢到商品");
                        break;
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {

                    jedis.unwatch();// exec,discard,unwatch命令都会清除连接中的所有监视

                }
            } // while
        } catch (Exception e) {
            // TODO: handle exception
            System.out.println("redis bug:" + e.getMessage());
        } finally {
            // 释放jedis连接
            try {
                jedis.close();
            } catch (Exception e) {
                System.out.println("redis bug:" + e.getMessage());
                // TODO Auto-generated catch block
            }
        }
    }
}

案例二:多线程秒杀抢购

实现原理:

(1) 初始化redisKey(已抢商品数量)为0,利用redis的watch功能,监控这个redisKey的状态值

(2) 获取redisKey的值,当redisKey大于限购数时,停止抢购

(3) 创建redis事务,每次并发给redisKey的值+1

(4) 然后去执行这个事务,如果key的值被修改过,说明数据已经被其它线程更改,此时key不+1

    <!--导入依赖-->
	<dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>2.9.0</version>
    </dependency>

代码实现:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author : xsh
 * @describe:
 *      1、利用redis的watch功能,监控这个redisKey的状态值
 *      2、获取redisKey的值
 *      3、创建redis事务
 *      4、给这个key的值+1
 *      5、然后去执行这个事务,如果key的值被修改过,说明数据已经被其它线程更改,key不+1
 */
public class seckillDemo2 {
    public static void main(String[] arg){
        String redisKey = "redisKey";
        //开启20个线程
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        try {
            Jedis jedis = new Jedis("127.0.0.1",6379);
            jedis.set(redisKey,"0");
            jedis.close();
        }catch (Exception e){
            e.printStackTrace();
        }

        for (int i=0;i<500;i++){ //此处五百并发,可以更大
            executorService.execute(()->{
                Jedis jedis1 = new Jedis("127.0.0.1",6379);
                try {
                    jedis1.watch(redisKey);
                    String redisValue = jedis1.get(redisKey);
                    int valInteger = Integer.valueOf(redisValue);
                    String userInfo = UUID.randomUUID().toString();
                    if (valInteger<20){
                        Transaction transaction = jedis1.multi();  //multi开始事务
                        //incr(redisKey),对redisKey的value值+1
                        transaction.incr(redisKey);
                        List<Object> exec = transaction.exec();//exec执行事务

                        if (exec.size()!=0){
                            System.out.println("用户:"+userInfo+",秒杀成功!当前成功人数:"+(valInteger+1));
                        }else {
                            System.out.println("用户:"+userInfo+",秒杀失败");
                        }
                    }else {
                        System.out.println("已经有20人秒杀成功,秒杀结束");
                    }
                }catch (Exception e){
                    e.printStackTrace();
                }finally {
                    jedis1.close();
                }
            });
        }
        executorService.shutdown();
    }
}

运行结果:最终成功人数只有20个,抢购成功人数满20后,秒杀结束。

案例三:多线程抢购优化

商品限量100,20个线程池,1000并发,并将抢购结果写入Redis数据库

MyRunnable.java:实现秒杀逻辑

package seckill3;

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

import java.util.List;

/**
 * @author : xsh
 * @create : 2020-01-03 - 14:36
 */
public class MyRunnable implements Runnable {
 
    String watchkeys = "watchkeys";// 监视keys
    
    Jedis jedis = new Jedis("127.0.0.1", 6379);
    String userinfo;
    public MyRunnable() {
    }
    public MyRunnable(String uinfo) {
        this.userinfo=uinfo;
    }
    @Override
    public void run() {
        try {
            jedis.watch(watchkeys);// watchkeys
 
            String val = jedis.get(watchkeys);
            int valint = Integer.valueOf(val);
            
            if (valint <= 100 && valint>=1) {
            
                 Transaction tx = jedis.multi();// 开启事务
               // tx.incr("watchkeys");
                tx.incrBy("watchkeys", -1);
 
                List<Object> list = tx.exec();// 提交事务,如果此时watchkeys被改动了,则返回null
                 
                if (list == null ||list.size()==0) {
 
                    String failuserifo = "fail"+userinfo;
                    String failinfo="用户:" + failuserifo + "商品争抢失败,抢购失败";
                    System.out.println(failinfo);
                    /* 抢购失败业务逻辑 */
                    jedis.setnx(failuserifo, failinfo);
                } else {
                    for(Object succ : list){
                         String succuserifo ="succ"+succ.toString() +userinfo ;
                         String succinfo="用户:" + succuserifo + "抢购成功,当前抢购成功人数:"
                                 + (1-(valint-100));
                         System.out.println(succinfo);
                         /* 抢购成功业务逻辑 */
                         jedis.setnx(succuserifo, succinfo);
                    }
                }

            } else {
                String failuserifo ="kcfail" +  userinfo;
                String failinfo1="用户:" + failuserifo + "商品被抢购完毕,抢购失败";
                System.out.println(failinfo1);
                jedis.setnx(failuserifo, failinfo1);
                //Thread.sleep(500);
                return;
            }
 
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            jedis.close();
        } 
    }          
}

MyRedistest.java:主方法

package seckill3;

import redis.clients.jedis.Jedis;

import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author : xsh
 * @create : 2020-01-03 - 14:32
 * @describe:
 */
public class MyRedistest {
   public static void main(String[] args) {
        final String watchkeys = "watchkeys";
        ExecutorService executor = Executors.newFixedThreadPool(20); //20个线程池并发数

        final Jedis jedis = new Jedis("127.0.0.1", 6379);
        jedis.set(watchkeys, "100");//设置起始的抢购数
        // jedis.del("setsucc", "setfail");
        jedis.close();
        for (int i = 0; i < 1000; i++) {//设置1000个人来发起抢购
            executor.execute(new MyRunnable("user"+getRandomString(6)));
        }
        executor.shutdown();
}

/*根据长度随机生成对应长度的字母串*/
public static String getRandomString(int length) { //length是随机字符串长度
       String base = "abcdefghijklmnopqrstuvwxyz0123456789";
       Random random = new Random();
       StringBuffer sb = new StringBuffer();
       for (int i = 0; i < length; i++) {
            int number = random.nextInt(base.length());
            sb.append(base.charAt(number));
        }
        return sb.toString();
    }
}

运行结果:

且数据成功存入Redis:

案例四:综合案例(常用)

本例采用异步方式记录交易log表,之所以要插入此log表,是为了方便统计最终商品交易的成功数、失败数。所以需引入mysql驱动包。

    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>2.9.0</version>
    </dependency>

    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>5.1.47</version>
    </dependency>

需创建test数据库,在test数据库内创建t_buy表存放购买结果,表内有两个字段:user(varchar),result(int)

MyJedisPool.java

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

/**
 * @author : xsh
 * @describe: Redis客户端pool的实现
 */
public class MyJedisPool {

    private static JedisPool pool;

    static {
        JedisPoolConfig config = new JedisPoolConfig();
        // 设置的逐出策略类名, 默认DefaultEvictionPolicy(当连接超过最大空闲时间,或连接数超过最大空闲连接数)
        config.setEvictionPolicyClassName("org.apache.commons.pool2.impl.DefaultEvictionPolicy");
        // 最大连接数
        config.setMaxTotal(8);
        // 最大空闲连接数
        config.setMaxIdle(8);
        // 获取连接时的最大等待毫秒数(如果设置为阻塞时BlockWhenExhausted),如果超时就抛异常, 小于零:阻塞不确定的时间,
        // 默认-1
        config.setMaxWaitMillis(-1);
        // 是否启用后进先出,默认true
        config.setLifo(true);
        // 最小空闲连接数, 默认0
        config.setMinIdle(0);
        // 每次逐出检查时 逐出的最大数目 如果为负数就是 : 1/abs(n), 默认3
        config.setNumTestsPerEvictionRun(3);
        // 对象空闲多久后逐出, 当空闲时间>该值 且 空闲连接>最大空闲数
        // 时直接逐出,不再根据MinEvictableIdleTimeMillis判断 (默认逐出策略)
        config.setSoftMinEvictableIdleTimeMillis(1800000);
        // 在获取连接的时候检查有效性, 默认false
        config.setTestOnBorrow(false);
        // 在空闲时检查有效性, 默认false
        config.setTestWhileIdle(false);
        // 逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
        config.setTimeBetweenEvictionRunsMillis(-1);

        pool = new JedisPool(config, "localhost");
    }

    public static Jedis getJedis() {
        return pool.getResource();
    }

    /** 归还jedis对象 */
    public static void recycleJedisOjbect(Jedis jedis) {
        jedis.close();
    }
}

Trade.java

/**
 * @author : xsh
 * @describe: 交易记录数据模型
 */
public class Trade {
    private String user;
    private int result;
    public int getResult() {
        return result;
    }
    public void setResult(int result) {
        this.result = result;
    }
    public String getUser() {
        return user;
    }
    public void setUser(String user) {
        this.user = user;
    }
}

LogManager.java

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.concurrent.LinkedBlockingQueue;

/**
 * @author : xsh
 * @describe: 异步记录交易Log的服务
 */
public class LogManager implements Runnable {
    private static LinkedBlockingQueue<Trade> list = new LinkedBlockingQueue<Trade>();
    private static String url="jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=UTF-8";
    private static Connection conn;

    static {
        try {
            Class.forName("com.mysql.jdbc.Driver");
            conn = DriverManager.getConnection(url, "root","123456");
        } catch (ClassNotFoundException | SQLException e) {
            e.printStackTrace();
        }

        new Thread(new LogManager()).start();
    }

    public static void addLog(Trade log) {
        list.add(log);
    }

    @Override
    public void run() {
        while(true) {
            Trade trade = null;
            try {
                trade = list.take();
                log(trade);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void log(Trade trade) {
        String sql = "insert into t_buy (user, result) values(?, ?)";
        try {
            PreparedStatement pst = conn.prepareStatement(sql);
            pst.setString(1, trade.getUser());
            pst.setInt(2, trade.getResult());
            pst.execute();
        } catch(SQLException e) {
            e.printStackTrace();
        }
    }
}

FlashSaleTest.java

import redis.clients.jedis.Jedis;
import redis.clients.jedis.Transaction;

import java.util.List;

/**
 * @author : xsh
 * @describe:  抢购模拟
 */
public class FlashSaleTest {
    private static String KEY = "COUNT";
    private int userCount;
    private int interval;

    /**
     * @param totalItemCount 商品总数
     * @param userCount 模拟用户数
     * @param interval 用户采购间隔(毫秒)
     */
    public FlashSaleTest(int totalItemCount, int userCount, int interval) {
        this.userCount = userCount;
        this.interval = interval;
        Jedis jedis = MyJedisPool.getJedis();
        jedis.set(KEY, "" + totalItemCount);
        MyJedisPool.recycleJedisOjbect(jedis);
    }

    public void start() {
        for(int i=0; i<userCount; i++) {
            Thread tt = new UserThread("Thread" + i);
            tt.start();
            try {
                Thread.sleep(interval);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private static int buy() {
        Jedis jedis = MyJedisPool.getJedis();
        jedis.watch(KEY);
        int value = Integer.valueOf(jedis.get(KEY)).intValue();
        int result;
        if(value > 0) {
            Transaction tx = jedis.multi();
            tx.decr(KEY);
            List<Object> res = tx.exec();
            if(res.size() == 0) {
                result = 1; // 失败
            } else {
                result = 0; // 成功
            }
        } else {
            result = 2;  // 已售完
        }
        MyJedisPool.recycleJedisOjbect(jedis);
        return result;
    }

    static class UserThread extends Thread {
        private String user = null;
        public UserThread(String user) {
            this.user = user;
        }
        @Override
        public void run() {
            int result = buy();
            Trade trade = new Trade();
            trade.setUser(this.user);
            trade.setResult(result);
            LogManager.addLog(trade);
            System.out.println("user(" + user + ") result(" + result + ")");
        }
    }

    public static void main(String[] args) {
        FlashSaleTest test = new FlashSaleTest(100, 200, 100);
        test.start();
    }
}

运行FlashSaleTest.java中的main方法,并查看数据库:

select result, count(*) from t_buy group by result;
resultcount(*)
0100
14
296

可以看到:

  • 最终成功100件,和商品总数一致。所有商品被抢购完了,且没有发生“超发”
  • 抢购中失败4次,即抢购提交中,数据已经被其它线程更改,因此失败
  • 其它96次失败,是商品已经售罄
redis
  • 作者:管理员(联系作者)
  • 发表时间:2020-01-04 02:04
  • 版权声明:自由转载-非商用-非衍生-保持署名(null)
  • undefined
  • 评论