You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

274 lines
21 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 30| 应用开发:北京市小客车(汽油车)摇号趋势分析
你好,我是吴磊。
如果你也在北京生活,那小汽车摇号这件事大概率也和你息息相关。我身边很多人也一直都和我抱怨说:“小汽车摇号这件事太难了,遥遥无期,完全看不到希望,感觉还没买彩票靠谱呢”。
实不相瞒我自己也在坚持小汽车摇号在享受8倍概率的情况下还是没能中签。因此包括我在内的很多人都想知道为什么摇号这么费劲一个人平均需要参与多少次摇号才会中签中签率的变化趋势真的和官方宣布的一致吗倍率这玩意儿真的能提高中签的概率吗
这些问题我们都能通过开发一个北京市小汽车摇号趋势分析的应用来解答。我会用两讲的时间带你完成这个应用的开发在这个过程中我们可以把前面学过的原理篇、通用调优篇和Spark SQL调优篇的大部分知识都上手实践一遍是不是一听就很期待呢
话不多说,我们赶紧开始吧!
## 课前准备
既然是做开发那我们就需要做一些准备工作。准备工作分为3部分分别是准备数据、准备开发环境和准备运行环境。
### 准备数据
应用所需的数据,我已经帮你准备好,也上传到了网盘,你可以点击[这个地址](https://pan.baidu.com/s/1Vys1Z1mofQFoU52ye7SKuw),输入提取码**ajs6** 进行下载。
数据的文件名是“2011-2019小汽车摇号数据.tar.gz”解压之后的目录结构如下图所示。根目录下有apply和lucky两个子目录apply的目录内容是2011-2019年各个批次参与摇号的申请编号lucky目录包含的是各个批次中签的申请编号。方便起见我们把参与过摇号的人叫“申请者”把中签的人叫“中签者”。apply和lucky的下一级子目录是各个摇号批次而摇号批次目录下包含的是Parquet格式的数据分片。
![](https://static001.geekbang.org/resource/image/74/4f/7472040477c9b7756102890f2819284f.jpg "数据的文件目录结构")
apply和lucky两个子目录在逻辑上分别对应着事实表和维度表也可以叫做“申请者表”和“中签者表”。两张表的Schema都是batchNumcarNum也就是摇号批次申请编号。总之事实表和维度表在存储方式上都做了分区设计且分区键都是batchNum。
### 准备开发环境
数据下载、解压完成之后然后我们再来准备开发环境。首先我们来说说开发语言。要完成“趋势分析应用”的开发你可以结合个人偏好使用Python、Java、Scala三种语言中的任意一种。由于我本人习惯使用Scala做开发因此整个项目的代码都是用Scala实现的。如果你是Java或是Python开发者也完全不必担心结合后续应用逻辑的讲解与Scala版本的参考实现我相信你也很快能完成应用的开发。
“趋势分析应用”非常轻量Scala版本的参考实现不超过200行代码。因此只使用Sublime甚至是VI这样的纯文本编辑器我们也能很快实现。不过为了提高开发效率以及方便后续应用的打包和部署我还是推荐你使用集成式的IDE比如IntelliJ IDEA、Eclipse 、IntelliJ PyCharm等等。IDE的选取原则和开发语言一样只要选择自己最顺手的就行了。
### 准备运行环境
最后是运行环境由于咱们的应用比较轻量而且数据量较小解压之后的Parquet文件总大小还不超4GB因此你甚至可以用手里的笔记本电脑或是台式PC就可以把应用从头到尾地跑通。选择“轻装上阵”主要是考虑到不少同学可能不方便搭建分布式的物理集群我们要确保这部分同学不会因为硬件的限制而不能参与实战。
不过,毕竟咱们这两讲的初衷和重点是性能调优实践,网络开销优化是其中关键的一环。因此,**有条件的同学,我还是鼓励你搭建分布式的物理集群,或是采用云原生的分布式环境**。一来这样的分布式环境更接近实际工作中的真实情况,二来调优前后的性能差异会更加地显著,有利于你加深理解不同调优技巧的作用和效果。
我这边选择了3台物理节点它们的资源配置分别如下。其实为了跑通应用和做性能对比你并不需要这么强悍的机器配置。我这么做主要是贪图执行效率因为想要说明不同调优技巧的作用与功效我只需要拿到调优前后的对比结果就可以了这样的配置可以减少我的等待时间。
![](https://static001.geekbang.org/resource/image/51/f5/514bfaa1696ce723fcdcbbef2e72fff5.jpeg "硬件资源配置")
## 应用开发
准备好了数据、开发环境和执行环境之后,我们就步入正题,开始进行“趋势分析应用”的开发。为了解答大家关于小汽车摇号的种种困惑,在这个应用中,我们主要分析如下几个案例:
* 2011到2019年底总共有多少人参与摇号
* 摇号次数的总分布情况,以及申请者的分布情况和中签者的分布情况分别是什么
* 中签率的变化趋势是什么
* 中签率是否发生过较大变化,怎么对它做局部洞察
* 倍率高的人是否更容易中签
接下来,我们就来一一厘清这些案例的计算逻辑,并进行代码实现。
### 案例1人数统计
首先我们需要对数据有一个基本的认知。我们先从最简单的统计计数开始也就是统计一下截至到2019年底参与摇号的总人次和幸运的中签者人数。应该说这样的需求非常简单我们只需使用Parquet API读取源文件、创建DataFrame然后调用count就可以了。
```
val rootPath: String = _
// 申请者数据(因为倍率的原因,每一期,同一个人,可能有多个号码)
val hdfs_path_apply = s"${rootPath}/apply"
val applyNumbersDF = spark.read.parquet(hdfs_path_apply)
applyNumbersDF.count
// 中签者数据
val hdfs_path_lucky = s"${rootPath}/lucky"
val luckyDogsDF = spark.read.parquet(hdfs_path_lucky)
luckyDogsDF.count
```
把这段代码丢到spark-shell或是打包部署到分布式环境去运行我们很快就能够得到计算结果。截至到2019年底摇号总人次为381972118也就是3.8亿人次中签的人数是1150828也就是115万人。你可能会好奇“摇号总人次为什么会有这么高的数量级
这其实并不奇怪。首先同一个人可能参与多个批次的摇号比如我就至少参加了60个批次的摇号苦啊。再者从2016年开始小汽车摇号有了倍率这个概念。倍率的设计初衷是让申请者的中签概率随着参与批次数量的增加而成比例地增加。也就是说参与了60次摇号的人比仅参与10次摇号的人的中签概率更高。不过官方对于倍率的实现略显简单粗暴。如果你去观察apply目录下2016年以后的批次文件就会发现所谓的倍率实际上就是申请编号的副本数量。
正是出于以上两个原因摇号总人次的体量才会有3.8亿人次。如果我们把倍率这个因素去掉,实际的摇号体量会是什么量级呢?
```
val applyDistinctDF = applyNumbersDF.select("batchNum", "carNum").distinct
applyDistinctDF.count
```
batchNumcarNum为粒度进行去重计数我们就能得到实际的摇号体量是135009819也就是1.35亿人次。这意味着从2011年到2019年这9年的时间里有1.35亿人次参与了一项“抽奖游戏”但是仅有115万人幸运中奖摇号之难可见一斑。
### 案例2摇号次数分布
接下来我们进一步向下追踪Drill Down挖掘一下不同人群摇号次数的分布也就是统计所有申请者累计参与了多少次摇号所有中签者摇了多少次号才能幸运地摇中签。对于这两个统计计算我们需要消除倍率的影响。也就是说同一个申请编号在同一个批次中应该只保留一份副本。因此我们需要使用去重之后的“申请者表”applyDistinctDF。
#### 场景1参与摇号的申请者
首先,我们先来分析所有申请者的分布情况,当然也包括中签者。根据刚刚介绍的“业务需求”,我们很快就能写出相应的查询语句。
```
val result02_01 = applyDistinctDF
.groupBy(col("carNum"))
.agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis"))
.agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")
result02_01.write.format("csv").save("_")
```
将上述代码付诸执行我们会得到如下图所示的计算结果。其中横坐标代表申请者参与过的摇号批次次数纵坐标是对应的参与人数。从2011年到2013年摇号是每月一次的。而从2014开始摇号是每两个月一次的。因此截至到2019年底总共有7212 \* 3 + 6 \* 6次摇号。所以我们看到横坐标的值域是从1到721表示摇过1次的人72就比较惨了它表示摇过72次的人。
![](https://static001.geekbang.org/resource/image/d2/1c/d2e28f14e580916c8b0e1b37db9e7f1c.jpeg)
从图中我们不难发现随着摇号次数的逐级递增人数分布基本上呈现出了逐级递减的趋势。那这意味着什么呢这意味着每隔两个月就会有新人从驾校毕业加入到庞大的摇号大军中来。仔细观察上图的左半部分我们会发现摇号次数凡是遇到3的倍数对应的人数往往比其“左邻右舍”多出甚至两倍这是为什么呢
我们刚刚说过从2014年开始摇号是每两个月进行一次。因此摇号次数相差3则意味着两次摇号之间的时间差是半年左右。比如说摇了3次的人就比摇了6次的人晚半年加入摇号大军。那么半年意味着什么呢我们不妨脑洞一下尽管每个月都有从驾校毕业的学员但是寒暑假往往是大批量“生产”学员的高峰时期而寒暑假恰好相差半年左右。你觉得我这个推测合理吗
#### 场景2幸运的中签者
接下来我们再来看看那些中签的幸运儿们到底有多幸运要想得到中签者的摇号次数我们需要把applyDistinctDF和luckyDogsDF两张表做内关联然后再做分组、聚合代码实现如下表所示。
```
val result02_02 = applyDistinctDF
.join(luckyDogsDF.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("carNum")).agg(count(lit(1)).alias("x_axis"))
.groupBy(col("x_axis")).agg(count(lit(1)).alias("y_axis"))
.orderBy("x_axis")
result02_02.write.format("csv").save("_")
```
将上述代码付诸执行我们会得到如下图所示的计算结果其中横纵坐标的含义与场景1一样分别是摇号批次数和对应的人数分布。我们发现随着摇号次数的逐级递增人数的分布完全是单调递减的。也就是说摇号的次数越多中签者的数量越少。我能想到的一个原因是摇号的次数越高对应的参与人数就越少这一点在场景1已经得到了验证。这个其实也不难理解能一直坚持摇60次以上的玩家真的都是骨灰级玩家。那么参与的人基数小中签者的数量自然就更少。
![](https://static001.geekbang.org/resource/image/f8/4b/f8e20abc26316a4e7ab9abda09c0344b.jpeg)
不过如果假设申请者两个月摇一次号那么我们就会得出一个非常扎心的结论摇号中签的人往往不需要等待太长的时间绝大多数都是在2-3年内摇中了购车资格因为前半部分的总数占到了绝大多数。而等待3年以上才摇上号的人反而成了幸运儿群体中的“少数派”。这不禁让我想起了当年大家开玩笑的那句话“你要是人品够用早就该摇上了。超过3年还没摇上就说明你人品余额不足摇号这件事以后也就不用指望了”。
### 案例3中签率的变化趋势
从摇号次数的分布来看,申请者和中签者的变化趋势是一致的,那这是否意味着二者相除之后的比例是稳定的呢?二者的商实际上就是中签率。接下来,我们就去探究一下中签率的变化趋势。要计算中签率,我们需要分别统计每一个摇号批次中的申请者和中签者人数,然后再把它们做关联、聚合,代码实现如下所示。
```
// 统计每批次申请者的人数
val apply_denominator = applyDistinctDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("denominator"))
// 统计每批次中签者的人数
val lucky_molecule = luckyDogsDF
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))
val result03 = apply_denominator
.join(lucky_molecule, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")
result03.write.format("csv").save("_")
```
我们得到的中签率示意图如下所示。其中横坐标为各个摇号批次从201101到201906也就是从2011年的第一批到2019年的第72批纵坐标就是中签率。从中我们可以很直观地看到随着摇号批次的推进中签率呈锐减的趋势。201101批次的中签率在9.4%左右不到10%。而201906批次的中签率为1.9‰也就是千分之一点九。这么看来1000个人里面能摇上号的还凑不够两个人这也难怪摇号如此之难了。
![](https://static001.geekbang.org/resource/image/92/13/92c3ae9b999f697e6b568a5107f45713.jpeg)
### 案例4中签率局部洞察
第4个案例与案例3的区别在于我们只关注2018年的中签率变化趋势。这样做的原因有二一来通过计算和对比我发现2018年的中签率相比2017年几乎经历了“断崖式”的下跌因此我想给2018年一个特写二来只关注2018年的数据可以让我们有机会对比启用AQE Join策略调整前后的性能差异。
基于案例3的代码实现要关注2018年我们只需要在batchNum之上添加个过滤条件就好了。
```
// 筛选出2018年的中签数据并按照批次统计中签人数
val lucky_molecule_2018 = luckyDogsDF
.filter(col("batchNum").like("2018%"))
.groupBy(col("batchNum"))
.agg(count(lit(1)).alias("molecule"))与
// 通过与筛选出的中签数据按照批次做关联,计算每期的中签率
val result04 = apply_denominator
.join(lucky_molecule_2018, Seq("batchNum"), "inner")
.withColumn("ratio", round(col("molecule")/col("denominator"), 5))
.orderBy("batchNum")
result04.write.format("csv").save("_")
```
![](https://static001.geekbang.org/resource/image/2d/de/2db2d2751c7930b271b8e456d2f5abde.jpeg)
结合案例3与案例4的执行结果我们至少有两点发现。第一点2018年内各批次中签率下降较为平缓从201801批次的2.3‰下降至201806批次的2.1‰整体下降幅度不超过10%。第二点2017年最后一个批次也就是201706批次的中签率在4.9‰左右而201801批次的中签率为2.3‰在短短两个月之内中签率惨遭“腰斩”并在接下来的两年里一路阴跌最终在201906批次破掉2‰。
### 案例5倍率分析
那么,在中签率如此之低的情况下,倍率这玩意还有意义吗?接下来,我们先去探索倍率的分布情况,然后再去观察,不同倍率的人群,他们的中签比例是怎样分布的。
#### 场景1不同倍率下的中签人数
我们先来统计一下那些有幸中签的人分别是在多大的倍率下中签的。从2016年开始才有倍率这个概念因此对于倍率的统计我们只需要关注2016年以后的摇号数据即可。对于同一个中签者他在不同批次的倍率可能是不同的我们只需要拿到其中最大的倍率参与统计就可以了。原因很简单最大的倍率就是她/他中签之前的倍率。
另外倍率的计算需要依赖原始的多副本摇号数据所以这里我们不能再使用去重的摇号数据而应该用包含重复申请编号的applyNumbersDF表。基于这样的逻辑我们的代码实现如下。
```
val result05_01 = applyNumbersDF
.join(luckyDogsDF.filter(col("batchNum") >= "201601")
.select("carNum"), Seq("carNum"), "inner")
.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result05_01.write.format("csv").save("_")
```
中签者的倍率分布如下图所示。其中,横坐标为中签者的倍率,更准确地说,是中签者在参与的摇号批次中最大的副本数量,纵坐标是人数分布。通过观察执行结果我们不难发现,中签者的倍率呈现明显的正态分布。因此,从这张图我们可以得到初步结论:要想摇中车牌号,你并不需要很高的倍率。换句话说,对于中签这件事来说,倍率的作用和贡献并不是线性递增的。
![](https://static001.geekbang.org/resource/image/5d/93/5d75f65cd115d560b1b3d19cd78eb393.jpeg)
不过和案例2类似这里同样存在一个基数的问题。也就是说倍率高的人本来就少其中的中签者数量自然也少。因此我们还要结合申请者的倍率分布去计算不同倍率下的中签比例才能更加完备地对倍率的作用下结论。
#### 场景2不同倍率下的中签比例
对倍率分布有了初步认知之后我们再来计算不同倍率人群的中签比例去探究倍率本身对于中签的贡献究竟有多大。有了场景1中签者的倍率分布我们只需要去计算申请者的倍率分布然后把两份数据做关联、聚合就可以得到我们想要的结果。
```
// Step01: 过滤出2016-2019申请者数据统计出每个申请者在每一期内的倍率并在所有批次中选取最大的倍率作为申请者的最终倍率最终算出各个倍率下的申请人数
val apply_multiplier_2016_2019 = applyNumbersDF
.filter(col("batchNum") >= "201601")
.groupBy(col("batchNum"), col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
.groupBy("multiplier")
.agg(count(lit(1)).alias("apply_cnt"))
// Step02: 将各个倍率下的申请人数与各个倍率下的中签人数左关联,并求出各个倍率下的中签率
val result05_02 = apply_multiplier_2016_2019
.join(result05_01.withColumnRenamed("cnt", "lucy_cnt"), Seq("multiplier"), "left")
.na.fill(0)
.withColumn("ratio", round(col("lucy_cnt")/col("apply_cnt"), 5))
.orderBy("multiplier")
result05_02.write.format("csv").save("_")
```
不同倍率下的中签比例如下图所示。其中横坐标为倍率纵坐标有两个。蓝色柱状图体代表中签人数它的分布与场景1的分布是一致的绿色柱状条表示的是中签比例它表示在同一个倍率下中签人数与申请人数的比值。
![](https://static001.geekbang.org/resource/image/6e/ab/6e302d72dd4d036c12a5fdd5d4fa04ab.jpeg)
与中签人数一样中签比例在不同的倍率下也呈现出了正态分布。有了这份数据做补充我们可以夯实场景1中得出的结论。也就是倍率对中签的贡献极其有限。这个结论很好地解释了为什么摇号很久倍率很高的人也难以中签。
到此为止,通过以上几个案例的分析,我们就对摇号次数分布、中签率变化趋势、倍率分布与中签比例有了答案。
## 小结
今天这一讲我们重点开发了一个趋势分析应用来解答北京市小汽车摇号的各个问题。这个应用主要实现了5个案例分别是摇号次数分布、中签率变化趋势、中签率的大变动、倍率分布与中签比例。为了方便理解我把它们要解决的问题、答案、主要的实现思路都总结在了下面的脑图中你可以看一看。
![](https://static001.geekbang.org/resource/image/cd/15/cdde310d7636209202587308e2938315.jpg)
至于这5个案例的代码实现和执行结果我把它们都上传到了公用的GitHub仓库你可以从[这个地址](https://github.com/wulei-bj-cn/potatoes.git)获取完整内容。
当然,目前的代码肯定存在很多可以优化的地方,至于怎么优化,我先卖个关子,下一讲再详细来说。
## 每日一练
1. 如果让你来实现小汽车摇号的倍率机制,你觉得怎么实现才更严谨呢?
2. 基于这份2011-2019的小汽车摇号数据你还能想到哪些有意思的洞察、视角和案例值得我们进一步去探索呢
3. 你认为,倍率对于中签的贡献和作用微乎其微的原因是什么呢?
期待在留言区看到你的思考和答案,我们下一讲见!