【Java开发岗:项目篇】

本文详细梳理了Java开发岗位面试中的核心知识点,涵盖项目经验的撰写、秒杀系统设计、权限控制、数据库优化、消息中间件使用、Spring全家桶以及微服务架构等多个方面。重点讨论了如何解决高并发、海量数据和分布式事务的问题,如使用Redis缓存、MySQL调优、RocketMQ与Kafka的消息处理,以及SpringBoot、SpringCloud的应用。此外,还探讨了系统权限设计,包括接口防止篡改、用户权限设计等,为Java开发者提供面试准备的全面指导。

点击:【第一章:Java开发岗:基础篇

计算机基础问题、HashMap、Fail-safe机制/Fail-fast机制、Synchronized、ThreadLocal、AQS、线程池、JVM内存模型、内存屏障、class文件结构、类加载 机制、双亲委派、垃圾回收算法、垃圾回收器、空间分配担保策略、安全点、JIT技术、可达性分析、强软弱虚引用、gc的过程、三色标记、跨代引用、 逃逸分析、 内存泄漏与溢出、JVM线上调优、CPU飙高系统反应慢怎么排查。

点击:【第二章:Java开发岗:MySQL篇

隔离级别、ACID底层实现原理、 一致性非锁定读(MVCC的原理)、BufferPool缓存机制、filesort过程、 离散读、ICP优化、全文检索、 行锁、表锁、间隙锁、死锁、主键自增长实现原理、索引数据结构、SQL优化、索引失效的几种情况、聚集索引、辅助索引、覆盖索引、联合索引、redo log、bin log、undolog、分布式事务、SQL的执行流程、重做日志刷盘策略、有MySQL调优、分库分表、主从复制、读写分离、高可用。

点击:【第三章:Java开发岗:Redis篇

多路复用模式、单线程模型、简单字符串、链表、字典、跳跃表、压缩列表、encoding属性编码、持久化、布隆过滤器、分布式寻址算法、过期策略、内存淘汰策略 、Redis与数据库的数据一致性、Redis分布式锁、热点数据缓存、哨兵模式、集群模式、多级缓存架构、并发竞争、主从架构、集群架构及高可用、缓存雪崩、 缓存穿透、缓存失效。

点击:【第四章:Java开发岗:MQ篇

RabbitMQ、RockerMQ、Kafka 三种消息中间件出现的消息可靠投递、消息丢失、消息顺序性、消息延迟、过期失效、消息队列满了、消息高可用等问题的解决方案。RabbitMQ的工作模式,RocketMQ的消息类型、消息存储机制,Kafka消费模式、主题/分区/日志、核心总控制器以及它的选举机制、Partition副本选举Leader机制、消费者消费消息的offset记录机制、消费者Rebalance机制、Rebalance分区分配策略、Rebalance过程、 producer发布消息机制、HW与LEO、日志分段存储、十亿消息数据线上环境规划、JVM参数设置。

点击:【第五章:Java开发岗:Spring篇

SpringBean生命周期、Spring循环依赖、Spring容器启动执行流程、Spring事务底层实现原理、Spring IOC容器加载过程、Spring AOP底层实现原理、Spring的自动装配、Spring Boot自动装配、Spring Boot启动过程、SpringMVC执行流程、Mybatis的缓存机制。

点击:【第六章:Java开发岗:SpringCould篇

微服务构建、客户端负载均衡、服务治理、服务容错保护、声明式服务调用、API网关服务、分布式配置中心、消息总线、消息驱动、分布式服务追踪、分布式事务、流量控制。

点击:【第七章:Java开发岗:项目篇

简历上面的项目经历怎么写(项目介绍、负责模块、使用技术),面试项目实战(秒杀下单设计、权限设计、红包雨设计)


系列文章:每篇文章字数都是大几万,保证质量,文章以备战面试为背景,薪资参考坐标:上海;每个地方,每个时间段薪资待遇都不一样,文章仅做面试参考,具体能否谈到理想的薪资取决于面试表现、平时的积累、市场行情、机遇。
提示:系列文章还未全部完成,后续的文章,会慢慢补充进去的。

文章目录

这里总结一下35k的Java开发岗需要掌握的面试题,本文主讲项目亮点,简历上面的项目经历怎么写(项目介绍、负责模块、使用技术),面试项目实战(秒杀下单设计、权限设计、红包雨设计),帮助大家快速复习,突破面试瓶颈。本章主讲项目亮点、难点相关知识点,薪资参考的坐标:上海,参考时间:2022年8月。

简历上面的项目经历怎么写

一般而言,面试官拿到你的简历通过你的项目经历,了解你的项目经验,进一步根据项目中的场景对你进行提问,所以项目经历怎么写才能引导面试官,按照你的思路来询问,这就显的比较重要了。

项目经历一般而言可以分为项目名称、项目时间、项目介绍、负责模块、使用技术、项目访问地址(如果可以公网访问的,提供网站地址或者APP下载途径,增加真实性;如果是内网或者内部项目就可以不提供)。

项目介绍

尽量一句话概括,简短的把主流程的业务描述即可。

负责模块

如果没有亮点描述简要些,如果有亮点或者有难点,口述的时候可以把遇到问题的场景描述一下,然后是怎么解决的,有哪些技术手段,最后基于什么考虑选择了什么技术,解决了什么问题,满足了业务的需求。

简历上写负责模块的话,把有技术难点的提前,没有技术亮点和技术难点的往后放,并且淡化处理。

比如性能调优,调优前后性能提升了多少,有数据支持的话更好,更具备说服力。

  • JVM调优:通过visual vm可视化性能监控工具监控应用程序的cpu、堆、永久区、线程的总体情况,发现新生代有毛刺现象,通过jvm调优之后,JVM堆大小变得较为平滑,FULL次数减少,大接口的响应时间缩短了1500ms,性能得到提升。

  • MySQL调优:通过explain执行计划和show processlist查看,发现有很多session在处理sort 操作,发现项目中有部分 sql没有办法走索引排序Index,而是走的文件排序filesort,通过调整max_length_for_sort_data参数修改为8096,影响MySQL选择的算法为单路排序,sort_buffer_size参数修改为2M,最大程度保证使用mmap进行内存分配,减少在排序过 程中对要排序的数据进行分段,尽量保证不使用临时表来进行交换排序,调优之后性能提升了不少,不再是ALL级别了。

也可以直接在负责模块中写解决过哪些技术难点,比如基于什么业务,使用了mq,在使用mq的过程中,为了避免什么问题,通过什么手段解决了什么问题。

  • 使用RocketMq消息队列作为Kpi信号异步推送业务数据,利用事务消息机制保证消息零丢失,消费端使用MessageId加上 业务的唯一标识来作为判断幂等的依据,消费端注入的MessageListenerOrderly对象保证RocketMQ内部就会通过锁队列 的方式保证消息的顺序性。

使用技术

不要进行简单的罗列,最好有一定引导性。

  • 比如使用SpringCould+SpringBoot+Mysql+Nginx实现架构落地,太过笼统,可以详细些。
  • 改造后采用Spring Cloud架构,利用Spring Boot构建应用,利用Spring Cloud Alibaba Nacos实现动态服务发现、服务配置管理,利用Open feign实现与其他系统进行交互,利用Hystrix实现熔断和错误处理,利用Ribbon实现客户端负载均衡,利用Nginx实现服务端负载均衡,利用Gateway管理外部系统访问。

面试项目实战

