1.高并发解决方法


 
trade-order-gateway : 用于承担负载均衡(既请求转发工作),需要实现的部分
trade-order-api: 核心的订单功能,需要实现的部分
 
1.完成了订单服务优惠券抵扣功能的业务逻辑,并且利用负载均衡、限流算法、超时重试等方法尝试预防订单服务高并发场景问题。
//1.模拟路由 (负载均衡) 获取接口(实现类Nginx请求转发功能)
负载均衡:将流量打到某一台服务器上
Hosts:本地地址和两个域名对应
String server= null;
server = getRoundRobinServer();//轮询算法(通过appliction.xnml中actions注入),host里配置了域名和ip对应 
对于当前轮询的位置变量pos,为了保证服务器选择的顺序性,需要在操作时对其加锁,
使得同一时刻只能有一个线程可以修改pos的值否则当pos变量被并发修改,则无法保证服务器选择的顺序性。
随机:负载均衡方法随机的把负载分配到各个可用的服务器上,  ,然后把连接发送给它。它的有效性一直受到质疑,除非把服务器的可运行时间看的很重。 
轮询:轮询算法按顺序把每个新的连接请求分配给下一个服务器,最终把所有请求平分给所有的服务器。轮询算法在大多数情况下都工作的不错,如果负载均衡的设备在处理速度、连接速度和内存等方面不是完全均等,效果会更好。 加权轮询:该算法中,每个机器接受的连接数量是按权重比例分配的。这是对普通轮询算法的改进,比如可以设定:第三台机器的处理能力是第一台机器的两倍,那么负载均衡器会把两倍的连接数量分配给第3台机器。 源地址哈希:获取客户端访问的IP地址值,通过哈希函数计算得到一个数,用该数值对服务器列表大小进行取模运算,得到要访问的服务器的序号。保证了相同客户端IP地址将会被哈希到同一台后端服务器,直到后端服务器列表变更。根据此特性可以在服务消费者与服务提供者之间建立有状态的session会话。缺点在于:除非集群中服务器的非常稳定,否则一旦有服务器上下线,那么通过源地址哈希算法路由到的服务器是服务器上线、下线前路由到的服务器的概率非常低,如果是session则取不到session,如果是缓存则可能引发
"雪崩"。 动态轮询:类似于加权轮询,但是,权重值基于对各个服务器的持续监控,并且不断更新。这是一个动态负载均衡算法,基于服务器的实时性能分析分配连接,比如每个节点的当前连接数或者节点的最快响应时间等。 最快算法:最快算法基于所有服务器中的最快响应时间分配连接。该算法在服务器跨不同网络的环境中特别有用。 最少连接:系统把新连接分配给当前连接数目最少的服务器。该算法在各个服务器运算能力基本相似的环境中非常有效。
 
//2.查询订单详情(缓存)
用户查询接口: /online/user/{id}, 本接口响应时间强制控制在2s以上  
机房查询接口: /online/region/list, 本接口响应时间强制控制在4s以上

需要从两个第三方接口获取部分信息,再根据获取的信息去数据库里调数据。(两个第三方接口,缓存Caffine

//引入依赖
public
class CaffeineCache { private static final Cache<String, String> CACHE = Caffeine.newBuilder() .maximumSize(100) // 设置缓存最大容量为100 .expireAfterWrite(10, TimeUnit.MINUTES) // 设置缓存过期时间为10分钟 .build(); public static void put(String key,String data) throws Exception { CACHE.put(key,data); } public static Object get(String key) throws Exception { // 从缓存中获取数据 Object data = CACHE.get(key, t -> queryDataFromUrl(key)); return data; } private static String queryDataFromUrl(String key) {// 缓存中无数据,就去请求接口,结果存入缓存 CloseableHttpClient httpclient = HttpClients.createDefault(); ... return
result; } }

产品订单表: ksc_trade_order模拟数据200万
云产品配置表: ksc_trade_product_config 模拟数据200万
抵扣券表: ksc_voucher_deduct
 
