Java项目经验二:二手车系统

1、项目简介

开发环境:IDEA+ MySQL + JDK1.8 + Git + Maven

使用技术:Spring Cloud + Mybatis Plus + MySQL + RocketMQ + Nginx + Nacos + Redis + MongoDB + ElasticSearch + Shiro

项目描述:

XX二手车的服务贯穿二手车交易各个环节,运用成熟的互联网技术,以海量、真实的二手车信息为基 础,坚持诚信、公正的准则,通过政策解析、价格评估、担保、置换和保险等服务,建立专业、严谨、使用 便捷的交易体系,推动中国二手车行业的良性发展。

项目服务流程:

用户注册登录,卖家会通过客服对车辆进行信息采集和估价在平台展示信息。买家根据 当前城市和对车的需求,在平台中查找自己想要的车辆,找到后进行线下试车,从而完成交易。同时平台还 会定期进行二手车抢购福利。

项目主要模块:

搜索模块,广告模块,抢购模块(Redis),车辆智能推送(Redis),订单库存模块(分布式 事务),评论模块(MongoDB),在线聊天模块(WebSocket),车辆详情页模块(RocketMQ+多线程),车辆 评估模块(多线程),用户模块等主要模块。

二、项目介绍: 

  • 项目介绍
  1. 我参与研发的是一个二手车的项目,项目的初衷就是秉承着买卖双方互惠互利和诚信经营的宗旨来做的,因为考虑到现在生活水平的上升,实现一户一车也是大势所趋,车子的存在其实就是为了便利我们生活的,所以有些家庭其实对车的要求就是无非是出行方便,冬暖夏凉的基本要求,这个时候二手车就成了不错的选择,性价比高,还能满足需求。
  2. 我们项目的主要的业务流程就是在用户第一次进入首页后,会根据热度高,新上架,比较优质的车辆来推荐展示,用户再次进入还会根据用户上次浏览多的车辆进行展示,我们还有订单、抢购、车辆评估等服务。
  3. 因为当时我们组的人比较少,所以我负责的模块相对多一些,有广告投放模块,搜索模块,订单模块,支付模块,抢购模块,车辆评估等都是我做的。
  4. 我们这个项目的主题就是卖家将车辆估价后我们运营人员审核完以后再挂到平台上进行售卖,然后买家进行购买,付款成功后我们抽取一定的手续费用这样的一个流程。
  5. 这个项目当时是和我们老大一起搭建的,所以我也比较了解,要不我就跟您说一下我们项目的架构吧。

三、项目架构

四、架构分析 

1、首先,我们的项目使用的是Spring Cloud微服务架构,之所以选择Spring Cloud,是因为它可以使各个服务之间解耦合,每一个服务都可以独立的开发部署, 只关注一个业务功能,改善故障隔离,即使一个服务宕机也不会影响其他的服务的正常运行,而且开发起来方便快捷。(提供了一站式解决方案)(可能会问springcloud和dubbo的区别)

2、然后,我们选择了nacos作为注册中心,因为它不但可以作为服务的注册中心,还可以作为服务的配置中心,服务的配置中心分为config server和config client,由于nacos本身就是一个配置中心服务,因此我们只需在微服务中搭建config client即可,使用起来比较方便。(可能会问其他注册中心,注意nacos和zookeeper以及eureka的区别)

入口层

首先是入口层。当时考虑到项目上线后,用户量和访问量会越来越大,肯定会出现高并发的情况,所以呢,为了解决高并发,我们使用了nginx作为项目的入口,实现反向代理和负载均衡,还利用了它的限流功能(漏桶算法限流)来限制用户的访问频率。另外为了防止单点故障,我们给nginx做了一主一备。(可能会问为什么nginx的优势或nignx的负载均衡是怎么实现的或者负载均衡策略)

网关层

