商品秒杀系统-秒杀模块的开发【https://github.com/XCXCXCXCX/KillSystem】

在开始设计秒杀模块的时候由于对一些知识了解还不够,设计的比较复杂,想把秒杀思路改变的心路历程都记录下来。

一、秒杀思路变形记

最初思路:

    三层秒杀

    1.在tomcat维护管理每个商品库存的线程,商品库存为0后撤销线程

    2.在redis记录库存量和订单信息

    3.在mysql记录库存量和订单信息

每次发起抢购请求,在redis写入订单信息并且在tomcat维护的库存线程上进行减一的原子操作

在后台开启一个线程,定期将原子计数器中的库存数和redis中的支付成功的订单信息加入到FIFO队列写入mysql

预计,

优点:

服务器响应速度快,只要redis写入成功且原子计数器减一即返回成功

缺点:

1.开发上稍麻烦 

2.一旦tomcat上的线程阻塞或死亡用户将无法抢购

3.如果抢购的商品过多会维护过多的线程,占用系统资源导致崩溃

4.原子计数器是采用CAS操作,当多个的线程进行操作时失败操作会增多,而单线程操作处理速度又有局限性

5.组件之间的联系很重要,比如新库存数写入mysql和订单信息写入mysql本应是合并事务,如果其中一个线程出现问题都会导致数据不一致

后期思路:

    两层秒杀

    1.在redis记录库存量和订单信息

    2.在mysql持久化库存量和订单信息

每次发起抢购请求,在redis中创建订单并令库存减一,支付成功后将订单信息、库存减一合并为事务操作后加入到FIFO队列

优点:

1.开发思路上组件清晰

2.redis是单线程,能保证操作原子性,且处理速度非常可观

缺点:

1.redis的连接量有限,每次在redis中的操作开启连接和关闭连接要造成许多开销

2.如果数据写入mysql失败,将会丢失

二、后台开发

不多说直接贴代码,注释的很清楚了,controller和InitFIFOListener的具体实现如下:

    1.controller层代码

@RequestMapping("/createOrder.do")
	@ResponseBody
	public ServerResponse createOrder(Order order,HttpServletRequest request,HttpSession session) {
		if (session.getAttribute("tel_num") == null || session.getAttribute("passwd") == null) {
			return ServerResponse.createByErrorCodeMessage(ResponseCode.NEED_LOGIN.getCode(), "用户登录已过期");
		}
		
		//1.一个ip5分钟内只接受一次请求 
		//ps.暂未编码,编码基本思路是在Spring管理一个拦截器,重写preHandler接口
		//由于会影响到测试效果,先注释掉
		
		//2.判断商品是否存在
		Goods goods = new Goods();
		goods.setGoods_id(order.getGoods_id());
		if(goodsService.getGoodsById(goods)==null) {
			return ServerResponse.createByErrorMessage("商品不存在!");
		}
		//3. 一个用户不允许购买同一商品多次(在mysql中检查)
		//ps:在redis中的检查已经封装到createOrderInRedis方法中
		//由于会影响到测试效果,先注释掉
		/*
		String tel_num = (String)session.getAttribute("tel_num");
		int goods_id = order.getGoods_id();
		if(orderService.orderIsExist(tel_num,goods_id)) {
			return ServerResponse.createByErrorMessage("您已抢购过该商品!请稍后再试!");
		}
		*/
		//使用MD5算法加密用户信息和当前时间的字符串,构造不重复order_id
		//(1)防止生成相同的订单号导致的创建订单失败
		//(2)避免使用自增id,自增id能产生的订单数有局限性
		String order_id = MD5Util.MD5EncodeUtf8(order.getTel_num()+order.getGoods_id()+DateTime.now().toString());
		order.setOrder_id(order_id);
		//4.如果该订单关联商品库存为0,返回“商品已抢光”
		//否则继续进行
		if(!goodsService.checkGoodsStockInRedis(order)) {
			return ServerResponse.createByErrorMessage("商品库存已抢光!秒杀接口已关闭!");
		}
		//5.检查是否存在该订单
		if(orderService.orderIsExistInRedis(order)) {
			log.error("订单已经存在!");
			return ServerResponse.createByErrorMessage("订单已经存在!");
		}
		
		//6.执行创建订单操作
		try {
			
			//6.1.在redis中创建订单,若失败,返回“创建订单失败”
			if(orderService.createOrderInRedis(order) == 0) {
				log.error("订单已经存在!");
				return ServerResponse.createByErrorMessage("订单已经存在!");
			}
			
			//6.2.在redis中库存减一,若失败,撤回创建订单操作并返回“创建订单失败”
			if(goodsService.decrGoodsStock(order) == 0) {
				log.error("库存已抢光!");
				return ServerResponse.createByErrorMessage("库存已抢光!");
			}
			
		} catch (Exception e) {
			// TODO: handle exception
			log.error("redis中创建订单失败!", e);
			return ServerResponse.createByErrorMessage("创建订单失败!");
		}
		//创建定时器对象
        //Timer t=new Timer();
        //在3秒后执行MyTask类中的run方法
        //t.schedule(new MyTask(order), 360000);
		System.out.println("创建订单成功!");
		return ServerResponse.createBySuccess("创建订单成功!", order);
		
	}

