百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分类 > 正文

秒杀系统—4.第二版升级优化的技术文档二

ztj100 2025-06-04 08:58 8 浏览 0 评论

大纲

7.秒杀系统的秒杀活动服务实现

(1)数据库表设计

(2)秒杀活动状态机

(3)添加秒杀活动

(4)删除秒杀活动

(5)修改秒杀活动

(6)后台查询秒杀活动

(7)前台查询秒杀活动

(8)查询秒杀商品的销售进度

(9)秒杀活动添加秒杀商品

(10)秒杀活动删除秒杀商品

(11)触发渲染秒杀活动的商品列表页和商品详情页

(12)触发库存分片并同步到Redis

(13)清理秒杀活动相关的数据


7.秒杀系统的秒杀活动服务实现

(1)数据库表设计

(2)秒杀活动状态机

(3)添加秒杀活动

(4)删除秒杀活动

(5)修改秒杀活动

(6)后台查询秒杀活动

(7)前台查询秒杀活动

(8)查询秒杀商品的销售进度

(9)秒杀活动添加秒杀商品

(10)秒杀活动删除秒杀商品

(11)触发渲染秒杀活动的商品列表页和商品详情页

(12)触发库存分片并同步到Redis

(13)清理秒杀活动相关的数据


(1)数据库表设计

一.秒杀活动表

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("seckill_activity")
public class Activity implements Serializable {
    //主键
    private Long id;
    //秒杀活动名称
    private String activityName;
    //秒杀活动开始展示时间
    private Date showTime;
    //秒杀活动开始时间
    private Date startTime;
    //秒杀活动结束时间
    private Date endTime;
    //秒杀活动状态
    private Integer status;
    //秒杀活动的商品列表页是否已经渲染好了,秒杀活动页面是否渲染好
    private Boolean pageReady;
    //审核备注
    private String auditComment;
    private Date createTime;
    private Date updateTime;
}

二.秒杀活动和秒杀商品关系表

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("seckill_activity_sku_ref")
public class ActivitySkuRef implements Serializable {
    //主键
    private Long id;
    //秒杀活动id
    private Long activityId;
    //商品skuId
    private Long skuId;
    //秒杀商品的原价
    private Integer price;
    //秒杀商品的秒杀库存
    private Integer seckillStock;
    //下面三个字段是秒杀结束之后的库存状态
    private Integer salableStock;
    //对秒杀系统里的Redis库存进行锁定
    private Integer lockedStock;
    //如果用户抢购商品成功后完成了订单支付,就会把商品库存进行扣减,已售库存
    private Integer soldStock;
    //秒杀商品的详情页是否已经渲染好
    private Boolean pageReady;
    private Date createTime;
    private Date updateTime;
}

(2)秒杀活动状态机

(3)添加秒杀活动

步骤一:构建一个秒杀活动记录对象

步骤二:将该秒杀活动对象的状态设为"新创建"

@RestController
@RequestMapping("/activity")
public class ActivityController {
    @Autowired
    private ActivityService activityService;
    ...
    
    //添加秒杀活动
    @PostMapping("/save")
    public MapResult save(@RequestBody ActivitySaveRequest request) {
        String validateResult = request.validateParams();
        if (Objects.nonNull(validateResult)) {
            return MapResult.errorResult().setInfo(validateResult);
        }
  
        //构造器模式,构建一个秒杀活动
        Activity.ActivityBuilder builder = Activity.builder();
        //设置来自参数的字段值
        builder.activityName(request.getActivityName()).showTime(request.getShowTime()).startTime(request.getStartTime()).endTime(request.getEndTime());
        //将该秒杀活动对象的状态设为"新创建"
        builder.status(ActivityStatusVal.NEW_CREATE.getCode());
        builder.pageReady(Boolean.FALSE);
        builder.auditComment(null);
  
        Activity activity = builder.build();
        activityService.save(activity);
        log.info("保存活动,activityId={},activity={}", activity.getId(), JSON.toJSONString(activity));
        return MapResult.successResult().set("id", activity.getId());
    }
    ...
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    ...
    
    @Override
    public void save(Activity activity) {
        activity.setCreateTime(new Date());
        activity.setUpdateTime(activity.getCreateTime());
        activityMapper.insert(activity);
    }
    ...
}

(4)删除秒杀活动

如果一场秒杀活动处于新创建的状态,那么是可以进行删除的。如果一场秒杀活动处于审核通过的状态,那么也是可以进行删除的。但如果一场秒杀活动处于页面渲染中以及之后的状态,则不能随便删除。


所以可以规定一场秒杀活动:如果处于"页面渲染中"之前的状态,则都可以直接删除。如果处于"页面渲染中"以及之后的状态,则都不可以删除,但可标记无效。当一场秒杀活动被标记为无效后,可以让其执行正常的结束清理逻辑。


删除秒杀活动的具体逻辑是:首先判断是否可以删除,如果可以删除则释放商品库存 + 删除商品和活动的关系 + 删除秒杀活动

@RestController
@RequestMapping("/activity")
public class ActivityController {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    
    @Autowired
    private ProductApi productApi;
    ...
    
    //删除秒杀活动
    @DeleteMapping("/remove/{id}")
    public MapResult remove(@PathVariable("id") Long id) {
        //查询秒杀活动
        Activity activity = activityService.queryById(id);
  
        //如果秒杀活动activity处于"页面渲染中"之前的状态,则可以直接删除;
        //如果秒杀活动activity处于"页面渲染中"以及之后的状态,则不可以删除,但可标记无效;
        //比如秒杀活动已经审核通过并展示给用户了、快准备开始了,此时则不能删除
        //比如秒杀活动已经开始了,而且秒杀活动还没结束,此时也不能删除
        if (activity.getStatus() >= ActivityStatusVal.PAGE_RENDERING.getCode()) {
            return MapResult.errorResult().setInfo("活动在当前状态下不可以删除");
        }
      
        //查询秒杀商品和秒杀活动的关联关系
        List<ActivitySkuRef> activitySkuRefs = activitySkuRefService.queryByActivityId(id);
        List<SkuIdAndStock> skuIdAndStockList = new ArrayList<>();
        for (ActivitySkuRef activitySkuRef : activitySkuRefs) {
            skuIdAndStockList.add(new SkuIdAndStock(activitySkuRef.getSkuId(), activitySkuRef.getSeckillStock()));
        }
      
        //调用商品服务释放商品库存
        productApi.batchReleaseStock(skuIdAndStockList);
        log.info("释放秒杀活动关联的秒杀商品的库存");
  
        //删除秒杀商品和秒杀活动的关联关系
        List<Long> activitySkuRefIds = activitySkuRefs.stream().map(ActivitySkuRef::getId).collect(Collectors.toList());
        if (CollectionUtils.isNotEmpty(activitySkuRefIds)) {
            activitySkuRefService.batchRemove(activitySkuRefIds);
        }
        log.info("删除秒杀活动和秒杀商品关联关系");
  
        //删除秒杀活动
        activityService.remove(id);
        log.info("删除秒杀活动");
        return MapResult.successResult();
    }
    ...
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    
    //查询秒杀活动
    @Override
    public Activity queryById(Long id) {
        return activityMapper.selectById(id);
    }
    
    //删除秒杀活动
    @Override
    public void remove(Long id) {
        activityMapper.deleteById(id);
    }
    ...
}

@Service
public class ActivitySkuRefServiceImpl implements ActivitySkuRefService {
    @Autowired
    private ActivitySkuRefMapper activitySkuRefMapper;
    
    @Override
    public ActivitySkuRef queryById(Long id) {
        return activitySkuRefMapper.selectById(id);
    }
    
    //查询秒杀商品和秒杀活动的关系
    @Override
    public List<ActivitySkuRef> queryByActivityId(Long activityId) {
        return queryByActivityIds(Collections.singletonList(activityId));
    }
    
    //删除秒杀商品和秒杀活动的关联关系
    @Override
    public void batchRemove(List<Long> ids) {
        activitySkuRefMapper.deleteBatchIds(ids);
    }
    ...
}

