Quantcast
Channel: 数据库内核月报
Viewing all 692 articles
Browse latest View live

PolarDB · 牛逼产品 · 深入了解阿里云新一代产品 PolarDB

$
0
0

背景意义

云计算为如今的互联网时代提供了更多的计算能力,乃至创造能力,关系型数据库作为所有应用不可或缺的重要部件,开箱即用,高性价加比特性的云数据库深受开发者的喜爱。作为一线的开发和运维人员,在阿里云的线上值班中,我们经常会碰到以下经典的客户抱怨。
“你好,我用了你们的MySQL数据库,为啥在主库上稍微有点压力,我的备库就延迟啊啊啊啊啊,万一物理机挂了,你们这个主备切换到底会不会切过去,强行切过去的话,是不是数据就丢了??”
“啊啊啊,江湖救急啊,为啥我这个只读实例看到的还是昨天的数据啊,最近的数据貌似没有同步过来啊。。什么?你说因为数据不一致复制中断了???我擦,什么鬼啊”
“我在4天前,手工做了一个备份,之前你们说差不多70个小时备份就可以成功了,为啥现在还没好??啥?你说因为数据量太大,备份在68小时的时候失败了,就因为我执行了一个大表的optimize语句?你妹的,太不靠谱了吧,我老板还着急要数据呢”
“我上周五,花钱买了一个只读实例,然后这都周三了,实例还没创建好,3T数据量是大了一点,但是貌似也没那么满吧?”
“你们这个RDS性能貌似还没有我在ECS自建的数据库性能好啊,什么情况?阿里云数据库技术不是很牛逼么?不行,我要迁回自建数据库”
上述抱怨/吐槽都来自用户真实的案例,总结起来,传统的云数据库由于自身架构原因,会遇到如下问题:
1. 读写实例和只读实例通过增量逻辑数据同步,读写实例上所有的SQL需要在只读实例上重新执行一遍(包括SQL解析,SQL优化等无效步骤),同时,复制并发读最高是基于表维度,导致主备延迟非常普遍,进而影响高可用主备切换。
2. MySQL由于早期复制代码质量原因,在有一些极端的情况下,容易导致复制中断,进一步影响主备复制。
3. 读写实例和只读实例各自拥有一份独立的数据,新建一个只读实例需要重新拷贝数据,考虑到网络限流,速度不会很快。
4. 传统备份技术,由于也涉及到拷贝数据,并上传廉价存储,速度因此也受网络影响。
5. MySQL数据库早期的版本,对早期的系统/硬件做了很多优化,但是并没有考虑到现代主流的系统/硬件的优秀特性,在高并发环境下,性能还有很大的提升空间。
随着数据库数据量的增大,这些小麻烦会不断的加剧,给DBA,开发乃至CTO带来困恼。
如今,这些困扰大家已久的问题,在阿里云即将推出去的PolarDB中将得到解决,注意,是从本质上解决,而不是想个trick绕过去。

PolarDB是阿里云ApsaraDB数据库团队研发的基于云计算架构的下一代关系型数据库(暂时仅支持MySQL,PostgreSQL正在紧锣密鼓的开发中),其最大的特色是计算节点(主要做SQL解析以及存储引擎计算的服务器)与存储节点(主要做数据块存储,数据库快照的服务器)分离,其次,与传统的云数据库一个实例一份数据拷贝不同,同一个用户的所有实例(包括读写实例和只读实例)都访问存储节点上的同一份数据,最后,借助优秀的RDMA网络以及最新的块存储技术,PolarDB的数据备份耗时可以做到秒级别(备份时间与底层数据量无关),这三点相结合,我们可以推断出PolarDB不但满足了公有云计算环境下用户业务快速弹性扩展的刚性需求(只读实例(包括克隆实例)扩展时间与底层数据量无关),同时也满足了互联网环境下用户对数据库服务器高可用的需求(服务器宕机后无需搬运数据重启进程即可服务)。

架构分析

image.png
image.png

图1是PolarDB公测版本的总体架构图。图2是PolarDB数据库内核上的架构。
解释一下图1中的各个组件。
上部分三个黑色框表示三台计算节点,下部分三个蓝色框表示三台存储节点。
在计算节点上,主要有三个重要部件:

DB Server:即数据库进程(Polar DataBase, 简称PolarDB)。PolarDB数据库内核区分实例角色,目前包括三种角色,Primary,Standby和Replica。Primary即为拥有读写权限的读写库,Replica即为只读实例,仅仅拥有读取数据的权限(后台线程也不能修改数据),Primary和Replica采用Shared Everything架构,即底层共享同一份数据文件和日志文件。StandBy节点拥有一份独立的数据和日志文件(如图2所示),虽然用户线程依然只有读取数据的权限,但是后台线程可以更新数据,例如通过物理复制的方式从Primary节点更新增量数据。StandBy节点主要用来机房级别的容灾以及创建跨可用区的只读实例,公测阶段暂时不开放。由于只读实例的扩展不需要拷贝数据,创建新的只读实例不但速度快,而且很便宜,用户只需要支付相应计算节点的成本即可。我们称StandBy和Replica节点为Slave节点,Primary节点也可称为Master节点。

User Space File System:即用户态文件系统(Polar File Sytem, 简称PolarFS)。由于多个主机的数据库实例需要访问块存储上的同一份数据,常用的Ext4等文件系统不支持多点挂载,PolarDB数据库团队自行研发了专用的用户态文件系统,提供常见的文件读写查看接口,便于MySQL和相关的外围运维工具使用文件系统支持类似O_DIRECT的非缓存方式读写数据,还支持数据页原子写,IO优先级等优秀的特性,为上层数据库的高性能提供了结实的保障。传统的文件系统,由于嵌入在操作系统内核中,每次系统文件读写操作都需要先陷入内核态,完成后再返回用户态,造成效率低下。PolarFS以函数库形式编译在MySQL中,因此都运行在用户态,从而减少了操作系统切换的开销。

Data Router & Cache:即块存储系统客户端(Polar Store Client, 别名PolarSwitch)。PolarFS收到读写请求后,会通过共享内存的方式把数据发送给PolarSwitch,PolarSwith是一个计算节点主机维度的后台守护进程,接收主机上所有实例以及工具发来的读写块存储的请求。PolarSwith做简单的聚合,统计后分发给相应的存储节点上的守护进程。由此可见PolarSwitch是一个重资源的进程,如果处理不好,对计算节点上的数据库实例有很大的影响,因此我们的管控程序对其使用了CPU绑定,内存预分配,资源隔离等一些手段,并且同时部署了高效可靠的监控系统,保证其稳定运行。

Data Chunk Server:即块存储系统服务器端(Polar Store Server, 别名ChunkSever)。上述三个部件都运行在计算节点上,这个部件则运行在存储节点上。主要负责相应数据块的读取。数据块的大小目前为10GB,每个数据块都有三个副本(位于三台不同的存储节点上),两个副本写成功,才给客户端返回成功。支持数据块维度的高可用,即如果一个数据块发生不可用,可以在上层无感知的情况下秒级恢复。此外,PolarStore使用了类似Copy On Write技术,支持秒级快照,即对数据库来说,不管底层数据有多大,都能快速完成全量数据备份,因此PolarDB支持高达64T的磁盘规格。
计算节点和存储节点之间通过25G RDMA网络连接,保证数据的传输瓶颈不会出现在网络上。
此外,PolarDB还有一套完善的基于docker的管控系统,处理用户下发的创建实例,删除实例,创建账号等任务,还包括完善详细的监控,以及可靠的高可用切换。管控系统还维护了一套元数据库,用以记录各个数据块的位置信息,提供给PolarSwitch,便于其转发。
可以说,PolarDB整个项目用了很多很多的新技术黑科技,给用户直接的感受是,又快(性能是官方MySQL5倍)又大(磁盘规格支持高达64T)又便宜(价格只有商业数据库的1/10)。
接下里,我们重点分析PolarDB在MySQL内核方面的功能增强、性能优化以及未来的规划。便于读者了解PolarDB数据库内部运行的机制。

功能增强

物理复制

简单的想,如果读写实例和只读实例共享了底层的数据和日志,只要把只读数据库配置文件中的数据目录换成读写实例的目录,貌似就可以直接工作了。但是这样会遇到很多问题,随便列举几条:
1. 如果读写实例上对某条数据进行了修改,由于Buffer Pool缓存机制,数据页可能还没有被刷新到磁盘上,这个时候只读实例就会看不到数据,难道每次都刷盘,那跟文件有什么区别。
2. 再仔细想一想事务,一个读写事务,修改了3个数据页,2个数据页刷下去了,但是1个数据页还没有刷盘,这个时候只读节点因为需要查询也需要这几个数据页,然后数据就不一致了?也就是说只读节点上的MVCC机制似乎会被破坏。
3. 考虑一下DDL,如果在读写实例上把一个表给删除了,万一只读实例上还有对这个表的查询呢?只读实例去哪里查询数据哈?要知道IBD文件已经被读写实例这个家伙干掉了,可怜的只读实例只能coredump以示不满?
4. 如果读写实例正在写一个数据页,然而刚好只读实例也要读取这个数据页,然后只读实例有可能读了一个写了一半的数据页上来,然后checksum校验不过,直接挂,哭。
所以,那些觉得数据库共享数据很简单的,可以洗洗睡了。为了解决上述问题,我们需要只读实例也按照读写实例更新数据的节奏来更新数据并且需要一套完善的脏页刷盘机制。现代关系型数据库中其实已经有一份记录数据页更新的Redolog事务日志了,在MySQL中,就是ib_logfileXX类似这种文件,因此参考Binlog复制框架,PolarDB使用这种事务日志构建了一套数据复制方法,在只读实例上只需要顺序应用读写实例产生的日志即可,StandBy节点和Replica节点两者都需要更新内存中的数据结构,但是StandBy节点由于独立维护一份数据和日志,所以需要把更新的增量数据写入到自己的数据文件和日志中,而Replica则不需要。由于MySQL的Redolog相比于Binlog记录的都是物理的数据页(当然也有逻辑部分),所以我们把这种复制称为物理复制。
由于Redolog并没有记录用户的SQL,仅仅记录了最终的结果,即这个SQL执行后造成的数据页变化,所以依赖这种复制架构,不需要SQL解析SQL优化,MySQL直接找到对应的文件中的数据页,定位到指定偏移,直接更新即可。因此性能上可以做到极致,毕竟并发粒度从之前的表级别变为了数据页级别。当然也会带进来很多新的问题,简单列举几个:
物理复制中的MVCC。MySQL的MVCC依赖Undo来获取数据的多版本,如果Primary节点需要删除一个Undo数据页,这个时候如果Replica节点还在读取的话就会有问题,同理,StandBy节点也有这个问题,因此我们给客户提供两种方式,一种是所有Slave定期向Primary汇报自己的最大能删除的Undo数据页,Primary节点统筹安排,另外一种是当Primary节点删除Undo数据页时候,Slave接收到日志后,判断即将被删除的数据页是否还在被使用,如果在使用则等待,超过一个时间后直接给客户端报错。此外,为了让Slave节点感知到事务的开始和结束以及时间点,我们也在日志中增加了不少逻辑日志类型。
物理复制中的DDL。Primary节点删除一个表之后,Replica可能还会有对此表的请求。因此,我们约定,如果主库对一个表进行了表结构变更操作,在操作返回成功前,必须通知到所有的Replica(有一个最大的超时时间),告诉他们,这个表已经被删除了,后续的请求都失败吧,具体实现上可以使用MDL锁来控制。当然这种强同步操作会给性能带来极大的影响,后续我们会对DDL做进一步的优化。
物理复制中的数据复制。除了复制引擎层的数据之外,PolarDB还需要考虑MySQL Server层表结构的一些文件复制,例如frm, opt文件等。此外,还需要考虑一些Server层的Cache一致性问题,包括权限信息,表级别的统计信息等。
物理复制中的日志并发应用。既然物理日志可以让我们按照数据页的维度来支持并发,那么PolarDB需要充分的利用这个特性,同时保证在数据库奔溃后也能正确的应用日志和回滚事务。其实这部分的代码逻辑与MySQL崩溃恢复的逻辑很像,PolarDB对其进行了复用和改造,做了大量的性能上的优化,保证物理复制又快有稳。
物理复制中的Change Buffer问题。Change Buffer本质上是为了减少二级索引带来的IO开销而产生的一种特殊缓存机制。当对应的二级索引页没有被读入内存时,暂时缓存起来,当数据页后续被读进内存时,再进行应用,这个特性也带来的一些问题,例如Primary节点可能因为数据页还未读入内存,相应的操作还缓存在Change Buffer中,但是StandBy节点则因为不同的查询请求导致这个数据页已经读入内存,发生了Change Buffer Merge操作,从而导致数据不一致,为了解决这个问题,我们引入shadow page的概念,把未修改的数据页保存到其中,将change buffer记录合并到原来的数据页上,同时关闭该Mtr的redo log,这样修改后的Page就不会放到flush list上了。此外,为了保证Change Buffer merge中,不被用户线程看到中间状态,我们需要加入新的日志类型来控制。
物理复制中的脏页控制。Primary节点不能毫无控制的刷脏页,因为这样Replica会读取到不一致的数据页,也会读取到未提交事务的数据页。
物理复制中的Query Cache问题,discard/import表空间问题,都需要考虑。
此外,由于PolarDB暂时只支持InnoDB引擎,Myisam和Tokudb暂时都不支持,而系统表很多都是Myisam的,所以也需要转换。
物理复制能带来性能上的巨大提升,但是逻辑日志由于其良好的兼容性也并不是一无是处,所以PolarDB依然保留了Binlog的逻辑,方便用户开启。

实例角色

传统的MySQL数据库并没有实例角色这个概念,我们只能通过查询read_only这个变量还判断实例当前的角色。PolarDB中引入三种角色,满足不同场景下的需求。Primary节点在初始化的时候指定,StandBy和Replica则通过后续的配置文件指定。

故障切换。

目前支持三种类型的切换,Primary降级为StandBy,StandBy提升为Primary以及Replica提升为Primary。每次切换后,都会在系统表中记录,便于后续查询。Primary节点和StandBy节点由于拥有各自的数据文件,在异步复制的模式下,容易造成数据不一致,我们提供了一种机制,保证新StandBy的数据一定与新Primary的数据一致。想法很简单,当新StandBy再次启动时,去新Primary查询,回滚掉多余的数据即可,只是我们这里不使用基于Binlog的SQL FlashBack,而是基于Redolog的数据页FlashBack,更加高效和准确。Primary节点和Replica节点,由于共享同一份数据,不存在数据不一致的分享,当发生切换的时候,新Primary只需要把老Primary未应用完的日志应用完即可,由于这个也是并行的操作,速度很快。
这里简单提一下,目前公测阶段的故障切换逻辑:首先是管控系统检测到Primary不可用(或者发起主动运维操作),则连接上Primary(如果还可以),kill所有用户连接,设置read_only,设置PolarStore对此IP只读(Primary节点和Replica节点一定在不同的计算节点上),接着,连接到即将升级的Replica上,设置PolarStore对此IP读写,MySQL重新以读写模式挂载PolarFS,最后执行Replica提升为Primary的语句。正常情况下,整个过程时间很短,30秒内即可切换完成,但是当Primary和Replica延迟比较大的时候,需要更多的时间,但是相比于之前Binlog的复制,延迟时间至少下降2个数量级。后续我们会对这块继续进行深度优化,进一步提高实例可用性。

复制模式

传统的Binlog复制模式只有异步复制和半同步复制两种。在PolarDB中,我们还增加了强同步复制,即要求Slave节点应用完数据才返回成功,这个特性对复制特别敏感的用户来说,是个好消息,能保证只读实例查询出来的数据一定与读写库上查询出来的一样。此外,PolarDB还支持延迟复制功能,复制到指定事务,复制到指定时间点等,当然这几个特性主要是提供给灾备角色StandBy的。此外,为了进一步减少复制延迟,如果Replica发现自身延迟超过某个阈值,就会自动开启Boost模式(会影响实例上的读),加速复制。如果Primary节点发现某个Replica延迟实在太大,出于安全考虑,会暂时把这个Replica踢出复制拓扑,这个时候只需要重新启动一下Replica即可(由于Replica不需要做奔溃恢复,重启操作很快)。PolarDB还支持复制过滤功能,即只复制指定的几个数据库或者指定几个表,这样由于不需要复制查询不感兴趣的数据,从而可以进一步降低复制延迟。这里提到的各种有趣的特性,在公测阶段还暂时不支持,我们内部正在做更多的测试,在不久的将来,会陆续开放给大家。

日志管理

类似Binlog的日志管理,PolarDB也提供了类似的机制和工具来管理Redolog。首先,与传统MySQL循环使用Redolog不同,与Binlog类似,PolarDB中Redolog也是以文件编号递增的方式来管理,并提供相应的删除命令给管控系统使用。PolarDB依据PolarStore的全量备份和Redolog增量日志为客户提供还原到任意时间点的功能。PolarDB还有一个专门用来解析Redolog日志的工具mysqlredolog,类似Binlog解析工具mysqlbinlog,这个工具在系统诊断中非常有用。Redolog中,PolarDB也增加了不少独特的日志类型,为了做到兼容性,Redolog也有版本管理机制。传统的MySQL仅仅数据文件支持O_DIRECT,PolarDB为了适配新的文件系统,Redolog日志也支持O_DIRECT方式写入。
此外,PolarDB对Undo日志也进行了深度的开发,目前支持Online Undo Truncate,妈妈再也不用担心我的ibdata数据文件过大啦。

监控信息

PolarDB增加了那么多功能,自然也会提供很多新的命令,例如用户可以使用SHOW POLAR STATUS来查看相关信息,使用SHOW POLAR REPLICAS来查看所有已经连接的replica节点。使用START POLAR SLAVE来启动复制,使用SHOW POLAR LOGS来查看产生的Redolog文件,等等等。PolarDB在information_schema中也增加了好多表,例如INNODB_LOG_INFO, INNODB_LOG_APPLY_INFO, INNODB_LOG_READER, INNODB_POLAR_HISTORY等,有兴趣的读者可以猜一下,这里面到底存的是什么。

实例迁移

目前数据库内核支持(大概再过几个月会提供给用户使用)从RDS 5.6迁移到PolarDB上,流程主要如下,首先在RDS 5.6上使用xtrabackup做个全量的数据备份,接着在计算节点上,xtrabackup做恢复,成功后,通过PolarFS提供的运维工具把所有的数据文件放到PolarStore上,然后使用特定的参数启动PolarDB,PolarDB会把RDS 5.6日志文件格式转换成PolarDB的Redolog,接着可以使用Binlog方式追增量数据,当追上RDS 5.6的读写库且到达用户指定的切换时间,设置RDS 5.6读写库只读,并且PolarDB做一次重启,关闭Binlog复制,最后把VIP切换到PolarDB上即可完成迁移工作。

周边工具

除了上文提到的解析Redolog日志工具外,由于对源码进行了大幅度的改动,PolarDB还对MySQL原生的TestCase Framework进行了改动,保证在共享数据日志的场景下(Local FS/Disk and PolarFS/PolarStore)所有的Testcase能通过。

性能优化

PolarDB除了拥有大量新的特性外,我们还做了很多性能上的优化,这里简单列举一些:
1. 在高并发的场景下,PolarDB对很多latch做了优化,把有些latch分解成粒度更小的锁,把有些latch改成引用计数的方式从而避免锁竞争,例如Undo segment mutex, log system mutex等等。PolarDB还把部分热点的数据结构改成了Lock Free的结构,例如Server层的MDL锁。
2. 针对用户在SQL语句中直接指定主键的SQL语句,PolarDB也做了相应的优化,这样可以减少一些优化器的工作,从而得到更好的性能。
3. PolarDB中物理复制的性能至关重要,我们不仅通过基于数据页维度的并行提高了性能,还对复制中的必要流程进行了优化,例如在MTR日志中增加了一个长度字段,从而减少了日志Parse阶段的CPU开销,这个简单的优化就能减少60%的日志Parse时间。我们还通过复用Dummy Index的内存数据结构,减少了其在Malloc/Free上的开销,进一步提高复制性能。
4. Redolog的顺序写性能对数据库性能的影响很大,为了减少Redolog切换时对性能的影响,我们后台采用类似Fallocate的方式预先分配日志文件,此外,现代的SSD硬盘很多都是4K对齐,而MySQL代码还是按照早期磁盘512字节对齐的方式刷日志的,这样会导致磁盘做很多不必要的读操作,不能发挥出SSD盘的性能,我们在这方面也做了优化。
5. PolarDB的Replica节点,日志目前是一批一批应用的,因此当新的一批日志被应用之前,Replica上的读请求不需要重复创建新的ReadView,可以使用上次缓存下来的。这个优化也能提高Replica上的读性能。
6. PolarDB对临时表也做了一些优化,例如在临时表上可以把Change Buffer关掉,临时表的修改可以少做一些操作,例如在应用日志的时候可以不对索引加锁等。
7. PolarDB也继承了AliSQL几乎所有的性能优化点,例如,日志核心函数log_write_up_to的优化, LOCK_grant锁的优化,jemalloc内存分配库的使用,日志Group Commit的优化,Double RedoLog Buffer的优化,Buffer Pool Hazard Pointer的优化,自适应concurrency tickets的优化,只读事务的优化等等,详情可以参考GitHub上的AliSQL首页。

展望未来

PolarDB目前已经公测,但是未来我们还有很多有趣的特性可以做,在性能方面也有很多的优化点,例如:
1. 目前PolarDB高可用切换和实例可用性检测都需要依赖外部的管控系统,另外一方面,由于Primary和Replica共享数据和日志,如果Replica和Primary之间的通信中断,Replica就感知不到数据文件的状态,在这种情况下,Replica将会不可服务,为了解决这两个问题,PolarDB也会引入类似Raft协议下的自制集群机制,自动检测可用性,自动发起切换。具体实现可以参考RDS金融版三节点实例自制集群的方案。
2. 可以在Replica节点上引入类似Materialized view的特性,当然这里不是指数据库概念中的视图,而是InnoDB中的ReadView。
3. 上文提到过,目前的DDL是个强同步点,因此我们需要进一步优化DDL,例如引入文件多版本来解决Replica读的问题。另外,Online DDL很多情况下依然需要拷贝整个数据文件,也许我们可以参考PolarStore COW的思想,把这部分拷贝数据的IO压力分摊到后续的DML中。
4. 在阿里云,用户有很多上云迁移数据的需求,物理迁移的方式毕竟只能服务MySQL数据库,逻辑的SQL导入才是最通用的解决方法,因此我们需要尽量提高在导入大量数据时的性能。一个简单的优化就是数据按照主键顺序排序,我们记录最后一次插入的BTree页节点位置,后续插入就不需要再从Root节点开始扫描了。我们也可以在导入数据的时候,暂时关闭Redolog日志写入,也能提高性能,类似Oracle Nologging机制。
5. 在InnoDB锁机制上,PolarDB也还可以做更多的优化,使用更多的lock free结构,减少锁冲突。我们也考虑使用最新的Futex来取代效率低下的Spinlock,当然这些功能需要经过完善缜密的测试,才能上线。
6. 由于PolarFS/PolarStore能提供16K数据页维度的原子写支持,所以我们可以把InnoDB的double write buffer给关闭,并对写数据页的操作进一步优化,例如通过某些机制可以释放数据页在刷盘时hold住的读锁。PolarDB也可以使用PolarFS提供的IO优先级功能,保证日志是以第一优先级刷入磁盘。这些改动能大幅度提升性能,但是与锁机制类似,我们需要做充足的测试。
7. 此外,我们也会Port官方MySQL 8.0的最新的数据字典结构,因为MySQL支持事务性DDL需要这些必要的改动。

写在最后

在数据库内核,文件系统内核以及块存储内核上,阿里云ApsaraDB团队倾尽所有,不骄不躁,卧薪尝胆,三年耕耘,一个全新的自研云数据库即将诞生,PolarDB不但是阿里云云计算2.0时代产品进化的关键里程碑之一,也是开源数据库生态的积极推动力(在适当的时候,与AliSQL类似,我们会开源代码)。如果您在上述三个领域有比较深的造诣且对技术有无法自拔的好感和兴趣,那么欢迎加入我们,跟我们一起称霸亚洲走向世界。

参考文献:

http://www.infoq.com/cn/news/2017/08/ali-polardb
https://www.percona.com/live/data-performance-conference-2016/users/zhai-weixiang
https://www.percona.com/live/17/users/zhai-weixiang
http://mysql.taobao.org/monthly/

image.png


HybridDB · 最佳实践 · 阿里云数据库PetaData

$
0
0

前言

随着互联网DT时代的高速发展,业界需要简单高效的数据处理方式在海量数据中挖掘价值,企业厂商和开源界目前较流行的的做法,是提供支持类SQL接口的数据库服务,或者是提供SDK接口的数据处理平台服务。

在SQL接口的数据库服务中,传统的关系数据库,如MySQL、PG等,处理海量数据显得越来越力不从心,既无法突破单机硬件资源限制,又无法并行利用多机硬件资源;大数据NewSQL数据库,必须依赖外部数据库保证数据的事务特性,并通过数据导入工具将完整提交的数据导入进来计算,系统复杂度和成本较高。

在SDK接口的数据处理平台服务中,用户需要进行再次开发,扩展性和自由度较好,但是上线周期较长,易用性较差,且需要长期维护代码,保证稳定性和性能。

那么问题来了,是否有一类产品,能够兼顾易用性、大容量、低成本,既支持高并发低延迟OLTP业务,又支持海量数据的OLAP业务,一站解决这些问题呢?本文将介绍阿里云HTAP数据库HybridDB for MySQL,为用户解决海量数据处理的问题。

阿里云HTAP数据库HybridDB for MySQL

HTAP数据库是数据库专业评级机构Gartner提出的新数据库象限,指能够同时支持OLTP和OLAP业务的分布式数据库,典型的产品如,国外的SAP Hana和Oracle RAC,阿里云自研的HybridDB for MySQL等。

1.建设初衷和设计思路

HTAP数据库是数据库专业评级机构Gartner提出的新数据库象限,指能够同时支持OLTP和OLAP业务的分布式数据库,典型的产品如,国外的SAP Hana和Oracle RAC,阿里云自研的HybridDB for MySQL等。

在过去的几年,阿里云出现了大量的全链路监控分析类需求,包括监测物理机的资源消耗、网络的流量延迟、业务实例的内部统计,多维聚合分析各类监控数据并找出全链路异常,找出具体问题进行自动化运维等。在选型数据库时考虑到了这些问题:

  1. 数据总量大,日新增数据量大:单个业务每日新增各类统计数据,从2T-20T不等,数据保存至少30天,总量百T甚至上P;
  2. 访问并发大:数据库总连接在数百到数万不等,并发活跃连接在数百到数千不等;
  3. 响应延迟低,支持update批量数据:写入延迟不得超过秒级,部分业务需要update批量数据,以支持多轮迭代分析;
  4. 支持多维度检索和复杂分析:支持从不同维度查询数据,且各维度查询均不得超过秒级,同时要支持各类复杂的分析类需求,分析类sql支持范围覆盖tpc-h、tpc-ds等,分析类查询时间范围在秒级到小时级;
  5. 数据自动过期:用户设定一个数据保留的时间范围,数据库可以自动帮助用户清理;
  6. 在线扩容:数据库可随数据总量规模扩大而扩容,以承载更大规模的业务;
  7. 使用简便:用户可以使用SQL接口,无需额外写计算代码;无需关心数据备份等运维问题,监控告警系统完善;数据操纵、数据导入等生态周边齐全,支持常用的网络链路类型;
  8. 成本:数据库有低成本方案,支持冷数据以更低成本存储;

在这些苛刻的需求下,使得我们无法选择kv类存储引擎,因为在非主键类查询场景下,必须扫描全库数据,这导致查询完全不可用;也无法选择mr类计算引擎,因为其单次查询延迟过高,无法用在高并发业务场景中。RDS团队决定自己动手,自研一个数据库,以解决同类问题。
pic
HTAP数据库的技术思路,是将链路、存储、计算完全分离,且各个组件均允许水平扩容,存储分区间无共享,一份存储数据,扩容时无需搬动全局数据,精细地对每一类业务场景的SQL设计执行链路,以保证低延迟和高吞吐,各个组件的硬件容器可以替换,从而保证高性能和低成本可以兼得。用户只需要利用MySQL的各类连接器和客户端,如jdbc、navicat等,就可以直接使用和访问数据库,兼容用户的各类使用习惯。

2.HTAP数据库云化服务

经历数年的成长,HybridDB for MySQL先后服务了集团内外的多个用户,包括RDS、SLB、CDN、菜鸟、安全等团队,日新增数据数百T,存量数据数P。

在公有云上,HybridDB for MySQL已经积累了大量的云服务接口,与RDS和传统解决方案对比起来:
pic

此外,HybridDB for MySQL也在努力补齐其他云服务功能,以对齐RDS for MySQL,支持常用的数据操纵平台,如DMS等,支持常用的数据迁移平台,如DTS、CDP等。

HTAP数据库在阿里云的最佳实践

随着互联网DT时代的高速发展,业界需要简单高效的数据处理方式在海量数据中挖掘价值,企业厂商和开源界目前较流行的的做法,是提供支持类SQL接口的数据库服务,或者是提供SDK接口的数据处理平台服务。

1.典型应用和架构

HybridDB for MySQL的一个典型应用,是在阿里云全链路大盘业务中,该业务涉及了阿里云多个核心产品的数据汇总分析、多维处理,引用该案例,可以介绍HTAP数据库的最佳实践。
pic

