乐优商城(16)–秒杀服务
一、创建秒杀服务
1.1、创建module
父模块

interface模块

service模块

1.2、pom文件
interface的pom文件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>leyou-seckill</artifactId>
<groupId>com.leyou.seckill</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>leyou-seckill-interface</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.leyou.user</groupId>
<artifactId>leyou-user-interface</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.item</groupId>
<artifactId>leyou-item-interface</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
</project>
service的pom文件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>myLeyou</artifactId>
<groupId>com.leyou.parent</groupId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<groupId>com.leyou.seckill</groupId>
<artifactId>leyou-seckill</artifactId>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<!--服务注册中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!-- feign -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.item</groupId>
<artifactId>leyou-item-interface</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.order</groupId>
<artifactId>leyou-order-interface</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.auth</groupId>
<artifactId>leyou-auth-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.common</groupId>
<artifactId>leyou-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.leyou.seckill</groupId>
<artifactId>leyou-seckill-interface</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<!-- mybatis的启动器 -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
<!-- 通用mapper启动器 -->
<dependency>
<groupId>tk.mybatis</groupId>
<artifactId>mapper-spring-boot-starter</artifactId>
</dependency>
<!-- 分页助手启动器 -->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
</dependency>
<!-- jdbc启动器 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
</dependencies>
</project>
1.3、application.yaml
server:
port: 8090
spring:
application:
name: seckill-service
datasource:
url: jdbc:mysql://127.0.0.1:3306/leyou
username: root
password: 123456
hikari:
max-lifetime: 28830000 # 一个连接的生命时长(毫秒),超时而且没被使用则被释放(retired),缺省:30分钟,建议设置比数据库超时时长少30秒,参考MySQL wait_timeout参数(show variables like '%timeout%';)
maximum-pool-size: 9 # 连接池中允许的最大连接数。缺省值:10;推荐的公式:((core_count * 2) + effective_spindle_count)
driver-class-name: com.mysql.jdbc.Driver
elasticsearch:
rest:
uris: ip地址:9200
rabbitmq:
host: ip地址
username: leyou
password: leyou
virtual-host: /leyou
template:
exchange: leyou.order.exchange
jackson:
default-property-inclusion: non_null
redis:
host: ip地址
port: 8975
cloud:
nacos:
discovery:
server-addr: ip地址:8848
username: nacos
password: nacos
resources:
add-mappings: true
chain:
enabled: true
html-application-cache: true
compressed: true
cache:
period: 3600m
mybatis:
type-aliases-package: com.leyou.seckill.pojo
1.4、启动类
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
@MapperScan("com.leyou.seckill.mapper")
public class LeyouSeckillApplication {
public static void main(String[] args) {
SpringApplication.run(LeyouSeckillApplication.class,args);
}
}
二、页面分析
2.1、页面展示
秒杀商品展示页面:

秒杀商品详情页面:

2.2、需要的数据
秒杀商品列表页面

不难看出所需数据字段有:
image,title,seckill_stock,price,skuId
秒杀商品详情页面
和普通商品详情页面一样
2.3、数据库表设计