//3.根据机房Id查询机房名称(超时重试)
循环:最简单的就是循环,循环了十次(如果有结果就返回)
  建议读取超时设置为 1500ms,重试三次,如果超过三次未获取,报异常。
切面:这个思路比较清晰,在需要添加重试的方法上添加一个用于重试的自定义注解,然后在切面中实现重试的逻辑,主要的配置参数则根据注解中的选项来初始化
Spring Retey:为 Spring 应用程序提供了声明性重试支持
 

//4.订单优惠券抵扣公摊
一个订单的金额(price)可使用多张优惠券。

需求:抵扣券表: ksc_voucher_deduct  

     post有优惠券id,订单id,优惠券金额

     需要保证在很多请求的情况下:

    优惠券不被使用多次、使用多张优惠券的话,一直要找到最新的价格、最新价格不能<0

执行该段代码时,可以用线程池管理线程,进行执行,代码块可以采用synchonized上锁,并且同步代码块的锁指定为用户id(ip),那么同一个用户并发操作时会被锁定,不同用户互相没有影响,整体效率也是可以接受的。

用@Transactional注解帮助回滚事务。

需要插入数据可以使用悲观锁Synchronized,乐观锁适合更新数据。

//扣减库存 将现在的价格和之前查询的最新价格做对比,如果一样,则表明没有人在此中间修改过库存,则认定线程安全,扣除库存