下单/秒杀设计(瞬时高并发、海量数据、分布式事务)

一般而言,促销类的活动或者秒杀系统都会有高并发,海量数据的处理以及分布式事务的问题,这里着重讲讲。
在这里插入图片描述

如何解决瞬时高并发?

瞬时高并发的场景,从以下几个方面入手:

页面静态化

活动页面是用户流量的第一入口,所以是并发量最大的地方。如果这些流量都能直接访问服务端,恐怕服务端会因为承受不住这么大的压力,而直接挂掉。活动页面绝大多数内容是固定的,比如:商品名称、商品描述、图片等。为了减少不必要的服务端请求,通常情况下,会对活动页面做静态化处理。用户浏览商品等常规操作,并不会请求到服务端。只有到了秒杀时间点,并且用户主动点了秒杀按钮才允许访问服务端。这样能过滤大部分无效请求。

CDN加速

因为用户分布在全国各地,有些人在北京,有些人在成都,有些人在深圳,地域相差很远,网速各不相同。如何才能让用户最快访问到活动页面呢?这就需要使用CDN,它的全称是Content Delivery Network,即内容分发网络。使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。

秒杀按钮

大部分用户怕错过秒杀时间点,一般会提前进入活动页面。此时看到的秒杀按钮是置灰,不可点击的。只有到了秒杀时间点那一时刻,秒杀按钮才会自动点亮,变成可点击的。但此时很多用户已经迫不及待了,通过不停刷新页面,争取在第一时间看到秒杀按钮的点亮。

在静态页面中如何控制秒杀按钮,只在秒杀时间点时才点亮呢?

使用js文件控制。为了性能考虑,一般会将css、js和图片等静态资源文件提前缓存到CDN上,让用户能够就近访问秒杀页面。CDN上的js文件是如何更新的?秒杀开始之前,js标志为false,还有另外一个随机参数。当秒杀开始的时候系统会生成一个新的js文件,此时标志为true,并且随机参数生成一个新值,然后同步给CDN。由于有了这个随机参数,CDN不会缓存数据,每次都能从CDN中获取最新的js代码。此外,前端还可以加一个定时器,控制比如:10秒之内,只允许发起一次请求。如果用户点击了一次秒杀按钮,则在10秒之内置灰,不允许再次点击,等到过了时间限制,又允许重新点击该按钮。

缓存(读多写少场景)

在秒杀的过程中,系统一般会先查一下库存是否足够,如果足够才允许下单,写数据库。如果不够,则直接返回该商品已经抢完。由于大量用户抢少量商品,只有极少部分用户能够抢成功,所以绝大部分用户在秒杀时,库存其实是不足的,系统会直接返回该商品已经抢完。这是非常典型的:读多写少的场景。

如果有数十万的请求过来,同时通过数据库查缓存是否足够,此时数据库可能会挂掉。因为数据库的连接资源非常有限,比如:mysql,无法同时支持这么多的连接。所以应该改用缓存,比如:redis。即便用了redis,也需要部署多个节点。

通常情况下,我们需要在redis中保存商品信息,里面包含:商品id、商品名称、规格属性、库存等信息,同时数据库中也要有相关信息,毕竟缓存并不完全可靠。用户在点击秒杀按钮,请求秒杀接口的过程中,需要传入的商品id参数,然后服务端需要校验该商品是否合法。根据商品id,先从缓存中查询商品,如果商品存在,则参与秒杀。如果不存在,则需要从数据库中查询商品,如果存在,则将商品信息放入缓存,然后参与秒杀。如果商品不存在,则直接提示失败。除此之外还需要预防缓存击穿,比如商品A第一次秒杀时,缓存中是没有数据的,但数据库中有。虽说上面有如果从数据库中查到数据,则放入缓存的逻辑。然而,在高并发下,同一时刻会有大量的请求,都在秒杀同一件商品,这些请求同时去查缓存中没有数据,然后又同时访问数据库。结果悲剧了,数据库可能扛不住压力,直接挂掉。

如何解决这个问题呢?这就需要加锁,最好使用分布式锁。最好在项目启动之前,先把缓存进行预热。即事先把所有的商品,同步到缓存中,这样商品基本都能直接从缓存中获取到,就不会出现缓存击穿的问题了。是不是上面加锁这一步可以不需要了?表面上看起来,确实可以不需要。但如果缓存中设置的过期时间不对,缓存提前过期了,或者缓存被不小心删除了,如果不加速同样可能出现缓存击穿。其实这里加锁,相当于买了一份保险。

另外还需要预防缓存穿透,如果有大量的请求传入的商品id,在缓存中和数据库中都不存在,这些请求不就每次都会穿透过缓存,而直接访问数据库了。由于前面已经加了锁,所以即使这里的并发量很大,也不会导致数据库直接挂掉。但很显然这些请求的处理性能并不好,有没有更好的解决方案?这时可以想到布隆过滤器。系统根据商品id,先从布隆过滤器中查询该id是否存在,如果存在则允许从缓存中查询数据,如果不存在,则直接返回失败。虽说该方案可以解决缓存穿透问题,但是又会引出另外一个问题:布隆过滤器中的数据如何更缓存中的数据保持一致?这就要求,如果缓存中数据有更新,则要及时同步到布隆过滤器中。如果数据同步失败了,还需要增加重试机制,而且跨数据源,能保证数据的实时一致性吗?显然是不行的。所以布隆过滤器绝大部分使用在缓存数据更新很少的场景中。如果缓存数据更新非常频繁,又该如何处理呢?这时,就需要把不存在的商品id也缓存起来。下次,再有该商品id的请求过来,则也能从缓存中查到数据,只不过该数据比较特殊,表示商品不存在。需要特别注意的是,这种特殊缓存设置的超时时间应该尽量短一点。

mq异步处理

我们都知道在真实的秒杀场景中,有三个核心流程:秒杀-》下单-》支付。而这三个核心流程中,真正并发量大的是秒杀功能,下单和支付功能实际并发量很小。所以,我们在设计秒杀系统时,有必要把下单和支付功能从秒杀的主流程中拆分出来,特别是下单功能要做成mq异步处理的。而支付功能,比如支付宝支付,是业务场景本身保证的异步。于是,秒杀后下单的流程变成如下:秒杀-》发送mq消息-》mq服务端-》消费mq消息-》下单。

秒杀成功了,往mq发送下单消息的时候,有可能会失败。原因有很多,比如:网络问题、broker挂了、mq服务端磁盘问题等。这些情况,都可能会造成消息丢失。如何防止消息丢失呢?答:加一张消息发送表。在生产者发送mq消息之前,先把该条消息写入消息发送表,初始状态是待处理,然后再发送mq消息。消费者消费消息时,处理完业务逻辑之后,再回调生产者的一个接口,修改消息状态为已处理。如果生产者把消息写入消息发送表之后,再发送mq消息到mq服务端的过程中失败了,造成了消息丢失。

这时候,要如何处理呢?答:使用job,增加重试机制。用job每隔一段时间去查询消息发送表中状态为待处理的数据,然后重新发送mq消息。本来消费者消费消息时,在ack应答的时候,如果网络超时,本身就可能会消费重复的消息。但由于消息发送者增加了重试机制,会导致消费者重复消息的概率增大。那么,如何解决重复消息问题呢?答:加一张消息处理表。消费者读到消息之后,先判断一下消息处理表,是否存在该消息,如果存在,表示是重复消费,则直接返回。如果不存在,则进行下单操作,接着将该消息写入消息处理表中,再返回。有个比较关键的点是:下单和写消息处理表,要放在同一个事务中,保证原子操作。

