SQL 正在消耗数据科学家的生命

SQL 应用很广,数据科学家(分析师)们在日常工作中经常需要用到 SQL 语言进行数据查询和处理。很多企业也认为只要 IT 部门建好数据仓库(数据平台),提供了 SQL 功能,数据科学家们就能自由地查询分析企业数据了。

表面上看确实是这样,有了 SQL,数据科学家们就可以对数据进行查询计算。而且, SQL 很像英语,看起来很容易上手,有些简单的 SQL 语句甚至直接可以作为英语读。

比如过滤:

Select id,name from T where id=1

这跟英语 Query id and name from T if id equals 1 几乎一致。

分组汇总:

Select area,sum(amount) from T group by area

跟英语表达 Summarize amount by area from T 也十分接近。

像英语(自然语言)有一个显著的好处,那就是:简单。使用自然语言的方式就能实现数据查询,这样即使业务性人员(数据科学家们常常就是熟悉业务而对 IT 技术没那么精通的人群)也能掌握。这正是 SQL 如此设计的初衷:让普通业务人员也能使用。

实际情况怎么样呢?

如果都是这种简单的分组、过滤计算,一般业务人员的确都能掌握,SQL 写起来也很简单。但数据科学家们面临的业务场景通常可能会是这样的:

  • 根据销售数据找出销售额占到总销售额的 50% 和 80% 的前 n 个客户,以便进行精准营销
  • 根据连锁餐饮门店的顾客数量、消费金额、消费时间、消费地点等数据,分析哪些门店最受欢迎,哪些时间段最繁忙,哪些菜品最受欢迎
  • 根据汽车销售数据分别计算每个型号的销售量、销售额、平均价格、销售地区等,分析哪些型号最受欢迎,哪些型号需要调整价格或改进设计
  • 根据股票交易数据,挑选发生过连续三个交易日涨停(涨幅 >=10%)的股票,用于构建投资组合
  • 根据某只股票行情数据,分析该股票最长连续上涨了多少天,以评估其历史表现
  • 根据游戏登录数据进行用户分析,列出用户首次登录的记录、用户最近一次登录间隔,并计算用户在最后一次登录前三天内的登录次数
  • 根据客户的账户余额、交易历史和信用评级等信息,评估客户是否会逾期还款,并识别哪些客户最有可能逾期
  • 根据患者的病历、诊断结果和治疗方案等数据,识别哪些患者最需要进行预防和治疗
  • 根据运营商的客户通话记录、短信记录和流量使用情况等信息,计算客户的月均通话时长、流量使用量、用量高峰时段,并识别哪些客户是高消费客户。
  • 根据电商用户行为数据进行漏斗分析,分别计算浏览商品、加购物车、下单、付款等环节每个步骤的用户流失率
  • 根据电商客户的购买历史和偏好等数据分析,将客户划分购买力强、偏好女装、偏好男装等不同群体,以方便针对不同群体进行不同的促销活动
  • ……

我们暂时举这些例子,实际业务中还要多得多。通过这些例子我们可以看出,大部分有业务意义的分析都是这种,而不是简单的过滤分组。而对于这样的分析我们用 SQL 完成就不太容易了,有些甚至接近写不出。这里我们试着写几个看看实现难度。

找出销售额占到总额一半(50%)的前 n 个客户,并按销售额从大到小排序

with A as
 (select customer, amount, row_number() over(order by amount) ranking
    from orders)
select customer, amount
  from (select customer,
               amount,
               sum(amount) over(order by ranking) cumulative_amount
          from A)
 where cumulative_amount > (select sum(amount) / 2 from orders)
 order by amount desc

找出发生过连续三交易日涨停(涨幅 >=10%)的股票

with A as
 (select code,
         trade_date,
         close_price / lag(close_price) over(partition by code order by trade_date) - 1 rising_range
    from stock_price),
B as
 (select code,
         case
           when rising_range >= 0.1 and lag(rising_range)
            over(partition by code order by trade_date) >= 0.1 and
                lag(rising_range, 2)
            over(partition by code order by trade_date) >= 0.1 then
            1
           else
            0
         end rising_three_days
    from A)

