0%

乐观锁

乐观锁不是数据库自带的,需要我们自己去实现。乐观锁是指操作数据库时(更新操作),想法很乐观,认为这次的操作不会导致冲突,在操作数据时,并不进行任何其他的特殊处理(也就是不加锁),而在进行更新后,再去判断是否有冲突了。

通常实现是这样的:在表中的数据进行操作时(更新),先给数据表加一个版本(version)字段,每操作一次,将那条记录的版本号加1。也就是先查询出那条记录,获取出version字段,如果要对那条记录进行操作(更新),则先判断此刻version的值是否与刚刚查询出来时的version的值相等,如果相等,则说明这段期间,没有其他程序对其进行操作,则可以执行更新,将version字段的值加1;如果更新时发现此刻的version值与刚刚获取出来的version的值不相等,则说明这段期间已经有其他程序对其进行操作了,则不进行更新操作。

举例:

下单操作包括3步骤:

  1. 查询出商品信息

    1
    select (status,status,version) from t_goods where id=#{id}
  2. 根据商品信息生成订单

  3. 修改商品status为2

    1
    2
    3
    update t_goods
    set status=2,version=version+1
    where id=#{id} and version=#{version};

    除了自己手动实现乐观锁之外,现在网上许多框架已经封装好了乐观锁的实现,如hibernate,需要时,可能自行搜索”hiberate 乐观锁”试试看。

悲观锁

与乐观锁相对应的就是悲观锁了。悲观锁就是在操作数据时,认为此操作会出现数据冲突,所以在进行每次操作时都要通过获取锁才能进行对相同数据的操作,这点跟java中的synchronized很相似,所以悲观锁需要耗费较多的时间。另外与乐观锁相对应的,悲观锁是由数据库自己实现了的,要用的时候,我们直接调用数据库的相关语句就可以了。

说到这里,由悲观锁涉及到的另外两个锁概念就出来了,它们就是共享锁与排它锁。共享锁和排它锁是悲观锁的不同的实现,它俩都属于悲观锁的范畴。

共享锁(S锁)

共享锁(S锁):共享 (S) 指的就是对于多个不同的事务,对同一个资源共享同一个锁. 用于不更改或不更新数据的操作(只读操作),如 SELECT 语句。

如果事务T对数据A加上共享锁后,则其他事务只能对A再加共享锁,不能加排他锁。获准共享锁的事务只能读数据,不能修改数据。

刚刚说了,对于悲观锁,一般数据库已经实现了,共享锁也属于悲观锁的一种,那么共享锁在mysql中是通过什么命令来调用呢。通过查询资料,了解到通过在执行语句后面加上lock in share mode就代表对某些资源加上共享锁了。
比如,我这里通过mysql打开两个查询编辑器,在其中开启一个事务,并不执行commit语句
city表DDL如下:

1
2
3
4
5
6
7
8
9
CREATE TABLE `city` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`state` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8;

begin;
SELECT * from city where id = "1" lock in share mode;

然后在另一个查询窗口中,对id为1的数据进行更新

1
2
3
4
update city set name="666" where id ="1";
此时,操作界面进入了卡顿状态,过几秒后,也提示错误信息
[SQL]update city set name="666" where id ="1";
[Err] 1205 - Lock wait timeout exceeded; try restarting transaction

那么证明,对于id=1的记录加锁成功了,在上一条记录还没有commit之前,这条id=1的记录被锁住了,只有在上一个事务释放掉锁后才能进行操作,或用共享锁才能对此数据进行操作。

排他锁(X锁)

排他锁(X锁):排它锁与共享锁相对应,就是指对于多个不同的事务,对同一个资源只能有一把锁。用于数据修改操作,例如 INSERT、UPDATE 或 DELETE。确保不会同时同一资源进行多重更新。

如果事务T对数据A加上排他锁后,则其他事务不能再对A加任任何类型的封锁。获准排他锁的事务既能读数据,又能修改数据。

我们在操作数据库的时候,可能会由于并发问题而引起的数据的不一致性(数据冲突)

行锁

行锁,由字面意思理解,就是给某一行加上锁,也就是一条记录加上锁。

比如之前演示的共享锁语句

1
SELECT * from city where id = "1" lock in share mode;

