专业的编程技术博客社区

网站首页 > 博客文章 正文

一个极简高效的秒杀系统(战术实践篇)

baijin 2024-10-01 07:28:13 博客文章 6 ℃ 0 评论



在上一篇《一个极简、高效的秒杀系统(战略设计篇)》中,楼主重点讲解了基于Redis + Lua脚本的秒杀系统设计方案,如果没看过的同学,请花十分钟复习下。在这一篇中,楼主会结合代码,来探讨如何将设计方案落地。


提前剧透,工程源代码地址见楼主GitHub:https://github.com/Heroicai0101/seckill ,可下载到本地对照本篇看。


工程骨架


DDD概述


在看具体代码前,先窥一幅DDD工程骨架图,该图是楼主根据《领域驱动设计:软件核心复杂性应对之道》这本书的示例工程代码(Github地址戳这里[1])进行绘制的,图中每个框,代表一个包,整个工程代码组织结构就是该图的层次结构。如果对DDD感兴趣,也建议把代码下载下来,后续对照着书本进行阅读。对于新手,推荐先粗略看一遍《领域驱动设计:软件核心复杂性应对之道》了解DDD相关专业术语和概念,再精读几遍《实现领域驱动设计》。


DDD楼主认识有限,这里也不细说,简单概述下DDD的四个层次,从上到下依次是用户界面层(User Interface)、应用层(Application)、领域层(Domain)、基础设施层(Infrastructure)。


  • 用户界面层:负责向用户展示信息和解释用户命令。这里的用户是广义概念,既可以是用户界面的使用者,还可以是与当前系统交互的其他应用。
  • 应用层:定义系统需要对外提供的能力,应用层通常不包含业务规则,主要是通过编排领域服务来完成能力建设。
  • 领域层:提炼并抽象业务概念、业务规则,实现细节在基础设施层。DDD核心思想就是围绕领域对象来建模,故领域层是业务系统的核心。
  • 基础设施层:向其他层提供底层技术能力,如消息发送、数据库持久化等。基础设施层也不包含业务规则,可简单理解为对数据进行存/取的资源库。


这四个层次调用关系如下图(红色箭头代表调用方向):可以看出用户界面层权力比较大,可以直接调用应用层、领域层、基础设施层;应用层可以调用领域层和基础设施层。


工程结构


有了上面各层整体认识后,再对照看下我们这个秒杀工程结构就容易理解了,结构基本是一样的!急不可耐的同学,如果想快速run起来的话,强烈建议参照楼主GitHub项目的README文档来起飞!


源码解读


在《一个极简高效的秒杀系统(战略设计篇)》这篇E-R图中提到了几个重要的领域模型:活动、活动准入规则、活动商品。既然DDD是围绕领域对象来建模的,所以在系统实现上,首要任务就是建立领域对象,并围绕领域对象来建模。So,我们先从领域层开始吧!


领域层


领域模型


1、活动


活动对象的设计比较简单:


  • 活动对象的属性包含:活动id、活动名称、活动开始/结束时间、活动是否启用状态,以及一个活动规则列表;
  • 活动对象的方法有三个:判断活动是否进行中onSale()、启用/禁用活动enableActivity() 以及判断当前请求是否符合活动准入条件canPass();这三个方法,其实就是活动这个领域对象应该具备的业务知识,只有活动对象才拥有完备的知识知道如何判断活动是否进行中、怎么启用/禁用活动、以及请求是否符合活动准入条件。假如这些业务逻辑按我们通常写法,把方法丢到某些Service对象中,就会出现本该由领域对象管理的业务逻辑散落到系统各处,造成只有属性没有方法的贫血模型。


  1. /**
  2. * 活动信息
  3. */
  4. @Data
  5. public class Activity {

  6. /** 活动id */
  7. private ActivityId activityId;

  8. /** 活动名称 */
  9. private String activityName;

  10. /** 活动开始时间 */
  11. private Long startTime;

  12. /** 活动结束时间 */
  13. private Long endTime;

  14. /** 活动是否启用 */
  15. private boolean enabled;

  16. /** 活动准入规则 */
  17. private List<ActivityRule> activityRules;

  18. /**
  19. * 活动进行中
  20. */
  21. public boolean onSale(Long orderTime) {
  22. return enabled && (orderTime >= startTime && orderTime < endTime);
  23. }

  24. /**
  25. * 启用/禁用活动
  26. */
  27. public void enableActivity(boolean enabled) {
  28. this.enabled = enabled;
  29. }

  30. /**
  31. * 活动准入规则校验
  32. * 1、活动未配置规则,则无需校验
  33. * 2、活动若配置了规则,则逐一进行校验
  34. */
  35. public ActivityRuleCheckResult canPass(ActivityAccessContext context) {
  36. if (CollectionUtils.isEmpty(activityRules)) {
  37. return ActivityRuleCheckResult.ok();
  38. }

  39. Assert.notNull(context, "活动准入条件为空");
  40. for (ActivityRule activityRule : activityRules) {
  41. ActivityRuleCheckResult result = activityRule.satisfy(context);
  42. if (!result.isPass()) {
  43. return result;
  44. }
  45. }
  46. return ActivityRuleCheckResult.ok();
  47. }

  48. }


