💡
一、时间是人为的定义
1.1 时间之喜
1.2 时间之忧
二、编程语言中的时间
同步与异步
流处理与批处理
32位系统与程序的末日
64位系统与程序的狂欢
2.1 时区
2.2 时间戳
2.3 贯穿始终的时间线
三、日志与时间
3.1 最佳搭档
3.2 日志标准化
四、时间的格式化
五、实际业务中的时间场景
5.1 时间是天然的分区
5.2 同比
5.3 环比
5.4 第一天与最后一天
5.5 月份差
5.6 SQL中的时间维度:日、周、月、季度、年
六、聊聊踩过的坑
6.1 SQL中跨年第n周问题
6.2 月、季度、年的环比周期问题
6.3 ETL流程中的查询耗时问题
七、结语
一、时间是人为的定义
时间是人类用来描述外在世界物质运动、事件发生过程的一个参数,时间这个看不见摸不着的东西,由人类生产和生活的需要而定义。
或许熟悉物理学的同学会反驳,毕竟在物理世界里,时间是非常关键的因素,它是确实存在的并作为重要的因素应用在物理学公式上,许多这样的公式和原理已在服务于人类的生产和生活。
实际上这并不相悖,“时间”只是一个定义,日常生活与科研攻关,只是使用者不同罢了。
1.1 时间之喜
人类自从定义了时间,便以时间作为生产的标尺,把一年分为四季、十二个月、三百六十五天,农业可以适时而作,工业可以最大化生产效率。随着一次次工业革命,人类的生产力呈指数级上涨,带来了更多的物质满足,为消除贫困带来了希望。
1.2 时间之忧
凡事都有两面性,人类对于时间的利用,可谓是喜忧参半。以时间为标尺,推动着整个人类社会不断向前,人们追赶着时间,“卷”得身不由己,却又无可奈何。
当然,本文并不是为了探讨物理世界中的时间的来龙去脉,而是想借着聊聊时间在编程过程中的场景,找到时间应用的共性,试图去解决编程过程中时间带来的难题。
二、编程语言中的时间
2.1 时区
因为日出时间的不同,人为将地球划分为24个时区。
当我们所涉及的业务场景,需要走出国门,面向其他国家或地区的时候,由时区引发的各种问题和困扰会随之而来。
比如:跨时区结算、多时区日志时间等问题。
2.2 时间戳
我们常用的是Unix时间戳,表示从1970年1月1日午夜开始至今的秒数。
Unix时间戳是定长的,那么它就有一个极限,那它的极限是什么呢?到达极限了会发生什么?
32位系统与程序的末日
32位系统与程序,Unix时间戳最多可以使用到格林威治时间2038年01月19日03时14分07秒(二进制:01111111 11111111 11111111 11111111)。
其后一秒,二进制数字会变为10000000 00000000 00000000 00000000,发生溢出错误,造成系统将时间误解为1901年12月13日20时45分52秒。
这很可能会引起软件故障,甚至是系统瘫痪。
64位系统与程序的狂欢
使用64位二进制数字表示时间的系统或应用,最多可以使用到格林威治时间292,277,026,596年12月04日15时30分08秒,因此基本不会遇到这类溢出问题。
目前64位系统已经比较普及,但是仍旧有许多32位的应用,这些应用同样是有溢出错误的风险的。
2.3 贯穿始终的时间线
不管是编程开发还是数据处理, 时间总是贯穿始终。
同步与异步
在编程中,我们时常会碰到同步或是异步的场景。
同步要求过程是结果实时、过程环环相扣的、串行的,一步一步按顺序执行完成,直至全部完成,最终得到预期结果。
异步则是对于结果允许一定的延迟,旨在非阻塞的场景下,最大化地去发起事件处理,最终通过事件处理端主动回调来更新事件结果,以此达到最终结果一致性。
选择同步与异步,取决于业务场景对于时间的预期,如果条件允许,应最大化使用异步,以获得更大的吞吐。
在易览实际的业务中,本季度对易览文章标签算法抽取的改造,就是从同步改为异步,最终取得了比较好的效果:
标签算法抽取不再阻塞现有任务,天翼ETL任务堆积得到缓解
标签抽取请求效率提高,只需发起算法接口请求即收到反馈,由算法后置回调再更新标签,最终展示文章
最新的异步流程如下图:
流处理与批处理
流与批,这两个概念主要是来自于大数据处理。简单理解流处理和批处理,可以理解为:
流处理,是实时的,线性的有序的,数据被获取后需要立刻处理,不可能等到所有数据都到达后再进行处理。
批处理,是延时的,对数据的顺序并没有强要求的,在批处理模式下,首先将数据流持久化到存储系统(文件系统或对象存储)中,然后对整个数据集的数据进行读取、排序、统计或汇总计算,最后输出结果。
这两个模型,在时间的支配下,我们应该针对不同的场景,选择不同的模型。
三、日志与时间
3.1 最佳搭档
日志与时间,就像形与影,紧紧相依,不能分离。
你能想象一份日志操作,没有记录时间信息吗?这简直是灾难。
3.2 日志标准化
在我们日常编程开发中,写日志是最常见的操作了,而我们往往很容易陷入一个泥沼当中,这个泥沼是什么呢?就是日志格式的混乱。
我们可能在A脚本调试的时候,打印日志是纯字符输出异常捕获信息,而在B脚本打印的是json格式的提示,随着越来越多的脚本,排查日志的工作简直是大海捞针。
我在技术中心见过比较好的日志标准化,是海纳。标准化日志格式与上报方式,统一入口进行日志筛查,让开发人员享受日志收敛带来的好处,节省了大量的排查时间。
四、时间的格式化
在不同的编程语言当中,对于时间的处理总是常见的,最常见的是对时间的格式化操作。
像PHP的 date 函数,Python的 time 模块,Golang的 time 包等等。
五、实际业务中的时间场景
5.1 时间是天然的分区
曾经我做过游戏日志数据的存储与处理,尽管数据量并不大(3亿的数据行),但是只存在Mysql当中,即便建立了联合索引,查询起来还是比较吃力的。
为了最大化保持程序逻辑不变,最终选择了表分区,在如何选择分区键上进入了一番争论:
有人说使用游戏服ID作为分区键,这样不同的游戏服的数据会落入对应的分区,对于查询单个游戏服数据的查询来说能直接命中分区。
有人说使用渠道ID作为分区键,这样查询筛选项选择渠道时,能直接命中分区。
这些都有一定道理,也确实存在部分场景是吻合的,但是却忽视了一个最重要的因素,那就是时间。
尽管不是所有的分区表都推荐用时间作为分区键,但是对于使用者,他们查询报表的时候,更加普遍性的还是选择时间段,然后其次才是组合上游戏服、渠道以及其他条件进行查询。
那么最终设计的结果,就是“时间作为分区键,查询命中分区之后,根据查询条件命中分区中的组合索引”。
下图主要就是阐述了这样一个根据使用场景的推导过程,当时间因素是每次查询都必选的时候,时间就是天然的分区:
5.2 同比
在易览系统中,对于收入、下载数据,有一个常见的使用场景,就是要将当前选择的月份数据与去年同期进行比较。
这个比较的过程,就是同比,即同期月份数据对比。
5.3 环比
与同比类似的业务场景,但是却有另一个时间维度的比较需求,需要将当前选择的月份与上一个月进行比较。
这个比较的过程,就是环比,即连续2个周期的数据对比。
5.4 第一天与最后一天
你是否碰到过这样的需求:
需要获取本周,第一天与最后一天的日期
需要获取本月,第一天与最后一天的日期
需要获取本季度,第一天与最后一天的日期
再进阶的需求可能是:
需要获取指定某一天那一周,第一天与最后一天的日期
需要获取指定某一天那一个月,第一天与最后一天的日期
需要获取指定某一天哪一个季度,第一天与最后一天的日期
这其中涉及的难点主要在于:
(1)每个月的天数是不一致的,且存在闰年,2月份的天数会变化
(2)指定某一天,需要换算进去对应的时间周期,才能算出当时周期的起始
5.5 月份差
有两个日期,需要算出他们之间的相差的月份,不满一个月的按一个月算。
看似简单的需求,实现起来却并不容易。
难点在于:
(1)跨年结算,月份差值会有偏差;
(2)无法简单的相减,需要根据相差的年份计算之后,才能计算月份差。
以下是PHP实现月份差的代码函数,仅供参考,如有更好解决方案,万望不吝赐教:
/**
* 计算两个日期的月份差值(不足一个月按一个月计算)
*/
public static function diffMonth($start, $end) {
$starY = date("Y",strtotime($start));
$starM = date("n",strtotime($start));
$starD = date("j",strtotime($start));
$nowY = date("Y",strtotime($end));
$nowM = date("n",strtotime($end));
$nowD = date("j",strtotime($end));
$diffM = 0;
if ($starY == $nowY) {
if ($starM == $nowM) {
if ($starD < $nowD) {
$diffM = 1;
} elseif ($starD = $nowD) {
$diffM = 0;
} else {
$diffM = false;
}
} elseif ($starM < $nowM) {
if ($starD < $nowD) {
$diffM = $nowM - $starM + 1;
} else {
$diffM = $nowM - $starM;
}
} else {
$diffM = false;
}
} elseif ($starY < $nowY) {
$diffY = $nowY - $starY;
if($starD < $nowD) {
$diffM = (12 - $starM + $nowM + 1) + 12 * ($diffY - 1);
} else {
$diffM = (12 - $starM + $nowM) + 12 * ($diffY - 1);
}
} else {
$diffM = false;
}
return $diffM;
}
5.6 SQL中的时间维度:日、周、月、季度、年
以易览核心数据表中的数据时间为例,数据时间是 int 类型,格式为:YYYYMMDD。
以Clickhouse上的日、周、月、季度、年的维度处理方式,分别是:
日(YYYY-MM-DD)
formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y-%m-%d')周(第W周)
周(第W周)
concat(substring(toString(toYearWeek(toDate(formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y-%m-%d')), 1)), 1, 4), '-', substring(toString(toYearWeek(toDate(formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y-%m-%d')), 1)), 5, 2), '周')
月(YYYY-MM)
formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y-%m')
季度(YYYY-Q1/2/3/4)
concat(formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y'),'-Q',toString(toQuarter(parseDateTimeBestEffort(toString(APP_DAY)))))
年(YYYY)
formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y')
以上处理方式,例如“周”的为何这么长?且看下一章节对于这个坑的解释吧。
六、聊聊踩过的坑
6.1 SQL中跨年第n周问题
这个场景主要是在SQL中,按周的维度聚合查询数据的时候发现的,其主要表现是:
上一年最后一周部分跨入新的一年
以Clickhouse为例,看看下面这个截图应该就知道是怎么回事了:
格式化的时候,当%Y与%V同时使用时,SQL编译器只是简单帮你把年和周拼起来,最终出来个新一年+旧一年的周的结果。
于是进行优化,查阅对应的Clickhouse版本,发现新版本可以通过将 %Y-%V 改为 %Y-%G 即可修复这个问题。但是,这里有个但是,低版本的Clickhosue 19并不支持 %G。
这就尴尬了,这可咋整,于是与组内外的同事进行交流,其中有一个方法是写case when判断是否是具体某一年,比如2021则进行加减。但是这个方法需要每一年都进行代码修改,一旦忘了改就会数据出错,并不是最理想的办法。
于是另辟蹊径,最终通过这种方式成功匹配:
concat(substring(toString(toYearWeek(toDate(formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y-%m-%d')), 1)), 1, 4), '-', substring(toString(toYearWeek(toDate(formatDateTime(parseDateTimeBestEffort(toString(APP_DAY)), '%Y-%m-%d')), 1)), 5, 2), '周')
效果图:
6.2 月、季度、年的环比周期问题
由于月、季度、年,这3种时间维度的天数是不固定的,因此在进行环比计算的时候,需要进行特殊的处理,否则就会出现环比周期多几天或者少几天的情况。
易览的环比概念,实际上跟通用的环比概念又有些许不同,易览支持不是整月的方式进行环比,意味着可以任意选择连续的几天、几周、几个月、几个季度、几年进行环比计算。
难点在于:
月、季度、年天数不固定
环比自由度高,可以跨多个周期
最终,对这3个维度中最小维度“月”进行详细分析,找出月份中的特殊逻辑,实现了自由时间的环比计算。
可以参考以下PHP的函数:
/**
* get_hb_date 根据当前时间段返回 n 个时间段:当前时间段/当前时间往前推 n-1 个环比时间段
* @param $start
* @param $end
* @param int $period
* @return array
*/
public static function get_hb_date($start, $end, $period = 1, $time_type = 'day')
{
$data = array();
// 月、季度、年,每个周期时间不固定,特殊逻辑判断
switch ($time_type) {
case 'month' :
$data['end_0'] = $end;
$data['start_0'] = $start;
for ($i=1; $i <= $period; $i++) {
$last = $i-1;
$month_diff = self::diffMonth($data['start_'.$last], $data['end_'.$last]);
$data['end_'.$i] = date('Y-m-d', strtotime($data['start_'.$last]."-1 day"));
$data['start_'.$i] = date('Y-m-d', strtotime(date("Y-m-d", strtotime("last day of -{$month_diff} month", strtotime($data['end_'.$i])))."+1 day"));
}
break;
case 'quarter' :
$data['end_0'] = $end;
$data['start_0'] = $start;
for ($i=1; $i <= $period; $i++) {
$last = $i - 1;
$month_diff = self::diffMonth($data['start_'.$last], $data['end_'.$last]);
$data['end_'.$i] = date('Y-m-d', strtotime($data['start_'.$last]."-1 day"));
$data['start_'.$i] = date('Y-m-d', strtotime(date("Y-m-d", strtotime("last day of -{$month_diff} month", strtotime($data['end_'.$i])))."+1 day"));
}
break;
case 'year' :
$data['end_0'] = $end;
$data['start_0'] = $start;
for ($i=1; $i <= $period; $i++) {
$last = $i - 1;
$year_diff = date('Y', strtotime($data['end_'.$last])) - date('Y', strtotime($data['start_'.$last]));
$data['end_'.$i] = date('Y-m-d', strtotime($data['start_'.$last]."-1 day"));
$data['start_'.$i] = date('Y-01-01', strtotime($data['end_'.$i]."-{$year_diff} years"));
}
break;
default :
// day、week 由于符合周期固定,因此使用一套规则
$day_diff = (strtotime(date('Ymd', strtotime($end)))-strtotime(date('Ymd', strtotime($start))))/86400; //前后日期差
$data['end_0'] = $end;
$data['start_0'] = $start;
for ($i=1; $i <= $period; $i++) {
$last = $i-1;
$data['end_'.$i] = date('Y-m-d', strtotime($data['start_'.$last]."-1 day"));
$data['start_'.$i] = date('Y-m-d', strtotime($data['end_'.$i]."-{$day_diff} days"));
}
break;
}
return $data;
}
/**
* 计算两个日期的月份差值(不足一个月按一个月计算)
*/
public static function diffMonth($start, $end) {
$starY = date("Y",strtotime($start));
$starM = date("n",strtotime($start));
$starD = date("j",strtotime($start));
$nowY = date("Y",strtotime($end));
$nowM = date("n",strtotime($end));
$nowD = date("j",strtotime($end));
$diffM = 0;
if ($starY == $nowY) {
if ($starM == $nowM) {
if ($starD < $nowD) {
$diffM = 1;
} elseif ($starD = $nowD) {
$diffM = 0;
} else {
$diffM = false;
}
} elseif ($starM < $nowM) {
if ($starD < $nowD) {
$diffM = $nowM - $starM + 1;
} else {
$diffM = $nowM - $starM;
}
} else {
$diffM = false;
}
} elseif ($starY < $nowY) {
$diffY = $nowY - $starY;
if($starD < $nowD) {
$diffM = (12 - $starM + $nowM + 1) + 12 * ($diffY - 1);
} else {
$diffM = (12 - $starM + $nowM) + 12 * ($diffY - 1);
}
} else {
$diffM = false;
}
return $diffM;
}
6.3 ETL流程中的查询耗时问题
主要表现为在ETL处理流程中,在数据写入之前,需要先查一遍进行数据校验和补充。
原来的逻辑是在批量数据循环每一条数据的时候都查一次,再写入,如此反复。
这样的逻辑是有问题的,问题在于如果有一千条数据,则需要查询一千次,单单连接的消耗都非常耗时了。
最终的方案,实际上就是对时间的把控,将这一批的数据,在进入ETL流程时一次性查出,后续写入前对比只需要在内存中获取数据即可。
典型的空间换时间,这种场景应该也是比较常见的。
七、结语
诗仙李白《将进酒》中有句关于时间的诗句:
君不见,黄河之水天上来,奔流到海不复回。
君不见,高堂明镜悲白发,朝如青丝暮成雪。
黄河之水天上来,把空间拉得无限长;朝如青丝暮成雪,把时间拉得无限长。
愿你能与李白的诗句一般,在这漫漫时间长河中,找到时间的密钥。