select distinct code from B where rising_three_days = 1

某支股票最长连续涨了多少交易日

SELECT max(consecutive_day)
  FROM (SELECT count(*) consecutive_day
          FROM (SELECT sum(rise_or_fall) OVER(ORDER BY trade_date) day_no_gain
                  FROM (SELECT trade_date,
                               CASE
                                 when close_price > lag(close_price)
                                  OVER(ORDER BY trade_date) then
                                  0
                                 else
                                  1
                               end rise_or_fall
                          FROM stock_price))
         GROUP BY day_no_gain)

电商漏斗分析(这里只计算浏览、加购物车、下单三个步骤的用户数)

with e1 as
 (select uid, 1 as step1, min(etime) as t1
    from event
   where etime >= to_date('2021-01-10')
     and etime < to_date('2021-01-25')
     and eventtype = 'eventtype1'
     and …
   group by 1),
e2 as
 (select uid, 1 as step2, min(e1.t1) as t1, min(e2.etime) as t2
    from event as e2
   inner join e1
      on e2.uid = e1.uid
   where e2.etime >= to_date('2021-01-10')
     and e2.etime < to_date('2021-01-25')
     and e2.etime > t1
     and e2.etime < t1 + 7
     and eventtype = 'eventtype2'
     and …
   group by 1),
e3 as
 (select uid, 1 as step3, min(e2.t1) as t1, min(e3.etime) as t3
    from event as e3
   inner join e2
      on e3.uid = e2.uid
   where e3.etime >= to_date('2021-01-10')
     and e3.etime < to_date('2021-01-25')
     and e3.etime > t2
     and e3.etime < t1 + 7
     and eventtype = 'eventtype3'
     and …
   group by 1)
select sum(step1) as step1, sum(step2) as step2, sum(step3) as step3
  from e1
  left join e2
    on e1.uid = e2.uid
  left join e3
    on e2.uid = e3.uid

这几个例子无一例外地都用了多层嵌套的子查询,有的 SQL 虽然不是很长但理解起来却很困难(如股票最长连续上涨天数),更不用提写了;有的难度则因为过高而接近写不出了(如漏斗分析)。

业务简单的计算用 SQL 确实容易方便,但业务稍微复杂后,SQL 就不容易了,而实际业务尤其是数据科学家们面对的大都是这种有相当复杂度的场景。而且,简单的情况根本也用不着数据科学家写 SQL,有很多 BI 工具可以提供可视化的界面直接把这些查询拖拽出来。所以我们基本可以这样说:

数据科学家们要写的 SQL 并不简单!

这会带来什么后果呢?

这将直接导致数据科学家们要消耗大量时间精力去编写复杂 SQL,工作效率低下。简而言之,SQL 正在消耗数据科学的生命

SQL 如何消耗数据科学家的生命

复杂情况编写困难

像前面举例中的 SQL,有的看起来并不很长,但要读懂却不容易,写出来就更难。出现这种现象的原因之一在于:SQL 像英语导致分步困难

SQL 设计的像英语是为了方便业务(非技术)人员也能使用,如前所述,简单的查询确实能达到这个目标。但对于数据科学家这样的专业分析人员来说,要面对的情况要复杂得多,而一旦业务变复杂看似贴近自然语言的 SQL 反而会带来很多困难。

自然语言的一个优势是模糊性,而 SQL 需要遵循非常严格语法,稍不合规就会被解释器拒绝。像英语的好处没有体现,坏处却很严重,将语法设计得像自然语言,看起来容易掌握,其实恰恰相反。

SQL 为了把整个句子写成符合英语习惯的形式,会补充很多不必要的介词,比如 FROM 作为语句的运算主体却被写到后面,GROUP 后面要写一个多余的 BY。