@FeignClient("demo-product-system")
@RequestMapping("/product")
public interface ProductApi {
    //根据多个skuId和指定的商品数量批量释放商品库存
    @PostMapping("/batchReleaseStock")
    MapResult batchReleaseStock(@RequestBody List<SkuIdAndStock> skuIdAndStockList);
    ...
}

@RestController
@RequestMapping("/product")
public class ProductController {
    @Autowired
    private SkuService skuService;
    ...
    
    @PutMapping("/lockStock")
    public Boolean lockStock(@RequestParam("skuId") Long skuId, @RequestParam("count") Integer count) {
        if (skuService.lockStock(skuId, count)) {
            log.info("锁定商品库存, skuId={}, 锁定的库存={}", skuId, count);
            return Boolean.TRUE;
        }
        log.info("锁定商品库存但是库存不足, skuId={}, 锁定的库存={}", skuId, count);
        return Boolean.FALSE;
    }
    
    @PutMapping("/batchReleaseStock")
    public Boolean batchReleaseStock(@RequestBody List<SkuIdAndStock> skuIdAndStockList) {
        for (SkuIdAndStock skuIdAndStock : skuIdAndStockList) {
            skuService.releaseStock(skuIdAndStock.getSkuId(), skuIdAndStock.getStock());
            log.info("释放商品库存, skuId={}, 锁定的库存={}", skuIdAndStock.getSkuId(), skuIdAndStock.getStock());
        }
        return Boolean.TRUE;
    }
    ...
}

@Service
public class SkuServiceImpl implements SkuService {
    @Autowired
    private SkuMapper skuMapper;
    ...
    
    @Override
    public boolean lockStock(Long skuId, Integer count) {
        int affectedRows = skuMapper.lockStock(skuId, count);
        return affectedRows == 1;
    }
    
    @Override
    public boolean releaseStock(Long skuId, Integer count) {
        int affectedRows = skuMapper.releaseStock(skuId, count);
        return affectedRows == 1;
    }
    ...
}

public interface SkuMapper extends BaseMapper<Sku> {
    @Update("UPDATE sku SET current_stock = current_stock - #{count}, locked_stock = locked_stock + #{count} " +
        "WHERE id = #{id} AND current_stock > #{count}")
    @ResultType(Integer.class)
    int lockStock(@Param("id") Long id, @Param("count") Integer count);

    @Update("UPDATE sku SET current_stock = current_stock + #{count}, locked_stock = locked_stock - #{count} " +
        "WHERE id = #{id} AND locked_stock > #{count}")
    @ResultType(Integer.class)
    int releaseStock(@Param("id") Long id, @Param("count") Integer count);
}

(5)修改秒杀活动

一.判断是否可修改("审核通过"前的状态都可修改)

二.可以修改的字段:名称、开始时间、结束时间

@RestController
@RequestMapping("/activity")
public class ActivityController {
    @Autowired
    private ActivityService activityService;
    ...
    
    //修改秒杀活动
    @PutMapping("/modify")
    public MapResult modify(@RequestBody ActivityModifyRequest request) {
        String validateResult = request.validateParams();
        if (Objects.nonNull(validateResult)) {
            return MapResult.errorResult().setInfo(validateResult);
        }
        Activity activity = activityService.queryById(request.getActivityId());
        if (Objects.isNull(activity)) {
            return MapResult.errorResult().setInfo("活动不存在");
        }
        //采取严格的措施,但凡秒杀活动审核已通过,则不让修改
        if (activity.getStatus() >= ActivityStatusVal.AUDIT_PASS.getCode()) {
            return MapResult.errorResult().setInfo("该活动在当前状态下不允许修改");
        }
        activityService.modify(request);
        return MapResult.successResult();
    }
    ...
    
    //提交审核秒杀活动
    @PutMapping("/submit/{id}")
    public MapResult submit(@PathVariable("id") Long id) {
        activityService.updateStatus(id, ActivityStatusVal.NEW_CREATE.getCode(), ActivityStatusVal.AUDIT_PENDING.getCode());
        return MapResult.successResult();
    }
    
    //审核秒杀活动
    @PutMapping("/audit/{id}")
    public MapResult audit(@PathVariable("id") Long id, Boolean result) {
        if (result) {
            //审核通过
            activityService.updateStatus(id, ActivityStatusVal.AUDIT_PENDING.getCode(), ActivityStatusVal.AUDIT_PASS.getCode());
        } else {
            //审核不通过
            activityService.updateStatus(id, ActivityStatusVal.AUDIT_PENDING.getCode(), ActivityStatusVal.AUDIT_NOT_PASS.getCode());
        }
        return MapResult.successResult();
    }
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    ...
    
    //查询秒杀活动
    @Override
    public Activity queryById(Long id) {
        return activityMapper.selectById(id);
    }
    
    @Override
    public void modify(ActivityModifyRequest request) {
        Activity.ActivityBuilder builder = Activity.builder().id(request.getActivityId());
        if (StringUtils.isNotBlank(request.getActivityName())) {
            builder.activityName(request.getActivityName());
        }
        if (Objects.nonNull(request.getStartTime())) {
            builder.startTime(request.getStartTime());
        }
        if (Objects.nonNull(request.getEndTime())) {
            builder.endTime(request.getEndTime());
        }
        activityMapper.updateById(builder.build());
    }
    
    @Override
    public boolean updateStatus(Long activityId, Integer oldStatus, Integer newStatus) {
        Activity activity = Activity.builder().status(newStatus).build();
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("id", activityId);
        if (Objects.nonNull(oldStatus)) {
            queryWrapper.eq("status", oldStatus);
        }
        int affectedRows = activityMapper.update(activity, queryWrapper);
        return affectedRows == 1;
    }
    ...
}

(6)后台查询秒杀活动

一.按照名称、状态、时间范围分页查询

二.返回秒杀活动信息和秒杀活动下的商品数量

@RestController
@RequestMapping("/activity")
public class ActivityController {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    ...
    
    //后台查询秒杀活动
    @GetMapping("/queryPage")
    public MapResult queryPage(@RequestBody ActivityQueryPageRequest request) {
        String validateResult = request.validateParams();
        if (Objects.nonNull(validateResult)) {
            return MapResult.errorResult().setInfo(validateResult);
        }
  
        IPage<Activity> page = activityService.queryPage(request);
        List<Activity> activities = page.getRecords();
        if (CollectionUtils.isEmpty(activities)) {
            return MapResult.successResult().set("totalPage", 1).set("totalCount", 0).set("dataList", Collections.emptyList());
        }
  
        List<Long> activityIds = activities.stream().map(Activity::getId).collect(Collectors.toList());
        List<ActivitySkuRef> activitySkuRefs = activitySkuRefService.queryByActivityIds(activityIds);
        Map<Long, List<ActivitySkuRef>> activityIdSkuRefMap = activitySkuRefs.stream().collect(Collectors.groupingBy(ActivitySkuRef::getActivityId));
  
        List<Object> dataList = new ArrayList<>();
        for (Activity activity : activities) {
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("activityId", activity.getId());
            data.put("activityName", activity.getActivityName());
            data.put("showTime", activity.getShowTime());
            data.put("startTime", activity.getStartTime());
            data.put("endTime", activity.getEndTime());
            data.put("status", activity.getStatus());
            data.put("pageReady", activity.getPageReady());
            data.put("auditComment", activity.getAuditComment());
            data.put("createTime", activity.getCreateTime());
            data.put("updateTime", activity.getUpdateTime());
            data.put("seckillSkuCount", Optional.ofNullable(activityIdSkuRefMap).map(e -> e.get(activity.getId())).map(List::size).orElse(0));
            dataList.add(data);
        }
        return MapResult.successResult().set("totalPage", page.getPages()).set("totalCount", page.getTotal()).set("dataList", dataList);
    }
    ...
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    ...
    
    @Override
    public IPage<Activity> queryPage(ActivityQueryPageRequest request) {
        Page<Activity> page = new Page<>(request.getPageNum(), request.getPageSize());
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        if (StringUtils.isNotBlank(request.getActivityName())) {
            queryWrapper.eq("activity_name", request.getActivityName());
        }
        if (StringUtils.isNotBlank(request.getMinStartTime())) {
            queryWrapper.ge("start_time", request.getMinStartTime());
        }
        if (StringUtils.isNotBlank(request.getMaxStartTime())) {
            queryWrapper.le("start_time", request.getMaxStartTime());
        }
        return activityMapper.selectPage(page, queryWrapper);
    }
    ...
}

