Compare commits

...

10 Commits

Author SHA1 Message Date
089ca542be
lfs 2024-12-20 09:21:04 +08:00
张乾
4882464c37 books 2024-11-05 15:53:09 +08:00
张乾
76b4182278 books 2024-11-05 15:40:46 +08:00
张乾
f8573278a0 books 2024-11-05 11:35:42 +08:00
张乾
7169f1b510 first commit 2024-10-16 14:25:33 +08:00
张乾
4e19e098b0 first commit 2024-10-16 14:19:30 +08:00
张乾
c47809d1ff first commit 2024-10-16 13:06:13 +08:00
张乾
2393162ba9 first commit 2024-10-16 11:54:04 +08:00
张乾
c4bf92ea9d first commit 2024-10-16 11:38:31 +08:00
张乾
b2fae18d7e first commit 2024-10-16 11:19:41 +08:00
356 changed files with 61778 additions and 46 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.jpg filter=lfs diff=lfs merge=lfs -text
*.pdf filter=lfs diff=lfs merge=lfs -text

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/.idea/

BIN
Docker — 从入门到实践(v1.2.0).pdf (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Docker开源书.pdf (Stored with Git LFS) Normal file

Binary file not shown.

BIN
Java面试知识点.pdf (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,225 @@
# Awesome Quant [![Awesome](https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg)](https://github.com/sindresorhus/awesome)
一份精心挑选的中文Quant相关资源索引。(来源https://github.com/thuquant/awesome-quant)
## 目录
* [数据源](#数据源)
* [数据库](#数据库)
* [量化交易平台](#量化交易平台)
* [策略](#策略)
* [回测](#回测)
* [交易API](#交易api)
* [编程](#编程)
* [Python](#python)
* [R](#r)
* [C++](#c)
* [Julia](#julia)
* [论坛](#论坛)
* [书籍](#书籍)
* [论文](#论文)
* [政策](#政策)
* [值得关注的信息源](#值得关注的信息源)
* [其他Quant资源索引](#其他quant资源索引)
## 数据源
* [TuShare](http://tushare.org/) - 中文财经数据接口包
* [Quandl](https://www.quandl.com/) - 国际金融和经济数据
* [Wind资讯-经济数据库](http://www.wind.com.cn/NewSite/edb.html) - 收费
* [锐思数据 - 首页](http://www.resset.cn/) - 收费
* [国泰安数据服务中心](http://www.gtarsc.com/Home) - 收费
* [恒生API](https://open.hscloud.cn/cloud/open/apilibrary/queryLibraryMenu.html?parent_id=100313&menu_id=100307) - 收费
* [Bloomberg API](https://www.bloomberglabs.com/api/libraries/) - 收费
* [数库金融数据和深度分析API服务](http://developer.chinascope.com/) - 收费
* [Historical Data Sources](http://quantpedia.com/Links/HistoricalData) - 一个数据源索引
* [Python通达信数据接口](https://github.com/rainx/pytdx) - 免费通达信数据源
* [fooltrader](https://github.com/foolcage/fooltrader) - 大数据开源量化项目,自己维护了一个爬取整合的全市场数据源
* [JoinQuant/jqdatasdk](https://github.com/JoinQuant/jqdatasdk) - jqdatasdk是提供给用户获取聚宽金融数据的SDK
* [米筐科技的RQData数据接口](https://www.ricequant.com/introduce_rqdata) - 收费
## 数据库
* [manahl/arctic: High performance datastore for time series and tick data](https://github.com/manahl/arctic) - 基于mongodb和python的高性能时间序列和tick数据存储
* [kdb | The Leader in High-Performance Tick Database Technology | Kx Systems](https://kx.com/) - 收费的高性能金融序列数据库解决方案
* [MongoDB Blog](http://blog.mongodb.org/post/65517193370/schema-design-for-time-series-data-in-mongodb) - 用mongodb存储时间序列数据
* [InfluxDB Time-Series Data Storage | InfluxData](https://www.influxdata.com/time-series-platform/influxdb/) - Go写的分布式时间序列数据库
* [OpenTSDB/opentsdb: A scalable, distributed Time Series Database.](https://github.com/OpenTSDB/opentsdb) - 基于HBase的时间序列数据库
* [kairosdb/kairosdb: Fast scalable time series database](https://github.com/kairosdb/kairosdb) - 基于Cassandra的时间序列数据库
* [timescale/timescaledb: An open-source time-series database optimized for fast ingest and complex queries. Engineered up from PostgreSQL, packaged as an extension.](https://github.com/timescale/timescaledb) - 基于PostgreSQL的时间序列数据库
## 量化交易平台
* [JoinQuant聚宽量化交易平台](https://www.joinquant.com/) - 一个基于Python的在线量化交易平台
* [优矿 - 通联量化实验室](https://uqer.io/home/) - 一个基于Python的在线量化交易平台
* [Ricequant 量化交易平台](https://www.ricequant.com/) - 支持Python和Java的在线量化交易平台
* [掘金量化](http://www.myquant.cn/) - 支持C/C++、C#、MATLAB、Python和R的量化交易平台
* [Auto-Trader](http://www.atrader.com.cn/portal.php) - 基于MATLAB的量化交易平台
* [MultiCharts 中国版 - 程序化交易软件](https://www.multicharts.cn/)
* [BotVS - 首家支持传统期货与股票证券与数字货币的量化平台](https://www.botvs.com/)
* [Tradeblazer(TB) - 交易开拓者](http://www.tradeblazer.net/) - 期货程序化交易软件平台
* [MetaTrader 5](https://www.metatrader5.com/en) - Multi-Asset Trading Platform
* [BigQuant](https://bigquant.com) - 专注量化投资的人工智能/机器学习平台
## 策略
* [JoinQuant聚宽: 量化学习资料、经典交易策略、Python入门 - 雪球](https://xueqiu.com/8287840120/65009358)
* [myquant/strategy: 掘金策略集锦](https://github.com/myquant/strategy)
* [优矿社区内容索引](https://uqer.io/community/share/58243e7d228e5b91df6d5d19)
* [RiceQuant米筐量化社区 2016年4月以来优秀策略与研究汇总](https://www.ricequant.com/community/topic/1863//3)
* [雪球选股](https://xueqiu.com/9796081404)
* [botvs/strategies: 用Javascript OR Python进行量化交易](https://github.com/botvs/strategies)
## 回测
* [Zipline](https://github.com/quantopian/zipline) - 一个Python的回测框架
* [pyalgotrade](https://github.com/gbeced/pyalgotrade) - 一个Python的事件驱动回测框架
* [pyalgotrade-cn](https://github.com/Yam-cn/pyalgotrade-cn) - Pyalgotrade-cn在原版pyalgotrade的基础上加入了A股历史行情回测并整合了tushare提供实时行情。
* [ricequant/rqalpha](https://github.com/ricequant/rqalpha) - RQalpha: Ricequant 开源的基于Python的回测引擎
* [quantdigger](https://github.com/QuantFans/quantdigger) - 基于python的量化回测框架,借鉴了主流商业软件比如TB, 金字塔)简洁的策略语法
* [pyktrader](https://github.com/harveywwu/pyktrader) - 基于pyctp接口并采用vnpy的eventEngine使用tkinter作为GUI的python交易平台
* [QuantConnect/Lean](https://github.com/QuantConnect/Lean) - Lean Algorithmic Trading Engine by QuantConnect (C#, Python, F#, VB, Java)
* [QUANTAXIS](https://github.com/yutiansut/QUANTAXIS) - QUANTAXIS 量化金融策略框架 - 中小型策略团队解决方案
* [Hikyuu](http://hikyuu.org) - 基于Python/C++的开源量化交易研究框架
## 交易API
* [上海期货信息技术有限公司CTP API](http://www.sfit.com.cn/5_2_DocumentDown.htm) - 期货交易所提供的API
* [飞马快速交易平台 - 上海金融期货信息技术有限公司](http://www.cffexit.com.cn/static/3000201.html) - 飞马
* [大连飞创信息技术有限公司](http://www.dfitc.com.cn/portal/cate?cid=1364967839100#1) - 飞创
* [vnpy](https://github.com/vnpy/vnpy) - 基于python的开源交易平台开发框架
* [QuantBox/XAPI2](https://github.com/QuantBox/XAPI2) - 统一行情交易接口第2版
* [easytrader](https://github.com/shidenggui/easytrader) - 提供券商华泰/佣金宝/银河/广发/雪球的基金、股票自动程序化交易,量化交易组件
* [实盘易](http://www.iguuu.com/e)[SDK](https://github.com/sinall/ShiPanE-Python-SDK) - 管理通达信等交易终端,提供基于 HTTP 协议的 RESTFul API各大在线量化交易平台实盘解决方案
* [IB API | Interactive Brokers](https://www.interactivebrokers.com.hk/cn/index.php?f=5234&ns=T) - 盈透证券的交易API
* [FutunnOpen/futuquant](https://github.com/FutunnOpen/futuquant) - 富途量化平台 API
## 编程
### Python
#### 安装
* [Anaconda](https://www.continuum.io/downloads) - 推荐通过[清华大学镜像 ](https://mirrors.tuna.tsinghua.edu.cn/help/anaconda/)下载安装
* [Python Extension Packages for Windows - Christoph Gohlke](http://www.lfd.uci.edu/~gohlke/pythonlibs/) - Windows用户从这里可以下载许多python库的预编译包
#### 教程
* [Python | Codecademy](https://www.codecademy.com/learn/python)
* [用 Python 玩转数据 - 南京大学 | Coursera](https://www.coursera.org/learn/hipython)
* [Introduction to Data Science in Python - University of Michigan | Coursera](https://www.coursera.org/learn/python-data-analysis)
* [The Python Tutorial — Python 3.5.2 documentation](https://docs.python.org/3/tutorial/)
* [Python for Finance](https://book.douban.com/subject/25921015/)
* [Algorithmic Thinking](https://www.coursera.org/learn/algorithmic-thinking-1) - Python 算法思维训练
#### 库
* [awesome-python: A curated list of awesome Python frameworks, libraries, software and resources](https://github.com/vinta/awesome-python)
* [pandas](http://pandas.pydata.org) - Python做数据分析的基础
* [pyql: Cython QuantLib wrappers](https://github.com/enthought/pyql)
* [ffn](http://pmorissette.github.io/ffn/quick.html) - 绩效评估
* [ta-lib: Python wrapper for TA-Lib (http://ta-lib.org/).](https://github.com/mrjbq7/ta-lib) - 技术指标
* [StatsModels: Statistics in Python — statsmodels documentation](http://statsmodels.sourceforge.net/) - 常用统计模型
* [arch: ARCH models in Python](https://github.com/bashtage/arch) - 时间序列
* [pyfolio: Portfolio and risk analytics in Python](https://github.com/quantopian/pyfolio) - 组合风险评估
* [twosigma/flint: A Time Series Library for Apache Spark](https://github.com/twosigma/flint) - Apache Spark上的时间序列库
* [PyFlux](https://github.com/RJT1990/pyflux) - Python 的时间序列建模(频率派和贝叶斯)
### R
#### 安装
* [The Comprehensive R Archive Network](https://mirrors.tuna.tsinghua.edu.cn/CRAN/) - 从国内清华镜像下载安装
* [RStudio](https://www.rstudio.com/products/rstudio/download/) - R的常用开发平台下载
#### 教程
* [Free Introduction to R Programming Online Course](https://www.datacamp.com/courses/free-introduction-to-r) - datacamp的在线学习
* [R Programming - 约翰霍普金斯大学 | Coursera](https://www.coursera.org/learn/r-programming)
* [Intro to Computational Finance with R](https://www.datacamp.com/community/open-courses/computational-finance-and-financial-econometrics-with-r) - 用R进行计算金融分析
#### 库
* [CRAN Task View: Empirical Finance](https://cran.r-project.org/web/views/Finance.html) - CRAN官方的R金融相关包整理
* [qinwf/awesome-R: A curated list of awesome R packages, frameworks and software.](https://github.com/qinwf/awesome-R) - R包的awesome
### C++
#### 教程
* [C++程序设计](http://www.xuetangx.com/courses/course-v1:PekingX+04831750.1x+2015T1/about) - 北京大学 郭炜
* [基于Linux的C++ ](http://www.xuetangx.com/courses/course-v1:TsinghuaX+20740084X+sp/about) - 清华大学 乔林
* [面向对象程序设计C++](http://www.xuetangx.com/courses/course-v1:TsinghuaX+30240532X+sp/about) - 清华大学 徐明星
* [C++ Design Patterns and Derivatives Pricing ](https://book.douban.com/subject/1485468/) - C++设计模式
* [C++ reference - cppreference.com](http://en.cppreference.com/w/cpp) - 在线文档
#### 库
* [fffaraz/awesome-cpp: A curated list of awesome C/C++ frameworks, libraries, resources, and shiny things.](https://github.com/fffaraz/awesome-cpp) - C++库整理
* [rigtorp/awesome-modern-cpp: A collection of resources on modern C++](https://github.com/rigtorp/awesome-modern-cpp) - 现代C++库整理
* [QuantLib: a free/open-source library for quantitative finance](http://quantlib.org/index.shtml)
* [libtrading/libtrading: Libtrading, an ultra low-latency trading connectivity library for C and C++.](https://github.com/libtrading/libtrading)
### Julia
#### 教程
* [Learning Julia](http://julialang.org/learning/) - 官方整理
* [QUANTITATIVE ECONOMICS with Julia](http://quant-econ.net/_static/pdfs/jl-quant-econ.pdf) - 经济学诺奖获得者Thomas Sargent教你[Julia](http://julialang.org/)在量化经济的应用。
#### 库
* [Quantitative Finance in Julia](https://github.com/JuliaQuant) - 多数为正在实现中,感兴趣的可以参与
### 编程论坛
- [Stack Overflow](http://stackoverflow.com/) - 对应语言的tag
- [SegmentFault](https://segmentfault.com/) - 对应语言的tag
### 编程能力在线训练
* [Solve Programming Questions | HackerRank](https://www.hackerrank.com/domains) - 包含常用语言(C++, Java, Python, Ruby, SQL)和相关计算机应用技术(算法、数据结构、数学、AI、Linux Shell、分布式系统、正则表达式、安全)的教程和挑战。
* [LeetCode Online Judge](https://leetcode.com/) - C, C++, Java, Python, C#, JavaScript, Ruby, Bash, MySQL在线编程训练
## 论坛
* [Quantitative Finance StackExchange](http://quant.stackexchange.com/) - stackexchange 系列的 quant 论坛
* [JoinQuant社区](https://www.joinquant.com/community) - JoinQuant社区
* [优矿社区](https://uqer.io/community/list) - 优矿社区
* [RiceQuant量化社区](https://www.ricequant.com/community/) - RiceQuant量化社区
* [掘金量化社区](http://forum.myquant.cn/) - 掘金量化社区
* [清华大学学生经济金融论坛](http://forum.thuquant.com/) - 清华大学学生金融数据与量化投资协会主办
## 书籍
* [My Life as a Quant: Reflections on Physics and Finance](http://www.amazon.com/My-Life-Quant-Reflections-Physics/dp/0470192739) - In My Life as a Quant, Emanuel Derman relives his exciting journey as one of the first high-energy particle physicists to migrate to Wall Street.
* [量化交易](https://book.douban.com/subject/25878150/) - Ernest P. Chan撰写的量化投资理论
* [量化投资与对冲基金丛书:波动率交易](https://book.douban.com/subject/25711100/)
* [Following the Trend](https://book.douban.com/subject/19990593/)
* [Statistical Inference](https://book.douban.com/subject/1464795/) - 统计推断入门
* [All of Nonparametric Statistics](https://book.douban.com/subject/4251603/) - 非参统计入门
* [The Elements of Statistical Learning](https://book.douban.com/subject/3294335/) - Data Mining, Inference, and Prediction
* [Analysis of Financial Time Series](https://book.douban.com/subject/4719140/) - Ruey S. Tsay 的时间序列分析
* [Options, Futures, and Other Derivatives](https://book.douban.com/subject/6127888/) - 期权期货等衍生品
## 论文
* [awesome-quant/papers.md](https://github.com/thuquant/awesome-quant/blob/master/papers.md)
## 值得关注的信息源
* [Quantitative Finance arxiv](https://arxiv.org/archive/q-fin)
* [雪球工程师1号](http://xueqiu.com/engineer) - 财经社交网络雪球的量化相关账号。
* [Ricequant量化](http://xueqiu.com/ricequant) - Ricequant量化平台的雪球账号。
* [量化哥-优矿Uqer](http://xueqiu.com/4105947155) - 优矿Uqer量化平台的雪球账号。
* [宽客 (Quant) - 索引 - 知乎](https://www.zhihu.com/topic/19557481)
* 量化投资与机器学习 - 微信公众号
* THU量协 - 微信公众号
* 优矿量化实验室 - 微信公众号
* Ricequant - 微信公众号
* 鲁明量化全视角 - 微信公众号
## 政策
* [中国证券监督管理委员会](http://www.csrc.gov.cn/pub/newsite/)
* [考试报名-中国证券业协会](http://www.sac.net.cn/cyry/kspt/ksbm/) - 证券从业资格报名
* [中国证券投资基金业协会](http://www.amac.org.cn/) - 内有相关法规教育和从业资格报名入口
* [大连商品交易所](http://www.dce.com.cn/)
* [上海期货交易所首页](http://www.shfe.com.cn/)
* [郑州商品交易所网站](http://www.czce.com.cn/portal/index.htm)
* [上海证券交易所](http://www.sse.com.cn/)
* [深圳证券交易所](http://www.szse.cn/)
# 其他Quant资源索引
* [Quantitative Finance Reading List - QuantStart](https://www.quantstart.com/articles/Quantitative-Finance-Reading-List#general-quant-finance-reading)
* [Master reading list for Quants, MFE (Financial Engineering) students | QuantNet Community](https://www.quantnet.com/threads/master-reading-list-for-quants-mfe-financial-engineering-students.535/)
# 其他 Awesome 列表
* 英文版 awesome-quant [wilsonfreitas/awesome-quant: A curated list of insanely awesome libraries, packages and resources for Quants (Quantitative Finance)](https://github.com/wilsonfreitas/awesome-quant)
* Other awesome lists [awesome-awesomeness](https://github.com/bayandin/awesome-awesomeness).
* Even more lists [awesome](https://github.com/sindresorhus/awesome).
* Another list? [list](https://github.com/jnv/lists).
* WTF! [awesome-awesome-awesome](https://github.com/t3chnoboy/awesome-awesome-awesome).
* Analytics [awesome-analytics](https://github.com/onurakpolat/awesome-analytics).

Binary file not shown.

BIN
books/股票期权相关/原版海龟交易法则.pdf (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
books/股票期权相关/弗里德-自由选择.pdf (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
books/股票期权相关/算法交易与套利交易.pdf (Stored with Git LFS) Normal file

Binary file not shown.

BIN
books/股票期权相关/统计套利(中文版).pdf (Stored with Git LFS) Normal file

Binary file not shown.

BIN
books/股票期权相关/罗杰斯环球投资旅行.pdf (Stored with Git LFS) Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
books/股票期权相关/量化交易行业概览.pdf (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

BIN
books/股票期权相关/韭菜的自我修养.pdf (Stored with Git LFS) Executable file

Binary file not shown.

View File

@ -58,7 +58,7 @@ foreach ($lines as $line) {
echo $fileName;
echo PHP_EOL;
$uri = str_replace(' ', '%20', $name);
$uri = str_replace(' ', '%20', html_entity_decode($name));
$fileUrl = $url . $line . '/' . $uri;
if(filesize($fileName) > 0) continue;

View File

@ -1,45 +0,0 @@
<?php
// Define the URL
$url = "https://learn.lianglianglee.com/";
# 1 获取文件主目录
// $response = file_get_contents($url);
// if ($response === FALSE) {
// echo "Failed to access the URL.";
// } else {
// // Use regex to find the href values across multiple lines (with the 's' modifier)
// preg_match_all('/<li><a href="([^"]*)">([^<]*)<\/a><\/li>/', $response, $matches);
// // Prepare the output for the readme file
// $output = "";
// if (!empty($matches[1])) {
// foreach ($matches[1] as $href) {
// $output .= $href.PHP_EOL;
// }
// } else {
// $output = "No match found.\n";
// }
// // Write the results to readme.txt file
// file_put_contents("README.md", $output);
// echo "Results saved to readme.txt";
// }
# 2 生成不同目录的文件夹
// Path to the README.md file
$readmePath = 'README.md';
// Read the file into an array of lines
$lines = file($readmePath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
// Remove spaces from each line
$folderName = str_replace(' ', '', $line);
$folderName = "/Users/01397713/Documents/github/learn-tech".$folderName;
$line = str_replace(' ', '%20', $line);
$curlUrl = $url. $line;
echo $curlUrl.PHP_EOL;
}

View File

@ -0,0 +1,70 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
122 计算机视觉领域的深度学习模型VGG & GoogleNet
在上第一期的分享中我们通过一篇经典论文讲了AlexNet这个模型。可以说这篇文章是深度学习在计算机视觉领域发挥作用的奠基之作。
AlexNet在2012年发表之后研究界对这个模型做了很多改进工作使得这个模型得到了不断优化特别是在ImageNet上的表现获得了显著提升。今天我们就来看看针对AlexNet模型的两个重要改进分别是VGG和GoogleNet。
VGG网络
我们要分享的第一篇论文题目是《用于大规模图像识别的深度卷积网络》Very Deep Convolutional Networks for Large-Scale Image Recognition[1]。这篇文章的作者都来自于英国牛津大学的“视觉几何实验室”Visual Geometry Group简称VGG所以文章提出的模型也被叫作 VGG网络。到目前为止这篇论文的引用次数已经多达1万4千次。
首先,我们简单来了解一下这篇论文的作者。
第一作者叫卡伦·西蒙彦Karen Simonyan发表论文的时候他在牛津大学计算机系攻读博士学位。之后西蒙彦加入了谷歌在DeepMind任职继续从事深度学习的研究。
第二作者叫安德鲁·兹泽曼Andrew Zisserman是牛津大学计算机系的教授也是计算机视觉领域的学术权威。他曾经三次被授予计算机视觉最高荣誉“马尔奖”Marr Prize
这篇论文的主要贡献是什么呢一个重要贡献就是研究如何把之前的模型例如AlexNet加深层次从而能够拥有更好的模型泛化能力最终实现更小的分类错误率。
为了更好地理解这篇文章的贡献我们来回忆一下AlexNet的架构。AlexNet拥有8层神经网络分别是5层卷积层和3层全联通层。AlexNet之所以能够有效地进行训练是因为这个模型利用了“线性整流函数”ReLu、数据增强Data Augmentation以及Dropout等手段。这些方法让AlexNet能够达到8层。
但是学术界一直以来都认为从理论上看神经网络应该是层数越多泛化能力越好。而且在理论上一个8层的神经网络完全可以加到18层或者80层。但是在现实中梯度消失和过拟合等情况让加深神经网络变得非常困难。在这篇论文中VGG网络就尝试从AlexNet出发看能否加入更多的神经网络层数来达到更好的模型效果。
那VGG是怎么做到加深神经网络层数的呢总体来说VGG对卷积层的“过滤器”Filter进行了更改达到了19层的网络结构。从结果上看和AlexNet相比VGG在ImageNet上的错误率要降低差不多一半。可以说这是第一个真正意义上达到了“深层”的网络结构。
VGG在“过滤器”上着手更改那么具体的改变细节究竟有哪些呢简单来说就是在卷积层中仅仅使用“3*3”的“接受域”Receptive Field使得每一层都非常小。我们可以从整个形象上来理解认为这是一组非常“瘦”的网络架构。在卷积层之后是三层全联通层以及最后一层进行分类任务的层。一个细节是VGG放弃了我们之前介绍的AlexNet中引入的一个叫“局部响应归一化”Local Response Normalization的技术原因是这个技巧并没有真正带来模型效果的提升。
VGG架构在训练上的一个要点是先从一个小的结构开始我们可以理解为首先训练一个AlexNet然后利用训练的结果来初始化更深结构的网络。作者们发现采用这种“初始训练”Pre-Training的办法要比完全从随机状态初始化模型训练得更加稳定。
GoogleNet
我们要分享的第二篇论文题目是《更深层的卷积》Going deeper with convolutions[2]。因为这篇论文的作者基本都来自于谷歌,所以文章提出的模型有时候又被叫作 GoogleNet。这篇论文拥有8千多次的引用数。
GoogleNet不仅和VGG一样在把架构做“深”上下文章而且在模型的效率上比AlexNet更加优秀。作者们利用了比AlexNet少12倍的参数在更深的架构上达到了更好的效果。
GoogleNet创新的重点是在网络架构上。和AlexNet以及VGG都不同的是GoogleNet的作者们认为更加合适的网络架构不是简单地把相同的卷积层叠加起来然后再把相同的全联通层叠加。如果我们需要更深的架构必须从原理上对网络架构有一个不同的理解。作者们认为网络结构必须走向“稀疏化”Sparsity才能够达到更深层次、更高效的目的。
那么能否直接用稀疏结构来进行网络的架构呢过去的经验表明这条路并不那么直观。第一直接利用稀疏的结构所表达的网络结构效果并不好第二这样做就无法利用现代的硬件特别是GPU的加速功能。现代的GPU之所以能够高效地处理视觉以及其他一系列类似的问题主要的原因就是快速的紧密矩阵运算。所以直接使用稀疏结构有一定的挑战。
这篇论文的核心思想就是希望用一组“局部的”Local紧密结构来逼近理想中的最优的稀疏化结构从而能够在计算上达到高效率同时在理论思想上能够利用稀疏化结构来达到更深的网络架构。
这种局部模块被作者们称作是Inception模块。什么意思呢传统上卷积层都是直接叠加起来的。而这篇论文提出的Inception模块其实就是让卷积层能够在水平方向上排列起来然后整个模块再进行垂直方向的叠加。至于水平方向排列多少个卷积层垂直方向排列多少Inception模块都是采用经验试错的方式来进行实验的。
这篇论文最终提出的GoogleNet有22层网络结构。如果把所有的平行结构都算上的话整个网络超过了100层。为了能够在这么深的结构上训练模型作者们还采用了一种方法那就是在中间的一些层次中插入分类器。相比之下我们之前遇到过的网络结构都是在最后一层才有一个分类器。分类器层的特点就是最终的标签信息会在这里被利用也就是说分类的准确性或者说是图片中物体究竟是什么都会被这个标签信息所涵盖。在中间层加入分类器其实就是希望标签信息能够正确引导中间层的目标并且能够让梯度依然有效经过。
在实验中GoogleNet模型可以说是达到了非常好的效果。在2014年的ImageNet竞赛中GoogleNet和VGG分列比赛的第一名和第二名。两个模型之间的差距仅有不到1个百分点。
小结
今天我为你讲了两篇基于深度学习的经典论文讨论了两个模型VGG和GoogleNet。这两个模型在AlexNet的基础上做了不少的革新。
一起回顾一下要点第一VGG模型对卷积层的“过滤器”进行了更改实现了19层的网络结构可以说是第一个真正意义上达到了“深层”的网络结构第二GoogleNet模型的创新是在网络架构上利用稀疏化结构达到了更深的网络架构。
最后给你留一个思考题总结和比较VGG和GoogleNet这两个模型我们看到了深度模型研发的一个什么趋势
欢迎你给我留言,我们一起讨论。
参考文献
K. Simonyan, A. Zisserman. Very Deep Convolutional Networks for Large-Scale Image Recognition. International Conference on Learning Representations, 2015.
C. Szegedy et al. Going deeper with convolutions. IEEE Conference on Computer Vision and Pattern Recognition (CVPR), Boston, MA, pp. 1-9, 2015.

View File

@ -0,0 +1,304 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 应用编排与管理Job & DaemonSet
Job
需求来源
Job 背景问题
首先我们来看一下 Job 的需求来源。我们知道 K8s 里面,最小的调度单元是 Pod我们可以直接通过 Pod 来运行任务进程。这样做将会产生以下几种问题:
我们如何保证 Pod 内进程正确的结束?
如何保证进程运行失败后重试?
如何管理多个任务,且任务之间有依赖关系?
如何并行地运行任务,并管理任务的队列大小?
Job管理任务的控制器
我们来看一下 Kubernetes 的 Job 为我们提供了什么功能:
首先 kubernetes 的 Job 是一个管理任务的控制器,它可以创建一个或多个 Pod 来指定 Pod 的数量,并可以监控它是否成功地运行或终止;
我们可以根据 Pod 的状态来给 Job 设置重置的方式及重试的次数;
我们还可以根据依赖关系,保证上一个任务运行完成之后再运行下一个任务;
同时还可以控制任务的并行度,根据并行度来确保 Pod 运行过程中的并行次数和总体完成大小。
用例解读
我们根据一个实例来看一下Job是如何来完成下面的应用的。
Job 语法
上图是 Job 最简单的一个 yaml 格式,这里主要新引入了一个 kind 叫 Job这个 Job 其实就是 job-controller 里面的一种类型。 然后 metadata 里面的 name 来指定这个 Job 的名称,下面 spec.template 里面其实就是 pod 的 spec。
这里面的内容都是一样的,唯一多了两个点:
第一个是 restartPolicy在 Job 里面我们可以设置 Never、OnFailure、Always 这三种重试策略。在希望 Job 需要重新运行的时候,我们可以用 Never希望在失败的时候再运行再重试可以用 OnFailure或者不论什么情况下都重新运行时 Alway
另外Job 在运行的时候不可能去无限的重试,所以我们需要一个参数来控制重试的次数。这个 backoffLimit 就是来保证一个 Job 到底能重试多少次。
所以在 Job 里面,我们主要重点关注的一个是 restartPolicy 重启策略和 backoffLimit 重试次数限制。
Job 状态
Job 创建完成之后,我们就可以通过 kubectl get jobs 这个命令,来查看当前 job 的运行状态。得到的值里面,基本就有 Job 的名称、当前完成了多少个 Pod进行多长时间。
AGE的含义是指这个 Pod 从当前时间算起,减去它当时创建的时间。这个时长主要用来告诉你 Pod 的历史、Pod 距今创建了多长时间。
DURATION主要来看我们 Job 里面的实际业务到底运行了多长时间当我们的性能调优的时候这个参数会非常的有用。COMPLETIONS主要来看我们任务里面这个 Pod 一共有几个,然后它其中完成了多少个状态,会在这个字段里面做显示。
查看 Pod
下面我们来看一下 Pod其实 Job 最后的执行单元还是 Pod。我们刚才创建的 Job 会创建出来一个叫“pi”的一个 Pod这个任务就是来计算这个圆周率Pod 的名称会以“jobnamejobname{random-suffix}”,我们可以看一下下面 Pod 的 yaml 格式。
它比普通的 Pod 多了一个叫 ownerReferences这个东西来声明此 pod 是归哪个上一层 controller 来管理。可以看到这里的 ownerReferences 是归 batch/v1也就是上一个 Job 来管理的。这里就声明了它的 controller 是谁,然后可以通过 pod 返查到它的控制器是谁,同时也能根据 Job 来查一下它下属有哪些 Pod。
并行运行 Job
我们有时候有些需求:希望 Job 运行的时候可以最大化的并行,并行出 n 个 Pod 去快速地执行。同时,由于我们的节点数有限制,可能也不希望同时并行的 Pod 数过多有那么一个管道的概念我们可以希望最大的并行度是多少Job 控制器都可以帮我们来做到。
这里主要看两个参数:一个是 completions一个是 parallelism。
首先第一个参数是用来指定本 Pod 队列执行次数。可能这个不是很好理解,其实可以把它认为是这个 Job 指定的可以运行的总次数。比如这里设置成 8即这个任务一共会被执行 8 次;
第二个参数代表这个并行执行的个数。所谓并行执行的次数,其实就是一个管道或者缓冲器中缓冲队列的大小,把它设置成 2也就是说这个 Job 一定要执行 8 次,每次并行 2 个 Pod这样的话一共会执行 4 个批次。
查看并行 Job 运行
下面来看一下它的实际运行效果,上图就是当这个 Job 整体运行完毕之后可以看到的效果,首先看到 job 的名字,然后看到它一共创建出来了 8 个 pod执行了 2 分 23 秒,这是创建的时间。
接着来看真正的 podspods 总共出来了 8 个 pod每个 pod 的状态都是完成的,然后来看一下它的 AGE就是时间。从下往上看可以看到分别有 73s、40s、110s 和 2m26s。每一组都有两个 pod 时间是相同的,即:时间段是 40s 的时候是最后一个创建、 2m26s 是第一个创建的。也就是说,总是两个 pod 同时创建出来,并行完毕、消失,然后再创建、再运行、再完毕。
比如说,刚刚我们其实通过第二个参数来控制了当前 Job 并行执行的次数,这里就可以了解到这个缓冲器或者说管道队列大小的作用。
Cronjob 语法
下面来介绍另外一个 Job叫做 CronJob其实也可以叫定时运行 Job。CronJob 其实和 Job 大体是相似的,唯一的不同点就是它可以设计一个时间。比如说可以定时在几点几分执行,特别适合晚上做一些清理任务,还有可以几分钟执行一次,几小时执行一次等等,这就叫定时任务。
定时任务和 Job 相比会多几个不同的字段:
scheduleschedule 这个字段主要是设置时间格式,它的时间格式和 Linux 的 crontime 是一样的,所以直接根据 Linux 的 crontime 书写格式来书写就可以了。举个例子: */1 指每分钟去执行一下 Job这个 Job 需要做的事情就是打印出大约时间然后打印出“Hello from the kubernetes cluster” 这一句话;
startingDeadlineSeconds每次运行 Job 的时候,它最长可以等多长时间,有时这个 Job 可能运行很长时间也不会启动。所以这时如果超过较长时间的话CronJob 就会停止这个 Job
concurrencyPolicy就是说是否允许并行运行。所谓的并行运行就是比如说我每分钟执行一次但是这个 Job 可能运行的时间特别长,假如两分钟才能运行成功,也就是第二个 Job 要到时间需要去运行的时候,上一个 Job 还没完成。如果这个 policy 设置为 true 的话,那么不管你前面的 Job 是否运行完成,每分钟都会去执行;如果是 false它就会等上一个 Job 运行完成之后才会运行下一个;
JobsHistoryLimit这个就是每一次 CronJob 运行完之后,它都会遗留上一个 Job 的运行历史、查看时间。当然这个额不能是无限的,所以需要设置一下历史存留数,一般可以设置默认 10 个或 100 个都可以,这主要取决于每个人集群不同,然后根据每个人的集群数来确定这个时间。
操作演示
Job 的编排文件
下面看一下具体如何使用 Job。
Job 的创建及运行验证
首先看一下 job.yaml。这是一个非常简单的计算 pi 的一个任务。使用 kubectl creat-f job.yaml这样 job 就能提交成功了。来看一下 kubectl.get.jobs可以看到这个 job 正在运行get pods 可以看到这个 pod 应该是运行完成了,那么接下来 logs 一下这个 job 以及 pod。可以看到下图里面打印出来了圆周率。
并行 Job 的编排文件
下面再来看第二个例子:
并行 Job 的创建及运行验证
这个例子就是指刚才的并行运行 Job 创建之后,可以看到有第二个并行的 Job。
现在已经有两个 Pod 正在 running可以看到它大概执行了快到 30s
30s 之后它应该会起第二个。
第一批的 pod 已经执行完毕,第二批的 pod 正在 running每批次分别是两个Pod。也就是说后面每隔 40s 左右,就会有两个 pod 在并行执行,它一共会执行 4 批,共 8 个 pod等到所有的 pod 执行完毕,就是刚才所说的并行执行的缓冲队列功能。
过一段时间再看这个 pods可以发现第二批已经执行结束接下来开始创建第三批……
Cronjob 的编排文件
下面来看第三个例子 —— CronJob。 CronJob 是每分钟执行一次,每次一个 job。
Cronjob 的创建及运行验证
如下图 CronJob 已经创建了,可以通过 get cronjob 来看到当前有一个 CronJob这个时候再来看 jobs由于它是每分钟执行一次所以得稍微等一下。
同时可以看到,上一个 job 还在运行,它的时间是 2m12s 左右,它的完成度是 7/8、6/8刚刚看到 78 到 8/8也就是说我们上一个任务执行了最后一步而且每次都是两个两个地去运行。每次两个运行的 job 都会让我们在运行一些大型工作流或者工作任务的时候感到特别的方便。
上图中可以看到突然出现了一个 job“hello-xxxx”这个 job 就是刚才所说的 CronJob。它距离刚才 CronJob 提交已经过去 1 分钟了,这样就会自动创建出来一个 job如果不去干扰它的话它以后大概每一分钟都会创建出来这么一个 job除非等我们什么时候指定它不可以再运行的时候它才会停止创建。
在这里 CronJob 其实主要是用来运作一些清理任务或者说执行一些定时任务。比如说 Jenkins 构建等方面的一些任务,会特别有效。
架构设计
Job 管理模式
我们来看一下 job 的架构设计。Job Controller 其实还是主要去创建相对应的 pod然后 Job Controller 会去跟踪 Job 的状态,及时地根据我们提交的一些配置重试或者继续创建。同时我们刚刚也提到,每个 pod 会有它对应的 label来跟踪它所属的 Job Controller并且还去配置并行的创建 并行或者串行地去创建 pod。
Job 控制器
上图是一个 Job 控制器的主要流程。所有的 job 都是一个 controller它会 watch 这个 API Server我们每次提交一个 Job 的 yaml 都会经过 api-server 传到 ETCD 里面去,然后 Job Controller 会注册几个 Handler每当有添加、更新、删除等操作的时候它会通过一个内存级的消息队列发到 controller 里面。
通过 Job Controller 检查当前是否有运行的 pod如果没有的话通过 Scale up 把这个 pod 创建出来;如果有的话,或者如果大于这个数,对它进行 Scale down如果这时 pod 发生了变化,需要及时 Update 它的状态。
同时要去检查它是否是并行的 job或者是串行的 job根据设置的配置并行度、串行度及时地把 pod 的数量给创建出来。最后,它会把 job 的整个的状态更新到 API Server 里面去,这样我们就能看到呈现出来的最终效果了。
DaemonSet
需求来源
DaemonSet 背景问题
下面介绍第二个控制器DaemonSet。同样的问题如果我们没有 DaemonSet 会怎么样?下面有几个需求:
首先如果希望每个节点都运行同样一个 pod 怎么办?
如果新节点加入集群的时候,想要立刻感知到它,然后去部署一个 pod帮助我们初始化一些东西这个需求如何做
如果有节点退出的时候,希望对应的 pod 会被删除掉,应该怎么操作?
如果 pod 状态异常的时候,我们需要及时地监控这个节点异常,然后做一些监控或者汇报的一些动作,那么这些东西运用什么控制器来做?
DaemonSet守护进程控制器
DaemonSet 也是 Kubernetes 提供的一个 default controller它实际是做一个守护进程的控制器它能帮我们做到以下几件事情
首先能保证集群内的每一个节点都运行一组相同的 pod
同时还能根据节点的状态保证新加入的节点自动创建对应的 pod
在移除节点的时候,能删除对应的 pod
而且它会跟踪每个 pod 的状态,当这个 pod 出现异常、Crash 掉了,会及时地去 recovery 这个状态。
用例解读
DaemonSet 语法
下面举个例子来看一下DaemonSet.yaml 会稍微长一些。
首先是 kind:DaemonSet。如果前面学过 deployment 后,其实我们再看这个 yaml 会比较简单。例如它会有 matchLabel通过 matchLabel 去管理对应所属的 pod这个 pod.label 也要和这个 DaemonSet.controller.label 想匹配,它才能去根据 label.selector 去找到对应的管理 Pod。下面 spec.container 里面的东西都是一致的。
这里用 fluentd 来做例子。DaemonSet 最常用的点在于以下几点内容:
首先是存储GlusterFS 或者 Ceph 之类的东西,需要每台节点上都运行一个类似于 Agent 的东西DaemonSet 就能很好地满足这个诉求;
另外,对于日志收集,比如说 logstash 或者 fluentd这些都是同样的需求需要每台节点都运行一个 Agent这样的话我们可以很容易搜集到它的状态把各个节点里面的信息及时地汇报到上面
还有一个就是,需要每个节点去运行一些监控的事情,也需要每个节点去运行同样的事情,比如说 Promethues 这些东西,也需要 DaemonSet 的支持。
查看 DaemonSet 状态
创建完 DaemonSet 之后,我们可以使用 kubectl get DaemonSetDaemonSet 缩写为 ds。可以看到 DaemonSet 返回值和 deployment 特别像即它当前一共有正在运行的几个然后我们需要几个READY 了几个。当然这里面READY 都是只有 Pod所以它最后创建出来所有的都是 pod。
这里有几个参数,分别是:需要的 pod 个数、当前已经创建的 pod 个数、就绪的个数,以及所有可用的、通过健康检查的 pod还有 NODE SELECTOR因为 NODE SELECTOR 在 DaemonSet 里面非常有用。有时候我们可能希望只有部分节点去运行这个 pod 而不是所有的节点所以有些节点上被打了标的话DaemonSet 就只运行在这些节点上。比如,我只希望 master 节点运行某些 pod或者只希望 Worker 节点运行某些 pod就可以使用这个 NODE SELECTOR。
更新 DaemonSet
其实 DaemonSet 和 deployment 特别像,它也有两种更新策略:一个是 RollingUpdate另一个是 OnDelete。
RollingUpdate 其实比较好理解,就是会一个一个的更新。先更新第一个 pod然后老的 pod 被移除,通过健康检查之后再去见第二个 pod这样对于业务上来说会比较平滑地升级不会中断
OnDelete 其实也是一个很好的更新策略就是模板更新之后pod 不会有任何变化,需要我们手动控制。我们去删除某一个节点对应的 pod它就会重建不删除的话它就不会重建这样的话对于一些我们需要手动控制的特殊需求也会有特别好的作用。
操作演示
DaemonSet 的编排
下面举一个例子。比如说我们去改了些 DaemonSet 的镜像,然后看到了它的状态,它就会去一个一个地更新。
上图这个就是刚才 DaemonSet 的 yaml会比刚才会多一些 我们做一些资源的限制,这个都不影响。
DaemonSet 的创建与运行验证
下面我们创建一下 DaemonSet ,然后再看一下它的状态。下图就是我们刚才看到的 DaemonSet 在 ready 里打出来的状态。
从下图中可以看到,一共有 4 个 pod 被创建出来。为什么是 4 个 pod呢因为只有 4 个节点,所以每个节点上都会运行一个对应的 pod。
DaemonSet 的更新
这时,我们来更新 DaemonSet 执行完了kubectl apply -f 后,它的 DaemonSet 就已经更新了。接下来我们去查看 DaemonSet 的更新状态。
上图中可以看到DaemonSet 默认这个是 RollingUpdate 的,我们看到是 0-4现在是 1-4也就是说它在更新第一个第一个更新完成会去更新第二个第二个更新完就更新第三个……这个就是 RollingUpdate。RollingUpdate 可以做到全自动化的更新,不用有人值守,而是一个一个地去自动更新,更新的过程也比较平滑,这样可以有利于我们在现场发布或者做一些其他操作。
上图结尾处可以看到,整个的 DaemonSet 已经 RollingUpdate 完毕。
架构设计
DaemonSet 管理模式
接下来看一下 DaemonSet 架构设计。DaemonSet 还是一个 controller它最后真正的业务单元也是 PodDaemonSet 其实和 Job controller 特别相似,它也是通过 controller 去 watch API Server 的状态,然后及时地添加 pod。唯一不同的是它会监控节点的状态节点新加入或者消失的时候会在节点上创建对应的 pod然后同时根据你配置的一些 affinity 或者 label 去选择对应的节点。
DaemonSet 控制器
最后我们来看一下 DaemonSet 的控制器DaemonSet 其实和 Job controller 做的差不多:两者都需要根据 watch 这个 API Server 的状态。现在 DaemonSet 和 Job controller 唯一的不同点在于DaemonsetSet Controller需要去 watch node 的状态,但其实这个 node 的状态还是通过 API Server 传递到 ETCD 上。
当有 node 状态节点发生变化时它会通过一个内存消息队列发进来然后DaemonSet controller 会去 watch 这个状态,看一下各个节点上是都有对应的 Pod如果没有的话就去创建。当然它会去做一个对比如果有的话它会比较一下版本然后加上刚才提到的是否去做 RollingUpdate如果没有的话就会重新创建Ondelete 删除 pod 的时候也会去做 check 它做一遍检查,是否去更新,或者去创建对应的 pod。
当然最后的时候,如果全部更新完了之后,它会把整个 DaemonSet 的状态去更新到 API Server 上,完成最后全部的更新。
本节总结
Job & CronJobs 基础操作与概念解析:本节详细介绍了 Job 和 CronJob 的概念,并通过两个实际的例子介绍了 Job 和 CronJob 的使用,对于 Job 和 CronJob 内的各种功能便签都进行了详细的演示;
DaemonSet 基础操作与概念解析:通过类比 Deployment 控制器,我们理解了一下 DaemonSet 控制器的工作流程与方式,并且通过对 DaemonSet 的更新了解了滚动更新的概念和相对应的操作方式。

View File

@ -0,0 +1,710 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 加餐HTTP 协议 + JSON-RPCDubbo 跨语言就是如此简单
在前面课时介绍 Protocol 和 Invoker 实现时,我们重点介绍了 AbstractProtocol 以及 DubboInvoker 实现。其实Protocol 还有一个实现分支是 AbstractProxyProtocol如下图所示
AbstractProxyProtocol 继承关系图
从图中我们可以看到gRPC、HTTP、WebService、Hessian、Thrift 等协议对应的 Protocol 实现,都是继承自 AbstractProxyProtocol 抽象类。
目前互联网的技术栈百花齐放,很多公司会使用 Node.js、Python、Rails、Go 等语言来开发 一些 Web 端应用,同时又有很多服务会使用 Java 技术栈实现这就出现了大量的跨语言调用的需求。Dubbo 作为一个 RPC 框架,自然也希望能实现这种跨语言的调用,目前 Dubbo 中使用“HTTP 协议 + JSON-RPC”的方式来达到这一目的其中 HTTP 协议和 JSON 都是天然跨语言的标准,在各种语言中都有成熟的类库。
下面我们就重点来分析 Dubbo 对 HTTP 协议的支持。首先,我会介绍 JSON-RPC 的基础,并通过一个示例,帮助你快速入门,然后介绍 Dubbo 中 HttpProtocol 的具体实现,也就是如何将 HTTP 协议与 JSON-RPC 结合使用,实现跨语言调用的效果。
JSON-RPC
Dubbo 中支持的 HTTP 协议实际上使用的是 JSON-RPC 协议。
JSON-RPC 是基于 JSON 的跨语言远程调用协议。Dubbo 中的 dubbo-rpc-xml、dubbo-rpc-webservice 等模块支持的 XML-RPC、WebService 等协议与 JSON-RPC 一样,都是基于文本的协议,只不过 JSON 的格式比 XML、WebService 等格式更加简洁、紧凑。与 Dubbo 协议、Hessian 协议等二进制协议相比JSON-RPC 更便于调试和实现,可见 JSON-RPC 协议还是一款非常优秀的远程调用协议。
在 Java 体系中,有很多成熟的 JSON-RPC 框架,例如 jsonrpc4j、jpoxy 等其中jsonrpc4j 本身体积小巧,使用方便,既可以独立使用,也可以与 Spring 无缝集合,非常适合基于 Spring 的项目。
下面我们先来看看 JSON-RPC 协议中请求的基本格式:
{
"id":1
"method":"sayHello",
"params":[
"Dubbo json-rpc"
]
}
JSON-RPC请求中各个字段的含义如下
id 字段,用于唯一标识一次远程调用。
method 字段,指定了调用的方法名。
params 数组,表示方法传入的参数,如果方法无参数传入,则传入空数组。
在 JSON-RPC 的服务端收到调用请求之后,会查找到相应的方法并进行调用,然后将方法的返回值整理成如下格式,返回给客户端:
{
"id":1
"result":"Hello Dubbo json-rpc",
"error":null
}
JSON-RPC响应中各个字段的含义如下
id 字段,用于唯一标识一次远程调用,该值与请求中的 id 字段值保持一致。
result 字段,记录了方法的返回值,若无返回值,则返回空;若调用错误,返回 null。
error 字段,表示调用发生异常时的异常信息,方法执行无异常时该字段为 null。
jsonrpc4j 基础使用
Dubbo 使用 jsonrpc4j 库来实现 JSON-RPC 协议,下面我们使用 jsonrpc4j 编写一个简单的 JSON-RPC 服务端示例程序和客户端示例程序,并通过这两个示例程序说明 jsonrpc4j 最基本的使用方式。
首先,我们需要创建服务端和客户端都需要的 domain 类以及服务接口。我们先来创建一个 User 类,作为最基础的数据对象:
public class User implements Serializable {
private int userId;
private String name;
private int age;
// 省略上述字段的getter/setter方法以及toString()方法
}
接下来创建一个 UserService 接口作为服务接口,其中定义了 5 个方法,分别用来创建 User、查询 User 以及相关信息、删除 User
public interface UserService {
User createUser(int userId, String name, int age);
User getUser(int userId);
String getUserName(int userId);
int getUserId(String name);
void deleteAll();
}
UserServiceImpl 是 UserService 接口的实现类,其中使用一个 ArrayList 集合管理 User 对象,具体实现如下:
public class UserServiceImpl implements UserService {
// 管理所有User对象
private List<User> users = new ArrayList<>();
@Override
public User createUser(int userId, String name, int age) {
System.out.println("createUser method");
User user = new User();
user.setUserId(userId);
user.setName(name);
user.setAge(age);
users.add(user); // 创建User对象并添加到users集合中
return user;
}
@Override
public User getUser(int userId) {
System.out.println("getUser method");
// 根据userId从users集合中查询对应的User对象
return users.stream().filter(u -> u.getUserId() == userId).findAny().get();
}
@Override
public String getUserName(int userId) {
System.out.println("getUserName method");
// 根据userId从users集合中查询对应的User对象之后获取该User的name
return getUser(userId).getName();
}
@Override
public int getUserId(String name) {
System.out.println("getUserId method");
// 根据name从users集合中查询对应的User对象然后获取该User的id
return users.stream().filter(u -> u.getName().equals(name)).findAny().get().getUserId();
}
@Override
public void deleteAll() {
System.out.println("deleteAll");
users.clear(); // 清空users集合
}
}
整个用户管理业务的核心大致如此。下面我们来看服务端如何将 UserService 与 JSON-RPC 关联起来。
首先,我们创建 RpcServlet 类,它是 HttpServlet 的子类,并覆盖了 HttpServlet 的 service() 方法。我们知道HttpServlet 在收到 GET 和 POST 请求的时候,最终会调用其 service() 方法进行处理HttpServlet 还会将 HTTP 请求和响应封装成 HttpServletRequest 和 HttpServletResponse 传入 service() 方法之中。这里的 RpcServlet 实现之中会创建一个 JsonRpcServer并在 service() 方法中将 HTTP 请求委托给 JsonRpcServer 进行处理:
public class RpcServlet extends HttpServlet {
private JsonRpcServer rpcServer = null;
public RpcServlet() {
super();
// JsonRpcServer会按照json-rpc请求调用UserServiceImpl中的方法
rpcServer = new JsonRpcServer(new UserServiceImpl(), UserService.class);
}
@Override
protected void service(HttpServletRequest request,
HttpServletResponse response) throws ServletException, IOException {
rpcServer.handle(request, response);
}
}
最后,我们创建一个 JsonRpcServer 作为服务端的入口类,在其 main() 方法中会启动 Jetty 作为 Web 容器,具体实现如下:
public class JsonRpcServer {
public static void main(String[] args) throws Throwable {
// 服务器的监听端口
Server server = new Server(9999);
// 关联一个已经存在的上下文
WebAppContext context = new WebAppContext();
// 设置描述符位置
context.setDescriptor("/dubbo-demo/json-rpc-demo/src/main/webapp/WEB-INF/web.xml");
// 设置Web内容上下文路径
context.setResourceBase("/dubbo-demo/json-rpc-demo/src/main/webapp");
// 设置上下文路径
context.setContextPath("/");
context.setParentLoaderPriority(true);
server.setHandler(context);
server.start();
server.join();
}
}
这里使用到的 web.xml 配置文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<web-app
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
version="3.1">
<servlet>
<servlet-name>RpcServlet</servlet-name>
<servlet-class>com.demo.RpcServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>RpcServlet</servlet-name>
<url-pattern>/rpc</url-pattern>
</servlet-mapping>
</web-app>
完成服务端的编写之后,下面我们再继续编写 JSON-RPC 的客户端。在 JsonRpcClient 中会创建 JsonRpcHttpClient并通过 JsonRpcHttpClient 请求服务端:
public class JsonRpcClient {
private static JsonRpcHttpClient rpcHttpClient;
public static void main(String[] args) throws Throwable {
// 创建JsonRpcHttpClient
rpcHttpClient = new JsonRpcHttpClient(new URL("http://127.0.0.1:9999/rpc"));
JsonRpcClient jsonRpcClient = new JsonRpcClient();
jsonRpcClient.deleteAll(); // 调用deleteAll()方法删除全部User
// 调用createUser()方法创建User
System.out.println(jsonRpcClient.createUser(1, "testName", 30));
// 调用getUser()、getUserName()、getUserId()方法进行查询
System.out.println(jsonRpcClient.getUser(1));
System.out.println(jsonRpcClient.getUserName(1));
System.out.println(jsonRpcClient.getUserId("testName"));
}
public void deleteAll() throws Throwable {
// 调用服务端的deleteAll()方法
rpcHttpClient.invoke("deleteAll", null);
}
public User createUser(int userId, String name, int age) throws Throwable {
Object[] params = new Object[]{userId, name, age};
// 调用服务端的createUser()方法
return rpcHttpClient.invoke("createUser", params, User.class);
}
public User getUser(int userId) throws Throwable {
Integer[] params = new Integer[]{userId};
// 调用服务端的getUser()方法
return rpcHttpClient.invoke("getUser", params, User.class);
}
public String getUserName(int userId) throws Throwable {
Integer[] params = new Integer[]{userId};
// 调用服务端的getUserName()方法
return rpcHttpClient.invoke("getUserName", params, String.class);
}
public int getUserId(String name) throws Throwable {
String[] params = new String[]{name};
// 调用服务端的getUserId()方法
return rpcHttpClient.invoke("getUserId", params, Integer.class);
}
}
// 输出:
// User{userId=1, name='testName', age=30}
// User{userId=1, name='testName', age=30}
// testName
// 1
AbstractProxyProtocol
在 AbstractProxyProtocol 的 export() 方法中,首先会根据 URL 检查 exporterMap 缓存,如果查询失败,则会调用 ProxyFactory.getProxy() 方法将 Invoker 封装成业务接口的代理类,然后通过子类实现的 doExport() 方法启动底层的 ProxyProtocolServer并初始化 serverMap 集合。具体实现如下:
public <T> Exporter<T> export(final Invoker<T> invoker) throws RpcException {
// 首先查询exporterMap集合
final String uri = serviceKey(invoker.getUrl());
Exporter<T> exporter = (Exporter<T>) exporterMap.get(uri);
if (exporter != null) {
if (Objects.equals(exporter.getInvoker().getUrl(), invoker.getUrl())) {
return exporter;
}
}
// 通过ProxyFactory创建代理类将Invoker封装成业务接口的代理类
final Runnable runnable = doExport(proxyFactory.getProxy(invoker, true), invoker.getInterface(), invoker.getUrl());
// doExport()方法返回的Runnable是一个回调其中会销毁底层的Server将会在unexport()方法中调用该Runnable
exporter = new AbstractExporter<T>(invoker) {
public void unexport() {
super.unexport();
exporterMap.remove(uri);
if (runnable != null) {
runnable.run();
}
}
};
exporterMap.put(uri, exporter);
return exporter;
}
在 HttpProtocol 的 doExport() 方法中,与前面介绍的 DubboProtocol 的实现类似,也要启动一个 RemotingServer。为了适配各种 HTTP 服务器例如Tomcat、Jetty 等Dubbo 在 Transporter 层抽象出了一个 HttpServer 的接口。
dubbo-remoting-http 模块位置
dubbo-remoting-http 模块的入口是 HttpBinder 接口,它被 @SPI 注解修饰,是一个扩展接口,有三个扩展实现,默认使用的是 JettyHttpBinder 实现,如下图所示:
JettyHttpBinder 继承关系图
HttpBinder 接口中的 bind() 方法被 @Adaptive 注解修饰,会根据 URL 的 server 参数选择相应的 HttpBinder 扩展实现,不同 HttpBinder 实现返回相应的 HttpServer 实现。HttpServer 的继承关系如下图所示:
HttpServer 继承关系图
这里我们以 JettyHttpServer 为例简单介绍 HttpServer 的实现,在 JettyHttpServer 中会初始化 Jetty Server其中会配置 Jetty Server 使用到的线程池以及处理请求 Handler
public JettyHttpServer(URL url, final HttpHandler handler) {
// 初始化AbstractHttpServer中的url字段和handler字段
super(url, handler);
this.url = url;
DispatcherServlet.addHttpHandler( // 添加HttpHandler
url.getParameter(Constants.BIND_PORT_KEY,
url.getPort()), handler);
// 创建线程池
int threads = url.getParameter(THREADS_KEY, DEFAULT_THREADS);
QueuedThreadPool threadPool = new QueuedThreadPool();
threadPool.setDaemon(true);
threadPool.setMaxThreads(threads);
threadPool.setMinThreads(threads);
// 创建Jetty Server
server = new Server(threadPool);
// 创建ServerConnector并指定绑定的ip和port
ServerConnector connector = new ServerConnector(server);
String bindIp = url.getParameter(Constants.BIND_IP_KEY, url.getHost());
if (!url.isAnyHost() && NetUtils.isValidLocalHost(bindIp)) {
connector.setHost(bindIp);
}
connector.setPort(url.getParameter(Constants.BIND_PORT_KEY, url.getPort()));
server.addConnector(connector);
// 创建ServletHandler并与Jetty Server关联由DispatcherServlet处理全部的请求
ServletHandler servletHandler = new ServletHandler();
ServletHolder servletHolder = servletHandler.addServletWithMapping(DispatcherServlet.class, "/*");
servletHolder.setInitOrder(2);
// 创建ServletContextHandler并与Jetty Server关联
ServletContextHandler context = new ServletContextHandler(server, "/", ServletContextHandler.SESSIONS);
context.setServletHandler(servletHandler);
ServletManager.getInstance().addServletContext(url.getParameter(Constants.BIND_PORT_KEY, url.getPort()), context.getServletContext());
server.start();
}
我们可以看到 JettyHttpServer 收到的全部请求将委托给 DispatcherServlet 这个 HttpServlet 实现,而 DispatcherServlet 的 service() 方法会把请求委托给对应接端口的 HttpHandler 处理:
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 从HANDLERS集合中查询端口对应的HttpHandler对象
HttpHandler handler = HANDLERS.get(request.getLocalPort());
if (handler == null) { // 端口没有对应的HttpHandler实现
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Service not found.");
} else { // 将请求委托给HttpHandler对象处理
handler.handle(request, response);
}
}
了解了 Dubbo 对 HttpServer 的抽象以及 JettyHttpServer 的核心之后,我们回到 HttpProtocol 中的 doExport() 方法继续分析。
在 HttpProtocol.doExport() 方法中会通过 HttpBinder 创建前面介绍的 HttpServer 对象,并记录到 serverMap 中用来接收 HTTP 请求。这里初始化 HttpServer 以及处理请求用到的 HttpHandler 是 HttpProtocol 中的内部类,在其他使用 HTTP 协议作为基础的 RPC 协议实现中也有类似的 HttpHandler 实现类,如下图所示:
HttpHandler 继承关系图
在 HttpProtocol.InternalHandler 中的 handle() 实现中,会将请求委托给 skeletonMap 集合中记录的 JsonRpcServer 对象进行处理:
public void handle(HttpServletRequest request, HttpServletResponse response) throws ServletException {
String uri = request.getRequestURI();
JsonRpcServer skeleton = skeletonMap.get(uri);
if (cors) { ... // 处理跨域问题 }
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
response.setStatus(200); // 处理OPTIONS请求
} else if (request.getMethod().equalsIgnoreCase("POST")) {
// 只处理POST请求
RpcContext.getContext().setRemoteAddress(
request.getRemoteAddr(), request.getRemotePort());
skeleton.handle(request.getInputStream(), response.getOutputStream());
} else {// 其他Method类型的请求例如GET请求直接返回500
response.setStatus(500);
}
}
skeletonMap 集合中的 JsonRpcServer 是与 HttpServer 对象一同在 doExport() 方法中初始化的。最后,我们来看 HttpProtocol.doExport() 方法的实现:
protected <T> Runnable doExport(final T impl, Class<T> type, URL url) throws RpcException {
String addr = getAddr(url);
// 先查询serverMap缓存
ProtocolServer protocolServer = serverMap.get(addr);
if (protocolServer == null) { // 查询缓存失败
// 创建HttpServer,注意传入的HttpHandler实现是InternalHandler
RemotingServer remotingServer = httpBinder.bind(url, new InternalHandler(url.getParameter("cors", false)));
serverMap.put(addr, new ProxyProtocolServer(remotingServer));
}
// 创建JsonRpcServer对象并将URL与JsonRpcServer的映射关系记录到skeletonMap集合中
final String path = url.getAbsolutePath();
final String genericPath = path + "/" + GENERIC_KEY;
JsonRpcServer skeleton = new JsonRpcServer(impl, type);
JsonRpcServer genericServer = new JsonRpcServer(impl, GenericService.class);
skeletonMap.put(path, skeleton);
skeletonMap.put(genericPath, genericServer);
return () -> {// 返回Runnable回调在Exporter中的unexport()方法中执行
skeletonMap.remove(path);
skeletonMap.remove(genericPath);
};
}
介绍完 HttpProtocol 暴露服务的相关实现之后,下面我们再来看 HttpProtocol 中引用服务相关的方法实现,即 protocolBindinRefer() 方法实现。该方法首先通过 doRefer() 方法创建业务接口的代理,这里会使用到 jsonrpc4j 库中的 JsonProxyFactoryBean 与 Spring 进行集成,在其 afterPropertiesSet() 方法中会创建 JsonRpcHttpClient 对象:
public void afterPropertiesSet() {
... ... // 省略ObjectMapper等对象
try {
// 创建JsonRpcHttpClient用于后续发送json-rpc请求
jsonRpcHttpClient = new JsonRpcHttpClient(objectMapper, new URL(getServiceUrl()), extraHttpHeaders);
jsonRpcHttpClient.setRequestListener(requestListener);
jsonRpcHttpClient.setSslContext(sslContext);
jsonRpcHttpClient.setHostNameVerifier(hostNameVerifier);
} catch (MalformedURLException mue) {
throw new RuntimeException(mue);
}
}
下面来看 doRefer() 方法的具体实现:
protected <T> T doRefer(final Class<T> serviceType, URL url) throws RpcException {
final String generic = url.getParameter(GENERIC_KEY);
final boolean isGeneric = ProtocolUtils.isGeneric(generic) || serviceType.equals(GenericService.class);
JsonProxyFactoryBean jsonProxyFactoryBean = new JsonProxyFactoryBean();
... // 省略其他初始化逻辑
jsonProxyFactoryBean.afterPropertiesSet();
// 返回的是serviceType类型的代理对象
return (T) jsonProxyFactoryBean.getObject();
}
在 AbstractProxyProtocol.protocolBindingRefer() 方法中,会通过 ProxyFactory.getInvoker() 方法将 doRefer() 方法返回的代理对象转换成 Invoker 对象,并记录到 Invokers 集合中,具体实现如下:
protected <T> Invoker<T> protocolBindingRefer(final Class<T> type, final URL url) throws RpcException {
final Invoker<T> target = proxyFactory.getInvoker(doRefer(type, url), type, url);
Invoker<T> invoker = new AbstractInvoker<T>(type, url) {
@Override
protected Result doInvoke(Invocation invocation) throws Throwable {
Result result = target.invoke(invocation);
// 省略处理异常的逻辑
return result;
}
};
invokers.add(invoker); // 将Invoker添加到invokers集合中
return invoker;
}
总结
本课时重点介绍了在 Dubbo 中如何通过“HTTP 协议 + JSON-RPC”的方案实现跨语言调用。首先我们介绍了 JSON-RPC 中请求和响应的基本格式,以及其实现库 jsonrpc4j 的基本使用;接下来我们还详细介绍了 Dubbo 中 AbstractProxyProtocol、HttpProtocol 等核心类,剖析了 Dubbo 中“HTTP 协议 + JSON-RPC”方案的落地实现。

View File

@ -0,0 +1,378 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 PersistentVolume + NFS怎么使用网络共享存储
你好我是Chrono。
在上节课里我们看到了Kubernetes里的持久化存储对象PersistentVolume、PersistentVolumeClaim、StorageClass把它们联合起来就可以为Pod挂载一块“虚拟盘”让Pod在其中任意读写数据。
不过当时我们使用的是HostPath存储卷只能在本机使用而Kubernetes里的Pod经常会在集群里“漂移”所以这种方式不是特别实用。
要想让存储卷真正能被Pod任意挂载我们需要变更存储的方式不能限定在本地磁盘而是要改成网络存储这样Pod无论在哪里运行只要知道IP地址或者域名就可以通过网络通信访问存储设备。
网络存储是一个非常热门的应用领域有很多知名的产品比如AWS、Azure、CephKubernetes还专门定义了CSIContainer Storage Interface规范不过这些存储类型的安装、使用都比较复杂在我们的实验环境里部署难度比较高。
所以今天的这次课里我选择了相对来说比较简单的NFS系统Network File System以它为例讲解如何在Kubernetes里使用网络存储以及静态存储卷和动态存储卷的概念。
如何安装NFS服务器
作为一个经典的网络存储系统NFS有着近40年的发展历史基本上已经成为了各种UNIX系统的标准配置Linux自然也提供对它的支持。
NFS采用的是Client/Server架构需要选定一台主机作为Server安装NFS服务端其他要使用存储的主机作为Client安装NFS客户端工具。
所以接下来我们在自己的Kubernetes集群里再增添一台名字叫Storage的服务器在上面安装NFS实现网络存储、共享网盘的功能。不过这台Storage也只是一个逻辑概念我们在实际安装部署的时候完全可以把它合并到集群里的某台主机里比如这里我就复用了[第17讲]里的Console。
新的网络架构如下图所示:
在Ubuntu系统里安装NFS服务端很容易使用apt即可
sudo apt -y install nfs-kernel-server
安装好之后你需要给NFS指定一个存储位置也就是网络共享目录。一般来说应该建立一个专门的 /data 目录,这里为了简单起见,我就使用了临时目录 /tmp/nfs
mkdir -p /tmp/nfs
接下来你需要配置NFS访问共享目录修改 /etc/exports指定目录名、允许访问的网段还有权限等参数。这些规则比较琐碎和我们的Kubernetes课程关联不大我就不详细解释了你只要把下面这行加上就行注意目录名和IP地址要改成和自己的环境一致
/tmp/nfs 192.168.10.0/24(rw,sync,no_subtree_check,no_root_squash,insecure)
改好之后,需要用 exportfs -ra 通知NFS让配置生效再用 exportfs -v 验证效果:
sudo exportfs -ra
sudo exportfs -v
现在,你就可以使用 systemctl 来启动NFS服务器了
sudo systemctl start nfs-server
sudo systemctl enable nfs-server
sudo systemctl status nfs-server
你还可以使用命令 showmount 来检查NFS的网络挂载情况
showmount -e 127.0.0.1
如何安装NFS客户端
有了NFS服务器之后为了让Kubernetes集群能够访问NFS存储服务我们还需要在每个节点上都安装NFS客户端。
这项工作只需要一条apt命令不需要额外的配置
sudo apt -y install nfs-common
同样,在节点上可以用 showmount 检查NFS能否正常挂载注意IP地址要写成NFS服务器的地址我在这里就是“192.168.10.208”:
现在让我们尝试手动挂载一下NFS网络存储先创建一个目录 /tmp/test 作为挂载点:
mkdir -p /tmp/test
然后用命令 mount 把NFS服务器的共享目录挂载到刚才创建的本地目录上
sudo mount -t nfs 192.168.10.208:/tmp/nfs /tmp/test
最后测试一下,我们在 /tmp/test 里随便创建一个文件,比如 x.yml
touch /tmp/test/x.yml
再回到NFS服务器检查共享目录 /tmp/nfs应该会看到也出现了一个同样的文件 x.yml这就说明NFS安装成功了。之后集群里的任意节点只要通过NFS客户端就能把数据写入NFS服务器实现网络存储。
如何使用NFS存储卷
现在我们已经为Kubernetes配置好了NFS存储系统就可以使用它来创建新的PV存储对象了。
先来手工分配一个存储卷,需要指定 storageClassName 是 nfs而 accessModes 可以设置成 ReadWriteMany这是由NFS的特性决定的它支持多个节点同时访问一个共享目录。
因为这个存储卷是NFS系统所以我们还需要在YAML里添加 nfs 字段指定NFS服务器的IP地址和共享目录名。
这里我在NFS服务器的 /tmp/nfs 目录里又创建了一个新的目录 1g-pv表示分配了1GB的可用存储空间相应的PV里的 capacity 也要设置成同样的数值,也就是 1Gi。
把这些字段都整理好后我们就得到了一个使用NFS网络存储的YAML描述文件
apiVersion: v1
kind: PersistentVolume
metadata:
name: nfs-1g-pv
spec:
storageClassName: nfs
accessModes:
- ReadWriteMany
capacity:
storage: 1Gi
nfs:
path: /tmp/nfs/1g-pv
server: 192.168.10.208
现在就可以用命令 kubectl apply 来创建PV对象再用 kubectl get pv 查看它的状态:
kubectl apply -f nfs-static-pv.yml
kubectl get pv
再次提醒你注意spec.nfs 里的IP地址一定要正确路径一定要存在事先创建好否则Kubernetes按照PV的描述会无法挂载NFS共享目录PV就会处于“pending”状态无法使用。
有了PV我们就可以定义申请存储的PVC对象了它的内容和PV差不多但不涉及NFS存储的细节只需要用 resources.request 来表示希望要有多大的容量这里我写成1GB和PV的容量相同
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs-static-pvc
spec:
storageClassName: nfs
accessModes:
- ReadWriteMany
resources:
requests:
storage: 1Gi
创建PVC对象之后Kubernetes就会根据PVC的描述找到最合适的PV把它们“绑定”在一起也就是存储分配成功
我们再创建一个Pod把PVC挂载成它的一个volume具体的做法和[上节课]是一样的,用 persistentVolumeClaim 指定PVC的名字就可以了
apiVersion: v1
kind: Pod
metadata:
name: nfs-static-pod
spec:
volumes:
- name: nfs-pvc-vol
persistentVolumeClaim:
claimName: nfs-static-pvc
containers:
- name: nfs-pvc-test
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: nfs-pvc-vol
mountPath: /tmp
Pod、PVC、PV和NFS存储的关系可以用下图来形象地表示你可以对比一下HostPath PV的用法看看有什么不同
因为我们在PV/PVC里指定了 storageClassName 是 nfs节点上也安装了NFS客户端所以Kubernetes就会自动执行NFS挂载动作把NFS的共享目录 /tmp/nfs/1g-pv 挂载到Pod里的 /tmp完全不需要我们去手动管理。
最后还是测试一下,用 kubectl apply 创建Pod之后我们用 kubectl exec 进入Pod再试着操作NFS共享目录
退出Pod再看一下NFS服务器的 /tmp/nfs/1g-pv 目录你就会发现Pod里创建的文件确实写入了共享目录
而且更好的是因为NFS是一个网络服务不会受Pod调度位置的影响所以只要网络通畅这个PV对象就会一直可用数据也就实现了真正的持久化存储。
如何部署NFS Provisoner
现在有了NFS这样的网络存储系统你是不是认为Kubernetes里的数据持久化问题就已经解决了呢
对于这个问题,我觉得可以套用一句现在的流行语:“解决了,但没有完全解决。”
说它“解决了”是因为网络存储系统确实能够让集群里的Pod任意访问数据在Pod销毁后仍然存在新创建的Pod可以再次挂载然后读取之前写入的数据整个过程完全是自动化的。
说它“没有完全解决”是因为PV还是需要人工管理必须要由系统管理员手动维护各种存储设备再根据开发需求逐个创建PV而且PV的大小也很难精确控制容易出现空间不足或者空间浪费的情况。
在我们的这个实验环境里只有很少的PV需求管理员可以很快分配PV存储卷但是在一个大集群里每天可能会有几百几千个应用需要PV存储如果仍然用人力来管理分配存储管理员很可能会忙得焦头烂额导致分配存储的工作大量积压。
那么能不能让创建PV的工作也实现自动化呢或者说让计算机来代替人类来分配存储卷呢
这个在Kubernetes里就是“动态存储卷”的概念它可以用StorageClass绑定一个Provisioner对象而这个Provisioner就是一个能够自动管理存储、创建PV的应用代替了原来系统管理员的手工劳动。
有了“动态存储卷”的概念前面我们讲的手工创建的PV就可以称为“静态存储卷”。
目前Kubernetes里每类存储设备都有相应的Provisioner对象对于NFS来说它的Provisioner就是“NFS subdir external provisioner”你可以在GitHub上找到这个项目https://github.com/kubernetes-sigs/nfs-subdir-external-provisioner
NFS Provisioner也是以Pod的形式运行在Kubernetes里的在GitHub的 deploy 目录里是部署它所需的YAML文件一共有三个分别是rbac.yaml、class.yaml和deployment.yaml。
不过这三个文件只是示例,想在我们的集群里真正运行起来还要修改其中的两个文件。
第一个要修改的是rbac.yaml它使用的是默认的 default 名字空间,应该把它改成其他的名字空间,避免与普通应用混在一起,你可以用“查找替换”的方式把它统一改成 kube-system。
第二个要修改的是deployment.yaml它要修改的地方比较多。首先要把名字空间改成和rbac.yaml一样比如是 kube-system然后重点要修改 volumes 和 env 里的IP地址和共享目录名必须和集群里的NFS服务器配置一样。
按照我们当前的环境设置就应该把IP地址改成 192.168.10.208,目录名改成 /tmp/nfs
spec:
template:
spec:
serviceAccountName: nfs-client-provisioner
containers:
...
env:
- name: PROVISIONER_NAME
value: k8s-sigs.io/nfs-subdir-external-provisioner
- name: NFS_SERVER
value: 192.168.10.208 #改IP地址
- name: NFS_PATH
value: /tmp/nfs #改共享目录名
volumes:
- name: nfs-client-root
nfs:
server: 192.168.10.208 #改IP地址
Path: /tmp/nfs #改共享目录名
还有一件麻烦事deployment.yaml的镜像仓库用的是gcr.io拉取很困难而国内的镜像网站上偏偏还没有它为了让实验能够顺利进行我不得不“曲线救国”把它的镜像转存到了Docker Hub上。
所以你还需要把镜像的名字由原来的“k8s.gcr.io/sig-storage/nfs-subdir-external-provisioner:v4.0.2”改成“chronolaw/nfs-subdir-external-provisioner:v4.0.2”,其实也就是变动一下镜像的用户名而已。
把这两个YAML修改好之后我们就可以在Kubernetes里创建NFS Provisioner了
kubectl apply -f rbac.yaml
kubectl apply -f class.yaml
kubectl apply -f deployment.yaml
使用命令 kubectl get再加上名字空间限定 -n kube-system就可以看到NFS Provisioner在Kubernetes里运行起来了。
如何使用NFS动态存储卷
比起静态存储卷动态存储卷的用法简单了很多。因为有了Provisioner我们就不再需要手工定义PV对象了只需要在PVC里指定StorageClass对象它再关联到Provisioner。
我们来看一下NFS默认的StorageClass定义
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-client
provisioner: k8s-sigs.io/nfs-subdir-external-provisioner
parameters:
archiveOnDelete: "false"
YAML里的关键字段是 provisioner它指定了应该使用哪个Provisioner。另一个字段 parameters 是调节Provisioner运行的参数需要参考文档来确定具体值在这里的 archiveOnDelete: "false" 就是自动回收存储空间。
理解了StorageClass的YAML之后你也可以不使用默认的StorageClass而是根据自己的需求任意定制具有不同存储特性的StorageClass比如添加字段 onDelete: "retain" 暂时保留分配的存储,之后再手动删除:
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: nfs-client-retained
provisioner: k8s-sigs.io/nfs-subdir-external-provisioner
parameters:
onDelete: "retain"
接下来我们定义一个PVC向系统申请10MB的存储空间使用的StorageClass是默认的 nfs-client
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: nfs-dyn-10m-pvc
spec:
storageClassName: nfs-client
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Mi
写好了PVC我们还是在Pod里用 volumes 和 volumeMounts 挂载然后Kubernetes就会自动找到NFS Provisioner在NFS的共享目录上创建出合适的PV对象
apiVersion: v1
kind: Pod
metadata:
name: nfs-dyn-pod
spec:
volumes:
- name: nfs-dyn-10m-vol
persistentVolumeClaim:
claimName: nfs-dyn-10m-pvc
containers:
- name: nfs-dyn-test
image: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: nfs-dyn-10m-vol
mountPath: /tmp
使用 kubectl apply 创建好PVC和Pod让我们来查看一下集群里的PV状态
从截图你可以看到虽然我们没有直接定义PV对象但由于有NFS Provisioner它就自动创建一个PV大小刚好是在PVC里申请的10MB。
如果你这个时候再去NFS服务器上查看共享目录也会发现多出了一个目录名字与这个自动创建的PV一样但加上了名字空间和PVC的前缀
我还是把Pod、PVC、StorageClass和Provisioner的关系画成了一张图你可以清楚地看出来这些对象的关联关系还有Pod是如何最终找到存储设备的
小结
好了今天的这节课里我们继续学习PV/PVC引入了网络存储系统以NFS为例研究了静态存储卷和动态存储卷的用法其中的核心对象是StorageClass和Provisioner。
我再小结一下今天的要点:
在Kubernetes集群里网络存储系统更适合数据持久化NFS是最容易使用的一种网络存储系统要事先安装好服务端和客户端。
可以编写PV手工定义NFS静态存储卷要指定NFS服务器的IP地址和共享目录名。
使用NFS动态存储卷必须要部署相应的Provisioner在YAML里正确配置NFS服务器。
动态存储卷不需要手工定义PV而是要定义StorageClass由关联的Provisioner自动创建PV完成绑定。
课下作业
最后是课下作业时间,给你留两个思考题:
动态存储卷相比静态存储卷有什么好处?有没有缺点?
StorageClass在动态存储卷的分配过程中起到了什么作用
期待你的思考。如果觉得有收获,也欢迎你分享给朋友一起讨论。我们下节课再见。

View File

@ -0,0 +1,273 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 pdb & cProfile调试和性能分析的法宝
你好,我是景霄。
在实际生产环境中,对代码进行调试和性能分析,是一个永远都逃不开的话题。调试和性能分析的主要场景,通常有这么三个:
一是代码本身有问题需要我们找到root cause并修复
二是代码效率有问题比如过度浪费资源增加latency因此需要我们debug
三是在开发新的feature时一般都需要测试。
在遇到这些场景时,究竟应该使用哪些工具,如何正确的使用这些工具,应该遵循什么样的步骤等等,就是这节课我们要讨论的话题。
用pdb进行代码调试
pdb的必要性
首先我们来看代码的调试。也许不少人会有疑问代码调试说白了不就是在程序中使用print()语句吗?
没错在程序中相应的地方打印的确是调试程序的一个常用手段但这只适用于小型程序。因为你每次都得重新运行整个程序或是一个完整的功能模块才能看到打印出来的变量值。如果程序不大每次运行都非常快那么使用print(),的确是很方便的。
但是如果我们面对的是大型程序运行一次的调试成本很高。特别是对于一些tricky的例子来说它们通常需要反复运行调试、追溯上下文代码才能找到错误根源。这种情况下仅仅依赖打印的效率自然就很低了。
我们可以想象下面这个场景。比如你最常使用的极客时间App最近出现了一个bug部分用户无法登陆。于是后端工程师们开始debug。
他们怀疑错误的代码逻辑在某几个函数中如果使用print()语句debug很可能出现的场景是工程师们在他们认为的10个最可能出现bug的地方都使用print()语句然后运行整个功能块代码从启动到运行花了5min看打印出来的结果值是不是和预期相符。
如果结果值和预期相符,并能直接找到错误根源,显然是最好的。但实际情况往往是,
要么与预期并不相符需要重复以上步骤继续debug
要么虽说与预期相符但前面的操作只是缩小了错误代码的范围所以仍得继续添加print()语句再一次运行相应的代码模块又要5min进行debug。
你可以看到这样的效率就很低下了。哪怕只是遇到稍微复杂一点的case两、三个工程师一下午的时间可能就没了。
可能又有人会说现在很多的IDE不都有内置的debug工具吗
这话说的也没错。比如我们常用的Pycharm可以很方便地在程序中设置断点。这样程序只要运行到断点处便会自动停下你就可以轻松查看环境中各个变量的值并且可以执行相应的语句大大提高了调试的效率。
看到这里你不禁会问既然问题都解决了那为什么还要学习pdb呢其实在很多大公司产品的创造与迭代往往需要很多编程语言的支持并且公司内部也会开发很多自己的接口尝试把尽可能多的语言给结合起来。
这就使得很多情况下单一语言的IDE对混合代码并不支持UI形式的断点调试功能或是只对某些功能模块支持。另外考虑到不少代码已经挪到了类似Jupyter的Notebook中往往就要求开发者使用命令行的形式来对代码进行调试。
而Python的pdb正是其自带的一个调试库。它为Python程序提供了交互式的源代码调试功能是命令行版本的IDE断点调试器完美地解决了我们刚刚讨论的这个问题。
如何使用pdb
了解了pdb的重要性与必要性后接下来我们就一起来看看pdb在Python中到底应该如何使用。
首先要启动pdb调试我们只需要在程序中加入“import pdb”和“pdb.set_trace()”这两行代码就行了,比如下面这个简单的例子:
a = 1
b = 2
import pdb
pdb.set_trace()
c = 3
print(a + b + c)
当我们运行这个程序时时它的输出界面是下面这样的表示程序已经运行到了“pdb.set_trace()”这行,并且暂停了下来,等待用户输入。
> /Users/jingxiao/test.py(5)<module>()
-> c = 3
这时我们就可以执行在IDE断点调试器中可以执行的一切操作比如打印语法是"p <expression>"
(pdb) p a
1
(pdb) p b
2
你可以看到我打印的是a和b的值分别为1和2与预期相符。为什么不打印c呢显然打印c会抛出异常因为程序目前只运行了前面几行此时的变量c还没有被定义
(pdb) p c
*** NameError: name 'c' is not defined
除了打印常见的操作还有“n”表示继续执行代码到下一行用法如下
(pdb) n
-> print(a + b + c)
而命令”l“则表示列举出当前代码行上下的11行源代码方便开发者熟悉当前断点周围的代码状态
(pdb) l
1 a = 1
2 b = 2
3 import pdb
4 pdb.set_trace()
5 -> c = 3
6 print(a + b + c)
命令“s“就是 step into 的意思,即进入相对应的代码内部。这时,命令行中会显示”--Call--“的字样,当你执行完内部的代码块后,命令行中则会出现”--Return--“的字样。
我们来看下面这个例子:
def func():
print('enter func()')
a = 1
b = 2
import pdb
pdb.set_trace()
func()
c = 3
print(a + b + c)
# pdb
> /Users/jingxiao/test.py(9)<module>()
-> func()
(pdb) s
--Call--
> /Users/jingxiao/test.py(1)func()
-> def func():
(Pdb) l
1 -> def func():
2 print('enter func()')
3
4
5 a = 1
6 b = 2
7 import pdb
8 pdb.set_trace()
9 func()
10 c = 3
11 print(a + b + c)
(Pdb) n
> /Users/jingxiao/test.py(2)func()
-> print('enter func()')
(Pdb) n
enter func()
--Return--
> /Users/jingxiao/test.py(2)func()->None
-> print('enter func()')
(Pdb) n
> /Users/jingxiao/test.py(10)<module>()
-> c = 3
这里我们使用命令”s“进入了函数func()的内部,显示”--Call--“而当我们执行完函数func()内部语句并跳出后,显示”--Return--“。
另外,
与之相对应的命令”r“表示step out即继续执行直到当前的函数完成返回。
命令”b [ ([filename:]lineno | function) [, condition] ]“可以用来设置断点。比方说我想要在代码中的第10行再加一个断点那么在pdb模式下输入”b 11“即可。
而”c“则表示一直执行程序直到遇到下一个断点。
当然除了这些常用命令还有许多其他的命令可以使用这里我就不在一一赘述了。你可以参考对应的官方文档https://docs.python.org/3/library/pdb.html#module-pdb来熟悉这些用法。
用cProfile进行性能分析
关于调试的内容,我主要先讲这么多。事实上,除了要对程序进行调试,性能分析也是每个开发者的必备技能。
日常工作中我们常常会遇到这样的问题在线上我发现产品的某个功能模块效率低下延迟latency占用的资源多但却不知道是哪里出了问题。
这时对代码进行profile就显得异常重要了。
这里所谓的profile是指对代码的每个部分进行动态的分析比如准确计算出每个模块消耗的时间等。这样你就可以知道程序的瓶颈所在从而对其进行修正或优化。当然这并不需要你花费特别大的力气在Python中这些需求用cProfile就可以实现。
举个例子,比如我想计算斐波拉契数列,运用递归思想,我们很容易就能写出下面这样的代码:
def fib(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fib(n-1) + fib(n-2)
def fib_seq(n):
res = []
if n > 0:
res.extend(fib_seq(n-1))
res.append(fib(n))
return res
fib_seq(30)
接下来我想要测试一下这段代码总的效率以及各个部分的效率。那么我就只需在开头导入cProfile这个模块并且在最后运行cProfile.run()就可以了:
import cProfile
# def fib(n)
# def fib_seq(n):
cProfile.run('fib_seq(30)')
或者更简单一些,直接在运行脚本的命令中,加入选项“-m cProfile”也很方便
python3 -m cProfile xxx.py
运行完毕后,我们可以看到下面这个输出界面:
这里有一些参数你可能比较陌生,我来简单介绍一下:
ncalls是指相应代码/函数被调用的次数;
tottime是指对应代码/函数总共执行所需要的时间(注意,并不包括它调用的其他代码/函数的执行时间);
tottime percall就是上述两者相除的结果也就是tottime / ncalls
cumtime则是指对应代码/函数总共执行所需要的时间,这里包括了它调用的其他代码/函数的执行时间;
cumtime percall则是cumtime和ncalls相除的平均结果。
了解这些参数后再来看这张图。我们可以清晰地看到这段程序执行效率的瓶颈在于第二行的函数fib()它被调用了700多万次。
有没有什么办法可以提高改进呢答案是肯定的。通过观察我们发现程序中有很多对fib()的调用,其实是重复的,那我们就可以用字典来保存计算过的结果,防止重复。改进后的代码如下所示:
def memoize(f):
memo = {}
def helper(x):
if x not in memo:
memo[x] = f(x)
return memo[x]
return helper
@memoize
def fib(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fib(n-1) + fib(n-2)
def fib_seq(n):
res = []
if n > 0:
res.extend(fib_seq(n-1))
res.append(fib(n))
return res
fib_seq(30)
这时我们再对其进行profile你就会得到新的输出结果很明显效率得到了极大的提高。
这个简单的例子便是cProfile的基本用法也是我今天想讲的重点。当然cProfile还有很多其他功能还可以结合stats类来使用你可以阅读相应的 官方文档 来了解。
总结
这节课我们一起学习了Python中常用的调试工具pdb和经典的性能分析工具cProfile。pdb为Python程序提供了一种通用的、交互式的高效率调试方案而cProfile则是为开发者提供了每个代码块执行效率的详细分析有助于我们对程序的优化与提高。
关于它们的更多用法,你可以通过它们的官方文档进行实践,都不太难,熟能生巧。
思考题
最后留一个开放性的交流问题。你在平时的工作中常用的调试和性能分析工具是什么呢有发现什么独到的使用技巧吗你曾用到过pdb、cProfile或是其他相似的工具吗
欢迎在下方留言与我讨论,也欢迎你把这篇文章分享出去。我们一起交流,一起进步。

View File

@ -0,0 +1,256 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 RESTful & Socket搭建交易执行层核心
你好,我是景霄。
上一节,我们简单介绍了量化交易的历史、严谨的定义和它的基本组成结构。有了这些高层次的基本知识,接下来我们就分模块,开始讲解量化交易系统中具体的部分。
从这节课开始,我们将实打实地从代码出发,一步步设计出一套清晰完整、易于理解的量化交易系统。
一个量化交易系统,可以说是一个黑箱。这个黑箱连接交易所获取到的数据,通过策略运算,然后再连接交易所进行下单操作。正如我们在输入输出那节课说的那样,黑箱的特性是输入和输出。每一个设计网络交互的同学,都需要在大脑中形成清晰的交互状态图:
知道包是怎样在网络间传递的;
知道每一个节点是如何处理不同的输入包,然后输出并分发给下一级的。
在你搞不明白的时候,可以先在草稿纸上画出交互拓扑图,标注清楚每个节点的输入和输出格式,然后想清楚网络是怎么流动的。这一点,对网络编程至关重要。
现在,我假设你对网络编程只有很基本的了解。所以接下来,我将先从 REST 的定义讲起,然后过渡到具体的交互方式——如何通过 Python 和交易所进行交互,从而执行下单、撤单、查询订单等网络交互方式。
REST 简介
什么是 REST API什么是 Socket有过网络编程经验的同学一定对这两个词汇不陌生。
REST的全称是表征层状态转移REpresentational State Transfer本意是指一种操作资源方法。不过你不用纠结于这个绕口的名字。换种方式来说REST的实质可以理解为通过URL定位资源用GET、POST、PUT、DELETE等动词来描述操作。而满足REST要求的接口就被称为RESTful的接口。
为了方便你更容易理解这些概念,这里我举个例子来类比。小明同学不是很聪明但很懂事,每天会在他的妈妈下班回来后给妈妈泡茶。刚开始,他的妈妈会发出这样的要求:
用红色杯子去厨房泡一杯放了糖的37.5度的普洱茶。
可是小明同学不够聪明,很难理解这个定语很多的句子。于是,他妈妈为了让他更简单明白需要做的事情,把这个指令设计成了更简洁的样子:
泡厨房的茶,要求如下:
类型=普洱;
杯子=红色;
放糖=True
温度=37.5度。
这里的“茶”就是资源“厨房的茶”就是资源的地址URI“泡”是动词后面的要求都是接口参数。这样的一个接口就是小明提供的一个REST接口。
如果小明是一台机器那么解析这个请求就会非常容易而我们作为维护者查看小明的代码也很简单。当小明把这个接口暴露到网上时这就是一个RESTful的接口。
总的来说RESTful接口通常以HTTP GET和POST形式出现。但并非所有的GET、POST请求接口都是RESTful的接口。
这话可能有些拗口我们举个例子来看。上节课中我们获取了Gemini交易所中BTC对USD价格的ticker接口
GET https://api.gemini.com/v1/pubticker/btcusd
这里的“GET”是动词后边的URI是“Ticker”这个资源的地址。所以这是一个RESTful的接口。
但下面这样的接口就不是一个严格的RESTful接口
POST https://api.restful.cn/accounts/delete/:username
因为URI中包含动词“delete”删除所以这个URI并不是指向一个资源。如果要修改成严格的RESTful接口我们可以把它改成下面这样
DELETE https://api.rest.cn/accounts/:username
然后我们带着这个观念去看Gemini的取消订单接口
POST https://api.gemini.com/v1/order/cancel
你会发现这个接口不够“RESTful”的地方有
动词设计不准确接口使用“POST”而不是重用HTTP动词“DELETE”
URI里包含动词cancel
ID代表的订单是资源但订单ID是放在参数列表而不是URI里的因此URI并没有指向资源。
所以严格来说这不是一个RESTful的接口。
此外如果我们去检查Gemini的其他私有接口Private私有接口是指需要附加身份验证信息才能访问的接口我们会发现那些接口的设计都不是严格RESTful的。不仅如此大部分的交易所比如Bitmex、Bitfinex、OKCoin等等它们提供的“REST接口”也都不是严格RESTful的。这些接口之所以还能被称为“REST接口”是因为他们大部分满足了REST接口的另一个重要要求无状态。
无状态的意思是每个REST请求都是独立的不需要服务器在会话Session中缓存中间状态来完成这个请求。简单来说如果服务器A接收到请求的时候宕机了而此时把这个请求发送给交易所的服务器B也能继续完成那么这个接口就是无状态的。
这里我再给你举一个简单的有状态的接口的例子。服务器要求在客户端请求取消订单的时候必须发送两次不一样的HTTP请求。并且第一次发送让服务器“等待取消”第二次发送“确认取消”。那么就算这个接口满足了RESTful的动词、资源分离原则也不是一个REST接口。
当然对于交易所的REST接口你并不需要过于纠结“RESTful”这个概念否则很容易就被这些名词给绕晕了。你只需要把握住最核心的一点一个HTTP请求完成一次完整操作。
交易所 API 简介
现在,你对 REST 和 Web Socket 应该有一个大致了解了吧。接下来,我们就开始做点有意思的事情。
首先,我来介绍一下交易所是什么。区块链交易所是个撮合交易平台: 它兼容了传统撮合规则撮合引擎,将资金托管和交割方式替换为区块链。数字资产交易所,则是一个中心化的平台,通过 Web 页面或 PC、手机客户端的形式让用户将数字资产充值到指定钱包地址交易所创建的钱包然后在平台挂买单、卖单以实现数字资产之间的兑换。
通俗来说,交易所就是一个买和卖的菜市场。有人在摊位上大声喊着:“二斤羊肉啊,二斤羊肉,四斤牛肉来换!”这种人被称为 maker挂单者。有的人则游走于不同摊位不动声色地掏出两斤牛肉顺手拿走一斤羊肉。这种人被称为 taker吃单者
交易所存在的意义,一方面是为 maker 和 taker 提供足够的空间活动;另一方面,让一个名叫撮合引擎的玩意儿,尽可能地把单子撮合在一起,然后收取一定比例的保护费…啊不对,是手续费,从而保障游戏继续进行下去。
市场显然是个很伟大的发明,这里我们就不进行更深入的哲学讨论了。
然后,我再来介绍一个叫作 Gemini 的交易所。Gemini双子星交易所全球首个获得合法经营许可的、首个推出期货合约的、专注于撮合大宗交易的数字货币交易所。Gemini 位于纽约是一家数字货币交易所和托管机构允许客户交易和存储数字资产并直接受纽约州金融服务部门NYDFS的监管。
Gemini 的界面清晰API 完整而易用,更重要的是,还提供了完整的测试网络,也就是说,功能和正常的 Gemini 完全一样。但是他家的交易采用虚拟币,非常方便从业者在平台上进行对接测试。
另一个做得很好的交易所,是 Bitmex他家的 API UI 界面和测试网络也是币圈一流。不过,鉴于这家是期货交易所,对于量化初学者来说有一定的门槛,我们还是选择 Gemini 更方便一些。
在进入正题之前我们最后再以比特币和美元之间的交易为例介绍四个基本概念orderbook 的概念这里就不介绍了,你也不用深究,你只需要知道比特币的价格是什么就行了)。
buy用美元买入比特币的行为。
sell用比特币换取美元的行为。
市价单market order给交易所一个方向买或者卖和一个数量交易所把给定数量的美元或者比特币换成比特币或者美元的单子。
限价单limit order给交易所一个价格、一个方向买或者卖和一个数量交易所在价格达到给定价格的时候把给定数量的美元或者比特币换成比特币或者美元的单子。
这几个概念都不难懂。其中,市价单和限价单,最大的区别在于,限价单多了一个给定价格。如何理解这一点呢?我们可以来看下面这个例子。
小明在某一天中午12:00:00告诉交易所我要用1000美元买比特币。交易所收到消息在 12:00:01 回复小明,现在你的账户多了 0.099 个比特币,少了 1000 美元,交易成功。这是一个市价买单。
而小强在某一天中午 11:59:00告诉交易所我要挂一个单子数量为 0.1 比特币1个比特币的价格为 10000 美元低于这个价格不卖。交易所收到消息在11:59:01 告诉小强,挂单成功,你的账户余额中 0.1 比特币的资金被冻结。又过了一分钟交易所告诉小强你的单子被完全执行了fully executed现在你的账户多了 1000 美元,少了 0.1 个比特币。这就是一个限价卖单。
(这里肯定有人发现不对了:貌似少了一部分比特币,到底去哪儿了呢?嘿嘿,你不妨自己猜猜看。)
显然,市价单,在交给交易所后,会立刻得到执行,当然执行价格也并不受你的控制。它很快,但是也非常不安全。而限价单,则限定了交易价格和数量,安全性相对高很多。缺点呢,自然就是如果市场朝相反方向走,你挂的单子可能没有任何人去接,也就变成了干吆喝却没人买。因为我没有讲解 orderbook所以这里的说辞不完全严谨但是对于初学者理解今天的内容已经够用了。
储备了这么久的基础知识想必你已经跃跃欲试了吧下面我们正式进入正题手把手教你使用API下单。
手把手教你使用 API 下单
手动挂单显然太慢,也不符合量化交易的初衷。我们就来看看如何用代码实现自动化下单吧。
第一步,你需要做的是,注册一个 Gemini Sandbox 账号。请放心,这个测试账号不需要你充值任何金额,注册后即送大量虚拟现金。这口吻是不是听着特像网游宣传语,接下来就是“快来贪玩蓝月里找我吧”?哈哈,不过这个设定确实如此,所以赶紧来注册一个吧。
注册后,为了满足好奇,你可以先尝试着使用 Web 界面自行下单。不过,事实上,未解锁的情况下是无法正常下单的,因此这样尝试并没啥太大意义。
所以第二步,我们需要来配置 API Key。菜单栏User Settings->API Settings然后点 GENERATE A NEW ACCOUNT API KEY记下 Key 和 Secret 这两串字符。因为窗口一旦消失,这两个信息就再也找不到了,需要你重新生成。
配置到此结束。接下来,我们来看具体实现。
先强调一点,在量化系统开发的时候,你的心中一定要有清晰的数据流图。下单逻辑是一个很简单的 RESTful 的过程,和你在网页操作的一样,构造你的请求订单、加密请求,然后 POST 给 gemini 交易所即可。
不过,因为涉及到的知识点较多,带你一步一步从零来写代码显然不太现实。所以,我们采用“先读懂后记忆并使用”的方法来学,下面即为这段代码:
import requests
import json
import base64
import hmac
import hashlib
import datetime
import time
base_url = "https://api.sandbox.gemini.com"
endpoint = "/v1/order/new"
url = base_url + endpoint
gemini_api_key = "account-zmidXEwP72yLSSybXVvn"
gemini_api_secret = "375b97HfE7E4tL8YaP3SJ239Pky9".encode()
t = datetime.datetime.now()
payload_nonce = str(int(time.mktime(t.timetuple())*1000))
payload = {
"request": "/v1/order/new",
"nonce": payload_nonce,
"symbol": "btcusd",
"amount": "5",
"price": "3633.00",
"side": "buy",
"type": "exchange limit",
"options": ["maker-or-cancel"]
}
encoded_payload = json.dumps(payload).encode()
b64 = base64.b64encode(encoded_payload)
signature = hmac.new(gemini_api_secret, b64, hashlib.sha384).hexdigest()
request_headers = {
'Content-Type': "text/plain",
'Content-Length': "0",
'X-GEMINI-APIKEY': gemini_api_key,
'X-GEMINI-PAYLOAD': b64,
'X-GEMINI-SIGNATURE': signature,
'Cache-Control': "no-cache"
}
response = requests.post(url,
data=None,
headers=request_headers)
new_order = response.json()
print(new_order)
########## 输出 ##########
{'order_id': '239088767', 'id': '239088767', 'symbol': 'btcusd', 'exchange': 'gemini', 'avg_execution_price': '0.00', 'side': 'buy', 'type': 'exchange limit', 'timestamp': '1561956976', 'timestampms': 1561956976535, 'is_live': True, 'is_cancelled': False, 'is_hidden': False, 'was_forced': False, 'executed_amount': '0', 'remaining_amount': '5', 'options': ['maker-or-cancel'], 'price': '3633.00', 'original_amount': '5'}
我们来深入看一下这段代码。
RESTful 的 POST 请求,通过 requests.post 来实现。post 接受三个参数url、data 和 headers。
这里的 url 等价于 https://api.sandbox.gemini.com/v1/order/new但是在代码中分两部分写。第一部分是交易所 API 地址;第二部分,以斜杠开头,用来表示统一的 API endpoint。我们也可以在其他交易所的 API 中看到类似的写法,两者连接在一起,就构成了最终的 url。
而接下来大段命令的目的,是为了构造 request_headers。
这里我简单说一下 HTTP request这是互联网中基于 TCP 的基础协议。HTTP 协议是 Hyper Text Transfer Protocol超文本传输协议的缩写用于从万维网WWW:World Wide Web服务器传输超文本到本地浏览器的传送协议。而 TCPTransmission Control Protocol则是面向连接的、可靠的、基于字节流的传输层通信协议。
多提一句,如果你开发网络程序,建议利用闲暇时间认真读一读《计算机网络:自顶向下方法》这本书,它也是国内外计算机专业必修课中广泛采用的课本之一。一边学习,一边应用,对于初学者的能力提升是全面而充分的。
回到 HTTP它的主要特点是连接简单、灵活可以使用“简单请求收到回复然后断开连接”的方式也是一种无状态的协议因此充分符合 RESTful 的思想。
HTTP 发送需要一个请求头request header也就是代码中的 request_headers用 Python 的语言表示,就是一个 str 对 str 的字典。
这个字典里,有一些字段有特殊用途, 'Content-Type': "text/plain" 和 'Content-Length': "0" 描述 Content 的类型和长度,这里的 Content 对应于参数 data。但是 Gemini 这里的 request 的 data 没有任何用处,因此长度为 0。
还有一些其他字段,例如 'keep-alive' 来表示连接是否可持续化等,你也可以适当注意一下。要知道,网络编程很多 bug 都会出现在不起眼的细节之处。
继续往下走看代码。payload 是一个很重要的字典,它用来存储下单操作需要的所有的信息,也就是业务逻辑信息。这里我们可以下一个 limit buy限价买单价格为 3633 刀。
另外,请注意 nonce这是个很关键并且在网络通信中很常见的字段。
因为网络通信是不可靠的,一个信息包有可能会丢失,也有可能重复发送,在金融操作中,这两者都会造成很严重的后果。丢包的话,我们重新发送就行了;但是重复的包,我们需要去重。虽然 TCP 在某种程度上可以保证但为了在应用层面进一步减少错误发生的机会Gemini 交易所要求所有的通信 payload 必须带有 nonce。
nonce 是个单调递增的整数。当某个后来的请求的 nonce比上一个成功收到的请求的 nouce 小或者相等的时候Gemini 便会拒绝这次请求。这样一来,重复的包就不会被执行两次了。另一方面,这样也可以在一定程度上防止中间人攻击:
一则是因为 nonce 的加入,使得加密后的同样订单的加密文本完全混乱;
二则是因为,这会使得中间人无法通过“发送同样的包来构造重复订单”进行攻击。
这样的设计思路是不是很巧妙呢?这就相当于每个包都增加了一个身份识别,可以极大地提高安全性。希望你也可以多注意,多思考一下这些巧妙的用法。
接下来的代码就很清晰了。我们要对 payload 进行 base64 和 sha384 算法非对称加密,其中 gemini_api_secret 为私钥;而交易所存储着公钥,可以对你发送的请求进行解密。最后,代码再将加密后的请求封装到 request_headers 中,发送给交易所,并收到 response这个订单就完成了。
总结
这节课我们介绍了什么是 RESTful API带你了解了交易所的 RESTful API 是如何工作的,以及如何通过 RESTful API 来下单。同时,我简单讲述了网络编程中的一些技巧操作,希望你在网络编程中要注意思考每一个细节,尽可能在写代码之前,对业务逻辑和具体的技术细节有足够清晰的认识。
下一节,我们同样将从 Web Socket 的定义开始,讲解量化交易中数据模块的具体实现。
思考题
最后留一个思考题。今天的内容里,能不能使用 timestamp 代替 nonce为什么欢迎留言写下你的思考也欢迎你把这篇文章分享出去。

View File

@ -0,0 +1,334 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
35 RESTful & Socket行情数据对接和抓取
你好,我是景霄。
上一节课我们介绍了交易所的交易模式数字货币交易所RESTful接口的常见概念以及如何调用RESTful接口进行订单操作。众所周知买卖操作的前提是你需要已知市场的最新情况。这节课里我将介绍交易系统底层另一个最重要的部分行情数据的对接和抓取。
行情数据最重要的是实时性和有效性。市场的情况瞬息万变合适的买卖时间窗口可能只有几秒。在高频交易里合适的买卖机会甚至在毫秒级别。要知道一次从北京发往美国的网络请求即使是光速传播都需要几百毫秒的延迟。更别提用Python这种解释型语言建立HTTP连接导致的时间消耗。
经过上节课的学习你对交易应该有了基本的了解这也是我们今天学习的基础。接下来我们先从交易所撮合模式讲起然后介绍行情数据有哪些之后我将带你基于Websocket的行情数据来抓取模块。
行情数据
回顾上一节我们提到的,交易所是一个买方、卖方之间的公开撮合平台。买卖方把需要/可提供的商品数量和愿意出/接受的价格提交给交易所,交易所按照公平原则进行撮合交易。
那么撮合交易是怎么进行的呢?假设你是一个人肉比特币交易所,大量的交易订单往你这里汇总,你应该如何选择才能让交易公平呢?
显然,最直观的操作就是,把买卖订单分成两个表,按照价格由高到低排列。下面的图,就是买入和卖出的委托表。
如果最高的买入价格小于最低的卖出价格,那就不会有任何交易发生。这通常是你看到的委托列表的常态。
如果最高的买入价格和最低的卖出价格相同那么就尝试进行撮合。比如BTC在9002.01就会发生撮合最后按照9002.01的价格成交0.0330个BTC。当然交易完成后小林未完成部分的订单余下0.1126 - 0.0330 = 0.0796 个 BTC 未卖出),还会继续在委托表里。
不过你可能会想,如果买入和卖出的价格有交叉,那么成交价格又是什么呢?事实上,这种情况并不会发生。我们来试想一下下面这样的场景。
如果你尝试给一个委托列表里加入一个新买入订单,它的价格比所有已有的最高买入价格高,也比所有的卖出价格高。那么此时,它会直接从最低的卖出价格撮合。等到最低价格的卖出订单吃完了,它便开始吃价格第二低的卖出订单,直到这个买入订单完全成交。反之亦然。所以,委托列表价格不会出现交叉。
当然,请注意,这里我说的只是限价订单的交易方式。而对于市价订单,交易规则会有一些轻微的区别,这里我就不详细解释了,主要是让你有个概念。
其实说到这里所谓的“交易所行情”概念就呼之欲出了。交易所主要有两种行情数据委托账本Order Book和活动行情Tick data
我们把委托表里的具体用户隐去,相同价格的订单合并,就得到了下面这种委托账本。我们主要观察右边的数字部分,其中:
上半部分里第一列红色数字代表BTC的卖出价格中间一列数字是这个价格区间的订单BTC总量最右边一栏是从最低卖出价格到当前价格区间的积累订单量。
中间的大字部分9994.10 USD是当前的市场价格也就是上一次成交交易的价格。
下面绿色部分的含义与上半部分类似,不过指的是买入委托和对应的数量。
Gemini的委托账本来自https://cryptowat.ch
这张图中,最低的卖出价格比最高的买入价格要高 6.51 USD这个价差通常被称为Spread。这里验证了我们前面提到的委托账本的价格永不交叉 同时Spread很小也能说明这是一个非常活跃的交易所。
每一次撮合发生意味着一笔交易Trade的发生。卖方买方都很开心于是交易所也很开心地通知行情数据的订阅者刚才发生了一笔交易交易的价格是多少成交数量是多少。这个数据就是活动行情Tick。
有了这些数据,我们也就掌握了这个交易所的当前状态,可以开始搞事情了。
Websocket介绍
在本文的开头我们提到过行情数据很讲究时效性。所以行情从交易所产生到传播给我们的程序之间的延迟应该越低越好。通常交易所也提供了REST的行情数据抓取接口。比如下面这段代码
import requests
import timeit
def get_orderbook():
orderbook = requests.get("https://api.gemini.com/v1/book/btcusd").json()
n = 10
latency = timeit.timeit('get_orderbook()', setup='from __main__ import get_orderbook', number=n) * 1.0 / n
print('Latency is {} ms'.format(latency * 1000))
###### 输出 #######
Latency is 196.67642089999663 ms
我在美国纽约附近城市的一个服务器上测试了这段代码你可以看到平均每次访问orderbook的延迟有0.25秒左右。显然,如果在国内,这个延迟只会更大。按理说,这两个美国城市的距离很短,为什么延迟会这么大呢?
这是因为REST接口本质上是一个HTTP接口在这之下是TCP/TLS套接字Socket连接。每一次REST请求通常都会重新建立一次TCP/TLS握手然后在请求结束之后断开这个链接。这个过程比我们想象的要慢很多。
举个例子来验证这一点在同一个城市我们试验一下。我从纽约附近的服务器和Gemini在纽约的服务器进行连接TCP/SSL握手花了多少时间呢
curl -w "TCP handshake: %{time_connect}s, SSL handshake: %{time_appconnect}s\n" -so /dev/null https://www.gemini.com
TCP handshake: 0.072758s, SSL handshake: 0.119409s
结果显示HTTP连接构建的过程就占了一大半时间也就是说我们每次用REST请求都要浪费一大半的时间在和服务器建立连接上这显然是非常低效的。很自然的你会想到我们能否实现一次连接、多次通信呢
事实上Python的某些HTTP请求库也可以支持重用底层的TCP/SSL连接。但那种方法一来比较复杂二来也需要服务器的支持。该怎么办呢其实在有WebSocket的情况下我们完全不需要舍近求远。
我先来介绍一下WebSocket。WebSocket是一种在单个TCP/TLS连接上进行全双工、双向通信的协议。WebSocket可以让客户端与服务器之间的数据交换变得更加简单高效服务端也可以主动向客户端推送数据。在WebSocket API中浏览器和服务器只需要完成一次握手两者之间就可以直接创建持久性的连接并进行双向数据传输。
概念听着很痛快,不过还是有些抽象。为了让你快速理解刚刚的这段话,我们还是来看两个简单的例子。二话不说,先看一段代码:
import websocket
import thread
# 在接收到服务器发送消息时调用
def on_message(ws, message):
print('Received: ' + message)
# 在和服务器建立完成连接时调用
def on_open(ws):
# 线程运行函数
def gao():
# 往服务器依次发送0-4每次发送完休息0.01秒
for i in range(5):
time.sleep(0.01)
msg="{0}".format(i)
ws.send(msg)
print('Sent: ' + msg)
# 休息1秒用于接收服务器回复的消息
time.sleep(1)
# 关闭Websocket的连接
ws.close()
print("Websocket closed")
# 在另一个线程运行gao()函数
thread.start_new_thread(gao, ())
if __name__ == "__main__":
ws = websocket.WebSocketApp("ws://echo.websocket.org/",
on_message = on_message,
on_open = on_open)
ws.run_forever()
#### 输出 #####
Sent: 0
Sent: 1
Received: 0
Sent: 2
Received: 1
Sent: 3
Received: 2
Sent: 4
Received: 3
Received: 4
Websocket closed
这段代码尝试和wss://echo.websocket.org建立连接。当连接建立的时候就会启动一条线程连续向服务器发送5条消息。
通过输出可以看出我们在连续发送的同时也在不断地接受消息。这并没有像REST一样每发送一个请求要等待服务器完成请求、完全回复之后再进行下一个请求。换句话说我们在请求的同时也在接受消息这也就是前面所说的”全双工“。
RESTHTTP单工请求响应的示意图
Websocket全双工请求响应的示意图
再来看第二段代码。为了解释”双向“我们来看看获取Gemini的委托账单的例子。
import ssl
import websocket
import json
# 全局计数器
count = 5
def on_message(ws, message):
global count
print(message)
count -= 1
# 接收了5次消息之后关闭websocket连接
if count == 0:
ws.close()
if __name__ == "__main__":
ws = websocket.WebSocketApp(
"wss://api.gemini.com/v1/marketdata/btcusd?top_of_book=true&offers=true",
on_message=on_message)
ws.run_forever(sslopt={"cert_reqs": ssl.CERT_NONE})
###### 输出 #######
{"type":"update","eventId":7275473603,"socket_sequence":0,"events":[{"type":"change","reason":"initial","price":"11386.12","delta":"1.307","remaining":"1.307","side":"ask"}]}
{"type":"update","eventId":7275475120,"timestamp":1562380981,"timestampms":1562380981991,"socket_sequence":1,"events":[{"type":"change","side":"ask","price":"11386.62","remaining":"1","reason":"top-of-book"}]}
{"type":"update","eventId":7275475271,"timestamp":1562380982,"timestampms":1562380982387,"socket_sequence":2,"events":[{"type":"change","side":"ask","price":"11386.12","remaining":"1.3148","reason":"top-of-book"}]}
{"type":"update","eventId":7275475838,"timestamp":1562380986,"timestampms":1562380986270,"socket_sequence":3,"events":[{"type":"change","side":"ask","price":"11387.16","remaining":"0.072949","reason":"top-of-book"}]}
{"type":"update","eventId":7275475935,"timestamp":1562380986,"timestampms":1562380986767,"socket_sequence":4,"events":[{"type":"change","side":"ask","price":"11389.22","remaining":"0.06204196","reason":"top-of-book"}]}
可以看到在和Gemini建立连接后我们并没有向服务器发送任何消息没有任何请求但是服务器却源源不断地向我们推送数据。这可比REST接口“每请求一次获得一次回复”的沟通方式高效多了
因此相对于REST来说Websocket是一种更加实时、高效的数据交换方式。当然缺点也很明显因为请求和回复是异步的这让我们程序的状态控制逻辑更加复杂。这一点后面的内容里我们会有更深刻的体会。
行情抓取模块
有了 Websocket 的基本概念,我们就掌握了和交易所连接的第二种方式。
事实上Gemini 提供了两种 Websocket 接口,一种是 Public 接口,一种为 Private 接口。
Public 接口,即公开接口,提供 orderbook 服务,即每个人都能看到的当前挂单价和深度,也就是我们这节课刚刚详细讲过的 orderbook。
而 Private 接口,和我们上节课讲的挂单操作有关,订单被完全执行、被部分执行等等其他变动,你都会得到通知。
我们以 orderbook 爬虫为例,先来看下如何抓取 orderbook 信息。下面的代码详细写了一个典型的爬虫,同时使用了类进行封装,希望你不要忘记我们这门课的目的,了解 Python 是如何应用于工程实践中的:
import copy
import json
import ssl
import time
import websocket
class OrderBook(object):
BIDS = 'bid'
ASKS = 'ask'
def __init__(self, limit=20):
self.limit = limit
# (price, amount)
self.bids = {}
self.asks = {}
self.bids_sorted = []
self.asks_sorted = []
def insert(self, price, amount, direction):
if direction == self.BIDS:
if amount == 0:
if price in self.bids:
del self.bids[price]
else:
self.bids[price] = amount
elif direction == self.ASKS:
if amount == 0:
if price in self.asks:
del self.asks[price]
else:
self.asks[price] = amount
else:
print('WARNING: unknown direction {}'.format(direction))
def sort_and_truncate(self):
# sort
self.bids_sorted = sorted([(price, amount) for price, amount in self.bids.items()], reverse=True)
self.asks_sorted = sorted([(price, amount) for price, amount in self.asks.items()])
# truncate
self.bids_sorted = self.bids_sorted[:self.limit]
self.asks_sorted = self.asks_sorted[:self.limit]
# copy back to bids and asks
self.bids = dict(self.bids_sorted)
self.asks = dict(self.asks_sorted)
def get_copy_of_bids_and_asks(self):
return copy.deepcopy(self.bids_sorted), copy.deepcopy(self.asks_sorted)
class Crawler:
def __init__(self, symbol, output_file):
self.orderbook = OrderBook(limit=10)
self.output_file = output_file
self.ws = websocket.WebSocketApp('wss://api.gemini.com/v1/marketdata/{}'.format(symbol),
on_message = lambda ws, message: self.on_message(message))
self.ws.run_forever(sslopt={'cert_reqs': ssl.CERT_NONE})
def on_message(self, message):
# 对收到的信息进行处理,然后送给 orderbook
data = json.loads(message)
for event in data['events']:
price, amount, direction = float(event['price']), float(event['remaining']), event['side']
self.orderbook.insert(price, amount, direction)
# 整理 orderbook排序只选取我们需要的前几个
self.orderbook.sort_and_truncate()
# 输出到文件
with open(self.output_file, 'a+') as f:
bids, asks = self.orderbook.get_copy_of_bids_and_asks()
output = {
'bids': bids,
'asks': asks,
'ts': int(time.time() * 1000)
}
f.write(json.dumps(output) + '\n')
if __name__ == '__main__':
crawler = Crawler(symbol='BTCUSD', output_file='BTCUSD.txt')
###### 输出 #######
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11407.92, 1.0], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11412.42, 1.0], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558996535}
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11407.92, 1.0], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11412.42, 1.0], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558997377}
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11412.42, 1.0], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558997765}
{"bids": [[11398.73, 0.96304843], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558998638}
{"bids": [[11398.73, 0.97131753], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.95, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558998645}
{"bids": [[11398.73, 0.97131753], [11398.72, 0.98914437], [11397.32, 1.0], [11396.13, 2.0], [11395.87, 1.0], [11394.09, 0.11803397], [11394.08, 1.0], [11393.59, 0.1612581], [11392.96, 1.0]], "asks": [[11407.42, 1.30814001], [11409.48, 2.0], [11409.66, 2.0], [11412.15, 0.525], [11413.77, 0.11803397], [11413.99, 0.5], [11414.28, 1.0], [11414.72, 1.0]], "ts": 1562558998748}
代码比较长,接下来我们具体解释一下。
这段代码的最开始,封装了一个叫做 orderbook 的 class专门用来存放与之相关的数据结构。其中的 bids 和 asks 两个字典,用来存储当前时刻下的买方挂单和卖方挂单。
此外,我们还专门维护了一个排过序的 bids_sorted 和 asks_sorted。构造函数有一个参数 limit用来指示 orderbook 的 bids 和 asks 保留多少条数据。对于很多策略top 5 的数据往往足够,这里我们选择的是前 10 个。
再往下看insert() 函数用于向 orderbook 插入一条数据。需要注意,这里的逻辑是,如果某个 price 对应的 amount 是 0那么意味着这一条数据已经不存在了删除即可。insert 的数据可能是乱序的,因此在需要的时候,我们要对 bids 和 asks 进行排序,然后选取前面指定数量的数据。这其实就是 sort_and_truncate() 函数的作用,调用它来对 bids 和 asks 排序后截取,最后保存回 bids 和 asks。
接下来的 get_copy_of_bids_and_asks()函数,用来返回排过序的 bids 和 asks 数组。这里使用深拷贝,是因为如果直接返回,将会返回 bids_sorted 和 asks_sorted 的指针;那么,在下一次调用 sort_and_truncate() 函数的时候,两个数组的内容将会被改变,这就造成了潜在的 bug。
最后来看一下 Crawler 类。构造函数声明 orderbook然后定义 Websocket 用来接收交易所数据。这里需要注意的一点是,回调函数 on_message() 是一个类成员函数。因此,应该你注意到了,它的第一个参数是 self这里如果直接写成 on_message = self.on_message 将会出错。
为了避免这个问题,我们需要将函数再次包装一下。这里我使用了前面学过的匿名函数,来传递中间状态,注意我们只需要 message因此传入 message 即可。
剩下的部分就很清晰了on_message 回调函数在收到一个新的 tick 时,先将信息解码,枚举收到的所有改变;然后插入 orderbook排序最后连同 timestamp 一并输出即可。
虽然这段代码看起来挺长,但是经过我这么一分解,是不是发现都是学过的知识点呢?这也是我一再强调基础的原因,如果对你来说哪部分内容变得陌生了(比如面向对象编程的知识点),一定要记得及时往前复习,这样你学起新的更复杂的东西,才能轻松很多。
回到正题。刚刚的代码,主要是为了抓取 orderbook 的信息。事实上Gemini 交易所在建立数据流 Websocket 的时候,第一条信息往往非常大,因为里面包含了那个时刻所有的 orderbook 信息。这就叫做初始数据。之后的消息,都是基于初始数据进行修改的,直接处理即可。
总结
这节课我们继承上一节,从委托账本讲起,然后讲述了 WebSocket 的定义、工作机制和使用方法,最后以一个例子收尾,带你学会如何爬取 Orderbook 的信息。希望你在学习这节课的内容时,能够和上节课的内容联系起来,仔细思考 Websocket 和 RESTFul 的区别,并试着总结网络编程中不同模型的适用范围。
思考题
最后给你留一道思考题。WebSocket 会丢包吗?如果丢包的话, Orderbook 爬虫又会发生什么?这一点应该如何避免呢?欢迎留言和我讨论,也欢迎你把这篇文章分享出去。

View File

@ -0,0 +1,574 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
36 Pandas & Numpy策略与回测系统
大家好,我是景霄。
上节课我们介绍了交易所的数据抓取特别是orderbook和tick数据的抓取。今天这节课我们考虑的是怎么在这些历史数据上测试一个交易策略。
首先我们要明确对于很多策略来说我们上节课抓取的密集的orderbook和tick数据并不能简单地直接使用。因为数据量太密集包含了太多细节而且长时间连接时网络随机出现的不稳定会导致丢失部分tick数据。因此我们还需要进行合适的清洗、聚合等操作。
此外为了进行回测我们需要一个交易策略还需要一个测试框架。目前已存在很多成熟的回测框架但是为了Python学习我决定带你搭建一个简单的回测框架并且从中简单一窥Pandas的优势。
OHLCV数据
了解过一些股票交易的同学可能知道K线这种东西。K线又称“蜡烛线”是一种反映价格走势的图线。它的特色在于一个线段内记录了多项讯息相当易读易懂且实用有效因此被广泛用于股票、期货、贵金属、数字货币等行情的技术分析。下面便是一个K线示意图。
K线示意图
其中每一个小蜡烛都代表着当天的开盘价Open、最高价High、最低价Low和收盘价Close也就是我画的第二张图表示的这样。
K线的“小蜡烛” OHLC
类似的除了日K线之外还有周K线、小时K线、分钟K线等等。那么这个K线是怎么计算来的呢
我们以小时K线图为例还记得我们当时抓取的tick数据吗也就是每一笔交易的价格和数量。那么如果从上午10:00开始我们开始积累tick的交易数据以10:00开始的第一个交易作为Open数据11:00前的最后一笔交易作为Close值并把这一个小时最低和最高的成交价格分别作为High和Low的值我们就可以绘制出这一个小时对应的“小蜡烛”形状了。
如果再加上这一个小时总的成交量Volumn就得到了OHLCV数据。
所以如果我们一直抓取着tick底层原始数据我们就能在上层聚合出1分钟K线、小时K线以及日、周k线等等。如果你对这一部分操作有兴趣可以把此作为今天的课后作业来实践。
接下来我们将使用Gemini从2015年到2019年7月这个时间内BTC对USD每个小时的OHLCV数据作为策略和回测的输入。你可以在这里下载数据。
数据下载完成后我们可以利用Pandas读取比如下面这段代码。
def assert_msg(condition, msg):
if not condition:
raise Exception(msg)
def read_file(filename):
# 获得文件绝对路径
filepath = path.join(path.dirname(__file__), filename)
# 判定文件是否存在
assert_msg(path.exists(filepath), "文件不存在")
# 读取CSV文件并返回
return pd.read_csv(filepath,
index_col=0,
parse_dates=True,
infer_datetime_format=True)
BTCUSD = read_file('BTCUSD_GEMINI.csv')
assert_msg(BTCUSD.__len__() > 0, '读取失败')
print(BTCUSD.head())
########## 输出 ##########
Time Symbol Open High Low Close Volume
Date
2019-07-08 00:00:00 BTCUSD 11475.07 11540.33 11469.53 11506.43 10.770731
2019-07-07 23:00:00 BTCUSD 11423.00 11482.72 11423.00 11475.07 32.996559
2019-07-07 22:00:00 BTCUSD 11526.25 11572.74 11333.59 11423.00 48.937730
2019-07-07 21:00:00 BTCUSD 11515.80 11562.65 11478.20 11526.25 25.323908
2019-07-07 20:00:00 BTCUSD 11547.98 11624.88 11423.94 11515.80 63.211972
这段代码提供了两个工具函数。
一个是read_file它的作用是用pandas读取csv文件。
另一个是assert_msg它的作用类似于assert如果传入的条件contidtion为否就会抛出异常。不过你需要提供一个参数用于指定要抛出的异常信息。
回测框架
说完了数据我们接着来看回测数据。常见的回测框架有两类。一类是向量化回测框架它通常基于Pandas+Numpy来自己搭建计算核心后端则是用MySQL或者MongoDB作为源。这种框架通过Pandas+Numpy对OHLC数组进行向量运算可以在较长的历史数据上进行回测。不过因为这类框架一般只用OHLC所以模拟会比较粗糙。
另一类则是事件驱动型回测框架。这类框架本质上是针对每一个tick的变动或者orderbook的变动生成事件然后再把一个个事件交给策略进行执行。因此虽然它的拓展性很强可以允许更加灵活的策略但回测速度是很慢的。
我们想要学习量化交易,使用大型成熟的回测框架,自然是第一选择。
比如Zipline就是一个热门的事件驱动型回测框架背后有大型社区和文档的支持。
PyAlgoTrade也是事件驱动的回测框架文档相对完整整合了知名的技术分析Techique Analysis库TA-Lib。在速度和灵活方面它比Zipline 强。不过,它的一大硬伤是不支持 Pandas 的模块和对象。
显然对于我们Python学习者来说第一类也就是向量型回测框架才是最适合我们练手的项目了。那么我们就开始吧。
首先,我先为你梳理下回测流程,也就是下面五步:
读取OHLC数据
对OHLC进行指标运算
策略根据指标向量决定买卖;
发给模拟的”交易所“进行交易;
最后,统计结果。
对此,使用之前学到的面向对象思维方式,我们可以大致抽取三个类:
交易所类( ExchangeAPI负责维护账户的资金和仓位以及进行模拟的买卖
策略类Strategy负责根据市场信息生成指标根据指标决定买卖
回测类框架Backtest包含一个策略类和一个交易所类负责迭代地对每个数据点调用策略执行。
接下来我们先从最外层的大框架开始。这样的好处在于我们是从上到下、从外往内地思考虽然还没有开始设计依赖项Backtest的依赖项是ExchangeAPI和Strategy但我们可以推测出它们应有的接口形式。推测接口的本质其实就是推测程序的输入。
这也是我在一开始提到过的,对于程序这个“黑箱”,你在一开始设计的时候,就要想好输入和输出。
回到最外层Backtest类。我们需要知道输出是最后的收益那么显然输入应该是初始输入的资金数量cash
此外为了模拟得更加真实我们还要考虑交易所的手续费commission。手续费的多少取决于券商broker或者交易所比如我们买卖股票的券商手续费可能是万七那么就是0.0007。但是在比特币交易领域手续费通常会稍微高一点可能是千分之二左右。当然无论怎么多一般也不会超过5 %。否则我们大家交易几次就破产了,也就不会有人去交易了。
这里说一句题外话,不知道你有没有发现,无论数字货币的价格是涨还是跌,总有一方永远不亏,那就是交易所。因为只要有人交易,他们就有白花花的银子进账。
回到正题至此我们就确定了Backtest的输入和输出。
它的输入是:
OHLC数据
初始资金;
手续费率;
交易所类;
策略类。
输出则是:
最后剩余市值。
对此,你可以参考下面这段代码:
class Backtest:
"""
Backtest回测类用于读取历史行情数据、执行策略、模拟交易并估计
收益。
初始化的时候调用Backtest.run来时回测
instance, or `backtesting.backtesting.Backtest.optimize` to
optimize it.
"""
def __init__(self,
data: pd.DataFrame,
strategy_type: type(Strategy),
broker_type: type(ExchangeAPI),
cash: float = 10000,
commission: float = .0):
"""
构造回测对象。需要的参数包括:历史数据,策略对象,初始资金数量,手续费率等。
初始化过程包括检测输入类型,填充数据空值等。
参数:
:param data: pd.DataFrame pandas Dataframe格式的历史OHLCV数据
:param broker_type: type(ExchangeAPI) 交易所API类型负责执行买卖操作以及账户状态的维护
:param strategy_type: type(Strategy) 策略类型
:param cash: float 初始资金数量
:param commission: float 每次交易手续费率。如2%的手续费此处为0.02
"""
assert_msg(issubclass(strategy_type, Strategy), 'strategy_type不是一个Strategy类型')
assert_msg(issubclass(broker_type, ExchangeAPI), 'strategy_type不是一个Strategy类型')
assert_msg(isinstance(commission, Number), 'commission不是浮点数值类型')
data = data.copy(False)
# 如果没有Volumn列填充NaN
if 'Volume' not in data:
data['Volume'] = np.nan
# 验证OHLC数据格式
assert_msg(len(data.columns & {'Open', 'High', 'Low', 'Close', 'Volume'}) == 5,
("输入的`data`格式不正确,至少需要包含这些列:"
"'Open', 'High', 'Low', 'Close'"))
# 检查缺失值
assert_msg(not data[['Open', 'High', 'Low', 'Close']].max().isnull().any(),
('部分OHLC包含缺失值请去掉那些行或者通过差值填充. '))
# 如果行情数据没有按照时间排序,重新排序一下
if not data.index.is_monotonic_increasing:
data = data.sort_index()
# 利用数据,初始化交易所对象和策略对象。
self._data = data # type: pd.DataFrame
self._broker = broker_type(data, cash, commission)
self._strategy = strategy_type(self._broker, self._data)
self._results = None
def run(self):
"""
运行回测,迭代历史数据,执行模拟交易并返回回测结果。
Run the backtest. Returns `pd.Series` with results and statistics.
Keyword arguments are interpreted as strategy parameters.
"""
strategy = self._strategy
broker = self._broker
# 策略初始化
strategy.init()
# 设定回测开始和结束位置
start = 100
end = len(self._data)
# 回测主循环,更新市场状态,然后执行策略
for i in range(start, end):
# 注意要先把市场状态移动到第i时刻然后再执行策略。
broker.next(i)
strategy.next(i)
# 完成策略执行之后,计算结果并返回
self._results = self._compute_result(broker)
return self._results
def _compute_result(self, broker):
s = pd.Series()
s['初始市值'] = broker.initial_cash
s['结束市值'] = broker.market_value
s['收益'] = broker.market_value - broker.initial_cash
return s
这段代码有点长,但是核心其实就两部分。
初始化函数init传入必要参数对OHLC数据进行简单清洗、排序和验证。我们从不同地方下载的数据可能格式不一样而排序的方式也可能是从前往后。所以这里我们把数据统一设置为按照时间从之前往现在的排序。
执行函数run这是回测框架的主要循环部分核心是更新市场还有更新策略的时间。迭代完成所有的历史数据后它会计算收益并返回。
你应该注意到了此时我们还没有定义策略和交易所API的结构。不过通过回测的执行函数我们可以确定这两个类的接口形式。
策略类Strategy的接口形式为
初始化函数init()根据历史数据进行指标Indicator计算。
步进函数next(),根据当前时间和指标,决定买卖操作,并发给交易所类执行。
交易所类ExchangeAPI的接口形式为
步进函数next(),根据当前时间,更新最新的价格;
买入操作buy(),买入资产;
卖出操作sell(),卖出资产。
交易策略
接下来我们来看交易策略。交易策略的开发是一个非常复杂的学问。为了达到学习的目的,我们来想一个简单的策略——移动均值交叉策略。
为了了解这个策略我们先了解一下什么叫做简单移动均值Simple Moving Average简称为SMA以下皆用SMA表示简单移动均值。我们知道N个数的序列 x[0]、x[1] .…… x[N] 的均值就是这N个数的和除以N。
现在我假设一个比较小的数K比N小很多。我们用一个K大小的滑动窗口在原始的数组上滑动。通过对每次框住的K个元素求均值我们就可以得到原始数组的窗口大小为K的SMA了。
SMA实质上就是对原始数组进行了一个简单平滑处理。比如某支股票的价格波动很大那么我们用SMA平滑之后就会得到下面这张图的效果。
某个投资品价格的SMA窗口大小为50
你可以看出如果窗口大小越大那么SMA应该越平滑变化越慢反之如果SMA比较小那么短期的变化也会越快地反映在SMA上。
于是我们想到能不能对投资品的价格设置两个指标呢这俩指标一个是小窗口的SMA一个是大窗口的SMA。
如果小窗口的SMA曲线从下面刺破或者穿过大窗口SMA那么说明这个投资品的价格在短期内快速上涨同时这个趋势很强烈可能是一个买入的信号
反之如果大窗口的SMA从下方突破小窗口SMA那么说明投资品的价格在短期内快速下跌我们应该考虑卖出。
下面这幅图,就展示了这两种情况。
明白了这里的概念和原理后接下来的操作就不难了。利用Pandas我们可以非常简单地计算SMA和SMA交叉。比如你可以引入下面两个工具函数
def SMA(values, n):
"""
返回简单滑动平均
"""
return pd.Series(values).rolling(n).mean()
def crossover(series1, series2) -> bool:
"""
检查两个序列是否在结尾交叉
:param series1: 序列1
:param series2: 序列2
:return: 如果交叉返回True反之False
"""
return series1[-2] < series2[-2] and series1[-1] > series2[-1]
如代码所示对于输入的一个数组Pandas的rolling(k)函数可以方便地计算窗内口大小为K的SMA数组而想要检查某个时刻两个SMA是否交叉你只需要查看两个数组末尾的两个元素即可。
那么,基于此,我们就可以开发出一个简单的策略了。下面这段代码表示策略的核心思想,我做了详细的注释,你理解起来应该没有问题:
def next(self, tick):
# 如果此时快线刚好越过慢线,买入全部
if crossover(self.sma1[:tick], self.sma2[:tick]):
self.buy()
# 如果是慢线刚好越过快线,卖出全部
elif crossover(self.sma2[:tick], self.sma1[:tick]):
self.sell()
# 否则,这个时刻不执行任何操作。
else:
pass
说完策略的核心思想,我们开始搭建策略类的框子。
首先我们要考虑到策略类Strategy应该是一个可以被继承的类同时应该包含一些固定的接口。这样回测器才能方便地调用。
于是我们可以定义一个Strategy抽象类包含两个接口方法init和next分别对应我们前面说的指标计算和步进函数。不过注意抽象类是不能被实例化的。所以我们必须定义一个具体的子类同时实现了init和next方法才可以。
这个类的定义,你可以参考下面代码的实现:
import abc
import numpy as np
from typing import Callable
class Strategy(metaclass=abc.ABCMeta):
"""
抽象策略类,用于定义交易策略。
如果要定义自己的策略类,需要继承这个基类,并实现两个抽象方法:
Strategy.init
Strategy.next
"""
def __init__(self, broker, data):
"""
构造策略对象。
@params broker: ExchangeAPI 交易API接口用于模拟交易
@params data: list 行情数据数据
"""
self._indicators = []
self._broker = broker # type: _Broker
self._data = data # type: _Data
self._tick = 0
def I(self, func: Callable, *args) -> np.ndarray:
"""
计算买卖指标向量。买卖指标向量是一个数组,长度和历史数据对应;
用于判定这个时间点上需要进行"买"还是"卖"。
例如计算滑动平均:
def init():
self.sma = self.I(utils.SMA, self.data.Close, N)
"""
value = func(*args)
value = np.asarray(value)
assert_msg(value.shape[-1] == len(self._data.Close), '指示器长度必须和data长度相同')
self._indicators.append(value)
return value
@property
def tick(self):
return self._tick
@abc.abstractmethod
def init(self):
"""
初始化策略。在策略回测/执行过程中调用一次,用于初始化策略内部状态。
这里也可以预计算策略的辅助参数。比如根据历史行情数据:
计算买卖的指示器向量;
训练模型/初始化模型参数
"""
pass
@abc.abstractmethod
def next(self, tick):
"""
步进函数执行第tick步的策略。tick代表当前的"时间"。比如data[tick]用于访问当前的市场价格。
"""
pass
def buy(self):
self._broker.buy()
def sell(self):
self._broker.sell()
@property
def data(self):
return self._data
为了方便访问成员我们还定义了一些Python property。同时我们的买卖请求是由策略类发出、由交易所API来执行的所以我们的策略类里依赖于ExchangeAPI类。
现在有了这个框架我们实现移动均线交叉策略就很简单了。你只需要在init函数中定义计算大小窗口SMA的逻辑同时在next函数中完成交叉检测和买卖调用就行了。具体实现你可以参考下面这段代码
from utils import assert_msg, crossover, SMA
class SmaCross(Strategy):
# 小窗口SMA的窗口大小用于计算SMA快线
fast = 10
# 大窗口SMA的窗口大小用于计算SMA慢线
slow = 20
def init(self):
# 计算历史上每个时刻的快线和慢线
self.sma1 = self.I(SMA, self.data.Close, self.fast)
self.sma2 = self.I(SMA, self.data.Close, self.slow)
def next(self, tick):
# 如果此时快线刚好越过慢线,买入全部
if crossover(self.sma1[:tick], self.sma2[:tick]):
self.buy()
# 如果是慢线刚好越过快线,卖出全部
elif crossover(self.sma2[:tick], self.sma1[:tick]):
self.sell()
# 否则,这个时刻不执行任何操作。
else:
pass
模拟交易
到这里,我们的回测就只差最后一块儿了。胜利就在眼前,我们继续加油。
我们前面提到过交易所类负责模拟交易而模拟的基础就是需要当前市场的价格。这里我们可以用OHLC中的Close作为那个时刻的价格。
此外,为了简化设计,我们假设买卖操作都利用的是当前账户的所有资金、仓位,且市场容量足够大。这样,我们的下单请求就能够马上完全执行。
也别忘了手续费这个大头。考虑到有手续费的情况,此时,我们最核心的买卖函数应该怎么来写呢?
我们一起来想这个问题。假设我们现在有1000.0元此时BTC的价格是100.00元当然没有这么好的事情啊这里只是假设并且交易手续费为1%。那么我们能买到多少BTC呢
我们可以采用这种算法:
买到的数量 = 投入的资金 * (1.0 - 手续费) / 价格
那么此时你就能收到9.9个BTC。
类似的,卖出的时候结算方式如下,也不难理解:
卖出的收益 = 持有的数量 * 价格 * (1.0 - 手续费)
所以,最终模拟交易所类的实现,你可以参考下面这段代码:
from utils import read_file, assert_msg, crossover, SMA
class ExchangeAPI:
def __init__(self, data, cash, commission):
assert_msg(0 < cash, "初始现金数量大于0输入的现金数量{}".format(cash))
assert_msg(0 <= commission <= 0.05, "合理的手续费率一般不会超过5%,输入的费率:{}".format(commission))
self._inital_cash = cash
self._data = data
self._commission = commission
self._position = 0
self._cash = cash
self._i = 0
@property
def cash(self):
"""
:return: 返回当前账户现金数量
"""
return self._cash
@property
def position(self):
"""
:return: 返回当前账户仓位
"""
return self._position
@property
def initial_cash(self):
"""
:return: 返回初始现金数量
"""
return self._inital_cash
@property
def market_value(self):
"""
:return: 返回当前市值
"""
return self._cash + self._position * self.current_price
@property
def current_price(self):
"""
:return: 返回当前市场价格
"""
return self._data.Close[self._i]
def buy(self):
"""
用当前账户剩余资金,按照市场价格全部买入
"""
self._position = float(self._cash / (self.current_price * (1 + self._commission)))
self._cash = 0.0
def sell(self):
"""
卖出当前账户剩余持仓
"""
self._cash += float(self._position * self.current_price * (1 - self._commission))
self._position = 0.0
def next(self, tick):
self._i = tick
其中的current_price当前价格可以方便地获得模拟交易所当前时刻的商品价格而market_value则可以获得当前总市值。在初始化函数的时候我们检查手续费率和输入的现金数量是不是在一个合理的范围。
有了所有的这些部分,我们就可以来模拟回测啦!
首先我们设置初始资金量为10000.00美元交易所手续费率为0。这里你可以猜一下如果我们从2015年到现在都按照SMA来买卖现在应该有多少钱呢
def main():
BTCUSD = read_file('BTCUSD_GEMINI.csv')
ret = Backtest(BTCUSD, SmaCross, ExchangeAPI, 10000.0, 0.00).run()
print(ret)
if __name__ == '__main__':
main()
铛铛铛,答案揭晓,程序将输出:
初始市值 10000.000000
结束市值 576361.772884
收益 566361.772884
结束时我们将有57万美元翻了整整57倍啊简直不要太爽。不过等等这个手续费率为0实在是有点碍眼因为根本不可能啊。我们现在来设一个比较真实的值吧大概千分之三然后再来试试
初始市值 10000.000000
结束市值 2036.562001
收益 -7963.437999
什么鬼我们变成赔钱了只剩下2000美元了这是真的吗
这是真的,也是假的。
我说的“真”是指如果你真的用SMA交叉这种简单的方法去交易那么手续费摩擦和滑点等因素确实可能让你的高频策略赔钱。
而我说是“假”是指,这种模拟交易的方式非常粗糙。真实的市场情况,并非这么理想——比如买卖请求永远马上执行;再比如,我们在市场中进行交易的同时不会影响市场价格等,这些理想情况都是不可能的。所以,很多时候,回测永远赚钱,但实盘马上赔钱。
总结
这节课,我们继承上一节,介绍了回测框架的分类、数据的格式,并且带你从头开始写了一个简单的回测系统。你可以把今天的代码片段“拼”起来,这样就会得到一个简化的回测系统样例。同时,我们实现了一个简单的交易策略,并且在真实的历史数据上运行了回测结果。我们观察到,在加入手续费后,策略的收益情况发生了显著的变化。
思考题
最后给你留一个思考题。之前我们介绍了如何抓取tick数据你可以根据抓取的tick数据生成5分钟、每小时和每天的OHLCV数据吗欢迎在留言区写下你的答案和问题也欢迎你把这篇文章分享出去。

View File

@ -0,0 +1,243 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 Kafka & ZMQ自动化交易流水线
你好,我是景霄。
在进行这节课的学习前,我们先来回顾一下,前面三节课,我们学了些什么。
第 34 讲,我们介绍了如何通过 RESTful API 在交易所下单;第 35 讲,我们讲解了如何通过 Websocket ,来获取交易所的 orderbook 数据;第 36 讲,我们介绍了如何实现一个策略,以及如何对策略进行历史回测。
事实上,到这里,一个简单的、可以运作的量化交易系统已经成型了。你可以对策略进行反复修改,期待能得到不错的 PnL。但是对于一个完善的量化交易系统来说只有基本骨架还是不够的。
在大型量化交易公司,系统一般是分布式运行的,各个模块独立在不同的机器上,然后互相连接来实现。即使是个人的交易系统,在进行诸如高频套利等算法时,也需要将执行层布置在靠近交易所的机器节点上。
所以,从今天这节课开始,我们继续回到 Python 的技术栈,从量化交易系统这个角度切入,为你讲解如何实现分布式系统之间的复杂协作。
中间件
我们先来介绍一下中间件这个概念。中间件,是将技术底层工具和应用层进行连接的组件。它要实现的效果则是,让我们这些需要利用服务的工程师,不必去关心底层的具体实现。我们只需要拿着中间件的接口来用就好了。
这个概念听起来并不难理解,我们再举个例子让你彻底明白。比如拿数据库来说,底层数据库有很多很多种,从关系型数据库 MySQL 到非关系型数据库 NoSQL从分布式数据库 Spanner 到内存数据库 Redis不同的数据库有不同的使用场景也有着不同的优缺点更有着不同的调用方式。那么中间件起什么作用呢
中间件,等于在这些不同的数据库上加了一层逻辑,这一层逻辑专门用来和数据库打交道,而对外只需要暴露同一个接口即可。这样一来,上层的程序员调用中间件接口时,只需要让中间件指定好数据库即可,其他参数完全一致,极大地方便了上层的开发;同时,下层技术栈在更新换代的时候,也可以做到和上层完全分离,不影响程序员的使用。
它们之间的逻辑关系,你可以参照下面我画的这张图。我习惯性把中间件的作用调侃为:没有什么事情是加一层解决不了的;如果有,那就加两层。
当然,这只是其中一个例子,也只是中间件的一种形式。事实上,比如在阿里,中间件主要有分布式关系型数据库 DRDS、消息队列和分布式服务这么三种形式。而我们今天主要会用到消息队列因为它非常符合量化交易系统的应用场景即事件驱动模型。
消息队列
那么,什么是消息队列呢?一如其名,消息,即互联网信息传递的个体;而队列,学过算法和数据结构的你,应该很清楚这个 FIFO先进先出的数据结构吧。如果算法基础不太牢建议你可以学习极客时间平台上王争老师的“数据结构与算法之美”专栏第 09讲即为队列知识
简而言之,消息队列就是一个临时存放消息的容器,有人向消息队列中推送消息;有人则监听消息队列,发现新消息就会取走。根据我们刚刚对中间件的解释,清晰可见,消息队列也是一种中间件。
目前,市面上使用较多的消息队列有 RabbitMQ、Kafka、RocketMQ、ZMQ 等。不过今天,我只介绍最常用的 ZMQ 和 Kafka。
我们先来想想,消息队列作为中间件有什么特点呢?
首先是严格的时序性。刚刚说了,队列是一种先进先出的数据结构,你丢给它 1, 2, 3然后另一个人从里面取数据那么取出来的一定也是 1, 2, 3严格保证了先进去的数据先出去后进去的数据后出去。显然这也是消息机制中必须要保证的一点不然颠三倒四的结果一定不是我们想要的。
说到队列的特点,简单提一句,与“先进先出“相对的是栈这种数据结构,它是先进后出的,你丢给它 1, 2, 3再从里面取出来的时候拿到的就是3, 2, 1了这一点一定要区分清楚。
其次,是分布式网络系统的老生常谈问题。如何保证消息不丢失?如何保证消息不重复?这一切,消息队列在设计的时候都已经考虑好了,你只需要拿来用就可以,不必过多深究。
不过,很重要的一点,消息队列是如何降低系统复杂度,起到中间件的解耦作用呢?我们来看下面这张图。
消息队列的模式是发布和订阅,一个或多个消息发布者可以发布消息,一个或多个消息接受者可以订阅消息。 从图中你可以看到,消息发布者和消息接受者之间没有直接耦合,其中,
消息发布者将消息发送到分布式消息队列后,就结束了对消息的处理;
消息接受者从分布式消息队列获取该消息后,即可进行后续处理,并不需要探寻这个消息从何而来。
至于新增业务的问题,只要你对这类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,所以也就实现了业务的可扩展性设计。
讲了这么多概念层的东西,想必你迫不及待地想看具体代码了吧。接下来,我们来看一下 ZMQ 的实现。
ZMQ
先来看 ZMQ这是一个非常轻量级的消息队列实现。
作者 Pieter Hintjens 是一位大牛他本人的经历也很传奇2010年诊断出胆管癌并成功做了手术切除。但2016年4月却发现癌症大面积扩散到了肺部已经无法治疗。他写的最后一篇通信模式是关于死亡协议的之后在比利时选择接受安乐死。
ZMQ 是一个简单好用的传输层,它有三种使用模式:
Request - Reply 模式;
Publish - Subscribe 模式;
Parallel Pipeline 模式。
第一种模式很简单client 发消息给 serverserver 处理后返回给 client完成一次交互。这个场景你一定很熟悉吧没错和 HTTP 模式非常像,所以这里我就不重点介绍了。至于第三种模式,与今天内容无关,这里我也不做深入讲解。
我们需要详细来看的是第二种即“PubSub”模式。下面是它的具体实现代码很清晰你应该很容易理解
# 订阅者 1
import zmq
def run():
context = zmq.Context()
socket = context.socket(zmq.SUB)
socket.connect('tcp://127.0.0.1:6666')
socket.setsockopt_string(zmq.SUBSCRIBE, '')
print('client 1')
while True:
msg = socket.recv()
print("msg: %s" % msg)
if __name__ == '__main__':
run()
########## 输出 ##########
client 1
msg: b'server cnt 1'
msg: b'server cnt 2'
msg: b'server cnt 3'
msg: b'server cnt 4'
msg: b'server cnt 5'
# 订阅者 2
import zmq
def run():
context = zmq.Context()
socket = context.socket(zmq.SUB)
socket.connect('tcp://127.0.0.1:6666')
socket.setsockopt_string(zmq.SUBSCRIBE, '')
print('client 2')
while True:
msg = socket.recv()
print("msg: %s" % msg)
if __name__ == '__main__':
run()
########## 输出 ##########
client 2
msg: b'server cnt 1'
msg: b'server cnt 2'
msg: b'server cnt 3'
msg: b'server cnt 4'
msg: b'server cnt 5'
# 发布者
import time
import zmq
def run():
context = zmq.Context()
socket = context.socket(zmq.PUB)
socket.bind('tcp://*:6666')
cnt = 1
while True:
time.sleep(1)
socket.send_string('server cnt {}'.format(cnt))
print('send {}'.format(cnt))
cnt += 1
if __name__ == '__main__':
run()
########## 输出 ##########
send 1
send 2
send 3
send 4
send 5
这里要注意的一点是,如果你想要运行代码,请先运行两个订阅者,然后再打开发布者。
接下来,我来简单讲解一下。
对于订阅者,我们要做的是创建一个 zmq Context连接 socket 到指定端口。其中setsockopt_string() 函数用来过滤特定的消息,而下面这行代码:
socket.setsockopt_string(zmq.SUBSCRIBE, '')
则表示不过滤任何消息。最后,我们调用 socket.recv() 来接受消息就行了,这条语句会阻塞在这里,直到有新消息来临。
对于发布者,我们同样要创建一个 zmq Context绑定到指定端口不过请注意这里用的是 bind 而不是 connect。因为在任何情况下同一个地址端口 bind 只能有一个,但却可以有很多个 connect 链接到这个地方。初始化完成后,再调用 socket.send_string ,即可将我们想要发送的内容发送给 ZMQ。
当然,这里还有几个需要注意的地方。首先,有了 send_string我们其实已经可以通过 JSON 序列化,来传递几乎我们想要的所有数据结构,这里的数据流结构就已经很清楚了。
另外,把发布者的 time.sleep(1) 放在 while 循环的最后,严格来说应该是不影响结果的。这里你可以尝试做个实验,看看会发生什么。
你还可以思考下另一个问题,如果这里是多个发布者,那么 ZMQ 应该怎么做呢?
Kafka
接着我们再来看一下 Kafka。
通过代码实现你也可以发现ZMQ 的优点主要在轻量、开源和方便易用上,但在工业级别的应用中,大部分人还是会转向 Kafka 这样的有充足支持的轮子上。
相比而言Kafka 提供了点对点网络和发布订阅模型的支持,这也是用途最广泛的两种消息队列模型。而且和 ZMQ 一样Kafka 也是完全开源的,因此你也能得到开源社区的充分支持。
Kafka的代码实现和ZMQ大同小异这里我就不专门讲解了。关于Kafka的更多内容极客时间平台也有对 Kafka 的专门详细的介绍对此有兴趣的同学可以在极客时间中搜索“Kafka核心技术与实战”这个专栏里胡夕老师用详实的篇幅讲解了 Kafka 的实战和内核,你可以加以学习和使用。
来自极客时间专栏“Kafka核心技术与实战”
基于消息队列的 Orderbook 数据流
最后回到我们的量化交易系统上。
量化交易系统中,获取 orderbook 一般有两种用途:策略端获取实时数据,用来做决策;备份在文件或者数据库中,方便让策略和回测系统将来使用。
如果我们直接单机监听交易所的消息,风险将会变得很大,这在分布式系统中叫做 Single Point Failure。一旦这台机器出了故障或者网络连接突然中断我们的交易系统将立刻暴露于风险中。
于是,一个很自然的想法就是,我们可以在不同地区放置不同的机器,使用不同的网络同时连接到交易所,然后将这些机器收集到的信息汇总、去重,最后生成我们需要的准确数据。相应的拓扑图如下:
当然,这种做法也有很明显的缺点:因为要同时等待多个数据服务器的数据,再加上消息队列的潜在处理延迟和网络延迟,对策略服务器而言,可能要增加几十到数百毫秒的延迟。如果是一些高频或者滑点要求比较高的策略,这种做法需要谨慎考虑。
但是对于低频策略、波段策略这种延迟换来的整个系统的稳定性和架构的解耦性还是非常值得的。不过你仍然需要注意这种情况下消息队列服务器有可能成为瓶颈也就是刚刚所说的Single Point Failure一旦此处断开依然会将系统置于风险之中。
事实上我们可以使用一些很成熟的系统例如阿里的消息队列AWS 的 Simple Queue Service 等等,使用这些非常成熟的消息队列系统,风险也将会最小化。
总结
这节课,我们分析了现代化软件工程领域中的中间件系统,以及其中的主要应用——消息队列。我们讲解了最基础的消息队列的模式,包括点对点模型、发布者订阅者模型,和一些其他消息队列自己支持的模型。
在真实的项目设计中,我们要根据自己的产品需求,来选择使用不同的模型;同时也要在编程实践中,加深对不同技能点的了解,对系统复杂性进行解耦,这才是设计出高质量系统的必经之路。
思考题
今天的思考题文中我也提到过这里再专门列出强调一下。在ZMQ 那里,我提出了两个问题:
如果你试着把发布者的 time.sleep(1) 放在 while 循环的最后,会发生什么?为什么?
如果有多个发布者ZMQ 应该怎么做呢?
欢迎留言写下你的思考和疑惑,也欢迎你把这篇文章分享给更多的人一起学习。

View File

@ -0,0 +1,81 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
43 Q&A聊一聊职业发展和选择
你好,我是景霄。
在前面几节课中我分享了在FB工作的一些经验和感想不少同学都提出了自己的困惑也希望我能给出一些职业发展方面的建议。综合这些问题我主要选取了下面三个主题来说说职业发展、职业选择方面我的看法。
Q程序员的岗位主要有哪些类型我该如何选择
A无论是在求职阶段还是正式进入公司工作后你都会发现工程师普遍按技术的不同分为下面几个岗位。
前端包括移动Android、iOS以及Web前端JavaScript、CSS开发。
后端(服务器端):主要是服务器端的开发,简单来说,就是输入为请求,输出为响应,发送给客户端。
算法:主要涉及到的是机器学习,比如推荐系统如何更好地实现个性化推荐,搜索引擎返回的结果如何才能更符合地用户的需求等等。
架构涉及系统架构偏底层语言以C++为主。
从薪酬的角度来看,普遍来说:算法 > 架构 > 后端 > 前端。当然,这主要是由市场的供需关系决定的。
就拿算法岗来说国内市场普遍缺少算法人才也是因为这个岗位的培养难度更大需要投入更大的精力。在顶尖互联网公司参与核心产品研发的算法工程师们工作三年年收入100-200W人民币是很常见的。
不过我这里所说的算法人才绝不是指类似在校生那种看过几篇论文写过一些MATLAB在学校做过几个科研项目的程度。算法工作岗位需要的算法能力是你必须身体力行有某些产品线的实践经历。还需要你真正了解市场比如今日头条的推荐算法是怎样的Google搜索引擎是怎么工作的头条里的广告排序又是怎么做的等等。
再来说说架构,这也是目前一个热门的方向。我一直认为这是一个很偏工程、很硬核的领域,发展前景也相当不错,可以说是一个产品的基石。就拿刚刚提到的推荐系统来说,广告的定位和排序系统背后,都需要强有力的架构支撑。因此,这一行也可以称得上是人才紧缺,是企业舍得花高薪聘请的对象之一。
与算法不同的是这个领域不会涉及很深的数学知识工程师的主要关注点在于如何提高系统性能包括如何使系统高扩展、减小系统的延迟和所需CPU的容量等等。架构师需要很强的编程能力常用的语言是C++当然最重要的还是不断积累大型项目中获得的第一手经验对常见的问题有最principle的处理方式。
最后说说后端和前端,这是绝大多数程序员从事的岗位,也是我刚进公司时的选择。也许比起前两个岗位,不少人会认为,后端、前端工程师的薪酬较低,没有什么发展前景。这其实大错特错了!从一个产品的角度出发,你可以没有算法工程师、没有架构师,但是你能缺少后端和前端的开发人员吗?显然是不可能的。
后端和前端,相当于是一个产品的框架。框架搭好了,才会有机器学习、算法等的锦上添花。诚然,这两年来看,后端和前端没有前两者那么热门(还是市场供需关系的问题),但这并不代表,这些岗位没有发展前景,或者你就可以小看其技术含量。
比起算法和架构,后端、前端确实门槛更低些,但是其工作依然存在很高的技术含量。比如对一个产品或者其中的某些部件来说,如何设计搭建前后端的开发框架结构,使系统更加合理、可维护性更高,就是很多资深的开发工程师正在做的事。
前面聊了这么多,最后回到最根本的问题上:到底如何选择呢?
这里我给出的建议是:首先以自己的兴趣为出发点,因为只有自己感兴趣的东西,你才能做到最好。比如,一些人就是对前端感兴趣,那么为啥偏要去趟机器学习这趟浑水呢?当然不少人可能没有明确的偏好,那么这种情况下,我建议你尽可能多地去尝试,这是了解自己兴趣最好的方法。
另外从广义的角度来看计算机这门技术存在着study deep和study broad这两个方向你得想清楚你属于哪类。所谓的study deep就意味着数十年专攻一个领域励志成为某个领域的专家而study broad便是类似于全栈工程师对一个产品、系统的end to end都有一个了解能够随时胜任任意角色的工作这一点在初创公司身上体现得最为明显。
Q如何成为一个全栈工程师
A相信屏幕前的不少同学是在创业公司工作的刚刚也提到了创业公司里全栈工程师的需求尤为突出。那么如何成为一个优秀的全栈工程师呢
简单来说,最好的方法就是“尽可能地多接触、多实践不同领域的项目”。身体力行永远是学习新知识、提高能力的最好办法。
当然,在每个领域的初始阶段,你可能会感觉到异常艰难,比如从未接触过前端的人被要求写一个页面,一时间内显然会不知从何下手。这个时候,我建议你可以先从“依葫芦画瓢”开始,通过阅读别人相似的代码,并在此基础上加以修改,完成你要实现的功能。时间久了,你看的多了,用的多了,理解自然就越来越深,动起手来也就越来越熟练了。
有条件的同学比如工作在类似于FB这种文化的公司可以通过在公司内部换组的方式去接触不同的项目。这自然是最好不过了因为和特定领域的人合作永远比一个人单干强得多你能够迅速学到更多的东西。
不过,没这种条件的同学也不必绝望,你还可以利用业余时间“充电“,自己做一些项目来培养和加强别的领域的能力。毕竟,对于成年人来说,自学才是精进自己的主要方式。
这样,到了最后,你应该达到的结果便是,自己一个人能够扛起整条产品线的开发,也对系统的整个工作流程有一个全面而深入的理解。
Q学完本专栏后在Python领域我该如何继续进阶呢
A在我看来这个专栏的主要目的是带你掌握Python这门语言的常见基本和高阶用法。接下来的进阶便是Python本身在各种不同方向的运用拿后端开发这个方向来说比如如何搭建大型系统的后台便是你需要掌握的。一个好的后端自然离不开
合理的系统、框架设计;
简约高效的代码质量;
稳健齐全的单元测试;
出色的性能表现。
具体来说,你搭建的系统后端是不是易于拓展呢?比如过半年后,有了新的产品需求,需要增加新的功能。那么,在你的框架下,是否可以尽可能少地改动来实现新的功能,而不需要把某部分推倒重来呢?
再比如你搭建的系统是不是符合可维护性高、可靠性高、单元测试齐全的要求从而不容易在线上发生bug呢
总之,在某一领域到了进阶的阶段,你需要关注的,绝不仅仅只是某些功能的实现,更需要你考虑所写代码的性能、质量,甚至于整个系统的设计等等。
虽然讲了这么多东西,但最后我想说的是,三百六十行,行行出状元。对于计算机行业,乃至整个职场来说,每一个领域都没有优劣之分,每个领域你都可以做得很牛逼,前提是你不懈地学习、实践和思考。
那么,对于职业选择和发展,你又是如何看待和理解的呢?欢迎留言和我一起交流探讨,也希望屏幕前的一直不懈学习的你,能找到属于自己的方向,不断前进和创新,实现自己的人生理想。

View File

@ -0,0 +1,228 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 zipfile压缩库如何给数据压缩&加密备份?
你好,我是尹会生。
你在日常工作中,肯定和压缩文件打过交道,它们能把文件夹制作成一个体积更小的压缩文件,不仅方便数据备份,还方便作为邮件附件来传输,或者与他人共享。
但是如果你需要每天都进行数据备份或者把压缩包作为每天工作的日报发送给领导你肯定希望它能自动化的压缩。面对这个需求我们同样可以通过python来解决。我们可以用Python来自动压缩文件夹并为压缩包设置密码保证备份数据的安全。
在Python中要想实现数据的压缩一般可以采用基于标准库zipfile的方式来实现也可以采用命令行方式来实现。
当我们希望能够用Python自动压缩一个无需密码保护的文件夹时可以通过zipfile来实现它的好处是使用简单而且不用安装任何的软件包就能制作出“zip”格式的压缩包。不过zipfile没法对压缩文件进行加密因此当你需要对压缩文件加密时还需要调用可执行命令。
这两种实现方式就是我们今天要学习的重点了,接下来我们分别看一下这两种方式的具体操作方法。
使用zipfile实现无密码压缩
如果我想要把“C:\data\”文件夹压缩为“当前日期.zip”文件就可以使用目录遍历、按日期自动生成压缩包的文件名、把文件夹写入压缩文件这三个步骤来实现。
目录遍历
我们先来学习怎么实现目录遍历功能。我在第16讲已经为你讲解过它的技术细节了这里我就继续使用os库来实现目录的遍历。
由于目录遍历的功能与其他功能之间的调用关系耦合得比较宽松所以我就把目录遍历功能单独定义成一个getAllFiles()函数,并把要遍历的目录作为函数的参数,把该目录下的所有文件以及所在路径作为函数的返回值。
我把getAllFiles()函数的代码放在下方,供你参考。
import os
# 遍历目录,得到该目录下所有的子目录和文件
def getAllFiles(dir):
for root,dirs,files in os.walk(dir):
for file in files:
yield os.path.join(root, file)
细心的你一定发现了在函数getAllFiles()的返回语句中我使用yield语句代替了之前学习过的return语句返回文件路径和名称。为什么我要使用yield语句呢
原因就在于,*一个函数如果使用yield语句来返回的话这个函数则被称作生成器*。yield的返回数据类型以及对类型的访问方式都和return不同。我来为你解释一下yield和return的具体区别以及使用yield的好处。
首先从返回类型来看yield返回的数据类型叫做生成器类型这一类型的好处是调用getAllFiles()一次函数就会返回一个文件路径和文件名。而return返回的是一个列表类型需要一次性把要备份目录下的所有文件都访问一次一旦要备份的文件数量非常多就会导致计算机出现程序不响应的问题。
除了返回类型还有调用方式也和return不同。使用yield返回的对象被称作生成器对象该对象没法像列表一样一次性获得对象中的所有数据你必须使用for循环迭代访问才能依次获取数据。
此外当所有的数据访问完成还会引发一个“StopIteration”异常告知当前程序这个生成器对象的内容已经全部被取出来那么这个生成器将会在最后一次访问完成被计算机回收这样yield就能够知道对象是否已经全部被读取完。
从yield和return的行为对比可以说yield返回对象最大的好处是可以逐个处理而不是一次性处理大量的磁盘读写操作这样就有效减少了程序因等待磁盘IO而出现不响应的情况。这就意味着你不必在调用getAllFiles()函数时,因为需要备份的文件过多,而花费较长的时间等待它执行完成。
按日期自动生成压缩包的文件名
接下来我们来学习一下按日期自动生成压缩包的函数genZipfilename()。按日期生成文件名,在定时备份的场景中经常被用到,我们希望每天产生一个新的备份文件,及时保存计算机每天文件的变化。
这就要求今天的备份的文件名称不能和昨天的同名,避免覆盖上次备份的文件。
所以genZipfilename()函数就把程序执行的日期作为文件名来进行备份例如当前的日期是2021年4月12日那么备份文件会自动以“20210412.zip”作为文件名称。我把代码贴在下方供你参考。
import datetime
# 以年月日作为zip文件名
def genZipfilename():
today = datetime.date.today()
basename = today.strftime('%Y%m%d')
extname = "zip"
return f"{basename}.{extname}"
在这段代码中“datetime.date.today()”函数能够以元组格式取得今天的日期不过它的返回格式是元组且年、月、日默认采用了三个元素被存放在元组中这种格式是没法直接作为文件名来使用的。因此你还需要通过strftime()函数把元组里的年、月、日三个元素转换为一个字符串,再把字符串作为文件的名称来使用。
把文件夹写入压缩文件
最后准备工作都完成之后你就可以使用zipfile库把要备份的目录写入到zip文件了。zipfile库是Python的标准库所以不需要安装软件包为了让这个完整脚本都不需要安装第三方软件包我在实现文件遍历的时候同样采用os库代替pathlib库。
除了不需要安装之外zipfile库在使用上也比较友好它创建和写入zip文件的方式就是模仿普通文件的操作流程使用with关键字打开zip文件并使用write()函数把要备份的文件写入zip文件。
所以通过学习一般文件的操作你会发现Python在对其他格式的文件操作上都遵循着相同的操作逻辑这也体现出Python语言相比其他语言更加优雅和简单。
那么我把使用zipfile库实现创建zip文件的功能写入zipWithoutPassword()函数中,你可以对照一般文件的写入逻辑来学习和理解这段代码,代码如下:
from zipfile import ZipFile
def zipWithoutPassword(files,backupFilename):
with ZipFile(backupFilename, 'w') as zf:
for f in files:
zf.write(f)
对比一般的文件写入操作zip文件的打开使用了“ZipFile()函数”而一般文件的打开使用了open函数。写入方法与一般文件相同都是调用“write()”函数实现写入。
这三个函数也就是函数getAllFiles()、genZipfilename()和zipWithoutPassword()就是把备份目录到zip文件的核心函数了。我们以备份“C:\data”文件夹为“20210412.zip”压缩文件为例依次调用三个函数就能实现自动备份目录了我把调用的代码也写在下方供你参考。
if __name__ == '__main__':
# 要备份的目录
backupDir = r"C:\data"
# 要备份的文件
backupFiles = getAllFiles(backupDir)
# zip文件的名字“年月日.zip”
zipFilename = genZipfilename()
# 自动将要备份的目录制作成zip文件
zipWithoutPassword(backupFiles, zipFilename)
在执行这段代码后就会在代码运行的目录下产生“20210412.zip”文件你通过计算机上的winrar等压缩软件查看就会发现其中会有“C:\data”文件夹下的所有文件。由于文件名称是以当前日期自动产生的所以每天执行一次备份脚本就能实现按天备份指定的文件夹为压缩包了。
不过在备份时,除了要保证数据的可用性,你还有考虑数据的安全性,最好的办法就是在备份时为压缩包指定密码。接下来我就带你使用命令行调用实现有密码的文件压缩。
使用可执行命令实现有密码压缩
在制作有密码的压缩包时我们必须使用命令代替zipfile来压缩文件因为zipfile默认是不支持密码压缩功能的。当你需要对压缩数据有保密性的要求时可以使用7zip、winrar这些知名压缩软件的命令行进加密压缩。
我在本讲中就以7zip压缩工具为例带你学习一下怎么使用Python通过命令行方式调用7zip实现文件的加密压缩。
执行方式和执行参数
要想使用7zip实现压缩并被Python直接调用你除了需要在Windows上安装7zip外还需要知道它的执行方式和执行的参数。
我先来带你学习一下执行方式。7zip软件Windows安装成功后它的命令行可执行程序叫做“7z.exe”。但是它想要在命令行运行的话需要指定程序的完整路径。例如“c:\path\to\installed\7zip\7z.exe”。如果你希望在命令行直接输入“7z.exe”运行需要你把可执行程序放在命令搜索路径中。我在这里有必要为你解释一下命令搜索路径的概念有助于你以后在各种操作系统上执行命令行工具。
一条命令要想运行,必须要使用路径+可执行文件的名称才可以。例如我Windows中需要把Python的可执行命令“python.exe”安装到“C:\python3.8\scripts\python.exe”这一位置。
那么一般情况下当你需要运行Python解释器时必须输入很长的路径。这种做法在经常使用命令行参数时没法接受的一个是你需要记住大量命令的所在路径另一个是较长的路径也会降低你的执行效率。
因此在各种操作系统上都有“命令搜索路径”的概念。在Windows中命令搜索路径被保存在Path环境变量中Path变量的参数是由分号分隔开的文件夹当你在命令行输入“python.exe”并回车运行它时操作系统会遍历Path变量参数中的每个文件夹。如果找到了“python.exe”文件就可以直接运行它如果没有找到则会提示用户该命令不存在。这就避免你每次执行一条命令时都需要输入较长的路径。
再回到7zip的命令行执行文件“7z.exe”上我把它安装在“C:\7zip\”文件夹下如果你希望执行运行7z.exe且不输入路径那么根据上面的分析现在有两种解决办法。
把7z.exe放到现有的命令搜索路径中例如“C:\python3.8\scripts\”文件夹。
把7z.exe所在的文件夹“C:\7zip\”加入到命令搜索路径Path变量的参数中。加入的方法是在Windows的搜索栏搜索关键字“环境变量然后在弹出的环境变量菜单把路径加入到Path变量参数即可。
设置完成环境变量后7z.exe就不必在命令行中输入路径直接运行即可。
在你掌握了执行方式后我再来带你学习一下它的参数要想使用支持密码加密方式的zip压缩包你需要使用四个参数它们分别是
a参数7z.exe能够把文件夹压缩为压缩包也能解压一个压缩包。a参数用来指定7z将要对一个目录进行的压缩操作。
-t参数用来指定7z.exe制作压缩包的类型和名称。为了制作一个zip压缩包我将把该参数指定为-tzip并在该参数后指定zip压缩包的名称。
-p参数用来指定制作的压缩包的密码。
“目录”参数:用来指定要把哪个目录制作为压缩包。
如果我希望把压缩包“20210412.zip”的密码制作为“password123”可以把这四个压缩包的参数组合在一起使用如下命令行
7z.exe a -tzip 20210412.zip -ppassword123 C:\data
扩展zipfile
由于命令的参数较多且记住它的顺序也比较复杂所以我们可以利用Python的popen()函数把“7z.exe”封装在Python代码中会更容易使用。
因此我在无密码压缩的代码中就可以再增加一个函数zipWithPassword()用来处理要压缩的目录、压缩文件名和密码参数并通过这个函数再去调用popen()函数封装命令行调用7z.exe的代码从而实现有密码的压缩功能。代码如下
import os
def zipWithPassword(dir, backupFilename, password=None):
cmd = f"7z.exe a -tzip {backupFilename} -p{password} {dir}"
status = os.popen(cmd)
return status
我来解释一下这段代码。在实现有密码压缩的函数中为了调用函数更加方便我把“压缩的文件夹、zip文件名称、密码”作为该函数的参数这样当在你调用zipWithPassword()函数时就能指定所有需要加密的文件和目录了。此外在执行命令时我还通过os.popen()函数产生了一个新的子进程如果你不记得这个概念可以参考第五讲用来执行7z.exe这样7z.exe会按照函数的参数把文件夹压缩成zip文件并增加密码。
通过zipWithPassword()函数你就能够实现zipfile的扩展实现有密码文件压缩功能了。
小结
最后我来为你总结一下今天这节课的主要内容。我通过zipfile库和7zip软件分别实现了无密码压缩文件和有密码压缩文件。
无密码压缩文件更加简单方便,而有密码压缩文件更加安全,配合自动根据当前日期改变压缩文件名称,可以作为你进行每日数据自动化备份的主要工具。
除了备份功能的学习外我还为你讲解了新的函数返回方式yield和return不同的是yield返回的是生成器对象需要使用for迭代方式访问它的全部数据。yield语句除了可以和zipfile库一起实现数据备份外还经常被应用于互联网上的图片批量下载压缩场景中。
以上内容就是怎么实现无密码和有密码压缩的全部内容了,我将完整代码贴在下方中,一起提供给你,你可以直接修改需要备份的目录,完成你自己文件夹的一键备份脚本。
from zipfile import ZipFile
import os
import datetime
# 以年月日作为zip文件名
def genZipfilename():
today = datetime.date.today()
basename = today.strftime('%Y%m%d')
extname = "zip"
return f"{basename}.{extname}"
# 遍历目录,得到该目录下所有的子目录和文件
def getAllFiles(dir):
for root,dirs,files in os.walk(dir):
for file in files:
yield os.path.join(root, file)
# 无密码生成压缩文件
def zipWithoutPassword(files,backupFilename):
with ZipFile(backupFilename, 'w') as zf:
for f in files:
zf.write(f)
def zipWithPassword(dir, backupFilename, password=None):
cmd = f"7z.exe a -tzip {backupFilename} -p{password} {dir}"
status = os.popen(cmd)
return status
if __name__ == '__main__':
# 要备份的目录
backupDir = "/data"
# 要备份的文件
backupFiles = getAllFiles(backupDir)
# zip文件的名字“年月日.zip”
zipFilename = genZipfilename()
# 自动将要备份的目录制作成zip文件
zipWithoutPassword(backupFiles, zipFilename)
# 使用密码进行备份
zipWithPassword(backupDir, zipFilename, "password123")
思考题
按照惯例,我来为你留一道思考题,如果需要备份的是两个甚至更多的目录,你会怎么改造脚本呢?
欢迎把你的想法和思考分享在留言区,我们一起交流讨论。也欢迎你把课程分享给你的同事、朋友,我们一起做职场中的效率人。我们下节课再见!

View File

@ -0,0 +1,43 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 自动化 CI&CD 与灰度发布
环境管理和自动化部署
当我们从传统开发迁移到 Serverless 下,对于环境和部署的管理思路也会有所不同。当用户转到 Serverless ,可以轻松地提供更多的环境,而这个好处常被忽略。
当我们开发项目时,通常需要一个生产环境,然后需要预发环境,还有一些测试环境。但通常每个环境都需要消耗资源和成本,以保持服务在线。而大多数时候非生产环境上的访问量非常少,为此付出大量的成本很不划算。
但是,在 Serverless 架构中,我们可以为每位开发人员提供一个准生产环境。做 CI/CD 的时候,可以为每个功能分支创建独立的演示环境。
当团队成员在开发功能或者修复 bug 时,想要预览新功能,就可以立即部署,而不需要在自己机器上模拟或者找其他同事协调测试环境的使用时间。
这一切都受益于 Serverless我们不需要为空闲资源付费。当我们去部署那些基本没有访问量的环境时成本是极低的。
由于部署新环境变得很容易,对于自动化部署的要求就变高了。当然无论是否采用 Serverless 架构,自动化部署都很重要。能否自动化地构建、部署和创建整个环境是判断开发团队优秀与否的重要因素。在 serverless 场景,这种能力尤为重要,因为只有这样才能充分利用平台的优势。
后面的课程我们会了解到,借助于函数计算平台提供的 Funcraft 工具,开发人员可以用从前做不到的方式在准生产环境中轻松部署和测试代码。
灰度发布
由于 Serverless 提供的弹性机制,没有访问量的时候能自动缩容到零,极大地节约了部署的多环境的成本。然而在同一套环境内的多个不同的版本也可以受益于这套机制。
传统应用虽然也支持在一个环境中并存多个版本,但相比于 Serverless 更加困难。首先每个版本都需要相对独立的运行环境,会消耗更多的资源。其次需要解决多个版本之间流量的分配问题。
在 FaaS 上这些问题已经被版本和别名机制完美的解决。由于没有流量就不消耗计算资源,所以发布一个版本的成本极低,每次发布都可以形成一个版本。然后通过别名进行版本的切换和流量分配。
基于 FaaS 的这套抽象,让灰度发布和 A/B 测试变得非常的简单。不再需要像 K8s 那样复杂的基础设置,开发者也能轻松地享受到平滑升级和快速验证的高级特性。
结语
Serverless 让开发和部署都变得更加的简单。希望您能继续探索其他 Serverless 和函数计算的内容,更多相关的资料可以访问函数计算的产品页 https://www.aliyun.com/product/fc

View File

@ -0,0 +1,39 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 Serverless 应用如何管理日志&持久化数据
实时日志
首先SAE 支持查看应用实例分组下各个 Pod 的实时日志。当应用出现异常情况时,可以通过查看 Pod 的实时日志定位问题。当应用运行时,可以在【控制台 - 日志管理菜单下 - 实时日志子菜单】方便地看到应用实例的实时日志。
文件日志
SAE 将业务文件日志(不包含 stdout 和 stderr 日志)收集并输入 SLS 中,实现无限制行数查看日志、自行聚合分析日志,方便业务日志对接,并按日志使用量计费。
您可以在部署应用时配置日志收集服务,填入需要采集的日志源,对于滚动日志的场景,可以填入通配符进行解决。
当配置完成后,可以在【控制台 - 日志管理菜单 - 文件日志子菜单】方便地看到采集的文件日志。
NAS 持久化存储
由于存储在容器中数据是非持久化的SAE 支持了 NAS 存储功能,解决了应用实例数据持久化和实例间多读共享数据的问题。
您可以通过部署应用来配置持久化存储,选择创建好的 NAS并填入容器中对应的挂载路径即可。
当配置完成后,可以通过 cat /proc/mount | grep nfs 命令查看是否挂载成功,或者可以准备 2 个应用实例A 和 B分别挂载 NAS。对 A 执行写入命令 echo “hello” > tmp.txt对 B 执行读取命令 cat tmp.txt。如果 B 中能够读取到在 A 中写入的 hello表示 NAS 挂载成功。

View File

@ -0,0 +1,98 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 如何通过压测工具+ SAE 弹性能力轻松应对大促
传统大促挑战
一次常见的大促活动,技术人员通常会从下面几个方面着手,进行准备工作:
架构梳理:对参与大促的服务,进行系统性的架构梳理;
容量规划:结合架构梳理,确定系统 SLA 指标,形成容量模型,帮助业务进行评估;
性能测试:核心系统的单机容量评估,与核心链路全链路压测,可以验证容量模型,发现系统存在的问题;
应用/数据库优化:对发现的系统问题,譬如热点、死锁或慢 SQL 等,进行优化,确保系统可以支撑大促;
准备扩容方案:通过容量规划与性能测试,可以确定一套满足活动需求的扩容方案,既保障业务,又降低成本;
应急预案准备:当遇到突发情况如何应对,譬如业务降级,砍掉非核心逻辑,或者限流降级,保障核心链路稳定;
大促在线应急保障:专人专项,对问题进行响应,执行应急预案。
要完成上述准备工作,经常会遇到如下痛点:
系统核心全链路,缺少全局关系视角。需要花大量时间,整理依赖关系。
链路上下游问题、定位问题比较耗时。压测与在线应急保障过程中,汇总链路上下游问题,定位问题比较耗时,缺少快速定位分析工具。
业务开发迭代快,需要常态化压测支持。大量重复性人力投入,给大家造成很大负担。
预留资源成本高,需要频繁扩缩容。需要产品化支持自动弹性伸缩,降低自建机房等高成本高闲置的固定投入。
SAE 大促解决方案
首先SAE 是一款面向应用的 Serverless PaaS 平台,在传统 PaaS 功能之外,提供了完备的全链路监控、微服务管理等能力,并借助 Serverless 能力,最大程度进行快速扩缩容、降低手工运维成本。
SAE 提供的解决方案,将从三方面入手:
指标可视化:借助应用监控 ARMS 提供丰富的 JVM、全链路 Tracing 、慢 SQL 等功能,便捷地评估水位、定位问题;
应用高可用:借助 AHAS 限流降级能力,流量激增时,保护核心服务,保障可用性不完全跌 0
性能压测:借助压测工具如 PTS模拟单机压测或全链路压测验证容量规划、发现应用问题。
快速压测验证
那么如何通过 SAE ,进行一次快速的大促压测验证呢?下面将进行一次完整的展示:
第一步:观察应用监控指标,大致拟定弹性/压测/限流降级
通过观察应用监控,对日常业务的监控指标,有一个大致的概念。以一个典型的电商类应用为例。
从监控情况看:
该应用为 HTTP 微服务应用;
应用依赖大量 HTTP 微服务调用,少量使用 Redis / MySQL 服务,适合使用单机 + 分布式压测工具,分别进行压测;
QPS 指标,相比 CPU、MEM 和 RT 指标,对业务更敏感,更适合作为弹性策略指标。
第二步:选择合适的压测工具
根据业务诉求,可以选择快速使用的工具,或功能完整的压测工具。
譬如单机 HTTP 压测工具 ab、wrk可以提供简单快速的压测方式但只支持单机、不支持上下文。
如果我们需要支持 WebSocket 、常态化压测,云产品 PTS 可以提供较为完整的服务,相比自建成本更低。
第三步:配置 SAE 弹性伸缩策略 + AHAS 限流降级策略
无需精准设置,选择一些合适的指标,配置 SAE 弹性伸缩策略,或额外配置 AHAS 限流策略 / ARMS 告警。
对 API 类型,可通过对 API QPS、SQL QPS 等指标进行限流,保障超过系统水位的请求,快速 failover降低对容量内业务的 SLA并选择应用监控指标 QPS、RT配置弹性规则让系统进行弹性伸缩
对于计算型应用,则可选择更敏感的指标,如 CPU、Memory 对应用进行扩缩容。
第四步:执行压测 观察结果 优化代码 调整策略配置
1根据压测与监控结果看是否有必要优化代码或调整 SAE 弹性伸缩策略、AHAS 限流策略。 2执行压测查看压测结果发现存在失败请求。 3查看监控异常发现存在 GC 异常。通过 SAE 控制台,优化 JVM 参数解决。 4再次压测验证问题是否解决。 5如此重复一两轮解决其中发现的主要问题可以更从容地面对大促。
详细演示过程请点击【视频课链接】进行观看。

View File

@ -0,0 +1,256 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 数据分片:如何实现分库、分表、分库+分表以及强制路由?(上)
通过前面几个课时的介绍,相信你对 ShardingSphere 已经有了初步了解。从今天开始,我将带领你通过案例分析逐步掌握 ShardingSphere 的各项核心功能,首当其冲的就是分库分表机制。
单库单表系统
我们先从单库单表系统说起。在整个课程中,如果没有特殊强调,我们将默认使用 Spring Boot 集成和 ShardingSphere 框架,同时基于 Mybatis 实现对数据库的访问。
导入开发框架
系统开发的第一步是导入所需的开发框架。在下面这段代码中,我们新建了一个 Spring Boot 代码工程,在 pom 文件中需要添加对 sharding-jdbc-spring-boot-starter 和 mybatis-spring-boot-starter 这两个 starter 的引用:
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>
开发环境初始化要做的工作也就是这些,下面我们来介绍案例的业务场景。
梳理业务场景
我们考虑一个在医疗健康领域中比较常见的业务场景。在这类场景中每个用户User都有一份健康记录HealthRecord存储着代表用户当前健康状况的健康等级HealthLevel以及一系列健康任务HealthTask。通常医生通过用户当前的健康记录创建不同的健康任务然后用户可以通过完成医生所指定的任务来获取一定的健康积分而这个积分决定了用户的健康等级并最终影响到整个健康记录。健康任务做得越多健康等级就越高用户的健康记录也就越完善反过来健康任务也就可以越做越少从而形成一个正向的业务闭环。这里我们无意对整个业务闭环做过多的阐述而是关注这一业务场景下几个核心业务对象的存储和访问方式。
在这个场景下,我们关注 User、HealthRecord、HealthLevel 和 HealthTask 这四个业务对象。在下面这张图中,对每个业务对象给出最基础的字段定义,以及这四个对象之间的关联关系:
完成基础功能
既然采用 Mybatis 作为 ORM 框架,那么就需要遵循 Mybatis 的开发流程。首先,我们需要完成各个业务实体的定义:
业务实体的类定义
基于这些业务实体,我们需要完成对应的 Mapper 文件编写,我把这些 Mapper 文件放在代码工程的 resources 目录下:
Mybatis Mapper 文件定义
下一步是数据源信息的配置,我们把这些信息放在一个单独的 application-traditional.properties 配置文件中。
spring.datasource.driverClassName = com.mysql.jdbc.Driver
spring.datasource.url = jdbc:mysql://localhost:3306/ds
spring.datasource.username = root
spring.datasource.password = root
按照 Spring Boot 的配置约定,我们在 application.properties 配置文件中把上述配置文件设置为启动 profile。通过使用不同的 profile我们可以完成不同配置体系之间的切换。
spring.profiles.active=traditional
接下来要做的事情就是创建 Repository 层组件:
Repository 层接口定义
最后,我们设计并实现了相关的三个服务类,分别是 UserService、HealthLevelService 和 HealthRecordService。
Service 层接口和实现类定义
通过 UserService我们会插入一批用户数据用于完成用户信息的初始化。然后我们有一个 HealthLevelService专门用来初始化健康等级信息。请注意与其他业务对象不同健康等级信息是系统中的一种典型字典信息我们假定系统中存在 5 种健康等级。
第三个,也是最重要的服务就是 HealthRecordService我们用它来完成 HealthRecord 以及 HealthTask 数据的存储和访问。这里以 HealthRecordService 服务为例,下面这段代码给出了它的实现过程:
@Service
public class HealthRecordServiceImpl implements HealthRecordService {
@Autowired
private HealthRecordRepository healthRecordRepository;
@Autowired
private HealthTaskRepository healthTaskRepository;
@Override
public void processHealthRecords() throws SQLException{
insertHealthRecords();
}
private List<Integer> insertHealthRecords() throws SQLException {
List<Integer> result = new ArrayList<>(10);
for (int i = 1; i <= 10; i++) {
HealthRecord healthRecord = insertHealthRecord(i);
insertHealthTask(i, healthRecord);
result.add(healthRecord.getRecordId());
}
return result;
}
private HealthRecord insertHealthRecord(final int i) throws SQLException {
HealthRecord healthRecord = new HealthRecord();
healthRecord.setUserId(i);
healthRecord.setLevelId(i % 5);
healthRecord.setRemark("Remark" + i);
healthRecordRepository.addEntity(healthRecord);
return healthRecord;
}
private void insertHealthTask(final int i, final HealthRecord healthRecord) throws SQLException {
HealthTask healthTask = new HealthTask();
healthTask.setRecordId(healthRecord.getRecordId());
healthTask.setUserId(i);
healthTask.setTaskName("TaskName" + i);
healthTaskRepository.addEntity(healthTask);
}
}
现在,我们已经从零开始实现了一个完整业务场景所需要的 DAO 层和 Service 层组件。这些组件在业务逻辑上都非常简单,而在技术上也是完全采用了 Mybatis 的经典开发过程。最后,我们可以通过一组简单的单元测试来验证这些组件是否能够正常运行。下面这段代码以 UserServiceTest 类为例给出它的实现,涉及 @RunWith@SpringBootTest 等常见单元测试注解的使用:
@RunWith(SpringRunner.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
public class UserServiceTest {
@Autowired
private UserService userService;
@Test
public void testProcessUsers() throws Exception {
userService.processUsers();
}
}
运行这个单元测试,我们可以看到测试通过,并且在数据库的 User 表中也看到了插入的数据。至此,一个单库单表的系统已经构建完成。接下来,我们将对这个系统做分库分表改造。
在传统单库单表的数据架构上进行分库分表的改造,开发人员只需要做一件事情,那就是基于上一课时介绍的 ShardingSphere 配置体系完成针对具体场景的配置工作即可,所有已经存在的业务代码都不需要做任何的变动,这就是 ShardingSphere 的强大之处。让我们一起开始吧。
系统改造:如何实现分库?
作为系统改造的第一步,我们首先来看看如何基于配置体系实现数据的分库访问。
初始化数据源
针对分库场景,我们设计了两个数据库,分别叫 ds0 和 ds1。显然针对两个数据源我们就需要初始化两个 DataSource 对象,这两个 DataSource 对象将组成一个 Map 并传递给 ShardingDataSourceFactory 工厂类:
spring.shardingsphere.datasource.names=ds0,ds1
spring.shardingsphere.datasource.ds0.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost:3306/ds0
spring.shardingsphere.datasource.ds0.username=root
spring.shardingsphere.datasource.ds0.password=root
spring.shardingsphere.datasource.ds1.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost:3306/ds1
spring.shardingsphere.datasource.ds1.username=root
spring.shardingsphere.datasource.ds1.password=root
设置分片策略
明确了数据源之后,我们需要设置针对分库的分片策略:
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id
spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{user_id % 2}
我们知道,在 ShardingSphere 中存在一组 ShardingStrategyConfiguration这里使用的是基于行表达式的 InlineShardingStrategyConfiguration。
InlineShardingStrategyConfiguration 包含两个需要设置的参数,一个是指定分片列名称的 shardingColumn另一个是指定分片算法行表达式的 algorithmExpression。在我们的配置方案中将基于 user_id 列对 2 的取模值来确定数据应该存储在哪一个数据库中。同时注意到这里配置的是“default-database-strategy”项。结合上一课时的内容设置这个配置项相当于是在 ShardingRuleConfiguration 中指定了默认的分库 ShardingStrategy。
设置绑定表和广播表
接下来我们需要设置绑定表。绑定表BindingTable是 ShardingSphere 中提出的一个新概念,我来给你解释一下。
所谓绑定表是指与分片规则一致的一组主表和子表。例如在我们的业务场景中health_record 表和 health_task 表中都存在一个 record_id 字段。如果我们在应用过程中按照这个 record_id 字段进行分片,那么这两张表就可以构成互为绑定表关系。
引入绑定表概念的根本原因在于,互为绑定表关系的多表关联查询不会出现笛卡尔积,因此关联查询效率将大大提升。举例说明,如果所执行的为下面这条 SQL
SELECT record.remark_name FROM health_record record JOIN health_task task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
如果我们不显式配置绑定表关系,假设分片键 record_id 将值 1 路由至第 1 片,将数值 2 路由至第 0 片,那么路由后的 SQL 应该为 4 条,它们呈现为笛卡尔积:
SELECT record.remark_name FROM health_record0 record JOIN health_task0 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
SELECT record.remark_name FROM health_record0 record JOIN health_task1 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
SELECT record.remark_name FROM health_record1 record JOIN health_task0 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
SELECT record.remark_name FROM health_record1 record JOIN health_task1 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
然后,在配置绑定表关系后,路由的 SQL 就会减少到 2 条:
SELECT record.remark_name FROM health_record0 record JOIN health_task0 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
SELECT record.remark_name FROM health_record1 record JOIN health_task1 task ON record.record_id=task.record_id WHERE record.record_id in (1, 2);
请注意,如果想要达到这种效果,互为绑定表的各个表的分片键要完全相同。在上面的这些 SQL 语句中,我们不难看出,这个需要完全相同的分片键就是 record_id。
让我们回到案例中的场景显然health_record 和 health_task 应该互为绑定表关系。所以,我们可以在配置文件中添加对这种关系的配置:
spring.shardingsphere.sharding.binding-tables=health_record, health_task
介绍完绑定表再来看广播表的概念。所谓广播表BroadCastTable是指所有分片数据源中都存在的表也就是说这种表的表结构和表中的数据在每个数据库中都是完全一样的。广播表的适用场景比较明确通常针对数据量不大且需要与海量数据表进行关联查询的应用场景典型的例子就是每个分片数据库中都应该存在的字典表。
同样回到我们的场景,对于 health_level 表而言,由于它保存着有限的健康等级信息,可以认为它就是这样的一种字典表。所以,我们也在配置文件中添加了对广播表的定义,在下面这段代码中你可以看到:
spring.shardingsphere.sharding.broadcast-tables=health_level
设置表分片规则
通过前面的这些配置项,我们根据需求完成了 ShardingRuleConfiguration 中与分库操作相关的配置信息设置。我们知道 ShardingRuleConfiguration 中的 TableRuleConfiguration 是必填项。所以,我们来看一下这个场景下应该如何对表分片进行设置。
TableRuleConfiguration 是表分片规则配置,包含了用于设置真实数据节点的 actualDataNodes用于设置分库策略的 databaseShardingStrategyConfig以及用于设置分布式环境下的自增列生成器的 keyGeneratorConfig。前面已经在 ShardingRuleConfiguration 中设置了默认的 databaseShardingStrategyConfig现在我们需要完成剩下的 actualDataNodes 和 keyGeneratorConfig 的设置。
对于 health_record 表而言,由于存在两个数据源,所以,它所属于的 actual-data-nodes 可以用行表达式 ds$->{0..1}.health_record 来进行表示,代表在 ds0 和 ds1 中都存在表 health_record。而对于 keyGeneratorConfig 而言通常建议你使用雪花算法。明确了这些信息之后health_record 表对应的 TableRuleConfiguration 配置也就顺理成章了:
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds$->{0..1}.health_record
spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id
spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE
spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
同样的health_task 表的配置也完全类似,这里需要根据实际情况调整 key-generator.column 的具体数据列:
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds$->{0..1}.health_task
spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id
spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE
spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
让我们重新执行 HealthRecordTest 单元测试,并检查数据库中的数据。下面这张图是 ds0 中的 health_record 和 health_task 表:
ds0 中 health_record 表数据
ds0 中 health_task 表数据
而这张图是 ds1 中的 health_record 和 health_task 表:
ds1 中 health_record 表数据
ds1 中 health_task 表数据
显然,这两张表的数据已经正确进行了分库。
小结
从本课时开始,我们正式进入到 ShardingSphere 核心功能的讲解。为了介绍这些功能特性,我们将从单库单表架构讲起,基于一个典型的业务场景梳理数据操作的需求,并给出整个代码工程的框架,以及基于测试用例验证数据操作结果的实现过程。今天的内容关注于如何实现分库操作,我们通过引入 ShardingSphere 中强大的配置体系实现了分库效果。
这里给你留一道思考题:如何理解绑定表和广播表的含义和作用?
分库是 ShardingSphere 中分片引擎的核心功能之一,也可以说是最简单的功能之一。在下一课时中,我们将继续介绍分表、分库+分表以及强制路由等分片机制。

View File

@ -0,0 +1,380 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 数据分片:如何实现分库、分表、分库+分表以及强制路由?(下)
在上一课时中,我们基于业务场景介绍了如何将单库单表架构改造成分库架构。今天我们继续后续的改造工作,主要涉及如何实现分表、分库+分表以及如何实现强制路由。
系统改造:如何实现分表?
相比分库,分表操作是在同一个数据库中,完成对一张表的拆分工作。所以从数据源上讲,我们只需要定义一个 DataSource 对象即可,这里把这个新的 DataSource 命名为 ds2
spring.shardingsphere.datasource.names=ds2
spring.shardingsphere.datasource.ds2.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds2.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds2.url=jdbc:mysql://localhost:3306/ds2
spring.shardingsphere.datasource.ds2.username=root
spring.shardingsphere.datasource.ds2.password=root
同样,为了提高访问性能,我们设置了绑定表和广播表:
spring.shardingsphere.sharding.binding-tables=health_record, health_task
spring.shardingsphere.sharding.broadcast-tables=health_level
现在,让我们再次回想起 TableRuleConfiguration 配置,该配置中的 tableShardingStrategyConfig 代表分表策略。与用于分库策略的 databaseShardingStrategyConfig 一样,设置分表策略的方式也是指定一个用于分表的分片键以及分片表达式:
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2}
在代码中可以看到,对于 health_record 表而言,我们设置它用于分表的分片键为 record_id以及它的分片行表达式为 health_record$->{record_id % 2}。也就是说,我们会根据 record_id 将 health_record 单表拆分成 health_record0 和 health_record1 这两张分表。
基于分表策略,再加上 actualDataNodes 和 keyGeneratorConfig 配置项,我们就可以完成对 health_record 表的完整分表配置:
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds2.health_record$->{0..1}
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2}
spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id
spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE
spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
对于 health_task 表而言,可以采用同样的配置方法完成分表操作:
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds2.health_task$->{0..1}
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.sharding-column=record_id
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.algorithm-expression=health_task$->{record_id % 2}
spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id
spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE
spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
可以看到,由于 health_task 与 health_record 互为绑定表,所以在 health_task 的配置中,我们同样基于 record_id 列进行分片,也就是说,我们会根据 record_id 将 health_task 单表拆分成 health_task0 和 health_task1 两张分表。当然,自增键的生成列还是需要设置成 health_task 表中的 task_id 字段。
这样,完整的分表配置就完成了。现在,让我们重新执行 HealthRecordTest 单元测试,会发现数据已经进行了正确的分表。下图是分表之后的 health_record0 和 health_record1 表:
分表后的 health_record0 表数据
分表后的 health_record1 表数据
而这是分表之后的 health_task0 和 health_task1 表:
分表后的 health_task0 表数据
分表后的 health_task1表数据
系统改造:如何实现分库+分表?
在完成独立的分库和分表操作之后,系统改造的第三步是尝试把分库和分表结合起来。这个过程听起来比较复杂,但事实上,基于 ShardingSphere 提供的强大配置体系,开发人员要做的只是将分表针对分库和分表的配置项整合在一起就可以了。这里我们重新创建 3 个新的数据源,分别为 ds3、ds4 和 ds5:
spring.shardingsphere.datasource.names=ds3,ds4,ds5
spring.shardingsphere.datasource.ds3.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds3.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds3.url=jdbc:mysql://localhost:3306/ds3
spring.shardingsphere.datasource.ds3.username=root
spring.shardingsphere.datasource.ds3.password=root
spring.shardingsphere.datasource.ds4.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds4.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds4.url=jdbc:mysql://localhost:3306/ds4
spring.shardingsphere.datasource.ds4.username=root
spring.shardingsphere.datasource.ds4.password=root
spring.shardingsphere.datasource.ds5.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds5.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds5.url=jdbc:mysql://localhost:3306/ds5
spring.shardingsphere.datasource.ds5.username=root
spring.shardingsphere.datasource.ds5.password=root
注意,到现在有 3 个数据源,而且命名分别是 ds3、ds4 和 ds5。所以为了根据 user_id 来将数据分别分片到对应的数据源,我们需要调整行表达式,这时候的行表达式应该是 ds$->{user_id % 3 + 3}
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id
spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=ds$->{user_id % 3 + 3}
spring.shardingsphere.sharding.binding-tables=health_record,health_task
spring.shardingsphere.sharding.broadcast-tables=health_level
对于 health_record 和 health_task 表而言,同样需要调整对应的行表达式,我们将 actual-data-nodes 设置为 ds\(->{3..5}.health_record\)->{0..2},也就是说每张原始表将被拆分成 3 张分表:
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=ds$->{3..5}.health_record$->{0..2}
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 3}
spring.shardingsphere.sharding.tables.health_record.key-generator.column=record_id
spring.shardingsphere.sharding.tables.health_record.key-generator.type=SNOWFLAKE
spring.shardingsphere.sharding.tables.health_record.key-generator.props.worker.id=33
spring.shardingsphere.sharding.tables.health_task.actual-data-nodes=ds$->{3..5}.health_task$->{0..2}
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.sharding-column=record_id
spring.shardingsphere.sharding.tables.health_task.table-strategy.inline.algorithm-expression=health_task$->{record_id % 3}
spring.shardingsphere.sharding.tables.health_task.key-generator.column=task_id
spring.shardingsphere.sharding.tables.health_task.key-generator.type=SNOWFLAKE
spring.shardingsphere.sharding.tables.health_task.key-generator.props.worker.id=33
这样,整合分库+分表的配置方案就介绍完毕了,可以看到,这里并没有引入任何新的配置项让我们重新执行单元测试,从而确认数据是否已经正确地进行了分库分表。这是 ds3 中的 health_record0、health_record1 和 health_record2 表:
ds3 中的 health_record0 表数据
ds3 中的 health_record1 表数据
ds3 中的 health_record2 表数据
这是 ds4 中的 health_record0、health_record1 和 health_record2 表:
ds4 中的 health_record0 表数据
ds4 中的 health_record1 表数据
ds4 中的 health_record2 表数据
而下面是 ds5 中的 health_record0、health_record1 和 health_record2 表:
ds5 中的 health_record0 表数据
ds5 中的 health_record1 表数据
ds5 中的 health_record2 表数据
对于 health_task 表而言,我们得到的也是类似的分库分表效果。
系统改造:如何实现强制路由?
从 SQL 执行效果而言,分库分表可以看作是一种路由机制,也就是说把 SQL 语句路由到目标数据库或数据表中并获取数据。在实现了分库分表的基础之上,我们将要引入一种不同的路由方法,即强制路由。
什么是强制路由?
强制路由与一般的分库分表路由不同,它并没有使用任何的分片键和分片策略。我们知道通过解析 SQL 语句提取分片键,并设置分片策略进行分片是 ShardingSphere 对重写 JDBC 规范的实现方式。但是,如果我们没有分片键,是否就只能访问所有的数据库和数据表进行全路由呢?显然,这种处理方式也不大合适。有时候,我们需要为 SQL 执行开一个“后门”,允许在没有分片键的情况下,同样可以在外部设置目标数据库和表,这就是强制路由的设计理念。
在 ShardingSphere 中,通过 Hint 机制实现强制路由。我们在这里对 Hint 这一概念再做进一步的阐述。在关系型数据库中Hint 作为一种 SQL 补充语法扮演着非常重要的角色。它允许用户通过相关的语法影响 SQL 的执行方式,改变 SQL 的执行计划,从而对 SQL 进行特殊的优化。很多数据库工具也提供了特殊的 Hint 语法。以 MySQL 为例,比较典型的 Hint 使用方式之一就是对所有索引的强制执行和忽略机制。
MySQL 中的强制索引能够确保所需要执行的 SQL 语句只作用于所指定的索引上,我们可以通过 FORCE INDEX 这一 Hint 语法实现这一目标:
SELECT * FROM TABLE1 FORCE INDEX (FIELD1)
类似的IGNORE INDEX 这一 Hint 语法使得原本设置在具体字段上的索引不被使用:
SELECT * FROM TABLE1 IGNORE INDEX (FIELD1, FIELD2)
对于分片字段非 SQL 决定、而由其他外置条件决定的场景,可使用 SQL Hint 灵活地注入分片字段。
如何设计和开发强制路由?
基于 Hint 进行强制路由的设计和开发过程需要遵循一定的约定同时ShardingSphere 也提供了专门的 HintManager 来简化强制路由的开发过程。
HintManager
HintManager 类的使用方式比较固化,我们可以通过查看源码中的类定义以及核心变量来理解它所包含的操作内容:
public final class HintManager implements AutoCloseable {
//基于ThreadLocal存储HintManager实例
private static final ThreadLocal<HintManager> HINT_MANAGER_HOLDER = new ThreadLocal<>();
//数据库分片值
private final Multimap<String, Comparable<?>> databaseShardingValues = HashMultimap.create();
//数据表分片值
private final Multimap<String, Comparable<?>> tableShardingValues = HashMultimap.create();
//是否只有数据库分片
private boolean databaseShardingOnly;
//是否只路由主库
private boolean masterRouteOnly;
}
在变量定义上,我们注意到 HintManager 使用了 ThreadLocal 来保存 HintManager 实例。显然,基于这种处理方式,所有分片信息的作用范围就是当前线程。我们也看到了用于分别存储数据库分片值和数据表分片值的两个 Multimap 对象以及分别用于指定是否只有数据库分片以及是否只路由主库的标志位。可以想象HintManager 基于这些变量开放了一组 get/set 方法供开发人员根据具体业务场景进行分片键的设置。
同时,在类的定义上,我们也注意到 HintManager 实现了 AutoCloseable 接口,这个接口是在 JDK7 中引入的一个新接口用于自动释放资源。AutoCloseable 接口只有一个 close 方法,我们可以实现这个方法来释放自定义的各种资源。
public interface AutoCloseable {
void close() throws Exception;
}
在 JDK1.7 之前,我们需要手动通过 try/catch/finally 中的 finally 语句来释放资源,而使用 AutoCloseable 接口,在 try 语句结束的时候,不需要实现 finally 语句就会自动将这些资源关闭JDK 会通过回调的方式,调用 close 方法来做到这一点。这种机制被称为 try with resource。AutoCloseable 还提供了语法糖,在 try 语句中可以同时使用多个实现这个接口的资源,并通过使用分号进行分隔。
HintManager 中通过实现 AutoCloseable 接口支持资源的自动释放事实上JDBC 中的 Connection 和 Statement 接口的实现类同样也实现了这个 AutoCloseable 接口。
对于 HintManager 而言,所谓的资源实际上就是 ThreadLocal 中所保存的 HintManager 实例。下面这段代码实现了 AutoCloseable 接口的 close 方法,进行资源的释放:
public static void clear() {
HINT_MANAGER_HOLDER.remove();
}
@Override
public void close() {
HintManager.clear();
}
HintManager 的创建过程使用了典型的单例设计模式,下面这段代码展现了通过一个静态的 getInstance 方法,从 ThreadLocal 中获取或设置针对当前线程的 HintManager 实例。
public static HintManager getInstance() {
Preconditions.checkState(null == HINT_MANAGER_HOLDER.get(), "Hint has previous value, please clear first.");
HintManager result = new HintManager();
HINT_MANAGER_HOLDER.set(result);
return result;
}
在理解了 HintManager 的基本结构之后,在应用程序中获取 HintManager 的过程就显得非常简单了,这里给出推荐的使用方式:
try (HintManager hintManager = HintManager.getInstance();
Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
}
可以看到,我们在 try 语句中获取了 HintManager、Connection 和 Statement 实例,然后就可以基于这些实例来完成具体的 SQL 执行。
实现并配置强制路由分片算法
开发基于 Hint 的强制路由的基础还是配置。在介绍与 Hint 相关的配置项之前,让我们回想在 05 课时“ShardingSphere 中的配置体系是如何设计的?”中介绍的 TableRuleConfiguration。我们知道 TableRuleConfiguration 中包含两个 ShardingStrategyConfiguration分别用于设置分库策略和分表策略。而 ShardingSphere 专门提供了 HintShardingStrategyConfiguration 用于完成 Hint 的分片策略配置,如下面这段代码所示:
public final class HintShardingStrategyConfiguration implements ShardingStrategyConfiguration {
private final HintShardingAlgorithm shardingAlgorithm;
public HintShardingStrategyConfiguration(final HintShardingAlgorithm shardingAlgorithm) {
Preconditions.checkNotNull(shardingAlgorithm, "ShardingAlgorithm is required.");
this.shardingAlgorithm = shardingAlgorithm;
}
}
可以看到HintShardingStrategyConfiguration 中需要设置一个 HintShardingAlgorithm。HintShardingAlgorithm 是一个接口,我们需要提供它的实现类来根据 Hint 信息执行分片。
public interface HintShardingAlgorithm<T extends Comparable<?>> extends ShardingAlgorithm {
//根据Hint信息执行分片
Collection<String> doSharding(Collection<String> availableTargetNames, HintShardingValue<T> shardingValue);
}
在 ShardingSphere 中内置了一个 HintShardingAlgorithm 的实现类 DefaultHintShardingAlgorithm但这个实现类并没有执行任何的分片逻辑只是将传入的所有 availableTargetNames 直接进行返回而已,如下面这段代码所示:
public final class DefaultHintShardingAlgorithm implements HintShardingAlgorithm<Integer> {
@Override
public Collection<String> doSharding(final Collection<String> availableTargetNames, final HintShardingValue<Integer> shardingValue) {
return availableTargetNames;
}
}
我们可以根据需要提供自己的 HintShardingAlgorithm 实现类并集成到 HintShardingStrategyConfiguration 中。例如,我们可以对比所有可用的分库分表键值,然后与传入的强制分片键进行精准匹配,从而确定目标的库表信息:
public final class MatchHintShardingAlgorithm implements HintShardingAlgorithm<Long> {
@Override
public Collection<String> doSharding(final Collection<String> availableTargetNames, final HintShardingValue<Long> shardingValue) {
Collection<String> result = new ArrayList<>();
for (String each : availableTargetNames) {
for (Long value : shardingValue.getValues()) {
if (each.endsWith(String.valueOf(value))) {
result.add(each);
}
}
}
return result;
}
}
一旦提供了自定的 HintShardingAlgorithm 实现类,就需要将它添加到配置体系中。在这里,我们基于 Yaml 配置风格来完成这一操作:
defaultDatabaseStrategy:
hint:
algorithmClassName: com.tianyilan.shardingsphere.demo.hint.MatchHintShardingAlgorithm
ShardingSphere 在进行路由时,如果发现 TableRuleConfiguration 中设置了 Hint 的分片算法,就会从 HintManager 中获取分片值并进行路由操作。
如何基于强制路由访问目标库表?
在理解了强制路由的概念和开发过程之后,让我们回到案例。这里以针对数据库的强制路由为例,给出具体的实现过程。为了更好地组织代码结构,我们先来构建两个 Helper 类,一个是用于获取 DataSource 的 DataSourceHelper。在这个 Helper 类中,我们通过加载 .yaml 配置文件来完成 DataSource 的构建:
public class DataSourceHelper {
static DataSource getDataSourceForShardingDatabases() throws IOException, SQLException {
return YamlShardingDataSourceFactory.createDataSource(getFile("/META-INF/hint-databases.yaml"));
}
private static File getFile(final String configFile) {
return new File(Thread.currentThread().getClass().getResource(configFile).getFile());
}
}
这里用到了 YamlShardingDataSourceFactory 工厂类,针对 Yaml 配置的实现方案你可以回顾 05 课时中的内容。
另一个 Helper 类是包装 HintManager 的 HintManagerHelper。在这个帮助类中我们通过使用 HintManager 开放的 setDatabaseShardingValue 来完成数据库分片值的设置。在这个示例中我们只想从第一个库中获取目标数据。HintManager 还提供了 addDatabaseShardingValue 和 addTableShardingValue 等方法设置强制路由的分片值。
public class HintManagerHelper {
static void initializeHintManagerForShardingDatabases(final HintManager hintManager) {
hintManager.setDatabaseShardingValue(1L);
}
}
最后,我们构建一个 HintService 来完成整个强制路由流程的封装:
public class HintService {
private static void processWithHintValueForShardingDatabases() throws SQLException, IOException {
DataSource dataSource = DataSourceHelper.getDataSourceForShardingDatabases();
try (HintManager hintManager = HintManager.getInstance();
Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
HintManagerHelper.initializeHintManagerForShardingDatabases(hintManager);
ResultSet result = statement.executeQuery("select * from health_record");
while (result.next()) {
System.out.println(result.getInt(0) + result.getString(1));
}
}
}
}
可以看到,在这个 processWithHintValueForShardingDatabases 方法中,我们首先通过 DataSourceHelper 获取目标 DataSource。然后使用 try with resource 机制在 try 语句中获取了 HintManager、Connection 和 Statement 实例,并通过 HintManagerHelper 帮助类设置强制路由的分片值。最后,通过 Statement 来执行一个全表查询,并打印查询结果:
2020-05-25 21:58:13.932 INFO 20024 --- [ main] ShardingSphere-SQL : Logic SQL: select user_id, user_name from user
2020-05-25 21:58:13.932 INFO 20024 --- [ main] ShardingSphere-SQL : Actual SQL: ds1 ::: select user_id, user_name from user
6: user_6
7: user_7
8: user_8
9: user_9
10: user_10
我们获取执行过程中的日志信息,可以看到原始的逻辑 SQL 是 select user_id, user_name from user而真正执行的真实 SQL 则是 ds1 ::: select user_id, user_name from user。显然强制路由发生了效果我们获取的只是 ds1 中的所有 User 信息。
小结
承接上一课时的内容,今天我们继续在对单库单表架构进行分库操作的基础上,讲解如何实现分表、分库+分表以及强制路由的具体细节。有了分库的实践经验,要完成分表以及分库分表是比较容易的,所做的工作只是调整和设置对应的配置项。而强制路由是一种新的路由机制,我们通过较大的篇幅来对它的概念和实现方法进行了展开,并结合业务场景给出了案例分析。
这里给你留一道思考题ShardingSphere 如何基于 Hint 机制实现分库分表场景下的强制路由?
从路由的角度讲,基于数据库主从架构的读写分离机制也可以被认为是一种路由。在下一课时的内容中,我们将对 ShardingSphere 提供的读写分离机制进行讲解,并同样给出读写分离与分库分表、强制路由进行整合的具体方法。

View File

@ -0,0 +1,189 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 读写分离:如何集成分库分表+数据库主从架构?
为了应对高并发场景下的数据库访问需求,读写分离架构是现代数据库架构的一个重要组成部分。今天,我就和你一起来学习 ShardingSphere 中所提供的读写分离机制,以及这一机制如何与前面介绍的分库分表和强制路由整合在一起使用。
ShardingSphere 中的读写分离
为了应对数据库读写分离ShardingSphere 所提供的解决方案还是依赖于强大的配置体系。为了更好地理解这些读写分离相关的配置,我们有必要对读写分离与主从架构有一定的了解。
读写分离与主从架构
目前,大部分的主流关系型数据库都提供了主从架构的实现方案,通过配置两台或多台数据库的主从关系,可以将一台数据库服务器的数据更新自动同步到另一台服务器上。而应用程序可以利用数据库的这一功能,实现数据的读写分离,从而改善数据库的负载压力。
可以看到,所谓的读写分离,实际上就是将写操作路由到主数据库,而将读操作路由到从数据库。对于互联网应用而言,读取数据的需求远远大于写入数据的需求,所以从数据库一般都是多台。当然,对于复杂度较高的系统架构而言,主库的数量同样也可以是多台。
读写分离与 ShardingSphere
就 ShardingSphere 而言,支持主从架构下的读写分离是一项核心功能。目前 ShardingSphere 支持单主库、多从库的主从架构来完成分片环境下的读写分离,暂时不支持多主库的应用场景。
在数据库主从架构中,因为从库一般会有多台,所以当执行一条面向从库的 SQL 语句时我们需要实现一套负载均衡机制来完成对目标从库的路由。ShardingSphere 默认提供了随机Random和轮询RoundRobin这两种负载均衡算法来完成这一目标。
另一方面由于主库和从库之间存在一定的同步时延和数据不一致情况所以在有些场景下我们可能更希望从主库中获取最新数据。ShardingSphere 同样考虑到了这方面需求,开发人员可以通过 Hint 机制来实现对主库的强制路由。
配置读写分离
实现读写分离要做的还是配置工作。通过配置,我们的目标是获取支持读写分离的 MasterSlaveDataSource而 MasterSlaveDataSource 的创建依赖于 MasterSlaveDataSourceFactory 工厂类:
public final class MasterSlaveDataSourceFactory {
public static DataSource createDataSource(final Map<String, DataSource> dataSourceMap, final MasterSlaveRuleConfiguration masterSlaveRuleConfig, final Properties props) throws SQLException {
return new MasterSlaveDataSource(dataSourceMap, new MasterSlaveRule(masterSlaveRuleConfig), props);
}
}
在上面这段代码中,我们可以看到 createDataSource 方法中传入了三个参数,除了熟悉的 dataSourceMap 和 props 之外,还有一个 MasterSlaveRuleConfiguration而这个 MasterSlaveRuleConfiguration 包含了所有我们需要配置的读写分离信息:
public class MasterSlaveRuleConfiguration implements RuleConfiguration {
//读写分离数据源名称
private final String name;
//主库数据源名称
private final String masterDataSourceName;
//从库数据源名称列表
private final List<String> slaveDataSourceNames;
//从库负载均衡算法
private final LoadBalanceStrategyConfiguration loadBalanceStrategyConfiguration;
}
从 MasterSlaveRuleConfiguration 类所定义的变量中不难看出,我们需要配置读写分离数据源名称、主库数据源名称、从库数据源名称列表以及从库负载均衡算法这四个配置项,仅此而已。
系统改造:如何实现读写分离?
在掌握了读写分离的基本概念以及相关配置项之后,我们回到案例,看如何在单库单表架构中引入读写分离机制。
第一步,仍然是设置用于实现读写分离的数据源。为了演示一主多从架构,我们初始化了一个主数据源 dsmaster 以及两个从数据源 dsslave0 和 dsslave1
spring.shardingsphere.datasource.names=dsmaster,dsslave0,dsslave1
spring.shardingsphere.datasource.dsmaster.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.dsmaster.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.dsmaster.url=jdbc:mysql://localhost:3306/dsmaster
spring.shardingsphere.datasource.dsmaster.username=root
spring.shardingsphere.datasource.dsmaster.password=root
spring.shardingsphere.datasource.dsslave0.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.dsslave0.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.dsslave0.url=jdbc:mysql://localhost:3306/dsslave0
spring.shardingsphere.datasource.dsslave0.username=root
spring.shardingsphere.datasource.dsslave0.password=root
spring.shardingsphere.datasource.dsslave1.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.dsslave1.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.dsslave1.url=jdbc:mysql://localhost:3306/dsslave1?serverTimezone=UTC&useSSL=false&useUnicode=true&characterEncoding=UTF-8
spring.shardingsphere.datasource.dsslave1.username=root
spring.shardingsphere.datasource.dsslave1.password=root
有了数据源之后,我们需要设置 MasterSlaveRuleConfiguration 类中所指定的 4 个配置项,这里负载均衡算法设置的是 random也就是使用的随机算法
spring.shardingsphere.masterslave.name=health_ms
spring.shardingsphere.masterslave.master-data-source-name=dsmaster
spring.shardingsphere.masterslave.slave-data-source-names=dsslave0,dsslave1
spring.shardingsphere.masterslave.load-balance-algorithm-type=random
现在我们来插入 User 对象从控制台的日志中可以看到ShardingSphere 执行的路由类型是 master-slave ,而具体 SQL 的执行是发生在 dsmaster 主库中:
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : Rule Type: master-slave
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : SQL: INSERT INTO user (user_id, user_name) VALUES (?, ?) ::: DataSources: dsmaster
Insert User:1
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : Rule Type: master-slave
2020-05-25 19:58:08.721 INFO 4392 --- [ main] ShardingSphere-SQL : SQL: INSERT INTO user (user_id, user_name) VALUES (?, ?) ::: DataSources: dsmaster
Insert User:2
然后,我们再对 User 对象执行查询操作并获取 SQL 执行日志:
2020-05-25 20:00:33.066 INFO 3364 --- [main] ShardingSphere-SQL : Rule Type: master-slave
2020-05-25 20:00:33.066 INFO 3364 --- [main] ShardingSphere-SQL : SQL : SELECT * FROM user; ::: DataSources: dsslave0
可以看到,这里用到的 DataSource 是 dsslave0也就是说查询操作发生在 dsslave0 从库中。由于设置的是随机负载均衡策略,当我们多次执行查询操作时,目标 DataSource 会在 dsslave0 和 dsslave1 之间交替出现。
系统改造:如何实现读写分离+分库分表?
我们同样可以在分库分表的基础上添加读写分离功能。这时候,我们需要设置两个主数据源 dsmaster0 和 dsmaster1然后针对每个主数据源分别设置两个从数据源
spring.shardingsphere.datasource.names=dsmaster0,dsmaster1,dsmaster0-slave0,dsmaster0-slave1,dsmaster1-slave0,dsmaster1-slave1
这时候的库分片策略 default-database-strategy 同样分别指向 dsmaster0 和 dsmaster1 这两个主数据源:
spring.shardingsphere.sharding.default-database-strategy.inline.sharding-column=user_id
spring.shardingsphere.sharding.default-database-strategy.inline.algorithm-expression=dsmaster$->{user_id % 2}
而对于表分片策略而言,我们还是使用在 07 课时中介绍的分片方式进行设置:
spring.shardingsphere.sharding.tables.health_record.actual-data-nodes=dsmaster$->{0..1}.health_record$->{0..1}
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.sharding-column=record_id
spring.shardingsphere.sharding.tables.health_record.table-strategy.inline.algorithm-expression=health_record$->{record_id % 2}
完成这些设置之后,同样需要设置两个主数据源对应的配置项:
spring.shardingsphere.sharding.master-slave-rules.dsmaster0.master-data-source-name=dsmaster0
spring.shardingsphere.sharding.master-slave-rules.dsmaster0.slave-data-source-names=dsmaster0-slave0, dsmaster0-slave1
spring.shardingsphere.sharding.master-slave-rules.dsmaster1.master-data-source-name=dsmaster1
spring.shardingsphere.sharding.master-slave-rules.dsmaster1.slave-data-source-names=dsmaster1-slave0, dsmaster1-slave1
这样我们就在分库分表的基础上添加了对读写分离的支持。ShardingSphere 所提供的强大配置体系使得开发人员可以在原有配置的基础上添加新的配置项,而不需要对原有配置做过多调整。
系统改造:如何实现读写分离下的强制路由?
在上个课时中我们介绍了强制路由,在这个基础上,我将给出如何基于 Hint完成读写分离场景下的主库强制路由方案。
要想实现主库强制路由,我们还是要使用 HintManager。HintManager 专门提供了一个 setMasterRouteOnly 方法,用于将 SQL 强制路由到主库中。我们把这个方法也封装在 HintManagerHelper 帮助类中:
public class HintManagerHelper {
static void initializeHintManagerForMaster(final HintManager hintManager) {
hintManager.setMasterRouteOnly();
}
}
现在,我们在业务代码中加入主库强制路由的功能,下面这段代码演示了这个过程:
@Override
public void processWithHintValueMaster() throws SQLException, IOException {
DataSource dataSource = DataSourceHelper.getDataSourceForMaster();
try (HintManager hintManager = HintManager.getInstance();
Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
HintManagerHelper.initializeHintManagerForMaster(hintManager);
ResultSet result = statement.executeQuery("select user_id, user_name from user");
while (result.next()) {
System.out.println(result.getLong(1) + ": " + result.getString(2));
}
}
}
执行这段代码,可以在控制台日志中获取执行的结果:
2020-05-25 22:06:17.166 INFO 16680 --- [ main] ShardingSphere-SQL : Rule Type: master-slave
2020-05-25 22:06:17.166 INFO 16680 --- [ main] ShardingSphere-SQL : SQL: select user_id, user_name from user ::: DataSources: dsmaster
1: user_1
2: user_2
显然,这里的路由类型是 master-slave而执行 SQL 的 DataSource 只有 dsmaster也就是说我们完成了针对主库的强制路由。
小结
继续承接上一课时的内容,今天我们讲解 ShardingSphere 中的读写分离机制。在日常开发过程中读写分离是应对高并发数据访问的一种有效技术手段。而在ShardingSphere中读写分离既可以单独使用也可以和分库组合在一起使用。ShardingSphere的另一个强大之处还在于提供了针对主库的强制路由机制这在需要确保获取主库最新数据的场景下非常有用。
这里给你留一道思考题:如果我们想要在主从架构中只访问主库中的数据,在 ShardingSphere 中有什么方法可以做到这一点?

View File

@ -0,0 +1,395 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 系统集成:如何完成 ShardingSphere 内核与 Spring+SpringBoot 的无缝整合?
今天,我们将进入整个课程中最后一个模块——系统集成模块的介绍。这里所谓的系统集成,指的就是 ShardingSphere 和 Spring 框架的集成。
到目前为止ShardingSphere 实现了两种系统集成机制一种是命名空间namespace机制即通过扩展 Spring Schema 来实现与 Spring 框架的集成;而另一种则是通过编写自定义的 starter 组件来完成与 Spring Boot 的集成。本课时我将分别讲解这两种系统集成机制。
基于系统集成模块,无论开发人员采用哪一种 Spring 框架,对于使用 ShardingSphere 而言都是零学习成本。
基于命名空间集成 Spring
从扩展性的角度讲,基于 XML Schema 的扩展机制也是非常常见和实用的一种方法。在 Spring 中,允许我们自己定义 XML 的结构,并且可以用自己的 Bean 解析器进行解析。通过对 Spring Schema 的扩展ShardingSphere 可以完成与 Spring 框架的有效集成。
1.基于命名空间集成 Spring 的通用开发流程
基于命名空间机制实现与 Spring 的整合,开发上通常采用的是固定的一个流程,包括如下所示的五大步骤:
这些步骤包括:编写业务对象、编写 XSD 文件、编写 BeanDefinitionParser 实现类、编写 NamespaceHandler 实现类,以及编写 spring.handlers 和 spring.schemas 配置文件,我们来看看 ShardingSphere 中实现这些步骤的具体做法。
2.ShardingSphere 集成 Spring
ShardingSphere 中存在两个以“spring-namespace”结尾的代码工程即 sharding-jdbc-spring-namespace 和 sharding-jdbc-orchestration-spring-namespace显然后者关注的是编排治理相关功能的集成相对比较简单。再因为命名空间机制的实现过程也基本一致因此我们以 sharding-jdbc-spring-namespace 工程为例展开讨论。
而在 sharding-jdbc-spring-namespace 工程中,又包含了对普通分片、读写分离和数据脱敏这三块核心功能的集成内容,它们的实现也都是采用了类似的方式,因此我们也不会重复进行说明,这里就以普通分片为例进行介绍。
首先,我们发现了一个专门用于与 Spring 进行集成的 SpringShardingDataSource 类,这个类就是业务对象类,如下所示:
public class SpringShardingDataSource extends ShardingDataSource {
public SpringShardingDataSource(final Map<String, DataSource> dataSourceMap, final ShardingRuleConfiguration shardingRuleConfiguration, final Properties props) throws SQLException {
super(dataSourceMap, new ShardingRule(shardingRuleConfiguration, dataSourceMap.keySet()), props);
}
}
可以看到这个 SpringShardingDataSource 类实际上只是对 ShardingDataSource 的一种简单封装,没有包含任何实际操作。
然后我们来看配置项标签的定义类这种类是一种简单的工具类其作用就是定义标签的名称。在命名上ShardingSphere 中的这些类都以“BeanDefinitionParserTag”结尾例如如下所示的 ShardingDataSourceBeanDefinitionParserTag
public final class ShardingDataSourceBeanDefinitionParserTag {
public static final String ROOT_TAG = "data-source";
public static final String SHARDING_RULE_CONFIG_TAG = sharding-rule";
public static final String PROPS_TAG = "props";
public static final String DATA_SOURCE_NAMES_TAG = "data-source-names";
public static final String DEFAULT_DATA_SOURCE_NAME_TAG = "default-data-source-name";
public static final String TABLE_RULES_TAG = "table-rules";
}
这里定义了一批 Tag 和一批 Attribute我们不做 一 一 展开。可以对照如下所示的基于 XML 的配置示例来对这些定义的配置项进行理解:
<sharding:data-source id="shardingDataSource">
<sharding:sharding-rule data-source-names="ds0,ds1">
<sharding:table-rules>
<sharding:table-rule />
<sharding:table-rule />
</sharding:table-rules>
</sharding:sharding-rule>
</sharding:data-source>
然后,我们在 sharding-jdbc-spring-namespace 代码工程的 META-INF/namespace 文件夹下找到了对应的 sharding.xsd 文件,其基本结构如下所示:
<xsd:schema xmlns="http://shardingsphere.apache.org/schema/shardingsphere/sharding"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:beans="http://www.springframework.org/schema/beans"
xmlns:encrypt="http://shardingsphere.apache.org/schema/shardingsphere/encrypt"
targetNamespace="http://shardingsphere.apache.org/schema/shardingsphere/sharding"
elementFormDefault="qualified" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://shardingsphere.apache.org/schema/shardingsphere/encrypt http://shardingsphere.apache.org/schema/shardingsphere/encrypt/encrypt.xsd">
<xsd:import namespace="http://www.springframework.org/schema/beans" schemaLocation="http://www.springframework.org/schema/beans/spring-beans.xsd" />
<xsd:import namespace="http://shardingsphere.apache.org/schema/shardingsphere/encrypt" schemaLocation="http://shardingsphere.apache.org/schema/shardingsphere/encrypt/encrypt.xsd"/>
<xsd:element name="data-source">
<xsd:complexType>
<xsd:all>
<xsd:element ref="sharding-rule" />
<xsd:element ref="props" minOccurs="0" />
</xsd:all>
<xsd:attribute name="id" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:schema>
可以看到对于“data-source”这个 element 而言包含了“sharding-rule”和“props”这两个子 element其中“props”不是必需的。同时“data-source”还可以包含一个“id”属性而这个属性则是必填的我们在前面的配置示例中已经看到了这一点。而对于“sharding-rule”而言则可以有很多内嵌的属性sharding.xsd 文件中对这些属性都做了定义。
同时我们应该注意到的是sharding.xsd 中通过使用 xsd:import 标签还引入了两个 namespace一个是 Spring 中的http://www.springframework.org/schema/beans另一个则是 ShardingSphere 自身的http://shardingsphere.apache.org/schema/shardingsphere/encrypt这个命名空间的定义位于与 sharding.xsd 同目录下的 encrypt.xsd文件中。
有了业务对象类,以及 XSD 文件的定义,接下来我们就来看看 NamespaceHandler 实现类 ShardingNamespaceHandler如下所示
public final class ShardingNamespaceHandler extends NamespaceHandlerSupport {
@Override
public void init() {
registerBeanDefinitionParser(ShardingDataSourceBeanDefinitionParserTag.ROOT_TAG, new ShardingDataSourceBeanDefinitionParser());
registerBeanDefinitionParser(ShardingStrategyBeanDefinitionParserTag.STANDARD_STRATEGY_ROOT_TAG, new ShardingStrategyBeanDefinitionParser());
}
}
可以看到这里也是直接使用了 registerBeanDefinitionParser 方法来完成标签项与具体的 BeanDefinitionParser 类之间的对应关系。我们来看这里的 ShardingDataSourceBeanDefinitionParser其核心的 parseInternal 方法如下所示:
@Override
protected AbstractBeanDefinition parseInternal(final Element element, final ParserContext parserContext) {
//构建针对 SpringShardingDataSource 的 BeanDefinitionBuilder
BeanDefinitionBuilder factory = BeanDefinitionBuilder.rootBeanDefinition(SpringShardingDataSource.class);
//解析构造函数中的 DataSource 参数
factory.addConstructorArgValue(parseDataSources(element));
//解析构造函数中 ShardingRuleConfiguration 参数 factory.addConstructorArgValue(parseShardingRuleConfiguration(element));
//解析构造函数中 Properties 参数
factory.addConstructorArgValue(parseProperties(element, parserContext));
factory.setDestroyMethodName("close");
return factory.getBeanDefinition();
}
这里,我们自己定义了一个 BeanDefinitionBuilder 并将其绑定到前面定义的业务对象类 SpringShardingDataSource。然后我们通过三个 addConstructorArgValue 方法的调用,分别为 SpringShardingDataSource 构造函数中所需的 dataSourceMap、shardingRuleConfiguration 以及 props 参数进行赋值。
我们再来进一步看一下上述方法中的 parseDataSources 方法,如下所示:
private Map<String, RuntimeBeanReference> parseDataSources(final Element element) {
Element shardingRuleElement = DomUtils.getChildElementByTagName(element, ShardingDataSourceBeanDefinitionParserTag.SHARDING_RULE_CONFIG_TAG);
List<String> dataSources = Splitter.on(",").trimResults().splitToList(shardingRuleElement.getAttribute(ShardingDataSourceBeanDefinitionParserTag.DATA_SOURCE_NAMES_TAG));
Map<String, RuntimeBeanReference> result = new ManagedMap<>(dataSources.size());
for (String each : dataSources) {
result.put(each, new RuntimeBeanReference(each));
}
return result;
}
基于前面介绍的配置示例我们理解这段代码的作用是获取所配置的“ds0,ds1”字符串并对其进行拆分然后基于每个代表具体 DataSource 的名称构建 RuntimeBeanReference 对象并进行返回,这样就可以把在 Spring 容器中定义的其他 Bean 加载到 BeanDefinitionBuilder 中。
关于 ShardingDataSourceBeanDefinitionParser 中其他 parse 方法的使用,大家可以通过阅读对应的代码进行理解,处理方式都是非常类似的,就不再重复展开。
最后,我们需要在 META-INF 目录下提供spring.schemas 文件,如下所示:
http\://shardingsphere.apache.org/schema/shardingsphere/sharding/sharding.xsd=META-INF/namespace/sharding.xsd
http\://shardingsphere.apache.org/schema/shardingsphere/masterslave/master-slave.xsd=META-INF/namespace/master-slave.xsd
http\://shardingsphere.apache.org/schema/shardingsphere/encrypt/encrypt.xsd=META-INF/namespace/encrypt.xsd
同样spring.handlers 的内容如下所示:
http\://shardingsphere.apache.org/schema/shardingsphere/sharding=org.apache.shardingsphere.shardingjdbc.spring.namespace.handler.ShardingNamespaceHandler
http\://shardingsphere.apache.org/schema/shardingsphere/masterslave=org.apache.shardingsphere.shardingjdbc.spring.namespace.handler.MasterSlaveNamespaceHandler
http\://shardingsphere.apache.org/schema/shardingsphere/encrypt=org.apache.shardingsphere.shardingjdbc.spring.namespace.handler.EncryptNamespaceHandler
至此,我们对 ShardingSphere 中基于命名空间机制与 Spring 进行系统集成的实现过程介绍完毕。
接下来,我们来看 ShardingSphere 中实现一个自定义 spring-boot-starter 的过程。
基于自定义 starter 集成 Spring Boot
与基于命名空间的实现方式一样ShardingSphere 提供了 sharding-jdbc-spring-boot-starter 和 sharding-jdbc-orchestration-spring-boot-starter 这两个 starter 工程。篇幅关系,我们同样只关注于 sharding-jdbc-spring-boot-starter 工程。
对于 Spring Boot 工程,我们首先来关注 META-INF 文件夹下的 spring.factories 文件。Spring Boot 中提供了一个 SpringFactoriesLoader 类,该类的运行机制类似于 “13 | 微内核架构ShardingSphere如何实现系统的扩展性” 中所介绍的 SPI 机制,只不过以服务接口命名的文件是放在 META-INF/spring.factories 文件夹下,对应的 Key 为 EnableAutoConfiguration。SpringFactoriesLoader 会查找所有 META-INF/spring.factories 目录下的配置文件,并把 Key 为 EnableAutoConfiguration 所对应的配置项通过反射实例化为配置类并加载到容器。在 sharding-jdbc-spring-boot-starter 工程中,该文件内容如下所示:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
org.apache.shardingsphere.shardingjdbc.spring.boot.SpringBootConfiguration
现在这里的 EnableAutoConfiguration 配置项指向了 SpringBootConfiguration 类。也就是说,这个类在 Spring Boot 启动过程中都能够通过 SpringFactoriesLoader 被加载到运行时环境中。
1.SpringBootConfiguration 中的注解
接下来,我们就来到这个 SpringBootConfiguration首先关注于加在该类上的各种注解如下所示
@Configuration
@ComponentScan("org.apache.shardingsphere.spring.boot.converter")
@EnableConfigurationProperties({
SpringBootShardingRuleConfigurationProperties.class,
SpringBootMasterSlaveRuleConfigurationProperties.class, SpringBootEncryptRuleConfigurationProperties.class, SpringBootPropertiesConfigurationProperties.class})
@ConditionalOnProperty(prefix = "spring.shardingsphere", name = "enabled", havingValue = "true", matchIfMissing = true)
@AutoConfigureBefore(DataSourceAutoConfiguration.class)
@RequiredArgsConstructor
public class SpringBootConfiguration implements EnvironmentAware
首先,我们看到了一个 @Configuration 注解。这个注解不是 Spring Boot 引入的新注解,而是属于 Spring 容器管理的内容。该注解表明这个类是一个配置类,可以启动组件扫描,用来将带有 @Bean 注解的实体进行实例化 bean。
然后,我们又看到了一个同样属于 Spring 容器管理范畴的老注解,即 @ComponentScan 注解。@ComponentScan 注解就是扫描基于 @Component 等注解所标注的类所在包下的所有需要注入的类,并把相关 Bean 定义批量加载到IoC容器中。
显然Spring Boot 应用程序中同样需要这个功能。注意到,这里需要进行扫描的包路径位于另一个代码工程 sharding-spring-boot-util 的 org.apache.shardingsphere.spring.boot.converter 包中。
然后,我们看到了一个 @EnableConfigurationProperties 注解,该注解的作用就是使添加了 @ConfigurationProperties 注解的类生效。在 Spring Boot 中,如果一个类只使用了 @ConfigurationProperties 注解,然后该类没有在扫描路径下或者没有使用 @Component 等注解,就会导致无法被扫描为 bean那么就必须在配置类上使用 @EnableConfigurationProperties 注解去指定这个类,才能使 @ConfigurationProperties 生效,并作为一个 bean 添加进 spring 容器中。这里的 @EnableConfigurationProperties 注解包含了四个具体的 ConfigurationProperties。以 SpringBootShardingRuleConfigurationProperties 为例,该类的定义如下所示,可以看到,这里直接继承了 sharding-core-common 代码工程中的 YamlShardingRuleConfiguration
@ConfigurationProperties(prefix = "spring.shardingsphere.sharding")
public class SpringBootShardingRuleConfigurationProperties extends YamlShardingRuleConfiguration {
}
SpringBootConfiguration 上的下一个注解是 @ConditionalOnProperty,该注解的作用在于只有当所提供的属性属于 true 时才会实例化 Bean。
最后一个与自动加载相关的注解是 @AutoConfigureBefore,如果该注解用在类名上,其作用是标识在加载当前类之前需要加载注解中所设置的配置类。基于这一点,我们明确在加载 SpringBootConfiguration 类之前Spring Boot 会先加载 DataSourceAutoConfiguration。这一步的作用与我们后面要看到的创建各种 DataSource 相关。
2.SpringBootConfiguration 中的功能
介绍完这些注解之后,我们来看一下 SpringBootConfiguration 类所提供的功能。
我们知道对于 ShardingSphere 而言,其对外的入口实际上就是各种 DataSource因此 SpringBootConfiguration 中提供了一批创建不同 DataSource 的入口方法,例如如下所示的 shardingDataSource 方法:
@Bean
@Conditional(ShardingRuleCondition.class)
public DataSource shardingDataSource() throws SQLException {
return ShardingDataSourceFactory.createDataSource(dataSourceMap, new ShardingRuleConfigurationYamlSwapper().swap(shardingRule), props.getProps());
}
该方法上添加了两个注解,一个是常见的 @Bean,另一个则是 @Conditional 注解,该注解的作用是只有满足指定条件的情况下才能加载这个 Bean。我们看到 @Conditional 注解中设置了一个 ShardingRuleCondition该类如下所示
public final class ShardingRuleCondition extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(final ConditionContext conditionContext, final AnnotatedTypeMetadata annotatedTypeMetadata) {
boolean isMasterSlaveRule = new MasterSlaveRuleCondition().getMatchOutcome(conditionContext, annotatedTypeMetadata).isMatch();
boolean isEncryptRule = new EncryptRuleCondition().getMatchOutcome(conditionContext, annotatedTypeMetadata).isMatch();
return isMasterSlaveRule || isEncryptRule ? ConditionOutcome.noMatch("Have found master-slave or encrypt rule in environment") : ConditionOutcome.match();
}
}
可以看到 ShardingRuleCondition 是一个标准的 SpringBootCondition实现了 getMatchOutcome 抽象方法。我们知道 SpringBootCondition 的作用就是代表一种用于注册类或加载 Bean 的条件。ShardingRuleCondition 类的实现上分别调用了 MasterSlaveRuleCondition 和 EncryptRuleCondition 来判断是否满足这两个 SpringBootCondition。显然对于 ShardingRuleCondition 而言,只有在两个条件都不满足的情况下才应该被加载。对于 masterSlaveDataSource 和 encryptDataSource 这两个方法而言,处理逻辑也类似,不做赘述。
最后,我们注意到 SpringBootConfiguration 还实现了 Spring 的 EnvironmentAware 接口。在 Spring Boot 中,当一个类实现了 EnvironmentAware 接口并重写了其中的 setEnvironment 方法之后,在代码工程启动时就可以获得 application.properties 配置文件中各个配置项的属性值。SpringBootConfiguration 中所重写的 setEnvironment 方法如下所示:
@Override
public final void setEnvironment(final Environment environment) {
String prefix = "spring.shardingsphere.datasource.";
for (String each : getDataSourceNames(environment, prefix)) {
try {
dataSourceMap.put(each, getDataSource(environment, prefix, each));
} catch (final ReflectiveOperationException ex) {
throw new ShardingException("Can't find datasource type!", ex);
} catch (final NamingException namingEx) {
throw new ShardingException("Can't find JNDI datasource!", namingEx);
}
}
}
这里的代码逻辑是获取“spring.shardingsphere.datasource.name”或“spring.shardingsphere.datasource.names”配置项然后根据该配置项中所指定的 DataSource 信息构建新的 DataSource 并加载到 dataSourceMap 这个 LinkedHashMap。这点我们可以结合课程案例中的配置项来加深理解
spring.shardingsphere.datasource.names=ds0,ds1
spring.shardingsphere.datasource.ds0.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds0.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds0.url=jdbc:mysql://localhost/ds0
spring.shardingsphere.datasource.ds0.username=root
spring.shardingsphere.datasource.ds0.password=root
spring.shardingsphere.datasource.ds1.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds1.driver-class-name=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds1.url=jdbc:mysql://localhost/ds1
spring.shardingsphere.datasource.ds1.username=root
spring.shardingsphere.datasource.ds1.password=root
至此,整个 SpringBootConfiguration 的实现过程介绍完毕。
从源码解析到日常开发
今天所介绍的关于 ShardingSphere 集成 Spring 的实现方法可以直接导入到日常开发过程中。如果我们需要实现一个自定义的框架或工具类,从面向开发人员的角度讲,最好能与 Spring 等主流的开发框架进行集成,以便提供最低的学习和维护成本。与 Spring 框架的集成过程都有固定的开发步骤,我们按照今天课时中所介绍的内容,就可以模仿 ShardingSphere 中的做法自己实现这些步骤。
小结与预告
本课时是 ShardingSphere 源码解析的最后一部分内容,我们围绕如何集成 Spring 框架这一主题对 ShardingSphere 的具体实现方法做了展开。ShardingSphere 在这方面提供了一种可以直接进行参考的模版式的实现方法,包括基于命名空间的 Spring 集成以及基于 starter的Spring Boot 集成方法。
这里给你留一道思考题:在 ShardingSphere 集成 Spring Boot 时SpringBootConfiguration 类上的注解有哪些,分别起到了什么作用?
讲完 ShardingSphere 源码解析部分内容之后,下一课时是整个课程的最后一讲,我们将对 ShardingSphere 进行总结,并对它的后续发展进行展望。

View File

@ -0,0 +1,93 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 互联网产品 + 大数据产品 = 大数据平台
从今天开始,我们进入专栏的“大数据平台与系统集成”模块。
前面我讲了各种大数据技术的原理与架构大数据计算通过将可执行的代码分发到大规模的服务器集群上进行分布式计算以处理大规模的数据即所谓的移动计算比移动数据更划算。但是在分布式系统中分发执行代码并启动执行这样的计算方式必然不会很快即使在一个规模不太大的数据集上进行一次简单计算MapReduce也可能需要几分钟Spark快一点也至少需要数秒的时间。
而互联网产品处理用户请求需要毫秒级的响应也就是说要在1秒内完成计算因此大数据计算必然不能实现这样的响应要求。但是互联网应用又需要使用大数据实现统计分析、数据挖掘、关联推荐、用户画像等一系列功能。
那么如何才能弥补这互联网和大数据系统之间的差异呢?解决方案就是将面向用户的互联网产品和后台的大数据系统整合起来,也就是今天我要讲的构建一个大数据平台。
大数据平台,顾名思义就是整合网站应用和大数据系统之间的差异,将应用程序产生的数据导入到大数据系统,经过处理计算后再导出给应用程序使用。
下图是一个典型的互联网大数据平台的架构。
在这张架构图中,大数据平台里面向用户的在线业务处理组件用褐色标示出来,这部分是属于互联网在线应用的部分,其他蓝色的部分属于大数据相关组件,使用开源大数据产品或者自己开发相关大数据组件。
你可以看到,大数据平台由上到下,可分为三个部分:数据采集、数据处理、数据输出与展示。
数据采集
将应用程序产生的数据和日志等同步到大数据系统中由于数据源不同这里的数据同步系统实际上是多个相关系统的组合。数据库同步通常用Sqoop日志同步可以选择Flume打点采集的数据经过格式化转换后通过Kafka等消息队列进行传递。
不同的数据源产生的数据质量可能差别很大,数据库中的数据也许可以直接导入大数据系统就可以使用了,而日志和爬虫产生的数据就需要进行大量的清洗、转化处理才能有效使用。
数据处理
这部分是大数据存储与计算的核心数据同步系统导入的数据存储在HDFS。MapReduce、Hive、Spark等计算任务读取HDFS上的数据进行计算再将计算结果写入HDFS。
MapReduce、Hive、Spark等进行的计算处理被称作是离线计算HDFS存储的数据被称为离线数据。在大数据系统上进行的离线计算通常针对某一方面的全体数据比如针对历史上所有订单进行商品的关联性挖掘这时候数据规模非常大需要较长的运行时间这类计算就是离线计算。
除了离线计算还有一些场景数据规模也比较大但是要求处理的时间却比较短。比如淘宝要统计每秒产生的订单数以便进行监控和宣传。这种场景被称为大数据流式计算通常用Storm、Spark Steaming等流式大数据引擎来完成可以在秒级甚至毫秒级时间内完成计算。
数据输出与展示
前面我说过大数据计算产生的数据还是写入到HDFS中但应用程序不可能到HDFS中读取数据所以必须要将HDFS中的数据导出到数据库中。数据同步导出相对比较容易计算产生的数据都比较规范稍作处理就可以用Sqoop之类的系统导出到数据库。
这时,应用程序就可以直接访问数据库中的数据,实时展示给用户,比如展示给用户关联推荐的商品。淘宝卖家的量子魔方之类的产品,其数据都来自大数据计算产生。
除了给用户访问提供数据,大数据还需要给运营和决策层提供各种统计报告,这些数据也写入数据库,被相应的后台系统访问。很多运营和管理人员,每天一上班,就是登录后台数据系统,查看前一天的数据报表,看业务是否正常。如果数据正常甚至上升,就可以稍微轻松一点;如果数据下跌,焦躁而忙碌的一天马上就要开始了。
将上面三个部分整合起来的是任务调度管理系统不同的数据何时开始同步各种MapReduce、Spark任务如何合理调度才能使资源利用最合理、等待的时间又不至于太久同时临时的重要任务还能够尽快执行这些都需要任务调度管理系统来完成。
有时候,对分析师和工程师开放的作业提交、进度跟踪、数据查看等功能也集成在这个任务调度管理系统中。
简单的大数据平台任务调度管理系统其实就是一个类似Crontab的定时任务系统按预设时间启动不同的大数据作业脚本。复杂的大数据平台任务调度还要考虑不同作业之间的依赖关系根据依赖关系的DAG图进行作业调度形成一种类似工作流的调度方式。
对于每个公司的大数据团队最核心开发、维护的也就是这个系统大数据平台上的其他系统一般都有成熟的开源软件可以选择但是作业调度管理会涉及很多个性化的需求通常需要团队自己开发。开源的大数据调度系统有Oozie也可以在此基础进行扩展。
上面我讲的这种大数据平台架构也叫Lambda架构是构建大数据平台的一种常规架构原型方案。Lambda架构原型请看下面的图。
1.数据new data同时写入到批处理大数据层batch layer和流处理大数据层speed layer
2.批处理大数据层是数据主要存储与计算的地方,所有的数据最终都会存储到批处理大数据层,并在这里被定期计算处理。
3.批处理大数据层的计算结果输出到服务层serving layer供应用使用者查询访问。
4.由于批处理的计算速度比较慢数据只能被定期处理计算比如每天因此延迟也比较长只能查询到截止前一天的数据即数据输出需要T+1。所以对于实时性要求比较高的查询会交给流处理大数据层speed layer在这里进行即时计算快速得到结果。
5.流处理计算速度快,但是得到的只是最近一段时间的数据计算结果(比如当天的);批处理会有延迟,但是有全部的数据计算结果。所以查询访问会将批处理计算的结果和流处理计算的结果合并起来,作为最终的数据视图呈现。
小结
我们看下一个典型的互联网企业的数据流转。用户通过App等互联网产品使用企业提供的服务这些请求实时不停地产生数据由系统进行实时在线计算并把结果数据实时返回用户这个过程被称作在线业务处理涉及的数据主要是用户自己一次请求产生和计算得到的数据。单个用户产生的数据规模非常小通常内存中一个线程上下文就可以处理。但是大量用户并发同时请求系统对系统而言产生的数据量就非常可观了比如天猫“双十一”开始的时候一分钟就有数千万用户同时访问天猫的系统。
在线数据完成和用户的交互后,会以数据库或日志的方式存储在系统的后端存储设备中,大量的用户日积月累产生的数据量非常庞大,同时这些数据中蕴藏着大量有价值的信息需要计算。但是我们没有办法直接在数据库以及磁盘日志中对这些数据进行计算,前面我们也一再讨论过大规模数据计算的挑战,所以需要将这些数据同步到大数据存储和计算系统中进行处理。
但是这些数据并不会立即被数据同步系统导入到大数据系统而是需要隔一段时间再同步通常是隔天比如每天零点后开始同步昨天24小时在线产生的数据到大数据平台。因为数据已经距其产生间隔了一段时间所以这些数据被称作离线数据。
离线数据被存储到HDFS进一步由Spark、Hive这些离线大数据处理系统计算后再写入到HDFS中由数据同步系统同步到在线业务的数据库中这样用户请求就可以实时使用这些由大数据平台计算得到的数据了。
离线计算可以处理的数据规模非常庞大,可以对全量历史数据进行计算,但是对于一些重要的数据,需要实时就能够进行查看和计算,而不是等一天,所以又会用到大数据流式计算,对于当天的数据实时进行计算,这样全量历史数据和实时数据就都被处理了。
我的专栏前面三个模块都是关于大数据产品的,但是在绝大多数情况下,我们都不需要自己开发大数据产品,我们仅仅需要用好这些大数据产品,也就是如何将大数据产品应用到自己的企业中,将大数据产品和企业当前的系统集成起来。
大数据平台听起来高大上,事实上它充当的是一个粘合剂的作用,将互联网线上产生的数据和大数据产品打通,它的主要组成就是数据导入、作业调度、数据导出三个部分,因此开发一个大数据平台的技术难度并不高。前面也有同学提问说,怎样可以转型做大数据相关业务,我觉得转型去做大数据平台开发也许是一个不错的机会。
思考题
如果你所在的公司安排你去领导开发公司的大数据平台,你该如何开展工作?建议从资源申请、团队组织、跨部门协调、架构设计、开发进度、推广实施多个维度思考。
欢迎你点击“请朋友读”,把今天的文章分享给好友。也欢迎你写下自己的思考或疑问,与我和其他同学一起讨论。

View File

@ -0,0 +1,141 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
Q&A加餐丨关于代码质量你关心的那些事儿
专栏上线后有一些同学对于代码质量有关的问题还不是很清楚有很多疑问所以我特意做了一期Q&A来回答一下这些问题。
有没有什么技巧可以不费力地查看源代码?
———————–
这是一个好问题。但遗憾的是,我们费力的程度,主要取决于代码的作者,而不是我们自己。我想了好久,也没有找到不费力气查看源代码的技巧。
通常我自己用的办法,有时候就像剥洋葱,从外朝里看;有时候也像挖井,找到地表的一小块地儿,朝下一直挖,直到我理解了代码的逻辑关系。
如果你刚开始接触我建议你先不要看代码先去看README再去看用户指南。先把软件干什么、怎么用搞清楚。然后再去看开发者指南搞清楚模块之间的关系、功能理解代码中的示例。最后再去看代码。
看代码的时候找一个顺手的IDE。IDE丰富的检索功能可以帮助我们找到一个方法在什么地方定义的有哪些地方使用了。
如果你还不知道要看哪一个源代码,先找一个例子开始。不管这个例子是开发指南里的,还是测试代码里的。先找出一个例子,把它读懂,然后阅读例子中调用的源代码。
比如你要是看到示例代码调用了Collections.unmodifiableList()方法,如果想了解它,就查看它的规范文档或者源代码。从例子开始剥每一个你关心的方法,一层一层地深入下去。
OpenJDK的代码评审很多时候代码量很大。代码评审的时候很多文档还没有出来。我一般是分层看的。先看用户接口设计的这部分代码这一部分的代码量一般比较少。看完用户接口的设计才能明白作者的思路和目标。这样自己就有了一个思路对代码的方向有了一个大致的了解。然后再看接口实现的代码看看实现和接口是不是吻合的。这个过程中我一般会记录下类和方法之间的依赖关系也会顺着依赖关系来理解代码的逻辑关系。
好的代码,有清晰的分割和层次,逻辑清晰,代码的行文一般也是简单直观,读起来比较容易。不好的代码,阅读起来就费力得多了。
代码质量和工作效率的矛盾如何取舍?
———————
这个问题有一个隐含的假设,就是代码质量和工作效率不可兼得。这本身是个常见的误区。这个误区也给了我们一个看似成立的借口:要么牺牲代码质量,要么牺牲工作效率。
代码质量和工作效率,是不是矛盾的呢?这取决于我们观察的时间、地点以及维度,甚至我们是怎么定义效率的。
如果给我们一个小时的时间,看看谁写的代码多。不顾及代码质量的也许会胜出(只是也许,我们后面再说为什么只是也许);认真设计、认真对待每一行代码的也许会败北(也只是也许)。
短期内代码写得多与否,我们可以把这个比喻成“走得慢,还是走得快”的问题。
如果给我们半年的时间,那些质量差的代码,编写效率也许可以和质量好的代码保持在同一水准,特别是软件还没有见到用户的时候。
如果给我们一年的时间,软件已经见到了用户,那么质量差的代码的编写效率,应该大幅度落后于优质代码了。甚至生产这些代码的团队,都被市场无情淘汰了。
看谁的代码能够长期赢得竞争,我们可以把这个比喻成“到得慢,还是到得快”问题。
为什么会这样呢? 一小时内,什么都不管,什么都不顾,怎么能不多产呢!
可是,不管不顾,并不意味真的可以高枕无忧。需求满足不了就会返工,程序出了问题也会返工,测试通不过还会返工······每一次的返工,都要你重新阅读代码,梳理逻辑,修改代码。
有很多时候,你会发现,这代码真是垃圾,没法改了,只有推倒重来。
这个时候再回过头看看这种代码编写的背景,你能说这是一种高效率的行为吗?
这就相当于一个马拉松比赛前1000米你在前头后来你就要往回跑。1000米这个槛有人跑一次就够了你要是跑七八次还谈什么效率呢。这种绝望的事情看似荒唐其实每天都会发生。
为什么会这样呢? 因为在软件开发的过程中,遗留的问题需要弥补,这就类似于往回跑。所以,走得快,不一定到得快。
你不妨记录一下三个月以来,你的工作时间,看看有多少时间是花在了修修补补上,有多少时间是花在了新的用户需求上。这样,对这个问题可能有不一样的感受。
另外,是不是关注代码质量,就一定走得慢呢?
其实也不是这样的。比如说,如果一个定义清晰,承载功能单一的接口,我们就容易理解,编码思路也清晰,写代码就又快又好。可是,简单直观的接口怎么来?我们需要花费大量的时间,去设计接口,才能获得这样的效果。
为什么有的人一天可以写几千行代码,有的人一天就只能写几十行代码呢?这背后最重要的一个区别就是心里有没有谱,思路是不是清晰。几千行的代码质量就比几十行的差吗? 也不一定。
你有没有遇到这样的例子,一个同学软件已经实现一半了,写了十万行代码。另一个熊孩子还在吭哧吭哧地设计接口,各种画图。当这个同学写了十五万行代码的时候,完成一大半工作的时候,那个熊孩子已经五万行代码搞定了所有的事情。你想想,效率到底该怎么定义呢?
那个熊孩子是不是没有能力写二十万行代码呢?不是的,只要他愿意,他也许可以写得更快。只是,既然目标实现了,为什么不去聊聊天,喝喝咖啡呢?搞这么多代码干啥!你想想,效率能用代码数量定义吗?
就单个的程序员而言,代码质量其实是一个意识和技能的问题。当我们有了相关的意识和技能以后,编写高质量的代码甚至还会节省时间。如果我们没有代码质量的意识,就很难积累相关的技能,编写代码就是一件苦差事,修修补补累死人。
有很多公司不愿意做代码评审,效率也是其中一个重要的考量。大家太注重一小时内的效率,而不太关切一年内的效率。如果我们将目光放得更长远,就会发现很多不一样的东西。
比如说代码评审,就可以减少错误,减少往回跑的频率,从而节省时间。代码评审也可以帮助年轻的程序员快速地成长,降低团队出错的机率,提高团队的效率。
有一些公司,定了编写规范,定了安全规范,定了很多规范,就是执行不下去,为什么呢? 没有人愿意记住那么多生硬的规范,这个时候,代码评审就是一个很好的方法,有很多眼睛看着代码,有反馈,有讨论,有争议,有建议,团队能够更快地形成共识,找出问题,形成习惯,共同进步。看似慢,其实快。
英文里,有一句经典的话 “Run slowly, and you will get there faster”。汉语的说法更简洁“因为慢所以快”。
一般情况下,通常意义上的软件开发,如果我们从产品的角度看,我认为高质量的代码,会提升工作的效率,而不是降低工作效率。
当然,也有特殊情况。比如我们对质量有着偏执般的追求,这时候,效率就不是我们的首选项。也有情况需要我们在五秒以内眨眼之间就给出代码,这时候,质量也不是我们的首选项。
代码的质量该怎么取舍呢?这取决于具体的环境,和你的真实目标。
你加入了Java SE团队经历了从JDK 1.5.0到JDK 12的整个迭代过程这个阶段中Java开发的流程都经历了哪些迭代
———————————————————————-
在十多年间Java开发的流程有很多大大小小的调整。影响最大的我觉得主要有三个。
第一个变化是更加开放了。Java开源以后不仅仅是把代码开放出来开发流程也开放了出来。OpenJDK有详细的开发人员手册告诉大家怎么参与到OpenJDK社区中来。
OpenJDK开放了Java改进的流程这就是JEPJDK Enhancement-Proposal & Roadmap Process。每一个Java的改进从雏形开始一直到改进完成都要经过OpenJDK社区的讨论、评审。什么都要经过OpenJDK讨论这效率不是变慢了吗其实这种开放反而加快了Java的演进。
创新性的想法第一时间就送到用户面前,接受用户的审视。
一个项目是好还是坏做还是不做该怎么做这都在用户可以“挑剔”的范围内。Java的演进也从少数的专家委员会模式变更为小步快走的大集市模式。
OpenJDK也开放了Java代码评审的流程。现在几乎所有的变更都是通过OpenJDK进行的。为什么要变更变更的是什么变更带来的影响有哪些都要描述得清清楚楚。而且任何人都可参与评审都可以发表意见。如果有兼容性的影响用户会在第一时间发现而不是等到系统出了问题再来修补。透明化带来的好处就是有更多的眼睛盯着Java的变更代码的质量会更好潜在的问题也会更少。
第二个变化是研发节奏更快了。Java的版本演进从传统的好几年一个版本变更为半年一个版本。两三年一个版本的演进模式使得Java的任何改进都要两三年以后见。即使这些改进已经成熟了也得在代码库里躺着到不了用户的场景里。没有用户反馈产品的质量也就没有经过真实的检验了没有改进的真实建议。这其实是一种浪费效率会变低。
第三个变化是自动化程度提高了。现在OpenJDK提交的每一个变更都会自动运行很多的测试。如果这个变更引起了测试错误测试系统会给参与者发邮件指明出错的测试以及潜在的怀疑对象。变更提交前我们也可以发出指令运行这些测试。这些自动化的测试可以提高代码的质量减轻工程师的压力提高工作的效率。
您是JDK 11 TLS 1.3项目的leader在这个项目中你对代码安全又是怎么理解的呢
—————————————————-
代码的安全,我一直以为是一个见识问题。一个安全问题,你见识到了,认识到了,你就会在代码里解决掉。没有认识到安全问题,可能想破脑袋,也不知道问题出在哪。
比如说TLS 1.3废弃掉了密码学的很多经典算法包括RSA密钥交换、链式加密算法等。如果去你去查看经典的密码学教材你会发现这些算法都被看做牢不可破的算法全世界的每一粒沙子都变成CPU也破解不了它们。
可是站在2019年再来看这些算法各有各的问题有的破解也就是几分钟的事情。那我们还应该使用这些算法吗当然要想办法升级。可现实是你根本不知道这些算法已经有问题了。当然也想不到去升级使用这些算法的应用程序。这就是我们说的见识。知道了你才能想到去解决。
见识是一个坏东西,需要我们看得多、见得多,才能够拥有。甚至,需要付出代价,比如遭受到黑客攻击。
见识也是一个好东西,见得越多,看得越多,你构筑起来的竞争优势就越明显。随着阅历的增长,见识也会增强,竞争力就提高了。
如果一个东西,每个人三秒就可以掌握,那当然是好的。但同时,它就算不上你的优势了。即使有优势,也只是三秒钟的差距。
另一个常见的问题,就是认为安全的代码牺牲效率。
编写安全的代码,会不会牺牲工作的效率呢?一般情况下,这对效率的影响几乎可以忽略不计。 比如说,一个公开接口,我们不应该信任用户输入的数据,应该检查用户输入数据的边界和有效性。做这样的检查,增加不了多少代码量,反而会让我们的思路变得清晰。再编写具体的业务逻辑的时候,编码的效率就变高了,甚至还会减少代码量。
就拿TLS 1.3来说当废弃掉一些经典的算法时一幅全新的画面就出现在我们面前。TLS协议的设计更加简单更有效效率也会翻倍地提升。
代码质量、工作效率、软件性能、代码安全,这些东西可以作为基准,但是不适用拿来对比。如果非要单纯地从概念上对比,看看有没有冲突,没有一点儿现实意义。安全的代码会牺牲软件性能吗? 不一定。重视代码质量,就会牺牲工作效率吗?也不一定。
今天挑了几个同学的问题来回答。其实关注代码质量这种事情,就像爬山一样,每个人都会,但不是所有人都能爬到最后。会当凌绝顶,一览众山小。当自己在山峰上爬得越来越高的时候,再回过头,你会发现自己和身边的人已经不一样了。
如果你觉得这篇文章对你有所启发,欢迎你点击“请朋友读”,把它分享给你的朋友或者同事。

View File

@ -0,0 +1,173 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 万丈高楼平地起- 物理层 + 数据链路层
物理层
本来是想要一层层的来介绍。但是物理层确实没有太多你需要知道的内容。你可以理解为就是物理连接如果面试问你网线怎么做的话转身就跑这是要让你去干苦力呀。还好TCP/IP模型也为我开了这个后门把这两层放到了一起叫做数据链路层所以我就可以冠冕堂皇的说我是按照TCP/IP来讲解的。
即使没有太多要讲的,还是要讲,存在即合理。说明物理层肯定是做了一些有作用的工作(只是作为工程师的你没有必要知道这些细节)。不然也就没有存在的必要了对不对,我们来试想一下,每一层是不是都有它的作用,物理层也必然如此。它的主要功能是什么呢?有以下几点。
硬件规格的定义:你能随便拿一根线就插在你的电脑上和网络连接吗?电脑和电脑之间连接用什么线呢?这些是不是都需要有规定,你能想到的常见硬件设备有什么?电缆,连接器,无线电收发器,网络接口卡和其他硬件设备的操作详细信息通常是物理层的功能。
编码和信号处理。其实我的本科主要学的就是信号处理(何为处理就是数学计算我那个时候高等数学玩的贼溜甚至选了一门课就是去证明高等数学里面教的这些公式是不是对的。郭德纲说过雅到极致就没饭吃了这个东西你算的再好不是做研究你觉得有必要吗我当年要不就是读博士要不就是读个硕士就工作了别问我为什么不是本科就工作因为我有追求呀其实是本科毕业的我只会算高等数学。其实我读博的话是去研究5G但是我太俗气了想赶紧工作。哈哈有点扯远了我就是为了告诉你们这个东西知道就好了你如果不是博士毕业的话或者是做硬件的信号处理这辈子基本不太会碰到这玩意。
数据收发数据说白了是什么就是一个个的信号。主要作用是传输比特流比特流就是01转化为电流然后到了目的地时候在转换回来但是错了怎么办呢不好意思物理层解决不了它只能传输
拓扑和物理网络设计物理层也被认为是许多与硬件相关的网络设计问题的领域例如LAN和WAN拓扑。
既然是物理层,就会涉及到实体,比如说双绞线呀,铜线呀,光纤呀,肯定不同的设备和材料传输的速度就是不同,但是这个专栏是针对程序员的,不是针对纯网络工程师,在这里就不多加赘述了。
数据链路层
我们上面提到的数据收发,只能传输,那出错了怎么办?谁来处理?正是因为可能出现的传输错误,数据接收者可能需要调节数据到达的速率, 处理同步以及接口交接的问题。所以需要一层来处理比如错误检测,流量控制,错误控制等,而这一层也就是我们要讲的数据链路层。
链路层还包括两小层(逻辑连接控制层(LLC)和媒体访问控制层也就是我们常说的MAC)。
MAC层在下面我们先说。
媒体访问控制MAC子层提供用于访问传输介质的控制。它负责通过共享的传输介质将数据包从一个网络接口卡NIC移动到另一个。物理寻址在MAC子层进行。 MAC也在这一层进行处理。这是指用于为计算机分配网络访问权限并防止它们同时传输从而导致数据冲突的方法。常见的MAC方法包括以太网网络使用的载波侦听多路访问/冲突检测CSMA / CDAppleTalk网络使用的载波侦听多路访问/冲突避免CSMA / CA和令牌环和光纤使用的令牌传递分布式数据接口FDDI网络。这里就不展开讲解了。
然后在向上就是逻辑连接控制层。如IEEE-802 LAN规范所述LLC子层的作用是控制各种应用程序和服务之间的数据流并提供确认和错误通知机制。然后LLC子层可以与许多IEEE 802 MAC子层进行对话。
下面我们来看一下这些功能
Flow Control(流量控制)
Flow Control是为了确保数据流的传输不会使接收方不堪重负。接收方通常会分配一些最大传输长度的数据缓冲区当数据收到了接收方必须要在传给上一层之前对一定的数据进行处理。 我们先来看一下正常的传输和有错误的是什么样子的。
那Flow Control是怎么来保证他的工作的有什么算法来解决这些问题呢
首先第一个叫做 Stop-and-Wait Flow Control(这些简单的英文我就不翻译了).
这是一个最简单的算法。从起点发送一个Frame到终点如果收到了就返回一个Ack。起点会等待收到上一个Frame的ack才会去发送下一个Frame。当然如果这个Frame比较大的话一般会分成多个小Frame。仔细想一下你能想到是什么原因吗
缓冲的大小是有限制的。
越大的文件越容易出错,越小的文件可以越早发现错误,当然也更容易重新传送。
还有一个就是在共用的媒介上比如LAN, 你如果发送一个越大的文件,是不是占用的资源时间就越长,你好意思自己霸占着不放吗,当然会有一个限时,时间太长,就会被踢走。那是不是不管重试多少次都没用。
第二个算法是Sliding-Window Flow Control.
第一种算法一次只能传送一个Frame很明显效率很低是不是一次性多传送几个会快很多呢就好像多线程一样记住知识都是相通的
好像上图所示比如一次性发送4个作为一个window。收到了两个可以ack两个然后起点再发两个始终保持一个窗口有四个Frame在发送中。这个算法我们后面还会用到而且面试中也会考Sliding Window的算法题感兴趣的同学自己试一下
Error- Control(错误控制)
我们当然希望一切都是那么的美好可以按照设定好的来走但是发生错误是再正常不过的事情了。那错误发生了应该怎么办重新发送就好了就好像你点外卖美团小哥给你弄撒了那咋办从做一份然后再送给你不就好了。这种方法叫做ARQ(automatic repeat request) 自动重发。
Stop-and-wait ARQ
这个就是基于Stop-and-wait Flow Control的算法。起点直到收到ack才会发下一个Frame. 当然终点收到Frame的时候会去检测出没出错误。通常会有两种错误。一种是到达终点的Frame已经损坏了。接收方通过某些方法知道出错了别问我怎么知道我就是知道。还是比如你点外卖我收到外卖后不和外卖小哥交流。也不告诉外卖小哥外卖坏了我就是傲娇我直接把外卖扔了。那卖家怎么知道出错了呢。他有一个时间表当到时间了发现还没有买家留评价说收到那就意识到出现问题了卖家会重新发送一份外卖。
那第二种的错误可能是什么仔细想一下。买家收到了外卖也完好无损但是当留评价的时候美团当机了或者买家自己的网断了发不出去评价了。那卖家那边的timer到时间了没有收到评价卖家怎么办它就要再发一份那买家收到第二份之后怎么告诉卖家呢他会留评价叫做ack0和ack1. 当ack1收到的时候是不是就可以发送下一份外卖了。这种情况只有Sliding-window flow control需要自己想一下为什么)。 当然这种算法最大的优点就是简单,缺点是什么,还是没有效率。
Go-Back-N ARQ
这是基于滑动窗口流量控制的最常用的算法。当没有错误的时候接收方会回应RR=receive ready。如果接收方发现Frame里面有错误比如FrameA会回复一个消极的ack。也就是REJ=reject。 接收方会扔掉这个坏掉的Frame以及在那之后所有的Frame直到收到正确的FrameA。所以发送方当他收到一个REJ的时候必须要立即重新发送FrameA。
根据我们第一种算法我们知道会有两种情况一种是Damaged Frame(出错的帧另一种是出错的RR。试想一下这个场景Frame (i - 1) 以及之前的所有Frame都没有问题现在开始传送Frame i了但是发生了错误。甚至这个错误可能导致接收方B 都没有感觉自己收到了这个Frame。
对于Damaged Frame a. 如果没有超时的问题接收方首先收到了Frame i+1发送回REJ给发送方发送方必须要重新发送i以及所有i之后的Frame。
b. 超时发生了那就是B什么都没有收到所以既没有发送RR也没有发送REJ. 那双方僵持的时间长了是不是就会发生超时这时候发送方就像暖男一样发送一个诚意满满的“道歉包”RR (包括玫瑰花也就是P bit 也就是1). B也不是小气的人对吧会回复一个RR 暗示A我们和好了你可以发送Frame i了。当A收到了就会重新发送Frame i了。
对于Damaged RR
a. B 收到了Frame i发送回RR(i + 1), 但是发生了错误比如RR 4的话说明所有的帧直到4都已经收到了A很有可能会收到下一个不同的RR可能是RR 5 可能是RR1,然后根据不同的RR来进行不同的处理 b. 如果A的timer超时了。效仿a2的情况。
对于Damaged REJ, 如果REJ丢了情况和a2相同。
Seletive-Reject ARQ
这种算法中唯一需要重新传的Frame是收到了一个SREJ的回复或者是超时这种算法比上一种要更高效因为减少了需要重传的量。具体可以看一下下面的图。
希望到这里你没有看睡着呀细心的朋友是不是发现我漏了什么我讲了发生了错误怎么办但是却没有讲怎么发现错误对不对哈哈那让我们来看一下错误是怎么被发现的。首先我们要知道什么是错误如果没有一个标准来定义对与错那怎么去找呢。我们知道物理层传输的是比特也就是0和1那么我要传送0但是你收到了1或者相反传送1你收到了0这就是错误。
常见的错误检测方式分为
奇偶校验Parity Check: 偶校验用于同步传输,奇校验用于异步传输
循环冗余校验Cyclic Redundancy check这是最常用也是最有效的方式利用的是XOR也就是异或运算。这里我先卖个关子在第二大章的二进制计算里面会帮你们彻底弄明白这个运算。算法面试也会考到哦
Frame 帧
我们这一章从头到尾都在使用一个名字那就是Frame你肯定会想知道Frame的格式是什么。
记住一点这很重要你试想一下在网络中传输的数据都是0和1那怎么来区分哪个是哪个呢这就是每一个packet或者说Frame或者说IP等等都会有的一个东东那就是header这些header里包含着我是谁我要去哪等等重要的信息。
Flag Filed
好我们来看一下Frame的头。从图上可以看到左右头和尾都有一个Flag的区域这个Flag使用的是01111110来作为唯一的模式。在用户网络接口的两侧接收器不断寻找Flag的序列来进行帧的同步。在接收帧的同时会继续搜寻该序列以确定帧的结尾这个结尾就是上面提到的那个标识01111110。因为该协议允许存在任意位模式你不能保证01111110这个模式不会被用到别的地方也就是说在Information区域可能会出现01111110的信息。这里害怕你迷糊来多说两句这个01111110没有什么特殊的只是选它作为标识你可以选择00111100作为标识或者是二狗子作为标识。这只是一个定义所以你不能限制这个标识不出现在其他的地方。但问题是如果这个01111110出现在别的地方是不是就破坏了帧的同步因为接收方只知道寻找01111110作为头和尾。那这个问题怎么解决呢这里使用的解决方法叫做bit suffing
bit suffing 比特填充的首尾标志法
对于开始标志和结束标志之间的所有位发送器在帧中每出现5个1后插入一个额外的0位。当出现五个1的模式时第六位被检查。如果该位为0则将其删除。如果第六位和第七位均为1则发送方指示中止条件。通过使用位修饰可以将任意位模式插入帧的数据字段。此属性称为数据透明属性。看一下下面这个例子。
Address Filed 地址字段标识
好看完了Flag位下面是Address位。地址字段标识作为已发送或将要接收帧的辅助位。点对点链接不需要此字段但为了统一起见始终包含此字段。地址字段通常为8位长。但可以使用扩展格式如图其中实际地址长度为7位的倍数。每个八位位组的最左位是1或0这取决于它是否是地址字段的最后一个八位位组。每个八位位组的其余7位构成地址的一部分。 1111111的单字节地址被解释为基本格式和扩展格式的全站地址。它用于允许主要节点广播帧以供所有辅助节点接收。
Control Filed 控制位标识
HDLCHigh-level Data Link Control)定义了三种类型的Frame。每一种Frame都有自己独有的控制位标识。
信息帧 Information Frames):携带要为用户传输的数据。另外流和错误控制数据根据arq机制被附加在信息帧上。
管理帧 (Supervisory Frames) : 不使用搭载时提供arq机制。
无编号帧 Unnumbered Frames):提供补充的链接控制功能。
Inforamtion Field 消息标识
这个消息标识只在信息帧和一些无编号帧上存在。该字段可以包含任何位序列,但必须包含整数个八位位组。信息字段的长度是可变的,可以大到系统的最大值。
Frame Check Sequence Field 帧检查序列字段
帧检查序列字段是从帧的其余位(不包括标志)计算出的错误检测代码。
总结一下。数据链路层的主要功能就是以下几点。
处理比特传输发生的错误。
它确保数据流的传输速度不会使发送和接收设备不堪重负。
它允许将数据传输到网络层的第3层并在其中进行寻址和路由。
我个人是希望你们可以把这些原理理解的很透彻,但是如果不能,也不用强求,面试的时候,可以和面试官把几种算法讲出来,哪种好,好在哪里就可以了,因为你的面试官也不一定会。好,我们下一节再见。

View File

@ -0,0 +1,202 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 1+1 = 2吗 - 二进制的计算
Hello, 大家好希望上一节课的基础大家都已经掌握了。那我们就来继续这一小节的学习来看一下1+1=2这一道永远的难题。
二进制的运算 - 加法和减法
加法
加法从基础来说和普通数字的加法也没有什么太大的区别。加法都是从右向左一次一个数。在每一个点上都会生成一个和的数字以及一个要进位的数字。当然我们这里不是每到9才产生一个进位而是每到2就会产生一个进位。那我们现在来做一道小小的加法11 + 3 = 你肯定知道是14吧。哈哈。那让我们来看一下在二进制里是怎么做呢
11 的二进制表达式 -> 01011
3 的二进制表达式 -> 00011
相加的结果 -> 01110
复制
01110 = 什么呢? 1 * 8 + 1 * 4 + 1 * 2 = 14。我们来验证一下上面的加法从右向左1+1是不是要进位所以最右是一个0然后第二位有三个1那就留下一个1在进位1个1就成为了10然后1+0等于1 - > 110之后又是一个1 + 0 = 1所以结果就是1110了是不是很简单。
减法
下面我们再来看一下减法减法实际上就是加法的变种只不过就是A + -B。好我们来看一下这个例子14 - 9。在这里我们要插播一下补码和反码的小知识要不然你完全无法理解-9是怎么用二进制来表示
小知识课堂
原码
什么叫原码可能提到原码你能想到的是底层源码比如java的源码是什么spring的源码是什么但是因为我们中文的博大精深所以会造成这个误解但是你看字的话很明显有不同是不是。那这里的原码是指什么呢话语千遍不如一个实例。
对于正数来说,原码就是自己,比如
我们来用9来作为实例。00000000 00000000 00000000 00001001是9的原码
那-9的原码呢其实就是在最高位加一个1。
这里1表示负数0表示正数 100000000 00000000 00000000 00001001是-9的原码
复制
这里的最高位是你自己来选择是作为数值还是作为符号比如一个byte类型的话有8个字节。0000 0000如果不使用符号位的话数值就是从0到2550就是0000 0000255就是1111 1111。如果使用符号位的话范围就是-127 到 127。1111 1111 因为第一位是符号位所以表示负数然后后面的7个1表示127所以值是-127。然后0111 1111也就是正数的最大位等于127。所以范围就是-127到127。
我相信聪明的你这时候会有一个疑问,为什么负数不能直接用原码,而有什么之后要讨论的反码,补码?
那是因为原码有它的弱点
首先0是两个那就是会有两个0,也就是+0和-000000000和10000000
当要进行异号相加或同号相减时,方法比较笨拙- 先要判断2个数的绝对值大小然后进行加减操作最后运算结果的符号还要与大的符号相同。于是,反码产生了。(每一个概念的出现都是为了解决一个问题,对不对)
反码
反码也是属于数值存储的一种多应用于系统环境设置如linux平台的目录和文件的默认权限的设置umask就是使用反码原理。在计算机内定点数有3种表示法原码、反码和补码。
对于正数来说,反码与原码相同(正数是多么善良和正义的存在)。
负数的反码是对该数的原码各位取反(符号位除外)。比如
100000000 00000000 00000000 00001001 的反码
111111111 11111111 11111111 11110110
复制
因为反码 还是有+0和-0这个问题。但是不修改的话就会被时代所淘汰反码就成为了过滤产物,也就是, 后来补码出现了。
补码
首先是谁也不怎么认真读的概念:补码表示统一的符号位和数值位,使得符号位可以和数值位一起直接参与运算,这也为后面设计乘法器除法器等运算器件提供了极大的方便。
对于正数来说,补码与原码相同(正数才是正道的光)。
负数的补码是对该数的原码各位取反符号位除外。然后在最后一位加1。比如
100000000 00000000 00000000 00001001 的补码加一之前是
111111111 11111111 11111111 11110110 然后再加上一
111111111 11111111 11111111 11110111
复制
现在让我们再回归到上面讲的那个减法。我们的例子是14 - 9。好现在让我们来分析以下。
14是一个正数所以基本上不太会有任何的trick。
00000000 00001110
下面让我们来分析一下-9。
首先是9 -> 00000000 00001001
-9 10000000 00001001
反码 11111111 11110110
补码 11111111 11110111
+00000000 00001110 这个是之前的14
00000000 00000101 这个是最后的结果。不用我说你也能算出来结果是5吧。
复制
所以我们做减法的顺序是
把要减的数的正数算出来
把第一个最高位变成符号位1
把这个数的反码写出来
把这个数的补码写出来
把这个补码和之前的正数进行相加。
最后的结果就是相减的结果
希望你读到这里,头还没有晕
二进制的运算 - 逻辑计算
AND运算
AND是二进制的逻辑运算法这意味着它需要两个输入的数值。也可以认为AND需要两个来源。它的运算很简单。基本的原则是
A B AND
0 0 0
0 1 0
1 0 0
1 1 1
复制
从上图你可以看出只有A和B同时都是1的时候AND的结果才是1。你可以把它想象成一个串行电路。一前一后两个电路。只有两个电路同时通的时候整体才会通电有任何一个不能通电的话那就不成功。虽然很简单还是给你们一个例子吧。我要做一个严谨的人。
00111010 01101001 A
01011001 00100001 B
00011000 00100001 And之后的结果
复制
OR运算
OR是二进制的另一个重要的逻辑运算法它的需求和AND一样需要两个输入的数值。它的运算也很简单。基本的原则是
A B OR
0 0 0
0 1 1
1 0 1
1 1 1
复制
从上图你可以看出只要A和B任意一个是1的时候OR的结果就是是1。你可以把它想象成一个并行电路。上下两个电路。只要有一个电路通的时候整体就会通电只有两个都不通电的时候才会不成功。同样是一个小例子
00111010 01101001 A
01011001 00100001 B
01111011 01101001 OR之后的结果
复制
NOT运算
OR是二进制的另一个重要的逻辑运算法它只需要一个输入就可
A NOT
0 1
1 0
复制
Exclusive-OR运算
Exclusive-OR通常也简写成XOR。当然它也是一个逻辑运算符。同样需要两个输入。当然它的结果可能是有一点和你平时的计算不同。只有当两个输入不同的时候才会是1。相同的话就会是0。
A B XOR
0 0 0
0 1 1
1 0 1
1 1 0
复制
这几种逻辑运算符中最最复杂的可能就是这个。我们还是看一个小案例
00111010 01101001 A
01011001 00100001 B
01100011 01001000 XOR之后的结果
复制
其实这一小节还是属于基础只不过不是二进制的基础而是二进制计算的基础。我们讲了原码反码和补码加法减法以及逻辑运算。小朋友你是不是有很多问号你的小脑瓜一定在想这些有什么用我可以负责任的告诉你。IP的计算会用到。电路的计算会用到。因为二进制归根到底就是电流。那个就有点扯远了。还有一个最重要的就是算法的考试和面试也会问到。而往往二进制的算法题不长但是不练习或者不知道基础的你往往想不到。我们下一节来看一下16进制然后我会给你们举一些面试的时候会问到问题。

View File

@ -0,0 +1,46 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 分布式事务考点梳理 + 高频面试题
本课时我将和你一起梳理一下面试中分布式事务的高频考点,做到温故知新。
如何考察分布式事务
数据一致性和分布式事务是互联网分布式系统设计中必须要考虑的,所以对分布式事务的考察是中高级工程师面试必须跨过的一道门槛。
面试官通常会通过一个实际的系统设计题来展开提问,以考察候选人对分布式基础理论的理解、对各种数据一致性模型的掌握,以及对分布式下事务实现的原理、机制和各种实现手段的熟悉程度。
下面我模拟一个实际的面试场景,面试官可能会对你提出以下一连串的问题,你可以检测一下自己在学习中的掌握程度:
请说说你对分布式系统 CAP 理论的理解CAP 分别代表什么含义?
为什么分布式系统的一致性和可用性不能同时满足?
你是如何理解数据一致性的?数据一致性有哪几种模型?
你在做系统设计时,如何选择实现强一致性还是弱一致性?
在你的项目里,是如何设计分布式事务,实现最终一致性的?
你了解数据库的 binlog 和 redolog 吗?是如何实现一致性的呢?
需要说明的是,面试并不是应试考试,很多问题并没有标准答案,不过这里的问题,很多都可以在“模块二:分布式事务”中找到思路。
分布式事务高频考点
在分布式事务的面试中,主要会围绕分布式理论、一致性算法、分布式事务及其应用来展开提问。下面我进行了简单梳理,这里有一张分布式事务的知识点思维导图,你可以对照这张图片,查漏补缺进行分析。
分布式理论部分的主要内容包括 CAP 理论、Base 理论、各种数据一致性模型的应用等。在工作中应用比较多的是 ZooKeeper需要了解 ZooKeeper 的原理和实现、应用场景等。
一致性算法部分,希望你能够对经典的数据一致性算法,比如 Paxos 算法等有自己的理解,并不是要做到对算法细节倒背如流,而是要能够通过自己的描述,把算法的整体流程讲清楚。
分布式事务的应用是日常开发中打交道最多的部分,如果你在工作中实践过分布式事务的实现是最好的,若没有,可以去了解一些开源的分布式事务中间件。比如我在专栏中多次介绍过的 Alibaba Seata 等组件,通过学习开源组件设计思路,你也可以对这一部分内容有个整体的把握。
在专栏的第 [10]、[11]课时我们一起讨论了分布式锁的应用场景和实现细节,你可以回顾一下,使用 Redis 实现分布式锁,需要注意哪些细节呢?不同的实现方式,又存在哪些缺陷呢?
另外,除了专栏的内容,我推荐你结合一些经典的公开课程去学习,以加深印象,建议关注拉勾教育直播课哦,有许多分布式相关的主题分享。

View File

@ -0,0 +1,54 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 分布式服务考点梳理 + 高频面试题
本课时我将和你回顾一下该模块的核心内容,并且一起梳理一下面试中分布式服务的高频考点。
如何考察分布式服务
在整个分布式课程中,分布式服务是大部分工程师实际开发中应用最多的,也是面试中经常出现的一个热点。
在分布式服务部分的面试中,面试官通常会围绕“服务治理”的各个场景进行提问,考察候选人对微服务和服务治理各个环节的掌握程度。分布式服务这部分内容涉及的比较广,有非常丰富的内涵和外延知识。本课程只是带你描述了一些核心领域的知识点,剩下的内容,还需要你在平时的工作和学习中多多积累。
我们在课程中提到了 Spring Cloud 和 Dubbo 两个技术栈,这两大技术栈是目前大部分公司进行服务治理的选择。当然,一些公司使用的是 Thrift 和 gRPC 等服务框架,但是应用比例要小很多,在实际的面试中,通常会选择一个服务治理的技术栈来展开提问,对候选人进行考察。
下面我以 Dubbo 技术栈为例,整理了一些分布式服务相关的问题,来模拟实际的面试场景。这些问题都是比较基础的,你可以作为对照,检测一下掌握程度:
为什么需要 Dubbo
Dubbo 的主要应用场景?
Dubbo 的核心功能?
Dubbo 服务注册与发现的流程?
Dubbo 的服务调用流程?
Dubbo 支持哪些协议,每种协议的应用场景、优缺点?
Dubbo 有些哪些注册中心?
Dubbo 如何实现服务治理?
Dubbo 的注册中心集群挂掉,如何正常消费?
Dubbo 集群提供了哪些负载均衡策略?
Dubbo 的集群容错方案有哪些?
Dubbo 支持哪些序列化方式?
需要你注意的是,即使开发框架不同,但是在服务治理中关注的功能是一致的,如果你应用的是另外的分布式服务框架,可以把关键词做一些替换,比如 Spring Cloud 的主要应用场景、Spring Cloud 的核心功能,同样可以用来考察自己对整体技术栈的掌握程度。
微服务技术栈梳理
下面我分别展开 Dubbo 和 Spring Cloud 这两大微服务技术栈,并且简单描绘了一张知识点思维导图,你可以对照这张图片,查漏补缺进行针对性的学习。
对 Spring Cloud 和 Dubbo 两大技术栈的掌握重在深入而不是只能泛泛而谈。举个例子Dubbo 在不同业务场景时,如何选择集群容错策略和不同的线程模型,又如何配置不同的失败重试机制呢?
Dubbo 为什么选择通过 SPI 来实现服务扩展,又对 Java 原生的 SPI 机制做了哪些调整呢?这些应用细节都要针对性地了解,才能在系统设计时避免各种问题。
除了上层的技术组件之外,微服务底层的技术支撑也要去了解一下,比如 Docker 容器化相关知识容器内隔离是如何实现的JVM 对容器资源限制的理解,以及可能产生的问题,还有容器如何调度等。
继续扩展,你可以思考一下,为什么现在很多企业选择 Golang 作为主要的开发语言,其中一个原因,就和 Go 语言部署和构建快速,占用容器资源小有关系。
在技术之外,微服务设计常用的 DDD领域驱动设计思路开发中的设计模式也要有一定的理解和掌握。

View File

@ -0,0 +1,59 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 分布式存储考点梳理 + 高频面试题
你好,欢迎来到分布式存储模块的加餐环节,本课时我将和你一起梳理面试中分布式系统的数据库的高频考点,做到温故知新。
面试中如何考察分布式存储
广义的分布式存储根据不同的应用领域,划分为以下的类别:
分布式协同系统
分布式文件系统
分布式任务调度框架
分布式 NoSQL 存储
分布式关系数据库
各种消息队列 MQ
流式计算框架
当然,这只是一种划分方式,你也可以根据存储数据的特点,将分布式存储系统划分为块存储、对象存储等不同的分类。
可以看到,分布式存储技术的范围非常大,技术覆盖的广度和深度都很有料,比如分布式协同系统或者各种流计算框架,都可以单独作为一个专栏来进行展开讲解。
由于篇幅有限,我在分布式存储这个模块里,主要围绕分布式系统下的关系型数据库这一主题,选择了与大部分开发者直接相关的热点内容,包括数据库的读写分离、分库分表存储拆分后的唯一主见问题,以及典型的 NoSQL 数据库应用。另外,简单介绍了 ElasticSearch 技术、倒排索引的实现等。
和之前一样,我在这里选择了一些热点技术问题,你可以考察一下自己的掌握程度。以分布式场景下的数据库拆分为例,面试官会对你进行下面的考察:
当高并发系统设计时,为什么要分库分表?
用过哪些分库分表中间件?
不同的分库分表中间件都有什么优点和缺点?
如何对数据库进行垂直拆分或水平拆分?
如果要设计一个可以动态扩容缩容的分库分表方案,应该如何做?
数据库分库分表以后,如何处理设计主键生成器?
不同的主键生成方式有什么区别?
上面的问题,都可以在“分布式存储”模块的内容中找到思路,你可以对照本模块学过的知识,整理自己的答案。
分布式存储有哪些高频考点
上面我提到过,分布式存储包含了非常丰富的技术栈,本模块的内容虽然在实际开发中有着高频应用,但只是分布式存储技术领域中非常小的一部分。在下面这张思维导图中,除了分布式下的关系型数据库之外的内容,我还补充了一些经典分布式存储技术的部分,你可以对照这张思维导图,进行针对性的扩展。
以分布式文件系统为例,常见的分布式文件系统有 Google 的 GFS、Hadoop 实现的分布式文件系统 HDFS、Sun 公司推出的 Lustre、淘宝的 TFS、FastDFS 等,这几种存储组件都有各自的应用场景。
比如淘宝的 TFS 适合用于图片等小文件、大规模存储的应用场景是淘宝专门为了支持电商场景下数以千万的商品图片而开发的FastDFS 类似 GFS是一款开源的分布式文件系统适合各类规模较小的图片和视频网站。
比如流式计算框架有著名的流式计算三剑客Storm、Spark 和 Flink这三个框架基本上覆盖了绝大多数的流式计算业务适用于不同的大数据处理场景。
今天的内容就到这里了,也欢迎你留言分享自己的面试经验,和大家一起讨论。

View File

@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
37 消息队列考点梳理 + 高频面试题
你好,欢迎来到分布式消息队列模块的加餐环节,本课时我将和你一起梳理面试中消息队列的高频考点,做到温故知新。
面试中如何考察消息队列
消息队列作为日常开发中应用最高频的基础组件之一,相关的问题自然也是面试中的常客。
在面试中对消息队列的考察方式,主要包括两种形式,一种是针对消息队列的相关理论,比如消息队列重复消费、消费幂等性、消息队列的可靠传输等;另一种考察方式是针对某个具体的消息队列中间件,考察组件应用的原理,实现方案和应用细节,比如常见的 Kafka、RabbitMQ、RocketMQ 等消息队列组件。
下面我梳理了一些面试中的高频问题,你可以对照这些问题,检测自己是否掌握了问题考察的内容,针对自己薄弱的环节,进行针对性地提高。
消息队列理论高频问题
对消息队列应用相关理论和设计的考察,面试官可以提出下面一系列的问题:
如何保证消息队列的高可用?
如何保证消息不被重复消费?
如何保证消费的时候是幂等?
如何保证消息的可靠性传输?
传输过程出现消息丢失了怎么办?
如何保证消息的顺序性?
如何解决消息队列的延时问题?
如何解决消息队列的过期失效问题?
消息队列满了以后该怎么处理?
有几百万消息持续积压几小时,应该怎么解决?
如果让你写一个消息队列,该如何进行架构设计?
可以看到,这方面的问题非常重视考察候选人对实际问题处理的经验,不过没有固定的答案。我在专栏里多次强调,授人以鱼不如授人以渔,关于分布式的方法论是最重要的。如果让你从零到一设计一个消息队列,该如何展开呢?你可以从分布式的基础理论出发,从数据存储的一致性,集群扩展结合我在分布式消息队列模块所讲解的内容,同时融入自己对系统架构的理解,最后形成自己的观点。
消息队列应用高频问题
面试中对具体某一种消息组件的考察,一般是候选人有过该组件的应用经验,重点是考察候选人对基础组件掌握的深度,出现问题后的解决办法等。
以 Kafka 为例,可以提出以下的问题:
描述一下 Kafka 的设计架构?
Kafka、ActiveMQ、RabbitMQ、RocketMQ 之间都有什么区别?
Kafka 消费端是否可能出现重复消费问题?
Kafka 为什么会分区?
Kafka 如何保证数据一致性?
Kafka 中 ISR、OSR、AR 是什么?
Kafka 在什么情况下会出现消息丢失?
Kafka 消息是采用 Pull 模式,还是 Push 模式?
Kafka 如何和 ZooKeeper 进行交互?
Kafka 是如何实现高吞吐率的?
如果是 RocketMQ很多问题都是类似的可以从以下的问题出发进行考察
RocketMQ 和 ActiveMQ 有哪些区别?
为什么 RocketMQ 不会丢失消息?
RocketMQ 的事务消息都有哪些应用?
RocketMQ 是怎么保证系统高可用的?
这些问题中一部分可以在专栏中找到思路,但大部分的问题还要靠你在平时多积累与思考,比如消息队列的高可用,你可以多机器部署,防止单点故障;主从结构复制,通过消息冗余防止消息丢失;消息持久化,磁盘写入的 ACK 等角度进行分析。
今天的内容就到这里了,也欢迎你留言分享自己的面试经验,和大家一起讨论。

View File

@ -0,0 +1,68 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
44 分布式缓存考点梳理 + 高频面试题
你好,欢迎来到分布式缓存模块的加餐环节,本课时我将和你一起梳理面试中分布式缓存的高频考点,做到温故知新。
分布式缓存在面试中如何考察
对缓存和数据库的考察,一直都是业务开发同学在面试中的核心问题,特别是缓存部分,随着大部分公司业务规模的增加,缓存的应用越来越重要。我偶尔会和身边的同事调侃:如何应对高并发?答案是加一层缓存,如果不够,就再加一层缓存。
缓存在分布式场景下的应用,比单机情况下更加复杂,除了常见的缓存雪崩、缓存穿透的预防,还要额外考虑缓存数据之间的一致性,缓存节点的负载均衡,缓存的监控和优化等。在面试中,对分布式缓存的考察一般有两种方式:
通过实际场景来考察对缓存设计和应用的理解;
直接考察常用的缓存组件,比如 Redis、Memcached、Tair。
面试官通常会通过一个实际场景,结合常用的缓存组件,进行 System Design 相关方面的考察。下面我梳理了部分分布式缓存的高频考点,希望可以帮助你提纲挈领,体系化地去学习相关知识。
缓存如何应用
缓存雪崩、缓存穿透如何理解?
如何在业务中避免相关问题?
如何保证数据库与缓存的一致性?
如何进行缓存预热?
缓存的高可用
缓存集群如何失效?
一致性哈希有哪些应用?
缓存如何监控和优化热点 key
Redis 应用
Redis 有哪些数据结构?
Redis 和 Memcached 有哪些区别?
单线程的 Redis 如何实现高性能读写?
Redis 支持事务吗?
Redis 的管道如何实现?
Redis 有哪些失效策略?
Redis 的主从复制如何实现?
Redis 的 Sentinel 有哪些应用?
Redis 集群有哪几种方式?
Redis 和 memcached 什么区别?
Redis 的集群模式如何实现?
Redis 的 key 是如何寻址的?
Redis 的持久化底层如何实现?
Redis 过期策略都有哪些?
缓存与数据库不一致怎么办?
Redis 常见的性能问题和解决方案?
使用 Redis 如何实现异步队列?
Redis 如何实现延时队列?
以上的这些问题,都是面试中非常高频的,你可以进行一个模拟面试,考察自己对这部分知识的掌握程度,有一部分问题在专栏中已经介绍过了,比如缓存集群、缓存一致性、缓存负载均衡等,专栏没有涉及的,可以作为一份索引,帮助你有针对性地学习。
今天的内容就到这里了,也欢迎你留言分享自己的面试经验,和大家一起讨论。

View File

@ -0,0 +1,190 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 基本概念:指标+日志+链路追踪=可观测性?
你好,我是翁一磊。
上节课,我们介绍了计算机系统监控的发展历史,这节课我们来具体聊一聊可观测性,以及大家对于可观测性的一些误解。
什么是可观测性?
就像我们在开篇词中说的,可观测性强调的是可以从系统向外部输出的信息来推断出系统内部状态的好坏。
当我们把“可观测性”这个概念挪到软件系统时,其实强调的也是一种度量能力,一个软件应用程序具有可观测性,意味着它能够让我们通过各种维度和各种角度去分析和理解这个系统当前所处的任何状态,无论这种状态有多奇怪、无论我们之前有没有遇到过,都不需要预先定义或预测。如果能够在不发布新代码(如增加一个用于调试的日志)的情况下理解任何奇怪或不确定性的状态,那么我们的系统就具备可观测性。
因此,可观测性是描述人们如何与他们的复杂系统互动,以及如何理解这些复杂系统的概念。如果你接受这个定义,那么看看接下来这些问题:
如何收集数据并将它们组合起来进行分析?
处理这些数据的技术要求是什么?
要从这些数据中获益,团队需要具备哪些能力?
这些问题,我们都会在专栏中一一解答。不过别着急,这节课我们还是要先把可观测性的概念和内涵理清楚。
指标+日志+链路追踪=可观测性?
既然选择学习这门课程你八成听过可观测性的“三大支柱”指标metrics日志logs和链路追踪Tracing。但是指标、日志再加上链路追踪真的就是可观测性吗让我们先来看一下这三类数据的含义。
指标:是在⼀段时间内测量的数值。它包括特定属性,例如时间戳、名称、键和值。和⽇志不同,指标在默认情况下是结构化的,这会让查询和优化存储变得更加容易。
例如2022/05/20 12:48:22CPU usage user23.87%,它就表示 CPU 运行在用户态的时间占比在这一刻为 23.87%。
日志:是对特定时间发⽣的事件的⽂本记录。日志一般是非结构化字符串,会在程序执行期间被写入磁盘。每个请求会产生一行或者多行的日志,每个日志行可能包含 1-5 个维度的有用数据(例如客户端 IP时间戳调用方法响应码等等。当系统出现问题时⽇志通常也是工程师⾸先查看的地⽅。常见的日志格式是下面的样子。
127.0.0.1 - - [24/Mar/2021:13:54:19 +0800] "GET /basic_status HTTP/1.1" 200 97 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36"
链路追踪有时候也被称为分布式追踪Distributed Tracing表示请求通过分布式系统的端到端的路径。当请求通过主机系统时 它执⾏的每个操作被称为“跨度”Span
举个分布式调用的例子:客户端发起请求,请求首先到达负载均衡器,经过认证服务、系统服务,然后请求资源,最终返回结果;那这里面的操作就包括请求网关、身份认证、请求资源、以及返回结果等。
链路追踪一般会通过一个可视化的瀑布图展现出来。瀑布图展示了用于调试的请求的每个阶段,以及每个部分的开始时间和持续时长。
比方说,在下图这个例子里,瀑布图由 Span 组成。特定的链路追踪中的 Span 可能是根 Span也就是最顶层的 Span也可能是根 Span 以下的 Span。Span还可能包含 Span这种常被称为父子关系。比如如果服务 B 调用服务 B-1服务 B-1 调用 B-2那么在这条链路中Span B 是 Span B-1 的父亲 SpanSpan B-1 是 Span B-2 的父亲 Span。
然而,仅仅是收集这些数据类并不能保证系统的可观测性,尤其是当你彼此独⽴地使⽤它们时。从根本上来说,指标、日志和链路追踪只是数据类型,与可观测性无关。
另一方面,这三种数据类型也有着局限性。
指标
由于指标最大的特点是聚合性,它生成的数值反映了预定义时间段内系统状态的汇总报告,在此期间处于活动状态的所有请求的行为都会汇总为一个数值,因此缺乏细颗粒度。同时这些指标很可能都是彼此不相关的,没有关联性。
例如page_load_time 指标可能会检查在最后 5 秒间加载所有活动页面所花费的平均时间requests_per_second 指标可能会检查任何给定服务在最后一秒内打开的 HTTP 连接数。这就导致能够挖掘的信息的颗粒度是比较粗的,如果在 5 秒内发生了一千个离散事件,从 page_load_time 指标中根本无法获取某一事件的具体情况。
当然,这并不是说指标完全没用,指标对于静态仪表板的构建、随时间变化的趋势分析、或监控维度是否保持在定义的阈值内很有用,但这些并不是可观测性,因为这些信息的颗粒度在做故障排查或根因分析时是远远不够的。
日志
日志文件本质上是分散的事件,是一大块非结构化文本,旨在方便人类阅读,但要达到这个目的,日志通常要将一个事件的所有细节分成多行文本。这样在生产环境中,日志通常散布在数以百万计的文本行中,通过使用某种类型的日志文件解析器才可以完成对它们的搜索。解析器将日志数据拆分为信息块,并尝试以有意义的方式对它们进行分组。但是,对于非结构化数据,解析变得复杂,因为不同类型的日志文件存在不同的格式化规则里(或根本没有规则)。
针对这一点的解决方案是创建结构化日志数据,例如将上面的日志解析成下面这样。
结构化日志是机器可解析的,如果它们被重新设计为类似于结构化事件的话,可以帮助我们实现可观测的目标。关于结构化事件,后面还会做进一步介绍。
"fields": {
"agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36",
"browser":"Chrome",
"browserVer":"89.0.4389.72",
"bytes":97,
"client_ip":"127.0.0.1",
"engine":"AppleWebKit",
"engineVer":"537.36",
"http_method":"GET",
"http_url":"/basic_status",
"http_version":"1.1",
"isBot":false,
"isMobile":false,
"message":"127.0.0.1 - - [24/Mar/2021:13:54:19 +0800] "GET /basic_status HTTP/1.1" 200 97 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_1_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.72 Safari/537.36"",
"os":"Intel Mac OS X 11_1_0",
"referrer":"-",
"status":"OK",
"status_code":200,
"ua":"Macintosh"
},
链路追踪
链路追踪检测的主要问题是,如果仅靠开发人员“插桩”(英文 Instrument有些地方也翻译成埋点是指将有关系统状态的数据发送到监测系统他们的代码是不够的。大量应用程序是使用可能需要额外工具的开源框架或库构建的。这在多语言架构的地方变得更加具有挑战性因为要考虑到每种语言、框架和协议的不同。
同时,增加插桩的成本也是比较高的,很难真正做到全面覆盖。这样的方式只适用于具体的业务场景,如果其他地方有类似的需要,就需要再次插桩。而且随着产品的不断迭代,我们很难一次性把需要插桩的地方都考虑周全,这就会带来反复的工作,也可能会涉及多次上线,增加了工作量的同时也降低了系统的可靠性。另一方面,大量的插桩也会占用比较高的计算资源。
总之,指标、日志和链路追踪只是数据的类型,本身并不代表可观测性。可观测性也不是供应商提供的一种技术,而是你构建的系统的属性,就像可用性、高可用性和稳定性这些一样。
设计和构建可观测系统的目标是确保系统运行时,操作员可以检测到服务停机、错误和响应缓慢等不良行为,并可以通过足够的信息来确定问题的根本原因。
可观测性的特性
就像我在前面介绍的我们对软件系统的“可观测性”的定义是一种度量能力能够帮助你更好地理解和解释系统当前所处的任何状态无论这种状态或者问题是否在之前出现过。而结构化的事件Structured Events就是可观测性的基础。
事件指的是特定请求与服务交互时所有信息的记录,通过事件能了解生产环境中服务所受到的影响。
那什么是结构化的事件呢?
在请求第一次进入服务时会有一个空的地图Map被初始化出来。在该请求的生命周期内发生的任何细节包括唯一的 ID、变量值、标头、请求传递的每个参数、执行时间、对远程服务的任何调用、这些远程调用的执行时间或任何可能在之后的调试中有价值的上下文都会附加到这个地图中。然后当请求即将退出或出错时刚刚所发生的事情都被丰富地记录了下来。写入该地图的数据被组织和格式化为键值对以便于搜索。换句话说这些数据就是结构化的事件。
这样做的好处是什么呢?
当你调试服务中的问题时,可以相互比较结构化事件,及时发现异常。当某些事件的行为与其他事件明显不同时,你可以尝试确定这些异常值的共同点。探索这些异常值,需要分析可能与你的调查相关的事件,按照这些事件中所包含的不同维度(甚至是不同维度的组合)进行过滤和分组。另一方面,对你有帮助的信息可能包含不特定于任何给定请求的运行时信息(例如容器信息或版本信息),也包含有关通过服务的每个请求的信息(例如购物车 ID、用户 ID 或会话令牌等等)。这两种类型的数据都对调试很有用。
所有这些数据都可以用于调试并存储在你的事件中。它们是任意“宽度”的事件因为你需要的调试数据可能包含大量字段或是来自任意维度而不应该有实际限制。如果要分析一个异常的状态具有可观测性的调试方式就是尽量保留每一个请求的上下文这样你就可以针对这个上下文分析定位修复这个Bug或者调整相关的环境配置了。
所以我们说,数据的高基数和高维度,这将成为能够发现隐藏在复杂系统架构中的其他隐藏问题的关键组成部分。我们分开来看一下。
基数的作用
在数据库的概念中,基数是指包含在一个集合中的唯一值的数量。低基数意味着这一列在其集合中有很多重复的值;高基数意味着该列包含很大比例的完全唯一的值。
举例来说在一个包含1亿条用户记录的集合中任何通用唯一标识符UUID都是高基数的另外用户名也具有很高的基数当然会低于UUID因为有些名称可能是重复的。另一方面像性别这样的领域的基数就会很低。再举个例子假设所有用户都是人类像物种这样的字段可能具有最低的基数。
基数对于可观测性很重要,因为高基数信息在调试或理解系统的数据时是最有用的。如果能够按照这些字段,例如 userid、cartid、requestid 或任何其他 ID host、container_name、hostname、version、span 等),根据其中的唯一 ID 来查询数据,是在“大海”中精确定位每一滴“水滴”的最佳方法。你总是可以通过聚合采样高基数的值获得较低基数的值(例如,通过首字母存储姓氏),但没法反过来。
维度的作用
基数指的是数据中值的唯一性维度指的则是数据中键key的数量。在可观测系统中遥测数据被生成为任意“宽度”的结构化事件它们可以而且应该包含数百甚至数千个键值对即维度。事件范围越广事件发生时获取的上下文就越丰富在以后调试时就越容易定义问题的原因。
假设你有一个事件模式,每个事件定义了六个高基数维度:时间、应用、主机、用户、端点以及状态。通过这六个维度,你可以创建查询,分析任何维度组合,以发现可能导致异常的相关模式。例如,你可以检索:“过去半小时内,发生在主机 host001 上的所有的502错误请求”或是“由用户 vipuser001 在做数据导出时产生的所有403错误请求”。
也就是说,只需六个基本维度,你就可以通过一组有用的条件,来确定你的应用程序系统中可能发生的情况。但是在现代系统中,可能发生的故障的排列方式是无限的,只在传统监控数据中捕捉几个基本维度是不够的。现在想象一下,除了六个维度之外,你还可以关注数百乃至数千个包含无数细节、值、计数器或字符串的维度,这些维度在将来的某个时候可能对你的调试有帮助。例如,你可以包含像这样的维度:
create_time
component
date_ns
duration
endpoint
env
http.route
host
operation
parent_id
pid
resource
service
servlet.path
source
source_type
start
span_id
span_type
status
trace.id
thread.id
thread_name
version
有了更多可用的维度,你就可以检测各种事件,在任何一组服务请求之间建立高度复杂的关联了。数据的维度越高,就越有可能发现应用程序行为中隐藏的、难以捉摸的模式。在后面的章节,我们还会更详细地讲解这部分内容。
小结
好了,这节课就讲到这里,我来小结一下。
尽管“可观测性”这个专有名词已经出现几十年了,但在软件系统中它还是一个新事物,它带来了一些新的考虑和特性。可观测性的出现,其实也刚好符合计算机领域现阶段的需求,由于现代系统引入了额外的复杂性,系统的故障比以往任何时候都更难预测、检测和修复。
为了减轻这种复杂性,工程团队现在必须能够以灵活的方式不断收集遥测数据,及时调试问题,而不需要首先预知故障可能如何发生。可观测性让工程师能够以灵活的方式分析遥测数据,快速找到未知问题的根源。
可观测性通常被错误地描述为包含指标、日志和追踪的“三个支柱”,但其实这些只是遥测数据类型。如果我们必须拥有可观测性的三个支柱,那么它们应该是支持高基数、高维度和可探索性工具。下节课,我们会探讨可观测性与传统系统监控方法的不同之处。
课后题
在这节课的最后,留给你一道思考题。
你在使用监控工具对系统和应用进行监控的时候,遇到过哪些难以依靠单纯的监控来解决的问题?后来是如何找到问题原因的?
欢迎你在留言区和我交流讨论,我们下节课见!

View File

@ -0,0 +1,278 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
不定期加餐5 借助实例探究C++编译器的内部机制
你好我是宫文学。欢迎来到编译原理实战课的加餐环节今天我们来探讨一下C++的编译器。
在前面的课程中我们已经一起解析了很多语言的编译器了但一直没有讨论C和C++的编译器。并不是因为它们不重要而是因为C语言家族的编译器实现起来要更复杂一些阅读代码的难度也更高一些会对初学者造成比较大的挑战。
不过没有解析C和C++语言的特性及其编译器的实现其实在我心里也多多少少有点遗憾因为C和C++是很经典的语言。至今为止,我们仍然有一些编程任务是很难用其他语言来代替的,比如,针对响应时间和内存访问量,需要做精确控制的高性能的服务端程序,以及一些系统级的编程任务,等等。
C和C++有很多个编译器今天我们要研究的是Clang编译器。其实它只是前端编译器而后端用的是LLVM。之所以选择Clang是因为它的模块划分更清晰更便于理解并且还可以跟课程里介绍过的LLVM后端工具串联起来学习。
另外因为C++语言的特性比较多编译器实现起来也比较复杂一些下手阅读编译器的源代码会让人觉得有点挑战。所以今天这一讲我的主要目的就是给你展示如何借助调试工具深入到Clang的内部去理解它的运行机制。
我们会具体探究哪个特性呢我选择了C++的模板技术。这个技术是很多人学习C++时感觉有困难的一个技术点。通过探究它在编译器中的实现过程你不仅会加深了解编译器是如何支持元编程的也能够加深对C++模板技术本身的了解。
那么下面我们就先来认识一下Clang这个前端。
认识Clang
Clang是LLVM的一个子项目它是C、C++和Objective-C的前端。在llvm.org的官方网站上你可以下载Clang+LLVM的源代码这次我用的是10.0.1版本。为了省事你可以下载带有全部子项目的代码这样就同时包含了LLVM和Clang。然后你可以参考官网的文档用Cmake编译一下。
我使用的命令如下,你可以参考:
cd llvm-project-10.0.1
#创建用于编译的目录
mkdir build
cd build
#生成用于编译的文件
cmake -DCMAKE_BUILD_TYPE=Debug -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_BUILD_EXAMPLES=ON ../llvm
#调用底层的build工具去执行具体的build
cmake --build .
这里你要注意的地方是我为Cmake提供的一些变量的值。我让Cmake只为x86架构生成代码这样可以大大降低编译工作量也减少了对磁盘空间的占用并且我是编译成了debug的版本这样的话我就可以用LLDB或其他调试工具来跟踪Clang编译C++代码的过程。
编译完毕以后你要把llvm-project-10.0.1 /build/bin目录加到PATH中以便在命令行使用Clang和LLVM的各种工具。你可以写一个简单的C++程序比如说foo.cpp然后就可以用“clang++ foo.cpp”来编译这个程序。
补充如果你像我一样是在macOS上编译C++程序并且使用了像iostream这样的标准库那么可能编译器会报找不到头文件的错误。这是我们经常会遇到的一个问题。-
这个时候你需要安装Xcode的命令行工具。甚至还要像我一样在.zshrc文件中设置两个环境变量
export CPLUS_INCLUDE_PATH="/Library/Developer/CommandLineTools/usr/include/c++/v1:$CPLUS_INCLUDE_PATH"
export SDKROOT="/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk"
到目前为止你就把Clang的环境配置好了。那回过头来你可以先去看看Clang的源代码结构。
你会看到Clang的源代码主要分为两个部分头文件.h文件全部放在include目录下而.cpp文件则都放在了lib目录下。这两个目录下的子目录结构是一致的每个子目录代表了一个模块模块的划分还是很清晰的。比如
AST目录包含了AST的数据结构以及对AST进行遍历处理的功能。
Lex目录词法分析功能。
Parse目录语法分析功能。
Sema目录语义分析功能Sema是Sematic Analysis的缩写
接下来你可以进入这些目录去寻找一下词法分析、语法分析、语义分析等功能的实现。由于Clang的代码组织很清晰你可以很轻松地根据源代码的名称猜到它的功能从而找到语法分析等功能的具体实现。
现在你可以先猜测一下Clang的词法分析和语法分析都是如何实现的呢
如果你已经学过了第二个模块中几个编译器的实现可能就会猜测得非常准确因为你已经在Java编译器、Go的编译器、V8的编译器中多次见到了这种实现思路
词法分析:手写的词法分析器,也就是用手工的方法构造有限自动机。
语法分析:总体上,采用了手写的递归下降解析器;在表达式解析部分,采用的是运算符优先级解析器。
所以,针对词法分析和语法分析的内容,我们就不多展开了。
那么Clang的语义分析有什么特点呢
通过前面课程的学习现在你已经知道语义分析首先要做的是建立符号表并做引用消解。C和C++在这方面的实现比较简单。简单在哪里呢?因为它要求必须声明在前,使用在后,这就让引用消解变得很简单。
而更现代一些的语言在声明和使用的顺序上可以更加自由比如Java类中方法中可以引用类成员变量和其他方法而被引用的成员变量和方法可以在该方法之后声明。这种情况对引用消解算法的要求就要更高一些。
然后现在你也知道在语义分析阶段编译器还要做类型检查、类型推导和其他很多的语义检查。这些方面Clang实现得也很清晰你可以去看它的StaticAnalysis模块。
最后在语义分析阶段Clang还会做一些更加复杂的工作比如C++的模板元编程机制。
我在探究元编程的那一讲中介绍过C++的模板机制它能有效地提高代码的复用能力。比如你可以实现一个树的容器类用来保存整型、浮点型等各种不同类型的数据并且它不会像Java的泛型那样浪费额外的存储空间。因为C++的模板机制,会根据不同的模板类型生成不同的代码。
那么C++具体是如何实现这一套机制的呢?接下来我就带你一起去深入了解一下,从而让你对模板元编程技术的理解也更加深入。
揭秘模板的实现机制
首先我们通过一个示例程序来观察一下Clang是如何编译模板程序的。假设你写了一个简单的函数min用来比较两个参数的大小并返回比较小的那个参数。
int min(float a, float b){
return a<b ? a : b;
}
你可以用clang++命令带上“-ast-dump”参数来编译这个示例程序并显示编译后产生的AST。
clang++ -Xclang -ast-dump min.cpp
下图中展示的是min函数对应的AST。你能发现AST节点的命名都很直观一下子就能看明白每个节点的含义。其中函数声明的节点是FunctionDecl也就是Function Declaration的缩写。
min函数是一个普通的函数只适用于参数为浮点型的情况。那么我们再增加一个使用模板的版本并且函数名称一样这样就可以支持用多种数据类型来比较大小比如整型、双精度型等。
template <typename T> T min(T a, T b){
return a<b ? a : b;
}
这时顶层的AST节点是FunctionTemplateDecl也就是函数模板声明。它有两个子节点一个是模板类型参数声明TemplateTypeParmDecl也就是尖括号里面的部分第二个子节点其实是一个普通的函数声明节点其AST的结构几乎跟普通的min函数版本是一样的。
这样通过查看AST你就能了解函数模板和普通函数的联系和区别了。接下来就要进入重点了函数模板是如何变成一个具体的函数的
为此我们在main函数里调用一下min函数并传入两个整型的参数min(2,3)
int main(){
min(2,3);
}
这个时候我们再看一下它生成的AST就会发现函数模板声明之下增加了一个新的函数声明。这个函数的名称仍然是min但是参数类型具体化了是整型。
这说明当编译器发现有一个min(2,3)这样的函数调用的时候就会根据参数的类型在函数模板的基础上生成一个参数类型确定的函数然后编译成目标代码。这个过程叫做特化Specialization也就是从一般到具体的过程。函数模板可以支持各种类型而特化后的版本只针对某个具体的数据类型。
那么特化过程是怎样发生的呢我们目前只看到了ASTAST反映了编译的结果但它并没有揭示编译的过程。而只有搞清楚这个过程我们才能真正理解模板函数的编译机制。
要揭示编译过程最快的方法是用调试器来跟踪程序的执行过程。最常用的调试器就是LLDB和GDB。这里我使用的是LLDB你可以参考我给出的命令来设置断点、调试程序。
小提示如果你像我一样是在macOS中运行LLDB可能会遇到报错信息即操作系统不让LLDB附加到被调试的程序上。这是出于安全上的考虑。你需要重启macOS并在启动时按住command-R键进入系统恢复界面然后在命令行窗口里输入“csrutil disable”来关闭这个安全选项。
不过在跟踪clang++执行的时候你会发现clang++只是一个壳真正的编译工作不是在这个可执行文件里完成的。实际上clang++启动了一个子进程来完成编译工作这个子进程执行的是clang-10。所以你需要另外启动一个LLDB来调试新启动的进程。
在使用LLDB的时候你会发现确定好在什么位置上设置断点是特别重要的这能大大节省单步跟踪所花费的时间。
那么现在,我们想要探究函数模板是什么时候被特化的,应该在哪里设置断点呢?
在研究前面示例程序的AST的时候我们发现编译器会在函数特化的时候创建一棵新的函数声明的子树这就需要建立一个新的FunctionDecl节点。因此我们可以监控FunctionDecl的构建函数都是什么时候被调用的就可以快速得到整个调用过程。
那怎么查看调用过程呢当clang-10在FunctionDecl断点停下以后你可以用“bt”命令打印出调用栈。我把这个调用栈整理了一下并加了注释你可以很容易看清楚编译器的运行过程
接着,分析这个调用栈,你会发现其主要的处理过程是这样的:
第一语法分析器在解析表达式“min(2,3)”的时候会去做引用消解弄清楚这个min()函数是在哪里定义的。在这里,你又一次看到语法分析和语义分析交错起来的情况。在这个点上,编译器并没有做完所有的语法分析工作,但是语义分析的功能会被按需调用。
第二,由于函数允许重载,所以编译器会在所有可能的重载函数中,去匹配参数类型正确的那个。
第三编译器没有找到与参数类型相匹配的普通函数于是就去函数模板中找结果找到了以T作为类型参数的函数模板。
第四根据min(2,3)中参数的类型对函数模板的类型参数进行推导结果推导出T应该是整型。这里你要注意min(2,3)的第一个参数和第二个参数的类型需要是一样的,这样才能推导出正确的模板参数。如果一个是整型,一个是浮点型,那么类型推导就会失败。
最后,把推导出来的类型,也就是整型,去替换函数模板中的类型参数,就得到了一个新的函数定义。不过在这里,编译器只生成了函数声明的节点,缺少函数体,是个空壳子。
注意,这里最后一句的说法只是目前我自己的判断,所以我们要来验证一下。
Clang在重要的数据结构中都有dump()函数AST节点也有这个函数。因此你可以在LLDB中调用dump()函数来显示一棵AST子树的信息。
(lldb) expr Function->dump()
这个时候在父进程的LLDB窗口中会显示出被dump出的信息输出格式跟我们在编译的时候使用-ast-dump参数显示的AST是一样的。从输出的信息中你会看到当前的函数声明是缺少函数体的。
那么,函数体是什么时候被添加进来的呢?这个也不难,你仍然可以用调试器来找到答案。
从前面函数模板的AST中你已经知道函数体中包含了一个ConditionalOperator节点。所以我们可以故技重施在ConditionalOperator()上设置断点来等着。因为编译器要实例化函数体就一定会新创建一个ConditionalOperator节点。
事实证明,这个策略是成功的。程序会按照你的预期在这个断点停下,然后你会得到下面的调用栈:
研究这个调用栈,你会得到两个信息:
从函数模板实例化出具体的函数是被延后执行的程序是在即将解析完毕AST之后才去执行这项任务的。
Clang使用了TreeTransform这样的工具类自顶向下地遍历一棵子树来完成对AST的变换。
这样经过上述处理以后函数的特化才算最终完成。这个时候你再dump一下这个函数声明节点的信息就会发现它已经是一个完整的函数声明了。
好了到此为止你就知道了Clang对函数模板的处理过程。我再给你强调一下其中的关键步骤你需要好好掌握
在处理函数调用时,要去消解函数的引用,找到这个函数的定义;
如果有多个重载的函数,需要找到参数类型匹配的那个;
如果找不到符合条件的普通函数,那就去找函数模板;
找到函数模板后,推导出模板参数,也就是正确的数据类型;
之后,根据推导出的模板参数来生成一个具体的函数声明。
其中的关键点,是特化的过程。编译器总是要把模板做特化处理,然后才能被程序的其他部分使用。
抓住了这个关键点你还可以进一步在大脑中推演一下编译器是如何处理类模板的。然后你可以通过打印AST和跟踪执行这两个技术手段来验证你的想法。
不过模板技术可不仅仅能够支持函数模板和类模板它还有很多其他的能力。比如在第36讲我介绍元编程的时候曾经举过一个计算阶乘的例子。在那个例子中模板参数不是类型而是一个整数这样程序就可以在编译期实现对阶乘值的计算。
好了,现在你已经知道,对于类型参数,编译器的主要工作是进行类型推导和特化。
那么针对非类型参数,编译器是如何处理的呢?如何完成编译期的计算功能的呢?接下来,我们就一起来分析一下。
使用非类型模板参数
首先,你可以看看我新提供的这个示例程序,这个程序同样使用了模板技术,来计算阶乘值。
template<int n>
struct Fact {
static const int value = n*Fact<n-1>::value; //递归计算
};
template<>
struct Fact<1> {
static const int value =1; //当参数为1时阶乘值是1
};
int main(){
int a = Fact<3>::value; //在编译期就计算出阶乘值
}
在Fact这个结构体中value是一个静态的常量。在运行时你可以用Fact::value这样的表达式直接使用一个阶乘值不需要进行计算。而这个值其实是在编译期计算出来的。
那编译期具体的计算过程是怎样的呢?你可以像我们在前面研究函数模板那样如法炮制,马上就能探究清楚。
比如你可以先看一下示例程序在编译过程中形成的AST我在其中做了一些标注方便你理解
可以看到在AST中首先声明了一个结构体的模板其AST节点的类型是ClassTemplateDecl。
接着是针对这个模板做的特化。由于在main函数中引用了Fact::value所以编译器必须把Fact特化。特化的结果是生成了一棵ClassTemplateSpecializationDecl子树此时模板参数为3。而这个特化版本又引用了Fact::value。
那么编译器需要再把Fact特化。进一步这个特化版本又引用了Fact::value。
而Fact这个特化版本在程序中就已经提供了它的value字段的值是常数1。
那么经过这个分析过程Fact的值就可以递归地计算出来了。如果Fact<n>n的值更大那计算过程也是一样的。
Fact<3>::value = 3 * Fact<2>::value
= 3 * 2 * Fact<1>::value
= 3 * 2 * 1
另外你还可以用这节课中学到的debug方法跟踪一下上述过程验证一下你的想法。在这个过程中你仍然要注意设置最合适的断点。
课程小结
今天我们一起探讨了C++的模板机制的部分功能并借此了解了Clang编译C++程序的机制。通过这节课你会发现编译器是通过特化的机制来生成新的AST子树也就是生成新的程序从而支持模板机制的。另外你还要明确特化的过程是递归的直到不再有特化任务为止。
模板功能是一个比较复杂的功能。而你发现,当你有能力进到编译器的内部时,你会更快、更深刻地掌握模板功能的实质。这也是编译原理知识对于学习编程的帮助。
探究C++的编译器是一项有点挑战的工作。所以在这节课里我更关注的是如何带你突破障碍掌握探究Clang编译器的方法。这节课我只带你涉及了Clang编译器一个方面的功能你可以用这节课教给你的方法继续去探究你关心的其他特性是如何实现的可能会有很多惊喜的发现呢
一课一思
在计算阶乘的示例程序中当n是正整数时都是能够正常编译的。而当n是0或者负数时是不能正常编译的。你能否探究一下编译器是如何发现和处理这种类型的编译错误的呢
欢迎在留言区分享你的发现。如果你使用这节课的方法探究了C++编译器的其他特性,也欢迎你分享出来。

View File

@ -0,0 +1,166 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 CISC & RISC从何而来何至于此
你好我是LMOS。
这个专栏我会带你学习计算机基础。什么是基础?
基础就是根,从哪里来,到哪里去。而学习计算机基础,首先就要把握它的历史,这样才能了解计算机是怎么一步步发展到今天这个样子的,再根据今天的状况推导出未来的发展方向。
正所谓读历史方知进退,明兴衰。人类比其它动物高级的原因,就是人类能使用和发现工具。从石器时代到青铜器时代,再到铁器时代,都是工具种类和材料的发展,推动了文明升级。
让我们先从最古老的算盘开始聊起接着了解一下机械计算机、图灵机和电子计算机。最后我会带你一起看看芯片的发展尤其是它的两种设计结构——CISC与RISC。
从算盘到机械计算机
算盘就是一种辅助计算的工具,由中国古代劳动人民发明,迄今已有两千多年的历史,一直沿用至今。我准备了算盘的平面草图,你可以感受一下:
上图中周围一圈蓝色的是框架,一串一串的是算椽和算珠,一根算椽上有七颗算珠,可以上下拨动,从右至左有个、十、百……亿等计数位。有了算盘,计算的准确性和速度得到提高,我们从中可以感受到先辈的智慧。
与其说算盘是计算机,还不如说它是个数据寄存器。“程序”的执行需要人工实现,按口诀拨动算珠。过了两千多年,人们开始思考,能不能有一种机器,不需要人实时操作就能自动完成一些计算呢?
16世纪苏格兰人John Napier发表了论文提到他发明了一种精巧设备可以进行四则运算和解决方根运算。之后到了18世纪英国人Babbage设计了一台通用分析机。这期间还出现了计算尺等机械计算设备主要是利用轴、杠杆、齿轮等机械部件来做计算。
尤其是Babbage设计的分析机设计理论非常超前既有保存1000个50位数的“齿轮式储存室”用于运算的“运算室”还有发送和读取数据的部件以及负责在“存储室”、“运算室”运算运输数据的部件。具体的构思细节你有兴趣可以自行搜索资料探索。
一个多世纪之后现代电脑的结构几乎是Babbage分析机的翻版无非是主要部件替换成了大规模集成电路。仅此一点Babbage作为计算机系统设计的“开山鼻祖”就当之无愧。
值得一提的是Babbage设计分析机的过程里遇到了一位得力女助手——Ada。虽说两人的故事无从考证但Ada的功劳值得铭记她是为分析机编写程序计算三角函数的程序、伯努利函数程序等的第一人也是公认的世界上第一位软件工程师。
又过了一个世纪据说美国国防部花了十年光阴才把开发军事产品所需的全部软件功能都归纳整理到了一种计算机语言上期待它成为军方千种计算机的标准。1981年这种语言被正式命名为ADA语言。
可惜的是这种分析机需要非常高的机械工程制造技术后来政府停止了对他们的支持。尽管二人后来贫困潦倒Ada也在36岁就英年早逝但这两个人的思想和为计算机发展作出的贡献足以彪炳史册流芳百世。
图灵机
机械计算机有很多缺点,比如难于制造,难于维护,计算速度太慢,理论不成熟等。这些难题导致用机械计算机做通用计算的话,并不可取。
而真正奠定现代通用计算机理论的人在20世纪初横空出世他就是图灵图灵奖就是用他名字命名的。
图灵在计算可行性和人工智能领域贡献卓越,最重要的就是提出了图灵机。
图灵机的概念是怎么来的呢?图灵在他的《论可计算数及其在判定问题中的应用》一文中,全面分析了人的计算过程。他把计算提炼成最简单、基本、确定的动作,然后提出了一种简单的方法,用来描述机械性的计算程序,让任何程序都能对应上这些动作。
该方法以一个抽象自动机概念为基础,不但定义了什么“计算”,还首次将计算和自动机联系起来。这对后世影响巨大,而这种“自动机”后来就被我们称为“图灵机”。
图灵机是一个抽象的自动机数学模型,它是这样运转的:有一条无限长的纸带,纸带上有无限个小格子,小格子中写有相关的信息。纸带上有一个读头,读头能根据纸带小格子里的信息做相关的操作,并且能来回移动。
如果你感觉文字叙述还不够形象,我再来画一幅示意图:
我们不妨想象一下,把自己写的一条条代码,放入上图纸带的格子中,随着读头的读取代码做相应的动作。读头移动到哪一个,就会读取哪一格的代码,然后执行相应的顺序、跳转、循环动作,完成相应计算工作。
如果我们把读头及读头的运行规则理解为CPU把纸带解释为内存把纸带上信息理解为程序和数据那这个模型就非常接近现代计算机了。在我看来以最简单的方法抽象出统一的计算模型这就是图灵的伟大之处。
电子计算机
图灵机这种美好的抽象模型,如果没有好的实施方案,是做不出实际产品的,这将是一个巨大的遗憾。为此,人类进行了多次探索,可惜都没有结果。最后还是要感谢弗莱明和福雷斯特,尽管他们一个是英国人,一个是美国人。
这两个人本来没什么交集,不过后来福雷斯特在弗莱明的真空二极管里,加上了一个电极(一种栅栏式的金属网,形成电子管的第三个极),就获得了可以放大电流的新器件,他把这个新器件命名为三极管,也叫真空三极管。这个三极管提高了弗莱明的真空二极管的检波灵敏度。
不过,一个三极管虽然做不了计算机,但是许多个三极管组合起来形成的数字电路,就能够实现布尔代数中的逻辑运算,电子计算机的大门自此打开。
1946年ENIAC成功研制它诞生于美国宾夕法尼亚大学是世界上第一台真正意义上的电子计算机。
ENIAC占地面积约170平方米估计你在城里的房子也放不下这台机器。它有多达30个操作台重达30吨耗电量150千瓦。
别说屋子里放不下电费咱们也花不起。这台机器包含了17468根电子管和7200根晶体二极管1500个继电器6000多个开关等许多其它电子元件计算速度是每秒5000次加法或者400次乘法大约是人工计算速度的20万倍。
但是三极管也不是完美的,因为三极管的内部封装在一个抽成真空的玻璃管中,这种方案在当时是非常高级的,但是仍然不可靠,用不了多久就会坏掉了。电子计算机一般用一万多根三极管,坏了其中一根,查找和维护都极为困难。
直到1947年12月美国贝尔实验室的肖克利、巴丁和布拉顿组成的研究小组研制出了晶体管问题才得以解决。现在我们常说的晶体管通常指的是晶体三极管。
晶体三极管跟真空三极管功能一样,不过制造材料是半导体。它的特点在于响应速度快,准确性高,稳定性好,不易损坏。关键它可以做得非常小,一块集成电路即可容纳十几亿到几十亿个晶体管。
这样的器件用来做计算机就是天生的好材料。可以说,晶体管是后来几十年电子计算机飞速发展的基础。没有晶体管,我们简直不敢想像,计算机能做成今天这个样子。具体是如何做的呢?我们接着往下看。
芯片
让我们加点速迈入芯片时代。我们不要一提到芯片就只想到CPU。
CPU确实也是芯片中的一种但芯片是所有半导体元器件的统称它是把一定数量的常用电子元件如电阻、电容、晶体管等以及这些元件之间的连线通过半导体工艺集成在一起的、具有特定功能的电路。你也可以把芯片想成集成电路。
那芯片是如何实现集成功能的呢?
20世纪60年代人们把硅提纯切成硅片。想实现具备一定功能的电路离不开晶体管、电阻、电容等元件及它们之间的连接导线把这些集成到硅片上再经过测试、封装就成了最终的产品——芯片。相关的制造工艺氧化、光刻、粒子注入等极其复杂是人类的制造极限。
正因为出现了集成电路原先占地广、重量大的庞然大物才能集成于“方寸之间”。而且性能高出数万倍功耗缩小数千倍。随着制造工艺的升级现在指甲大小的晶片上集成数十亿个晶体管甚至在一块晶片上集成了CPU、GPU、NPU和内部总线等每秒钟可进行上10万亿次操作。在集成电路发展初期这样的这样的性能是不可想像的。
下面我们看看芯片中的特例——CPU它里面包括了控制部件和运算部件即中央处理器。1971年Intel将运算器和控制器集成在一个芯片上称为4004微处理器这标志着CPU的诞生。到了1978年开发的8086处理器奠定了X86指令集架构。此后8086系列处理器被广泛应用于个人计算机以及高性能服务器中。
那CPU是怎样运行的呢CPU的工作流程分为以下 5 个阶段:取指令、指令译码、执行指令、访存读取数据和结果写回。指令和数据统一存储在内存中,数据与指令需要从统一的存储空间中存取,经由共同的总线传输,无法并行读取数据和指令。这就是大名鼎鼎的冯诺依曼体系结构。
CPU运行程序会循环执行上述五个阶段它既是程序指令的执行者又被程序中相关的指令所驱动最后实现了相关的计算功能。这些功能再组合成相应算法然后由多种算法共同实现功能强大的软件。
既然CPU的工作离不开指令指令集架构就显得尤其重要了。
CISC
从前面的内容中我们已经得知CPU就是不断地执行指令来实现程序的执行最后实现相应的功能。但是一颗CPU能实现多少条指令每条指令完成多少功能却是值得细细考量的问题。
显然CPU的指令集越丰富、每个指令完成的功能越多为该CPU编写程序就越容易因为每一项简单或复杂的任务都有一条对应的指令不需要软件开发人员写大量的指令。这就是复杂指令集计算机体系结构——CISC。
CISC的典型代表就是x86体系架构x86 CPU中包含大量复杂指令集比如串操作指令、循环控制指令、进程任务切换指令等还有一些数据传输指令和数据运算指令它们包含了丰富的内存寻址操作。
有了这些指令工程师们编写汇编程序的工作量大大降低。CISC的优势在于用少量的指令就能实现非常多的功能程序自身大小也会下降减少内存空间的占用。但凡事有利就有弊这些复杂指令集包含的指令数量多而且功能复杂。
而想实现这些复杂指令离不开CPU运算单元和控制单元的电路硬件工程师要想设计制造这样的电路难度非常高。
到了20世纪80年代各种高级编程语言的出现大大简化了程序的开发难度。
高级语言编写的代码所对应的语言编译器很容易就能编译生成对应的CPU指令而且它们生成的多条简单指令跟原先CISC里复杂指令完成的功能等价。因此那些功能多样的复杂指令光环逐渐黯淡。
说到这里你应该也发现了在CPU发展初期CISC体系设计是合理的设计大量功能复杂的指令是为了降低程序员的开发难度。因为那个时代开发软件只能用汇编或者机器语言这等同于用硬件电路设计帮了软件工程师的忙。
随着软硬件技术的进步CISC的局限越来越突出因此开始出现了与CISC相反的设计。是什么设计呢我们继续往下看。
RISC
每个时代都有每个时代的产物。
20世纪80年代编译器技术的发展导致各种高级编程语言盛行。这些高级语言编译器生成的低级代码比程序员手写的低级代码高效得多使用的也是常用的几十条指令。
前面我说过文明的发展离不开工具的种类与材料升级。指令集的发展我们也可以照这个思路推演。芯片生产工艺升级之后人们在CPU上可以实现高速缓存、指令预取、分支预测、指令流水线等部件。
不过,这些部件的加入引发了新问题,那些一次完成多个功能的复杂指令,执行的时候就变得捉襟见肘,困难重重。
比如一些串操作指令同时依赖多个寄存器和内存寻址这导致分支预测和指令流水线无法工作。另外当时在IBM工作的John Cocke也发现计算机80%的工作由大约20%的CPU指令来完成这代表CISC里剩下的80%的指令都没有发挥应有的作用。
这些最终导致人们开始向CISC的反方向思考由此产生了RISC——精简指令集计算机体系结构。
正如它的名字一样RISC设计方案非常简约通常有20多条指令的简化指令集。每条指令长度固定由专用的加载和储存指令用于访问内存减少了内存寻址方式大多数运算指令只能访问操作寄存器。
而CPU中配有大量的寄存器这些指令选取的都是工程中使用频率最高的指令。由于指令长度一致功能单一操作依赖于寄存器这些特性使得CPU指令预取、分支预测、指令流水线等部件的效能大大发挥几乎一个时钟周期能执行多条指令。
这对CPU架构的设计和功能部件的实现也很友好。虽然完成某个功能要编写更多的指令程序的大小也会适当增加更占用内存。但是有了高级编程语言再加上内存容量的扩充这些已经不是问题。
RISC的代表产品是ARM和RISC-V。其实到了现在RISC与CISC早已没有明显界限开始互相融合了比如ARM中加入越来越多的指令x86 CPU通过译码器把一条指令翻译成多条内部微码相当于精简指令。x86这种外CISC内RISC的选择正好说明了这一点。
历史的车轮滚滚向前留下的都是经典历史也因此多彩而厚重今天的课程就到这里了我们要相信即便自己不能改写历史也能在历史上留下点什么。我们下一节课见下次我想继续跟你聊聊芯片行业的新贵RISC-V。
重点回顾
今天我们一起完成了一次“穿越之旅”从最早的算盘、机械计算机现代计算机雏形的图灵机一路讲到芯片和CPU的两种指令架构集。
其实仅仅一节课的时间,很难把计算机的历史一一道来,所以我选择了那些对计算机产生和演进最关键的事件或者技术,讲给你听。我把今天的重点内容为你梳理了一张思维导图。
-
有了这些线索,你就能在脑海里大致勾勒出,计算机是如何一步步变成今天的样子。技术发展的“接力棒”现在传到了我们这代人手里,我对未来的发展充满了期待。
就拿CPU的发展来说我觉得未来的CPU可能是多种不同指令集的整合一个CPU指令能执行多类型的指令分别完成不同的功能。不同类型的指令由不同的CPU功能组件来执行有的功能组件执行数字信号分析指令有的功能组件执行图形加速指令有的功能组件执行神经网络推算指令……
思考题
为什么RISC的CPU能同时执行多条指令
欢迎你在留言区跟我交流互动,如果觉得这节课讲得不错,也推荐你分享给身边的朋友。

View File

@ -0,0 +1,194 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 堆&栈:堆与栈的区别和应用
你好我是LMOS。
在上一课中我们讲了虚拟内存和物理内存明白了虚拟内存是一个假想的地址空间想要真正工作运行起来就必须要经过MMU把虚拟地址转换成物理地址寻址索引到真正的DRAM。
今天,我们继续深入到应用程序的虚拟内存地址空间中,弄清楚一个常规应用程序的虚拟内存地址空间中都有哪些东西。首先,我们看看里面的整体布局,然后看看里面的堆与栈,最后我还会重点带你了解一下堆与栈的区别和应用场景。
课程的配套代码你可以从这里下载。
应用程序的虚拟内存布局
你可以把应用程序的虚拟内存,想成一个房子。房子自然要有个合理的布局,有卧室、客厅、厨房这些不同的房间。同样地,应用程序的虚拟内存,承载着应用程序的指令、数据、资源等各种信息。
既然我们想要观察应用程序的虚拟内存布局,首先得有一个应用程序。当然,你也可以观察系统正在运行的应用程序,但是这些应用往往是很复杂的。
为了找到一个足够简单、又能说明问题的观察对象,我们还是自己动手写一个应用,代码如下所示:
#include "stdio.h"
#include "stdlib.h"
#include "unistd.h"
//下面变量来自于链接器
extern int __executable_start,etext, edata, __bss_start, end;
int main()
{
char c;
printf("Text段程序运行时指令数据开始:%p,结束:%p\n", &__executable_start, &etext);
printf("Data段程序运行时初始化全局变量和静态变量的数据开始:%p,结束:%p\n", &etext, &edata);
printf("Bss段程序运行时未初始化全局变量和静态变量的数据开始:%p,结束:%p\n", &__bss_start, &end);
while(1)
{
printf("(pid:%d)应用程序正在运行,请输入:c,退出\n", getpid());
printf("请输入:");
c = getchar();
if(c == 'c')
{
printf("应用程序退出\n");
return 0;
}
printf("%c\n", c);
}
return 0;
}
我来给你解释一下这个简单的应用程序开始的三个printf函数会输出该应用程序自身的三大段即Text段、Data段、Bss段的开始、结束地址这些地址由链接器产生都在应用程序的虚拟内存空间中。Text段、Data段、Bss段中包含了什么在代码里我已经做了说明只是Bss段并不在应用程序文件中占有空间而是操作系统加载应用程序时动态分配空间并将其初始化为0。
由于我们要观察应用程序在运行中的虚拟内存布局这就需要人为地控制应用程序退出而不是不直接运行完就退出导致我们没办法观察。所以我们要在一个死循环中输出应用程序对应进程的id和提示信息然后等待我们下一步的输入。如果输入c则退出否则输出信息继续循环。
你现在需要把这个应用程序编译并运行起来。其实这个工作并不复杂只需要进入对应的工程目录make一下再make run就可以把程序运行起来了。
要如何才能观察到应用程序的虚拟内存布局呢这在Windows下非常困难但是Linux对开发人员很友好它提供了一个proc文件系统这个目录下有所有应用程序进程的相关信息每个进程一个文件夹文件夹的名称就是进程的id这就是上述代码中要打印进程的pid的原因。
每个进程目录下包括一个maps和smaps文件后者更为详细我们只要用后面的命令读取它们就行了。
sudo cat /proc/59916/maps > main.map
#或者
sudo cat /proc/59916/smaps > main.map
上述命令是我机器上的情况59916是我运行程序后给出的pid上述命令就是把/proc/59916/maps 或者 smaps 读取输出到main.map文件中我们打开main.map文件看到的情况如下图所示
对照截图我们可以看到每一行都表示一个应用进程虚拟内存中的一个区段。第一列表示该区间的起始、结束虚拟地址。第二列是该区段的属性r代表读、w代表写、x代表执行、p代表私有。最后一列是该区段的内容属于哪个文件。
我们发现一个应用程序运行之后它的虚拟内存中不仅仅有它自身的指令和数据main.elf一共有5个区段包含了text、data、bss还有其它的文件内容比如共享动态链接库。共享动态链接库也是一种程序可以通过应用调用其功能接口。
同时,我们也注意到了后面要详细探索的堆、栈,我为你画幅图总结一下,如下所示:
应用程序自身的段,取决于编译器和链接器的操作,堆段、内存映射段、栈段、环境变量和命令行参数段,这取决于操作系统的定义。需要注意的是,堆段和栈段的大小都是动态增加和减少的、且增长方向相反。堆是向高地址方向增长,栈是向低地址方向增长。这就是一个应用程序被操作系统加载运行后的虚拟内存布局。
下面我们将重点关注堆和栈。我们经常把堆栈作为一个名词,连在一起说,但这其实并不准确。因为堆是堆而栈是栈,这是两个不同的概念,不可以混为一谈。
在计算机学科里heap是一类特殊的数据结构的统称我们通常把堆看作一棵树的数组对象。堆具备这样两个性质一是堆中某个结点的值总是不大于或不小于其父结点的值二是堆总是一棵完全二叉树。
不过,我们今天要关注的重点,是操作系统为应用程序建立的堆。所以这节课要探讨的“堆”,不具有数据结构中对堆定义的完整特性,你可以只把它看作一个可以动态增加和减少大小的数组对象。
简单点说堆就是应用程序在运行时刻调用malloc函数时动态分配的一块儿内存区域有了它就能满足应用程序在运行时动态分配内存空间从而存放数据的需求了。
你可以结合后面的示意图来理解。
由上图可以看出,堆其实是虚拟内存空间中一个段,由堆的开始地址和结束地址控制其大小,有一个堆指针指向未分配的第一个字节。所以,堆在本质上是指应用程序在运行过程中进行动态分配的内存区域,堆的管理通常在库函数中完成。
之所以叫做堆是因为通常会使用堆这种数据结构来管理分配出来的这块内存但也可以使用更简单的方法来管理下面让我们看看Linux是如何对堆区进行操作的。
关于如何得到上图右边的map文件可以参考前面应用程序虚拟内存布局的那部分内容。
上图代码中的sbrk函数是库函数它会调用Linux内核中的brk系统调用。这个brk系统调用用于增加或者减少进程的mm_struct中的堆区指针brk。
由于堆区指针始终指向未分配的堆区空间brk系统调用会首先保存当前的brk到临时的tmpbrk然后让当前brk加上传进来的大小赋给brk最后返回tmpbrk这样就实现了堆区内存的分配。你可以看到图中三次调用sbrk函数返回的地址确实落在应用程序的堆区内。
分配的地址也是从低到高这也验证了我们之前所说的堆的增长方向。你也可以自行阅读Linux内核中brk系统调用函数的代码进行考证尽管内核代码中的细节很多但核心逻辑和我们这里描述的相差无几。
堆也有界限,虽然可以调整,但却不能无限增加其大小。堆到底可以“占多大面积”,这取决于虚拟地址空间的大小和操作系统的定义。
在堆区分配内存速度很快,为什么呢?根据前面的信息可知,在堆区分配内存,只需要增加堆指针就行了,因此分配速度很快。由于实现分配的大小与请求分配大小是相同的、且地址也是连续的,所以它不会有内存碎片的情况。
但这个分配方式有一个致命的缺点释放堆区中的内存不会立即见效。比如上述代码中分配了alloc2之后释放alloc虽然这时currheap与alloc2之间有空闲内存这时也是不能分配的由此产生了内存空洞只有等alloc2也释放了内存空洞才会消失。
现在我们已经知道了操作系统为应用程序建立的堆,不同于数据结构中的堆。应用程序的堆区,不过是一个动态增加或减少的内存空间,用于应用程序动态分配内存,它的分配性能很好,但会产生内存空洞。
好,堆就说到这里,我们接下来去研究栈。
说到栈,你应该想到存储货物的仓库或者供旅客歇脚住宿的客栈,那么引入到计算机领域里,就是指数据暂时存储的地方,所以才有了后面的压栈、出栈的说法。
虽然应用程序的堆区和数据结构中的堆不是一回事儿但应用程序的栈区确实就是数据结构的那个栈。栈是支持程序运行的基本数据结构它跟计算机硬件比如CPU的栈指针寄存器、操作系统息息相关还跟编译器关系密切。
我们先来看看栈的本质是什么,再分析它怎么用。
栈作为一种数据结构,相当于只能在一端进行插入和删除操作的特殊线性表。它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后进入的数据在栈顶,需要读数据的时候从栈顶开始弹出数据,最后进入的数据会被首先读出来。
你可以把栈想象成一个桶,你往桶里压入东西就是压栈,你从桶里拿出东西就是出栈。但是要记住,你只能从桶的最上面开始拿,这就是栈。如下图所示:
由于CPU的硬件特性导致栈是从内存高地址向内存低地址增长的所以实际应用程序中的栈更像是一个倒立的桶栈其实也像一个反过来的堆。
栈有两个基本的操作:压栈和出栈,有时也称为压入和弹出。压入操作就是栈指针减去一个栈中对象的大小,然后将对象写入栈指针对应的内存空间中;而弹出是将栈指针指向的对象读出,然后将栈指针加上一个栈中对象的大小,从而指向栈中的前一个对象。
前面我们说过栈是和计算机硬件相关的那是因为CPU很多指令都依赖于栈例如x86 CPU的 call、ret、push、pop等指令push和pop是栈的压入和弹出指令call是函数调用指令它把下一条指令的地址压入栈中而ret指令则将call指令压入栈中的地址弹出实现函数返回。
栈还和编译器特别跟C语言编译器有关这是因为我们在函数中定义的局部变量就是放在栈中的。C语言编译器会生成额外的代码来为局部变量在栈中分配和释放空间自动处理各个变量的生命周期不需要程序员手动维护更不用担心局部变量导致内存泄漏因为C函数返回时会自己从栈中弹出变量。栈的先进后出的特性能保证被调用函数可以使用调用者函数的数据反过来就不行了。
另一个重点是函数的调用和返回也是依赖于栈所以C语言想要正常工作必须要有栈才行。下面我们写代码验证一下。
我们来写两个函数主要就是打印自身的三个局部变量的地址stacktest2函数被stacktest1函数调用而stacktest1函数最终会被main函数所调用。打印这些局部变量的地址是为了方便我们查看这些变量放在了内存的什么地方。代码如下
void stacktest2()
{
long val1 = 1;
long val2 = 2;
long val3 = 3;
printf("stacktest2运行时val1地址:%p val2地址:%p val3地址:%p\n", &val1, &val2, &val3);
return;
}
void stacktest1()
{
long val1 = 1;
long val2 = 2;
long val3 = 3;
printf("stacktest1运行时val1地址:%p val2地址:%p val3地址:%p\n", &val1, &val2, &val3);
stacktest2();
return;
}
按照前面的描述C函数的局部变量是放在栈中的现在我们运行这个程序看一看运行截图如下所示
由上图可以看出两个函数的三个变量都落在了应用程序的栈区我们可以用课程开头的命令得到图中的map文件就可以看到应用程序栈区的地址区间的范围了。
再结合前面说的栈区空间是从高地址向低地址方向增长继续分析。我们首先看到的是stacktest1函数的三个变量其地址从高到低每次会下降8个字节这就是因为long类型在64位系统上占用8字节的空间。然后是stacktest2函数的三个变量它们的地址要远低于stacktest1函数的三个变量的地址这是因为stacktest2函数是被stacktest1函数调用的。
现在我们已经知道了,栈是现代计算机运行不可缺少的基础数据结构。本质上,栈就是动态增长的内存空间,它遵守先进后出的原则,在此基础上就定义了两个操作:压入和弹出。
重点回顾
今天我们学习了应用虚拟内存布局。需要区分清楚的是,堆是堆、栈是栈,它们之间区别很大。理解了这节课,相信你也能清晰地把堆和栈的本质讲给身边的同学了。
现在我们来回顾一下这节课的重点内容。首先,我们从应用程序的虚拟内存空间布局出发,了解了应用程序虚拟内存空间中都有什么。除了程序自身的指令和数据,虚拟内存空间里包括有堆区、内存映射区、栈区、环境变量与命令行参数区。
然后,我们重点研究了堆,发现应用程序虚拟内存空间的堆区,跟数据结构里的堆并不是一回事儿,它只是一个可以从低地址向高地址动态增长的内存空间,用于应用程序动态分配内存。
最后我们探讨了栈。硬件、应用程序、高级语言编译器都需要栈。它是一种地址由高向低动态增长的内存空间并且定义了压栈、出栈两个操作遵守先进后出的原则。C语言的运行环境必须要有栈栈是现代计算机运行的基础数据结构。
这节课的导图如下,供你参考回顾。
思考题
你觉得堆、栈空间是虚拟内存空间吗?如果是,请问是在什么时候分配的物理内存呢?
期待你在留言区记录自己的思考或疑问,积极参与是提升学习效果的秘诀。如果觉得这节课不错,别忘了分享给更多朋友。

View File

@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐02 学习攻略(一):大数据&云计算,究竟怎么学?
你好我是LMOS。
上节课我带你了解了云计算中IAAS层的技术。结合云计算的分层架构下面一层就是PaaSPaaS与IaaS相似区别在于云服务提供商还提供了操作系统和数据库。
这节课我们就一起了解一下云计算PaaS层的大数据体系吧。什么是大数据呢其实这是早在1980年出版的图书《第三次浪潮》里就预见到的一种场景而具体到工程落地层面就不得不提到Google的“三驾马车”。
今天这节课,我想从需求角度,和你讨论一下在工程上为什么要这样设计。
GFS的核心问题
我们先从谷歌文件系统GFS开始说起。
顾名思义,这个系统是用来储存文件的。你可能觉得,存文件听起来好像不难呀?
我们可以仔细思考一下存文件会有什么难度呢先让我们停下手头的工作看看自己电脑上的硬盘空间还有多大500G还是1TB、5TB
没错,空间容量就是我们遇到的第一个门槛,单台电脑的存储空间确实不是无限大的。
接下来我们找出一份大一点的文件把它复制到另一个目录看看复制速度如何这里就碰到了第二个问题——文件写入速度。一般来说机械盘硬盘的最高写入速度是200MB/s左右而固态硬盘的写入速度是3000MB/s左右。
试想一下如果我们有1TB的数据写入硬盘就算真的有一块1TB空间的固态硬盘可以使用那我们也至少需要4天时间数据才能完全写入完毕。
还有一个生活中常见的问题你遇到过电脑故障、死机或者硬盘坏掉的情况么是的在普通PC机器运行的过程中故障其实是常态。你平常用家里的网络打网游时遇到过丢包、掉线、卡顿之类的情况么没错网络故障确实也是我们要考虑的问题。
那么到底怎样才能设计一套文件系统,同时满足以下条件呢:
容量“无限”大;
对大容量的数据读写性能高;
遇到软硬件问题时,系统可靠性也很高。
这里就要用到问题切分和并行化的思想了,这些我们在[第四十节课]也讲过。
比如想要解决文件比较大的问题,就我们可以考虑把它切分成很多份。切分完了之后,我们还得想到鸡蛋(文件)放在一个篮子里,遇到故障“全军覆没”的风险。为此,咱们就得多搞几台机器,多存几份呗。
还担心存的比较慢?那我们就把多个文件并行存储到不同的硬盘上,这样就不会受到磁盘写入速度的限制了。
说到这里,你现在是不是已经跃跃欲试,想要开始实现一套分布式文件系统啦?别着急,让我们先把刚刚讨论到的设计思路梳理一下:
首先为了不给使用者应用程序增加太多负担我们还是希望用户能像以前单机读写文件一样通过简单的API就能完成文件读写。这时候我们就需要抽象出一套统一的客户端client提供给用户使用。
其次是切分成很多份文件。GFS会把每一份文件叫做一个chunk这个chunk大小的默认值是64MB这比操作系统上的文件系统要大一些这么做为了减少GFS client和GFS master的交互次数、提升文件读取性能。同时为了保障可靠性GFS还会为每个chunk保留三个副本。
但是这里还有个问题没解决文件都切成很多份存到很多机器上了我们怎么知道哪一个chunk存到哪里去了呢这时候我们就需要把这种chunk分片文件映射到存储位置、原始文件名、权限之类的关联关系抽象出来我们把这类用来找数据的数据叫做元数据信息。
那么元数据存在哪里好一点?
聪明的你可能已经想到了我们可以给这些服务器分一下类让老大master带着小弟chunkserver来干活儿元数据比较重要所以咱们就交给老大来保管。有了这些思路相信你再看 GFS论文中的架构图时就会感觉清晰很多。
MapReduce的分分合合
接下来我们再说说MapReduce。
我们首先要搞清楚MapReduce是什么当看到MapReduce时你可能感觉它是一个概念但其实不然MapReduce应该是Map、Reduce是两个概念即映射和归约。
用软件实现这两个概念就会形成Map、Reduce两个操作落实到代码中可能是两个接口函数、或者库又或者是进程。我们可以把这些东西理解成一套编程模型。
那么什么是Map呢Map字面意思为映射但本质是拆分。
接下来我们以汽车为例看一下我们把一辆完好的汽车执行Map操作之后的状态如下图所示
从上图可以看出执行map操作时汽车首先作为输入然后标记出汽车的各种零部件最后汽车被拆分成各种零件。
现在。让我们切换一下视角把这辆汽车转换成用户的大规模数据于是就变成了对一个大数据进行标记然后拆分成许多小数据的过程这就是MapReduce中的Map操作。
什么又是Reduce呢Reduce的字面意思为归约是Map操作是逆向操作其本质是合并。同样地我们以汽车为例看看一辆被Map操作的汽车在Reduce的操作下会变成什么样子。如下图所示
我们可以看到执行Reduce操作时是之前把Map汽车产生的各个零件作为输入然后进行各种零部件的组装最后合并生成汽车或者是更高级的类汽车产品。
同样地把这辆汽车各种零部件换成用户Map后的各种小数据就相当于合并许多个小数据然后生成原来的大数据或者对数据进行更高级的处理这就是MapReduce中Reduce操作的作用。
我们刚刚把一台车子进行了一大波MapReduce操作这台车子就变成了变形金刚了哈哈。 举个例子理解了MapReduce的原理之后我们再来看一下它的六大步骤。
如果你是家大型汽车生产厂家, 你拥有许多不同类型的汽车设计方案Input还拥有许多汽车零件供应商不同的汽车零件供应商会主动挑选不同的汽车零件Split挑选好之后你就把汽车生产方案进行拆解Map
之后再把不同的零件下发到不同供应商的生产车间生产Shuffle最后要能根据不同的顾客需求取用不同的零件拼装成最终的汽车这就是Reduce。拼装好汽车之后会放到售卖部那边等待客户取货Ticket这个过程是Finalize。
所以MapReduce是六大过程简单来说就是 Input、Split、Map、Shuffle、Reduce和 Finalize。那么这六大步骤又是怎样被一套框架管理起来的呢答案其实还是老大Master带着小弟Worker干活。
下面我们结合MapReduce的架构图分析一下它的工作原理。
我们的用户程序要想使用MapReduce必须要链接MapReduce库。有了MapReduce库就可以进行Map、Reduce操作了。
用户程序运行后先声明数据有多少然后需要将它们拆分成一些Mapper和Reducer来执行。假如把数据分成n份那就要找n个Mapper来处理。这时会产生许多Worker这些Worker有的是执行Map操作的有的 Worker是执行Reduce操作的。
最重要的是还会产生一个 Master Worker它与其他Worker的等级是相同的它会调度其它Worker运行并作为用户的代理来协调整个过程让用户可以做其他事情。
Master Worker会让一个Worker去处理0号数据另一个Worker负责处理1号数据等等这就是分配数据的过程。每个Worker都会在本地处理数据并把结果写入缓存或硬盘。当执行Map操作的 Worker完成任务后Master Worker会让执行Reduce操作的Worker去获取数据。
他们会从各个Worker那里获取需要的数据并在本地完成Reduce操作最后将结果写入最终的文件中这就是Finalize。这个过程其实就是前面说过的六个步骤。
BigTable
最后一驾马车就是BigTable。在说它之前我们先聊聊表。
请和我一起思考一下什么是表呢为了更好理解我们可以抄出Excel这个神器来仔细认识一下表的基本构成
不难发现,表是由一个又一个的格子构成的,而每一个格子里的内容,又能通过行和列的坐标定位到。
这时候我们不妨联想一下,是不是我们只需要存储足够多的格子,就可以存储各种各样的表啦。那么光有行、列和格子里内容就足够了么?
并非如此别忘了格子里的内容还有可能会修改。比如上图中的B1单元格里的Linux版本需要从1.0.5更新到1.0.6,因此还需要记录格子的时间。
没错BigTable其实也是这样的思路BigTable把每个格子的数据都抽象成了Key Value的键值对的格式。其中key是由行row:string、列column:string、时间戳time:int64这三部分构成的而Value则是用string来存储的。
这样的Key Value数据结构有没有让你联想到什么其实它就类似于我们数据结构中常用的HashMap。但这个HashMap有点特殊因为它还要支持后面这几种功能
给定几个key能够快速返回小于或者等于某个key的那个数据。
给定key1和key2可以返回key3值中最高的数据。
key也可以只给前缀格式prefix返回所有符合前缀的值。
这个“HashMap”在读、写性能上都要相对比较好。
这个“HashMap”要能持久化因为数据不能丢。
有了上述功能的约束,你是不是感觉一时半会儿还真没想出来,要怎么设计这个数据结构?
其实Google已经把这个数据结构设计好了这个数据结构叫做SSTable具体实现确实有些复杂但好在有官方开源的单机实现——LevelDB。后面还有基于LevelDB演进升级的RocksDB也是一个不错的项目感兴趣的话可以自行了解。
现在我们有了把表化简成小格子再把每个格子使用Key Value结构存储到了单机的“HashMap”数据结构上。接下来我们还得想清楚如何让单机的“HashMap”数据结构变成可以分布式运算的。
这时候,我们就可以把前面这个思路做进一步抽象,你可以结合后面的示意图看一下,具体是抽象成了三层:
首先,对于每个表,我们都需要保存这个表的元数据。
其次如果随着数据增长表变得比较大了我们需要具备自动切分这张表的能力。切分表的最小单位我们叫做Tablet也就是说一张表会对应一个或多个Tablet。
具体到每一个Tablet我们是基于一个或多个单机的“HashMap”数据结构也就是SSTable来实现的而每一个SSTable中存储的又是一堆用Key Value格式表示的单元格。
对应到服务上我们又可以套用前面讲的老大带小弟干活主从架构的思路把一个或者多个Tablet交给Tablet Server这一类小弟服务来干活儿。而老大Master主要负责为Tablet服务器分配Tablets、检测新加入的或者过期失效的Tablet服务器、对Tablet服务器进行负载均衡、对保存在GFS上的文件做垃圾收集、处理和模式相关的修改操作比如建立表和列族
理清了思路,你再来看看后面这张架构图,是不是就很容易理解了呢?
小结
今天我们主要了解了现代云计算PAAS层中大数据体系的由来。其中最核心的就是谷歌的三驾马车即谷歌文件系统GFS、面向大型集群的简化数据处理MapReduce、BigtTable结构化数据的分布式存储系统。
GFSGoogle文件系统是一种分布式文件系统它为Google的大型数据处理应用提供了数据存储和访问功能MapReduce是一种编程模型它允许开发人员更方便地处理大量数据而BigTable是一种高性能的分布式存储系统它可以处理海量的结构化数据。
如果学过今天内容你还觉得意犹未尽想要更深入地学习这三种技术建议阅读谷歌相关的论文和文档并尝试去做一下mit 6.824分布式系统课程提供的课后练习。
思考题
推荐你在课后能搜索GFS、MapReduce、BigTable这三篇原始论文阅读一下结合今天学到的设计过程的思路进一步思考这么设计的优点和缺点分别是什么还有什么改进空间
欢迎你在评论区和我交流讨论,如果觉得这节课内容还不错,也可以转发给你的朋友,一起学习进步。

View File

@ -0,0 +1,192 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
加餐03 学习攻略(二):大数据&云计算,究竟怎么学?
你好我是LMOS。
上节课我们从谷歌的三驾马车开始,学习了大数据三件套的设计思路,可惜谷歌三驾马车作为商用软件,只开放了论文来讲解原理,却并没有开放出对应的源代码。
为了帮你更好地理解这些核心技术是怎么落地的,这节课我会简单介绍一下另外三个基础组件的设计原理,它们也是开源大数据生态中的重要角色。
HDFS设计原理
首先我们来说说HDFS它的全称是Hadoop Distributed File System你可以理解为一个可以由低成本的普通PC机组成的大规模分布式文件系统。
HDFS的架构图如下所示
其实HDFS的核心架构和[上节课]讲过的GFS架构思路是一脉相承的。
HDFS基于主/从架构设计其集群的核心是由NameNode充当主服务器、DataNode充当从服务器、Client这三部分构成的。各部分的含义和功能你可以参考后面这张表-
通过这几个组件的配合,我们就拥有了一个可靠的分布式文件系统。
那么HDFS有哪些优势呢主要是后面这四点
容错性:可以在集群中的任意节点发生故障时继续运行,这能保证数据的安全性。
大数据处理能力HDFS可以存储海量的数据并支持大规模并行计算。
高可靠性HDFS将文件分割成多个块存储并在集群中多次复制可以保证数据的高可靠性。
简单易用HDFS提供了简单易用的文件存储和访问接口与其他系统集成很方便。
但是HDFS也有一些不足具体包括
性能相对较低:不适合低延迟的数据访问。
不支持随机写入:不支持随机写入,只能进行顺序写入。
对小文件不友好:不能很好地存储小文件,因为它需要将小文件分割成大块存储,而这会导致存储和计算效率低下。
总之HDFS能够高效地存储海量数据并支持大规模并行计算。但是HDFS 不适合用于低延迟的数据访问,也不适合用于存储小文件。
说到这我们就不难推测HDFS的适用场景了——它适合用在海量数据存储和大规模数据分析的场景中例如搜索引擎、广告系统、推荐系统等。
YARN设计原理
其实早期Hadoop也按照Google Mapreduce的架构实现了一套Mapreduce的资源管理器用于管理和调度MapReduce任务所需要的资源。但是JobTracker存在单点故障它承受的访问压力也比较大这影响了系统的可扩展性。另外早期设计还不支持MapReduce之外的计算框架比如Spark、Flink
正是因为上述问题Hadoop才做出了YARN这个新的Hadoop资源管理器。YARN的全称是Yet Another Resource Negotiator让我们结合架构图了解一下它的工作原理。-
根据架构图可见YARN由ResourceManager、NodeManager、JobHistoryServer、Containers、Application Master、job、Task、Client组成。
YARN的架构图中的各个模块的功能你可以参考后面这张表格-
了解了每个模块大致的功能之后我们再看看YARN运行的基本流程吧-
到YARN运行主要是包括后面表格里的八个步骤。-
其实我们计算的每一个MapReduce的作业也都是通过这几步被YARN资源管理器调度到不同的机器上运行的。弄懂了YARN的工作原理对“Hadoop大数据生态下如何调度计算作业到不同容器做计算”这个问题你会更容易理解。
然而解决了存储和计算问题还不够。因为大数据生态下需要的组件非常多各种组件里还有很多需要同步、订阅或通知的状态信息。如果这些信息没有一个统一组件处理那整个分布式系统的运行都会失控这就不得不提到一个重要的协调组件——ZooKeeper了。
ZooKeeper设计原理
ZooKeeper集群中包含Leader、Follower以及Observer三个角色。
Leader负责进行投票的发起和决议更新系统状态Leader是由选举产生的。Follower用于接受客户端请求并向客户端返回结果在选主过程中会参与投票。
Observer的目的是扩展系统提高读取速度。Observer会从客户端接收请求并将结果返回给客户端。Observer可以接受客户端连接也可以接收读写请求并将写请求转发给Leader。但是Observer不参与投票过程只同步Leader的状态。
后面是ZooKeeper的架构图
-
在其核心Zookeeper使用原子广播来保持服务器同步。实现这种机制的协议称为Zab协议它包括恢复模式用于主选择和广播模式用于同步
当服务启动或leader崩溃后Zab协议进入恢复模式。恢复模式结束时leader已经当选大多数服务器已经同步完成leader的状态。这种状态同步可以确保leader和Server的系统状态相同。
为了保证事务序列的一致性ZooKeeper使用递增的事务IDzxid来标识事务。所有提案提交时都会附上zxid。Zxid为64位整数高32位表示领导人关系是否发生变化每选出一个领导者就会创建一个新的epoch表示当前领导人所属的统治时期低32位用于增量计数。
在工作期间,每个服务器都有三种状态:
LOOKING表示当前服务器不知道该领导者正在寻找他。
LEADING表示当前Server为已当选的leader。
FOLLOWING表示该leader已经当选当前Server正在与该leader同步。
通过这样一套可靠的一致性协议和架构设计Zookeeper把用户改变数据状态的操作抽象成了类似于对文件目录树的操作。这样就简化了分布式系统中数据状态协调的难度提高了分布式系统运行的稳定性和可靠性。
综合应用与环境搭建
学了这么多基础概念我们来挑战一个综合性问题。假设在一个大型Hadoop集群中你作为系统管理员需要解决这样一个问题——如何保证数据的安全性
你会如何解决呢使用哪些HDFS、YARN、ZooKeeper中的哪些功能为什么这样选择呢你可以自己先思考一下再听听我的想法。
为了保证数据的安全性我们可以使用HDFS的多副本机制来保存数据。在HDFS中我们可以将文件分成若干块存储在集群中的多个节点上并设置每个块的副本数量。这样即使某个节点出现故障也可以通过其他节点上的副本来恢复数据。
此外还可以利用YARN的资源管理功能来控制集群中节点的使用情况以避免资源过度使用导致的数据丢失。
最后我们还可以利用ZooKeeper的分布式锁功能来保证集群中只有一个节点可以访问某个文件。这样多个节点同时写入同一个文件造成的数据冲突也能够避免。
总的来说综合使用HDFS的多副本机制、YARN的资源管理功能以及Zookeeper的分布式锁功能可以帮我们有效保证数据的安全性。
接下来就让我们动手搭建一套大数据开发环境吧。大数据开发环境搭建一般环节比较多所以比较费时。为了节约部署时间提高开发效率我比较推荐使用Docker部署。
首先我们先安装好Docker和docker-compose。
要安装Docker一共要执行六步操作。第一步在终端中更新软件包列表
sudo apt update
第二步,安装依赖包:
sudo apt install apt-transport-https ca-certificates curl software-properties-common
第三步添加Docker的官方GPG密钥
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
第四步在系统中添加Docker的存储库
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
第五步更新软件包列表并安装Docker
sudo apt update
sudo apt install docker-ce
第六步启动Docker服务并将其设置为开机启动
sudo systemctl start docker
sudo systemctl enable docker
安装完Docker接下来我们来还需要执行两个步骤来安装 Docker Compose。首先我们要下载Docker Compose可执行文件代码如下
sudo curl -L "https://github.com/docker/compose/releases/download/1.27.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
第二步为Docker Compose可执行文件设置执行权限
sudo chmod +x /usr/local/bin/docker-compose
现在Docker和Docker Compose都安好了。为了确认安装是否成功可以使用后面的命令验证
docker --version
docker-compose --version
接下来,我们就可以启动大数据项目了。首先需要使用命令克隆仓库:
git clone https://github.com/spancer/bigdata-docker-compose.git
然后,我们打开项目目录运行下面的命令:
docker-compose up -d
等待项目启动成功我们就可以使用Hadoop生态的各个组件做更多的探索实验啦。
总结
这节课我们学到了开源大数据生态中的三个重要角色它们是Hadoop大数据平台的基础负责了文件存储、资源管理和分布式协调。
HDFS是Hadoop的分布式文件系统它可以将海量数据分布在集群中的多个节点上进行存储采用多副本机制保证数据安全。
YARN是Hadoop的资源管理系统负责调度任务并管理资源。
ZooKeeper是分布式协调服务提供分布式锁、队列、通知等功能常用于分布式系统的配置管理、分布式协调和集群管理。
了解了这些组件的原理之后我们还一起分析了一道综合应用题帮你加深理解。最后动手环节也必不可少利用Docker可以帮我们快速搭建一套大数据开发环境课后你有兴趣的话也推荐自己上手试试看。
欢迎你在留言区和我交流讨论,我们下节课见。

View File

@ -0,0 +1,137 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 效率为王:脚本与数据的解耦 + Page Object模型
在上一篇文章中我用Selenium 2.0实现了我们的第一个GUI自动化测试用例在你感觉神奇的同时是否也隐隐感到一丝丝的担忧呢比如测试脚本中既有测试数据又有测试操作所有操作都集中在一个脚本中等等。
那么今天我就通过介绍GUI测试中两个非常重要的概念测试脚本和数据的解耦以及页面对象Page Object模型带你看看如何优化这个测试用例。
测试脚本和数据的解耦
我在前面的文章中和你分享过GUI自动化测试适用的场景它尤其适用于需要回归测试页面功能的场景。那么你现在已经掌握了一些基本的GUI自动化测试用例的实现方法是不是正摩拳擦掌准备批量开发GUI自动化脚本把自己从简单、重复的GUI界面操作中解放出来呢
但是你很快就会发现如果在测试脚本中硬编码hardcode测试数据的话测试脚本灵活性会非常低。而且对于那些具有相同页面操作而只是测试输入数据不同的用例来说就会存在大量重复的代码。
举个最简单的例子上一篇文章中实现的百度搜索的测试用例当时用例中搜索的关键词是“极客时间”假设我们还需要测试搜索关键词是“极客邦”和“InfoQ”的场景如果不做任何处理那我们就可能需要将之前的代码复制3份每份代码的主体完全一致只是其中的搜索关键词和断言Assert的预期结果不同。
显然,这样的做法是低效的。
更糟糕的是界面有任何的变更需要修改自动化脚本时你之前复制出来的三个脚本都需要做相应的修改。比如搜索输入框的名字发生了变化你就需要修改所有脚本中findElement方法的by.name属性。
而这里只有三个脚本还好如果有30个或者更多的脚本呢你会发现脚本的维护成本实在是太高了。那么这种情况应该怎么处理呢
相信你现在已经想到了把测试数据和测试脚本分离。也就是说测试脚本只有一份其中需要输入数据的地方会用变量来代替然后把测试输入数据单独放在一个文件中。这个存放测试输入数据的文件通常是表格的形式也就是最常见的CSV文件。
然后在测试脚本中通过data provider去CSV文件中读取一行数据赋值给相应的变量执行测试用例。接着再去CSV文件中读取下一行数据读取完所有的数据后测试结束。CSV文件中有几行数据测试用例就会被执行几次。具体流程如图1所示。
图1 数据驱动测试的基本概念
这也就是典型的数据驱动Data-driven测试了。
数据驱动很好地解决了大量重复脚本的问题,实现了“测试脚本和数据的解耦”。 目前几乎所有成熟的自动化测试工具和框架都支持数据驱动的测试而且除了支持CSV这种最常见的数据源外还支持xls文件、JSON文件YAML文件甚至还有直接以数据库中的表作为数据源的比如QTP就支持以数据库中的表作为数据驱动的数据源。
数据驱动测试的数据文件中不仅可以包含测试输入数据,还可以包含测试验证结果数据,甚至可以包含测试逻辑分支的控制变量。 图1中的“Result_LoginSuccess_Flag”变量其实就是用户分支控制变量。
数据驱动测试的思想不仅适用于GUI测试还可以用于API测试、接口测试、单元测试等。 所以很多API测试工具比如SoapUI以及单元测试框架都支持数据驱动测试它们往往都是通过Test Data Provider模块将外部测试数据源逐条“喂”给测试脚本。
页面对象Page Object模型
为了让你了解“页面对象Page Object模型”这个概念的来龙去脉并能够深入理解这个概念的核心思想我会先从早期的GUI自动化测试开始讲起。
早期的GUI自动化测试脚本无论是用开源的Selenium开发还是用商用的QTPQuick Test Professional现在已经改名为Unified Functional Testing开发脚本通常是由一系列的页面控件的顺序操作组成的如图2所示的伪代码展示了一个典型的早期GUI测试脚本的结构。
图2 早期的GUI测试脚本伪代码示例
我先来简单介绍一下这个脚本实现的功能。
第1-4行输入用户名和密码并点击“登录”按钮登录完成后页面将跳转至新页面
第5行在新页面找到“图书”链接然后点击链接跳转至图书的页面
第7-10行在图书搜索框输入需要查找的书名点击“搜索”按钮然后通过assert验证搜索结果
第11-12行用户登出。
看完这段伪代码,你是不是觉得脚本有点像操作级别的“流水账”,而且可读性也比较差,这主要体现在以下几个方面:
脚本逻辑层次不够清晰属于All-in-one的风格既有页面元素的定位查找又有对元素的操作。
脚本的可读性差。 为了方便你理解示例中的代码用了比较直观的findElementByName你可以很方便地从name的取值比如“username”和“password”猜出脚本所执行的操作。-
但在实际代码中很多元素的定位都会采用Xpath、ID等方法此时你就很难从代码中直观看出到底脚本在操作哪个控件了。也就是说代码的可读性会更差带来的直接后果就是后期脚本的维护难度增大。-
有些公司自动化测试脚本的开发和维护是两拨人,脚本开发并调试完以后,开发人员就会把脚本移交给自动化测试执行团队使用并维护,这种情况下脚本的可读性就至关重要了。但即使是同一拨人维护,一段时间后,当时的开发人员也会遗忘某些甚至是大部分的开发步骤。
由于脚本的每一行都直接描述各个页面上的元素操作,你很难一眼看出脚本更高层的业务测试流程。 比如图2的业务测试流程其实就三大步用户登录、搜索书籍和用户登出但是通过阅读代码很难一下看出来。
通用步骤会在大量测试脚本中重复出现。 脚本中的某些操作集合在业务上是属于通用步骤比如上面伪代码的第1-4行完成的是用户登录操作第11-12行完成的是用户的登出操作。
这些通用的操作,会在其他测试用例的脚本中被多次重复。无论操作发生变动,还是页面控件的定位发生变化时,都需要同时修改大量的脚本。
其实我上面说到的这四点正是早期GUI自动化测试的主要问题这也是我一直说“开发几个GUI自动化测试玩玩会觉得很高效但是当你开发成百上千个GUI自动化测试的时候你会很痛苦”的本质含义。
那怎么解决这个问题呢?你可能已经想到了软件设计中模块化设计的思想。
没错就是利用模块化思想把一些通用的操作集合打包成一个个名字有意义的函数然后GUI自动化脚本直接去调用这些操作函数来构成整个测试用例这样GUI自动化测试脚本就从原本的“流水账”过渡到了“可重用脚本片段”。
如图3所示就是利用了模块化思想的伪代码。
图3 基于模块化的GUI测试用例伪代码示例
第1-6行就是测试用例非常简单直接一眼就可以看出测试用例具体在执行什么操作而各个操作函数的具体内部实现还是之前那些“流水账”。当然这里对于测试输入数据完全可以采用测试驱动方法这里为了直观我就直接硬编码了测试示例数据。
实际工程应用中第1-6行的测试用例和第8-30行的操作函数通常不会放在一个文件中因为操作函数往往会被很多测试用例共享。这种模块化的设计思想带来的好处包括
解决了脚本可读性差的问题,脚本的逻辑层次也更清晰了;
解决了通用步骤会在大量测试脚本中重复出现的问题, 现在操作函数可以被多个测试用例共享,当某个步骤的操作或者界面控件发生变化时,只要一次性修改相关的操作函数就可以了,而不需要去每个测试用例中逐个修改。
但是这样的设计并没有完全解决早期GUI自动化测试的主要问题比如每个操作函数内部的脚本可读性问题依然存在而且还引入了新的问题即如何把控操作函数的粒度以及如何衔接两个操作函数之间的页面。
关于这两个新引入的问题我会在后面的文章中为你详细阐述。我先来跟你聊聊怎么解决早期GUI自动化测试的“可读性差、难以维护”问题。
现在,操作函数的内部实现还只是停留在“既有页面元素的定位查找,又有对元素的操作”的阶段,当业务操作本身比较复杂或者需要跨多个页面时,“可读性差、难以维护”的问题就会暴露得更加明显了。
那么有什么更好的办法来解决这个问题吗答案就是我要分享的GUI自动化测试的第二个概念页面对象Page Object模型。
页面对象模型的核心理念是以页面Web Page 或者Native App Page为单位来封装页面上的控件以及控件的部分操作。而测试用例更确切地说是操作函数基于页面封装对象来完成具体的界面操作最典型的模式是“XXXPage.YYYComponent.ZZZOperation”。
基于这个思想上述用例的伪代码可以进化成如图4所示的结构。这里我只给出了login函数的伪代码建议你按照这种思路自己去实现一下search和logout的代码这样可以帮你更好的体会页面对象模型带来的变化。
图4 基于页面对象模型的伪代码示例
通过这样的代码结构,你可以清楚地看到是在什么页面执行什么操作,代码的可读性以及可维护性大幅度提高,也可以更容易地将具体的测试步骤转换成测试脚本。
总结
今天我给你讲了什么是数据驱动的测试让你明白了“测试脚本和数据解耦”的实现方式以及应用场景。接着从GUI自动化测试历史发展演变的角度引出了GUI测试中的“页面对象模型”的概念。
“测试脚本和数据解耦”的本质是实现了数据驱动的测试,让操作相同但是数据不同的测试可以通过同一套自动化测试脚本来实现,只是在每次测试执行时提供不同的测试输入数据。
“页面对象模型”的核心理念是,以页面为单位来封装页面上的控件以及控件的部分操作。而测试用例使用页面对象来完成具体的界面操作。
希望这篇文章可以让你更清楚地认识GUI自动化测试用例的逻辑以及结构。同时你可能已经发现这篇文章的内容并不是局限在某个GUI自动化测试框架上你可以把这些设计思想灵活地运用其他GUI自动化测试项目中这也是我希望达到的“授人以鱼不如授人以渔”。
思考题
我在文中有这样一段描述:页面对象模型的核心理念是,以页面为单位来封装页面上的控件以及控件的部分操作。但是,现在业界对“是否应该在页面对象模型中封装控件的操作”一直有不同的看法。
有些观点认为,可以在页面对象模型中封装页面控件的操作;而有些观点则认为,页面对象模型只封装控件,而操作应该再做一层额外的封装。
你更认同哪种观点呢,说说你的理由吧。
欢迎你给我留言。

View File

@ -0,0 +1,116 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 脑洞大开GUI测试还能这么玩Page Code Gen + Data Gen + Headless
在前面的几篇文章中我介绍了GUI自动化测试的数据驱动测试、页面对象Page Object模型、业务流程封装以及测试数据相关的内容。
今天这篇文章我将从页面对象自动生成、GUI测试数据自动生成、无头浏览器三个方面展开这也是GUI测试中三个比较有意思的知识点。
页面对象自动生成
在前面的文章中我已经介绍过页面对象Page Object模型的概念。页面对象模型是以Web页面为单位来封装页面上的控件以及控件的部分操作而测试用例基于页面对象完成具体操作。最典型的模式就是XXXPage.YYYComponent.ZZZOperation。
基于页面对象模型的伪代码示例如图1所示。
图1 基于页面对象模型的伪代码示例
如果你在实际项目中已经用过页面对象模型你会发现开发和维护页面对象的类Page Class是一件很耗费时间和体力的事儿。
你需要打开页面识别出可以唯一确定某元素的属性或者属性集合然后把它们写到Page Class里比如图1的第2行代码username_input=findElementByName(“username”)就是通过控件的名字username来定位元素的。
更糟糕的是GUI的页面会经常变动如果开发人员开发前端代码时没有严格遵循可测试性的要求Page Class的维护成本就会更高。
那么什么方法能够解决这个问题呢答案就是页面对象自动生成技术它非常适用于需要维护大量页面对象的中大型GUI自动化测试项目。
页面对象自动生成技术属于典型的“自动化你的自动化”的应用场景。它的基本思路是你不用再手工维护Page Class了只需要提供Web的URL它就会自动帮你生成这个页面上所有控件的定位信息并自动生成Page Class。
但是需要注意的是那些依赖于数据的动态页面对象也会被包含在自动生成的Page Class里而这种动态页面对象通常不应该包含在Page Class里所以往往需要以手工的方式删除。
目前很多商用自动化工具比如UFT已经支持页面对象自动生成功能了同时还能够对Page Class进行版本管理。
但是,开源的自动化方案,页面对象自动生成功能一般需要自己开发,并且需要与你所用的自动化测试框架深度绑定。目前,中小企业很少有自己去实现这一功能的。
不过有个好消息是目前国内应用还不算多、免费的Katalon Studio已经提供了类似的页面对象库管理功能如果感兴趣的话你可以去试用一下。
GUI测试数据自动生成
GUI测试数据自动生成指的由机器自动生成测试用例的输入数据。
乍一听上去是不是感觉有点玄乎?机器不可能理解你的业务逻辑,怎么可能自动生成测试数据呢?
你的这个想法完全合理,并且也是完全正确的。所以,我在这里说的“测试数据自动生成”,仅仅局限于以下两种情况:
根据GUI输入数据类型以及对应的自定义规则库自动生成测试输入数据。 比如GUI界面上有一个“书名”输入框它的数据类型是string。-
那么,基于数据类型就可以自动生成诸如 Null、SQL注入、超长字符串、非英语字符等测试数据。-
同时,根据自定义规则库,还可以根据具体规则生成各种测试数据。这个自定义规则库里面的规则,往往反映了具体的业务逻辑。比如,对于“书名”,就会有书名不能大于多少个字符、一些典型的书名(比如,英文书名、中文书名等)等等业务方面的要求,那么就可以根据这些业务要求来生成测试数据。-
根据自定义规则生成测试数据的核心思想与安全扫描软件AppScan基于攻击规则库自动生成和执行安全测试的方式有异曲同工之处。
对于需要组合多个测试输入数据的场景,测试数据自动生成可以自动完成多个测试数据的笛卡尔积组合,然后再以人工的方式剔除掉非法的数据组合。-
但是,这种方式并不一定是最高效的。对于输入参数比较多,且数据之间合法组合比较少或者难以明确的情况,先自动化生成笛卡尔积组合,再删除非法组合,效率往往还不如人为组合来得高。所以,在这个场景下是否要用测试数据自动生成方法,还需要具体问题具体分析。-
更常见的用法是,先手动选择部分输入数据进行笛卡尔积,并删除不合法的部分;然后,在此基础上,再人为添加更多业务上有意义的输入数据组合。-
比如输入数据有A、B、C、D、E、F六个参数你可以先选取最典型的几个参数生成笛卡尔积假设这里选取A、B和C然后在生成的笛卡尔积中删除业务上不合法的组合最后再结合D、E和F的一些典型取值构成更多的测试输入数据组合。
无头浏览器
无头浏览器即Headless Browser是一种没有界面的浏览器。
什么?浏览器没有界面,还叫什么浏览器啊?别急,我将为你一一道来。
无头浏览器其实是一个特殊的浏览器你可以把它简单地想象成是运行在内存中的浏览器。它拥有完整的浏览器内核包括JavaScript解析引擎、渲染引擎等。
与普通浏览器最大的不同是无头浏览器执行过程中看不到运行的界面但是你依然可以用GUI测试框架的截图功能截取它执行中的页面。
无头浏览器的主要应用场景包括GUI自动化测试、页面监控以及网络爬虫这三种。在GUI测试过程中使用无头浏览器的好处主要体现在四个方面
测试执行速度更快。 相对于普通浏览器来说无头浏览器无需加载CSS以及渲染页面在测试用例的执行速度上有很大的优势。
减少对测试执行的干扰。 可以减少操作系统以及其他软件(比如杀毒软件等)不可预期的弹出框,对浏览器测试的干扰。
简化测试执行环境的搭建。 对于大量测试用例的执行而言可以减少对大规模Selenium Grid集群的依赖GUI测试可以直接运行在无界面的服务器上。
在单机环境实现测试的并发执行。 可以在单机上很方便地运行多个无头浏览器,实现测试用例的并发执行。
但是,无头浏览器并不完美,它最大的缺点是,不能完全模拟真实的用户行为,而且由于没有实际完成页面的渲染,所以不太适用于需要对于页面布局进行验证的场景。同时,业界也一直缺乏理想的无头浏览器方案。
在Google发布Headless Chrome之前PhantomJS是业界主流的无头浏览器解决方案。但是这个项目的维护一直以来做得都不够好已知未解决的缺陷数量多达1800多个虽然支持主流的Webkit浏览器内核但是依赖的Chrome版本太低。所以无头浏览器一直难以在GUI自动化测试中大规模应用。
但好消息是2017年Google发布了Headless Chrome以及与之配套的Puppeteer框架Puppeteer不仅支持最新版本的Chrome而且得到Google官方的支持这使得无头浏览器可以在实际项目中得到更好的应用。
也正是这个原因PhantomJS的创建者Ariya Hidayat停止了它的后续维护Headless Chrome成了无头浏览器的首选方案。
那什么是Puppeteer呢Puppeteer是一个Node库提供了高级别的API封装这些API会通过Chrome DevTools Protocol与Headless Chrome的交互达到自动化操作的目的。
Puppeteer也是由Google开发的所以它可以很好地支持Headless Chrome以及后续Chrome的版本更新。
如果你也迫不及待地想要尝试把Headless Chrome应用到自己的GUI测试中那还等什么赶紧下载并开始吧。
总结
我分别介绍了无头浏览器、页面对象自动生成以及GUI测试数据自动生成这三个GUI测试中比较有意思的知识点包括它们的概念、应用场景等内容。
对于页面对象自动生成,商用测试软件已经实现了这个功能。但是,如果你选择开源测试框架,就需要自己实现这个功能了。
GUI测试数据自动生成主要是基于测试输入数据的类型以及对应的自定义规则库实现的并且对于多个测试输入数据可以基于笛卡尔积来自动组合出完整的测试用例集合。
对于无头浏览器你可以把它简单地想象成运行在内存中的浏览器它拥有完整的浏览器内核。与普通浏览器最大的不同是它在执行过程中看不到运行的界面。目前Headless Chrome结合Puppeteer是最先进的无头浏览器方案如果感兴趣你可以下载试用。
思考题
在你的工作中还有哪些好的方法和实践可以提高GUI自动化测试的效率吗
欢迎你给我留言。

View File

@ -0,0 +1,194 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 数据库文件系统实例MySQL 中 B 树和 B+ 树有什么区别?
这一讲给你带来的关联面试题是MySQL 中 B 树和 B+ 树的区别?
B 树和 B+ 树是两种数据结构(关于它们的名字为什么以 B 开头,因为众说纷纭,本讲我就不介绍了),构建了磁盘中的高速索引结构,因此不仅 MySQL 在用MongoDB、Oracle 等也在用,基本属于数据库的标配常规操作。
数据库要经常和磁盘与内存打交道,为了提升性能,通常需要自己去构建类似文件系统的结构。这一讲的内容有限,我只是先带你入一个门,如果你感兴趣后续可以自己深入学习。下面我们一起来探讨数据库如何利用磁盘空间设计索引。
行存储和列存储
在学习构建磁盘数据的索引结构前,我们先通过行存储、列存储的学习来了解一些基本的存储概念,帮助你建立一个基本的认知。
目前数据库存储一张表格主要是行存储Row Storage和列存储Column Storage两种存储方式。行存储将表格看作一个个记录每个记录是一行。以包含订单号、金额、下单时间 3 项的表为例,行存储如下图所示:
如上图所示在计算机中没有真正的行的概念。行存储本质就是数据一个接着一个排列一行数据后面马上跟着另一行数据。如果订单表很大一个磁盘块Block存不下那么实际上就是每个块存储一定的行数。 类似下图这样的结构:
行存储更新一行的操作往往可以在一个块Block中进行。而查询数据聚合数据比如求 4 月份的订单数往往需要跨块Block。因此行存储优点很明显更新快、单条记录的数据集中适合事务。但缺点也很明显查询慢。
还有一种表格的存储方式是列存储Column Storage列存储中数据是一列一列存的。还以订单表为例如下图所示
你可以看到订单号在一起、姓名在一起、时间在一起、金额也在一起——每个列的数据都聚集在一起。乍一看这样的结构很低效,比如说你想取出第一条订单,需要取第 1 列的第 1 个数据1001然后取第 2 列的第 1 个数据小明,以此类推,需要 4 次磁盘读取。特别是更新某一条记录的时候,需要更新多处,速度很慢。那么列存储优势在哪里呢?优势其实是在查询和聚合运算。
在列存储中同一列数据总是存放在一起,比如要查找某个时间段,很有可能在一个块中就可以找到,因为时间是集中存储的。假设磁盘块的大小是 4KB一条记录是 100 字节, 那么 4KB 可以存 40 条记录;但是存储时间戳只需要一个 32 位整数4KB 可以存储 1000 个时间。更关键的是,我们可以把一片连续的硬盘空间通过 DMA 技术直接映射到内存,这样就大大减少了搜索需要的时间。所以有时候在行存储需要几分钟的搜索操作,在列存储中只需几秒钟就可以完成。
总结一下,行存储、列存储,最终都需要把数据存到磁盘块。行存储记录一个接着一个,列存储一列接着一列。前面我们提到行存储适合更新及事务处理,更新好理解,因为一个订单可以在相同的 Block 中更新,那么为什么适合事务呢?
其实适合不适合是相对的,说行存储适合是因为列存储非常不适合事务。试想一下,你更新一个表的若干个数据,如果要在不同块中更新,就可能产生多次更新操作。更新次数越多,保证一致性越麻烦。在单机环境我们可以上锁,可以用阻塞队列,可以用屏障……但是分布式场景中保证一致性(特别是强一致性)开销很大。因此我们说行存储适合事务,而列存储不适合。
索引
接下来我们在行存储、列存储的基础上讨论如何创建一些更高效的查询结构这种结构通常称为索引。我们经常会遇到根据一个订单编号查订单的情况比如说select * from order where id=1000000这个时候就需要用到索引。而下面我将试图通过二分查找的场景和你一起讨论索引是什么。
在亿级的订单 ID 中查找某个编号,很容易想到二分查找。要理解二分查找,最需要关心的是算法的进步机制。这个算法每进行一次查找,都会让问题的规模减半。当然,也有场景限制,二分查找只能应用在排序好的数据上。
比如我们要在下面排序好的数组中查找 3
1,3,5,8,11,12,15,19,21,25
数组中一共有 10 个元素,因此我们第一次查找从数组正中间的元素找起。如果数组正中间有两个元素,就取左边的那个——对于这个例子是 11。我们比较 11 和 3 的值,因为 11 大于 3因此可以排除包括 11 在内的所有 11 右边的元素。相当于我们通过一次运算将数据的规模减半。假设我们有 240 1T 数据)个元素需要查询(规模已经相当大了,万亿级别),用二分查找只需要 40 次运算。
所以按照这个思路,我们需要做的是将数据按照订单 ID 排好序,查询的时候就可以应用二分查找了。而且按照二分查找的思路,也可以进行范围查找。比如要查找 [a,b] 之间的数据,可以先通过二分查找找到 a 的序号,再二分找到 b 的序号,两个序号之间的数据就是目标结果。
但是直接在原始数据上排序,我们可能会把数据弄乱,常规做法是设计冗余数据描述这种排序关系——这就是索引。下面我通过一个简单的例子告诉你为什么不能在原始数据上直接排序。
假设我们有一个订单表,里面有订单 ID 和金额。使用列存储做演示如下:
订单 ID 列:
10005 10001 ……
订单金额列:
99.00 100.00 ……
可以看到订单10001是第 2 个订单。但是进行排序后订单10001会到第 1 个位置。这样会弄乱订单 ID10001和 金额100.00)对应的关系。
因此我们必须用空间换时间,额外将订单列拷贝一份排序:
10001210005 1
以上这种专门用来进行数据查询的额外数据,就是索引。索引中的一个数据,也称作索引条目。上面的索引条目一个接着一个,每个索引条目是 <订单 ID, 序号> 的二元组。
如果你考虑是行存储(比如 MySQL那么依然可以生成上面的索引订单 ID 和序号(行号)关联。如果有多个索引,就需要创造多个上面的数据结构。如果有复合索引,比如 <订单状态日期序号> 作为一个索引条目,其实就是先按照订单状态,再按照日期排序的索引。
所以复合索引,无非就是多消耗一些空间,排序维度多一些。而且你可以看出复合索引和单列索引完全是独立关系,所以我们可以认为每创造一组索引,就创造了一份冗余的数据。也创造了一种特别的查询方式。关于索引还有很多有趣的知识,我们先介绍这些,如果感兴趣可以自己查资料深挖。
接下来,请分析一个非常核心的问题:上面的索引是一个连续的、从小到大的索引,那么应不应该使用这种从小到大排序的索引呢?例如,我们需要查询订单,就事先创建另一个根据订单 ID 从小到大排序的索引,当用户查找某个订单的时候,无论是行存储、还是列存储,我们就用二分查找查询对应的索引条目。这种方式,我们姑且称为线性排序索引——看似很不错的一个方式,但是并不是非常好的一种做法,请看我接下来的讨论。
二叉搜索树
线性排序的数据虽然好用,但是插入新元素的时候性能太低。如果是内存操作,插入一个元素,需要将这个元素之后的所有元素后移一位。但如果这个操作发生在磁盘中呢?这必然是灾难性的。因为磁盘的速度比内存慢至少 10-1000 倍,如果是机械硬盘可能慢几十万到百万倍。
所以我们不能用一种线性结构将磁盘排序。那么树呢? 比如二叉搜索树Binary Serach Tree行不行呢利用磁盘的空间形成一个二叉搜索树例如将订单 ID 作为二叉搜索树的 Key。
如下图所示,二叉搜索树的特点是一个节点的左子树的所有节点都小于这个节点,右子树的所有节点都大于这个节点。而且,因为索引条目较少,确实可以考虑在查询的时候,先将足够大的树导入内存,然后再进行搜索。搜索的算法是递归的,与二分查找非常类似,每次计算可以将问题规模减半。当然,具体有多少数据可以导入内存,受实际可以使用的内存数量的限制。
在上面的二叉搜索树中,每个节点的数据分成 Key 和 Value。Key 就是索引值,比如订单 ID 创建索引,那么 Key 就是订单 ID。值中至少需要序号对行存储也就是行号。这样如果们想找 18 对应的行,就可以先通过二叉搜索树找到对应的行号,然后再去对应的行读取数据。
二叉搜索树是一个天生的二分查找结构,每次查找都可以减少一半的问题规模。而且二叉搜索树解决了插入新节点的问题,因为二叉搜索树是一个跳跃结构,不必在内存中连续排列。这样在插入的时候,新节点可以放在任何位置,不会像线性结构那样插入一个元素,所有元素都需要向后排列。
那么回到本质问题,在使用磁盘的时候,二叉搜索树是不是一种合理的查询结构?
当然还不算,因此还需要继续优化我们的算法。二叉搜索树,在内存中是一个高效的数据结构。这是因为内存速度快,不仅可以随机存取,还可以高频操作。注意 CPU 缓存的穿透率只有 5% 左右,也就是 95% 的操作是在更快的 CPU 缓存中执行的。而且即便穿透,内存操作也是在纳秒级别可以完成。
但是,这个逻辑在磁盘中是不存在的,磁盘的速度慢太多了。我们可以尝试把尽可能多的二叉搜索树读入磁盘,但是如果数据量大,只能读入一部分呢?因此我们还需要继续改进算法。
B 树和 B+ 树
二叉搜索树解决了连续结构插入新元素开销很大的问题,同时又保持着天然的二分结构。但是,当需要索引的数据量很大,无法在一个磁盘 Block 中存下整棵二叉搜索树的时候。每一次递归向下的搜索,实际都是读取不同的磁盘块。这个时候二叉搜索树的开销很大。
试想一个一万亿条订单的表,进行 40 次查找找到答案,在内存中不是问题,要考虑到 CPU 缓存有 90% 以上的命中率(当然前提是内存足够大)。通常情况下我们没有这么大的内存空间,如果 40 次查找发生在磁盘上,也是非常耗时的。那么有没有更好的方案呢?
一个更好的方案,就是继续沿用树状结构,利用好磁盘的分块让每个节点多一些数据,并且允许子节点也多一些,树就会更矮。因为树的高度决定了搜索的次数。
上图中我们构造的树被称为 B 树B-Tree开头说过B 这个字母具体是哪个单词或者人名的缩写,至今有争议,具体你可以查查资料。
B-Tree 是一种递归的搜索结构与二叉搜索树非常类似。不同的是B 树中的父节点中的数据会对子树进行区段分割。比如上图中节点 1 有 3 个子节点,并用数字 9,30 对子树的区间进行了划分。
上图中的 B 树是一个 3-4 B 树3 指的是每个非叶子节点允许最大 3 个索引4 指的是每个节点最多允许 4 个子节点4 也指每个叶子节点可以存 4 个索引。上面只是一个例子,在实际的操作中,子节点有几十个、甚至上百个索引也很常见,因为我们希望树变矮,好减少磁盘操作。
B 树的每个节点是一个索引条目(例如:一个 <订单 ID序号> 的组合),如果是行数据库可以索引到一条存储在磁盘上的记录。
继承 B 树B+ 树
为了达到最高的效率,实战中我们往往使用的是一种继承于 B 树设计的结构,称为 B+ 树。B+ 树只有叶子节点才映射数据,下图中是对 B 树设计的一种改进,节点 1 为冗余节点,它不存储数据,只划定子树数据的范围。你可以看到节点 1 的索引 Key12 和 30在节点 3 和 4 中也有一份。
树的形成:插入
下面我以一棵 2-3 B+ 树来演示 B+ 树的插入过程。2 指的是 B+ 树每个非叶子节点允许 2 个数据,叶子节点最多允许 3 个索引,每个节点允许最多 3 个子节点。我们要在 2-3 B+ 树中依次插入 3,6,9,12,19,15,26,8,30。下图是演示
插入 3,6,9 过程很简单,都写入一个节点即可,因为叶子节点最多允许每个 3 个索引。接下来我们插入 12会发生一次过载然后节点就需要拆分这个时候按照 B+ 树的设计会产生冗余节点。
然后插入 15 非常简单,直接加入即可:
接下来插入 19 这个时候下图中红色部分发生过载:
因此需要拆分节点数据我们从中间把红色的节点拆开15 作为冗余的索引写入父节点,就形成下图的情况:
接着插入 26 写入到对应位置即可。
接下来,插入 8 到对应位置即可。
然后我们插入 30此时右边节点发生过载
解决完一次过载问题之后,因为 26 会浮上去,根节点又发生了过载:
再次解决过载,拆分红色部分,得到最后结果:
在上述过程中B+ 树始终可以保持平衡状态,而且所有叶子节点都在同一层级。更复杂的数学证明,我就不在这里讲解了。不过建议对算法感兴趣对同学,可以学习《算法导论》中关于树的部分。
插入和删除效率
B+ 树有大量的冗余节点比如删除一个节点的时候可以直接从叶子节点中删除甚至可以不动非叶子节点。这样删除非常快。B 树则不同B 树没有冗余节点,删除节点的时候非常复杂。比如删除根节点中的数据,可能涉及复杂的树的变形。
B+ 树的插入也是一样,有冗余节点,插入可能存在节点的拆分(如果节点饱和),但是最多只涉及树的一条路径。而且 B+ 树会自动平衡,不需要更多复杂的算法,类似红黑树的旋转操作等。
因此B+ 树的插入和删除效率更高。
搜索:链表的作用
B 树和 B+ 树搜索原理基本一致。先从根节点查找,然后对比目标数据的范围,最后递归的进入子节点查找。
你可能会注意到B+ 树所有叶子节点间还有一个链表进行连接。这种设计对范围查找非常有帮助,比如说我们想知道 1 月 20 日和 1 月 22 日之间的订单,这个时候可以先查找到 1 月 20 日所在的叶子节点,然后利用链表向右遍历,直到找到 1 月22 日的节点。这样我们就进一步节省搜索需要的时间。
总结
这一讲我们学习了在数据库中如何利用文件系统造索引。无论是行存储还是列存储,构造索引的过程都是类似的。索引有很多做法,除了 B+ 树,还有 HashTable、倒排表等。如果是存储海量数据的数据库我们的思考点需要放在 I/O 的效率上。如果把今天的知识放到分布式数据库上,那除了需要节省磁盘读写还需要节省网络 I/O。
那么通过这一讲的学习你现在可以尝试来回答本讲关联的面试题目MySQL 中的 B 树和 B+ 树有什么区别?
【解析】B+ 树继承于 B 树都限定了节点中数据数目和子节点的数目。B 树所有节点都可以映射数据B+ 树只有叶子节点可以映射数据。
单独看这部分设计,看不出 B+ 树的优势。为了只有叶子节点可以映射数据B+ 树创造了很多冗余的索引(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,而且可以自动平衡,因此 B+ 树的所有叶子节点总是在一个层级上。所以 B+ 树可以用一条链表串联所有的叶子节点,也就是索引数据,这让 B+ 树的范围查找和聚合运算更快。

View File

@ -0,0 +1,368 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 数据结构Vec_T_、&[T]、Box_[T]_ ,你真的了解集合容器么?
16数据结构Vec、&[T]、Box<[T]> ,你真的了解集合容器么?
你好,我是陈天。今天来学集合容器。
现在我们接触到了越来越多的数据结构,我把 Rust 中主要的数据结构从原生类型、容器类型和系统相关类型几个维度整理一下,你可以数数自己掌握了哪些。-
-
可以看到,容器占据了数据结构的半壁江山。
提到容器,很可能你首先会想到的就是数组、列表这些可以遍历的容器,但其实只要把某种特定的数据封装在某个数据结构中,这个数据结构就是一个容器。比如 Option它是一个包裹了 T 存在或不存在的容器而Cow 是一个封装了内部数据 B 或被借用或拥有所有权的容器。
对于容器的两小类,到目前为止,像 Cow 这样,为特定目的而产生的容器我们已经介绍了不少,包括 Box、Rc、Arc、RefCell、还没讲到的 Option 和 Result 等。
今天我们来详细讲讲另一类,集合容器。
集合容器
集合容器,顾名思义,就是把一系列拥有相同类型的数据放在一起,统一处理,比如:
我们熟悉的字符串 String、数组 [T; n]、列表 Vec和哈希表 HashMap等
虽然到处在使用,但还并不熟悉的切片 slice
在其他语言中使用过,但在 Rust 中还没有用过的循环缓冲区 VecDeque、双向列表 LinkedList 等。
这些集合容器有很多共性,比如可以被遍历、可以进行 map-reduce 操作、可以从一种类型转换成另一种类型等等。
我们会选取两类典型的集合容器:切片和哈希表,深入解读,理解了这两类容器,其它的集合容器设计思路都差不多,并不难学习。今天先介绍切片以及和切片相关的容器,下一讲我们学习哈希表。
切片究竟是什么?
在 Rust 里,切片是描述一组属于同一类型、长度不确定的、在内存中连续存放的数据结构,用 [T] 来表述。因为长度不确定,所以切片是个 DSTDynamically Sized Type
切片一般只出现在数据结构的定义中,不能直接访问,在使用中主要用以下形式:
&[T]:表示一个只读的切片引用。
&mut [T]:表示一个可写的切片引用。
Box<[T]>:一个在堆上分配的切片。
怎么理解切片呢?我打个比方,切片之于具体的数据结构,就像数据库中的视图之于表。你可以把它看成一种工具,让我们可以统一访问行为相同、结构类似但有些许差异的类型。
来看下面的代码,辅助理解:
fn main() {
let arr = [1, 2, 3, 4, 5];
let vec = vec![1, 2, 3, 4, 5];
let s1 = &arr[..2];
let s2 = &vec[..2];
println!("s1: {:?}, s2: {:?}", s1, s2);
// &[T] 和 &[T] 是否相等取决于长度和内容是否相等
assert_eq!(s1, s2);
// &[T] 可以和 Vec<T>/[T;n] 比较,也会看长度和内容
assert_eq!(&arr[..], vec);
assert_eq!(&vec[..], arr);
}
对于 array 和 vector虽然是不同的数据结构一个放在栈上一个放在堆上但它们的切片是类似的而且对于相同内容数据的相同切片比如 &arr[1…3] 和 &vec[1…3],这两者是等价的。除此之外,切片和对应的数据结构也可以直接比较,这是因为它们之间实现了 PartialEq trait源码参考资料
下图比较清晰地呈现了切片和数据之间的关系:
另外在 Rust 下,切片日常中都是使用引用 &[T],所以很多同学容易搞不清楚 &[T] 和 &Vec 的区别。我画了张图,帮助你更好地理解它们的关系:
在使用的时候,支持切片的具体数据类型,你可以根据需要,解引用转换成切片类型。比如 Vec 和 [T; n] 会转化成为 &[T],这是因为 Vec 实现了 Deref trait而 array 内建了到 &[T] 的解引用。我们可以写一段代码验证这一行为(代码):
use std::fmt;
fn main() {
let v = vec![1, 2, 3, 4];
// Vec 实现了 Deref&Vec<T> 会被自动解引用为 &[T],符合接口定义
print_slice(&v);
// 直接是 &[T],符合接口定义
print_slice(&v[..]);
// &Vec<T> 支持 AsRef<[T]>
print_slice1(&v);
// &[T] 支持 AsRef<[T]>
print_slice1(&v[..]);
// Vec<T> 也支持 AsRef<[T]>
print_slice1(v);
let arr = [1, 2, 3, 4];
// 数组虽没有实现 Deref但它的解引用就是 &[T]
print_slice(&arr);
print_slice(&arr[..]);
print_slice1(&arr);
print_slice1(&arr[..]);
print_slice1(arr);
}
// 注意下面的泛型函数的使用
fn print_slice<T: fmt::Debug>(s: &[T]) {
println!("{:?}", s);
}
fn print_slice1<T, U>(s: T)
where
T: AsRef<[U]>,
U: fmt::Debug,
{
println!("{:?}", s.as_ref());
}
这也就意味着通过解引用这几个和切片有关的数据结构都会获得切片的所有能力包括binary_search、chunks、concat、contains、start_with、end_with、group_by、iter、join、sort、split、swap 等一系列丰富的功能,感兴趣的同学可以看切片的文档。
切片和迭代器 Iterator
迭代器可以说是切片的孪生兄弟。切片是集合数据的视图,而迭代器定义了对集合数据的各种各样的访问操作。
通过切片的 iter() 方法,我们可以生成一个迭代器,对切片进行迭代。
在[第12讲]Rust类型推导已经见过了 iterator trait用 collect 方法把过滤出来的数据形成新列表。iterator trait 有大量的方法,但绝大多数情况下,我们只需要定义它的关联类型 Item 和 next() 方法。
Item 定义了每次我们从迭代器中取出的数据类型;
next() 是从迭代器里取下一个值的方法。当一个迭代器的 next() 方法返回 None 时,表明迭代器中没有数据了。
#[must_use = “iterators are lazy and do nothing unless consumed”]
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
// 大量缺省的方法,包括 size_hint, count, chain, zip, map,
// filter, for_each, skip, take_while, flat_map, flatten
// collect, partition 等
...
}
看一个例子,对 Vec 使用 iter() 方法,并进行各种 map/filter/take 操作。在函数式编程语言中这样的写法很常见代码的可读性很强。Rust 也支持这种写法(代码):
fn main() {
// 这里 Vec<T> 在调用 iter() 时被解引用成 &[T],所以可以访问 iter()
let result = vec![1, 2, 3, 4]
.iter()
.map(|v| v * v)
.filter(|v| *v < 16)
.take(1)
.collect::<Vec<_>>();
println!("{:?}", result);
}
需要注意的是 Rust 下的迭代器是个懒接口lazy interface也就是说这段代码直到运行到 collect 时才真正开始执行,之前的部分不过是在不断地生成新的结构,来累积处理逻辑而已。你可能好奇,这是怎么做到的呢?
在 VS Code 里,如果你使用了 rust-analyzer 插件,就可以发现这一奥秘:-
原来Iterator 大部分方法都返回一个实现了 Iterator 的数据结构,所以可以这样一路链式下去,在 Rust 标准库中,这些数据结构被称为 Iterator Adapter。比如上面的 map 方法,它返回 Map 结构,而 Map 结构实现了 Iterator源码
整个过程是这样的(链接均为源码资料):
在 collect() 执行的时候,它实际试图使用 FromIterator 从迭代器中构建一个集合类型,这会不断调用 next() 获取下一个数据;
此时的 Iterator 是 TakeTake 调自己的 next(),也就是它会调用 Filter 的 next()
Filter 的 next() 实际上调用自己内部的 iter 的 find(),此时内部的 iter 是 Mapfind() 会使用 try_fold(),它会继续调用 next(),也就是 Map 的 next()
Map 的 next() 会调用其内部的 iter 取 next() 然后执行 map 函数。而此时内部的 iter 来自 Vec。
所以,只有在 collect() 时,才触发代码一层层调用下去,并且调用会根据需要随时结束。这段代码中我们使用了 take(1),整个调用链循环一次,就能满足 take(1) 以及所有中间过程的要求,所以它只会循环一次。
你可能会有疑惑:这种函数式编程的写法,代码是漂亮了,然而这么多无谓的函数调用,性能肯定很差吧?毕竟,函数式编程语言的一大恶名就是性能差。
这个你完全不用担心, Rust 大量使用了 inline 等优化技巧,这样非常清晰友好的表达方式,性能和 C 语言的 for 循环差别不大。如果你对性能对比感兴趣,可以去最后的参考资料区看看。
介绍完是什么,按惯例我们就要上代码实际使用一下了。不过迭代器是非常重要的一个功能,基本上每种语言都有对迭代器的完整支持,所以只要你之前用过,对此应该并不陌生,大部分的方法,你一看就能明白是在做什么。所以这里就不再额外展示,等你遇到具体需求时,可以翻 Iterator 的文档查阅。
如果标准库中的功能还不能满足你的需求,你可以看看 itertools它是和 Python 下 itertools 同名且功能类似的工具,提供了大量额外的 adapter。可以看一个简单的例子代码
use itertools::Itertools;
fn main() {
let err_str = "bad happened";
let input = vec![Ok(21), Err(err_str), Ok(7)];
let it = input
.into_iter()
.filter_map_ok(|i| if i > 10 { Some(i * 2) } else { None });
// 结果应该是vec![Ok(42), Err(err_str)]
println!("{:?}", it.collect::<Vec<_>>());
}
在实际开发中,我们可能从一组 Future 中汇聚出一组结果,里面有成功执行的结果,也有失败的错误信息。如果想对成功的结果进一步做 filter/map那么标准库就无法帮忙了就需要用 itertools 里的 filter_map_ok()。
特殊的切片:&str
好,学完了普通的切片 &[T],我们来看一种特殊的切片:&str。之前讲过String 是一个特殊的 Vec所以在 String 上做切片,也是一个特殊的结构 &str。
对于 String、&String、&str很多人也经常分不清它们的区别我们在之前的一篇加餐中简单聊了这个问题在上一讲智能指针中也对比过String和&str。对于&String 和 &str如果你理解了上文中 &Vec 和 &[T] 的区别,那么它们也是一样的:
String 在解引用时,会转换成 &str。可以用下面的代码验证代码
use std::fmt;
fn main() {
let s = String::from("hello");
// &String 会被解引用成 &str
print_slice(&s);
// &s[..] 和 s.as_str() 一样,都会得到 &str
print_slice(&s[..]);
// String 支持 AsRef<str>
print_slice1(&s);
print_slice1(&s[..]);
print_slice1(s.clone());
// String 也实现了 AsRef<[u8]>,所以下面的代码成立
// 打印出来是 [104, 101, 108, 108, 111]
print_slice2(&s);
print_slice2(&s[..]);
print_slice2(s);
}
fn print_slice(s: &str) {
println!("{:?}", s);
}
fn print_slice1<T: AsRef<str>>(s: T) {
println!("{:?}", s.as_ref());
}
fn print_slice2<T, U>(s: T)
where
T: AsRef<[U]>,
U: fmt::Debug,
{
println!("{:?}", s.as_ref());
}
有同学会有疑问:那么字符的列表和字符串有什么关系和区别?我们直接写一段代码来看看:
use std::iter::FromIterator;
fn main() {
let arr = ['h', 'e', 'l', 'l', 'o'];
let vec = vec!['h', 'e', 'l', 'l', 'o'];
let s = String::from("hello");
let s1 = &arr[1..3];
let s2 = &vec[1..3];
// &str 本身就是一个特殊的 slice
let s3 = &s[1..3];
println!("s1: {:?}, s2: {:?}, s3: {:?}", s1, s2, s3);
// &[char] 和 &[char] 是否相等取决于长度和内容是否相等
assert_eq!(s1, s2);
// &[char] 和 &str 不能直接对比,我们把 s3 变成 Vec<char>
assert_eq!(s2, s3.chars().collect::<Vec<_>>());
// &[char] 可以通过迭代器转换成 StringString 和 &str 可以直接对比
assert_eq!(String::from_iter(s2), s3);
}
可以看到,字符列表可以通过迭代器转换成 StringString 也可以通过 chars() 函数转换成字符列表,如果不转换,二者不能比较。
下图我把数组、列表、字符串以及它们的切片放在一起比较,可以帮你更好地理解它们的区别:
切片的引用和堆上的切片,它们是一回事么?
开头我们讲过,切片主要有三种使用方式:切片的只读引用 &[T]、切片的可变引用 &mut [T] 以及 Box<[T]>。刚才已经详细学习了只读切片 &[T],也和其他各种数据结构进行了对比帮助理解,可变切片 &mut [T] 和它类似,不必介绍。
现在我们来看看 Box<[T]>。
Box<[T]> 是一个比较有意思的存在,它和 Vec 有一点点差别Vec 有额外的 capacity可以增长而 Box<[T]> 一旦生成就固定下来,没有 capacity也无法增长。
Box<[T]>和切片的引用&[T] 也很类似它们都是在栈上有一个包含长度的胖指针指向存储数据的内存位置。区别是Box<[T]> 只会指向堆,&[T] 指向的位置可以是栈也可以是堆此外Box<[T]> 对数据具有所有权,而 &[T] 只是一个借用。
那么如何产生 Box<[T]> 呢?目前可用的接口就只有一个:从已有的 Vec 中转换。我们看代码:
use std::ops::Deref;
fn main() {
let mut v1 = vec![1, 2, 3, 4];
v1.push(5);
println!("cap should be 8: {}", v1.capacity());
// 从 Vec<T> 转换成 Box<[T]>,此时会丢弃多余的 capacity
let b1 = v1.into_boxed_slice();
let mut b2 = b1.clone();
let v2 = b1.into_vec();
println!("cap should be exactly 5: {}", v2.capacity());
assert!(b2.deref() == v2);
// Box<[T]> 可以更改其内部数据,但无法 push
b2[0] = 2;
// b2.push(6);
println!("b2: {:?}", b2);
// 注意 Box<[T]> 和 Box<[T; n]> 并不相同
let b3 = Box::new([2, 2, 3, 4, 5]);
println!("b3: {:?}", b3);
// b2 和 b3 相等,但 b3.deref() 和 v2 无法比较
assert!(b2 == b3);
// assert!(b3.deref() == v2);
}
运行代码可以看到Vec 可以通过 into_boxed_slice() 转换成 Box<[T]>Box<[T]> 也可以通过 into_vec() 转换回 Vec。
这两个转换都是很轻量的转换,只是变换一下结构,不涉及数据的拷贝。区别是,当 Vec 转换成 Box<[T]> 时没有使用到的容量就会被丢弃所以整体占用的内存可能会降低。而且Box<[T]> 有一个很好的特性是,不像 Box<[T;n]> 那样在编译时就要确定大小,它可以在运行期生成,以后大小不会再改变。
所以,当我们需要在堆上创建固定大小的集合数据,且不希望自动增长,那么,可以先创建 Vec再转换成 Box<[T]>。tokio 在提供 broadcast channel 时,就使用了 Box<[T]> 这个特性,你感兴趣的话,可以自己看看源码。
小结
我们讨论了切片以及和切片相关的主要数据类型。切片是一个很重要的数据类型,你可以着重理解它存在的意义,以及使用方式。
今天学完相信你也看到了,围绕着切片有很多数据结构,而切片将它们抽象成相同的访问方式,实现了在不同数据结构之上的同一抽象,这种方法很值得我们学习。此外,当我们构建自己的数据结构时,如果它内部也有连续排列的等长的数据结构,可以考虑 AsRef 或者 Deref 到切片。
下图描述了切片和数组 [T;n]、列表 Vec、切片引用 &[T] /&mut [T],以及在堆上分配的切片 Box<[T]> 之间的关系。建议你花些时间理解这张图,也可以用相同的方式去总结学到的其他有关联的数据结构。-
下一讲我们继续学习哈希表……
思考题
1.在讲 &str 时,里面的 print_slice1 函数,如果写成这样可不可以?你可以尝试一下,然后说明理由。
// fn print_slice1<T: AsRef<str>>(s: T) {
// println!("{:?}", s.as_ref());
// }
fn print_slice1<T, U>(s: T)
where
T: AsRef<U>,
U: fmt::Debug,
{
println!("{:?}", s.as_ref());
}
2.类似 itertools你可以试着开发一个新的 Iterator trait IteratorExt为其提供 window_count 函数,使其可以做下图中的动作(来源):-
感谢你的阅读如果你觉得有收获也欢迎你分享给你身边的朋友邀他一起讨论。你已经完成了Rust学习的第16次打卡啦我们下节课见。
参考资料Rust 的 Iterator 究竟有多快?
当使用 Iterator 提供的这种函数式编程风格的时候,我们往往会担心性能。虽然我告诉你 Rust 大量使用 inline 来优化,但你可能还心存疑惑。
下面的代码和截图来自一个 Youtube 视频Sharing code between iOS & Android with Rust演讲者通过在使用 Iterator 处理一个很大的图片,比较 Rust/Swift/Kotlin native/C 这几种语言的性能。你也可以看到在处理迭代器时, Rust 代码和 Kotlin 或者 Swift 代码非常类似。
运行结果在函数式编程方式下C 没有函数式编程支持,所以直接使用了 for 循环Rust 和 C 几乎相当在1s 左右C 比 Rust 快 20%Swift 花了 11.8s,而 Kotlin native 直接超时:-
所以 Rust 在对函数式编程,尤其是 Iterator 上的优化,还是非常不错的。这里面除了 inline 外Rust 闭包的优异性能也提供了很多支持(未来我们会讲为什么)。在使用时,你完全不用担心性能。

View File

@ -0,0 +1,578 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 Unsafe Rust如何用C++的方式打开Rust
你好,我是陈天。
到目前为止,我们撰写的代码都在 Rust 精心构造的内存安全的国度里做一个守法好公民。通过遵循所有权、借用检查、生命周期等规则,我们自己的代码一旦编译通过,就相当于信心满满地向全世界宣布:这个代码是安全的!
然而,安全的 Rust 并不能适应所有的使用场景。
首先为了内存安全Rust 所做的这些规则往往是普适性的,编译器会把一切可疑的行为都严格地制止掉。可是,这种一丝不苟的铁面无情往往会过于严苛,导致错杀。
就好比“屋子的主人只会使用钥匙开门如果一个人尝试着撬门那一定是坏人”正常情况下这个逻辑是成立的所有尝试撬门的小偷都会被抓获编译错误然而有时候主人丢了钥匙不得不请开锁匠开门unsafe code此时是正常的诉求是可以网开一面的。
其次,无论 Rust 将其内部的世界构建得多么纯粹和完美,它总归是要跟不纯粹也不完美的外界打交道,无论是硬件还是软件。
计算机硬件本身是 unsafe 的,比如操作 IO 访问外设,或者使用汇编指令进行特殊操作(操作 GPU或者使用 SSE 指令集)。这样的操作,编译器是无法保证内存安全的,所以我们需要 unsafe 来告诉编译器要法外开恩。
同样的,当 Rust 要访问其它语言比如 C/C++ 的库,因为它们并不满足 Rust 的安全性要求,这种跨语言的 FFIForeign Function Interface也是 unsafe 的。
这两种使用 unsafe Rust 的方式是不得而为之,所以情有可原,是我们需要使用 unsafe Rust 的主要原因。
还有一大类使用 unsafe Rust 纯粹是为了性能。比如略过边界检查、使用未初始化内存等。这样的 unsafe 我们要尽量不用,除非通过 benchmark 发现用 unsafe 可以解决某些性能瓶颈,否则使用起来得不偿失。因为,在使用 unsafe 代码的时候,我们已经把 Rust 的内存安全性,降低到了和 C++ 同等的水平。
可以使用 unsafe 的场景
好,在了解了为什么需要 unsafe Rust 之后,我们再来看看在日常工作中,都具体有哪些地方会用到 unsafe Rust。
我们先看可以使用、也推荐使用 unsafe 的场景,根据重要/常用程度,会依次介绍:实现 unsafe trait主要是 Send/Sync 这两个 trait、调用已有的 unsafe 接口、对裸指针做解引用,以及使用 FFI。
实现 unsafe trait
Rust 里,名气最大的 unsafe 代码应该就是 Send/Sync 这两个 trait 了:
pub unsafe auto trait Send {}
pub unsafe auto trait Sync {}
相信你应该对这两个 trait 非常了解了,但凡遇到和并发相关的代码,尤其是接口的类型声明时,少不了要使用 Send/Sync 来约束。我们也知道,绝大多数数据结构都实现了 Send/Sync但有一些例外比如 Rc/RefCell /裸指针等。
因为 Send/Sync 是 auto trait所以大部分情况下你自己的数据结构不需要实现 Send/Sync然而当你在数据结构里使用裸指针时因为裸指针是没有实现 Send/Sync 的,连带着你的数据结构也就没有实现 Send/Sync。但很可能你的结构是线程安全的你也需要它线程安全。
此时,如果你可以保证它能在线程中安全地移动,那可以实现 Send如果可以保证它能在线程中安全地共享也可以去实现 Sync。之前我们讨论过的 Bytes 就在使用裸指针的情况下实现了 Send/Sync
pub struct Bytes {
ptr: *const u8,
len: usize,
// inlined "trait object"
data: AtomicPtr<()>,
vtable: &'static Vtable,
}
// Vtable must enforce this behavior
unsafe impl Send for Bytes {}
unsafe impl Sync for Bytes {}
但是,在实现 Send/Sync 的时候要特别小心,如果你无法保证数据结构的线程安全,错误实现 Send/Sync之后会导致程序出现莫名其妙的还不太容易复现的崩溃。
比如下面的代码,强行为 Evil 实现了 Send而 Evil 内部携带的 Rc 是不允许实现 Send 的。这段代码通过实现 Send 而规避了 Rust 的并发安全检查,使其可以编译通过(代码):
use std::{cell::RefCell, rc::Rc, thread};
#[derive(Debug, Default, Clone)]
struct Evil {
data: Rc<RefCell<usize>>,
}
// 为 Evil 强行实现 Send这会让 Rc 整个紊乱
unsafe impl Send for Evil {}
fn main() {
let v = Evil::default();
let v1 = v.clone();
let v2 = v.clone();
let t1 = thread::spawn(move || {
let v3 = v.clone();
let mut data = v3.data.borrow_mut();
*data += 1;
println!("v3: {:?}", data);
});
let t2 = thread::spawn(move || {
let v4 = v1.clone();
let mut data = v4.data.borrow_mut();
*data += 1;
println!("v4: {:?}", data);
});
t2.join().unwrap();
t1.join().unwrap();
let mut data = v2.data.borrow_mut();
*data += 1;
println!("v2: {:?}", data);
}
然而在运行的时候,有一定的几率出现崩溃:
cargo run --example rc_send
v4: 1
v3: 2
v2: 3
cargo run --example rc_send
v4: 1
thread '<unnamed>' panicked at 'already borrowed: BorrowMutError', examples/rc_send.rs:18:32
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Any { .. }', examples/rc_send.rs:31:15
所以,如果你没有十足的把握,不宜胡乱实现 Send/Sync。
既然我们提到了 unsafe trait你也许会好奇什么 trait 会是 unsafe 呢?除了 Send/Sync 外,还会有其他 unsafe trait 么?当然会有。
任何 trait只要声明成 unsafe它就是一个 unsafe trait。而一个正常的 trait 里也可以包含 unsafe 函数,我们看下面的示例(代码):
// 实现这个 trait 的开发者要保证实现是内存安全的
unsafe trait Foo {
fn foo(&self);
}
trait Bar {
// 调用这个函数的人要保证调用是安全的
unsafe fn bar(&self);
}
struct Nonsense;
unsafe impl Foo for Nonsense {
fn foo(&self) {
println!("foo!");
}
}
impl Bar for Nonsense {
unsafe fn bar(&self) {
println!("bar!");
}
}
fn main() {
let nonsense = Nonsense;
// 调用者无需关心 safety
nonsense.foo();
// 调用者需要为 safety 负责
unsafe { nonsense.bar() };
}
可以看到unsafe trait 是对 trait 的实现者的约束,它告诉 trait 的实现者:实现我的时候要小心,要保证内存安全,所以实现的时候需要加 unsafe 关键字。
但 unsafe trait 对于调用者来说,可以正常调用,不需要任何 unsafe block因为这里的 safety 已经被实现者保证了,毕竟如果实现者没保证,调用者也做不了什么来保证 safety就像我们使用 Send/Sync 一样。
而unsafe fn 是函数对调用者的约束,它告诉函数的调用者:如果你胡乱使用我,会带来内存安全方面的问题,请妥善使用,所以调用 unsafe fn 时,需要加 unsafe block 提醒别人注意。
再来看一个实现和调用都是 unsafe 的 traitGlobalAlloc。
下面这段代码在智能指针的[那一讲]中我们见到过,通过 GlobalAlloc 我们可以实现自己的内存分配器。因为内存分配器对内存安全的影响很大所以实现者需要保证每个实现都是内存安全的。同时alloc/dealloc 这样的方法,使用不正确的姿势去调用,也会发生内存安全的问题,所以这两个方法也是 unsafe 的:
use std::alloc::{GlobalAlloc, Layout, System};
struct MyAllocator;
unsafe impl GlobalAlloc for MyAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
let data = System.alloc(layout);
eprintln!("ALLOC: {:p}, size {}", data, layout.size());
data
}
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
System.dealloc(ptr, layout);
eprintln!("FREE: {:p}, size {}", ptr, layout.size());
}
}
#[global_allocator]
static GLOBAL: MyAllocator = MyAllocator;
unsafe trait 就讲这么多,如果你想了解更多详情,可以看 Rust RFC2585。如果你想看一个完整的 unsafe trait 定义到实现的过程,可以看 BufMut。
调用已有的 unsafe 函数
接下来我们讲 unsafe 函数。有些时候,你会发现,标准库或者第三方库提供给你的函数本身就标明了 unsafe。比如我们之前为了打印 HashMap 结构所使用的 transmute 函数:
use std::collections::HashMap;
fn main() {
let map = HashMap::new();
let mut map = explain("empty", map);
map.insert(String::from("a"), 1);
explain("added 1", map);
}
// HashMap 结构有两个 u64 的 RandomState然后是四个 usize
// 分别是 bucket_mask, ctrl, growth_left 和 items
// 我们 transmute 打印之后,再 transmute 回去
fn explain<K, V>(name: &str, map: HashMap<K, V>) -> HashMap<K, V> {
let arr: [usize; 6] = unsafe { std::mem::transmute(map) };
println!(
"{}: bucket_mask 0x{:x}, ctrl 0x{:x}, growth_left: {}, items: {}",
name, arr[2], arr[3], arr[4], arr[5]
);
// 因为 std:mem::transmute 是一个 unsafe 函数,所以我们需要 unsafe
unsafe { std::mem::transmute(arr) }
}
前面已经说过,要调用一个 unsafe 函数,你需要使用 unsafe block 把它包裹起来。这相当于在提醒大家,注意啊,这里有 unsafe 代码!
另一种调用 unsafe 函数的方法是定义 unsafe fn然后在这个 unsafe fn 里调用其它 unsafe fn。
如果你阅读一些标准库的代码会发现有时候同样的功能Rust 会提供 unsafe 和 safe 的版本,比如,把 &[u8] 里的数据转换成字符串:
// safe 版本,验证合法性,如果不合法返回错误
pub fn from_utf8(v: &[u8]) -> Result<&str, Utf8Error> {
run_utf8_validation(v)?;
// SAFETY: Just ran validation.
Ok(unsafe { from_utf8_unchecked(v) })
}
// 不验证合法性,调用者需要确保 &[u8] 里都是合法的字符
pub const unsafe fn from_utf8_unchecked(v: &[u8]) -> &str {
// SAFETY: the caller must guarantee that the bytes `v` are valid UTF-8.
// Also relies on `&str` and `&[u8]` having the same layout.
unsafe { mem::transmute(v) }
}
安全的 str::from_utf8() 内部做了一些检查后,实际调用了 str::from_utf8_unchecked()。如果我们不需要做这一层检查,这个调用可以高效很多(可能是一个量级的区别),因为 unsafe 的版本就只是一个类型的转换而已。
那么这样有两个版本的接口,我们该如何调用呢?
如果你并不是特别明确,一定要调用安全的版本,不要为了性能的优势而去调用不安全的版本。如果你清楚地知道,&[u8] 你之前已经做过检查,或者它本身就来源于你从 &str 转换成的 &[u8],现在只不过再转换回去,那可以调用不安全的版本,并在注释中注明为什么这里是安全的。
对裸指针解引用
unsafe trait 和 unsafe fn 的使用就了解到这里啦,我们再看裸指针。很多时候,如果需要进行一些特殊处理,我们会把得到的数据结构转换成裸指针,比如刚才的 Bytes。
裸指针在生成的时候无需 unsafe因为它并没有内存不安全的操作但裸指针的解引用操作是不安全的潜在有风险它也需要使用 unsafe 来明确告诉编译器,以及代码的阅读者,也就是说要使用 unsafe block 包裹起来。
下面是一段对裸指针解引用的操作(代码):
fn main() {
let mut age = 18;
// 不可变指针
let r1 = &age as *const i32;
// 可变指针
let r2 = &mut age as *mut i32;
// 使用裸指针,可以绕过 immutable/mutable borrow rule
// 然而,对指针解引用需要使用 unsafe
unsafe {
println!("r1: {}, r2: {}", *r1, *r2);
}
}
fn immutable_mutable_cant_coexist() {
let mut age = 18;
let r1 = &age;
// 编译错误
let r2 = &mut age;
println!("r1: {}, r2: {}", *r1, *r2);
}
我们可以看到,使用裸指针,可变指针和不可变指针可以共存,不像可变引用和不可变引用无法共存。这是因为裸指针的任何对内存的操作,无论是 ptr::read/ptr::write还是解引用都是unsafe 的操作,所以只要读写内存,裸指针的使用者就需要对内存安全负责。
你也许会觉得奇怪,这里也没有内存不安全的操作啊,为啥需要 unsafe 呢?是的,虽然在这个例子里,裸指针来源于一个可信的内存地址,所有的代码都是安全的,但是,下面的代码就是不安全的,会导致 segment fault代码
fn main() {
// 裸指针指向一个有问题的地址
let r1 = 0xdeadbeef as *mut u32;
println!("so far so good!");
unsafe {
// 程序崩溃
*r1 += 1;
println!("r1: {}", *r1);
}
}
这也是为什么我们在撰写 unsafe Rust 的时候,要慎之又慎,并且在 unsafe 代码中添加足够的注释来阐述为何你觉得可以保证这段代码的安全。
使用裸指针的时候,大部分操作都是 unsafe 的(下图里表三角惊叹号的):-
-
如果你对此感兴趣,可以查阅 std::ptr 的文档。
使用 FFI
最后一种可以使用 unsafe 的地方是 FFI。
当 Rust 要使用其它语言的能力时Rust 编译器并不能保证那些语言具备内存安全,所以和第三方语言交互的接口,一律要使用 unsafe比如我们调用 libc 来进行 C 语言开发者熟知的 malloc/free代码
use std::mem::transmute;
fn main() {
let data = unsafe {
let p = libc::malloc(8);
let arr: &mut [u8; 8] = transmute(p);
arr
};
data.copy_from_slice(&[1, 2, 3, 4, 5, 6, 7, 8]);
println!("data: {:?}", data);
unsafe { libc::free(transmute(data)) };
}
从代码中可以看到,所有的对 libc 函数的调用,都需要使用 unsafe block。下节课我们会花一讲的时间谈谈 Rust 如何做 FFI到时候细讲。
不推荐的使用 unsafe 的场景
以上是我们可以使用 unsafe 的场景。还有一些情况可以使用 unsafe但是我并不推荐。比如处理未初始化数据、访问可变静态变量、使用 unsafe 提升性能。
虽然不推荐使用,但它们作为一种用法,在标准库和第三方库中还是会出现,我们即便自己不写,在遇到的时候,也最好能够读懂它们。
访问或者修改可变静态变量
首先是可变静态变量。之前的课程中,我们见识过全局的 static 变量,以及使用 lazy_static 来声明复杂的 static 变量。然而之前遇到的 static 变量都是不可变的。
Rust 还支持可变的 static 变量,可以使用 static mut 来声明。
显而易见的是,全局变量如果可写,会潜在有线程不安全的风险,所以如果你声明 static mut 变量,在访问时,统统都需要使用 unsafe。以下的代码就使用了 static mut并试图在两个线程中分别改动它。你可以感受到这个代码的危险代码
use std::thread;
static mut COUNTER: usize = 1;
fn main() {
let t1 = thread::spawn(move || {
unsafe { COUNTER += 10 };
});
let t2 = thread::spawn(move || {
unsafe { COUNTER *= 10 };
});
t2.join().unwrap();
t1.join().unwrap();
unsafe { println!("COUNTER: {}", COUNTER) };
}
其实我们完全没必要这么做。对于上面的场景,我们可以使用 AtomicXXX 来改进:
use std::{
sync::atomic::{AtomicUsize, Ordering},
thread,
};
static COUNTER: AtomicUsize = AtomicUsize::new(1);
fn main() {
let t1 = thread::spawn(move || {
COUNTER.fetch_add(10, Ordering::SeqCst);
});
let t2 = thread::spawn(move || {
COUNTER
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |v| Some(v * 10))
.unwrap();
});
t2.join().unwrap();
t1.join().unwrap();
println!("COUNTER: {}", COUNTER.load(Ordering::Relaxed));
}
有同学可能会问:如果我的数据结构比较复杂,无法使用 AtomicXXX 呢?
如果你需要定义全局的可变状态,那么,你还可以使用 Mutex 或者 RwLock 来提供并发安全的写访问,比如:
use lazy_static::lazy_static;
use std::{collections::HashMap, sync::Mutex, thread};
// 使用 lazy_static 初始化复杂的结构
lazy_static! {
// 使用 Mutex/RwLock 来提供安全的并发写访问
static ref STORE: Mutex<HashMap<&'static str, &'static [u8]>> = Mutex::new(HashMap::new());
}
fn main() {
let t1 = thread::spawn(move || {
let mut store = STORE.lock().unwrap();
store.insert("hello", b"world");
});
let t2 = thread::spawn(move || {
let mut store = STORE.lock().unwrap();
store.insert("goodbye", b"world");
});
t2.join().unwrap();
t1.join().unwrap();
println!("store: {:?}", STORE.lock().unwrap());
}
所以,我非常不建议你使用 static mut。任何需要 static mut 的地方,都可以用 AtomicXXX/Mutex/RwLock 来取代。千万不要为了一时之快,给程序种下长远的祸根。
在宏里使用 unsafe
虽然我们并没有介绍宏编程,但已经在很多场合使用过宏了,宏可以在编译时生成代码。
在宏中使用 unsafe是非常危险的。
首先使用你的宏的开发者,可能压根不知道 unsafe 代码的存在;其次,含有 unsafe 代码的宏在被使用到的时候,相当于把 unsafe 代码注入到当前上下文中。在不知情的情况下,开发者到处调用这样的宏,会导致 unsafe 代码充斥在系统的各个角落,不好处理;最后,一旦 unsafe 代码出现问题,你可能都很难找到问题的根本原因。
以下是 actix_web 代码库中的 downcast_dyn 宏,你可以感受到本来就比较晦涩的宏,跟 unsafe 碰撞在一起,那种令空气都凝固了的死亡气息:
// Generate implementation for dyn $name
macro_rules! downcast_dyn {
($name:ident) => {
/// A struct with a private constructor, for use with
/// `__private_get_type_id__`. Its single field is private,
/// ensuring that it can only be constructed from this module
#[doc(hidden)]
#[allow(dead_code)]
pub struct PrivateHelper(());
impl dyn $name + 'static {
/// Downcasts generic body to a specific type.
#[allow(dead_code)]
pub fn downcast_ref<T: $name + 'static>(&self) -> Option<&T> {
if self.__private_get_type_id__(PrivateHelper(())).0
== std::any::TypeId::of::<T>()
{
// SAFETY: external crates cannot override the default
// implementation of `__private_get_type_id__`, since
// it requires returning a private type. We can therefore
// rely on the returned `TypeId`, which ensures that this
// case is correct.
unsafe { Some(&*(self as *const dyn $name as *const T)) }
} else {
None
}
}
/// Downcasts a generic body to a mutable specific type.
#[allow(dead_code)]
pub fn downcast_mut<T: $name + 'static>(&mut self) -> Option<&mut T> {
if self.__private_get_type_id__(PrivateHelper(())).0
== std::any::TypeId::of::<T>()
{
// SAFETY: external crates cannot override the default
// implementation of `__private_get_type_id__`, since
// it requires returning a private type. We can therefore
// rely on the returned `TypeId`, which ensures that this
// case is correct.
unsafe { Some(&mut *(self as *const dyn $name as *const T as *mut T)) }
} else {
None
}
}
}
};
}
所以,除非你是一个 unsafe 以及宏编程的老手,否则不建议这么做。
使用 unsafe 提升性能
unsafe 代码在很多 Rust 基础库中有大量的使用,比如哈希表那一讲提到的 hashbrown如果看它的代码库你会发现一共有 222 处使用 unsafe
hashbrown on master
ag "unsafe" | wc -l
222
这些 unsafe 代码,大多是为了性能而做的妥协。
比如下面的代码就使用了 SIMD 指令来加速处理:
unsafe {
// A byte is EMPTY or DELETED iff the high bit is set
BitMask(x86::_mm_movemask_epi8(self.0) as u16)
}
然而,如果你不是在撰写非常基础的库,并且这个库处在系统的关键路径上,我也很不建议使用 unsafe 来提升性能。
性能,是一个系统级的问题。在你没有解决好架构、设计、算法、网络、存储等其他问题时,就来抠某个函数的实现细节的性能,我认为是不妥的,尤其是试图通过使用 unsafe 代码,跳过一些检查来提升性能。
要知道,好的算法和不好的算法可以有数量级上的性能差异。而有些时候,即便你能够使用 unsafe 让局部性能达到最优,但作为一个整体看的时候,这个局部的优化可能根本没有意义。
所以,如果你用 Rust 做 Web 开发、做微服务、做客户端,很可能都不需要专门撰写 unsafe 代码来提升性能。
撰写 unsafe 代码
了解了unsafe可以使用和不建议使用的具体场景最后我们来写一段小小的代码看看如果实际工作中遇到不得不写 unsafe 代码时,该怎么做。
需求是要实现一个 split() 函数,得到一个字符串 s按照字符 sep 第一次出现的位置,把字符串 s 截成前后两个字符串。这里,当找到字符 sep 的位置 pos 时,我们需要使用一个函数,得到从字符串开头到 pos 的子串,以及从字符 sep 之后到字符串结尾的子串。
要获得这个子串Rust 有安全的 get 方法,以及不安全的 get_unchecked 方法。正常情况下,我们应该使用 get() 方法,但这个实例,我们就强迫自己使用 get_unchecked() 来跳过检查。
先看这个函数的安全性要求:-
-
在遇到 unsafe 接口时,我们都应该仔细阅读其安全须知,然后思考如何能满足它。如果你自己对外提供 unsafe 函数,也应该在文档中详细地给出类似的安全须知,告诉调用者,怎么样调用你的函数才算安全。
对于 split 的需求,我们完全可以满足 get_unchecked() 的安全要求,以下是实现(代码):
fn main() {
let mut s = "我爱你!中国".to_string();
let r = s.as_mut();
if let Some((s1, s2)) = split(r, '') {
println!("s1: {}, s2: {}", s1, s2);
}
}
fn split(s: &str, sep: char) -> Option<(&str, &str)> {
let pos = s.find(sep);
pos.map(|pos| {
let len = s.len();
let sep_len = sep.len_utf8();
// SAFETY: pos 是 find 得到的,它位于字符的边界处,同样 pos + sep_len 也是如此
// 所以以下代码是安全的
unsafe { (s.get_unchecked(0..pos), s.get_unchecked(pos + sep_len..len)) }
})
}
同样的,在撰写 unsafe 代码调用别人的 unsafe 函数时,我们一定要用注释声明代码的安全性,这样,别人在阅读我们的代码时,可以明白为什么此处是安全的、是符合这个 unsafe 函数的预期的。
小结
unsafe 代码,是 Rust 这样的系统级语言必须包含的部分,当 Rust 跟硬件、操作系统以及其他语言打交道unsafe 是必不可少的。-
当我们使用 unsafe 撰写 Rust 代码时,要格外小心,因为此时编译器已经把内存安全的权杖完全交给了你,在打开 unsafe block 的那一刻,你会获得 C/C++ 代码般的自由度,但这个自由背后的代价就是安全性上的妥协。
好的 unsafe 代码足够短小、精简只包含不得不包含的内容。unsafe 代码是开发者对编译器和其它开发者的一种庄重的承诺:我宣誓,这段代码是安全的。
今天讲的内容里的很多代码都是反面教材,并不建议你大量使用,尤其是初学者。那为什么我们还要讲 unsafe 代码呢?老子说:知其雄守其雌。我们要知道 Rust 的阴暗面unsafe rust才更容易守得住它光明的那一面safe rust
这一讲了解了 unsafe 代码的使用场景,希望你日后,在阅读 unsafe 代码的时候,不再心里发怵;同时,在撰写 unsafe 代码时,能够对其足够敬畏。
思考题
上文中,我们使用 s.get_unchecked() 来获取一个子字符串,通过使用合适的 pos可以把一个字符串 split 成两个。如果我们需要一个 split_mut 接口怎么实现?
fn split_mut(s: &mut str, sep: char) -> (&mut str, &mut str)
你可以尝试使用 get_unchecked_mut(),看看代码能否编译通过?想想为什么?然后,试着自己构建 unsafe 代码实现一下?
小提示,你可以把 s 先转换成裸指针,然后再用 std::slice::from_raw_parts_mut() 通过一个指针和一个长度,构建出一个 slice还记得 &[u8] 其实内部就是一个 ptr + len 么?)。然后,再通过 std::str::from_utf8_unchecked_mut() 构建出 &mut str。
感谢你的收听今天你完成了Rust学习的第30次打卡。如果你觉得有收获也欢迎你分享给身边的朋友邀他一起讨论。我们下节课见

View File

@ -0,0 +1,501 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 语言不仅是工具,还是思维方式
你好,我是 Pedro一名普普通通打工人平平凡凡小码农。
可能你在课程留言区看到过我,也跟我讨论过问题。今天借着这篇用户故事的机会,正好能跟你再多聊几句。
我简单整理了一下自己入坑编程以来的一些思考,主要会从思维、语言和工具三个方面来聊一聊,最后也给你分享一点自己对 Rust 的看法,当然以下观点都是“主观”的,观点本身不重要,重要的是得到观点的过程。
从思维谈起
从接触编程开始,我们就已经开始与编程语言打交道,很多人学习编程的道路往往就是熟悉编程语言的过程。
在这个过程中,很多人会不适应,写出的代码往往都不能运行,更别提设计与抽象。出现这个现象最根本的原因是,代码体现的是计算机思维,而人脑思维和计算机思维差异巨大,很多人一开始无法接受两种思维差异带来的巨大冲击。
那么,究竟什么是计算机思维?
计算机思维是全方位的,体现在方方面面,我以个人视角来简单概括一下:
自顶向下:自顶向下是计算机思维的精髓,人脑更加适合自底向上。计算机通过自顶向下思维将大而难的问题拆解为小问题,再将小问题逐一解决,从而最终解决大问题。
多维度、多任务:人脑是线性的,看问题往往是单维的,我们很难同时处理和思考多个问题,但是计算机不一样,它可以有多个 CPU 核心,在保存上下文的基础上能够并发运行成百上千的任务。
全局性:人的精力、脑容量是有限的,而计算机的容量几乎是无限的;人在思考问题时,限于自己的局部性,拿到局部解就开始做了,而计算机可以在海量数据的基础上再做决策,从而逼近全局最优。
协作性:计算机本身就是一件极其精细化的工程艺术品,它复杂精巧,每个部分都只会做自己最擅长的事情,比如将计算和存储剥离,计算机高效运作的背后是每个部分协作的结果,而人更擅长单体作战,只有通过大量的训练,才能发挥群体的作用。
迭代快:人类进化、成长是缓慢的,直到现在,很多人的思维方式仍旧停留在上个世纪,而计算机则不同,进入信息时代后,计算机就遵循着摩尔定律,每 18 个月翻一番,十年前的手机放在今天可能连微信都无法正常运行。
取舍:在长期的社会发展中,人过分喜欢强调对与错,喜欢追求绝对的公平,讽刺的是,由二进制组成的计算机却不会做出非黑即白的决策,无论是计算机本身(硬件),还是里面运行的软件,每一个部分都是性能、成本、易用性多角度权衡的结果。
So on…
当这些思维直接体现在代码里面,比如,自顶向下体现在编程语言中就是递归、分治;多维度、多任务的体现就是分支、跳转、上下文;迭代、协作和取舍在编程中也处处可见。
而这些恰恰是人脑思维不擅长的点,所以很多人无法短时间内做到编程入门。想要熟练掌握编程,就必须认识到人脑与计算机思维的差异,强化计算机思维的训练,这个训练的过程是不太可能短暂的,因此编程入门必须要消耗大量的时间和精力。
语言
不过思维的训练和评估是需要有载体的,就好比评估你的英文水平,会考察你用英文听/说/读/写的表达能力。那我们的计算机思维怎么表达呢?
于人而言,我们可以通过肢体动作、神情、声音、文字等来表达思维。在漫长的人类史中,动作、神情、声音这几种载体很难传承和传播,直到近代,音、视频的兴起才开始慢慢解决这个问题。
文字,尤其是语言诞生后的文字,成了人类文明延续、发展的主要途径之一,直至今天,我们仍然可以通过文字来与先贤对话。当然,对话的前提是,这些文字你得看得懂。
而看得懂的前提是,我们使用了同一种或类似的语言。
回到计算机上来,现代计算机也是有通用语言的,也就是我们常说的二进制机器语言,专业一点叫指令集。二进制是计算机的灵魂,但是人类却很难理解、记忆和应用,因此为了辅助人类操纵计算机工作,上一代程序员们对机器语言做了第一次抽象,发明了汇编语言。
但伴随着硬件、软件的快速发展程序代码越来越长应用变得愈来愈庞大汇编级别的抽象已经无法满足工程师对快速高效工作的需求了。历史的发展总是如此地相似当发现语言抽象已经无法满足工作时工程师们就会在原有层的基础上再抽象出一层而这一层的著名佼佼者——C语言直接奠定了今天计算机系统的基石。
从此以后,不计其数的编程语言走向计算机的舞台,它们如同满天繁星,吸引了无数的编程爱好者,比如说迈向中年的 Java 和新生代的 Julia。虽然学习计算机最正确的途径不是从语言开始但学习编程最好、最容易获取成就感的路径确实是应该从语言入手。因此编程语言的重要性不言而喻它是我们走向编程世界的大门。
C 语言是一种命令式编程语言,命令式是一种编程范式;使用 C 写代码时,我们更多是在思考如何描述程序的运行,通过编程语言来告诉计算机如何执行。
举个例子,使用 C 语言来筛选出一个数组中大于 100 的数字。对应代码如下:
int main() {
int arr[5] = { 100, 105, 110, 99, 0 };
for (int i = 0; i < 5; ++i) {
if (arr[i] > 100) {
// do something
}
}
return 0;
}
在这个例子中,代码撰写者需要使用数组、循环、分支判断等逻辑来告诉计算机如何去筛选数字,写代码的过程往往就是计算机的执行过程。
而对于另一种语言而言,比如 JavaScript筛选出大于 100 的数字的代码大概是这样的:
let arr = [ 100, 105, 110, 99, 0 ]
let result = arr.filter(n => n > 100)
相较于 C 来说JavaScript 做出了更加高级的抽象,代码撰写者无需关心数组容量、数组遍历,只需将数字丢进容器里面,并在合适的地方加上筛选函数即可,这种编程方式被称为声明式编程。
可以看到的是,相较于命令式编程,声明式编程更倾向于表达在解决问题时应该做什么,而不是具体怎么做。这种更高级的抽象不仅能够给开发者带来更加良好的体验,也能让更多非专业人士进入编程这个领域。
不过命令式编程和声明式编程其实并没有优劣之分,主要区别体现在两者的语言特性相较于计算机指令集的抽象程度。
其中,命令式编程语言的抽象程度更低,这意味着该类语言的语法结构可以直接由相应的机器指令来实现,适合对性能极度敏感的场景。而声明式编程语言的抽象程度更高,这类语言更倾向于以叙事的方式来描述程序逻辑,开发者无需关心语言背后在机器指令层面的实现细节,适合于业务快速迭代的场景。
不过语言不是一成不变的。编程语言一直在进化,它的进化速度绝对超过了自然语言的进化速度。
在抽象层面上,编程语言一直都停留在机器码 -> 汇编 -> 高级语言这三层上。而对于我们广大开发者来说,我们的目光一直聚焦在高级语言这一层上,所以,高级编程语言也慢慢成为了狭隘的编程语言(当然,这是一件好事,每一类人都应该各司其职做好自己的事情,不用过多担心指令架构、指令集差异带来的麻烦)。
谈到这里,不知你是否发现了一个规律:抽象越低的编程语言越接近计算机思维,而抽象越高越接近人脑思维。
是的。现代层出不穷的编程语言,往往都是在人脑、计算机思维之间的平衡做取舍。那些设计语言的专家们似乎在这个毫无硝烟的战场上博弈,彼此对立却又彼此借鉴。不过哪怕再博弈,按照人类自然语言的趋势来看,也几乎不可能出现一家独大的可能,就像人类目前也是汉语、英语等多种语言共存,即使世界语于 1887 年就被发明,但我们似乎从未见过谁说世界语。
既然高级编程语言那么多,对于有选择困难症的我们,又该做出何种选择呢?
工具
一提到选语言,估计你常听这么一句话,语言是工具。很长一段时间里,我也这么告诫自己,无所谓一门语言的优劣,它仅仅只是一门工具,而我需要做的就是将这门工具用好。语言是表达思想的载体,只要有了思想,无论是何种语言,都能表达。
可当我接触了越来越多的编程语言,对代码、指令、抽象有了更深入的理解之后,我推翻了这个想法,认识到了“语言只是工具”这个说法的狭隘性。
编程语言,显然不仅只是工具,它一定程度上桎梏了我们的思维。
举例来说,使用 Java 或者 C# 的人能够很轻易地想到对象的设计与封装,那是因为 Java 和 C# 就是以类作为基本的组织单位,无论你是否有意识地去做这件事,你都已经做了。而对于 C 和 JavaScript 的使用者来说,大家似乎更倾向于使用函数来进行封装。
抛开语言本身的优劣,这是一种思维的惯性,恰恰也印证了上面我谈到的,语言一定程度上桎梏了我们的思维。其实如果从人类语言的角度出发,一个人说中文和说英文的思维方式是大相径庭的,甚至一个人分别说方言和普通话给别人的感觉也像是两个人一样。
Rust
所以如果说思维是我们创造的出发点那么编程语言在表达思维的同时也在一定程度上桎梏了我们的思维。聊到这里终于到我们今天的主角——Rust这门编程语言出场了。
Rust 是什么?
Rust 是一门高度抽象、性能与安全并重的现代化高级编程语言。我学习、推崇它的主要原因有三点:
高度抽象、表达能力强,支持命令式、声明式、元编程、范型等多种编程范式;
强大的工程能力,安全与性能并重;
良好的底层能力,天然适合内核、数据库、网络。
Rust 很好地迎合了人类思维,对指令集进行了高度抽象,抽象后的表达力能让我们以更接近人类思维的视角去写代码,而 Rust 负责将我们的思维翻译为计算机语言,并且性能和安全得到了极大的保证。简单说就是,完美兼顾了一门语言的思想性和工具性。
仍以前面“选出一个数组中大于 100 的数字”为例,如果使用 Rust那么代码是这样的
let arr = vec![ 100, 105, 110, 99, 0 ]
let result = arr.iter().filter(n => n > 100).collect();
如此简洁的代码会不会带来性能损耗Rust 的答案是不会,甚至可以比 C 做到更快。
我们对应看三个小例子的实现思路/要点,来感受一下 Rust 的语言表达能力、工程能力和底层能力。
简单协程
Rust 可以无缝衔接到 C、汇编代码这样我们就可以跟下层的硬件打交道从而实现协程。
实现也很清晰。首先,定义出协程的上下文:
#[derive(Debug, Default)]
#[repr(C)]
struct Context {
rsp: u64, // rsp 寄存器
r15: u64,
r14: u64,
r13: u64,
r12: u64,
rbx: u64,
rbp: u64,
}
#[naked]
unsafe fn ctx_switch() {
// 注意16 进制
llvm_asm!(
"
mov %rsp, 0x00(%rdi)
mov %r15, 0x08(%rdi)
mov %r14, 0x10(%rdi)
mov %r13, 0x18(%rdi)
mov %r12, 0x20(%rdi)
mov %rbx, 0x28(%rdi)
mov %rbp, 0x30(%rdi)
mov 0x00(%rsi), %rsp
mov 0x08(%rsi), %r15
mov 0x10(%rsi), %r14
mov 0x18(%rsi), %r13
mov 0x20(%rsi), %r12
mov 0x28(%rsi), %rbx
mov 0x30(%rsi), %rbp
"
);
}
结构体 Context 保存了协程的运行上下文信息(寄存器数据),通过函数 ctx_switch当前协程就可以交出 CPU 使用权,下一个协程接管 CPU 并进入执行流。
然后我们给出协程的定义:
#[derive(Debug)]
struct Routine {
id: usize,
stack: Vec<u8>,
state: State,
ctx: Context,
}
协程 Routine 有自己唯一的 id、栈 stack、状态 state以及上下文 ctx。Routine 通过 spawn 函数创建一个就绪协程yield 函数会交出 CPU 执行权:
pub fn spawn(&mut self, f: fn()) {
// 找到一个可用的
// let avaliable = ....
let sz = avaliable.stack.len();
unsafe {
let stack_bottom = avaliable.stack.as_mut_ptr().offset(sz as isize); // 高地址内存是栈顶
let stack_aligned = (stack_bottom as usize & !15) as *mut u8;
std::ptr::write(stack_aligned.offset(-16) as *mut u64, guard as u64);
std::ptr::write(stack_aligned.offset(-24) as *mut u64, hello as u64);
std::ptr::write(stack_aligned.offset(-32) as *mut u64, f as u64);
avaliable.ctx.rsp = stack_aligned.offset(-32) as u64; // 16 字节对齐
}
avaliable.state = State::Ready;
}
pub fn r#yield(&mut self) -> bool {
// 找到一个 ready 的,然后让其运行
let mut pos = self.current;
//.....
self.routines[pos].state = State::Running;
let old_pos = self.current;
self.current = pos;
unsafe {
let old: *mut Context = &mut self.routines[old_pos].ctx;
let new: *const Context = &self.routines[pos].ctx;
llvm_asm!(
"mov $0, %rdi
mov $1, %rsi"::"r"(old), "r"(new)
);
ctx_switch();
}
self.routines.len() > 0
}
运行结果如下:
1 STARTING
routine: 1 counter: 0
2 STARTING
routine: 2 counter: 0
routine: 1 counter: 1
routine: 2 counter: 1
routine: 1 counter: 2
routine: 2 counter: 2
routine: 1 counter: 3
routine: 2 counter: 3
routine: 1 counter: 4
routine: 2 counter: 4
routine: 1 counter: 5
routine: 2 counter: 5
routine: 1 counter: 6
routine: 2 counter: 6
routine: 1 counter: 7
routine: 2 counter: 7
routine: 1 counter: 8
routine: 2 counter: 8
routine: 1 counter: 9
routine: 2 counter: 9
1 FINISHED
具体代码实现参考协程 。
简单内核
操作系统内核是一个极为庞大的工程,但是如果只是写个简单内核输出 Hello World那么 Rust 就能很快完成这个任务。你可以自己体验一下。
首先,添加依赖工具:
rustup component add llvm-tools-preview
cargo install bootimage
然后编辑 main.rs 文件输出一个 Hello World
#![no_std]
#![no_main]
use core::panic::PanicInfo;
static HELLO:&[u8] = b"Hello World!";
#[no_mangle]
pub extern "C" fn _start() -> ! {
let vga_buffer = 0xb8000 as *mut u8;
for (i, &byte) in HELLO.iter().enumerate() {
unsafe {
*vga_buffer.offset(i as isize * 2) = byte;
*vga_buffer.offset(i as isize * 2 + 1) = 0xb;
}
}
loop{}
}
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}
然后编译、打包运行:
cargo bootimage
cargo run
运行结果如下:-
具体代码实现参考内核 。
简单网络协议栈
同操作系统一样,网络协议栈也是一个庞大的工程系统。但是借助 Rust 和其完备的生态,我们可以迅速完成一个小巧的 HTTP 协议栈。
首先,在数据链路层,我们定义 Mac 地址结构体:
#[derive(Debug)]
pub struct MacAddress([u8; 6]);
impl MacAddress {
pub fn new() -> MacAddress {
let mut octets: [u8; 6] = [0; 6];
rand::thread_rng().fill_bytes(&mut octets); // 1. 随机生成
octets[0] |= 0b_0000_0010; // 2
octets[1] &= 0b_1111_1110; // 3
MacAddress { 0: octets }
}
}
MacAddress 用来表示网卡的物理地址,此处的 new 函数通过随机数来生成随机的物理地址。
然后实现 DNS 域名解析函数,通过 IP 地址获取 MAC 地址,如下:
pub fn resolve(
dns_server_address: &str,
domain_name: &str,
) -> Result<Option<std::net::IpAddr>, Box<dyn Error>> {
let domain_name = Name::from_ascii(domain_name).map_err(DnsError::ParseDomainName)?;
let dns_server_address = format!("{}:53", dns_server_address);
let dns_server: SocketAddr = dns_server_address
.parse()
.map_err(DnsError::ParseDnsServerAddress)?;
// ....
let mut encoder = BinEncoder::new(&mut request_buffer);
request.emit(&mut encoder).map_err(DnsError::Encoding)?;
let _n_bytes_sent = localhost
.send_to(&request_buffer, dns_server)
.map_err(DnsError::Sending)?;
loop {
let (_b_bytes_recv, remote_port) = localhost
.recv_from(&mut response_buffer)
.map_err(DnsError::Receiving)?;
if remote_port == dns_server {
break;
}
}
let response = Message::from_vec(&response_buffer).map_err(DnsError::Decoding)?;
for answer in response.answers() {
if answer.record_type() == RecordType::A {
let resource = answer.rdata();
let server_ip = resource.to_ip_addr().expect("invalid IP address received");
return Ok(Some(server_ip));
}
}
Ok(None)
}
接着实现 HTTP 协议的 GET 方法:
pub fn get(
tap: TapInterface,
mac: EthernetAddress,
addr: IpAddr,
url: Url,
) -> Result<(), UpstreamError> {
let domain_name = url.host_str().ok_or(UpstreamError::InvalidUrl)?;
let neighbor_cache = NeighborCache::new(BTreeMap::new());
// TCP 缓冲区
let tcp_rx_buffer = TcpSocketBuffer::new(vec![0; 1024]);
let tcp_tx_buffer = TcpSocketBuffer::new(vec![0; 1024]);
let tcp_socket = TcpSocket::new(tcp_rx_buffer, tcp_tx_buffer);
let ip_addrs = [IpCidr::new(IpAddress::v4(192, 168, 42, 1), 24)];
let fd = tap.as_raw_fd();
let mut routes = Routes::new(BTreeMap::new());
let default_gateway = Ipv4Address::new(192, 168, 42, 100);
routes.add_default_ipv4_route(default_gateway).unwrap();
let mut iface = EthernetInterfaceBuilder::new(tap)
.ethernet_addr(mac)
.neighbor_cache(neighbor_cache)
.ip_addrs(ip_addrs)
.routes(routes)
.finalize();
let mut sockets = SocketSet::new(vec![]);
let tcp_handle = sockets.add(tcp_socket);
// HTTP 请求
let http_header = format!(
"GET {} HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n",
url.path(),
domain_name,
);
let mut state = HttpState::Connect;
'http: loop {
let timestamp = Instant::now();
match iface.poll(&mut sockets, timestamp) {
Ok(_) => {}
Err(smoltcp::Error::Unrecognized) => {}
Err(e) => {
eprintln!("error: {:?}", e);
}
}
{
let mut socket = sockets.get::<TcpSocket>(tcp_handle);
state = match state {
HttpState::Connect if !socket.is_active() => {
eprintln!("connecting");
socket.connect((addr, 80), random_port())?;
HttpState::Request
}
HttpState::Request if socket.may_send() => {
eprintln!("sending request");
socket.send_slice(http_header.as_ref())?;
HttpState::Response
}
HttpState::Response if socket.can_recv() => {
socket.recv(|raw_data| {
let output = String::from_utf8_lossy(raw_data);
println!("{}", output);
(raw_data.len(), ())
})?;
HttpState::Response
}
HttpState::Response if !socket.may_recv() => {
eprintln!("received complete response");
break 'http;
}
_ => state,
}
}
phy_wait(fd, iface.poll_delay(&sockets, timestamp)).expect("wait error");
}
Ok(())
}
最后在 main 函数中使用 HTTP GET 方法:
fn main() {
// ...
let tap = TapInterface::new(&tap_text).expect(
"error: unable to use <tap-device> as a \
network interface",
);
let domain_name = url.host_str().expect("domain name required");
let _dns_server: std::net::Ipv4Addr = dns_server_text.parse().expect(
"error: unable to parse <dns-server> as an \
IPv4 address",
);
let addr = dns::resolve(dns_server_text, domain_name).unwrap().unwrap();
let mac = ethernet::MacAddress::new().into();
http::get(tap, mac, addr, url).unwrap();
}
运行程序,结果如下:
$ ./target/debug/rget http://www.baidu.com tap-rust
HTTP/1.0 200 OK
Accept-Ranges: bytes
Cache-Control: no-cache
Content-Length: 9508
Content-Type: text/html
具体代码实现参考协议栈 。
通过这三个简单的小例子,无论是协程、内核还是协议栈,这些听上去都很高大上的技术,在 Rust 强大的表现力、生态和底层能力面前显得如此简单和方便。
思维是出发点,语言是表达体,工具是媒介,而 Rust 完美兼顾了一门语言的思想性和工具性,赋予了我们极强的工程表达能力和完成能力。
总结
作为极其现代的语言Rust 集百家之长而成,将性能、安全、语言表达力都做到了极致,但同时也带来了巨大的学习曲线。
初学时,每天都要和编译器做斗争,每次编译都是满屏的错误信息;攻克一个陡坡后,发现后面有更大的陡坡,学习的道路似乎无穷无尽。那我们为什么要学习 Rust
这里引用左耳朵耗子的一句话:
如果你对 Rust 的概念认识得不完整,你完全写不出程序,那怕就是很简单的一段代码。这逼着程序员必须了解所有的概念才能编码。
Rust 是一个对开发者极其严格的语言,严格到你学的不扎实,就不能写程序,但这无疑也是一个巨大的机会,改掉你不好的编码习惯,锻炼你的思维,让你成为真正的大师。
聊到这里,你是否已经对 Rust 有了更深的认识和更多的激情,那么放手去做吧!期待你与 Rust 擦出更加明亮的火花!
参考资料
Writing an OS in Rust
green-threads-explained-in-200-lines-of-rust
https://github.com/PedroGao/rust-examples
《深入理解计算机系统》
《Rust in Action》
《硅谷来信》
《浪潮之巅》

View File

@ -0,0 +1,163 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 永续之原Rust学习如何持续精进
你好,我是陈天。
首先,恭喜你完成了这门课程!
六月底,我确定了自己会在极客时间上出这个 Rust 的专栏。
其实以前我对这样子的付费课程不是太感冒,因为自己随性惯了,写公众号自由洒脱,想写就写,想停就停,一个主题写腻了还可以毫无理由地切换到另一个主题上。但一旦写付费的专栏签下合同,就意味着品味、质量、内容以及更新的速度都不能随心所欲,得按照人家的要求来。
最要命的是更新的速度——我没有专职做过文字工作者,想来和代码工作者性质类似,一些开创性工作的开始特别需要灵感,非常依赖妙手偶得的那个契机。这种不稳定的输出模式,遇到进度的压力,就很折磨人。所以之前很多机会我都婉拒了。
但这次思来想去,我还是接下了 Rust 第一课这个挑战。
大部分原因是我越来越喜爱 Rust 这门语言,想让更多的人也能爱上它,于是之前在公众号和 B 站上也做了不少输出。但这样的输出左一块右一块的没有一个完整的体系所以有这样一个机会来构建出我个人总结的Rust学习体系也许对大家的学习会有很大的帮助。
另外一部分原因也是出于我的私心。自从 2016 年《途客圈创业记》出版后,我就没有正式出版过东西,很多口头答应甚至签下合同的选题,也都因为各种原因被我终止或者搁置了。我特别想知道,自己究竟是否还能拿起笔写下严肃的可以流传更广、持续更久的文字。
可是——介绍一门语言的文字可以有持久的生命力么?
你一定有这个疑问。
撰写介绍一门编程语言的文字,却想让它拥有持久的生命力,这听上去似乎是痴人说梦。现代编程语言的进化速度相比二十年前,可谓是一日千里。就拿 Rust 来说,稳定的六周一个版本,三年一个版次,别说是拥有若干年的生命力了,就算是专栏连载的几个月,都会过去两三个版本,也就意味着有不少新鲜的东西被加入到语言中。
不过好在 Rust 极其注重向后兼容,也就意味着我现在介绍的代码,只要是 Rust 语言或者标准库中稳定的内容若干年后应该还是可以有效的。Rust 这种不停迭代却一直保持向后兼容的做法,让它相对于其它语言在教学上有一些优势,所以,撰写介绍 Rust 的文字,生命力会更加持久一些。
当然这还远远不够。让介绍一门编程语言的文字更持久的方式就是,从本原出发,帮助大家理解语言表层概念背后的思想或者机理,这也是这个专栏最核心的设计思路。
通用型计算机诞生后差不多七十年了,当时的冯诺依曼结构依然有效;从 C 语言诞生到现在也有快五十年了,编程语言处理内存的方式还是堆和栈,常用的算法和数据结构也还是那些。虽然编程语言在不断进化,但解决问题的主要手段还都是差不多的。
比如说,引用计数,你如果在之前学习的任何一门语言中弄明白了它的思路,那么理解 Rust 下的 Rc/Arc 也不在话下。所以,只要我们把基础知识夯实,很多看似难懂的问题,只不过是在同样本质上套了让人迷惑的外衣而已。
那么如何拨开迷雾抵达事物的本原呢?我的方法有两个:一曰问,二曰切。对,就是中医“望闻问切”后两个字。
问就是刨根追底,根据已有的认知,发出直击要害的疑问,这样才能为后续的探索(切)叩开大门。比如你知道引用计数通行的实现方法,也知道 Rust 的单一所有权机制把堆内存的生命周期和栈内存绑定在一起,栈在值在,栈亡值亡。
那么你稍微思考一下就会产生疑问Rc/Arc 又是怎么打破单一所有权机制,做到让堆上的内存跳脱了栈上内存的限制呢?问了这个问题,你就有机会往下“切”。
“切”是什么呢,就是深入查看源代码,顺着脉络找出问题的答案。初学者往往不看标准库的源码,实际上,看源代码是最能帮助你成长的。无论是学习一门语言,还是学习 Linux 内核或者别的什么,源码都是第一手资料。别人的分析讲得再好,也是嚼过的饭,受限于他的理解能力和表达能力,这口嚼过的饭还真不一定比你自己亲自上嘴更好下咽。
比如想知道上面Rc/Arc的问题自然要看 Rc::new 的源码实现:
pub fn new(value: T) -> Rc<T> {
// There is an implicit weak pointer owned by all the strong
// pointers, which ensures that the weak destructor never frees
// the allocation while the strong destructor is running, even
// if the weak pointer is stored inside the strong one.
Self::from_inner(
Box::leak(box RcBox { strong: Cell::new(1), weak: Cell::new(1), value }).into(),
)
}
不看不知道,一看吓一跳。可疑的 Box::leak 出现在我们眼前。这个 Box::leak 又是干什么的呢?顺着这个线索追溯下去,我们发现了一个宝贵的金矿(你可以回顾生命周期的那一讲)。
在追溯本原的基础上,我们还要学会分析问题和解决问题的正确方法。我觉得编程语言的学习不应该只局限于学习语法本身,更应该在这个过程中,不断提升自己学习知识和处理问题的能力。
如果你还记得 HashMap 那一讲,我们先是宏观介绍解决哈希冲突的主要思路,它是构建哈希表的核心算法;然后使用 transmute 来了解 Rust HashMap 的组织结构,通过 gdb 查看内存布局,再结合代码去找到 HashMap 构建和扩容的具体思路。
这样一层层剥茧抽丝,边学习,边探索,边总结,最终我们得到了对 Rust 哈希表非常扎实的掌握。这种掌握程度,哪怕你十年都不碰 Rust十年后有人问你 Rust 的哈希表怎么工作的,你也能回答个八九不离十。
我希望你能够掌握这种学习的方式这是终生受益的方式。2006 年,我在 Juniper 工作时,用类似的方式,把 ScreenOS 系统的数据平面的处理流程总结出来了,到现在很多细节我记忆犹新。
很多时候面试一些同学,详细询问他们三五年前设计和实现过的一些项目时,他们会答不上来,经常给出“这个项目太久了,我记不太清楚”这样的答复,让我觉得好奇怪。对我而言,只要是做过的项目、阅读过的代码,不管多久,都能回忆起很多细节,就好像它们是自己的一部分一样。
尽管快有 20 年没有碰,我还记得第一份工作中 OSPFv2 和 IGMPv3 协议的部分细节,知道 netlink 如何工作,也对 Linux VMM 管理的流程有一个基本印象。现在想来,可能就是我掌握了正确的学习方法而已。
所以,在这门介绍语言的课程中,我还夹带了很多方法论相关的私货,它们大多散落在文章的各个角落,除了刚刚谈到的分析问题/解决问题的方法外,还有阅读代码的方法、架构设计的方法、撰写和迭代接口的方法、撰写测试的方法、代码重构的方法等等。希望这些私货能够让你产生共鸣,结合你自己在职业生涯中总结出来的方法,更好地服务于你的学习和工作。
在撰写这个专栏的过程中我参考了不少书籍。比如《Programming Rust》、《Designing Data-intensive Applications》以及《Fundamentals of Software Architecture》。可惜 Jon Gjengset 的《Rust for Rustaceans》姗姗来迟否则这个专栏的水准可以更上一个台阶。
我们做软件开发的似乎到了一定年纪就不怎么阅读这样不好。毕加索说“good artists copy; great artists steal.”当你从一个人身上学习时,你在模仿;当你从一大群人身上学习时,你自己就慢慢融会贯通,成为大师。
所以不要指望学了这门Rust 第一课,就大功告成,这门课仅仅是一个把你接引至 Rust 世界的敲门砖,接下来你还要进一步从各个方面学习和夯实更多的知识。
就像我回答一个读者的问题所说的:很多时候,我们缺乏的不是对 Rust 知识的理解,更多是对软件开发更广阔知识的理解。所以,不要拘泥于 Rust 本身,对你自己感兴趣的,以及你未来会涉猎的场景广泛阅读、深度思考。
伴随着学习,阅读,思考,我们还要广泛地实践。不要一有问题就求助,想想看,自己能不能构造足够简单的代码来帮助解决问题。
比如有人问HTTP/2 是怎么工作的?这样的问题,你除了可以看 RFC阅读别人总结的经验还可以动动手几行代码就可以获得很多信息。比如
use tracing::info;
use tracing_subscriber::EnvFilter;
fn main() {
tracing_subscriber::fmt::fmt()
.with_env_filter(EnvFilter::from_default_env())
.init();
let url = "<https://www.rust-lang.org/>";
let _body = reqwest::blocking::get(url).unwrap().text().unwrap();
info!("Fetching url: {}", url);
}
这段代码相信你肯定能写得出来,但你是否尝试过 RUST_LOG=debug 甚至 RUST_LOG=trace 来看看输出的日志呢?又有没有尝试着顺着日志的脉络,去分析涉及的库呢?
下面是这几行代码 RUST_LOG=debug 的输出,可以让你看到 HTTP/2 基本的运作方式,我建议你试试 RUST_LOG=trace内容太多就不贴了如果你能搞清楚输出的信息那么 Rust 下用 hyper 处理 HTTP/2 的主流程你就比较明白了。
RUST_LOG=debug cargo run --quiet
2021-12-12T21:28:00.612897Z DEBUG reqwest::connect: starting new connection: <https://www.rust-lang.org/>
2021-12-12T21:28:00.613124Z DEBUG hyper::client::connect::dns: resolving host="www.rust-lang.org"
2021-12-12T21:28:00.629392Z DEBUG hyper::client::connect::http: connecting to 13.224.7.43:443
2021-12-12T21:28:00.641156Z DEBUG hyper::client::connect::http: connected to 13.224.7.43:443
2021-12-12T21:28:00.641346Z DEBUG rustls::client::hs: No cached session for DnsName(DnsName(DnsName("www.rust-lang.org")))
2021-12-12T21:28:00.641683Z DEBUG rustls::client::hs: Not resuming any session
2021-12-12T21:28:00.656251Z DEBUG rustls::client::hs: Using ciphersuite Tls13(Tls13CipherSuite { suite: TLS13_AES_128_GCM_SHA256, bulk: Aes128Gcm })
2021-12-12T21:28:00.656754Z DEBUG rustls::client::tls13: Not resuming
2021-12-12T21:28:00.657046Z DEBUG rustls::client::tls13: TLS1.3 encrypted extensions: [ServerNameAck, Protocols([PayloadU8([104, 50])])]
2021-12-12T21:28:00.657151Z DEBUG rustls::client::hs: ALPN protocol is Some(b"h2")
2021-12-12T21:28:00.658435Z DEBUG h2::client: binding client connection
2021-12-12T21:28:00.658526Z DEBUG h2::client: client connection bound
2021-12-12T21:28:00.658602Z DEBUG h2::codec::framed_write: send frame=Settings { flags: (0x0), enable_push: 0, initial_window_size: 2097152, max_frame_size: 16384 }
2021-12-12T21:28:00.659062Z DEBUG Connection{peer=Client}: h2::codec::framed_write: send frame=WindowUpdate { stream_id: StreamId(0), size_increment: 5177345 }
2021-12-12T21:28:00.659327Z DEBUG hyper::client::pool: pooling idle connection for ("https", www.rust-lang.org)
2021-12-12T21:28:00.659674Z DEBUG Connection{peer=Client}: h2::codec::framed_write: send frame=Headers { stream_id: StreamId(1), flags: (0x5: END_HEADERS | END_STREAM) }
2021-12-12T21:28:00.672087Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Settings { flags: (0x0), max_concurrent_streams: 128, initial_window_size: 65536, max_frame_size: 16777215 }
2021-12-12T21:28:00.672173Z DEBUG Connection{peer=Client}: h2::codec::framed_write: send frame=Settings { flags: (0x1: ACK) }
2021-12-12T21:28:00.672244Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=WindowUpdate { stream_id: StreamId(0), size_increment: 2147418112 }
2021-12-12T21:28:00.672308Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Settings { flags: (0x1: ACK) }
2021-12-12T21:28:00.672351Z DEBUG Connection{peer=Client}: h2::proto::settings: received settings ACK; applying Settings { flags: (0x0), enable_push: 0, initial_window_size: 2097152, max_frame_size: 16384 }
2021-12-12T21:28:00.956751Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Headers { stream_id: StreamId(1), flags: (0x4: END_HEADERS) }
2021-12-12T21:28:00.956921Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:00.957015Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:00.957079Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:00.957316Z DEBUG reqwest::async_impl::client: response '200 OK' for <https://www.rust-lang.org/>
2021-12-12T21:28:01.018665Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1) }
2021-12-12T21:28:01.018885Z DEBUG Connection{peer=Client}: h2::codec::framed_read: received frame=Data { stream_id: StreamId(1), flags: (0x1: END_STREAM) }
2021-12-12T21:28:01.020158Z INFO http2: Fetching url: <https://www.rust-lang.org/>
所以,很多时候,知识就在我们身边,我们写一写代码就能获取。
在这个过程中,你自己思考之后撰写的探索性的代码、你分析输出过程中付出的思考和深度的阅读,以及最后在梳理过程中进行的总结,都会让知识牢牢变成你自己的。
最后我们聊一聊写代码这个事。
学习任何语言最重要的步骤都是用学到的知识解决实际的问题。Rust 能不能胜任你需要完成的各种任务?大概率能。但你能不能用 Rust 来完成这些任务?不一定。每个十指俱全的人都能学习弹钢琴,但不是每个学弹钢琴的人都能达到十级的水平。这其中现实和理想间巨大的鸿沟就是“刻意练习”。
想要成为 Rust 专家,想让 Rust 成为你职业生涯中的一项重要技能刻意练习必不可少需要不断地撰写代码。的确Rust 的所有权和生命周期学习和使用起来让人难于理解所有权、生命周期跟类型系统包括泛型、trait以及异步开发结合起来更是障碍重重但通过不断学习和不断练习你一定会发现它们不过是你的一段伟大旅程中越过的一个小山丘而已。
最后的最后,估计很多同学都是在艰难斗争、默默学习,在专栏要结束的今天,欢迎你在留言区留言,我非常希望能听到你的声音,听听你学习这个专栏的感受和收获,见到你的身影。点这里还可以提出你对课程的反馈与建议。
感谢你选择我的 Rust 第一课。感谢你陪我们一路走到这里。接下来,就看你的了。
“Go where you must go, and hope!”— Gandalf

View File

@ -0,0 +1,124 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
00 开篇词 入门Spark你需要学会“三步走”
你好我是吴磊欢迎和我一起入门学习Spark。
在过去的7年里我一直在围绕着Spark来规划我的职业发展。2014年Spark以“星火燎原”之势席卷了整个大数据生态圈正是在那个时候我结识了Spark。起初怀揣着强烈的好奇心我花了一个星期用Spark重写了公司IBM的ETL任务。
让我颇为惊讶的是Spark版本的ETL任务在执行性能上提升了一个数量级。从那以后我便深深地着迷于Spark孜孜不倦、乐此不疲地学习、实践与Spark有关的一切从官方文档到技术博客从源代码再到最佳实践从动手实验再到大规模应用在这个过程里
在IBM我用Spark Streaming构建了流处理应用帮助业务人员去实时分析用户行为。
在联想研究院我用Spark SQL + Hive搭建的公司级数仓服务于所有业务部门。
在微博我基于Spark MLlib来构建微博机器学习框架配置化的开发框架让上百位算法工程师从繁重的数据处理、特征工程、样本工程中解脱出来把宝贵的精力和时间投入到了算法研究与模型调优上来。
在FreeWheel在所有的机器学习项目中我们使用Spark进行数据探索、数据处理、特征工程、样本工程与模型训练将一个又一个机器学习项目落地到业务中。
为了把Spark吃得更透在日常的工作中我热衷于把学过的知识、习得的技巧、踩过的坑、绕过的弯路付诸笔头。通过这种“学、用、写”不断迭代的学习方式我把零散的开发技巧与知识点逐渐地归纳成了结构化的知识体系。
在2021年的3月份我与极客时间合作了《Spark性能调优实战》这一专栏把我积累的与性能调优有关的技巧、心得、最佳实践分享给有需要的同学。
让我欣慰的是专栏的内容受到了同学们的广泛好评有不少同学反馈采用专栏中的调优技巧Spark作业的执行性能提升了好几倍。但同时也有一些同学反馈自己才入门大数据专栏中的很多内容表示看不懂。
实际上我身边也有不少同学他们有的科班出身于机器学习、人工智能有的准备从后端开发、DBA甚至是其他行业转型做大数据开发有的想基于开源框架构建企业级数据仓库都面临着如何快速入门Spark的难题。
“快”和“全”让Spark成了互联网公司的标配
不过你可能会好奇“Spark还有那么火吗会不会已经过时了”实际上历经十多年的发展Spark已经由当初的“大数据新秀”成长为数据应用领域的中流砥柱。在数据科学与机器学习魔力象限当中IT研究与咨询公司Gartner连续3年2018 ~ 2020将DatabricksSpark云原生商业版本提名为Market Leader。
不仅如此凭借其自身的诸多优势Spark早已成为绝大多数互联网公司的标配。比如字节跳动基于 Spark 构建数据仓库服务着旗下几乎所有的产品线包括抖音、今日头条、西瓜视频、火山视频比如美团早在2014年就引入了Spark并逐渐将其覆盖到美团App、美团外卖、美团打车等核心产品再比如Netflix基于Spark构建端到端的机器学习流水线围绕着Spark打造服务于超过两亿订阅用户的推荐引擎。
事实上任何一家互联网公司都离不开推荐、广告、搜索这3类典型业务场景。推荐与搜索帮助企业引流、提升用户体验、维持用户黏性、拓展用户增长而广告业务则用于将流量变现是互联网公司最重要的商业模式之一。而在这些业务场景背后的技术栈当中你都能看到Spark的身影它或是用于ETL与流处理、或是用于构建企业级数据分析平台、或是用于打造端到端的机器学习流水线。
那么我们不禁要问“在发展迅猛的数据应用领域同类竞品可以说是层出不穷、日新月异Spark何以傲视群雄在鹰视狼顾的厮杀中脱颖而出并能持久地立于不败之地”在我看来这主要是得益于Spark的两大优势快、全。
有两个方面一个是开发效率快另一个是执行效率快。Spark支持多种开发语言如Python、Java、Scala、R和SQL同时提供了种类丰富的开发算子如RDD、DataFrame、Dataset。这些特性让开发者能够像搭积木一样信手拈来、驾轻就熟地完成数据应用开发。
在我的身边有很多不具备大数据背景却需要从零开始用Spark做开发的同学。最开始他们往往需要“照葫芦画瓢”、参考别人的代码实现才能完成自己的工作。但是经过短短3个月的强化练习之后绝大多数同学都能够独当一面、熟练地实现各式各样的业务需求。而这自然要归功于Spark框架本身超高的开发效率。
再者凭借Spark Core和Spark SQL这两个并驾齐驱的计算引擎我们开发出的数据应用并不需要太多的调整或是优化就能享有不错的执行性能。
而这主要得益于Spark社区对于底层计算引擎的持续打磨与优化才让开发者能够把精力专注于业务逻辑实现而不必关心框架层面的设计细节。
说完了Spark的“快”接下来我们再来说说它的“全”。全指的是Spark在计算场景的支持上非常全面。我们知道在数据应用领域有如下几类计算场景它们分别是批处理、流计算、数据分析、机器学习和图计算。
批处理作为大数据的基础,自然不必多说了。与以往任何时候都不同,今天的大数据处理,对于延迟性的要求越来越高,流处理的基本概念与工作原理,是每一个大数据从业者必备的“技能点”。而在人工智能火热的当下,数据分析与机器学习也是我们必须要关注的重中之重。
对于这几类计算场景Spark提供了丰富的子框架予以支持。比如针对流计算的Structured Streaming用于数据分析的Spark SQL服务于机器学习的Spark MLlib等等。Spark全方位的场景支持让开发者“足不出户”、在同一套计算框架之内即可实现不同类型的数据应用从而避免为了实现不同类型的数据应用而疲于奔命地追逐各式各样的新技术、新框架。
不难发现Spark集众多优势于一身在互联网又有着极其深远的影响力对于想要在数据应用领域有所建树的同学来说Spark可以说是一门必修课。
不管你是专注于应用开发与二次开发的大数据工程师还是越来越火热的数据分析师、数据科学家、以及机器学习算法研究员Spark都是你必须要掌握的一项傍身之计。
不过尽管Spark优势众多但入门Spark却不是一件容易的事情。身边的同学经常有这样的感叹
网上的学习资料实在太多,但大部分都是零星的知识点,很难构建结构化的知识体系;
Spark相关的书籍其实也不少但多是按部就班、照本宣科地讲原理看不下去
要想学习Spark还要先学ScalaScala语法晦涩难懂直接劝退
开发算子太多了,记不住,来了新的业务需求,不知道该从哪里下手;
……
既然Spark是数据应用开发者在职业发展当中必需的一环而入门Spark又有这样那样的难处和痛点那么我们到底该如何入门Spark呢
如何入门Spark
如果把Spark比作是公路赛车的话那么我们每一个开发者就是准备上车驾驶的赛车手。要想开好这辆赛车那么第一步我们首先要熟悉车辆驾驶的基本操作比如挡位怎么挂油门、离合、刹车踏板分别在什么地方等等。
再者,为了发挥出赛车的性能优势,我们得了解赛车的工作原理,比如它的驱动系统、刹车系统等等。只有摸清了它的工作原理,我们才能灵活地操纵油、离、刹之间的排列组合。
最后,在掌握了赛车的基本操作和工作原理之后,对于不同的地形,比如公路、山路、沙漠等等,我们还要总结出针对不同驾驶场景的一般套路。遵循这样的三步走,我们才能从一个赛车小白,逐渐化身为资深赛车手。
和学习驾驶赛车一样入门Spark也需要这样的“三步走”。第一步就像是需要熟悉赛车的基本操作我们需要掌握Spark常用的开发API与开发算子。毕竟通过这些API与开发算子我们才能启动并驱使Spark的分布式计算引擎。
接着要想让Spark这台车子跑得稳我们必须要深入理解它的工作原理才行。因此在第二步我会为你讲解Spark的核心原理。
第三步就像是应对赛车的不同驾驶场景我们需要了解并熟悉Spark不同的计算子框架Spark SQL、Spark MLlib和Structured Streaming来应对不同的数据应用场景比如数据分析、机器学习和流计算。
与三步走相对应我把这门课设计成了4个模块其中第一个模块是基础知识模块我会专注于三步走的前两步也即熟悉开发API和吃透核心原理。在后面的三个模块中我会依次讲解Spark应对不同数据场景的计算子框架分别是Spark SQL、Spark MLlib和Structured Streaming。由于图计算框架GraphFrames在工业界的应用较少因此咱们的课程不包含这部分内容的介绍。
这四个模块和“三步走”的关系如下图所示:
从图中你可以看到由于在这三种子框架中Spark SQL在扮演数据分析子框架这个角色的同时还是Spark新一代的优化引擎其他子框架都能共享Spark SQL带来的“性能红利”所以我在讲解Spark SQL的时候也会涉及一些第一步、第二步中的基本操作和原理介绍。
在这四个模块中我们都会从一个小项目入手由浅入深、循序渐进地讲解项目涉及的算子、开发API、工作原理与优化技巧。尽管每个项目给出的代码都是由Scala实现的但你完全不用担心我会对代码逐句地进行注释提供“保姆级”的代码解释。
第一个模块是基础知识。
在这个模块中我们会从一个叫作“Word Count”的小项目开始。以Word Count的计算逻辑为线索我们会去详细地讲解RDD常用算子的含义、用法、注意事项与适用场景让你一站式掌握RDD算子我还会用一个又一个有趣的故事以轻松诙谐、深入浅出的方式为你讲解Spark核心原理包括RDD编程模型、Spark进程模型、调度系统、存储系统、Shuffle管理、内存管理等等从而让你像读小说一样去弄懂Spark。
第二个模块在讲Spark SQL时我首先会从“小汽车摇号”这个小项目入手带你熟悉Spark SQL开发API。与此同时依托这个小项目我会为你讲解Spark SQL的核心原理与优化过程。最后我们再重点介绍Spark SQL与数据分析有关的部分如数据的转换、清洗、关联、分组、聚合、排序等等。
在第三个模块我们会学习Spark机器学习子框架Spark MLlib。
在这个模块中我们会从“房价预测”这个小项目入手初步了解机器学习中的回归模型、以及Spark MLlib的基本用法。我还会为你介绍机器学习的一般场景会带你一起深入学习Spark MLlib丰富的特征处理函数细数Spark MLlib都支持哪些模型与算法并学习构建端到端的机器学习流水线。最后我还会讲Spark + XGBoost集成是如何帮助开发者应对大多数的回归与分类问题。
在课程的最后一部分我们一起来学习Spark的流处理框架Structured Streaming。
在这个模块中我们将重点讲解Structured Streaming如何同时保证语义一致性与数据一致性以及如何应对流处理中的数据关联并通过Kafka + Spark这对“Couple”的系统集成来演示流处理中的典型计算场景。
经过“熟悉开发API、吃透核心原理与玩转子框架”这三步走之后你就建立了属于自己的Spark知识体系完全跨进了Spark应用开发的大门。
对于绝大多数的数据应用需求来说,我相信你都能够游刃有余地做到灵活应对,分分钟交付一个满足业务需求、运行稳定、且执行性能良好的分布式应用。
最后欢迎你在这里畅所欲言提出你的困惑和疑问也欢迎多多给我留言你们的鼓励是我的动力。三步走的路线已经规划完毕让我们一起携手并进、轻松而又愉快地完成Spark的入门之旅吧
掌握了Spark这项傍身之计我坚信它可以让你在笔试、面试或是日常的工作中脱颖而出从而让Spark为你的职业发展增光添彩

View File

@ -0,0 +1,254 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
01 Spark从“大数据的Hello World”开始
你好,我是吴磊。
从这节课开始我们先来学习Spark的“基础知识”模块对Spark的概念和核心原理先做一个整体的了解。我并不会从RDD、DAG这些基本概念给你讲起。坦白地说这些抽象的概念枯燥而又乏味对于刚开始接触Spark的你来说很难学进去。因此我们不妨反其道而行之先从实战入手用一个小例子来直观地认识Spark看看Spark都能做些什么。
这就好比我们学习一门新的编程语言往往都是从“Hello World”开始。我还记得刚刚学编程那会屏幕上打印出的“Hello World”足足让我兴奋了一整天让我莫名地有一种“I can change the world”的冲动。
今天这一讲我们就从“大数据的Hello World”开始去学习怎么在Spark之上做应用开发。不过“大数据的Hello World”并不是把字符串打印到屏幕上这么简单而是要先对文件中的单词做统计计数然后再打印出频次最高的5个单词江湖人称“Word Count”。
之所以会选择Word Count作为我们迈入Spark门槛的第一个项目主要有两个原因一是Word Count场景比较简单、容易理解二是Word Count麻雀虽小但五脏俱全一个小小的Word Count就能够牵引出Spark许多的核心原理帮助我们快速入门。
好啦话不多说下面我们正式开启Word Count之旅。
准备工作
巧妇难为无米之炊要做Word Count我们得先把源文件准备好。
咱们做Word Count的初衷是学习Spark因此源文件的内容无足轻重。这里我提取了Wikipedia中对Spark的介绍来做我们的源文件。我把它保存到了与课程配套的GitHub项目中并把它命名为“wikiOfSpark.txt”。你可以从这里下载它。
为了跑通Word Count实例我们还需要在本地Local部署Spark运行环境。这里的“本地”指的是你手头能够获取到的任何计算资源比如服务器、台式机或是笔记本电脑。
在本地部署Spark运行环境非常简单即便你从来没有和Spark打过交道也不必担心。只需要下面这3个步骤我们就可以完成Spark的本地部署了。
下载安装包从Spark官网下载安装包选择最新的预编译版本即可
解压解压Spark安装包到任意本地目录
配置:将“${解压目录}/bin”配置到PATH环境变量。
我这里给你准备了一个本地部署的小视频,你可以直观地感受一下。
接下来我们确认一下Spark是否部署成功。打开命令行终端敲入“spark-shell version”命令如果该命令能成功地打印出Spark版本号就表示我们大功告成了就像这样
在后续的实战中我们会用spark-shell来演示Word Count的执行过程。spark-shell是提交Spark作业众多方式中的一种我们在后续的课程中还会展开介绍这里你不妨暂时把它当做是Spark中的Linux shell。spark-shell提供交互式的运行环境REPLRead-Evaluate-Print-Loop以“所见即所得”的方式让开发者在提交源代码之后就可以迅速地获取执行结果。
不过需要注意的是spark-shell在运行的时候依赖于Java和Scala语言环境。因此为了保证spark-shell的成功启动你需要在本地预装Java与Scala。好消息是关于Java与Scala的安装网上的资料非常丰富你可以参考那些资料来进行安装咱们在本讲就不再赘述Java与Scala的安装步骤啦。
梳理Word Count的计算步骤
做了一番准备之后接下来我们就可以开始写代码了。不过在“下手”之前咱们不妨一起梳理下Word Count的计算步骤先做到心中有数然后再垒代码也不迟。
之前我们提到Word Count的初衷是对文件中的单词做统计计数打印出频次最高的5个词汇。那么Word Count的第一步就很明显了当然是得读取文件的内容不然咱们统计什么呢
我们准备好的文件是wikiOfSpark.txt它以纯文本的方式记录了关于Spark的简单介绍我摘取了其中的部分内容给你看一下
我们知道文件的读取往往是以行Line为单位的。不难发现wikiOfSpark.txt的每一行都包含多个单词。
我们要是以“单词”作为粒度做计数就需要对每一行的文本做分词。分词过后文件中的每一句话都被打散成了一个个单词。这样一来我们就可以按照单词做分组计数了。这就是Word Count的计算过程主要包含如下3个步骤
读取内容调用Spark文件读取API加载wikiOfSpark.txt文件内容
分词:以行为单位,把句子打散为单词;
分组计数:按照单词做分组计数。
明确了计算步骤后接下来我们就可以调用Spark开发API对这些步骤进行代码实现从而完成Word Count的应用开发。
众所周知Spark支持种类丰富的开发语言如Scala、Java、Python等等。你可以结合个人偏好和开发习惯任意选择其中的一种进行开发。尽管不同语言的开发API在语法上有着细微的差异但不论是功能方面、还是性能方面Spark对于每一种语言的支持都是一致的。换句话说同样是Word Count你用Scala实现也行用Python实现也可以两份代码的执行结果是一致的。不仅如此在同样的计算资源下两份代码的执行效率也是一样的。
因此就Word Count这个示例来说开发语言不是重点我们不妨选择Scala。你可能会说“我本来对Spark就不熟更没有接触过Scala一上来就用Scala演示Spark应用代码理解起来会不会很困难
其实大可不必担心Scala语法比较简洁Word Count的Scala实现不超过10行代码。再者对于Word Count中的每一行Scala代码我会带着你手把手、逐行地进行讲解和分析。我相信跟着我过完一遍代码之后你能很快地把它“翻译”成你熟悉的语言比如Java或Python。另外绝大多数的Spark 源码都是由 Scala 实现的接触并了解一些Scala的基本语法有利于你后续阅读、学习Spark源代码。
Word Count代码实现
选定了语言接下来我们就按照读取内容、分词、分组计数这三步来看看Word Count具体怎么实现。
第一步,读取内容
首先我们调用SparkContext的textFile方法读取源文件也就是wikiOfSpark.txt代码如下表所示
import org.apache.spark.rdd.RDD
// 这里的下划线"_"是占位符,代表数据文件的根目录
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
在这段代码中你可能会发现3个新概念分别是spark、sparkContext和RDD。
其中spark和sparkContext分别是两种不同的开发入口实例
spark是开发入口SparkSession实例InstanceSparkSession在spark-shell中会由系统自动创建
sparkContext是开发入口SparkContext实例。
在Spark版本演进的过程中从2.0版本开始SparkSession取代了SparkContext成为统一的开发入口。换句话说要开发Spark应用你必须先创建SparkSession。关于SparkSession和SparkContext我会在后续的课程做更详细的介绍这里你只要记住它们是必需的开发入口就可以了。
我们再来看看RDDRDD的全称是Resilient Distributed Dataset意思是“弹性分布式数据集”。RDD是Spark对于分布式数据的统一抽象它定义了一系列分布式数据的基本属性与处理方法。关于RDD的定义、内涵与作用我们留到[下一讲]再去展开。
在这里你不妨先简单地把RDD理解成“数组”比如代码中的lineRDD变量它的类型是RDD[String]你可以暂时把它当成元素类型是String的数组数组的每个元素都是文件中的一行字符串。
获取到文件内容之后,下一步我们就要做分词了。
第二步,分词
“分词”就是把“数组”的行元素打散为单词。要实现这一点我们可以调用RDD的flatMap方法来完成。flatMap操作在逻辑上可以分成两个步骤映射和展平。
这两个步骤是什么意思呢我们还是结合Word Count的例子来看
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
要把lineRDD的行元素转换为单词我们得先用分隔符对每个行元素进行分割Split咱们这里的分隔符是空格。
分割之后每个行元素就都变成了单词数组元素类型也从String变成了Array[String],像这样以元素为单位进行转换的操作,统一称作“映射”。
映射过后RDD类型由原来的RDD[String]变为RDD[Array[String]]。如果把RDD[String]看成是“数组”的话那么RDD[Array[String]]就是一个“二维数组”,它的每一个元素都是单词。
为了后续对单词做分组,我们还需要对这个“二维数组”做展平,也就是去掉内层的嵌套结构,把“二维数组”还原成“一维数组”,如下图所示。
就这样在flatMap算子的作用下原来以行为元素的lineRDD转换成了以单词为元素的wordRDD。
不过值得注意的是我们用“空格”去分割句子有可能会产生空字符串。所以在完成“映射”和“展平”之后对于这样的“单词”我们要把其中的空字符串都过滤掉这里我们调用RDD的filter方法来过滤
// 过滤掉空字符串
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
这样一来我们在分词阶段就得到了过滤掉空字符串之后的单词“数组”类型是RDD[String]。接下来,我们就可以准备做分组计数了。
第三步,分组计数
在RDD的开发框架下聚合类操作如计数、求和、求均值需要依赖键值对Key Value Pair类型的数据元素也就是KeyValue形式的“数组”元素。
因此在调用聚合算子做分组计数之前我们要先把RDD元素转换为KeyValue的形式也就是把RDD[String]映射成RDD[(String, Int)]。
其中我们统一把所有的Value置为1。这样一来对于同一个的单词在后续的计数运算中我们只要对Value做累加即可就像这样
下面是对应的代码:
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
这样一来RDD就由原来存储String元素的cleanWordRDD转换为了存储StringInt的kvRDD。
完成了形式的转换之后我们就该正式做分组计数了。分组计数其实是两个步骤也就是先“分组”再“计数”。下面我们使用聚合算子reduceByKey来同时完成分组和计数这两个操作。
对于kvRDD这个键值对“数组”reduceByKey先是按照Key也就是单词来做分组分组之后每个单词都有一个与之对应的Value列表。然后根据用户提供的聚合函数对同一个Key的所有Value做reduce运算。
这里的reduce你可以理解成是一种计算步骤或是一种计算方法。当我们给定聚合函数后它会用折叠的方式把包含多个元素的列表转换为单个元素值从而统计出不同元素的数量。
在Word Count的示例中我们调用reduceByKey实现分组计算的代码如下
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
可以看到我们传递给reduceByKey算子的聚合函数是(x, y) => x + y也就是累加函数。因此在每个单词分组之后reduce会使用累加函数依次折叠计算Value列表中的所有元素最终把元素列表转换为单词的频次。对于任意一个单词来说reduce的计算过程都是一样的如下图所示。
reduceByKey完成计算之后我们得到的依然是类型为RDD[(String, Int)]的RDD。不过与kvRDD不同wordCounts元素的Value值记录的是每个单词的统计词频。到此为止我们就完成了Word Count主逻辑的开发与实现。
在程序的最后我们还要把wordCounts按照词频做排序并把词频最高的5个单词打印到屏幕上代码如下所示。
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
代码执行
应用开发完成之后我们就可以把代码丢进已经准备好的本地Spark部署环境里啦。首先我们打开命令行终端Terminal敲入“spark-shell”打开交互式运行环境如下图所示。
然后把我们开发好的代码依次敲入spark-shell。为了方便你操作我把完整的代码实现整理到下面了
import org.apache.spark.rdd.RDD
// 这里的下划线"_"是占位符,代表数据文件的根目录
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
我们把上面的代码依次敲入到spark-shell之后spark-shell最终会把词频最高的5个单词打印到屏幕上
在Wikipedia的Spark介绍文本中词频最高的单词分别是the、Spark、a、and和of除了“Spark”之外其他4个单词都是常用的停用词Stop Word因此它们几个高居榜首也就不足为怪了。
好啦到此为止我们在Spark之上完成了“大数据领域Hello World”的开发与实现恭喜你跨入大数据开发的大门
重点回顾
今天这一讲我们围绕着Word Count初步探索并体验了Spark应用开发。你首先需要掌握的是Spark的本地部署从而可以通过spark-shell来迅速熟悉Spark获得对Spark的“第一印象”。要在本地部署Spark你需要遵循3个步骤
从Spark官网下载安装包选择最新的预编译版本即可
解压Spark安装包到任意本地目录
将“${解压目录}/bin”配置到PATH环境变量。
然后我们一起分析并实现了入门Spark的第一个应用程序Word Count。在我们的例子中Word Count要完成的计算任务是先对文件中的单词做统计计数然后再打印出频次最高的5个单词。它的实现过程分为3个步骤
读取内容调用Spark文件读取API加载wikiOfSpark.txt文件内容
分词:以行为单位,把句子打散为单词;
分组计数:按照单词做分组计数。
也许你对RDD API还不熟悉甚至从未接触过Scala不过没关系完成了这次“大数据的Hello World”开发之旅你就已经踏上了新的征程。在接下来的课程里让我们携手并肩像探索新大陆一样一层一层地剥开Spark的神秘面纱加油
每课一练
在Word Count的代码实现中我们用到了多种多样的RDD算子如map、filter、flatMap和reduceByKey除了这些算子以外你知道还有哪些常用的RDD算子吗提示可以结合官网去查找
另外,你能说说,以上这些算子都有哪些共性或是共同点吗?
欢迎你把答案分享到评论区,我在评论区等你。
如果这一讲对你有帮助,也欢迎你分享给自己的朋友,我们下一讲再见!

View File

@ -0,0 +1,195 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
02 RDD与编程模型延迟计算是怎么回事
你好,我是吴磊。
在上一讲我们一起开发了一个Word Count小应用并把它敲入到spark-shell中去执行。Word Count的计算步骤非常简单首先是读取数据源然后是做分词最后做分组计数、并把词频最高的几个词汇打印到屏幕上。
如果你也动手实践了这个示例你可能会发现在spark-shell的REPL里所有代码都是立即返回、瞬间就执行完毕了相比之下只有最后一行代码花了好长时间才在屏幕上打印出the、Spark、a、and和of这几个单词。
针对这个现象你可能会觉得很奇怪“读取数据源、分组计数应该是最耗时的步骤为什么它们瞬间就返回了呢打印单词应该是瞬间的事为什么这一步反而是最耗时的呢”要解答这个疑惑我们还是得从RDD说起。
什么是RDD
为什么非要从RDD说起呢首先RDD是构建Spark分布式内存计算引擎的基石很多Spark核心概念与核心组件如DAG和调度系统都衍生自RDD。因此深入理解RDD有利于你更全面、系统地学习 Spark 的工作原理。
其次尽管RDD API使用频率越来越低绝大多数人也都已经习惯于DataFrame和Dataset API但是无论采用哪种API或是哪种开发语言你的应用在Spark内部最终都会转化为RDD之上的分布式计算。换句话说如果你想要对Spark作业有更好的把握前提是你要对RDD足够了解。
既然RDD如此重要那么它到底是什么呢用一句话来概括RDD是一种抽象是Spark对于分布式数据集的抽象它用于囊括所有内存中和磁盘中的分布式数据实体。
在[上一讲]中我们把RDD看作是数组咱们不妨延续这个思路通过对比RDD与数组之间的差异认识一下RDD。
我列了一个表做了一下RDD和数组对比你可以先扫一眼
我在表中从四个方面对数组和RDD进行了对比现在我来详细解释一下。
首先就概念本身来说数组是实体它是一种存储同类元素的数据结构而RDD是一种抽象它所囊括的是分布式计算环境中的分布式数据集。
因此这两者第二方面的不同就是在活动范围数组的“活动范围”很窄仅限于单个计算节点的某个进程内而RDD代表的数据集是跨进程、跨节点的它的“活动范围”是整个集群。
至于数组和RDD的第三个不同则是在数据定位方面。在数组中承载数据的基本单元是元素而RDD中承载数据的基本单元是数据分片。在分布式计算环境中一份完整的数据集会按照某种规则切割成多份数据分片。这些数据分片被均匀地分发给集群内不同的计算节点和执行进程从而实现分布式并行计算。
通过以上对比不难发现数据分片Partitions是RDD抽象的重要属性之一。在初步认识了RDD之后接下来咱们换个视角从RDD的重要属性出发去进一步深入理解RDD。要想吃透RDD我们必须掌握它的4大属性
partitions数据分片
partitioner分片切割规则
dependenciesRDD依赖
compute转换函数
如果单从理论出发、照本宣科地去讲这4大属性未免过于枯燥、乏味、没意思所以我们从一个制作薯片的故事开始去更好地理解RDD的4大属性。
从薯片的加工流程看RDD的4大属性
在很久很久以前,有个生产桶装薯片的工坊,工坊的规模较小,工艺也比较原始。为了充分利用每一颗土豆、降低生产成本,工坊使用 3 条流水线来同时生产 3 种不同尺寸的桶装薯片。3 条流水线可以同时加工 3 颗土豆,每条流水线的作业流程都是一样的,分别是清洗、切片、烘焙、分发和装桶。其中,分发环节用于区分小、中、大号 3 种薯片3 种不同尺寸的薯片分别被发往第 1、2、3 条流水线。具体流程如下图所示。
好了,故事讲完了。那如果我们把每一条流水线看作是分布式运行环境的计算节点,用薯片生产的流程去类比 Spark 分布式计算,会有哪些有趣的发现呢?
显然这里的每一种食材形态如“带泥土豆”、“干净土豆”、“土豆片”等都可以看成是一个个RDD。而薯片的制作过程实际上就是不同食材形态的转换过程。
起初,工人们从麻袋中把“带泥土豆”加载到流水线,这些土豆经过清洗之后,摇身一变,成了“干净土豆”。接下来,流水线上的切片机再把“干净土豆”切成“土豆片”,然后紧接着把这些土豆片放进烤箱。最终,土豆片烤熟之后,就变成了可以放心食用的即食薯片。
通过分析我们不难发现不同食材形态之间的转换过程与Word Count中不同RDD之间的转换过程如出一辙。
所以接下来我们就结合薯片的制作流程去理解RDD的4大属性。
首先,咱们沿着纵向,也就是从上到下的方向,去观察上图中土豆工坊的制作工艺。
我们可以看到对于每一种食材形态来说流水线上都有多个实物与之对应比如“带泥土豆”是一种食材形态流水线上总共有3颗“脏兮兮”的土豆同属于这一形态。
如果把“带泥土豆”看成是RDD的话那么RDD的partitions属性囊括的正是麻袋里那一颗颗脏兮兮的土豆。同理流水线上所有洗净的土豆一同构成了“干净土豆”RDD的partitions属性。
我们再来看RDD的partitioner属性这个属性定义了把原始数据集切割成数据分片的切割规则。在土豆工坊的例子中“带泥土豆”RDD的切割规则是随机拿取也就是从麻袋中随机拿取一颗脏兮兮的土豆放到流水线上。后面的食材形态如“干净土豆”、“土豆片”和“即食薯片”则沿用了“带泥土豆”RDD的切割规则。换句话说后续的这些RDD分别继承了前一个RDD的partitioner属性。
这里面与众不同的是“分发的即食薯片”。显然“分发的即食薯片”是通过对“即食薯片”按照大、中、小号做分发得到的。也就是说对于“分发的即食薯片”来说它的partitioner属性重新定义了这个RDD数据分片的切割规则也就是把先前RDD的数据分片打散按照薯片尺寸重新构建数据分片。
由这个例子我们可以看出数据分片的分布是由RDD的partitioner决定的。因此RDD的partitions属性与它的partitioner属性是强相关的。
横看成岭侧成峰,很多事情换个视角看,相貌可能会完全不同。所以接下来,我们横向地,也就是沿着从左至右的方向,再来观察土豆工坊的制作工艺。
不难发现流水线上的每一种食材形态都是上一种食材形态在某种操作下进行转换得到的。比如“土豆片”依赖的食材形态是“干净土豆”这中间用于转换的操作是“切片”这个动作。回顾Word Count当中RDD之间的转换关系我们也会发现类似的现象。
在数据形态的转换过程中每个RDD都会通过dependencies属性来记录它所依赖的前一个、或是多个RDD简称“父RDD”。与此同时RDD使用compute属性来记录从父RDD到当前RDD的转换操作。
拿Word Count当中的wordRDD来举例它的父RDD是lineRDD因此它的dependencies属性记录的是lineRDD。从lineRDD到wordRDD的转换其所依赖的操作是flatMap因此wordRDD的compute属性记录的是flatMap这个转换函数。
总结下来薯片的加工流程与RDD的概念和4大属性是一一对应的
不同的食材形态如带泥土豆、土豆片、即食薯片等等对应的就是RDD概念
同一种食材形态在不同流水线上的具体实物,就是 RDD 的 partitions 属性;
食材按照什么规则被分配到哪条流水线,对应的就是 RDD 的 partitioner 属性;
每一种食材形态都会依赖上一种形态,这种依赖关系对应的是 RDD 中的 dependencies 属性;
不同环节的加工方法对应 RDD的 compute 属性。
在你理解了RDD的4大属性之后还需要进一步了解RDD的编程模型和延迟计算。编程模型指导我们如何进行代码实现而延迟计算是Spark分布式运行机制的基础。只有搞明白编程模型与延迟计算你才能流畅地在Spark之上做应用开发在实现业务逻辑的同时避免埋下性能隐患。
编程模型与延迟计算
你还记得我在上一讲的最后给你留的一道思考题吗map、filter、flatMap和reduceByKey这些算子有哪些共同点现在我们来揭晓答案
首先这4个算子都是作用Apply在RDD之上、用来做RDD之间的转换。比如flatMap作用在lineRDD之上把lineRDD转换为wordRDD。
其次这些算子本身是函数而且它们的参数也是函数。参数是函数、或者返回值是函数的函数我们把这类函数统称为“高阶函数”Higher-order Functions。换句话说这4个算子都是高阶函数。
关于高阶函数的作用与优劣势我们留到后面再去展开。这里我们先专注在RDD算子的第一个共性RDD转换。
RDD是Spark对于分布式数据集的抽象每一个RDD都代表着一种分布式数据形态。比如lineRDD它表示数据在集群中以行Line的形式存在而wordRDD则意味着数据的形态是单词分布在计算集群中。
理解了RDD那什么是RDD转换呢别着急我来以上次Word Count的实现代码为例来给你讲一下。以下是我们上次用的代码
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
回顾Word Count示例我们会发现Word Count的实现过程实际上就是不同RDD之间的一个转换过程。仔细观察我们会发现Word Count示例中一共有4次RDD的转换我来具体解释一下
起初我们通过调用textFile API生成lineRDD然后用flatMap算子把lineRDD转换为wordRDD-
接下来filter算子对wordRDD做过滤并把它转换为不带空串的cleanWordRDD-
然后为了后续的聚合计算map算子把cleanWordRDD又转换成元素为KeyValue对的kvRDD-
最终我们调用reduceByKey做分组聚合把kvRDD中的Value从1转换为单词计数。
这4步转换的过程如下图所示
我们刚刚说过RDD代表的是分布式数据形态因此RDD到RDD之间的转换本质上是数据形态上的转换Transformations
在RDD的编程模型中一共有两种算子Transformations类算子和Actions类算子。开发者需要使用Transformations类算子定义并描述数据形态的转换过程然后调用Actions类算子将计算结果收集起来、或是物化到磁盘。
在这样的编程模型下Spark在运行时的计算被划分为两个环节。
基于不同数据形态之间的转换构建计算流图DAGDirected Acyclic Graph
通过Actions类算子以回溯的方式去触发执行这个计算流图。
换句话说开发者调用的各类Transformations算子并不立即执行计算当且仅当开发者调用Actions算子时之前调用的转换算子才会付诸执行。在业内这样的计算模式有个专门的术语叫作“延迟计算”Lazy Evaluation
延迟计算很好地解释了本讲开头的问题为什么Word Count在执行的过程中只有最后一行代码会花费很长时间而前面的代码都是瞬间执行完毕的呢
这里的答案正是Spark的延迟计算。flatMap、filter、map这些算子仅用于构建计算流图因此当你在spark-shell中敲入这些代码时spark-shell会立即返回。只有在你敲入最后那行包含take的代码时Spark才会触发执行从头到尾的计算流程所以直观地看上去最后一行代码是最耗时的。
Spark程序的整个运行流程如下图所示
你可能会问“在RDD的开发框架下哪些算子属于Transformations算子哪些算子是Actions算子呢
我们都知道Spark有很多算子Spark官网提供了完整的RDD算子集合不过对于这些算子官网更多地是采用一种罗列的方式去呈现的没有进行分类看得人眼花缭乱、昏昏欲睡。因此我把常用的RDD算子进行了归类并整理到了下面的表格中供你随时查阅。
结合每个算子的分类、用途和适用场景这张表格可以帮你更快、更高效地选择合适的算子来实现业务逻辑。对于表格中不熟悉的算子比如aggregateByKey你可以结合官网的介绍与解释或是进一步查阅网上的相关资料有的放矢地去深入理解。重要的算子我们会在之后的课里详细解释。
重点回顾
今天这一讲我们重点讲解了RDD的编程模型与延迟计算并通过土豆工坊的类比介绍了什么是RDD。RDD是Spark对于分布式数据集的抽象它用于囊括所有内存中和磁盘中的分布式数据实体。对于RDD你要重点掌握它的4大属性这是我们后续学习的重要基础
partitions数据分片
partitioner分片切割规则
dependenciesRDD依赖
compute转换函数
深入理解RDD之后你需要熟悉RDD的编程模型。在RDD的编程模型中开发者需要使用Transformations类算子定义并描述数据形态的转换过程然后调用Actions类算子将计算结果收集起来、或是物化到磁盘。
而延迟计算指的是开发者调用的各类Transformations算子并不会立即执行计算当且仅当开发者调用Actions算子时之前调用的转换算子才会付诸执行。
每课一练
对于Word Count的计算流图与土豆工坊的流水线工艺尽管看上去毫不相关风马牛不相及不过你不妨花点时间想一想它们之间有哪些区别和联系
欢迎你把答案分享到评论区,我在评论区等你,也欢迎你把这一讲分享给更多的朋友和同事,我们下一讲见!

View File

@ -0,0 +1,273 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
03 RDD常用算子RDD内部的数据转换
你好,我是吴磊。
在上一讲的最后我们用一张表格整理了Spark官网给出的RDD算子。想要在Spark之上快速实现业务逻辑理解并掌握这些算子无疑是至关重要的。
因此,在接下来的几讲,我将带你一起梳理这些常见算子的用法与用途。不同的算子,就像是厨房里的炒勺、铲子、刀具和各式各样的锅碗瓢盆,只有熟悉了这些“厨具”的操作方法,才能在客人点餐的时候迅速地做出一桌好菜。
今天这一讲我们先来学习同一个RDD内部的数据转换。掌握RDD常用算子是做好Spark应用开发的基础而数据转换类算子则是基础中的基础因此我们优先来学习这类RDD算子。
在这些算子中我们重点讲解的就是map、mapPartitions、flatMap、filter。这4个算子几乎囊括了日常开发中99%的数据转换场景剩下的mapPartitionsWithIndex我把它留给你作为课后作业去探索。
俗话说巧妇难为无米之炊要想玩转厨房里的厨具我们得先准备好米、面、油这些食材。学习RDD算子也是一样要想动手操作这些算子咱们得先有RDD才行。
所以接下来我们就一起来看看RDD是怎么创建的。
创建RDD
在Spark中创建RDD的典型方式有两种
通过SparkContext.parallelize在内部数据之上创建RDD
通过SparkContext.textFile等API从外部数据创建RDD。
这里的内部、外部是相对应用程序来说的。开发者在Spark应用中自定义的各类数据结构如数组、列表、映射等都属于“内部数据”而“外部数据”指代的是Spark系统之外的所有数据形式如本地文件系统或是分布式文件系统中的数据再比如来自其他大数据组件Hive、Hbase、RDBMS等的数据。
第一种创建方式的用法非常简单只需要用parallelize函数来封装内部数据即可比如下面的例子
import org.apache.spark.rdd.RDD
val words: Array[String] = Array("Spark", "is", "cool")
val rdd: RDD[String] = sc.parallelize(words)
你可以在spark-shell中敲入上述代码来直观地感受parallelize创建RDD的过程。通常来说在Spark应用内定义体量超大的数据集其实都是不太合适的因为数据集完全由Driver端创建且创建完成后还要在全网范围内跨节点、跨进程地分发到其他Executors所以往往会带来性能问题。因此parallelize API的典型用法是在“小数据”之上创建RDD。
要想在真正的“大数据”之上创建RDD我们还得依赖第二种创建方式也就是通过SparkContext.textFile等API从外部数据创建RDD。由于textFile API比较简单而且它在日常的开发中出现频率比较高因此我们使用textFile API来创建RDD。在后续对各类RDD算子讲解的过程中我们都会使用textFile API从文件系统创建RDD。
为了保持讲解的连贯性我们还是使用第一讲中的源文件wikiOfSpark.txt来创建RDD代码实现如下所示
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
好啦创建好了RDD我们就有了可以下锅的食材。接下来咱们就要正式地走进厨房把铲子和炒勺挥起来啦。
RDD内的数据转换
首先我们先来认识一下map算子。毫不夸张地说在所有的RDD算子中map“出场”的概率是最高的。因此我们必须要掌握map的用法与注意事项。
map以元素为粒度的数据转换
我们先来说说map算子的用法给定映射函数fmap(f)以元素为粒度对RDD做数据转换。其中f可以是带有明确签名的带名函数也可以是匿名函数它的形参类型必须与RDD的元素类型保持一致而输出类型则任由开发者自行决定。
这种照本宣科的介绍听上去难免会让你有点懵别着急接下来我们用些小例子来更加直观地展示map的用法。
在[第一讲]的Word Count示例中我们使用如下代码把包含单词的RDD转换成元素为KeyValue对的RDD后者统称为Paired RDD。
// 把普通RDD转换为Paired RDD
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
在上面的代码实现中传递给map算子的形参word => word1就是我们上面说的映射函数f。只不过这里f是以匿名函数的方式进行定义的其中左侧的word表示匿名函数f的输入形参而右侧的word1则代表函数f的输出结果。
如果我们把匿名函数变成带名函数的话可能你会看的更清楚一些。这里我用一段代码重新定义了带名函数f。
// 把RDD元素转换为KeyValue的形式
// 定义映射函数f
def f(word: String): (String, Int) = {
return (word, 1)
}
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(f)
可以看到我们使用Scala的def语法明确定义了带名映射函数f它的计算逻辑与刚刚的匿名函数是一致的。在做RDD数据转换的时候我们只需把函数f传递给map算子即可。不管f是匿名函数还是带名函数map算子的转换逻辑都是一样的你不妨把以上两种实现方式分别敲入到spark-shell去验证执行结果的一致性。
到这里为止我们就掌握了map算子的基本用法。现在你就可以定义任意复杂的映射函数f然后在RDD之上通过调用map(f)去翻着花样地做各种各样的数据转换。
比如通过定义如下的映射函数f我们就可以改写Word Count的计数逻辑也就是把“Spark”这个单词的统计计数权重提高一倍
// 把RDD元素转换为KeyValue的形式
// 定义映射函数f
def f(word: String): (String, Int) = {
if (word.equals("Spark")) { return (word, 2) }
return (word, 1)
}
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(f)
尽管map算子足够灵活允许开发者自由定义转换逻辑。不过就像我们刚刚说的map(f)是以元素为粒度对RDD做数据转换的在某些计算场景下这个特点会严重影响执行效率。为什么这么说呢我们来看一个具体的例子。
比方说我们把Word Count的计数需求从原来的对单词计数改为对单词的哈希值计数在这种情况下我们的代码实现需要做哪些改动呢我来示范一下
// 把普通RDD转换为Paired RDD
import java.security.MessageDigest
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map{ word =>
// 获取MD5对象实例
val md5 = MessageDigest.getInstance("MD5")
// 使用MD5计算哈希值
val hash = md5.digest(word.getBytes).mkString
// 返回哈希值与数字1的Pair
(hash, 1)
}
由于map(f)是以元素为单元做转换的那么对于RDD中的每一条数据记录我们都需要实例化一个MessageDigest对象来计算这个元素的哈希值。
在工业级生产系统中一个RDD动辄包含上百万甚至是上亿级别的数据记录如果处理每条记录都需要事先创建MessageDigest那么实例化对象的开销就会聚沙成塔不知不觉地成为影响执行效率的罪魁祸首。
那么问题来了有没有什么办法能够让Spark在更粗的数据粒度上去处理数据呢还真有mapPartitions和mapPartitionsWithIndex这对“孪生兄弟”就是用来解决类似的问题。相比mapPartitionsmapPartitionsWithIndex仅仅多出了一个数据分区索引因此接下来我们把重点放在mapPartitions上面。
mapPartitions以数据分区为粒度的数据转换
按照介绍算子的惯例我们还是先来说说mapPartitions的用法。mapPartitions顾名思义就是以数据分区为粒度使用映射函数f对RDD进行数据转换。对于上述单词哈希值计数的例子我们结合后面的代码来看看如何使用mapPartitions来改善执行性能
// 把普通RDD转换为Paired RDD
import java.security.MessageDigest
val cleanWordRDD: RDD[String] = _ // 请参考第一讲获取完整代码
val kvRDD: RDD[(String, Int)] = cleanWordRDD.mapPartitions( partition => {
// 注意这里是以数据分区为粒度获取MD5对象实例
val md5 = MessageDigest.getInstance("MD5")
val newPartition = partition.map( word => {
// 在处理每一条数据记录的时候可以复用同一个Partition内的MD5对象
(md5.digest(word.getBytes()).mkString,1)
})
newPartition
})
可以看到在上面的改进代码中mapPartitions以数据分区匿名函数的形参partition为粒度对RDD进行数据转换。具体的数据处理逻辑则由代表数据分区的形参partition进一步调用map(f)来完成。你可能会说“partition. map(f)仍然是以元素为粒度做映射呀!这和前一个版本的实现,有什么本质上的区别呢?”
仔细观察你就会发现相比前一个版本我们把实例化MD5对象的语句挪到了map算子之外。如此一来以数据分区为单位实例化对象的操作只需要执行一次而同一个数据分区中所有的数据记录都可以共享该MD5对象从而完成单词到哈希值的转换。
通过下图的直观对比你会发现以数据分区为单位mapPartitions只需实例化一次MD5对象而map算子却需要实例化多次具体的次数则由分区内数据记录的数量来决定。
对于一个有着上百万条记录的RDD来说其数据分区的划分往往是在百这个量级因此相比map算子mapPartitions可以显著降低对象实例化的计算开销这对于Spark作业端到端的执行性能来说无疑是非常友好的。
实际上。除了计算哈希值以外对于数据记录来说凡是可以共享的操作都可以用mapPartitions算子进行优化。这样的共享操作还有很多比如创建用于连接远端数据库的Connections对象或是用于连接Amazon S3的文件系统句柄再比如用于在线推理的机器学习模型等等不一而足。你不妨结合实际工作场景把你遇到的共享操作整理到留言区期待你的分享。
相比mapPartitionsmapPartitionsWithIndex仅仅多出了一个数据分区索引这个数据分区索引可以为我们获取分区编号当你的业务逻辑中需要使用到分区编号的时候不妨考虑使用这个算子来实现代码。除了这个额外的分区索引以外mapPartitionsWithIndex在其他方面与mapPartitions是完全一样的。
介绍完map与mapPartitions算子之后接下来我们趁热打铁再来看一个与这两者功能类似的算子flatMap。
flatMap从元素到集合、再从集合到元素
flatMap其实和map与mapPartitions算子类似在功能上与map和mapPartitions一样flatMap也是用来做数据映射的在实现上对于给定映射函数fflatMap(f)以元素为粒度对RDD进行数据转换。
不过与前两者相比flatMap的映射函数f有着显著的不同。对于map和mapPartitions来说其映射函数f的类型都是元素 => 元素即元素到元素。而flatMap映射函数f的类型元素 => 集合即元素到集合如数组、列表等。因此flatMap的映射过程在逻辑上分为两步
以元素为单位,创建集合;
去掉集合“外包装”,提取集合元素。
这么说比较抽象我们还是来举例说明。假设我们再次改变Word Count的计算逻辑由原来统计单词的计数改为统计相邻单词共现的次数如下图所示
对于这样的计算逻辑我们该如何使用flatMap进行实现呢这里我们先给出代码实现然后再分阶段地分析flatMap的映射过程
// 读取文件内容
val lineRDD: RDD[String] = _ // 请参考第一讲获取完整代码
// 以行为单位提取相邻单词
val wordPairRDD: RDD[String] = lineRDD.flatMap( line => {
// 将行转换为单词数组
val words: Array[String] = line.split(" ")
// 将单个单词数组,转换为相邻单词数组
for (i <- 0 until words.length - 1) yield words(i) + "-" + words(i+1)
})
在上面的代码中我们采用匿名函数的形式来提供映射函数f。这里f的形参是String类型的line也就是源文件中的一行文本而f的返回类型是Array[String]也就是String类型的数组。在映射函数f的函数体中我们先用split语句把line转化为单词数组然后再用for循环结合yield语句依次把单个的单词转化为相邻单词词对。
注意for循环返回的依然是数组也即类型为Array[String]的词对数组。由此可见函数f的类型是String => Array[String]也就是刚刚说的第一步从元素到集合。但如果我们去观察转换前后的两个RDD也就是lineRDD和wordPairRDD会发现它们的类型都是RDD[String]换句话说它们的元素类型都是String。
回顾map与mapPartitions这两个算子我们会发现转换前后RDD的元素类型与映射函数f的类型是一致的。但在flatMap这里却出现了RDD元素类型与函数类型不一致的情况。这是怎么回事呢其实呢这正是flatMap的“奥妙”所在为了让你直观地理解flatMap的映射过程我画了一张示意图如下所示
不难发现映射函数f的计算过程对应着图中的步骤1与步骤2每行文本都被转化为包含相邻词对的数组。紧接着flatMap去掉每个数组的“外包装”提取出数组中类型为String的词对元素然后以词对为单位构建新的数据分区如图中步骤3所示。这就是flatMap映射过程的第二步去掉集合“外包装”提取集合元素。
得到包含词对元素的wordPairRDD之后我们就可以沿用Word Count的后续逻辑去计算相邻词汇的共现次数。你不妨结合文稿中的代码与第一讲中Word Count的代码去实现完整版的“相邻词汇计数统计”。
filter过滤RDD
在今天的最后我们再来学习一下与map一样常用的算子filter。filter顾名思义这个算子的作用是对RDD进行过滤。就像是map算子依赖其映射函数一样filter算子也需要借助一个判定函数f才能实现对RDD的过滤转换。
所谓判定函数它指的是类型为RDD元素类型 => Boolean的函数。可以看到判定函数f的形参类型必须与RDD的元素类型保持一致而f的返回结果只能是True或者False。在任何一个RDD之上调用filter(f)其作用是保留RDD中满足f也就是f返回True的数据元素而过滤掉不满足f也就是f返回False的数据元素。
老规矩我们还是结合示例来讲解filter算子与判定函数f。
在上面flatMap例子的最后我们得到了元素为相邻词汇对的wordPairRDD它包含的是像“Spark-is”、“is-cool”这样的字符串。为了仅保留有意义的词对元素我们希望结合标点符号列表对wordPairRDD进行过滤。例如我们希望过滤掉像“Spark-&”、“|-data”这样的词对。
掌握了filter算子的用法之后要实现这样的过滤逻辑我相信你很快就能写出如下的代码实现
// 定义特殊字符列表
val list: List[String] = List("&", "|", "#", "^", "@")
// 定义判定函数f
def f(s: String): Boolean = {
val words: Array[String] = s.split("-")
val b1: Boolean = list.contains(words(0))
val b2: Boolean = list.contains(words(1))
return !b1 && !b2 // 返回不在特殊字符列表中的词汇对
}
// 使用filter(f)对RDD进行过滤
val cleanedPairRDD: RDD[String] = wordPairRDD.filter(f)
掌握了filter算子的用法之后你就可以定义任意复杂的判定函数f然后在RDD之上通过调用filter(f)去变着花样地做数据过滤,从而满足不同的业务需求。
重点回顾
好啦到此为止关于RDD内数据转换的几个算子我们就讲完了我们一起来做个总结。今天这一讲你需要掌握map、mapPartitions、flatMap和filter这4个算子的作用和具体用法。
首先我们讲了map算子的用法它允许开发者自由地对RDD做各式各样的数据转换给定映射函数fmap(f)以元素为粒度对RDD做数据转换。其中f可以是带名函数也可以是匿名函数它的形参类型必须与RDD的元素类型保持一致而输出类型则任由开发者自行决定。
为了提升数据转换的效率Spark提供了以数据分区为粒度的mapPartitions算子。mapPartitions的形参是代表数据分区的partition它通过在partition之上再次调用map(f)来完成数据的转换。相比mapmapPartitions的优势是以数据分区为粒度初始化共享对象这些共享对象在我们日常的开发中很常见比如数据库连接对象、S3文件句柄、机器学习模型等等。
紧接着我们介绍了flatMap算子。flatMap的映射函数f比较特殊它的函数类型是元素 => 集合这里集合指的是像数组、列表这样的数据结构。因此flatMap的映射过程在逻辑上分为两步这一点需要你特别注意
以元素为单位,创建集合;
去掉集合“外包装”,提取集合元素。
最后我们学习了filter算子filter算子的用法与map很像它需要借助判定函数f来完成对RDD的数据过滤。判定函数的类型必须是RDD元素类型 => Boolean也就是形参类型必须与RDD的元素类型保持一致返回结果类型则必须是布尔值。RDD中的元素是否能够得以保留取决于判定函数f的返回值是True还是False。
虽然今天我们只学了4个算子但这4个算子在日常开发中的出现频率非常之高。掌握了这几个简单的RDD算子你几乎可以应对RDD中90%的数据转换场景。希望你对这几个算子多多加以练习,从而在日常的开发工作中学以致用。
每课一练
讲完了正课我来给你留3个思考题
请你结合官网的介绍自学mapPartitionsWithIndex算子。请你说一说在哪些场景下可能会用到这个算子
对于我们今天学过的4个算子再加上没有详细解释的mapPartitionsWithIndex你能说说它们之间有哪些共性或是共同点吗
你能说一说在日常的工作中还遇到过哪些可以在mapPartitions中初始化的共享对象呢
欢迎你在评论区回答这些练习题。你也可以把这一讲分享给更多的朋友或者同事,和他们一起讨论讨论,交流是学习的催化剂。我在评论区等你。

View File

@ -0,0 +1,165 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
04 进程模型与分布式部署:分布式计算是怎么回事?
你好,我是吴磊。
在[第2讲]的最后我们留了一道思考题。Word Count的计算流图与土豆工坊的流水线工艺二者之间有哪些区别和联系如果你有点记不清了可以看下后面的图回忆一下。
我们先来说区别。首先Word Count计算流图是一种抽象的流程图而土豆工坊的流水线是可操作、可运行而又具体的执行步骤。然后计算流图中的每一个元素如lineRDD、wordRDD都是“虚”的数据集抽象而流水线上各个环节不同形态的食材比如一颗颗脏兮兮的土豆都是“实实在在”的实物。
厘清了二者之间的区别之后,它们之间的联系自然也就显而易见了。如果把计算流图看作是“设计图纸”,那么流水线工艺其实就是“施工过程”。前者是设计层面、高屋建瓴的指导意见,而后者是执行层面、按部就班的实施过程。前者是后者的基石,而后者是前者的具化。
你可能会好奇:“我们为什么非要弄清这二者之间的区别和联系呢?”原因其实很简单,分布式计算的精髓,在于如何把抽象的计算流图,转化为实实在在的分布式计算任务,然后以并行计算的方式交付执行。
今天这一讲我们就来聊一聊Spark是如何实现分布式计算的。分布式计算的实现离不开两个关键要素一个是进程模型另一个是分布式的环境部署。接下来我们先去探讨Spark的进程模型然后再来介绍Spark都有哪些分布式部署方式。
进程模型
在Spark的应用开发中任何一个应用程序的入口都是带有SparkSession的main函数。SparkSession包罗万象它在提供Spark运行时上下文的同时如调度系统、存储系统、内存管理、RPC通信也可以为开发者提供创建、转换、计算分布式数据集如RDD的开发API。
不过在Spark分布式计算环境中有且仅有一个JVM进程运行这样的main函数这个特殊的JVM进程在Spark中有个专门的术语叫作“Driver”。
Driver最核心的作用在于解析用户代码、构建计算流图然后将计算流图转化为分布式任务并把任务分发给集群中的执行进程交付运行。换句话说Driver的角色是拆解任务、派活儿而真正干活儿的“苦力”是执行进程。在Spark的分布式环境中这样的执行进程可以有一个或是多个它们也有专门的术语叫作“Executor”。
我把Driver和Executor的关系画成了一张图你可以看看
分布式计算的核心是任务调度而分布式任务的调度与执行仰仗的是Driver与Executors之间的通力合作。在后续的课程中我们会深入讲解Driver如何与众多Executors协作完成任务调度不过在此之前咱们先要厘清Driver与Executors的关系从而为后续的课程打下坚实的基础。
Driver与Executors包工头与施工工人
简单来看Driver与Executors的关系就像是工地上包工头与施工工人们之间的关系。包工头负责“揽活儿”拿到设计图纸之后负责拆解任务把二维平面图细化成夯土、打地基、砌墙、浇筑钢筋混凝土等任务然后再把任务派发给手下的工人。工人们认领到任务之后相对独立地去完成各自的任务仅在必要的时候进行沟通与协调。
其实不同的建筑任务之间往往是存在依赖关系的比如砌墙一定是在地基打成之后才能施工同理浇筑钢筋混凝土也一定要等到砖墙砌成之后才能进行。因此Driver这个“包工头”的重要职责之一就是合理有序地拆解并安排建筑任务。
再者为了保证施工进度Driver除了分发任务之外还需要定期与每个Executor进行沟通及时获取他们的工作进展从而协调整体的执行进度。
一个篱笆三个桩一个好汉三个帮。要履行上述一系列的职责Driver自然需要一些给力的帮手才行。在Spark的Driver进程中DAGScheduler、TaskScheduler和SchedulerBackend这三个对象通力合作依次完成分布式任务调度的3个核心步骤也就是
根据用户代码构建计算流图;-
根据计算流图拆解出分布式任务;-
将分布式任务分发到Executors中去。
接收到任务之后Executors调用内部线程池结合事先分配好的数据分片并发地执行任务代码。对于一个完整的RDD每个Executors负责处理这个RDD的一个数据分片子集。这就好比是对于工地上所有的砖头甲、乙、丙三个工人分别认领其中的三分之一然后拿来分别构筑东、西、北三面高墙。
好啦到目前为止关于Driver和Executors的概念他们各自的职责以及相互之间的关系我们有了最基本的了解。尽管对于一些关键对象如上述DAGScheduler、TaskScheduler我们还有待深入但这并不影响咱们居高临下地去理解Spark进程模型。
不过你可能会说“一说到模型就总觉得抽象能不能结合示例来具体说明呢”接下来我们还是沿用前两讲展示的Word Count示例一起去探究spark-shell在幕后是如何运行的。
spark-shell执行过程解析
在第1讲我们在本机搭建了Spark本地运行环境并通过在终端敲入spark-shell进入交互式REPL。与很多其他系统命令一样spark-shell有很多命令行参数其中最为重要的有两类一类是用于指定部署模式的master另一类则用于指定集群的计算资源容量。
不带任何参数的spark-shell命令实际上等同于下方这个命令
spark-shell --master local[*]
这行代码的含义有两层。第一层含义是部署模式其中local关键字表示部署模式为Local也就是本地部署第二层含义是部署规模也就是方括号里面的数字它表示的是在本地部署中需要启动多少个Executors星号则意味着这个数量与机器中可用CPU的个数相一致。
也就是说假设你的笔记本电脑有4个CPU那么当你在命令行敲入spark-shell的时候Spark会在后台启动1个Driver进程和3个Executors进程。
那么问题来了当我们把Word Count的示例代码依次敲入到spark-shell中Driver进程和3个Executors进程之间是如何通力合作来执行分布式任务的呢
为了帮你理解这个过程,我特意画了一张图,你可以先看一下整体的执行过程:
首先Driver通过take这个Action算子来触发执行先前构建好的计算流图。沿着计算流图的执行方向也就是图中从上到下的方向Driver以Shuffle为边界创建、分发分布式任务。
Shuffle的本意是扑克牌中的“洗牌”在大数据领域的引申义表示的是集群范围内跨进程、跨节点的数据交换。我们在专栏后续的内容中会对Shuffle做专门的讲解这里我们不妨先用Word Count的例子来简单地对Shuffle进行理解。
在reduceByKey算子之前同一个单词比如“spark”可能散落在不用的Executors进程比如图中的Executor-0、Executor-1和Executor-2。换句话说这些Executors处理的数据分片中都包含单词“spark”。
那么要完成对“spark”的计数我们需要把所有“spark”分发到同一个Executor进程才能完成计算。而这个把原本散落在不同Executors的单词分发到同一个Executor的过程就是Shuffle。
大概理解了Shuffle后我们回过头接着说Driver是怎么创建分布式任务的。对于reduceByKey之前的所有操作也就是textFile、flatMap、filter、map等Driver会把它们“捏合”成一份任务然后一次性地把这份任务打包、分发给每一个Executors。
三个Executors接收到任务之后先是对任务进行解析把任务拆解成textFile、flatMap、filter、map这4个步骤然后分别对自己负责的数据分片进行处理。
为了方便说明我们不妨假设并行度为3也就是原始数据文件wikiOfSpark.txt被切割成了3份这样每个Executors刚好处理其中的一份。数据处理完毕之后分片内容就从原来的RDD[String]转换成了包含键值对的RDD[(String, Int)]其中每个单词的计数都置位1。此时Executors会及时地向Driver汇报自己的工作进展从而方便Driver来统一协调大家下一步的工作。
这个时候要继续进行后面的聚合计算也就是计数操作就必须进行刚刚说的Shuffle操作。在不同Executors完成单词的数据交换之后Driver继续创建并分发下一个阶段的任务也就是按照单词做分组计数。
数据交换之后所有相同的单词都分发到了相同的Executors上去这个时候各个Executors拿到reduceByKey的任务只需要各自独立地去完成统计计数即可。完成计数之后Executors会把最终的计算结果统一返回给Driver。
这样一来spark-shell便完成了Word Count用户代码的计算过程。经过了刚才的分析对于Spark进程模型、Driver与Executors之间的关联与联系想必你就有了更清晰的理解和把握。
不过到目前为止对于Word Count示例和spark-shell的讲解我们一直是在本地部署的环境中做展示。我们知道Spark真正的威力其实在于分布式集群中的并行计算。只有充分利用集群中每个节点的计算资源才能充分发挥出Spark的性能优势。因此我们很有必要去学习并了解Spark的分布式部署。
分布式环境部署
Spark支持多种分布式部署模式如Standalone、YARN、Mesos、Kubernetes。其中Standalone是Spark内置的资源调度器而YARN、Mesos、Kubernetes是独立的第三方资源调度与服务编排框架。
由于后三者提供独立而又完备的资源调度能力对于这些框架来说Spark仅仅是其支持的众多计算引擎中的一种。Spark在这些独立框架上的分布式部署步骤较少流程比较简单我们开发者只需下载并解压Spark安装包然后适当调整Spark配置文件、以及修改环境变量就行了。
因此对于YARN、Mesos、Kubernetes这三种部署模式我们不做详细展开我把它给你留作课后作业进行探索。今天这一讲我们仅专注于Spark在Standalone模式下的分布式部署。
为了示意Standalone模式的部署过程我这边在AWS环境中创建并启动了3台EC2计算节点操作系统为Linux/CentOS。
需要指出的是Spark分布式计算环境的部署对于节点类型与操作系统本身是没有要求和限制的但是在实际的部署中请你尽量保持每台计算节点的操作系统是一致的从而避免不必要的麻烦。
接下来我就带你手把手地去完成Standalone模式的分布式部署。
Standalone在资源调度层面采用了一主多从的主从架构把计算节点的角色分为Master和Worker。其中Master有且只有一个而Worker可以有一到多个。所有Worker节点周期性地向Master汇报本节点可用资源状态Master负责汇总、变更、管理集群中的可用资源并对Spark应用程序中Driver的资源请求作出响应。
为了方便描述我们把3台EC2的hostname分别设置为node0、node1、node2并把node0选做Master节点而把node1、node2选做Worker节点。
首先为了实现3台机器之间的无缝通信我们先来在3台节点之间配置无密码的SSH环境
接下来我们在所有节点上准备Java环境并安装Spark其中步骤2的“sudo wget”你可以参考这里的链接操作命令如下表所示
在所有节点之上完成Spark的安装之后我们就可以依次启动Master和Worker节点了如下表所示
集群启动之后我们可以使用Spark自带的小例子来验证Standalone分布式部署是否成功。首先打开Master或是Worker的命令行终端然后敲入下面这个命令
MASTER=spark://node0:7077 $SPARK_HOME/bin/run-example org.apache.spark.examples.SparkPi
如果程序能够成功计算Pi值也就是3.14如下图所示那么说明咱们的Spark分布式计算集群已然就绪。你可以对照文稿里的截图验证下你的环境是否也成功了。
重点回顾
今天这一讲我们提到分布式计算的精髓在于如何把抽象的计算流图转化为实实在在的分布式计算任务然后以并行计算的方式交付执行。而要想透彻理解分布式计算你就需要掌握Spark进程模型。
进程模型的核心是Driver和Executors我们需要重点理解它们之间的协作关系。任何一个Spark应用程序的入口都是带有SparkSession的main函数而在Spark的分布式计算环境中运行这样main函数的JVM进程有且仅有一个它被称为 “Driver”。
Driver最核心的作用在于解析用户代码、构建计算流图然后将计算流图转化为分布式任务并把任务分发给集群中的Executors交付执行。接收到任务之后Executors调用内部线程池结合事先分配好的数据分片并发地执行任务代码。
对于一个完整的RDD每个Executors负责处理这个RDD的一个数据分片子集。每当任务执行完毕Executors都会及时地与Driver进行通信、汇报任务状态。Driver在获取到Executors的执行进度之后结合计算流图的任务拆解依次有序地将下一阶段的任务再次分发给Executors付诸执行直至整个计算流图执行完毕。
之后我们介绍了Spark支持的分布式部署模式主要有Standalone、YARN、Mesos、Kubernetes。其中Standalone是Spark内置的资源调度器而YARN、Mesos、Kubernetes是独立的第三方资源调度与服务编排框架。在这些部署模式中你需要重点掌握Standalone环境部署的操作步骤。
每课一练
好,在这一讲的最后,我给你留两道作业,帮助你巩固今日所学。
与take算子类似collect算子用于收集计算结果结合Spark进程模型你能说一说相比collect算子相比take算子来说都有哪些隐患吗
如果你的生产环境中使用了YARN、Mesos或是Kubernetes你不妨说一说要完成Spark在这些独立框架下的分布式部署都需要哪些必备的步骤
今天这一讲就到这里了,如果你在部署过程中遇到的什么问题,欢迎你在评论区提问。如果你觉得这一讲帮助到了你,也欢迎你分享给更多的朋友和同事,我们下一讲再见。

View File

@ -0,0 +1,201 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
05 调度系统:如何把握分布式计算的精髓?
你好,我是吴磊。
在上一讲我们通过“包工头与施工工人”的例子初步认识了Spark进程模型中的Driver和Executors、以及它们之间的交互关系。Driver负责解析用户代码、构建计算流图然后将计算流图转化为分布式任务并把任务分发给集群中的Executors交付运行。
不过你可能会好奇“对于给定的用户代码和相应的计算流图Driver是怎么把计算图拆解为分布式任务又是按照什么规则分发给Executors的呢还有Executors具体又是如何执行分布式任务的呢
我们之前一再强调,分布式计算的精髓,在于如何把抽象的计算图,转化为实实在在的分布式计算任务,然后以并行计算的方式交付执行。深入理解分布式计算,是我们做好大数据开发的关键和前提,它能有效避免我们掉入“单机思维”的陷阱,同时也能为性能导向的开发奠定坚实基础。
而上面的这一系列问题恰恰是我们吃透分布式计算的关键所在。因此今天这一讲我们就顺着这些问题一起去深入探究Spark调度系统进而弄清楚分布式计算的来龙去脉。
角色划分与斯巴克建筑集团
在上一讲我们通过“包工头与施工工人”的类比、以及Word Count的示例其实已经大致厘清了Spark分布式任务调度的核心环节与关键步骤。今天这一讲的核心任务就是带你去深入其中的每一个环节做到“既见森林、也见树木”。这里咱们不妨先把这些环节和涉及的组件梳理出来从而让你在整体上有一个清晰的把握。
不难发现表中的步骤与组件众多要是照本宣科地去讲调度系统先别说你可能看着看着就开始犯困了就连我自己也可能写着写着就睡着了。因此咱们不妨把这些环节与组件融入到一个故事中去让你像读小说一样在捧腹之余弄懂Spark调度系统。
话说很久以前美国有一家名扬海内外的建筑集团名为“斯巴克Spark”。这家建筑集团规模庞大设有一个总公司Driver和多个分公司Executors。斯巴克公司的主要服务对象是建筑设计师开发者建筑设计师负责提供设计图纸用户代码、计算图而斯巴克公司的主营业务是将图纸落地、建造起一栋栋高楼大厦。
要完成主营业务集团公司需要招聘能够看懂图纸、并将其转化为建筑项目的架构师因此斯巴克公司挖角了行业知名架构师“戴格”DAGScheduler。集团公司给戴格安排的职位是总公司的一把手同时要求两位创始元老“塔斯克”和“拜肯德”全力配合戴格的工作。
听到这里,你肯定会问“塔斯克”和“拜肯德”是谁呢?
塔斯克TaskScheduler一毕业就加入了斯巴克公司现任总公司施工经理成功指挥完成了多个大大小小的工程项目业绩非常突出深得公司赏识。拜肯德SchedulerBackend和塔斯克在上大学的时候就是上下铺关系好得穿一条裤子现任总公司人力资源总监负责与分公司协调、安排人力资源。从公司的安排来看三位主管的分工还是比较明确的。
之所以说塔斯克TaskScheduler和拜肯德SchedulerBackend是公司元老原因在于在SparkContext / SparkSession的初始化中TaskScheduler和SchedulerBackend是最早、且同时被创建的调度系统组件。这二者的关系非常微妙SchedulerBackend在构造方法中引用TaskScheduler而TaskScheduler在初始化时会引用SchedulerBackend。
值得一提的是SchedulerBackend组件的实例化取决于开发者指定的Spark MasterURL也就是我们使用spark-shell或是spark-submit时指定的master 参数如“master spark://ip:host”就代表Standalone 部署模式master yarn”就代表YARN 模式等等。
不难发现SchedulerBackend 与资源管理器Standalone、YARN、Mesos等强绑定是资源管理器在 Spark 中的代理。其实硬件资源与人力资源一样都是“干活儿的”。所以如果我们用集团公司的人力资源来类比Spark集群的硬件资源那么“拜肯德”就是名副其实的人力资源总监。
从全局视角来看DAGScheduler是任务调度的发起者DAGScheduler以TaskSet为粒度向TaskScheduler提交任务调度请求。TaskScheduler在初始化的过程中会创建任务调度队列任务调度队列用于缓存 DAGScheduler提交的TaskSets。TaskScheduler结合SchedulerBackend提供的 WorkerOffer按照预先设置的调度策略依次对队列中的任务进行调度。
简而言之DAGScheduler手里有“活儿”SchedulerBackend手里有“人力”TaskScheduler的核心职能就是把合适的“活儿”派发到合适的“人”的手里。由此可见TaskScheduler承担的是承上启下、上通下达的关键角色这也正是我们将“塔斯克”视为斯巴克建筑公司元老之一的重要原因。
那么,了解了这三个主管的角色职责,我们接下来就来详细说说,他们是怎么各自完成自己的工作的。
总架戴格DAGScheduler
回到我们的故事里,戴格在两位元老的协助下,工作开展得还算顺利,然而,冰层之下,暗流涌动,作为一名空降的领导,戴老板还需亲自“露两手”,才能赢得平级的认可与信任。
作为集团公司的“总架”总架构师戴格的核心职责是把计算图DAG拆分为执行阶段StagesStages指的是不同的运行阶段同时还要负责把Stages转化为任务集合TaskSets也就是把“建筑图纸”转化成可执行、可操作的“建筑项目”。
用一句话来概括从 DAG 到 Stages 的拆分过程,那就是:以 Actions 算子为起点,从后向前回溯 DAG以 Shuffle 操作为边界去划分 Stages。
在[第2讲]介绍编程模型的时候我们以Word Count为例提到Spark作业的运行分为两个环节第一个是以惰性的方式构建计算图第二个则是通过Actions算子触发作业的从头计算
对于图中的第二个环节Spark在实际运行的过程中会把它再细化为两个步骤。第一个步骤就是以Shuffle为边界从后向前以递归的方式把逻辑上的计算图DAG转化成一个又一个Stages。
我们还是以Word Count为例Spark以take算子为起点依次把DAG中的RDD划入到第一个Stage直到遇到reduceByKey算子。由于reduceByKey算子会引入Shuffle因此第一个Stage创建完毕且只包含wordCounts这一个RDD。接下来Spark继续向前回溯由于未曾碰到会引入Shuffle的算子因此它把“沿途”所有的RDD都划入了第二个Stage。
在Stages创建完毕之后就到了触发计算的第二个步骤Spark从后向前以递归的方式依次提请执行所有的Stages。
具体来说在Word Count的例子中DAGScheduler最先提请执行的是Stage1。在提交的时候DAGScheduler发现Stage1依赖的父Stage也就是Stage0还没有执行过那么这个时候它会把Stage1的提交动作压栈转而去提请执行Stage0。当Stage0执行完毕的时候DAGScheduler通过出栈的动作再次提请执行Stage 1。
对于提请执行的每一个StageDAGScheduler根据Stage内RDD的partitions属性创建分布式任务集合TaskSet。TaskSet包含一个又一个分布式任务TaskRDD有多少数据分区TaskSet就包含多少个Task。换句话说Task与RDD的分区是一一对应的。
你可能会问“Task代表的是分布式任务不过它具体是什么呢”要更好地认识Task我们不妨来看看它的关键属性。
在上表中stageId、stageAttemptId标记了Task与执行阶段Stage的所属关系taskBinary则封装了隶属于这个执行阶段的用户代码partition就是我们刚刚说的RDD数据分区locs属性以字符串的形式记录了该任务倾向的计算节点或是Executor ID。
不难发现taskBinary、partition和locs这三个属性一起描述了这样一件事情Task应该在哪里locs为谁partition执行什么任务taskBinary
到这里我们讲完了戴格的职责让我们来一起简单汇总一下戴格指代的是DAGSchedulerDAGScheduler的主要职责有三个
根据用户代码构建DAG
以Shuffle为边界切割Stages
基于Stages创建TaskSets并将TaskSets提交给TaskScheduler请求调度。
现在,戴格不辱使命,完成了“建筑图纸”到“建筑项目”的转化,接下来,他需要把这些“活儿”下派给塔斯克,由塔斯克进一步完成任务的委派。
不过,对于塔斯克来说,要想把这些“活儿”委派出去,他得先摸清楚集团内有多少“适龄劳动力”才行。要做到这一点,他必须仰仗死党:拜肯德的帮忙。
拜肯德SchedulerBackend
作为集团公司的人力资源总监拜肯德的核心职责就是实时汇总并掌握全公司的人力资源状况。前面我们讲了全公司的人力资源对应的就是Spark的计算资源。对于集群中可用的计算资源SchedulerBackend用一个叫做ExecutorDataMap的数据结构来记录每一个计算节点中Executors的资源状态。
这里的ExecutorDataMap是一种HashMap它的Key是标记 Executor 的字符串Value是一种叫做ExecutorData的数据结构。ExecutorData用于封装Executor的资源状态如RPC地址、主机地址、可用CPU核数和满配CPU核数等等它相当于是对Executor做的“资源画像”。
有了ExecutorDataMap这本“人力资源小册子”对内SchedulerBackend可以就Executor做“资源画像”对外SchedulerBackend以WorkerOffer为粒度提供计算资源。其中WorkerOffer封装了Executor ID、主机地址和CPU核数它用来表示一份可用于调度任务的空闲资源。
显然基于Executor资源画像SchedulerBackend可以同时提供多个WorkerOffer用于分布式任务调度。WorkerOffer这个名字起得很传神Offer的字面意思是公司给你提供的工作机会到了Spark调度系统的上下文它就变成了使用硬件资源的机会。
你可能会好奇坐镇总公司的拜肯德对于整个集团的人力资源他是怎么做到足不出户就如数家珍的一个篱笆三个桩一个好汉三个帮。仅凭拜肯德一己之力自然是力不从心幕后功臣实际上是驻扎在分公司的一众小弟们ExecutorBackend。
SchedulerBackend与集群内所有Executors中的ExecutorBackend保持周期性通信双方通过LaunchedExecutor、RemoveExecutor、StatusUpdate等消息来互通有无、变更可用计算资源。拜肯德正是通过这些小弟发送的“信件”来不停地更新自己手中的那本小册子从而对集团人力资源了如指掌。
塔斯克TaskScheduler
一把手戴格有“活儿”三把手拜肯德出“人力”接下来终于轮到牵线搭桥的塔斯克出马了。作为施工经理塔斯克的核心职责是给定拜肯德提供的“人力”遴选出最合适的“活儿”并派发出去。而这个遴选的过程就是任务调度的核心所在如下图步骤3所示
那么问题来了对于SchedulerBackend提供的一个个WorkerOfferTaskScheduler是依据什么规则来挑选Tasks的呢
用一句话来回答对于给定的WorkerOfferTaskScheduler是按照任务的本地倾向性来遴选出TaskSet中适合调度的Tasks。这是什么意思呢听上去比较抽象我们还是从DAGScheduler在Stage内创建任务集TaskSet说起。
我们刚刚说过Task与RDD的partitions是一一对应的在创建Task的过程中DAGScheduler会根据数据分区的物理地址来为Task设置locs属性。locs属性记录了数据分区所在的计算节点、甚至是Executor进程ID。
举例来说当我们调用textFile API从HDFS文件系统中读取源文件时Spark会根据HDFS NameNode当中记录的元数据获取数据分区的存储地址例如node0:/rootPath/partition0-replica0node1:/rootPath/partition0-replica1和node2:/rootPath/partition0-replica2。
那么DAGScheduler在为该数据分区创建Task0的时候会把这些地址中的计算节点记录到Task0的locs属性。
如此一来当TaskScheduler需要调度Task0这个分布式任务的时候根据Task0的locs属性它就知道“Task0所需处理的数据分区在节点node0、node1、node2上存有副本因此如果WorkOffer是来自这3个节点的计算资源那对Task0来说就是投其所好”。
从这个例子我们就能更好地理解,每个任务都是自带本地倾向性的,换句话说,每个任务都有自己的“调度意愿”。
回到斯巴克建筑集团的类比就好比是某个“活儿”并不是所有人都能干而是只倾向于让某些人来做因为他们更专业。比如砌墙这件事更倾向于给工龄3年以上的瓦工来做而吊顶则更倾向于给经验超过5年的木工来做诸如此类。
像上面这种定向到计算节点粒度的本地性倾向Spark中的术语叫做NODE_LOCAL。除了定向到节点Task还可以定向到进程Executor、机架、任意地址它们对应的术语分别是PROCESS_LOCAL、RACK_LOCAL和ANY。
对于倾向PROCESS_LOCAL的Task来说它要求对应的数据分区在某个进程Executor中存有副本而对于倾向RACK_LOCAL的Task来说它仅要求相应的数据分区存在于同一机架即可。ANY则等同于无定向也就是Task对于分发的目的地没有倾向性被调度到哪里都可以。
下图展示的是TaskScheduler依据本地性倾向依次进行任务调度的运行逻辑
不难发现从PROCESS_LOCAL、NODE_LOCAL、到RACK_LOCAL、再到ANYTask的本地性倾向逐渐从严苛变得宽松。TaskScheduler接收到WorkerOffer之后也正是按照这个顺序来遍历TaskSet中的Tasks优先调度本地性倾向为PROCESS_LOCAL的Task而NODE_LOCAL次之RACK_LOCAL为再次最后是ANY。
你可能会问“Spark区分对待不同的本地倾向性它的初衷和意图是什么呢”实际上不同的本地性倾向本质上是用来区分计算代码与数据之间的关系。
Spark调度系统的核心思想是“数据不动、代码动”。也就是说在任务调度的过程中为了完成分布式计算Spark倾向于让数据待在原地、保持不动而把计算任务代码调度、分发到数据所在的地方从而消除数据分发引入的性能隐患。毕竟相比分发数据分发代码要轻量得多。
本地性倾向则意味着代码和数据应该在哪里“相会”PROCESS_LOCAL是在JVM进程中NODE_LOCAL是在节点内RACK_LOCAL是不超出物理机架的范围而ANY则代表“无所谓、不重要”。
好啦到此为止结合WorkerOffer与任务的本地性倾向塔斯克TaskScheduler挑选出了适合调度的“活儿”Tasks。接下来TaskScheduler就把这些Tasks通过LaunchTask消息发送给好基友SchedulerBackend。人力资源总监SchedulerBackend拿到这些活儿之后同样使用LaunchTask消息把活儿进一步下发给分公司的小弟ExecutorBackend。
那么小弟ExecutorBackend拿到活之后是怎么工作的呢我们接着往下看吧
付诸执行ExecutorBackend
作为分公司的人力资源主管ExecutorBackend拿到“活儿”之后随即把活儿派发给分公司的建筑工人。这些工人就是Executors线程池中一个又一个的CPU线程每个线程负责处理一个Task。
每当Task处理完毕这些线程便会通过ExecutorBackend向Driver端的SchedulerBackend发送StatusUpdate事件告知Task执行状态。接下来TaskScheduler与SchedulerBackend通过接力的方式最终把状态汇报给DAGScheduler如图中步骤7、8、9所示
对于同一个TaskSet当中的Tasks来说当它们分别完成了任务调度与任务执行这两个环节时也就是上图中步骤1到步骤9的计算过程Spark调度系统就完成了DAG中某一个Stage的任务调度。
不过故事到这里并未结束。我们知道一个DAG会包含多个Stages一个Stage的结束即宣告下一个Stage的开始而这也是戴格起初将DAG划分为Stages的意义所在。只有当所有的Stages全部调度、执行完毕才表示一个完整的Spark作业宣告结束。
路遥知马力,在一起合作了一个又一个建筑项目之后,空降老大戴格终于赢得了元老塔斯克和拜肯德的信任与认可,坐稳了斯巴克建筑集团的头把交椅。来日可期,戴格的前景一片光明。
重点回顾
今天这一讲我们用斯巴克建筑集团的故事介绍了Spark调度系统的工作原理。对于调度系统的工作流程你需要掌握表格中的5个关键环节
具体说来任务调度分为如下5个步骤
DAGScheduler以Shuffle为边界将开发者设计的计算图DAG拆分为多个执行阶段Stages然后为每个Stage创建任务集TaskSet。-
SchedulerBackend通过与Executors中的ExecutorBackend的交互来实时地获取集群中可用的计算资源并将这些信息记录到ExecutorDataMap数据结构。-
与此同时SchedulerBackend根据ExecutorDataMap中可用资源创建WorkerOffer以WorkerOffer为粒度提供计算资源。-
对于给定WorkerOfferTaskScheduler结合TaskSet中任务的本地性倾向按照PROCESS_LOCAL、NODE_LOCAL、RACK_LOCAL和ANY的顺序依次对TaskSet中的任务进行遍历优先调度本地性倾向要求苛刻的Task。-
被选中的Task由TaskScheduler传递给SchedulerBackend再由SchedulerBackend分发到Executors中的ExecutorBackend。Executors接收到Task之后即调用本地线程池来执行分布式任务。
今天的内容就是这些调度系统是分布式计算系统的核心掌握了Spark任务调度的来龙去脉你也就把握住了Spark分布式计算引擎的精髓这会为你开发出高性能的Spark分布式应用打下坚实基础。
每课一练
课程的最后我来给你留一道练习题。请你想一想DAGScheduler如何得知一个Stage中所有的Tasks都已调度、执行完毕然后才决定开始调度DAG中的下一个Stage
欢迎你在评论区回答这个问题。如果你觉得这一讲对你有所帮助,也欢迎你把它分享给更多的朋友和同事。我在评论区等你,咱们下一讲见!

View File

@ -0,0 +1,156 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
06 Shuffle管理为什么Shuffle是性能瓶颈
你好,我是吴磊。
在上一讲我们拜访了斯巴克国际建筑集团总公司结识了Spark调度系统的三巨头DAGScheduler、TaskScheduler和SchedulerBackend。相信你已经感受到调度系统组件众多且运作流程精密而又复杂。
任务调度的首要环节是DAGScheduler以Shuffle为边界把计算图DAG切割为多个执行阶段Stages。显然Shuffle是这个环节的关键。那么我们不禁要问“Shuffle是什么为什么任务执行需要Shuffle操作Shuffle是怎样一个过程
今天这一讲我们转而去“拜访”斯巴克国际建筑集团的分公司用“工地搬砖的任务”来理解Shuffle及其工作原理。由于Shuffle的计算几乎需要消耗所有类型的硬件资源比如CPU、内存、磁盘与网络在绝大多数的Spark作业中Shuffle往往是作业执行性能的瓶颈因此我们必须要掌握Shuffle的工作原理从而为Shuffle环节的优化打下坚实基础。
什么是Shuffle
我们先不急着给Shuffle下正式的定义为了帮你迅速地理解Shuffle的含义从而达到事半功倍的效果我们不妨先去拜访斯巴克集团的分公司去看看“工地搬砖”是怎么一回事。
斯巴克集团的各家分公司分别驻扎在不同的建筑工地,每家分公司的人员配置和基础设施都大同小异:在人员方面,各家分公司都有建筑工人若干、以及负责管理这些工人的工头。在基础设施方面,每家分公司都有临时搭建、方便存取建材的临时仓库,这些仓库配备各式各样的建筑原材料,比如混凝土砖头、普通砖头、草坪砖头等等。
咱们参观、考察斯巴克建筑集团的目的毕竟还是学习Spark因此我们得把分公司的人与物和Spark的相关概念对应上这样才能方便你快速理解Spark的诸多组件与核心原理。
分公司的人与物和Spark的相关概念是这样对应的
基于图中不同概念的对应关系接下来我们来看“工地搬砖”的任务。斯巴克建筑集团的3家分公司分别接到3个不同的建筑任务。第一家分公司的建筑项目是摩天大厦第二家分公司被要求在工地上建造一座“萌宠乐园”而第三家分公司收到的任务是打造露天公园。为了叙述方便我们把三家分公司分别称作分公司1、分公司2和分公司3。
显然,不同建筑项目对于建材的选型要求是有区别的,摩天大厦的建造需要刚性强度更高的混凝土砖头,同理,露天公园的建设需要透水性好的草坪砖头,而萌宠乐园使用普通砖头即可。
可是不同类型的砖头分别散落在3家公司的临时仓库中。为了实现资源的高效利用每个分公司的施工工人们都需要从另外两家把项目特需的砖头搬运过来。对于这个过程我们把它叫作“搬砖任务”。
有了“工地搬砖”的直观对比我们现在就可以直接给Shuffle下一个正式的定义了。
Shuffle的本意是扑克的“洗牌”在分布式计算场景中它被引申为集群范围内跨节点、跨进程的数据分发。在工地搬砖的任务中如果我们把不同类型的砖头看作是分布式数据集那么不同类型的砖头在各个分公司之间搬运的过程与分布式计算中的Shuffle可以说是异曲同工。
要完成工地搬砖的任务,每位工人都需要长途跋涉到另外两家分公司,然后从人家的临时仓库把所需的砖头搬运回来。分公司之间相隔甚远,仅靠工人们一块砖一块砖地搬运,显然不现实。因此,为了提升搬砖效率,每位工人还需要借助货运卡车来帮忙。不难发现,工地搬砖的任务需要消耗大量的人力物力,可以说是劳师动众。
Shuffle的过程也是类似分布式数据集在集群内的分发会引入大量的磁盘I/O与网络I/O。在DAG的计算链条中Shuffle环节的执行性能是最差的。你可能会问“既然Shuffle的性能这么差为什么在计算的过程中非要引入Shuffle操作呢免去Shuffle环节不行吗
其实计算过程之所以需要Shuffle往往是由计算逻辑、或者说业务逻辑决定的。
比如对于搬砖任务来说不同的建筑项目就是需要不同的建材只有这样才能满足不同的施工要求。再比如在Word Count的例子中我们的“业务逻辑”是对单词做统计计数那么对单词“Spark”来说在做“加和”之前我们就是得把原本分散在不同Executors中的“Spark”拉取到某一个Executor才能完成统计计数的操作。
结合过往的工作经验我们发现在绝大多数的业务场景中Shuffle操作都是必需的、无法避免的。既然我们躲不掉Shuffle那么接下来我们就一起去探索看看Shuffle到底是怎样的一个计算过程。
Shuffle工作原理
为了方便你理解我们还是用Word Count的例子来做说明。在这个示例中引入Shuffle操作的是reduceByKey算子也就是下面这行代码完整代码请回顾[第1讲])。
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
我们先来直观地回顾一下这一步的计算过程然后再去分析其中涉及的Shuffle操作
如上图所示以Shuffle为边界reduceByKey的计算被切割为两个执行阶段。约定俗成地我们把Shuffle之前的Stage叫作Map阶段而把Shuffle之后的Stage称作Reduce阶段。在Map阶段每个Executors先把自己负责的数据分区做初步聚合又叫Map端聚合、局部聚合在Shuffle环节不同的单词被分发到不同节点的Executors中最后的Reduce阶段Executors以单词为Key做第二次聚合又叫全局聚合从而完成统计计数的任务。
不难发现Map阶段与Reduce阶段的计算过程相对清晰明了二者都是利用reduce运算完成局部聚合与全局聚合。在reduceByKey的计算过程中Shuffle才是关键。
仔细观察上图你就会发现与其说Shuffle是跨节点、跨进程的数据分发不如说Shuffle是Map阶段与Reduce阶段之间的数据交换。那么问题来了两个执行阶段之间是如何实现数据交换的呢
Shuffle中间文件
如果用一句来概括的话那就是Map阶段与Reduce阶段通过生产与消费Shuffle中间文件的方式来完成集群范围内的数据交换。换句话说Map阶段生产Shuffle中间文件Reduce阶段消费Shuffle中间文件二者以中间文件为媒介完成数据交换。
那么接下来的问题是什么是Shuffle中间文件它是怎么产生的又是如何被消费的
我把它的产生和消费过程总结在下图中了:
在上一讲介绍调度系统的时候我们说过DAGScheduler会为每一个Stage创建任务集合TaskSet而每一个TaskSet都包含多个分布式任务Task。在Map执行阶段每个Task以下简称Map Task都会生成包含data文件与index文件的Shuffle中间文件如上图所示。也就是说Shuffle文件的生成是以Map Task为粒度的Map阶段有多少个Map Task就会生成多少份Shuffle中间文件。
再者Shuffle中间文件是统称、泛指它包含两类实体文件一个是记录KeyValue键值对的data文件另一个是记录键值对所属Reduce Task的index文件。换句话说index文件标记了data文件中的哪些记录应该由下游Reduce阶段中的哪些Task简称Reduce Task消费。在上图中为了方便示意我们把首字母是S、i、c的单词分别交给下游的3个Reduce Task去消费显然这里的数据交换规则是单词首字母。
在Spark中Shuffle环节实际的数据交换规则要比这复杂得多。数据交换规则又叫分区规则因为它定义了分布式数据集在Reduce阶段如何划分数据分区。假设Reduce阶段有N个Task这N个Task对应着N个数据分区那么在Map阶段每条记录应该分发到哪个Reduce Task是由下面的公式来决定的。
P = Hash(Record Key) % N
对于任意一条数据记录Spark先按照既定的哈希算法计算记录主键的哈希值然后把哈希值对N取模计算得到的结果数字就是这条记录在Reduce阶段的数据分区编号P。换句话说这条记录在Shuffle的过程中应该被分发到Reduce阶段的P号分区。
熟悉了分区规则与中间文件之后,接下来,我们再来说一说中间文件是怎么产生的。
Shuffle Write
我们刚刚说过Shuffle中间文件是以Map Task为粒度生成的我们不妨使用下图中的Map Task以及与之对应的数据分区为例来讲解中间文件的生成过程。数据分区的数据内容如图中绿色方框所示
在生成中间文件的过程中Spark会借助一种类似于Map的数据结构来计算、缓存并排序数据分区中的数据记录。这种Map结构的Key是Reduce Task Partition IDRecord Key而Value是原数据记录中的数据值如图中的“内存数据结构”所示。
对于数据分区中的数据记录Spark会根据我们前面提到的公式1逐条计算记录所属的目标分区ID然后把主键Reduce Task Partition IDRecord Key和记录的数据值插入到Map数据结构中。当Map结构被灌满之后Spark根据主键对Map中的数据记录做排序然后把所有内容溢出到磁盘中的临时文件如图中的步骤1所示。
随着Map结构被清空Spark可以继续读取分区内容并继续向Map结构中插入数据直到Map结构再次被灌满而再次溢出如图中的步骤2所示。就这样如此往复直到数据分区中所有的数据记录都被处理完毕。
到此为止磁盘上存有若干个溢出的临时文件而内存的Map结构中留有部分数据Spark使用归并排序算法对所有临时文件和Map结构剩余数据做合并分别生成data文件、和与之对应的index文件如图中步骤4所示。Shuffle阶段生成中间文件的过程又叫Shuffle Write。
总结下来Shuffle中间文件的生成过程分为如下几个步骤
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 IDKey排序将所有数据溢出到临时文件同时清空数据结构-
重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;-
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。
到目前为止我们熟悉了Spark在Map阶段生产Shuffle中间文件的过程那么在Reduce阶段不同的Tasks又是如何基于这些中间文件来定位属于自己的那部分数据从而完成数据拉取呢
Shuffle Read
首先我们需要注意的是对于每一个Map Task生成的中间文件其中的目标分区数量是由Reduce阶段的任务数量又叫并行度决定的。在下面的示意图中Reduce阶段的并行度是3因此Map Task的中间文件会包含3个目标分区的数据而index文件恰恰是用来标记目标分区所属数据记录的起始索引。
对于所有Map Task生成的中间文件Reduce Task需要通过网络从不同节点的硬盘中下载并拉取属于自己的数据内容。不同的Reduce Task正是根据index文件中的起始索引来确定哪些数据内容是“属于自己的”。Reduce阶段不同于Reduce Task拉取数据的过程往往也被叫做Shuffle Read。
好啦到此为止我们依次解答了本讲最初提到的几个问题“什么是Shuffle为什么需要Shuffle以及Shuffle是如何工作的”。Shuffle是衔接不同执行阶段的关键环节Shuffle的执行性能往往是Spark作业端到端执行效率的关键因此掌握Shuffle是我们入门Spark的必经之路。希望今天的讲解能帮你更好地认识Shuffle。
重点回顾
今天的内容比较多,我们一起来做个总结。
首先我们给Shuffle下了一个明确的定义在分布式计算场景中Shuffle指的是集群范围内跨节点、跨进程的数据分发。
我们在最开始提到Shuffle的计算会消耗所有类型的硬件资源。具体来说Shuffle中的哈希与排序操作会大量消耗CPU而Shuffle Write生成中间文件的过程会消耗宝贵的内存资源与磁盘I/O最后Shuffle Read阶段的数据拉取会引入大量的网络I/O。不难发现Shuffle是资源密集型计算因此理解Shuffle对开发者来说至关重要。
紧接着我们介绍了Shuffle中间文件。Shuffle中间文件是统称它包含两类文件一个是记录KeyValue键值对的data文件另一个是记录键值对所属Reduce Task的index文件。计算图DAG中的Map阶段与Reduce阶段正是通过中间文件来完成数据的交换。
接下来我们详细讲解了Shuffle Write过程中生成中间文件的详细过程归纳起来这个过程分为4个步骤
对于数据分区中的数据记录,逐一计算其目标分区,然后填充内存数据结构;-
当数据结构填满后,如果分区中还有未处理的数据记录,就对结构中的数据记录按(目标分区 IDKey排序将所有数据溢出到临时文件同时清空数据结构-
重复前 2 个步骤,直到分区中所有的数据记录都被处理为止;-
对所有临时文件和内存数据结构中剩余的数据记录做归并排序,生成数据文件和索引文件。
最后在Reduce阶段Reduce Task通过index文件来“定位”属于自己的数据内容并通过网络从不同节点的data文件中下载属于自己的数据记录。
每课一练
这一讲就到这里了,我在这给你留个思考题:
在Shuffle的计算过程中中间文件存储在参数spark.local.dir设置的文件目录中这个参数的默认值是/tmp你觉得这个参数该如何设置才更合理呢
欢迎你在评论区分享你的答案,我在评论区等你。如果这一讲对你有所帮助,你也可以分享给自己的朋友,我们下一讲见。

View File

@ -0,0 +1,212 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
07 RDD常用算子Spark如何实现数据聚合
你好,我是吴磊。
积累了一定的理论基础之后今天我们继续来学习RDD常用算子。在[RDD常用算子]那一讲我们讲了四个算子map、mapPartitions、flatMap和filter同时留了这样一道思考题“这些算子之间有哪些共同点
今天我们就来揭晓答案。首先在功能方面这4个算子都用于RDD内部的数据转换而学习过Shuffle的工作原理之后我们不难发现这4个算子当中没有任何一个算子会引入Shuffle计算。
而今天我们要学习的几个算子则恰恰相反它们都会引入繁重的Shuffle计算。这些算子分别是groupByKey、reduceByKey、aggregateByKey和sortByKey也就是表格中加粗的部分。
我们知道在数据分析场景中典型的计算类型分别是分组、聚合和排序。而groupByKey、reduceByKey、aggregateByKey和sortByKey这些算子的功能恰恰就是用来实现分组、聚合和排序的计算逻辑。
尽管这些算子看上去相比其他算子的适用范围更窄也就是它们只能作用Apply在Paired RDD之上所谓Paired RDD它指的是元素类型为KeyValue键值对的RDD。
但是在功能方面,可以说,它们承担了数据分析场景中的大部分职责。因此,掌握这些算子的用法,是我们能够游刃有余地开发数据分析应用的重要基础。那么接下来,我们就通过一些实例,来熟悉并学习这些算子的用法。
我们先来说说groupByKey坦白地说相比后面的3个算子groupByKey在我们日常开发中的“出镜率”并不高。之所以要先介绍它主要是为后续的reduceByKey和aggregateByKey这两个重要算子做铺垫。
groupByKey分组收集
groupByKey的字面意思是“按照Key做分组”但实际上groupByKey算子包含两步即分组和收集。
具体来说对于元素类型为KeyValue键值对的Paired RDDgroupByKey的功能就是对Key值相同的元素做分组然后把相应的Value值以集合的形式收集到一起。换句话说groupByKey会把RDD的类型由RDD[(Key, Value)]转换为RDD[(Key, Value集合)]。
这么说比较抽象我们还是用一个小例子来说明groupByKey的用法。还是我们熟知的Word Count对于分词后的一个个单词假设我们不再统计其计数而仅仅是把相同的单词收集到一起那么我们该怎么做呢按照老规矩咱们还是先来给出代码实现
import org.apache.spark.rdd.RDD
// 以行为单位做分词
val cleanWordRDD: RDD[String] = _ // 完整代码请参考第一讲的Word Count
// 把普通RDD映射为Paired RDD
val kvRDD: RDD[(String, String)] = cleanWordRDD.map(word => (word, word))
// 按照单词做分组收集
val words: RDD[(String, Iterable[String])] = kvRDD.groupByKey()
结合前面的代码可以看到相比之前的Word Count我们仅需做两个微小的改动即可实现新的计算逻辑。第一个改动是把map算子的映射函数f由原来的word => word1变更为word => wordword这么做的效果是把kvRDD元素的Key和Value都变成了单词。
紧接着第二个改动我们用groupByKey替换了原先的reduceByKey。相比reduceByKeygroupByKey的用法要简明得多。groupByKey是无参函数要实现对Paired RDD的分组、收集我们仅需在RDD之上调用groupByKey()即可。
尽管groupByKey的用法非常简单但它的计算过程值得我们特别关注下面我用一张示意图来讲解上述代码的计算过程从而让你更加直观地感受groupByKey可能存在的性能隐患。
从图上可以看出为了完成分组收集对于Key值相同、但分散在不同数据分区的原始数据记录Spark需要通过Shuffle操作跨节点、跨进程地把它们分发到相同的数据分区。我们之前在[第6讲]中说了Shuffle是资源密集型计算对于动辄上百万、甚至上亿条数据记录的RDD来说这样的Shuffle计算会产生大量的磁盘I/O与网络I/O开销从而严重影响作业的执行性能。
虽然groupByKey的执行效率较差不过好在它在应用开发中的“出镜率”并不高。原因很简单在数据分析领域中分组收集的使用场景很少而分组聚合才是统计分析的刚需。
为了满足分组聚合多样化的计算需要Spark提供了3种RDD算子允许开发者灵活地实现计算逻辑它们分别是reduceByKey、aggregateByKey和combineByKey。
reduceByKey我们并不陌生第1讲的Word Count实现就用到了这个算子aggregateByKey是reduceByKey的“升级版”相比reduceByKeyaggregateByKey用法更加灵活支持的功能也更加完备。
接下来我们先来回顾reduceByKey然后再对aggregateByKey进行展开。相比aggregateByKeycombineByKey仅在初始化方式上有所不同因此我把它留给你作为课后作业去探索。
reduceByKey分组聚合
reduceByKey的字面含义是“按照Key值做聚合”它的计算逻辑就是根据聚合函数f给出的算法把Key值相同的多个元素聚合成一个元素。
在[第1讲]Word Count的实现中我们使用了reduceByKey来实现分组计数
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x: Int, y: Int) => x + y)
重温上面的这段代码你有没有觉得reduceByKey与之前讲过的map、filter这些算子有一些相似的地方没错给定处理函数f它们的用法都是“算子(f)”。只不过对于map来说我们把f称作是映射函数对filter来说我们把f称作判定函数而对于reduceByKey我们把f叫作聚合函数。
在上面的代码示例中reduceByKey的聚合函数是匿名函数(x, y) => x + y。与map、filter等算子的用法一样你也可以明确地定义带名函数f然后再用reduceByKey(f)的方式实现同样的计算逻辑。
需要强调的是给定RDD[(Key类型Value类型)]聚合函数f的类型必须是Value类型Value类型 => Value类型。换句话说函数f的形参必须是两个数值且数值的类型必须与Value的类型相同而f的返回值也必须是Value类型的数值。
咱们不妨再举一个小例子让你加深对于reduceByKey算子的理解。
接下来我们把Word Count的计算逻辑改为随机赋值、提取同一个Key的最大值。也就是在kvRDD的生成过程中我们不再使用映射函数word => (word, 1)而是改为word => (word, 随机数)然后再使用reduceByKey算子来计算同一个word当中最大的那个随机数。
你可以先停下来,花点时间想一想这个逻辑该怎么实现,然后再来参考下面的代码:
import scala.util.Random._
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, nextInt(100)))
// 显示定义提取最大值的聚合函数f
def f(x: Int, y: Int): Int = {
return math.max(x, y)
}
// 按照单词提取最大值
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey(f)
观察上面的代码片段不难发现reduceByKey算子的用法还是比较简单的只需要先定义好聚合函数f然后把它传给reduceByKey算子就行了。那么在运行时上述代码的计算又是怎样的一个过程呢
我把reduceByKey的计算过程抽象成了下图
从图中你可以看出来尽管reduceByKey也会引入Shuffle但相比groupByKey以全量原始数据记录的方式消耗磁盘与网络reduceByKey在落盘与分发之前会先在Shuffle的Map阶段做初步的聚合计算。
比如在数据分区0的处理中在Map阶段reduceByKey把Key同为Streaming的两条数据记录聚合为一条聚合逻辑就是由函数f定义的、取两者之间Value较大的数据记录这个过程我们称之为“Map端聚合”。相应地数据经由网络分发之后在Reduce阶段完成的计算我们称之为“Reduce端聚合”。
你可能会说“做了Map聚合又能怎样呢相比groupByKeyreduceByKey带来的性能收益并不算明显呀”确实就上面的示意图来说我们很难感受到reduceByKey带来的性能收益。不过量变引起质变在工业级的海量数据下相比groupByKeyreduceByKey通过在Map端大幅削减需要落盘与分发的数据量往往能将执行效率提升至少一倍。
应该说,对于大多数分组&聚合的计算需求来说只要设计合适的聚合函数f你都可以使用reduceByKey来实现计算逻辑。不过术业有专攻reduceByKey算子的局限性在于其Map阶段与Reduce阶段的计算逻辑必须保持一致这个计算逻辑统一由聚合函数f定义。当一种计算场景需要在两个阶段执行不同计算逻辑的时候reduceByKey就爱莫能助了。
比方说还是第1讲的Word Count我们想对单词计数的计算逻辑做如下调整
在Map阶段以数据分区为单位计算单词的加和
而在Reduce阶段对于同样的单词取加和最大的那个数值。
显然Map阶段的计算逻辑是sum而Reduce阶段的计算逻辑是max。对于这样的业务需求reduceByKey已无用武之地这个时候就轮到aggregateByKey这个算子闪亮登场了。
aggregateByKey更加灵活的聚合算子
老规矩算子的介绍还是从用法开始。相比其他算子aggregateByKey算子的参数比较多。要在Paired RDD之上调用aggregateByKey你需要提供一个初始值一个Map端聚合函数f1以及一个Reduce端聚合函数f2aggregateByKey的调用形式如下所示
val rdd: RDD[(Key类型Value类型)] = _
rdd.aggregateByKey(初始值)(f1, f2)
初始值可以是任意数值或是字符串而聚合函数我们也不陌生它们都是带有两个形参和一个输出结果的普通函数。就这3个参数来说比较伤脑筋的是它们之间的类型需要保持一致具体来说
初始值类型必须与f2的结果类型保持一致
f1的形参类型必须与Paired RDD的Value类型保持一致
f2的形参类型必须与f1的结果类型保持一致。
不同类型之间的一致性描述起来比较拗口,咱们不妨结合示意图来加深理解:
熟悉了aggregateByKey的用法之后接下来我们用aggregateByKey这个算子来实现刚刚提到的“先加和再取最大值”的计算逻辑代码实现如下所示
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 显示定义Map阶段聚合函数f1
def f1(x: Int, y: Int): Int = {
return x + y
}
// 显示定义Reduce阶段聚合函数f2
def f2(x: Int, y: Int): Int = {
return math.max(x, y)
}
// 调用aggregateByKey实现先加和、再求最大值
val wordCounts: RDD[(String, Int)] = kvRDD.aggregateByKey(0) (f1, f2)
怎么样是不是很简单结合计算逻辑的需要我们只需要提前定义好两个聚合函数同时保证参数之间的类型一致性然后把初始值、聚合函数传入aggregateByKey算子即可。按照惯例我们还是通过aggregateByKey在运行时的计算过程来帮你深入理解算子的工作原理
不难发现在运行时与reduceByKey相比aggregateByKey的执行过程并没有什么两样最主要的区别还是Map端聚合与Reduce端聚合的计算逻辑是否一致。值得一提的是与reduceByKey一样aggregateByKey也可以通过Map端的初步聚合来大幅削减数据量在降低磁盘与网络开销的同时提升Shuffle环节的执行性能。
sortByKey排序
在这一讲的最后我们再来说说sortByKey这个算子顾名思义它的功能是“按照Key进行排序”。给定包含KeyValue键值对的Paired RDDsortByKey会以Key为准对RDD做排序。算子的用法比较简单只需在RDD之上调用sortByKey()即可:
val rdd: RDD[(Key类型Value类型)] = _
rdd.sortByKey()
在默认的情况下sortByKey按照Key值的升序Ascending对RDD进行排序如果想按照降序Descending来排序的话你需要给sortByKey传入false。总结下来关于排序的规则你只需要记住如下两条即可
升序排序调用sortByKey()、或者sortByKey(true)
降序排序调用sortByKey(false)。
重点回顾
今天这一讲我们介绍了数据分析场景中常用的4个算子它们分别是groupByKey、reduceByKey、aggregateByKey和sortByKey掌握这些算子的用法与原理将为你游刃有余地开发数据分析应用打下坚实基础。
关于这些算子你首先需要了解它们之间的共性。一来这4个算子的作用范围都是Paired RDD二来在计算的过程中它们都会引入Shuffle。而Shuffle往往是Spark作业执行效率的瓶颈因此在使用这4个算子的时候对于它们可能会带来的性能隐患我们要做到心中有数。
再者你需要掌握每一个算子的具体用法与工作原理。groupByKey是无参算子你只需在RDD之上调用groupByKey()即可完成对数据集的分组和收集。但需要特别注意的是以全量原始数据记录在集群范围内进行落盘与网络分发会带来巨大的性能开销。因此除非必需你应当尽量避免使用groupByKey算子。
利用聚合函数freduceByKey可以在Map端进行初步聚合大幅削减需要落盘与分发的数据量从而在一定程度上能够显著提升Shuffle计算的执行效率。对于绝大多数分组&聚合的计算需求只要聚合函数f设计得当reduceByKey都能实现业务逻辑。reduceByKey也有其自身的局限性那就是其Map阶段与Reduce阶段的计算逻辑必须保持一致。
对于Map端聚合与Reduce端聚合计算逻辑不一致的情况aggregateByKey可以很好地满足这样的计算场景。aggregateByKey的用法是aggregateByKey(初始值)(Map端聚合函数Reduce端聚合函数)对于aggregateByKey的3个参数你需要保证它们之间类型的一致性。一旦类型一致性得到满足你可以通过灵活地定义两个聚合函数来翻着花样地进行各式各样的数据分析。
最后对于排序类的计算需求你可以通过调用sortByKey来进行实现。sortByKey支持两种排序方式在默认情况下sortByKey()按Key值的升序进行排序sortByKey()与sortByKey(true)的效果是一样的。如果想按照降序做排序你只需要调用sortByKey(false)即可。
到此为止我们一起学习了RDD常用算子的前两大类也就是数据转换和数据聚合。在日常的开发工作中应该说绝大多数的业务需求都可以通过这些算子来实现。
因此恭喜你毫不夸张地说学习到这里你的一只脚已经跨入了Spark分布式应用开发的大门。不过我们还不能骄傲“学会”和“学好”之间还有一定的距离在接下来的时间里期待你和我一起继续加油真正做到吃透Spark、玩转Spark
每课一练
这一讲到这里就要结束了,今天的练习题是这样的:
学习过reduceByKey和aggregateByKey之后你能说说它们二者之间的联系吗你能用aggregateByKey来实现reduceByKey的功能吗
欢迎你分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,和他一起来讨论一下本讲的练习题,我们下一讲再见。

View File

@ -0,0 +1,210 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
08 内存管理Spark如何使用内存
你好,我是吴磊。
在[第6讲]我们拜访了斯巴克建筑集团的分公司熟悉了分公司的办公环境与人员配置同时用“工地搬砖的任务”作类比介绍了Spark Shuffle的工作原理。
今天这一讲我们再次来到分公司去看看斯巴克公司都在承接哪些建筑项目以及这些项目是如何施工的。通过熟悉项目的施工过程我们一起来学习Spark的内存管理。
相比其他大数据计算引擎关于Spark的特性与优势想必你听到最多的字眼就是“内存计算”。合理而又充分地利用内存资源是Spark的核心竞争力之一。因此作为开发者我们弄清楚Spark是如何使用内存的就变得非常重要。
好啦闲言少叙请你戴好安全帽跟我一起再次去拜访斯巴克集团分公司吧。不过在正式“拜访”之前我们还有一项准备工作要做那就是先了解清楚Spark的内存区域是怎样划分的。
Spark内存区域划分
对于任意一个Executor来说Spark会把内存分为4个区域分别是Reserved Memory、User Memory、Execution Memory和Storage Memory。
其中Reserved Memory固定为300MB不受开发者控制它是Spark预留的、用来存储各种 Spark 内部对象的内存区域User Memory用于存储开发者自定义的数据结构例如RDD算子中引用的数组、列表、映射等等。
Execution Memory用来执行分布式任务。分布式任务的计算主要包括数据的转换、过滤、映射、排序、聚合、归并等环节而这些计算环节的内存消耗统统来自于Execution Memory。
Storage Memory用于缓存分布式数据集比如RDD Cache、广播变量等等。关于广播变量的细节我们留到第10讲再去展开。RDD Cache指的是RDD物化到内存中的副本。在一个较长的DAG中如果同一个RDD被引用多次那么把这个RDD缓存到内存中往往会大幅提升作业的执行性能。我们在这节课的最后会介绍RDD Cache的具体用法。
不难发现Execution Memory和Storage Memory这两块内存区域对于Spark作业的执行性能起着举足轻重的作用。因此在所有的内存区域中Execution Memory和Storage Memory是最重要的也是开发者最需要关注的。
在 Spark 1.6 版本之前Execution Memory 和 Storage Memory的空间划分是静态的一旦空间划分完毕不同内存区域的用途与尺寸就固定了。也就是说即便你没有缓存任何 RDD 或是广播变量Storage Memory 区域的空闲内存也不能用来执行映射、排序或聚合等计算任务,宝贵的内存资源就这么白白地浪费掉了。
考虑到静态内存划分的弊端,在 1.6 版本之后Spark 推出了统一内存管理模式在这种模式下Execution Memory 和 Storage Memory 之间可以相互转化。这是什么意思呢?接下来,我们一起走进斯巴克集团分公司,看看不同内存区域相互转化的逻辑。
不同内存区域的相互转化
刚一走进分公司的大门,我们就能看到工人们在工地上如火如荼的忙碌景象。走近一问,才知道他们承接了一个“集装箱改装活动房”的建筑项目。顾名思义,这个项目的具体任务,就是把集装箱改装成活动房。
活动房的制作过程并不复杂,只需一系列简单的步骤,就能把集装箱改装为小巧而又别致的活动房,这些步骤包括清洗、切割开窗、切割开门、刷漆、打隔断、布置家居、装饰点缀。活动房的制作在工地上完成,成功改装的活动房会被立即拉走,由货运卡车运往集团公司的物流集散地。
好了介绍完集装箱改装活动房的项目我们必须要交代一下这个项目与Spark之间的关联关系。毕竟再有趣的故事也是用来辅助咱们更好地学习Spark嘛。
项目中涉及的原材料、施工步骤与Spark之间的类比关系我把它整理到了下面的这张表格中-
从表中可以看到集装箱相当于是RDD数据源而切割门窗等施工步骤对应的正是各式各样的RDD算子。而工地用于提供施工场所这与计算节点内存提供数据处理场所的作用如出一辙。这么看下来集装箱改装活动房的项目就可以看作是Spark作业或者说是Spark应用。
接下来,我们来考察一下这个项目的施工过程。走近工地,我们发现工地上赫然划着一条红色的虚线,把工地一分为二。虚线的左侧,堆放着若干沾满泥土的集装箱,而工地的右侧,则是工人们在集装箱上叮叮当当地做着改装,有的集装箱已经开始布置家居,有的还在切割门窗。
看到地上的红线,我们不免好奇,走近前去问,工头为我们道清了原委。
按理说,像集装箱、家具这些生产资料都应该放在临时仓库(节点硬盘)的,工地(节点内存)原则上只用来进行改装操作。不过,工地离临时仓库还有一段距离,来回运输不太方便。
为了提升工作效率工地被划分成两个区域。在上图中红线左边的那块地叫作暂存区Storage Memory专门用来暂存建筑材料而右边的那部分叫作操作区Execution Memory用来给工人改装集装箱、制作活动房。
之所以使用虚线标记,原因就在于,两块区域的尺寸大小并不是一成不变的,当一方区域有空地时,另一方可以进行抢占。
举例来说假设操作区只有两个工人CPU 线程)分别在改装集装箱,此时操作区空出来可以容纳两个物件的空地,那么这片空地就可以暂时用来堆放建筑材料,暂存区也因此得到了实质性的扩张。
不过当有足够的工人可以扩大生产的时候比如在原有两个工人在作业的基础上又来了两个工人此时共有4个工人可以同时制作活动房那么红色虚线到蓝色实线之间的任何物件比如上图的沙发和双人床都需要腾出到临时仓库腾空的区域交给新来的两个工人改装集装箱。毕竟改装集装箱、制作活动房才是项目的核心任务。
相反如果暂存区堆放的物件比较少、留有空地而工人又比较充裕比如有6个工人可以同时进行改装那么此时暂存区的空地就会被操作区临时征用给工人用来制作活动房。这个时候操作区实际上也扩大了。
当有更多的物件需要堆放到暂存区的时候扩张的操作区相应地也需要收缩到红色虚线的位置。不过对于红色实线与红色虚线之间的区域我们必须要等到工人们把正在改装的活动房制作完毕Task Complete才能把这片区域归还给暂存区。
好啦,活动房的项目到这里就介绍完了。不难发现,操作区类比的是 Execution Memory而暂存区其实就是 Storage Memory。Execution Memory 和 Storage Memory 之间的抢占规则,一共可以总结为 3 条:
如果对方的内存空间有空闲,双方可以互相抢占;
对于Storage Memory抢占的Execution Memory部分当分布式任务有计算需要时Storage Memory必须立即归还抢占的内存涉及的缓存数据要么落盘、要么清除
对于Execution Memory抢占的Storage Memory部分即便Storage Memory有收回内存的需要也必须要等到分布式任务执行完毕才能释放。
介绍完Execution Memory与Storage Memory之间的抢占规则之后接下来我们来看看不同内存区域的初始大小是如何设置的。
内存配置项
总体来说Executor JVM Heap的划分由图中的3个配置项来决定
其中spark.executor.memory是绝对值它指定了Executor进程的JVM Heap总大小。另外两个配置项spark.memory.fraction和spark.memory.storageFraction都是比例值它们指定了划定不同区域的空间占比。
spark.memory.fraction用于标记Spark处理分布式数据集的内存总大小这部分内存包括Execution Memory和Storage Memory两部分也就是图中绿色的矩形区域。M 300* 1 mf刚好就是User Memory的区域大小也就是图中蓝色区域的部分。
spark.memory.storageFraction则用来进一步区分Execution Memory和Storage Memory的初始大小。我们之前说过Reserved Memory固定为300MB。M 300* mf * sf是Storage Memory的初始大小相应地M 300* mf * 1 sf就是Execution Memory的初始大小。
熟悉了以上3个配置项作为开发者我们就能有的放矢地去调整不同的内存区域从而提升内存的使用效率。我们在前面提到合理地使用RDD Cache往往能大幅提升作业的执行性能因此在这一讲的最后我们一起来学习一下RDD Cache的具体用法。
RDD Cache
在一个Spark作业中计算图DAG中往往包含多个RDD我们首先需要弄清楚什么时候对哪个RDD进行Cache盲目地滥用Cache可不是明智之举。我们先说结论当同一个RDD被引用多次时就可以考虑对其进行Cache从而提升作业的执行效率。
我们拿第1讲中的Word Count来举例完整的代码如下所示
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
// 将分组计数结果落盘到文件
val targetPath: String = _
wordCounts.saveAsTextFile(targetPath)
细心的你可能发现了我们今天的代码与第1讲中的代码实现不同。我们在最后追加了saveAsTextFile落盘操作这样一来wordCounts这个RDD在程序中被引用了两次。
如果你把这份代码丢进spark-shell去执行会发现take和saveAsTextFile这两个操作执行得都很慢。这个时候我们就可以考虑通过给wordCounts加Cache来提升效率。
那么问题来了Cache该怎么加呢很简单你只需要在wordCounts完成定义之后在这个RDD之上依次调用cache和count即可如下所示
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
wordCounts.cache// 使用cache算子告知Spark对wordCounts加缓存
wordCounts.count// 触发wordCounts的计算并将wordCounts缓存到内存
// 打印词频最高的5个词汇
wordCounts.map{case (k, v) => (v, k)}.sortByKey(false).take(5)
// 将分组计数结果落盘到文件
val targetPath: String = _
wordCounts.saveAsTextFile(targetPath)
由于cache函数并不会立即触发RDD在内存中的物化因此我们还需要调用count算子来触发这一执行过程。添加上面的两条语句之后你会发现take和saveAsTextFile的运行速度明显变快了很多。强烈建议你在spark-shell中对比添加Cache前后的运行速度从而直观地感受RDD Cache对于作业执行性能的提升。
在上面的例子中我们通过在RDD之上调用cache来为其添加缓存而在背后cache函数实际上会进一步调用persistMEMORY_ONLY来完成计算。换句话说下面的两条语句是完全等价的二者的含义都是把RDD物化到内存。
wordCounts.cache
wordCounts.persist(MEMORY_ONLY)
就添加Cache来说相比cache算子persist算子更具备普适性结合多样的存储级别如这里的MEMORY_ONLYpersist算子允许开发者灵活地选择Cache的存储介质、存储形式以及副本数量。
Spark支持丰富的存储级别每一种存储级别都包含3个最基本的要素。
存储介质:数据缓存到内存还是磁盘,或是两者都有
存储形式:数据内容是对象值还是字节数组,带 SER 字样的表示以序列化方式存储,不带 SER 则表示采用对象值
副本数量:存储级别名字最后的数字代表拷贝数量,没有数字默认为 1 份副本。
我把Spark支持的存储级别总结到了下表其中打钩的地方表示某种存储级别支持的存储介质与存储形式你不妨看一看做到心中有数。
通过上表对琳琅满目的存储级别进行拆解之后我们就会发现它们不过是存储介质、存储形式和副本数量这3类基本要素的排列组合而已。上表列出了目前Spark支持的所有存储级别通过它你可以迅速对比查找不同的存储级别从而满足不同的业务需求。
重点回顾
今天这一讲你需要掌握Executor JVM Heap的划分原理并学会通过配置项来划分不同的内存区域。
具体来说Spark把Executor内存划分为4个区域分别是Reserved Memory、User Memory、Execution Memory和Storage Memory。
通过调整spark.executor.memory、spark.memory.fraction和spark.memory.storageFraction这3个配置项你可以灵活地调整不同内存区域的大小从而去适配Spark作业对于内存的需求。
再者在统一内存管理模式下Execution Memory与Storage Memory之间可以互相抢占你需要弄清楚二者之间的抢占逻辑。总结下来内存的抢占逻辑有如下3条
如果对方的内存空间有空闲,双方可以互相抢占;
对于Storage Memory抢占的Execution Memory部分当分布式任务有计算需要时Storage Memory必须立即归还抢占的内存涉及的缓存数据要么落盘、要么清除
对于Execution Memory抢占的Storage Memory部分即便Storage Memory有收回内存的需要也必须要等到分布式任务执行完毕才能释放。
最后我们介绍了RDD Cache的基本用法当一个RDD在代码中的引用次数大于1时你可以考虑通过给RDD加Cache来提升作业性能。具体做法是在RDD之上调用cache或是persist函数。
其中persist更具备普适性你可以通过指定存储级别来灵活地选择Cache的存储介质、存储形式以及副本数量从而满足不同的业务需要。
每课一练
好啦,这节课就到这里了,我们今天的练习题是这样的:
给定如下配置项设置请你计算不同内存区域Reserved、User、Execution、Storage的空间大小。
欢迎你在评论区分享你的答案。如果这一讲对你有帮助,也欢迎你把这一讲分享给自己的朋友,我们下一讲再见。

View File

@ -0,0 +1,287 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
09 RDD常用算子数据的准备、重分布与持久化
你好,我是吴磊。
在RDD常用算子的前两讲中我们分别介绍了用于RDD内部转换与聚合的诸多算子今天这一讲我们继续来介绍表格中剩余部分的算子。
按照惯例,表格中的算子我们不会全都介绍,而是只挑选其中最常用、最具代表性的进行讲解。今天要讲的算子,我用加粗字体进行了高亮显示,你不妨先扫一眼,做到心中有数。
你可能会觉得,这些高亮显示的算子乍一看也没什么关联啊?但如果我们从数据生命周期的角度入手,给它们归归类,很容易就会发现这些算子分别隶属于生命周期的某个阶段。
结合上图,我们分别来看看每个算子所在的生命周期和它们实现的功能。
首先在数据准备阶段union与sample用于对不同来源的数据进行合并与拆分。
我们从左往右接着看接下来是数据预处理环节。较为均衡的数据分布对后面数据处理阶段提升CPU利用率更有帮助可以整体提升执行效率。那这种均衡要怎么实现呢没错这时就要coalesce与repartition登场了它们的作用就是重新调整RDD数据分布。
在数据处理完毕、计算完成之后我们自然要对计算结果进行收集。Spark提供了两类结果收集算子一类是像take、first、collect这样把结果直接收集到Driver端另一类则是直接将计算结果持久化到分布式文件系统比如咱们这一讲会提到的saveAsTextFile。
好啦,清楚了我们今天要讲哪些算子,以及它们大致的定位与功用之后,接下来,我们就正式来讲讲这些算子的具体用法。
数据准备
首先我们先来说说数据准备阶段的union和sample。
union
在我们日常的开发中union非常常见它常常用于把两个类型一致、但来源不同的RDD进行合并从而构成一个统一的、更大的分布式数据集。例如在某个数据分析场景中一份数据源来自远端数据库而另一份数据源来自本地文件系统要将两份数据进行合并我们就需要用到union这个操作。
具体怎么使用呢我来举个例子。给定两个RDDrdd1和rdd2调用rdd1.union(rdd2)或是rdd1 union rdd2其结果都是两个RDD的并集具体代码如下
// T数据类型
val rdd1: RDD[T] = _
val rdd2: RDD[T] = _
val rdd = rdd1.union(rdd2)
// 或者rdd1 union rdd2
需要特别强调的是union操作能够成立的前提就是参与合并的两个RDD的类型必须完全一致。也就是说RDD[String]只能与RDD[String]合并到一起却无法与除RDD[String]以外的任何RDD类型如RDD[Int]、甚至是RDD[UserDefinedClass])做合并。
对于多个类型一致的RDD我们可以通过连续调用union把所有数据集合并在一起。例如给定类型一致的3个RDDrdd1、rdd2和rdd3我们可以使用如下代码把它们合并在一起。
// T数据类型
val rdd1: RDD[T] = _
val rdd2: RDD[T] = _
val rdd3: RDD[T] = _
val rdd = (rdd1.union(rdd2)).union(rdd3)
// 或者 val rdd = rdd1 union rdd2 union rdd3
不难发现union的典型使用场景是把多份“小数据”合并为一份“大数据”从而充分利用Spark分布式引擎的并行计算优势。
与之相反在一般的数据探索场景中我们往往只需要对一份数据的子集有基本的了解即可。例如对于一份体量在TB级别的数据集我们只想随机提取其部分数据然后计算这部分子集的统计值均值、方差等
那么,面对这类把“大数据”变成 “小数据”的计算需求Spark又如何进行支持呢这就要说到RDD的sample算子了。
sample
RDD的sample算子用于对RDD做随机采样从而把一个较大的数据集变为一份“小数据”。相较其他算子sample的参数比较多分别是withReplacement、fraction和seed。因此要在RDD之上完成数据采样你需要使用如下的方式来调用sample算子sample(withReplacement, fraction, seed)。
其中withReplacement的类型是Boolean它的含义是“采样是否有放回”如果这个参数的值是true那么采样结果中可能会包含重复的数据记录相反如果该值为false那么采样结果不存在重复记录。
fraction参数最好理解它的类型是Double值域为0到1其含义是采样比例也就是结果集与原数据集的尺寸比例。seed参数是可选的它的类型是Long也就是长整型用于控制每次采样的结果是否一致。光说不练假把式我们还是结合一些示例这样才能更好地理解sample算子的用法。
// 生成0到99的整型数组
val arr = (0 until 100).toArray
// 使用parallelize生成RDD
val rdd = sc.parallelize(arr)
// 不带seed每次采样结果都不同
rdd.sample(false, 0.1).collect
// 结果集Array(11, 13, 14, 39, 43, 63, 73, 78, 83, 88, 89, 90)
rdd.sample(false, 0.1).collect
// 结果集Array(6, 9, 10, 11, 17, 36, 44, 53, 73, 74, 79, 97, 99)
// 带seed每次采样结果都一样
rdd.sample(false, 0.1, 123).collect
// 结果集Array(3, 11, 26, 59, 82, 89, 96, 99)
rdd.sample(false, 0.1, 123).collect
// 结果集Array(3, 11, 26, 59, 82, 89, 96, 99)
// 有放回采样,采样结果可能包含重复值
rdd.sample(true, 0.1, 456).collect
// 结果集Array(7, 11, 11, 23, 26, 26, 33, 41, 57, 74, 96)
rdd.sample(true, 0.1, 456).collect
// 结果集Array(7, 11, 11, 23, 26, 26, 33, 41, 57, 74, 96)
我们的实验分为3组前两组用来对比添加seed参数与否的差异最后一组用于说明withReplacement参数的作用。
不难发现在不带seed参数的情况下每次调用sample之后的返回结果都不一样。而当我们使用同样的seed调用算子时不论我们调用sample多少次每次的返回结果都是一致的。另外仔细观察第3组实验你会发现结果集中有重复的数据记录这是因为withReplacement被置为true采样的过程是“有放回的”。
好啦到目前为止数据准备阶段常用的两个算子我们就讲完了。有了union和sample你就可以随意地调整分布式数据集的尺寸真正做到收放自如。
数据预处理
接下来在数据预处理阶段我们再来说说负责数据重分布的两个算子repartition和coalesce。
在了解这两个算子之前你需要先理解并行度这个概念。所谓并行度它实际上就是RDD的数据分区数量。还记得吗RDD的partitions属性记录正是RDD的所有数据分区。因此RDD的并行度与其partitions属性相一致。
开发者可以使用repartition算子随意调整提升或降低RDD的并行度而coalesce算子则只能用于降低RDD并行度。显然在数据分布的调整方面repartition灵活度更高、应用场景更多我们先对它进行介绍之后再去看看coalesce有什么用武之地。
repartition
一旦给定了RDD我们就可以通过调用repartition(n)来随意调整RDD并行度。其中参数n的类型是Int也就是整型因此我们可以把任意整数传递给repartition。按照惯例咱们还是结合示例熟悉一下repartition的用法。
// 生成0到99的整型数组
val arr = (0 until 100).toArray
// 使用parallelize生成RDD
val rdd = sc.parallelize(arr)
rdd.partitions.length
// 4
val rdd1 = rdd.repartition(2)
rdd1.partitions.length
// 2
val rdd2 = rdd.repartition(8)
rdd2.partitions.length
// 8
首先我们通过数组创建用于实验的RDD从这段代码里可以看到该RDD的默认并行度是4。在我们分别用2和8来调整RDD的并行度之后通过计算RDD partitions属性的长度我们发现新RDD的并行度分别被相应地调整为2和8。
看到这里你可能还有疑问“我们为什么需要调整RDD的并行度呢2和8看上去也没什么实质性的区别呀”。
在RDD那一讲[第2讲]我们介绍过每个RDD的数据分区都对应着一个分布式Task而每个Task都需要一个CPU线程去执行。
因此RDD的并行度很大程度上决定了分布式系统中CPU的使用效率进而还会影响分布式系统并行计算的执行效率。并行度过高或是过低都会降低CPU利用率从而白白浪费掉宝贵的分布式计算资源因此合理有效地设置RDD并行度至关重要。
这时你可能会追问“既然如此那么我该如何合理地设置RDD的并行度呢”坦白地说这个问题并没有固定的答案它取决于系统可用资源、分布式数据集大小甚至还与执行内存有关。
不过结合经验来说把并行度设置为可用CPU的2到3倍往往是个不错的开始。例如可分配给Spark作业的Executors个数为N每个Executors配置的CPU个数为C那么推荐设置的并行度坐落在N_C_2到N_C_3这个范围之间。
尽管repartition非常灵活你可以用它随意地调整RDD并行度但是你也需要注意这个算子有个致命的弊端那就是它会引入Shuffle。
我们知道([第6讲]详细讲过由于Shuffle在计算的过程中会消耗所有类型的硬件资源尤其是其中的磁盘I/O与网络I/O因此Shuffle往往是作业执行效率的瓶颈。正是出于这个原因在做应用开发的时候我们应当极力避免Shuffle的引入。
但你可能会说“如果数据重分布是刚需而repartition又必定会引入Shuffle我该怎么办呢”如果你想增加并行度那我们还真的只能仰仗repartitionShuffle的问题自然也就无法避免。但假设你的需求是降低并行度这个时候我们就可以把目光投向repartition的孪生兄弟coalesce。
coalesce
在用法上coalesce与repartition一样它也是通过指定一个Int类型的形参完成对RDD并行度的调整即coalesce (n)。那两者的用法到底有什么差别呢我们不妨结合刚刚的代码示例来对比coalesce与repartition。
// 生成0到99的整型数组
val arr = (0 until 100).toArray
// 使用parallelize生成RDD
val rdd = sc.parallelize(arr)
rdd.partitions.length
// 4
val rdd1 = rdd.repartition(2)
rdd1.partitions.length
// 2
val rdd2 = rdd.coalesce(2)
rdd2.partitions.length
// 2
可以看到在用法上coalesce与repartition可以互换二者的效果是完全一致的。不过如果我们去观察二者的DAG会发现同样的计算逻辑却有着迥然不同的执行计划。
在RDD之上调用toDebugStringSpark可以帮我们打印出当前RDD的DAG。尽管图中的打印文本看上去有些凌乱但你只要抓住其中的一个关键要点就可以了。
这个关键要点就是在toDebugString的输出文本中每一个带数字的小括号比如rdd1当中的“(2)”和“(4)”都代表着一个执行阶段也就是DAG中的Stage。而且不同的Stage之间会通过制表符Tab缩进进行区分比如图中的“(4)”显然要比“(2)”缩进了一段距离。
对于toDebugString的解读你只需要掌握到这里就足够了。学习过调度系统之后我们已经知道在同一个DAG内不同Stages之间的边界是Shuffle。因此观察上面的打印文本我们能够清楚地看到repartition会引入Shuffle而coalesce不会。
那么问题来了同样是重分布的操作为什么repartition会引入Shuffle而coalesce不会呢原因在于二者的工作原理有着本质的不同。
给定RDD如果用repartition来调整其并行度不论增加还是降低对于RDD中的每一条数据记录repartition对它们的影响都是无差别的数据分发。
具体来说给定任意一条数据记录repartition的计算过程都是先哈希、再取模得到的结果便是该条数据的目标分区索引。对于绝大多数的数据记录目标分区往往坐落在另一个Executor、甚至是另一个节点之上因此Shuffle自然也就不可避免。
coalesce则不然在降低并行度的计算中它采取的思路是把同一个Executor内的不同数据分区进行合并如此一来数据并不需要跨Executors、跨节点进行分发因而自然不会引入Shuffle。
这里我还特意准备了一张示意图更直观地为你展示repartition与coalesce的计算过程图片文字双管齐下相信你一定能够更加深入地理解repartition与coalesce之间的区别与联系。
好啦到此为止在数据预处理阶段用于对RDD做重分布的两个算子我们就讲完了。掌握了repartition和coalesce这两个算子结合数据集大小与集群可用资源你就可以随意地对RDD的并行度进行调整进而提升CPU利用率与作业的执行性能。
结果收集
预处理完成之后数据生命周期的下一个阶段是数据处理在这个环节你可以使用RDD常用算子[那一讲]介绍的各类算子,去对数据进行各式各样的处理,比如数据转换、数据过滤、数据聚合,等等。完成处理之后,我们自然要收集计算结果。
在结果收集方面Spark也为我们准备了丰富的算子。按照收集路径区分这些算子主要分为两类第一类是把计算结果从各个Executors收集到Driver端第二个类是把计算结果通过Executors直接持久化到文件系统。在大数据处理领域文件系统往往指的是像HDFS或是S3这样的分布式文件系统。
first、take和collect
我们今天要介绍的第一类算子有first、take和collect它们的用法非常简单按照老规矩我们还是使用代码示例进行讲解。这里我们结合第1讲的Word Count分别使用first、take和collect这三个算子对不同阶段的RDD进行数据探索。
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
lineRDD.first
// res1: String = Apache Spark
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
val cleanWordRDD: RDD[String] = wordRDD.filter(word => !word.equals(""))
cleanWordRDD.take(3)
// res2: Array[String] = Array(Apache, Spark, From)
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
wordCounts.collect
// res3: Array[(String, Int)] = Array((Because,1), (Open,1), (impl...
其中first用于收集RDD数据集中的任意一条数据记录而take(n: Int)则用于收集多条记录记录的数量由Int类型的参数n来指定。
不难发现first与take的主要作用在于数据探索。对于RDD的每一步转换比如Word Count中从文本行到单词、从单词到KV转换我们都可以用first或是take来获取几条计算结果从而确保转换逻辑与预期一致。
相比之下collect拿到的不是部分结果而是全量数据也就是把RDD的计算结果全量地收集到Driver端。在上面Word Count的例子中我们可以看到由于全量结果较大屏幕打印只好做截断处理。
为了让你更深入地理解collect算子的工作原理我把它的计算过程画在了后面的示意图中。
结合示意图不难发现collect算子有两处性能隐患一个是拉取数据过程中引入的网络开销另一个Driver的OOM内存溢出Out of Memory
网络开销很好理解既然数据的拉取和搬运是跨进程、跨节点的那么和Shuffle类似这个过程必然会引入网络开销。
再者通常来说Driver端的预设内存往往在GB量级而RDD的体量一般都在数十GB、甚至上百GB因此OOM的隐患不言而喻。collect算子尝试把RDD全量结果拉取到Driver当结果集尺寸超过Driver预设的内存大小时Spark自然会报OOM的异常Exception
正是出于这些原因我们在使用collect算子之前务必要慎重。不过你可能会问“如果业务逻辑就是需要收集全量结果而collect算子又不好用那我该怎么办呢”别着急我们接着往下看。
saveAsTextFile
对于全量的结果集我们还可以使用第二类算子把它们直接持久化到磁盘。在这类算子中最具代表性的非saveAsTextFile莫属它的用法非常简单给定RDD我们直接调用saveAsTextFile(path: String)即可。其中path代表的是目标文件系统目录它可以是本地文件系统也可以是HDFS、Amazon S3等分布式文件系统。
为了让你加深对于第二类算子的理解我把它们的工作原理也整理到了下面的示意图中。可以看到以saveAsTextFile为代表的算子直接通过Executors将RDD数据分区物化到文件系统这个过程并不涉及与Driver端的任何交互。
由于数据的持久化与Driver无关因此这类算子天然地避开了collect算子带来的两个性能隐患。
好啦到此为止用于结果收集的算子我们就介绍完了掌握了first、take、collect和saveAsTextFile等算子之后你可以先用first、take等算子验证计算逻辑的正确性然后再使用saveAsTextFile算子把全量结果持久化到磁盘以备之后使用。
重点回顾
今天这一讲我们介绍并讲解了很多RDD算子这些算子可以分别归类到数据生命周期的不同阶段算子与阶段的对应关系如下图所示。
在数据准备阶段你可以使用union和sample来扩张或是缩小分布式数据集需要特别注意的是参与union的多个RDD在类型上必须保持一致。
在数据预处理阶段你可以利用repartition和coalesce来调整RDD的并行度。RDD并行度对于CPU利用率至关重要它在很大程度上决定着并行计算的执行效率。一般来说给定Executors个数N以及CPU/Executor配置个数C那么我会推荐你把RDD的并行度设置在N_C_2到N_C_3之间。
最后在结果收集阶段你可以使用first、take、collect等算子来探索数据这些算子可以用来验证计算过程中的转换逻辑是否与预期一致。当你确认计算逻辑准确无误之后就可以使用saveAsTextFile等算子将全量结果集持久化到分布式文件系统。
到今天为止我们用三讲的篇幅学习了RDD开发API中的大部分算子。灵活地运用这些算子你就能轻松应对日常开发中大部分的业务需求。为了方便你随时回顾、查阅我把我们一起学过的这些算子整理到了后面的表格中希望对你有所帮助。
每课一练
给定3个RDD除了使用rdd1 union rdd2 union rdd3把它们合并在一起之外你认为还有其他更加优雅的写法吗提示reduce
相比repartitioncoalesce有哪些可能的潜在隐患提示数据分布
欢迎你在留言区跟我交流互动也推荐你把这一讲分享给更多的同事、朋友帮他理清RDD的常用算子。

View File

@ -0,0 +1,226 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
10 广播变量 & 累加器:共享变量是用来做什么的?
你好,我是吴磊。
今天是国庆第一天,首先祝你节日快乐。专栏上线以来,有不少同学留言说期待后续内容,所以国庆期间我们仍旧更新正文内容,让我们一起把基础知识模块收个尾。
学习过RDD常用算子之后回顾这些算子你会发现它们都是作用Apply在RDD之上的。RDD的计算以数据分区为粒度依照算子的逻辑Executors以相互独立的方式完成不同数据分区的计算与转换。
不难发现对于Executors来说分区中的数据都是局部数据。换句话说在同一时刻隶属于某个Executor的数据分区对于其他Executors来说是不可见的。
不过在做应用开发的时候总会有一些计算逻辑需要访问“全局变量”比如说全局计数器而这些全局变量在任意时刻对所有的Executors都是可见的、共享的。那么问题来了像这样的全局变量或者说共享变量Spark又是如何支持的呢
今天这一讲我就来和你聊聊Spark共享变量。按照创建与使用方式的不同Spark提供了两类共享变量分别是广播变量Broadcast variables和累加器Accumulators。接下来我们就正式进入今天的学习去深入了解这两种共享变量的用法、以及它们各自的适用场景。
广播变量Broadcast variables
我们先来说说广播变量。广播变量的用法很简单给定普通变量x通过调用SparkContext下的broadcast API即可完成广播变量的创建我们结合代码例子看一下。
val list: List[String] = List("Apache", "Spark")
// sc为SparkContext实例
val bc = sc.broadcast(list)
在上面的代码示例中我们先是定义了一个字符串列表list它包含“Apache”和“Spark”这两个单词。然后我们使用broadcast函数来创建广播变量bcbc封装的内容就是list列表。
// 读取广播变量内容
bc.value
// List[String] = List(Apache, Spark)
// 直接读取列表内容
list
// List[String] = List(Apache, Spark)
使用broadcast API创建广播变量
广播变量创建好之后通过调用它的value函数我们就可以访问它所封装的数据内容。可以看到调用bc.value的效果这与直接访问字符串列表list的效果是完全一致的。
看到这里你可能会问“明明通过访问list变量就可以直接获取字符串列表为什么还要绕个大弯儿先去封装广播变量然后又通过它的value函数来获取同样的数据内容呢”实际上这是个非常好的问题要回答这个问题咱们需要做个推演看看直接访问list变量会产生哪些弊端。
在前面的几讲中我们换着花样地变更Word Count的计算逻辑。尽管Word Count都快被我们“玩坏了”不过一以贯之地沿用同一个实例有助于我们通过对比迅速掌握新的知识点、技能点。因此为了让你迅速掌握广播变量的“精髓”咱们不妨“故技重施”继续在Word Count这个实例上做文章。
普通变量的痛点
这一次为了对比使用广播变量前后的差异我们把Word Count变更为“定向计数”。
所谓定向计数它指的是只对某些单词进行计数例如给定单词列表list我们只对文件wikiOfSpark.txt当中的“Apache”和“Spark”这两个单词做计数其他单词我们可以忽略。结合[第1讲]Word Count的完整代码这样的计算逻辑很容易实现如下表所示。
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
// 创建单词列表list
val list: List[String] = List("Apache", "Spark")
// 使用list列表对RDD进行过滤
val cleanWordRDD: RDD[String] = wordRDD.filter(word => list.contains(word))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 获取计算结果
wordCounts.collect
// Array[(String, Int)] = Array((Apache,34), (Spark,63))
将上述代码丢进spark-shell我们很快就能算出在wikiOfSpark.txt文件中“Apache”这个单词出现了34次而“Spark”则出现了63次。虽说得出计算结果挺容易的不过知其然还要知其所以然接下来咱们一起来分析一下这段代码在运行时是如何工作的。
如上图所示list变量本身是在Driver端创建的它并不是分布式数据集如lineRDD、wordRDD的一部分。因此在分布式计算的过程中Spark需要把list变量分发给每一个分布式任务Task从而对不同数据分区的内容进行过滤。
在这种工作机制下如果RDD并行度较高、或是变量的尺寸较大那么重复的内容分发就会引入大量的网络开销与存储开销而这些开销会大幅削弱作业的执行性能。为什么这么说呢
要知道Driver端变量的分发是以Task为粒度的系统中有多少个Task变量就需要在网络中分发多少次。更要命的是每个Task接收到变量之后都需要把它暂存到内存以备后续过滤之用。换句话说在同一个Executor内部多个不同的Task多次重复地缓存了同样的内容拷贝毫无疑问这对宝贵的内存资源是一种巨大的浪费。
RDD并行度较高意味着RDD的数据分区数量较多而Task数量与分区数相一致这就代表系统中有大量的分布式任务需要执行。如果变量本身尺寸较大大量分布式任务引入的网络开销与内存开销会进一步升级。在工业级应用中RDD的并行度往往在千、万这个量级在这种情况下诸如list这样的变量会在网络中分发成千上万次作业整体的执行效率自然会很差 。
面对这样的窘境,我们有没有什么办法,能够避免同一个变量的重复分发与存储呢?答案当然是肯定的,这个时候,我们就可以祭出广播变量这个“杀手锏”。
广播变量的优势
想要知道广播变量到底有啥优势,我们可以先用广播变量重写一下前面的代码实现,然后再做个对比,很容易就能发现广播变量为什么能解决普通变量的痛点。
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
// 创建单词列表list
val list: List[String] = List("Apache", "Spark")
// 创建广播变量bc
val bc = sc.broadcast(list)
// 使用bc.value对RDD进行过滤
val cleanWordRDD: RDD[String] = wordRDD.filter(word => bc.value.contains(word))
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 获取计算结果
wordCounts.collect
// Array[(String, Int)] = Array((Apache,34), (Spark,63))
可以看到代码的修改非常简单我们先是使用broadcast函数来封装list变量然后在RDD过滤的时候调用bc.value来访问list变量内容。你可不要小看这个改写尽管代码的改动微乎其微几乎可以忽略不计但在运行时整个计算过程却发生了翻天覆地的变化。
在使用广播变量之前list变量的分发是以Task为粒度的而在使用广播变量之后变量分发的粒度变成了以Executors为单位同一个Executor内多个不同的Tasks只需访问同一份数据拷贝即可。换句话说变量在网络中分发与存储的次数从RDD的分区数量锐减到了集群中Executors的个数。
要知道在工业级系统中Executors个数与RDD并行度相比二者之间通常会相差至少两个数量级。在这样的量级下广播变量节省的网络与内存开销会变得非常可观省去了这些开销对作业的执行性能自然大有裨益。
好啦到现在为止我们讲解了广播变量的用法、工作原理以及它的优势所在。在日常的开发工作中当你遇到需要多个Task共享同一个大型变量如列表、数组、映射等数据结构的时候就可以考虑使用广播变量来优化你的Spark作业。接下来我们继续来说说Spark支持的第二种共享变量累加器。
累加器Accumulators
累加器顾名思义它的主要作用是全局计数Global counter。与单机系统不同在分布式系统中我们不能依赖简单的普通变量来完成全局计数而是必须依赖像累加器这种特殊的数据结构才能达到目的。
与广播变量类似累加器也是在Driver端定义的但它的更新是通过在RDD算子中调用add函数完成的。在应用执行完毕之后开发者在Driver端调用累加器的value函数就能获取全局计数结果。按照惯例咱们还是通过代码来熟悉累加器的用法。
聪明的你可能已经猜到了我们又要对Word Count“动手脚”了。在第1讲的Word Count中我们过滤掉了空字符串然后对文件wikiOfSpark.txt中所有的单词做统计计数。
不过这一次,我们在过滤掉空字符的同时,还想知道文件中到底有多少个空字符串,这样我们对文件中的“脏数据”就能做到心中有数了。
注意这里对于空字符串的计数不是主代码逻辑它的计算结果不会写入到Word Count最终的统计结果。所以只是简单地去掉filter环节是无法实现空字符计数的。
那么你自然会问“不把filter环节去掉怎么对空字符串做统计呢”别着急这样的计算需求正是累加器可以施展拳脚的地方。你可以先扫一眼下表的代码实现然后我们再一起来熟悉累加器的用法。
import org.apache.spark.rdd.RDD
val rootPath: String = _
val file: String = s"${rootPath}/wikiOfSpark.txt"
// 读取文件内容
val lineRDD: RDD[String] = spark.sparkContext.textFile(file)
// 以行为单位做分词
val wordRDD: RDD[String] = lineRDD.flatMap(line => line.split(" "))
// 定义Long类型的累加器
val ac = sc.longAccumulator("Empty string")
// 定义filter算子的判定函数f注意f的返回类型必须是Boolean
def f(x: String): Boolean = {
if(x.equals("")) {
// 当遇到空字符串时累加器加1
ac.add(1)
return false
} else {
return true
}
}
// 使用f对RDD进行过滤
val cleanWordRDD: RDD[String] = wordRDD.filter(f)
// 把RDD元素转换为KeyValue的形式
val kvRDD: RDD[(String, Int)] = cleanWordRDD.map(word => (word, 1))
// 按照单词做分组计数
val wordCounts: RDD[(String, Int)] = kvRDD.reduceByKey((x, y) => x + y)
// 收集计数结果
wordCounts.collect
// 作业执行完毕通过调用value获取累加器结果
ac.value
// Long = 79
与第1讲的Word Count相比这里的代码主要有4处改动
使用SparkContext下的longAccumulator来定义Long类型的累加器
定义filter算子的判定函数f当遇到空字符串时调用add函数为累加器计数
以函数f为参数调用filter算子对RDD进行过滤
作业完成后调用累加器的value函数获取全局计数结果。
你不妨把上面的代码敲入到spark-shell里直观体验下累加器的用法与效果ac.value给出的结果是79这说明以空格作为分隔符切割源文件wikiOfSpark.txt之后就会留下79个空字符串。
另外你还可以验证wordCounts这个RDD它包含所有单词的计数结果不过你会发现它的元素并不包含空字符串这与我们预期的计算逻辑是一致的。
除了上面代码中用到的longAccumulatorSparkContext还提供了doubleAccumulator和collectionAccumulator这两种不同类型的累加器用于满足不同场景下的计算需要感兴趣的话你不妨自己动手亲自尝试一下。
其中doubleAccumulator用于对Double类型的数值做全局计数而collectionAccumulator允许开发者定义集合类型的累加器相比数值类型集合类型可以为业务逻辑的实现提供更多的灵活性和更大的自由度。
不过就这3种累加器来说尽管类型不同但它们的用法是完全一致的。都是先定义累加器变量然后在RDD算子中调用add函数从而更新累加器状态最后通过调用value函数来获取累加器的最终结果。
好啦,到这里,关于累加器的用法,我们就讲完了。在日常的开发中,当你遇到需要做全局计数的场景时,别忘了用上累加器这个实用工具。
重点回顾
今天的内容讲完了,我们一起来做个总结。今天这一讲,我们重点讲解了广播变量与累加器的用法与适用场景。
广播变量由Driver端定义并初始化各个Executors以只读Read only的方式访问广播变量携带的数据内容。累加器也是由Driver定义的但Driver并不会向累加器中写入任何数据内容累加器的内容更新完全是由各个Executors以只写Write only的方式来完成而Driver仅以只读的方式对更新后的内容进行访问。
关于广播变量你首先需要掌握它的基本用法。给定任意类型的普通变量你都可以使用SparkContext下面的broadcast API来创建广播变量。接下来在RDD的转换与计算过程中你可以通过调用广播变量的value函数来访问封装的数据内容从而辅助RDD的数据处理。
需要额外注意的是在Driver与Executors之间普通变量的分发与存储是以Task为粒度的因此它所引入的网络与内存开销会成为作业执行性能的一大隐患。在使用广播变量的情况下数据内容的分发粒度变为以Executors为单位。相比前者广播变量的优势高下立判它可以大幅度消除前者引入的网络与内存开销进而在整体上提升作业的执行效率。
关于累加器首先你要清楚它的适用场景当你需要做全局计数的时候累加器会是个很好的帮手。其次你需要掌握累加器的具体用法可以分为这样3步
使用SparkContext下的[long | double | collection]Accumulator来定义累加器
在RDD的转换过程中调用add函数更新累加器状态
在作业完成后调用value函数获取累加器的全局结果。
每课一练
在使用累加器对空字符串做全局计数的代码中,请你用普通变量去替换累加器,试一试,在不使用累加器的情况,能否得到预期的计算结果?
累加器提供了Long、Double和Collection三种类型的支持那么广播变量在类型支持上有限制吗除了普通类型、集合类型之外广播变量还支持其他类型吗比如Spark支持在RDD之上创建广播变量吗
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给你身边的朋友,说不定就能帮他解决一个难题。

View File

@ -0,0 +1,136 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
11 存储系统:数据到底都存哪儿了?
你好,我是吴磊。
感谢你在国庆假期仍然坚持学习今天这一讲我们来学习存储系统与调度系统一样它也是Spark重要的基础设施之一。不过你可能会好奇“掌握Spark应用开发需要去了解这么底层的知识吗”坦白地说还真需要为什么这么说呢
我们前面学了Shuffle管理、RDD Cache和广播变量这些功能与特性对Spark作业的执行性能有着至关重要的影响。而想要实现这些功能底层的支撑系统正是Spark存储系统。
学习和熟悉存储系统不单单是为了完善我们的知识体系它还能直接帮你更好地利用RDD Cache和广播变量这些特性。在未来这些知识也能为你做Shuffle的调优奠定良好的基础。
既然存储系统这么重要,那要怎样高效快速地掌握它呢?本着学以致用的原则,我们需要先了解系统的服务对象,说白了就是存储系统是用来存什么东西的。
服务对象
笼统地说Spark存储系统负责维护所有暂存在内存与磁盘中的数据这些数据包括Shuffle中间文件、RDD Cache以及广播变量。
对于上述三类数据我们并不陌生。我们先回顾一下什么是Shuffle中间文件在Shuffle的计算过程中Map Task在Shuffle Write阶段生产data与index文件。接下来根据index文件提供的分区索引Shuffle Read阶段的Reduce Task从不同节点拉取属于自己的分区数据。而Shuffle中间文件指的正是两个阶段为了完成数据交换所仰仗的data与index文件。
RDD Cache指的是分布式数据集在内存或是磁盘中的物化它往往有利于提升计算效率。广播变量[上一讲]我们刚刚介绍过它的优势在于以Executors为粒度分发共享变量从而大幅削减数据分发引入的网络与存储开销。
我们刚才对这三类数据做了简单回顾如果你觉得哪里不是特别清楚的话不妨翻回前面几讲再看一看我们在第7、8、10这3讲分别对它们做了详细讲解。好啦了解了存储系统服务的主要对象以后接下来我们来细数Spark存储系统都有哪些重要组件看看它们之间又是如何协作的。
存储系统的构成
理论的学习总是枯燥而又乏味为了让你更加轻松地掌握存储系统的核心组件咱们不妨还是用斯巴克国际建筑集团的类比来讲解Spark存储系统。
相比调度系统复杂的人事关系(戴格、塔斯克、拜肯德),存储系统的人员构成要简单得多。在内存管理[那一讲],我们把节点内存看作是施工工地,而把节点磁盘看作是临时仓库,那么显然,管理数据存储的组件,就可以看成是仓库管理员,简称库管。
布劳克家族
在斯巴克建筑集团,库管这个关键角色,一直以来都是由布劳克家族把持着。
布劳克家族在斯巴克集团的地位举足轻重老布劳克BlockManagerMaster坐镇集团总公司Driver而他的子嗣们、小布劳克BlockManager则驻守在各个分公司Executors
对集团公司建材与仓库的整体情况,老布劳克了如指掌,当然,这一切要归功于他众多的子嗣们。各家分公司的小布劳克,争先恐后地向老爸汇报分公司的建材状态与仓库状况。关于他们的父子关系,我整理到了下面的示意图中。
从上图我们能够看得出来,小布劳克与老布劳克之间的信息交换是双向的。不难发现,布劳克家族的家风是典型的“家长制”和“一言堂”。如果小布劳克需要获取其他分公司的状态,他必须要通过老布劳克才能拿到这些信息。
在前面的几讲中我们把建材比作是分布式数据集那么BlockManagerMaster与BlockManager之间交换的信息实际上就是Executors之上数据的状态。说到这里你可能会问“既然BlockManagerMaster的信息都来自于BlockManager那么BlockManager又是从哪里获取到这些信息的呢”要回答这个问题我们还要从BlockManager的职责说起。
我们开头说过存储系统的服务对象有3个分别是Shuffle中间文件、RDD Cache以及广播变量而BlockManager的职责正是在Executors中管理这3类数据的存储、读写与收发。就存储介质来说这3类数据所消耗的硬件资源各不相同。
具体来说Shuffle中间文件消耗的是节点磁盘而广播变量主要占用节点的内存空间RDD Cache则是“脚踏两条船”既可以消耗内存也可以消耗磁盘。
不管是在内存、还是在磁盘这些数据都是以数据块Blocks为粒度进行存取与访问的。数据块的概念与RDD数据分区Partitions是一致的在RDD的上下文中说到数据划分的粒度我们往往把一份数据称作“数据分区”。而在存储系统的上下文中对于细分的一份数据我们称之为数据块。
有了数据块的概念我们就可以进一步细化BlockManager的职责。BlockManager的核心职责在于管理数据块的元数据Meta data这些元数据记录并维护数据块的地址、位置、尺寸以及状态。为了让你直观地感受一下元数据我把它的样例放到了下面的示意图里你可以看一看。
只有借助元数据BlockManager才有可能高效地完成数据的存与取、收与发。这就回答了前面我提出的问题BlockManager与数据状态有关的所有信息全部来自于元数据的管理。那么接下来的问题是结合这些元数据BlockManager如何完成数据的存取呢
不管是工地上,还是仓库里,这些场所都是尘土飞扬、人来人往,像存取建材这种事情,养尊处优的小布劳克自然不会亲力亲为。于是,他招募了两个帮手,来帮他打理这些脏活累活。
这两个帮手也都不是外人一个是大表姐迈美瑞MemoryStore另一个是大表哥迪斯克DiskStore。顾名思义MemoryStore负责内存中的数据存取而相应地DiskStore则负责磁盘中的数据访问。
好啦到此为止存储系统的重要角色已经悉数登场我把他们整理到了下面的表格中。接下来我们以RDD Cache和Shuffle中间文件的存取为例分别说一说迈美瑞和迪斯克是如何帮助小布劳克来打理数据的。
MemoryStore内存数据访问
大表姐迈美瑞秀外慧中做起事情来井井有条。为了不辜负小布劳克的托付迈美瑞随身携带着一本小册子这本小册子密密麻麻记满了关于数据块的详细信息。这个小册子是一种特别的数据结构LinkedHashMap[BlockId, MemoryEntry]。顾名思义LinkedHashMap是一种Map其中键值对的Key是BlockIdValue是MemoryEntry。
BlockId用于标记Block的身份需要注意的是BlockId不是一个仅仅记录Id的字符串而是一种记录Block元信息的数据结构。BlockId这个数据结构记录的信息非常丰富包括Block名字、所属RDD、Block对应的RDD数据分区、是否为广播变量、是否为Shuffle Block等等。
MemoryEntry是对象它用于承载数据实体数据实体可以是某个RDD的数据分区也可以是广播变量。存储在LinkedHashMap当中的MemoryEntry相当于是通往数据实体的地址。
不难发现BlockId和MemoryEntry一起就像是居民户口簿一样完整地记录了存取某个数据块所需的所有元信息相当于“居民姓名”、“所属派出所”、“家庭住址”等信息。基于这些元信息我们就可以像“查户口”一样有的放矢、精准定向地对数据块进行存取访问。
val rdd: RDD[_] = _
rdd.cache
rdd.count
以RDD Cache为例当我们使用上述代码创建RDD缓存的时候Spark会在后台帮我们做如下3件事情这个过程我把它整理到了下面的示意图中你可以看一看。
以数据分区为粒度计算RDD执行结果生成对应的数据块
将数据块封装到MemoryEntry同时创建数据块元数据BlockId
BlockIdMemoryEntry键值对添加到“小册子”LinkedHashMap。
随着RDD Cache过程的推进LinkedHashMap当中的元素会越积越多当迈美瑞的小册子完成记录的时候Spark就可以通过册子上的“户口簿”来访问每一个数据块从而实现对RDD Cache的读取与访问。
DiskStore磁盘数据访问
说完大表姐,接下来,我们再来说说大表哥迪斯克。迪斯克的主要职责,是通过维护数据块与磁盘文件的对应关系,实现磁盘数据的存取访问。相比大表姐的一丝不苟、亲力亲为,迪斯克要“鸡贼”得多,他跟布劳克一样,都是甩手掌柜。
看到大表姐没日没夜地盯着自己的“小册子”迪斯克可不想无脑地给布劳克卖命于是他招募了一个帮手DiskBlockManager来帮他维护元数据。
有了DiskBlockManager这个帮手给他打理各种杂事迪斯克这个家伙就可以哼着小曲、喝着咖啡坐在仓库门口接待来来往往的施工工人就好了。这些工人有的存货有的取货但不论是干什么的迪斯克会统一把他们打发到DiskBlockManager那里去让DiskBlockManager告诉他们货物都存在哪些货架的第几层。
帮手DiskBlockManager是类对象它的getFile方法以BlockId为参数返回磁盘文件。换句话说给定数据块要想知道它存在了哪个磁盘文件需要调用getFile方法得到答案。有了数据块与文件之间的映射关系我们就可以轻松地完成磁盘中的数据访问。
以Shuffle为例在Shuffle Write阶段每个Task都会生成一份中间文件每一份中间文件都包括带有data后缀的数据文件以及带着index后缀的索引文件。那么对于每一份文件来说我们都可以通过DiskBlockManager的getFile方法来获取到对应的磁盘文件如下图所示。
可以看到获取data文件与获取index文件的流程是完全一致的他们都是使用BlockId来调用getFile方法从而完成数据访问。
重点回顾
今天这一讲我们重点讲解了Spark存储系统。关于存储系统你首先需要知道是RDD Cache、Shuffle中间文件与广播变量这三类数据是存储系统最主要的服务对象。
接着我们介绍了存储系统的核心组件它们是坐落在Driver端的BlockManagerMaster以及“驻守”在Executors的BlockManager、MemoryStore和DiskStore。BlockManagerMaster与众多BlockManager之间通过心跳来完成信息交换这些信息包括数据块的地址、位置、大小和状态等等。
在Executors中BlockManager通过MemoryStore来完成内存的数据存取。MemoryStore通过一种特殊的数据结构LinkedHashMap来完成BlockId到MemoryEntry的映射。其中BlockId记录着数据块的元数据而MemoryEntry则用于封装数据实体。
与此同时BlockManager通过DiskStore来实现磁盘数据的存取与访问。DiskStore并不直接维护元数据列表而是通过DiskBlockManager这个对象来完成从数据库到磁盘文件的映射进而完成数据访问。
每课一练
LinkedHashMap是一种很特殊的数据结构在今天这一讲我们仅介绍了它在Map方面的功用。你可以试着自己梳理一下LinkedHashMap这种数据结构的特点与特性。
期待在留言区看到你的思考。如果这一讲对你有帮助,也推荐你转发给更多的同事、朋友。我们下一讲见!

View File

@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
12 基础配置详解:哪些参数会影响应用程序稳定性?
你好,我是吴磊。
国庆假期即将结束我们的基础模块也即将收尾。到目前为止我们一起学习了RDD编程模型、Spark分布式部署、Spark工作原理以及RDD常用算子。恭喜你到这里可以说你已经完全跨入了Spark分布式应用开发的大门。有了现在的知识储备对于大多数的业务需求我相信你都能很快地实现。
不过,快速用代码实现各式各样的业务需求,这还只是第一步。我们不光要让代码跑起来,还需要让代码跑得又快又稳。
要想做到这些我们还需要配置项来帮忙。如果把Spark看作是一部F1赛车的话那么配置项就是赛车车身的各项配置参数如发动机缸数、最大转矩、车身轴距、悬挂方式、整车装备质量等等。只有合理地配置车身参数才能让车子本身的稳定性和性能得到保障为选手的出色发挥奠定基础。
今天这一讲我们就来说一说Spark都有哪些配置项以及这些配置项的含义与作用。
配置项
打开Spark官网的Configuration页面在这里你能找到全部的Spark配置项。
不过让人沮丧的是配置项数目过于庞大种类繁多有的需要设置true/false有的则需要我们给出明确的数值让人看上去眼花缭乱、无所适从。
那么问题来了,面对这么多的配置项,我们应该从哪里入手呢?别着急,既然我们的目的是让车子“跑得稳”、“跑得快”,那咱们不妨从这两个角度出发,来整理那些我们必须要掌握的配置项。
在这一讲咱们先来梳理那些能让Spark跑得稳的配置项而在后续介绍Spark SQL的时候我们再去关注那些与“跑得快”有关的部分。
关于跑得稳这件事你可能会有这样的疑问“一般的车子出厂就能开并不需要特别调整什么车辆参数。同理大部分Spark配置项都有默认值开发者使用出厂设置、省去调参的麻烦它不香吗” 遗憾的是对于大多数的应用场景来说在默认的参数设置下Spark还真就跑不起来。
以spark.executor.memory这个配置项为例它用于指定Executor memory也就是Executor可用内存上限。这个参数的默认值是1GB显然对于动辄上百GB、甚至上TB量级的工业级数据来说这样的设置太低了分布式任务很容易因为OOM内存溢出Out of memory而中断。
你看为了能让Spark跑得稳咱们还是得花些心思。对于刚才说的情况如果你以为直接把内存参数设置到上百GB就可以一劳永逸那未免有些草率。单纯从资源供给的角度去调整配置项参数是一种“简单粗暴”的做法并不可取。实际上应用程序运行得稳定与否取决于硬件资源供给与计算需要是否匹配。
这就好比是赛车组装,要得到一辆高性能的车子,我们并不需要每一个部件都达到“顶配”的要求,而是要让组装配件之间相互契合、匹配,才能让车子达到预期的马力输出。
因此,咱们不妨从硬件资源的角度切入,去探索开发者必须要关注的配置项都有哪些。既然上面我们用内存举例,而且关于内存的配置项,我们在内存管理那一讲简单提过,你可能还有一些印象,那么接下来,我们就从内存入手,说一说和它有关的配置项。
内存
说起内存咱们不妨先来回顾一下Spark的内存划分。对于给定的Executor MemorySpark将JVM Heap划分为4个区域分别是Reserved Memory、User Memory、Execution Memory和Storage Memory如下图所示。
不同内存区域的含义和它们的计算公式,我们在[第8讲]做过详细讲解,如果你印象不深了可以回顾一下,这里我们重点分析一下这些内存配置项数值的设置思路。
结合图解其中Reserved Memory大小固定为300MB其他3个区域的空间大小则有3个配置项来划定它们分别是spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction。
为了后续叙述方便我们分别把它们简称为M、mf和sf其中大写的M是绝对值而小写的mf和sf都是比例值这一点需要你注意。
其中M用于指定划分给Executor进程的JVM Heap大小也即是Executor Memory。Executor Memory由Execution Memory、Storage Memory和User Memory“这三家”瓜分。
M 300* mf划分给Execution Memory和Storage Memory而User Memory空间大小由M 300*1 - mf这个公式划定它用于存储用户自定义的数据结构比如RDD算子中包含的各类实例化对象或是集合类型如数组、列表等都属于这个范畴。
因此如果你的分布式应用并不需要那么多自定义对象或集合数据你应该把mf的值设置得越接近1越好这样User Memory无限趋近于0大面积的可用内存就可以都留给Execution Memory和Storage Memory了。
我们知道在1.6版本之后Spark推出了统一的动态内存管理模式在对方资源未被用尽的时候Execution Memory与Storage Memory之间可以互相进行抢占。不过即便如此我们仍然需要sf这个配置项来划定它们之间的那条虚线从而明确告知Spark我们开发者更倾向于“偏袒”哪一方。
那么对于sf的设置开发者该如何进行取舍呢答案是看数据的复用频次。这是什么意思呢我们分场景举例来说。
对于ETLExtract、Transform、Load类型的作业来说数据往往都是按照既定的业务逻辑依序处理其中绝大多数的数据形态只需访问一遍很少有重复引用的情况。
因此在ETL作业中RDD Cache并不能起到提升执行性能的作用那么自然我们也就没必要使用缓存了。在这种情况下我们就应当把sf的值设置得低一些压缩Storage Memory可用空间从而尽量把内存空间留给Execution Memory。
相反如果你的应用场景是机器学习、或是图计算这些计算任务往往需要反复消耗、迭代同一份数据处理方式就不一样了。在这种情况下咱们要充分利用RDD Cache提供的性能优势自然就要把sf这个参数设置得稍大一些从而让Storage Memory有足够的内存空间来容纳需要频繁访问的分布式数据集。
好啦到此为止对于内存的3个配置项我们分别解读了它们的含义以及设置的一般性原则。你需要根据你的应用场景合理设置这些配置项这样程序运行才会高速、稳定。学会了这些内存配置项这一关你基本上已经拿到80分了。而剩下的20分需要你从日常开发的反复实践中去获取期待你总结归纳出更多的配置经验。
在硬件资源方面内存的服务对象是CPU。内存的有效配置一方面是为了更好地容纳数据另一方面更重要的就是提升CPU的利用率。那说完内存接下来我们再来看看CPU。
CPU
与CPU直接相关的配置项我们只需关注两个参数它们分别是spark.executor.instances和spark.executor.cores。其中前者指定了集群内Executors的个数而后者则明确了每个Executors可用的CPU CoresCPU核数
我们知道一个CPU Core在同一时间只能处理一个分布式任务因此spark.executor.instances与spark.executor.cores的乘积实际上决定了集群的并发计算能力这个乘积我们把它定义为“并发度”Degree of concurrency
说到并发度我们就不得不说另外一个概念并行度Degree of parallism。相比并发度并行度是一个高度相关、但又完全不同的概念。并行度用于定义分布式数据集划分的份数与粒度它直接决定了分布式任务的计算负载。并行度越高数据的粒度越细数据分片越多数据越分散。
这也就解释了并行度为什么总是跟分区数量、分片数量、Partitions 这些属性相一致。举个例子第9讲我们就说过并行度对应着RDD的数据分区数量。
与并行度相关的配置项也有两个分别是spark.default.parallelism和spark.sql.shuffle.partitions。其中前者定义了由SparkContext.parallelize API所生成RDD的默认并行度而后者则用于划定Shuffle过程中Shuffle Read阶段Reduce阶段的默认并行度。
对比下来,并发度的出发点是计算能力,它与执行内存一起,共同构成了计算资源的供给水平,而并行度的出发点是数据,它决定着每个任务的计算负载,对应着计算资源的需求水平。一个是供给,一个是需求,供需的平衡与否,直接影响着程序运行的稳定性。
CPU、内存与数据的平衡
由此可见所谓供需的平衡实际上就是指CPU、内存与数据之间的平衡。那么问题来了有没有什么量化的办法来让三者之间达到供需之间平衡的状态呢其实只需要一个简单的公式我们就可以轻松地做到这一点。
为了叙述方便我们把由配置项spark.executor.cores指定的CPU Cores记为c把Execution Memory内存大小记为m还记得吗m的尺寸由公式M - 300* mf *1 - sf给出。不难发现c和m一同量化了一个Executor的可用计算资源。
量化完资源供给我们接着再来说数据。对于一个待计算的分布式数据集我们把它的存储尺寸记为D而把其并行度记录为P。给定D和P不难推出D/P就是分布式数据集的划分粒度也就是每个数据分片的存储大小。
学习过调度系统我们知道在Spark分布式计算的过程中一个数据分片对应着一个Task分布式任务而一个Task又对应着一个CPU Core。因此把数据看作是计算的需求方要想达到CPU、内存与数据这三者之间的平衡我们必须要保证每个Task都有足够的内存来让CPU处理对应的数据分片。
为此我们要让数据分片大小与Task可用内存之间保持在同一量级具体来说我们可以使用下面的公式来进行量化。
D/P ~ m/c
其中波浪线的含义是其左侧与右侧的表达式在同一量级。左侧的表达式D/P为数据分片大小右侧的m/c为每个Task分到的可用内存。以这个公式为指导结合分布式数据集的存储大小我们就可以有的放矢、有迹可循地对上述的3类配置项进行设置或调整也就是与CPU、内存和并行度有关的那几个配置项。
磁盘
说完了CPU和内存接下来我们再来说说磁盘。与前两者相比磁盘的配置项相对要简单得多值得我们关注的仅有spark.local.dir这一个配置项为了叙述方便后续我们把它简称为ld。这个配置项的值可以是任意的本地文件系统目录它的默认值是/tmp目录。
ld参数对应的目录用于存储各种各样的临时数据如Shuffle中间文件、RDD Cache存储级别包含“disk”等等。这些临时数据对程序能否稳定运行有着至关重要的作用。
例如Shuffle中间文件是Reduce阶段任务执行的基础和前提如果中间文件丢失Spark在Reduce阶段就会抛出“Shuffle data not found”异常从而中断应用程序的运行。
既然这些临时数据不可或缺,我们就不能盲从默认选项了,而是有必要先考察下/tmp目录的情况。遗憾的是ld参数默认的/tmp目录一来存储空间有限二来该目录本身的稳定性也值得担忧。因此在工业级应用中我们通常都不能接受使用/tmp目录来设置ld配置项。
了解了ld这个配置项的作用之后我们自然就能想到应该把它设置到一个存储空间充沛、甚至性能更有保障的文件系统比如空间足够大的SSDSolid State Disk文件系统目录。
好啦到此为止我们分别介绍了与CPU、内存、磁盘有关的配置项以及它们的含义、作用与设置技巧。说到这里你可能有些按捺不住“这些配置项的重要性我已经get到了那我应该在哪里设置它们呢”接下来我们继续来说说开发者都可以通过哪些途径来设置配置项。
配置项的设置途径
为了满足不同的应用场景Spark为开发者提供了3种配置项设置方式分别是配置文件、命令行参数和SparkConf对象这些方式都以KeyValue键值对的形式记录并设置配置项。
配置文件指的是spark-defaults.conf这个文件存储在Spark安装目录下面的conf子目录。该文件中的参数设置适用于集群范围内所有的应用程序因此它的生效范围是全局性的。对于任意一个应用程序来说如果开发者没有通过其他方式设置配置项那么应用将默认采用spark-defaults.conf中的参数值作为基础设置。
在spark-defaults.conf中设置配置项你只需要用空格把配置项的名字和它的设置值分隔开即可。比如以spark.executor.cores、spark.executor.memory和spark.local.dir这3个配置项为例我们可以使用下面的方式对它们的值进行设置。
spark.executor.cores 2
spark.executor.memory 4g
spark.local.dir /ssd_fs/large_dir
不过在日常的开发工作中不同应用对于资源的诉求是不一样的有些需要更多的CPU Cores有些则需要更高的并行度凡此种种、不一而足可谓是众口难调这个时候我们只依赖spark-defaults.conf来进行全局设置就不灵了。
为此Spark为开发者提供了两种应用级别的设置方式也即命令行参数和SparkConf对象它们的生效范围仅限于应用本身我们分别看看这两种方式具体怎么用。
先说命令行参数它指的是在运行了spark-shell或是spark-submit命令之后通过conf关键字来设置配置项。我们知道spark-shell用于启动交互式的分布式运行环境而spark-submit则用于向Spark计算集群提交分布式作业。
还是以刚刚的3个配置项为例以命令行参数的方式进行设置的话你需要在提交spark-shell或是spark-submit命令的时候conf Key=Value的形式对参数进行赋值。
spark-shell --master local[*] --conf spark.executor.cores=2 --conf spark.executor.memory=4g --conf spark.local.dir=/ssd_fs/large_dir
不难发现尽管这种方式能让开发者在应用级别灵活地设置配置项但它的书写方式过于繁琐每个配置项都需要以conf作前缀。不仅如此命令行参数的设置方式不利于代码管理随着时间的推移参数值的设置很可能会随着数据量或是集群容量的变化而变化但是这个变化的过程却很难被记录并维护下来而这无疑会增加开发者与运维同学的运维成本。
相比之下不论是隔离性还是可维护性SparkConf对象的设置方式都更胜一筹。在代码开发的过程中我们可以通过定义SparkConf对象并调用其set方法来对配置项进行设置。老规矩还是用刚刚的CPU、内存和磁盘3个配置项来举例。
import org.apache.spark.SparkConf
val conf = new SparkConf()
conf.set("spark.executor.cores", "2")
conf.set("spark.executor.memory", "4g")
conf.set("spark.local.dir", "/ssd_fs/large_dir")
好啦到此为止我们一起梳理了CPU、内存、磁盘的相关配置项并重点强调了CPU、内存与数据之间的供需平衡。掌握了这些设置方法与要点之后你不妨自己动手去试试这些配置项可以拿之前的Word Count小例子练练手巩固一下今天所学的内容。
重点回顾
今天这一讲我们分别从CPU、内存和磁盘三个方面讲解了影响应用程序稳定性的几个重要参数。你需要掌握它们的含义、作用还有适用场景为了方便你记忆我把它们整理到后面的表格里你可以随时拿来参考。
熟悉了这些关键配置项之后你还需要了解它们的设置方式。Spark提供了3种配置项设置途径分别是spark-defaults.conf配置文件、命令行参数和SparkConf对象。其中第一种方式用于全局设置而后两者的适用范围是应用本身。
对于这3种方式Spark会按照“SparkConf对象 -> 命令行参数 -> 配置文件”的顺序依次读取配置项的参数值。对于重复设置的配置项Spark以前面的参数取值为准。
每课一练
请你粗略地过一遍Spark官网中的 Configuration页面说一说其中哪些配置项适合在spark-defaults.conf中进行设置而哪些配置项使用SparkConf对象的方式来设置比较好
欢迎你在留言区跟我交流。如果这一讲对你有帮助的话,也推荐你把这节课分享给有需要的的同事、朋友,我们下一讲见。

View File

@ -0,0 +1,222 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
13 Spark SQL让我们从“小汽车摇号分析”开始
你好,我是吴磊。
在开篇词我们提出“入门Spark需要三步走”到目前为止我们携手并肩跨越了前面两步首先恭喜你学到这里熟练掌握了Spark常用算子与核心原理以后你已经可以轻松应对大部分数据处理需求了。
不过数据处理毕竟是比较基础的数据应用场景就像赛车有着不同的驾驶场景想成为Spark的资深赛车手我们还要走出第三步——学习Spark计算子框架。只有完成这一步我们才能掌握Spark SQLStructured Streaming和Spark MLlib的常规开发方法游刃有余地应对不同的数据应用场景如数据分析、流计算和机器学习等等。
那这么多子框架从哪里入手比较好呢在所有的子框架中Spark SQL是代码量最多、Spark社区投入最大、应用范围最广、影响力最深远的那个。就子框架的学习来说我们自然要从Spark SQL开始。
今天我们从一个例子入手在实战中带你熟悉数据分析开发的思路和实现步骤。有了对Spark SQL的直观体验我们后面几讲还会深入探讨Spark SQL的用法、特性与优势让你逐步掌握Spark SQL的全貌。
业务需求
今天我们要讲的小例子来自于北京市小汽车摇号。我们知道为了限制机动车保有量从2011年开始北京市政府推出了小汽车摇号政策。随着摇号进程的推进在2016年为了照顾那些长时间没有摇中号码牌的“准司机”摇号政策又推出了“倍率”制度。
所谓倍率制度,它指的是,结合参与摇号次数,为每个人赋予不同的倍率系数。有了倍率加持,大家的中签率就由原来整齐划一的基础概率,变为“基础概率 * 倍率系数”。参与摇号的次数越多,倍率系数越大,中签率也会相应得到提高。
不过身边无数的“准司机”总是跟我说其实倍率这玩意没什么用背了8倍、10倍的倍率照样摇不上那么今天这一讲咱们就来借着学习Spark SQL的机会用数据来为这些还没摸过车的“老司机”答疑解惑帮他们定量地分析一下倍率与中签率之间到底有没有关系
准备工作
巧妇难为无米之炊既然是做数据分析那咱们得先有数据才行。我这边为你准备了2011年到2019年北京市小汽车的摇号数据你可以通过这个地址从网盘进行下载提取码为ajs6。
这份数据的文件名是“2011-2019 小汽车摇号数据.tar.gz”解压之后的目录结构如下图所示。
可以看到根目录下有apply和lucky两个子目录apply目录的内容是 2011-2019 年各个批次参与摇号的申请号码而lucky目录包含的是各个批次中签的申请号码。为了叙述方便我们把参与过摇号的人叫“申请者”把中签的人叫“中签者”。apply和lucky的下一级子目录是各个摇号批次而摇号批次目录下包含的是Parquet格式的数据文件。
数据下载、解压完成之后,接下来,我们再来准备运行环境。
咱们的小例子比较轻量Scala版本的代码实现不会超过20行再者摇号数据体量很小解压之后的Parquet文件总大小也不超过4G。
选择这样的例子也是为了轻装上阵避免你因为硬件限制而难以实验。想要把用于分析倍率的应用跑起来你在笔记本或是PC上通过启动本地spark-shell环境就可以。不过如果条件允许的话我还是鼓励你搭建分布式的物理集群。关于分布式集群的搭建细节你可以参考[第4讲]。
好啦,准备好数据与运行环境之后,接下来,我们就可以步入正题,去开发探索倍率与中签率关系的数据分析应用啦。
数据探索
不过先别忙着直接上手数据分析。在此之前我们先要对数据模式Data Schema有最基本的认知也就是源数据都有哪些字段这些字段的类型和含义分别是什么这一步就是我们常说的数据探索。
数据探索的思路是这样的首先我们使用SparkSession的read API读取源数据、创建DataFrame。然后通过调用DataFrame的show方法我们就可以轻松获取源数据的样本数据从而完成数据的初步探索代码如下所示。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 数据打印
applyNumbersDF.show
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 数据打印
luckyDogsDF.show
看到这里想必你已经眉头紧锁“SparkSessionDataFrame这些都是什么鬼你好像压根儿也没有提到过这些概念呀”别着急对于这些关键概念我们在后续的课程中都会陆续展开今天这一讲咱们先来“知其然”“知其所以然”的部分咱们放到后面去讲。
对于SparkSession你可以把它理解为是SparkContext的进阶版是Spark2.0版本以后新一代的开发入口。SparkContext通过textFile API把源数据转换为RDD而SparkSession通过read API把源数据转换为DataFrame。
而DataFrame你可以把它看作是一种特殊的RDD。RDD我们已经很熟悉了现在就把DataFrame跟RDD做个对比让你先对DataFrame有个感性认识。
先从功能分析与RDD一样DataFrame也用来封装分布式数据集它也有数据分区的概念也是通过算子来实现不同DataFrame之间的转换只不过DataFrame采用了一套与RDD算子不同的独立算子集。
再者在数据内容方面与RDD不同DataFrame是一种带Schema的分布式数据集因此你可以简单地把DataFrame看作是数据库中的一张二维表。
最后DataFrame背后的计算引擎是Spark SQL而RDD的计算引擎是Spark Core这一点至关重要。不过关于计算引擎之间的差异我们留到[下一讲]再去展开。
好啦言归正传。简单了解了SparkSession与DataFrame的概念之后我们继续来看数据探索。
把上述代码丢进spark-shell之后分别在applyNumbersDF和luckyDogsDF这两个DataFrame之上调用show函数我们就可以得到样本数据。可以看到“这两张表”的Schema是一样的它们都包含两个字段一个是String类型的carNum另一个是类型为Int的batchNum。
其中carNum的含义是申请号码、或是中签号码而batchNum则代表摇号批次比如201906表示2019年的最后一批摇号201401表示2014年的第一次摇号。
好啦,进行到这里,初步的数据探索工作就告一段落了。
业务需求实现
完成初步的数据探索之后我们就可以结合数据特点比如两张表的Schema完全一致但数据内容的范畴不同来实现最开始的业务需求计算中签率与倍率之间的量化关系。
首先既然是要量化中签率与倍率之间的关系我们只需要关注那些中签者lucky目录下的数据的倍率变化就好了。而倍率的计算要依赖apply目录下的摇号数据。因此要做到仅关注中签者的倍率我们就必须要使用数据关联这个在数据分析领域中最常见的操作。此外由于倍率制度自2016年才开始推出所以我们只需要访问2016年以后的数据即可。
基于以上这些分析,我们先把数据过滤与数据关联的代码写出来,如下所示。
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
在上面的代码中我们使用filter算子对luckyDogsDF做过滤然后使用select算子提取carNum字段。
紧接着我们在applyNumbersDF之上调用join算子从而完成两个DataFrame的数据关联。join算子有3个参数你可以对照前面代码的第5行来理解这里第一个参数用于指定需要关联的DataFrame第二个参数代表Join Key也就是依据哪些字段做关联而第三个参数指定的是关联形式比如inner表示内关联left表示左关联等等。
做完数据关联之后,接下来,我们再来说一说,倍率应该怎么统计。对于倍率这个数值,官方的实现略显粗暴,如果去观察 apply 目录下 2016 年以后各个批次的文件,你就会发现,所谓的倍率,实际上就是申请号码的副本数量。
比如说我的倍率是8那么在各个批次的摇号文件中我的申请号码就会出现8次。是不是很粗暴因此要统计某个申请号码的倍率我们只需要统计它在批次文件中出现的次数就可以达到目的。
按照批次、申请号码做统计计数是不是有种熟悉的感觉没错这不就是我们之前学过的Word Count吗它本质上其实就是一个分组计数的过程。不过这一次咱们不再使用reduceByKey这个RDD算子了而是使用DataFrame的那套算子来实现我们先来看代码。
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
分组计数
对照代码我给你分析下思路我们先是用groupBy算子来按照摇号批次和申请号码做分组然后通过agg和count算子把batchNumcarNum出现的次数作为carNum在摇号批次batchNum中的倍率并使用alias算子把倍率重命名为“multiplier”。
这么说可能有点绕我们可以通过在multipliers之上调用show函数来直观地观察这一步的计算结果。为了方便说明我用表格的形式来进行示意。
可以看到,同一个申请号码,在不同批次中的倍率是不一样的。就像我们之前说的,随着摇号的次数增加,倍率也会跟着提升。不过,这里咱们要研究的是倍率与中签率的关系,所以只需要关心中签者是在多大的倍率下中签的就行。因此,对于同一个申请号码,我们只需要保留其中最大的倍率就可以了。
需要说明的是取最大倍率的做法会把倍率的统计基数变小从而引入幸存者偏差。更严谨的做法应该把中签者过往的倍率也都统计在内这样倍率的基数才是准确的。不过呢结合实验幸存者偏差并不影响“倍率与中签率是否有直接关系”这一结论。因此咱们不妨采用取最大倍率这种更加简便的做法。毕竟学习Spark SQL才是咱们的首要目标。
为此我们需要“抹去”batchNum这个维度按照carNum对multipliers做分组并提取倍率的最大值代码如下所示。
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
分组聚合的方法跟前面差不多我们还是先用groupBy做分组不过这次仅用carNum一个字段做分组然后使用agg和max算子来保留倍率最大值。经过这一步的计算之后我们就得到了每个申请号码在中签之前的倍率系数
可以看到uniqueMultipliers这个DataFrame仅包含申请号码carNum和倍率multiplier这两个字段且carNum字段不存在重复值也就是说在这份数据集中一个申请号码只有一个最大倍率与之对应。
好啦,到此为止,我们拿到了每一个中签者,在中签之前的倍率系数。接下来,结合这份数据,我们就可以统计倍率本身的分布情况。
具体来说我们想知道的是不同倍率之下的人数分布是什么样子的。换句话说这一次我们要按照倍率来对数据做分组然后计算不同倍率下的统计计数。不用说这次咱们还是得仰仗groupBy和agg这两个算子代码如下所示。
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
在最后一步我们依然使用groupBy和agg算子如法炮制得到按照倍率统计的人数分布之后我们通过collect算子来收集计算结果并同时触发上述的所有代码从头至尾交付执行。
计算结果result包含两个字段一个是倍率一个是持有该倍率的统计人数。如果把result结果数据做成柱状图的话我们可以更加直观地观察到中签率与倍率之间的关系如下图所示。
不难发现,不同倍率下的中签者人数,呈现出正态分布。也即是说,对于一个申请者来说,他/她有幸摇中的概率,并不会随着倍率的增加而线性增长。用身边那些“老司机”的话说,中签这件事,确实跟倍率的关系不大。
重点回顾
今天这一讲,我们一起动手,开发了“倍率的统计分布”这个数据分析应用,并解答了中签率与倍率之间是否存在关联关系这一难题。
尽管在实现的过程中我们遇到了一些新概念和新的算子但你不必担心更不必着急。今天这节课你只需要对Spark SQL框架下的应用开发有一个感性的认识就可以了。
在Spark SQL的开发框架下我们通常是通过SparkSession的read API从源数据创建DataFrame。然后以DataFrame为入口在DataFrame之上调用各式各样的转换算子如agg、groupBy、select、filter等等对DataFrame进行转换进而完成相应的数据分析。
为了后续试验方便我把今天涉及的代码片段整理到了一起你可以把它们丢进spark-shell去运行观察每个环节的计算结果体会不同算子的计算逻辑与执行结果之间的关系。加油祝你好运
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
// 以batchNum、carNum做分组统计倍率系数
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
// 以carNum做分组保留最大的倍率系数
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
// 以multiplier倍率做分组统计人数
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
每课一练
脑洞时间:你觉得汽车摇号的倍率制度应该怎样设计,才是最合理的?
请在你的Spark环境中把代码运行起来并确认执行结果是否与result一致。
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的朋友、同事。我们下一讲见!

View File

@ -0,0 +1,191 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
14 台前幕后DataFrame与Spark SQL的由来
你好,我是吴磊。
在上一讲结合“小汽车倍率分析”的例子我们学习了在Spark SQL子框架下做应用开发的一般模式。我们先是使用SparkSession的read API来创建DataFrame然后以DataFrame为入口通过调用各式各样的算子来完成不同DataFrame之间的转换从而进行数据分析。
尽管我们说过你可以把DataFrame看作是一种特殊的RDD但你可能仍然困惑DataFrame到底跟RDD有什么本质区别。Spark已经有了RDD这个开发入口为什么还要重复造轮子整出个DataFrame来呢
相信学完了上一讲这些问题一定萦绕在你的脑海里挥之不去。别着急今天我们就来高屋建瓴地梳理一下DataFrame的来龙去脉然后再追本溯源看看帮助DataFrame崭露头角的幕后大佬Spark SQL又是怎么回事儿。
RDD之殇优化空间受限
在RDD算子那一讲[第3讲]我们曾经留过一道思考题像map、mapPartitions、filter、flatMap这些算子它们之间都有哪些共性
今天我们从一个全新的视角来重新审视这个问题。先说结论它们都是高阶函数Higher-order Functions
所谓高阶函数,它指的是形参为函数的函数,或是返回类型为函数的函数。换句话说,高阶函数,首先本质上也是函数,特殊的地方在于它的形参和返回类型,这两者之中只要有一个是函数类型,那么原函数就属于高阶函数。
上面提到的这些算子如map、filter它们都需要一个辅助函数f来作为形参通过调用map(f)、filter(f)才能完成计算。以map为例我们需要函数f来明确对哪些字段做映射以什么规则映射。filter也一样我们需要函数f来指明以什么条件在哪些字段上过滤。
但是这样一来Spark只知道开发者要做map、filter但并不知道开发者打算怎么做map和filter。换句话说对于Spark来说辅助函数f是透明的。在RDD的开发框架下Spark Core只知道开发者要“做什么”而不知道“怎么做”。这让Spark Core两眼一抹黑除了把函数f以闭包的形式打发到Executors以外实在是没有什么额外的优化空间。而这就是RDD之殇。
DataFrame横空出世
针对RDD优化空间受限的问题Spark社区在1.3版本发布了DataFrame。那么相比RDDDataFrame到底有何不同呢我们不妨从两个方面来对比它们的不同一个是数据的表示形式Data Representation另一个是开发算子。
DataFrame与RDD一样都是用来封装分布式数据集的。但在数据表示方面就不一样了DataFrame是携带数据模式Data Schema的结构化数据而RDD是不携带Schema的分布式数据集。恰恰是因为有了Schema提供明确的类型信息Spark才能耳聪目明有针对性地设计出更紧凑的数据结构从而大幅度提升数据存储与访问效率。
在开发API方面RDD算子多采用高阶函数高阶函数的优势在于表达能力强它允许开发者灵活地设计并实现业务逻辑。而DataFrame的表达能力却很弱它定义了一套DSL算子Domain Specific Language如我们上一节课用到的select、filter、agg、groupBy等等它们都属于DSL算子。
DSL语言往往是为了解决某一类特定任务而设计非图灵完备因此在表达能力方面非常有限。DataFrame的算子大多数都是标量函数Scalar Functions它们的形参往往是结构化二维表的数据列Columns
尽管DataFrame算子在表达能力方面更弱但是DataFrame每一个算子的计算逻辑都是确定的比如select用于提取某些字段groupBy用于对数据做分组等等。这些计算逻辑对Spark来说不再是透明的因此Spark可以基于启发式的规则或策略甚至是动态的运行时信息去优化DataFrame的计算过程。
总结下来相比RDDDataFrame通过携带明确类型信息的Schema、以及计算逻辑明确的转换算子为Spark引擎的内核优化打开了全新的空间。
幕后英雄Spark SQL
那么问题来了优化空间打开之后真正负责优化引擎内核Spark Core的那个幕后英雄是谁相信不用我说你也能猜到它就是Spark SQL。
想要吃透Spark SQL我们先得弄清楚它跟Spark Core的关系。随着学习进程的推进我们接触的新概念、知识点会越来越多厘清Spark SQL与Spark Core的关系有利于你构建系统化的知识体系和全局视角从而让你在学习的过程中“既见树木、也见森林”。
首先Spark Core特指Spark底层执行引擎Execution Engine它包括了我们在基础知识篇讲过的调度系统、存储系统、内存管理、Shuffle管理等核心功能模块。而Spark SQL则凌驾于Spark Core之上是一层独立的优化引擎Optimization Engine。换句话说Spark Core负责执行而Spark SQL负责优化Spark SQL优化过后的代码依然要交付Spark Core来做执行。
再者从开发入口来说在RDD框架下开发的应用程序会直接交付Spark Core运行。而使用DataFrame API开发的应用则会先过一遍Spark SQL由Spark SQL优化过后再交由Spark Core去做执行。
弄清二者的关系与定位之后接下来的问题是“基于DataFrameSpark SQL是如何进行优化的呢”要回答这个问题我们必须要从Spark SQL的两个核心组件说起Catalyst优化器和Tungsten。
先说Catalyst优化器它的职责在于创建并优化执行计划它包含3个功能模块分别是创建语法树并生成执行计划、逻辑阶段优化和物理阶段优化。Tungsten用于衔接Catalyst执行计划与底层的Spark Core执行引擎它主要负责优化数据结果与可执行代码。
接下来我们结合上一讲“倍率分析”的例子来说一说那段代码在Spark SQL这一层是如何被优化的。我把“倍率分析”完整的代码实现贴在了这里你不妨先简单回顾一下。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
// 以batchNum、carNum做分组统计倍率系数
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
// 以carNum做分组保留最大的倍率系数
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
// 以multiplier倍率做分组统计人数
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
Catalyst优化器
首先我们先来说说Catalyst的优化过程。基于代码中DataFrame之间确切的转换逻辑Catalyst会先使用第三方的SQL解析器ANTLR生成抽象语法树ASTAbstract Syntax Tree。AST由节点和边这两个基本元素构成其中节点就是各式各样的操作算子如select、filter、agg等而边则记录了数据表的Schema信息如字段名、字段类型等等。
以下图“倍率分析”的语法树为例它实际上描述了从源数据到最终计算结果之间的转换过程。因此在Spark SQL的范畴内AST语法树又叫作“执行计划”Execution Plan
可以看到由算子构成的语法树、或者说执行计划给出了明确的执行步骤。即使不经过任何优化Spark Core也能把这个“原始的”执行计划按部就班地运行起来。
不过从执行效率的角度出发这么做并不是最优的选择。为什么这么说呢我们以图中绿色的节点为例Scan用于全量扫描并读取中签者数据Filter则用来过滤出摇号批次大于等于“201601”的数据Select节点的作用则是抽取数据中的“carNum”字段。
还记得吗我们的源文件是以Parquet格式进行存储的而Parquet格式在文件层面支持“谓词下推”Predicates Pushdown和“列剪枝”Columns Pruning这两项特性。
谓词下推指的是利用像“batchNum >= 201601”这样的过滤条件在扫描文件的过程中只读取那些满足条件的数据文件。又因为Parquet格式属于列存Columns Store数据结构因此Spark只需读取字段名为“carNum”的数据文件而“剪掉”读取其他数据文件的过程。
以中签数据为例在谓词下推和列剪枝的帮助下Spark Core只需要扫描图中绿色的文件部分。显然这两项优化都可以有效帮助Spark Core大幅削减数据扫描量、降低磁盘I/O消耗从而显著提升数据的读取效率。
因此如果能把3个绿色节点的执行顺序从“Scan > Filter > Select”调整为“Filter > Select > Scan”那么相比原始的执行计划调整后的执行计划能给Spark Core带来更好的执行性能。
像谓词下推、列剪枝这样的特性都被称为启发式的规则或策略。而Catalyst优化器的核心职责之一就是在逻辑优化阶段基于启发式的规则和策略调整、优化执行计划为物理优化阶段提升性能奠定基础。经过逻辑阶段的优化之后原始的执行计划调整为下图所示的样子请注意绿色节点的顺序变化。
经过逻辑阶段优化的执行计划依然可以直接交付Spark Core去运行不过在性能优化方面Catalyst并未止步于此。
除了逻辑阶段的优化Catalyst在物理优化阶段还会进一步优化执行计划。与逻辑阶段主要依赖先验的启发式经验不同物理阶段的优化主要依赖各式各样的统计信息如数据表尺寸、是否启用数据缓存、Shuffle中间文件等等。换句话说逻辑优化更多的是一种“经验主义”而物理优化则是“用数据说话”。
以图中蓝色的Join节点为例执行计划仅交代了applyNumbersDF与filteredLuckyDogs这两张数据表需要做内关联但是它并没有交代清楚这两张表具体采用哪种机制来做关联。按照实现机制来分类数据关联有3种实现方式分别是嵌套循环连接NLJNested Loop Join、排序归并连接Sort Merge Join和哈希连接Hash Join
而按照数据分发方式来分类数据关联又可以分为Shuffle Join和Broadcast Join这两大类。因此在分布式计算环境中至少有6种Join策略供Spark SQL来选择。对于这6种Join策略我们以后再详细展开这里你只需要了解不同策略在执行效率上有着天壤之别即可。
回到蓝色Join节点的例子在物理优化阶段Catalyst优化器需要结合applyNumbersDF与filteredLuckyDogs这两张表的存储大小来决定是采用运行稳定但性能略差的Shuffle Sort Merge Join还是采用执行性能更佳的Broadcast Hash Join。
不论Catalyst决定采用哪种Join策略优化过后的执行计划都可以丢给Spark Core去做执行。不过Spark SQL优化引擎并没有就此打住当Catalyst优化器完成它的“历史使命”之后Tungsten会接过接力棒在Catalyst输出的执行计划之上继续打磨、精益求精力求把最优的执行代码交付给底层的SparkCore执行引擎。
Tungsten
站在Catalyst这个巨人的肩膀上Tungsten主要是在数据结构和执行代码这两个方面做进一步的优化。数据结构优化指的是Unsafe Row的设计与实现执行代码优化则指的是全阶段代码生成WSCGWhole Stage Code Generation
我们先来看看为什么要有Unsafe Row。对于DataFrame中的每一条数据记录Spark SQL默认采用org.apache.spark.sql.Row对象来进行封装和存储。我们知道使用Java Object来存储数据会引入大量额外的存储开销。
为此Tungsten设计并实现了一种叫做Unsafe Row的二进制数据结构。Unsafe Row本质上是字节数组它以极其紧凑的格式来存储DataFrame的每一条数据记录大幅削减存储开销从而提升数据的存储与访问效率。
以下表的Data Schema为例对于包含如下4个字段的每一条数据记录来说如果采用默认的Row对象进行存储的话那么每条记录需要消耗至少60个字节。
但如果用Tungsten Unsafe Row数据结构进行存储的话每条数据记录仅需消耗十几个字节如下图所示。
说完了Unsafe Row的数据结构优化接下来我们再来说说WSCG全阶段代码生成。所谓全阶段其实就是我们在调度系统中学过的Stage。以图中的执行计划为例标记为绿色的3个节点在任务调度的时候会被划分到同一个Stage。
而代码生成指的是Tungsten在运行时把算子之间的“链式调用”捏合为一份代码。以上图3个绿色的节点为例在默认情况下Spark Core会对每一条数据记录都依次执行Filter、Select和Scan这3个操作。
经过了Tungsten的WSCG优化之后Filter、Select和Scan这3个算子会被“捏合”为一个函数f。这样一来Spark Core只需要使用函数f来一次性地处理每一条数据就能消除不同算子之间数据通信的开销一气呵成地完成计算。
好啦到此为止分别完成Catalyst和Tungsten这两个优化环节之后Spark SQL终于“心满意足”地把优化过的执行计划、以及生成的执行代码交付给老大哥Spark Core。Spark Core拿到计划和代码在运行时利用Tungsten Unsafe Row的数据结构完成分布式任务计算。到此我们这一讲的内容也就讲完了。
重点回顾
今天这一讲涉及的内容很多,我们一起做个总结。
首先在RDD开发框架下Spark Core的优化空间受限。绝大多数RDD高阶算子所封装的封装的计算逻辑形参函数f对于Spark Core是透明的Spark Core除了用闭包的方式把函数f分发到Executors以外没什么优化余地。
而DataFrame的出现带来了新思路它携带的Schema提供了丰富的类型信息而且DataFrame算子大多为处理数据列的标量函数。DataFrame的这两个特点为引擎内核的优化打开了全新的空间。在DataFrame的开发框架下负责具体优化过程的正是Spark SQL。
Spark SQL则是凌驾于Spark Core之上的一层优化引擎它的主要职责是在用户代码交付Spark Core之前对用户代码进行优化。
Spark SQL由两个核心组件构成分别是Catalyst优化器和Tungsten其优化过程也分为Catalyst和Tungsten两个环节。
在Catalyst优化环节Spark SQL首先把用户代码转换为AST语法树又叫执行计划然后分别通过逻辑优化和物理优化来调整执行计划。逻辑阶段的优化主要通过先验的启发式经验如谓词下推、列剪枝对执行计划做优化调整。而物理阶段的优化更多是利用统计信息选择最佳的执行机制、或添加必要的计算节点。
Tungsten主要从数据结构和执行代码两个方面进一步优化。与默认的Java Object相比二进制的Unsafe Row以更加紧凑的方式来存储数据记录大幅提升了数据的存储与访问效率。全阶段代码生成消除了同一Stage内部不同算子之间的数据传递把多个算子融合为一个统一的函数并将这个函数一次性地作用Apply到数据之上相比不同算子的“链式调用”这会显著提升计算效率。
每课一练
学完这一讲之后我们知道只有DataFrame才能“享受”到Spark SQL的优化过程而RDD只能直接交付Spark Core执行。那么这是不是意味着RDD开发框架会退出历史舞台而我们之前学过的与RDD有关的知识点如RDD概念、RDD属性、RDD算子都白学了呢
欢迎你在留言区和我交流讨论,也推荐你把这一讲的内容分享给更多朋友。

View File

@ -0,0 +1,363 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
15 数据源与数据格式DataFrame从何而来
你好,我是吴磊。
在上一讲我们重点讲解了DataFrame与Spark SQL的渊源并提到DataFrame是Spark SQL的重要入口。换句话说通过创建DataFrame并沿用DataFrame开发API我们才能充分利用Spark SQL优化引擎提供种种“性能红利”。显然对于初学者来说第一步的创建DataFrame就变得至关重要。
之前 [第13讲]我们做小汽车摇号倍率分析时用了SparkSession的read API从Parquet文件创建DataFrame其实创建DataFrame的方法还有很多。毫不夸张地说DataFrame的创建途径异常丰富为什么这么说呢
如下图所示Spark支持多种数据源按照数据来源进行划分这些数据源可以分为如下几个大类Driver端自定义的数据结构、分布式文件系统、关系型数据库RDBMS、关系型数据仓库、NoSQL数据库以及其他的计算引擎。
显然要深入地介绍Spark与每一种数据源的集成并不现实也没必要咱们只需要把注意力放在那些最常用、最常见的集成方式即可。
这一讲我会从Driver、文件系统与RDBMS三个方面为你讲解5种常见的DataFrame创建方式然后带你了解不同方式的使用场景跟优劣分析。
从Driver创建DataFrame
在Driver端Spark可以直接从数组、元组、映射等数据结构创建DataFrame。使用这种方式创建的DataFrame通常数据量有限因此这样的DataFrame往往不直接参与分布式计算而是用于辅助计算或是数据探索。尽管如此学习这部分知识点还是非常必要的因为它可以帮我们更直观地理解DataFrame与RDD的关系。
还记得吗在数据表示Data Representation相比RDDDataFrame仅仅是多了一个Schema。甚至可以说DataFrame就是带Schema的RDD。因此创建DataFrame的第一种方法就是先创建RDD然后再给它“扣上”一顶Schema的“帽子”。
从本地数据结构创建RDD我们用的是SparkContext的parallelize方法而给RDD“扣帽子”我们要用到SparkSession的createDataFrame方法。
createDataFrame方法
为了创建RDD我们先来定义列表数据seq。seq的每个元素都是二元元组元组第一个元素的类型是String第二个元素的类型是Int。有了列表数据结构接下来我们创建RDD如下所示。
import org.apache.spark.rdd.RDD
val seq: Seq[(String, Int)] = Seq(("Bob", 14), ("Alice", 18))
val rdd: RDD[(String, Int)] = sc.parallelize(seq)
有了RDD之后我们来给它制作一顶“帽子”也就是我们刚刚说的Schema。创建Schema我们需要用到Spark SQL内置的几种类型如StructType、StructField、StringType、IntegerType等等。
其中StructType用于定义并封装SchemaStructFiled用于定义Schema中的每一个字段包括字段名、字段类型而像StringType、IntegerType这些*Type类型表示的正是字段类型。为了和RDD数据类型保持一致Schema对应的元素类型应该是StringTypeIntegerType
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
val schema:StructType = StructType( Array(
StructField("name", StringType),
StructField("age", IntegerType)
))
好啦到此为止我们有了RDD也有了为它量身定做的“帽子”Schema。不过在把帽子扣上去之前我们还要先给RDD整理下“发型”。这是什么意思呢
createDataFrame方法有两个形参第一个参数正是RDD第二个参数是Schema。createDataFrame要求RDD的类型必须是RDD[Row]其中的Row是org.apache.spark.sql.Row因此对于类型为RDD[(String, Int)]的rdd我们需要把它转换为RDD[Row]。
import org.apache.spark.sql.Row
val rowRDD: RDD[Row] = rdd.map(fileds => Row(fileds._1, fileds._2))
“发型”整理好之后我们就可以调用createDataFrame来创建DataFrame代码如下所示。
import org.apache.spark.sql.DataFrame
val dataFrame: DataFrame = spark.createDataFrame(rowRDD,schema)
DataFrame创建好之后别忘了验证它的可用性我们可以通过调用show方法来做简单的数据探索验证DataFrame创建是否成功。
dataFrame.show
/** 结果显示
+----+---+
| name| age|
+----+---+
| Bob| 14|
| Alice| 18|
+----+---+
*/
历尽千辛万苦我们先是用Driver端数据结构创建RDD然后再调用createDataFrame把RDD转化为DataFrame。你可能会说“相比用parallelize创建RDD用createDataFrame创建DataFrame的方法未免复杂了些有没有更简便的方法呢”我们接着往下看。
toDF方法
其实要把RDD转化为DataFrame我们并不一定非要亲自制作Schema这顶帽子还可以直接在RDD之后调用toDF方法来做到这一点。咱们先来看toDF函数的用法然后再去分析spark.implicits是如何帮我们轻松创建DataFrame的。
import spark.implicits._
val dataFrame: DataFrame = rdd.toDF
dataFrame.printSchema
/** Schema显示
root
|-- _1: string (nullable = true)
|-- _2: integer (nullable = false)
*/
可以看到我们显示导入了spark.implicits包中的所有方法然后通过在RDD之上调用toDF就能轻松创建DataFrame。实际上利用spark.implicits我们甚至可以跳过创建RDD这一步直接通过seq列表来创建DataFrame。
import spark.implicits._
val dataFrame: DataFrame = seq.toDF
dataFrame.printSchema
/** Schema显示
root
|-- _1: string (nullable = true)
|-- _2: integer (nullable = false)
*/
是不是感觉这个方法很简洁、很轻松不过你可能会问“既然有toDF这条捷径一开始干嘛还要花功夫去学步骤繁琐的createDataFrame方法呢
网络上流行过这么一句话“你的岁月静好是有人在背后帮你负重前行。”toDF也是同样的道理我们之所以能用toDF轻松创建DataFrame关键在于spark.implicits这个包提供了各种隐式方法。
隐式方法是Scala语言中一类特殊的函数这类函数不需要开发者显示调用函数体中的计算逻辑在适当的时候会自动触发。正是它们在背后默默地帮我们用seq创建出RDD再用createDataFrame方法把RDD转化为DataFrame。
从文件系统创建DataFrame
说完第一类数据源接下来我们再来看看Spark如何从文件系统创建DataFrame。
Spark支持多种文件系统常见的有HDFS、Amazon S3、本地文件系统等等。不过无论哪种文件系统Spark都要通过SparkSession的read API来读取数据并创建DataFrame。所以接下来我们需要先弄明白read API要怎样使用都有哪些注意事项。
read API由SparkSession提供它允许开发者以统一的形式来创建DataFrame如下图所示。
可以看到要使用read API创建DataFrame开发者只需要调用SparkSession的read方法同时提供3类参数即可。这3类参数分别是文件格式、加载选项和文件路径它们分别由函数format、option和load来指定。
先来看第1类参数文件格式它就是文件的存储格式如CSVComma Separated Values、Text、Parquet、ORC、JSON。Spark SQL支持种类丰富的文件格式除了这里列出的几个例子外Spark SQL还支持像Zip压缩文件、甚至是图片Image格式。
完整的格式支持你可以参考下图或是访问官网给出的列表。在后续的讲解中我们还会挑选一些常用的数据格式来演示read API的具体用法。
文件格式决定了第2类参数加载选项的可选集合也就是说不同的数据格式可用的选型有所不同。比如CSV文件格式可以通过option(“header”, true)来表明CSV文件的首行为Data Schema但其他文件格式就没有这个选型。之后讲到常见文件格式用法时我们再对其加载选项做具体讲解。
值得一提的是加载选项可以有零个或是多个当需要指定多个选项时我们可以用“option(选项1, 值1).option(选项2, 值2)”的方式来实现。
read API的第3类参数是文件路径这个参数很好理解它就是文件系统上的文件定位符。比如本地文件系统中的“/dataSources/wikiOfSpark.txt”HDFS分布式文件系统中的“hdfs://hostname:port/myFiles/userProfiles.csv”或是Amazon S3上的“s3://myBucket/myProject/myFiles/results.parquet”等等。
了解了read API的一般用法之后接下来我们结合一些常见的数据格式来进行举例说明。对于那些在这节课没有展开介绍的文件格式你可以参考官网给出的用法来做开发。
从CSV创建DataFrame
以可读性好的纯文本方式来存储结构化数据CSV文件格式的身影常见于数据探索、数据分析、机器学习等应用场景。经过上面的分析我们知道要从CSV文件成功地创建DataFrame关键在于了解并熟悉与之有关的加载选项。那么我们就来看看CSV格式都有哪些对应的option它们的含义都是什么。
从上往下看首先是“header”header的设置值为布尔值也即true或false它用于指定CSV文件的首行是否为列名。如果是的话那么Spark SQL将使用首行的列名来创建DataFrame否则使用“_c”加序号的方式来命名每一个数据列比如“_c0”、“_c1”等等。
对于加载的每一列数据不论数据列本身的含义是什么Spark SQL都会将其视为String类型。例如对于后面这个CSV文件Spark SQL将“name”和“age”两个字段都视为String类型。
name,age
alice,18
bob,14
import org.apache.spark.sql.DataFrame
val csvFilePath: String = _
val df: DataFrame = spark.read.format("csv").option("header", true).load(csvFilePath)
// df: org.apache.spark.sql.DataFrame = [name: string, age: string]
df.show
/** 结果打印
+-----+---+
| name| age|
+-----+---+
| alice| 18|
| bob| 14|
+-----+---+
*/
要想在加载的过程中为DataFrame的每一列指定数据类型我们需要显式地定义Data Schema并在read API中通过调用schema方法来将Schema传递给Spark SQL。Data Schema的定义我们讲createDataFrame函数的时候提过咱们不妨一起来回顾一下。
定义Schema
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
val schema:StructType = StructType( Array(
StructField("name", StringType),
StructField("age", IntegerType)
))
调用schema方法来传递Data Schema
val csvFilePath: String = _
val df: DataFrame = spark.read.format("csv").schema(schema).option("header", true).load(csvFilePath)
// df: org.apache.spark.sql.DataFrame = [name: string, age: int]
可以看到在使用schema方法明确了Data Schema以后数据加载完成之后创建的DataFrame类型由原来的“[name: string, age: string]”,变为“[name: string, age: int]”。需要注意的是并不是所有文件格式都需要schema方法来指定Data Schema因此在read API的一般用法中schema方法并不是必需环节。
我们接着说CSV格式的option选项。在“header”之后第二个选项是“seq”它是用于分隔列数据的分隔符可以是任意字符串默认值是逗号。常见的分隔符还有Tab、“|”,等等。
之后的“escape”和“nullValue”分别用于指定文件中的转义字符和空值而“dateFormat”则用于指定日期格式它的设置值是任意可以转换为Java SimpleDateFormat类型的字符串默认值是“yyyy-MM-dd”。
最后一个选项是“mode”它用来指定文件的读取模式更准确地说它明确了Spark SQL应该如何对待CSV文件中的“脏数据”。
所谓脏数据它指的是数据值与预期数据类型不符的数据记录。比如说CSV文件中有一列名为“age”数据它用于记录用户年龄数据类型为整型Int。那么显然age列数据不能出现像“8.5”这样的小数、或是像“8岁”这样的字符串这里的“8.5”或是“8岁”就是我们常说的脏数据。
在不调用schema方法来显示指定Data Schema的情况下Spark SQL将所有数据列都看作是String类型。我们不难发现mode选项的使用往往会与schema方法的调用如影随形。
mode支持3个取值分别是permissive、dropMalformed和failFast它们的含义如下表所示。
可以看到除了“failFast”模式以外另外两个模式都不影响DataFrame的创建。以下面的CSV文件为例要想剔除脏数据也就是“cassie, six”这条记录同时正常加载满足类型要求的“干净”数据我们需要同时结合schema方法与mode选项来实现。
CSV文件内容
name,age
alice,18
bob,14
cassie, six
调用schema方法来传递Data Schema
val csvFilePath: String = _
val df: DataFrame = spark.read.format("csv")
.schema(schema)
.option("header", true)
.option("mode", "dropMalformed")
.load(csvFilePath)
// df: org.apache.spark.sql.DataFrame = [name: string, age: int]
df.show
/** 结果打印
+-----+---+
| name| age|
+-----+---+
| alice| 18|
| bob| 14|
+-----+---+
*/
好啦关于从CSV文件创建DataFrame我们就讲完了。不难发现从CSV创建DataFrame过程相对比较繁琐开发者需要注意的细节也很多。不过毕竟CSV简单直接、跨平台、可读性好、应用广泛因此掌握这部分开发技巧还是非常值得的。
从Parquet / ORC创建DataFrame
接下来我们就来说说Parquet格式和ORC格式相比从CSV创建DataFrame这两个方法就没那么麻烦了。
Parquet与ORC都是应用广泛的列存Column-based Store文件格式。顾名思义列存是相对行存Row-based Store而言的。
在传统的行存文件格式中,数据记录以行为单位进行存储。虽然这非常符合人类的直觉,但在数据的检索与扫描方面,行存数据往往效率低下。例如,在数据探索、数据分析等数仓应用场景中,我们往往仅需扫描数据记录的某些字段,但在行存模式下,我们必须要扫描全量数据,才能完成字段的过滤。
CSV就是典型的行存数据格式以如下的内容为例如果我们想要统计文件中女生的数量那么我们不得不扫描每一行数据判断gender的取值然后才能决定是否让当前记录参与计数。
CSV文件内容
name,age,gender
alice,18,female
bob,14,male
列存文件则不同,它以列为单位,对数据进行存储,每一列都有单独的文件或是文件块。还是以上面的文件内容为例,如果采用列存格式的话,那么文件的存储方式将会变成下面的样子。
可以看到数据按列存储想要统计女生的数量我们只需扫描gender列的数据文件而不必扫描name与age字段的数据文件。相比行存列存有利于大幅削减数据扫描所需的文件数量。
不仅如此对于每一个列存文件或是文件块列存格式往往会附加header和footer等数据结构来记录列数据的统计信息比如最大值、最小值、记录统计个数等等。这些统计信息会进一步帮助提升数据访问效率例如对于max=“male”同时min=“male”的gender文件来说在统计女生计数的时候我们完全可以把这样的文件跳过不进行扫描。
再者很多列存格式往往在文件中记录Data Schema比如Parquet和ORC它们会利用Meta Data数据结构来记录所存储数据的数据模式。这样一来在读取类似列存文件时我们无需再像读取CSV一样去手工指定Data Schema这些繁琐的步骤都可以省去。因此使用read API来读取Parquet或是ORC文件就会变得非常轻松如下所示。
使用read API读取Parquet文件
val parquetFilePath: String = _
val df: DataFrame = spark.read.format("parquet").load(parquetFilePath)
使用read API读取ORC文件
val orcFilePath: String = _
val df: DataFrame = spark.read.format("orc").load(orcFilePath)
可以看到在read API的用法中我们甚至不需要指定任何option只要有format和load这两个必需环节即可。是不是非常简单
好啦到此为止我们梳理了如何从文件系统在不同的数据格式下创建DataFrame。在这一讲的最后我们再来简单地了解一下如何从关系型数据库创建DataFrame毕竟这个场景在我们日常的开发中还是蛮常见的。
从RDBMS创建DataFrame
使用read API读取数据库就像是使用命令行连接数据库那么简单。而使用命令行连接数据库我们往往需要通过参数来指定数据库驱动、数据库地址、用户名、密码等关键信息。read API也是一样只不过这些参数通通由option选项来指定以MySQL为例read API的使用方法如下。
使用read API连接数据库并创建DataFrame
spark.read.format("jdbc")
.option("driver", "com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://hostname:port/mysql")
.option("user", "用户名")
.option("password","密码")
.option("numPartitions", 20)
.option("dbtable", "数据表名 ")
.load()
访问数据库我们同样需要format方法来指定“数据源格式”这里的关键字是“jdbc”。请注意由于数据库URL通过option来指定因此调用load方法不再需要传入“文件路径”我们重点来关注option选项的设置。
与命令行一样option选项同样需要driver、url、user、password这些参数来指定数据库连接的常规设置。不过毕竟调用read API的目的是创建DataFrame因此我们还需要指定“dbtable”选项来确定要访问哪个数据表。
有意思的是除了将表名赋值给“dbtable”以外我们还可以把任意的SQL查询语句赋值给该选项这样在数据加载的过程中就能完成数据过滤提升访问效率。例如我们想从users表选出所有的女生数据然后在其上创建DataFrame。
val sqlQuery: String = “select * from users where gender = female
spark.read.format("jdbc")
.option("driver", "com.mysql.jdbc.Driver")
.option("url", "jdbc:mysql://hostname:port/mysql")
.option("user", "用户名")
.option("password","密码")
.option("numPartitions", 20)
.option("dbtable", sqlQuery)
.load()
此外为了提升后续的并行处理效率我们还可以通过“numPartitions”选项来控制DataFrame的并行度也即DataFrame的Partitions数量。
需要额外注意的是在默认情况下Spark安装目录并没有提供与数据库连接有关的任何Jar包因此对于想要访问的数据库不论是MySQL、PostgreSQL还是Oracle、DB2我们都需要把相关Jar包手工拷贝到Spark安装目录下的Jars文件夹。与此同时我们还要在spark-shell命令或是spark-submit中通过如下两个命令行参数来告诉Spark相关Jar包的访问地址。
driver-class-path mysql-connector-java-version.jar
jars mysql-connector-java-version.jar
好啦,到此为止,这一讲的内容就全部讲完啦!今天的内容有点多,我们来一起总结一下。
重点回顾
今天这一讲我们聚焦在DataFrame的创建方式上。Spark支持种类丰富的数据源与数据格式我们今天的重点是通过Driver、文件系统和关系型数据库来创建DataFrame。
在Driver端我们可以使用createDataFrame方法来创建DataFrame需要注意的是这种创建方式有两个前提条件。一是底层RDD的类型必须是RDD[Row]二是我们需要手工创建Data Schema。Schema的创建需要用到StructType、StructField等数据类型你要牢记在心。
import org.apache.spark.sql.types.{StringType, IntegerType, StructField, StructType}
val schema:StructType = StructType( Array(
StructField("name", StringType),
StructField("age", IntegerType)
))
除了这种比较繁琐的方式之外我们还可以利用spark.implicits._提供的隐式方法通过在RDD或是原始序列数据之上调用toDF方法轻松创建DataFrame。
接着使用SparkSession的read API我们分别讲解了从CSV、Parquet、ORC和关系型数据库创建DataFrame的一般方法。read API调用的一般方法需要你熟练掌握。
由于Parquet、ORC这类列存格式在文件中内置了Data Schema因此访问这类文件格式只有format和load两个方法是必需的。
相比之下读取CSV较为复杂。首先为了指定Data Schema开发者需要额外通过schema方法来输入预定义的数据模式。再者CSV的option选项比较多你可以参考后面的表格来更好地控制CSV数据的加载过程。
最后我们学习了read API访问RDBMS的一般方法。与命令行的访问方式类似你需要通过多个option选项来指定数据库连接所必需的访问参数如数据库驱动、URL地址、用户名、密码等等。特别地你还可以为“dbtable”选项指定表名或是查询语句对数据的加载过程进行干预和控制。
每课一练
给定如下CSV文件请你分别使用permissive, dropMalformed, failFast这3种mode对比read API所创建的DataFrame之间的区别。
name,age
alice,18
bob,14
cassie, six
欢迎你在留言区跟我交流活动,也推荐你把这一讲的内容分享给更多的同事、朋友,跟他一起学习进步。

View File

@ -0,0 +1,428 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
16 数据转换如何在DataFrame之上做数据处理
你好,我是吴磊。
在上一讲我们学习了创建DataFrame的各种途径与方法那么有了DataFrame之后我们该如何在DataFrame之上做数据探索、数据分析以及各式各样的数据转换呢在数据处理完毕之后我们又该如何做数据展示与数据持久化呢今天这一讲我们就来解答这些疑问。
为了给开发者提供足够的灵活性对于DataFrame之上的数据处理Spark SQL支持两类开发入口一个是大家所熟知的结构化查询语言SQL另一类是DataFrame开发算子。就开发效率与执行效率来说二者并无优劣之分选择哪种开发入口完全取决于开发者的个人偏好与开发习惯。
与RDD类似DataFrame支持种类繁多的开发算子但相比SQL语言DataFrame算子的学习成本相对要高一些。因此本着先易后难的思路咱们先来说说DataFrame中SQL语句的用法然后再去理解DataFrame开发算子。
SQL语句
对于任意的DataFrame我们都可以使用createTempView或是createGlobalTempView在Spark SQL中创建临时数据表。
两者的区别在于createTempView创建的临时表其生命周期仅限于SparkSession内部而createGlobalTempView创建的临时表可以在同一个应用程序中跨SparkSession提供访问。有了临时表之后我们就可以使用SQL语句灵活地倒腾表数据。
通过后面这段代码我为你演示了如何使用createTempView创建临时表。我们首先用toDF创建了一个包含名字和年龄的DataFrame然后调用createTempView方法创建了临时表。
import org.apache.spark.sql.DataFrame
import spark.implicits._
val seq = Seq(("Alice", 18), ("Bob", 14))
val df = seq.toDF("name", "age")
df.createTempView("t1")
val query: String = "select * from t1"
// spark为SparkSession实例对象
val result: DataFrame = spark.sql(query)
result.show
/** 结果打印
+-----+---+
| n ame| age|
+-----+---+
| Alice| 18|
| Bob| 14|
+-----+---+
*/
以上表为例我们先是使用spark.implicits._隐式方法通过toDF来创建DataFrame然后在其上调用createTempView来创建临时表“t1”。接下来给定SQL查询语句“query”我们可以通过调用SparkSession提供的sql API来提请执行查询语句得到的查询结果被封装为新的DataFrame。
值得一提的是与RDD的开发模式一样DataFrame之间的转换也属于延迟计算当且仅当出现Action类算子时如上表中的show所有之前的转换过程才会交付执行。
Spark SQL采用ANTLR语法解析器来解析并处理SQL语句。我们知道ANTLR是一款强大的、跨语言的语法解析器因为它全面支持SQL语法所以广泛应用于Oracle、Presto、Hive、ElasticSearch等分布式数据仓库和计算引擎。因此像Hive或是Presto中的SQL查询语句都可以平滑地迁移到Spark SQL。
不仅如此Spark SQL还提供大量Built-in Functions内置函数用于辅助数据处理如array_distinct、collect_list等等。你可以浏览官网的Built-in Functions页面查找完整的函数列表。结合SQL语句以及这些灵活的内置函数你就能游刃有余地应对数据探索、数据分析这些典型的数据应用场景。
SQL语句相对比较简单学习路径短、成本低你只要搞清楚如何把DataFrame转化为数据表剩下的事就水到渠成了。接下来我们把主要精力放在DataFrame支持的各类算子上这些算子提供的功能往往能大幅提升开发效率让我们事半功倍。
DataFrame算子
不得不说DataFrame支持的算子丰富而又全面这主要源于DataFrame特有的“双面”属性。一方面DataFrame来自RDD与RDD具有同源性因此RDD支持的大部分算子DataFrame都支持。另一方面DataFrame携带Schema是结构化数据因此它必定要提供一套与结构化查询同源的计算算子。
正是由于这样“双面”的特性我们从下图可以看到DataFrame所支持的算子用“琳琅满目”来形容都不为过。
人类的大脑偏好结构化的知识为了方便你记忆与理解我把DataFrame上述两个方面的算子进一步划分为6大类它们分别是RDD同源类算子、探索类算子、清洗类算子、转换类算子、分析类算子和持久化算子。
你可能会困扰“天呐这么多算子要学这不是逼我从入门到放弃吗”别着急上面这张图你可以把它当作是“DataFrame算子脑图”或是一本字典。在日常的开发中思路枯竭的时候你不妨把它翻出来看看哪些算子能够帮你实现业务逻辑。
今天这一讲,我们也会根据这张“脑图”,重点讲解其中最常用、最关键的部分。
同源类算子
我们从DataFrame中的RDD同源类算子说起这些算子在RDD算子那三讲做过详细的介绍如果你对有哪个算子的作用或含义记不清了不妨回看之前的三讲。我按照之前的分类把这些算子整理成了一张表格。
探索类算子
接下来就是DataFrame的探索类算子。所谓探索指的是数据探索这类算子的作用在于帮助开发者初步了解并认识数据比如数据的模式Schema、数据的分布、数据的“模样”等等为后续的应用开发奠定基础。
对于常用的探索类算子,我把它们整理到了下面的表格中,你不妨先看一看,建立“第一印象”。
我们来依次“避轻就重”地说一说这些算子。首先columns/schema/printSchema这3个算子类似都可以帮我们获取DataFrame的数据列和Schema。尤其是printSchema它以纯文本的方式将Data Schema打印到屏幕上如下所示。
import org.apache.spark.sql.DataFrame
import spark.implicits._
val employees = Seq((1, "John", 26, "Male"), (2, "Lily", 28, "Female"), (3, "Raymond", 30, "Male"))
val employeesDF: DataFrame = employees.toDF("id", "name", "age", "gender")
employeesDF.printSchema
/** 结果打印
root
|-- id: integer (nullable = false)
|-- name: string (nullable = true)
|-- age: integer (nullable = false)
|-- gender: string (nullable = true)
*/
了解数据模式之后我们往往想知道数据具体长什么样子对于这个诉求show算子可以帮忙达成。在默认情况下show会随机打印出DataFrame的20条数据记录。
employeesDF.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| John| 26| Male|
| 2| Lily| 28|Female|
| 3|Raymond| 30| Male|
+---+-------+---+------+
*/
看清了数据的“本来面目”之后你还可以进一步利用describe去查看数值列的统计分布。比如通过调用employeesDF.describe(“age”)你可以查看age列的极值、平均值、方差等统计数值。
初步掌握了数据的基本情况之后如果你对当前DataFrame的执行计划感兴趣可以通过调用explain算子来获得Spark SQL给出的执行计划。explain对于执行效率的调优来说有着至关重要的作用后续课程中我们还会结合具体的实例来深入讲解explain的用法和释义在这里你仅需知道explain是用来查看执行计划的就好。
清洗类算子
完成数据探索以后我们正式进入数据应用的开发阶段。在数据处理前期我们往往需要对数据进行适当地“清洗”“洗掉”那些不符合业务逻辑的“脏数据”。DataFrame提供了如下算子来帮我们完成这些脏活儿、累活儿。
首先drop算子允许开发者直接把指定列从DataFrame中予以清除。举个例子对于上述的employeesDF假设我们想把性别列清除那么直接调用 employeesDF.drop(“gender”) 即可。如果要同时清除多列只需要在drop算子中用逗号把多个列名隔开即可。
第二个是distinct它用来为DataFrame中的数据做去重。还是以employeesDF为例当有多条数据记录的所有字段值都相同时使用distinct可以仅保留其中的一条数据记录。
接下来是dropDuplicates它的作用也是去重。不过与distinct不同的是dropDuplicates可以指定数据列因此在灵活性上更胜一筹。还是拿employeesDF来举例这个DataFrame原本有3条数据记录如果我们按照性别列去重最后只会留下两条记录。其中一条记录的gender列是“Male”另一条的gender列为“Female”如下所示。
employeesDF.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| John| 26| Male|
| 2| Lily| 28|Female|
| 3|Raymond| 30| Male|
+---+-------+---+------+
*/
employeesDF.dropDuplicates("gender").show
/** 结果打印
+---+----+---+------+
| id|name|age|gender|
+---+----+---+------+
| 2|Lily| 28|Female|
| 1|John| 26| Male|
+---+----+---+------+
*/
表格中的最后一个算子是na它的作用是选取DataFrame中的null数据na往往要结合drop或是fill来使用。例如employeesDF.na.drop用于删除DataFrame中带null值的数据记录而employeesDF.na.fill(0) 则将DataFrame中所有的null值都自动填充为整数零。这两种用例在数据清洗的场景中都非常常见因此你需要牢牢掌握na.drop与na.fill的用法。
数据清洗过后,我们就得到了一份“整洁而又干净”的数据,接下来,可以放心大胆地去做各式各样的数据转换,从而实现业务逻辑需求。
转换类算子
转换类算子的主要用于数据的生成、提取与转换。转换类的算子的数量并不多,但使用方式非常灵活,开发者可以变着花样地变换数据。
首先select算子让我们可以按照列名对DataFrame做投影比如说如果我们只关心年龄与性别这两个字段的话就可以使用下面的语句来实现。
employeesDF.select("name", "gender").show
/** 结果打印
+-------+------+
| name|gender|
+-------+------+
| John| Male|
| Lily|Female|
|Raymond| Male|
+-------+------+
*/
不过虽然用起来比较简单但select算子在功能方面不够灵活。在灵活性这方面selectExpr做得更好。比如说基于id和姓名我们想把它们拼接起来生成一列新的数据。像这种需求正是selectExpr算子的用武之地。
employeesDF.selectExpr("id", "name", "concat(id, '_', name) as id_name").show
/** 结果打印
+---+-------+---------+
| id| name| id_name|
+---+-------+---------+
| 1| John| 1_John|
| 2| Lily| 2_Lily|
| 3|Raymond|3_Raymond|
+---+-------+---------+
*/
这里我们使用concat这个函数把id列和name列拼接在一起生成新的id_name数据列。
接下来的where和withColumnRenamed这两个算子比较简单where使用SQL语句对DataFrame做数据过滤而withColumnRenamed的作用是字段重命名。
比如想要过滤出所有性别为男的员工我们就可以用employeesDF.where(“gender = Male”)来实现。如果打算把employeesDF当中的“gender”重命名为“sex”就可以用withColumnRenamed来帮忙employeesDF.withColumnRenamed(“gender”, “sex”)。
紧接着的是withColumn虽然名字看上去和withColumnRenamed很像但二者在功能上有着天壤之别。
withColumnRenamed是重命名现有的数据列而withColumn则用于生成新的数据列这一点上withColumn倒是和selectExpr有着异曲同工之妙。withColumn也可以充分利用Spark SQL提供的Built-in Functions来灵活地生成数据。
比如,基于年龄列,我们想生成一列脱敏数据,隐去真实年龄,你就可以这样操作。
employeesDF.withColumn("crypto", hash($"age")).show
/** 结果打印
+---+-------+---+------+-----------+
| id| name|age|gender| crypto|
+---+-------+---+------+-----------+
| 1| John| 26| Male|-1223696181|
| 2| Lily| 28|Female|-1721654386|
| 3|Raymond| 30| Male| 1796998381|
+---+-------+---+------+-----------+
*/
可以看到我们使用内置函数hash生成一列名为“crypto”的新数据数据值是对应年龄的哈希值。有了新的数据列之后我们就可以调用刚刚讲的drop把原始的age字段丢弃掉。
表格中的最后一个算子是explode这个算子很有意思它的作用是展开数组类型的数据列数组当中的每一个元素都会生成一行新的数据记录。为了更好地演示explode的用法与效果我们把employeesDF数据集做个简单的调整给它加上一个interests兴趣字段。
val seq = Seq( (1, "John", 26, "Male", Seq("Sports", "News")),
(2, "Lily", 28, "Female", Seq("Shopping", "Reading")),
(3, "Raymond", 30, "Male", Seq("Sports", "Reading"))
)
val employeesDF: DataFrame = seq.toDF("id", "name", "age", "gender", "interests")
employeesDF.show
/** 结果打印
+---+-------+---+------+-------------------+
| id| name|age|gender| interests|
+---+-------+---+------+-------------------+
| 1| John| 26| Male| [Sports, News]|
| 2| Lily| 28|Female|[Shopping, Reading]|
| 3|Raymond| 30| Male| [Sports, Reading]|
+---+-------+---+------+-------------------+
*/
employeesDF.withColumn("interest", explode($"interests")).show
/** 结果打印
+---+-------+---+------+-------------------+--------+
| id| name|age|gender| interests|interest|
+---+-------+---+------+-------------------+--------+
| 1| John| 26| Male| [Sports, News]| Sports|
| 1| John| 26| Male| [Sports, News]| News|
| 2| Lily| 28|Female|[Shopping, Reading]|Shopping|
| 2| Lily| 28|Female|[Shopping, Reading]| Reading|
| 3|Raymond| 30| Male| [Sports, Reading]| Sports|
| 3|Raymond| 30| Male| [Sports, Reading]| Reading|
+---+-------+---+------+-------------------+--------+
*/
可以看到,我们多加了一个兴趣列,列数据的类型是数组,每个员工都有零到多个兴趣。
如果我们想把数组元素展开让每个兴趣都可以独占一条数据记录。这个时候就可以使用explode再结合withColumn生成一列新的interest数据。这列数据的类型是单个元素的String而不再是数组。有了新的interest数据列之后我们可以再次利用drop算子把原本的interests列抛弃掉。
数据转换完毕之后我们就可以通过数据的关联、分组、聚合、排序去做数据分析从不同的视角出发去洞察数据。这个时候我们还要依赖Spark SQL提供的多个分析类算子。
分析类算子
毫不夸张地说,前面的探索、清洗、转换,都是在为数据分析做准备。在大多数的数据应用中,数据分析往往是最为关键的那环,甚至是应用本身的核心目的。因此,熟练掌握分析类算子,有利于我们提升开发效率。
Spark SQL的分析类算子看上去并不多但灵活组合使用就会有“千变万化”的效果让我们一起看看。
为了演示上述算子的用法我们先来准备两张数据表employees和salaries也即员工信息表和薪水表。我们的想法是通过对两张表做数据关联来分析员工薪水的分布情况。
import spark.implicits._
import org.apache.spark.sql.DataFrame
// 创建员工信息表
val seq = Seq((1, "Mike", 28, "Male"), (2, "Lily", 30, "Female"), (3, "Raymond", 26, "Male"))
val employees: DataFrame = seq.toDF("id", "name", "age", "gender")
// 创建薪水表
val seq2 = Seq((1, 26000), (2, 30000), (4, 25000), (3, 20000))
val salaries:DataFrame = seq2.toDF("id", "salary")
employees.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| Mike| 28| Male|
| 2| Lily| 30|Female|
| 3|Raymond| 26| Male|
+---+-------+---+------+
*/
salaries.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 4| 25000|
| 3| 20000|
+---+------+
*/
那么首先我们先用join算子把两张表关联起来关联键Join Keys我们使用两张表共有的id列而关联形式Join Type自然是内关联Inner Join
val jointDF: DataFrame = salaries.join(employees, Seq("id"), "inner")
jointDF.show
/** 结果打印
+---+------+-------+---+------+
| id|salary| name|age|gender|
+---+------+-------+---+------+
| 1| 26000| Mike| 28| Male|
| 2| 30000| Lily| 30|Female|
| 3| 20000|Raymond| 26| Male|
+---+------+-------+---+------+
*/
可以看到我们在salaries之上调用join算子join算子的参数有3类。第一类是待关联的数据表在我们的例子中就是员工表employees。第二类是关联键也就是两张表之间依据哪些字段做关联我们这里是id列。第三类是关联形式我们知道关联形式有inner、left、right、anti、semi等等这些关联形式我们下一讲再展开这里你只需要知道Spark SQL支持这些种类丰富的关联形式即可。
数据完成关联之后,我们实际得到的仅仅是最细粒度的事实数据,也就是每个员工每个月领多少薪水。这样的事实数据本身并没有多少价值,我们往往需要从不同的维度出发,对数据做分组、聚合,才能获得更深入、更有价值的数据洞察。
比方说,我们想以性别为维度,统计不同性别下的总薪水和平均薪水,借此分析薪水与性别之间可能存在的关联关系。
val aggResult = fullInfo.groupBy("gender").agg(sum("salary").as("sum_salary"), avg("salary").as("avg_salary"))
aggResult.show
/** 数据打印
+------+----------+----------+
|gender|sum_salary|avg_salary|
+------+----------+----------+
|Female| 30000| 30000.0|
| Male| 46000| 23000.0|
+------+----------+----------+
*/
这里我们先是使用groupBy算子按照“gender”列做分组然后使用agg算子做聚合运算。在agg算子中我们分别使用sum和avg聚合函数来计算薪水的总数和平均值。Spark SQL对于聚合函数的支持我们同样可以通过Built-in Functions页面来进行检索。结合Built-in Functions提供的聚合函数我们就可以灵活地对数据做统计分析。
得到统计结果之后为了方便查看我们还可以使用sort或是orderBy算子对结果集进行排序二者在用法与效果上是完全一致的如下表所示。
aggResult.sort(desc("sum_salary"), asc("gender")).show
/** 结果打印
+------+----------+----------+
|gender|sum_salary|avg_salary|
+------+----------+----------+
| Male| 46000| 23000.0|
|Female| 30000| 30000.0|
+------+----------+----------+
*/
aggResult.orderBy(desc("sum_salary"), asc("gender")).show
/** 结果打印
+------+----------+----------+
|gender|sum_salary|avg_salary|
+------+----------+----------+
| Male| 46000| 23000.0|
|Female| 30000| 30000.0|
+------+----------+----------+
*/
可以看到sort / orderBy支持按照多列进行排序且可以通过desc和asc来指定排序方向。其中desc表示降序排序相应地asc表示升序排序。
好啦到此为止我们沿着数据的生命周期分别梳理了生命周期不同阶段的Spark SQL算子它们分别是探索类算子、清洗类算子、转换类算子和分析类算子。
所谓行百里者半九十纵观整个生命周期我们还剩下数据持久化这一个环节。对于最后的这个持久化环节Spark SQL提供了write API与上一讲介绍的read API相对应write API允许开发者把数据灵活地物化为不同的文件格式。
持久化类算子
没有对比就没有鉴别在学习write API之前我们不妨先来回顾一下上一讲介绍的read API。
如上图所示read API有3个关键点一是由format指定的文件格式二是由零到多个option组成的加载选项最后一个是由load标记的源文件路径。
与之相对write API也有3个关键环节分别是同样由format定义的文件格式零到多个由option构成的“写入选项”以及由save指定的存储路径如下图所示。
这里的format和save与read API中的format和load是一一对应的分别用于指定文件格式与存储路径。实际上option选项也是类似的除了mode以外write API中的选项键与read API中的选项键也是相一致的如seq用于指定CSV文件分隔符、dbtable用于指定数据表名、等等你可以通过回顾[上一讲]来获取更多的option选项。
在read API中mode选项键用于指定读取模式如permissive, dropMalformed, failFast。但在write API中mode用于指定“写入模式”分别有Append、Overwrite、ErrorIfExists、Ignore这4种模式它们的含义与描述如下表所示。
有了write API我们就可以灵活地把DataFrame持久化到不同的存储系统中为数据的生命周期画上一个圆满的句号。
重点回顾
今天这一讲我们主要围绕数据的生命周期学习了Spark SQL在不同数据阶段支持的处理算子如下图所示。
图中涉及的算子很多尽管大部分我们都举例讲过了但要在短时间之内一下子掌握这么多内容确实强人所难。不过你不用担心今天这一讲最主要的目的还是想让你对Spark SQL支持的算子有一个整体的把握。
至于每个算子具体是用来做什么的在日后的开发工作中你可以反复地翻看这一讲结合实践慢慢地加深印象这样学习更高效。我也强烈建议你空闲时把官网的Built-in Functions列表过一遍对这些内置函数的功能做到心中有数实现业务逻辑时才会手到擒来。
除了DataFrame本身支持的算子之外在功能上SQL完全可以实现同样的数据分析。给定DataFrame你只需通过createTempView或是createGlobalTempView来创建临时表然后就可以通过写SQL语句去进行数据的探索、倾斜、转换与分析。
最后需要指出的是DataFrame算子与SQL查询语句之间并没有优劣之分他们可以实现同样的数据应用而且在执行性能方面也是一致的。因此你可以结合你的开发习惯与偏好自由地在两者之间进行取舍。
每课一练
在转换类算子中我们举例介绍了explode这个算子它的作用是按照以数组为元素的数据列把一条数据展开爆炸成多条数据。结合这个算子的作用你能否分析一下explode操作是否会引入Shuffle计算呢
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给有需要的朋友。

View File

@ -0,0 +1,323 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
17 数据关联:不同的关联形式与实现机制该怎么选?
你好,我是吴磊。
在上一讲我们学习了Spark SQL支持的诸多算子。其中数据关联Join是数据分析场景中最常见、最重要的操作。毫不夸张地说几乎在所有的数据应用中你都能看到数据关联的“身影”。因此今天这一讲咱们继续详细说一说Spark SQL对于Join的支持。
众所周知Join的种类非常丰富。如果按照关联形式Join Types来划分数据关联分为内关联、外关联、左关联、右关联等等。对于参与关联计算的两张表关联形式决定了结果集的数据来源。因此在开发过程中选择哪种关联形式是由我们的业务逻辑决定的。
而从实现机制的角度Join又可以分为NLJNested Loop Join、SMJSort Merge Join和HJHash Join。也就是说同样是内关联我们既可以采用NLJ来实现也可以采用SMJ或是HJ来实现。区别在于在不同的计算场景下这些不同的实现机制在执行效率上有着天壤之别。因此了解并熟悉这些机制对咱们开发者来说至关重要。
今天我们就分别从这两个角度来说一说Spark SQL当中数据关联的来龙去脉。
数据准备
为了让你更好地掌握新知识我会通过一个个例子为你说明Spark SQL数据关联的具体用法。在去介绍数据关联之前咱们先把示例中会用到的数据准备好。
import spark.implicits._
import org.apache.spark.sql.DataFrame
// 创建员工信息表
val seq = Seq((1, "Mike", 28, "Male"), (2, "Lily", 30, "Female"), (3, "Raymond", 26, "Male"), (5, "Dave", 36, "Male"))
val employees: DataFrame = seq.toDF("id", "name", "age", "gender")
// 创建薪资表
val seq2 = Seq((1, 26000), (2, 30000), (4, 25000), (3, 20000))
val salaries:DataFrame = seq2.toDF("id", "salary")
如上表所示我们创建了两个DataFrame一个用于存储员工基本信息我们称之为员工表另一个存储员工薪水我们称之为薪资表。
数据准备好之后我们有必要先弄清楚一些数据关联的基本概念。所谓数据关联它指的是这样一个计算过程给定关联条件Join Conditions将两张数据表以不同关联形式拼接在一起的过程。关联条件包含两层含义一层是两张表中各自关联字段Join Key的选择另一层是关联字段之间的逻辑关系。
在[上一讲]我们说到Spark SQL同时支持DataFrame算子与SQL查询因此咱们不妨结合刚刚准备好的数据分别以这两者为例来说明数据关联中的基本概念。
首先约定俗成地我们把主动参与Join的数据表如上图中的salaries表称作“左表”而把被动参与关联的数据表如employees表称作是“右表”。
然后我们来关注图中蓝色的部分。可以看到两张表都选择id列作为关联字段而两者的逻辑关系是“相等”。这样的一个等式就构成了我们刚刚说的关联条件。接下来我们再来看图中绿色的部分inner指代的就是内关联的关联形式。
关联形式是我们今天要学习的重点内容之一。接下来我们还是一如既往地绕过SQL查询这种开发方式以DataFrame算子这种开发模式为例说一说Spark SQL都支持哪些关联形式以及不同关联形式的效果是怎样的。
关联形式Join Types
在关联形式这方面Spark SQL的支持比较全面为了让你一上来就建立一个整体的认知我把Spark SQL支持的Joint Types都整理到了如下的表格中你不妨先粗略地过一遍。
结合已经准备好的数据,我们分别来说一说每一种关联形式的用法,以及它们各自的作用与效果。我们先从最简单、最基础、也是最常见的内关联说起。
内关联Inner Join
对于登记在册的员工,如果我们想获得他们每个人的薪资情况,就可以使用内关联来实现,如下所示。
// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
jointDF.show
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
// 左表
salaries.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 4| 25000|
| 3| 20000|
+---+------+
*/
// 右表
employees.show
/** 结果打印
+---+-------+---+------+
| id| name|age|gender|
+---+-------+---+------+
| 1| Mike| 28| Male|
| 2| Lily| 30|Female|
| 3|Raymond| 26| Male|
| 5| Dave| 36| Male|
+---+-------+---+------+
*/
可以看到基于join算子的一般用法我们只要在第3个参数中指定“inner”这种关联形式就可以使用内关联的方式来达成两表之间的数据拼接。不过如果仔细观察上面打印的关联结果集以及原始的薪资表与员工表你会发现左表和右表的原始数据并没有都出现在结果集当中。
例如在原始的薪资表中有一条id为4的薪资记录而在员工表中有一条id为5、name为“Dave”的数据记录。这两条数据记录都没有出现在内关联的结果集中而这正是“内关联”这种关联形式的作用所在。
内关联的效果是仅仅保留左右表中满足关联条件的那些数据记录。以上表为例关联条件是salaries(“id”) === employees(“id”)而在员工表与薪资表中只有1、2、3这三个值同时存在于他们各自的id字段中。相应地结果集中就只有id分别等于1、2、3的这三条数据记录。
理解了内关联的含义与效果之后,你再去学习其他的关联形式,比如说外关联,就会变得轻松许多。
外关联Outer Join
外关联还可以细分为3种形式分别是左外关联、右外关联、以及全外关联。这里的左、右对应的实际上就是左表、右表。
由简入难我们先来说左外关联。要把salaries与employees做左外关联我们只需要把“inner”关键字替换为“left”、“leftouter”或是“left_outer”即可如下所示。
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "left")
jointDF.show
/** 结果打印
+---+------+----+-------+----+------+
| id|salary| id| name| age|gender|
+---+------+----+-------+----+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 4| 25000|null| null|null| null|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+----+-------+----+------+
*/
不难发现左外关联的结果集实际上就是内关联结果集再加上左表salaries中那些不满足关联条件的剩余数据也即id为4的数据记录。值得注意的是由于右表employees中并不存在id为4的记录因此结果集中employees对应的所有字段值均为空值null。
没有对比就没有鉴别为了更好地理解前面学的内关联、左外关联我们再来看看右外关联的执行结果。为了计算右外关联在下面的代码中我们把“left”关键字替换为“right”、“rightouter”或是“right_outer”。
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "right")
jointDF.show
/** 结果打印
+----+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+----+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
|null| null| 5| Dave| 36| Male|
+----+------+---+-------+---+------+
*/
仔细观察你会发现与左外关联相反右外关联的结果集恰恰是内关联的结果集再加上右表employees中的剩余数据也即id为5、name为“Dave”的数据记录。同样的由于左表salaries并不存在id等于5的数据记录因此结果集中salaries相应的字段置空以null值进行填充。
理解了左外关联与右外关联全外关联的功用就显而易见了。全外关联的结果集就是内关联的结果再加上那些不满足关联条件的左右表剩余数据。要进行全外关联的计算关键字可以取“full”、“outer”、“fullouter”、或是“full_outer”如下表所示。
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "full")
jointDF.show
/** 结果打印
+----+------+----+-------+----+------+
| id|salary| id| name| age|gender|
+----+------+----+-------+----+------+
| 1| 26000| 1| Mike| 28| Male|
| 3| 20000| 3|Raymond| 26| Male|
|null| null| 5| Dave| 36| Male|
| 4| 25000|null| null|null| null|
| 2| 30000| 2| Lily| 30|Female|
+----+------+----+-------+----+------+
*/
到这里,内、外关联的作用我们就讲完了。聪明的你可能早已发现,这里的“内”,它指的是,在关联结果中,仅包含满足关联条件的那些数据记录;而“外”,它的含义是,在关联计算的结果集中,还包含不满足关联条件的数据记录。而外关联中的“左”、“右”、“全”,恰恰是在表明,那些不满足关联条件的记录,来自于哪里。
弄清楚“内”、“外”、“左”、“右”这些说法的含义能够有效地帮我们避免迷失在种类繁多、却又彼此相关的关联形式中。其实除了内关联和外关联Spark SQL还支持左半关联和左逆关联这两个关联又是用来做什么的呢
左半/逆关联Left Semi Join / Left Anti Join
尽管名字听上去拗口但它们的含义却很简单。我们先来说左半关联它的关键字有“leftsemi”和“left_semi”。左半关联的结果集实际上是内关联结果集的子集它仅保留左表中满足关联条件的那些数据记录如下表所示。
// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
jointDF.show
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
// 左半关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftsemi")
jointDF.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 3| 20000|
+---+------+
*/
为了方便你进行对比我分别打印出了内关联与左半关联的计算结果。这里你需要把握左半关联的两大特点首先左半关联是内关联的一个子集其次它只保留左表salaries中的数据。这两个特点叠加在一起很好地诠释了“左、半”这两个字。
有了左半关联的基础左逆关联会更好理解一些。左逆关联同样只保留左表的数据它的关键字有“leftanti”和“left_anti”。但与左半关联不同的是它保留的是那些不满足关联条件的数据记录如下所示。
// 左逆关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "leftanti")
jointDF.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 4| 25000|
+---+------+
*/
通过与上面左半关联的结果集做对比我们一眼就能看出左逆关联和它的区别所在。显然id为4的薪资记录是不满足关联条件salaries(“id”) === employees(“id”)的,而左逆关联留下的,恰恰是这些“不达标”的数据记录。
好啦关于Spark SQL支持的关联形式到这里我们就全部说完了。根据这些不同关联形式的特点与作用再结合实际场景中的业务逻辑相信你可以在日常的开发中做到灵活取舍。
关联机制Join Mechanisms
不过从功能的角度出发使用不同的关联形式来实现业务逻辑可以说是程序员的一项必备技能。要在众多的开发者中脱颖而出咱们还要熟悉、了解不同的关联机制。哪怕同样是内关联不同的Join实现机制在执行效率方面差异巨大。因此掌握不同关联机制的原理与特性有利于我们逐渐培养出以性能为导向的开发习惯。
在这一讲的开头我们提到Join有3种实现机制分别是NLJNested Loop Join、SMJSort Merge Join和HJHash Join。接下来我们以内关联为例结合salaries和employees这两张表来说说它们各自的实现原理与特性。
// 内关联
val jointDF: DataFrame = salaries.join(employees, salaries("id") === employees("id"), "inner")
jointDF.show
/** 结果打印
+---+------+---+-------+---+------+
| id|salary| id| name|age|gender|
+---+------+---+-------+---+------+
| 1| 26000| 1| Mike| 28| Male|
| 2| 30000| 2| Lily| 30|Female|
| 3| 20000| 3|Raymond| 26| Male|
+---+------+---+-------+---+------+
*/
NLJNested Loop Join
对于参与关联的两张表如salaries和employees按照它们在代码中出现的顺序我们约定俗成地把salaries称作“左表”而把employees称作“右表”。在探讨关联机制的时候我们又常常把左表称作是“驱动表”而把右表称为“基表”。
一般来说,驱动表的体量往往较大,在实现关联的过程中,驱动表是主动扫描数据的那一方。而基表相对来说体量较小,它是被动参与数据扫描的那一方。
在NLJ的实现机制下算法会使用外、内两个嵌套的for循环来依次扫描驱动表与基表中的数据记录。在扫描的同时还会判定关联条件是否成立如内关联例子中的salaries(“id”) === employees(“id”)。如果关联条件成立,就把两张表的记录拼接在一起,然后对外进行输出。
在实现的过程中,外层的 for 循环负责遍历驱动表的每一条数据,如图中的步骤 1 所示。对于驱动表中的每一条数据记录,内层的 for 循环会逐条扫描基表的所有记录依次判断记录的id字段值是否满足关联条件如步骤 2 所示。
不难发现,假设驱动表有 M 行数据,而基表有 N 行数据,那么 NLJ 算法的计算复杂度是 O(M * N)。尽管NLJ的实现方式简单、直观、易懂但它的执行效率显然很差。
SMJSort Merge Join
鉴于NLJ低效的计算效率SMJ应运而生。Sort Merge Join顾名思义SMJ的实现思路是先排序、再归并。给定参与关联的两张表SMJ先把他们各自排序然后再使用独立的游标对排好序的两张表做归并关联。
具体计算过程是这样的起初驱动表与基表的游标都会先锚定在各自的第一条记录上然后通过对比游标所在记录的id字段值来决定下一步的走向。对比结果以及后续操作主要分为 3 种情况:
满足关联条件两边的id值相等那么此时把两边的数据记录拼接并输出然后把驱动表的游标滑动到下一条记录
不满足关联条件驱动表id值小于基表的id值此时把驱动表的游标滑动到下一条记录
不满足关联条件驱动表id值大于基表的id值此时把基表的游标滑动到下一条记录。
基于这 3 种情况SMJ不停地向下滑动游标直到某张表的游标滑到尽头即宣告关联结束。对于驱动表的每一条记录由于基表已按id字段排序且扫描的起始位置为游标所在位置因此SMJ算法的计算复杂度为 O(M + N)。
然而,计算复杂度的降低,仰仗的其实是两张表已经事先排好了序。但是我们知道,排序本身就是一项很耗时的操作,更何况,为了完成归并关联,参与 Join 的两张表都需要排序。
因此SMJ的计算过程我们可以用“先苦后甜”来形容。苦指的是要先花费时间给两张表做排序而甜指的则是有序表的归并关联能够享受到线性的计算复杂度。
HJHash Join
考虑到SMJ对于排序的苛刻要求后来又有人推出了HJ算法。HJ的设计初衷是以空间换时间力图将基表扫描的计算复杂度降低至O(1)。
具体来说HJ的计算分为两个阶段分别是Build阶段和Probe阶段。在Build阶段在基表之上算法使用既定的哈希函数构建哈希表如上图的步骤 1 所示。哈希表中的Key是id字段应用Apply哈希函数之后的哈希值而哈希表的 Value 同时包含了原始的Join Keyid字段和Payload。
在Probe阶段算法依次遍历驱动表的每一条数据记录。首先使用同样的哈希函数以动态的方式计算Join Key的哈希值。然后算法再用哈希值去查询刚刚在Build阶段创建好的哈希表。如果查询失败则说明该条记录与基表中的数据不存在关联关系相反如果查询成功则继续对比两边的Join Key。如果Join Key一致就把两边的记录进行拼接并输出从而完成数据关联。
好啦到此为止对于Join的3种实现机制我们暂时说到这里。对于它们各自的实现原理想必你已经有了充分的把握。至于这3种机制都适合哪些计算场景以及Spark SQL如何利用这些机制在分布式环境下做数据关联我们留到[下一讲]再去展开。
重点回顾
今天这一讲我们重点介绍了数据关联中的关联形式Join Types与实现机制Join Mechanisms。掌握了不同的关联形式我们才能游刃有余地满足不断变化的业务需求。而熟悉并理解不同实现机制的工作原理则有利于培养我们以性能为导向的开发习惯。
Spark SQL支持的关联形式多种多样为了方便你查找我把它们的含义与效果统一整理到了如下的表格中。在日后的开发工作中当你需要区分并确认不同的关联形式时只要回顾这张表格就能迅速得到结论。
在此之后我们又介绍了Join的3种实现机制它们分别是Nested Loop Join、Sort Merge Join和Hash Join。这3种实现机制的工作原理我也整理成了表格方便你随时查看。
每课一练
对于Join的3种实现机制也即Nested Loop Join、Sort Merge Join和Hash Join结合其实现原理你能猜一猜它们可能的适用场景都有哪些吗或者换句话说在什么样的情况下更适合使用哪种实现机制来进行数据关联
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给身边的同事、朋友。

View File

@ -0,0 +1,143 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
18 数据关联优化都有哪些Join策略开发者该如何取舍
你好,我是吴磊。
在上一讲,我们分别从关联形式与实现机制这两个方面,对数据分析进行了讲解和介绍。对于不同关联形式的用法和实现机制的原理,想必你已经了然于胸。不过,在大数据的应用场景中,数据的处理往往是在分布式的环境下进行的,在这种情况下,数据关联的计算还要考虑网络分发这个环节。
我们知道在分布式环境中Spark支持两类数据分发模式。一类是我们在[第7讲]学过的ShuffleShuffle通过中间文件来完成Map阶段与Reduce阶段的数据交换因此它会引入大量的磁盘与网络开销。另一类是我们在[第10讲]介绍的广播变量Broadcast Variables广播变量在Driver端创建并由Driver分发到各个Executors。
因此从数据分发模式的角度出发数据关联又可以分为Shuffle Join和Broadcast Join这两大类。将两种分发模式与Join本身的3种实现机制相结合就会衍生出分布式环境下的6种Join策略。
那么对于这6种Join策略Spark SQL是如何支持的呢它们的优劣势与适用场景都有哪些开发者能否针对这些策略有的放矢地进行取舍今天这一讲咱们就来聊聊这些话题。
Join实现机制的优势对比
首先我们先来说一说不同Join实现机制本身的一些特性与适用场景从而为后续的讨论打好基础。需要说明的是咱们这里说的Join实现机制指的是算法层面的工作原理不同的算法有着不同的适用场景与复杂度我们需要对它们有足够认识并有所区分。
我们知道Join支持3种实现机制它们分别是Hash Join、Sort Merge Join和Nested Loop Join。三者之中Hash Join的执行效率最高这主要得益于哈希表O(1)的查找效率。不过在Probe阶段享受哈希表的“性能红利”之前Build阶段得先在内存中构建出哈希表才行。因此Hash Join这种算法对于内存的要求比较高适用于内存能够容纳基表数据的计算场景。
相比之下Sort Merge Join就没有内存方面的限制。不论是排序、还是合并SMJ都可以利用磁盘来完成计算。所以在稳定性这方面SMJ更胜一筹。
而且与Hash Join相比SMJ的执行效率也没有差太多前者是O(M)后者是O(M + N)可以说是不分伯仲。当然O(M + N)的复杂度得益于SMJ的排序阶段。因此如果准备参与Join的两张表是有序表那么这个时候采用SMJ算法来实现关联简直是再好不过了。
与前两者相比Nested Loop Join看上去有些多余嵌套的双层for循环带来的计算复杂度最高O(M * N)。不过尺有所短寸有所长执行高效的HJ和SMJ只能用于等值关联也就是说关联条件必须是等式像salaries(“id”) < employees(id)这样的关联条件HJ和SMJ是无能为力的相反NLJ既可以处理等值关联Equi Join也可以应付不等值关联Non Equi Join可以说是数据关联在实现机制上的最后一道防线
Shuffle Join与Broadcast Join
分析完不同Join机制的优缺点之后接下来我们再来说说分布式环境下的Join策略。与单机环境不同在分布式环境中两张表的数据各自散落在不同的计算节点与Executors进程。因此要想完成数据关联Spark SQL就必须先要把Join Keys相同的数据分发到同一个Executors中去才行。
我们还是用上一讲的员工信息和薪资表来举例如果我们打算对salaries和employees两张表按照id列做关联那么对于id字段值相同的薪资数据与员工数据我们必须要保证它们坐落在同样的Executors进程里Spark SQL才能利用刚刚说的HJ、SMJ、以及NLJ以Executors进程为粒度并行地完成数据关联。
换句话说以Join Keys为基准两张表的数据分布保持一致是Spark SQL执行分布式数据关联的前提。而能满足这个前提的途径只有两个Shuffle与广播。这里我额外提醒一下Shuffle和广播变量我们在前面的课程有过详细的介绍如果你记不太清了不妨翻回去看一看。
回到正题开篇咱们说到如果按照分发模式来划分数据关联可以分为Shuffle Join和Broadcast Join两大类。通常来说在执行性能方面相比Shuffle JoinBroadcast Join往往会更胜一筹。为什么这么说呢
接下来我们就一起来分析分析这两大类Join在分布式环境下的执行过程然后再来回答这个问题。理解了执行过程你自然就能解答这个问题了。
Shuffle Join
在没有开发者干预的情况下Spark SQL默认采用Shuffle Join来完成分布式环境下的数据关联。对于参与Join的两张数据表Spark SQL先是按照如下规则来决定不同数据记录应当分发到哪个Executors中去
根据Join Keys计算哈希值
将哈希值对并行度Parallelism取模
由于左表与右表在并行度分区数上是一致的因此按照同样的规则分发数据之后一定能够保证id字段值相同的薪资数据与员工数据坐落在同样的Executors中。
如上图所示颜色相同的矩形代表Join Keys相同的数据记录可以看到在Map阶段数据分散在不同的Executors当中。经过Shuffle过后Join Keys相同的记录被分发到了同样的Executors中去。接下来在Reduce阶段Reduce Task就可以使用HJ、SMJ、或是NLJ算法在Executors内部完成数据关联的计算。
Spark SQL之所以在默认情况下一律采用Shuffle Join原因在于Shuffle Join的“万金油”属性。也就是说在任何情况下不论数据的体量是大是小、不管内存是否足够Shuffle Join在功能上都能够“不辱使命”成功地完成数据关联的计算。然而有得必有失功能上的完备性往往伴随着的是性能上的损耗。
学习过 [Shuffle的原理]第6讲之后不用我多说Shuffle的弊端想必你早已烂熟于心。我们知道从CPU到内存从磁盘到网络Shuffle的计算几乎需要消耗所有类型的硬件资源。尤其是磁盘和网络开销这两座大山往往是应用执行的性能瓶颈。
那么问题来了除了Shuffle Join这种“万金油”式的Join策略开发者还有没有其他效率更高的选择呢答案当然是肯定的Broadcast Join就是用来克制Shuffle的“杀手锏”。
Broadcast Join
在广播变量那一讲第10讲我们讲过把用户数据结构封装为广播变量的过程。实际上Spark不仅可以在普通变量上创建广播变量在分布式数据集如RDD、DataFrame之上也可以创建广播变量。这样一来对于参与Join的两张表我们可以把其中较小的一个封装为广播变量然后再让它们进行关联。
光说思路你可能体会不深,我们还是结合例子理解。以薪资表和员工表为例,只要对代码稍加改动,我们就能充分利用广播变量的优势。
更改后的代码如下所示。
import org.apache.spark.sql.functions.broadcast
// 创建员工表的广播变量
val bcEmployees = broadcast(employees)
// 内关联PS将原来的employees替换为bcEmployees
val jointDF: DataFrame = salaries.join(bcEmployees, salaries("id") === employees("id"), "inner")
在Broadcast Join的执行过程中Spark SQL首先从各个Executors收集employees表所有的数据分片然后在Driver端构建广播变量bcEmployees构建的过程如下图实线部分所示。
可以看到散落在不同Executors内花花绿绿的矩形代表的正是employees表的数据分片。这些数据分片聚集到一起就构成了广播变量。接下来如图中虚线部分所示携带着employees表全量数据的广播变量bcEmployees被分发到了全网所有的Executors当中去。
在这种情况下体量较大的薪资表数据只要“待在原地、保持不动”就可以轻松关联到跟它保持之一致的员工表数据了。通过这种方式Spark SQL成功地避开了Shuffle这种“劳师动众”的数据分发过程转而用广播变量的分发取而代之。
尽管广播变量的创建与分发同样需要消耗网络带宽但相比Shuffle Join中两张表的全网分发因为仅仅通过分发体量较小的数据表来完成数据关联Spark SQL的执行性能显然要高效得多。这种小投入、大产出用极小的成本去博取高额的性能收益可以说是“四两拨千斤”
Spark SQL支持的Join策略
不论是Shuffle Join还是Broadcast Join一旦数据分发完毕理论上可以采用HJ、SMJ和NLJ这3种实现机制中的任意一种完成Executors内部的数据关联。因此两种分发模式与三种实现机制它们组合起来总共有6种分布式Join策略如下图所示。
虽然组合起来选择多样,但你也不必死记硬背,抓住里面的规律才是关键,我们一起来分析看看。
在这6种Join策略中Spark SQL支持其中的5种来应对不用的关联场景也即图中蓝色的5个矩形。对于等值关联Equi JoinSpark SQL优先考虑采用Broadcast HJ策略其次是Shuffle SMJ最次是Shuffle HJ。对于不等值关联Non Equi JoinSpark SQL优先考虑Broadcast NLJ其次是Shuffle NLJ。
不难发现不论是等值关联、还是不等值关联只要Broadcast Join的前提条件成立Spark SQL一定会优先选择Broadcast Join相关的策略。那么问题来了Broadcast Join的前提条件是什么呢
回顾Broadcast Join的工作原理图我们不难发现Broadcast Join得以实施的基础是被广播数据表图中的表2的全量数据能够完全放入Driver的内存、以及各个Executors的内存如下图所示。
另外为了避免因广播表尺寸过大而引入新的性能隐患Spark SQL要求被广播表的内存大小不能超过8GB。
这里我们简单总结一下。只要被广播表满足上述两个条件我们就可以利用SQL Functions中的broadcast函数来创建广播变量进而利用Broadcast Join策略来提升执行性能。
当然在Broadcast Join前提条件不成立的情况下Spark SQL就会退化到Shuffle Join的策略。在不等值的数据关联中Spark SQL只有Shuffle NLJ这一种选择因此咱们无需赘述。
但在等值关联的场景中Spark SQL有Shuffle SMJ和Shuffle HJ这两种选择。尽管如此Shuffle SMJ与Shuffle HJ的关系就像是关羽和周仓的关系。周仓虽说武艺也不错但他向来只是站在关公后面提刀。大战在即刘备仰仗的自然是站在前面的关羽而很少启用后面的周仓。在Shuffle SMJ与Shuffle HJ的取舍上Spark SQL也是如此。
学习过Shuffle之后我们知道Shuffle在Map阶段往往会对数据做排序而这恰恰正中SMJ机制的下怀。对于已经排好序的两张表SMJ的复杂度是O(M + N)这样的执行效率与HJ的O(M)可以说是不相上下。再者SMJ在执行稳定性方面远胜于HJ在内存受限的情况下SMJ可以充分利用磁盘来顺利地完成关联计算。因此考虑到Shuffle SMJ的诸多优势Shuffle HJ就像是关公后面的周仓Spark SQL向来对之视而不见所以对于HJ你大概知道它的作用就行。
重点回顾
好啦到此为止今天的课程就全部讲完了我们一起来做个总结。首先我们一起分析、对比了单机环境中不同Join机制的优劣势我把它们整理到了下面的表格中供你随时查看。
在分布式环境中要想利用上述机制完成数据关联Spark SQL首先需要把两张表中Join Keys一致的数据分发到相同的Executors中。
因此数据分发是分布式数据关联的基础和前提。Spark SQL支持Shuffle和广播两种数据分发模式相应地Join也被分为Shuffle Join和Broadcast Join其中Shuffle Join是默认的关联策略。关于两种策略的优劣势对比我也整理到了如下的表格中供你参考。
结合三种实现机制和两种数据分发模式Spark SQL支持5种分布式Join策略。对于这些不同的Join策略Spark SQL有着自己的选择偏好我把它整理到了如下的表格中供你随时查看。
其中Broadcast Join的生效前提是基表能够放进内存且存储尺寸小于8GB。只要前提条件成立Spark SQL就会优先选择Broadcast Join。
每课一练
在6种分布式Join策略中Spark SQL唯独没有支持Broadcast SMJ你能想一想为什么Spark SQL没有选择支持这种Join策略吗提示一下你可以从SMJ与HJ的执行效率入手做分析。
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。

View File

@ -0,0 +1,188 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
19 配置项详解:哪些参数会影响应用程序执行性能?
你好,我是吴磊。
在上一讲我们学习了Broadcast Join这种执行高效的Join策略。要想触发Spark SQL选择这类Join策略可以利用SQL Functions中的broadcast函数来强制广播基表。在这种情况下Spark SQL会完全“尊重”开发者的意愿只要基表小于8GB它就会竭尽全力地去尝试进行广播并采用Broadcast Join策略。
除了这种比较“强势”的做法我们还可以用另一种比较温和方式来把选择权“下放”给Spark SQL让它自己来决定什么时候选择Broadcast Join什么时候回退到Shuffle Join。这种温和的方式就是配置项设置。在第12讲我们掌握了Spark常规配置项今天这一讲咱们来说一说与Spark SQL有关的那些配置项。
不过打开Spark官网的 Configuration页面你会发现这里有上百个配置项与Spark SQL相关的有好几十个看得人眼花缭乱、头晕目眩。实际上绝大多数配置项仅需采用默认值即可并不需要我们过多关注。因此我们把目光和注意力聚集到Join策略选择和AQE上。
Join策略的重要性不必多说AQEAdaptive Query Execution是Spark 3.0推出的新特性它帮助Spark SQL在运行时动态地调整执行计划更加灵活地优化作业的执行性能。
Broadcast Join
接下来我们先来说说如何使用配置项来“温和”地让Spark SQL选择Broadcast Join。对于参与Join的两张表来说我们把其中尺寸较小的表称作基表。
如果基表的存储尺寸小于广播阈值那么无需开发者显示调用broadcast函数Spark SQL同样会选择Broadcast Join的策略在基表之上创建广播变量来完成两张表的数据关联。
那么问题来了广播阈值是什么它是怎么定义的呢广播阈值实际上就是一个标记存储尺寸的数值它可以是10MB、也可是1GB等等。广播阈值由如下配置项设定只要基表小于该配置项的设定值Spark SQL就会自动选择Broadcast Join策略。
如上表所示广播阈值的默认值为10MB。一般来说在工业级应用中我们往往把它设置到2GB左右即可有效触发Broadcast Join。广播阈值有了要比较它与基表存储尺寸谁大谁小Spark SQL还要还得事先计算好基表的存储尺寸才行。那问题来了Spark SQL依据什么来计算这个数值呢
这个问题要分两种情况讨论如果基表数据来自文件系统那么Spark SQL用来与广播阈值对比的基准就是基表在磁盘中的存储大小。如果基表数据来自DAG计算的中间环节那么Spark SQL将参考DataFrame执行计划中的统计值跟广播阈值做对比如下所示。
val df: DataFrame = _
// 先对分布式数据集加Cache
df.cache.count
// 获取执行计划
val plan = df.queryExecution.logical
// 获取执行计划对于数据集大小的精确预估
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes
讲到这里你也许会有点不耐烦“何必这么麻烦又要设置配置项又要提前预估基表大小真是麻烦还不如用上一讲提到的broadcast函数来得干脆
从开发者的角度看来确实broadcast函数用起来更方便一些。不过广播阈值加基表预估的方式除了为开发者提供一条额外的调优途径外还为Spark SQL的动态优化奠定了基础。
所谓动态优化自然是相对静态优化来说的。在3.0版本之前对于执行计划的优化Spark SQL仰仗的主要是编译时运行时之前的统计信息如数据表在磁盘中的存储大小等等。
因此在3.0版本之前Spark SQL所有的优化机制如Join策略的选择都是静态的它没有办法在运行时动态地调整执行计划从而顺应数据集在运行时此消彼长的变化。
举例来说在Spark SQL的逻辑优化阶段两张大表的尺寸都超过了广播阈值因此Spark SQL在物理优化阶段就不得不选择Shuffle Join这种次优的策略。
但实际上在运行时期间其中一张表在Filter过后剩余的数据量远小于广播阈值完全可以放进广播变量。可惜此时“木已成舟”静态优化机制没有办法再将Shuffle Join调整为Broadcast Join。
AQE
为了弥补静态优化的缺陷、同时让Spark SQL变得更加智能Spark社区在3.0版本中推出了AQE机制。
AQE的全称是Adaptive Query Execution翻译过来是“自适应查询执行”。它包含了3个动态优化特性分别是Join策略调整、自动分区合并和自动倾斜处理。
或许是Spark社区对于新的优化机制偏向于保守AQE机制默认是未开启的要想充分利用上述的3个特性我们得先把spark.sql.adaptive.enabled修改为true才行。
好啦成功开启了AQE机制之后接下来我们就结合相关的配置项来聊一聊这些特性都解决了哪些问题以及它们是如何工作的。
Join策略调整
我们先来说说Join策略调整如果用一句话来概括Join策略调整指的就是Spark SQL在运行时动态地将原本的Shuffle Join策略调整为执行更加高效的Broadcast Join。
具体来说每当DAG中的Map阶段执行完毕Spark SQL就会结合Shuffle中间文件的统计信息重新计算Reduce阶段数据表的存储大小。如果发现基表尺寸小于广播阈值那么Spark SQL就把下一阶段的Shuffle Join调整为Broadcast Join。
不难发现这里的关键是Shuffle以及Shuffle的中间文件。事实上不光是Join策略调整这个特性整个AQE机制的运行都依赖于DAG中的Shuffle环节。
所谓巧妇难为无米之炊要做到动态优化Spark SQL必须要仰仗运行时的执行状态而Shuffle中间文件则是这些状态的唯一来源。
举例来说通过Shuffle中间文件Spark SQL可以获得诸如文件尺寸、Map Task数据分片大小、Reduce Task分片大小、空文件占比之类的统计信息。正是利用这些统计信息Spark SQL才能在作业执行的过程中动态地调整执行计划。
我们结合例子进一步来理解以Join策略调整为例给定如下查询语句假设salaries表和employees表的存储大小都超过了广播阈值在这种情况下对于两张表的关联计算Spark SQL只能选择Shuffle Join策略。
不过实际上employees按照年龄过滤之后剩余的数据量是小于广播阈值的。这个时候得益于AQE机制的Join策略调整Spark SQL能够把最初制定的Shuffle Join策略调整为Broadcast Join策略从而在运行时加速执行性能。
select * from salaries inner join employees
on salaries.id = employees.id
where employees.age >= 30 and employees.age < 45
你看在这种情况下广播阈值的设置、以及基表过滤之后数据量的预估就变得非常重要。原因在于这两个要素决定了Spark SQL能否成功地在运行时充分利用AQE的Join策略调整特性进而在整体上优化执行性能。因此我们必须要掌握广播阈值的设置方法以及数据集尺寸预估的方法。
介绍完Join策略调整接下来我们再来说说AQE机制的另外两个特性自动分区合并与自动倾斜处理它们都是对于Shuffle本身的优化策略。
我们先来说说自动分区合并与自动倾斜处理都在尝试解决什么问题。我们知道Shuffle的计算过程分为两个阶段Map阶段和Reduce阶段。Map阶段的数据分布往往由分布式文件系统中的源数据决定因此数据集在这个阶段的分布是相对均匀的。
Reduce阶段的数据分布则不同它是由Distribution Key和Reduce阶段并行度决定的。并行度也就是分区数目这个概念咱们在之前的几讲反复强调想必你并不陌生。
而Distribution Key则定义了Shuffle分发数据的依据对于reduceByKey算子来说Distribution Key就是Paired RDD的Key而对于repartition算子来说Distribution Key就是传递给repartition算子的形参如repartition($“Column Name”)。
在业务上Distribution Key往往是user_id、item_id这一类容易产生倾斜的字段相应地数据集在Reduce阶段的分布往往也是不均衡的。
数据的不均衡往往体现在两个方面一方面是一部分数据分区的体量过小而另一方面则是少数分区的体量极其庞大。AQE机制的自动分区合并与自动倾斜处理正是用来应对数据不均衡的这两个方面。
自动分区合并
了解了自动分区合并的用武之地接下来我们来说说Spark SQL具体如何做到把Reduce阶段过小的分区合并到一起。要弄清楚分区合并的工作原理我们首先得搞明白“分区合并从哪里开始又到哪里结束呢
具体来说Spark SQL怎么判断一个数据分区是不是足够小、它到底需不需要被合并再者既然是对多个分区做合并那么自然就存在一个收敛条件。原因很简单如果一直不停地合并下去那么整个数据集就被捏合成了一个超级大的分区并行度也会下降至1显然这不是我们想要的结果。
事实上Spark SQL采用了一种相对朴素的方法来实现分区合并。具体来说Spark SQL事先并不去判断哪些分区是不是足够小而是按照分区的编号依次进行扫描当扫描过的数据体量超过了“目标尺寸”时就进行一次合并。而这个目标尺寸由以下两个配置项来决定。
其中开发者可以通过第一个配置项spark.sql.adaptive.advisoryPartitionSizeInBytes来直接指定目标尺寸。第二个参数用于限制Reduce阶段在合并之后的并行度避免因为合并导致并行度过低造成CPU资源利用不充分。
结合数据集大小与最低并行度,我们可以反推出来每个分区的平均大小,假设我们把这个平均大小记作是#partitionSize。那么实际的目标尺寸取advisoryPartitionSizeInBytes设定值与#partitionSize之间较小的那个数值
确定了目标尺寸之后Spark SQL就会依序扫描数据分区当相邻分区的尺寸之和大于目标尺寸的时候Spark SQL就把扫描过的分区做一次合并。然后继续使用这种方式依次合并剩余的分区直到所有分区都处理完毕。
自动倾斜处理
没有对比就没有鉴别,分析完自动分区合并如何搞定数据分区过小、过于分散的问题之后,接下来,我们再来说一说,自动倾斜处理如何应对那些倾斜严重的大分区。
经过上面的分析我们不难发现自动分区合并实际上包含两个关键环节一个是确定合并的目标尺寸一个是依次扫描合并。与之相对应自动倾斜处理也分为两步第一步是检测并判定体量较大的倾斜分区第二步是把这些大分区拆分为小分区。要做到这两步Spark SQL需要依赖如下3个配置项。
其中前两个配置项用于判定倾斜分区第3个配置项advisoryPartitionSizeInBytes我们刚刚学过这个参数除了用于合并小分区外同时还用于拆分倾斜分区可以说是“一菜两吃”。
下面我们重点来讲一讲Spark SQL如何利用前两个参数来判定大分区的过程。
首先Spark SQL对所有数据分区按照存储大小做排序取中位数作为基数。然后将中位数乘以skewedPartitionFactor指定的比例系数得到判定阈值。凡是存储尺寸大于判定阈值的数据分区都有可能被判定为倾斜分区。
为什么说“有可能”而不是“一定”呢原因是倾斜分区的判定还要受到skewedPartitionThresholdInBytes参数的限制它是判定倾斜分区的最低阈值。也就是说只有那些尺寸大于skewedPartitionThresholdInBytes设定值的“候选分区”才会最终判定为倾斜分区。
为了更好地理解这个判定的过程我们来举个例子。假设数据表salaries有3个分区大小分别是90MB、100MB和512MB。显然这3个分区的中位数是100MB那么拿它乘以比例系数skewedPartitionFactor默认值为5得到判定阈值为100MB * 5 = 500MB。因此在咱们的例子中只有最后一个尺寸为512MB的数据分区会被列为“候选分区”。
接下来Spark SQL还要拿512MB与skewedPartitionThresholdInBytes作对比这个参数的默认值是256MB。
显然512MB比256MB要大得多这个时候Spark SQL才会最终把最后一个分区判定为倾斜分区。相反假设我们把skewedPartitionThresholdInBytes这个参数调大设置为1GB那么最后一个分区就不满足最低阈值因此也就不会被判定为倾斜分区。
倾斜分区判定完毕之后下一步就是根据advisoryPartitionSizeInBytes参数指定的目标尺寸对大分区进行拆分。假设我们把这个参数的值设置为256MB那么刚刚512MB的大分区就会被拆成两个小分区512MB / 2 = 256MB。拆分之后salaries表就由3个分区变成了4个分区每个数据分区的尺寸都不超过256MB。
重点回顾
好啦到此为止与Spark SQL相关的重要配置项我们就讲到这里。今天的内容很多我们一起来总结一下。
首先我们介绍了广播阈值这一概念它的作用在于当基表尺寸小于广播阈值时Spark SQL将自动选择Broadcast Join策略来完成关联计算。
然后我们分别介绍了AQEAdaptive Query Execution机制的3个特性分别是Join策略调整、自动分区合并、以及自动倾斜处理。与Spark SQL的静态优化机制不同AQE结合Shuffle中间文件提供的统计信息在运行时动态地调整执行计划从而达到优化作业执行性能的目的。
所谓Join策略调整它指的是结合过滤之后的基表尺寸与广播阈值Spark SQL在运行时动态地将原本的Shuffle Join策略调整为Broadcast Join策略的过程。基表尺寸的预估可以使用如下方法来获得。
val df: DataFrame = _
// 先对分布式数据集加Cache
df.cache.count
// 获取执行计划
val plan = df.queryExecution.logical
// 获取执行计划对于数据集大小的精确预估
val estimated: BigInt = spark
.sessionState
.executePlan(plan)
.optimizedPlan
.stats
.sizeInBytes
自动分区合并与自动倾斜处理实际上都是用来解决Shuffle过后数据分布不均匀的问题。自动分区合并的作用在于合并过小的数据分区从而避免Task粒度过细、任务调度开销过高的问题。与之相对自动倾斜处理它的用途在于拆分过大的数据分区从而避免个别Task负载过高而拖累整个作业的执行性能。
不论是广播阈值还是AQE的诸多特性我们都可以通过调节相关的配置项来影响Spark SQL的优化行为。为了方便你回顾、查找这些配置项我整理了如下表格供你随时参考。
每课一练
结合AQE必须要依赖Shuffle中间文件这一特点你能说一说AQE有什么不尽如人意之处吗提示从Shuffle的两个计算阶段出发去思考这个问题
欢迎你在留言区跟我交流讨论,也推荐你把这一讲分享给更多的同事、朋友。

View File

@ -0,0 +1,235 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
20 Hive + Spark强强联合分布式数仓的不二之选
你好,我是吴磊。
在数据源与数据格式以及数据转换那两讲第15、16讲我们介绍了在Spark SQL之上做数据分析应用开发的一般步骤。
这里我们简单回顾一下首先我们通过SparkSession read API从分布式文件系统创建DataFrame。然后通过创建临时表并使用SQL语句或是直接使用DataFrame API来进行各式各样的数据转换、过滤、聚合等操作。最后我们再用SparkSession的write API把计算结果写回分布式文件系统。
实际上直接与文件系统交互仅仅是Spark SQL数据应用的常见场景之一。Spark SQL另一类非常典型的场景是与Hive做集成、构建分布式数据仓库。我们知道数据仓库指的是一类带有主题、聚合层次较高的数据集合它的承载形式往往是一系列Schema经过精心设计的数据表。在数据分析这类场景中数据仓库的应用非常普遍。
在Hive与Spark这对“万金油”组合中Hive擅长元数据管理而Spark的专长是高效的分布式计算二者的结合可谓是“强强联合”。今天这一讲我们就来聊一聊Spark与Hive集成的两类方式一类是从Spark的视角出发我们称之为Spark with Hive而另一类则是从Hive的视角出发业界的通俗说法是Hive on Spark。
Hive架构与基本原理
磨刀不误砍柴工在讲解这两类集成方式之前我们不妨先花点时间来了解一下Hive的架构和工作原理避免不熟悉Hive的同学听得云里雾里。
Hive是Apache Hadoop社区用于构建数据仓库的核心组件它负责提供种类丰富的用户接口接收用户提交的SQL查询语句。这些查询语句经过Hive的解析与优化之后往往会被转化为分布式任务并交付Hadoop MapReduce付诸执行。
Hive是名副其实的“集大成者”它的核心部件其实主要是User Interface1和Driver3。而不论是元数据库4、存储系统5还是计算引擎6Hive都以“外包”、“可插拔”的方式交给第三方独立组件所谓“把专业的事交给专业的人去做”如下图所示。
Hive的User Interface为开发者提供SQL接入服务具体的接入途径有Hive Server 22、CLI和Web InterfaceWeb界面入口。其中CLI与Web Interface直接在本地接收SQL查询语句而Hive Server 2则通过提供JDBC/ODBC客户端连接允许开发者从远程提交SQL查询请求。显然Hive Server 2的接入方式更为灵活应用也更为广泛。
我们以响应一个SQL查询为例看一看Hive是怎样工作的。接收到SQL查询之后Hive的Driver首先使用其Parser组件将查询语句转化为ASTAbstract Syntax Tree查询语法树
紧接着Planner组件根据AST生成执行计划而Optimizer则进一步优化执行计划。要完成这一系列的动作Hive必须要能拿到相关数据表的元信息才行比如表名、列名、字段类型、数据文件存储路径、文件格式等等。而这些重要的元信息通通存储在一个叫作“Hive Metastore”4的数据库中。
本质上Hive Metastore其实就是一个普通的关系型数据库RDBMS它可以是免费的MySQL、Derby也可以是商业性质的Oracle、IBM DB2。实际上除了用于辅助SQL语法解析、执行计划的生成与优化Metastore的重要作用之一是帮助底层计算引擎高效地定位并访问分布式文件系统中的数据源。
这里的分布式文件系统可以是Hadoop生态的HDFS也可以是云原生的Amazon S3。而在执行方面Hive目前支持3类计算引擎分别是Hadoop MapReduce、Tez和Spark。
当Hive采用Spark作为底层的计算引擎时我们就把这种集成方式称作“Hive on Spark”。相反当Spark仅仅是把Hive当成是一种元信息的管理工具时我们把Spark与Hive的这种集成方式叫作“Spark with Hive”。
你可能会觉得很困惑“这两种说法听上去差不多嘛两种集成方式到底有什么本质的不同呢”接下来我们就按照“先易后难”的顺序先来说说“Spark with Hive”这种集成方式然后再去介绍“Hive on Spark”。
Spark with Hive
在开始正式学习Spark with Hive之前我们先来说说这类集成方式的核心思想。前面我们刚刚说过Hive Metastore利用RDBMS来存储数据表的元信息如表名、表类型、表数据的Schema、表分区数据的存储路径、以及存储格式等等。形象点说Metastore就像是“户口簿”它记录着分布式文件系统中每一份数据集的“底细”。
Spark SQL通过访问Hive Metastore这本“户口簿”即可扩充数据访问来源。而这就是Spark with Hive集成方式的核心思想。直白点说在这种集成模式下Spark是主体Hive Metastore不过是Spark用来扩充数据来源的辅助工具。厘清Spark与Hive的关系有助于我们后面区分Hive on Spark与Spark with Hive之间的差异。
作为开发者我们可以通过3种途径来实现Spark with Hive的集成方式它们分别是
创建SparkSession访问本地或远程的Hive Metastore
通过Spark内置的spark-sql CLI访问本地Hive Metastore
通过Beeline客户端访问Spark Thrift Server。
SparkSession + Hive Metastore
为了更好地理解Hive与Spark的关系我们先从第一种途径也就是通过SparkSession访问Hive Metastore说起。首先我们使用如下命令来启动Hive Metastore。
hive --service metastore
Hive Metastore启动之后我们需要让Spark知道Metastore的访问地址也就是告诉他数据源的“户口簿”藏在什么地方。
要传递这个消息我们有两种办法。一种是在创建SparkSession的时候通过config函数来明确指定hive.metastore.uris参数。另一种方法是让Spark读取Hive的配置文件hive-site.xml该文件记录着与Hive相关的各种配置项其中就包括hive.metastore.uris这一项。把hive-site.xml拷贝到Spark安装目录下的conf子目录Spark即可自行读取其中的配置内容。
接下来我们通过一个小例子来演示第一种用法。假设Hive中有一张名为“salaries”的薪资表每条数据都包含id和salary两个字段表数据存储在HDFS那么在spark-shell中敲入下面的代码我们即可轻松访问Hive中的数据表。
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.DataFrame
val hiveHost: String = _
// 创建SparkSession实例
val spark = SparkSession.builder()
.config("hive.metastore.uris", s"thrift://hiveHost:9083")
.enableHiveSupport()
.getOrCreate()
// 读取Hive表创建DataFrame
val df: DataFrame = spark.sql(“select * from salaries”)
df.show
/** 结果打印
+---+------+
| id|salary|
+---+------+
| 1| 26000|
| 2| 30000|
| 4| 25000|
| 3| 20000|
+---+------+
*/
在[第16讲]我们讲过利用createTempView函数从数据文件创建临时表的方法临时表创建好之后我们就可以使用SparkSession的sql API来提交SQL查询语句。连接到Hive Metastore之后咱们就可以绕过第一步直接使用sql API去访问Hive中现有的表是不是很方便
更重要的是createTempView函数创建的临时表它的生命周期仅限于Spark作业内部这意味着一旦作业执行完毕临时表也就不复存在没有办法被其他应用复用。Hive表则不同它们的元信息已经持久化到Hive Metastore中不同的作业、应用、甚至是计算引擎如Spark、Presto、Impala等等都可以通过Hive Metastore来访问Hive表。
总结下来在SparkSession + Hive Metastore这种集成方式中Spark对于Hive的访问仅仅涉及到Metastore这一环节对于Hive架构中的其他组件Spark并未触及。换句话说在这种集成方式中Spark仅仅是“白嫖”了Hive的Metastore拿到数据集的元信息之后Spark SQL自行加载数据、自行处理如下图所示。
在第一种集成方式下通过sql API你可以直接提交复杂的SQL语句也可以在创建DataFrame之后再使用第16讲提到的各种算子去实现业务逻辑。
spark-sql CLI + Hive Metastore
不过你可能会说“既然是搭建数仓那么能不能像使用普通数据库那样直接输入SQL查询绕过SparkSession的sql API呢
答案自然是肯定的接下来我们就来说说Spark with Hive的第二种集成方式spark-sql CLI + Hive Metastore。与spark-shell、spark-submit类似spark-sql也是Spark内置的系统命令。将配置好hive.metastore.uris参数的hive-site.xml文件放到Spark安装目录的conf下我们即可在spark-sql中直接使用SQL语句来查询或是处理Hive表。
显然在这种集成模式下Spark和Hive的关系与刚刚讲的SparkSession + Hive Metastore一样本质上都是Spark通过Hive Metastore来扩充数据源。
不过相比前者spark-sql CLI的集成方式多了一层限制那就是在部署上spark-sql CLI与Hive Metastore必须安装在同一个计算节点。换句话说spark-sql CLI只能在本地访问Hive Metastore而没有办法通过远程的方式来做到这一点。
在绝大多数的工业级生产系统中不同的大数据组件往往是单独部署的Hive与Spark也不例外。由于Hive Metastore可用于服务不同的计算引擎如前面提到的Presto、Impala因此为了减轻节点的工作负载Hive Metastore往往会部署到一台相对独立的计算节点。
在这样的背景下不得不说spark-sql CLI本地访问的限制极大地削弱了它的适用场景这也是spark-sql CLI + Hive Metastore这种集成方式几乎无人问津的根本原因。不过这并不妨碍我们学习并了解它这有助于我们对Spark与Hive之间的关系加深理解。
Beeline + Spark Thrift Server
说到这里你可能会追问“既然spark-sql CLI有这样那样的限制那么还有没有其他集成方式既能够部署到生产系统又能让开发者写SQL查询呢”答案自然是“有”Spark with Hive集成的第三种途径就是使用Beeline客户端去连接Spark Thrift Server从而完成Hive表的访问与处理。
Beeline原本是Hive客户端通过JDBC接入Hive Server 2。Hive Server 2可以同时服务多个客户端从而提供多租户的Hive查询服务。由于Hive Server 2的实现采用了Thrift RPC协议框架因此很多时候我们又把Hive Server 2称为“Hive Thrift Server 2”。
通过Hive Server 2接入的查询请求经由Hive Driver的解析、规划与优化交给Hive搭载的计算引擎付诸执行。相应地查询结果再由Hiver Server 2返还给Beeline客户端如下图右侧的虚线框所示。
Spark Thrift Server脱胎于Hive Server 2在接收查询、多租户服务、权限管理等方面这两个服务端的实现逻辑几乎一模一样。它们最大的不同在于SQL查询接入之后的解析、规划、优化与执行。
我们刚刚说过Hive Server 2的“后台”是Hive的那套基础架构。而SQL查询在接入到Spark Thrift Server之后它首先会交由Spark SQL优化引擎进行一系列的优化。
在第14讲我们提过借助于Catalyst与Tungsten这对“左膀右臂”Spark SQL对SQL查询语句先后进行语法解析、语法树构建、逻辑优化、物理优化、数据结构优化、以及执行代码优化等等。然后Spark SQL将优化过后的执行计划交付给Spark Core执行引擎付诸运行。
不难发现SQL查询在接入Spark Thrift Server之后的执行路径与DataFrame在Spark中的执行路径是完全一致的。
理清了Spark Thrift Server与Hive Server 2之间的区别与联系之后接下来我们来说说Spark Thrift Server的启动与Beeline的具体用法。要启动Spark Thrift Server我们只需调用Spark提供的start-thriftserver.sh脚本即可。
// SPARK_HOME环境变量指向Spark安装目录
cd $SPARK_HOME/sbin
// 启动Spark Thrift Server
./start-thriftserver.sh
脚本执行成功之后Spark Thrift Server默认在10000端口监听JDBC/ODBC的连接请求。有意思的是关于监听端口的设置Spark复用了Hive的hive.server2.thrift.port参数。与其他的Hive参数一样hive.server2.thrift.port同样要在hive-site.xml配置文件中设置。
一旦Spark Thrift Server启动成功我们就可以在任意节点上通过Beeline客户端来访问该服务。在客户端与服务端之间成功建立连接Connections之后咱们就能在Beeline客户端使用SQL语句处理Hive表了。需要注意的是在这种集成模式下SQL语句背后的优化与计算引擎是Spark。
/**
用Beeline客户端连接Spark Thrift Server
其中hostname是Spark Thrift Server服务所在节点
*/
beeline -u “jdbc:hive2://hostname:10000”
好啦到此为止Spark with Hive这类集成方式我们就讲完了。
为了巩固刚刚学过的内容咱们趁热打铁一起来做个简单的小结。不论是SparkSession + Hive Metastore、spark-sql CLI + Hive Metastore还是Beeline + Spark Thrift ServerSpark扮演的角色都是执行引擎而Hive的作用主要在于通过Metastore提供底层数据集的元数据。不难发现在这类集成方式中Spark唱“主角”而Hive唱“配角”。
Hive on Spark
说到这里你可能会好奇“对于Hive社区与Spark社区来说大家都是平等的那么有没有Hive唱主角而Spark唱配角的时候呢”还真有这就是Spark与Hive集成的另一种形式Hive on Spark。
基本原理
在这一讲的开头我们简单介绍了Hive的基础架构。Hive的松耦合设计使得它的Metastore、底层文件系统、以及执行引擎都是可插拔、可替换的。
在执行引擎方面Hive默认搭载的是Hadoop MapReduce但它同时也支持Tez和Spark。所谓的“Hive on Spark”实际上指的就是Hive采用Spark作为其后端的分布式执行引擎如下图所示。
从用户的视角来看使用Hive on MapReduce或是Hive on Tez与使用Hive on Spark没有任何区别执行引擎的切换对用户来说是完全透明的。不论Hive选择哪一种执行引擎引擎仅仅负责任务的分布式计算SQL语句的解析、规划与优化通通由Hive的Driver来完成。
为了搭载不同的执行引擎Hive还需要做一些简单的适配从而把优化过的执行计划“翻译”成底层计算引擎的语义。
举例来说在Hive on Spark的集成方式中Hive在将SQL语句转换为执行计划之后还需要把执行计划“翻译”成RDD语义下的DAG然后再把DAG交付给Spark Core付诸执行。从第14讲到现在我们一直在强调Spark SQL除了扮演数据分析子框架的角色之外还是Spark新一代的优化引擎。
在Hive on Spark这种集成模式下Hive与Spark衔接的部分是Spark Core而不是Spark SQL这一点需要我们特别注意。这也是为什么相比Hive on SparkSpark with Hive的集成在执行性能上会更胜一筹。毕竟Spark SQL + Spark Core这种原装组合相比Hive Driver + Spark Core这种适配组合在契合度上要更高一些。
集成实现
分析完原理之后接下来我们再来说说Hive on Spark的集成到底该怎么实现。
首先既然我们想让Hive搭载Spark那么我们事先得准备好一套完备的Spark部署。对于Spark的部署模式Hive不做任何限定Spark on Standalone、Spark on Yarn或是Spark on Kubernetes都是可以的。
Spark集群准备好之后我们就可以通过修改hive-site.xml中相关的配置项来轻松地完成Hive on Spark的集成如下表所示。
其中hive.execution.engine用于指定Hive后端执行引擎可选值有“mapreduce”、“tez”和“spark”显然将该参数设置为“spark”即表示采用Hive on Spark的集成方式。
确定了执行引擎之后接下来我们自然要告诉Hive“Spark集群部署在哪里”spark.master正是为了实现这个目的。另外为了方便Hive调用Spark的相关脚本与Jar包我们还需要通过spark.home参数来指定Spark的安装目录。
配置好这3个参数之后我们就可以用Hive SQL向Hive提交查询请求而Hive则是先通过访问Metastore在Driver端完成执行计划的制定与优化然后再将其“翻译”为RDD语义下的DAG最后把DAG交给后端的Spark去执行分布式计算。
当你在终端看到“Hive on Spark”的字样时就证明Hive后台的执行引擎确实是Spark如下图所示。
当然除了上述3个配置项以外Hive还提供了更多的参数用于微调它与Spark之间的交互。对于这些参数你可以通过访问Hive on Spark配置项列表来查看。不仅如此在第12讲我们详细介绍了Spark自身的基础配置项这些配置项都可以配置到hive-site.xml中方便你更细粒度地控制Hive与Spark之间的集成。
重点回顾
好啦,到此为止,今天的内容就全部讲完啦!内容有点多,我们一起来做个总结。
今天这一讲你需要了解Spark与Hive常见的两类集成方式Spark with Hive和Hive on Spark。前者由Spark社区主导以Spark为主、Hive为辅后者则由Hive社区主导以Hive为主、Spark为辅。两类集成方式各有千秋适用场景各有不同。
在Spark with Hive这类集成方式中Spark主要是利用Hive Metastore来扩充数据源从而降低分布式文件的管理与维护成本如路径管理、分区管理、Schema维护等等。
对于Spark with Hive我们至少有3种途径来实现Spark与Hive的集成分别是SparkSession + Hive Metastorespark-sql CLI + Hive Metastore和Beeline + Spark Thrift Server。对于这3种集成方式我把整理了表格供你随时查看。
与Spark with Hive相对另一类集成方式是Hive on Spark。这种集成方式本质上是Hive社区为Hive用户提供了一种新的选项这个选项就是在执行引擎方面除了原有的MapReduce与Tez开发者还可以选择执行性能更佳的Spark。
因此在Spark大行其道的当下习惯使用Hive的团队与开发者更愿意去尝试和采用Spark作为后端的执行引擎。
熟悉了不同集成方式的区别与适用场景之后在日后的工作中当你需要将Spark与Hive做集成的时候就可以做到有的放矢、有章可循加油。
每课一练
在Hive on Spark的部署模式下用另外一套Spark部署去访问Hive Metastore比如通过创建SparkSession并访问Hive Metastore来扩充数据源。那么在这种情况下你能大概说一说用户代码的执行路径吗
尽管咱们专栏的主题是Spark但我强烈建议你学习并牢记Hive的架构设计。松耦合的设计理念让Hive本身非常轻量的同时还给予了Hive极大的扩展能力。也正因如此Hive才能一直牢牢占据开源数仓霸主的地位。Hive的设计思想是非常值得我们好好学习的这样的设计思想可以推而广之应用到任何需要考虑架构设计的地方不论是前端、后端还是大数据与机器学习。
欢迎你在留言区跟我交流互动,也欢迎把这一讲的内容分享给更多同事、朋友。

View File

@ -0,0 +1,198 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
21 Spark UI如何高效地定位性能问题
你好,我是吴磊。
到目前为止我们完成了基础知识和Spark SQL这两个模块的学习这也就意味着我们完成了Spark入门“三步走”中的前两步首先恭喜你在学习的过程中我们逐渐意识到Spark Core与Spark SQL作为Spark并驾齐驱的执行引擎与优化引擎承载着所有类型的计算负载如批处理、流计算、数据分析、机器学习等等。
那么显然Spark Core与Spark SQL运行得是否稳定与高效决定着Spark作业或是应用的整体“健康状况”。不过在日常的开发工作中我们总会遇到Spark应用运行失败、或是执行效率未达预期的情况。对于这类问题想找到根本原因Root Cause我们往往需要依赖Spark UI来获取最直接、最直观的线索。
如果我们把失败的、或是执行低效的Spark应用看作是“病人”的话那么Spark UI中关于应用的众多度量指标Metrics就是这个病人的“体检报告”。结合多样的Metrics身为“大夫”的开发者即可结合经验来迅速地定位“病灶”。
今天这一讲,让我们以小汽车摇号中“倍率与中签率分析”的应用(详细内容你可以回顾[第13讲]为例用图解的方式一步步地去认识Spark UI看一看它有哪些关键的度量指标这些指标都是什么含义又能为开发者提供哪些洞察Insights
这里需要说明的是Spark UI的讲解涉及到大量的图解、代码与指标释义内容庞杂。因此为了减轻你的学习负担我按照Spark UI的入口类型一级入口、二级入口把Spark UI拆成了上、下两讲。一级入口比较简单、直接我们今天这一讲先来讲解这一部分二级入口的讲解留到下一讲去展开。
准备工作
在正式开始介绍Spark UI之前我们先来简单交代一下图解案例用到的环境、配置与代码。你可以参考这里给出的细节去复现“倍率与中签率分析”案例Spark UI中的每一个界面然后再结合今天的讲解以“看得见、摸得着”的方式去更加直观、深入地熟悉每一个页面与度量指标。
当然如果你手头一时没有合适的执行环境也不要紧。咱们这一讲的特点就是图多后面我特意准备了大量的图片和表格带你彻底了解Spark UI。
由于小汽车摇号数据体量不大,因此在计算资源方面,我们的要求并不高,“倍率与中签率分析”案例用到的资源如下所示:
接下来是代码,在[小汽车摇号应用开发]那一讲,我们一步步地实现了“倍率与中签率分析”的计算逻辑,这里咱们不妨一起回顾一下。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
// 申请者数据
val hdfs_path_apply: String = s"${rootPath}/apply"
// spark是spark-shell中默认的SparkSession实例
// 通过read API读取源文件
val applyNumbersDF: DataFrame = spark.read.parquet(hdfs_path_apply)
// 中签者数据
val hdfs_path_lucky: String = s"${rootPath}/lucky"
// 通过read API读取源文件
val luckyDogsDF: DataFrame = spark.read.parquet(hdfs_path_lucky)
// 过滤2016年以后的中签数据且仅抽取中签号码carNum字段
val filteredLuckyDogs: DataFrame = luckyDogsDF.filter(col("batchNum") >= "201601").select("carNum")
// 摇号数据与中签数据做内关联Join Key为中签号码carNum
val jointDF: DataFrame = applyNumbersDF.join(filteredLuckyDogs, Seq("carNum"), "inner")
// 以batchNum、carNum做分组统计倍率系数
val multipliers: DataFrame = jointDF.groupBy(col("batchNum"),col("carNum"))
.agg(count(lit(1)).alias("multiplier"))
// 以carNum做分组保留最大的倍率系数
val uniqueMultipliers: DataFrame = multipliers.groupBy("carNum")
.agg(max("multiplier").alias("multiplier"))
// 以multiplier倍率做分组统计人数
val result: DataFrame = uniqueMultipliers.groupBy("multiplier")
.agg(count(lit(1)).alias("cnt"))
.orderBy("multiplier")
result.collect
今天我们在此基础上做一点变化为了方便展示StorageTab页面内容我们这里“强行”给applyNumbersDF 和luckyDogsDF这两个DataFrame都加了Cache。对于引用数量为1的数据集实际上是没有必要加Cache的这一点还需要你注意。
回顾完代码之后再来看看配置项。为了让Spark UI能够展示运行中以及执行完毕的应用我们还需要设置如下配置项并启动History Server。
// SPARK_HOME表示Spark安装目录
${SPAK_HOME}/sbin/start-history-server.sh
好啦到此为止一切准备就绪。接下来让我们启动spark-shell并提交“倍率与中签率分析”的代码然后把目光转移到Host1的8080端口也就是Driver所在节点的8080端口。
Spark UI 一级入口
今天的故事要从Spark UI的入口开始其实刚才说的8080端口正是Spark UI的入口我们可以从这里进入Spark UI。
打开Spark UI首先映入眼帘的是默认的Jobs页面。Jobs页面记录着应用中涉及的Actions动作以及与数据读取、移动有关的动作。其中每一个Action都对应着一个Job而每一个Job都对应着一个作业。我们一会再去对Jobs页面做展开现在先把目光集中在Spark UI最上面的导航条这里罗列着Spark UI所有的一级入口如下图所示。
导航条最左侧是Spark Logo以及版本号后面则依次罗列着6个一级入口每个入口的功能与作用我整理到了如下的表格中你可以先整体过一下后面我们再挨个细讲。
形象点说这6个不同的入口就像是体检报告中6大类不同的体检项比如内科、外科、血常规等等。接下来让我们依次翻开“体检报告”的每一个大项去看看“倍率与中签率分析”这个家伙的体质如何。
不过本着由简入难的原则咱们并不会按照Spark UI罗列的顺序去查看各个入口而是按照Executors > Environment > Storage > SQL > Jobs > Stages的顺序去翻看“体检报告”。
其中前3个入口都是详情页不存在二级入口而后3个入口都是预览页都需要访问二级入口才能获取更加详细的内容。显然相比预览页详情页来得更加直接。接下来让我们从Executors开始先来了解一下应用的计算负载。
Executors
Executors Tab的主要内容如下主要包含“Summary”和“Executors”两部分。这两部分所记录的度量指标是一致的其中“Executors”以更细的粒度记录着每一个Executor的详情而第一部分“Summary”是下面所有Executors度量指标的简单加和。
我们一起来看一下Spark UI都提供了哪些Metrics来量化每一个Executor的工作负载Workload。为了叙述方便我们以表格的形式说明这些Metrics的含义与作用。
不难发现Executors页面清清楚楚地记录着每一个Executor消耗的数据量以及它们对CPU、内存与磁盘等硬件资源的消耗。基于这些信息我们可以轻松判断不同Executors之间是否存在负载不均衡的情况进而判断应用中是否存在数据倾斜的隐患。
对于Executors页面中每一个Metrics的具体数值它们实际上是Tasks执行指标在Executors粒度上的汇总。因此对于这些Metrics的释义咱们留到Stages二级入口再去展开这里暂时不做一一深入。你不妨结合“倍率与中签率分析”的应用去浏览一下不同Metrics的具体数值先对这些数字有一个直观上的感受。
实际上这些具体的数值并没有什么特别之处除了RDD Blocks和Complete Tasks这两个Metrics。细看一下这两个指标你会发现RDD Blocks是51总数而Complete Tasks总数是862。
之前讲RDD并行度的时候我们说过RDD并行度就是RDD的分区数量每个分区对应着一个Task因此RDD并行度与分区数量、分布式任务数量是一致的。可是截图中的51与862显然不在一个量级这是怎么回事呢
这里我先买个关子,把它给你留作思考题,你不妨花些时间,去好好想一想。如果没想清楚也没关系,我们在评论区会继续讨论这个问题。
Environment
接下来我们再来说说Environment。顾名思义Environment页面记录的是各种各样的环境变量与配置项信息如下图所示。
为了让你抓住主线我并没有给你展示Environment页面所包含的全部信息就类别来说它包含5大类环境信息为了方便叙述我把它们罗列到了下面的表格中。
显然这5类信息中Spark Properties是重点其中记录着所有在运行时生效的Spark配置项设置。通过Spark Properties我们可以确认运行时的设置与我们预期的设置是否一致从而排除因配置项设置错误而导致的稳定性或是性能问题。
Storage
说完Executors与Environment我们来看一级入口的最后一个详情页Storage。
Storage详情页记录着每一个分布式缓存RDD Cache、DataFrame Cache的细节包括缓存级别、已缓存的分区数、缓存比例、内存大小与磁盘大小。
在[第8讲]我们介绍过Spark支持的不同缓存级别它是存储介质内存、磁盘、存储形式对象、序列化字节与副本数量的排列组合。对于DataFrame来说默认的级别是单副本的Disk Memory Deserialized如上图所示也就是存储介质为内存加磁盘存储形式为对象的单一副本存储方式。
Cached Partitions与Fraction Cached分别记录着数据集成功缓存的分区数量以及这些缓存的分区占所有分区的比例。当Fraction Cached小于100%的时候,说明分布式数据集并没有完全缓存到内存(或是磁盘),对于这种情况,我们要警惕缓存换入换出可能会带来的性能隐患。
后面的Size in Memory与Size in Disk则更加直观地展示了数据集缓存在内存与硬盘中的分布。从上图中可以看到由于内存受限3GB/Executor摇号数据几乎全部被缓存到了磁盘只有584MB的数据缓存到了内存中。坦白地说这样的缓存对于数据集的重复访问并没有带来实质上的性能收益。
基于Storage页面提供的详细信息我们可以有的放矢地设置与内存有关的配置项如spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction从而有针对性对Storage Memory进行调整。
SQL
接下来我们继续说一级入口的SQL页面。当我们的应用包含DataFrame、Dataset或是SQL的时候Spark UI的SQL页面就会展示相应的内容如下图所示。
具体来说一级入口页面以Actions为单位记录着每个Action对应的Spark SQL执行计划。我们需要点击“Description”列中的超链接才能进入到二级页面去了解每个执行计划的详细信息。这部分内容我们留到下一讲的二级入口详情页再去展开。
Jobs
同理对于Jobs页面来说Spark UI也是以Actions为粒度记录着每个Action对应作业的执行情况。我们想要了解作业详情也必须通过“Description”页面提供的二级入口链接。你先有个初步认识就好下一讲我们再去展开。
相比SQL页面的3个Actionssave保存计算结果、count统计申请编号、count统计中签编号结合前面的概览页截图你会发现Jobs页面似乎凭空多出来很多Actions。
主要原因在于在Jobs页面Spark UI会把数据的读取、访问与移动也看作是一类“Actions”比如图中Job Id为0、1、3、4的那些。这几个Job实际上都是在读取源数据元数据与数据集本身
至于最后多出来的、Job Id为7的save你不妨结合最后一行代码去想想问什么。这里我还是暂时卖个关子留给你足够的时间去思考咱们评论区见。
result05_01.write.mode("Overwrite").format("csv").save(s"${rootPath}/results/result05_01")
Stages
我们知道每一个作业都包含多个阶段也就是我们常说的Stages。在Stages页面Spark UI罗列了应用中涉及的所有Stages这些Stages分属于不同的作业。要想查看哪些Stages隶属于哪个Job还需要从Jobs的Descriptions二级入口进入查看。
Stages页面更多地是一种预览要想查看每一个Stage的详情同样需要从“Description”进入Stage详情页下一讲详细展开
好啦到此为止对于导航条中的不同页面我们都做了不同程度的展开。简单汇总下来其中Executors、Environment、Storage是详情页开发者可以通过这3个页面迅速地了解集群整体的计算负载、运行环境以及数据集缓存的详细情况而SQL、Jobs、Stages更多地是一种罗列式的展示想要了解其中的细节还需要进入到二级入口。
正如开篇所说,二级入口的讲解,我们留到下一讲再去探讨,敬请期待。
重点回顾
好啦今天的课程到这里就讲完啦。今天的内容比较多涉及的Metrics纷繁而又复杂仅仅听一遍我的讲解还远远不够还需要你结合日常的开发去多多摸索与体会加油
今天这一讲我们从简单、直接的一级入口入手按照“Executors -> Environment -> Storage -> SQL -> Jobs -> Stages”的顺序先后介绍了一级入口的详情页与概览页。对于这些页面中的内容我把需要重点掌握的部分整理到了如下表格供你随时参考。
每课一练
今天的思考题我们在课程中已经提过了。一个是在Executors页面为什么RDD Blocks与Complete Tasks的数量不一致。第二个是在Jobs页面为什么最后会多出来一个save Action
欢迎你在留言区跟我交流探讨,也欢迎推荐你把这一讲分享给有需要的朋友、同事。

View File

@ -0,0 +1,187 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
22 Spark UI如何高效地定位性能问题
你好,我是吴磊。
在上一讲我们一起梳理了Spark UI的一级入口。其中Executors、Environment、Storage是详情页开发者可以通过这3个页面迅速地了解集群整体的计算负载、运行环境以及数据集缓存的详细情况。不过SQL、Jobs、Stages更多地是一种罗列式的展示想要了解其中的细节还需要进入到二级入口。
沿用之前的比喻,身为“大夫”的开发者想要结合经验,迅速定位“病灶”,离不开各式各样的指标项。而今天要讲的二级入口,相比一级入口,内容更加丰富、详尽。要想成为一名“临床经验丰富”的老医生,咱们先要做到熟练解读这些度量指标。
所谓二级入口它指的是通过一次超链接跳转才能访问到的页面。对于SQL、Jobs和Stages这3类入口来说二级入口往往已经提供了足够的信息基本覆盖了“体检报告”的全部内容。因此尽管Spark UI也提供了少量的三级入口需要两跳才能到达的页面但是这些隐藏在“犄角旮旯”的三级入口往往并不需要开发者去特别关注。
接下来我们就沿着SQL -> Jobs -> Stages的顺序依次地去访问它们的二级入口从而针对全局DAG、作业以及执行阶段获得更加深入的探索与洞察。
SQL详情页
在SQL Tab一级入口我们看到有3个条目分别是count统计申请编号、count统计中签编号和save。前两者的计算过程都是读取数据源、缓存数据并触发缓存的物化相对比较简单因此我们把目光放在save这个条目上。
点击图中的“save at:27”即可进入到该作业的执行计划页面如下图所示。
为了聚焦重点,这里我们仅截取了部分的执行计划,想要获取完整的执行计划,你可以通过访问这里来获得。为了方便你阅读,这里我手绘出了执行计划的示意图,供你参考,如下图所示。
可以看到“倍率与中签率分析”应用的计算过程非常具有代表性它涵盖了数据分析场景中大部分的操作也即过滤、投影、关联、分组聚合和排序。图中红色的部分为Exchange代表的是Shuffle操作蓝色的部分为Sort也就是排序而绿色的部分是Aggregate表示的是局部与全局的数据聚合。
无疑这三部分是硬件资源的主要消费者同时对于这3类操作Spark UI更是提供了详细的Metrics来刻画相应的硬件资源消耗。接下来咱们就重点研究一下这3类操作的度量指标。
Exchange
下图中并列的两个Exchange对应的是示意图中SortMergeJoin之前的两个Exchange。它们的作用是对申请编码数据与中签编码数据做Shuffle为数据关联做准备。
可以看到对于每一个ExchangeSpark UI都提供了丰富的Metrics来刻画Shuffle的计算过程。从Shuffle Write到Shuffle Read从数据量到处理时间应有尽有。为了方便说明对于Metrics的解释与释义我以表格的方式进行了整理供你随时查阅。
结合这份Shuffle的“体检报告”我们就能以量化的方式去掌握Shuffle过程的计算细节从而为调优提供更多的洞察与思路。
为了让你获得直观感受我还是举个例子说明。比方说我们观察到过滤之后的中签编号数据大小不足10MB7.4MB这时我们首先会想到对于这样的大表Join小表Spark SQL选择了SortMergeJoin策略是不合理的。
基于这样的判断我们完全可以让Spark SQL选择BroadcastHashJoin策略来提供更好的执行性能。至于调优的具体方法想必不用我多说你也早已心领神会要么用强制广播要么利用Spark 3.x版本提供的AQE特性。
你不妨结合本讲开头的代码去完成SortMergeJoin到BroadcastHashJoin策略转换的调优期待你在留言区分享你的调优结果。
Sort
接下来我们再来说说Sort。相比ExchangeSort的度量指标没那么多不过他们足以让我们一窥Sort在运行时对于内存的消耗如下图所示。
按照惯例我们还是先把这些Metrics整理到表格中方便后期查看。
可以看到“Peak memory total”和“Spill size total”这两个数值足以指导我们更有针对性地去设置spark.executor.memory、spark.memory.fraction、spark.memory.storageFraction从而使得Execution Memory区域得到充分的保障。
以上图为例结合18.8GB的峰值消耗以及12.5GB的磁盘溢出这两条信息我们可以判断出当前3GB的Executor Memory是远远不够的。那么我们自然要去调整上面的3个参数来加速Sort的执行性能。
Aggregate
与Sort类似衡量Aggregate的度量指标主要记录的也是操作的内存消耗如图所示。
可以看到对于Aggregate操作Spark UI也记录着磁盘溢出与峰值消耗即Spill size和Peak memory total。这两个数值也为内存的调整提供了依据以上图为例零溢出与3.2GB的峰值消耗证明当前3GB的Executor Memory设置对于Aggregate计算来说是绰绰有余的。
到此为止我们分别介绍了Exchange、Sort和Aggregate的度量指标并结合“倍率与中签率分析”的例子进行了简单的调优分析。
纵观“倍率与中签率分析”完整的DAG我们会发现它包含了若干个Exchange、Sort、Aggregate以及Filter和Project。结合上述的各类Metrics对于执行计划的观察与洞见我们需要以统筹的方式由点到线、由局部到全局地去进行。
Jobs详情页
接下来我们再来说说Jobs详情页。Jobs详情页非常的简单、直观它罗列了隶属于当前Job的所有Stages。要想访问每一个Stage的执行细节我们还需要通过“Description”的超链接做跳转。
Stages详情页
实际上要访问Stage详情我们还有另外一种选择那就是直接从Stages一级入口进入然后完成跳转。因此Stage详情页也归类到二级入口。接下来我们以Id为10的Stage为例去看一看详情页都记录着哪些关键信息。
在所有二级入口中Stage详情页的信息量可以说是最大的。点进Stage详情页可以看到它主要包含3大类信息分别是Stage DAG、Event Timeline与Task Metrics。
其中Task Metrics又分为“Summary”与“Entry details”两部分提供不同粒度的信息汇总。而Task Metrics中记录的指标类别还可以通过“Show Additional Metrics”选项进行扩展。
Stage DAG
接下来我们沿着“Stage DAG -> Event Timeline -> Task Metrics”的顺序依次讲讲这些页面所包含的内容。
首先我们先来看最简单的Stage DAG。点开蓝色的“DAG Visualization”按钮我们就能获取到当前Stage的DAG如下图所示。
之所以说Stage DAG简单是因为咱们在SQL二级入口已经对DAG做过详细的说明。而Stage DAG仅仅是SQL页面完整DAG的一个子集毕竟SQL页面的DAG针对的是作业Job。因此只要掌握了作业的DAG自然也就掌握了每一个Stage的DAG。
Event Timeline
与“DAG Visualization”并列在“Summary Metrics”之上有一个“Event Timeline”按钮点开它我们可以得到如下图所示的可视化信息。
Event Timeline记录着分布式任务调度与执行的过程中不同计算环节主要的时间花销。图中的每一个条带都代表着一个分布式任务条带由不同的颜色构成。其中不同颜色的矩形代表不同环节的计算时间。
为了方便叙述,我还是用表格形式帮你梳理了这些环节的含义与作用,你可以保存以后随时查看。
理想情况下,条带的大部分应该都是绿色的(如图中所示),也就是任务的时间消耗,大部分都是执行时间。不过,实际情况并不总是如此,比如,有些时候,蓝色的部分占比较多,或是橙色的部分占比较大。
在这些情况下我们就可以结合Event Timeline来判断作业是否存在调度开销过大、或是Shuffle负载过重的问题从而有针对性地对不同环节做调优。
比方说如果条带中深蓝的部分Scheduler Delay很多那就说明任务的调度开销很重。这个时候我们就需要参考公式D / P ~ M / C来相应地调整CPU、内存与并行度从而减低任务的调度开销。其中D是数据集尺寸P为并行度M是Executor内存而C是Executor的CPU核数。波浪线~表示的是,等式两边的数值,要在同一量级。
再比如如果条带中黄色Shuffle Write Time与橙色Shuffle Read Time的面积较大就说明任务的Shuffle负载很重这个时候我们就需要考虑有没有可能通过利用Broadcast Join来消除Shuffle从而缓解任务的Shuffle负担。
Task Metrics
说完Stage DAG与Event Timeline最后我们再来说一说Stage详情页的重头戏Task Metrics。
之所以说它是重头戏在于Task Metrics以不同的粒度提供了详尽的量化指标。其中“Tasks”以Task为粒度记录着每一个分布式任务的执行细节而“Summary Metrics”则是对于所有Tasks执行细节的统计汇总。我们先来看看粗粒度的“Summary Metrics”然后再去展开细粒度的“Tasks”。
Summary Metrics
首先我们点开“Show Additional Metrics”按钮勾选“Select All”让所有的度量指标都生效如下图所示。这么做的目的在于获取最详尽的Task执行信息。
可以看到“Select All”生效之后Spark UI打印出了所有的执行细节。老规矩为了方便叙述我还是把这些Metrics整理到表格中方便你随时查阅。其中Task Deserialization Time、Result Serialization Time、Getting Result Time、Scheduler Delay与刚刚表格中的含义相同不再赘述这里我们仅整理新出现的Task Metrics。
对于这些详尽的Task Metrics难能可贵地Spark UI以最大最小max、min以及分位点25%分位、50%分位、75%分位的方式提供了不同Metrics的统计分布。这一点非常重要原因在于这些Metrics的统计分布可以让我们非常清晰地量化任务的负载分布。
换句话说根据不同Metrics的统计分布信息我们就可以轻而易举地判定当前作业的不同任务之间是相对均衡还是存在严重的倾斜。如果判定计算负载存在倾斜那么我们就要利用AQE的自动倾斜处理去消除任务之间的不均衡从而改善作业性能。
在上面的表格中有一半的Metrics是与Shuffle直接相关的比如Shuffle Read Size / RecordsShuffle Remote Reads等等。
这些Metrics我们在介绍SQL详情的时候已经详细说过了。另外Duration、GC Time、以及Peak Execution Memory这些Metrics的含义要么已经讲过要么过于简单、无需解释。因此对于这3个指标咱们也不再多着笔墨。
这里特别值得你关注的是SpillMemory和SpillDisk这两个指标。Spill也即溢出数据它指的是因内存数据结构PartitionedPairBuffer、AppendOnlyMap等等空间受限而腾挪出去的数据。SpillMemory表示的是这部分数据在内存中的存储大小而SpillDisk表示的是这些数据在磁盘中的大小。
因此用SpillMemory除以SpillDisk就可以得到“数据膨胀系数”的近似值我们把它记为Explosion ratio。有了Explosion ratio对于一份存储在磁盘中的数据我们就可以估算它在内存中的存储大小从而准确地把握数据的内存消耗。
Tasks
介绍完粗粒度的Summary Metrics接下来我们再来说说细粒度的“Tasks”。实际上Tasks的不少指标与Summary是高度重合的如下图所示。同理这些重合的Metrics咱们不再赘述你可以参考Summary的部分来理解这些Metrics。唯一的区别就是这些指标是针对每一个Task进行度量的。
按照惯例咱们还是把Tasks中那些新出现的指标整理到表格中以备后续查看。
可以看到新指标并不多这里最值得关注的是Locality level也就是本地性级别。在调度系统中我们讲过每个Task都有自己的本地性倾向。结合本地性倾向调度系统会把Tasks调度到合适的Executors或是计算节点尽可能保证“数据不动、代码动”。
Logs与Errors属于Spark UI的三级入口它们是Tasks的执行日志详细记录了Tasks在执行过程中的运行时状态。一般来说我们不需要深入到三级入口去进行Debug。Errors列提供的报错信息往往足以让我们迅速地定位问题所在。
重点回顾
好啦今天的课程到这里就讲完啦。今天这一讲我们分别学习了二级入口的SQL、Jobs与Stages。每个二级入口的内容都很丰富提前知道它们所涵盖的信息对我们寻找、启发与探索性能调优的思路非常有帮助。
到此为止关于Spark UI的全部内容就讲完啦。Spark UI涉及的Metrics纷繁而又复杂一次性记住确实有难度所以通过这一讲你只要清楚各级入口怎么找到知道各个指标能给我们提供什么信息就好了。当然仅仅跟着我去用“肉眼”学习一遍只是第一步之后还需要你结合日常的开发去多多摸索与体会加油
最后的最后还是想提醒你由于我们的应用是通过spark-shell提交的因此节点8080端口的Spark UI会一直展示应用的“体检报告”。在我们退出spark-shell之后节点8080端口的内存也随即消失404 Page not found
要想再次查看应用的“体检报告”需要移步至节点的18080端口这里是Spark History Server的领地它收集了所有已执行完毕应用的“体检报告”并同样使用Spark UI的形式进行展示切记切记。
每课一练
今天的思考题需要你发散思维。学习过Spark UI之后请你说一说都可以通过哪些途径来定位数据倾斜问题
欢迎你把Spark UI使用的心得体会分享到课后的评论区我们一起讨论共同进步也推荐你把这一讲分享更多同事、朋友。

View File

@ -0,0 +1,276 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
23 Spark MLlib从“房价预测”开始
你好,我是吴磊。
从今天这一讲开始我们进入课程的第三个模块Spark MLlib机器学习。在数据科学、机器学习与人工智能火热的当下积累一些机器学习的知识储备有利于我们拓展视野甚至为职业发展提供新的支点。
在这个模块中我们首先从一个“房价预测”的小项目入手来初步了解机器学习以及Spark MLlib的基本用法。接下来我们会着重讲解机器学习的两个关键环节特征工程与模型调优在深入学习Spark MLlib的同时进一步优化“房价预测”的模型效果从而让房价的预测越来越准。
熟悉了关键环节之后我们再去探讨在Spark MLlib的框架之下高效构建机器学习流水线的一般方法。好啦话不多说让我们先来一起看看“房价预测”这个小项目吧。
为兼顾项目的权威性与代表性这里我选择了Kaggle数据科学竞赛平台的“House Prices - Advanced Regression Techniques”竞赛项目。这个项目的要求是给定房屋的79个属性特征以及历史房价训练房价预测模型并在测试集上验证模型的预测效果。
数据准备
虽然项目的要求相当清晰明了,不过你可能会说:“我没有机器学习背景,上面提到这些什么特征啊、模型啊,还有测试集、效果验证,我都没有概念,那接下来的课程,要怎么学呢?”别担心,随着课程的推进,我会逐渐把这些概念给你讲清楚。
接下来,我们先直观了解一下项目中的房屋数据。
房屋数据记录着美国爱荷华州2006年到2010年的房屋交易数据其中包含着79个房屋属性以及当时的成交价格你可以通过竞赛项目的data页面进行下载。
数据下载、解压之后我们会得到4个文件分别是data_description.txt、train.csv、test.csv和sample_submission.csv。这4个文件的体量很小总大小不超过5MB它们的内容与含义如下表所示。
其中train.csv与test.csv的Schema完全一致都包含79个房屋属性字段以及一个交易价格字段描述文件则详细地记录着79个字段的含义与取值范围。二者的唯一区别在于用途train.csv用于训练模型而test.csv用于验证模型效果。-
sample_submission.csv文件则用于提交比赛结果由于咱们暂时不打算参赛因此这个文件可以暂时忽略。
说到这里,我们又提到了与机器学习有关的一些术语,如“训练数据”、“测试数据”、“模型效果”,等等。为了照顾缺少机器学习背景的同学,接下来,我们对机器做一个简单的介绍。
机器学习简介
不过,在去正式介绍机器学习之前,我们不妨先来想一想人类学习的过程,然后再来看看,在学习这方面,机器与人类有哪些相似之处。
每个人在成长的过程中,或是通过书本,或是结合过往的经历,都在不断地吸取经验教训,从而总结出为人处世、待人接物的一般原则,然后再将这些原则应用到余下的人生中去。人类学习与成长的过程,大抵如此。
实际上,机器学习的过程也是类似的。基于历史数据,机器会根据一定的算法,尝试从历史数据中挖掘并捕捉出一般规律。然后,再把找到的规律应用到新产生的数据中,从而实现在新数据上的预测与判断。
好啦,对于机器学习有了基本的认知之后,接下来, 我们就给它下一个正式的定义,从而以更加严谨的方式,来认识机器学习。
所谓机器学习Machine Learning它指的是这样一种计算过程对于给定的训练数据Training samples选择一种先验的数据分布模型Models然后借助优化算法Learning Algorithms自动地持续调整模型参数Model Weights / Parameters从而让模型不断逼近训练数据的原始分布。
这个持续调整模型参数的过程称为“模型训练”Model Training。模型的训练依赖于优化算法基于过往的计算误差Loss优化算法以不断迭代的方式自动地对模型参数进行调整。由于模型训练是一个持续不断的过程那么自然就需要一个收敛条件Convergence Conditions来终结模型的训练过程。一旦收敛条件触发即宣告模型训练完毕。
模型训练完成之后我们往往会用一份新的数据集Testing samples去测试模型的预测能力从而验证模型的训练效果这个过程我们把它叫作“模型测试”Model Testing
说到这里,你的大脑可能快被各种各样的机器学习术语挤爆了,不要紧,我们结合房价预测的例子,来更好地理解这些概念。
回顾房价预测项目的4个数据文件其中的train.csv就是我们说的训练数据Training samples它用于训练机器学习模型。相应地test.csv是测试数据Testing samples它用于验证我们模型的训练效果。
更严谨地说测试数据用于考察模型的泛化能力Generalization也就是说对于一份模型从来没有“看见过”的数据我们需要知道模型的预测能力与它在训练数据上的表现是否一致。
train.csv和test.csv这两个文件的Schema完全一致都包含81个字段除了其中的79个房屋属性与1个交易价格外还包含一个ID字段。在房价预测这个项目中我们的任务是事先选定一个数据分布模型Models然后在训练数据上对它进行训练Model Training模型参数收敛之后再用训练好的模型去测试集上查看它的训练效果。
房价预测
理论总是没有实战来的更直接接下来我们就来借助Spark MLlib机器学习框架去完成“房价预测”这个机器学习项目的实现。与此同时随着项目的推进我们再结合具体实现来深入理解刚刚提到的基本概念与常用术语。
模型选型
那么都有哪些模型可供我们选择呢?对于房价预测的项目,我们又该选择其中哪一个呢?像这种如何挑选合适模型的问题,我们统一把它称作“模型选型”。
在机器学习领域,模型的种类非常多,不仅如此,模型的分类方法也各有不同。按照拟合能力来分类,有线性模型与非线性模型之分;按照预测标的来划分,有回归、分类、聚类、挖掘之分;按照模型复杂度来区分,模型可以分为经典算法与深度学习;按照模型结构来说,又可以分为广义线性模型、树模型、神经网络,等等。如此种种,不一而足。
不过咱们学习的重点是入门机器学习、入门Spark MLlib因此关于机器学习的模型与算法部分我们留到第24讲再去展开。在这里你只要知道有“模型选型”这回事就可以了。
在“房价预测”这个项目中我们的预测标的Label是房价而房价是连续的数值型字段因此我们需要回归模型Regression Model来拟合数据。再者在所有的模型中线性模型是最简单的因此本着由浅入深的原则在第一版的实现中咱们不妨选定线性回归模型Linear Regression来拟合房价与房屋属性之间的线性关系。
数据探索
要想准确地预测房价,我们得先确定,在与房屋相关的属性中,哪些因素对于房价的影响最大。在模型训练的过程中,我们需要选择那些影响较大的因素,而剔除那些影响较小的干扰项。
结合这里用到的例子,对房价来说,房屋的建筑面积一定是一个很重要的因素。相反,街道的路面类型(水泥路面、沥青路面还是方砖路面),对房价的影响就没那么重要了。
在机器学习领域中与预测标的相关的属性统称为“数据特征”Features而选择有效特征的过程我们称之为“特征选择”Features Selection。在做特性选择之前我们自然免不了先对数据做一番初步的探索才有可能得出结论。
具体的探索过程是这样的。首先我们使用SparkSession的read API从train.csv文件创建DataFrame然后调用show与printSchema函数来观察数据的样本构成与Schema。
由于数据字段较多不方便把打印出的数据样本和Schema堆放在文稿中因此这一步的探索我把它留给你试验你不妨把下面的代码敲入到spark-shell观察一下数据到底“长什么模样”。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
val filePath: String = s"${rootPath}/train.csv"
// 从CSV文件创建DataFrame
val trainDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
trainDF.show
trainDF.printSchema
通过观察数据,我们会发现房屋的属性非常丰富,包括诸如房屋建筑面积、居室数量、街道路面情况、房屋类型(公寓还是别墅)、基础设施(水、电、燃气)、生活周边(超市、医院、学校)、地基类型(砖混还是钢混)、地下室面积、地上面积、厨房类型(开放还是封闭)、车库面积与位置、最近一次交易时间,等等。
数据提取
按道理来说要遴选那些对房价影响较大的特征我们需要计算每一个特征与房价之间的相关性。不过在第一版的实现中咱们重点关注Spark MLlib的基本用法暂时不看重模型效果。
所以咱们不妨一切从简只选取那些数值型特征这类特征简单直接适合上手如建筑面积、地上面积、地下室面积和车库面积即”LotArea”“GrLivArea”“TotalBsmtSF”和”GarageArea”如下表所示。严谨的特征选择我们留到下一讲的特征工程再去展开。
import org.apache.spark.sql.types.IntegerType
// 提取用于训练的特征字段与预测标的房价SalePrice
val selectedFields: DataFrame = trainDF.select("LotArea", "GrLivArea", "TotalBsmtSF", "GarageArea", "SalePrice")
// 将所有字段都转换为整型Int
val typedFields = selectedFields
.withColumn("LotAreaInt",col("LotArea").cast(IntegerType)).drop("LotArea")
.withColumn("GrLivAreaInt",col("GrLivArea").cast(IntegerType)).drop("GrLivArea")
.withColumn("TotalBsmtSFInt",col("TotalBsmtSF").cast(IntegerType)).drop("TotalBsmtSF")
.withColumn("GarageAreaInt",col("GarageArea").cast(IntegerType)).drop("GarageArea")
.withColumn("SalePriceInt",col("SalePrice").cast(IntegerType)).drop("SalePrice")
typedFields.printSchema
/** 结果打印
root
|-- LotAreaInt: integer (nullable = true)
|-- GrLivAreaInt: integer (nullable = true)
|-- TotalBsmtSFInt: integer (nullable = true)
|-- GarageAreaInt: integer (nullable = true)
|-- SalePriceInt: integer (nullable = true)
*/
从CSV创建DataFrame所有字段的类型默认都是String而模型在训练的过程中只能消费数值型数据。因此我们这里还要做一下类型转换把所有字段都转换为整型。
准备训练样本
好啦数据准备就绪接下来我们就可以借助Spark MLlib框架开启机器学习的开发之旅。首先第一步我们把准备用于训练的多个特征字段捏合成一个特征向量Feature Vectors如下所示。
import org.apache.spark.ml.feature.VectorAssembler
// 待捏合的特征字段集合
val features: Array[String] = Array("LotAreaInt", "GrLivAreaInt", "TotalBsmtSFInt", "GarageAreaInt")
// 准备“捏合器”,指定输入特征字段集合,与捏合后的特征向量字段名
val assembler = new VectorAssembler().setInputCols(features).setOutputCol("features")
// 调用捏合器的transform函数完成特征向量的捏合
val featuresAdded: DataFrame = assembler.transform(typedFields)
.drop("LotAreaInt")
.drop("GrLivAreaInt")
.drop("TotalBsmtSFInt")
.drop("GarageAreaInt")
featuresAdded.printSchema
/** 结果打印
root
|-- SalePriceInt: integer (nullable = true)
|-- features: vector (nullable = true) // 注意features的字段类型是Vector
*/
捏合完特征向量之后我们就有了用于模型训练的训练样本Training Samples它包含两类数据一类正是特征向量features另一类是预测标的SalePriceInt。
接下来,我们把训练样本成比例地分成两份,一份用于模型训练,剩下的部分用于初步验证模型效果。
val Array(trainSet, testSet) = featuresAdded.randomSplit(Array(0.7, 0.3))
将训练样本拆分为训练集和验证集
模型训练
训练样本准备就绪接下来我们就可以借助Spark MLlib来构建线性回归模型了。实际上使用Spark MLlib构建并训练模型非常简单直接只需3个步骤即可搞定。
第一步是导入相关的模型库在Spark MLlib中线性回归模型由LinearRegression类实现。第二步是创建模型实例并指定模型训练所需的必要信息。第三步是调用模型的fit函数同时提供训练数据集开始训练。
import org.apache.spark.ml.regression.LinearRegression
// 构建线性回归模型,指定特征向量、预测标的与迭代次数
val lr = new LinearRegression()
.setLabelCol("SalePriceInt")
.setFeaturesCol("features")
.setMaxIter(10)
// 使用训练集trainSet训练线性回归模型
val lrModel = lr.fit(trainSet)
可以看到在第二步我们先是创建LinearRegression实例然后通过setLabelCol函数和setFeaturesCol函数来分别指定预测标的字段与特征向量字段也即“SalePriceInt”和“features”。紧接着我们调用setMaxIter函数来指定模型训练的迭代次数。
这里我有必要给你解释一下迭代次数这个概念。在前面介绍机器学习时我们提到模型训练是一个持续不断的过程训练过程会反复扫描同一份数据从而以迭代的方式一次又一次地更新模型中的参数Parameters也叫作权重Weights直到模型的预测效果达到一定的标准才能结束训练。
关于这个标准的制定,来自于两个方面。一方面是对于预测误差的要求,当模型的预测误差小于预先设定的阈值时,模型迭代即可收敛、结束训练。另一个方面就是对于迭代次数的要求,也就是说,不论预测误差是多少,只要达到了预先设定的迭代次数,模型训练即宣告结束。
说到这里,你可能会眉头紧锁:“又出现了些新概念,模型迭代、模型参数,模型的训练到底是一个什么样的过程呢?”为了让你更好地理解模型训练,我来给你举个生活化的例子。
实际上,机器学习中的模型训练,与我们生活中使用微波炉的过程别无二致。假设我们手头上有一款老式的微波炉,微波炉上只有两个旋钮,一个控制温度,另一个控制加热时长。
现在,我们需要烘烤一块馅饼,来当晚饭充饥。晚饭只有一块馅饼,听上去确实是惨了些,不过咱们对于口感的要求还是蛮高的,我们想要得到一块外面焦脆、里面柔嫩的馅饼。
如上图所示对于烹饪经验为0的我们来说想要得到一张烘烤完美的馅饼只能一次次地准备馅饼胚子、一次次把它们送进微波炉然后不断尝试不同的温度与时长组合直到烘焙出外焦里嫩的美味馅饼才会得到最佳的温度与时长组合。
在确定了成功的温度与时长组合之后,当我们需要再次烘烤其他类似食物(比如肉饼、披萨)的时候,就可以把它们送进微波炉,然后直接按下开启键就可以了。
模型训练也是类似的,我们一次次地把训练数据,“喂给”模型算法,一次次地调整模型参数,直到把预测误差降低到一定的范围、或是模型迭代达到一定的次数,即宣告训练结束。当有新的数据需要预测时,我们就把它喂给训练好的模型,模型就能生成预测结果。
不过与我们不停地手动调节“温度”与“时长”旋钮不同模型权重的调整依赖的往往是一种叫作“梯度下降”Gradient Descend的优化算法。在模型的每一次迭代中梯度下降算法会自动地调整模型权重而不需要人为的干预。这个优化算法咱们留到第24讲模型训练那里再展开。
不难发现,在上面馅饼烘焙这个生活化的例子中,相比模型训练,馅饼胚子实际上就是训练数据,微波炉就是模型算法,温度与时长就是模型参数,预测误差就是实际口感与期望口感之间的差距,而尝试的烘焙次数就是迭代次数。关于馅饼烘焙与模型训练的对比,我把它整理到了下图中,你可以看看。
熟悉了与模型训练相关的基本概念之后我们再来回顾一下刚刚的线性回归训练代码。除了表中的3个setXXX函数以外关于模型定义的更多选项你可以参考官网中的开发API来获取完整内容。模型定义好之后我们就可以通过调用fit函数来完成模型的训练过程。
import org.apache.spark.ml.regression.LinearRegression
// 构建线性回归模型,指定特征向量、预测标的与迭代次数
val lr = new LinearRegression()
.setLabelCol("SalePriceInt")
.setFeaturesCol("features")
.setMaxIter(10)
// 使用训练集trainSet训练线性回归模型
val lrModel = lr.fit(trainSet)
模型效果评估
模型训练好之后,我们需要对模型的效果进行验证、评估,才能判定模型的“好”、“坏”。这就好比,馅饼烤熟之后,我们得亲自尝一尝,才能知道它的味道跟我们期待的口感是否一致。
首先我们先来看看模型在训练集上的表现怎么样。在线性回归模型的评估中我们有很多的指标用来量化模型的预测误差。其中最具代表性的要数RMSERoot Mean Squared Error也就是均方根误差。我们可以通过在模型上调用summary函数来获取模型在训练集上的评估指标如下所示。
val trainingSummary = lrModel.summary
println(s"RMSE: ${trainingSummary.rootMeanSquaredError}")
/** 结果打印
RMSE: 45798.86
*/
在训练集的数据分布中房价的值域在34900755000之间因此45798.86的预测误差还是相当大的。这说明我们得到的模型,甚至没有很好地拟合训练数据。换句话说,训练得到的模型,处在一个“欠拟合”的状态。
这其实很好理解,一方面,咱们的模型过于简单,线性回归的拟合能力本身就非常有限。
再者在数据方面我们目前仅仅使用了4个字段LotAreaIntGrLivAreaIntTotalBsmtSFIntGarageAreaInt。房价影响因素众多仅用4个房屋属性是很难准确地预测房价的。所以在后面的几讲中我们还会继续深入研究特征工程与模型选型对于模型拟合能力的影响。
面对这种欠拟合的情况,我们自然还需要进一步调试、优化这个模型。在后续的几讲中,我们会分别从特征工程与模型调优这两个角度出发,去逐步完善我们的“房价预测”模型,拭目以待吧!
重点回顾
今天的内容比较多我们一起来做个总结。今天这一讲我们主要围绕着“房价预测”这个小项目分别介绍了机器学习的基本概念以及如何借助Spark MLlib框架完成机器学习开发。
首先你需要掌握机器学习是怎样的一个计算过程。所谓机器学习Machine Learning它指的是这样一种计算过程。对于给定的训练数据Training samples选择一种先验的数据分布模型Models然后借助优化算法Learning Algorithms自动地持续调整模型参数Model Weights / Parameters从而让模型不断逼近训练数据的原始分布。
然后在Spark MLlib子框架下你需要掌握机器学习开发的基本流程和关键步骤我把这些步骤整理到了如下的表格中方便你随时回顾。
今天这一讲我们采用了“机器学习基础知识”与“Spark MLlib开发流程”相交叉的方式来同时讲解机器学习本身与Spark MLlib子框架。对于机器学习背景较为薄弱的同学来说学习今天的内容可能有些挑战。
不过,你不用担心,对于本讲中挖下的“坑”,我们在后续的几讲中,都会陆续补上,力争让你系统掌握机器学习的开发方法与常规套路。
每日一练
请按照这一讲的行文顺序整理从加载数据到模型训练、模型评估的所有代码。然后请你从Kaggle数据科学竞赛平台的“House Prices - Advanced Regression Techniques”竞赛项目下载训练数据完成从数据加载到模型训练的整个过程。
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友,一起动手试试从数据加载到模型训练的整个过程。

View File

@ -0,0 +1,369 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
24 特征工程(上):有哪些常用的特征处理函数?
你好,我是吴磊。
在上一讲,我们一起构建了一个简单的线性回归模型,来预测美国爱荷华州的房价。从模型效果来看,模型的预测能力非常差。不过,事出有因,一方面线性回归的拟合能力有限,再者,我们使用的特征也是少的可怜。
要想提升模型效果,具体到我们“房价预测”的案例里就是把房价预测得更准,我们需要从特征和模型两个方面着手,逐步对模型进行优化。
在机器学习领域有一条尽人皆知的“潜规则”Garbage ingarbage out。它的意思是说当我们喂给模型的数据是“垃圾”的时候模型“吐出”的预测结果也是“垃圾”。垃圾是一句玩笑话实际上它指的是不完善的特征工程。
特征工程不完善的成因有很多,比如数据质量参差不齐、特征字段区分度不高,还有特征选择不到位、不合理,等等。
作为初学者,我们必须要牢记一点:特征工程制约着模型效果,它决定了模型效果的上限,也就是“天花板”。而模型调优,仅仅是在不停地逼近这个“天花板”而已。因此,提升模型效果的第一步,就是要做好特征工程。
为了减轻你的学习负担我把特征工程拆成了上、下两篇。我会用两讲的内容带你了解在Spark MLlib的开发框架下都有哪些完善特征工程的方法。总的来说我们需要学习6大类特征处理方法今天这一讲我们先来学习前3类下一讲再学习另外3类。
课程安排
打开Spark MLlib特征工程页面你会发现这里罗列着数不清的特征处理函数让人眼花缭乱。作为初学者看到这么长的列表更是会感到无所适从。
不过,你别担心,对于列表中的函数,结合过往的应用经验,我会从特征工程的视角出发,把它们分门别类地进行归类。
如图所示从原始数据生成可用于模型训练的训练样本这个过程又叫“特征工程”我们有很长的路要走。通常来说对于原始数据中的字段我们会把它们分为数值型Numeric和非数值型Categorical。之所以要这样区分原因在于字段类型不同处理方法也不同。
在上图中从左到右Spark MLlib特征处理函数可以被分为如下几类依次是
预处理
特征选择
归一化
离散化
Embedding
向量计算
除此之外Spark MLlib还提供了一些用于自然语言处理NLPNatural Language Processing的初级函数如图中左上角的虚线框所示。作为入门课这部分不是咱们今天的重点如果你对NLP感兴趣的话可以到官网页面了解详情。
我会从每个分类里各挑选一个最具代表性的函数(上图中字体加粗的函数),结合“房价预测”项目为你深入讲解。至于其他的处理函数,跟同一分类中我们讲到的函数其实是大同小异的。所以,只要你耐心跟着我学完这部分内容,自己再结合官网进一步探索其他处理函数时,也会事半功倍。
特征工程
接下来咱们就来结合上一讲的“房价预测”项目去探索Spark MLlib丰富而又强大的特征处理函数。
在上一讲我们的模型只用到了4个特征分别是”LotArea”“GrLivArea”“TotalBsmtSF”和”GarageArea”。选定这4个特征去建模意味着我们做了一个很强的先验假设房屋价格仅与这4个房屋属性有关。显然这样的假设并不合理。作为消费者在决定要不要买房的时候绝不会仅仅参考这4个房屋属性。
爱荷华州房价数据提供了多达79个房屋属性其中一部分是数值型字段如记录各种尺寸、面积、大小、数量的房屋属性另一部分是非数值型字段比如房屋类型、街道类型、建筑日期、地基类型等等。
显然房价是由这79个属性当中的多个属性共同决定的。机器学习的任务就是先找出这些“决定性”因素房屋属性然后再用一个权重向量模型参数来量化不同因素对于房价的影响。
预处理StringIndexer
由于绝大多数模型(包括线性回归模型)都不能直接“消费”非数值型数据,因此,咱们的第一步,就是把房屋属性中的非数值字段,转换为数值字段。在特征工程中,对于这类基础的数据转换操作,我们统一把它称为预处理。
我们可以利用Spark MLlib提供的StringIndexer完成预处理。顾名思义StringIndexer的作用是以数据列为单位把字段中的字符串转换为数值索引。例如使用StringIndexer我们可以把“车库类型”属性GarageType中的字符串转换为数字如下图所示。
StringIndexer的用法比较简单可以分为三个步骤
第一步实例化StringIndexer对象
第二步通过setInputCol和setOutputCol来指定输入列和输出列
第三步调用fit和transform函数完成数据转换。
接下来我们就结合上一讲的“房价预测”项目使用StringIndexer对所有的非数值字段进行转换从而演示并学习它的用法。
首先我们读取房屋源数据并创建DataFrame。
import org.apache.spark.sql.DataFrame
// 这里的下划线"_"是占位符,代表数据文件的根目录
val rootPath: String = _
val filePath: String = s"${rootPath}/train.csv"
val sourceDataDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
然后我们挑选出所有的非数值字段并使用StringIndexer对其进行转换。
// 导入StringIndexer
import org.apache.spark.ml.feature.StringIndexer
// 所有非数值型字段也即StringIndexer所需的“输入列”
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
// 非数值字段对应的目标索引字段也即StringIndexer所需的“输出列”
val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
// 将engineeringDF定义为var变量后续所有的特征工程都作用在这个DataFrame之上
var engineeringDF: DataFrame = sourceDataDF
// 核心代码循环遍历所有非数值字段依次定义StringIndexer完成字符串到数值索引的转换
for ((field, indexField) <- categoricalFields.zip(indexFields)) {
// 定义StringIndexer指定输入列名、输出列名
val indexer = new StringIndexer()
.setInputCol(field)
.setOutputCol(indexField)
// 使用StringIndexer对原始数据做转换
engineeringDF = indexer.fit(engineeringDF).transform(engineeringDF)
// 删除掉原始的非数值字段列
engineeringDF = engineeringDF.drop(field)
}
尽管代码看上去很多但我们只需关注与StringIndexer有关的部分即可。我们刚刚介绍了StringIndexer用法的三个步骤咱们不妨把这些步骤和上面的代码对应起来这样可以更加直观地了解StringIndexer的具体用法。
以“车库类型”GarageType字段为例我们先初始化一个StringIndexer实例。然后把GarageType传入给它的setInputCol函数。接着把GarageTypeIndex传入给它的setOutputCol函数。
注意GarageType是原始字段也就是engineeringDF这个DataFrame中原本就包含的数据列而GarageTypeIndex是StringIndexer即将生成的数据列目前的engineeringDF暂时还不包含这个字段。
最后我们在StringIndexer之上依次调用fit和transform函数来生成输出列这两个函数的参数都是待转换的DataFrame在我们的例子中这个DataFrame是engineeringDF。
转换完成之后你会发现engineeringDF中多了一个新的数据列也就是GarageTypeIndex这个字段。而这一列包含的数据内容就是与GarageType数据列对应的数值索引如下所示。
engineeringDF.select("GarageType", "GarageTypeIndex").show(5)
/** 结果打印
+----------+---------------+
|GarageType|GarageTypeIndex|
+----------+---------------+
| Attchd| 0.0|
| Attchd| 0.0|
| Attchd| 0.0|
| Detchd| 1.0|
| Attchd| 0.0|
+----------+---------------+
only showing top 5 rows
*/
可以看到转换之后GarageType字段中所有的“Attchd”都被映射为0而所有“Detchd”都被转换为1。实际上剩余的“CarPort”、“BuiltIn”等字符串也都被转换成了对应的索引值。
为了对DataFrame中所有的非数值字段都进行类似的处理我们使用for循环来进行遍历你不妨亲自动手去尝试运行上面的完整代码并进一步验证除GarageType以外的其他字段的转换也是符合预期的。
好啦到此为止我们以StringIndexer为例跑通了Spark MLlib的预处理环节拿下了特征工程的第一关恭喜你接下来我们再接再厉一起去挑战第二道关卡特征选择。
特征选择ChiSqSelector
特征选择,顾名思义,就是依据一定的标准,对特征字段进行遴选。
以房屋数据为例它包含了79个属性字段。在这79个属性当中不同的属性对于房价的影响程度是不一样的。显然像房龄、居室数量这类特征远比供暖方式要重要得多。特征选择就是遴选出像房龄、居室数量这样的关键特征然后进行建模而抛弃对预测标的房价无足轻重的供暖方式。
不难发现,在刚刚的例子中,我们是根据日常生活经验作为遴选特征字段的标准。实际上,面对数量众多的候选特征,业务经验往往是特征选择的重要出发点之一。在互联网的搜索、推荐与广告等业务场景中,我们都会尊重产品经理与业务专家的经验,结合他们的反馈来初步筛选出候选特征集。
与此同时,我们还会使用一些统计方法,去计算候选特征与预测标的之间的关联性,从而以量化的方式,衡量不同特征对于预测标的重要性。
统计方法在验证专家经验有效性的同时,还能够与之形成互补,因此,在日常做特征工程的时候,我们往往将两者结合去做特征选择。
业务经验因场景而异无法概述因此咱们重点来说一说可以量化的统计方法。统计方法的原理并不复杂本质上都是基于不同的算法如Pearson系数、卡方分布来计算候选特征与预测标的之间的关联性。不过你可能会问“我并不是统计学专业的做特征选择是不是还要先去学习这些统计方法呢
别担心其实并不需要。Spark MLlib框架为我们提供了多种特征选择器Selectors这些Selectors封装了不同的统计方法。要做好特征选择我们只需要搞懂Selectors该怎么用而不必纠结它背后使用的到底是哪些统计方法。
以ChiSqSelector为例它所封装的统计方法是卡方检验与卡方分布。即使你暂时还不清楚卡方检验的工作原理也并不影响我们使用ChiSqSelector来轻松完成特征选择。
接下来咱们还是以“房价预测”的项目为例说一说ChiSqSelector的用法与注意事项。既然是量化方法这就意味着Spark MLlib的Selectors只能用于数值型字段。要使用ChiSqSelector来选择数值型字段我们需要完成两步走
第一步使用VectorAssembler创建特征向量
第二步基于特征向量使用ChiSqSelector完成特征选择。
VectorAssembler原本属于特征工程中向量计算的范畴不过在Spark MLlib框架内很多特征处理函数的输入参数都是特性向量Feature Vector比如现在要讲的ChiSqSelector。因此这里我们先要对VectorAssembler做一个简单的介绍。
VectorAssembler的作用是把多个数值列捏合为一个特征向量。以房屋数据的三个数值列“LotFrontage”、“BedroomAbvGr”、“KitchenAbvGr”为例VectorAssembler可以把它们捏合为一个新的向量字段如下图所示。
VectorAssembler的用法很简单初始化VectorAssembler实例之后调用setInputCols传入待转换的数值字段列表如上图中的3个字段使用setOutputCol函数来指定待生成的特性向量字段如上图中的“features”字段。接下来我们结合代码来演示VectorAssembler的具体用法。
// 所有数值型字段共有27个
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
// 预测标的字段
val labelFields: Array[String] = Array("SalePrice")
import org.apache.spark.sql.types.IntegerType
// 将所有数值型字段转换为整型Int
for (field <- (numericFields ++ labelFields)) {
engineeringDF = engineeringDF.withColumn(s"${field}Int",col(field).cast(IntegerType)).drop(field)
}
import org.apache.spark.ml.feature.VectorAssembler
// 所有类型为Int的数值型字段
val numericFeatures: Array[String] = numericFields.map(_ + "Int").toArray
// 定义并初始化VectorAssembler
val assembler = new VectorAssembler()
.setInputCols(numericFeatures)
.setOutputCol("features")
// 在DataFrame应用VectorAssembler生成特征向量字段"features"
engineeringDF = assembler.transform(engineeringDF)
代码内容较多我们把目光集中到最下面的两行。首先我们定义并初始化VectorAssembler实例将包含有全部数值字段的数组numericFeatures传入给setInputCols函数并使用setOutputCol函数指定输出列名为“features”。然后通过调用VectorAssembler的transform函数完成对engineeringDF的转换。
转换完成之后engineeringDF就包含了一个字段名为“features”的数据列它的数据内容就是拼接了所有数值特征的特征向量。
好啦,特征向量准备完毕之后,我们就可以基于它来做特征选择了。还是先上代码。
import org.apache.spark.ml.feature.ChiSqSelector
import org.apache.spark.ml.feature.ChiSqSelectorModel
// 定义并初始化ChiSqSelector
val selector = new ChiSqSelector()
.setFeaturesCol("features")
.setLabelCol("SalePriceInt")
.setNumTopFeatures(20)
// 调用fit函数在DataFrame之上完成卡方检验
val chiSquareModel = selector.fit(engineeringDF)
// 获取ChiSqSelector选取出来的入选特征集合索引
val indexs: Array[Int] = chiSquareModel.selectedFeatures
import scala.collection.mutable.ArrayBuffer
val selectedFeatures: ArrayBuffer[String] = ArrayBuffer[String]()
// 根据特征索引值,查找数据列的原始字段名
for (index <- indexs) {
selectedFeatures += numericFields(index)
}
首先我们定义并初始化ChiSqSelector实例分别通过setFeaturesCol和setLabelCol来指定特征向量和预测标的。毕竟ChiSqSelector所封装的卡方检验需要将特征与预测标的进行关联才能量化每一个特征的重要性。
接下来对于全部的27个数值特征我们需要告诉ChiSqSelector要从中选出多少个进行建模。这里我们传递给setNumTopFeatures的参数是20也就是说ChiSqSelector需要帮我们从27个特征中挑选出对房价影响最重要的前20个特征。
ChiSqSelector实例创建完成之后我们通过调用fit函数对engineeringDF进行卡方检验得到卡方检验模型chiSquareModel。访问chiSquareModel的selectedFeatures变量即可获得入选特征的索引值再结合原始的数值字段数组我们就可以得到入选的原始数据列。
听到这里你可能已经有点懵了不要紧结合下面的示意图你可以更加直观地熟悉ChiSqSelector的工作流程。这里我们还是以“LotFrontage”、“BedroomAbvGr”、“KitchenAbvGr”这3个字段为例来进行演示。
可以看到对房价来说ChiSqSelector认为前两个字段比较重要而厨房个数没那么重要。因此在selectedFeatures这个数组中ChiSqSelector记录了0和1这两个索引分别对应着原始的“LotFrontage”和“BedroomAbvGr”这两个字段。
好啦到此为止我们以ChiSqSelector为代表学习了Spark MLlib框架中特征选择的用法打通了特征工程的第二关。接下来我们继续努力去挑战第三道关卡归一化。
归一化MinMaxScaler
归一化Normalization的作用是把一组数值统一映射到同一个值域而这个值域通常是[0, 1]。也就是说不管原始数据序列的量级是105还是10-5归一化都会把它们统一缩放到[0, 1]这个范围。
这么说可能比较抽象我们拿“LotArea”、“BedroomAbvGr”这两个字段来举例。其中“LotArea”的含义是房屋面积它的单位是平方英尺量级在105而“BedroomAbvGr”的单位是个数它的量级是101。
假设我们采用Spark MLlib提供的MinMaxScaler对房屋数据做归一化那么这两列数据都会被统一缩放到[0, 1]这个值域范围,从而抹去单位不同带来的量纲差异。
你可能会问:“为什么要做归一化呢?去掉量纲差异的动机是什么呢?原始数据它不香吗?”
原始数据很香,但原始数据的量纲差异不香。当原始数据之间的量纲差异较大时,在模型训练的过程中,梯度下降不稳定、抖动较大,模型不容易收敛,从而导致训练效率较差。相反,当所有特征数据都被约束到同一个值域时,模型训练的效率会得到大幅提升。关于模型训练与模型调优,我们留到下一讲再去展开,这里你先理解归一化的必要性即可。
既然归一化这么重要,那具体应该怎么实现呢?其实很简单,只要一个函数就可以搞定。
Spark MLlib支持多种多样的归一化函数如StandardScaler、MinMaxScaler等等。尽管这些函数的算法各有不同但效果都是一样的。
我们以MinMaxScaler为例看一看对于任意的房屋面积eiMinMaxScaler使用如下公式来完成对“LotArea”字段的归一化。
其中max和min分别是目标值域的上下限默认为1和0换句话说目标值域为[0, 1]。而Emax和Emin分别是“LotArea”这个数据列中的最大值和最小值。使用这个公式MinMaxScaler就会把“LotArea”中所有的数值都映射到[0, 1]这个范围。
接下来我们结合代码来演示MinMaxScaler的具体用法。
与很多特征处理函数如刚刚讲过的ChiSqSelector一样MinMaxScaler的输入参数也是特征向量因此MinMaxScaler的用法也分为两步走
第一步使用VectorAssembler创建特征向量
第二步基于特征向量使用MinMaxScaler完成归一化。
// 所有类型为Int的数值型字段
// val numericFeatures: Array[String] = numericFields.map(_ + “Int”).toArray
// 遍历每一个数值型字段
for (field <- numericFeatures) {
// 定义并初始化VectorAssembler
val assembler = new VectorAssembler()
.setInputCols(Array(field))
.setOutputCol(s”${field}Vector”)
// 调用transform把每个字段由Int转换为Vector类型
engineeringData = assembler.transform(engineeringData)
}
在第一步我们使用for循环遍历所有数值型字段依次初始化VectorAssembler实例把字段由Int类型转为Vector向量类型。接下来在第二步我们就可以把所有向量传递给MinMaxScaler去做归一化了。可以看到MinMaxScaler的用法与StringIndexer的用法很相似。
import org.apache.spark.ml.feature.MinMaxScaler
// 锁定所有Vector数据列
val vectorFields: Array[String] = numericFeatures.map(_ + "Vector").toArray
// 归一化后的数据列
val scaledFields: Array[String] = vectorFields.map(_ + "Scaled").toArray
// 循环遍历所有Vector数据列
for (vector <- vectorFields) {
// 定义并初始化MinMaxScaler
val minMaxScaler = new MinMaxScaler()
.setInputCol(vector)
.setOutputCol(s"${vector}Scaled")
// 使用MinMaxScaler完成Vector数据列的归一化
engineeringData = minMaxScaler.fit(engineeringData).transform(engineeringData)
}
首先我们创建一个MinMaxScaler实例然后分别把原始Vector数据列和归一化之后的数据列传递给函数setInputCol和setOutputCol。接下来依次调用fit与transform函数完成对目标字段的归一化。
这段代码执行完毕之后engineeringDataDataFrame就包含了多个后缀为“Scaled”的数据列这些数据列的内容就是对应原始字段的归一化数据如下所示。
好啦到此为止我们以MinMaxScaler为代表学习了Spark MLlib框架中数据归一化的用法打通了特征工程的第三关。
重点回顾
好啦,今天的内容讲完啦,我们一起来做个总结。今天这一讲,我们主要围绕特征工程展开,你需要掌握特征工程不同环节的特征处理方法,尤其是那些最具代表性的特征处理函数。
从原始数据到生成训练样本,特征工程可以被分为如下几个环节,我们今天重点讲解了其中的前三个环节,也就是预处理、特征选择和归一化。
针对不同环节Spark MLlib框架提供了丰富的特征处理函数。作为预处理环节的代表StringIndexer负责对非数值型特征做初步处理将模型无法直接消费的字符串转换为数值。
特征选择的动机,在于提取与预测标的关联度更高的特征,从而精简模型尺寸、提升模型泛化能力。特征选择可以从两方面入手,业务出发的专家经验和基于数据的统计分析。
Spark MLlib基于不同的统计方法提供了多样的特征选择器Feature Selectors其中ChiSqSelector以卡方检验为基础选择相关度最高的前N个特征。
归一化的目的在于去掉不同特征之间量纲的影响避免量纲不一致而导致的梯度下降震荡、模型收敛效率低下等问题。归一化的具体做法是把不同特征都缩放到同一个值域。在这方面Spark MLlib提供了多种归一化方法供开发者选择。
在下一讲我们将继续离散化、Embedding和向量计算这3个环节的学习最后还会带你整体看一下各环节优化过后的模型效果敬请期待。
每课一练
对于我们今天讲解的特征处理函数如StringIndexer、ChiSqSelector、MinMaxScaler你能说说它们之间的区别和共同点吗
欢迎你在留言区跟我交流互动,也推荐你把今天的内容转发给更多同事和朋友,跟他一起交流特征工程相关的内容。

View File

@ -0,0 +1,231 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
25 特征工程(下):有哪些常用的特征处理函数?
你好,我是吴磊。
在上一讲我们提到典型的特征工程包含如下几个环节即预处理、特征选择、归一化、离散化、Embedding和向量计算如下图所示。
在上一讲我们着重讲解了其中的前3个环节也就是预处理、特征选择和归一化。按照之前的课程安排今天这一讲咱们继续来说说剩下的离散化、Embedding与向量计算。
特征工程是机器学习的重中之重只要你耐心学下去必然会不虚此行。这一讲的最后我还会对应用了6种不同特征工程的模型性能加以对比帮你深入理解特征工程中不同环节的作用与效果。
特征工程
在上一讲,我们打卡到了“第三关”:归一化。因此,接下来,我们先从“第四关”:离散化说起。
离散化Bucketizer
与归一化一样离散化也是用来处理数值型字段的。离散化可以把原本连续的数值打散从而降低原始数据的多样性Cardinality。举例来说“BedroomAbvGr”字段的含义是居室数量在train.csv这份数据样本中“BedroomAbvGr”包含从1到8的连续整数。
现在,我们根据居室数量,把房屋粗略地划分为小户型、中户型和大户型。
不难发现“BedroomAbvGr”离散化之后数据多样性由原来的8降低为现在的3。那么问题来了原始的连续数据好好的为什么要对它做离散化呢离散化的动机主要在于提升特征数据的区分度与内聚性从而与预测标的产生更强的关联。
就拿“BedroomAbvGr”来说我们认为一居室和两居室对于房价的影响差别不大同样三居室和四居室之间对于房价的影响也是微乎其微。
但是小户型与中户型之间以及中户型与大户型之间房价往往会出现跃迁的现象。换句话说相比居室数量户型的差异对于房价的影响更大、区分度更高。因此把“BedroomAbvGr”做离散化处理目的在于提升它与预测标的之间的关联性。
那么在Spark MLlib的框架下离散化具体该怎么做呢与其他环节一样Spark MLlib提供了多个离散化函数比如Binarizer、Bucketizer和QuantileDiscretizer。我们不妨以Bucketizer为代表结合居室数量“BedroomAbvGr”这个字段来演示离散化的具体用法。老规矩还是先上代码为敬。
// 原始字段
val fieldBedroom: String = "BedroomAbvGrInt"
// 包含离散化数据的目标字段
val fieldBedroomDiscrete: String = "BedroomDiscrete"
// 指定离散区间,分别是[负无穷, 2]、[3, 4]和[5, 正无穷]
val splits: Array[Double] = Array(Double.NegativeInfinity, 3, 5, Double.PositiveInfinity)
import org.apache.spark.ml.feature.Bucketizer
// 定义并初始化Bucketizer
val bucketizer = new Bucketizer()
// 指定原始列
.setInputCol(fieldBedroom)
// 指定目标列
.setOutputCol(fieldBedroomDiscrete)
// 指定离散区间
.setSplits(splits)
// 调用transform完成离散化转换
engineeringData = bucketizer.transform(engineeringData)
不难发现Spark MLlib提供的特征处理函数在用法上大同小异。首先我们创建Bucketizer实例然后将数值型字段BedroomAbvGrInt作为参数传入setInputCol同时使用setOutputCol来指定用于保存离散数据的新字段BedroomDiscrete。
离散化的过程是把连续值打散为离散值但具体的离散区间如何划分还需要我们通过在setSplits里指定。离散区间由浮点型数组splits提供从负无穷到正无穷划分出了[负无穷, 2]、[3, 4]和[5, 正无穷]这三个区间。最终我们调用Bucketizer的transform函数对engineeringData做离散化。
离散化前后的数据对比,如下图所示。
好啦到此为止我们以Bucketizer为代表学习了Spark MLlib框架中数据离散化的用法轻松打通了特征工程的第四关。
Embedding
实际上Embedding是一个非常大的话题随着机器学习与人工智能的发展Embedding的方法也是日新月异、层出不穷。从最基本的热独编码到PCA降维从Word2Vec到Item2Vec从矩阵分解到基于深度学习的协同过滤可谓百花齐放、百家争鸣。更有学者提出“万物皆可Embedding”。那么问题来了什么是Embedding呢
Embedding是个英文术语如果非要找一个中文翻译对照的话我觉得“向量化”Vectorize最合适。Embedding的过程就是把数据集合映射到向量空间进而把数据进行向量化的过程。这句话听上去有些玄乎我换个更好懂的说法Embedding的目标就是找到一组合适的向量来刻画现有的数据集合。
以GarageType字段为例它有6个取值也就是说我们总共有6种车库类型。那么对于这6个字符串来说我们该如何用数字化的方式来表示它们呢毕竟模型只能消费数值不能直接消费字符串。
一种方法是采用预处理环节的StringIndexer把字符串转换为连续的整数然后让模型去消费这些整数。在理论上这么做没有任何问题。但从模型的效果出发整数的表达方式并不合理。为什么这么说呢
我们知道连续整数之间是存在比较关系的比如1 < 36 > 5等等。但是原始的字符串之间比如“Attchd”与“Detchd”并不存在大小关系如果强行用0表示“Attchd”、用1表示“Detchd”逻辑上就会出现“Attchd”<“Detchd”的悖论。
因此预处理环节的StringIndexer仅仅是把字符串转换为数字转换得到的数值是不能直接喂给模型做训练。我们需要把这些数字进一步向量化才能交给模型去消费。那么问题来了对于StringIndexer输出的数值我们该怎么对他们进行向量化呢这就要用到Embedding了。
作为入门课咱们不妨从最简单的热独编码One Hot Encoding开始去认识Embedding并掌握它的基本用法。我们先来说说热独编码是怎么一回事。相比照本宣科说概念咱们不妨以GarageType为例从示例入手你反而更容易心领神会。
首先通过StringIndexer我们把GarageType的6个取值分别映射为0到5的六个数值。接下来使用热独编码我们把每一个数值都转化为一个向量。
向量的维度为6与原始字段GarageType的多样性Cardinality保持一致。换句话说热独编码的向量维度就是原始字段的取值个数。
仔细观察上图的六个向量只有一个维度取值为1其他维度全部为0。取值为1的维度与StringIndexer输出的索引相一致。举例来说字符串“Attchd”被StringIndexer映射为0对应的热独向量是[1, 0, 0, 0, 0, 0]。向量中索引为0的维度取值为1其他维度全部取0。
不难发现热独编码是一种简单直接的Embedding方法甚至可以说是“简单粗暴”。不过在日常的机器学习开发中“简单粗暴”的热独编码却颇受欢迎。
接下来,我们还是从“房价预测”的项目出发,说一说热独编码的具体用法。
在预处理环节我们已经用StringIndexer把非数值字段全部转换为索引字段接下来我们再用OneHotEncoder把索引字段进一步转换为向量字段。
import org.apache.spark.ml.feature.OneHotEncoder
// 非数值字段对应的目标索引字段也即StringIndexer所需的“输出列”
// val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
// 热独编码的目标字段也即OneHotEncoder所需的“输出列”
val oheFields: Array[String] = categoricalFields.map(_ + "OHE").toArray
// 循环遍历所有索引字段,对其进行热独编码
for ((indexField, oheField) <- indexFields.zip(oheFields)) {
val oheEncoder = new OneHotEncoder()
.setInputCol(indexField)
.setOutputCol(oheField)
engineeringData= oheEncoder.transform(engineeringData)
}
可以看到我们循环遍历所有非数值特征依次创建OneHotEncoder实例。在实例初始化的过程中我们把索引字段传入给setInputCol函数把热独编码目标字段传递给setOutputCol函数。最终通过调用OneHotEncoder的transform在engineeringData之上完成转换。
好啦到此为止我们以OneHotEncoder为代表学习了Spark MLlib框架中Embedding的用法初步打通了特征工程的第五关。
尽管还有很多其他Embedding方法需要我们进一步探索不过从入门的角度来说OneHotEncoder完全可以应对大部分机器学习应用。
向量计算
打通第五关之后,特征工程“这套游戏”还剩下最后一道关卡:向量计算。
向量计算作为特征工程的最后一个环节主要用于构建训练样本中的特征向量Feature Vectors。在Spark MLlib框架下训练样本由两部分构成第一部分是预测标的Label在“房价预测”的项目中Label是房价。
而第二部分就是特征向量在形式上特征向量可以看作是元素类型为Double的数组。根据前面的特征工程流程图我们不难发现特征向量的构成来源多种多样比如原始的数值字段、归一化或是离散化之后的数值字段、以及向量化之后的特征字段等等。
Spark MLlib在向量计算方面提供了丰富的支持比如前面介绍过的、用于集成特征向量的VectorAssembler用于对向量做剪裁的VectorSlicer以元素为单位做乘法的ElementwiseProduct等等。灵活地运用这些函数我们可以随意地组装特征向量从而构建模型所需的训练样本。
在前面的几个环节中预处理、特征选择、归一化、离散化、Embedding我们尝试对数值和非数值类型特征做各式各样的转换目的在于探索可能对预测标的影响更大的潜在因素。
接下来我们使用VectorAssembler将这些潜在因素全部拼接在一起、构建特征向量从而为后续的模型训练准备好训练样本。
import org.apache.spark.ml.feature.VectorAssembler
/**
入选的数值特征selectedFeatures
归一化的数值特征scaledFields
离散化的数值特征fieldBedroomDiscrete
热独编码的非数值特征oheFields
*/
val assembler = new VectorAssembler()
.setInputCols(selectedFeatures ++ scaledFields ++ fieldBedroomDiscrete ++ oheFields)
.setOutputCol("features")
engineeringData = assembler.transform(engineeringData)
转换完成之后engineeringData这个DataFrame就包含了一列名为“features”的新字段这个字段的内容就是每条训练样本的特征向量。接下来我们就可以像上一讲那样通过setFeaturesCol和setLabelCol来指定特征向量与预测标的定义出线性回归模型。
// 定义线性回归模型
val lr = new LinearRegression()
.setFeaturesCol("features")
.setLabelCol("SalePriceInt")
.setMaxIter(100)
// 训练模型
val lrModel = lr.fit(engineeringData)
// 获取训练状态
val trainingSummary = lrModel.summary
// 获取训练集之上的预测误差
println(s"Root Mean Squared Error (RMSE) on train data: ${trainingSummary.rootMeanSquaredError}")
好啦,到此为止,我们打通了特征工程所有关卡,恭喜你!尽管不少关卡还有待我们进一步去深入探索,但这并不影响我们从整体上把握特征工程,构建结构化的知识体系。对于没讲到的函数与技巧,你完全可以利用自己的碎片时间,借鉴这两节课我给你梳理的学习思路,来慢慢地将它们补齐,加油!
通关奖励:模型效果对比
学习过VectorAssembler的用法之后你会发现特征工程任一环节的输出都可以用来构建特征向量从而用于模型训练。在介绍特征工程的部分我们花了大量篇幅介绍不同环节的作用与用法。
你可能会好奇:“这些不同环节的特征处理,真的会对模型效果有帮助吗?毕竟,折腾了半天,我们还是要看模型效果的”。
没错,特征工程的最终目的,是调优模型效果。接下来,通过将不同环节输出的训练样本喂给模型,我们来对比不同特征处理方法对应的模型效果。
不同环节对应的代码地址如下:
调优对比基准-
特征工程-调优1-
特征工程-调优2-
特征工程-调优3-
特征工程-调优4-
特征工程-调优5-
特征工程-调优6
可以看到,随着特征工程的推进,模型在训练集上的预测误差越来越小,这说明模型的拟合能力越来越强,而这也就意味着,特征工程确实有助于模型性能的提升。
对应特征工程不同环节的训练代码,我整理到了最后的“代码地址”那一列。强烈建议你动手运行这些代码,对比不同环节的特征处理方法,以及对应的模型效果。
当然,我们在评估模型效果的时候,不能仅仅关注它的拟合能力,更重要的是模型的泛化能力。拟合能力强,只能说明模型在训练集上的预测误差足够小;而泛化能力,量化的是模型在测试集上的预测误差。换句话说,泛化能力的含义是,模型在一份“未曾谋面”的数据集上表现如何。
这一讲,咱们的重点是特征工程,因此暂时忽略了模型在测试集上的表现。从下一讲的模型训练开始,对于模型效果,我们将同时关注模型这两方面的能力:拟合与泛化。
重点回顾
好啦今天的内容讲完啦我们一起来做个总结。今天这一讲我们主要围绕着特征工程中的离散化、Embedding和向量计算展开你需要掌握其中最具代表性的特征处理函数。
到此为止Spark MLlib特征工程中涉及的6大类特征处理函数我们就都讲完了。为了让你对他们有一个整体上的把握同时能够随时回顾不同环节的作用与效果我把每一个大类的特点、以及咱们讲过的处理函数都整理到了如下的表格中供你参考。
今天的内容很多需要我们多花时间去消化。受2/8理论的支配在机器学习实践中特征工程往往会花费我们80%的时间和精力。由于特征工程制约着模型效果的上限,因此,尽管特征工程的步骤繁多、过程繁琐,但是我们千万不能在这个环节偷懒,一定要认真对待。
这也是为什么我们分为上、下两部分来着重讲解特征工程,从概览到每一个环节,从每一个环节的作用到它包含的具体方法。数据质量构筑了模型效果的天花板,特征工程道阻且长,然而行则将至,让我们一起加油!
每课一练
结合上一讲对于我们介绍过的所有特征处理函数如StringIndexer、ChiSqSelector、MinMaxScaler、Bucketizer、OneHotEncoder和VectorAssembler你能说说他们之间的区别和共同点吗
欢迎你在留言区记录你的收获与思考,也欢迎你向更多同事、朋友分享今天的内容,说不定就能帮他解决特征工程方面的问题。

View File

@ -0,0 +1,133 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
26 模型训练(上):决策树系列算法详解
你好,我是吴磊。
在上一讲我们重点介绍了机器学习中的特征工程以及Spark MLlib框架支持的特征处理函数。基于线性回归模型我们对比了不同特征处理方法下的模型效果。一般来说线性模型的模型容量比较有限它仅适合拟合特征向量与预测标的之间存在线性关系的场景。
但在实际应用中,线性关系少之又少,就拿“房价预测”的项目来说,不同的房屋属性与房价之间,显然不是单纯的线性关系。这也是为什么在房价预测的任务上,线性回归模型的预测误差一直高居不下。因此,为了提升房价预测的准确度,我们有必要从模型选型的角度,着手去考虑采用其他类型的模型算法,尤其是非线性模型。
Spark MLlib框架支持种类丰富的模型算法为了在减轻你学习负担的同时尽量全面地覆盖其中的内容我把模型训练分为了上、中、下三讲。今天这一讲我们专注在决策树系列算法的讲解。
后面两讲我再结合房屋预测和电影推荐场景带你在实践中掌握Spark MLlib模型算法从而让你在不同的场景下得心应手地开展模型选型与模型调优。
课程安排
因为模型训练的部分内容比较丰富为了让你有一个清晰的学习计划咱们还是先来交代一下课程安排。在机器学习领域如果按照“样本是否存在预测标的Label”为标准机器学习问题可以分为监督学习Supervised Learning与非监督学习Unsupervised Learning。Spark MLlib同时支持这两大类机器学习算法如下图所示。
可以看到在Spark MLlib开发框架下按照使用场景不同监督学习又被细分为回归Regression、分类Classification和协同过滤Collaborative Filtering而非监督学习被细分为聚类Clustering与频繁项集Frequency Patterns
不同的分类下Spark MLlib支持的模型算法多样而又庞杂。如果逐一讲解每种算法的原理和用法不但枯燥乏味而且容易遗忘。所以对于每个分类我都会精选一个最具代表性的算法再结合实例进行讲解这样你学完之后印象会更加深刻。
与5个子分类相对应模型训练课程的实例也有5个分别是房价预测、房屋分类、电影推荐1、房屋聚类、电影推荐2。根据数据来源的不同这5个实例又可以分为两类如下图所示。
为了照顾基础薄弱的同学我们需要先搞清楚决策树、GBDTGradient-boosted Decision Trees和RFRandom Forest这些前置知识。学完这节课之后你会发现一个很有趣的现象这些知识点背后的原理跟人类的决策过程惊人的相似但相比人类经验机器又能青出于蓝。
好啦,让我们正式开始今天的学习。
决策树系列算法
马上就是“双十一”了,你可能很想血拼一把,但一摸自己的钱包,理智又占领了高地。试想一下,预算有限的情况下,你会如何挑选一款手机呢?我们往往会结合价位、品牌、评价等一系列因素考量,最后做出决策。
其实这个依据不同决定性因素来构建决策路径的过程,放在机器学习里,就是决策树。接下来,我们用严谨一点的术语再描述一下什么是决策树。
决策树Decision Trees是一种根据样本特征向量而构建的树形结构。决策树由节点Nodes与有向边Vertexes组成其中节点又分为两类一类是内部节点一类是叶子节点。内部节点表示的是样本特征而叶子节点代表分类。
举例来说假设我们想根据“居室数量”和“房屋面积”这两个特征把房屋分为5类。那么我们就可以构建一个决策树来做到这一点如下图所示。
其中椭圆形代表的是内部节点每个内部节点都包含一个特征并同时拥有两条有向边。每条有向边都表示一组特征取值。比方说图中决策树的根节点顶端的内部节点包含的特征是“居室数量”左边的有向边表示的是居室数量小于4的数据样本而右边的有向边代表的是居室数量大于等于4的数据样本。
就这样原始的房屋样本被一分为二按照居室数量被“劈”成了两份。“劈”到左侧的样本继续按照“房屋面积”是否小于6作区分而“劈”到右侧的样本则按照“房屋面积”是否小于10来做进一步的区分。就这样根据不同特征的不同取值范围数据样本一层一层地被加以区分直到圆形节点也即叶子节点为止。
叶子节点表示数据样本的分类图中的5个圆形即代表5个叶子节点。每个叶子节点中都包含若干的数据样本显然掉落到同一个叶子节点的样本同属于一个分类。
不难发现在上面的决策树中结合“居室数量”和“房屋面积”这两个特征的不同取值原始的数据样本被划分成了“不重不漏”的5份子集如下图所示。
基于这5份样本子集我们就有能力去解决分类或是回归问题。假设数据样本中的标签列Label是“房屋质量”数据样本按照房屋质量的取值被分为差、一般、好、很好和极好。
决策树中的5个叶子节点对应的就是Label的5个不同取值。因此凡是掉落在蓝色圆形节点的样本它的房屋质量都是“差”同理凡是掉落在黄色圆形节点的样本对应的房屋质量都是“极好”。如此一来我们便按照“房屋质量”完成了对原始样本的分类过程。
实际上回归过程也是类似的。如果数据样本中的标签不再是离散的“房屋质量”而是连续的“房屋价格”那么我们同样可以利用决策树来完成回归预测。假设我们用100条数据样本来构建上面的决策树并假设每个叶子节点都包含20条数据样本。
那么当有一条新的数据样本需要预测房价的时候我们只需要让它遍历决策树然后看看它掉落到哪一个叶子节点中去。假设它掉落到了Set3这个节点那么要预测这条样本的房价我们就取Set3中那20条样本的房价均值。
好啦,到此为止,我们介绍了什么是决策树,怎么用决策树来预测新的数据样本。不难发现,决策树的推理过程,与人类的决策过程非常相似。
人类也常常“货比三家”,结合生活经验,根据一些关键因素做出决策。说到这里,你可能会好奇:“我做决定的时候,往往是结合生活经验,那么模型算法是依据什么,来构建决策树的呢?它怎么知道,哪些特征是决定性因素,而哪些特征又没什么用呢?”
用一句话来概括数据样本的纯度决定了模型算法选择哪些特征作为内部节点同时也决定着决策树何时收敛。所谓样本纯度简单地说就是标签的多样性Cardinality。对于一个集合中的样本如果样本的标签都一样也即标签的多样性为1那么我们就说这个集合的样本纯度很高。
相反,如果这个集合中的样本标签取值非常多,多样性非常高,那么我们就说这个集合的样本纯度很低。在数学上,我们可以用信息熵来量化样本的纯度(或者说标签多样性),不过作为入门课,咱们暂时不必深究,只要从概念上理解样本的纯度就好。
模型算法在构建决策树的时候,会去遍历每一个特征,并考察每个特征的“提纯”能力。所谓“提纯”,就是把原始样本结合特征进行区分之后,两个样本子集在纯度上有所提升。换句话说,经过候选特征分割后的样本子集,其纯度越高,就代表候选特征的“提纯”能力越高。
正是基于这样的逻辑模型算法依次筛选“提纯”能力最高、次高、第三高的特征逐级地去构建决策树直到收敛为止。对于收敛条件一方面我们可以人为地设置纯度阈值另一方面我们也可以通过设定树的深度Depth、Levels来进行限制。
在理想情况下我们期望决策树每个叶子节点的纯度尽可能地接近于0用信息熵来量化也即每个节点的标签都是一样的。但在实际工作中我们很难做到这一点。不仅如此一般来说一棵决策树的拟合能力是相当有限的它很难把样本的纯度提升得足够高。
这时就要说到GBDTGradient-boosted Decision Trees和RFRandom Forest这两种算法了尽管它们的设计思想各不相同但本质上都是为了进一步提升数据样本的纯度。
Random Forest
Random Forest又叫“随机森林”它的设计思想是“三个臭皮匠、赛过诸葛亮”。既然一棵树的拟合能力有限那么就用多棵树来“凑数儿”毕竟老话说得好人多出韩信。
举例来说,我们想结合多个特征,来对房屋质量进行分类。对于给定的数据样本,随机森林算法会训练多棵决策树,树与树之间是相互独立的,彼此之间不存在任何依赖关系。对于每一棵树,算法会随机选择部分样本与部分特征,来进行决策树的构建,这也是随机森林命名中“随机”一词的由来。
以上图为例随机森林算法构建了3棵决策树第一棵用到了“居室数量”和“房屋面积”这两个特征而第二棵选择了“建筑年龄”、“装修情况”和“房屋类型”三个特征最后一棵树选择的是“是否带泳池”、“房屋面积”、“装修情况”和“厨房数量”四个特征。
每棵树都把遍历的样本分为5个类别每个类别都包含部分样本。当有新的数据样本需要预测房屋质量时我们把数据样本同时“喂给”随机森林的3棵树预测结果取决于3棵树各自的输出结果。
假设样本经过第一棵树的判别之后掉落在了Set3经过第二棵树的“决策”之后掉落在了Set2而经过第三棵树的判定之后归类到了Set3那么样本最终的预测结果就是Set3。也即按照“少数服从多数”的原则随机森林最终的预测结果会取所有决策树结果中的大多数。回归问题也是类似最简单的办法就是取所有决策树判定结果的均值。
GBDT
接下来我们再说说GBDTGradient-boosted Decision Trees。与随机森林类似GBDT也是用多棵决策树来拟合数据样本但是树与树之间是有依赖关系的每一棵树的构建都是基于前一棵树的训练结果。因此与随机森林不同GBDT的设计思想是“站在前人的肩膀上看得更远”如下图所示。
具体来说在GBDT的训练过程中每一棵树的构建都是基于上一棵树输出的“样本残差”。如下图所示预测值与真实值Ground Truth之间的差值即是样本残差。后面决策树的拟合目标不再是原始的房屋价格而是这个样本残差。
以此类推后续的决策树都会基于上一棵树的残差去做拟合从而使得预测值与真实值之间的误差越来越小并最终趋近于0。不难发现只要GBDT训练的决策树足够多预测误差就可以足够小因此GBDT的拟合能力是非常强的。
不过与此同时我们要提防GBDT的过拟合问题在训练集上过分拟合往往会导致模型在测试集上的表现不尽如人意。解决过拟合的思路就是让模型由复杂变得简单要做到这一点我们可以通过限制决策树的数量与深度来降低GBDT模型的复杂度。
好啦到此为止我们学习了决策树以及由决策树衍生的随机森林与GBDT算法。光说不练假把式在下一讲我们就以房价预测和房屋分类为例体会一下在Spark MLlib的框架下具体要如何应用这些算法解决实际问题。
重点回顾
好啦,到此为止,我们今天的内容就全部讲完啦。让我们一起来做个总结。
首先你需要知道Spark MLlib开发框架都支持哪些模型算法我把这些模型算法、以及算法的分类整理到了下面的脑图中供你随时参考。
你需要掌握决策树系列算法的特点与基本原理。其中决策树系列算法既可以用于解决分类问题也可以解决回归问题。相比线性模型树模型拥有更强的非线性拟合能力而且树模型具备良好的可解释性它的工作原理非常符合人类的思考方式。随机森林与GBDT是衍生自决策树的两类集成类算法。
随机森林的设计思想是“三个臭皮匠、赛过诸葛亮”,通过在多棵树上随机选取训练样本与特征,随机森林将多个简单模型集成在一起,用投票的方式共同来决定最终的预测结果。
而GBDT的思想是“站在前人的肩膀上看得更远”它也是基于多棵树的集成模型。与随机森林不同在GBDT中树与树之间是存在依赖关系的。每一棵树的训练都是基于前一棵树拟合的样本残差从而使得预测值不断地逼近真实值。GBDT的特点是拟合能力超强但同时要注意决策树过深、过多而带来的过拟合隐患。
每课一练
结合今天的课程内容你能说说GBDT与Random Forest模型算法各自的优缺点吗
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。

View File

@ -0,0 +1,247 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
27 模型训练(中):回归、分类和聚类算法详解
你好,我是吴磊。
在上一讲我们学习了决策树系列算法包括决策树、GBDT和随机森林。今天这一讲我们来看看在Spark MLlib框架下如何将这些算法应用到实际的场景中。
你还记得我们给出的Spark MLlib模型算法“全景图”么对于这张“全景图”我们会时常回顾它。一方面它能为我们提供“全局视角”再者有了它我们就能够轻松地把学习过的内容对号入座从而对于学习的进展做到心中有数。
今天这一讲我们会结合房屋预测场景一起学习回归、分类与聚类中的典型算法在Spark MLlib框架下的具体用法。掌握这些用法之后针对同一类机器学习问题回归、分类或是聚类你就可以在其算法集合中灵活、高效地做算法选型。
房屋预测场景
在这个场景中我们有3个实例分别是房价预测、房屋分类和房屋聚类。房价预测我们并不陌生在前面的学习中我们一直在尝试把房价预测得更准。
房屋分类它指的是给定离散标签Label如“OverallQual”房屋质量结合房屋属性特征将所有房屋分类到相应的标签取值如房屋质量的“好、中、差”三类。
而房屋聚类,它指的是,在不存在标签的情况下,根据房屋特征向量,结合“物以类聚”的思想,将相似的房屋聚集到一起,形成聚类。
房价预测
在特征工程的两讲中我们一直尝试使用线性模型来拟合房价但线性模型的拟合能力相当有限。决策树系列模型属于非线性模型在拟合能力上更胜一筹。经过之前的讲解想必你对Spark MLlib框架下模型训练的“套路”已经了然于胸模型训练基本上可以分为3个环节
准备训练样本
定义模型,并拟合训练数据
验证模型效果
除了模型定义第一个与第三个环节实际上是通用的。不论我们采用哪种模型训练样本其实都大同小异度量指标不论是用于回归的RMSE还是用于分类的AUC本身也与模型无关。因此今天这一讲我们把重心放在第二个环节对于代码实现我们在文稿中也只粘贴这一环节的代码其他环节的代码你可以参考特征工程的两讲的内容。
[上一讲]我们学过了决策树系列模型及其衍生算法也就是随机森林与GBDT算法。这两种算法既可以解决分类问题也可以用来解决回归问题。既然GBDT擅长拟合残差那么我们不妨用它来解决房价预测的回归问题而把随机森林留给后面的房屋分类。
要用GBDT来拟合房价我们首先还是先来准备训练样本。
// numericFields代表数值字段indexFields为采用StringIndexer处理后的非数值字段
val assembler = new VectorAssembler()
.setInputCols(numericFields ++ indexFields)
.setOutputCol("features")
// 创建特征向量“features”
engineeringDF = assembler.transform(engineeringDF)
import org.apache.spark.ml.feature.VectorIndexer
// 区分离散特征与连续特征
val vectorIndexer = new VectorIndexer()
.setInputCol("features")
.setOutputCol("indexedFeatures")
// 设定区分阈值
.setMaxCategories(30)
// 完成数据转换
engineeringDF = vectorIndexer.fit(engineeringDF).transform(engineeringDF)
我们之前已经学过了VectorAssembler的用法它用来把多个字段拼接为特征向量。你可能已经发现在VectorAssembler之后我们使用了一个新的特征处理函数对engineeringDF进一步做了转换这个函数叫作VectorIndexer。它是用来干什么的呢
简单地说它用来帮助决策树系列算法如GBDT、随机森林区分离散特征与连续特征。连续特征也即数值型特征数值之间本身是存在大小关系的。而离散特征如街道类型在经过StringIndexer转换为数字之后数字与数字之间会引入原本并不存在的大小关系具体你可以回看[第25讲])。
这个问题要怎么解决呢首先对于经过StringIndexer处理过的离散特征VectorIndexer会进一步对它们编码抹去数字之间的比较关系从而明确告知GBDT等算法该特征为离散特征数字与数字之间相互独立不存在任何关系。
VectorIndexer对象的setMaxCategories方法用于设定阈值该阈值用于区分离散特征与连续特征我们这里设定的阈值为30。这个阈值有什么用呢凡是多样性Cardinality大于30的特征后续的GBDT模型会把它们看作是连续特征而多样性小于30的特征GBDT会把它们当作是离散特征来进行处理。
说到这里,你可能会问:“对于一个特征,区分它是连续的、还是离散的,有这么重要吗?至于这么麻烦吗?”
还记得在决策树基本原理中,特征的“提纯”能力这个概念吗?对于同样一份数据样本,同样一个特征,连续值与离散值的“提纯”能力可能有着天壤之别。还原特征原本的“提纯”能力,将为决策树的合理构建,打下良好的基础。
好啦样本准备好之后接下来我们就要定义并拟合GBDT模型了。
import org.apache.spark.ml.regression.GBTRegressor
// 定义GBDT模型
val gbt = new GBTRegressor()
.setLabelCol("SalePriceInt")
.setFeaturesCol("indexedFeatures")
// 限定每棵树的最大深度
.setMaxDepth(5)
// 限定决策树的最大棵树
.setMaxIter(30)
// 区分训练集、验证集
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 拟合训练数据
val gbtModel = gbt.fit(trainingData)
可以看到我们通过定义GBTRegressor来定义GBDT模型其中setLabelCol、setFeaturesCol都是老生常谈的方法了不再赘述。值得注意的是setMaxDepth和setMaxIter这两个方法用于避免GBDT模型出现过拟合的情况前者限定每棵树的深度而后者直接限制了GBDT模型中决策树的总体数目。后面的训练过程依然是调用模型的fit方法。
到此为止我们介绍了如何通过定义GBDT模型来拟合房价。后面的效果评估环节鼓励你结合[第23讲]的模型验证部分,去自行尝试,加油!
房屋分类
接下来我们再来说说房屋分类。我们知道在“House Prices - Advanced Regression Techniques”竞赛项目中数据集总共有79个字段。在之前我们一直把售价SalePrice当作是预测标的也就是Label而用其他字段构建特征向量。
现在我们来换个视角把房屋质量OverallQual看作是Label让售价SalePrice作为普通字段去参与构建特征向量。在房价预测的数据集中房屋质量是离散特征它的取值总共有10个如下图所示。
如此一来我们就把先前的回归问题预测连续值转换成了分类问题预测离散值。不过不管是什么机器学习问题模型训练都离不开那3个环节
准备训练样本
定义模型,并拟合训练数据
验证模型效果
在训练样本的准备上除了把预测标的从SalePrice替换为OverallQual我们完全可以复用刚刚使用GBDT来预测房价的代码实现。
// Label字段"OverallQual"
val labelField: String = "OverallQual"
import org.apache.spark.sql.types.IntegerType
engineeringDF = engineeringDF
.withColumn("indexedOverallQual", col(labelField).cast(IntegerType))
.drop(labelField)
接下来我们就可以定义随机森林模型、并拟合训练数据。实际上除了类名不同RandomForestClassifier在用法上与GBDT的GBTRegressor几乎一模一样如下面的代码片段所示。
import org.apache.spark.ml.regression.RandomForestClassifier
// 定义随机森林模型
val rf= new RandomForestClassifier ()
// Label不再是房价而是房屋质量
.setLabelCol("indexedOverallQual")
.setFeaturesCol("indexedFeatures")
// 限定每棵树的最大深度
.setMaxDepth(5)
// 限定决策树的最大棵树
.setMaxIter(30)
// 区分训练集、验证集
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 拟合训练数据
val rfModel = rf.fit(trainingData)
模型训练好之后,在第三个环节,我们来初步验证模型效果。
需要注意的是衡量模型效果时回归与分类问题各自有一套不同的度量指标。毕竟回归问题预测的是连续值我们往往用不同形式的误差如RMSE、MAE、MAPE等等来评价回归模型的好坏。而分类问题预测的是离散值因此我们通常采用那些能够评估分类“纯度”的指标比如说准确度、精准率、召回率等等。
这里我们以Accuracy准确度为例来评估随机森林模型的拟合效果代码如下所示。
import org.apache.spark.ml.evaluation.MulticlassClassificationEvaluator
// 在训练集上做推理
val trainPredictions = rfModel.transform(trainingData)
// 定义分类问题的评估对象
val evaluator = new MulticlassClassificationEvaluator()
.setLabelCol("indexedOverallQual")
.setPredictionCol("prediction")
.setMetricName("accuracy")
// 在训练集的推理结果上计算Accuracy度量值
val accuracy = evaluator.evaluate(trainPredictions)
好啦到此为止我们以房价预测和房屋分类为例分别介绍了如何在Spark MLlib框架下去应对回归问题与分类问题。分类与回归是监督学习中最典型的两类模型算法是我们必须要熟悉并掌握的。接下来让我们以房屋聚类为例说一说非监督学习。
房屋聚类
与监督学习相对非监督学习泛指那些数据样本中没有Label的机器学习问题。
以房屋数据为例整个数据集包含79个字段。如果我们把“SalePrice”和“OverallQual”这两个字段抹掉那么原始数据集就变成了不带Label的数据样本。你可能会好奇“对于这些没有Label的样本我们能拿他们做些什么呢
其实能做的事情还真不少基于房屋数据我们可以结合“物以类聚”的思想使用K-means算法把他们进行分门别类的处理。再者在下一讲电影推荐的例子中我们还可以基于频繁项集算法挖掘出不同电影之间共现的频次与关联规则从而实现推荐。
今天我们先来讲K-mean结合数据样本的特征向量根据向量之间的相对距离K-means算法可以把所有样本划分为K个类别这也是算法命名中“K”的由来。举例来说图中的每个点都代表一个向量给定不同的K值K-means划分的结果会随着K的变化而变化。
在Spark MLlib的开发框架下我们可以轻而易举地对任意向量做聚类。
首先在模型训练的第一个环节我们先把训练样本准备好。注意这一次我们去掉了“SalePrice”和“OverallQual”这两个字段。
import org.apache.spark.ml.feature.VectorAssembler
val assembler = new VectorAssembler()
// numericFields包含连续特征oheFields为离散特征的One hot编码
.setInputCols(numericFields ++ oheFields)
.setOutputCol("features")
接下来在第二个环节我们来定义K-means模型并使用刚刚准备好的样本去做模型训练。可以看到模型定义非常简单只需实例化KMeans对象并通过setK指定K值即可。
import org.apache.spark.ml.clustering.KMeans
val kmeans = new KMeans().setK(20)
val Array(trainingSet, testSet) = engineeringDF
.select("features")
.randomSplit(Array(0.7, 0.3))
val model = kmeans.fit(trainingSet)
这里我们准备把不同的房屋划分为20个不同的类别。完成训练之后我们同样需要对模型效果进行评估。由于数据样本没有Label因此先前回归与分类的评估指标不适合像K-means这样的非监督学习算法。
K-means的设计思想是“物以类聚”既然如此那么同一个类别中的向量应该足够地接近而不同类别中向量之间的距离应该越远越好。因此我们可以用距离类的度量指标如欧氏距离来量化K-means的模型效果。
import org.apache.spark.ml.evaluation.ClusteringEvaluator
val predictions = model.transform(trainingSet)
// 定义聚类评估器
val evaluator = new ClusteringEvaluator()
// 计算所有向量到分类中心点的欧氏距离
val euclidean = evaluator.evaluate(predictions)
好啦到此为止我们使用非监督学习算法K-means根据房屋向量对房屋类型进行了划分。不过你要注意使用这种方法划分出的类型是没有真实含义的比如它不能代表房屋质量也不能代表房屋评级。既然如此我们用K-means忙活了半天图啥呢
尽管K-means的结果没有真实含义但是它以量化的形式刻画了房屋之间的相似性与差异性。你可以这样来理解我们用K-means为房屋生成了新的特征相比现有的房屋属性这个生成的新特征Generated Features往往与预测标的如房价、房屋类型有着更强的关联性所以让这个新特性参与到监督学习的训练就有希望优化/提升监督学习的模型效果。
好啦到此为止结合房价预测、房屋分类和房屋聚类三个实例我们成功打卡了回归、分类和聚类这三类模型算法。恭喜你离Spark MLlib模型算法通关咱们还有一步之遥。在下一讲我们会结合电影推荐的场景继续学习两个有趣的模型算法协同过滤与频繁项集。
重点回顾
今天这一讲你首先需要掌握K-means算法的基本原理。聚类的设计思想是“物以类聚、人以群分”给定任意向量集合K-means都可以把它划分为K个子集合从而完成聚类。
K-means的计算主要依赖向量之间的相对距离它的计算结果一方面可以直接用于划分“人群”、“种群”另一方面可以拿来当做生成特征去参与到监督学习的训练中去。
此外你需要掌握GBTRegressor和RandomForestClassifier的一般用法。其中setLabelCol与setFeaturesCol分别用于指定模型的预测标的与特征向量。而setMaxDepth与setMaxIter分别用于设置模型的超参数也即最大树深与最大迭代次数决策树的数量从而避免模型出现过拟合的情况。
每课一练
对于房价预测与房屋分类这两个场景,你觉得在它们之间,有代码(尤其是特征工程部分的代码)复用的必要和可能性吗?
欢迎你在留言区跟我交流互动,也推荐你把这一讲的内容分享给更多的同事、朋友。

View File

@ -0,0 +1,260 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
28 模型训练(下):协同过滤与频繁项集算法详解
你好,我是吴磊。
如果你平时爱刷抖音或者热衷看电影不知道有没有过这样的体验这类影视App你用得越久它就好像会读心术一样总能给你推荐对胃口的内容。其实这种迎合用户喜好的推荐离不开机器学习中的推荐算法。
今天是咱们模型训练的最后一讲在今天这一讲我们就结合两个有趣的电影推荐场景为你讲解Spark MLlib支持的协同过滤与频繁项集算法。与上一讲一样咱们还是先来贴出下面这张“全景图”方便你对学过和即将要学的知识做到心中有数。
电影推荐场景
今天这一讲咱们结合Kaggle竞赛中的MovieLens数据集使用不同算法来构建简易的电影推荐引擎。尽管MovieLens数据集包含了多个文件但课程中主要用到的是ratings.csv这个文件。文件中的每条数据条目记录的都是用户对于电影的打分如下表所示。
其中第一列userId为用户IDmovieId表示电影ID而rating就是用户对于电影的评分。像这样同时存有用户与物品电影信息的二维表我们把它们统称为“交互矩阵”或是“共现矩阵”。你可能会疑惑通过这么一份简单的二维表我们能干些什么呢
可别小瞧这份数据与合适的模型算法搭配在一起我就能根据它们构建初具模样的推荐引擎。在Spark MLlib框架下至少有两种模型算法可以做到这一点一个是协同过滤Collaborative Filtering另一个是频繁项集Frequency Patterns。其中前者天生就是用来做推荐用的而后者是一种常规的非监督学习算法你可以结合数据特点把这个算法灵活运用于推荐场景。
协同过滤
我们先说协同过滤,从字面上来说,“过滤”是目的,而“协同”是方式、方法。简单地说,协同过滤的目标,就是从物品集合(比如完整的电影候选集)中,“过滤”出那些用户可能感兴趣的物品子集。而“协同”,它指的是,利用群体行为(全部用户与全部物品的交互历史)来实现过滤。
这样说有些绕,实际上,协同过滤的核心思想很简单,就是“相似的人倾向于喜好相似的物品集”。
交互矩阵看上去简单,但其中隐含着大量的相似性信息,只要利用合适的模型算法,我们就能挖掘出用户与用户之间的相似性、物品与物品之间的相似性,以及用户与物品之间的相似性。一旦这些相似性可以被量化,我们自然就可以基于相似性去做推荐了。思路是不是很简单?
那么问题来了,这些相似性,该怎么量化呢?答案是:矩阵分解。
在数学上给定维度为MN的交互矩阵C我们可以把它分解为两个矩阵U与I的乘积。其中我们可以把U称作“用户矩阵”它的维度为MK而I可以看作是“物品矩阵”它的维度是KN
在用户矩阵与物品矩阵中K是超参数它是由开发者人为设定的。不难发现对于用户矩阵U中的每一行 都可以看作是用户的Embedding也即刻画用户的特征向量。同理物品矩阵中的每一列也都可以看作是物品的Embedding也即刻画物品的特征向量。
正所谓万物皆可Embedding。对于任何事物一旦它们被映射到同一个向量空间我们就可以使用欧氏距离或是余弦夹角等方法来计算他们向量之间的相似度从而实现上述各种相似性用户与用户、物品与物品、用户与物品的量化。
基于相似度计算我们就可以翻着花样地去实现各式各样的推荐。比方说对于用户A来说首先搜索与他/她最相似的前5个用户然后把这些用户喜欢过的物品电影推荐给用户A这样的推荐方式又叫基于用户相似度的推荐。
再比如对于用户A喜欢过的物品我们搜索与这些物品最相似的前5个物品然后把这些搜索到的物品再推荐给用户A这叫做基于物品相似度的推荐。
甚至在一些情况下我们还可以直接计算用户A与所有物品之间的相似度然后把排名靠前的5个物品直接推荐给用户A。
基于上述逻辑我们还可以反其道而行之从物品的视角出发给物品电影推荐用户。不难发现一旦完成Embedding的转换过程我们就可以根据相似度计算来灵活地设计推荐系统。
那么接下来的问题是在Spark MLlib的框架下我们具体要怎么做才能从原始的互动矩阵获得分解之后的用户矩阵、物品矩阵进而获取到用户与物品的Embedding并最终设计出简易的推荐引擎呢
按照惯例,我们还是先上代码,用代码来演示这个过程。
import org.apache.spark.sql.DataFrame
// rootPath表示数据集根目录
val rootPath: String = _
val filePath: String = s"${rootPath}/ratings.csv"
var data: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
// 类型转换
import org.apache.spark.sql.types.IntegerType
import org.apache.spark.sql.types.FloatType
// 把ID类字段转换为整型把Rating转换为Float类型
data = data.withColumn(s"userIdInt",col("userId").cast(IntegerType)).drop("userId")
data = data.withColumn(s"movieIdInt",col("movieId").cast(IntegerType)).drop("movieId")
data = data.withColumn(s"ratingFloat",col("rating").cast(IntegerType)).drop("rating")
// 切割训练与验证数据集
val Array(trainingData, testData) = data.randomSplit(Array(0.8, 0.2))
第一步还是准备训练样本我们从ratings.csv创建DataFrame然后对相应字段做类型转换以备后面使用。第二步我们定义并拟合模型完成协同过滤中的矩阵分解。
import org.apache.spark.ml.recommendation.ALS
// 基于ALSAlternative Least Squares交替最小二乘构建模型完成矩阵分解
val als = new ALS()
.setUserCol("userIdInt")
.setItemCol("movieIdInt")
.setRatingCol("ratingFloat")
.setMaxIter(20)
val alsModel = als.fit(trainingData)
值得一提的是在Spark MLlib的框架下对于协同过滤的实现Spark并没有采用解析解的方式数学上严格的矩阵分解而是用了一种近似的方式来去近似矩阵分解。这种方式就是ALSAlternative Least Squares交替最小二乘
具体来说给定交互矩阵C对于用户矩阵U与物品矩阵ISpark先给U设定一个初始值然后假设U是不变的在这种情况下Spark把物品矩阵I的优化转化为回归问题不停地去拟合I直到收敛。然后固定住物品矩阵I再用回归的思路去优化用户矩阵U直至收敛。如此反复交替数次U和I都逐渐收敛到最优解Spark即宣告训练过程结束。
因为Spark把矩阵分解转化成了回归问题所以我们可以用回归相关的度量指标来衡量ALS模型的训练效果如下所示。
import org.apache.spark.ml.evaluation.RegressionEvaluator
val evaluator = new RegressionEvaluator()
// 设定度量指标为RMSE
.setMetricName("rmse")
.setLabelCol("ratingFloat")
.setPredictionCol("prediction")
val predictions = alsModel.transform(trainingData)
// 计算RMSE
val rmse = evaluator.evaluate(predictions)
验证过模型效果之后接下来我们就可以放心地从模型当中去获取训练好的用户矩阵U和物品矩阵I。这两个矩阵中保存的正是用户Embedding与物品Embedding。
alsModel.userFactors
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
alsModel.userFactors.show(1)
/** 结果打印
+---+--------------------+
| id| features|
+---+--------------------+
| 10|[0.53652495, -1.0...|
+---+--------------------+
*/
alsModel.itemFactors
// org.apache.spark.sql.DataFrame = [id: int, features: array<float>]
alsModel.itemFactors.show(1)
/** 结果打印
+---+--------------------+
| id| features|
+---+--------------------+
| 10|[1.1281404, -0.59...|
+---+--------------------+
*/
就像我们之前说的有了用户与物品的Embedding我们就可以灵活地设计推荐引擎。如果我们想偷懒的话还可以利用Spark MLlib提供的API来做推荐。具体来说我们可以通过调用ALS Model的相关方法来实现向用户推荐物品或是向物品推荐用户如下所示。
// 为所有用户推荐10部电影
val userRecs = alsModel.recommendForAllUsers(10)
// 为每部电影推荐10个用户
val movieRecs = alsModel.recommendForAllItems(10)
// 为指定用户推荐10部电影
val users = data.select(als.getUserCol).distinct().limit(3)
val userSubsetRecs = alsModel.recommendForUserSubset(users, 10)
// 为指定电影推荐10个用户
val movies = data.select(als.getItemCol).distinct().limit(3)
val movieSubSetRecs = alsModel.recommendForItemSubset(movies, 10)
好啦到此为止我们介绍了协同过滤的核心思想与工作原理并使用Spark MLlib提供的ALS算法实现了一个简单的电影推荐引擎。接下来我们再来想一想还有没有其他的思路来打造一个不一样的推荐引擎。
频繁项集
频繁项集Frequency Patterns是一种经典的数据挖掘算法我们可以把它归类到非监督学习的范畴。频繁项集可以挖掘数据集中那些经常“成群结队”出现的数据项并尝试在它们之间建立关联规则Association Rules从而为决策提供支持。
举例来说,基于对上百万条交易记录的统计分析,蔬果超市发现(“葱”,“姜”,“蒜”)这三种食材经常一起出现。换句话说,购买了“葱”、“姜”的人,往往也会再买上几头蒜,或是买了大葱的人,结账前还会再把姜、蒜也捎上。
在这个购物篮的例子中“葱”“姜”“蒜”就是频繁项Frequency Itemset也即经常一起共现的数据项集合。而像“葱”、“姜”->“蒜”)和(“葱”->“姜”、“蒜”)这样的关联关系,就叫做关联规则。
不难发现,基于频繁项与关联规则,我们能够提供简单的推荐能力。以刚刚的(“葱”,“姜”,“蒜”)为例,对于那些手中提着大葱、准备结账的人,精明的导购员完全可以向他/她推荐超市新上的河北白皮蒜或是山东大生姜。
回到电影推荐的场景,我们同样可以基于历史,挖掘出频繁项和关联规则。比方说,电影(“八佰”、“金刚川”、“长津湖”)是频繁项,而(“八佰”、“金刚川”->“长津湖”)之间存在着关联关系。那么,对于看过“八佰”和“金刚川”的人,我们更倾向于判断他/她大概率也会喜欢“长津湖”,进而把这部电影推荐给他/她。
那么基于MovieLens数据集在Spark MLlib的开发框架下我们该如何挖掘其中的频繁项与关联规则呢
首先第一步是数据准备。在蔬果超市的例子中超市需要以交易为单位收集顾客曾经一起购买过的各种蔬果。为了在MovieLens数据集上计算频繁项集我们也需要以用户为粒度收集同一个用户曾经看过的所有电影集合如下图所示。
要完成这样的转换,我们只需要一行代码即可搞定。
// data是从ratings.csv创建的DataFrame
val movies: DataFrame = data
// 按照用户分组
.groupBy("userId")
// 收集该用户看过的所有电影把新的集合列命名为movieSeq
.agg(collect_list("movieId").alias("movieSeq"))
// 只保留movieSeq这一列去掉其他列
.select("movieSeq")
// movies: org.apache.spark.sql.DataFrame = [movieSeq: array<string>]
movies.show(1)
/** 结果打印
+--------------------+
| movieSeq|
+--------------------+
|[151, 172, 236, 2...|
+--------------------+
*/
数据准备好之后接下来我们就可以借助Spark MLlib框架来完成频繁项集的计算。
import org.apache.spark.ml.fpm.FPGrowth
val fpGrowth = new FPGrowth()
// 指定输入列
.setItemsCol("movieSeq")
// 超参数,频繁项最小支持系数
.setMinSupport(0.1)
// 超参数,关联规则最小信心系数
.setMinConfidence(0.1)
val model = fpGrowth.fit(movies)
可以看到定义并拟合频繁项集模型还是比较简单的用法上与其他模型算法大同小异。不过这里有两个超参数需要特别关注一个是由setMinSupport设置的最小支持系数另一个是由setMinConfidence指定的最小信心系数。
最小支持系数它用来设定频繁项的“选拔阈值”这里我们把它设置为0.1。这是什么意思呢?
举例来说在MovieLens数据集中总共有7120个用户相应地movies这个DataFrame中就有7120条电影集合数据。对于“八佰”、“金刚川”、“长津湖”这个组合来说当且仅当它出现的次数大于7127120 * 0.1),这个组合才会被算法判定为频繁项。换句话说,最小支持系数越高,算法挖掘出的频繁项越少、越可靠,反之越多。
相应地最小信心系数是用来约束关联规则的例子中的取值也是0.1。我们再来举例说明假设在7120条电影集合数据中“八佰”、“金刚川”这对组合一起出现过1000次那么要想“八佰”、“金刚川”->“长津湖”这条关联规则成立“八佰”、“金刚川”、“长津湖”这个组合必须至少出现过100次1000 * 0.1)。同理,最小信心系数越高,算法挖掘出的关联规则越少、越可靠,反之越多。
模型训练好之后,我们就可以从中获取经常出现的频繁项与关联规则,如下所示。
model.freqItemsets.show(1)
/** 结果打印
+--------------------+----+
| items|freq|
+--------------------+----+
|[318, 593, 356, 296]|1465|
+--------------------+----+
*/
model.associationRules.show(1)
/** 结果打印
+--------------------+----------+------------------+
| antecedent|consequent| confidence|
+--------------------+----------+------------------+
|[592, 780, 480, 593]| [296]|0.8910463861920173|
+--------------------+----------+------------------+
*/
基于关联规则我们就可以提供初步的推荐功能。比方说对于看过592、780、480、593这四部电影的用户我们可以把ID为296的电影推荐给他/她。
重点回顾
好啦,到此为止,模型训练的上、中、下三讲,我们就全部讲完啦!这三讲的内容较多,涉及的算法也很多,为了让你对他们有一个整体的把握,我把这些算法的分类、原理、特点与适用场景,都整理到了如下的表格中,供你随时回顾。
不难发现,机器学习的场景众多,不同的场景下,又有多种不同的算法供我们选择。掌握这些算法的原理与特性,有利于我们高效地进行模型选型与模型训练,从而去解决不同场景下的特定问题。
对于算法的调优与应用,还需要你结合日常的实践去进一步验证、巩固,也欢迎你在留言区分享你的心得与体会,让我们一起加油!
每课一练
对于本讲介绍的两种推荐思路(协同过滤与频繁项集),你能说说他们各自的优劣势吗?
你有什么学习收获或者疑问,都可以跟我交流,咱们留言区见。

View File

@ -0,0 +1,330 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
29 Spark MLlib Pipeline高效开发机器学习应用
你好,我是吴磊。
前面我们一起学习了如何在Spark MLlib框架下做特征工程与模型训练。不论是特征工程还是模型训练针对同一个机器学习问题我们往往需要尝试不同的特征处理方法或是模型算法。
结合之前的大量实例,细心的你想必早已发现,针对同一问题,不同的算法选型在开发的过程中,存在着大量的重复性代码。
以GBDT和随机森林为例它们处理数据的过程是相似的原始数据都是经过StringIndexer、VectorAssembler和VectorIndexer这三个环节转化为训练样本只不过GBDT最后用GBTRegressor来做回归而随机森林用RandomForestClassifier来做分类。
不仅如此,在之前验证模型效果的时候我们也没有闭环,仅仅检查了训练集上的拟合效果,并没有在测试集上进行推理并验证。如果我们尝试去加载新的测试数据集,那么所有的特征处理过程,都需要在测试集上重演一遍。无疑,这同样会引入大量冗余的重复代码。
那么有没有什么办法能够避免上述的重复开发让Spark MLlib框架下的机器学习开发更加高效呢答案是肯定的今天这一讲我们就来说说Spark MLlib Pipeline看看它如何帮助开发者大幅提升机器学习应用的开发效率。
Spark MLlib Pipeline
什么是Spark MLlib Pipeline呢简单地说Pipeline是一套基于DataFrame的高阶开发API它让开发者以一种高效的方式来打造端到端的机器学习流水线。这么说可能比较抽象我们不妨先来看看Pipeline都有哪些核心组件它们又提供了哪些功能。
Pipeline的核心组件有两类一类是Transformer我们不妨把它称作“转换器”另一类是Estimator我把它叫作“模型生成器”。我们之前接触的各类特征处理函数实际上都属于转换器比如StringIndexer、MinMaxScaler、Bucketizer、VectorAssembler等等。而前面3讲提到的模型算法全部都是Estimator。
Transformer
我们先来说说Transformer数据转换器。在形式上Transformer的输入是DataFrame输出也是DataFrame。结合特定的数据处理逻辑Transformer基于原有的DataFrame数据列去创建新的数据列而新的数据列中往往包含着不同形式的特征。
以StringIndexer为例它的转换逻辑很简单就是把字符串转换为数值。在创建StringIndexer实例的时候我们需要使用setInputCol(s)和setOutputCol(s)方法,来指定原始数据列和期待输出的数据列,而输出数据列中的内容就是我们需要的特征,如下图所示。
结合图示可以看到Transformer消费原有DataFrame的数据列然后把生成的数据列再追加到该DataFrame就会生成新的DataFrame。换句话说Transformer并不是“就地”Inline修改原有的DataFrame而是基于它去创建新的DataFrame。
实际上每个Transformer都实现了setInputCol(s)和setOutputCol(s)这两个接口方法。除此之外Transformer还提供了transform接口用于封装具体的转换逻辑。正是基于这些核心接口Pipeline才能把各式各样的Transformer拼接在一起打造出了特征工程流水线。
一般来说在一个机器学习应用中我们往往需要多个Transformer来对数据做各式各样的转换才能生成所需的训练样本。在逻辑上多个基于同一份原始数据生成的、不同“版本”数据的DataFrame它们会同时存在于系统中。
不过受益于Spark的惰性求值Lazy Evaluation设计应用在运行时并不会出现多份冗余数据重复占用内存的情况。
不过为了开发上的遍历我们还是会使用var而不是用val来命名原始的DataFrame。原因很简单如果用val的话我们需要反复使用新的变量名来命名新生成的DataFrame。关于这部分开发小细节你可以通过回顾[上一讲]的代码来体会。
Estimator
接下来我们来说说Estimator。相比TransformerEstimator要简单得多它实际上就是各类模型算法如GBDT、随机森林、线性回归等等。Estimator的核心接口只有一个那就是fit中文可以翻译成“拟合”。
Estimator的作用就是定义模型算法然后通过拟合DataFrame所囊括的训练样本来生产模型Models。这也是为什么我把Estimator称作是“模型生成器”。
不过有意思的是虽然模型算法是Estimator但是Estimator生产的模型却是不折不扣的Transformer。
要搞清楚为什么模型是Transformer我们得先弄明白模型到底是什么。所谓机器学习模型它本质上就是一个参数Parameters又称权重Weights矩阵外加一个模型结构。模型结构与模型算法有关比如决策树结构、GBDT结构、神经网络结构等等。
模型的核心用途就是做推断Inference或者说预测。给定数据样本模型可以推断房价、推断房屋类型等等。在Spark MLlib框架下数据样本往往是由DataFrame封装的而模型推断的结果还是保存在新的DataFrame中结果的默认列名是“predictions”。
其实基于训练好的推理逻辑通过增加“predictions”列把一个DataFrame转化成一个新的DataFrame这不就是Transformer在做的事情吗而这也是为什么在模型算法上我们调用的是fit方法而在做模型推断时我们在模型上调用的是transform方法。
构建Pipeline
好啦了解了Transformer和Estimator之后我们就可以基于它们去构建Pipeline来打造端到端的机器学习流水线。实际上一旦Transformer、Estimator准备就绪定义Pipeline只需一行代码就可以轻松拿下如下所示。
import org.apache.spark.ml.Pipeline
// 像之前一样,定义各种特征处理对象与模型算法
val stringIndexer = _
val vectorAssembler = _
val vectorIndexer = _
val gbtRegressor = _
// 将所有的Transformer、Estimator依序放入数组
val stages = Array(stringIndexer, vectorAssembler, vectorIndexer, gbtRegressor)
// 定义Spark MLlib Pipeline
val newPipeline = new Pipeline()
.setStages(stages)
可以看到要定义Pipeline只需创建Pipeline实例然后把之前定义好的Transformer、Estimator纷纷作为参数传入setStages方法即可。需要注意的是一个Pipeline可以包含多个Transformer和Estimator不过Pipeline的最后一个环节必须是Estimator切记。
到此为止Pipeline的作用、定义以及核心组件我们就讲完了。不过你可能会说“概念是讲完了不过我还是不知道Pipeline具体怎么用以及它到底有什么优势”别着急光说不练假把式接下来我们就结合GBDT与随机森林的例子来说说Pipeline的具体用法以及怎么用它帮你大幅度提升开发效率。
首先我们来看看在一个机器学习应用中Pipeline如何帮助我们提高效率。在上一讲我们用GBDT来拟合房价并给出了代码示例。
现在咱们把代码稍微调整一下用Spark MLlib Pipeline来实现模型训练。第一步我们还是先从文件创建DataFrame然后把数值型字段与非数值型字段区分开如下所示。
import org.apache.spark.sql.DataFrame
// rootPath为房价预测数据集根目录
val rootPath: String = _
val filePath: String = s"${rootPath}/train.csv"
// 读取文件创建DataFrame
var engineeringDF: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
// 所有数值型字段
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
// Label字段
val labelFields: Array[String] = Array("SalePrice")
import org.apache.spark.sql.types.IntegerType
for (field <- (numericFields ++ labelFields)) {
engineeringDF = engineeringDF
.withColumn(s"${field}Int",col(field).cast(IntegerType))
.drop(field)
}
数据准备好之后接下来我们就可以开始着手为Pipeline的构建打造零件依次定义转换器Transformer和模型生成器Estimator。在上一讲我们用StringIndexer把非数值字段转换为数值字段这一讲咱们也依法炮制。
import org.apache.spark.ml.feature.StringIndexer
// 所有非数值型字段
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
// StringIndexer期望的输出列名
val indexFields: Array[String] = categoricalFields.map(_ + "Index").toArray
// 定义StringIndexer实例
val stringIndexer = new StringIndexer()
// 批量指定输入列名
.setInputCols(categoricalFields)
// 批量指定输出列名,输出列名与输入列名,必须要一一对应
.setOutputCols(indexFields)
.setHandleInvalid("keep")
在上一讲定义完StringIndexer实例之后我们立即拿它去对engineeringDF做转换。不过在构建Pipeline的时候我们不需要这么做只需要把这个“零件”定义好即可。接下来我们来打造下一个零件VectorAssembler。
import org.apache.spark.ml.feature.VectorAssembler
// 转换为整型的数值型字段
val numericFeatures: Array[String] = numericFields.map(_ + "Int").toArray
val vectorAssembler = new VectorAssembler()
/** 输入列为:数值型字段 + 非数值型字段
注意非数值型字段的列名要用indexFields
而不能用原始的categoricalFields不妨想一想为什么
*/
.setInputCols(numericFeatures ++ indexFields)
.setOutputCol("features")
.setHandleInvalid("keep")
与上一讲相比VectorAssembler的定义并没有什么两样。
下面我们继续来打造第三个零件VectorIndexer它用于帮助模型算法区分连续特征与离散特征。
import org.apache.spark.ml.feature.VectorIndexer
val vectorIndexer = new VectorIndexer()
// 指定输入列
.setInputCol("features")
// 指定输出列
.setOutputCol("indexedFeatures")
// 指定连续、离散判定阈值
.setMaxCategories(30)
.setHandleInvalid("keep")
到此为止Transformer就全部定义完了原始数据经过StringIndexer、VectorAssembler和VectorIndexer的转换之后会生成新的DataFrame。在这个最新的DataFrame中会有多个由不同Transformer生成的数据列其中“indexedFeatures”列包含的数据内容即是特征向量。
结合DataFrame一路携带过来的“SalePriceInt”列特征向量与预测标的终于结合在一起了就是我们常说的训练样本。有了训练样本接下来我们就可以着手定义Estimator。
import org.apache.spark.ml.regression.GBTRegressor
val gbtRegressor = new GBTRegressor()
// 指定预测标的
.setLabelCol("SalePriceInt")
// 指定特征向量
.setFeaturesCol("indexedFeatures")
// 指定决策树的数量
.setMaxIter(30)
// 指定决策树的最大深度
.setMaxDepth(5)
好啦到这里Pipeline所需的零件全部打造完毕零件就位只欠组装。我们需要通过Spark MLlib提供的“流水线工艺”把所有零件组装成Pipeline。
import org.apache.spark.ml.Pipeline
val components = Array(stringIndexer, vectorAssembler, vectorIndexer, gbtRegressor)
val pipeline = new Pipeline()
.setStages(components)
怎么样是不是很简单接下来的问题是有了Pipeline我们都能用它做些什么呢
// Pipeline保存地址的根目录
val savePath: String = _
// 将Pipeline物化到磁盘以备后用复用
pipeline.write
.overwrite()
.save(s"${savePath}/unfit-gbdt-pipeline")
// 划分出训练集和验证集
val Array(trainingData, validationData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 调用fit方法触发Pipeline计算并最终拟合出模型
val pipelineModel = pipeline.fit(trainingData)
首先我们可以把Pipeline保存下来以备后用至于怎么复用我们待会再说。再者把之前准备好的训练样本传递给Pipeline的fit方法即可触发整条Pipeline从头至尾的计算逻辑从各式各样的数据转换到最终的模型训练一步到位。-
Pipeline fit方法的输出结果即是训练好的机器学习模型。我们最开始说过模型也是Transformer它可以用来推断预测结果。
看到这里你可能会说“和之前的代码实现相比Pipeline也没有什么特别之处无非是用Pipeline API把之前的环节拼接起来而已”。其实不然基于构建好的Pipeline我们可以在不同范围对其进行复用。对于机器学习应用来说我们既可以在作业内部实现复用也可以在作业之间实现复用从而大幅度提升开发效率。
作业内的代码复用
在之前的模型训练过程中我们仅仅在训练集与验证集上评估了模型效果。实际上在工业级应用中我们最关心的是模型在测试集上的泛化能力。就拿Kaggle竞赛来说对于每一个机器学习项目Kaggle都会同时提供train.csv和test.csv两个文件。
其中train.csv是带标签的用于训练模型而test.csv是不带标签的。我们需要对test.csv中的数据做推断然后把预测结果提交到Kaggle线上平台平台会结合房屋的实际价格来评判我们的模型到那时我们才能知道模型对于房价的预测到底有多准或是有多不准
要完成对test.csv的推断我们需要把原始数据转换为特征向量也就是把“粗粮”转化为“细粮”然后才能把它“喂给”模型。
在之前的代码实现中要做到这一点我们必须把之前加持到train.csv的所有转换逻辑都重写一遍比如StringIndexer、VectorAssembler和VectorIndexer。毫无疑问这样的开发方式是极其低效的更糟的是手工重写很容易会造成测试样本与训练样本不一致而这样的不一致是机器学习应用中的大忌。
不过有了Pipeline我们就可以省去这些麻烦。首先我们把test.csv加载进来并创建DataFrame然后把数值字段从String转为Int。
import org.apache.spark.sql.DataFrame
val rootPath: String = _
val filePath: String = s"${rootPath}/test.csv"
// 加载test.csv并创建DataFrame
var testData: DataFrame = spark.read.format("csv").option("header", true).load(filePath)
// 所有数值型字段
val numericFields: Array[String] = Array("LotFrontage", "LotArea", "MasVnrArea", "BsmtFinSF1", "BsmtFinSF2", "BsmtUnfSF", "TotalBsmtSF", "1stFlrSF", "2ndFlrSF", "LowQualFinSF", "GrLivArea", "BsmtFullBath", "BsmtHalfBath", "FullBath", "HalfBath", "BedroomAbvGr", "KitchenAbvGr", "TotRmsAbvGrd", "Fireplaces", "GarageCars", "GarageArea", "WoodDeckSF", "OpenPorchSF", "EnclosedPorch", "3SsnPorch", "ScreenPorch", "PoolArea")
// 所有非数值型字段
val categoricalFields: Array[String] = Array("MSSubClass", "MSZoning", "Street", "Alley", "LotShape", "LandContour", "Utilities", "LotConfig", "LandSlope", "Neighborhood", "Condition1", "Condition2", "BldgType", "HouseStyle", "OverallQual", "OverallCond", "YearBuilt", "YearRemodAdd", "RoofStyle", "RoofMatl", "Exterior1st", "Exterior2nd", "MasVnrType", "ExterQual", "ExterCond", "Foundation", "BsmtQual", "BsmtCond", "BsmtExposure", "BsmtFinType1", "BsmtFinType2", "Heating", "HeatingQC", "CentralAir", "Electrical", "KitchenQual", "Functional", "FireplaceQu", "GarageType", "GarageYrBlt", "GarageFinish", "GarageQual", "GarageCond", "PavedDrive", "PoolQC", "Fence", "MiscFeature", "MiscVal", "MoSold", "YrSold", "SaleType", "SaleCondition")
import org.apache.spark.sql.types.IntegerType
// 注意test.csv没有SalePrice字段也即没有Label
for (field <- (numericFields)) {
testData = testData
.withColumn(s"${field}Int",col(field).cast(IntegerType))
.drop(field)
}
接下来我们只需要调用Pipeline Model的transform方法就可以对测试集做推理。还记得吗模型是Transformer而transform是Transformer用于数据转换的统一接口。
val predictions = pipelineModel.transform(testData)
有了Pipeline我们就可以省去StringIndexer、VectorAssembler这些特征处理函数的重复定义在提升开发效率的同时消除样本不一致的隐患。除了在同一个作业内部复用Pipeline之外我们还可以在不同的作业之间对其进行复用从而进一步提升开发效率。
作业间的代码复用
对于同一个机器学习问题我们往往会尝试不同的模型算法以期获得更好的模型效果。例如对于房价预测我们既可以用GBDT也可以用随机森林。不过尽管模型算法不同但是它们的训练样本往往是类似的甚至是完全一样的。如果每尝试一种模型算法就需要从头处理一遍数据这未免过于低效也容易出错。
有了Pipeline我们就可以把算法选型这件事变得异常简单。还是拿房价预测来举例之前我们尝试使用GBTRegressor来训练模型这一次咱们来试试RandomForestRegressor也即使用随机森林来解决回归问题。按照惯例我们还是结合代码来进行讲解。
import org.apache.spark.ml.Pipeline
val savePath: String = _
// 加载之前保存到磁盘的Pipeline
val unfitPipeline = Pipeline.load(s"${savePath}/unfit-gbdt-pipeline")
// 获取Pipeline中的每一个StageTransformer或Estimator
val formerStages = unfitPipeline.getStages
// 去掉Pipeline中最后一个组件也即EstimatorGBTRegressor
val formerStagesWithoutModel = formerStages.dropRight(1)
import org.apache.spark.ml.regression.RandomForestRegressor
// 定义新的EstimatorRandomForestRegressor
val rf = new RandomForestRegressor()
.setLabelCol("SalePriceInt")
.setFeaturesCol("indexedFeatures")
.setNumTrees(30)
.setMaxDepth(5)
// 将老的Stages与新的Estimator拼接在一起
val stages = formerStagesWithoutModel ++ Array(rf)
// 重新定义新的Pipeline
val newPipeline = new Pipeline()
.setStages(stages)
首先我们把之前保存下来的Pipeline重新加载进来。然后用新的RandomForestRegressor替换原来的GBTRegressor。最后再把原有的Stages和新的Estimator拼接在一起去创建新的Pipeline即可。接下来只要调用fit方法就可以触发新Pipeline的运转并最终拟合出新的随机森林模型。
// 像之前一样从train.csv创建DataFrame准备数据
var engineeringDF = _
val Array(trainingData, testData) = engineeringDF.randomSplit(Array(0.7, 0.3))
// 调用fit方法触发Pipeline运转拟合新模型
val pipelineModel = newPipeline.fit(trainingData)
可以看到短短的几行代码就可以让我们轻松地完成模型选型。到此Pipeline在开发效率与容错上的优势可谓一览无余。
重点回顾
今天的内容就讲完啦今天这一讲我们一起学习了Spark MLlib Pipeline。你需要理解Pipeline的优势所在并掌握它的核心组件与具体用法。Pipeline的核心组件是Transformer与Estimator。
其中Transformer完成从DataFrame到DataFrame的转换基于固有的转换逻辑生成新的数据列。Estimator主要是模型算法它基于DataFrame中封装的训练样本去生成机器学习模型。将若干Transformer与Estimator拼接在一起通过调用Pipeline的setStages方法即可完成Pipeline的创建。
Pipeline的核心优势在于提升机器学习应用的开发效率并同时消除测试样本与训练样本之间不一致这一致命隐患。Pipeline可用于作业内的代码复用或是作业间的代码复用。
在同一作业内Pipeline能够轻松地在测试集之上完成数据推断。而在作业之间开发者可以加载之前保存好的Pipeline然后用“新零件”替换“旧零件”的方式在复用大部分处理逻辑的同时去打造新的Pipeline从而实现高效的模型选型过程。
在今后的机器学习开发中我们要充分利用Pipeline提供的优势来降低开发成本从而把主要精力放在特征工程与模型调优上。
到此为止Spark MLlib模块的全部内容我们就讲完了。
在这个模块中我们主要围绕着特征工程、模型训练和机器学习流水线等几个方面梳理了Spark MLlib子框架为开发者提供的种种能力。换句话说我们知道了Spark MLlib能做哪些事情、擅长做哪些事情。如果我们能够做到对这些能力了如指掌在日常的机器学习开发中就可以灵活地对其进行取舍从而去应对不断变化的业务需求加油
每日一练
我们今天一直在讲Pipeline的优势你能说一说Pipeline有哪些可能的劣势吗
欢迎你在留言区和我交流互动也推荐你把这一讲分享给更多同事、朋友说不定就能让他进一步理解Pipeline。

View File

@ -0,0 +1,226 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
30 Structured Streaming从“流动的Word Count”开始
你好,我是吴磊。
从今天这一讲开始,我们将进入流计算的学习模块。与以往任何时候都不同,今天的大数据处理,对于延迟性的要求越来越高,因此流处理的基本概念与工作原理,是每一个大数据从业者必备的“技能点”。
在这个模块中按照惯例我们还是从一个可以迅速上手的实例开始带你初步认识Spark的流处理框架Structured Streaming。然后我们再从框架所提供的能力、特性出发深入介绍Structured Streaming工作原理、最佳实践以及开发注意事项等等。
在专栏的第一个模块我们一直围绕着Word Count在打转也就是通过从文件读取内容然后以批处理的形式来学习各式各样的数据处理技巧。而今天这一讲我们换个花样从一个“流动的Word Count”入手去学习一下在流计算的框架下Word Count是怎么做的。
环境准备
要上手今天的实例你只需要拥有Spark本地环境即可并不需要分布式的物理集群。
不过咱们需要以“流”的形式为Spark提供输入数据因此要完成今天的实验我们需要开启两个命令行终端。一个用于启动spark-shell另一个用于开启Socket端口并输入数据如下图所示。
流动的Word Count
环境准备好之后接下来我们具体来说一说什么是“流动的Word Count”。
所谓没有对比就没有鉴别为了说清楚“流动的Word Count”咱们不妨拿批处理版本的Word Count作对比。在之前的Word Count中数据以文件wikiOfSpark.txt的形式一次性地“喂给”Spark从而触发一次Job计算。而在“流动的Word Count”里数据以行为粒度分批地“喂给”Spark每一行数据都会触发一次Job计算。
具体来说我们使用netcat工具向本地9999端口的Socket地址发送数据行。而Spark流处理应用则时刻监听着本机的9999端口一旦接收到数据条目就会立即触发计算逻辑的执行。当然在我们的示例中这里的计算逻辑就是Word Count。计算执行完毕之后流处理应用再把结果打印到终端Console上。
与批处理不同只要我们不人为地中断流处理应用理论上它可以一直运行到永远。以“流动的Word Count”为例只要我们不强制中断它它就可以一直监听9999端口接收来自那里的数据并以实时的方式处理它。
好啦,弄清楚我们要做的事情之后,接下来,我们一起来一步一步地实现它。
首先第一步我们在第二个用来输入数据的终端敲入命令“nc -lk 9999”也就是使用netcat工具开启本机9999端口的Socket地址。一般来说大多数操作系统都预装了netcat工具因此不论你使用什么操作系统应该都可以成功执行上述命令。
命令敲击完毕之后光标会在屏幕上一直闪烁这表明操作系统在等待我们向Socket地址发送数据。我们暂且把它搁置在这里等一会流处理应用实现完成之后再来处理它。
接下来第二步我们从第一个终端进入spark-shell本地环境然后开始开发流处理应用。首先我们先导入DataFrame并指定应用所需监听的主机与端口号。
import org.apache.spark.sql.DataFrame
// 设置需要监听的本机地址与端口号
val host: String = "127.0.0.1"
val port: String = "9999"
数据加载
然后是数据加载环节我们通过SparkSession的readStream API来创建DataFrame。
// 从监听地址创建DataFrame
var df: DataFrame = spark.readStream
.format("socket")
.option("host", host)
.option("port", port)
.load()
仔细观察上面的代码你有没有觉得特别眼熟呢没错readStream API与SparkSession的read API看上去几乎一模一样。
可以看到与read API类似readStream API也由3类最基本的要素构成也就是
format指定流处理的数据源头类型
option与数据源头有关的若干选项
load将数据流加载进Spark
流计算场景中有3个重要的基础概念需要我们重点掌握。它们依次是Source、流处理引擎与Sink。其中Source是流计算的数据源头也就是源源不断地产生数据的地方。与之对应Sink指的是数据流向的目的地也就是数据要去向的地方后面我们讲到writeSteam API的时候再去展开。
而流处理引擎是整个模块的学习重点后续我们还会深入讨论。它的作用显而易见在数据流动过程中实现数据处理保证数据完整性与一致性。这里的数据处理包括我们Spark SQL模块讲过的各种操作类型比如过滤、投影、分组、聚合、排序等等。
现在让我们先把注意力放到readStream API与Source上来。通过readStream API的format函数我们可以指定不同类型的数据源头。在Structured Streaming框架下Spark主要支持3类数据源分别是Socket、File和Kafka。
其中Socket类型主要用于开发试验或是测试应用的连通性这也是这一讲中我们采用Socket作为数据源的原因。File指的是文件系统Spark可以通过监听文件夹把流入文件夹的文件当作数据流来对待。而在实际的工业级应用中Kafka + Spark的组合最为常见因此在本模块的最后我们会单独开辟一篇专门讲解Kafka与Spark集成的最佳实践。
通过format指定完数据源之后还需要使用零到多个option来指定数据源的具体地址、访问权限等信息。以咱们代码中的Socket为例我们需要明确主机地址与端口地址。
// 从监听地址创建DataFrame
var df: DataFrame = spark.readStream
.format("socket")
.option("host", host)
.option("port", port)
.load()
一切准备就绪之后我们就可以通过load来创建DataFrame从而把数据流源源不断地加载进Spark系统。
数据处理
有了DataFrame在手我们就可以使用之前学习过的各类DataFrame算子去实现Word Count的计算逻辑。这一步比较简单你不妨先自己动手试试然后再接着往下看。
/**
使用DataFrame API完成Word Count计算
*/
// 首先把接收到的字符串以空格为分隔符做拆分得到单词数组words
df = df.withColumn("words", split($"value", " "))
// 把数组words展平为单词word
.withColumn("word", explode($"words"))
// 以单词word为Key做分组
.groupBy("word")
// 分组计数
.count()
首先需要说明的是我们从Socket创建的DataFrame默认只有一个“value”列它以行为粒度存储着从Socket接收到数据流。比方说我们在第二个终端也就是netcat界面敲入两行数据分别是“Apache Spark”和“Spark Logo”。那么在“value”列中就会有两行数据与之对应同样是“Apache Spark”和“Spark Logo”。
对于“value”列我们先是用空格把它拆分为数组words然后再用explode把words展平为单词word接下来就是对单词word做分组计数。这部分处理逻辑比较简单你很容易就可以上手鼓励你尝试其他不同的算子来实现同样的逻辑。
数据输出
数据处理完毕之后与readStream API相对应我们可以调用writeStream API来把处理结果写入到Sink中。在Structured Streaming框架下Spark支持多种Sink类型其中有Console、File、Kafka和Foreach(Batch)。对于这几种Sink的差异与特点我们留到[下一讲]再去展开。
这里我们先来说说ConsoleConsole就是我们常说的终端选择Console作为SinkSpark会把结果打印到终端。因此Console往往与Socket配合用于开发实验与测试连通性代码实现如下所示。
/**
将Word Count结果写入到终端Console
*/
df.writeStream
// 指定Sink为终端Console
.format("console")
// 指定输出选项
.option("truncate", false)
// 指定输出模式
.outputMode("complete")
//.outputMode("update")
// 启动流处理应用
.start()
// 等待中断指令
.awaitTermination()
可以看到writeStream API看上去与DataFrame的write API也是极为神似。
其中format用于指定Sink类型option则用于指定与Sink类型相关的输出选项比如与Console相对应的“truncate”选项用来表明输出内容是否需要截断。在write API中我们最终通过调用save把数据保持到指定路径而在writeStream API里我们通过start来启动端到端的流计算。
所谓端到端的流计算它指的就是我们在“流动的Word Count”应用中实现的3个计算环节也即从数据源不断地加载数据流以Word Count的计算逻辑处理数据并最终把计算结果打印到Console。
整个计算过程持续不断即便netcat端没有任何输入“流动的Word Count”应用也会一直运行直到我们强制应用退出为止。而这正是函数awaitTermination的作用顾名思义它的目的就是在“等待用户中断”。
对于writeStream API与write API的不同除了刚刚说的start和awaitTermination以外细心的你想必早已发现writeStream API多了一个outputMode函数它用来指定数据流的输出模式。
想要理解这个函数就要清楚数据流的输出模式都有哪些。我们先来说一说Structured Streaming都支持哪些输出模式然后再用“流动的Word Count”的执行结果来直观地进行对比说明。
一般来说Structured Streaming支持3种Sink输出模式也就是
Complete mode输出到目前为止处理过的全部内容
Append mode仅输出最近一次作业的计算结果
Update mode仅输出内容有更新的计算结果
当然这3种模式并不是在任何场景下都适用。比方说在我们“流动的Word Count”示例中Append mode就不适用。原因在于对于有聚合逻辑的流处理来说开发者必须要提供Watermark才能使用Append mode。
后面第32讲我们还会继续学习Watermark和Sink的三种输出模式这里你有个大致印象就好。
执行结果
到目前为止“流动的Word Count”应用代码已全部开发完毕接下来我们先让它跑起来感受一下流计算的魅力。然后我们再通过将outputMode中的“complete”替换为“update”直观对比一下它们的特点和区别。
要运行“流动的Word Count”首先第一步我们把刚刚实现的所有代码依次敲入第一个终端的spark-shell。全部录入之后等待一会你应该会看到如下的画面
当出现“Batch: 0”字样后这表明我们的流处理应用已经成功运行并在9999端口等待数据流的录入。接下来我们切换到第二个终端也就是开启netcat的终端界面然后依次逐行注意依次逐行输入下面的文本内容每行数据录入之间请间隔3~5秒。
然后我们再把屏幕切换到spark-shell终端你会看到Spark跑了4批作业执行结果分别如下。
可以看到在Complete mode下每一批次的计算结果都会包含系统到目前为止处理的全部数据内容。你可以通过对比每个批次与前面批次的差异来验证这一点。
接下来我们在spark-shell终端输入强制中断命令ctrl + D或ctrl + C退出spark-shell。然后再次在终端敲入“spark-shell”命令再次进入spark-shell本地环境并再次录入“流动的Word Count”代码。不过这一次在代码的最后我们把writeStream中的outputMode由原来的“complete”改为“update”。
代码录入完毕之后我们再切回到netcat终端并重新录入刚刚的4条数据然后观察第一个终端spark-shell界面的执行结果。
对比之下一目了然可以看到在Update mode下每个批次仅输出内容有变化的数据记录。所谓有变化也就是要么单词是第一次在本批次录入计数为1要么单词是重复录入计数有所变化。你可以通过观察不同批次的输出以及对比Update与Complete不同模式下的输出结果来验证这一点。
好啦到目前为止我们一起开发了一个流处理小应用“流动的Word Count”并一起查看了它在不同输出模式下的计算结果。恭喜你学到这里可以说你的一只脚已经跨入了Spark流计算的大门。后面还有很多精彩的内容有待我们一起去发掘让我们一起加油
重点回顾
今天这一讲你需要掌握如下几点。首先你需要熟悉流计算场景中3个重要的基本概念也就是Source、流处理引擎和Sink如下图所示。
再者对于Source与Sink你需要知道在Structured Streaming框架下Spark都能提供哪些具体的支持。以Source为例Spark支持Socket、File和Kafka而对于SinkSpark支持Console、File、Kafka和Foreach(Batch)。
之后我们结合一个流处理小应用借此熟悉了在Structured Streaming框架下流处理应用开发的一般流程。一般来说我们通过readStream API从不同类型的Source读取数据流、并创建DataFrame然后使用DataFrame算子处理数据如数据的过滤、投影、分组、聚合等最终通过writeStream API将处理结果写入到不同形式的Sink中去。
最后对于结果的输出我们需要了解在不同的场景下Structured Streaming支持不同的输出模式。输出模式主要有3种分别是Complete mode、Append mode和Update mode。其中Complete mode输出到目前为止处理过的所有数据而Update mode仅输出在当前批次有所更新的数据内容。
每课一练
在运行“流动的Word Count”的时候我们强调依次逐行输入数据内容请你把示例给出的4行数据一次性地输入netcat拷贝&粘贴然后观察Structured Streaming给出的结果与之前相比有什么不同
欢迎你在留言区跟我交流互动也推荐你把今天的内容分享给更多同事、朋友一起动手搭建这个Word Count流计算应用。

View File

@ -0,0 +1,171 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
31 新一代流处理框架Batch mode和Continuous mode哪家强
你好,我是吴磊。
在上一讲我们通过“流动的Word Count”示例初步结识了Structured Streaming并学习了流处理开发三要素也就是Source、流处理引擎与Sink。
今天这一讲让我们把目光集中到Structured Streaming也就是流处理引擎本身。Structured Streaming与Spark MLlib并列是Spark重要的子框架之一。值得一提的是Structured Streaming天然能够享受Spark SQL提供的处理能力与执行性能同时也能与其他子框架无缝衔接。因此基于Structured Streaming这个新一代框架开发的流处理应用天然具备优良的执行性能与良好的扩展性。
知己知彼百战百胜。想要灵活应对不同的实时计算需求我们就要先了解Structured Streaming的计算模型长啥样搞清楚它如何应对容错、保持数据一致性。我们先从计算模型说起。
计算模型
当数据像水流一样源源不断地流进Structured Streaming引擎的时候引擎并不会自动地依次消费并处理这些数据它需要一种叫做Trigger的机制来触发数据在引擎中的计算。
换句话说Trigger机制决定了引擎在什么时候、以怎样的方式和频率去处理接收到的数据流。Structured Streaming支持4种Trigger如下表所示。
要为流处理设置Trigger我们只需基于writeStream API调用trigger函数即可。Trigger的种类比较多一下子深入细节容易让你难以把握重点所以现在你只需要知道Structured Streaming支持种类繁多的Trigger即可。
我们先把注意力放在计算模型上面。对于流数据Structured Streaming支持两种计算模型分别是Batch mode和Continuous mode。所谓计算模型本质上它要解决的问题就是Spark以怎样的方式来对待并处理流数据。
这是什么意思呢没有对比就没有鉴别咱们不妨通过对比讲解Batch mode和Continuous mode来深入理解计算模型的含义。
Batch mode
我们先来说说Batch mode所谓Batch mode它指的是Spark将连续的数据流切割为离散的数据微批Micro-batch也即小份的数据集。
形象一点说Batch mode就像是“抽刀断水”两刀之间的水量就是一个Micro-batch。而每一份Micro-batch都会触发一个Spark Job每一个Job会包含若干个Tasks。学习过基础知识与Spark SQL模块之后我们知道这些Tasks最终会交由Spark SQL与Spark Core去做优化与执行。
在这样的计算模型下不同种类的Trigger如Default、Fixed interval以及One-time无非是在以不同的方式控制Micro-batch切割的粒度罢了。
比方说在Default Trigger下Spark会根据数据流的流入速率自行决定切割粒度无需开发者关心。而如果开发者想要对切割粒度进行人为的干预则可以使用Fixed interval Trigger来明确定义Micro-batch切割的时间周期。例如Trigger.ProcessingTime(“5 seconds”)表示的是每隔5秒钟切割一个Micro-batch。
Continuous mode
与Batch mode不同Continuous mode并不切割数据流而是以事件/消息Event / Message为粒度用连续的方式来处理数据。这里的事件或是消息指代的是原始数据流中最细粒度的数据形式它可以是一个单词、一行文本或是一个画面帧。
以“流动的Word Count”为例Source中的事件/消息就是一个个英文单词。说到这里你可能会有疑问“在Batch mode下Structured Streaming不也是连续地创建Micro-batch吗数据同样是不丢不漏Continuous mode与Batch mode有什么本质上的区别吗
一图胜千言对比两种计算模型的示意图我们可以轻松地发现它们之间的差异所在。在Continuous mode下Structured Streaming使用一个常驻作业Long running job来处理数据流或者说服务中的每一条消息。
那么问题来了相比每个Micro-batch触发一个作业Continuous mode选择采用常驻作业来进行服务有什么特别的收益吗或者换句话说这两种不同的计算模型各自都有哪些优劣势呢
用一句话来概括Batch mode吞吐量大、延迟高秒级而Continuous mode吞吐量低、延迟也更低毫秒级。吞吐量指的是单位时间引擎处理的消息数量批量数据能够更好地利用Spark分布式计算引擎的优势因此Batch mode在吞吐量自然更胜一筹。
而要回答为什么Continuous mode能够在延迟方面表现得更加出色我们还得从Structured Streaming的容错机制说起。
容错机制
对于任何一个流处理引擎来说,容错都是一项必备的能力。所谓容错,它指的是,在计算过程中出现错误(作业层面、或是任务层面,等等)的时候,流处理引擎有能力恢复被中断的计算过程,同时保证数据上的不重不漏,也即保证数据处理的一致性。
从数据一致性的角度出发这种容错的能力可以划分为3种水平
At most once最多交付一次数据存在丢失的风险
At least once最少交付一次数据存在重复的可能
Exactly once交付且仅交付一次数据不重不漏。
这里的交付指的是数据从Source到Sink的整个过程。对于同一条数据它可能会被引擎处理一次或在有作业或是任务失败的情况下多次但根据容错能力的不同计算结果最终可能会交付给Sink零次、一次或是多次。
聊完基本的容错概念之后我们再说回Structured Streaming。就Structured Streaming的容错能力来说Spark社区官方的说法是“结合幂等的SinkStructured Streaming能够提供Exactly once的容错能力”。
实际上这句话应该拆解为两部分。在数据处理上结合容错机制Structured Streaming本身能够提供“At least once”的处理能力。而结合幂等的SinkStructured Streaming可以实现端到端的“Exactly once”容错水平。
比方说应用广泛的Kafka在Producer级别提供跨会话、跨分区的幂等性。结合Kafka这样的Sink在端到端的处理过程中Structured Streaming可以实现“Exactly once”保证数据的不重不漏。
不过,在 Structured Streaming 自身的容错机制中为了在数据处理上做到“At least once”Batch mode 与 Continuous mode 这两种不同的计算模型,分别采用了不同的实现方式。而容错实现的不同,正是导致两种计算模型在延迟方面差异巨大的重要因素之一。
接下来我们就来说一说Batch mode 与 Continuous mode 分别如何做容错。
Batch mode容错
在Batch mode下Structured Streaming利用Checkpoint机制来实现容错。在实际处理数据流中的Micro-batch之前Checkpoint机制会把该Micro-batch的元信息全部存储到开发者指定的文件系统路径比如HDFS或是Amazon S3。这样一来当出现作业或是任务失败时引擎只需要读取这些事先记录好的元信息就可以恢复数据流的“断点续传”。
要指定Checkpoint目录只需要在writeStream API的option选项中配置checkpointLocation即可。我们以上一讲的“流动的Word Count”为例代码只需要做如下修改即可。
df.writeStream
// 指定Sink为终端Console
.format("console")
// 指定输出选项
.option("truncate", false)
// 指定Checkpoint存储地址
.option("checkpointLocation", "path/to/HDFS")
// 指定输出模式
.outputMode("complete")
//.outputMode("update")
// 启动流处理应用
.start()
// 等待中断指令
.awaitTermination()
在Checkpoint存储目录下有几个子目录分别是offsets、sources、commits和state它们所存储的内容就是各个Micro-batch的元信息日志。对于不同子目录所记录的实际内容我把它们整理到了下面的图解中供你随时参考。
-
对于每一个Micro-batch来说在它被Structured Streaming引擎实际处理之前Checkpoint机制会先把它的元信息记录到日志文件因此这些日志文件又被称为Write Ahead LogWAL日志
换句话说当源数据流进Source之后它需要先到Checkpoint目录下进行“报道”然后才会被Structured Streaming引擎处理。毫无疑问“报道”这一步耽搁了端到端的处理延迟如下图所示。
除此之外由于每个Micro-batch都会触发一个Spark作业我们知道作业与任务的频繁调度会引入计算开销因此也会带来不同程度的延迟。在运行模式与容错机制的双重加持下Batch mode的延迟水平往往维持在秒这个量级在最好的情况下能达到几百毫秒左右。
Continuous mode容错
相比Batch modeContinuous mode下的容错没那么复杂。在Continuous mode下Structured Streaming利用Epoch Marker机制来实现容错。
因为Continuous mode天然没有微批所以不会涉及到微批中的延迟到达Source中的消息可以立即被Structured Streaming引擎消费并处理。但这同时也带来一个问题那就是引擎如何把当前的处理进度做持久化从而为失败重试提供可能。
为了解决这个问题Spark引入了Epoch Marker机制。所谓Epoch Marker你可以把它理解成是水流中的“游标”这些“游标”随着水流一起流动。每个游标都是一个Epoch Marker而游标与游标之间的水量就是一个Epoch开发者可以通过如下语句来指定Epoch间隔。
writeStream.trigger(continuous = "1 second")
以表格中的代码为例对于Source中的数据流Structured Streaming每隔1秒就会安插一个Epoch Marker而两个Epoch Marker之间的数据就称为一个Epoch。你可能会问“Epoch Marker的概念倒是不难理解不过它有什么用呢
在引擎处理并交付数据的过程中每当遇到Epoch Marker的时候引擎都会把对应Epoch中最后一条消息的Offset写入日志从而实现容错。需要指出的是日志的写入是异步的因此这个过程不会对数据的处理造成延迟。
有意思的是对于这个日志的称呼网上往往也把它叫作Write Ahead Log。不过我觉得这么叫可能不太妥当原因在于准备写入日志的消息都已经被引擎消费并处理过了。Batch mode会先写日志、后处理数据而Continuous mode不一样它是先处理数据、然后再写日志。所以把Continuous mode的日志称作是“Write After Log”也许更合适一些。
我们还是用对比的方法来加深理解接下来我们同样通过消息到达Source与Structured Streaming引擎的时间线来示意Continuous mode下的处理延迟。
可以看到消息从Source产生之后可以立即被Structured Streaming引擎消费并处理因而在延迟性方面能够得到更好的保障。而Epoch Marker则会帮助引擎识别当前最新处理的消息从而把相应的Offset记录到日志中以备失败重试。
重点回顾
到此为止,今天的内容就全部讲完了,我们一起来做个总结。
今天这一讲我们学习了Structured Streaming中两种不同的计算模型——Batch mode与Continuous mode。只有了解了它们各自在吞吐量、延迟性和容错等方面的特点在面对日常工作中不同的流计算场景时我们才能更好地做出选择。
在Batch mode下Structured Streaming会将数据流切割为一个个的Micro-batch。对于每一个Micro-batch引擎都会创建一个与之对应的作业并将作业交付给Spark SQL与Spark Core付诸优化与执行。
Batch mode的特点是吞吐量大但是端到端的延迟也比较高延迟往往维持在秒的量级。Batch mode的高延迟一方面来自作业调度本身一方面来自它的容错机制也就是Checkpoint机制需要预写WALWrite Ahead Log日志。
要想获得更低的处理延迟你可以采用Structured Streaming的Continuous mode计算模型。在Continuous mode下引擎会创建一个Long running job来负责消费并服务来自Source的所有消息。
在这种情况下Continuous mode天然地避开了频繁生成、调度作业而引入的计算开销。与此同时利用Epoch Marker通过先处理数据、后记录日志的方式Continuous mode进一步消除了容错带来的延迟影响。
尺有所短、寸有所长Batch mode在吞吐量上更胜一筹而Continuous mode在延迟性方面则能达到毫秒级。
不过需要特别指出的是到目前为止在Continuous mode下Structured Streaming仅支持非聚合Aggregation类操作比如map、filter、flatMap等等。而聚合类的操作比如“流动的Word Count”中的分组计数Continuous mode暂时是不支持的这一点难免会限制Continuous mode的应用范围需要你特别注意。
每课一练
Batch mode通过预写WAL日志来实现容错请你脑洞一下有没有可能参考Continuous mode中先处理数据、后记录日志的方式把Batch mode中写日志的动作也挪到数据消费与处理之后呢
欢迎你在留言区跟我交流讨论,也推荐你把这一讲的内容分享给更多朋友。

View File

@ -0,0 +1,186 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
32 Window操作&Watermark流处理引擎提供了哪些优秀机制
你好,我是吴磊。
在上一讲我们从原理的角度出发学习了Structured Streaming的计算模型与容错机制。深入理解这些基本原理会帮我们开发流处理应用打下坚实的基础。
在“流动的Word Count”[那一讲]我们演示了在Structured Streaming框架下如何做流处理开发的一般流程。基于readStream API与writeStream API我们可以像读写DataFrame那样轻松地从Source获取数据流并把处理过的数据写入Sink。
今天这一讲咱们从功能的视角出发继续来聊一聊Structured Streaming流处理引擎都为开发者都提供了哪些特性与能力让你更灵活地设计并实现流处理应用。
Structured Streaming怎样坐享其成
学习过计算模型之后我们知道不管是Batch mode的多个Micro-batch、多个作业的执行方式还是Continuous mode下的一个Long running job这些作业的执行计划最终都会交付给Spark SQL与Spark Core付诸优化与执行。
而这会带来两个方面的收益。一方面凡是Spark SQL支持的开发能力不论是丰富的DataFrame算子还是灵活的SQL查询Structured Streaming引擎都可以拿来即用。基于之前学过的内容我们可以像处理普通的DataFrame那样对基于流数据构建的DataFrame做各式各样的转换与聚合。
另一方面既然开发入口同为DataFrame那么流处理应用同样能够享有Spark SQL提供的“性能红利”。在Spark SQL学习模块我们学习过Catalyst优化器与Tungsten这两个组件会对用户代码做高度优化从而提升应用的执行性能。
因此就框架的功能来说我们可以简单地概括为Spark SQL所拥有的能力Structured Streaming都有。不过除了基本的数据处理能力以外为了更好地支持流计算场景Structured Streaming引擎还提供了一些专门针对流处理的计算能力比如说Window操作、Watermark与延迟数据处理等等。
Window操作
我们先来说说Window操作它指的是Structured Streaming引擎会基于一定的时间窗口对数据流中的消息进行消费并处理。这是什么意思呢首先我们需要了解两个基本概念Event Time和Processing Time也即事件时间和处理时间。
所谓事件时间它指的是消息生成的时间比如我们在netcat中敲入“Apache Spark”的时间戳是“2021-10-01 09:30:00”那么这个时间就是消息“Apache Spark”的事件时间。
而处理时间它指的是这个消息到达Structured Streaming引擎的时间因此也有人把处理时间称作是到达时间Arrival Time也即消息到达流处理系统的时间。显然处理时间要滞后于事件时间。
所谓Window操作实际上就是Structured Streaming引擎基于事件时间或是处理时间以固定间隔划定时间窗口然后以窗口为粒度处理消息。在窗口的划分上Structured Streaming支持两种划分方式一种叫做Tumbling Window另一种叫做Sliding Window。
我们可以用一句话来记住二者之间的区别Tumbling Window划分出来的时间窗口“不重不漏”而Sliding Window划分出来的窗口可能会重叠、也可能会有遗漏如下图所示。
不难发现Sliding Window划分出来的窗口是否存在“重、漏”取决于窗口间隔Interval与窗口大小Size之间的关系。Tumbling Window与Sliding Window并无优劣之分完全取决于应用场景与业务需要。
干讲理论总是枯燥无趣接下来咱们对之前的“流动的Word Count”稍作调整来演示Structured Streaming中的Window操作。为了让演示的过程更加清晰明了这里我们采用Tumbling Window的划分方式Sliding Window留给你作为课后作业。
为了完成实验我们还是需要准备好两个终端。第一个终端用于启动spark-shell并提交流处理代码而第二个终端用于启动netcat、输入数据流。要基于窗口去统计单词我们仅需调整数据处理部分的代码readStream与writeStreamUpdate Mode部分的代码不需要任何改动。因此为了聚焦Window操作的学习我这里仅贴出了有所变动的部分。
df = df.withColumn("inputs", split($"value", ","))
// 提取事件时间
.withColumn("eventTime", element_at(col("inputs"),1).cast("timestamp"))
// 提取单词序列
.withColumn("words", split(element_at(col("inputs"),2), " "))
// 拆分单词
.withColumn("word", explode($"words"))
// 按照Tumbling Window与单词做分组
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
// 统计计数
.count()
为了模拟事件时间我们在netcat终端输入的消息会同时包含时间戳和单词序列。两者之间以逗号分隔而单词与单词之间还是用空格分隔如下表所示。
因此,对于输入数据的处理,我们首先要分别提取出时间戳和单词序列,然后再把单词序列展开为单词。接下来,我们按照时间窗口与单词做分组,这里需要我们特别关注这行代码:
// 按照Tumbling Window与单词做分组
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
其中window(col(“eventTime”), “5 minute”)的含义就是以事件时间为准以5分钟为间隔创建Tumbling时间窗口。显然window函数的第一个参数就是创建窗口所依赖的时间轴而第二个参数则指定了窗口大小Size。说到这里你可能会问“如果我想创建Sliding Window该怎么做呢
其实非常简单只需要在window函数的调用中再添加第三个参数即可也就是窗口间隔Interval。比如说我们还是想创建大小为5分钟的窗口但是使用以3分钟为间隔进行滑动的方式去创建那么我们就可以这样来实现window(col(“eventTime”), “5 minute”, “3 minute”)。是不是很简单?
完成基于窗口和单词的分组之后我们就可以继续调用count来完成计数了。不难发现代码中的大多数转换操作实际上都是我们常见的DataFrame算子这也印证了这讲开头说的Structured Streaming先天优势就是能坐享其成享有Spark SQL提供的“性能红利”。
代码准备好之后我们就可以把它们陆续敲入到spark-shell并等待来自netcat的数据流。切换到netcat终端并陆续注意是陆续输入刚刚的文本内容我们就可以在spark-shell终端看到如下的计算结果。
可以看到与“流动的Word Count”不同这里的统计计数是以窗口5分钟为粒度的。对于每一个时间窗口来说Structured Streaming引擎都会把事件时间落入该窗口的单词统计在内。不难推断随着时间向前推进已经计算过的窗口将不会再有状态上的更新。
比方说当引擎处理到“2021-10-01 09:39:00,Spark Streaming”这条消息记作消息39理论上前一个窗口“{2021-10-01 09:30:00, 2021-10-01 09:35:00}”记作窗口30-35的状态也就是不同单词的统计计数应该不会再有变化。
说到这里你可能会有这样的疑问“那不见得啊如果在消息39之后引擎又接收到一条事件时间落在窗口30-35的消息那该怎么办呢”要回答这个问题我们还得从Late data和Structured Streaming的Watermark机制说起。
Late data与Watermark
我们先来说Late data所谓Late data它指的是那些事件时间与处理时间不一致的消息。虽然听上去有点绕但通过下面的图解我们就能瞬间理解Late data的含义。
通常来说,消息生成的时间,与消息到达流处理引擎的时间,应该是一致的。也即先生成的消息先到达,而后生成的消息后到达,就像上图中灰色部分消息所示意的那样。
不过在现实情况中总会有一些消息因为网络延迟或者这样那样的一些原因它们的处理时间与事件时间存在着比较大的偏差。这些消息到达引擎的时间甚至晚于那些在它们之后才生成的消息。像这样的消息我们统称为“Late data”如图中红色部分的消息所示。
由于有Late data的存在流处理引擎就需要一个机制来判定Late data的有效性从而决定是否让晚到的消息参与到之前窗口的计算。
就拿红色的“Spark is cool”消息来说在它到达Structured Streaming引擎的时候属于它的事件时间窗口“{2021-10-01 09:30:00, 2021-10-01 09:35:00}”已经关闭了。那么在这种情况下Structured Streaming到底要不要用消息“Spark is cool”中的单词去更新窗口30-35的状态单词计数
为了解决Late data的问题Structured Streaming采用了一种叫作Watermark的机制来应对。为了让你能够更容易地理解Watermark机制的原理在去探讨它之前我们先来澄清两个极其相似但是又完全不同的概念水印和水位线。
要说清楚水印和水位线,咱们不妨来做个思想实验。假设桌子上有一盒鲜牛奶、一个吸管、还有一个玻璃杯。我们把盒子开个口,把牛奶全部倒入玻璃杯,接着,把吸管插入玻璃杯,然后通过吸管喝一口新鲜美味的牛奶。好啦,实验做完了,接下来,我们用它来帮我们澄清概念。
如图所示,最开始的时候,我们把牛奶倒到水印标示出来的高度,然后用吸管喝牛奶。不过,不论我们通过吸管喝多少牛奶,水印位置的牛奶痕迹都不会消失,也就是说,水印的位置是相对固定的。而水位线则不同,我们喝得越多,水位线下降得就越快,直到把牛奶喝光,水位线降低到玻璃杯底部。
好啦澄清了水印与水位线的概念之后我们还需要把这两个概念与流处理中的概念对应上。毕竟“倒牛奶”的思想实验是用来辅助我们学习Watermark机制的。
首先水印与水位线对标的都是消息的事件时间。水印相当于系统当前接收到的所有消息中最大的事件时间。而水位线指的是水印对应的事件时间减去用户设置的容忍值。为了叙述方便我们把这个容忍值记作T。在Structured Streaming中我们把水位线对应的事件时间称作Watermark如下图所示。
显然在流处理引擎不停地接收消息的过程中水印与水位线也会相应地跟着变化。这个过程跟我们刚刚操作的“倒牛奶、喝牛奶”的过程很像。每当新到消息的事件时间大于当前水印的时候系统就会更新水印这就好比我们往玻璃杯里倒牛奶一直倒到最大事件时间的位置。然后我们用吸管喝牛奶吸掉深度为T的牛奶让水位线下降到Watermark的位置。
把不同的概念关联上之后接下来我们来正式地介绍Structured Streaming的Watermark机制。我们刚刚说过Watermark机制是用来决定哪些Late data可以参与过往窗口状态的更新而哪些Late data则惨遭抛弃。
如果用文字去解释Watermark机制很容易把人说得云里雾里因此咱们不妨用一张流程图来阐释这个过程。
可以看到当有新消息到达系统后Structured Streaming首先判断它的事件时间是否大于水印。如果事件时间大于水印的话Watermark机制则相应地更新水印与水位线也就是最大事件时间与Watermark。
相反假设新到消息的事件时间在当前水印以下那么系统进一步判断消息的事件时间与“Watermark时间窗口下沿”的关系。所谓“Watermark时间窗口下沿”它指的是Watermark所属时间窗口的起始时间。
咱们来举例说明假设Watermark为“2021-10-01 09:34:00”且事件时间窗口大小为5分钟那么Watermark所在时间窗口就是[“2021-10-01 09:30:00”“2021-10-01 09:35:00”]也即窗口30-35。这个时候“Watermark时间窗口下沿”就是窗口30-35的起始时间也就是“2021-10-01 09:30:00”如下图所示。
对于最新到达的消息如果其事件时间大于“Watermark时间窗口下沿”则消息可以参与过往窗口的状态更新否则消息将被系统抛弃不再参与计算。换句话说凡是事件时间小于“Watermark时间窗口下沿”的消息系统都认为这样的消息来得太迟了没有资格再去更新以往计算过的窗口。
不难发现在这个过程中延迟容忍度T是Watermark机制中的决定性因素它决定了“多迟”的消息可以被系统容忍并接受。那么问题来了既然T是由用户设定的那么用户通过什么途径来设定这个T呢再者在Structured Streaming的开发框架下Watermark机制要如何生效呢
其实要开启Watermark机制、并设置容忍度T我们只需一行代码即可搞定。接下来我们就以刚刚“带窗口的流动Word Count”为例演示并说明Watermark机制的具体用法。
df = df.withColumn("inputs", split($"value", ","))
// 提取事件时间
.withColumn("eventTime", element_at(col("inputs"),1).cast("timestamp"))
// 提取单词序列
.withColumn("words", split(element_at(col("inputs"),2), " "))
// 拆分单词
.withColumn("word", explode($"words"))
// 启用Watermark机制指定容忍度T为10分钟
.withWatermark("eventTime", "10 minute")
// 按照Tumbling Window与单词做分组
.groupBy(window(col("eventTime"), "5 minute"), col("word"))
// 统计计数
.count()
可以看到,除了“.withWatermark(“eventTime”, “10 minute”)”这一句代码其他部分与“带窗口的流动Word Count”都是一样的。这里我们用withWatermark函数来启用Watermark机制该函数有两个参数第一个参数是事件时间而第二个参数就是由用户指定的容忍度T。
为了演示Watermark机制产生的效果接下来咱们对netcat输入的数据流做一些调整如下表所示。注意消息7“Test Test”和消息8“Spark is cool”都是Late data。
基于我们刚刚对于Watermark机制的分析在容忍度T为10分钟的情况下Late data消息8“Spark is cool”会被系统接受并消费而消息7“Test Test”则将惨遭抛弃。你不妨先花点时间自行推断出这一结论然后再来看后面的结果演示。
上图中左侧是输入消息7“Test Test”时spark-shell端的输出可以看到消息7被系统丢弃没能参与计算。而右侧是消息8“Spark is cool”对应的执行结果可以看到“Spark”、“is”、“cool”这3个单词成功地更新了之前窗口30-35的状态注意这里的“Spark”计数为3而不是1
重点回顾
好啦今天的内容到这里就讲完了我们一起来做个总结。首先我们需要知道在数据处理方面Structured Streaming完全可以复用Spark SQL现有的功能与性能优势。因此开发者完全可以“坐享其成”使用DataFrame算子或是SQL语句来完成流数据的处理。
再者我们需要特别关注并掌握Structured Streaming的Window操作与Watermark机制。Structured Streaming支持两类窗口一个是“不重不漏”的Tumbling Window另一个是“可重可漏”的Sliding Window。二者并无高下之分作为开发者我们可以使用window函数结合事件时间、窗口大小、窗口间隔等多个参数来灵活地在两种窗口之间进行取舍。
对于Late data的处理Structured Streaming使用Watermark机制来决定其是否参与过往窗口的计算与更新。关于Watermark机制的工作原理我把它整理到了下面的流程图中供你随时查看。
每课一练
请你结合Tumbling Window的代码把Tumbling Window改为Sliding Window。-
对于Watermark机制中的示例请你分析一下为什么消息8“Spark is cool”会被系统接受并处理而消息7“Test Test”却惨遭抛弃
欢迎你在留言区跟我交流讨论,也推荐你把这一讲分享给更多同事、朋友。

View File

@ -0,0 +1,247 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
33 流计算中的数据关联:流与流、流与批
你好,我是吴磊。
在上一讲我们提到Structured Streaming会复用Spark SQL所提供的一切数据处理能力比如数据抽取、过滤、分组聚合、关联、排序等等。不过在这些常规的数据处理类型中有一类操作需要我们特别关注它就是数据关联Joins
这主要是出于两方面的原因,一来,数据关联的应用非常普遍,可以说是数据应用中“出场率”最高的操作类型之一;再者,与批处理中的数据关联不同,流计算中的数据关联,还需要考虑到流处理过程中固有的一些限制,比如说时间窗口、数据延迟容忍度、输出模式,等等。
因此今天这一讲我们专门来说一说Structured Streaming中的数据关联。我们先盘点好Structured Streaming的技能树看看它都支持哪些种类的数据关联。之后再用一个短视频推荐的例子上手试验一下总结出不同类型数据关联的适用场景以及注意事项。
流计算中的数据关联
我们知道如果按照关联形式来划分的话数据关联可以分为Inner Join、Left Join、Right Join、Semi Join、Anti Join等等。如果按照实现方式来划分的话可以分为Nested Loop Join、Sort Merge Join和Hash Join。而如果考虑分布式环境下数据分发模式的话Join又可以分为Shuffle Join和Broadcast Join。
对于上述的3种分类标准它们之间是相互正交的我们在Spark SQL学习模块介绍过它们各自的适用场景与优劣势记不清的可以回顾第[17]、[18]讲)。
而在流计算的场景下按照数据来源的不同数据关联又可以分为“流批关联”与“双流关联”。所谓“流批关联”Stream-Static Join它指的是参与关联的一张表来自离线批数据而另一张表的来源是实时的数据流。换句话说动态的实时数据流可以与静态的离线数据关联在一起为我们提供多角度的数据洞察。
而“双流关联”Stream-Stream Join顾名思义它的含义是参与关联的两张表都来自于不同的数据流属于动态数据与动态数据之间的关联计算如下图所示。
显然相对于关联形式、实现方式和分发模式数据来源的分类标准与前三者也是相互正交的。我们知道基于前3种分类标准数据关联已经被划分得足够细致。再加上一种正交的分类标准数据关联的划分只会变得更为精细。
更让人头疼的是在Structured Streaming流计算框架下“流批关联”与“双流关联”对于不同的关联形式有着不同的支持与限制。而这也是我们需要特别关注流处理中数据关联的原因之一。
接下来,我们就分别对“流批关联”和“双流关联”进行展开,说一说它们支持的功能与特性,以及可能存在的限制。本着由简入难的原则,我们先来介绍“流批关联”,然后再去说“双流关联”。
流批关联
为了更好地说明流批关联,咱们不妨从一个实际场景入手。在短视频流行的当下,推荐引擎扮演着极其重要的角色,而要想达到最佳的推荐效果,推荐引擎必须依赖用户的实时反馈。
所谓实时反馈,其实就是我们习以为常的点赞、评论、转发等互动行为,不过,这里需要突出的,是一个“实时性”、或者说“及时性”。毕竟,在选择越来越多的今天,用户的兴趣与偏好,也在随着时间而迁移、变化,捕捉用户最近一段时间的兴趣爱好更加重要。
假设现在我们需要把离线的用户属性和实时的用户反馈相关联从而建立用户特征向量。显然在这个特征向量中我们既想包含用户自身的属性字段如年龄、性别、教育背景、职业等等更想包含用户的实时互动信息比如1小时内的点赞数量、转发数量等等从而对用户进行更为全面的刻画。
一般来说,实时反馈来自线上的数据流,而用户属性这类数据,往往存储在离线数据仓库或是分布式文件系统。因此,用户实时反馈与用户属性信息的关联,正是典型的流批关联场景。
那么,针对刚刚说的短视频场景,我们该如何把离线用户属性与线上用户反馈“合二为一”呢?为了演示流批关联的过程与用法,咱们自然需要事先把离线数据与线上数据准备好。本着一切从简的原则,让你仅用笔记本电脑就能复现咱们课程中的实例,这里我们使用本地文件系统来存放离线的用户属性。
而到目前为止对于数据流的生成我们仅演示过Socket的用法。实际上除了用于测试的Socket以外Structured Streaming还支持Kafka、文件等Source作为数据流的来源。为了尽可能覆盖更多知识点这一讲咱们不妨通过文件的形式来模拟线上的用户反馈。
还记得吗Structured Streaming通过readStream API来创建各式各样的数据流。要以文件的方式创建数据流我们只需将文件格式传递给format函数然后启用相应的option即可如下所示。关于readStream API的一般用法你可以回顾“流动的Word Count”[第30讲])。
var streamingDF: DataFrame = spark.readStream
.format("csv")
.option("header", true)
.option("path", s"${rootPath}/interactions")
.schema(actionSchema)
.load
对于这段代码片段来说需要你特别注意两个地方。一个是format函数它的形参是各式各样的文件格式如CSV、Parquet、ORC等等。第二个地方是指定监听地址的option选项也就是option(“path”, s”${rootPath}/interactions”)。
该选项指定了Structured Streaming需要监听的文件系统目录一旦有新的数据内容进入该目录Structured Streaming便以流的形式把新数据加载进来。
需要说明的是上面的代码并不完整目的是让你先对文件形式的Source建立初步认识。随着后续讲解的推进待会我们会给出完整版代码并详细讲解其中的每一步。
要用文件的形式模拟数据流的生成我们只需将包含用户互动行为的文件依次拷贝到Structured Streaming的监听目录即可在我们的例子中也就是interactions目录。
如上图的步骤1所示我们事先把用户反馈文件保存到临时的staging目录中然后依次把文件拷贝到interactions目录即可模拟数据流的生成。而用户属性信息本身就是离线数据因此我们把相关数据文件保存到userProfile目录即可如图中步骤3所示。
对于上面的流批关联计算过程在给出代码实现之前咱们不妨先来了解一下数据从而更好地理解后续的代码内容。离线的用户属性比较简单仅包含id、name、age与gender四个字段文件内容如下所示。
线上的用户反馈相对复杂一些分别包含userId、videoId、event、eventTime等字段。前两个字段分别代表用户ID与短视频ID而event是互动类型包括Like点赞、Comment评论、Forward转发三个取值eventTime则代表产生互动的时间戳如下所示。
除了上面的interactions0.csv以外为了模拟数据流的生成我还为你准备了interactions1.csv、interactions2.csv两个文件它们的Schema与interactions0.csv完全一致内容也大同小异。对于这3个文件我们暂时把它们缓存在staging目录下。
好啦数据准备好之后接下来我们就可以从批数据与流数据中创建DataFrame并实现两者的关联达到构建用户特征向量的目的。首先我们先来加载数据。
import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.types.StructType
// 保存staging、interactions、userProfile等文件夹的根目录
val rootPath: String = _
// 使用read API读取离线数据创建DataFrame
val staticDF: DataFrame = spark.read
.format("csv")
.option("header", true)
.load(s"${rootPath}/userProfile/userProfile.csv")
// 定义用户反馈文件的Schema
val actionSchema = new StructType()
.add("userId", "integer")
.add("videoId", "integer")
.add("event", "string")
.add("eventTime", "timestamp")
// 使用readStream API加载数据流注意对比readStream API与read API的区别与联系
var streamingDF: DataFrame = spark.readStream
// 指定文件格式
.format("csv")
.option("header", true)
// 指定监听目录
.option("path", s"${rootPath}/interactions")
// 指定数据Schema
.schema(actionSchema)
.load
为了方便你把代码与计算流程对应上这里我再一次把流批关联示意图贴在了下面。上述代码对应的是下图中的步骤2与步骤3也就是流数据与批数据的加载。
从代码中我们不难发现readStream API与read API的用法几乎如出一辙不仅如此二者的返回类型都是DataFrame。因此流批关联在用法上与普通的DataFrame之间的关联看上去并没有什么不同如下所示。
// 互动数据分组、聚合对应流程图中的步骤4
streamingDF = streamingDF
// 创建Watermark设置最大容忍度为30分钟
.withWatermark("eventTime", "30 minutes")
// 按照时间窗口、userId与互动类型event做分组
.groupBy(window(col("eventTime"), "1 hours"), col("userId"), col("event"))
// 记录不同时间窗口,用户不同类型互动的计数
.count
/**
流批关联对应流程图中的步骤5
可以看到与普通的两个DataFrame之间的关联看上去没有任何差别
*/
val jointDF: DataFrame = streamingDF.join(staticDF, streamingDF("userId") === staticDF("id"))
除了在用法上没有区别以外普通DataFrame数据关联中适用的优化方法同样适用于流批关联。比方说对于streamingDF来说它所触发的每一个Micro-batch都会扫描一次staticDF所封装的离线数据。
显然在执行效率方面这并不是一种高效的做法。结合Spark SQL模块学到的Broadcast Join的优化方法我们完全可以在staticDF之上创建广播变量然后把流批关联原本的Shuffle Join转变为Broadcast Join来提升执行性能。这个优化技巧仅涉及几行代码的修改因此我把它留给你作为课后作业去练习。
完成流批关联之后我们还需要把计算结果打印到终端Console是Structured Streaming支持的Sink之一它可以帮我们确认计算结果与预期是否一致如下所示。
jointDF.writeStream
// 指定Sink为终端Console
.format("console")
// 指定输出选项
.option("truncate", false)
// 指定输出模式
.outputMode("update")
// 启动流处理应用
.start()
// 等待中断指令
.awaitTermination()
上面这段代码想必你并不陌生咱们在之前的几讲中都是指定Console为输出Sink这里的操作没什么不同。
好啦到此为止流批关联实例的完整代码就是这些了。接下来让我们把代码敲入本地环境的spark-shell然后依次把staging文件夹中的interactions*.csv拷贝到interactions目录之下来模拟数据流的生成从而触发流批关联的计算。代码与数据的全部内容你可以通过这里的GitHub地址进行下载。
这里我贴出部分计算结果供你参考。下面的截图是我们把interactions0.csv文件拷贝到interactions目录之后得到的结果你可以在你的环境下去做验证同时继续把剩下的两个文件拷贝到监听目录来进一步观察流批关联的执行效果。
双流关联
了解了流批关联之后,我们再来说说“双流关联”。显然,与流批关联相比,双流关联最主要的区别是数据来源的不同。除此之外,在双流关联中,事件时间的处理尤其关键。为什么这么说呢?
学过上一讲之后我们知道在源源不断的数据流当中总会有Late Data产生。Late Data需要解决的主要问题就是其是否参与当前批次的计算。
毫无疑问数据关联是一种最为常见的计算。因此在双流关联中我们应该利用Watermark机制明确指定两条数据流各自的Late Data“容忍度”从而避免Structured Streaming为了维护状态数据而过度消耗系统资源。Watermark的用法很简单你可以通过回顾[上一讲]来进行复习。
说到这里,你可能会问:“什么是状态数据?而维护状态数据,又为什么会过度消耗系统资源呢?”一图胜千言,咱们不妨通过下面的示意图,来说明状态数据的维护,会带来哪些潜在的问题和隐患。
假设咱们有两个数据流一个是短视频发布的数据流其中记录着短视频相关的元信息如ID、Name等等。另一个数据流是互动流也就是用户对于短视频的互动行为。其实在刚刚的流批关联例子中我们用到数据流也是互动流这个你应该不会陌生。
现在我们想统计短视频在发布一段时间比如1个小时、6个小时、12个小时等等之后每个短视频的热度。所谓热度其实就是转评赞等互动行为的统计计数。
要做到这一点咱们可以先根据短视频ID把两个数据流关联起来然后再做统计计数。上图演示的是两条数据流在Micro-batch模式下的关联过程。为了直击要点咱们把注意力放在ID=1的短视频上。
显然在视频流中短视频的发布有且仅有一次即便是内容完全相同的短视频在数据的记录上也会有不同的ID值。而在互动流中ID=1的数据条目会有多个而且会分布在不同的Micro-batch中。事实上只要视频没有下线随着时间的推移互动流中总会夹带着ID=1的互动行为数据。
为了让视频流中ID=1的记录能够与互动流的数据关联上我们需要一直把视频流中批次0的全部内容缓存在内存中从而去等待“迟到”的ID=1的互动流数据。像视频流这种为了后续计算而不得不缓存下来的数据我们就把它称作为“状态数据”。显然状态数据在内存中积压的越久、越多内存的压力就越大。
在双流关联中除了要求两条数据流要添加Watermark机之外为了进一步限制状态数据的尺寸Structured Streaming还要求在关联条件中对于事件时间加以限制。这是什么意思呢咱们还是结合视频流与互动流的示例通过代码来解读。
import org.apache.spark.sql.DataFrame
import org.apache.spark.sql.types.StructType
// 保存staging、interactions、userProfile等文件夹的根目录
val rootPath: String = _
// 定义视频流Schema
val postSchema = new StructType().add("id", "integer").add("name", "string").add("postTime", "timestamp")
// 监听videoPosting目录以实时数据流的方式加载新加入的文件
val postStream: DataFrame = spark.readStream.format("csv").option("header", true).option("path", s"${rootPath}/videoPosting").schema(postSchema).load
// 定义Watermark设置Late data容忍度
val postStreamWithWatermark = postStream.withWatermark("postTime", "5 minutes")
// 定义互动流Schema
val actionSchema = new StructType().add("userId", "integer").add("videoId", "integer").add("event", "string").add("eventTime", "timestamp")
// 监听interactions目录以实时数据流的方式加载新加入的文件
val actionStream: DataFrame = spark.readStream.format("csv").option("header", true).option("path", s"${rootPath}/interactions").schema(actionSchema).load
// 定义Watermark设置Late data容忍度
val actionStreamWithWatermark = actionStream.withWatermark("eventTime", "1 hours")
// 双流关联
val jointDF: DataFrame = actionStreamWithWatermark
.join(postStreamWithWatermark,
expr("""
// 设置Join Keys
videoId = id AND
// 约束Event time
eventTime >= postTime AND
eventTime <= postTime + interval 1 hour
"""))
代码的前两部分比较简单分别是从监听文件夹读取新增的文件内容依次创建视频流和互动流并在两条流上设置Watermark机制。这些内容之前已经学过不再重复咱们把重点放在最后的双流关联代码上。
可以看到在关联条件中除了要设置关联的主外键之外还必须要对两张表各自的事件时间进行约束。其中postTime是视频流的事件时间而eventTime是互动流的事件时间。上述代码的含义是对于任意发布的视频流我们只关心它一小时以内的互动行为一小时以外的互动数据将不再参与关联计算。
这样一来在Watermark机制的“保护”之下事件时间的限制进一步降低了状态数据需要在内存中保存的时间从而降低系统资源压力。简言之对于状态数据的维护有了Watermark机制与事件时间的限制可谓是加了“双保险”。
重点回顾
好啦到这里我们今天的内容就讲完啦咱们一起来做个总结。首先我们要知道根据数据流的来源不同Structured Streaming支持“流批关联”和“双流关联”两种关联模式。
流批关联统一了流处理与批处理二者的统一使得Structured Streaming有能力服务于更广泛的业务场景。流批关联的用法相对比较简单通过readStream API与read API分别读取实时流数据与离线数据然后按照一般Join语法完成数据关联。
在今天的演示中我们用到了File这种形式的Source你需要掌握File Source的一般用法。具体来说你需要通过readStream API的format函数来指定文件格式然后通过option指定监听目录。一旦有新的文件移动到监听目录Spark便以数据流的形式加载新数据。
对于双流关联来说我们首先需要明白在这种模式下Structured Streaming需要缓存并维护状态数据。状态数据的维护主要是为了保证计算逻辑上的一致性。为了让满足条件的Late data同样能够参与计算Structured Streaming需要一直在内存中缓存状态数据。毫无疑问状态数据的堆积会对系统资源带来压力与隐患。
为了减轻这样的压力与隐患在双流关联中一来我们应该对参与关联的两条数据流设置Watermark机制再者在语法上Structured Streaming在关联条件中会强制限制事件时间的适用范围。在这样的“双保险”机制下开发者即可将状态数据维护带来的性能隐患限制在可控的范围内从而在实现业务逻辑的同时保证应用运行稳定。
课后练习题
今天的题目有两道。
第一道题目是我在流批关联那里用interactions0.csv文件给你演示了数据关联操作/请你动手在你的环境下去做验证同时继续把剩下的两个文件interactions1.csv、interactions2.csv两个文件拷贝到监听目录来进一步观察流批关联的执行效果。
第二道题目是在双流关联中我们需要Watermark和关联条件来同时约束状态数据维护的成本与开销。那么在流批关联中我们是否也需要同样的约束呢为什么
欢迎你在留言区跟我交流互动,也推荐你把这一讲分享给更多同事、朋友。

View File

@ -0,0 +1,421 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
34 Spark + Kafka流计算中的“万金油”
你好,我是吴磊。
在前面的几讲中咱们不止一次提到就数据源来说Kafka是Structured Streaming最重要的Source之一。在工业级的生产系统中Kafka与Spark这对组合最为常见。因此掌握Kafka与Spark的集成对于想从事流计算方向的同学来说是至关重要的。
今天这一讲咱们就来结合实例说一说Spark与Kafka这对“万金油”组合如何使用。随着业务飞速发展各家公司的集群规模都是有增无减。在集群规模暴涨的情况下资源利用率逐渐成为大家越来越关注的焦点。毕竟不管是自建的Data center还是公有云每台机器都是真金白银的投入。
实例:资源利用率实时计算
咱们今天的实例就和资源利用率的实时计算有关。具体来说我们首先需要搜集集群中每台机器的资源CPU、内存利用率并将其写入Kafka。然后我们使用Spark的Structured Streaming来消费Kafka数据流并对资源利用率数据做初步的分析与聚合。最后再通过Structured Streaming将聚合结果打印到Console、并写回到Kafka如下图所示。
一般来说在工业级应用中上图中的每一个圆角矩形在部署上都是独立的。绿色矩形代表待监测的服务器集群蓝色矩形表示独立部署的Kafka集群而红色的Spark集群也是独立部署的。所谓独立部署它指的是集群之间不共享机器资源如下图所示。
如果你手头上没有这样的部署环境,也不用担心。要完成资源利用率实时计算的实例,咱们不必非要依赖独立部署的分布式集群。实际上,仅在单机环境中,你就可以复现今天的实例。
课程安排
今天这一讲涉及的内容比较多,在正式开始课程之前,咱们不妨先梳理一下课程内容,让你做到心中有数。
对于上图的1、2、3、4这四个步骤我们会结合代码实现分别讲解如下这四个环节
生成CPU与内存消耗数据流写入Kafka-
Structured Streaming消费Kafka数据并做初步聚合-
Structured Streaming将计算结果打印到终端-
Structured Streaming将计算结果写回Kafka以备后用。
除此之外为了照顾不熟悉Kafka的同学咱们还会对Kafka的安装、Topic创建与消费、以及Kafka的基本概念做一个简单的梳理。
速读Kafka的架构与运行机制
在完成前面交代的计算环节之前我们需要了解Kafka都提供了哪些核心功能。
在大数据的流计算生态中Kafka是应用最为广泛的消息中间件Messaging Queue。消息中间件的核心功能有以下三点。
连接消息生产者与消息消费者;-
缓存生产者生产的消息(或者说事件);-
有能力让消费者以最低延迟访问到消息。
所谓消息生产者它指的是事件或消息的来源与渠道。在我们的例子中待监测集群就是生产者。集群中的机器源源不断地生产资源利用率消息。相应地消息的消费者它指的是访问并处理消息的系统。显然在这一讲的例子中消费者是Spark。Structured Streaming读取并处理Kafka中的资源利用率消息对其进行聚合、汇总。
经过前面的分析,我们不难发现,消息中间件的存在,让生产者与消费者这两个系统之间,天然地享有如下三方面的收益。
解耦:双方无需感知对方的存在,二者除了消息本身以外,再无交集;
异步:双方都可以按照自己的“节奏”和“步调”,来生产或是消费消息,而不必受制于对方的处理能力;
削峰:当消费者订阅了多个生产者的消息,且多个生产者同时生成大量消息时,得益于异步模式,消费者可以灵活地消费并处理消息,从而避免计算资源被撑爆的隐患。
好啦了解了Kafka的核心功能与特性之后接下来我们说一说Kafka的系统架构。与大多数主从架构的大数据组件如HDFS、YARN、Spark、Presto、Flink等等不同Kafka为无主架构。也就是说在Kafka集群中没有Master这样一个角色来维护全局的数据状态。
集群中的每台Server被称为Kafka BrokerBroker的职责在于存储生产者生产的消息并为消费者提供数据访问。Broker与Broker之间都是相互独立的彼此不存在任何的依赖关系。
如果就这么平铺直叙去介绍Kafka架构的话难免让你昏昏欲睡所以我们上图解。配合示意图解释Kafka中的关键概念会更加直观易懂。
刚刚说过Kafka为无主架构它依赖ZooKeeper来存储并维护全局元信息。所谓元信息它指的是消息在Kafka集群中的分布与状态。在逻辑上消息隶属于一个又一个的Topic也就是消息的话题或是主题。在上面的示意图中蓝色圆角矩形所代表的消息全部隶属于Topic A而绿色圆角矩形则隶属于Topic B。
而在资源利用率的实例中我们会创建两个Topic一个是CPU利用率cpu-monitor另一个是内存利用率mem-monitor。生产者在向Kafka写入消息的时候需要明确指明消息隶属于哪一个Topic。比方说关于CPU的监控数据应当发往cpu-monitor而对于内存的监控数据则应该发往mem-monitor。
为了平衡不同Broker之间的工作负载在物理上同一个Topic中的消息以分区、也就是Partition为粒度进行存储示意图中的圆角矩形代表的正是一个个数据分区。在Kafka中一个分区实际上就是磁盘上的一个文件目录。而消息则依序存储在分区目录的文件中。
为了提供数据访问的高可用HAHigh Availability在生产者把消息写入主分区Leader之后Kafka会把消息同步到多个分区副本Follower示意图中的步骤1与步骤2演示了这个过程。
一般来说消费者默认会从主分区拉取并消费数据如图中的步骤3所示。而当主分区出现故障、导致数据不可用时Kafka就会从剩余的分区副本中选拔出一个新的主分区来对外提供服务这个过程又称作“选主”。
好啦到此为止Kafka的基础功能和运行机制我们就讲完了尽管这些介绍不足以覆盖Kafka的全貌但是对于初学者来说这些概念足以帮我们进军实战做好Kafka与Spark的集成。
Kafka与Spark集成
接下来咱们就来围绕着“资源利用率实时计算”这个例子手把手地带你实现Kafka与Spark的集成过程。首先第一步我们先来准备Kafka环境。
Kafka环境准备
要配置Kafka环境我们只需要简单的三个步骤即可
安装ZooKeeper、安装Kafka启动ZooKeeper-
修改Kafka配置文件server.properties设置ZooKeeper相关配置项-
启动Kafka创建Topic。
首先,咱们从 ZooKeeper官网与 Kafka官网分别下载二者的安装包。然后依次解压安装包、并配置相关环境变量即可如下表所示。
// 下载ZooKeeper安装包
wget https://archive.apache.org/dist/zookeeper/zookeeper-3.7.0/apache-zookeeper-3.7.0-bin.tar.gz
// 下载Kafka安装包
wget https://archive.apache.org/dist/kafka/2.8.0/kafka_2.12-2.8.0.tgz
// 把ZooKeeper解压并安装到指定目录
tar -zxvf apache-zookeeper-3.7.0-bin.tar.gz -C /opt/zookeeper
// 把Kafka解压并安装到指定目录
tar -zxvf kafka_2.12-2.8.0.tgz -C /opt/kafka
// 编辑环境变量
vi ~/.bash_profile
/** 输入如下内容到文件中
export ZOOKEEPER_HOME=/opt/zookeeper/apache-zookeeper-3.7.0-bin
export KAFKA_HOME=/opt/kafka/kafka_2.12-2.8.0
export PATH=$PATH:$ZOOKEEPER_HOME/bin:$KAFKA_HOME/bin
*/
// 启动ZooKeeper
zkServer.sh start
接下来我们打开Kafka配置目录下也即$KAFKA_HOME/config的server.properties文件将其中的配置项zookeeper.connect设置为“hostname:2181”也就是主机名加端口号。
如果你把ZooKeeper和Kafka安装到同一个节点那么hostname可以写localhost。而如果是分布式部署hostname要写ZooKeeper所在的安装节点。一般来说ZooKeeper默认使用2181端口来提供服务这里我们使用默认端口即可。
配置文件设置完毕之后我们就可以使用如下命令在多个节点启动Kafka Broker。
kafka-server-start.sh -daemon $KAFKA_HOME/config/server.properties
Kafka启动之后咱们就来创建刚刚提到的两个Topiccpu-monitor和mem-monitor它们分别用来存储CPU利用率消息与内存利用率消息。
kafka-topics.sh --zookeeper hostname:2181/kafka --create
--topic cpu-monitor
--replication-factor 3
--partitions 1
kafka-topics.sh --zookeeper hostname:2181/kafka --create
--topic mem-monitor
--replication-factor 3
--partitions 1
怎么样是不是很简单要创建Topic只要指定ZooKeeper服务地址、Topic名字和副本数量即可。不过这里需要特别注意的是副本数量也就是replication-factor不能超过集群中的Broker数量。所以如果你是本地部署的话也就是所有服务都部署到一台节点那么这里的replication-factor应该设置为1。
好啦到此为止Kafka环境安装、配置完毕。下一步我们就该让生产者去生产资源利用率消息并把消息源源不断地注入Kafka集群了。
消息的生产
在咱们的实例中我们要做的是监测集群中每台机器的资源利用率。因此我们需要这些机器每隔一段时间就把CPU和内存利用率发送出来。而要做到这一点咱们只需要完成一下两个两个必要步骤
每台节点从本机收集CPU与内存使用数据-
把收集到的数据按照固定间隔发送给Kafka集群。-
由于消息生产这部分代码比较长而我们的重点是学习Kafka与Spark的集成因此这里咱们只给出这两个步骤所涉及的关键代码片段。完整的代码实现你可以从这里进行下载。
import java.lang.management.ManagementFactory
import java.lang.reflect.Modifier
def getUsage(mothedName: String): Any = {
// 获取操作系统Java Bean
val operatingSystemMXBean = ManagementFactory.getOperatingSystemMXBean
// 获取操作系统对象中声明过的方法
for (method <- operatingSystemMXBean.getClass.getDeclaredMethods) {
method.setAccessible(true)
// 判断是否为我们需要的方法名
if (method.getName.startsWith(mothedName) && Modifier.isPublic(method.getModifiers)) {
// 调用并执行方法获取指定资源CPU或内存的利用率
return method.invoke(operatingSystemMXBean)
}
}
throw new Exception(s"Can not reflect method: ${mothedName}")
}
// 获取CPU利用率
def getCPUUsage(): String = {
var usage = 0.0
try{
// 调用getUsage方法传入”getSystemCpuLoad”参数获取CPU利用率
usage = getUsage("getSystemCpuLoad").asInstanceOf[Double] * 100
} catch {
case e: Exception => throw e
}
usage.toString
}
// 获取内存利用率
def getMemoryUsage(): String = {
var freeMemory = 0L
var totalMemory = 0L
var usage = 0.0
try{
// 调用getUsage方法传入相关内存参数获取内存利用率
freeMemory = getUsage("getFreePhysicalMemorySize").asInstanceOf[Long]
totalMemory = getUsage("getTotalPhysicalMemorySize").asInstanceOf[Long]
// 用总内存,减去空闲内存,获取当前内存用量
usage = (totalMemory - freeMemory.doubleValue) / totalMemory * 100
} catch {
case e: Exception => throw e
}
usage.toString
}
利用Java的反射机制获取资源利用率
上面的代码用来获取CPU与内存利用率。在这段代码中最核心的部分是利用Java的反射机制来获取操作系统对象的各个公有方法然后通过调用这些公有方法来完成资源利用率的获取。
不过看到这你可能会说“我并不了解Java的反射机制上面的代码看不太懂。”这也没关系只要你能结合注释把上述代码的计算逻辑搞清楚即可。获取到资源利用率的数据之后接下来我们就可以把它们发送给Kafka了。
import org.apache.kafka.clients.producer.{Callback, KafkaProducer, ProducerConfig, ProducerRecord}
import org.apache.kafka.common.serialization.StringSerializer
// 初始化属性信息
def initConfig(clientID: String): Properties = {
val props = new Properties
val brokerList = "localhost:9092"
// 指定Kafka集群Broker列表
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, brokerList)
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, classOf[StringSerializer].getName)
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, classOf[StringSerializer].getName)
props.put(ProducerConfig.CLIENT_ID_CONFIG, clientID)
props
}
val clientID = "usage.monitor.client"
val cpuTopic = "cpu-monitor"
val memTopic = "mem-monitor"
// 定义属性其中包括Kafka集群信息、序列化方法等等
val props = initConfig(clientID)
// 定义Kafka Producer对象用于发送消息
val producer = new KafkaProducer[String, String](props)
// 回调函数,可暂时忽略
val usageCallback = _
while (true) {
var cpuUsage = new String
var memoryUsage = new String
// 调用之前定义的函数获取CPU、内存利用率
cpuUsage = getCPUUsage()
memoryUsage = getMemoryUsage()
// 为CPU Topic生成Kafka消息
val cpuRecord = new ProducerRecord[String, String](cpuTopic, clientID, cpuUsage)
// 为Memory Topic生成Kafka消息
val memRecord = new ProducerRecord[String, String](memTopic, clientID, memoryUsage)
// 向Kafka集群发送CPU利用率消息
producer.send(cpuRecord, usageCallback)
// 向Kafka集群发送内存利用率消息
producer.send(memRecord, usageCallback)
// 设置发送间隔2秒
Thread.sleep(2000)
}
从上面的代码中,我们不难发现,其中的关键步骤有三步:
定义Kafka Producer对象其中需要我们在属性信息中指明Kafka集群相关信息
调用之前定义的函数getCPUUsage、getMemoryUsage获取CPU与内存资源利用率
把资源利用率封装为消息并发送给对应的Topic。-
好啦到此为止生产端的事情我们就全部做完啦。在待监测的集群中每隔两秒钟每台机器都会向Kafka集群的cpu-monitor和mem-monitor这两个Topic发送即时消息。Kafka接收到这些消息之后会把它们落盘到相应的分区中等待着下游也就是Spark的消费。
消息的消费
接下来终于要轮到Structured Streaming闪亮登场了。在流计算模块的[第一讲]我们就提到Structured Streaming支持多种SourceSocket、File、Kafka而在这些Source中Kafka的应用最为广泛。在用法上相比其他Source从Kafka接收并消费数据并没有什么两样咱们依然是依赖“万能”的readStream API如下表所示。
import org.apache.spark.sql.DataFrame
// 依然是依赖readStream API
val dfCPU:DataFrame = spark.readStream
// format要明确指定Kafka
.format("kafka")
// 指定Kafka集群Broker地址多个Broker用逗号隔开
.option("kafka.bootstrap.servers", "hostname1:9092,hostname2:9092,hostname3:9092")
// 订阅相关的Topic这里以cpu-monitor为例
.option("subscribe", "cpu-monitor")
.load()
对于readStream API的用法想必你早已烂熟于心了上面的代码你应该会觉得看上去很眼熟。这里需要我们特别注意的主要有三点
format中需要明确指定Kafka
为kafka.bootstrap.servers键值指定Kafka集群Broker多个Broker之间以逗号分隔
为subscribe键值指定需要消费的Topic名明确Structured Streaming要消费的Topic。
挥完上面的“三板斧”之后我们就得到了用于承载CPU利用率消息的DataFrame。有了DataFrame我们就可以利用Spark SQL提供的能力去做各式各样的数据处理。再者结合Structured Streaming框架特有的Window和Watermark机制我们还能以时间窗口为粒度做计数统计同时决定“多迟”的消息我们将不再处理。
不过在此之前咱们不妨先来直观看下代码感受一下存在Kafka中的消息长什么样子。
import org.apache.spark.sql.streaming.{OutputMode, Trigger}
import scala.concurrent.duration._
dfCPU.writeStream
.outputMode("Complete")
// 以Console为Sink
.format("console")
// 每10秒钟触发一次Micro-batch
.trigger(Trigger.ProcessingTime(10.seconds))
.start()
.awaitTermination()
利用上述代码通过终端我们可以直接观察到Structured Streaming获取的Kafka消息从而对亟待处理的消息建立一个感性的认知如下图所示。
在上面的数据中除了Key、Value以外其他信息都是消息的元信息也即消息所属Topic、所在分区、消息的偏移地址、录入Kafka的时间等等。
在咱们的实例中Key对应的是发送资源利用率数据的服务器节点而Value则是具体的CPU或是内存利用率。初步熟悉了消息的Schema与构成之后接下来咱们就可以有的放矢地去处理这些实时的数据流了。
对于这些每两秒钟就产生的资源利用率数据假设我们仅关心它们在一定时间内比如10秒钟的平均值那么我们就可以结合Trigger与聚合计算来做到这一点代码如下所示。
import org.apache.spark.sql.types.StringType
dfCPU
.withColumn("clientName", $"key".cast(StringType))
.withColumn("cpuUsage", $"value".cast(StringType))
// 按照服务器做分组
.groupBy($"clientName")
// 求取均值
.agg(avg($"cpuUsage").cast(StringType).alias("avgCPUUsage"))
.writeStream
.outputMode("Complete")
// 以Console为Sink
.format("console")
// 每10秒触发一次Micro-batch
.trigger(Trigger.ProcessingTime(10.seconds))
.start()
.awaitTermination()
可以看到我们利用Fixed interval trigger每隔10秒创建一个Micro-batch。然后在一个Micro-batch中我们按照发送消息的服务器做分组并计算CPU利用率平均值。最后将统计结果打印到终端如下图所示。
再次写入Kafka
实际上除了把结果打印到终端外我们还可以把它写回Kafka。我们知道Structured Streaming支持种类丰富的Sink除了常用于测试的Console以外还支持File、Kafka、Foreach(Batch)等等。要把数据写回Kafka也不难我们只需在writeStream API中指定format为Kafka并设置相关选项即可如下表所示。
dfCPU
.withColumn("key", $"key".cast(StringType))
.withColumn("value", $"value".cast(StringType))
.groupBy($"key")
.agg(avg($"value").cast(StringType).alias("value"))
.writeStream
.outputMode("Complete")
// 指定Sink为Kafka
.format("kafka")
// 设置Kafka集群信息本例中只有localhost一个Kafka Broker
.option("kafka.bootstrap.servers", "localhost:9092")
// 指定待写入的Kafka Topic需事先创建好Topiccpu-monitor-agg-result
.option("topic", "cpu-monitor-agg-result")
// 指定WAL Checkpoint目录地址
.option("checkpointLocation", "/tmp/checkpoint")
.trigger(Trigger.ProcessingTime(10.seconds))
.start()
.awaitTermination()
我们首先指定Sink为Kafka然后通过option选项分别设置Kafka集群信息、待写入的Topic名字以及WAL Checkpoint目录。将上述代码敲入spark-shellStructured Streaming会每隔10秒钟就从Kafka拉取原始的利用率信息Topiccpu-monitor然后按照服务器做分组聚合最终再把聚合结果写回到KafkaTopiccpu-monitor-agg-result
这里有两点需要特别注意一个是读取与写入的Topic要分开以免造成逻辑与数据上的混乱。再者细心的你可能已经发现写回Kafka的数据在Schema上必须用“key”和“value”这两个固定的字段而不能再像写入Console时可以灵活地定义类似于“clientName”和“avgCPUUsage”这样的字段名关于这一点还需要你特别关注。
重点回顾
好啦到此为止我手把手地带你实现了Kafka与Spark的集成完成了图中涉及的每一个环节也即从消息的生产、到写入Kafka再到消息的消费与处理并最终写回Kafka。
今天的内容比较多你除了需要掌握集成中的每一个环节与用法外还需要了解一些有关Kafka的基本概念与特性。Kafka是应用最为广泛的消息中间件Messaging Queue它的核心功能有三个
连接消息生产者与消息消费者;-
缓存生产者生产的消息(或者说事件);-
有能力让消费者以最低延迟访问到消息。-
对于Kafka的一些基本概念你无需死记硬背在需要的时候回顾后面这张架构图即可。这张图中清楚地标记了Kafka的基础概念以及消息生产、缓存与消费的简易流程。
而对于Kafka与Spark两者的集成不管是Structured Streaming通过readStream API消费Kafka消息还是使用writeStream API将计算结果写入Kafka你只需要记住如下几点即可轻松地搭建这对“万金油”组合。
在format函数中指定Kafka为Source或Sink
在option选项中为kafka.bootstrap.servers键值指定Kafka集群Broker
在option选项中设置subscribe或是topic指定读取或是写入的Kafka Topic。
每课一练
请你结合本讲中CPU利用率的代码针对内存利用率完成示意图中的各个环节也即内存利用率消息的生产、写入Kafka步骤1、消息的消费与计算步骤2、3聚合结果再次写入Kafka步骤4
欢迎你把今天这讲内容转发给更多同事、朋友跟他一起动手试验一下Spark + Kafka的实例我再留言区等你分享。

View File

@ -0,0 +1,91 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
用户故事 小王:保持空杯心态,不做井底之蛙
你好我是小王是一名大数据开发者目前在一家通信运营商公司从事开发与运维工作从业已4年有余。
从我的经验看,如果某位工程师从事的不是大数据底层开发或顶层架构,而是业务开发,那么这位工程师所用到的大数据框架,主要分为三大块:数据采集、数据存储、数据计算。
而这些框架中出镜率很高生态发展很繁荣而且工作中常用面试中常问的框架Apache Spark也必然数一数二。
我之前是如何学习Spark的
作为平平无奇的普通开发者,我平时学习新东西的套路,总结起来就是三板斧:第一步,先请教老员工这个框架在架构图中的所处位置和核心作用;第二步,去网上找入门视频或博客资料,快速刷完,在心中描绘出一个大致轮廓,做到心中有数;第三步,去官网细读文档,跟着文档写代码,在代码中梳理开发流程和弄清细节。
我之前学习Spark也是用这种三板斧的思路来学习的。正如专栏的[开篇词]所说我在“经过短短3个月的强化练习之后已经能够独当一面熟练地实现各式各样的业务需求。而这自然要归功于 Spark 框架本身超高的开发效率”。
到这里我自认为我已经是一名Spark初级工程师了。在我通读了Spark官网文档并付诸代码后我甚至以Spark中级工程师自居了。
最近我通过考核,成为了公司的内训师,公司为了实现经验沉淀和知识共享,内训师们需要录制结合公司业务的实战课程。
为了不误人子弟我意识到自己还得好好巩固下自己的Spark知识。这里不得不说到前面三板斧的第二步也就是刷资料这步是相对坎坷的。尽管网上的资料林林总总内容虽多但“天下文章一大抄”不同的博客网站里总能看到一模一样的文章。有的文章作者水平一般讲得读者昏昏欲睡事小给读者灌输了错误知识事大。
所以在这个过程中,想找到优质的资料相对较难,费力劳心。幸运的是遇到了这个专栏,其中的内容也超出了我的预期,给我带来了很多启发。
学习专栏有什么收获?
在仔细研读了《零基础入门Spark》专栏后我才发现我错得离谱我可能离“初级”都还差得远呢。在阅读此专栏的过程中“这就触及我的知识盲区了”这个表情包不停地在我脑海中闪现。
天呐,发现自己的盲区让我心中一紧,感叹“基础不牢,地动山摇”。
因为我从来没有思考过RDD与数组的异同而将新知识与熟悉的知识做对比往往是get新知识的捷径我也从来没有将算子分门别类地分组整理过其实做好整理可以让你在开发时不假思索、信手拈来我也从来没试过对RDD的重要属性DAG计算流图做生活化联想而这个技巧可以延长我们的记忆曲线尤其适合记忆这类概念性知识点……
通过这个专栏这些从没深入思考过的点逐渐被点亮。除了新知识的理解Spark的几大核心系统也相当重要。
比如,调度系统的流转过程及其三大组件的各自职责,这部分内容掌握了,我们才能把握住分布式计算的精髓。再比如说内存、存储系统,把这些组件吃透,也正好是我们写出高性能代码的重要前提。
要想自己独自弄明白这些重要的点,最直接的方法自然是阅读源码。但是对于资质平平无奇的我来说,阅读源码可谓是“蜀道难”。不过面对这样的困难,这个专栏刚好提供了很有效的学习线索,仿佛武当梯云纵,让我们更有可能登高望远。
在这个专栏里吴老师并没有像其他课程那样按照Spark的模块按部就班地讲述而是通过一个入门case去将底层知识串联起来讲述以高屋建瓴之势述底层架构之蓝图。别担心听不懂因为吴老师对这些知识点做了生活化联想对应到工厂流水线、对应到建筑工地寓教于乐。
不要小看类比、联想的威力,相比干涩的名词,生活化联想可以有效规避死记硬背,让你出口成章,口吐莲花;关键是能让你理解更透彻,达成“既见树木又见森林”中的“见森林”。
有了“见森林”的底子后,当我再去阅读源码时,因为心里有了一条相对清晰的线索,按图索骥,所以知道哪里该重点阅读,哪里是里程碑,也不再惧怕阅读源码这件事了。
不知道你听没听过诺贝尔物理学奖获得者费曼的学习理论,也就是大名鼎鼎的费曼学习法,其中一个步骤是“用最简单的语言把一件事讲清楚,简单到小朋友也能听得懂”。而生活化联想的学习方式,也恰好与此学习方法的理念不谋而合。
在学习《零基础入门Spark》这个专栏的过程中我有一个小小的感悟相对于真正0基础的同学来说我认为有经验的同学反而可能会在学习的过程中更难一点。因为他的脑海中可能对某些知识点已经建立了刻板印象或错误认知遇到冲突的时候得先清空脑海中的既有知识。这好比得先清空自己杯子里的茶水才能接纳老禅师斟的新鲜茶水。
我是怎样学习专栏的?
在我看来,学习方法只是手段,把知识学到手才是目的。这里我聊聊我的个人经验,仅供参考。
吴老师讲得再清楚,知识也是吴老师的。所以我在学习的过程中,一直坚持自己写笔记,做好自己的内化。由于资质平平无奇,一篇文章我得阅读三四遍甚至更多,才能领会文章的思想。
我的具体操作如下:
第一遍,逐字仔细阅读,遇到问题我会努力克制住“马上去搜索、提问”的坏毛病,坚持把第一遍读完,建立大纲。
第二遍,带着大纲和问题再次阅读文章和评论区,说不定答案就藏在被我忽视的上下文的细节中,也说不定,会在评论区看到其他同学有着相似的疑问及大家的讨论交流(顺便说一句,评论区可是拓展认知边界的好地方,正所谓他山之石可以攻玉)。
第三遍,把标题抄下来,关掉文章,看自己能否对着标题,把相关的知识点罗列出来,以及每个知识点需要注意的事项。
文稿后面的内容来自我的学习笔记供你做个参考。这三张图分别梳理了调度系统、并行度与并行任务以及Spark存储相关的知识网络。
其实画图也好,记录笔记也罢,关键就是帮助自己把知识之间的逻辑关系建立起来。如果你在整理过程中遇到卡壳的地方,不妨再去阅读课程和官网资料查漏补缺。
在这样的学习、消化梳理之后,我还会将这些知识落到写代码上,或者跟读源码上,避免纸上谈兵。三四遍下来,“既见树木又见森林”中的“见树木”这个小目标也达成了。
对普通人来说事业成功的原因99%以上源于work with great people。吴老师就是这位the great people这个专栏就是the great thing。我很庆幸阅读了吴老师的这门课程。把好东西牢牢抱在怀里的那种感觉你知道吗这么好的东西咱可不能暴殄天物。
俗话说“最好的偷懒方式就是不偷懒”无数次的经验告诉我们偷过的懒都会加倍还回来。既然精进Spark是大数据工程师躲不掉的事情那么咱们就踏踏实实、按部就班地学习、行动起来吧。
纸上得来终觉浅,绝知此事要躬行。只有“躬行”了,专栏里的知识才会缓缓流进你的大脑里,当你用双手在键盘辛勤耕耘的时候,再从你飞舞的指尖上流出,编织成优雅美丽的代码。
保持空杯心态,不做井底之蛙。希望我们可以一起精进技术,学以致用,加油!

View File

@ -0,0 +1,71 @@
因收到Google相关通知网站将会择期关闭。相关通知内容
结束语 进入时间裂缝,持续学习
你好,我是吴磊。
时间过得真快,不知不觉,就到了要和你说再见的时候。首先要感谢你们的支持和陪伴,坦白地说,现在的我,有些恍惚,不敢相信专栏已经结束了。
从7月份开始筹备《零基础入门Spark》这门专栏以来赶稿子、改稿子、录制音频、回复留言这些任务已经变成了我每天的日常。回忆起这4个月的经历我脑海中闪现出的第一个词语就是“夹缝中求生存”。
为什么说是夹缝中求生存呢?作为一名有家庭的中年职场男来说,工作与家庭的双重“夹击”,让原本就有限的时间和精力变得更加“捉襟见肘”。工作的重要性不言而喻,它是我们个人发展的基础,自然需要全力以赴。而咱们国人也讲究“家事如天”,所以家里再小的事情,优先级也远超任何其他事情。
毫无疑问一天下来工作与家庭就占据了大部分时间。这样算下来如果把时间比作是一面墙的话那么一天24小时留给我专心写专栏的时间就像是墙上的一道夹缝或是一道裂缝。
记忆最深刻的是11月初的那两周。由于工作的原因党政机关妻子需要集中封闭两周她和我们处于完全失联的状态。那么自然照顾娃的生活起居的“重任”就落到了我的肩上。在“闭关”前妻子甚至特意为我这个“大老粗”列出了一份详细的清单上面洋洋洒洒地记录着每一天的日常。还没生儿育女的同学就别看了容易劝退。
古语云:“取乎其上,得乎其中;取乎其中,得乎其下”。看到妻子列出的这份“取乎其上”的清单,我就知道,以我对于闺女脾气的了解、以及我那粗线条的性格,我一定会把这份清单执行得“得乎其下”。
原因很简单这份清单的最佳候选人应当是全职奶爸而我显然并不是。因此在执行层面免不了要打折扣。我只好围绕着“让娃吃饱、穿暖、不着急、不生气、不生病”的大原则尽可能地参考妻子给出的Best Practices来个“曲线救国”。
举例来说,为了让娃睡个安稳觉,也为了我自己能早点赶稿子,我只好祭出独创的“扛娃入睡”大法。我会扛着她在屋子里左晃右晃、溜达来溜达去,最后小心地把她移到床上。
说真的,保证娃儿在哄睡过程中不被上下文(体感温度,光线变化,声波抖动等)的切换惊醒,是一件比拆装炸弹还要精细的作业。孩子是天生的多功能传感器,能够捕获外界多种信号源,而且捕获信号后她回调什么函数(仅仅翻个身,还是哭喊出来)来响应你,全看造化。
哄睡是个技术活儿,更是个体力活。这之后,我基本上已经是腰酸、背痛、腿抽筋,就差瘫倒在地上了。可问题是,时间都被工作和带娃占据了,稿子什么时候写呢?
熬夜的方案看似可行,牺牲睡眠时间,来赶稿子。但是,我不敢这么做,并不是我不肯吃熬夜的苦,而是我要保证内容生产的质量,而且也担心第二天因为精力涣散、在送娃的路上有所闪失。
所以在多个重要且紧急的事情同时压在身上的时候,我会更加注重睡眠质量,只有保持精力充沛,才有可能“多进程工作”。
在常规时间被占满的情况下,我只好钻进时间裂缝,也就是利用零散时间完成片段。对我来说,能利用的碎片化时间,就是上下班的地铁通勤。从家到公司,每天来回大概要两个小时,除去换乘的时间,满打满算,还剩一个多小时让我可以用拇指在手机上码字。
尽管地铁上的环境嘈杂而又拥挤不过我发现人在压力之下反而更容易专注那段时间我每天在地铁上都能码出800字左右的片段。
为了让你更轻松地学会Spark我还会主动思考有什么生活化的比喻或是类比。专栏里工地搬砖、斯巴克集团的故事以及玻璃杯牛奶等等例子不少都来自通勤时的灵光一闪。到了晚上或是周末我会把一周积累的片段系统化地进行整理、配图、配代码、加注释并最终编辑成一篇完整的文稿。
现代人的工作和生活节奏都很快,我们的时间被切割得不成样子。人们总是拿时间过于细碎作为拒绝学习的理由:“上下班通勤不过 2 个小时,中间还要换乘几次,思路总被打断,根本没法集中注意力学什么东西,还不如刷刷视频呢!”
然而实际上,系统化的知识体系与碎片化的内容摄取,并不冲突。构建知识体系,确实需要大段的、集中的时间,但是一旦建立,体系内的一个个知识点,完全可以利用碎片化的 20-30 分钟来搞定——番茄时间以 25 分钟为单位还是有科学依据的。
以Spark MLlib为例经过那个模块的学习想必你会觉得Spark MLlib支持的特征处理函数和模型算法好多啊数量多到让人想从入门到放弃的地步。但是在一番梳理之下我们不难发现不同的特征处理函数也好模型算法也好它们都可以被归类到某一个范畴中去。至于不同的类别之间的区别与联系咱们在课程中都做了系统化的梳理。
因此要想掌握Spark MLlib其实咱们不需要每天刻意抽出大段的时间去学习。不太谦虚地说专栏里的Spark MLlib模块已经足够系统化从范畴划分到适用场景从基础分类到典型案例解析。
通过这样的“分类指南”咱们已经掌握了Spark MLlib的主要脉络。接下来我们需要做的事情就是利用碎片化的时间钻进时间裂缝去学习每一个具体的函数或是模型算法为已有的知识体系添砖加瓦。
Spark MLlib模块如此Spark整体的学习也道理相通。关键在于上了一天班累得跟三孙子似的你是否还愿意钻进时间裂缝、利用一切空余时间以水滴石穿的毅力、持之以恒地完善你的知识体系。
实际上,像这种时间裂缝,并不仅仅是被动的通勤时间,在工作中,我们完全可以根据需要,主动地把时间切割为一个又一个裂缝。在每一个裂缝中,我们只专注于一个事件,不接受任何干扰。
比方说在每天工作的8小时里我们可以切割出来多个不连续的coding time在这些时间里我们不理会任何的即时消息只醉心于编写代码。当然从这8小时中我们也可以切割出来多个meeting裂缝这时我们暂且不管是不是还有个bug需要修复只专注于讨论、沟通以及如何说服对方。
总之时间裂缝的核心要义就是专注100%专注于一件事情。这其实有点像CPU的工作原理CPU的时钟周期是固定的每个时钟周期实际上只能处理一个任务。串行的工作方式看上去很笨但是一天下来你会发现这颗CPU实际上做了许多的事情。
拿我自己来说在过去的4个月里时间裂缝还帮我读完了一本书《清醒思考的艺术》、完成了一门极客时间课程[《技术管理实战36讲》]。在忙碌于产出的同时,还能有持续性的输入,我心里会觉得非常踏实,也会觉得很开心。
做一名坚定的技术内容生产者是我为自己设立的长期目标。而要想持续地输出高质量的内容持续学习必不可少。水柔弱而又刚强充满变化能适应万物的形状且从不向困难屈服。李小龙就曾经说过“Be water, my friend”。
让我们抓住每一个成长精进的契机,进入时间裂缝,持续学习,与君共勉。
最后,我还给你准备了一份毕业问卷,题目不多,两分钟左右就能填好,期待你能畅所欲言,谢谢。

Some files were not shown because too many files have changed in this diff Show More