贴近自然语言带来的主要坏处是非过程性。我们知道,分步计算是处理复杂计算的有效法门,几乎所有高级语言都支持这个特性。但自然语言却不是这样,两句话之间的引用关系靠少量几个代词维系,不够用且不精确,所以更习惯的做法是把尽量多的任务写在一句话中,复杂情况下就会大量使用从句。在 SQL 中的表现就是一句话中配有多个动作,SELECT、WHERE、GROUP 都拼进去,比如 WHERE 和 HAVING 其实就是一个意思,却要采用两个词以示区别,而查询需求复杂时就会出现多层嵌套的子查询。嵌套多层还要写成一句势必会带来书写和理解上的困难。实际情况也是这样,分析师面临的复杂 SQL 语句,很少以行计,经常是以 K 计。而同样的 100 行代码,分成 100 个语句还是只有 1 个语句,其复杂度完全不是一个层面的。这种代码理解起来非常困难,好不容易写出来,过两个月后自己都读不懂。

除了缺乏过程性,SQL 难写的更重要原因在于其理论基础上的缺陷:50 年前的关系代数缺乏必要的数据类型和运算支持,支持现代数据分析业务非常困难。

SQL 体系中有记录的概念,但并没有显式的记录数据类型。单条记录会被 SQL 作为只有一条记录的临时表处理,也就是个单成员的集合。这种缺乏离散性的特点会造成数据科学家无法按自然思维处理分析任务,导致理解和书写上的严重困难。

比如前面的漏斗分析,在 CTE 语法的支持下使得 SQL 具备了一定的分步能力,但仍然写起来非常复杂,每个子查询中都需要用原表与上一个子查询的结果进行关联,绕来绕去的 JOIN 运算非常难写,超出了许多数据科学家的能力。正常来讲,我们只需要按照用户分组,组内数据按照时间排好序,然后拿每个分组(用户)单独遍历计算进行到哪个步骤即可。将符合条件或分组后的数据作为独立的记录进行计算(也就是离散性)可以显著简化漏斗分析过程。但缺乏离散性支持的 SQL 却无法提供这样的有序计算,只能用反复关联完成漏斗分析,导致写着难、跑得慢。

实际上,这种游离记录的观念在 Java、C++ 等高级语言中很常见,但 SQL 却没有。关系代数定义了丰富的集合运算,但是离散性却非常差,描述这种复杂的多步骤运算就会出现难写(以及性能差)的情况。而这种理论上的缺陷很难通过工程来弥补。

这里有更细致的说明解释了 SQL 在数据类型和运算方面的缺失: SQL 为什么动不动就 N 百行以 K 计

调试困难

除了难写以外,SQL 的调试困难也会加剧“消耗数据科学家生命”这个现象。

SQL 的调试困难是出了名的,越是复杂的 SQL 就越难调试,而复杂 SQL 偏偏又最需要调试,毕竟正确性是第一位的。

一句嵌套的长 SQL 写完发现结果不对,应该怎么调试呢?通常我们只能将 SQL 拆解,从里到外一层一层执行查找问题出处。

然而,当 SQL 语句过于复杂时,这种调试方式可能会变得非常耗时和困难。因为在复杂的 SQL 语句中可能存在大量的嵌套和关联查询,经常想要拆分也不是一件容易的事。

尽管很多 SQL 编辑器提供了交互式的开发界面,但对于复杂的 SQL 语句来说,这些功能并没有起到太大的作用。调试困难又会作用到开发效率上,导致开发效率进一步降低。

性能低

除了难写难调试,复杂 SQL 的性能往往也很低。性能低就要等待,有的大数据场景下甚至要等数小时到一天,数据科学家的生命就这样在等待中消耗。运气不好时等了很长时间最后结果却没算对还需要重来,这又会导致成倍的时间成本。

复杂 SQL 为什么会慢?

复杂 SQL 的查询性能主要依赖数据库提供的优化引擎,好的数据库可以根据计算目标采用更高效的算法(而不是 SQL 字面的意思)。但这种自动优化机制在面对复杂情况时经常失效,而过于透明的机制又导致我们想要手动干预执行路径会非常困难,更别提让 SQL 使用我们自己指定的算法了。

举个简单的例子,如果要从 1 亿条数据中取前 10 名,SQL 写出来是这样的:

SELECT TOP 10 x FROM T ORDER BY x DESC

这个 SQL 虽然有 ORDER BY 的字样,但数据库优化引擎并不会真正进行大排序(大数据排序很慢),而会选择其他更高效的算法。