紧接着是网关层。使用zuul网关是为了更好的高并发限流处理,因为nginx作为项目的统一入口,它的限流是粗粒度的,所以必须通过网关加以弥补(也就是令牌桶算法二次限流)。我们还使用网关对整个微服务集群进行统一的校验和过滤,当nginx将用户请求转发到zuul网关后,由网关统一转发给对应的服务。然后为了保证网关服务的高可用,给Zuul网关也配置了集群。(zuul网关解决的问题,令牌桶和漏桶算法的区别)

服务层

接下来是服务层。当各个服务启动后,会注册到nacos上。然后各个服务间的调用是通过feign接口来实现的。由于feign集成了ribbon和hystrix,所以它具有熔断的能力,可以分摊我们的访问压力。当服务请求失败时还可以快速做出响应,防止出现服务雪崩的现象。并且可以通过zipkin链路追踪定位到出现问题的地方。(feign和ribbon的区别,服务熔断的作用,服务降级)

数据层

再往后就是数据层。我们用的是mysql数据库。为了提高数据的安全性,我们给mysql做了主从同步。当然了,对于一些热数据(查新比较多更新比较少),我们使用的是redis缓存来处理的,因为它不但速度快,而且可以减轻数据库的压力。同时,为了提高redis的可用性,我们给它搭建了集群。(mysql主从同步,读写分离,mycat分库分表)

用户评论

另外,用户评论这一块由于数据量比较大而且结构松散,所以我们使用了mongodb数据库。同样的,为了提高可用性和系统性能,我们搭建了mongodb副本集。(mongodb副本集:主、从、仲裁)

搜索

还有就是在搜索方面我们使用了es索引库,因为es的实时性还是比较高的而且支持分布式,数据库同步这方面使用的是logstash,实现数据源数据的统一。(es近实时,倒排索引,与solr的区别,logstash的同步原理)

中间件

最后在一些需要异步处理的场景中,我们使用的是rocketmq消息中间件。并且我们也是使用rocketmq的事务消息来解决我们项目中的分布式事务,比如订单支付、扣减库存等。(分布式事务解决方案和事务消息流程)

我们这个项目的整体架构大概就是这样,您看还有什么想要了解的?

当然除了架构以外我还参与了抢购、广告、限时特惠、比如:我们的抢购是.......

个人负责模块一:搜索服务

搜索服务一:

搜索是一个并发量相当高的服务,并且一次搜索会关联到许多张表。因此我们如果是走数据库的话我们后台压力可能会非常大,所以这边我采用的是搜索引擎es索引库而不是从数据库直接查询。

搜索服务二:

首先简单说明一下,es是java编写的,它提供了简单易用的RestfulApi,我们可以通过RestfulApi轻松实现搜索功能,不需要面对lucene的复杂性,从而使全文搜索变得简单。其次他还支持横向扩展,支持pb级的结构化或非结构化的海量结构处理;简单的说就是通过增加机器来解决存储容量问题。(这边也可以穿插个为什么不用solr)。至于es和数据库的同步问题,这边是采用的是logstash进行的同步,工作原理里很简单:就是定时执行配置文件中我们定义的sql。这边需要两个插件,一个是读取mysql数据的插件,一个是同步es的插件。

Es相对于mysql有这么几个优势:

1.首先es用的是倒排索引,倒排索引维护的是字典表,也就是key,key是我们搜索的关键词,而value是包含关键词的数据id;我们只需对比字典表就知道哪几条数据有我们查询的关键字,然后拿到这些数据的id,一下找到数据;

2.而mysql用的是正排索引,就是从头到尾把数据查出来,看那些数据包含我们查询的关键字,效率比较低。

3.其次就是mysql索引只存储字段内容,没有分词。而es存储的是分词以后的索引;

4.两者精确匹配没什么区别,但是一旦用到%在词的左边,mysql查询会特别慢;es只需查搜索词包含的文档id。

个人负责模块二:千人千面(猜你喜欢)

1、在做这个功能之前,我们考虑到每个用户喜欢的车辆都不一样,所以给每个用户展示的车辆是不同的,为了给用户良好的体验,我们根据用户浏览车辆的标签来进行定向推送。