实体类
@Table(name = "tb_seckill_sku")
public class SeckillGoods {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; //主键
private Long skuId; //商品id
private Date startTime; //开始时间
private Date endTime; //结束时间
private String title; //标题
private Long seckillPrice;//秒杀价格
private String image; //商品图片
private Boolean enable;//是否秒杀
@Transient
private Integer stock; //秒杀库存
@Transient
private Integer seckillTotal; //秒杀总量
//get和set
}
三、后端接口
3.1、添加秒杀商品
3.1.1、功能需求
根据传入的商品id,将其设置为可以秒杀的商品。主要是针对后台管理开发的功能。
3.1.2、Controller
- 请求方式:POST
- 请求路径:/seckill/addSeckill
- 参数:SeckillParameter对象
- 返回结果:200添加成功
@RestController
@RequestMapping("/seckill")
public class SeckillController {
@Autowired
private SeckillService seckillService;
/**
* 添加秒杀商品
* @param seckillParameter
* @return
*/
@PostMapping("/addSeckill")
public ResponseEntity<Void> addSeckillGoods(@RequestBody SeckillParameter seckillParameter){
if (seckillParameter == null){
return ResponseEntity.badRequest().build();
}
this.seckillService.addSeckillGoods(seckillParameter);
return ResponseEntity.ok().build();
}
}
SeckillParameter
/**
* 秒杀设置参数
*/
public class SeckillParameter {
private Long skuId; //商品id
private Date startTime; //开始时间
private Date endTime; //结束时间
private Integer count; //参与秒杀的商品总量
private Long discount; //折扣
//get和set
}
3.1.3、Mapper
/**
* SeckillGoods 的通用 mapper
*/
public interface SeckillGoodsMapper extends Mapper<SeckillGoods> {
}
/**
* Stock 的通用 mapper
*/
public interface StockMapper extends Mapper<Stock> {
}
3.1.4、Service
public interface SeckillService {
/**
* 添加秒杀商品
* @param seckillParameter
* @return
*/
void addSeckillGoods(SeckillParameter seckillParameter);
}
实现类:
这里为了方便后面的测试,秒杀的开始和结束时间都是在在系统内部设定的
(实际上秒杀活动的开启也是由系统设定,商家应该是选择参不参加)
@Service
public class SeckillServiceImpl implements SeckillService {
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
@Autowired
private StockMapper stockMapper;
@Autowired
private GoodsClient goodsClient;
/**
* 添加秒杀商品
*
* @param seckillParameter
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void addSeckillGoods(SeckillParameter seckillParameter) {
//设置时间
Calendar calendar = Calendar.getInstance();
calendar.setTime(new Date());
seckillParameter.setStartTime(calendar.getTime());
calendar.add(Calendar.HOUR,2);
seckillParameter.setEndTime(calendar.getTime());
//根据skuId查询具体商品
Long skuId = seckillParameter.getSkuId();
Sku sku = this.goodsClient.querySkuById(skuId);
//将信息存入秒杀商品对象中
SeckillGoods seckillGoods = new SeckillGoods();
seckillGoods.setEnable(true);
seckillGoods.setStartTime(seckillParameter.getStartTime());
seckillGoods.setEndTime(seckillParameter.getEndTime());
seckillGoods.setImage(sku.getImages());
seckillGoods.setSkuId(skuId);
seckillGoods.setTitle(sku.getTitle());
seckillGoods.setStock(seckillParameter.getCount());
seckillGoods.setSeckillPrice(sku.getPrice() * seckillParameter.getDiscount());
this.seckillGoodsMapper.insert(seckillGoods);
//更新商品库存信息
Stock stock = this.stockMapper.selectByPrimaryKey(skuId);
if (null == stock.getSeckillStock()) stock.setSeckillStock(0);
if (null == stock.getSeckillTotal()) stock.setSeckillTotal(0);
stock.setSeckillStock(stock.getSeckillStock() + seckillParameter.getCount());
stock.setSeckillTotal(stock.getSeckillTotal() + seckillParameter.getCount());
stock.setStock(stock.getStock() - seckillParameter.getCount());
this.stockMapper.updateByPrimaryKeySelective(stock);
}
}
GoodsClient
@FeignClient(value = "item-service")
public interface GoodsClient extends GoodsApi {
}
3.1.5、添加网关路由

3.2、查询秒杀商品
3.2.1、Controller
- 请求方式:GET
- 请求路径:/seckill/list
- 请求参数:无
- 返回结果:秒杀商品列表
/**
* 查询全部秒杀商品
* @return
*/
@GetMapping("/list")
public ResponseEntity<List<SeckillGoods>> queryAllSeckillGoods(){
List<SeckillGoods> goodsList = this.seckillService.queryAllSeckillGoods();
if (CollectionUtils.isEmpty(goodsList)){
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(goodsList);
}
3.2.2、Service
**
* 查询全部秒杀商品
* @return
*/
List<SeckillGoods> queryAllSeckillGoods();
实现类:
/**
* 查询全部秒杀商品
*
* @return
*/
@Override
public List<SeckillGoods> queryAllSeckillGoods() {
//查询秒杀商品中enable为true
Example example = new Example(SeckillGoods.class);
example.createCriteria().andEqualTo("enable",true);
List<SeckillGoods> seckillGoods = this.seckillGoodsMapper.selectByExample(example);
seckillGoods.forEach(goods -> goods.setStock(this.stockMapper.selectByPrimaryKey(goods.getSkuId()).getStock()));
return seckillGoods;
}
3.3、下单秒杀商品
秒杀商品,每个用户只能秒杀一件商品
(改成每个时间段的秒杀活动,每个用户只能买一件商品好些,这里简单约束了一下)
3.3.1、实体类
@Table(name = "tb_seckill_order")
public class SeckillOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; //秒杀订单id
private Long userId; //用户id
private Long OrderId; //订单id
private Long skuId; //商品id
//get和set
}
3.3.2、Controller
- 请求方式:POST
- 请求路径:/seckill/seck
- 请求参数:SeckillGoods对象
- 返回结果:秒杀成功返回订单号
@PostMapping("/seck")
public ResponseEntity<Long> seckillOrder(@RequestBody SeckillGoods seckillGoods){
//1.创建订单
Long id = this.seckillService.createOrder(seckillGoods);
//2.判断秒杀是否成功
if (null == id){
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build();
}
return ResponseEntity.ok(id);
}
3.3.3、Service
/**
* 创建秒杀订单
* @param seckillGoods
* @return
*/
Long createOrder(SeckillGoods seckillGoods);
实现类:
/**
* 创建秒杀订单
*
* @param seckillGoods
* @return
*/
@Override
public Long createSeckillOrder(SeckillGoods seckillGoods) {
//查询要秒杀商品的库存
Stock stock = this.stockMapper.selectByPrimaryKey(seckillGoods.getSkuId());
//秒杀库存为空
if (stock.getSeckillStock() <= 0) return null;
SeckillOrder seckillOrder = new SeckillOrder();
long orderId = idWorker.nextId();
seckillOrder.setOrderId(orderId);
seckillOrder.setSkuId(seckillGoods.getSkuId());
seckillOrder.setUserId(LoginInterceptor.getLoginUser().getId());
//保存秒杀订单信息
this.seckillOrderMapper.insertSelective(seckillOrder);
//将秒杀商品库存-1
this.stockMapper.reduceSeckStock(seckillGoods.getSkuId());
return orderId;
}
这里需要获取到用户的信息和生成订单id即雪花算法
这里将相关类和配置引入:IdWorkerConfig、LoginInterceptor、IdWorkerProperties、JwtProperties,还有一个最重要的MvcConfig
application.yaml:添加配置

3.3.4、StockMapper
/**
* Stock 的通用 mapper
*/
public interface StockMapper extends Mapper<Stock> {
/**
* 更新对应商品的秒杀库存,且库存必须大于0,否则回滚。
* @param skuId
*/
@Update("update tb_stock set seckill_stock = seckill_stock - 1 where sku_id = #{skuId} and seckill_stock > 0")
void reduceSeckStock(Long skuId);
}
四、接口测试
注意:这里需要修复一些bug:
将leyou-gateway网关中的配置进行修改:

将以下类中的请求路径统一添加前缀item:

4.1、添加秒杀商品
输入数据:
{
"skuId":2868393,
"startTime":"",
"endTime":"",
"count":99,
"discount":0.5
}

数据库:


4.2、查询秒杀商品

4.3、下单秒杀商品
ApiPost启用全局cookie功能,用于传递token


开启后,调用登录授权接口,即可传递token

可调用接口http://api.leyou.com/api/order/list测试是否传递token了
输入数据:
{
"id": 1,
"skuId": 2868393,
"startTime": "2021-08-15T11:37:25.849+00:00",
"endTime": "2021-08-15T11:37:25.849+00:00",
"title": "三星 Galaxy C5(SM-C5000)4GB+32GB 枫叶金 移动联通电信4G手机 双卡双待",
"seckillPrice": 59950,
"image": "http://image.leyou.com/images/12/15/1524297315534.jpg",
"enable": true
}
测试前数据库:


测试后:



库存成功-1
五、秒杀优化
5.1、优化思路
主要就是减少数据库的访问:
- 首先查询当前商品的库存,库存不足直接返回
- 将商品的库存信息存入redis,redis中库存不足直接返回
- 设置一个内存标记,库存为空则标记改变,直接返回
5.2、后端实现
改造秒杀接口,SeckillController中的seckillOrder方法
@Autowired
private StringRedisTemplate redisTemplate;
private static final String SECK_PREFIX = "leyou:seckill:stock:";
//内存标记
private static Map<Long,Boolean> SECKILL_FLAG = new ConcurrentHashMap<>();
/**
* 系统初始化,初始化秒杀商品数量
* @throws Exception
*/
@PostConstruct
public void init(){
//查询全部秒杀商品
List<SeckillGoods> seckillGoods = this.seckillService.queryAllSeckillGoods();
//将秒杀商品库存信息存入redis中
seckillGoods.forEach(goods -> this.redisTemplate.opsForValue().set(SECK_PREFIX+goods.getSkuId(),goods.getStock().toString()));
}
/**
* 创建秒杀订单
* @param seckillGoods
* @return
*/
@PostMapping("/seck")
public ResponseEntity<Long> seckillOrder(@RequestBody SeckillGoods seckillGoods){
Long skuId = seckillGoods.getSkuId();
//内存标记不为空,说明库存已空
if (SECKILL_FLAG.get(skuId) != null) return ResponseEntity.noContent().build();
//使用redis中原子性减操作将库存-1
Long stock = this.redisTemplate.opsForValue().decrement(SECK_PREFIX + skuId);
//如果库存为小于0,则直接返回
if (stock < 0) {
SECKILL_FLAG.put(skuId,true);
//这里将库存+1,防止少卖
this.redisTemplate.opsForValue().increment(SECK_PREFIX + skuId);
//异常,将标记清除
if (SECKILL_FLAG.get(skuId) != null) SECKILL_FLAG.remove(skuId);
return ResponseEntity.noContent().build();
}
//创建订单
Long id = null;
try {
id = this.seckillService.createSeckillOrder(seckillGoods);
} catch (Exception e) {
e.printStackTrace();
//出现异常,会导致数据库和缓存数据不一致,所以需要将redis中库存+1
this.redisTemplate.opsForValue().increment(SECK_PREFIX + skuId);
return ResponseEntity.noContent().build();
}
//判断秒杀是否成功
if (id == null){
return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build();
}
return ResponseEntity.ok(id);
}
这里可能会有点疑惑:为什么是stock < 0而不是小于等于0?
因为stock是减库存后得到的库存数量,若等于0会导致最后一个订单无法创建,导致少卖了
5.3、秒杀订单展示
在用户的订单中心展示的秒杀订单与普通订单一样,所以需要将秒杀订单组装为普通订单
5.3.1、消息生产

这里面要发送的消息内容包含三部分,第一个部分就是用户信息(通过登录拦截器从cookie中解析token获得),第二部分就是秒杀订单的信息,第三部分是秒杀商品信息,所以这里面将其封装为消息对象SeckillMessage。
SeckillMessage
/**
* 秒杀消息
*/
public class SeckillMessage {
//用户信息
private UserInfo userInfo;
//秒杀订单
private SeckillOrder seckillOrder;
//秒杀商品
private SeckillGoods seckillGoods;
public SeckillMessage() {
}
public SeckillMessage(UserInfo userInfo, SeckillOrder seckillOrder,SeckillGoods seckillGoods) {
this.userInfo = userInfo;
this.seckillOrder = seckillOrder;
this.seckillGoods = seckillGoods;
}
public UserInfo getUserInfo() {
return userInfo;
}
public void setUserInfo(UserInfo userInfo) {
this.userInfo = userInfo;
}
public SeckillOrder getSeckillOrder() {
return seckillOrder;
}
public void setSeckillOrder(SeckillOrder seckillOrder) {
this.seckillOrder = seckillOrder;
}
public SeckillGoods getSeckillGoods() {
return seckillGoods;
}
public void setSeckillGoods(SeckillGoods seckillGoods) {
this.seckillGoods = seckillGoods;
}
}
sendMessage
/**
* 发送消息到秒杀队列
* @param seckillMessage
*/
private void sendMessage(SeckillMessage seckillMessage){
String jsonStr = JsonUtils.serialize(seckillMessage);
try {
this.amqpTemplate.convertAndSend("order.seckill",jsonStr);
} catch (Exception e) {
e.printStackTrace();
}
}
rabbitmq配置

直接指定要使用的交换机
5.3.2、消息消费
在订单微服务中设置消息队列监听器,读取消息队列的信息,然后创建订单
创建listener用来接收消息。
- 创建队列:leyou.order.seckill.queue
- 绑定交换机:leyou.order.exchange
- 交换机类型:Topic
- 接收消息类型:order.seckill
导入依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>com.leyou.seckill</groupId>
<artifactId>leyou-seckill-interface</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
SeckillListener
@Component
public class SeckillListener {
@Autowired
private OrderMapper orderMapper;
@Autowired
private SkuMapper skuMapper;
@Autowired
private OrderDetailMapper orderDetailMapper;
@Autowired
private OrderStatusMapper orderStatusMapper;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(value = "leyou.order.seckill.queue",durable = "true"),
exchange = @Exchange(
value = "leyou.order.exchange",
ignoreDeclarationExceptions = "true",
type = ExchangeTypes.TOPIC
),
key = {"order.seckill"}
))
@Transactional
public void listenSeckill(String seck){
//解析消息
SeckillMessage seckillMessage = JsonUtils.parse(seck, SeckillMessage.class);
UserInfo userInfo = seckillMessage.getUserInfo();
SeckillOrder seckillOrder = seckillMessage.getSeckillOrder();
SeckillGoods seckillGoods = seckillMessage.getSeckillGoods();
//将秒杀订单组装为普通订单
Order order = new Order();
order.setPaymentType(1);
// 初始化数据
order.setOrderId(seckillOrder.getOrderId());
order.setTotalPay(seckillGoods.getSeckillPrice());
order.setActualPay(seckillGoods.getSeckillPrice());
order.setPostFee(0+"");
order.setInvoiceType(0);
order.setSourceType(2);
order.setUserId(userInfo.getId());
order.setBuyerNick(userInfo.getUsername());
order.setBuyerRate(false);
order.setCreateTime(new Date());
OrderDetail orderDetail = new OrderDetail();
orderDetail.setOrderId(seckillOrder.getOrderId());
orderDetail.setSkuId(seckillOrder.getSkuId());
orderDetail.setNum(1);
orderDetail.setTitle(seckillGoods.getTitle());
orderDetail.setImage(seckillGoods.getImage());
orderDetail.setPrice(seckillGoods.getSeckillPrice());
orderDetail.setOwnSpec(this.skuMapper.selectByPrimaryKey(seckillOrder.getSkuId()).getOwnSpec());
order.setOrderDetails(Arrays.asList(orderDetail));
//保存订单数据
this.orderMapper.insertSelective(order);
OrderStatus orderStatus = new OrderStatus();
orderStatus.setOrderId(seckillOrder.getOrderId());
orderStatus.setStatus(1);
orderStatus.setCreateTime(order.getCreateTime());
//保存订单详情
this.orderDetailMapper.insertSelective(orderDetail);
//保存订单状态
this.orderStatusMapper.insertSelective(orderStatus);
}
}
六、前端简单渲染
6.1、秒杀商品列表
6.1.1、数据渲染
获取数据

