- 分布式
- 分布式锁提高性能方法
- 库存超卖现象是怎么产生的?
- 技术选型的选型原则
- 关系型数据库与非关系型数据库
- Rpc原理
- MQ问题
- 防止超卖处理
- 1.Redis乐观锁解决超卖问题(预减库存)
- MySQL分库分表
- 一致性哈希分库分表
- 为什么要分库分表?
- 手动分表
- 分库同步过程
- 4 如何解决分库中分布式事务问题
- MySQL数据库如何迁移?
- 分布式事务
- 分布式锁性能低问题
- 二、分布式锁的设计因素
- RPC原理
- 接口限流防刷
- Redis缓存问题
- Redis数据结构
- Redis持久化机制(默认端口6379)
- AOF持久化
- Redis哨兵模式
- Redis主从复制原理
- 非关系型数据库
- Springboot循环依赖问题
- 什么情况下循环依赖可以被处理?
- Spring生命周期
- @Component 和 @Bean 的区别是什么?
- springIOC
- 面向切面编程(AOP)">Spring面向切面编程(AOP)
- Redis五种数据结构
分布式
远程调用实现系统间的通信:通过调用本地的java接口的方法来透明的调用远程java的实现。具体的细节有框架来实现。
缺点: 就是会增加技术的复杂度。
Redis如何实现分布式锁?
Setnx命令 **SET if Not eXists 的缩写,也就是只有不存在的时候才设置
key是锁的唯一标识,按业务来决定命名。比如想要给一种商品的秒杀活动加锁,可以给key命名为 “locksale商品ID” 。而value设置成什么呢?锁的value值为一个随机生成的UUID。我们可以姑且设置成1。加锁的伪代码如下:
当一个线程执行setnx返回1,说明key原本不存在,该线程成功得到了锁;当一个线程执行setnx返回0,说明key已经存在,该线程抢锁失败。
有加锁就得有解锁。当得到锁的线程执行完任务,需要释放锁,以便其他线程可以进入。释放锁的最简单方式是执行del指令,伪代码如下:
**3.锁超时
锁超时是什么意思呢?如果一个得到锁的线程在执行任务的过程中挂掉,来不及显式地释放锁,这块资源将会永远被锁住,别的线程再也别想进来。
4. del 导致误删
又是一个极端场景,假如某线程成功得到了锁,并且设置的超时时间是30秒。
可以在加锁的时候把当前的线程ID当做value,并在删除之前验证key对应的value是不是自己线程的ID。
5.判断和释放锁是两个独立操作,不是原子性的。
使用lua脚本的evel函数();
分布式锁可重入性
是对非可重入锁的增强,避免非可重入锁在嵌套使用时产生死锁。
如果lock是非可重入锁,则methodA加锁后调用methodB,methodB尝试加锁会失败(因为methodA在占用),导致methodB一直等待methodA释放锁,但是methodA在等待methodB执行完成后才能释放锁;造成循环等待,产生死锁
特殊情况:Redis主从复制过程中突然主宕机了,导致新主机也获得锁,不安全。。
- 在Redis的master节点上拿到了锁;
- 但是这个加锁的key还没有同步到slave节点;
- master故障,发生故障转移,slave节点升级为master节点;
- 导致锁丢失。
解决:红锁机制
获取当前Unix时间,以毫秒为单位。
依次尝试从5个实例,使用相同的key和具有唯一性的value(例如UUID)获取锁。当向Redis请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis实例请求获取锁。
客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(N/2+1,这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。
如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
注意:Redis支持多个数据库,并且每个数据库的数据是隔离的不能共享,并且基于单机才有,如果是集群就没有数据库的概念。 因为在redis在进群配置的时候默认使用db0
Redis的缓存淘汰策略:LRU
- 把Redis当缓存来用.
提供一种简单实现缓存失效的思路: LRU(最近少用的淘汰)
即redis的缓存每命中一次,就给命中的缓存增加一定ttl(过期时间)(根据具体情况来设定, 比如10分钟).一段时间后, 热数据的ttl都会较大, 不会自动失效, 而冷数据基本上过了设定的ttl就马上失效了Redis epoll多路复用
1)阻塞
我和女友点完餐后,不知道什么时候能做好,只好坐在餐厅里面等,直到做好,然后吃完才离开(2)非阻塞
我女友不甘心白白在这等,又想去逛商场,又担心饭好了。所以我们逛一会,回来询问服务员饭好了没有,来来回回好多次,饭都还没吃都快累死了啦。
这样会导致CPU占用率非常高,因此一般情况下很少使用while循环这种方式来读取数据。
(3)IO多路复用
与第二个方案差不多,餐厅安装了电子屏幕用来显示点餐的状态,这样我和女友逛街一会,回来就不用去询问服务员了,直接看电子屏幕就可以了。这样每个人的餐是否好了,都直接看电子屏幕就可以了,
而多路复用IO模式,通过一个线程就可以管理多个socket,只有当socket真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。
4)异步
女友不想逛街,又餐厅太吵了,回家好好休息一下。于是我们叫外卖,打个电话点餐,然后我和女友可以在家好好休息一下,饭好了送货员送到家里来。这就是典型的异步,只需要打个电话说一下,然后可以做自己的事情,饭好了就送来了
在异步IO模型中,当用户线程发起read操作之后,立刻就可以开始去做其它的事。
1)多路 I/O 复用模型
多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去查询这些套接字上是否有事件发生,轮询完后,再将句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll的设计和实现与select完全不同。epoll通过在Linux内核中申请一个简易的文件系统(文件系统一般用什么数据结构实现?B+树)。把原先的select/poll调用分成了3个部分:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生的事件的连接
如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。
数据过期
2、先删除缓存再更新数据库
1、并发操作问题
该方案会导致不一致的原因是。同时有一个**请求A进行更新操作,另一个请求B**进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,**删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到**旧值
(4)请求B将**旧值写入缓存
(5)请求A将新**值写入数据库
上述情况就会导致不一致的情形出现。而且,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。
采用延时双删+设置超时时间
第一次删的是缓存中旧数据
第二次删的是更改数据库完成之前,更新一半的脏数据
延时的作用就是让这些脏数据都缓存完好统一删!
具体过程如下图;
具体时间看读操作时间加几百ms就OK
- 更新完数据库再删除缓存(推荐)失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中:应用程序从cache中取数据,取到后返回。
更新:先把数据存到数据库中,成功后,再让缓存失效。 - 下图这情况是双删依然存在旧缓存的情况,延时是确保 修改数据库-》清空缓存前,其他事务的更改缓存操作已经执行完。
- 因为双删策略执行的结果是把redis中保存的那条数据删除了,以后的查询就都会去查询数据库。所以redis使用的是读远远大于改的数据缓存。
- 请求一:1.1修改数据库数据 1.2 删除redis数据 1.3 延时3—5s再去删除redis中数据
请求二:3.1查询redis中数据 3.2 查询数据库数据 3.3 新查到的数据写入redis
1 如何实现延时?
比较一般的: 创建线程池,线程池中拿一个线程,线程体中延时3-5s再去执行最后一步任务(不能忘了启动线程)
- 比较差的: 单独创建一个线程去实现延时执行
分布式锁提高性能方法
分段锁思想原理
具体的分段的数量和锁的数量要和CPU核数匹配,并不是锁越多越好。
尝试加锁: 首先当一个请求发送过来了,尝试获取分段锁得时候会查出所有的分段仓库的key并尝试创建标识如果成功返回仓库的KEY不成功就进行下个仓库KEY的如果成功了会使用仓库的KEY创建一个标识来锁定这个库存并添加过期时间然后返回一个 仓库的KEY
解锁:根据之前那个 仓库的KEY 查出用户唯一标识然后和我们解锁传入的用户id进行比较看看是否相对再决定是否释放这个锁,释放锁的时候我们会检查库存如果为0那么顺便删除库存提高下次锁获取的效率。库存超卖现象是怎么产生的?
分布式锁解决
这样会导致对同一个商品的下单请求,就必须串行化,一个接一个的处理。
分布式锁实现Key可以是stock1,value是唯一的uuid,分段锁思想
其实说出来也很简单,相信很多人看过java里的ConcurrentHashMap的源码和底层原理,应该知道里面的核心思路,就是分段加锁!
把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。不至于说,同一时间只能有一个线程独占修改ConcurrentHashMap中的数据。
LongAdder中也是采用了类似的分段CAS操作,失败则自动迁移到下一个分段进行CAS的思路。
注意点:
分段加锁思想。假如你现在iphone有1000个库存,那么你完全可以给拆成20个库存段,要是你愿意,可以在数据库的表里建20个库存字段,每个库存段是50件库存,比如stock_01对应50件库存,stock_02对应50件库存。类似这样的,也可以在redis之类的地方放20个库存key。
接着,1000个/s 请求,用一个简单的随机算法,每个请求都是随机在20个分段库存里,选择一个进行加锁。
每个下单请求锁了一个库存分段,然后在业务逻辑里面,就对数据库或者是Redis中的那个分段库存进行操作即可,包括查库存 -> 判断库存是否充足 -> 扣减库存。
相当于一个20毫秒,可以并发处理掉20个下单请求,那么1秒,也就可以依次处理掉20 50 = 1000个对iphone的下单请求了。
一旦对某个数据做了分段处理之后,有一个坑一定要注意:就是如果某个下单请求,咔嚓加锁,然后发现这个分段库存里的库存不足了,此时咋办?这时你得*自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。 这个过程一定要实现。技术选型的选型原则
在日常开发中,我们经常要面对各种相似而又繁多的技术框架组件选择,在专业上我们叫做技术选型。,我个人的建议有如下几点:
1、最好使用开源产品。
如果在使用过程中遇到一些bug,你可以通过源码阅读和分析,快速的进行修复。
2、最好选择比较流行的产品。
该产品要有一个相对活跃的社区或者在github上star数比较高,这样意味着产品bug更少相对更加稳定成熟,而且与周边的生态系统有更好的兼容性。而在出现问题时,你也可以快速找到解决方案。
3、要根据具体的需求场景来选型,避免过度设计,避免为了用而用。
4、尽可能的使用自己熟悉的技术。
在一个项目中,采用新技术的比重最好不超过30%。这样可以避免大部分不确定性,降低风险。当然对于新技术我们不能有排斥,要用于接受,用于尝试,让团队保持在时代的技术前沿。
5、使用前要做充分的调研和比对,真正了解我们要使用的产品。
对于同类产品要进行多维度的比对和测试,了解产品的特性和优缺点。对于新技术更是要慎之又慎,最好团队的成员大部分能够认可而且觉得有必要,并且初期要控制适用范围,有个试错的过程,经过充分场景验证后可以逐步推广到大规模。
6、要用动态发展的视角去看待。
在项目初期充分考虑业务和数据的增长速度和规模,尽可能简单易维护。后期随着时间的推移再经历几次大大小小的重构,会自然而然的淘汰掉过时的技术,换上更适用的新技术。关系型数据库与非关系型数据库
非关系型数据库的优势:
1. 性能NoSQL是基于键值对的,可以想象成表中的主键和值的对应关系,而且不需要经过SQL层的解析,所以性能非常高。
2. 可扩展性同样也是因为基于键值对,数据之间没有耦合性,所以非常容易水平扩展。
水平扩展
3、成本:NoSQL数据库简单易部署,基本都是开源软件,不需要像使用oracle那样花费大量成本购买使用,相比关系型数据库价格便宜。
4、查询速度:NoSQL数据库将数据存储于缓存之中,关系型数据库将数据存储在硬盘中,自然查询速度远不及nosql数据库。
5、存储数据的格式:NoSQL的存储格式是key,value形式、文档形式、图片形式等等,所以可以存储基础类型以及对象或者是集合等各种格式,而数据库则只支持基础类型。
缺点:
1)维护的工具和资料有限,因为nosql是属于新的技术,不能和关系型数据库10几年的技术同日而语。
2)不提供对sql的支持,如果不支持sql这样的工业标准,将产生一定用户的学习和使用成本。
3)不提供关系型数据库对事务的处理。
关系型数据库的优势:
1. 复杂查询可以用SQL语句方便的在一个表以及多个表之间做非常复杂的数据查询,如join。
2.最大优势:保持数据的一致性(事务处理) 事务支持使得对于安全性能很高的数据访问要求得以实现。关系型数据库把所有的数据都通过行和列的二元表现形式表示出来。
3. 由于以标准化为前提,数据更新的开销很小(相同的字段基本上都只有一处)
缺点:
1.扩展性:关系型数据库有类似join这样的多表查询机制的限制导致扩展很艰难。
2.不擅长如下处理
1). 大量数据的写入处理
2). 为有数据更新的表做索引或表结构(schema)变更
3). 字段不固定时应用
4). 对简单查询需要快速返回结果的处理
—大量数据的写入处理
mongodb是文档存储数据库,支持二级索引,但比较消耗内存,查询功能强大,类似json格式存储,一般可以用来存放评论等半结构化数据
redis是KV数据库,不支持二级索引,读写性能高,支持list,set等多种数据格式,适合读多写少的业务场景,可以用来做缓存系统Rpc原理
RPC 只是一种设计而已
RPC 只是一种概念、一种设计,就是为了解决 不同服务之间的调用问题, 它一般会包含有 传输协议 和 序列化协议 这两个。
就是客户端基于某种传输协议通过网络向服务提供端请求服务处理,然后获取返回的数据
具体调用过程:
1、服务消费者(client客户端)通过调用本地服务的方式调用需要消费的服务;
2、客户端存根(client stub)接收到调用请求后负责将方法、入参等信息序列化(组装)成能够进行网络传输的消息体;
3、客户端存根(client stub)找到远程的服务地址,并且将消息通过网络发送给服务端;
4、服务端存根(server stub)收到消息后进行解码(反序列化操作);
5、服务端存根(server stub)根据解码结果调用本地的服务进行相关处理;
6、本地服务执行具体业务逻辑并将处理结果返回给服务端存根(server stub);
7、服务端存根(server stub)将返回结果重新打包成消息(序列化)并通过网络发送至消费方;
8、客户端存根(client stub)接收到消息,并进行解码(反序列化);
9、服务消费方得到最终结果;
而RPC框架的实现目标则是将上面的第2-10步完好地封装起来,也就是把调用、编码/解码的过程给封装起来,让用户感觉上像调用本地服务一样的调用远程服务。
序列化:把对象转换为字节序列的过程称为对象的序列化,也就是编码的过程。
反序列化:把字节序列恢复为对象的过程称为对象的反序列化,也就是解码的过程。MQ问题
MQ+本地消息表 !!!重点
消息消费的顺序问题:发送消息指定队列,消息消费者指定队列可以解决,消费者只能一个。
消息消费的重复问题:每次消费消息时候创建一消息表,在消费消息前先查询该表,如果消息存在就说明已经消费。
回顾整个下单流程,我们之前做了下单减缓存库存优化以及回补库存的操作,但是因为整个下单是属于一个transaction事务,如果用户下单成功,但是之后订单入库或返回前端的过程中失败,事务回滚,会导致少卖的现象,有可能造成库存堆积
我们的解决方法就是异步消息的发送要在整个事务提交成功后再发送RocketMQ事务型消息
1.顺序消费
保证RocketMQ消息顺序消费的关键主要有以下几点:
保证生产者消费者用同一topic
保证生产者消费者用同一topic下的同一个queue(默认一个topic下有4个queue)
发消息的时候用一个线程去发送消息
消费的时候 只用一个线程去消费一个queue里的消息(默认MessageListenerConcurrently使用20个线程去消费处理消息
问题?
异步消息发送失败
扣减操作执行失败
下单失败(用户退单)无法正确回补库存
消息队列如何实现异步?
那这样我们可以通过RabbitMQ (消息队列)对我们数据库减轻压力:当”幸运儿”成功的将其的秒杀请求放到消息队列中,给其返回抢购成功,实际上用户并不关心自己的订单号马上返回,用户只关心自己是否能够成功抢购,所以对于生成订单号,减少库存等操作我们可以通过异步处理订单将数据写入数据库,比起多线程同步修改数据库的操作,大大缓解了数据库的连接压力,最主要的好处就表现在数据库连接的减少
2 消息丢失
RocketMQ 第一阶段发送 Prepared 消息时,会拿到消息的地址,第二阶段执行本地事物,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。细心的读者可能又发现问题了,如果确认消息发送失败了怎么办?RocketMQ 会定期扫描消息集群中的事物消息,这时候发现了 Prepared 消息,它会向消息发送者确认,Bob 的钱到底是减了还是没减呢?如果减了是回滚还是继续发送确认消息呢?RocketMQ 会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败
网络抖动,没来得及刷盘就宕机,消费时宕机等
回顾整个下单流程,我们之前做了下单减缓存库存优化以及回补库存的操作,但是因为整个下单是属于一个transaction事务,如果用户下单成功,但是之后订单入库或返回前端的过程中失败,事务回滚,会导致少卖的现象,有可能造成库存堆积
RocketMQ提供了消息回查机制
RocketMQ消息的事务架构设计:
生产者执行本地事务,修改订单支付状态(下单),并且提交事务
生产者发送事务消息到broker上,消息发送到broker上在没有确认之前,消息对于consumer是不可见状态(prepare状态)
生产者确认事务消息,使得发送到broker上的事务消息对于消费者可见
消费者获取到消息进行消费,消费完之后执行ack进行确认
RocketMQ执行本地事务的回调函数executeLocalTransaction()可以有三种返回值:
- ROLLBACK_MESSAGE:回滚事务 broker会回收step1中的不可见消息
- COMMIT_MESSAGE:提交事务
- UNKNOW:broker会定时回查Producer消息状态,直到彻底成功或失败
或者producer与broker网络异常 ,一般是查询数据库中数据是否已经持久化),如果消息回传的时候,consumer和broker之间网络断开,会不断重试直到达到默认的16次,如果重试次数足够多之后仍然无法消费成功,必须通过工单、日志等方式进行人工干预以让producer事务进行回退处理。
tep3网络断开没关系,因为在pushconsumer+clustering的消费方式向,broker端保存了offset,无论consumer何时下线,一旦consumer上线,都会从offset处开始消费,我们不必为其操心
产者根据具体情况记录日志or保存数据库,后续可以通过定时任务或者人工处理失败的消息,进行重新发送。
避免mq重复消费
消费者幂等处理 幂等指的是多次执行与一次执行产生的影响相同。
上述分析中,消费者可能会重复发送消息,因此需要消费者进行幂等处理,否则会导致业务异常,比如重复扣减库存数量。
实际产生消息重复的情况有如下几种:
生产者重复发送;
broker投递消息时,重复投递;当broker推送到消费者,但是没有得到消费者的响应(比如消费者响应时,网络异常),此时broker会重复投递消息;
负载均衡时消息重复:当rocketmq的broker宕机、重启、扩容、缩容时,可能会触发rebalance,导致消息重复消费。
常用的幂等处理方案:
消费者在接收到消息之后,在消息处理之前,先查询数据库中是否存在,存在则不处理。如果不存在,则首先将消息存入数据库,存入成功后,在处理消息。这里注意的是,要使用一定的规则来界定消息的重复性,比如使用订单id(不要使用消息id,因为rocketmq不保证消息id的唯一性),如果使用mysql,这个订单id要加唯一索引,避免并发时,两个线程同时查询数据库,发现不存在,然后同时将消息存入数据库的情况
1.2.2 如何解决消息队列幂等性问题?避免重复消费?
给每一条消息引入一个全局ID,并增加消息应用状态表(message_apply),通俗来说就是个账本,用于记录消息的消费情况,每次来一个消息,在真正执行之前,先去消息应用状态表中查询一遍,如果找到说明是重复消息,丢弃即可,如果没找到才执行,同时插入到消息应用状态表(同一事务)。RocketMQ优缺点
优点在于:
由于消息主题都是通过CommitLog来进行读写,ConsumerQueue中只存储很少的数据,所以队列更加轻量化。对于磁盘的访问是串行化从而避免了磁盘的竞争
缺点在于:
消息写入磁盘虽然是基于顺序写,但是读的过程确是随机的。读取一条消息会先读取ConsumeQueue,再读CommitLog,会降低消息读的效率。RocketMQ 消息发送流程
Producer将消息发送到Broker后,Broker会采用同步或者异步的方式把消息写入到CommitLog。RocketMQ所有的消息都会存放在CommitLog中,为了保证消息存储不发生混乱,对CommitLog写之前会加锁,同时也可以使得消息能够被顺序写入到CommitLog,只要消息被持久化到磁盘文件CommitLog,那么就可以保证Producer发送的消息不会丢失。
与Kafka区别
RocketMQ的消息存储采用的是混合型的存储结构,也就是Broker单个实例下的所有队列公用一个日志数据文件CommitLog。这个是和Kafka又一个不同之处。为什么不采用kafka的设计,针对不同的partition存储一个独立的物理文件呢?这是因为在kafka的设计中,一旦kafka中Topic的Partition数量过多,队列文件会过多,那么会给磁盘的IO读写造成比较大的压力,也就造成了性能瓶颈。所以RocketMQ进行了优化,消息主题统一存储在CommitLog中。当然它也有它的优缺点
防止超卖处理
1.Redis乐观锁解决超卖问题(预减库存)
情景假设:现在华为最新手机在做活动,双十二 00:00 准时前十名抢购的用户可以1元秒杀。而数据库对这个秒杀的动作呢,需要作出两个动作:
1、库存减1
2、记录秒杀成功的用户id2.设置唯一索引,防止重复卖
4.数据库加乐观,悲观锁(最后的保证)
1.当查询某条记录时,即让数据库为该记录加锁,锁住记录后别人无法操作,使用类似如下语法:
select stock from tb_sku where id=1 for update;使用乐观锁需修改数据库的事务隔离级别:
乐观锁并不是真实存在的锁,而是在更新的时候判断此时的库存是否是之前查询出的库存,如果相同,表示没人修改,可以更新库存,否则表示别人抢过资源,不再执行库存更新。类似如下操作:
使用乐观锁的时候,如果一个事务修改了库存并提交了事务,那其他的事务应该可以读取到修改后的数据值,所以不能使用可重复读的隔离级别,应该修改为读取已提交(Read committed)。
即为数据库增加一个版本表示的字段,在读取数据的时候将版本号一同读出,在数据更细后,对此版本号加一。然后将提交的版本数据跟数据表对应记录信息进行对比,如果提交的数据版本号大于数据表的版本号,则更细。都这认为是过期数据
加上version字段,每一次的操作都会更新version,提交时如果version不匹配,停止本次提交,可以尝试下一次的提交,以保证拿到的是操作1提交后的结果。
2.实现
1)先读task表的数据(实际上这个表只有一条记录),得到version的值为versionValue
2)每次更新task表中的value字段时,为了防止发生冲突,需要这样操作
update task set value = newValue, version = versionValue + 1 where version = versionValue;
只有这条语句执行了,才表明本次更新value字段的值成功
如假设有两个节点A和B都要更新task表中的value字段值,差不多在同一时刻,A节点和B节点从task表中读到的version值为2,那么A节点和B节点在更新value字段值的时候,都操作 update task set value = newValue,version = 3 where version = 2;,实际上只有1个节点执行该SQL语句成功,假设A节点执行成功,那么此时task表的version字段的值是3,B节点再操作update task set value = newValue,version = 3 where version = 2;这条SQL语句是不执行的,这样就保证了更新task表时不发生冲突;
set session transaction isolation level repeatable read; 修改默认隔离级别
5.解决方案3 任务队列
任务队列将下单的逻辑放到任务队列中(如celery),将并行转为串行,所有人排队下单。比如开启只有一个进程的Celery,一个订单一个订单的处理。
缺点:性能受限于队列处理机处理性能和DB的写入性能中最短的那个,另外多商品同时抢购的时候需要准备多条队列。
3 分布式锁(同上边)
2、平滑扩容
数据库扩容的过程中,如果想要持续对外提供服务,保证服务的可用性,平滑扩容方案是最好的选择。平滑扩容就是将数据库数量扩容成原来的2倍,比如:由2个数据库扩容到4个数据库,具体步骤如下:
新增2个数据库
配置双主进行数据同步(先测试、后上线)
数据同步完成之后,配置双主双写(同步因为有延迟,如果时时刻刻都有写和更新操作,会存在不准确问题)
数据同步完成后,删除双主同步,修改数据库配置,并重启;
分区
分区指的是分散数据到不同的磁盘,查询数据的时候只需要查询指定的分区.一张表的数据分成N个物理区块来负责.
一致性哈希分库分表
普通哈希算法: 当我们对同一个图片名称做相同的哈希计算时,得出的结果应该是不变的,如果我们有3台服务器,使用哈希后的结果对3求余,那么余数一定是0、1或者2;
当服务器数量发生改变时,所有缓存在一定时间内是失效的,当应用无法从缓存中获取数据时,则会向后端服务器请求数据;
一致性Hash算法的作用就是,使服务器失效或者新增时的影响范围达到最小
致性哈希算法也是使用取模的方法,但是取模算法是对服务器的数量进行取模,而一致性哈希算法是对 2^32 取模,具体步骤如下:
步骤一:一致性哈希算法将整个哈希值空间按照顺时针方向组织成一个虚拟的圆环,称为 Hash 环;
步骤二:接着将各个服务器使用 Hash 函数进行哈希,具体可以选择服务器的IP或主机名作为关键字进行哈希,从而确定每台机器在哈希环上的位置
步骤三:最后使用算法定位数据访问到相应服务器:将数据key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针寻找,第一台遇到的服务器就是其应该定位到的服务器**
一致性Hash算法对于节点的增减都只需重定位环空间中的一小部分数据,只有部分缓存会失效,不至于将所有压力都在同一时间集中到后端服务器上,具有较好的容错性和可扩展性。
为什么要分库分表?
1.数据库表的数据量会不断增大,查询所需要的时间会越来越长,另外由于MySQL对写操作会加锁,会阻塞读操作
2.对于数据量的问题,可以用分库分表解决.然后对于写操作,可以使用读写分离来解决
3.读写分离需要用到MySQL提供的主从机制
读写分离原理与实现
- 增删改操作让主数据库处理
- 读操作让从数据库处理
手动分表
这个在秒杀一中已有体现,这里仅仅是分表而已,提供一种思路,供参考,测试的时候自行建表。
按照用户 ID 来做 hash 分散订单数据。为了减少迁移的数据量,一般扩容是以倍数的形式增加。比如原来是8个库,扩容的时候,就要增加到16个库,再次扩容,就增加到32个库。这样迁移的数据量,就小很多了。
这个问题不算很大问题,毕竟一次扩容,可以保证比较长的时间,而且使用倍数增加的方式,已经减少了数据迁移量。
)修改服务的配置(不管是在配置文件里,还是在配置中心),将2个库的数据库配置,改为4个库的数据库配置,修改的时候要注意旧库与辛苦的映射关系:
%2=0的库,会变为%4=0与%4=2;
%2=1的部分,会变为%4=1与%4=3;
这样修改是为了保证,拆分后依然能够路由到正确的数据。
a)即使%2寻库和%4寻库同时存在,也不影响数据的正确性,因为此时仍然是双主数据同步的
b)服务reload之前是不对外提供服务的,冗余的服务能够保证高可用
完成了实例的扩展,会发现每个数据库的数据量依然没有下降,所以第三个步骤还要做一些收尾工作。
a)把双虚ip修改回单虚ip
b)解除旧的双主同步,让成对库的数据不再同步增加
c)增加新的双主同步,保证高可用
d)删除掉冗余数据,例如:ip0里%4=2的数据全部干掉,只为%4=0的数据提供服务啦分库同步过程
MySQL主从复制的基础是主服务器对数据库修改记录二进制日志,从服务器通过主服务器的二进制日志自动执行更新
主库中可以设置binlog,binlog是主库中保存更新事件日志的二进制文件。
主节点 binary log dump 线程
如上图所示:当从节点连接主节点时,主节点会创建一个log dump 线程,用于发送bin-log的内容。在读取bin-log中的操作时,此线程会对主节点上的bin-log加锁,当读取完成,甚至在发动给从节点之前,锁会被释放。
binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如”给 ID=2 这一行的 c 字段加1“。
binlog 是“追加写”的,一个文件写完了会切换到下一个,不会覆盖以前的日志。
同时binlog原原本本记录了我们的SQL语句。
因为两者分工不同。binlog主要用来做数据归档,但是它并不具备崩溃恢复的能力,也就是说如果你的系统突然崩溃,重启后可能会有部分数据丢失,而redo log的存在则可以完美解决这个问题。
- redo log通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。记录的是在某个表做了什么修改,
- 修改从库配置文件my.cnf**
4 如何解决分库中分布式事务问题
什么是两阶段提交
当有数据修改时,会先将修改redo log cache和binlog cache然后在刷入到磁盘形成redo log file,当redo log file全都刷入到磁盘时(prepare 状态)和提交成功后才能将binlog cache刷入磁盘,当binlog全部刷新到磁盘后会记录一个xid,然后在relo log file上打上commit标志(commit阶段)。为什么需要两阶段提交?
我们先假设没有两阶段提交时,可能会有以下两种情况:
redo log 提交成功了,这时候数据库挂掉导致 binlog 没有成功写入。数据库重启之后通过 redo log 把数据恢复回来,但是 binlog 没有成功写入,导致我们在做主从复制或者数据恢复的时候,数据不一致。
binlog 提交成功了,这时候数据库挂掉导致 redo log 没有成功写入。数据库重启之后,无法恢复崩溃之前提交的那个事务,这部分数据更改在主库缺失。但是 binlog 已经成功写入了,从库反而有了该事务的改动,导致数据不一致。
综上我们知道,redo log 和 binlog 必须同时成功或同时失败,才能保证数据一致性。
4 第四种方案就是经典的分布式事务设计中的 两阶段提交思想。两阶段提交的有三个重要的子操作:准备提交,提交,回滚。
继续拿文件导入来举例子,各个分库作为一个事务参与者 , 我们需要设计各个分库的准备提交操作,提交,回滚操作。
准备提交阶段:各个分库可以把要处理的文件明细保存到一张临时表里面,并且记住这一次事务中上下文信息。
提交阶段:把这一次事务上下文中对应的临时表数据同步到对应的明细表中
回滚阶段:删除本次事务相关的临时表流水信息。MySQL数据库如何迁移?
1.停机迁移在这个时间段,将系统停掉,将旧库的数据读出来,写到新库去,修改系统的数据库连接配置,重新部署,上线之后,验证一下系统,缺点是必须停机一段时间,如果一旦迁移没成功,必须切回原来的旧库,
2.现在比较常用的迁移方案是双写迁移,首先改造业务代码,在数据写入的时候,不仅要写入旧库也要写入新库,基于性能的考虑,可以异步地写入新库,只要保证旧库写入成功即可,需要将写入新库失败的数据记录在单独的日志中(这段时间的redog日志监控记录在一个新日志上,只要回滚就有),这样方便后续对这些数据补写,保证新库和旧库的数据一致性,然后双写功能部署上线后,用数据迁移工具,读老库数据写到新库,接着需要校验新旧库的数据,由于数据库中数据量很大,做全量的数据校验不太现实,可以抽取部分数据,具体数据量依据总体数据量来定,只要保证这些数据是一致的就可以,一次切换全量读流量可能会对系统产生未知的影响,所以最好采用灰度的方式来切换,比如开始切换 10% 的流量,如果没有问题再切换到 40% 的流量,逐渐切换到 100%,由于有双写的存在,所以在切换的过程中出现任何的问题都可以将读写流量随时切换到旧库去,保障系统的性能,再观察了一段时间之后,系统都能正常运行,数据的迁移没有问题,就可以将数据库的双写改造成只写新库,数据的迁移就完成啦分布式事务
CAP理论
在互联网领域的绝大多数的场景,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
- C(一致性):所有的节点上的数据时刻保持同步
- A(可用性):每个请求都能接受到一个响应,无论响应成功或失败
P(分区容错):系统应该能持续提供服务,即使系统内部有消息丢失(分区)
分布式锁性能低问题
1,如何对分布式锁进行高并发优化?
分段加锁的思想
把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据。不至于说,同一时间只能有一个线程独占修改ConcurrentHashMap中的数据。(根据Concurrent1.7的分段锁思路)
另外,Java 8中新增了一个LongAdder类,也是针对Java 7以前的AtomicLong进行的优化,解决的是CAS类操作在高并发场景下,使用乐观锁思路,会导致大量线程长时间重复循环。
LongAdder中也是采用了类似的分段CAS操作,失败则自动迁移到下一个分段进行CAS的思路。
分段加锁只是一个思路,但是也是存在大量的代码实现的复杂度:
某个下单请求,加锁,然后发现这个分段库存里的库存不足导致的问题,这个时候需要代码层面实现自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。这个过程一定要实现。
首先,你得对一个数据分段存储,一个库存字段本来好好的,现在要分为20个分段库存字段;
其次,你在每次处理库存的时候,还得自己写随机算法,随机挑选一个分段来处理;
最后,如果某个分段中的数据不足了,你还得自动切换到下一个分段数据去处理。二、分布式锁的设计因素
1、互斥性:同一时刻只能有一个服务(或应用)访问资源,特殊情况下有读写锁
2、原子性:一致性要求保证加锁和解锁的行为是原子性的
3、安全性:锁只能被持有该锁的服务(或应用)释放
4、容错性:在持有锁的服务崩溃时,锁仍能得到释放避免死锁
5、可重用性:同一个客户端获得锁后可递归调用
8、高可用:获取锁和释放锁 要高可用
9、高性能:获取锁和释放锁的性能要好
10、持久性:锁按业务需要自动续约/自动延期RPC原理
一般,远程过程调用RPC就是本地动态代理隐藏通信细节,通过组件序列化请求,走网络到服务端,执行真正的服务代码,然后将结果返回给客户端,反序列化数据给调用方法的过程。
RPC 只是一种概念、一种设计,就是为了解决 不同服务之间的调用问题, 它一般会包含有 传输协议 和 序列化协议 这两个。实现 RPC 的可以传输协议可以直接建立在 TCP 之上,也可以建立在 HTTP 协议之上。大部分 RPC 框架都是使用的 TCP 连接
运输层(transport layer)的主要任务就是负责向两台主机进程之间的通信提供通用的数据传输服务。TCP是传输层协议,主要解决数据如何在网络中传输。
应用层(application-layer)的任务是通过应用进程间的交互来完成特定网络应用。**
重点:
主要关键就在 HTTP 使用的 TCP 协议,和我们自定义的 TCP 协议在报文上的区别。
使用自定义 TCP 协议进行传输就会避免上面这个问题,极大地减轻了传输数据的开销接口限流防刷
思路:对接口做限流
可以把用户访问这个url的次数存入 redis中
做次数限制
key是 前缀+url路径+用户id
使用拦截器,拦截器中判断次数
实现只写一个注解,就可以对这个url判断
多少秒,多少次数,是否需要登录数据公式验证码添加
Redis缓存问题
缓存穿透
指的是对某个一定不存在的数据进行请求,该请求将会穿透缓存到达数据库
解决方案:
1接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截;
2.**布隆过滤器**布隆过滤器
。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
如果想判断一个元素是不是在一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定。布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
比如我们的数据库用户id是111,112,113,114依次递增,但是别人要攻击你,故意拿-100,-936,-545这种乱七八糟的key来查询,这时候redis和数据库这种值都是不存在的,人家每次拿的key也不一样,你就算缓存了也没用,这时候数据库的压力是相当大,比上面这种情况可怕的多,怎么办呢,这时候我们今天的主角布隆过滤器就登场了。
这里引入一种节省空间的数据结构,位图,他是一个有序的数组,只有两个值,0 和 1。0代表不存在,1代表存在。
用哈希函数有两个好处,第一是哈希函数无论输入值的长度是多少,得到的输出值长度是固定的,第二是他的分布是均匀的,如果全挤的一块去那还怎么区分,
第一种扩容,但是占内存
第二种方式就是经过多几个哈希函数的计算,你想啊,24和147现在经过一次计算就碰撞了,那我经过5次,10次,100次计算还能碰撞的话那真的是缘分了,你们可以在一起了,但也不是越多次哈希函数计算越好,因为这样很快就会填满位图,而且计算也是需要消耗时间,所以我们需要在时间和空间上寻求一个平衡。
从元素的角度来说:
如果元素实际存在,布隆过滤器一定判断存在
如果元素实际不存在,布隆过滤器可能判断存在
从容器的角度来说:
如果布隆过滤器判断元素在集合中存在,不一定存在
如果布隆过滤器判断不存在,一定不存在
原理过程图!!!!
缓存击穿
缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
解决方案:
1.设置热点数据永远不过期。
2.加互斥锁,缓存雪崩
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
解决方案:
1缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
2如果缓存数据库是分布式部署,将热点数据均匀分布在不同搞得缓存数据库中。
3设置热点数据永远不过期。Redis数据结构
Redis(1)——5种基本数据结构 - 我没有三颗心脏的博客 (wmyskxz.com)
string(字符串)、list(列表)、hash(字典)、set(集合) 和 zset(有序集合)。Redis跳表
那么会先从第顶层开始找,方式就是循环比较,如过顶层节点的下一个节点为空说明到达末尾,会跳到第二层,继续遍历,直到找到对应节点。
继续找顶层的下一个节点,发现 66 也是大于五的,继续遍历。由于下一节点为空,则会跳到 level 2。
记录下当前处在 5 这个节点,那接下来遍历是 5 往后走,发现 100 大于目标 66,所以还是继续下沉。
平均时间复杂度为O(log n)。String类型
SDS 与 C 字符串的区别
获取字符串长度为 O(N) 级别的操作 → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
不能很好的杜绝 缓冲区溢出/内存泄漏 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
C 字符串 只能保存文本数据 中间出现的 ‘\0’ 可能会被判定为提前结束的字符串而识别不了
SDS称为「简单动态字符串」len保存了字符串的长度,
- free表示buf数组中未使用的字节数量
- buf数组则是保存字符串的每一个字符元素。
1.Redis中获取字符串只要读取len的值就可,时间复杂度变为O(1)。
2.SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据
3.SDS还提供「空间预分配」和「惰性空间释放」两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能「减少连续的执行字符串增长带来内存重新分配的次数」。
4.而SDS会先根据len属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以「不会出现缓冲区溢出的情况」。
SDS 的自动扩容机制完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查 SDS 的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题
惰性空间释放机制:则用于优化 SDS 字符串缩短时并不立即使用内存重分配来回收缩短后多出来的空间,而仅仅更新 SDS 的len属性,多出来的空间供将来使用。
buf的空间并未真正被清空,新的数据可以复写,而不用重新申请内存。
Redis持久化机制(默认端口6379)
RDB持久化(默认支持,无需配置)
(原理是将Reids在内存中的数据库记录定时dump到磁盘上的RDB持久化)
实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
优点
- 启动快
- 效率高
缺点
RDB弊端:
存储数据量较大,效率较低
基于快照思想,每次读写都是全部数据,当数据量巨大时,效率非常低
大数据量下的IO性能较低
基于fork创建子进程,内存产生额外消耗
宕机带来的数据丢失风险
AOF持久化
(原理是将Reids的操作日志以追加的方式写入文件)
以日志的形式记录服务器所处理的每一个写、删除操作,查询操作不会记录,以文本的方式记录。
当客户端发出一条指令给服务器时,服务器收到并没有马上记录,而是放到临时区域:刷新缓存区,缓存区是最终存成文件时用的
优点
- 高可用! 即每秒同步
- 可读性强!AOF文件的内容非常容易被人读懂,对文件进行分析也很轻松
缺点
1). 对于相同数量的数据集而言,AOF文件通常要大于RDB文件。RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
- . 根据同步策略的不同,AOF在运行效率上往往会慢于RDB。
官方建议 两种持久化机制同时开启,如果两个同时开启 优先使用aof
不建议单独用 AOF,因为可能会出现Bug。
真出什么时候第一时间用RDB恢复,然后AOF做数据补全
Redis哨兵模式
哨兵+主从并不能保证数据不丢失,但是可以保证集群的高可用。
集群监控:负责监控 Redis master 和 slave 进程是否正常工作。
消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。
但是你让这个master机器去写,数据同步给别的slave机器,他们都拿去读,分发掉大量的请求那是不是好很多,而且扩容的时候还可以轻松实现水平扩容。
数据同步
回归正题,他们数据怎么同步的呢?
你启动一台slave 的时候,他会发送一个psync命令给master ,如果是这个slave第一次连接到master,他会触发一个全量复制。master就会启动一个线程,生成RDB快照,还会把新的写请求都缓存在内存中,RDB文件生成后,master会将这个RDB发送给slave的,slave拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存,然后master会把内存里面缓存的那些新命名都发给slave。
数据传输的时候断网了或者服务器挂了怎么办啊?
传输过程中有什么网络问题啥的,会自动重连的,并且连接之后会把缺少的数据补上的。
大家需要记得的就是,RDB快照的数据生成的时候,缓存区也必须同时开始接受新请求,不然你旧的数据过去了,你在同步期间的增量数据咋办?是吧?
Redis的过期策略,是有定期删除+惰性删除*两种。
Redis主从复制原理
11.Redis 主从同步原理
Slave 初始化中是全量同步,
- 从服务器连接主服务器,发送SYNC命令;
- 主服务器接收到SYNC命名后,开始执行BGSAVE命令生成RDB文件并使用缓冲区记 录此后执行的所有写命令;
- 主服务器BGSAVE执行完后,向所有从服务器发送快照文件,并在发送期间继续记录 被执行的写命令;
- 从服务器收到快照文件后丢弃所有旧数据,载入收到的快照;
- 主服务器快照发送完毕后开始向从服务器发送缓冲区中的写命令;
- 从服务器完成对快照的载入,开始接收命令请求,并执行来自主服务器缓冲区的写命 令;
全量之后是增量同步:指Slave初始化后开始正常工作时主服务器发生的写操作同 步到从服务器的过程。
- 一个master可以有多个slave,slave也可以有多个slave,组成树状结构2)主从同步不会阻塞master,但是会阻塞slave。也就是说当一个或多个slave与master进行初次同步数据时,master可以继续处理client发来的请求。相反slave在初次同步数据时则会阻塞不能处理client的请求;3)主从同步可以用来提高系统的可伸缩性,我们可以用多个slave专门处理client的读请求,也可以用来做简单的数据冗余或者只在slave上进行持久化从 而提升集群的整体性能。
非关系型数据库
5 关系数据库:
是建立在关系模型基础上的数据库,借助于集合代数等数学概念和方法来处理数据库中的数据。简单说来就是关系型数据库用了选择、投影、连接、并、交、差、除、增删查改等数学方法来实现对数据的存储和查询。可以用SQL语句方便的在一个表及其多个表之间做非常复杂的数据查询。安全性高。
其中server层包括连接池、查询缓存、分析器、优化器等部分,
重点:表与表之间有关系,1对多(靠主外键),多对多(靠中间表)2.非关系型数据库:
简称NOSQL,是基于键值对的对应关系,并且不需要经过SQL层的解析,所以性能非常高。但是不适合用在多表联合查询和一些较复杂的查询中。NoSQL用于超大规模数据的存储。
重点:表与表之间无关系,比如Redis,key,valueSpringboot循环依赖问题
什么情况下循环依赖可以被处理?
在回答这个问题之前首先要明确一点,Spring解决循环依赖是有前置条件的
1.出现循环依赖的Bean必须要是单例
2.依赖注入的方式不能全是构造器注入的方式(很多博客上说,只能解决setter方法的循环依赖,这是错误的)
关于循环依赖的解决方式应该要分两种情况来讨论
1.简单的循环依赖(没有AOP)
2.结合了AOP的循环依赖
与此同时,我们应该知道,Spring在创建Bean的过程中分为三步
1.实例化,简单理解就是new了一个对象
2.属性注入,为实例化中new出来的对象填充属性
3.初始化,执行aware接口中的方法,初始化方法,完成AOP代理
循环依赖原因
原因很好理解,创建新的A时,发现要注入原型字段B,又创建新的B发现要注入原型字段A…
这就套娃了, 你猜是先StackOverflow还是OutOfMemory?
Spring怕你不好猜,就先抛出了BeanCurrentlyInCreationException三级缓存
singletonObjects:第一级缓存,里面存放的都是创建好的成品Bean。
earlySingletonObjects : 第二级缓存,里面存放的都是半成品的Bean。
singletonFactories :第三级缓存, 不同于前两个存的是 Bean对象引用,此缓存存的bean 工厂对象,也就存的是 专门创建Bean的一个工厂对象。此缓存用于解决循环依赖
- singletonObjects:成品缓存
- earlySingletonObjects: 半成品缓存
- singletonFactories :单例工厂缓存
循环依赖的关键点:**提前暴露绑定A原始引用的工厂类到工厂缓存。等需要时触发后续操作处理A的早期引用,将处理结果放入二级缓存**
3.为啥需要三个缓存
Spring 为啥用三个缓存去解决循环依赖问题?
上面两个缓存的地方,我们只是没有考虑代理的情况。
代理的存在
Bean在创建的最后阶段,会检查是否需要创建代理,如果创建了代理,那么最终返回的就是代理实例的引用。我们通过beanname获取到最终是代理实例的引用
也就是说:上文中,假设A最终会创建代理,提前暴露A的引用, B填充属性时填充的是A的原始对象引用。A最终放入成品库里是代理的引用。那么B中依然是A的早期引用。这种结果最终会与我们的期望的大相径庭了。
Spring生命周期
Bean 的生命周期概括起来就是 4 个阶段:
- 实例化(Instantiation)
- 属性赋值(Populate)
- 初始化(Initialization)
- 销毁(Destruction)
Bean 容器找到配置文件中 Spring Bean 的定义。
Bean 容器利用 Java Reflection API 创建一个Bean的实例。
如果涉及到一些属性值 利用 set()方法设置一些属性值。
如果 Bean 实现了 BeanNameAware 接口,调用 setBeanName()方法,传入Bean的名字。
如果 Bean 实现了 BeanClassLoaderAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoader对象的实例。
如果Bean实现了 BeanFactoryAware 接口,调用 setBeanClassLoader()方法,传入 ClassLoade r对象的实例。
与上面的类似,如果实现了其他 *.Aware接口,就调用相应的方法。
如果有和加载这个 Bean 的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessBeforeInitialization() 方法
如果Bean实现了InitializingBean接口,执行afterPropertiesSet()方法。
如果 Bean 在配置文件中的定义包含 init-method 属性,执行指定的方法。
如果有和加载这个 Bean的 Spring 容器相关的 BeanPostProcessor 对象,执行postProcessAfterInitialization() 方法
当要销毁 Bean 的时候,如果 Bean 实现了 DisposableBean 接口,执行 destroy() 方法。
当要销毁 Bean 的时候,如果 Bean 在配置文件中的定义包含 destroy-method 属性,执行指定的方法
4. 总结
最后总结下如何记忆 Spring Bean 的生命周期:
- 首先是实例化、属性赋值、初始化、销毁这 4 个大阶段;
- 再是初始化的具体操作,有 Aware 接口的依赖注入、BeanPostProcessor 在初始化前后的处理以及 InitializingBean 和 init-method 的初始化操作;
- 销毁的具体操作,有注册相关销毁回调接口,最后通过DisposableBean 和 destory-method 进行销毁
Spring 框架中用到了哪些设计模式?
工厂设计模式 : Spring使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。
代理设计模式 : Spring AOP 功能的实现。
单例设计模式 : Spring 中的 Bean 默认都是单例的。
模板方法模式 : Spring 中 jdbcTemplate、hibernateTemplate 等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
适配器模式 :Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配Controller。@Component 和 @Bean 的区别是什么?
控制反转,简单点说,就是创建对象的控制权,被反转到了Spring框架上。
通常,我们实例化一个对象时,都是使用类的构造方法来new一个对象,这个过程是由我们自己来控制的,而控制反转就把new对象的工交给了Spring容器。
依赖注入,组件不做定位查询,只提供标准的Java方法让容器去决定依赖关系。容器全权负责组件的装配,把符合依赖关系的对象通过Java Bean属性或构造方法传递给需要的对象。
依赖注入:由IoC容器动态地将某个对象所需要的外部资源(包括对象、资源、常量数据)注入到组件(Controller, Service等)之中。简单点说,就是IoC容器会把当前对象所需要的外部资源动态的注入给我们。
Spring依赖注入的方式主要有四个,基于注解注入方式、set注入方式、构造器注入方式、静态工厂注入方式。推荐使用基于注解注入方式,配置较少,比较方便。
@Autowired默认按类型进行自动装配(该注解属于Spring),默认情况下要求依赖对象必须存在,如果要允许为null,需设置required属性为false,例:@Autowired(required=false)。如果要使用名称进行装配,可以与@Qualifier注解一起使用。
在 bean 实例化完成、属性注入完成之后,会执行回调方法,具体请参见类 AbstractAutowireCapableBeanFactory#initBean 方法。
首先会回调几个实现了 Aware 接口的 bean,然后就开始回调 BeanPostProcessor 的 postProcessBeforeInitialization 方法,之后是回调 init-method,然后再回调 BeanPostProcessor 的 postProcessAfterInitialization 方法
这里将会初始化 BeanFactory、加载 Bean、注册 Bean 等等。这个方法将根据配置,加载各个 Bean,然后放到 BeanFactory 中。读取配置的操作在 XmlBeanDefinitionReader 中,其负责加载配置、解析。
id 为 “car” 的 bean 其实指定的是一个 FactoryBean,不过配置的时候,我们直接让配置 Person 的 Bean 直接依赖于这个 FactoryBean 就可以了。
Spring面向切面编程(AOP)
应用场景:记录日志、异常处理等操作,AOP把所有共用代码都剥离出来,单独放置到某个类中进行集中管理,在具体运行时,由容器进行动态织入这些公共代码。
Redis五种数据结构
1、Hash类型(ziplist和hashtable)
Hash对象的实现方式有两种分别是ziplist和hashtable,其中hashtable的存储方式key是String类型的,value也是以key value的形式进行存储。
字典类型的底层就是hashtable实现的,明白了字典的底层实现原理也就是明白了hashtable的实现原理,hashtable的实现原理可以与HashMap的是底层原理相类比。
字典
两者在新增时都会通过key计算出数组下标,不同的是计算法方式不同:
HashMap中是以hash函数的方式,
hashtable中计算出hash值后,还要通过sizemask 属性和哈希值再次得到数组下标。
我们知道hash表最大的问题就是hash冲突,为了解决hash冲突,假如hashtable中不同的key通过计算得到同一个index,就会形成单向链表(「链地址法」),如下图所示:
rehash
在字典的底层实现中,value对象以每一个dictEntry的对象进行存储,当hash表中的存放的键值对不断的增加或者减少时,需要对hash表进行一个扩展或者收缩。
这里就会和HashMap一样也会就进行rehash操作,进行重新散列排布。从上图中可以看到有ht[0]和ht[1]两个对象,先来看看对象中的属性是干嘛用的。
在hash表结构定义中有四个属性分别是:
「哈希表数组、hash表大小、用于计算索引值,总是等于size-1、hash表中已有的节点数」。
渐进式rehash(有点类似于垃圾回收的form和to区)
h[1] 不断扩大,h[0] 和h[1]不断调换,
假如在rehash的过程中数据量非常大,Redis不是一次性把全部数据rehash成功,这样会导致Redis对外服务停止,Redis内部为了处理这种情况采用「渐进式的rehash」。
ht[0]是用来最开始存储数据的,当要进行扩展或者收缩时,ht[0]的大小就决定了ht[1]的大小,ht[0]中的所有的键值对就会重新散列到ht[1]中。
扩展操作:ht[1]扩展的大小是比当前 ht[0].used 值的二倍大的第一个 2 的整数幂;收缩操作:ht[0].used 的第一个大于等于的 2 的整数幂。
当ht[0]上的所有的键值对都rehash到ht[1]中,会重新计算所有的数组下标值,当数据迁移完后ht[0]就会被释放,然后将ht[1]改为ht[0],并新创建ht[1],为下一次的扩展和收缩做准备。
应用场景
存储用户数据
第一个场景比如我们要储存用户信息,一般使用用户的ID作为key值,保持唯一性,用户的其他信息(地址、年龄、生日、电话号码等)作为value值存储。
再压缩列表中每一个entry节点又有三部分组成,包括previous_entry_ength、encoding、content。
previous_entry_ength表示前一个节点entry的长度,可用于计算前一个节点的其实地址,因为他们的地址是连续的。
content:content保存的是每一个节点的内容
encoding:这里保存的是content的内容类型和长度。
ziplist
压缩列表(ziplist)是一组连续内存块组成的顺序的数据结构,压缩列表能够节省空间,压缩列表中使用多个节点来存储数据。
压缩列表是列表键和哈希键底层实现的原理之一,「压缩列表并不是以某种压缩算法进行压缩存储数据,而是它表示一组连续的内存空间的使用,节省空间」,压缩列表的内存结构图如下:
为什么要优先使用ziplist压缩列表?
- 压缩列表是非常紧凑的数据结构,占用的内存已经压缩的非常小了,可以提高内存的利用率。
- 压缩列表底层是连续的内存空间,对CPU高速缓存支持更友好,提高查询效率。
hash对象保存的键和值字符串长度都小于64字节
hash对象保存的键值对数量小于512 ziplist
2.List
Redis中链表的特性:
- 每一个节点都有指向前一个节点和后一个节点的指针。
- 头节点和尾节点的prev和next指针指向为null,所以链表是无环的。
- 链表有自己长度的信息,获取长度的时间复杂度为O(1)。
应用场景
Redis中的列表可以实现「阻塞队列」,结合lpush和brpop命令就可以实现。生产者使用lupsh从列表的左侧插入元素,消费者使用brpop命令从队列的右侧获取元素进行消费。3、set集合「ht和intset」
Redis中列表和集合都可以用来存储字符串,但是「Set是不可重复的集合,而List列表可以存储相同的字符串」,Set集合是无序的这个和后面讲的ZSet有序集合相对。
Set的底层实现是「ht和intset」,ht(哈希表)前面已经详细了解过,下面我们来看看inset类型的存储结构。
当满足如下两个条件的时候,采用整数集合实现,否则用哈希表。
集合中的所有元素都为整数
集合中的元素个数不大于 512
可以通过修改 set-max-intset-entries调整集合大小,默认512
inset也叫做整数集合,用于保存整数值的数据结构类型,它可以保存int16_t、int32_t 或者int64_t 的整数值。
在整数集合中,有三个属性值encoding、length、contents[],分别表示编码方式、整数集合的长度、以及元素内容,length就是记录contents里面的大小。
在整数集合新增元素的时候,若是超出了原集合的长度大小,就会对集合进行升级,具体的升级过程如下:
首先扩展底层数组的大小,并且数组的类型为新元素的类型。
然后将原来的数组中的元素转为新元素的类型,并放到扩展后数组对应的位置。
整数集合升级后就不会再降级,编码会一直保持升级后的状态。
其中hashtable的key为set中元素的值,而value为null
inset为可以理解为数组,使用inset数据结构需要满足下述两个条件:元素个数不少于默认值512应用场景
Set集合的应用场景可以用来「去重、抽奖、共同好友、二度好友」等业务类型。4、ZSet集合(ziplist或skiplist)
ZSet是有序集合,从上面的图中可以看到ZSet的底层实现是ziplist和skiplist实现的,ziplist上面已经详细讲过,这里来讲解skiplist的结构实现。
skiplist也叫做「跳跃表」,跳跃表是一种有序的数据结构,它通过每一个节点维持多个指向其它节点的指针,从而达到快速访问的目的。
skiplist有如下几个特点:
有很多层组成,由上到下节点数逐渐密集,最上层的节点最稀疏,跨度也最大。
每一层都是一个有序链表,至少包含两个节点,头节点和尾节点。
每一层的每一个每一个节点都含有指向同一层下一个节点和下一层同一个位置节点的指针。
如果一个节点在某一层出现,那么该以下的所有链表同一个位置都会出现该节点。
具体实现的结构图如下所示:
level表示层数,
len表示跳跃表的长度,
BW表示后退指针,在从尾向前遍历的时候使用。
zskiplist编码分为两部分,dict+ zskiplist,dict和跳跃表都存储数据,实际上 dict 和跳跃表最终使用指针都指向了同一份数据,即数据是被两部分共享的,dict结构,主要key是其集合元素,而value就是对应分值,而zkiplist作为跳跃表,按照分值排序,方便定位成员。
应用场景
因为ZSet是有序的集合,因此ZSet在实现排序类型的业务是比较常见的,比如在首页推荐10个最热门的帖子,也就是阅读量由高到低,排行榜的实现等业务。
1.排行榜
排行榜是非常常见的实现,比如收入排行榜,积分排行榜。在某种情况下,可以直接缓存整个排行榜,然后设置定时任务,在某个时间点更新。但对于一些实时性比较强的,需要及时更新数据,可以利用redis的有序队列实现,原理是利用有序队列的关联评分。
PS:如果要自己实现,可以考虑使用二叉堆实现,效率能到到O(log(N))
具体实现:zadd 排行榜名字 89 逆战 99 稻香
Redis-有序集合对象(zset)
zset为有序(有限score排序,score相同则元素字典序),自动去重的集合数据类型,其底层实现为 字典(dict) + 跳表(skiplist),当数据比较少的时候用ziplist编码结构存储。
同时满足以下两个条件采用ziplist存储:有序集合保存的元素数量小于默认值128个
有序集合保存的所有元素的长度小于默认值64字节
ziplist存储方式
当ziplist作为zset的底层存储结构时候,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个元素保存元素的分值
字典(dict) + 跳表(skiplist)的存储方式
zset底层的存储结构包括ziplist或skiplist,在同时满足以下两个条件的时候使用ziplist,其他时候使用skiplist,两个条件如下:
有序集合保存的元素数量小于128个 有序集合保存的所有元素的长度小于64字节5.String类型
SDS 与 C 字符串的区别
获取字符串长度为 O(N) 级别的操作 → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
不能很好的杜绝 缓冲区溢出/内存泄漏 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
C 字符串 只能保存文本数据 中间出现的 ‘\0’ 可能会被判定为提前结束的字符串而识别不了
SDS称为「简单动态字符串」
- len保存了字符串的长度,
- free表示buf数组中未使用的字节数量
- buf数组则是保存字符串的每一个字符元素。
1.Redis中获取字符串只要读取len的值就可,时间复杂度变为O(1)。
2.SDS是二进制安全的,除了可以储存字符串以外还可以储存二进制文件(如图片、音频,视频等文件的二进制数据
3.SDS还提供「空间预分配」和「惰性空间释放」两种策略。在为字符串分配空间时,分配的空间比实际要多,这样就能「减少连续的执行字符串增长带来内存重新分配的次数」。
4.而SDS会先根据len属性判断空间是否满足要求,若是空间不够,就会进行相应的空间扩展,所以「不会出现缓冲区溢出的情况」。
SDS 的自动扩容机制完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查 SDS 的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题
惰性空间释放机制:则用于优化 SDS 字符串缩短时并不立即使用内存重分配来回收缩短后多出来的空间,而仅仅更新 SDS 的len属性,多出来的空间供将来使用。
buf的空间并未真正被清空,新的数据可以复写,而不用重新申请内存。
应用场景
1.单值缓存:商品库存,key=商品id,value=库存数量
2.对象缓存:
1). set 存储用户信息,key=user:id value=json格式数据
2). mset 批量存储用户信息,适用于数据不断变化的应用场景,
如用户微信余额,存取方便,效率高
3.分布式锁:
适用场景:在一个集群环境下,多个web应用时对同一个商品进行抢购和减库存操作时,
可能出现超卖时会用到分布式锁
SETNX命令(SET if Not eXists)
语法:SETNX key value
功能:当且仅当 key 不存在,将 key 的值设为 value ,并返回1;
若给定的 key 已经存在,则 SETNX 不做任何动作,并返回0。