Day01 - 初识项目

1.1 项目介绍

1.1.1 功能演示

天机学堂是一个基于微服务架构的生产级在线教育项目。项目源码:https://gitee.com/yaoyu_turin/tj-exam

  1. 管理端(B端):

演示地址:https://tjxt-admin.itheima.net/#/main/index,账号:12088888888,密码:888itcast.CN764%...

7261435

  1. 学生端(C端):

演示地址:https://tjxt-user.itheima.net/#/main/index,账号:12077777777,密码:888itcast.CN764%...

7261433

1.1.2 项目亮点

  1. 项目亮点:

7261500

  1. 技术架构:

7261501

1.2 项目环境搭建

1.2.1 企业开发模式

  1. 微服务项目与传统项目相比,包含项目模块非常多,每个模块都要独立部署,因此在开发模式上有很大差别:

    • 开发人员成倍增多,分组分别开发不同的微服务模块

    • 微服务模块之间有业务关联,需要相互协作

  2. 我们开发时,是否能把所有的项目代码都拉取到本地,然后在本地部署运行、开发测试?

    大型微服务项目显然不能,原因如下:

    • 我们可能没有其它模块的代码拉取权限

    • 微服务运行环境过于复杂,本地部署成本较高

    • 微服务模块较多,本地计算机性能难以支撑

7261530

  1. 在开发的不同阶段,往往有不同的测试手段:

    • 单元测试:测试最小的可测试单元

    • 集成测试:验证某个功能接口

    • 组件测试:验证微服务组件

    • 端对端联调:验证整个系统

7261532

1.2.2 模拟企业环境

为了模拟真实开发环境,我们准备了一台虚拟机,在其中安装了各种各样的公共服务和组件。

虚拟机导入说明:https://b11et3un53m.feishu.cn/wiki/wikcnp21tivsf57nQrHckQuZ3Vd

自定义部署:https://b11et3un53m.feishu.cn/wiki/R068wmzUtifYg5kEMP7c6EMTn9d

7261900

7261533

1.2.3 持续集成环境

我们在虚拟机中已经基于 Jenkins 实现了持续集成,访问 http://jenkins.tianji.com (账号:root/123) 即可查看控制台:

7261626

1.2.4 本地开发部署

我们在虚拟机环境中准备了一个Git私服,访问 http://git.tianji.com (账号:tjxt/123321) 即可查看私服控制台:

7261641

  1. 将代码克隆到自己的 IDEA 工作空间中:

  1. 以 lesson-init 分支为起点,创建一个 dev 分支,完成项目开发:

  1. 然后用 IDEA 打开项目即可。

1.2.5 熟悉项目

首先是项目结构,目前企业微服务开发项目结构有两种模式:

SpringBoot 的配置文件支持多环境配置,在天机学堂中也基于不同环境有不同配置文件:

文件说明
bootstrap.yml通用配置属性,包含服务名、端口、日志等等各环境通用信息
bootstrap-dev.yml线上开发环境配置属性,虚拟机中部署使用
bootstrap-local.yml本地开发环境配置属性,本地开发、测试、部署使用

除了基本配置以外,其它的配置都放在了 Nacos 中共享,包括两大类:共享的配置、微服务中根据业务变化的配置。

登录 http://nacos.tianji.com (账号:nacos/nacos) 即可看到所有被管理的配置信息。

7261924

1.3 学会 DEBUG

1.3.1 阅读源码

BUG:都是普通用户,为什么用户 Jack 可以删除自己的订单,而用户 Rose 则无法成功删除?

7261721

1.3.2 远程调试

如果部署的微服务不在本地,我们可以利用 IDEA 的远程调试功能:

7261722

1.3.3 分析解决

BUG 分析:Integer、Long 等包装类 -128 到 127 之间的数值使用共享对象,可以用 == 判断,但其它则需要采用 equals 判断。

1.3.4 测试部署

当我们完成项目的 BUG 修复后,可以把项目提交并推送到 Git 私服,而 Jenkins 可以帮我们完成项目自动编译、构建。

由于我们现在是基于 dev 分支开发,所以必须修改 Jenkins 中的自动化构建脚本,让其监听 dev 分支的变动:

7262031

 

Day02 - 我的课程

2.1 分析产品原型

2.1.1 分析业务流程

7262055

2.1.2 设计业务接口

管理端 - 产品原型与接口:https://lanhuapp.com/link/#/invite?sid=qx03viNU,密码: Ssml

用户端 - 产品原型与接口:https://lanhuapp.com/link/#/invite?sid=qx0Fy3fa,密码: ZsP3

编号接口简述请求方式
1支付后,添加课程到课表中MQ 通知
2分页查询我的课表GET
3查询学习中的课程GET
4查询指定课程状态GET
5删除课表中的课程DELETE
6退款后,移除课表中的课程MQ 通知
7检查课程是否有效(Feign 接口)GET
8统计课程学习人数(Feign 接口)GET

2.2 数据结构

2.2.1 字段分析

课表要记录的是用户的学习状态,所谓学习状态就是记录谁在学习哪个课程,学习的进度如何。

2.2.2 表结构

课表对应的数据库结构应该是这样的:

在一个名为 tj_learning 的 database 中,执行资料提供的 SQL 脚本,创建 learning_lesson 表:

7262224