其中注释的代码是之前用来计时判断创建订单操作是否失效,后来想到在redis有设置失效时间的操作,效果好太多

ps:这里给自己再提个醒!!这样启动计时器方法的非常不好,每次请求都会开启一个长时间存在的线程,可想而知,效果有多差了吧。

    2.goodsService中的相关方法

    goodsService接口类

public interface GoodsService extends BaseService{
	PageInfo> select(Goods goods,int pageNum,int pageSize);
	PageInfo> selectById(Goods goods,int pageNum,int pageSize);
	Goods getGoodsById(Goods goods);
	long decrGoodsStock(Order order);
	long incrGoodsStock(Order order);
	boolean checkGoodsStockInRedis(Order order);
	int countGoods();
}

 

    goodsService实现类

 

	//redis操作:库存减一
	@Override
	public long decrGoodsStock(Order order) {
		return goodsDao.setGoodsStock(order);
		
	}

	//redis操作:库存加一
	@Override
	public long incrGoodsStock(Order order) {
		// TODO Auto-generated method stub
		return goodsDao.setBackGoodsStock(order);
	}

	//redis操作:检查库存是否小于等于0
	@Override
	public boolean checkGoodsStockInRedis(Order order) {
		// TODO Auto-generated method stub
		return goodsDao.checkGoodsStockInRedis(order);
	}

 

    goodsDao接口类

 

public interface GoodsDao extends BaseDao{

	List> selectById(Goods goods);

	Goods getGoodsById(Goods goods);

	long setGoodsStock(Order order);

	long setBackGoodsStock(Order order);

	boolean checkGoodsStockInRedis(Order order);

	void initGoodsStock();

	int updateGoodsStockBack(Goods goods);

	int updateGoodsStock(Goods goods);

	int countGoods();

}

 

    goodsDao实现类

 

