更加详细的原理分析
代码实现原理请点此此处
启动项目时运行redis 本地服务
server:
port: 8080
spring:
datasource:
name: springboot
type: com.alibaba.druid.pool.DruidDataSource
#druid相关配置
druid:
#监控统计拦截的filters
filter: stat
#mysql驱动
driver-class-name: com.mysql.jdbc.Driver
#基本属性
url: jdbc:mysql://127.0.0.1:3306/skill?useUnicode=true&characterEncoding=UTF-8&allowMultiQueries=true&?zeroDateTimeBehavior=convertToNull
username: root
password: hzy
#配置初始化大小/最小/最大
initial-size: 1
min-idle: 1
max-active: 20
#获取连接等待超时时间
max-wait: 60000
#间隔多久进行一次检测,检测需要关闭的空闲连接
time-between-eviction-runs-millis: 60000
thymeleaf:
prefix: classpath:/templates/
check-template-location: true
suffix: .html
encoding: UTF-8
mode: LEGACYHTML5
cache: false
#文件上传相关设置
servlet:
multipart:
max-file-size: 10Mb
max-request-size: 100Mb
#devtools插件
devtools:
livereload:
enabled: true #是否支持livereload
port: 35729
restart:
enabled: true #是否支持热部署
#redis缓存
redis:
#redis数据库索引,默认是0
database: 0
#redis服务器地址,这里用本地的redis
host: 127.0.0.1
# Redis服务器连接密码(默认为空)
password:
#redis服务器连接端口,默认是6379
port: 6379
# 连接超时时间(毫秒)
timeout: 1000
jedis:
pool:
# 连接池最大连接数(使用负值表示没有限制)
max-active: 8
# 连接池最大阻塞等待时间(使用负值表示没有限制
max-wait: -1
# 连接池中的最大空闲连接
max-idle: 8
# 连接池中的最小空闲连接
min-idle: 0
#mybatis配置
mybatis:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: cn.tycoding.entity
configuration:
# 使用jdbc的getGeneratedKeys 可以获取数据库自增主键值
use-generated-keys: true
# 使用列别名替换列名,默认true。如:select name as title from table
use-column-label: true
# 开启驼峰命名转换,如:Table(create_time) -> Entity(createTime)。不需要我们关心怎么进行字段匹配,mybatis会自动识别`大写字母与下划线`
map-underscore-to-camel-case: true
# 打印sql
logging:
level:
cn.tycoding.mapper: DEBUG
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.0.5.RELEASEversion>
<relativePath/>
parent>
<properties>
<project.build.sourceEncoding>UTF-8project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8project.reporting.outputEncoding>
<java.version>1.8java.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-jdbcartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-thymeleafartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>1.3.2version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-devtoolsartifactId>
<scope>runtimescope>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<scope>runtimescope>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>druid-spring-boot-starterartifactId>
<version>1.1.9version>
dependency>
<dependency>
<groupId>redis.clientsgroupId>
<artifactId>jedisartifactId>
dependency>
<dependency>
<groupId>junitgroupId>
<artifactId>junitartifactId>
<version>4.12version>
<scope>testscope>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<version>1.18.8version>
dependency>
dependencies>
这里的实体类是根据两张表设计的,需要注意在编写实体类的时候要养成习惯继承Serializable接口。在SeckillOrder中我们注入了Seckill类作为一个属性,目的是为了可以使用多表查询的方式从seckill_order表中查询出来对应的seckill表数据。
Seckill.java
@Data
@ToString
public class Seckill implements Serializable {
private long seckillId; //商品ID
private String title; //商品标题
private String image; //商品图片
private BigDecimal price; //商品原价格
private BigDecimal costPrice; //商品秒杀价格
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime; //创建时间
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date startTime; //秒杀开始时间
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date endTime; //秒杀结束时间
private long stockCount; //剩余库存数量
}
SeckillOrder.java
@Data
@ToString
public class SeckillOrder implements Serializable {
private long seckillId; //秒杀到的商品ID
private BigDecimal money; //支付金额
private long userPhone; //秒杀用户的手机号
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date createTime; //创建时间
private boolean status; //订单状态, -1:无效 0:成功 1:已付款
private Seckill seckill; //秒杀商品,和订单是一对多的关系
}
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS `seckill`;
CREATE TABLE `seckill` (
`seckill_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '商品ID',
`title` varchar(1000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '商品标题',
`image` varchar(1000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '商品图片',
`price` decimal(10, 2) NULL DEFAULT NULL COMMENT '商品原价格',
`cost_price` decimal(10, 2) NULL DEFAULT NULL COMMENT '商品秒杀价格',
`stock_count` bigint(20) NULL DEFAULT NULL COMMENT '剩余库存数量',
`start_time` timestamp(0) NOT NULL DEFAULT '1970-02-01 00:00:01' COMMENT '秒杀开始时间',
`end_time` timestamp(0) NOT NULL DEFAULT '1970-02-01 00:00:01' COMMENT '秒杀结束时间',
`create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`seckill_id`) USING BTREE,
INDEX `idx_start_time`(`start_time`) USING BTREE,
INDEX `idx_end_time`(`end_time`) USING BTREE,
INDEX `idx_create_time`(`end_time`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '秒杀商品表' ROW_FORMAT = Dynamic;
INSERT INTO `seckill` VALUES (1, 'Apple/苹果 iPhone 6s Plus 国行原装苹果6sp 5.5寸全网通4G手机', 'https://g-search3.alicdn.com/img/bao/uploaded/i4/i3/2249262840/O1CN011WqlHkrSuPEiHxd_!!2249262840.jpg_230x230.jpg', 2600.00, 1100.00,1, '2018-10-06 16:30:00', '2019-11-17 16:30:00', '2019-11-16 11:30:24');
INSERT INTO `seckill` VALUES (2, 'ins新款连帽毛领棉袄宽松棉衣女冬外套学生棉服', 'https://gw.alicdn.com/bao/uploaded/i3/2007932029/TB1vdlyaVzqK1RjSZFzXXXjrpXa_!!0-item_pic.jpg_180x180xz.jpg', 200.00, 150.00, 10, '2018-10-06 16:30:00', '2019-11-17 16:30:00', '2019-11-16 11:30:24');
INSERT INTO `seckill` VALUES (3, '可爱超萌兔子毛绒玩具垂耳兔公仔布娃娃睡觉抱女孩玩偶大号女生 ', 'https://g-search3.alicdn.com/img/bao/uploaded/i4/i2/3828650009/TB22CvKkeOSBuNjy0FdXXbDnVXa_!!3828650009.jpg_230x230.jpg', 160.00, 130.00, 20, '2018-10-06 16:30:00', '2019-11-17 16:30:00', '2019-11-16 11:30:24');
DROP TABLE IF EXISTS `seckill_order`;
CREATE TABLE `seckill_order` (
`seckill_id` bigint(20) NOT NULL COMMENT '秒杀商品ID',
`money` decimal(10, 2) NULL DEFAULT NULL COMMENT '支付金额',
`user_phone` bigint(20) NOT NULL COMMENT '用户手机号',
`create_time` timestamp(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '创建时间',
`state` tinyint(4) NOT NULL DEFAULT -1 COMMENT '状态:-1无效 0成功 1已付款',
PRIMARY KEY (`seckill_id`, `user_phone`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '秒杀订单表' ROW_FORMAT = Dynamic;
SET FOREIGN_KEY_CHECKS = 1;
DAO层开发,即DAO层接口开发,主要设计需要和数据库交互的数据有哪些?应该用什么返回值类型接收查询到的数据?所以包含的方法有哪些?带着这些问题,我们先看一下秒杀系统的业务流程:
相对与本项目而言和数据库打交道的主要涉及两个操作:
1.减库存(秒杀商品表): 减少当前被秒杀到的商品的库存数量,这也是秒杀系统中一个处理难点的地方。实现减库存即count-1,但是我们需要考虑Mysql的事务特性引发的种种问题、需要考虑如何避免同一用户重复秒杀的行为。
2.记录购买明细(订单表)。
如果减库存的业务解决了那么记录购买明细的业务就相对简单很多了,我们需要记录购买用户的姓名、手机号、购买的商品ID等。因为本项目中不涉及支付功能,所以记录用户的购买订单的业务并不复杂。
SeckillMapper.java
@Mapper
@Repository
public interface SeckillMapper {
/**
* 查询所有秒杀商品的记录信息
*
* @return
*/
List<Seckill> findAll();
/**
* 根据主键查询当前秒杀商品的数据
*
* @param id
* @return
*/
Seckill findById(long id);
/**
* 减库存。
* 对于Mapper映射接口方法中存在多个参数的要加@Param()注解标识字段名称,不然Mybatis不能识别出来哪个字段相互对应
*
* @param seckillId 秒杀商品ID
* @param killTime 秒杀时间
* @return 返回此SQL更新的记录数,如果>=1表示更新成功
*/
int reduceStock(@Param("seckillId") long seckillId, @Param("killTime") Date killTime);
}
SeckillOrderMapper.java
@Mapper
@Repository
public interface SeckillOrderMapper {
/**
* 插入购买订单明细
*
* @param seckillId 秒杀到的商品ID
* @param money 秒杀的金额
* @param userPhone 秒杀的用户
* @return 返回该SQL更新的记录数,如果>=1则更新成功
*/
int insertOrder(@Param("seckillId") long seckillId, @Param("money") BigDecimal money, @Param("userPhone") long userPhone);
/**
* 根据秒杀商品ID查询订单明细数据并得到对应秒杀商品的数据,因为我们再SeckillOrder中已经定义了一个Seckill的属性
*
* @param seckillId
* @param userPhone
* @return
*/
SeckillOrder findById(@Param("seckillId") long seckillId, @Param("userPhone") long userPhone);
}
SeckillMapper.xml
<mapper namespace="com.master.mapper.SeckillMapper">
<select id="findAll" resultType="Seckill">
SELECT * FROM seckill
select>
<select id="findById" resultType="Seckill">
SELECT * FROM seckill WHERE seckill_id = #{id}
select>
<update id="reduceStock">
UPDATE seckill
SET stock_count = stock_count - 1
WHERE seckill_id = #{seckillId}
AND start_time <= #{killTime}
AND end_time >= #{killTime}
AND stock_count > 0
update>
mapper>
SeckillOrderMapper.xml
<mapper namespace="com.master.mapper.SeckillOrderMapper">
<insert id="insertOrder">
INSERT ignore INTO seckill_order(seckill_id, money, user_phone)
VALUES (#{seckillId}, #{money}, #{userPhone})
insert>
<select id="findById" resultType="SeckillOrder">
SELECT
so.seckill_id,
so.user_phone,
so.money,
so.create_time,
so.state,
s.seckill_id "seckill.seckill_id",
s.title "seckill.title",
s.cost_price "seckill.cost_price",
s.create_time "seckill.create_time",
s.start_time "seckill.start_time",
s.end_time "seckill.end_time",
s.stock_count "seckill.stock_count"
FROM seckill_order so
INNER JOIN seckill s ON so.seckill_id = s.seckill_id
WHERE so.seckill_id = #{seckillId} AND so.user_phone = #{userPhone}
select>
mapper>
设计业务层接口,应该站在使用者角度上设计:
1.定义业务方法的颗粒度要细。
2.方法的参数要明确简练,不建议使用类似Map这种类型,让使用者可以封装进Map中一堆参数而传递进来,尽量精确到哪些参数。
3.方法的return返回值,除了应该明确返回值类型,还应该指明方法执行可能产生的异常(RuntimeException),并应该手动封装一些通用的异常处理机制。
SeckillService.java
public interface SeckillService {
/**
* 获取所有的秒杀商品列表
*
* @return
*/
List<Seckill> findAll();
/**
* 获取某一条商品秒杀信息
*
* @param seckillId
* @return
*/
Seckill findById(long seckillId);
/**
* 秒杀开始时输出暴露秒杀的地址
* 否者输出系统时间和秒杀时间
*
* @param seckillId
*/
Exposer exportSeckillUrl(long seckillId);
/**
* 执行秒杀的操作
*
* @param seckillId
* @param userPhone
* @param money
* @param md5
*/
SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException;
}
SeckillServiceImpl.java
@Service
public class SeckillServiceImpl implements SeckillService {
private Logger logger = LoggerFactory.getLogger(this.getClass());
//设置盐值字符串,随便定义,用于混淆MD5值
private final String salt = "sjajaspu-i-2jrfm;sd";
//设置秒杀redis缓存的key
private final String key = "seckill";
@Autowired
private SeckillMapper seckillMapper;
@Autowired
private SeckillOrderMapper seckillOrderMapper;
@Autowired
private RedisTemplate redisTemplate;
@Override
public List<Seckill> findAll() {
List<Seckill> seckillList = redisTemplate.boundHashOps("seckill").values();
if (seckillList == null || seckillList.size() == 0){
//说明缓存中没有秒杀列表数据
//查询数据库中秒杀列表数据,并将列表数据循环放入redis缓存中
seckillList = seckillMapper.findAll();
for (Seckill seckill : seckillList){
//将秒杀列表数据依次放入redis缓存中,key:秒杀表的ID值;value:秒杀商品数据
redisTemplate.boundHashOps(key).put(seckill.getSeckillId(), seckill);
logger.info("findAll -> 从数据库中读取放入缓存中");
}
}else{
logger.info("findAll -> 从缓存中读取");
}
return seckillList;
}
@Override
public Seckill findById(long seckillId) {
return seckillMapper.findById(seckillId);
}
@Override
public Exposer exportSeckillUrl(long seckillId) {
Seckill seckill = (Seckill) redisTemplate.boundHashOps(key).get(seckillId);
if (seckill == null) {
//说明redis缓存中没有此key对应的value
//查询数据库,并将数据放入缓存中
seckill = seckillMapper.findById(seckillId);
if (seckill == null) {
//说明没有查询到
return new Exposer(false, seckillId);
} else {
//查询到了,存入redis缓存中。 key:秒杀表的ID值; value:秒杀表数据
redisTemplate.boundHashOps(key).put(seckill.getSeckillId(), seckill);
logger.info("RedisTemplate -> 从数据库中读取并放入缓存中");
}
} else {
logger.info("RedisTemplate -> 从缓存中读取");
}
Date startTime = seckill.getStartTime();
Date endTime = seckill.getEndTime();
//获取系统时间
Date nowTime = new Date();
if (nowTime.getTime() < startTime.getTime() || nowTime.getTime() > endTime.getTime()) {
return new Exposer(false, seckillId, nowTime.getTime(), startTime.getTime(), endTime.getTime());
}
//转换特定字符串的过程,不可逆的算法
String md5 = getMD5(seckillId);
return new Exposer(true, md5, seckillId);
}
//生成MD5值
private String getMD5(Long seckillId) {
String base = seckillId + "/" + salt;
String md5 = DigestUtils.md5DigestAsHex(base.getBytes());
return md5;
}
/**
* 使用注解式事务方法的有优点:开发团队达成了一致约定,明确标注事务方法的编程风格
* 使用事务控制需要注意:
* 1.保证事务方法的执行时间尽可能短,不要穿插其他网络操作PRC/HTTP请求(可以将这些请求剥离出来)
* 2.不是所有的方法都需要事务控制,如只有一条修改的操作、只读操作等是不需要进行事务控制的
*
* Spring默认只对运行期异常进行事务的回滚操作,对于编译异常Spring是不进行回滚的,所以对于需要进行事务控制的方法尽可能将可能抛出的异常都转换成运行期异常
*/
@Override
@Transactional
public SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException {
if (md5 == null || !md5.equals(getMD5(seckillId))) {
throw new SeckillException("seckill data rewrite");
}
//执行秒杀逻辑:1.减库存;2.储存秒杀订单
Date nowTime = new Date();
try {
//记录秒杀订单信息
int insertCount = seckillOrderMapper.insertOrder(seckillId, money, userPhone);
//唯一性:seckillId,userPhone,保证一个用户只能秒杀一件商品
if (insertCount <= 0) {
//重复秒杀
throw new RepeatKillException("seckill repeated");
} else {
//减库存
int updateCount = seckillMapper.reduceStock(seckillId, nowTime);
if (updateCount <= 0) {
//没有更新记录,秒杀结束
throw new SeckillCloseException("seckill is closed");
} else {
//秒杀成功
SeckillOrder seckillOrder = seckillOrderMapper.findById(seckillId, userPhone);
//更新缓存(更新库存数量)
Seckill seckill = (Seckill) redisTemplate.boundHashOps(key).get(seckillId);
seckill.setStockCount(seckill.getSeckillId() - 1);
redisTemplate.boundHashOps(key).put(seckillId, seckill);
return new SeckillExecution(seckillId, SeckillStatEnum.SUCCESS, seckillOrder);
}
}
} catch (SeckillCloseException e) {
throw e;
} catch (RepeatKillException e) {
throw e;
} catch (Exception e) {
logger.error(e.getMessage(), e);
//所有编译期异常,转换为运行期异常
throw new SeckillException("seckill inner error:" + e.getMessage());
}
}
}
SeckillStatEnum.java
public enum SeckillStatEnum {
SUCCESS(1, "秒杀成功"),
END(0, "秒杀结束"),
REPEAT_KILL(-1,"重复秒杀"),
INNER_ERROR(-2, "系统异常"),
DATA_REWRITE(-3, "数据串改");
private int state;
private String stateInfo;
SeckillStatEnum(int state, String stateInfo) {
this.state = state;
this.stateInfo = stateInfo;
}
public int getState() {
return state;
}
public String getStateInfo() {
return stateInfo;
}
public static SeckillStatEnum stateOf(int index){
for (SeckillStatEnum state : values()){
if (state.getState() == index){
return state;
}
}
return null;
}
}
SeckillController.java
@Controller
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private SeckillService seckillService;
private final Logger logger = LoggerFactory.getLogger(this.getClass());
@RequestMapping("/list")
public String findSeckillList(Model model) {
List<Seckill> list = seckillService.findAll();
model.addAttribute("list", list);
return "page/seckill";
}
@ResponseBody
@RequestMapping("/findById")
public Seckill findById(@RequestParam("id") Long id) {
return seckillService.findById(id);
}
@RequestMapping("/{seckillId}/detail")
public String detail(@PathVariable("seckillId") Long seckillId, Model model) {
if (seckillId == null) {
return "page/seckill";
}
Seckill seckill = seckillService.findById(seckillId);
model.addAttribute("seckill", seckill);
if (seckill == null) {
return "page/seckill";
}
return "page/seckill_detail";
}
@ResponseBody
@RequestMapping(value = "/{seckillId}/exposer",
method = RequestMethod.POST, produces = {"application/json;charset=UTF-8"})
public SeckillResult<Exposer> exposer(@PathVariable("seckillId") Long seckillId) {
SeckillResult<Exposer> result;
try {
Exposer exposer = seckillService.exportSeckillUrl(seckillId);
result = new SeckillResult<Exposer>(true, exposer);
} catch (Exception e) {
logger.error(e.getMessage(), e);
result = new SeckillResult<Exposer>(false, e.getMessage());
}
return result;
}
@RequestMapping(value = "/{seckillId}/{md5}/execution",
method = RequestMethod.POST,
produces = {"application/json;charset=UTF-8"})
@ResponseBody
public SeckillResult<SeckillExecution> execute(@PathVariable("seckillId") Long seckillId,
@PathVariable("md5") String md5,
@RequestParam("money") BigDecimal money,
@CookieValue(value = "killPhone", required = false) Long userPhone) {
if (userPhone == null) {
return new SeckillResult<SeckillExecution>(false, "未注册");
}
try {
SeckillExecution execution = seckillService.executeSeckill(seckillId, money, userPhone, md5);
return new SeckillResult<SeckillExecution>(true, execution);
} catch (RepeatKillException e) {
SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStatEnum.REPEAT_KILL);
return new SeckillResult<SeckillExecution>(true, seckillExecution);
} catch (SeckillCloseException e) {
SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStatEnum.END);
return new SeckillResult<SeckillExecution>(true, seckillExecution);
} catch (SeckillException e) {
SeckillExecution seckillExecution = new SeckillExecution(seckillId, SeckillStatEnum.INNER_ERROR);
return new SeckillResult<SeckillExecution>(true, seckillExecution);
}
}
@ResponseBody
@GetMapping(value = "/time/now")
public SeckillResult<Long> time() {
Date now = new Date();
return new SeckillResult(true, now.getTime());
}
}
BaseController.java
@Controller
public class BaseController {
/**
* 跳转到秒杀商品页
*
* @return
*/
@RequestMapping("/seckill")
public String seckillGoods() {
return "redirect:/seckill/list";
}
}
详细代码可以参考:GitHub