2.2.3 代码生成

  1. 一般开发新功能都需要创建一个 feature 类型分支,不能在 DEV 分支直接开发:

  1. 我们使用的是 Mybatis 作为持久层框架,并且引入了 MybatisPlus 来简化开发。因此,在创建据库以后,就需要创建对应的实体类、mapper、service等。这些代码格式固定,编写起来又比较费时。好在 IDEA 中提供了一个 MP 插件,可以生成这些重复代码:

7262228

  1. 安装完成以后,我们先配置一下数据库地址:

7262237

  1. 严格按照下图的模式去设置,不要填错项目名称和包名称。最后,点击 code generatro 按钮,即可生成代码:

7262231

2.2.4 状态枚举

  1. 在数据结构中,课表是有学习状态的,学习计划也有状态。如果每次编码手写很容易写错,因此一般都会定义出枚举:

  1. 这样一来,我们就需要修改 PO 对象,用枚举类型替代原本的 Integer 类型:

MybatisPlus会在我们与数据库交互时实现自动的数据类型转换,无需我们操心。

2.3 开发接口功能

2.3.1 添加课程到课表中

需求:用户购买课程后,交易服务会通过 MQ 通知学习服务,学习服务将课程加入用户课表中。

7262320

  1. 在 tj-learning 服务中定义一个 MQ 的监听器:

  1. 实现 LearningLessonServiceImpl 中的 addUserLessons 方法:

2.3.2 分页查询我的课表

2.3.2.1 获取登录用户

7271313

  1. 网关解析 token,获取用户存入header,向下传递:

  1. 拦截器解析 header 中的用户信息,存入 UserContext:

  1. UserContext 是一个基于 ThreadLocal 的工具,可以确保不同的请求之间互不干扰,避免线程安全问题发生:

2.3.2.2 分页查询我的课表

需求:在个人中心-我的课程页面,可以分页查询用户的课表及学习状态信息。

7271311

  1. Query 实体类:

  1. Controller 层:

  1. Service 层:

2.3.3 查询学习中的课程

需求:在首页、个人中心-课程表页,需要查询并展示当前用户最近一次学习的课程。

7271413

  1. Controller 层:

  1. Service 层:

2.3.4 删除课表中的课程

需求:用户可以直接删除《已失效》的课程,学习服务将删除用户课表中的课程。

  1. Controller 层:

  1. Service 层:

2.3.5 移除课表中的课程

需求:用户退款课程后,交易服务会通过 MQ 通知学习服务,学习服务将移除用户课表中的课程。

在 tj-learning 服务中定义一个 MQ 的监听器即可,deleteCourseFromLesson 方法已实现:

2.3.6 检查课程是否有效

需求:当用户学习课程时,可能需要播放视频。此时提供播放功能的媒资系统就需要校验用户是否有播放资格。

接口说明根据课程 id,检查当前用户的课表中是否有该课程,课程状态是否有效。
请求方式Http 请求,GET
请求路径/ls/lessons/{courseId}/valid
请求参数格式课程 id,请求路径占位符,参数名:courseId
返回值格式课表 lessonId,如果是报名了则返回 lessonId,否则返回空
  1. Controller 层:

  1. Service 层:

2.3.7 查询指定课程状态

需求:进入详情页以后,需要查询用户的课表中是否有该课程,如果有则返回课程的学习进度、课程有效期等信息。

接口说明根据课程 id,查询课表中是否有该课程,如果有则返回课程的学习进度、课程有效期等信息。
请求方式Http请求,GET
请求路径/ls/lessons/{courseId}
请求参数格式课程 id,请求路径占位符,参数名:courseId
  1. Controller 层:

  1. Service 层:

2.3.8 统计课程学习人数

需求:根据课程 id,统计该课程的报名人数。

接口说明根据课程 id,统计该课程的报名人数。
请求方式Http 请求,GET
请求路径/ls/lessons/{courseId}/count
请求参数格式课程 id,请求路径占位符,参数名:courseId
  1. Controller 层:

  1. Service 层:

 

Day03 - 学习系统

3.1 分析产品原型

3.1.1 分析业务流程

7271545

3.1.2 设计业务接口

  1. 提交学习记录:

需求:在课程学习页面播放视频时或考试后,需要提交学习记录信息到服务端保存。

7271547

  1. 查询学习记录:

需求:在课程学习页面需要查询出每一个小节的基本信息,以及小节对应的学习记录。

7271548

  1. 创建学习计划:

需求:当点击创建学习计划时,会提交课程信息和计划的学习频率到服务端。服务端需要将数据写入对应的课表中。

7271549

  1. 查询学习计划:

需求:在我的课程页面中,需要统计用户本周的学习计划及进度,数据较多,采用分页查询。

7271551

3.1.3 抽取业务实体

7271557

3.2 开发接口功能

3.2.1 提交学习记录

  1. Controller 层:

  1. Service 层:

7271608

3.2.2 查询学习记录

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

3.2.3 创建学习计划

  1. Controller 层:

  1. Service 层:

3.2.4 查询学习计划

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

3.2.5 定时任务 (作业)

需求:编写一个 SpringTask 定时任务,定期检查 learning_lesson 表中的课程是否过期,如果过期则将课程状态修改为已过期。

 

3.3 高并发优化

3.3.1 高并发优化方案