//redis操作:库存减一
	@Override
	public long setGoodsStock(Order order) {
		try {
			jedis = JedisUtil.getConn();
			return jedis.decr(String.valueOf(order.getGoods_id()));
		} finally {
//			System.out.println("getNumActive:"+JedisPoolManager.INSTANCE.getJedisPool().getNumActive());
//			System.out.println("getNumIdle:"+JedisPoolManager.INSTANCE.getJedisPool().getNumIdle());
//			System.out.println("getNumWaiters:"+JedisPoolManager.INSTANCE.getJedisPool().getNumWaiters());
			if(jedis == null) {
				jedis.close();				
			}else {
				JedisUtil.returnConn(jedis);
			}
			
		}
	}
	
	//redis操作:撤回库存减一
	@Override
	public long setBackGoodsStock(Order order) {
		try {
			jedis = JedisUtil.getConn();
			return jedis.incr(String.valueOf(order.getGoods_id()));
		} finally {
			if(jedis == null) {
				jedis.close();
			}else {
				JedisUtil.returnConn(jedis);
			}
			
		}
	}
	
	//初始化redis中的默认库存数为mysql中的库存数
	@Override
	public void initGoodsStock() {
		try {
			jedis = JedisUtil.getConn();
			for(int i = 1;i < 100;i++) {
				Goods goods = new Goods();
				goods.setGoods_id(i);
				goods = getGoodsById(goods);
				if(goods != null) {
					jedis.setnx(i + "", goods.getGoods_stock() + "");
				}
			}
		} finally {
			if(jedis == null) {
				jedis.close();
			}else {
				JedisUtil.returnConn(jedis);
			}
			
		}
	}

	//redis操作:检查商品库存是否小于等于0
	@Override
	public boolean checkGoodsStockInRedis(Order order) {
		try {
			jedis = JedisUtil.getConn();
			return "0".equals(jedis.get(order.getOrder_id())) ? false : true;
		} finally {
			if(jedis == null) {
				jedis.close();
			}else {
				JedisUtil.returnConn(jedis);
			}
			
		}
	}

 

    3.orderService中的相关方法

 

 

    orderService接口类

public interface OrderService extends BaseService{
	int updateOrderState(Order order);
	PageInfo> select(Order order,int pageNum,int pageSize);
	long createOrderInRedis(Order order);
	long createPayInRedis(Order order);
	String updateOrderPayInRedis(Order order);
	boolean orderIsExist(Order order);
	boolean orderIsExistInRedis(Order order);
	String getPayState(Order order);
	ServerResponse pay(Order order,String path);
	ServerResponse aliCallback(Map params);
	boolean orderIsExist(String tel_num, int goods_id);
}

 

    orderService实现类

 

@Override
	public long createOrderInRedis(Order order) {
		// TODO Auto-generated method stub
		return orderDao.createOrderInRedis(order);
	}

@Override
	public boolean orderIsExistInRedis(Order order) {
		// TODO Auto-generated method stub
		return orderDao.orderIsExistInRedis(order);
	}

 

    orderDao接口类

 

public interface OrderDao extends BaseDao{

	int updateOrderState(Order order);

	long createOrderInRedis(Order order);

	long createPayInRedis(Order order);

	String updateOrderPayInRedis(Order order);

	boolean orderIsExist(Order order);

	boolean orderIsExistInRedis(Order order);

	String getPayState(Order order);

	int createOrder(Order order);

	Order selectByorderIdInRedis(Order order);

	boolean createOrderAndupdateGoodsStock(Order order, Goods goods);
	
}

 

    orderDao实现类

 

@Override
	public boolean orderIsExistInRedis(Order order) {
		try {
			jedis = JedisUtil.getConn();
			System.out.println(jedis.get(order.getOrder_id()));
			return jedis.get(order.getOrder_id()) == null ? false : true;
		}finally {
			if(jedis == null) {
				jedis.close();
			}else {
				JedisUtil.returnConn(jedis);
			}
		}
	}

@Override
	public long createOrderInRedis(Order order) {
		try {
			jedis = JedisUtil.getConn();
			//在redis中查找,如果该用户购买过该商品,则创建订单失败
			if (jedis.setnx(order.getTel_num()+","+order.getGoods_id(), order.getOrder_id()) == 0){
				return 0;
			}
			return jedis.setnx(order.getOrder_id(), order.getTel_num() + "," + order.getAddress() + ","
					+ order.getGoods_id() + "," + DateTime.now().toString("YYYY-MM-dd HH-mm-ss"));
		}finally {
			if(jedis.expire(order.getTel_num()+","+order.getGoods_id(), 300)!=1) {
				log.error("设置超时时间失败!");
			}
			if(jedis.expire(order.getOrder_id(), 300)!=1) {
				log.error("设置超时时间失败!");
			}
			if(jedis == null) {
				jedis.close();
			}else {
				JedisUtil.returnConn(jedis);
			}
			
		}
	}

 

    4.MD5Util工具类

 

public class MD5Util {