2、活动商品


商品本身依附于活动对象,没什么业务方法,持有商品id、商品标题、图片链接、原价、活动价、活动库存、限购数量等属性;值得注意的是,E-R图上我们看到活动跟商品有联系,但在Activity这个对象一点都没体现出来。在这里,我们看到是通过ActivityItem持有活动id来建立二者联系的。


  1. /**
  2. * 活动商品
  3. */
  4. @Data
  5. public class ActivityItem {

  6. /** 商品id */
  7. private ItemId itemId;

  8. /** 活动id */
  9. private ActivityId activityId;

  10. /** 商品标题 */
  11. private String itemTitle;

  12. /** 商品副标题 */
  13. private String subTitle;

  14. /** 商品图片链接 */
  15. private String itemImage;

  16. /** 商品原价 */
  17. private Long itemPrice;

  18. /** 商品活动价 */
  19. private Long activityPrice;

  20. /** 每人限购件数 */
  21. private Integer quota;

  22. /** 商品活动库存 */
  23. private Integer stock;

  24. }


3、库存扣减流水


库存扣减流水:记录在哪个活动(activityId)、哪个用户(buyerId)、在何时下了哪笔订单、拍下哪个商品多少个(orderInfo);扣库存、回库存操作强依赖这一对象。


  1. /**
  2. * 库存扣减流水:记录在哪个活动(activityId)、哪个用户(buyerId)、在何时下了哪笔订单、拍下哪个商品多少个(orderInfo)
  3. */
  4. @Builder
  5. @Data
  6. @NoArgsConstructor
  7. @AllArgsConstructor
  8. public class StockReduceFlow {

  9. /** 活动id */
  10. private ActivityId activityId;

  11. /** 买家id */
  12. private BuyerId buyerId;

  13. /** 订单信息 */
  14. private OrderInfo orderInfo;

  15. }


4、仓储


看到这里,可能会纳闷,上面这些领域对象都怎么构建出来的,它们存在哪里?这就不得不提到仓储(Repository)这个概念。在DDD理念里,仓储负责领域对象的存储,但仓储本身并没规定存储介质。也就是说仓储只负责定义领域对象的读/写协议,至于具体用内存、MySQL还是Oracle,它不关心。在《领域驱动设计:软件核心复杂性应对之道》中提到,一般只对聚合根建立仓储,但在我们秒杀系统中,活动对象Activity可算一个聚合根,至于活动商品、商品销量其实都不是聚合根,但楼主还是为这几个对象定义了仓储。实在是仓储这个概念的度拿捏不好,没办法严格照搬书上的做法。如果大家有独到的见解,欢迎和楼主交流!


活动仓储(ActivityRepository):查活动、保存活动。


  1. /**
  2. * 活动
  3. */
  4. public interface ActivityRepository {

  5. /**
  6. * 查活动列表
  7. */
  8. List<Activity> listActivity();

  9. /**
  10. * 查单个活动
  11. */
  12. Activity findActivity(ActivityId activityId);

  13. /**
  14. * 保存活动
  15. */
  16. void saveActivity(Activity activity);

  17. }


活动商品仓储(ActivityItemRepository):查商品、保存商品。


  1. /**
  2. * 活动商品
  3. */
  4. public interface ActivityItemRepository {

  5. /**
  6. * 保存指定活动的商品配置
  7. */
  8. void saveActivityItem(ActivityId activityId, List<ActivityItem> activityItems);

  9. /**
  10. * 查指定活动的指定商品
  11. */
  12. Optional<ActivityItem> findActivityItem(ActivityId activityId, ItemId itemId);

  13. /**
  14. * 查活动商品(缺商品销量)
  15. */
  16. List<ActivityItem> queryActivityItems(ActivityId activityId);

  17. }


库存扣减流水仓储(StockReduceFlowRepository):查库存扣减流水。


  1. /**
  2. * 库存扣减流水
  3. */
  4. public interface StockReduceFlowRepository {

  5. Optional<StockReduceFlow> queryStockReduceFlow(ActivityId activityId, OrderId orderId);

  6. }


商品销量仓储(ItemSalesRepository):查单个、全部商品销量。


  1. /**
  2. * 商品销量
  3. */
  4. public interface ItemSalesRepository {

  5. /**
  6. * 查活动全部商品销量
  7. */
  8. Map<Long, Integer> queryActivityItemSales(ActivityId activityId);

  9. /**
  10. * 查商品指定活动的销量
  11. */
  12. ItemSales queryItemSales(ActivityId activityId, ItemId itemId);

  13. }


领域服务


1、活动配置


完整的活动配置操作,涉及活动及活动商品多个领域对象,并且还需要进行数据持久化。这些职责显然不能放在单个活动或者活动商品对象上,这时我们就需要提炼出一个领域服务。ActivityService这个领域服务就是用来完成活动、活动商品配置,以及活动启用/禁用能力的。


  1. public interface ActivityService {

  2. /**
  3. * 配置活动及活动商品
  4. */
  5. void saveActivity(Activity activity, List<ActivityItem> activityItems);

  6. /**
  7. * 启用/禁用活动
  8. */
  9. void enableActivity(ActivityId activityId, boolean enabled);

  10. }


