项目流程图:https://kdocs.cn/join/gvfzfav?f=101
业务背景:
数据表:专辑表、专辑统计信息表、分类(1,2,3分类)、专辑标签关系表、标签表、标签值表、声音表、声音统计信息表。【金山文档 | WPS云文档】 3、数据表结构 https://kdocs.cn/l/cjsYNYg4gSps
内容创作者/听书运营人员 新增保存专辑,需要填写专辑相关信息:1.专辑标题 2.上传专辑封面(上传到MInIO) 3.专辑简介(PC端有富文本编辑器) 4.所属分类 5.专辑标签 6.专辑付费类型等(付费类型、价格类型,折扣) 点击提交保存专辑,对专辑中文本,图片调用腾讯云内容安全接口进行内容安全审核。审核通过后才能保存/发布专辑。 专辑被编辑前要求将专辑先下架,编辑内容后再次进行审核,审核通过后才能进行再次上架处理。
解决方案:
内容安全方案(腾讯云)
kafka异步进行发布专辑
生产者(专辑服务)
当内容经过审核,使用KafkaTemplate对象发送上架消息(审核通过专辑ID)到上架话题
消费者(搜索服务)
监听上架话题,获取专辑ID,封装专辑对应各项数据-专辑索引对象(1.专辑信息、2.分类信息、 3.主播信息 、 4.统计信息 对应是4个OpenFeign接口调用,采用线程池+异步任务),将索引库对象存入专辑索引库、将专辑标题存入提词索引库(搜索自动联想功能)、将专辑ID存入布隆过滤器(初始化)
问题:确保消息可靠(不丢失)、幂等性(ES新增文档处理)
分析业务是否需要可靠
分析消费者端是否需要进行幂等性处理
腾讯云点播
优势:云点播(Video on Demand,VOD)面向音视频、图片等媒体,提供制作上传、存储、转码、媒体处理、媒体 AI、加速分发播放、版权保护等一体化高品质媒体服务。
问题:声音源文件防盗 结论:无法百分比确保被盗
启用防盗链:登录 云点播控制台,
进入应用后在导航栏中的分发播放设置 > 域名管理,对您使用的域名单击设置。
进入访问控制菜单栏,开启 “Referer 防盗链”,“防盗链类型”选择“白名单”,文本框中输入允许播放视频的站点域名列表,单击确定。
打开 “Key 防盗链”,输入或随机生成防盗链 Key,单击确定。
此时,已为域名开启了 Referer 防盗链和 Key 防盗链.
微服务架构下会话保持,传统/单体项目cookie+session(会话)服务端通过Session对象存放用户信息,写会SessionID到Cookie中(JsessionID)Cookie自动提交,服务端根据JsessionID查询服务端Session对象(默认30分钟),包含用户信息。Session未查到到说明用户已退出系统。
实现会话保持:
背景:
客户端端有PC端,APP端,小程序等。单点登录是有状态登录(服务端存储用户信息),并且有多种登录方式,所以结合了策略模式+工厂模式,定义了抽象策略类,并提供了具体策略实现包括:
账户登录:用户名和密码 听书后台验证账户合法性
手机验证码:短信(阿里云短信服务-判断删除采用lua脚本) 听书后台验证手机号合法性
微信登录:openId(只要获取到OpenID账户合法)
小程序端
- 集成微信SDK,根据当前微信个人信息调用微信服务端获取code(临时票据,有效期:5分钟) 携带code访问服务端,用于获取token
服务端:
策略模式+工厂模式
> 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框架提供分布式读写锁方案 更新业务方法伪代码
- 通过Redisson客户端对象获取读写锁对象,锁粒度设计(基于数据ID产生锁)
- 通过读写锁对象获取写锁 成功:并发读写禁止
- 执行写数据库操作
- 释放写锁
查询业务方法伪代码
优点:确保数据一致性 缺点:代码入侵严重(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 作为一款低延迟垃圾收集器,旨在满足以下目标:
选择JDK17的不可拒绝的理由:低延迟的业务需求,毫秒级耗时的GC。开启ZGC的JVM参数:-XX:+UseZGC