注意:(1)优惠券只能用一次 (2)可用多张优惠券,所以需要找到最新价格 (3)最新价格不能<0如果多个商品用一个优惠券(分摊策略:按照商品价格占比计算优惠价格占比),
保证高并发场景下,优惠券抵扣不会出错(代码块加锁///使用 InnoDB 在修改某个记录时会上锁,所以修改数据时不会出现多个线程同时修改数据,可以避免优惠券超领)
//抵扣券金额 private BigDecimal amount; //订单抵扣券前金额 private BigDecimal beforeDeductAmount; //订单抵扣券后金额 private BigDecimal afterDeductAmount;

    //1.判断voucher_no是否出现过 //优惠券编号(优惠券只能用一次)
        List<Integer> id=voucherDeductMapper.queryByVoucherNo(voucherNo);
        Timestamp timestamp = new Timestamp(System.currentTimeMillis());
        if(id.size()!=0){
            return "优惠券不可重复使用!";
        }else{
            //2.order_id未出现过,直接插入(第一次用优惠券)
            //如果没有出现过,令before=price,after=price-amount,createtime=updatetime
            Integer orderId=voucherDeduct.getOrderId();
            VoucherDeduct voucherQuery=voucherDeductMapper.queryByOrderId(orderId);
            if(voucherQuery==null){
                TradeOrder tradeOrder=tradeOrderMapper.queryById(orderId);
                BigDecimal priceValue=tradeOrder.getPriceValue();
                voucherDeduct.setBeforeDeductAmount(priceValue);
                voucherDeduct.setAfterDeductAmount(priceValue.subtract(voucherDeduct.getAmount()));
                voucherDeduct.setCreateTime(timestamp);
                voucherDeduct.setUpdateTime(timestamp);
                voucherDeductMapper.insert(voucherDeduct);
            }else{//3.order_id出现过,找最近的一次updatetime,获取最新afterAmount(第n次用优惠券:需要获取最近一次优惠后价格,进行计算)
                BigDecimal afterAmount=voucherQuery.getAfterDeductAmount().subtract(voucherDeduct.getAmount());
                voucherDeduct.setBeforeDeductAmount(voucherQuery.getAfterDeductAmount());
                if(afterAmount.compareTo(BigDecimal.ZERO)==-1){
                    voucherDeduct.setAfterDeductAmount(new BigDecimal(0));
                }else{
                    voucherDeduct.setAfterDeductAmount(afterAmount);
                }
                voucherDeduct.setCreateTime(voucherQuery.getCreateTime());//上次优惠券使用时间
                voucherDeduct.setUpdateTime(timestamp);//当前时间
                voucherDeductMapper.insert(voucherDeduct);
            }
        }

 

//5.基于Redis实现漏桶限流算法QPS:5 (每秒支持5个请求) 限流算法:漏桶 限流组件:不借助第三方框架来实现,这个接口只在gateway模块实现即可。

漏桶算法

  我们可以把发请求的动作比作成注水到桶中,我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水,以一定速率流出水。当水超过桶流量则丢弃,因为桶容量是不变的,保证了整体的速率。  
如果想要实现这个算法的话也很简单,准备一个队列用来保存请求,然后我们定期从队列中拿请求来执行就好了(和消息队列削峰/限流的思想是一样的)。漏桶算法
while(b){//b请求
  bt=now;
  Wb=(bt-at)*r;
  W=max(W-Wb,0);
  if(W<C)
    水桶未满,可以处理b请求
    W++;
  else
    水桶满了;
    return false;
}
import java.util.HashMap; import java.util.Map; //分布式:redis-cell public class FunnelRateLimiter { static class Funnel { int capacity; //漏斗容量 C float leakingRate; //漏斗流水速率 r :QPS int leftQuota; //漏斗剩余空间 w long leakingTs; //上一次漏水时间 at 上一次请求时间 public Funnel(int capacity, float leakingRate) { this.capacity = capacity; this.leakingRate = leakingRate; this.leftQuota = capacity; this.leakingTs = System.currentTimeMillis(); } void makeSpace() { long nowTs = System.currentTimeMillis(); //距离上一次漏水过去了多久 long deltaTs = nowTs - leakingTs; //上一次漏水后腾出的空间 int deltaQuota = (int)(deltaTs * leakingRate); //间隔时间太长,整数数字过大溢出,整个漏斗空了 if (deltaQuota < 0) { this.leftQuota = capacity; this.leakingTs = nowTs; return; } //腾出空间太小,最小单位是1 if (deltaQuota < 1) { return; } //增加空间 this.leftQuota += deltaQuota; //记录漏水时间 this.leakingTs = nowTs; //剩余空间不得高于容量 if (this.leftQuota > this.capacity) { this.leftQuota = this.capacity; } } boolean watering(int quota) { makeSpace(); //判断剩余空间是否足够 if (this.leftQuota >= quota) { this.leftQuota -= quota; return true; } return false; } } private Map<String, Funnel> funnels = new HashMap<>();//所有的漏斗 /** * @param capacity 漏斗容量 * @param leakingRate 漏嘴流水速率 quota/s */ public boolean isActonAllowed(String userId, String actionKey, int capacity, float leakingRate) { String key = String.format("%s:%s", userId, actionKey); Funnel funnel = funnels.get(key); if (funnel == null) { funnel = new Funnel(capacity, leakingRate); funnels.put(key, funnel); } //需要一个quota return funnel.watering(1); } }
 固定窗口计数器算法固定窗口其实就是时间窗口。固定窗口计数器算法 规定了我们单位时间处理的请求数量。假如我们规定系统中某个接口 1 分钟只能访问 33 次的话,使用固定窗口计数器算法的实现思路如下:
  • 给定一个变量 counter 来记录当前接口处理的请求数量,初始值为 0(代表接口当前 1 分钟内还未处理请求)。
  • 1 分钟之内每处理一个请求之后就将 counter+1 ,当 counter=33 之后(也就是说在这 1 分钟内接口已经被访问 33 次的话),后续的请求就会被全部拒绝。
  • 等到 1 分钟结束后,将 counter 重置 0,重新开始计数
  • 这种限流算法无法保证限流速率,因而无法保证突然激增的流量。
滑动窗口计数器算法  滑动窗口计数器算法相比于固定窗口计数器算法的优化在于:它把时间以一定比例分片 。  
   例如我们的接口限流每分钟处理 60 个请求,我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次,每个窗口一秒只能处理不大于 60(请求数)/60(窗口数) 的请求,
   如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。 很显然,当滑动窗口的格子划分的越多,滑动窗口的滚动就越平滑,限流的统计就会越精确。

 

2.自定义Maven打包插件,实现自动化打包。
  Maven是⽬前流⾏的Java项⽬⾃动化构建⼯具,它通过定义POM(ProjectObjectModel)和⼀系列插 件来标准化整个项⽬的构建,同时它⼜是⼀个项⽬管理⼯具,通过Maven可以整合与拆分项⽬模块 (Module),解决项⽬之间的依赖关系。
 Java可执⾏Jar包的构建与运⾏(class加载机制)
Maven插件编写、构建、绑定、打包等
Java读写⽂件以及打包相关API,如ZipOutputStream、JarOutputStream、Manifest等

⾃定义⽬标绑定
编写⼀个简单的插件,在插件中定义⼀个⽬标,然后将插件打包安装到本地仓库,其它项⽬则引⽤这 个插件,将其⽬标绑定到指定的⽣命周期阶段上。
(1)开发插件
  通过命令⾏以⾮交互模式快速创建⼀个mvn项⽬,maven官⽅插件命名规范是maven-xxx-plugin,⽽ 我们⾃定义的插件命名规范是xxx-maven-plugin。

mvn archetype:generate -DgroupId=com.ksyun.course -DartifactId=course-maven-plugin-Dversion=1.0 -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
  将项⽬导⼊idea,编译器相关版本设置Java8,编码设置为UTF-8
  开发maven插件,packaging必须设置为maven-plugin
<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/maven-v4_0_0.xsd">
 <modelVersion>4.0.0</modelVersion>
 <groupId>com.ksyun.course</groupId>
 <artifactId>course-maven-plugin</artifactId>
 <packaging>maven-plugin</packaging>
 <version>1.0</version>

 <dependencies>
     <dependency>
     </dependencies>
</project>
View Code
  添加依赖
<dependency>
     <groupId>org.apache.maven</groupId>
     <artifactId>maven-plugin-api</artifactId>
</dependency>
<dependency>
     <groupId>org.apache.maven.plugin-tools</groupId>
     <artifactId>maven-plugin-annotations</artifactId>
</dependency>

<dependency>
     <groupId>org.apache.maven</groupId>
     <artifactId>maven-core</artifactId>
</dependency>
View Code
  com.ksyun.course包下创建类BootJarMojo,BootJarMojo继承AbstractMojo,通过注解 @Mojo定义了插件⽬标名为bootJar。
package com.ksyun.course;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugins.annotations.Mojo;
@Mojo(name="hello")
public class PrintlnHelloMojo extends AbstractMojo {
     public void execute() {
         getLog().info("Hello, this is my custom plugin");
     }
}
View Code
  将插件构建安装到本地仓库,com.ksyun.course:course-maven-plugin:1.0就开发完成。
mvn clean install
(2)绑定插件目标
  通过命令⾏再次创建⼀个maven项⽬,在这个项⽬中使⽤开发好的插件course-maven-plugin构建项⽬。
mvn archetype:generate -DgroupId=com.ksyun.course -DartifactId=use-coursemaven-plugin-Dversion=1.0 -DarchetypeArtifactId=maven-archetype-quickstart -DinteractiveMode=false
  ⾃定义构建过程,将插件course-maven-plugin:1.0⽬标bootJar绑定到compile⽣命周期阶段。
<build>
 <plugins>
     <plugin>
         <groupId>com.ksyun.course</groupId>
         <artifactId>course-maven-plugin</artifactId>
         <version>1.0</version>
         <executions>
             <execution>
                 <phase>compile</phase>
                 <goals>
                     <goal>hello</goal>
                 </goals>
         </execution>
     </executions>
     </plugin>
 </plugins>
</build>   
View Code
  编写完构建过程,在idea的右侧maven栏则可以看⻅⾃定义的course插件。
  由于插件⽬标hello定义时,并未绑定默认的⽣命的周期,如果此时不填写 compile,那插件则不会运⽤到构建过程中,执⾏如下任何⼀个命令验证,是否输出对应的⽇志。
# mvn course:hello
# mvn compile
 
@Mojo(name="bootJar")
public class BootJarMojo extends AbstractMojo {
    @Parameter( defaultValue = "${settings.localRepository}",
             required = true )
    private String localRepository;
    @Parameter( property = "main.class",required = true)
    private String mainClass;
    @Component
    protected MavenProject project;

    @Override
    public void execute() throws MojoExecutionException, MojoFailureException {
        File baseDir = project.getBasedir();
        String artifactId = project.getArtifactId();
        String version = project.getVersion();
        File targetDirectory = new File(baseDir, "target");
        File classesDirectory = new File(targetDirectory, "classes");
        getLog().info("project classes dir is " +classesDirectory.getAbsolutePath());
        List<File> dependencyFiles = project.getDependencyArtifacts().
                stream()
                .map(Artifact::getFile)
                .filter(Objects::nonNull).
                collect(Collectors.toList());
        String classPath = dependencyFiles
                .stream()
                .map(dependencyFile -> "lib/" + dependencyFile.getName())
                .collect(Collectors.joining(" "));
        String jarFilePath = targetDirectory+"\\"+artifactId+"-"+version+".jar";
        String zipFilePath = targetDirectory+"\\"+artifactId+"-"+version+".zip";
        jarPackage(classPath,classesDirectory,jarFilePath);
        zipPackage(jarFilePath,dependencyFiles,zipFilePath);
        jarDelete(jarFilePath);//删除target下原jar包
    }

    private void jarDelete(String jarFilePath){
        File jarFile=new File(jarFilePath);
        if(jarFile.exists()){
            jarFile.delete();
        }
    }

    private void jarPackage(String classPath,File classesDirectory,String jarFilePath){
        Manifest manifest = new Manifest();//主清单
        Attributes attributes = manifest.getMainAttributes();
        attributes.put(Attributes.Name.MANIFEST_VERSION, project.getVersion());
        attributes.put(Attributes.Name.MAIN_CLASS, mainClass);
        attributes.put(Attributes.Name.CLASS_PATH,classPath);
        try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFilePath), manifest)) {
       //要将清单文件添加到JAR文件,在JarOutputStream类的一个构造函数中指定它。
        //创建一个jar输出流,以使用Manifest对象创建一个.jar文件:
            addJarEntry(jos,classesDirectory,"");
            jos.finish();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    public void zipPackage(String jarFilePath,List<File> dependencyFiles,String zipFilePath) { 
  try (ZipOutputStream zos = new JarOutputStream(new FileOutputStream(zipFilePath))) { 
  addZipEntry(zos,dependencyFiles,jarFilePath); zos.finish(); } catch (Exception e) { e.printStackTrace(); } }

    private void addZipEntry(ZipOutputStream zos,List<File> dependencyFiles,String jarFilePath) throws Exception {
        //压缩lib、jar
        File jarFile = new File(jarFilePath);
        try {
            zos.putNextEntry(new ZipEntry(jarFile.getName()));
            writeFile(zos, jarFile);
            if (Objects.nonNull(dependencyFiles) && dependencyFiles.size() > 0) {
                for (File f : dependencyFiles) {
                    zos.putNextEntry(new ZipEntry("lib/" + f.getName()));
                    writeFile(zos,f);
                }
            }
        } catch (Exception e) {
            getLog().error(e);
            throw e;
        }
    }

    private void addJarEntry(JarOutputStream jos, File file, String rootPath)throws Exception {
        try {
            if (file.isDirectory()) {
                File[] files = file.listFiles();
                if (Objects.isNull(files) || files.length == 0) {
                    return;
                }
                if (Objects.nonNull(rootPath) && rootPath.length() > 0) {
                    rootPath = rootPath + "/";
                }
                for (File f : files) {
                    addJarEntry(jos, f, rootPath + f.getName());
                }
            } else {
                jos.putNextEntry(new JarEntry(rootPath));
                writeFile(jos,file);
            }
        }catch (Exception e) {
            getLog().error(e);
            throw e;
        }
    }

    private void writeFile(OutputStream os, File file) throws Exception {
        try (FileInputStream fis = new FileInputStream(file)) {
            byte[] buf = new byte[1024];
            int len;
            while ((len = fis.read(buf)) != -1) {
                os.write(buf, 0, len);
            }
        } catch (Exception e) {
            getLog().error("write file error", e);
            throw e;
        }
    }
}
 

 