2、当用户未登录或者第一次访问的时侯会直接推荐当下最热门、评分最高的车辆信息。

3、用户登录以后会随着不断浏览车辆信息,我们会直接在首页中定向推荐用户最近访问、频繁访问的相关车辆。

4、具体实现:首先平台会为车辆定下许多标签,运营人员在录入车辆时需要为车辆选定三个标签。然后用户在浏览车辆时,我们会将车辆单个标签存放到redis中,使用redis的zset数据类型,因为zset更擅长对权重值的控制。

5、以用户id+浏览量标识(num)为key,以标签名为value,以1为分数,进行存放;存放之前首先判断缓存中是否有该标签,如果有就将分数加1,如果没有则将分数设为1,(该缓存负责统计各个标签浏览量),然后再以用户id+时间标识(time)为key,以标签名为value,将当前时间毫秒值为分数放入redis(该缓存负责统计各个标签访问的时间)。

6、此时我们设置定时器每半个小时查询这两个zset ,然后获取浏览量排名前三的标签。然后将这三个标签以zset类型放入redis缓存,分数分别为3,2,1,然后获取用户最近访问的三个标签也放入这个redis缓存,分数统一设定为3,如果该标签已存在则将原有的分数加3(这个缓存是用来定向推荐的,每半小时更新一次)。当用户再次访问首页时获取用来定向推荐的缓存中的标签,然后进行多字段查询es,在首页中进行展示。

 个人负责模块三:车辆回收

我们为卖家提供了一个二手车估价的功能,用户卖车的时候可以通过我们的车辆评估功能来对自己需要转手的车辆进行估价,用户点击首页的车辆评估按钮,会弹出填写车辆信息的弹框(包括所在城市、车辆品牌,车系、车款、车龄、行驶里程六个维度),用户填写完车辆评估信息之后,将评估信息提交到后台业务逻辑方法,后台会先通过车辆品牌,车系、车款这三个维度去调用京东智联云的一个评估的api接口,返回车辆的原始价格,将其存放到redis中,方便我们的取用,然后根据原始价格通过车龄、行驶里程、磨损程度进行一个折损计算,计算方法如下有

三个模块:

  1. 第一个就是检测报告:

重大问题检测、车辆外观检测、内饰功能检测、底盘悬架项检测、发动机舱检测、动态路试检测。0表示无损伤、1代表轻微损伤、2代表中度损伤、3代表重度损伤。

轻微损伤折旧5%、中度损伤折旧15%、重度损伤折旧30%。

  1. 然后是公里数:

目前国内里程评估法就一个,54321法: 5+4+3+2+1=15,即汽车开6万公里时,折旧5/15;

当开到第二个6万公里时,也就是开了12万公里,再折旧4/15,此时二手车价格为新车裸车价的1-5/15-4/15=6/15。

  1. 还有一个就是年份:

运用重置成本法将汽车寿命规定为15年,为了估值精确,将其处成180个月,使用了多少个月就把使用月份减掉,然后把剩余月份的残值计算出来。

二手车价格=新车价格X(180-已使用月份)÷180

------------------------------------

这时呢,我们考虑到运算速度和用户体验度方面,我们决定利用多线程的CallableCountDownLatch分别去并发执行运算任务,对这几个运算任务进行提速,根据每个维度计算出的对应的折损价格返回,当计算完成之后,分别调用CountDownLatch的countDown方法将计数器减1,当所有的线程都已执行完之后,这时候计数器的值为零,表示我们的三个线程都已执行完毕,再恢复计算总价格的线程任务,并将最后的估价结果返回给前台,这样可以提升查询效率,将结果快速地显示给用户,提高用户体验度。

------------------------------------