@Service
public class ActivitySkuRefServiceImpl implements ActivitySkuRefService {
    @Autowired
    private ActivitySkuRefMapper activitySkuRefMapper;
    ...
    
    @Override
    public List<ActivitySkuRef> queryByActivityIds(List<Long> activityIds) {
        QueryWrapper<ActivitySkuRef> queryWrapper = new QueryWrapper<>();
        queryWrapper.in("activity_id", activityIds);
        return activitySkuRefMapper.selectList(queryWrapper);
    }
    ...
}

(7)前台查询秒杀活动

对于已经开始和即将开始的秒杀活动,可让用户查看对应的商品列表。对于已经结束的和要过很久才开始的秒杀活动,则不让用户在前台查看。


至于一场秒杀活动什么时候可以被被展示出来,决定于提前多久把该秒杀活动对应的静态面渲染好、商品库存同步好。


一场秒杀活动的商品列表按道理应该要被提前展示出来,这样用户才能查看,但不同性质的秒杀活动提前展示的时间是不同的。比如特别热的秒杀活动,可能需要让用户提前一天来查看。而不是特别热门的秒杀活动,则只需要让用户提前半天来查看。


因此,前台查询秒杀活动的接口应该:返回已经到了"开始展示时间"的所有秒杀活动 + 通过读取内存缓存来进行查询。比如定时把一段时间内需要展示的秒杀活动全部都加载到内存缓存中,因为秒杀活动的特点就是直到秒杀结束后都是固定不会变的。

@RestController
@RequestMapping("/activity/frontend")
public class FrontendActivityController {
    @Autowired
    private ActivityCacheSupport activityCacheSupport;

    //可以新增一个接口,让用户实时查看某个商品秒杀售卖的进度
    //进度就从Redis里查询,秒杀进度 = (锁定库存 + 已售库存) / 总库存
    //总库存 = 可售库存 + 锁定库存 + 已售库存
    //进度可以缓存在本地JVM里,每隔1分钟刷新一次

    //前台查询秒杀活动
    @GetMapping("/queryList")
    public MapResult queryList() {
        List<Activity> activities = activityCacheSupport.queryShowableActivity();
        if (CollectionUtils.isEmpty(activities)) {
            return MapResult.successResult().set("dataList", Collections.emptyList());
        }
        List<Object> dataList = new ArrayList<>();
        for (Activity activity : activities) {
            Map<String, Object> data = new LinkedHashMap<>();
            data.put("activityId", activity.getId());
            data.put("activityName", activity.getActivityName());
            data.put("showTime", activity.getShowTime());
            data.put("startTime", activity.getStartTime());
            data.put("endTime", activity.getEndTime());
            dataList.add(data);
        }
        return MapResult.successResult().set("data", dataList);
    }
}

@Component
public class ActivityCacheSupport implements InitializingBean {
    @Autowired
    private ActivityService activityService;

    private final LoadingCache<String, List<Activity>> currentMinuteShowableActivityCache = CacheBuilder.newBuilder()
        .maximumSize(100)
        .refreshAfterWrite(30, TimeUnit.SECONDS)//每次间隔30s以后自动去做一个刷新缓存
        .build(new CacheLoader<String, List<Activity>>() {
            @Override
            public List<Activity> load(String key) throws Exception {
                return loadActivityList();
            }
        });
   
    //从DB中加载秒杀活动列表
    private List<Activity> loadActivityList() {
        List<Activity> activities = activityService.queryShowableList();
        if (CollectionUtils.isEmpty(activities)) {
            return Collections.emptyList();
        }
        return activities;
    }

    public List<Activity> queryShowableActivity() {
        String currentMinute = FastDateFormat.getInstance("yyyy-MM-dd HH:mm").format(new Date());
        try {
            //如果当前这一分钟的key不存在,Guava Cache会通过CacheLoader.load()方法去加载数据
            //也就是会从DB里加载达到showTime的秒杀活动的最新列表,然后缓存在JVM本地
            //本地缓存currentMinuteShowableActivityCache的有效时间也就1分钟,这也有利于展示已渲染好的、新的秒杀活动
            //get方法注释:Returns the value associated with key in this cache, first loading that value if necessary.
            return currentMinuteShowableActivityCache.get(currentMinute);
        } catch (ExecutionException e) {
            List<Activity> activities = loadActivityList();
            currentMinuteShowableActivityCache.put(currentMinute, activities);
            return activities;
        }
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        String currentMinute = FastDateFormat.getInstance("yyyy-MM-dd HH:mm").format(new Date());
        currentMinuteShowableActivityCache.put(currentMinute, activityService.queryShowableList());
    }
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    ...
    
    //查询用户可以看到的秒杀活动
    //此时此刻用户需要看到2种类型的秒杀活动
    //1.还未开始的但是已经到了展示时间了,showTime <= now
    //2.已经开始了的但是还没结束的,endTime >= now
    @Override
    public List<Activity> queryShowableList() {
        String now = FastDateFormat.getInstance("yyyy-MM-dd HH:mm:ss").format(new Date());
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        queryWrapper.le("show_time", now);
        queryWrapper.ge("end_time", now);
        queryWrapper.eq("status", ActivityStatusVal.INVENTORY_SYNCED.getCode());
        return activityMapper.selectList(queryWrapper);
    }
    ...
}

(8)查询秒杀商品的销售进度

秒杀活动开始后:用户访问秒杀活动的商品列表时,需要看到每个秒杀商品的销售进度。前端在加载完秒杀商品列表后,会异步请求所有秒杀商品的销售进度。这个数据是一个百分比的数字,可以不是实时的,取一个近似值即可。


查询秒杀商品的销售进度的接口:需要前台传来秒杀活动的ID,根据秒杀活动ID查询出所有的秒杀商品(直接查内存缓存),根据秒杀商品skuId查询出所有商品对应的销售进度(直接查内存缓存)。因为这个数据查的只是当前正在进行的秒杀活动的商品数据,所以数据量很小,可以直接存入内存缓存。

@RestController
@RequestMapping("/inventory/frontend")
public class FrontendInventoryController {
    @Autowired
    private InventoryService inventoryService;
    
    private final LoadingCache<String, List<SalePercent>> salePercentCache = CacheBuilder.newBuilder()
        .maximumSize(100)
        .expireAfterWrite(15, TimeUnit.SECONDS)
        .refreshAfterWrite(10, TimeUnit.SECONDS)//每10秒刷新
        .build(new CacheLoader<String, List<SalePercent>>() {
            @Override
            public List<SalePercent> load(String key) throws Exception {
                //从Redis中获取库存数据,更新到本地缓存
                return loadSalePercent(key);
            }
        });

    @PostMapping("/queryCurrentStock")
    public MapResult queryCurrentStock(@RequestBody QueryCurrentStockRequest request) {
        String validateResult = request.validateParams();
        if (Objects.nonNull(validateResult)) {
            return MapResult.errorResult().setInfo(validateResult);
        }
  
        //key的格式为activityId:skuId,skuId
        String key = request.getActivityId() + ":" + request.getSkuIds().stream().map(e -> e + "").collect(Collectors.joining(","));
        List<SalePercent> dataList = Collections.emptyList();
        try {
            dataList = salePercentCache.get(key);
        } catch (ExecutionException e) {
            dataList = loadSalePercent(key);
        }
        return MapResult.successResult().setDataList(dataList);
    }

    //从Redis中获取库存数据
    private List<SalePercent> loadSalePercent(String key) {
        String activityId = key.substring(0, key.indexOf(":"));
        String[] skuIds = key.substring(key.indexOf(":") + 1).split(",");
        List<SalePercent> list = new ArrayList<>();
        for (String skuId : skuIds) {
            //从Redis中获取当前库存数据
            ProductStockVo productStockVo = inventoryService.queryCurrentStock(Long.parseLong(activityId), Long.parseLong(skuId));
            list.add(calculateSalePercent(productStockVo));
        }
        return list;
    }

