秒杀活动就是平台商家在某个固定时间段,发布一些超低价格的商品,买家集中在该时间段进行网上抢购的一种销售方式。由于商品价格低,往往一上架就被抢购一空,有时仅需一秒钟,所以被称为秒杀。
面对秒杀业务极大的瞬时流量,我们在设计秒杀系统时,可以考虑以下方面。
秒杀服务即使自己扛不住压力挂掉,也不能影响其他服务。
1. 新建秒杀服务gulimall-seckill,引入依赖:
Spring Boot DevTools、Lombok、Spring Web、Spring Data Redis、OpenFeign。
引入依赖gulimall-common。
2. 配置gulimall-seckill。
spring.application.name=gulimall-seckill
server.port=25000
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.redis.host=192.168.56.10
3. gulimall-seckill启动类上加@EnableDiscoveryClient注解。
4. 配置网关。
在gulimall-gateway服务配置文件中添加:
gateway:
routes:
- id: gulimall_seckill_route
uri: lb://gulimall-seckill
predicates:
- Host=seckill.gulimall.com
使用Nginx做动静分离,使gulimall-seckill服务专注于处理秒杀请求。
详情见《谷粒商城》开发记录 7:压力测试和性能调优
提前将秒杀商品信息写入缓存。
在向缓存中写入秒杀商品信息时,根据秒杀商品数量创建分布式信号量一并写入缓存,处理秒杀商品请求时,扣减信号量而不是到数据库扣减商品库存。
● 引入Redisson:
1. 引入依赖。
groupId: org.redisson
artifactId: redisson
2. 创建配置类。
@Configuration
public class RedissonConfig {
@Bean(destroyMethod="shutdown")
public RedissonClient redisson() throws IOException {
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.56.10:6379"); // 单节点模式
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
秒杀时,扣减信号量,同时向消息队列发送一条消息。消息队列中的消息可以离线慢慢处理。
1. 引入依赖。
groupId: org.springframework.boot
artifactId: spring-boot-starter-amqp
2. 配置。
spring.rabbitmq.host=192.168.56.10
spring.rabbitmq.virtual-host=/
3. 创建配置类。
@Configuration
public class MyRabbitConfig{
@Autowired
RabbitTemplate rabbitTemplate;
@Bean
public MessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}
4. 创建队列。
@Bean
public Queue orderSeckillOrderQueue(){
return new Queue("order.seckill.order.queue", true, false, false);
}
5. 创建绑定关系。
@Bean
public Binding orderSeckillOrderQueueBinding(){
return new Binding("order.seckill.order.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.seckill.order", null);
}
● 防止恶意请求攻击。
● 防止链接暴露,工作人员提前秒杀商品。
使用各种手段,将流量分担到更大的时间尺度上,比如使用验证码。
详见本文第二部分。
● 秒杀活动的单位是场次,商家会在每场秒杀中上架一些商品,每种商品都是限量的。
● 每个秒杀场次的时间是固定的,因此可以使用定时任务来开启。
● 需要提前将参与秒杀的商品的信息存入缓存。
1. 引入依赖。
不需要引入任何依赖。
2. 创建配置类。
@EnableAsync
@EnableScheduling
@Configuration
public class SchedulerConfig{
}
3. 配置定时任务线程池。在配置文件中添加:
spring.task.execution.pool.core-size=50
spring.task.execution.pool.max-size=50
(如果要使用自定义线程池,这一步可以跳过)
4. 测试。
● 定时任务默认是线程同步的,一个定时任务完成后其他定时任务才会开启,使用@Async注解可以使用定时任务线程池异步地执行定时任务。当然也可以不加@Async,手动地创建异步任务,使用自定义线程池异步执行。
● 定时调度的核心注解是@Scheduled,使用cron表达式控制该调度任务何时执行。
@Component
public class TestScheduler {
@Async
@Scheduled(cron = "0 0 0 * * ?")
public void hello() {
System.out.println("hello world");
}
}
网上有很多在线的Cron表达式生成器。
在线Cron表达式生成器
quartz/Cron/Crontab表达式在线生成工具-BeJSON.com
所以对Cron表达式有个大概印象就可以了。
Cron基本规则:
● Cron表达式本身是一个长字符串,由7个短字符串组成,短字符串之间用空格隔开。7个字符串分别代表【秒 分 时 日 月 周 年】。其中年不被Spring支持,换言之,Spring中的Cron表达式只有6个短字符串。
● 特殊字符。
, 表示枚举,cron="5,10 * * * * ?" 表示每分钟的第6秒(开始)和第11秒(开始)。
- 表示范围,cron="0 3-5 * * * ?" 表示每小时的第4分钟第1秒、第5分钟第1秒、第6分钟第1秒。
* 表示任意。
/ 表示步长,cron="0 0 0/2 * * ?" 表示每天0:00:00以后每经过2个小时的第1分钟第1秒。
? 用来防止日与周冲突,表示不指定,只能用在日和周两个位置,且不能同时使用。
L 表示最后一个,cron="0 0 0 L * ?" 表示每个月的最后一天的0:00:00。
W 表示工作日,cron="0 0 0 LW * ?" 表示每个月的最后一个工作日的0:00:00。
# 表示第几个,cron="0 0 0 ? * 5#2" 表示每个月的第2个周四的0:00:00。
● 原生态的Cron表达式使用0-6表示周一到周日,但在Spring中,使用1-7来表示周一到周日。
商品定时上架的规则是:每天凌晨3点,上架最近三天参与秒杀活动的商品。
1. 调用gulimall_coupon服务,获取最近三天参与秒杀活动的商品信息。
1.1 根据系统当前时间,获取今天的0:00:00和后天的23:59:59两个时间点。推荐使用LocalDateTime。
1.2 根据两个时间点查出期间的秒杀场次信息。
1.3 根据秒杀场次ID查询关联的商品信息。
1.4 返回一个秒杀场次列表。
2. 将商品信息缓存到session中。
2.1 遍历秒杀场次列表。
可以拼接场次的开始时间和结束时间得到一个长字符串,以这个长字符串为key,判断session中是否存在当前key,如果没有,将该秒杀场次信息(转化成JSON格式)存入session。
2.3 再次遍历秒杀场次列表。
2.3.1 为每个秒杀场次生成一个随机码token,可以用UUID。
2.3.2 遍历每个场次关联的商品列表。
2.3.2.1 可以拼接秒杀场次ID和SKU ID得到一个长字符串,以这个长字符串为key,判断session中是否存在当前key,如果没有,将该SKU信息存入session。
2.3.2.2 向session存入SKU信息时,同时存入随机码。
2.3.2.3 使用秒杀商品数目作为分布式信号量(秒杀商品时扣减信号量)。
RSemaphore semaphore = redissonClient.getSemaphore("seckill:stock:" + token);
semaphore.trySetPermits(num);
● 获取今天的0:00:00。
LocalDate today = LocalDate.now(); // 获取今天的日期
LocalTime min = LocalTime.MIN; // 获取一天的最小时间
LocalDateTime start = LocalDateTime.of(today, min); // 组装日期和时间
String startFormat = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); // 获取格式化的时间
● 获取后天的23:59:59。
LocalDate today = LocalDate.now(); // 获取今天的日期
LocalDate plus = today.plusDays(2); // 将今天的日期向后推两天
LocalTime max = LocalTime.MAX; // 获取一天的最大时间
LocalDateTime end = LocalDateTime.of(plus, min); // 组装日期和时间
String endFormat = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); // 获取格式化的时间
秒杀缓存对象SeckillSessionEntity:
Long id; // 场次ID
String name; // 场次名称
Date startTime; // 开始时间
Date endTime; // 结束时间
Integer status; // 启用状态
Date createTime; // 创建时间
秒杀商品关联对象seckillSkuRelationEntity:
Long id; // 关联ID
Long promotionId; // 促销活动ID
Long promotionSessionId; // 促销活动缓存ID, 关联SeckillEntity.id
Long skuId; // SKU ID
BigDecimal seckillPrice; // 秒杀价格
Integer seckillCount; // 秒杀数目
Integer seckillLimit; // 秒杀限购数目
Integer seckillSort; // 秒杀排序
用于页面展示秒杀商品,从session中查询已上架的商品信息。
详情见《谷粒商城》开发记录 10:注册和登录
1. 从秒杀页面查询结果中获取参数:秒杀ID、随机码、秒杀商品数量。
● 秒杀ID:拼接秒杀场次ID和SKU ID得到的长字符串。
● 随机码:随机码。
● 秒杀商品数量:大于0,小于限购值。
2. 根据秒杀ID从session中获取该商品的详细信息。
3. 秒杀信息校验。
3.1 校验时间。根据商品信息中的startTime和endTime两个属性校验秒杀时间是否合法。如果不合法,秒杀失败。
3.2 校验随机码。判断页面传递随机码与商品信息中的randomCode属性是否一致,如果不一致,秒杀失败。
3.3 验证秒杀商品数量是否合理。如果不合理,秒杀失败。
3.3.1 秒杀商品数量小于等于商品信息中的seckillLimit属性值。
3.3.2 秒杀商品数量小于等于当前信号量。
redisTemplate.opsForValue().get("seckill:stock:" + randomCode);
3.4 幂等性验证。如果bool为false,秒杀失败。
Boolean bool = redisTemplate.opsForValue().setIfAbsent(key, num, ttl, TimeUnit.MILLISECONDS);
● key:用户ID拼接秒杀ID。
● num:秒杀商品数量。
● ttl:自动过期时间 = 秒杀场次结束时间 - 当前时间。
4. 尝试秒杀。有概率秒杀失败。
RSemaphore semaphore = redissonClient.getSemaphore("seckill:stock:" + randomCode);
boolean success = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
5. 如果秒杀成功,组装订单信息,向消息队列发送消息。
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", seckillOrderTo);
6. 如果秒杀成功,返回订单号。
1. 监听器。
@Component
@RabbitListener(queues = "order.seckill.order.queue")
public class OrderSeckillListener{
@Autowired
private OrderService orderService;
@RabbitHandler
public void listener(SeckillOrderTo orderTo, Channel channel, Message message) throws IOException{
try {
orderService.createSeckillOrder(orderTo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e){
channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
}
}
}
2. 创建秒杀单。
保存订单、保存订单项信息。
秒杀订单对象seckillOrderTo:
String orderSn; // 订单号
Long promotionSessionId; // 秒杀场次ID
Long skuId; // SKU ID
BigDecimal seckillPrice; // 秒杀价格
Integer num; // 购买数量
Long memberId; // 会员ID
● 降级:在网站处于流量高峰期时,根据当前的业务情况,我们可以有策略地停止一部分服务,以此缓解服务器的压力,保证核心业务正常运行。
● 熔断:如果A服务调用B服务时由于各种原因(网络不稳定、B服务宕机等)导致响应时间很长,我们可以直接将B服务切断,后面凡是发送给B服务的请求都直接返回降级数据,这样B服务的故障就不会级联影响到其他服务。
Home · alibaba/Sentinel Wiki · GitHub
1. common模块引入依赖。
groupId: com.alibaba.cloud
artifactId: spring-cloud-starter-alibaba-sentinel
2. 下载Sentinel控制台。
https://github.com/alibaba/Sentinel/releases
启动控制台。在下载路径下执行命令:
java -jar sentinel-dashboard-1.6.3.jar --server.port=8333
浏览器访问http://localhost:8333/可以登录控制台,用户名和密码默认都是sentinel。
3. 配置每个使用到Sentinel的服务。
spring.cloud.sentinel.transport.dashboard=localhost:8333
spring.cloud.sentinel.transport.port=8719
4. common模块创建配置类。
@Configuration
public class SeckillSentinelConfig{
public SeckillSentinelConfig(){
WebCallbackManager.setUrlBlockHandler(new UrlBlockHandler(){
@Override
public void blocked(HttpServletRequest request, HttpServletResponse response, BlockException e){
R error = R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getDesc());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(JSON.toJSONString(error));
}
});
}
}
● 抛出异常的方式。
try (Entry entry = SphU.entry("resourceName")){
// do something
} catch (BlockException ex){
// 降级处理
}
在Sentinel控制台可以对资源resourceName做流控、降级等操作。
● 注解的方式。
@SentinelResource(blockHandler="blockHandlerForGetUser")
public User getUserById(String id){
throw new RuntimeException("getUserById command failed");
}
// 降级处理方法
public User blockHandlerForGetUser(String id, BlockException ex){
return new User("admin");
}
在Sentinel控制台可以对资源getUserById做流控、降级等操作。
1. 引入依赖(创建gulimall-seckill服务时已完成)。
groupId: org.springframework.cloud
artifactId: spring-cloud-starter-openfeign
2. 配置。
feign.sentinel.enabled=true
3. 创建远程调用降级回调方法。
@FeignClient(value="gulimall-seckill", fallback=SeckillFeignServiceFallBack.class)
public interface SeckillFeignService{
@GetMapping("/sku/seckill/{skuId}")
R getSkuSeckillInfo(@PathVariable("skuId") Long skuId);
}
@Component
public class SeckillFeignServiceFallBack implements SeckillFeignService{
@Override
public R getSkuSeckillInfo(Long skuId){
return R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getDesc());
}
}
网关流控的思想是,当请求量过大时,直接在网关拒绝请求,执行降级处理方法。
1. 引入依赖。
groupId: com.alibaba.cloud
artifactId: spring-cloud-alibaba-sentinel-gateway
2. 创建配置类。
@Configuration
public class SentinelGatewayConfig{
public SentinelGatewayConfig(){
GatewayCallbackManager.setBlockHandler(new BlockRequestHandler(){
@Override
public Mono
R error = R.error(BizCodeEnum.TOO_MANY_REQUEST.getCode(), BizCodeEnum.TOO_MANY_REQUEST.getDesc());
String errJson = JSON.toJSONString(error);
Mono
return body;
}
});
}
}
3. 在Sentinel控制台对gulimall_seckill_route进行流控、降级等操作。
略。
● Span:跨度,基本工作单元。发送一个远程调度任务就会产生一个Span,Span是一个64位ID唯一标识的,Trace是用另一个64位ID唯一标识的,Span还有其他数据信息,比如摘要、时间戳事件、Span的ID、以及进度ID。
● Trace:跟踪,一系列Span组成的一个树状结构。请求一个微服务系统的API接口,这个API接口需要调用多个微服务,调用每个微服务都会产生一个新的Span,所有由这个请求产生的Span组成了这个Trace。
● Annotation:标注,用来及时记录一个事件。一些核心注解用来定义一个请求的开始和结束。
1. 服务提供者和消费者都要引入Sleuth依赖。
(下面要引入的Zipkin依赖包含了Sleuth,所以这一步可以跳过)
groupId: org.springframework.cloud
artifactId: spring-cloud-starter-sleuth
2. 开启debug日志。向配置文件中写入:
logging.level.org.springframework.cloud.openfeign=debug
logging.level.org.springframework.cloud.sleuth=debug
OpenZipkin · A distributed tracing system
Sleuth用来链路追踪,Zipkin用来可视化观察。
1. 引入依赖。
groupId: org.springframework.cloud
artifactId: spring-cloud-starter-zipkin
2. 配置。
# Zipkin服务器的地址
spring.zipkin.base-url=http://192.168.56.10:9411/
# 关闭服务发现,否则Spring Cloud会把Zipkin的url当作服务名称
spring.zipkin.discovery-client-enabled=false
# 使用HTTP协议传输数据
spring.zipkin.sender.type=web
# 设置抽样采集率为100%,默认为10%
spring.sleuth.sampler.probability=1
3. 网页登录。
http://192.168.56.10:9411/
推荐使用Elasticsearch作为Zipkin的存储数据库。
这个项目没有做,以后有机会再看下吧。