(Callable、ConuntDownLath

Callable:实现了callable接口的线程可以将执行结果返回,并且它的call方法可以允许异常的抛出。

CountDownLatch工作原理:

CountDownLatch是在java1.5被引入的,存在于java.util.concurrent包下。 CountDownLatch作用:能够使一个线程等待其他线程完成各自的工作后再执行。CountDownLatch 底层是通过一个计数器来实现的, 计数器的初始值为线程的数量。每当一个线程完成了自己的任务后,计数器的值就会减1。

当计数器值到达0时,它表示所有的线程已经完成了任务,然后在闭锁上等待的线程就可以恢复执行任务。)

------------------------------------

然后我们每一辆车都有它对应的保值率,如果计算得出的价格低于它对应的保值率,我们就会把保值率的价格提供给用户,如果不低于对应的保值率就将最后的计算得出的价格提供给用户进行参考。

个人负责模块四:新人特惠

1、用户在注册完成之后,我们会默认发放一张2000元的优惠券,然后会给优惠券一个30天的过期时间,在我们实际的项目中,当有大量用户的未使用优惠券要过期时,肯定不能人工去操作,这时候就需要一个任务调度框架,帮我们自动去执行这些程序。我们使用的就是Quartz框架。

2、Quartz是OpenSymphony开源组织在Job scheduling领域一个开源项目, 是完全由java代码开发的一个开源的任务日程管理系统,“任务进度管理器”就是一个在预先确定(被纳入日程)的时间到达时,负责执行(或者通知)其他软件组件的系统。Quartzh提供了极为广泛的特性如持久化任务,集群和分布式任务等。

3、当用户注册完成之后,后台就会向数据库插入一条注册时间数据,通过每天查询25天之前注册的用户,去判断用户的优惠券是否快要过期,一旦优惠券过期,我们就通过Quartz中的Trigger(触发器)去通知Scheduler(调度程序)调用JavaMail中的方法给用户邮箱发送一个过期提醒。

个人负责模块五:限时特惠

1、我们这个限时特惠就是让用户在规定的时间去抢购他要买的车辆,首先我们后台人员将需要抢购的一些车辆添加到限时特惠车辆表,然后在前台的限时特惠区进行展示,这里我们通过定时任务将抢购车辆放入到redis缓存中,每周一早上10点给我们的抢购车辆进行更新,不管有没有被抢购完,都会清除redis中的相关数据。

2、其实对于抢购来说,最重要的也就是抗并发和防止超卖的情况,为了防止这两种情况的发生,我是借助redis的decr来做的,首先它是可以保证原子性的, 我们提前将需要抢购的车辆库存数量放到redis中,通过车辆的id作为key, 当用户点击抢购的时候发送请求到服务端, 根据传递过来的车辆id去redis中查询库存和抢购的开始时间,如果当前时间小于抢购开始时间,直接返回抢购未开始提示,这样可以防止用户拿到接口直接调用的问题。

3、然后判断库存,如果库存小于等于0直接给用户返回抢购失败,这样的话后面的大量请求不会给系统带来压力。

4、然后如果这些条件都满足的话,执行decr操作,decr操作的话会将库存减一并且返回当前的值,然后再次判断当前值是否小于0,如果小于0,则返回抢购失败。否则mq异步的方式生成订单并且返回给前端一个成功标记。

5、为什么用mq? )

这里用到了mq的异步处理。说到rocketmq那么他在这里的作用就是给用户带来更好的体验度,因为后台的代码逻辑是非常复杂的,如果要执行的话需要很长时间,前台如果不做处理的话,那么用户将会等待很长的时间,这对于限时特惠这样的业务是不符合要求的,所以我们会使用rokcetmq直接给用户返回一个成功的消息,如果发送不成功,那么就返回失败,这里如果mq发送失败的也会加入死信队列来处理,这样也利用的mq的事务消息的原子性,保证了事务执行的成功。

6、还有一个问题就是如果有大批用户同时点击抢购,可能就会产生高并发。因为分布式环境下不同的线程需要对共享资源进行同步,那么使用Java中的锁机制就无法实现了,所以要借助分布式锁来解决共享资源同步的问题,我们使用的是Redis这种解决方案。