    private SalePercent calculateSalePercent(ProductStockVo productStockVo) {
        Integer salableStock = productStockVo.getSalableStock();
        Integer lockedStock = productStockVo.getLockedStock();
        Integer soldStock = productStockVo.getSoldStock();
  
        //三者相加等于这个商品的总库存
        int totalStock = salableStock + lockedStock + soldStock;
        double percent = (double) (totalStock - salableStock) / totalStock;
        NumberFormat percentInstance = NumberFormat.getPercentInstance();
        String format = percentInstance.format(percent);
        return new SalePercent(productStockVo.getSkuId(), format);
    }
}

@Service
public class InventoryServiceImpl implements InventoryService {
    @Autowired
    private CacheSupport cacheSupport;
    
    //从Redis中获取当前库存数据
    @Override
    public ProductStockVo queryCurrentStock(Long activityId, Long skuId) {
        //调用RedisCacheSupport.hgetAllOnAllRedis()方法
        List<Map<String, String>> stockList = cacheSupport.hgetAllOnAllRedis(CacheKey.buildStockKey(activityId, skuId));
        int salableStock = 0;
        int lockedStock = 0;
        int soldStock = 0;
        for (Map<String, String> stockMap : stockList) {
            salableStock += Integer.parseInt(stockMap.get(CacheKey.SALABLE_STOCK));
            lockedStock += Integer.parseInt(stockMap.get(CacheKey.LOCKED_STOCK));
            soldStock += Integer.parseInt(stockMap.get(CacheKey.SALED_STOCK));
        }
        return ProductStockVo.builder().activityId(activityId).skuId(skuId).salableStock(salableStock).lockedStock(lockedStock).soldStock(soldStock).build();
    }
    ...
}

public class RedisCacheSupport implements CacheSupport {
    private final JedisManager jedisManager;
    
    public RedisCacheSupport(JedisManager jedisManager) {
        this.jedisManager = jedisManager;
    }
    
    @Override
    public int getRedisCount() {
        return jedisManager.getRedisCount();
    }
    ...
    
    //由于一个商品的库存数据可能会分散在各个Redis节点上
    //所以需要从各个Redis节点查询商品库存数据,然后合并起来才算是一份总的数据
    @Override
    public List<Map<String, String>> hgetAllOnAllRedis(String key) {
        List<Map<String, String>> list = new ArrayList<>();
        for (int i = 0; i < jedisManager.getRedisCount(); i++) {
            try (Jedis jedis = jedisManager.getJedisByIndex(i)) {
                list.add(jedis.hgetAll(key));
            }
        }
        return list;
    }
    ...
}

(9)秒杀活动添加秒杀商品

需要传入秒杀活动的ID + 参加秒杀的商品skuId,需要状态校验("待审核"状态后的秒杀活动都不可以再添加秒杀商品)。如果可以添加商品,则调用商品系统锁定库存(库存不足返回提示)。最后保存一条ActivitySkuRef记录。

@RestController
@RequestMapping("/activity/skuRef")
public class ActivitySkuRefController {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    
    @Autowired
    private ProductApi productApi;
    ...
    
    //给活动添加一个秒杀商品
    @PostMapping("/save")
    public MapResult save(@RequestBody ActivitySkuRefSaveRequest request) {
        String validateResult = request.validateParams();
        if (Objects.nonNull(validateResult)) {
            return MapResult.errorResult().setInfo(validateResult);
        }
  
        //校验活动状态,如果已经审核通过了就不能再添加商品了
        Activity activity = activityService.queryById(request.getActivityId());
        if (Objects.isNull(activity)) {
            return MapResult.errorResult().setInfo("活动不存在");
        }
        if (activity.getStatus() > ActivityStatusVal.AUDIT_PASS.getCode()) {
            return MapResult.errorResult().setInfo("该活动在当前状态下不允许添加商品");
        }
  
        //校验sku是否重复
        Integer count = activitySkuRefService.countByActivityIdAndSkuId(request.getActivityId(), request.getSkuId());
        if (count > 0) {
            return MapResult.errorResult().setInfo("该商品已存在");
        }
  
        //调用依赖的商品系统接口锁定商品库存
        if (!productApi.lockStock(request.getSkuId(), request.getSeckillStock())) {
            return MapResult.errorResult().setInfo("锁定商品库存失败");
        }
        log.info("调用依赖的商品系统锁定商品库存, skuId={}, 锁定的库存={}", request.getSkuId(), request.getSeckillStock());
  
        ActivitySkuRef.ActivitySkuRefBuilder builder = ActivitySkuRef.builder();
        //来自参数的字段值
        builder.activityId(request.getActivityId()).skuId(request.getSkuId()).price(request.getPrice()).seckillStock(request.getSeckillStock());
        //设置字段的初始值
        builder.pageReady(Boolean.FALSE);
        ActivitySkuRef activitySkuRef = builder.build();
        activitySkuRefService.save(activitySkuRef);
        log.info("保存活动和商品之间的关联关系, activitySkuRefId={}", activitySkuRef.getId());
        return MapResult.successResult().set("id", activitySkuRef.getId());
    }
    ...
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    
    //查询秒杀活动
    @Override
    public Activity queryById(Long id) {
        return activityMapper.selectById(id);
    }
    ...
}

@Service
public class ActivitySkuRefServiceImpl implements ActivitySkuRefService {
    @Autowired
    private ActivitySkuRefMapper activitySkuRefMapper;
    ...
    
    @Override
    public Integer countByActivityIdAndSkuId(Long activityId, Long skuId) {
        QueryWrapper<ActivitySkuRef> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("activity_id", activityId);
        queryWrapper.eq("sku_id", skuId);
        return activitySkuRefMapper.selectCount(queryWrapper);
    }
    
    @Override
    public void save(ActivitySkuRef activitySkuRef) {
        activitySkuRef.setCreateTime(new Date());
        activitySkuRef.setUpdateTime(activitySkuRef.getCreateTime());
        activitySkuRefMapper.insert(activitySkuRef);
    }
    ...
}

@FeignClient("demo-product-system")
@RequestMapping("/product")
public interface ProductApi {
    ...
    //根据skuId和指定的商品数量锁定对应的商品库存,不能修改库存
    @PutMapping("/lockStock")
    Boolean lockStock(@RequestParam("skuId") Long skuId, @RequestParam("count") Integer count);
    ...
}

@RestController
@RequestMapping("/product")
public class ProductController {
    @Autowired
    private SkuService skuService;
    ...
    
    @PutMapping("/lockStock")
    public Boolean lockStock(@RequestParam("skuId") Long skuId, @RequestParam("count") Integer count) {
        if (skuService.lockStock(skuId, count)) {
            log.info("锁定商品库存, skuId={}, 锁定的库存={}", skuId, count);
            return Boolean.TRUE;
        }
        log.info("锁定商品库存但是库存不足, skuId={}, 锁定的库存={}", skuId, count);
        return Boolean.FALSE;
    }
    ...
}

@Service
public class SkuServiceImpl implements SkuService {
    @Autowired
    private SkuMapper skuMapper;
    ...
    
    @Override
    public boolean lockStock(Long skuId, Integer count) {
        int affectedRows = skuMapper.lockStock(skuId, count);
        return affectedRows == 1;
    }
    
    @Override
    public boolean releaseStock(Long skuId, Integer count) {
        int affectedRows = skuMapper.releaseStock(skuId, count);
        return affectedRows == 1;
    }
    ...
}

public interface SkuMapper extends BaseMapper<Sku> {
    @Update("UPDATE sku SET current_stock = current_stock - #{count}, locked_stock = locked_stock + #{count} " +
        "WHERE id = #{id} AND current_stock > #{count}")
    @ResultType(Integer.class)
    int lockStock(@Param("id") Long id, @Param("count") Integer count);

    @Update("UPDATE sku SET current_stock = current_stock + #{count}, locked_stock = locked_stock - #{count} " +
        "WHERE id = #{id} AND locked_stock > #{count}")
    @ResultType(Integer.class)
    int releaseStock(@Param("id") Long id, @Param("count") Integer count);
}

