SpringBoot实现Java高并发秒杀系统之Service层开发(二)

继上一篇文章:SpringBoot实现Java高并发秒杀系统之DAO层开发 我们创建了SpringBoot项目并熟悉了秒杀系统的表设计,下面我们将讲解一下秒杀系统的核心部分:Service业务层的开发。

Service层又称为业务层,在Spring阶段主要是由@Service注解标记的一层,包含Service业务接口的开发和业务接口实现类的开发,这里我们将讲解如何优雅的设计业务层接口以及针对秒杀系统业务层的优化技术等和针对高并发的解决方案。

本项目的源码请参看:springboot-seckill 如果觉得不错可以star一下哦(#^.^#)

本项目一共分为四个模块来讲解,具体的开发教程请看我的博客文章:

Service接口的设计

之前我们写好了DAO层的接口,这里我们要开始着手编写业务层接口,然后编写业务层接口的实现类并编写业务层的核心逻辑。

设计业务层接口,应该站在使用者角度上设计,如我们应该做到:

  • 1.定义业务方法的颗粒度要细。

  • 2.方法的参数要明确简练,不建议使用类似Map这种类型,让使用者可以封装进Map中一堆参数而传递进来,尽量精确到哪些参数。

  • 3.方法的return返回值,除了应该明确返回值类型,还应该指明方法执行可能产生的异常(RuntimeException),并应该手动封装一些通用的异常处理机制。

类比DAO层接口的定义,我这里先给出完整的SeckillService.java的定义(注意:在DAO层(Mapper)中我们定义了两个接口SeckillMapperSeckillOrderMapper,但是Service层接口为1个):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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;
}

这里我将依次讲解一下为什么接口会这样设计?接口方法的返回值是怎样定义的?

findById和findAll方法

这两个方法就简单很多:

  • findById(): 顾名思义根据ID主键查询。按照接口的设计我们需要指定参数是seckillId(秒杀商品的ID值。注意:这里定义为long类型,不要定义为包装类类型,因为包装类类型不能直接进行大小比较,必须转换为基本类型才能进行值大小比较);返回值自然是查询到的商品表数据级Seckill实体类了。

  • findAll(): 顾名思义是查询数据库中所有的秒杀商品表的数据,因为记录数不止一条,所以一般就用List集合接收,并制定泛型是List<Seckill>,表示从数据库中查询到的列表数据都是Seckill实体类对应的数据,并以Seckill实体类的结构将列表数据封装到List集合中。

exportSeckillUrl方法

exportSeckillUrl()方法可有的讲了,他是暴露接口用到的方法,目的就是获取秒杀商品抢购的地址

1.为什么要单独创建一个方法来获取秒杀地址?

在之前我们做的后端项目中,跳转到某个详情页一般都是:根据ID查询该详情数据,然后将页面跳转到详情页并将数据直接渲染到页面上。但是秒杀系统不同,它也不能就这样简单的定义,要知道秒杀技术的难点就是如何应对高并发?同一件商品,比如瞬间有十万的用户访问,而还存在各种黄牛,有各种工具去抢购这个商品,那么此时肯定不止10万的访问量的,并且开发者要尽量的保证每个用户抢购的公平性,也就是不能让一个用户抢购一堆数量的此商品。

这就是我们常说的接口防刷问题。因此单独定义一个获取秒杀接口的方法是有必要的。

2.如何做到接口防刷?

接口方法:Exposer exportSeckillUrl(long seckillId);从参数列表中很易明白:就是根据该商品的ID获取到这个商品的秒杀url地址;但是返回值类型Exposer是什么呢?

思考一下如何做到接口防刷?

  1. 首先要保证该商品处于秒杀状态。也就是1.秒杀开始时间要<当前时间;2.秒杀截止时间要>当前时间。

  2. 要保证一个用户只能抢购到一件该商品,应做到商品秒杀接口对应同一用户只能有唯一的一个URL秒杀地址,不同用户间秒杀地址应是不同的,且配合订单表seckill_order联合主键的配置实现。

