简历汇总参考技术描述.md 75 KB

项目流程图:https://kdocs.cn/join/gvfzfav?f=101

1. 采用腾讯内容安全对文本,图片,音视频进行审核,确保内容安全前提下通过Kafka异步进行发布专辑/声音到索引库库ElasticSearch

业务背景

数据表:专辑表、专辑统计信息表、分类(1,2,3分类)、专辑标签关系表、标签表、标签值表、声音表、声音统计信息表。【金山文档 | WPS云文档】 3、数据表结构 https://kdocs.cn/l/cjsYNYg4gSps

内容创作者/听书运营人员 新增保存专辑,需要填写专辑相关信息:1.专辑标题 2.上传专辑封面(上传到MInIO) 3.专辑简介(PC端有富文本编辑器) 4.所属分类 5.专辑标签 6.专辑付费类型等(付费类型、价格类型,折扣) 点击提交保存专辑,对专辑中文本,图片调用腾讯云内容安全接口进行内容安全审核。审核通过后才能保存/发布专辑。 专辑被编辑前要求将专辑先下架,编辑内容后再次进行审核,审核通过后才能进行再次上架处理。

解决方案

  • 内容安全方案(腾讯云)

    • 问题:文本(请求参数内容文本Base64编码),图片(使用Hutool工具类Base64编码)可以同步审核,立即获取审核结果,但是音频审核只提供异步审核,保存声音业务中,上传音频文件,得到音频文件详情,发起审核任务(得到审核任务ID,关联声音记录-更新声音表审核任务ID),但审核结果不确定审核结束时间。采用分布式定时任务框架xxl-job、powerJob开启定时任务(避免超过查询审核结果腾讯QPS,限制每次查询声音数量),检查处于待审核状态声音对应审核任务结果,根据审核任务结果更新声音审核状态,在门户专辑详情页面,只有经过审核声音才能出现在声音列表中。
  • kafka异步进行发布专辑

    • 生产者(专辑服务)

    • 当内容经过审核,使用KafkaTemplate对象发送上架消息(审核通过专辑ID)到上架话题

    • 消费者(搜索服务)

    • 监听上架话题,获取专辑ID,封装专辑对应各项数据-专辑索引对象(1.专辑信息、2.分类信息、 3.主播信息 、 4.统计信息 对应是4个OpenFeign接口调用,采用线程池+异步任务),将索引库对象存入专辑索引库、将专辑标题存入提词索引库(搜索自动联想功能)、将专辑ID存入布隆过滤器(初始化)

    • 问题:确保消息可靠(不丢失)、幂等性(ES新增文档处理)

    • 分析业务是否需要可靠

    • 分析消费者端是否需要进行幂等性处理

  • 腾讯云点播

    • 优势:云点播(Video on Demand,VOD)面向音视频、图片等媒体,提供制作上传、存储、转码、媒体处理、媒体 AI加速分发播放版权保护等一体化高品质媒体服务。

    • 问题:声音源文件防盗 结论:无法百分比确保被盗

    • 启用防盗链:登录 云点播控制台

    1. 进入应用后在导航栏中的分发播放设置 > 域名管理,对您使用的域名单击设置。

    2. 进入访问控制菜单栏,开启 “Referer 防盗链”,“防盗链类型”选择“白名单”,文本框中输入允许播放视频的站点域名列表,单击确定。

    3. 打开 “Key 防盗链”,输入或随机生成防盗链 Key,单击确定

    此时,已为域名开启了 Referer 防盗链和 Key 防盗链.

2. 采用自定义Token机制实现单点登录(基于微信登录),令牌自动续期,自定义注解验证认证状态

微服务架构下会话保持,传统/单体项目cookie+session(会话)服务端通过Session对象存放用户信息,写会SessionID到Cookie中(JsessionID)Cookie自动提交,服务端根据JsessionID查询服务端Session对象(默认30分钟),包含用户信息。Session未查到到说明用户已退出系统。

实现会话保持:

  • Spring-Session 将Session信息存入Redis
  • token机制(项目中使用)原理同上

背景:

客户端端有PC端,APP端,小程序等。单点登录是有状态登录(服务端存储用户信息),并且有多种登录方式,所以结合了策略模式+工厂模式,定义了抽象策略类,并提供了具体策略实现包括:

  1. 账户登录:用户名和密码 听书后台验证账户合法性

  2. 手机验证码:短信(阿里云短信服务-判断删除采用lua脚本) 听书后台验证手机号合法性

  3. 微信登录:openId(只要获取到OpenID账户合法)

小程序端

  • 集成微信SDK,根据当前微信个人信息调用微信服务端获取code(临时票据,有效期:5分钟) 携带code访问服务端,用于获取token