在页面加载成功后,加载数据
定义一个数据模型接收数据

渲染
商品列表是由<li>标签组成的

6.1.2、图片懒加载
首先导入vue相关js:

引入懒加载模块:

图片渲染:

6.1.3、立即抢购
编写一个点击事件:

6.2、秒杀商品详情
6.2.1、查询秒杀商品
页面需要渲染秒杀商品的具体信息,要后端查询出该秒杀商品
首先定义一个数据模型接收该商品:
data:{
ly,
seckillGood:{},//商品详情
skuId: 0 //商品id
},
当页面加载时查询后端数据:

6.2.2、后端实现
- 请求方式:GET
- 请求路径:/seckill/sku/{id}
- 请求参数:id,秒杀商品id
- 返回结果:返回秒杀商品对象
SeckillController
/**
* 根据skuId查询秒杀商品
* @param id
* @return
*/
@GetMapping("/sku/{id}")
public ResponseEntity<SeckillGoods> querySeckillGoodsBySkuId(@PathVariable("id") Long id){
SeckillGoods seckillGoods = this.seckillGoodsService.querySeckillGoodsBySkuId(id);
if (null == seckillGoods) return ResponseEntity.notFound().build();
return ResponseEntity.ok(seckillGoods);
}
SeckillGoodsService
public interface SeckillGoodsService {
/**
* 根据skuId查询秒杀商品
* @param id
* @return
*/
SeckillGoods querySeckillGoodsBySkuId(Long id);
}
实现类:
@Service
public class SeckillGoodsServiceImpl implements SeckillGoodsService {
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
/**
* 根据skuId查询秒杀商品
*
* @param id
* @return
*/
@Override
public SeckillGoods querySeckillGoodsBySkuId(Long id) {
SeckillGoods seckillGoods = new SeckillGoods();
seckillGoods.setSkuId(id);
return this.seckillGoodsMapper.selectOne(seckillGoods);
}
}
SeckillGoodsMapper
/**
* SeckillGoods 的通用 mapper
*/
public interface SeckillGoodsMapper extends Mapper<SeckillGoods> {
}
6.2.3、测试
库存表