上图中体现了HybridDB for MySQL的几个典型应用:

  1. 作为分布式数据库,承接第一手数据写入和更新事务,保证数据的完整性,并为各类外部查询业务,提供不同层面的查询支持,包括高并发多维在线查询、数据报表、复杂分析等;
  2. 作为数据仓库,将HybridDB for MySQL内的数据进行二次加工,以支持ETL类业务;
  3. 作为更大规模数据处理系统(如odps)的数据缓存,利用数据交换工具工具,将外部数据源的数据汇总到HybridDB for MySQL中,以支持活跃数据的存储和计算;
  4. 作为各个子系统的数据总线,利用数据交换工具工具,将数据过滤导出到各个子系统中,以帮助这些专项子系统对数据进一步处理;
    接下来将逐步介绍HybridDB for MySQL在这些应用场景的最佳实践。

2.实时写入、实时多维查询、实时分析

支持实时、高并发、低延迟类大数据业务,是HybridDB for MySQL的杀手锏,整套系统,仅有一份数据,既可保证在线业务的数据,具备事务特性,又可支持超越传统数据库的分析函数、计算语法等。

在阿里云全链路大盘中,包括RDS、SLB、CDN等产品的原始性能数据(如主机cpu、iops,业务实例qps、副本延迟,全局网络延迟等),需要实时汇总到中心HybridDB for MySQL数据库中,而这些数据在storm、flink、galaxy等流计算平台进行聚合分析(如每个时间窗口的统计量加和、join、开窗分组等),产生的分析结果,也将汇入相同的HybridDB for MySQL数据库中,单个业务产生的性能数据,常常达到每秒数百万条,并发活跃连接常常达到数百条甚至上千条。而这些原始数据和流式分析结果,也需要支持实时、多维度的呈现在用户面前,有的分析结果用于人工排查问题;有的分析结果则可以直接触发报警;有的分析结果可以指导自动化运维,如数据迁移等;有的分析结果可以用于指导产品运营状况、用户画像等。
pic

RDS数据分析系统是这类业务的翘楚,并引领了一股实时写入、实时分析的风潮,帮助RDS大幅改善了运维体验,提升了整个RDS的稳定性,发现和解决问题的周期,由过去的月、周级,减少到了现在的天、小时级,且有进一步提升的趋势。

3.批处理ETL

有些计算场景下,单次流计算并不能解决所有问题,一些较复杂的业务场景,比如参照大量的历史数据、外部数据源数据,进行级联事件分析(如全链路大盘参考ECS、SLB等产品的性能数据,挖掘RDS在过去一个月内的链路延迟状况)、事件回放,这类计算业务,则依赖HybridDB for MySQL已经存储的历史数据和外部数据源的数据进行综合计算,此时需要批量数据的ETL,重新抽取各个数据源的历史数据,进行再次计算。

在HybridDB for MySQL中,ETL则简化为了一类SQL语句模式:Insert into … Select …,这类语句,从HybridDB for MySQL的各个数据表中抽取数据,进行复杂计算,并将计算结果持久化下来,以便用户的多次访问。ETL功能可以令HybridDB for MySQL形成数据闭环,进一步降低用户导数据的开销和复杂度。
pic

HybridDB for MySQL的ETL功能预计6月初上线,阿里云部分云产品的数据分析系统正在试用该功能。

4.实时数据总线

在HybridDB for MySQL不能解决所有大数据问题时,用户还可以将HybridDB for MySQL当做数据总线,利用数据交换工具(如DTS、CDP等),将数据导出到其它数据存储和计算系统上,从而发挥数据更大的价值。
pic

从HybridDB for MySQL导出数据,可以有不同的目的,包括:导入到大数据计算存储平台(如ODPS、OSS),用于存储更长期限的历史数据,在需要时,亦可导回HybridDB for MySQL计算;导入到队列(如loghub、kafka),以供用户自己的消息总线播散数据,并级联到其它系统上;导入到RDS,以供用户在RDS生态下,继续扩展数据业务。

用HybridDB for MySQL充当数据总线的业务,以公有云用户居多,这些用户同时使用了阿里云的多套产品,组合出复杂的业务网,为了降低系统间的耦合性和成本,通常选择HybridDB for MySQL,既充当一级数据存储和计算系统,又充当二级数据总线,串联起其他的服务端。

5.实时数据缓存

凭借强大的存储和计算带宽,HybridDB for MySQL还可作为外部数据源的实时分布式数据缓存,利用数据交换工具(如DTS、CDP等),从其它数据存储和计算系统上,将数据导入到HybridDB for MySQL,可以帮助这些系统具备海量数据的实时处理能力。
pic

用HybridDB for MySQL充当数据总线的业务,以搜索类、日志类用户居多,这类用户期望在大数据存储和计算平台上的数据(如ODPS)也能有实时查询的能力,或者期望暂存数据系统(如loghub、kafka)能够支持多维数据检索,又或者期望小型数据库(如RDS)能够存储更多的历史数据,HybridDB for MySQL便很好的弥补了这些系统的缺憾,从而与这些系统共同提升和改进。

后记

HybridDB for MySQL依靠实时、高并发、低延迟的特性,已经应用在集团内外数十个企业级业务中,随着生态和功能越来越完善,HybridDB for MySQL将能承载更多的业务类型,帮助用户在DT时代快速发展。

MySQL · 捉虫动态 · show binary logs 灵异事件

$
0
0

问题背景

最近在运维 MySQL 中遇到一个神奇的问题,分享给大家。现象是这样的,show binary logs没有返回结果,flush binary logs后也不行,
但是 binlog 是正常工作的,show master staus是有输出的。

mysql> show binary logs;
Empty set (0.00 sec)

mysql> show master status\G
*************************** 1. row ***************************
             File: master-bin.000004
         Position: 120
     Binlog_Do_DB:
 Binlog_Ignore_DB:
Executed_Gtid_Set:
1 row in set (0.00 sec)

mysql> show binary logs;
Empty set (0.00 sec)

mysql> show master status\G
*************************** 1. row ***************************
             File: master-bin.000004
         Position: 120
     Binlog_Do_DB:
 Binlog_Ignore_DB:
Executed_Gtid_Set:
1 row in set (0.00 sec)

mysql> flush binary logs;
Query OK, 0 rows affected (0.01 sec)

mysql> show binary logs;
Empty set (0.00 sec)

mysql> show master status\G
*************************** 1. row ***************************
             File: master-bin.000005
         Position: 120
     Binlog_Do_DB:
 Binlog_Ignore_DB:
Executed_Gtid_Set:
1 row in set (0.00 sec)

mysql>

问题排查

这个问题是笔者第一次遇到,从问题的现象看,binlog 是没有问题的,可以正常写入和切换,只是 show 命令看不到 binlog 文件列表,我们知道 MySQL 是用一个 index 元文件来维护当前使用的 binlog 的,而 show binary logs也是读这个文件来展示的,因此问题应该出在 index 文件上。

我们首先检查 index 文件的权限,发现也是没问题的,mysqld 进程用户是有读写权限的,然后我们用 tail -f命令监控 index 文件,另一个窗口连接mysql,执行 flush binary logs,发现新产生的 binlog 文件也是会追加到 index 里。越排查越觉得诡异,并且没有排查下去的思路了,难道是 show binary logs逻辑有问题,翻开代码确认了下,主体逻辑非常简单,就是从 index 文件头开始遍历,一行对应一个 binlog 文件,每一个 binlog 文件都获取下文件size,然后把结果发给客户端,详见 rpl_master.cc:show_binlogs():

  reinit_io_cache(index_file, READ_CACHE, (my_off_t) 0, 0, 0);

  /* The file ends with EOF or empty line */
  while ((length=my_b_gets(index_file, fname, sizeof(fname))) > 1)
  {
    int dir_len;
    ulonglong file_length= 0;                   // Length if open fails
    fname[--length] = '\0';                     // remove the newline

    protocol->prepare_for_resend();
    dir_len= dirname_length(fname);
    length-= dir_len;
    protocol->store(fname + dir_len, length, &my_charset_bin);

    if (!(strncmp(fname+dir_len, cur.log_file_name+cur_dir_len, length)))
      file_length= cur.pos;  /* The active log, use the active position */
    else
    {
      /* this is an old log, open it and find the size */
      if ((file= mysql_file_open(key_file_binlog,
                                 fname, O_RDONLY | O_SHARE | O_BINARY,
                                 MYF(0))) >= 0)
      {
        file_length= (ulonglong) mysql_file_seek(file, 0L, MY_SEEK_END, MYF(0));
        mysql_file_close(file, MYF(0));
      }
    }
    protocol->store(file_length);
    if (protocol->write())
    {
      DBUG_PRINT("info", ("stopping dump thread because protocol->write failed at line %d", __LINE__));
      goto err;
    }
  }

代码逻辑看起来没毛病,心想这问题真是神奇了。。。笔者都准备掏出 gdb 一步一步跟代码看了,在此之前抱着试试看的心态,用 vim 打开了 index 文件,准备人肉看一遍,一打开就发现了可疑的地方,文件内容如下:


./master-bin.000001
./master-bin.000002
./master-bin.000003
./master-bin.000004
./master-bin.000005

有没有看出什么?

细心的读者可能已经发现,第一行是空行,再看下刚的代码,有这么一个判断逻辑:

  /* The file ends with EOF or empty line */
  while ((length=my_b_gets(index_file, fname, sizeof(fname))) > 1)

空行被认为是文件结束,WTF!心中万头羊驼奔腾。

解法很简单,删了第一行的空行,然后 flush binary logs生成新的 index 文件把 cache 失效掉,就可以了。

mysql> flush binary logs;
Query OK, 0 rows affected (0.00 sec)

mysql> show binary logs;
+-------------------+-----------+
| Log_name          | File_size |
+-------------------+-----------+
| master-bin.000001 |       467 |
| master-bin.000002 |       168 |
| master-bin.000003 |       168 |
| master-bin.000004 |       168 |
| master-bin.000005 |       168 |
| master-bin.000006 |       120 |
+-------------------+-----------+
6 rows in set (0.00 sec)

那么下一个问题来了,为什么第一行会是个空行呢,因为之前主机磁盘被堆满,为了快速清出空间,运维同学把一些老的 binlog 清理掉了,同时 “贴心的” 的把 index 文件也同步手动编辑了,但是因为手残留下了一个空行。。。

问题总结

这个问题是比较简单的,遇到过一次的话,后面就不会被坑了。。。

从这个问题我们也可以看出,MySQL 在有些时候的逻辑处理非常粗糙简单,对于文件格式没有适当地检测机制,像这种诡异问题就被隐藏吞没掉。如果翻看 commit 历史的话,可以看到“空行就认为是文件结束”的逻辑,在2002年之后就一直是这样的了:-(

MySQL · myrocks · myrocks之Bloom filter

$
0
0

Bloom filter 简介

Bloom filter用于判断一个元素是不是在一个集合里,当一个元素被加入集合时,通过k个散列函数将这个元素映射成一个位数组中的k个点,把它们置为1。检索时如果这些点有任何一个为0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
优点:布隆过滤器存储空间和插入/查询时间都是常数O(k)。
缺点:有一定的误算率,同时标准的Bloom Filter不支持删除操作。
Bloom Filter通过极少的错误换取了存储空间的极大节省。

bloom filter

设集合元素个数为n,数组大小为m, 散列函数个数为k

有一个规律是当 k=m/n*ln2时,误算率最低。参考Bloom_filter wiki

rocksdb与bloom filter

rocksdb中memtable和SST file都属于集合类数据且不需要删除数据,比较适合于Bloom filter.

rocksdb memtable和SST file都支持bloom filter, memtable 的bloom filter数组就存储在内存中,而SST file的bloom filter持久化在bloom filter中.

  • SST Bloom filter
    SST Boomfilter 在Flush生成SST files时通过计算产生,分为两个阶段
    1. 将prefix_extrator指定的key前缀加入到HASH表hash_entries_中
    2. 将hash_entries_所有映射到Bloom filter的数组中

SST Bloom filter相关参数有

filter_policy=bloomfilter:10:false;
whole_key_filtering=0
prefix_extractor=capped:24
partition_filters=false

其中prefix_extractor=capped:24, 表示最多取前缀24个字节,另外还有fixed:n方式表示只取前缀n个字节,忽略小于n个字节的key. 具体可参考CappedPrefixTransform,FixedPrefixTransform

filter_policy=bloomfilter:10:false;其中bits_per_key_=10, bits_per_key_实际就是前面公式k=m/n*ln2 中的m/n. 从而如下计算k即num_probes_的方式

 void initialize() {
    // We intentionally round down to reduce probing cost a little bit
    num_probes_ = static_cast<size_t>(bits_per_key_ * 0.69);  // 0.69 =~ ln(2)
    if (num_probes_ < 1) num_probes_ = 1;
    if (num_probes_ > 30) num_probes_ = 30;
  }

use_block_based_builder_表示是使用block base filter还是full filter
partition_filters 表示时否使用partitioned filter,SST数据有序排列,按block_size进行分区后再产生filter,index_on_filter block存储分区范围. 开启partition_filters 需配置index_type =kTwoLevelIndexSearch

filter 参数优先级如下 block base > partitioned > full. 比如说同时指定use_block_based_builder_=true和partition_filters=true实际使用的block based filter

whole_key_filtering,取值true, 表示增加全key的filter. 它和前缀filter并不冲突可以共存。

rocksdb 内部 bloom filter实现方式有三种
1. block based filter,SST file每2kb作为一个block构建bloom filter信息。
2. full filter. 整个SST file构建一个bloom filter信息。
3. partitioned filter, 将SST filter按block_size将进行分区, 每个分区构建bloom filter信息。分区是有序的,有最大值和最小值,从而在分区之上构建索引存储在SST block中。

屏幕快照 2017-08-31 上午10.46.09.png

  • memtable Bloom filter
  • memtable 在每次Add数据时都会更新Bloom filter.
  • Bloom filter提供参数memtable_prefix_bloom_size_ratio,其值不超过0.25, Bloom filter数组大小为write_buffer_size* memtable_prefix_bloom_size_ratio.
  • memtable Bloom filter 中的num_probes_取值硬编码为6

另外参数cache_index_and_filter_blocks可以让filter信息缓存在block cache中。

MyRocks和bloom filter

在myrocks中,Bloom filter是全局的,设置了Bloom filter后,所有表都有Bloom filter。Bloom filter和索引是绑定在一起的。也就是说,表在查询过程中,如果可以用到某个索引,且设置了Bloom filter,那么就有可能会用到索引的Bloom filter.

MyRocks可以使用Bloom filter的条件如下,详见函数can_use_bloom_filter

  • 必须是索引前缀或索引全列的等值查询
  • 等值前缀的长度应该符合prefix_extrator的约定

我们可以通过以下两个status变量来观察Bloom filter使用情况
rocksdb_bloom_filter_prefix_checked:是否使用了Bloom filter
rocksdb_bloom_filter_prefix_useful:使用Bloom filter判断出不存在
rocksdb_bloom_filter_useful:BlockBasedTable::Get接口使用Bloom filter判断出不存在

设置参数rocksdb_skip_bloom_filter_on_read可以让查询不使用Bloom filter。

示例

最后给个示例
参数设置如下,使用partitioned filter

rocksdb_default_cf_options=write_buffer_size=64k;block_based_table_factory={filter_policy=bloomfilter:10:false;whole_key_filtering=0;partition_filters=true;index_type=kTwoLevelIndexSearch};prefix_extractor=capped:24

SQL

CREATE TABLE t1 (id1 INT, id2 VARCHAR(100), id3 BIGINT, value INT, PRIMARY KEY (id1, id2, id3)) ENGINE=rocksdb collate latin1_bin;
let $i = 1;
while ($i <= 10000) {
  let $insert = INSERT INTO t1 VALUES($i, $i, $i, $i);
  inc $i;
  eval $insert;
}

## case 1: 等值条件prefix长度 < 24, 用不Bbloom filter
select variable_value into @c from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_checked';
select variable_value into @u from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_useful';
select count(*) from t1 WHERE id1=100 and id2 ='10';
count(*)
0
select (variable_value-@c) > 0 from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_checked';
(variable_value-@c) > 0
0
select (variable_value-@u) > 0 from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_useful';
(variable_value-@u) > 0
0

# case 2: 符合使用Bbloom filter的条件,且成功判断出不存在
select variable_value into @c from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_checked';
select variable_value into @u from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_useful';
select count(*) from t1 WHERE id1=100 and id2 ='00000000000000000000';
count(*)
0
select (variable_value-@c) > 0 from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_checked';
(variable_value-@c) > 0
1
select (variable_value-@u) > 0 from information_schema.global_status where variable_name='rocksdb_bloom_filter_prefix_useful';
(variable_value-@u) > 0
1

MySQL · 特性分析 · 浅谈 MySQL 5.7 XA 事务改进

$
0
0

关于MySQL XA 事务

MySQL XA 事务通常用于分布式事务处理当中。比如在分库分表的场景下,当遇到一个用户事务跨了多个分区,需要使用XA事务 来完成整个事务的正确的提交和回滚,即保证全局事务的一致性。

XA 事务在分库分表场景的使用

下图是个典型的分库分表场景,前端是一个Proxy后面带若干个MySQL实例,每个实例是一个分区。

XA-sharding.png

假设一个表test定义如下,Proxy根据主键”a”算Hash决定一条记录应该分布在哪个节点上:

create table test(a int primay key, b int) engine = innodb;

应用发到Proxy的一个事务如下:

begin;
insert into test values (1, 1);
update test set b = 1 where a = 10;
commit;

Proxy收到这个事务需要将它转成XA事务发送到后端的数据库以保证这个事务能够安全的提交或回滚,一般的Proxy的处理步骤 如下:

  1. Proxy先收到begin,它只需要设置一下自己的状态不需要向后端数据库发送
  2. 当收到 insert 语句时Proxy会解析语句,根据“a”的值计算出该条记录应该位于哪个节点上,这里假设是“分库1”
  3. Proxy就会向分库1上发送语句xa start ‘xid1’,开启一个XA事务,这里xid1是Proxy自动生成的一个全局事务ID;同时原来 的insert语句insert into values(1,1)也会一并发送到分库1上。
  4. 这时Proxy遇到了update语句,Proxy会解析 where条件主键的值来决定该条语句会被发送到哪个节点上,这里假设是“分库2”
  5. Proxy就会向分库2上发送语句xa start ‘xid1’,开启一个XA事务,这里xid1是Proxy之前已经生成的一个全局事务ID;同时原来 的update语句update test set b = 1 where a = 10也会一并发送到分库2上。
  6. 最后当Proxy解析到commit语句时,就知道一个用户事务已经结束了,就开启提交流程
  7. Proxy会向分库1和分库2发送 xa end ‘xid1’;xa prepare ‘xid1’语句,当收到执行都成功回复后,则继续进行到下一步,如果任何一个分 库返回失败,则向分库1和分库2 发送 xa rollback ‘xid1’,回滚整个事务
  8. 当 xa prepare ‘xid1’都返回成功,那么 proxy会向分库1和分库2上发送 xa commit ‘xid1’,来最终提交事务。

这里有一个可能的优化,即在步骤4时如果Proxy计算出update语句发送的节点仍然是“分库1”时,在遇到commit时,由于只涉 及到一个分库,它可以直接向“分库1”发送 xa end ‘xid1’; xa commit ‘xid1’ one phase来直接提交该事务,避免走 prepare阶段来提高效率。

XA对事务安全的影响分析

从以上分库分表场景下分布式事务的处理过程来看,整个分布式事务的安全性依赖是XA Prepare了的事务的可靠性,也就是在 数据库节点上 XA Prepare了的事务必须是持久化了的,这样当XA Commit发来时才可以提交。设想如下场景:

  1. Proxy已经向分库1和分库2上发送完了 xa prepare ‘xid1’语句,并得到了成功的回复
  2. Proxy向分库1上发送了 ‘xa commit ‘xid1’语句,并已经成功返回
  3. 当 Proxy向分库2上发送 ‘xa commit ‘xid1’时,网络断开了,或者分库2的数据库实例被kill了
  4. 当网络恢复(这时相关的Session已经退出了)或数据库实例再启动后(或切换到备库),XA prepare了的事务已经回滚了, 当Proxy XA commit ‘xid1’发过来后数据库实例根本找不到xid1这个xa事务

上面的过程就导致了分布式事务的不一致:分库1提交了事务,分库2回滚了事务,整个事务提交了一半,回滚了一半。

在MySQL 5.6中以上过程是可能发生的,因为xa prepare并没有严格的持久化,当Session断开,数据库崩溃等情况下这些事务 会被回滚掉,而且的当一个主库配置了SemiSync的备库时xa prepare了的事务也不会被发送的备库,如果主库切换到备库这些 事务也会丢失。

MySQL 5.7 XA可靠性改进

MySQL 5.7解决了 xa prepare了的事务的严格持久化问题,也就是在session断开和实例崩溃重启情况下这些事务不丢,同时在 xa prepare ‘xid1’返回之前XA事务也会同步到备库。下面将通过在5.6和5.7上分别执行xa prepare并对binlog event进行分析 来演示这个改进。

断开连接对xa prepare的事务影响

在5.6和5.7上分别执行如下sql然后断开连接,再重新连接使用的xa recover验证 XA 事务是否回滚了。

xa start 'xid1';
insert into test values(1, 1);
xa end 'xid1';
xa prepare 'xid1';
-- 这里断开再连上新连接执行 xa recover

在 5.6 的版本上将返回空的结果,在 5.7 的版本上返回:

mysql> xa recover;
+----------+--------------+--------------+------+
| formatID | gtrid_length | bqual_length | data |
+----------+--------------+--------------+------+
|        1 |            4 |            0 | xid1 |
+----------+--------------+--------------+------+
1 row in set (0.00 sec)

说明断开连接后 5.7的prepare了的xa事务没有丢失。

XA 事务的 Binlog events 异同

在5.6和5.7上分别执行如下事务,然后用 show binlog events 查看两者binlog的不同:

xa start 'xid1';
insert into test values(1, 1);
xa end 'xid1';
xa prepare 'xid1';
xa commit 'xid1';

5.6的结果:

mysql-bin.000001 | 304 | Gtid           |      3706 |         352 | SET @@SESSION.GTID_NEXT= 'uuid:2'
mysql-bin.000001 | 352 | Query          |      3706 |         424 | BEGIN
mysql-bin.000001 | 424 | Table_map      |      3706 |         472 | table_id: 71 (test.test)
mysql-bin.000001 | 472 | Write_rows     |      3706 |         516 | table_id: 71 flags: STMT_END_F
mysql-bin.000001 | 516 | Query          |      3706 |         589 | COMMIT

5.7的结果:

mysql-bin.000001 |  544 | Gtid           |      3707 |         592 | SET @@SESSION.GTID_NEXT= 'uuid:3'
mysql-bin.000001 |  592 | Query          |      3707 |         685 | XA START X'78696431',X'',1
mysql-bin.000001 |  685 | Table_map      |      3707 |         730 | table_id: 74 (test.t) 
mysql-bin.000001 |  730 | Write_rows     |      3707 |         774 | table_id: 74 flags: STMT_END_F
mysql-bin.000001 |  774 | Query          |      3707 |         865 | XA END X'78696431',X'',1 
mysql-bin.000001 |  865 | XA_prepare     |      3707 |         905 | XA PREPARE X'78696431',X'',1
mysql-bin.000001 |  905 | Gtid           |      3707 |         953 | SET @@SESSION.GTID_NEXT= 'uuid:4' |
mysql-bin.000001 |  953 | Query          |      3707 |        1047 | XA COMMIT X'78696431',X'',1

可以看到 MySQL 5.6 XA 事务和普通事务的binlog是一样的,并没有体现 xa prepare。而到了 MySQL 5.7 XA 事务的binlog和 普通的事务是完全不同的,XA Prepare有单独的Log event类型,有自己的Gtid,当开启semi-sync的情况下,MySQL 5.7 执行 XA prepare 时会等备库回复后才返回结果给客户端,这样XA prepare执行完就是安全的。

通过以上分析可以看出 MySQL 5.7在XA事务安全性方面做了很大的改进,后续月报文章将会对它的实现做分析。

MySQL · 特性分析 · 利用gdb跟踪MDL加锁过程

$
0
0

MDL(Meta Data LocK)的作用

在MySQL5.1及之前的版本中,如果有未提交的事务trx,当执行DROP/RENAME/ALTER TABLE RENAME操作时,不会被其他事务阻塞住。这会导致如下问题(MySQL bug#989)

master:
未提交的事务,但SQL已经完成(binlog也准备好了),表schema发生更改,在commit的时候不会被察觉到.

slave:
在binlog里是以事务提交顺序记录的,DDL隐式提交,因此在备库先执行DDL,后执行事务trx,由于trx作用的表已经发生了改变,因此trx会执行失败。
在DDL时的主库DML压力越大,这个问题触发的可能性就越高

在5.5引入了MDL(meta data lock)锁来解决在这个问题

MDL锁的类型

metadata lock也是一种锁。每个metadata lock都会定义锁住的对象,锁的持有时间和锁的类型

属性范围作用
GLOBAL全局锁主要作用是防止DDL和写操作的过程中执行 set golbal_read_only =on 或flush tables with read lock;
commit提交保护锁主要作用是执行flush tables with read lock后,防止已经开始在执行的写事务提交
SCHEMA库锁对象
TABLE表锁对象
FUNCTION函数锁对象
PROCEDURE存储过程锁对象
TRIGGER触发器锁对象
EVENT事件锁对象

这些锁具有以下层级关系
MDL_SCOPE.png

MDL锁的简单示例

在实际工作中,最常见的MDL冲突就DDL的操作被没用提交的事务所阻塞。 我们下面通过一个具体的实例来演示DDL加MDL锁的过程。在这个实例中,利用gdb来跟踪DDL申请MDL锁的过程。

会话1:

mysql> create table ti(id int primary key, c1 int, key(c1)) engine=InnoDB  
stats_auto_recalc=default;
Query OK, 0 rows affected (0.03 sec)

mysql> insert into ti values (1,1), (2,2);
Query OK, 2 rows affected (0.03 sec)
Records: 2  Duplicates: 0  Warnings: 0

mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from ti;
+----+------+
| id | c1   |
+----+------+
|  1 |    1 |
|  2 |    2 |
+----+------+
2 rows in set (0.00 sec)


再开启第二个会话,利用gdb来跟踪mysql加MDL的过程
会话2:

[root@localhost mysql]# ps -ef|grep mysql
root      3336  2390  0 06:33 pts/2    00:00:01 /u02/mysql/bin/mysqld --basedir=/u02/mysql/ --datadir=/u02/mysql/data 
--plugin-dir=/u02/mysql//lib/plugin --user=root 
--log-error=/u02/mysql/tmp/error1.log --open-files-limit=10240 
--pid-file=/u02/mysql/tmp/mysql.pid 
--socket=/u02/mysql/tmp/mysql.sock --port=3306

[root@localhost mysql]# gdb -p 3336
----在GDB设置以下断点
(gdb) b MDL_context::acquire_lock
Breakpoint 1 at 0x730cab: file /u02/mysql-server-5.6/sql/mdl.cc, line 2187.
(gdb) b lock_rec_lock
Breakpoint 2 at 0xb5ef50: file /u02/mysql-server-5.6/storage/innobase/lock/lock0lock.cc, line 2296.

(gdb) c
Continuing.....

开启第三个会话

mysql> alter table ti stats_auto_recalc=1;
这个操作被hang住

在会话2中执行下面的操作

(gdb) p mdl_request
$1 = (MDL_request *) 0x7f697d1c3bd0
(gdb) p *mdl_request
$2 = {
type = MDL_INTENTION_EXCLUSIVE, duration = MDL_STATEMENT, next_in_list = 0x7f697002a560, prev_in_list = 0x7f697d1c3df8, ticket = 0x0, key = {m_length = 3, m_db_name_length = 0,
    m_ptr = '\000'<repeats 20 times>, "0|\002p\000\000\001\000\060<\034}i\177\000\000>\240\344\000\000\000\000\000\000\t\000pi\177\000\000\000\t\000pi\177\000\000`>\034}i\177\000\000V\312\344\000\000\000\000\000\240>\034}i\177\000\000\333\361\254\000b\001\000\000\a?\000\001", '\000'<repeats 20 times>, "0|\002p\000\000\001\000\220<\034}i\177\000\000>\240\344\000\000\000\000\000\340\236\002pi\177\000\000\333\361\254\000\000\000\000\000\a?\000\001", '\000'<repeats 12 times>"\340, >\034}i\177\000\000\060|\002p\000\000\001\000\350\062\220\003\000\000\000\000\333\361\254\000\000\000\000\000$\226\363", '\000'<repeats 14 times>,
"?\034}i\177\000\000\060|\002p\000\000\001\000\000=\034}i\177\000\000>\240\344\000\000\000\000\000\000"...,
static m_namespace_to_wait_state_name = {
{m_key = 101,
        m_name = 0xf125a2 "Waiting for global read lock", m_flags = 0}, 
{m_key = 102, 
	m_name = 0xf125c0 "Waiting for schema metadata lock", m_flags = 0}, 
{m_key = 103,
        m_name = 0xf125e8 "Waiting for table metadata lock", m_flags = 0}, 
{m_key = 104, 
	m_name = 0xf12608 "Waiting for stored function metadata lock", m_flags = 0}, 
{m_key = 105,
        m_name = 0xf12638 "Waiting for stored procedure metadata lock", m_flags = 0}, 
{m_key = 106, 
	m_name = 0xf12668 "Waiting for trigger metadata lock", m_flags = 0}, 
{m_key = 107,
        m_name = 0xf12690 "Waiting for event metadata lock", m_flags = 0}, 
{m_key = 108, 
	m_name = 0xf126b0 "Waiting for commit lock", m_flags = 0}}}}
(gdb)

从上面的输出中,我只能看到申请了一个语句级别的MDL_INTENTION_EXCLUSIVE。并没有看到什么其他有意义的信息。我们继续gdb跟踪

(gdb) p *(mdl_request->next_in_list)
$3 = {type = MDL_INTENTION_EXCLUSIVE, duration = MDL_TRANSACTION, next_in_list = 0x7f697002a388, prev_in_list = 0x7f697d1c3bd8, ticket = 0x0, key = {m_length = 7, m_db_name_length = 4,
    m_ptr = "\001test\000\000\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217\217", 
static m_namespace_to_wait_state_name = {
{m_key = 101,
        m_name = 0xf125a2 "Waiting for global read lock", m_flags = 0}, 
{m_key = 102, 
	m_name = 0xf125c0 "Waiting for schema metadata lock", m_flags = 0}, 
{m_key = 103,
        m_name = 0xf125e8 "Waiting for table metadata lock", m_flags = 0}, 
{m_key = 104, 
	m_name = 0xf12608 "Waiting for stored function metadata lock", m_flags = 0}, 
{m_key = 105,
        m_name = 0xf12638 "Waiting for stored procedure metadata lock", m_flags = 0}, 
{m_key = 106, 
	m_name = 0xf12668 "Waiting for trigger metadata lock", m_flags = 0}, 
{m_key = 107,
        m_name = 0xf12690 "Waiting for event metadata lock", m_flags = 0}, 
{m_key = 108, 
	m_name = 0xf126b0 "Waiting for commit lock", m_flags = 0}}}}        

从上面的输出中,我们看到了需要在test(见输出中的 m_ptr = “\001test)数据库上加一把事务级的MDL_INTENTION_EXCLUSIVE锁。它并没有告诉我们最终的MDL会落在哪个对象上。我们继续跟踪

$4 = {type = MDL_SHARED_UPGRADABLE, duration = MDL_TRANSACTION, next_in_list = 0x0, prev_in_list = 0x7f697002a568, ticket = 0x0, key = {m_length = 9, m_db_name_length = 4,
    m_ptr = "\002test\000ti", '\000'<repeats 378 times>, 
static m_namespace_to_wait_state_name = {
{m_key = 101, 
	m_name = 0xf125a2 "Waiting for global read lock", m_flags = 0}, 
{m_key = 102,
	m_name = 0xf125c0 "Waiting for schema metadata lock", m_flags = 0}, 
{m_key = 103, 
	m_name = 0xf125e8 "Waiting for table metadata lock", m_flags = 0}, 
{m_key = 104,
        m_name = 0xf12608 "Waiting for stored function metadata lock", m_flags = 0}, 
{m_key = 105, 
	m_name = 0xf12638 "Waiting for stored procedure metadata lock", m_flags = 0}, 
{m_key = 106,
        m_name = 0xf12668 "Waiting for trigger metadata lock", m_flags = 0}, 
{m_key = 107, 
	m_name = 0xf12690 "Waiting for event metadata lock", m_flags = 0}, 
{m_key = 108,
        m_name = 0xf126b0 "Waiting for commit lock", m_flags = 0}}}}

从上面的输出中,我们可以看出最终是要在test数据库的ti对象上加一把MDL_SHARED_UPGRADABLE锁。在做DDL时会先加MDL_SHARED_UPGRADABLE锁,然后升级到MDL_EXCLUSIVE锁

我来执行下面的过程
会话1

mysql> commit;
Query OK, 0 rows affected (5.51 sec)

会话2

(gdb) p *mdl_request
$5 = {type = MDL_EXCLUSIVE, duration = MDL_TRANSACTION, next_in_list = 0x20302000000, prev_in_list = 0x200000001, ticket = 0x0, key = {m_length = 9, m_db_name_length = 4,
    m_ptr = "\002test\000ti\000\000\000\000@\031\220\003\000\000\000\000\333\361\254\000\000\000\000\000\260<\034}i\177\000\000\302\362\254\000\000\000\000\000\300<\034}i\177\000\000\060|\002pi\177\000\000\320<\034}i\177\000\000\360\236\344\000\000\000\000\000\000\t\000pi\177\000\000(}\002pi\177\000\000\360<\034}i\177\000\000\234\312\344\000\000\000\000\000H\245\002pi\177\000\000\333\361\254\000\000\000\000\000\023\360\000\001", '\000'<repeats 12 times>, "`S\005pi\177\000\000\060|\002p\000\000\001\000\060=\034}i\177\000\000>\240\344\000\000\000\000\000\000\t\000pi\177\000\000\000\t\000pi\177\000\000\200=\034}i\177\000\000\231\310\344\000\000\000\000\000\240=\034}i\177\000\000l-d0t\b\000\000H\344\000\001\000\000\000\000\023\360\000\001\000\000\000\000\226"..., 
static m_namespace_to_wait_state_name = {
{m_key = 101, 
	m_name = 0xf125a2 "Waiting for global read lock", m_flags = 0}, 
{m_key = 102, 
	m_name = 0xf125c0 "Waiting for schema metadata lock", m_flags = 0}, 
{m_key = 103,
        m_name = 0xf125e8 "Waiting for table metadata lock", m_flags = 0}, 
{m_key = 104, 
	m_name = 0xf12608 "Waiting for stored function metadata lock", m_flags = 0}, 
{m_key = 105,
        m_name = 0xf12638 "Waiting for stored procedure metadata lock", m_flags = 0}, 
{m_key = 106, 
	m_name = 0xf12668 "Waiting for trigger metadata lock", m_flags = 0}, 
{m_key = 107,
        m_name = 0xf12690 "Waiting for event metadata lock", m_flags = 0}, 
{m_key = 108, 
	m_name = 0xf126b0 "Waiting for commit lock", m_flags = 0}}}}       

从上面的输出中,我们看到了最终是在test.ti上申请了事务级别的MDL_EXCLUSIVE锁。

会话3

mysql> alter table ti stats_auto_recalc=1;
Query OK, 0 rows affected (22 min 58.99 sec)
Records: 0  Duplicates: 0  Warnings: 0

小结

本例只是简单的演示了,在同一个事务的不同时期加的不同的MDL的锁。MYSQL中DDL的操作不属于事务操作的范围。这就给mysql主备基于语句级别同步带来了困难。mysql主备在同步的过程中,为了保证主备结构一致性,而引入了MDL机制。为了尽可能的降低MDL带来的影响。请在业务低谷的时候,执行DDL操作。

MySQL · 源码分析 · Innodb 引擎Redo日志存储格式简介

$
0
0

MySQL有多种日志。不同种类、不同目的的日志会记录在不同的日志文件中,它们可以帮助你找出mysqld内部发生的事情。比如错误日志:用来记录启动、运行或停止mysqld进程时出现的问题;查询日志:记录建立的客户端连接和执行的语句;二进制日志:记录所有更改数据的语句,主要用于逻辑复制;慢日志:记录所有执行时间超过long_query_time秒的所有查询或不使用索引的查询。而对MySQL中最常用的事务引擎innodb,redo日志是保证事务一致性非常重要的。本文结合MySQL版本5.6为分析源码介绍MySQL innodb引擎的重做(Redo)日志存储格式。

Redo日志

任何对Innodb表的变动, redo log都要记录对数据的修改,redo日志就是记录要修改后的数据。redo 日志是保证事务一致性非常重要的手段,同时也可以使在bufferpool修改的数据不需要在事务提交时立刻写到磁盘上减少数据的IO从而提高整个系统的性能。这样的技术推迟了bufferpool页面的刷新,从而提升了数据库的吞吐,有效的降低了访问时延。带来的问题是额外的写redo log操作的开销。而为了保证数据的一致性,都要求WAL(Write Ahead Logging)。而redo 日志也不是直接写入文件,而是先写入redo log buffer,而是批量写入日志。当需要将日志刷新到磁盘时(如事务提交),将许多日志一起写入磁盘。关于redo的产生及其生命周期详细过程,详见:https://yq.aliyun.com/articles/219。

Redo日志文件格式

MySQL redo日志是一组日志文件,它们会被循环使用。Redo log文件的大小和数目可以通过特定的参数设置,详见innodb_log_file_size 和 innodb_log_files_in_group 。

日志组结构

在实现上日志组是由定义在log0log.h中的log_group_t结构体来表示的。在日志组结构体定义中含有以下重要信息:
日志文件的大小(file_size):记录日志组内每个日志文件的大小,通过参数innodb_log_file_size配置。
日志文件的个数(n_files): 记录这个日志组中的文件个数,,通过参数innodb_log_files_in_group配置。
Checkpoint相关的信息:只有做完checkpoint后,其之前的日志才可以不再保留,否则系统崩溃时则无法恢复。在系统崩溃后的恢复,需要从checkpoint点开始。但我们需要把checkpoint的相关信息持久化的保存下来,才能在系统崩溃时不会丢失这些检查点相关的信息。Checkpoint相关的信息只存放在ib _logfile0中。

日志文件结构

每个日志文件的前2048字节是存放的文件头信息。头结构定义在”storage/innobase/include/log0log.h” 中。其在重做日志文件内的布局如下图所示:

Redo 日志存储排列

其中几个重要的字段在这里加以说明:
日志文件头共占用4个OS_FILE_LOG_BLOCK_SIZE的大小,这里对部分字段做简要介绍:
1) LOG_GROUP_ID               这个log文件所属的日志组,占用4个字节,当前都是0;
2) LOG_FILE_START_LSN     这个log文件记录的开始日志的lsn,占用8个字节;
3) LOG_FILE_WAS_CRATED_BY_HOT_BACKUP   备份程序所占用的字节数,共占用32字节;
4) LOG_CHECKPOINT_1/LOG_CHECKPOINT_2   两个记录InnoDB checkpoint信息的字段,分别从文件头的第二个和第四个block开始记录,只使用日志文件组的第一个日志文件。
从地址2KB偏移量开始,其后就是顺序写入的各个日志块(log block)。