服务端:

  1. 策略模式+工厂模式

    >    1.获取不同社交账户唯一标识策略接口   定义一个方法:获取不同平台上对应账户OpenID
    >    2.提供不同社交平台对应策略实现类(其中IOC容器BeanID=前端登录类型) 全部 实现策略接口获取账户OpenID方法
    >    3.提供工厂,采用自动注入 通过Map<String, 策略接口> 将策略接口下包含实现类对象注入Map中其中Key:BeanID,Value:实现类对象
    >    4.工厂中提供根据类型返回对应策略实现类对象-在不同策略实现类中完成不同验证账户合法逻辑
    >    ```
    >
    > 2. 以微信为例,调用微信服务端根据前端提交code获取微信服务端账户唯一标识:WXOpenid获取成功:代表账户验证通过
    >
    > 3. 微信/微博/QQ后续逻辑都一样
    >
    > 4. 根据登录类型+社交账户OpenID查询用户记录
    >
    > 5. 如果未查到:说明该社交账户首次登录
    >
    >    1. 保存用户信息 关联社交登录类型及OPenID
    >    2. 隐式初始化账户(消费余额账户)
    >       1. 话术1:采用OpenFeign同步调用账户服务 确保分布式事务一致性-Seata
    >       2. 话术2:采用Kafka异步初始化账户 确保消息可靠
    >
    > 6. 查询到基于用户信息产生Token
    >
    >    1. 采用UUID作为Key
    >    2. 将用户基本信息作为Value,为了防止Token被窃取,将用户设备标识(前端获取),调用百度地图SDK,根据IP获取所在行政区域。将来在认证状态就可以进行验证
    >
    > 7. 前端拿到token存入Cookie/localStorage/其他存储
    >
    > 8. 后续每次业务接口调用携带token访问
    
    4. 微博登录
    
    5. QQ登录
    
    6. 其他第三方社交账户登录  调用第三方接口完成账户校验返回社交账户唯一标识
    
    只要身份合法,基于用户信息生成登录Token,将Token存入后端Redis中,将生成Token响应给客户端,客户端将Token保存保存Cookie或者LocalStorage中。登录后发起任意请求必须携带授权头:token。
    
    服务端校验token(校验用户登录状态)根据登录状态,接口登录限制动态引导用户进行登录
    
    
    
    **话术一**:如果其他技术描述里没有涉及到分布式事务技术(**简历中没写订单、支付模块**)
    
    我们还使用了分布式事务:例如微信登录时,如果用户已存在则直接登录,否则还要隐式去注册用户并初始化账户信息,由于用户和账户是两个服务。
    
    - 事务发起方:用户服务,新增用户记录(用户记录关联社交账户)
    - 事务参与方:账户服务,新增账户,新用户赠送余额,新增账户变动日志
    
    **话术二**:其他技术描述里没有涉及到Kafka消息队列
    
    不要求同步初始化账户信息,采用Kafka-MQ异步初始化,隐式初始化账户数据
    
    - 生产者(用户服务):新增用户记录(用户记录关联社交账户)
    - 消费者:(账户服务),新增账户,新用户赠送余额,新增账户变动日志
    
    无论采用哪种话术,保存用户信息后,基于用户信息生成Token存入Redis  Key:token  Value:用户信息  TTL:7天
    
    
    
    **校验用户登录状态**:采用网关过滤器、SpringMVC拦截器、AOP切面
    
    - 自定义认证注解:控制接口方法是否登录访问
    
    - 切面:环绕通知作用到自定义注解(对注解修饰方法所在类进行增强,产生代理对象(JDK,CGLIB动态代理))
    - 获取请求对象,获取请求头:token
    - 根据token查询Redis中用户信息
    - 如果目标方法注解要求必须登录,且用户信息为空(token不合法,过期)抛出异常前端引导用户登录
    - 如果用户信息有值
    - 判断设备是标识否发生变更、行政区域是否发生变更
    - 方便获取用户ID,将用户ID存入**ThreadLocal**中
    - 执行目标方法(原始类对象方法)(Controller-service-Mapper)从**ThreadLocal**中获取用户ID
    - 清理ThreadLocal中相关数据,避免出现**ThreadLocal**内存泄漏
    
    **解决方案:**
    
    - 自定义注解/AOP切面
    
    - 新建@interface注解,设置元注解
    - 新增切面类,采用环绕通知 切入点表达式对controller层及控制层方法使用认证注解进行增强
    
    - ThreadLocal线程本地私有变量 原理:https://blog.csdn.net/u010445301/article/details/111322569
    
    ![image-20240604103931480](assets/image-20240604103931480.png)
    
    ThreadLocal对象内存图
    
      
    
    ![image-20240604105000254](assets/image-20240604105000254.png)
    
    强引用:new 对象 ,即使内存泄漏不能被GC回收
    
    软引用:SoftReference包装数据;当发生GC如果**内存不足**,将**软引用对象就会被回收**
    
    弱引用:WeakReference保存数据;**只要发生GC**弱引用对象都会被**GC回收**,弱引用生命周期存在**两次GC间**
    
    > **正常情况new Thread:线程执行结束,Thread线程引用为空,线程对象成为垃圾,GC时候Thread对象包含内部ThreadLocalMap及Entry及data数据都会被清理,释放内存**
    >
    > 项目中应用线程池(核心=最大线程)前提:核心线程不会被销毁(一直在运行)。
    >
    > 假如将Key设置为强引用:随着用户程序不断复用核心线程,将数据存入ThreadLocal中,导致堆内存中产生大量ThreadLocal实例(无法被垃圾回收器回收),导致最终堆内存OOM。
    
    > **内存泄漏(内存无法被释放,无法被访问)的发生跟ThreadLocalMap中的key是否使用弱引用是没有关系的。那么内存泄漏的的真正原因是什么呢?两个前提:**
    >
    > 1.没有手动删除这个Entry ,未调用ThreadLocal中remove方法
    >
    > 2.当前线程(核心线程)一直运行
    > 第一点:只要在使用完ThreadLocal,调用其remove方法删除对应的Entry,就能避免内存泄漏。
    >
    > 第二点:由于ThreadLocalMap是Thread的一个属性,被当前线程所引用,所以它的生命周期跟Thread一样长。那么在使用完ThreadLocal的使用,如果当前Thread也随之执行结束,ThreadLocalMap自然也会被gc回收,从根源上避免了内存泄漏。
    > 故:ThreadLocal内存泄漏的根源是:**由于ThreadLocalMap的生命周期跟Thread一样长**,如果没有手动删除对应key就会导致内存泄漏。
    >
    > 事实上,在ThreadLocalMap中的set/getEntry方法中,会对key为null(也即是ThreadLocal为null )进行判如果为nul的话,会将value置为null的。这就意味着使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,**弱引用比强引用可以多一层保障,弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set,get, remove中的任意方法的时候会被清除,从而避免内存泄漏。**
    
    - 分布式事务方案:阿里Seata
    
    - Kafka消息队列
    
    - 消息可靠性问题
    - 消息幂等性问题
    - 消息顺序性问题
    - 消息吞吐量提升问题
    
    - Token如何续期?
    
    - 在切面中判断key的剩余过期时间,如果小于阈值再次设置key的过期时间
    
    - Token被窃取问题
    
    - 登录成功后,将设备标识(申请手机权限)、IP所在行政区域(https://lbsyun.baidu.com/faq/api?title=webapi/ip-api-base)等信息获取到存入Redis
    - 认证切面类中:拿着要求前端提交设备标识+IP所属行政区域,如果发现设备变更,或者异地登录将Token删除,响应208业务状态码,前端引导用户再次登录。
    - 将Token过期时间设置适当短(Token过期后问题不符存在)
    
    #### 3. 采用ElasticSearch进行站内多条件专辑检索、首页置顶分类热门专辑、关键字自动补全、竞价排名等功能.
    
    背景:
    
    - 多条件站内检索
    
    - 业务:在PC端,小程序端,APP端,首页中支持站内搜索。只要专辑审核通过上架专辑都可以通过该接口检索到业务数据。支持 **条件1**:查询关键字 查询专辑标题,专辑简介,主播名称、支持 **条件2**:一二三级分类ID,**条件3**:根据分类下多组标签进行筛选专辑。查询结果需要分页,对搜索关键字文本进行高亮显示。 支持根据不同方式进行排序:热度、播放量、发布时间
    
    - 实现方案
    
    - 1.设计(专辑)索引库,特殊字段类型:专辑标题/简介需要进行分词/匹配查询设置为text,专辑标签列表设置Nested(根据多组标签ID,标签值ID检索专辑)创建索引库方法1:调用restfulAPI  方法2:采用SpringDataElasticSearch提供实体类,属性进行文档映射,启动项目自动创建索引库
    
      - 一、业务字段-满足检索结果渲染数据
      - 二、检索字段-作为检索条件
      - 三、排序字段-进行排序
      - 四、辅助字段
    
    - 2.专辑索引数据同步-利用Kakfa消息队列
    
      - 专辑服务中经过内容安全审核,发送消息到上架话题(存放专辑ID)
      - 搜索服务监听话题中消息,根据消息中专辑ID采用异步任务**CompletableFuture**+**线程池** 封装索引库文档对象,将专辑文档存入ES索引库
        - 开启并行线程分别OpenFeign调用专辑服务查询专辑信息(包含标签列表)、查询专辑统计信息
        - 专辑信息异步任务执行完毕后,开启两个子线程查询专辑分类及专辑主播信息
      - 将上架专辑ID存入分布式布隆过滤器(解决缓存穿透)
      - 将上架专辑 名称 存入提词索引库(用于自动联想/补全功能)
    
    - 2.站内搜索
    
      > 封装检索DSL语句:
      >
      > 4.设置查询条件 条件1:关键字 条件2:分类ID 条件3:标签 三个大条件采用and
      > 4.1 模拟用户录入“古典”关键字进行查询  需求匹配查询专辑标题或简介,或精确查询主播名称。关键字查询大条件包含三个子条件,三个子条件满足其一即可。考虑到用户录入内容千奇百怪,故选择**must**合适,会有相关性算分。
      >
      > 4.2 模拟用户选择“有声书=2”1级 分类进行过滤数据,分类数据是系统限制用户选择,适合于进行缓存,相同分类数据进行缓存,缓存命中率比较高。故采用**filter**
      >
      > 4.3 模拟用户选择标签条件(有声书分类1=男频小说1)进行标签过滤、选择标签条件(讲播形式2=多人3) 过滤数据,标签数据是系统限制用户选择,适合于进行缓存,相同分页数据进行缓存,缓存命中率比较高。故采用**filter** 一组标签查询包含两个子条件(标签ID、标签值ID)采用Nested查询-每个netsted查询中包含bool查询,采用must精确查询两个子条件
      >
      > 对照DSL语句完成后端代码实现
      >
      > 一、构建检索请求对象(封装完整DSL-请求路径,请求体参数)
      >
      > 二、执行检索
      >
      > 三、解析ES响应命中业务结果
    
    - 置顶分类热门专辑
    
    - 业务:在门户前端/app/小程序首页展示每个1级分类下置顶三级分类,同时还需要展示置顶分类下包含热门专辑,为了页面排版,前端展示7个置顶分类+全部、某个置顶分类下展示6个热度较高专辑。点击首页专辑,也支持根据任意级分类进行检索
    
    - 实现
    
    >#需求:根据7个置顶3级分类ID查询每个分类下热度最高的6条记录 封装DSL语句
    >#分析1:query查询条件 3级分类ID选择**多关键字精确查询**
    >
    >#1.模拟对音乐分类下置顶7个三级分类ID查询所有专辑
    >
    >#2.根据专辑所属三级分类ID进行分组(聚合)
    >
    >#3.对三级分类ID一组专辑列表进行热度排序 获取前6条记录
    >
    >服务端实现
    >
    >一、构建检索请求对象(封装完整DSL-请求路径,请求体参数)
    >
    >二、执行检索
    >
    >三、解析ES响应聚合结果
    
    - 关键字自动补全
    
    - 业务:根据用户已经录入字符快速响应提示词进行自动补全,设计索引库采用completation数据类型存入内存中,建议查询效率很高。支持汉字、汉语拼音、拼音首字母都能实现自动联想,设置三个用于建议查询字段(汉字、拼音、拼音首字母)
    
    - 实现
    
    >提词索引库设计:包含四个业务字段
    >
    >1.业务字段:存放原始内容,用于给用户提词结果展示数据
    >
    >2.提词检索字段用于汉字提词检索 Completion(在内存加载,查询效率)
    >
    >3.提词检索字段用于拼音提词检索 Completion
    >
    >4.提词检索字段用于拼音首字母提词检索 Completion
    >
    >DSL语句发起:
    >
    >- 采用建议suggestAPI,包含三个自定义建议查询不同建议词字段
    >- 查询用于汉字提词字段 根据汉字作为前缀起始内容返回
    >- 查询用于汉语拼音提词字段 根据汉语拼音作为前缀起始内容返回
    >- 查询用于汉语拼音首字母提词字段 根据汉语拼音首字母作为前缀起始内容返回
    >
    >服务端:
    >
    >一、构建检索请求对象(封装完整DSL-请求路径,请求体参数)
    >
    >二、执行检索
    >
    >三、解析ES响应建议结果
    >
    >* 执行解析建议词结果数量小于10
    >* 采用分词查询专辑索引库尝试补全到10个返回给前端
    
    - 专辑优先推荐
    
    - 业务:推广某个分类下专辑(文档有字段是否热门-后台手动设置)当选择某个分类后,凡是该分类下标识为热门专辑,改变原有算分值(设置权重*10),ES中默认按照相关性算分返回。达到优先推荐 ES:算分函数API https://blog.csdn.net/hza419763578/article/details/131770639
    
    - 智能推荐(大数据实现)
    
    - 业务:根据用户的搜索记录(搜索关键字),提取关键字排序,根据提取的关键字搜索,取出5条返回
    
    
    #### 4. 采用Spring线程池ThreadPoolTaskExecutor+异步任务CompletableFuture提升详情页接口效率提高页面渲染效率
    
    业务背景:
    
    >场景1:专辑审核发布上架,搜索服务监听到上架专辑ID后,封装索引库文档对象包含四项数据(1.专辑信息,2.分类信息,3.主播信息 4.统计信息)对应4个OpenFeign接口,采用多线程+异步任务并行获取四项数据提高导入索引库效率。
    
    >场景2:当前用户在小程序/APP/PC 门户前端可以进行站内条件检索或点击首页中热门专辑,选择感兴趣专辑进入详情页面,(预示着读多写少)页面中展示(格式相对固定):**1、专辑相关信息 (包含一、专辑信息 二、所属分类 三、统计信息  四、主播信息)** **2、专辑下声音列表(动态显示付费标识,当用户点击付费标识。如果价格类型为整张专辑购买弹出购买专辑页面;如果价格类型为单集购买弹出选择分集购买页面)** 。根据用户身份显示是否开通/续费会员;在声音列表中声音记录后动态根据用户登录状态、身份、购买情况动态渲染付费按钮。 引导购买会员,购买专辑/声音进入下一项订单业务
    
    解决方案:
    
    应对高并发场景:**1.缓存**  **2.线程池+异步**   **3.限流**   **4.SQL优化** **5.搭建集群**
    
    创建线程方式:
    
    1. 继承Thread
    2. 实现Runnable接口
    3. 实现Callable接口-获取线程执行结果
    4. 基于线程池创建线程
    
    - 线程池作用:
    
    - 任务来了之后不需创建线程,直接从线程池中获取空闲线程执行,执行效率高
    
    - 线程不需要频繁创建销毁,线程复用
    
    - 统一管理JVM用户线程,提供监控功能
    
    > 工具类Executors创建线程池(线程数或阻塞队列长度Integer最大值,容易出现栈溢出,堆内存移除)、自定义JDK线程池**ThreadPoolExecutor**,项目集成Zipkin链路追踪组件,发现问题:当多个远程调用串行调用,链路追踪完整(OpenFeign远程调用会自动将链路ID传递下游服务),改为多线程异步后,远程调用由开启子线程调用,子线程无法自动获取主线程中链路ID,导致链路不完整。解决办法:**采用Spring线程池类ThreadPoolTaskExecutor**,包含JDK线程池所有功能,提供装饰器setTaskDecorator编写主线程跟子线程之间传递参数(链路ID)解决问题。
    >
    > 准备好:线程池作用原理、线程池7个参数、线程池拒绝策略、线程池核心数如何设置:2N(核心数)+1阻塞队列一般设置为300~500左右
    >
    > ![image-20240604153722762](assets/image-20240604153722762.png)
    
    - 异步任务CompletableFuture
    
    - 对任务进行编排,指定任务执行时机,多个任务并行或者串行
    
    - 简化异步任务开发
    
    - 创建异步任务无返回值 CompletableFuture.runAsync(()->{//任务逻辑}, 线程池对象);
    - 创建异步任务返回结果CompletableFuture.supplyAsync(()->{//任务逻辑  return ?;}, 线程池对象)
    - 获取其他任务执行结果,当前任务无结果:xXXCompletableFuture.thenAcceptAsync(data->{//}, 线程池对象)
    - 获取其他任务执行结果,当前任务返回结果:xXXCompletableFuture.thenApplyAsync(data->{// return 当前任务结果;}, 线程池对象)
    - 组合异步任务:CompletableFuture.allOf(CompletableFuture<?>... cfs).join()所有任务执行完毕:方法
    - 只需要某个任务执行结束 .CompletableFuture.anyOf(CompletableFuture<?>... cfs) 
    
    - QPS计算方式
    
    > 通常情况下平均接口响应时间小于200ms,普通接口小于500ms,特殊接口要求小于1s
    >
    > ![image-20240604163917920](assets/image-20240604163917920.png)
    
    - 优化结果:压测工具(ApacheJmeter,ab,ApiPost/ApiFox-在线接口文档及压测一体工具)
    
    > 开发环境:压测并发100持续1分钟 专辑信息接口-SQL优化(至少达到Range级别)-80ms左右
    >
    > ​                     压测并发100持续1分钟 专辑信息接口-SQL优化(至少达到Range级别)-50ms左右
    >
    > 总计请求数:2w+
    >
    > 成功比例:99.98% 不能大于百分之1
    >
    > 平均接口响应时间:400ms--->260ms
    
    声音列表中付费标识动态判断:
    
    > **关键**:动态根据**登录状态**、**用户身份**、**专辑付费类型**、**购买情况** 综合判断出付费标识,默认列表声音VO对象付费标识:false   前提:
    >
    > - 未登录:**情况一**:专辑付费类型 **VIP免费**或**付费** 将该专辑下除免费试听声音外,将其他声音付费标识均改为true,主要用Stream流处理集合(遍历foreach、映射map、过滤filter、排序sort)collect(分组groupBy,)
    >
    > - 已登录:
    >   - 普通用户:**情况二**:正常情况下VIP免费或付费专辑无法收听,**进一步确认用户购买情况**,如果未购买,将声音付费标识均改为true
    >   - VIP会员:**情况三**:正常情况下 **付费** 专辑无法收听(普通用户/VIP用户),**进一步确认用户购买情况**,如果未购买,将声音付费标识均改为true
    >
    > 
    
    #### 5. 采用Redisson分布式锁解决Redis缓存击穿问题,熟悉Redisson加锁,续期,解锁原理
    
    缓存中业务数据:查询业务数据时候,如果缓存直接返回,缓存数据为空查询数据库,将查询结果返回同时放入缓存Redis。
    
    **业务**:热点专辑key可能会在某些时间点被超高并发地访问,如果这个key在大量请求同时进来之前正好失效,那么所有对这个key的数据查询都落到DB,避免大量请求直接查库采用**分布式锁**解决,同一时间只允许一个进程(线程)获取锁成功,唯一加锁成功进程执行查库操作将结果再次放入到缓存中,其他加锁失败线程,自旋在执行方法命中缓存。带来性能下降,将锁粒度设置小:每个专辑ID作为锁的Key一部分,不同业务数据间不存在干扰。
    
    分布式锁实现方案:1.基于MySQL实现(唯一索引)  2.基于Zookeeper实现(临时顺序节点节点最小获取锁-CP数据一致性)  3.基于Redis实现(落地方案)
    
    > 分布式锁目标: Redis高性能,有持久化机制选择Redis
    >
    > 1.保证排他性:同一时间只有一个进程获取锁成功
    >
    > 2.避免死锁:锁被异常占用,其他进程无法获取锁
    >
    > 3.多进程可见:所有java进程都可以访问服务
    >
    > 4.锁服务高可用:单节点故障,搭建集群保证锁服务高可用
    
    - 方式一:自定义分布式锁 SpringDataRedis 采用**redis set k v ex nx**命令实现
    
    - 锁无法自动续期,违背锁排他性
    - 锁采用**String**数据类型**set nx**实现,**无法**实现**可重入锁**
    - 加锁释放锁自己来实现,编码繁琐
    
    - 方式二:采用**Redisson**框架提供分布式锁 
    
    - 应用:前提使用是Redis哨兵集群
    
    - 导入启动依赖
    - 编写配置:Redis哨兵节点连接信息(IP及端口)
    - 在IOC容器中注册**RedissonClient对象-**使用所有Redisson提供分布式对象,服务基础
    - 基于RedissonClient对象创建锁-入参需要指定锁名称-hash结构key
    - 调用lock,tryLock方法加锁
    - 执行业务(受保护方法-同一时间只有一个线程执行)
    - 调用unlock方法释放锁
        
    - 基本原理(**Hash**+**lUA脚本**)
      
    - Redisson之所以能实现可重入采用Redis-**Hash数据类型**
      - Key:锁名称 加入业务数据标识,降低锁粒度
      - field:线程标识  UUID:线程ID
      - value:重入次数 
      
    - 在加锁解锁锁续期逻辑中存在大量判断,多个指令执行,采用**lua脚本**确保原子性
      
    > Lua脚本的原子性执行:**Redis内置Lua解释器**,Redis服务器在执行Lua脚本时,会将整个脚本作为一个整体进行执行,中间不会被其他请求打断。这意味着在Lua脚本执行期间,Redis会暂停处理其他客户端的请求,直到该脚本执行完毕。这种原子性的执行方式确保了Lua脚本中的多个Redis命令会按照脚本中指定的顺序连续执行,不会被其他客户端的请求插入或打断。因此,在Lua脚本执行过程中,无需担心会出现竞态条件或数据不一致的问题。
      
    - 源码分析
    
    - 加锁 通过lua脚本操作hash结构的"锁"
    
      > 1. 判断锁是非被占用 0 说明锁空闲 or 判断持有锁的线程是否为当前线程 
      >
      > 2. 满足第1步加锁条件,当前线程可以加锁
      >
      >    2.1 对Hash锁中重入次数+1
      >
      >    2.2 将hash锁过期时间设置为30s
      >    
      >    2.3 返回null 上锁成功
      >    
      > 3. 获取锁失败,则返回当前持有锁锁过期时间单位毫秒
    
        
    
    - 续期 通过lua脚本操作hash结构的"锁"
    
      > 持有锁的线程开启锁续期,开启看门狗续期的条件:**锁释放时间**必须等于**-1**,持有锁的线程加锁成功,开启固定延迟时间为10s续期任务,每次续期锁为30s
      >
      > 1. 判断持有锁的线程是否为当前线程 条件成立:说明线程没有主动调用unlock释放锁(业务还未执行结束)
      > 2. 再次对锁的过期时间设置为30s
      > 3. 返回1续期成功
      > 4. 反之:当前线程的锁被线程释放掉,锁过期.返回0
    - 解锁 通过lua脚本操作hash结构的"锁"
    
      > 1. 判断持有锁的线程是否为当前线程
      >    1. 当前线程已经不再持有锁,锁自动过期 返回null
      > 2. 当前线程是持有锁的线程,对重入次数进行-1 返回重入次数
      >    1. 重入次数-1后重入次数大于0,再次对锁的过期时间设置为30s 确保上一次加锁任务逻辑有时间执行,返回false
      >    2. 重入次数-1后,重入次数小于等于0
      >       1.  删除锁
      >       2. 利用Redis发布订阅 命令publish通知阻塞的客户端可以抢锁了
    
    #### 6. 采用自定义缓存注解优化去除冗余缓存及分布式锁业务代码、提高代码复用性
    
    不直接使用SpringCache缓存注解原因:仅仅将方法名称作为key ,方法执行结果作为Value进行缓存,没有提供解决缓存击穿逻辑
    
    - 定义缓存注解
    
    > 声明公共注解,注解定义属性定义缓存到redis中key的前缀。被该注解修饰方法,优先从缓存中获取数据,没有获取到获取分布式锁(避免缓存击穿)在执行查询库业务逻辑,将查询结果放入缓存,响应结果给客户端。
    >
    > ```java
    > @Target({ElementType.METHOD})
    > @Retention(RetentionPolicy.RUNTIME)
    > @Inherited
    > @Documented
    > public @interface GuiGuCache {
    >     /**
    >      * 存入Redis中键前缀(业务键,锁的键)
    >      * @return
    >      */
    >     String prefix() default "none:";
    > }
    > ```
    
    - 声明切面类(切入点(使用缓存注解方法)+环绕通知)
    
    > 1. 尝试从Redis获取业务数据
    >
    >    1.1 构建业务数据Key=缓存注解前缀:目标方法参数(多个参数使用_拼接) 
    >
    >    1.2 查询Redis缓存中业务数据,命中缓存直接返回即可
    >
    > 2. 未命中缓存,获取分布式锁
    >
    >    2.1 构建锁Key(为了提升效率,需要将锁粒度小一些)故设置为:业务Key:锁后缀
    >
    >    2.2 基于RedissonClient对象创建锁对象 入参为锁key,在Redis中锁结构hash中作为锁名称
    >
    >    2.3 调用锁对象lock方法或者tryLock方法。没有看门狗机制:多进程同时执行业务,违背排他性
    >
    >    ​       lock方法会一直阻塞到获取锁成功
    >
    >    ​       tryLock方法锁获取失败,锁获取等待超时都会返回false(自旋),获取锁成功返回true.
    >
    > 3. 执行查库业务
    >
    >    3.1 被缓存注解修饰方法(查询数据库)
    >
    >    3.2 将查询数据库结果放入缓存,使用业务数据Key,缓存业务数据时间基础时间1小时+随机时间
    >
    >    3.3 将业务数据放入缓存 
    >
    >    3.4 将业务数据返回
    >
    > 4. 释放锁
    >
    >    解锁-(加锁次数跟解锁次数一致)
    
    #### 7. 采用Redisson分布式布隆过滤器解决缓存穿透问题及布隆过滤器扩容方案
    
    - 缓存穿透现象:查询一个不存在的数据,由于缓存无法命中,将去查询数据库,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。不存在的数据缓存null到缓存,无法解决缓存随机穿透
    
    - 业务中使用
    
    - 存入布隆过滤器:专辑审核通过上架业务中,搜索服务作为消费者监听到上架专辑ID后业务处理:1.专辑索引库对象存入ES;将专辑名称存入提词索引库(用于搜索关键字自动补全);将专辑ID存入布隆过滤器。
    - 查询元素是否存在:详情/搜索服务中处理查询指定专辑业务中,先根据专辑ID判断该专辑是否包含在布隆过滤器,如果返回false说明该专辑一定不存在;返回true:由于布隆过滤器存在误判说明专辑可能存在。
    
    - 分布式布隆过滤器
    
    > **Redisson**提供**分布式布隆过滤**应用:场景从海量数据中判断元素是否存在
    >
    > 1. 通过**RedissonClient**对象进行初始化布隆过滤器(布隆过滤器配置未变化,不会重复初始化)产生布隆过滤配置:采用Redis的Hash存储配置信息(1.数据规模2.误判率3.hash个数4.bitmap位图长度)
    >
    >    ```java
    >    bloomFilter.tryInit(数据规模,误判率);
    >    ```
    >
    > 2. 将数据添加到布隆过滤器,修改元素hash结果映射到二进制bitmap位置为1
    >
    >    ```java
    >    bloomFilter.add(元素);
    >    ```
    >
    > 3. 判断元素是否存在,在访问缓存及数据库前具备判断元素是否存在
    >
    >    ```java
    >    boolean exists = bloomFilter.contains(元素);  //true:元素可能存在  false:元素一定不存在
    >    ```
    >
    > 4. 获取布隆过滤器中元素数量
    >
    >    ```java
    >    Integer count = bloomFilter.count();   
    >    ```
    >
    > 项目使用:
    >
    > 1. 在启动类实现**CommandLineRunner**接口中,boot应用启动后自动run方法
    >    1. 通过RedissonClient 创建布隆过滤器对象,入参为布隆过滤器名称
    >    2. 判断布隆过滤器是否存在,如果不存在在进行初始化,避免扩容后重复初始化布隆器
    > 2. 在监听到专辑上架Kakfa消息后,除了将专辑索引库文档记录存储ES,还将**专辑ID存入布隆过滤器**,保存提词索引库等
    > 3. 在用户访问专辑详情接口入库,在执行Feign远程调用前,判断元素是否存在于布隆过滤器,如果不存在抛出异常
    >
    > 原理:
    >
    > 1. 初始化布隆过滤器,产生布隆过滤器配置,存入Redis的hash数据类型,Redisson框架会自动基于初始化方法参数:1.数据规模 2.误判率  产生 ---->3. hash个数 4. bitmap位图长度(18KB左右)。数据规模2W,误判率0.03,实际位bitmap图长度14W左右,hash个数:5个 
    > 2. 调用add方法新增元素,对元素进行5次hash运算,将5次hash计算结果映射到bitmap位图位置将对应5个位置从0改为1(可能现有位置已经是1-发生hash冲突)高
    > 3. 判断元素是否存在,对元素进行5次hash运算,将5次hash计算结果映射到bitmap位图位置将对应5个位置,判断5个位置对应值,如果有任意位置值为0说明元素一定不存在。任意位置都等于1只能说明元素可能存在。
    >
    > 优势: 不存原始数据保密性好,hash效率,占空空间较小
    >
    > 缺点: 存在hash冲突,所以导致存在误判,无法删除元素
    
    - 布隆过滤器扩容/重建
    
    > 背景:当业务数据从数据库里被删除,由于布隆过滤器无法删除元素,导致访问被删除元素通过布隆过滤器判断;数据实际存储数量超过期望数据规模,无法确保访问误判率(误判率上升)选择定时任务,每月1号凌晨3点钟启动重建/扩容布隆过滤器任务。
    >
    > 重建/扩容方案:分布式定时任务框架**xxl-job**,避免执行器集群环境中任务被重复执行
    >
    > 1. 执行器中xxl-job任务触发时间:每月1号凌晨3点进行扩容定时任务
    > 2. 获取现有布隆过滤器对象,得到相关配置(现有数据规模,误判率,现有元素个数)
    > 3. 判断布隆过滤器现有元素个数是否大于等于期望数据规模 true:需要进行扩容 false:保留现状 或者 设置扩容间隔时间大于3个月即使 存储数据元素小于 期望数据规模 执行重建扩容逻辑
    > 4. 基于RedissonClient创建新布隆过滤器,初始化新布隆过滤器(数据规模*1.5或2,误判率原样)
    > 5. 查询数据库中现有审核通过未删除未下架专辑ID,加入到新布隆过滤器中
    > 6. 将原有布隆器删除.bloomFilter.delete()
    > 7. 对现有布隆过滤器重命名,改名原有布隆过滤器
    
    #### 8. 采用阿里增量订阅组件Canal基于MySQL-BinLog解决缓存一致性问题
    
    

    wiki

背景:产生不一致问题 常规处理方案:1.双写,删除/失效方案 当应用存在并发写,并发读写可能造成数据不一致。 例如:数据库中记录字段值=100

  • A写线程更新=200
  • B为读线程(先从缓存获取,缓存没有再次查库,将查库结果放入缓存)
    ** AB并发执行 修改线程A先执行 删除缓存 CPU时间片耗尽 A暂停. 查询线程B执行 查询缓存未命中 查询数据库将数据库中100放入到缓存中 Redis记录=100 修改线程A继续执行 更新DB记录更新为:200 只有等到key过期或者下次单独执行写操作,才会将数据保存一致。

    
    解决方案:
    
    - 延时双删  采用就是该方案 
    
    > 更新方法业务伪代码
    >
    > 1. 删除缓存,失效
    > 2. 更新数据库
    > 3. 睡眠一段时间(确保此时并发读线程将可能读取"脏数据"放入缓存)
    > 4. 再次删除缓存
    >
    >  优势:实现成本较低,经过线上环境验证3个月暂未出现不一致问题,并发读写操作频率较低
    >
    >  缺点:入侵代码;睡眠时间不太好把控,经过大量并发压测试得出查询某项业务数据耗时,最久耗时+50ms
    
    - 分布式读写锁方案   https://github.com/Redisson/Redisson/wiki/8.-distributed-locks-and-synchronizers
    
    

本质:通过读写锁禁止并发写,并发读写从“根”解决不一致问题,适用于读多写少场景 分布式读写锁: 当某个进程获取写锁后,其他进程无法同时获取写锁或者读锁

           当某个进程获取读锁后,其他进程可以获取读锁,但不能获取写锁

Redisson框架提供分布式读写锁方案 更新业务方法伪代码

  1. 通过Redisson客户端对象获取读写锁对象,锁粒度设计(基于数据ID产生锁)
  2. 通过读写锁对象获取写锁 成功:并发读写禁止
  3. 执行写数据库操作
  4. 释放写锁

查询业务方法伪代码

  1. 通过Redisson客户端对象获取读写锁对象,锁粒度设计(基于数据ID产生锁)
  2. 尝试获取缓存中业务数据,如果命中返回;通过读写锁对象获取读锁 成功:并发写禁止 ,允许并发读
  3. 执行读缓存/或读数据库;将查询结果再次放入缓存中
  4. 释放读锁

优点:确保数据一致性 缺点:代码入侵严重(AOP进行优化),效率低


- MySQL binlog 机制-**Apache/阿里巴巴**增量订阅组件Canal  https://github.com/alibaba/canal

  适用于:所有源头是MySQL数据同步采用canal实现

  > 工作原理:本质MySQL主从复制原理  好处:无业务代码入侵 缺点:引入新中间件系统复杂性上升
  >
  > MySQL产生:二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操作语言)语句,但不包括数据查询(SELECT、SHOW)语句。
  >
  > **Binlog日志**形式:
  >
  > - ROW(默认-**行复制模式**): 记录变更前后数据,缺点:Binlog 文件较大,效率低
  > - Statement(**语句复制模式**):记录增删改SQL语句,缺点:执行函数now()数据不一致 优点:效率高
  > - **mixed**(混合):是将**语句复制模式**和**行复制模式**结合起来使用
  >   - 如果**存在函数**自动选择ROW模式
  >   - 反之采用Statement
  >
  > 工作原理:阿里的canal组件实现数据同步:不需要更改业务代码,部署一个canal服务。canal服务把自已伪装成mysyl的一个从节点,当mysql数据更新以后,canal会读取binlog数据,然后再通过canal的客户端获取到数据,更新/删除缓存即可。
  >
  > 主从复制原理:
  >
  > - master(MySQL)**主库需要打开binlog开关**
  >
  >   1. Master 主库在事务提交(增删改)时,会把数据变更记录在二进制日志文件 Binlog 中。
  >
  > - Canal服务端(主从中角色:客户端)
  >
  >   1. 执行**START SLAVE**命令从节点开启IO线程从库读取主库的二进制日志文件 Binlog
  >
  >   2. 将读取到binlog写入到从库的中继日志 Replay Log(处于内存中,读写快)
  >
  >   3. 由从节点SQL线程负责读取中继日志文件,达到复制数据一致
  >
  > - Canal客户端 项目中没有使用原生Canal客户端,采用启动依赖:Canal-spring-boot-starter
  >
  >   1. 配置文件定义连接Canal服务端IP跟端口,话题名称
  >   2. 准备实体类用户监听变更数据记录 @Column注解获取解析日志后得到变更数据
  >   3. 提供处理器类上使用注解:@CanalTable("表名称") 监听指定表变更 EntryHandler
  >   4. 实现接口中新增,修改,删除方法中删除缓存
  >
  > 总结:只要源头数据来源于MySQL,需要将MySQL数据同步到其他服务/存储中都也可以选择该方案

#### 9. 参与订单主要业务开发

订单核心业务:将**虚拟物品**换成**钱**过程,用户通过订单途径购买VIP会员,专辑,声音,支付方式支持:余额支付;在线支付(微信支付)付款后商家账户入账,用户账户付款。

- 步骤一:订单结算

  - VIP会员

  ![image-20231018102637239](assets/image-20231018102637239.png)

  - 购买专辑:业务需求:当用户查询专辑声音列表,声音最后显示付费标识说明该用户无法直接收听,用户点击付费标识进行购买。如果该专辑**价格类型**为:“**整张专辑购买**”弹出选择专辑购买(专辑存在折扣展示折扣信息) 选择购买后 进入订单结算页,禁止用户专辑重复购买,将购买“商品”专辑展示到订单结算页

  ![image-20231018102607811](assets/image-20231018102607811.png)

  - 购买声音

  当用户查询专辑声音列表,声音最后显示付费标识说明该用户无法直接收听,用户点击付费标识进行购买。如果该**专辑价格类型**为:“**单集购买**”弹出分集购买列表(本集,后10集,后20集..全集)当用户选择购买分集,将用户选择声音作为起始位置标准计算后续待结算声音列表(将用户已购声音排除掉),将声音列表作为购买商品清单,展示本次交易价格信息,商品信息。声音不支持折扣。

  ![image-20231018104258648](assets/image-20231018104258648.png)

除了展示“商品”信息外,还需要在订单确认页面中采用隐藏域方式,流水号、跟本次订单签名信息

流水号:订单确认页渲染接口服务端,采用UUID生成本次订单流水号字符串,将流水号存入Redis设置ttl-5分钟。

签   名:订单确认页面中将所有订单VO参数参与加密 对称加密算法(排序好订单vo所有值+"秘钥") 得到对应签名值

- 步骤二:订单提交(余额支付)

  > 业务流程:“商品”三种:1.VIP会员 2.专辑 3.声音,其中VIP会员及专辑支持两种付款方式(余额跟微信),声音仅支持余额付款。当选择余额支付,首先**业务校验**(一、验证流水号**采用lua脚本(判断跟删除原子性)**验证流水号及删除流水号。二、验签,采用订单确认页相同对称加密算法对订单VO信息+秘钥再次进行签名比对两次签名值是否一致);经过业务校验后,`核心业务1`:保存订单,保存订单其中订单编号(确保全局唯一:采用年月日+雪花算法);保存订单商品明细列表;保存优惠明细列表,此时订单状态:**未支付。**`核心业务2`:扣减账户余额且增加账户变动日志,只要余额扣减成功将地订单状态修改为:**已支付**。`核心业务3`:虚拟物品发货,专辑/声音新增购买记录即可(订单详情页面中声音付费标识-查询用户购买情况);VIP会员新增会员购买记录,更新用户VIP标识及延长会员过期时间。
  >
  > **亮点**:考虑到代码可扩展性,后续可能会再增加其他商品类型代码编写大量if else if,采用**策略+工厂设计模式**(**展开**)符合编程“**开闭原则**”,如果增加新增项目类型,不需要修改现有代码,再次新增对应购买项目策略实现类即可。
  >
  > **亮点**:在三项核心业务对应三个服务三个数据库面临分布式事务问题,**采用阿里巴巴/Apache Seata框架**提供AT模式解决(**展开**工作原理+写隔离/全局锁机制)分布式事务问题;当时遇到问题:由于项目采用全局异常处理机制,导致(Feign远程调用)即使分之事务发生异常,被全局异常类拦截完成处理,导致Feign请求没有异常,导致**分布式事务失效**。解决方案:1.事务发起方判断Feign响应业务状态码(成功业务状态码Integer是200,使用==判断判断返回false,原因:Integer将-128~127将常用数值对应Integer对象进行缓存,改为equals判断),手动抛出异常,被事务发起方感知异常 2. 分之事务业务逻辑中例如:扣减账户结果失败,手动调用Seata全局事务回滚方法,参考:https://seata.apache.org/zh-cn/blog/seata-spring-boot-aop-aspectj

<img src="assets/2024060400001.png" style="zoom:200%;" />

如果用户选择第三方支付平台:微信涉及调用微信支付SDK,用户付款动作。基于延迟任务,完成延迟关单:将超时未支付订单自动关闭;

#### 10. 利用流水号机制避免小程序回退造成的订单重复提交-采用Lua脚本确保判断删除原子性

- 订单结算页-生成流水号

  > 对本次提交的订单产生全局唯一流水号采用:UUID/雪花算法。将流水号存入Redis,Redis中Key:前缀:用户ID  Value:流水号值。 过期时间:5分钟
  >
  > 将流水号作为出参响应给客户端(订单确认页采用隐藏域流水号)
  >
  > 提交订单流水号作为入参,提交到服务端

- 订单提交-验证流水号

  > 1. 入参中包含用户提交流水号
  > 2. 根据用户ID查询Redis中存放流水号的值
  > 3. 采用通过一个Lua脚本进行判断及删除Redis中存储流水号,防止其被再次使用
  > 4. lua脚本返回true:执行后续业务 false:终止业务

#### 11. 采用签名机制(订单数据加签,验签)防止订单数据被篡改

背景:在订单确认页面展示数据正常都从服务端响应,如果用户通过"技术手段"提交订单时候修改订单vo中商品信息,价格信息等,没有验签机制造成商家损失。

- 订单结算页-生成签名 基于订单vo出参信息采用加密算法得出签名

  > 1. 出参订单vo转为Map,将Map转为有序TreeMap
  > 2. 遍历TreeMap得到Entry中Value(参数值)进行拼接得到明文参数
  > 3. 采用常见加密算法(对称加密-效率高于非对称加密)对称加密MD5(明文参数, **秘钥**)得到签名
  > 4. 将签名值作为vo出参响应给客户端

- 订单提交-验签 基于订单vo入参信息采用加密算法生成新签名,比对两次签名值

  > 1. 将前端提交订单VO参数(包含原有签名)再次进行相同方式生成签名,比对签名即可
  > 2. 入参订单vo转为Map,将Map转为有序TreeMap
  > 3. 遍历TreeMap得到Entry中Value(参数值)进行拼接得到明文参数,将原有签名去掉
  > 4. 采用加签相同方式进行再次签名:md5(明文参数, **秘钥**) 得到新签名
  > 5. 判断两次签名值是否一致 一致:继续后续业务 不一致:参数被篡改,业务终止

对称加密:效率高,安全系数低

非对称加密:效率低,安全性系数高

面试延伸:场景涉及要求安全系数很高Restful接口调用,如何防止该接口参数在网络传输中被篡改?

答:采用非对称加密(RSA加密方式),产生两把“钥匙”:

- 公钥:用来加密信息,A给B发送消息,拿着公钥进行加密。必须用配套私钥进行解密

- 私钥:用来解密信息,收到来自A的消息,拿着私钥进行解密

#### 12. 采用策略模式+工厂模式优化不同类型商品处理虚拟物品发货业务

背景:目前项目中提供三种商品类别(产品经理规划后续可能还会进一步丰富会员体系,增加其他商品类别-视频)如果仍然采用传统if else if判断处理不同商品类别,代码可维护性很差,扩展性很差。 特点:不同类型商品“发货”逻辑各不一样,采用行为型设计模式:策略模式 为了能具备“开闭原则”采用工厂统一管理所有策略,提供策略标识返回策略实现类。

> 1. 抽象策略类接口 :处理虚拟物品发货-新增购买记录逻辑方法
> 2. 在抽象类接口新增不同策略实现类,bean对象ID跟前端提交购买项目类型一致,完善新增购买记录业务逻辑
> 3. 提供工厂对象,采用Spring自动注入 将策略接口类型下 所有策略实现类对象注入 Map<Bean对象ID,策略实现类对象>;提供方法根据购买项目类型返回策略实现类
> 4. 处理不同购买项目业务代码;根据前端提交购买类型,精确调用具体策略实现类对象方法。
> 5. 扩展性提高,如果再有新的项目类型,只需要
>    1. 提供新策略实现类,自动注入工厂中Map中(所有策略实现类对象)完善新项目类型业务逻辑
>    2. 跟前端沟通提交对应购买类型参数
> 6. 整个过程不需要修改现有代码就实现功能增加

策略模式+工厂模式

优点:遵循编码“开闭原则”,消灭if else 判断逻辑

缺点:每增加一种策略都需要提供新的策略实现类

使用过程中关键点/注意事项: 策略实现类BeanID跟传入类型字符串一致,做到不修改原有代码增加不同策略处理逻辑。

#### 13. 采用Apache/Seata-AT模式解决分布式事务一致性

背景:在订单提交(余额支付)业务中涉及到三个系统业务处理(1.订单系统  2.账户系统 3.用户系统)订单系统分别通过OpenFeign远程调用账户系统跟用户系统;在账户系统中完成业务处理(1.账户余额检查扣减 2.新增账户变动日志)、在用户系统中完成业务处理(虚拟物品发货 1.新增专辑/声音购买记录 2.新增会员购买记录更新会员标识)多个应用多数据源管理,分布式事务问题。

提供解决分布式事务方案:

- Apache-Seata https://seata.apache.org/zh-cn/docs/overview/what-is-seata

  > - XA模式:工作原理 效率低
  >
  >   > RM:
  >   > 一阶段:
  >   > 1.执行业务SQL但不提交事务,MySQL记录Undolog日志(回滚依赖的日志文件),此时记录锁被占用
  >   > 2.RM上报结果到TC
  >   > 二阶段:
  >   > 接收来取TC提交请求:提交本地事务
  >   > 接收来取TC回滚请求:依赖Undolog日志进行数据回滚
  >   > TC:
  >   > 接收所有RM一阶段执行结果,如果所有RM都执行没有问题,TC发送提交指令
  >   > 接收所有RM一阶段执行结果,如果有任意RM有问题,TC发送回滚指令
  >   > 优点:数据强一致;主流数据库都支持XA协议;实现简单业务代码无入侵
  >   > 缺点:一阶段被锁定记录,一直等待二阶段提交或回滚 记录锁才被释放
  >
  > - TCC模式:性能最高,实现成本最高
  >
  >   > 初始:总金额100,锁定:0,可用:100
  >   >
  >   > T:Try 资源预留阶段 例如锁定扣减金额(总金额100,锁定:5,可用:95);锁定扣减库存
  >   >
  >   > C:Confirm 只要Try成功 理论上就可以Commit;(总金额95,锁定:0,可用金额:95)
  >   >
  >   > C:Cancel:任意RM有问题触发回滚手动提供事务补偿方法:还原(总金额100,锁定:0,可用:100)
  >   >
  >   > 系统自行实现 Try,Confirm,Cancel 三个操作,对业务系统有着非常大的入侵性,设计相对复杂。
  >
  > - AT模式:
  >
  >   > 应用:
  >   >
  >   > 1. 在涉及分布式事务 服务对应数据库中增加日志表:**undo_log表**
  >   > 2. 在配置文件配置:RM注入Seata信息;设置模式:AT(默认)
  >   > 3. 在事务发起方业务逻辑方法上使用注解@GlobalTransactional
  >   > 4. 事务参与方,不需要加任何注解
  >   > 5. 所有事务参与方都没有问题提交即可。任意事务参与方有异常进行全局事务回滚(Seata自动完成)
  >   >
  >   > 工作原理:
  >   >
  >   > - RM(所有事务参与方)
  >   >
  >   >   一阶段:
  >   >
  >   >   1. 被Seata代理持久层,解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC')等相关的信息。
  >   >   2. 查询前镜像:根据查询条件得到变更前数据
  >   >   3.  开启本地事务(执行业务 SQL,得到变更后数据,将变更前后回滚日志记录+业务SQL放在一个本地事务)
  >   >   4. 提交本地事务前,向 TC 获取 当前分之事务对应业务记录主键全局锁(分布式锁)超过300ms未获取到全局锁就会回滚本地事务
  >   >   5. 上报RM执行结果给TC
  >   >
  >   >   二阶段:
  >   >
  >   >   1. 接收TC**提交**请求
  >   >
  >   >      1. 把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC
  >   >
  >   >      2. 异步任务队列中请求执行-删除undoLog日志记录(业务事务在一阶段已提交)
  >   >
  >   >   2. 接收TC**回滚**请求
  >   >
  >   >      1.通过 XID(全局事务ID) 和 Branch ID(分之事务ID) 查找到相应的 UNDO LOG 记录
  >   >
  >   >      2.数据校验,校验在此期间是否有外部原因修改了记录
  >   >
  >   >      3.根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息 生成并执行回滚的语句,数据回滚
  >   >
  >   > - TC(事务协调器)
  >   >
  >   >   提交:向所有的RM发送提交指令
  >   >
  >   >   回滚:向所有的RM发送回滚指令
  >   >
  >   > 写隔离/全局锁机制:(加分项) https://seata.apache.org/zh-cn/docs/overview/what-is-seata
  >   >
  >   > 优点:
  >   >
  >   > - 业务代码无入侵
  >   > - 效率尚可满足大多数业务场景
  >   >
  >   > 缺点:
  >   >
  >   > - 各个RM可能存在短暂数据不一致,**最终一致性**

- 消息队列+本地消息表: https://blog.csdn.net/huanghuozhiye/article/details/136218214

  优势:效率高  缺点:实现复杂 确保本地事务跟发送消息原子性

  > 通过本地消息表(也称为可靠消息表)实现分布式事务是一种常见的做法,用于保证在分布式环境中**消息的可靠传递**和事务的一致性。以下是使用本地消息表实现分布式事务的一般步骤:
  >
  > - 消息生产方(也就是发起方),需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
  >
  >   ```
  >   下单业务逻辑
  >   1. 开始本地事务
  >   2. 保存订单(未支付)
  >   3. 构建保存扣减库存消息记录-本地消息表-状态 已通知
  >   4. 发送MQ消息到Kakfa/Rabbitm/RocketMQ消息队列,发送失败进行重试
  >   
  >   监听器:
  >   监听B系统业务回执
  >   监听回执消息OK:业务正常处理,修改自身业务数据状态(成功)修改消息表状态(成功)
  >   监听回执消息ERROR:对业务数据进行回滚,修改消息表状态为失败
  >   ```
  >
  > - 消息消费方(也就是发起方的依赖方),需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
  >
  >   ```
  >   扣减库存业务逻辑
  >   1.监听扣减库存消息 
  >   2.开始本地事务
  >   3.执行扣减库存
  >   3.1 扣减成功 发送消息通知订单系统更新自己本地消息表 业务数据状态
  >   3.2 扣减失败 重试仍然失败,发送其他消息通知订单系统 数据回滚
  >   ```
  >
  > 生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。
  >

#### 14. 采用Redisson延迟任务完成自动关单

背景:各项订单状态变更全局配置,订单超时多久未支付改为关闭;订单支付成功后多久未评论自动好评。下单购买VIP会员,专辑,声音选择微信支付,如果超过规定时间(60分钟),且未支付将订单自动关闭。

延迟消息:方案

- RabbitMQ  

  > 方式一:死信队列   消息成为死信条件:队列满;消息超时;
  >
  > ![image-20240605153700282](assets/image-20240605153700282.png)
  >
  > 方式二:延迟插件
  >
  > ![image-20240605153832797](assets/image-20240605153832797.png)
  >
  > 首先技术选型是项目高级/架构师决定,我的猜想:后续规划中包含大数据业务:用户画像,智能推荐可能需要用到处理海量日志数据场景。但目前业务量来看选择Kafka不合适,虽然Kafka吞吐量高,但是消息可靠性比起RabbitMQ来说是较弱的,我认为目前选择RabbitMQ足以。
  >
  > RabbitMQ:低延迟(底层采用ErLang语言(爱立信公司推出),通信效率仅次于C)可靠性高

- Redisson延迟任务

  > - 生产者:
  >   1. 基于RedissonClient创建阻塞队列,传入队列名称
  >   2. 基于RedissonClient将阻塞队列入参 创建延迟队列
  >   3. 调用延迟队列对象offer方法发送延迟消息
  >
  > - 消费者:
  >   1. 项目启动后开启单一线程线程池,专门负责监听阻塞队列中消息
  >   2. 获取需要检查订单ID对应订单支付状态,如果仍然是未支付,将订单修改为关闭。该笔订单不允许支付
  >
  > ![image-20240605154208938](assets/image-20240605154208938.png)

 ![image-20240706165333043](assets/image-20240706165333043.png)

#### 15. 采用分布式任务调度框架Xxl-Job进行定时更新会员状态、排行榜数据更新、腾讯音频审核结果、布隆过滤器扩容等

- SpringTask

  > 使用:优点:简单易用,适用于单体项目简单定时任务  
  >
  > 1. 启动类/配置类开启定时任务支持
  > 2. 在任务类中任务方法使用注解@Scheduled(任务执行时间)
  > 3. 项目启动自动执行任务
  >
  > 缺点:不支持分布式任务调度;如果进行分布式集群还需自己提供分布式锁避免任务重复执行;服务宕机导致任务丢失;没有管理页面任务执行状态只能通过查看日志获取执行状态

- Xxl-Job

  > 调度中心:统一管理任务调度,负责任务调度;并且提供任务管理平台
  >
  > 管理平台:
  >
  > 执行器:负责接收“调度中心”的调度并执行
  >
  > 工作原理:
  >
  > 1. 部署执行器,项目启动会进行执行器注册(将当前执行器应用名称,执行器netty服务端端口)发送到调度中心,直至执行器注册成功
  > 2. 调度中心收到注册请求,获取执行器netty服务端端口,调度中心作为Client跟执行器建立连接。
  > 3. 调度中心根据管理页面中任务配置,触发任务执行。
  > 4. 执行器接收调度中心调度,执行具体任务方法即可,上报任务执行结果
  >
  > 执行器使用:
  >
  > 1. 依赖,配置:调度中心IP端口等访问令牌、当前执行器信息:应用名称,TCP端口
  > 2. IOC容器中注册Bean XxlJobSpringExecutor 
  > 3. 任务类提供任务方法 方法使用注解@Xxljob("任高级配置识")
  > 4. 在管理页面:新增执行器,新增任务;配置任务
  >    1. 基础配置 报警邮件
  >    2. 调度配置 cron 指定任务执行时间
  >    3. 任务配置 指定任务标识
  >    4. 高级配置:设置路由策略-轮询(调用中心轮询方式调度每个执行器)、分片广播(同时调度没有执行器执行任务,自动携带分片参数,执行器根据分片参数(总分片数,当前分片索引)开发分片任务)
  >
  > 项目中:
  >
  > 1. 提供单独定时服务(执行器)
  >    1. 更新会员状态:OpenFeign远程调用用户服务接口
  >    2. 更新排行榜数据:OpenFeign远程调用搜索服务接口
  >    3. 。。。。

#### 16. 使用Kafka消息中间件来实现应用的异步,解耦,削峰以及数据的最终一致性,掌握消息可靠方案,消息幂等方案

- 消息可靠性方案:

1. 业务场景是否需要可靠
   1. 不需要确保可靠(吞吐量) 日志/行为数据确保吞吐量优先无需要考虑丢失问题
      1. 生产者异步发送消息
      2. 批量发送消息-batch_size=16KB linger(间隔发送时间) 批次数据大小跟间隔时间加长
      3. 将生产者应答级别:0 不需要等待broker应答(不关心持久化结果)
      4. 消费者端使用批量自动提交(时间尽可能长)
      5. 开启消费者端多线程
      6. 适当增加分区数量,达到消息并行处理
   2. 分析哪些环节可能会出现消息丢失
      1. 生产者:通过**网络**发送消息(**异步发送失败**)可能丢失
      2. broker服务器:生产者发送消息->持久化到leader分区->follow分区从leader获取数据达成同步。Leader分区持久化成功,follow分区未同步,broker宕机
      3. 消费者:自动提交offset,接收到消息后,触发自动提交,消息业务执行异常/服务宕机。导致消息丢失
   3. 提供解决方案
      1. 生产者端:同步发送消息或异步+回调,将应答级别设置为-1/all:broker服务器将消息写入到Leader分区本地日志文件中,Leader分区等待follow分区同步成功应答(只要有一个分区持久层成功)。立即返回 应答结果给生产者,记录就不会丢失。这是最有力的保证。这相当于acks=-1的设置。开启生产者重试,重试次数阈值超过3将发送失败消息,持久化到生产者异常消息表中,由人工处理;
      2. broker服务器:做好持久化(Kafka本身持久化)采用同步刷盘(IO阻塞一致到写入磁盘文件成功)
      3. 消费者:
         1. 将自动提交改为手动提交Offset,当监听到消息且业务代码执行没有问题才提交offset
         2. 业务执行中发生异常
            1. 开启**消费者重试,在消费者监听器上使用**@RetryableTopic配置重试策略, 设置重试阈值:3次(实际重试2次)
            2. 将重试3次仍然失败消息自动发送到死信话题
            3. 消费者监听死信话题中消息,将未消费成功消息持久化到“消费者异常消息表”由人工在管理后台处理

- 消息幂等性:

  幂等性就是指Producer不论向Broker发送多少次重复数据,Broker端都只会持久化一条,保证了不重复。或者无论broker同一消息向消费者投递多少次,只处理一次。

  > 生产者:发送消息失败触发重试,Broker服务器确保生产者幂等性,生产者发送消息或者重试消息同一消息Sequence序号相同。
  >
  > 消费者:
  >
  > 消息者业务处理抛出异常,Broker默认重复投递消息10次。
  >
  > 设置为自动提交,消息被监听到后,业务正常处理(事务已提交)未来得及提交offset,服务宕机了,服务重启后,继续从上次记录offset拉取消息,消息重复获取
  >
  > 方案:
  >
  > - 生产者端发送消息设置消息唯一标识,消费者端监听到消息后将消息唯一标识set k v nx 存入Redis第一次存储成功处理业务(有异常+删除key),同一消息重复接收,后续set nx 失败。
  > - 本身业务数据中含有标识数据(例如:库存商品编号,订单编号)利用标识构建set nx 对应的key

如何确保消息顺序性:关键点-将一组需要确保顺序消息发送到同一个分区即可

- 采用分区策略:key-hash策略(hash(key)取值对总分区数取余=得到发送目标分区索引)  将一组需要确保顺序消息 设置相同key 例如:一组消息对订单处理,将订单编号作为Key
- 单分区内每个消息都会被分配offset 单分区内消息有序

#### 17. 熟悉MySQL执行计划,对负责业务模块SQL进行优化

**背景:**

通过接口,QA测试反馈接口不达标、开启MySQL慢查询日志(默认是关闭的)设置最大忍耐时间,读取慢查询日志获取耗时久SQL、链路追踪定位耗时较长接口。

目标:要求所有业务SQL至少达到Range级别

1. 通过explain查看SQL执行计划

   | id       | 代表"几趟"查询 ID相同自上而下执行,ID值越大优先级越高,越先被执行 |
   | -------- | ------------------------------------------------------------ |
   | **type** | const > eq_ref > ref >range>index>all                        |
   | key      | 使用索引                                                     |
   | key_len  | 使用索引字节数                                               |
   | rows     | 预计检查行数 值约小约好                                      |
   | filter   | 实际跟预计检查行号百分比:值越高约好                         |
   | Extra    | 额外信息:索引库覆盖;索引下推;文件排序等重要参考指标       |

2. 合理设置表中索引

   1. 单表

      > 全职匹配我最爱;最佳左前缀法则;否定会导致索引失效 <> != not;计算函数;%? 开头会导致索引失效;条件范围右边会导致索引失效;or 也会导致索引失效;类型转换会导致索引失效

   2. 多表

      > 内连接: Inner join 内部优化器会自动识别谁是驱动、被驱动表
      >
      > 外连接: 建议在被驱动表[从表,选择大表]上创建索引
      >
      > 子查询:建议使用关联查询替换
      >
      > 覆盖索引 using Index

   3. 其他

      > 1. 去掉 * 目的就是尽量还用覆盖索引
      > 2.  在where或on后面的条件加索引

#### 18. Nginx

**Nginx可以健康检查和故障重试**

//配置反向代理 upstream test {

server 127.0.0.1:8001 fail_timeout=60s max_fails=2; # Server A
server 127.0.0.1:8002 fail_timeout=60s max_fails=2; # Server B

}


当A节点出现 connect refused时(端口关闭或服务器挂了),说明服务不可用,可能是服务发布,也可能是服务器挂了。此时nginx会把失败的请求自动转发到B节点。假设第二个请求 请求到A还是失败,正好累计2个失败了,那么Nginx会自动把A节点剔除存活列表 60 秒,然后继续把请求2 转发到B节点进行处理。60秒后,再次尝试转发请求到A节点…… 循环往复,直至A节点活过来……

而这一过程客户端是感知不到失败的。因为两次请求都二次转发到B节点成功处理了。客户端并不会感知到A节点的处理失败,这就是Nginx 反向代理的好处。即客户端不用直连服务端,加了个中间商,服务端的个别节点宕机或发布,对客户端都毫无影响。

**虚拟主机反向代理**

把多个二级域名映射到不同的文件目录,例如

- bbs.abc.com,映射到 html/bbs
- blog.abc.com 映射到 html/blog

http {

include       mime.types;
default_type  application/octet-stream;
sendfile        on;
keepalive_timeout  65;
server {
    listen       80;
    server_name  www.abc.com;
    location / {
        root   html/www;
        index  index.html index.htm;
    }
}

server {
    listen       80;
    server_name  bbs.abc.com;
    location / { 
        root   html/bbs;
        index  index.html index.htm;
    }   
}   

server {
    listen       80;
    server_name  blog.abc.com;
    location / { 
        root   html/blog;
        index  index.html index.htm;
    }   
}   

}


把不同的二级域名或者URL路径 映射到不同的 Tomcat集群

- 分别定义 serverGroup1、serverGroup2 两个Tomcat集群
- 分别把路径group1、group1 反向代理到serverGroup1、serverGroup2

json upstream serverGroup1 { # 定义负载均衡设备的ip和状态

    server 192.168.225.100:8080 ;           # 默认权重值为1
    server 192.168.225.101:8082 weight=2;   # 值越高,负载的权重越高
    server 192.168.225.102:8083 ;       
    server 192.168.225.103:8084 backup;     # 当其他非backup状态的server 不能正常工作时,才请求该server,简称热备
}

upstream serverGroup2 { # 定义负载均衡设备的ip和状态

    server 192.168.225.110:8080 ;           # 默认权重值为1
    server 192.168.225.111:8080 weight=2;   # 值越高,负载的权重越高
    server 192.168.225.112:8080 ;
    server 192.168.225.113:8080 backup;     # 当其他非backup状态的server 不能正常工作时,才请求该server,简称热备
}

server {                                    # 设定虚拟主机配置
    listen  80;                             # 监听的端口
    server_name  picture.itdragon.com;      # 监听的地址,多个域名用空格隔开
    location /group1 {                      # 默认请求 ,后面 "/group1" 表示开启反向代理,也可以是正则表达式
       root     html;                       # 监听地址的默认网站根目录位置
       proxy_pass   http://serverGroup1;   # 代理转发
       index  index.html index.htm;         # 欢迎页面
       deny 127.0.0.1;                      # 拒绝的ip
       allow 192.168.225.133;               # 允许的ip
    }
location /group2 {                      # 默认请求 ,后面 "/group2" 表示开启反向代理,也可以是正则表达式
       root     html;                       # 监听地址的默认网站根目录位置
       proxy_pass   http://serverGroup2;   # 代理转发
       index  index.html index.htm;         # 欢迎页面
       deny 127.0.0.1;                      # 拒绝的ip
       allow 192.168.225.133;               # 允许的ip
    }

    error_page   500 502 503 504  /50x.html;# 定义错误提示页面     
    location = /50x.html {                  # 配置错误提示页面
        root   html;
    }
}

#### 19. 项目中遇到的一些难点/参与过JVM调优吗/亦可作为亮点

首先明确JVM调优的目的:**一是减少Full gc的次数,二是缩短一次Full gc的时间**

> 背景:服务部署到线上,每日9-10点优惠购开启期间,用户反馈访问订单业务相关接口会明显的卡顿,接口耗时较长。运营反馈问题到研发部。这里我们采用阿里**Arthas**这个**JVM内存诊断工具**,通过**dashboard**命令定时显示JVM的运行状况,其中观察GC的次数及GC的耗时情况。发现在活动期间订单服务实例发生频繁的FullGC,每隔十分钟左右就会触发一次FullGC,由于在此期间只有下单接口被高并发调用,所以重点的关注点就在下单,分析主线下单业务定位找到原因所在。设置合适的JVM参数减少GC次数或减少GC耗时,达到JVM调优目的。

- **1.分析核心主线业务**

  9-10点期间举办优惠促销活动,订单服务集群中的单一服务实例服务器配置2核4G,单个节点处理请求TPS为300/s。该接口背后涉及到订单,优惠券,积分,库存等相关业务处理。同时订单服务还包含订单查询、删除、优惠券查询,积分查询等接口。

- **2.估算涉及对象大小**

  一般大的对象最多几十个上百个字段,我们假设一个订单对象大小1KB,这已经算比较大的,一般对象不这么大。按照TPS 300/s**估算每秒就会有300KB的订单对象生成**。还会涉及库存,优惠券,积分等其他相关对象;同时还包括其他的订单业务操作;既然是估算,我们肯定要放大一下才可靠。**故进行冗余20倍** **=300KB*20**

  约等于6M。**同时还应该包含用户其他操作,订单查询,订单删除,再冗余10倍=6M*10=60MB对象,1秒后成为垃圾**

- **3.画出对象内存模型**

   ![image-20240708093927816](assets/image-20240708093927816.png)

根据目前的业务场景每秒有60M对象产生进去Eden区,那么13秒就会占满Eden区域,发生minor gc(YGC),这些订单对象其中90%其实已经是垃圾对象了,因为在gc的那一刻,这些对象肯定还有一部分的业务操作还没有完成,所以他们不会被回收,我们这里假设每次minor gc都有60M对象还不会被回收------->60兆左右对象进入到幸存者0区

这60M对象会从Eden区移到S0区域,这里的60M虽然小于100M,同样会直接被移入老年代。原因:**JVM对象动态年龄判断**,就是如果你移动的这批对象超过了Surviror区的50%,同样会把这批对象移入老年代。

结果:每13秒就会有60M的对象被移入老年代,那么大概5~7分钟老年代的2G就会被占满,那么老年代一满就要发生full gc(STW现象),但是full gc使我们最不愿意看到的结果。

- **4.调整合适JVM参数调优**

我们这个系统其实没有很多会长久存在的对象,也就是老不死的对象,我们放在老年代的那些60M的对象,在一两秒后其实都会变为垃圾对象,在下一次full gc时都会被回收掉,那么我们这种业务场景,完全没必要给老年代设置2G的内存,根本用不到。我们完全可以把年轻代设置的大一些;

![image-20240708094255714](assets/image-20240708094255714.png)

此时需要25~30秒把伊甸园区放满,放满minor gc后有60M对象不被回收,要移到S0区,这时60M<200M/2,是可以移入S0区域的,下一次伊甸园区再放满做minor gc的时候,这时这60M对象所对应的订单已经生成了,已经变成了垃圾对象,是可以直接被回收的,所以没有什么对象是需要被移入老年代的。

那么这么一设置的话,这个系统是不是正常情况下基本不会再发生full gc了呢?就算发生,也是很久才会一次了。

常用的JVM运行参数:

sh nohup java -jar -Duser.timezone=GMT+8 -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m -Xms1024m -Xmx1024m -Xss256k -XX:SurvivorRatio=8 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/errorDump.hprof app.jar > nohop.log & ```

最后,我们项目选择JDK17的另一重要原因

垃圾回收器的暂停问题对实时响应要求较高的服务来说,一直是个痛点, CMS和G1等主流垃圾回收器的数十毫秒乃至上百毫秒的暂停时间相当致命。此外,调优门槛也相对较高,需要对垃圾回收器的内部机制有一定的了解,才能够进行有效的调优。随着ZGC的出现, 使得这一痛点彻底解决, ZGC 最初在 JDK 11 中作为实验性功能引入,并在 JDK 15 中宣布为生产就绪, 由于JDK17才是比较正式提供给大众实用的LTS支持版本,而且一部分公司已经在使用,所以我们最新项目使用JDK17。

ZGC 作为一款低延迟垃圾收集器,旨在满足以下目标:

  • 8MB到16TB的堆大小支持
  • 10ms最大GC暂时
  • 最糟糕的情况下吞吐量会降低15%

选择JDK17的不可拒绝的理由:低延迟的业务需求,毫秒级耗时的GC。开启ZGC的JVM参数:-XX:+UseZGC