秒杀商品

其他表:tb_seckill_order、tb_order、tb_order_detail、tb_order_status则全为空
本地存储

点击立即抢购二维码成功生成:

查看数据库
库存成功减1

秒杀订单成功生成:

普通订单、订单详情、订单状态:



6.2.4、bug
订单id精度损失
将order服务里配置的configureMessageConverters,拷贝即可,添加注解@EnableWebMvc
重新测试:


添加秒杀商品
相同的商品再次添加时应该只修改对应商品的库存,而不是新插入一个商品

七、秒杀安全
7.1、秒杀地址隐藏
防止黄牛等有心人恶意抢单
思路:
- 改造生成秒杀订单的接口,带上秒杀路径
- 生成秒杀路径用于前端请求
- 请求会携带路径到达后端,后端验证路径
7.1.2、创建秒杀路径
Controller
- 请求方式:GET
- 请求路径:/seckill/getPath/{id}
- 请求参数:id,秒杀商品id
- 返回结果:String,秒杀路径
/**
* 创建秒杀路径
* @param id
* @return
*/
@GetMapping("/getPath/{id}")
public ResponseEntity<String> getSeckillPath(@PathVariable("id") Long id){
UserInfo userInfo = LoginInterceptor.getLoginUser();
if (null == userInfo) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
//创建秒杀路径
String path = this.seckillService.createSeckillPath(id,userInfo.getId());
//为空,返回404
if (StringUtils.isEmpty(path)) return ResponseEntity.notFound().build();
return ResponseEntity.ok(path);
}
Service
/**
* 创建秒杀路径
* @param id
* @param userId
* @return
*/
String createSeckillPath(Long id, Long userId);
实现类:
将用户id和秒杀商品的id先进行加密,然后放入redis中,并且设置过期时间为60秒
@Autowired
private StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX_PATH = "leyou:seckill:path:";
/**
* 创建秒杀路径
* @param id
* @param userId
* @return
*/
@Override
public String createSeckillPath(Long id, Long userId) {
//加密商品id和用户id
String path = DigestUtils.md5Hex(id.toString() + userId);
BoundHashOperations<String, Object, Object> boundHashOps = this.redisTemplate.boundHashOps(KEY_PREFIX_PATH);
String key = id + "-" + userId;
boundHashOps.put(key,path);
boundHashOps.expire(60, TimeUnit.SECONDS);
return path;
}
7.1.3、验证秒杀路径
Controller