日志块结构

所有的redo日志记录是以日志块为单位组织在一起的,日志块的大小为OS_FILE_LOG_BLOCK_SIZE(默认值为512字节),所有的日志记录以日志块为单位顺序写入日志文件。每一条记录都有自己的LSN(log sequence number, 表示从日志记录创建开始到特定的日志记录已经写入的字节数)。每个日志块包含一个日志头段(12字节)、一个尾段(4字节),以及一组日志记录(512 – 12 – 4 = 496字节) 。

Redo 日志块结构

首先看下日志块头结构。
1) log block number字段:占用日志块最开始的4个字节表示这是第几个block块。 其是通过LSN计算得来,计算的函数是log_block_convert_lsn_to_no();
2) block data len 字段:两个字节表示该block中已经有多少个字节被使用; 若是整个块都写满了日志的话它的长度就应该是(OS_FILE_LOG_BLOCK_SIZE) 512 字节。
3) First Record offset 字段:占用两个字节,表示该block中作为第一个新的mtr开始log record的偏移量。log_block_get_first_rec_group()就是用保存在这个字段的值,获取到此块中第一个新的mtr开始的日志位置。
4) 中间496字节存放真正的Redo日志。
5) Checksum字段:是块的尾,占用四个字节,表示此log block计算出的校验值,用于正确性校验。

LSN和文件偏移量(offset)之间映射

在MySQL Innodb引擎中LSN是一个非常重要的概念,表示从日志记录创建开始到特定的日志记录已经写入的字节数,LSN的计算是包含每个BLOCK的头和尾字段的。那如何由一个给定LSN的日志,在日志文件中找到它存储的位置的偏移量并能正确的读出来呢。所有的日志文件要属于日志组,而在log_group_t里的lsn和lsn_offset字段已经记录了某个日志lsn和其存放在文件内的偏移量之间的对应关系。我们可以利用存储在group内的lsn和给定lsn之间的相对位置,来计算出给定lsn在文件中的存储位置。可以参考函数log_group_calc_lsn_offset()的实现。其核心代码实现如下:

    gr_lsn = group->lsn;

    gr_lsn_size_offset = log_group_calc_size_offset(group->lsn_offset, group);

    group_size = log_group_get_capacity(group);

    if (lsn >= gr_lsn) {

        difference = lsn - gr_lsn;
    } else {
        difference = gr_lsn - lsn;

        difference = difference % group_size;

        difference = group_size - difference;
    }

    offset = (gr_lsn_size_offset + difference) % group_size;

    /* fprintf(stderr,
    "Offset is " LSN_PF " gr_lsn_offset is " LSN_PF
    " difference is " LSN_PF "\n",
    offset, gr_lsn_size_offset, difference);
    */

    return(log_group_calc_real_offset(offset, group));

MSSQL · 应用案例 · 日志表设计优化与实现

$
0
0

摘要

这篇文章从日志表问题引入、日志表的共有特性、日志表的设计需求、设计思路以及设计详细实现的角度,阐述了在SQL Server数据库中如何最优化设计日志表来降低系统资源的占用和提高系统吞吐量。

问题引入

在平时与客户服务与交流过程中,我们不止一次的被客人问及这样的场景:我们现在面临如何设计SQL Server日志表方案,如何最优化设计数据库日志记录表。因为,日志表设计会面对如下问题:

表记录数大:日志表由于记录了应用程序的很多操作日志,有的业务有很多步骤,甚至每个步骤操作都会被记录到日志表中,所以通常日志记录表都很大,表记录数据很多,表空间占用很大。

事务操作频繁:由于日志记录表写入(INSERT)操作非常频繁,加之表变得很大,通常的做法是会删除过期的日志信息(DELETE),所以事务操作非常频繁。

占用了昂贵的IOPS资源:日志表原本写入操作就非常频繁了,加之需要删除过时数据操作,这一写一删操作,使得事务量加倍,导致了数据库系统IOPS资源的过度被日志表的操作所占用了。

吞吐量上不去:日志表事务操作频繁,如果删除操作的事务没有控制好(比如:我见过很多客人一个DELETE语句下去删掉几十、几百万记录数的操作),很有可能会导致锁等待的发生(Blocking),从而影响应用的吞吐量和并发能能力。

日志表设计面临的这一系列问题给我们设计带来了不小的挑战,而我们今天这篇文章就是要解决日志表设计面临的这些问题。这篇文章将会分享SQL Server日志表设计的优化方案以及方案的实现细节,聪明的你甚至可以将这个设计方案推广到其他的关系型数据库。

日志表特性

首先,在分享设计方案之前,我们来看看关系型数据中的日志表,具有哪些共同的特性:

事务特性

日志表最为明显的特性是事务特性,或者称之为最重要的特性也不为过,即:写多读少,再准确一点说,是INSERT或者DELETE操作多;SELECT或者UPDATE操作少,这个很好理解。

INSERT操作:INSERT操作是记录日志信息,当然是非常频繁且是少不了的,几乎是每时每刻都在发生。对系统吞吐量和并发要求高。

UPDATE操作:日志表中记录一旦写入,几乎不会被修改,所以UPDATE操作应该非常少,更或者没有。

SELECT操作:SELECT操作也不会太频繁,只会在排查问题,查询日志的时候使用,所以,概率也比较小。

DELETE操作:而为了保证表记录数尽可能的少和查询操作(SELECT)的高效性,我们往往会定时清理过时的日志记录,所以DELETE操作相对还是很频繁的,和INSERT操作的频繁度是对等的。当然这里是可以通过设计来优化的,这也恰恰是这篇文章的中心思想。

设计特性

由于日志表的事务特性,所以,我们对日志表结构的设计就十分考究,比如:

主键设计:日志表因为几乎不会被UPDATE,数据都是追加写入,因此,主键最好选择是INT或者BIGINT数据类型的自增列(IDENTITY属性列),且做为CLUSTERED升序排列索引,即表按照主键列物理升序排列。这样设计的好处是,日志记录追加写入数据时,表不会被部分(或全部)重新排序,且几乎不会产生碎片。举一个反例,如果表主键列为UNIQUEIDENTIFIER数据类型,且值通常为默认值NEWID,那么当新追加的记录主键值小于之前的记录主键值时,会导致这一页的数据重排序,而且容易产生索引碎片。

索引设计适可而止:基于日志表事务特性的分析,我们很少在日志表上进行SELECT查询操作,所以,索引不宜过多,适可而止。理由是:索引过多,影响日志表INSERT操作的性能,因为,系统需要额外维护索引结构中数据和基表数据的一致性。按照我们的经验,通常,我们只需要在时间字段上建立索引即可,然后通过时间来过滤查询结果集。

索引填充因子设计:由于日志表数据记录按时间升序追加写入特性加之很少UPDATE操作,因此,索引数据页也具备了按时间升序追加写入的相似特性。所以,我们可以把索引的填充因子调高,可以设置为90 - 95都没有问题,换句话说,我们可以让索引数据页仅留下5% ~ 10%的剩余空间。

索引碎片维护设计:可能没有人会关心发生在两个月前的日志记录,因此,日志表记录会随着时间推移而删除过时的数据(DELETE)。这样也会导致索引碎片随着时间的推移变得越来越高,进而影响查询效率,消耗更多的系统IOPS资源。所以,我们需要定期维护(或重建或者重组)索引。当然,我们可以通过设计优化来避免DELETE操作,从而避免索引碎片的产生,也因此可以避免索引维护的成本。

重要性分析

日志表数据的最大功用是供我们排查应用系统问题时使用的,因此重要性相对于核心业务系统中的业务表数据,没有那么重要,所以我们会定期清理日志表过时数据。但是,它的确实实在在占用了我们昂贵的数据库系统资源。包括:存储开销、系统I/O开销、CPU开销、网络开销、连接开销以及系统吞吐量开销等。因此,针对日志表设计优化也显得尤为重要,这也是这篇文章分享的意义所在。

需求分析

基于以上“问题引入”和“日志表特性”章节的描述与分析,我们大致可以得出日志表最优化设计的需求与目标:

减少系统IOPS消耗:写入操作(INSERT)无法避免和减少了,我们可以考虑减少或者消除删除操作(DELETE)对IOPS的消耗。

增大日志表写入的吞吐量:要达到增大日志表写入吞吐量的目的,我们可以采用分区表的方式来解决,或者是分库分表。

减少索引碎片维护对系统性能消耗:索引维护的目的是为了减少索引的碎片率,提高I/O利用率,使得执行计划更加准确,查询高效。但是,这个操作也会带来数据库系统性能的大量开销,有时候可能会导致锁等待,甚至可能严重到死锁的地步。

设计思路

在讲解设计思路之前,我们需要先来看看SQL Server中删除表中所有数据的一个叫着TRUNCATE的语句。

TRUNCATE

TRUNCATE是SQL Server中一种删除表中所有数据的一种执行非常快速的方法,它仅仅只记录非常少的日志信息,哪怕表记录数达到千万级,亿级别,正常情况下同样可以秒级别执行成功。

使用方法

TRUNCATE使用方法非常简单,即:TRUNCATE TABLE后面跟表名字即可。
TRUNCATE TABLE dbo.tb_Table
注意:
TRUNCATE TABLE非常简单高效的删除表中所有数据,但是有可能也非常危险,因为,你一旦使用了该方法清理了表中所有的数据,你很难将表中的数据在不使用备份集的情况下把数据找回来。因此,在使用这个操作之前请确保你十分清楚它的风险。

使用场景

在如下的场景中,我们可以考虑使用TRUNCATE操作:

重置表状态:当你使用TRUNCATE操作以后,表的所有数据会被清理,Identity属性列值会被重置为初始值。

快速清理表数据:你想要非常高效,快速清理大表数据,而又不希望导致长时间的锁等待或者死锁。

清理表数据又不想导致事务日志文件暴涨:常规的DELETE事务操作,在清理大表或者一次性操作大量数据时,会导致数据库的日志文件空间暴涨,而使用TRUNCATE操作因为仅会写入非常少的日志信息,因此不会导致事务日志空间暴涨。

删除表数据又不想触发表上的Trigger:删除表数据,但不希望触发表上删除操作的Trigger。

设计实现

基于以上的详细分析,我们对日志表的特定,需求有了比较明确的认知,接下来,让我们看看日志表的常规的设计和优化方案的设计,以及这些方案的优缺点。

常规设计

以下是关于日志表的常规设计,在我接触的客户场景中,几乎所有客人都是按照这样的思路和方法来实现日志表的,当然有可能表结构的设计还没有这般考究。

实现方法

首先,建立一个日志表,这里在主键、聚集索引、主键列设计上非常有讲究,我们选择BIGINT数据类型的IDENTITY属性列做为聚集索引升序排列的主键列,以最大限度的符合日志表的特性。详情参见下面代码中创建表部分的注释文字。

其次,建立日志表查询必须的索引,这里我们在时间字段上建立索引,以便后面的数据清理工作高效运行,这里我们将索引的FILLFACOTR设置为95。即,索引数据页预留5%的空间,以避免索引数据页中数据的移动。

接下来,实现清理过时数据的方法,这里使用一个存储过程来模拟。这里需要强调的是,请务必采用循环删除的方法,且每批次删除后,需要有一段时间的停顿,以释放进程获取到的资源,这里的资源包含但不仅限于锁资源、IOPS资源、CPU等,供其他的进程使用。我们在平时服务过程中,有的客人没有将日志表的清理过程采用批量删除的方式,有的是直接使用一个大事务删除所有过时数据,导致长时间锁住甚至无法写入。

日志表常规设计的实现方法和数据清理过程,参见下面的代码,请注意代码中的注释,非常重要。

USE master
GO
-- 创建测试数据库
IF DB_ID('TestDb') IS NULL
	CREATE DATABASE [TestDb];
GO

USE [TestDb]
GO

-- 创建测试表
IF OBJECT_ID('dbo.tb_ApplicationLogs', 'U') IS NOT NULL
	DROP TABLE dbo.tb_ApplicationLogs
GO

/*
创建表的时候,需要特别注意以下几点:
1、RowID被设计为IDENTITY属性,以便日志表追加写入
2、RowID做为表CLUSTERED(聚集)类型主键,且升序排列
3、Indate表示记录进入表中的时间,采用GETDATE做为默认值
4、需要在Indate字段上建立索引,以提高查询和清理数据效率
*/
CREATE TABLE dbo.tb_ApplicationLogs(
	RowID BIGINT IDENTITY(1,1) NOT NULL,
	AppName SYSNAME NOT NULL,
	HostName SYSNAME NOT NULL,
	Indate DATETIME NOT NULL 
		CONSTRAINT DF_Indate DEFAULT(GETDATE()),
	LogInfo NVARCHAR(2000) NULL,
		CONSTRAINT PK_tb_ApplicationLogs_RowID 
		PRIMARY KEY CLUSTERED (RowID ASC)
);


--创建时间字段上的索引,以加快数据清理和查询效率
CREATE NONCLUSTERED INDEX IX_Indate
ON dbo.tb_ApplicationLogs(Indate) WITH(FILLFACTOR = 95)
;
GO


IF OBJECT_ID('dbo.Up_CleanAppLogs', 'P') IS NOT NULL
	DROP PROC dbo.Up_CleanAppLogs
GO
/*
Function:
这个存储过程用来模拟清理dbo.tb_ApplicationLogs日志表中的数据,
采用循环删除超过@days_before天的数据。

参数含义:
@days_before:超过这个参数值天数的数据将会被删除掉。
@batch_Size: 每个批次删除的记录条数

注意:
1、在计算时间的地方,需要使用DATEADD(day, -ABS(@days_before), GETDATE())方法,
这里的取绝对值非常重要,否则当传入一个负数时,表中的所有数据都会被删除。
2、为了避免一个大的事务,一定是采用循环删除的方式,这里一次性删除万条
3、每个删除万条数据的批次之间,要有一段时间的停顿,释放资源供其他进程使用。

*/

CREATE PROC dbo.Up_CleanAppLogs(
	@days_before INT = 7,
	@batch_Size INT = 10000
)
AS
BEGIN
	SET NOCOUNT ON
	DECLARE
		@dt DATETIME
		,@row_affected INT
		,@row_removed_total INT
	;
	SELECT
		@dt = DATEADD(day, -ABS(@days_before), GETDATE())
		,@row_removed_total = 0
	;
	
	WHILE EXISTS(
				SELECT TOP 1 1
				FROM dbo.tb_ApplicationLogs WITH(NOLOCK)
				WHERE Indate <= @dt)
	BEGIN
		DELETE TOP(@batch_Size) A
		FROM dbo.tb_ApplicationLogs AS A
		WHERE Indate <= @dt	--请注意,这个条件非常重要,否则有可能会导致当前数据被误删除
		
		SELECT 
			@row_affected = @@ROWCOUNT
			,@row_removed_total = @row_removed_total + @row_affected
		;
		
		RAISERROR('--%d rows removed, totaly %d rows cleared and waiting for next batch', 10, 1, @row_affected, @row_removed_total) WITH NOWAIT
		WAITFOR DELAY '00:00:02'
	END	
END
GO

缺点分析

以上的日志表常规设计方法,对于日志量不大(每分钟在1000条记录以内)的场景当然可以应付自如。当随着日志的不断增长,可能会带来如下问题:

单表吞吐量上不去:单日志表,日志写入吞吐量始终是有极限的,当每分钟需要写入的日质量大于单表吞吐量时,瓶颈就出现了。

DELETE操作消耗I/O资源:大量的日志操作过期时,会导致DELETE操作的频繁进行,会导致I/O资源的消耗,浪费数据库系统昂贵的I/O资源。

DELETE操作无法按预期删除所有过时数据:因为我们每个删除批次之间有短暂停顿,当日志写入量非常巨大,甚至超过删除的速度时,会导致日志写入量大于删除量,数据清理工作无法按预期清理完毕,进而导致日志表数据不断累加,数据量越来越大。

DELETE操作导致索引碎片:如此频繁的DELETE数据清理操作,会导致时间字段(Indate)上的索引碎片产生,影响查询和数据清理效率。

索引维护成本:为了解决时间字段Indate上的索引碎片产生带来的问题,我们必须对此索引进行维护重建或者重整工作,进一步导致I/O的消耗,严重者甚至导致锁等待或者死锁,进一步影响日志系统吞吐量。

为了解决日志表常规设计中的种种问题和缺点,我们利用TRUNCATE操作来优化设计表的设计和数据清理方案。

优化设计

我们通过“常规设计”中“缺点分析”部分,了解到了常规设计的众多缺点,优化设计的原则就是要来解决这些问题。假设我们的日志表数据记录按天为单位归档,建立分区表和分区视图,那么优化后的具体的设计思路是:

建立分区表

使用循环语句与动态SQL相配合的方式来一次性建立31张表,表的尾号预示写入数据的日期号数。分区表的设计需要:

分区字段为LogDay,表示Log写入的表编号,也即是写入的日期号数据。

分区字段上建立的Check约束是用来实现分区作用。

分区字段必须是主键的一部分,所以主键建立在RowId和LogDay的联合

请在Indate字段上建立索引,加快分区视图、分区表的查询效率,FILLFACTOR应当选择90以上,此处选择为95。

建立分区视图

为了操作日志表简单方便,实现一个分区视图用来直接操作数据,否则,我们的日志写入应用必须要先确定当前日志写入哪一张表,然后才能写入到对应的表中。使用分区视图的话,我们就可以一股脑儿的直接写入到分区视图中,不用事先区分写入的表。分区视图的实现方法也非常简单,只需要将所有的分区表UNION ALL起来,构成成一个分区视图即可。

注意:
分区视图中的分区表RowID不能为Identity属性列。

清理过时数据

使用TRUNCATE TABLE方法清理过时数据,仅会写非常少的事务日志信息,事务日志不会记录数据回滚所需要的所有信息,并且在很少概率情况下会发生锁等待,所以,该操作非常简单、快捷、高效而又节约系统资源。即使表数据记录数达到亿级甚至十亿百亿级,这个操作也会在秒级别完成。

实现代码

基于以上的实现方法分析,具体的实现代码如下,请注意参考代码中的注释。

USE master
GO

-- 创建测试数据库
IF DB_ID('TestDb') IS NULL
	CREATE DATABASE [TestDb];
GO

USE [TestDb]
GO

-- 删除表之前,需要先删除分区视图
IF OBJECT_ID('dbo.V_ApplicationAllLogs', 'V') IS NOT NULL
	DROP VIEW dbo.V_ApplicationAllLogs
GO

/**
-- 循环创建张表,每张表以数字尾号结尾,比如:XXX_01,XXX_02,...,XXX_31
-- 这里需要注意,为了方便数据写入直接使用分区视图,表有如下限制:
1. 分区字段为logDay,表示Log写入的表编号
2. 分区字段上建立Check约束来实现分区
3. 分区字段必须是主键的一部分,所以主键建立在RowId和LogDay的联合
4. 为了使用分区视图直接操作数据,RowID不能为Identity属性列

请在Indate字段上建立索引,FILLFACTOR选择以上。
*/
DECLARE
	@drop_sql NVARCHAR(MAX)
	,@create_sql NVARCHAR(MAX)
	,@do INT = 1
	,@loop INT = 31
	,@postfix CHAR(2)
;