7272215

  1. 提高单机并发:

    • 读:① 优化代码及 SQL;② 缓存。

    • 写:① 优化代码及 SQL;② 变同步为异步;③ 合并写请求。

  2. 变同步为异步:

    • 优点:① 无需等待,减少响应时间;② 利用 MQ 暂存消息,流量削峰整形作用;③ 降低写频率,减轻数据库并发压力。

    • 缺点:① 依赖MQ可靠性;② 没有降低数据库写次数,仅仅降低写频率。

7272224

  1. 合并写请求:

    • 优点:① 写缓存速度快,大大减少响应时间;② 降低了数据库写频率和写次数,减轻数据库并发压力。

    • 缺点:① 实现相对复杂;② 依赖缓存系统可靠性;③ 不支持事务和复杂业务。

7272226

3.3.2 播放进度优化方案

  1. 优化方案选择:

    提交播放进度业务虽然看起来复杂,但大多数请求的处理很简单,就是更新播放进度。

    并且播放进度数据是可以合并的(覆盖之前旧数据),所以采用合并写请求方案。

  2. Redis 数据结构设计:

    • KEY:课表 id(用户 id + 课程 id):lessonId

    • HashKey:小节 id:sectionId

    • HashValue:记录 id:id,播放进度:moment,是否学完:finished

  3. 持久化思路:

7272247

3.3.3 延迟任务

  1. 延迟任务方案:

7272259

  1. DelayQueue 的原理:

首先来看一下 DelayQueue 的源码:

可以看到 DelayQueue 实现了 BlockingQueue 接口,是一个阻塞队列。DelayQueue 叫延迟队列,其中存储的就是延迟执行的任务。

根据 DelayQueue 的泛型定义,可知存入队列的元素必须是 Delayed 类型,这其实就是一个延迟任务的规范接口:

可以看出Delayed 类型必须具备两个方法:getDelay():获取任务剩余延迟时间;compareTo(T t):根据延迟时间判断执行顺序。

3.3.4 代码改造

7272315

  1. 定义延迟任务工具类:(了解)

  1. 改造提交学习记录功能:

3.3.5 线程池的使用 (作业)

需求:目前我们的延迟任务执行还是单线程模式,大家将其改造为线程池模式,核心线程数与CPU核数一致即可。

3.4 面试题

3.4.1 你负责学习系统的设计和开发,那能不能讲讲是如何设计的?

答:我参与了整个学习中心的功能开发,其中有很多的学习辅助功能都很有特色。比如视频播放的进度记录。

我们网站的课程是以录播视频为主,为了提高用户体验,需要实现续播功能。功能本身并不复杂,只不过我们产品提出的要求比较高:

要达成这个目的,传统的手段显然是不行的。首先,要做到切换设备后还能续播,用户的播放进度必须保存在服务端,而不是客户端。

其次,续播的时间误差不能超过 30 秒,那播放进度的记录频率就需要比较高。我们会在前端每隔 15 秒就发起一次心跳请求,提交最新的播放进度,记录到服务端。这样用户下一次续播时直接读取服务端的播放进度,就可以将时间误差控制在 15 秒左右。

3.4.2 播放进度保存在服务端哪里?如果写在数据库,那压力是不是太大了?

答:提交播放记录最终肯定是要保存到数据库中的。 因为我们不仅要做视频续播,还有用户学习计划、学习进度统计等功能,都需要用到用户的播放记录数据。

但确实如你所说,前端每隔 15 秒一次请求,如果在用户量较大时,直接全部写入数据库,对数据库压力会比较大。

因此我们采用了合并写请求的方案,当用户提交播放进度时会先缓存在 Redis 中,后续再将数据保存到数据库即可。 由于播放进度会不断覆盖,只保留最后一次即可。这样就可以大大减少对于数据库的访问次数和访问频率了。

 

Day04 - 问答系统

4.1 分析产品原型

4.1.1 分析业务流程

  1. 分析业务流程:

7281559

  1. 统计接口:

 编号接口简述
互动提问1新增互动问题(C 端)
 2修改互动问题(C 端)
 3分页查询问题(C 端)
 4查询问题详情(C 端)
 5删除互动问题(C 端)
 6分页查询问题(B 端)
 7查询问题详情(B 端)
 8隐藏显示问题(B 端)
回答及评论9新增回答或评论(C 端)
 10分页查询回答或评论(C 端)
 11分页查询回答或评论(B 端)
 12隐藏显示回答或评论(B 端)

4.1.2 设计业务接口

  1. 新增互动问题(C 端):

7281615

  1. 修改互动问题(C 端):

7281624

  1. 分页查询问题(C 端):

7281625

  1. 查询问题详情(C 端):

7281626

  1. 删除互动问题(C 端):

请求方式GET
请求路径/admin/questions/page
  1. 分页查询问题(B 端):

7281631

  1. 查询问题详情(B 端):

7281632

  1. 隐藏显示问题(B 端):

请求方式PUT
请求路径/admin/questions/{id}/hidden/{hidden}
请求参数路径占位符参数
  1. 新增回答或评论(C 端):

7281633

  1. 分页查询回答或评论(C 端):

请求方式GET
请求路径/replies/page
  1. 分页查询回答或评论(B 端):

7281635

  1. 隐藏显示回答或评论(B 端):