但如果我们稍微改一下,计算每个分组内的前 10 名,SQL 写起来是这样:

SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER (PARTITION BY Area ORDER BY Amount 
  DESC) rn
FROM Orders )
WHERE rn<=10

虽然实现上并没有复杂太多,但此时大部分数据库的优化引擎就会犯晕了,猜不出这句 SQL 的目的,只能老老实实地执行按语句书写的逻辑去执行排序(这个语句中还是有 ORDER BY 的字样),结果性能陡降。

现实业务中的 SQL 的复杂度远远超过这个例子,数据库优化引擎犯晕的情况相当常见。比如前面那个漏斗运算的 SQL 语句,需要反复关联,不仅是写出来困难,执行性能也极其低下。

当然,还可以通过自定义函数(UDF)扩展 SQL 的能力,以实现自己期望的算法。但 UDF 往往也只是想想而已,且不说数据库的存储无法随算法变化保证性能,UDF 本身的实现难度也会超出绝大部分数据科学家的技术能力,即使费了好大劲实现又会面临前面的复杂度问题,性能经常也得不到保证。

封闭性

SQL 的问题还不止于此。

SQL 是数据库的形式化语言,数据库还有一个封闭性的问题会导致数据处理困难。所谓封闭性,是指要被数据库计算和处理的数据,必须事先装入数据库之内,数据在数据库内部还是外部是很明确的。

而实际业务中,数据分析师们经常还要处理其他来源的数据,文本、Excel、程序接口、网络爬虫不一而足。这些数据有些只是临时用一下,如果每次都需要装进数据库才能使用,不仅会占用数据库的空间,ETL 过程也需要消耗大量时间,数据库通常还有约束,有些不符合规范的数据无法写入,这就需要先花时间精力整理数据;整理完数据写入又需要时间(数据库写入很慢),而时间就是生命,浪费不起。

当然,数据科学家的武器库里可以不止有一样,还可以有 Java 和 Python 等其他工具。那这些工具行不行呢?

Java 支持过程计算,也具备良好的离散性,但对集合运算的支持却很差。Java 缺乏基本的数据类型和计算类库导致实现数据处理会异常繁琐,比如在 SQL 中很容易实现的分组汇总用 Java 来做就不容易,而其他像过滤、连接以及多种混合运算就难了。而且 Java 对数据分析人员来讲太重了,交互性太差,虽然理论上什么计算都能实现,但实际上并不具备可用性。

Python 比 Java 要好一些,计算类库更丰富,实现同样的计算也比 Java 更简单(经常与 SQL 相当)。但用 Pandas 实现复杂计算也很繁琐,相对 SQL 并没有太大优势,而且交互性也不好(中间结果仍然要手动打印输出)。同时,由于没有真正的并行机制以及存储保障也面临大数据量计算下的性能问题。

还有其他选择吗?

esProc SPL 解救数据科学家

对于经常处理结构化数据的数据科学家来说,esProc SPL 是数据分析武器库非常值得加入的工具。

esProc 是专门面向结构化数据处理的工具,SPL (Structured Process Language)作为 esProc 的形式化语言提供了与 SQL、JAVA 完全不同的数据处理能力。

写着更简单

我们先来看一下 SPL 实现前面例子与 SQL 有何不同。

找出销售额占到一半的前 n 个客户,并按销售额从大到小排序


A


1

=db.query(“select * from orders”).sort(amount:-1)


2

=A1.cumulate(amount)

计算累计序列

3

=A2.m(-1)/2

最后的累计值即是总和

4

=A2.pselect(~>=A3)

超过一半的位置

5

=A1(to(A4))


计算股票最长连涨天数


A

1

=stock.sort(tradeDate)

2

=0

3

=A1.max(A2=if(closePrice>closePrice[-1],A2+1,0))

找出发生过连续三交易日涨停(涨幅 >=10%)的股票


A

B

C

D

1

=db.query(“select * from stock_price”).group(code).(~.sort(trade_date))


=[]

结果集在 C1

2

for A1

=0



3


if A2.pselect(B2=if(close_price/close_price[-1]>=1.1,B2+1,0):3)>0