WHILE @do <= @loop
BEGIN
	SELECT
		@postfix = RIGHT(CAST(@do+100 AS VARCHAR(3)), 2)
		,@drop_sql = N'
	IF OBJECT_ID(''dbo.tb_ApplicationLogs_' + @postfix + N''', ''U'') IS NOT NULL
			DROP TABLE dbo.tb_ApplicationLogs_' + @postfix
		,@create_sql = N'
	CREATE TABLE dbo.tb_ApplicationLogs_' + @postfix +N'(
		RowID BIGINT NOT NULL,
		AppName SYSNAME NOT NULL,
		HostName SYSNAME NOT NULL,
		LogDay INT,
			CONSTRAINT CHK_tb_ApplicationLogs_' + @postfix +N'_LogDay CHECK(LogDay= ' + CAST(@do AS NVARCHAR(2)) + N'),
		Indate DATETIME NOT NULL,
		LogInfo NVARCHAR(2000) NULL,
			CONSTRAINT PK_tb_ApplicationLogs_' + @postfix + N'_RowID 
			PRIMARY KEY CLUSTERED (RowID ASC,LogDay ASC)
	);
	CREATE NONCLUSTERED INDEX IX_Indate
	ON dbo.tb_ApplicationLogs_' + @postfix + N'(Indate) WITH(FILLFACTOR = 95)
	;
	'
	
	EXEC sys.sp_executesql @drop_sql

	--print @create_sql
	EXEC sys.sp_executesql @create_sql
	
	SET @do = @do + 1;
END
GO


USE [TestDb]
GO

IF OBJECT_ID('dbo.V_ApplicationAllLogs', 'V') IS NOT NULL
	DROP VIEW dbo.V_ApplicationAllLogs
GO

/*
-- 将所有的分区表UNION ALL起来,构成分区视图
供我们查询、数据写入和数据更新
*/
CREATE VIEW dbo.V_ApplicationAllLogs
AS
SELECT * FROM dbo.tb_ApplicationLogs_01 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_02 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_03 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_04 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_05 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_06 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_07 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_08 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_09 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_10 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_11 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_12 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_13 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_14 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_15 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_16 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_17 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_18 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_19 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_20 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_21 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_22 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_23 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_24 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_25 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_26 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_27 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_28 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_29 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_30 WITH(NOLOCK)
UNION ALL
SELECT * FROM dbo.tb_ApplicationLogs_31 WITH(NOLOCK)
GO


USE [TestDb]
GO

-- 这里模拟应用程序写入日志信息
;WITH a 
AS (
	SELECT * 
	FROM (VALUES(1),(2),(3),(4),(5),(6),(7),(8),(9),(10)) AS a(a)
), RoundData
AS(
SELECT TOP(100)
	RowId = ROW_NUMBER() OVER (ORDER BY a.a)
	,AppName = NEWID()
	,HostName = NEWID()
	,Indate = GETDATE() - 8
	,LogInfo = NEWID()
FROM a, a AS b, a AS c, a AS d, a AS e, a AS f, a AS g, a AS h
)
INSERT INTO dbo.V_ApplicationAllLogs(RowId, AppName, LogDay, HostName,Indate, LogInfo)
SELECT
	RowId 
	,AppName
	,LogDay = RowId % 31 + 1
	,HostName
	,Indate = CAST('2017-08-1 22:54:39.873' AS DATETIME)+ RowId % 31
	,LogInfo
FROM RoundData;
GO

-- 从分区视图中查询日志信息
SELECT * FROM dbo.V_ApplicationAllLogs WITH(NOLOCK)
WHERE Indate >='2017-08-1' AND Indate <= '2017-08-3'



IF OBJECT_ID('dbo.Up_CleanAppLogs', 'P') IS NOT NULL
	DROP PROC dbo.Up_CleanAppLogs
GO

USE [TestDb]
GO
/*
Function:
这个存储过程用来模拟清理dbo.tb_ApplicationLogs日志表中的数据,
采用TRUNATE TABLE的方式清理超过@days_before天的数据,非常迅速。

参数含义:
@days_before:超过这个参数值天数的数据将会被直接TRUNCATE删除掉。


注意:
1、在计算时间的地方,需要使用DATEADD(day, -ABS(@days_before), GETDATE())方法,
这里的取绝对值非常重要,否则当传入一个负数时,表中的所有数据都会被删除。
2、这个存储过程只需要每天执行一次就好了
*/

CREATE PROC dbo.Up_CleanAppLogs(
	@days_before INT = 7
)
AS
BEGIN
	SET NOCOUNT ON
	DECLARE
		@log_postfix INT
		,@sql NVARCHAR(MAX)
	;
	
	SELECT
		@log_postfix = DATEPART(dd, DATEADD(day, -ABS(@days_before), GETDATE()))
		,@sql = N'TRUNCATE TABLE dbo.tb_ApplicationLogs_' + RIGHT(CAST(@log_postfix+100 AS VARCHAR(3)), 2)
	;
	
	EXEC sys.sp_executesql @sql
	RAISERROR('Executing sql: %s', 10, 1, @sql) WITH NOWAIT
END;
GO

最后总结

这篇文章从日志表特性、日志表设计思路、和两种日志表设计方案等方面分享了日志表的优化设计方案与具体实现。最优化的设计方案,减少了系统资源的占用的同时,还进一步提高了系统的吞吐量,非常有价值。


PgSQL · 应用案例 · 海量用户实时定位和圈人-团圆社会公益系统

$
0
0

背景

老人、儿童是最容易走丢的人群,一定要看好老人和小孩,但是万一走丢了怎么办呢?

阿里有一个公益系统,团圆,这个系统是用来帮助发布走丢人群信息的,公安通过发布的走丢人的照片,最后一次的位置信息,向社会发布。

通过公益平台的合作伙伴(例如运营商、购物软件等)可以向最后一次走丢人士出现的位置附近的人推送寻人启事,调动社会力量帮助寻找丢失人。

为了实现这个目的,需要收集社会人士的实时位置,现在有很多技术可以实现,例如手机基站定位、GPS定位等。

假设有10亿手机用户,用户的位置实时变动,实时的位置信息需要更新到数据库中。每天可能有千亿次位置更新。

同时发布走失信息后,需要到数据库中,根据走失位置圈出附近的人。

简单粗暴设计

1、表结构设计:

create table tbl_pos(    
  id int primary key,  -- 用户ID    
  pos point  -- 用户实时位置    
);    

2、空间索引

create index idx_tbl_pos on tbl_pos using gist(pos);    

性能评测

实时更新10亿用户位置,使用insert on conflict语法。

vi test.sql    
    
\set id random(1,1000000000)    
insert into tbl_pos values (:id, point(random()*180,random()*90)) on conflict (id) do update set pos=excluded.pos;    

使用32个并发,实时生成用户随机位置.

nohup pgbench -M prepared -n -r -P 5 -f ./test.sql -c 32 -j 32 -T 120000 > ./pos.log 2>&1 &    

1、实时位置更新TPS,约18万/s。

179799    

服务器负载,服务器还是非常空闲的,有足够的资源提供给查询

top - 01:52:34 up 76 days, 15:32,  2 users,  load average: 33.74, 33.56, 31.47    
Tasks: 1064 total,  34 running, 1030 sleeping,   0 stopped,   0 zombie    
%Cpu(s): 47.6 us,  5.4 sy,  0.0 ni, 46.9 id,  0.2 wa,  0.0 hi,  0.0 si,  0.0 st    
KiB Mem : 52807456+total, 32911484+free, 10949652 used, 18801006+buff/cache    
KiB Swap:        0 total,        0 free,        0 used. 42997945+avail Mem     

2、查询性能。

在位置更新的同时,测试查询性能。

假设走失人口最后位置出现在杭州,那么我们需要查询在某个平面(例如杭州市)内的点。返回500万个点(社会用户),仅需28秒。

使用空间索引,返回速度杠杠的。

postgres=# explain (analyze,verbose,timing,costs,buffers) select * from tbl_pos where box(point(1,1), point(25.5,25.5)) @> pos limit 5000000;    
                                                                      QUERY PLAN                                                                           
-------------------------------------------------------------------------------------------------------------------------------------------------------    
 Limit  (cost=0.55..412954.11 rows=407872 width=20) (actual time=1.433..27536.623 rows=5000000 loops=1)    
   Output: id, pos    
   Buffers: shared hit=6183117 dirtied=31842    
   ->  Index Scan using idx_tbl_pos on public.tbl_pos  (cost=0.55..412954.11 rows=407872 width=20) (actual time=1.431..26861.352 rows=5000000 loops=1)    
         Output: id, pos    
         Index Cond: ('(25.5,25.5),(1,1)'::box @> tbl_pos.pos)    
         Buffers: shared hit=6183117 dirtied=31842    
 Planning time: 0.353 ms    
 Execution time: 27950.171 ms    
(9 rows)    

实际查询用,可以使用游标,流式返回。例子

postgres=# begin;    
BEGIN    
postgres=# declare cur cursor for select * from tbl_pos where box(point(1,1), point(25.5,25.5)) @> pos;    
DECLARE CURSOR    
postgres=# fetch 10 from cur;    
    id     |                 pos                     
-----------+-------------------------------------    
 680844515 | (2.08381220698357,1.25674836337566)    
 498274514 | (2.23715107887983,1.27883949782699)    
  72310072 | (2.1013452205807,1.32945269811898)    
 301147261 | (2.12246049195528,1.33455505594611)    
 186462127 | (2.13169047608972,1.24054086394608)    
 726143191 | (2.27320306934416,1.31862969137728)    
 902518425 | (2.27059512399137,1.32658164482564)    
 534516939 | (2.18118946999311,1.29441328346729)    
 329417311 | (2.27630747482181,1.2547113513574)    
 853173913 | (2.28139906190336,1.33868838194758)    
(10 rows)    
    
postgres=# \timing    
Timing is on.    
    
postgres=# fetch 10 from cur;    
    id     |                 pos                     
-----------+-------------------------------------    
 223759458 | (2.24917919375002,1.31508464924991)    
 215111891 | (2.10541740059853,1.26674327999353)    
 925178989 | (2.08201663568616,1.2974686967209)    
 954808979 | (2.10515496321023,1.32548315450549)    
 514021414 | (2.17867707833648,1.27732987515628)    
 872436892 | (2.22504794597626,1.31386948283762)    
 507169369 | (2.05484946258366,1.30171341821551)    
 317349985 | (2.25962312892079,1.30945896729827)    
 200956423 | (2.10705514065921,1.30409182514995)    
 598969258 | (1.98812280781567,1.30866004619747)    
(10 rows)    
    
Time: 0.306 ms    

通过游标,客户端可以边接收,边发短信或者向软件推送寻人启事。

实现流式推送,节省宝贵的寻人时间。

优化设计

单表十亿空间数据,对于查询来说,前面已经看到了,毫无压力。但是随着频繁的更新,可能到GiST索引的膨胀,膨胀后,PostgreSQL提供了并行创建索引的方法(不影响堵塞,可以在一个列创建同样的索引),来维护索引。但是10亿数据创建索引会变得很久。

为了解决这个问题,建议使用分区表。例如将ID哈希,分成64个分区,每个分区1500万左右数据。

在PostgreSQL中,目前性能最好的分区是pg_pathman插件。或者使用schemaless的方式。下面以schemaless为例子。其实在我曾经写过的另外的案例中也非常常见

《行为、审计日志 (实时索引/实时搜索)建模 - 最佳实践 2》

《PostgreSQL 时序最佳实践 - 证券交易系统数据库设计 - 阿里云RDS PostgreSQL最佳实践》

定义基表

postgres=# create table tbl_pos(id int primary key, pos point);  
CREATE TABLE  
postgres=# create index idx_tbl_pos_1 on tbl_pos using gist(pos);  
CREATE INDEX  

定义自动建表函数

create or replace function create_schemaless(  
  target name,   -- 目标表名  
  src name       -- 源表名  
) returns void as $$      
declare      
begin      
  execute format('create table if not exists %I (like %I including all)', target, src);      
  execute format('alter table %I inherit %I', target, src);      
exception when others then      
  return;      
end;      
$$ language plpgsql strict;      

定义以schemaless的方式写数据的函数

创建一个插入数据的函数,使用动态SQL,如果遇到表不存在的错误,则调用建表函数进行建表。

create or replace function ins_schemaless(  
  id int,   -- id  
  md int,   -- 取模数  
  pos point -- 位置  
) returns void as $$      
declare     
  target name := 'tbl_pos_'||mod(id,md) ;    
begin      
  execute format('insert into %I values (%L, %L) on conflict (id) do update set pos=point_add(%I.pos, point(random()*10-5, random()*10-5))', target, id, pos, target);     
  -- 为了模拟真实情况,因为人的移动速度有限,即使驾车,飞机(少数情况),所以用了pos=point_add(%I.pos, point(random()*10-5, random()*10-5))这种方法模拟更真实的情况  
  -- 实际场景,请改成pos=excluded.pos  
  exception       
    WHEN SQLSTATE '42P01' THEN       
    perform create_schemaless(target, 'tbl_pos');      
    execute format('insert into %I values (%L, %L) on conflict (id) do update set pos=point_add(%I.pos, point(random()*10-5, random()*10-5))', target, id, pos, target);       
    -- 为了模拟真实情况,因为人的移动速度有限,即使驾车,飞机(少数情况),所以用了pos=point_add(%I.pos, point(random()*10-5, random()*10-5))这种方法模拟更真实的情况  
    -- 实际场景,请改成pos=excluded.pos  
end;      
$$ language plpgsql strict; 

数据库端的schemaless会牺牲一部分性能,因为无法使用绑定变量。

如果可能的话,建议业务层实现schemaless(自动拼接表名,自动建表,自动写入),以提高性能。

测试功能

postgres=# select ins_schemaless(2,32,point(1,2));  
 ins_schemaless   
----------------  
   
(1 row)  
  
postgres=# select ins_schemaless(1,32,point(1,2));  
 ins_schemaless   
----------------  
   
(1 row)  
  
postgres=# select tableoid::regclass,* from tbl_pos;  
 tableoid  | id |  pos    
-----------+----+-------  
 tbl_pos_2 |  2 | (1,2)  
 tbl_pos_1 |  1 | (1,2)  
(2 rows)  

schemaless设计压测

vi ~/test.sql  
\set id random(1,1000000000)  
select ins_schemaless(:id, 32, point(random()*360-180, random()*180-90));  
  
  
nohup pgbench -M prepared -n -r -P 5 -f ./test.sql -c 32 -j 32 -T 120000 > ./pos.log 2>&1 &  

性能依旧杠杠的。

125977 tps  

小结

1、通过PostgreSQL的空间数据类型、空间索引。加上insert on conflict的特性。实现了单机约18万行/s的10亿用户的实时位置更新,同时输出500万个点的量级,仅需20几秒。真正实现了团圆公益系统的时效性。

2、采用游标,流式返回,实现了边获取数据,边向社会各界发送寻人启事的目的。

3、另一方面,用户位置的变更,实际上是有一定过滤性的,比如用户从办公室去上个洗手间,虽然位置可能发生了变化,但是非常细微,这种变化在这套系统中可以过滤(不更新),从而减少数据的更新量。

按照现有的测试数据,可以做到每天155亿次的更新。假设每10条更新仅有1条是有效更新,那么实际上可以支持1550亿次的MOVE采集。

4、PostgreSQL是一个很有爱心的数据库系统哦。

5、将来流计算引擎pipelinedb插件化后,PostgreSQL内部将整合这个流计算引擎,通过流计算引擎,理论上可以轻松实现40万行/s级别的更新速度,每天支撑300多亿次的实时位置更新。

6、采用流计算的方法除了提高性能,同时也降低了XID的消耗,在目前32BIT XID的情形下,可以有效的环节FREEZE带来的负担。如果不使用流计算,也建议合并更新,例如一个事务中更新若干条,比如100条,那么一天的事务数就将到了1.5亿。

7、参考

https://www.postgresql.org/docs/9.6/static/gist-implementation.html#GIST-BUFFERING-BUILD

《行为、审计日志 (实时索引/实时搜索)建模 - 最佳实践 2》

《PostgreSQL 时序最佳实践 - 证券交易系统数据库设计 - 阿里云RDS PostgreSQL最佳实践》

MySQL · 源码分析 · 一条insert语句的执行过程

$
0
0

本文只分析了insert语句执行的主路径,和路径上部分关键函数,很多细节没有深入,留给读者继续分析

create table t1(id int);

insert into t1 values(1)

略过建立连接,从 mysql_parse() 开始分析