针对上面的两条分析,我们给出Exposer的设计(要注意此类定义在/dto/路径下表明此类是我们手动封装的结果属性,它类似JavaBean但又不属于,仅用来封装秒杀状态的结果,目的是提高代码的重用率):

此例源码请看:GitHub

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Exposer {

//是否开启秒杀
private boolean exposed;

//加密措施,避免用户通过抓包拿到秒杀地址
private String md5;

//ID
private long seckillId;

//系统当前时间(毫秒)
private long now;

//秒杀开启时间
private long start;

//秒杀结束时间
private long end;

public Exposer(boolean exposed, String md5, long seckillId) {
this.exposed = exposed;
this.md5 = md5;
this.seckillId = seckillId;
}

public Exposer(boolean exposed, Long seckillId, long now, long start, long end) {
this.exposed = exposed;
this.seckillId = seckillId;
this.now = now;
this.start = start;
this.end = end;
}

public Exposer(boolean exposed, long seckillId) {
this.exposed = exposed;
this.seckillId = seckillId;
}
}

如上我们封装的结果类可以满足我们的需求:1.首先指明商品当前秒杀状态:秒杀未开始、秒杀进行中、秒杀已结束;2.如果秒杀未开始返回false和相关时间用于前端展示秒杀倒计时;3。如果秒杀已经结束就返回false和当前商品的ID;3.如果秒杀正在进行中就返回该商品的秒杀地址(md5混合值,避免用户抓包拿到秒杀地址)。

executeSeckill方法

这里我们再回顾一下秒杀系统的业务分析:

可以看到,秒杀的业务逻辑很清晰,用户抢购了商品业务层需要完成:1.减库存;2.储存用户秒杀订单明细。而因为储存订单明细应该是在用户成功秒杀到订单后才执行的操作,所以并不需要定义在Service接口中。那么我们就看一下用户针对库存的业务分析:

可以看到针对库存业务其实还是两个操作:1.减库存;2.记录购买明细。但是其中涉及到很多事物操作和性能优化问题我们放在后面讲。这里我们将这两个操作合并为一个接口方法:执行秒杀的操作。

所以再看一下我们对exexuteSeckill()方法的定义:

1
2
SeckillExecution executeSeckill(long seckillId, BigDecimal money, long userPhone, String md5)
throws SeckillException, RepeatKillException, SeckillCloseException;

1.分析参数列表

由于executeSeckill()方法涉及:1.减库存;2.记录购买明细。因为我们的项目不涉及复杂的数据,所以没有太多的明细参数(用money替代)。那么当前参数分别有何作用?

  • seckillIduserPhone用于在insert订单明细时进行防重复秒杀;只要有相同的seckillIduserPhone就一定主键冲突报错。

  • seckillIdmd5用于组成秒杀接口地址的一部分,当用户点击抢购时获取到之前暴露的秒杀地址中的md5值和当前传入的md5值进行比较,如果匹配再进行下一步操作。

2.分析返回值类型

和在设计exportSeckillUrl接口方法时一样,针对秒杀操作也应该包含很多返回数据,比如:秒杀结束、秒杀成功、秒杀系统异常…信息,我们也将这些信息用类封装在dto文件夹中。于是我们的返回值SeckillExecution类定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class SeckillExecution {

private Long seckillId;

//秒杀执行结果状态
private int state;

//状态表示
private String stateInfo;

//秒杀成功的订单对象
private SeckillOrder seckillOrder;

public SeckillExecution(Long seckillId, int state, String stateInfo, SeckillOrder seckillOrder) {
this.seckillId = seckillId;
this.state = state;
this.stateInfo = stateInfo;
this.seckillOrder = seckillOrder;
}

public SeckillExecution(Long seckillId, int state, String stateInfo) {
this.seckillId = seckillId;
this.state = state;
this.stateInfo = stateInfo;
}
}

state用于-1,0,1这种状态的表示,这些数字分别被赋予不同的含义,后面讲到。stateInfo表示state状态数字的中文解释,比如:秒杀成功、秒杀结束、秒杀系统异常等信息。

3.分析异常