这套方案表面上看起来没有问题,但如果出现了消息消费失败的情况。比如:由于某些原因,消息消费者下单一直失败,一直不能回调状态变更接口,这样job会不停的重试发消息。最后,会产生大量的垃圾消息。那么,如何解决这个问题呢?每次在job重试时,需要先判断一下消息发送表中该消息的发送次数是否达到最大限制,如果达到了,则直接返回。如果没有达到,则将次数加1,然后发送消息。这样如果出现异常,只会产生少量的垃圾消息,不会影响到正常的业务。

通常情况下,如果用户秒杀成功了,下单之后,在15分钟之内还未完成支付的话,该订单会被自动取消,回退库存。那么,在15分钟内未完成支付,订单被自动取消的功能,要如何实现呢?我们首先想到的可能是job,因为它比较简单。但job有个问题,需要每隔一段时间处理一次,实时性不太好。还有更好的方案?答:使用延迟队列。我们都知道rocketmq,自带了延迟队列的功能。下单时消息生产者会先生成订单,此时状态为待支付,然后会向延迟队列中发一条消息。达到了延迟时间,消息消费者读取消息之后,会查询该订单的状态是否为待支付。如果是待支付状态,则会更新订单状态为取消状态。如果不是待支付状态,说明该订单已经支付过了,则直接返回。还有个关键点,用户完成支付之后,会修改订单状态为已支付。

限流

通过秒杀活动,如果我们运气爆棚,可能会用非常低的价格买到不错的商品(这种概率堪比买福利彩票中大奖)。但有些高手,并不会像我们一样老老实实,通过秒杀页面点击秒杀按钮,抢购商品。他们可能在自己的服务器上,模拟正常用户登录系统,跳过秒杀页面,直接调用秒杀接口。如果是我们手动操作,一般情况下,一秒钟只能点击一次秒杀按钮。但是如果是服务器,一秒钟可以请求成上千接口。这种差距实在太明显了,如果不做任何限制,绝大部分商品可能是被机器抢到,而非正常的用户,有点不太公平。所以,我们有必要识别这些非法请求,做一些限制。

那么,我们该如何现在这些非法请求呢?

对同一用户限流:为了防止某个用户,请求接口次数过于频繁,可以只针对该用户做限制。

限制同一个用户id,比如每分钟只能请求5次接口。对同一ip限流:有时候只对某个用户限流是不够的,有些高手可以模拟多个用户请求,这种nginx就没法识别了。这时需要加同一ip限流功能。

限制同一个ip,比如每分钟只能请求5次接口。但这种限流方式可能会有误杀的情况,比如同一个公司或网吧的出口ip是相同的,如果里面有多个正常用户同时发起请求,有些用户可能会被限制住。

对接口限流:别以为限制了用户和ip就万事大吉,有些高手甚至可以使用代理,每次都请求都换一个ip。这时可以限制请求的接口总次数。在高并发场景下,这种限制对于系统的稳定性是非常有必要的。但可能由于有些非法请求次数太多,达到了该接口的请求上限,而影响其他的正常用户访问该接口。看起来有点得不偿失。

加验证码:相对于上面三种方式,加验证码的方式可能更精准一些,同样能限制用户的访问频次,但好处是不会存在误杀的情况。通常情况下,用户在请求之前,需要先输入验证码。用户发起请求之后,服务端会去校验该验证码是否正确。只有正确才允许进行下一步操作,否则直接返回,并且提示验证码错误。此外,验证码一般是一次性的,同一个验证码只允许使用一次,不允许重复使用。普通验证码,由于生成的数字或者图案比较简单,可能会被破解。优点是生成速度比较快,缺点是有安全隐患。还有一个验证码叫做:移动滑块,它生成速度比较慢,但比较安全,是目前各大互联网公司的首选。

提高业务门槛:有时候达到某个目的,不一定非要通过技术手段,通过业务手段也一样。12306刚开始的时候,全国人民都在同一时刻抢火车票,由于并发量太大,系统经常挂。后来,重构优化之后,将购买周期放长了,可以提前20天购买火车票,并且可以在9点、10、11点、12点等整点购买火车票。调整业务之后(当然技术也有很多调整),将之前集中的请求,分散开了,一下子降低了用户并发量。回到这里,我们通过提高业务门槛,比如只有会员才能参与秒杀活动,普通注册用户没有权限。或者,只有等级到达3级以上的普通用户,才有资格参加该活动。这样简单的提高一点门槛,即使是黄牛党也束手无策,他们总不可能为了参加一次秒杀活动,还另外花钱充值会员吧?

分布式锁

上面说了为了预防缓存击穿使用分布式锁,这里可以简单说一下redis和zookeeper二种分布式锁的实现。

先说redis分布式锁:redis使用setnx作为分布式锁,多个线程setnx调用时,有且仅有一个线程会拿到这把锁,所以拿到锁的执行业务代码,最后释放掉锁。加大了调用次数,执行业务代码需要一点时间,这段时间拒绝了很多等待获取锁的请求。假如redis服务挂掉了,抛出异常了,这时锁不会被释放掉,出现死锁问题,可以添加try finally处理,Redis服务挂掉导致死锁的问题解决了,但是,如果服务器果宕机了,又会导致锁不能被释放的现象,所以可以设置超时时间为10s。如果有一个线程执行需要15s,当执行到10s时第二个线程进来拿到这把锁,会出现多个线程拿到同一把锁执行,在第一个线程执行完时会释放掉第二个线程的锁,以此类推…就会导致锁的永久失效。所以,只能自己释放自己的锁,可以给当前线程取一个名字,永久失效的问题解决了,但是,如果第一个线程执行15s,还是会存在多个线程拥有同一把锁的现象。所以,需要续期超时时间,当一个线程执行5s后对超时时间进行续期都10s,就可以解决了,续期设置可以借助redission工具,加锁成功,后台新开一个线程,每隔10秒检查是否还持有锁,如果持有则延长锁的时间,如果加锁失败一直循环(自旋)加锁。

这就是redis分布式锁的实现,接着在说说zookeeper分布式锁的实现。

客户端A要获取分布式锁的时候首先到locker下创建一个临时顺序节点(node_n),然后立即获取locker下的所有(一级)子节点。此时因为会有多个客户端同一时间争取锁,因此locker下的子节点数量就会大于1。对于顺序节点,特点是节点名称后面自动有一个数字编号,先创建的节点数字编号小于后创建的,因此可以将子节点按照节点名称后缀的数字顺序从小到大排序,这样排在第一位的就是最先创建的顺序节点,此时它就代表了最先争取到锁的客户端!此时判断最小的这个节点是否为客户端A之前创建出来的node_n,如果是则表示客户端A获取到了锁,如果不是则表示锁已经被其它客户端获取,因此客户端A要等待它释放锁,也就是等待获取到锁的那个客户端B把自己创建的那个节点删除。此时就通过监听比node_n次小的那个顺序节点的删除事件来知道客户端B是否已经释放了锁,如果是,此时客户端A再次获取locker下的所有子节点,再次与自己创建的node_n节点对比,直到自己创建的node_n是locker的所有子节点中顺序号最小的,此时表示客户端A获取到了锁!