7、使用redis实现分布式锁)

1.1、在使用redis时, 需要用到几个核心的方法,:

setIfAbsent(key,value): 该方法会判断缓存中是否存在该key,如果有返回false, 如果没有,设置value值,并返回true。

getAndSet(key,value): 该方法每次只允许一个线程操作,它会获取key的上一个value值,并将当前value值放入缓存。

1.2、在具体的实现中, 首先需要设置一个唯一的标识作为key,还需要将当前时间和有效时间相加得出锁的过期时间作为value,当多个线程调用该方法时,会先通过setIfAbsent(key,value)方法判断缓存中是否存在该key, 如果没有, 说明目前还没有线程拿到锁, 此时, 可以将key和value存入缓存, 返回一个true, 表示该线程拿到锁,当线程执行完相关逻辑之后, 调用解锁方法, 将该线程的缓存删除。

1.3、那么, 这个时候, 有可能出于某种原因, 会使线程长时间还没有解锁, 那为了防止锁超时, 也就是防止死锁的产生,新进的线程第一次没有拿到锁,但可以获取上一个锁的过期时间也就是value和当前系统时间比较, 如果当前时间大于上一个锁的过期时间, 那么就说明上一个锁运行超时了,此时, 可以通过getAndSet(key,value)方法获取上一个锁的时间,并且将当前时间和过期时间相加得出过期时间作为value放入缓存。

1.4、为了防止多个线程同时获取到锁,就对比当前线程获取的时间和上一次锁的到期时间是否相同, 如果相同说明该线程是最先执行的, 那只有该线程拿到锁, 返回一个true, 其他线程返回false。

1.5、那么, 通过这样一个设计就基本上可以解决分布式锁的问题,在实际项目开发当中,就比如在抢购、广告投放等业务中就可以利用这种锁的应用,这一个设计其实也是一个非公平锁。

 个人负责模块六:订单服务

车辆达到用户的预期,进行平台的支付定金,为了减库存和订单生成的最终一致性,也就是分布式事务的最终一致性,我采用的是rocketmq事务消息;因为rocketmq中的broker和producer有双信通信能力,使得我们的broker天生可以作为一个协调者的存在,从而确保了本地事务执行与消息发送的原子性问题,并且rocketmq本身提供存储机制,使得我们的事务消息有一个是持久化的能力;rocketmq的这种高可用机制以及可靠消息设计,则为我们的事务消息发生异常时,依然能保证事务最终一致性的达成。

事务消息的流程:

   1、我们的事务发送方首先发送一个半主题给我们的mq(消费者不可见),发送方发送消息成功后,然后执行本地事务;根据本地事务执行的结果返回conmmit或者rollback。

    2、如果是commit消息,mq将事务消息从半主题中提出并生成索引存入业务topic(对消费者可见),如果是rockback,不生成索引(对消费者不可见);

如果执行本地事务中发生异常(超时或挂掉),mq会不停的回查;收到回查消息后,根据本地事务的状态,重新返回commit或rollback。

3、这个流程保证了发送方与本地事务消息的原子性,而我们的消费者消费确是利用我们的rocketmq本身自带的ack消息重试机制来确保消息消费成功,只有消费方明确返回消费成功,rocket明确才认为消息消费成功,否则就会发起重试.重试最多次数是16次。我们通常设置为三次,就手动返回一个success 然后存到我们的死信队列表里,后续让我们的运维进行人为修改.这边消息重试也会遇到一个小问题就是消息消费成功,这边又发送了重试,我这边用的是redis 日志记录来解决的,记录已经消费成功的messageid,如果传来的messageid已经在我们的日志表中,那我们就放过不处理,以上就是车辆减库存和订单生成最终一致性的解决方案。

猜你喜欢

转载自blog.csdn.net/weixin_45934981/article/details/130627661
今日推荐