由于对于city表中,id字段为主键,就也相当于索引。执行加锁时,会将id这个索引为1的记录加上锁,那么这个锁就是行锁。

表锁

表锁,和行锁相对应,给这个表加上锁。

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。

句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

直接指针
如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

引言

二分查找的大名,如雷贯耳,它可以在有序的一个数组中实现实现$O(logn)$的复杂度查找。

然而,业界还流传着一句话

十个二分九个错

这句调侃的话可谓精准的描述了二分查找的特性,二分查找从实现思路上是非常简单的,利用有序特性,对半查找,不断缩小范围,直到找到结果。然而在细节边界问题上,二分查找可以说是难得过分,如:

  • 退出循环的标准,是< 还是 <= ?
  • mid的计算标准,是(left+right)/2还是 (left+right+1)/2 ?
  • 折半的移动标准,是 =mid 还是 =mid-1 ?
  • 返回值的选定,怎么有写是left,有写是right,有写还要额外判断?

因为这些边界问题,导致二分查找以为懂了的你,写出的代码总是不行,本文为个人为了彻底弄懂二分查找,一劳永逸的从根本上理解的沉淀。

二分查找的核心思想

二分查找的核心思想就是对半查找,如下图所示,有一个[1,2,3,4,5,6,7] 数组,要判断2是否存在于这个数据中。

二分查找的方法为:

  1. 数组[1,2,3,4,5,6,7]的中间值为 (1+7)/2=4,找第四个数字,该值为4,由于4!=2,因此需要继续判断,又由于4>2,因此得出目标值在左边这个区间,即[1,2,3]这里面
  2. 数组[1,2,3]的中间值为 (1+3)/2=2,找第二个数字,该值为2,是期望要找的目标,返回ture。

核心思想非常简单,非常简介。

二分查找的分类


二分查找的类型可以分为

  • 寻找target值的下标
  • 寻找左侧边界
  • 寻找右侧边界
  • 寻找小于target值的最大值
  • 寻找>target值的最小值

其中
寻找左侧边界= 寻找>=target值的最小值
寻找右侧边界= 寻找<=target值的最大值

不同类型的二分代码

先直接给代码,之后再通过分析代码直接的异常点来确认为什么代码要这么写。

最简单的二分,寻找target值的下标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 /**
* 最简单的二分,不会出错,判断目标值是否在array中
* */
public int binarySearch(int[] array,int target){
int l=0;
int r=array.length-1;
while (l<=r){
int mid=(l+r)>>1;
if(array[mid]==target){
return mid;
}else if(array[mid]<target){
l=mid+1;
}else{
r=mid-1;
}
}
return -1;
}

寻找左侧边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 寻找左侧边界
* */
public int binarySearchLeft(int[] array,int target){
int l=0;
int r=array.length-1;
while (l<r){
int mid=(l+r)>>1;
if(array[mid]>=target){
r=mid;
}else{
l=mid+1;
}
}
return array[l]==target?l:-1;
}

寻找右侧边界

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 寻找右侧边界
* */
public int binarySearchRight(int[] array,int target){
int l=0;
int r=array.length-1;
while (l<r){
int mid=(l+r+1)>>1;
if(array[mid]<=target){
l=mid;
}else{
r=mid-1;
}
}
return array[r]==target?r:-1;
}

寻找小于target的最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 寻找小于target的最大值
* */
public int binarySearchLtTarget(int[] array,int target){
int l=0;
int r=array.length-1;
while (l<r){
int mid=(l+r+1)>>1;
if(array[mid]>=target){
r=mid-1;
}else{
l=mid;
}
}
return array[r]<target?r:-1;
}

寻找大于target的最小值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 寻找大于target的最小值
* */
public int binarySearchGtTarget(int[] array,int target){
int l=0;
int r=array.length-1;
while (l<r){
int mid=(l+r)>>1;
if(array[mid]<=target){
l=mid+1;
}else{
r=mid;
}
}
return array[l]>target?l:-1;
}
  1. 区间判断采用闭区间

4步掌握二分查找写法

要想写出不出错的二分查找,必须得有区间思维,带着去见思维去写代码。个人总结了4步操作

1.二分查找框架模板伪代码