然后再说说二者的区别:redis是基于redisson实现,zk是基于curatorFramework实现;

  • 实现的数据结构不同:redis的分布式锁是利用redis的hash数据结构,大key存储的锁名称,小key存储uuid-线程id,value存储重入次数;通过客户端往redis服务器发送lua脚本,实现redis上hash结构锁信息的更新;另外会定时复位超时时间,默认超时时间设置为30s,每10s后,若发现锁依旧被原线程持有,则复位超时时间;zk的分布式锁是利用存储在zk上的临时有序节点,每个线程会依次在zk上创建一个临时有序节点,每个节点监听其上一个节点,没有获得锁的线程会利用synchronized的wait方法实现等待,当锁被释放时,会删除临时顺序节点,只会触发后一顺序节点去获取锁,理论上不存在竞争,只排队,非抢占,公平锁,先到先得;
  • 没有获得锁的线程阻塞的方式不同:redis上是通过semaphore信号量,使没有获得锁的线程阻塞,state初始化为0,意味着所有线程都无法获得信号量,都将阻塞;zk是通过synchronized,使没有获得锁的线程全部进入等待状态;
  • 阻塞的线程被唤醒的方式不同:redis基于自带的发布订阅模式,当锁被释放时,会发布主题,从而订阅该主题的线程会唤醒semphore上阻塞的自己,继续循环获取锁;zk是基于synchronized的wait和notify机制,以及监听watch机制,以及最小序号的节点获得锁规则;被唤醒后,继续循环直到拿到锁;
  • 锁的高可用:zk具有强一致性,虽然牺牲了一定的性能,但能保证高可用,所以对追求可靠性较高的场景,可使用zk实现分布式锁;redis具有最终一致性,特别是在redis主从架构时,只要master上锁的元数据更新了,就立即返回给客户端ok,后边会慢慢同步给slave,牺牲了可靠,对于追求性能的场景,可使用redis实现分布式锁;
库存问题

真正的秒杀商品的场景,不是说扣完库存,就完事了,如果用户在一段时间内,还没完成支付,扣减的库存是要加回去的。所以,在这里引出了一个预扣库存的概念
在这里插入图片描述

扣减库存中除了上面说到的预扣库存和回退库存之外,还需要特别注意的是库存不足和库存超卖问题。扣减库存一般会选择使用lua脚本减扣库存,因为使用数据库扣减库存,需要频繁访问数据库,我们都知道数据库连接是非常昂贵的资源。在高并发的场景下,可能会造成系统雪崩。而且,容易出现多个请求,同时竞争行锁的情况,造成相互等待,从而出现死锁的问题。

而使用redis扣减库存,incr方法是原子性的,可以用该方法扣减库存,在高并发下,有多个请求同时查询库存,当时都大于0。由于查询库存和更新库存非原则操作,则会出现库存为负数的情况,即库存超卖。另外有多个请求同时扣减库存,大多数请求的incr操作之后,结果都会小于0。虽说,库存出现负数,不会出现超卖的问题。但由于这里是预减库存,如果负数值负的太多的话,后面万一要回退库存时,就会导致库存不准。

lua脚本扣减库存能够完美解决上面的问题。
lua脚本有段非常经典的代码:

  StringBuilder lua = new StringBuilder();
  lua.append("if (redis.call('exists', KEYS[1]) == 1) then");
  lua.append("    local stock = tonumber(redis.call('get', KEYS[1]));");
  lua.append("    if (stock == -1) then");
  lua.append("        return 1;");
  lua.append("    end;");
  lua.append("    if (stock > 0) then");
  lua.append("        redis.call('incrby', KEYS[1], -1);");
  lua.append("        return stock;");
  lua.append("    end;");
  lua.append("    return 0;");
  lua.append("end;");
  lua.append("return -1;");

该代码的主要流程如下:

  • 先判断商品id是否存在,如果不存在则直接返回。
  • 获取该商品id的库存,判断库存如果是-1,则直接返回,表示不限制库存。
  • 如果库存大于0,则扣减库存。
  • 如果库存等于0,是直接返回,表示库存不足。
如何解决海量数据?

所谓海量数据处理,无非就是基于海量数据上的存储、处理、操作。何谓海量,就 是数据量太大,所以导致要么是无法在较短时间内迅速解决,要么是数据太大,导致无法一次性装入内存。

处理和操作

采用巧妙的算法搭配合适的数据结构,大而化小,分而治之。

算法/数据结构
  • Bloom Filter:利用位数组(是一种空间效率很高的随机数据结构)判断一个元素是否存在集合里,比其他常见的算法(如hash,折半查找)极大节省了空间,适用范围: 可以用来实现数据字典,进行数据的判重,或者集合求交集。
  • Hash:把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是 散列值。这种转换是一种压缩映射,就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
  • Bit-map:节约空间,用一个bit位来标记某个元素对应的值。假设某个电商平台有过亿的会员数量,需要保存会员当天的打卡记录。假设会员号用一个四字节整形存储,是否打卡用一个字节存储。那最大需要19G的存储空间。如果用BITMAP实现最大只需要128M就可以。
  • 外排序:适合大数据的排序,去重。首先按内存大小,将外存上含n个记录的文件分成若干长度L的子文件或段。依 次读入内存并利用有效的内部排序对他们进行排序,并将排序后得到的有序字文件 重新写入外存,通常称这些子文件为归并段。然后对这些归并段进行逐趟归并,使归并段逐渐由小到大,直至得到整个有序文件为止。
实战场景
1、海量日志数据,提取出某日访问百度次数最多的那个IP。

算法思想: 分而治之+Hash
①.IP地址最多有2^32=4G种取值情况,所以不能完全加载到内存中处理;
②.可以考虑采用“分而治之”的思想,按照IP地址的Hash(IP)%1024值,把海量IP日 志分别存储到1024个小文件中。这样,每个小文件最多包含4MB个IP地址;
③.对于每一个小文件,可以构建一个IP为key,出现次数为value的Hash map,同时 记录当前出现次数最多的那个IP地址;
④.可以得到1024个小文件中的出现次数最多的IP,再依据常规的排序算法得到总体 上出现次数最多的IP;

2丶搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但如果除去重复后,不超过3百万个。一个查询串的重复度越高, 说明查询它的用户越多,也就是越热门),统计最热门的10个查询串,要求使用的内存不能超过1G。

算法思想: hashmap+堆
①.先对这批海量数据预处理,在O(N)的时间内用Hash表完成统计;
②.借助堆这个数据结构,找出Top K,时间复杂度为O(N*logK)。 或者:采用trie树,关键字域存该查询串出现的次数,没有出现为0。最后用10个元 素的最小推来对出现频率进行排序。

3、有一个1G大小的一个文件,里面每一行是一个词,词的大小 不超过16字节,内存限制大小是1M。返回频数最高的100个词。

算法思想: 分而治之 + hash统计 + 堆排序
①.顺序读文件中,对于每个词x,取hash(x)%5000,然后按照该值存到5000个小文 件(记为x0,x1,…x4999)中。这样每个文件大概是200k左右。如果其中的有的文件 超过了1M大小,还可以按照类似的方法继续往下分,直到分解得到的小文件的大小 都不超过1M。
②.对每个小文件,采用trie树/hash_map等统计每个文件中出现的词以及相应的频 率。
③.取出出现频率最大的100个词(可以用含100个结点的最小堆)后,再把100个词 及相应的频率存入文件,这样又得到了5000个文件。最后就是把这5000个文件进 行归并(类似于归并排序)的过程了。

4、有10个文件,每个文件1G,每个文件的每一行存放的都是用 户的query,每个文件的query都可能重复。要求你按照query的 频度排序。

