高并发核心编程Spring Cloud+Nginx秒杀实战,秒杀业务的参考实现

秒杀业务的参考实现

本节从功能入手重点介绍Spring Cloud秒杀实战业务处理的3层实现:dao层、service层、controller层。

秒杀的功能模块和接口设计

秒杀系统的实现有多种多样的版本,本节从方便演示的角度出发设计一个相当简单的秒杀练习版本,具体分为3个主要的模块:

(1)seckill-web模块:此模块是一个独立的Spring Boot程序,作为一个静态的Web服务器独立运行,主要运行秒杀的前端页面、脚本。在生产场景中,为了提高性能,可以将这个模块的所有静态资源全部迁移到Nginx高性能Web服务器。

(2)seckill-provider模块:秒杀的后端Spring Cloud微服务提供者主要运行获取秒杀令牌、秒杀订单等后端相关接口。

(3)uaa-provider模块:用户账号与认证(UAA)的后端SpringCloud微服务提供者主要运行用户认证、用户信息相关的后端接口。

以上3个模块的关系为:seckill-web模块作为静态资源程序会将秒杀的操作页面呈现给用户,seckill-web的页面会根据用户的操作将相应的URL接口,通过Nginx外部网关跳过内部网关Zuul直接发送给后端的uaa-provider和seckill-provider微服务提供者。为什么要跳过Zuul内部网关呢?内部网关需要对请求的URL进行用户权限验证,如果请求没有带token或者没有通过验证,请求就会被拦截并返回未授权的错误。

为了在练习时调试方便,建议直接跳过Zuul内部网关的权限验证功能,通过Nginx的反向代理将请求直接代理到后端的微服务提供者。

在秒杀练习系统中,三个模块的关系如图10-7所示。

图10-7 秒杀练习系统中三个模块的关系

本秒杀练习系统中的秒杀操作流程大致有以下4步:

(1)前端设置秒杀用户。

在用户点击后,seckill-web的前端页面将通过请求uaa-provider服务的/api/user/detail/v1接口获取用户信息。在实际的秒杀场景中这一步是不需要的,因为这一步所获取的用户信息就是当前登录用户本人的信息。

(2)前端设置秒杀商品。

seckill-web的前端页面通过请求seckill-provider服务的

/api/seckill/good/detail/v1接口获取所需要的秒杀商品。而在seckill-provider服务后端会将商品的库存信息缓存到Redis,方便下一步的秒杀令牌的获取。

(3)前端获取秒杀令牌。

seckill-web的前端页面通过请求seckill-provider服务的

/api/seckill/redis/token/v1接口获取商品的秒杀令牌,执行秒杀操作,减少商品的Redis库存。后端接口首先减Redis库存量,如果减库存成功,就生成秒杀专用的令牌存入Redis,在下一步用户下单时拿来进行验证。如果扣减Redis库存失败,就返回对应的错误提示。这一步操作没有涉及数据库,对库存的减少操作直接在Redis中完成,所扣减的并不是真正的商品库存。

(4)前端用户下单。

seckill-web的前端页面通过请求seckill-provider服务的/api/seckill/redis/do/v1接口执行真正的下单操作。后端接口会判断秒杀专用的token令牌是否有效,如果有效,就执行真正的下单操作,在数据库中扣减库存和生成秒杀订单,然后返回给前端。

秒杀练习系统的秒杀业务流程如图10-8所示。

图10-8 秒杀练习系统中的秒杀业务流程

在开发过程中,为了使得来自seckill-web前端页面的请求能够顺利地跳过内部网关Zuul而直接发送给后端的微服务提供者uaa-provider和seckill-provider,这里特意配置了一份专门的Nginx配置文件nginx-seckill.conf,对秒杀练习的三大模块进行定制化的反向代理配置,在启动Nginx的脚本openresty-start.sh文件中使用这份配置文件即可。

配置文件nginx-seckill.conf的核心配置如下:

由于笔者在开发过程中,seckill-web、seckill-provider两个进程在IDEA中(Windows开发环境)启动,而uaa-provider进程运行在自验证CentOS环境(虚拟机)中,因此进行了上面的反向代理配置。更多有关环境和运行的内容使用视频方式介绍起来更加直接,所以请查看疯狂创客圈社群的秒杀练习演示视频。

接下来,为大家介绍秒杀练习的秒杀操作流程的特点,有以下3点:

(1)增加了获取秒杀令牌的环节,将秒杀和下单操作分离。