减库存操作和插入购买明细操作都会产生很多未知异常(RuntimeException),比如秒杀结束、重复秒杀等。除了要返回这些异常信息,还有一个非常重要的操作就是捕获这些RuntimeException,从而避免系统直接报错。

针对秒杀关闭的异常,我们定义SeckillCloseException.java:

1
2
3
4
5
6
7
8
9
10
public class SeckillCloseException extends SeckillException {

public SeckillCloseException(String message) {
super(message);
}

public SeckillCloseException(String message, Throwable cause) {
super(message, cause);
}
}

针对重复秒杀的异常,我们定义RepeatKillException.java:

1
2
3
4
5
6
7
8
9
10
public class RepeatKillException extends SeckillException {

public RepeatKillException(String message) {
super(message);
}

public RepeatKillException(String message, Throwable cause) {
super(message, cause);
}
}

同时,系统还可能出现其他位置异常,所以我们还需要定义一个异常继承所有异常的父类Exception:

1
2
3
4
5
6
7
8
9
10
public class SeckillException extends RuntimeException {

public SeckillException(String message) {
super(message);
}

public SeckillException(String message, Throwable cause) {
super(message, cause);
}
}


ServiceImpl实现类的设计

我们在src/cn/tycoding/service/impl下创建Service接口的实现类: SeckillServiceImpl.java

在开始讲解之前我们先理解几个概念:

1.为什么我们的系统需要事务?

举个栗子:比如a在购买商品A的同时,售卖该商品的商家突然调低了A商品的价格,但此瞬时价格调整还没有更新到数据库用户购买的订单就已经提交了,那么用户不就多掏了钱吗?又比如a购买的商品后库存数量减少的sql还没有更新到数据库,此时瞬间b用户看到还有商品就点击购买了,而此时商品的库存数量其实已经为0了,这样就造成了超卖。

针对上面两个栗子,我们必须要给出解决方案,不然就太坑了。

2.什么是事务?

在软件开发领域,全有或全无的操作称为事务(transaction)。事务有四个特性,即ACID:

  • 原子性:原子性确保事务中所有操作全部发生或全部不发生。
  • 一致性:一旦事务完成(不管成功还是失败),系统必须却把它所建模的业务处于一致的状态。
  • 隔离性:事务允许多个用户对相同的数据进行操作,每个用户的操作不会与其他用户纠缠在一起。
  • 持久性:一旦事务完成,事务的结果应该持久化,这样就能从任何的系统崩溃中恢复过来。

事务常见的问题:

  • 更新丢失:当多个事务选择同一行操作,并且都是基于最初的选定的值,由于每个事务都不知道其他事务的存在,就会发生更新覆盖的问题。
  • 脏读:事务A读取了事务B已经修改但为提交的数据。若事务B回滚数据,事务A的数据存在不一致的问题。
  • 不可重复读:书屋A第一次读取最初数据,第二次读取事务B已经提交的修改或删除的数据。导致两次数据读取不一致。不符合事务的隔离性。
  • 幻读:事务A根据相同条件第二次查询到的事务B提交的新增数据,两次数据结果不一致,不符合事务的隔离性。

3.Spring对事务的控制

Spring框架针对事务提供了很多事务管理解决方案。我们这里只说常用的:声明式事务。声明式事务通过传播行为、隔离级别、只读提示、事务超时及回滚规则来进行定义。我们这里讲用Spring提供的注解式事务方法:@Transaction

使用注解式事务的优点:开发团队达到一致的约定,明确标注事务方法的编程风格。

使用事务控制需要注意:

  1. 保证事务方法的执行时间尽可能短,不要穿插其他的网络操作PRC/HTTP请求(可以将这些请求剥离出来)。
  2. 不是所有的放阿飞都需要事务控制,如只有一条修改操作、只读操作等是不需要事务控制的。

注意

Spring默认只对运行期异常(RuntimeException)进行事务回滚操作,对于编译异常Spring是不进行回滚的,所以对于需要进行事务控制的方法尽量将可能抛出的异常都转换成运行期异常。这也是我们我什么要在Service接口中手动封装一些RuntimeException信息的一个重要原因。