方案1:算法思想:分而治之 + hash统计 + 堆排序
顺序读取10个文件,按照hash(query)%10的结果将query写入到另外10个文件中。 这样新生成的文件每个的大小大约也1G,大于1G继续按照上述思路分。找一台内存在2G左右的机器,依次对用hash_map(query, query_count)来统计每个 query出现的次数。利用快速/堆/归并排序按照出现次数进行排序。将排序好的 query和对应的query_cout输出到文件中。这样得到了10个排好序的文件(记 为)。
对这10个文件进行归并排序(内排序与外排序相结合)。

方案2: 算法思想:hashmap+堆
一般query的总量是有限的,只是重复的次数比较多而已,可能对于所有的query, 一次性就可以加入到内存了。这样,我们就可以采用trie树/hash_map等直接来统计 每个query出现的次数,然后按出现次数做快速/堆/归并排序就可以了。

5、 给定a、b两个文件,各存放50亿个url,每个url各占64字 节,内存限制是4G,让你找出a、b文件共同的url

方案1:可以估计每个文件安的大小为5G×64=320G,远远大于内存限制的4G。所 以不可能将其完全加载到内存中处理。考虑采取分而治之的方法。算法思想: 分而治之 + hash统计
遍历文件a,对每个url求取hash(url)%1000,然后根据所取得的值将url分别存储到 1000个小文件(记为a0,a1,…,a999)中。这样每个小文件的大约为300M。遍历文件b,采取和a相同的方式将url分别存储到1000小文件(记为 b0,b1,…,b999)。这样处理后,所有可能相同的url都在对应的小文件 (a0vsb0,a1vsb1,…,a999vsb999)中,不对应的小文件不可能有相同的url。然后 我们只要求出1000对小文件中相同的url即可。求每对小文件中相同的url时,可以把其中一个小文件的url存储到hash_set中。然后 遍历另一个小文件的每个url,看其是否在刚才构建的hash_set中,如果是,那么就 是共同的url,存到文件里面就可以了。

方案2: 如果允许有一定的错误率,可以使用Bloom filter,4G内存大概可以表示 340亿bit。将其中一个文件中的url使用Bloom filter映射为这340亿bit,然后挨个读 取另外一个文件的url,检查是否与Bloom filter,如果是,那么该url应该是共同的 url(注意会有一定的错误率)。

6、在2.5亿个整数中找出不重复的整数,内存不足以容纳这 2.5亿个整数。

采用2-Bitmap(每个数分配2bit,00表示不存在,01表示出现一次,10表示多次, 11无意义)进行,共需内存2^32 * 2 bit=1 GB内存,还可以接受。然后扫描这2.5亿 个整数,查看Bitmap中相对应位,如果是00变01,01变10,10保持不变。所描完 事后,查看bitmap,把对应位是01的整数输出即可。

存储

数据的存储大致可分缓存、数据库、消息中间件

缓存Redis

海量数据+高并发+高可用的场景的情况下,使用Redis cluster ,自动将数据进行分片,每个 master 上放一部分数据,它支撑 N个 Redis master node,每个 master node 都可以挂载多个 slave node。 这样整个 Redis就可以横向扩容了,如果你要支撑更大数据量的缓存,那就横向扩容更多的 master 节点,每个 master节点就能存放更多的数据了。而且部分 master 不可用时,还是可以继续工作的。

在 Redis cluster 架构下,使用cluster bus 进行节点间通信,用来进行故障检测、配置更新、故障转移授权。cluster bus 用了一种二进制的协议, gossip 协议,用于节点间进行高效的数据交换,占用更少的网络带宽和处理时间。

数据库MySQL

在实际进行系统设计时,最好是用MySQL数据库只用来存储关系性较强的热点数据,而对海量数据采取另外的一些分布式存储产品。例如Spark、HBase、Hive、ES等这些大数据组件来存储。目前经常使用的关系型数据库如 MySQ以“行”为单位进行存储,为了快速检索,也都采用了B树或其他索引技术。

1️⃣从原理上来讲,表中的数据越多,索引树的范围越大,磁盘读取也越多,性能也就越低。
2️⃣从实践角度来看,一般以百万到千万作为一个表的存储量级,超出该范围之后,性能就会下降,需要采用其他技术手段解决。

【读写分离】
首先想到的就是能否将读和写分离,主数据库用于写入,读数据库(多个)用于对外提供查询,通过数据复制的方式将主数据库的数据同步到读库。该架构提升了数据库的读写能力,但对于主数据库的写入能力依然没法扩展。

【分库分表】
其次,依据数据库分区的思路,可以将不同的数据分散到不同的库中,每个库存储的数据都不同,这样就可以将单一库的压力分散到多个库中,从而提升整个数据库的服务能力,这就是所说的分库分表技术。

【主从复制】
最后,需要实时灾备,用于故障切换等问题,需要使用主从复制保证数据高可用。

消息中间件RocketMQ/Kafka

RocketMQ

  • 每台机器上部署的RocketMQ进程一般称之为Broker,每个Broker都会收到不同的消息,然后就会将这批消息存储在自己的本地磁盘文件中(这里是不是就要回想到为什么MQ中积压一些qps是没有关系的);

  • 假设有1亿条消息,10台机器都部署了RocketMQ的Broker,理论上就可以让每天机器上面存储1000万条消息

  • MQ海量数据的由来:本质上RocketMQ存储海量消息的机制就是为了分布式的存储,所谓的分布式存储,就是把数据分散在多台机器上来存储,每台机器存储一部分消息,这样多台机器就可以存储海量的消息了;

Kafka

  • kafka提供了分区并行、ISR机制、顺序写入、页缓存、高效序列化、零拷贝等机制保证了大数据的吞吐量,由于kafka的消息存储涉及到海量数据读写,所以利用零拷贝能够显著的降低延迟,提高效率。(linux内核中的函数sendfile()就是零拷贝)
如何解决分布式事务的问题呢?

2PC(两阶段提交),第一阶段,事务开始执行时就发送一条消息给相关的服务告诉他们,我要开始执行了,执行完以后返回一条消息,告诉这个服务业务执行成功了没有;第二阶段,如果上一阶段返回的是执行成功了,那么再发一条消息告诉所有的服务事务执行成功了,相关的事务都可以提交了,如果第一阶段失败,所有事务回滚。这种方式缺点就比较明显,实现太复杂,事务执行过程中数据锁定的范围太大了,在业务本身未执行完毕之前,数据库相关的表都是锁定状态,所以这种方式性能比较差,在高并发的业务中用的比较少。

TCC(补偿业务),这种方式的前提是面对事务都要有一套确认事务执行的业务,一套取消执行的业务。比如说减库存这个业务,确认事务就是减库存,补偿事务就是加库存,使用这种处理方式时,所有业务都开始执行,互不等待,完成了就提交,解决了两阶段提交问题中大面积数据锁定的情况,但是如果业务A提交成功,业务B提交失败的话,没关系,我们有补偿事务,这种解决方案不是靠事务回滚的方式,靠的是事务的补偿。但是也有缺点,虽然它解决了业务问题,但是它让业务变得更复杂了,写一个业务必须写一个确定业务执行方法和一个补偿业务的方法,除此之外还要考虑补偿方案的失败问题,如果补偿方案也执行失败了,这时候就要考虑重试问题,人工介入问题。

异步确保,执行时发送一条消息,另外一方接收消息,如果执行不成功就一直重试,直到执行成功。