    private static String byteArrayToHexString(byte b[]) {
        StringBuffer resultSb = new StringBuffer();
        for (int i = 0; i < b.length; i++)
            resultSb.append(byteToHexString(b[i]));

        return resultSb.toString();
    }

    private static String byteToHexString(byte b) {
        int n = b;
        if (n < 0)
            n += 256;
        int d1 = n / 16;
        int d2 = n % 16;
        return hexDigits[d1] + hexDigits[d2];
    }

    /**
     * 返回大写MD5
     *
     * @param origin
     * @param charsetname
     * @return
     */
    private static String MD5Encode(String origin, String charsetname) {
        String resultString = null;
        try {
            resultString = new String(origin);
            MessageDigest md = MessageDigest.getInstance("MD5");
            if (charsetname == null || "".equals(charsetname))
                resultString = byteArrayToHexString(md.digest(resultString.getBytes()));
            else
                resultString = byteArrayToHexString(md.digest(resultString.getBytes(charsetname)));
        } catch (Exception exception) {
        }
        return resultString.toUpperCase();
    }

    public static String MD5EncodeUtf8(String origin) {
    	//加入自己设置的passwordsalt
        origin = origin + PropertiesUtil.getProperty("password.salt", "");
        return MD5Encode(origin, "utf-8");
    }


    private static final String hexDigits[] = {"0", "1", "2", "3", "4", "5",
            "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"};

}

5.InitFIFOListener的实现

前文提到的FIFO队列写入mysql

当支付成功时,将order对象加入到InitFIFOListener中的queue中,自动处理

配置在druid-db.xml

   
 	  
 	 

 

InitFIFOListener实现类

 

/**
 * @author xcxcxcxcx
 * 
 * @Comments
 * 先进先出队列
 * 启动先进先出队列,并反复在队列中poll出order对象,
 * 如果order不为空,向mysql创建该order并令库存减一
 * 
 * 使用事务管理实现创建订单、库存减一的事务
 * 
 * 2018年4月5日
 *
 */
@Repository
public class InitFIFOListener{
	
	private static  final Logger log = LoggerFactory.getLogger(InitFIFOListener.class);
	
	@Autowired
	private OrderDao orderDao;
	
	@Autowired
	private GoodsDao goodsDao;
	
	public static ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue();
	
	
	void initMethod() {
		goodsDao.initGoodsStock();
		new Thread(new Runnable() {

			@Override
			public void run() {
				//消费者线程
				// TODO Auto-generated method stub
				while(true) {
					Order order = InitFIFOListener.queue.poll();
					boolean toContinue = true;
					if(order == null) {
						toContinue = false;
					}
					if(!toContinue) {
						continue;
					}
					log.info("FIFO队列开始处理一个请求了!");
					log.info("address:"+order.getAddress()+",createTime:"+order.getCreate_time()+",goods_id:"+order.getGoods_id()+",order_id:"+order.getOrder_id()+",tel_num:"+order.getTel_num());
					
					Goods goods = new Goods();
					goods.setGoods_id(order.getGoods_id());
					
					//开启事务回滚
					orderDao.createOrderAndupdateGoodsStock(order,goods);
					
				}
			}
			
		}).start();
	}
}

createOrderAndupdateGoodsStock(order,goods);

看一下这个实现代码

//开启事务注解
	//mysql操作:创建订单
	@Transactional
	@Override
	public int createOrder(Order order)  {
		// TODO Auto-generated method stub
		try {
			return orderMapper.createOrder(order);
		}catch (RuntimeException e) {
			log.error("mysql创建订单"+order.getOrder_id()+"失败"+":goods_id="+order.getGoods_id()+",tel_num="+order.getTel_num()+",address="+order.getAddress());
			goodsDao.setBackGoodsStock(order);
			throw e;
		}
	}
	
	//开启事务注解
	//mysql操作:创建订单并令库存减一
	@Transactional
	@Override
	public boolean createOrderAndupdateGoodsStock(Order order,Goods goods) {
		// TODO Auto-generated method stub
		try {
			//在mysql中写入order信息
			createOrder(order);
			//初始化分布式锁
			RedisLock lock = new RedisLock(String.valueOf(order.getGoods_id()), 10000, 20000);
			//加锁
			try {
			    if(lock.lock()) {
			    	//令mysql中的库存减一
			    	goodsDao.updateGoodsStock(goods);
			    }
			}catch (InterruptedException e) {
			    e.printStackTrace();
			}finally {
			    //为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做DEL操作,因为可能客户端因为某个耗时的操作而挂起,
			    //操作完的时候锁因为超时已经被别人获得,这时就不必解锁了。 ————这里没有做
			    lock.unlock();
			}
			
		}catch(RuntimeException e) {
			log.error("mysql创建订单并库存减一事务操作失败!订单号:{},商品号:{}",order.getOrder_id(),order.getGoods_id());
		}
		return true;
	}

 

