学成在线(第19天)分布式事务
订单与选课需求分析
订单支付流程
学成在线的课程分为免费和收费两种。对于收费课程,用户需提交订单并完成支付方可在线学习。
提交订单及支付流程如下:
1、用户提交订单需要先登录系统
2、提交订单,订单信息保存到订单数据库
3、订单支付,调用微信支付接口完成支付
4、完成支付,微信支付系统通知学成在线支付结果
5、学成在线接收到支付结果通知,更新支付结果
自动选课需求
支付成功即完成订单,订单完成之后系统需自动添加选课。
下图是微信支付、学成在线订单服务、学成在线学习服务交互图:
1、用户支付完成,微信支付系统会主动通知学成在线支付结果,学成在线也可主动请求微信支付查询订单的支付
结果。
最终得到支付结果后将订单支付结果保存到订单数据库中。
2、订单支付完成系统自动向选课表添加学生选课记录。
3、选课记录添加完成学习即可在线开始学习。
分布式事务
什么是分布式事务
1、什么是分布式系统?
部署在不同结点上的系统通过网络交互来完成协同工作的系统。
比如:充值加积分的业务,用户在充值系统向自己的账户充钱,在积分系统中自己积分相应的增加。充值系统和积
分系统是两个不同的系统,一次充值加积分的业务就需要这两个系统协同工作来完成。
2、什么是事务?
事务是指由一组操作组成的一个工作单元,这个工作单元具有原子性(atomicity)、一致性(consistency)、隔
离性(isolation)和持久性(durability)。
原子性:执行单元中的操作要么全部执行成功,要么全部失败。如果有一部分成功一部分失败那么成功的操作要全
部回滚到执行前的状态。 一致性:执行一次事务会使用数据从一个正确的状态转换到另一个正确的状态,执行前后
数据都是完整的。 隔离性:在该事务执行的过程中,任何数据的改变只存在于该事务之中,对外界没有影响,事务
与事务之间是完全的隔离的。只有事务提交后其它事务才可以查询到最新的数据。 持久性:事务完成后对数据的改
变会永久性的存储起来,即使发生断电宕机数据依然在。
3、什么是本地事务?
本地事务就是用关系数据库来控制事务,关系数据库通常都具有ACID特性,传统的单体应用通常会将数据全部存储
在一个数据库中,会借助关系数据库来完成事务控制。
4、什么是分布式事务?
在分布式系统中一次操作由多个系统协同完成,这种一次事务操作涉及多个系统通过网络协同完成的过程称为分布
式事务。这里强调的是多个系统通过网络协同完成一个事务的过程,并不强调多个系统访问了不同的数据库,即使
多个系统访问的是同一个数据库也是分布式事务,如下图:
另外一种分布式事务的表现是,一个应用程序使用了多个数据源连接了不同的数据库,当一次事务需要操作多个数
据源,此时也属于分布式事务,当系统作了数据库拆分后会出现此种情况。
上面两种分布式事务表现形式以第一种据多。
5、分布式事务有哪些场景?
1) 电商系统中的下单扣库存
电商系统中,订单系统和库存系统是两个系统,一次下单的操作由两个系统协同完成
2)金融系统中的银行卡充值
在金融系统中通过银行卡向平台充值需要通过银行系统和金融系统协同完成。
3)教育系统中下单选课业务
在线教育系统中,用户购买课程,下单支付成功后学生选课成功,此事务由订单系统和选课系统协同完成。
4) SNS系统的消息发送
在社交系统中发送站内消息同时发送手机短信,一次消息发送由站内消息系统和手机通信系统协同完成。
CAP 理论
如何进行分布式事务控制?CAP理论是分布式事务处理的理论基础,了解了CAP理论有助于我们研究分布式事务的
处理方案。
CAP理论是:分布式系统在设计时只能在一致性(Consistency)、可用性(Availability)、分区容忍性(Partition
Tolerance)中满足两种,无法兼顾三种。
通过下图理解CAP理论:
一致性(Consistency):服务A、B、C三个结点都存储了用户数据, 三个结点的数据需要保持同一时刻数据一致
性。
可用性(Availability):服务A、B、C三个结点,其中一个结点宕机不影响整个集群对外提供服务,如果只有服务A结
点,当服务A宕机整个系统将无法提供服务,增加服务B、C是为了保证系统的可用性。
分区容忍性(Partition Tolerance):分区容忍性就是允许系统通过网络协同工作,分区容忍性要解决由于网络分区
导致数据的不完整及无法访问等问题。
分布式系统不可避免的出现了多个系统通过网络协同工作的场景,结点之间难免会出现网络中断、网延延迟等现
象,这种现象一旦出现就导致数据被分散在不同的结点上,这就是网络分区。
CAP有哪些组合方式?
1、CA:放弃分区容忍性,加强一致性和可用性,关系数据库按照CA进行设计。
2、AP:放弃一致性,加强可用性和分区容忍性,追求最终一致性,很多NoSQL数据库按照AP进行设计。
说明:这里放弃一致性是指放弃强一致性,强一致性就是写入成功立刻要查询出最新数据。追求最终一致性是指允
许暂时的数据不一致,只要最终在用户接受的时间内数据 一致即可。
3、CP:放弃可用性,加强一致性和分区容忍性,一些强一致性要求的系统按CP进行设计,比如跨行转账,一次转
账请求要等待双方银行系统都完成整个事务才算完成。
消息队列实现最终一致
本方案是将分布式事务拆分成多个本地事务来完成,并且由消息队列异步协调完成,如下图:
下边以下单减少库存为例来说明:
1、订单服务和库存服务完成检查和预留资源。
2、订单服务在本地事务中完成添加订单表记录和添加“减少库存任务消息”。
3、由定时任务根据消息表的记录发送给MQ通知库存服务执行减库存操作。
4、库存服务执行减少库存,并且记录执行消息状态(为避免重复执行消息,在执行减库存之前查询是否执行过此
消息)。
5、库存服务向MQ发送完成减少库存的消息。
6、订单服务接收到完成库存减少的消息后删除原来添加的“减少库存任务消息”。
实现最终事务一致要求:预留资源成功理论上要求正式执行成功,如果执行失败会进行重试,要求业务执行方法实
现幂等。
优点 :
由MQ按异步的方式协调完成事务,性能较高。
不用实现try/confirm/cancel接口,开发成本比TCC低。
缺点:
此方式基于关系数据库本地事务来实现,会出现频繁读写数据库记录,浪费数据库资源,另外对于高并发操作不是
最佳方案。
Spring Task 定时任务
需求分析
根据分布式事务的研究结果,订单服务需要定时扫描任务表向MQ发送任务。本节研究定时任务处理的方案,并实
现定时任务扫描任务表并向MQ发送消息。
实现定时任务的方案如下:
1 、使用jdk的Timer和TimerTask实现
可以实现简单的间隔执行任务,无法实现按日历去调度执行任务。
2、使用Quartz实现
Quartz 是一个异步任务调度框架,功能丰富,可以实现按日历调度。
3、使用Spring Task实现
Spring 3.0后提供Spring Task实现任务调度,支持按日历调度,相比Quartz功能稍简单,但是在开发基本够用,支
持注解编程方式。
本项目使用Spring Task实现任务调度。
Spring Task 串行任务
在Spring boot启动类上添加注解:@EnableScheduling
新建任务测试类TestTask,编写测试方法如下:
@Component public class ChooseCourseTask { private static final Logger LOGGER = LoggerFactory.getLogger(ChooseCourseTask.class); // @Scheduled(fixedRate = 5000) //上次执行开始时间后5秒执行 // @Scheduled(fixedDelay = 5000) //上次执行完毕后5秒执行 // @Scheduled(initialDelay=3000, fixedRate=5000) //第一次延迟3秒,以后每隔5秒执行一次 @Scheduled(cron="0/3 * * * * *")//每隔3秒执行一次 public void task1(){ LOGGER.info("===============测试定时任务1开始==============="); try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } LOGGER.info("===============测试定时任务1结束==============="); }
串行任务测试
参考 task1方法的的定义方法,再定义task2方法,此时共用两个任务方法。
@Scheduled(fixedRate = 3000) //上次执行开始时间后5秒执行 public void task2(){ LOGGER.info("===============测试定时任务2开始==============="); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } LOGGER.info("===============测试定时任务2结束==============="); }
Spring Task 并行任务
配置异步任务
创建异步任务配置类,需要配置线程池实现多线程调度任务。
@Configuration @EnableScheduling public class AsyncTaskConfig implements SchedulingConfigurer, AsyncConfigurer { //线程池线程数量 private int corePoolSize = 5; @Bean public ThreadPoolTaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.initialize();//初始化线程池 scheduler.setPoolSize(corePoolSize);//线程池容量 return scheduler; } @Override public Executor getAsyncExecutor() { Executor executor = taskScheduler(); return executor; } @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return null; } @Override public void configureTasks(ScheduledTaskRegistrar scheduledTaskRegistrar) { scheduledTaskRegistrar.setTaskScheduler(taskScheduler()); } }
订单服务定时发送消息
需求分析
定时任务发送消息流程如下:
1、每隔1分钟扫描一次任务表。
1、定时任务扫描task表,一次取出多个任务,取出超过1分钟未处理的任务
2、考虑订单服务可能集群部署,为避免重复发送任务使用乐观锁的方式每次从任务列表取出要处理的任务
3、任务发送完毕更新任务发送时间
关于任务表的添加:
正常的流程是订单支付成功向更新订单支付状态并向任务表写入“添加选课任务”。
目前订单支付功能没有开发,采用手动向任务表添加任务。
RabbitMQ 配置
向RabbitMQ声明两个队列:添加选课、完成选课,交换机使用路由模式,代码如下:
@Configuration public class RabbitMQConfig { //添加选课任务交换机 public static final String EX_LEARNING_ADDCHOOSECOURSE = "ex_learning_addchoosecourse"; //添加选课消息队列 public static final String XC_LEARNING_ADDCHOOSECOURSE = "xc_learning_addchoosecourse"; //完成添加选课消息队列 public static final String XC_LEARNING_FINISHADDCHOOSECOURSE = "xc_learning_finishaddchoosecourse"; //添加选课路由key public static final String XC_LEARNING_ADDCHOOSECOURSE_KEY = "addchoosecourse"; //完成添加选课路由key public static final String XC_LEARNING_FINISHADDCHOOSECOURSE_KEY = "finishaddchoosecourse"; /** * 交换机配置 * @return the exchange */ @Bean(EX_LEARNING_ADDCHOOSECOURSE) public Exchange EX_DECLARE() { return ExchangeBuilder.directExchange(EX_LEARNING_ADDCHOOSECOURSE).durable(true).build(); } //声明队列 @Bean(XC_LEARNING_FINISHADDCHOOSECOURSE) public Queue QUEUE_XC_LEARNING_FINISHADDCHOOSECOURSE() { Queue queue = new Queue(XC_LEARNING_FINISHADDCHOOSECOURSE); return queue; } //声明队列 @Bean(XC_LEARNING_ADDCHOOSECOURSE) public Queue QUEUE_XC_LEARNING_ADDCHOOSECOURSE() { Queue queue = new Queue(XC_LEARNING_ADDCHOOSECOURSE); return queue; } /** * 绑定队列到交换机 . * @param queue the queue * @param exchange the exchange * @return the binding */ @Bean public Binding BINDING_QUEUE_FINISHADDCHOOSECOURSE(@Qualifier(XC_LEARNING_FINISHADDCHOOSECOURSE) Queue queue, @Qualifier(EX_LEARNING_ADDCHOOSECOURSE) Exchange exchange) { return BindingBuilder.bind(queue).to(exchange).with(XC_LEARNING_FINISHADDCHOOSECOURSE_KEY).noargs(); } @Bean public Binding BINDING_QUEUE_ADDCHOOSECOURSE(@Qualifier(XC_LEARNING_ADDCHOOSECOURSE) Queue queue, @Qualifier(EX_LEARNING_ADDCHOOSECOURSE) Exchange exchange) { return BindingBuilder.bind(queue).to(exchange).with(XC_LEARNING_ADDCHOOSECOURSE_KEY).noargs(); } }
查询前N条任务
Dao
在XcTaskRepository中自定义方法如下:
public interface XcTaskRepository extends JpaRepository<XcTask, String> { //取出指定时间之前的记录 Page<XcTask> findByUpdateTimeBefore(Pageable pageable,Date updateTime); }
Service
@Service public class TaskService { @Autowired XcTaskRepository xcTaskRepository; @Autowired RabbitTemplate rabbitTemplate; //取出前n条任务,取出指定时间之前处理的任务 public List<XcTask> findTaskList(Date updateTime,int n){ //设置分页参数,取出前n 条记录 Pageable pageable = new PageRequest(0, n); Page<XcTask> xcTasks = xcTaskRepository.findByUpdateTimeBefore(pageable,updateTime); return xcTasks.getContent(); } }
编写任务类
编写任务类,每分钟执行任务,启动订单工程,观察定时发送消息日志,观察rabbitMQ队列中是否有消息,代码
如下:
package com.xuecheng.order.mq; @Component public class ChooseCourseTask { private static final Logger LOGGER = LoggerFactory.getLogger(ChooseCourseTask.class); @Autowired TaskService taskService; //每隔1分钟扫描消息表,向mq发送消息 @Scheduled(fixedDelay = 60000) public void sendChoosecourseTask(){ //取出当前时间1分钟之前的时间 Calendar calendar =new GregorianCalendar(); calendar.setTime(new Date()); calendar.add(GregorianCalendar.MINUTE,‐1); Date time = calendar.getTime(); List<XcTask> taskList = taskService.findTaskList(time, 1000); } }
定时发送任务
Dao
添加更新任务方法:
//更新任务处理时间 @Modifying @Query("update XcTask t set t.updateTime = :updateTime where t.id = :id ") public int updateTaskTime(@Param(value = "id") String id,@Param(value = "updateTime")Date updateTime);
Service
添加发送消息方法:
/** * //发送消息 * @param xcTask 任务对象 * @param ex 交换机id * @param routingKey */ @Transactional public void publish(XcTask xcTask,String ex,String routingKey){ //查询任务 Optional<XcTask> taskOptional = xcTaskRepository.findById(taskId); if(taskOptional.isPresent()){ XcTask xcTask = taskOptional.get(); //String exchange, String routingKey, Object object rabbitTemplate.convertAndSend(ex,routingKey,xcTask); //更新任务时间为当前时间 xcTask.setUpdateTime(new Date()); xcTaskRepository.save(xcTask); } }
编写任务类
编写任务类,每分钟执行任务,启动订单工程,观察定时发送消息日志,观察rabbitMQ队列中是否有消息,代码
如下:
package com.xuecheng.order.mq; @Component public class ChooseCourseTask { private static final Logger LOGGER = LoggerFactory.getLogger(ChooseCourseTask.class); @Autowired TaskService taskService; //每隔1分钟扫描消息表,向mq发送消息 @Scheduled(fixedDelay = 60000) public void sendChoosecourseTask(){ //取出当前时间1分钟之前的时间 Calendar calendar =new GregorianCalendar(); calendar.setTime(new Date()); calendar.add(GregorianCalendar.MINUTE,‐1); Date time = calendar.getTime(); List<XcTask> taskList = taskService.findTaskList(time, 1000); //遍历任务列表 for(XcTask xcTask:taskList){ //发送选课消息 taskService.publish(xcTask, xcTask.getMqExchange(),xcTask.getMqRoutingkey()); LOGGER.info("send choose course task id:{}",taskId); } } }
自动添加选课开发
学习服务添加选课
需求分析
学习服务接收MQ发送添加选课消息,执行添加 选 课操作。
添加选课成功向学生选课表插入记录、向历史任务表插入记录、并向MQ发送“完成选课”消息。
RabbitMQ配置
学习服务监听MQ的添加选课队列,并且声明完成选课队列,配置代码同订单服务中RabbitMQ配置
Dao
学生选课Dao:
public interface XcLearningCourseRepository extends JpaRepository<XcLearningCourse, String> { //根据用户和课程查询选课记录,用于判断是否添加选课 XcLearningCourse findXcLearningCourseByUserIdAndCourseId(String userId, String courseId); }
历史任务Dao:
public interface XcTaskHisRepository extends JpaRepository<XcTaskHis,String> { }
Service
1、添加选课方法
向xc_learning_course添加记录,为保证不重复添加选课,先查询历史任务表,如果从历史任务表查询不到任务说
明此任务还没有处理,此时则添加选课并添加历史任务。
在学习服务中编码如下代码:
//完成选课 @Transactional public ResponseResult addcourse(String userId, String courseId,String valid,Date startTime,Date endTime,XcTask xcTask){ if (StringUtils.isEmpty(courseId)) { ExceptionCast.cast(LearningCode.LEARNING_GETMEDIA_ERROR); } if (StringUtils.isEmpty(userId)) { ExceptionCast.cast(LearningCode.CHOOSECOURSE_USERISNULL); } if(xcTask == null || StringUtils.isEmpty(xcTask.getId())){ ExceptionCast.cast(LearningCode.CHOOSECOURSE_TASKISNULL); } //查询历史任务 Optional<XcTaskHis> optional = xcTaskHisRepository.findById(xcTask.getId()); if(optional.isPresent()){ return new ResponseResult(CommonCode.SUCCESS); } XcLearningCourse xcLearningCourse = xcLearningCourseRepository.findXcLearningCourseByUserIdAndCourseId(userId, courseId); if (xcLearningCourse == null) {//没有选课记录则添加 xcLearningCourse = new XcLearningCourse(); xcLearningCourse.setUserId(userId); xcLearningCourse.setCourseId(courseId); xcLearningCourse.setValid(valid); xcLearningCourse.setStartTime(startTime); xcLearningCourse.setEndTime(endTime); xcLearningCourse.setStatus("501001"); xcLearningCourseRepository.save(xcLearningCourse); } else {//有选课记录则更新日期 xcLearningCourse.setValid(valid); xcLearningCourse.setStartTime(startTime); xcLearningCourse.setEndTime(endTime); xcLearningCourse.setStatus("501001"); xcLearningCourseRepository.save(xcLearningCourse); } //向历史任务表播入记录 Optional<XcTaskHis> optional = xcTaskHisRepository.findById(xcTask.getId()); if(!optional.isPresent()){ //添加历史任务 XcTaskHis xcTaskHis = new XcTaskHis(); BeanUtils.copyProperties(xcTask,xcTaskHis); xcTaskHisRepository.save(xcTaskHis); } return new ResponseResult(CommonCode.SUCCESS); }
接收添加选课消息
接收到添加选课的消息调用添加选课方法完成添加选课,并发送完成选课消息。
在com.xuecheng.learning.mq包下添加ChooseCourseTask类
@Component public class ChooseCourseTask { private static final Logger LOGGER = LoggerFactory.getLogger(ChooseCourseTask.class); @Autowired LearningService learningService; @Autowired RabbitTemplate rabbitTemplate; /** * 接收选课任务 */ @RabbitListener(queues = {RabbitMQConfig.XC_LEARNING_ADDCHOOSECOURSE}) public void receiveChoosecourseTask(XcTask xcTask,Message message,Channel channel) throws IOException { LOGGER.info("receive choose course task,taskId:{}",xcTask.getId()); //接收到 的消息id String id = xcTask.getId(); //添加选课 try { String requestBody = xcTask.getRequestBody(); Map map = JSON.parseObject(requestBody, Map.class); String userId = (String) map.get("userId"); String courseId = (String) map.get("courseId"); String valid = (String) map.get("valid"); Date startTime = null; Date endTime = null; SimpleDateFormat dateFormat = new SimpleDateFormat("YYYY‐MM‐dd HH:mm:ss"); if(map.get("startTime")!=null){ startTime =dateFormat.parse((String) map.get("startTime")); } if(map.get("endTime")!=null){ endTime =dateFormat.parse((String) map.get("endTime")); } //添加选课 ResponseResult addcourse = learningService.addcourse(userId, courseId, valid,startTime, endTime,xcTask); //选课成功发送响应消息 if(addcourse.isSuccess()){ //发送响应消息 rabbitTemplate.convertAndSend(RabbitMQConfig.EX_LEARNING_ADDCHOOSECOURSE, RabbitMQConfig.XC_LEARNING_FINISHADDCHOOSECOURSE_KEY, xcTask ); LOGGER.info("send finish choose course taskId:{}",id); } } catch (Exception e) { e.printStackTrace(); LOGGER.error("send finish choose course taskId:{}", id); } } }
订单服务结束任务
需求分析
订单服务接收MQ完成选课的消息,将任务从当前任务表删除,将完成的任务添加到完成任务表。
Dao
1、删除xc_task
2、添加xc_task_his
Service
在TaskService中定义删除任务方法
//删除任务 @Transactional public void finishTask(String taskId){ Optional<XcTask> taskOptional = xcTaskRepository.findById(taskId); if(taskOptional.isPresent()){ XcTask xcTask = taskOptional.get(); xcTask.setDeleteTime(new Date()); XcTaskHis xcTaskHis = new XcTaskHis(); BeanUtils.copyProperties(xcTask, xcTaskHis); xcTaskHisRepository.save(xcTaskHis); xcTaskRepository.delete(xcTask); } }
接收完成选课消息
在 com.xuecheng.manage_order.mq包下ChooseCourseTask类中添加receiveChoosecourseTask,接收完成选
课任务消息并进行处理。
/** * 接收选课响应结果 */ @RabbitListener(queues = {RabbitMQConfig.xc_learning_finishaddchoosecourse}) public void receiveFinishChoosecourseTask(XcTask task,Message message, Channel channel) throws IOException { LOGGER.info("receiveChoosecourseTask...{}",task.getId()); //接收到 的消息id String id = task.getId(); //删除任务,添加历史任务 taskService.finishTask(id); }