2PC+MQ,两阶段提交方式加异步确保。缺点呢,事务无法回滚,不适合减库存这个业务

其实在电商项目中最适合的还是TCC,虽然业务变复杂了,但是行之有效,如果是转账业务的话,就比较适合异步确保,转账业务只要消息可靠就可以,执行时间晚一点也没关系,所以异步确保的关键点是消息可靠。

另外阿里推出的seata分布式事务不适合作为秒杀减库存场景

性能损耗: 一条Update的SQL,则需要全局事务xid获取(与TC通讯)、before image(解析SQL,查询一次数据库)、after image(查询一次数据库)、insert undo log(写一次数据库)、before commit(与TC通讯,判断锁冲突),这些操作都需要一次远程通讯RPC,而且是同步的。另外undo log写入时blob字段的插入性能也是不高的。每条写SQL都会增加这么多开销,粗略估计会增加5倍响应时间。

性价比:为了进行自动补偿,需要对所有交易生成前后镜像并持久化,可是在实际业务场景下,这个是成功率有多高,或者说分布式事务失败需要回滚的有多少比率?按照二八原则预估,为了20%的交易回滚,需要将80%的成功交易的响应时间增加5倍,这样的代价相比于让应用开发一个补偿交易是否是值得?

全局锁
热点数据:相比XA,Seata 虽然在一阶段成功后会释放数据库锁,但一阶段在commit前全局锁的判定也拉长了对数据锁的占有时间,这个开销比XA的prepare低多少需要根据实际业务场景进行测试。全局锁的引入实现了隔离性,但带来的问题就是阻塞,降低并发性,尤其是热点数据,这个问题会更加严重。

回滚锁释放时间:Seata在回滚时,需要先删除各节点的undo log,然后才能释放TC内存中的锁,所以如果第二阶段是回滚,释放锁的时间会更长。

死锁问题:Seata的引入全局锁会额外增加死锁的风险,但如果出现死锁,会不断进行重试,最后靠等待全局锁超时,这种方式并不优雅,也延长了对数据库锁的占有时间。

关于seata是可以做到对项目代码无入侵,代价是需要部署和维护一个中间件,关于at和xa模式对比从概念上看很难区别,我的理解差异点在于AT模式的隔离就是靠全局锁来保证,粒度细至行级,锁信息存储在Seata-Server一侧。

XA模式的隔离性就是由本地数据库保证,锁存储在各个本地数据库中。由于XA模式一旦执行了prepare后,再也无法重入这个XA事务,也无法跟其他XA事务共享锁。因为XA协议,仅是通过XID来start一个xa事务,本身它不存在所谓的分支事务说法,它本事就是一个XA事务而已,也就是说它只管它自己。at模式的undolog就是把本地事务作用中的undolog,利用他的原理,做到了分布式事务中,来保证了分布式事务下的事务一致性。

目前使用:目前是结合sharding在使用,如xxljob跑任务会用到一些订单实时报价并修改用户订单概览等信息,需要与第三方系统交互,系统就需要保证数据的最终一致性

系统权限设计(系统安全)

接口防止篡改

在支付场景中,请求支付金额为 10 元,被拦截后篡改为 100 元,服务端接收到请求,接口增加签名、验签的步骤,请求参数计算得到验签摘要值,通过非对称加密算法解密得到签名摘要值,由于请求参数金额发生了变化,验签摘要值不等于签名摘要值,因此验签失败,该请求不予处理。关于加签、验签过程中使用到的算法、排序、拼接等都需要签名方开发人员、验签方开发人工共同协商与约定。

执行步骤:

  • 一般而言,前后端(签名方、验签方人工协商好)约定好,前端在请求头中添加token、时间戳、随机字符串、请求地址、appId字段,给一个签名摘要值字段作为前面几个参数加签后的结果,后端接收到请求头里的参数。

  • 先判断请求头里的参数是否为空,是否是非法请求

  • 然后验证token有效性,看看能不能得到用户信息,防止token伪造

  • 接着判断时间戳是否大于60秒,防止重放攻击(这里讲一下重放攻击:入侵者从网络上截取主机A发送给主机B的报文,把A加密的报文发送给B,让主机B误以为入侵者就是主机A,然后主机B向伪装成A的入侵者发送应当发送给A的报文。一般通过时间戳和 token结合使用,防止重放攻击)

  • 然后判断该用户的随机字符串参数是否已经在redis中,主要用作请求仅一次有效,防止短时间内的重放攻击

  • 接着判断请求的url参数是否正确,防止跨域攻击,判断请求的appid是否是正确,前后端约定好应用私钥,双方都一致,比如(H5、安卓、IOS、Web等)/(赣政通、中央党校、南昌市政府等)请求分别对应不同的appId,防止内部系统请求访问了不属于自己应用的请求。

  • 然后对请求头参数进行签名,token + timestamp + nonceStr + url + appId + request进行非对称加密,得到摘要值,判断请求头中的签名摘要值和请求参数计算得到验签摘要值是否一致,判断请求参数是否篡改

  • 验签通过之后,最后才把本次用户请求的随机字符串参数存到redis中设置60秒后自动删除

代码示例:

// 获取token
String token = request.getHeader("token");
// 获取时间戳
String timestamp = request.getHeader("timestamp");
// 获取随机字符串
String nonceStr = request.getHeader("nonceStr");
// 获取请求地址
String url = request.getHeader("url");
// 获取APPID
String appId= request.getHeader("appId");
// 获取签名
String signature = request.getHeader("signature");

// 判断参数是否为空
if (StringUtils.isBlank(token) || StringUtils.isBlank(timestamp) || StringUtils.isBlank(appId)
|| StringUtils.isBlank(nonceStr) || StringUtils.isBlank(url) || StringUtils.isBlank(signature)) {
    //非法请求
    return;
}

//验证token有效性,得到用户信息
UserTokenInfo userTokenInfo = TokenUtils.getUserTokenInfo(token);

if (userTokenInfo == null) {
    //token认证失败(防止token伪造)
    return;
}

// 判断时间是否大于60秒
if(DateUtils.getSecond()-DateUtils.toSecond(timestamp)>60){
    //请求超时(防止重放攻击)
    return;
}

// 判断该用户的nonceStr参数是否已经在redis中
if (RedisUtils.haveNonceStr(userTokenInfo,nonceStr)){
    //请求仅一次有效(防止短时间内的重放攻击)
    return;
}

// 判断请求的url参数是否正确
if (!request.getRequestURI().equals(url)){
    //非法请求 (防止跨域攻击)
    return;
}

// 判断请求的appid是否是正确
if (RedisUtils.getAppId(appId)){
	//前后端约定好应用私钥,双方都一致,比如(H5、安卓、IOS、Web等)/(赣政通、中央党校、南昌市政府等)请求分别对应不同的appId
    return;
}

// 对请求头参数进行签名
String stringB = SignUtil.signature(token, timestamp, nonceStr, url, appId, request);

// 如果签名验证不通过
if (!signature.equals(stringB)) {
    //非法请求(防止请求参数被篡改)
    return;
}

// 将本次用户请求的nonceStr参数存到redis中设置60秒后自动删除
RedisUtils.saveNonceStr(userTokenInfo,nonceStr,60);

//开始处理合法的请求
...
用户权限设计

一般而言,一个用户有多个角色,一个角色有多个权限,所以一个最基础的用户权限表分为用户表,角色表,权限表,用户角色表,角色权限表。
表结构设计:

-- tb_authority

create table  `tb_authority`
(
       `id`              bigint(20) auto_increment primary key not null comment '主键Id',
       `create_id`       bigint(20) comment '创建人Id',
       `create_time`     DATETIME comment '创建时间',
       `update_time`     DATETIME comment '更新时间',
       `deleted`         bigint(2) comment '是否逻辑删除',
       `order_no`        bigint(10) comment '排序号',
       `authority`       VARCHAR(50) comment '权限名称',
       `url`             VARCHAR(100) comment '权限路径(菜单路径) 比如(''用户管理'',''user/list'')',
       `type`            VARCHAR(20) comment '类型(查询:00;增加:11;修改:22:删除:33)',
       `pid`             VARCHAR(20) comment '父节点',
       `status`          bigint(10) comment '状态:是否启用(0:未启用;1:启用)'
);
alter table `tb_authority` comment= '权限表';

-- tb_role
create table  `tb_role`
(
       `id`              bigint(20) auto_increment primary key not null comment '主键Id',
       `create_id`       bigint(20) comment '创建人Id',
       `create_time`     DATETIME comment '创建时间',
       `update_time`     DATETIME comment '更新时间',
       `deleted`         bigint(2) comment '是否逻辑删除',
       `order_no`        bigint(10) comment '排序号',
       `role`            VARCHAR(50) comment '角色名称'
);
alter table `tb_role` comment= '角色表';

-- tb_user_role
create table  `tb_user_role`
(
       `id`              bigint(20) auto_increment primary key not null comment '主键Id',
       `create_id`       bigint(20) comment '创建人Id',
       `create_time`     DATETIME comment '创建时间',
       `update_time`     DATETIME comment '更新时间',
       `deleted`         bigint(2) comment '是否逻辑删除',
       `order_no`        bigint(10) comment '排序号',
       `user_id`         bigint(20) comment '用户id',
       `role_id`         bigint(20) comment '角色id'
);
alter table `tb_user_role` comment= '用户角色表 一个用户可以有多种角色';

-- tb_role_authority
create table  `tb_role_authority`
(
       `id`              bigint(20) auto_increment primary key not null comment '主键Id',
       `create_id`       bigint(20) comment '创建人Id',
       `create_time`     DATETIME comment '创建时间',
       `update_time`     DATETIME comment '更新时间',
       `deleted`         bigint(2) comment '是否逻辑删除',
       `order_no`        bigint(10) comment '排序号',
       `role_id`         bigint(20) comment '角色id',
       `authority_id`    bigint(20) comment '权限id'
);
alter table `tb_role_authority` comment= '角色权限表';

用户权限核心主要是判断当前登录用户是否具备某个权限,比如当前登录用户,它的角色是超级管理员,那么它具备所有权限(比如可以看到所有菜单目录);比如当前登录用户,它的角色是系统管理员,那么它具备当前系统管理员具备的权限(比如可以看到普通用户的管理页面,不能看到系统用户的管理页面,同级别的不具备操作权限);

请求进来之后,需要获取当前登录用户的信息,然后根据当前登录用户的id查询当前登录用户有哪些角色,这些角色具备的权限有哪些,是否具备当前请求的操作权限。

微服务统一认证中心

登录流程分析:https://www.processon.com/view/link/60a32e7a079129157118740f

核心就二个认证和授权:

  • 认证(Authentication) :用户认证就是判断一个用户的身份是否合法的过程,用户去访问系统资源时系统要求验证用户的身份信息,身份合法方可继续访问,不合法则拒绝访问。常见的用户身份认证方式有:用户名密码登录,二维码登录,手机短信登录,指纹认证等方式。
  • 授权(Authorization): 授权是用户认证通过根据用户的权限来控制用户访问资源的过程,拥有资源的访问权限则正常访问,没有权限则拒绝访问。

Spring Security OAuth2构建一个授权服务器来验证用户身份以提供access_token,并使用这个access_token来从资源服务器请求数据。
流程:

  • 用户访问,没有Token,重定向到授权服务器。
  • 授权服务器生成授权码并返回给客户端。
  • 客户端拿到授权码去授权服务器生成Token并返回给客户端
  • 客户端拿到Token去资源服务器进行校验,校验通过可以获取资源。
客户端授权模式

客户端必须得到用户的授权(authorization grant),才能获得令牌(access token),不管哪一种授权方式,第三方应用申请令牌之前,都必须先到系统备案,说明自己的身份,然后会拿到两个身份识别码:客户端 ID(client ID)和客户端密钥(client secret)。这是为了防止令牌被滥用,没有备案过的第三方应用,是不会拿到令牌的。OAuth 2.0颁发令牌分成四种授权类型(authorization grant),适用于不同的互联网场景。

  • 授权码模式(authorization code)
  • 密码模式(resource owner password credentials)
  • 简化(隐式)模式(implicit)
  • 客户端模式(client credentials)
获取令牌
授权码模式

第三方应用先申请一个授权码,然后再用该码获取令牌。

执行步骤:

A网站提供一个链接,用户点击后就会跳转到 B 网站,授权用户数据给 A 网站使用。下面就是 A 网站跳转 B 网站的一个示意链接。

 https://b.com/oauth/authorize?
   response_type=code&            		#要求返回授权码(code)
   client_id=CLIENT_ID&           		#让 B 知道是谁在请求   
   redirect_uri=CALLBACK_URL&     		#B 接受或拒绝请求后的跳转网址 
   scope=read							# 要求的授权范围(这里是只读)	

用户跳转后,B 网站会要求用户登录,然后询问是否同意给予 A 网站授权。用户表示同意,这时 B 网站就会跳回redirect_uri参数指定的网址。跳转时,会传回一个授权码,就像下面这样。

https://a.com/callback?code=AUTHORIZATION_CODE    #code参数就是授权码

A 网站拿到授权码以后,就可以在后端,向 B 网站请求令牌。 用户看不到服务端行为

https://b.com/oauth/token?
client_id=CLIENT_ID&
client_secret=CLIENT_SECRET&  #client_id和client_secret用来让B确认A的身份,client_secret参数是保密的,因此只能在后端发请求
grant_type=authorization_code&#采用的授权方式是授权码
code=AUTHORIZATION_CODE&      #上一步拿到的授权码
redirect_uri=CALLBACK_URL	  #令牌颁发后的回调网址	

B 网站收到请求以后,就会颁发令牌。具体做法是向redirect_uri指定的网址,发送一段 JSON 数据。

 {    
   "access_token":"ACCESS_TOKEN",     # 令牌
   "token_type":"bearer",
   "expires_in":2592000,
   "refresh_token":"REFRESH_TOKEN",
   "scope":"read",
   "uid":100101,
   "info":{...}
 }
简化(隐式)模式

有些 Web 应用是纯前端应用,没有后端,将令牌储存在前端。不通过第三方应用程序的服务器,直接在浏览器中向授权服务器申请令牌,跳过了"授权码"这个步骤,所有步骤在浏览器中完成,令牌对访问者是可见的,且客户端不需要认证。这种方式把令牌直接传给前端,是很不安全的。只能用于一些安全要求不高的场景,并且令牌的有效期必须非常短,通常就是会话期间(session)有效,浏览器关掉,令牌就失效了。

执行步骤:
A 网站提供一个链接,要求用户跳转到 B 网站,授权用户数据给 A 网站使用。

 https://b.com/oauth/authorize?
   response_type=token&          # response_type参数为token,表示要求直接返回令牌
   client_id=CLIENT_ID&
   redirect_uri=CALLBACK_URL&
   scope=read