掌握二分查找框架模板,之后再一步一步实现细节,一个通用的模板如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/**
* 二分查找通用伪代码
* */
public int binarySearch(int[] array,int target){
int l=0;
int r=array.length-1;
while (loopCondition(l,r)){
int mid=calcMid(l,r);
if(check(array[mid],target)){
l=changeLeft(mid);
}else{
r=changeRight(mid);
}
}
return rs;
}

其中,exitLoopCondition,calcMid,check,changeLeft,changeRight,rs等都为伪代码,需要后续去实现。

2. 确认进入循环条件

确认进入循环的条件,即确认上文伪代码中的exitLoopCondition片段。

确认循环进入条件,首先确认我们的左右查询区间。

  • 如果是$[l,r]$的闭区间,那么判断条件应该为 l<=r
  • 如果是$[l,r)$的左闭又开区间,那么判断条件应该为 l<r

至于$(l,r]$左开右闭及$(l,r)$左开右开,由于太过特例独行,本篇就不进行考虑,当人个人可以进行推导。

所以,为什么$[l,r]$的闭区间,判断条件应该为$l<=r?$,而$[l,r)$的左闭又开区间,判断条件应该为 l<r?

对于二分查找,如果区间内不为空且存在值,那么就应该进入循环继续判断。
那么进入循环的条件判断就转化为,l,r满足什么条件,区间内存在值且不为空?
这个问题只要动用简单的数学知识,就能知道。

  • 对于闭区间$[l,r]$,当边界区间内有值的条件为$l<=r$,因此当$l<=r$时,都应该进入循环。
  • 而对于左闭右开区间$[l,r)$,边界的有值条件为$l<r$

是不是感觉和之前的话一模一样,但是视角不一样了。

3. mid值的计算

mid值的计算写法有很多种,本文列举几种

1
2
3
4
5
6
7
8
9
10
# 左边界
int mid=(l+r)/2;
int mid=(l+r)>>1;
int mid=l+(r-l)/2;
int mid=l+((r-l)>>1);
# 右边界
int mid=(l+r+1)/2;
int mid=(l+r+1)>>1;
int mid=l+(r-l+1)/2;
int mid=l+((r-l+1)>>1);

tips

  1. mid值可能溢出,因此如果可以,将int升级为long类型
  2. 位运算符比除号性能更好
  3. 求右边界时,需要+1
  4. 使用位运算符,注意位运算的计算优先级

4. 区间结果判断&区间变更

本段主要确认的代码为

1
2
3
4
5
if(check(array[mid],target)){
l=changeLeft(mid);
}else{
r=changeRight(mid);
}

从代码上来说,则是确认 l=mid,r=mid-1还是l=mid-1,r=mid

还是区间的概念,只要简单分析就明白了。

对于左开右闭区间$[l,r)$,二分后的区间应该为$[l,mid)$和$[mid,r)$

4.1 确认mid的归属,归属左半边还是右半边

常见问题

边界分类

文章参考

二叉树的遍历主要有3中,前序、中序和后序。

很多人经常只记住了名字,但是记不住前序、中序和后序到底是如何遍历的。

这里我们只要记住,前序,中序和后序指的是根节点的位置即可,即(根)前序,(根)中序,(根)后序,意思就是根节点在根节点、左节点,右节点这三个节点时遍历的顺序。
(根前序)根左右

(根中序)左根右

(根后序)左右根

这样子就非常形象了。

下面,根据实际的一个二叉树举例说明

前序遍历结果为:FDBACEGIHJ

中序遍历结果为:ABCDEFGIHJ

后序遍历结果为:ACBEDHJIGF

转载

本文转自 CAP 定理的含义

前言

分布式系统(distributed system)正变得越来越重要,大型网站几乎都是分布式的。

分布式系统的最大难点,就是各个节点的状态如何同步。CAP 定理是这方面的基本定理,也是理解分布式系统的起点。

本文介绍该定理。它其实很好懂,而且是显而易见的。下面的内容主要参考了 Michael Whittaker 的文章

一、分布式系统的三个指标


1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。

1
2
3
Consistency
Availability
Partition tolerance

它们的第一个字母分别是 C、A、P。

Eric Brewer 说,这三个指标不可能同时做到。这个结论就叫做 CAP 定理。

二、Partition tolerance

先看 Partition tolerance,中文叫做”分区容错”。