有三天涨停

4



>C1=C1|A2.code


电商漏斗分析


A

1

=["etype1","etype2","etype3"]

2

=file("event.ctx").open()

3

=A2.cursor(id,etime,etype;etime>=date("2021-01-10") && etime<date("2021-01-25") && A1.contain(etype) && …)

4

=A3.group(uid).(~.sort(etime))

5

=A4.new(~.select@1(etype==A1(1)):first,~:all).select(first)

6

=A5.(A1.(t=if(#==1,t1=first.etime,if(t,all.select@1(etype==A1.~ && etime>t && etime<t1+7).etime, null))))

7

=A6.groups(;count(~(1)):STEP1,count(~(2)):STEP2,count(~(3)):STEP3)

可以看到 SPL 的实现比 SQL 简单,甚至在不了解 SPL 语法的情况基本能读懂。如果再熟悉一下 SPL 语法,实现这些计算并不是一件难事。

之所以 SPL 更简单,首先是因为 SPL天然支持过程计算

前面说过,过程计算可以有效降低复杂业务的实现难度,开发效率的提升可以帮助数据科学家创造更多价值。SQL 虽然在 CTE 语法和存储过程的支持下具备了一定程度的过程化,但仍远远不够。SPL 在这方面提供了天然支持,将复杂计算分解成多步从而降低实现难度。

像股票连涨天数的计算,按照自然思路,先按交易日排序,然后前一个交易日比较收盘价,大于则借助中间变量累加,否则清零,最后求序列中的最大值即最长连涨天数。整个过程不需要嵌套,按照自然思维分步就可以轻松实现,这是过程化带来的好处。而漏斗计算不仅通过分步将实现难度降低,实现代码也更加通用,能够处理任意步骤数的漏斗计算(只要改变参数即可)。

其次,SPL 还提供了更丰富的数据类型和计算类库,进一步简化计算。

SPL 提供了专业的结构化数据对象序表,并在序表的基础上提供了丰富的计算类库,从而使得 SPL 具备了完善且简单的结构化数据处理能力。

比如部分常规计算:

Orders.sort(Amount) // 排序
Orders.select(Amount*Quantity>3000 && like(Client,"*S*")) // 过滤
Orders.groups(Client; sum(Amount)) // 分组
Orders.id(Client) // 去重
join(Orders:o,SellerId ; Employees:e,EId) // 连接

有了过程化和序表的支持,SPL 就可以完成更加丰富的运算。比如对有序运算的支持 SPL 就更为直接和彻底。上面股票价格比较时用了 [-1] 来引用上一条记录,也可以通过 avg(price[-1:1])来计算移动平均。有了有序运算,计算股票最长连涨天数也可以这样实现:

stock.sort(trade_date).group@i(close_price<close_price[-1]).max(~.len())

还有分组运算,SPL 可以保留分组子集,即集合的集合,这样可以很方便完成我们分组后对分组结果的进一步操作。相比之下,SQL 没有显式的集合数据类型,无法返回集合的集合这类数据,不能实现独立的分组,就只能强迫分组和聚合作为一个整体来计算了。

此外,SPL 对聚合运算也有新的理解,聚合结果除了常见的单值 SUM、COUNT、MAX、MIN 等之外,也可以是个集合。比如常常出现的 TOPN 计算,SPL 也看作和 SUM、COUNT 一样的聚合计算,既可以针对全集也可以针对分组子集。

其实 SPL 还有很多特性不仅比 SQL 更加完善,相对 Java/Python 也更加丰富。像离散性可以让构成数据表的记录游离于数据表外独立反复使用;普遍集合支持任何数据构成的集合并参与运算;连接运算区分了三种不同连接类型可以因地制宜;……。

有了这些特性以后,数据科学家再处理数据就会更加简单、高效,杜绝浪费生命。

编辑调试容易

影响开发效率的另外一个因素是调试环境,如何更方便地进行代码调试以及与数据科学家进行交互也是 SPL 重点考虑的问题。SPL 为此提供了独立的 IDE:

与其他语言使用文本编程方式不同,SPL 采用了一种格子代码的编码方式。格子代码有一些天然的好处,比如编码时可以不用定义变量,后面步骤中可以直接使用前面单元格名(如 A1)就能使用该格的计算结果,这样就不用费心给变量起名字了;当然也支持定义变量。另外格子代码会非常整齐,即使某句代码很长也只会占一格,不影响整体结构,阅读更方便。还有就是 SPL 的 IDE 提供了多种调试方式,执行、调试执行、执行到光标等等,有了好用的编辑调试功能就可以提升编码效率。

IDE 的右侧还有一个可以实时显示每个计算格子的结果面板,每步结果实时查看进一步增加了调试的便利。有了这个特点,数据科学家不仅能轻松实现常规的数据分析工作,还可以进行交互式分析,根据上一步的结果临时决定下一步动作,反过来想回头查看中间的某步结果也非常方便。

高性能

支持过程计算并提供丰富的计算类库让 SPL 快速完成数据分析任务,而好用的 IDE 还能进一步提升开发效率。除了开发简便以外,SPL 的性能如何呢?毕竟运算性能对数据科学家也很关键。

SPL 首先在运算能力上提供了游标计算来应对超出内存容量的大数据计算。

=file("orders.txt").cursor@t(area,amount).groups(area;sum(amount):amount)

同时还为内外存计算都提供了并行计算支持。简单增加一个 @m 选项就可以实现并行充分利用 CPU 多核的能力,非常方便:

=file("orders.txt").cursor@tm(area,amount;4).groups(area;sum(amount):amount)

除了游标和并行计算外,SPL 还内置了很多高性能算法。像前面提到的 TOPN 问题,SPL 把这种运算和普通的聚合运算同样看待后,写出来的取前 N 名的语句中就不会有排序动作,执行效率因此更高。

类似的,SPL 还提供了很多这样的高性能算法。包括:

  • 内存计算类的二分法、序号定位、位置索引、哈希索引、多层序号定位、……
  • 外存查找类的二分法、哈希索引、排序索引、带值索引、全文检索、……
  • 遍历计算类的延迟游标、遍历复用、多路并行游标、有序分组汇总、序号分组、……
  • 外键关联类的外键地址化、外键序号化、索引复用、对位序列、单边分堆、……
  • 归并与连接类的有序归并、分段归并、关联定位、附表、……
  • 多维分析类的部分预汇总、时间段预汇总、冗余排序、布尔维序列、标签位维度、……
  • 集群计算类的集群复组表、复写维表、分段维表、冗余与备胎容错、负载均衡、……

为了充分发挥高性能算法效力,SPL 还设计了高性能文件存储,采用了编码、压缩、列存、索引、分段等多种性能保障机制。有了灵活且高效存储后,数据科学家就可以根据要实施的计算和数据特征设计数据存储形式(如排序、索引、附表),并在此之上使用更高效的算法从而获得极致的性能体验,节约时间就是节约生命。

开放性

不同于数据库需要数据先入库再计算(封闭性),SPL 面对多样性数据源时可以直接计算,具备良好的开放性。

SPL 没有传统数据仓库中“库“的概念,也没有元数据概念,更没有约束。任何可访问到的数据源都可以看作 SPL 的数据,并可被直接计算。计算前不需要先“入库”,计算后也可以用接口写出目标数据源中,不需要刻意“出库”。

SPL 对常见的数据源都封装了访问接口,如各种关系数据库(JDBC 数据源)、MongoDB、HBase、HDFS、HTTP/Restful、…,以及 SalesForces、SAP BW、…。这些数据源在逻辑上地位基本相同,访问后都可以单独或混合计算,不同之处仅仅在于访问接口以及不同接口表现出来的性能。

有了开放性的支持,数据科学家就可以直接快速处理多源数据,节省原来数据整理、入库出库的时间,提升数据处理效率。


总体来看,SPL 为数据科学家提供了全面的结构化数据处理能力,而结构化数据又是目前数据分析的重中之重。有了 SPL 以后不仅能实现更快的分析效率,还能得到充分的性能保障,写得快、跑得快、具备良好的开放性、交互性还好的工具才不会浪费数据科学家的生命。