普通索引和唯一索引
查询性能
select id from T where k=4
通过B+树从root开始层序遍历到叶节点,数据页内部通过二分搜索:
-
普通索引 查找到满足条件的第一个记录(4,400)后,继续查找下个记录,直到碰到第一个不满足
k=4
的记录 -
唯一索引 查到第一个满足条件的,就停止搜索
看起来性能差距很小。
InnoDB数据按数据页单位读写。即读一条记录时,并非将该一个记录从磁盘读出,而以页为单位,将其整体读入内存。所以普通索引,多了一次“查找和判断下一条记录”的操作,即一次指针寻找和一次计算。
若k=4
记录恰为该数据页的最后一个记录,则此时要取下个记录,还得读取下个数据页。对整型字段,一个数据页可存近千个key,因此这种情况概率其实也很低。
因此计算平均性能差异时,可认为该操作成本对CPU开销忽略不计。
更新性能
往表中插入一个新记录(4,400),InnoDB会有什么反应?这要看该记录要更新的目标页是否在内存:
在内存
-
普通索引 找到3和5之间的位置,插入值,结束。
-
唯一索引 找到3和5之间的位置,判断到没有冲突,插入值,结束。
只是一个判断的差别,耗费微小CPU时间。
不在内存
-
唯一索引 将数据页读入内存,判断到没有冲突,插入值,结束。
-
普通索引 将更新记录在change buffer,结束。
将数据从磁盘读入内存涉及随机I/O访问,是DB里成本最高的操作之一。而change buffer可以减少随机磁盘访问,所以更新性能提升明显。
索引选择最佳实践
普通索引、唯一索引在查询性能上无差别,主要考虑更新性能。所以,推荐尽量选择普通索引。若所有更新后面,都紧跟对该记录的查询,就该关闭change buffer。其它情况下,change buffer都能提升更新性能。
普通索引和change buffer的配合使用,对数据量大的表的更新优化还是明显的。在使用机械硬盘时,change buffer收益也很大。所以,当你有“历史数据”库,且出于成本考虑用机械硬盘,应该关注这些表里的索引,尽量用普通索引,把change buffer开大,确保“历史数据”表的数据写性能。
普通索引带change buffer的读过程和写过程
插入流程
insert into t(id,k)values (id1,k1),(id2,k2);
假设当前k索引树的状态,查找到位置后:
-
k1所在数据页在内存(buffer pool)
-
k2数据页不在内存
看如下流程:带change buffer的更新流程
图中箭头都是后台操作,不影响更新请求的响应。
该更新做了如下操作:
-
Page1在内存,直接更新内存
-
Page2不在内存,就往change buffer区,缓存一个“往Page2插一行记录”的信息
-
将前两个动作记入redo log
至此,事务完成。执行该更新语句成本很低,只是写两处内存,然后写一处磁盘(前两次操作合在一起写了一次磁盘),还是顺序写。
处理之后的读请求
select * from t where k in (k1, k2);
读语句紧随更新语句之后,这时内存中的数据都还在,所以此时这俩读操作就与系统表空间和 redo log 无关。
带change buffer的读过程
读Page1时,直接从内存返回。
WAL之后若读数据,是否一定要读盘?一定要从redo log将数据更新后才能返回?其实不用。看上图状态,虽然磁盘上还是之前的数据,但这里直接从内存返回结果,结果是正确的。
读Page2时,需将Page2从磁盘读入内存,然后应用change buffer里的操作日志,生成一个正确版本并返回结果。所以一直到需要读Page2时,该数据页才会被从磁盘读入内存。
综上,这俩机制的更新性能:
-
redo log 主要节省随机写磁盘的I/O消耗(转成顺序写)
-
change buffer主要节省随机读磁盘的I/O消耗
change buffer 和 redo log 底层原理
更新流程中涉及到:重做日志(redo log)和归档日志(bin log)。重做日志(redo log)是在引擎层中,采用“黑板-账本”模式(即WAL技术)。
为什么需要redo log?
每一次的更新操作的具体数据变更,都是需要写入到磁盘中去的,因为写入的是一条确定的数据,所以我们还需要在磁盘中找到那条相对应的记录,然后才能完成更新。而对于磁盘的IO操作,众所周知是最消耗的操作,因此为了解决这个问题,我们使用redo log。
redo log中记录的是要更新的数据,比如一条数据已提交成功,并不会立即同步到磁盘,而是先记录到redo log中,等待合适的时机再刷盘,为了实现事务的持久性。如果没有redo log, 那么每次事务提交的时候,将该事务涉及修改的数据页全部刷新到磁盘中。
但是这么做会有严重的性能问题,主要体现在两个方面:
因为 Innodb 是以 页 为单位进行磁盘交互的,而一个事务很可能只修改一个数据页里面的几个字节,这个时候将完整的数据页刷到磁盘的话,太浪费资源。一个事务可能涉及修改多个数据页,并且这些数据页在物理上并不连续,使用随机IO写入性能太差。
因此 MySQL设计了 redo log , 具体来说就是只记录事务对数据页做了哪些修改,这样就能完美地解决性能问题了(相对而言文件更小并且是顺序IO)。
redo log的具体过程
在redo log中采用是“黑板”+“账本”的过程。通俗的想法是,一旦有需要写入的内容时,我们先写在黑板上,当黑板上写不下的时候或者有空闲的时候,再将黑板上的内容都拷贝到账本中。
-
黑板,在MySQL中对应日志(redo log);
-
账本,对应MySQL中的磁盘。
举例---当有一条记录需要更新,InnoDB引擎会先把记录写到redo log(黑板)中,并更新内存。到这一步,该引擎认为这个更新步骤已经完成了。当InnoDB遇到了系统空闲的时候,才会将这个操作记录更新到磁盘中去。
update图解执行过程
bin log
归档日志(bin log)---在server层,记录MySQL功能层面的事情。
redo log跟bin log的区别:
redo log是存储引擎层产生的,而bin log是数据库层产生的。
假设一个事务,对表做了10万行的记录插入,在这个过程中,一直不断的往redo log顺序记录,而bin log不会记录,直到这个事务提交,才会一次写入bin log文件中。
两阶段提交
从流程图中,我们可以看到,redo log的完成过程是被分成了两个阶段的,分别是:prepare阶段和commit状态。这就是所谓的两阶段提交。
目的:为了保证数据库的目前状态和它通过日志恢复出来的库的状态是一致的。
redo log 的好处
相较于在事务提交时将所有修改过的页刷新到磁盘中,只将该事务执行过程中产生的redo日志刷到磁盘,有下面的好处:
(1) redo日志降低了刷盘频率。
(2) redo日志占用的空间非常小。
(3) redo日志是顺序写入磁盘的。
在执行事务的过程中,每执行一条语句,就可能产生若干条redo日志,这些日志是按照产生的顺序写入磁盘的,也就是顺序IO。
InnoDB存储引擎的事务采用了WAL技术(Write-Ahead Logging),这种技术就是先写日志,再写磁盘,只有日志写入成功,才算事务提交成功,这里的日志就是redo log。
当发生宕机且数据未刷新到磁盘的时候,可以通过redo log恢复过来,保证事务的持久性。
事务的原子性、一致性和持久性由事务的 redo 日志和 undo 日志来保证。
redo log称为重做日志 ,提供再写入操作,恢复提交事务修改的页操作,用来保证事务的持久性。
undo log称为回滚日志 ,回滚行记录到某个特定版本,用来保证事务的原子性、一致性。
redo log是存储引擎层生成的日志,记录的是物理级别上的页修改操作,比如页号xxx,偏移量yyy,写入了zzz数据,主要是为了保证数据的可靠性。
undo log是存储引擎层生成的日志,记录的是逻辑操作的日志,比如对某一行数据进行了insert语句操作,那么undo log就记录一条与之相反的delete操作。主要用于事务的回滚和一致性非锁定读(mvcc)。
什么是 Buffer Pool
InnoDB存储引擎在处理客户端的请求时,如果需要访问某个页的数据,就会把完整的页中的数据全部加载到内存中,即使只访问页中的一条记录,也需要先把整个页的数据加载到内存中。
将整个页加载到内存后就可以进行读写访问了,而且在读写访问之后并不着急把该页对应的内存空间释放掉,而是将其缓存起来,这样将来有请求再次访问该页面时,就可以剩下磁盘IO的开销了。
为了缓存磁盘中的页,Innodb在MySQL服务器启动时就像操作系统申请了一片连续的内存,即Buffer Pool(缓冲池)。默认情况下,Buffer Pool的大小为128M。
Buffer Pool对应的一片连续的内存被划分为若干个页面,页面大小与Innodb表空间用的页面大小一致,默认都是16kb,为了与磁盘中的页面区分开来,我们把这些Buffer Pool中的页面称为缓冲页。
当我们修改了Buffer Pool中某个缓冲页的数据,它就与磁盘上的页不一致了,这样的缓冲页称为脏页。
当然,我们可以每当修改完某个数据页时,就立即将其刷新到磁盘中对应的页上,但是频繁的往磁盘中写数据会严重的影响程序的性能,所以每次修改缓冲页后,我们并不着急立即将修改刷新到磁盘上,而是在某个时间点进行刷新。
后台有专门的线程负责每隔一段时间就把脏页刷新到磁盘,这样就可以不影响用户线程处理正常的请求。
总之:
InnoDB存储引擎是以页为单位来管理存储空间的,在真正访问页面之前,需要先把磁盘中的页加载到内存中的Buffer Pool中,之后才可以访问,所有的变更都必须先更新缓冲池中的数据,然后缓冲池中的脏页以一定的频率刷新到磁盘(checkpoint机制),通过缓冲池来优化CPU和磁盘之间的鸿沟,这样就能保证整体的性能不会下降的太快。
Buffer Pool 缓存表数据与索引数据,把磁盘上的数据加载到缓冲池,避免每次访问都进行磁盘IO,起到加速访问的作用。
速度快,那为啥不把所有数据都放到缓冲池里?凡事都具备两面性,抛开数据易失性不说,访问快速的反面是存储容量小:
(1)缓存访问快,但容量小,数据库存储了200G数据,缓存容量可能只有64G;
(2)内存访问快,但容量小,买一台笔记本磁盘有2T,内存可能只有16G;
因此,只能把“最热”的数据放到“最近”的地方,以“最大限度”的降低磁盘访问。
什么是 change buffer
change buffer 是 buffer pool 中的一部分内存;它既在内存中有拷贝,也可以持久化到磁盘;其大小通过参数 innodb_change_buffer_max_size 控制,表示最多占用 buffer pool的百分比;当需要更新一个数据页时,如果数据页在内存中,则直接更新;否则,在不影响数据一致性的前提下,InnoDB 将这些操作缓存在 change buffer 中,这样就不必从磁盘中读取数据,当下次查询需要访问这个数据页时,再将数据页读入内存,然后执行 change buffer 中与这个页有关的操作,最后将查询结果返回。
merge:将 change buffer 的操作应用到数据页的过程称为 merge。
除了访问数据页会触发 merge 外;系统后台有线程会定期 merge;数据库正常关闭的过程中也会触发 merge 操作。更新操作记录到 change buffer,可以减少读磁盘,提高执行效率;而且读入数据会占用 buffer pool ,还可以提高内存使用率。
change buffer使用条件
-
对于唯一索引,所有更新操作都需要做唯一性约束的判断,必须将数据页读入内存,直接在内存中更新,不使用 change buffer 。
-
对于普通索引,当数据页在内存中时,直接进行更新操作即可;当数据页不在内存中时,直接将更新操作写入 change buffer 即可。
change buffer 使用场景
change buffer 的主要作用就是将记录的变更操作缓存下来,在 merge 之前, change buffer 记录的越多,收益就越大。change buffer 适合页面变更完之后被马上访问的 概率较小的场景。就是修改之后,就访问的概率小。
如果页面变更之后又要被访问,此时会立即触发 merge 过程,这样反而增加了 change buffer 的维护代价,多了一个写 change buffer 的操作,此时关闭 change buffer 反而能提高效率;
redo log 和 change buffer的区别
redo log:节省了随机写磁盘的IO消耗
chagne buffer:节省了随机读磁盘的IO消耗
更新普通索引时:
如果数据不在内存中,可以直接将变更操作缓存到 change buffer,而不需要将数据读入内存;如果没有 change buffer,则必须将数据读入内存,然后更新数据。此时节省了随机读磁盘的IO消耗;
redo log 将数据变更操作记录在日志中,不用写入磁盘即可返回执行结果;如果没有 redo log,则更新完数据必须先将数据写入磁盘,再返回执行结果。此时节省了随机写磁盘的IO消耗;
查询普通索引时:
如果数据不在内存中,必须先将数据读入磁盘,再执行 change buffer 中的相关操作,然后返回查询结果。只有查询数据时,才需要将数据读磁盘到内存
总结
因为唯一索引用不了change buffer,若业务可以接受,从性能角度,优先考虑非唯一索引。
到底何时使用唯一索引
问题就在于“业务可能无法确保”,而本文前提是“业务代码已保证不会写入重复数据”,才讨论的性能问题。
-
若业务无法保证或业务就是要求数据库来做约束 没有撤退可言,必须创建唯一索引。那本文意义就在于,若碰上大量插入数据慢、内存命中率低时,多提供了一个排查思路
-
“归档库”场景,可考虑使用唯一索引 比如线上数据只需保留半年,然后历史数据存在归档库。此时,归档数据已是确保没有唯一键冲突。要提高归档效率,可考虑把表的唯一索引改为普通索引。
若某次写入使用了change buffer,之后主机异常重启,是否会丢失change buffer数据
不会!虽然是只更新内存,但在事务提交时,change buffer的操作也被记录到了redo log。所以崩溃恢复时,change buffer也能找回。
merge时是否会把数据直接写回磁盘
merge流程
-
从磁盘读入数据页到内存(老版本数据页)
-
从change buffer找出该数据页的change buffer 记录(可能多个),依次应用,得到新版数据页
-
写redo log 该redo log包含数据的变更和change buffer的变更
至此merge结束。
这时,数据页和内存中change buffer对应磁盘位置都尚未修改,是脏页,之后各自刷回自己物理数据,就是另外一过程。
100w人在线的弹幕系统架构
问题分析
按照背景来分析,系统将主要面临以下问题:
-
带宽压力
假如说每3秒促达用户一次,那么每次内容至少需要有15条才能做到视觉无卡顿。15条弹幕+http包头的大小将超过3k,那么每秒的数据大小约为8Gbps,而运维同学通知我们所有服务的可用带宽仅为10Gbps。
-
弱网导致的弹幕卡顿、丢失
该问题已在线上环境
-
性能与可靠性
百万用户同时在线,按照上文的推算,具体QPS将超过30w QPS。如何保证在双十一等重要活动中不出问题,至关重要。性能也是另外一个需要着重考虑的点。
架构设计和优化
那么,该如何做架构设计和优化呢?主要的架构优化有:
-
业务解耦+服务拆分
-
引入本地缓存,优化高并发读
-
引入限流,优化高并发写
-
使用滑动窗口,实现无锁化读写
-
通过短轮训实现弹幕促达
-
传输优化、节约带宽
业务解耦+服务拆分
为了保证服务的稳定性我们对服务进行了拆分,进行业务解耦+服务拆分
业务解耦+服务拆分的具体架构方案
将逻辑较为复杂、调用较少的发送弹幕业务与逻辑简单、调用量高的弹幕拉取服务拆分开来。将复杂的逻辑收拢到发送弹幕的一端。
在这里插入图片描述
服务拆分主要考虑因素是为了不让服务间相互影响,对于这种系统服务,不同服务的QPS往往是不对等的,例如像拉取弹幕的服务的请求频率和负载,通常会比发送弹幕服务高1到2个数量级。
解耦之后的优势
实现一个小3高的目标:高可用、高扩展、高协同
-
高可用
最大度的保证系统的可用性,在这种情况下,不能让拉弹幕服务把发弹幕服务搞垮,反之亦然,不能让 发弹服务把拉弹幕服务 搞垮
-
高扩展
方便扩容和缩容, 更加方便对各个服务做Scale-Up和Scale-Out。
-
高协同
方便协同开发, 服务拆分也划清了业务边界,方便协同开发。
引入本地缓存优化高并发读
在拉取弹幕服务的一端:引入本地缓存
数据更新的策略是:服务会定期发起RPC调用,从弹幕服务拉取数据,拉取到的弹幕缓存到内存中,这样后续的请求过来时便能直接走本地内存的读取,大大幅降低了调用时延。
这样做还有另外一个好处就是缩短调用链路,把数据放到离用户最近的地方, 同时还能降低外部依赖的服务故障对业务的影响
引入限流,优化高并发写
在发送弹幕的一端: 限流(有损服务),因为用户一定时间能看得过来弹幕总量是有限的,所以可以对弹幕进行限流,有选择的丢弃多余的弹幕。
同时,采用柔性的处理方式,拉取用户头像、敏感词过滤等分支在调用失败的情况下,仍然能保证服务的核心流程不受影响,即弹幕能够正常发送和接收,提供有损的服务。
使用滑动窗口,实现无锁化读写
弹幕数据的读写,如果使用阻塞队列,那么需要加锁。如果加锁,在超高并发场景,会性能非常低。如何实现无锁化读写呢?
基于滑动窗口技术,实现无锁化读写,保证在超高并发场景并发读写的性能
ring-buffer
为了数据拉取方便,我们将数据按照时间进行分片,将时间作为数据切割的单位,按照时间存储、拉取、缓存数据(RingBuffer),简化了数据处理流程。
与传统的Ring Buffer不一样的是,我们只保留了尾指针,它随着时间向前移动,每秒向前移动一格,把时间戳和对应弹幕列表并写到一个区块当中,因此最多保留60秒的数据。
同时,如果此时来了一个读请求,那么缓冲环会根据客户端传入的时间戳计算出指针的索引位置,并从尾指针的副本区域往回遍历直至跟索引重叠,收集到一定数量的弹幕列表返回,这种机制保证了缓冲区的区块是整体有序的,因此在读取的时候只需要简单地遍历一遍即可,加上使用的是数组作为存储结构,带来的读效率是相当高的。
再来考虑可能出现数据竞争的情况。
先来说写操作,由于在这个场景下,写操作是单线程的,因此可不必关心并发写带来的数据一致性问题。再来说读操作,由图可知写的方向是从尾指针以顺时针方向移动,而读方向是从尾指针以逆时针方向移动,决定读和写的位置是否出现重叠取决于index的位置,由于我们保证了读操作最多只能读到30秒内的数据,因此缓冲环完全可以做到无锁读写
通过短轮训实现弹幕促达
Long Polling和Websockets都不适用弱环境,所以我们最终采取了短轮训的方案来实现弹幕促达
弹幕卡顿、丢失分析
在开发弹幕系统的的时候,最常见的问题是该怎么选择促达机制,
-
推送 vs 拉取
-
长轮询 vs 短 轮询
基于AJAX的长轮询方案 (Long Polling via AJAX)
客户端打开一个到服务器端的 AJAX 请求,然后等待响应,服务器端需要一些特定的功能来允许请求被挂起,只要一有事件发生,服务器端就会在挂起的请求中送回响应。如果打开Http的Keepalived开关,还可以节约握手的时间。
polling-ajax
优点:减少轮询次数,低延迟,浏览器兼容性较好。
缺点:服务器需要保持大量连接。
基于WebSockets 的双向通讯方案
长轮询虽然省去了大量无效请求,减少了服务器压力和一定的网络带宽的占用,但是还是需要保持大量的连接。
那么人们就在考虑了,有没有这样一个完美的方案,即能双向通信,又可以节约请求的 header 网络开销,并且有更强的扩展性,最好还可以支持二进制帧,压缩等特性呢?
于是人们就发明了这样一个目前看似“完美”的解决方案 —— WebSocket。
它的最大特点就是:服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话。
websockets
优点1:较少的控制开销
较少的控制开销:在连接创建后,WS用于协议控制的数据包头部相对较小。在不包含扩展的情况下,服务端到客户端WS 头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。但是,与 HTTP 头部比,此项开销显著减少了。
优点2:更强的实时性
WEBSocket由于协议是全双工的,服务器可以随时推数据。WEBSocket延迟明显更少;
优点3:长连接,保持连接状态
Long Polling vs Websockets
无论是以上哪种方式,都使用到TCP长连接,那么TCP的长连接是如何发现连接已经断开了呢?
TCP Keepalived会进行连接状态探测,探测间隔主要由三个配置控制。
keepalive_probes:探测次数(默认:7次)keepalive_time 探测的超时(默认:2小时)
keepalive_intvl 探测间隔(默认:75s)
但是由于在东南亚的弱网情况下,TCP长连接会经常性的断开:
Long Polling 能发现连接异常的最短间隔为:min(keepalive_intvl, polling_interval)Websockets能发现连接异常的最短间隔为:Websockets: min(keepalive_intvl, client_sending_interval)
如果下次发送数据包的时候可能连接已经断开了,所以使用TCP长连接对于两者均意义不大。并且弱网情况下, Websockets其实已经不能作为一个候选项了
-
即使Websockets服务端已经发现连接断开,仍然没有办法推送数据,只能被动等待客户端重新建立好连接才能推送,在此之前数据将可能会被采取丢弃的措施处理掉。
-
在每次断开后均需要再次发送应用层的协议进行连接建立。
根据了解, 腾讯云的弹幕系统:
-
在300人以下使用的是推送模式,
-
300人以上则是采用的轮训模式。
但是考虑到资源消耗情况,他们可能使用的是Websocket来实现的弹幕系统,也就是 300人以上轮训模式, 腾讯云也是基于Websocket 来实现的,不太可能基于 AJAX来实现,正式因为基于Websocket ,在弱网环境下,所以才会出现弹幕卡顿、丢失的情况。
综上所述,Long Polling和Websockets都不适用我们面临的环境,所以我们最终采取了短轮训的方案来实现弹幕促达。
polling
传输优化、节约带宽
为了降低带宽压力,我们主要采用了以下方案:
-
启用Http压缩
通过查阅资料,http gzip压缩比率可以达到40%以上(gzip比deflate要高出4%~5%)。
-
Response结构简化
request response
-
内容排列顺序优化
根据gzip的压缩的压缩原理可以知道,重复度越高,压缩比越高,因此 : 可以将字符串和数字内容放在一起摆放
-
频率控制
通过请求频率调整带宽:通过添加请求间隔参数,实现客户端的请求频率服务端可控。间隔时间太长,在突发流量的时候, 可能会出现有损服务,对于弹幕来说,是可以容忍的。 延长请求频率,可以避免无效请求:在弹幕稀疏和空洞的时间段,通过控制下次请求时间,避免客户端的无效请求。
总结
danmaku architecture
最终该服务在双十二活动中,在Redis出现短暂故障的背景下,高效且稳定的支撑了单房间100w用户在线,成功完成了既定的100w用户的目标
参考文献:
-
https://halfrost.com/websocket/
-
https://shopee-sz.github.io/2019/02/27/livechat/
-
https://www.cyningsun.com/03-31-2019/live-streaming-danmaku.html
volatile
聊聊:volatile的作用?
volatile的作用或者特性:
-
保证变量对所有线程可见性
-
禁止指令重排序
-
不保证原子性
聊聊:volatile的典型场景
通常来说,使用volatile必须具备以下2个条件:
-
1)对变量的写操作不依赖于当前值: 这意味着变量的新值不应该基于其当前值来计算。如果写操作依赖于当前值,那么使用
volatile
就无法保证操作的原子性,可能会导致在多线程环境中的不一致状态。例如,以下操作是不适合使用volatile
的: -
2)该变量没有包含在具有其他变量的不变式中: 不变式是指保持恒成立的条件或约束。如果某个变量是另一个不变式的一部分,那么仅仅将该变量声明为
volatile
是不够的。因为volatile
不能保证复合操作(即涉及多个变量的操作)的原子性。
实际上,volatile场景一般就是
-
状态标志
-
DCL单例模式
-
CAS 轻量级乐观锁场景
场景1:状态标志场景
深入理解Java虚拟机,书中的例子:
Map configOptions;
char[] configText;
// 此变量必须定义为 volatile
volatile boolean initialized = false;
// 假设以下代码在线程 A 中运行
// 模拟读取配置信息, 当读取完成后将 initialized 设置为 true 以告知其他线程配置可用
configOptions = new HashMap();
configText = readConfigFile(fileName);
processConfigOptions(configText, configOptions);
initialized = true;
// 假设以下代码在线程 B 中运行
// 等待 initialized 为 true, 代表线程 A 已经把配置信息初始化完成
while(!initialized) {
sleep();
}
// 使用线程 A 中初始化好的配置信息
doSomethingWithConfig();
场景2:DCL单例模式
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
场景3:CAS 轻量级乐观锁场景
JUC AQS源码中,其核心的成员 state (同步状态)、head (头指针)、tail (尾部指针), 都是通过CAS进行乐观锁修改的,都需要进行 volatile保证可见性。
同样,在高性能组件 caffeine 、 disruptor 源码中, 都是CAS + volatile 配合使用的
聊聊:volatile的内存语义
-
当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
-
当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
聊聊:什么是内存可见性,什么是指令重排序?
-
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
-
指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
聊聊:volatile重排序规则
volatile禁止重排序场景:
-
第二个操作是volatile写,不管第一个操作是什么都不会重排序。
-
第一个操作是volatile读,不管第二个操作是什么都不会重排序。
-
第一个操作时volatile写,第二个操作时volatile读,也不会发生重排序。
聊聊:并发编程的3大特性
-
原子性
-
可见性
-
有序性
聊聊:volatile是如何解决java并发中可见性的问题
底层是通过内存屏障实现的,volatile能保证修饰的变量后,可以立即同步回主内存,每次使用前立即先从主内存刷新最新的值。
聊聊:volatile底层的实现机制
volatile如何保证可见性和禁止指令重排,底层是通过内存屏障实现的
聊聊:volatile如何防止指令重排
底层是通过内存屏障实现防止指令重排的
-
1、在每个volatile写操作的前面插入一个StoreStore屏障。
-
2、在每个volatile写操作的后面插入一个StoreLoad屏障。
-
3、在每个volatile读操作的后面插入一个LoadLoad屏障。
-
4、在每个volatile读操作的后面插入一个LoadStore屏障。
聊聊:你对内存屏障理解?
分为两个层面:
-
JVM层面的内存屏障
-
硬件层面内存屏障
JVM层面的内存屏障
-
LoadLoad屏障:(指令Load1;LoadLoad;Load2),在Load2及后续 读取操作要读取的数据访问前,保障Load1要读取的数据被读取完毕。
-
LoadStore屏障:(指令Load1;LoadStore;Store2),在Store2及后续写入操作被刷出前,保障Load1要读取的数据被读取完毕。
-
StoreStore屏障:(指令Store1;StoreStore;Store2),在Store2及后续写入操作执行前,保障Store1的写入操作对其他处理器可见;
-
StoreLoad屏障:(指令Store1;StoreLoad;Load2),在Load2及后续所有读取操作执行前保障Store1的写入对所有处理器可见。它的开销时四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障;间距其他三种内存屏障的功能。
硬件层面内存屏障
硬件层提供了一系列的内存屏障memory barrier/memory fence来提供一致性的能力,拿X86平台来说,有以下几种内存屏障:
-
lfence,是一种Load Barrier读屏障,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。
-
sfence,是一种Store Barrier写屏障,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存。
-
mfence,是一种全能型的屏障,具备lfence和sfence的能力
-
Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线或高速缓存加锁,可以理解为CPU指令级的一种锁。它先对高速缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据刷新回主内存。在Lock锁总线的时候,其他CPU的读写请求斗会被阻塞,直到锁释放。
不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。
聊聊:内存屏障的作用?
两个作用:
-
阻止屏障两边的指令重排序
-
刷新处理器缓存
聊聊:volatile可以解决原子性嘛?为什么?
不可以,可以直接举i++那个例子,原子性需要synchronzied或者lock保证
public class Test {
public volatile int race = 0;
public void increase() {
race++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<100;j++)
test.increase();
};
}.start();
}
//等待所有累加线程结束
while(Thread.activeCount()>1)
Thread.yield();
System.out.println(test.race);
}
}
聊聊:volatile和synchronized的区别?
-
volatile修饰的是变量,synchronized一般修饰代码块或者方法
-
volatile保证可见性、禁止指令重排,但是不保证原子性;synchronized可以保证原子性
-
volatile不会造成线程阻塞,synchronized可能会造成线程的阻塞,所以后面才有锁优化
MySQL 数据更新原理实现
1、redo log(重做日志)
redo log 属于MySQL存储引擎InnoDB的事务日志。
MySQL如何减少磁盘IO呢?
如果每次读写数据都需做磁盘IO操作,如果并发场景下性能就会很差。MySQL提供了一个优化手段,引入缓存Buffer Pool。Buffer Pool缓存中包含了磁盘中部分数据页(page)的映射,以此来缓解数据库的磁盘压力。有了Buffer Pool缓存后,读取数据先缓存再磁盘,首先从缓存中读取,如果缓存中没有,则从磁盘读取后放入缓存;写入数据也是先缓存再磁盘,先向缓存写入,此时缓存中的数据页数据变更,这个数据页称为脏页,MYSQL会按照设定的更新策略,将脏Page页定期刷到磁盘中,这个过程称为刷脏页。
1.1 redo log如何保证数据不丢失 ,实现高可靠,实现事务持久性 ?
如果刷脏页还未完成,可MySQL由于某些原因宕机重启,此时Buffer Pool中修改的数据还没有及时的刷到磁盘中,就会导致数据丢失,无法保证事务的持久性。为了解决这个问题引入了redo log,redo Log如其名侧重于重做。它记录的是数据库中每个页的修改,而不是某一行或某几行修改成怎样,可以用来恢复提交后的物理数据页,且只能恢复到最后一次提交的位置。
redo log用到了WAL(Write-Ahead Logging)技术,这个技术的核心就在于修改记录前,一定要先写日志,并保证日志先落盘,才能算事务提交完成。有了redo log再修改数据时,InnoDB引擎会把更新记录先写在redo log中,再修改Buffer Pool中的数据,当提交事务时,调用fsync把redo log刷入磁盘。
至于缓存中更新的数据文件何时刷入磁盘,则由后台线程异步处理。
注意:此时redo log的事务状态是prepare,还未真正提交成功,要等bin log日志写入磁盘完成才会变更为commit,事务才算真正提交完成。
这样一来即使刷脏页之前MySQL意外宕机也没关系,只要在重启时解析redo log中的更改记录进行重放,重新刷盘即可。
1.2 redo log 大小固定
redo log采用固定大小,循环写入的格式,当redo log写满之后,重新从头开始如此循环写,形成一个环状。
那为什么要如此设计呢?
因为redo log记录的是数据页上的修改,如果Buffer Pool中数据页已经刷磁盘后,那这些记录就失效了,新日志会将这些失效的记录进行覆盖擦除。
上图中的write pos表示redo log当前记录的日志序列号LSN(log sequence number),写入还未刷盘,循环往后递增;check point表示redo log中的修改记录已刷入磁盘后的LSN,循环往后递增,这个LSN之前的数据已经全落盘。
write pos到check point之间的部分是redo log空余的部分(绿色),用来记录新的日志;check point到write pos之间是redo log已经记录的数据页修改数据,此时数据页还未刷回磁盘的部分。当write pos追上check point时,会先推动check point向前移动,空出位置(刷盘)再记录新的日志。
注意:redo log日志满了,在擦除之前,需要确保这些要被擦除记录对应在内存中的数据页都已经刷到磁盘中了。擦除旧记录腾出新空间这段期间,是不能再接收新的更新请求的,此刻MySQL的性能会下降。所以在并发量大的情况下,合理调整redo log的文件大小非常重要。
1.3 crash-safe
因为redo log的存在使得Innodb引擎具有了crash-safe的能力,即MySQL宕机重启,系统会自动去检查redo log,将修改还未写入磁盘的数据从redo log恢复到MySQL中。MySQL启动时,不管上次是正常关闭还是异常关闭,总是会进行恢复操作。
会先检查数据页中的LSN,如果这个 LSN 小于 redo log 中的LSN,即write pos位置,说明在redo log上记录着数据页上尚未完成的操作,接着就会从最近的一个check point出发,开始同步数据。
简单理解,比如:redo log的LSN是500,数据页的LSN
是300,表明重启前有部分数据未完全刷入到磁盘中,那么系统则将redo log中LSN序号300到500的记录进行重放刷盘。
2. undo log(回滚日志)
undo log也是属于MySQL存储引擎InnoDB的事务日志。undo log属于逻辑日志,如其名主要起到回滚的作用,它是保证事务原子性的关键。undo log 是innodb引擎的一种日志,在事务的修改记录之前,会把该记录的原值(before image)先保存起来再做修改,原来的值就是 undo log,以便修改过程中出错能够 恢复原值或者其他的事务 读取。
undo log 作用:
1、用于数据的回滚。比如数据执行时候发生错误,操作系统的错误,断点等,程序员手动rollback等操作场景都会用到数据的回滚。
2、实现MVCC。即在InnoDB存储引擎中MVCC的实现是通过undo来完成。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。
undo log 日志记录细节:
每当我们要对一条记录做改动时(这里的改动可以指INSERT、DELETE、UPDATE),都需要"留一手"把回滚时所需的东西记下来。比如:
1、插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。(对于每个INSERT, InnoDB存储引擎会完成一个DELETE)
2、删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。(对于每个DELETE,InnoDB存储引擎会执行一个INSERT)
3、修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。(对于每个UPDATE,InnoDB存储引擎会执行一个相反的UPDATE,将修改前的行放回去)
那可能有人会问:同一个事物内的一条记录被多次修改,那是不是每次都要把数据修改前的状态都写入undo log呢?答案是不会的!
undo log只负责记录事务开始前要修改数据的原始版本,当我们再次对这行数据进行修改,所产生的修改记录会写入到redo log,undo log负责完成回滚,redo log负责完成前滚。
回滚和前滚的区别
2.1 回滚
未提交的事务,即事务未执行commit
。但该事务内修改的脏页中,可能有一部分脏块已经刷盘。如果此时数据库实例宕机重启,就需要用回滚来将先前那部分已经刷盘的脏块从磁盘上撤销。
2.2 前滚
未完全提交的事务,即事务已经执行commit,但该事务内修改的脏页中只有一部分数据被刷盘,另外一部分还在buffer pool缓存上,如果此时数据库实例宕机重启,就需要用前滚来完成未完全提交的事务。将先前那部分由于宕机在内存上的未来得及刷盘数据,从redo log中恢复出来并刷入磁盘。
数据库实例恢复时,先做前滚,后做回滚。
undo log、redo log、bin log三种日志都是在刷脏页之前就已经刷到磁盘了的,三种日志,相互协作最大限度保证了用户提交的数据不丢失。
3. bin log(归档日志)
bin log是一种数据库Server层(和什么引擎无关),以二进制形式存储在磁盘中的逻辑日志。bin log记录了数据库所有DDL
和DML
操作(不包含 SELECT
和 SHOW
等命令,因为这类操作对数据本身并没有修改)。
1、MySQL的二进制日志binlog可以说是MySQL最重要的日志,它记录了所有的DDL和DML语句(除了数据查询语句select),以事件形式记录,还包含语句所执行的消耗的时间,MySQL的二进制日志是事务安全型的。
a、DDL:Data Definition Language 数据库定义语言
主要的命令有create、alter、drop等,ddl主要是用在定义或改变表(table)的结构,数据类型,表之间的连接和约束等初始工作上,他们大多在建表时候使用。
b、DML:Data Manipulation Language 数据操纵语言
主要命令是slect,update,insert,delete,就像它的名字一样,这4条命令是用来对数据库里的数据进行操作的语言
2、mysqlbinlog常见的选项有一下几个:
a、--start-datetime:从二进制日志中读取指定等于时间戳或者晚于本地计算机的时间
b、--stop-datetime:从二进制日志中读取指定小于时间戳或者等于本地计算机的时间 取值和上述一样
c、--start-position:从二进制日志中读取指定position 事件位置作为开始。
d、--stop-position:从二进制日志中读取指定position 事件位置作为事件截至
3、一般来说开启binlog日志大概会有1%的性能损耗。
4、binlog日志有两个最重要的使用场景。
a、mysql主从复制:mysql replication在master端开启binlog,master把它的二进制日志传递给slaves来达到master-slave数据一致的目的。
b、数据恢复:通过mysqlbinlog工具来恢复数据。
binlog日志包括两类文件:
1)、二进制日志索引文件(文件名后缀为.index)用于记录所有的二进制文件。
2)、二进制日志文件(文件名后缀为.00000*)记录数据库所有的DDL和DML(除了数据查询语句select)语句事件。
可以通过以下命令查看二进制日志是否开启:
mysql> SHOW VARIABLES LIKE 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin | OFF |
+---------------+-------+
bin log也被叫做归档日志,因为它不会像redo log那样循环写擦除之前的记录,而是会一直记录日志。一个bin log日志文件默认最大容量1G
(也可以通过max_binlog_size参数修改),单个日志超过最大值,则会新创建一个文件继续写。
mysql> show binary logs;
+-----------------+-----------+
| Log_name | File_size |
+-----------------+-----------+
| mysq-bin.000001 | 8687 |
| mysq-bin.000002 | 1445 |
| mysq-bin.000003 | 3966 |
| mysq-bin.000004 | 177 |
| mysq-bin.000005 | 6405 |
| mysq-bin.000006 | 177 |
| mysq-bin.000007 | 154 |
| mysq-bin.000008 | 154 |
bin log日志的内容格式其实就是执行SQL命令的反向逻辑,这点和undo log有点类似。一般来说开启bin log都会给日志文件设置过期时间(expire_logs_days参数,默认永久保存),要不然日志的体量会非常庞大。
mysql> show variables like 'expire_logs_days';
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| expire_logs_days | 0 |
+------------------+-------+
1 row in set
mysql> SET GLOBAL expire_logs_days=30;
Query OK, 0 rows affected
bin log主要应用于MySQL主从模式(master-slave)中,主从节点间的数据同步;以及基于时间点的数据还原。
3.1 主从同步
通过下图MySQL的主从复制过程,来了解下bin log在主从模式下的应用。
-
用户在主库master执行
DDL
和DML
操作,修改记录顺序写入bin log; -
从库slave的I/O线程连接上Master,并请求读取指定位置position的日志内容;
-
Master收到从库slave请求后,将指定位置position之后的日志内容,和主库bin log文件的名称以及在日志中的位置推送给从库;
-
slave的I/O线程接收到数据后,将接收到的日志内容依次写入到relay log文件最末端,并将读取到的主库bin log文件名和位置position记录到master-info文件中,以便在下一次读取用;
-
slave的SQL线程检测到relay log中内容更新后,读取日志并解析成可执行的SQL语句,这样就实现了主从库的数据一致;
3.2 基于时间点还原
我们看到bin log也可以做数据的恢复,而redo log也可以,那它们有什么区别?
-
层次不同:redo log 是InnoDB存储引擎实现的,bin log 是MySQL的服务器层实现的,但MySQL数据库中的任何存储引擎对于数据库的更改都会产生bin log。
-
作用不同:redo log 用于碰撞恢复(crash recovery),保证MySQL宕机也不会影响持久性;bin log 用于时间点恢复(point-in-time recovery),保证服务器可以基于时间点恢复数据和主从复制。
-
内容不同:redo log 是物理日志,内容基于磁盘的页Page;bin log的内容是二进制,可以根据binlog_format参数自行设置。
-
写入方式不同:redo log 采用循环写的方式记录;binlog 通过追加的方式记录,当文件大小大于给定值后,后续的日志会记录到新的文件上。
-
刷盘时机不同:bin log在事务提交时写入;redo log 在事务开始时即开始写入。
bin log 与 redo log 功能并不冲突而是起到相辅相成的作用,需要二者同时记录,才能保证当数据库发生宕机重启时,数据不会丢失。
聊聊:通过索引排序内部流程是什么?
select name,id from user where name like '%明' order by name;
select name,id,age from user where name like '%明'
关键配置:
-
sort_buffer可供排序的内存缓冲区大小
-
max_length_for_sort_data 单行所有字段总和限制,超过这个大小启动双路排序
-
通过索引检过滤筛选条件索到需要排序的字段+其他字段(如果是符合索引)
-
判断索引内容是否覆盖select的字段
i. 如果覆盖索引,select的字段和排序都在索引上,那么在内存中进行排序,排序后输出结果
ii. 如果索引没有覆盖查询字段,接下来计算select的字段是否超过max_length_for_sort_data限制,如果超过,启动双路排序,否则使用单路
聊聊:什么是双路排序和单路排序
单路排序:⼀次取出所有字段进行排序,内存不够用的时候会使用磁盘
双路排序:取出排序字段进行排序,排序完成后再次回表查询所需要的其他字段
如果不在索引列上,filesort有两种算法:mysql就要启动双路排序和单路排序
双路排序(慢)
Select id,age,name from stu order by name;
-
MySQL 4.1之前是使用双路排序,字⾯意思就是两次扫描磁盘,最终得到数据, 读取行指针和order by列,对他们进行排序,然后扫描已经排序好的列表,按照列表中的值重新从列表中读取对应的数据输出
-
从磁盘取排序字段,在buffer进行排序,再从磁盘取其他字段。
-
取⼀批数据,要对磁盘进行两次扫描,众所周知,I\O是很耗时的,所以在mysql4.1之后,出现了第二种改进的算法,就是单路排序。
单路排序(快)
从磁盘读取查询需要的所有列,按照order by列在buffer对它们进行排序,然后扫描排序后的列表进行输出, 它的效率更快⼀些,避免了第二次读取数据。
并且把随机IO变成了顺序IO,但是它会使用更多的空间, 因为它把每⼀行都保存在内存中了。
结论及引申出的问题
但是用单路有问题
在sort_buffer中,单路比多路要多占用很多空间,因为单路是把所有字段都取出,所以有可能取出的数据的总大小超出了sort_buffer的容量,导致每次只能取sort_buffer容量大小的数据,进行排序(创建tmp⽂件,多路合并),排完再取sort_buffer容量大小,再排……从而多次I/O。
单路本来想省⼀次I/O操作,反而导致了大量的I/O操作,反而得不偿失。
优化策略
-
增大sort_buffer_size参数的设置
-
增大max_length_for_sort_data参数的设置
-
减少select 后⾯的查询的字段。禁⽌使用select *
提高Order By的速度
1. Order by时select * 是⼀个大忌。只Query需要的字段, 这点非常重要。在这⾥的影响是:
-
当Query的字段大小总和小于max_length_for_sort_data 而且排序字段不是 TEXT|BLOB 类型时,会用改进后的算法单路排序, 否则用⽼算法多路排序。
-
两种算法的数据都有可能超出sort_buffer的容量,超出之后,会创建tmp⽂件进行合并排序,导致多次I/O,但是用单路排序算法的⻛险会更大⼀些,所以要提高sort_buffer_size。
2. 尝试提高 sort_buffer_size
不管用哪种算法,提高这个参数都会提高效率,当然,要根据系统的能⼒去提高,因为这个参数是针对每个进程(connection)的 1M-8M之间调整。MySQL5.7和8.0,InnoDB存储引擎默认值是1048576字节,1MB。
SHOW VARIABLES LIKE '%sort_buffer_size%';
3. 尝试提高 max_length_for_sort_data
提高这个参数, 会增加用改进算法的概率。
SHOW VARIABLES LIKE '%max_length_for_sort_data%';
#5.7默认1024字节
#8.0默认4096字节
但是如果设的太高,数据总容量超出sort_buffer_size的概率就增大,明显症状是高的磁盘I/O活动和低的处理器使用率。如果需要返回的列的总⻓度大于max_length_for_sort_data,使用双路算法,否则使用单路算法。1024-8192字节之间调整
聊聊:group by 分组和order by在索引使用上有什么区别?
group by 使用索引的原则几乎跟order by⼀致 ,唯⼀区别:
-
group by 先排序再分组,遵照索引建的最佳左前缀法则
-
group by没有过滤条件,也可以用上索引。Order By 必须有过滤条件才能使用上索引。
聊聊:被经常查询的字段为null,该创建索引?
应该创建索引,使用的时候尽量使用is null判断。
100Wqps 短链系统,如何设计?
1、短URL系统的背景
短网址替代长URL,在互联网网上传播和引用。例如QQ微博的url.cn,新郎的sinaurl.cn等。
在QQ、微博上发布网址的时候,会自动判别网址,并将其转换,例如:http://url.cn/2hytQx
为什么要这样做的,无外乎几点:
-
缩短地址长度,留足更多空间的给 有意义的内容。URL是没有意义的,有的原始URL很长,占用有效的屏幕空间。微博限制字数为140字一条,那么如果这个连接非常的长,以至于将近要占用我们内容的一半篇幅,这肯定是不能被允许的,链接变短,对于有长度限制的平台发文,可编辑的文字就变多了, 所以短网址应运而生了。
-
可以很好的对原始URL内容管控。有一部分网址可以会涵盖XX,暴力,广告等信息,这样我们可以通过用户的举报,完全管理这个连接将不出现在我们的应用中,应为同样的URL通过加密算法之后,得到的地址是一样的。
-
可以很好的对原始URL进行行为分析。我们可以对一系列的网址进行流量,点击等统计,挖掘出大多数用户的关注点,这样有利于我们对项目的后续工作更好的作出决策。
-
短网址和短ID相当于间接提高了带宽的利用率、节约成本
-
链接太长在有些平台上无法自动识别为超链接
-
短链接更加简洁好看且安全,不暴露访问参数。而且,能规避关键词、域名屏蔽等手段
2、短URL系统的原理
短URL系统的核心:将长的 URL 转化成短的 URL。客户端在访问系统时,短URL的工作流程如下:
-
先使用短地址A访问 短链Java 服务
-
短链Java 服务 进行 地址转换和映射,将 短URL系统映射到对应的长地址URL
-
短链Java 服务 返回302 重定向 给客户端
-
然后客户端再重定向到原始服务
如下图所示:
那么,原始URL如何变短呢?简单来说, 可以将原始的地址,使用编号进行替代。编号如何进一步变短呢? 可以使用更大的进制来表示
六十二进制表示法
顾名思义短网址就是非常短的网址,比如http://xxx.cn/EYyCO9T,其中核心的部分 EYyCO9T 只有7位长度。其实这里的7位长度是使用62进制来表示的,就是常用的0-9、a-z、A-Z,也就是10个数字+26个小写+26个大写=62位。那么7位长度62进制可以表示多大范围呢?
62^7 = 3,521,614,606,208 (合计3.5万亿),
说明:
10进制 最大只能生成 10 ^ 6 - 1 =999999个
16进制 最大只能生成 16 ^ 6 - 1 =16777215个
16进制里面已经包含了 A B C D E F 这几个字母
62进制 最大竟能生成 62 ^ 6 - 1 =56800235583个 基本上够了。
A-Z a-z 0-9 刚好等于62位
注意:
int(4个字节) ,存储的范围是-21亿到21亿
long(8个字节),存储的范围是-900万万亿 到 900万万亿
至于短网址的长度,可以根据自己需要来调整,如果需要更多,可以增加位数,即使6位长度62^6也能达到568亿的范围,这样的话只要算法得当,可以覆盖很大的数据范围。
在编码的过程中,可以按照自己的需求来调整62进制各位代表的含义。一个典型的场景是, 在编码的过程中,如果不想让人明确知道转换前是什么,可以进行弱加密,比如A站点将字母c表示32、B站点将字母c表示60,就相当于密码本了。
128进制表示法
标准ASCII 码也叫基础ASCII码,使用7 位二进制数(剩下的1位二进制为0),包含128个字符,看到这里你或许会说,使用128进制(如果有的话)岂不是网址更短,是的,7 位二进制数(剩下的1位二进制为0)表示所有的大写和小写字母,数字0 到9、标点符号,以及在美式英语中使用的特殊控制字符 [1] 。
注意:
128个进制就可能会出现大量的不常用字符
比如 # % & * 这些,这样的话,对于短链接而言,通用性和记忆性就变差了,所以,62进制是个权衡折中。
3、短 URL 系统的功能分析
假设短地址长度为8位,62的8次方足够一般系统使用了
系统核心实现三大功能
-
发号
-
存储
-
映射
可以分为两个模块:发号与存储模块、映射模块
发号与存储模块
-
发号:使用发号器发号,为每个长地址分配一个号码ID,并且需要防止地址二义,也就是防止同一个长址多次请求得到的短址不一样
-
存储:将号码与长地址存放在DB中,将号码转化成62进制,用于表示最终的短地址,并返回给用户
映射模块
用户使用62进制的短地址请求服务 ,
-
转换:将62进制的数转化成10进制,因为咱们系统内部是long 类型的10进制的数字ID
-
映射:在DB中寻找对应的长地址
-
通过302重定向,将用户请求重定向到对应的地址上
4、发号器的高并发架构
回顾一下发号器的功能:
-
为每个长地址分配一个号码ID
-
并且需要防止地址歧义
以下对目前流行的分布式ID方案做简单介绍
方案1:使用地址的hash 编码作为ID
可以通过 原始Url的 hash编码,得到一个 整数,作为 短链的ID。哈希算法简单来说就是将一个元素映射成另一个元素,哈希算法可以简单分类两类,
-
加密哈希,如MD5,SHA256等,
-
非加密哈希,如MurMurHash,CRC32,DJB等。
MD5算法
MD5消息摘要算法(MD5 Message-Digest Algorithm),一种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(hash value),MD5算法将数据(如一段文字)运算变为另一固定长度值,是散列算法的基础原理。
由美国密码学家 Ronald Linn Rivest设计,于1992年公开并在 RFC 1321 中被加以规范。
CRC算法
循环冗余校验(Cyclic Redundancy Check)是一种根据网络数据包或电脑文件等数据,产生简短固定位数校验码的一种散列函数,由 W. Wesley Peterson 于1961年发表。
生成的数字在传输或者存储之前计算出来并且附加到数据后面,然后接收方进行检验确定数据是否发生变化。
由于本函数易于用二进制的电脑硬件使用、容易进行数学分析并且尤其善于检测传输通道干扰引起的错误,因此获得广泛应用。
MurmurHash
MurmurHash 是一种非加密型哈希函数,适用于一般的哈希检索操作。由 Austin Appleby 在2008年发明,并出现了多个变种,与其它流行的哈希函数相比,对于规律性较强的键,MurmurHash的随机分布特征表现更良好。
这个算法已经被很多开源项目使用,比如libstdc++ (4.6版)、Perl、nginx (不早于1.0.1版)、Rubinius、 libmemcached、maatkit、Hadoop、Redis,Memcached,Cassandra,HBase,Lucene等。
MurmurHash 计算可以是 128位、64位、32位,位数越多,碰撞概率越少。所以,可以把长链做 MurmurHash 计算,可以得到的一个整数哈希值 ,所得到的短链,类似于下面的形式
固定短链域名+哈希值 = www.weibo.com/888888888
如何缩短域名?传输的时候,可以把 MurmurHash之后的数字为10进制,可以把数字转成62进制
www.weibo.com/abcdef
那么,使用地址的hash 编码作为ID的问题是啥呢?会出现碰撞,所以这种方案不适合。
方案2:数据库自增长ID
属于完全依赖数据源的方式,所有的ID存储在数据库里,是最常用的ID生成办法,在单体应用时期得到了最广泛的使用,建立数据表时利用数据库自带的auto_increment作主键,或是使用序列完成其他场景的一些自增长ID的需求。
但是这种方式存在在高并发情况下性能问题,要解决该问题,可以通过批量发号来解决,提前为每台机器发放一个ID区间 [low,high],然后由机器在自己内存中使用 AtomicLong 原子类去保证自增,减少对DB的依赖,
每台机器,等到自己的区间即将满了,再向 DB 请求下一个区段的号码,为了实现写入的高并发,可以引入 队列缓冲+批量写入架构,等区间满了,再一次性将记录保存到DB中,并且异步进行获取和写入操作, 保证服务的持续高并发。
比如可以每次从数据库获取10000个号码,然后在内存中进行发放,当剩余的号码不足1000时,重新向MySQL请求下10000个号码,在上一批号码发放完了之后,批量进行写入数据库。
但是这种方案,更适合于单体的 DB 场景,在分布式DB场景下, 使用 MySQL的自增主键, 会存在不同DB库之间的ID冲突,又要使用各种办法去解决,总结一下, MySQL的自增主键生成ID的优缺点和使用场景:
-
优点:非常简单,有序递增,方便分页和排序。
-
缺点:分库分表后,同一数据表的自增ID容易重复,无法直接使用(可以设置步长,但局限性很明显);性能吞吐量整个较低,如果设计一个单独的数据库来实现 分布式应用的数据唯一性,即使使用预生成方案,也会因为事务锁的问题,高并发场景容易出现单点瓶颈。
-
适用场景:单数据库实例的表ID(包含主从同步场景),部分按天计数的流水号等;分库分表场景、全系统唯一性ID场景不适用。
所以,高并发场景, MySQL的自增主键,很少用。
方案3:分布式、高性能的中间件生成ID
MySQL 不行,可以考虑分布式、高性能的中间件完成。比如 Redis、MongoDB 的自增主键,或者其他 分布式存储的自增主键,但是这就会引入额外的中间组件。
假如使用Redis,则通过Redis的INCR/INCRBY自增原子操作命令,能保证生成的ID肯定是唯一有序的,本质上实现方式与数据库一致。但是,超高并发场景,分布式自增主键的生产性能,没有本地生产ID的性能高。
总结一下,分布式、高性能的中间件生成ID的优缺点和使用场景:
-
优点:整体吞吐量比数据库要高。
-
缺点:Redis实例或集群宕机后,找回最新的ID值有点困难。
-
适用场景:比较适合计数场景,如用户访问量,订单流水号(日期+流水号)等。
方案4:UUID、GUID生成ID
UUID:
按照OSF制定的标准计算,用到了以太网卡地址、纳秒级时间、芯片ID码和许多可能的数字。由以下几部分的组合:当前日期和时间(UUID的第一个部分与时间有关,如果你在生成一个UUID之后,过几秒又生成一个UUID,则第一个部分不同,其余相同),时钟序列,全局唯一的IEEE机器识别号(如果有网卡,从网卡获得,没有网卡以其他方式获得)
GUID:
微软对UUID这个标准的实现。UUID还有其它各种实现,不止GUID一种。这两种属于不依赖数据源方式,真正的全球唯一性ID。总结一下,UUID、GUID生成ID的优缺点和使用场景:
-
优点:不依赖任何数据源,自行计算,没有网络ID,速度超快,并且全球唯一。
-
缺点:没有顺序性,并且比较长(128bit),作为数据库主键、索引会导致索引效率下降,空间占用较多。
-
适用场景:只要对存储空间没有苛刻要求的都能够适用,比如各种链路追踪、日志存储等。
方式5:snowflake算法(雪花算法)生成ID
snowflake ID 严格来说,属于 本地生产 ID,这点和 Redis ID、MongoDB ID不同, 后者属于远程生产的ID。本地生产ID性能高,远程生产的ID性能低。
snowflake ID原理是使用Long类型(64位),按照一定的规则进行分段填充:时间(毫秒级)+集群ID+机器ID+序列号,每段占用的位数可以根据实际需要分配,其中集群ID和机器ID这两部分,在实际应用场景中要依赖外部参数配置或数据库记录。
总结一下,snowflake ID 的优缺点和使用场景:
-
优点:高性能、低延迟、去中心化、按时间总体有序
-
缺点:要求机器时钟同步(到秒级即可),需要解决 时钟回拨问题。如果某台机器的系统时钟回拨,有可能造成 ID 冲突,或者 ID 乱序。
-
适用场景:分布式应用环境的数据主键
高并发ID的技术选型
这里,不用地址的hash 编码作为ID。这里,不用数据库的自增长ID。这里,不用redis、mongdb的分布式ID。最终,这里,从发号性能、整体有序(B+树索引结构更加友好)的角度出发,最终选择的snowflake算法。
snowflake算法的吞吐量在 100W ops +。但是 snowflake算法 问题是啥呢?需要解决时钟回拨的问题。如何解决时钟回拨的问题,可以参考 推特官方的 代码、 百度ID的代码、Shardingjdbc ID的源码,综合存储方案设计解决。
Snowflake面临的一个主要问题是时钟回拨,即系统时钟被设置为之前的某个时间。以下是解决时钟回拨问题的几种方法:
1. 停止生成ID
当检测到时钟回拨时,最简单的方法是停止ID生成服务,等待时钟同步到回拨之前的时刻,然后继续生成ID。这种方法简单但会导致服务中断。
2. 使用等待策略
如果发生时钟回拨,可以让算法等待一定的时间,直到时钟追上回拨前的时刻。这种方法不会中断服务,但会影响ID生成的速度。
3. 记录并忽略回拨
可以记录最后一次生成ID的时间戳,如果检测到时钟回拨,则忽略这次回拨,继续使用记录的时间戳生成ID。这种方法需要确保回拨的时间足够短,不会影响到时间戳的唯一性。
4. 使用额外的逻辑
可以通过一些额外的逻辑来处理时钟回拨,例如:
- 预留时间戳:为时间戳分配一个较大的范围,当发生回拨时,使用预留的时间戳。
- 时间戳位扩展:增加时间戳部分的位数,以减少因回拨造成的时间戳冲突。
以下是具体实现时钟回拨解决方案的参考:
推特官方的代码
Twitter的Snowflake算法实现中,当检测到时钟回拨时,会抛出一个异常并停止ID生成,直到时钟同步。
百度ID的代码
百度的UIDGenerator在检测到时钟回拨时,会采用等待策略,并且提供了最大容忍回拨时间(默认为5秒),超过这个时间则抛出异常。
Shardingjdbc ID的源码
ShardingSphere的分布式ID生成器在检测到时钟回拨时,默认采用等待策略,并且可以通过配置来决定是否抛出异常或者等待时钟同步。
综合存储方案设计
在设计存储方案时,可以考虑以下几点:
- 冗余存储:将ID生成服务部署在多个节点上,即使某个节点时钟回拨,其他节点仍可正常工作。
- 持久化最后时间戳:将最后生成ID的时间戳持久化到数据库或其他存储系统中,在启动时加载,用于处理时钟回拨。
- 监控和报警:实施监控系统来检测时钟偏差,并在检测到时钟回拨时触发报警。
通过上述方法,可以在不同的场景下有效地解决时钟回拨问题,保证Snowflake算法在分布式系统中稳定地生成唯一ID
5、数据存储的高并发架构
这个数据,非常的结构化,可以使用结构化数据库MYSQL存储。
结构非常简单,我们会有二列:
1. ID,int, // 分布式雪花id;
2. SURL,varchar, // 原始URL;
接下来,开始高并发、海量数据场景,需要进行 MYSQL存储 的分库分表架构。然后按照分治模式,进行两大维度的分析架构:
-
数据容量(存储规模) 的 分治架构、
-
访问流量 (吞吐量规模)的 分治架构。
6、二义性检查的高并发架构
所谓的地址二义性,就行同一个长址多次请求得到的短址不一样。在生产地址的时候,需要进行二义性检查,防止每次都会重新为该长址生成一个短址,一个个长址多次请求得到的短址是不一样。
通过二义性检查,实现长短链接真正意义上的一对一。怎么进行 二义性检查?
最简单,最为粗暴的方案是:直接去数据库中检查。但是,这就需要付出很大的性能代价。要知道:数据库主键不是 原始url,而是 短链url 。如果根据 原始url 去进行存在性检查,还需要额外建立索引。问题的关键是,数据库性能特低,没有办法支撑超高并发 二义性检查。所以,这里肯定不能每次用数据库去检查。
这里很多同学可能会想到另一种方案,就是 redis 的布隆过滤, 把已经生成过了的 原始url,大致的方案是,可以把已经生成过的 原始url ,在 redis 布隆过滤器中进行记录。
每次进行二义性检查,走redis 布隆过滤器。
布隆过滤器就是bitset+多次hash的架构,宏观上是空间换时间,不对所有的 surl (原始url)进行内容存储,只对surl进行存在性存储,这样就节省大家大量的内存空间。在数据量比较大的情况下,既满足时间要求,又满足空间的要求。
布隆过滤器的巨大用处就是,能够迅速判断一个元素是否在一个集合中。布隆过滤器的常用使用场景如下:
-
黑名单 : 反垃圾邮件,从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱(垃圾短信)
-
URL去重 : 网页爬虫对 URL 的去重,避免爬取相同的 URL 地址
-
单词拼写检查
-
Key-Value 缓存系统的 Key 校验 (缓存穿透) : 缓存穿透,将所有可能存在的数据缓存放到布隆过滤器中,当黑客访问不存在的缓存时迅速返回避免缓存及 DB 挂掉。
-
ID 校验,比如订单系统查询某个订单 ID 是否存在,如果不存在就直接返回。
Bloom Filter 专门用来解决我们上面所说的去重问题的,使用 Bloom Filter 不会像使用缓存那么浪费空间。当然,他也存在一个小小问题,就是不太精确。
规则是:存在不一定存在,说不存在一定不存在
Bloom Filter 相当于是一个不太精确的 set 集合,我们可以利用它里边的 contains 方法去判断某一个对象是否存在,但是需要注意,这个判断不是特别精确。一般来说,通过 contains 判断某个值不存在,那就一定不存在,但是判断某个值存在的话,则他可能不存在。
那么对于 surl,处理的方案是:
-
如果 redis bloom filter 不存在,直接生成
-
否则,如果 redis bloom filter 判断为存在,可能是误判,还需要进行db的检查。
但是, redis bloom filter误判的概率很低,合理优化之后,也就在1%以下。可能有小伙伴说,如果100Wqps,1%也是10W1ps,DB还是扛不住,怎么办?
可以使用缓存架构,甚至多级缓存架构
具体来说,可以使用 Redis 缓存进行 热门url的缓存,实现部分地址的一对一缓存。
比如将最近/最热门的对应关系存储在K-V数据库中,比如在本地缓存 Caffeine中存储最近生成的长对短的对应关系,并采用过期机制实现 LRU 淘汰,从而保证频繁使用的 URL 的总是对应同一个短址的,但是不保证不频繁使用的URL的对应关系,从而大大减少了空间上的消耗。
7、映射模块(/转换模块)高并发架构
可以使用了缓存,二级缓存、三级缓存,加快id 到 surl的转换。
简单的缓存方案
将热门的长链接(需要对长链接进来的次数进行计数)、最近的长链接(可以使用 Redis 保存最近一个小时的数据)等等进行一个缓存,如果请求的长URL命中了缓存,那么直接获取对应的短URL进行返回,不需要再进行生成操作
复杂的缓存方案
服务间的重定向301 和 302 的不同
301永久重定向和 302 临时重定向。
-
301永久重定向:第一次请求拿到长链接后,下次浏览器再去请求短链的话,不会向短网址服务器请求了,而是直接从浏览器的缓存里拿,减少对服务器的压力。
-
302临时重定向:每次去请求短链都会去请求短网址服务器(除非响应中用 Cache-Control 或 Expired 暗示浏览器进行缓存)
使用 301 虽然可以减少服务器的压力,但是无法在 server 层获取到短网址的访问次数了,如果链接刚好是某个活动的链接,就无法分析此活动的效果以及用于大数据分析了。
而 302 虽然会增加服务器压力,但便于在 server 层统计访问数,所以如果对这些数据有需求,可以采用 302,因为这点代价是值得的,但是具体采用哪种跳转方式,还是要结合实际情况进行选型。
参考文章:
https://www.zhihu.com/question/29270034/answer/46446911
https://cloud.tencent.com/developer/article/1451239
https://www.cnblogs.com/jobs2/p/3301955.html