//开启事务注解
//mysql操作:库存减一
@Transactional
	@Override
	public int updateGoodsStock(Goods goods){
		
		try {
			return goodsMapper.updateGoodsStock(goods);
		}catch (RuntimeException e) {
			log.error("mysql更新库存失败"+goods.getGoods_stock());
			throw e;
		}
		
	}

当mysql进行库存减一操作时使用了RedisLock,因为最初是想搭建两个tomcatWEB服务器,那么就会有两个InitFIFOListener同时操作mysql,而在mysql中进行库存减一操作时是存在竞争条件的,所以在这里增加了RedisLock,实现代码结合了别人博客思路。

public class RedisLock {

    private static Logger log = LoggerFactory.getLogger(RedisLock.class);

    private Jedis jedis;

    private static final int DEFAULT_ACQUIRY_RESOLUTION_MILLIS = 100;

    /**
     * Lock key path.
     */
    private String lockKey;

    /**
     * 锁超时时间,防止线程在入锁以后,无限的执行等待
     */
    private int expireMsecs = 60 * 1000;

    /**
     * 锁等待时间,防止线程饥饿
     */
    private int timeoutMsecs = 10 * 1000;

    private volatile boolean locked = false;

    /**
     * Detailed constructor with default acquire timeout 10000 msecs and lock expiration of 60000 msecs.
     *
     * @param lockKey lock key (ex. account:1, ...)
     */
    public RedisLock(String lockKey) {
        this.lockKey = lockKey + "_lock";
    }

    /**
     * Detailed constructor with default lock expiration of 60000 msecs.
     *
     */
    public RedisLock(String lockKey, int timeoutMsecs) {
        this(lockKey);
        this.timeoutMsecs = timeoutMsecs;
    }

    /**
     * Detailed constructor.
     *
     */
    public RedisLock(String lockKey, int timeoutMsecs, int expireMsecs) {
        this(lockKey, timeoutMsecs);
        this.expireMsecs = expireMsecs;
    }

    /**
     * @return lock key
     */
    public String getLockKey() {
        return lockKey;
    }

    private String get(final String key) {
    	String obj = null;
        try {
        	jedis = JedisUtil.getConn();
        	obj = jedis.get(key);
        } catch (Exception e) {
        	jedis.close();
            log.error("get redis error, key : {}", key);
        } finally {
        	jedis.close();
        }
        return obj != null ? obj : null;
    }

    private boolean setNX(final String key, final String value) {
    	long obj = 0;
    	try {
    		jedis = JedisUtil.getConn();
        	obj = jedis.setnx(key, value);
        } catch (Exception e) {
        	jedis.close();
            log.error("setNX redis error, key : {}", key);
        }finally {
        	jedis.close();
        }
        return obj == 1 ? true : false;
    }

    private String getSet(final String key, final String value) {
        String obj = null;
        try {
        	jedis = JedisUtil.getConn();
        	obj = jedis.getSet(key, value);
        } catch (Exception e) {
        	jedis.close();
            log.error("setNX redis error, key : {}", key);
        }finally {
        	jedis.close();
        }
        
        return obj != null ? obj : null;
    }

    /**
     * 获得 lock.
     * 实现思路: 主要是使用了redis 的setnx命令,缓存了锁.
     * reids缓存的key是锁的key,所有的共享, value是锁的到期时间(注意:这里把过期时间放在value了,没有时间上设置其超时时间)
     * 执行过程:
     * 1.通过setnx尝试设置某个key的值,成功(当前没有这个锁)则返回,成功获得锁
     * 2.锁已经存在则获取锁的到期时间,和当前时间比较,超时的话,则设置新的值
     *
     * @return true if lock is acquired, false acquire timeouted
     * @throws InterruptedException in case of thread interruption
     */
    public synchronized boolean lock() throws InterruptedException {
        int timeout = timeoutMsecs;
        while (timeout >= 0) {
            long expires = System.currentTimeMillis() + expireMsecs + 1;
            String expiresStr = String.valueOf(expires); //锁到期时间
            if (this.setNX(lockKey, expiresStr)) {
                // lock acquired
                locked = true;
                return true;
            }

            String currentValueStr = this.get(lockKey); //redis里的时间
            if (currentValueStr != null && Long.parseLong(currentValueStr) < System.currentTimeMillis()) {
                //判断是否为空,不为空的情况下,如果被其他线程设置了值,则第二个条件判断是过不去的
                // lock is expired

                String oldValueStr = this.getSet(lockKey, expiresStr);
                //获取上一个锁到期时间,并设置现在的锁到期时间,
                //只有一个线程才能获取上一个线上的设置时间,因为jedis.getSet是同步的
                if (oldValueStr != null && oldValueStr.equals(currentValueStr)) {
                    //防止误删(覆盖,因为key是相同的)了他人的锁——这里达不到效果,这里值会被覆盖,但是因为什么相差了很少的时间,所以可以接受

                    //[分布式的情况下]:如过这个时候,多个线程恰好都到了这里,但是只有一个线程的设置值和当前值相同,他才有权利获取锁
                    // lock acquired
                    locked = true;
                    return true;
                }
            }
            timeout -= DEFAULT_ACQUIRY_RESOLUTION_MILLIS;

            /*
                延迟100 毫秒,  这里使用随机时间可能会好一点,可以防止饥饿进程的出现,即,当同时到达多个进程,
                只会有一个进程获得锁,其他的都用同样的频率进行尝试,后面有来了一些进行,也以同样的频率申请锁,这将可能导致前面来的锁得不到满足.
                使用随机的等待时间可以一定程度上保证公平性
             */
            Thread.sleep(DEFAULT_ACQUIRY_RESOLUTION_MILLIS);

        }
        return false;
    }


    /**
     * Acqurired lock release.
     */
    public synchronized void unlock() {
        if (locked) {
        	jedis = JedisUtil.getConn();
            jedis.del(lockKey);
            jedis.close();
            locked = false;
        }
    }

}

 

项目详细代码请进入我的gitHub下载

有问题请指出,欢迎相互学习交流

 

 

 

###后记

回头看了一下以前的博客,发现一个编码上的问题

if(jedis == null) {
    //这里进行jedis.close()操作是存在问题的,jedis为空的情况下执行会报空指针
    //注释掉jedis.close(); 
}else {
    //由于高并发情况下jedis是共用的对象,由多个线程操作,应该考虑线程安全问题
    //即使在每次请求都返回了连接,也无法解决高并发情况下服务端报错的情况
	JedisUtil.returnConn(jedis);
}

            思路:
            
            采用Threadlocal维护一个线程独立的jedis连接对象,由于在服务端有线程最大数的限制,jedis        
         连接对象的数量限制最大不超过服务端线程最大数,不会存在连接数不足的情况,这样提高了jedis连             
         接的利用率,减少了jedis连接的创建、销毁的性能损耗。

            无论如何,jedis连接数的控制和jedis连接利用率的最优组合都需要经过实验来找到一个最优性能     
         的实践,在情况1.每次请求都创建和回收连接而生产出大量的jedis对象 与 情况2.每次请求都共用固    
         定数量的jedis连接而保证jedis的较高利用率 之间权衡。

你可能感兴趣的:(java学习笔记)