(10)秒杀活动删除秒杀商品

需要传入ActivitySkuRef的ID,然后状态校验(在"待审核"状态之后的活动都不可以再删除秒杀商品),接着调用商品系统的接口释放商品库存,最后删除ActivitySkuRef记录。

@RestController
@RequestMapping("/activity/skuRef")
public class ActivitySkuRefController {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    
    @Autowired
    private ProductApi productApi;
    ...
    
    //删除秒杀活动的商品
    @DeleteMapping("/remove")
    public MapResult remove(Long activitySkyRefId) {
        if (Objects.isNull(activitySkyRefId) || activitySkyRefId <= 0) {
            return MapResult.errorResult().setInfo("参数不合法");
        }
        ActivitySkuRef activitySkuRef = activitySkuRefService.queryById(activitySkyRefId);
  
        //校验活动状态,如果已经审核通过了就不能再删除商品了
        Activity activity = activityService.queryById(activitySkuRef.getActivityId());
        if (Objects.isNull(activity)) {
            return MapResult.errorResult().setInfo("活动不存在");
        }
        if (activity.getStatus() > ActivityStatusVal.AUDIT_PASS.getCode()) {
            return MapResult.errorResult().setInfo("该活动在当前状态下不允许删除商品");
        }
  
        //释放库存
        if (!productApi.releaseStock(activitySkuRef.getSkuId(), activitySkuRef.getSeckillStock())) {
            return MapResult.errorResult().setInfo("操作库存失败");
        }
        activitySkuRefService.remove(activitySkyRefId);
        return MapResult.successResult();
    }
    ...
}

@Service
public class ActivitySkuRefServiceImpl implements ActivitySkuRefService {
    @Autowired
    private ActivitySkuRefMapper activitySkuRefMapper;
   
    //查询秒杀商品和秒杀活动的关系
    @Override
    public ActivitySkuRef queryById(Long id) {
        return activitySkuRefMapper.selectById(id);
    }
    ...
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    
    //查询秒杀活动
    @Override
    public Activity queryById(Long id) {
        return activityMapper.selectById(id);
    }
    ...
}

@FeignClient("demo-product-system")
@RequestMapping("/product")
public interface ProductApi {
    ...
    //根据skuId和指定的商品数量释放对应的商品库存,可以修改库存了
    @PutMapping("/releaseStock")
    Boolean releaseStock(@RequestParam("skuId") Long skuId, @RequestParam("count") Integer count);
    ...
}

@RestController
@RequestMapping("/product")
public class ProductController {
    @Autowired
    private SkuService skuService;
    ...
    
    @PutMapping("/releaseStock")
    public Boolean releaseStock(@RequestParam("skuId") Long skuId, @RequestParam("count") Integer count) {
        skuService.releaseStock(skuId, count);
        log.info("释放商品库存, skuId={}, 锁定的库存={}", skuId, count);
        return Boolean.TRUE;
    }
    ...
}

@Service
public class SkuServiceImpl implements SkuService {
    @Autowired
    private SkuMapper skuMapper;
    ...
    
    @Override
    public boolean lockStock(Long skuId, Integer count) {
        int affectedRows = skuMapper.lockStock(skuId, count);
        return affectedRows == 1;
    }
    
    @Override
    public boolean releaseStock(Long skuId, Integer count) {
        int affectedRows = skuMapper.releaseStock(skuId, count);
        return affectedRows == 1;
    }
    ...
}

public interface SkuMapper extends BaseMapper<Sku> {
    @Update("UPDATE sku SET current_stock = current_stock - #{count}, locked_stock = locked_stock + #{count} " +
        "WHERE id = #{id} AND current_stock > #{count}")
    @ResultType(Integer.class)
    int lockStock(@Param("id") Long id, @Param("count") Integer count);

    @Update("UPDATE sku SET current_stock = current_stock + #{count}, locked_stock = locked_stock - #{count} " +
        "WHERE id = #{id} AND locked_stock > #{count}")
    @ResultType(Integer.class)
    int releaseStock(@Param("id") Long id, @Param("count") Integer count);
}

(11)触发渲染秒杀活动的商品列表页和商品详情页

一.什么时候触发渲染

首先必须在秒杀活动的展示时间之前完成渲染,因为一旦到了展示时间,虽然秒杀活动还没开始,但用户已经能看到。用户可以看到秒杀活动,就会查看秒杀活动里有哪些秒杀商品。也就是会查看秒杀活动的商品列表,以及查看秒杀商品的详情。这时面上是不会展示任何与库存相关的信息的,因为活动还没开始,所以可以在秒杀活动展示时间的前1小时就开始渲染这些面。


二.如何知道所有面已渲染完成

首先必须要知道什么时候完成了面渲染,因为当所有面渲染完成后,才可以去同步库存到Redis。当一个面渲染完成之后,会发送一条页面渲染结果的消息到MQ,然后就可以消费该消息修改对应Activity表和ActivitySkuRef表的字段,接着再count一下判断是否所有面都已渲染完成。如果是那就修改活动的状态,表示已经完成页面渲染。


三.详细步骤的时序图

下面是发送渲染页面消息的定时任务:

@Component
public class TriggerPageTask {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    
    @Autowired
    private LockService lockService;
    
    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    @Scheduled(fixedDelay = 10_000)
    public void run() {
        //通过加锁,可以确保,同时只有一个定时调度任务在处理页面渲染触发
        String lockToken = lockService.tryLock(CacheKey.TRIGGER_PAGE_LOCK, 1, TimeUnit.SECONDS);
        if (lockToken == null) {
            return;
        }
        log.info("触发渲染页面,获取分布式锁成功, lockToken={}", lockToken);
        try {
            //在秒杀活动展示之前1小时开始渲染页面
            //发起渲染条件是:showTime - now < 1小时,同时秒杀活动已通过审核
            List<Activity> activities = activityService.queryListForTriggerPageTask();
            if (CollectionUtils.isEmpty(activities)) {
                return;
            }
            for (Activity activity : activities) {
                Long id = activity.getId();
                List<ActivitySkuRef> activitySkuRefs = activitySkuRefService.queryByActivityId(id);
                if (CollectionUtils.isEmpty(activitySkuRefs)) {
                    continue;
                }
                //发送渲染秒杀活动商品列表页的消息
                List<Long> skuIds = activitySkuRefs.stream().map(ActivitySkuRef::getSkuId).collect(Collectors.toList());
                String renderActivityPageMessage = PageRenderMessage.builder()
                    .pageCode("seckill_activity")
                    .bizData(ImmutableMap.of("type", "activity", "activityId", id))
                    .params(ImmutableMap.of("activityId", id, "activityName", activity.getActivityName(), "startTime", activity.getStartTime(), "endTime", activity.getEndTime(), "skuIds", skuIds))
                    .fileName(FileNameUtils.generateSeckillActivityFilename(id))
                    .build().toJsonString();
                rocketMQTemplate.syncSend(QueueKey.QUEUE_RENDER_PAGE, renderActivityPageMessage);
                log.info("触发渲染页面,发送渲染商品列表页的消息, message={}", renderActivityPageMessage);
  
                for (ActivitySkuRef activitySkuRef : activitySkuRefs) {
                    //发送渲染秒杀商品详情页的消息
                    Long skuId = activitySkuRef.getSkuId();
                    String renderProductPageMessage = PageRenderMessage.builder()
                        .pageCode("seckill_product")
                        .bizData(ImmutableMap.of("type", "product", "activityId", id, "skuId", skuId))
                        .params(ImmutableMap.of("skuId", skuId))
                        .fileName(FileNameUtils.generateSeckillProductFilename(skuId))
                        .build().toJsonString();
                    rocketMQTemplate.syncSend(QueueKey.QUEUE_RENDER_PAGE, renderProductPageMessage);
                    log.info("触发渲染页面,发送渲染商品详情页的消息, message={}", renderProductPageMessage);
                }
  
                //把秒杀活动的状态修改为页面渲染中
                activityService.updateStatus(id, ActivityStatusVal.AUDIT_PASS.getCode(), ActivityStatusVal.PAGE_RENDERING.getCode());
                log.info("触发渲染页面,把秒杀活动状态改成页面渲染中");
            }
        } finally {
            lockService.release(CacheKey.TRIGGER_PAGE_LOCK, lockToken);
            log.info("触发渲染页面,释放分布式锁");
        }
    }
}