Service
/**
* 验证秒杀路径
* @param skuId
* @param userId
* @param path
* @return
*/
boolean verifySeckillPath(Long skuId, Long userId, String path);
实现类:
@Override
public boolean verifySeckillPath(Long skuId, Long userId, String path) {
String key = skuId + "-" + userId;
BoundHashOperations<String, Object, Object> boundHashOps = this.redisTemplate.boundHashOps(KEY_PREFIX_PATH);
String encodePath = (String) boundHashOps.get(key);
//验证是否相等
return DigestUtils.md5Hex(skuId.toString()+userId).equals(encodePath);
}
7.1.4、前端请求
先请求秒杀路径再将路径传入

7.2、接口限流
限定用户在某一段时间内有限次的访问地址
7.2.1、自定义注解限流
思路:将用户访问地址的次数写入redis当中,同时设置过期时间。当用户每次访问,该值就加一,当访问次数超出限定数值时,那么就直接返回
定义注解
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface AccessLimit {
int seconds();//限流时间,单位为秒
int maxCount();//最大请求数
boolean needLogin() default true;//是否需要登录,默认需要
}
编写拦截器
@Service
public class AccessInterceptor extends HandlerInterceptorAdapter {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod){
HandlerMethod handlerMethod = (HandlerMethod) handler;
//获取注解信息
AccessLimit accessLimit = handlerMethod.getMethodAnnotation(AccessLimit.class);
// 为空,直接放行
if (null == accessLimit) return true;
UserInfo userInfo = LoginInterceptor.getLoginUser();
int seconds = accessLimit.seconds();
int maxCount = accessLimit.maxCount();
String key = request.getRequestURI();
//需要登录
if (accessLimit.needLogin()){
if (null == userInfo){
sendOut(response,"账号未登录,请先登录");
return false;
}
key += "-" + userInfo.getId();
}
String count = this.redisTemplate.opsForValue().get(key);
if (null == count){
this.redisTemplate.opsForValue().set(key,"1",seconds, TimeUnit.SECONDS);
}else if (Integer.parseInt(count) < maxCount){
this.redisTemplate.opsForValue().increment(key,1);
}else {
sendOut(response,"点击太多次了,请稍后再试");
}
}
return super.preHandle(request, response, handler);
}
/**
* 输出限流信息
* @param response
* @param str
* @throws IOException
*/
private void sendOut(HttpServletResponse response,String str) throws IOException {
ServletOutputStream outputStream = response.getOutputStream();
outputStream.write(str.getBytes(StandardCharsets.UTF_8));
outputStream.flush();
outputStream.close();
}
}
配置拦截器