大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。

上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设计的时候,必须考虑到这种情况。

一般来说,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。CAP 定理告诉我们,剩下的 C 和 A 无法同时做到。

三、Consistency

Consistency 中文叫做”一致性”。意思是,写操作之后的读操作,必须返回该值。举例来说,某条记录是 v0,用户向 G1 发起一个写操作,将其改为 v1。

接下来,用户的读操作就会得到 v1。这就叫一致性。

问题是,用户有可能向 G2 发起读操作,由于 G2 的值没有发生变化,因此返回的是 v0。G1 和 G2 读操作的结果不一致,这就不满足一致性了。

为了让 G2 也能变为 v1,就要在 G1 写操作的时候,让 G1 向 G2 发送一条消息,要求 G2 也改成 v1。

这样的话,用户向 G2 发起读操作,也能得到 v1。

四、Availability

Availability 中文叫做”可用性”,意思是只要收到用户的请求,服务器就必须给出回应。

用户可以选择向 G1 或 G2 发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是 v0 还是 v1,否则就不满足可用性。

五、Consistency 和 Availability 的矛盾

一致性和可用性,为什么不可能同时成立?答案很简单,因为可能通信失败(即出现分区容错)。

如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性不。

如果保证 G2 的可用性,那么势必不能锁定 G2,所以一致性不成立。

综上所述,G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。

在什么场合,可用性高于一致性?

举例来说,发布一张网页到 CDN,多个服务器有这张网页的副本。后来发现一个错误,需要更新网页,这时只能每个服务器都更新一遍。

一般来说,网页的更新不是特别强调一致性。短时期内,一些用户拿到老版本,另一些用户拿到新版本,问题不会特别大。当然,所有人最终都会看到新版本。所以,这个场合就是可用性高于一致性。

Base64 是网络中存储和传输的二进制数据的普遍用法。Base64 一个字节只能表示 64 种情况,且编码格式每个字节的前两位都只能是 0,使用剩下的 6 位表示内容。

看到这里相信大家也能够意识到,这种编码格式无法充分利用存储资源,效能较低。那为什么还会成为网络中的普遍用法呢?

其实 Base64 最早是应用在邮件传输协议中的。当时邮件传输协议只支持 ASCII 字符传递,使用 ASCII 码来表示所有的英文字符和数字还有一些符号。这里有一个问题,如果邮件中只传输英文数字等,那么 ASCII 可以直接支持。但是如果要在文件中传输图片、视频等资源的话,这些资源转成 ASCII 的时候会出现非英文数字的情况。而且邮件中还存在很多控制字符,这些控制字符又会成为不可见字符。非英文字符和控制字符在传输过程中很容易产生错误,影响邮件的正确传输。为此才有了诞生了一个新的编码规则,把二进制以 3 个字节为一组,再把每组的 3 个字节(24 位)转换成 4 个 6 位,每 6 位根据查表对应一个 ASCII 符号,这就是 Base64。

Base64 将 8 位为一个单元的字节数据,拆分为 6 位为一个单元的二进制片段。每一个 6 位单元对应 Base64 索引表中的一个字符。简单举个例子,下图中 M 的 ASCII 码是 77 , 而转换为二进制后前六位二进制对应值为 19,为 Base64 字典中的 T。

当然这里也会有一个问题,如果要编码的二进制数据不是 3 的倍数,那就会剩下一至二个字节。为此 Base64 使用 000000 字节值在末尾补足,使其字节数能够被 3 整除,补位用 = 表示,= 的个数可表示补了多少字节,并在解码时自动去除。总体来看相比编码前,Base64 编码后的字符增加了约 33%。


IDEA整合LeetCode的插件,有了这个插件,可以在IDEA本地编辑代码并且运行提交,还能关联自己的账号,简直实用之极。看网上介绍的都不太详细,我来写个清楚点的。插件如图:

一:下载插件

点击intelij Idea->Preferences->Plugins:

搜索leetcode下载就行了。如果你的搜不到,可以尝试重新打开Setting重新搜,还没有的话,可以去官网插件库下载,然后导入就可以了。链接:https://plugins.jetbrains.com/plugin/12132-leetcode-editor

二:配置

安装完成之后,点击IdealiJ idea->Preference->Tools->leetcode plugin,