请求方式PUT
请求路径/admin/replies/{id}/hidden/{hidden}
请求参数路径占位符参数

4.1.3 抽取业务实体

  1. 问题表 interaction_question

  1. 回答、评论表 interaction_reply

4.2 开发接口功能

4.2.1 新增互动问题(C 端)

  1. Controller 层:

  1. Service 层:

4.2.2 修改互动问题(C 端)

  1. Controller 层:

  1. Service 层:

4.2.3 分页查询问题(C 端)

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

4.2.4 查询问题详情(C 端)

  1. Controller 层:

  1. Service 层:

4.2.5 删除互动问题(C 端)

  1. Controller 层:

  1. Service 层:

4.2.6 分页查询问题(B 端)

4.2.6.1 课程名称模糊搜索

在项目中,所有上线的课程数据都会存储到 Elasticsearch 中,方便用户检索课程。并且在 tj-search 模块中提供了相关的查询接口。

其中就有根据课程名称搜索课程信息的功能,并且这个功能还对外开放了一个 Feign 客户端方便我们调用:

4.2.6.2 课程分类数据

  1. 查询思路分析:

管理端除了要查询到问题,还需要返回问题所属的一系列信息。这些数据对应到 interaction_question 表中,只包含一些 id 字段。

课程分类信息在以往的查询中没有涉及过,课程分类在首页就能看到,共分为3级。 只要我们查询到了问题所属的课程,就能知道课程关联的三级分类 id,接下来只需要根据分类 id 查询出分类名称即可。

而在 course-service 服务中提供了一个接口,可以查询到所有的分类:

  1. 多级缓存:

Redis 虽然能提高性能,但每次查询缓存还是会增加网络带宽消耗,也有网络延迟。

分类数据特点:数据量小、长时间不会发生变化。像这样的数据,还适合做本地缓存(Local Cache),形成多级缓存机制:

Caffeine 是一个基于 Java8 开发的,提供了最佳命中率的高性能的本地缓存库。目前 Spring 内部缓存使用的就是 Caffeine。

  1. 课程分类的本地缓存:(了解)

4.2.6.3 实现分页查询问题

  1. Controller 层:

  1. Service 层:

4.2.7 查询问题详情(B 端)

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

4.2.8 隐藏显示问题(B 端)

  1. Controller 层:

  1. Service 层:

4.2.9 新增回答或评论(C 端)

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

4.2.10 分页查询回答或评论(C 端)

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

4.2.11 分页查询回答或评论(B 端)

  1. Controller 层:

  1. Service 层:

4.2.12 隐藏显示回答或评论(B 端)

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

 

Day05 - 点赞系统

5.1 分析产品原型

5.1.1 需求分析

7282232

5.1.2 数据结构

5.2 点赞功能

5.2.1 点赞或取消

7282237

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

5.2.2 查询点赞状态

  1. Controller 层:

  1. Service 层:

  1. 暴露 Feign 接口:

由于该接口是给其它微服务调用的,所以必须暴露出 Feign 客户端,并且定义好 fallback 降级处理:

由于每个微服务扫描包不一致,因此我们需要通过 SpringBoot 的自动加载机制来加载这些 fallback 类:

  1. 改造 分页查询回答或评论 接口:

5.2.3 监听点赞数变更的消息

7282344

远程调用接口:

5.3 点赞功能改进

5.3.1 改进思路分析

7282350

5.3.2 改造点赞功能

5.3.2.1 点赞或取消

5.3.2.2 查询点赞状态

当我们判断某用户是否点赞时,需要使用下面命令:SISMEMBER bizId userId 但这个命令只能判断一个用户对某一个业务的点赞状态。而我们的接口是要查询当前用户对多个业务的点赞状态。

Redis 中提供了一个功能 Pipeline,可以在一次请求中执行多个命令,实现批处理效果:

5.3.2.3 定时任务

定时任务的实现方案有很多,简单的例如:SpringTask、Quartz。

还有一些依赖第三方服务的分布式任务框架:Elastic-Job、XXL-Job。此处使用简单的 SpringTask 来实现并测试效果。

  1. 在 tj-remark 模块的启动类上添加注解:

  1. 定义一个定时任务处理器类:

  1. 调用接口:

5.3.2.4 监听点赞数变更的信息

5.4 面试题

5.4.1 你负责点赞系统的设计和开发,那能不能讲讲是如何设计的?