接口使用

测试
使用ApiPost工具测试,先调用登录授权接口,前提是开启全局cookie:

调用生成秒杀路径接口,限制是20秒内点击5次,20秒内点击六次后

7.2.2、Sentinel限流
Sentinel是阿里开源的项目,提供了流量控制、熔断降级、系统负载保护等多个维度来保障服务之间的稳定性
Sentinel 可以简单的分为 Sentinel 核心库和 Dashboard。核心库不依赖 Dashboard,但是结合Dashboard 可以取得最好的效果。
使用 Sentinel 来进行熔断保护,主要分为几个步骤:
- 定义资源
- 资源:可以是任何东西,一个服务,服务里的方法,甚至是一段代码。
- 定义规则
- 规则:Sentinel 支持以下几种规则:流量控制规则、熔断降级规则、系统保护规则、来源访问控制规则
和 热点参数规则。
- 规则:Sentinel 支持以下几种规则:流量控制规则、熔断降级规则、系统保护规则、来源访问控制规则
- 检验规则是否生效
Sentinel 的所有规则都可以在内存态中动态地查询及修改,修改之后立即生效. 先把可能需要保护的资源定义好,之后再配置规则。
也可以理解为,只要有了资源,就可以在任何时候灵活地定义各种流量控制规则。在编码的时候,只需要考虑这个代码是否需要保护,如果需要保护,就将之定义为一个资源。
导入依赖
<!--sentinel启动器-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
定义资源
entinel 支持通过 @SentinelResource 注解定义资源并配置 blockHandler 和 fallback 函数来进行限流之后的处理