也可以点击右下角的leetcode图标

配置界面如图:

注意:上图中TempFilePath对应的文件夹一定要是你此项目模块源码的位置 。我的新建一个项目的意思是,像我那样重新建一个名为“LeetCode”的项目,然后选择其src目录,评论区红色无效文件可能就是这个原因。

关于下面几个参数的定义,官方给的是:

  • Custom code template: 开启使用自定义模板,否则使用默认生成格式

  • CodeFileName: 生成文件的名称,默认为题目标题

  • CodeTemplate: 生成题目代码的内容,默认为题目描述和题目代码

  • TemplateConstant: 模板常用变量

  • ${question.title}:题目标题,例如:两数之和

  • ${question.titleSlug}:题目标记,例如:two-sum

  • ${question.frontendQuestionId}:题目编号,例如:1

  • ${question.content}:题目描述内容

  • ${question.code}:题目代码部分

  • $!velocityTool.camelCaseName(str):一个函数,用来将字符串转化为驼峰样式

CodeFileName这个里面填的就是以后自动生成类的类名,使用我的这个配置刚好可以

1
P$!{question.frontendQuestionId}$!velocityTool.camelCaseName(${question.titleSlug})

CodeTemplate就是自动生成的代码格式,对于有强迫症的人来说,这个自动生成的格式就非常重要了,不然看着心里就烦。其中main()方法是用来debug的。我的配置(如果复制过去格式不对,请手动改成我这样的,空行也不要删):

1
2
3
4
5
6
7
8
9
10
package leetcode.editor.cn;
${question.content}
public class $!velocityTool.camelCaseName(${question.titleSlug}){undefined
public
static
void main(String[] args) {undefined
Solution solution = new $!velocityTool.camelCaseName(${question.titleSlug})().new Solution();
}
${question.code}
}

就这样自动生成的代码是这样的,个人觉得还可以:

注意:

在生成的自定义代码中包含两行关键信息:

  • leetcode submit region begin(Prohibit modification and deletion):提交到leetcode进行验证的代码开始标记

  • leetcode submit region end(Prohibit modification and deletion):提交到leetcode进行验证的代码结束标记

这两行标记标示了提交到leetcode服务器进行验证的代码范围,在此范围内只允许有出现与题目解答相关的内容,出现其他内容可能导致leetcode验证不通过。

除了此范围内,其他区域是可以任意填写的,内容不会提交到leetcode,可以增加一些可以本地调试的内容,例如:import java.util.Arrays;

所以,这两行内容是不能被删除和修改的,否则将识别不到提交的内容。

补充:

如图中的文档注释中的类,没有快捷键可以一次性取消,如果一行一行删又太费事 ,我们可以用这个方法。

光标放在这里,按下Alt+鼠标左键,就可以对多行进行删除,简单方便。

三:使用

点击左下角的按钮,然后点击上面的小地球进行联网登录,登陆成功就是图中的画面了。双击题目,就会自动创建类

写完代码,右键

如图,就可以运行测试和提交了,在下面的Event Log可以查看运行情况

spark 的启动流程?

shell端的启动流程

以一个常规的wordcount 程序spark启动命令为例,spark在shell端的启动流程如下

  1. 首先,用户在shell端提交spark提交命令,一个常见的workcount提交命令如下
1
2
3
4
5
./spark-submit --master yarn-client \
--class com.example.spark.WordCount \
--executor-memory 1G \
--total-executor-cores 2 \
/opt/wordcount.jar hdfs://hacluster/aa/hello.tx

该命令表示像yarn提交一个spark程序进行运行,在shell端的执行流程如下。

  1. spark-submit 比较简单,直接将shell后面的参数转交给spark-class,并告诉spark-class以后运行的java类为 org.apache.spark.deploy.SparkSubmit。
1
exec "${SPARK_HOME}"/bin/spark-class org.apache.spark.deploy.SparkSubmit "$@"
  1. 此时程序在spark-class执行
  • spark-class会对环境变量做一些简单的查找和配置,如设置SPARK_HOME,SPARK_JARS_DIR,JAVA_HOME等
  • 调用build_command方法获取最终要执行的java命令,该命令会调用org.apache.spark.launcher.Main

build_command的代码如下