//进行Redis加锁时,会对key进行hash路由到某个Redis节点,再执行具体的加锁逻辑
public class RedisLockService implements LockService {
    private static final String DEL_KEY_BY_VALUE = 
        "if redis.call('get', '%s') == '%s'" +
        "then" +
        "   redis.call('del','%s');" +
        "   return '1';" +
        "else" +
        "    return '0'" +
        "end";
        
    private final JedisManager jedisManager;
    
    public RedisLockService(JedisManager jedisManager) {
        this.jedisManager = jedisManager;
    }

    @Override
    public String tryLock(String lockKey, long expiration, TimeUnit timeUnit) {
        int hashKey = lockKey.hashCode();
        try (Jedis jedis = jedisManager.getJedisByHashKey(hashKey)) {
            String lockToken = UUID.randomUUID().toString();
            String result = jedis.set(lockKey, lockToken, SetParams.setParams().nx().px(timeUnit.toMillis(expiration)));
            if ("OK".equals(result)) {
                return lockToken;
            }
        }
        return null;
    }

    @Override
    public boolean release(String lockKey, String lockToken) {
        int hashKey = lockKey.hashCode();
        try (Jedis jedis = jedisManager.getJedisByHashKey(hashKey)) {
            String script = String.format(DEL_KEY_BY_VALUE, lockKey, lockToken, lockKey);
            String result = (String) jedis.eval(script);
            if ("1".equals(result)) {
                return true;
            }
        }
        return false;
    }
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    ...
    
    //1.距离展示时间还有1小时
    //2.状态是已审核的
    @Override
    public List<Activity> queryListForTriggerPageTask() {
        Date showTime = DateUtils.addHours(new Date(), 1);
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        queryWrapper.le("show_time", showTime);
        queryWrapper.eq("status", ActivityStatusVal.AUDIT_PASS.getCode());
        return activityMapper.selectList(queryWrapper);
    }
    
    @Override
    public boolean updateStatus(Long activityId, Integer oldStatus, Integer newStatus) {
        Activity activity = Activity.builder().status(newStatus).build();
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("id", activityId);
        if (Objects.nonNull(oldStatus)) {
            queryWrapper.eq("status", oldStatus);
        }
        int affectedRows = activityMapper.update(activity, queryWrapper);
        return affectedRows == 1;
    }
    ...
}

下面是消费渲染页面结果的消息

//消费渲染页面结果的消息(每渲染完一个页面就会发送一条页面渲染结果的消息)
@Component
@RocketMQMessageListener(topic = QueueKey.QUEUE_RENDER_PAGE_RESULT, consumerGroup = "pageResultGroup")
public class PageResultListener implements RocketMQListener<String> {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    
    @Override
    public void onMessage(String messageString) {
        log.info("收到渲染页面的结果, message={}", messageString);
        JSONObject message = JSONObject.parseObject(messageString);
        if (!message.getBoolean("success")) {
            log.error("页面渲染失败,需要及时查看问题");
            return;
        }
  
        //获取指定的bizData
        //渲染秒杀活动列表页时指定的bizData如下:
        //.bizData(ImmutableMap.of("type", "activity", "activityId", activity.getId()))
        //渲染秒杀商品详情页时指定的bizData如下:
        //.bizData(ImmutableMap.of("type", "product", "activityId", activity.getId(), "skuId", activitySkuRef.getSkuId()))
        JSONObject bizData = message.getJSONObject("bizData");
        String type = bizData.getString("type");
        Long activityId = bizData.getLong("activityId");
  
        //判断本次渲染成功的页面,是活动列表页还是商品详情页
        if (StringUtils.equals(type, "activity")) {
            activityService.updatePageReady(activityId, true);
            log.info("收到渲染页面的结果, 是活动页面的结果, 把活动的pageReady字段修改为true");
        } else if (StringUtils.equals(type, "product")) {
            activitySkuRefService.updatePageReady(activityId, bizData.getLong("skuId"), true);
            log.info("收到渲染页面的结果, 是商品页面的结果, 把商品的pageReady字段修改为true");
        }
  
        //判断当前活动是否所有的静态页面都渲染好了
        Activity activity = activityService.queryById(activityId);
        //count一下该秒杀活动下还没渲染完成的商品数量
        Integer count = activitySkuRefService.countByActivityIdAndPageReady(activityId, false);
        //当秒杀活动的页面已渲染成功 + 秒杀活动的所有商品详情页也渲染成功,则更新秒杀活动的状态为'页面已完成渲染'
        if (activity.getPageReady() && count == 0) {
            //更新该秒杀活动的状态,从"页面渲染中"到"页面已完成渲染"
            activityService.updateStatus(activityId, ActivityStatusVal.PAGE_RENDERING.getCode(), ActivityStatusVal.PAGE_RENDERED.getCode());
            log.info("收到渲染页面的结果, 检查后发现当前活动的活动页面和商品页面都渲染好了,把活动状态改为'页面已渲染'");
            //下一步就是同步库存到Redis,进行库存数据的初始化了
            //触发执行库存数据初始化的定时任务的两个条件:
            //1.秒杀活动的所有页面已渲染完毕 + 2.now距离showTime在1小时以内
        }
    }
}

(12)触发库存分片并同步到Redis

一.什么时候触发库存分片

由于只有秒杀活动开始后才需要库存相关的数据,所以执行库存分片时所有静态面都已渲染完毕,且秒杀活动还没开始。


可以在秒杀活动开始时间前1小时,把秒杀商品的库存分片同步到Redis,进行库存分片时可以同步调用库存服务的接口。库存分片完成后,则修改秒杀活动的状态为"库存已同步"。这样,这个秒杀活动就算就绪了,只要时间一到,用户就可以发起抢购。


二.详细步骤的时序图

//库存分片和同步库存
@Component
public class TriggerStockTask {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    
    @Autowired
    private LockService lockService;
    
    @Autowired
    private InventoryApi inventoryApi;

    @Scheduled(fixedDelay = 10_000)
    public void run() {
        String lockToken = lockService.tryLock(CacheKey.TRIGGER_STOCK_LOCK, 1, TimeUnit.SECONDS);
        if (lockToken == null) {
            return;
        }
        log.info("触发库存分片和同步库存,获取分布式锁成功, lockToken={}", lockToken);
        try {
            //查询已经渲染好页面的所有秒杀活动
            List<Activity> activities = activityService.queryListForTriggerStockTask();
            if (CollectionUtils.isEmpty(activities)) {
                return;
            }
            for (Activity activity : activities) {
                List<ActivitySkuRef> activitySkuRefs = activitySkuRefService.queryByActivityId(activity.getId());
                if (CollectionUtils.isEmpty(activitySkuRefs)) {
                    continue;
                }
                //要进行缓存初始化的商品,封装库存初始化请求
                List<SyncProductStockRequest> request = new ArrayList<>();
                for (ActivitySkuRef activitySkuRef : activitySkuRefs) {
                    SyncProductStockRequest syncProductStockRequest = SyncProductStockRequest.builder()
                        .activityId(activitySkuRef.getActivityId())
                        .skuId(activitySkuRef.getSkuId())
                        .seckillStock(activitySkuRef.getSeckillStock()).build();
                    request.add(syncProductStockRequest);
                }
                //把封装的库存初始化请求,发送到秒杀库存服务里
                //每个商品的库存数据都会分散到各个Redis节点上去,实现对商品库存分片存放
                if (inventoryApi.syncStock(request)) {
                    log.info("触发库存分片和同步库存,调用库存接口将商品库存同步到Redis");
                    activityService.updateStatus(activity.getId(), ActivityStatusVal.PAGE_RENDERED.getCode(), ActivityStatusVal.INVENTORY_SYNCED.getCode());
                    log.info("触发库存分片和同步库存,将秒杀活动的状态修改为库存已同步");
                    //完成库存分片后,用户就可以对商品发起秒杀抢购了
                } else {
                    log.error("触发库存分片和同步库存,库存同步失败");
                }
            }
        } finally {
            lockService.release(CacheKey.TRIGGER_STOCK_LOCK, lockToken);
            log.info("触发库存分片和同步库存,释放分布式锁");
        }
    }
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    ...
    