2、库存扣减


秒杀系统的核心就是正确执行库存扣减,这里定义了库存扣减服务的两大核心方法:扣库存reduce()、cancelReduce()。


扣库存:入参为库存扣减流水,通过流水信息知道扣哪个活动ActivityId哪个用户BuyerId抢购资格,以及订单上的信息(商品id、购买数量、订单id、下单时间)指导扣哪个商品的活动库存;


回库存:入参为库存扣减流水,通过流水信息知道具体怎么把商品活动库存、用户抢购资格给加回去;本质就是扣库存的逆向操作。


  1. /**
  2. * 库存扣减服务
  3. */
  4. public interface StockReduceService {

  5. /**
  6. * 扣库存(同步调用)
  7. */
  8. StockReduceResult reduce(StockReduceFlow flow);

  9. /**
  10. * 回库存
  11. */
  12. StockReduceResult cancelReduce(StockReduceFlow flow);

  13. }


小结


这一章,我们完成了领域对象的定义,并赋予了领域对象部分方法用来封装相应的职责;定义了两个领域服务:1、通过ActivityService,可以做到配置活动、启用/禁用活动;2、通过StockReduceService,可以做到扣库存、回库存;至此,秒杀系统的两大核心业务流程: 「创建秒杀活动」(配活动、配商品)、「参与秒杀活动」(扣库存、回库存)的实现骨架已搭建起来!


应用层


活动应用服务


在DDD四层概念中,提到过应用层就是定义系统需要对外提供的能力。说白了,就是近似定义对外提供的接口集合。对照之前的设计方案,系统需要具备的接口有:配置活动、启用/禁用活动、查看活动列表、查看活动详情、查看活动商品详情。


按图索骥,毫不费力就推导出如下应用服务接口定义:其中配置活动、启用/禁用活动的业务逻辑,可直接借用活动领域服务ActivityService能力;剩下的几个纯粹查询(查看活动列表、查看活动详情、查看活动商品详情)属于「查看秒杀活动」业务流程,没啥业务规则,通过直接调用仓储的数据读取能力就能搞定。


接口定义:


  1. public interface ActivityAppService {

  2. /**
  3. * 配置活动及商品列表
  4. */
  5. Long saveActivity(SaveActivityCommand command);

  6. /**
  7. * 启用/禁用活动
  8. */
  9. void changeActivityStatus(UpdateActivityStatusCommand command);

  10. /**
  11. * 活动列表
  12. */
  13. List<ActivityDTO> activityList();

  14. /**
  15. * 活动详情页(透出活动商品及商品销量)
  16. */
  17. ActivityDetailDTO activityDetail(Long activityId);

  18. /**
  19. * 活动商品详情
  20. */
  21. ActivityItemDetailDTO activityItemDetail(ActivityId activityId, ItemId itemId);

  22. }


