支付业务、秒杀服务、Sentinel总结
1.支付业务
1.demo测试
把电脑支付demo导入idea的方法就是new ->第二行->选择自己需要的项目->导入->去到project structure->moudle去掉抱错的jar->去到facet中设置web项目的位置->然后就是create atificial->最后就是设置tomcat.
2.沙箱支付Demo
密钥介绍
①沙箱服务这里用到是公钥和密钥,非对称加密。
②也就是我们idea商户有商户密钥和支付宝公钥,支付宝有支付宝密钥和商户的公钥,我们可以通过支付宝密钥生成商户的公钥和密钥。然后传输公钥到支付宝,获取支付宝的公钥放到idea里面。
拓展与坑
①填入appid
②注意下面的return_url和notify_url需要根据web里面的路径来进行匹配而不是官方给定那个。
3.内网穿透原理
本质上就是别人通过内网穿透服务器端传输请求到我们电脑中下载好的客户端,然后通过这个通道来传输请求并且获取资源。
napapp使用
①下载客户端,注册免费隧道
②根据token去到软件位置cmd执行 natapp -authtoken yourauthtoken
③测试是否能够成功
4.应用到项目中的支付功能
思路
①导入sdk依赖,第二就是导入template。
②点击支付宝支付的时候跳转到payOrder请求处理,主要通过AlipayTemplate和订单号,查询订单信息封装到payVo,生成页面的字符串。本质就是一个表单,然后再次提交去获取其它的页面。
③接着就是配置好appid、商户密钥和支付宝公钥。
拓展与坑
①测试的时候需要开启demo也就是电脑网络支付的demo。用于跳转到return之后的页面。因为之前在隧道绑定的接口就是demo的默认接口8080。所以回调会使用之前demo的资源。如果想用自己的需要进行配置
@Controller
public class PayWebController {
@Autowired
AlipayTemplate alipayTemplate;
@Autowired
OrderService orderService;
@ResponseBody
@GetMapping(value = "payOrder",produces = "text/html")
public String payOrder(@RequestParam("orderSn")String orderSn) throws AlipayApiException {
PayVo payVo=orderService.payOrder(orderSn);
String pay = alipayTemplate.pay(payVo);
System.out.println(pay);
return pay;
}
}
@Override
public PayVo payOrder(String orderSn) {
PayVo payVo = new PayVo();
OrderEntity orderEntity = this.getOne(new QueryWrapper<OrderEntity>().eq("order_sn", orderSn));
payVo.setOut_trade_no(orderSn);
BigDecimal payAmount = orderEntity.getPayAmount();
BigDecimal bigDecimal = payAmount.setScale(2, BigDecimal.ROUND_UP);
payVo.setTotal_amount(bigDecimal.toString());
//查询订单项
List<OrderItemEntity> orderItems = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
OrderItemEntity orderItem=orderItems.get(0);
payVo.setSubject(orderItem.getSkuName());
payVo.setBody(orderItem.getSkuAttrsVals());
return payVo;
}
5.支付成功后跳到订单列表。
思路
①member的nginx静态资源,host,gateway处理。
②登录拦截器(放行member,远程调用请求头去掉的问题。)和springsession(yml->type是redis,开启注解,SessionConfig->存入redis的数据转换类型)引入。
③然后就是在回调页面的时候调用member写好的memberObject.html就可以了.
6.渲染页面
思路
①支付成功之后跳转到订单列表,可以在这里根据用户id获取订单列表和订单项类表(远程调用)
②获取数据之后进行渲染
拓展与坑
①远程调用order出现的问题就是远程调用的请求头丢失,需要通过FeignConfig来把这个请求头给补上
②然后就是页面404的各种原因(1)没有更新服务,导致con没有这个方法(2)method类型错误(3)访问了其它服务。
③500页面的原因(1)服务器异常,可能出现了各种奇怪的内部异常问题
④如果需要展示总记录和总页数需要用到mybatisplus的插件来完成。
Order远程调用
con
@PostMapping("/listWithItems")
//@RequiresPermissions("order:order:list")
public R listWithItems(@RequestBody Map<String, Object> params){
PageUtils page = orderService.queryPageWithItems(params);
return R.ok().put("page", page);
}
ser
@Override
public PageUtils queryPageWithItems(Map<String, Object> params) {
MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
//查询用户的订单
IPage<OrderEntity> page = this.page(
new Query<OrderEntity>().getPage(params),
new QueryWrapper<OrderEntity>().eq("member_id",memberResVo.getId()).orderByDesc("id")
);
//查询用户的订单中的订单项
List<OrderEntity> records = page.getRecords().stream().map(order -> {
String orderSn = order.getOrderSn();
List<OrderItemEntity> orderItems = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
order.setItemEntities(orderItems);
return order;
}).collect(Collectors.toList());
page.setRecords(records);
return new PageUtils(page);
}
MemberWebCon
@GetMapping("memberOrder.html")
public String memberList(@RequestParam(value = "pageNum",defaultValue = "1")Integer pageNum,
Model model){
Map<String,Object> params=new HashMap<>();
params.put("page",pageNum);
R r = orderFeignService.listWithItems(params);
String s = JSON.toJSONString(r);
System.out.println(s);
model.addAttribute("orders",r);
return "orderList";
}
7.异步通知
思路
①其实就是支付之后为了防止网站突然崩溃,支付宝可以通过异步的方式来调用商城的接口。notify。这里采用的是最大努力通知,它会每隔一段时间告诉商城已经完成支付了。
②隧道进入的是虚拟机的80端口,进行内网穿透访问
③配置好nginx,内网穿透的路径就是natapp提供的域名,host就是order.gulimall.com自己设置的。但是去到nginx首先需要拦截这个路径,然后重新设置host,因为host会丢失。
④然后就是创建notifyListener,等待支付宝调用并返回success,支付宝接收success之后就明白应用已经支付成功。
⑤拦截器需要放payed路径过去,因为我们要访问订单,而且是内网穿透的方式,支付宝来访问,肯定没有user,这个时候就需要直接放行这个路径。
拓展与坑
①nginx这个地方如果用的是natapp,通过*.cc来作为host映射。
②如果发现传输到后端拦截到的uri是error可以检查一下自己发送的请求是不是method出现了错误
listen 80;
server_name gulimall.com *.gulimall.com *.cc;
#charset koi8-r;
#access_log /var/log/nginx/log/host.access.log main;
location /static{
root /usr/share/nginx/html;
}
location /payed {
proxy_set_header Host order.gulimall.com;
proxy_pass http://gulimall;
}
8.支付完成
思路
①支付完成之后支付宝会调用异步请求来访问内网,这个时候可以接收vo
②然后就是验收签名
③最后就是保存支付记录,并且修改订单状态为已经支付。
拓展与坑
①如果发现了验收签名失败,可能就是前面的一行转码没有去掉
②沙箱容易出bug,而且很卡。需要不断重新尝试
orderSer
@Override
public String handlePayResult(PayAsyncVo payAsyncVo) {
try {
//1.创建支付记录
PaymentInfoEntity paymentInfoEntity = new PaymentInfoEntity();
paymentInfoEntity.setAlipayTradeNo(payAsyncVo.getTrade_no());
paymentInfoEntity.setCallbackTime(payAsyncVo.getNotify_time());
paymentInfoEntity.setOrderSn(payAsyncVo.getOut_trade_no());
paymentInfoEntity.setPaymentStatus(payAsyncVo.getTrade_status());
paymentInfoService.save(paymentInfoEntity);
//2.修改订单状态
if(payAsyncVo.getTrade_status().equals("TRADE_SUCCESS")||payAsyncVo.getTrade_status().equals("TRADE_FINISHED")){
this.baseMapper.updateOrderStatus(payAsyncVo.getOut_trade_no(),OrderStatusEnum.PAYED.getCode());
}
return "success";
} catch (Exception e) {
e.printStackTrace();
return "error";
}
}
OrderPayListener
@RestController
public class OrderPayListener {
@Autowired
OrderService orderService;
@Autowired
AlipayTemplate alipayTemplate;
@PostMapping("/payed/notify")
public String handlePaySuccess(PayAsyncVo payAsyncVo, HttpServletRequest request) throws UnsupportedEncodingException, AlipayApiException {
System.out.println("执行成功");
//获取支付宝POST过来反馈信息
Map<String,String> params = new HashMap<String,String>();
Map<String,String[]> requestParams = request.getParameterMap();
for (Iterator<String> iter = requestParams.keySet().iterator(); iter.hasNext();) {
String name = (String) iter.next();
String[] values = (String[]) requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
//乱码解决,这段代码在出现乱码时使用
// valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
params.put(name, valueStr);
}
boolean signVerified = AlipaySignature.rsaCheckV1(params,alipayTemplate.getAlipay_public_key() , alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名
if(signVerified){
System.out.println("签名成功");
//签名成功
String result = orderService.handlePayResult(payAsyncVo);
return result;
}else{
System.out.println("签名验证失败");
return "error";
}
}
}
9.收单
思路
①直接在发送请求的时候加上+ ““timeout_express”:”"+ timeout +"","。这个主要是在alipayTem上面的pay方法上的
拓展与坑
①为什么要收单?
原因就是如果你订单关单之后是不能够进行支付的。
②还有什么问题会出现?
如果发送的异步通知太慢会导致,关单先处理了,然后异步过来修改订单的状态。解决办法就是关单的时候可以发送一个请求给支付宝告诉它,我已经关单了。
2.秒杀服务
1.秒杀简介
就是在某一段时间,商品可以在那段时间被低价购买。这个时候就会有大量的流量涌入,去抢这些秒杀商品。而这个时候就需要处理大量的高并发问题。
2.环境配置
- 配置好coupon
- gateway
- 开启人人服务。
- 测试增加秒杀时间段和与商品进行关联。
3.Cron表达式
秒分时日周月年
①,:枚举
②- :范围
③*:随意一个
④/:每多少x执行一次
4.定时加异步任务
思路
①开启注解@EnableSchedule来开启定时任务,然后给方法加上@Schedule和cron来完成定时设计
②然后就是@EnableAsync来开启异步任务,@Async指定对应的异步任务。
拓展与坑
①TaskSchedulingAutoConfiguration是定时任务的自动配置类,自己含有对应的线程池
②TaskExecutionAutoConfiguration是异步任务的自动配置类,它的组件ThreadPoolTaskExecutor本质就是JUC的线程池接口的实现类。也就是内置线程池,可以通过很多线程来完成对应的异步任务操作。
5.时间日期处理
思路
①创建ScheduleConfig开启异步任务和定时任务
②SeckillSchedule执行定时方法上架最近三天的秒杀活动
③SeckillSer远程调用获取最近三天SeckillSession.
拓展与坑
①计算日期可以使用LocalDate的plus
②LocalDateTime合并LocalDate和LocalTime
ScheduleConfig
@Configuration
@EnableAsync
@EnableScheduling
public class ScheduleConfig {
}
SeckillSchedule
@Service
@Slf4j
public class SeckillSkuScheduled {
@Autowired
SeckillService seckillService;
@Scheduled(cron = "0 0 3 * * ?")
public void uploadSeckSkuLatest3Days(){
seckillService.uploadSeckSkuLatest3Days();
}
}
SeckillSessionCon
@Override
public List<SeckillSessionEntity> getLatest3DaySession() {
List<SeckillSessionEntity> sessions = this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time", start(), end()));
return sessions;
}
private String start(){
LocalDate now = LocalDate.now();
LocalTime min = LocalTime.MIN;
LocalDateTime start = LocalDateTime.of(now, min);
String format = start.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}
private String end(){
LocalDate now = LocalDate.now();
LocalDate plus = now.plusDays(2);
LocalTime max = LocalTime.MAX;
LocalDateTime end = LocalDateTime.of(plus,max);
String format = end.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return format;
}
6.商品上架
思路
①从coupon的秒杀后台中获取秒杀活动信息和秒杀商品的关联信息
②回到秒杀服务,把这些信息进行遍历,保存到redis上
③活动信息通过key是开始时间和结束时间,value是skuID
④接着就是保存商品的详细信息,包括秒杀信息和基本信息,通过hash来保存。key1是前缀,key2就是skuid,value就时SeckillSkuRedisTo
SeckillSessionSer
@Override
public List<SeckillSessionEntity> getLatest3DaySession() {
//1.查询最近3天的秒杀活动
List<SeckillSessionEntity> sessions = this.list(new QueryWrapper<SeckillSessionEntity>().between("start_time", start(), end()));
//2.查询秒杀活动里面的商品关联信息
List<SeckillSessionEntity> sessionResults = sessions.stream().map(session -> {
Long id = session.getId();
List<SeckillSkuRelationEntity> relationSkus = seckillSkuRelationService.list(new QueryWrapper<SeckillSkuRelationEntity>().eq("promotion_session_id", id));
session.setRelationSkus(relationSkus);
return session;
}).collect(Collectors.toList());
return sessionResults;
}
SeckillSer
@Override
public void uploadSeckSkuLatest3Days() {
R r = couponFeignService.getLatest3DaySession();
if(r.getCode()==0){
//上架商品,放到redis中
List<SeckillSessionWithSkus> sessions = r.getData(new TypeReference<List<SeckillSessionWithSkus>>() {
});
saveSessionInfos(sessions);
saveSessionSkuInfos(sessions);
}else{
}
}
private void saveSessionSkuInfos(List<SeckillSessionWithSkus> sessions) {
//1.遍历session,key是开始时间和结束时间的拼接
sessions.stream().forEach(session->{
long startTime = session.getStartTime().getTime();
long endTime = session.getEndTime().getTime();
String key=startTime+"_"+endTime;
//2.保存所有的skuids
List<String> skuIds = session.getRelationSkus().stream().map(item -> item.getSkuId().toString()).collect(Collectors.toList());
redisTemplate.opsForList().leftPushAll(key,skuIds);
});
}
private void saveSessionInfos(List<SeckillSessionWithSkus> sessions) {
sessions.stream().forEach(session->{
//1.获取所有关系信息
List<SeckillSkuVo> relationSkus = session.getRelationSkus();
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
relationSkus.stream().forEach(item->{
SeckillSkuRedisTo seckillSkuRedisTo = new SeckillSkuRedisTo();
//1.获取sku信息
//2.获取秒杀获取sku信息
BeanUtils.copyProperties(item,seckillSkuRedisTo);
//保存为JSON字符串
ops.put(item.getSkuId(), JSON.toJSONString(seckillSkuRedisTo));
});
});
}
7.秒杀商品上架2
思路
①远程调用获取skuInfo封装到skuInfoVo中,再放进redisto对象里面
②然后就是获取活动中所有的秒杀商品信息copy到redisTo
③然后就是设置活动开始和结束时间,和随机值
④最后就是把每个秒杀商品的数量,放到redission的信号量中,信号量=前缀+商品的随机值。
拓展与坑
①为什么要用到随机值?
原因就是商品秒杀如果是固定的一个路径,那么有的人就能够直接通过软件来抢这些商品。但是如果后面加上一个随机值,秒杀活动开始的时候才放出这个·随机值,那么就不能够提前预知道路径到底是什么。
②为什么要用到信号量?
原因就是限流,大量请求访问redis不代表这些请求都要去访问数据库是否还有秒杀库存。可以通过放秒杀商品的库存作为信号量放到redis中,每次请求进来先查询信号量是否还满足购买条件(是否有库存),如果没有那么就可以释放请求,让线程去做别的事情。能够处理更多的高并发的情境。
③关于redis保存数据的思考
(1)秒杀商品活动与skuid进行一个绑定,可以使用开始结束时间作为key,value就是skuid的普通opsForValue操作
(2)第二个就是秒杀商品的具体信息保存。可以用一个前缀key1来表示这是具体商品信息,商品就用具体的skuid与skuRedisTo来通过hash保存进redis。好处就是可以保存多个这样的商品,并且可以通过getSkuId快速定位商品的位置。
(3)最后就是信号量使用的是redisson的key与value的保存方式。
private void saveSessionInfos(List<SeckillSessionWithSkus> sessions) {
sessions.stream().forEach(session->{
//1.获取所有关系信息
List<SeckillSkuVo> relationSkus = session.getRelationSkus();
//开启一个hashkey1
BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
relationSkus.stream().forEach(item->{
SeckillSkuRedisTo seckillSkuRedisTo = new SeckillSkuRedisTo();
//1.获取sku信息
R skuInfo = productFeignService.getSkuInfo(item.getSkuId());
if(skuInfo.getCode()==0){
SkuInfoVo skuInfoVo = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {
});
seckillSkuRedisTo.setSkuInfoVo(skuInfoVo);
}
//2.获取秒杀获取sku信息
BeanUtils.copyProperties(item,seckillSkuRedisTo);
//3.保存商品的活动开始与结束时间
seckillSkuRedisTo.setStartTime(session.getStartTime().getTime());
seckillSkuRedisTo.setEndTime(session.getEndTime().getTime());
//4.保存一个随机值
String randomCode = UUID.randomUUID().toString();
seckillSkuRedisTo.setRandomCode(randomCode);
//5.开启redission,使用信号量,限流,实际上就是库存量
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
semaphore.trySetPermits(item.getSeckillCount().intValue());
//保存为JSON字符串
ops.put(item.getSkuId(), JSON.toJSONString(seckillSkuRedisTo));
});
});
}
8.幂等性保证
思路
①给定时任务加上分布式锁。通过redisson获取锁,然后锁上,通过trycatch来解锁。因为无论业务是否执行完成都需要解锁。这个设定好的锁。
②接着就是判空,秒杀场是否已经存在,如果存在这个key那么就不能再次加入保证幂等性。
③接着就是给商品信息判空。如果有商品信息的key那么也是不能够加入的
拓展与坑
①这个地方场次存入的value应该是场次id+秒杀商品id,同样商品的key也是这样的格式。好处就是解决多个场次同一个商品不能加入的问题。如果场次存入的value和商品信息存入的key是skuId,那么只要有这个商品就不能够存入。所以需要通过场次id+商品id来进行对一个商品的区别。
②第二个就是商品信息的判空不能用redisTem原因是redisTem判断的是key-value的。但是存入商品信息用的是hash结构,也就是key1来辨识hashList,hashList里面才是各个key-value的hash结构。也就是说如果判空需要去除操作hash的操作类。boundHash才能够对里面的key与value进行判断
③为什么需要分布式锁?
原因就是多个定时任务进入的话,可能会出现判断同时成功之后转换线程,另外一个线程判断成功并存入数据,换回之前那些判断成功的线程也会再次存入,会出现数据重复的问题。
//session判空
if(!redisTemplate.hasKey(key)){
List<String> skuIds = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId().toString()+"_"+item.getSkuId().toString()).collect(Collectors.toList());
redisTemplate.opsForList().leftPushAll(key,skuIds);
}
//hash判空
if(!ops.hasKey(item.getPromotionSessionId().toString()+"_"+item.getSkuId().toString())){。。。。}
//定时任务加锁
@Scheduled(cron = "*/3 * * * * ?")
public void uploadSeckSkuLatest3Days(){
log.info("上架商品");
RLock lock = redissonClient.getLock(upload_lock);
lock.lock();
try {
seckillService.uploadSeckSkuLatest3Days();
} catch (Exception e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
9.查询秒杀商品
思路
①创建SeckillCon来处理这个获取操作
②主要就是获取当前的时间,然后获取redis所有会场的时间范围,如果找到当前时间在时间范围的会场,那么就取出这个会场的所有商品id。然后再获取hashOps,mutiget里面传入所有的id,然后取出商品信息。
拓展与坑
①这个地方我查出来的时间是晚了八个小时的。原因就是mysql的时区错误。你要把mysql的时区设置为gmt+8东八区。而不是修改springboot里面的时区。还有这个地方你可以先通过TimeZone.getDefault来查看springboot的时区,如果springboot的时区不是东八区那么就可以对springboot的配置进行修改。
@GetMapping("/currentSeckillSkus")
public R getCurrentSeckillSkus(){
List<SeckillSkuRedisTo> list=seckillService.getCurrentSeckillSkus();
return R.ok().setData(list);
}
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
//1.取出当前时间
long time = new Date().getTime();
//2.取出所有的场次,并且查询现在的时间有什么活动
Set<String> keys = redisTemplate.keys(SESSION_CACHE_PREFIX + "*");
for (String key : keys) {
String replace = key.replace(SESSION_CACHE_PREFIX, "");
String[] s = replace.split("_");
Long start=Long.parseLong(s[0]);
Long end=Long.parseLong(s[1]);
System.out.println((time>start)+"_"+(time<end));
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println("time:"+format.format(new Date(time))+"==>start:"+format.format(new Date(start))+"==>end:"+format.format(new Date(end)));
//TODO 时间多了8个小时的问题
if(time>=start&&time<=end){
//1.查出这个场次拥有的所有商品id
//range(key)加上一个查询的范围
List<String> skuIdsWithSessionIds = redisTemplate.opsForList().range(key, -100, 100);
//2.去到hash结构中查询所有的商品信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
//一次性查询所有
List<String> skuRedisToList = hashOps.multiGet(skuIdsWithSessionIds);
//3.遍历所有的to转换成to类
if(skuRedisToList!=null){
List<SeckillSkuRedisTo> collect = skuRedisToList.stream().map(item -> {
return JSON.parseObject(item, SeckillSkuRedisTo.class);
}).collect(Collectors.toList());
return collect;
}
break;
}
}
return null;
}
前端处理
$.get("http://seckill.gulimall.com/currentSeckillSkus",function(res){
if(res.data.length>0){
//遍历data
res.data.forEach(function(item){
// alert(item.skuInfoVo)
$("<li></li>")
.append($("<img style='height: 130px;width: 130px' src='"+item.skuInfoVo.skuDefaultImg+"'/>"))
.append($("<p>"+item.skuInfoVo.skuTitle+"</p>"))
.append($("<span>"+item.seckillPrice+"</span>"))
.append($("<s>"+item.skuInfoVo.price+"</s>"))
.appendTo("#seckillSkuContent")
})
}
}
10.秒杀页面渲染
思路
①详细页需要显示秒杀商品信息,商品服务远程调用获取秒杀商品信息。
②秒杀服务,写一个接口根据skuId来获取对应的商品的秒杀信息,并且根据秒杀信息的时间来决定是否要设置随机码(防止别人恶意用软件去秒买秒杀商品)。
③主要是根据_skuId的key的格式,然后用正则表达式来匹配这些key,如果匹配成功那么就先判断当前时间是不是在活动范围内,如果不是那么就去掉随机码,接着就返回对应的商品信息。一个商品可以出现在多个会场,但是当前时间通常只能参与一个秒杀活动。
@Override
public SeckillSkuRedisTo getSkuSeckillInfo(Long skuId) {
//1.取出所有商品的key
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
Set<String> keys = hashOps.keys();
String regx="\\d_"+skuId;
for (String key : keys) {
//2.取出匹配成功的数据
if(Pattern.matches(regx,key)){
//3.接着就是取出数据
String seckillSkuRedisToJson = hashOps.get(key);
SeckillSkuRedisTo seckillSkuRedisTo = JSON.parseObject(seckillSkuRedisToJson, SeckillSkuRedisTo.class);
//4.取出当前时间,如果当前时间是在商品秒杀活动范围内,那么就返回这个秒杀商品信息
long time = new Date().getTime();
//5.如果是范围内的商品那么就设置随机码返回,如果不是那么就把随机码设置为空
if(time>=seckillSkuRedisTo.getStartTime()&&time<=seckillSkuRedisTo.getEndTime()){
}else{
seckillSkuRedisTo.setRandomCode(null);
}
return seckillSkuRedisTo;
}
}
return null;
}
11.秒杀服务系统设计
- 服务单一职责:秒杀服务独立出来一个服务不干扰其它服务
- 链接加密:相当于就是随机码防止提前秒杀
- 库存预热+快速扣减:redis信号量,请求获取信号量可操作后放到任务队列
- 动静分离:静态资源通过nginx来获取
- 恶意拦截请求:大量恶意请求访问
- 流量错峰:100W流量访问,通过各种方法让流量拆分,比如验证码,大家输入速度不同,可以导致流量能够分开
- 限流、熔断、降级:限流限制流量流入系统处理(网关),熔断其实就是处理那些出现问题的服务,不让后面的请求等待太久,快速返回错误页面。降级就是服务处理不过来,把流量引导到一个等待页面。
- 队列削峰:大量请求冲击系统,可以快速先把流量放到队列中,然后订单监听慢慢进行处理。
12.登录检查
思路
①在详细页面修改加入购物车为立即抢购(如果是秒杀商品)
②点击之后发送请求到秒杀服务抢购,需要发送skuid,sessionid、num购买数量和随机码
③秒杀服务需要做一个登录检查,访问kill的时候才需要,所以特殊处理一下login拦截器。然后引入springsession调整session保存类型
@GetMapping("/kill")
public R secKill(
@RequestParam("killId")String killId,
@RequestParam("key")String key,
@RequestParam("num")String num
){
return null;
}
13.秒杀流程
思路
①点击抢购之后就是发送参数给秒杀服务,进行登录检查,秒杀服务取出商品,查询当前时间是不是在商品的活动时间之内。
②然后就是进行合法性校验(包括随机码是否正确,killid是否正确),
③接着就是校验购买的数量是否符合在限购范围之内。
④接着就是秒杀成功需要在redis中占位,防止他再次发送请求来秒杀。key是memberid、sessionid和skuid,value是num
⑤最后就是取出信号量,进行定时扣减,如果定时扣减失败那么就不能发送下单消息,如果可以那么就发送下单消息到消息队列,让订单服务对队列进行一个监听操作。
@Override
public String kill(String killId, String key, String num) {
MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
//1.取出商品的秒杀信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
String json = hashOps.get(killId);
//2.判断秒杀商品是否为空
if(!StringUtils.isEmpty(json)){
SeckillSkuRedisTo redisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
//3.校验当前时间是不是在这个商品的秒杀活动时间
long time = new Date().getTime();
Long startTime = redisTo.getStartTime();
Long endTime = redisTo.getEndTime();
if(time>=startTime&&time<=endTime){
//4.校验合法性
String killIdFromRedis=redisTo.getPromotionSessionId()+"_"+redisTo.getSkuId();
if(redisTo.getRandomCode().equals(key)&&killId.equals(killIdFromRedis)){
//5.判断数量是否在秒杀之内
if(Integer.parseInt(num)<=redisTo.getSeckillLimit().intValue()){
//7.秒杀后去redis中占位
long ttl = endTime - time;
String redisKey=memberResVo.getId()+"_"+redisTo.getPromotionSessionId()+"_"+redisTo.getSkuId();
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num, ttl, TimeUnit.MILLISECONDS);
if(aBoolean){
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE+key);
try {
//6.取信号量,并且尝试扣减,如果100毫秒内无法扣减,那么就不处理
semaphore.tryAcquire(Integer.parseInt(num),100, TimeUnit.MILLISECONDS);
//8.下订单发送消息
String orderSn = IdWorker.getTimeId();
return orderSn;
} catch (InterruptedException e) {
e.printStackTrace();
return null;
}
}else{
return null;
}
}else{
return null;
}
}else{
return null;
}
}else{
return null;
}
}else{
return null;
}
}
14.秒杀创建订单效果
思路
①给秒杀服务引入rabbitmq,配置yml的host和virtual。
②接着就是创建一个秒杀订单to,接着就是设置对应的信息
③发送消息到秒杀队列中,等待订单监听。
④订单服务创建一个新的秒杀队列和关联
⑤开启一个监听器,监听这个队列消息。有消息那么接收秒杀订单信息就去创建订单和订单项。
拓展与坑
①如果发现商品不存在,那么就很可能就是你的秒杀会场时间starTime出现问题。因为每次取这个秒杀会场的时候都是根据startTime的后三天。而startTime就是现在的日期0点,endTime就是3天后的23点。如果你的startTime取的是昨天0点,那么很明显就无法取出对应会场。因为现在要取的是今天和后三天的活动。而不是昨天到后三天的。
②最后测试debug一下即可。
//7.秒杀后去redis中占位
long ttl = endTime - time;
String redisKey=memberResVo.getId()+"_"+redisTo.getPromotionSessionId()+"_"+redisTo.getSkuId();
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num, ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + key);
//6.取信号量,并且尝试扣减,如果100毫秒内无法扣减,那么就不处理
boolean b = semaphore.tryAcquire(Integer.parseInt(num));
if(b){
//8.下订单发送消息
String orderSn = IdWorker.getTimeId();
SeckillOrderTo seckillOrderTo = new SeckillOrderTo();
seckillOrderTo.setOrderSn(orderSn);
seckillOrderTo.setMemberId(memberResVo.getId());
seckillOrderTo.setSeckillCount(new BigDecimal(num));
seckillOrderTo.setSeckillPrice(redisTo.getSeckillPrice());
seckillOrderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
seckillOrderTo.setSkuId(redisTo.getSkuId());
//发送消息
rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order",seckillOrderTo);
return orderSn;
}else{
return null;
}
@Override
public void createSeckillOrder(SeckillOrderTo seckillOrderTo) {
//1.保存订单
OrderEntity orderEntity = new OrderEntity();
orderEntity.setOrderSn(seckillOrderTo.getOrderSn());
orderEntity.setMemberId(seckillOrderTo.getMemberId());
orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
this.save(orderEntity);
OrderItemEntity orderItemEntity = new OrderItemEntity();
orderItemEntity.setOrderSn(seckillOrderTo.getOrderSn());
BigDecimal multiply = seckillOrderTo.getSeckillCount().multiply(seckillOrderTo.getSeckillPrice());
orderItemEntity.setRealAmount(multiply);
orderItemEntity.setSkuQuantity(seckillOrderTo.getSeckillCount().intValue());
orderItemService.save(orderItemEntity);
}
@RabbitListener(queues = {"order.seckill.order.queue"})
@Service
public class OrderSeckillListener {
@Autowired
OrderService orderService;
@Autowired
RabbitTemplate rabbitTemplate;
@RabbitHandler
public void listener(SeckillOrderTo seckillOrderTo, Channel channel, Message message) throws IOException {
System.out.println("创建秒杀订单:"+seckillOrderTo.getOrderSn());
try {
orderService.createSeckillOrder(seckillOrderTo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
e.printStackTrace();
//如果没有关闭成功那么就把消息返回去直到成功为止
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
15.秒杀页面完成
思路
①把购物车中的success页面拿过来,引入thymeleaf。
②秒杀创建订单之后(TODO可以发送消息关单,还有就是锁定库存),跳转到秒杀成功页面。
③点击成功页面的支付可以使用支付宝的沙箱环境进行支付。主要就是调用订单里面的payOrder接口,只需要传入对应的订单id即可。
3.Sentinel
1.sentinel简介
- 主要处理熔断和降级、限流
- 熔断:一个服务出现问题,然后其它服务调用这个服务的时候迅速返回错误页面而不是阻塞。相当于熔断了访问这个服务的链路
- 降级:比如现在是秒杀服务,大量流量涌进,需要更多资源,这个时候可以先暂停注册服务,提供更多的资源。别人访问注册服务的时候返回一个等候或者是一个错误页面。
- 熔断与降级的不同:熔断是被调用方出现问题,降级是主动调整
- 限流:根据服务可处理的并发量放流量进来
- 它有一个核心库和dashborad
- 它是一个保护资源的框架,使用的方法
- 定义资源
- 定义规则
- 测试是否有效果
Hystrix 与Sentinel
①Hystrix基于线程池,每个接口都有自己的一个线程池,独立处理。但是浪费大量的资源。Sentinel基于信号量。不需要创建线程池,减少消耗的资源
②熔断降级策略:Hystrix基于异常比率,Sentinel基于响应时间,异常比率,异常数
③限流:Sentinel提供更多的限流方案和功能。
2.整合SpringBoot
思路
①引入sentinel的jar
②开启sentinel的控制台服务。也就是开启jar包
③然后就是给资源加上流控进行测试
spring.cloud.sentinel.transport.dashboard=localhost:8333
spring.cloud.sentinel.transport.port=8719
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
3.自定义流控
思路
①引入actutor依赖,然后设置好yml开放接口
②引入sentinelconfig,主要就是返回对应的流控等页面。超过流量限制就返回这个页面
@Configuration
public class SeckillSentinelConfig {
public SeckillSentinelConfig(){
WebCallbackManager.setUrlBlockHandler(new UrlBlockHandler() {
@Override
public void blocked(HttpServletRequest req, HttpServletResponse res, BlockException e) throws IOException {
R error = R.error(BizCodeEnume.TOO_MANY_REQUEST.getCode(), BizCodeEnume.TOO_MANY_REQUEST.getMsg());
res.setCharacterEncoding("UTF-8");
res.setContentType("application/json");
res.getWriter().write(JSON.toJSONString(error));
}
});
}
}
4.流控
①单机与集群类型的阀值流量分担
②链路限流。a->b或者是a->c这段需要限流。也就是两个资源之间的调用,远程调用
③关联,两个资源之间一读一写,一个慢,那么就会影响到另外一个
④warm up:逐渐放流量过去
⑤排队+延时
5.熔断与降级
①熔断开启feign.sentinel.enable。
②重写SeckillFeignSer,并且设定好Feign的fallback类就是我们重新实现的这个类。每次如果发现提供方服务出现问题,那么就立马调用熔断方法。这里OrderSer调用了秒杀服务的获取秒杀商品方法,发现秒杀服务挂了,立刻调用熔断方法。
③也可以在提供方设置降级机制,比如说请求过多,那么就会触发消费方的熔断机制(默认的降级策略)。
④也可以是在降级方设置一个config也就是上面的那个SentinelConfig,主要就是手动设置降级策略
6.自定义受保护资源
思路
①通过注解@SentinelResource或者是trycatch的方式可以可进行对自己想要保护的资源,限流或者是降级。通过限流和降级服务都会被引导到同一个方法
拓展与坑
①这里出现了一个springcloud的版本问题,springcloudalibaba都是采用旧版本的openFeign的SentinelContractHolder,但是Sentinel>2.2.1之后都是采用新版本的openFeign。导致方法parseAndValidateMetadata(新版本),parseAndValidatateMetadata(旧版本)。cloud用旧的,sentinel用新的。那么这个时候就需要自己引入一个SentinelContractHolder,在包com.alibaba.cloud.sentinel.feign下
package com.alibaba.cloud.sentinel.feign;
import feign.Contract;
import feign.MethodMetadata;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class SentinelContractHolder implements Contract {
private final Contract delegate;
/**
* map key is constructed by ClassFullName + configKey. configKey is constructed by {@link
* feign.Feign#configKey}
*/
public final static Map<String, MethodMetadata> METADATA_MAP = new HashMap<>();
public SentinelContractHolder(Contract delegate) {
this.delegate = delegate;
}
@Override
public List<MethodMetadata> parseAndValidatateMetadata(Class<?> targetType) {
List<MethodMetadata> metadatas = delegate.parseAndValidatateMetadata(targetType);
metadatas.forEach(metadata -> METADATA_MAP
.put(targetType.getName() + metadata.configKey(), metadata));
return metadatas;
}
}
public List<SeckillSkuRedisTo> blockHandler(BlockException e){
log.error("限流,降级处理");
return null;
}
@SentinelResource(value = "getCurrentSeckillSkus",blockHandler ="blockHandler")
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
try(Entry entry=SphU.entry("currentSeckillSkus")){
//1.取出当前时间
long time = new Date().getTime();
//2.取出所有的场次,并且查询现在的时间有什么活动
Set<String> keys = redisTemplate.keys(SESSION_CACHE_PREFIX + "*");
for (String key : keys) {
String replace = key.replace(SESSION_CACHE_PREFIX, "");
String[] s = replace.split("_");
Long start=Long.parseLong(s[0]);
Long end=Long.parseLong(s[1]);
System.out.println((time>start)+"_"+(time<end));
SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
System.out.println("time:"+format.format(new Date(time))+"==>start:"+format.format(new Date(start))+"==>end:"+format.format(new Date(end)));
//TODO 时间多了8个小时的问题
if(time>=start&&time<=end){
//1.查出这个场次拥有的所有商品id
//range(key)加上一个查询的范围
List<String> skuIdsWithSessionIds = redisTemplate.opsForList().range(key, -100, 100);
//2.去到hash结构中查询所有的商品信息
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
//一次性查询所有
List<String> skuRedisToList = hashOps.multiGet(skuIdsWithSessionIds);
//3.遍历所有的to转换成to类
if(skuRedisToList!=null){
List<SeckillSkuRedisTo> collect = skuRedisToList.stream().map(item -> {
return JSON.parseObject(item, SeckillSkuRedisTo.class);
}).collect(Collectors.toList());
return collect;
}
break;
}
}
}catch (BlockException e){
log.info("获取current失败,限流处理");
return null;
}
return null;
}
7.网关流控
思路
①记住routeid,然后根据route来指定对应的限流规则,进行测试。
②导入对应sentinel-gateway依赖就能使用了。
③创建一个config,在构造方法里面实现BolckRequestHandler的实现,完成限流的回调
拓展与坑
①使用sentinel的各种功能第一个就是需要注册进去,也就是要配置好服务连接sentinel的路径,不然发现网关不见了
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
<version>2.1.0.RELEASE</version>
</dependency>
@Configuration
public class GatewaySentinelConfig {
public GatewaySentinelConfig(){
GatewayCallbackManager.setBlockHandler(new BlockRequestHandler() {
@Override
public Mono<ServerResponse> handleRequest(ServerWebExchange serverWebExchange, Throwable throwable) {
R error = R.error(BizCodeEnume.TOO_MANY_REQUEST.getCode(), BizCodeEnume.TOO_MANY_REQUEST.getMsg());
String s = JSON.toJSONString(error);
Mono<ServerResponse> body = ServerResponse.ok().body(Mono.just(s), String.class);
return body;
}
});
}
}
4.Sleuth与Zipkin(未完)
1.Sleuth简介
服务很多很复杂,需要通过Sleuth来进行链路追踪。Sleuth就是为了追踪这些服务而创建的框架。
基本概念
- span:工作基本单元,比如说接收请求,发送请求,带上一个spanid
- trace:记录一条链路的id
- annotation:标注,sr(服务器接收请求),cr(客户端接收应答),ss(服务器端发送res),cs(客户端发送req)
- 可以通过sr、cr、ss、cs的时间戳计算来查询各种网络传输。
2.引用sleuth
思路
①引入依赖
②设置好debug级别
拓展与坑
①redis的lettuce 与sleuth不兼容,所以需要去掉lettuce。然后引入jedis来进行操作。
logging.level.org.springframework.cloud.openfeign=debug
logging.level.org.springframework.cloud.sleuth=debug
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
String s = JSON.toJSONString(error);
Mono<ServerResponse> body = ServerResponse.ok().body(Mono.just(s), String.class);
return body;
}
});
}