void mysql_parse(THD *thd, char *rawbuf, uint length,
                 Parser_state *parser_state)
{
  /* ...... */
	
  /* 检查query_cache,如果结果存在于cache中,直接返回 */
  if (query_cache_send_result_to_client(thd, rawbuf, length) <= 0)   
  {
     LEX *lex= thd->lex;
 	 
 	 /* 解析语句 */
     bool err= parse_sql(thd, parser_state, NULL);
		
	 /* 整理语句格式,记录 general log */
	 /* ...... */
	 		  /* 执行语句 */
             error= mysql_execute_command(thd);
             /* 提交或回滚没结束的事务(事务可能在mysql_execute_command中提交,用trx_end_by_hint标记事务是否已经提交) */
             if (!thd->trx_end_by_hint)         
             {
               if (!error && lex->ci_on_success)
                 trans_commit(thd);
 
               if (error && lex->rb_on_fail)
                 trans_rollback(thd);
             }

进入 mysql_execute_command()

  /*  */
  /* ...... */
  
  case SQLCOM_INSERT:
  {  
    
    /* 检查权限 */
    if ((res= insert_precheck(thd, all_tables)))
      break;

    /* 执行insert */
    res= mysql_insert(thd, all_tables, lex->field_list, lex->many_values,
                      lex->update_list, lex->value_list,
                      lex->duplicates, lex->ignore);

	/* 提交或者回滚事务 */
    if (!res)
    {
      trans_commit_stmt(thd);
      trans_commit(thd);
      thd->trx_end_by_hint= TRUE;
    }
    else if (res)
    {
      trans_rollback_stmt(thd);
      trans_rollback(thd);
      thd->trx_end_by_hint= TRUE;
    }

进入 mysql_insert()

bool mysql_insert(THD *thd,TABLE_LIST *table_list,
                  List<Item> &fields, /* insert 的字段 */
                  List<List_item> &values_list, /* insert 的值 */
                  List<Item> &update_fields,
                  List<Item> &update_values,
                  enum_duplicates duplic,
                  bool ignore)
{ 
  /*对每条记录调用 write_record */
  while ((values= its++))
  {
	if (lock_type == TL_WRITE_DELAYED)
    {
      LEX_STRING const st_query = { query, thd->query_length() };
      DEBUG_SYNC(thd, "before_write_delayed");
      /* insert delay */
      error= write_delayed(thd, table, st_query, log_on, &info);
      DEBUG_SYNC(thd, "after_write_delayed");
      query=0;
    }
    else 
      /* normal insert */
      error= write_record(thd, table, &info, &update);
  }
  
  /*
    这里还有
    thd->binlog_query()写binlog
    my_ok()返回ok报文,ok报文中包含影响行数
  */

进入 write_record

/*
  COPY_INFO *info 用来处理唯一键冲突,记录影响行数
  COPY_INFO *update 处理 INSERT ON DUPLICATE KEY UPDATE 相关信息
*/
int write_record(THD *thd, TABLE *table, COPY_INFO *info, COPY_INFO *update)
{
  if (duplicate_handling == DUP_REPLACE || duplicate_handling == DUP_UPDATE)
  {
    /* 处理 INSERT ON DUPLICATE KEY UPDATE 等复杂情况 */
  }
  /* 调用存储引擎的接口 */
  else if ((error=table->file->ha_write_row(table->record[0])))
  {
    DEBUG_SYNC(thd, "write_row_noreplace");
    if (!ignore_errors ||
        table->file->is_fatal_error(error, HA_CHECK_DUP))
      goto err; 
    table->file->restore_auto_increment(prev_insert_id);
    goto ok_or_after_trg_err;
  }
}

进入ha_write_row、write_row

/* handler 是各个存储引擎的基类,这里我们使用InnoDB引擎*/
int handler::ha_write_row(uchar *buf)
{
  /* 指定log_event类型*/
  Log_func *log_func= Write_rows_log_event::binlog_row_logging_function;
  error= write_row(buf);
}

进入引擎层,这里是innodb引擎,handler对应ha_innobase
插入的表信息保存在handler中

int
ha_innobase::write_row(
/*===================*/
        uchar*  record) /*!< in: a row in MySQL format */
{
		error = row_insert_for_mysql((byte*) record, prebuilt);
}
UNIV_INTERN
dberr_t
row_insert_for_mysql(                                                                                                                                                                                       
/*=================*/
        byte*           mysql_rec,      /*!< in: row in the MySQL format */
        row_prebuilt_t* prebuilt)       /*!< in: prebuilt struct in MySQL
                                        handle */
{
		/*记录格式从MySQL转换成InnoDB*/
		row_mysql_convert_row_to_innobase(node->row, prebuilt, mysql_rec);
	
        thr->run_node = node;
        thr->prev_node = node;
		
		/*插入记录*/
        row_ins_step(thr);
}
UNIV_INTERN
que_thr_t*
row_ins_step(
/*=========*/
        que_thr_t*      thr)    /*!< in: query thread */
{
		/*给表加IX锁*/
		err = lock_table(0, node->table, LOCK_IX, thr);
		
		/*插入记录*/
		err = row_ins(node, thr);
}

InnoDB表是基于B+树的索引组织表

如果InnoDB表没有主键和唯一键,需要分配隐含的row_id组织聚集索引

row_id分配逻辑在row_ins中,这里不详细展开

static __attribute__((nonnull, warn_unused_result))
dberr_t
row_ins(
/*====*/
        ins_node_t*     node,   /*!< in: row insert node */
        que_thr_t*      thr)    /*!< in: query thread */
{
		if (node->state == INS_NODE_ALLOC_ROW_ID) {
				/*若innodb表没有主键和唯一键,用row_id组织索引*/
        		row_ins_alloc_row_id_step(node);
				
				/*获取row_id的索引*/
                node->index = dict_table_get_first_index(node->table);
                node->entry = UT_LIST_GET_FIRST(node->entry_list);
		}
		
		/*遍历所有索引,向每个索引中插入记录*/
		while (node->index != NULL) {
                if (node->index->type != DICT_FTS) {
                        /* 向索引中插入记录 */
                        err = row_ins_index_entry_step(node, thr);

                        if (err != DB_SUCCESS) {

                                return(err);
                        }
                }                                                                                                                                                                                           
				
				/*获取下一个索引*/
                node->index = dict_table_get_next_index(node->index);
                node->entry = UT_LIST_GET_NEXT(tuple_list, node->entry);

                }
        }
}

插入单个索引项

static __attribute__((nonnull, warn_unused_result))
dberr_t
row_ins_index_entry_step(                                                                                                                                                                                   
/*=====================*/
        ins_node_t*     node,   /*!< in: row insert node */
        que_thr_t*      thr)    /*!< in: query thread */
{
        dberr_t err;

        /*给索引项赋值*/
        row_ins_index_entry_set_vals(node->index, node->entry, node->row);

		/*插入索引项*/
        err = row_ins_index_entry(node->index, node->entry, thr);

        return(err);
}
static
dberr_t
row_ins_index_entry(                                                                                                                                                                                        
/*================*/
        dict_index_t*   index,  /*!< in: index */
        dtuple_t*       entry,  /*!< in/out: index entry to insert */
        que_thr_t*      thr)    /*!< in: query thread */
{

        if (dict_index_is_clust(index)) {
        		/* 插入聚集索引 */
                return(row_ins_clust_index_entry(index, entry, thr, 0));
        } else {
        		/* 插入二级索引 */
                return(row_ins_sec_index_entry(index, entry, thr));
        }
}

row_ins_clust_index_entry 和 row_ins_sec_index_entry 函数结构类似,只分析插入聚集索引

UNIV_INTERN
dberr_t
row_ins_clust_index_entry(
/*======================*/
        dict_index_t*   index,  /*!< in: clustered index */
        dtuple_t*       entry,  /*!< in/out: index entry to insert */
        que_thr_t*      thr,    /*!< in: query thread */
        ulint           n_ext)  /*!< in: number of externally stored columns */
{
        if (UT_LIST_GET_FIRST(index->table->foreign_list)) {
                err = row_ins_check_foreign_constraints(
                        index->table, index, entry, thr);
                if (err != DB_SUCCESS) {
                        return(err);
                }
        }
        
        /* flush log,make checkpoint(如果需要) */
        log_free_check();

		/* 先尝试乐观插入,修改叶子节点 BTR_MODIFY_LEAF */
        err = row_ins_clust_index_entry_low(
                0, BTR_MODIFY_LEAF, index, n_uniq, entry, n_ext, thr, 
                &page_no, &modify_clock);
                
        if (err != DB_FAIL) {
                DEBUG_SYNC_C("row_ins_clust_index_entry_leaf_after");
                return(err);
        }    
		
		/* flush log,make checkpoint(如果需要) */
        log_free_check();

		/* 乐观插入失败,尝试悲观插入 BTR_MODIFY_TREE */
        return(row_ins_clust_index_entry_low(
                        0, BTR_MODIFY_TREE, index, n_uniq, entry, n_ext, thr,
                        &page_no, &modify_clock));

row_ins_clust_index_entry_low 和 row_ins_sec_index_entry_low 函数结构类似,只分析插入聚集索引

UNIV_INTERN
dberr_t
row_ins_clust_index_entry_low(
/*==========================*/
        ulint           flags,  /*!< in: undo logging and locking flags */
        ulint           mode,   /*!< in: BTR_MODIFY_LEAF or BTR_MODIFY_TREE,
                                depending on whether we wish optimistic or
                                pessimistic descent down the index tree */
        dict_index_t*   index,  /*!< in: clustered index */
        ulint           n_uniq, /*!< in: 0 or index->n_uniq */
        dtuple_t*       entry,  /*!< in/out: index entry to insert */
        ulint           n_ext,  /*!< in: number of externally stored columns */
        que_thr_t*      thr,    /*!< in: query thread */
        ulint*          page_no,/*!< *page_no and *modify_clock are used to decide
                                whether to call btr_cur_optimistic_insert() during
                                pessimistic descent down the index tree.
                                in: If this is optimistic descent, then *page_no
                                must be ULINT_UNDEFINED. If it is pessimistic
                                descent, *page_no must be the page_no to which an
                                optimistic insert was attempted last time
                                row_ins_index_entry_low() was called.
                                out: If this is the optimistic descent, *page_no is set
                                to the page_no to which an optimistic insert was
                                attempted. If it is pessimistic descent, this value is
                                not changed. */
        ullint*         modify_clock) /*!< in/out: *modify_clock == ULLINT_UNDEFINED
                                during optimistic descent, and the modify_clock
                                value for the page that was used for optimistic
                                insert during pessimistic descent */
{
		/* 将cursor移动到索引上待插入的位置 */
		btr_cur_search_to_nth_level(index, 0, entry, PAGE_CUR_LE, mode,                                                                                                                                     
                                    &cursor, 0, __FILE__, __LINE__, &mtr);
                        
                        /*根据不同的flag检查主键冲突*/
                        err = row_ins_duplicate_error_in_clust_online(
                                n_uniq, entry, &cursor,
                                &offsets, &offsets_heap);
                                
                        err = row_ins_duplicate_error_in_clust(
                                flags, &cursor, entry, thr, &mtr);

		/*
		  如果要插入的索引项已存在,则把insert操作改为update操作
		  索引项已存在,且没有主键冲突,是因为之前的索引项对应的数据被标记为已删除
		  本次插入的数据和上次删除的一样,而索引项并未删除,所以变为update操作		
		*/
        if (row_ins_must_modify_rec(&cursor)) {
                /* There is already an index entry with a long enough common
                prefix, we must convert the insert into a modify of an
                existing record */
                mem_heap_t*     entry_heap      = mem_heap_create(1024);
				
				/* 更新数据到存在的索引项 */
                err = row_ins_clust_index_entry_by_modify(
                        flags, mode, &cursor, &offsets, &offsets_heap,
                        entry_heap, &big_rec, entry, thr, &mtr);
                
                /*如果索引正在online_ddl,先记录insert*/
                if (err == DB_SUCCESS && dict_index_is_online_ddl(index)) {
                        row_log_table_insert(rec, index, offsets);
                }

				/*提交mini transaction*/
                mtr_commit(&mtr);
                mem_heap_free(entry_heap);
        } else {
                rec_t*  insert_rec;

                if (mode != BTR_MODIFY_TREE) {
                		/*进行一次乐观插入*/
                        err = btr_cur_optimistic_insert(
                                flags, &cursor, &offsets, &offsets_heap,
                                entry, &insert_rec, &big_rec,
                                n_ext, thr, &mtr);
                } else {
                		/*
                		  如果buffer pool余量不足25%,插入失败,返回DB_LOCK_TABLE_FULL
                		  处理DB_LOCK_TABLE_FULL错误时,会回滚事务
                		  防止大事务的锁占满buffer pool(注释里写的)
                		*/
                        if (buf_LRU_buf_pool_running_out()) {

                                err = DB_LOCK_TABLE_FULL;
                                goto err_exit;
                        }

                        if (/*太长了,略*/) {
                        		 /*进行一次乐观插入*/
                                err = btr_cur_optimistic_insert(
                                        flags, &cursor,
                                        &offsets, &offsets_heap,
                                        entry, &insert_rec, &big_rec,
                                        n_ext, thr, &mtr);
                        } else {
                                err = DB_FAIL;
                        }

                        if (err == DB_FAIL) {
                        		 /*乐观插入失败,进行悲观插入*/
                                err = btr_cur_pessimistic_insert(
                                        flags, &cursor,
                                        &offsets, &offsets_heap,
                                        entry, &insert_rec, &big_rec,
                                        n_ext, thr, &mtr);
                        }
                }

}

btr_cur_optimistic_insert 和 btr_cur_pessimistic_insert 涉及B+树的操作,内部细节很多,以后再做分析

PgSQL · 特性分析 · MVCC机制浅析

$
0
0

背景

我们在使用PostgreSQL的时候,可能会碰到表膨胀的问题(关于表膨胀可以参考之前的月报),即表的数据量并不大,但是占用的磁盘空间比较大,查询比较慢。为什么PostgreSQL有可能发生表膨胀呢?这是因为PostgreSQL引入了MVCC机制来保证事务的隔离性,实现数据库的隔离级别。

在数据库中,并发的数据库操作会面临脏读(Dirty Read)、不可重复读(Nonrepeatable Read)、幻读(Phantom Read)和串行化异常等问题,为了解决这些问题,在标准的SQL规范中对应定义了四种事务隔离级别:
- RU(Read uncommitted):读未提交
- RC(Read committed):读已提交
- RR(Repeatable read):重复读
- SERIALIZABLE(Serializable):串行化

当前PostgreSQL已经支持了这四种标准的事务隔离级别(可以使用SET TRANSACTION语句来设置,详见文档),下表是PostgreSQL官方文档上列举的四种事务隔离级别和对应数据库问题的关系:

Isolation LevelDirty ReadNonrepeatable ReadPhantom ReadSerialization Anomaly
Read uncommittedAllowed, but not in PGPossiblePossiblePossible
Read committedNot possiblePossiblePossiblePossible
Repeatable readNot possibleNot possibleAllowed, but not in PGPossible
SerializableNot possibleNot possibleNot possibleNot possible

需要注意的是,在PostgreSQL中:
- RU隔离级别不允许脏读,实际上和Read committed一样
- RR隔离级别不允许幻读

在PostgreSQL中,为了保证事务的隔离性,实现数据库的隔离级别,引入了MVCC(Multi-Version Concurrency Control)多版本并发控制。

MVCC常用实现方法

一般MVCC有2种实现方法:
- 写新数据时,把旧数据转移到一个单独的地方,如回滚段中,其他人读数据时,从回滚段中把旧的数据读出来,如Oracle数据库和MySQL中的innodb引擎。
- 写新数据时,旧数据不删除,而是把新数据插入。PostgreSQL就是使用的这种实现方法。

两种方法各有利弊,相对于第一种来说,PostgreSQL的MVCC实现方式优缺点如下:
- 优点
- 无论事务进行了多少操作,事务回滚可以立即完成
- 数据可以进行很多更新,不必像Oracle和MySQL的Innodb引擎那样需要经常保证回滚段不会被用完,也不会像oracle数据库那样经常遇到“ORA-1555”错误的困扰
- 缺点
- 旧版本的数据需要清理。当然,PostgreSQL 9.x版本中已经增加了自动清理的辅助进程来定期清理
- 旧版本的数据可能会导致查询需要扫描的数据块增多,从而导致查询变慢

PostgreSQL中MVCC的具体实现

为了实现MVCC机制,必须要:
- 定义多版本的数据。在PostgreSQL中,使用元组头部信息的字段来标示元组的版本号
- 定义数据的有效性、可见性、可更新性。在PostgreSQL中,通过当前的事务快照和对应元组的版本号来判断该元组的有效性、可见性、可更新性
- 实现不同的数据库隔离级别

接下来,我们会按照上面的顺序,首先介绍多版本元组的存储结构,再介绍事务快照、数据可见性的判断以及数据库隔离级别的实现。

多版本元组存储结构

为了定义MVCC 中不同版本的数据,PostgreSQL在每个元组的头部信息HeapTupleHeaderData中引入了一些字段如下:

struct HeapTupleHeaderData
{
	union
	{
		HeapTupleFields t_heap;
		DatumTupleFields t_datum;
	}			t_choice;

	ItemPointerData t_ctid;		/* current TID of this or newer tuple (or a
								 * speculative insertion token) */

	/* Fields below here must match MinimalTupleData! */

	uint16		t_infomask2;	/* number of attributes + various flags */

	uint16		t_infomask;		/* various flag bits, see below */

	uint8		t_hoff;			/* sizeof header incl. bitmap, padding */

	/* ^ - 23 bytes - ^ */

	bits8		t_bits[FLEXIBLE_ARRAY_MEMBER];	/* bitmap of NULLs */

	/* MORE DATA FOLLOWS AT END OF STRUCT */
};

其中:
- t_heap存储该元组的一些描述信息,下面会具体去分析其字段
- t_ctid存储用来记录当前元组或新元组的物理位置
- 由块号和块内偏移组成
- 如果这个元组被更新,则该字段指向更新后的新元组
- 这个字段指向自己,且后面t_heap中的xmax字段为空,就说明该元组为最新版本
- t_infomask存储元组的xmin和xmax事务状态,以下是t_infomask每位分别代表的含义:

#define HEAP_HASNULL        0x0001    /* has null attribute(s) */
#define HEAP_HASVARWIDTH        0x0002    /* has variable-width attribute(s) 有可变参数 */
#define HEAP_HASEXTERNAL        0x0004    /* has external stored attribute(s) */
#define HEAP_HASOID        0x0008    /* has an object-id field */
#define HEAP_XMAX_KEYSHR_LOCK    0x0010    /* xmax is a key-shared locker */
#define HEAP_COMBOCID        0x0020    /* t_cid is a combo cid */
#define HEAP_XMAX_EXCL_LOCK    0x0040    /* xmax is exclusive locker */
#define HEAP_XMAX_LOCK_ONLY    0x0080    /* xmax, if valid, is only a locker */
/* xmax is a shared locker */
#define HEAP_XMAX_SHR_LOCK  (HEAP_XMAX_EXCL_LOCK | HEAP_XMAX_KEYSHR_LOCK)
#define HEAP_LOCK_MASK    (HEAP_XMAX_SHR_LOCK | HEAP_XMAX_EXCL_LOCK | \
                         HEAP_XMAX_KEYSHR_LOCK)
#define HEAP_XMIN_COMMITTED    0x0100    /* t_xmin committed 即xmin已经提交*/
#define HEAP_XMIN_INVALID        0x0200    /* t_xmin invalid/aborted */
#define HEAP_XMIN_FROZEN        (HEAP_XMIN_COMMITTED|HEAP_XMIN_INVALID)
#define HEAP_XMAX_COMMITTED    0x0400    /* t_xmax committed即xmax已经提交*/
#define HEAP_XMAX_INVALID        0x0800    /* t_xmax invalid/aborted */
#define HEAP_XMAX_IS_MULTI        0x1000    /* t_xmax is a MultiXactId */
#define HEAP_UPDATED        0x2000    /* this is UPDATEd version of row */
#define HEAP_MOVED_OFF        0x4000    /* moved to another place by pre-9.0                    * VACUUM FULL; kept for binary                     * upgrade support */
#define HEAP_MOVED_IN        0x8000    /* moved from another place by pre-9.0                * VACUUM FULL; kept for binary                  * upgrade support */
#define HEAP_MOVED (HEAP_MOVED_OFF | HEAP_MOVED_IN)
#define HEAP_XACT_MASK        0xFFF0    /* visibility-related bits */

上文HeapTupleHeaderData中的t_heap存储着元组的一些描述信息,结构如下:

typedef struct HeapTupleFields
{
TransactionId t_xmin;   /* inserting xact ID */
TransactionId t_xmax;   /* deleting or locking xact ID */

union
{
   CommandId t_cid;   /* inserting or deleting command ID, or both */
   TransactionId t_xvac; /* VACUUM FULL xact ID */
}    t_field3;
} HeapTupleFields;

其中:
- t_xmin 存储的是产生这个元组的事务ID,可能是insert或者update语句
- t_xmax 存储的是删除或者锁定这个元组的事务ID
- t_cid 包含cmin和cmax两个字段,分别存储创建这个元组的Command ID和删除这个元组的Command ID
- t_xvac 存储的是VACUUM FULL 命令的事务ID

这里需要简单介绍下PostgreSQL中的事务ID:
- 由32位组成,这就有可能造成事务ID回卷的问题,具体参考文档
- 顺序产生,依次递增
- 没有数据变更,如INSERT、UPDATE、DELETE等操作,在当前会话中,事务ID不会改变

PostgreSQL主要就是通过t_xmin,t_xmax,cmin和cmax,ctid,t_infomask来唯一定义一个元组(t_xmin,t_xmax,cmin和cmax,ctid实际上也是一个表的隐藏的标记字段),下面以一个例子来表示元组更新前后各个字段的变化。

  • 创建表test,插入数据,并查询t_xmin,t_xmax,cmin和cmax,ctid属性
postgres=# create table test(id int);
CREATE TABLE
postgres=# insert into test values(1);
INSERT 0 1
postgres=# select ctid, xmin, xmax, cmin, cmax,id from test;
 ctid  | xmin | xmax | cmin | cmax | id
-------+------+------+------+------+----
 (0,1) | 1834 |    0 |    0 |    0 |  1
(1 row)
  • 更新test,并查询t_xmin,t_xmax,cmin和cmax,ctid属性
postgres=# update test set id=2;
UPDATE 1
postgres=# select ctid, xmin, xmax, cmin, cmax,id from test;
 ctid  | xmin | xmax | cmin | cmax | id
-------+------+------+------+------+----
 (0,2) | 1835 |    0 |    0 |    0 |  2
(1 row)
  • 使用heap_page_items 方法查看test表对应page header中的内容
postgres=# select * from heap_page_items(get_raw_page('test',0));
 lp | lp_off | lp_flags | lp_len | t_xmin | t_xmax | t_field3 | t_ctid | t_infomask2 | t_infomask | t_hoff | t_bits | t_oid
----+--------+----------+--------+--------+--------+----------+--------+-------------+------------+--------+--------+-------
  1 |   8160 |        1 |     28 |   1834 |   1835 |        0 | (0,2)  |       16385 |       1280 |     24 |        |
  2 |   8128 |        1 |     28 |   1835 |      0 |        0 | (0,2)  |       32769 |      10496 |     24 |        |

从上面可知,实际上数据库存储了更新前后的两个元组,这个过程中的数据块中的变化大体如下:
image.png

Tuple1更新后会插入一个新的Tuple2,而Tuple1中的ctid指向了新的版本,同时Tuple1的xmax从0变为1835,这里可以被认为被标记为过期(只有xmax为0的元组才没过期),等待PostgreSQL的自动清理辅助进程回收掉。

也就是说,PostgreSQL通过HeapTupleHeaderData 的几个特殊的字段,给元组设置了不同的版本号,元组的每次更新操作都会产生一条新版本的元组,版本之间从旧到新形成了一条版本链(旧的ctid指向新的元组)。

不过这里需要注意的是,更新操作可能会使表的每个索引也产生新版本的索引记录,即对一条元组的每个版本都有对应版本的索引记录。这样带来的问题就是浪费了存储空间,旧版本占用的空间只有在进行VACCUM时才能被回收,增加了数据库的负担。

为了减缓更新索引带来的影响,8.3之后开始使用HOT机制。定义符合下面条件的为HOT元组:
- 索引属性没有被修改
- 更新的元组新旧版本在同一个page中,其中新的被称为HOT元组

更新一条HOT元组不需要引入新版本的索引,当通过索引获取元组时首先会找到最旧的元组,然后通过元组的版本链找到HOT元组。这样HOT机制让拥有相同索引键值的不同版本元组共用一个索引记录,减少了索引的不必要更新。

事务快照的实现

为了实现元组对事务的可见性判断,PostgreSQL引入了事务快照SnapshotData,其具体数据结构如下:

typedef struct SnapshotData
{
    SnapshotSatisfiesFunc satisfies;    /* tuple test function */
    TransactionId xmin;        /* all XID < xmin are visible to me */
    TransactionId xmax;        /* all XID >= xmax are invisible to me */
    TransactionId *xip;    //所有正在运行的事务的id列表
    uint32    xcnt;    /* # of xact ids in xip[],正在运行的事务的计数 */
    TransactionId *subxip;           //进程中子事务的ID列表
    int32    subxcnt;        /* # of xact ids in subxip[],进程中子事务的计数 */
    bool    suboverflowed;    /* has the subxip array overflowed? */
    bool    takenDuringRecovery;    /* recovery-shaped snapshot? */
    bool    copied;            /* false if it's a static snapshot */
    CommandId    curcid;    /* in my xact, CID < curcid are visible */
    uint32    speculativeToken;
    uint32    active_count;    /* refcount on ActiveSnapshot stack,在活动快照链表里的
*引用计数 */
    uint32    regd_count;    /* refcount on RegisteredSnapshots,在已注册的快照链表
*里的引用计数 */
    pairingheap_node ph_node;    /* link in the RegisteredSnapshots heap */
    TimestampTz  whenTaken;    /* timestamp when snapshot was taken */
    XLogRecPtr   lsn;        /* position in the WAL stream when taken */
} SnapshotData;

这里注意区分SnapshotData的xmin,xmax和HeapTupleFields的t_xmin,t_xmax

事务快照是用来存储数据库的事务运行情况。一个事务快照的创建过程可以概括为:
- 查看当前所有的未提交并活跃的事务,存储在数组中
- 选取未提交并活跃的事务中最小的XID,记录在快照的xmin中
- 选取所有已提交事务中最大的XID,加1后记录在xmax中
- 根据不同的情况,赋值不同的satisfies,创建不同的事务快照

其中根据xmin和xmax的定义,事务和快照的可见性可以概括为:
- 当事务ID小于xmin的事务表示已经被提交,其涉及的修改对当前快照可见
- 事务ID大于或等于xmax的事务表示正在执行,其所做的修改对当前快照不可见
- 事务ID处在 [xmin, xmax)区间的事务, 需要结合活跃事务列表与事务提交日志CLOG,判断其所作的修改对当前快照是否可见,即SnapshotData中的satisfies。

satisfies是PostgreSQL提供的对于事务可见性判断的统一操作接口。目前在PostgreSQL 10.0中具体实现了以下几个函数:

  • HeapTupleSatisfiesMVCC:判断元组对某一快照版本是否有效
  • HeapTupleSatisfiesUpdate:判断元组是否可更新
  • HeapTupleSatisfiesDirty:判断当前元组是否已脏
  • HeapTupleSatisfiesSelf:判断tuple对自身信息是否有效
  • HeapTupleSatisfiesToast:用于TOAST表(参考文档)的判断
  • HeapTupleSatisfiesVacuum:用在VACUUM,判断某个元组是否对任何正在运行的事务可见,如果是,则该元组不能被VACUUM删除
  • HeapTupleSatisfiesAny:所有元组都可见
  • HeapTupleSatisfiesHistoricMVCC:用于CATALOG 表

上述几个函数的参数都是 (HeapTuple htup, Snapshot snapshot, Buffer buffer),其具体逻辑和判断条件,本文不展开具体讨论,有兴趣的可以参考《PostgreSQL数据库内核分析》的7.10.2 MVCC相关操作。

此外,为了对可用性判断的过程进行加速,PostgreSQL还引入了Visibility Map机制(详见文档)。Visibility Map标记了哪些page中是没有dead tuple的。这有两个好处:
- 当vacuum时,可以直接跳过这些page
- 进行index-only scan时,可以先检查下Visibility Map。这样减少fetch tuple时的可见性判断,从而减少IO操作,提高性能

另外visibility map相对整个relation,还是小很多,可以cache到内存中。

隔离级别的实现

PostgreSQL中根据获取快照时机的不同实现了不同的数据库隔离级别(对应代码中函数GetTransactionSnapshot):

  • 读未提交/读已提交:每个query都会获取最新的快照CurrentSnapshotData
  • 重复读:所有的query 获取相同的快照都为第1个query获取的快照FirstXactSnapshot
  • 串行化:使用锁系统来实现

总结

为了保证事务的原子性和隔离性,实现不同的隔离级别,PostgreSQL引入了MVCC多版本机制,概括起就是:
- 通过元组的头部信息中的xmin,xmax以及t_infomask等信息来定义元组的版本
- 通过事务提交日志来判断当前数据库各个事务的运行状态
- 通过事务快照来记录当前数据库的事务总体状态
- 根据用户设置的隔离级别来判断获取事务快照的时间

如上文所讲,PostgreSQL的MVCC实现方法有利有弊。其中最直接的问题就是表膨胀,为了解决这个问题引入了AutoVacuum自动清理辅助进程,将MVCC带来的垃圾数据定期清理,这部分内容我们将在下期月报进行分析,敬请期待。

MySQL · 性能优化· CloudDBA SQL优化建议之统计信息获取

$
0
0

阿里云CloudDBA具有SQL优化建议功能,包括SQL重写建议和索引建议。SQL索引建议是帮助数据库优化器创造最佳执行路径,需要遵循数据库优化器的一系列规则来实现。CloudDBA需要首先计算表统计信息,是因为:

  • 数据库优化器通常是基于代价寻找执行路径;
  • SQL优化建议所针对的数据库不限于MySQL数据库,也不局限于某一个特定版本;

1. 基本原则

数据库统计信息在SQL优化起到重要作用。用来估算查询条件选择度的常见统计信息包括表统计信息和字段统计信息。DBA计算查询条件选择度或代价时经常通过手工执行SQL语句获取,并进行返回行数或代价的粗略估算。

  • 表统计信息:表中总记录数;
  • 字段统计信息:包括最大值,最小值;以及不同值个数;

而要相对更准确的获取条件选择度的估算,往往需要统计直方图(Histogram),因为多数情况,每个值的出现频度是不一样的。针对复杂SQL的优化,比如多条件查询、Range查询以及多表关联查询等,统计直方图能帮助DBA更好的进行代价估算。

在云上环境,获取统计信息以最小代价为前提的,不能对生产系统造成任何性能上的负面影响,也不能耗费较长时间。获取统计数据的基本原则如下:

  • 从备库获取统计数据;
  • 只统计最近数据;
  • 采取抽样的方式获取数据;
  • 不抽取原始数据,只对数据的hash值进行统计;

2. 最近数据统计

长期变化的数据通常具有周期性,并且以天为基本周期符合一般业务逻辑。因此多数情况无需对全量数据进行统计,抽取最近一天的数据通常具有代表性。

3. 样例数据统计

云上数据库通常要求表设计中有自增主键。在这一条件下获取表的最近数据的方法较为简单,比如:

	select * from tab order by id desc limit 1000;

该语句通过在自增主键上做排序并获取最近插入的1000行数据。由于id是主键,排序并无额外代价。类似方式可以获取第其它样例数据,比如:

	select * from tab order by id desc limit 10000, 1000;

4. 数据特征分析

基于抽样数据,对影响选择度或查询返回行数的特性进行分析:

  • 数据频率

    对每一份样例数据中不同字段的频率统计之后,需要推导出或预测字段中的某个数值在全表中的频率情况。通过分析不同样例数据间的数据重合度在具体实践中具有实际意义。

  • 数据密度

    获取每个字段的最大值和最小值代价较高。变通方法就是通过样例数据的最大最小值以及频率进行数据密度计算。基于数据密度数据,估算范围查询返回行数。

  • 字段关联性

    评估多条件查询的选择度需要首先获取字段之间的关联性。若多条件查询条件关联性很低,则综合选择度就是单个条件选择度的乘积;若多条件查询条件关联性较高,则采用最小选择度(或乘以系数)作为综合选择度。

5. 总结

  • 直方图是对基本数据的估计,任何直方图都不是精确的;
  • 云上环境以最小代价获取统计数据是基本前提;
  • 数据库优化器需要选择的是最佳路径,得出字段之间选择度的相对值更为重要;

MySQL · 引擎特性 · InnoDB mini transation

$
0
0

前言

InnoDB有两个非常重要的日志,undo log 和 redo log;通过undo log可以看到数据较早版本,实现MVCC,或回滚事务等功能;redo log用来保证事务持久性

本文以一条insert语句为线索介绍 mini transaction

mini transaction 简介

mini transation 主要用于innodb redo log 和 undo log写入,保证两种日志的ACID特性

mini-transaction遵循以下三个协议:

  1. The FIX Rules

  2. Write-Ahead Log

  3. Force-log-at-commit

The FIX Rules

修改一个页需要获得该页的x-latch

访问一个页是需要获得该页的s-latch或者x-latch

持有该页的latch直到修改或者访问该页的操作完成

Write-Ahead Log

持久化一个数据页之前,必须先将内存中相应的日志页持久化

每个页有一个LSN,每次页修改需要维护这个LSN,当一个页需要写入到持久化设备时,要求内存中小于该页LSN的日志先写入到持久化设备中

Force-log-at-commit

一个事务可以同时修改了多个页,Write-AheadLog单个数据页的一致性,无法保证事务的持久性

Force -log-at-commit要求当一个事务提交时,其产生所有的mini-transaction日志必须刷到持久设备中

这样即使在页数据刷盘的时候宕机,也可以通过日志进行redo恢复

代码简介

本文使用 MySQL 5.6.16 版本进行分析

mini transation 相关代码路径位于 storage/innobase/mtr/ 主要有 mtr0mtr.cc 和 mtr0log.cc 两个文件

另有部分代码在 storage/innobase/include/ 文件名以 mtr0 开头

mini transaction 的信息保存在结构体 mtr_t 中,结构体成员描述如下

成员属性描述
statemini transaction所处状态 MTR_ACTIVE, MTR_COMMITTING, MTR_COMMITTED
memomtr 持有锁的栈
logmtr产生的日志
inside_ibufinsert buffer 是否修改
modifications是否修改buffer pool pages
made_dirty是否产生buffer pool脏页
n_log_recslog 记录数
n_freed_pages释放page数
log_mode日志模式,默认MTR_LOG_ALL
start_lsnlsn 起始值
end_lsnlsn 结束值
magic_n魔术字

一个 mini transaction 从 mtr_start(mtr)开始,到 mtr_commit(mtr)结束

一条insert语句涉及的 mini transaction

下面涉及 mtr 的嵌套,在代码中,每个 mtr_t 对象变量名都叫 mtr,本文中为了区分不同 mtr,给不同的对象加编号

下面一般省略 mtr_t 以外的参数

第一个 mtr 从 row_ins_clust_index_entry_low 开始

mtr_start(mtr_1) // mtr_1 贯穿整条insert语句
row_ins_clust_index_entry_low


mtr_s_lock(dict_index_get_lock(index), mtr_1) // 对index加s锁
btr_cur_search_to_nth_level
row_ins_clust_index_entry_low


mtr_memo_push(mtr_1) // buffer RW_NO_LATCH 入栈
buf_page_get_gen
btr_cur_search_to_nth_level
row_ins_clust_index_entry_low


mtr_memo_push(mtr_1) // page RW_X_LATCH 入栈
buf_page_get_gen
btr_block_get_func
btr_cur_latch_leaves
btr_cur_search_to_nth_level
row_ins_clust_index_entry_low

	
	mtr_start(mtr_2) // mtr_2 用于记录 undo log
	trx_undo_report_row_operation
	btr_cur_ins_lock_and_undo
	btr_cur_optimistic_insert
	row_ins_clust_index_entry_low
	
	
		mtr_start(mtr_3) // mtr_3 分配或复用一个 undo log
		trx_undo_assign_undo
		trx_undo_report_row_operation
		btr_cur_ins_lock_and_undo
		btr_cur_optimistic_insert
		row_ins_clust_index_entry_low
		
		mtr_memo_push(mtr_3) // 对复用(也可能是分配)的 undo log page 加 RW_X_LATCH 入栈
		buf_page_get_gen
		trx_undo_page_get
		trx_undo_reuse_cached // 这里先尝试复用,如果复用失败,则分配新的 undo log
		trx_undo_assign_undo
		trx_undo_report_row_operation
		

 		trx_undo_insert_header_reuse(mtr_3) // 写 undo log header
		trx_undo_reuse_cached
		trx_undo_assign_undo
		trx_undo_report_row_operation
		
		
		trx_undo_header_add_space_for_xid(mtr_3) // 在 undo header 中预留 XID 空间
		trx_undo_reuse_cached
		trx_undo_assign_undo
		trx_undo_report_row_operation
		
		
		mtr_commit(mtr_3) // 提交 mtr_3
		trx_undo_assign_undo
		trx_undo_report_row_operation
		btr_cur_ins_lock_and_undo
		btr_cur_optimistic_insert
		row_ins_clust_index_entry_low
	
	mtr_memo_push(mtr_2) // 即将写入的 undo log page 加 RW_X_LATCH 入栈
	buf_page_get_gen
	trx_undo_report_row_operation
	btr_cur_ins_lock_and_undo
	btr_cur_optimistic_insert
	row_ins_clust_index_entry_low
	
	
	trx_undo_page_report_insert(mtr_2) // undo log 记录 insert 操作
	trx_undo_report_row_operation
	btr_cur_ins_lock_and_undo
	btr_cur_optimistic_insert
	row_ins_clust_index_entry_low
	
	
	mtr_commit(mtr_2) // 提交 mtr_2
	trx_undo_report_row_operation
	btr_cur_ins_lock_and_undo
	btr_cur_optimistic_insert
	row_ins_clust_index_entry_low
	
/*
	mtr_2 提交后开始执行 insert 操作
	page_cur_insert_rec_low 具体执行 insert 操作
	在该函数末尾调用 page_cur_insert_rec_write_log 写 redo log
*/


page_cur_insert_rec_write_log(mtr_1) // insert 操作写 redo log
page_cur_insert_rec_lowpage_cur_tuple_insert
btr_cur_optimistic_insert


mtr_commit(mtr_1) // 提交 mtr_1
row_ins_clust_index_entry_low	

至此 insert 语句执行结束后

一条 insert 是一个单语句事务,事务提交时也会涉及 mini transaction

提交事务时,第一个 mtr 从 trx_prepare 开始

mtr_start(mtr_4) // mtr_4 用于 prepare transaction
trx_prepare
trx_prepare_for_mysql
innobase_xa_prepare
ha_prepare_low
MYSQL_BIN_LOG::prepare
ha_commit_trans
trans_commit_stmt
mysql_execute_command


mtr_memo_push(mtr_4) // undo page 加 RW_X_LATCH 入栈
buf_page_get_gen
trx_undo_page_get
trx_undo_set_state_at_prepare
trx_prepare


mlog_write_ulint(seg_hdr + TRX_UNDO_STATE, undo->state, MLOG_2BYTES, mtr_4) 写入TRX_UNDO_STATE
trx_undo_set_state_at_prepare
trx_prepare


mlog_write_ulint(undo_header + TRX_UNDO_XID_EXISTS, TRUE, MLOG_1BYTE, mtr_4) 写入 TRX_UNDO_XID_EXISTS
trx_undo_set_state_at_prepare
trx_prepare


trx_undo_write_xid(undo_header, &undo->xid, mtr_4) undo 写入 xid
trx_undo_set_state_at_prepare
trx_prepare


mtr_commit(mtr_4) // 提交 mtr_4
trx_prepare




mtr_start(mtr_5) // mtr_5 用于 commit transaction
trx_commit
trx_commit_for_mysql
innobase_commit_low
innobase_commit
ha_commit_low
MYSQL_BIN_LOG::process_commit_stage_queue
MYSQL_BIN_LOG::ordered_commit
MYSQL_BIN_LOG::commit
ha_commit_trans
trans_commit_stmt
mysql_execute_command



mtr_memo_push(mtr_5) // undo page 加 RW_X_LATCH 入栈
buf_page_get_gen
trx_undo_page_get
trx_undo_set_state_at_finish
trx_write_serialisation_history
trx_commit_low
trx_commit


trx_undo_set_state_at_finish(mtr_5) // set undo state, 这里是 TRX_UNDO_CACHED
trx_write_serialisation_history
trx_commit_low
trx_commit


mtr_memo_push(mtr_5) // 系统表空间 transaction system header page 加 RW_X_LATCH 入栈
buf_page_get_gen
trx_sysf_get
trx_sys_update_mysql_binlog_offset
trx_write_serialisation_history
trx_commit_low
trx_commit


trx_sys_update_mysql_binlog_offset // 更新偏移量信息到系统表空间
trx_write_serialisation_history
trx_commit_low
trx_commit

mtr_commit(mtr_5) // 提交 mtr_5
trx_commit_low
trx_commit

至此 insert 语句涉及的 mini transaction 全部结束

总结

上面可以看到加锁、写日志到 mlog 等操作在 mini transaction 过程中进行

解锁、把日志刷盘等操作全部在 mtr_commit 中进行,和事务类似

mini transaction 没有回滚操作, 因为只有在 mtr_commit 才将修改落盘,如果宕机,内存丢失,无需回滚;如果落盘过程中宕机,崩溃恢复时可以看出落盘过程不完整,丢弃这部分修改

mtr_commit 主要包含以下步骤

  1. mlog 中日志刷盘
  2. 释放 mtr 持有的锁,锁信息保存在 memo 中,以栈形式保存,后加的锁先释放
  3. 清理 mtr 申请的内存空间,memo 和 log
  4. mtr—>state 设置为 MTR_COMMITTED

上面的步骤 1. 中,日志刷盘策略和 innodb_flush_log_at_trx_commit 有关

  • 当设置该值为1时,每次事务提交都要做一次fsync,这是最安全的配置,即使宕机也不会丢失事务
  • 当设置为2时,则在事务提交时只做write操作,只保证写到系统的page cache,因此实例crash不会丢失事务,但宕机则可能丢失事务
  • 当设置为0时,事务提交不会触发redo写操作,而是留给后台线程每秒一次的刷盘操作,因此实例crash将最多丢失1秒钟内的事务

MySQL · 特性介绍 · 一些流行引擎存储格式简介

$
0
0

概述

本文简要介绍了一些存储引擎存储结构,包括InnoDB, TokuDB, RocksDB, TiDB, CockroachDB, 供大家对比分析

InnoDB

InnoDB 底层存储结构为B+树,结构如下
image.png

B树的每个节点对应innodb的一个page,page大小是固定的,一般设为16k。
其中非叶子节点只有键值,叶子节点包含完整数据。
InnoDB按segment, extent, page方式管理page
image.png

每个数据节点page结构如下
image.png

数据记录record按行存储,record具体格式由row_format决定.
详情可以参考数据内核月报

TokuDB

TokuDB 底层存储结构为Fractal Tree
屏幕快照 2017-10-16 下午2.38.11.png

Fractal Tree的结构与B+树有些类似, 在Fractal Tree中,每一个child指针除了需要指向一个child节点外,还会带有一个Message Buffer ,这个Message Buffer 是一个FIFO的队列,用来缓存更新操作。

例如,一次插入操作只需要落在某节点的Message Buffer就可以马上返回了,并不需要搜索到叶子节点。这些缓存的更新会在查询时或后台异步合并应用到对应的节点中。

RocksDB

RockDB的存储结构如下
xx.png

RocksDB写入数据时,先写到memtable中,memtable一般为skiplist, memtable写满时转为immutable memtable并刷入Level 0.

Level0中的SST文件中的数据都是有序的,Level0中SST文件之间的数据范围可能存在重叠。
其他Level中的SST文件之间的数据范围不重叠。

RocksDB会以一定的机制从低level compact数据到高level中。

RocksDB中SST文件的结构如下
image.png

MyRocks使用的存储引擎就是RocksDB, MyRocks的中RocksDB的数据映射关系参考 之前的月报
image.png

TiDB

TiDB的存储结构

image.png

TiDB是分布式存储,分为两个部分TiKV和Placement Driver server。
TiKV用于存储真正的数据,TiKV由分布在不同机器上的RocksDB实例组成。
数据按范围划分为一个个Region. 并且会尽量保持每个 Region 中保存的数据不超过一定的大小(这个大小可以配置,目前默认是 64MB). 同一Region分布在不同的RocksDB实例中,一个RocksDB实例包含多个Region.
图中,Region4有三个副本分布在三个RocksDB实例中,这三个Region副本组成一个RaftGroup,副本间通过Raft协议保证一致性。
Placement Driver server(PD), 也是一个集群,也通过Raft协议保证一致性。PD主要有以下作用:

  • 存储region的位置等元数据信息
  • 调度和rebalance regions, TiKV中的Raft leader等信息
  • 分配全局事务ID

TiDB的数据映射关系
以下表为例

create table user(user_id int primary key, name varchar(100), email varchar(200));
INSERT INTO user VALUES (1, “bob”, “huang@pingcap.com”);
INSERT INTO user VALUES (2, “tom”, “tom@pingcap.com”);

对应到RocksDB中的KV结构如下

KeyValues
user/1bob huang@pingcap.com
user/2tom tom@pingcap.com

CockroachDB

CockroachDB的存储结构

image.png

image.png

CockroachDB的也是分布式存储,其结构和TiDB类似。CockroachDB按范围划分为Range,Range默认为64M,Range的存储为RocksDB, CockroachDB的一个node包含多个RocksDB实例。
Range副本分布在不同的node中,通过Raft协议保证一致。

Range的元数据信息也保存在Range中(靠前的Range中).

System keys come in several subtypes:

  • Global keys store cluster-wide data such as the “meta1” and “meta2” keys as well as various other system-wide keys such as the node and store ID allocators.
  • Store local keys are used for unreplicated store metadata (e.g. the StoreIdent structure). “Unreplicated” indicates that these values are not replicated across multiple stores because the data they hold is tied to the lifetime of the store they are present on.
  • Range local keys store range metadata that is associated with a global key. Range local keys have a special prefix followed by a global key and a special suffix. For example, transaction records are range local keys which look like: \x01ktxn-.
  • Replicated Range ID local keys store range metadata that is present on all of the replicas for a range. These keys are updated via Raft operations. Examples include the range lease state and abort cache entries.
  • Unreplicated Range ID local keys store range metadata that is local to a replica. The primary examples of such keys are the Raft state and Raft log.

CockroachDB的数据映射关系

以下表为例

create table mydb.customers(name varchar(100) primary key, address varchar(100) , URL varchar(100));
insert into mydb.customers values('Apple','1 Infinite Loop, Cupertino, CA','http://apple.com/');

表结构信息

KeyValues
/system/databases/mydb/id51
/system/tables/customer/id42
/system/desc/51/42/address69
/system/desc/51/42/url66

表中的数据

KeyValues
/51/42/Apple/691 Infinite Loop, Cupertino, CA
/51/42/Apple/66http://apple.com/

最后

本文简要介绍了各存储引擎的结构,供大家参考,有错误之处请指正.

参考文档

MSSQL · 架构分析 · 从SQL Server 2017发布看SQL Server架构的演变

$
0
0

摘要

美国时间2017年10月2日,微软正式发布了最新一代可以运行在Linux平台的数据库SQL Server 2017。SQL Server 2017给用户带来了一系列的新功能特性的同时,也体现了微软关于自家关系型数据库平台建设方面的最新设计与思考。这篇文章旨在介绍SQL Server 2017新特性,以及微软是如何从架构层面的演进来快速实现Linux平台的SQL Server 2017产品。

SQL Server 2017发布

早在2016年,当微软宣布SQL Server将很快在Linux上运行时,这一消息对用户、权威人士以及SQL Server从业者来说都是一个巨大的惊喜。果然,微软不负众望,在美国时间2017年10月2日,正式发布了最新一代可以运行在Linux平台的数据库SQL Server 2017。近年来随着各类NoSQL数据库产品和Hadoop生态的出现与流行,给了传统关系型数据库(RDBMS)带来了巨大的挑战。从微软提供Linux版SQL Server这件事情,我们可以诡探出微软大的战略转型:变得更加开放、包容和拥抱变化,而不是像以前一样与自家的微软系列生态系统紧密的捆绑在一起。微软的这种良性转变,对用户和SQL Server数据库从业者来说也是巨大的福音。因此,可以说SQL Server与Linux相爱了,SQL Server 2017就是他们爱的结晶。
01.png

SQL Server 2017新特性简介

微软对于新一代数据库产品SQL Server 2017的发布,植入了非常多的新特性和看点,重要包括:

对Linux平台的支持:当然最大的特性是对Linux平台的支持。

对容器类产品Docker的支持:对容器类产品的支持。

内置图数据库:将图数据库功能内置到SQL Server引擎中。

内置机器学习功能:对Python语言的支持,大大扩展了机器学习功能特性。

自适应查询处理:全新的Batch Model查询语句执行方式,边执行便优化。

支持Linux平台

SQL Server 2017对Linux平台的支持,是它最大的看点和进步,说明微软拥抱变化的决心初现成果。SQL Server 2017支持的Linux平台包括:

RedHat Enterprise Linux (RHEL)

SUSE Enterprise Linux (SLES)

Ubuntu

02.png
[1]:SQL Server 2017对Linux平台的支持

支持容器化

SQL Server 2017除了支持这些常见的开源Linux平台外,还支持将SQL Server服务跑在容器中,这一点对于需要将SQL Server服务进行容器化管理的用户来讲,非常便利。SQL Server支持的容器产品包括:

Windows Container:微软自家亲儿子,是不遗余力的毫无疑问支持。

Linux Docker:目前最为火爆的容器技术,当然也是支持的。

03.png

内置图数据库

SQL Server 2017中内置了关于图数据的查询功能,使得图数据的查询变得简单而高效。
04.png
2:SQL Server 2017内置图数据库的功能

内置机器学习

SQL Server 2017中提供了R语言和Python语言的支持,用户可以利用列存技术和内存优化表存储基础数据,然后利用Python语言本身关于机器学习的天然优势,来实现深置于数据库系统内部的机器学习,实时分析,及时决策的目的。
05.png
[1]:SQL Server 2017提供机器学习功能

自适应查询处理

针对于SQL Server Batch Model Processing查询语句,SQL Server 2017引入了自适应查询处理机制,使得查询更加高效。简单的讲就是一边处理查询一边进行优化的策略,而不是传统的根据统计信息首先生成执行计划。这样可以应对很多因为统计信息过时或者统计信息片面导致的执行计划不准确的,而影响查询性能的场景。
06.png

SQL Server架构演进

按照以往微软对SQL Server数据库产品的发布节奏,一般情况下是两年一个大版本更新迭代,比如SQL Server 2012,2014和2016。但是,SQL Server 2017的发布仅仅只用了一年时间,而且实现了Linux版本SQL Server的巨大转变,并且所有功能和Windows版本对齐。很多用户和从业者对这一点都非常好奇,微软到底是如何做到这一点的?要回答这个问题,我们从SQL Server底层的架构演进来分析这个问题的答案。总结来看,到SQL Server 2017的出现,微软对数据库底层架构的演进经历了以下几个阶段:

使用Windows对SQL Server系统进行资源管理:这个阶段没有一个特定的名称叫法,这个阶段的SQL Server服务无法突破Windows内核对资源的限制。

SQL OS阶段:为了使得SQL Server数据库拥有更好的性能,SQL OS(也叫SOS)出现了。

Drawbridge的出现:研究性项目,用于实现应用的沙盒(Sandbox),最开始不是为SQL Server专门设计的,但在SQL Server 2017中扮演中非常重要的角色。

SQL PAL的出现:SQL Server 2017整合了SQL OS和Drawbridge,进行底层封装,形成了SQL PAL层。

SQL Server 2005之前

追述到SQL Server 2005版本之前(SQL Server 2000及更早版本),SQL Server服务是以一个用户态进程运行在Windows操作系统中,这个服务进程与其他普通的进程没有任何差异,它依赖于操作系统内核对底层硬件资源进行管理和交互,SQL Server服务本身没有对系统资源的管理能力。具体的架构图大致如下所示:
07.png
3:SQL Server 2005之前的底层架构图

这个架构最大的缺点是SQL Server服务本身无法突破Windows内核对系统资源使用的限制,换话句话说SQL Server无法榨干系统硬件资源,加之缺乏对操作系统资源的控制能力,只能依赖于操作系统内核对底层硬件资源进行调度,因此SQL Server服务很难最大限度充分利用系统所有硬件资源,阻碍了SQL Server系统性能的进一步提升。

SQL OS

为了解决上面的问题和获取更好的性能,微软花了很大的力气来抽象一个中间层对系统硬件资源进行调度和管理,并发布在SQL Server 2005版本中,也因此SQL Server 2005版本经历了长达5年的时间才得以面世。这个对硬件资源进行集中调度和管理的中间层叫着SQL OS(也叫着SOS)。SQL OS的主要职责包括:Processor Scheduling,Memory Management,Network,Disk I/O使得SQL Server性能最大化(可以使得SQL Server用户进程最大限度的充分利用操作系统硬件资源)。SQL Server 2005引入了SQL OS后的底层架构图如下所示:
08.png

SQL Server 2005赋予了SQL OS非常全面的资源管理功能,涉及到数据库系统核心功能的方方面面,具体包括:

Deadlock Monitor:死锁监控

Resource Monitor:资源监控

Lazy Writer:延迟写,将随机I/O写,转化为顺序I/O写

Scheduler Monitor:调度器监控

Buffer Pool:缓存池

Memory Manager:内存管理

Scheduling:调度

Synchronization Service:同步服务

Lock Manager:锁管理器

I/O:I/O资源管理

详细架构如下图SQLOS API部分所示:
09.png

有了SQL OS层次的抽象,得以在数据库内部实现对系统资源的集中管理,摆脱系统内核对SQL Server资源使用的限制,使得SQL Server服务队系统资源有了很强的控制能力,SQL Server 2005性能有了大幅的提升,成了微软关系型数据库历史上划时代的版本,也为SQL Server 2017能够提供跨平台能力提供了可能性,可以毫不夸张的说,没有SQL OS的出现,微软不可能在如此短的时间内实现Linux版的SQL Server。

Drawbridge

微软研究院在2011年9月建立了一个全新的研究性项目,名称叫Drawbridge,目的是提供应用程序新的虚拟化资源隔离解决方案,减少虚拟资源的使用,使得在同一个硬件主机上,可以运行更多的虚拟机(类似于Docker产品对硬件资源的管理)。Drawbridge其中一个非常重要的组件Library OS仅依靠约50个底层内核应用二进制接口(ABI:Application Binary Interface)实现了一千多个常用的Windows API,同时还具备了为其他组件提供宿主的能力,比如:MSXML和CLR等组件。 在Windows 10版本中存在着Drawbridge的大量应用。
10.png
3:引入Drawbridge后的系统架构

SQL PAL

SQL Server数据库团队基于Drawbridge项目与SQL OS两者进行了必要的重写和充分的融合,形成了新一代数据库底层抽象和封装,叫SQL PAL (Platform Abstract Layer),同时将上层逻辑代码移植到SQL PAL之上。如此,微软只需要确保SQL PAL层可以在Windows平台和Linux平台运行良好即可。这样SQL Server即使运行在Linux平台,也无需修改SQL Server本身的代码,SQL Server自己本身与平台无关。能做到这一点完全是由Drawbridge中的ABI(Application Binary Interface)来达到目的的,这些ABI我们叫着Host Extension,所以为了支持SQL Server 2017的跨平台特性,微软只需要实现基于Windows平台的Host Extension和Linux平台的Host Extension,这样做最大的好处是:

大大缩短开发周期:微软无需对SQL Server本身做任何的代码修改就可以将SQL Server移植到Linux平台。

产品功能一贯性:对SQL Server新功能、新特性的支持,无需对两个平台进行重复开发,Windows平台支持了,Linux平台也就支持了,保持了产品功能的一致性。

良好的后期维护性:假如SQL Server存在某个Bug,只需要修复SQL Server本身,那么Windows平台,Linux平台上相应的Bug也同样被修复掉了,具备良好的可维护性。
11.png

以上架构图是比较宏观的层面展示,以下是SQL PAL功能更为详细的描述架构图:
12.png
[1]:SQL PAL详细架构图

从这张图,我们可以清晰的看到SQL PAL层次对于Host Extension的调用,以及构建在这层次之上的SQL Server服务,包括:数据库引擎、集成服务、分析服务和报表服务。

SQL PAL性能影响

提到SQL PAL对SQL Server 2017数据库服务的影响,很多用户最为担忧的应该就是性能的影响了。请不要担心,根据TPC-H测试来看,SQL Server 2017相对于SQL Server 2016来看,性能不但没有任何损失,反而性能不降反升。

TPC-H性能测试

从TPC-H测试数据总结来看,相对于SQL Server 2016来看,不论是性能和性价比,都有小幅提升,如下截图:
13.png
[1]: SQL Server 2017 TPC-H性能比较

附带TPC官网公布的性能数据截图:
14.png

微软内部测试

以下展示微软内部测试实例,在拥有一台12 TB内存,480个逻辑CPUs的机器上,处理30 TB,2500亿条数据的8个字段的3种类型复杂统计汇总查询,耗时仅用18秒。由此可见,性能还是相当强劲的,截图留恋:
15.png

总结

本篇文章介绍了SQL Server 2017支持Linux平台,支持容器化,内置图数据库,内置机器学习和自适应查询处理的功能新特性;同时从底层架构演进的层面分析了微软能够在短时间内实现Linux版SQL Server 2017的根本原因是SQL PAL架构中间层的出现,而SQL PAL是站在SQL OS和Drawbridge的肩膀之上的。由此可见,微软对SQL Server支持Linux平台在SQL Server 2005版本中已经开始布局,应该说还是非常具有远见的。

备注

1:图片来自于微软Lindsey Allen的培训“SQL Server 2017 - Power your entire data estate from on-premises to cloud”截图。

2:图片来自于吴晓晨在云栖大会上关于“SQL Server 2017”的分享。

3:图片来自于“Everything you need to know about SQL Server 2017”截图。

4:截图来自于《Inside Microsoft SQL Server 2005_ The Storage Engine, 2005 Edition》Components of the SQL Server Engine章节。


MySQL · 引擎介绍 · Sphinx源码剖析(三)

$
0
0

在本节中我会介绍Sphinx在构建索引之前做的一些事情,主要是从mysql拉取数据保存,然后分词排序保存到内存等等一系列的操作。下面是几个相关指令

    sql_query = \
        SELECT id, group_id, UNIX_TIMESTAMP(date_added) AS date_added, \
            title, content \
        FROM documents
    sql_query_range = SELECT MIN(id),MAX(id) FROM documents
    sql_range_step = 1000

其中sql_query是sphinx每次从mysql拉取数据的sql,而sql_query_range则是取得需要从mysql拉取的数据条目,而sql_rang_step则是表示每次从mysql拉取多少数据。sql_rang_range执行分两种情况,第一种是第一次拉取数据的时候,第二种是当当前的range数据读取完毕之后。

首先来看CSphSource_SQL::NextDocument函数,这个函数的主要作用是从mysql读取数据然后切分保存,首先我们来看读取数据这一部分,这里步骤很简单,就是执行对应的sql,然后判断当前range的数据是否读取完毕,如果读取完毕则继续执行sql_query_rang(RunQueryStep)。这里要注意的是,sphinx读取数据是一条一条的读取然后执行的.

	do
	{
		// try to get next row
		bool bGotRow = SqlFetchRow ();

		// when the party's over...
		while ( !bGotRow )
		{
			// is that an error?
			if ( SqlIsError() )
			{
				sError.SetSprintf ( "sql_fetch_row: %s", SqlError() );
				m_tDocInfo.m_uDocID = 1; // 0 means legal eof
				return NULL;
			}

			// maybe we can do next step yet?
			if ( !RunQueryStep ( m_tParams.m_sQuery.cstr(), sError ) )
			{
				// if there's a message, there's an error
				// otherwise, we're just over
				if ( !sError.IsEmpty() )
				{
					m_tDocInfo.m_uDocID = 1; // 0 means legal eof
					return NULL;
				}

			} else
			{
				// step went fine; try to fetch
				bGotRow = SqlFetchRow ();
				continue;
			}

			SqlDismissResult ();

			// ok, we're over
			ARRAY_FOREACH ( i, m_tParams.m_dQueryPost )
			{
				if ( !SqlQuery ( m_tParams.m_dQueryPost[i].cstr() ) )
				{
					sphWarn ( "sql_query_post[%d]: error=%s, query=%s",
						i, SqlError(), m_tParams.m_dQueryPost[i].cstr() );
					break;
				}
				SqlDismissResult ();
			}

			m_tDocInfo.m_uDocID = 0; // 0 means legal eof
			return NULL;
		}

		// get him!
		m_tDocInfo.m_uDocID = VerifyID ( sphToDocid ( SqlColumn(0) ) );
		m_uMaxFetchedID = Max ( m_uMaxFetchedID, m_tDocInfo.m_uDocID );
	} while ( !m_tDocInfo.m_uDocID );

上面的代码我们可以看到一个很关键的字段m_uDocID,这个字段表示当前doc的id(因此数据库的表设计必须有这个id字段).

读取完毕数据之后,开始处理读取的数据,这里会按照字段来切分,主要是将对应的数据库字段保存到索引fielld

	// split columns into fields and attrs
	for ( int i=0; i<m_iPlainFieldsLength; i++ )
	{
		// get that field
		#if USE_ZLIB
		if ( m_dUnpack[i]!=SPH_UNPACK_NONE )
		{
			DWORD uUnpackedLen = 0;
			m_dFields[i] = (BYTE*) SqlUnpackColumn ( i, uUnpackedLen, m_dUnpack[i] );
			m_dFieldLengths[i] = (int)uUnpackedLen;
			continue;
		}
		#endif
		m_dFields[i] = (BYTE*) SqlColumn ( m_tSchema.m_dFields[i].m_iIndex );
		m_dFieldLengths[i] = SqlColumnLength ( m_tSchema.m_dFields[i].m_iIndex );
	}

紧接着就是处理attribute,后续我们会详细介绍attribute,现在我们只需要知道它是一个类似二级索引的东西(不进入全文索引).

		switch ( tAttr.m_eAttrType )
		{
			case SPH_ATTR_STRING:
			case SPH_ATTR_JSON:
				// memorize string, fixup NULLs
				m_dStrAttrs[i] = SqlColumn ( tAttr.m_iIndex );
				if ( !m_dStrAttrs[i].cstr() )
					m_dStrAttrs[i] = "";

				m_tDocInfo.SetAttr ( tAttr.m_tLocator, 0 );
				break;
..................................
			default:
				// just store as uint by default
				m_tDocInfo.SetAttr ( tAttr.m_tLocator, sphToDword ( SqlColumn ( tAttr.m_iIndex ) ) ); // FIXME? report conversion errors maybe?
				break;
		}

然后我们来看Sphinx如何处理得到的数据,核心代码在 RtIndex_t::AddDocument中,这个函数主要是用来分词(IterateHits中)然后保存数据到对应的数据结构,而核心的数据结构是RtAccum_t,也就是最终sphinx在写索引到文件之前,会将数据保存到这个数据结构,这里要注意一般来说sphinx会保存很多数据,然后最后一次性提交给索引引擎来处理.而索引引擎中处理的就是这个数据结构.因此最终会调用RtAccum_t::AddDocument.

这里需要注意两个地方,第一个是m_dAccum这个域,这个域是一个vector,而这个vector里面保存了CSphWordHit这个结构,我们来看这个结构的定义

    struct CSphWordHit
    {
        SphDocID_t		m_uDocID;		///< document ID
        SphWordID_t		m_uWordID;		///< word ID in current dictionary
        Hitpos_t		m_uWordPos;		///< word position in current document
    };

可以看到其实这个结构也就是保存了对应分词的信息.

然后我们来看核心代码,这里主要是便利刚才从mysql得到的数据,去重然后保存数据.

	int iHits = 0;
	if ( pHits && pHits->Length() )
	{
		CSphWordHit tLastHit;
		tLastHit.m_uDocID = 0;
		tLastHit.m_uWordID = 0;
		tLastHit.m_uWordPos = 0;

		iHits = pHits->Length();
		m_dAccum.Reserve ( m_dAccum.GetLength()+iHits );
		for ( const CSphWordHit * pHit = pHits->First(); pHit<=pHits->Last(); pHit++ )
		{
			// ignore duplicate hits
			if ( pHit->m_uDocID==tLastHit.m_uDocID && pHit->m_uWordID==tLastHit.m_uWordID && pHit->m_uWordPos==tLastHit.m_uWordPos )
				continue;

			// update field lengths
			if ( pFieldLens && HITMAN::GetField ( pHit->m_uWordPos )!=HITMAN::GetField ( tLastHit.m_uWordPos ) )
				pFieldLens [ HITMAN::GetField ( tLastHit.m_uWordPos ) ] = HITMAN::GetPos ( tLastHit.m_uWordPos );

			// accumulate
			m_dAccum.Add ( *pHit );
			tLastHit = *pHit;
		}
		if ( pFieldLens )
			pFieldLens [ HITMAN::GetField ( tLastHit.m_uWordPos ) ] = HITMAN::GetPos ( tLastHit.m_uWordPos );
	}

做完上面这些事情之后,就需要提交数据给索引处理引擎了,这里核心的代码都是在RtIndex_t::Commit中.

这个函数主要做两个事情,第一个提取出前面我们构造好的RtAccum_t,然后对于所有的doc进行排序,创建segment,也就是对应的索引块(ram chunk),最后调用CommitReplayable来提交ram chunk到磁盘.

其实可以这么理解,保存在内存中的索引也就是segment,然后当内存的大小到达限制后就会刷新内存中的索引到磁盘.

    void RtIndex_t::Commit ( int * pDeleted, ISphRtAccum * pAccExt )
    {
        assert ( g_bRTChangesAllowed );
        MEMORY ( MEM_INDEX_RT );

        RtAccum_t * pAcc = AcquireAccum ( NULL, pAccExt, true );
        if ( !pAcc )
            return;

    ...................................
        pAcc->Sort();

        RtSegment_t * pNewSeg = pAcc->CreateSegment ( m_tSchema.GetRowSize(), m_iWordsCheckpoint );
    .............................................

        // now on to the stuff that needs locking and recovery
        CommitReplayable ( pNewSeg, pAcc->m_dAccumKlist, pDeleted );
    ......................................
    }

然后我们来看RtAccum_t::CreateSegment函数,这个函数用来将分词好的数据保存到ram chunk,这里需要注意两个数据结构分别是RtDoc_t和RtWord_t,这两个数据结构分别表示doc信息和分词信息.

结构很简单,后面的注释都很详细

    template < typename DOCID = SphDocID_t >
    struct RtDoc_T
    {
        DOCID						m_uDocID;	///< my document id
        DWORD						m_uDocFields;	///< fields mask
        DWORD						m_uHits;	///< hit count
        DWORD						m_uHit;		///< either index into segment hits, or the only hit itself (if hit count is 1)
    };

    template < typename WORDID=SphWordID_t >
    struct RtWord_T
    {
        union
        {
            WORDID					m_uWordID;	///< my keyword id
            const BYTE *			m_sWord;
        };
        DWORD						m_uDocs;	///< document count (for stats and/or BM25)
        DWORD						m_uHits;	///< hit count (for stats and/or BM25)
        DWORD						m_uDoc;		///< index into segment docs
    };

然后来看代码,首先是初始化对应的写结构,可以看到都是会写到我们创建好的segment中.

	RtDocWriter_t tOutDoc ( pSeg );
	RtWordWriter_t tOutWord ( pSeg, m_bKeywordDict, iWordsCheckpoint );
	RtHitWriter_t tOutHit ( pSeg );

然后就是写数据了,这里主要是做一个聚合,也就是将相同的keyword对应的属性聚合起来.

	ARRAY_FOREACH ( i, m_dAccum )
	{
        .......................................
		// new keyword; flush current keyword
		if ( tHit.m_uWordID!=tWord.m_uWordID )
		{
			tOutDoc.ZipRestart ();
			if ( tWord.m_uWordID )
			{
				if ( m_bKeywordDict )
				{
					const BYTE * pPackedWord = pPacketBase + tWord.m_uWordID;
					assert ( pPackedWord[0] && pPackedWord[0]+1<m_pDictRt->GetPackedLen() );
					tWord.m_sWord = pPackedWord;
				}
				tOutWord.ZipWord ( tWord );
			}

			tWord.m_uWordID = tHit.m_uWordID;
			tWord.m_uDocs = 0;
			tWord.m_uHits = 0;
			tWord.m_uDoc = tOutDoc.ZipDocPtr();
			uPrevHit = EMPTY_HIT;
		}
        ..................
    }

这次就分析到这里,下次我们将会分析最核心的部分就是Sphinx如何刷新数据到磁盘.

PgSQL · 内核开发 · 如何管理你的 PostgreSQL 插件

$
0
0

一.背景

我们都知道 PostgreSQL 提供了丰富数据库内核编程的接口,允许开发者以插件的形式把功能融入数据库内核。

PostgreSQL 提供了一个插件管理模块,用于管理用户创建的插件。

本文给大家介绍 PostgreSQL 插件管理模块,帮助大家管理自己的插件。

二.PostgreSQL的插件内容

通常一个 PostgreSQL 内核插件包括下面的部分

  • 1. 包含功能的逻辑的动态库,即 so 文件。
  • 2. 描述插件信息的的控制文件,即 control 文件。
  • 3. 一组文件用于创建、更新和删除插件,这是一组按照版本命名的 SQL 文本文件。

如果缺少了上述部分,或版本号不正确,插件的管理功能会异常。

三.插件的管理

我们使用 create extension, drop extension alter extension 管理指定的插件。

1.插件的创建

例如 postgres_fdw 的创建

create extension postgres_fdw;
drop extension postgres_fdw;

我们可以选择把插件创建到指定的模式中。

2.插件的管理视图

这是最简单的部分,创建插件后,我们可以通过插件管理视图看到一些细节信息

select * from pg_extension ;
   extname    | extowner | extnamespace | extrelocatable | extversion | extconfig | extcondition 
--------------+----------+--------------+----------------+------------+-----------+-------------- 
 postgres_fdw |       10 |         2200 | t              | 1.0        |           | 

可以看到,postgres_fdw 的 owner, 存在的 schema 和插件的小版本。

3.插件的删除

插件的内容可以是任何的数据库对象,例如:函数、操作符等等。

这些对象可能被其他的对象引用,例如我们在 postgres_fdw 创建了基于 postgres_fdw 的外部表。

当我们要删除 postgres_fdw 时,需要加上 cascade 子句,把相关对象一并删除。

drop extension postgres_fdw cascade;

这么做带来的问题是,所有依赖这个插件的对象都会被删除。再次使用需要重建。

4.插件的更新

有时候,我们需要做插件的 BUGFIX ,或定制一些功能。这就用到了插件更新功能。

  • 首先,我们需要升级插件的小版本

修改控制文件 .control, 增加一个小版本,如果当前版本是 1.1,则文件中版本号修改成 1.2

  • 添加新版本的的 DDL SQL 文件

添加新版本的 DDL SQL 文件 *–1.2.sql, 用于从零创建该插件。

该 SQL 文件应该包括该插件的所有对象的 DDL。

  • 添加用户老版本升级到新版本的 DDL SQL 文件

创建 *1.1–1.2.sql,用于从版本 1.1 升级到 1.2

该 SQL 文件只包含 1.2 版本中新创建的对象。用户的升级操作会调用该 SQL 文件,从而避免了完全重新创建。

  • 修改源码添加新的功能,编译并安装到指定目录。

  • 使用 SQL 升级小版本

alter extension postgres_fdw update;

如果成功更新,我们能从视图中看到对应的小版本号被更新了。

postgres=# select * from pg_extension ;
   extname    | extowner | extnamespace | extrelocatable | extversion | extconfig | extcondition 
--------------+----------+--------------+----------------+------------+-----------+--------------
postgres_fdw |       10 |         2200 | t              | 1.2        |           | 
(2 rows)

使用 PostgreSQL 的插件管理功能,用户很容开发和维护需要的插件。

其他

有几点需要特别提醒,这是在开发和管理插件时,经常碰到的问题,需要多加注意

  • 插件是通过动态库形式引入到内核中。和内核在同一个进程中运行,且没有内存保护,影响内核的稳定性。开发中需要特别注意内存的使用。不要造成内存泄露或越界写。建议使用 PostgreSQL 的内存管理机制,插件中也能使用。
  • 内核中被标记成 PGDLLIMPORT 的全局变量都能在插件中直接使用,这些通常是一些 GUC 参数。
  • 内核中非 static 的函数也能在插件中使用,只需要先 extern 它们。
  • 我们可以实现 _PG_init 用于实现一些初始化工作,该函数在连接建立后只会被执行一次。
  • 我们可以在 _PG_init 中使用函数 DefineCustom*Variable 定义对应插件相关的 GUC 参数,他们可以用于开启和关闭该插件的一些功能。
  • 插件的参数需要以插件名开头且加上点,例如 oss_fdw.enable_parallel_read。

参考

  1. PostgreSQL 插件的创建
  2. PostgreSQL 插件的删除
  3. PostgreSQL 插件的修改/升级

MySQL · 特性分析 · 数据一样checksum不一样

$
0
0

背景

有一个特殊环境需要进行人肉迁移数据,对比了表里的数据一模一样,但是无论如何checksum就是不一致,那么问题出在哪里呢?

问题排查

数据是否一致

眼睛都把屏幕盯穿了,也没发现不一致的数据。

导出数据的方式

image
checksum还是不一致,所以这个原因排除。

MySQL版本

嗯,这个确实不一样,源端是5.5,目的端是5.6,但是这个是checksum函数不一样吗?还是表的结构变了?咨询了内核的同学,说checksum的源代码没变啊,接下来那应该是表结构变了?通过查手册发现:
The checksum value depends on the table row format. If the row format changes, the checksum also changes. For example, the storage format for temporal types such as TIME, DATETIME, and TIMESTAMP changes in MySQL 5.6 prior to MySQL 5.6.5, so if a 5.5 table is upgraded to MySQL 5.6, the checksum value may change.
既然这样了,那我们就来验证下是不是因为datetime的问题。
image
嗯,确实是datetime的格式导致的。

总结

这个问题总结来说是因为MySQL5.5和5.6的时间存储格式有变化,导致了checksum不一样。

这个问题知道原因后觉得非常简单,但是排查起来却不是那么简单的。遇到问题不要慌,理出要查的1,2,3,然后用排除法一步一步验证就能知道问题在哪里了。

附一详细步骤如下:

源端:

1.备份结构和数据

mysqldump -h127.0.0.1 -P源端口 -u root --default-character-set=utf8 -B drcdb>src_lingluo.sql

2.拷贝文件

scp src_lingluo.sql lingluo.sss@目的端:/tmp

目的端:

3.把数据导进去

mysql>set names utf8;source /tmp/src_lingluo.sql;

4.在两边取checksum

sh get_checksum.sh

5.目的端:

scp table_checksum.txt lingluo.sss@目的端:/tmp

6.vimdiff 对比文件

如果文件内容一样的话,说明数据一样

附二get_checksum.sh

#!/bin/sh 
port=$1

table_list='`mysql -h127.0.0.1 -P${port} -u root -A -N << EOF | tail -n +3
    use db_name;show tables
EOF`'

for i in ${table_list}
do
    table_cs='`mysql -h127.0.0.1 -P${port} -u root -A -N -D db_name -e \"checksum 
table ${i};\"`'
    echo ${table_cs} >> table_checksum.txt
done

PgSQL · 应用案例 · 经营、销售分析系统DB设计之共享充电宝

$
0
0

背景

共享充电宝、共享单车、共享雨伞,共享女朋友^|^,共享汽车,。。。 共享经济最近几年发展确实非常迅猛。

共享必定涉及被共享对象的管理、会员的管理等,实际上也属于一种物联网系统。

本文以共享充电宝的场景为例,分享一下共享充电宝的经营分析、销售管理系统的后台数据库的设计。(老板关心的是整体销售的业绩,以及各个渠道的透视等。销售经理关心的是他管辖片区的销售业绩,运维人员关心的是设备的状态。)

一、数据结构和数据量

业务模式是什么样的?

在饭店、商场、火车站、足浴店等各种场所,都能看到充电宝的身影。每个充电宝会有相对固定的位置(比如放在外婆家餐馆),每个固定的位置都有相对固定的销售(就好像古惑仔受保护费一样),每个销售都有固定的上级。

用户借充电宝操作很简答,用户扫码,下单,借走;有些是不能借走的,那就扫码,下单,充电。

(这里除了充电业务,实际上还可以与商户合作,搞一些用户画像和广告推送、商家促销的业务。当然,前提是有用户画像。)

数据结构抽象

pic

1、人员表(BOSS,销售总监,门店经理)。

数据量预估:3000+,极少更新。

2、类目表(足浴店、酒店、火车站、饭店。。。)

数据量预估:100+ , 极少更新

3、门店表

数据量预估:百万级以内 , 极少更新

4、设备表

数据量预估:百万级 , 每个设备 每隔N分钟上报一次心跳

5、订单表

数据量预估:百万级/天 ,插入、并且每个订单至少更新一次(创建订单、支付订单、退单等),订单有最终状态。

二、分析需求

1、实时分析需求:

以日、月、年时间维度;再加上以全局、员工、员工一级下属、员工所有下属、类目、门店、设备等维度进行透视。

2、聚合指标:

新增设备数、在线设备数、离线设备数、新建订单量、成交订单量、退订量、账务流水等等。

3、时间需求:

有查询当天订单统计需求、有查询当天、前一天统一时间点统计需求,算同比。同样的也有月、年需求。

4、查询并发:

分析系统的查询并发通常不会太高,因为都是自己人使用的。一分钟可能不会超过3000。

5、查询时效性:

月、年统计 每天离线生成。(建议这么做,因为业务上月指标没必要实时看。)

日维度的统计,实时产生。(日数据量并不大,实时产生,实时查询,可以满足并发、响应时间的需求。同时也满足业务的需求。)

响应时间要求:几十毫秒级。

并发要求:100以内。

三、数据库选型

PostgreSQL 10:HTAP数据库,支持10TB级OLTP和OLAP混合需求。TP性能强劲,功能丰富。支持多核并行计算,HASH JOIN等一系列强大的功能,AP性能亦适中。

HybridDB for PostgreSQL:PB级,纯分析型数据库,支持多机并行计算。AP性能强劲,但是TP性能非常弱。

如果想了解更多的详情,请参考:

《空间|时间|对象 圈人 + 透视 - 暨PostgreSQL 10与Greenplum的对比和选择》

本场景到底选哪个呢?干脆两个都来做个DEMO设计,对比一下。

四、PostgreSQL 10 方案1

设计表结构

create table a (          -- 员工层级信息    
  id int primary key,     -- 编号 ID    
  nick name,              -- 名字    
  pid int                 -- 上级 ID    
);    
    
create table c (          -- 类目    
  id int primary key,     -- 类目ID    
  comment text            -- 类目名称    
);    
    
create table b (          -- 终端门店    
  id int primary key,     -- 编号    
  nick text,              -- 名称    
  cid int,                -- 类目    
  aid int                 -- 门店经理ID    
);    
    
create table d (          -- 设备    
  id int primary key,     -- 设备编号    
  bid int,                -- 门店编号    
  alive_ts timestamp      -- 设备心跳时间    
);    
    
create table log (        -- 订单日志    
  did int,                -- 设备ID    
  state int2,             -- 订单最终状态    
  crt_time timestamp,     -- 订单创建时间    
  mod_time timestamp      -- 订单修改时间    
) partition by range (crt_time);    
    
create table log_201701 partition of log for values from ('2017-01-01') to ('2017-02-01') with (parallel_workers =32);     
create table log_201702 partition of log for values from ('2017-02-01') to ('2017-03-01') with (parallel_workers =32);      
create table log_201703 partition of log for values from ('2017-03-01') to ('2017-04-01') with (parallel_workers =32);      
create table log_201704 partition of log for values from ('2017-04-01') to ('2017-05-01') with (parallel_workers =32);      
create table log_201705 partition of log for values from ('2017-05-01') to ('2017-06-01') with (parallel_workers =32);      
create table log_201706 partition of log for values from ('2017-06-01') to ('2017-07-01') with (parallel_workers =32);      
create table log_201707 partition of log for values from ('2017-07-01') to ('2017-08-01') with (parallel_workers =32);      
create table log_201708 partition of log for values from ('2017-08-01') to ('2017-09-01') with (parallel_workers =32);      
create table log_201709 partition of log for values from ('2017-09-01') to ('2017-10-01') with (parallel_workers =32);      
create table log_201710 partition of log for values from ('2017-10-01') to ('2017-11-01') with (parallel_workers =32);      
create table log_201711 partition of log for values from ('2017-11-01') to ('2017-12-01') with (parallel_workers =32);      
create table log_201712 partition of log for values from ('2017-12-01') to ('2018-01-01') with (parallel_workers =32);      
create table log_201801 partition of log for values from ('2018-01-01') to ('2018-02-01') with (parallel_workers =32);    
    
create index idx_log_201701_1 on log_201701 using btree (crt_time) ;    
create index idx_log_201702_1 on log_201702 using btree (crt_time) ;    
create index idx_log_201703_1 on log_201703 using btree (crt_time) ;    
create index idx_log_201704_1 on log_201704 using btree (crt_time) ;    
create index idx_log_201705_1 on log_201705 using btree (crt_time) ;    
create index idx_log_201706_1 on log_201706 using btree (crt_time) ;    
create index idx_log_201707_1 on log_201707 using btree (crt_time) ;    
create index idx_log_201708_1 on log_201708 using btree (crt_time) ;    
create index idx_log_201709_1 on log_201709 using btree (crt_time) ;    
create index idx_log_201710_1 on log_201710 using btree (crt_time) ;    
create index idx_log_201711_1 on log_201711 using btree (crt_time) ;    
create index idx_log_201712_1 on log_201712 using btree (crt_time) ;    
create index idx_log_201801_1 on log_201801 using btree (crt_time) ;    

初始化数据

1、初始化员工层级 (0为老板,1-30为销售总监,31-3000为门店经理。)

do language plpgsql $$    
declare     
begin    
  truncate a;    
  insert into a select generate_series(0,3000);    
  update a set pid=0 where id between 1 and 30;    
  for i in 1..30 loop    
    update a set pid=i where id between 31+100*(i-1) and 31+100*i-1;    
  end loop;    
end;    
$$;    

2、初始化类目

insert into c select generate_series(1,100);    

3、初始化门店

insert into b select generate_series(1,500000), '', ceil(random()*100), 30+ceil(random()*(3000-30));    

4、初始化设备

insert into d select generate_series(1,1000000), ceil(random()*500000);    

5、生成1年订单,约3.65亿,实际写入3.78亿(每天100万比订单,90%支付,10%退款)

do language plpgsql $$    
declare    
  s date := '2017-01-01';    
  e date := '2017-12-31';    
begin    
  for x in 0..(e-s) loop    
    insert into log     
      select ceil(random()*1000000), case when random()<0.1 then 0 else 1 end, s + x + (i||' second')::interval     
      from generate_series(0,86399) t(i),     
           generate_series(1,12);      -- 12是100万一天除以86400得到的,主要是方便写入测试数据。      
  end loop;    
end;    
$$;    
  
  
  
postgres=# select count(*) from log;  
   count     
-----------  
 378432001  
(1 row)  

6、索引(可选操作,优化项)

(建议实时数据使用btree索引,静态数据使用BRIN块级索引,静态数据删除BTREE索引。)。

例子

当订单数据成为静态历史数据时,删除静态表旧btree索引,增加如下brin索引。

create index idx_log_201701_1 on log_201701 using brin (crt_time) ;    
create index idx_log_201702_1 on log_201702 using brin (crt_time) ;    
create index idx_log_201703_1 on log_201703 using brin (crt_time) ;    
create index idx_log_201704_1 on log_201704 using brin (crt_time) ;    
create index idx_log_201705_1 on log_201705 using brin (crt_time) ;    
create index idx_log_201706_1 on log_201706 using brin (crt_time) ;    
create index idx_log_201707_1 on log_201707 using brin (crt_time) ;    
create index idx_log_201708_1 on log_201708 using brin (crt_time) ;    
create index idx_log_201709_1 on log_201709 using brin (crt_time) ;    
create index idx_log_201710_1 on log_201710 using brin (crt_time) ;    
create index idx_log_201711_1 on log_201711 using brin (crt_time) ;    
create index idx_log_201712_1 on log_201712 using brin (crt_time) ;    
create index idx_log_201801_1 on log_201801 using brin (crt_time) ;    

创建必要的UDF函数

1、创建immutable函数,获取当前时间,前天,前年时间。(使用immutable函数,优化器将过滤不必查询的分区。),如果要支持并行,设置为parallel safe.

create or replace function cdate() returns date as $$    
  select current_date;    
$$ language sql strict immutable PARALLEL safe;    
    
    
create or replace function cts(interval default '0') returns timestamp as $$        
  select (now() - $1)::timestamp;    
$$ language sql strict immutable PARALLEL safe;    

透视SQL设计

按人,查询下级所有层级,关联门店,关联设备,关联订单。

输出统计信息:

1、聚合项:

今日截止总订单,今日截止支付订单,同比昨日截止总订单,同比昨日截止支付订单

当月截止总订单,当月截止支付订单,同比上月截止总订单,同比上月截止支付订单

当年截止总订单,当年截止支付订单,同比上年截止总订单,同比上年截止支付订单

2、聚合维度:

全量,TOP

类目,TOP

门店,TOP

所有下属,TOP

所有下属,类目,TOP

所有下属,门店,TOP

门店经理,TOP

门店经理,类目,TOP

门店经理,门店,TOP

透视SQL性能指标举例

1、全量透视,32个并发,77毫秒。

select t1.cnt, t1.succ_cnt, t2.cnt, t2.succ_cnt from    
(    
  select count(*) cnt, sum(state) succ_cnt from log where crt_time between cdate() and cts()    
) t1,    
(    
  select count(*) cnt, sum(state) succ_cnt from log where crt_time between cdate()-1 and cts(interval '1 day')    
) t2;    
  cnt   | succ_cnt |  cnt   | succ_cnt     
--------+----------+--------+----------    
 796621 |   716974 | 796620 |   716930    
(1 row)    
    
Time: 76.697 ms    

2、类目 TOP,32个并发,446毫秒。

select c.id, count(*) cnt, sum(state) succ_cnt from c     
    join b on (c.id=b.cid)     
    join d on (b.id=d.bid)     
    join log on (d.id=log.did)     
  where crt_time between cdate() and cts()    
  group by c.id    
  order by cnt desc limit 10;    
    
 id | cnt  | succ_cnt     
----+------+----------    
 39 | 8369 |     7543    
 70 | 8346 |     7517    
 64 | 8281 |     7488    
 13 | 8249 |     7412    
 29 | 8222 |     7427    
  3 | 8217 |     7370    
 90 | 8200 |     7387    
 79 | 8199 |     7346    
 71 | 8175 |     7348    
 75 | 8169 |     7373    
(10 rows)    
    
Time: 446.977 ms    

3、我的总销量(包括所有下属),464毫秒。

这里用到了with recursive递归语法,根据当前登录用户的ID,树形查询所有下属。

with recursive tmp as (    
  select * from a where id=31                -- 输入我的USER ID    
  union all     
  select a.* from a join tmp on (a.pid=tmp.id)     
)    
select count(*) cnt, sum(state) succ_cnt from tmp     
  join b on (tmp.id=b.aid)    
  join d on (b.id=d.bid)    
  join log on (d.id=log.did)    
  where crt_time between cdate() and cts()    
  ;    
 cnt | succ_cnt     
-----+----------    
 296 |      268    
(1 row)    
    
Time: 463.970 ms    

4、我的直接下属,TOP,2.6秒。

这里用到了with recursive递归语法,根据当前登录用户的ID,树形查询所有下属。

这里还用到了正则表达式,用于对直接下属进行分组聚合。得到他们的销量。

with recursive tmp as (               
  select id::text from a where id=0   -- 输入我的USER ID      
  union all     
  select tmp.id||'.'||a.id as id from a join tmp on (a.pid=substring(tmp.id, '([\d]+)$')::int)     
)    
select substring(tmp.id, '^[\d]*\.?([\d]+)'), count(*) cnt, sum(state) succ_cnt from tmp     
  join b on (substring(tmp.id, '([\d]+)$')::int=b.aid)    
  join d on (b.id=d.bid)    
  join log on (d.id=log.did)    
  where crt_time between cdate() and cts()    
  group by 1    
  order by cnt desc limit 10    
  ;    
    
   substring |  cnt  | succ_cnt     
-----------+-------+----------    
 15        | 27341 |    24615    
 19        | 27242 |    24500    
 17        | 27190 |    24481    
 26        | 27184 |    24481    
 9         | 27179 |    24466    
 3         | 27157 |    24323    
 6         | 27149 |    24481    
 1         | 27149 |    24402    
 21        | 27141 |    24473    
 12        | 27140 |    24439    
(10 rows)    
    
Time: 2661.556 ms (00:02.662)    

5、我的所有下属(递归),TOP,642毫秒。

这里用到了with recursive递归语法,根据当前登录用户的ID,树形查询所有下属。

with recursive tmp as (     
  select * from a where id=30   -- 输入我的USER ID     
  union all     
  select a.* from a join tmp on (a.pid=tmp.id)     
)    
select tmp.id, count(*) cnt, sum(state) succ_cnt from tmp     
  join b on (tmp.id=b.aid)    
  join d on (b.id=d.bid)    
  join log on (d.id=log.did)    
  where crt_time between cdate() and cts()    
  group by tmp.id     
  order by cnt desc limit 10    
  ;    
  id  | cnt | succ_cnt     
------+-----+----------    
 2996 | 385 |      353    
 2969 | 339 |      301    
 2935 | 335 |      312    
 2936 | 332 |      304    
 2988 | 326 |      290    
 2986 | 321 |      295    
 2960 | 319 |      293    
 2964 | 313 |      276    
 2994 | 309 |      268    
 2975 | 308 |      276    
(10 rows)    
    
Time: 641.719 ms    

五、PostgreSQL 10 方案设计2 - 极限优化

方案1的优化点分析

前面看到,虽然用了并行,实际上部分透视查询的效率并没有达到100毫秒内的响应。

主要的消耗在JOIN层面,虽然已经并行哈希JOIN了,接下来的优化方法很奇妙,可以在订单写入时,自动补齐确实的上游信息(订单所对应设备的 销售的员工ID(ltree),类目、门店等)。

补齐信息后,就可以实现不需要JOIN的透视。

如何补齐呢?

补齐时,销售员工必须是包含所有层级关系的,因此我们选择了PostgreSQL ltree树类型来存储这个关系。

写入订单时,通过触发器,自动根据设备号补齐(用户ID(ltree),类目、门店)

1、创建树类型

create extension ltree;     

2、创建复合类型,包含树、类目、门店信息。

create type ntyp as (lt ltree, cid int, bid int);    

对订单表新增补齐字段

alter table log add column addinfo ntyp;  

3、创建物化视图1,存储实时员工结构。物化后,不需要再通过递归进行查询。

CREATE MATERIALIZED VIEW mv1 as     
select id, (    
  with recursive tmp as (    
  select id::text as path from a where id=t.id    
  union all     
  select a.pid||'.'||tmp.path as path from a join tmp on (a.id=substring(tmp.path, '^([\d]+)')::int)     
  )    
  select * from tmp order by length(path) desc nulls last limit 1    
) from a as t;    

3.1、创建UK

create unique index mv1_uk1 on mv1 (id);    

3.2、刷新方法,当有员工结构变化时,刷一下即可。刷新速度很快。

refresh materialized view CONCURRENTLY mv1;    

4、创建物化视图2,实时设备补齐值(类目和门店ID)。物化后,通过设备号,可以直接取出类目、门店。

CREATE MATERIALIZED VIEW mv2 as     
select a.id as aid, c.id as cid, b.id as bid, d.id as did from     
  a join b on (a.id=b.aid)     
    join c on (c.id=b.cid)     
    join d on (d.bid=b.id)    
;    

4.1、创建UK

create unique index mv2_uk1 on mv2(did);    

4.2、增量刷新物化视图,当设备与门店、类目关系发生变化时,刷新一下即可。刷新速度很快。

refresh materialized view CONCURRENTLY mv2;    

5、创建函数,通过设备号得到设备号补齐信息:(用户ID(ltree),类目、门店)

create or replace function gen_res (vdid int) returns ntyp as $$    
  select (mv1.path, mv2.cid, mv2.bid)::ntyp from     
  mv1 join mv2 on (mv1.id=mv2.aid) where mv2.did=vdid;    
$$ language sql strict;    

7、对订单表创建触发器,自动补齐关系(设备->门店->类目 和 销售->层级关系)

create or replace function tg() returns trigger as $$    
declare    
begin    
  NEW.addinfo := gen_res(NEW.did);    
  return NEW;    
end;    
$$ language plpgsql strict;    
    
create trigger tg before insert on log_201701 for each row execute procedure tg();    
create trigger tg before insert on log_201702 for each row execute procedure tg();    
create trigger tg before insert on log_201703 for each row execute procedure tg();    
create trigger tg before insert on log_201704 for each row execute procedure tg();    
create trigger tg before insert on log_201705 for each row execute procedure tg();    
create trigger tg before insert on log_201706 for each row execute procedure tg();    
create trigger tg before insert on log_201707 for each row execute procedure tg();    
create trigger tg before insert on log_201708 for each row execute procedure tg();    
create trigger tg before insert on log_201709 for each row execute procedure tg();    
create trigger tg before insert on log_201710 for each row execute procedure tg();    
create trigger tg before insert on log_201711 for each row execute procedure tg();    
create trigger tg before insert on log_201712 for each row execute procedure tg();    
create trigger tg before insert on log_201801 for each row execute procedure tg();    

8、效果

postgres=# insert into log values (1,1,now());    
INSERT 0 1    
    
    
postgres=# select * from log_201709 where did=1;    
 did | state |          crt_time          | mod_time |        addinfo            
-----+-------+----------------------------+----------+-----------------------    
   1 |     1 | 2017-09-23 16:58:47.736402 |          | (0.17.1702,60,417943)    

9、老数据订正,补齐设备号补齐(用户ID(ltree),类目、门店)为空的记录(例如某些时刻,设备号新上的,还没有刷新到MV1,MV2中)。

update log set addinfo=gen_res(did) where addinfo is null;    

补齐后的数据透视(完全规避JOIN),开启并行,贼快

1、全量(不变,性能杠杠的),74毫秒。

select t1.cnt, t1.succ_cnt, t2.cnt, t2.succ_cnt from    
(    
  select count(*) cnt, sum(state) succ_cnt from log where crt_time between cdate() and cts()    
) t1,    
(    
  select count(*) cnt, sum(state) succ_cnt from log where crt_time between cdate()-1 and cts(interval '1 day')    
) t2;    
    
  cnt   | succ_cnt |  cnt   | succ_cnt     
--------+----------+--------+----------    
 836965 |   753286 | 836964 |   753178    
(1 row)    
    
Time: 74.205 ms    

2、类目 TOP,41毫秒。

postgres=# select (log.addinfo).cid, count(*) cnt, sum(state) succ_cnt from log    
  where crt_time between cdate() and cts()     
  group by (log.addinfo).cid    
  order by cnt desc limit 10;     
    
 cid | cnt  | succ_cnt     
-----+------+----------    
  70 | 8796 |     7919    
  39 | 8793 |     7930    
  64 | 8700 |     7863    
  13 | 8659 |     7777    
  29 | 8621 |     7787    
  71 | 8613 |     7739    
  79 | 8613 |     7719    
   3 | 8597 |     7714    
  75 | 8590 |     7747    
  90 | 8579 |     7725    
(10 rows)    
    
Time: 41.221 ms    

3、我的总销量(包括所有下属),41毫秒

select count(*) cnt, sum(state) succ_cnt from log     
  where crt_time between cdate() and cts()    
  and (log.addinfo).lt ~ '*.1.*'    -- 求USER ID = 1 的总销量(包括所有下属)    
  ;    
    
  cnt  | succ_cnt     
-------+----------    
 28502 |    25627    
(1 row)    
    
Time: 41.065 ms    

4、我的直接下属,TOP

BOSS 视角查看,111毫秒。

select substring(((log.addinfo).lt)::text, '\.?(0\.?[\d]*)'),   -- USER ID = 0 的直接下属,请使用输入的用户ID替换    
  count(*) cnt, sum(state) succ_cnt from log     
  where crt_time between cdate() and cts()    
  and (log.addinfo).lt ~ '*.0.*'                                -- USER ID = 0,请使用输入的用户ID替换。    
  group by 1                                                    -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
    
 substring |  cnt  | succ_cnt     
-----------+-------+----------    
 0.19      | 28656 |    25756    
 0.15      | 28655 |    25792    
 0.26      | 28560 |    25721    
 0.1       | 28548 |    25668    
 0.9       | 28545 |    25701    
 0.6       | 28506 |    25706    
 0.12      | 28488 |    25646    
 0.17      | 28485 |    25652    
 0.21      | 28469 |    25665    
 0.3       | 28459 |    25486    
(10 rows)    
    
Time: 111.221 ms    

一级销售经理视角,41毫秒

select substring(((log.addinfo).lt)::text, '\.?(1\.?[\d]*)'),   -- USER ID = 1 的直接下属,请使用输入的用户ID替换    
  count(*) cnt, sum(state) succ_cnt from log     
  where crt_time between cdate() and cts()    
  and (log.addinfo).lt ~ '*.1.*'                                -- USER ID = 1,请使用输入的用户ID替换。    
  group by 1                                                    -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
    
 substring | cnt | succ_cnt     
-----------+-----+----------    
 1.120     | 368 |      320    
 1.59      | 367 |      331    
 1.54      | 357 |      316    
 1.93      | 344 |      313    
 1.80      | 342 |      306    
 1.37      | 338 |      305    
 1.64      | 334 |      298    
 1.90      | 329 |      299    
 1.66      | 327 |      296    
 1.109     | 326 |      293    
(10 rows)    
    
Time: 41.276 ms    

5、我的所有下属(递归),TOP

BOSS 视角(全体末端销售TOP),231毫秒。

select (log.addinfo).lt,                                        -- 所有下属(递归)    
  count(*) cnt, sum(state) succ_cnt from log     
  where crt_time between cdate() and cts()    
  and (log.addinfo).lt ~ '*.0.*'                                -- USER ID = 0,请使用输入的用户ID替换。    
  group by 1                                                    -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
    
    lt     | cnt | succ_cnt     
-----------+-----+----------    
 0.30.2996 | 405 |      371    
 0.28.2796 | 402 |      350    
 0.21.2093 | 393 |      347    
 0.3.234   | 391 |      356    
 0.14.1332 | 381 |      347    
 0.13.1283 | 381 |      344    
 0.19.1860 | 380 |      347    
 0.16.1553 | 380 |      341    
 0.28.2784 | 377 |      346    
 0.7.672   | 377 |      347    
(10 rows)    
    
Time: 230.630 ms    

一级销售经理视角,41毫秒

select (log.addinfo).lt,                                        -- 所有下属(递归)    
  count(*) cnt, sum(state) succ_cnt from log     
  where crt_time between cdate() and cts()    
  and (log.addinfo).lt ~ '*.1.*'                                -- USER ID = 1,请使用输入的用户ID替换。    
  group by 1                                                    -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
    
   lt    | cnt | succ_cnt     
---------+-----+----------    
 0.1.59  | 367 |      331    
 0.1.120 | 367 |      320    
 0.1.54  | 355 |      315    
 0.1.93  | 344 |      313    
 0.1.80  | 341 |      305    
 0.1.37  | 338 |      305    
 0.1.64  | 334 |      298    
 0.1.90  | 328 |      298    
 0.1.66  | 327 |      296    
 0.1.109 | 325 |      293    
(10 rows)    
    
Time: 41.558 ms    

补齐订单addinfo信息的好处

1、当人员结构、类目、门店发生变化时,是否需要订正订单中的(用户ID(ltree),类目、门店)数据,请业务方决定。

2、实际上,原来的方法是有问题的,例如A经理铺设的设备,一个月后,负责人发生了变化,统计时,如果实时JOIN,那么涉及上月的订单则会挂到新的负责人头上,但是显然出现了误差。

3、感觉还是补齐后的方法更加精确,是谁的就是谁的,不会搞错(把销量搞错问题可严重了,影响人家的绩效呢。)。

六、PostgreSQL 10 小结

用到了哪些PostgreSQL数据库特性?

1、递归查询

2、并行查询

3、JOIN方法

4、继承(分区表)

5、触发器

6、复合类型

7、ltree树类型

https://www.postgresql.org/docs/9.6/static/ltree.html

七、Greenplum

Greenplum 方案1

注意前面已经提到了Greenplum的TP能力很弱,如果设备心跳实时更新、订单实时写入、实时更新,可能会扛不住压力。(目前greenplum update, delete都是锁全表的,很大的锁。)

因此在设计时需要注意,把设备更新心跳做成批量操作(例如从TP数据库,每隔几分钟导出全量到Greenplum中)。把订单的更新做成插入(通过RULE实现)。

pic

表结构设计

create table a (          -- 员工层级信息    
  id int primary key,     -- 编号 ID    
  nick name,              -- 名字    
  pid int                 -- 上级 ID    
) DISTRIBUTED BY(id);    
    
create table c (          -- 类目    
  id int primary key,     -- 类目ID    
  comment text            -- 类目名称    
) DISTRIBUTED BY(id);    
    
create table b (          -- 终端门店    
  id int primary key,     -- 编号    
  nick text,              -- 名称    
  cid int,                -- 类目    
  aid int                 -- 门店经理ID    
) DISTRIBUTED BY(id);    
    
create table d (          -- 设备    
  id int primary key,     -- 设备编号    
  bid int,                -- 门店编号    
  alive_ts timestamp      -- 设备心跳时间    
) DISTRIBUTED BY(id);    
    
create table log1 (        -- 订单日志,创建订单    
  did int,                -- 设备ID    
  state int2,             -- 订单最终状态    
  crt_time timestamp,     -- 订单创建时间    
  mod_time timestamp      -- 订单修改时间    
) DISTRIBUTED BY(did)     
PARTITION BY range (crt_time)    
(start (date '2017-01-01') inclusive end (date '2018-01-01') exclusive every (interval '1 month'));     
    
create table log2 (        -- 订单日志,最终状态    
  did int,                -- 设备ID    
  state int2,             -- 订单最终状态    
  crt_time timestamp,     -- 订单创建时间    
  mod_time timestamp      -- 订单修改时间    
) DISTRIBUTED BY(did)     
PARTITION BY range (crt_time)    
(start (date '2017-01-01') inclusive end (date '2018-01-01') exclusive every (interval '1 month'));     
    
-- 创建规则,更新改成插入    
create rule r1 as on update to log1 do instead insert into log2 values (NEW.*);    

测试心跳表导入速度

导入100万设备数据,耗时约1秒。

date +%F%T;psql -c "copy d to stdout"|psql -h 127.0.0.1 -p 15432 -U digoal postgres -c "copy d from stdin"; date +%F%T;    
    
2017-09-2319:42:22    
COPY 1000000    
2017-09-2319:42:23    

测试订单写入速度

注意所有写入操作建议改成批量操作。

批量写入约87万行/s。

date +%F%T; psql -c "copy (select did,state,crt_time,mod_time from log) to stdout"|psql -h 127.0.0.1 -p 15432 -U digoal postgres -c "copy log1 from stdin"; date +%F%T;    
    
2017-09-2320:04:44    
COPY 378432001    
2017-09-2320:12:03    

数据导入

psql -c "copy a to stdout"|psql -h 127.0.0.1 -p 15432 -U digoal postgres -c "copy a from stdin"    
psql -c "copy b to stdout"|psql -h 127.0.0.1 -p 15432 -U digoal postgres -c "copy b from stdin"    
psql -c "copy c to stdout"|psql -h 127.0.0.1 -p 15432 -U digoal postgres -c "copy c from stdin"    
# psql -c "copy d to stdout"|psql -h 127.0.0.1 -p 15432 -U digoal postgres -c "copy d from stdin"    
# psql -c "copy (select * from log) to stdout"|psql -h 127.0.0.1 -p 15432 -U digoal postgres -c "copy log1 from stdin"

透视SQL测试

1、全量透视,610毫秒。

select t1.cnt, t1.succ_cnt, t2.cnt, t2.succ_cnt from    
(    
  select count(*) cnt, sum(state) succ_cnt from log1 where crt_time between cdate() and cts(interval '0')    
) t1,    
(    
  select count(*) cnt, sum(state) succ_cnt from log1 where crt_time between cdate()-1 and cts(interval '1 day')    
) t2;    
    
  cnt   | succ_cnt |  cnt   | succ_cnt     
--------+----------+--------+----------    
 876301 |   788787 | 876300 |   788564    
(1 row)    
    
Time: 609.801 ms    

2、类目 TOP,219毫秒。

select c.id, count(*) cnt, sum(state) succ_cnt from c     
    join b on (c.id=b.cid)     
    join d on (b.id=d.bid)     
    join log1 on (d.id=log1.did)     
  where crt_time between cdate() and cts(interval '0')    
  group by c.id    
  order by cnt desc limit 10;    
    
 id | cnt  | succ_cnt     
----+------+----------    
 70 | 9220 |     8311    
 39 | 9197 |     8303    
 64 | 9096 |     8220    
 79 | 9034 |     8095    
 13 | 9033 |     8114    
 29 | 9033 |     8151    
 75 | 9033 |     8148    
  3 | 9005 |     8084    
 71 | 9002 |     8098    
 90 | 8974 |     8079    
(10 rows)    
    
Time: 218.695 ms    

3、我的总销量(包括所有下属),208毫秒。

返回所有下属以及当前用户ID。

create or replace function find_low(int) returns int[] as $$    
declare    
  res int[] := array[$1];    
  tmp int[] := res;    
begin    
  loop    
    select array_agg(id) into tmp from a where pid = any (tmp);    
    res := array_cat(res,tmp);    
    if tmp is null then    
      exit;    
    end if;    
  end loop;    
  return res;    
end;    
$$ language plpgsql strict;    
select count(*) cnt, sum(state) succ_cnt from     
(select unnest(find_low(31)) as id) as tmp     
  join b on (tmp.id=b.aid)    
  join d on (b.id=d.bid)    
  join log1 on (d.id=log1.did)    
  where crt_time between cdate() and cts(interval '0')    
  ;    
 cnt | succ_cnt     
-----+----------    
 342 |      312    
(1 row)    
    
Time: 208.585 ms    

4、我的直接下属,TOP。

Greenplum 暂不支持递归语法,需要自定义UDF实现。

5、我的所有下属(递归),TOP。

Greenplum 暂不支持递归语法,需要自定义UDF实现。

Greenplum 方案2

与PostgreSQL 方案2一样,将“设备对应门店、类目、销售、销售以及他的所有上级”的数据物化。

准备工作:

1、新增字段

alter table log1 add column aid int;  
alter table log1 add column path text;  
alter table log1 add column cid int;  
alter table log1 add column bid int;  
  
alter table log2 add column aid int;  
alter table log2 add column path text;  
alter table log2 add column cid int;  
alter table log2 add column bid int;  

2、修改之前定义的rule,业务的更新转换为INSERT,批量订单补齐的更新操作不转换。

drop rule r1 on log1;  
  
create rule r1 as on update to log1 where (NEW.aid is null) do instead insert into log2 values (NEW.*);    

物化

1、物化视图1:设备 -> 门店 -> 类目 -> 销售

创建物化视图mv1:

create table mv1 (did int, bid int, cid int, aid int) distributed by (did);  
create index idx_mv1_did on mv1(did);  

初始化物化视图mv1:

insert into mv1   
  select d.id as did, b.id as bid, c.id as cid, a.id as aid from d join b on (d.bid=b.id) join c on (b.cid=c.id) join a on (a.id=b.aid);  

刷新物化视图mv1:

begin;  
update mv1 set bid=t1.bid , cid=t1.cid , aid=t1.aid  
  from   
  (  
    select d.id as did, b.id as bid, c.id as cid, a.id as aid from d join b on (d.bid=b.id) join c on (b.cid=c.id) join a on (a.id=b.aid)  
  ) t1  
where mv1.did=t1.did and (t1.bid<>mv1.bid or t1.cid<>mv1.cid or t1.aid<>mv1.aid);  
  
insert into mv1   
  select t1.* from  
  (  
    select d.id as did, b.id as bid, c.id as cid, a.id as aid from d join b on (d.bid=b.id) join c on (b.cid=c.id) join a on (a.id=b.aid)  
  ) t1  
  left join mv1 on (t1.did=mv1.did) where mv1.* is null;  
end;  
vacuum mv1;  

2、物化视图2:销售 -> 销售以及他的所有上级

创建返回 销售以及他的所有上级 的函数

create or replace function find_high(int) returns text as $$    
declare    
  res text := $1;    
  tmp text := res;    
begin    
  loop    
    select pid into tmp from a where id = tmp::int;    
    if tmp is null then    
      exit;    
    end if;    
    res := tmp||'.'||res;   
  end loop;    
  return res;    
end;    
$$ language plpgsql strict;    

没有递归语法,Greenplum的函数调用效率并不高:

postgres=# select find_high(id) from generate_series(100,110) t(id);  
 find_high   
-----------  
 0.1.100  
 0.1.101  
 0.1.102  
 0.1.103  
 0.1.104  
 0.1.105  
 0.1.106  
 0.1.107  
 0.1.108  
 0.1.109  
 0.1.110  
(11 rows)  
Time: 1472.435 ms  
  
同样的操作,在PostgreSQL里面只需要0.5毫秒:  
  
postgres=# select find_high(id) from generate_series(100,110) t(id);  
 find_high   
-----------  
 0.1.100  
 0.1.101  
 0.1.102  
 0.1.103  
 0.1.104  
 0.1.105  
 0.1.106  
 0.1.107  
 0.1.108  
 0.1.109  
 0.1.110  
(11 rows)  
Time: 0.524 ms  

验证

postgres=# select find_high(1);  
 find_high   
-----------  
 0.1  
(1 row)  
  
postgres=# select find_high(0);  
 find_high   
-----------  
 0  
(1 row)  
  
postgres=# select find_high(100);  
 find_high   
-----------  
 0.1.100  
(1 row)  

创建物化视图mv2

create table mv2 (aid int, path text) distributed by (aid);  
create index idx_mv2_did on mv2(aid);  

初始化、刷新物化视图mv2

-- GP不支持这样的操作,本来就简单了:insert into mv2 select id, find_high(id) from a;  
  
postgres=# select id, find_high(id) from a;  
ERROR:  function cannot execute on segment because it accesses relation "postgres.a" (functions.c:155)  (seg1 slice1 tb2a07543.sqa.tbc:25433 pid=106586) (cdbdisp.c:1328)  
DETAIL:    
SQL statement "select pid from a where id =  $1 "  
PL/pgSQL function "find_high" line 7 at SQL statement  

创建函数

create or replace function refresh_mv2() returns void as $$  
declare  
  aid int[];  
begin  
  select array_agg(id) into aid from a;  
  delete from mv2;  
  insert into mv2 select id, find_high(id) from unnest(aid) t(id);  
end;  
$$ language plpgsql strict;  

调用函数刷新mv2,时间基本无法接受。

select refresh_mv2();  

PS:建议程序生成这部分员工树型结构数据。再插入到GPDB中。因为总共才3001条。或者你可以在PostgreSQL中生成,PG实在太方便了。

修正订单

调度任务,批量更新:

update log1 set aid=t1.aid, path=t1.path, cid=t1.cid, bid=t1.bid  
from  
(  
select did, bid, cid, mv1.aid, mv2.path from mv1 join mv2 on (mv1.aid=mv2.aid)  
) t1  
where log1.did=t1.did and log1.aid is null;  
  
UPDATE 378432001  
  
  
update log2 set aid=t1.aid, path=t1.path, cid=t1.cid, bid=t1.bid  
from  
(  
select did, bid, cid, mv1.aid, mv2.path from mv1 join mv2 on (mv1.aid=mv2.aid)  
) t1  
where log2.did=t1.did and log2.aid is null;  
  
UPDATE 378432001  

透视查询

1、全量透视,205毫秒。

select t1.cnt, t1.succ_cnt, t2.cnt, t2.succ_cnt from    
(    
  select count(*) cnt, sum(state) succ_cnt from log1 where crt_time between cdate() and cts(interval '0')    
) t1,    
(    
  select count(*) cnt, sum(state) succ_cnt from log1 where crt_time between cdate()-1 and cts(interval '1 day')    
) t2;   
  
  cnt   | succ_cnt |  cnt   | succ_cnt   
--------+----------+--------+----------  
 480228 |   432151 | 480228 |   432205  
(1 row)  
  
Time: 205.436 ms  

2、类目 TOP,254毫秒。

select c.id, count(*) cnt, sum(state) succ_cnt from c     
    join b on (c.id=b.cid)     
    join d on (b.id=d.bid)     
    join log1 on (d.id=log1.did)     
  where crt_time between cdate() and cts(interval '0')    
  group by c.id    
  order by cnt desc limit 10;    
 id | cnt  | succ_cnt   
----+------+----------  
 64 | 5052 |     4555  
 29 | 4986 |     4483  
 34 | 4982 |     4509  
 70 | 4968 |     4466  
 71 | 4964 |     4491  
  5 | 4953 |     4474  
 79 | 4937 |     4454  
 63 | 4936 |     4420  
 66 | 4934 |     4436  
 18 | 4922 |     4417  
(10 rows)  
  
Time: 254.007 ms  

3、我的总销量(包括所有下属),110毫秒。

select count(*) cnt, sum(state) succ_cnt from log1     
  where crt_time between cdate() and cts(interval '0')    
  and (path like '1.%' or path like '%.1' or path like '%.1.%')    -- 求USER ID = 1 的总销量(包括所有下属)        
  ;   
  cnt  | succ_cnt   
-------+----------  
 16605 |    14964  
(1 row)  
  
Time: 110.396 ms  

4、我的直接下属,TOP。

BOSS 视角查看,180毫秒。

set escape_string_warning TO off;  
  
select substring(path, '\.?(0\.?[0-9]*)'),                       -- USER ID = 0 的直接下属,请使用输入的用户ID替换    
  count(*) cnt, sum(state) succ_cnt from log1     
  where crt_time between cdate() and cts(interval '0')    
  and (path like '0.%' or path like '%.0' or path like '%.0.%')  -- USER ID = 0,请使用输入的用户ID替换。    
  group by 1                                                     -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
  
 substring |  cnt  | succ_cnt   
-----------+-------+----------  
 0.3       | 17014 |    15214  
 0.15      | 17006 |    15285  
 0.11      | 16958 |    15285  
 0.22      | 16901 |    15231  
 0.19      | 16887 |    15217  
 0.21      | 16861 |    15160  
 0.6       | 16841 |    15075  
 0.9       | 16831 |    15123  
 0.26      | 16787 |    15060  
 0.14      | 16777 |    15048  
(10 rows)  
  
Time: 179.950 ms 

一级销售经理视角,176毫秒

select substring(path, '\.?(1\.?[0-9]*)'),                       -- USER ID = 1 的直接下属,请使用输入的用户ID替换    
  count(*) cnt, sum(state) succ_cnt from log1     
  where crt_time between cdate() and cts(interval '0')    
  and (path like '1.%' or path like '%.1' or path like '%.1.%')  -- USER ID = 1,请使用输入的用户ID替换。    
  group by 1                                                     -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
  
 substring | cnt | succ_cnt   
-----------+-----+----------  
 1.120     | 222 |      202  
 1.54      | 218 |      193  
 1.92      | 217 |      192  
 1.51      | 209 |      187  
 1.93      | 206 |      181  
 1.53      | 203 |      182  
 1.59      | 203 |      187  
 1.37      | 202 |      188  
 1.82      | 197 |      177  
 1.66      | 196 |      180  
(10 rows)  
  
Time: 176.298 ms  

5、我的所有下属(递归),TOP。

BOSS 视角(全体末端销售TOP),155毫秒。

select path,                                                      -- 所有下属(递归)    
  count(*) cnt, sum(state) succ_cnt from log1     
  where crt_time between cdate() and cts(interval '0')    
  and (path like '0.%' or path like '%.0' or path like '%.0.%')   -- USER ID = 0,请使用输入的用户ID替换。    
  group by 1                                                      -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
    
   path    | cnt | succ_cnt   
-----------+-----+----------  
 0.5.482   | 261 |      229  
 0.28.2796 | 248 |      229  
 0.24.2348 | 242 |      225  
 0.13.1318 | 240 |      213  
 0.21.2093 | 237 |      211  
 0.26.2557 | 235 |      210  
 0.4.346   | 233 |      205  
 0.30.2935 | 231 |      214  
 0.14.1332 | 229 |      205  
 0.26.2620 | 229 |      204  
(10 rows)  
  
Time: 155.268 ms    

一级销售经理视角,151毫秒

select path,                                                      -- 所有下属(递归)    
  count(*) cnt, sum(state) succ_cnt from log1     
  where crt_time between cdate() and cts(interval '0')    
  and (path like '1.%' or path like '%.1' or path like '%.1.%')   -- USER ID = 1,请使用输入的用户ID替换。    
  group by 1                                                      -- 第一个字段为分组    
  order by cnt desc limit 10    
;    
    
  path   | cnt | succ_cnt   
---------+-----+----------  
 0.1.120 | 222 |      202  
 0.1.92  | 218 |      193  
 0.1.54  | 218 |      193  
 0.1.51  | 209 |      187  
 0.1.93  | 207 |      182  
 0.1.59  | 204 |      187  
 0.1.53  | 203 |      182  
 0.1.37  | 202 |      188  
 0.1.82  | 198 |      178  
 0.1.66  | 196 |      180  
(10 rows)  
  
Time: 150.883 ms  

八、Greenplum 小结

1、使用Greenplum需要注意数据倾斜的问题,所以在分布键的选择上请参考:

《分布式DB(Greenplum)中数据倾斜的原因和解法 - 阿里云HybridDB for PostgreSQL最佳实践》

2、Greenplum暂时还没有支持递归语法,因此需要使用UDF来实现类似求所有下级、或者补齐所有上级等操作的功能。

3、Greenplum的方案二。重点是物化视图、补齐(实际上不在订单中补齐也没关系,只要生成一张 (设备号->门店->类目和员工层级关系) 的表即可,查询起来就会方便很多。

4、Greenplum的delete和update操作会锁全表,堵塞其他该表的insert、delete、update操作。不堵塞查询。需要特别注意。

5、订单补齐采用批量更新的方式。

九、小结

对于本例,建议还是使用PostgreSQL 10(特别是将来量要往100 TB这个量级发展的时候,迁移到PolarDB for PostgreSQL会特别方便,完全兼容。)。性能方面,TP和AP都满足需求。功能方面也完全满足需求,而且有很多可以利用的特性来提升用户体验:

如果要使用Greenplum(HybridDB for PostgreSQL)的方案,那么建议依旧使用类似PostgreSQL 10方案2的设计方法(订单补齐使用规则实现、或者批量更新实现)。

1、递归查询,用于检索树形结构的数据,例如员工层级,图式搜索等。

2、并行查询,可以有效利用多个CPU的能力,类似游戏中的放大招,加速查询。

3、JOIN方法,有hash, merge, nestloop等多种JOIN方法,可以处理任意复杂的JOIN。

4、继承(分区表),订单按时间分区。

5、触发器,用于实现订单自动补齐。

6、复合类型,补齐 “设备->门店->类目和员工层级”的信息。

7、ltree树类型,存储完成的员工上下级关系。

https://www.postgresql.org/docs/9.6/static/ltree.htm

8、物化视图,用在将员工等级进行了补齐。一键刷新,不需要业务处理复杂的人事变动逻辑。同时也便于透视分析语句的实现。

9、正则表达式,用在了ltree的正则匹配上,例如按直接下属分组聚合,按当前登录用户组分组聚合等。

10、以及本方案中没有用到的诸多特性(例如SQL流计算,oss_ext对象存储外部表 等)。

接下来阿里云会推出PolarDB for PostgreSQL,100TB 级,共享存储,一写多读架构。对标AWSAurora与Oracle RAC。

11、本例三种方案(同等硬件资源, 32C)的实时透视QUERY性能对比:

方案用例响应时间
PostgreSQL 10 方案1全量透视77 毫秒
PostgreSQL 10 方案1类目 TOP446 毫秒
PostgreSQL 10 方案1我的总销量(包括所有下属)464 毫秒
PostgreSQL 10 方案1我的直接下属,TOP2.6 秒
PostgreSQL 10 方案1我的所有下属(递归),TOP642 毫秒
PostgreSQL 10 方案2全量透视74 毫秒
PostgreSQL 10 方案2类目 TOP41 毫秒
PostgreSQL 10 方案2我的总销量(包括所有下属)41 毫秒
PostgreSQL 10 方案2我的直接下属,TOP41 毫秒
PostgreSQL 10 方案2我的所有下属(递归),TOP41 毫秒
Greenplum 方案1全量透视610 毫秒
Greenplum 方案1类目 TOP219 毫秒
Greenplum 方案1我的总销量(包括所有下属)208 毫秒
Greenplum 方案1我的直接下属,TOP不支持递归、未测试
Greenplum 方案1我的所有下属(递归),TOP不支持递归、未测试
Greenplum 方案2全量透视205 毫秒
Greenplum 方案2类目 TOP254 毫秒
Greenplum 方案2我的总销量(包括所有下属)110 毫秒
Greenplum 方案2我的直接下属,TOP176 毫秒
Greenplum 方案2我的所有下属(递归),TOP151 毫秒

12、Greenplum和PostgreSQL两个产品的差异、如何选型可以参考:

《空间|时间|对象 圈人 + 透视 - 暨PostgreSQL 10与Greenplum的对比和选择》

章节:Greenplum和PostgreSQL两个产品的特色和选择指导。

13、月与年的数据,由于时效性没有日的高,所以可以按天为单位进行统计并存放结果,不需要实时查询。需要查询时查询统计结果即可。

MySQL · 捉虫动态 · 信号处理机制分析

$
0
0

背景

AliSQL上面有人提交了一个 bug,在使用主备的时候 service stop mysql 不能关闭主库,一直显示 shutting down mysql …,到底怎么回事呢,先来看一下 service stop mysql 是怎么停止数据库的。配置 MySQL 在系统启动时启动需要把 MYSQL_BASEDIR/support-files 目录下的脚本 mysql.sever 放到 /etc/init.d/ 目录下,脚本来控制 mysqld 的启动和停止。看一下脚本中的代码 :

if test -s "$mysqld_pid_file_path"
     then
       mysqld_pid=`cat "$mysqld_pid_file_path"`

       if (kill -0 $mysqld_pid 2>/dev/null)
       then
         echo $echo_n "Shutting down MySQL"
         kill $mysqld_pid
         # mysqld should remove the pid file when it exits, so wait for it.
         wait_for_pid removed "$mysqld_pid""$mysqld_pid_file_path"; return_value=$?
	...
	

实际上的关闭动作就是向 mysqld 进程发送一个 kill pid 的信号,也就是 TERM , wait_for_pid 函数中就是不断检测 $MYSQL_DATADIR 下面的 pid 文件是否存在,并且打印 ‘.’,所以上述问题应该是 mysqld 没有正确处理接收到的信号。

信号处理机制

多线程信号处理

进程中的信号处理是异步的,当信号发送给进程之后,就会中断进程当前的执行流程,跳到注册的对应信号处理函数中,执行完毕后再返回进程的执行流程。在多线程信号处理中,一般采用一个单独的线程阻塞的等待信号集,然后处理信号,重新阻塞等待。线程的信号处理有以下几个特点:

  • 每个线程都有自己的信号屏蔽字(单个线程可以屏蔽某些信号)
  • 信号的处理是整个进程中所有线程共享的(某个线程修改信号处理行为后,也会影响其它线程)
  • 进程中的信号是递送到单个线程的,如果一个信号和硬件故障相关,那么该信号就会被递送到引起该事件的线程,否是是发送到任意一个线程。
int pthread_sigmask(int how, const sigset_t * restrict set, sigset_t *restrict oset);

在进程中使用 sigprocmask 设置信号屏蔽字,在线程中使用 pthread_sigmask,他们的基本相同,pthread_sigmask 工作在线程中,失败时返回错误码,而 sigprocmask 会设置 errno 并返回 -1。参数 how 控制设置屏蔽字的行为,值为 SIG_BLOCK(把信号集添加到现有信号集中,取并集), SIG_SET_MASK(设置信号集为 set), SIG_UNBLOCK(从信号集中移除 set 中的信号)。set 表示需要操纵的信号集合。oset 返回设置之前的信号屏蔽字,如果设置 set 为 NULL,可以通过 oset 获得当前的信号屏蔽字。

int sigwait(const sigset_t \*restrict set, int \*restrict sig)

sigwait 将会挂起调用线程,直到接收到 set 中设置的信号,具体的信号将会通过 sig 返回,同时会从 set 中删除 sig 信号。 在调用 sigwait 之前,必须阻塞那些它正在等待的信号,否则在调用的时间窗口就可能接收到信号。

int pthread_kill(pthread_t thread, int sig)

发送信号到指定线程,如果 sig 为 0,可以用来判断线程是否还活着。

man pthread_sigmask 里面给了一个例子:

  1 #include <pthread.h>
  2 #include <stdio.h>
  3 #include <stdlib.h>
  4 #include <unistd.h>
  5 #include <signal.h>
  6 #include <errno.h>
  7
  8 /* Simple error handling functions */
  9
 10 #define handle_error_en(en, msg) \
 11     do { errno = en; perror(msg); exit(EXIT_FAILURE); } while (0)
 12
 13     static void *
 14 sig_thread(void *arg)
 15 {
 16     sigset_t *set = (sigset_t *) arg;
 17     int s, sig;
 18
 19     for (;;) {
 20         s = sigwait(set, &sig);
 21         if (s != 0)
 22             handle_error_en(s, "sigwait");
 23         printf("Signal handling thread got signal %d\n", sig);
 24     }
 25 }
 26
 27 int main(int argc, char *argv[])
 28 {
 29     pthread_t thread;
 30     sigset_t set;
 31     int s;
 32     /* Block SIGINT; other threads created by main() will inherit
 33      *               a copy of the signal mask. */                                                                                                
 32     /* Block SIGINT; other threads created by main() will inherit
 33      *               a copy of the signal mask. */
 34
 35     sigemptyset(&set);
 36     sigaddset(&set, SIGQUIT);
 37     sigaddset(&set, SIGUSR1);
 38     s = pthread_sigmask(SIG_BLOCK, &set, NULL);
 39     //s = sigprocmask(SIG_BLOCK, &set, NULL);
 40     if (s != 0)
 41         handle_error_en(s, "pthread_sigmask");
 42
 43     s = pthread_create(&thread, NULL, &sig_thread, (void *) &set);
 44     if (s != 0)
 45         handle_error_en(s, "pthread_create");
 46
 47     /* Main thread carries on to create other threads and/or do
 48      *               other work */
 49
 50     pause();            /* Dummy pause so we can test program */
 51     return 0;
 52 }

执行一下:

$ ./a.out &
[1] 5423
$ kill -QUIT %1
Signal handling thread got signal 3
$ kill -USR1 %1
Signal handling thread got signal 10
$ kill -TERM %1
[1]+  Terminated              ./a.out

测试了一下,把上面代码的 pthread_sigmask 替换成 sigprocmask ,同样能够正确执行,说明线程也能够继承原进程的屏蔽字,不过还是尽量使用 pthread_sigmask, 表述清楚点,而且说不定还有其它坑。

MySQL 信号处理

MySQL 是典型的多线程处理,它的信号处理形式和上一小节介绍的差不多,在 mysqld 启动的时候调用 my_init_signal 初始化信号屏蔽字,把需要信号处理线程处理的信号屏蔽起来,然后启动信号处理函数,入口是 signal_hand 。

在 my_init_signal 函数中,设置 SIGSEGC, SIGABORT, SIGBUS, SIGILL, SIGFPE 的处理函数为 handle_fatal_signal,把 SIGPIPE,SIGQUIT, SIGHUP, SIGTERM, SIGTSTP 加入到信号屏蔽字里,调用 sigprocmask 和 pthread_sigmask 设置屏蔽字。这一系列动作是在 mysql 启动其它辅助线程之前完成的动作,意图很明显,就是让之后的线程都继承设置的信号屏蔽字,把所有的信号交给信号处理线程去处理。

signal_hand 函数首先把需要处理的信号放到信号集合里去,然后完成 create_pid_file ,data 目录下的 pid 文件实际上是由信号处理线程创建的。接着等待 mysqld 完成启动,各个线程之间需要同步,核心代码是一个死循环,通过 my_sigwait 调用 sigwait 阻塞的等待信号的到来。我们目前主要关心 SIGTERM 的处理,和 SIGQUIT, SIGKILL 处理方式相同,都是调用 kill_server 关闭整个数据库。

Bug Fix

文中开头的链接中提到 loose-rpl_semi_sync_master_enabled = 0 关闭就不会有问题, 如果为 1 就会出现无法关闭的情况,顺着这个线索寻找,rpl_semi_sync_master_enabled 在主备使用 semisync 情况下控制启动 Master 节点的 Ack Receiver 线程,初始化阶段的调用堆栈为:

init_common_variables
		|
		|----- ReplSemiSyncMaster::initObject
						|
						|----- Ack_receiver::start
								

而 init_common_variables 的调用是在 my_init_signal 之前,也就是 Ack Receiver 线程没有办法继承信号屏蔽字,不会屏蔽 SIGTERM 信号。在 my_init_signal 中还有一段这样的代码:

/* Fix signals if blocked by parents (can happen on Mac OS X) */
  ....
  sa.sa_handler = print_signal_warning;
  sigaction(SIGTERM, &sa, (struct sigaction\*) 0);
  ...

对于信号的修改的作用于整个进程的,也就是说之前启动的 Ack Receiver 线程没有信号屏蔽字,而且注册了信号处理函数。当 SIGTERM 发生后,信号处理线程和 Ack Receiver 线程都可以接收信号处理,信号被随机的分发(测试高概率都是发给 Ack Receiver),print_signal_warning 仅仅打印信息到 errlog,就出现了无法关闭 mysqld 的情况了。

修改也比较简单,把 initObject 的操作放到 my_init_signal 之后就好,注意不能把 init_common_variables 整个移到 my_init_signal 之前,因为 my_init_signal 里面还有要初始化的变量呢。

Viewing all 692 articles
Browse latest View live