    //获取状态是已渲染好页面的秒杀活动
    @Override
    public List<Activity> queryListForTriggerStockTask() {
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("status", ActivityStatusVal.PAGE_RENDERED.getCode());
        return activityMapper.selectList(queryWrapper);
    }
    ...
}

@FeignClient("demo-seckill-inventory-service")
@RequestMapping("/inventory")
public interface InventoryApi {
    @PostMapping("/syncStock")
    Boolean syncStock(@RequestBody List<SyncProductStockRequest> request);
    ...
}


@RestController
@RequestMapping("/inventory")
public class InventoryController {
    @Autowired
    private InventoryService inventoryService;
    
    @PostMapping("/syncStock")
    Boolean syncStock(@RequestBody List<SyncProductStockRequest> request) {
        for (SyncProductStockRequest syncProductStockRequest : request) {
            inventoryService.syncStock(syncProductStockRequest.getActivityId(), syncProductStockRequest.getSkuId(), syncProductStockRequest.getSeckillStock());
            log.info("同步商品库存, syncProductStockRequest={}", JSON.toJSONString(syncProductStockRequest));
        }
        return Boolean.TRUE;
    }
    ...
}

@Service
public class InventoryServiceImpl implements InventoryService {
    @Autowired
    private CacheSupport cacheSupport;
    ...
    
    @Override
    public Boolean syncStock(Long activityId, Long skuId, Integer stock) {
        //下面这种分片方式会有一个问题
        //比如,现在库存是10,Redis的节点个数是6
        //那么按照如下方式,最后的结果是:1、1、1、1、1、5
        //但是我们希望尽可能均分成:2、2、2、2、1、1
        //int redisCount = cacheSupport.getRedisCount();
        //int stockPerRedis = stock / redisCount;
        //int stockLastRedis = stock - (stockPerRedis * (redisCount - 1));
  
        //所以改成如下这种分片方式
        //首先获取Redis实例数量,将库存拆分为与Redis实例个数一样的redisCount个库存分片
        int redisCount = cacheSupport.getRedisCount();
        //然后将具体的库存分片结果存放到一个Map中
        //其中key是某Redis节点的索引,value是该Redis节点应该分的库存
        Map<Integer, Integer> map = new HashMap<>();
        for (int i = 0; i < stock; i++) {
            //均匀把stock的数据分散放到我们的各个节点上去
            int index = i % redisCount;
            //对每个节点的库存数量不停进行累加操作
            map.putIfAbsent(index, 0);
            map.put(index, map.get(index) + 1);
        }
  
        List<Map<String, String>> stockList = new ArrayList<>();
        for (int i = 0; i < redisCount; i++) {
            Map<String, String> stockMap = new HashMap<>();
            stockMap.put(CacheKey.SALABLE_STOCK, map.get(i) + "");
            stockMap.put(CacheKey.LOCKED_STOCK, "0");
            stockMap.put(CacheKey.SOLD_STOCK, "0");
            stockList.add(stockMap);
            log.info("库存分片 stockMap={}", JSON.toJSONString(stockMap));
        }
        cacheSupport.hsetOnAllRedis(CacheKey.buildStockKey(activityId, skuId), stockList);
        return Boolean.TRUE;
    }
    ...
}

public class RedisCacheSupport implements CacheSupport {
    private final JedisManager jedisManager;
    
    public RedisCacheSupport(JedisManager jedisManager) {
        this.jedisManager = jedisManager;
    }
    
    @Override
    public int getRedisCount() {
        return jedisManager.getRedisCount();
    }
    ...
    
    @Override
    public void hsetOnAllRedis(String key, List<Map<String, String>> hashList) {
        for (int i = 0; i < jedisManager.getRedisCount(); i++) {
            //通过hset命令,向每个Redis节点写入库存分片数据
            try (Jedis jedis = jedisManager.getJedisByIndex(i)) {
                jedis.hset(key, hashList.get(i));
            }
        }
    }
    ...
}

(13)清理秒杀活动相关的数据

当秒杀活动结束后,就需要清理静态面的数据和用户相关的缓存数据。比如以到了实际结束时间之后为准,不考虑因为库存抢购完而结束的情况。其中对于缓存数据的清理,会通过手动设置过期时间来实现自动过期。

@Component
public class CleanDataTask {
    @Autowired
    private ActivityService activityService;
    
    @Autowired
    private ActivitySkuRefService activitySkuRefService;
    
    @Autowired
    private LockService lockService;
    
    @Autowired
    private InventoryApi inventoryApi;

    @Scheduled(fixedDelay = 10_000)
    public void run() {
        String lockToken = lockService.tryLock(CacheKey.CLEAN_DATA_LOCK, 1, TimeUnit.SECONDS);
        if (lockToken == null) {
            return;
        }
        log.info("秒杀活动结束后清理数据,获取分布式锁成功, lockToken={}", lockToken);
        try {
            //查询已经结束1小时的秒杀活动
            //当秒杀活动都结束1小时后,对于抢购成功的商品,要么已支付,要么已自动取消,因为订单默认是超过30分钟不支付就取消的
            List<Activity> activities = activityService.queryListForCleanDataTask();
            if (CollectionUtils.isEmpty(activities)) {
                return;
            }
  
            for (Activity activity : activities) {
                List<ActivitySkuRef> activitySkuRefs = activitySkuRefService.queryByActivityId(activity.getId());
                if (CollectionUtils.isEmpty(activitySkuRefs)) {
                    continue;
                }
  
                //清理库存
                List<Long> skuIds = activitySkuRefs.stream().map(ActivitySkuRef::getSkuId).collect(Collectors.toList());
                CleanStockRequest request = CleanStockRequest.builder().activityId(activity.getId()).skuIds(skuIds).build();
                List<ProductStockVo> productStockVos = inventoryApi.cleanStock(request);
  
                //将此时秒杀商品的库存数据保存到数据库中,方便以后查看本次秒杀活动最终数据
                for (ProductStockVo vo : productStockVos) {
                    activitySkuRefService.modify(vo.getActivityId(), vo.getSkuId(), vo.getSalableStock(), vo.getLockedStock(), vo.getSoldStock());
                    log.info("秒杀活动结束后清理数据,保存库存信息, stock={}", JSON.toJSONString(vo));
                }
  
                //修改秒杀活动状态
                activityService.updateStatus(activity.getId(), ActivityStatusVal.INVENTORY_SYNCED.getCode(), ActivityStatusVal.DATA_CLEANED.getCode());
            }
        } finally {
            lockService.release(CacheKey.TRIGGER_STOCK_LOCK, lockToken);
            log.info("秒杀活动结束之后请清理数据,释放分布式锁");
        }
    }
}

//进行Redis加锁时,会对key进行hash路由到某个Redis节点,再执行具体的加锁逻辑
public class RedisLockService implements LockService {
    private static final String DEL_KEY_BY_VALUE = 
        "if redis.call('get', '%s') == '%s'" +
        "then" +
        "   redis.call('del','%s');" +
        "   return '1';" +
        "else" +
        "    return '0'" +
        "end";
    
    private final JedisManager jedisManager;
    
    public RedisLockService(JedisManager jedisManager) {
        this.jedisManager = jedisManager;
    }
    
    @Override
    public String tryLock(String lockKey, long expiration, TimeUnit timeUnit) {
        int hashKey = lockKey.hashCode();
        try (Jedis jedis = jedisManager.getJedisByHashKey(hashKey)) {
            String lockToken = UUID.randomUUID().toString();
            String result = jedis.set(lockKey, lockToken, SetParams.setParams().nx().px(timeUnit.toMillis(expiration)));
            if ("OK".equals(result)) {
                return lockToken;
            }
        }
        return null;
    }