3.手写微服务框架,包括服务注册中心、服务端、客户端和一个日志收集服务端,实现了服务和注册中心之间的注册、 注销、心跳、发现的逻辑,日志收集端具备日志收集和日志简单查询功能。

注册中心:

注册和心跳:注册中心保存一个注册表。

  服务端启动时,就会进入ServerRunner(实现了ApplicationRunner),然后做两件事:

    (1)向注册中心发送注册信息,注册中心会保存该服务的信息

    (2)发送心跳(60s一次:@Scheduled(cron = "0/60 * * * * ?")),如果注册中心超过60s还没有接收到服务端发来的心跳,会注销该服务的注册表信息

注销:服务下线自动注销

发现:客户端,此接口默认有一个可选参数,name。携带此参数请求时,通过指定的服务名称来“发现服务”,(即返回一个可用的指定的服务),返回信息中包含节点 IP 地址及端口号、服务唯一标识,       

如果不携带,就采用服务端负载均衡逻辑,用轮询算法返回一个服务(注册表注入)。

 

服务端:两个SpringBoot实例,和注册中心交互

    实现一个简单时间服务 (服务名 time-service),此服务在运行时有2个实例,其服务端口分别为 8280,8281,  

    此服务在运行时,定期调用注册中心服务的心跳接口发送心跳。应用正常退出时,将发送注销请求。