1
2
3
4
build_command() {
"$RUNNER" -Xmx128m -cp "$LAUNCH_CLASSPATH" org.apache.spark.launcher.Main "$@"
printf "%d\0" $?
}

该代码将shell后面的参数传入org.apache.spark.launcher.Main,获取执行结果

  1. org.apache.spark.launcher.Main,该类根据传入的参数,拼装classpath,jar等参数,返回最终要执行的java命令给spark-class
  • 输入:shell后面自带的参数,如
    –class com.example.spark.WordCount
    –executor-memory 1G
  • 输出我们看到的最终执行的java命令,如
    1
    2
    3
    4
    /opt/java/bin/java -cp /opt/spark/conf/:/opt/spark/jars/* \
    -Xmx1g org.apache.spark.deploy.SparkSubmit --master yarn \
    --class com.example.spark.WordCount --executor-memory 1G \
    --total-executor-cores 2 /opt/wordcount.jar hdfs://hacluster/aa/hello.tx
  • 执行过程
    Main类通过一个标准的建造者模式,传入参数,构建AbstractCommandBuilder
    1
    2
    3
    4
    AbstractCommandBuilder builder;
    if (className.equals("org.apache.spark.deploy.SparkSubmit")) {
    try {
    builder = new SparkSubmitCommandBuilder(args);
    最后传入env返回cmdList,通过对应系统的prepareCommand输出,由spark-class shell接收回参数
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    List<String> cmd = builder.buildCommand(env);
    if (printLaunchCommand) {
    System.err.println("Spark Command: " + join(" ", cmd));
    System.err.println("========================================");
    }

    if (isWindows()) {
    System.out.println(prepareWindowsCommand(cmd, env));
    } else {
    // In bash, use NULL as the arg separator since it cannot be used in an argument.
    List<String> bashCmd = prepareBashCommand(cmd, env);
    for (String c : bashCmd) {
    System.out.print(c);
    System.out.print('\0');
    }
    }
  1. spark-class 执行最终的启动命令
    1
    2
    CMD=("${CMD[@]:0:$LAST}")
    exec "${CMD[@]}"

至此,在shell端的代码执行完毕,spark程序进入真正运行的java端代码

背景

当前数仓表的生命周期策略数据生命周期管理规范,除个别表外,无条件对所有表采用93天快照策略,数据重复存储,造成存储压力大。

现状:共xxx张表,占用xxx张空间,平均每张xxx存储。

基于合理利用存储,控制存储无序扩展,节约公司存储成本,需要该表快照策略,由快照策略改为拉链策略。

说明

本方案所说拉链,指的是替换快照方式日粒度的存储拉链,有别于基于业务流水数据设计的拉链。

方案比较

快照vs拉链

简洁版

比较项 快照 拉链
写入 无需加工 需要加工
使用 无成本 理解成本高
存储 126份副本 2~5份副本
计算 去重&排序
适用 所有 有主键&数据变更时间

明细版

比较项 快照 拉链
写入 快照直接按数据日期写入,简单易懂 拉链需要结合当天最新数据和历史拉链数据加工出最新数据,略微复杂
使用 快照关联历史直接按数据日期取多版本,简单易懂 拉链需要根据业务日期和开始时间及结束时间比较,确定唯一版本,使用成本高
存储 快照为93天周期+每月最后一天,以3年为例,共计126份副本 拉链只有在数据变更才会重复存储,根据数据量变更频繁程度,预估2~5份副本
计算 无计算 需要根据id和创建时间和结束时间进行去重和排序,进而计算出开始时间和结束时间
适用 所有 需要有主键&数据变更时间

总结

快照方案简单粗暴有效,拉链方案复杂优雅有效。

加工和使用成本上,可以认为拉链是快照方案的2~5倍

存储成本上,可以任务拉链是快照方案的 1/30 之一

前提条件

并非所有的表都可以做成拉链表来存储历史记录,业务表必须满足一下几点方可设计拉链

条件 解释 现状
有唯一主键id 业务库必须有唯一主键id用于标识同一条记录
这样才能对同一条记录进行版本区分
开发规范里有明确必须有主键id
不排除个别表
有业务数据变更时间 如果数据字段发生变更,然而数据时间不进行变更会导致拉链表无法确认时间 开发规范里有明确edit_time需要自动更新
历史老表存在不更新现象,需要在mysql表结构中确认是否自动更新

设计

统一规范

1)无穷大的结束时间,统一采用 29991231 表示
2)无穷小的开始时间,统一采用 20000101表示
3)拉链表统一新增三个字段
之所以加上data前置,为了防止字段重名

字段名 解释
data_start_date 数据生效开始日期
data_end_date 数据生效结束日期
data_is_active 数据是否当前有效
4)拉链表分区统一使用三个字段进行分区
其中data_start_year和data_end_year是为了加快使用效率
dayid是为了获取昨天版本
字段名 解释
———– ———–
data_start_year 数据开始年份
data_end_year 数据结束年份
dayid 数据日期

5)data_start_date和data_end_date采用闭区间设计
6)拉链表的生命周期为2,即保留两份副本
7) 命名统一采用 xxx_fds命名
8)当前拉链表只针对dwd进行设计

设计举例

mysql 举例表结构

表名:test_a

字段 解释 其他
id 主键id pk
test_name 测试名称
create_time 创建时间
edit_time 编辑时间

mysql 举例表数据

20210701数据

日期 id test_name create_time edit_time
20210701 1 what’s your name 20210701 20210701
20210701 2 what’s your age 20210701 20210701
2021072 1 what’s wrong 20210701 20210702
20210702 2 what’s your age 20210701 20210701
20210710 1 whattttttttttt 20210701 20210710
20210710 2 what’s your age 20210701 20210701

拉链表代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
set hive.exec.dynamic.partition=true;
set hive.exec.dynamic.partition.mode=nostrick;

-- 通用字段
set hivevar:com_columns=id,test_name,create_time,edit_time;

-- 原始dwd表名称
set hivevar:dwd_table_name=dwd_test_a_d;

-- fds全量拉量表名称
set hivevar:fds_table_name=dws_test_a_fds;

-- 主键id,分区表id会重复,注意
set hivevar:pk_id=id;



create table if not exists ${fds_table_name}
(
data_start_date string comment '开始日期'
,data_end_date string comment '结束日期'
,id bigint COMMENT '自增主键'
,test_name string COMMENT '测试名称'
,create_time string COMMENT '表记录创建时间'
,edit_time string COMMENT '表记录修改时间')
partitioned by (dayid string comment '数据日期',data_is_active tinyint comment '数据是否当前最新版本'
,data_start_year string comment '起始年',data_end_year string comment '结束年')
stored as orc;





--删除旧分区,防止脏数据无法删除
ALTER TABLE ${fds_table_name} DROP if exists PARTITION (dayid='$v_date');

--去重后的全量数据
with full_data as (
select dayid
,${com_columns} --字符串会被替换,需要转义
from
(select dayid
,${com_columns}
from ${dwd_table_name} --获取当天最新数据
where dayid='$v_date'
and substr(create_time,1,8)<='$v_date'

union all

select data_start_date as dayid
,\${com_columns}
from \${fds_table_name} -- 从历史拉链表获取历史所有数据
where substr(create_time,1,8)<='$v_date'
and dayid=date_format(date_sub(to_date('$v_date','yyyyMMdd'),1),'yyyyMMdd')
) a
group by dayid,\${com_columns}
)
--清洗数据,使数据合规,我们做的是天粒度的拉链,同一个主键id一天取最后一条记录
--之所以要清洗,理论上同一个id一天是会存在多条记录的,比如记录在1号03分变更,然后在1号10分抽取,然后在1号19:08分变更,然后在2号10分抽取
,legal_data as (
select *
from
(select *
,row_number() over(partition by ${pk_id},substr(edit_time,1,8) order by edit_time desc,dayid desc) as data_rank -- 加入dayid排序,为修改值,但是eidt未改变的值,取最新的数据
from full_data
) a
where data_rank=1 --同一个主键id一天取最后一条记录
)
--日粒度的拉链
,date_data_ds as (
select if(lag_edit_date is null,substr(create_time,1,8),substr(edit_time,1,8)) as data_start_date --如果上一条编辑时间为空,说明是第一条,采用创建日期作为开始日期,不然采用本条编辑时间作为开始时间
,case when lead_edit_date is null and dayid='$v_date' then '29991231' --如果下一条编辑时间为空,且该条记录分区时间为最新分区,说明是最后一条,采用默认29991231作为结束时间,不然采用下一条编辑时间作为结束时间
when lead_edit_date is null and dayid<'$v_date' then if(substr(edit_time,1,8)>=dayid,substr(edit_time,1,8),dayid) -- 如果下一条编辑时间为空,该表记录分区不是最新分区,说明该记录在最新分区中已被删除,该记录的修改时间和分区时间取最大值。 该判断为兼容记录物理删除骚操作,取最大值为防止dwd重刷导致的数据错乱
else lead_edit_date -- 下一条记录存在,采用下一条编辑时间作为结束时间
end as data_end_date
,if(lead_edit_date is null and dayid='$v_date',1,0) as data_is_active --没有下一条,说明是最新的一条
,\${com_columns}
from
(select *
,lead(date_format(date_add(to_date(substr(edit_time,1,8),'yyyyMMdd'),-1),'yyyyMMdd'),1,null) over (partition by ${pk_id} order by create_time,edit_time) as lead_edit_date --下一条记录的编辑日期
,lag(substr(edit_time,1,8),1,null) over (partition by ${pk_id} order by create_time,edit_time) as lag_edit_date --上一条记录的编辑时间,由于开始时间和结束时间是区间包含,edit是作为结束时间的闭区间,因此需要上一条日期+1作为开始时间
--,last_value(edit_time) over (partition by id order by create_time,edit_time) as last_edit_time --分组内最后一条记录编辑日期
from legal_data
) a

)


insert overwrite table ${fds_table_name} partition (dayid,data_is_active,data_start_year,data_end_year)
select data_start_date
,data_end_date
,${com_columns}
,dayid
,data_is_active
,data_start_year
,data_end_year
from
(select data_start_date
,data_end_date
,data_is_active
,${com_columns}
,substr(data_start_date,1,4) as data_start_year
,substr(data_end_date,1,4) as data_end_year
,'$v_date' as dayid
from date_data_ds
) a
;

拉链表数据效果

|数据日期| data_start_date| data_end_date| data_is_active| id| test_name| create_time| edit_time| data_start_year| data_end_year|
|—–| —–| —–| —–| —–| —–| —–| —–| —–| —–|
|20210701| 20210701| 29991231| 1| 1| what’s your name| 20210701| 20210701| 2021| 2999|
|20210701|20210701 |29991231 |1 |2 |what’s your age| 20210701| 20210701| 2021| 2999|
|20210702| 20210701| 20210701| 0| 1| what’s your name| 20210701| 20210701| 2021| 2021|
|20210702|20210702| 29991231| 1| 1| what’s wrong| 20210701| 20210702| 2021| 2999|
|20210702|20210701| 29991231| 1| 2| what’s your age| 20210701| 20210701| 2021| 2999|
|20210710| 20210701| 20210701| 0| 1| what’s your name| 20210701| 20210701| 2021| 2021|
|20210710|20210702| 20210709| 0| 1| what’s wrong| 20210701| 20210702| 2021| 2021|
|20210710|20210710| 29991231| 1| 1| whattttttttttt| 20210701| 20210710 |2021| 2999|
|20210710|20210701 |29991231 |1| 2| what’s your age| 20210701| 20210701| 2021| 2999|

拉链表的使用

获取当前最新数据

1)优先使用最新表而非快照表

2)一定要用快照: sql select * from dwd_test_a_fds where dayid='cur_time' and data_is_active=1

获取某一天的状态

1
2
3
4
5
6
select * from dwd_test_a_fds
where dayid='cur_time'
and data_start_year<=substr('$v_date',1,4) --索引查询
and data_end_year>=substr('$v_date',1,4)
and data_start_date<='$v_date'
and data_end_date>='$v_date

根据业务字段字段进行匹配

1
2
3
4
5
6
7
8
select a.*
from
(select * from test_test where dayid='$v_date') a
left join
(select * from dwd_test_a_fds where dayid='cur_time') b
on a.biz_id=b.id
and substr(a.biz_time,1,8)>=b.data_start_time
and substr(a.biz_time,1,8)<=b.data_end_time

效果

待测试,暂定dwd_order_shop_full_d 表

流程