一、需求描述
秒杀活动是电子商务兴起后出现的一种新型的购物方式,通过网上APP、小程序等平台推出一些低于市场价格的商品,提升购买率的营销活动,所有买家在同一时间网上抢购的一种销售方式。对比其他的营销活动,秒杀限时性更强,抢购氛围更浓,可营造出一种不是所有人都能抢到的刺激感。
秒杀与限时促销功能的区别在于应用的场景不同,限时促销往往是被商家作为一种日常的促销(如我们商城版本的买两年送两年),而秒杀倾向于作为一种阶段性(限时感更强)的营销。同时,两种功能的营销效果不同,秒杀的作用是拉新促活,不是所有人都能抢到低价的商品,刺激客户及时下单购买,而限时促销呢,商家一般会提供充足的库存,基本上都能抢到,注重的是打造促销专场,吸引用户点击进入。
二、使用角色说明
运营人员:创建秒杀商品,创建秒杀活动,创建活动参与人数。
用户:抢购下单
三、关键功能点
1、秒杀活动商品库存数量set进入redis。
2、用户下单,使用redis redLock锁,判断商品库存是否还有。
3、如果库存还剩,为用户生成秒杀订单。
4、如果库存已购完,则抛异常,并关闭秒杀功能。
四、数据库设计
easy_product_seckill,秒杀活动表。记录开始时间,结束时间,秒杀商品,秒杀商品库存。
CREATE TABLE `easy_product_seckill` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '秒杀产品表id',
`product_id` bigint(20) unsigned NOT NULL COMMENT '商品id',
`image` varchar(255) NOT NULL COMMENT '推荐图',
`images` varchar(2000) NOT NULL COMMENT '轮播图',
`title` varchar(255) NOT NULL COMMENT '活动标题',
`info` varchar(255) NOT NULL COMMENT '简介',
`price` decimal(10,2) unsigned DEFAULT NULL COMMENT '价格',
`ot_price` decimal(10,2) unsigned DEFAULT NULL COMMENT '原价',
`give_integral` decimal(10,2) unsigned DEFAULT NULL COMMENT '返多少券积分',
`sort` int(10) unsigned NOT NULL COMMENT '排序',
`stock` int(10) unsigned NOT NULL COMMENT '库存',
`sales` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '销量',
`unit_name` varchar(16) NOT NULL COMMENT '单位名',
`description` text COMMENT '内容',
`start_time` date NOT NULL COMMENT '开始时间',
`stop_time` date NOT NULL COMMENT '结束时间',
`create_time` datetime NOT NULL COMMENT '添加时间',
`create_user_id` bigint(20) unsigned NULL COMMENT '添加人id',
`update_time` datetime DEFAULT NULL,
`update_user_id` bigint(20) unsigned NULL COMMENT '更新人id',
`status` tinyint(1) unsigned NOT NULL COMMENT '产品状态',
`is_del` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '删除 0未删除1已删除',
`num` int(11) unsigned NOT NULL COMMENT '最多秒杀几个',
`is_show` tinyint(1) unsigned NOT NULL DEFAULT '1' COMMENT '显示',
`time_id` int(10) unsigned DEFAULT '0' COMMENT '时间段id',
`spec_type` tinyint(1) DEFAULT NULL COMMENT '规格 0单 1多',
PRIMARY KEY (`id`) USING BTREE,
KEY `product_id` (`product_id`) USING BTREE,
KEY `start_time` (`start_time`,`stop_time`) USING BTREE,
KEY `is_del` (`is_del`) USING BTREE,
KEY `is_show` (`status`) USING BTREE,
KEY `add_time` (`create_time`) USING BTREE,
KEY `sort` (`sort`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC COMMENT='商品秒杀产品表';
五、核心代码
5.1 引入redis依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
5.2 配置redis
spring:
redis:
host: localhost
port: 6379
5.3 编写加锁和解锁的方法
package com.xx.xx.service;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* Created by on 2018/4/5.
*/
@Component
public class RedisLock {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 加锁
* @param key 商品id
* @param value 当前时间+超时时间
* @return
*/
public boolean lock(String key, String value) {
//这个其实就是setnx命令,只不过在java这边稍有变化,返回的是boolea
if (redisTemplate.opsForValue().setIfAbsent(key, value)) {
return true;
}
//避免死锁,且只让一个线程拿到锁
String currentValue = redisTemplate.opsForValue().get(key);
//如果锁过期了
if (!StringUtils.isEmpty(currentValue) && Long.parseLong(currentValue) < System.currentTimeMillis()) {
//获取上一个锁的时间
String oldValues = redisTemplate.opsForValue().getAndSet(key, value);
/*
只会让一个线程拿到锁
如果旧的value和currentValue相等,只会有一个线程达成条件,因为第二个线程拿到的oldValue已经和currentValue不一样了
*/
if (!StringUtils.isEmpty(oldValues) && oldValues.equals(currentValue)) {
return true;
}
}
return false;
}
/**
* 解锁
* @param key
* @param value
*/
public void unlock(String key, String value) {
try {
String currentValue = redisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) {
redisTemplate.opsForValue().getOperations().delete(key);
}
} catch (Exception e) {
logger.error("『redis分布式锁』解锁异常,{}", e);
}
}
}
为什么要有避免死锁的一步呢?
假设没有『避免死锁』这一步,结果在执行到下单代码的时候出了问题,毕竟操作数据库、网络、io的时候抛了个异常,这个异常是偶然抛出来的,就那么偶尔一次,那么会导致解锁步骤不去执行,这时候就没有解锁,后面的请求进来自然也或得不到锁,这就被称之为死锁。
而这里的『避免死锁』,就是给锁加了一个过期时间,如果锁超时了,就返回true,解开之前的那个死锁。
5.4 下单代码中引入加锁和解锁,确保只有一个线程操作
@Autowired
private RedisLock redisLock;
@Override
@Transactional
public String seckill(Integer id)throws RuntimeException {
//加锁
long time = System.currentTimeMillis() + 1000*10; //超时时间:10秒,最好设为常量
boolean isLock = redisLock.lock(String.valueOf(id), String.valueOf(time));
if(!isLock){
throw new RuntimeException("人太多了,换个时间再试试~");
}
//查秒杀库存
Product product = productMapper.findById(id);
if(storeSckill.getStock()==0){
throw new RuntimeException("已经卖光");
}
//写入订单表
Order order=new Order();
order.setProductId(product.getId());
order.setProductName(product.getName());
orderMapper.add(order);
//减库存
product.setPrice(null);
product.setName(null);
product.setStock(product.getStock()-1);
productMapper.update(product);
//解锁
redisLock.unlock(String.valueOf(id),String.valueOf(time));
return findProductInfo(id);
}
封装-----
@Autowired
private RedisTemplate redisTemplate;
/**
* @Title: lock
* @Description: 加锁机制
* @param @param lock 锁的名称
* @param @param expire 锁占有的时长(毫秒)
* @param @return 设定文件
* @return Boolean 返回类型
* @throws
*/
@SuppressWarnings("unchecked")
public Boolean lock(final String lock, final int expire) {
return (Boolean) redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
boolean locked = false;
byte[] lockValue = redisTemplate.getValueSerializer().serialize(DateUtil.getDateAddMillSecond(null, expire));
byte[] lockName = redisTemplate.getStringSerializer().serialize(lock);
locked = connection.setNX(lockName, lockValue);
if (locked) {
connection.expire(lockName, TimeoutUtils.toSeconds(expire, TimeUnit.MILLISECONDS));
}
return locked;
}
});
}
/**
* @Title: unDieLock
* @Description: 处理发生的死锁
* @param @param lock 是锁的名称
* @param @return 设定文件
* @return Boolean 返回类型
* @throws
*/
@SuppressWarnings("unchecked")
public Boolean unDieLock(final String lock) {
boolean unLock = false;
Date lockValue = (Date) redisTemplate.opsForValue().get(lock);
if (lockValue != null && lockValue.getTime() <= (System.currentTimeMillis())) {
redisTemplate.delete(lock);
unLock = true;
}
return unLock;
}
DateUtil类的方法
/**
* 日期相减(返回秒值)
* @param date Date
* @param date1 Date
* @return int
* @author
*/
public static Long diffDateTime(Date date, Date date1) {
return (Long) ((getMillis(date) - getMillis(date1))/1000);
}
public static long getMillis(Date date) {
Calendar c = Calendar.getInstance();
c.setTime(date);
return c.getTimeInMillis();
}
/**
* 获取 指定日期 后 指定毫秒后的 Date
* @param date
* @param millSecond
* @return
*/
public static Date getDateAddMillSecond(Date date, int millSecond) {
Calendar cal = Calendar.getInstance();
// 没有 就取当前时间
if (null != date) {
cal.setTime(date);
}
cal.add(Calendar.MILLISECOND, millSecond);
return cal.getTime();
}
新补充
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.TimeoutUtils;
import org.springframework.stereotype.Component;
import java.util.Calendar;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* @ClassName: LockRetry
* @Description: 此功能只用于促销组
* @date 2017年7月29日 上午11:54:54
*/
@SuppressWarnings("rawtypes")
@Component("lockRetry")
public class LockRetry {
private Logger logger = LoggerFactory.getLogger(getClass());
@Autowired
private RedisTemplate redisTemplate;
/**
* @param @param lock 名称
* @param @param expire 锁定时长(秒),建议10秒内
* @param @param num 取锁重试试数,建议不大于3
* @param @param interval 重试时长
* @param @param forceLock 强制取锁,不建议;
* @param @return
* @param @throws Exception 设定文件
* @return Boolean 返回类型
* @throws
* @Title: retry
* @Description: 重入锁
*/
@SuppressWarnings("unchecked")
public Boolean retryLock(final String lock, final int expire, final int num, final long interval, final boolean forceLock) throws Exception {
Date lockValue = (Date) redisTemplate.opsForValue().get(lock);
if (forceLock) {
RedisUtils.remove(lock);
}
if (num <= 0) {
if (null != lockValue && lockValue.getTime() >= (System.currentTimeMillis())) {
logger.debug(String.valueOf((lockValue.getTime() - System.currentTimeMillis())));
Thread.sleep(lockValue.getTime() - System.currentTimeMillis());
RedisUtils.remove(lock);
return retryLock(lock, expire, 1, interval, forceLock);
}
return false;
} else {
return (Boolean) redisTemplate.execute(new RedisCallback<Boolean>() {
@Override
public Boolean doInRedis(RedisConnection connection) throws DataAccessException {
boolean locked = false;
byte[] lockValue = redisTemplate.getValueSerializer().serialize(DateUtils.getDateAdd(null, expire, Calendar.SECOND));
byte[] lockName = redisTemplate.getStringSerializer().serialize(lock);
logger.debug(lockValue.toString());
locked = connection.setNX(lockName, lockValue);
if (locked) {
return connection.expire(lockName, TimeoutUtils.toSeconds(expire, TimeUnit.SECONDS));
} else {
try {
Thread.sleep(interval);
return retryLock(lock, expire, num - 1, interval, forceLock);
} catch (Exception e) {
e.printStackTrace();
return locked;
}
}
}
});
}
}
}
public class RedisUtils{
/**
* @param @param date
* @param @param millSecond
* @param @return 设定文件
* @return Date 返回类型
* @throws
* @Title: getDateAddMillSecond
* @Description: (TODO)取将来时间
*/
public static Date getDateAdd(Date date, int expire, int idate) {
Calendar calendar = Calendar.getInstance();
// 默认当前时间
if (null != date) {
calendar.setTime(date);
}
calendar.add(idate, expire);
return calendar.getTime();
}
/**
* 删除对应的value
*
* @param key
*/
public static void remove(final String key) {
if (exists(key)) {
stringRedisTemplate.delete(key);
}
}
/**
* 判断缓存中是否有对应的value
*
* @param key
* @return
*/
public static boolean exists(final String key) {
return stringRedisTemplate.hasKey(key);
}
private static StringRedisTemplate stringRedisTemplate = ((StringRedisTemplate) SpringContextHolder.getBean("stringRedisTemplate"));
}
六、自测&压测
详见今日头条。