客户端两个SpringBoot实例,调用服务  

    此客户端在运行时,定期调用注册中心服务的心跳接口发送心跳(每60秒)。客户端正常退出时,将向注册中心发送注销请求。  

    此外,运行时客户端每1秒向日志收集服务端写入一条日志,其包含客户端服务名称、客户端实例ID,时间(GMT、带毫秒)、消息级别、消息内容。  

    客户端运行 10 秒后,检查 日志收集服务,应该有至少 5 条成功上报的日志信息,且与其他同名称的客户端的日志互相不冲突

 

日志收集端:具备日志收集和日志简单查询功能

端点 功能说明 备注
/api/logging 记录日志信息 接收客户端提交的日志记录请求,并将其保存于服务内部的存储中。提交的日志记录使用 JSON 格式,以 request body 方式传递。加分项,非必须:相同内容的日志信息重复提交,能够去重(不产生重复记录)
/api/list 获取日志信息列表 此 API 带有一个可选参数 service,其代表指定的服务ID。如果此参数存在,则列表中的数据按服务名过滤,此列表默认显示最后的5条记录(注意排序)。如果不带可选参数 service,则显示全部记录,按记录 logId 倒序排列

  (1)不带参数调用 /api/list 接口时,将返回全部客户端的日志信息。当所有客户端运行 10 秒后,检查 日志收集服务,应该每个客户端有至少 5 条成功上报的日志信息  

(2)带有 service 参数调用 /api/list 接口时,将只返回指定 服务ID 的客户端的最后5条日志。将验证每条记录的时间日期 (datetime) 属性是倒序(从晚到早)排列的

 

 
 
 
 
 
posted @ 2023-09-19 15:13  壹索007  阅读(13)  评论(0编辑  收藏  举报