用户跳转到 B 网站,登录后同意给予 A 网站授权。这时,B 网站就会跳回redirect_uri参数指定的跳转网址,并且把令牌作为 URL 参数,传给 A 网站。

https://a.com/callback#token=ACCESS_TOKEN     #token参数就是令牌,A 网站直接在前端拿到令牌。
密码模式

高度信任某个应用,允许用户把用户名和密码,直接告诉该应用。该应用就使用你的密码,申请令牌,这种方式称为"密码式"。
A 网站要求用户提供 B 网站的用户名和密码,拿到以后,A 就直接向 B 请求令牌。整个过程中,客户端不得保存用户的密码。

 https://oauth.b.com/token?
   grant_type=password&       # 授权方式是"密码式"
   username=USERNAME&
   password=PASSWORD&
   client_id=CLIENT_ID

B 网站验证身份通过后,直接给出令牌。注意,这时不需要跳转,而是把令牌放在 JSON 数据里面,作为 HTTP 回应,A 因此拿到令牌。
在这里插入图片描述

客户端模式

客户端以自己的名义,而不是以用户的名义,向"服务提供商"进行授权。适用于没有前端的命令行应用,即在命令行下请求令牌。一般用来提供给我们完全信任的服务器端服务。
A 应用在命令行向 B 发出请求。

 https://oauth.b.com/token?
   grant_type=client_credentials&
   client_id=CLIENT_ID&
   client_secret=CLIENT_SECRET

B 网站验证通过以后,直接返回令牌。
在这里插入图片描述

令牌的使用

A 网站拿到令牌以后,就可以向 B 网站的 API 请求数据了,每个发到 API 的请求,都必须带有令牌。具体做法是在请求的头信息,加上一个Authorization字段,令牌就放在这个字段里面。

 curl -H "Authorization: Bearer ACCESS_TOKEN" \
 "https://api.b.com"

也可以通过添加请求参数access_token请求数据。
在这里插入图片描述
令牌的有效期到了,如果让用户重新走一遍上面的流程,再申请一个新的令牌,很可能体验不好,而且也没有必要。OAuth 2.0 允许用户自动更新令牌。
具体方法是,B 网站颁发令牌的时候,一次性颁发两个令牌,一个用于获取数据,另一个用于获取新的令牌(refresh token 字段)。令牌到期前,用户使用 refresh token 发一个请求,去更新令牌。

 https://b.com/oauth/token?
   grant_type=refresh_token&    # grant_type参数为refresh_token表示要求更新令牌
   client_id=CLIENT_ID&
   client_secret=CLIENT_SECRET&
   refresh_token=REFRESH_TOKEN    # 用于更新令牌的令牌

在这里插入图片描述

红包雨设计(高并发、海量数据)

系统设计的目的:
  • 高性能:为了保证用户体验,用户可以尽快看到结果,把金额加到账户里。
  • 高可靠:不能超发,红包超发或者促销活动超卖,都会给企业带来损失,使用需要保证高可靠。
  • 高可用:保证活动期间服务不挂。
如何实现高性能?

机房流量调度,如果流量入口是同一个的话,压力会比较大,这个时候就得根据,TPS(每秒处理的请求)级别选择不同类型的负载均衡策略,一般的Linux服务器上安装个Nginx大概能到5万每秒,Lvs的性能是十万级,据说可以达到80万每秒,F5的性能是百万级,从200万每秒到800万每秒都有。如果不想用这种商业负载均衡设备,或者想尽量减少网络延迟,可以这样设计。

说下我对红包的想法:

  • 把红包根据规则拆分好,放在不同的机房,不同地区的用户在活动开始之前,就已经分配好了机房,可以用HTTPDNS或者不同的域名来实现机房流量调度,当用户抢红包或者查询红包的时候,只需要在本地机房做处理就可以了,最后在把结果通过MQ异步或者同步到账户服务里面。这里也是对业务是做了一定牺牲的,红包金额同步到用户账户会有一定延迟,用户红包没有办法马上入账,这样处理就可以把TPS数量级降下来,如果机房数量足够多,甚至以后边缘计算发展起来,后端抢红包服务基本上都不需要太多关注大流量问题。
  • 红包可以提前做好10亿个待领取的列表,金额提前写好,节省运算。
  • 按用户id取模,直接给1分钱、2分钱,分行档次。因为红包是先计算后分发和预先分好按顺序领取,所以减少了抢红包时的计算,提升了响应时间。
  • 使用Redis集群,部署多个实例,假设要到达50万TPS,按照redis单机8万TPS来算,只需要7个实例,可以冗余部署到10个实例,防止突发状况,然后在应用层面做轮询,如果应用层面发现有实例挂了,就马上剔除掉。
  • 每个请求进来先判断用户是否可领取,比如身份正常、未领取等等。
  • 每个redis实例上都会存储一个拆分好的红包id+红包金额list,抢红包的时候用lua脚本从红包金额list里面pop一个红包金额元素,放到另外一个list里面,存储uid+红包id+红包金额。定时任务集群不断从redis集群里面取uid+金额数据,批量插入到mysql集群里面,同时发送mq通知账户服务入库。当然也可以在活动结束后读取mysql查询每个用户汇总结果,同步给账户服务,这样还可以减少消息量。mysql集群这里可以用uid作为维度进行分库,主要是用来查询用户已抢到的用户红包列表。
  • 把符合抢红包要求的用户放到头部列表上,领取到红包的用户出列,放到已领取列表里。
  • 判定未领取,可以把用户ID的二进制进行覆盖时铺写,按长度先写0,有值就写成1,逐位比对到0的就是未领取过,这样避免全表搜索。全为1,再全表搜索。
如何实现高可靠?
拆分红包算法
二倍均值法

随机金额的平均值都是相等的,不会因为抢红包的先后顺序造成不公平。
在这里插入图片描述假设有10个人,红包总额100元,100/102=20。所以一个人的随机范围是0到20,平均可以抢到10元,如果第一个人抢到10元,剩余的红包金额就是90,90/92=20。所以第二个人的随机范围同样0到20,平均可以抢到10元。以此类推,每次的随机范围均值都是相等的。

线段切割法

把红包总金额想象成一条很长的线段,每个人抢到的金额是主线段拆分出来的若干个子线段。
在这里插入图片描述当N个人一起抢红包的时候,就需要切割N-1个切割点。所以当N个人一起抢总金额为M的红包的时候,就需要做N-1次随机运算,随机的范围在1到100*M,当所有的切割点都确认之后,线段的长度也随之确认,这样每个人来抢红包的时候,只需要顺序领取和子线段长度等价的红包金额即可。

二种算法优缺点

在这里插入图片描述

风控防刷

复杂的风控规则后置,因为抢红包到金额加入到账户到使用账户金额,整个链路需要经过一段时间,可以利用这段时间来跑异步风控规则,比如跑风控模型等等。实时的规则只有最简单的就好,比如控制访问频率,黑白名单等等。这个也是技术和业务的一个取舍。

如何实现高可用?

缓存集群高可用、数据库集群高可用、消息中间件高可用、无状态服务弹性伸缩(在机器不够的时候紧急扩容)、压测和限流(活动上线前要做好压测,预估好容量,做好限流保证能在承受范围以内,避免流量过大造成服务雪崩)

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Java程序员廖志伟

赏我包辣条呗

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值