exportSeckillUrl方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
public class SeckillServiceImpl implements SeckillService {
private Logger logger = LoggerFactory.getLogger(this.getClass());

//设置盐值字符串,随便定义,用于混淆MD5值
private final String salt = "sjajaspu-i-2jrfm;sd";
@Autowired
private SeckillMapper seckillMapper;

@Autowired
private SeckillOrderMapper seckillOrderMapper;

@Override
public Exposer exportSeckillUrl(long seckillId) {
Seckill seckill = seckillMapper.findById(seckillId);
if (seckill == null) {
//说明没有查询到
return new Exposer(false, seckillId);
}
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;
}

exportSeckillUrl()还是比较清晰的,主要逻辑:根据传进来的seckillId查询seckill表中对应数据,如果没有查询到就直接返回Exposer(false,seckillId)标识没有查询到该商品的秒杀接口信息,可能是用户非法输入的数据;如果查询到了,就获取秒杀开始时间和秒杀结束时间以及new一个当前系统时间进行判断当前秒杀商品是否正在进行秒杀活动,还没有开始或已经结束都直接返回Exposer;如果上面两个条件都符合了就证明该商品存在且正在秒杀活动中,那么我们需要暴露秒杀接口地址。

因为我们要做到接口防刷的功能,所以需要生成一串md5值作为秒杀接口中一部分。而Spring提供了一个工具类DigestUtils用于生成MD5值,且又由于要做到更安全所以我们采用md5+盐的加密方式生成一传md5加密数据作为秒杀URL地址的一部分发送给Controller。

executeSeckill方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@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);
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());
}
}

executeSeckill方法相对复杂一些,主要涉及两个业务操作:1.减库存(调用reduceStock());2.记录订单明细(调用insertOrder())。我们以一张图来描述一下主要逻辑:

由此我抛出以下问答:

1.insertCount和updateCount哪来?

在之前我们写项目中可能对于insert和update的操作直接设置返回值类型为void,虽然Mybatis的<insert><update>语句都没有resultType属性,但是并不带表其没有返回值,默认的返回值是0或1…表示该条SQL影响的行数,如果为0就表示该SQL没有影响数据库,但是为了避免系统遇到错误的SQL返回错误信息而不是直接报错,我们可以在书写SQL时:insert ignore into xxx即用ignore参数,当Mybatis执行该SQL发生异常时直接返回0表示更新失败而不是系统报错。

2.为什么先记录秒杀订单信息操作再执行减库存操作?

这里涉及了一个简单的Java并发优化操作,详细内容优化方式请看:SpringBoot实现Java高并发秒杀系统之系统优化

3.上例中用到的SeckillStatEnum是什么?

之前我们讲exportSeckillUrl时在/dto/中创建了类Exposer;在讲executeSeckill的时候创建了SeckillExecution类,他们都是用来封装返回的结果信息的,不是说他们是必须的,而是用这种方式会更规范且代码看起来更加整洁,而且我们的代码的重用率会更高。

于是,当用户秒杀成功后其实需要返回一句话秒杀成功即可,但是我们单独提取到了一个枚举类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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;
}
}

具体枚举的语法不再讲,简单来说就是将这些通用的返回结果提取出来,且枚举这种类型更适合当前方法的返回值特点。除了创建这个枚举对象,还需要修改SeckillExecution的源代码,这里不再贴出。

4.为什么要cache这么多异常?

前面我们已经提到了Spring默认只对运行期异常进行事务回滚操作,对于编译期异常时不进行回滚的,所以这也是我们为什么一直强调要手动创建异常类。

这里就是要将所有编译期异常转换为运行期异常,因为我们定义的所有异常最终都是继承RuntimeException。

此例具体代码请看:GitHub


交流

如果大家有兴趣,欢迎大家加入我的Java交流群:671017003 ,一起交流学习Java技术。博主目前一直在自学JAVA中,技术有限,如果可以,会尽力给大家提供一些帮助,或是一些学习方法,当然群里的大佬都会积极给新手答疑的。所以,别犹豫,快来加入我们吧!


联系

If you have some questions after you see this article, you can contact me or you can find some info by clicking these links.

坚持原创技术分享,您的支持将鼓励我继续创作!