/**
* 被流控或降级调用的处理方法
* 注意:
* 访问权限一定要public
* 返回值一定要和源方法保证一致, 包含源方法的参数。
* 可以在参数最后添加BlockException 可以区分是什么规则的处理方法
* @param skuId
* @param e
* @return
*/
public ResponseEntity<String> blockHandlerForGetSeckillPath(Long skuId, BlockException e){
e.printStackTrace();
String msg = "服务器正忙,请稍后再试";
return ResponseEntity.ok(msg);
}
定义规则
/**
* 初始化规则
*/
@PostConstruct
public void initFlowRules(){
//可能不止一个规则,所以这里是集合
List<FlowRule> rules = new ArrayList<>();
//流控规则
FlowRule flowRule = new FlowRule();
//指定流控资源
flowRule.setResource("getPath");
// 设置流控规则 QPS
flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS);
// 设置受保护的资源阈值
flowRule.setCount(1);
rules.add(flowRule);
// 加载配置好的规则
FlowRuleManager.loadRules(rules);
}
这里为了测试,设置了qps为1即1秒中只能由一个请求,超过则调用限流方法
测试

流量规则的定义
重要属性:
| Field | 说明 | 默认值 |
|---|---|---|
| resource | 资源名,资源名是限流规则的作用对象 | |
| count | 限流阈值 | |
| grade | 限流阈值类型,QPS 模式(1)或并发线程数模式(0) | QPS 模式 |
| limitApp | 流控针对的调用来源 | default,代表不区分调用来源 |
| strategy | 调用关系限流策略:直接、链路、关联 | 根据资源本身(直接) |
| controlBehavior | 流控效果(直接拒绝/WarmUp/匀速+排队等待),不支持按调用关系限流 | 直接拒绝 |
| clusterMode | 是否集群限流 | 否 |
同一个资源可以同时有多个限流规则,检查规则时会依次检查
不足:
秒杀模块做完后,仔细思考了下,还是有很多不足:
- 最大的缺陷就是没有通过mq发送消息给订单模块,而是由本模块直接操作数据库,这样就没法通过mq的机制去削峰填谷。
- 还有就是隐藏秒杀路径,实现的时候太简单。。
- 没有提供一个加载库存的接口,其实也可以写一个定时任务在秒杀活动即将开启时去扫描数据库获取秒杀商品相关信息,然后加载进redis中,起到一个库存预热的效果
- 此外还需要知道的是redis从2.6版本开始支持Lua脚本,通过内嵌对Lua环境的支持,Redis解决了长久以来不能高效地处理 CAS (check-and-set)命令的缺点, 并且可以通过组合使用多个命令, 轻松实现以前很难实现或者不能高效实现的模式。也就是说Lua脚本是类似Redis事务,有一定的原子性,不会被其他命令插队,可以完成一些Redis事务性的操作。
- ……
本文档详细介绍了乐优商城的秒杀服务实现,包括创建秒杀服务、页面分析、后端接口设计、接口测试、秒杀优化、前端渲染和安全措施。涉及数据库表设计、接口限流、消息队列在订单创建中的应用,以及前端商品列表和详情的渲染。此外,还讨论了秒杀服务的安全问题,如秒杀地址隐藏和接口限流。

被折叠的 条评论
为什么被折叠?