    @Override
    public boolean release(String lockKey, String lockToken) {
        int hashKey = lockKey.hashCode();
        try (Jedis jedis = jedisManager.getJedisByHashKey(hashKey)) {
            String script = String.format(DEL_KEY_BY_VALUE, lockKey, lockToken, lockKey);
            String result = (String) jedis.eval(script);
            if ("1".equals(result)) {
                return true;
            }
        }
        return false;
    }
}

@Service
public class ActivityServiceImpl implements ActivityService {
    @Autowired
    private ActivityMapper activityMapper;
    ...
    
    //获取状态是库存已同步+结束时间已经过了1小时的秒杀活动
    @Override
    public List<Activity> queryListForCleanDataTask() {
        Date endTime = DateUtils.addHours(new Date(), -1);
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        queryWrapper.le("end_time", endTime);
        queryWrapper.eq("status", ActivityStatusVal.INVENTORY_SYNCED.getCode());
        return activityMapper.selectList(queryWrapper);
    }
    
    @Override
    public boolean updateStatus(Long activityId, Integer oldStatus, Integer newStatus) {
        Activity activity = Activity.builder().status(newStatus).build();
        QueryWrapper<Activity> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("id", activityId);
        if (Objects.nonNull(oldStatus)) {
            queryWrapper.eq("status", oldStatus);
        }
        int affectedRows = activityMapper.update(activity, queryWrapper);
        return affectedRows == 1;
    }
    ...
}

@FeignClient("demo-seckill-inventory-service")
@RequestMapping("/inventory")
public interface InventoryApi {
    ...
    @PostMapping("/cleanStock")
    List<ProductStockVo> cleanStock(@RequestBody CleanStockRequest request);
}

@RestController
@RequestMapping("/inventory")
public class InventoryController {
    @Autowired
    private InventoryService inventoryService;
    ...
    
    @PostMapping("/cleanStock")
    List<ProductStockVo> cleanStock(@RequestBody CleanStockRequest request) {
        List<ProductStockVo> resultList = new ArrayList<>();
        Long activityId = request.getActivityId();
        //查询库存&删除库存
        for (Long skuId : request.getSkuIds()) {
            ProductStockVo productStockVo = inventoryService.queryCurrentStock(activityId, skuId);
            inventoryService.cleanStock(activityId, skuId);
            resultList.add(productStockVo);
        }
        return resultList;
    }
    ...
}

@Service
public class InventoryServiceImpl implements InventoryService {
    @Autowired
    private CacheSupport cacheSupport;
    
    //从Redis中获取当前库存数据
    @Override
    public ProductStockVo queryCurrentStock(Long activityId, Long skuId) {
        List<Map<String, String>> stockList = cacheSupport.hgetAllOnAllRedis(CacheKey.buildStockKey(activityId, skuId));
        int salableStock = 0;
        int lockedStock = 0;
        int soldStock = 0;
        for (Map<String, String> stockMap : stockList) {
            salableStock += Integer.parseInt(stockMap.get(CacheKey.SALABLE_STOCK));
            lockedStock += Integer.parseInt(stockMap.get(CacheKey.LOCKED_STOCK));
            soldStock += Integer.parseInt(stockMap.get(CacheKey.SOLD_STOCK));
        }
        return ProductStockVo.builder().activityId(activityId).skuId(skuId).salableStock(salableStock).lockedStock(lockedStock).soldStock(soldStock).build();
    }
    
    @Override
    public Boolean cleanStock(Long activityId, Long skuId) {
        cacheSupport.delOnAllRedis(CacheKey.buildStockKey(activityId, skuId));
        return Boolean.TRUE;
    }
    ...
}

public class RedisCacheSupport implements CacheSupport {
    private final JedisManager jedisManager;
    
    public RedisCacheSupport(JedisManager jedisManager) {
        this.jedisManager = jedisManager;
    }
    
    @Override
    public int getRedisCount() {
        return jedisManager.getRedisCount();
    }
    ...
    
    @Override
    public List<Map<String, String>> hgetAllOnAllRedis(String key) {
        List<Map<String, String>> list = new ArrayList<>();
        for (int i = 0; i < jedisManager.getRedisCount(); i++) {
            try (Jedis jedis = jedisManager.getJedisByIndex(i)) {
                list.add(jedis.hgetAll(key));
            }
        }
        return list;
    }
    
    @Override
    public Long delOnAllRedis(String key) {
        for (int i = 0; i < jedisManager.getRedisCount(); i++) {
            try (Jedis jedis = jedisManager.getJedisByIndex(i)) {
                jedis.del(key);
            }
        }
        return 1L;
    }
    ...
}

相关推荐

拒绝躺平,如何使用AOP的环绕通知实现分布式锁

如何在分布式环境下,像用synchronized关键字那样使用分布式锁。比如开发一个注解,叫@DistributionLock,作用于一个方法函数上,每次调方法前加锁,调完之后自动释放锁。可以利用Sp...

「解锁新姿势」 兄dei,你代码需要优化了

前言在我们平常开发过程中,由于项目时间紧张,代码可以用就好,往往会忽视代码的质量问题。甚至有些复制粘贴过来,不加以整理规范。往往导致项目后期难以维护,更别说后续接手项目的人。所以啊,我们要编写出优雅的...

消息队列核心面试点讲解(消息队列面试题)

Rocketmq消息不丢失一、前言RocketMQ可以理解成一个特殊的存储系统,这个存储系统特殊之处数据是一般只会被使用一次,这种情况下,如何保证这个被消费一次的消息不丢失是非常重要的。本文将分析Ro...

秒杀系统—4.第二版升级优化的技术文档二

大纲7.秒杀系统的秒杀活动服务实现...

SpringBoot JPA动态查询与Specification详解:从基础到高级实战

一、JPA动态查询概述1.1什么是动态查询动态查询是指根据运行时条件构建的查询,与静态查询(如@Query注解或命名查询)相对。在业务系统中,80%的查询需求都是动态的,例如电商系统中的商品筛选、订...

Java常用工具类技术文档(java常用工具类技术文档有哪些)

一、概述Java工具类(UtilityClasses)是封装了通用功能的静态方法集合,能够简化代码、提高开发效率。本文整理Java原生及常用第三方库(如ApacheCommons、GoogleG...

Guava 之Joiner 拼接字符串和Map(字符串拼接join的用法)

Guave是一个强大的的工具集合,今天给大家介绍一下,常用的拼接字符串的方法,当然JDK也有方便的拼接字符串的方式,本文主要介绍guava的,可以对比使用基本的拼接的话可以如下操作...

SpringBoot怎么整合Redis,监听Key过期事件?

一、修改Redis配置文件1、在Redis的安装目录2、找到redis.windows.conf文件,搜索“notify-keyspace-events”...

如何使用Python将多个excel文件数据快速汇总?

在数据分析和处理的过程中,Excel文件是我们经常会遇到的数据格式之一。本文将通过一个具体的示例,展示如何使用Python和Pandas库来读取、合并和处理多个Excel文件的数据,并最终生成一个包含...

利用Pandas高效处理百万级数据集,速度提升10倍的秘密武器

处理大规模数据集,尤其是百万级别的数据量,对效率的要求非常高。使用Pandas时,可以通过一些策略和技巧显著提高数据处理的速度。以下是一些关键的方法,帮助你使用Pandas高效地处理大型数据集,从而实...

Python进阶-Day 25: 数据分析基础

目标:掌握Pandas和NumPy的基本操作,学习如何分析CSV数据集并生成报告。课程内容...

Pandas 入门教程 - 第五课: 高级数据操作

在前几节课中,我们学习了如何使用Pandas进行数据操作和可视化。在这一课中,我们将进一步探索一些高级的数据操作技巧,包括数据透视、分组聚合、时间序列处理以及高级索引和切片。高级索引和切片...

原来这才是Pandas!(原来这才是薯片真正的吃法)

听到一些人说,Pandas语法太乱、太杂了,根本记不住。...

python(pandas + numpy)数据分析的基础

数据NaN值排查,统计,排序...

利用Python进行数据分组/数据透视表

1.数据分组源数据表如下所示:1.1分组键是列名分组键是列名时直接将某一列或多列的列名传给groupby()方法,groupby()方法就会按照这一列或多列进行分组。按照一列进行分组...

取消回复欢迎 发表评论: