在开始设计秒杀模块的时候由于对一些知识了解还不够,设计的比较复杂,想把秒杀思路改变的心路历程都记录下来。
最初思路:
三层秒杀
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
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的较高利用率 之间权衡。