答:首先在设计之初我们分析了一下点赞业务可能需要的一些要求。

  1. 在我们项目中需要用到点赞的业务不止一个,因此点赞系统必须具备通用性,独立性,不能跟具体业务耦合。

  2. 点赞业务可能会有较高的并发,我们要考虑到高并发写库的压力问题。(此处停顿,等待追问

具体实现细节:

5.4.2 那你们 Redis 中具体使用了哪种数据结构呢?

答:我们使用了两种数据结构,SET 和 ZSET。

为什么不让用户 id 为 key,业务 id 为 value 呢?如果用户量很大,可能出现 BigKey 吧?

答:考虑到我们的项目数据量并不会很大,因此点赞数量通常不会超过 1000,因此不会出现 BigKey。

那你 ZSET 干什么用的?

答:如果只有 SET,我们没办法知道哪些业务的点赞数发生了变化。因此,我们用 ZSET 来记录点赞数变化的业务及对应的点赞总数。

可以理解为一个待持久化的点赞任务队列。每当业务被点赞,不仅要缓存点赞记录,还要把 业务id点赞总数 写入 ZSET。

这样定时任务开启时,只需要从 ZSET 中获取并移除数据,然后发送 MQ 给业务方,并持久化到数据库即可。

5.4.3 为什么一定要用 ZSET 结构,把更新过的业务扔到一个 List 不行吗?

答:扔到 List 结构中虽然也能实现,但是存在一些问题:

那就改为 SET 结构,SET 中只放业务 id,业务方收到 MQ 通知后再次查询不就行了。

答:这样会导致多次网络通信,增加系统网络负担。而 ZSET 则可以同时保存业务 id 及最新点赞数量,避免多次网络查询。

 

Day06 - 积分系统

6.1 分析产品原型

6.1.1 接口统计

业务编号接口简述
签到1签到
 2查询签到记录
积分3新增积分记录
 4查询今日积分情况
 5查询赛季列表
排行榜6查询实时赛季积分榜
 7查询历史赛季积分榜

6.1.2 数据结构

  1. 签到记录:

  1. 积分记录:

  1. 排行榜记录:

6.2 签到功能

6.2.1 思路分析

我们按月来统计用户签到信息,签到记录为 1,未签到则记录为 0。 把每一个 bit 位对应当月的每一天,形成了映射关系。用 0 和 1 标示业务状态,这种思路就称为位图(BitMap)。

Redis 中提供了 BitMap 数据结构,并且提供了很多操作 bit 的命令。 BitMap 基于 String 结构,而 String 类型的最大空间是 512MB(2^31 个 bit),因此保存数据的量级是十分恐怖的。

  1. 修改 BitMap 中的数据:

  1. 读取 BitMap 中的数据:

6.2.2 签到

  1. Controller 层:

  1. Service 层:

6.2.3 查询签到记录

  1. Controller 层:

  1. Service 层:

6.3 积分功能

6.3.1 新增积分记录

6.3.1.1 思路分析

虽然我们积分功能目前是在学习中心实现的,不过考虑的以后的扩展性,此处我们还是考虑使用 MQ 来实现异步解耦:

7291646

6.3.1.2 发送MQ消息

  1. 先定义一个 MQ 消息体:

  1. 然后改造签到功能:

6.3.1.3 编写消息监听器

6.3.1.4 保存积分明细

7291642

  1. Service 层:

  1. Mapper 层:

6.3.2 查询今日积分情况

  1. Controller 层:

  1. Service 层:

  1. Mapper 层:

6.3.3 查询赛季列表

  1. Controller 层:

  1. Service 层:

  1. Mapper 层:

6.4 排行榜功能

6.4.1 查询实时赛季积分榜

6.4.1.1 思路分析

7292001

6.4.1.2 生成实时榜单

  1. 定义 Redis 的 KEY 前缀:

  1. 修改 保存积分明细 接口:

6.4.1.3 查询实时赛季积分榜

7292015

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

6.4.2 查询历史赛季积分榜

6.4.2.1 存储方案

7292046

  1. 分区:

    分区是一种数据存储方案,可以解决单表数据较多的问题。MySQL5.1 开始支持表分区功能。

    • 优点:提高数据检索、统计的性能;打破磁盘容量限制;根据分区清理数据效率高。

    • 缺点:分区字段必须是索引的一部分或全部;分区方式不够灵活;只支持水平分区。

  2. 分表:

    分表是一种表设计方案,由开发者在创建表时按照自己的业务需求拆分表。

    • 优点:解决了字段多和数据多引起的各种问题;分区方式更灵活。

    • 缺点:需要判断操作哪张表;垂直分表需要处理事务问题、数据关联问题;水平分表要处理聚合操作的数据合并问题。

  3. 分库和集群:

    • 按照业务垂直分库

      • 优点:避免了不同服务间数据耦合;请求分流,提高了整体的并发能力。

      • 缺点:热点服务容易出现单点故障;部分库依然存在瓶颈;有分布式事务问题。

    • 主从备份读写分离

      • 优点:解决了海量数据存储的问题;提高了读写并发能力,避免了单点故障。

      • 缺点:分片后数据聚合统计比较复杂;会有主从数据同步问题;存在分布式事务问题。

7292100

6.4.2.2 存储策略

7292135

6.4.2.3 定时任务创建表

  1. 定时任务:

  1. 查询赛季 id:

  1. 创建榜单表:

  1. Mapper 层:

6.4.2.4 分布式任务调度

XXL-JOB 是一个分布式任务调度平台,其设计目标是开发迅速、学习简单、轻量级、易扩展。已接入多家公司线上产品线,开箱即用。

7292209

  1. 部署调度中心:

运行资料中提供的,初始化 SQL 文件,创建 XXL-JOB 所需表。参考以下 Docker 命令创建容器:

  1. 集成执行器:

首先引入 XXL-JOB 依赖,配置执行器:(了解)

然后,在 PointsBoardPersistentHandler 中将任务的 @Scheduled 注解替换为 @XXLJob 注解:

  1. 注册执行器:登录 XXL-JOB 控制台,注册执行器:

7292224

  1. 配置任务调度:进入任务管理菜单,选中 学习中心执行器,然后新增任务:

7292229

6.4.2.5 榜单持久化

需求:每个月第一天凌晨,将 Redis 中的上月榜单数据持久化到数据库中,并清理 Redis 数据。

7301317

  1. 动态表名:

定义一个传递表名称的工具:

定义一个配置类:

修改 MybatisConfig 的拦截器配置:

  1. 定时持久化任务:

  1. XXL-JOB 任务分片:

刚才定义的定时持久化任务,通过 while 死循环,不停的查询数据,直到把所有数据都持久化为止。 如果数据量达到数百万,将来肯定会将学习服务多实例部署,这样就会有多个执行器并行执行。 但是如果交给多个任务执行器,大家都从第 1 页逐页处理数据,又会重复处理。怎样才能不重复呢?可以参考扑克牌发牌的原理:

7301347

要想知道每一个执行器执行哪些页数据,只要弄清楚两个关键参数即可:分页起始页码 index、分页跨度 total。

  1. 清理 Redis 缓存任务:

当任务持久化以后,我们还要清理 Redis 中的上赛季的榜单数据,避免过多的内存占用:

  1. 任务链:

7301359

进入任务管理页面,添加 3 个任务(createPointsBoardTablesavePointsBoard2DBclearPointsBoardFromRedis):

7301400

6.4.2.6 查询历史赛季积分榜

6.5 面试题

6.5.1 你负责积分功能的开发,那能不能讲讲是如何设计实现的?

答:因为签到功能数据量非常大,我们用了 BitMap 结构。

BitMap 是用 bit 位来表示签到数据,31bit 位就能表示 1 个月的签到记录,非常节省空间,而且查询效率也比较高。

你使用 Redis 保存签到记录,那如果 Redis 宕机怎么办?

答:对于 Redis 的高可用数据安全问题,有很多种方案。

6.5.2 你负责积分排行榜功能的开发,那能不能讲讲是如何设计实现的?

答:我们的排行榜功能分为两部分:一个是当前赛季排行榜,一个是历史排行榜。每个月为一个赛季,月初清零积分记录。

你们使用 Redis 的 SortedSet 来保存榜单数据,如果用户量非常多怎么办?

答:Redis 的 SortedSet 底层利用了跳表机制,性能还是非常不错的。即便有百万级别的用户量,也没什么问题。

历史榜单采用的定时任务框架是?处理数百万的榜单数据时任务是如何分片的?你们是如何确保多个任务依次执行的呢?

答:我们采用的是 XXL-JOB 框架。XXL-JOB 自带任务分片广播机制,每一个任务执行器都能通过 API 得到自己的分片编号、分片总数量。

 

Day07 - 优惠券管理

7.1 产品原型分析

7.1.1 业务流程分析

7301530

7.1.2 接口统计

 编号接口简述
优惠券管理1新增优惠券
 2修改优惠券
 3删除优惠券
 4查询优惠券
 5分页查询优惠券
优惠券发放6发放优惠券
 7停发优惠券
 8生成兑换码

7.1.3 表结构设计

7301540

7.2 优惠券管理

7.2.1 新增优惠券

7301547

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

7.2.2 修改优惠券

7301614

  1. Controller 层:

  1. Service 层:

7.2.3 删除优惠券

7301620

  1. Controller 层:

  1. Service 层:

7.2.4 查询优惠券

7301628

  1. Controller 层:

  1. Service 层:

  1. 远程调用接口:

7.2.5 分页查询优惠券

7301639

  1. Controller 层:

  1. Service 层:

7.3 优惠券发放

7.3.1 发放优惠券

7301653

  1. Controller 层:

  1. Service 层:

7.3.2 停发优惠券

7302140

  1. Controller 层:

  1. Service 层:

7.3.3 生成兑换码

7.3.3.1 兑换码生成算法

  1. 兑换码的需求:

    • 可读性好:兑换码是要给用户使用的,因此可读性必须好:长度不超过 10 个字符,只能是 24 个大写字母和 8 个数字

    • 数据量大:优惠活动比较频繁,必须有充足的兑换码,最好有 10 亿以上的量

    • 唯一性:10 亿兑换码都必须唯一,不能重复,否则会出现兑换混乱的情况

    • 不可重兑:兑换码必须便于校验兑换状态,避免重复兑换

    • 防止爆刷:兑换码的规律性不能很明显,不能轻易被人猜测到其它兑换码

    • 高效:兑换码生成、验证的算法必须保证效率,避免对数据库带来较大的压力

  2. 算法分析:

    • Base32 转码

      角标0123456789101112131415
      字符ABCDEFGHJKLMNPQR
      角标16171819202122232425262728293031
      字符STUVWXYZ23456789

      举例:假如我们经过自增 id 计算出一个复杂数字,转为二进制,并每 5 位一组,结果如下:

    • 重兑校验算法

      基于 BitMap:兑换或没兑换就是两个状态,对应 0 和 1,而兑换码使用的是自增 id。 我们如果每一个自增 id 对应一个 bit 位,可以用每一个 bit 位的状态表示兑换状态。

      • 优点:简答、高效、性能好

      • 缺点:依赖于 Redis

    • 防刷校验算法

      我们采用自增 id 的同时,还需要利用某种校验算法对 id 做加密验证,避免他人找出规律。

      为了避免秘钥被人猜测出规律,我们可以准备 16 组秘钥。在兑换码自增 id 前拼接一个 4 位的新鲜值:

      7302253

  3. 算法实现:(了解)

7.3.3.2 异步生成兑换码

判断是否需要生成兑换码,要同时满足两个要求:① 领取方式是兑换码方式;② 之前的状态是待发放,不能是暂停。 优惠券发放以后是可以暂停的,暂停之后还可以再次发放,再次生成兑换码时,就重复了。

7302302

而且,由于生成兑换码的数量较多,可能比较耗时,这里推荐基于线程池异步生成:

  1. 定义线程池:

同时,在启动类添加 @EnableAsync 注解,开启异步功能。

  1. 改造 发放优惠券 功能接口:

  1. 远程调用接口:

7.4 面试题

7.4.1 你们优惠券支持兑换码的方式是吧,哪兑换码是如何生成的呢?

答:要考虑兑换码的验证的高效性,最佳的方案肯定是用自增序列号。因为可以借助于 BitMap 验证兑换状态,完全不用查询数据库。

7.4.2 你在项目中哪些地方用到过线程池?

答:很多地方,比如我在实现优惠券的兑换码生成的时候。

那你的线程池参数是怎么设置的?

答:线程池的常见参数包括:核心线程最大线程队列线程名称拒绝策略等。

 

Day08 - 领取优惠券

8.1 领取优惠券

8.1.1 产品原型分析

8.1.1.1 原型分析

  1. 查询优惠券列表(C 端):

7302327

  1. 领取优惠券(C 端):

    • 优惠券:是用来封装优惠信息的实体,不属于任何人,因此不能在消费时使用。

    • 用户券:是某个优惠券发放给某个用户后得到的实体,属于某一个用户,可以在消费时使用。

    • 用户券表:用来保存用户和券的关系、使用状态等信息。用户券可以看做是用户和券的关系,即谁领了哪张券。

  2. 兑换优惠券(C 端):

7302330

  1. 查询我的优惠券(C 端):

7302331

8.1.1.2 数据结构

8.1.2 领取优惠券

8.1.2.1 查询优惠券列表(C 端)

  1. Controller 层:

  1. Service 层:

8.1.2.2 领取优惠券(C 端)

  1. Controller 层:

  1. Service 层:

  1. Mapper 层:

8.1.2.3 兑换优惠券(C 端)

  1. Controller 层:

  1. Service 层:

修改 领取优惠券 实现类代码:

  1. 远程调用接口:

8.1.2.4 查询优惠券(C 端)

  1. Controller 层:

  1. Service 层:

  1. Mapper 层:

8.1.3 并发安全问题

8.1.3.1 超卖问题

  1. 分析原因:

    我们对于优惠券库存的处理逻辑是这样的:先查询,再判断,再更新,而以上三步操作并不具备原子性。

    超卖问题产生原因:① 多线程并行运行;② 多行代码操作共享资源,但不具备原子性。这就是典型的线程并发安全问题。

  2. 解决方案:

    • 悲观锁:一种独占和排他的锁机制,悲观地认为数据会被其他事务修改,在整个数据处理过程中将数据处于锁定状态。

      • 优点:安全性高

      • 缺点:性能较差

    • 乐观锁:一种较为乐观的控制机制,乐观地认为多用户并发不会产生安全问题,因此无需独占和锁定资源。 但在更新数据前,会先检查是否有其他线程修改了该数据,如果有则认为可能有风险,会放弃修改操作。

      • 优点:安全性高、性能较好

      • 缺点:并发较高时,更新成功率低

    针对更新成功率低的问题,有一个改进方案:无需判断 issue_num 是否与原来一致,只要判断是否小于 total_num 即可:

  3. 实现方案:

    • 修改更新库存的 SQL 语句:

    • 修改 checkAndCreateUserCoupon 方法:

8.1.3.2 锁失效问题

  1. 分析原因:

    除了优惠券库存判断,领券时还有对于用户限领数量的判断:先查询,再判断,再新增,以上三步也不具备原子性。

    乐观锁常用在更新,所以这里只能采用悲观锁方案,也就是大家熟悉的 Synchronized 或者 Lock

    用户限领数量判断是针对单个用户的,因此锁的范围不需要是整个方法,只要锁定某个用户即可:

    测试发现并发问题依然存在,锁失效。原来是因为 userId 是 Long 类型,其中 toString() 采用的是 new String() 的方式。 也就是说,哪怕是同一个用户 id,但 toString() 得到的也是多个不同对象,也就是多把不同的锁。

8.1.3.3 事务边界问题

  1. 分析原因:

    经过同步锁的改造,理论上问题已经是解决了。不过经过测试后,发现问题依然存在,用户还是会超领。

    这是由于事务的隔离导致的,整个领券发放加了事务,而在内部我们加锁:

    整体业务流程是这样的:开启事务 -> 获取锁 -> 执行业务 -> 释放锁 -> 提交事务

    假设此时有两个线程并行执行这段逻辑:

    • 线程 1 执行完业务释放锁,线程 2 立刻获取锁成功,开始执行业务

      • 线程 1 尚未提交事务,于是线程 2 也新增一条券,安全问题就发生了

  2. 解决方案:

    解决方法很简单,调整事务边界和锁边界:获取锁 -> 开启事务 -> 执行业务 -> 提交事务 -> 释放锁

8.1.3.4 事务失效问题

  1. 分析原因:

    事务失效的原因有很多,接下来我们就逐一分析一些常见的原因:

    • 事务方法非 public 修饰:

    • 非事务方法调用事务方法:(此次失效原因)

    • 事务方法的异常被捕获了:

    • 事务异常类型不对:

    • 事务传播行为不对:

    • 没有被 Spring 管理:

  2. 解决方案:

    既然事务失效的原因是方法内部调用走的是 this,而不是代理对象,那想办法获取代理对象就可以了:

    • 引入 AspectJ 依赖:

    • 在启动类上加注解,暴露代理对象:

    • 使用代理对象:

8.2 领取优惠券优化 // TODO

8.2.1 分布式锁

8.2.1.1 集群下的锁失效问题

8.2.1.2 简单分布式锁

8.2.1.3 分布式锁的问题

8.2.1.4 Redisson

8.2.1.5 通用分布式锁组件

 

8.2.2 异步领券

8.2.2.1 优化思路分析

8.2.2.2 优惠券缓存

8.2.2.3 异步领券

8.2.2.4 异步的兑换码领券

8.2.2.5 基于 lua 脚本的异步领券

 

8.3 面试题

8.3.1 你做的优惠券功能如何解决券超发的问题?

答:券超发问题常见的有两种场景:① 券库存不足导致超发;② 发券时超过了每个用户限领数量;

你这里聊到悲观锁,是用什么来实现的呢?

答:由于优惠券服务是多实例部署形成的负载均衡集群。因此考虑到分布式下 JVM锁失效问题,采用了基于 Redisson 的分布式锁

8.3.2 加锁以后性能会比较差,有什么好的办法吗?

答:针对领券问题,我们采用了 MQ 来做异步领券,起到一个流量削峰和整型的作用,降低数据库压力。

8.3.3 事务失效的情况碰到过吗?哪些情况会导致事务失效?

答:非事务方法调用、方法不是 public、方法的异常被捕获、抛出的异常类型不对、传播行为使用错误、Bean 没有被 Spring 管理。

 

Day09 - 使用优惠券

9.1 优惠券规则定义

9.1.1 业务流程分析

7311700

编号接口简述
1根据订单查询可用优惠方案(C 端) // TODO
2根据券方案计算订单优惠明细(C 端)
3核销优惠券(C 端)
4退还优惠券(C 端)
5查询优惠券(C 端)

9.1.2 优惠券规则定义

所谓的优惠券方案推荐,就是从用户的所有优惠券中筛选出可用的优惠券,并且计算哪种优惠方案用券最少,优惠金额最高。

我们抽象一个接口来标示优惠券规则:

数据库表结构如下:

7311709

9.2 优惠券智能推荐 // TODO

9.2.1 思路分析

9.2.2 定义接口

9.2.3 查询用户券并初步筛选

9.2.4 细筛

9.2.5 优惠方案全排列组合

9.2.6 计算优惠明细

9.2.7 CompleteableFuture 并发计算

9.2.8 筛选最优解

 

9.3 优惠券使用

9.3.1 根据券方案计算订单优惠明细(C 端)

参数说明
请求方式POST
请求路径/user-coupons/discount
请求参数userCouponIds、courseList
返回值discountAmount、discountDetail
  1. Controller 层:

  1. Service 层:

修改 calculateSolutionDiscount 方法:

  1. Mapper 层:

  1. 定义 FeignClient 方法:

  1. 改造交易服务接口:

9.3.2 核销优惠券(C 端)

参数说明
请求方式PUT
请求路径/user-coupons/use
请求参数couponIds
返回值
  1. Controller 层:

  1. Service 层:

  1. 定义 FeignClient 方法:

  1. 改造交易服务接口:

9.3.3 退还优惠券(C 端)

参数说明
请求方式PUT
请求路径/user-coupons/refund
请求参数couponIds
返回值
  1. Controller 层:

  1. Service 层:

  1. 定义 FeignClient 方法:

  1. 改造交易服务接口:

9.3.4 查询优惠券(C 端)

参数说明
请求方式GET
请求路径/user-coupons/rules
请求参数couponIds
返回值rules
  1. Controller 层:

  1. Service 层:

  1. 定义 FeignClient 方法:

  1. 改造交易服务接口:

9.4 面试题

9.4.1 你们的优惠券规则是如何编码实现的?

答:在初期做调研的时候也考虑过规则引擎,不过考虑到我们的优惠规则并不复杂,最终选择了基于策略模式来自定义规则。

9.4.2 你在项目中有没有使用到设计模式?

答:在优惠券功能中使用了策略模式来定义优惠规则。还有基于注解的通用分布式锁组件,也使用到了策略模式、工厂模式。

9.4.3 你在项目中有没有使用到线程池或者并发编程?

答:在实现优惠券的推荐算法时,我们采用的是排列组合多种优惠方案,然后分别计算,最终筛选出最优解的思路。

由于需要计算的优惠方案可能较多,为了提高计算效率,我们利用了 CompletableFuture 来实现多方案的并行计算。 并且由于要筛选最优解,那就需要等待所有方案都计算完毕,再来筛选。因此就使用了 CountdownLatch 来做多线程的并行控制。

9.4.4 使用优惠券的订单如果部分退货,如何操作?优惠券如何处理?

答:基于产品的需求,我们采用的是退款不退券的方案。