接口实现:


  1. @Service
  2. public class ActivityAppServiceImpl implements ActivityAppService {

  3. @Resource
  4. private ActivityRepository activityRepository;

  5. @Resource
  6. private ActivityItemRepository activityItemRepository;

  7. @Resource
  8. private ItemSalesRepository itemSalesRepository;

  9. @Resource
  10. private ActivityAssembler activityAssembler;

  11. @Resource
  12. private ActivityService activityService;

  13. /**
  14. * 配置活动及商品列表
  15. */
  16. @Override
  17. public Long saveActivity(SaveActivityCommand command) {
  18. // 活动
  19. Activity activity = activityAssembler.assembleActivity(command);

  20. // 活动商品
  21. ActivityId activityId = activity.getActivityId();
  22. List<ActivityItem> activityItems = activityAssembler.assembleActivityItem(activityId, command);

  23. activityService.saveActivity(activity, activityItems);
  24. return activityId.getId();
  25. }

  26. /**
  27. * 启用/禁用活动
  28. */
  29. @Override
  30. public void changeActivityStatus(UpdateActivityStatusCommand command) {
  31. ActivityId activityId = new ActivityId(command.getActivityId());
  32. activityService.enableActivity(activityId, command.isEnabled());
  33. }

  34. /**
  35. * 活动列表
  36. */
  37. @Override
  38. public List<ActivityDTO> activityList() {
  39. List<Activity> activityList = activityRepository.listActivity();
  40. return activityList.stream()
  41. .map(act -> activityAssembler.asActivityDTO(act))
  42. .collect(Collectors.toList());
  43. }

  44. /**
  45. * 活动详情页(透出活动商品及商品销量)
  46. */
  47. @Override
  48. public ActivityDetailDTO activityDetail(Long activityId) {
  49. // 活动信息
  50. ActivityId aid = new ActivityId(activityId);
  51. Activity act = activityRepository.findActivity(aid);
  52. Assert.notNull(act, "活动不存在:activityId=" + activityId);

  53. // 活动商品
  54. List<ActivityItem> activityItems = activityItemRepository.queryActivityItems(aid);

  55. // 商品销量
  56. Map<Long, Integer> itemId2Sales = itemSalesRepository.queryActivityItemSales(aid);

  57. List<ActivityItemDTO> itemDTOList = activityItems.stream().map(item -> {
  58. int itemSales = itemId2Sales.getOrDefault(item.getItemId().getId(), 0);
  59. return activityAssembler.assembleActivityItemDTO(item, itemSales);
  60. }).collect(Collectors.toList());

  61. return ActivityDetailDTO.builder()
  62. .activityId(aid.getId())
  63. .activityName(act.getActivityName())
  64. .startTime(act.getStartTime())
  65. .endTime(act.getEndTime())
  66. .enabled(act.isEnabled())
  67. .items(itemDTOList)
  68. .build();
  69. }

  70. /**
  71. * 活动商品详情
  72. */
  73. @Override
  74. public ActivityItemDetailDTO activityItemDetail(ActivityId activityId, ItemId itemId) {
  75. // 活动商品
  76. Optional<ActivityItem> optItem = activityItemRepository.findActivityItem(activityId, itemId);
  77. Assert.isTrue(optItem.isPresent(), "商品不存在:itemId=" + itemId.getId());
  78. ActivityItem item = optItem.get();

  79. // 商品销量
  80. ItemSales itemSales = itemSalesRepository.queryItemSales(activityId, itemId);

  81. // 活动信息
  82. Activity act = activityRepository.findActivity(activityId);
  83. Assert.notNull(act, "活动不存在:activityId=" + activityId.getId());

  84. ActivityDTO activityDTO = ActivityDTO.builder()
  85. .activityId(act.getActivityId().getId())
  86. .activityName(act.getActivityName())
  87. .startTime(act.getStartTime())
  88. .endTime(act.getEndTime())
  89. .enabled(act.isEnabled())
  90. .build();

  91. return ActivityItemDetailDTO.builder()
  92. .itemId(item.getItemId().getId())
  93. .itemTitle(item.getItemTitle())
  94. .subTitle(item.getSubTitle())
  95. .itemImage(item.getItemImage())
  96. .itemPrice(item.getItemPrice())
  97. .activityPrice(item.getActivityPrice())
  98. .quota(item.getQuota())
  99. .stock(item.getStock())
  100. .sold(itemSales.getSold())
  101. .activity(activityDTO)
  102. .build();
  103. }

  104. }


说明:


  • 应用服务直接持有领域服务ActivityService和多个仓储对象(如: ActivityRepository、ActivityItemRepository、ItemSalesRepository);
  • 配置活动、启用/禁用活动:直接交给领域服务来完成;
  • 查活动列表、活动详情、活动商品详情:直接利用仓储对象读取数据,并进行数据整合;不需要领域服务参与。


库存应用服务


参与秒杀活动对外暴露的能力就是扣库存、回库存,这个应该没什么疑问。


同上,应用层我们面向交易系统提供两个API,来定义库存扣减交互协议;其中库存扣减这一核心能力,前面我们在领域服务已进行了封装。故库存扣减这个应用服务的业务逻辑也应该非常薄!


接口定义:


  1. /**
  2. * 库存扣减应用服务
  3. */
  4. public interface StockAppService {

  5. /**
  6. * 扣库存
  7. */
  8. StockReduceResult reduce(ReduceCommand command);

  9. /**
  10. * 回库存
  11. */
  12. StockReduceResult cancelReduce(CancelReduceCommand command);

  13. }


接口实现:


  1. /**
  2. * 库存扣减应用服务
  3. */
  4. @Service
  5. public class StockAppServiceImpl implements StockAppService {

  6. @Resource
  7. private ActivityRepository activityRepository;

  8. @Resource
  9. private StockReduceFlowRepository stockReduceFlowRepository;

  10. @Resource
  11. private StockReduceFlowAssembler stockReduceFlowAssembler;

  12. @Resource
  13. private StockReduceService stockReduceService;

  14. /**
  15. * 扣库存
  16. */
  17. @Override
  18. public StockReduceResult reduce(@NonNull ReduceCommand command) {
  19. ActivityId activityId = new ActivityId(command.getActivityId());
  20. StockReduceFlow reduceFlow = stockReduceFlowAssembler.assembleStockReduceFlow(command);

  21. // 前置校验:活动是否存在
  22. Activity activity = activityRepository.findActivity(activityId);
  23. if (Objects.isNull(activity)) {
  24. return StockReduceResult.error(BizStatusCode.ACTIVITY_NOT_EXISTS, activityId.getId());
  25. }

  26. // 前置校验:活动是否进行中
  27. OrderInfo orderInfo = reduceFlow.getOrderInfo();
  28. if (!activity.onSale(orderInfo.getOrderTime())) {
  29. return StockReduceResult.error(BizStatusCode.ACTIVITY_OFFLINE, activityId.getId());
  30. }

  31. // 活动准入规则校验
  32. ActivityAccessContext accessContext = stockReduceFlowAssembler.assembleActivityAccessContext(command);
  33. ActivityRuleCheckResult activityRuleCheckResult = activity.canPass(accessContext);
  34. if (!activityRuleCheckResult.isPass()) {
  35. return StockReduceResult.error(activityRuleCheckResult.getErrmsg());
  36. }

  37. // 扣库存
  38. return stockReduceService.reduce(reduceFlow);
  39. }

  40. /**
  41. * 回库存
  42. */
  43. @Override
  44. public StockReduceResult cancelReduce(@NonNull CancelReduceCommand command) {
  45. ActivityId activityId = new ActivityId(command.getActivityId());
  46. OrderId orderId = new OrderId(command.getOrderId());

  47. Optional<StockReduceFlow> optFlow = stockReduceFlowRepository.queryStockReduceFlow(activityId, orderId);
  48. if (optFlow.isPresent()) {
  49. StockReduceFlow reduceFlow = optFlow.get();
  50. return stockReduceService.cancelReduce(reduceFlow);
  51. }
  52. return StockReduceResult.ok();
  53. }

  54. }


说明:


  • 扣库存:在调用库存扣减领域服务之前,还做了一些前置校验工作,比如校验活动是否存在、活动是否进行中、以及当前请求是否符合活动准入规则;
  • 回库存:通过活动id和订单id,得到库存扣减流水,然后直接调用库存扣减领域服务进行回库存操作。


小结


这一章,我们完成了两个应用服务的定义:1、通过ActivityAppService,我们完成了面向用户(买家、运营)的查看秒杀活动、配置秒杀活动的接口定义;2、通过StockAppService,完成了面向用户(交易系统)的扣库存、回库存接口定义。至此,配置秒杀活动、查看秒杀活动、参与秒杀活动三大业务流程,所需的接口能力都已定义清楚。


用户界面层


用户界面层的职责就是向用户展示信息(读请求),以及将用户的命令(写请求)传递到应用层、领域层甚至基础设施层(比如直接写数据库)。这一层毫无业务逻辑可言。搞笑点说,就是面向视觉稿编程,用户需要什么就给什么。有了应用服务的接口定义,用户界面层也不费神,通常就是直接利用应用服务的能力!


活动接口:


  1. @Api(value = "活动接口")
  2. @RestController
  3. @RequestMapping("/api/v1/activity")
  4. public class ActivityController {

  5. @Resource
  6. private ActivityAppService activityAppService;

  7. @ApiOperation(value = "配置活动")
  8. @PostMapping("/save")
  9. public Response<Long> saveActivity(@RequestBody SaveActivityCommand command) {
  10. Long activityId = activityAppService.saveActivity(command);
  11. return ResponseBuilder.ok(activityId);
  12. }

  13. @ApiOperation(value = "启用/禁用活动")
  14. @PostMapping("/changeStatus")
  15. public Response<Void> changeActivityStatus(@RequestBody UpdateActivityStatusCommand command) {
  16. activityAppService.changeActivityStatus(command);
  17. return ResponseBuilder.ok();
  18. }

  19. @ApiOperation(value = "活动列表")
  20. @GetMapping("/list")
  21. public Response<List<ActivityDTO>> activityList() {
  22. List<ActivityDTO> dto = activityAppService.activityList();
  23. return ResponseBuilder.ok(dto);
  24. }

  25. @ApiOperation(value = "活动详情(透出活动商品及商品销量)")
  26. @GetMapping("/detail")
  27. public Response<ActivityDetailDTO> activityDetail(
  28. @ApiParam(value = "activityId", defaultValue = "1") @RequestParam("activityId") Long activityId) {
  29. ActivityDetailDTO activityDetailDTO = activityAppService.activityDetail(activityId);
  30. return ResponseBuilder.ok(activityDetailDTO);
  31. }

  32. @ApiOperation(value = "活动商品详情(透出商品销量)")
  33. @GetMapping("/itemDetail")
  34. public Response<ActivityItemDetailDTO> activityItemDetail(
  35. @ApiParam(value = "activityId", defaultValue = "1") @RequestParam("activityId") Long activityId,
  36. @ApiParam(value = "itemId", defaultValue = "53724") @RequestParam("itemId") Long itemId) {
  37. ActivityId curActivityId = new ActivityId(activityId);
  38. ItemId curItemId = new ItemId(itemId);

  39. ActivityItemDetailDTO activityDetailDTO = activityAppService.activityItemDetail(curActivityId, curItemId);
  40. return ResponseBuilder.ok(activityDetailDTO);
  41. }

  42. }


库存扣减接口:


  1. @Api(value = "库存扣减接口")
  2. @RestController
  3. @RequestMapping("/api/v1/stock")
  4. public class StockController {

  5. @Resource
  6. private StockAppService stockAppService;

  7. @ApiOperation(value = "减库存")
  8. @PostMapping("/reduce")
  9. public Response<StockReduceResult> tryReduce(@RequestBody ReduceCommand command) {
  10. StockReduceResult res = stockAppService.reduce(command);
  11. return ResponseBuilder.ok(res);
  12. }

  13. @ApiOperation(value = "回库存")
  14. @PostMapping("/cancelReduce")
  15. public Response<StockReduceResult> cancelReduce(@RequestBody CancelReduceCommand command) {
  16. StockReduceResult res = stockAppService.cancelReduce(command);
  17. return ResponseBuilder.ok(res);
  18. }

  19. }


如果熟悉swagger-ui使用方式,启动工程访问http://localhost:8080/swagger-ui.html,就会看到如下界面:


基础设施层


前面噼里啪啦一大堆,看到的大多是接口定义,好奇宝宝们还是想知道到底怎样做库存扣减的,接下来我们就讲讲库存扣减的实现。在领域层我们定义了领域服务,但仅仅只是一个接口,领域服务的实现是在基础设施层。本文的基调就是Redis+Lua脚本实现秒杀,库存扣减服务的实现StockReduceServiceImpl就是完全依赖Lua脚本。


领域服务实现


库存扣减领域服务实现类StockReduceServiceImpl:


  1. @Slf4j
  2. @Service
  3. public class StockReduceServiceImpl implements StockReduceService {

  4. @Resource
  5. private Gson gson;

  6. @Resource
  7. private RedissonClient redissonClient;

  8. private String reduceLua;

  9. private String cancelReduceLua;

  10. @PostConstruct
  11. public void scriptLoading() {
  12. try {
  13. reduceLua = LuaScriptHelper.readScript(LuaScriptConstant.Seckill.REDUCE_LUA);
  14. cancelReduceLua = LuaScriptHelper.readScript(LuaScriptConstant.Seckill.CANCEL_REDUCE_LUA);
  15. } catch (IOException ioe) {
  16. throw new IllegalStateException("Script not found!", ioe);
  17. }
  18. }

  19. /**
  20. * 执行Lua脚本扣库存
  21. */
  22. @Override
  23. public StockReduceResult reduce(StockReduceFlow flow) {
  24. Long activityId = flow.getActivityId().getId();
  25. Long itemId = flow.getOrderInfo().getItemId().getId();

  26. /* KEYS[1] 库存扣减流水,KEYS[2] 活动商品,KEYS[3] 买家已购,KEYS[4] 商品销量 */
  27. String stockReduceFlowHash = SeckillNamespace.stockReduceFlowHash(activityId);
  28. String activityItemHash = SeckillNamespace.activityItemsHash(activityId);
  29. String buyerHoldHash = SeckillNamespace.buyerHoldHash(activityId, itemId);
  30. String itemSalesHash = SeckillNamespace.itemSalesHash(activityId);
  31. List<Object> keys = Lists.newArrayList(stockReduceFlowHash, activityItemHash, buyerHoldHash, itemSalesHash);

  32. /* ARGV[1] 订单id,ARGV[2] 买家id,ARGV[3] 商品id,ARGV[4] 抢购数量,ARGV[5] json化库存扣减流水 */
  33. StockReduceFlowDO reduceFlowDO = StockReduceFlowConverter.toDO(flow);
  34. String reduceFlowJson = gson.toJson(reduceFlowDO);
  35. Object[] values = {
  36. reduceFlowDO.getOrderId(),
  37. reduceFlowDO.getBuyerId(),
  38. reduceFlowDO.getItemId(),
  39. reduceFlowDO.getQuantity(),
  40. reduceFlowJson
  41. };

  42. // 执行减库存Lua脚本
  43. String resultCode = LuaScriptHelper.create(redissonClient)
  44. .evalLuaScript(keys, values, reduceLua);

  45. if (!LuaResultDictionary.SUCCESS_RESULT.equals(resultCode)) {
  46. Status status = LuaResultDictionary.mapping(resultCode);
  47. String errmsg = status.getMsg(reduceFlowDO.getOrderId());
  48. log.error("reduce_exception||reduceFlow={}||errmsg={}", reduceFlowJson, errmsg);
  49. return StockReduceResult.error(errmsg);
  50. }

  51. log.info("reduce_success||reduceFlow={}", reduceFlowJson);
  52. return StockReduceResult.ok();
  53. }

  54. /**
  55. * 执行Lua脚本回库存
  56. */
  57. @Override
  58. public StockReduceResult cancelReduce(StockReduceFlow stockReduceFlow) {
  59. ActivityId activityId = stockReduceFlow.getActivityId();
  60. OrderInfo orderInfo = stockReduceFlow.getOrderInfo();
  61. OrderId orderId = orderInfo.getOrderId();
  62. ItemId itemId = orderInfo.getItemId();

  63. Long aid = activityId.getId();
  64. String stockReduceFlowHash = SeckillNamespace.stockReduceFlowHash(aid);
  65. String buyerHoldHash = SeckillNamespace.buyerHoldHash(aid, itemId.getId());
  66. String itemSalesHash = SeckillNamespace.itemSalesHash(aid);

  67. /* KEYS[1] 库存扣减流水,KEYS[2] 买家已购,KEYS[3] 商品销量 */
  68. List<Object> keys = Lists.newArrayList(stockReduceFlowHash, buyerHoldHash, itemSalesHash);

  69. /* ARGV[1] 订单id */
  70. Object[] values = {orderId.getId()};

  71. // 执行回库存Lua脚本
  72. String resultCode = LuaScriptHelper.create(redissonClient)
  73. .evalLuaScript(keys, values, cancelReduceLua);

  74. if (!LuaResultDictionary.SUCCESS_RESULT.equals(resultCode)) {
  75. Status status = LuaResultDictionary.mapping(resultCode);
  76. String errmsg = status.getMsg(orderId.getId());
  77. log.error("cancel_reduce_exception||activityId={}||orderId={}||itemId={}||errmsg={}",
  78. aid, orderId.getId(), itemId.getId(), errmsg);

  79. return StockReduceResult.error(errmsg);
  80. }

  81. log.info("cancel_reduce_success||activityId={}||orderId={}||itemId={}", aid, orderId.getId(), itemId.getId());
  82. return StockReduceResult.ok();
  83. }

  84. }


StockReduceServiceImpl唯一有意义的事就是在启动时加载Lua脚本,剩下干的事就是传参给Lua脚本。所以,真相就在Lua脚本。看看Lua脚本都在干啥?

扣库存Lua脚本:


1、通过校验库存扣减流水是否已存在,来判断是否重复请求;


2、加载商品活动配置,得到商品每人限购数量及活动库存,进而判断用户抢购资格以及商品库存是否充足;


3、真正做扣库存该干的事:记录库存扣减流水、增买家已购计数、增商品销量计数。


  1. --[[
  2. KEYS[1] 库存扣减流水, KEYS[2] 活动商品, KEYS[3] 买家已购, KEYS[4] 商品销量
  3. ARGV[1] 订单id, ARGV[2] 买家id, ARGV[3] 商品id, ARGV[4] 抢购数量, ARGV[5] json化库存扣减流水
  4. --]]
  5. local orderId = ARGV[1];
  6. local buyerId = ARGV[2];
  7. local itemId = ARGV[3];

  8. -- 防重判断
  9. local STOCK_REDUCE_FLOW_HASH = KEYS[1];
  10. local flowExists = redis.call("HEXISTS", STOCK_REDUCE_FLOW_HASH, orderId);
  11. if flowExists == 1 then
  12. return "REPEATED_REQUEST";
  13. end

  14. -- 校验商品是否参加了活动
  15. local ACTIVITY_ITEMS_HASH = KEYS[2];
  16. local activityExists = redis.call("HEXISTS", ACTIVITY_ITEMS_HASH, itemId);
  17. if activityExists == 0 then
  18. return "ITEM_ACTIVITY_ABSENT";
  19. end

  20. -- 加载活动商品配置
  21. local config = redis.call("HGET", ACTIVITY_ITEMS_HASH, itemId);
  22. local payload = cjson.decode(config);

  23. -- 用户已购数量
  24. local BUYER_HOLD_HASH = KEYS[3];
  25. local bookedCount = redis.call("HGET", BUYER_HOLD_HASH, buyerId);
  26. if bookedCount == false then
  27. bookedCount = 0;
  28. end
  29. if bookedCount + tonumber(ARGV[4]) > tonumber(payload["quota"]) then
  30. return "QUOTA_NOT_ENOUGH";
  31. end

  32. -- 商品累计售出数量
  33. local ITEM_SALES_HASH = KEYS[4];
  34. local soldCount = redis.call("HGET", ITEM_SALES_HASH, itemId);
  35. if soldCount == false then
  36. soldCount = 0;
  37. end
  38. if soldCount + tonumber(ARGV[4]) > tonumber(payload["stock"]) then
  39. return "STOCK_NOT_ENOUGH";
  40. end

  41. -- 记录库存扣减流水、增买家已购、增商品销量
  42. redis.call("HSET", STOCK_REDUCE_FLOW_HASH, orderId, ARGV[5]);
  43. redis.call("HINCRBY", BUYER_HOLD_HASH, buyerId, ARGV[4]);
  44. redis.call("HINCRBY", ITEM_SALES_HASH, itemId, ARGV[4]);
  45. return "OK";


回库存Lua脚本:


1、通过校验库存扣减流水是否已存在,来判断是否重复请求;


2、加载商品库存扣减流水,从流水得到用户需要回滚的已购数量以及商品销量需要回滚的数量;


3、真正做回库存该干的事: 减买家已购计数、减商品销量计数、删除库存扣减计数。


  1. --[[
  2. KEYS[1] 库存扣减流水, KEYS[2] 活动商品, KEYS[3] 买家已购, KEYS[4] 商品销量
  3. ARGV[1] 订单id, ARGV[2] 买家id, ARGV[3] 商品id, ARGV[4] 抢购数量, ARGV[5] json化库存扣减流水
  4. --]]
  5. local orderId = ARGV[1];
  6. local buyerId = ARGV[2];
  7. local itemId = ARGV[3];

  8. -- 防重判断
  9. local STOCK_REDUCE_FLOW_HASH = KEYS[1];
  10. local flowExists = redis.call("HEXISTS", STOCK_REDUCE_FLOW_HASH, orderId);
  11. if flowExists == 1 then
  12. return "REPEATED_REQUEST";
  13. end

  14. -- 校验商品是否参加了活动
  15. local ACTIVITY_ITEMS_HASH = KEYS[2];
  16. local activityExists = redis.call("HEXISTS", ACTIVITY_ITEMS_HASH, itemId);
  17. if activityExists == 0 then
  18. return "ITEM_ACTIVITY_ABSENT";
  19. end

  20. -- 加载活动商品配置
  21. local config = redis.call("HGET", ACTIVITY_ITEMS_HASH, itemId);
  22. local payload = cjson.decode(config);

  23. -- 用户已购数量
  24. local BUYER_HOLD_HASH = KEYS[3];
  25. local bookedCount = redis.call("HGET", BUYER_HOLD_HASH, buyerId);
  26. if bookedCount == false then
  27. bookedCount = 0;
  28. end
  29. if bookedCount + tonumber(ARGV[4]) > tonumber(payload["quota"]) then
  30. return "QUOTA_NOT_ENOUGH";
  31. end

  32. -- 商品累计售出数量
  33. local ITEM_SALES_HASH = KEYS[4];
  34. local soldCount = redis.call("HGET", ITEM_SALES_HASH, itemId);
  35. if soldCount == false then
  36. soldCount = 0;
  37. end
  38. if soldCount + tonumber(ARGV[4]) > tonumber(payload["stock"]) then
  39. return "STOCK_NOT_ENOUGH";
  40. end

  41. -- 记录库存扣减流水、增买家已购、增商品销量
  42. redis.call("HSET", STOCK_REDUCE_FLOW_HASH, orderId, ARGV[5]);
  43. redis.call("HINCRBY", BUYER_HOLD_HASH, buyerId, ARGV[4]);
  44. redis.call("HINCRBY", ITEM_SALES_HASH, itemId, ARGV[4]);
  45. return "OK";


仓库实现


本文的存储介质百分百为Redis,故仓储的实现,完全就是利用Redis的数据结构来做纯CRUD,没有任何业务逻辑和技术含量,故仅以活动仓储ActivityRepositoryImpl来示例。


仓储实现类ActivityRepositoryImpl: 利用的是Redis的Hash结构,活动信息存储在activity_catalog这个Hash结构中:


  1. @Repository
  2. public class ActivityRepositoryImpl implements ActivityRepository {

  3. @Resource
  4. private Gson gson;

  5. @Resource
  6. private RedissonClient redissonClient;

  7. /**
  8. * 活动列表
  9. */
  10. @Override
  11. public List<Activity> listActivity() {
  12. String activityCatalogHash = SeckillNamespace.activityCatalogHash();

  13. RMap<String, String> activityMap = redissonClient.getMap(activityCatalogHash);
  14. List<ActivityDO> activityList = activityMap.values().stream()
  15. .map(activity -> gson.fromJson(activity, ActivityDO.class))
  16. .collect(Collectors.toList());
  17. return activityList.stream()
  18. .map(ActivityConverter::fromDO)
  19. .collect(Collectors.toList());
  20. }

  21. /**
  22. * 根据活动id查活动
  23. */
  24. @Override
  25. public Activity findActivity(ActivityId activityId) {
  26. String activityCatalogHash = SeckillNamespace.activityCatalogHash();

  27. Map<String, String> activityMap = redissonClient.getMap(activityCatalogHash);
  28. String activity = activityMap.get(String.valueOf(activityId.getId()));
  29. Assert.hasText(activity, "活动不存在:activityId=" + activityId.getId());

  30. ActivityDO activityDO = gson.fromJson(activity, ActivityDO.class);
  31. return ActivityConverter.fromDO(activityDO);
  32. }

  33. /**
  34. * 保存活动
  35. */
  36. @Override
  37. public void saveActivity(Activity activity) {
  38. ActivityId activityId = activity.getActivityId();
  39. ActivityDO activityDO = ActivityConverter.toDO(activity);

  40. String activityCatalogHash = SeckillNamespace.activityCatalogHash();
  41. redissonClient.getMap(activityCatalogHash)
  42. .put(String.valueOf(activityId.getId()), gson.toJson(activityDO));
  43. }

  44. }


总结


本文遵循DDD领域建模思想,从领域层、应用层、用户界面层逐层复原秒杀系统设计方案的落地全过程,并结合源码进行了分析阐述。其实,代码不重要,建模过程中的思想才是最有价值的,希望读者能领略到DDD建模的魅力,并在实际工作中进行运用实践!学习都是从模仿开始,感兴趣的读者可以将《领域驱动设计:软件核心复杂性应对之道》示例代码工程源码或楼主的工程代码码下载下来参考。


其它未讲到的点:


  • 活动准入规则:活动规则的实现比较有技巧性,楼主未曾展开讲。挑战点就是如何将一段字符串转换为成Java类,核心代码见com.cgx.marketing.domain.model.activity.rule.ActivityRuleRegistrar 和 com.cgx.marketing.domain.model.activity.rule.BaseActivityRule,看了不后悔!
  • 工程里面提供了一个单测模拟1000个用户并发10万次扣库存请求,耗时约32秒,即系统扣库存单机Qps达到3000+;读者可以从单测入手,通过调试逐渐加深对代码的理解。单测代码见com.cgx.marketing.application.activity.StockAppServiceTest;最后再次建议参照楼主Github项目的README文档把工程Run起来,上手更快!

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表