cover_image

聊一聊“时间”

三七互娱技术团队
2022年08月08日 10:35

💡

  • 一、时间是人为的定义

    • 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流程时一次性查出,后续写入前对比只需要在内存中获取数据即可。

典型的空间换时间,这种场景应该也是比较常见的。


七、结语

诗仙李白《将进酒》中有句关于时间的诗句:

君不见,黄河之水天上来,奔流到海不复回。

君不见,高堂明镜悲白发,朝如青丝暮成雪。


黄河之水天上来,把空间拉得无限长;朝如青丝暮成雪,把时间拉得无限长。

愿你能与李白的诗句一般,在这漫漫时间长河中,找到时间的密钥。


继续滑动看下一个
三七互娱技术团队
向上滑动看下一个