这样做的好处有两方面:一方面,可以让秒杀操作和下单操作从执行上进行分离,使得秒杀操作可以独立于订单相关业务;另一方面,秒杀接口可以阻挡大部分并发流程,从而避免让低效率的下单操作耗费大量的计算资源。

(2)前端缺少了轮询环节。

在生产场景中,用户获取令牌后,前端应该会自动发起下单操作,然后通过前端Ajax脚本轮询是否下单成功。本练习实例为了清晰地展示秒杀操作过程,将自动下单操作修改成了手动下单操作,并且,由于后端下单没有经过消息队列进行异步处理,因此前端也不需要进行结果的轮询。

(3)后端缺少失效令牌的库存恢复操作。

在生产场景中,存在用户拿到令牌而不去完成下单的情况,导致令牌失效。所以,后端需要有定时任务对秒杀令牌进行有效性检查,如果令牌没有被使用或者生效,就需要恢复Redis中的秒杀库存,方便后面的请求去秒杀。无效令牌检查的定时任务可以设置为每分钟一次或者每两分钟一次,以保障被无效令牌消耗的库存能够及时得到恢复。

数据表和PO实体类设计

秒杀系统的表设计相对简单清晰,主要涉及两张核心表:秒杀商品表和订单表。

当然,实际秒杀场景肯定不止这两张表,还有付款信息相关的其他配套表等,出于学习的目的,这里我们只考虑秒杀系统的核心表,不考虑实际系统涉及的其他配套表。

与两个核心表相对应,系统中设计了两个PO实体类:秒杀商品PO类和秒杀订单PO类。本 文的命名规范:Java实体类统一使用PO作为后缀,Java传输类统一使用DTO作为后缀。

由于本案例使用JPA作为持久层框架,可以基于PO类逆向地生成数据库的表,因此这里不对数据表的结构进行展开说明,而是对PO类进行说明。

秒杀商品PO类SeckillGoodPO的代码如下:

在秒杀系统中,SECKILL_GOOD商品表的GOOD_ID字段和SECKILL_ORDER订单表中的GOOD_ID字段在业务逻辑上存在一对多的关系,但是不建议在数据库层面使用表与表之间的外键关系。为什么呢?因为如果秒杀订单量巨大,就必须进行分库分表,这时SECKILL_ORDER表和SECKILL_GOOD表中GOOD_ID相同的数据可能分布在不同的数据库中,所以数据库表层面的关联关系可能会导致维护起来非常困难。

使用分布式ID生成器

在实际开发中,很多项目为了应付交付和追求速度,简单粗暴地使用Java的UUID作为数据的ID。实际上,由于UUID非常长,除了占用大量存储空间外,主要的问题在索引上,在建立索引和基于索引进行查询时都存在性能问题。

下面使用主流的ZooKeeper+Snowflake算法实现高性能的Long类型分布式ID生成器,并且封装成了一个通用的Hibernate的ID生成器类

CommonSnowflakeIdGenerator,具体的代码如下:

以上Hibernate ID生成器只是对ZooKeeper+Snowflake算法分布式ID生成器的简单封装。

秒杀的控制层设计

本小节首先介绍秒杀练习的REST接口设计,然后介绍它的控制层(controller)的大致实现逻辑。启动秒杀服务seckill-provider,然后通过Swagger UI界面访问它的REST接口清单,大致如图10-9所示。

图10-9 秒杀练习的REST接口示意图

秒杀服务seckill-provider的控制层的REST接口分为4部分:

(1)秒杀练习RedisLock版本。此秒杀版本含有两个接口:一个获取令牌的接口和一个执行秒杀的接口。此版本使用RedisLock分布式锁保护秒杀数据库操作。

(2)秒杀练习ZkLock版本。

此秒杀版本包含两个接口:一个获取令牌的接口和一个执行秒杀的接口。此版本使用ZkLock分布式锁保护秒杀数据库操作。此版本的意义是为大家学习和使用ZooKeeper分布式锁提供案例。

(3)秒杀练习商品管理。

此部分REST接口主要对秒杀的商品进行CRUD操作。

(4)秒杀练习订单管理。

此部分REST接口主要对秒杀的订单进行查询、清除操作。

由于各部分REST接口涉及的知识体系大致相同,因此本文只介绍秒杀练习RedisLock版本控制层的实现,其他的控制层接口可自行分析和研究。

秒杀练习RedisLock版本的控制层类的代码如下:

以上

SeckillByRedisLockController仅仅做了REST服务的发布,真正的秒杀逻辑在服务层的RedisSeckillServiceImpl类中实现。

service层逻辑:获取秒杀令牌

本文的秒杀案例特意删除了服务层的接口类,只剩下了服务层的实现类,表面上违背了“面向接口编程”的原则,实际上这样做能使代码更加干净和简洁,也减少了代码维护的工作量。之所以这样简化,主要的原因是:删除的那些接口类都是单实现类接口(一个接口只有一个实现类),那些接口在使用时不会存在多种实现对象赋值给同一个接口变量的多态情况。笔者从事开发这么多年,可谓经历项目无数,发现不知道有多少实际项目,出于“面向接口编程”的原则,写了无数个单实现类接口,将“面向接口编程”的编程原则僵化和教条化。

回到主题,下面给大家介绍RedisSeckillServiceImpl秒杀实现类,该类主要有两个功能:获取秒杀令牌和完成秒杀下单。

本小节介绍其中的第一个功能——获取秒杀令牌,该功能由getSeckillToken方法实现,具体的流程图如图10-10所示。

图10-10 获取秒杀令牌流程图

获取秒杀令牌的输入为用户的userId和秒杀商品的seckillGoodId,其输出为一个代表秒杀令牌的UUID字符串,获取秒杀令牌的重点是进行3个判断:

(1)判断秒杀的商品是否存在,如果不存在,就抛出对应异常。

(2)判断秒杀商品的库存是否足够,如果没有足够库存,就抛出对应异常。

(3)判断用户是否已经获取过商品的秒杀令牌,如果获取过,就抛出对应异常。

只有秒杀商品存在、库存足够而且之前没有被userId代表的用户秒杀过这3个条件都满足,才能允许用户获取商品的秒杀令牌。

获取秒杀令牌的代码节选如下:

通过上面的代码可以看出,getSeckillToken方法并没有获取令牌的核心逻辑,仅仅调用缓存在Redis内部的seckill.lua脚本的setToken方法判断和设置秒杀令牌,然后对seckill.lua脚本的返回值进行判断,并根据不同的返回值做出不同的反应。设置令牌的核心逻辑存在于seckill.lua脚本中。为什么要用Lua脚本呢?

(1)由于Redis脚本作为一个整体来执行,中间不会被其他命令插入,天然具备分布式锁的特点,因此不需要使用专门的分布式锁对设置令牌的逻辑进行并发控制。

(2)秒杀令牌在Redis中进行缓存,在设置新令牌之前需要查找旧令牌并且进行是否存在的判断,如果这些逻辑都编写在Java程序中,那么完成查找旧令牌和设置新令牌需要多次的Redis往返操作,也就是说需要进行多次网络传输。大家知道,网络的传输延迟是损耗性能的大户,所以使用Lua脚本能减少网络传输次数,从而提高性能。

在seckill.lua脚本中,除了有setToken令牌的设置方法外,还有其他的方法,如checkToken令牌检查方法,该脚本稍后再为大家统一介绍。

service层逻辑:执行秒杀下单

前面讲到RedisSeckillServiceImpl秒杀实现类主要有两个功能:

获取秒杀令牌和完成秒杀下单。下面来看秒杀下单的业务逻辑。

秒杀下单很简单、清晰,只有两点:减库存和存储用户秒杀订单明细。但是其中涉及两个问题:

(1)数据一致性问题:同一商品在秒杀商品表中的库存数和在订单表中的订单数需要保持一致。

(2)超卖问题:秒杀商品的剩余库存数不能为负数。

以上两个问题主要借助Redis分布式锁解决。另外,由于代码中存在减库存和存订单两次数据库操作,为了防止出现一次失败一次成功的情况,需要通过数据库事务对这两次操作进行数据一致性保护。

秒杀下单的执行流程如图10-11所示。

图10-11 秒杀下单的流程图

由于存在数据库事务,因此将秒杀下单的整体流程分成两个方法实现:

(1)executeSeckill(SeckillDTO):负责下单前的分布式锁获取和库存的检查。

(2)doSeckill(SeckillDTO):负责真正的下单操作(减库存和存储秒杀订单)。

秒杀下单流程的实现代码如下:

executeSeckill在执行秒杀前调用seckill.lua脚本中的checkToken方法判断令牌是否有效。如果Lua脚本的checkToken方法的返回值不是5(令牌有效标识),就抛出运行时异常。

秒杀的Lua脚本设计

前面讲到,在seckill.lua脚本中完成设置令牌和令牌检查的工作有两大优势:一是在Redis内部执行Lua脚本天然具备分布式锁的特点;二是能减少网络传输次数,提高性能。

在seckill.lua脚本中定义了两个方法:setToken令牌设置方法和checkToken令牌检查方法。其中,setToken令牌设置方法的执行流程如下:

(1)检查token秒杀令牌是否存在,如果存在,就返回标志5,表明排队过了。

(2)检查以JSON格式缓存的秒杀商品的库存是否足够,如果库存不够,就返回标志4,表明库存不足。

(3)为秒杀商品减少一个库存,并编码成JSON格式,再一次缓存起来。

(4)使用hset命令将用户的秒杀令牌保存在Redis哈希表结构中,其hash key为用户的userId。

(5)最终返回标志1,表明排队成功。

checkToken令牌检查方法的执行流程如下:

(1)使用hget命令从保存秒杀令牌的Redis哈希表结构中,以用户的userId作为hash key,取出之前缓存的秒杀令牌。

(2)如果令牌获取成功,就返回标志5,表明排队成功。

(3)如果令牌不存在,就返回标志-1,表明没有排队。

seckill.lua脚本的源码如下:

以上seckill.lua脚本在Java中可以通过spring-data-redis包的以下方法来执行:

RedisTemplate.execute(RedisScript script, List keys, Object,..., args)在开发脚本的过程中往往需要进行脚本调试,可以通过Shell指令redis-cli--eval直接执行seckill.lua脚本,具体的调试执行过程可查看疯狂创客圈社群的秒杀练习演示视频。

BusinessException定义

减库存操作和插入购买明细操作都会产生很多业务异常,比如库存不足、重复秒杀等,这些业务异常与crazy-springcloud脚手架中的其他业务异常一样,全部被封装成BusinessException通用业务异常实例抛出。

一般项目怎么划分自定义异常呢?大致有两种方式:

(1)按异常来源所处的controller、service、dao层划分业务异常,例如DaoException、ServiceException、ControllerException等。

(2)按异常来源所处的模块组件(如数据库、消息中间件、业务模块)划分业务异常,例如MysqlExceptioin、RedisException、ElasticSearchException、SeckillException等。

无论按照哪个维度划分都出于同一个目标:一旦出现异常,就可以很容易定位到是哪个层或组件出现了问题。

在实际开发过程中,定义太多异常类型之后,需要不厌其烦地将异常一层层抛出、一层层捕获,反而会加大代码的复杂度。所以,虽然crazyspringcloud脚手架和其他项目一样定义了一个自己的全局异常基类BusinessException,但是crazy-springcloud脚手没有定义太多业务异常子类。一般情况下,重新定义一个异常的子类其实没有太大必要,因为可以根据异常的编码和异常的消息进行区分。

crazy-springcloud脚手架的基础业务异常类BusinessException的代码如下:

该类有errCode、errMsg两个属性,errCode属性用于存放异常的编码,errMsg属性用于存放一些错误附加信息。

特别注意,该类继承了RuntimeException运行时异常类,而不是Exception受检异常基类,表明BusinessException类其实是一个非受检的运行时异常类。

为什么要这样呢?有两个原因:

(1)默认情况下,Spring Boot事务只有检查到RuntimeException运行时异常才会回滚,如果检查到的是普通的受检异常,那么Spring Boot事务是不会回滚的,除非经过特殊配置。

(2)简化编程的代码,如果没有必要,就不需要在业务程序中对异常进行捕获,而是由项目中的全局异常解析器统一负责处理。

crazy-springcloud脚手架的全局异常解析器ExceptionResolver的代码如下:

上面的ExceptionResolver全局异常解析器使用了Spring Boot的@RestControllerAdvice注解,该注解首先会对系统的异常进行拦截,并且交给对应的异常处理方法进行处理,然后将异常处理结果返回给客户端。

ExceptionResolver的每个异常处理方法都使用@ExceptionHandler注解配置自己希望处理的异常类型,传入的参数为异常类型的class实例,如果要处理多个异常类型,那么其参数可以是一个异常类型class实例数组。需要注意的是,不能在两个异常处理方法的@ExceptionHandler注解中配置同一个异常类型,如果存在一种异常类型被处理多次,在初始化全局异常解析器时就会失败。

相关文章