- 1 哪里应用分布式了?
- 什么会造成分布式事务问题
- 跨域问题
- 什么是反向代理?
- 1.3.2.生成ID的方式
- 各服务调用的顺序性如何保证
- 本地消息表+MQ
- 本地消息表(事务)
- Mq加本地消息表保证分布式事务
- 参考博客
- 延时双删
- 消息丢失,顺序,重试,堆积,幂等问题及解决测试
- 5 秒杀项目做过压测吗?压测多少QPS?
- Redis缓存问题
- 超卖问题
- 上面这种简单的实现有以下几个问题:
- 缺点
- 二、基于缓存的分布式锁
- 共性问题
- Zookeeper实现的分布式锁">7 基于Zookeeper实现的分布式锁
- Redis如何实现分布式锁?
- 分布式锁提高性能方法
- 库存超卖现象是怎么产生的?
- 分段锁思想
- LRU的策略,Redis的5种数据结构?
- 双主同步
- 为什么要分库分表?
- 手动分表
- 分库同步过程
- 一致性哈希
- 小林的一致性哈希(正确)
1 哪里应用分布式了?
什么会造成分布式事务问题
但是因为整个下单是属于一个transaction事务,如果用户下单成功,但是之后订单入库或返回前端的过程中失败,事务回滚,会导致少卖的现象,有可能造成库存堆积
我们还可以利用RocketMQ的事务型消息来解决分布式事务问题。首先来看RocketMQ消息的事务架构设计
跨域问题
虽然域名解决了,但是现在如果我们要访问,还得自己加上端口:http://manage.taotao.com:9001。
这就不够优雅了。我们希望的是直接域名访问:http://manage.taotao.com。这种情况下端口默认是80,如何才能把请求转移到9001端口呢?
配置配置文件就好
跨域不一定会有跨域问题。
因为跨域问题是浏览器对于ajax请求的一种安全限制:一个页面发起的ajax请求,只能是于当前页同域名的路径,这能有效的阻止跨站攻击
。优势:
- 在服务端进行控制是否允许跨域,可自定义规则
- 支持各种请求方式
2 跨域问题
为什么我的项目会产生跨域?
因为是前后端分离的,前端静态是部署在Nginx服务器上的,而后端实现动态用了两个服务器,服务器不一样时,ip地址不一样,域名不一样,自然就产生了跨域问题
什么是跨域?
CORS 是跨域资源分享(Cross-Origin Resource Sharing)的缩写。它是 W3C 标准,属于跨源 AJAX 请求的根本解决方法。
当一个请求url的协议、域名、端口三者之间任意一个与当前页面url不同即为跨域
浏览器只是针对同源策略的一种实现。同源策略会阻止一个域的javascript脚本和另外一个域的内容进行交互。所谓同源(即指在同一个域)就是两个页面具有相同的协议(protocol),主机(host)和端口号(port)
带cookie跨域请求:前后端都需要进行设置
【服务端设置】
服务器端对于CORS的支持,主要是通过设置Access-Control-Allow-Origin来进行的。如果浏览器检测到相应的设置,就可以允许Ajax进行跨域的访问。
什么是反向代理?
- 代理:通过客户机的配置,实现让一台服务器代理客户机,客户的所有请求都交给代理服务器处理。
- 反向代理:用一台服务器,代理真实服务器,用户访问时,不再是访问真实服务器,而是代理服务器。
nginx可以当做反向代理服务器来使用:
- 我们需要提前在nginx中配置好反向代理的规则,不同的请求,交给不同的真实服务器处理
- 当请求到达nginx,nginx会根据已经定义的规则进行请求的转发,从而实现路由功能解决反向代理会话问题
这里就要用到反向代理工具:Nginx
也可以用Nginx反向代理固定一个服务器,哈希,一致性哈希。
Redis会话
1.3.2.生成ID的方式
第一位:为未使用
第二部分:41位为毫秒级时间(41位的长度可以使用69年)
第三部分:5位datacenterId和5位workerId(10位的长度最多支持部署1024个节点)
第四部分:最后12位是毫秒内的计数(12位的计数顺序号支持每个节点每毫秒产生4096个ID序号)
snowflake生成的ID整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由datacenter和workerId作区分),并且效率较高。经测试snowflake每秒能够产生26万个ID。
说完优点后,再来分析一下,RPC的缺点:
- 耦合性强: 相较于 RESTful而言,RPC 框架在跨语言的场景下实现比较困难。并且版本依赖比较强。服务脱离了当前内网环境后,无法正常提供服务,迁移成本高。
- 内网调用: RPC更适合内网传输,在公网环境下,显得没那么安全。
各服务调用的顺序性如何保证
1、面试题
分布式服务接口请求的顺序性如何保证?
2、面试官心里分析
其实分布式系统接口的调用顺序,也是个问题,一般来说是不用保证顺序的。但是有的时候可能确实是需要严格的顺序保证。给大家举个例子,你服务A调用服务B,先插入再删除。好,结果俩请求过去了,落在不同机器上,可能插入请求因为某些原因执行慢了一些,导致删除请求先执行了,此时因为没数据所以啥效果也没有;结果这个时候插入请求过来了,好,数据插入进去了,那就尴尬了。
本来应该是先插入 -> 再删除,这条数据应该没了,结果现在先删除 -> 再插入,数据还存在,最后你死都想不明白是怎么回事。
所以这都是分布式系统一些很常见的问题。
3、面试题剖析
首先,一般来说,我个人给你的建议是,你们从业务逻辑上最好设计的这个系统不需要这种顺序性的保证,因为一旦引入顺序性保障,会导致系统复杂度上升,而且会带来效率低下,热点数据压力过大,等问题。
下面我给个我们用过的方案吧,简单来说,首先你得用dubbo的一致性hash负载均衡策略,将比如某一个订单id对应的请求都给分发到某个机器上去,接着就是在那个机器上因为可能还是多线程并发执行的,你可能得立即将某个订单id对应的请求扔一个内存队列里去,强制排队,这样来确保他们的顺序性。
但是这样引发的后续问题就很多,比如说要是某个订单对应的请求特别多,造成某台机器成热点怎么办?解决这些问题又要开启后续一连串的复杂技术方案。曾经这类问题弄的我们头疼不已,所以,还是建议什么呢?
最好是比如说刚才那种,一个订单的插入和删除操作,能不能合并成一个操作,就是一个删除,或者是什么,避免这种问题的产生。
如何设置秒杀,未支付,先保存15分钟,后边24小时之后删除
设置消息过ttl15分钟,过期之后放在死信队列里,如果支付状态改变,回查死信队列,getMessageId,获取消息,处理消息,如果死信队列里也没有,就直接返回。
本地消息表+MQ
本地消息表(事务)
本地消息表方案应该是业界内使用最为广泛的,因为它使用简单,成本比较低。本地消息表的方案最初是由eBay提出(完整方案),核心思路是将分布式事务拆分成本地事务进行处理。
它的处理流程如下:
事务发起方把要处理的业务事务和写消息表这两个操作放在同一个本地事务里
事务发起方有一个定时任务轮询消息表,把没处理的消息发送到消息中间件
事务被动方从消息中间件获取消息后,返回成功
事务发起方更新消息状态为已成功
Redis成功减库存之后记录表,id 商品id ,Redis状态,0为减成功,1为失败,失败就回滚+1、。删表,删除订单。数据库先查这个表,如果Redis状态为0,开始减库存,如果失败就删表,Redis减1,回滚
为什么用RocketMq?
但是因为整个下单是属于一个transaction事务,如果用户下单成功,但是之后订单入库或返回前端的过程中失败,事务回滚,会导致**少卖**的现象,有可能造成库存堆积
包括spring事务失效可能引起本地事务失败
spring事务失效场景
原理:利用Aop增加通知,把MySQL的自动提交关闭
执行完spring事务在整体提交实现,如果出现用原类对象直接调用,比如互相调用方法时
类里边有a ,b,d调用a的方法,b加了事务,但是事务是不生效的,因为相当于直接用原型bean调用了。
解决利用当前bean的代理对象去调用,注意启动类要加注解显示代理类一定要注意启动类上要添加@EnableAspectJAutoProxy(exposeProxy = true)注解,否则启动报错:()
注解修饰的方法被类内部方法调用
这种失效场景是我们日常开发中最常踩坑的地方;在类A里面有方法a 和方法b, 然后方法b上面用 @Transactional加了方法级别的事务,在方法a里面 调用了方法b, 方法b里面的事务不会生效。
为什么会失效呢?:其实原因很简单,Spring在扫描Bean的时候会自动为标注了@Transactional注解的类生成一个代理类(proxy),当有注解的方法被调用的时候,实际上是代理类调用的,代理类在调用之前会开启事务,执行事务的操作,但是同类中的方法互相调用,相当于this.B(),此时的B方法并非是代理类调用,而是直接通过原有的Bean直接调用,所以注解会失效。,此时的B方法并非是代理类调用,而是直接通过原有的Bean直接调用,所以注解会失效。)
非public权限修饰
数据库存储引擎不支持事务
Mq加本地消息表保证分布式事务
延时双删。
Mq保证的是下单与数据库减库存一致性,
半消息机制加消息回查机制
半消息保证一定事务,回查补偿,防止确认没到。
保持性能异步发送到broker,然后是半消息,知道broker确认,然后消息显露,可以消费,然后如果网络原因,确认失效,这时候回查三个状态,回滚,未知,成功。消费端消息也确认,防止幂等性,构建消费表,天然幂等性解释,分布式唯一ID,雪花算法。每次查id,如果存在就删除,不存在就创建消费。
半消息此时回查本地事务,如果成功就显露消息。订单取消就改事务消息表。
为什么Mq和本地消费表要弄成两个,
因为有的可能订单都没成功,就不用考虑什么一致性问题了,直接删除这个订单就OK了,如果都设置成一个,回滚的情况会特别多,耗费性能。两个事务的话可以异步,主要是为了保证性能,下单成功,可以干别的去了,因为下单已经提交了,这时候可以慢慢减库存,但是如果都是一个事务的话,好不容易下单成功了,你事务没提交又不返回,最后减库存又失败了,这个过程要等特别长时间。所以分开两个事物,下单操作比减库存操作更频繁,因为有人下单撤销,
两阶段提交协议
3 如何解决分库中分布式事务问题
什么是两阶段提交
当有数据修改时,会先将修改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 提交成功了,这时候数据库挂掉导致 r**edo log 没有成功写入。**数据库重启之后,无法恢复崩溃之前提交的那个事务,这部分数据更改在主库缺失。但是 binlog 已经成功写入了,从库反而有了该事务的改动,导致数据不一致。
综上我们知道,redo log 和 binlog** 必须同时成功或同时失败,才能保证数据一致性。**
参考博客
如何保持mysql和redis中数据的一致性? - 知乎 (zhihu.com)
延时双删
数据过期
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
4 如何实现延时?
比较一般的: 创建线程池,线程池中拿一个线程,线程体中延时3-5s再去执行最后一步任务(不能忘了启动线程)
- 比较差的: 单独创建一个线程去实现延时执行
消息丢失,顺序,重试,堆积,幂等问题及解决测试
一次完整的通信流程是怎样的?
Producer 与 NameServer集群中的其中一个节点(随机选择)建立长连接,定期从 NameServer 获取 Topic 路由信息,并向提供 Topic 服务的 Broker Master 建立长连接,且定时向 Broker 发送心跳。
Producer 只能将消息发送到 Broker master,但是 Consumer 则不一样,它同时和提供 Topic 服务的 Master 和 Slave建立长连接,既可以从 Broker Master 订阅消息,也可以从 Broker Slave 订阅消息。
消息去重
同步默认去重试两次,异步只重试一次。也可设置重试次数为0,保证效率
单向发送(无重试)保证效率单向发送是指只负责发送消息而不等待服务器回应且没有回调函数触发,适用于某些耗时非常短但对可靠性要求并不高的场景,例如日志收集。
并不是所有异常都会去重试,只有生产者客户端异常,broker,消费端等发生异常才回去重试,比如超时异常就直接返回失败
应用场景:适用于某些耗时非常短,但对可靠性要求并不高的场景,例如日志收集。
通过这段源码很明显可以看出以下几点
- 如果是异步发送 那么重试次数只有1次
- 对于同步而言,超时异常也是不会再去重试。
- 如果发生重试是在一个for 循环里去重试,所以它是立即重试而不是隔一段时间去重试。
注重消费端重试
一般设置成这样的代码这里的代码意思很明显: 主动抛出一个异常,然后如果超过3次,那么就不继续重试下去,而是将该条记录保存到数据库由人工来兜底。
Timeout
说明 这里的超时异常并非真正意义上的超时,它指的是指获取消息后,因为某种原因没有给RocketMQ返回消费的状态,那么 RocketMQ会认为该消息没有发送,会一直发送。因为它会认为该消息根本就没有发送给消费者,所以肯定没消费。
及时关掉进程,再次重启之后还是会消费的,当获得 当前消费重试次数为 = 0 后 , 关掉该进程。再重新启动该进程,那么依然能够获取该条消息
消费者默认是重试16次,16次之后就不再重试。
并且重试时间间隔逐步增加1s,5s,10s,30s,1m,2m,3m,4m,5m,6m,7m,8m,9m,10m,20m,30m,1h,2h
消息订阅:
过滤,根据tag标签过滤
* 1.匹配所有tag,
去重原则:使用业务端逻辑保持幂等性
幂等性:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用,数据库的结果都是唯一的,不可变的。
只要保持幂等性,不管来多少条重复消息,最后处理的结果都一样,需要业务端来实现。
去重策略:
保证每条消息都有唯一编号(比如唯一流水号),且保证消息处理成功与去重表的日志同时出现。
建立一个消息表,拿到这个消息做数据库的insert操作。给这个消息做一个唯一主键(primary key)或者唯一约束,那么就算出现重复消费的情况,就会导致主键冲突,那么就不再处理这条消息。
消息重复
消息领域有一个对消息投递的QoS定义,分为:
最多一次(At most once)
至少一次(At least once)
仅一次( Exactly once)
RocketMQ没有内置消息去重的解决方案,最新版本是否支持还需确认。
不同的消息队列发送的确认信息形式不同,例如RabbitMQ是发送一个ACK确认消息,RocketMQ是返回一个CONSUME_SUCCESS成功标志,Kafka实际上有个offset的概念。
消息的可用性
RocketMQ对消息的刷盘提供了同步和异步的策略来满足我们的,当我们选择同步刷盘之后,如果刷盘超时会给返回FLUSH_DISK_TIMEOUT,如果是异步刷盘不会返回刷盘相关信息,选择同步刷盘可以尽最大程度满足我们的消息不会丢失。
RocketMQ采用的是混合型的存储结构,即为Broker单个实例下所有的队列共用一个日志数据文件(即为CommitLog)来存储。
而Kafka采用的是独立型的存储结构,每个队列一个文件。
这里帅丙认为,RocketMQ采用混合型存储结构的缺点在于,会存在较多的随机读操作,因此读的效率偏低。同时消费消息需要依赖ConsumeQueue,构建该逻辑消费队列需要一定开销。
RocketMQ 刷盘实现
Broker 在消息的存取时直接操作的是内存(内存映射文件)
顺序消息:
在某些业务中,consumer在消费消息时,是需要按照生产者发送消息的顺序进行消费的,比如在电商系统中,订单的消息,会有创建订单、订单支付、订单完成,如果消息的顺序发生改变,那么这样的消息就没有意义了。 生产者,消费者都要保证,从同一个队列
这玩意是阿里开源的,生产者消费者一般需要保证顺序消息的话,可能就是一个业务场景下的,比如订单的创建、支付、发货、收货。
那这些东西是不是一个订单号呢?一个订单的肯定是一个订单号的说,那简单了呀。
一个topic下有多个队列,为了保证发送有序,RocketMQ提供了MessageQueueSelector队列选择机制,他有三种实现: 用唯一id进行hash取模,hash值不唯一
我们可使用Hash取模法,让同一个订单发送到同一个队列中,再使用同步发送,只有同个订单的创建消息发送成功,再发送支付消息。这样,我们保证了发送有序。
RocketMQ的topic内的队列机制,可以保证存储满足FIFO(First Input First Output 简单说就是指先进先出),剩下的只需要消费者顺序消费即可。
RocketMQ仅保证顺序发送,顺序消费由消费者业务保证!!!
这里很好理解,一个订单你发送的时候放到一个队列里面去,你同一个的订单号Hash一下是不是还是一样的结果,那肯定是一个消费者消费,那顺序是不是就保证了?
具体实现
生产者 每次根据唯一id hash取模队列个数,传入选择队列中
消费者 重写接口的listenOrder方法就可
RocketMQ架构
RocketMQ(1)-架构原理 - 雨点的名字 - 博客园 (cnblogs.com)
不采取广播模式
(1)广播消费模式下不支持顺序消息。
(2)广播消费模式下不支持重置消费位点。
(3)每条消息都需要被相同逻辑的多台机器处理。
(4)广播模式下, 消息队列 RocketMQ 保证每条消息至少被每台客户端消费一次, 但是并不会对消费失败的消息进行失败重投, 因此业务方需要关注消费失败的情况。
(5)广播模式下, 客户端每一次重启都会从最新消息消费。 客户端在被停止期间发送至服务端的消息将会被自 动跳过, 请谨慎选择。
(6)广播模式下, 每条消息都会被大量的客户端重复处理, 因此推荐尽可能使用集群模式。
5 秒杀项目做过压测吗?压测多少QPS?
采用Jmeter开启5000个线程,每个线程执行10次,5000个用户进行秒杀抢购
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设置热点数据永远不过期。
超卖问题
分布式锁
我们需要怎么样的分布式锁?
可以保证在分布式部署的应用集群中,同一个方法在同一时间只能被一台机器上的一个线程执行。
这把锁要是一把可重入锁(避免死锁)
这把锁最好是一把阻塞锁(根据业务需求考虑要不要这条)需要考虑
这把锁最好是一把公平锁(根据业务需求考虑要不要这条)不需要
有高可用的获取锁和释放锁功能
获取锁和释放锁的性能要好
死锁
可重入锁最大的作用是避免死锁
可重入锁,也叫递归锁。
“重入”将获取锁的粒度由“调用”转变为“线程”,即当一个线程请求一个未持有的锁,该线程将顺利得到锁并被记录,且将计数值从0变为1;当下次同一个线程再次请求这把锁,该线程无需排队,而是直接得到锁,且计数值由1变为2,仅仅是数量上的累加;每次退出一个线程,计数值-1,直到计数值为0,这把锁将被释放。
如下:两个方法同时锁,如果首先执行method1,则该线程拥有Test对象的锁,但如果synchronized不是可重入锁,当method1方法调用method2,发现method2需要等待method1锁释放,但是method1的执行又必须依赖method2,于是循环等待,形成死锁。
6 基于数据库实现的分布式锁
基于表实现的分布式锁[非阻塞]
秒杀下单操作的方法名,备注信息,保存数据的时间,newdate自动生成
每个主机进行下单操作之前都需要去插入这个表,因为对这个表中方法名字段,做了唯一约束,因此只能有一个主机进程插入成功,所以实现了线程安全
当我们想要锁住某个方法时,执行以下SQL: insert into methodLock(method_name,desc) values (‘method_name’,‘desc’)
因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。
当方法执行完毕之后,想要释放锁的话,需要执行以下Sql: delete from methodLock where method_name =’method_name’
上面这种简单的实现有以下几个问题:
这把锁强依赖数据库的可用性,数据库是一个单点,一旦数据库挂掉,会导致业务系统不可用。问题?如何设置主从数据库
这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在数据库中,其他线程无法再获得到锁。
这把锁只能是非阻塞的,因为数据的insert操作,一旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
这把锁是非重入的,同一个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。
这把锁是非公平锁,所有等待锁的线程凭运气去争夺锁。
当然,我们也可以有其他方式解决上面的问题。
数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
没有失效时间?只要做一个定时任务,每隔一定时间把数据库中的超时数据清理一遍。
非阻塞的?搞一个while循环,直到insert成功再返回成功。
非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。
非公平的?再建一张中间表,将等待锁的线程全记录下来,并根据创建时间排序,只有最先创建的允许获取锁
缺点
会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
操作数据库需要一定的开销,性能问题需要考虑。
二、基于缓存的分布式锁
相比较于基于数据库实现分布式锁的方案来说,基于缓存来实现在性能方面会表现的更好一点。
目前有很多成熟的缓存产品,包括Redis,memcached等。这里以Redis为例来分析下使用缓存实现分布式锁的方案。
基于Redis实现分布式锁在网上有很多相关文章,其中主要的实现方式是使用Jedis.setNX方法来实现。
SETNX key value
含义(setnx = SET if Not eXists):
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
返回值:
设置成功,返回 1 。
设置失败,返回 0 。
该命令相当于将下面两行操作合并为一个原子操作
SET key value
EXPIRE key seconds # 设置生存时间
这样就可以直接保证解锁过程的原子性
雪花算法加key生成唯一id
给大家举个例子吧,比如下面那个 64 bit 的 long 型数字:
第一个部分,是 1 个 bit:0,这个是无意义的。
第二个部分是 41 个 bit:表示的是时间戳。
第三个部分是 5 个 bit:表示的是机房 id,10001。
第四个部分是 5 个 bit:表示的是机器 id,1 1001。
第五个部分是 12 个 bit:表示的序号,就是某个机房某台机器上这一毫秒内同时生成的 id 的序号,0000 00000000。
具体实现代码
public boolean trylock(String key) {
ResultCode code = jedis.setNX(key, “This is a Lock.”);
if (ResultCode.SUCCESS.equals(code))
return true;
else
return false;
}
以上实现方式同样存在几个问题:
1、单点问题。
2、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在redis中,其他线程无法再获得到锁。
3、这把锁只能是非阻塞的,无论成功还是失败都直接返回。
4、这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在redis中已经存在。无法再执行setNX操作。
5、这把锁是非公平的,所有等待的线程同时去发起setNX操作,运气好的线程能获取锁。
当然,同样有方式可以解决。
现在主流的缓存服务都支持集群部署,通过集群来解决单点问题。
没有失效时间?redis的setExpire方法支持传入失效时间,到达时间之后数据会自动删除。
非阻塞?while重复执行。
非可重入?在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。
非公平?在线程获取锁之前先把所有等待的线程放入一个队列中,然后按先进先出原则获取锁。
共性问题
失效时间我设置多长时间为好?**如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。**如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。
这个问题使用数据库实现分布式锁同样存在。
对于这个问题目前主流的做法是每获得一个锁时,只设置一个很短的超时时间,同时起一个线程在每次快要到超时时间时去刷新锁的超时时间。在释放锁的同时结束这个线程。如redis官方的分布式锁组件redisson,就是用的这种方案。利用看门狗机制去监控,当快过期的时候去监控线程,如果没执行完就刷新过期时间,
性能好
7 基于Zookeeper实现的分布式锁
特性: 基于上面的图片,每一个节点是不能够重复的,如果谁创建了节点,那么谁就获取到了锁
基于zookeeper临时有序节点可以实现的分布式锁。
大致思想即为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。
来看下Zookeeper能不能解决前面提到的问题。
锁无法释放?使用Zookeeper可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在ZK中创建一个临时节点,一旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
非阻塞锁?使用Zookeeper可以实现阻塞的锁,客户端可以通过在ZK中创建顺序节点,并且在节点上绑定监听器,一旦节点有变化,Zookeeper会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
不可重入?使用Zookeeper也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写入到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对一下就可以了。如果和自己的信息一样,那么自己直接获取到锁,如果不一样就再创建一个临时的顺序节点,参与排队。
单点问题?使用Zookeeper可以有效的解决单点问题,ZK是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。
公平问题?使用Zookeeper可以解决公平锁问题,客户端在ZK中创建的临时节点是有序的,每次锁被释放时,ZK可以通知最小节点来获取锁,保证了公平。
优点
有效的解决单点问题,不可重入问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。
缺点
性能上不如使用缓存实现分布式锁。 需要对ZK的原理有所了解。
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
分布式锁提高性能方法
分段锁思想原理
具体的分段的数量和锁的数量要和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的下单请求了。
一旦对某个数据做了分段处理之后,有一个坑一定要注意:就是如果某个下单请求,咔嚓加锁,然后发现这个分段库存里的库存不足了,此时咋办?这时你得*自动释放锁,然后立马换下一个分段库存,再次尝试加锁后尝试处理。 这个过程一定要实现。
LRU的策略,Redis的5种数据结构?
- String:字符串类型
- List:列表类型
- Set:无序集合类型
- ZSet:有序集合类型
- Hash:哈希表类型
2.LRU:最近最少使用,淘汰最近不使用的页面
实现步骤原理如下:
1. 新数据插入到链表头部;
2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
3. 当链表满的时候,将链表尾部的数据丢弃。
双链表 + hashtable实现原理:
将Cache的所有位置都用双连表连接起来,当一个位置被命中之后,就将通过调整链表的指向,将该位置调整到链表头的位置,新加入的Cache直接加到链表头中。这样,在多次进行Cache操作后,最近被命中的,就会被向链表头方向移动,而没有命中的,而想链表后面移动,链表尾则表示最近最少使用的Cache。当需要替换内容时候,链表的最后位置就是最少被命中的位置,我们只需要淘汰链表最后的部分即可。
具体实现
具体实现Map里边是key是itemId,value是库存,每次去Map里查是否有key,如果有就直接返回value,每次命中插入链表头,如果队列满了,就抛弃队尾节点。。
双主同步
2、平滑扩容
数据库扩容的过程中,如果想要持续对外提供服务,保证服务的可用性,平滑扩容方案是最好的选择。平滑扩容就是将数据库数量扩容成原来的2倍,比如:由2个数据库扩容到4个数据库,具体步骤如下:
新增2个数据库
配置双主进行数据同步(先测试、后上线)
数据同步完成之后,配置双主双写(同步因为有延迟,如果时时刻刻都有写和更新操作,会存在不准确问题)
数据同步完成后,删除双主同步,修改数据库配置,并重启;
分区
分区指的是分散数据到不同的磁盘,查询数据的时候只需要查询指定的分区.一张表的数据分成N个物理区块来负责.
为什么要分库分表?
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通常是物理日志,记录的是数据页的物理修改,而不是某一行或某几行修改成怎样怎样,它用来恢复提交后的物理数据页(恢复数据页,且只能恢复到最后一次提交的位置)。记录的是在某个表做了什么修改,
一致性哈希
小林的一致性哈希(正确)
哈希算法。
因为对同一个关键字进行哈希计算,每次计算都是相同的值,这样就可以将某个 key 确定到一个节点了,可以满足分布式系统的负载均衡需求。
哈希算法最简单的做法就是进行取模运算,比如分布式系统中有 3 个节点,基于 hash(key) % 3 公式对数据进行了映射。问题
但是有一个很致命的问题,如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。一致哈希算法
一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值。
我们可以把一致哈希算法是对 2^32 进行取模运算的结果值组织成一个圆环,就像钟表一样,钟表的圆可以理解成由 60 个点组成的圆,而此处我们把这个圆想象成由 2^32 个点组成的圆,这个圆环被称为哈希环,如下图:
一致性哈希要进行两步哈希:
第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
第二步:当对数据进行存储或访问时,对数据进行哈希映射;
所以,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上。
问题来了,对「数据」进行哈希映射得到一个结果要怎么找到存储该数据的节点呢?
答案是,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。
首先,对 key 进行哈希计算,确定此 key 在环上的位置;
然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。
好处,在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。
但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上
问题
所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题
解决
,实际中我们没有那么多节点。所以这个时候我们就加入虚拟节点,也就是对一个真实节点做多个副本。
具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。
比如对每个节点分别设置 3 个虚拟节点:
对节点 A 加上编号来作为虚拟节点:A-01、A-02、A-03
对节点 B 加上编号来作为虚拟节点:B-01、B-02、B-03
对节点 C 加上编号来作为虚拟节点:C-01、C-02、C-03
你可以看到,节点数量多了后,节点在哈希环上的分布就相对均匀了。这时候,如果有访问请求寻址到「A-01」这个虚拟节点,接着再通过「A-01」虚拟节点找到真实节点 A,这样请求就能访问到真实节点 A 了。
因此,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景