From ff66c1662eb61a0b589eae1f4c2c013360ab6377 Mon Sep 17 00:00:00 2001 From: Reid00 Date: Tue, 23 Apr 2024 11:47:55 +0000 Subject: [PATCH] feat: add two or more space to go next line in hugo Reid00/Reid00.github.io.source@6eae7bbccf202b23995a7c0f9cec30aac243c6b1 --- en/index.json | 2 +- .../index.html" | 20 +++---------------- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/en/index.json b/en/index.json index 0756f14ad..41c730abd 100644 --- a/en/index.json +++ b/en/index.json @@ -1 +1 @@ -[{"content":"介绍 这是我博客 Blog 的地址 和 Github Repositroy。\n本博客是用Hugo 来生成静态网站。 Hugo GitHub\n并通过 GitHub Action 来自动化部署到 GitHub Pages。\n搭建步骤 创建代码仓库 首先按照文档创建 GitHub Pages 站点。该仓库可见性必须是 Public。\n另外创建一个仓库用来存放 Hugo 的源文件,名称随意,这里假设仓库名叫 .github.io.source。建议将仓库可见性设置成 Private 以保护好你的源代码。\n创建完毕后你的账户下将存在以下两个代码仓库:\nhttps://github.com/\u0026lt;YourName\u0026gt;/\u0026lt;YourName\u0026gt;.github.io (公开的)\nhttps://github.com/\u0026lt;YourName\u0026gt;/\u0026lt;YourName\u0026gt;.github.io.source(私有的)\n生成Hugo 网站 安装Hugo For Windows\n到Github Release 下载最新版本,用hugo version 或者extended version (部分主题需要extended version 才能使用)\n安装步骤参考官方提供\n在C盘新建Hugo/sites 目录用于 生成hugo 项目\n在C盘新建Hugo/bin 目录,用来存放上面解压后的hugo 二进制文件\n添加C:\\Hugo\\bin 到系统环境变量中\n添加完成后,在cmd 或者其他console 中输入hugo version检查 环境变量是否添加成功。\n出现下面的表示成功。注意:环境变量添加成功后,记得重启console\nFor mac/linux\n可以只用用命令下载,此处不多讲了。\nHugo 生成网站 在/c/Hugo/sites 目录下使用命令hugo new site siteName生成网站\n1 hugo new site hello-hugo 执行成功后,Hugo 会给出温馨的提示:\nJust a few more steps and you’re ready to go:\nDownload a theme into the same-named folder. Choose a theme from https://themes.gohugo.io/ or create your own with the “hugo new theme ” command. Perhaps you want to add some content. You can add single files with “hugo new .”. Start the built-in live server via “hugo server”.\n先看看执行完 hugo new site 命令后,Hugo 为我们做了什么。\n进入 hello-hugo 目录,Hugo 生成的内容如下图所示:\n这些大致作用如下:\narchetypes:存放博客的模板,默认提供了一个 default.md 作为所有博客的模板。 data:存放一些数据,如 xml、json 等。 layouts:与博客页面布局相关的内容,如博客网页中的 header、footer 等。 static:存放静态资源,如图标、图片等。 themes:主题相关。 config.toml:站点、主题等相关内容的配置文件,它支持 yaml、toml 和 json 格式,后续将会一直和这个文件打交道。 建议config.toml 改为config.yaml 语法看着更舒服点。\nHugo 主题使用 根据提示,要使用 Hugo,我们必须先下载 主题,这里我选择自己比较喜欢的 PaperMod。\n根据文档安装 hugo 主题。\n文档提供了三种方式,建议使用第一种或者第二种。\n安装主题之后,在项目 theme 文件夹下生成了主题名称的文件夹。\n1 2 3 4 5 6 7 8 9 PS C:\\Hugo\\sites\\Reid00.github.io.source\\themes\u0026gt; ls 目录: C:\\Hugo\\sites\\Reid00.github.io.source\\themes Mode LastWriteTime Length Name ---- ------------- ------ ---- d----- 2022/6/6 19:39 PaperMod 建议修改archetypes/defeault.md, 以后hugo new post/1.md 新建文档的时候就会使用改模板\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 --- title: \u0026#34;{{ replace .Name \u0026#34;-\u0026#34; \u0026#34; \u0026#34; | title }}\u0026#34; date: {{ .Date }} lastmod: {{ .Date }} author: [\u0026#34;Reid\u0026#34;] categories: - tags: - series: - description: \u0026#34;\u0026#34; weight: # 输入1可以顶置文章,用来给文章展示排序,不填就默认按时间排序 slug: \u0026#34;\u0026#34; draft: false # 是否为草稿 comments: true showToc: true # 显示目录 TocOpen: true # 自动展开目录 hidemeta: false # 是否隐藏文章的元信息,如发布日期、作者等 disableShare: true # 底部不显示分享栏 showbreadcrumbs: true #顶部显示当前路径 isCJKLanguage: true cover: image: \u0026#34;\u0026#34; caption: \u0026#34;\u0026#34; alt: \u0026#34;\u0026#34; relative: false --- 修改项目配置config.yaml 我的配置如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 baseURL: \u0026#34;https://reid00.github.io/\u0026#34; # 绑定的域名 languageCode: zh-cn # en-us title: \u0026#34;Reid\u0026#39;s Blog\u0026#34; theme: PaperMod # 主题名字,和themes文件夹下的一致 enableInlineShortcodes: true enableRobotsTXT: true # 允许爬虫抓取到搜索引擎,建议 true buildDrafts: false buildFuture: false buildExpired: false enableEmoji: true # 允许使用 Emoji 表情,建议 true pygmentsUseClasses: true googleAnalytics: UA-123-45 # 谷歌统计 Copyright: hasCJKLanguage: true # 自动检测是否包含 中文日文韩文 如果文章中使用了很多中文引号的话可以开启 paginate: 10 # 首页每页显示的文章数 minify: disableXML: true # minifyOutput: true defaultContentLanguage: en # 最顶部首先展示的语言页面 defaultContentLanguageInSubdir: true languages: en: languageName: \u0026#34;English\u0026#34; weight: 1 taxonomies: category: categories tag: tags series: series menu: main: - name: Archive url: archives weight: 5 - name: Search url: search/ weight: 7 - name: Categorys url: categories/ weight: 10 - name: Tags url: tags/ weight: 10 outputs: home: - HTML - RSS - JSON params: env: production # to enable google analytics, opengraph, twitter-cards and schema. description: \u0026#34;Reid\u0026#39;s Personal Notes -- https://github.com/Reid00\u0026#34; author: Reid # author: [\u0026#34;Me\u0026#34;, \u0026#34;You\u0026#34;] # multiple authors defaultTheme: auto # disableThemeToggle: true DateFormat: \u0026#34;2006-01-02\u0026#34; ShowShareButtons: false ShowReadingTime: true # disableSpecial1stPost: true displayFullLangName: true ShowPostNavLinks: true ShowBreadCrumbs: true ShowCodeCopyButtons: true ShowRssButtonInSectionTermList: true ShowLastMod: true # 显示文章更新时间 ShowToc: true # 显示目录 TocOpen: true # 自动展开目录 comments: true images: [\u0026#34;https://i.loli.net/2021/09/26/3OMGXylm8HUYJ6p.png\u0026#34;] # profileMode: # enabled: false # title: PaperMod # imageUrl: \u0026#34;#\u0026#34; # imageTitle: my image # # imageWidth: 120 # # imageHeight: 120 # buttons: # - name: Archives # url: archives # - name: Tags # url: tags homeInfoParams: Title: \u0026#34;Hi there \\U0001F44B\u0026#34; Content: \u0026gt; Welcome to My Blog. - **Blog** 是我个人的一些笔记 - 包含Go, Python, 机器学习, KV 存储引擎的一些相关笔记, 方便以后复习 - [GitHub主页](https://github.com/Reid00) socialIcons: - name: github url: \u0026#34;https://github.com/Reid00\u0026#34; - name: twitter url: \u0026#34;https://twitter.com\u0026#34; - name: RsS url: \u0026#34;index.xml\u0026#34; # editPost: # URL: \u0026#34;https://github.com/adityatelange/hugo-PaperMod/tree/exampleSite/content\u0026#34; # Text: \u0026#34;Suggest Changes\u0026#34; # edit text # appendFilePath: true # to append file path to Edit link # label: # text: \u0026#34;Home\u0026#34; # icon: icon.png # iconHeight: 35 # analytics: # google: # SiteVerificationTag: \u0026#34;XYZabc\u0026#34; # assets: # favicon: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # favicon16x16: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # favicon32x32: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # apple_touch_icon: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # safari_pinned_tab: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; cover: responsiveImages: false # 仅仅用在Page Bundle情况下,此处不讨论 hidden: false # hide everywhere but not in structured data hiddenInList: false # hide on list pages and home hiddenInSingle: false # hide on single page # fuseOpts: # isCaseSensitive: false # shouldSort: true # location: 0 # distance: 1000 # threshold: 0.4 # minMatchCharLength: 0 # keys: [\u0026#34;title\u0026#34;, \u0026#34;permalink\u0026#34;, \u0026#34;summary\u0026#34;, \u0026#34;content\u0026#34;] markup: goldmark: renderer: unsafe: true highlight: # anchorLineNos: true codeFences: true guessSyntax: true lineNos: true noClasses: false style: monokai privacy: vimeo: disabled: false simple: true twitter: disabled: false enableDNT: true simple: true instagram: disabled: false simple: true youtube: disabled: false privacyEnhanced: true services: instagram: disableInlineCSS: true twitter: disableInlineCSS: true 新建hugo 网页 这时候主题已经激活了,我们先往博客中添加一篇文章,hugo new post/first.md:\n1 2 hugo new post/first.md hugo new post/second.md 此处观看content 目录,会生成posts 文件夹\n1 2 3 4 5 6 7 8 9 10 11 12 13 PS C:\\Hugo\\sites\\Reid00.github.io.source\\content\u0026gt; tree /F 文件夹 PATH 列表 卷序列号为 E2D5-548C C:. │ archives.md │ search.md │ └─post 4th.md first.md second.md test.md third.md 另外要使用 Archive 和 Search,需要进行以下操作:\n在 content 下增加 archives.md 文件,具体位置如下:\n1 2 3 4 5 6 7 . ├── content/ │ ├── archives.md \u0026lt;--- Create archive.md here │ └── posts/ ├── static/ └── themes/ └── PaperMod/ archives.md 内容为:\n1 2 3 4 5 6 --- title: \u0026#34;Archive\u0026#34; layout: \u0026#34;archives\u0026#34; url: \u0026#34;/archives\u0026#34; summary: \u0026#34;archives\u0026#34; --- 同样在 content 新增一个 search.md,内容如下:\n1 2 3 4 5 6 7 --- title: \u0026#34;Search\u0026#34; # in any language you want layout: \u0026#34;search\u0026#34; # is necessary # url: \u0026#34;/archive\u0026#34; description: \u0026#34;Search part\u0026#34; summary: \u0026#34;search\u0026#34; --- 查看网站效果: 在项目目录c/hugo/sites/projectName 目录下输入hugo server -D\n看到一下输出, 表示可以访问 http://localhost:1313/ 查看效果,如果有其他不满意的地方,可以自行查找其他资料修改配置。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 Start building sites … hugo v0.100.0-27b077544d8efeb85867cb4cfb941747d104f765+extended windows/amd64 BuildDate=2022-05-31T08:37:12Z VendorInfo=gohugoio | EN -------------------+----- Pages | 20 Paginator pages | 0 Non-page files | 0 Static files | 0 Processed images | 0 Aliases | 2 Sitemaps | 1 Cleaned | 0 Built in 98 ms Watching for changes in C:\\Hugo\\sites\\Reid00.github.io.source\\{archetypes,content,data,layouts,static,themes} Watching for config changes in C:\\Hugo\\sites\\Reid00.github.io.source\\config.yaml Environment: \u0026#34;development\u0026#34; Serving pages from memory Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender Web Server is available at http://localhost:1313/ (bind address 127.0.0.1) Press Ctrl+C to stop 效果如下:\n部署到GitHub pages 两种方式:\nhugo 命令后生成public 文件夹,将public 单独push 到.github.io 仓库 是将整个项目部署到.github.io.source 仓库,通过github action 自动部署 SSH 这里需要重新生成一对密钥,使用之前用来配置过 Github 的密钥不能在这里使用啦,会报错提示已经被使用过啦。\n1 2 3 4 5 6 7 8 9 10 ssh-keygen -t rsa - -C \u0026#34;$(git config user.email)\u0026#34; # 注意:这次不要直接回车,以免覆盖之前生成的 # 确认秘钥的保存路径(如果不需要改路径则直接回车);如果已经有秘钥文件,则需要换一个路径,避免覆盖掉,如我更改之后的路径为 /home/kearney/.ssh_action/id_rsa; # 创建密码(如果不需要密码则直接回车); # 确认密码(如果不需要密码则直接回车),生成结束; # 查看公钥,路径需要改为你上面的设置 cat ~/.ssh_action/id_rsa.pub # 查看私钥 cat ~/.ssh_action/id_rsa Page 仓库 Reid00/ Reid00.github.io 中,点击 Setting - Deploy keys - Add deploy key,名称随意,粘贴进去刚生成的公钥,务必勾选 Allow write access 。点击保存。\n源码仓库 Reid00/ Reid00.github.io.source 。点击 Setting - Secrets - New repo secrets ,名称务必设置为 ACTIONS_DEPLOY_KEY, 添加刚刚生成的私钥(id_rsa),变量名称是要在 action 的配置文件中使用的,因此要保持统一,可修改为别的名称,但要相同即可。\n将项目hello-hugo 推送到 Reid00.github.io.source 仓库 讲到这里突然发现还没有讲,把项目推送到github,方式如下:\n此处本质就是用git 创建项目,推送到Github 如果出现错误,请自行搜索排查。\n1 2 3 4 5 6 git init git remote add origin git@github.com:Reid00/Reid00.github.io.source.git git add . git commit -m \u0026#39;init\u0026#39; git push -f --set-upstream origin master 配置 Github Action 在源码跟目录下新建 /.github/workflows/github-actions-demo.yml,内容如下,需要更改的地方为 Page 仓库、分支。\nyml push 上去之后,再更新下文章,直接 push 源码,后台就会自动生成并发布到 Page 仓库。\n测试良好,但也发现了一些问题,本人对 Action 了解不够深,猜测是 yml 没抄好。一个问题是源码仓库中 public 指向的网页仓库的 commit 标志未更新,但实际上对于的网页已经更新到 Page 中了,不过这不影响网页发布,暂不考虑解决这个问题。\n其次是我的 submodule 主题中有个在 gitee 中,需要移回到 github 并设置,还有 public 也是,那么干脆就将这些 submodule 取消掉的了,直接并入源码仓库,也不要考虑啥 commit 指针标记了。\n遇到问题 网站css 错乱\n参考此处 Fixing the CSS Integrity Digest Error in Hugo\nwindows git add 的时候LF 会用CRLF 替换,需要对git 进行设置。\n参考文章第二种方法解决\n单独 将public push 到github pages 仓库可以用,但是Github Action 推送后,Github pages 主页变成了index.xml 不是index.html,不可用?\n原因:public 里面又github pages 的.git 文件夹,会导致workflow那边submodules 出错。\n删除.git 文件夹 在与config.yaml 同级目录下添加.gitmodules 1 2 3 4 5 6 7 8 C:. │ .gitmodules │ config.yaml │ README.md [submodule \u0026#34;themes/PaperMod\u0026#34;] path = themes/PaperMod url = https://github.com/adityatelange/hugo-PaperMod.git ","permalink":"https://reid00.github.io/en/posts/other/hugo%E6%90%AD%E5%BB%BA%E5%8D%9A%E5%AE%A2%E5%B9%B6%E7%94%A8githubaction%E9%83%A8%E7%BD%B2/","summary":"介绍 这是我博客 Blog 的地址 和 Github Repositroy。 本博客是用Hugo 来生成静态网站。 Hugo GitHub 并通过 GitHub Action 来自动化部署到 GitHub Pages。 搭建步骤 创建代码","title":"Hugo搭建博客并用GitHubAction部署"},{"content":"0-1 背包 背包问题整体分为以下几种,情况比较复杂,但是对于面试的话,掌握01背包和完全背包,就差不多了,本篇主要介绍01背包和完全背包。 题干 有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。 比如:\n1 2 3 weight = [1, 3, 4] // 三个物品 value = [15, 20, 30] // 对应该的价值 bagweight = 4 // 背包的容量为4 问背包能背的物品最大价值是多少?\n解法一 二维数组 确定dp数组以及下标的含义 使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大dp[i][j]。\n确定状态转移方程 在遍历到i 的时候,有两种情况来确定价值\n不放物品i;即背包容量为j,里面不放物品i的最大价值,此时dp[i][j] 就等于dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。) 放物品i;dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值 由此可得: dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]) 数组初始化 首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。 状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。 dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。 那么很明显当 j \u0026lt; weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。 当j \u0026gt;= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。\n1 2 3 4 5 6 7 8 9 10 // 第一列初始化为0 for (int j = 0 ; j \u0026lt; weight[0]; j++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略 dp[0][j] = 0; } // 第一行初始化为value[0] // 正序遍历 for (int j = weight[0]; j \u0026lt;= bagweight; j++) { dp[0][j] = value[0]; } 遍历顺序 由状态转移方程: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程 和 先遍历背包,再遍历物品 都可以让 (i-1,j) 和 (i-1, j-weight[i]) 先与(i,j) 所以这个遍历顺序都可以。但是建议还是先遍历物品,在遍历背包,符合大部分情况下的解法。\n代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 // weightBag func weightBag(weight, value []int, bagWeight int) int { // dp[i][j] 二维,第一维i 表示选择的物品,从weight 中找 // 第二维 j 表示背包能放下的最大重量 // dp[i][j] 表示从下标为[0-i]的物品里任意取(包含i),放进容量为j的背包,价值总和最大是多少 // dp[i][j] = // 不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。 // (其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。) // 放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] // 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值 n := len(weight) dp := make([][]int, n) // 第一维物品的个数 for i := 0; i \u0026lt; n; i++ { dp[i] = make([]int, bagWeight+1) // 第二维,背包能放下的最大重量,包含bagWeight 本身 } // 初始化dp // 当j为零的时候,价值最大为0 for i := 0; i \u0026lt; n; i++ { dp[i][0] = 0 } // 当i为零的时候,价值为value[0] // i 开始是weight[0] weight 是递增的 for i := weight[0]; i \u0026lt; bagWeight+1; i++ { dp[0][i] = value[0] } // 为什么从1开始遍历,因为状态转移公式 for i := 1; i \u0026lt; n; i++ { // 遍历物品 for j := 0; j \u0026lt; bagWeight+1; j++ { // 遍历背包容量 if j \u0026lt; weight[i] { // 物品不能放到背包中 dp[i][j] = dp[i-1][j] } else { dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]]+value[i]) } } } return dp[n-1][bagWeight] } 解法二 一维数组 背包问题依赖分析 dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]) 有状态转移方程,看下图 从上图看我们在计算第i的数据的时候我们只依赖第i - 1行,我们在第i行从后往前遍历并不会破坏动态转移公式的要求。 我们已经很清楚了我们在计算dp数据的时候进行计算的时候只使用了两行数据,那么我们只需要申请两行的空间即可,不需要申请那么大的数组空间,计算的时候反复在两行数据当中交替计算既可。比如说我们已经计算好第一行的数据了(初始化),那么我们可以根据第一行得到的结果得到第二行,然后根据第二行,将计算的结结果重新存储到第一行,如此交替反复,像这种方法叫做滚动数组。\n我们还能继续压缩空间吗🤣?我们在进行空间问题的优化的时候只要不破坏动态转移公式,只需要我们进行的优化能够满足dp[i][j]的计算在它所依赖的数据之后计算即可。\n根据动态转移公式dp[i][j] = max(dp[i - 1][j - v[i]] + w[i], dp[i - 1][j])我们知道,第i行的第j个数据只依赖第i - 1行的前j个数据,跟第j个数据之后的数据没有关系。因此我们在使用一维数组的时候可以从后往前计算(且只能从后往前计算,如果从前往后计算会破坏动态转移公式,因为第j个数据跟他前面的数据有依赖关系,跟他后面的数据没有依赖关系)就能够满足我们的动态转移公式。 如果我们从在使用单行数组的时候从前往后计算,那么会使得一维数据前面部分数据的状态从i - 1行的状态变成第i行的状态,像下面这样 但是一维数组当中后部分的数据还是i - 1行的状态,当我们去更新他们的时候他们依赖前面部分数据的i - 1行的状态,但是他们已经更新到第i的状态了,因此破坏了动态规划的转移方程,但是如果我们从后往前进行遍历那么前面的状态始终是第i - 1行的状态,因此没有破坏动态规划的转移方程,因此我们需要从后往前遍历。 一维数组具体分析 确定dp数组的定义 dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是dp[i][j] 的值。 用一维数组之后dp[j]: 容量为j的背包,所背的物品价值可以最大为dp[j]\n状态转移方程 不放物品i: 一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j] 放物品i: dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j]) 所以 dp[j] = max(dp[j], dp[j-weight[i]] + value[i]) 一维dp数组如何初始化 dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。 那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢? 看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。 这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。\n遍历顺序 结果如下 1 2 3 4 5 6 7 for i := 0; i \u0026lt; n; i++ { //遍历物品 // 需要注意的是,j 一定要从大到小,防止物品0 被重复计算 for j := bagWeight; j \u0026gt;= weight[i]; j-- { //遍历背包容量 dp[j] = max(dp[j], dp[j-weight[i]]+value[i]) } } 二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。\n倒序遍历是为了保证物品i只被放入一次! 但如果一旦正序遍历了,那么物品0就会被重复加入多次!\n举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15 如果正序遍历 dp[1] = dp[1 - weight[0]] + value[0] = 15 dp[2] = dp[2 - weight[0]] + value[0] = 30 此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。\n为什么倒序遍历,就可以保证物品只放入一次呢? 倒序就是先算dp[2] dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0) dp[1] = dp[1 - weight[0]] + value[0] = 15\n那么问题又来了,为什么二维dp数组遍历的时候不用倒序呢? 因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!\n再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢? 不可以! 因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。\n倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。\n代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 背包优化 // dp + 滚动数组 func weightBag2(weight, value []int, bagWeight int) int { // dp[j] 表示 容量为j的背包,所背的物品价值可以最大为dp[j] // dp[j] = max(dp[j], dp[j-weight[i]] + val[i]) // 此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j], // 即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值, dp := make([]int, bagWeight+1) // 初始化,背包容量为0,的时候价值都为0 // dp 上面声明的时候,已经初始化 n := len(weight) for i := 0; i \u0026lt; n; i++ { //遍历物品 // 需要注意的是,j 一定要从大到小,防止物品0 被重复计算 for j := bagWeight; j \u0026gt;= weight[i]; j-- { //遍历背包容量 dp[j] = max(dp[j], dp[j-weight[i]]+value[i]) } } return dp[bagWeight] } ","permalink":"https://reid00.github.io/en/posts/algo/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E4%B9%8B01%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98/","summary":"0-1 背包 背包问题整体分为以下几种,情况比较复杂,但是对于面试的话,掌握01背包和完全背包,就差不多了,本篇主要介绍01背包和完全背包。 题干 有n","title":"动态规划之01背包问题"},{"content":"完全背包 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。\n完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。\n题干解析 有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。 比如:\n1 2 3 weight = [1, 3, 4] // 三个物品 value = [15, 20, 30] // 对应该的价值 bagweight = 4 // 背包的容量为4 问背包能背的物品最大价值是多少?\n01背包和完全背包唯一不同就是体现在遍历顺序上,我们直接分析。\n首先再回顾一下01背包的核心代码:\n1 2 3 4 5 6 7 for i := 0; i \u0026lt; n; i++ { //遍历物品 // 需要注意的是,j 一定要从大到小,防止物品0 被重复计算 for j := bagWeight; j \u0026gt;= weight[i]; j-- { //遍历背包容量 dp[j] = max(dp[j], dp[j-weight[i]]+value[i]) } } 我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。\n而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:\n1 2 3 4 5 6 7 for i :=0; i \u0026lt; n; i ++ {\t//遍历物品 // 完全背包,i 可以重复添加,要从小到大遍历 for j := weight[i]; j \u0026lt;= bagWeight; j ++ { dp[j] = max(dp[j], dp[j-weight[i]] + value[i]) } } 其实还有一个很重要的问题,为什么遍历物品在外层循环,遍历背包容量在内层循环? 难道就不能遍历背包容量在外层,遍历物品在内层? 01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。 在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!\n因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。\n在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的! 因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。\n应用 讲解了纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。\n但是仅仅是纯完全背包的遍历顺序是这样的,题目稍有变化,两个for循环的先后顺序就不一样了。\n如果求组合数就是外层for循环遍历物品,内层for遍历背包。 如果求排列数就是外层for遍历背包,内层for循环遍历物品。\n求组合数 518.零钱兑换II\n求排列数 377. 组合总和 Ⅳ 70. 爬楼梯\n最小数 那么两层for循环的先后顺序就无所谓了 322. 零钱兑换 279. 完全平方数\n背包总结 问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); 对应题目如下: 416.分割等和子集 1049.最后一块石头的重量 II\n问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下: 494.目标和 518.零钱兑换II 377. 组合总和 Ⅳ 70. 爬楼梯\n问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); 对应题目如下: 474.一和零\n问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); 对应题目如下: 322. 零钱兑换 279. 完全平方数\n","permalink":"https://reid00.github.io/en/posts/algo/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E4%B9%8B%E5%AE%8C%E5%85%A8%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98/","summary":"完全背包 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就","title":"动态规划之完全背包问题"},{"content":"Rust LinkedList 定义 Leetcode: rust 如下:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // Definition for singly-linked list. #[derive(PartialEq, Eq, Clone, Debug)] pub struct ListNode { pub val: i32, pub next: Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt;, } impl ListNode { #[inline] fn new(val: i32) -\u0026gt; Self { ListNode { next: None, val } } } /// 单链表 #[derive(Debug)] struct LinkedList\u0026lt;T\u0026gt; { head: Option\u0026lt;Box\u0026lt;Node\u0026lt;T\u0026gt;\u0026gt;\u0026gt;, } Go 如下:\n1 2 3 4 type ListNode struct { Val int Next *ListNode } 问题 1: 为什么 Rust 有ListNode之后还有LinkedList 定义? 与其他语言不通,Rust 中有所有权概念,在 Option\u0026lt;Box\u0026gt;的实现中,next 节点不存在引用类型,暗含的意思就是:链表头是整个链表的拥有者,负责整个链表所占据内存的管理(包括最终销)。进一步说,Rust 中这样实现的链表和用 C++实现的链表是完全不同的:每个节点不再是独立存在的了,而是被先驱节点所管理,同时也管理着它的 next 字段后所有的后驱节点。 问题 2: 为什么 next 是 Option\u0026lt;Box\u0026lt;LisNode\u0026gt;\u0026gt; 类型,而不是 ListNode?\n节点的 next 指向下一个节点,可能为空,故类型为 Option; 避免编译器无法计算节点大小,用 Box 包裹 Node; next 实际上是用 Option 包裹的指向下个节点的 Box 指针,并且拥有下个节点的所有权; Box 的作用 在 Rust 中,Box\u0026lt;T\u0026gt; 是一个“箱子”类型,它在堆上存储数据。你可以把它看作是一个智能指针,用来封装和管理堆上的内存。\n在这个 Node 结构体中,Box 是一个指向堆上的 Node 实例的指针。Option\u0026lt;Box\u0026gt; 则意味着这个指针可能有值(即 Some(Box(Node))),也可能没有值(即 None)。\n以下是 Box\u0026lt;T\u0026gt; 在这个上下文中的一些主要用途:\n递归数据结构:在定义递归或者链接的数据结构时,比如链表、树等,Box\u0026lt;T\u0026gt; 是必要的。在你的例子中,Node 结构体是一个链表节点,它有一个 next 字段指向下一个 Node。这个 next 字段需要使用 Box,因为 Rust 需要知道 Node 结构体的确切大小,而 Box 是一个固定大小的指针。 转移所有权:当你需要将值的所有权转移到其他函数或变量时,可以使用 Box\u0026lt;T\u0026gt;。Box\u0026lt;T\u0026gt; 拥有它所指向的值的所有权,当 Box\u0026lt;T\u0026gt; 被销毁时,它所指向的值也会被销毁。 大对象和内存管理:如果你有一个很大的对象或者需要手动管理内存,Box\u0026lt;T\u0026gt; 是一个很好的工具。因为 Box\u0026lt;T\u0026gt; 在堆上分配内存,当你不再需要这个值时,Box\u0026lt;T\u0026gt; 会自动回收它所使用的内存。 以上就是 Box\u0026lt;T\u0026gt; 的一些主要用途。在你的例子中,Box\u0026lt;T\u0026gt; 主要用于定义递归的链表数据结构。\n不使用 Box 可行? 在定义递归数据结构时,如链表或树,Rust 语言要求必须使用 Box 或者其他形式的间接寻址(如 Rc、Arc 等)。这是由 Rust 的所有权和内存安全性规则决定的。\nRust 编译器需要在编译时就知道每种类型的确切大小。对于递归类型,例如链表节点 Node,如果不使用 Box 或其他形式的指针,Node 类型会有一个无限的大小。这是因为每个 Node 都包含另一个 Node,这形成了一个无限的嵌套。这会导致编译时错误,因为 Rust 无法确定类型的确切大小。\nBox(以及 Rc、Arc 等)是一个固定大小的指针类型,它在堆上存储数据。这意味着,对于 Box 类型,无论 Node 的实际大小如何,Box 类型总是有一个固定的大小。这使得在 Rust 中定义递归类型成为可能。\n所以,对于你的例子,如果你要在 Node 中定义一个指向另一个 Node 的字段,你必须使用 Box 或其他形式的指针。否则,你会得到一个编译错误。\n和 Rc\u0026lt;RefCell\u0026gt; 的区别 Box 和 Rc\u0026lt;RefCell\u0026lt;T\u0026gt;\u0026gt; 都是 Rust 中的智能指针类型,但是它们的用途和行为不同。\nBox\u0026lt;T\u0026gt; 提供了在堆上分配一个值的能力,并拥有这个值的所有权。当 Box 被丢弃(drop)时,它包含的值也会被丢弃。Box 只有一个所有者。 Rc 是一个引用计数类型,可以让一个值有多个所有者。每当你克隆一个 Rc 指针,引用计数就会增加。当一个 Rc 指针被丢弃时,引用计数就会减少。只有当引用计数为 0 时,值才会被丢弃。 RefCell 提供了内部可变性。在 Rust 中,我们不能同时拥有一个值的可变引用和不可变引用。然而,有时我们可能需要在运行时改变一个值,即使我们拥有的是一个不可变引用。这就是 RefCell 发挥作用的地方。RefCell 允许我们在运行时借用和改变值,但是如果我们违反了借用规则(例如,同时拥有可变引用和不可变引用),RefCell 就会导致程序 panic。 当你看到 Option\u0026lt;Rc\u0026lt;RefCell\u0026raquo; 时,这是因为在一些情况下(例如,在树或图结构中),我们需要一个节点有多个所有者,或者我们需要修改一个被多个地方引用的值。在这种情况下,我们就需要使用 Rc 和 RefCell。\n然而,请注意,Rc\u0026lt;RefCell\u0026gt; 在运行时执行借用检查,可能会引发 panic,而且引用计数会增加运行时开销。在可以确定一个值只有一个所有者,并且不需要在运行时修改的情况下,使用 Box\u0026lt;T\u0026gt; 会更简单、更安全、更高效。\n反转链表实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 pub fn reverse_list(head: Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt;) -\u0026gt; Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt; { let mut prev = None; let mut cur = head; while let Some(mut node) = cur { let next = node.next; node.next = prev; cur = next; prev = Some(node); } prev } let mut list = ListNode::new(1); list.next = Some(Box::new(ListNode::new(2))); list.next.as_mut().unwrap().next = Some(Box::new(ListNode::new(3))); list.next.as_mut().unwrap().next.as_mut().unwrap().next = Some(Box::new(ListNode::new(4))); list.next .as_mut() .unwrap() .next .as_mut() .unwrap() .next .as_mut() .unwrap() .next = Some(Box::new(ListNode::new(5))); let reversed_list = reverse_linked_list(Option::from(Box::from(list))); assert_eq!(reversed_list.as_ref().unwrap().value, 5); assert_eq!( reversed_list.as_ref().unwrap().next.as_ref().unwrap().value, 4 ); as_mut().unwrap(),.as_ref().unwrap() 的作用 在 Rust 中,.as_mut() 和 .as_ref() 方法通常被用来转换 Option 或 Result 类型。\n.as_mut() 是 Option 和 Result 类型的一个方法,用来将 Option 或 Result\u0026lt;T, E\u0026gt; 转换成 Option\u0026lt;\u0026amp;mut T\u0026gt; 或 Result\u0026lt;\u0026amp;mut T, \u0026amp;mut E\u0026gt;。也就是说,它返回一个包含可变引用的新的 Option 或 Result。\n.unwrap() 是 Option 和 Result 类型的一个方法,用来获取它们包含的值。如果 Option 是 Some(v),它返回 v,如果 Option 是 None,它会 panic(意味着程序会立即停止,并给出一个错误信息)。对于 Result,如果它是 Ok(v),它返回 v,如果它是 Err(e),它也会 panic。\n.as_ref() 是 Option 和 Result 类型的一个方法,用来将 Option 或 Result\u0026lt;T, E\u0026gt; 转换成 Option\u0026lt;\u0026amp;T\u0026gt; 或 Result\u0026lt;\u0026amp;T, \u0026amp;E\u0026gt;。也就是说,它返回一个包含不可变引用的新的 Option 或 Result。\nas_mut().unwrap() 和 as_ref().unwrap() 都被用来获取 Option\u0026lt;Box\u0026gt; 中的 Node。这样,你就可以修改或访问 Node 中的 value 和 next。因为 Option\u0026lt;Box\u0026gt; 中可能没有 Node(也就是它可能是 None),所以需要使用 unwrap() 来获取 Node。但是请注意,如果 Option\u0026lt;Box\u0026gt; 是 None,unwrap() 会引发 panic。为了避免这种情况,你需要在调用 unwrap() 之前确保 Option 不是 None。\nunwrap() 不能调用多次,其实是所有权问题 你遇到的这个错误是由于在 Rust 中的所有权规则所引起的。当你对 Option 类型的值调用 unwrap() 方法时,它会获取 Option 的所有权,这意味着原来的 Option 值将不能再使用。\n这就是为什么你在 while 循环中多次使用 head.unwrap().next 时会遇到问题。在第一次调用 unwrap() 之后,head 就被移动(move)了,也就是说它的所有权被转移走了,所以你不能再次对 head 调用 unwrap()。\nOption::from 和 Some 是一样的? 是的,你完全正确。在 Rust 中,Some(value) 和 Option::from(value) 是等价的。它们都会创建一个包含 value 的 Option 枚举值。\nSome(value) 是更常见的使用方式,它直接创建了一个 Option::Some 枚举变量。例如:3\n1 let some_number = Some(5); Option::from(value) 是 From trait 的一部分,这个 trait 用于定义如何将一种类型转换成另一种类型。Option::from 的定义如下:\n1 2 3 4 5 impl\u0026lt;T\u0026gt; From\u0026lt;T\u0026gt; for Option\u0026lt;T\u0026gt; { fn from(value: T) -\u0026gt; Option\u0026lt;T\u0026gt; { Some(value) } } 因此,Option::from(value) 实际上就是返回 Some(value)。例如:\n1 let some_number = Option::from(5); 在大多数情况下,直接使用 Some(value) 会更简单和直观。\ntake 函数 1 2 3 4 let mut x = Some(2); let y = x.take(); assert_eq!(x, None); assert_eq!(y, Some(2)); 这段代码中的 x.take() 方法是 Option 类型特有的方法。take() 方法将 Option 置为 None 并返回原来的值。\n当你调用 x.take() 时,它会把 x 里面的值取出并返回,此时 x 的值就变为了 None,因为你已经取走了它的值。所以,在 x.take() 后,x 的值就是 None 了。\n而 y 的值是 x.take() 的返回值,即 x 原来的值 Some(2),所以 y 的值就是 Some(2)。\n简单地说,x.take() 做了两件事:\n将 x 设为 None。 返回 x 原来的值。 因此,x 在调用 take 之后的值是 None,而 y 的值就是 x 在调用 take 之前的值,即 Some(2)。\n这就是为什么在 assert_eq!(x, None) 和 assert_eq!(y, Some(2)) 的断言语句都能通过,因为它们分别测试的是 x 和 y 的值,这些值都符合预期。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/rust-leetcode%E9%93%BE%E8%A1%A8%E5%AE%9E%E7%8E%B0/","summary":"Rust LinkedList 定义 Leetcode: rust 如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // Definition for singly-linked list. #[derive(PartialEq, Eq, Clone, Debug)] pub struct ListNode { pub val: i32, pub next: Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt;, } impl ListNode { #[inline] fn new(val: i32) -\u0026gt; Self { ListNode { next: None, val } } } /// 单链表","title":"Rust Leetcode链表实现"},{"content":"一道面试题 1 2 3 4 5 6 7 8 9 int test(int n) { int fact = 1, num = n+1; for(int i =1; i\u0026lt;num; i++) { fact *= 1; } return fact; } 面试官:这段求阶乘的代码怎么样? 答:挺简洁的,简单易懂。不过如果参数 n 值比较大的话,会导致 fact 溢出,结果是错的。 面试官:嗯,是的。不过,咱们先不考虑溢出的问题,你觉得这段代码的性能怎么样? 答:时间复杂度是 O(n),而且代码比较精炼,性能应该还挺不错的吧?(心虚 ing\u0026hellip;) 面试官:你能想办法把它优化一下,让性能更好吗? 思考 ing\u0026hellip; 答:在多 CPU 系统上,如果 n 的值比较大的话,可以考虑用多线程来实现。 面试官:嗯,这是一个思路。如果是单 CPU 呢? 再次思考 ing\u0026hellip; 答:用 GCC 编译的话,可以加上优化选项-O3,应该能提高性能。 面试官:嗯,还有吗? 答:没了。 面试官:好了,感谢来参加面试,回去等通知吧! 思考一下,如果是你的话,会怎么回答呢? 下面,来深入讲解一下,隐藏在这道题背后的深层次知识! 本文较长,且涉及到 CPU 内部很底层的知识,请耐心看完,一定会有收获!\n测试程序 测试程序 test.c 非常简单,计算 1000000000 的阶乘:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i++) { fact *= 1; } return fact; } int main() { return cacl(1000000000); } 为方便分析,函数 calc()前面加上attribute((noinline)),禁止 GCC 把 calc 内联在 main()中。此外,calc()中,fact 类型是 int,main()中调用 calc(1000000000),会导致 fact 溢出,但不影响测试,不用管它。\n然后,把程序稍微改一下,命名为 test_2.c:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact0=1, fact1=1, fact2=1, fact3=1; for(int i =1; i\u0026lt;n; i += 4) { fact0 *= i; fact1 *= i+1; fact2 *= i+2; fact3 *= i+3; } return fact0 * fact1 * fact2 * fact3; } int main() { return cacl(1000000000); } 注意:这里为方便讲解,假设 n 总是 4 的倍数。如果要处理 n 不是 4 的倍数的情况,只需要在主循环体外,增加一个小的循环单独处理最后的 n%4 个数,也就是最多 3 个数即可,对整体性能影响几乎为 0.\n运行耗时从原来的 3.29 秒降到了 1 秒,性能提升了 200%!你以为这就完了?\n这还不是最终的结果,因为我们还有一个优化技巧还没加上,最终优化后的结果是 0.3 秒!文末会讲!先不着急,咱们一个一个来讲!\n优化: 关于循环展开:你真的理解吗? 看到这里,有人会说,不就是循环展开嘛,很简单的,没什么好研究的,而且加了优化选项之后,编译器会自动进行循环展开的,没必要手动去展开,也就没有研究的价值了!\n真的是这样吗?先尝试回答下面几个问题:\n循环展开为什么能提高程序性能,其背后的深层次原理是什么? 循环随便怎么展开都一定可以提高性能吗? 用了优化选项,编译器一定会帮我们自动进行循环展开优化吗? 第一个问题后面会详细讲解,我们先用实例回答下第 2 个和第 3 个问题。\n先看第 2 个问题。\n循环随便展开都能提高性能吗? 答案是否定的。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i += 4) { fact *= i; fact *= i+1; fact *= i+2; fact *= i+3; } return fact; } int main() { return cacl(1000000000); } 仍然是循环展开,只不过把循环展开的方式稍微改了一下。再编译一下,用 time 命令测量下运行耗时: 和 test.c 相比运行耗时只减少了 0.2 秒!为什么同样是循环展开,test_2.c 只需要 1.6 秒,而 test_3.c 却要 3 秒,为什么性能差异这么大呢?别着急,后面细讲。\n再看第三个问题,加了优化选项,编译器一定会帮我们自动进行循环展开优化吗?一试便知\n-O3,编译器一定会循环展开吗? 重新编译下 test.c, test_2.c, 和 test_3.c,只不过,这次我们加上-O3 优化选项,然后分别用 time 命令再测量下运行时间。\n先是 test.c: 加了-O3 优化后,程序耗时从原来的 3.29 秒降到了 1.07 秒,性能提升确实非常明显!是否好奇,-O3 选项对 test.c 做了什么样的优化,能够把程序耗时降到三分之一?这个后面再讲。\n现在,我们先试下 test_2.c: 同样,加了-O3 后,程序耗时从原来的 1 秒降到了 0.368 秒!此外,在同样加了-O3 的情况下,使用了循环展开的 test_2.c,程序耗时仍然是 test.c 的三分之一!可见,编译器确实优化了一些东西,但是,无论是否加-O3 优化选项,进行手动循环展开的 test.c 仍然是性能最好的!\n最后,再试下 test_3.c: 看到了吧?同样加了-O3 优化选项的前提下,性能仍然与 test_2.c 相差甚远!\n小结一下我们现在得到的几组测试结果: 在解释这些性能差异的原因之前,必须要先补充一些 CPU 相关的基础知识,否则无法真正理解这背后的原因!所以,请务必认真看完!\n这会涉及到 CPU 内部实现细节的知识,相对比较底层,而且对绝大多数程序员是透明的,因此很多人甚至都没听说过这些概念。不过,也不用担心,跟之前一样,我会尽量用通俗易懂的语言来解释这些概念。\n背景知识:CPU 内部架构 指令流水线(pipeline) 所谓流水线,是把指令的执行过程分成多个阶段,每个阶段使用 CPU 内部不同的硬件资源来完成。以经典的 5 级流水线为例,一条指令的执行被分为 5 个阶段:\n取指(IF):从内存中取出一条指令。 译码(ID):对指令进行解码,确定该指令要执行的操作。 执行(EX):执行该指令要执行的操作。 访存(MEM):进行内存访问操作。 写回(WB):把执行的结果写回寄存器或内存。 在时钟信号的驱动下,CPU 依次来执行这些步骤,这就构成了指令流水线(pipeline)。如下图所示: 在CPU内部,执行每个阶段使用不同的硬件资源,从而可以让多条指令的执行时间相互重叠。当第一条指令完成取指,进入译码阶段时,第二条指令就可以进入取指阶段了。以此类推,在一个 5 级流水线中,理想情况下,可以有 5 条不同的指令的不同阶段在同时执行,因此每个时钟周期都会有一条指令完成执行,从而大大提高了 CPU 执行指令的吞吐率,从而提高 CPU 整体性能。这就叫做 ILP - 指令级并行(Instruction Level Parallelism)。如下图所示: 通过把指令执行分为多个阶段,CPU 每个时钟周期只处理一个阶段的工作,这样大大简化了 CPU 内部负责每个阶段的功能单元,每个时钟周期要做的事情少了,提高时钟频率也变得简单了。\n前面说过,有了流水线技术,理想情况下,每个时钟周期,CPU 可以完成一条指令的执行。那有没有什么方法,可以让 CPU 在每个时钟周期,完成多条指令的执行呢,这岂不是会大大提高 CPU 整体性能吗?\n当然有!这就是 Superscalar 技术!(除此之外还有 VLIW,不是本文重点,不再展开讨论。)\n超标量(Superscalar) Superscalar,通过在 CPU 内部实现多条指令流水线,可以真正实现多条命令并行执行,也被称为多发射数据通路技术。以双发射流水线为例,每个时钟周期,CPU 可以同时读取两条指令,然后同时对这两条指令进行译码,同时执行,然后同时写回。如下图所示: 流水线冲突 大家可能注意到了,前面多次强调过,“在理想状态下”,为什么呢? 现实中程序的指令序列之间往往存在各种各样的依赖和相关性,而 CPU 为了解决这种指令间的依赖和相关性,有时候不得不“停顿”下来,直到这些依赖得到解决,这就导致 CPU 指令流水线无法总是保持“全速运行”。\n这种现象被称之为 Pipeline Hazard,很多资料翻译为“流水线冒险”,我觉得“流水线冲突”更为贴切易懂。\n归结起来,有三种情况:\n数据冲突(Data Hazard) 控制冲突(Control Hazard) 结构冲突(Structure Hazard) 下面分别举例解释这三种类型的冲突。\n数据冲突 所谓数据冲突,简单讲,就是两条在流水线中并行执行的指令,第二条指令需要用到第一条指令的执行结果,因此第二条指令的执行不得不暂停,一直到可以获取到第一条指令的执行结果为止。\n比如,用伪代码举例:\n1 2 x = 1; y = x; 要对 y 进行赋值,必须要先得到 x 的值,因此这两条语句无法完全并行执行。 这只是其中的一种典型情况,其他情况不再赘述。\n控制冲突 所谓控制冲突,简单讲,就是在 CPU 在执行分支跳转时,无法预知下一条要执行的指令。 比如:\n1 2 3 4 5 if(a \u0026gt; 100) { x = 1; } else { y = 2; } 在 CPU 计算出 a \u0026gt; 100 这个条件是否成立之前,无法确定接下来是应该执行 x = 1 还是执行 y = 2。\n为了解决这个问题,CPU 可以简单的让流水线停顿一直到确定下一条要执行的指令,也可以采取如分支预测(branch prediction)和推测执行(speculation execution)等手段,但是,预测失败的话,流水线往往会受到比较严重的性能惩罚。之后会有专门的文章分析这个问题,感兴趣的话,可以右上角关注一下!\n结构冲突 结构冲突,简单来说,就是多条指令同时竞争同一个硬件资源,由于硬件资源短缺,无法同时满足所有指令的执行请求。如两条并行执行的命令需要同时访问内存,而内存地址译码单元可能只有一个,这就产生了结构冲突。\n有了上面这些基础知识做铺垫,接下来就可以开始真正分析这个问题了。\ntest.c 为什么性能最差? 对于计算阶乘,test.c 可能是最简单直观、可读性最强的算法。不过可惜的是,它也是性能最差的。\n我们再看一下 test.c 的源码:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i++) { fact *= 1; } return fact; } int main() { return cacl(1000000000); } 说它性能最差,主要有三点原因:\n热点路径无用指令太多。 热点路径跳转指令太多。 热点路径内存访问太多。 注意,这里说的无用指令,是指对计算阶乘本身不产生直接影响的指令,但是它们对整个算法的正确性仍然是必不可少的!\n为例方便理解,我们来分别看下 test.c 不加优化选项和加了-O3 编译之后的汇编代码。\ntest.c 不加优化选项时 绿色方框标注出来的 8 ~ 14 行是 for 循环,也就是主循环体。其中,蓝色方框标注出来的 8 ~ 11 行是真正计算阶乘的代码,12 ~ 14 行是循环控制代码,对计算阶乘来说,则是无用代码。\n不难看出:\n热点路径上,也就是循环体内无用指令占比是 3/7 = 42%!即便在不考虑其他因素的情况下,CPU 单单用来执行这些无用的指令,也是一笔不小的开销! 整个阶乘计算过程中,循环体内需要执行 1000000000 次条件跳转指令!条件跳转又会造成控制冲突,使得流水线无法全速运行,从而造成巨大的性能损失。 整个函数一共有 10 个内存访问操作,而循环体内就有 6 个内存操作!尽管很多时候可以通过 Cache 来缓解,但相对于 CPU 计算速度来说,内存操作仍然是非常慢的,而且容易造成流水线冲突! 那加了-O3 优化选项之后,编译器能不能帮我们解决这些问题呢?\ntest.c 加了-O3 优化选项后 首先,不得不感叹,现在的编译器的优化真的是太强大了!直接把整个 for 循环优化成了 4 条指令!\n不难看出,对于 test.c 而言,加了-O3 之后,GCC 做的最大的优化是把所有变量存放在寄存器中,消除了所有的内存访问操作!\n可以回过头去看下优化之前的汇编代码,整个函数一共有 10 个内存访问操作,其中 6 个是在循环体内,而加了-O3 之后,整个函数没有任何内存访问操作!难怪-O3 编译后性能提升那么多!由此可见,内存访问相对寄存器访问的开销实在是太大了!当然,即便不使用-O3,也有优化内存操作的办法,这个后面再讲。\n但是,也不难看出,对于其他两个问题,GCC 并没有帮我们解决:现在无用指令占比是 2/4 = 50%! 整个阶乘计算过程,仍然需要执行 1000000000 次条件跳转指令,仍然无法充分发挥流水线和 superscalar 的指令并行执行能力!\n知道了 test.c 性能差的原因之后,现在我们来看看,通过手动循环展开,test_2.c 又帮我们解决什么问题呢?\ntest_2.c 性能提升原因 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact0=1, fact1=1, fact2=1, fact3=1; for(int i =1; i\u0026lt;n; i += 4) { fact0 *= i; fact1 *= i+1; fact2 *= i+2; fact3 *= i+3; } return fact0 * fact1 * fact2 * fact3; } int main() { return cacl(1000000000); } 通过对循环进行 4 次展开,之前每次循环执行 1 次乘法,现在每次循环执行 4 次,这就带来了三点很重要的变化:\n循环次数减少 75%,无用指令减少了,相应的 CPU 执行这些指令本身的开销也少了。 计算过程中,热点路径的条件跳转指令少了 75%,这样就减少了由于控制相关引起的流水线冲突,提升了流水线执行的效率。 提升了指令的并行度,使得 CPU superscalar 的技术得到更充分的发挥,提高了每个时钟周期并行执行指令的条数。 这也就是为什么在使用同样的编译选项时,test_2.c 比 test.c 的性能提升了 200%!不过,热点路径上内存访问操作太多的问题仍然存在。其实,这个其实很好解决,我会在下文给出解决方法。我们先把注意力放在这里所说的三点变化上。\n对于第 1 点和第 2 点,有了前面介绍的指令流水线的背景知识,即便从 C 语言的角度也很好理解,不需要过多解释。\n至于第 3 点,为了便于理解,我们和 test_3.c 对比来看。\ntest_3.c 性能差的原因 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i += 4) { fact *= i; fact *= i+1; fact *= i+2; fact *= i+3; } return fact; } int main() { return cacl(1000000000); } 很明显,后面一条指令执行前,必须要先知道前面一条语句计算的结果。还记得前面讲过的造成流水线冲突的三个原因吗?这就是典型的数据依赖,会造成流水线冲突!\n可见,虽然 test_3.c 也通过循环展开,减少了无用指令,也减少了热点路径上分支跳转引起的流水线控制冲突,但它同时引入了数据依赖,进而导致流水线冲突,仍然无法发挥流水线和superscalar的指令级并行执行的能力!\n这就是为什么,用同样的选项编译时,test_3.c 虽然比未经过循环展开的 test.c 性能稍微提升了一点点,但相比同样循环展开且没有引入数据相关性的 test_2.c 来说,性能仍然是非常差的!\n讲到这里,本想演示下用 perf 测量出来的性能指标的,但由于篇幅过长,就不再展开讨论了,以后会专门更新文章介绍 perf 相关工具的使用!\n最后,来看一下前面遗留的那个问题:不加优化选项的情况下,怎么解决热点路径内存访问过多的问题。\n杀手锏:优化热点路径内存访问 其实很简单,只需要把 test_2.c 中定义局部变量的时候加上register关键字就可以了:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { register int fact0=1, fact1=1, fact2=1, fact3=1; for(register int i =1; i\u0026lt;n; i += 4) { fact0 *= i; fact1 *= i+1; fact2 *= i+2; fact3 *= i+3; } return fact0 * fact1 * fact2 * fact3; } int main() { return cacl(1000000000); } C语言中,register关键字的作用是建议编译器,尽可能地把变量存放在寄存器中,以加快其访问速度。\n我们现在看下,加了 register 关键字后,test_2.c 的性能如何呢?\n加了 register 后,几乎达到了和加-O3 优化选项一样的性能!\n当然,register 的使用还有很多限制,而且它只是给编译器的一种建议,不是强制要求,编译器只能尽量满足,当变量太多,寄存器不够用的时候,还是不得不把变量放到栈中,这和-O3 的行为是一样的。\nregister 不是本文重点,限于篇幅,不再赘述。\n小结 循环展开是一种非常重要的优化方法,也是编译器后端中常用的一种优化方式,它可以通过减少热点路径上的“无用指令”以及分支指令的个数,来更好地发挥 CPU 指令流水线的指令并行执行能力,从而提高程序整体性能。\n很多时候,我们可以借助编译器来帮我们实现这种优化,但编译器也有失效的时候,比如文中这个例子。这时,我们就不得不手动来进行循环展开来优化程序性能。循环展开时,必须尽量减少语句间的相互依赖。\n此外,循环展开的次数并没有一个固定的公式,需要根据具体代码和CPU来决定,通常需要多次尝试来找到一个最优值。\n不过,手动循环展开往往是以牺牲代码可读性为代价的,因此使用时也做好取舍。此外,循环展开还会在一定程度上增加程序代码段的大小,还可能会影响到程序局部性,对 cache 产生影响,因此使用时候,要仔细权衡。\n","permalink":"https://reid00.github.io/en/posts/os_network/for%E5%BE%AA%E7%8E%AF%E8%80%97%E6%97%B6%E4%BB%8E3.2%E7%A7%92%E9%99%8D%E5%88%B00.3%E7%A7%92/","summary":"一道面试题 1 2 3 4 5 6 7 8 9 int test(int n) { int fact = 1, num = n+1; for(int i =1; i\u0026lt;num; i++) { fact *= 1; } return fact; } 面试官:这段求阶乘的代码怎么样? 答:挺简洁的,简单易懂。不过如果","title":"For循环耗时从3.2秒降到0.3秒"},{"content":"前言 首先,为什么要总结B树、B+树的知识呢?最近在学习数据库索引调优相关知识,数据库系统普遍采用B-/+Tree作为索引结构(例如mysql的InnoDB引擎使用的B+树),理解不透彻B树,则无法理解数据库的索引机制;接下来将用最简洁直白的内容来了解B树、B+树的数据结构\n另外,B-树,即为B树。因为B树的原英文名称为B-tree,而国内很多人喜欢把B-tree译作B-树,其实,这是个非常不好的直译,很容易让人产生误解。如人们可能会以为B-树是一种树,而B树又是一种树。而事实上是,B-tree就是指的B树,目前理解B的意思为平衡\nB树的出现是为了弥合不同的存储级别之间的访问速度上的巨大差异,实现高效的 I/O。平衡二叉树的查找效率是非常高的,并可以通过降低树的深度来提高查找的效率。但是当数据量非常大,树的存储的元素数量是有限的,这样会导致二叉查找树结构由于树的深度过大而造成磁盘I/O读写过于频繁,进而导致查询效率低下。另外数据量过大会导致内存空间不够容纳平衡二叉树所有结点的情况。B树是解决这个问题的很好的结构\n概念 首先,B树不要和二叉树混淆,在计算机科学中,B树是一种自平衡树数据结构,它维护有序数据并允许以对数时间进行搜索,顺序访问,插入和删除。B树是二叉搜索树的一般化,因为节点可以有两个以上的子节点。[1]与其他自平衡二进制搜索树不同,B树非常适合读取和写入相对较大的数据块(如光盘)的存储系统。它通常用于数据库和文件系统。\n定义 B树是一种平衡的多叉树,通常我们说m阶的B树,它必须满足如下条件:\n每个节点最多只有m个子节点。 每个非叶子节点(除了根)具有至少⌈ m/2⌉子节点。 如果根不是叶节点,则根至少有两个子节点。 具有k个子节点的非叶节点包含k -1个键。 所有叶子都出现在同一水平,没有任何信息(高度一致)。 什么是B树的阶 ? B树中一个节点的子节点数目的最大值,用m表示,假如最大值为4,则为4阶,如图 所有节点中,节点【13,16,19】拥有的子节点数目最多,四个子节点(灰色节点),所以可以定义上面的图片为4阶B树,现在懂什么是阶了吧\n什么是根节点 ? 节点【10】即为根节点,特征:根节点拥有的子节点数量的上限和内部节点相同,如果根节点不是树中唯一节点的话,至少有俩个子节点(不然就变成单支了)。在m阶B树中(根节点非树中唯一节点),那么有关系式2\u0026lt;= M \u0026lt;=m,M为子节点数量;包含的元素数量 1\u0026lt;= K \u0026lt;=m-1,K为元素数量。\n什么是内部节点 ? 节点【13,16,19】、节点【3,6】都为内部节点,特征:内部节点是除叶子节点和根节点之外的所有节点,拥有父节点和子节点。假定m阶B树的内部节点的子节点数量为M,则一定要符合(m/2)\u0026lt;= M \u0026lt;=m关系式,包含元素数量M-1;包含的元素数量 (m/2)-1\u0026lt;= K \u0026lt;=m-1,K为元素数量。m/2向上取整。\n什么是叶子节点? 节点【1,2】、节点【11,12】等最后一层都为叶子节点,叶子节点对元素的数量有相同的限制,但是没有子节点,也没有指向子节点的指针。特征:在m阶B树中叶子节点的元素符合(m/2)-1\u0026lt;= K \u0026lt;=m-1。\n插入 针对m阶高度h的B树,插入一个元素时,首先在B树中是否存在,如果不存在,即在叶子结点处结束,然后在叶子结点中插入该新的元素。\n若该节点元素个数小于m-1,直接插入; 若该节点元素个数等于m-1,引起节点分裂;以该节点中间元素为分界,取中间元素(偶数个数,中间两个随机选取)插入到父节点中; 重复上面动作,直到所有节点符合B树的规则;最坏的情况一直分裂到根节点,生成新的根节点,高度增加1; 上面三段话为插入动作的核心,接下来以5阶B树为例,详细讲解插入的动作;\n5阶B树关键点:\n2\u0026lt;=根节点子节点个数\u0026lt;=5 3\u0026lt;=内节点子节点个数\u0026lt;=5 1\u0026lt;=根节点元素个数\u0026lt;=4 2\u0026lt;=非根节点元素个数\u0026lt;=4 插入8 图(1)插入元素【8】后变为图(2),此时根节点元素个数为5,不符合 1\u0026lt;=根节点元素个数\u0026lt;=4,进行分裂(真实情况是先分裂,然后插入元素,这里是为了直观而先插入元素,下面的操作都一样,不再赘述),取节点中间元素【7】,加入到父节点,左右分裂为2个节点,如图(3) 接着插入元素【5】,【11】,【17】时,不需要任何分裂操作,如图(4) 插入元素【13】 节点元素超出最大数量,进行分裂,提取中间元素【13】,插入到父节点当中,如图(6) 接着插入元素【6】,【12】,【20】,【23】时,不需要任何分裂操作,如图(7) 插入【26】时,最右的叶子结点空间满了,需要进行分裂操作,中间元素【20】上移到父节点中,注意通过上移中间元素,树最终还是保持平衡,分裂结果的结点存在2个关键字元素。 插入【4】时,导致最左边的叶子结点被分裂,【4】恰好也是中间元素,上移到父节点中,然后元素【16】,【18】,【24】,【25】陆续插入不需要任何分裂操作 最后,当插入【19】时,含有【14】,【16】,【17】,【18】的结点需要分裂,把中间元素【17】上移到父节点中,但是情况来了,父节点中空间已经满了,所以也要进行分裂,将父节点中的中间元素【13】上移到新形成的根结点中,这样具体插入操作的完成。 删除 首先查找B树中需删除的元素,如果该元素在B树中存在,则将该元素在其结点中进行删除;删除该元素后,首先判断该元素是否有左右孩子结点,如果有,则上移孩子结点中的某相近元素(“左孩子最右边的节点”或“右孩子最左边的节点”)到父节点中,然后是移动之后的情况;如果没有,直接删除。\n某结点中元素数目小于(m/2)-1,(m/2)向上取整,则需要看其某相邻兄弟结点是否丰满; 如果丰满(结点中元素个数大于(m/2)-1),则向父节点借一个元素来满足条件; 如果其相邻兄弟都不丰满,即其结点数目等于(m/2)-1,则该结点与其相邻的某一兄弟结点进行“合并”成一个结点; 接下来还以5阶B树为例,详细讲解删除的动作;\n关键要领,元素个数小于 2(m/2 -1)就合并,大于4(m-1)就分裂 如图依次删除依次删除【8】,【20】,【18】,【5】 首先删除元素【8】,当然首先查找【8】,【8】在一个叶子结点中,删除后该叶子结点元素个数为2,符合B树规则,操作很简单,咱们只需要移动【11】至原来【8】的位置,移动【12】至【11】的位置(也就是结点中删除元素后面的元素向前移动) 下一步,删除【20】,因为【20】没有在叶子结点中,而是在中间结点中找到,咱们发现他的继承者【23】(字母升序的下个元素),将【23】上移到【20】的位置,然后将孩子结点中的【23】进行删除,这里恰好删除后,该孩子结点中元素个数大于2,无需进行合并操作。 下一步删除【18】,【18】在叶子结点中,但是该结点中元素数目为2,删除导致只有1个元素,已经小于最小元素数目2,而由前面我们已经知道:如果其某个相邻兄弟结点中比较丰满(元素个数大于ceil(5/2)-1=2),则可以向父结点借一个元素,然后将最丰满的相邻兄弟结点中上移最后或最前一个元素到父节点中,在这个实例中,右相邻兄弟结点中比较丰满(3个元素大于2),所以先向父节点借一个元素【23】下移到该叶子结点中,代替原来【19】的位置,【19】前移;然【24】在相邻右兄弟结点中上移到父结点中,最后在相邻右兄弟结点中删除【24】,后面元素前移。 最后一步删除【5】, 删除后会导致很多问题,因为【5】所在的结点数目刚好达标,刚好满足最小元素个数(ceil(5/2)-1=2),而相邻的兄弟结点也是同样的情况,删除一个元素都不能满足条件,所以需要该节点与某相邻兄弟结点进行合并操作;首先移动父结点中的元素(该元素在两个需要合并的两个结点元素之间)下移到其子结点中,然后将这两个结点进行合并成一个结点。所以在该实例中,咱们首先将父节点中的元素【4】下移到已经删除【5】而只有【6】的结点中,然后将含有【4】和【6】的结点和含有【1】,【3】的相邻兄弟结点进行合并成一个结点。 也许你认为这样删除操作已经结束了,其实不然,在看看上图,对于这种特殊情况,你立即会发现父节点只包含一个元素【7】,没达标(因为非根节点包括叶子结点的元素K必须满足于2=\u0026lt; K \u0026lt;=4, 而此处的K=1),这是不能够接受的。如果这个问题结点的相邻兄弟比较丰满,则可以向父结点借一个元素。而此时兄弟节点元素刚好为2,刚刚满足,只能进行合并,而根结点中的唯一元素【13】下移到子结点,这样,树的高度减少一层。 磁盘IO与预读 计算机存储设备一般分为两种:内存储器(main memory)和外存储器(external memory)。\n内存储器为内存,内存存取速度快,但容量小,价格昂贵,而且不能长期保存数据(在不通电情况下数据会消失)。\n外存储器即为磁盘读取,磁盘读取数据靠的是机械运动,每次读取数据花费的时间可以分为寻道时间、旋转延迟、传输时间三个部分,寻道时间指的是磁臂移动到指定磁道所需要的时间,主流磁盘一般在5ms以下;旋转延迟就是我们经常听说的磁盘转速,比如一个磁盘7200转,表示每分钟能转7200次,也就是说1秒钟能转120次,旋转延迟就是1/120/2 = 4.17ms;传输时间指的是从磁盘读出或将数据写入磁盘的时间,一般在零点几毫秒,相对于前两个时间可以忽略不计。那么访问一次磁盘的时间,即一次磁盘IO的时间约等于5+4.17 = 9ms左右,听起来还挺不错的,但要知道一台500 -MIPS的机器每秒可以执行5亿条指令,因为指令依靠的是电的性质,换句话说执行一次IO的时间可以执行40万条指令,数据库动辄十万百万乃至千万级数据,每次9毫秒的时间,显然是个灾难。下图是计算机硬件延迟的对比图,供大家参考: 考虑到磁盘IO是非常高昂的操作,计算机操作系统做了一些优化,当一次IO时,不光把当前磁盘地址的数据,而是把相邻的数据也都读取到内存缓冲区内,因为局部预读性原理告诉我们,当计算机访问一个地址的数据的时候,与其相邻的数据也会很快被访问到。每一次IO读取的数据我们称之为一页(page)。具体一页有多大数据跟操作系统有关,一般为4k或8k,也就是我们读取一页内的数据时候,实际上才发生了一次IO,这个理论对于索引的数据结构设计非常有帮助。\n事实1 : 不同容量的存储器,访问速度差异悬殊。\n磁盘(ms级别) \u0026laquo; 内存(ns级别), 100000倍 若内存访问需要1s,则一次外存访问需要一天 为了避免1次外存访问,宁愿访问内存100次\u0026hellip;所以将最常用的数据存储在最快的存储器中 事实2 : 从磁盘中读 1 B,与读写 1KB 的时间成本几乎一样\n从以上数据中可以总结出一个道理,索引查询的数据主要受限于硬盘的I/O速度,查询I/O次数越少,速度越快,所以B树的结构才应需求而生;B树的每个节点的元素可以视为一次I/O读取,树的高度表示最多的I/O次数,在相同数量的总元素个数下,每个节点的元素个数越多,高度越低,查询所需的I/O次数越少;假设,一次硬盘一次I/O数据为8K,索引用int(4字节)类型数据建立,理论上一个节点最多可以为2000个元素,200020002000=8000000000,80亿条的数据只需3次I/O(理论值),可想而知,B树做为索引的查询效率有多高;\n另外也可以看出同样的总元素个数,查询效率和树的高度密切相关\nB树的高度 一棵含有N个总关键字数的m阶的B树的最大高度是多少? log(m/2)(N+1)/2 + 1 ,log以(m/2)为低,(N+1)/2的对数再加1 B+树 B+树是应文件系统所需而产生的B树的变形树,那么可能一定会想到,既然有了B树,又出一个B+树,那B+树必然是有很多优点的\nB+树的特征:\n有m个子树的中间节点包含有m个元素(B树中是k-1个元素),每个元素不保存数据,只用来索引; 所有的叶子结点中包含了全部关键字的信息,及指向含有这些关键字记录的指针,且叶子结点本身依关键字的大小自小而大的顺序链接。 (而B 树的叶子节点并没有包括全部需要查找的信息); 所有的非终端结点可以看成是索引部分,结点中仅含有其子树根结点中最大(或最小)关键字。 (而B 树的非终节点也包含需要查找的有效信息); 为什么说B+树比B树更适合数据库索引? B+树的磁盘读写代价更低 B+树的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了;\nB+树查询效率更加稳定 由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当;\nB+树便于范围查询(最重要的原因,范围查找是数据库的常态) B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低;不懂可以看看这篇解读-》范围查找\n补充:B树的范围查找用的是中序遍历,而B+树用的是在链表上遍历;\nB+Tree 补充 ","permalink":"https://reid00.github.io/en/posts/storage/b+%E6%A0%91/","summary":"前言 首先,为什么要总结B树、B+树的知识呢?最近在学习数据库索引调优相关知识,数据库系统普遍采用B-/+Tree作为索引结构(例如mysql","title":"B+树"},{"content":"分布式事务初探 分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit)。\n之所以提及分布式事务,是因为对于拥有大量数据的人来说,他们通常会将数据进行分割或者分片到许多不同的服务器上。假设你运行了一个银行,你一半用户的账户在一个服务器,另一半用户的账户在另一个服务器,这样的话可以同时满足负载分担和存储空间的要求。对于其他的场景也有类似的分片,比如说对网站上文章的投票,或许有上亿篇文章,那么可以在一个服务器上对一半的文章进行投票,在另一个服务器对另一半进行投票。\n对于一些操作,可能会要求从多个服务器上修改或者读取数据。比如说我们从一个账户到另一个账户完成银行转账,这两个账户可能在不同的服务器上。因此,为了完成转账,我们必须要读取并修改两个服务器的数据。\n一种构建系统的方式,我们在后面的课程也会看到,就是尝试向应用程序的开发人员,隐藏将数据分割在多个服务器上带来的复杂度。在过去的几十年间,这都是设计数据库需要考虑的问题,所以很多现在的材料的介绍都是基于数据库。但是这种方式(隐藏数据分片在多个服务器),现在在一些与传统数据库不相关的分布式系统也在广泛应用。 人们通常将并发控制和原子提交放在一起,当做事务。有关事务,我们之前介绍过。\n可以这么理解事务:程序员有一些不同的操作,或许针对数据库不同的记录,他们希望所有这些操作作为一个整体,不会因为失败而被分割,也不会被其他活动看到中间状态。事务处理系统要求程序员对这些读操作、写操作标明起始和结束,这样才能知道事务的起始和结束。事务处理系统可以保证在事务的开始和结束之间的行为是可预期的。 例如,假设我们运行了一个银行,我们想从用户Y转账到用户X,这两个账户最开始都有10块钱,这里的X,Y都是数据库的记录。\n这里有两个交易,第一个是从Y转账1块钱到X,另一个是对于所有的银行账户做审计,确保总的钱数不会改变,因为毕竟在账户间转钱不会改变所有账户的总钱数。我们假设这两个交易同时发生。为了用事务来描述这里的交易,我们需要有两个事务,第一个事务称为T1,程序员会标记它的开始,我们称之为BEGIN_X,之后是对于两个账户的操作,我们会对账户X加1,对账户Y加-1。之后我们需要标记事务的结束,我们称之为END_X。 同时,我们还有一个事务,会检查所有的账户,对所有账户进行审计,确保尽管可能存在转账,但是所有账户的金额加起来总数是不变的。所以,第二个事务是审计事务,我们称为T2。我们也需要为事务标记开始和结束。这一次我们只是读数据,所以这是一个只读事务。我们需要获取所有账户的当前余额,因为现在我们只有两个账户,所以我们使用两个临时的变量,第一个是用来读取并存放账户X的余额,第二个用来读取并存放账户Y的余额,之后我们将它们都打印出来,最后是事务的结束。\n这里的问题是,这两个事务的合法结果是什么?这是我们首先想要确定的事情。最初的状态是,两个账户都是10块钱,但是在同时运行完两个事务之后,最终结果可能是什么?我们需要一个概念来定义什么是正确的结果。一旦我们知道了这个概念,我们需要构建能执行这些事务的机制,在可能存在并发和失败的前提下,仍然得到正确的结果。 所以,首先,什么是正确性?数据库通常对于正确性有一个概念称为ACID。分别代表:\nAtomic,原子性。它意味着,事务可能有多个步骤,比如说写多个数据记录,尽管可能存在故障,但是要么所有的写数据都完成了,要么没有写数据能完成。不应该发生类似这种情况:在一个特定的时间发生了故障,导致事务中一半的写数据完成并可见,另一半的写数据没有完成,这里要么全有,要么全没有(All or Nothing)。 Consistent,一致性。我们实际上不会担心这一条,它通常是指数据库会强制某些应用程序定义的数据不变,这不是我们今天要考虑的点。 Isolated,隔离性。这一点还比较重要。这是一个属性,它表明两个同时运行的事务,在事务结束前,能不能看到彼此的更新,能不能看到另一个事务中间的临时的更新。目标是不能。隔离在技术上的具体体现是,事务需要串行执行,我之后会再解释这一条。但是总结起来,事务不能看到彼此之间的中间状态,只能看到完成的事务结果。 Durable,持久化的。这意味着,在事务提交之后,在客户端或者程序提交事务之后,并从数据库得到了回复说,yes,我们执行了你的事务,那么这时,在数据库中的修改是持久化的,它们不会因为一些错误而被擦除。在实际中,这意味着数据需要被写入到一些非易失的存储(Non-Volatile Storage),持久化的存储,例如磁盘。 今天的课程会讨论,在考虑到错误,考虑到多个并发行为的前提下,什么才是正确的行为,并确保数据在出现故障之后,仍然存在。这里对我们来说最有意思的部分是有关隔离性或者串行的具体定义。我会首先介绍这一点,之后再介绍如何执行上面例子中的两个事务。 通常来说,隔离性(Isolated)意味着可序列化(Serializable)。它的定义是如果在同一时间并行的执行一系列的事务,那么可以生成一系列的结果。这里的结果包括两个方面:由任何事务中的修改行为产生的数据库记录的修改;和任何事务生成的输出。所以前面例子中的两个事务,T1的结果是修改数据库记录,T2的结果是打印出数据。\n我们说可序列化是指,并行的执行一些事物得到的结果,与按照某种串行的顺序来执行这些事务,可以得到相同的结果。实际的执行过程或许会有大量的并行处理,但是这里要求得到的结果与按照某种顺序一次一个事务的串行执行结果是一样的。所以,如果你要检查一个并发事务执行是否是可序列化的,你查看结果,并看看是否可以找到对于同一些事务,存在一次只执行一个事务的顺序,按照这个顺序执行可以生成相同的结果。(存在穿行执行得结果和并发执行事务的结果相同)。\n隔离性(Isolated) 所以,我们刚刚例子中的事务,只有两种一次一个的串行顺序,要么是T1,T2,要么是T2,T1。我们可以看一下这两种串行执行生成的结果。 我们先执行T1,再执行T2,我们得到X=11,Y=9,因为T1先执行,T2中的打印,可以看到这两个更新过后的数据,所以这里会打印字符串“11,9”。\n另一种可能的顺序是,先执行T2,再执行T1,这种情况下,T2可以看到更新之前的数据,但是更新仍然会在T1中发生,所以最后的结果是X=11,Y=9。但是这一次,T2打印的是字符串“10,10”。 所以,这是两种串行执行的合法结果。如果我们同时执行这两个事务,看到了这两种结果之外的结果,那么我们运行的数据库不能提供序列化执行的能力(也就是不具备隔离性 Isolated)。所以,实际上,我们在考虑问题的时候,可以认为这是唯二可能的结果,我们最好设计我们的系统,并让系统只输出两个结果中的一个。\n如果你同时提交两个事务,你不知道是T1,T2的顺序,还是T2,T1的顺序,所以你需要预期可能会有超过一个合法的结果。当你同时运行了更多的事务,结果也会更加复杂,可能会有很多不同的正确的结果,这些结果都是可序列化的,因为这里对于事务存在许多顺序,可以被用来满足序列化的要求。 现在我们对于正确性有了一个定义,我们甚至知道了可能的结果是什么。我们可以提出几个有关执行顺序的假设。\n例如,假设系统实际上这么执行,开始执行T2,并执行到读X,之后执行了T1。在T1结束之后,T2再继续执行。 如果不是T2这样的读事务,最后的结果可能也是合法的。但是现在,我们想知道如果按照这种方式执行,我们得到的结果是否是之前的两种结果之一。在这里,T2事务中的变量t1可以看到10,t2会看到减Y之后的结果所以是9,最后的打印将会是字符串“10,9”。这不符合之前的两种结果,所以这里描述的执行方式不是可序列化的,它不合法。\n另一个有趣的问题是,如果我们一开始执行事务T1,然后在执行完第一个add时,执行了整个事务T2 这意味着,在T2执行的点,T2可以读到X为11,Y为10,之后打印字符串“11,10”。这也不是之前的两种合法结果之一。所以对于这两个事务,这里的执行过程也不合法。\n可序列化是一个应用广泛且实用的定义,背后的原因是,它定义了事务执行过程的正确性。它是一个对于程序员来说是非常简单的编程模型,作为程序员你可以写非常复杂的事务而不用担心系统同时在运行什么,或许有许多其他的事务想要在相同的时间读写相同的数据,或许会发生错误,这些你都不需要关心。可序列化特性确保你可以安全的写你的事务,就像没有其他事情发生一样。因为系统最终的结果必须表现的就像,你的事务在这种一次一个的顺序中是独占运行的。这是一个非常简单,非常好的编程模型。\n可序列化的另一方面优势是,只要事务不使用相同的数据,它可以允许真正的并行执行事务。我们之前的例子之所以有问题,是因为T1和T2都读取了数据X和Y。但是如果它们使用完全没有交集的数据库记录,那么这两个事务可以完全并行的执行。在一个分片的系统中,不同的数据在不同的机器上,你可以获得真正的并行速度提升,因为可能一个事务只会在第一个机器的第一个分片上执行,而另一个事务并行的在第二个机器上执行。所以,这里有可能可以获得更高的并发性能。\n在我详细介绍可序列化的事务之前,我还想提出一个小点。有一件场景我们需要能够应付,事务可能会因为这样或那样的原因在执行的过程中失败或者决定失败,通常这被称为Abort。对于大部分的事务系统,我们需要能够处理,例如当一个事务尝试访问一个不存在的记录,或者除以0,又或者是,某些事务的实现中使用了锁,一些事务触发了死锁,而解除死锁的唯一方式就是干掉一个或者多个参与死锁的事务,类似这样的场景。所以在事务执行的过程中,如果事务突然决定不能继续执行,这时事务可能已经修改了部分数据库记录,我们需要能够回退这些事务,并撤回任何已经做了的修改。\n实现事务的策略,我会划分成两块,在这门课程中我都会介绍它们,先来简单的看一下这两块。\n第一个大的有关实现的话题是并发控制(Concurrency Control)。这是我们用来提供可序列化的主要工具。所以并发控制就是可序列化的别名。通过与其他尝试使用相同数据的并发事务进行隔离,可以实现可序列化。 另一个有关实现的大的话题是原子提交(Atomic Commit)。它帮助我们处理类似这样的可能场景:前面例子中的事务T1在执行过程中可能已经修改了X的值,突然事务涉及的一台服务器出现错误了,我们需要能从这种场景恢复。所以,哪怕事务涉及的机器只有部分还在运行,我们需要具备能够从部分故障中恢复的能力。这里我们使用的工具就是原子提交。我们后面会介绍。 并发控制 在并发控制中,主要有两种策略:\n第一种主要策略是悲观并发控制(Pessimistic Concurrency Control)。 这里通常涉及到锁, 实际上,数据库的事务处理系统也会使用锁。这里的想法或许你已经非常熟悉了,那就是在事务使用任何数据之前,它需要获得数据的锁。如果一些其他的事务已经在使用这里的数据,锁会被它们持有,当前事务必须等待这些事务结束,之后当前事务才能获取到锁。在悲观系统中,如果有锁冲突,比如其他事务持有了锁,就会造成延时等待。所以这里需要为正确性而牺牲性能。\n第二种主要策略是乐观并发控制(Optimistic Concurrency Control) 这里的基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。\n实际,这两种策略哪个更好取决于不同的环境。如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗。今天我们只会介绍悲观并发控制。几周之后的论文,我们会讨论一种乐观并发控制的方法。\n所以,今天讨论悲观并发控制,这里涉及到的基本上就是锁机制。这里的锁是两阶段锁(Two-Phase Locking),这是一种最常见的锁。\n两阶段锁(Two-Phase Locking) 对于两阶段锁来说,当事务需要使用一些数据记录时,例如前面例子中的X,Y,第一个规则是在使用任何数据之前,在执行任何数据的读写之前,先获取锁。 第二个对于事务的规则是,事务必须持有任何已经获得的锁,直到事务提交或者Abort,你不允许在事务的中间过程释放锁。你必须要持有所有的锁,并不断的累积你持有的锁,直到你的事务完成了。所以,这里的规则是,持有锁直到事务结束。\n所以,这就是两阶段锁的两个阶段,第一个阶段获取锁,第二个阶段是在事务结束前一直持有锁。 为什么两阶段锁能起作用呢?虽然有很多的变种,在一个典型的锁系统中,每一个数据库中的记录(每个Table中的每一行)都有一个独立的锁(虽然实际中粒度可能更大)。一个事务,例如前面例子中的T1,最开始的时候不持有任何锁,当它第一次使用X记录时,在它真正使用数据前,它需要获得对于X的锁,这里或许需要等待。当它第一次使用Y记录时,它需要获取另一个对于Y的锁,当它结束之后,它会释放这两个锁。如果我们同时运行之前例子中的两个事务,它们会同时竞争对于X的锁。任何一个事务先获取了X的锁,它会继续执行,最后结束并提交。同时,另一个没有获得X的锁,它会等待锁,在对X进行任何修改之前,它需要先获取锁。所以,如果T2先获取了锁,它会获取X,Y的数值,打印,结束事务,之后释放锁。只有在这时,事务T1才能获得对于X的锁。\n如你所见的,这里基本上迫使事务串行执行,在刚刚的例子中,两阶段锁迫使执行顺序是T2,T1。所以这里显式的迫使事务的执行遵循可序列化的定义,因为实际上就是T2完成之后,再执行T1。所以我们可以获得正确的执行结果。 这里有一个问题是,为什么需要在事务结束前一直持有锁?你或许会认为,你可以只在使用数据的时候持有锁,这样也会更有效率。在刚刚的例子中,或许只在T2获取记录X的数值时持有对X的锁,或许只在T1执行对X加1操作的时候持有对于X的锁,之后立即释放锁,虽然这样违反了两阶段锁的规则,但是如果立刻释放对于数据的锁,另一个事务可以早一点执行,我们就可以有更多的并发度,进而获得更高的性能。所以,两阶段锁必然对于性能来说很糟糕,所以我们才需要确认,它对于正确性来说是必要的。\n如果事务尽可能早的释放锁,会发生什么呢?假设T2读取了X,然后立刻释放了锁,那么在这个位置,T2不持有任何锁,因为它刚刚释放了对于X的锁。\n因为T2不持有任何锁,这意味着T1可以完全在这个位置执行。从前面的反例我们已经知道,这样的执行是错误的(因为T2会打印“10,9”),因为它没能生成正确结果。 类似的,如果T1在执行完对X加1之后,就释放了对X的锁,这会使得整个T2有可能在这个位置执行。\n我们之前也看到了,这会导致非法的结果。\n如果在修改完数据之后就释放锁,还会有额外的问题。如果T1在执行完对X加1之后释放锁,它允许T2看到修改之后的X,之后T2会打印出这个结果。但是如果T1之后Abort了,或许因为银行账户Y并不存在,或许账户Y存在,但是余额为0,而我们不允许对于余额为0的账户再做减法,这样会造成透支。所以T1有可能会修改X,然后Abort。Abort的一部分工作就是要撤回对于X的修改,这样才能维持原子性。这意味着,如果T1释放了对于X的锁,事务T2会看到X的虚假数值11,这个数值最终不存在,因为T1中途Abort了,T2会看到一个永远不曾存在的数值。T2的结果最好是看起来就像是T2自己在运行,并没有T1的存在。但是这里,T2会看到X加1,然后打印出11,这与数据库的任何状态都对应不上。\n所以,使用了两阶段锁可以避免这两种违反可序列化特性的场景。 对于这些规则,还有一些需要知道的事情。首先是,这里非常容易产生死锁。例如我们有两个事务,T1读取记录X,之后再读取记录Y,T2读取记录Y,之后再读取记录X。如果它们同时运行,这里就是个死锁。\n每个事务都获取了第一个读取数据的锁,直到事务结束了,它们都不会释放这个锁。所以接下来,它们都会等待另一个事务持有的锁,除非数据库足够聪明,这里会永远死锁。实际上,事务有各种各样的策略,包括了判断循环,超时来判断它们是不是陷入到这样一个场景中。如果是的话,数据库会Abort其中一个事务,撤回它所有的操作,并表现的像这个事务从来没有发生一样。 所以这就是使用两阶段锁的并发控制。这是一个完全标准的数据库行为,在一个单主机的数据库中是这样,在一个分布式数据库也是这样,不过会更加的有趣。\n两阶段提交(Two-Phase Commit) 在一个分布式环境中,数据被分割在多台机器上,如何构建数据库或存储系统以支持事务。所以这个话题是,如何构建分布式事务(Distributed Transaction)。具体来说,如何应付错误,甚至是由多台机器中的一台引起的部分错误。这种部分错误在分布式系统中很常见。所以,在分布式事务之外,我们也要确保出现错误时,数据库仍然具有可序列化和某种程度的All-or-Nothing原子性。 一个场景是,我们或许有两个服务器,服务器S1保存了X的记录,服务器S2保存了Y的记录,它们的初始值都是10。 接下来我们要运行之前的两个事务。事务T1同时修改了X和Y,相应的我们需要向数据库发送消息说对X加1,对Y减1。但是如果我们不够小心,我们很容易就会陷入到这个场景中:我们告诉服务器S1去对X加1, 但是,之后出现了一些故障,或许持有Y记录的服务器S2故障了,使得我们没有办法完成更新的第二步。所以,这是一个问题:某个局部的故障会导致事务被分割成两半。如果我们不够小心,我们会导致一个事务中只有一半指令会生效。 甚至服务器没有崩溃都可能触发这里的场景。如果X完成了在事务中的工作,并且在服务器S2上,收到了对Y减1的请求,但是服务器S2发现Y记录并不存在。\n或者存在,但是账户余额等于0。这时,不能对Y减1。\n不管怎样,服务器2不能完成它在事务中应该做的那部分工作。但是服务器1又完成了它在事务中的那部分工作。所以这也是一种需要处理的问题。 这里我们想要的特性,我之前也提到过,就是,要么系统中的每一部分都完成它们在事务中的工作,要么系统中的所有部分都不完成它们在事务中的工作。在前面,我们违反的规则是,在故障时没有保证原子性。\n原子性是指,事务的每一个部分都执行,或者任何一个部分都不执行。很多时候,我们看到的解决方案是原子提交协议(Atomic Commit Protocols)。通常来说,原子提交协议的风格是:假设你有一批计算机,每一台都执行一个大任务的不同部分,原子提交协议将会帮助计算机来决定,它是否能够执行它对应的工作,它是否执行了对应的工作,又或者,某些事情出错了,所有计算机都要同意,没有一个会执行自己的任务。 这里的挑战是,如何应对各种各样的故障,机器故障,消息缺失。同时,还要考虑性能。原子提交协议在今天的阅读内容中有介绍,其中一种是两阶段提交(Two-Phase Commit)。\n两阶段提交不仅被分布式数据库所使用,同时也被各种看起来不像是传统数据库的分布式系统所使用。通常情况下,我们需要执行的任务会以某种方式分包在多个服务器上,每个服务器需要完成任务的不同部分。所以,在前一个例子中,实际上是数据被分割在不同的服务器上,所以相应的任务(为X加1,为Y减1)也被分包在不同的服务器上。我们将会假设,有一个计算机会用来管理事务,它被称为事务协调者(Transaction Coordinator)。事务协调者有很多种方法用来管理事务,我们这里就假设它是一个实际运行事务的计算机。在一个计算机上,事务协调者以某种形式运行事务的代码,例如Put/Get/Add,它向持有了不同数据的其他计算机发送消息,其他计算机再执行事务的不同部分。 所以,在我们的配置中,我们有一个计算机作为事务协调者(TC),然后还有服务器S1,S2,分别持有X,Y的记录。\n事务协调者会向服务器S1发消息说,请对X加1,向服务器S2发消息说,请对Y减1。 之后会有更多消息来确认,要么两个服务器都执行了操作,要么两个服务器都没有执行操作。这就是两阶段提交的实现框架。 有些事情你需要记住,在一个完整的系统中,或许会有很多不同的并发运行事务,也会有许多个事务协调者在执行它们各自的事务。在这个架构里的各个组成部分,都需要知道消息对应的是哪个事务。它们都会记录状态。每个持有数据的服务器会维护一个锁的表单,用来记录锁被哪个事务所持有。所以对于事务,需要有事务ID(Transaction ID),简称为TID。\n虽然不是很确定,这里假设系统中的每一个消息都被打上唯一的事务ID作为标记。这里的ID在事务开始的时候,由事务协调器来分配。这样事务协调器会发出消息说:这个消息是事务95的。同时事务协调器会在本地记录事务95的状态,对事务的参与者(例如服务器S1,S2)打上事务ID的标记。 这就是一些相关的术语,我们有事务协调者,我们还有其他的服务器执行部分的事务,这些服务器被称为参与者(Participants)。\n接下来,让我画出两阶段提交协议的一个参考执行过程。我们将Two-Phase Commit简称为2PC。参与者有:事务协调者(TC),我们假设只有两个参与者(A,B),两个参与者就是持有数据的两个不同的服务器。\n事务协调者运行了整个事务,它会向A,B发送Put和Get,告诉它们读取X,Y的数值,对X加1等等。所以,在事务的最开始,TC会向参与者A发送Get请求并得到回复,之后再向参与者B发送一个Put请求并得到回复。 这里只是举个例子,如果有一个复杂的事务,可能会有一个更长的请求序列。 之后,当事务协调者到达了事务的结束并想要提交事务,这样才能:\n释放所有的锁, 并使得事务的结果对于外部是可见的, 再向客户端回复。 我们假设有一个外部的客户端C,它在最最开始的时候会向TC发请求说,请运行这个事务。并且之后这个客户端会等待回复。 在开始执行事务时,TC需要确保,所有的事务参与者能够完成它们在事务中的那部分工作。更具体的,如果在事务中有任何Put请求,我们需要确保,执行Put的参与者仍然能执行Put。TC为了确保这一点,会向所有的参与者发送Prepare消息。\n当A或者B收到了Prepare消息,它们就知道事务要执行但是还没执行的内容,它们会查看自身的状态并决定它们实际上能不能完成事务。或许它们需要Abort这个事务因为这个事务会引起死锁,或许它们在故障重启过程中并完全忘记了这个事务因此不能完成事务。所以,A和B会检查自己的状态并说,我有能力或者我没能力完成这个事务,它们会向TC回复Yes或者No。\n事务协调者会等待来自于每一个参与者的这些Yes/No投票。如果所有的参与者都回复Yes,那么事务可以提交,不会发生错误。之后事务协调者会发出一个Commit消息,给每一个事务的参与者,之后,事务参与者通常会回复ACK说,我们知道了要commit。当事务协调者发出Prepare消息时,如果所有的参与者都回复Yes,那么事务可以commit。如果任何一个参与者回复了No,表明自己不能完成这个事务,或许是因为错误,或许有不一致性,或许丢失了记录,那么事务协调者不会发送commit消息,它会发送一轮Abort消息给所有的参与者说,请撤回这个事务。\n在事务Commit之后,会发生两件事情。首先,事务协调者会向客户端发送代表了事务输出的内容,表明事务结束了,事务没有被Abort并且被持久化保存起来了。另一个有意思的事情是,为了遵守前面的锁规则(两阶段锁),事务参与者会释放锁(这里不论Commit还是Abort都会释放锁)。\n实际上,为了遵循两阶段锁规则,每个事务参与者在参与事务时,会对任何涉及到的数据加锁。所以我们可以想象,在每个参与者中都会有个表单,表单会记录数据当前是为哪个事务加的锁。当收到Commit或者Abort消息时,事务参与者会对数据解锁,之后其他的事务才可以使用相应的数据。这里的解锁操作会解除对于其他事务的阻塞。这实际上是可序列化机制的一部分。\n目前来说,还没有问题,因为架构中的每一个成员都遵循了协议,没有错误,两个参与者只会一起Commit,如果其中一个需要Abort,那么它们两个都会Abort。所以,基于刚刚描述的协议,如果没有错误的话,我们得到了这种All-or-Noting的原子特性。\n故障恢复(Crash Recovery) 现在,我们需要在脑中设想各种可能发生的错误,并确认这里的两阶段提交协议是否仍然可以提供All-or-Noting的原子特性。如果不能的话,我们该如何调整或者扩展协议?\n事务参与者崩溃 第一个我想考虑的错误是故障重启。我的意思是类似于断电,服务器会突然中断执行,当电力恢复之后,作为事务处理系统的一部分,服务器会运行一些恢复软件。这里实际上有两个场景需要考虑。 第一个场景是,参与者B可能在回复事务协调者的Prepare消息之前的崩溃了,所以,B在回复Yes之前就崩溃了。从TC的角度来看,B没有回复Yes,TC也就不能Commit,因为它需要等待所有的参与者回复Yes。\n如果B发现自己不可能发送Yes,比如说在发送Yes之前自己就故障了,那么B被授权可以单方面的Abort事务。因为B知道自己没有发送Yes,那么它也知道事务协调者不可能Commit事务。这里有很多种方法可以实现,其中一种方法是,因为B故障重启了,内存中的数据都会清除,所以B中所有有关事务的信息都不能活过故障,所以,故障之后B不知道任何有关事务的信息,也不知道给谁回复过Yes。之后,如果事务协调者发送了一个Prepare消息过来,因为B不知道事务,B会回复No,并要求Abort事务。\n第二个场景是 当然,B也可能在回复了Yes给事务协调者的Prepare消息之后崩溃的。B可能开心的回复给事务协调者说好的,我将会commit。但是在B收到来自事务协调者的commit消息之前崩溃了。\n现在我们有了一个完全不同的场景。现在B承诺可以commit,因为它回复了Yes。接下来极有可能发生的事情是,事务协调者从所有的参与者获得了Yes的回复,并将Commit消息发送给了A,所以A实际上会执行事务分包给它的那一部分,持久化存储结果,并释放锁。这样的话,为了确保All-or-Nothing原子性,我们需要确保B在故障恢复之后,仍然能完成事务分包给它的那一部分。在B故障的时候,不知道事务是否能Commit,因为它还没有收到Commit消息。但是B还是需要做好Commit的准备。这意味着,在故障重启的时候,B不能丢失对于事务的状态记录。\n在B回复Prepare之前,它必须确保记住当前事务的中间状态,记住所有要做的修改,记住事务持有的所有的锁,这些信息必须在磁盘上持久化存储。通常来说,这些信息以Log的形式在磁盘上存储。所以在B回复Yes给Prepare消息之前,它首先要将相应的Log写入磁盘,并在Log中记录所有有关提交事务必须的信息。这包括了所有由Put创建的新的数值,和锁的完整列表。之后,B才会回复Yes。 之后,如果B在发送完Yes之后崩溃了,当它重启恢复时,通过查看自己的Log,它可以发现自己正在一个事务的中间,并且对一个事务的Prepare消息回复了Yes。Log里有Commit需要做的所有的修改,和事务持有的所有的锁。之后,当B最终收到了Commit而不是Abort,通过读取Log,B就知道如何完成它在事务中的那部分工作。\n所以,这里是我之前在介绍协议的时候遗漏的一点。B在这个时间点(回复Yes给TC的Prepare消息之前),必须将Log写入到自己的磁盘中。这里会使得两阶段提交稍微有点慢,因为这里要持久化存储数据。\n第三个场景最后一个可能崩溃的地方是,B可能在收到Commit之后崩溃了。但是这样的话,B就完成了修改,并将数据持久化存储在磁盘上了。这样的话,故障重启就不需要做任何事情,因为事务已经完成了。\n因为没有收到ACK,事务协调者会再次发送Commit消息。当B重启之后,收到了Commit消息时,它可能已经将Log中的修改写入到自己的持久化存储中、释放了锁、并删除了有关事务的Log。所以我们需要关心,如果B收到了同一个Commit消息两次,该怎么办?这里B可以记住事务的信息,但是这会消耗内存,所以实际上B会完全忘记已经在磁盘上持久化存储的事务的信息。对于一个它不知道事务的Commit消息,B会简单的ACK这条消息。这一点在后面的一些介绍中非常重要。\n上面是事务的参与者在各种奇怪的时间点崩溃的场景。那对于事务协调者呢?它只是一个计算机,如果它出现故障,也会是个问题。\n事务协调者崩溃 如果事务的任何一个参与者可能已经提交了,或者事务协调者可能已经回复给客户端了,那么我们不能忽略事务。比如,如果事务协调者已经向A发送了Commit消息,但是还没来得及向B发送Commit消息就崩溃了,那么事务协调者必须在重启的时候准备好向B重发Commit消息,以确保两个参与者都知道事务已经提交了。所以,事务协调者在哪个时间点崩溃了非常重要。\n如果事务协调者在发送Commit消息之前就崩溃了,那就无所谓了,因为没有一个参与者会Commit事务。也就是说,如果事务协调者在崩溃前没有发送Commit消息,它可以直接Abort事务。因为参与者可以在自己的Log中看到事务,但是又从来没有收到Commit消息,事务的参与者会向事务协调者查询事务,事务协调者会发现自己不认识这个事务,它必然是之前崩溃的时候Abort的事务。所以这就是事务协调者在Commit之前就崩溃了的场景。\n如果事务协调者在发送完一个或者多个Commit消息之后崩溃,那么就不允许它忘记相关的事务。这意味着,在崩溃的时间点,也就是事务协调者决定要Commit而不是Abort事务,并且在发送任何Commit消息之前,它必须先将事务的信息写入到自己的Log,并存放在例如磁盘的持久化存储中,这样计算故障重启了,信息还会存在。\n所以,事务协调者在收到所有对于Prepare消息的Yes/No投票后,会将结果和事务ID写入存在磁盘中的Log,之后才会开始发送Commit消息。之后,可能在发送完第一个Commit消息就崩溃了,也可能发送了所有的Commit消息才崩溃,不管在哪,当事务协调者故障重启时,恢复软件查看Log可以发现哪些事务执行了一半,哪些事务已经Commit了,哪些事务已经Abort了。作为恢复流程的一部分,对于执行了一半的事务,事务协调者会向所有的参与者重发Commit消息或者Abort消息,以防在崩溃前没有向参与者发送这些消息。这就是为什么参与者需要准备好接收重复的Commit消息的一个原因。\n这些就是主要的服务器崩溃场景。我们还需要担心如果消息在网络传输的时候丢失了怎么办?或许你发送了一个消息,但是消息永远也没有送达。或许你发送了一个消息,并且在等待回复,或许回复发出来了,但是之后被丢包了。这里的任何一个消息都有可能丢包,我们必须想清楚在这样的场景下该怎么办?\n举个例子,事务协调者发送了Prepare消息,但是并没有收到所有的Yes/No消息,事务协调者这时该怎么做呢? 其中一个选择是,事务协调者重新发送一轮Prepare消息,表明自己没有收到全部的Yes/No回复。事务协调者可以持续不断的重发Prepare消息。但是如果其中一个参与者要关机很长时间,我们将会在持有锁的状态下一直等待。假设A不响应了,但是B还在运行,因为我们还没有Commit或者Abort,B仍然为事务持有了锁,这会导致其他的事务等待。所以,如果可以避免的话,我们不想永远等待。\n在事务协调者没有收到Yes/No回复一段时间之后,它可以单方面的Abort事务。因为它知道它没有得到完整的Yes/No消息,当然它也不可能发送Commit消息,所以没有一个参与者会Commit事务,所以总是可以Abort事务。事务的协调者在等待完整的Yes/No消息时,如果因为消息丢包或者某个参与者崩溃了,而超时了,它可以直接决定Abort这个事务,并发送一轮Abort消息。\n之后,如果一个崩溃了的参与者重启了,向事务协调者发消息说,我并没有收到来自你的有关事务95的消息,事务协调者会发现自己并不知道到事务95的存在,因为它在之前就Abort了这个事务并删除了有关这个事务的记录。这时,事务协调者会告诉参与者说,你也应该Abort这个事务。 类似的,如果参与者等待Prepare消息超时了,那意味着它必然还没有回复Yes消息,进而意味着事务协调者必然还没有发送Commit消息。所以如果一个参与者在这个位置因为等待Prepare消息而超时,那么它也可以决定Abort事务。在之后的时间里,如果事务协调者上线了,再次发送Prepare消息,B会说我不知道有关事务的任何事情并回复No。这也没问题,因为这个事务在这个时间也不可能在任何地方Commit了。所以,如果网络某个地方出现了问题,或者事务协调器挂了一会,事务参与者仍然在等待Prepare消息,总是可以允许事务参与者Abort事务,并释放锁,这样其他事务才可以继续。这在一个负载高的系统中可能会非常重要。\n但是,假设B收到了Prepare消息,并回复了Yes。大概在下图的位置中, 这个时候参与者没有收到Commit消息,它接下来怎么也等不到Commit消息。或许网络出现问题了,或许事务协调器的网络连接中断了,或者事务协调器断电了,不管什么原因,B等了很长时间都没有收到Commit消息。这段时间里,B一直持有事务涉及到数据的锁,这意味着,其他事务可能也在等待这些锁的释放。所以,这里我们应该尽早的Abort事务,并释放锁。所以这里的问题是,如果B收到了Prepare消息,并回复了Yes,在等待了10秒钟或者10分钟之后还没有收到Commit消息,它能单方面的决定Abort事务吗? 很不幸的是,这里的答案不行。\n在回复Yes给Prepare消息之后,并在收到Commit消息之前这个时间区间内,参与者会等待Commit消息。如果等待Commit消息超时了,参与者不允许Abort事务,它必须无限的等待Commit消息,这里通常称为Block。\n这里的原因是,因为B对Prepare消息回复了Yes,这意味着事务协调者可能收到了来自于所有参与者的Yes,并且可能已经向部分参与者发送Commit消息。这意味着A可能已经看到了Commit消息,Commit事务,持久化存储事务的结果并释放锁。所以在上面的区间里,B不能单方面的决定Abort事务,它必须无限等待事务协调者的Commit消息。如果事务协调者故障了,最终会有人来修复它,它在恢复过程中会读取Log,并重发Commit消息。\n就像不能单方面的决定Abort事务一样,这里B也不能单方面的决定Commit事务。因为A可能对Prepare消息回复了No,但是B没有收到相应的Abort消息。所以,在上面的区间中,B既不能Commit,也不能Abort事务。\n这里的Block行为是两阶段提交里非常重要的一个特性,并且它不是一个好的属性。因为它意味着,在特定的故障中,你会很容易的陷入到一个需要等待很长时间的场景中,在等待过程中,你会一直持有锁,并阻塞其他的事务。所以,人们总是尝试在两阶段提交中,将这个区间尽可能快的完成,这样可能造成Block的时间窗口也会尽可能的小。所以人们尽量会确保协议中这部分尽可能轻量化,甚至对于一些变种的协议,对于一些特定的场景都不用等待。\n这就是基本的协议。为什么这里的两阶段提交协议能构建一个A和B要么全Commit,要么全Abort的系统?其中一个原因是,决策是在一个单一的实例,也就是事务协调者完成的。A或者B不能决定Commit还是不Commit事务,A和B之间不会交互来达成一致并完成事务的Commit,相反的只有事务协调者可以做决定。事务协调者是一个单一的实例,它会通知其他的部分这是我的决定,请执行它。但是,使用一个单一实例的事务协调者的缺点是,在某个时间点你需要Block并等待事务协调者告诉你决策是什么。\n一个进一步的问题是,我们知道事务协调者必然在它的Log中记住了事务的信息,那么它在什么时候可以删除Log中有关事务的信息?这里的答案是,如果事务协调者成功的得到了所有参与者的ACK,那么它就知道所有的参与者知道了事务已经Commit或者Abort,所有参与者必然也完成了它们在事务中相应的工作,并且永远也不会需要知道事务相关的信息。所以当事务协调者得到了所有的ACK,它可以擦除所有有关事务的记忆。\n类似的,当一个参与者收到了Commit或者Abort消息,完成了它们在事务中的相应工作,持久化存储事务结果并释放锁,那么在它发送完ACK之后,参与者也可以完全忘记相关的事务。 当然事务协调者或许不能收到ACK,这时它会假设丢包了并重发Commit消息。这时,如果一个参与者收到了一个Commit消息,但是它并不知道对应的事务,因为它在之前回复ACK之后就忘记了这个事务,那么参与者会再次回复一个ACK。因为如果参与者收到了一个自己不知道的事务的Commit消息,那么必然是因为它之前已经完成对这个事务的Commit或者Abort,然后选择忘记这个事务了。\n总结 这就是两阶段提交,它实现了原子提交。两阶段提交在大量的将数据分割在多个服务器上的分片数据库或者存储系统中都有使用。两阶段提交可以支持读写多条记录,一些更特殊的存储系统不允许你在多条记录上支持事务。对于这些不支持事务中包含多条数据的系统,你就不需要两阶段提交。但是如果你需要在事务中支持多条数据,并且你将数据分片在多台服务器之上,那么你必须支持两阶段提交。\n然而,两阶段提交有着极差的名声。其中一个原因是,因为有多轮消息的存在,它非常的慢。在上面的图中,各个组成部分之间着大量的交互。另一个原因是,这里有大量的写磁盘操作,比如说B在回复Yes给Prepare消息之后不仅要向磁盘写入数据,还需要等待磁盘写入结束,如果你使用一个机械硬盘,这会花费10毫秒来完成Log数据的写入,这决定了事务的参与者能够以多快的速度处理事务。10毫秒完成Log写磁盘,那么最快就是每秒处理100个事务,这是一个非常慢的结果。同时,事务协调者也需要写磁盘,在收到所有Prepare消息的Yes回复之后,它也需要将Log写入磁盘,并等待磁盘写入结束。之后它才能发送Commit消息,这里又有了10毫秒。在这两个10毫秒内,锁都被参与者持有者,其他使用相关数据的事务都会被阻塞。\n这里我持续的在介绍性能,但是它的确非常重要,因为在一个繁忙的事务处理系统中,存在大量的事务,许多事务都会等待相同的数据,我们希望不要在一个长时间内持有锁。但是两阶段提交迫使我们在各个阶段都做等待。\n进一步的问题是,如果任何地方出错了,消息丢了,某台机器崩溃了,如果你不够幸运进入到Block区间,参与者需要在持有锁的状态下等待一段长时间。\n因此,你只会在一个小的环境中看到两阶段提交,比如说在一个组织的一个机房里面。你不会在不同的银行之间转账看到它,你或许可以在银行内部的系统中看见两阶段提交,但是你永远也不会在物理分隔的不同组织之间看见两阶段提交,因为它可能会陷入到Block区间中。你不会想将你的数据库的命运寄托在其他的数据库不在错误的时间崩溃,从而使得你的数据库被迫在很长一段时间持有锁。 因为两阶段提交很慢,有很多很多的研究都是关于如何让它变得更快,比如以各种方式放松这里的规则进而使得它变得更快,又比如对于一些特定的场景做一些定制化从而避免一些消息,我们在这门课中会看到很多这种定制。\n两阶段提交的架构中,本质上是有一个Leader(事务协调者),将消息发送给Follower(事务参与者),Leader只能在收到了足够多Follower的回复之后才能继续执行。这与Raft非常像,但是,这里协议的属性与Raft又非常的不一样。这两个协议解决的是完全不同的问题。\n使用Raft可以通过将数据复制到多个参与者得到高可用。Raft的意义在于,即使部分参与的服务器故障了或者不可达,系统仍然能工作。Raft能做到这一点是因为所有的服务器都在做相同的事情,所以我们不需要所有的服务器都参与,我们只需要过半服务器参与。然而两阶段提交,参与者完全没有在做相同的事情,每个参与者都在做事务中的不同部分,比如A可能在对X加1,B可能在对Y减1。所以在两阶段提交中,所有的参与者都在做不同的事情。所有的参与者都必须完成自己那部分工作,这样事务才能结束,所以这里需要等待所有的参与者。\n所以,Raft通过复制可以不用每一个参与者都在线,而两阶段提交每个参与者都做了不同的工作,并且每个参与者的工作都必须完成,所以两阶段提交对于可用性没有任何帮助。Raft完全就是可用性,而两阶段提交完全不是高可用的,系统中的任何一个部分出错了,系统都有可能等待直到这个部分修复。比如事务协调者在错误的时间崩溃了,我们需要等待它上线并读取它的Log再重发Commit消息。如果一个参与者在错误的时间崩溃了,如果我们足够幸运,我们只需要Abort事务。所以实际上,两阶段提交的可用性非常低,因为任何一个部分崩溃都有可能阻止整个系统的运行。Raft并不需要确保所有的参与者执行操作,它只需要过半服务器执行操作,或许少数的服务器完全没有执行操作也没关系。这里的原因是Raft系统中,所有的参与者都在做相同的事情,我们不必等待所有的参与者。这就是为什么Raft有更高的可用性。所以这是两个完全不同的协议。\n然而,是有可能结合这两种协议的。两阶段提交对于故障来说是非常脆弱的,在故障时它可以有正确的结果,但是不具备可用性。所以,这里的问题是,是否可以构建一个合并的系统,同时具备Raft的高可用性,但同时又有两阶段提交的能力将事务分包给不同的参与者。这里的结构实际上是,通过Raft或者Paxos或者其他协议,来复制两阶段提交协议里的每一个组成部分。\n所以,在前面的例子中,我们会有三个不同的集群,事务协调器会是一个复制的服务,包含了三个服务器,我们在这3个服务器上运行Raft,其中一个服务器会被选为Leader,它们会有复制的状态,它们有Log来帮助它们复制,我们只需要等待过半服务器响应就可以执行事务协调器的指令。事务协调器还是会执行两阶段提交里面的各个步骤,并将这些步骤记录在自己的Raft集群的Log中。 每个事务参与者也同样是一个Raft集群。最终,消息会在这些集群之间传递。 不得不承认,这里很复杂,但是它展示了你可以结合两种思想来同时获得高可用和原子提交。在Lab4,我们会构建一个类似的系统,实际上就是个分片的数据库,每个分片以这种形式进行复制,同时还有一个配置管理器,来允许将分片的数据从一个Raft集群移到另一个Raft集群。除此之外,我们还会读一篇论文叫做Spanner,它描述了Google使用的一种数据库,Spanner也使用了这里的结构来实现事务写。\n","permalink":"https://reid00.github.io/en/posts/storage/%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1/","summary":"分布式事务初探 分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit","title":"分布式事务"},{"content":"CPU缓存 CPU缓存(CPU Cache)的目的是为了提高访问内存(RAM)的效率,这虽然已经涉及到硬件的领域,但它仍然与我们息息相关,了解了它的一些原理,能让我们写出更高效的程序,另外在多线程程序中,一些不可思议的问题也与缓存有关。\n现代多核处理器,一个CPU由多个核组成,每个核又可以有多个硬件线程,比如我们说4核8线程,就是指有4个核,每个核2个线程,这在OS看来就像8个并行处理器一样。\nCPU缓存有多级缓存,比如L1, L2, L3等:\nL1容量最小,速度最快,每个核都有L1缓存,L1又专门针对指令和数据分成L1d(数据缓存),L1i(指令缓存)。 L2容量比L1大,速度比L1慢,每个核都有L2缓存。 L3容量最大,速度最慢,多个核共享一个L3缓存。 有些CPU可能还有L4缓存,不过不常见;此外还有其他类型的缓存,比如TLB(translation lookaside buffer),用于物理地址和虚拟地址转译,这不是我们关心的缓存。\n下图展示了缓存和CPU的关系: Linux用下面命令可以查看CPU缓存的信息:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [root@server-6 import]# getconf -a | grep CACHE LEVEL1_ICACHE_SIZE 32768 LEVEL1_ICACHE_ASSOC 8 LEVEL1_ICACHE_LINESIZE 64 LEVEL1_DCACHE_SIZE 32768 LEVEL1_DCACHE_ASSOC 8 LEVEL1_DCACHE_LINESIZE 64 LEVEL2_CACHE_SIZE 1048576 LEVEL2_CACHE_ASSOC 16 LEVEL2_CACHE_LINESIZE 64 LEVEL3_CACHE_SIZE 37486592 LEVEL3_CACHE_ASSOC 11 LEVEL3_CACHE_LINESIZE 64 LEVEL4_CACHE_SIZE 0 LEVEL4_CACHE_ASSOC 0 LEVEL4_CACHE_LINESIZE 0 上面显示CPU只有3级缓存,L4都为0。 L1的数据缓存和指令缓存分别是32KB;L2为256KB;L3为30MB。 在缓存和主存之间,数据是按固定大小的块传输的 该块称为缓存行(cache line),这里显示每行的大小为64Bytes。 ASSOC表示主存地址映射到缓存的策略,这里L1是8路组相联,L2是16路组联,L3是11路组相联,稍后解释是什么意思。 缓存结构 一块CPU缓存可以看成是一个数组,数组元素是缓存项(cache entry),一个缓存项的内容大概是这样的:\n1 2 3 +-------------------------------------------+ | tag | data block(cache line) | flag | +-------------------------------------------+ data block就是从内存中拷贝过来的数据,也就是我们说的cache line,从上面信息可知大小是64字节。 tag 保存了内存地址的一部分,是用来验证是否缓存命中的。 flag 是一些标志位,比如缓存是否失效,写dirty等等。 实际上LEVEL1_ICACHE_SIZE这个数据,是用data block来算的,并不包括tag和flag占用的大小,比如64 x 512 = 32768,表示LEVEL1_ICACHE_SIZE可以缓存512个cache line。 缓存首先要解决的问题是:怎么映射内存地址和缓存地址?比如CPU要检查一个内存值是否已经缓存,那么它首先要能算出这个内存地址对应的缓存地址,然后才能检查。\n为了解决这个问题,缓存将一个内存地址分成下面几个部分:\n1 2 3 +-------------------------------------------+ | tag | index | offset | +-------------------------------------------+ tag和缓存项中的tag对应,用来验证是否缓存命中的。 index 缓存项数组中的索引。 offset 缓存块(cache line)中的偏移,因为缓存块是64字节,而内存值可能只有4个字节,一个缓存块可以保存多个连续的内存值。这个offset实际上就是指明内存值在cache line中的位置。 直接映射缓存 现在我们举一个具体的例子,说明内存和缓存是如何映射的:\n假如缓存的大小是32768B(32KB),缓存块大小是64Bytes,那么缓存项数组就有 32768/64=512 个。 CPU要访问一个内存地址0x1CAABBDD,它首先检查这个内存地址是否在缓存中,检查过程是这样的: 内存地址的二进制形式是(低位在前面): 1 2 | tag | index | offset | 0 0 0 1 1 1 0 0 1 0 1 0 1 0 1 0 1 0 1 1 1 0 1 1 1 1 0 1 1 1 0 1 先计算内存在cache line中的偏移,因为缓存块是64字节,那么offset需要占6位(2^6=64),即offset=011101=29。 -= 接着要计算缓存项的索引,因为缓存项数组是512个,所以index需要占9位(2^9=512),即index=011101111=239。 现在我们通过offset和index已经找到缓存块的具体位置了,但是因为内存要远比缓存大很多,所以多个内存块是可以映射到同一个位置的,怎么判断这个缓存块位置存的就是这个内存的值呢?答案就是tag:内存地址去掉index和offset的部分,剩下的就是tag=00011100101010101=0x3955。 通过index找到缓存项,比较缓存项中的tag是否与内存地址中的tag相同,如果相同表示命中,就直接取缓存块中的值;如果不同表示未命中,CPU需要将内存值拷贝到缓存(替换掉老的) 这种映射方式就称为直接映射(Direct mapped),它的缺点就是多个内存地址会映射到同一个缓存地址,拿上面的内存地址来看,只要offset和index相同的内存地址,就一定会映射到同一个地方,比如:\n1 2 3 00011100101010100 011101111 011101 00011100101010110 011101111 011101 00011100101010111 011101111 011101 如果同时访问上面3个地址,就会一直替换缓存的值,也就是一直出现缓存冲突,这可能比没有缓存还要慢,因为除了访问内存外,还多一个拷贝内存值到缓存的操作。\nN路组联 为了解决上面的问题,我试着把缓存项数组分成2个数组(2路),比如分成2个256的数组,如下图所示: 查找过程和上面其实一样的:\n先通过index找到数组索引,只不过因为是2路,所以存在2个数组。 然后通过内存tag依次比较2个缓存顶的tag,如果其中一个tag相等,说明这个数组缓存命中;如果两个都不相等,说明缓存不命中,CPU会拷贝内存值到缓存中,但是现在有2个位置,要拷贝进哪个呢?我的理解CPU应该是随机选1路拷贝。 offset这个其实无关紧要,因为它是cache line中的偏移。 那这个和直接映射相比,好在哪里呢,因为一个内存值会随机拷贝到2路中的1个,所以缓存冲突(多个内存地址映射到同一个缓存地址)的概率会降低一半;如果把缓存项数组分成4个数组,这就是4路组相联。\n上面LEVEL1_ICACHE_ASSOC的值等于8,表明是8路组相联。分组越多,缓存冲突率越低,但是CPU要遍历的数组就越多,这是一个权衡的问题。\n通过观察也可以发现,其实直接映射就是1路组相联。如果直接分成512个数组,那每个数组只有1项,这种就是全相联,CPU直接遍历512个数组,判断内存地址在哪1个。\n缓存分配策略和更新策略 当CPU从内存读数据时,如果该数据没有在缓存中(read miss),CPU会把数据拷贝到缓存。\n当CPU往内存写数据时: 有多种写策略:\n如果在写的时候数在缓存中\nWrite through 更新缓存的数据,同时更新内存的数据。 Write back 只更新缓存的数据,同时在缓存项设置一个drity标志位,内存的数据只会在某个时刻更新(比如替换cache line时)。 如果在写的时候数据没有在缓存中(write miss),也有两种策略:\nWrite allocate 在写之前先把数据加载到缓存,然后再实施上面的写策略。 No-write allocate 不加载缓存,直接把数据写到内存。数据只有在 read miss 时才会加载到缓存。 虽然上面两组策略可以任意搭配,但通常情况下是 No-write allocate 和 Write through 一起使用,而 Write allocate 则和 Write back 一起使用,下面是 wikipedia 的两张流程图。\nNo-write allocate方式的 Write through Cache: Write allocate 方式的 Write back Cache 从上面描述我们知道,当我们向一个内存写数据时,内存中的数据可能不马上被更新,这个新数据可能还在cache line呆着。因为每个核都有自己的缓存,如果CPU不做处理,可以想象一定会出问题的:比如核1改了数据,核2去读同一个数据,此时数据还在核1的缓存中,核2读到的就是老的数据。CPU为了处理多核间的缓存同步,有一套复杂的一致性协议。关于这个后面再来学习。\n其他:Cache Aside(旁路缓存)策略 我们来考虑一种最简单的业务场景,比方说在你的电商系统中有一个用户表,表中只有 ID 和年龄两个字段,缓存中我们以 ID 为 Key 存储用户的年龄信息。那么当我们要把 ID 为 1 的用户的年龄从 19 变更为 20,要如何做呢?\n你可能会产生这样的思路:先更新数据库中 ID 为 1 的记录,再更新缓存中 Key 为 1 的数 据。 这个思路会造成缓存和数据库中的数据不一致。比如,A 请求将数据库中 ID 为 1 的用户年 龄从 19 变更为 20,与此同时,请求 B 也开始更新 ID 为 1 的用户数据,它把数据库中记 录的年龄变更为 21,然后变更缓存中的用户年龄为 21。紧接着,A 请求开始更新缓存数 据,它会把缓存中的年龄变更为 20。此时,数据库中用户年龄是 21,而缓存中的用户年龄 却是 20。 为什么产生这个问题呢?因为变更数据库和变更缓存是两个独立的操作,而我们并 没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不 同造成数据的不一致。\n另外,直接更新缓存还存在另外一个问题就是丢失更新。还是以我们的电商系统为例,假如 电商系统中的账户表有三个字段:ID、户名和金额,这个时候缓存中存储的就不只是金额 信息,而是完整的账户信息了。当更新缓存中账户金额时,你需要从缓存中查询完整的账户 数据,把金额变更后再写入到缓存中。\n这个过程中也会有并发的问题,比如说原有金额是 20,A 请求从缓存中读到数据,并且把 金额加 1,变更成 21,在未写入缓存之前又有请求 B 也读到缓存的数据后把金额也加 1, 也变更成 21,两个请求同时把金额写回缓存,这时缓存里面的金额是 21,但是我们实际上 预期是金额数加 2,这也是一个比较大的问题。\n那我们要如何解决这个问题呢?其实,我们可以在更新数据时不更新缓存,而是删除缓存中 的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存 中。\n这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这 个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策 略,其中读策略的步骤是: 从缓存中读取数据 如果缓存命中,则直接返回数据; 如果缓存不命中,则从数据库中查询数据; 查询到数据后,将数据写入到缓存中,并且返回给用户。 写策略的步骤是: 更新数据库中的记录; 删除缓存记录。 你也许会问了,在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样 也有可能出现缓存数据不一致的问题,我以用户表的场景为例解释一下。\n假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。 这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取 到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21,这就造成了缓存和数据库的不一致。\n那么像 Cache Aside 策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上 还是有缺陷的。假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到 年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并 且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中,造成缓存 和数据库数据不一致。 不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入,所以在实 际中很难出现请求 B 已经更新了数据库并且清空了缓存,请求 A 才更新完缓存的情况。而一 旦请求 A 早于请求 B 清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而 从数据库中重新加载数据,所以不会出现这种不一致的情况。\nCache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会 依情况而变。比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存 (当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从 分离时,会出现因为主从延迟所以读不到用户信息的情况。\n而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会 从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况。 Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这 样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑 两种解决方案:\n一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样 在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能 会有一些影响;\n另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样 即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。 当然了,除了这个策略,在计算机领域还有其他几种经典的缓存策略,它们也有各自适用的 使用场景。\n","permalink":"https://reid00.github.io/en/posts/storage/cpu%E7%BC%93%E5%AD%98%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/","summary":"CPU缓存 CPU缓存(CPU Cache)的目的是为了提高访问内存(RAM)的效率,这虽然已经涉及到硬件的领域,但它仍然与我们息息相关,了解了","title":"CPU缓存基础知识"},{"content":"Zookpeer 的先行一致性介绍 Zookeeper的确有一些一致性的保证,用来帮助那些使用基于Zookeeper开发应用程序的人,来理解他们的应用程序,以及理解当他们运行程序时,会发生什么。与线性一致一样,这些保证与序列有关。Zookeeper有两个主要的保证,它们在论文的2.3有提及。\n写请求是线性一致的。 现在,你可以发现,它(Zookeeper)对于线性一致的定义与我的不太一样,因为Zookeeper只考虑写,不考虑读。这里的意思是,尽管客户端可以并发的发送写请求,然后Zookeeper表现的就像以某种顺序,一次只执行一个写请求,并且也符合写请求的实际时间。所以如果一个写请求在另一个写请求开始前就结束了,那么Zookeeper实际上也会先执行第一个写请求,再执行第二个写请求。所以,这里不包括读请求,单独看写请求是线性一致的。 Zookeeper并不是一个严格的读写系统。写请求通常也会跟着读请求。对于这种混合的读写请求,任何更改状态的操作相比其他更改状态的操作,都是线性一致的。\nFIFO(First In First Out)客户端序列。 Zookeeper的另一个保证是,任何一个客户端的请求,都会按照客户端指定的顺序来执行,论文里称之为FIFO(First In First Out)客户端序列。\n这里的意思是,如果一个特定的客户端发送了一个写请求之后是一个读请求或者任意请求,那么首先,所有的写请求会以这个客户端发送的相对顺序,加入到所有客户端的写请求中(满足保证1)。所以,如果一个客户端说,先完成这个写操作,再完成另一个写操作,之后是第三个写操作,那么在最终整体的写请求的序列中,可以看到这个客户端的写请求以相同顺序出现(虽然可能不是相邻的)。所以,对于写请求,最终会以客户端确定的顺序执行。\n这里实际上是服务端需要考虑的问题,因为客户端是可以发送异步的写请求,也就是说客户端可以发送多个写请求给Zookeeper Leader节点,而不用等任何一个请求完成。Zookeeper论文并没有明确说明,但是可以假设,为了让Leader可以实际的按照客户端确定的顺序执行写请求,我设想,客户端实际上会对它的写请求打上序号,表明它先执行这个,再执行这个,第三个是这个,而Zookeeper Leader节点会遵从这个顺序。这里由于有这些异步的写请求变得非常有意思。\n读请求 对于读请求,这里会更加复杂一些。我之前说过,读请求不需要经过Leader,只有写请求经过Leader,读请求只会到达某个副本。所以,读请求只能看到那个副本的Log对应的状态。对于读请求,我们应该这么考虑FIFO客户端序列,客户端会以某种顺序读某个数据,之后读第二个数据,之后是第三个数据,对于那个副本上的Log来说,每一个读请求必然要在Log的某个特定的点执行,或者说每个读请求都可以在Log一个特定的点观察到对应的状态。\n然后,后续的读请求,必须要在不早于当前读请求对应的Log点执行。也就是一个客户端发起了两个读请求,如果第一个读请求在Log中的一个位置执行,那么第二个读请求只允许在第一个读请求对应的位置或者更后的位置执行。 第二个读请求不允许看到之前的状态,第二个读请求至少要看到第一个读请求的状态。这是一个极其重要的事实,我们会用它来实现正确的Zookeeper应用程序。\n这里特别有意思的是,如果一个客户端正在与一个副本交互,客户端发送了一些读请求给这个副本,之后这个副本故障了,客户端需要将读请求发送给另一个副本。这时,尽管客户端切换到了一个新的副本,FIFO客户端序列仍然有效。所以这意味着,如果你知道在故障前,客户端在一个副本执行了一个读请求并看到了对应于Log中这个点的状态,\n客户端请求副本发生变化 当客户端切换到了一个新的副本并且发起了另一个读请求,假设之前的读请求在这里执行, 那么尽管客户端切换到了一个新的副本,客户端的在新的副本的读请求,必须在Log这个点或者之后的点执行。\n这里工作的原理是,每个Log条目都会被Leader打上zxid的标签,这些标签就是Log对应的条目号。任何时候一个副本回复一个客户端的读请求,首先这个读请求是在Log的某个特定点执行的,其次回复里面会带上zxid,对应的就是Log中执行点的前一条Log条目号。客户端会记住最高的zxid,当客户端发出一个请求到一个相同或者不同的副本时,它会在它的请求中带上这个最高的zxid。这样,其他的副本就知道,应该至少在Log中这个点或者之后执行这个读请求。这里有个有趣的场景,如果第二个副本并没有最新的Log,当它从客户端收到一个请求,客户端说,上一次我的读请求在其他副本Log的这个位置执行,\n那么在获取到对应这个位置的Log之前,这个副本不能响应客户端请求。\n我不是很清楚这里具体怎么工作,但是要么副本阻塞了对于客户端的响应,要么副本拒绝了客户端的读请求并说:我并不了解这些信息,去问问其他的副本,或者过会再来问我。 最终,如果这个副本连上了Leader,它会更新上最新的Log,到那个时候,这个副本就可以响应读请求了。好的,所以读请求都是有序的,它们的顺序与时间正相关。\n更进一步,FIFO客户端请求序列是对一个客户端的所有读请求,写请求生效。所以,如果我发送一个写请求给Leader,在Leader commit这个请求之前需要消耗一些时间,所以我现在给Leader发了一个写请求,而Leader还没有处理完它,或者commit它。之后,我发送了一个读请求给某个副本。这个读请求需要暂缓一下,以确保FIFO客户端请求序列。读请求需要暂缓,直到这个副本发现之前的写请求已经执行了。这是FIFO客户端请求序列的必然结果,(对于某个特定的客户端)读写请求是线性一致的。\n最明显的理解这种行为的方式是,如果一个客户端写了一份数据,例如向Leader发送了一个写请求,之后立即读同一份数据,并将读请求发送给了某一个副本,那么客户端需要看到自己刚刚写入的值。如果我写了某个变量为17,那么我之后读这个变量,返回的不是17,这会很奇怪,这表明系统并没有执行我的请求。因为如果执行了的话,写请求应该在读请求之前执行。所以,副本必然有一些有意思的行为来暂缓客户端,比如当客户端发送一个读请求说,我上一次发送给Leader的写请求对应了zxid是多少,这个副本必须等到自己看到对应zxid的写请求再执行读请求。\n学生提问 学生提问:也就是说,从Zookeeper读到的数据不能保证是最新的? Robert教授:完全正确。我认为你说的是,从一个副本读取的或许不是最新的数据,所以Leader或许已经向过半服务器发送了C,并commit了,过半服务器也执行了这个请求。但是这个副本并不在Leader的过半服务器中,所以或许这个副本没有最新的数据。这就是Zookeeper的工作方式,它并不保证我们可以看到最新的数据。Zookeeper可以保证读写有序,但是只针对一个客户端来说。所以,如果我发送了一个写请求,之后我读取相同的数据,Zookeeper系统可以保证读请求可以读到我之前写入的数据。但是,如果你发送了一个写请求,之后我读取相同的数据,并没有保证说我可以看到你写入的数据。这就是Zookeeper可以根据副本的数量加速读请求的基础。\n学生提问:那么Zookeeper究竟是不是线性一致呢? Robert教授:我认为Zookeeper不是线性一致的,但是又不是完全的非线性一致。首先,所有客户端发送的请求以一个特定的序列执行,所以,某种意义上来说,所有的写请求是线性一致的。同时,每一个客户端的所有请求或许也可以认为是线性一致的。尽管我不是很确定,Zookeeper的一致性保证的第二条可以理解为,单个客户端的请求是线性一致的。\n学生提问:zxid必须要等到写请求执行完成才返回吗? Robert教授:实际上,我不知道它具体怎么工作,但是这是个合理的假设。当我发送了异步的写请求,系统并没有执行这些请求,但是系统会回复我说,好的,我收到了你的写请求,如果它最后commit了,这将会是对应的zxid。所以这里是一个合理的假设,我实际上不知道这里怎么工作。之后如果客户端执行读请求,就可以告诉一个副本说,这个zxid是我之前发送的一个写请求。\n学生提问:Log中的zxid怎么反应到key-value数据库的状态呢? Robert教授:如果你向一个副本发送读请求,理论上,客户端会认为副本返回的实际上是Table中的值。所以,客户端说,我只想从这个Table读这一行,这个副本会将其当前状态中Table中对应的值和上次更新Table的zxid返回给客户端。 我不太确定,这里有两种可能,我认为任何一种都可以。第一个是,每个服务器可以跟踪修改每一行Table数值的写请求对应的zxid(这样可以读哪一行就返回相应的zxid);另一个是,服务器可以为所有的读请求返回Log中最近一次commit的zxid,不论最近一次请求是不是更新了当前读取的Table中的行。因为,我们只需要确认客户端请求在Log中的执行点是一直向前推进,所以对于读请求,我们只需要返回大于修改了Table中对应行的写请求对应的zxid即可。\n好的,这些是Zookeeper的一致性保证。\nZookeeper API Zookeeper的API设计使得它可以成为一个通用的服务,从而分担一个分布式系统所需要的大量工作。那么为什么Zookeeper的API是一个好的设计?具体来看,因为它实现了一个值得去了解的概念:mini-transaction.\n我们回忆一下Zookeeper的特点:\nZookeeper基于(类似于)Raft框架,所以我们可以认为它是,当然它的确是容错的,它在发生网络分区的时候,也能有正确的行为。 当我们在分析各种Zookeeper的应用时,我们也需要记住Zookeeper有一些性能增强,使得读请求可以在任何副本被处理,因此,可能会返回旧数据。 另一方面,Zookeeper可以确保一次只处理一个写请求,并且所有的副本都能看到一致的写请求顺序。这样,所有副本的状态才能保证是一致的(写请求会改变状态,一致的写请求顺序可以保证状态一致)。 由一个客户端发出的所有读写请求会按照客户端发出的顺序执行。 一个特定客户端的连续请求,后来的请求总是能看到相比较于前一个请求相同或者更晚的状态(详见8.5 FIFO客户端序列)。 detail\n","permalink":"https://reid00.github.io/en/posts/storage/zookeeper%E4%B8%80%E8%87%B4%E4%BF%9D%E8%AF%81/","summary":"Zookpeer 的先行一致性介绍 Zookeeper的确有一些一致性的保证,用来帮助那些使用基于Zookeeper开发应用程序的人,来理解他们的应用程序,以","title":"Zookeeper一致保证"},{"content":"介绍 我曾经有过的所有这些对生命周期的误解,现在有很多初学者也深陷于此。 我用到的术语可能不是标准的,所以下面列了一个表格来解释它们的用意。\n短语 意为 T 包含了所有可能类型的集合 或 这个集合中的类型 所有权类型 不含引用的类型, 例如 i32, String, Vec, 等 借用类型 或 引用类型 不考虑可变性的引用类型, 例如 \u0026amp;i32, \u0026amp;mut i32 等 可变引用 或 独占引用 独占的可变引用, 即 \u0026amp;mut T 不可变引用 或 共享引用 共享的不可变引用, 即 \u0026amp;T 误解项 简而言之:变量的生命周期指的是这个变量所指的数据可以被编译器静态验证的、在当前内存地址有效期的长度。\n误解1: T 只包含所有权类型 这个误解比起说生命周期,它和泛型更相关,但在Rust中泛型和生命周期是紧密联系在一起的,不可只谈其一。\n当我刚开始学习Rust的时候,我理解i32,\u0026amp;i32,和\u0026amp;mut i32是不同的类型,也明白泛型变量T代表着所有可能类型的集合。 但尽管这二者分开都懂,当它们结合在一起的时候我却陷入困惑。在我这个Rust初学者的眼中,泛型是这样的运作的:\n类型变量 T \u0026amp;T \u0026amp;mut T 例子 i32 \u0026amp;i32 \u0026amp;mut i32 T 包含一切所有权类型; \u0026amp;T 包含一切不可变借用类型; \u0026amp;mut T 包含一切可变借用类型。 T, \u0026amp;T, 和 \u0026amp;mut T 是不相交的有限集。 简洁明了,符合直觉,但却完全错误。 下面这才是泛型真正的运作方式: 类型变量 T \u0026amp;T \u0026amp;mut T 例子 i32, \u0026amp;i32, \u0026amp;mut i32, \u0026amp;\u0026amp;i32, \u0026amp;mut \u0026amp;mut i32, \u0026hellip; \u0026amp;i32, \u0026amp;\u0026amp;i32, \u0026amp;\u0026amp;mut i32, \u0026hellip; \u0026amp;mut i32, \u0026amp;mut \u0026amp;mut i32, \u0026amp;mut \u0026amp;i32, \u0026hellip; T, \u0026amp;T, 和 \u0026amp;mut T 都是无限集, 因为你可以无限借用一个类型。 T 是 \u0026amp;T 和 \u0026amp;mut T的超集. \u0026amp;T 和 \u0026amp;mut T 是不相交的集合。 让我们用几个例子来检验一下这些概念:\n1 2 3 4 5 6 7 trait Trait {} impl\u0026lt;T\u0026gt; Trait for T {} impl\u0026lt;T\u0026gt; Trait for \u0026amp;T {} // 编译错误 impl\u0026lt;T\u0026gt; Trait for \u0026amp;mut T {} // 编译错误 上面的代码并不能如愿编译:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 error[E0119]: conflicting implementations of trait `Trait` for type `\u0026amp;_`: --\u0026gt; src/lib.rs:5:1 | 3 | impl\u0026lt;T\u0026gt; Trait for T {} | ------------------- first implementation here 4 | 5 | impl\u0026lt;T\u0026gt; Trait for \u0026amp;T {} | ^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `\u0026amp;_` error[E0119]: conflicting implementations of trait `Trait` for type `\u0026amp;mut _`: --\u0026gt; src/lib.rs:7:1 | 3 | impl\u0026lt;T\u0026gt; Trait for T {} | ------------------- first implementation here ... 7 | impl\u0026lt;T\u0026gt; Trait for \u0026amp;mut T {} | ^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `\u0026amp;mut _ 编译器不允许我们为\u0026amp;T和\u0026amp;mut T实现Trait,因为这样会与为T实现的Trait冲突, T本身已经包含了所有\u0026amp;T和\u0026amp;mut T。下面的代码能够如愿编译,因为\u0026amp;T和\u0026amp;mut T是不相交的:\n1 2 3 4 5 trait Trait {} impl\u0026lt;T\u0026gt; Trait for \u0026amp;T {} // 编译通过 impl\u0026lt;T\u0026gt; Trait for \u0026amp;mut T {} // 编译通过 要点:\nT 是 \u0026amp;T 和 \u0026amp;mut T的超集 \u0026amp;T 和 \u0026amp;mut T 是不相交的集合 误解2: 如果 T: \u0026lsquo;static 那么 T 必须在整个程序运行中都是有效的 误解推论\nT: \u0026lsquo;static 应该被看作 \u0026quot; T 拥有 \u0026lsquo;static 生命周期 \u0026quot; \u0026amp;\u0026lsquo;static T 和 T: \u0026lsquo;static 没有区别 如果 T: \u0026lsquo;static 那么 T 必须为不可变的 如果 T: \u0026lsquo;static 那么 T 只能在编译期创建 大部分Rust初学者是从类似下面这个代码示例中接触到 \u0026lsquo;static 生命周期的:\n1 2 3 fn main() { let str_literal: \u0026amp;\u0026#39;static str = \u0026#34;str literal\u0026#34;; } 他们被告知 \u0026ldquo;str literal\u0026rdquo; 是硬编码在编译出来的二进制文件中的, 并会在运行时被加载到只读内存,所以必须是不可变的且在整个程序的运行中都是有效的, 这就是它成为 \u0026lsquo;static 的原因。 而这些观念又进一步被用 static 关键字来定义静态变量的规则所加强。\n1 2 3 4 5 6 7 8 9 10 11 static BYTES: [u8; 3] = [1, 2, 3]; static mut MUT_BYTES: [u8; 3] = [1, 2, 3]; fn main() { MUT_BYTES[0] = 99; // 编译错误,修改静态变量是unsafe的 unsafe { MUT_BYTES[0] = 99; assert_eq!(99, MUT_BYTES[0]); } } 认为静态变量\n只可以在编译期创建 必须是不可变的,修改它们是unsafe的 在整个程序的运行过程中都是有效的 \u0026lsquo;static 生命周期大概是以静态变量的默认生命周期命名的,对吧? 那么有理由认为\u0026rsquo;static生命周期也应该遵守相同的规则,不是吗?\n是的,但拥有'static生命周期的类型与'static约束的类型是不同的。 后者能在运行时动态分配,可以安全地、自由地修改,可以被drop, 还可以有任意长度的生命周期。\n在这个点,很重要的是要区分 \u0026amp;'static T 和 T: 'static。\n\u0026amp;'static T 是对某个T的不可变引用,这个引用可以被无限期地持有直到程序结束。 这只可能发生在T本身不可变且不会在引用被创建后移动的情况下。 T并不需要在编译期就被创建,因为我们可以在运行时动态生成随机数据, 然后以内存泄漏为代价返回\u0026rsquo;static引用,例如:\n1 2 3 4 5 6 7 use rand; // 在运行时生成随机\u0026amp;\u0026#39;static str fn rand_str_generator() -\u0026gt; \u0026amp;\u0026#39;static str { let rand_string = rand::random::\u0026lt;u64\u0026gt;().to_string(); Box::leak(rand_string.into_boxed_str()) } T: 'static 是指T可以被无限期安全地持有直到程序结束。 T: \u0026lsquo;static包括所有\u0026amp;\u0026lsquo;static T,此外还包括所有的所有权类型,比如String, Vec等。 数据的所有者能够保证数据只要还被持有就不会失效,因此所有者可以无限期安全地持有该数据直到程序结束。 T: 'static应该被看作T受'static生命周期 \u0026quot;约束\u0026quot; 而非 T有着'static生命周期。 这段代码能帮我们阐释这些概念:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 use rand; fn drop_static\u0026lt;T: \u0026#39;static\u0026gt;(t: T) { std::mem::drop(t); } fn main() { let mut strings: Vec\u0026lt;String\u0026gt; = Vec::new(); for _ in 0..10 { if rand::random() { // 所有字符串都是随机生成的 // 并且是在运行时动态申请的 let string = rand::random::\u0026lt;u64\u0026gt;().to_string(); strings.push(string); } } // 这些字符串都是所有权类型,所以它们满足\u0026#39;static约束 for mut string in strings { // 这些字符串都是可以修改的 string.push_str(\u0026#34;a mutation\u0026#34;); // 这些字符串都是可以被drop的 drop_static(string); // 编译通过 } // 这些字符串都在程序结束之前失效 println!(\u0026#34;i am the end of the program\u0026#34;); } 要点:\nT: \u0026lsquo;static 应该被看作 “T受\u0026rsquo;static生命周期约束” 如果 T: \u0026lsquo;static 那么T可以是有着\u0026rsquo;static生命周期的借用类型 由于 T: \u0026lsquo;static 包括了所有权类型,这意味着T 可以在运行时动态分配 不一定要在整个程序的运行过程中都有效 可以被安全地、自由地修改 可以在运行时被动态drop掉 可以有不同长度的生命周期 误解3: \u0026amp;\u0026lsquo;a T 和 T: \u0026lsquo;a 是相同的 这个误解是上一个的泛化版本。\n\u0026amp;\u0026lsquo;a T 不光要求,同时也隐含着 T: \u0026lsquo;a, 因为如果T本身都不能在'a内有效, 那对T的有'a生命周期的引用也不可能是有效的。 例如,Rust编译器从来不会允许创建\u0026amp;\u0026lsquo;static Ref\u0026lt;\u0026lsquo;a, T\u0026gt;这个类型,因为如果Ref只在\u0026rsquo;a内有效,我们不可能弄出一个对它的\u0026rsquo;static的引用。\nT: 'a包括了所有\u0026amp;'a T,但反过来不对。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 只接受以\u0026#39;a约束的引用类型 fn t_ref\u0026lt;\u0026#39;a, T: \u0026#39;a\u0026gt;(t: \u0026amp;\u0026#39;a T) {} // 接受所有以\u0026#39;a约束的类型 fn t_bound\u0026lt;\u0026#39;a, T: \u0026#39;a\u0026gt;(t: T) {} // 包含引用的所有权类型 struct Ref\u0026lt;\u0026#39;a, T: \u0026#39;a\u0026gt;(\u0026amp;\u0026#39;a T); fn main() { let string = String::from(\u0026#34;string\u0026#34;); t_bound(\u0026amp;string); // 编译通过 t_bound(Ref(\u0026amp;string)); // 编译通过 t_bound(\u0026amp;Ref(\u0026amp;string)); // 编译通过 t_ref(\u0026amp;string); // 编译通过 t_ref(Ref(\u0026amp;string)); // 编译错误, 期待接收一个引用,但收到一个结构体 t_ref(\u0026amp;Ref(\u0026amp;string)); // 编译通过 // string变量是以\u0026#39;static约束的,也满足\u0026#39;a约束 t_bound(string); // 编译通过 } 要点:\nT: \u0026lsquo;a 比起 \u0026amp;\u0026lsquo;a T更泛化也更灵活 T: \u0026lsquo;a 接受所有权类型、包含引用的所有权类型以及引用 \u0026amp;\u0026lsquo;a T 只接受引用 如果 T: \u0026lsquo;static 那么 T: \u0026lsquo;a, 因为对于所有\u0026rsquo;a都有\u0026rsquo;static \u0026gt;= \u0026lsquo;a 误解4: 我的代码没用到泛型,也不含生命周期 误解推论:\n避免使用泛型和生命周期是可能的 这种安慰性的误解的存在是由于Rust的生命周期省略规则, 这些规则让你能够在函数中省略掉生命周期记号, 因为Rust的借用检查器能根据以下规则将它们推导出来:\n每个传入的引用都会有一个单独的生命周期 如果只有一个传入的生命周期,那么它将被应用到所有输出的引用上 如果有多个传入的生命周期,但其中一个是\u0026amp;self或者\u0026amp;mut self,那么这个生命周期将会被应用到所有输出的引用上 除此之外的输出的生命周期都必须显示标注出来 如果一时间难以想明白这么多东西,那让我们来看一些例子:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // 省略 fn print(s: \u0026amp;str); // 展开 fn print\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str); // 省略 fn trim(s: \u0026amp;str) -\u0026gt; \u0026amp;str; // 展开 fn trim\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; // 不合法,无法确定输出的生命周期,因为没有输入的 fn get_str() -\u0026gt; \u0026amp;str; // 显式的写法包括 fn get_str\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str; // 泛型版本 fn get_str() -\u0026gt; \u0026amp;\u0026#39;static str; // \u0026#39;static 版本 // 不合法,无法确定输出的生命周期,因为有多个输入 fn overlap(s: \u0026amp;str, t: \u0026amp;str) -\u0026gt; \u0026amp;str; // 显式(但仍有部分省略)的写法包括 fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输出生命周期不能长于s fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;str, t: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输出生命周期不能长于t fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输出生命周期不能长于s和t fn overlap(s: \u0026amp;str, t: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;static str; // 输出生命周期可以长于s和t fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;str, t: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输入和输出的生命周期无关 // 展开 fn overlap\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;a str; fn overlap\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;b str; fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; fn overlap\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;static str; fn overlap\u0026lt;\u0026#39;a, \u0026#39;b, \u0026#39;c\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;c str; // 省略 fn compare(\u0026amp;self, s: \u0026amp;str) -\u0026gt; \u0026amp;str; // 展开 fn compare\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(\u0026amp;\u0026#39;a self, \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;a str; 如果你曾写过\n结构体方法 接收引用的函数 返回引用的函数 泛型函数 trait object(后面会有更详细的讨论) 闭包(后面会有更详细的讨论) 那么你的代码就有被省略的泛型生命周期记号。 要点:\n几乎所有Rust代码都是泛型代码,到处都有被省略的生命周期记号 误解5: 如果编译能通过,那么我的生命周期标注就是正确的 误解推论\nRust对函数的的生命周期省略规则总是正确的 Rust的借用检查器在技术上和语义上总是正确的 Rust比我更了解我的程序的语义 Rust程序是有可能在技术上能通过编译,但语义上仍然是错的。来看一下这个例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct ByteIter\u0026lt;\u0026#39;a\u0026gt; { remainder: \u0026amp;\u0026#39;a [u8] } impl\u0026lt;\u0026#39;a\u0026gt; ByteIter\u0026lt;\u0026#39;a\u0026gt; { fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;\u0026amp;u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } fn main() { let mut bytes = ByteIter { remainder: b\u0026#34;1\u0026#34; }; assert_eq!(Some(\u0026amp;b\u0026#39;1\u0026#39;), bytes.next()); assert_eq!(None, bytes.next()); } ByteIter 是在字节切片上迭代的迭代器,为了简洁我们跳过对 Iterator trait的实现。 这看起来没什么问题,但如果我们想同时检查多个字节呢?\n1 2 3 4 5 6 7 8 fn main() { let mut bytes = ByteIter { remainder: b\u0026#34;1123\u0026#34; }; let byte_1 = bytes.next(); let byte_2 = bytes.next(); if byte_1 == byte_2 { // 做点什么 } } 啊哦!编译错误:\n1 2 3 4 5 6 7 8 9 10 error[E0499]: cannot borrow `bytes` as mutable more than once at a time --\u0026gt; src/main.rs:20:18 | 19 | let byte_1 = bytes.next(); | ----- first mutable borrow occurs here 20 | let byte_2 = bytes.next(); | ^^^^^ second mutable borrow occurs here 21 | if byte_1 == byte_2 { | ------ first borrow later used here 我觉得我们可以拷贝每一个字节。拷贝在我们处理字节的时候是可行的, 但当我们从 ByteIter 转向泛型切片迭代器用来迭代任意 \u0026amp;'a [T] 的时候 我们也会想到将来可能它会被应用到那些拷贝/克隆的代价很昂贵或根本不可能的类型上。 噢,我想我们对这没什么办法,代码能过编译,那么生命周期标记必然是对的不是吗?\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct ByteIter\u0026lt;\u0026#39;a\u0026gt; { remainder: \u0026amp;\u0026#39;a [u8] } impl\u0026lt;\u0026#39;a\u0026gt; ByteIter\u0026lt;\u0026#39;a\u0026gt; { fn next\u0026lt;\u0026#39;b\u0026gt;(\u0026amp;\u0026#39;b mut self) -\u0026gt; Option\u0026lt;\u0026amp;\u0026#39;b u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } 这一点帮助都没有,我仍然搞不明白。这里有个只有Rust专家才知道的小窍门: 给你的生命周期标记取个有描述性的名字。我们再试一次:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { remainder: \u0026amp;\u0026#39;remainder [u8] } impl\u0026lt;\u0026#39;remainder\u0026gt; ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { fn next\u0026lt;\u0026#39;mut_self\u0026gt;(\u0026amp;\u0026#39;mut_self mut self) -\u0026gt; Option\u0026lt;\u0026amp;\u0026#39;mut_self u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } 每个返回的字节都被用'mut_self标记了,但这些字节显然是来自于'remainder的, 让我们来改一下。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { remainder: \u0026amp;\u0026#39;remainder [u8] } impl\u0026lt;\u0026#39;remainder\u0026gt; ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;\u0026amp;\u0026#39;remainder u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } fn main() { let mut bytes = ByteIter { remainder: b\u0026#34;1123\u0026#34; }; let byte_1 = bytes.next(); let byte_2 = bytes.next(); std::mem::drop(bytes); // 我们甚至可以在这里把迭代器drop掉! if byte_1 == byte_2 { // 编译通过 // 做点什么 } } 现在让我们回顾一下,我们前一版的程序显然是错误的,但为什么Rust仍然允许它通过编译呢? 答案很简单:这么做是内存安全的。\nRust的借用检查器对程序的生命周期标记只要求到能够以静态的方式验证程序的内存安全。 Rust会爽快地编译一个程序,即使它的生命周期标记有语义上的错误, 这带来的结果就是程序会变得过于受限。\n来看一个与前一个相反的例子:Rust的生命周期省略规则恰好在这个例子上语义是正确的, 但我们却无意中用了一些多余的显式生命周期标记写了个非常受限的方法。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #[derive(Debug)] struct NumRef\u0026lt;\u0026#39;a\u0026gt;(\u0026amp;\u0026#39;a i32); impl\u0026lt;\u0026#39;a\u0026gt; NumRef\u0026lt;\u0026#39;a\u0026gt; { // 我的结构体是在\u0026#39;a上泛型的,所以我同样也要 // 标记一下我的self参数,对吗?(答案是:不,不对) fn some_method(\u0026amp;\u0026#39;a mut self) {} } fn main() { let mut num_ref = NumRef(\u0026amp;5); num_ref.some_method(); // 可变借用num_ref直到它剩余的生命周期结束 num_ref.some_method(); // 编译错误 println!(\u0026#34;{:?}\u0026#34;, num_ref); // 同样编译错误 } 如果我们有一个在 \u0026lsquo;a 上的泛型,我们几乎永远不会想要写一个接收\u0026amp;'a mut self的方法。 因为这意味着我们告诉Rust,这个方法会可变借用这个结构体直到整个结构体生命周期结束。 这也就告诉Rust的借用检查器最多只允许 some_method 被调用一次, 在这之后这个结构体将会被永久性地可变借用走,也就变得不可用了。 这样的用例非常非常少,但处于困惑中的初学者非常容易写出这种代码,并能通过编译。 正确的做法是不要添加这些多余的显式生命周期标记,让Rust的生命周期省略规则来处理它:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #[derive(Debug)] struct NumRef\u0026lt;\u0026#39;a\u0026gt;(\u0026amp;\u0026#39;a i32); impl\u0026lt;\u0026#39;a\u0026gt; NumRef\u0026lt;\u0026#39;a\u0026gt; { // 去掉mut self前面的\u0026#39;a fn some_method(\u0026amp;mut self) {} // 上一段代码脱掉语法糖后变为 fn some_method_desugared\u0026lt;\u0026#39;b\u0026gt;(\u0026amp;\u0026#39;b mut self){} } fn main() { let mut num_ref = NumRef(\u0026amp;5); num_ref.some_method(); num_ref.some_method(); // 编译通过 println!(\u0026#34;{:?}\u0026#34;, num_ref); // 编译通过 } 要点:\nRust的函数生命周期省略规则并不总是对所有情况都正确的 Rust对你的程序的语义了解并不比你多 给你的生命周期标记起一个更有描述性的名字 在你使用显式生命周期标记的时候要想清楚它们应该被用在哪以及为什么要这么用 误解6: 装箱的trait对象没有生命周期 早前我们讨论了Rust对函数的生命周期省略规则。Rust同样有着对于trait对象的生命周期省略规则,它们是:\n如果一个trait对象作为一个类型参数传递到泛型中,那么它的生命约束会从它包含的类型中推断 如果包含的类型中有唯一的约束,那么就使用这个约束。 如果包含的类型中有超过一个约束,那么必须显式指定约束。 如果以上都不适用,那么:\n如果trait是以单个生命周期约束定义的,那么就使用这个约束 如果所有生命周期约束都是 \u0026lsquo;static 的,那么就使用 \u0026lsquo;static 作为约束 如果trait没有生命周期约束,那么它的生命周期将会从表达式中推断,如果不在表达式中,那么就是 \u0026lsquo;static 的 这么多东西听起来超级复杂,但我们可以简单地总结为 \u0026ldquo;trait对象的生命周期约束是从上下文中推断出来的。\u0026rdquo; 在我们看过几个例子后,我们会发现生命周期约束推断其实是很符合直觉的,我们不需要去记这些很正式的规则。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 use std::cell::Ref; trait Trait {} // 省略 type T1 = Box\u0026lt;dyn Trait\u0026gt;; // 展开,Box\u0026lt;T\u0026gt;对T没有生命周期约束,所以被推断为\u0026#39;static type T2 = Box\u0026lt;dyn Trait + \u0026#39;static\u0026gt;; // 省略 impl dyn Trait {} // 展开 impl dyn Trait + \u0026#39;static {} // 省略 type T3\u0026lt;\u0026#39;a\u0026gt; = \u0026amp;\u0026#39;a dyn Trait; // 展开, 因为\u0026amp;\u0026#39;a T 要求 T: \u0026#39;a, 所以推断为 \u0026#39;a type T4\u0026lt;\u0026#39;a\u0026gt; = \u0026amp;\u0026#39;a (dyn Trait + \u0026#39;a); // 省略 type T5\u0026lt;\u0026#39;a\u0026gt; = Ref\u0026lt;\u0026#39;a, dyn Trait\u0026gt;; // 展开, 因为Ref\u0026lt;\u0026#39;a, T\u0026gt; 要求 T: \u0026#39;a, 所以推断为 \u0026#39;a type T6\u0026lt;\u0026#39;a\u0026gt; = Ref\u0026lt;\u0026#39;a, dyn Trait + \u0026#39;a\u0026gt;; trait GenericTrait\u0026lt;\u0026#39;a\u0026gt;: \u0026#39;a {} // 省略 type T7\u0026lt;\u0026#39;a\u0026gt; = Box\u0026lt;dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt;\u0026gt;; // 展开 type T8\u0026lt;\u0026#39;a\u0026gt; = Box\u0026lt;dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt; + \u0026#39;a\u0026gt;; // 省略 impl\u0026lt;\u0026#39;a\u0026gt; dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt; {} // 展开 impl\u0026lt;\u0026#39;a\u0026gt; dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt; + \u0026#39;a {} 实现了某个trait的具体的类型可以包含引用,因此它们同样拥有生命周期约束,且对应的trait对象也有生命周期约束。 你也可以直接为引用实现trait,而引用显然有生命周期约束。\n1 2 3 4 5 6 7 8 trait Trait {} struct Struct {} struct Ref\u0026lt;\u0026#39;a, T\u0026gt;(\u0026amp;\u0026#39;a T); impl Trait for Struct {} impl Trait for \u0026amp;Struct {} // 直接在引用类型上实现Trait impl\u0026lt;\u0026#39;a, T\u0026gt; Trait for Ref\u0026lt;\u0026#39;a, T\u0026gt; {} // 在包含引用的类型上实现Trait 不管怎样,这都值得我们仔细研究,因为新手们经常在将一个使用trait对象的函数重构成使用泛型的函数(或者反过来)的时候感到困惑。 我们来看看这个例子:\n1 2 3 4 5 6 7 8 9 10 11 12 13 use std::fmt::Display; fn dynamic_thread_print(t: Box\u0026lt;dyn Display + Send\u0026gt;) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } fn static_thread_print\u0026lt;T: Display + Send\u0026gt;(t: T) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } 这会抛出下面的编译错误:\n1 2 3 4 5 6 7 8 9 10 11 12 13 error[E0310]: the parameter type `T` may not live long enough --\u0026gt; src/lib.rs:10:5 | 9 | fn static_thread_print\u0026lt;T: Display + Send\u0026gt;(t: T) { | -- help: consider adding an explicit lifetime bound...: `T: \u0026#39;static +` 10 | std::thread::spawn(move || { | ^^^^^^^^^^^^^^^^^^ | note: ...so that the type `[closure@src/lib.rs:10:24: 12:6 t:T]` will meet its required lifetime bounds --\u0026gt; src/lib.rs:10:5 | 10 | std::thread::spawn(move || { | ^^^^^^^^^^^^^^^^^^ 很好,编译器告诉了我们怎么解决这个问题,我们来试试。\n1 2 3 4 5 6 7 8 9 10 11 12 13 use std::fmt::Display; fn dynamic_thread_print(t: Box\u0026lt;dyn Display + Send\u0026gt;) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } fn static_thread_print\u0026lt;T: Display + Send + \u0026#39;static\u0026gt;(t: T) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } 编译通过,但这两个函数放在一块儿看起来有点怪,为什么第二个函数对 T 有 \u0026lsquo;static 约束,而第一个没有? 这个问题很刁钻。根据生命周期省略规则,Rust自动为第一个函数推断出 \u0026lsquo;static 约束,所以两个函数实际上都有 \u0026lsquo;static 约束。 在Rust编译器的眼中是这样的:\n1 2 3 4 5 6 7 8 9 10 11 12 13 use std::fmt::Display; fn dynamic_thread_print(t: Box\u0026lt;dyn Display + Send + \u0026#39;static\u0026gt;) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } fn static_thread_print\u0026lt;T: Display + Send + \u0026#39;static\u0026gt;(t: T) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } 要点:\n所有trait对象都有着默认推断的生命周期约束 误解7: 编译器报错信息会告诉我怎么修改我的代码 误解推论\nRust编译器对于trait objects的生命周期省略规则总是对的 Rust编译器比我更懂我代码的语义 这个误解是前两个误解的合二为一的例子:\n1 2 3 4 5 use std::fmt::Display; fn box_displayable\u0026lt;T: Display\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display\u0026gt; { Box::new(t) } 抛出如下错误:\n1 2 3 4 5 6 7 8 9 10 11 12 13 error[E0310]: the parameter type `T` may not live long enough --\u0026gt; src/lib.rs:4:5 | 3 | fn box_displayable\u0026lt;T: Display\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display\u0026gt; { | -- help: consider adding an explicit lifetime bound...: `T: \u0026#39;static +` 4 | Box::new(t) | ^^^^^^^^^^^ | note: ...so that the type `T` will meet its required lifetime bounds --\u0026gt; src/lib.rs:4:5 | 4 | Box::new(t) | ^^^^^^^^^^^ 好吧,让我们照着编译器告诉我们的方式修改它,别在意这种改法基于了一个没有告知的事实: 编译器自动为我们的boxed trait object推断了一个\u0026rsquo;static的生命周期约束。\n1 2 3 4 5 use std::fmt::Display; fn box_displayable\u0026lt;T: Display + \u0026#39;static\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display\u0026gt; { Box::new(t) } 现在编译通过了,但这真的是我们想要的吗?可能是,也可能不是,编译去并没有告诉我们其它解决方法 但这个也许合适。\n1 2 3 4 5 use std::fmt::Display; fn box_displayable\u0026lt;\u0026#39;a, T: Display + \u0026#39;a\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display + \u0026#39;a\u0026gt; { Box::new(t) } 这个函数接收的参数和前一个版本一样,但多了不少东西。这样写能让它更好吗?不一定, 这取决于我们的程序的要求和约束。这个例子有些抽象,让我们来看看更简单明了的情况。\n1 2 3 fn return_first(a: \u0026amp;str, b: \u0026amp;str) -\u0026gt; \u0026amp;str { a } 报错:\n1 2 3 4 5 6 7 8 9 10 11 error[E0106]: missing lifetime specifier --\u0026gt; src/lib.rs:1:38 | 1 | fn return_first(a: \u0026amp;str, b: \u0026amp;str) -\u0026gt; \u0026amp;str { | ---- ---- ^ expected named lifetime parameter | = help: this function\u0026#39;s return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b` help: consider introducing a named lifetime parameter | 1 | fn return_first\u0026lt;\u0026#39;a\u0026gt;(a: \u0026amp;\u0026#39;a str, b: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str { | ^^^^ ^^^^^^^ ^^^^^^^ ^^^ 这个错误信息建议我们给输入和输出打上相同的生命周期标记。 这么做虽然能使得编译通过,但却过度限制了返回类型。 我们真正想要的是这个:\n1 2 3 fn return_first\u0026lt;\u0026#39;a\u0026gt;(a: \u0026amp;\u0026#39;a str, b: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;a str { a } 要点:\nRust对trait object的生命周期省略规则并不是在所有情况下都正确。 Rust不见得比你更懂你代码的语义。 Rust编译错误信息给出的修改建议可能能让你的代码编译通过,但这不一定是最符合你的要求的。 误解8: 生命周期可以在运行时变长缩短 误解推论\n容器类型可以通过更换引用在运行时更改自己的生命周期 Rust的借用检查会进行深入的控制流分析 这过不了编译:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 struct Has\u0026lt;\u0026#39;lifetime\u0026gt; { lifetime: \u0026amp;\u0026#39;lifetime str, } fn main() { let long = String::from(\u0026#34;long\u0026#34;); let mut has = Has { lifetime: \u0026amp;long }; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); { let short = String::from(\u0026#34;short\u0026#34;); // 换成短生命周期 has.lifetime = \u0026amp;short; assert_eq!(has.lifetime, \u0026#34;short\u0026#34;); // 换回长生命周期(并不行) has.lifetime = \u0026amp;long; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); // `short`在这里析构 } // 编译错误,`short`在析构后仍处于借用状态 assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); } 报错:\n1 2 3 4 5 6 7 8 9 10 error[E0597]: `short` does not live long enough --\u0026gt; src/main.rs:11:24 | 11 | has.lifetime = \u0026amp;short; | ^^^^^^ borrowed value does not live long enough ... 15 | } | - `short` dropped here while still borrowed 16 | assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); | --------------------------------- borrow later used here 下面这个代码同样过不了编译,报的错和上面一样。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct Has\u0026lt;\u0026#39;lifetime\u0026gt; { lifetime: \u0026amp;\u0026#39;lifetime str, } fn main() { let long = String::from(\u0026#34;long\u0026#34;); let mut has = Has { lifetime: \u0026amp;long }; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); // 这个代码块不会被执行 if false { let short = String::from(\u0026#34;short\u0026#34;); // 换成短生命周期 has.lifetime = \u0026amp;short; assert_eq!(has.lifetime, \u0026#34;short\u0026#34;); // 换回长生命周期(并不行) has.lifetime = \u0026amp;long; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); // `short`在这里析构 } // 仍旧编译错误,`short`在析构后仍处于借用状态 assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); } 生命周期只会在编译期被静态验证,并且Rust的借用检查只能做到基本的控制流分析, 它假设每个if-else中的代码块和match的每个分支都会被执行, 并且其中的每一个变量都能被指定一个最短的生命周期。 一旦变量被指定了一个生命周期,它就一直受到这个生命周期约束。变量的生命周期只能缩短, 并且所有缩短都会在编译器被确定。\n要点:\n生命周期是在编译期静态验证的 生命周期不能在运行时变长、缩短或者改变 Rust的借用检查总是会为所有变量指定一个最短可能的生命周期,并且假定所有代码路径都会被执行 误解9: 将可变引用降级为共享引用是安全的 误解推论\n重新借用一个引用会终止它的生命周期并且开始一个新的 你可以向一个接收共享引用的函数传递一个可变引用,因为Rust会隐式将可变引用重新借用为不可变引用:\n1 2 3 4 5 6 7 fn takes_shared_ref(n: \u0026amp;i32) {} fn main() { let mut a = 10; takes_shared_ref(\u0026amp;mut a); // 编译通过 takes_shared_ref(\u0026amp;*(\u0026amp;mut a)); // 上一行的显式写法 } 直觉上这没问题,将一个可变引用重新借用为不可变引用,应该不会有什么害处不是吗? 然而并非如此,下面的代码过不了编译。\n1 2 3 4 5 6 fn main() { let mut a = 10; let b: \u0026amp;i32 = \u0026amp;*(\u0026amp;mut a); // 重新借用为不可变 let c: \u0026amp;i32 = \u0026amp;a; dbg!(b, c); // 编译错误 } 报错:\n1 2 3 4 5 6 7 8 9 error[E0502]: cannot borrow `a` as immutable because it is also borrowed as mutable --\u0026gt; src/main.rs:4:19 | 3 | let b: \u0026amp;i32 = \u0026amp;*(\u0026amp;mut a); | -------- mutable borrow occurs here 4 | let c: \u0026amp;i32 = \u0026amp;a; | ^^ immutable borrow occurs here 5 | dbg!(b, c); | - mutable borrow later used here 可变借用出现后立即重新借用为不可变引用,然后可变引用自身析构。 为什么Rust会认为这个不可变的重新借用仍具有可变引用的独占生命周期? 虽然上面这个例子没什么问题,但允许将可变引用降级为共享引用实际上引入了潜在的内存安全问题。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 use std::sync::Mutex; struct Struct { mutex: Mutex\u0026lt;String\u0026gt; } impl Struct { // 将self的可变引用降级为str的共享引用 fn get_string(\u0026amp;mut self) -\u0026gt; \u0026amp;str { self.mutex.get_mut().unwrap() } fn mutate_string(\u0026amp;self) { // 如果Rust允许将可变引用降级为共享引用, // 那么下面这行代码会使得所有从get_string中得到的共享引用失效 *self.mutex.lock().unwrap() = \u0026#34;surprise!\u0026#34;.to_owned(); } } fn main() { let mut s = Struct { mutex: Mutex::new(\u0026#34;string\u0026#34;.to_owned()) }; let str_ref = s.get_string(); // 可变引用降级为共享引用 s.mutate_string(); // str_ref失效,变为悬空指针 dbg!(str_ref); // 编译错误,和我们预期的一样 } 这里的问题在于,当你将一个可变引用重新借用为共享引用,你会遇到一点麻烦: 即使可变引用已经析构,重新借用出来的共享引用还是会将可变引用的生命周期延长到和自己一样长。 这种重新借用出来的共享引用非常难用,因为它不能与其它共享引用共存。 它有着可变引用和不可变引用的所有缺点,却没有它们各自的优点。 我认为将可变引用重新借用为共享引用应该被认为是Rust的反模式(anti-pattern)。 对这种反模式保持警惕很重要,这可以让你在看到下面这样的代码的时候更容易发现它:\n1 2 3 4 5 6 7 8 9 10 11 12 // 将T的可变引用降级为共享引用 fn some_function\u0026lt;T\u0026gt;(some_arg: \u0026amp;mut T) -\u0026gt; \u0026amp;T; struct Struct; impl Struct { // 将self的可变引用降级为self共享引用 fn some_method(\u0026amp;mut self) -\u0026gt; \u0026amp;self; // 将self的可变引用降级为T的共享引用 fn other_method(\u0026amp;mut self) -\u0026gt; \u0026amp;T; } 即使你避免了函数和方法签名中的重新借用,Rust仍然会自动隐式重新借用, 所以很容易无意中遇到这样的问题:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 use std::collections::HashMap; type PlayerID = i32; #[derive(Debug, Default)] struct Player { score: i32, } fn start_game(player_a: PlayerID, player_b: PlayerID, server: \u0026amp;mut HashMap\u0026lt;PlayerID, Player\u0026gt;) { // 从服务器中获取player,如果不存在则创建并插入一个新的 let player_a: \u0026amp;Player = server.entry(player_a).or_default(); let player_b: \u0026amp;Player = server.entry(player_b).or_default(); // 用player做点什么 dbg!(player_a, player_b); // 编译错误 } 上面的代码编译失败。因为 or_default() 返回一个 \u0026amp;mut Player, 而我们的显式类型标注 \u0026amp;Player 使得这个 \u0026amp;mut Player 被隐式重新借用为 \u0026amp;Player 。 为了通过编译,我们不得不这样写:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 use std::collections::HashMap; type PlayerID = i32; #[derive(Debug, Default)] struct Player { score: i32, } fn start_game(player_a: PlayerID, player_b: PlayerID, server: \u0026amp;mut HashMap\u0026lt;PlayerID, Player\u0026gt;) { // 因为我们不能把它们放在一起用,所以这里把返回的Player可变引用析构掉 server.entry(player_a).or_default(); server.entry(player_b).or_default(); // 再次获取这些Player,这次以不可变的方式,避免出现隐式重新借用 let player_a = server.get(\u0026amp;player_a); let player_b = server.get(\u0026amp;player_b); // 用Player做点什么 dbg!(player_a, player_b); // 编译通过 } 虽然有点尴尬和笨重,但这也算是为内存安全做出的牺牲。\n要点:\n尽量不要把可变引用重新借用为共享引用,不然你会遇到不少麻烦 重新借用一个可变引用不会使得它的生命周期终结,即使这个可变引用已经析构 误解10: 闭包遵循和函数相同的生命周期省略规则 比起误解,这更像是Rust的一个小陷阱。\n闭包,虽然也是个函数,但是它并不遵循和函数相同的生命周期省略规则。\n1 2 3 4 5 6 7 fn function(x: \u0026amp;i32) -\u0026gt; \u0026amp;i32 { x } fn main() { let closure = |x: \u0026amp;i32| x; } 报错:\n1 2 3 4 5 6 7 8 error: lifetime may not live long enough --\u0026gt; src/main.rs:6:29 | 6 | let closure = |x: \u0026amp;i32| x; | - - ^ returning this value requires that `\u0026#39;1` must outlive `\u0026#39;2` | | | | | return type of closure is \u0026amp;\u0026#39;2 i32 | let\u0026#39;s call the lifetime of this reference `\u0026#39;1` 去掉语法糖后:\n1 2 3 4 5 6 7 8 9 10 // 输入的生命周期应用到输出上 fn function\u0026lt;\u0026#39;a\u0026gt;(x: \u0026amp;\u0026#39;a i32) -\u0026gt; \u0026amp;\u0026#39;a i32 { x } fn main() { // 输入和输出有它们自己独有的生命周期 let closure = for\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt; |x: \u0026amp;\u0026#39;a i32| -\u0026gt; \u0026amp;\u0026#39;b i32 { x }; // 注意:上面这行代码不是合法的语法,但可以表达出我们的意思 } 出现这种差异并没有一个好的理由。闭包最早的实现用的类型推断语义和函数不同, 现在变得没法改了,因为将它们统一起来会造成一个不兼容的改动。 那么我们要怎么样显式标注闭包的类型呢?我们可选的办法有:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 fn main() { // 转成trait object,变成不定长类型,编译错误 let identity: dyn Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 = |x: \u0026amp;i32| x; // 可以通过将它分配在堆上来绕过这个错误,但这样很笨重 let identity: Box\u0026lt;dyn Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32\u0026gt; = Box::new(|x: \u0026amp;i32| x); // 也可以跳过分配,直接创建一个静态的引用 let identity: \u0026amp;dyn Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 = \u0026amp;|x: \u0026amp;i32| x; // 上一行去掉语法糖之后:) let identity: \u0026amp;\u0026#39;static (dyn for\u0026lt;\u0026#39;a\u0026gt; Fn(\u0026amp;\u0026#39;a i32) -\u0026gt; \u0026amp;\u0026#39;a i32 + \u0026#39;static) = \u0026amp;|x: \u0026amp;i32| -\u0026gt; \u0026amp;i32 { x }; // 理想中的写法是这样的,但这不是有效的语法 let identity: impl Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 = |x: \u0026amp;i32| x; // 这样也不错,但也不是有效的语法 let identity = for\u0026lt;\u0026#39;a\u0026gt; |x: \u0026amp;\u0026#39;a i32| -\u0026gt; \u0026amp;\u0026#39;a i32 { x }; // \u0026#34;impl trait\u0026#34;可以写在函数返回的位置,我们也可以这样写 fn return_identity() -\u0026gt; impl Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 { |x| x } let identity = return_identity(); // 前一种解决方案更泛化的写法 fn annotate\u0026lt;T, F\u0026gt;(f: F) -\u0026gt; F where F: Fn(\u0026amp;T) -\u0026gt; \u0026amp;T { f } let identity = annotate(|x: \u0026amp;i32| x); } 相信你已经注意到,在上面的例子中,当闭包类型使用trait约束的时候会遵循一般函数的生命周期省略规则。\n这里没有什么真正的教训和洞察,只是它就是这样的而已。\n要点:\n每一门语言都有自己的小陷阱 🤷 误解11: \u0026lsquo;static 引用总能强制转换为 \u0026lsquo;a 引用 我前面给出了这个例子:\n1 2 fn get_str\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str; // 泛型版本 fn get_str() -\u0026gt; \u0026amp;\u0026#39;static str; // \u0026#39;static版本 有的读者问我这两个在实践中是否有区别。一开始我也不太确定,但不幸的是, 在经过一段时间的研究之后我发现它们在实践中确实存在着区别。\n所以一般来说,在操作值得时候我们可以使用 \u0026lsquo;static 引用来替换 \u0026lsquo;a 引用, 因为Rust会自动将 \u0026lsquo;static 引用强制转换到 \u0026lsquo;a 引用。 直觉上来讲,这没毛病,在一个要求较短生命周期引用的地方使用一个有着更长的生命周期的引用不会造成内存安全问题。 下面的代码和我们想的一样编译通过:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 use rand; fn generic_str_fn\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str { \u0026#34;str\u0026#34; } fn static_str_fn() -\u0026gt; \u0026amp;\u0026#39;static str { \u0026#34;str\u0026#34; } fn a_or_b\u0026lt;T\u0026gt;(a: T, b: T) -\u0026gt; T { if rand::random() { a } else { b } } fn main() { let some_string = \u0026#34;string\u0026#34;.to_owned(); let some_str = \u0026amp;some_string[..]; let str_ref = a_or_b(some_str, generic_str_fn()); // 编译通过 let str_ref = a_or_b(some_str, static_str_fn()); // 编译通过 } 然而,这种强制转换并不会在引用作为函数的类型签名的一部分的时候出现,所以下面这段代码无法通过编译:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 use rand; fn generic_str_fn\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str { \u0026#34;str\u0026#34; } fn static_str_fn() -\u0026gt; \u0026amp;\u0026#39;static str { \u0026#34;str\u0026#34; } fn a_or_b_fn\u0026lt;T, F\u0026gt;(a: T, b_fn: F) -\u0026gt; T where F: Fn() -\u0026gt; T { if rand::random() { a } else { b_fn() } } fn main() { let some_string = \u0026#34;string\u0026#34;.to_owned(); let some_str = \u0026amp;some_string[..]; let str_ref = a_or_b_fn(some_str, generic_str_fn); // 编译通过 let str_ref = a_or_b_fn(some_str, static_str_fn); // 编译错误 } 报错:\n1 2 3 4 5 6 7 8 9 10 error[E0597]: `some_string` does not live long enough --\u0026gt; src/main.rs:23:21 | 23 | let some_str = \u0026amp;some_string[..]; | ^^^^^^^^^^^ borrowed value does not live long enough ... 25 | let str_ref = a_or_b_fn(some_str, static_str_fn); | ---------------------------------- argument requires that `some_string` is borrowed for `\u0026#39;static` 26 | } | - `some_string` dropped here while still borrowed 这算不算Rust的小陷阱还有待商榷,因为这不是 \u0026amp;\u0026lsquo;static str 到 \u0026amp;\u0026lsquo;a str 简单直接的强制转换, 而是 for Fn() -\u0026gt; \u0026amp;\u0026lsquo;static T 到 for\u0026lt;\u0026lsquo;a, T\u0026gt; Fn() -\u0026gt; \u0026amp;\u0026lsquo;a T 这种更复杂的情况。 前者是值之间的强制转换,后者是类型之间的强制转换。\n要点:\n签名为 for\u0026lt;\u0026lsquo;a, T\u0026gt; Fn() -\u0026gt; \u0026amp;\u0026lsquo;a T 的函数要比签名为 for fn() -\u0026gt; \u0026amp;\u0026lsquo;static T 的函数更为灵活,并且能用在更多场景下 总结: T 是 \u0026amp;T 和 \u0026amp;mut T 的超集 \u0026amp;T 和 \u0026amp;mut T 是不相交的集合 T: \u0026lsquo;static 应该被读作 \u0026ldquo;T 受 \u0026lsquo;static 生命周期约束\u0026rdquo; 如果 T: \u0026lsquo;static 那么 T 可以是一个有着 \u0026lsquo;static 生命周期的借用类型,或是一个所有权类型 既然 T: \u0026lsquo;static 包含了所有权类型,那么意味着 T 可以在运行时动态分配 不必在整个程序中都是有效的 可以被安全地任意修改 可以在运行时动态析构 可以有不同长度的生命周期 T: \u0026lsquo;a 比 \u0026amp;\u0026lsquo;a T 更泛化、灵活 T: \u0026lsquo;a 接收所有权类型、带引用的所有权类型,以及引用 \u0026amp;\u0026lsquo;a T 只接收引用 如果 T: \u0026lsquo;static 那么 T: \u0026lsquo;a,因为对于所有 \u0026lsquo;a 都有 \u0026lsquo;static \u0026gt;= \u0026lsquo;a 几乎所有Rust代码都是泛型的,到处都有省略的生命周期 Rust的生命周期省略规则并不是在任何情况下都对 Rust并不比你更了解你程序的语义 给生命周期标记起一个有描述性的名字 考虑清楚哪里需要显式写出生命周期标记,以及为什么要这么写 所有trait object都有默认推断的生命周期约束 Rust的编译错误信息可以让你的代码通过编译,但不一定是最符合你代码要求的 生命周期是在编译期静态验证的 生命周期不会以任何方式在运行时变长缩短 Rust的借用检查总会为每个变量选择一个最短可能的生命周期,并且假定每条代码路径都会被执行 尽量避免将可变引用重新借用为不可变引用,不然你会遇到不少麻烦 重新借用一个可变引用不会终止它的生命周期,即使这个可变引用已经析构 每个语言都有自己的小陷阱 ","permalink":"https://reid00.github.io/en/posts/langs_linux/rust%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E5%B8%B8%E8%A7%81%E8%AF%AF%E5%8C%BA/","summary":"介绍 我曾经有过的所有这些对生命周期的误解,现在有很多初学者也深陷于此。 我用到的术语可能不是标准的,所以下面列了一个表格来解释它们的用意。 短语","title":"Rust生命周期常见误区"},{"content":"RDF和属性图 首先来介绍 RDF 和属性图。大家知道世界万物是普遍联系的,Internet 带来了信息的连通,IoT 带来了设备的连通,像微信、微博、抖音、快手这些 APP 带来了人际关系的连通。随着社交、零售、金融、电信、物流等行业的快速发展,当今社会支起了一张庞大而复杂的关系网,在人们的生产和生活过程中,每时每刻都产生着大量的数据。随着技术的发展,我们对这些数据的分析和使用也不再局限于从统计的角度进行一些相关性的分析,而是希望从关联的角度揭示数据的一些因果联系。这里的关联,指的是相互连接的 connectivity,而不是统计意义上的 correlation。\n关联分析的场景也非常多,覆盖我们生活的方方面面。比如从社交网络分析里,我们可以做精准营销、好友推荐、舆情追踪等等;金融领域可以做信用卡反欺诈的分析,资金流向识别;零售领域,我们可以做用户 360 画像做商品实时推荐,返薅羊毛;电力领域,可以做电网的调度仿真、故障分析、电台因子计算;电信领域,可以做电信防骚扰,电信防诈骗;政企领域,可以做道路规划、智能交通,还有疫情精准防控;在制造业,我们可以做供应链管理、物流优化、产品溯源等;网络安全行业,可以做攻击溯源、调用链分析等等。\n在做关联分析的时候,我们往往需要一个图模型来描述。常见的图模型分为 RDF 和属性图两种。RDF 图中用点来表示唯一标识的资源或者是字面量的值,边则用来表示谓词。点和边之间组成一个 SPO 的三元组。属性图中,点表示实体,边表示关系,属性是点或边上的一个键值对。\n相比之下,RDF 的优势是可以支持多值属性,因为它的属性也是一个点,所以一个点连出去,可以有多值的属性。也可以通过四元组的方式前面加上一个图的描述,来实现动态图。并且 RDF 开始的比较早,所以有一个比较统一的标准。\n属性图的优势在于它两点之间可以表示同类型的多条边,因为它在边上是可以有区分属性的,边上的属性值也能让边上的表达能力更丰富。并且它支持复杂的属性类型,比如 list、set、map 等。\n随着行业的发展,我们看到越来越多的可能。知识图谱的表示在逐渐用属性图来完成。当然也有少量的图数据库是用 RDF 模型来做的,但是未来更多的新型图数据库都会用属性图模型。\n图数据库存储的核心目标 完成一个图查询或者图分析的核心操作,就是邻居的迭代遍历。\n单独的访问点或者边,或者上面的属性并不是这里的关键。仅仅是单独访问,使用传统的数据库也可以提供很好的性能。在关联分析当中,不论是从一个起始点若干跳数内的邻域网络进行分析,还是对全图进行一些完整的计算,最核心的操作都是迭代遍历某个点的所有边,也就是所谓邻居的迭代遍历。在关系型数据库中是依赖外键,通过建立索引等方式来完成的。\n在图数据库中,会直接存储边数据,也就是所谓的实现 index-free adjacency。写入的时候,保证一个点和它直接相连的边总是存储在一起。查询的时候,迭代遍历一个点的所有邻居可以直接进行,不需要依赖于其它数据结构,从而可以大幅提升邻居迭代遍历的性能。\n这里是跟关系型数据库做的一个深点查询的性能对比,用的是 who-trust-whom 的一个公开数据集,这个数据集也不是很大,约 7.5 万点,50 万边。我们想知道一个信任的人这样一个多跳关联的查询结果。使用关键性数据库的时候,对比了加索引和不加索引的情况。可以看出 2 跳的时候加索引可以明显提升关系型数据库的查询速度,到 3 跳的时候提升就不多了, 4 跳以上的时候加不加索引都会变得很慢。而使用图数据库,查询性能一直会保持在一个非常快的水平。这就是图数据库的 index-free adjacency 的特性,能够大幅提升邻居查询的速度。\n图数据库的分类 根据实现免索引连接的方式,可以把图数据库分成三类。\n第一类是使用原生图存储的方式,它的数据存储层就直接实现了免索引连接。上面的处理计算层和业务层都是以完全图的结构来描述,并且也不依赖于第三方存储组件,所以这种实现免索引连接的性能是最高效的。 第二种方式是非原生存储,数据存储层使用的是一个第三方的开源存储组件,但是它在处理过程中实现了近似免索引连接,在大多数情况下也能提供不错的性能。它的问题是由于使用了第三方存储组件,在某些场景下可能做得不是最优化。 第三种方式就是完全非原生的存储,底下可能是一个关系型数据库,或者是一个文档型或者其它类型的数据库,它的存储层其实并不是真正地实现了免索引连接,而是处理成通过索引或者一些其它技术手段,向上表达了一个图模型的查询接口。这种其实只是在接口层上实现了图的一个语义,而底下的存储和计算层都不是完全地使用免索引连接,所以它的性能也会相对低一些。 图数据库存储的主流技术方案 前文中已经明确了数据库存储的核心目标就是实现免索引连接。那么接下来就来看一些具体实现免索引连接的主流技术方案。\n数组存储 首先我们能想到的最直接的一个方案,就是用一个数组把每个点上的边按照顺序一起存储。在这一存储方案中,点文件就是由一系列的点数据组成的。每个点的存储内容包括点的 ID、点的 Meta 信息,以及这个点的一系列属性。在边文件中,是按照起始点的顺序存储点上对应的边,每条边存储的内容包括终止点 ID、边的 Meta 信息、边的一系列属性。这里所谓的 Meta 信息包括点边的类型、方向,还有一些为了实现事务的额外字段,这对于整体的存储来说不是特别重要,在这里就不详细展开了。在这个存储方案中,可以直接从起始点开始遍历相邻边的所有数据,读取性能是非常高的。 数组存储劣势 这种存储需要处理的一个比较棘手的问题,就是数组变长的情况。这里的变长是由很多因素导致,比如两个点可能属性数量不一样,属性本身如果是字符串,长度也会不一样。属性长度不一样会导致每条边的存储空间也不一样,这样在边文件中就不能用一个简单的数组来进行寻址了。如果仅仅是属性导致的变长,还是有比较简单的解决方案的,比如可以把属性单独的再放到另一个存储文件中,这样点文件和边文件里面的内容,是不是定长的呢?其实也不一定,因为每个点上边的数量也是不一样的,所以在边文件里面,每个点触发的边序列的总长度也是不一样的。所以还是要处理数组变长的问题。\n解决思路一般是两种:\n一种是使用额外的一个 offset 的记录,相当于是用一个偏移量记录,来记录每一个点或者边的起始位置。这个记录本身就可以是定长的了,因为它是个 offset 值。或者是提前划分好一些额外的区域,来预留给它增长的空间。 为了解决这种数组存储变长的问题,我们自然也可以想到用类似链表的方式来存储。在链表方式的存储模式中,点和边全部存的都是 ID,包括点 ID、边 ID、属性 ID 等等。通过属性 ID ,可以在另外一个属性存储里面找到它的位置以及具体的值。因为存的都是 ID,所以每个点和每条边的数据长度就是固定的了。通过 ID 可以直接计算出偏移量,然后用偏移量的位置去读取数据。所以每个数据本身也不需要保存自身的ID,因为偏移量的位置是能够反推出来自身 ID 的。 链表存储 这是一个链表存储下进行边迭代的例子: 假设有一个起始点 A,需要迭代它的所有边。首先在点文件中找到点 A 的首个边,α。然后去边文件中找到 α 对应偏移量的位置,就可以读出这条边的数据。可以看到,是一个从点 A 到点 B 的边,A 是一个起始点,我们就去找起始点下一条边的 ID,就找到边 θ。然后去边 θ 的位置,找到偏移量,就找到边 θ。这里我们看到它是一个 C 到 A 的边,A 是终止点。我们就去找终止点的下一条边,是 ω。再去找到边 ω 的位置,看到是起始点 A 终止点 D,通过这样的方式就可以不断地去迭代边。 我们看到,用链表存储的方式很好地解决了数组变长的问题,因为新增边的时候,只需要新增固定长度的结构组成链表即可。每一次迭代也是在 O(1) 的时间内直接找到了下一条边,也不依赖于外部的索引或者其它结构。\n链表存储的劣势 这看似是一个比较好的方案,但实际的使用中,也存在着一些问题。不要忘记,现在讨论的是一个存储格式,而不是一个内存结构。存储格式意味着最终是要在磁盘 IO 上进行读写的。在链表存储方案下,每一次边迭代的时候,由于边 offset 的位置是随机的,所以会有大量的随机读操作。而磁盘对于随机读操作并不是很友好。 所以虽然这里理论上的迭代邻居找到下一条边的复杂度是 O(1),但 O(1) 的单位时间是磁盘随机读的时间,而不是顺序读的时间,这两者在性能上是会有非常大的差别的。所以使用这种链表的存储方式,通常来说会依赖一个非常高效实现的缓存机制,需要把大量的磁盘数据放到内存缓存中来读,在内存中进行随机访问的性能就会提升很多。 除了基于数组和链表的方法,还有其它一些格式可以实现 O(1) 时间的边迭代。比如,使用 LSM-Tree 的存储结构,这个结构是一种顺序写盘多层结构的 KV 存储。这里只简单介绍一下它的工作原理。\nLSM 存储 这个图忽略了像写 WAL 这样的细节,是 LSM 树读写的核心操作流程。 LSM 树是一种常用的键值存储结构,处理写请求的性能很高。它的读写操作流程如下:当一个请求进来的时候,直接写入内存中的一个 MemTable,如果 MemTable 没写满,就直接返回请求。因此它处理写请求的性能是很高的。当 MemTable 满的时候,会生成一个不可写的、只读的 Immutable MemTable,同时生成新的可写的 MemTable,以供后续使用。然后 Immutable MemTable 就会写到磁盘上,形成一个 SST 文件。SST 文件在写盘的时候,会根据 Key 排序,从而实现顺序的边迭代。其落盘结构的 SST 文件也是分层来组织的。从内存中直接写出来的第 0 层达到一定数据量大小的时候,或者触发某种条件的时候,就会进行一个归并排序,归并排序就是一个 Compaction 的过程。合并出来的第一层的 SST 文件,都是按照 Key 的顺序写排的。读取的时候是先去内存中的 MemTable 查找,找到了就返回,如果没有找到就去第 0 层的 SST 文件中查找,找不到再去第 1 层,这样逐层查找,一直到找到需要读取的 Key 为止。\n使用 SST 文件进行存储的一个关键就是设计边的 Key。因为在 SST 文件中,Key 是有序排列的,所以我们需要通过 LSMTree 来实现免索引连接的能力。关键点就是合理地设计边的 Key,使一个点所有边在排序后是相邻的。说起来比较拗口,其实实现起来并不难。\n我们看一下这个例子。只要把边 Key 的最高位放起始点 ID,那么后面无论是边的其它什么信息,都可以让从起始点 ID 出发的边自然地排序排在一起。这里也可以加入一个编号的字段,因为两点之间,起始点和终止点 Meta 这些是固定的,编号字段加入之后,就可以支持在两点之间同类型的多条边共存。因为这是一个 KV 结构。如果只有起始点、终止点和 Meta,两点之间同类型的边只能存在一条。所以比如转账交易或者是访问记录这些具有事件性质的边要存多条,可以加一个编号。当然也不一定都是必须从起始点开始来做边的 Key。 比如在例 2 中,把 type 边类型放在高位。它就可以先以 type 进行划分,后面才是起始点。这种方法也比较适合在分布式场景下按类型做分片,这样同一类型的边就会排在相邻的分片中,有利于提高分布式查询的性能。使用这个方式,有非常高的写入性能,并且读取的时候也能提供免索引连接的能力。\nnebula 点 nebula 边 nebula 解读 上图以最简单的两个点和一条边为例,起点 SrcVertex 通过边 EdgeA 连接目的点 DstVertex,形成路径(SrcVertex)-[EdgeA]-\u0026gt;(DstVertex)。这两个点和一条边会以 6 个键值对的形式保存在存储层的两个不同分片,即 Partition x 和 Partition y 中,详细说明如下:\n点 SrcVertex 的键值保存在 Partition x 中。 边 EdgeA 的第一份键值,这里用 EdgeA_Out 表示,与 SrcVertex 一同保存在 Partition x 中。key 的字段有 Type、PartID(x)、VID(Src,即点 SrcVertex 的 ID)、EdgeType(符号为正,代表边方向为出)、Rank(0)、VID(Dst,即点 DstVertex 的 ID)和 PlaceHolder。SerializedValue 即 Value,是序列化的边属性。 点 DstVertex 的键值保存在 Partition y 中。 边 EdgeA 的第二份键值,这里用 EdgeA_In 表示,与 DstVertex 一同保存在 Partition y 中。key 的字段有 Type、PartID(y)、VID(Dst,即点 DstVertex 的 ID)、EdgeType(符号为负,代表边方向为入)、Rank(0)、VID(Src,即点 SrcVertex 的 ID)和 PlaceHolder。SerializedValue 即 Value,是序列化的边属性,与 EdgeA_Out 中该部分的完全相同。 EdgeA_Out 和 EdgeA_In 以方向相反的两条边的形式存在于存储层,二者组合成了逻辑上的一条边 EdgeA。EdgeA_Out 用于从起点开始的遍历请求,例如(a)-[]-\u0026gt;();EdgeA_In 用于指向目的点的遍历请求,或者说从目的点开始,沿着边的方向逆序进行的遍历请求,例如例如()-[]-\u0026gt;(a)。\n如 EdgeA_Out 和 EdgeA_In 一样,NebulaGraph冗余了存储每条边的信息,导致存储边所需的实际空间翻倍。因为边对应的 key 占用的硬盘空间较小,但 value 占用的空间与属性值的长度和数量成正比,所以,当边的属性值较大或数量较多时候,硬盘空间占用量会比较大。\nnebula 的存储如何迭代边 NebulaGraph 的点和边在同一个 partition,虽然没有存在同一个 key-value 中,但是永远在同一个地方,所以: 遍历1hop的出入边(不需要点属性信息的时候),就是一个 prefix scan(只需要给边上左边的那个 id ,两个方向的边都能高效扫出来,边多存了一次换来这里) 获取点与边的属性的情况下,prefix scan 之后获取了点边信息,在用点边的vertex id, get_value(key_of_id) LSM 的劣势 首先是读性能,在读的时候,如果内存没有命中,下面是一个逐层的 SST 文件,去找 Key 的最坏情况,可能要把所有层的 SST 文件全部找完,才能找到合适的 Key。所以它的免索引连接是比较依赖于Compaction 操作的。只有在理想情况下,比如在一个完整的 Compaction 完成的情况下,它才能真正实现免索引连接,否则会在各个 SST 文件内部去查找。在整体上,它并没有完整地达到不去利用其它结构就能够进行快速的领域迭代。\n而做 Compaction 又是一个有比较大的磁盘 IO 的操作,并且如果使用的是第三方的存储结构,那么做 Compaction 的操作是不受图数据库本身控制的,可能是由一些其它的机制触发的,比如是在前台负载压力比较大的情况下触发了 Compaction,这样实际在使用的时候会出现一些瓶颈,所以必须要对第三方存储进行比较深度的改动,才能够更好地优化。\n可以看到,各种实现免索引连接的存储方式都不是一劳永逸的,而是有各自的优势和短板。通过数组的方式读取速度快,但是写入因为涉及到变长的问题,可能会比较慢。通过 LSM 树的方式写入速度快,但是读的时候又依赖于 Compaction 操作,在 Compaction 没有完成的情况下,它的读取速度也比较慢。通过链表的方式读取和写入速度都不占优,但是它的灵活性却最高,因为它是以 offset 形式的指针来实现的。\n在实际商业图数据库的实现过程中,需要根据设计理念去做取舍。也可以结合两种或者多种方案的优点,在不同的数据形式下,灵活地实现不同类型的存储。还有一些其它的问题,比如分区分片、反向边一致性、如何支持事务、数据索引怎么做、数据过期等等,都是要解决的问题,实现起来还是比较复杂的。\n","permalink":"https://reid00.github.io/en/posts/storage/%E7%9F%A5%E8%AF%86%E5%9B%BE%E8%B0%B1%E5%AD%98%E5%82%A8%E6%8A%80%E6%9C%AF/","summary":"RDF和属性图 首先来介绍 RDF 和属性图。大家知道世界万物是普遍联系的,Internet 带来了信息的连通,IoT 带来了设备的连通,像微信、微博、抖","title":"知识图谱存储技术"},{"content":"WebAssembly 简介 WebAssembly (有时缩写为 Wasm)为可执行程序定义了一种可移植的二进制代码格式和相应的文本格式以及软件接口,用于促进这些程序与其宿主环境之间的交互。 WebAssembly 的主要目标是在网页上启用高性能的应用程序,“但是它不会做出任何特定于 Web 的假设或提供特定于 Web 的特性,因此它也可以在其他环境中使用。”它是一个开放标准 ,旨在支持任何操作系统上的任何语言,实际上,所有最流行的语言都至少有一定程度的支持。 from wasm\nWebAssembly (sometimes abbreviated Wasm) defines a portable binary-code format and a corresponding text format for executable programs as well as software interfaces for facilitating interactions between such programs and their host environment. The main goal of WebAssembly is to enable high-performance applications on web pages, \u0026ldquo;but it does not make any Web-specific assumptions or provide Web-specific features, so it can be employed in other environments as well.\u0026quot;[7] It is an open standard[8][9] and aims to support any language on any operating system,[10] and in practice all of the most popular languages already have at least some level of support.\nWebAssembly 是一种二进制编码格式,而不是一门新的语言。 WebAssembly 不是为了取代 JavaScript,而是一种补充(至少现阶段是这样),结合 WebAssembly 的性能优势,很大可能集中在对性能要求高(例如游戏,AI),或是对交互体验要求高(例如移动端)的场景。 C/C++ 等语言可以编译 WebAssembly 的目标文件,也就是说,其他语言可以通过编译器支持,而写出能够在浏览器前端运行的代码。 Rust 如何使用WASM 完整项目看此处\n安装 安装Rust, 参考官网 install wasm-pack cargo install wasm-pack [可选] install cargo-generate cargo install cargo-generate Python3 用于启动一个简单的 HTTP 服务器 案例 创建一个rust lib 项目 cargo new --lib hello-wasm 打开项目,结构如下 1 2 3 4 5 6 -rw-r--r-- 1 zhangbl 197121 3203 Aug 16 15:41 Cargo.lock -rw-r--r-- 1 zhangbl 197121 224 Aug 16 15:51 Cargo.toml -rw-r--r-- 1 zhangbl 197121 314 Aug 16 17:35 index.html # 后面新建的 drwxr-xr-x 1 zhangbl 197121 0 Aug 16 18:13 pkg/ # 后面生成的 drwxr-xr-x 1 zhangbl 197121 0 Aug 16 15:27 src/ drwxr-xr-x 1 zhangbl 197121 0 Aug 16 16:01 target/ 打开src/lib.rs, 删除原先内容,用以下内容取代:\n1 2 3 4 5 6 7 8 9 10 11 use wasm_bindgen::prelude::*; #[wasm_bindgen] extern { pub fn alert(s: \u0026amp;str); } #[wasm_bindgen] pub fn greet(name: \u0026amp;str) { alert(\u0026amp;format!(\u0026#34;Hello, {}!\u0026#34;, name)); } 同时在cargo.toml 新增下面内容:\n1 2 3 4 5 [lib] crate-type = [\u0026#34;cdylib\u0026#34;] [dependencies] wasm-bindgen=\u0026#34;0.2\u0026#34; 代码解析 use wasm_bindgen::prelude::*;:导入了 wasm_bindgen 库的预导入(prelude)模块,它包含了一些常用的宏和类型。这样可以方便地在代码中使用 wasm_bindgen 提供的功能。 #[wasm_bindgen]:这是一个属性宏,用于标记下面的函数和外部接口需要与 JavaScript 进行绑定 extern 块:在 extern 块内部使用 pub fn alert(s: \u0026amp;str) 定义了一个外部函数接口。这个函数声明了一个参数 s,类型为字符串引用(\u0026amp;str)。 从 Rust 调用 JavaScript 中的外部函数 pub fn greet(name: \u0026amp;str):这是一个公共函数 greet 的定义,它接受一个字符串引用参数 name。生成 JavaScript 可以调用的 Rust 函数 alert(\u0026amp;format!(\u0026quot;Hello, {}!\u0026quot;, name)):在 greet 函数中调用了外部接口函数 alert。它使用 format! 宏将传入的 name 参数插入到格式化的字符串中,然后调用 alert 函数显示警告框,内容为拼接好的问候信息。 toml 解析 [lib] 告诉编译器,将要编译的类型是c 语言的动态类型 库\n编译 wasm-pack build --target web 请注意这步很慢,耗时会很久,请去喝茶。 编译完成之后,出现pkg 包,里面提供了我们项目名的mywasm_bg.wasm\n1 2 3 4 5 6 7 total 38 -rw-r--r-- 1 zhangbl 197121 1116 Aug 16 17:39 mywasm.d.ts -rw-r--r-- 1 zhangbl 197121 4910 Aug 16 17:39 mywasm.js -rw-r--r-- 1 zhangbl 197121 2503 Aug 16 16:03 mywasm_bg.js -rw-r--r-- 1 zhangbl 197121 17307 Aug 16 18:13 mywasm_bg.wasm -rw-r--r-- 1 zhangbl 197121 287 Aug 16 17:39 mywasm_bg.wasm.d.ts -rw-r--r-- 1 zhangbl 197121 213 Aug 16 18:13 package.json 在项目目录下,新建index.html 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;hello-wasm example\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import init, { greet } from \u0026#34;./pkg/mywasm.js\u0026#34;; init().then(() =\u0026gt; { greet(\u0026#34;WebAssembly\u0026#34;) }); \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 运行 python -m http.server 启动一个简单的web 服务, 让后访问 http://localhost:8000 Go 如何使用WASM (未完待续) Hello world 新建main.go, 使用 js.Global().get(‘alert’) 获取全局的 alert 对象,通过 Invoke 方法调用。等价于在 js 中调用 window.alert(\u0026ldquo;Hello World\u0026rdquo;)。 1 2 3 4 5 6 7 8 9 package main import \u0026#34;syscall/js\u0026#34; func main() { alert := js.Global().Get(\u0026#34;alert\u0026#34;) alert.Invoke(\u0026#34;Hello World!\u0026#34;) } 将 main.go 编译为 static/main.wasm GOOS=js GOARCH=wasm go build -o static/main.wasm\n拷贝 wasm_exec.js (JavaScript 支持文件,加载 wasm 文件时需要) 到 static 文件夹 cp \u0026quot;$(go env GOROOT)/misc/wasm/wasm_exec.js\u0026quot; static\n创建 index.html,引用 static/main.wasm 和 static/wasm_exec.js。\n1 2 3 4 5 6 7 8 9 \u0026lt;html\u0026gt; \u0026lt;script src=\u0026#34;static/wasm_exec.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script\u0026gt; const go = new Go(); WebAssembly.instantiateStreaming(fetch(\u0026#34;static/main.wasm\u0026#34;), go.importObject) .then((result) =\u0026gt; go.run(result.instance)); \u0026lt;/script\u0026gt; \u0026lt;/html\u0026gt; ","permalink":"https://reid00.github.io/en/posts/langs_linux/webassemblywasm-%E6%95%99%E7%A8%8B/","summary":"WebAssembly 简介 WebAssembly (有时缩写为 Wasm)为可执行程序定义了一种可移植的二进制代码格式和相应的文本格式以及软件接口,用于促进这些程序与其宿主环境之间的交","title":"WebAssembly(Wasm) 教程"},{"content":"磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化的目的就是为了提高系统的吞吐量,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数。\n这次,我们就以「文件传输」作为切入点,来分析 I/O 工作方式,以及如何优化传输文件的性能。\n为什么要有 DMA 技术? 在没有 DMA 技术前,I/O 的过程是这样的:\nCPU 发出对应的指令给磁盘控制器,然后返回; 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断; CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。 为了方便你理解,我画了一副图: 可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。\n简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。\n计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。\n什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。\n那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。 具体过程:\n用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态; 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务; DMA 进一步将 I/O 请求发送给磁盘; 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满; DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务; 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU; CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回; 可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。\n早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。\n传统的文件传输有多糟糕? 如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。\n传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。\n代码通常如下,一般会需要两个系统调用:\n1 2 read(file, tmp_buf, len); write(socket, tmp_buf, len); 代码很简单,虽然就两行代码,但是这里面发生了不少的事情。 首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。\n上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。\n其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:\n第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。 我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。\n这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。\n所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。\n如何优化文件传输的性能? 如何减少「用户态与内核态的上下文切换」的次数呢? 读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。\n而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。\n所以,要想减少上下文切换到次数,就要减少系统调用的次数。\n如何减少「数据拷贝」的次数 在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。\n因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。\n如何实现零拷贝? 零拷贝技术实现的方式通常有 2 种:\nmmap + write sendfile 下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。 mmap + write 在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。\n1 2 buf = mmap(file, len); write(sockfd, buf, len); mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。 具体过程如下:\n应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。 我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。\n但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。\nsendfile 在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:\n1 2 #include \u0026lt;sys/socket.h\u0026gt; ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); 它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。\n首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。\n其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图: 但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。\n你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:\n1 2 $ ethtool -k eth0 | grep scatter-gather scatter-gather: on 于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:\n第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里; 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝; 所以,这个过程之中,只进行了 2 次数据拷贝,如下图: 这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。\n零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。\n所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。\n使用零拷贝技术的项目 事实上,Kafka 这个开源项目,就利用了「零拷贝」技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一。\n如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo 方法:\n1 2 3 4 @Overridepublic long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { return fileChannel.transferTo(position, count, socketChannel); } 如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。\n曾经有大佬专门写过程序测试过,在同样的硬件条件下,传统文件传输和零拷拷贝文件传输的性能差异,你可以看到下面这张测试数据图,使用了零拷贝能够缩短 65% 的时间,大幅度提升了机器传输数据的吞吐量。\n另外,Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:\n1 2 3 4 5 http { ... sendfile on ... } sendfile 配置的具体意思:\n设置为 on 表示,使用零拷贝技术来传输文件:sendfile ,这样只需要 2 次上下文切换,和 2 次数据拷贝。 设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换,和 4 次数据拷贝。 当然,要使用 sendfile,Linux 内核版本必须要 2.1 以上的版本。 PageCache 有什么作用? 回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。\n由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。\n读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。\n但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。\n那问题来了,选择哪些磁盘数据拷贝到内存呢?\n我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。\n所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。\n还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。\n比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。\n所以,PageCache 的优点主要是两个:\n缓存最近被访问的数据; 预读功能; 这两个做法,将大大提高读写磁盘的性能。\n但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能\n这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。\n另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:\nPageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了; PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次; 所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。\n大文件传输用什么方式实现? 那针对大文件的传输,我们应该使用什么方式呢?\n我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图: 具体过程:\n当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好; 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里; 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。\n对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图: 它把读操作分为两部分:\n前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务; 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据; 而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。 绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。\n前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。\n于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。\n直接 I/O 应用场景常见的两种:\n应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启; 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。 另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:\n内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作; 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作; 于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。\n所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:\n传输大文件的时候,使用「异步 I/O + 直接 I/O」; 传输小文件的时候,则使用「零拷贝技术」; 在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:\n1 2 3 4 5 location /video/ { sendfile on; aio on; directio 1024m; } 当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。\n总结 早期 I/O 操作,内存与磁盘的数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。\n于是,为了解决这一问题,DMA 技术就出现了,每个 I/O 设备都有自己的 DMA 控制器,通过这个 DMA 控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的工作。\n传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。\n为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。\nKafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。\n零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。\n需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。\n另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。\n在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E9%9B%B6%E6%8B%B7%E8%B4%9D%E6%8A%80%E6%9C%AF/","summary":"磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化","title":"零拷贝技术"},{"content":"别小看这两个东西,特别是 Reactor 模式,市面上常见的开源软件很多都采用了这个方案,比如 Redis、Nginx、Netty 等等,所以学好这个模式设计的思想,有助于我们理解很多开源软件。\n演进 如果要让服务器服务多个客户端,那么最直接的方式就是为每一条连接创建线程。\n其实创建进程也是可以的,原理是一样的,进程和线程的区别在于线程比较轻量级些,线程的创建和线程间切换的成本要小些,为了描述简述,后面都以线程为例。\n处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来性能开销,也会造成浪费资源,而且如果要连接几万条连接,创建几万个线程去应对也是不现实的。\n要这么解决这个问题呢?我们可以使用「资源复用」的方式。\n也就是不用再为每个连接创建线程,而是创建一个「线程池」,将连接分配给线程,然后一个线程可以处理多个连接的业务。\n不过,这样又引来一个新的问题,线程怎样才能高效地处理多个连接的业务?\n当一个连接对应一个线程时,线程一般采用「read -\u0026gt; 业务处理 -\u0026gt; send」的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O),不过这种阻塞方式并不影响其他线程。\n但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。\n要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 read 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题,但是解决的方式比较粗暴,因为轮询是要消耗 CPU 的,而且随着一个 线程处理的连接越多,轮询的效率就会越低。\n上面的问题在于,线程并不知道当前连接是否有数据可读,从而需要每次通过 read 去试探。\n那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这一技术的就是 I/O 多路复用。\nI/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接。\n我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。\nselect/poll/epoll 是如何获取网络事件的呢?\n在获取事件时,先把我们要关心的连接传给内核,再由内核检测:\n如果没有事件发生,线程只需阻塞在这个系统调用,而无需像前面的线程池方案那样轮训调用 read 操作来判断是否有数据。 如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。 当下开源软件能做到网络高性能的原因就是 I/O 多路复用吗?\n是的,基本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,肯定知道是面向过程的方式写代码的,这样的开发的效率不高。\n于是,大佬们基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。\n大佬们还为这种模式取了个让人第一时间难以理解的名字:Reactor 模式。\nReactor 翻译过来的意思是「反应堆」,可能大家会联想到物理学里的核反应堆,实际上并不是的这个意思。\n这里的反应指的是「对事件反应」,也就是来了一个事件,Reactor 就有相对应的反应/响应。\n事实上,Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。\nReactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:\nReactor 负责监听和分发事件,事件类型包含连接事件、读写事件; 处理资源池负责处理事件,如 read -\u0026gt; 业务逻辑 -\u0026gt; send; Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:\nReactor 的数量可以只有一个,也可以有多个; 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程; 将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择:\n单 Reactor 单进程 / 线程; 单 Reactor 多进程 / 线程; 多 Reactor 单进程 / 线程; 多 Reactor 多进程 / 线程; 其中,「多 Reactor 单进程 / 线程」实现方案相比「单 Reactor 单进程 / 线程」方案,不仅复杂而且也没有性能优势,因此实际中并没有应用。\n剩下的 3 个方案都是比较经典的,且都有应用在实际的项目中:\n单 Reactor 单进程 / 线程; 单 Reactor 多线程 / 进程; 多 Reactor 多进程 / 线程; 方案具体使用进程还是线程,要看使用的编程语言以及平台有关: Java 语言一般使用线程,比如 Netty; C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。 接下来,分别介绍这三个经典的 Reactor 方案。\nReactor 单 Reactor 单进程 / 线程 一般来说,C 语言实现的是「单 Reactor 单进程」的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。\n而 Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。\n我们来看看「单 Reactor 单进程」的方案示意图: 可以看到进程里有 Reactor、Acceptor、Handler 这三个对象:\nReactor 对象的作用是监听和分发事件; Acceptor 对象的作用是获取连接; Handler 对象的作用是处理业务; 对象里的 select、accept、read、send 是系统调用函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。\n接下来,介绍下「单 Reactor 单进程」这个方案:\nReactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型; 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件; 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应; Handler 对象通过 read -\u0026gt; 业务处理 -\u0026gt; send 的流程来完成完整的业务流程。 单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。\n但是,这种方案存在 2 个缺点:\n第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能; 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟; 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能; 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟; 所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。\nRedis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。\n单 Reactor 多线程 / 多进程 如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了单 Reactor 多线程 / 多进程的方案。\n闻其名不如看其图,先来看看「单 Reactor 多线程」方案的示意图如下: 详细说一下这个方案:\nReactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型; 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件; 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应; 上面的三个步骤和单 Reactor 单线程方案是一样的,接下来的步骤就开始不一样了:\nHandler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理; 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client; 单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能力,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。\n例如,子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。\n要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。\n聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案。\n事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 \u0026lt;-\u0026gt; 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。\n而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。\n另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。\n多 Reactor 多进程 / 线程 要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多进程 / 线程的方案。\n老规矩,闻其名不如看其图。多 Reactor 多进程 / 线程方案的示意图如下(以线程为例): 方案详细说明如下:\n主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程; 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。 Handler 对象通过 read -\u0026gt; 业务处理 -\u0026gt; send 的流程来完成完整的业务流程。 多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:\n主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。 大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。\n采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。\n具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。\nProactor 前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式。\n这里先给大家复习下阻塞、非阻塞、同步、异步 I/O 的概念。\n先来看看阻塞 I/O,当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。\n注意,阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。过程如下图: 知道了阻塞 I/O ,来看看非阻塞 I/O,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。过程如下图: 注意,这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。\n举个例子,如果 socket 设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。\n因此,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。\n而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。\n当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图: 举个你去饭堂吃饭的例子,你好比应用程序,饭堂好比操作系统。 阻塞 I/O 好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。\n非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。\n异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。\n很明显,异步 I/O 比同步 I/O 性能更好,因为异步 I/O 在「内核数据准备好」和「数据从内核空间拷贝到用户空间」这两个过程都不用等待。\nProactor 正是采用了异步 I/O 技术,所以被称为异步网络模型。\n现在我们再来理解 Reactor 和 Proactor 的区别,就比较清晰了。\nReactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。 Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。 因此,Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。\n举个实际生活中的例子,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。\n无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。\n接下来,一起看看 Proactor 模式的示意图: 介绍一下 Proactor 模式的工作流程:\nProactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核; Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作; Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor; Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理; Handler 完成业务处理; 可惜的是,在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。\n而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。\n总结 常见的 Reactor 实现方案有三种。\n第一种方案单 Reactor 单进程 / 线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种方案的缺陷在于无法充分利用多核 CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于计算机密集型的场景,适用于业务处理快速的场景,比如 Redis(6.0之前 ) 采用的是单 Reactor 单进程的方案。 第二种方案单 Reactor 多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个 Reactor 对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。 第三种方案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了方案二的缺陷,主 Reactor 只负责监听事件,响应事件的工作交给了从 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,Nginx 则采用了类似于 「多 Reactor 多进程」的方案。 Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。\n因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 感知到事件后,还需要调用 read 来从内核中获取数据。\n不过,无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E9%AB%98%E6%80%A7%E8%83%BD%E7%BD%91%E7%BB%9C%E6%A8%A1%E5%BC%8F-reactor-proactor/","summary":"别小看这两个东西,特别是 Reactor 模式,市面上常见的开源软件很多都采用了这个方案,比如 Redis、Nginx、Netty 等等,所以学好这个模式设计的","title":"高性能网络模式: Reactor Proactor"},{"content":"最基本的 Socket 模型 要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。\nSocket 的中文名叫作插口,咋一看还挺迷惑的。事实上,双方要进行网络通信前,各自得创建一个 Socket,这相当于客户端和服务器都开了一个“口子”,双方读取和发送数据的时候,都通过这个“口子”。这样一看,是不是觉得很像弄了一根网线,一头插在客户端,一头插在服务端,然后进行通信。\n创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。\nUDP 的 Socket 编程相对简单些,这里我们只介绍基于 TCP 的 Socket 编程。\n服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 Socket 编程过程是怎样的。\n服务端首先调用 socket() 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口,绑定这两个的目的是什么?\n绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们。 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们; 绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听。\n服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。\n那客户端是怎么发起连接的呢?客户端在创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。\n在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:\n一个是「还没完全建立」连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态; 一个是「已经建立」连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态; 当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。\n注意,监听的 Socket 和真正用来传数据的 Socket 是两个:\n一个叫作监听 Socket; 一个叫作已连接 Socket; 连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read() 和 write() 函数来读写数据。 至此, TCP 协议的 Socket 程序的调用过程就结束了,整个过程如下图: 看到这,不知道你有没有觉得读写 Socket 的方式,好像读写文件一样。\n是的,基于 Linux 一切皆文件的理念,在内核中 Socket 也是以「文件」的形式存在的,也是有对应的文件描述符。\n内核部分 - 文件描述符 文件描述符的作用是什么?每一个进程都有一个数据结构 task_struct,该结构体里有一个指向「文件描述符数组」的成员指针。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件。\n然后每个文件都有一个 inode,Socket 文件的 inode 指向了内核中的 Socket 结构,在这个结构体里有两个队列,分别是发送队列和接收队列,这个两个队列里面保存的是一个个 struct sk_buff,用链表的组织形式串起来。\nsk_buff 可以表示各个层的数据包,在应用层数据包叫 data,在 TCP 层我们称为 segment,在 IP 层我们叫 packet,在数据链路层称为 frame。\n你可能会好奇,为什么全部数据包只用一个结构体来描述呢?协议栈采用的是分层结构,上层向下层传递数据时需要增加包头,下层向上层数据时又需要去掉包头,如果每一层都用一个结构体,那在层之间传递数据的时候,就要发生多次拷贝,这将大大降低 CPU 效率。\n于是,为了在层级之间传递数据时,不发生拷贝,只用 sk_buff 一个结构体来描述所有的网络包,那它是如何做到的呢?是通过调整 sk_buff 中 data 的指针,比如:\n当接收报文时,从网卡驱动开始,通过协议栈层层往上传送数据报,通过增加 skb-\u0026gt;data 的值,来逐步剥离协议首部。\n当要发送报文时,创建 sk_buff 结构体,数据缓存区的头部预留足够的空间,用来填充各层首部,在经过各下层协议时,通过减少 skb-\u0026gt;data 的值来增加协议首部。 你可以从下面这张图看到,当发送报文时,data 指针的移动过程。 如何服务更多的用户? 前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。\n可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。\n在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端?\n相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口。\n服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数。\n对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。\n这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:\n文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目; 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的; 那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗? 并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。\n从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。\n不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。\n多进程模型 基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。\n服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。\n这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。\n正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,\n可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。\n下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。 另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。\n因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数。\n这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。\n进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。\n多线程模型 既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。\n线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。\n当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。\n如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。\n那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。 需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。\n上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。\nI/O 多路复用 既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。 一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。\n我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。\nselect/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。\nselect/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。\nselect/poll select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。\n所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。\nselect 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。\npoll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。\n但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。\nepoll 先复习下 epoll 的用法。如下的代码中,先用e poll_create 创建一个 epoll 对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。\n1 2 3 4 5 6 7 8 9 10 11 12 13 int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...); listen(s, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 while(1) { int n = epoll_wait(...); for(接收到数据的socket){ //处理 } } epoll 通过两个方面,很好解决了 select/poll 的问题。\n第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。 第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。 从下图你可以看到 epoll 相关的接口作用: epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。\n插个题外话,网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。\n这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。 好了,这个题外话就说到这了,我们继续!\n边缘触发和水平触发 epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。\n这两个术语还挺抽象的,其实它们的区别还是很好理解的。\n使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完; 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取; 举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。\n这就是两者的区别: 水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。 如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。\n如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。 一般来说,边缘触发的效率比水平触发的效率要高`,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。\nselect/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。\n另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明:\nUnder Linux, select() may report a socket file descriptor as \u0026ldquo;ready for reading\u0026rdquo;, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.\n我谷歌翻译的结果:\n在Linux下,select() 可能会将一个 socket 文件描述符报告为 \u0026ldquo;准备读取\u0026rdquo;,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。\n简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。\n总结 最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。\n比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。\n为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是:select、poll、epoll。\nselect 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。\n在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。\n很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。\nepoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。\nepoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。 epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。 而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。\n","permalink":"https://reid00.github.io/en/posts/os_network/io-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8/","summary":"最基本的 Socket 模型 要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。 Socket 的中","title":"IO 多路复用"},{"content":"文件系统 文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。\n文件系统的基本数据单位是文件,它的目的是对磁盘上的文件进行组织管理,那组织的方式不同,就会形成不同的文件系统。\nLinux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。\nLinux 文件系统会为每个文件分配两个数据结构:Inode(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。\nInode,也就是inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。Inode是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以Inode同样占用磁盘空间。\n目录项,也就是dentry,用来记录文件的名字、Inode指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与Inode不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。\n由于Inode唯一标识一个文件,而目录项记录着文件的名,所以目录项和Inode的关系是多对一,也就是说,一个文件可以有多个目录。比如,硬链接的实现就是多个目录项中的Inode指向同一个文件。\n注意,目录也是文件,也是用Inode唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。\n目录项和目录是一个东西吗? 虽然名字很相近,但是它们不是一个东西,目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。\n如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。\n注意,目录项这个数据结构不只是表示目录,也是可以表示文件的。\n## 文件数据是如何存储在磁盘的呢? 磁盘读写的最小单位是扇区,扇区的大小只有 512字节,那么如果数据大于512字节时候,磁盘需要不停地移动磁头来查找数据,我们知道一般的文件很容易超过512字节那么如果把多个扇区合并为一个块,那么磁盘就可以提高效率了。那么磁头一次读取多个扇区就为一个块“block”(Linux上称为块,Windows上称为簇)。所以,文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区,这将大大提高了磁盘的读写的效率。 sector size \u0026lt;= block size \u0026lt;= memory page size\n文件系统记录的数据,除了其自身外,还有数据的权限信息,所有者等属性,这些信息都保存在inode中,那么谁来记录inode信息和文件系统本身的信息呢,比如说文件系统的格式,inode与data的数量呢?那么就有一个超级区块(supper block)来记录这些信息了。\nsuperblock:记录此 filesystem 的整体信息,包括inode/block的总量、使用量、剩余量, 以及文件系统的格式与相关信息等 inode:记录文件的属性信息,可以使用stat命令查看inode信息。 block:实际文件的内容,如果一个文件大于一个块时候,那么将占用多个block,但是一个块只能存放一个文件。(因为数据是由inode指向的,如果有两个文件的数据存放在同一个块中,就会乱套了) Inode用来指向数据block,那么只要找到inode,再由inode找到block编号,那么实际数据就能找出来了。\nInode是存储在硬盘上的数据,为了加速文件的访问,通常会把Inode加载到内存中。我们不可能把超级块和Inode区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:\n超级块:当文件系统挂载时进入内存; Inode区:当文件被访问时进入内存; 虚拟文件系统 文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(Virtual File System,VFS)。VFS 定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解 VFS 提供的统一接口即可。在 Linux 文件系统中,用户空间、系统调用、虚拟机文件系统、缓存、文件系统以及存储之间的关系如下图: Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:\n磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的/proc 和 /sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据。 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。 文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录。\nLinux 采用为分层的体系结构,将用户接口层、文件系统实现和存储设备的驱动程序分隔开,进而兼容不同的文件系统。虚拟文件系统(Virtual File System, VFS)是 Linux 内核中的软件层,它在内核中提供了一组标准的、抽象的文件操作,允许不同的文件系统实现共存,并向用户空间程序提供统一的文件系统接口。下面这张图展示了 Linux 虚拟文件系统的整体结构: 从上图可以看出,用户空间的应用程序直接、或是通过编程语言提供的库函数间接调用内核提供的 System Call 接口(如open()、write()等)执行文件操作。System Call 接口再将应用程序的参数传递给虚拟文件系统进行处理。\n每个文件系统都为 VFS 实现了一组通用接口,具体的文件系统根据自己对磁盘上数据的组织方式操作相应的数据。当应用程序操作某个文件时,VFS 会根据文件路径找到相应的挂载点,得到具体的文件系统信息,然后调用该文件系统的对应操作函数。\nVFS 提供了两个针对文件系统对象的缓存 INode Cache 和 DEntry Cache,它们缓存最近使用过的文件系统对象,用来加快对 INode 和 DEntry 的访问。Linux 内核还提供了 Buffer Cache 缓冲区,用来缓存文件系统和相关块设备之间的请求,减少访问物理设备的次数,加快访问速度。Buffer Cache 以 LRU 列表的形式管理缓冲区。\nVFS 的好处是实现了应用程序的文件操作与具体的文件系统的解耦,使得编程更加容易:\n应用层程序只要使用 VFS 对外提供的read()、write()等接口就可以执行文件操作,不需要关心底层文件系统的实现细节; 文件系统只需要实现 VFS 接口就可以兼容 Linux,方便移植与维护; 无需关注具体的实现细节,就实现跨文件系统的文件操作。 了解 Linux 文件系统的整体结构后,下面主要分析 Linux VFS 的技术原理。由于文件系统与设备驱动的实现非常复杂,笔者也未接触过这方面的内容,因此文中不会涉及具体文件系统的实现。\nVFS 接口 Linux 以一组通用对象的角度看待所有文件系统,每一级对象之间的关系如下图所示: fd 和 file 每个进程都持有一个fd[]数组,数组里面存放的是指向file结构体的指针,同一进程的不同fd可以指向同一个file对象;\nfile是内核中的数据结构,表示一个被进程打开的文件,和进程相关联。当应用程序调用open()函数的时候,VFS 就会创建相应的file对象。它会保存打开文件的状态,例如文件权限、路径、偏移量等等。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L936 结构体已删减 struct file { struct path f_path; struct inode *f_inode; const struct file_operations *f_op; unsigned int f_flags; fmode_t f_mode; loff_t f_pos; struct fown_struct f_owner; } // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/path.h#L8 struct path { struct vfsmount *mnt; struct dentry *dentry; } 从上面的代码可以看出,文件的路径实际上是一个指向 DEntry 结构体的指针,VFS 通过 DEntry 索引到文件的位置。\n除了文件偏移量f_pos是进程私有的数据外,其他的数据都来自于 INode 和 DEntry,和所有进程共享。不同进程的file对象可以指向同一个 DEntry 和 Inode,从而实现文件的共享。\nDEntry Inode Linux文件系统会为每个文件都分配两个数据结构,目录项(DEntry, Directory Entry)和索引节点(INode, Index Node)。\nDEntry 用来保存文件路径和 INode 之间的映射,从而支持在文件系统中移动。DEntry 由 VFS 维护,所有文件系统共享,不和具体的进程关联。dentry对象从根目录“/”开始,每个dentry对象都会持有自己的子目录和文件,这样就形成了文件树。举例来说,如果要访问”/home/beihai/a.txt”文件并对他操作,系统会解析文件路径,首先从“/”根目录的dentry对象开始访问,然后找到”home/“目录,其次是“beihai/”,最后找到“a.txt”的dentry结构体,该结构体里面d_inode字段就对应着该文件。\n1 2 3 4 5 6 7 8 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/dcache.h#L89 结构体已删减 struct dentry { struct dentry *d_parent; // 父目录 struct qstr d_name; // 文件名称 struct inode *d_inode; // 关联的 inode struct list_head d_child; // 父目录中的子目录和文件 struct list_head d_subdirs; // 当前目录中的子目录和文件 } 每一个dentry对象都持有一个对应的inode对象,表示 Linux 中一个具体的目录项或文件。INode 包含管理文件系统中的对象所需的所有元数据,以及可以在该文件对象上执行的操作。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L628 结构体已删减 struct inode { umode_t i_mode; // 文件权限及类型 kuid_t i_uid; // user id kgid_t i_gid; // group id const struct inode_operations *i_op; // inode 操作函数,如 create,mkdir,lookup,rename 等 struct super_block *i_sb; // 所属的 SuperBlock loff_t i_size; // 文件大小 struct timespec i_atime; // 文件最后访问时间 struct timespec i_mtime; // 文件最后修改时间 struct timespec i_ctime; // 文件元数据最后修改时间(包括文件名称) const struct file_operations *i_fop; // 文件操作函数,open、write 等 void *i_private; // 文件系统的私有数据 } 虚拟文件系统维护了一个 DEntry Cache 缓存,用来保存最近使用的 DEntry,加速查询操作。当调用open()函数打开一个文件时,内核会第一时间根据文件路径到 DEntry Cache 里面寻找相应的 DEntry,找到了就直接构造一个file对象并返回。如果该文件不在缓存中,那么 VFS 会根据找到的最近目录一级一级地向下加载,直到找到相应的文件。期间 VFS 会缓存所有被加载生成的dentry。\nINode 存储的数据存放在磁盘上,由具体的文件系统进行组织,当需要访问一个 INode 时,会由文件系统从磁盘上加载相应的数据并构造 INode。一个 INode 可能被多个 DEntry 所关联,即相当于为某一文件创建了多个文件路径(通常是为文件建立硬链接)。\n目录项介绍 Ext4文件系统目录项有两种实现方式:\n线性方式 该方式的目录项以ext4_dir_entry_2的结构一个接连一个直接存储在目录结点所指向的block块中。(缺省配置使用ext4_dir_entry_2这个结构) Hash树的方式 若目录下的文件数量很多,则若按照线性方式查找对应文件名的信息则会很低效。Hash树的方式,则可以用文件名来做hash计算,从而定位到对应文件的目录项结构所在的block,从而缩小查找范围、加快查找效率。 查看文件系统是否开启了方式二的目录管理方式 1 2 ➜ nebula-tool tune2fs -l /dev/vda1 | grep dir_index Filesystem features: has_journal ext_attr resize_inode dir_index filetype needs_recovery extent 64bit flex_bg sparse_super huge_file uninit_bg dir_nlink extra_isize hash树管理方式的打开和关闭 1 2 3 4 5 6 7 8 9 10 11 12 13 root@f303server:~# tune2fs -O ^dir_index /dev/sde3 tune2fs 1.42.11 (09-Jul-2014) root@f303server:~# tune2fs -l /dev/sde3 | grep dir_index # 查找不到了 root@f303server:~# tune2fs -O dir_index /dev/sde3 tune2fs 1.42.11 (09-Jul-2014) root@f303server:~# tune2fs -l /dev/sde3 | grep dir_index Filesystem features: has_journal ext_attr resize_inode dir_index filetype needs_recovery extent flex_bg sparse_super large_file huge_file uninit_bg dir_nlink extra_isize 怎么判断某目录是否以hash树的方式管理 文件系统开启了hash树管理目录结点的方式并不意味着所有的目录都按树形结构组织管理。在文件系统中,系统会根据某个目录底下文件的多少来自动进行目录管理方式的选择,只有当文件数量大于某个数时,才会采用hash树管理方式。\n怎么判断某目录的管理方式是那种? 若不是hash树的管理方式,则htree中debugfs中则会有如下提示\n1 2 3 4 5 ➜ nebula-tool debugfs debugfs 1.42.9 (28-Dec-2013) debugfs: htree htree: Filesystem not open debugfs: 若是hash 树,则展示:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Root node dump: Reserved zero: 0 Hash Version: 1 Info length: 8 Indirect levels: 0 Flags: 0 Number of entries (count): 2 Number of entries (limit): 508 Entry #0: Hash 0x00000000, block 1 Entry #1: Hash 0x775173ee, block 2 Entry #0: Hash 0x00000000, block 1 Reading directory block 1, phys 3154297 791969 0x33788c78-7df72ede (20) rtmutex.c 791971 0x12e2688e-f00920c3 (28) .latencytop.o.cmd 791973 0x6d76fd8a-f7dad208 (16) futex.o 791974 0x1f1d389c-0beb6325 (20) ns_cgroup.c 791975 0x21f726a2-367f43fb (24) .built-in.o.cmd 791977 0x2a43c4ba-ae0695eb (16) itimer.o 791978 0x6139ce78-4032f3c2 (16) user.c ext4_dir_entry_2 struct 参数 上表我们可以看到该结构中共5个数据项,前四项占8byte, 通过根目录为例,通过hexdump查看其二进制代码,则“目录项的长度(rec_len)= 文件名长度(name_len) + 8 ”。但是在很多情况下rec_len \u0026gt; = name_len + 8。原因是因为目录项每一项的起始位置必须按照后两位 00 对齐。故有时候会浪费几个字节。\n由图可知,在目录文件的数据块中存储了其下的文件名、目录名、目录本身的相对名称\u0026quot;.\u0026quot;和上级目录的相对名称\u0026quot;..\u0026quot;,还存储了这些文件名对应的inode号、目录项长度rec_len、文件名长度name_len和文件类型file_type。注意到除了文件本身的inode记录了文件类型,其所在的目录的数据块也记录了文件类型。由于rec_len只能是4的倍数,所以需要使用\u0026quot;\\0\u0026quot;来填充name_len不够凑满4倍数的部分。至于rec_len具体是什么,只需知道它是一种偏移即可。\n需要注意的是,inode table中的inode自身并没有存储每个inode的inode号,它是存储在目录的data block中的,通过inode号可以计算并索引到inode table中该inode号对应的inode记录,可以认为这个inode号是一个inode指针 (当然,并非真的是指针,但有助于理解通过inode号索引找到对应inode的这个过程,后文将在需要的时候使用inode指针这个词来表示inode号。至此,已经知道了两种指针:一种是inode table中每个inode记录指向其对应data block的block指针,一个此处的“inode指针”)。\n除了inode号,目录的data block中还使用数字格式记录了文件类型,数字格式和文件类型的对应关系如下图。 注意到目录的data block中前两行存储的是目录本身的相对名称\u0026quot;.\u0026ldquo;和上级目录的相对名称\u0026rdquo;..\u0026quot;,它们实际上是目录本身的硬链接和上级目录的硬链接。硬链接的本质后面说明。\n前面提到过,inode结构自身并没有保存inode号(同样,也没有保存文件名),那么inode号保存在哪里呢?目录的data block中保存了该目录中每个文件的inode号。\n另一个问题,既然inode中没有inode号,那么如何根据目录data block中的inode号找到inode table中对应的inode呢?\n实际上,只要有了inode号,就可以计算出inode表中对应该inode号的inode结构。在创建文件系统的时候,每个块组中的起始inode号以及inode table的起始地址都已经确定了,所以只要知道inode号,就能知道这个inode号和该块组起始inode号的偏移数量,再根据每个inode结构的大小(256字节或其它大小),就能计算出来对应的inode结构。\n所以,目录的data block中的inode number和inode table中的inode是通过计算的方式一一映射起来的。从另一个角度上看,目录data block中的inode number是找到inode table中对应inode记录的唯一方式。\n考虑一种比较特殊的情况:目录data block的记录已经删除,但是该记录对应的inode结构仍然存在于inode table中。这种inode称为孤儿inode(orphan inode):存在于inode table中,但却无法再索引到它。因为目录中已经没有该inode对应的文件记录了,所以其它进程将无法找到该inode,也就无法根据该inode找到该文件之前所占用的data block,这正是创建便删除所实现的真正临时文件,该临时文件只有当前进程和子进程才能访问。\nSuperBlock SuperBlock 表示特定加载的文件系统,用于描述和维护文件系统的状态,由 VFS 定义,但里面的数据根据具体的文件系统填充。每个 SuperBlock 代表了一个具体的磁盘分区,里面包含了当前磁盘分区的信息,如文件系统类型、剩余空间等。SuperBlock 的一个重要成员是链表s_list,包含所有修改过的 INode,使用该链表很容易区分出来哪个文件被修改过,并配合内核线程将数据写回磁盘。SuperBlock 的另一个重要成员是s_op,定义了针对其 INode 的所有操作方法,例如标记、释放索引节点等一系列操作。\n1 2 3 4 5 6 7 8 9 10 11 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L1425 结构体已删减 struct super_block { struct list_head s_list; // 指向链表的指针 dev_t s_dev; // 设备标识符 unsigned long s_blocksize; // 以字节为单位的块大小 loff_t s_maxbytes; // 文件大小上限 struct file_system_type *s_type; // 文件系统类型 const struct super_operations *s_op; // SuperBlock 操作函数,write_inode、put_inode 等 const struct dquot_operations *dq_op; // 磁盘限额函数 struct dentry *s_root; // 根目录 } SuperBlock 是一个非常复杂的结构,通过 SuperBlock 我们可以将一个实体文件系统挂载到 Linux 上,或者对 INode 进行增删改查操作。所以一般文件系统都会在磁盘上存储多份 SuperBlock,防止数据意外损坏导致整个分区无法读取。\nInode Inode包含很多的文件元信息,但不包含文件名,例如:字节数、属主UserID、属组GroupID、读写执行权限、时间戳等。而文件名存放在目录当中,但Linux系统内部不使用文件名,而是使用inode号码识别文件。对于系统来说文件名只是inode号码便于识别的别称。\nStat 查看Inode\n1 2 3 4 5 6 7 8 9 10 11 12 [root@localhost ~]# mkdir test [root@localhost ~]# echo \u0026#34;this is test file\u0026#34; \u0026gt; test.txt [root@localhost ~]# stat test.txt File: ‘test.txt’ Size: 18 Blocks: 8 IO Block: 4096 regular file Device: fd00h/64768d Inode: 33574994 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root) Context: unconfined_u:object_r:admin_home_t:s0 Access: 2019-08-28 19:55:05.920240744 +0800 Modify: 2019-08-28 19:55:05.920240744 +0800 Change: 2019-08-28 19:55:05.920240744 +0800 Birth: - 三个主要的时间属性:\nctime:change time是最后一次改变文件或目录(属性)的时间,例如执行chmod,chown等命令。 atime:access time是最后一次访问文件或目录的时间。 mtime:modify time是最后一次修改文件或目录(内容)的时间。 file 1 2 3 4 [root@localhost ~]# file test test: directory [root@localhost ~]# file test.txt test.txt: ASCII text Inode Number 表面上,用户通过文件名打开文件,实际上,系统内部将这个过程分为三步:\n系统找到这个文件名对应的inode号码; 通过inode号码,获取inode信息; 根据inode信息,找到文件数据所在的block,并读出数据。 其实系统还要根据inode信息,看用户是否具有访问的权限,有就指向对应的数据block,没有就返回权限拒绝。\n直接查看文件i节点号,也可以通过stat查看文件inode信息查看i节点号。\n1 2 [root@localhost ~]# ls -i 33574991 anaconda-ks.cfg 2086 test 33574994 test.txt Inode 大小 inode也会消耗硬盘空间,所以格式化的时候,操作系统自动将硬盘分成两个区域。一个是数据区,存放文件数据;另一个是inode区,存放inode所包含的信息。每个inode的大小,一般是128字节或256字节。通常情况下不需要关注单个inode的大小,而是需要重点关注inode总数。inode总数在格式化的时候就确定了。\ndf -i 查看硬盘分区的inode总数和已使用情况\n1 2 3 4 5 6 7 8 9 10 11 [root@***]# df -i Filesystem Inodes IUsed IFree IUse% Mounted on devtmpfs 16219999 413 16219586 1% /dev tmpfs 16222589 2 16222587 1% /dev/shm tmpfs 16222589 602 16221987 1% /run tmpfs 16222589 16 16222573 1% /sys/fs/cgroup /dev/vda1 2621440 122606 2498834 5% / /dev/vdb1 131072000 134633 130937367 1% /mnt tmpfs 16222589 22 16222567 1% /run/user/0 overlay 2621440 122606 2498834 5% /var/lib/docker/overlay2/2e836ead8a69c7413ec89faecf1357479a6df9ba1e515056d9c89bb121e6fba1/merged shm 16222589 1 16222588 1% /var/lib/docker/containers/1713b72ff979243ef1d36d0a5aaf6c79989a75b267531d341665a7e432fd5a09/shm 文件的读写 文件系统在打开一个文件时,要做的有:\n系统找到这个文件名对应的inode:在目录表中查找该文件名对应的项,由此得到该文件相对应的 inode 号 通过inode号,获取到磁盘中的inode信息,其中最重要的内容是磁盘地址表 通过inode信息中的磁盘地址表,文件系统把分散存放的文件物理块链接成文件的逻辑结构。在磁盘地址表中有 13 个块号,文件将以块号在磁盘地址表中出现的顺序依次读取相应的块。找到文件数据所在的block,读出数据。 根据以上流程,我们可以发现,inode应该是有一个专门的存储区域的,以方便系统快速查找。事实上,一块磁盘创建的时候,操作系统自动将硬盘分成两个区域:存放文件数据的数据区,与存放inode信息的inode区(inode table)。\n每个inode的大小一般是128B或者256B。inode节点的总数,在格式化时就给定,一般是每1KB或每2KB就设置一个inode。假定在一块1GB的硬盘中,每个inode节点的大小为128字节,每1KB就设置一个inode,那么inode table的大小就会达到128MB,占整块硬盘的12.8%。\n也就是说,每个分区的inode总数从格式化之后就固定了,因此有可能会出现存储空间没有占满,但因为小文件太多而耗尽了inode的情况。这个时候就只能清除inode占用高的文件或者目录或修改inode数量了,当然,inode的调整需要重新格式化磁盘,需要确保数据已经得到有效备份后,再进行此操作。\n这时候又产生了新的问题:文件创建时要为文件分配哪一个inode号呢?即如何保证分配的inode号没有被占用? 既然是”是否被占用”的问题,使用位图是最佳方案,像bmap记录block的占用情况一样。标识inode号是否被分配的位图称为inodemap简称为imap。这时要为一个文件分配inode号只需扫描imap即可知道哪一个inode号是空闲的。\n(位图法就是bitmap的缩写。所谓bitmap,就是用每一位来存放某种状态,适用于大规模数据,但数据状态又不是很多的情况。) 类似bmap块位图一样,inode号是预先规划好的。inode号分配后,文件删除也会释放inode号。分配和释放的inode号,像是在一个地图上挖掉一块,用完再补回来一样。 imap存在着和bmap和inode table一样需要解决的问题:如果文件系统比较大,imap本身就会很大,每次存储文件都要进行扫描,会导致效率不够高。同样,优化的方式是将文件系统占用的block划分成块组,每个块组有自己的imap范围,以减少检索时间。\nBlock Group Ext4文件系统将磁盘空间划分为若干组,以这一组为单位管理磁盘空间,这个组叫做块组(Block Group)。那么为什么要划分为块组呢?其主要原因是方便对磁盘的管理,由于磁盘被划分为若干组,因此上层访问数据时碰撞的概率就会大大减小,从而提升文件系统的整体性能。简单来说,块组就是一块磁盘区域,而同时其内部有元数据来管理这部分区域的磁盘。\n文件系统使用block group来组织block的原因有以下几点:\n把每个区进一步分为多个块组 (block group),每个块组有独立的inode/block体系 如果文件系统高达数百 GB 时,把所有的 inode 和block 通通放在一起会因为 inode 和 block的数量太庞大,不容易管理 这其实很好理解,因为分区是用户的分区,实际计算机管理时还有个最适合的大小,于是计算机会进一步的在分区中分块 (但这样岂不是可能出现大文件放不了的问题?有什么机制善后吗?) 每个块组实际还会分为分为6个部分,除了inode table 和 data block外还有4个附属模块,起到优化和完善系统性能的作用 利用df -i命令可以查看inode数量方面的信息\nEXT4 disk layout EXT4 是由多个块组(block group)组成的,每个块组的layout如下图所示:EXT4 是由多个块组(block group)组成的,每个块组的layout如下图所示: EXT4上承EXT3和EXT2,将大量的存储空间分成块组(Block Group),从上图看出,一个块组用1个block来存放inode的位图和block的位图,这就决定了块组的最大大小。以默认的4K为例,4KB=32K bit,因此,最多也就能记录32K个块的分配情况。因此一个块组是32K*4KB=128MB。\n一般而言,一个block的size总是4KB,很少需要调整,但是如果缺失需要调整block的大小,那么可以通过mkfs的 -b选项来指定block的大小。但是需要注意到,一旦block-ize发生了变化,那么块组的大小也就发生了变化。这个影响是两方面的,不仅仅是块大小变化了,而且因为一个块的bit发生了变化,由于位图,直接影响了块组容纳的块的个数。后面我们都以4096字节作为block-size\n对于EXT4文件系统而言,上图中的超级快并非每一个块组都要存在,但也不是只有一个super block块。如果只有一个superblock 块组,那么一旦损坏,文件系统也就不能用了,如果每个块组都要分配一个block,空间上有点浪费。因此mkfs的时候,有一个默认的选项sparse_super。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 ➜ nebula-tool cat /etc/mke2fs.conf [defaults] base_features = sparse_super,filetype,resize_inode,dir_index,ext_attr default_mntopts = acl,user_xattr enable_periodic_fsck = 0 blocksize = 4096 inode_size = 256 inode_ratio = 16384 [fs_types] ext3 = { features = has_journal } ext4 = { features = has_journal,extent,huge_file,flex_bg,uninit_bg,dir_nlink,extra_isize,64bit inode_size = 256 } ext4dev = { features = has_journal,extent,huge_file,flex_bg,uninit_bg,dir_nlink,extra_isize inode_size = 256 options = test_fs=1 } small = { blocksize = 1024 inode_size = 128 inode_ratio = 4096 } floppy = { blocksize = 1024 inode_size = 128 inode_ratio = 8192 } big = { inode_ratio = 32768 } huge = { inode_ratio = 65536 } news = { inode_ratio = 4096 } largefile = { inode_ratio = 1048576 blocksize = -1 } largefile4 = { inode_ratio = 4194304 blocksize = -1 } hurd = { blocksize = 4096 inode_size = 128 } 该选项的含义是,将superblock 稀疏地分散在文件系统中:既不是每个块组都有superblock,也不是一共只有一个superblock。那么哪些块组会有superblock呢?如果在用了sparse_super选项(默认选项),超级快位于满足一下条件的块组上\n块组0 块组id为3 或5 或7的幂(注意,块组#1是3的0次幂,因此也有backup superblock)。 从下面输出中不难看出:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #1 32768 = (32876) *3^0 #3 98304 = (32768) *3^1 #5 163840 = (32768) *5^1 #7 229376 = (32768) *7^1 #9 294912 = (32768) *3^2 #25 ... root@node-1:~# dumpe2fs /dev/sdb2|grep super dumpe2fs 1.42 (29-Nov-2011) Filesystem features: has_journal ext_attr resize_inode dir_index filetype needs_recovery extent flex_bg sparse_super large_file huge_file uninit_bg dir_nlink extra_isize Primary superblock at 0, Group descriptors at 1-464 Backup superblock at 32768, Group descriptors at 32769-33232 Backup superblock at 98304, Group descriptors at 98305-98768 Backup superblock at 163840, Group descriptors at 163841-164304 Backup superblock at 229376, Group descriptors at 229377-229840 Backup superblock at 294912, Group descriptors at 294913-295376 Backup superblock at 819200, Group descriptors at 819201-819664 Backup superblock at 884736, Group descriptors at 884737-885200 Backup superblock at 1605632, Group descriptors at 1605633-1606096 Backup superblock at 2654208, Group descriptors at 2654209-2654672 Backup superblock at 4096000, Group descriptors at 4096001-4096464 Backup superblock at 7962624, Group descriptors at 7962625-7963088 Backup superblock at 11239424, Group descriptors at 11239425-11239888 Backup superblock at 20480000, Group descriptors at 20480001-20480464 Backup superblock at 23887872, Group descriptors at 23887873-23888336 Backup superblock at 71663616, Group descriptors at 71663617-71664080 Backup superblock at 78675968, Group descriptors at 78675969-78676432 Backup superblock at 102400000, Group descriptors at 102400001-102400464 Backup superblock at 214990848, Group descriptors at 214990849-214991312 除了sparse_super选项,EXT4支持一种新的选项 sparse_super2来备份super block,这个comment的大意是,纵然Primary superblock损坏了,那么位于block group #1处的super block 也足以恢复,很少有情况需要用到位于其它位置的备用super block。因此,只提供了2个超级快的备用super block,分别位于block group #1和最后一个block group。引入这种方案,好处不仅仅是节省了磁盘空间,更重要的是使元数据分布的更灵活,比如支持后面提到的packed_meta_blocks扩展选项,将所有元数据固定在存储空间的开始位置。\ninode table 另外一个比较有意思的话题是inode table的长度。对于EXT4的默认情况,一个inode的大小是256字节,inode是EXT4最重要的元数据信息。\n尽管inode bitmap是32K个bit,但是并不意味着每个块组一定要分配32K个inode,因为128M的空间里,存放32K个inode太浪费了,只有几乎所有的文件的大小都小于4K的情况下,才会需要这么多的inode。因此,一个块组预先分配多少个inode,反应的是文件系统对系统内文件平均大小的预期。如果文件系统内存放的文件几乎全是1G以上的大文件,那么分配太多的inode,会浪费宝贵的存储空间。\n1 2 3 4 5 ➜ nebula-tool tune2fs -l /dev/vda1 | grep Inode Inode count: 128016 Inodes per group: 2032 Inode blocks per group: 254 Inode size: 128 从上面的内容不难看出,每个Inode的大小为256字节,一个块组有4096个inode,所有的inode消耗了256个block。这个情况表明,该文件系统一个块组128M的空间,预期文件个数不会超过4096个,即创建文件系统的人认为,文件系统的文件的平均大小不低于32K。如果该文件系统中所有的文件均是1K或者几KB的小文件,就会出现,磁盘空间还有大量的剩余,但是inode已经分配光的情况。这种情况下,再次创建文件就会有No Space之类的报错。本周,我来看到一个这种错误,同事问我df -h明明有大量的空间,为何报这种错误。如何发现文件系统Inode的使用情况呢:\n1 2 3 4 5 6 7 8 9 ➜ nebula-tool df -ih Filesystem Inodes IUsed IFree IUse% Mounted on /dev/vda2 10M 641K 9.3M 7% / devtmpfs 2.0M 368 2.0M 1% /dev tmpfs 2.0M 1 2.0M 1% /dev/shm tmpfs 2.0M 549 2.0M 1% /run tmpfs 2.0M 16 2.0M 1% /sys/fs/cgroup /dev/vda1 126K 335 125K 1% /boot tmpfs 2.0M 61 2.0M 1% /run/user/0 EXT4文件系统mkfs提供了一个 -i的选项,用来调节每个块组inode的个数。该参数的含义是 bytes-per-inode,即格式化的时候,提醒下系统,你认为你该文件系统每个文件的平均大小。使用该值的时候,注意该值不要比block-size小,如果比block-size还要小,意味着很多inode根本没有机会分配出去,纯属浪费。\nflex_bg 上面讲述的是经典的EXT4布局。从EXT4开始,内核引入了flexible block groups的概念。这个弹性块组群是个什么概念呢。就是打破128MB一个块组,块组之间泾渭分明的界限,让多个块组形成一个战斗小组。\n用更确切的话说就是多个块组,将block bitmap聚合在一起,inode bitmap聚合在一起,同时inode table 也聚合在一起,形成一个逻辑块组。这些信息连续的好处是,如果客户连续读,就减少因为inode或bitmap不连续而不得不寻道带来的额外effort。\n该格式化选项,默认是开着的,执行dubugfs -R stats /dev/loop0可以看到如下的参数:\n1 2 3 Inodes per group: 8192 Inode blocks per group: 512 Flex block group size: 16 也就说16个块组组成了一个战斗小组逻辑块组,这16个块组的inode位图,block位图,以及inode table是连续的,如下所示:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 roup 0: block bitmap at 1025, inode bitmap at 1041, inode table at 1057 23513 free blocks, 8181 free inodes, 2 used directories, 8181 unused inodes [Checksum 0x8fd1] Group 1: block bitmap at 1026, inode bitmap at 1042, inode table at 1569 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0xff08] Group 2: block bitmap at 1027, inode bitmap at 1043, inode table at 2081 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xebd4] Group 3: block bitmap at 1028, inode bitmap at 1044, inode table at 2593 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0x89d6] Group 4: block bitmap at 1029, inode bitmap at 1045, inode table at 3105 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xa182] Group 5: block bitmap at 1030, inode bitmap at 1046, inode table at 3617 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0xfc62] Group 6: block bitmap at 1031, inode bitmap at 1047, inode table at 4129 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x79ac] Group 7: block bitmap at 1032, inode bitmap at 1048, inode table at 4641 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0x646a] Group 8: block bitmap at 1033, inode bitmap at 1049, inode table at 5153 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xa43c] Group 9: block bitmap at 1034, inode bitmap at 1050, inode table at 5665 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0xf9dc] Group 10: block bitmap at 1035, inode bitmap at 1051, inode table at 6177 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xed00] Group 11: block bitmap at 1036, inode bitmap at 1052, inode table at 6689 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x6dc8] Group 12: block bitmap at 1037, inode bitmap at 1053, inode table at 7201 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xa756] Group 13: block bitmap at 1038, inode bitmap at 1054, inode table at 7713 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x187c] Group 14: block bitmap at 1039, inode bitmap at 1055, inode table at 8225 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x1d5f] Group 15: block bitmap at 1040, inode bitmap at 1056, inode table at 8737 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes 32768 blocks per group, 32768 fragments per group Group 16: block bitmap at 524288, inode bitmap at 524304, inode table at 524320 24544 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0x2f61] Group 17: block bitmap at 524289, inode bitmap at 524305, inode table at 524832 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xb41c] Group 18: block bitmap at 524290, inode bitmap at 524306, inode table at 525344 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x1572] 我们不难看出 Group 0~Group 15组成了战斗小组,这个战斗小组metadata信息是连续的,后面的Group 16~Group 31也是一样的,依次类推。\n1025~1040 block bitmap 1041~1056 inode bitmap 1057~8737+512 inode table EXT4文件系统有一个控制选项inode_readahead_blks,该参数是指定了inode 预读的块数,如果不启用flex_bg,纵然inode_readahead_blks设置的很大,比如4096,但是因为块组之间inode不连续(比如单个块组 inode table只占用了256个块),这种是没有意义的。 root@node-1:/# cat /sys/fs/ext4/sdb2/inode_readahead_blks 32\n对于连续读的场景,flex_bg配合较大的inode_readahead_blks,能提升连续读的性能。《Linux内核精髓:精通Linux内核必会的75个绝技》一书中提到,当inode_readahead_blks 等于1和等于4096时对比,读取内核所有源码文件有6%左右的提升。\nExtent or indirect blocks 在Ext4之前,也就是Ext2和Ext3文件系统中,都是通过间接块的方式存储大文件的数据的。具体如下图所示,文件数据的位置通过inode中i_block成员(15个32为整数成员的数组)指出,其前面12个成员直接指向12个数据块,第13个成员(block12)指向的磁盘块存储的不是文件数据,而是一个指向数据块的指针列表,我们称为一级块,一级间接块最多有block size / 4个指针,block size就是数据块的大小,因为一个索引是4个字节,所以除以4。以此类推,block13通过二级间接块指向具体的数据,而block14则通过三级间接块指向具体的数据。通过这种间接指向的方式实现对大文件的管理。\n文件大小: 按照上述方式计算下来,最大的文件可以使用的总块数为:12 + (block size/4) + (block size/4)^2 + (block size/4)^3,如果block size大小为4K,则为(12 + 2^10 + 2^20 + 2^30) * 2^12 约等于4T。 Ext4文件数据管理方式 Ext4文件系统有两种数据管理方式,一种是inline的方式,可以将数据存储在inode节点内部,另一种是通过extent的方式,将文件数据组织成为一个B树。当然,为了兼容Ext3及之前的文件系统,Ext4也实现了间接块的方式。\nExt4文件系统文件数据管理参考了现代文件系统的实现方式,也即extent方式。如下图所示,其数据管理的入口仍然是inode节点的i_block成员。差异是此时i_block并非一个32位整数数组,而是一个描述B树结构的数据结构(包含ext4_extent_header和ext4_extent_idx)。在该数据结构中,只有叶子节点中存储的数据包含文件逻辑地址与磁盘物理地址的映射关系。在数据管理中有3个关键的数据结构,分别是ext4_extent_header、ext4_extent_idx和ext4_extent。\next4_extent_header 该数据结构在一个磁盘逻辑块的最开始的位置,描述该磁盘逻辑块的B树属性,也即该逻辑块中数据的类型(例如是否为叶子节点)和数量。如果eh_depth为0,则该逻辑块中数据项为B树的叶子节点,此时其中存储的是ext4_extent数据结构实例,如果eh_depth\u0026gt;0,则其中存储的是非叶子节点,也即ext4_extent_idx,用于存储指向下一级的索引。\n1 2 3 4 5 6 7 struct ext4_extent_header { __le16 eh_magic; /* 魔数 */ __le16 eh_entries; /* 可用的项目的数量 */ __le16 eh_max; /* 本区域可以存储最大项目数量 */ __le16 eh_depth; /* 当前层树的深度 */ __le32 eh_generation; }; ext4_extent_idx 该数据结构是B树中的索引节点,该数据结构用于指向下一级,下一级可以仍然是索引节点,或者叶子节点。\n1 2 3 4 5 6 struct ext4_extent_idx { __le32 ei_block; /* 索引覆盖的逻辑块的数量,以块为单位 */ __le32 ei_leaf_lo; /* 指向下一级物理块的位置,*/ __le16 ei_leaf_hi; /* 物理块位置的高16位 */ __u16 ei_unused; }; ext4_extent 描述了文件逻辑地址与磁盘物理地址的关系。通过该数据结构,可以找到文件某个偏移的一段数据在磁盘的具体位置。\n1 2 3 4 5 6 struct ext4_extent { __le32 ee_block; /* 该extent覆盖的第一个逻辑地址,以块为单位 */ __le16 ee_len; /* 该extent覆盖的逻辑块的位置 */ __le16 ee_start_hi; /* 物理块的高16位 */ __le32 ee_start_lo; /* 物理块的低16位 */ }; 上图是一个示意图,表达了通过若干级索引指向磁盘物理块的关系。实际情况是未必有这么多级,可能比这个多,也可能比这个少。如果文件特别小,可能没有索引层,而是i_block中直接是ext4_extent,直接指向磁盘物理块的位置。\n文件操作 系统对文件的操作会可能影响inode:\n复制:创建一个包含全部数据与新inode号的新文件 移动:在同一磁盘下移动时,所在目录改变,inode号与实际数据存储的块的位置都不会变化。跨磁盘移动当然会删除本磁盘的数据并创建一条新的数据在另一块磁盘中。 硬链接: 同一个inode号代表的文件有多个文件名,即可以用不同的文件名访问同一份数据,但是它们指向的inode编号是相同的,并且文件元数据中链接数会增加。不可以对目录创建硬链接。 软链接: 软链接的本质是一个链接文件,其中存储的了对另一个文件的指针。所以对一个文件创建软链接,inode号不相同,创建软链接文件的链接数不会增加。可以对目录创建软链接。 删除:当删除文件时,会先检查inode中的链接数。如果链接数大于1,就只会删掉一个硬链接,不影响数据。如果链接数等于1,那么这个inode就会被释放掉,对应的inode指向的块也会被标记为空闲的(数据不会被置零,所以硬盘数据被误删除后,若没有新数据写入可恢复)。如果是软链接,原文件被删除后链接文件就变成了悬挂链接(dangling link),无法正常访问了。 利用inode还可以删除一些文件名中有转义字符或控制字符的文件,最典型的就是开头为减号-的文件。这种无法直接用rm命令来搞,就可以先查出它们的inode编号再删除: find ./ -inum 10086 -exec rm {} \\\n特有现象 由于inode号码与文件名分离,导致一些Unix/Linux系统具备以下几种特有的现象。\n文件名包含特殊字符,可能无法正常删除。这时直接删除inode,能够起到删除文件的作用;find ./* -inum 节点号 -delete 移动文件或重命名文件,只是改变文件名,不影响inode号码; 打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。 这种情况使得软件更新变得简单,可以在不关闭软件的情况下进行更新,不需要重启。因为系统通过inode号码,识别运行中的文件,不通过文件名。更新的时候,新版文件以同样的文件名,生成一个新的inode,不会影响到运行中的文件。等到下一次运行这个软件的时候,文件名就自动指向新版文件,旧版文件的inode则被回收。\ninode 耗尽故障 由于硬盘分区的inode总数在格式化后就已经固定,而每个文件必须有一个inode,因此就有可能发生inode节点用光,但硬盘空间还剩不少,却无法创建新文件。同时这也是一种攻击的方式,所以一些公用的文件系统就要做磁盘限额,以防止影响到系统的正常运行。至于修复,很简单,只要找出哪些大量占用i节点的文件删除就可以了。\n硬链接和软链接 Linux系统中有一种比较特殊的文件称之为链接(link)。通俗地说,链接就是从一个文件指向另外一个文件的路径。linux中链接分为俩种,硬链接和软链接。简单来说,硬链接相当于源文件和链接文件在磁盘和内存中共享一个inode,因此,链接文件和源文件有不同的dentry,因此,这个特性决定了硬链接无法跨越文件系统,而且我们无法为目录创建硬链接。软链接和硬链接不同,首先软链接可以跨越文件系统,其次,链接文件和源文件有着不同的inode和dentry,因此,两个文件的属性和内容也截然不同,软链接文件的文件内容是源文件的文件名。 硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。 软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。 软硬链接实现的原理不同\n硬链接是建立一个目录项,包含文件名和文件的inode,但inode是原来文件的inode号,并不建立其所对应得数据。所以硬链接并不占用inode。 软链接也创建一个目录项,也包含文件名和文件的inode,但它的inode指向的并不是原来文件名所指向的数据的inode,而是新建一个inode,并建立数据,数据指向的是原来文件名,所以原来文件名的字符数,即为软链接所占字节数 软硬链接所能创建的目标有区别\n因为每个分区各有一套不同的inode表,所以硬链接不能跨分区创建而软链接可以,因为软链接指向的是文件名。 硬链接不能指向目录 如果说目录有硬链接那么可能引入死循环,但是你可能会疑问软链接也会陷入循环啊,答案当然不是,因为软链接是存在自己的数据的,可以查看自己的文件属性,既然可以判断出来软链接,那么自然不会陷入循环,并且系统在连续遇到8个符号链接后就停止遍历。但是硬链接可就不行了,因为他的inode号一致,所以就判断不出是硬链接,所以就会陷入死循环了。\n相关概念在硬盘上的图示 Super Block, Group Descriptor Inode 展示的是ext2 的inode ext4 block 用的是extent tree 方式,不是现在展示的indirect 方式 Directory File Size Comparision of File System 文件描述符和Inode 的关系 概念 Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。\n文件描述符与文件、进程的关系 1 2 3 4 5 6 fd = open(pathname, flags, mode) // 返回了该文件的fd rlen = read(fd, buf, count) // IO操作均需要传入该文件的fd值 wlen = write(fd, buf, count) status = close(fd) 每当进程用open()函数打开一个文件,内核便会返回该文件的文件描述符(一个非负的整形值),此后所有对该文件的操作,都会以返回的fd文件描述符为参数。\n文件描述符可以理解为进程文件描述表这个表的索引,或者把文件描述表看做一个数组的话,文件描述符可以看做是数组的下标。当需要进行I/O操作的时候,会传入fd作为参数,先从进程文件描述符表查找该fd对应的那个条目,取出对应的那个已经打开的文件的句柄,根据文件句柄指向,去系统fd表中查找到该文件指向的inode,从而定位到该文件的真正位置,从而进行I/O操作。\n每个文件描述符会与一个打开的文件相对应 不同的文件描述符也可能指向同一个文件 相同的文件可以被不同的进程打开,也可以在同一个进程被多次打开 文件描述符相关表 进程级的文件描述符表 系统级的文件描述符表 文件系统的i-node表 进程级别的文件描述表 linux内核会为每一个进程创建一个task_truct结构体来维护进程信息,称之为 进程描述符,该结构体中 指针struct files_struct *files 指向一个名称为file_struct的结构体,该结构体即 进程级别的文件描述表。 它的每一个条目记录的是单个文件描述符的相关信息:\nfd控制标志,前内核仅定义了一个,即close-on-exec 文件描述符所打开的文件句柄的引用 文件句柄这里可以理解为文件名,或者文件的全路径名,因为linux文件系统文件名和文件是独立的,以此与inode区分\n系统级别的文件描述符表 内核对系统中所有打开的文件维护了一个描述符表,也被称之为 【打开文件表】,表格中的每一项被称之为 【打开文件句柄】,一个【打开文件句柄】 描述了一个打开文件的全部信息。 主要包括:\n当前文件偏移量(调用read()和write()时更新,或使用lseek()直接修改) 打开文件时所使用的状态标识(即,open()的flags参数) 文件访问模式(如调用open()时所设置的只读模式、只写模式或读写模式) 与信号驱动相关的设置 对该文件i-node对象的引用 文件类型(例如:常规文件、套接字或FIFO)和访问权限 一个指针,指向该文件所持有的锁列表 文件的各种属性,包括文件大小以及与不同类型操作相关的时间戳 Inode表 每个文件系统会为存储于其上的所有文件(包括目录)维护一个i-node表,单个i-node包含以下信息:\n文件类型(file type),可以是常规文件、目录、套接字或FIFO 访问权限 文件锁列表(file locks) 文件大小 等等 i-node存储在磁盘设备上,内核在内存中维护了一个副本,这里的i-node表为后者。副本除了原有信息,还包括:引用计数(从打开文件描述体)、所在设备号以及一些临时属性,例如文件锁。 在进程A中,文件描述符1和30都指向了同一个打开的文件句柄(标号23)。这可能是通过调用dup()、dup2()、fcntl()或者对同一个文件多次调用了open()函数而形成的。 dup(),也称之为文件描述符复制函数,在某些场景下非常有用,比如:标准输入/输出重定向。在shell下,完成这个操作非常简单,大部分人都会,但是极少人思考过背后的原理。\n大概描述一下需要的几个步骤,以标准输出(文件描述符为1)重定向为例:\n打开目标文件,返回文件描述符n; 关闭文件描述符1; 调用dup将文件描述符n复制到1; 关闭文件描述符n; 进程A的文件描述符2和进程B的文件描述符2都指向了同一个打开的文件句柄(标号73)。这种情形可能是在调用fork()后出现的(即,进程A、B是父子进程关系) 子进程会继承父进程的文件描述符表,也就是子进程继承父进程打开的文件 这句话的由来。 或者当某进程通过UNIX域套接字将一个打开的文件描述符传递给另一个进程时,也会发生。再者是不同的进程独自去调用open函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。\n此外,进程A的描述符0和进程B的描述符3分别指向不同的打开文件句柄,但这些句柄均指向i-node表的相同条目(1976),换言之,指向同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了open()调用。同一个进程两次打开同一个文件,也会发生类似情况。\n文件描述符限制 有资源的地方就有战争,“文件描述符”也是一种资源,系统中的每个进程都需要有“文件描述符”才能进行改变世界的宏图霸业。世界需要秩序,于是就有了“文件描述符限制”的规定。\n如下表: 永久修改用户级限制时有三种设置类型:\nsoft 指的是当前系统生效的设置值 hard 指的是系统中所能设定的最大值 “-” 指的是同时设置了 soft 和 hard 的值 ","permalink":"https://reid00.github.io/en/posts/os_network/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E4%B9%8B%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/","summary":"文件系统 文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会","title":"操作系统之文件系统"},{"content":"什么是内存 最直观的,我们买手机,电脑,内存条,都会标明内存是多大,例如途中的8G,16G,128G都指的内存大小。 我们应该都听说过 RAM 存储器,它是一种半导体存储器件。RAM 是英文单词 Random Access Memory 的缩写,即“随机”的意思。所以 RAM 存储器也称为“随机存储器”。\n那么 RAM 存储器和内存有什么关系呢?内存就是许多 RAM 存储器的集合,就是将许多 RAM 存储器集成在一起的电路板。RAM 存储器的优点是存取速度快、读写方便,所以内存的速度当然也就快了。\n操作系统发展历史 稍微了解操作系统历史的人,都知道没有操作系统的裸机-\u0026gt;一次只能运行一个程序的单道批处理系统-\u0026gt;多道批处理系统-\u0026gt;分时系统这个发展历程。\n裸机时代 主要是人工操作,程序员将对应用程序和数据的已穿孔的纸带(或卡片)装入输入机,然后启动输入机把程序和数据输入计算机内存,接着通过控制台开关启动程序针对数据运行;计算完毕,打印机输出计算结果;用户取走结果并卸下纸带(或卡片)后,才让下一个用户上机。\n人机矛盾:手工操作的慢速度和计算机的高速度之间形成了尖锐矛盾,手工操作方式已严重损害了系统资源的利用率(使资源利用率降为百分之几,甚至更低),不能容忍。唯一的解决办法:只有摆脱人的手工操作,实现作业的自动过渡。这样就出现了成批处理。\n单道批处理系统 特点是一次只能运行一个进程,只有运行完毕后才能将下一个进程加载到内存里面,所以进程的数据都是直接放在物理内存上的,因此CPU是直接操作内存的物理地址,这个时候不存在虚拟逻辑地址,因为一次只能运行一个程序。\n矛盾:每次主机内存中仅存放一道作业,每当它运行期间发出输入/输出(I/O)请求后,高速的CPU便处于等待低速的I/O完成状态,致使CPU空闲。\n多道批处理系统 到后来发展出了多道程序系统,它要求在计算机中存在着多个进程,处理器需要在多个进程间进行切换,当一道程序因I/O请求而暂停运行时,CPU便立即转去运行另一道程序。\n问题来了,这么多进程,内存不够用怎么办,各个进程同时运行时内存地址互相覆盖怎么办?\n这时候就出现问题了,链接器在链接一个可执行文件的时候,总是默认程序的起始地址为0x0,但物理内存上只有一个0x0的地址呀?也许你会说:”没关系,我们可以在程序装入内存的时候再次动态改变它的地址.”好吧我忍了。但如果我的物理内存大小只有1G,而现在某一个程序需要超过1G的空间怎么办呢?你还能用刚才那句话解释吗?\n操作系统的发展,包括后面的分时系统,其实都是在解决协调各个环节速度不匹配的矛盾。\nCPU比磁盘速度快太多 存储器层次之间的作用和关联为金字塔形状,CPU不可以直接操控磁盘,是通过操控内存来进行工作的,因为磁盘的速度远远小于CPU的速度,跟不上,需要中间的内存层进行缓冲。\n内存速度比硬盘速度快的原理: 内存的速度之所以比硬盘的速度快(不是快一点,而是快很多),是因为它们的存储原理和读取方式不一样。\n硬盘是机械结构,通过磁头的转动读取数据。一般情况下台式机的硬盘为每分钟 7200 转,而笔记本的硬盘为每分钟 5400 转。 而内存是没有机械结构的,内存是通过电存取数据的。\n内存通过电存取数据,本质上就是因为 RAM 存储器是通过电存储数据的。但也正因为它们是通过电存储数据的,所以一旦断电数据就都丢失了。因此内存只是供数据暂时逗留的空间,而硬盘是永久的,断电后数据也不会消失。\n小结:程序执行前需要先放到内存中才能被CPU处理,因此内存的主要作用就是缓和CPU与硬盘之间的速度矛盾。\n程序运行过程 在多道程序环境下,系统中会有多个程序并发执行,也就是说会有多个程序的数据需要同时放到内存中。那么,如何区分各个程序的数据是放在什么地方的呢?\n方案: 给内存的存储单元编地址。 程序运行过程如下: 编译: 把高级语言翻译为机器语言;\n链接: 由链接程序将编译后形成的一组目标模块,以及所需库函数链接在一起,形成一个完整的装入模块;\n装入(装载): 由装入程序将装入模块装入内存运行; 三种链接方式 静态链接 在程序运行之前,先将各目标模块及它们所需的库函数链接成一个完整的可执行文件(装入模块),即得到完整的逻辑地址,之后不再拆开。 装入时动态链接 运行前边装入边链接的链接方式。 运行时动态链接 运行时该目标模块时,才对它进行链接,用不到的模块不需要装入内存。其优点是便于修改和更新,便于实现对目标模块的共享。 可以看到运行时动态链接,不需要一次性将模块全部装入内存,可以等到运行时需要的时候再动态的连接进去,这样一来就就提供了内存不够用的问题的解决思路,还可以这样,用到了再链接进去\n三种装入方式 绝对装入 编译或汇编时得到绝对地址,即内存物理地址,直接存到对应的物理地址。 单道处理系统就是直接操作物理地址,因此绝对装入只适用于单道程序环境。\n静态重定位装入 又称可重定位装入,这里引入逻辑地址,装入时将逻辑地址重定位转化为物理地址,多道批处理系统的使用方式。 静态重定位的特点是在一个作业装入内存时,必须分配其要求的全部内存空间,如果没有足够的内存,就不能装入该作业。作业一旦进入内存后,在运行期间就不能再移动,也不能再申请内存空间。\n动态重定位装入 又称动态运行时装入,运行时将逻辑地址重定位转化为物理地址,这种方式需要一个重定位寄存器的支持,当然现代操作系统使用的都是这种。\n逻辑地址都是从0开始的,假设装入的起始物理地址为100,动态重定位装入如下图: 内存管理的职责 内存管理的概念,包含三部分:\n1 内存空间的分配和回收 2 内存空间的扩充 3 地址转化 4 存储保护 内存空间的分配和回收 - 连续内存管理方式 单一连续分配方式 固定分区分配 动态分区分配 单一连续分配方式 在单一连续分配方式中,内存被分为系统区和用户区。系统区通常位于内存的低地址部分,用于存放操作系统相关数据;用户区用于存放用户进程相关数据。内存中只能有一道用户程序,用户程序独占整个用户区空间。\n优点:实现简单;无外部碎片;\n缺点:只能用于单用户、单任务的操作系统中;有内部碎片;存储器利用率极低。 固定分区分配 将整个用户空间划分为若干个固定大小的分区,在每个分区中只装入一道作业,这样就形成了最早的、最简单的一种可运行多道程序的内存管理方式。\n操作系统需要建立一个数据结构——分区说明表,来实现各个分区的分配与回收。每个表项对应一个分区,通常按分区大小排列。每个表项包括对应分区的 大小、起始地址、状态(是否已分配),\n当某用户程序要装入内存时,由操作系统内核程序根据用户程序大小检索该表,从中找到一个能满足大小的、未分配的分区,将之分配给该程序,然后修改状态为“已分配”。\n优点: 实现简单,无外部碎片。\n缺点: 会产生内部碎片,内存利用率低。\n动态分区分配 动态分区分配又称为可变分区分配。这种分配方式不会预先划分内存分区,而是在进程装入内存时, 根据进程的大小动态地建立分区,并使分区的大小正好适合进程的需要。因此系统分区的大小和数 目是可变的。(eg:假设某计算机内存大小为 64MB,系统区 8MB,用户区共 56 MB\u0026hellip;)\n产生三个问题:\n系统要用什么样的数据结构记录内 存的使用情况? (常用的 空闲分区表和空闲分区链) 当很多个空闲分区都能满足需求时, 应该选择哪个分区进行分配? 如何进行分区的分配与回收操作? 动态分区分配又称为可变分区分配。这种分配方式不会预先划分内存分区,而是在进程装入内存时,根据进程的大小动态地建立分区,并使分区的大小正好适合进程的需要。因此系统分区的大小和数目是可变的。\n缺点:动态分区分配没有内部碎片,但是有外部碎片。\n内部碎片:分配给某进程的内存区域中,有些部分没有用上。 外部碎片:是指内存中的某些空闲分区由于太小而难以利用。\n如果内存中空闲空间的总和本来可以满足某进程的要求, 但由于进程需要的是一整块连续的内存空间,因此这些 “碎片”不能满足进程的需求。 可以通过紧凑(拼凑,Compaction)技术来解决外部碎片。 动态分区分配算法 首次适应算法: 每次都从低地址开始查找,找到第一个能满足大小的空闲分区\n最佳适应算法:由于动态分区分配是一种连续分配方式,为各进程分配的空间必须是连续的一整片区域。因此为了保证当“大进程”到来时能有连续的大片空间,可以尽可能多地留下大片的空闲区,即,优先使用更小的空闲区\n最坏适应算法:为了解决最佳适应算法的问题——即留下太多难以利用的小碎片,可以在每次分配时 优先使用最大的连续空闲区,这样分配后剩余的空闲区就不会太小,更方便使用\n邻近适应算法:首次适应算法每次都从链头开始查找的。这可能会导致低地址部分出现很多小的空闲分区,而每次分配查找时,都要经过这些分区,因此也增加了查找的开销。如果每次都从上次查找结束的位置开始检索,就能解决上述问题。 内存空间的分配与回收 - 非连续分配管理方式 连续分配:为用户进程分配的必须是一个连续的内存空间。 非连续分配:为用户进程分配的可以是一些分散的内存空间。\n基本分页存储管理 基本分段存储管理 段页式存储管理 分页-什么是基本分页存储 将内存空间分为一个个大小相等的分区(比如:每个分区 4KB),每个分区就是一个“页框”(页框=页帧=内存块=物理块=物理页面)。每个页框有一个编号,即“页框号”(页框号=页帧号=内存块号=物理块号=物理页号),页框号从0开始。 将进程的逻辑地址空间也分为与页框大小相等的一个个部分,每个部分称为一个“页”或“页面” 。每个页面也有一个编号,即“页号”,页号也是从0开始。\n操作系统以页框为单位为各个进程分配内存空间。进程的每个页面分别放入一个页框中。也就是说,进程的页面与内存的页框有一一对应的关系。各个页面不必连续存放,可以放到不相邻的各个页框中。\n注: 进程的最后一个页面可能没有一个页框那么大。也就是第 16K-1 内存,分页存储有可能产生内部碎片,因此页框不能太大,否则可能产生过大的内部碎片造成浪费。\n分页-页表 为了能知道进程的每个页面在内存中存放的位置,操作系统要为每个进程建立一张页表,页表通常存在PCB(进程控制块)中。 分页-分页之后的地址转换 页号 = 逻辑地址 / 页面长度 (取除法的整数部分) 页内偏移量 = 逻辑地址 % 页面长度(取除法的余数部分)\n如何实现地址转换:\n计算出逻辑地址对应的 逻辑页号和逻辑页内偏移量 查页表, 找到对应页面在物理内存的位置 - 页框(内存块) 物理地址 = 物理内存块地址 + 业内偏移量 基本地址变换 基本地址变换机构可以借助进程的页表将逻辑地址转换为物理地址。 通常会在系统中设置一个页表寄存器(PTR),存放页表在内存中的起始地址F 和页表长度M。 进程未执行时,页表的始址 和 页表长度 放在进程控制块(PCB)中,当进程被调度时,操作系统内核会把它们放到页表寄存器中。\n快表地址变换 快表,又称联想寄存器(TLB, translation lookaside buffer ),是一种访问速度比内存快很多的高速缓存(TLB不是内存!),用来存放最近访问的页表项的副本,可以加速地址变换的速度。与此对应,内存中的页表常称为慢表。\n注:TLB 和 普通 Cache 的区别——TLB 中只有页表项的副本,而普通 Cache 中可能会有其他各种数据的副本 快表快多少? 例:某系统使用基本分页存储管理,并采用了具有快表的地址变换机构。访问一次快表耗时 1us,访问一次内存耗时 100us。若快表的命中率为 90%,那么访问一个逻辑地址的平均耗时是多少? (1+100) * 0.9 + (1+100+100) * 0.1 = 111 us\n有的系统支持快表和慢表同时查找,如果是这样,平均耗时应该是 (1+100) * 0.9 + (100+100) * 0.1 = 110.9 us\n若未采用快表机制,则访问一个逻辑地址需要 100+100 = 200us 显然,引入快表机制后,访问一个逻辑地址的速度快多了。\n分页-两级页表 单级页表的问题: 问题一: 根据页号查询页表的方法:K 号页对应的页表项存放位置 = 页表始址 + K * 4 ,页表必须连续存放,因此当页表很大时,需要占用很多个连续的页框;\n问题二:没有必要让整个页表常驻内存,因为进程在一段时间内可能只需要访问某几个特定的页面。\n解决办法:把页表再分页并离散存储,然后再建立一张页表记录页表各个部分的存放位置,称为页目录表,或称外层页表,或称顶层页表。 分页-多级页表 分段-什么是分段 进程的地址空间:按照程序自身的逻辑关系划分为若干个段,每个段都有一个段名(在低级语言 中,程序员使用段名来编程),每段从0开始编址。\n内存分配规则: 以段为单位进行分配,每个段在内存中占据连续空间,但各段之间可以不相邻。 分段-段表 分段-地址转换 分段 VS 分页 1.1 页是信息的物理单位。分页的主要目的是为了实现离散分配,提高内存利用率。分页仅仅是系统管理上的需要,完全是系统行为,对用户是不可见的。 1.2 段是信息的逻辑单位。分段的主要目的是更好地满足用户需求。一个段通常包含着一组属于一个逻辑模块的信息。\n2.1 分段对用户是可见的,用户编程时需要显式地给出段名。 2.2 页的大小固定且由系统决定。段的长度却不固定,决定于用户编写的程序。\n3.1 分页的用户进程地址空间是一维的,程序员只需给出一个记忆符即可表示一个地址。 3.2 分段的用户进程地址空间是二维的,程序员在标识一个地址时,既要给出段名,也要给出段内地址。 4.1 分段比分页更容易实现信息的共享和保护。 不能被修改的代码称为纯代码或可重入代码(不属于临界资源),这样的代码是可以共享的。可修改的代码是不能共享的(比如,有一个代码段中有很多变量,各进程并发地同时访问可能造成数据不一致) 分段小结 段页式 分页管理 优点: 内存空间利用率高,不会产生外部碎片,只会有少量的页内碎片 缺点: 不方便按照逻辑模块实现信息的共享和保护\n分段管理 优点: 很方便按照逻辑模块实现信息的共享和保护 缺点: 如果段长过大,为其分配很大的连续空间会很不方便。另外,段式管理会产生外部碎片\n段页式-什么是段页式 每个段对应一个段表项,每个段表项由段号、页表长度、页表存放块号(页表起始 地址)组成。 每个段表项长度相等,段号是隐含的。 内存每个页面对应一个页表项,每个页表项由页号、页面存放的内存块号组成。每个页表项长度相等,页号是隐含的。\n段表式页表 段页式地址转换 段页式小结 内存空间的扩充 很多游戏的大小超过 60GB,按理来说这个游戏程序运行之前需要把 60GB 数据全部放入内存。然而,实际我的电脑内存才 8GB,我还要开着微信浏览器等别的进程,但为什么这个游戏可以顺利运行呢?\n利用虚拟技术(操作系统的虚拟性) 时间局部性: 如果执行了程序中的某条指令,那么不久后这条指令很有可能再次执行;如果某个数据被访问过,不久之后该数据很可能再次被访问。(因为程序中存在大量的循环);\n空间局部性: 一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也很有可能被访问。 (因为很多数据在内存中都是连续存放的,并且程序的指令也是顺序地在内存中存放的)\n这个程序执行时,会频繁访问10号 和23号页面\n1 2 3 4 5 6 7 int i = 0; int a[100]; while (i \u0026lt; 100) { a[i] = i; i ++ ; } 虚拟内存大小是多少?\n虚拟内存的最大容量是由计算机的地址结构(CPU寻址范围)确定的,虚拟内存的实际容量 = min(内存和外存容量之和,CPU寻址范围)\n如:某计算机地址结构为32位,按字节编址,内存大小为512MB,外存大小为2GB。\n则虚拟内存的最大容量为 2^32 B = 4GB;\n虚拟内存的实际容量 = min (2^32 B, 512MB+2GB) = 2GB+512MB;\n虚拟内存的实现 请求分页管理 请求分页存储管理与基本分页存储管理的主要区别: 请求调页:在程序执行过程中,当所访问的信息不在内存时,由操作系统负责将所需信息从外存调入内存,然后继续执行程序。\n页面置换:若内存空间不够,由操作系统负责将内存中暂时用不到的信息换出到外存。\n请求分页-缺页中断 缺页中断是因为当前执行的指令想要访问的目标页面未调入内存而产生的,因此属于内中断 一条指令在执行期间,可能产生多次缺页中断。(如:copy A to B,即将逻辑地址A中的数据复制到 逻辑地址B,而A、B属于不同的页面,则有可能产生两次中断)\n只有“写指令”才需要修改“修改位”。并且,一般来说只需修改快表中的数据,只有要将快表项删除时才需要写回内存中的慢表。这样可以减少访存次数。\n和普通的中断处理一样,缺页中断处理依然需要保留CPU现场。\n需要用某种“页面置换算法”来决定一个换出页面(下节内容)\n换入/换出页面都需要启动慢速的I/O操作,可见,如果换入/ 换出太频繁,会有很大的开销。\n页面调入内存后,需要修改慢表,同时也需要将表项复制到快表中\n小结: 请求分页-页面置换 页面的换入、换出需要磁盘 I/O,会有较大的开销,因此好的页面置换算法应该追求更少的缺页率\n最佳置换算法(OPT) 先进先出置换算法(FIFO) 最近最久未使用置换算法(LRU) 时钟置换算法(CLOCK) 改进型的时钟置换算法 内存管理的职责-地址转换 为了使编程更方便,程序员写程序时应该只需要关注指令、数据的逻辑地址。而逻辑地址到物理地址的转换(这个过程称为地址重定位)应该由操作系统负责,这样就保证了程序员写程序时不需要关注物理内存的实际情况。\n具体的地址转化方式如上。\n内存管理的职责-存储保护 ","permalink":"https://reid00.github.io/en/posts/os_network/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E4%B9%8B%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/","summary":"什么是内存 最直观的,我们买手机,电脑,内存条,都会标明内存是多大,例如途中的8G,16G,128G都指的内存大小。 我们应该都听说过 RAM 存储器,","title":"操作系统之内存管理"},{"content":"一、XGBoost和GBDT xgboost是一种集成学习算法,属于3类常用的集成方法(bagging,boosting,stacking)中的boosting算法类别。它是一个加法模型,基模型一般选择树模型,但也可以选择其它类型的模型如逻辑回归等。\nxgboost属于梯度提升树(GBDT)模型这个范畴,GBDT的基本想法是让新的基模型(GBDT以CART分类回归树为基模型)去拟合前面模型的偏差,从而不断将加法模型的偏差降低。\n相比于经典的GBDT,xgboost做了一些改进,从而在效果和性能上有明显的提升(划重点面试常考)。\n第一,GBDT将目标函数泰勒展开到一阶,而xgboost将目标函数泰勒展开到了二阶。保留了更多有关目标函数的信息,对提升效果有帮助。\n第二,GBDT是给新的基模型寻找新的拟合标签(前面加法模型的负梯度),而xgboost是给新的基模型寻找新的目标函数(目标函数关于新的基模型的二阶泰勒展开)。\n第三,xgboost加入了和叶子权重的L2正则化项,因而有利于模型获得更低的方差。\n**第四,xgboost增加了自动处理缺失值特征的策略。**通过把带缺失值样本分别划分到左子树或者右子树,比较两种方案下目标函数的优劣,从而自动对有缺失值的样本进行划分,无需对缺失特征进行填充预处理。\n此外,xgboost还支持候选分位点切割,特征并行等,可以提升性能。\n二、XGBoost原理概述 面从假设空间,目标函数,优化算法3个角度对xgboost的原理进行概括性的介绍。\n1,假设空间\n2,目标函数\n3,优化算法\n基本思想:贪心法,逐棵树进行学习,每棵树拟合之前模型的偏差。\n三、第t棵树学什么? 要完成构建xgboost模型,我们需要确定以下一些事情。\n1,如何boost? 如果已经得到了前面t-1棵树构成的加法模型,如何确定第t棵树的学习目标?\n2,如何生成树?已知第t棵树的学习目标的前提下,如何学习这棵树?具体又包括是否进行分裂?选择哪个特征进行分裂?选择什么分裂点位?分裂的叶子节点如何取值?\n我们首先考虑如何boost的问题,顺便解决分裂的叶子节点如何取值的问题。\n四、如何生成第t棵树? xgboost采用二叉树,开始的时候,全部样本都在一个叶子节点上。然后叶子节点不断通过二分裂,逐渐生成一棵树。\nxgboost使用levelwise的生成策略,即每次对同一层级的全部叶子节点尝试进行分裂。\n对叶子节点分裂生成树的过程有几个基本的问题:是否要进行分裂?选择哪个特征进行分裂?在特征的什么点位进行分裂?以及分裂后新的叶子上取什么值?\n叶子节点的取值问题前面已经解决了。我们重点讨论几个剩下的问题。\n1,是否要进行分裂? 根据树的剪枝策略的不同,这个问题有两种不同的处理。如果是预剪枝策略,那么只有当存在某种分裂方式使得分裂后目标函数发生下降,才会进行分裂。\n但如果是后剪枝策略,则会无条件进行分裂,等树生成完成后,再从上而下检查树的各个分枝是否对目标函数下降产生正向贡献从而进行剪枝。\nxgboost采用预剪枝策略,只有分裂后的增益大于0才会进行分裂。\n2,选择什么特征进行分裂?\nxgboost采用特征并行的方法进行计算选择要分裂的特征,即用多个线程,尝试把各个特征都作为分裂的特征,找到各个特征的最优分割点,计算根据它们分裂后产生的增益,选择增益最大的那个特征作为分裂的特征。\n3,选择什么分裂点位?\nxgboost选择某个特征的分裂点位的方法有两种,一种是全局扫描法,另一种是候选分位点法。 全局扫描法将所有样本该特征的取值按从小到大排列,将所有可能的分裂位置都试一遍,找到其中增益最大的那个分裂点,其计算复杂度和叶子节点上的样本特征不同的取值个数成正比。 而候选分位点法是一种近似算法,仅选择常数个(如256个)候选分裂位置,然后从候选分裂位置中找出最优的那个。\n五、XGBoost算法原理小结 XGBoost(eXtreme Gradient Boosting)全名叫极端梯度提升,XGBoost是集成学习方法的王牌,在Kaggle数据挖掘比赛中,大部分获胜者用了XGBoost,XGBoost在绝大多数的回归和分类问题上表现的十分顶尖,本文较详细的介绍了XGBoost的算法原理。\n目录\n最优模型的构建方法\nBoosting的回归思想\nXGBoost的目标函数推导\nXGBoost的回归树构建方法\nXGBoost与GDBT的区别\n最优模型的构建方法\n构建最优模型的一般方法是最小化训练数据的损失函数,我们用字母 L表示,如下式:\n式(1)称为经验风险最小化,训练得到的模型复杂度较高。当训练数据较小时,模型很容易出现过拟合问题。\n因此,为了降低模型的复杂度,常采用下式:\n其中J(f)为模型的复杂度,式(2)称为结构风险最小化,结构风险最小化的模型往往对训练数据以及未知的测试数据都有较好的预测 。\n应用:决策树的生成和剪枝分别对应了经验风险最小化和结构风险最小化,XGBoost的决策树生成是结构风险最小化的结果,后续会详细介绍。\nBoosting方法的回归思想\nBoosting法是结合多个弱学习器给出最终的学习结果,不管任务是分类或回归,我们都用回归任务的思想来构建最优Boosting模型 。\n回归思想:把每个弱学习器的输出结果当成连续值,这样做的目的是可以对每个弱学习器的结果进行累加处理,且能更好的利用损失函数来优化模型。\n假设\n是第 t 轮弱学习器的输出结果,\n是模型的输出结果,\n是实际输出结果,表达式如下:\n上面两式就是加法模型,都默认弱学习器的输出结果是连续值。因为回归任务的弱学习器本身是连续值,所以不做讨论,下面详细介绍分类任务的回归思想。\n分类任务的回归思想:\n根据2.1式的结果,得到最终的分类器:\n分类的损失函数一般选择指数函数或对数函数,这里假设损失函数为对数函数,学习器的损失函数是\n若实际输出结果yi=1,则:\n求(2.5)式对\n的梯度,得:\n负梯度方向是损失函数下降最快的方向,(2.6)式取反的值大于0,因此弱学习器是往增大\n的方向迭代的,图形表示为:\n如上图,当样本的实际标记 yi 是 1 时,模型输出结果\n随着迭代次数的增加而增加(红线箭头),模型的损失函数相应的减小;当样本的实际标记 yi 是 -1时,模型输出结果\n随着迭代次数的增加而减小(红线箭头),模型的损失函数相应的减小 。这就是加法模型的原理所在,通过多次的迭代达到减小损失函数的目的。\n小结:Boosting方法把每个弱学习器的输出看成是连续值,使得损失函数是个连续值,因此可以通过弱学习器的迭代达到优化模型的目的,这也是集成学习法加法模型的原理所在 。\nXGBoost算法的目标函数推导\n目标函数,即损失函数,通过最小化损失函数来构建最优模型,由第一节可知, 损失函数应加上表示模型复杂度的正则项,且XGBoost对应的模型包含了多个CART树,因此,模型的目标函数为:\n(3.1)式是正则化的损失函数,等式右边第一部分是模型的训练误差,第二部分是正则化项,这里的正则化项是K棵树的正则化项相加而来的。\nCART树的介绍:\n上图为第K棵CART树,确定一棵CART树需要确定两部分,第一部分就是树的结构,这个结构将输入样本映射到一个确定的叶子节点上,记为\n。第二部分就是各个叶子节点的值,q(x)表示输出的叶子节点序号,\n表示对应叶子节点序号的值。由定义得:\n树的复杂度定义\nXGBoost法对应的模型包含了多棵cart树,定义每棵树的复杂度:\n其中T为叶子节点的个数,||w||为叶子节点向量的模 。γ表示节点切分的难度,λ表示L2正则化系数。\n如下例树的复杂度表示:\n目标函数推导\n根据(3.1)式,共进行t次迭代的学习模型的目标函数为:\n泰勒公式的二阶导近似表示:\n令\n为Δx,则(3.5)式的二阶近似展开:\n其中:\n表示前t-1棵树组成的学习模型的预测误差,gi和hi分别表示预测误差对当前模型的一阶导和二阶导 ,当前模型往预测误差减小的方向进行迭代。\n忽略(3.8)式常数项,并结合(3.4)式,得:\n通过(3.2)式简化(3.9)式:\n(3.10)式第一部分是对所有训练样本集进行累加,因为所有样本都是映射为树的叶子节点,我们换种思维,从叶子节点出发,对所有的叶子节点进行累加,得:\n令\nGj 表示映射为叶子节点 j 的所有输入样本的一阶导之和,同理,Hj表示二阶导之和。\n得:\n对于第 t 棵CART树的某一个确定结构(可用q(x)表示),其叶子节点是相互独立的,Gj和Hj是确定量,因此,(3.12)可以看成是关于叶子节点的一元二次函数 。最小化(3.12)式,得:\n得到最终的目标函数:\n(3.14)也称为打分函数(scoring function),它是衡量树结构好坏的标准,值越小,代表这样的结构越好 。我们用打分函数选择最佳切分点,从而构建CART树。\nCART回归树的构建方法\n上节推导得到的打分函数是衡量树结构好坏的标准,因此,可用打分函数来选择最佳切分点。首先确定样本特征的所有切分点,对每一个确定的切分点进行切分,切分好坏的标准如下:\nGain表示单节点obj与切分后的两个节点的树obj之差,遍历所有特征的切分点,找到最大Gain的切分点即是最佳分裂点,根据这种方法继续切分节点,得到CART树。若 γ 值设置的过大,则Gain为负,表示不切分该节点,因为切分后的树结构变差了。γ值越大,表示对切分后obj下降幅度要求越严,这个值可以在XGBoost中设定。\nXGBoost与GDBT的区别\nXGBoost生成CART树考虑了树的复杂度,GDBT未考虑,GDBT在树的剪枝步骤中考虑了树的复杂度。\nXGBoost是拟合上一轮损失函数的二阶导展开,GDBT是拟合上一轮损失函数的一阶导展开,因此,XGBoost的准确性更高,且满足相同的训练效果,需要的迭代次数更少。\nXGBoost与GDBT都是逐次迭代来提高模型性能,但是XGBoost在选取最佳切分点时可以开启多线程进行,大大提高了运行速度。\n参考: https://mp.weixin.qq.com/s/cMgd-wBlzjacL21FPK2y7Q https://www.cnblogs.com/pinard/p/10979808.html\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Bxgboost/","summary":"一、XGBoost和GBDT xgboost是一种集成学习算法,属于3类常用的集成方法(bagging,boosting,stacking)中","title":"集成学习之xgboost"},{"content":"Boosting算法的工作机制 用初始权重D(1)从数据集中训练出一个弱学习器1 根据弱学习1的学习误差率表现来更新训练样本的权重D(2),使得之前弱学习器1学习误差率高的样本点的权重变高,使得这些误差率高的点在后面的弱学习器2中得到更多的重视。 然后基于调整权重后的训练集来训练弱学习器2 如此重复进行,直到弱学习器数达到事先指定的数目T,最终将这T个弱学习器通过集合策略进行整合,得到最终的强学习器。 现如今已经有很多的提升方法了,但最著名的就是Adaboost(适应性提升,是Adaptive Boosting的简称)和Gradient Boosting(梯度提升)。让我们先从 Adaboost 说起。\n什么是AdaBoost AdaBoost是一个具有里程碑意义的算法,其中,适应性(adaptive)是指:后续的分类器为更好地支持被先前分类器分类错误的样本实例而进行调整。通过对之前分类结果不对的训练实例多加关注,使新的预测因子越来越多地聚焦于之前错误的情况。\n具体说来,整个AdaBoost迭代算法就3步:\n初始化训练数据的权值分布。如果有N个样本,则每一个训练样本最开始时都被赋予相同的权值:。 训练弱分类器。具体训练过程中,如果某个样本点已经被准确地分类,那么在构造下一个训练集中,它的权值就被降低;相反,如果某个样本点没有被准确地分类,那么它的权值就得到提高。然后,权值更新过的样本集被用于训练下一个分类器,整个训练过程如此迭代地进行下去。 将各个训练得到的弱分类器组合成强分类器。各个弱分类器的训练过程结束后,加大分类误差率小的弱分类器的权重,使其在最终的分类函数中起着较大的决定作用,而降低分类误差率大的弱分类器的权重,使其在最终的分类函数中起着较小的决定作用。换言之,误差率低的弱分类器在最终分类器中占的权重较大,否则较小。 加法模型与前向分布 在学习AdaBoost之前需要了解两个数学问题,这两个数学问题可以帮助我们更好地理解AdaBoost算法,并且在面试官问你算法原理时不至于发懵。下面我们就来看看加法模型与前向分布。\n什么是加法模型 当别人问你“什么是加法模型”时,你应当知道:加法模型顾名思义就是把各种东西加起来求和。如果想要更严谨的定义,不妨用数学公式来表达: 这个公式看上去可能有些糊涂,如果我们套用到提升树模型中就比较容易理解一些。FM(x)表示最终生成的最好的提升树,其中M表示累加的树的个数。b(x;ym)表示一个决策树,$阿尔法m$ 表示第m个决策树的权重,ym表示决策树的参数(如叶节点的个数)。\n什么是前向分布 那么什么是前向分布算法呢?在损失函数 的条件下,加法模型FM(x)成为一个经验风险极小化问题,即使得损失函数极小化: 前向分布算法就是求解这个优化问题的一个思想:因为学习的是加法模型,如果能够从前向后,每一步只学习一个基函数(一棵决策树)及其权重,利用残差逐步逼近优化问题,那么就可以简化优化的复杂度。从而得到前向分布算法为:\n套用在提升树模型中进行理解就是:$fm-1(x)$是前一棵提升树(之前树的累加),在其基础上再加上一棵树$Bxi, Ym$乘上它的权重系数,用这棵树去拟合的残差!$阿尔法m$(观察值与估计值之间的差),再将这两棵树合在一起就得到了新的提升树。实际上就是让下一个基分类器去拟合当前分类器学习出来的残差。\n前向分布与Adaboost损失函数优化的关系 现在了解了加法模型与前向分布。那这两个概念与Adaboost又有什么关系呢?\nAdaboost可以认为其模型是加法模型、损失函数为指数函数、学习算法为前向分步算法的二类分类学习方法。我们可以使用前向分布算法作为框架,推导出Adaboost算法的损失函数优化问题的。\n在Adaboost中,各个基本分类器就相当于加法模型中的基函数$fm-1(x)$,且其损失函数为指数函数$b(xi;ym)$。\n即,需要优化的问题如下: 如果我们令,则上述公式可以改写成为: 因为与要么相等、要么不等。所以可以将其拆成两部分相加的形式:\n算法中需要关注的内容 首先看看算法中都关注了哪些内容: 首先,我们假设训练样本为$(x1,y1), (x2, y2)\u0026hellip;(xn, yn)$\n由于AdaBoost是由一个个的弱分类器迭代训练得到一个强分类器的,因此我们有如下定义:\n弱分类器表达式:$Ht(x)$ 先以二分类为例,它输出的值为1或-1,则有:$Ht(x) ∈{-1, 1}$\n首先,我们假设训练样本为 由于AdaBoost是由一个个的弱分类器迭代训练得到一个强分类器的,因此我们有如下定义:\n弱分类器表达式: 公式推导(通过Z最小化训练误差) Adaboost算法之所以称为十大算法之一,有一个重要原因就是它有完美的数学推导过程,其参数不是人工设定的,而是有解析解的,并且可以证明其误差上界越来越小,趋近于零;且可以推导出来。下面就来看一下公式推导。\n权重公式: 首先要把模型的误差表示出来,只有用数学公式表示出来,才能够讲模型的优化。\n先看第i个样本在t+1个弱学习器的权重是怎样的? 模型误差上限 模型误差上限最小化与Z 求出Z 既然最小化Zt就等同于最小化模型误差上界,那我们得先知道Zt长什么样,然后才能去最小化它。\n我们在前面已经说过,为了保证所有样本的权重加起来等于1。因此需要对每个权重除以归一化系数。即Zt实际上就是t+1时刻所有样本原始权重和,也就是时刻的各点权重乘以调整幅度再累加:\n求出使得Z最小的参数a AdaBoost计算步骤梳理及优缺点 理论上任何学习器都可以用于Adaboost。但一般来说,使用最广泛的Adaboost弱学习器是决策树和神经网络。对于决策树,Adaboost分类用了CART分类树,而Adaboost回归用了CART回归树。\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Badaboost/","summary":"Boosting算法的工作机制 用初始权重D(1)从数据集中训练出一个弱学习器1 根据弱学习1的学习误差率表现来更新训练样本的权重D(2),使得","title":"集成学习之AdaBoost"},{"content":"生成子模型的两种取样方式 那么为了造成子模型之间的差距,每个子模型只看样本中的一部分,这就涉及到两种取样方式:\n放回取样:Bagging,在统计学中也被称为bootstrap。 不放回取样:Boosting 在集成学习中我们通常采用 Bagging 的方式,具体原因如下:\n因为取样后放回,所以不受样本数据量的限制,允许对同一种分类器上对训练集进行进行多次采样,可以训练更多的子模型。 在 train_test_split 时,不那么强烈的依赖随机;而 Boosting的方式,会受到随机的影响; Boosting的随机问题:Pasting 的方式等同于将 500 个样本分成 5 份,每份 100 个样本,怎么分,将对子模型有较大影响,进而对集成系统的准确率有较大影响。 什么是Bagging Bagging,即bootstrap aggregating的缩写,每个训练集称为bootstrap。\nBagging是一种根据均匀概率分布从数据中重复抽样(有放回)的技术 。\nBagging能提升机器学习算法的稳定性和准确性,它可以减少模型的方差从而避免overfitting。它通常应用在决策树方法中,其实它可以应用到任何其它机器学习算法中。\nBagging方法在不稳定模型(unstable models)集合中表现比较好。这里说的不稳定的模型,即在训练数据发生微小变化时产生不同泛化行为的模型(高方差模型),如决策树和神经网络。\n但是Bagging在过于简单模型集合中表现并不好,因为Bagging是从总体数据集随机选取样本来训练模型,过于简单的模型可能会产生相同的预测结果,失去了多样性。\n总结一下Bagging方法:\nBagging通过降低基分类器的方差,改善了泛化误差 其性能依赖于基分类器的稳定性;如果基分类器不稳定,bagging有助于降低训练数据的随机波动导致的误差;如果稳定,则集成分类器的误差主要由基分类器的偏差引起 由于每个样本被选中的概率相同,因此bagging并不侧重于训练数据集中的任何特定实例 Bagging的使用 sklearn为Bagging提供了一个简单的API:BaggingClassifier类(回归是BaggingRegressor)。首先需要传入一个模型作为参数,可以使用决策树;然后需要传入参数n_estimator即集成多少个子模型;参数max_samples表示每次从数据集中取多少样本;参数bootstrap设置为True表示使用有放回取样Bagging,设置为False表示使用无放回取样Pasting。可以通过n_jobs参数来分配训练所需CPU核的数量,-1表示会使用所有空闲核(集成学习思路,极易并行化处理)。\nbagging是不能减小模型的偏差的,因此我们要选择具有低偏差的分类器来集成,例如:没有修剪的决策树。\nBootstrap 在每个预测器被训练的子集中引入了更多的分集,所以 Bagging 结束时的偏差比 Pasting 更高,但这也意味着预测因子最终变得不相关,从而减少了集合的方差。总体而言,Bagging 通常会导致更好的模型,这就解释了为什么它通常是首选的。然而,如果你有空闲时间和 CPU 功率,可以使用交叉验证来评估 Bagging 和 Pasting 哪一个更好。\nOut-of-Bag 对于Bagging来说,一些实例可能被一些分类器重复采样,但其他的有可能不会被采样。由于每个bootstrap的M个样本是有放回随机选取的,因此每个样本不被选中的概率为。当N和M都非常大时,比如N=M=10000,一个样本不被选中的概率p = 36.8%。因此一个bootstrap约包含原样本63.2%,约36.8%的样本未被选中。这些没有被采样的训练实例就叫做Out-of-Bag实例。但注意对于每一个的分类器来说,它们各自的未选中部分不是相同的。\n那么这些未选中的样本有什么用呢?\n因为在训练中分类器从来没有看到过Out-of-Bag实例,所以它可以在这些样本上进行预测,就不用分样本测试集和测试数据集了。\n在sklearn中,可以在训练后需要创建一个BaggingClassifier时设置oob_score=True来进行自动评估。\n1 2 3 4 5 bagging_clf = BaggingClassifier(DecisionTreeClassifier(), n_estimators=5000, max_samples=100, bootstrap=True, oob_score=True) bagging_clf.fit(X, y) bagging_clf.oob_score_ 另一种差异化方式:针对特征取样 我们上面提到的,都是通过对样本进行取样,来得到差异化的子模型。除了取部分样本以外,还可以针对特征进行随机取样。尤其是在样本特征非常多的情况下时,如图像领域中,每个像素点都是一个特征,则Bagging时可以对特征进行取样。\n在BaggingClassifier中有两个超参数,max_features表示每次取多少特征;bootstrap_features设置为True则开启。\n在Bagging中,所有的分类器都可以是并行训练的。与之相对应的,串行训练的Boosting也是集成训练中的一大类别。接下来我们会介绍Boosting算法。\nBoosting思想 Boosting是增强、推动的意思。它是一种迭代的方法,各个子模型彼此之间不是独立的关系,而是相互增强(Boosting)的关系。每一次训练的时候都更加关心分类错误的样例,给这些分类错误的样例增加更大的权重,下一次迭代的目标就是能够更容易辨别出上一轮分类错误的样例。每个模型都在尝试增强整体的效果,最终将这些弱分类器进行加权相加。\n因此与Bagging中各学习器并行处理不同的是:Boosting是串行的,环环相扣且有先后顺序的\nBoosting工作机制 Boosting的工作流程:\n现有原始数据集,首先挑出一些数据,然后在上训练分类器得到。然后用在原始数据集上测试一下,看哪些样本分类对了,哪些样本分类错了。然后把分错的和分对的分别挑出一部分,组成新的数据集(也就是说,刻意筛出有对有错的数据集)。再使用某分类器训练数据集,即专门地、有目的性地学习D1数据集哪些学对了、哪些学错了,得到C2.\n有了C1, C2 两个分类器都在原始数据集D上进行测试,目的是找到C1、C2结果不一致的样本,组成一个新的数据集D3,再用一个分类器训练D3,得到(专门用来解决C1、C2的争端)。\n其算法流程为:\n先从初始训练集训练出一个弱学习器; 再根据基学习器的表现对训练样本分布进行调整,使得先前基学习器做错的训练样本在后续受到更多关注; 然后基于调整后的样本分布来训练下一个基学习器; 如此重复进行,直至基学习器数目达到事先指定的值,最终将这个基学习器进行加权结合。使后续模型应该能够补偿早期模型所造成的错误。 先训练一个分类器,根据这个分类器的误差将训练样本重新调整,也就是加权重。原来的每个样本有同样的机会去作为训练样本,在某个样本上犯的错误越多,其权重也就越大。这很好理解,就是让后面的分类器去重点学习前面分错的样本。\nBoosting和Bagging的区别(重要总结) 1、样本选择上:\nBagging:训练集是在原始集中有放回选取的,从原始集中选出的各轮训练集之间是独立的。 Boosting:每一轮的训练集不变,只是训练集中每个样例在分类器中的权重发生变化。而权值是根据上一轮的分类结果进行调整。 2、样例权重:\nBagging:使用均匀取样,每个样例的权重相等。 Boosting:根据错误率不断调整样例的权值,错误率越大则权重越大。 3、分类器权重:\nBagging:所有分类器的权重相等。 Boosting:每个弱分类器都有相应的权重,对于分类误差小的分类器会有更大的权重。 4、并行\u0026amp;串行:\nBagging:各个预测函数可以并行生成。 Boosting:各个预测函数只能顺序生成,因为后一个模型参数需要前一轮模型的结果。 偏差和方差(重要!) 偏差(bias)衡量了模型的预测值与实际值之间的偏离关系,反映了模型本身的拟合能力;方差(variance)描述的是训练数据在不同迭代阶段的训练模型中,预测值的变化波动情况(或称之为离散情况)。\nBagging用多个分类器进行并行训练的目的就是降低方差。因为相互独立的分类器多了,就会让目标值更聚合。\n而对于Boosting来说,每一轮都针对于上一轮进行学习,力求准确,即可以降低偏差(残差)。\n因此,在实际使用时,我们就要考虑模型的特性。对于方差低的Bagging(随机森林)来说,采用深度较深且不剪枝的决策树,以此降低其偏差。对于偏差低的Boosting(GBDT)要选简单的、深度浅的决策树。\n在Bagging方法中各个基体学习器之间不存在依赖关系,集成多个模型,综合有差异的子模型,融合出比较好的模型,且可以并行处理。如随机森林算法(RF)等。\n而Boosting方法必须串行生成,各个基学习器存在依赖关系,基于前面模型的训练结果误差生成新的模型,代表的算法有:Adaboost、GBDT、XGBoost等。\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Bbaggingboosting/","summary":"生成子模型的两种取样方式 那么为了造成子模型之间的差距,每个子模型只看样本中的一部分,这就涉及到两种取样方式: 放回取样:Bagging,在统计","title":"集成学习之Bagging,Boosting"},{"content":"什么是GBDT 到底什么是梯度提升树?所谓的GBDT实际上就是:\nGBDT = Gradient Descent + Boosting + Desicion Tree\n与Adaboost算法类似,GBDT也是使用了前向分布算法的加法模型。只不过弱学习器限定了只能使用CART回归树模型,同时迭代思路和Adaboost也有所不同。\n在Adaboost算法中,我们是利用前一轮迭代弱学习器的误差率来更新训练集的权重。而Gradient Boosting是通过算梯度(gradient)来定位模型的不足。\nhttps://mp.weixin.qq.com/s/rmStKvdHq-BOCJo8ZuvgfQ\n最常用的决策树算法: RF, Adaboost, GBDT\nhttps://mp.weixin.qq.com/s/tUl3zhVxLfUd7o06_1Zg2g\nXgboost 的优势和原理 原理: https://www.jianshu.com/p/920592e8bcd2\n​\thttps://www.jianshu.com/p/ac1c12f3fba1\n优势: https://snaildove.github.io/2018/10/02/get-started-XGBoost/\nLightGBM 详解 https://blog.csdn.net/VariableX/article/details/106242202\nGBDT分类算法流程 GBDT的分类算法从思想上和GBDT的回归算法没有区别,但是由于样本输出不是连续的值,而是离散的类别,导致我们无法直接从输出类别去拟合类别输出的误差。\n为了解决这个问题,主要有两个方法:\n用指数损失函数,此时GBDT退化为Adaboost算法。 用类似于逻辑回归的对数似然损失函数的方法。也就是说,我们用的是类别的预测概率值和真实概率值的差来拟合损失。 下面我们用对数似然损失函数的GBDT分类。而对于对数似然损失函数,又有二元分类和多元分类的区别。\nsklearn中的GBDT调参大法 https://mp.weixin.qq.com/s/756Xsy0uhnb8_rheySqLLg\nBoosting重要参数 分类和回归算法的参数大致相同,不同之处会指出。\nn_estimators: 弱学习器的个数。个数太小容易欠拟合,个数太大容易过拟合。默认是100,在实际调参的过程中,常常将n_estimators和参数learning_rate一起考虑。\nlearning_rate: 每个弱学习器的权重缩减系数,也称作步长。如果我们在强学习器的迭代公式加上了正则化项:,则通过learning_rate来控制其权重。对于同样的训练集拟合效果,较小的learning_rate意味着需要更多的弱学习器。通常用二者一起决定算法的拟合效果。所以两个参数n_estimators和learning_rate要一起调参。一般来说,可以从一个小一点的补偿开始调参,默认是1。\nsubsample: 不放回抽样的子采样,取值为(0,1]。如果取值为1,则全部样本都使用,等于没有使用子采样。如果取值小于1,则只有一部分样本会去做GBDT的决策树拟合。选择小于1的比例可以减少方差,即防止过拟合,但是会增加样本拟合的偏差,因此取值不能太低。推荐在[0.5, 0.8]之间,默认是1.0,即不使用子采样。\ninit: 初始化时的弱学习器,即。如果我们对数据有先验知识,或者之前做过一些拟合,可以用init参数提供的学习器做初始化分类回归预测。一般情况下不输入,直接用训练集样本来做样本集的初始化分类回归预测。\nloss: GBDT算法中的损失函数。分类模型和回归模型的损失函数是不一样。\n对于回归模型,可以使用均方误差ls,绝对损失lad,Huber损失huber和分位数损失quantile,默认使用均方误差ls。如果数据的噪音点不多,用默认的均方差ls比较好;如果噪音点较多,则推荐用抗噪音的损失函数huber;而如果需要对训练集进行分段预测,则采用quantile。 对于分类模型,可以使用对数似然损失函数deviance和指数损失函数exponential。默认是对数似然损失函数deviance。在原理篇中对这些分类损失函数有详细的介绍。一般来说,推荐使用默认的\u0026quot;deviance\u0026quot;。它对二元分离和多元分类各自都有比较好的优化。而指数损失函数等于把我们带到了Adaboost算法。 alpha: 这个参数只有回归算法有,当使用Huber损失huber和分位数损失quantile时,需要指定分位数的值。默认是0.9,如果噪音点较多,可以适当降低这个分位数的值。\n弱学习器参数 GBDT使用了CART回归决策树,因此它的参数基本和决策树类似。\nmax_features: 划分时考虑的最大特征数,默认是\u0026quot;None\u0026quot;。默认时表示划分时考虑所有的特征数;如果是\u0026quot;log2\u0026quot;意味着划分时最多考虑个log2N特征;如果是\u0026quot;sqrt\u0026quot;或者\u0026quot;auto\u0026quot;意味着划分时最多考虑根号N个特征。如果是整数,代表考虑的特征绝对数。如果是浮点数,代表考虑特征百分比,即考虑(百分比*N)取整后的特征数。其中N为样本总特征数。一般来说,如果样本特征数不多,比如小于50,我们用默认的\u0026quot;None\u0026quot;就可以了,如果特征数非常多,可以灵活控制划分时考虑的最大特征数,以控制决策树的生成时间。 max_depth: 决策树最大深度。如果不输入,默认值是3。一般来说,数据少或者特征少的时候可以不管这个值。如果模型样本量多,特征也多的情况下,推荐限制这个最大深度,具体的取值取决于数据的分布。 min_samples_split: 内部节点再划分所需最小样本数。限制子树继续划分的条件,如果某节点的样本数少于min_samples_split,则不会继续再尝试选择最优特征来进行划分。默认是2,如果样本量数量级非常大,则增大这个值。 min_samples_leaf: 叶子节点最少样本数。限制叶子节点最少的样本数,如果某叶子节点数目小于样本数,则会和兄弟节点一起被剪枝。默认是1,可以输入最少的样本数的整数,或者最少样本数占样本总数的百分比。如果样本量不大,不需要管这个值。如果样本量数量级非常大,则推荐增大这个值。 min_weight_fraction_leaf: 叶子节点最小的样本权重和这个值限制了叶子节点所有样本权重和的最小值,如果小于这个值,则会和兄弟节点一起被剪枝。默认是0,就是不考虑权重问题。一般来说,如果我们有较多样本有缺失值,或者分类树样本的分布类别偏差很大,就会引入样本权重,这时我们就要注意这个值了。 max_leaf_nodes: 最大叶子节点数。通过限制最大叶子节点数,可以防止过拟合,默认是None,即不限制最大的叶子节点数。如果加了限制,算法会建立在最大叶子节点数内最优的决策树。如果特征不多,可以不考虑这个值,但是如果特征分成多的话,可以加以限制,具体的值可以通过交叉验证得到。 min_impurity_split: 节点划分最小不纯度。这个值限制了决策树的增长,如果某节点的不纯度(基于基尼系数,均方差)小于这个阈值,则该节点不再生成子节点。即为叶子节点 。一般不推荐改动默认值1e-7。 GBDT有很多优点:\n可以灵活处理连续值和离散值等各种类型的数据 可以少做一点特征工程部分 能够处理字段缺失的数据 能够自动组合多个特征,不用关心特征间是否依赖 能够自动处理特征间的交互,处理多种类型的异构数据 可以通过选择损失函数,来增强对异常值的鲁棒性,如:Huber损失函数和Quantile损失函数 GBDT也有一些局限性:\n在高维稀疏数据集上,表现不如神经网络和SVM Boosting家族算法,基学习器需要串行训练,只能通过局部并行提高速度(自采样的SGBT) ","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Bgbdt/","summary":"什么是GBDT 到底什么是梯度提升树?所谓的GBDT实际上就是: GBDT = Gradient Descent + Boosting + Desicion Tree 与Adaboost算法类似,GBDT也是使用了前向分布算法的","title":"集成学习之GBD"},{"content":"1.简介 逻辑回归是面试当中非常喜欢问到的一个机器学习算法,因为表面上看逻辑回归形式上很简单,很好掌握,但是一问起来就容易懵逼。所以在面试的时候给大家的第一个建议不要说自己精通逻辑回归,非常容易被问倒,从而减分。下面总结了一些平常我在作为面试官面试别人和被别人面试的时候,经常遇到的一些问题。\nRegression问题的常规步骤为:\n寻找h函数(即假设估计的函数); 构造J函数(损失函数); 想办法使得J函数最小并求得回归参数(θ); 数据拟合问题 2.正式介绍 如何凸显你是一个对逻辑回归已经非常了解的人呢。那就是用一句话概括它!逻辑回归假设数据服从伯努利分布,通过极大化似然函数的方法,运用梯度下降来求解参数,来达到将数据二分类的目的。\n这里面其实包含了5个点 1:逻辑回归的假设,2:逻辑回归的损失函数,3:逻辑回归的求解方法,4:逻辑回归的目的,5:逻辑回归如何分类。这些问题是考核你对逻辑回归的基本了解。\n逻辑回归的基本假设 任何的模型都是有自己的假设,在这个假设下模型才是适用的。逻辑回归的第一个基本假设是**假设数据服从伯努利分布。**伯努利分布有一个简单的例子是抛硬币,抛中为正面的概率是pp,抛中为负面的概率是1−p1−p.在逻辑回归这个模型里面是假设 hθ(x)hθ(x) 为样本为正的概率,1−hθ(x)1−hθ(x)为样本为负的概率。那么整个模型可以描述为\nhθ(x;θ)=phθ(x;θ)=p\n逻辑回归的第二个假设是假设样本为正的概率是\np=11+e−θTxp=11+e−θTx\n所以逻辑回归的最终形式\nhθ(x;θ)=11+e−θTx\n逻辑回归的求解方法 由于该极大似然函数无法直接求解,我们一般通过对该函数进行梯度下降来不断逼急最优解。在这个地方其实会有个加分的项,考察你对其他优化方法的了解。因为就梯度下降本身来看的话就有随机梯度下降,批梯度下降,small batch 梯度下降三种方式,面试官可能会问这三种方式的优劣以及如何选择最合适的梯度下降方式。\n简单来说 批梯度下降会获得全局最优解,缺点是在更新每个参数的时候需要遍历所有的数据,计算量会很大,并且会有很多的冗余计算,导致的结果是当数据量大的时候,每个参数的更新都会很慢。\n随机梯度下降是以高方差频繁更新,优点是使得sgd(随机梯度下降)会跳到新的和潜在更好的局部最优解,缺点是使得收敛到局部最优解的过程更加的复杂。\n如果使用梯度下降法(批量梯度下降法),那么每次迭代过程中都要对 个样本进行求梯度,所以开销非常大,随机梯度下降的思想就是随机采样一个样本 来更新参数,那么计算开销就从 下降到 。\n随机梯度下降虽然提高了计算效率,降低了计算开销,但是由于每次迭代只随机选择一个样本,因此随机性比较大,所以下降过程中非常曲折\n可以看到多了随机两个字,随机也就是说我们用样本中的一个例子来近似我所有的样本,来调整θ,因而随机梯度下降是会带来一定的问题,因为计算得到的并不是准确的一个梯度,**对于最优化问题,凸问题,**虽然不是每次迭代得到的损失函数都向着全局最优方向, 但是大的整体的方向是向全局最优解的,最终的结果往往是在全局最优解附近。\n小批量梯度下降结合了sgd和batch gd的优点,每次更新的时候使用n个样本。减少了参数更新的次数,可以达到更加稳定收敛结果,一般在深度学习当中我们采用这种方法。小批量梯度下降的开销为 其中 是批量大小。\n其实这里还有一个隐藏的更加深的加分项,看你了不了解诸如Adam,动量法等优化方法。因为上述方法其实还有两个致命的问题。 第一个是如何对模型选择合适的学习率。自始至终保持同样的学习率其实不太合适。因为一开始参数刚刚开始学习的时候,此时的参数和最优解隔的比较远,需要保持一个较大的学习率尽快逼近最优解。但是学习到后面的时候,参数和最优解已经隔的比较近了,你还保持最初的学习率,容易越过最优点,在最优点附近来回振荡,通俗一点说,就很容易学过头了,跑偏了。 第二个是如何对参数选择合适的学习率。在实践中,对每个参数都保持的同样的学习率也是很不合理的。有些参数更新频繁,那么学习率可以适当小一点。有些参数更新缓慢,那么学习率就应该大一点。这里我们不展开,有空我会专门出一个专题介绍。 逻辑回归的目的 该函数的目的便是将数据二分类,提高准确率。 逻辑回归如何分类 逻辑回归作为一个回归(也就是y值是连续的),如何应用到分类上去呢。y值确实是一个连续的变量。逻辑回归的做法是划定一个阈值,y值大于这个阈值的是一类,y值小于这个阈值的是另外一类。阈值具体如何调整根据实际情况选择。一般会选择0.5做为阈值来划分。 逻辑回归的损失函数为什么要使用极大似然函数作为损失函数? 损失函数一般有四种,平方损失函数,对数损失函数,HingeLoss0-1损失函数,绝对值损失函数。将极大似然函数取对数以后等同于对数损失函数。在逻辑回归这个模型下,对数损失函数的训练求解参数的速度是比较快的。至于原因大家可以求出这个式子的梯度更新\n这个式子的更新速度只和相关。和sigmod函数本身的梯度是无关的。这样更新的速度是可以自始至终都比较的稳定。\n为什么不选平方损失函数的呢?其一是因为如果你使用平方损失函数,你会发现梯度更新的速度和sigmod函数本身的梯度是很相关的。sigmod函数在它在定义域内的梯度都不大于0.25。这样训练会非常的慢。\n逻辑回归在训练的过程当中,如果有很多的特征高度相关或者说有一个特征重复了100遍,会造成怎样的影响? 先说结论,如果在损失函数最终收敛的情况下,其实就算有很多特征高度相关也不会影响分类器的效果。\n但是对特征本身来说的话,假设只有一个特征,在不考虑采样的情况下,你现在将它重复100遍。训练以后完以后,数据还是这么多,但是这个特征本身重复了100遍,实质上将原来的特征分成了100份,每一个特征都是原来特征权重值的百分之一\n如果在随机采样的情况下,其实训练收敛完以后,还是可以认为这100个特征和原来那一个特征扮演的效果一样,只是可能中间很多特征的值正负相消了。\n为什么我们还是会在训练的过程当中将高度相关的特征去掉? 去掉高度相关的特征会让模型的可解释性更好 可以大大提高训练的速度。如果模型当中有很多特征高度相关的话,就算损失函数本身收敛了,但实际上参数是没有收敛的,这样会拉低训练的速度。其次是特征多了,本身就会增大训练的时间。 4.逻辑回归的优缺点总结 优点\n形式简单,模型的可解释性非常好。从特征的权重可以看到不同的特征对最后结果的影响,某个特征的权重值比较高,那么这个特征最后对结果的影响会比较大。\n模型效果不错。在工程上是可以接受的(作为baseline),如果特征工程做的好,效果不会太差,并且特征工程可以大家并行开发,大大加快开发的速度。\n训练速度较快。分类的时候,计算量仅仅只和特征的数目相关。并且逻辑回归的分布式优化sgd发展比较成熟,训练的速度可以通过堆机器进一步提高,这样我们可以在短时间内迭代好几个版本的模型。\n资源占用小,尤其是内存。因为只需要存储各个维度的特征值,。\n方便输出结果调整。逻辑回归可以很方便的得到最后的分类结果,因为输出的是每个样本的概率分数,我们可以很容易的对这些概率分数进行cutoff,也就是划分阈值(大于某个阈值的是一类,小于某个阈值的是一类)。\n但是逻辑回归本身也有许多的缺点:\n准确率并不是很高。因为形式非常的简单(非常类似线性模型),很难去拟合数据的真实分布。\n很难处理数据不平衡的问题。举个例子:如果我们对于一个正负样本非常不平衡的问题比如正负样本比 10000:1.我们把所有样本都预测为正也能使损失函数的值比较小。但是作为一个分类器,它对正负样本的区分能力不会很好。\n处理非线性数据较麻烦。逻辑回归在不引入其他方法的情况下,只能处理线性可分的数据,或者进一步说,处理二分类的问题 。\n逻辑回归本身无法筛选特征。有时候,我们会用gbdt来筛选特征,然后再上逻辑回归。\n怎么防止过拟合? 通过正则化方法。正则化方法是指在进行目标函数或代价函数优化时,在目标函数或代价函数后面加上一个正则项,一般有L1正则与L2正则等\n为什么正则化可以防止过拟合? 过拟合表现在训练数据上的误差非常小,而在测试数据上误差反而增大。其原因一般是模型过于复杂,过分得去拟合数据的噪声**。正则化则是对模型参数添加先验,使得模型复杂度较小,对于噪声扰动相对较小**。\n最简单的解释就是加了先验。在数据少的时候,先验知识可以防止过拟合。\n举个例子:\n硬币,推断正面朝上的概率。如果只能抛5次,很可能5次全正面朝上,这样你就得出错误的结论:正面朝上的概率是1\u0026mdash;\u0026mdash;\u0026ndash;过拟合!如果你在模型里加正面朝上概率是0.5的先验,结果就不会那么离谱。这其实就是正则\nL1正则和L2正则有什么区别? 相同点:都用于避免过拟合\n不同点:L2与L1的区别在于,L1正则是拉普拉斯先验,而L2正则则是高斯先验。L1可以产生稀疏解,可以让一部分特征的系数缩小到0,从而间接实现特征选择。所以L1适用于特征之间有关联的情况。L2让所有特征的系数都缩小,但是不会减为0,它会使优化求解稳定快速。所以L2适用于特征之间没有关联的情况\n因为L1和服从拉普拉斯分布,所以L1在0点处不可导,难以计算,这个方法可以使用Proximal Algorithms或者ADMM来解决。\n如何用LR解决非线性问题? 将特征离散成高维的01特征可以解决分类模型的非线性问题\n3. 逻辑回归是线性模型么,说下原因? 狭义线性模型的前提是因变量误差是正态分布,但很多情况下这并不满足,比如对足球比分的预测显然用泊松分布是更好的选择。而广义的”广”在于引入了联系函数,于是误差变成了只要满足指数分布族就行了,因此适用性更强。\n​ 简单来说广义线性模型分为两个部分,第一个部分是描述了自变量和因变量的系统关系,也就是”线性”所在;第二个部分是描述了因变量的误差,这可以建模成各种满足指数分布族的分布。而联系函数就是把这两个部分连接起来的桥梁,也就是把因变量的期望表示为了自变量线性组合的函数。而像逻辑回归这样的简单广义线性模型,实际是将自变量的线性组合变成了联系函数的自然参数,这类联系函数也可以叫做正则联系函数。\n4. 逻辑回归算法为什么用的是sigmoid函数而不用阶跃函数? 阶跃函数虽然能够直观刻画分类的错误率,但是由于其非凸、非光滑的特点,使得算法很难直接对该函数进行优化。而sigmoid函数本身的特征(光滑无限阶可导),以及完美的映射到概率空间,就用于逻辑回归了。解释上可从三个方面:- 最大熵定理- 伯努利分布假设- 贝叶斯理论\n参考: https://fengxc.me/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E7%82%B9-%E6%8C%81%E7%BB%AD%E6%9B%B4%E6%96%B0%E4%B8%AD.html\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%80%BB%E8%BE%91%E5%9B%9E%E5%BD%92%E7%9A%84%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98%E6%80%BB%E7%BB%93/","summary":"1.简介 逻辑回归是面试当中非常喜欢问到的一个机器学习算法,因为表面上看逻辑回归形式上很简单,很好掌握,但是一问起来就容易懵逼。所以在面试的时","title":"逻辑回归的常见面试题总结"},{"content":"调参 ★ 在 scikit-learn 中,Random Forest(以下简称RF)的分类类是 RandomForestClassifier,回归类是 RandomForestRegressor。\nRF 需要调参的参数也包括两部分,第一部分是 Bagging 框架的参数,第二部分是 CART 决策树的参数。下面我们就对这些参数做一个介绍。\nRF 框架参数 首先我们关注于 RF 的 Bagging 框架的参数。这里可以和 GBDT 对比来学习。GBDT 的框架参数比较多,重要的有最大迭代器个数,步长和子采样比例,调参起来比较费力。但是 RF 则比较简单,这是因为 bagging 框架里的各个弱学习器之间是没有依赖关系的,这减小的调参的难度。换句话说,达到同样的调参效果,RF 调参时间要比 GBDT 少一些。\n下面我来看看 RF 重要的 Bagging 框架的参数,由于 RandomForestClassifier 和 RandomForestRegressor 参数绝大部分相同,这里会将它们一起讲,不同点会指出。\nn_estimators:也就是弱学习器的最大迭代次数,或者说最大的弱学习器的个数。一般来说 n_estimators 太小,容易欠拟合,n_estimators 太大,计算量会太大,并且 n_estimators 到一定的数量后,再增大 n_estimators 获得的模型提升会很小,所以一般选择一个适中的数值。默认是 100 。\noob_score:即是否采用袋外样本来评估模型的好坏。默认识 False 。个人推荐设置为 True ,因为袋外分数反应了一个模型拟合后的泛化能力。\ncriterion: 即 CART 树做划分时对特征的评价标准。分类模型和回归模型的损失函数是不一样的。分类 RF 对应的 CART 分类树默认是基尼系数 gini ,另一个可选择的标准是信息增益。回归 RF 对应的 CART 回归树默认是均方差 mse ,另一个可以选择的标准是绝对值差 mae 。一般来说选择默认的标准就已经很好的。\n从上面可以看出, RF 重要的框架参数比较少,主要需要关注的是 n_estimators,即 RF 最大的决策树个数。\nRF 决策树参数 RF 划分时考虑的最大特征数 max_features:\n可以使用很多种类型的值,默认是 auto ,意味着划分时最多考虑 $\\sqrt {N}$ 个特征;如果是 log2 意味着划分时最多考虑 $log_2N$ 个特征;如果是 sqrt 或者 auto 意味着划分时最多考虑$\\sqrt {N}$ 个特征。如果是整数,代表考虑的特征绝对数。如果是浮点数,代表考虑特征百分比,即考虑(百分比 x $N$)取整后的特征数。其中 $N$ 为样本总特征数。一般我们用默认的 auto 就可以了,如果特征数非常多,我们可以灵活使用刚才描述的其他取值来控制划分时考虑的最大特征数,以控制决策树的生成时间。\n决策树最大深度 max_depth:\n默认可以不输入,如果不输入的话,决策树在建立子树的时候不会限制子树的深度。一般来说,数据少或者特征少的时候可以不管这个值。如果模型样本量多,特征也多的情况下,推荐限制这个最大深度,具体的取值取决于数据的分布。常用的可以取值 10-100 之间。\n内部节点再划分所需最小样本数 min_samples_split:\n这个值限制了子树继续划分的条件,如果某节点的样本数少于 min_samples_split,则不会继续再尝试选择最优特征来进行划分。默认是 2,如果样本量不大,不需要管这个值。如果样本量数量级非常大,则推荐增大这个值。\n叶子节点最少样本数 min_samples_leaf:\n这个值限制了叶子节点最少的样本数,如果某叶子节点数目小于样本数,则会和兄弟节点一起被剪枝。 默认是 1,可以输入最少的样本数的整数,或者最少样本数占样本总数的百分比。如果样本量不大,不需要管这个值。如果样本量数量级非常大,则推荐增大这个值。\n叶子节点最小的样本权重 min_weight_fraction_leaf:\n这个值限制了叶子节点所有样本权重和的最小值,如果小于这个值,则会和兄弟节点一起被剪枝。默认是 0,就是不考虑权重问题。一般来说,如果我们有较多样本有缺失值,或者分类树样本的分布类别偏差很大,就会引入样本权重,这时我们就要注意这个值了。\n最大叶子节点数 max_leaf_nodes:\n通过限制最大叶子节点数,可以防止过拟合,默认是 None ,即不限制最大的叶子节点数。如果加了限制,算法会建立在最大叶子节点数内最优的决策树。如果特征不多,可以不考虑这个值,但是如果特征分成多的话,可以加以限制,具体的值可以通过交叉验证得到。\n节点划分最小不纯度 min_impurity_split:\n这个值限制了决策树的增长,如果某节点的不纯度(基于基尼系数,均方差)小于这个阈值,则该节点不再生成子节点。即为叶子节点 。一般不推荐改动,默认值 1e-7。\n上面决策树参数中最重要的包括最大特征数 max_features, 最大深度 max_depth, 内部节点再划分所需最小样本数 min_samples_split 和叶子节点最少样本数 min_samples_leaf\n调参实例 我们使用社交网络数据集为例,利用 sklearn.grid_search 中的 GridSearchCV 类进行网格搜索最佳参数,现有两种调参思路。\n串行调参思路:调整一个或几个参数,固定其他参数,得到所调整参数的最优。重复以上步骤,使得每个参数都得打最优 并行调参思路:参数同时进行调整,不过计算量比较大,但是一次性能够找到最好的参数 载入需要的库 1 2 from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestClassifier 调整参数 n_estimators 1 2 3 4 5 6 7 8 param_test1 = {\u0026#39;n_estimators\u0026#39;: list(range(10,71,10))} classifier = RandomForestClassifier(n_estimators=10, min_samples_split=20,min_samples_leaf=2,max_depth=9,criterion=\u0026#39;entropy\u0026#39;, oob_score=True,random_state=0) gsearch1= GridSearchCV(estimator=classifier,param_grid=param_test1,scoring=\u0026#39;roc_auc\u0026#39;,cv=5) gsearch1.fit(X_train,y_train) print(gsearch1.cv_results_) print(gsearch1.best_params_) print(gsearch1.best_score_) 调整参数 max_depth 得到了最佳的弱学习器迭代次数为 30 ,我们将相应参数进行修改,接着我们对决策树最大深度 max_depth 和内部节点再划分所需最小样本数 min_samples_split 进行网格搜索。\n1 2 3 4 5 6 7 8 9 10 param_test2 = {\u0026#39;max_depth\u0026#39;: list(range(3, 12, 1)), \u0026#39;min_samples_split\u0026#39;: list(range(5, 101, 5))} classifier = RandomForestClassifier(n_estimators=30, min_samples_leaf=2, criterion=\u0026#39;entropy\u0026#39;, oob_score=True, random_state=0) gsearch2 = GridSearchCV(estimator=classifier, param_grid=param_test2, scoring=\u0026#39;roc_auc\u0026#39;, iid=False, cv=5) gsearch2.fit(X_train, y_train) print(gsearch2.best_params_) print(gsearch2.best_score_) Result\n1 2 {\u0026#39;max_depth\u0026#39;: 7, \u0026#39;min_samples_split\u0026#39;: 20} 0.9426982890941702 调整参数 min_samples_split 和 min_samples_split 对于内部节点再划分所需最小样本数 min_samples_split ,我们暂时不能一起定下来,因为这个还和决策树其他的参数存在关联。下面我们再对内部节点再划分所需最小样本数 min_samples_split 和叶子节点最少样本数 min_samples_leaf 一起调参。\n1 2 3 4 5 6 7 8 9 10 param_test3 = {\u0026#39;min_samples_split\u0026#39;: list(range(5, 51, 5)), \u0026#39;min_samples_leaf\u0026#39;: list(range(5, 51, 5))} classifier = RandomForestClassifier(n_estimators=30, max_depth=7, criterion=\u0026#39;entropy\u0026#39;, oob_score=True, random_state=0) gsearch3 = GridSearchCV(estimator=classifier, param_grid=param_test3, scoring=\u0026#39;roc_auc\u0026#39;, iid=False, cv=5) gsearch3.fit(X_train, y_train) print(gsearch3.best_params_) print(gsearch3.best_score_) Result:\n1 2 {\u0026#39;min_samples_leaf\u0026#39;: 5, \u0026#39;min_samples_split\u0026#39;: 30} 0.9419350440517489 至此相关参数已调整完毕,不过这种串行调优方式并不能一次性找到最好的解。我们可以将参数可选的值一次性的导入网格,使得所有参数调优同步进行,但是这样会降低程序运行的效率,可能需要大量的计算资源。如果算力强大,还是值得一试的。\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9A%8F%E6%9C%BA%E6%A3%AE%E6%9E%97%E5%9B%9E%E5%BD%92%E6%A0%91%E6%A8%A1%E5%9E%8B/","summary":"调参 ★ 在 scikit-learn 中,Random Forest(以下简称RF)的分类类是 RandomForestClassifier,回归类是 RandomFores","title":"随机森林(回归树)模型"},{"content":"随机森林算法思想 随机森林(Random Forest)使用多个CART决策树作为弱学习器,不同决策树之间没有关联。当我们进行分类任务时,新的输入样本进入,就让森林中的每一棵决策树分别进行判断和分类,每个决策树会得到一个自己的分类结果,决策树的分类结果中哪一个分类最多,那么随机森林就会把这个结果当做最终的结果。\n随机森林在生成决策树的时候用随机选择的特征,即使用Bagging方法。这么做的原因是:如果训练集中的某几个特征对输出的结果有很强的预测性,那么这些特征会被每个决策树所应用,这样会导致树之间具有相关性,这样并不会减小模型的方差。\n随机森林对决策树的建立做了一些改进:\n随机森林不会像普通决策树一样选择最优特征进行子树的划分,而是随机选择节点上的一部分样本特征:Nsub(子集),然后在随机挑选出来的集合Nsub中,选择一个最优的特征来做决策树的左右子树划分。一般情况下,推荐子集Nsub内特征的个数为log2d个。这样进一步增强了模型的泛化能力。\n如果Nsub=N,则此时随机森林的CART决策树和普通的CART决策树没有区别。Nsub越小,则模型越健壮。当然此时对于训练集的拟合程度会变差。也就是说Nsub越小,模型的方差会减小,但是偏差会增大。在实际案例中,一般会通过交叉验证调参获取一个合适的的Nsub值。\n随机森林有一个缺点:不像决策树一样有很好地解释性。但是,随机森林有更好地准确性,同时也并不需要修剪随机森林。对于随机森林来说,只需要选择一个参数,生成决策树的个数。通常情况下,决策树的个数越多,性能越好,但是,计算开销同时也增大了。\n随机森林建立过程 第一步:原始训练集D中有N个样本,且每个样本有W维特征。从数据集D中有放回的随机抽取x个样本(Bootstraping方法)组成训练子集Dsub,一共进行w次采样,即生成w个训练子集Dsub。\n第二步:每个训练子集Dsub形成一棵决策树,形成了一共w棵决策树。而每一次未被抽到的样本则组成了w个oob(用来做预估)。\n第三步:对于单个决策树,树的每个节点处从M个特征中随机挑选m(m\u0026lt;M)个特征,按照结点不纯度最小原则进行分裂。每棵树都一直这样分裂下去,直到该节点的所有训练样例都属于同一类。在决策树的分裂过程中不需要剪枝。\n第四步:根据生成的多个决策树分类器对需要进行预测的数据进行预测。根据每棵决策树的投票结果,如果是分类树的话,最后取票数最高的一个类别;如果是回归树的话,利用简单的平均得到最终结果。\n随机森林算法优缺点总结及面试问题 随机森林是Bagging的一个扩展变体,是在以决策树为基学习器构建Bagging集成的基础上,进一步在决策树的训练过程中引入了随机属性选择。\n随机森林简单、容易实现、计算开销小,在很多实际应用中都变现出了强大的性能,被誉为“代表集成学习技术水平的方法”。可以看出,随机森林对Bagging只做了小改动。并且,Bagging满足差异性的方法是对训练集进行采样;而随机森林不但对训练集进行随机采样,而且还随机选择特征子集,这就使最终集成的泛化性进一步提升。\n随着基学习器数目的增加,随机森林通常会收敛到更低的泛化误差,并且训练效率是优于Bagging的。\n总结一下随机森林的优缺点:\n优点:\n训练可以高度并行化,对于大数据时代的大样本训练速度有优势。个人觉得这是的最主要的优点。 由于可以随机选择决策树节点划分特征,这样在样本特征维度很高的时候,仍然能高效的训练模型。 在训练后,可以给出各个特征对于输出的重要性。 由于采用了随机采样,训练出的模型的方差小,泛化能力强。 相对于Boosting系列的Adaboost和GBDT, RF实现比较简单。 对部分特征缺失不敏感。 缺点有:\n在某些噪音比较大的样本集上,RF模型容易陷入过拟合。 取值划分比较多的特征容易对RF的决策产生更大的影响,从而影响拟合的模型的效果。 下面看几个面试问题:\n1、为什么要有放回的抽样?保证样本集间有重叠,若不放回,每个训练样本集及其分布都不一样,可能导致训练的各决策树差异性很大,最终多数表决无法 “求同”,即最终多数表决相当于“求同”过程。\n2、为什么RF的训练效率优于bagging?因为在个体决策树的构建过程中,Bagging使用的是“确定型”决策树,bagging在选择划分属性时要对每棵树是对所有特征进行考察;而随机森林仅仅考虑一个特征子集。\n3、随机森林需要剪枝吗?不需要,后剪枝是为了避免过拟合,随机森林随机选择变量与树的数量,已经避免了过拟合,没必要去剪枝了。一般rf要控制的是树的规模,而不是树的置信度,剩下的每棵树需要做的就是尽可能的在自己所对应的数据(特征)集情况下尽可能的做到最好的预测结果。剪枝的作用其实被集成方法消解了,所以用处不大。\nExtra-Tree及其与RF的区别 Extra-Tree是随机森林的一个变种, 原理几乎和随机森林一模一样,可以称为:“极其随机森林”,即决策树在节点的划分上,使用随机的特征和随机的阈值。\n特征和阈值提供了额外随机性,抑制了过拟合,再一次用高偏差换低方差。它还使得 Extra-Tree 比规则的随机森林更快地训练,因为在每个节点上找到每个特征的最佳阈值是生长树最耗时的任务之一。\nExtra-Tree与随机森林的区别有以下两点:\n对于每个决策树的训练集,随机森林采用的是随机采样bootstrap来选择采样集作为每个决策树的训练集,而Extra-Tree一般不采用随机采样,即每个决策树采用原始训练集。 在选定了划分特征后,随机森林的决策树会基于基尼系数,均方差之类的原则,选择一个最优的特征值划分点,这和传统的决策树相同。但是Extra-Tree比较的激进,他会随机的选择一个特征值来划分决策树。 从第二点可以看出,由于随机选择了特征值的划分点位,而不是最优点位,这样会导致生成的决策树的规模一般会大于随机森林所生成的决策树。也就是说,模型的方差相对于随机森林进一步减少,但是偏倚相对于随机森林进一步增大。在某些时候,Extra-Tree的泛化能力比随机森林更好。\nRF评估特征重要性 在实际业务场景中,我们会关系如何在高维数据中选择对结果影响最大的前n个特征。我们可以使用PCA、LASSO等方法,当然也可以用RF算法来进行特征选择。感兴趣的话。\nRF算法的有一个典型的应用:评估单个特征变量的重要性并进行特征选择。\n举一个具体的应用场景:银行贷款业务中能否正确的评估企业的信用度,关系到能否有效地回收贷款。但是信用评估模型的数据特征有很多,其中不乏有很多噪音,所以需要计算出每一个特征的重要性并对这些特征进行一个排序,进而可以从所有特征中选择出重要性靠前的特征。\n下面我们来看看评估特征重要性的步骤:\n对于RF中的每一棵决策树,选择OOB数据计算模型的预测错误率,记为Error1。(在随机森林算法中不需要再进行交叉验证来获取测试集误差的无偏估计)\n然后在OOB中所有样本的特征A上加入随机噪声,接着再次用OOB数据计算模型预测错误率,记为Error2。\n若森林中有N棵树,则特征A的重要性为 求和(Error2-Error1/N)。\n我们细品:在某一特征A上增加了噪音,那么就有理由相信错误率Error2要大于Error1,Error2越大说明特征A重要。\n可以这么理解,小A从公司离职了,这个公司倒闭了,说明小A很重要;如果小A走了,公司没变化,说明小A也没啥用。\n在sklearn中我们可以这么做:\n1 2 3 4 5 6 7 8 from sklearn.cross_validation import train_test_split from sklearn.ensemble import RandomForestClassifier (处理数据) rf_clf = RandomForestClassifier(n_estimators=1000, random_state=666) rf_clf.fit(x_train, y_train) importances = rf_clf.feature_importances_ 从重要性到特征选择 我们已经知道如何使用RF算法评估特征重要性了,那么在此基础上做特征选择就很简单了:\n对每一个特征都计算其特征重要性。 将这些特征按照重要性从大到小排序。 设置要删除的特征的比率,删除重要性最小的那些特征。 剩下的那些特征,继续计算每个每个特征的重要性,然后循环回第一步,直到剩余特征数达到我们的要求。 承接上面的代码,我们进行特征选择:\n1 2 threshold = [某个阈值] x_selected = x_train[:, importances \u0026gt; threshold] ","permalink":"https://reid00.github.io/en/posts/ml/%E9%9A%8F%E6%9C%BA%E6%A3%AE%E6%9E%97%E7%AE%97%E6%B3%95%E5%8F%8A%E5%85%B6%E5%9C%A8%E7%89%B9%E5%BE%81%E9%80%89%E6%8B%A9%E4%B8%AD%E7%9A%84%E5%BA%94%E7%94%A8/","summary":"随机森林算法思想 随机森林(Random Forest)使用多个CART决策树作为弱学习器,不同决策树之间没有关联。当我们进行分类任务时,新的输","title":"随机森林算法及其在特征选择中的应用"},{"content":"什么是生成模型和判别模型? 从本质上讲,生成模型和判别模型是解决分类问题的两类基本思路。首先,您得先了解,分类问题,就是给定一个数据x,要判断它对应的标签y(这么naive的东西都要解释下,求面试官此时内心的阴影面积,嘎嘎)。生成模型就是要学习x和y的联合概率分布P(x,y),然后根据贝叶斯公式来求得条件概率P(y|x),预测条件概率最大的y。贝叶斯公式这么简单的知识相信您也了解,我就不啰嗦了。判别模型就是直接学习条件概率分布P(y|x)。\n举个栗子 例子1 假设你从来没有见过大象和猫,连听都没有听过,这时,给你看了一张大象的照片和一张猫的照片。如下所示:\n然后牵来我家的大象(面试官:你家开动物园的吗?),让你判断这是大象还是猫。你咋办?\n你开始回想刚刚看过的照片,大概记起来,大象和猫比起来,有个长鼻子,而眼前这个家伙也有个长鼻子,所以,你兴奋地说:“这是大象!”恭喜你答对了!\n你也有可能这样做,你努力回想刚才的两张照片,然后用笔把它们画在了纸上,拿着纸和我家的大象做比较,你发现,眼前的动物更像是大象。于是,你惊喜地宣布:“这玩意是大象!”恭喜你又答对了!\n在这个问题中,第一个解决问题的思路就是判别模型,因为你只记住了大象和猫之间的不同之处。第二个解决问题的思路就是生成模型,因为你实际上学习了什么是大象,什么是猫。\n例子2 来来来,看一下这四个形式为(x,y)的样本。(1,0), (1,0), (2,0), (2, 1)。假设,我们想从这四个样本中,学习到如何通过x判断y的模型。用生成模型,我们要学习P(x,y)。如下所示:\n我们学习到了四个概率值,它们的和是1,这就是P(x,y)。\n我们也可以用判别模型,我们要学习P(y|x),如下所示:\n我们同样学习到了四个概率值,但是,这次,是每一行的两个概率值的和为1了。让我们具体来看一下,如何使用这两个模型做判断。\n假设 x=1。\n对于生成模型, 我们会比较:\nP(x=1,y=0) = 1/2 P(x=1,y=1) = 0 我们发现P(x=1,y=0)的概率要比P(x=1,y=1)的概率大,所以,我们判断:x=1时,y=0。\n对于判别模型,我们会比较:\nP(y=0|x=1) = 1 P(y=1|x=1) = 0 同样,P(y=0|x=1)要比P(y=1|x=1)大,所以,我们判断:x=1时,y=0。\n我们看到,虽然最后预测的结果一样,但是得出结果的逻辑却是完全不同的。两个栗子说完,你心里感到很痛快,面试官脸上也露出了赞赏的微笑,但是,他突然问了一个问题。\n生成模型为啥叫生成模型 这个问题着实让你没想到,不过,聪明的你略加思考,应该就可以想到。生成模型之所以叫生成模型,是因为,它背后的思想是,x是特征,y是标签,什么样的标签就会生成什么样的特征。好比说,标签是大象,那么可能生成的特征就有大耳朵,长鼻子等等。\n当我们来根据x来判断y时,我们实际上是在比较,什么样的y标签更可能生成特征x,我们预测的结果就是更可能生成x特征的y标签。\n常见的生成模型和判别模型有哪些呢 生成模型\nHMM\n朴素贝叶斯\n判别模型\n逻辑回归\nSVM\nCRF\n最近邻\n一般的神经网络\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%94%9F%E6%88%90%E6%A8%A1%E5%9E%8Bvs%E5%88%A4%E5%88%AB%E6%A8%A1%E5%9E%8B/","summary":"什么是生成模型和判别模型? 从本质上讲,生成模型和判别模型是解决分类问题的两类基本思路。首先,您得先了解,分类问题,就是给定一个数据x,要判断","title":"生成模型vs判别模型"},{"content":"介绍 称函数为效用函数 线性回归模型看起来非常简单,简单到让人怀疑其是否有研究价值以及使用价值。但实际上,线性回归模型可以说是最重要的数学模型之一,很多模型都是建立在它的基础之上,可以被称为是“模型之母”。\n1.1 什么是简单线性回归 所谓简单,是指只有一个样本特征,即只有一个自变量;所谓线性,是指方程是线性的;所谓回归,是指用方程来模拟变量之间是如何关联的。\n简单线性回归,其思想简单,实现容易(与其背后强大的数学性质相关。同时也是许多强大的非线性模型(多项式回归、逻辑回归、SVM)的基础。并且其结果具有很好的可解释性。\n1.2 一种基本推导思 我们所谓的建模过程,其实就是找到一个模型,最大程度的拟合我们的数据。 在简单线回归问题中,模型就是我们的直线方程:y = ax + b 。\n要想最大的拟合数据,本质上就是找到没有拟合的部分,也就是损失的部分尽量小,就是损失函数(loss function)(也有算法是衡量拟合的程度,称函数为效用函数(utility function)):\n因此,推导思路为:\n通过分析问题,确定问题的损失函数或者效用函数; 然后通过最优化损失函数或者效用函数,获得机器学习的模型 近乎所有参数学习算法都是这样的套路,区别是模型不同,建立的目标函数不同,优化的方式也不同。\n回到简单线性回归问题,目标:\n已知训练数据样本、 ,找到和的值,使 尽可能小\n这是一个典型的最小二乘法问题(最小化误差的平方)\n通过最小二乘法可以求出a、b的表达式:\n最小二乘法 2.1 由损失函数引出一堆“风险” 2.1.1 损失函数 在机器学习中,所有的算法模型其实都依赖于最小化或最大化某一个函数,我们称之为“目标函数”。\n最小化的这组函数被称为“损失函数”。什么是损失函数呢?\n损失函数描述了单个样本预测值和真实值之间误差的程度。用来度量模型一次预测的好坏。\n损失函数是衡量预测模型预测期望结果表现的指标。损失函数越小,模型的鲁棒性越好。。\n常用损失函数有:\n0-1损失函数:用来表述分类问题,当预测分类错误时,损失函数值为1,正确为 平方损失函数:用来描述回归问题,用来表示连续性变量,为预测值与真实值差值的平方。(误差值越大、惩罚力度越强,也就是对差值敏感)\n绝对损失函数:用在回归模型,用距离的绝对值来衡量 对数损失函数:是预测值Y和条件概率之间的衡量。事实上,该损失函数用到了极大似然估计的思想。P(Y|X)通俗的解释就是:在当前模型的基础上,对于样本X,其预测值为Y,也就是预测正确的概率。由于概率之间的同时满足需要使用乘法,为了将其转化为加法,我们将其取对数。最后由于是损失函数,所以预测正确的概率越高,其损失值应该是越小,因此再加个负号取个反。 以上损失函数是针对于单个样本的,但是一个训练数据集中存在N个样本,N个样本给出N个损失,如何进行选择呢?\n这就引出了风险函数。\n2.1.2 期望风险 期望风险是损失函数的期望,用来表达理论上模型f(X)关于联合分布P(X,Y)的平均意义下的损失。又叫期望损失/风险函数。\n2.1.3 经验风险 模型f(X)关于训练数据集的平均损失,称为经验风险或经验损失。\n其公式含义为:模型关于训练集的平均损失(每个样本的损失加起来,然后平均一下)\n经验风险最小的模型为最优模型。在训练集上最小经验风险最小,也就意味着预测值和真实值尽可能接近,模型的效果越好。公式含义为取训练样本集中对数损失函数平均值的最小。\n2.1.4 经验风险最小化和结构风险最小化 期望风险是模型关于联合分布的期望损失,经验风险是模型关于训练样本数据集的平均损失。根据大数定律,当样本容量N趋于无穷时,经验风险趋于期望风险。\n因此很自然地想到用经验风险去估计期望风险。但是由于训练样本个数有限,可能会出现过度拟合的问题,即决策函数对于训练集几乎全部拟合,但是对于测试集拟合效果过差。因此需要对其进行矫正:\n结构风险最小化:当样本容量不大的时候,经验风险最小化容易产生“过拟合”的问题,为了“减缓”过拟合问题,提出了结构风险最小理论。结构风险最小化为经验风险与复杂度同时较小。 通过公式可以看出,结构风险:在经验风险上加上一个正则化项(regularizer),或者叫做罚项(penalty) 。正则化项是J(f)是函数的复杂度再乘一个权重系数(用以权衡经验风险和复杂度)\n2.1.5 小结 1、损失函数:单个样本预测值和真实值之间误差的程度。\n2、期望风险:是损失函数的期望,理论上模型f(X)关于联合分布P(X,Y)的平均意义下的损失。\n3、经验风险:模型关于训练集的平均损失(每个样本的损失加起来,然后平均一下)。\n4、结构风险:在经验风险上加上一个正则化项,防止过拟合的策略。\n2.2 最小二乘法 2.2.1 什么是最小二乘法 言归正传,进入最小二乘法的部分。\n大名鼎鼎的最小二乘法,虽然听上去挺高大上,但是思想还是挺朴素的,符合大家的直觉。\n最小二乘法源于法国数学家阿德里安的猜想:\n对于测量值来说,让总的误差的平方最小的就是真实值。这是基于,如果误差是随机的,应该围绕真值上下波动。\n即:\n那么为了求出这个二次函数的最小值,对其进行求导,导数为0的时候取得最小值:\n进而:\n正好是算数平均数(算数平均数是最小二乘法的特例)。\n这就是最小二乘法,所谓“二乘”就是平方的意思。\n(高斯证明过:如果误差的分布是正态分布,那么最小二乘法得到的就是最有可能的值。)\n2.2.2 线性回归中的应用 我们在第一章中提到:\n目标是,找到a和b,使得损失函数:尽可能的小。\n这里,将简单线性问题转为最优化问题。下面对函数的各个位置分量求导,导数为0的地方就是极值:\n对 进行求导:\n然后mb提到等号前面,两边同时除以m,等号右面的每一项相当于均值。\n现在 对 进行求导:\n此时将对 进行求导得到的结果 代入上式中,得到:\n将上式进行整理,得到\n将上式继续进行整理:\n这样在实现的时候简单很多。\n最终我们通过最小二乘法得到a、b的表达式:\n总结 本章中,我们从数学的角度了解了简单线性回归,从中总结出一类机器学习算法的基本思路:\n通过分析问题,确定问题的损失函数或者效用函数; 然后通过最优化损失函数或者效用函数,获得机器学习的模型。 理解了损失函数的概念,并列举出了常见损失函数,并引出了一堆“风险”。最后为了求出最小的损失函数,学习了最小二乘法,并进行了完整的数学推导。\n下一篇,我们将会实现简单线性回归,并添加到我们自己的工程文件里。\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%BA%BF%E6%80%A7%E5%9B%9E%E5%BD%92/","summary":"介绍 称函数为效用函数 线性回归模型看起来非常简单,简单到让人怀疑其是否有研究价值以及使用价值。但实际上,线性回归模型可以说是最重要的数学模型之","title":"线性回归"},{"content":"一、线性模型预测一个样本的损失量 损失量:模型对样本的预测结果和该样本对应的实际结果的差距;\n1)为什么会想到用 y = -log(x) 函数? (该函数称为 惩罚函数:预测结果与实际值的偏差越大,惩罚越大) y = 1(p ≥ 0.5)时,cost = -log(p),p 越小,样本发生概率越小(最小为 0),则损失函数越大,分类预测值和实际值的偏差越大;相反,p 越大,样本发生概率越大(最大为 0.5),则损失函数越小,则预测值和实际值的偏差越小; y = 0(p ≤ 0.5)时,cost = -log(1-p),p 越小,样本发生概率越小(最小为 0.5),则损失函数越大,分类预测值和实际值的偏差越大;相反,p 越大,样本发生概率越大(最大为 1),则损失函数越小,则预测值和实际值的偏差越小; 2)求一个样本的损失量 由于逻辑回归解决的是分类问题,而且是二分类,因此定义损失函数时也要有两类\n惩罚函数变形:\n惩罚函数作用:计算预测结果针对实际值的损失量;\n已知样本发生的概率 p(也可以相应求出预测值),以及该样本的实际分类结果,得出此次预测结果针对真值的损失量是多少; 二、求数据集的损失函数 模型变形,得到数据集的损失函数:数据集中的所有样本的损失值的和; 最终的损失函数模型 该模型不能优化成简单的数学表达式(或者说是正规方程解:线性回归算法找那个的fit_normal() 方法),只能使用梯度下降法求解; 该函数为凸函数,没有局部最优解,只存在全局最优解; 三、逻辑回归损失函数的梯度 损失函数: 1)σ(t) 函数的导数 2)log(σ(t)) 函数的导数 变形:\n3)log(1 - σ(t)) 函数的导数 4)对损失函数 J(θ) 的其中某一项(第 i 行,第 j 列)求导 两式相加: 5)损失函数 J(θ) 的梯度 与线性回归梯度对比\n注:两者的预测值 ý 不同; 梯度向量化处理 四、代码实现逻辑回归算法 逻辑回归算法是在线性回归算法的基础上演变的;\n1)代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 import numpy as np from .metrics import accuracy_score # accuracy_score方法:查看准确率 class LogisticRegression: def __init__(self): \u0026#34;\u0026#34;\u0026#34;初始化Logistic Regression模型\u0026#34;\u0026#34;\u0026#34; self.coef_ = None self.intercept_ = None self._theta = None def _sigmiod(self, t): \u0026#34;\u0026#34;\u0026#34;函数名首部为\u0026#39;_\u0026#39;,表明该函数为私有函数,其它模块不能调用\u0026#34;\u0026#34;\u0026#34; return 1. / (1. + np.exp(-t)) def fit(self, X_train, y_train, eta=0.01, n_iters=1e4): \u0026#34;\u0026#34;\u0026#34;根据训练数据集X_train, y_train, 使用梯度下降法训练Logistic Regression模型\u0026#34;\u0026#34;\u0026#34; assert X_train.shape[0] == y_train.shape[0], \\ \u0026#34;the size of X_train must be equal to the size of y_train\u0026#34; def J(theta, X_b, y): y_hat = self._sigmiod(X_b.dot(theta)) try: return - np.sum(y*np.log(y_hat) + (1-y)*np.log(1-y_hat)) / len(y) except: return float(\u0026#39;inf\u0026#39;) def dJ(theta, X_b, y): return X_b.T.dot(self._sigmiod(X_b.dot(theta)) - y) / len(X_b) def gradient_descent(X_b, y, initial_theta, eta, n_iters=1e4, epsilon=1e-8): theta = initial_theta cur_iter = 0 while cur_iter \u0026lt; n_iters: gradient = dJ(theta, X_b, y) last_theta = theta theta = theta - eta * gradient if (abs(J(theta, X_b, y) - J(last_theta, X_b, y)) \u0026lt; epsilon): break cur_iter += 1 return theta X_b = np.hstack([np.ones((len(X_train), 1)), X_train]) initial_theta = np.zeros(X_b.shape[1]) self._theta = gradient_descent(X_b, y_train, initial_theta, eta, n_iters) self.intercept_ = self._theta[0] self.coef_ = self._theta[1:] return self def predict_proda(self, X_predict): \u0026#34;\u0026#34;\u0026#34;给定待预测数据集X_predict,返回 X_predict 中的样本的发生的概率向量\u0026#34;\u0026#34;\u0026#34; assert self.intercept_ is not None and self.coef_ is not None, \\ \u0026#34;must fit before predict!\u0026#34; assert X_predict.shape[1] == len(self.coef_), \\ \u0026#34;the feature number of X_predict must be equal to X_train\u0026#34; X_b = np.hstack([np.ones((len(X_predict), 1)), X_predict]) return self._sigmiod(X_b.dot(self._theta)) def predict(self, X_predict): \u0026#34;\u0026#34;\u0026#34;给定待预测数据集X_predict,返回表示X_predict的分类结果的向量\u0026#34;\u0026#34;\u0026#34; assert self.intercept_ is not None and self.coef_ is not None, \\ \u0026#34;must fit before predict!\u0026#34; assert X_predict.shape[1] == len(self.coef_), \\ \u0026#34;the feature number of X_predict must be equal to X_train\u0026#34; proda = self.predict_proda(X_predict) # proda:单个待预测样本的发生概率 # proda \u0026gt;= 0.5:返回元素为布尔类型的向量; # np.array(proda \u0026gt;= 0.5, dtype=\u0026#39;int\u0026#39;):将布尔数据类型的向量转化为元素为 int 型的数组,则该数组中的 0 和 1 代表两种不同的分类类别; return np.array(proda \u0026gt;= 0.5, dtype=\u0026#39;int\u0026#39;) def score(self, X_test, y_test): \u0026#34;\u0026#34;\u0026#34;根据测试数据集 X_test 和 y_test 确定当前模型的准确度\u0026#34;\u0026#34;\u0026#34; y_predict = self.predict(X_test) # 分类问题的化,查看标准是分类的准确度:accuracy_score(y_test, y_predict) return accuracy_score(y_test, y_predict) def __repr__(self): \u0026#34;\u0026#34;\u0026#34;实例化类之后,输出显示 LogisticRegression()\u0026#34;\u0026#34;\u0026#34; return \u0026#34;LogisticRegression()\u0026#34; 2)使用自己的算法(Jupyter NoteBook 中使用) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import numpy as np import matplotlib.pyplot as plt from sklearn import datasets iris = datasets.load_iris() X = iris.data y = iris.target X = X[y\u0026lt;2, :2] y = y[y\u0026lt;2] from playML.train_test_split import train_test_split X_train, X_test, y_train, y_test = train_test_split(X, y, seed=666) from playML.LogisticRegression import LogisticRegression log_reg = LogisticRegression() log_reg.fit(X_train, y_train) log_reg.score(X_test, y_test) # 输出:1.0 # 查看测试数据集的样本发生的概率 log_reg.predict_proda(X_test) # 输出:array([0.92972035, 0.98664939, 0.14852024, 0.17601199, 0.0369836 , 0.0186637 , 0.04936918, 0.99669244, 0.97993941, 0.74524655, 0.04473194, 0.00339285, 0.26131273, 0.0369836 , 0.84192923, 0.79892262, 0.82890209, 0.32358166, 0.06535323, 0.20735334]) ","permalink":"https://reid00.github.io/en/posts/ml/%E9%80%BB%E8%BE%91%E5%9B%9E%E5%BD%92/","summary":"一、线性模型预测一个样本的损失量 损失量:模型对样本的预测结果和该样本对应的实际结果的差距; 1)为什么会想到用 y = -log(x) 函数? (该函数称为 惩罚函数","title":"逻辑回归"},{"content":"Summary 本文将从一个下山的场景开始,先提出梯度下降算法的基本思想,进而从数学上解释梯度下降算法的原理,最后实现一个简单的梯度下降算法的实例!\n梯度下降的场景假设 梯度下降法的基本思想可以类比为一个下山的过程。假设这样一个场景:一个人被困在山上,需要从山上下来(i.e. 找到山的最低点,也就是山谷)。但此时山上的浓雾很大,导致可视度很低。因此,下山的路径就无法确定,他必须利用自己周围的信息去找到下山的路径。这个时候,他就可以利用梯度下降算法来帮助自己下山。具体来说就是,以他当前的所处的位置为基准,寻找这个位置最陡峭的地方,然后朝着山的高度下降的地方走,同理,如果我们的目标是上山,也就是爬到山顶,那么此时应该是朝着最陡峭的方向往上走。然后每走一段距离,都反复采用同一个方法,最后就能成功的抵达山谷。\n我们同时可以假设这座山最陡峭的地方是无法通过肉眼立马观察出来的,而是需要一个复杂的工具来测量,同时,这个人此时正好拥有测量出最陡峭方向的能力。所以,此人每走一段距离,都需要一段时间来测量所在位置最陡峭的方向,这是比较耗时的。那么为了在太阳下山之前到达山底,就要尽可能的减少测量方向的次数。这是一个两难的选择,如果测量的频繁,可以保证下山的方向是绝对正确的,但又非常耗时,如果测量的过少,又有偏离轨道的风险。所以需要找到一个合适的测量方向的频率,来确保下山的方向不错误,同时又不至于耗时太多!\n梯度下降 首先,我们有一个可微分的函数。这个函数就代表着一座山。我们的目标就是找到这个函数的最小值,也就是山底。根据之前的场景假设,最快的下山的方式就是找到当前位置最陡峭的方向,然后沿着此方向向下走,对应到函数中,就是找到给定点的梯度 ,然后朝着梯度相反的方向,就能让函数值下降的最快!因为梯度的方向就是函数之变化最快的方向(在后面会详细解释) 所以,我们重复利用这个方法,反复求取梯度,最后就能到达局部的最小值,这就类似于我们下山的过程。而求取梯度就确定了最陡峭的方向,也就是场景中测量方向的手段。那么为什么梯度的方向就是最陡峭的方向呢?接下来,我们从微分开始讲起\n微分 看待微分的意义,可以有不同的角度,最常用的两种是:\n函数图像中,某点的切线的斜率\n函数的变化率 几个微分的例子:\n上面的例子都是单变量的微分,当一个函数有多个变量的时候,就有了多变量的微分,即分别对每个变量进行求微分\n梯度 梯度实际上就是多变量微分的一般化。 下面这个例子:\n我们可以看到,梯度就是分别对每个变量进行微分,然后用逗号分割开,梯度是用\u0026lt;\u0026gt;包括起来,说明梯度其实一个向量。\n梯度是微积分中一个很重要的概念,之前提到过梯度的意义\n在单变量的函数中,梯度其实就是函数的微分,代表着函数在某个给定点的切线的斜率 在多变量函数中,梯度是一个向量,向量有方向,梯度的方向就指出了函数在给定点的上升最快的方向 这也就说明了为什么我们需要千方百计的求取梯度!我们需要到达山底,就需要在每一步观测到此时最陡峭的地方,梯度就恰巧告诉了我们这个方向。梯度的方向是函数在给定点上升最快的方向,那么梯度的反方向就是函数在给定点下降最快的方向,这正是我们所需要的。所以我们只要沿着梯度的方向一直走,就能走到局部的最低点!\n梯度下降算法的数学解释 上面我们花了大量的篇幅介绍梯度下降算法的基本思想和场景假设,以及梯度的概念和思想。下面我们就开始从数学上解释梯度下降算法的计算过程和思想! 此公式的意义是:J是关于Θ的一个函数,我们当前所处的位置为Θ0点,要从这个点走到J的最小值点,也就是山底。首先我们先确定前进的方向,也就是梯度的反向,然后走一段距离的步长,也就是α,走完这个段步长,就到达了Θ1这个点!\n下面就这个公式的几个常见的疑问:\nα是什么含义? α在梯度下降算法中被称作为学习率或者步长,意味着我们可以通过α来控制每一步走的距离,以保证不要步子跨的太大扯着蛋,哈哈,其实就是不要走太快,错过了最低点。同时也要保证不要走的太慢,导致太阳下山了,还没有走到山下。所以α的选择在梯度下降法中往往是很重要的!α不能太大也不能太小,太小的话,可能导致迟迟走不到最低点,太大的话,会导致错过最低点! 为什么要梯度要乘以一个负号? 梯度前加一个负号,就意味着朝着梯度相反的方向前进!我们在前文提到,梯度的方向实际就是函数在此点上升最快的方向!而我们需要朝着下降最快的方向走,自然就是负的梯度的方向,所以此处需要加上负号\n梯度下降算法的实例 我们已经基本了解了梯度下降算法的计算过程,那么我们就来看几个梯度下降算法的小实例,首先从单变量的函数开始\n单变量函数的梯度下降 我们假设有一个单变量的函数\n函数的微分 初始化,起点为 学习率为 根据梯度下降的计算公式\n我们开始进行梯度下降的迭代计算过程:\nimage.png\n如图,经过四次的运算,也就是走了四步,基本就抵达了函数的最低点,也就是山底\n多变量函数的梯度下降 我们假设有一个目标函数\n现在要通过梯度下降法计算这个函数的最小值。我们通过观察就能发现最小值其实就是 (0,0)点。但是接下来,我们会从梯度下降算法开始一步步计算到这个最小值! 我们假设初始的起点为:\n初始的学习率为:\n函数的梯度为:\n进行多次迭代:\n我们发现,已经基本靠近函数的最小值点\n梯度下降算法的实现 下面我们将用python实现一个简单的梯度下降算法。场景是一个简单的线性回归的例子:假设现在我们有一系列的点,如下图所示\n我们将用梯度下降法来拟合出这条直线!\n首先,我们需要定义一个代价函数,在此我们选用均方误差代价函数\n此公式中\nm是数据集中点的个数\n½是一个常量,这样是为了在求梯度的时候,二次方乘下来就和这里的½抵消了,自然就没有多余的常数系数,方便后续的计算,同时对结果不会有影响\ny 是数据集中每个点的真实y坐标的值\nh 是我们的预测函数,根据每一个输入x,根据Θ 计算得到预测的y值,即\n我们可以根据代价函数看到,代价函数中的变量有两个,所以是一个多变量的梯度下降问题,求解出代价函数的梯度,也就是分别对两个变量进行微分\n明确了代价函数和梯度,以及预测的函数形式。我们就可以开始编写代码了。但在这之前,需要说明一点,就是为了方便代码的编写,我们会将所有的公式都转换为矩阵的形式,python中计算矩阵是非常方便的,同时代码也会变得非常的简洁。\n为了转换为矩阵的计算,我们观察到预测函数的形式\n我们有两个变量,为了对这个公式进行矩阵化,我们可以给每一个点x增加一维,这一维的值固定为1,这一维将会乘到Θ0上。这样就方便我们统一矩阵化的计算\n然后我们将代价函数和梯度转化为矩阵向量相乘的形式\ncoding time 首先,我们需要定义数据集和学习率\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import numpy as np # Size of the points dataset. m = 20 # Points x-coordinate and dummy value (x0, x1). X0 = np.ones((m, 1)) X1 = np.arange(1, m+1).reshape(m, 1) X = np.hstack((X0, X1)) # Points y-coordinate y = np.array([ 3, 4, 5, 5, 2, 4, 7, 8, 11, 8, 12, 11, 13, 13, 16, 17, 18, 17, 19, 21 ]).reshape(m, 1) # The Learning Rate alpha. alpha = 0.01 接下来我们以矩阵向量的形式定义代价函数和代价函数的梯度\n1 2 3 4 5 6 7 8 9 def error_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Error function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./2*m) * np.dot(np.transpose(diff), diff) def gradient_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Gradient of the function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./m) * np.dot(np.transpose(X), diff) 最后就是算法的核心部分,梯度下降迭代计算\n1 2 3 4 5 6 7 8 def gradient_descent(X, y, alpha): \u0026#39;\u0026#39;\u0026#39;Perform gradient descent.\u0026#39;\u0026#39;\u0026#39; theta = np.array([1, 1]).reshape(2, 1) gradient = gradient_function(theta, X, y) while not np.all(np.absolute(gradient) \u0026lt;= 1e-5): theta = theta - alpha * gradient gradient = gradient_function(theta, X, y) return theta 当梯度小于1e-5时,说明已经进入了比较平滑的状态,类似于山谷的状态,这时候再继续迭代效果也不大了,所以这个时候可以退出循环!\n完整的代码如下\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import numpy as np # Size of the points dataset. m = 20 # Points x-coordinate and dummy value (x0, x1). X0 = np.ones((m, 1)) X1 = np.arange(1, m+1).reshape(m, 1) X = np.hstack((X0, X1)) # Points y-coordinate y = np.array([ 3, 4, 5, 5, 2, 4, 7, 8, 11, 8, 12, 11, 13, 13, 16, 17, 18, 17, 19, 21 ]).reshape(m, 1) # The Learning Rate alpha. alpha = 0.01 def error_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Error function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./2*m) * np.dot(np.transpose(diff), diff) def gradient_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Gradient of the function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./m) * np.dot(np.transpose(X), diff) def gradient_descent(X, y, alpha): \u0026#39;\u0026#39;\u0026#39;Perform gradient descent.\u0026#39;\u0026#39;\u0026#39; theta = np.array([1, 1]).reshape(2, 1) gradient = gradient_function(theta, X, y) while not np.all(np.absolute(gradient) \u0026lt;= 1e-5): theta = theta - alpha * gradient gradient = gradient_function(theta, X, y) return theta optimal = gradient_descent(X, y, alpha) print(\u0026#39;optimal:\u0026#39;, optimal) print(\u0026#39;error function:\u0026#39;, error_function(optimal, X, y)[0,0]) 运行代码,计算得到的结果如下\n所拟合出的直线如下\n小结 至此,我们就基本介绍完了梯度下降法的基本思想和算法流程,并且用python实现了一个简单的梯度下降算法拟合直线的案例! 最后,我们回到文章开头所提出的场景假设: 这个下山的人实际上就代表了反向传播算法,下山的路径其实就代表着算法中一直在寻找的参数Θ,山上当前点的最陡峭的方向实际上就是代价函数在这一点的梯度方向,场景中观测最陡峭方向所用的工具就是微分 。在下一次观测之前的时间就是有我们算法中的学习率α所定义的。 可以看到场景假设和梯度下降算法很好的完成了对应!\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%A2%AF%E5%BA%A6%E4%B8%8B%E9%99%8D%E5%8E%9F%E7%90%86%E4%BB%8B%E7%BB%8D/","summary":"Summary 本文将从一个下山的场景开始,先提出梯度下降算法的基本思想,进而从数学上解释梯度下降算法的原理,最后实现一个简单的梯度下降算法的实例! 梯度下","title":"梯度下降原理介绍"},{"content":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说特征工程是机器学习成功的关键。\n什么是特征工程 特征工程又包含了Data PreProcessing(数据预处理)、Feature Extraction(特征提取)、Feature Selection(特征选择)和Feature construction(特征构造)等子问题,本章内容主要讨论数据预处理的方法及实现。 特征工程是机器学习中最重要的起始步骤,数据预处理是特征工程的最重要的起始步骤,而数据清洗是数据预处理的重要组成部分,会直接影响机器学习的效果。\n数据清洗整体介绍 1. 箱线图分析异常值 箱线图提供了识别异常值的标准,如果一个数下雨 QL-1.5IQR or 大于OU + 1.5 IQR, 则这个值被称为异常值。\nQL 下四分位数,表示四分之一的数据值比它小 QU 上四分位数,表示四分之一的数据值比它大 IRQ 四分位距,是QU-QL 的差值,包含了全部关差值的一般 2. 数据的光滑处理 除了检测出异常值然后再处理异常值外,还可以使用以下方法对异常数据进行光滑处理。\n2.1. 变量分箱(即变量离散化) 离散特征的增加和减少都很容易,易于模型的快速迭代; 稀疏向量内积乘法运算速度快,计算结果方便存储,容易扩展; 离散化后的特征对异常数据有很强的鲁棒性:比如一个特征是年龄\u0026gt;30是1,否则0。如果特征没有离散化,一个异常数据“年龄300岁”会给模型造成很大的干扰; 逻辑回归属于广义线性模型,表达能力受限;单变量离散化为N个后,每个变量有单独的权重,相当于为模型引入了非线性,能够提升模型表达能力,加大拟合; 离散化后可以进行特征交叉,由M+N个变量变为M*N个变量,进一步引入非线性,提升表达能力; 特征离散化后,模型会更稳定,比如如果对用户年龄离散化,20-30作为一个区间,不会因为一个用户年龄长了一岁就变成一个完全不同的人。当然处于区间相邻处的样本会刚好相反,所以怎么划分区间是门学问; 特征离散化以后,起到了简化了逻辑回归模型的作用,降低了模型过拟合的风险。 可以将缺失作为独立的一类带入模型。 将所有变量变换到相似的尺度上。 2.1.0 变量分箱的方法 2.1.1 无序变量分箱 举个例子,在实际模型建立当中,有个 job 职业的特征,取值为(“国家机关人员”,“专业技术人员”,“商业服务人员”),对于这一类变量,如果我们将其依次赋值为(国家机关人员=1;专业技术人员=2;商业服务人员=3),就很容易产生一个问题,不同种类的职业在数据层面上就有了大小顺序之分,国家机关人员和商业服务人员的差距是2,专业技术人员和商业服务人员的之间的差距是1,而我们原来的中文分类中是不存在这种先后顺序关系的。所以这么简单的赋值是会使变量失去原来的衡量效果。\n怎么处理这个问题呢? “一位有效编码” (one-hot Encoding)可以解决这个问题,通常叫做虚变量或者哑变量(dummpy variable):比如职业特征有3个不同变量,那么将其生成个2哑变量,分别是“是否国家党政职业人员”,“是否专业技术人员” ,每个虚变量取值(1,0)。 为什么2个哑变量而非3个? 在模型中引入多个虚拟变量时,虚拟变量的个数应按下列原则确定: 回归模型有截距:一般的,若该特征下n个属性均互斥(如,男/女;儿童/青年/中年/老年),在生成虚拟变量时,应该生成 n-1个虚变量,这样可以避免产生多重共线性 回归模型无截距项:有n个特征,设置n个虚拟变量 python 实现方法pd.get_dummies() 2.1.2 有序变量分箱 有序多分类变量是很常见的变量形式,通常在变量中有多个可能会出现的取值,各取值之间还存在等级关系。比如高血压分级(0=正常,1=正常高值,2=1级高血压,3=2级高血压,4=3级高血压)这类变量处理起来简直不要太省心,使用 pandas 中的 map()替换相应变量就行。\n1 2 3 4 5 import pandas as pd df= pd.DataFrame([\u0026#39;正常\u0026#39;,\u0026#39;3级高血压\u0026#39;,\u0026#39;正常\u0026#39;,\u0026#39;2级高血压\u0026#39;,\u0026#39;正常\u0026#39;,\u0026#39;正常高值\u0026#39;,\u0026#39;1级高血压\u0026#39;],columns=[\u0026#39;blood_pressure\u0026#39;]) dic_blood = {\u0026#39;正常\u0026#39;:0,\u0026#39;正常高值\u0026#39;:1,\u0026#39;1级高血压\u0026#39;:2,\u0026#39;2级高血压\u0026#39;:3,\u0026#39;3级高血压\u0026#39;:4} df[\u0026#39;blood_pressure_enc\u0026#39;] = df[\u0026#39;blood_pressure\u0026#39;].map(dic_blood) print(df) 2.1.3 连续变量的分箱方式 等宽划分:按照相同宽度将数据分成几等份。缺点是受到异常值的影响比较大。 pandas.cut方法可以进行等宽划分。 等频划分:将数据分成几等份,每等份数据里面的个数是一样的。pandas.qcut方法可以进行等频划分。 1 2 3 4 5 6 import pandas as pd df = pd.DataFrame([[22,1],[13,1],[33,1],[52,0],[16,0],[42,1],[53,1],[39,1],[26,0],[66,0]],columns=[\u0026#39;age\u0026#39;,\u0026#39;Y\u0026#39;]) #print(df) df[\u0026#39;age_bin_1\u0026#39;] = pd.qcut(df[\u0026#39;age\u0026#39;],3) #新增一列存储等频划分的分箱特征 df[\u0026#39;age_bin_2\u0026#39;] = pd.cut(df[\u0026#39;age\u0026#39;],3) #新增一列存储等距划分的分箱特征 print(df) 2.1.4 有监督学习分箱方法 最小熵法分箱 假设因变量为分类变量,可取值1,… ,J。令pijpij表示第i个分箱内因变量取值为j的观测的比例,i=1,…,k,j=1,…,J;那么第i个分箱的熵值为∑Jj=0−pij×logpij∑j=0J−pij×logpij。如果第i个分箱内因变量各类别的比例相等,即p11=p12=p1J=1/Jp11=p12=p1J=1/J,那么第i个分箱的熵值达到最大值;如果第i个分箱内因变量只有一种取值,即某个pijpij等于1而其他类别的比例等于0,那么第i个分箱的熵值达到最小值。 令riri表示第i个分箱的观测数占所有观测数的比例;那么总熵值为∑ki=0∑Jj=0(−pij×logpij)∑i=0k∑j=0J(−pij×logpij)。需要使总熵值达到最小,也就是使分箱能够最大限度地区分因变量的各类别。 卡方分箱 (常用) 自底向上的(即基于合并的)数据离散化方法。 它依赖于卡方检验:具有最小卡方值的相邻区间合并在一起,直到满足确定的停止准则。 基本思想: 对于精确的离散化,相对类频率在一个区间内应当完全一致。因此,如果两个相邻的区间具有非常类似的类分布,则这两个区间可以合并;否则,它们应当保持分开。而低卡方值表明它们具有相似的类分布。 2.2 无量纲化 无量纲化使不同规格的数据转换到同一规格。常见的无量纲化方法有标准化和区间缩放法。标准化的前提是特征值服从正态分布,标准化后,其转换成标准正态分布。区间缩放法利用了边界值信息,将特征的取值区间缩放到某个特点的范围,例如[0, 1]等。\n2.2.1 标准化 标准化需要计算特征的均值和标准差,公式表达为:\n使用preproccessing库的StandardScaler类对数据进行标准化的代码如下:\n1 2 3 4 from sklearn.preprocessing import StandardScaler #标准化,返回值为标准化后的数据 StandardScaler().fit_transform(iris.data) 2.2.2 区间缩放法 区间缩放法的思路有多种,常见的一种为利用两个最值进行缩放,公式表达为:\n使用preproccessing库的MinMaxScaler类对数据进行区间缩放的代码如下:\n1 2 3 4 from sklearn.preprocessing import MinMaxScaler #区间缩放,返回值为缩放到[0, 1]区间的数据 MinMaxScaler().fit_transform(iris.data) 2.1.3 标准化与归一化的区别 简单来说,标准化是依照特征矩阵的列处理数据,其通过求z-score的方法,将样本的特征值转换到同一量纲下。归一化是依照特征矩阵的行处理数据,其目的在于样本向量在点乘运算或其他核函数计算相似性时,拥有统一的标准,也就是说都转化为“单位向量”。规则为l2的归一化公式如下:\n什么时候需要进行归一化?\n归一化后加快了梯度下降求最优解的速度 归一化有可能提高精度 什么时候需要进行归一化?\n通常在需要用到梯度下降法的时候。 包括线性回归、逻辑回归、支持向量机、神经网络等模型。\n决策树模型就不适用 例如 C4.5 ,主要根据信息增益比来分裂,归一化不会改变样本在特征 x 上的信息增益\n比较概率大小分布即可,不需要。\n使用preproccessing库的Normalizer类对数据进行归一化的代码如下:\n1 2 3 4 from sklearn.preprocessing import Normalizer #归一化,返回值为归一化后的数据 Normalizer().fit_transform(iris.data) 2.3 对定量特征二值化 定量特征二值化的核心在于设定一个阈值,大于阈值的赋值为1,小于等于阈值的赋值为0,公式表达如下:\n使用preproccessing库的Binarizer类对数据进行二值化的代码如下:\n1 2 3 4 from sklearn.preprocessing import Binarizer #二值化,阈值设置为3,返回值为二值化后的数据 Binarizer(threshold=3).fit_transform(iris.data) 2.4 对定性特征哑编码 由于IRIS数据集的特征皆为定量特征,故使用其目标值进行哑编码(实际上是不需要的)。使用preproccessing库的OneHotEncoder类对数据进行哑编码的代码如下:\n1 2 3 4 from sklearn.preprocessing import OneHotEncoder #哑编码,对IRIS数据集的目标值,返回值为哑编码后的数据 OneHotEncoder().fit_transform(iris.target.reshape((-1,1))) 2.5 缺失值计算 由于IRIS数据集没有缺失值,故对数据集新增一个样本,4个特征均赋值为NaN,表示数据缺失。使用preproccessing库的Imputer类对数据进行缺失值计算的代码如下:\n1 2 3 4 5 6 7 from numpy import vstack, array, nan from sklearn.preprocessing import Imputer #缺失值计算,返回值为计算缺失值后的数据 #参数missing_value为缺失值的表示形式,默认为NaN #参数strategy为缺失值填充方式,默认为mean(均值) Imputer().fit_transform(vstack((array([nan, nan, nan, nan]), iris.data))) 2.6 数据变换 常见的数据变换有基于多项式的、基于指数函数的、基于对数函数的。4个特征,度为2的多项式转换公式如下:\n使用preproccessing库的PolynomialFeatures类对数据进行多项式转换的代码如下:\n1 2 3 4 5 from sklearn.preprocessing import PolynomialFeatures #多项式转换 #参数degree为度,默认值为2 PolynomialFeatures().fit_transform(iris.data) 基于单变元函数的数据变换可以使用一个统一的方式完成,使用preproccessing库的FunctionTransformer对数据进行对数函数转换的代码如下:\n1 2 3 4 5 6 from numpy import log1p from sklearn.preprocessing import FunctionTransformer #自定义转换函数为对数函数的数据变换 #第一个参数是单变元函数 FunctionTransformer(log1p).fit_transform(iris.data) 2.7 回归 可以用一个函数(如回归函数)拟合数据来光滑数据。线性回归涉及找出拟合两个属性(或变量)的“最佳”线,是的一个属性可以用来预测另一个。多元线性回归是线性回归的扩展,其中涉及的属性多于两个,并且数据拟合到一个多维曲面。\n3. 异常值处理方法 删除含有异常值的记录; 某些筛选出来的异常样本是否真的是不需要的异常特征样本,最好找懂业务的再确认一下,防止我们将正常的样本过滤掉了。 将异常值视为缺失值,交给缺失值处理方法来处理; 使用均值/中位数/众数来修正; 不处理。 4. 什么是组合特征?如何处理高维组合特征? 为了提高复杂关系的拟合能力,在特征工程中经常会把一阶离散特征两两组合成高阶特征,构成交互特征(Interaction Feature)。以广告点击预估问题为例,如图1所示,原始数据有语言和类型两种离散特征。为了提高拟合能力,语言和类型可以组成二阶特征。\n5. 类别型特征 什么是类别型特征?\n例如:性别(男、女)、血型(A、B、AB、O)\n通常是字符串形式,需要转化成数值型,传递给模型\n如何处理类别型特征?\n序号编码(Ordinal Encoding) 例如学习成绩有高中低三档,也就是不同类别之间关系。\n这时可以用321来表示,保留了大小关系。\n独热编码(One-hot Encoding) 例如血型,它的类别没有大小关系。A 型血表示为(1, 0, 0, 0),B 型血表示为(0, 1, 0, 0)……\n二进制编码(Binary Encoding) 第一步,先用序号编码给每个类别编码\n第二步,将类别 ID 转化为相应的二进制\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%89%B9%E5%BE%81%E5%B7%A5%E7%A8%8B%E4%B9%8B%E6%95%B0%E6%8D%AE%E9%A2%84%E5%A4%84%E7%90%86/","summary":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说","title":"特征工程之数据预处理"},{"content":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说特征工程是机器学习成功的关键。\n那特征工程是什么?\n​\t特征工程是利用数据领域的相关知识来创建能够使机器学习算法达到最佳性能的特征的过程。\n特征工程又包含了Feature Selection(特征选择)、Feature Extraction(特征提取)和Feature construction(特征构造)等子问题,本章内容主要讨论特征选择相关的方法及实现。\n在实际项目中,我们可能会有大量的特征可使用,有的特征携带的信息丰富,有的特征携带的信息有重叠,有的特征则属于无关特征,如果所有特征不经筛选地全部作为训练特征,经常会出现维度灾难问题,甚至会降低模型的准确性。因此,我们需要进行特征筛选,排除无效/冗余的特征,把有用的特征挑选出来作为模型的训练数据。\n特征选择介绍 特征按重要性分类 相关特征\n对于学习任务(例如分类问题)有帮助,可以提升学习算法的效果\n无关特征\n对于我们的算法没有任何帮助,不会给算法的效果带来任何提升\n冗余特征\n不会对我们的算法带来新的信息,或者这种特征的信息可以由其他的特征推断出\n特征选择的目的 对于一个特定的学习算法来说,哪一个特征是有效的是未知的。因此,需要从所有特征中选择出对于学习算法有益的相关特征。而且在实际应用中,经常会出现维度灾难问题。如果只选择所有特征中的部分特征构建模型,那么可以大大减少学习算法的运行时间,也可以增加模型的可解释性\n特征选择的原则 获取尽可能小的特征子集,不显著降低分类精度、不影响分类分布以及特征子集应具有稳定、适应性强等特点\n特征选择的方法 Filter 方法(过滤式) 先进行特征选择,然后去训练学习器,所以特征选择的过程与学习器无关。相当于先对特征进行过滤操作,然后用特征子集来训练分类器。\n**主要思想:**对每一维特征“打分”,即给每一维的特征赋予权重,这样的权重就代表着该特征的重要性,然后依据权重排序。\n主要方法:\n卡方检验 信息增益 相关系数 优点: 运行速度快,是一种非常流行的特征选择方法。\n**缺点:**无法提供反馈,特征选择的标准/规范的制定是在特征搜索算法中完成,学习算法无法向特征搜索算法传递对特征的需求。另外,可能处理某个特征时由于任意原因表示该特征不重要,但是该特征与其他特征结合起来则可能变得很重要。\nWrapper 方法 (封装式) 直接把最后要使用的分类器作为特征选择的评价函数,对于特定的分类器选择最优的特征子集。\n主要思想: 将子集的选择看作是一个搜索寻优问题,生成不同的组合,对组合进行评价,再与其他的组合进行比较。这样就将子集的选择看作是一个优化问题,这里有很多的优化算法可以解决,尤其是一些启发式的优化算法,如GA、PSO(如:优化算法-粒子群算法)、DE、ABC(如:优化算法-人工蜂群算法)等。\n主要方法:\n递归特征消除算法 优点: 对特征进行搜索时围绕学习算法展开的,对特征选择的标准/规范是在学习算法的需求中展开的,能够考虑学习算法所属的任意学习偏差,从而确定最佳子特征,真正关注的是学习问题本身。由于每次尝试针对特定子集时必须运行学习算法,所以能够关注到学习算法的学习偏差/归纳偏差,因此封装能够发挥巨大的作用。\n缺点: 运行速度远慢于过滤算法,实际应用用封装方法没有过滤方法流行。\nEmbedded 方法(嵌入式) 将特征选择嵌入到模型训练当中,其训练可能是相同的模型,但是特征选择完成后,还能给予特征选择完成的特征和模型训练出的超参数,再次训练优化。\n主要思想: 在模型既定的情况下学习出对提高模型准确性最好的特征。也就是在确定模型的过程中,挑选出那些对模型的训练有重要意义的特征。\n主要方法: 用带有L1正则化的项完成特征选择(也可以结合L2惩罚项来优化)、随机森林平均不纯度减少法/平均精确度减少法。\n优点: 对特征进行搜索时围绕学习算法展开的,能够考虑学习算法所属的任意学习偏差。训练模型的次数小于Wrapper方法,比较节省时间。\n缺点: 运行速度慢\n特征选择的实现方法 从两个方面考虑来选择特征: 特征是否发散: 如果一个特征不发散,例如方差接近于0,也就是说样本在这个特征上基本上没有差异,这个特征对于样本的区分并没有什么用。\n假设某特征的特征值只有0和1,并且在所有输入样本中,95%的实例的该特征取值都是1,那就可以认为这个特征作用不大。如果100%都是1,那这个特征就没意义了。\n**特征与目标的相关性:**这点比较显见,与目标相关性高的特征,应当优选选择。除方差法外,本文介绍的其他方法均从相关性考虑。\nFilter: 卡方检验 经典的卡方检验是检验定性自变量对定性因变量的相关性。假设自变量有N种取值,因变量有M种取值,考虑自变量等于i且因变量等于j的样本频数的观察值与期望的差距,构建统计量:\n不难发现,这个统计量的含义简而言之就是自变量对因变量的相关性。用feature_selection库的SelectKBest类结合卡方检验来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectKBest from sklearn.feature_selection import chi2 #选择K个最好的特征,返回选择特征后的数据 SelectKBest(chi2, k=2).fit_transform(iris.data, iris.target) 方差选择 使用方差选择法,先要计算各个特征的方差,然后根据阈值,选择方差大于阈值的特征。使用feature_selection库的VarianceThreshold类来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import VarianceThreshold #方差选择法,返回值为特征选择后的数据 #参数threshold为方差的阈值 VarianceThreshold(threshold=3).fit_transform(iris.data) 相关系数 使用相关系数法,先要计算各个特征对目标值的相关系数以及相关系数的P值。用feature_selection库的SelectKBest类结合相关系数来选择特征的代码如下:\n1 2 3 4 5 6 7 from sklearn.feature_selection import SelectKBest from scipy.stats import pearsonr #选择K个最好的特征,返回选择特征后的数据 #第一个参数为计算评估特征是否好的函数,该函数输入特征矩阵和目标向量,输出二元组(评分,P值)的数组,数组第i项为第i个特征的评分和P值。在此定义为计算相关系数 #参数k为选择的特征个数 SelectKBest(lambda X, Y: array(map(lambda x:pearsonr(x, Y), X.T)).T, k=2).fit_transform(iris.data, iris.target) 互信息法 经典的互信息也是评价定性自变量对定性因变量的相关性的,互信息计算公式如下:\n为了处理定量数据,最大信息系数法被提出,使用feature_selection库的SelectKBest类结合最大信息系数法来选择特征的代码如下:\n1 2 3 4 5 6 7 8 9 10 11 from sklearn.feature_selection import SelectKBest from minepy import MINE #由于MINE的设计不是函数式的,定义mic方法将其为函数式的,返回一个二元组,二元组的第2项设置成固定的P值0.5 def mic(x, y): m = MINE() m.compute_score(x, y) return (m.mic(), 0.5) #选择K个最好的特征,返回特征选择后的数据 SelectKBest(lambda X, Y: array(map(lambda x:mic(x, Y), X.T)).T, k=2).fit_transform(iris.data, iris.target) Wrapper: 递归特征消除法 递归消除特征法使用一个基模型来进行多轮训练,每轮训练后,消除若干权值系数的特征,再基于新的特征集进行下一轮训练。使用feature_selection库的RFE类来选择特征的代码如下:\n1 2 3 4 5 6 7 from sklearn.feature_selection import RFE from sklearn.linear_model import LogisticRegression #递归特征消除法,返回特征选择后的数据 #参数estimator为基模型 #参数n_features_to_select为选择的特征个数 RFE(estimator=LogisticRegression(), n_features_to_select=2).fit_transform(iris.data, iris.target) Embedded : 基于惩罚项的特征选择法 使用带惩罚项的基模型,除了筛选出特征外,同时也进行了降维。使用feature_selection库的SelectFromModel类结合带L1惩罚项的逻辑回归模型,来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectFromModel from sklearn.linear_model import LogisticRegression #带L1惩罚项的逻辑回归作为基模型的特征选择 SelectFromModel(LogisticRegression(penalty=\u0026#34;l1\u0026#34;, C=0.1)).fit_transform(iris.data, iris.target) 实际上,L1惩罚项降维的原理在于保留多个对目标值具有同等相关性的特征中的一个,所以没选到的特征不代表不重要。故,可结合L2惩罚项来优化。具体操作为:若一个特征在L1中的权值为1,选择在L2中权值差别不大且在L1中权值为0的特征构成同类集合,将这一集合中的特征平分L1中的权值,故需要构建一个新的逻辑回归模型:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 from sklearn.linear_model import LogisticRegression class LR(LogisticRegression): def __init__(self, threshold=0.01, dual=False, tol=1e-4, C=1.0, fit_intercept=True, intercept_scaling=1, class_weight=None, random_state=None, solver=\u0026#39;liblinear\u0026#39;, max_iter=100, multi_class=\u0026#39;ovr\u0026#39;, verbose=0, warm_start=False, n_jobs=1): #权值相近的阈值 self.threshold = threshold LogisticRegression.__init__(self, penalty=\u0026#39;l1\u0026#39;, dual=dual, tol=tol, C=C, fit_intercept=fit_intercept, intercept_scaling=intercept_scaling, class_weight=class_weight, random_state=random_state, solver=solver, max_iter=max_iter, multi_class=multi_class, verbose=verbose, warm_start=warm_start, n_jobs=n_jobs) #使用同样的参数创建L2逻辑回归 self.l2 = LogisticRegression(penalty=\u0026#39;l2\u0026#39;, dual=dual, tol=tol, C=C, fit_intercept=fit_intercept, intercept_scaling=intercept_scaling, class_weight = class_weight, random_state=random_state, solver=solver, max_iter=max_iter, multi_class=multi_class, verbose=verbose, warm_start=warm_start, n_jobs=n_jobs) def fit(self, X, y, sample_weight=None): #训练L1逻辑回归 super(LR, self).fit(X, y, sample_weight=sample_weight) self.coef_old_ = self.coef_.copy() #训练L2逻辑回归 self.l2.fit(X, y, sample_weight=sample_weight) cntOfRow, cntOfCol = self.coef_.shape #权值系数矩阵的行数对应目标值的种类数目 for i in range(cntOfRow): for j in range(cntOfCol): coef = self.coef_[i][j] #L1逻辑回归的权值系数不为0 if coef != 0: idx = [j] #对应在L2逻辑回归中的权值系数 coef1 = self.l2.coef_[i][j] for k in range(cntOfCol): coef2 = self.l2.coef_[i][k] #在L2逻辑回归中,权值系数之差小于设定的阈值,且在L1中对应的权值为0 if abs(coef1-coef2) \u0026lt; self.threshold and j != k and self.coef_[i][k] == 0: idx.append(k) #计算这一类特征的权值系数均值 mean = coef / len(idx) self.coef_[i][idx] = mean return self 使用feature_selection库的SelectFromModel类结合带L1以及L2惩罚项的逻辑回归模型,来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectFromModel #带L1和L2惩罚项的逻辑回归作为基模型的特征选择 #参数threshold为权值系数之差的阈值 SelectFromModel(LR(threshold=0.5, C=0.1)).fit_transform(iris.data, iris.target) 基于树模型的特征选择法\n树模型中GBDT也可用来作为基模型进行特征选择,使用feature_selection库的SelectFromModel类结合GBDT模型,来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectFromModel from sklearn.ensemble import GradientBoostingClassifier #GBDT作为基模型的特征选择 SelectFromModel(GradientBoostingClassifier()).fit_transform(iris.data, iris.target) 降维 当特征选择完成后,可以直接训练模型了,但是可能由于特征矩阵过大,导致计算量大,训练时间长的问题,因此降低特征矩阵维度也是必不可少的。常见的降维方法除了以上提到的基于L1惩罚项的模型以外,另外还有主成分分析法(PCA)和线性判别分析(LDA),线性判别分析本身也是一个分类模型。PCA和LDA有很多的相似点,其本质是要将原始的样本映射到维度更低的样本空间中,但是PCA和LDA的映射目标不一样:PCA是为了让映射后的样本具有最大的发散性;而LDA是为了让映射后的样本有最好的分类性能。所以说PCA是一种无监督的降维方法,而LDA是一种有监督的降维方法。\n主成分分析法(PCA) 使用decomposition库的PCA类选择特征的代码如下:\n1 2 3 4 5 from sklearn.decomposition import PCA #主成分分析法,返回降维后的数据 #参数n_components为主成分数目 PCA(n_components=2).fit_transform(iris.data) 线性判别分析法(LDA) 使用lda库的LDA类选择特征的代码如下:\n1 2 3 4 5 from sklearn.lda import LDA #线性判别分析法,返回降维后的数据 #参数n_components为降维后的维数 LDA(n_components=2).fit_transform(iris.data, iris.target) 什么是特征选择,为什么要进行特征选择,以及如何进行? 特征选择是通过选择旧属性的子集得到新属性,是一种维规约方式。\nWhy: 应用方面:提升准确率,特征选择能够删除冗余不相关的特征并降低噪声,避免维灾难。在许多数据挖掘算法中,维度较低,效果更好;\n执行方面:维度越少,运行效率越高,同时内存需求越少。\nHow: 过滤方法,独立于算法,在算法运行前进行特征选择。如可以选择属性的集合,集合内属性对之间的相关度尽可能低。常用对特征重要性(方差,互信息,相关系数,卡方检验)排序选择;可结合别的算法(随机森林,GBDT等)进行特征重要性提取,过滤之后再应用于当前算法。 包装方法,算法作为黑盒,在确定模型和评价准则之后,对特征空间的不同子集做交叉验证,进而搜索最佳特征子集。深度学习具有自动化包装学习的特性。 总之,特征子集选择是搜索所有可能的特性子集的过程,可以使用不同的搜索策略,但是搜索策略的效率要求比较高,并且应当找到最优或近似最优的特征子集。 嵌入方法,算法本身决定使用哪些属性和忽略哪些属性。即特征选择与训练过程融为一体,比如L1正则、决策树等; 参考: https://blog.csdn.net/Dream_angel_Z/article/details/49388733\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%89%B9%E5%BE%81%E5%B7%A5%E7%A8%8B%E4%B9%8B%E7%89%B9%E5%BE%81%E9%80%89%E6%8B%A9/","summary":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说","title":"特征工程之特征选择"},{"content":"简介 损失函数用来评价模型的预测值和真实值不一样的程度,损失函数越好,通常模型的性能越好。不同的模型用的损失函数一般也不一样。\n损失函数分为经验风险损失函数和结构风险损失函数。经验风险损失函数指预测结果和实际结果的差别,结构风险损失函数是指经验风险损失函数加上正则项。\n常见的损失函数以及其优缺点如下:\n1. 0-1损失函数(zero-one loss) 0-1损失是指预测值和目标值不相等为1, 否则为0:\n特点:\n(1) 0-1损失函数直接对应分类判断错误的个数,但是它是一个非凸函数,不太适用.\n(2) 感知机就是用的这种损失函数。但是相等这个条件太过严格,因此可以放宽条件,即满足 时认为相等,\n2. 绝对值损失函数 绝对值损失函数是计算预测值与目标值的差的绝对值:\n3. log对数损失函数 log对数损失函数的标准形式如下:\n特点:\n(1) log对数损失函数能非常好的表征概率分布,在很多场景尤其是多分类,如果需要知道结果属于每个类别的置信度,那它非常适合。\n(2) 健壮性不强,相比于hinge loss对噪声更敏感。\n(3) 辑回归的损失函数就是log对数损失函数。\n4. 平方损失函数 平方损失函数标准形式如下:\n特点:\n(1)经常应用与回归问题\n5. 指数损失函数(exponential loss) 指数损失函数的标准形式如下:\n特点:\n(1)对离群点、噪声非常敏感。经常用在AdaBoost算法中。\n6. Hinge 损失函数 Hinge损失函数标准形式如下:\n特点:\n(1) hinge损失函数表示如果被分类正确,损失为0,否则损失就为 。SVM就是使用这个损失函数。\n(2) 一般的 是预测值,在-1到1之间, 是目标值(-1或1)。其含义是, 的值在-1和+1之间就可以了,并不鼓励 ,即并不鼓励分类器过度自信,让某个正确分类的样本距离分割线超过1并不会有任何奖励,从而使分类器可以更专注于整体的误差。\n(3) 健壮性相对较高,对异常点、噪声不敏感,但它没太好的概率解释。\n7. 感知损失(perceptron loss)函数 感知损失函数的标准形式如下:\n特点:\n(1)是Hinge损失函数的一个变种,Hinge loss对判定边界附近的点(正确端)惩罚力度很高。而perceptron loss只要样本的判定类别正确的话,它就满意,不管其判定边界的距离。它比Hinge loss简单,因为不是max-margin boundary,所以模型的泛化能力没 hinge loss强。\n8. 交叉熵损失函数 (Cross-entropy loss function) 交叉熵损失函数的标准形式如下:\n注意公式中 表示样本, 表示实际的标签, 表示预测的输出, 表示样本总数量。\n特点:\n(1)本质上也是一种对数似然函数,可用于二分类和多分类任务中。\n二分类问题中的loss函数(输入数据是softmax或者sigmoid函数的输出):\n多分类问题中的loss函数(输入数据是softmax或者sigmoid函数的输出):\n(2)当使用sigmoid作为激活函数的时候,常用交叉熵损失函数而不用均方误差损失函数,因为它可以完美解决平方损失函数权重更新过慢的问题,具有“误差大的时候,权重更新快;误差小的时候,权重更新慢”的良好性质。\n最后奉献上交叉熵损失函数的实现代码:cross_entropy.\n这里需要更正一点,对数损失函数和交叉熵损失函数应该是等价的!!!(此处感谢\n@Areshyy\n的指正,下面说明也是由他提供)\n下面来具体说明:\n相关高频问题: 1.交叉熵函数与最大似然函数的联系和区别? 区别:交叉熵函数使用来描述模型预测值和真实值的差距大小,越大代表越不相近;似然函数的本质就是衡量在某个参数下,整体的估计和真实的情况一样的概率,越大代表越相近。\n联系:交叉熵函数可以由最大似然函数在伯努利分布的条件下推导出来,或者说最小化交叉熵函数的本质就是对数似然函数的最大化。\n怎么推导的呢?我们具体来看一下。\n设一个随机变量 满足伯努利分布,\n则 的概率密度函数为:\n因为我们只有一组采样数据 ,我们可以统计得到 和 的值,但是 的概率是未知的,接下来我们就用极大似然估计的方法来估计这个 值。\n对于采样数据 ,其对数似然函数为:\n可以看到上式和交叉熵函数的形式几乎相同,极大似然估计就是要求这个式子的最大值。而由于上面函数的值总是小于0,一般像神经网络等对于损失函数会用最小化的方法进行优化,所以一般会在前面加一个负号,得到交叉熵函数(或交叉熵损失函数):\n这个式子揭示了交叉熵函数与极大似然估计的联系,最小化交叉熵函数的本质就是对数似然函数的最大化。\n现在我们可以用求导得到极大值点的方法来求其极大似然估计,首先将对数似然函数对 进行求导,并令导数为0,得到\n消去分母,得:\n所以:\n这就是伯努利分布下最大似然估计求出的概率 。\n2. 在用sigmoid作为激活函数的时候,为什么要用交叉熵损失函数,而不用均方误差损失函数? 其实这个问题求个导,分析一下两个误差函数的参数更新过程就会发现原因了。\n对于均方误差损失函数,常常定义为:\n其中 是我们期望的输出, 为神经元的实际输出( )。在训练神经网络的时候我们使用梯度下降的方法来更新 和 ,因此需要计算代价函数对 和 的导数:\n然后更新参数 和 :\n因为sigmoid的性质,导致 在 取大部分值时会很小(如下图标出来的两端,几乎接近于平坦),这样会使得 很小,导致参数 和 更新非常慢。\n那么为什么交叉熵损失函数就会比较好了呢?同样的对于交叉熵损失函数,计算一下参数更新的梯度公式就会发现原因。交叉熵损失函数一般定义为:\n其中 是我们期望的输出, 为神经元的实际输出( )。同样可以看看它的导数:\n另外,\n所以有:\n所以参数更新公式为:\n可以看到参数更新公式中没有 这一项,权重的更新受 影响,受到误差的影响,所以当误差大的时候,权重更新快;当误差小的时候,权重更新慢。这是一个很好的性质。\n所以当使用sigmoid作为激活函数的时候,常用交叉熵损失函数而不用均方误差损失函数。\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E4%B9%8B%E5%B8%B8%E8%A7%81%E6%8D%9F%E5%A4%B1%E5%87%BD%E6%95%B0/","summary":"简介 损失函数用来评价模型的预测值和真实值不一样的程度,损失函数越好,通常模型的性能越好。不同的模型用的损失函数一般也不一样。 损失函数分为经验","title":"机器学习之常见损失函数"},{"content":"1. 无监督和有监督的区别? 有监督学习:对具有概念标记(分类)的训练样本进行学习,以尽可能对训练样本集外的数据进行标记(分类)预测。这里,所有的标记(分类)是已知的。因此,训练样本的岐义性低。\n无监督学习:对没有概念标记(分类)的训练样本进行学习,以发现训练样本集中的结构性知识。这里,所有的标记(分类)是未知的。因此,训练样本的岐义性高。聚类就是典型的无监督学习。\n2. SVM 的推导,特性?多分类怎么处理? SVM是最大间隔分类器,几何间隔和样本的误分次数之间存在关系, ,其中 从线性可分情况下,原问题,特征转换后的dual问题,引入kernel(线性kernel,多项式,高斯),最后是soft margin。\n线性:简单,速度快,但是需要线性可分。\n多项式:比线性核拟合程度更强,知道具体的维度,但是高次容易出现数值不稳定,参数选择比较多。\n高斯:拟合能力最强,但是要注意过拟合问题。不过只有一个参数需要调整。\n多分类问题,一般将二分类推广到多分类的方式有三种,一对一,一对多,多对多。\n一对一:将N个类别两两配对,产生N(N-1)/2个二分类任务,测试阶段新样本同时交给所有的分类器,最终结果通过投票产生。\n一对多:每一次将一个例作为正例,其他的作为反例,训练N个分类器,测试时如果只有一个分类器预测为正类,则对应类别为最终结果,如果有多个,则一般选择置信度最大的。从分类器角度一对一更多,但是每一次都只用了2个类别,因此当类别数很多的时候一对一开销通常更小(只要训练复杂度高于O(N)即可得到此结果)。\n多对多:若干各类作为正类,若干个类作为反类。注意正反类必须特殊的设计。\n3. LR 的推导,特性? LR的优点在于实现简单,并且计算量非常小,速度很快,存储资源低,缺点就是因为模型简单,对于复杂的情况下会出现欠拟合,并且只能处理2分类问题(可以通过一般的二元转换为多元或者用softmax回归)。\n4. 决策树的特性? 决策树基于树结构进行决策,与人类在面临问题的时候处理机制十分类似。其特点在于需要选择一个属性进行分支,在分支的过程中选择信息增益最大的属性,定义如下 在划分中我们希望决策树的分支节点所包含的样本属于同一类别,即节点的纯度越来越高。决策树计算量简单,可解释性强,比较适合处理有缺失属性值的样本,能够处理不相关的特征,但是容易过拟合,需要使用剪枝或者随机森林。信息增益是熵减去条件熵,代表信息不确定性较少的程度,信息增益越大,说明不确定性降低的越大,因此说明该特征对分类来说很重要。由于信息增益准则会对数目较多的属性有所偏好,因此一般用信息增益率(c4.5)\n其中分母可以看作为属性自身的熵。取值可能性越多,属性的熵越大。\nCart决策树使用基尼指数来选择划分属性,直观的来说,Gini(D)反映了从数据集D中随机抽取两个样本,其类别标记不一致的概率,因此基尼指数越小数据集D的纯度越高,一般为了防止过拟合要进行剪枝,有预剪枝和后剪枝,一般用cross validation集进行剪枝。\n连续值和缺失值的处理,对于连续属性a,将a在D上出现的不同的取值进行排序,基于划分点t将D分为两个子集。一般对每一个连续的两个取值的中点作为划分点,然后根据信息增益选择最大的。与离散属性不同,若当前节点划分属性为连续属性,该属性还可以作为其后代的划分属性。\n5. SVM,LR,决策树对比? SVM既可以用于分类问题,也可以用于回归问题,并且可以通过核函数快速的计算,LR实现简单,训练速度非常快,但是模型较为简单,决策树容易过拟合,需要进行剪枝等。从优化函数上看,soft margin的SVM用的是hinge loss,而带L2正则化的LR对应的是cross entropy loss,另外adaboost对应的是exponential loss。所以LR对远点敏感,但是SVM对outlier不太敏感,因为只关心support vector,SVM可以将特征映射到无穷维空间,但是LR不可以,一般小数据中SVM比LR更优一点,但是LR可以预测概率,而SVM不可以,SVM依赖于数据测度,需要先做归一化,LR一般不需要,对于大量的数据LR使用更加广泛,LR向多分类的扩展更加直接,对于类别不平衡SVM一般用权重解决,即目标函数中对正负样本代价函数不同,LR可以用一般的方法,也可以直接对最后结果调整(通过阈值),一般小数据下样本维度比较高的时候SVM效果要更优一些。\n6. GBDT 和随机森林的区别? 随机森林采用的是bagging的思想,bagging又称为bootstrap aggreagation,通过在训练样本集中进行有放回的采样得到多个采样集,基于每个采样集训练出一个基学习器,再将基学习器结合。随机森林在对决策树进行bagging的基础上,在决策树的训练过程中引入了随机属性选择。传统决策树在选择划分属性的时候是在当前节点属性集合中选择最优属性,而随机森林则是对结点先随机选择包含k个属性的子集,再选择最有属性,k作为一个参数控制了随机性的引入程度。\n另外,GBDT训练是基于Boosting思想,每一迭代中根据错误更新样本权重,因此是串行生成的序列化方法,而随机森林是bagging的思想,因此是并行化方法。\n7. 如何判断函数凸或非凸?什么是凸优化? 首先定义凸集,如果x,y属于某个集合C,并且所有的 也属于c,那么c为一个凸集,进一步,如果一个函数其定义域是凸集,并且\n则该函数为凸函数。上述条件还能推出更一般的结果,\n如果函数有二阶导数,那么如果函数二阶导数为正,或者对于多元函数,Hessian矩阵半正定则为凸函数。\n(也可能引到SVM,或者凸函数局部最优也是全局最优的证明,或者上述公式期望情况下的Jessen不等式)\n8. 如何解决类别不平衡问题? 有些情况下训练集中的样本分布很不平衡,例如在肿瘤检测等问题中,正样本的个数往往非常的少。从线性分类器的角度,在用 对新样本进行分类的时候,事实上在用预测出的y值和一个y值进行比较,例如常常在y\u0026gt;0.5的时候判为正例,否则判为反例。几率 反映了正例可能性和反例可能性的比值,阈值0.5恰好表明分类器认为正反的可能性相同。在样本不均衡的情况下,应该是分类器的预测几率高于观测几率就判断为正例,因此应该是 时预测为正例,这种策略称为rebalancing。但是训练集并不一定是真实样本总体的无偏采样,通常有三种做法,一种是对训练集的负样本进行欠采样,第二种是对正例进行升采样,第三种是直接基于原始训练集进行学习,在预测的时候再改变阈值,称为阈值移动。注意过采样一般通过对训练集的正例进行插值产生额外的正例,而欠采样将反例划分为不同的集合供不同的学习器使用。\n9. 解释对偶的概念。 一个优化问题可以从两个角度进行考察,一个是primal 问题,一个是dual 问题,就是对偶问题,一般情况下对偶问题给出主问题最优值的下界,在强对偶性成立的情况下由对偶问题可以得到主问题的最优下界,对偶问题是凸优化问题,可以进行较好的求解,SVM中就是将primal问题转换为dual问题进行求解,从而进一步引入核函数的思想。\n10. 如何进行特征选择 ? 特征选择是一个重要的数据预处理过程,主要有两个原因,首先在现实任务中我们会遇到维数灾难的问题(样本密度非常稀疏),若能从中选择一部分特征,那么这个问题能大大缓解,另外就是去除不相关特征会降低学习任务的难度,增加模型的泛化能力。冗余特征指该特征包含的信息可以从其他特征中推演出来,但是这并不代表该冗余特征一定没有作用,例如在欠拟合的情况下也可以用过加入冗余特征,增加简单模型的复杂度。\n在理论上如果没有任何领域知识作为先验假设那么只能遍历所有可能的子集。但是这显然是不可能的,因为需要遍历的数量是组合爆炸的。一般我们分为子集搜索和子集评价两个过程,子集搜索一般采用贪心算法,每一轮从候选特征中添加或者删除,分别成为前向和后先搜索。或者两者结合的双向搜索。子集评价一般采用信息增益,对于连续数据往往排序之后选择中点作为分割点。\n常见的特征选择方式有过滤式,包裹式和嵌入式,filter,wrapper和embedding。Filter类型先对数据集进行特征选择,再训练学习器。Wrapper直接把最终学习器的性能作为特征子集的评价准则,一般通过不断候选子集,然后利用cross-validation过程更新候选特征,通常计算量比较大。嵌入式特征选择将特征选择过程和训练过程融为了一体,在训练过程中自动进行了特征选择,例如L1正则化更易于获得稀疏解,而L2正则化更不容易过拟合。L1正则化可以通过PGD,近端梯度下降进行求解。\n11. 为什么会产生过拟合,有哪些方法可以预防或克服过拟合? 一般在机器学习中,将学习器在训练集上的误差称为训练误差或者经验误差,在新样本上的误差称为泛化误差。显然我们希望得到泛化误差小的学习器,但是我们事先并不知道新样本,因此实际上往往努力使经验误差最小化。然而,当学习器将训练样本学的太好的时候,往往可能把训练样本自身的特点当做了潜在样本具有的一般性质。这样就会导致泛化性能下降,称之为过拟合,相反,欠拟合一般指对训练样本的一般性质尚未学习好,在训练集上仍然有较大的误差。\n欠拟合:一般来说欠拟合更容易解决一些,例如增加模型的复杂度,增加决策树中的分支,增加神经网络中的训练次数等等。根本的原因是特征维度过少,导致拟合的函数无法满足训练集,误差较大。\n欠拟合问题可以通过增加特征维度来解决。可以考虑加入进特征组合、高次特征,来增大假设空间;\n添加多项式特征,这个在机器学习算法里面用的很普遍,例如将线性模型通过添加二次项或者三次项使模型泛化能力更强\n减少正则化参数,正则化的目的是用来防止过拟合的,但是现在模型出现了欠拟合,则需要减少正则化参数\n使用非线性模型,比如核SVM 、决策树、深度学习等模型\n过拟合:一般认为过拟合是无法彻底避免的,因为机器学习面临的问题一般是np-hard,但是一个有效的解一定要在多项式内可以工作,所以会牺牲一些泛化能力。过拟合的解决方案一般有增加样本数量,对样本进行降维,降低模型复杂度,利用先验知识(L1,L2正则化),利用cross-validation,early stopping等等。根本的原因则是特征维度过多,导致拟合的函数完美的经过训练集,但是对新数据的预测结果则较差。\n其他原因:\n训练数据集样本单一,样本不足。如果训练样本只有负样本,然后那生成的模型去预测正样本,这肯定预测不准。所以训练样本要尽可能的全面,覆盖所有的数据类型。 训练数据中噪声干扰过大。噪声指训练数据中的干扰数据。过多的干扰会导致记录了很多噪声特征,忽略了真实输入和输出之间的关系。 **模型过于复杂。**模型太复杂,已经能够“死记硬背”记下了训练数据的信息,但是遇到没有见过的数据的时候不能够变通,泛化能力太差。我们希望模型对不同的模型都有稳定的输出。模型太复杂是过拟合的重要因素。 获取和使用更多的数据(数据集增强)——解决过拟合的根本性方法\n减少特征维度; 可以人工选择保留的特征,或者模型选择算法\n重新清洗数据,导致过拟合的一个原因也有可能是数据不纯导致的,如果出现了过拟合就需要我们重新清洗数据。\n采用正则化方法。正则化方法包括L0正则、L1正则和L2正则,而正则一般是在目标函数之后加上对于的范数。但是在机器学习中一般使用L2正则,下面看具体的原因。\nL0范数是指向量中非0的元素的个数。L1范数是指向量中各个元素绝对值之和,也叫“稀疏规则算子”(Lasso regularization)。两者都可以实现稀疏性,既然L0可以实现稀疏,为什么不用L0,而要用L1呢?个人理解一是因为L0范数很难优化求解(NP难问题),二是L1范数是L0范数的最优凸近似,而且它比L0范数要容易优化求解。所以大家才把目光和万千宠爱转于L1范数。 L2范数是指向量各元素的平方和然后求平方根。可以使得W的每个元素都很小,都接近于0,但与L1范数不同,它不会让它等于0,而是接近于0。L2正则项起到使得参数w变小加剧的效果,但是为什么可以防止过拟合呢?一个通俗的理解便是:更小的参数值w意味着模型的复杂度更低,对训练数据的拟合刚刚好(奥卡姆剃刀),不会过分拟合训练数据,从而使得不会过拟合,以提高模型的泛化能力。还有就是看到有人说L2范数有助于处理 condition number不好的情况下矩阵求逆很困难的问题。 采用dropout方法。这个方法在神经网络里面很常用。 12. 什么是偏差与方差? 泛化误差可以分解成偏差的平方加上方差加上噪声。偏差度量了学习算法的期望预测和真实结果的偏离程度,刻画了学习算法本身的拟合能力,方差度量了同样大小的训练集的变动所导致的学习性能的变化,刻画了数据扰动所造成的影响,噪声表达了当前任务上任何学习算法所能达到的期望泛化误差下界,刻画了问题本身的难度。偏差和方差一般称为bias和variance,一般训练程度越强,偏差越小,方差越大,泛化误差一般在中间有一个最小值,如果偏差较大,方差较小,此时一般称为欠拟合,而偏差较小,方差较大称为过拟合。\n偏差: 方差: 13. 神经网络的原理,如何进行训练? 神经网络自发展以来已经是一个非常庞大的学科,一般而言认为神经网络是由单个的神经元和不同神经元之间的连接构成,不够的结构构成不同的神经网络。最常见的神经网络一般称为多层前馈神经网络,除了输入和输出层,中间隐藏层的个数被称为神经网络的层数。BP算法是训练神经网络中最著名的算法,其本质是梯度下降和链式法则。\n14. 介绍卷积神经网络,和 DBN 有什么区别? 卷积神经网络的特点是卷积核,CNN中使用了权共享,通过不断的上采用和卷积得到不同的特征表示,采样层又称为pooling层,基于局部相关性原理进行亚采样,在减少数据量的同时保持有用的信息。DBN是深度信念网络,每一层是一个RBM,整个网络可以视为RBM堆叠得到,通常使用无监督逐层训练,从第一层开始,每一层利用上一层的输入进行训练,等各层训练结束之后再利用BP算法对整个网络进行训练\n15. 采用 EM 算法求解的模型有哪些,为什么不用牛顿法或梯度下降法? 用EM算法求解的模型一般有GMM或者协同过滤,k-means其实也属于EM。EM算法一定会收敛,但是可能收敛到局部最优。由于求和的项数将随着隐变量的数目指数上升,会给梯度计算带来麻烦。\n16. 用 EM 算法推导解释 Kmeans k-means算法是高斯混合聚类在混合成分方差相等,且每个样本仅指派一个混合成分时候的特例。注意k-means在运行之前需要进行归一化处理,不然可能会因为样本在某些维度上过大导致距离计算失效。k-means中每个样本所属的类就可以看成是一个隐变量,在E步中,我们固定每个类的中心,通过对每一个样本选择最近的类优化目标函数,在M步,重新更新每个类的中心点,该步骤可以通过对目标函数求导实现,最终可得新的类中心就是类中样本的均值。\n17. 用过哪些聚类算法,解释密度聚类算法。 k-means算法,聚类性能的度量一般分为两类,一类是聚类结果与某个参考模型比较(外部指标),另外是直接考察聚类结果(内部指标)。后者通常有DB指数和DI,DB指数是对每个类,找出类内平均距离/类间中心距离最大的类,然后计算上述值,并对所有的类求和,越小越好。类似k-means的算法仅在类中数据构成簇的情况下表现较好,密度聚类算法从样本密度的角度考察样本之间的可连接性,并基于可连接样本不断扩展聚类蔟得到最终结果。DBSCAN(density-based spatial clustering of applications with noise)是一种著名的密度聚类算法,基于一组邻域参数 进行刻画,包括邻域,核心对象(邻域内至少包含 个对象),密度直达(j由i密度直达,表示j在i的邻域内,且i是一个核心对象),密度可达(j由i密度可达,存在样本序列使得每一对都密度直达),密度相连(xi,xj存在k,i,j均有k可达),先找出样本中所有的核心对象,然后以任一核心对象作为出发点,找出由其密度可达的样本生成聚类蔟,直到所有核心对象被访问过为止。\n18. 聚类算法中的距离度量有哪些? 聚类算法中的距离度量一般用闽科夫斯基距离,在p取不同的值下对应不同的距离,例如p=1的时候对应曼哈顿距离,p=2的情况下对应欧式距离,p=inf的情况下变为切比雪夫距离,还有jaccard距离,幂距离(闽科夫斯基的更一般形式),余弦相似度,加权的距离,马氏距离(类似加权)作为距离度量需要满足非负性,同一性,对称性和直递性,闽科夫斯基在p\u0026gt;=1的时候满足读来那个性质,对于一些离散属性例如{飞机,火车,轮船}则不能直接在属性值上计算距离,这些称为无序属性,可以用VDM(Value Diffrence Metrix),属性u上两个离散值a,b之间的VDM距离定义为\n其中 表示在第i个簇中属性u上a的样本数,样本空间中不同属性的重要性不同的时候可以采用加权距离,一般如果认为所有属性重要性相同则要对特征进行归一化。一般来说距离需要的是相似性度量,距离越大,相似度越小,用于相似性度量的距离未必一定要满足距离度量的所有性质,例如直递性。比如人马和人,人马和马的距离较近,然后人和马的距离可能就很远。\n19. 解释贝叶斯公式和朴素贝叶斯分类。 贝叶斯公式:\n最小化分类错误的贝叶斯最优分类器等价于最大化后验概率。\n基于贝叶斯公式来估计后验概率的主要困难在于,条件概率 是所有属性上的联合概率,难以从有限的训练样本直接估计得到。朴素贝叶斯分类器采用了属性条件独立性假设,对于已知的类别,假设所有属性相互独立。这样,朴素贝叶斯分类则定义为\n如果有足够多的独立同分布样本,那么 可以根据每个类中的样本数量直接估计出来。在离散情况下先验概率可以利用样本数量估计或者离散情况下根据假设的概率密度函数进行最大似然估计。朴素贝叶斯可以用于同时包含连续变量和离散变量的情况。如果直接基于出现的次数进行估计,会出现一项为0而乘积为0的情况,所以一般会用一些平滑的方法,例如拉普拉斯修正,\n这样既可以保证概率的归一化,同时还能避免上述出现的现象。\n20. 解释L1和L2正则化的作用。 L1正则化是在代价函数后面加上 ,L2正则化是在代价函数后面增加了 ,两者都起到一定的过拟合作用,两者都对应一定的先验知识,L1对应拉普拉斯分布,L2对应高斯分布,L1偏向于参数稀疏性,L2偏向于参数分布较为稠\n21. TF-IDF是什么? TF指Term frequecy,代表词频,IDF代表inverse document frequency,叫做逆文档频率,这个算法可以用来提取文档的关键词,首先一般认为在文章中出现次数较多的词是关键词,词频就代表了这一项,然而有些词是停用词,例如的,是,有这种大量出现的词,首先需要进行过滤,比如过滤之后再统计词频出现了中国,蜜蜂,养殖且三个词的词频几乎一致,但是中国这个词出现在其他文章的概率比其他两个词要高不少,因此我们应该认为后两个词更能表现文章的主题,IDF就代表了这样的信息,计算该值需要一个语料库,如果一个词在语料库中出现的概率越小,那么该词的IDF应该越大,一般来说TF计算公式为(某个词在文章中出现次数/文章的总词数),这样消除长文章中词出现次数多的影响,IDF计算公式为log(语料库文章总数/(包含该词的文章数)+1)。将两者乘乘起来就得到了词的TF-IDF。传统的TF-IDF对词出现的位置没有进行考虑,可以针对不同位置赋予不同的权重进行修正,注意这些修正之所以是有效的,正是因为人观测过了大量的信息,因此建议了一个先验估计,人将这个先验估计融合到了算法里面,所以使算法更加的有效\n22. 文本中的余弦距离是什么,有哪些作用? 余弦距离是两个向量的距离的一种度量方式,其值在-1~1之间,如果为1表示两个向量同相,0表示两个向量正交,-1表示两个向量反向。使用TF-IDF和余弦距离可以寻找内容相似的文章,例如首先用TF-IDF找出两篇文章的关键词,然后每个文章分别取出k个关键词(10-20个),统计这些关键词的词频,生成两篇文章的词频向量,然后用余弦距离计算其相似度。\n参考:\n应聘机器学习工程师?这是你需要知道的12个基础面试问题 Python机器学习Sklearn专题文章集锦 ","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E9%9D%A2%E8%AF%95%E9%A2%98/","summary":"1. 无监督和有监督的区别? 有监督学习:对具有概念标记(分类)的训练样本进行学习,以尽可能对训练样本集外的数据进行标记(分类)预测。这里,所有的","title":"机器学习面试题"},{"content":"正则化也是校招中常考的题目之一,在去年的校招中,被问到了多次:\n1、过拟合的解决方式有哪些,l1和l2正则化都有哪些不同,各自有什么优缺点(爱奇艺) 2、L1和L2正则化来避免过拟合是大家都知道的事情,而且我们都知道L1正则化可以得到稀疏解,L2正则化可以得到平滑解,这是为什么呢? 3、L1和L2有什么区别,从数学角度解释L2为什么能提升模型的泛化能力。(美团) 4、L1和L2的区别,以及各自的使用场景(头条)\n接下来,咱们就针对上面的几个问题,进行针对性回答!\nLink: https://mp.weixin.qq.com/s/t4vRBZXhc0LBST8WGzftgg\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%80%E5%B8%B8%E8%80%83%E7%9A%84%E6%AD%A3%E5%88%99%E9%97%AE%E9%A2%98l1l2/","summary":"正则化也是校招中常考的题目之一,在去年的校招中,被问到了多次: 1、过拟合的解决方式有哪些,l1和l2正则化都有哪些不同,各自有什么优缺点(爱","title":"最常考的正则问题L1L2"},{"content":"贝叶斯准备知识 贝叶斯决策论是概率框架下实施决策的基本方法。要了解贝叶斯决策论,首先得先了解以下几个概念:先验概率、条件概率、后验概率、误判损失、条件风险、贝叶斯判别准则\n先验概率: 所谓先验概率,就是根据以往的经验或者现有数据的分析所得到的概率。如,随机扔一枚硬币,则p(正面) = p(反面) = 1/2,这是我们根据已知的知识所知道的信息,即p(正面) = 1/2为先验概率。\n条件概率: 所谓条件概率是指事件A在另一事件B发生的条件下发送的概率。用数学符号表示为:P(B\\|A),即B在A发生的条件下发生的概率。举个栗子,你早上误喝了一瓶过期了的牛奶(A),那我们来算一下你今天拉肚子的概率(B),这个就叫做条件概率。即P(拉肚子\\|喝了过期牛奶), 易见,条件概率是有因求果(知道原因推测结果)。\n后验概率: 后验概率跟条件概率的表达形式有点相似。数学表达式为p(A\\|B), 即A在B发生的条件下发生的概率。以误喝牛奶的例子为例,现在知道了你今天拉肚子了(B),算一下你早上误喝了一瓶过期了的牛奶(A)的概率, 即P(A|B),这就是后验概率,后验概率是有果求因(知道结果推出原因)\n误判损失: 数学表达式:L(j|i), 判别损失表示把一个标记为i类的样本误分类为j类所造成的损失。 比如,当你去参加体检时,明明你各项指标都是正常的,但是医生却把你分为癌症病人,这就造成了误判损失,用数学表示为:L(癌症|正常)。\n条件风险: 是指基于后验概率P(i|x)可获得将样本x分类为i所产生的期望损失,公式为:R(i|x) = ∑L(i|j)P(j|x)。(其实就是所有判别损失的加权和,而这个权就是样本判为j类的概率,样本本来应该含有P(j|x)的概率判为j类,但是却判为了i类,这就造成了错判损失,而将所有的错判损失与正确判断的概率的乘积相加,就能得到样本错判为i类的平均损失,即条件风险。)\n举个栗子,假设把癌症病人判为正常人的误判损失是100,把正常人判为癌症病人的误判损失是10,把感冒病人判为癌症的误判损失是8,即L(正常|癌症) = 100, L(癌症|正常) = 10,L(癌症|感冒) = 8, 现在,我们经过计算知道有一个来体检的员工的后验概率分别为:p(正常|各项指标) = 0.2, p(感冒|各项指标) = 0.4, p( 癌症|各项指标)=0.4。假如我们需要计算将这个员工判为癌症的条件风险,则:R(癌症|各项指标) = L(癌症|正常) p(正常|各项指标) + L(癌症|感冒) * p(感冒|各项指标) = 5.2。*\n贝叶斯判别准则:\n贝叶斯判别准则是找到一个使条件风险达到最小的判别方法。即,将样本判为哪一类,所得到的条件风险R(i|x)(或者说平均判别损失)最小,那就将样本归为那个造成平均判别损失最小的类。\n此时:h*(x) = argminR(i|x) 就称为 贝叶斯最优分类器。\n总结:贝叶斯决策论是基于先验概率求解后验概率的方法,其核心是寻找一个判别准则使得条件风险达到最小。而在最小化分类错误率的目标下,贝叶斯最优分类器又可以转化为求后验概率达到最大的类别标记,即 h*(x) = argmaxP(i|x)。(此时,L(i|j) = 0, if i = j;L(i|j) = 1, otherwise)\n简单说说朴素贝叶斯 朴素贝叶斯采用 属性条件独立性 的假设,对于给定的待分类观测数据X,计算在X出现的条件下,各个目标类出现的概率(即后验概率),将该后验概率最大的类作为X所属的类。而计算后验概率的贝叶斯公式为:p(A|B) =[ p(A) * p(B|A)]/p(B),因为p(B)表示观测数据X出现的概率,它在所有关于X的分类计算公式中都是相同的,所以我们可以把p(B)忽略,则 p(A|B)= p(A) * p(B|A)。\n举个栗子,公司里面男性有60人,女性有40人,男性穿皮鞋的人数有25人,穿运动鞋的人数有35人,女性穿皮鞋的人数有10人,穿高跟鞋的人数有30人。现在你只知道有一个人穿了皮鞋,这时候你就需要推测他的性别是什么。如果推测出他是男性的概率大于女性,那么就认为他是男性,否则认为他是女性。(如果此时条件允许,你可以现场给面试官演示一下怎么计算, 计算过程如下:\n1 p(性别 = 男性) = 0.6p(性别 = 女性) = 0.4p(穿皮鞋\\|男性) = 0.417p(穿皮鞋\\|女性) = 0.25p(穿皮鞋\\|男性) * p(性别 = 男性) = 0.2502p(穿皮鞋\\|女性) * p(性别 = 女性) = 0.1 朴素贝叶斯中的朴素怎么理解 素贝叶斯中的朴素可以理解为是“简单、天真”的意思,因为“朴素”是假设了特征之间是同等重要、相互独立、互不影响的,但是在我们的现实社会中,属性之间并不是都是互相独立的,有些属性也会存在性,所以说朴素贝叶斯是一种很“朴素”的算法。\n素贝叶斯的工作流程 可以分为三个阶段进行,分别是准备阶段、分类器训练阶段和应用阶段。\n**准备阶段:**这个阶段的任务是为朴素贝叶斯分类做必要的准备,主要工作是根据具体情况确定特征属性,并对每个特征属性进行适当划分,去除高度相关性的属性(如果两个属性具有高度相关性的话,那么该属性将会在模型中发挥了2次作用,会使得朴素贝叶斯所预测的结果向该属性所希望的方向偏离,导致分类出现偏差),然后由人工对一部分待分类项进行分类,形成训练样本集合。这一阶段的输入是所有待分类数据,输出是特征属性和训练样本。(这一阶段是整个朴素贝叶斯分类中唯一需要人工完成的阶段,其质量对整个过程将有重要影响。)\n分类器训练阶段:这个阶段的任务就是生成分类器,主要工作是计算每个类别在训练样本中的出现频率及每个特征属性划分对每个类别的条件概率估计,并将结果记录。其输入是特征属性和训练样本,输出是分类器。这一阶段是机械性阶段,根据前面讨论的公式可以由程序自动计算完成。\n**应用阶段:**这个阶段的任务是使用分类器对待分类项进行分类,其输入是分类器和待分类项,输出是待分类项与类别的映射关系。这一阶段也是机械性阶段,由程序完成。\n朴素贝叶斯有什么优缺点 优点 朴素贝叶斯模型发源于古典数学理论,有稳定的分类效率。\n对缺失数据不太敏感,算法也比较简单,常用于文本分类。\n分类准确度高,速度快。\n对小规模的数据表现很好,能处理多分类任务,适合增量式训练,当数据量超出内存时,我们可以一批批的去增量训练(朴素贝叶斯在训练过程中只需要计算各个类的概率和各个属性的类条件概率,这些概率值可以快速地根据增量数据进行更新,无需重新全量计算)。\n缺点 对训练数据的依赖性很强,如果训练数据误差较大,那么预测出来的效果就会不佳。对输入数据的表达形式很敏感(离散、连续,值极大极小之类的)\n理论上,朴素贝叶斯模型与其他分类方法相比具有最小的误差率。\n但是在实际中,因为朴素贝叶斯“朴素,”的特点,导致在属性个数比较多或者属性之间相关性较大时,分类效果不好。\n而在属性相关性较小时,朴素贝叶斯性能最为良好。\n对于这一点,有半朴素贝叶斯之类的算法通过考虑部分关联性适度改进。\n需要知道先验概率,且先验概率很多时候是基于假设或者已有的训练数据所得的,这在某些时候可能会因为假设先验概率的原因出现分类决策上的错误。\n“朴素”是朴素贝叶斯在进行预测时候的缺点,那么有这么一个明显的假设缺点在,为什么朴素贝叶斯的预测仍然可以取得较好的效果? 对于分类任务来说,只要各个条件概率之间的排序正确,那么就可以通过比较概率大小来进行分类,不需要知道精确的概率值(朴素贝叶斯分类的核心思想是找出后验概率最大的那个类,而不是求出其精确的概率) 如果属性之间的相互依赖对所有类别的影响相同,或者相互依赖关系可以互相抵消,那么属性条件独立性的假设在降低计算开销的同时不会对分类结果产生不良影响。 什么是拉普拉斯平滑法? 在估计条件概率P(X|Y)时出现概率为0的情况怎么办?\n简单来说:引入λ,当λ=1时称为拉普拉斯平滑。\n拉普拉斯平滑法是朴素贝叶斯中处理零概率问题的一种修正方式。在进行分类的时候,可能会出现某个属性在训练集中没有与某个类同时出现过的情况,如果直接基于朴素贝叶斯分类器的表达式进行计算的话就会出现零概率现象。为了避免其他属性所携带的信息被训练集中未出现过的属性值“抹去”,所以才使用拉普拉斯估计器进行修正。具体的方法是:在分子上加1,对于先验概率,在分母上加上训练集中可能的类别数;对于条件概率,则在分母上加上第i个属性可能的取值数\n朴素贝叶斯中有没有超参数可以调? 朴素贝叶斯是没有超参数可以调的,所以它不需要调参,朴素贝叶斯是根据训练集进行分类,分类出来的结果基本上就是确定了的,拉普拉斯估计器不是朴素贝叶斯中的参数,不能通过拉普拉斯估计器来对朴素贝叶斯调参。\n朴素贝叶斯中有多少种模型? 朴素贝叶斯含有3种模型,分别是高斯模型,对连续型数据进行处理;多项式模型,对离散型数据进行处理,计算数据的条件概率(使用拉普拉斯估计器进行平滑的一个模型);伯努利模型,伯努利模型的取值特征是布尔型,即出现为ture,不出现为false,在进行文档分类时,就是一个单词有没有在一个文档中出现过。\n你知道朴素贝叶斯有哪些应用吗? 知道(肯定得知道啊,不然不就白学了吗?) 朴素贝叶斯的应用最广的应该就是在文档分类、垃圾文本过滤(如垃圾邮件、垃圾信息等)、情感分析(微博、论坛上的积极、消极等情绪判别)这些方面,除此之外还有多分类实时预测、推荐系统(贝叶斯与协同过滤组合使用)、拼写矫正(当你输入一个错误单词时,可以通过文档库中出现的概率对你的输入进行矫正)等。\n你觉得朴素贝叶斯对异常值敏不敏感? 朴素贝叶斯对异常值不敏感。所以在进行数据处理时,我们可以不去除异常值,因为保留异常值可以保持朴素贝叶斯算法的整体精度,而去除异常值则可能在进行预测的过程中由于失去部分异常值导致模型的泛化能力下降。\n朴素贝叶斯是高方差还是低方差模型? 朴素贝叶斯是低方差模型。(误差 = 偏差 + 方差)对于复杂模型来说,由于复杂模型充分拟合了部分数据,使得它们的偏差变小,但由于对部分数据过分拟合,这就导致预测的方差会变大。因为朴素贝叶斯假设了各个属性之间是相互的,算是一个简单的模型。对于简单的模型来说,则恰恰相反,简单模型的偏差会更大,相对的,方差就会较小。(偏差是模型输出值与真实值的误差,也就是模型的精准度,方差是预测值与模型输出期望的的误差,即模型的稳定性,也就是数据的集中性的一个指标)\nNavie Bayes和Logistic回归区别是什么? 前者是生成式模型,后者是判别式模型,二者的区别就是生成式模型与判别式模型的区别。\n1)首先,Navie Bayes通过已知样本求得先验概率P(Y), 及条件概率P(X|Y), 对于给定的实例,计算联合概率,进而求出后验概率。也就是说,它尝试去找到底这个数据是怎么生成的(产生的),然后再进行分类。哪个类别最有可能产生这个信号,就属于那个类别。\n优点:样本容量增加时,收敛更快;隐变量存在时也可适用。\n缺点:时间长;需要样本多;浪费计算资源\n2)相比之下,Logistic回归不关心样本中类别的比例及类别下出现特征的概率,它直接给出预测模型的式子。设每个特征都有一个权重,训练样本数据更新权重w,得出最终表达式。梯度法。\n优点:直接预测往往准确率更高;简化问题;可以反应数据的分布情况,类别的差异特征;适用于较多类别的识别。\n缺点:收敛慢;不适用于有隐变量的情况。\n参考:https://mp.weixin.qq.com/s/xzJDNRv8ipJY9hTo8WXoCw\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%B4%E7%B4%A0%E8%B4%9D%E5%8F%B6%E6%96%AF/","summary":"贝叶斯准备知识 贝叶斯决策论是概率框架下实施决策的基本方法。要了解贝叶斯决策论,首先得先了解以下几个概念:先验概率、条件概率、后验概率、误判损","title":"朴素贝叶斯"},{"content":"在调整模型更新权重和偏差参数的方式时,你是否考虑过哪种优化算法能使模型产生更好且更快的效果?应该用梯度下降,随机梯度下降,还是Adam方法?\n这篇文章介绍了不同优化算法之间的主要区别,以及如何选择最佳的优化方法。\n梯度: 是多元函数对当前给定点,上升最快的方向。梯度是一组向量,所以带有方向;\n梯度下降流程: https://zhuanlan.zhihu.com/p/68468520 w, b 每轮是每个样本的权重梯度向量和偏差梯度向量的平均值;\n梯度下降本质是沿着负梯度值方向寻找损失函数Loss的最小值解 时的参数w,b , 从而得出对样本数据拟合最好的参数w,b。 https://www.jianshu.com/p/c7e642877b0e\n什么是优化算法? 优化算法的功能,是通过改善训练方式,来最小化(或最大化)损失函数E(x)。\n模型内部有些参数,是用来计算测试集中目标值Y的真实值和预测值的偏差程度的,基于这些参数,就形成了损失函数E(x)。\n比如说,权重(W)和偏差(b)就是这样的内部参数,一般用于计算输出值,在训练神经网络模型时起到主要作用。\n**在有效地训练模型并产生准确结果时,模型的内部参数起到了非常重要的作用。**这也是为什么我们应该用各种优化策略和算法,来更新和计算影响模型训练和模型输出的网络参数,使其逼近或达到最优值。\n优化算法分为两大类:\n1. 一阶优化算法\n这种算法使用各参数的梯度值来最小化或最大化损失函数E(x)。最常用的一阶优化算法是梯度下降。\n函数梯度:导数dy/dx的多变量表达式,用来表示y相对于x的瞬时变化率。往往为了计算多变量函数的导数时,会用梯度取代导数,并使用偏导数来计算梯度。梯度和导数之间的一个主要区别是函数的梯度形成了一个向量场。\n因此,对单变量函数,使用导数来分析;而梯度是基于多变量函数而产生的。更多理论细节在这里不再进行详细解释。\n2. 二阶优化算法\n二阶优化算法使用了二阶导数(也叫做Hessian方法)来最小化或最大化损失函数。由于二阶导数的计算成本很高,所以这种方法并没有广泛使用。\n详解各种神经网络优化算法 梯度下降 在训练和优化智能系统时,梯度下降是一种最重要的技术和基础。梯度下降的功能是:\n通过寻找最小值,控制方差,更新模型参数,最终使模型收敛。\n网络更新参数的公式为:θ=θ−η×∇(θ).J(θ) ,其中η是学习率,∇(θ).J(θ)是损失函数J(θ)的梯度。\n这是在神经网络中最常用的优化算法。\n如今,梯度下降主要用于在神经网络模型中进行权重更新,即在一个方向上更新和调整模型的参数,来最小化损失函数。\n2006年引入的反向传播技术,使得训练深层神经网络成为可能。反向传播技术是先在前向传播中计算输入信号的乘积及其对应的权重,然后将激活函数作用于这些乘积的总和。这种将输入信号转换为输出信号的方式,是一种对复杂非线性函数进行建模的重要手段,并引入了非线性激活函数,使得模型能够学习到几乎任意形式的函数映射。然后,在网络的反向传播过程中回传相关误差,使用梯度下降更新权重值,通过计算误差函数E相对于权重参数W的梯度,在损失函数梯度的相反方向上更新权重参数。\n**图1:**权重更新方向与梯度方向相反 图1显示了权重更新过程与梯度矢量误差的方向相反,其中U形曲线为梯度。要注意到,当权重值W太小或太大时,会存在较大的误差,需要更新和优化权重,使其转化为合适值,所以我们试图在与梯度相反的方向找到一个局部最优值。\n梯度下降的变体 传统的批量梯度下降将计算整个数据集梯度,但只会进行一次更新,因此在处理大型数据集时速度很慢且难以控制,甚至导致内存溢出。\n权重更新的快慢是由学习率η决定的,并且可以在凸面误差曲面中收敛到全局最优值,在非凸曲面中可能趋于局部最优值。\n使用标准形式的批量梯度下降还有一个问题,就是在训练大型数据集时存在冗余的权重更新。\n标准梯度下降的上述问题在随机梯度下降方法中得到了解决。\n1. 随机梯度下降(SDG)\n随机梯度下降(Stochastic gradient descent,SGD)对每个训练样本进行参数更新,每次执行都进行一次更新,且执行速度更快。\nθ=θ−η⋅∇(θ) × J(θ;x(i);y(i)),其中x(i)和y(i)为训练样本。\n频繁的更新使得参数间具有高方差,损失函数会以不同的强度波动。这实际上是一件好事,因为它有助于我们发现新的和可能更优的局部最小值,而标准梯度下降将只会收敛到某个局部最优值。\n但SGD的问题是,由于频繁的更新和波动,最终将收敛到最小限度,并会因波动频繁存在超调量。\n虽然已经表明,当缓慢降低学习率η时,标准梯度下降的收敛模式与SGD的模式相同。\n**图2:**每个训练样本中高方差的参数更新会导致损失函数大幅波动,因此我们可能无法获得给出损失函数的最小值。 另一种称为“小批量梯度下降”的变体,则可以解决高方差的参数更新和不稳定收敛的问题。\n2. 小批量梯度下降\n为了避免SGD和标准梯度下降中存在的问题,一个改进方法为小批量梯度下降(Mini Batch Gradient Descent),因为对每个批次中的n个训练样本,这种方法只执行一次更新。\n使用小批量梯度下降的优点是:\n1) 可以减少参数更新的波动,最终得到效果更好和更稳定的收敛。\n2) 还可以使用最新的深层学习库中通用的矩阵优化方法,使计算小批量数据的梯度更加高效。\n3) 通常来说,小批量样本的大小范围是从50到256,可以根据实际问题而有所不同。\n4) 在训练神经网络时,通常都会选择小批量梯度下降算法。\n这种方法有时候还是被成为SGD。\n使用梯度下降及其变体时面临的挑战 1. 很难选择出合适的学习率。太小的学习率会导致网络收敛过于缓慢,而学习率太大可能会影响收敛,并导致损失函数在最小值上波动,甚至出现梯度发散。\n2. 此外,相同的学习率并不适用于所有的参数更新。如果训练集数据很稀疏,且特征频率非常不同,则不应该将其全部更新到相同的程度,但是对于很少出现的特征,应使用更大的更新率。\n3. 在神经网络中,最小化非凸误差函数的另一个关键挑战是避免陷于多个其他局部最小值中。实际上,问题并非源于局部极小值,而是来自鞍点,即一个维度向上倾斜且另一维度向下倾斜的点。这些鞍点通常被相同误差值的平面所包围,这使得SGD算法很难脱离出来,因为梯度在所有维度上接近于零。\n进一步优化梯度下降 现在我们要讨论用于进一步优化梯度下降的各种算法。\n1. 动量\nSGD方法中的高方差振荡使得网络很难稳定收敛,所以有研究者提出了一种称为动量(Momentum)的技术,通过优化相关方向的训练和弱化无关方向的振荡,来加速SGD训练。换句话说,这种新方法将上个步骤中更新向量的分量’γ’添加到当前更新向量。\nV(t)=γV(t−1)+η∇(θ).J(θ)\n最后通过θ=θ−V(t)来更新参数。\n动量项γ通常设定为0.9,或相近的某个值。\n这里的动量与经典物理学中的动量是一致的,就像从山上投出一个球,在下落过程中收集动量,小球的速度不断增加。\n在参数更新过程中,其原理类似:\n1) 使网络能更优和更稳定的收敛;\n2) 减少振荡过程。\n当其梯度指向实际移动方向时,动量项γ增大;当梯度与实际移动方向相反时,γ减小。这种方式意味着动量项只对相关样本进行参数更新,减少了不必要的参数更新,从而得到更快且稳定的收敛,也减少了振荡过程。\n2. Nesterov梯度加速法\n一位名叫Yurii Nesterov研究员,认为动量方法存在一个问题:\n如果一个滚下山坡的球,盲目沿着斜坡下滑,这是非常不合适的。一个更聪明的球应该要注意到它将要去哪,因此在上坡再次向上倾斜时小球应该进行减速。\n实际上,当小球达到曲线上的最低点时,动量相当高。由于高动量可能会导致其完全地错过最小值,因此小球不知道何时进行减速,故继续向上移动。\nYurii Nesterov在1983年发表了一篇关于解决动量问题的论文,因此,我们把这种方法叫做Nestrov梯度加速法。\n在该方法中,他提出先根据之前的动量进行大步跳跃,然后计算梯度进行校正,从而实现参数更新。这种预更新方法能防止大幅振荡,不会错过最小值,并对参数更新更加敏感。\nNesterov梯度加速法(NAG)是一种赋予了动量项预知能力的方法,通过使用动量项γV(t−1)来更改参数θ。通过计算θ−γV(t−1),得到下一位置的参数近似值,这里的参数是一个粗略的概念。因此,我们不是通过计算当前参数θ的梯度值,而是通过相关参数的大致未来位置,来有效地预知未来:\nV(t)=γV(t−1)+η∇(θ)J( θ−γV(t−1) ),然后使用θ=θ−V(t)来更新参数。\n现在,我们通过使网络更新与误差函数的斜率相适应,并依次加速SGD,也可根据每个参数的重要性来调整和更新对应参数,以执行更大或更小的更新幅度。\n3. Adagrad方法\nAdagrad方法是通过参数来调整合适的学习率η,对稀疏参数进行大幅更新和对频繁参数进行小幅更新。因此,Adagrad方法非常适合处理稀疏数据。\n在时间步长中,Adagrad方法基于每个参数计算的过往梯度,为不同参数θ设置不同的学习率。\n先前,每个参数θ(i)使用相同的学习率,每次会对所有参数θ进行更新。在每个时间步t中,Adagrad方法为每个参数θ选取不同的学习率,更新对应参数,然后进行向量化。为了简单起见,我们把在t时刻参数θ(i)的损失函数梯度设为g(t,i)。\n**图3:**参数更新公式 Adagrad方法是在每个时间步中,根据过往已计算的参数梯度,来为每个参数θ(i)修改对应的学习率η。\nAdagrad方法的主要好处是,不需要手工来调整学习率。大多数参数使用了默认值0.01,且保持不变。\nAdagrad方法的主要缺点是,学习率η总是在降低和衰减。\n因为每个附加项都是正的,在分母中累积了多个平方梯度值,故累积的总和在训练期间保持增长。这反过来又导致学习率下降,变为很小数量级的数字,该模型完全停止学习,停止获取新的额外知识。\n因为随着学习速度的越来越小,模型的学习能力迅速降低,而且收敛速度非常慢,需要很长的训练和学习,即学习速度降低。\n另一个叫做Adadelta的算法改善了这个学习率不断衰减的问题。\n4. AdaDelta方法\n这是一个AdaGrad的延伸方法,它倾向于解决其学习率衰减的问题。Adadelta不是累积所有之前的平方梯度,而是将累积之前梯度的窗口限制到某个固定大小w。\n与之前无效地存储w先前的平方梯度不同,梯度的和被递归地定义为所有先前平方梯度的衰减平均值。作为与动量项相似的分数γ,在t时刻的滑动平均值Eg²仅仅取决于先前的平均值和当前梯度值。\nEg²=γ.Eg²+(1−γ).g²(t),其中γ设置为与动量项相近的值,约为0.9。\nΔθ(t)=−η⋅g(t,i).\nθ(t+1)=θ(t)+Δθ(t)\n**图4:**参数更新的最终公式 AdaDelta方法的另一个优点是,已经不需要设置一个默认的学习率。\n目前已完成的改进 1) 为每个参数计算出不同学习率;\n2) 也计算了动量项momentum;\n3) 防止学习率衰减或梯度消失等问题的出现。\n还可以做什么改进? 在之前的方法中计算了每个参数的对应学习率,但是为什么不计算每个参数的对应动量变化并独立存储呢?这就是Adam算法提出的改良点。\nAdam算法\nAdam算法即自适应时刻估计方法(Adaptive Moment Estimation),能计算每个参数的自适应学习率。这个方法不仅存储了AdaDelta先前平方梯度的指数衰减平均值,而且保持了先前梯度M(t)的指数衰减平均值,这一点与动量类似:\nM(t)为梯度的第一时刻平均值,V(t)为梯度的第二时刻非中心方差值。\n**图5:**两个公式分别为梯度的第一个时刻平均值和第二个时刻方差 则参数更新的最终公式为:\n**图6:**参数更新的最终公式 其中,β1设为0.9,β2设为0.9999,ϵ设为10-8。\n在实际应用中,Adam方法效果良好。与其他自适应学习率算法相比,其收敛速度更快,学习效果更为有效,而且可以纠正其他优化技术中存在的问题,如学习率消失、收敛过慢或是高方差的参数更新导致损失函数波动较大等问题。\n对优化算法进行可视化\n**图8:**对鞍点进行SGD优化 从上面的动画可以看出,自适应算法能很快收敛,并快速找到参数更新中正确的目标方向;而标准的SGD、NAG和动量项等方法收敛缓慢,且很难找到正确的方向。\n结论 我们应该使用哪种优化器?\n在构建神经网络模型时,选择出最佳的优化器,以便快速收敛并正确学习,同时调整内部参数,最大程度地最小化损失函数。\nAdam在实际应用中效果良好,超过了其他的自适应技术。\n**如果输入数据集比较稀疏,SGD、NAG和动量项等方法可能效果不好。**因此对于稀疏数据集,应该使用某种自适应学习率的方法,且另一好处为不需要人为调整学习率,使用默认参数就可能获得最优值。\n如果想使训练深层网络模型快速收敛或所构建的神经网络较为复杂,则应该使用Adam或其他自适应学习速率的方法,因为这些方法的实际效果更优。\n希望你能通过这篇文章,很好地理解不同优化算法间的特性差异。\n相关链接: 二阶优化算法: https://web.stanford.edu/class/msande311/lecture13.pdf\nNesterov梯度加速法:http://cs231n.github.io/neural-networks-3/\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E4%B9%8B%E4%BC%98%E5%8C%96%E7%AE%97%E6%B3%95/","summary":"在调整模型更新权重和偏差参数的方式时,你是否考虑过哪种优化算法能使模型产生更好且更快的效果?应该用梯度下降,随机梯度下降,还是Adam方法?","title":"机器学习之优化算法"},{"content":"机器学习常见距离介绍 1. 欧式距离 2. 曼哈顿距离 我们可以定义曼哈顿距离的正式意义为L1-距离或城市区块距离,也就是在欧几里得空间的固定直角坐标系上两点所形成的线段对轴产生的投影的距离总和。例如在平面上,坐标(x1, y1)的点P1与坐标(x2, y2)的点P2的曼哈顿距离为:,要注意的是,曼哈顿距离依赖座标系统的转度,而非系统在座标轴上的平移或映射。 通俗来讲,想象你在曼哈顿要从一个十字路口开车到另外一个十字路口,驾驶距离是两点间的直线距离吗?显然不是,除非你能穿越大楼。而实际驾驶距离就是这个“曼哈顿距离”,此即曼哈顿距离名称的来源, 同时,曼哈顿距离也称为城市街区距离(City Block distance)。\n3. 切比雪夫距离 若二个向量或二个点p 、and q,其座标分别为p1,p2 4. 闵可夫斯基距离(Minkowski Distance) 闵氏距离不是一种距离,而是一组距离的定义.\n(1) 闵氏距离的定义 两个n维变量a(x11,x12,…,x1n)与 b(x21,x22,…,x2n)间的闵可夫斯基距离定义为: 其中p是一个变参数。 当p=1时,就是曼哈顿距离 当p=2时,就是欧氏距离 当p→∞时,就是切比雪夫距离 根据变参数的不同,闵氏距离可以表示一类的距离。\n5. 标准化欧氏距离 (Standardized Euclidean distance ) 标准化欧氏距离是针对简单欧氏距离的缺点而作的一种改进方案。标准欧氏距离的思路:既然数据各维分量的分布不一样,那先将各个分量都“标准化”到均值、方差相等。至于均值和方差标准化到多少,先复习点统计学知识。\n假设样本集X的数学期望或均值(mean)为m,标准差(standard deviation,方差开根)为s,那么X的“标准化变量”X*表示为:(X-m)/s,而且标准化变量的数学期望为0,方差为1。\n即,样本集的标准化过程(standardization)用公式描述就是: 标准化后的值 = ( 标准化前的值 - 分量的均值 ) /分量的标准差 经过简单的推导就可以得到两个n维向量a(x11,x12,…,x1n)与 b(x21,x22,…,x2n)间的标准化欧氏距离的公式: 如果将方差的倒数看成是一个权重,这个公式可以看成是一种加权欧氏距离(Weighted Euclidean distance)。\n6. 马氏距离(Mahalanobis Distance) 有M个样本向量X1~Xm,协方差矩阵记为S,均值记为向量μ,则其中样本向量X到u的马氏距离表示为: (协方差矩阵中每个元素是各个矢量元素之间的协方差Cov(X,Y),Cov(X,Y) = E{ [X-E(X)] [Y-E(Y)]},其中E为数学期望)\n而其中向量Xi与Xj之间的马氏距离定义为:\n若协方差矩阵是单位矩阵(各个样本向量之间独立同分布),则公式就成了: 也就是欧氏距离了。 若协方差矩阵是对角矩阵,公式变成了标准化欧氏距离。\n马氏距离的优缺点:量纲无关,排除变量之间的相关性的干扰。 「微博上的seafood高清版点评道:原来马氏距离是根据协方差矩阵演变,一直被老师误导了,怪不得看Killian在05年NIPS发表的LMNN论文时候老是看到协方差矩阵和半正定,原来是这回事」 7.巴氏距离(Bhattacharyya Distance) 在统计中,Bhattacharyya距离测量两个离散或连续概率分布的相似性。它与衡量两个统计样品或种群之间的重叠量的Bhattacharyya系数密切相关。Bhattacharyya距离和Bhattacharyya系数以20世纪30年代曾在印度统计研究所工作的一个统计学家A. Bhattacharya命名。同时,Bhattacharyya系数可以被用来确定两个样本被认为相对接近的,它是用来测量中的类分类的可分离性。\n8.汉明距离(Hamming distance) 两个等长字符串s1与s2之间的汉明距离定义为将其中一个变为另外一个所需要作的最小替换次数。例如字符串“1111”与“1001”之间的汉明距离为2。应用:信息编码(为了增强容错性,应使得编码间的最小汉明距离尽可能大)。\n9.夹角余弦(Cosine) 几何中夹角余弦可用来衡量两个向量方向的差异,机器学习中借用这一概念来衡量样本向量之间的差异。 0. 杰卡德相似系数(Jaccard similarity coefficient) (1) 杰卡德相似系数\n两个集合A和B的交集元素在A,B的并集中所占的比例,称为两个集合的杰卡德相似系数,用符号J(A,B)表示。 杰卡德相似系数是衡量两个集合的相似度一种指标。\n(2) 杰卡德距离\n与杰卡德相似系数相反的概念是杰卡德距离(Jaccard distance)。 杰卡德距离可用如下公式表示: 杰卡德距离用两个集合中不同元素占所有元素的比例来衡量两个集合的区分度。\n(3) 杰卡德相似系数与杰卡德距离的应用\n可将杰卡德相似系数用在衡量样本的相似度上。 举例:样本A与样本B是两个n维向量,而且所有维度的取值都是0或1,例如:A(0111)和B(1011)。我们将样本看成是一个集合,1表示集合包含该元素,0表示集合不包含该元素。\nM11 :样本A与B都是1的维度的个数\n​M01:样本A是0,样本B是1的维度的个数\n​M10:样本A是1,样本B是0 的维度的个数\n​M00:样本A与B都是0的维度的个数\n11.皮尔逊系数(Pearson Correlation Coefficient) 在具体阐述皮尔逊相关系数之前,有必要解释下什么是相关系数 ( Correlation coefficient )与相关距离(Correlation distance)。\n相关系数 ( Correlation coefficient )的定义是:\n(其中,E为数学期望或均值,D为方差,D开根号为标准差,E{ [X-E(X)] [Y-E(Y)]}称为随机变量X与Y的协方差,记为Cov(X,Y),即Cov(X,Y) = E{ [X-E(X)] [Y-E(Y)]},而两个变量之间的协方差和标准差的商则称为随机变量X与Y的相关系数,记为Pxy) 相关系数衡量随机变量X与Y相关程度的一种方法,相关系数的取值范围是[-1,1]。相关系数的绝对值越大,则表明X与Y相关度越高。当X与Y线性相关时,相关系数取值为1(正线性相关)或-1(负线性相关)。 具体的,如果有两个变量:X、Y,最终计算出的相关系数的含义可以有如下理解:\n当相关系数为0时,X和Y两变量无关系。 当X的值增大(减小),Y值增大(减小),两个变量为正相关,相关系数在0.00与1.00之间。 当X的值增大(减小),Y值减小(增大),两个变量为负相关,相关系数在-1.00与0.00之间。 (2)皮尔逊相关系数的适用范围 当两个变量的标准差都不为零时,相关系数才有定义,皮尔逊相关系数适用于:\n两个变量之间是线性关系,都是连续数据。 两个变量的总体是正态分布,或接近正态的单峰分布。 两个变量的观测值是成对的,每对观测值之间相互独立。 (3)皮尔逊相关的约束条件\n从以上解释, 也可以理解皮尔逊相关的约束条件:\n1 两个变量间有线性关系 2 变量是连续变量 3 变量均符合正态分布,且二元分布也符合正态分布 4 两变量独立 在实践统计中,一般只输出两个系数,一个是相关系数,也就是计算出来的相关系数大小,在-1到1之间;另一个是独立样本检验系数,用来检验样本一致性\nSummary: 简单说来,各种“距离”的应用场景简单概括为,\n空间:欧氏距离,\n路径:曼哈顿距离,\n国际象棋国王:切比雪夫距离,\n以上三种的统一形式:闵可夫斯基距离,\n加权:标准化欧氏距离,\n排除量纲和依存:马氏距离,\n向量差距:夹角余弦,\n编码差别:汉明距离,\n集合近似度:杰卡德类似系数与距离,\n相关:相关系数与相关距离。\n","permalink":"https://reid00.github.io/en/posts/ml/%E5%B8%B8%E8%A7%81%E8%B7%9D%E7%A6%BB%E7%9A%84%E4%BB%8B%E7%BB%8D/","summary":"机器学习常见距离介绍 1. 欧式距离 2. 曼哈顿距离 我们可以定义曼哈顿距离的正式意义为L1-距离或城市区块距离,也就是在欧几里得空间的固定直角坐标系上","title":"常见距离的介绍"},{"content":"Summary PCA 是无监督学习中最常见的数据降维方法,但是实际上问题特征很多的情况,PCA通常会预处理来减少特征个数。\n将维的意义: 通过降维提高算法的效率 通过降维更方便数据的可视化,通过可视化我们可以更好的理解数据\n相关统计概念 均值: 述的是样本集合的中间点。 方差: 概率论和统计方差衡量随机变量或一组数据时离散程度的度量。 标准差:而标准差给我们描述的是样本集合的各个样本点到均值的距离之平均。方差开根号。 标准差和方差一般是用来描述一维数据的 协方差: (多维)度量两个随机变量关系的统计量,来度量各个维度偏离其均值的程度。 协方差矩阵: (多维)度量各个维度偏离其均值的程度 当 cov(X, Y)\u0026gt;0时,表明X与Y正相关(X越大,Y也越大;X越小Y,也越小。) 当 cov(X, Y)\u0026lt;0时,表明X与Y负相关; 当 cov(X, Y)=0时,表明X与Y不相关。 cov协方差=[(x1-x均值)(y1-y均值)+(x2-x均值)(y2-y均值)+\u0026hellip;+(xn-x均值)*(yn-y均值)]/(n-1) PCA 思想 对数据进行归一化处理(代码中并非这么做的,而是直接减去均值) 计算归一化后的数据集的协方差矩阵 计算协方差矩阵的特征值和特征向量 将特征值排序 保留前N个最大的特征值对应的特征向量 将数据转换到上面得到的N个特征向量构建的新空间中(实现了特征压缩) 简述主成分分析PCA工作原理,以及PCA的优缺点? PCA旨在找到数据中的主成分,并利用这些主成分表征原始数据,从而达到降维的目的。\n​ 工作原理可由两个角度解释,第一个是最大化投影方差(让数据在主轴上投影的方差尽可能大);第二个是最小化平方误差(样本点到超平面的垂直距离足够近)。\n​ 做法是数据中心化之后,对样本数据协方差矩阵进行特征分解,选取前d个最大的特征值对应的特征向量,即可将数据从原来的p维降到d维,也可根据奇异值分解来求解主成分。\n优点: 1.计算简单,易于实现\n2.各主成分之间正交,可消除原始数据成分间的相互影响的因素\n3.仅仅需要以方差衡量信息量,不受数据集以外的因素影响\n4.降维维数木有限制,可根据需要制定\n缺点: 1.无法利用类别的先验信息\n2.降维后,只与数据有关,主成分各个维度的含义模糊,不易于解释\n3.方差小的非主成分也可能含有对样本差异的重要信息,因降维丢弃可能对后续数据处理有影响\n4.线性模型,对于复杂数据集难以处理(可用核映射方式改进)\nPCA中有第一主成分、第二主成分,它们分别是什么,又是如何确定的? 主成分分析是设法将原来众多具有一定相关性(比如P个指标),重新组合成一组新的互相无关的综合指标来代替原来的指标。主成分分析,是考察多个变量间相关性一种多元统计方法,研究如何通过少数几个主成分来揭示多个变量间的内部结构,即从原始变量中导出少数几个主成分,使它们尽可能多地保留原始变量的信息,且彼此间互不相关,通常数学上的处理就是将原来P个指标作线性组合,作为新的综合指标。\n​ 最经典的做法就是用F1(选取的第一个线性组合,即第一个综合指标)的方差来表达,即Var(F1)越大,表示F1包含的信息越多。因此在所有的线性组合中选取的F1应该是方差最大的,故称F1为第一主成分。如果第一主成分不足以代表原来P个指标的信息,再考虑选取F2即选第二个线性组合,为了有效地反映原来信息,F1已有的信息就不需要再出现在F2中,用数学语言表达就是要求Cov(F1, F2)=0,则称F2为第二主成分,依此类推可以构造出第三、第四,……,第P个主成分。\nLDA与PCA都是常用的降维方法,二者的区别 它其实是对数据在高维空间下的一个投影转换,通过一定的投影规则将原来从一个角度看到的多个维度映射成较少的维度。到底什么是映射,下面的图就可以很好地解释这个问题——正常角度看是两个半椭圆形分布的数据集,但经过旋转(映射)之后是两条线性分布数据集。\nLDA与PCA都是常用的降维方法,二者的区别在于:\n**出发思想不同。**PCA主要是从特征的协方差角度,去找到比较好的投影方式,即选择样本点投影具有最大方差的方向( 在信号处理中认为信号具有较大的方差,噪声有较小的方差,信噪比就是信号与噪声的方差比,越大越好。);而LDA则更多的是考虑了分类标签信息,寻求投影后不同类别之间数据点距离更大化以及同一类别数据点距离最小化,即选择分类性能最好的方向。\n**学习模式不同。**PCA属于无监督式学习,因此大多场景下只作为数据处理过程的一部分,需要与其他算法结合使用,例如将PCA与聚类、判别分析、回归分析等组合使用;LDA是一种监督式学习方法,本身除了可以降维外,还可以进行预测应用,因此既可以组合其他模型一起使用,也可以独立使用。\n**降维后可用维度数量不同。**LDA降维后最多可生成C-1维子空间(分类标签数-1),因此LDA与原始维度N数量无关,只有数据标签分类数量有关;而PCA最多有n维度可用,即最大可以选择全部可用维度。\n线性判别分析LDA算法由于其简单有效性在多个领域都得到了广泛地应用,是目前机器学习、数据挖掘领域经典且热门的一个算法;但是算法本身仍然存在一些局限性:\n当样本数量远小于样本的特征维数,样本与样本之间的距离变大使得距离度量失效,使LDA算法中的类内、类间离散度矩阵奇异,不能得到最优的投影方向,在人脸识别领域中表现得尤为突出\nLDA不适合对非高斯分布的样本进行降维\nLDA在样本分类信息依赖方差而不是均值时,效果不好\nLDA可能过度拟合数据\n主成分分析 PCA 详解 原理及对应操作 主成分分析顾名思义是对主成分进行分析,那么找出主成分应该是key点。PCA的基本思想就是将初始数据集中的n维特征映射至k维上,得到的k维特征就可以被称作主成分,k维不是在n维中挑选出来的,而是以n维特征为基础重构出来的。\nPCA会在已知数据的基础上重构坐标轴,它的原理是要最大化保留样本间的方差,两个特征之间方差越大不就代表相关性越差嘛。比如:\n第一个新坐标轴就是原始数据中方差最大的方向。 第二个新坐标轴要是与第一个新坐标轴正交的平面中方差最大的方向。 第三个新坐标轴要是与第一、第二新坐标轴正交的平面中方差最大的方向。 第四、第五\u0026hellip;依次类推直到第n个新坐标轴(对应n维)。 为了加深这部分理解,以二维平面先举一个例子,二维平面中依据原始数据新建坐标轴如下图:\n为了更直观的理解,若将方差换一个说法,那么第一个新坐标轴就是覆盖样本最多的一条(斜向右上),第二个新坐标轴需要与第一新坐标轴正交且覆盖样本最多(斜向左上),依次类推。\n覆盖的样本多少并不是以坐标轴穿过多少样本点评判的,而是通过样本点垂直映射至该轴的个数有多少,具体如下图:\n回到之前的n维重构坐标轴,由于顺序是依据方差的大小依次排序的,所以越到后面方差越小,而方差越小代表特征之间相关性越强,那么这类特征就可以删去,只保留前k个坐标轴(对应k维),这就相当于保留了含有数据集绝大部分方差的特征,而除去方差几乎为0的特征。\n那么问题来了,二维、三维可以根据样本点的分布画出重构坐标轴,但是更高维人的大脑是不接受的,我们不得不通过计算的方式求得特征之间的方差,进而得到这些新坐标轴的的方向。\n具体方法就是通过计算原始数据矩阵对应的协方差矩阵,然后可以得到协方差矩阵对应的特征值和特征向量,选取特征值最大的前k个特征向量组成的矩阵,通过特征矩阵就可以将原始数据矩阵从n维空间映射至k维空间,从而实现特征降维。\n方差、协方差及协方差矩阵 如果你曾经接触过线性代数可能对这三个概念很熟悉,可能间隔时间太久有些模糊,下面再帮大家温习一下:\n方差(Variance)一般用来描述样本集合中的各个样本点到样本均值的差的平方和的均值:\n协方差(Covariance)目的是度量两个变量(只能为两个)线性相关的程度:\n为可以说明两个变量线性无关,但不能证明两个变量相互独立,当时,二者呈正相关,时,二者呈负相关。\n协方差矩阵就是由两两变量之间的协方差组成,有以下特点:\n协方差矩阵可以处理多维度问题。 协方差矩阵是一个对称的矩阵,而且对角线是各个维度上的方差。 协方差矩阵计算的是不同维度之间的协方差,而不是不同样本之间的。 样本矩阵中若每行是一个样本,则每列为一个维度。 假设数据是3维的,那么对应协方差矩阵为:\n这里简要概括一下协方差矩阵是怎么求得的,假设一个数据集有3维特征、每个特征有m个变量,这个数据集对应的数据矩阵如下:\n若假设他们的均值都为0,可以得到下面等式:\n可以看到对角线上为每个特征方差,其余位置为两个特征之间的协方差,求得的就为协方差矩阵。\n这里叙述的有些简略,感兴趣的小伙伴可以自行查询相关知识。\n特征值和特征向量 得到了数据矩阵的协方差矩阵,下面就应该求协方差矩阵的特征值和特征向量,先了解一下这两个概念,如果一个向量v是矩阵A的特征向量,那么一定存在下列等式:\n其中A可视为数据矩阵对应的协方差矩阵,是特征向量v的特征值。数据矩阵的主成分就是由其对应的协方差矩阵的特征向量,按照对应的特征值由大到小排序得到的。最大的特征值对应的特征向量就为第一主成分,第二大的特征值对应的特征向量就为第二主成分,依次类推,如果由n维映射至k维就截取至第k主成分。\n实例操作 通过上述部分总结一下PCA降维操作的步骤:\n去均值化 依据数据矩阵计算协方差矩阵 计算协方差矩阵的特征值和特征向量 将特征值从大到小排序 保留前k个特征值对应的特征向量 将原始数据的n维映射至k维中 公式手推降维 原始数据集矩阵,每行代表一个特征:\n对每个特征去均值化:\n计算对应的协方差矩阵:\n依据协方差矩阵计算特征值和特征向量,套入公式:\n拆开计算如下:\n可以求得两特征值:\n,\n当时,对应向量应该满足如下等式:\n对应的特征向量可取为:\n同理当时,对应特征向量可取为:\n这里我就不对两个特征向量进行标准化处理了,直接合并两个特征向量可以得到矩阵P:\n选取大的特征值对应的特征向量乘以原数据矩阵后可得到降维后的矩阵A:\n综上步骤就是通过PCA手推公式实现二维降为一维的操作。\nnumpy实现降维 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import numpy as np def PCA(X,k): \u0026#39;\u0026#39;\u0026#39; X:输入矩阵 k:需要降低到的维数 \u0026#39;\u0026#39;\u0026#39; X = np.array(X) #转为矩阵 sample_num ,feature_num = X.shape #样本数和特征数 meanVals = np.mean(X,axis = 0) #求每个特征的均值 X_mean = X-meanVals #去均值化 Cov = np.dot(X_mean.T,X_mean)/sample_num #求协方差矩阵 feature_val,feature_vec = np.linalg.eig(Cov) #将特征值和特征向量打包 val_sort = [(np.abs(feature_val[i]),feature_vec[:,i]) for i in range(feature_num)] val_sort.sort(reverse=True) #按特征值由大到小排列 #截取至前k个特征向量组成特征矩阵 feature_mat = [feature[1] for feature in val_sort[:k]] # 降n维映射至k维 PCA_mat = np.dot(X_mean,np.array(feature_mat).T) return PCA_mat if __name__ == \u0026#34;__main__\u0026#34;: X = [[1, 1], [1, 3], [2, 3], [4, 4], [2, 4]] print(PCA(X,1)) 运行截图如下:\n代码部分就是公式的套用,每一步后都有注释,不再过多解释。可以看到得到的结果和上面手推公式得到的有些出入,上文曾提过特征向量是可以随意缩放的,这也是导致两个结果不同的原因,可以在运行代码时打印一下特征向量feature_vec,观察一下这个特点。\nsklearn库实现降维 1 2 3 4 5 6 7 8 from sklearn.decomposition import PCA import numpy as np X = [[1, 1], [1, 3], [2, 3], [4, 4], [2, 4]] X = np.array(X) pca = PCA(n_components=1) PCA_mat = pca.fit_transform(X) print(PCA_mat) 这里只说一下参数n_components,如果输入的是整数,代表数据集需要映射的维数,比如输入3代表最后要映射至3维;如果输入的是小数,则代表映射的维数为原数据维数的占比,比如输入0.3,如果原数据20维,就将其映射至6维。\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%95%B0%E6%8D%AE%E9%99%8D%E7%BB%B4%E4%B9%8B%E4%B8%BB%E6%88%90%E5%88%86%E5%88%86%E6%9E%90-pca/","summary":"Summary PCA 是无监督学习中最常见的数据降维方法,但是实际上问题特征很多的情况,PCA通常会预处理来减少特征个数。 将维的意义: 通过降维提高算法的效率 通","title":"数据降维之主成分分析 PCA"},{"content":"问题目录: 1、决策树的实现、ID3、C4.5、CART(贝壳) 2、CART回归树是怎么实现的?(贝壳) 3、CART分类树和ID3以及C4.5有什么区别(贝壳) 4、剪枝有哪几种方式(贝壳) 5、树集成模型有哪几种实现方式?(贝壳)boosting和bagging的区别是什么?(知乎、阿里) 6、随机森林的随机体现在哪些方面(贝壳、阿里) 7、AdaBoost是如何改变样本权重,GBDT分类树的基模型是?(贝壳) 8、gbdt,xgboost,lgbm的区别(百度、滴滴、阿里,头条) 9、bagging为什么能减小方差?(知乎)\n其他问题: 10、关于AUC的另一种解释:是挑选一个正样本和一个负样本,正样本排在负样本前面的概率?如何理解? 11、校招是集中时间刷题好,还是每天刷一点好呢? 12、现在推荐在工业界基本都用match+ranking的架构,但是学术界论文中的大多算法算是没有区分吗?end-to-end的方式,还是算是召回? 13、内推刷简历严重么?没有实习经历,也没有牛逼的竞赛和论文,提前批有面试机会么?提前批影响正式批么? 14、除了自己项目中的模型了解清楚,还需要准备哪些?看了群主的面经大概知道了一些,能否大致描述下?\n1、决策树的实现、ID3、C4.5、CART(贝壳) 这道题主要是要求把公式写一下,所以决策树的公式大家要理解,并且能熟练地写出来。这里咱们简单回顾一下吧。主要参考统计学习方法就好了。\nID3使用信息增益来指导树的分裂: C4.5通过信息增益比来指导树的分裂: CART的话既可以是分类树,也可以是回归树。当是分类树时,使用基尼系数来指导树的分裂: 当是回归树时,则使用的是平方损失最小: 2、CART回归树是怎么实现的?(贝壳) CART回归树的实现包含两个步骤: 1)决策树生成:基于训练数据生成决策树、生成的决策树要尽量大 2)决策树剪枝:用验证数据集对已生成的树进行剪枝并选择最优子树,这时用损失函数最小作为剪枝的标准。\n这部分的知识,可以看一下《统计学习方法》一书。\n3、CART分类树和ID3以及C4.5有什么区别(贝壳) 1)首先是决策规则的区别,CART分类树使用基尼系数、ID3使用的是信息增益,而C4.5使用的是信息增益比。 2)ID3和C4.5可以是多叉树,但是CART分类树只能是二叉树(这是我当时主要回答的点)\n4、剪枝有哪几种方式(贝壳) 前剪枝和后剪枝,参考周志华《机器学习》。\n5、树集成模型有哪几种实现方式?(贝壳)boosting和bagging的区别是什么?(知乎、阿里) 树集成模型主要有两种实现方式,分别是Bagging和Boosting。二者的区别主要有以下四点: 1)样本选择上: Bagging:训练集是在原始集中有放回选取的,从原始集中选出的各轮训练集之间是独立的. Boosting:每一轮的训练集不变,只是训练集中每个样例在分类器中的权重发生变化.而权值是根据上一轮的分类结果进行调整. 2)样例权重: Bagging:使用均匀取样,每个样例的权重相等 Boosting:根据错误率不断调整样例的权值,错误率越大则权重越大. 3)预测函数: Bagging:所有预测函数的权重相等. Boosting:每个弱分类器都有相应的权重,对于分类误差小的分类器会有更大的权重. 4)并行计算: Bagging:各个预测函数可以并行生成 Boosting:各个预测函数只能顺序生成,因为后一个模型参数需要前一轮模型的结果.\n6、随机森林的随机体现在哪些方面(贝壳、阿里) 随机森林的随机主要体现在两个方面:一个是建立每棵树时所选择的特征是随机选择的;二是生成每棵树的样本也是通过有放回抽样产生的。\n7、AdaBoost是如何改变样本权重,GBDT分类树的基模型是?(贝壳) AdaBoost改变样本权重:增加分类错误的样本的权重,减小分类正确的样本的权重。\n最后一个问题是我在面试之前没有了解到的,GBDT无论做分类还是回归问题,使用的都是CART回归树。\n8、gbdt,xgboost,lgbm的区别(百度、滴滴、阿里,头条) 首先来看GBDT和Xgboost,二者的区别如下:\n1)传统 GBDT 以 CART 作为基分类器,xgboost 还支持线性分类器,这个时候 xgboost 相当于带 L1 和 L2 正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题)。 2)传统 GBDT 在优化时只用到一阶导数信息,xgboost 则对代价函数进行了二阶泰勒展开,同时用到了一阶和二阶导数。顺便 一下,xgboost 工具支持自定义代价函数,只要函数可一阶和二阶求导。 3)xgboost 在代价函数里加入了正则项,用于控制模型的复杂度。正则项里包含了树的叶子节点个数、每个叶子节点上输出的 score 的 L2 模的平方和。从 Bias-variance tradeoff 角度来讲,正则项降低了模型的variance,使学习出来的模型更加简单,防止过拟合,这也是 xgboost 优于传统GBDT 的一个特性。 4)Shrinkage(缩减),相当于学习速率(xgboost 中的eta)。xgboost 在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削 弱每棵树的影响,让后面有更大的学习空间。实际应用中,一般把 eta 设置得小一点,然后迭代次数设置得大一点。(补充:传统 GBDT 的实现 也有学习速率) 5)列抽样(column subsampling)。xgboost 借鉴了随机森林的做法,支 持列抽样,不仅能降低过拟合,还能减少计算,这也是 xgboost 异于传 统 gbdt 的一个特性。 6)对缺失值的处理。对于特征的值有缺失的样本,xgboost 可以自动学习 出它的分裂方向。 7)xgboost 工具支持并行。boosting 不是一种串行的结构吗?怎么并行的? 注意 xgboost 的并行不是 tree 粒度的并行,xgboost 也是一次迭代完才能进行下一次迭代的(第 t 次迭代的代价函数里包含了前面 t-1 次迭代 的预测值)。xgboost 的并行是在特征粒度上的。我们知道,决策树的学习最耗时的一个步骤就是对特征的值进行排序(因为要确定最佳分割点),xgboost在训练之前,预先对数据进行了排序,然后保存为 block 结构,后面的迭代中重复地使用这个结构,大大减小计算量。这个block结构也使得并行成为了可能,在进行节点的分裂时,需要计算每 个特征的增益,最终选增益最大的那个特征去做分裂,那么各个特征的增益计算就可以开多线程进行。 8)可并行的近似直方图算法。树节点在进行分裂时,我们需要计算每个特征的每个分割点对应的增益,即用贪心法枚举所有可能的分割点。当数据无法一次载入内存或者在分布式情况下,贪心算法效率就会变得很低,所以 xgboost 还 出了一种可并行的近似直方图算法,用于高效地生成候选的分割点。\n再来看Xgboost和LightGBM,二者的区别如下:\n1)由于在决策树在每一次选择节点特征的过程中,要遍历所有的属性的所有取 值并选择一个较好的。XGBoost 使用的是近似算法算法,先对特征值进行排序 Pre-sort,然后根据二阶梯度进行分桶,能够更精确的找到数据分隔点;但是 复杂度较高。LightGBM 使用的是 histogram 算法,这种只需要将数据分割成不同的段即可,不需要进行预先的排序。占用的内存更低,数据分隔的复杂度更低。 2)决策树生长策略,我们刚才介绍过了,XGBoost采用的是 Level-wise 的树 生长策略,LightGBM 采用的是 leaf-wise 的生长策略。 3)并行策略对比,XGBoost 的并行主要集中在特征并行上,而 LightGBM 的并 行策略分特征并行,数据并行以及投票并行。\n9、bagging为什么能减小方差?(知乎) 这个当时也没有答上来,可以参考一下博客:https://blog.csdn.net/shenxiaoming77/article/details/53894973\n树模型相关的题目以上就差不多了。接下来整理一些最近群友提出的问题,我觉得有一些可能作为面试题,有一些是准备校招过程中的经验:\n10、关于AUC的另一种解释:是挑选一个正样本和一个负样本,正样本排在负样本前面的概率?如何理解? 我们都知道AUC是ROC曲线下方的面积,ROC曲线的横轴是真正例率,纵轴是假正例率。我们可以按照如下的方式理解一下:首先偷换一下概念,意思还是一样的,任意给定一个负样本,所有正样本的score中有多大比例是大于该负类样本的score?那么对每个负样本来说,有多少的正样本的score比它的score大呢?是不是就是当结果按照score排序,阈值恰好为该负样本score时的真正例率TPR?理解到这一层,二者等价的关系也就豁然开朗了。ROC曲线下的面积或者说AUC的值 与 测试任意给一个正类样本和一个负类样本,正类样本的score有多大的概率大于负类样本的score是等价的。\n11、校招是集中时间刷题好,还是每天刷一点好呢? 我的建议是平时每天刷3~5道,然后临近校招的时候集中刷。另外就是根据每次面试中被问到的问题,如果有没答上来的,就针对这一类的题型多刷刷。\n12、现在推荐在工业界基本都用match+ranking的架构,但是学术界论文中的大多算法算是没有区分吗?end-to-end的方式,还是算是召回? 学术界论文往往不针对整个推荐系统,而只针对match或者ranking阶段的某一种方法进行研究。比如DeepFM、Wide \u0026amp; Deep,只针对ranking阶段。而阿里有几篇介绍embedding的论文,只介绍match阶段的方法。end-to-end的方式,在match和ranking阶段都有吧。\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%80%E5%B8%B8%E8%80%83%E7%9A%84%E6%A0%91%E6%A8%A1%E5%9E%8B%E9%97%AE%E9%A2%98/","summary":"问题目录: 1、决策树的实现、ID3、C4.5、CART(贝壳) 2、CART回归树是怎么实现的?(贝壳) 3、CART分类树和ID3以及C4.5","title":"最常考的树模型问题"},{"content":"简述决策树原理? 决策树是一种自上而下,对样本数据进行树形分类的过程,由节点和有向边组成。节点分为内部节点和叶节点,其中每个内部节点表示一个特征或属性,叶节点表示类别。从顶部节点开始,所有样本聚在一起,经过根节点的划分,样本被分到不同的子节点中,再根据子节点的特征进一步划分,直至所有样本都被归到某个类别。\n为什么要对决策树进行减枝?如何进行减枝? 剪枝是决策树解决过拟合问题的方法。在决策树学习过程中,为了尽可能正确分类训练样本,结点划分过程将不断重复,有时会造成决策树分支过多,于是可能将训练样本学得太好,以至于把训练集自身的一些特点当作所有数据共有的一般特点而导致测试集预测效果不好,出现了过拟合现象。因此,可以通过剪枝来去掉一些分支来降低过拟合的风险。\n决策树剪枝的基本策略有“预剪枝”和“后剪枝”。预剪枝是指在决策树生成过程中,对每个结点在划分前先进行估计,若当前结点的划分不能带来决策树泛化性能提升,则停止划分并将当前结点标记为叶结点;后剪枝则是先从训练集生成一棵完整的决策树,然后自底向上地对非叶结点进行考察,若将该结点对应的子树替换为叶结点能带来决策树泛化性能提升,则将该子树替换为叶结点。\n预剪枝使得决策树的很多分支都没有\u0026quot;展开”,这不仅降低了过拟合的风险,还显著减少了决策树的训练时间开销和测试时间开销。但另一方面,有些分支的当前划分虽不能提升泛化性能、甚至可能导致泛化性能暂时下降?但在其基础上进行的后续划分却有可能导致性能显著提高;预剪枝基于\u0026quot;贪心\u0026quot;本质禁止这些分支展开,给预剪枝决策树带来了欠拟含的风险。\n后剪枝决策树通常比预剪枝决策树保留了更多的分支,一般情形下后剪枝决策树的欠拟合风险很小,泛化性能往往优于预剪枝决策树 。但后剪枝过程是在生成完全决策树之后进行的 并且要白底向上对树中的所有非叶结点进行逐 考察,因此其训练时间开销比未剪枝决策树和预剪枝决策树都要大得多。\n简述决策树的生成策略? 决策树主要有ID3、C4.5、CART,算法的适用略有不同,但它们有个总原则,即在选择特征、向下分裂、树生成中,它们都是为了让信息更“纯”。\n举一个简单例子,通过三个特征:是否有喉结、身高、体重,判断人群中的男女,是否有喉结把人群分为两部分,一边全是男性、一边全是女性,达到理想结果,纯度最高。 通过身高或体重,人群会有男有女。 上述三种算法,信息增益、增益率、基尼系数对“纯”的不同解读。如下详细阐述:\n​ 综上,ID3采用信息增益作为划分依据,会倾向于取值较多的特征,因为信息增益反映的是给定条件以后不确定性减少的程度,特征取值越多就意味着不确定性更高。C4.5对ID3进行优化,通过引入信息增益率,对特征取值较多的属性进行惩罚。\n随机森林 Bagging(套袋法) bagging的算法过程如下:\n从原始样本集中使用Bootstraping方法随机抽取n个训练样本,共进行k轮抽取,得到k个训练集。(k个训练集之间相互独立,元素可以有重复) 对于k个训练集,我们训练k个模型(这k个模型可以根据具体问题而定,比如决策树,knn等) 对于分类问题:由投票表决产生分类结果;对于回归问题:由k个模型预测结果的均值作为最后预测结果。(所有模型的重要性相同) Boosting(提升法) boosting的算法过程如下:\n对于训练集中的每个样本建立权值wi,表示对每个样本的关注度。当某个样本被误分类的概率很高时,需要加大对该样本的权值。 进行迭代的过程中,每一步迭代都是一个弱分类器。我们需要用某种策略将其组合,作为最终模型。(例如AdaBoost给每个弱分类器一个权值,将其线性组合最为最终分类器。误差越小的弱分类器,权值越大) 提升就是指每一步我都产生一个弱预测模型,然后加权累加到总模型中,然后每一步弱预测模型生成的的依据都是损失函数的负梯度方向,这样若干步以后就可以达到逼近损失函数局部最小值的目标。 Bagging,Boosting的主要区别 样本选择上:Bagging采用的是Bootstrap随机有放回抽样;而Boosting每一轮的训练集是不变的,改变的只是每一个样本的权重。\n每轮训练过后如何调整样本权重 ?\n如何确定最后各学习器的权重 这两个问题可由加法模型和指数损失函数推导出来。\n样本权重:Bagging使用的是均匀取样,每个样本权重相等;Boosting根据错误率调整样本权重,错误率越大的样本权重越大。\n预测函数:Bagging所有的预测函数的权重相等;Boosting中误差越小的预测函数其权重越大。\n并行计算:Bagging各个预测函数可以并行生成;Boosting各个预测函数必须按顺序迭代生成。\n下面是将决策树与这些算法框架进行结合所得到的新的算法: 1)Bagging + 决策树 = 随机森林 2)AdaBoost + 决策树 = 提升树 (自适应提升(AdaBoost)) 3)Gradient Boosting + 决策树 = GBDT 梯度下降提升树(GDBT)\n首先既然是树,那么它的基函数肯定就是决策树啦,而损失函数则是根据我们具体的问题去分析,但方法都一样,最终都走上了梯度下降的老路,比如说进行到第m步的时候,首先计算残差\n有了残差之后,我们再用(xi,rim)去拟合第m个基函数,假设这棵树把输入空间划分成j个空间R1m,R2m……,Rjm,假设它在每个空间上的输出为bjm,这样的话,第m棵树可以表示如下:\n下一步,对树的每个区域分别用线性搜索的方式寻找最佳步长,这个步长可以和上面的区域预测值bjm进行合并,最后就得到了第m步的目标函数\n当然了,对于GDBT比较容易出现过拟合的情况,所以有必要增加一点正则项,比如叶节点的数目或叶节点预测值的平方和,进而限制模型复杂度的过度提升,这里在下面的实践中的参数设置我们可以继续讨论。\n构造随机森林的 4 个步骤: 假如有N个样本,则有放回的随机选择N个样本(每次随机选择一个样本,然后返回继续选择)。这选择好了的N个样本用来训练一个决策树,作为决策树根节点处的样本。\n当每个样本有M个属性时,在决策树的每个节点需要分裂时,随机从这M个属性中选取出m个属性,满足条件m \u0026laquo; M。然后从这m个属性中采用某种策略(比如说信息增益)来选择1个属性作为该节点的分裂属性。\n策树形成过程中每个节点都要按照步骤2来分裂(很容易理解,如果下一次该节点选出来的那一个属性是刚刚其父节点分裂时用过的属性,则该节点已经达到了叶子节点,无须继续分裂了)。一直到不能够再分裂为止。注意整个决策树形成过程中没有进行剪枝。\n按照步骤1~3建立大量的决策树,这样就构成了随机森林了。\n随机森林的优缺点 优点 它可以出来很高维度(特征很多)的数据,并且不用降维,无需做特征选择 它可以判断特征的重要程度 可以判断出不同特征之间的相互影响 不容易过拟合 训练速度比较快,容易做成并行方法 实现起来比较简单 对于不平衡的数据集来说,它可以平衡误差。 如果有很大一部分的特征遗失,仍可以维持准确度。 缺点 随机森林已经被证明在某些噪音较大的分类或回归问题上会过拟合。 对于有不同取值的属性的数据,取值划分较多的属性会对随机森林产生更大的影响,所以随机森林在这种数据上产出的属性权值是不可信的 ","permalink":"https://reid00.github.io/en/posts/ml/%E5%86%B3%E7%AD%96%E6%A0%91%E5%88%B0%E9%9A%8F%E6%9C%BA%E6%A3%AE%E6%9E%97/","summary":"简述决策树原理? 决策树是一种自上而下,对样本数据进行树形分类的过程,由节点和有向边组成。节点分为内部节点和叶节点,其中每个内部节点表示一个特","title":"决策树到随机森林"},{"content":"Summary “所有模型都是坏的,但有些模型是有用的”。我们建立模型之后,接下来就要去评估模型,确定这个模型是否‘有用’。当你费尽全力去建立完模型后,你会发现仅仅就是一些单个的数值或单个的曲线去告诉你你的模型到底是否能够派上用场。\n​ 在实际情况中,我们会用不同的度量去评估我们的模型,而度量的选择,完全取决于模型的类型和模型以后要做的事。下面我们就会学习到一些用于评价模型的常用度量和图表以及它们各自的使用场景。\n模型评估这部分会介绍以下几方面的内容:\n性能度量 模型评估方法 泛化能力 过拟合、欠拟合 超参数调优 本文会首先介绍性能度量方面的内容,主要是分类问题和回归问题的性能指标,包括以下几个方法的介绍:\n准确率和错误率 精确率、召回率以及 F1 ROC 曲线 和 AUC 代价矩阵 回归问题的性能度量 其他评价指标,如计算速度、鲁棒性等 1. 性能度量 性能度量就是指对模型泛化能力衡量的评价标准。\n1.1 准确率和错误率 分类问题中最常用的两个性能度量标准\u0026ndash; 准确率和错误率。\n准确率: 指的是分类正确的样本数量占样本总数的比例,定义如下:\n错误率:指分类错误的样本占样本总数的比例,定义如下:\n错误率也是损失函数为 0-1 损失时的误差。\n这两种评价标准是分类问题中最简单也是最直观的评价指标。但它们都存在一个问题,在类别不平衡的情况下,它们都无法有效评价模型的泛化能力。即如果此时有 99% 的负样本,那么模型预测所有样本都是负样本的时候,可以得到 99% 的准确率。\n这种情况就是在类别不平衡的时候,占比大的类别往往成为影响准确率的最主要因素!\n这种时候,其中一种解决方法就是更换评价指标,比如采用更为有效的平均准确率(每个类别的样本准确率的算术平均),即:\n其中 m 是类别的数量。\n对于准确率和错误率,用 Python 代码实现如下图所示:\n1 2 3 4 5 6 def accuracy(y_true,y_pred): return sum(y==y_p for y,y_p in zip(y_true,y_pred))/len(y_true def error(y_true, y_pred): return sum(y != y_p for y, y_p in zip(y_true, y_pred)) / len(y_true) 一个简单的二分类测试样例:\n1 2 3 4 5 6 7 y_true = [1, 0, 1, 0, 1] y_pred = [0, 0, 1, 1, 0] acc = accuracy(y_true, y_pred) err = error(y_true, y_pred) print(\u0026#39;accuracy=\u0026#39;, acc) print(\u0026#39;error=\u0026#39;, err) 输出结果如下:\n1 2 accuracy= 0.4 error= 0.6 1.2 精确率、召回率、P-R 曲线和 F1 精确率,也被称作查准率,是指所有预测为正类的结果中,真正的正类的比例。公式如下:\n召回率,也被称作查全率,是指所有正类中,被分类器找出来的比例。公式如下:\n对于上述两个公式的符号定义,是在二分类问题中,我们将关注的类别作为正类,其他类别作为负类别,因此,定义:\nTP(True Positive):真正正类的数量,即分类为正类,实际也是正类的样本数量; FP(False Positive):假正类的数量,即分类为正类,但实际是负类的样本数量; FN(False Negative):假负类的数量,即分类为负类,但实际是正类的样本数量; TN(True Negative):真负类的数量,即分类是负类,实际也负类的样本数量。 更形象的说明,可以参考下表,也是混淆矩阵的定义:\n预测:正类 预测:负类 实际:正类 TP FN 实际:负类 FP TN 精确率和召回率是一对矛盾的度量,通常精确率高时,召回率往往会比较低;而召回率高时,精确率则会比较低,原因如下:\n精确率越高,代表预测为正类的比例更高,而要做到这点,通常就是只选择有把握的样本。最简单的就是只挑选最有把握的一个样本,此时 FP=0,P=1,但 FN 必然非常大(没把握的都判定为负类),召回率就非常低了; 召回率要高,就是需要找到所有正类出来,要做到这点,最简单的就是所有类别都判定为正类,那么 FN=0 ,但 FP 也很大,所有精确率就很低了。 而且不同的问题,侧重的评价指标也不同,比如:\n对于推荐系统,侧重的是精确率。也就是希望推荐的结果都是用户感兴趣的结果,即用户感兴趣的信息比例要高,因为通常给用户展示的窗口有限,一般只能展示 5 个,或者 10 个,所以更要求推荐给用户真正感兴趣的信息; 对于医学诊断系统,侧重的是召回率。即希望不漏检任何疾病患者,如果漏检了,就可能耽搁患者治疗,导致病情恶化。 精确率和召回率的代码简单实现如下,这是基于二分类的情况\n1 2 3 4 5 6 7 8 def precision(y_true, y_pred): true_positive = sum(y and y_p for y, y_p in zip(y_true, y_pred)) predicted_positive = sum(y_pred) return true_positive / predicted_positive def recall(y_true, y_pred): true_positive = sum(y and y_p for y, y_p in zip(y_true, y_pred)) real_positive = sum(y_true) return true_positive / real_positive 结果\n1 2 3 4 5 6 7 8 y_true = [1, 0, 1, 0, 1] y_pred = [0, 0, 1, 1, 0] precisions = precision(y_true, y_pred) recalls = recall(y_true, y_pred) print(\u0026#39;precisions=\u0026#39;, precisions) # 输出为0.5 print(\u0026#39;recalls=\u0026#39;, recalls) # 输出为 0.3333 1.2.2 P-R 曲线和 F1 预测结果其实就是分类器对样本判断为某个类别的置信度,我们可以选择不同的阈值来调整分类器对某个样本的输出结果,比如设置阈值是 0.9,那么只有置信度是大于等于 0.9 的样本才会最终判定为正类,其余的都是负类。\n我们设置不同的阈值,自然就会得到不同的正类数量和负类数量,依次计算不同情况的精确率和召回率,然后我们可以以精确率为纵轴,召回率为横轴,绘制一条“P-R曲线”,如下图所示:\n当然,以上这个曲线是比较理想情况下的,未来绘图方便和美观,实际情况如下图所示:\n对于 P-R 曲线,有:\n1.曲线从左上角 (0,1) 到右下角 (1,0) 的走势,正好反映了精确率和召回率是一对矛盾的度量,一个高另一个低的特点:\n开始是精确率高,因为设置阈值很高,只有第一个样本(分类器最有把握是正类)被预测为正类,其他都是负类,所以精确率高,几乎是 1,而召回率几乎是 0,仅仅找到 1 个正类。 右下角时候就是召回率很高,精确率很低,此时设置阈值就是 0,所以类别都被预测为正类,所有正类都被找到了,召回率很高,而精确率非常低,因为大量负类被预测为正类。 2.P-R 曲线可以非常直观显示出分类器在样本总体上的精确率和召回率。所以可以对比两个分类器在同个测试集上的 P-R 曲线来比较它们的分类能力:\n如果分类器 B 的 P-R 曲线被分类器 A 的曲线完全包住,如下左图所示,则可以说,A 的性能优于 B; 如果是下面的右图,两者的曲线有交叉,则很难直接判断两个分类器的优劣,只能根据具体的精确率和召回率进行比较: 一个合理的依据是比较 P-R 曲线下方的面积大小,它在一定程度上表征了分类器在精确率和召回率上取得“双高”的比例,但这个数值不容易计算; 另一个比较就是平衡点(Break-Event Point, BEP),它是精确率等于召回率时的取值,如下右图所示,而且可以判定,平衡点较远的曲线更好。 当然了,平衡点还是过于简化,于是有了 F1 值这个新的评价标准,它是精确率和召回率的调和平均值,定义为:\nF1 还有一个更一般的形式: ,能让我们表达出对精确率和召回率的不同偏好,定义如下:\n其中 度量了召回率对精确率的相对重要性,当 ,就是 F1;如果 ,召回率更加重要;如果 ,则是精确率更加重要。\n1.2.3 宏精确率/微精确率、宏召回率/微召回率以及宏 F1 / 微 F1 很多时候,我们会得到不止一个二分类的混淆矩阵,比如多次训练/测试得到多个混淆矩阵,在多个数据集上进行训练/测试来估计算法的“全局”性能,或者是执行多分类任务时对类别两两组合得到多个混淆矩阵。\n总之,我们希望在 n 个二分类混淆矩阵上综合考察精确率和召回率。这里一般有两种方法来进行考察:\n1.第一种是直接在各个混淆矩阵上分别计算出精确率和召回率,记为 ,接着计算平均值,就得到宏精确率(macro-P)、宏召回率(macro-R)以及宏 F1(macro-F1) , 定义如下:\n2.第二种则是对每个混淆矩阵的对应元素进行平均,得到 TP、FP、TN、FN 的平均值,再基于这些平均值就就得到微精确率(micro-P)、微召回率(micro-R)以及微 F1(micro-F1) , 定义如下:\n1.3 ROC 与 AUC 1.3.1 ROC 曲线 ROC 曲线的 Receiver Operating Characteristic 曲线的简称,中文名是“受试者工作特征”,起源于军事领域,后广泛应用于医学领域。\n它的横坐标是假正例率(False Positive Rate, FPR),纵坐标是真正例率(True Positive Rate, TPR),两者的定义分别如下:\nTPR 表示正类中被分类器预测为正类的概率,刚好就等于正类的召回率;\nFPR 表示负类中被分类器预测为正类的概率,它等于 1 减去负类的召回率,负类的召回率如下,称为真反例率(True Negative Rate, TNR), 也被称为特异性,表示负类被正确分类的比例。\n第二种更直观地绘制 ROC 曲线的方法,首先统计出正负样本的数量,假设分别是 P 和 N,接着,将横轴的刻度间隔设置为 1/N,纵轴的刻度间隔设置为 1/P。然后根据模型输出的概率对样本排序,并按顺序遍历样本,从零点开始绘制 ROC 曲线,每次遇到一个正样本就沿纵轴方向绘制一个刻度间隔的曲线,遇到一个负样本就沿横轴绘制一个刻度间隔的曲线,直到遍历完所有样本,曲线最终停留在 (1,1) 这个点,此时就完成了 ROC 曲线的绘制了。\n当然,更一般的 ROC 曲线是如下图所示的,会更加的平滑,上图是由于样本数量有限才导致的。\n对于 ROC 曲线,有以下几点特性:\n1.ROC 曲线通常都是从左下角 (0,0) 开始,到右上角 (1,1) 结束。\n开始时候,\n第一个样本被预测为正类\n,其他都是预测为负类别;\nTPR 会很低,几乎是 0,上述例子就是 0.1,此时大量正类没有被分类器找出来; FPR 也很低,可能就是0,上述例子就是 0,这时候被预测为正类的样本可能实际也是正类,所以几乎没有预测错误的正类样本。 结束时候,\n所有样本都预测为正类.\nTPR 几乎就是 1,因为所有样本都预测为正类,那肯定就找出所有的正类样本了; FPR 也是几乎为 1,因为所有负样本都被错误判断为正类。 2.ROC 曲线中:\n对角线对应于随机猜想模型,即概率为 0.5; 点 (0,1) 是理想模型,因为此时 TPR=1,FPR=0,也就是正类都预测出来,并且没有预测错误; 通常,ROC 曲线越接近点 (0, 1) 越好。 3.同样可以根据 ROC 曲线来判断两个分类器的性能:\n如果分类器 A 的 ROC 曲线被分类器 B 的曲线完全包住,可以说 B 的性能好过 A,这对应于上一条说的 ROC 曲线越接近点 (0, 1) 越好; 如果两个分类器的 ROC 曲线发生了交叉,则同样很难直接判断两者的性能优劣,需要借助 ROC 曲线下面积大小来做判断,而这个面积被称为 AUC:Area Under ROC Curve。 1.3.2 ROC 和 P-R 曲线的对比 相同点\n1.两者刻画的都是阈值的选择对分类度量指标的影响。虽然每个分类器对每个样本都会输出一个概率,也就是置信度,但通常我们都会人为设置一个阈值来影响分类器最终判断的结果,比如设置一个很高的阈值\u0026ndash;0.95,或者比较低的阈值\u0026ndash;0.3。\n如果是偏向于精确率,则提高阈值,保证只把有把握的样本判断为正类,此时可以设置阈值为 0.9,或者更高; 如果偏向于召回率,那么降低阈值,保证将更多的样本判断为正类,更容易找出所有真正的正样本,此时设置阈值是 0.5,或者更低。 2.两个曲线的每个点都是对应某个阈值的选择,该点是在该阈值下的 (精确率,召回率) / (TPR, FPR)。然后沿着横轴方向对应阈值的下降。\n不同\n相比较 P-R 曲线,ROC 曲线有一个特点,就是正负样本的分布发生变化时,它的曲线形状能够基本保持不变。如下图所示:\n分别比较了增加十倍的负样本后, P-R 和 ROC 曲线的变化,可以看到 ROC 曲线的形状基本不变,但 P-R 曲线发生了明显的变化。\n所以 ROC 曲线的这个特点可以降低不同测试集带来的干扰,更加客观地评估模型本身的性能,因此它适用的场景更多,比如排序、推荐、广告等领域。\n这也是由于现实场景中很多问题都会存在正负样本数量不平衡的情况,比如计算广告领域经常涉及转化率模型,正样本的数量往往是负样本数量的千分之一甚至万分之一,这时候选择 ROC 曲线更加考验反映模型本身的好坏。\n当然,如果希望看到模型在特定数据集上的表现,P-R 曲线会更直观地反映其性能。所以还是需要具体问题具体分析。\n1.3.3 AUC 曲线 AUC 是 ROC 曲线的面积,其物理意义是:从所有正样本中随机挑选一个样本,模型将其预测为正样本的概率是 ;从所有负样本中随机挑选一个样本,模型将其预测为正样本的概率是 。 的概率就是 AUC。\nAUC 曲线有以下几个特点:\n如果完全随机地对样本进行分类,那么 的概率是 0.5,则 AUC=0.5;\nAUC 在样本不平衡的条件下依然适用。\n如:在反欺诈场景下,假设正常用户为正类(设占比 99.9%),欺诈用户为负类(设占比 0.1%)。\n如果使用准确率评估,则将所有用户预测为正类即可获得 99.9%的准确率。很明显这并不是一个很好的预测结果,因为欺诈用户全部未能找出。\n如果使用 AUC 评估,则此时 FPR=1,TPR=1,对应的 AUC=0.5 。因此 AUC 成功的指出了这并不是一个很好的预测结果。\nAUC 反应的是模型对于样本的排序能力(根据样本预测为正类的概率来排序)。如:AUC=0.8 表示:给定一个正样本和一个负样本,在 80% 的情况下,模型对正样本预测为正类的概率大于对负样本预测为正类的概率。\nAUC 对于均匀采样不敏感。如:上述反欺诈场景中,假设对正常用户进行均匀的降采样。任意给定一个负样本 n,设模型对其预测为正类的概率为 Pn 。降采样前后,由于是均匀采样,因此预测为正类的概率大于 Pn 和小于 Pn 的真正样本的比例没有发生变化。因此 AUC 保持不变。\n但是如果是非均匀的降采样,则预测为正类的概率大于 Pn 和小于 Pn 的真正样本的比例会发生变化,这也会导致 AUC 发生变化。\n正负样本之间的预测为正类概率之间的差距越大,则 AUC 越高。因为这表明正负样本之间排序的把握越大,区分度越高。\n如:在电商场景中,点击率模型的 AUC 要低于购买转化模型的 AUC 。因为点击行为的成本低于购买行为的成本,所以点击率模型中正负样本的差别要小于购买转化模型中正负样本的差别。\nAUC 的计算可以通过对 ROC 曲线下各部分的面积求和而得。假设 ROC 曲线是由坐标为下列这些点按顺序连接而成的:\n那么 AUC 可以这样估算:\n1.4 代价矩阵 前面介绍的性能指标都有一个隐式的前提,错误都是均等代价。但实际应用过程中,不同类型的错误所造成的后果是不同的。比如将健康人判断为患者,与患者被判断为健康人,代价肯定是不一样的,前者可能就是需要再次进行检查,而后者可能错过治疗的最佳时机。\n因此,为了衡量不同类型所造成的不同损失,可以为错误赋予非均等代价(unequal cost)。\n对于一个二类分类问题,可以设定一个代价矩阵(cost matrix),其中 表示将第 i 类样本预测为第 j 类样本的代价,而预测正确的代价是 0 。如下表所示:\n预测:第 0 类 预测:第 1 类 真实:第 0 类 0 真实: 第 1 类 0 在非均等代价下,希望找到的不再是简单地最小化错误率的模型,而是希望找到最小化总体代价 total cost 的模型。\n在非均等代价下,ROC 曲线不能直接反映出分类器的期望总体代价,此时需要使用代价曲线 cost curve\n代价曲线的横轴是正例概率代价,如下所示,其中 p 是正例(第 0 类)的概率 代价曲线的纵轴是归一化代价,如下所示:\n其中,假正例率 FPR 表示模型将负样本预测为正类的概率,定义如下:\n假负例率 FNR 表示将正样本预测为负类的概率,定义如下:\n代价曲线如下图所示:\n1.5 回归问题的性能度量 对于回归问题,常用的性能度量标准有:\n1.均方误差(Mean Square Error, MSE),定义如下:\n2.均方根误差(Root Mean Squared Error, RMSE),定义如下:\n3.均方根对数误差(Root Mean Squared Logarithmic Error, RMSLE),定义如下\n4.平均绝对误差(Mean Absolute Error, MAE),定义如下:\n这四个标准中,比较常用的第一个和第二个,即 MSE 和 RMSE,这两个标准一般都可以很好反映回归模型预测值和真实值的偏离程度,但如果遇到个别偏离程度非常大的离群点时,即便数量很少,也会让这两个指标变得很差。\n遇到这种情况,有三种解决思路:\n将离群点作为噪声点来处理,即数据预处理部分需要过滤掉这些噪声点; 从模型性能入手,提高模型的预测能力,将这些离群点产生的机制建模到模型中,但这个方法会比较困难; 采用其他指标,比如第三个指标 RMSLE,它关注的是预测误差的比例,即便存在离群点,也可以降低这些离群点的影响;或者是 MAPE,平均绝对百分比误差(Mean Absolute Percent Error),定义为: RMSE 的简单代码实现如下所示:\n1 2 3 4 5 6 7 8 def rmse(predictions, targets): # 真实值和预测值的误差 differences = predictions - targets differences_squared = differences ** 2 mean_of_differences_squared = differences_squared.mean() # 取平方根 rmse_val = np.sqrt(mean_of_differences_squared) return rmse_val 1.6 其他评价指标 计算速度:模型训练和预测需要的时间; 鲁棒性:处理缺失值和异常值的能力; 可拓展性:处理大数据集的能力; 可解释性:模型预测标准的可理解性,比如决策树产生的规则就很容易理解,而神经网络被称为黑盒子的原因就是它的大量参数并不好理解。 ","permalink":"https://reid00.github.io/en/posts/ml/%E5%A6%82%E4%BD%95%E8%AF%84%E4%BB%B7%E6%A8%A1%E5%9E%8B%E5%A5%BD%E5%9D%8F/","summary":"Summary “所有模型都是坏的,但有些模型是有用的”。我们建立模型之后,接下来就要去评估模型,确定这个模型是否‘有用’。当你费尽全力去建立完模型后,你","title":"如何评价模型好坏"},{"content":"简介 常用的Normalization方法主要有:Batch Normalization(BN,2015年)、Layer Normalization(LN,2016年)、Instance Normalization(IN,2017年)、Group Normalization(GN,2018年)。它们都是从激活函数的输入来考虑、做文章的,以不同的方式对激活函数的输入进行 Norm 的。\n我们将输入的 feature map shape 记为**[N, C, H, W]**,其中N表示batch size,即N个样本;C表示通道数;H、W分别表示特征图的高度、宽度。这几个方法主要的区别就是在:\nBN是在batch上,对N、H、W做归一化,而保留通道 C 的维度。BN对较小的batch size效果不好。BN适用于固定深度的前向神经网络,如CNN,不适用于RNN;\nLN在通道方向上,对C、H、W归一化,主要对RNN效果明显;\nIN在图像像素上,对H、W做归一化,用在风格化迁移;\nGN将channel分组,然后再做归一化。\n每个子图表示一个特征图,其中N为批量,C为通道,(H,W)为特征图的高度和宽度。通过蓝色部分的值来计算均值和方差,从而进行归一化。\n如果把特征图比喻成一摞书,这摞书总共有 N 本,每本有 C 页,每页有 H 行,每行 有W 个字符。\nBN 求均值时,相当于把这些书按页码一一对应地加起来(例如第1本书第36页,第2本书第36页\u0026hellip;\u0026hellip;),再除以每个页码下的字符总数:N×H×W,因此可以把 BN 看成求“平均书”的操作(注意这个“平均书”每页只有一个字),求标准差时也是同理。\nLN 求均值时,相当于把每一本书的所有字加起来,再除以这本书的字符总数:C×H×W,即求整本书的“平均字”,求标准差时也是同理。\nIN 求均值时,相当于把一页书中所有字加起来,再除以该页的总字数:H×W,即求每页书的“平均字”,求标准差时也是同理。\nGN 相当于把一本 C 页的书平均分成 G 份,每份成为有 C/G 页的小册子,求每个小册子的“平均字”和字的“标准差”。\n参考:\nhttps://mp.weixin.qq.com/s/dDMPBYjPeilivSA8J8W7lA https://zhuanlan.zhihu.com/p/72589565 ","permalink":"https://reid00.github.io/en/posts/ml/%E5%B8%B8%E7%94%A8normalization%E6%96%B9%E6%B3%95%E7%9A%84%E6%80%BB%E7%BB%93%E4%B8%8E%E6%80%9D%E8%80%83/","summary":"简介 常用的Normalization方法主要有:Batch Normalization(BN,2015年)、Layer Normalizatio","title":"常用Normalization方法的总结与思考"},{"content":"1. SVM SVM的应用 SVM在很多诸如文本分类,图像分类,生物序列分析和生物数据挖掘,手写字符识别等领域有很多的应用,但或许你并没强烈的意识到,SVM可以成功应用的领域远远超出现在已经在开发应用了的领域。\n通常人们会从一些常用的核函数中选择(根据问题和数据的不同,选择不同的参数,实际上就是得到了不同的核函数),例如:多项式核、高斯核、线性核。\nSVM是一种二类分类模型。它的基本模型是在特征空间中寻找间隔最大化的分离超平面的线性分类器。(间隔最大是它有别于感知机)\n(1)当训练样本线性可分时,通过硬间隔最大化,学习一个线性分类器,即线性可分支持向量机;\n(2)当训练数据近似线性可分时,引入松弛变量,通过软间隔最大化,学习一个线性分类器,即线性支持向量机;\n(3)当训练数据线性不可分时,通过使用核技巧及软间隔最大化,学习非线性支持向量机。\n注:以上各SVM的数学推导应该熟悉:硬间隔最大化(几何间隔)\u0026mdash;学习的对偶问题\u0026mdash;软间隔最大化(引入松弛变量)\u0026mdash;非线性支持向量机(核技巧)。\n读者可能还是没明白核函数到底是个什么东西?我再简要概括下,即以下三点:\n实际中,我们会经常遇到线性不可分的样例,此时,我们的常用做法是把样例特征映射到高维空间中去(映射到高维空间后,相关特征便被分开了,也就达到了分类的目的); 但进一步,如果凡是遇到线性不可分的样例,一律映射到高维空间,那么这个维度大小是会高到可怕的。那咋办呢? 此时,核函数就隆重登场了,核函数的价值在于它虽然也是将特征进行从低维到高维的转换,但核函数绝就绝在它事先在低维上进行计算,而将实质上的分类效果表现在了高维上,避免了直接在高维空间中的复杂计算 2. SVM的一些问题 SVM为什么采用间隔最大化? 当训练数据线性可分时,存在无穷个分离超平面可以将两类数据正确分开。\n感知机利用误分类最小策略,求得分离超平面,不过此时的解有无穷多个。\n线性可分支持向量机利用间隔最大化求得最优分离超平面,这时,解是唯一的。另一方面,此时的分隔超平面所产生的分类结果是最鲁棒的,对未知实例的泛化能力最强。\n然后应该借此阐述,几何间隔,函数间隔,及从函数间隔—\u0026gt;求解最小化1/2 ||w||^2 时的w和b。即线性可分支持向量机学习算法—最大间隔法的由来。\nSVM如何处理多分类问题?** 一般有两种做法:一种是直接法,直接在目标函数上修改,将多个分类面的参数求解合并到一个最优化问题里面。看似简单但是计算量却非常的大。\n另外一种做法是间接法:对训练器进行组合。其中比较典型的有一对一,和一对多。\n一对多,就是对每个类都训练出一个分类器,由svm是二分类,所以将此而分类器的两类设定为目标类为一类,其余类为另外一类。这样针对k个类可以训练出k个分类器,当有一个新的样本来的时候,用这k个分类器来测试,那个分类器的概率高,那么这个样本就属于哪一类。这种方法效果不太好,bias比较高。\nsvm一对一法(one-vs-one),针对任意两个类训练出一个分类器,如果有k类,一共训练出C(2,k) 个分类器,这样当有一个新的样本要来的时候,用这C(2,k) 个分类器来测试,每当被判定属于某一类的时候,该类就加一,最后票数最多的类别被认定为该样本的类。\n是否存在一组参数使SVM训练误差为0? Y\n训练误差为0的SVM分类器一定存在吗? 一定存在\n加入松弛变量的SVM的训练误差可以为0吗? 如果数据中出现了离群点outliers,那么就可以使用松弛变量来解决。\n使用SMO算法训练的线性分类器并不一定能得到训练误差为0的模型。这是由 于我们的优化目标改变了,并不再是使训练误差最小。\n带核的SVM为什么能分类非线性问题? 核函数的本质是两个函数的內积,通过核函数将其隐射到高维空间,在高维空间非线性问题转化为线性问题, SVM得到超平面是高维空间的线性分类平面。其分类结果也视为低维空间的非线性分类结果, 因而带核的SVM就能分类非线性问题。\n如何选择核函数? 如果特征的数量大到和样本数量差不多,则选用LR或者线性核的SVM; 如果特征的数量小,样本的数量正常,则选用SVM+高斯核函数; 如果特征的数量小,而样本的数量很大,则需要手工添加一些特征从而变成第一种情况。 3. LR和SVM的联系与区别 相同点 都是线性分类器。本质上都是求一个最佳分类超平面。\n都是监督学习算法\n都是判别模型。判别模型不关心数据是怎么生成的,它只关心信号之间的差别,然后用差别来简单对给定的一个信号进行分类。常见的判别模型有:KNN、SVM、LR,常见的生成模型有:朴素贝叶斯,隐马尔可夫模型。\n不同点 LR是参数模型,svm是非参数模型,linear和rbf则是针对数据线性可分和不可分的区别\n从目标函数来看,区别在于逻辑回归采用的是logistical loss,SVM采用的是hinge loss,这两个损失函数的目的都是增加对分类影响较大的数据点的权重,减少与分类关系较小的数据点的权重。\nSVM的处理方法是只考虑support vectors,也就是和分类最相关的少数点,去学习分类器。而逻辑回归通过非线性映射,大大减小了离分类平面较远的点的权重,相对提升了与分类最相关的数据点的权重。\n逻辑回归相对来说模型更简单,好理解,特别是大规模线性分类时比较方便。而SVM的理解和优化相对来说复杂一些,SVM转化为对偶问题后,分类只需要计算与少数几个支持向量的距离,这个在进行复杂核函数计算时优势很明显,能够大大简化模型和计算。\nlogic 能做的 svm能做,但可能在准确率上有问题,svm能做的logic有的做不了。\n4. 线性分类器与非线性分类器的区别以及优劣 线性和非线性是针对模型参数和输入特征来讲的;比如输入x,模型y=ax+ax^2 那么就是非线性模型,如果输入是x和X^2则模型是线性的。\n线性分类器可解释性好,计算复杂度较低,不足之处是模型的拟合效果相对弱些。\nLR,贝叶斯分类,单层感知机、线性回归\n非线性分类器效果拟合能力较强,不足之处是数据量不足容易过拟合、计算复杂度高、可解释性不好。\n决策树、RF、GBDT、多层感知机\nSVM两种都有(看线性核还是高斯核 即RBF )\n线性核:主要用于线性可分的情形,参数少,速度快,对于一般数据,分类效果已经很理想了。\nRBF 核:主要用于线性不可分的情形,参数多,分类结果非常依赖于参数。有很多人是通过训练数据的交叉验证来寻找合适的参数,不过这个过程比较耗时。 如果 Feature 的数量很大,跟样本数量差不多,这时候选用线性核的 SVM。 如果 Feature 的数量比较小,样本数量一般,不算大也不算小,选用高斯核的 SVM。\n*为什么要转为对偶问题?(阿里面试)*\n(a) 目前处理的模型严重依赖于数据集的维度d,如果维度d太高就会严重提升运算时间;\n(b) 对偶问题事实上把SVM从依赖d个维度转变到依赖N个数据点,考虑到在最后计算时只有支持向量才有意义,所以这个计算量实际上比N小很多。\n一、是对偶问题往往更易求解(当我们寻找约束存在时的最优点的时候,约束的存在虽然减小了需要搜寻的范围,但是却使问题变得更加复杂。为了使问题变得易于处理,我们的方法是把目标函数和约束全部融入一个新的函数,即拉格朗日函数,再通过这个函数来寻找最优点。)\n二、自然引入核函数,进而推广到非线性分类问题\n参考: https://cloud.tencent.com/developer/article/1541701\n","permalink":"https://reid00.github.io/en/posts/ml/svm/","summary":"1. SVM SVM的应用 SVM在很多诸如文本分类,图像分类,生物序列分析和生物数据挖掘,手写字符识别等领域有很多的应用,但或许你并没强烈的意识到,S","title":"SVM"},{"content":"Word2vec 介绍 Word2Vec是google在2013年推出的一个NLP工具,它的特点是能够将单词转化为向量来表示。首先,word2vec可以在百万数量级的词典和上亿的数据集上进行高效地训练;其次,该工具得到的训练结果——词向量(word embedding),可以很好地度量词与词之间的相似性。随着深度学习(Deep Learning)在自然语言处理中应用的普及,很多人误以为word2vec是一种深度学习算法。其实word2vec算法的背后是一个浅层神经网络(有一个隐含层的神经元网络)。另外需要强调的一点是,word2vec是一个计算word vector的开源工具。当我们在说word2vec算法或模型的时候,其实指的是其背后用于计算word vector的CBOW模型和Skip-gram模型。很多人以为word2vec指的是一个算法或模型,这也是一种谬误。\n用词向量来表示词并不是Word2Vec的首创,在很久之前就出现了。最早的词向量采用One-Hot编码,又称为一位有效编码,每个词向量维度大小为整个词汇表的大小,对于每个具体的词汇表中的词,将对应的位置置为1。转化为N维向量。\n采用One-Hot编码方式来表示词向量非常简单,但缺点也是显而易见的,一方面我们实际使用的词汇表很大,经常是百万级以上,这么高维的数据处理起来会消耗大量的计算资源与时间。另一方面,One-Hot编码中所有词向量之间彼此正交,没有体现词与词之间的相似关系。\nWord2vec 是 Word Embedding 方式之一,属于 NLP 领域。他是将词转化为「可计算」「结构化」的向量的过程。本文将讲解 Word2vec 的原理和优缺点。\n什么是 Word2vec ? 什么是 Word Embedding ? 在说明 Word2vec 之前,需要先解释一下 Word Embedding。 它就是将「不可计算」「非结构化」的词转化为「可计算」「结构化」的向量。\n这一步解决的是”将现实问题转化为数学问题“,是人工智能非常关键的一步。 将现实问题转化为数学问题只是第一步,后面还需要求解这个数学问题。所以 Word Embedding 的模型本身并不重要,重要的是生成出来的结果——词向量。因为在后续的任务中会直接用到这个词向量。\n什么是 Word2vec ? Word2vec 是 Word Embedding 的方法之一。他是 2013 年由谷歌的 Mikolov 提出了一套新的词嵌入方法。\nWord2vec 在整个 NLP 里的位置可以用下图表示: Word2vec 的 2 种训练模式 CBOW(Continuous Bag-of-Words Model)和Skip-gram (Continuous Skip-gram Model),是Word2vec 的两种训练模式。CBOW适合于数据集较小的情况,而Skip-Gram在大型语料中表现更好。下面简单做一下解释:\n词向量训练的预处理步骤:\n1. 对输入的文本生成一个词汇表,每个词统计词频,按照词频从高到低排序,取最频繁的V个词,构成一个词汇表。每个词存在一个one-hot向量,向量的维度是V,如果该词在词汇表中出现过,则向量中词汇表中对应的位置为1,其他位置全为0。如果词汇表中不出现,则向量为全0 2. 将输入文本的每个词都生成一个one-hot向量,此处注意保留每个词的原始位置,因为是上下文相关的 3. 确定词向量的维数N CBOW 通过上下文来预测当前值。相当于一句话中扣掉一个词,让你猜这个词是什么。 CBOW的处理步骤:\n确定窗口大小window,对每个词生成2*window个训练样本,(i-window, i),(i-window+1, i),\u0026hellip;,(i+window-1, i),(i+window, i) 确定batch_size,注意batch_size的大小必须是2*window的整数倍,这确保每个batch包含了一个词汇对应的所有样本 训练算法有两种:层次 Softmax 和 Negative Sampling 神经网络迭代训练一定次数,得到输入层到隐藏层的参数矩阵,矩阵中每一行的转置即是对应词的词向量 Skip-gram 用当前词来预测上下文。相当于给你一个词,让你猜前面和后面可能出现什么词。 Skip-gram处理步骤:\n确定窗口大小window,对每个词生成2*window个训练样本,(i, i-window),(i, i-window+1),\u0026hellip;,(i, i+window-1),(i, i+window) 确定batch_size,注意batch_size的大小必须是2*window的整数倍,这确保每个batch包含了一个词汇对应的所有样本 训练算法有两种:层次 Softmax 和 Negative Sampling 神经网络迭代训练一定次数,得到输入层到隐藏层的参数矩阵,矩阵中每一行的转置即是对应词的词向量 我们先来看个最简单的例子。上面说到, y 是 x 的上下文,所以 y 只取上下文里一个词语的时候,语言模型就变成: 用当前词 x 预测它的下一个词 y 但如上面所说,一般的数学模型只接受数值型输入,这里的 x 该怎么表示呢? 显然不能用 Word2vec,因为这是我们训练完模型的产物,现在我们想要的是 x 的一个原始输入形式。\n答案是:one-hot encoder\n所谓 one-hot encoder,其思想跟特征工程里处理类别变量的 one-hot 一样。本质上是用一个只含一个 1、其他都是 0 的向量来唯一表示词语。\n我举个例子,假设全世界所有的词语总共有 V 个,这 V 个词语有自己的先后顺序,假设『吴彦祖』这个词是第1个词,『我』这个单词是第2个词,那么『吴彦祖』就可以表示为一个 V 维全零向量、把第1个位置的0变成1,而『我』同样表示为 V 维全零向量、把第2个位置的0变成1。这样,每个词语都可以找到属于自己的唯一表示。\nOK,那我们接下来就可以看看 Skip-gram 的网络结构了,x 就是上面提到的 one-hot encoder 形式的输入,y 是在这 V 个词上输出的概率,我们希望跟真实的 y 的 one-hot encoder 一样。 首先说明一点:隐层的激活函数其实是线性的,相当于没做任何处理(这也是 Word2vec 简化之前语言模型的独到之处),我们要训练这个神经网络,用反向传播算法,本质上是链式求导,在此不展开说明了,\n首先说明一点:隐层的激活函数其实是线性的,相当于没做任何处理(这也是 Word2vec 简化之前语言模型的独到之处),我们要训练这个神经网络,用反向传播算法,本质上是链式求导,在此不展开说明了,\n当模型训练完后,最后得到的其实是神经网络的权重,比如现在输入一个 x 的 one-hot encoder: [1,0,0,…,0],对应刚说的那个词语『吴彦祖』,则在输入层到隐含层的权重里,只有对应 1 这个位置的权重被激活,这些权重的个数,跟隐含层节点数是一致的,从而这些权重组成一个向量 vx 来表示x,而因为每个词语的 one-hot encoder 里面 1 的位置是不同的,所以,这个向量 vx 就可以用来唯一表示 x。\n所以 Word2vec 本质上是一种降维操作——把词语从 one-hot encoder 形式的表示降维到 Word2vec 形式的表示。\n隐层细节 假如词汇表长度为10000,首先使用one-hot形式表示每一个单词,经过隐层300个神经元计算,最后使用Softmax层对单词概率输出。每一对单词组,前者作为x输入,后者作为y标签。\n假如我们想要学习的词向量维度为300,则需要将隐层的神经元个数设置为300(300是Google在其发布的训练模型中使用的维度,可调)。\n隐层的权重矩阵就是词向量,我们模型学习到的就是隐层的权重矩阵。 之所以这样,来看一下one-hot输入后与隐层的计算就明白了。 当使用One-hot去乘以矩阵的时候,会将某一行选择出来,即查表操作,所以权重矩阵是所有词向量组成的列表。\nCBOW 详解: CBOW 是 Continuous Bag-of-Words 的缩写,与神经网络语言模型不同的是,CBOW去掉了最耗时的非线性隐藏层\n从图中可以看出,CBOW模型预测的是 ,由于图中目标词 前后只取了各两个词,所以窗口的总大小是2。假设目标词 前后各取k个词,即窗口的大小是k,那么CBOW模型预测的将是 输入层到隐藏层\n以图2为例,输入层是四个词的one-hot向量表示,分别为 (维度都为V x 1,V是模型的训练本文中所有词的个数),记输入层到隐藏层的权重矩阵为 (维度为V x d,d是认为给定的词向量维度),隐藏层的向量为 (维度为d x 1),那么\n其实这里就是一个简单地求和平均。\n隐藏层到输出层\n记隐藏层到输出层的权重矩阵为 (维度为d x V),输出层的向量为 (维度为V x 1),那么\n注意,输出层的向量 与输入层的向量为 虽然维度是一样的,但是 并不是one-hot向量,并且向量 中的每个元素都是有意义的。例如,我们假设训练样本只有一句话“I like to eat apple”,此刻我们正在使用 I、like、eat、apple 四个词来预测 to ,输出层的结果如图3所示。\n图3 向量y的例子\n向量y中的每个元素表示我用 I、like、eat、apple 四个词预测出来的词是当元素对应的词的概率,比如是like的概率为0.05,是to的概率是0.80。由于我们想让模型预测出来的词是to,那么我们就要尽量让to的概率尽可能的大,所以我们目标是最大化函数 有了最大化的目标函数,我们接下来要做的就是求解这个目标函数,首先求 ,然后求梯度,再梯度下降,具体细节在此省略,因为这种方法涉及到softmax层,softmax每次计算都要遍历整个词表,代价十分昂贵,所以实现的时候我们不用这种方法,次softmax或者负采样来替换掉输出层,降低复杂度。\n优化方法 为了提高速度,Word2vec 经常采用 2 种加速方式:\nNegative Sample(负采样)\n本质是预测总体类别的一个子集 Hierarchical Softmax (层次Softmax, huffman树)\n本质是把 N 分类问题变成 log(N)次二分类 Word2vec 的优缺点 需要说明的是:Word2vec 是上一代的产物(18 年之前), 18 年之后想要得到最好的效果,已经不使用 Word Embedding 的方法了,所以也不会用到 Word2vec。\n优点: 由于 Word2vec 会考虑上下文,跟之前的 Embedding 方法相比,效果要更好(但不如 18 年之后的方法) 比之前的 Embedding方 法维度更少,所以速度更快 通用性很强,可以用在各种 NLP 任务中 缺点: 由于词和向量是一对一的关系,所以多义词的问题无法解决。 Word2vec 是一种静态的方式,虽然通用性强,但是无法针对特定任务做动态优化 问题 假如使用词向量维度为300,词汇量为10000个单词,那么神经网络输入层与隐层,隐层与输出层的参数量会达到惊人的300x10000=300万!训练如词庞大的神经网络需要庞大的数据量,还要避免过拟合。因此,Google在其第二篇论文中说明了训练的trick,其创新点如下:\n将常用词对或短语视为模型中的单个”word”。 对频繁的词进行子采样以减少训练样例的数量。 在损失函数中使用”负采样(Negative Sampling)”的技术,使每个训练样本仅更新模型权重的一小部分。 子采样和负采样技术不仅降低了计算量,还提升了词向量的效果。\n对频繁词子采样 在以上例子中,可以看到频繁单词’the’的两个问题:\n对于单词对(‘fox’,’the’),其对单词’fox’的语义表达并没有什么有效帮助,’the’在每个单词的上下文中出现都非常频繁。 预料中有很多单词对(‘the’,…),我们应更好的学习单词’the’ Word2vec使用子采样技术来解决以上问题,根据单词的频次来削减该单词的采样率。以window size为10为例子,我们删除’the’:\n当我们训练其余单词时候,’the’不会出现在他们的上下文中。 当中心词为’the’时,训练样本数量少于10。 负采样(Negative Sampling) 训练一个网络是说,计算训练样本然后轻微调整所有的神经元权重来提高准确率。换句话说,每一个训练样本都需要更新所有神经网络的权重。\n就像如上所说,当词汇表特别大的时候,如此多的神经网络参数在如此大的数据量下,每次都要进行权重更新,负担很大。\n在每个样本训练时,只修改部分的网络参数,负采样是通过这种方式来解决这个问题的。\n当我们的神经网络训练到单词组(‘fox’, ‘quick’)时候,得到的输出或label都是一个one-hot向量,也就是说,在表示’quick’的位置数值为1,其它全为0。\n负采样是随机选择较小数量的’负(Negative)’单词(比如5个),来做参数更新。这里的’负’表示的是网络输出向量种位置为0表示的单词。当然,’正(Positive)’(即正确单词’quick’)权重也会更新。\n论文中表述,小数量级上采用5-20,大数据集使用2-5个单词。\n我们的模型权重矩阵为300x10000,更新的单词为5个’负’词和一个’正’词,共计1800个参数,这是输出层全部3M参数的0.06%!!\n负采样的选取是和频次相关的,频次越高,负采样的概率越大: $$P(w_i) = \\frac{f(w_i)^{3/4}}{\\sum_{j=0}^n(f(w_j)^{3/4})}$$ 论文选择0.75作为指数是因为实验效果好。C语言实现的代码很有意思:首先用索引值填充多次填充词汇表中的每个单词,单词索引出现的次数为$P(w_i) * \\text{table_size}$。然后负采样只需要生成一个1到100M的整数,并用于索引表中数据。由于概率高的单词在表中出现的次数多,很可能会选择这些词。\nGloVe 模型 模型目标:进行词的向量化表示,使得向量之间尽可能多地蕴含语义和语法的信息。\n输入:语料库\n输出:词向量\n方法概述:首先基于语料库构建词的共现矩阵,然后基于共现矩阵和GloVe模型学习词向量。\nGlobal Vector融合了矩阵分解的全局统计信息和上下文信息\n常见的问题 1、文本表示哪些方法? 基于 one-hot、tf-idf、textrank 等的 bag-of-words; 主题模型:LSA(SVD)、pLSA、LDA; 基于词向量的固定表征:Word2vec、FastText、GloVe 基于词向量的动态表征:ELMo、GPT、BERT 2、怎么从语言模型理解词向量?怎么理解分布式假设? 上面给出的 4 个类型也是 nlp 领域最为常用的文本表示了,文本是由每个单词构成的,而谈起词向量,one-hot 是可认为是最为简单的词向量,但存在维度灾难和语义鸿沟等问题;通过构建共现矩阵并利用 SVD 求解构建词向量,则计算复杂度高;而早期词向量的研究通常来源于语言模型,比如 NNLM 和 RNNLM,其主要目的是语言模型,而词向量只是一个副产物。\n所谓分布式假设,用一句话可以表达:相同上下文语境的词有似含义。而由此引申出了 Word2vec、FastText,在此类词向量中,虽然其本质仍然是语言模型,但是它的目标并不是语言模型本身,而是词向量,其所作的一系列优化,都是为了更快更好的得到词向量。GloVe 则是基于全局语料库、并结合上下文语境构建词向量,结合了 LSA 和 Word2vec 的优点。\n3、传统的词向量有什么问题?怎么解决?各种词向量的特点是什么? 上述方法得到的词向量是固定表征的,无法解决一词多义等问题,如“川普”。为此引入基于语言模型的动态表征方法:ELMo、GPT、BERT。\n各种词向量的特点:\nOne-hot 表示 :维度灾难、语义鸿沟; 分布式表示 (distributed representation) 矩阵分解(LSA):利用全局语料特征,但 SVD 求解计算复杂度大; 基于 NNLM/RNNLM 的词向量:词向量为副产物,存在效率不高等问题; Word2vec、FastText:优化效率高,但是基于局部语料; GloVe:基于全局预料,结合了 LSA 和 Word2vec 的优点; ELMo、GPT、BERT:动态特征; 4、Word2vec 和 NNLM 对比有什么区别?(Word2vecvs NNLM) 1)其本质都可以看作是语言模型;\n2)词向量只不过 NNLM 一个产物,Word2vec 虽然其本质也是语言模型,但是其专注于词向量本身,因此做了许多优化来提高计算效率:\n与 NNLM 相比,词向量直接 sum,不再拼接,并舍弃隐层; 考虑到 sofmax 归一化需要遍历整个词汇表,采用 hierarchical softmax 和 negative sampling 进行优化,hierarchical softmax 实质上生成一颗带权路径最小的哈夫曼树,让高频词搜索路劲变小;negative sampling 更为直接,实质上对每一个样本中每一个词都进行负例采样; 5、Word2vec 和 FastText 对比有什么区别?(Word2vec vs FastText) 1)都可以无监督学习词向量, FastText 训练词向量时会考虑 subword;\n2) FastText 还可以进行有监督学习进行文本分类,其主要特点:\n结构与 CBOW 类似,但学习目标是人工标注的分类结果; 采用 hierarchical softmax 对输出的分类标签建立哈夫曼树,样本中标签多的类别被分配短的搜寻路径 引入 N-gram,考虑词序特征 引入 subword 来处理长词,处理未登陆词问题; 6、GloVe 和 Word2vec、 LSA 对比有什么区别?(Word2vecvs GloVe vs LSA) 1)GloVe vs LSA\nLSA(Latent Semantic Analysis)可以基于 co-occurance matrix 构建词向量,实质上是基于全局语料采用 SVD 进行矩阵分解,然而 SVD 计算复杂度高;\nGloVe 可看作是对 LSA 一种优化的高效矩阵分解算法,采用 Adagrad 对最小平方损失进行优化;\n2)Word2vecvs GloVe\nWord2vec 是局部语料库训练的,其特征提取是基于滑窗的;而 GloVe 的滑窗是为了构建 co-occurance matrix,是基于全局语料的,可见 GloVe 需要事先统计共现概率;因此,Word2vec 可以进行在线学习,GloVe 则需要统计固定语料信息。\nWord2vec 是无监督学习,同样由于不需要人工标注;GloVe 通常被认为是无监督学习,但实际上 GloVe 还是有 label 的,即共现次数 [公式]。\nWord2vec 损失函数实质上是带权重的交叉熵,权重固定;GloVe 的损失函数是最小平方损失函数,权重可以做映射变换。\n总体来看,GloVe 可以被看作是更换了目标函数和权重函数的全局 Word2vec。\n7、 Word2vec 的两种优化方法是什么?它们的目标函数怎样确定的?训练过程又是怎样的? 不经过优化的 CBOW 和 Skip-gram 中 , 在每个样本中每个词的训练过程都要遍历整个词汇表,也就是都需要经过 softmax 归一化,计算误差向量和梯度以更新两个词向量矩阵(这两个词向量矩阵实际上就是最终的词向量,可认为初始化不一样),当语料库规模变大、词汇表增长时,训练变得不切实际。为了解决这个问题,Word2vec 支持两种优化方法:hierarchical softmax 和 negative sampling。\n(1)基于 hierarchical softmax 的 CBOW 和 Skip-gram\nhierarchical softmax 使用一颗二叉树表示词汇表中的单词,每个单词都作为二叉树的叶子节点。对于一个大小为 V 的词汇表,其对应的二叉树包含 V-1 非叶子节点。假如每个非叶子节点向左转标记为 1,向右转标记为 0,那么每个单词都具有唯一的从根节点到达该叶子节点的由{0 1}组成的代号(实际上为哈夫曼编码,为哈夫曼树,是带权路径长度最短的树,哈夫曼树保证了词频高的单词的路径短,词频相对低的单词的路径长,这种编码方式很大程度减少了计算量)。\n(2)基于 negative sampling 的 CBOW 和 Skip-gram\nnegative sampling 是一种不同于 hierarchical softmax 的优化策略,相比于 hierarchical softmax,negative sampling 的想法更直接——为每个训练实例都提供负例。\n负采样算法实际上就是一个带权采样过程,负例的选择机制是和单词词频联系起来的。\n具体做法是以 N+1 个点对区间 [0,1] 做非等距切分,并引入的一个在区间 [0,1] 上的 M 等距切分,其中 M \u0026raquo; N。源码中取 M = 10^8。然后对两个切分做投影,得到映射关系:采样时,每次生成一个 [1, M-1] 之间的整数 i,则 Table(i) 就对应一个样本;当采样到正例时,跳过(拒绝采样)。\n参考: https://zhuanlan.zhihu.com/p/44599645\n","permalink":"https://reid00.github.io/en/posts/ml/word2vec/","summary":"Word2vec 介绍 Word2Vec是google在2013年推出的一个NLP工具,它的特点是能够将单词转化为向量来表示。首先,word2vec可以在百万","title":"Word2vec"},{"content":"决策树 决策树(decision tree)是一个树结构(可以是二叉树或非二叉树)。其每个非叶节点表示一个特征属性上的测试,每个分支代表这个特征属性在某个值域上的输出,而每个叶节点存放一个类别。使用决策树进行决策的过程就是从根节点开始,测试待分类项中相应的特征属性,并按照其值选择输出分支,直到到达叶子节点,将叶子节点存放的类别作为决策结果[1]。 下面先来看一个小例子,看看决策树到底是什么概念(这个例子来源于[2])。\n决策树的训练数据往往就是这样的表格形式,表中的前三列(ID不算)是数据样本的属性,最后一列是决策树需要做的分类结果。通过该数据,构建的决策树如下:\n有了这棵树,我们就可以对新来的用户数据进行是否可以偿还的预测了。\n决策树最重要的是决策树的构造。所谓决策树的构造就是进行属性选择度量确定各个特征属性之间的拓扑结构。构造决策树的关键步骤是分裂属性。所谓分裂属性就是在某个节点处按照某一特征属性的不同划分构造不同的分支,其目标是让各个分裂子集尽可能地“纯”。尽可能“纯”就是尽量让一个分裂子集中待分类项属于同一类别。分裂属性分为三种不同的情况[1]: 1、属性是离散值且不要求生成二叉决策树。此时用属性的每一个划分作为一个分支。 2、属性是离散值且要求生成二叉决策树。此时使用属性划分的一个子集进行测试,按照“属于此子集”和“不属于此子集”分成两个分支。 3、属性是连续值。此时确定一个值作为分裂点split_point,按照\u0026gt;split_point和\u0026lt;=split_point生成两个分支。\n决策树的属性分裂选择是”贪心“算法,也就是没有回溯的。\nID3.5 好了,接下来说一下教科书上提到最多的决策树ID3.5算法(是最基本的模型,简单实用,但是在某些场合下也有缺陷)。\n信息论中有熵(entropy)的概念,表示状态的混乱程度,熵越大越混乱。熵的变化可以看做是信息增益,决策树ID3算法的核心思想是以信息增益度量属性选择,选择分裂后信息增益最大的属性进行分裂。\n设D为用(输出)类别对训练元组进行的划分,则D的熵表示为: info(D)=−∑i=1mpilog2(pi)info(D)=−∑i=1mpilog2⁡(pi)\n其中pipi表示第i个类别在整个训练元组中出现的概率,一般来说会用这个类别的样本数量占总量的占比来作为概率的估计;熵的实际意义表示是D中元组的类标号所需要的平均信息量。熵的含义可以看我前面写的PRML ch1.6 信息论的介绍。 如果将训练元组D按属性A进行划分,则A对D划分的期望信息为: infoA(D)=∑j=1v|Dj||D|info(Dj) infoA(D)=∑j=1v|Dj||D|info(Dj) 于是,信息增益就是两者的差值: gain(A)=info(D)−infoA(D) gain(A)=info(D)−infoA(D) ID3决策树算法就用到上面的信息增益,在每次分裂的时候贪心选择信息增益最大的属性,作为本次分裂属性。每次分裂就会使得树长高一层。这样逐步生产下去,就一定可以构建一颗决策树。(基本原理就是这样,但是实际中,为了防止过拟合,以及可能遇到叶子节点类别不纯的情况,需要有一些特殊的trick,这些留到最后讲)\nOK,借鉴一下[1]中的一个小例子,来看一下信息增益的计算过程。\n这个例子是这样的:输入样本的属性有三个——日志密度(L),好友密度(F),以及是否使用真实头像(H);样本的标记是账号是否真实yes or no。\n然后可以一次计算每一个属性的信息增益,比如日致密度的信息增益是0.276。\n同理可得H和F的信息增益为0.033和0.553。因为F具有最大的信息增益,所以第一次分裂选择F为分裂属性,分裂后的结果如下图表示:\n上面为了简便,将特征属性离散化了,其实日志密度和好友密度都是连续的属性。对于特征属性为连续值,可以如此使用ID3算法:先将D中元素按照特征属性排序,则每两个相邻元素的中间点可以看做潜在分裂点,从第一个潜在分裂点开始,分裂D并计算两个集合的期望信息,具有最小期望信息的点称为这个属性的最佳分裂点,其信息期望作为此属性的信息期望。\nC4.5 ID3有一些缺陷,就是选择的时候容易选择一些比较容易分纯净的属性,尤其在具有像ID值这样的属性,因为每个ID都对应一个类别,所以分的很纯净,ID3比较倾向找到这样的属性做分裂。\nC4.5算法定义了分裂信息,表示为: split_infoA(D)=−∑j=1v|Dj||D|log2(|Dj||D|) split_infoA(D)=−∑j=1v|Dj||D|log2⁡(|Dj||D|) 很容易理解,这个也是一个熵的定义,pi=|Dj||D|pi=|Dj||D|,可以看做是属性分裂的熵,分的越多就越混乱,熵越大。定义信息增益率: gain_ratio(A)=gain(A)split_info(A) gain_ratio(A)=gain(A)split_info(A)\nC4.5就是选择最大增益率的属性来分裂,其他类似ID3.5。\nCART CART(Classification And Regression Tree)算法既可以用于创建分类树,也可以用于创建回归树。CART算法的重要特点包含以下三个方面:\n二分(Binary Split):在每次判断过程中,都是对样本数据进行二分。CART算法是一种二分递归分割技术,把当前样本划分为两个子样本,使得生成的每个非叶子结点都有两个分支,因此CART算法生成的决策树是结构简洁的二叉树。由于CART算法构成的是一个二叉树,它在每一步的决策时只能是“是”或者“否”,即使一个feature有多个取值,也是把数据分为两部分 单变量分割(Split Based on One Variable):每次最优划分都是针对单个变量。 剪枝策略:CART算法的关键点,也是整个Tree-Based算法的关键步骤。剪枝过程特别重要,所以在最优决策树生成过程中占有重要地位。有研究表明,剪枝过程的重要性要比树生成过程更为重要,对于不同的划分标准生成的最大树(Maximum Tree),在剪枝之后都能够保留最重要的属性划分,差别不大。反而是剪枝方法对于最优树的生成更为关键。 CART分类决策树 GINI指数 CART的分支标准建立在GINI指数这个概念上,GINI指数主要是度量数据划分的不纯度,是介于0~1之间的数。GINI值越小,表明样本集合的纯净度越高;GINI值越大表明样本集合的类别越杂乱\nCART分类时,使用基尼指数(Gini)来选择最好的数据分割的特征,gini描述的是纯度,与信息熵的含义相似。CART中每一次迭代都会降低GINI系数。最好的划分就是使得GINI_Gain最小的划分。\n停止条件 决策树的构建过程是一个递归的过程,所以需要确定停止条件,否则过程将不会结束。一种最直观的方式是当每个子节点只有一种类型的记录时停止,但是这样往往会使得树的节点过多,导致过拟合问题(Overfitting)。另一种可行的方法是当前节点中的记录数低于一个最小的阀值,那么就停止分割,将max(P(i))对应的分类作为当前叶节点的分类。\n过度拟合 采用上面算法生成的决策树在事件中往往会导致过度拟合。也就是该决策树对训练数据可以得到很低的错误率,但是运用到测试数据上却得到非常高的错误率。过渡拟合的原因有以下几点: •噪音数据:训练数据中存在噪音数据,决策树的某些节点有噪音数据作为分割标准,导致决策树无法代表真实数据。 •缺少代表性数据:训练数据没有包含所有具有代表性的数据,导致某一类数据无法很好的匹配,这一点可以通过观察混淆矩阵(Confusion Matrix)分析得出。 •多重比较(Mulitple Comparision):举个列子,股票分析师预测股票涨或跌。假设分析师都是靠随机猜测,也就是他们正确的概率是0.5。每一个人预测10次,那么预测正确的次数在8次或8次以上的概率为 ,C810∗(0.5)10+C910∗(0.5)10+C1010∗(0.5)10C108∗(0.5)10+C109∗(0.5)10+C1010∗(0.5)10只有5%左右,比较低。但是如果50个分析师,每个人预测10次,选择至少一个人得到8次或以上的人作为代表,那么概率为 1−(1−0.0547)50=0.93991−(1−0.0547)50=0.9399,概率十分大,随着分析师人数的增加,概率无限接近1。但是,选出来的分析师其实是打酱油的,他对未来的预测不能做任何保证。上面这个例子就是多重比较。这一情况和决策树选取分割点类似,需要在每个变量的每一个值中选取一个作为分割的代表,所以选出一个噪音分割标准的概率是很大的。\n优化方案1:修剪枝叶 决策树过渡拟合往往是因为太过“茂盛”,也就是节点过多,所以需要裁剪(Prune Tree)枝叶。裁剪枝叶的策略对决策树正确率的影响很大。主要有两种裁剪策略。\n前置裁剪 (PrePrune:预剪枝)在构建决策树的过程时,提前停止。那么,会将切分节点的条件设置的很苛刻,导致决策树很短小。结果就是决策树无法达到最优。实践证明这中策略无法得到较好的结果。\n后置裁剪(PostPrune:后剪枝) 决策树构建好后,然后才开始裁剪。采用两种方法:1)用单一叶节点代替整个子树,叶节点的分类采用子树中最主要的分类;2)将一个字数完全替代另外一颗子树。后置裁剪有个问题就是计算效率,有些节点计算后就被裁剪了,导致有点浪费。\n剪枝可以分为两种:预剪枝(Pre-Pruning)和后剪枝(Post-Pruning),下面我们来详细学习下这两种方法: PrePrune:预剪枝,及早的停止树增长,方法可以参考见上面树停止增长的方法。 PostPrune:后剪枝,在已生成过拟合决策树上进行剪枝,可以得到简化版的剪枝决策树。\n优化方案2:K-Fold Cross Validation 首先计算出整体的决策树T,叶节点个数记作N,设i属于[1,N]。对每个i,使用K-Fold Validataion方法计算决策树,并裁剪到i个节点,计算错误率,最后求出平均错误率。(意思是说对每一个可能的i,都做K次,然后取K次的平均错误率。)这样可以用具有最小错误率对应的i作为最终决策树的大小,对原始决策树进行裁剪,得到最优决策树。\n优化方案3:Random Forest Random Forest是用训练数据随机的计算出许多决策树,形成了一个森林。然后用这个森林对未知数据进行预测,选取投票最多的分类。实践证明,此算法的错误率得到了经一步的降低。这种方法背后的原理可以用“三个臭皮匠定一个诸葛亮”这句谚语来概括。一颗树预测正确的概率可能不高,但是集体预测正确的概率却很高。RF是非常常用的分类算法,效果一般都很好。\nOK,决策树就讲到这里,商用的决策树C5.0了解不是很多;还有分类回归树CART也很常用。\n","permalink":"https://reid00.github.io/en/posts/ml/%E5%86%B3%E7%AD%96%E6%A0%91/","summary":"决策树 决策树(decision tree)是一个树结构(可以是二叉树或非二叉树)。其每个非叶节点表示一个特征属性上的测试,每个分支代表这个特征","title":"决策树"},{"content":"Summary 简单的说,k-近邻算法采用测量不同特征值之间的距离方法进行分类。 它的思路是:如果一个样本在特征空间中的k个最相似(即特征空间中最邻近)的样本中的大多数属于某一个类别,则该样本也属于这个类别,其中K通常是不大于20的整数。KNN算法中,所选择的邻居都是已经正确分类的对象。该方法在定类决策上只依据最邻近的一个或者几个样本的类别来决定待分样本所属的类别。\n优点:精度高、对异常值不敏感、无数据输入假定。\n缺点:计算复杂度高、空间复杂度高。\n适用数据范围:数值型和标称型。\n详细介绍 下面通过一个简单的例子说明一下:如下图,绿色圆要被决定赋予哪个类,是红色三角形还是蓝色四方形?如果K=3,由于红色三角形所占比例为2/3,绿色圆将被赋予红色三角形那个类,如果K=5,由于蓝色四方形比例为3/5,因此绿色圆被赋予蓝色四方形类。\n由此也说明了KNN算法的结果很大程度取决于K的选择。\n在KNN中,通过计算对象间距离来作为各个对象之间的非相似性指标,避免了对象之间的匹配问题,在这里距离一般使用欧氏距离或曼哈顿距离:\n**接下来对KNN算法的思想总结一下:**就是在训练集中数据和标签已知的情况下,输入测试数据,将测试数据的特征与训练集中对应的特征进行相互比较,找到训练集中与之最为相似的前K个数据,则该测试数据对应的类别就是K个数据中出现次数最多的那个分类,其算法的描述为:\n1)计算测试数据与各个训练数据之间的距离;\n2)按照距离的递增关系进行排序;\n3)选取距离最小的K个点;\n4)确定前K个点所在类别的出现频率;\n5)返回前K个点中出现频率最高的类别作为测试数据的预测分类。\n常见问题 1. K值设定为多大? K太小,分类结果易受噪声点影响;k太大,近邻中又可能包含太多的其它类别的点。(对距离加权,可以降低k值设定的影响) k值通常是采用交叉检验来确定(以k=1为基准) 经验规则:k一般低于训练样本数的平方根\n2. 类别如何判定最合适? 投票法没有考虑近邻的距离的远近,距离更近的近邻也许更应该决定最终的分类,所以加权投票法更恰当一些。\n3. 如何选择合适的距离衡量? 高维度对距离衡量的影响:众所周知当变量数越多,欧式距离的区分能力就越差。 变量值域对距离的影响:值域越大的变量常常会在距离计算中占据主导作用,因此应先对变量进行标准化。\n4. 训练样本是否要一视同仁? 在训练集中,有些样本可能是更值得依赖的。 可以给不同的样本施加不同的权重,加强依赖样本的权重,降低不可信赖样本的影响。\n5. 性能问题? KNN是一种懒惰算法,平时不好好学习,考试(对测试样本分类)时才临阵磨枪(临时去找k个近邻)。 懒惰的后果:构造模型很简单,但在对测试样本分类地的系统开销大,因为要扫描全部训练样本并计算距离。 已经有一些方法提高计算的效率,例如压缩训练样本量等。\n6. 能否大幅减少训练样本量,同时又保持分类精度? 浓缩技术(condensing) 编辑技术(editing)\n算法实例 如scikit-learn中的KNN算法使用:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #coding:utf-8 from sklearn import datasets #sk-learn 内置数据库 import numpy as np \u0026#39;\u0026#39;\u0026#39;KNN算法\u0026#39;\u0026#39;\u0026#39; iris = datasets.load_iris() #内置的鸢尾花卉数据集 #数据集包含150个数据集,分为3类,每类50个数据, #可通过花萼长度,花萼宽度,花瓣长度,花瓣宽度4个特征预测鸢尾花卉属于 #(Setosa,Versicolour,Virginica)三个种类中的哪一类 iris_X,iris_y = iris.data,iris.target #数据集及其对应的分类标签 # 将数据集随机分为训练数据集和测试数据集 np.random.seed(0) indices = np.random.permutation(len(iris_X)) #用于训练模型 iris_X_train = iris_X[indices[:-10]] iris_y_train = iris_y[indices[:-10]] #用于测试模型 iris_X_test = iris_X[indices[-10:]] iris_y_test = iris_y[indices[-10:]] from sklearn.neighbors import KNeighborsClassifier knn = KNeighborsClassifier() knn.fit(iris_X_train,iris_y_train) prediction = knn.predict(iris_X_test) score = knn.score(iris_X_test,iris_y_test) print \u0026#39;真实分类标签:\u0026#39;+str(iris_y_test) print \u0026#39;模型分类结果:\u0026#39;+str(prediction)+\u0026#39;\\n算法准确度:\u0026#39;+str(score) 输出结果:\n1 2 3 真实分类标签:[1 1 1 0 0 0 2 1 2 0] 模型分类结果:[1 2 1 0 0 0 2 1 2 0] 算法准确度:0.9 ","permalink":"https://reid00.github.io/en/posts/ml/knn%E7%AE%97%E6%B3%95/","summary":"Summary 简单的说,k-近邻算法采用测量不同特征值之间的距离方法进行分类。 它的思路是:如果一个样本在特征空间中的k个最相似(即特征空间中最邻近)的样","title":"KNN算法"},{"content":"概念 L0:计算非零个数,用于产生稀疏性,但是在实际研究中很少用,因为L0范数很难优化求解,是一个NP-hard问题,因此更多情况下我们是使用L1范数 L1:计算绝对值之和,用以产生稀疏性,因为它是L0范式的一个最优凸近似,容易优化求解 L2:计算平方和再开根号,L2范数更多是防止过拟合,并且让优化求解变得稳定很快速(这是因为加入了L2范式之后,满足了强凸)。\nL1范数(Lasso Regularization):向量中各个元素绝对值的和。\nL2范数(Ridge Regression):向量中各元素平方和再求平方根。\n作用 L1正则化可以产生稀疏权值矩阵,即产生一个稀疏模型,可以用于特征选择\nL2正则化可以防止模型过拟合(overfitting);一定程度上,L1也可以防止过拟合\nL1正则化是在代价函数后面加上 L2正则化是在代价函数后面增加了 两者都起到一定的过拟合作用,两者都对应一定的先验知识,L1对应拉普拉斯分布,L2对应高斯分布,L1偏向于参数稀疏性,L2偏向于参数分布较为稠。\n","permalink":"https://reid00.github.io/en/posts/ml/l1l2%E6%AD%A3%E5%88%99/","summary":"概念 L0:计算非零个数,用于产生稀疏性,但是在实际研究中很少用,因为L0范数很难优化求解,是一个NP-hard问题,因此更多情况下我们是使用","title":"L1L2正则"},{"content":"Refer :https://blog.csdn.net/shenfuli/article/details/106523650\nMulti-Head Attention: https://blog.csdn.net/qq_37394634/article/details/102679096\n","permalink":"https://reid00.github.io/en/posts/ml/self-attention/","summary":"Refer :https://blog.csdn.net/shenfuli/article/details/106523650 Multi-Head Attention: https://blog.csdn.net/qq_37394634/article/details/102679096","title":"Self Attention"},{"content":"概述 GBDT的加入,是为了弥补LR难以实现特征组合的缺点。\nLR LR作为一个线性模型,以概率形式输出结果,在工业上得到了十分广泛的应用。 其具有简单快速高效,结果可解释,可以分布式计算。搭配L1,L2正则,可以有很好地鲁棒性以及挑选特征的能力。\n但由于其简单,也伴随着拟合能力不足,无法做特征组合的缺点。\n通过梯度下降法可以优化参数\n可以称之上是 CTR 预估模型的开山鼻祖,也是工业界使用最为广泛的 CTR 预估模型\n但是在CTR领域,单纯的LR虽然可以快速处理海量高维离散特征,但是由于线性模型的局限性,其在特征组合方面仍有不足,所以后续才发展出了FM来引入特征交叉。在此之前,业界也有使用GBDT来作为特征组合的工具,其结果输出给LR。\nLR 优缺点 优点:由于 LR 模型简单,训练时便于并行化,在预测时只需要对特征进行线性加权,所以性能比较好,往往适合处理海量 id 类特征,用 id 类特征有一个很重要的好处,就是防止信息损失(相对于范化的 CTR 特征),对于头部资源会有更细致的描述。\n缺点:LR 的缺点也很明显,首先对连续特征的处理需要先进行离散化,如上文所说,人工分桶的方式会引入多种问题。另外 LR 需要进行人工特征组合,这就需要开发者有非常丰富的领域经验,才能不走弯路。这样的模型迁移起来比较困难,换一个领域又需要重新进行大量的特征工程。\nGBDT+LR 首先,GBDT是一堆树的组合,假设有k棵树 。 对于第i棵树 ,其存在 个叶子节点。而从根节点到叶子节点,可以认为是一条路径,这条路径是一些特征的组合,例如从根节点到某一个叶子节点的路径可能是“ ”这就是一组特征组合。到达这个叶子节点的样本都拥有这样的组合特征,而这个组合特征使得这个样本得到了GBDT的预测结果。 所以对于GBDT子树 ,会返回一个 维的one-hot向量 对于整个GBDT,会返回一个 维的向量 ,这个向量由0-1组成。\n然后,这个 ,会作为输入,送进LR模型,最终输出结果\n模型大致如图所示。上图中由两棵子树,分别有3和2个叶子节点。对于一个样本x,最终可以落入第一棵树的某一个叶子和第二棵树的某一个叶子,得到两个独热编码的结果例如 [0,0,1],[1,0]组合得[0,0,1,1,0]输入到LR模型最后输出结果。\n由于LR善于处理离散特征,GBDT善于处理连续特征。所以也可以交由GBDT处理连续特征,输出结果拼接上离散特征一起输入LR。\n讨论 至于GBDT为何不善于处理高维离散特征?\nhttps://cloud.tencent.com/developer/article/1005416\n缺点:对于海量的 id 类特征,GBDT 由于树的深度和棵树限制(防止过拟合),不能有效的存储;另外海量特征在也会存在性能瓶颈,经笔者测试,当 GBDT 的 one hot 特征大于 10 万维时,就必须做分布式的训练才能保证不爆内存。所以 GBDT 通常配合少量的反馈 CTR 特征来表达,这样虽然具有一定的范化能力,但是同时会有信息损失,对于头部资源不能有效的表达。\nhttps://www.zhihu.com/question/35821566\n后来思考后发现原因是因为现在的模型普遍都会带着正则项,而 lr 等线性模型的正则项是对权重的惩罚,也就是 W1一旦过大,惩罚就会很大,进一步压缩 W1的值,使他不至于过大,而树模型则不一样,树模型的惩罚项通常为叶子节点数和深度等,而我们都知道,对于上面这种 case,树只需要一个节点就可以完美分割9990和10个样本,惩罚项极其之小. 这也就是为什么在高维稀疏特征的时候,线性模型会比非线性模型好的原因了:带正则化的线性模型比较不容易对稀疏特征过拟合。\nGBDT当树深度\u0026gt;2时,其实组合的是多元特征了,而且由于子树规模的限制,导致其特征组合的能力并不是很强,所以才有了后续FM,FFM的发展x\nGBDT + LR 改进 Facebook 的方案在实际使用中,发现并不可行,因为广告系统往往存在上亿维的 id 类特征(用户 guid10 亿维,广告 aid 上百万维),而 GBDT 由于树的深度和棵树的限制,无法存储这么多 id 类特征,导致信息的损失。有如下改进方案供读者参考:\n**方案一:**GBDT 训练除 id 类特征以外的所有特征,其他 id 类特征在 LR 阶段再加入。这样的好处很明显,既利用了 GBDT 对连续特征的自动离散化和特征组合,同时 LR 又有效利用了 id 类离散特征,防止信息损失。\n**方案二:**GBDT 分别训练 id 类树和非 id 类树,并把组合特征传入 LR 进行二次训练。对于 id 类树可以有效保留头部资源的信息不受损失;对于非 id 类树,长尾资源可以利用其范化信息(反馈 CTR 等)。但这样做有一个缺点是,介于头部资源和长尾资源中间的一部分资源,其有效信息即包含在范化信息(反馈 CTR) 中,又包含在 id 类特征中,而 GBDT 的非 id 类树只存的下头部的资源信息,所以还是会有部分信息损失。\n优缺点:\n优点:GBDT 可以自动进行特征组合和离散化,LR 可以有效利用海量 id 类离散特征,保持信息的完整性。\n缺点:LR 预测的时候需要等待 GBDT 的输出,一方面 GBDT在线预测慢于单 LR,另一方面 GBDT 目前不支持在线算法,只能以离线方式进行更新。\n","permalink":"https://reid00.github.io/en/posts/ml/gbdt+lr/","summary":"概述 GBDT的加入,是为了弥补LR难以实现特征组合的缺点。 LR LR作为一个线性模型,以概率形式输出结果,在工业上得到了十分广泛的应用。 其具有简","title":"GBDT+LR"},{"content":"一、概述 网络表示学习(Representation Learning on Network),一般说的就是向量化(Embedding)技术,简单来说,就是将网络中的结构(节点、边或者子图),通过一系列过程,变成一个多维向量,通过这样一层转化,能够将复杂的网络信息变成结构化的多维特征,从而利用机器学习方法实现更方便的算法应用\n主流的KG embedding的方法包括基于平移的模型(典型代表:TransE),基于矩阵分解的模型(典型代表:RESCAL),基于神经网络的模型(典型代表:NTN)和基于图神经网络的模型(典型代表:RGCN)。\n我们开始介绍知识表示学习的几个代表模型,包括:结构向量模型、语义匹配能量模型、隐变量模型、神经张量网络模型、矩阵分解模型和平移模型,等等。\n但是传统的KG embedding模型存在一些不足,例如大多数方法完全依赖于知识图谱中的三元组数据,知识图谱表示学习过程缺乏可解释性。针对完全依赖于三元组数据的问题,一类有效的方案是引入知识图谱图结构中存在的路径信息,经典的基于路径的KG embedding的方法是PTransE,对于由关系路径中的所有关系的向量表示,PTtransE通过求和、乘积和RNN三种策略进行路径的组合。然而,现有的基于路径的知识图谱表示学习模型的路径表示过程中完全基于数据驱动,缺乏可解释性。同时,PTransE,PathRNN等完全数据驱动的方法在表示路径的过程中会造成误差累积并进一步限制路径表示的精度。\n目前提到图算法一般指:\n经典数据结构与算法层面的:最小生成树(Prim,Kruskal,\u0026hellip;),最短路(Dijkstra,Floyed,\u0026hellip;),拓扑排序,关键路径等\n概率图模型,涉及图的表示,推断和学习,详细可以参考Koller的书或者公开课\n图神经网络,主要包括Graph Embedding(基于随机游走)和Graph CNN(基于邻居汇聚)两部分。\n二、Trans 系列 现在主要介绍知识表示学习的一个最简单也是最有效的方案,叫TransE。在这个模型中,每个实体和关系都表示成低维向量。那么如何怎么学习这些低维向量呢?我们需要设计一个学习目标,这个目标就是,给定任何一个三元组,我们都将中间的relation看成是从head到tail的一个翻译过程,也就是说把head的向量加上relation的向量,要让它尽可能地等于tail向量。在学习过程中,通过不断调整、更新实体和关系向量的取值,使这些等式尽可能实现。\n些实体和关系的表示可以用来做什么呢?一个直观的应用就是Entity Prediction(实体预测)。就是说,如果给一个head entity,再给一个relation,那么可以利用刚才学到的向量表示,去预测它的tail entity可能是什么。思想非常简单,直接把h r,然后去找跟h r向量最相近的tail向量就可以了。实际上,我们也用这个任务来判断不同表示模型的效果。我们可以看到,以TransE为代表的翻译模型,需要学习的参数数量要小很多,但同时能够达到非常好的预测准确率。\ntrans 系列详解: http://aiblog.top/2019/07/08/Trans%E7%B3%BB%E5%88%97%E6%A8%A1%E5%9E%8B%E8%AF%A6%E8%A7%A3/\n这里举一些例子。首先,利用TransE学到的实体表示,我们可以很容易地计算出跟某个实体最相似的实体。大家可以看到\n,关于中国、奥巴马、苹果,通过TransE向量得到的相似实体能够非常好地反映这些实体的关联。\n如果已知head entity和relation,我们可以用TransE模型判断对应的tail entity是什么。比如说与中国相邻的国家或者地区,可以看到比较靠前的实体均比较相关。比如说奥巴马曾经入学的学校,虽然前面的有些并不准确,但是基本上也都是大学或教育机构。\n很多情况下TransE关于h r=t的假设其实本身并不符合实际。为什么呢?假如头实体是美国,关系是总统,而美国总统其实有非常多,我们拿出任意两个实体来,比如奥巴马和布什,这两个人都可以跟USA构成同样的关系。在这种情况下,对这两个三元组学习TransE模型,就会发现,它倾向于让奥巴马和布什在空间中变得非常接近。而这其实不太符合常理,因为奥巴马和布什虽然都是美国总统,但是在其他方面有千差万别。这其实就是涉及到复杂关系的处理问题,即所谓的1对N,N对1、N对N这些关系。刚才例子就是典型的1对N关系,就是一个USA可能会对应多个tail entity。为了解决TransE在处理复杂关系时的不足,研究者提出很多扩展模型,基本思想是,首先把实体按照关系进行映射,然后与该关系构建翻译等式。\n1 - 1 transE 效果很好,但是1-N, N-1, N-N 这些复杂情况比较难。\nTransH和TransR均为代表扩展模型之一,其中TransH由MSRA研究者提出,TransR由我们实验室提出。可以看到,TransE在实体预测任务能够达到47.1的准确率,而采用TransH和TransR,特别是TransR可以达到20%的提升。对于知识图谱复杂关系的处理,还有很多工作需要做。这里只是简介了一些初步尝试。\n对于TransH和TransR的效果我们给出一些例子。比如对于《泰坦尼克号》电影,想看它的电影风格是什么,TransE得到的效果比TransH和TransR都要差一些。再如剑桥大学的杰出校友有哪些?我们可以看到对这种典型的1对N关系,TransR和TransH均做得更好一些。\nTrans 系列Github: https://github.com/thunlp/OpenKE\n考虑知识图谱复杂关系: 按照知识图谱中关系两端连接实体的对应数目,我们可以将关系划分为一对一、一对多、多对一和多对多四种类型。类型关系指的是,该类型关系中的一个左侧实体会平均对应多个右侧实体。 现有知识表示学习算法在处理四种类型关系时的性能差异较大。针对这个问题,我们提出了基于空间转移的 TransR 模型对不同的知识/关系的结构类型进行精细建模。\n考虑知识图谱复杂路径: 在知识图谱中,有些多步关系路径也能够反映实体之间的关系。为了突破现有知识表示学习模型孤立学习每个三元组的局限性,我们将借鉴循环神经网络(Recursive Neural Networks)的学术思想,提出考虑关系路径的表示学习方法。我们以平移模型 TransE 作为基础进行扩展,提出 Path-based TransE(PTransE)模型对知识图谱中的复杂关系路径进行建模。\n考虑知识图谱复杂属性: 现有知识表示学习模型将所有关系都表示为向量,这在极大程度上限制了对关系的语义的表示能力。这种局限性在属性知识的表示上尤为突出。我们面向属性知识,研究利用分类模型表示属性关系,通 过学习分类器建立实体与属性之间的关系,在既有知识图谱关系表示方案的基础上,探索具有更强表示能力的表示方案。\n二、DeepWalk DeepWalk的思想类似word2vec,使用图中节点与节点的共现关系来学习节点的向量表示。那么关键的问题就是如何来描述节点与节点的共现关系,DeepWalk给出的方法是使用随机游走(RandomWalk)的方式在图中进行节点采样。\nRandomWalk是一种可重复访问已访问节点的深度优先遍历算法。给定当前访问起始节点,从其邻居中随机采样节点作为下一个访问节点,重复此过程,直到访问序列长度满足预设条件。\n获取足够数量的节点访问序列后,使用skip-gram model 进行向量学习。\nDeepWalk 核心代码 DeepWalk算法主要包括两个步骤,第一步为随机游走采样节点序列,第二步为使用skip-gram modelword2vec学习表达向量。\n①构建同构网络,从网络中的每个节点开始分别进行Random Walk 采样,得到局部相关联的训练数据;\n②对采样数据进行SkipGram训练,将离散的网络节点表示成向量化,最大化节点共现,使用Hierarchical Softmax来做超大规模分类的分类器\nRandom Walk 我们可以通过并行的方式加速路径采样,在采用多进程进行加速时,相比于开一个进程池让每次外层循环启动一个进程,我们采用固定为每个进程分配指定数量的num_walks的方式,这样可以最大限度减少进程频繁创建与销毁的时间开销。\ndeepwalk_walk方法对应上一节伪代码中第6行,_simulate_walks对应伪代码中第3行开始的外层循环。最后的Parallel为多进程并行时的任务分配操作。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def deepwalk_walk(self, walk_length, start_node): walk = [start_node] while len(walk) \u0026lt; walk_length: cur = walk[-1] cur_nbrs = list(self.G.neighbors(cur)) if len(cur_nbrs) \u0026gt; 0: walk.append(random.choice(cur_nbrs)) else: break return walk def _simulate_walks(self, nodes, num_walks, walk_length,): walks = [] for _ in range(num_walks): random.shuffle(nodes) for v in nodes: walks.append(self.deepwalk_walk(alk_length=walk_length, start_node=v)) return walks results = Parallel(n_jobs=workers, verbose=verbose, )( delayed(self._simulate_walks)(nodes, num, walk_length) for num in partition_num(num_walks, workers)) walks = list(itertools.chain(*results)) Word2vec 这里就偷个懒直接用gensim里的Word2Vec了。\n1 2 from gensim.models import Word2Vec w2v_model = Word2Vec(walks,sg=1,hs=1) DeepWalk应用 这里简单的用DeepWalk算法在wiki数据集上进行节点分类任务和可视化任务。 wiki数据集包含 2,405 个网页和17,981条网页之间的链接关系,以及每个网页的所属类别。\n本例中的训练,评测和可视化的完整代码在下面的git仓库中,后面还会陆续更新line,node2vec,sdne,struc2vec等graph embedding算法以及一些GCN算法\n1 2 3 4 5 6 7 8 G = nx.read_edgelist(\u0026#39;../data/wiki/Wiki_edgelist.txt\u0026#39;,create_using=nx.DiGraph(),nodetype=None,data=[(\u0026#39;weight\u0026#39;,int)]) model = DeepWalk(G,walk_length=10,num_walks=80,workers=1) model.train(window_size=5,iter=3) embeddings = model.get_embeddings() evaluate_embeddings(embeddings) plot_embeddings(embeddings) 分类任务结果 micro-F1 : 0.6674\nmacro-F1 : 0.5768\n","permalink":"https://reid00.github.io/en/posts/ml/kg%E8%A1%A8%E7%A4%BA%E5%AD%A6%E4%B9%A0/","summary":"一、概述 网络表示学习(Representation Learning on Network),一般说的就是向量化(Embedding)技术,简单来说,就是将网络中","title":"KG表示学习"},{"content":"聚类与分类的区别 分类:类别是已知的,通过对已知分类的数据进行训练和学习,找到这些不同类的特征,再对未分类的数据进行分类。属于监督学习。\n聚类:事先不知道数据会分为几类,通过聚类分析将数据聚合成几个群体。聚类不需要对数据进行训练和学习。属于无监督学习。\n关于监督学习和无监督学习,这里给一个简单的介绍:是否有监督,就看输入数据是否有标签,输入数据有标签,则为有监督学习,否则为无监督学习。\nk-means 聚类 聚类算法有很多种,K-Means 是聚类算法中的最常用的一种,算法最大的特点是简单,好理解,运算速度快,但是只能应用于连续型的数据,并且一定要在聚类前需要手工指定要分成几类。\nK-Means 聚类算法的大致意思就是“物以类聚,人以群分”:\n首先输入 k 的值,即我们指定希望通过聚类得到 k 个分组; 从数据集中随机选取 k 个数据点作为初始大佬(质心); 对集合中每一个小弟,计算与每一个大佬的距离,离哪个大佬距离近,就跟定哪个大佬。 这时每一个大佬手下都聚集了一票小弟,这时候召开选举大会,每一群选出新的大佬(即通过算法选出新的质心)。 如果新大佬和老大佬之间的距离小于某一个设置的阈值(表示重新计算的质心的位置变化不大,趋于稳定,或者说收敛),可以认为我们进行的聚类已经达到期望的结果,算法终止。 如果新大佬和老大佬距离变化很大,需要迭代3~5步骤。 用Python 代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # dataSet样本点,k 簇的个数 # disMeas距离量度,默认为欧几里得距离 # createCent,初始点的选取 def kMeans(dataSet, k, distMeas=distEclud, createCent=randCent): m = shape(dataSet)[0] #样本数 clusterAssment = mat(zeros((m,2))) #m*2的矩阵 centroids = createCent(dataSet, k) #初始化k个中心 clusterChanged = True while clusterChanged: #当聚类不再变化 clusterChanged = False for i in range(m): minDist = inf; minIndex = -1 for j in range(k): #找到最近的质心 distJI = distMeas(centroids[j,:],dataSet[i,:]) if distJI \u0026lt; minDist: minDist = distJI; minIndex = j if clusterAssment[i,0] != minIndex: clusterChanged = True # 第1列为所属质心,第2列为距离 clusterAssment[i,:] = minIndex,minDist**2 print centroids # 更改质心位置 for cent in range(k): ptsInClust = dataSet[nonzero(clusterAssment[:,0].A==cent)[0]] centroids[cent,:] = mean(ptsInClust, axis=0) return centroids, clusterAssment 重点理解一下:\n1 2 3 for cent in range(k): ptsInClust = dataSet[nonzero(clusterAssment[:,0].A==cent)[0]] centroids[cent,:] = mean(ptsInClust, axis=0) 循环每一个质心,找到属于当前质心的所有点,然后根据这些点去更新当前的质心。 nonzero()返回的是一个二维的数组,其表示非0的元素位置。\n1 2 3 4 5 6 7 8 \u0026gt;\u0026gt;\u0026gt; from numpy import * \u0026gt;\u0026gt;\u0026gt; a=array([[1,0,0],[0,1,2],[2,0,0]]) \u0026gt;\u0026gt;\u0026gt; a array([[1, 0, 0], [0, 1, 2], [2, 0, 0]]) \u0026gt;\u0026gt;\u0026gt; nonzero(a) (array([0, 1, 1, 2]), array([0, 1, 2, 0])) K-Means算法的缺陷 k均值算法非常简单且使用广泛,但是其有主要的两个缺陷:\nK值需要预先给定,属于预先知识,很多情况下K值的估计是非常困难的,对于像计算全部微信用户的交往圈这样的场景就完全的没办法用K-Means进行。对于可以确定K值不会太大但不明确精确的K值的场景,可以进行迭代运算,然后找出Cost Function最小时所对应的K值,这个值往往能较好的描述有多少个簇类。 K-Means算法对初始选取的聚类中心点是敏感的,不同的随机种子点得到的聚类结果完全不同 K均值算法并不是很所有的数据类型。它不能处理非球形簇、不同尺寸和不同密度的簇,银冠指定足够大的簇的个数是他通常可以发现纯子簇。 对离群点的数据进行聚类时,K均值也有问题,这种情况下,离群点检测和删除有很大的帮助 KMeans 的时间复杂度: O(knt)\n","permalink":"https://reid00.github.io/en/posts/ml/kmeans%E8%81%9A%E7%B1%BB%E5%88%86%E6%9E%90/","summary":"聚类与分类的区别 分类:类别是已知的,通过对已知分类的数据进行训练和学习,找到这些不同类的特征,再对未分类的数据进行分类。属于监督学习。 聚类:","title":"KMeans聚类分析"},{"content":"01 全连接网络 全连接、密集和线性网络是最基本但功能强大的架构这是机器学习的直接扩展,将神经网络与单个隐藏层结合使用。全连接层充当所有架构的最后一部分,用于获得使用下方深度网络所得分数的概率分布。\n**如其名称所示,全连接网络将其上一层和下一层中的所有神经元相互连接。**网络可能最终通过设置权重来关闭一些神经元,但在理想情况下,最初所有神经元都参与训练。\n02 编码器和解码器 编码器和解码器可能是深度学习另一个最基本的架构之一。所有网络都有一个或多个编码器–解码器层。你可以将全连接层中的隐藏层视为来自编码器的编码形式,将输出层视为解码器,它将隐藏层解码并作为输出。通常,编码器将输入编码到中间状态,其中输入为向量,然后解码器网络将该中间状态解码为我们想要的输出形式。\n编码器–解码器网络的一个规范示例是序列到序列 (seq2seq)网络(图1.11),可用于机器翻译。一个句子将被编码为中间向量表示形式,其中整个句子以一些浮点数字的形式表示,解码器根据中间向量解码以生成目标语言的句子作为输出。\n▲图1.11 seq2seq 网络\n自动编码器(图1.12)是一种特殊的编码器–解码器网络,属于无监督学习范畴。自动编码器尝试从未标记的数据中进行学习,将目标值设置为输入值。\n例如,如果输入一个大小为100×100的图像,则输入向量的维度为10 000。因此,输出的大小也将为 10 000,但隐藏层的大小可能为 500。简而言之,你正在尝试将输入转换为较小的隐藏状态表示形式,从隐藏状态重新生成相同的输入。\n图1.12 自动编码器的结构\n你如果能够训练一个可以做到这一点的神经网络,就会找到一个好的压缩算法,其可以将高维输入变为低维向量,这具有数量级收益。\n如今,自动编码器正被广泛应用于不同的情景和行业。\n03 循环神经网络 循环神经网络(RNN)是**最常见的深度学习算法之一,它席卷了整个世界。**我们现在在自然语言处理或理解方面几乎所有最先进的性能都归功于RNN的变体。在循环网络中,你尝试识别数据中的最小单元,并使数据成为一组这样的单元。\n在自然语言的示例中,最常见的方法是将一个单词作为一个单元,并在处理句子时将句子视为一组单词。你在整个句子上展开RNN,一次处理一个单词(图1.13)。RNN 具有适用于不同数据集的变体,有时我们会根据效率选择变体。长短期记忆 (LSTM)和门控循环单元(GRU)是最常见的 RNN 单元。\n图1.13 循环网络中单词的向量表示形式\n04 递归神经网络 顾名思义,递归神经网络是一种树状网络,用于理解序列数据的分层结构。递归网络被研究者(尤其是 Salesforce 的首席科学家理查德·索彻和他的团队)广泛用于自然语言处理。\n字向量能够有效地将一个单词的含义映射到一个向量空间,但当涉及整个句子的含义时,却没有像word2vec这样针对单词的首选解决方案。递归神经网络是此类应用最常用的算法之一。 递归网络可以创建解析树和组合向量,并映射其他分层关系(图1.14),这反过来又帮助我们找到组合单词和形成句子的规则。斯坦福自然语言推理小组开发了一种著名的、使用良好的算法,称为SNLI,这是应用递归网络的一个好例子。\n▲图1.14 递归网络中单词的向量表示形式\n05 卷积神经网络 卷积神经网络(CNN)(图1.15)使我们能够在计算机视觉中获得超人的性能,它在2010年代早期达到了人类的精度,而且其精度仍在逐年提高。\n卷积网络是最容易理解的网络,因为它有可视化工具来显示每一层正在做什么。\nFacebook AI研究(FAIR)负责人Yann LeCun早在20世纪90年代就发明了CNN。人们当时无法使用它,因为并没有足够的数据集和计算能力。CNN像滑动窗口一样扫描输入并生成中间表征,然后在它到达末端的全连接层之前对其进行逐层抽象。CNN也已成功应用于非图像数据集。\n▲图1.15 典型的 CNN\nFacebook的研究小组发现了一个基于卷积神经网络的先进自然语言处理系统,其卷积网络优于RNN,而后者被认为是任何序列数据集的首选架构。虽然一些神经科学家和人工智能研究人员不喜欢CNN(因为他们认为大脑不会像CNN那样做),但基于CNN的网络正在击败所有现有的网络实现。\n06 生成对抗网络 生成对抗网络(GAN)由 Ian Goodfellow 于 2014 年发明,自那时起,它颠覆了整个 AI 社群。它是最简单、最明显的实现之一,但其能力吸引了全世界的注意。GAN的配置如图1.16所示。\n▲图1.16 GAN配置 两个网络相互竞争,最终达到一种平衡,即生成网络可以生成数据,而鉴别网络很难将其与实际图像区分开。\n一个真实的例子就是警察和造假者之间的斗争:假设一个造假者试图制造假币,而警察试图识破它。最初,造假者没有足够的知识来制造看起来真实的假币。随着时间的流逝,造假者越来越善于制造看起来更像真实货币的假币。这时,警察起初未能识别假币,但最终他们会再次成功识别。\n这种生成–对抗过程最终会形成一种平衡。GAN 具有极大的优势。\n07 强化学习 通过互动进行学习是人类智力的基础,强化学习是领导我们朝这个方向前进的方法。过去强化学习是一个完全不同的领域,它认为人类通过试错进行学习。然而,随着深度学习的推进,另一个领域出现了“深度强化学习”,它结合了深度学习与强化学习。\n现代强化学习使用深度网络来进行学习,而不是由人们显式编码这些规则。我们将研究Q学习和深度Q学习,展示结合深度学习的强化学习与不结合深度学习的强化学习之间的区别。\n强化学习被认为是通向一般智能的途径之一,其中计算机或智能体通过与现实世界、物体或实验互动或者通过反馈来进行学习。**训练强化学习智能体和训练狗很像,它们都是通过正、负激励进行的。**当你因为狗捡到球而奖励它一块饼干或者因为狗没捡到球而对它大喊大叫时,你就是在通过积极和消极的奖励向狗的大脑中强化知识。\n**我们对AI智能体也做了同样的操作,但正奖励将是一个正数,负奖励将是一个负数。**尽管我们不能将强化学习视为与 CNN/RNN 等类似的另一种架构,但这里将其作为使用深度神经网络来解决实际问题的另一种方法,其配置如图1.17所示。\n","permalink":"https://reid00.github.io/en/posts/ml/cnn-rnn-gan/","summary":"01 全连接网络 全连接、密集和线性网络是最基本但功能强大的架构这是机器学习的直接扩展,将神经网络与单个隐藏层结合使用。全连接层充当所有架构的最后","title":"CNN RNN GAN"},{"content":"简介 在推荐、搜索、广告等领域,CTR(click-through rate)预估是一项非常核心的技术,这里引用阿里妈妈资深算法专家朱小强大佬的一句话:“它(CTR预估)是镶嵌在互联网技术上的明珠”。\n本篇文章主要是对CTR预估中的常见模型进行梳理与总结,并分成模块进行概述。每个模型都会从「模型结构」、「优势」、「不足」三个方面进行探讨,在最后对所有模型之间的关系进行比较与总结。本篇文章讨论的模型如下图所示(原创图),这个图中展示了本篇文章所要讲述的算法以及之间的关系,在文章的最后总结会对这张图进行详细地说明。\n一. 分布式线性模型 Logistic Regression Logistic Regression是每一位算法工程师再也熟悉不过的基本算法之一了,毫不夸张地说,LR作为最经典的统计学习算法几乎统治了早期工业机器学习时代。这是因为其具备简单、时间复杂度低、可大规模并行化等优良特性。在早期的CTR预估中,算法工程师们通过手动设计交叉特征以及特征离散化等方式,赋予LR这样的线性模型对数据集的非线性学习能力,高维离散特征+手动交叉特征构成了CTR预估的基础特征。LR在工程上易于大规模并行化训练恰恰适应了这个时代的要求。\n模型结构:\n优势:\n模型简单,具备一定可解释性 计算时间复杂度低 工程上可大规模并行化 不足:\n依赖于人工大量的特征工程,例如需要根据业务背知识通过特征工程融入模型 特征交叉难以穷尽 对于训练集中没有出现的交叉特征无法进行参数学习 二. 自动化特征工程 GBDT + LR(2014)—— 特征自动化时代的初探索 Facebook在2014年提出了GBDT+LR的组合模型来进行CTR预估,其本质上是通过Boosting Tree模型本身的特征组合能力来替代原先算法工程师们手动组合特征的过程。GBDT等这类Boosting Tree模型本身具备了特征筛选能力(每次分裂选取增益最大的分裂特征与分裂点)以及高阶特征组合能力(树模型天然优势)对应树的一条路径(用叶子节点来表示),因此通过GBDT来自动生成特征向量就成了一个非常自然的思路。注意这里虽然是两个模型的组合,但实际并非是端到端的模型,而是两阶段的、解耦的,即先通过GBDT训练得到特征向量后,再作为下游LR的输入,LR的在训练过程中并不会对GBDT进行更新。\n模型结构:\n通过GBDT训练模型,得到组合的特征向量。例如训练了两棵树,每棵树有5个叶子结点,对于某个特定样本来说,落在了第一棵树的第3个结点,此时我们可以得到向量 ;落在第二棵树的第4个结点,此时的到向量 ;那么最终通过concat所有树的向量,得到这个样本的最终向量 。将这个向量作为下游LR模型的inputs,进行训练。\n优势:\n特征工程自动化,通过Boosting Tree模型的天然优势自动探索特征组合 不足:\n两阶段的、非端到端的模型 CTR预估场景涉及到大量高维稀疏特征,树模型并不适合处理(因此实际上会将dense特征或者低维的离散特征给GBDT,剩余高维稀疏特征在LR阶段进行训练) GBDT模型本身比较复杂,无法做到online learning,模型对数据的感知相对较滞后(必须提高离线模型的更新频率) 由于LR善于处理离散特征,GBDT善于处理连续特征。所以也可以交由GBDT处理连续特征,输出结果拼接上离散特征一起输入LR。\n三. FM模型以及变体 (1)FM:Factorization Machines, 2010 —— 隐向量学习提升模型表达 FM是在2010年提出的一种可以学习二阶特征交叉的模型,通过在原先线性模型的基础上,枚举了所有特征的二阶交叉信息后融入模型,提高了模型的表达能力。但不同的是,模型在二阶交叉信息的权重学习上,采用了隐向量内积(也可看做embedding)的方式进行学习。\nFM和基于树的模型(e.g. GBDT)都能够自动学习特征交叉组合。基于树的模型适合连续中低度稀疏数据,容易学到高阶组合。但是树模型却不适合学习高度稀疏数据的特征组合,一方面高度稀疏数据的特征维度一般很高,这时基于树的模型学习效率很低,甚至不可行;另一方面树模型也不能学习到训练数据中很少或没有出现的特征组合。相反,FM模型因为通过隐向量的内积来提取特征组合,对于训练数据中很少或没有出现的特征组合也能够学习到。例如,特征 和特征 在训练数据中从来没有成对出现过,但特征 经常和特征 成对出现,特征 也经常和特征 成对出现,因而在FM模型中特征 和特征 也会有一定的相关性。毕竟所有包含特征 的训练样本都会导致模型更新特征 的隐向量 ,同理,所有包含特征 的样本也会导致模型更新隐向量 ,这样 就不太可能为0。\n模型结构:\nFM的公式包含了一阶线性部分与二阶特征交叉部分:\n在LR中,一般是通过手动构造交叉特征后,喂给模型进行训练,例如我们构造性别与广告类别的交叉特征: (gender=’女’ \u0026amp; ad_category=’美妆’),此时我们会针对这个交叉特征学习一个参数 。但是在LR中,参数梯度更新公式与该特征取值 关系密切: ,当 取值为0时,参数 就无法得到更新,而 要非零就要求交叉特征的两项都要非零,但实际在数据高度稀疏,一旦两个特征只要有一个取0,参数 不能得到有效更新;除此之外,对于训练集中没有出现的交叉特征,也没办法学习这类权重,泛化性能不够好。\n另外,在FM中通过将特征隐射到k维空间求内积的方式,打破了交叉特征权重间的隔离性(break the independence of the interaction parameters),增加模型在稀疏场景下学习交叉特征的能力。一个交叉特征参数的估计,可以帮助估计其他相关的交叉特征参数。例如,假设我们有交叉特征gender=male \u0026amp; movie_genre=war,我们需要估计这个交叉特征前的参数 ,FM通过将 分解为 的方式进行估计,那么对于每次更新male或者war的隐向量 时,都会影响其他与male或者war交叉的特征参数估计,使得特征权重的学习不再互相独立。这样做的好处是,对于traindata set中没有出现过的交叉特征,FM仍然可以给到一个较好的非零预估值。\n优势:\n可以有效处理稀疏场景下的特征学习 具有线性时间复杂度(化简思路: ) 对训练集中未出现的交叉特征信息也可进行泛化 不足: 2-way的FM仅枚举了所有特征的二阶交叉信息,没有考虑高阶特征的信息 FFM(Field-aware Factorization Machine)是Yuchin Juan等人在2015年的比赛中提出的一种对FM改进算法,主要是引入了field概念,即认为每个feature对于不同field的交叉都有不同的特征表达。FFM相比于FM的计算时间复杂度更高,但同时也提高了本身模型的表达能力。FM也可以看成只有一个field的FFM,这里不做过多赘述。\n(2)AFM:Attentional Factorization Machines, 2017 —— 引入Attention机制的FM AFM全称Attentional Factorization Machines,顾名思义就是引入Attention机制的FM模型。我们知道FM模型枚举了所有的二阶交叉特征(second-order interactions),即 ,实际上有一些交叉特征可能与我们的预估目标关联性不是很大;AFM就是通过Attention机制来学习不同二阶交叉特征的重要性(这个思路与FFM中不同field特征交叉使用不同的embedding实际上是一致的,都是通过引入额外信息来表达不同特征交叉的重要性)。\n举例来说,在预估用户是否会点击广告时,我们假设有用户性别、广告版位尺寸大小、广告类型三个特征,分别对应三个embedding: , , ,对于用户“是否点击”这一目标 来说,显然性别与ad_size的交叉特征对于 的相关度不大,但性别与ad_category的交叉特征(如gender=女性\u0026amp;category=美妆)就会与 更加相关;换句话说,我们认为当性别与ad_category交叉时,重要性应该要高于性别与ad_size的交叉;FFM中通过引入Field-aware的概念来量化这种与不同特征交叉时的重要性,AFM则是通过加入Attention机制,赋予重要交叉特征更高的重要性。\n模型结构:\nAFM在FM的二阶交叉特征上引入Attention权重,公式如下:\n其中 代表element-wise的向量相乘,下同。\n其中, 是模型所学习到的 与 特征交叉的重要性,其公式如下:\n我们可以看到这里的权重 实际是通过输入 和 训练了一个一层隐藏层的NN网络,让模型自行去学习这个权重。\n对比AFM和FM的公式我们可以发现,AFM实际上是FM的更加泛化的一种形式。当我们令向量 ,权重 时,AFM就会退化成FM模型。\n优势:\n在FM的二阶交叉项上引入Attention机制,赋予不同交叉特征不同的重要度,增加了模型的表达能力 Attention的引入,一定程度上增加了模型的可解释性 不足:\n仍然是一种浅层模型,模型没有学习到高阶的交叉特征 四. Embedding+MLP结构下的浅层改造 本章所介绍的都是具备Embedding+MLP这样结构的模型,之所以称作浅层改造,主要原因在于这些模型都是在embedding层进行的一些改变,例如FNN的预训练Embedding、PNN的Product layer、NFM的Bi-Interaction Layer等等,这些改变背后的思路可以归纳为:使用复杂的操作让模型在浅层尽可能包含更多的信息,降低后续下游MLP的学习负担。\n(1)FNN: Factorisation Machine supported Neural Network, 2016 —— 预训练Embedding的NN模型 FNN是2016年提出的一种基于FM预训练Embedding的NN模型,其思路也比较简单;FM本身具备学习特征Embedding的能力,DNN具备高阶特征交叉的能力,因此将两者结合是很直接的思路。FM预训练的Embedding可以看做是“先验专家知识”,直接将专家知识输入NN来进行学习。注意,FNN本质上也是两阶段的模型,与Facebook在2014年提出GBDT+LR模型在思想上一脉相承。\n模型结构:\nFNN本身在结构上并不复杂,如上图所示,就是将FM预训练好的Embedding向量直接喂给下游的DNN模型,让DNN来进行更高阶交叉信息的学习。\n优势:\n离线训练FM得到embedding,再输入NN,相当于引入先验专家经验 加速模型的训练和收敛 NN模型省去了学习feature embedding的步骤,训练开销低 不足:\n非端到端的两阶段模型,不利于online learning 预训练的Embedding受到FM模型的限制 FNN中只考虑了特征的高阶交叉,并没有保留低阶特征信息 (2)PNN:Product-based Neural Network, 2016 —— 引入不同Product操作的Embedding层 PNN是2016年提出的一种在NN中引入Product Layer的模型,其本质上和FNN类似,都属于Embedding+MLP结构。作者认为,在DNN中特征Embedding通过简单的concat或者add都不足以学习到特征之间复杂的依赖信息,因此PNN通过引入Product Layer来进行更复杂和充分的特征交叉关系的学习。PNN主要包含了IPNN和OPNN两种结构,分别对应特征之间Inner Product的交叉计算和Outer Product的交叉计算方式。\n模型结构:\nPNN结构显示通过Embedding Lookup得到每个field的Embedding向量,接着将这些向量输入Product Layer,在Product Layer中包含了两部分,一部分是左边的 ,就是将特征原始的Embedding向量直接保留;另一部分是右侧的 ,即对应特征之间的product操作;可以看到PNN相比于FNN一个优势就是保留了原始的低阶embedding特征。\n在PNN中,由于引入Product操作,会使模型的时间和空间复杂度都进一步增加。这里以IPNN为例,其中 是pair-wise的特征交叉向量,假设我们共有N个特征,每个特征的embedding信息 ;在Inner Product的情况下,通过交叉项公式 会得到 (其中 是对称矩阵),此时从Product层到 层(假设 层有 个结点),对于 层的每个结点我们有: ,因此这里从product layer到L1层参数空间复杂度为 ;作者借鉴了FM的思想对参数 进行了矩阵分解: ,此时L1层每个结点的计算可以化简为: ,空间复杂度退化 。\n优势:\nPNN通过 保留了低阶Embedding特征信息 通过Product Layer引入更复杂的特征交叉方式, 不足:\n计算时间复杂度相对较高 (3)NFM:Neural Factorization Machines, 2017 —— 引入Bi-Interaction Pooling结构的NN模型 NFM全程为Neural Factorization Machines,它与FNN一样,都属于将FM与NN进行结合的模型。但不同的是NFM相比于FNN是一种端到端的模型。NFM与PNN也有很多相似之出,本质上也属于Embedding+MLP结构,只是在浅层的特征交互上采用了不同的结构。NFM将PNN的Product Layer替换成了Bi-interaction Pooling结构来进行特征交叉的学习。\n模型结构:\nNFM的整个模型公式为:\n其中 是Bi-Interaction Pooling+NN部分的输出结果。我们重点关注NFM中的Bi-Interaction Pooling层:\nNFM的结构如上图所示,通过对特征Embedding之后,进入Bi-Interaction Pooling层。这里注意一个小细节,NFM的对Dense Feature,Embedding方式于AFM相同,将Dense Feature Embedding以后再用dense feature原始的数据进行了scale,即 。\nNFM的Bi-Interaction Pooling层是对两两特征的embedding进行element-wise的乘法,公式如下:\n假设我们每个特征Embedding向量的维度为 ,则 ,Bi-Interaction Pooling的操作简单来说就是将所有二阶交叉的结果向量进行sum pooling后再送入NN进行训练。对比AFM的Attention层,Bi-Interaction Pooling层采用直接sum的方式,缺少了Attention机制;对比FM莫明星,NFM如果将后续DNN隐藏层删掉,就会退化为一个FM模型。\nNFM在输入层以及Bi-Interaction Pooling层后都引入了BN层,也加速了模型了收敛。\n优势:\n相比于Embedding的concat操作,NFM在low level进行interaction可以提高模型的表达能力 具备一定高阶特征交叉的能力 Bi-Interaction Pooling的交叉具备线性计算时间复杂度 不足:\n直接进行sum pooling操作会损失一定的信息,可以参考AFM引入Attention (4)ONN:Operation-aware Neural Network, 2019 —— FFM与NN的结合体 ONN是2019年发表的CTR预估,我们知道PNN通过引入不同的Product操作来进行特征交叉,ONN认为针对不同的特征交叉操作,应该用不同的Embedding,如果用同样的Embedding,那么各个不同操作之间就会互相影响而最终限制了模型的表达。\n我们会发现ONN的思路在本质上其实和FFM、AFM都有异曲同工之妙,这三个模型都是通过引入了额外的信息来区分不同field之间的交叉应该具备不同的信息表达。总结下来:\nFFM:引入Field-aware,对于field a来说,与field b交叉和field c交叉应该用不同的embedding AFM:引入Attention机制,a与b的交叉特征重要度与a与c的交叉重要度不同 ONN:引入Operation-aware,a与b进行内积所用的embedding,不同于a与b进行外积用的embedding 对比上面三个模型,本质上都是给模型增加更多的表达能力,个人觉得ONN就是FFM与NN的结合。\n模型结构:\nONN沿袭了Embedding+MLP结构。在Embedding层采用Operation-aware Embedding,可以看到对于一个feature,会得到多个embedding结果;在图中以红色虚线为分割,第一列的embedding是feature本身的embedding信息,从第二列开始往后是当前特征与第n个特征交叉所使用的embedding。\n在Embedding features层中,我们可以看到包含了两部分:\n左侧部分为每个特征本身的embedding信息,其代表了一阶特征信息 右侧部分是与FFM相同的二阶交叉特征部分 这两部分concat之后接入MLP得到最后的预测结果。\n优势:\n引入Operation-aware,进一步增加了模型的表达能力 同时包含了特征一阶信息与高阶交叉信息 不足:\n模型复杂度相对较高,每个feature对应多个embedding结果 五. 双路并行的模型组合 这一部分将介绍双路并行的模型结构,之所以称为双路并行,是因为在这一部分的模型中,以Wide\u0026amp;Deep和DeepFM为代表的模型架构都是采用了双路的结构。例如Wide\u0026amp;Deep的左路为Embedding+MLP,右路为Cross Feature LR;DeepFM的左路为FM,右路为Embedding+MLP。这类模型通过使用不同的模型进行联合训练,不同子模型之间互相弥补,增加整个模型信息表达和学习的多样性。\n(1)WDL:Wide and Deep Learning, 2016 —— Memorization与Generalization的信息互补 Wide And Deep是2016年Google提出的用于Google Play app推荐业务的一种算法。其核心思想是通过结合Wide线性模型的记忆性(memorization)和Deep深度模型的泛化性(generalization)来对用户行为信息进行学习建模。\n模型结构:\n优势:\nWide层与Deep层互补互利,Deep层弥补Memorization层泛化性不足的问题 wide和deep的joint training可以减小wide部分的model size(即只需要少数的交叉特征) 可以同时学习低阶特征交叉(wide部分)和高阶特征交叉(deep部分) 不足: 仍需要手动设计交叉特征 (2)DeepFM:Deep Factorization Machines, 2017 —— FM基础上引入NN隐式高阶交叉信息 我们知道FM只能够去显式地捕捉二阶交叉信息,而对于高阶的特征组合却无能为力。DeepFM就是在FM模型的基础上,增加DNN部分,进而提高模型对于高阶组合特征的信息提取。DeepFM能够做到端到端的、自动的进行高阶特征组合,并且不需要人工干预。\n模型结构:\nDeepFM包含了FM和NN两部分,这两部分共享了Embedding层:\n左侧FM部分就是2-way的FM:包含了线性部分和二阶交叉部分右侧NN部分与FM共享Embedding,将所有特征的embedding进行concat之后作为NN部分的输入,最终通过NN得到。\n优势:\n模型具备同时学习低阶与高阶特征的能力 共享embedding层,共享了特征的信息表达 不足:\nDNN部分对于高阶特征的学习仍然是隐式的 参考:\nhttps://wqw547243068.github.io/2020/08/02/CTR/ https://zhuanlan.zhihu.com/p/35465875 CTR 预估模型的进化之路 ","permalink":"https://reid00.github.io/en/posts/ml/ctr%E5%8F%91%E5%B1%95/","summary":"简介 在推荐、搜索、广告等领域,CTR(click-through rate)预估是一项非常核心的技术,这里引用阿里妈妈资深算法专家朱小强大佬的","title":"CTR发展"},{"content":"介绍 FM和FMM模型在数据量比较大并且特征稀疏的情况下,仍然有优秀的性能表现,在CTR/CVR任务上尤其突出。\n本文包括:\n- FM 模型 - FFM 模型 - Deep FM 模型 - Deep FFM模型 FM模型的引入-广告特征的稀疏性 FM(Factorization machines)模型由Steffen Rendle于2010年提出,目的是解决稀疏数据下的特征组合问题。\n在介绍FM模型之前,来看看稀疏数据的训练问题。\n以广告CTR(click-through rate)点击率预测任务为例,假设有如下数据\nClicked? Country Day Ad_type 1 USA 26/11/15 Movie 0 China 19/2/15 Game 1 China 26/11/15 Game 第一列Clicked是类别标记,标记用户是否点击了该广告,而其余列则是特征(这里的三个特征都是类别类型),一般的,我们会对数据进行One-hot编码将类别特征转化为数值特征,转化后数据如下:\nClicked? Country=USA Country=China Day=26/11/15 Day=19/2/15 Ad_type=Movie Ad_type=Game 1 1 0 1 0 1 0 0 0 1 0 1 0 1 1 0 1 1 0 0 1 经过One-hot编码后,特征空间是十分稀疏的。特别的,某类别特征有m种不同的取值,则one-hot编码后就会被变为m维!当类别特征越多、类别特征的取值越多,其特征空间就更加稀疏。\n此外,往往我们会将特征进行两两的组合,这是因为:\n通过观察大量的样本数据可以发现,某些特征经过关联之后,与label之间的相关性就会提高。例如,“USA”与“Thanksgiving”、“China”与“Chinese New Year”这样的关联特征,对用户的点击有着正向的影响。换句话说,来自“China”的用户很可能会在“Chinese New Year”有大量的浏览、购买行为,而在“Thanksgiving”却不会有特别的消费行为。这种关联特征与label的正向相关性在实际问题中是普遍存在的,如“化妆品”类商品与“女”性,“球类运动配件”的商品与“男”性,“电影票”的商品与“电影”品类偏好等。\n再比如,用户更常在饭点的时间下载外卖app,因此,引入两个特征的组合是非常有意义的。\n如何表示两个特征的组合呢?一种直接的方法就是采用多项式模型来表示两个特征的组合,xixi为第ii个特征的取值(注意和以往表示第ii个样本的特征向量的区别),xixjxixj表示特征xixi和xjxj的特征组合,其系数wijwij即为我们学习的参数,也是xixjxixj组合的重要程度:\n式1-1也可以称为Poly2(degree-2 poly-nomial mappings)模型。注意到式子1-1中参数的个数是非常多的!一次项有d+1个,二次项(即组合特征的参数)共有d(d−1)2d(d−1)2个,而参数与参数之间彼此独立,在稀疏场景下,二次项的训练是很困难的。因为要训练wijwij,需要有大量的xixi和xjxj都非零的样本(只有非零组合才有意义)。而样本本身是稀疏的,满足xixj≠0xixj≠0的样本会非常少,样本少则难以估计参数wijwij,训练出来容易导致模型的过拟合。\n为此,Rendle于2010年提出FM模型,它能很好的求解式1-1,其特点如下:\nFM模型可以在非常稀疏的情况下进行参数估计 FM模型是线性时间复杂度的,可以直接使用原问题进行求解,而且不用像SVM一样依赖支持向量。 FM模型是一个通用的模型,其训练数据的特征取值可以是任意实数。而其它最先进的分解模型对输入数据有严格的限制。FMs可以模拟MF、SVD++、PITF或FPMC模型。 FM模型 前面提到过,式1-1的参数难以训练时因为训练数据的稀疏性。对于不同的特征对xi,xjxi,xj和xi,xkxi,xk,式1-1认为是完全独立的,对参数wijwij和wikwik分别进行训练。而实际上并非如此,不同的特征之间进行组合并非完全独立,如下图所示:\n回想矩阵分解,一个rating可以分解为user矩阵和item矩阵,如下图所示:\n分解后得到user和item矩阵的维度分别为nknk和kmkm,(k一般由用户指定),相比原来的rating矩阵,空间占用得到降低,并且分解后的user矩阵暗含着user偏好,Item矩阵暗含着item的属性,而user矩阵乘上item矩阵就是rating矩阵中用户对item的评分。\n因此,参考矩阵分解的过程,FM模型也将式1-1的二次项参数wijwij进行分解:\n其中vivi是第ii维特征的隐向量,其长度为k(k≪d)k(k≪d)。 (vi⋅vj)(vi⋅vj)为内积,其乘积为原来的wijwij,即 ^wij=(vi⋅vj)=∑kf=1vi,f⋅vj,fw^ij=(vi⋅vj)=∑f=1kvi,f⋅vj,f\n为了方便说明,考虑下面的数据集(实际中应该进行one-hot编码,但并不影响此处的说明):\n数据集 Clicked? Publisher Advertiser Poly2参数 FM参数 训练集 1 NBC Nike wNBC,NikewNBC,Nike VNBC⋅VNikeVNBC⋅VNike 训练集 0 EPSN Adidas wEPSN,AdidaswEPSN,Adidas VEPSN⋅VAdidasVEPSN⋅VAdidas 测试集 ? NBC Adidas wNBC,AdidaswNBC,Adidas VNBC⋅VAdidas 对于上面的训练集,没有(NBC,Adidas)组合,因此,Poly2模型就无法学习到参数wNBC,AdidaswNBC,Adidas。而FM模型可以通过特征组合(NBC,Nike)、(EPSN,Adidas) 分别学习到隐向量VNBCVNBC和VAdidasVAdidas,这样使得在测试集中得以进行预测。\n更一般的,经过分解,式2-1的参数个数减少为kdkd个,对比式1-1,参数个数大大减少。使用小的k,使得模型能够提高在稀疏情况下的泛化性能。此外,将wijwij进行分解,使得不同的特征对不再是完全独立的,而它们的关联性可以用隐式因子表示,这将使得有更多的数据可以用于模型参数的学习。比如xi,xjxi,xj与xi,xkxi,xk的参数分别为:⟨vi,vj⟩⟨vi,vj⟩和⟨vi,vk⟩⟨vi,vk⟩,它们都可以用来学习vivi,更一般的,包含xixj≠0\u0026amp;i≠jxixj≠0\u0026amp;i≠j的所有样本都能用来学习vivi,很大程度上避免了数据稀疏性的影响。\n此外,式2-1的复杂度可以从O(kd2)O(kd2)优化到O(kd)O(kd):\n可以看出,FM模型可以在线性的时间做出预测。\nFM模型学习 在2-4式中,∑dj=1vj,fxj∑j=1dvj,fxj只与ff有关而与ii无关,在每次迭代过程中,可以预先对所有ff的∑dj=1vj,fxj∑j=1dvj,fxj进行计算,复杂度O(kd)O(kd),就能在常数时间O(1)O(1)内得到vi,fvi,f的梯度。而对于其它参数w0w0和wiwi,显然也是在常数时间内计算梯度。此外,更新参数只需要O(1)O(1), 一共有1+d+kd1+d+kd个参数,因此FM参数训练的复杂度也是O(kd)O(kd)。\n所以说,FM模型是一种高效的模型,是线性时间复杂度的,可以在线性的时间做出训练和预测。\nFFM模型 考虑下面的数据集:\nClicked? Publisher(P) Advertiser(A) Gender(G) 1 EPSN Nike Male 0 NBC Adidas Female 对于第一条数据来说,FM模型的二次项为:wEPSN⋅wNike+wEPSN⋅wMale+wNike⋅wMalewEPSN⋅wNike+wEPSN⋅wMale+wNike⋅wMale。(这里只是把上面的v符合改成了w)每个特征只用一个隐向量来学习和其它特征的潜在影响。对于上面的例子中,Nike是广告主,Male是用户的性别,描述(EPSN,Nike)和(EPSN,Male)特征组合,FM模型都用同一个wESPNwESPN,而实际上,ESPN作为广告商,其对广告主和用户性别的潜在影响可能是不同的。\n因此,Yu-Chin Juan借鉴Michael Jahrer的论文(Ensemble of collaborative filtering and feature engineered models for click through rate prediction),将field概念引入FM模型。\nfield是什么呢?即相同性质的特征放在一个field。比如EPSN、NBC都是属于广告商field的,Nike、Adidas都是属于广告主field,Male、Female都是属于性别field的。简单的说,同一个类别特征进行one-hot编码后生成的数值特征都可以放在同一个field中,比如最开始的例子中Day=26/11/15 Day=19/2/15可以放于同一个field中。如果是数值特征而非类别,可以直接作为一个field。\n引入了field后,对于刚才的例子来说,二次项变为:\n对于特征组合(EPSN,Nike)来说,其隐向量采用的是wEPSN,AwEPSN,A和wNike,PwNike,P,对于wEPSN,AwEPSN,A这是因为Nike属于广告主(Advertiser)的field,而第二项wNike,PwNike,P则是EPSN是广告商(Publisher)的field。 再举个例子,对于特征组合(EPSN,Male)来说,wEPSN,GwEPSN,G 是因为Male是用户性别(Gender)的field,而第二项wMale,PwMale,P是因为EPSN是广告商(Publisher)的field。 下面的图来自criteo,很好的表示了三个模型的区别\nFFM 数学公式 因此,FFM的数学公式表示为:\n其中fifi和fjfj分别代表第i个特征和第j个特征所属的field。若field有ff个,隐向量的长度为k,则二次项系数共有dfkdfk个,远多于FM模型的dkdk个。此外,隐向量和field相关,并不能像FM模型一样将二次项化简,计算的复杂度是d2kd2k。\n通常情况下,每个隐向量只需要学习特定field的表示,所以有kFFM≪kFMkFFM≪kFM。\nFFM 模型学习 为了方便推导,这里省略FFM的一次项和常数项,公式为:\n注意到∂Lerr∂ϕ∂Lerr∂ϕ和参数无关,每次更新模型时,只需要计算一次,之后直接调用结果即可。对于总共有dfkdfk个模型参数的计算来说,使用这种方式能极大提升运算效率。\n第二个trick是FFM的学习率是随迭代次数变化的,具体的是采用AdaGrad算法,这里进行简单的介绍。\nAdagrad算法能够在训练中自动的调整学习率,对于稀疏的参数增加学习率,而稠密的参数则降低学习率。因此,Adagrad非常适合处理稀疏数据。\n设gt,jgt,j为第t轮第j个参数的梯度,则SGD和采用Adagrad的参数更新公式分别如下:\n可以看出,Adagrad在学习率ηη上还除以一项√Gt,jj+ϵGt,jj+ϵ,这是什么意思呢?ϵϵ为平滑项,防止分母为0,Gt,jj=∑tι=1g2ι,jjGt,jj=∑ι=1tgι,jj2即Gt,jjGt,jj为对角矩阵,每个对角线位置j,jj,j的值为参数wjwj每一轮的平方和,可以看出,随着迭代的进行,每个参数的历史梯度累加到一起,使得每个参数的学习率逐渐减小。\n实现的trick 除了上面提到的梯度分步计算和自适应学习率两个trick外,还有:\nOpenMP多核并行计算。OpenMP是用于共享内存并行系统的多处理器程序设计的编译方案,便于移植和多核扩展[12]。FFM的源码采用了OpenMP的API,对参数训练过程SGD进行了多线程扩展,支持多线程编译。因此,OpenMP技术极大地提高了FFM的训练效率和多核CPU的利用率。在训练模型时,输入的训练参数ns_threads指定了线程数量,一般设定为CPU的核心数,便于完全利用CPU资源。 SSE3指令并行编程。SSE3全称为数据流单指令多数据扩展指令集3,是CPU对数据层并行的关键指令,主要用于多媒体和游戏的应用程序中[13]。SSE3指令采用128位的寄存器,同时操作4个单精度浮点数或整数。SSE3指令的功能非常类似于向量运算。例如,a和b采用SSE3指令相加(a和b分别包含4个数据),其功能是a种的4个元素与b中4个元素对应相加,得到4个相加后的值。采用SSE3指令后,向量运算的速度更加快捷,这对包含大量向量运算的FFM模型是非常有利的。 除了上面的技巧之外,FFM的实现中还有很多调优技巧需要探索。例如,代码是按field和特征的编号申请参数空间的,如果选取了非连续或过大的编号,就会造成大量的内存浪费;在每个样本中加入值为1的新特征,相当于引入了因子化的一次项,避免了缺少一次项带来的模型偏差等。\n适用范围和使用技巧 在FFM原论文中,作者指出,FFM模型对于one-hot后类别特征十分有效,但是如果数据不够稀疏,可能相比其它模型提升没有稀疏的时候那么大,此外,对于数值型的数据效果不是特别的好。\n在Github上有FFM的开源实现,要使用FFM模型,特征需要转化为“field_id:feature_id:value”格式,相比LibSVM的格式多了field_id,即特征所属的field的编号,feature_id是特征编号,value为特征的值。\n此外,美团点评的文章中,提到了训练FFM时的一些注意事项:\n第一,样本归一化。FFM默认是进行样本数据的归一化的 。若不进行归一化,很容易造成数据inf溢出,进而引起梯度计算的nan错误。因此,样本层面的数据是推荐进行归一化的。\n第二,特征归一化。CTR/CVR模型采用了多种类型的源特征,包括数值型和categorical类型等。但是,categorical类编码后的特征取值只有0或1,较大的数值型特征会造成样本归一化后categorical类生成特征的值非常小,没有区分性。例如,一条用户-商品记录,用户为“男”性,商品的销量是5000个(假设其它特征的值为零),那么归一化后特征“sex=male”(性别为男)的值略小于0.0002,而“volume”(销量)的值近似为1。特征“sex=male”在这个样本中的作用几乎可以忽略不计,这是相当不合理的。因此,将源数值型特征的值归一化到[0,1]是非常必要的。\n第三,省略零值特征。从FFM模型的表达式(3-1)可以看出,零值特征对模型完全没有贡献。包含零值特征的一次项和组合项均为零,对于训练模型参数或者目标值预估是没有作用的。因此,可以省去零值特征,提高FFM模型训练和预测的速度,这也是稀疏样本采用FFM的显著优势。\n参考: https://www.hrwhisper.me/machine-learning-fm-ffm-deepfm-deepffm/\n","permalink":"https://reid00.github.io/en/posts/ml/fm-ffm-deepfm/","summary":"介绍 FM和FMM模型在数据量比较大并且特征稀疏的情况下,仍然有优秀的性能表现,在CTR/CVR任务上尤其突出。 本文包括: - FM 模型 - FFM 模型 - Deep","title":"FM FFM DeepFM"},{"content":"前言 先来看看一则小故事\n我们写好的一行行代码,为了让其工作起来,我们还得把它送进城(进程)里,那既然进了城里,那肯定不能胡作非为了。\n城里人有城里人的规矩,城中有个专门管辖你们的城管(操作系统),人家让你休息就休息,让你工作就工作,毕竟摊位(CPU)就一个,每个人都要占这个摊位来工作,城里要工作的人多着去了。\n所以城管为了公平起见,它使用一种策略(调度)方式,给每个人一个固定的工作时间(时间片),时间到了就会通知你去休息而换另外一个人上场工作。\n另外,在休息时候你也不能偷懒,要记住工作到哪了,不然下次到你工作了,你忘记工作到哪了,那还怎么继续?\n有的人,可能还进入了县城(线程)工作,这里相对轻松一些,在休息的时候,要记住的东西相对较少,而且还能共享城里的资源。\n“哎哟,难道本文内容是进程和线程?”\n可以,聪明的你猜出来了,也不枉费我瞎编乱造的故事了。\n进程和线程对于写代码的我们,真的天天见、日日见了,但见的多不代表你就熟悉它们,比如简单问你一句,你知道它们的工作原理和区别吗?\n不知道没关系,今天就要跟大家讨论操作系统的进程和线程。\n提纲\n正文 进程 我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」。\n现在我们考虑有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻傻的等硬盘返回数据的话,那 CPU 的利用率是非常低的。\n做个类比,你去煮开水时,你会傻傻的等水壶烧开吗?很明显,小孩也不会傻等。我们可以在水壶烧开之前去做其他事情。当水壶烧开了,我们自然就会听到“嘀嘀嘀”的声音,于是再把烧开的水倒入到水杯里就好了。\n所以,当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个中断,于是 CPU 再继续运行这个进程。\n进程 1 与进程 2 切换\n这种多个程序、交替执行的思想,就有 CPU 管理多个进程的初步想法。\n对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。\n虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。\n并发和并行有什么区别?\n一图胜千言。\n并发与并行\n进程与程序的关系的类比\n到了晚饭时间,一对小情侣肚子都咕咕叫了,于是男生见机行事,就想给女生做晚饭,所以他就在网上找了辣子鸡的菜谱,接着买了一些鸡肉、辣椒、香料等材料,然后边看边学边做这道菜。\n突然,女生说她想喝可乐,那么男生只好把做菜的事情暂停一下,并在手机菜谱标记做到哪一个步骤,把状态信息记录了下来。\n然后男生听从女生的指令,跑去下楼买了一瓶冰可乐后,又回到厨房继续做菜。\n这体现了,CPU 可以从一个进程(做菜)切换到另外一个进程(买可乐),在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。\n所以,可以发现进程有着「运行 - 暂停 - 运行」的活动规律。\n进程的状态 在上面,我们知道了进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。\n它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。\n所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。\n进程的三种基本状态\n上图中各个状态的意义:\n运行状态(Runing):该时刻进程占用 CPU; 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止; 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行; 当然,进程另外两个基本状态:\n创建状态(new):进程正在被创建时的状态; 结束状态(Exit):进程正在从系统中消失时的状态; 于是,一个完整的进程状态的变迁如下图:\n进程五种状态的变迁\n再来详细说明一下进程的状态变迁:\nNULL -\u0026gt; 创建状态:一个新进程被创建时的第一个状态; 创建状态 -\u0026gt; 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的; 就绪态 -\u0026gt; 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程; 运行状态 -\u0026gt; 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理; 运行状态 -\u0026gt; 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行; 运行状态 -\u0026gt; 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件; 阻塞状态 -\u0026gt; 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态; 另外,还有一个状态叫挂起状态,它表示进程没有占有物理内存空间。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。\n由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。\n虚拟内存管理-换入换出\n挂起状态可以分为两种:\n阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现; 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行; 这两种挂起状态加上前面的五种状态,就变成了七种状态变迁(留给我的颜色不多了),见如下图:\n七种状态变迁\n进程的控制结构 在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。\n那 PCB 是什么呢?打开知乎搜索你就会发现这个东西并不是那么简单。\n知乎搜 PCB 的提示\n打住打住,我们是个正经的人,怎么会去看那些问题呢?是吧,回来回来。\nPCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。\nPCB 具体包含什么信息呢?\n进程描述信息:\n进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符; 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务; 进程控制和管理信息:\n进程当前状态,如 new、ready、running、waiting 或 blocked 等; 进程优先级:进程抢占 CPU 时的优先级; 资源分配清单:\n有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。 CPU 相关信息:\nCPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。 可见,PCB 包含信息还是比较多的。\n每个 PCB 是如何组织的呢?\n通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:\n将所有处于就绪状态的进程链在一起,称为就绪队列; 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列; 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。 那么,就绪队列和阻塞队列链表的组织形式如下图:\n就绪队列和阻塞队列\n除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。\n一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。\n进程的控制 我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的创建、终止、阻塞、唤醒的过程,这些过程也就是进程的控制。\n01 创建进程\n操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源,当子进程被终止时,其在父进程处继承的资源应当还给父进程。同时,终止父进程时同时也会终止其所有的子进程。\n创建进程的过程如下:\n为新进程分配一个唯一的进程标识号,并申请一个空白的 PCB,PCB 是有限的,若申请失败则创建失败; 为进程分配资源,此处如果资源不足,进程就会进入等待状态,以等待资源; 初始化 PCB; 如果进程的调度队列能够接纳新进程,那就将进程插入到就绪队列,等待被调度运行; 02 终止进程\n进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。\n终止进程的过程如下:\n查找需要终止的进程的 PCB; 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程; 如果其还有子进程,则应将其所有子进程终止; 将该进程所拥有的全部资源都归还给父进程或操作系统; 将其从 PCB 所在队列中删除; 03 阻塞进程\n当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。\n阻塞进程的过程如下:\n找到将要被阻塞进程标识号对应的 PCB; 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行; 将该 PCB 插入的阻塞队列中去; 04 唤醒进程\n进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。\n如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。\n唤醒进程的过程如下:\n在该事件的阻塞队列中找到相应进程的 PCB; 将其从阻塞队列中移出,并置其状态为就绪状态; 把该 PCB 插入到就绪队列中,等待调度程序调度; 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。\n进程的上下文切换 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。\n在详细说进程上下文切换前,我们先来看看 CPU 上下文切换\n大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。\n任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。\n所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。\nCPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快的多。\n再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。\n所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。\n既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。\nCPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。\n系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。\n上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。\n进程的上下文切换到底是切换什么呢?\n进程是由内核管理和调度的,所以进程的切换只能发生在内核态。\n所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。\n通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:\n进程上下文切换\n大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。\n发生进程上下文切换有哪些场景?\n为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行; 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行; 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度; 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行; 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序; 以上,就是发生进程上下文切换的常见场景了。\n线程 在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。\n为什么使用线程? 我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:\n从视频文件当中读取数据; 对读取的数据进行解压缩; 把解压缩后的视频数据播放出来; 对于单进程的实现方式,我想大家都会是以下这个方式:\n单进程实现方式\n对于单进程的这种方式,存在以下问题:\n播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,Read 的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放; 各个函数之间不是并发执行,影响资源的使用效率; 那改进成多进程的方式:\n多进程实现方式\n对于多进程的这种方式,依然会存在问题:\n进程之间如何通信,共享数据? 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息; 那到底如何解决呢?需要有一种新的实体,满足以下特性:\n实体之间可以并发运行; 实体之间共享相同的地址空间; 这个新的实体,就是线程( *Thread* ),线程之间可以并发运行且共享相同的地址空间。\n什么是线程? 线程是进程当中的一条执行流程。\n同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。\n多线程\n线程的优缺点?\n线程的优点:\n一个进程中可以同时存在多个线程; 各个线程之间可以并发执行; 各个线程之间可以共享地址空间和文件等资源; 线程的缺点:\n当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃。 举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程。\n线程与进程的比较 线程与进程的比较如下:\n进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位; 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈; 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系; 线程能减少并发执行的时间和空间开销; 对于,线程相比进程能减少开销,体现在:\n线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们; 线程的终止时间比进程快,因为线程释放的资源相比进程少很多; 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的; 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了; 所以,线程比进程不管是时间效率,还是空间效率都要高。\n线程的上下文切换 在前面我们知道了,线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。\n所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。\n对于线程和进程,我们可以这么理解:\n当进程只有一个线程时,可以认为进程就等于线程; 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的; 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。\n线程上下文切换的是什么?\n这还得看线程是不是属于同一个进程:\n当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样; 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据; 所以,线程的上下文切换相比进程,开销要小很多。\n线程的实现 主要有三种线程的实现方式:\n用户线程(*User Thread*):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理; 内核线程(*Kernel Thread*):在内核中实现的线程,是由内核管理的线程; 轻量级进程(*LightWeight Process*):在内核中来支持用户线程; 那么,这还需要考虑一个问题,用户线程和内核线程的对应关系。\n首先,第一种关系是多对一的关系,也就是多个用户线程对应同一个内核线程:\n多对一\n第二种是一对一的关系,也就是一个用户线程对应一个内核线程:\n一对一\n第三种是多对多的关系,也就是多个用户线程对应到多个内核线程:\n多对多\n用户线程如何理解?存在什么优势和缺陷?\n用户线程是基于用户态的线程管理库来实现的,那么线程控制块(*Thread Control Block, TCB*) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。\n所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。\n用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程,如下图所示:\n用户级线程模型\n用户线程的优点:\n每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统; 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快; 用户线程的缺点:\n由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢; 以上,就是用户线程的优缺点了。\n那内核线程如何理解?存在什么优势和缺陷?\n内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。\n内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程,如下图所示:\n内核线程模型\n内核线程的优点:\n在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行; 分配给线程,多线程的进程获得更多的 CPU 运行时间; 内核线程的缺点:\n在支持内核线程的操作系统中,由内核来维护进程和线程的上下问信息,如 PCB 和 TCB; 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大; 以上,就是内核线的优缺点了。\n最后的轻量级进程如何理解?\n轻量级进程(*Light-weight process,LWP*)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。\n另外,LWP 只能由内核管理并像普通进程一样被调度,Linux 内核是支持 LWP 的典型例子。\n在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。\n在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:\n1 : 1,即一个 LWP 对应 一个用户线程; N : 1,即一个 LWP 对应多个用户线程; N : N,即多个 LMP 对应多个用户线程; 接下来针对上面这三种对应关系说明它们优缺点。先下图的 LWP 模型:\nLWP 模型\n1 : 1 模式\n一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。\n优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP; 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。 N : 1 模式\n多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。\n优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高; 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。 M : N 模式\n根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。\n优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。 组合模式\n如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。\n调度 进程都希望自己能够占用 CPU 进行工作,那么这涉及到前面说过的进程上下文切换。\n一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。\n选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序(scheduler)。\n那到底什么时候调度进程,或以什么原则来调度进程呢?\n调度时机 在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。\n比如,以下状态的变化都会触发操作系统的调度:\n从就绪态 -\u0026gt; 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行; 从运行态 -\u0026gt; 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须另外一个进程运行; 从运行态 -\u0026gt; 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行; 因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。\n另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:\n非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制。 调度原则 原则一:如果运行的程序,发生了 I/O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回。这样的过程,势必会造成 CPU 突然的空闲。所以,为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。\n原则二:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低。所以,要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。\n原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。\n原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。\n原则五:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。\n五种调度原则\n针对上面的五种调度原则,总结成如下:\nCPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率; 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量; 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好; 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意; 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。 说白了,这么多调度原则,目的就是要使得进程要「快」。\n调度算法 不同的调度算法适用的场景也是不同的。\n接下来,说说在单核 CPU 系统中常见的调度算法。\n01 先来先服务调度算法\n最简单的一个调度算法,就是非抢占式的先来先服务(*First Come First Severd, FCFS*)算法了。\nFCFS 调度算法\n顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。\n这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。\nFCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。\n02 最短作业优先调度算法\n最短作业优先(*Shortest Job First, SJF*)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。\nSJF 调度算法\n这显然对长作业不利,很容易造成一种极端现象。\n比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。\n03 高响应比优先调度算法\n前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。\n那么,高响应比优先 (*Highest Response Ratio Next, HRRN*)调度算法主要是权衡了短作业和长作业。\n每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:\n从上面的公式,可以发现:\n如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行; 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会; 04 时间片轮转调度算法\n最古老、最简单、最公平且使用最广的算法就是时间片轮转(*Round Robin, RR*)调度算法。 。\nRR 调度算法\n每个进程被分配一个时间段,称为时间片(*Quantum*),即允许该进程在该时间段中运行。\n如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程; 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换; 另外,时间片的长度就是一个很关键的点:\n如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率; 如果设得太长又可能引起对短作业进程的响应时间变长。将 通常时间片设为 20ms~50ms 通常是一个比较合理的折中值。\n05 最高优先级调度算法\n前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。\n但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法。\n进程的优先级可以分为,静态优先级或动态优先级:\n静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化; 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。 该算法也有两种处理优先级高的方法,非抢占式和抢占式:\n非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。 但是依然有缺点,可能会导致低优先级的进程永远不会运行。\n06 多级反馈队列调度算法\n多级反馈队列(*Multilevel Feedback Queue*)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。\n顾名思义:\n「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列; 多级反馈队列\n来看看,它是如何工作的:\n设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短; 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成; 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行; 可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。\n看的迷迷糊糊?那我拿去银行办业务的例子,把上面的调度算法串起来,你还不懂,你锤我!\n办理业务的客户相当于进程,银行窗口工作人员相当于 CPU。\n现在,假设这个银行只有一个窗口(单核 CPU ),那么工作人员一次只能处理一个业务。\n银行办业务\n那么最简单的处理方式,就是先来的先处理,后面来的就乖乖排队,这就是先来先服务(*FCFS*)调度算法。但是万一先来的这位老哥是来贷款的,这一谈就好几个小时,一直占用着窗口,这样后面的人只能干等,或许后面的人只是想简单的取个钱,几分钟就能搞定,却因为前面老哥办长业务而要等几个小时,你说气不气人?\n先来先服务\n有客户抱怨了,那我们就要改进,我们干脆优先给那些几分钟就能搞定的人办理业务,这就是短作业优先(*SJF*)调度算法。听起来不错,但是依然还是有个极端情况,万一办理短业务的人非常的多,这会导致长业务的人一直得不到服务,万一这个长业务是个大客户,那不就捡了芝麻丢了西瓜\n最短作业优先\n那就公平起见,现在窗口工作人员规定,每个人我只处理 10 分钟。如果 10 分钟之内处理完,就马上换下一个人。如果没处理完,依然换下一个人,但是客户自己得记住办理到哪个步骤了。这个也就是时间片轮转(*RR*)调度算法。但是如果时间片设置过短,那么就会造成大量的上下文切换,增大了系统开销。如果时间片过长,相当于退化成退化成 FCFS 算法了。\n时间片轮转\n既然公平也可能存在问题,那银行就对客户分等级,分为普通客户、VIP 客户、SVIP 客户。只要高优先级的客户一来,就第一时间处理这个客户,这就是最高优先级(*HPF*)调度算法。但依然也会有极端的问题,万一当天来的全是高级客户,那普通客户不是没有被服务的机会,不把普通客户当人是吗?那我们把优先级改成动态的,如果客户办理业务时间增加,则降低其优先级,如果客户等待时间增加,则升高其优先级。\n最高优先级(静态)\n那有没有兼顾到公平和效率的方式呢?这里介绍一种算法,考虑的还算充分的,多级反馈队列(*MFQ*)调度算法,它是时间片轮转算法和优先级算法的综合和发展。它的工作方式:\n多级反馈队列\n银行设置了多个排队(就绪)队列,每个队列都有不同的优先级,各个队列优先级从高到低,同时每个队列执行时间片的长度也不同,优先级越高的时间片越短。 新客户(进程)来了,先进入第一级队列的末尾,按先来先服务原则排队等待被叫号(运行)。如果时间片用完客户的业务还没办理完成,则让客户进入到下一级队列的末尾,以此类推,直至客户业务办理完成。 当第一级队列没人排队时,就会叫号二级队列的客户。如果客户办理业务过程中,有新的客户加入到较高优先级的队列,那么此时办理中的客户需要停止办理,回到原队列的末尾等待再次叫号,因为要把窗口让给刚进入较高优先级队列的客户。 可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端的现象,可以说是综合上面几种算法的优点。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E8%BF%9B%E7%A8%8B%E4%B8%8E%E7%BA%BF%E7%A8%8B%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/","summary":"前言 先来看看一则小故事 我们写好的一行行代码,为了让其工作起来,我们还得把它送进城(进程)里,那既然进了城里,那肯定不能胡作非为了。 城里人有城","title":"进程与线程基础知识"},{"content":"介绍 什么是高并发,从字面上理解,就是在某一时刻产生大量的请求,那么多少量称为大量,业界并没有标准的衡量范围。原因非常简单,不同的业务处理复杂度不一样。\n而我所理解的高并发,它并不只是一个数字,而更是一种架构思维模式,它让你在面对不同的复杂情况下,从容地选择不同的技术手段,来提升应用系统的处理能力。\n但是,并不意味应用系统从诞生的那一刻,就需要具备强大的处理能力,这种做法并不提倡。要知道,脱离实际情况的技术,会显得毫无价值,甚至是一种浪费的表现。\n言归正传,那高并发到底是一种怎样的架构思维模式,它对架构设计又有什么影响,以及如何通过它来驱动架构演进,让我们接着往下读,慢慢去体会这其中的精髓。\n性能是一种基础 在架构设计的过程中,思考固然重要,但目标更为关键。通过目标的牵引力,可以始终确保推进方向,不会脱离成功的轨道。那高并发的目标是什么,估计你的第一反应就是性能。\n没错,性能是高并发的目标之一,它不可或缺,但并不代表所有。而我将它视为是高并发的一种基础能力,它的能力高低将会直接影响到其他能力的取舍。例如:服务可用性,数据一致性等。\n性能在软件研发过程中无处不在,不管是在非功能性需求中,还是在性能测试报告中,都能见到它的身影。那么如何来衡量它的高低呢,先来看看常用的性能指标。\n每秒处理事务数(TPS) 每秒能够处理的事务数,其中T(Transactions)可以定义不同的含义,它可以是完整的一笔业务,也可以是单个的接口请求。\n每秒请求数(RPS) 每秒请求数量,也可以叫做QPS,但它与TPS有所不同,前者注重请求能力,后者注重处理能力。不过,若所有请求都在得到响应后再次发起,那么RPS基本等于TPS。\n响应时长(RT) 从发出请求到得到响应的耗时,一般可以采用毫秒单位来表示,而在一些对RT比较敏感的业务场景下,可以使用精度更高的微秒来表示。\n并发用户数(VU) 同时请求的用户数,很多人将它与并发数画上等号,但两者稍有不同,前者关注客户端,后者关注服务端,除非每个用户仅发送一笔请求,且请求从客户端到服务端没有延迟,同时服务端有足够的处理线程。\n以上都是些常用的性能指标,基本可以覆盖80%以上的性能衡量要求。但千万不要以单个指标的高低来衡量性能。比如:订单查询TPS=100万就认为性能很高,但RT=10秒。\n这显然毫无意义。因此,建议同时观察多个指标的方式来衡量性能的高低,大多数情况下主要会关注TPS和RT,而你可以将TPS视为一种水平能力,注重并行处理能力,将RT视为一种垂直能力,注重单笔处理能力,两者缺一不可。\n接触过性能测试的同学,可能会见过如下这种性能测试结果图,图中包含了刚才提到过的三个性能指标,其中横坐标为VU,纵坐标分别为TPS和RT。 图中的两条曲线,在不断增加VU的情况下,TPS不断上升,但RT保持稳定,但当VU增加到一定量级的时候,TPS开始趋于稳定,而RT不断上升。\n如果你仔细观察,还会发现一个奇妙的地方,当RT=25ms时,它们三者存在着某种关系,即:TPS=VU/RT。但当RT\u0026gt;25ms时,这种关系似乎被打破了,这里暂时先卖个关子,稍后再说。\n根据表格中的数据,性能测试报告结论:最大TPS=65000,当RT=25ms(最短)时,最大可承受VU=1500。\n感觉有点不对劲,用刚才的公式来验证一下,1500/0.025s=60000,但最大却是TPS=65000。那是因为,当VU=1500时,应用系统的使用资源还有空间。\n再来观察一下表格中的数据,VU从1500增加到1750时,TPS继续上升,且到了最大值65000。此时,你是不是会理解为当VU增加到1750时,使用资源被耗尽了。话虽没错,但不严谨。\n注:使用资源不一定是指硬件资源,也可能是其他方面,例如:应用系统设置的最大处理线程。\n其实在VU增加到1750前,使用资源就已饱和,那如何来测算VU的临界值呢。你可以将最大TPS作为已知条件,即:VU=TPS * RT,65000*0.025s=1625。也就是说,当VU=1625时,使用资源将出现瓶颈。\n调整性能测试报告结论:最大TPS=65000,当RT=25ms(最短)时,最大可承受VU=1625。\n有人会问,表格中的RT是不是平均值,首先回答为是。不过,高并发场景对RT会特别敏感,所以除了要考虑RT的平均值外,建议还要考虑它的分位值,例如:P99。\n举例:假设1000笔请求,其中900笔RT=23ms,50笔RT=36ms,50笔RT=50ms\n平均值 P99值 P95 P90 25ms 50ms 36ms 23ms P99的计算方式,是将1000笔请求的RT从小到大进行排序,然后取排在第99%位的数值,基于以上举例数据来进行计算,P99=50ms,其他分位值的计算方式类似。\n再次调整性能测试报告结论:最大TPS=65000,当RT(平均)=25ms(最短)时,最大可承受VU=1625,RT(P99)=50ms,RT(P95)=36ms,RT(P90)=23ms。\n在非功能性需求中,你可能会看到这样的需求,性能指标要求:RT(平均)\u0026lt;=30。结合刚才的性能测试报告结论,当RT(平均)=25ms(最短)时,最大可承受VU=1625。那就等于在RT上还有5ms的容忍时间。\n既然是这样的话,那我们不妨就继续尝试增加VU,不过RT(平均)会出现上升,但只要控制不要上升到30ms即可,这是一种通过牺牲耗时(RT)来换取并发用户数(VU)的行为。但请不要把它理解为每笔请求耗时都会上升5ms,这将是一个严重的误区。\nRT(平均)的增加,完全可能是由于应用系统当前没有足够的使用资源来处理请求所造成的,例如:处理线程。如果没有可用线程可以分配给请求时,就会将这请求先放入队列,等前面的请求处理完成并释放线程后,就可以继续处理队列中的请求了。\n那也就是说,没有进入队列的请求并不会增加额外的耗时,而只有进入队列的请求会增加。那么进入队列的请求会增加多少耗时呢,在理想情况下(RT恒定),可能会是正常处理一笔请求耗时的倍数,而倍数的大小又取决于并发请求的数量。\n假设最大处理线程=1625,若每个用户仅发送一笔请求,且请求从客户端到服务端没有延迟的条件下,当并发用户数=1625时,能够保证RT=25ms,但当并发用户数\u0026gt;1625时,因为线程只能分配给1625笔请求,那多余的请求就无法保证RT=25ms。\n超过1625笔的请求会先放入队列,等前面1625笔请求处理完成后,再从队列中拿出最多1625笔请求进行下一批处理,如果队列中还有剩余请求,那就继续按照这种方式循环处理。\n进入队列的请求,每等待一批就需要增加前一批的处理耗时。在理想情况下,每一批都是RT=25ms,如果这笔请求在队列中等待了两批,那就要额外增加50ms的耗时。\n因此,并不能简单通过VU=TPS* RT=65000*0.03=1950来计算最大可承受VU。而是需要引入一种叫做科特尔法则(Little’s Law)的排队模型来估算,不过由于这个法则比较复杂,这里暂时不做展开。\n通过粗略估算后,VU大约在2032,我们再对这个值用上述表格中再反向验算一下。 最终调整性能测试报告结论:最大TPS=65000,当RT(平均)=25(最短)时,最大可承受VU=1625,RT(P99)=50,RT(P95)=36,RT(P90)=23;当RT(平均)=30(容忍)时,(理想情况)最大可承受VU=2032,RT(P99)=RT(P95)=50,RT(P90)=25。\n这就解释了为什么当RT\u0026gt;25ms时,VU=TPS*RT会不成立的原因。不过,这些都是在理想情况下推演出来的,实际情况会比这要复杂得多。\n所以,还是尽量采用多轮性能测试来得到性能指标,这样也更具备真实性。毕竟影响性能的因素实在大多且很难完全掌控,任何细微变化都将影响性能指标的变化。\n到这里,我们已经了解了可以用哪些指标来衡量性能的高低。不过,这里更想强调的是,性能是高并发的基础能力,是实现高并发的基础条件,并且你需要有侧重性地提升不同维度的性能指标,而非仅关注某一项。\n限制是一种设计 上文说到,性能是高并发的目标之一。追求性能没有错,但并非永无止境。想要提升性能,势必投入成本,不过它们并不是一直成正比,而是随着成本不断增加,性能提升幅度逐渐衰减,甚至可能不再提升。所以,有时间我们要懂得适可而止。\n思考一下,追求性能是为了解决什么问题,至少有一点,是为了让应用系统能够应对突发请求。换言之,如果能解决这个问题,是不是也算实现了高并发的目标。\n而有时候,我们在解决问题时,不要总是习惯做加法,还可以尝试做减法,架构设计同样如此。那么,如何通过做减法的方式,来解决应对突发请求的问题呢。让我们来讲讲限制。\n限制,从狭义上可以理解为是一种约束或控制能力。在软件领域中,它可以针对功能性或非功能性,而在高并发的场景中,它更偏向于非功能性。\n限制应用系统的处理能力,并不代表要降低应用系统的处理能力,而是通过某些控制手段,让突发请求能够被平滑地处理,同时起到应用系统的保护能力,避免瘫痪,还能将应用系统的资源进行合理分配,避免浪费。\n那么,到底有哪些控制手段,既能实现以上这些能力,又能减少对客户体验上的影响,下面就来介绍几种常用的控制手段。\n第一招:限流 限流,是在一个时间窗口内,对请求进行速率控制。若请求达到提前设定的阈值时,则对请求进行排队或拒绝。常用的限流算法有两种:漏桶算法和令牌桶算法。\n漏桶算法 所有请求先进入漏桶,然后按照一个恒定的速率对漏桶里的请求进行处理,是一种控制处理速率的限流方式,用于平滑突发请求速率。\n它的优点是,能够确保资源不会瞬间耗尽,避免请求处理发生阻塞现象,另外,还能够保护被应用系统所调用的外部服务,也免受突发请求的冲击。\n它的缺点是,对于突发请求仍然会以一个恒定的速率来进行处理,其灵活性会较弱一点,容易发生突发请求超过漏桶的容量,导致后续请求直接被丢弃。\n令牌桶算法 应用系统会以一个恒定的速率往桶里放入令牌,请求处理前,会从桶里获取令牌,当桶里没有令牌可取时,则拒绝服务,是一种平均流入速率的限流方式。\n它的优点是,在限制平均流入速率的同时,还能在面对突发请求的情况下,确保资源被充分利用,不会被闲置或浪费。\n它的缺点是,舍弃了处理速率的强控制能力,那么如果某些功能依赖外部服务,可能将会让外部服务无法承受压力,导致无法正常返回,而且还浪费了这次获取的令牌。\n综上,两种算法并没有绝对的好坏,而是需要根据实际的情况,选择合适的方式,从而在发挥限流作用的同时不会引发其他问题。但在一些秒杀活动中,软件党的高频请求,会很容易触发限流,导致大量正常请求被误杀的问题。\n虽然在请求被限流后,会返回友好话术,减轻对客户体验的影响,但也有可能他们的请求,会一直无法得到有效处理,这时候耐心再好的客户也会离开及抱怨。\n所以,我们除了使用限流这招外,还得搭配其他的招数组合一起使用,从而让应用系统能够对资源进行合理分配,避免资源浪费,减少正常请求被误杀的情况。\n第二招:降频 降频,是在一个时间窗口内,对同一特征的请求进行速率控制。若请求达到提前设定的阈值时,则会对请求进行拒绝。\n虽然和限流有点类似,但存在着细微的差别。对限流而言,它并不关心请求方,而只对服务端的速率进行控制,而对降频而言,它会基于某种特征,对请求方的请求速率进行控制。\n而降频的目的,是为了减少应用系统资源被不正常的请求所消耗,而导致正常的请求因限流被拒绝的情况发生。它的实现方式也有多种,而且在前端和后端都可以使用。\n识别不正常的请求是降频的第一步,也是最关键的一步。一般会制定某种特征+某段时间+请求数量这种三段式的识别规则。\n特征可以是账号、会话、IP地址、设备号等,时间一般会是1秒,也可以设置更长。账号+1秒+5笔,意思就是同一个账号在1秒内可以发生5笔请求,但是这里请求数量与限流的设定参考依据不同。\n限流大小主要依据性能来决定,而降频中的请求数量,一般会以正常人的交互速率作为参考。所以,并不能因为性能好,就设定账号+1秒+100笔这种识别规则,这不但不科学还会浪费资源。\n接下来,有了识别规则还得搭配对应的处置手段,常见的有两种模式:挑战和拒绝。 限流会发生误杀,难道降频就不会吗,其实也会发生,特别是用户的网络环境是一个出口IP地址时。所以,如果是基于IP地址特征的识别规则,请求数量建议适当放大。\n在降频策略方面,建议配置多层+渐进式的方式,识别规则较为严格的采用挑战模式,识别规则较为宽松的采用拒绝模式,减少因降频而引发的误杀情况,参考如下: 降频确实可以使应用系统的资源,被合理地分配给请求方,但并不能保证万无一失,特别对于那些技术高超的软件党们,他们仍然可以通过其他方式绕开这种控制手段。\n不过,你可以将此视为一种攻防战,通过增强防守的方式,来提高攻击者成本,而攻击者一定会权衡成本和收益,当成本大于收益时,可能就不会有攻击,毕竟没有人会这么无聊透顶。\n虽然有了限流和降频这两招,但仍可能无法应对高并发的场景,况且在初期,限流和降频的策略,也无法设计得非常完美。所以,有些时候还得使出最后一招。\n第三招:降级 降级,是当应用系统处理超载时,对其服务进行裁剪的一种机制。常见的是应用系统处理阻塞时,会关闭非核心服务,并将资源给到核心服务,从而确保核心服务正常。\n经常有人将它与熔断混为一谈,但并非一回事。降级主要是针对应用系统本身,若处理能力不足则可触发,而熔断主要是针对应用系统所调用的外部服务,若外部服务不稳定时则可触发。\n当然,两者也有一定的关系,因为当发生熔断时,也可以触发降级机制,比如当同步调用外部服务出现性能问题时,可以降级为异步调用,避免造成线程阻塞而瘫痪。\n不过在降级前,必须得先梳理应用系统中的核心服务,可以采用经典的二八原则,将服务划分为20%核心服务+80%非核心服务。而这种分法的意图,是希望让你找到真正重要的核心服务,不然,你会觉得都很重要。\n在梳理过程中,建议通过多个维度来进行综合评判,如下是我经常采用的一种梳理方法,你可以将此作为一种参考,并结合自己的服务分类标准进行调整。\n首先,可以设计一张类似如下的矩阵图,请尽量地简约它,将应用系统中的各类服务,按照矩阵所设定的不同属性进行分门别类。 然后,将业务类+操作类的挑选出来作为核心服务,你会不会认为这就结束了。不好意思,游戏才刚刚开始。不过你可以试想一下,假设仅保留这些核心服务,会出现什么问题。\n用户登录不了无法订单支付,订单查询不了无法订单退货。所以,我们还需引入服务关键路径的概念,可以理解为在使用某个服务前,还必须要使用的其他服务。\n分别对挑选出来的核心服务,进行服务关键路径的梳理。 路径1:用户登录——\u0026gt;商品查询——\u0026gt;订单下单 路径2:用户登录——\u0026gt;商品查询——\u0026gt;订单下单——\u0026gt;订单支付 路径3:用户登录——\u0026gt;订单查询——\u0026gt;订单退货\n待服务关键路径梳理完成后,再对路径上的所有服务进行合并及去重,将会得到一组新的核心服务:用户登录/商品查询/订单下单/订单支付/订单查询/订单退货。\n来计算下核心服务的占比,所有服务14个/核心服务6个,占42.86%,远远超过了20%。所以,建议继续从这些核心服务中,识别更核心的部分,但仍然以服务关键路径为整体。\n相比订单下单/订单支付/订单退货这三条服务关键路径,我想订单支付可能会更有价值。最后,我们可以仅将订单支付这条服务关键路径上的服务作为核心服务。\n重新再来计算下核心服务的占比,所有服务14个/核心服务4个,占28.57%,虽然还是超过了20%,但这并不是重点,重点是我们已经找到了最核心的服务。\n其余的核心服务,可以降级为准核心服务,重组后得到如下这份服务重要程度清单。 当拥有这份清单后,若应用系统处理阻塞时,就可以按照非核心服务\u0026gt;准核心服务\u0026gt;核心服务这个顺序依次进行降级。不过,降级不一定要拒绝请求,也可以是限流请求,这样可以减少对服务能力的裁剪力度。\n以上只是一种相对较粗的降级策略,如果你想要制定更精细化的降级策略,还需要对每个服务进行优先级的设定,高低依据可以结合自身需要来制定,例如:历史服务使用情况。\n当有了限流、降频、降级这三招,基本就能够在资源有限的情况下,让突发请求能够被平滑地处理,将应用系统的资源能够被合理地分配,以及当应用系统处理堵塞时,确保核心服务正常。\n取舍是一种权衡 现在,我们已经基本了解了如何衡量性能的指标,以及大致掌握了如何保护应用系统的招数。但这些就是高并发全部了吗,我想说这仅仅只是入门级别。\n上述内容,主要是为了让你能够清晰地看到应用系统的性能水位在哪里,以及在资源有限下,当面对突发请求时可以采取哪些招数,能让应用系统安全地存活下来。\n存活,即代表着可用性,它也是高并发的一个特性,而且是我认为相对比较重要的特性。设想一下,如果你的应用系统连可用性都无法保证,那再高的性能又有何意义。\n对于大部分应用系统而言,大家都会比较关注应用系统的可用性,99.9%不够那就99.99%,甚至还有想做到99.999%的,毕竟可用性的不足会直接影响到业务运作。\n但对于一个想成为高并发的应用系统而言,仅单方面关注高可用,肯定无法称得上这个头衔,它仍然还需要在其他特性上具备极佳的表现。\n让我们拉回到最初的目标,性能是高并发的目标之一,不过,这里我们不再谈论性能指标,而是来研究如何来提升性能。因为,高性能是高并发的另外一个重要特性。\n想要提升性能,势必投入成本。随着成本不断增加,性能提升幅度逐渐衰减。这两句话,是不是觉得有点耳熟,但不管你是否还记得,先让我在这里打个问号再说。\n在架构设计的过程中,你是否经常会听到“取舍”的这个词,它是通过牺牲一种能力来换取另外一种能力的方式,这些能力可以是性能、可用性、数据一致性或是其他能力。\n等等,你是不是突然有意识到,提升性能并不只有投入成本这种方式,至少是在硬件资源方面,我们还可以通过牺牲一种能力去换取。那到底选择牺牲哪种能力呢,牺牲可用性,一般不会第一时间考虑,那是不是可以考虑牺牲数据一致性。\n但在考虑前得先声明一下,所谓牺牲数据一致性,并不是完全不要,而是将数据强一致性降级为最终一致性。而对于数据最终一致性的理解,就是在数据更新后,要过一段时间后才能看到,而时间的长短就代表着牺牲了多少。\n但并不是说,所有情况都必须牺牲数据一致性来提升性能,有些时候也可以考虑牺牲其他能力。但在取舍前,得先弄清楚当前要什么,但更需要弄清楚当前可以失去什么,不合理的取舍,不但无法换取收益,反而还会引来更多的问题。\n情况1:数据缓存 缓存,是高并发架构设计中一种不可或缺的能力,一般是指那些经常被访问的热点数据,可以将它放入缓存中,从而提升数据被读取的效率。\n但是否所有的数据都适合放入缓存中,如果是静态数据,那么你可以很安心地放入。原因很简单,静态数据不会更新,那么缓存和数据源始终保持一致,而且就算缓存中的数据丢失了,至少还有一份在数据源。\n通过将静态数据缓存,可以很轻易地提升静态数据的访问性能,甚至可能是几十倍的效果。但应用系统中还有大量的动态数据,仅提升静态数据可能对总体的提升并不一定显著。\n你是不是想说,那就把动态数据也放入缓存中不就行了。在下这个决定前,建议你先想一下,动态数据是会更新的,这就意味着动态数据在放入缓存后,当数据源中的数据被更新后,再次访问返回的都是更新前的数据,这种效果你是否可以接受。\n我想应该没有人会接受吧,而你是不是又想说,设置下缓存会过期不就能解决了。没错,但得等过期后才能解决,那还没过期前呢,这种方式只能缓解,但并不能根治,而且还会引入一个新的问题,请问过期设置多久才合适。\n设置缓存5秒钟过期,可能永远无法命中缓存,而且不但没有提升性能,还增加了代码复杂度,有点画蛇添足的感觉。设置缓存5分钟过期,命中缓存的几率可能会提高,但缓存后在5分钟内的如果数据更新,要从缓存开始往后推5分钟才能看到。\n即:第1分钟缓存,第1-6分钟内的任何数据更新,要第6分钟后才能看到。\n所以,如果你无法容忍这种情况,请你不要滥用缓存,虽然性能提高了,但问题可能也出现了。反之,如果你可以容忍这种情况,那就可以这么操作,而至于过期设置多久,可以结合业务场景及使用频率综合来评估,毕竟不同的业务容忍度是不同的。\n对于是否要将动态数据进行缓存,本质上,其实就是一种取舍,是一种性能与数据一致性的权衡,而缓存的过期时长,就像是保持这种平衡的支点,从而让这种牺牲变得更有意义。\n情况2:单机限流 限流,前面已经有介绍过,它有两种常用的限流算法,漏桶算法和令牌桶算法。不过,这两种算法都仅支持单机限流,不支持全局限流。\n单机限流,就是对单节点设定一个限流阈值,如果单节点上的请求到达阈值,则会拒绝请求。例如:限流阈值=每秒100次请求,如果在1秒内单节点上,有第101次的请求则拒绝。\n全局限流,就是对一组节点设定一个限流总阈值,如果这组节点上的汇总请求到达阈值,则会拒绝请求。例如:10个节点的限流总阈值=1000次请求,如果在1秒内这组节点上,汇总有第1001次请求则拒绝,不过单节点上有超过第100次的请求也会接受。\n这么看下来,感觉单机限流控制能力更厉害一点,它能保证单节点的请求不会超过100次。而全局限流在极端情况下,单节点都有可能在1秒内会接受1000次请求。当然,这种情况的可能性比较低,比如在突发请求时,9个节点同时宕机。\n既然如此,那全局限流有存在的意义吗,难道这就是漏桶算法和令牌桶算法都不支持全局限流的原因。全局限流就真的没有存在的意义吗。存在即合理,既然存在,那就一定有它存在的道理。\n换个情况,还是将10个节点为一组,不过这次换成采用单机限流。问题来了,每个节点的限流阈值该如何设定,如果采用平均分配,则限流阈值=每秒100次请求,让我们来测试一下,在1秒内依次发出1000次请求,会发生什么现象。\n结果是在第100次请求后,从第101次到第1000次的请求中,可能有些请求会发生被拒绝的情况,而且请求一会儿成功一会儿拒绝,没有任何规律。原因可能是10个节点请求负载不均所引发的,导致某个节点提前超过了100次请求。\n基于以上情况,最终1000次请求没有全部成功,这种情况等同于降低了应用系统的吞吐能力。而在实际情况中,就算采用轮询的负载算法,请求数不均的可能性仍然还是会存在的。这么一看,单机限流好像也有缺陷。\n估计你已经被我说晕了吧,让我们整体再重新梳理一遍,并对两种不同限流模式的影响进行对比。不过,这次还加上每秒不同的请求数量。 两种限流对比下来,单机限流更强调单机的控制范围,但可能会造成额外的请求拒绝,但对单节点不会造成性能压力,而全局限流更强调整体的控制范围,虽不会造成额外的请求拒绝,但可能会对单节点造成性能压力,引发性能过载。\n除此之外,全局限流还是一种采用中心化的设计思路,因此在网络开销方面,还会产生额外的性能损耗,这种损耗在请求量少的时候估计还可以容忍,但在高并发的情况下可能是场灾难,因为在每次限流判断前,还会产生一次网络开销。\n所以,不能为了想要实现更精准的限流,就盲目地采用全局限流,它将在高并发的情况下损耗更多的性能。而单机限流所额外造成的少量请求拒绝,在某些情况下,可以考虑采用某些技术手段进行补偿。\n不过,不管是单机限流还是全局限流,似乎都和数据一致性没有关系。但事实上,全局限流这种精准限流的方式,也可以视为另一种一致性的表现,而单机限流就是通过对这种一致性的牺牲,来减少性能损耗,何尝不是提升性能的另一种方式。\n以上,只是简单列举了两种不同情况下的取舍,而在高并发架构上,可取舍的地方远不止这些。你得知道,高并发的每一处设计或每一份设计方案的背后,都曾是通过不断地取舍所获得的,而没有取舍的高并发架构决策,将会显得毫无说服力。\n取舍不但可以作为高并发架构决策的有力武器,也将是驱动架构演进最合理的一种方式。但要切记,取舍的方向并不是一成不变的,而是会随着外界环境的变化而变化,它将是一种独特的艺术。\n写在最后 高并发的魅力之处,就在于它没有唯一的答案,而答案是需要我们以不同的业务场景作为线索去不断地寻找,这种寻找的过程也是一种不断思考的过程,这就是我为什么说高并发是一种架构思维模式。\n本文从浅到深依次讲述了性能是实现高并发的基础条件,控制是实现资源最大化利用的方式,以及如何通过取舍来换取当前应用系统更所需的能力,但这些仅仅只是高并发世界里的一个角落。因篇幅有限,今天就暂告一段落。\n最后想说,高并发其实并不可怕,可怕的是你知其然而不知其所以然。对于追求技术的你,需要不断地拓宽你的技术深度与广度,才能更好地掌握高并发,以及运用高并发的思维模式来提升应用系统处理能力。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E9%AB%98%E5%B9%B6%E5%8F%91%E6%9E%B6%E6%9E%84/","summary":"介绍 什么是高并发,从字面上理解,就是在某一时刻产生大量的请求,那么多少量称为大量,业界并没有标准的衡量范围。原因非常简单,不同的业务处理复杂","title":"高并发架构"},{"content":"板瓦工以及产品介绍 该商家隶属于美国IT7公司旗下的一款便宜年付KVM架构的VPS主机商家,从2013年开始推出低价VPS主机配置进入市场,确实受到广大网友的喜欢,且在最近几年开始改变策略,取消低价配置,然后以高配置和优化线路速度。以前我们搬瓦工VPS主机的用户感觉可能并不是特别适应,因为以前喜欢他们是因为便宜,如今价格比较高,但是线路和速度比较好。\n搬瓦工VPS商家支持支付宝、微信、PAYPAL、银联以及信用卡多种付款方式,这个也是很多国内用户选择的原因之一。目前最低配置是年付49.99美元,价格上肯定没有早年便宜,但是性价比在同行中还是具有一定优势的。搬瓦工VPS主机的特点在于线路还是不错的,而且带宽最高10Gbps,支持切换到其他机房,全部是自己操作。我们一定要正规使用。\n**搬瓦工VPS当前库存查看列表:**https://www.laozuo.org/go/bandwagonhost-cart\n搬瓦工vps主机方案分享 CN2 GIA ECOMMERCE(推荐) CPU:2核 内存:1GB 硬盘:20GB SSD 流量:1000GB 端口:2.5Gbps 架构:KVM+KiwiVM面板 IP数:1独立IP 系统:Linux 价格:$65.99/半年(购买) CPU:3核 内存:2GB 硬盘:40GB SSD 流量:2000GB 端口:2.5Gbps 架构:KVM+KiwiVM面板 IP数:1独立IP 系统:Linux 价格:$69.99/季度(购买) 这个配置方案,我们可以看到2.5Gbps带宽起步,最高达到10Gbps,同时我们可以看到一共有7个方案,根据配置不同有区别的。相比一般的配置方案,我们可以看到带宽确实比较高,而且是CN2 GIA优化线路,如果有需要大带宽方案的可以选择,而且这个方案可以切换到其他机房。\n2、KVM普通线路(8机房可切CN2 GT)\nCPU:2核\n内存:1024MB\n硬盘:20GB SSD\n流量:1000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$49.99/年(购买)\nCPU:3核\n内存:2048MB\n硬盘:40GB SSD\n流量:2000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$27.99/季度(购买)\nKVM普通方案有目前最低年付49.99方案,2018年12月份下架原来年付19.99方案。入门VPS可选方案,有8个机房可以切换,可以切换至单程CN2 GT线路。\n3、CN2 GIA优化线路(三网直连双程CN2)\nCPU:1核心\n内存:512MB\n硬盘:10GB SSD\n流量:300GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$39.99/年(限量缺货)\nCPU:2核心\n内存:1024MB\n硬盘:20GB SSD\n流量:1000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$25.99/季度(CN2 GIA)\n目前中美线路中较好的就是CN2 GIA线路机房方案,双程CN2,且三网直连,特价限量方案容易缺货,季付19.99方案好像一直有货。方案购买之后可以切换10个机房,但是一般我们肯定用CN2 GIA,毕竟买它就是追求速度和稳定的。\n4、CN2 GT(单程优化线路CN2)\nCPU:1核心\n内存:512MB\n硬盘:10GB SSD\n流量:500GB\n端口:1Gbps\n架构:KVM\n系统:Linux\nIP数:1独立IP\n价格:$29.99/年(下架缺货)\nCPU:1核心\n内存:1024MB\n硬盘:20GB SSD\n流量:1000GB\n端口:1Gbps\n架构:KVM\n系统:Linux\nIP数:1独立IP\n价格:$49.99/年(CN2 GT)\nCN2 GT线路白天基本上没有多大差别,老左测试后发现晚上相对CN2 GIA还是有点诧异的。采用单程CN2线路,这个方案套餐有9个机房可以切换,不可以切换到CN2 GIA。\n5、香港机房(PCCW)\nCPU:2核\n内存:2GB\n硬盘:40GB\n流量:500GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$89.99/月(香港机房)\nCPU:4核\n内存:4GB\n硬盘:80GB\n流量:1000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$159.99/月(香港机房)\n搬瓦工VPS主机香港机房,不可以切换到其他机房,其他配置也不可以切换到香港机房。线路是PCCW,速度确实还是不错,但是价格成本也高,毕竟是1Gbps带宽,适合预算比较充足的用户和速度用户。\n搬瓦工VPS优惠码/活动 通过上面介绍和整理的搬瓦工当前已有方案配置,我们可以看到有各种机房可以选择,但是之间还是有差异的。目前大部分方案都是多机房可选的,但是之间也有不可以互通切换,早期如果我们购买的单机房的,是不可以切换多机房的。如果我们需要购买可以顺带使用BWH3HYATVBJW(目前优惠力度最大的6.58%)优惠码,如果点击看到是OUT OF STOCK就表示无货。\n购买教程 (推荐购买SPECIAL 20G KVM PROMO V3 - LOS ANGELES - CN2)\n注册搬瓦工 https://bwh88.net/index.php\n方案推荐\n方案 内存 CPU 硬盘 流量/月 带宽 价格 机房 购买 CN2 (最便宜) 1GB 1核 20GB 1TB 1Gbps $49.99/年 DC8 CN2 DC3 CN2 购买 CN2 2GB 1核 40GB 2TB 1Gbps $52.99/半年 $99.99/年 DC8 CN2 DC3 CN2 购买 CN2 GIA-E (最推荐) 1GB 2核 20GB 1TB 2.5Gbps $65.99/半年 $119.99/年 DC6 CN2 GIA-E DC9 CN2 GIA 购买 (缺货) CN2 GIA-E 2GB 3核 40GB 2TB 2.5Gbps $69.99/季度 $229.99/年 DC6 CN2 GIA-E DC9 CN2 GIA 购买 CN2 GIA 4GB 4核 80GB 3TB 1Gbps $32.99/月 $339.99/年 DC9 CN2 GIA 购买 (缺货) CN2 GIA 8GB 6核 160GB 5TB 1Gbps $62.99/月 $645.99/年 DC9 CN2 GIA 购买 (缺货) HK 2GB 2核 40GB 0.5TB 1Gbps $89.99/月 $899.99/年 香港 PCCW 购买 HK 4GB 4核 80GB 1TB 1Gbps $155.99/月 $1559.99/年 香港 PCCW 购买 点击上面的“直达通道”或者购买链接进入购买页。(其他套餐,请在下方表格自行选择,并分别点击表格中的购买链接进入。否则会出现无法看到验证码的问题)\n购买之后如下截图\n购买完之后会收到下面的邮件,告诉你root 的账号密码,端口等。 点击此页面查看VPS 的更多信息: https://bandwagonhost.com/clientarea.php?action=products\n用Xshell 远程VPS SSH 协议 ​\t具体教程可以搜索Xshell remote VPS\n​\thttps://www.bwgblog.net/bandwagonhost-xshell-ssh.html\n搭建Shadowsocks 安装Shadowsock\nwget \u0026ndash;no-check-certificate -O shadowsocks-all.sh https://raw.githubusercontent.com/teddysun/shadowsocks_install/master/shadowsocks-all.sh\nif promote no weget , run below commad:\n1 yum -y install wget 增加执行权限\n1 chmod +x shadowsocks-all.sh 配置并运行\n./shadowsocks-all.sh 2\u0026gt;\u0026amp;1 | tee shadowsocks-all.log 期间会有很多选择的配置,包括shadowsock 版本,密码,端口设置,协议加密方式等,自行选择并设置。如果出现以下截图即为成功。 如果需要卸载 shadowsocks:\n./shadowsocks-libev.sh uninstall shadowsock 相关命令:\nShadowsocks-Python 版: /etc/init.d/shadowsocks-python start | stop | restart | status\nShadowsocksR 版: /etc/init.d/shadowsocks-r start | stop | restart | status\nShadowsocks-Go 版: /etc/init.d/shadowsocks-go start | stop | restart | status\nShadowsocks-libev 版: /etc/init.d/shadowsocks-libev start | stop | restart | status\n客户端配置Shadowsocks 自行百度,可有: https://ssr.tools/386\n或者\nhttps://crifan.github.io/scientific_network_summary/website/server_client_mode/ss_client/ss_clients/ss_windows.html\n如若下载速度太慢,可以到我的网盘下载:\n链接: https://pan.baidu.com/s/1eJr7KlJSB_exVyXZeLseng 提取码: buei\n安装BBR加速 关闭防火\n1 2 systemctl disable firewalld systemctl stop firewalld BBR安装脚本\n1 2 3 4 5 6 7 8 9 10 11 wget https://raw.githubusercontent.com/kuoruan/shell-scripts/master/ovz-bbr/ovz-bbr-installer.sh chmod +x ovz-bbr-installer.sh ./ovz-bbr-installer.sh 或者: sed -i \u0026#39;/net.core.default_qdisc/d\u0026#39; /etc/sysctl.conf sed -i \u0026#39;/net.ipv4.tcp_congestion_control/d\u0026#39; /etc/sysctl.conf echo \u0026#34;net.core.default_qdisc = fq\u0026#34; \u0026gt;\u0026gt; /etc/sysctl.conf echo \u0026#34;net.ipv4.tcp_congestion_control = bbr\u0026#34; \u0026gt;\u0026gt; /etc/sysctl.conf sysctl -p reboot 安装过程中会提示加速端口,最后判断BBR 是否正常工作\n验证当前TCP控制算法的命令: sysctl net.ipv4.tcp_available_congestion_control\n返回值一般为: net.ipv4.tcp_available_congestion_control = bbr cubic reno 或者为: net.ipv4.tcp_available_congestion_control = reno cubic bbr\n验证BBR是否已经启动\nsysctl net.ipv4.tcp_congestion_control\n返回值一般为: net.ipv4.tcp_congestion_control = bbr\n​\tlsmod | grep bbr\n​ 返回值有 tcp_bbr 模块即说明 bbr 已启动。 注意:并不是所有的 VPS 都会有此返回值,若没有也属正常。\n","permalink":"https://reid00.github.io/en/posts/other/%E6%9D%BF%E7%93%A6%E5%B7%A5%E6%90%AD%E5%BB%BAvps%E6%90%AD%E5%BB%BAvpn/","summary":"板瓦工以及产品介绍 该商家隶属于美国IT7公司旗下的一款便宜年付KVM架构的VPS主机商家,从2013年开始推出低价VPS主机配置进入市场,确","title":"板瓦工搭建VPS搭建vpn"},{"content":"一直以来,编码问题像幽灵一般,不少开发人员都受过它的困扰。\n试想你请求一个数据,却得到一堆乱码,丈二和尚摸不着头脑。有同事质疑你的数据是乱码,虽然你很确定传了 UTF-8 ,却也无法自证清白,更别说帮同事 debug 了。\n有时,靠着百度和一手瞎调的手艺,乱码也能解决。尽管如此,还是很羡慕那些骨灰级程序员。为什么他们每次都能犀利地指出问题,并快速修复呢?原因在于,他们早就把编码问题背后的各种来龙去脉搞清楚了。\n本文从 ASCII 码说起,带你扒一扒编码背后那些事。相信搞清编码的原理后,你将不再畏惧任何编码问题。\n从 ASCII 码说起 现代计算机技术从英文国家兴起,最先遇到的也是英文文本。英文文本一般由 26 个字母、 10 个数字以及若干符号组成,总数也不过 100 左右。\n计算机中最基本的存储单位为 字节 ( byte ),由 8 个比特位( bit )组成,也叫做 八位字节 ( octet )。8 个比特位可以表示 $ 2^8 = 256 $ 个字符,看上去用字节来存储英文字符即可?\n计算机先驱们也是这么想的。他们为每个英文字符编号,再加上一些控制符,形成了我们所熟知的 ASCII 码表。实际上,由于英文字符不多,他们只用了字节的后 7 位而已。\n根据 ASCII 码表,由 01000001 这 8 个比特位组成的八位字节,代表字母 A 。\n顺便提一下,比特本身没有意义,比特 在 上下文 ( context )中才构成信息。举个例子,对于内存中一个字节 01000001 ,你将它看做一个整数,它就是 65 ;将它作为一个英文字符,它就是字母 A ;你看待比特的方式,就是所谓的上下文。\n所以,猜猜下面这个程序输出啥?\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #include \u0026lt;stdio.h\u0026gt; int main(int argc, char *argv[]) { char value = 0x41; // as a number, value is 65 or 0x41 in hexadecimal printf(\u0026#34;%d\\n\u0026#34;, value); // as a ASCII character, value is alphabet A printf(\u0026#34;%c\\n\u0026#34;, value); return 0; } latin1 西欧人民来了,他们主要使用拉丁字母语言。与英语类似,拉丁字母数量并不大,大概也就是几十个。于是,西欧人民打起 ASCII 码表那个未用的比特位( b8 )的主意。\n还记得吗?ASCII 码表总共定义了 128 个字符,范围在 0~127 之间,字节最高位 b8 暂未使用。于是,西欧人民将拉丁字母和一些辅助符号(如欧元符号)定义在 128~255 之间。这就构成了 latin1 ,它是一个 8 位字符集,定义了以下字符:\n图中绿色部分是不可打印的( unprintable )控制字符,左半部分是 ASCII 码。因此,latin1 字符集是 ASCII 码的超集:\n一个字节掰成两半,欧美两兄弟各用一半。至此,欧美人民都玩嗨了,东亚人民呢?\nGB2312、GBK和GB18030 由于受到汉文化的影响,东亚地区主要是汉字圈,我们便以中文为例展开介绍。\n汉字有什么特点呢?—— 光常用汉字就有几千个,这可不是一个字节能胜任的。一个字节不够就两个呗。道理虽然如此,操作起来却未必这么简单。\n首先,将需要编码的汉字和 ASCII 码整理成一个字符集,例如 GB2312 。为什么需要 ASCII 码呢?因为,在计算机世界,不可避免要跟数字、英文字母打交道。至于拉丁字母,重要性就没那么大,也就无所谓了。\nGB2312 字符集总共收录了 6 千多个汉字,用两个字节来表示足矣,但事情远没有这么简单。同样的数字字符,在 GB2312 中占用 2 个字节,在 ASCII 码中占用 1 个字节,这不就不兼容了吗?计算机里太多东西涉及 ASCII 码了,看看一个 http 请求:\n1 2 GET / HTTP/1.1 Host: www.example.com 那么,怎么兼容 GB2312 和 ASCII 码呢?天无绝人之路, 变长 编码方案应运而生。\n变长编码方案,字符由长度不一的字节表示,有些字符只需 1 字节,有些需要 2 字节,甚至有些需要更多字节。GB2312 中的 ASCII 码与原来保持一致,还是用一个字节来表示,这样便解决了兼容问题。\n在 GB2312 中,如果一个字节最高位 b8 为 0 ,该字节便是单字节编码,即 ASCII 码。如果字节最高位 b8 为 1 ,它就是双字节编码的首字节,与其后字节一起表示一个字符。\n变长编码方案目的在于兼容 ASCII 码,但也带来一个问题:由于字节编码长度不一,定位第 N 个字符只能通过遍历实现,时间复杂度从 $ O(1) $ 退化到 $ O(N) $ 。好在这种操作场景并不多见,因此影响可以忽略。\nGB2312 收录的汉字个数只有常用的 6 千多个,遇到生僻字还是无能为力。因此,后来又推出了 GBK 和 GB18030 字符集。GBK 是 GB2312 的超集,完全兼容 GB2312 ;而 GB18030 又是 GBK 的超集,完全兼容 GBK 。\n因此,对中文编码文本进行解码,指定 GB18030 最为健壮:\n1 2 3 \u0026gt;\u0026gt;\u0026gt; raw = b\u0026#39;\\xfd\\x88\\xb5\\xc4\\xb4\\xab\\xc8\\xcb\u0026#39; \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;gb18030\u0026#39;) \u0026#39;龍的传人\u0026#39; 指定 GBK 或 GB2312 就只好看运气了,GBK 多半还没事:\n1 2 \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;gbk\u0026#39;) \u0026#39;龍的传人\u0026#39; GB2312 经常直接抛锚不商量:\n1 2 3 4 \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;gb2312\u0026#39;) Traceback (most recent call last): File \u0026#34;\u0026lt;stdin\u0026gt;\u0026#34;, line 1, in \u0026lt;module\u0026gt; UnicodeDecodeError: \u0026#39;gb2312\u0026#39; codec can\u0026#39;t decode byte 0xfd in position 0: illegal multibyte sequence chardet 是一个不错的文本编码检测库,用起来很方便,但对中文编码支持不是很好。经常中文编码的文本进去,检测出来的结果是 GB2312 ,但一用 GB2312 解码就跪:\n1 2 3 4 5 6 7 8 \u0026gt;\u0026gt;\u0026gt; import chardet \u0026gt;\u0026gt;\u0026gt; raw = b\u0026#39;\\xd6\\xd0\\xb9\\xfa\\xc8\\xcb\\xca\\xc7\\xfd\\x88\\xb5\\xc4\\xb4\\xab\\xc8\\xcb\u0026#39; \u0026gt;\u0026gt;\u0026gt; chardet.detect(raw) {\u0026#39;encoding\u0026#39;: \u0026#39;GB2312\u0026#39;, \u0026#39;confidence\u0026#39;: 0.99, \u0026#39;language\u0026#39;: \u0026#39;Chinese\u0026#39;} \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;GB2312\u0026#39;) Traceback (most recent call last): File \u0026#34;\u0026lt;stdin\u0026gt;\u0026#34;, line 1, in \u0026lt;module\u0026gt; UnicodeDecodeError: \u0026#39;gb2312\u0026#39; codec can\u0026#39;t decode byte 0xfd in position 8: illegal multibyte sequence 掌握 GB2312 、 GBK 、 GB18030 三者的关系后,我们可以略施小计。如果 chardet 检测出来结果是 GB2312 ,就用 GB18030 去解码,大概率可以成功!\n1 2 \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;GB18030\u0026#39;) \u0026#39;中国人是龍的传人\u0026#39; Unicode GB2312 、 GBK 与 GB18030 都是中文编码字符集。虽然 GB18030 也包含日韩表意文字,算是国际字符集,但毕竟是以中文为主,无法适应全球化应用。\n在计算机发展早期,不同国家都推出了自己的字符集和编码方案,互不兼容。中文编码的文本在使用日文编码的系统上是无法显示的,这就给国际交往带来障碍。\n这时,英雄出现了。统一码联盟 站出来说要发展一个通用的字符集,收录世界上所有字符,这就是 Unicode 。经过多年发展, Unicode 已经成为世界上最通用的字符集,也是计算机科学领域的业界标准。\nUnicode 已经收录的字符数量已经超过 13 万个,每个字符需占用超过 2 字节。由于常用编程语言一般没有 24 位数字类型,因此一般用 32 位数字表示一个字符。这样一来,同样的一个英文字母,在 ASCII 中只需占用 1 字节,在 Unicode 则需要占用 4 字节!英美人民都要哭了,试想你磁盘中的文件大小都增大了 4 倍是什么感受!\nUTF-8 为了兼容 ASCII 并优化文本空间占用,我们需要一种变长字节编码方案,这就是著名的 UTF-8 。与 GB2312 等中文编码一样,UTF-8 用不固定的字节数来表示字符:\nASCII 字符 Unicode 码位由 U+0000 至 U+007F ,用 1 个字节编码,最高位为 0 ; 码位由 U+0080 至 U+07FF 的字符,用 2 个字节编码,首字节以 110 开头,其余字节以 10 开头; 码位由 U+0800 至 U+FFFF 的字符,用 3 个字节编码,首字节以 1110 开头,其余字节同样以 10 开头; 4 至 6 字节编码的情况以此类推; 如图,以 0 开头的字节为 单字节 编码,总共 7 个有效编码位,编码范围为 U+0000 至 U+007F ,刚好对应 ASCII 码所有字符。以 110 开头的字节为 双字节 编码,总共 11 个有效编码位,最大值是 0x7FF ,因此编码范围为 U+0080 至 U+07FF ;以 1110 开头的字节为 三字节 编码,总共 16 个有效编码位,最大值是 0xFFFF 因此编码范围为 U+0800 至 U+FFFF 。\n根据开头不同, UTF-8 流中的字节,可以分为以下几类:\n| 字节最高位 | 类别 | 有效位 | |:\u0026mdash;\u0026mdash;\u0026ndash; |:\u0026mdash;\u0026ndash;|:\u0026mdash;\u0026mdash;| | 0 | 单字节编码 | 7 | | 10 | 多字节编码非首字节 | | | 110 | 双字节编码首字节 | 11 | | 1110 | 三字节编码首字节 | 16 | | 11110 | 四字节编码首字节 | 21 | | 111110 | 五字节编码首字节 | 26 | | 1111110 | 六字节编码首字节 | 31 |\n至此,我们已经具备了读懂 UTF-8 编码字节流的能力,不信来看一个例子:\n概念回顾 一直以来,字符集 和 编码 这两个词一直是混着用的。现在,我们总算有能力厘清这两者间的关系了。\n字符集 顾名思义,就是由一定数量字符组成的集合,每个字符在集合中有唯一编号。前文提及的 ASCII 、 latin1 、 GB2312 、GBK 、GB18030 以及 Unicode 等,无一例外,都是字符集。\n计算机存储和网络通讯的基本单位都是 字节 ,因此文本必须以 字节序列 的形式进行存储或传输。那么,字符编号如何转化成字节呢?这就是 编码 要回答的问题。\n在 ASCII 码和 latin 中,字符编号与字节一一对应,这是一种编码方式。GB2312 则采用变长字节,这是另一种编码方式。而 Unicode 则存在多种编码方式,除了 最常用的 UTF-8 编码,还有 UTF-16 等。实际上,UTF-16 编码效率比 UTF-8 更高,但由于无法兼容 ASCII ,应用范围受到很大制约。\n最佳实践 认识文本编码的前世今生之后,应该如何规避编码问题呢?是否存在一些最佳实践呢?答案是肯定的。\n编码选择 项目开始前,需要选择一种适应性广的编码方案,UTF-8 是首选,好处多多:\nUnicode 是业界标准,编码字符数量最多,天然支持国际化; UTF-8 完全兼容 ASCII 码,这是硬性指标; UTF-8 目前应用最广; 如因历史原因,不得不使用中文编码方案,则优先选择 GB18030 。这个标准最新,涵盖字符最多,适应性最强。尽量避免采用 GBK ,特别是 GB2312 等老旧编码标准。\n编程习惯 如果你使用的编程语言,字符串类型支持 Unicode ,那问题就简单了。由于 Unicode 字符串肯定不会导致诸如乱码等编码问题,你只需在输入和输出环节稍加留意。\n举个例子,Python 从 3 以后, str 就是 Unicode 字符串了,而 bytes 则是 字节序列 。因此,在 Python 3 程序中,核心逻辑应该统一用 str 类型,避免使用 bytes 。文本编码、解码操作则统一在程序的输入、输出层中进行。\n假如你正在开发一个 API 服务,数据库数据编码是 GBK ,而用户却使用 UTF-8 编码。那么,在程序 输入层 , GBK 数据从数据库读出后,解码转换成 Unicode 数据,再进入核心层处理。在程序 核心层 ,数据以 Unicode 形式进行加工处理。由于核心层处理逻辑可能很复杂,统一采用 Unicode 可以减少问题的发生。最后,在程序的 输出层 将数据以 UTF-8 编码,再返回给客户端。\n整个过程伪代码大概如下:\n1 2 3 4 5 6 7 8 9 10 11 # input # read gbk data from database and decode it to unicode data = read_from_database().decode(\u0026#39;gbk\u0026#39;) # core # process unicode data only result = process(data) # output # encoding unicode data into utf8 response_to_user(result.encode(\u0026#39;utf8\u0026#39;)) 这样的程序结构看起来跟个三明治一样,非常形象:\n当然了,还有很多编程语言字符串还不支持 Unicode 。Python 2 中的 str 对象,跟 Python 3 中的 bytes 比较像,只是字节序列;C 语言中的字符串甚至更原始。\n这都无关紧要,好的编程习惯是相通的:程序核心层统一使用某种编码,输入输出层则负责编码转换。至于核心层使用何种编码,主要看程序中哪种编码使用最多,一般是跟数据库编码保持一致即可。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/%E7%BC%96%E7%A0%81%E9%82%A3%E4%BA%9B%E4%BA%8B/","summary":"一直以来,编码问题像幽灵一般,不少开发人员都受过它的困扰。 试想你请求一个数据,却得到一堆乱码,丈二和尚摸不着头脑。有同事质疑你的数据是乱码,","title":"编码那些事"},{"content":"前言 我们每天都在用 Google, 百度这些搜索引擎,那大家有没想过搜索引擎是如何实现的呢,看似简单的搜索其实技术细节非常复杂,说搜索引擎是 IT 皇冠上的明珠也不为过,今天我们来就来简单过一下搜索引擎的原理,看看它是如何工作的,当然搜索引擎博大精深,一篇文章不可能完全介绍完,我们只会介绍它最重要的几个步骤,不过万变不离其宗,搜索引擎都离开这些重要步骤,剩下的无非是在其上添砖加瓦,所以掌握这些「关键路径」,能很好地达到观一斑而窥全貎的目的。\n本文将会从以下几个部分来介绍搜索引擎,会深度剖析搜索引擎的工作原理及其中用到的一些经典数据结构和算法,相信大家看了肯定有收获。\n搜索引擎系统架构图\n搜索引擎工作原理详细剖析\n搜索引擎系统架构图 搜索引擎整体架构图如下图所示,大致可以分为搜集,预处理,索引,查询这四步,每一步的技术细节都很多,我们将在下文中详细分析每一步的工作原理。 搜索引擎工作原理详细剖析 一、搜索 爬虫一开始是不知道该从哪里开始爬起的,所以我们可以给它一组优质种子网页的链接,比如新浪主页,腾讯主页等,这些主页比较知名,在 Alexa 排名上也非常靠前,拿到这些优质种子网页后,就对这些网页通过广度优先遍历不断遍历这些网页,爬取网页内容,提取出其中的链接,不断将其将入到待爬取队列,然后爬虫不断地从 url 的待爬取队列里提取出 url 进行爬取,重复以上过程\u0026hellip;\n当然了,只用一个爬虫是不够的,可以启动多个爬虫并行爬取,这样速度会快很多。\n1、待爬取的 url 实现 待爬取 url 我们可以把它放到 Redis 里,保证了高性能,需要注意的是,Redis要开启持久化功能,这样支持断点续爬,如果 Redis 挂掉了,重启之后由于有持续久功能,可以从上一个待爬的 url 开始重新爬。\n2、如何判重 如何避免网页的重复爬取呢,我们需要对 url 进行去重操作,去重怎么实现?可能有人说用散列表,将每个待抓取 url 存在散列表里,每次要加入待爬取 url 时都通过这个散列表来判断一下是否爬取过了,这样做确实没有问题,但我们需要注意到的是这样需要会出巨大的空间代价,有多大,我们简单算一下,假设有 10 亿 url (不要觉得 10 亿很大,像 Google, 百度这样的搜索引擎,它们要爬取的网页量级比 10 亿大得多),放在散列表里,需要多大存储空间呢?\n我们假设每个网页 url 平均长度 64 字节,则 10 亿个 url 大约需要 60 G 内存,如果用散列表实现的话,由于散列表为了避免过多的冲突,需要较小的装载因子(假设哈希表要装载 10 个元素,实际可能要分配 20 个元素的空间,以避免哈希冲突),同时不管是用链式存储还是用红黑树来处理冲突,都要存储指针,各种这些加起来所需内存可能会超过 100 G,再加上冲突时需要在链表中比较字符串,性能上也是一个损耗,当然 100 G 对大型搜索引擎来说不是什么大问题,但其实还有一种方案可以实现远小于 100 G 的内存:布隆过滤器。\n针对 10 亿个 url,我们分配 100 亿个 bit,大约 1.2 G, 相比 100 G 内存,提升了近百倍!可见技术方案的合理选择能很好地达到降本增效的效果。\n当然有人可能会提出疑问,布隆过滤器可能会存在误判的情况,即某个值经过布隆过滤器判断不存在,那这个值肯定不存在,但如果经布隆过滤器判断存在,那这个值不一定存在,针对这种情况我们可以通过调整布隆过滤器的哈希函数或其底层的位图大小来尽可能地降低误判的概率,但如果误判还是发生了呢,此时针对这种 url 就不爬好了,毕竟互联网上这么多网页,少爬几个也无妨。\n3、网页的存储文件: doc_raw.bin 爬完网页,网页该如何存储呢,有人说一个网页存一个文件不就行了,如果是这样,10 亿个网页就要存 10 亿个文件,一般的文件系统是不支持的,所以一般是把网页内容存储在一个文件(假设为 doc_raw.bin)中,如下\n当然一般的文件系统对单个文件的大小也是有限制的,比如 1 G,那在文件超过 1 G 后再新建一个好了。\n图中网页 id 是怎么生成的,显然一个 url 对应一个网页 id,所以我们可以增加一个发号器,每爬取完一个网页,发号器给它分配一个 id,将网页 id 与 url 存储在一个文件里,假设命名为 doc_id.bin,如下\n二、预处理 爬取完一个网页后我们需要对其进行预处理,我们拿到的是网页的 html 代码,需要把\u0026lt;script\u0026gt;,\u0026lt;style\u0026gt;,\u0026lt;option\u0026gt; 这些无用的标签及标签包含的内容给去掉,怎么查找是个学问,可能有人会说用 BF,KMP 等算法,这些算法确实可以,不过这些算法属于单模式串匹配算法,查询单个字段串效率确实不错,但我们想要一次性查出\u0026lt;script\u0026gt;,\u0026lt;style\u0026gt;,\u0026lt;option\u0026gt;这些字段串,有啥好的方法不,答案是用AC 自动机多模式串匹配算法,可以高效一次性找出几个待查找的字段串,有多高效,时间复杂度接近 0(n)!关于 AC 自动机多模式匹配算法的原理不展开介绍,大家可以去网上搜搜看, 这里只是给大家介绍一下思路。\n找到这些标签的起始位置后,剩下的就简单了,接下来对每个这些标签都查找其截止标签 \u0026lt;/script\u0026gt;,\u0026lt;/style\u0026gt;,\u0026lt;/option\u0026gt;,找到之后,把起始终止标签及其中的内容全部去掉即可。\n做完以上步骤后,我们也要把其它的 html 标签去掉(标签里的内容保留),因为我们最终要处理的是纯内容(内容里面包含用户要搜索的关键词)\n三、分词并创建倒排索引 拿到上述步骤处理过的内容后,我们需要将这些内容进行分词,啥叫分词呢,就是将一段文本切分成一个个的词。比如 「I am a chinese」分词后,就有 「I」,「am」,「a」,「chinese」这四个词,从中也可以看到,英文分词相对比较简单,每个单词基本是用空格隔开的,只要以空格为分隔符切割字符串基本可达到分词效果,但是中文不一样,词与词之类没有空格等字符串分割,比较难以分割。以「我来到北京清华大学」为例,不同的模式产生的分词结果不一样,以 github 上有名的 jieba 分词开源库以例,它有如下几种分词模式\n【全模式】: 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学 【精确模式】: 我/ 来到/ 北京/ 清华大学 【新词识别】:他, 来到, 了, 网易, 杭研, 大厦 【搜索引擎模式】: 小明, 硕士, 毕业, 于, 中国, 科学, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造\n分词一般是根据现成的词库来进行匹配,比如词库中有「中国」这个词,用处理过的网页文本进行匹配即可。当然在分词之前我们要把一些无意义的停止词如「的」,「地」,「得」先给去掉。\n经过分词之后我们得到了每个分词与其文本的关系,如下\n这样我们在搜「大学」的时候找到「大学」对应的行,就能找到所有包含有「大学」的文档 id 了。\n看到以上「分词」+「倒排索引」的处理流程,大家想到了什么?没错,这不就是 ElasticSearch 搜索引擎干的事吗,也是 ES 能达到毫秒级响应的关键!\n这里还有一个问题,根据某个词语获取得了一组网页的 id 之后,在结果展示上,哪些网页应该排在最前面呢,为啥我们在 Google 上搜索一般在第一页的前几条就能找到我们想要的答案。这就涉及到搜索引擎涉及到的另一个重要的算法: PageRank,它是 Google 对网页排名进行排名的一种算法,它以网页之间的超链接个数和质量作为主要因素粗略地分析网页重要性以便对其进行打分。我们一般在搜问题的时候,前面一两个基本上都是 stackoverflow 网页,说明 Google 认为这个网页的权重很高,因为这个网页被全世界几乎所有的程序员使用着,也就是说有无数个网页指向此网站的链接,根据 PageRank 算法,自然此网站权重就啦,恩,可以简单地这么认为,实际上 PageRank 的计算需要用到大量的数学知识,毕竟此算法是 Google 的立身之本,大家如果有兴趣,可以去网上多多了解一下。\n完成以上步骤,搜索引擎对网页的处理就完了,那么用户输入关键词搜索引擎又是怎么给我们展示出结果的呢。\n四、查询 用户输入关键词后,首先肯定是要经过分词器的处理。比如我输入「中国人民」,假设分词器分将其分为「中国」,「人民」两个词,接下来就用这个两词去倒排索引里查相应的文档\n得到网页 id 后,我们分别去 doc_id.bin,doc_raw.bin 里提取出网页的链接和内容,按权重从大到小排列即可。\n这里的权重除了和上文说的 PageRank 算法有关外,还与另外一个「 TF-IDF 」(https://zh.wikipedia.org/wiki/Tf-idf)算法有关,大家可以去了解一下。\n另外相信大家在搜索框输入搜索词的时候,都会注意到底下会出现一串搜索提示词,\n如何实现的,这就不得不提到一种树形结构:Trie 树。Trie 树又叫字典树、前缀树(Prefix Tree)、单词查找树,是一种多叉树结构,如下图所示:\n这颗多叉树表示了关键字集合 [\u0026ldquo;to\u0026rdquo;,\u0026ldquo;tea\u0026rdquo;,\u0026ldquo;ted\u0026rdquo;,\u0026ldquo;ten\u0026rdquo;,\u0026ldquo;a\u0026rdquo;,\u0026ldquo;i\u0026rdquo;,\u0026ldquo;in\u0026rdquo;, \u0026ldquo;inn\u0026rdquo;]。从中可以看出 Trie 树具有以下性质:\n根节点不包含字符,除根节点外的每一个子节点都包含一个字符 从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应的字符串 每个节点的所有子节点包含的字符互不相同 通常在实现的时候,会在节点结构中设置一个标志,用来标记该结点处是否构成一个单词(关键字)。\n另外我们不难发现一个规律,具有公共前缀的关键字(单词),它们前缀部分在 Trie 树中是相同的,这也是 Trie 树被称为前缀树的原因,有了这个思路,我们不难设计出上文所述搜索时展示一串搜索提示词的思路:\n一般搜索引擎会维护一个词库,假设这个词库由所有搜索次数大于某个阈值(如 1000)的字符串组成,我们就可以用这个词库构建一颗 Trie 树,这样当用户输入字母的时候,就可以以这个字母作为前缀去 Trie 树中查找,以上文中提到的 Trie 树为例,则我们输入「te」时,由于以「te」为前缀的单词有 [\u0026ldquo;tea\u0026rdquo;,\u0026ldquo;ted\u0026rdquo;,\u0026ldquo;ted\u0026rdquo;,\u0026ldquo;ten\u0026rdquo;],则在搜索引擎的搜索提示框中就可以展示这几个字符串以供用户选择。\n五、寻找热门搜索字符串 Trie 树除了作为前缀树来实现搜索提示词的功能外,还可以用来辅助寻找热门搜索字符串,只要对 Trie 树稍加改造即可。假设我们要寻找最热门的 10 个搜索字符串,则具体实现思路如下:\n一般搜索引擎都会有专门的日志来记录用户的搜索词,我们用用户的这些搜索词来构建一颗 Trie 树,但要稍微对 Trie 树进行一下改造,上文提到,Trie 树实现的时候,可以在节点中设置一个标志,用来标记该结点处是否构成一个单词,也可以把这个标志改成以节点为终止字符的搜索字符串个数,每个搜索字符串在 Trie 树遍历,在遍历的最后一个结点上把字符串个数加 1,即可统计出每个字符串被搜索了多少次(根节点到结点经过的路径即为搜索字符串),然后我们再维护一个有 10 个节点的小顶堆(堆顶元素比所有其他元素值都小,如下图示)\n依次遍历 Trie 树的节点,将节点(字符串+次数)传给小顶堆,根据搜索次数不断调整小顶堆,这样遍历完 Trie 树的节点后,小顶堆里的 10 个节点对应的字符串即是最热门的搜索字符串。\n总结 本文简述了搜索引擎的工作原理,相信大家看完后对其工作原理应该有了比较清醒的认识,我们可以看到,搜索引擎中用到了很多经典的数据结构和算法,所以现在大家应该能明白为啥 Google, 百度这些公司对候选人的算法要求这么高了。\n","permalink":"https://reid00.github.io/en/posts/algo/%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E%E8%83%8C%E5%90%8E%E7%9A%84%E7%BB%8F%E5%85%B8%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E5%92%8C%E7%AE%97%E6%B3%95/","summary":"前言 我们每天都在用 Google, 百度这些搜索引擎,那大家有没想过搜索引擎是如何实现的呢,看似简单的搜索其实技术细节非常复杂,说搜索引擎是 IT 皇冠上的明珠也","title":"搜索引擎背后的经典数据结构和算法"},{"content":"常见反直觉定理 生日悖论 假设房间里有23人,那么两个人生日是同天的概率将大于50%。我们很容易得出,任何一个特定的日子里某人过生日的概率是1/365。\n所以这个理论看似是无法成立,但理论与现实差异正源自于:我们的唯一要求是两个人彼此拥有同一天生日即可,不限定在特定的一天。 否则,如果换做某人在某特定日期生日,例如2月19日,那么23个人中概率便仅为6.12%。\n另一方面如果你在有23个人的房间挑选一人问他:“有人和你同一天生日吗?”答案很可能是否定的。 但如果重复询问其余22人,每问一次,你便会有更大机会得到肯定答复,最终这个概率是50.7%。 结论 当房间里有23人,那么存在生日相同的概率超过50%, 如果有60人,则超过99%\n生日悖论的应用 日悖论普遍的应用于检测哈希函数:N 位长度的哈希表可能发生碰撞测试次数不是 2N 次而是只有 2N/2 次。这一结论被应用到破解密码哈希函数 (cryptographic hash function) 的 “生日攻击” 中。 生日问题所隐含的理论已经在 [Schnabel 1938] 名字叫做 “标记重捕法” (capture-recapture) 的统计试验得到应用,来估计湖里鱼的数量。 巴拿赫-塔尔斯基悖论(分球定理) 数学中,有一条极其基本的公理,叫做选择公理,许多数学内容都要基于这条定理才得以成立。 在1924年,数学家斯特·巴拿赫和阿尔弗莱德·塔斯基根据选择公理,得到一个奇怪的推论——分球定理。 该定理指出,一个三维实心球分成有限份,然后可以根据旋转和平移,组成和原来完全相同的两个实心球。没错,每一个和原来的一模一样。 分球定理太违反直觉,但它就是选择公理的严格推论,而且不容置疑的,除非你抛弃选择公理,但数学家会为此付出更大的代价。\n在现实生活中我们没有任何办法能将一个物体凭空复制成两个。但事实上他却是成立的,这个结果似乎挑战了物理中的质量守恒定律,但似乎又是在说一个物体的质量可以凭空变为原来的两倍? 但如若原质量是无限的话,翻倍后还是无限大,那么从这一层面出发来看这一理论也并没有打破物理法则。\n有不同层次的无穷大(无穷大也有等级大小) 你可能从来想象不到,有一些无穷大比其他的无穷更大。无穷大应该被称为基数,并且一个无穷大如果比另一个无穷大拥有更大的基数,则说它比另一个无穷大要大。\n在二十世纪以前,数学家们遇到无穷大都避而让之,认为要么哪里出了问题,要么结果是没有意义的。 直到1895年,康托尔建立超穷数理论,人们才得知无穷大也是有等级的,比如实数个数的无穷,就比整数个数的无穷的等级高。 还有许多关于无穷大的基数大大出乎我们的意料。举一个非常经典的例子:整数比奇数多吗?你可能会毫不犹豫的回答,那是当然! 因为整数多出了一系列的偶数。但答案是否定的,他们拥有相同的基数,因而整数并不比奇数多。知道了这个道理,就不难回答这个问题了吧:有理数多于整数吗?不,有理数与整数相同多。 实数通常被认为是连续统,并且至今并能完全知道,是否有介于整数基数和连续统基数的无穷大?这个猜想被称为连续统猜想。\n这也太违反直觉了,我们从来不把无穷大当作数,但是无穷大在超穷数理论中,却存在不同的等级。\n哥德尔不完备定理 “可证”和“真”不是等价的 1931年,奥地利数学家哥德尔,提出一条震惊学术界的定理——哥德尔不完备定理。 该定理指出,我们目前的数学系统中,必定存在不能被证明也不能被证伪的定理。该定理一出,就粉碎了数学家几千年的梦想——即建立完善的数学系统,从一些基本的公理出发,推导出一切数学的定理和公式。\n它的逻辑是这样的:\n任何一个足够强的系统都存在一个命题,既不能被证明也不能被证伪(例如连续统假设) 任何一个足够强的系统都不能证明它自身是不推出矛盾,即便它不能被推出矛盾 以上两条定义即著名的哥德尔不完备定理。他的意义并不仅仅局限于数学,也给了我们深深地哲学启迪。\n蒙提霍尔问题 三门问题亦称为蒙提霍尔问题,大致出自美国的电视游戏节目Let\u0026rsquo;s Make a Deal。问题名字来自该节目的主持人蒙提·霍尔。 参赛者会看见三扇关闭了的门,其中一扇的后面有一辆汽车,选中后面有车的那扇门可赢得该汽车,另外两扇门后面则各藏有一只山羊。 当参赛者选定了一扇门,但未去开启它的时候,节目主持人开启剩下两扇门的其中一扇,露出其中一只山羊。主持人其后会问参赛者要不要换另一扇仍然关上的门。 问题是:换另一扇门会否增加参赛者赢得汽车的机会率? 不换门的话,赢得汽车的几率是1/3。换门的话,赢得汽车的几率是2/3。\n这个问题亦被叫做蒙提霍尔悖论:虽然该问题的答案在逻辑上并不自相矛盾,但十分违反直觉。\n巴塞尔问题 将自然数各自平方取倒数加在一起等于π²/6。 一般人都会觉得,左边这一坨自然数似乎和π(圆的周长与直径的比值)不会存在任何联系!然而它就这么发生了!\n阿贝尔不可解定理 曼德勃罗集 德勃罗集是一个复数集,考虑函数f(z)=z²+c,c为复常数,在这为参数。 若从z=0开始不断的利用f(z)进行迭代,则凡是使得迭代结果不会跑向无穷大的c组成的集合被称为曼德勃罗集。规则不复杂,但你可能没预料到会得到这么复杂的图像。 当你放大曼德勃罗集时,你会又发现无限个小的曼德勃罗集,其中每个又亦是如此\u0026hellip;(这种性质是分形所特有的) 这真的很契合那句俗话“大中有大,小中有小”,下面有一个关于放大他的视频,我想这绝对令人兴奋不已。 一维可以和二维甚至更高维度一一对应 按照我们的常识,二维比一维等级高,三维比四维等级高,比如线是一维的,所以线不能一一对应于面积。 但事实并非如此,康托尔证明了一维是可以一一对应高维的,也就是说一条线上的点,可以和一块面积甚至体积的点一一对应,或者说他们包含的点一样多。 证明: 在1890年,意大利数学家皮亚诺,就发明了一个函数,使得函数在实轴[0,1]上的取值,可以一一对应于单位正方形上的所有点,这条曲线叫做皮亚诺曲线。 这个性质的发现,暗示着人类对维度的主观认识,很可能是存在缺陷的。\n希尔伯特的旅店 希尔伯特有一个旅店,旅店里有无限多间房间。有一天所有房间都住满了人。然后又来了一名旅客。希尔伯特说:很抱歉,房间都住满了。旅客说:没关系。你可以让第一间房间的人住第二间房,第二间房间的人住第三间房,如此类推,我就可以住第一间房间了。也可以让第一间房间的人住第二间房,第二间房间的人住第四间房,以此类推,我也可以住第一间房间。\n甚至,按照这种做法,无论再来多少客人,只要客人是有限个,这个住满的酒店都可以继续空出房间来给他们安排。 他的说法很反直觉,但是从数学角度上,他是正确的。\n为了讨论这个问题,我们首先要问:全体正整数和全体正偶数谁多? 大部分人的直觉都是:整数多。因为整数包含奇数和偶数,所以整数多。\n但是,正整数集合和正偶数集合都是无限多个元素。在数学上,无限多个元素的集合比较多少时要使用“势”的概念。也就是:如果两个集合可以建立一个一一对应的关系,那么这两个集合的元素个数就是一样多的。 所以,如果我们令x表示正整数,y表示正偶数,建立对应关系y=2x,那么正整数和正偶数之间就建立了一一对应的关系,所以正整数集合和正偶数集合是等势的,或者说全体正整数和全体正偶数一样多。 按照这种观点,我们可以继而可以证明全体正整数数和全体非负整数一样多:建立一一对应关系:y=x-1,x属于正整数,y属于非负整数。 所以,客人来到希尔伯特的酒店住宿,提出的两种方案都是合理的。第一种方案基于全体正整数(房间)和全体非负整数(客人)是一样多的,第二种方案是基于全体正偶数(房间)和全体正整数(客人)是一样多的。\n有理点 我们把平面上横坐标和纵坐标都为有理数的点称为 有理点. 显然,平面上的有理点有无穷多个,甚至不难证明它们在整个平面内是稠密的,即密密麻麻地铺满了整个平面. 设想一下,如果我们在平面上随手画一条曲线,直觉上我们可能认为这根曲线应该会经过无限多个有理点,也就是说曲线上有无限多个有理点. 但事实上并非一定如此!比如我们非常熟悉的曲线 y=e^x,它除了(0, 1)外没有其他任何有理点,也就是说它非常神奇地避开了处处稠密的有理点,这就挺反直觉的. 买彩票 买彩票中奖的概率问题。同一天内买30注不同的号码中一等奖概率高还是连续30期每次买一注至少有一次中大奖的概率高。我想当然得人为是后者概率高,但是实际上经过计算后,是前者概率高,并且两者的中奖奖金期望值是相同的。\n","permalink":"https://reid00.github.io/en/posts/other/%E6%95%B0%E5%AD%A6%E4%B8%AD%E7%9A%84%E5%8D%81%E5%A4%A7%E6%82%96%E8%AE%BA/","summary":"常见反直觉定理 生日悖论 假设房间里有23人,那么两个人生日是同天的概率将大于50%。我们很容易得出,任何一个特定的日子里某人过生日的概率是1/","title":"数学中的十大悖论"},{"content":"1. 位运算概述 从现代计算机中所有的数据二进制的形式存储在设备中。即 0、1 两种状态,计算机对二进制数据进行的运算(+、-、*、/)都是叫位运算,即将符号位共同参与运算的运算。\n1 2 3 int a = 35; int b = 47; int c = a + b; 实际上运算如下: 计算两个数的和,因为在计算机中都是以二进制来进行运算,所以上面我们所给的 int 变量会在机器内部先转换为二进制在进行相加:\n1 2 3 4 35: 0 0 1 0 0 0 1 1 47: 0 0 1 0 1 1 1 1 ———————————————————— 82: 0 1 0 1 0 0 1 0 所以,相比在代码中直接使用(+、-、*、/)运算符,合理的运用位运算更能显著提高代码在机器上的执行效率。\n2. 位运算概览 3. 按位与运算符 定义:参加运算的两个数据,按二进制位进行\u0026quot;与\u0026quot;运算。 运算规则:\n1 0\u0026amp;0=0 0\u0026amp;1=0 1\u0026amp;0=0 1\u0026amp;1=1 ==总结:两位同时为1,结果才为1,否则结果为0。==\n例如:3\u0026amp;5 即 0000 0011\u0026amp; 0000 0101 = 0000 0001,因此 3\u0026amp;5 的值得1。 注意:负数按补码形式参加按位与运算。\n与运算 \u0026amp; 的用途: 1)清零 如果想将一个单元清零,即使其全部二进制位为0,只要与一个各位都为零的数值相与,结果为零。\n2)取一个数的指定位 比如取数 X=1010 1110 的低4位,只需要另找一个数Y,令Y的低4位为1,其余位为0,即Y=0000 1111,然后将X与Y进行按位与运算(X\u0026amp;Y=0000 1110)即可得到X的指定位。\n3)判断奇偶 只要根据最未位是0还是1来决定,为0就是偶数,为1就是奇数。因此可以用if ((a \u0026amp; 1) == 0)代替if (a % 2 == 0)来判断a是不是偶数。\n4. 异或运算符(^) 定义:参加运算的两个数据,按二进制位进行\u0026quot;异或\u0026quot;运算。\n1 0^0=0 0^1=1 1^0=1 1^1=0 ==总结:参加运算的两个对象,如果两个相应位相同为0,相异为1。==\n异或的几条性质:\n交换律 结合律 (a^b)^c == a^(b^c) 对于任何数x,都有 x^x=0,x^0=x 自反性: a^b^b=a^0=a; 与运算 ^ 的用途: 1)翻转指定位 比如将数 X=1010 1110 的低4位进行翻转,只需要另找一个数Y,令Y的低4位为1,其余位为0,即Y=0000 1111,然后将X与Y进行异或运算(X^Y=1010 0001)即可得到。\n2)与0相异或值不变 例如:1010 1110 ^ 0000 0000 = 1010 1110\n3)常见操作\n1 2 3 4 5 6 7 a=0^a=a^0 0=a^a 由上面两个推导出:a=a^b^b 获取最后一个 1 diff=(n\u0026amp;(n-1))^n 4) 交换两个数\n1 2 3 4 5 void swap(int \u0026amp;a, int \u0026amp;b) { a ^= b; b ^= a; a ^= b; } 5) n \u0026amp; n -1\n1 2 3 4 5 6 7 8 9 移除最后一个 1 a=n\u0026amp;(n-1) # 用 O(1) 时间检测整数 n 是否是 2 的幂次 N如果是2的幂次,则N满足两个条件。 1.N \u0026gt;0 2.N的二进制表示中只有一个1 因为N的二进制表示中只有一个1,所以使用N \u0026amp; (N - 1)将N唯一的一个1消去,应该返回0。 5. 左移运算符(\u0026laquo;) 定义:将一个运算对象的各二进制位全部左移若干位(左边的二进制位丢弃,右边补0)。\n设 a=1010 1110,a = a\u0026laquo; 2 将a的二进制位左移2位、右补0,即得a=1011 1000。\n若左移时舍弃的高位不包含1,则每左移一位,相当于该数乘以2。\n6. 右移运算符(\u0026raquo;) 定义:将一个数的各二进制位全部右移若干位,正数左补0,负数左补1,右边丢弃。\n例如:a=a\u0026raquo;2 将a的二进制位右移2位,左补0 或者 左补1得看被移数是正还是负。\n操作数每右移一位,相当于该数除以2。\n应用 位操作统计二进制中 1 的个数\n1 2 3 4 5 count = 0 for a \u0026gt; 0{ a = a \u0026amp; (a - 1); count++; } 用一 数组中,只有一个数出现一次,剩下都出现两次,找出出现一次的数\n1 a := a^b^b ","permalink":"https://reid00.github.io/en/posts/algo/%E5%B8%B8%E8%A7%81%E7%9A%84%E4%BA%8C%E8%BF%9B%E4%BD%8D%E8%BF%90%E7%AE%97%E6%8A%80%E5%B7%A7/","summary":"1. 位运算概述 从现代计算机中所有的数据二进制的形式存储在设备中。即 0、1 两种状态,计算机对二进制数据进行的运算(+、-、*、/)都是叫位运算,","title":"常见的二进位运算技巧"},{"content":"背景 今天,聊一个有趣的问题:拔掉网线几秒,再插回去,原本的 TCP 连接还存在吗?\n可能有的同学会说,网线都被拔掉了,那说明物理层被断开了,那在上层的传输层理应也会断开,所以原本的 TCP 连接就不会存在了。就好像, 我们拨打有线电话的时候,如果某一方的电话线被拔了,那么本次通话就彻底断了。\n真的是这样吗?\n上面这个逻辑就有问题。问题在于,错误地认为拔掉网线这个动作会影响传输层,事实上并不会影响。\n实际上,TCP 连接在 Linux 内核中是一个名为 struct socket 的结构体,该结构体的内容包含 TCP 连接的状态等信息。当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变。\n我在我的电脑上做了个小实验,我用 ssh 终端连接了我的云服务器,然后我通过断开 wifi 的方式来模拟拔掉网线的场景,此时查看 TCP 连接的状态没有发生变化,还是处于 ESTABLISHED 状态。 通过上面这个实验结果,我们知道了,拔掉网线这个动作并不会影响 TCP 连接的状态。 接下来,要看拔掉网线后,双方做了什么动作。 针对这个问题,要分场景来讨论:\n拔掉网线后,有数据传输; 拔掉网线后,没有数据传输。 拔掉网线后,有数据传输 在客户端拔掉网线后,服务端向客户端发送的数据报文会得不到任何的响应,在等待一定时长后,服务端就会触发超时重传机制,重传未得到响应的数据报文。\n如果在服务端重传报文的过程中,客户端刚好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。\n此时,客户端和服务端的 TCP 连接依然存在,就感觉什么事情都没有发生。\n但是,如果在服务端重传报文的过程中,客户端一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。\n而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元组的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。\n此时,客户端和服务端的 TCP 连接都已经断开了。\n那 TCP 的数据报文具体重传几次呢? 在 Linux 系统中,提供了一个叫 tcp_retries2 配置项,默认值是 15,如下:\n1 2 [root@nebula-server-6 shell]# cat /proc/sys/net/ipv4/tcp_retries2 15 这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。\n不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,内核还会基于「最大超时时间」来判定。\n每一轮的超时时间都是倍数增长的,比如第一次触发超时重传是在 2s 后,第二次则是在 4s 后,第三次则是 8s 后,以此类推。\n内核会根据 tcp_retries2 设置的值,计算出一个最大超时时间。\n在重传报文且一直没有收到对方响应的情况时,先达到「最大重传次数」或者「最大超时时间」这两个的其中一个条件后,就会停止重传,然后就会断开 TCP 连接。\n拔掉网线后,没有数据传输。 针对拔掉网线后,没有数据传输的场景,还得看是否开启了 TCP keepalive 机制 (TCP 保活机制)。\n如果没有开启 TCP keepalive 机制,在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。\n而如果开启了 TCP keepalive 机制,在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文:\n如果对端是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。 如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。 TCP keepalive 机制具体是怎么样的? 这个机制的原理是这样的: 定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。\n在 Linux 内核有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:\n1 2 3 net.ipv4.tcp_keepalive_time=7200 net.ipv4.tcp_keepalive_intvl=75 net.ipv4.tcp_keepalive_probes=9 tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制; tcp_keepalive_intvl=75:表示每次检测间隔 75 秒; tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。 也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。\ntcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes ==\u0026gt; 7200 + 75 * 9 =7879s (2h11min15s)\n注意,应用程序若想使用 TCP 保活机制,需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。\nTCP keepalive 机制探测的时间也太长了吧? 对的,是有点长。\nTCP keepalive 是 TCP 层(内核态) 实现的,它是给所有基于 TCP 传输协议的程序一个兜底的方案。\n实际上,我们应用层可以自己实现一套探测机制,可以在较短的时间内,探测到对方是否存活。\n比如,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在发完一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。 总结 客户端拔掉网线后,并不会直接影响 TCP 连接状态。所以,拔掉网线后,TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输。\n有数据传输的情况:\n在客户端拔掉网线后,如果服务端发送了数据报文,那么在服务端重传次数没有达到最大值之前,客户端就插回了网线,那么双方原本的 TCP 连接还是能正常存在,就好像什么事情都没有发生。\n在客户端拔掉网线后,如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,服务端就会断开 TCP 连接。等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端相同四元组的 TCP 连接,所以就会回 RST 报文,客户端收到后就会断开 TCP 连接。至此, 双方的 TCP 连接都断开了。\n没有数据传输的情况:\n如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在。 如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接。而如果在 TCP 探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在。 除了客户端拔掉网线的场景,还有客户端「宕机和杀死进程」的两种场景。\n第一个场景,客户端宕机这件事跟拔掉网线是一样无法被服务端感知的,所以如果在没有数据传输,并且没有开启 TCP keepalive 机制时,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。\n所以,我们可以得知一个点。在没有使用 TCP 保活机制,且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态时,并不代表另一方的 TCP 连接还一定是正常的。\n第二个场景,杀死客户端的进程后,客户端的内核就会向服务端发送 FIN 报文,与客户端进行四次挥手。\n所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知得到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。\n扩展 TCP重置报文段及RST常见场景分析 RST表示连接重置,用于关闭那些已经没有必要继续存在的连接。一般情况下表示异常关闭连接,区别与四次分手正常关闭连接。\n我们知道TCP建立连接的时候需要三次连接,TCP释放连接的时候需要四次挥手,在这个过程中,出现了很多特殊的标志报文段,例如SYN ACK FIN,在TCP协议中,除了上面说了那些标志报文段之外,还有其他的报文段,如PUSH标志报文段以及今天需要重点讲解的RST报文段。\nRST:(Reset the connection)用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到RST位时候,通常发生了某些错误;\n发送RST包关闭连接时,不必等缓冲区的包都发出去,直接就丢弃缓冲区中的包,发送RST;接收端收到RST包后,也不必发送ACK包来确认。\n“Connection reset”的原因是服务器关闭了Connection[调用了Socket.close()方法]。大家可能有疑问了:服务器关闭了Connection为什么会返回“RST”而不是返回“FIN”标志。原因在于Socket.close()方法的语义和TCP的“FIN”标志语义不一样: 发送TCP的“FIN”标志表示我不再发送数据了,而Socket.close()表示我不在发送也不接受数据了。问题就出在“我不接受数据” 上,如果此时客户端还往服务器发送数据,服务器内核接收到数据,但是发现此时Socket已经close了,则会返回“RST”标志给客户端。当然,此时客户端就会提示:“Connection reset”。\n产生RST的三个条件是:\n目的地 为某端口的SYN到达,然而在该端口上并没有正在监听的服务器; TCP想取消一个已有连接; TCP接收到一个根本不存在的连接上的分节。 Connection reset 与 Connection reset by peer 服务器返回了 “RST” 时,如果此时客户端正在从 Socket 套接字的输出流中读数据则会提示 Connection reset ; A向B发起连接,但B之上并未监听相应的端口,这时B操作系统上的 TCP 处理程序会发 RST 包。\n服务器返回了 “RST” 时,如果此时客户端正在往 Socket 套接字的输入流中写数据则会提示 Connection reset by peer 。 AB正常建立连接了,正在通讯时,A向B发送了FIN包要求关连接,B发送ACK后,网断了,A通过若干原因放弃了这个连接(例如进程重启)。 等网络恢复之后,B又开始发数据包(客户端并不知道,服务器已经忘记三次握手了),A收到后表示压力很大,不知道这野连接哪来的,就发了个RST包强制把连接关了,B收到后会出现 connect reset by peer 错误。\n需要注意的是,服务端有两种情况不会发送RST:\n服务器关机: 会断开 TCP 连接,会发送 FIN 数据报\n服务器主机崩溃的状态 如果,客户端和服务器已经建立了连接的时候,此时服务器崩溃(达到这一标准可以把服务器的网线拔掉,这个时候,服务器就不能发送 FIN 数据报了,和关机不一样的)\n此时如果客户端向服务器发送数据的时候,因为服务器已经不存在了,那么客户端就不能接受到服务器给客户端的 ack 信息,这个时候,客户端建立的是 TCP 连接,就会重发数据报,发送多少次之后就会返回超时,也就是 ETIMEOUT 。\nETIMEOUT:当connect调用的时候会进行三次握手,如果客户端没有收到服务器对SYN的ACK数据报,就会返回ETIMEOUT(客户端在返回这个错误之前会重发SYN数据报)\n前面谈到了导致 “Connection reset” 的原因,而具体的解决方案有如下几种:\n出错了重试; 客户端和服务器统一使用TCP长连接; 客户端和服务器统一使用TCP短连接。 首先是出错了重试:这种方案可以简单防止 “Connection reset” 错误,然后如果服务不是 “幂等” 的则不能使用该方法;比如提交订单操作就不是幂等的,如果使用重试则可能造成重复提单。\n然后是客户端和服务器统一使用 TCP 长连接:客户端使用 TCP 长连接很容易配置(直接设置HttpClient就好),而服务器配置长连接就比较麻烦了,就拿tomcat来说,需要设置 tomcat 的 maxKeepAliveRequests 、connectionTimeout 等参数。另外如果使用了 nginx 进行反向代理或负载均衡,此时也需要配置 nginx 以支持长连接(nginx默认是对客户端使用长连接,对服务器使用短连接,详见 keepalived 相关指令)。\n使用长连接可以避免每次建立 TCP 连接的三次握手而节约一定的时间,但是我这边由于是内网,客户端和服务器的 3 次握手很快,大约只需1ms。ping一下大约0.93ms(一次往返);三次握手也是一次往返(第三次握手不用返回)。根据80/20原理,1ms可以忽略不计;又考虑到长连接的扩展性不如短连接好、修改nginx和tomcat的配置代价很大(所有后台服务都需要修改);所以这里并没有使用长连接。\n小结\nConnection reset,远程主机没有监听这个端口、连接,可以是: 服务端已关闭,客户端仍旧请求,服务端返回Rst; 服务端未监听该端口,客户端请求,服务端返回Rst; Connection reset by peer,是远程主机强迫关闭了一个现有的连接,可以是: 客户端断网重连,服务端返回Rst; 服务端进程崩溃后重启,向先前的客户端返回Rst,并等待下次重新与客户端建连; ","permalink":"https://reid00.github.io/en/posts/os_network/%E6%8B%94%E6%8E%89%E7%BD%91%E7%BA%BF%E5%90%8E%E5%8E%9F%E6%9C%AC%E7%9A%84tcp%E8%BF%9E%E6%8E%A5%E8%BF%98%E5%AD%98%E5%9C%A8%E5%90%97/","summary":"背景 今天,聊一个有趣的问题:拔掉网线几秒,再插回去,原本的 TCP 连接还存在吗? 可能有的同学会说,网线都被拔掉了,那说明物理层被断开了,那在上层的","title":"拔掉网线后,原本的TCP连接还存在吗?"},{"content":"安装 Utterances 首先要有一个 GitHub 仓库。如果是用 GitHub Page 托管网站就可以不需要额外创建,就用你的GitHub Page repositroy 如:.github.io 仓库, 当然也可以自己重新创建一个,用来存放评论。但是需要注意的是这个仓库必须是Public 的。 比如我的为https://github.com/Reid00/hugo-blog-talks\n然后去 https://github.com/apps/utterances 安装 utterances。\n在打开的页面中选择Only select repositories,并在下拉框中选择自己的博客仓库(比如我就是 Reid00/hugo-blog-talks,也可以安装到其他仓库, 也可以所有仓库,但是不推荐),然后点击 Install。 配置Hugo 复制以下代码,repo 要修改成自己的仓库,repo 为你存放评论的仓库。\n1 2 3 4 5 6 7 8 \u0026lt;script src=\u0026#34;https://utteranc.es/client.js\u0026#34; repo=\u0026#34;Reid00/hugo-blog-talks\u0026#34; issue-term=\u0026#34;pathname\u0026#34; label=\u0026#34;Comment\u0026#34; theme=\u0026#34;github-light\u0026#34; crossorigin=\u0026#34;anonymous\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; 在主题配置目录下创建 layouts/partials/comments.html 文件,并添加上述内容\n1 2 3 4 5 6 7 8 9 10 11 {{- /* Comments area start */ -}} {{- /* to add comments read =\u0026gt; https://gohugo.io/content-management/comments/ */ -}} \u0026lt;script src=\u0026#34;https://utteranc.es/client.js\u0026#34; repo=\u0026#34;Reid00/hugo-blog-talks\u0026#34; issue-term=\u0026#34;pathname\u0026#34; label=\u0026#34;Comment\u0026#34; theme=\u0026#34;github-light\u0026#34; crossorigin=\u0026#34;anonymous\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; {{- /* Comments area end */ -}} 然后根据 PaperMod 文档,打开 config.yml 文件,添加以下内容\n1 2 params: comments: true 问题 如果你用GitHub Action 部署的GitHub Page, 并且你的workflow 里面写了\n1 2 3 4 5 - name: Check out repository code uses: actions/checkout@v3 with: submodules: recursive # Fetch Hugo themes (true OR recursive) fetch-depth: 0 这个时候,theme 主题会自动pull 源主题的repo,覆盖layouts/partials/comments.html 文件, 此时,你可以在项目的layouts 创建改文件和内容即可。\n","permalink":"https://reid00.github.io/en/posts/other/utterances-%E7%BB%99-hugo-papermod-%E4%B8%BB%E9%A2%98%E6%B7%BB%E5%8A%A0%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/","summary":"安装 Utterances 首先要有一个 GitHub 仓库。如果是用 GitHub Page 托管网站就可以不需要额外创建,就用你的GitHub Page repositroy 如:.github.io 仓库, 当然也可以自己重新","title":"Utterances 给 Hugo PaperMod 主题添加评论系统"},{"content":"准备vscode 插件 在vs code的扩展商店中搜索remote-ssh, install 配置remmote-ssh 插件 使用快捷点, ctrl + shift + P 输入config 选择第一个,在.ssh 目录的config文件 按照以下格式配置\n1 2 3 4 5 Host Personal HostName 172.16.1.1 User root Port 22 IdentityFile C:\\Users\\ld\\.ssh\\id_rsa Host: 自定义的服务器名称,用于个人区分 HostName: 需要远程的服务器的IP 地址 User: 远程服务器用的账号 Port: 默认ssh 端口22 IdentityFile: 免登录的id_rsa路径 注意: 多次实验加入IdentityFile 都不能做到通过跳板机免密码,最后把客户机的id_rsa.pub添加到target 才免密, 相当于直接可以连接target机器了。\n如果通过跳板机连接服务器 有时候我们需要跳板机来连接服务器,也即先连接一台跳板机服务器,然后通过这台跳板机所在的内网再次跳转到目标服务器。 最简单的做法就是按上述方法连接到跳板机,然后在跳板机的终端用ssh指令跳转到目标服务器,但这样跳转后,我们无法在VScode中打开服务器的文件目录,操作起来很不方便。我们可以把config的设置改成如下,就可以通过c00跳板机跳转到c01了\n1 2 3 4 5 6 Host BackupCluster HostName 1.16.1.1 User root Port 22 ProxyCommand C:\\Windows\\System32\\OpenSSH\\ssh.exe -W %h:%p -q Personal IdentityFile C:\\Users\\ld\\.ssh\\id_rsa ProxyCommand: openssh的安装目录(我这里是C:\\Windows\\System32\\OpenSSH\\ssh.exe) -W表示stdio forwarding模式 接着后面的%h是一个占位符,表示要连接的目标机,也就是Hostname指定的ip或者主机名 %p同样也是占位符,表示要连接到目标机的端口。 %h, %p这里可以直接写死固定值,但是使用%h和%p可以保证在Hostname和Port变化的情况下ProxyCommand这行不用跟着变化 openssh 的安装方法\n以管理员身份运行window Powershell,然后键入如下两条命令\n1 Get-WindowsCapability -Online | ? Name -like \u0026#39;OpenSSH*\u0026#39; 这条是用来检测是否有适合安装的openssh软件,正常情况下应有如下返回:\n1 2 3 4 Name : OpenSSH.Client~~~~0.0.1.0 State : NotPresent Name : OpenSSH.Server~~~~0.0.1.0 State : NotPresent 第二条命令:\n1 Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 如果安装完成应有如下返回:\n1 2 3 Path : Online : True RestartNeeded : False 免密码登录 如从A登录B,A=\u0026gt;B 本机创建ssh密钥, 生成在~/.ssh目录下\n1 ssh-keygen -t rsa 说明:\nauthorized_keys:其实就是存放各个机器公钥的地方, id_rsa : 生成的私钥文件, id_rsa.pub : 生成的公钥文件, know_hosts : 已知的主机公钥清单, 设置互信其实就是将id_rsa.pub公钥信息发送到需要被信任的机器上的authorized_keys文件中即可,也就是A发送到B上authorized_keys文件中\n发送密钥 在A 机上运行\n1 ssh-copy-id root@192.168.x.x 如果ssh-copy-id执行不了的话(没有ssh默认库的情况),可以使用scp进行发送,即:\n1 scp -p ~/.ssh/id_rsa.pub root@192.168.x.x:/root/.ssh/authorized_keys 通过以上命令, 即可将公钥发送过去,发送过去之后可以登录B机器查看authorized_keys文件,可以看到了机器A的公钥信息,如过是多个机器发送给B的话则保存多个公钥信息,如下 发送成功之后,再次ssh登录,从A机器登录到B 机器。\n","permalink":"https://reid00.github.io/en/posts/other/vscode%E8%BF%9C%E7%A8%8B%E5%BC%80%E5%8F%91%E9%85%8D%E7%BD%AE/","summary":"准备vscode 插件 在vs code的扩展商店中搜索remote-ssh, install 配置remmote-ssh 插件 使用快捷点, ctrl + shift + P 输入confi","title":"Vscode远程开发配置"},{"content":"在做接口测试时,经常会碰到请求参数为token的类型,但是可能大部分测试人员对token,cookie,session的区别还是一知半解。\nCookie cookie 是一个非常具体的东西,指的就是浏览器里面能永久存储的一种数据,仅仅是浏览器实现的一种数据存储功能。\ncookie由服务器生成,发送给浏览器,浏览器把cookie以kv形式保存到某个目录下的文本文件内,下一次请求同一网站时会把该cookie发送给服务器。由于cookie是存在客户端上的,所以浏览器加入了一些限制确保cookie不会被恶意使用,同时不会占据太多磁盘空间,所以每个域的cookie数量是有限的。\nSession session 从字面上讲,就是会话。这个就类似于你和一个人交谈,你怎么知道当前和你交谈的是张三而不是李四呢?对方肯定有某种特征(长相等)表明他就是张三。\nsession 也是类似的道理,服务器要知道当前发请求给自己的是谁。为了做这种区分,服务器就要给每个客户端分配不同的“身份标识”,然后客户端每次向服务器发请求的时候,都带上这个“身份标识”,服务器就知道这个请求来自于谁了。至于客户端怎么保存这个“身份标识”,可以有很多种方式,对于浏览器客户端,大家都默认采用 cookie 的方式。\n服务器使用session把用户的信息临时保存在了服务器上,用户离开网站后session会被销毁。这种用户信息存储方式相对cookie来说更安全,可是session有一个缺陷:如果web服务器做了负载均衡,那么下一个操作请求到了另一台服务器的时候session会丢失。\nToken Token的引入:Token是在客户端频繁向服务端请求数据,服务端频繁的去数据库查询用户名和密码并进行对比,判断用户名和密码正确与否,并作出相应提示,在这样的背景下,Token便应运而生。\nToken的定义:Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。最简单的token组成:uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,由token的前几位+盐以哈希算法压缩成一定长的十六进制字符串,可以防止恶意第三方拼接token请求服务器)。\n使用Token的目的:Token的目的是为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。\n传统身份验证 HTTP 是一种没有状态的协议,也就是它并不知道是谁是访问应用。这里我们把用户看成是客户端,客户端使用用户名还有密码通过了身份验证,不过下回这个客户端再发送请求时候,还得再验证一下。\n解决的方法就是,当用户请求登录的时候,如果没有问题,我们在服务端生成一条记录,这个记录里可以说明一下登录的用户是谁,然后把这条记录的 ID 号发送给客户端,客户端收到以后把这个 ID 号存储在 Cookie 里,下次这个用户再向服务端发送请求的时候,可以带着这个 Cookie ,这样服务端会验证一个这个 Cookie 里的信息,看看能不能在服务端这里找到对应的记录,如果可以,说明用户已经通过了身份验证,就把用户请求的数据返回给客户端。\n上面说的就是 Session,我们需要在服务端存储为登录的用户生成的 Session ,这些 Session 可能会存储在内存,磁盘,或者数据库里。我们可能需要在服务端定期的去清理过期的 Session 。\n基于 Token 的身份验证 使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。大概的流程是这样的:\n客户端使用用户名跟密码请求登录 服务端收到请求,去验证用户名与密码 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据 APP登录的时候发送加密的用户名和密码到服务器,服务器验证用户名和密码,如果成功,以某种方式比如随机生成32位的字符串作为token,存储到服务器中,并返回token到APP,以后APP请求时,凡是需要验证的地方都要带上该token,然后服务器端验证token,成功返回所需要的结果,失败返回错误信息,让他重新登录。其中服务器上token设置一个有效期,每次APP请求的时候都验证token和有效期。\n那么我的问题来了:1.服务器上的token存储到数据库中,每次查询会不会很费时。如果不存储到数据库,应该存储到哪里呢。2.客户端得到的token肯定要加密存储的,发送token的时候再解密。存储到数据库还是配置文件呢?\ntoken是个易失数据,丢了无非让用户重新登录一下,新浪微博动不动就让我重新登录,反正这事儿我是无所谓啦。 所以如果你觉得普通的数据库表撑不住了,可以放到 MSSQL/MySQL 的内存表里(不过据说mysql的内存表性能提升有限),可以放到 Memcache里(讲真,这个是挺常见的策略),可以放到redis里(我做过这样的实现),甚至可以放到 OpenResty 的变量字典里(只要你有信心不爆内存)。\ntoken是个凭条,不过它比门票温柔多了,门票丢了重新花钱买,token丢了重新操作下认证一个就可以了,因此token丢失的代价是可以忍受的——前提是你别丢太频繁,要是让用户隔三差五就认证一次那就损失用户体验了。\n基于这个出发点,如果你认为用数据库来保持token查询时间太长,会成为你系统的瓶颈或者隐患,可以放在内存当中。 比如memcached、redis,KV方式很适合你对token查询的需求。 这个不会太占内存,比如你的token是32位字符串,要是你的用户量在百万级或者千万级,那才多少内存。 要是数据量真的大到单机内存扛不住,或者觉得一宕机全丢风险大,只要这个token生成是足够均匀的,高低位切一下分到不同机器上就行,内存绝对不会是问题。\n客户端方面这个除非你有一个非常安全的办法,比如操作系统提供的隐私数据存储,那token肯定会存在泄露的问题。比如我拿到你的手机,把你的token拷出来,在过期之前就都可以以你的身份在别的地方登录。 解决这个问题的一个简单办法 1、在存储的时候把token进行对称加密存储,用时解开。 2、将请求URL、时间戳、token三者进行合并加盐签名,服务端校验有效性。 这两种办法的出发点都是:窃取你存储的数据较为容易,而反汇编你的程序hack你的加密解密和签名算法是比较难的。然而其实说难也不难,所以终究是防君子不防小人的做法。话说加密存储一个你要是被人扒开客户端看也不会被喷明文存储…… 方法1它拿到存储的密文解不开、方法2它不知道你的签名算法和盐,两者可以结合食用。 但是如果token被人拷走,他自然也能植入到自己的手机里面,那到时候他的手机也可以以你的身份来用着,这你就瞎了。 于是可以提供一个让用户可以主动expire一个过去的token类似的机制,在被盗的时候能远程止损。\n在网络层面上token明文传输的话会非常的危险,所以建议一定要使用HTTPS,并且把token放在post body里。\n补充 cookie与session的区别 1、cookie数据存放在客户端上,session数据放在服务器上。\n2、cookie不是很安全,别人可以分析存放在本地的COOKIE并进行COOKIE欺骗 考虑到安全应当使用session。\n3、session会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能 考虑到减轻服务器性能方面,应当使用COOKIE。\n4、单个cookie保存的数据不能超过4K,很多浏览器都限制一个站点最多保存20个cookie。\n5、所以个人建议: 将登陆信息等重要信息存放为SESSION 其他信息如果需要保留,可以放在COOKIE中\nsession与token的区别 session 和 oauth token并不矛盾,作为身份认证 token安全性比session好,因为每个请求都有签名还能防止监听以及重放攻击,而session就必须靠链路层来保障通讯安全了。如上所说,如果你需要实现有状态的会话,仍然可以增加session来在服务器端保存一些状态\nApp通常用restful api跟server打交道。Rest是stateless的,也就是app不需要像browser那样用cookie来保存session,因此用session token来标示自己就够了,session/state由api server的逻辑处理。 如果你的后端不是stateless的rest api, 那么你可能需要在app里保存session.可以在app里嵌入webkit,用一个隐藏的browser来管理cookie session.\nSession 是一种HTTP存储机制,目的是为无状态的HTTP提供的持久机制。所谓Session 认证只是简单的把User 信息存储到Session 里,因为SID 的不可预测性,暂且认为是安全的。这是一种认证手段。 而Token ,如果指的是OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对App 。其目的是让 某App有权利访问 某用户 的信息。这里的 Token是唯一的。不可以转移到其它 App上,也不可以转到其它 用户 上。 转过来说Session 。Session只提供一种简单的认证,即有此 SID,即认为有此 User的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方App。 所以简单来说,如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。\n打破误解: “只要关闭浏览器 ,session就消失了?”\n不对。对session来说,除非程序通知服务器删除一个session,否则服务器会一直保留,程序一般都是在用户做log off的时候发个指令去删除session。\n然而浏览器从来不会主动在关闭之前通知服务器它将要关闭,因此服务器根本不会有机会知道浏览器已经关闭,之所以会有这种错觉,是大部分session机制都使用会话cookie来保存session id,而关闭浏览器后这个session id就消失了,再次连接服务器时也就无法找到原来的session。如果服务器设置的cookie被保存在硬盘上,或者使用某种手段改写浏览器发出的HTTP请求头,把原来的session id发送给服务器,则再次打开浏览器仍然能够打开原来的session.\n恰恰是**由于关闭浏览器不会导致session被删除,迫使服务器为session设置了一个失效时间,当距离客户端上一次使用session的时间超过这个失效时间时,服务器就可以以为客户端已经停止了活动,才会把session删除以节省存储空间。**\n","permalink":"https://reid00.github.io/en/posts/os_network/token-cookie-session%E5%8C%BA%E5%88%AB/","summary":"在做接口测试时,经常会碰到请求参数为token的类型,但是可能大部分测试人员对token,cookie,session的区别还是一知半解。 Cookie","title":"Token Cookie Session区别"},{"content":"简介 这有篇很好的文章,可以明白这个问题:\n为什么会报错“UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)”?本文就来研究一下这个问题。\n字符串在Python内部的表示是unicode编码,因此,在做编码转换时,通常需要以unicode作为中间编码,即先将其他编码的字符串解码(decode)成unicode,再从unicode编码(encode)成另一种编码。\ndecode的作用是将其他编码的字符串转换成unicode编码,如str1.decode('gb2312'),表示将gb2312编码的字符串str1转换成unicode编码。\nencode的作用是将unicode编码转换成其他编码的字符串,如str2.encode('gb2312'),表示将unicode编码的字符串str2转换成gb2312编码。\n因此,转码的时候一定要先搞明白,字符串str是什么编码,然后decode成unicode,然后再encode成其他编码\n代码中字符串的默认编码与代码文件本身的编码一致。\n如:s=\u0026lsquo;中文\u0026rsquo;\n如果是在utf8的文件中,该字符串就是utf8编码,如果是在gb2312的文件中,则其编码为gb2312。这种情况下,要进行编码转换,都需 要先用decode方法将其转换成unicode编码,再使用encode方法将其转换成其他编码。通常,在没有指定特定的编码方式时,都是使用的系统默 认编码创建的代码文件。\n如果字符串是这样定义:s=u\u0026rsquo;中文'\n则该字符串的编码就被指定为unicode了,即python的内部编码,而与代码文件本身的编码无关。因此,对于这种情况做编码转换,只需要直接使用encode方法将其转换成指定编码即可。\n如果一个字符串已经是unicode了,再进行解码则将出错,因此通常要对其编码方式是否为unicode进行判断:\nisinstance(s, unicode) #用来判断是否为unicode\n用非unicode编码形式的str来encode会报错\n如何获得系统的默认编码?\n1 2 3 4 5 6 7 #!/usr/bin/env python #coding=utf-8 import sys print sys.getdefaultencoding() 该段程序在英文WindowsXP上输出为:ascii\n在某些IDE中,字符串的输出总是出现乱码,甚至错误,其实是由于IDE的结果输出控制台自身不能显示字符串的编码,而不是程序本身的问题。\n如在UliPad中运行如下代码:\n1 2 3 s=u\u0026#34;中文\u0026#34; print s 会提示:UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)。这是因为UliPad在英文WindowsXP上的控制台信息输出窗口是按照ascii编码输出的(英文系统的默认编码是 ascii),而上面代码中的字符串是Unicode编码的,所以输出时产生了错误。\n将最后一句改为:print s.encode('gb2312')\n则能正确输出“中文”两个字。\n若最后一句改为:print s.encode('utf8')\n则输出:\\xe4\\xb8\\xad\\xe6\\x96\\x87,这是控制台信息输出窗口按照ascii编码输出utf8编码的字符串的结果。\nunicode(str,'gb2312')与str.decode('gb2312')是一样的,都是将gb2312编码的str转为unicode编码\n使用str.__class__可以查看str的编码形式\n1 2 3 4 5 \u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt; groups.google.com/group/python-cn/browse_thread/thread/be4e4e0d4c3272dd ----- python是个容易出现编码问题的语言。所以,我按照我的理解写下下面这些文字。\n1. 首先,要了解几个概念。 字节:计算机数据的表示。8位二进制。可以表示无符号整数:0-255。下文,用“字节流”表示“字节”组成的串。\n字符:英文字符“abc”,或者中文字符“你我他”。字符本身不知道如何在计算机中保存。下文中,会避免使用“字符串”这个词,而用“文本”来表\n示“字符”组成的串。\n编码(动词):按照某种规则(这个规则称为:编码(名词))将“文本”转换为“字节流”。(在python中:unicode变成str)\n解码(动词):将“字节流”按照某种规则转换成“文本”。(在Python中:str变成unicode)\n实际上,任何东西在计算机中表示,都需要编码。例如,视频要编码然后保存在文件中,播放的时候需要解码才能观看。\nunicode:unicode定义了,一个“字符”和一个“数字”的对应,但是并没有规定这个“数字”在计算机中怎么保存。(就像在C中,一个整数既\n可以是int,也可以是short。unicode没有规定用int还是用short来表示一个“字符”)\nutf8:unicode实现。它使用unicode定义的“字符”“数字”映射,进而规定了,如何在计算机中保存这个数字。其它的utf16等都是 unicode实现。\ngbk:类似utf8这样的“编码”。但是它没有使用unicode定义的“字符”“数字”映射,而是使用了另一套的映射方法。而且,它还定义了如何在计算机中保存。\n2. python中的encode,decode方法 首先,要知道encode是 unicode转换成str。decode是str转换成unicode。\n下文中,u代表unicode类型的变量,s代表str类型的变量。\nu.encode('...')基本上总是能成功的,只要你填写了正确的编码。就像任何文件都可以压缩成zip文件。\ns.decode('...')经常是会出错的,因为str是什么“编码”取决于上下文,当你解码的时候需要确保s是用什么编码的。就像,打开zip文件的时候,你要确保它确实是zip文件,而不仅仅是伪造了扩展名的zip文件。\nu.decode(),s.encode()不建议使用,s.encode相当于s.decode().encode()首先用默认编码(一般是ascii)转换成unicode在进行encode。\n3. 关于#coding=utf8= 当你在py文件的第一行中,写了这句话,并确实按照这个编码保存了文本的话,那么这句话有以下几个功能。\n1.使得词法分析器能正常运作,对于注释中的中文不报错了。\n2.对于u\u0026quot;中文\u0026quot;这样literal string能知道两个引号中的内容是utf8编码的,然后能正确转换成unicode\n3.\u0026ldquo;中文\u0026quot;对于这样的literal string你会知道,这中间的内容是utf8编码,然后就可以正确转换成其它编码或unicode了。\n4. Python编码和Windows控制台 我发现,很多初学者出错的地方都在print语句,这牵涉到控制台的输出。我不了解linux,所以只说控制台的。\n首先,Windows的控制台确实是unicode(utf16_le编码)的,或者更准确的说使用字符为单位输出文本的。\n但是,程序的执行是可以被重定向到文件的,而文件的单位是“字节”。\n所以,对于C运行时的函数printf之类的,输出必须有一个编码,把文本转换成字节。可能是为了兼容95,98,\n没有使用unicode的编码,而是mbcs(不是gbk之类的)。\nwindows的mbcs,也就是ansi,它会在不同语言的windows中使用不同的编码,在中文的windows中就是gb系列的编码。\n这造成了同一个文本,在不同语言的windows中是不兼容的。\n现在我们知道了,如果你要在windows的控制台中输出文本,它的编码一定要是“mbcs”。\n对于python的unicode变量,使用print输出的话,会使用sys.getfilesystemencoding()返回的编码,把它变成str。\n如果是一个utf8编码str变量,那么就需要 print s.decode(\u0026lsquo;utf8\u0026rsquo;).encode(\u0026lsquo;mbcs\u0026rsquo;)\n最后,对于str变量,file文件读取的内容,urllib得到的网络上的内容,都是以“字节”形式的。\n它们如果确实是一段“文本”,比如你想print出来看看。那么你必须知道它们的编码。然后decode成unicode。\n如何知道它们的编码:\n事先约定。(比如这个文本文件就是你自己用utf8编码保存的) 协议。(python文件第一行的#coding=utf8,html中的等) 猜。 这个非常好,但还不是很明白将“文本”转换为“字节流”。(在python中:unicode变成str)\n\u0026ldquo;最后,对于str变量,file文件读取的内容,urllib得到的网络上的内容,都是以“字节”形式的。\u0026rdquo;\n虽然文件或者网页是文本的,但是在保存或者传输时已经被编码成bytes了,所以用\u0026quot;rb\u0026quot;打开的file和从socket读取的流是基于字节的.\n\u0026ldquo;它们如果确实是一段“文本”,比如你想print出来看看。那么你必须知道它们的编码。然后decode成unicode。\u0026rdquo;\n这里的加引号的\u0026quot;文本\u0026rdquo;,其实还是字节流(bytes),而不是真正的文本(unicode),只是说明我们知道他是可以解码成文本的.\n在解码的时候,如果是基于约定的,那就可以直接从指定地方读取如BOM或者python文件的指定coding或者网页的meta,就可以正确解码,\n但是现在很多文件/网页虽然指定了编码,但是文件格式实际却使用了其他的编码(比如py文件指定了coding=utf8,但是你还是可以保存成ansi\u0026ndash;记事本的默认编码),这种情况下真实的编码就需要去猜了\n解码了的文本只存在运行环境中,如果你需要打印/保存/输出给数据库/网络传递,就又需要一次编码过程,这个编码与上面的编码没有关系,只是依赖于你的选择,但是这个编码也不是可以随便选择的,因为编码后的bytes如果又需要传递给其他人/环境,那么如果你的编码也不遵循约定,又给下一个人/环境造成了困扰,于是递归之~~~~\n主要有一条非常容易误解:\n一般人会认为Unicode(广义)统一了编码,其实不然。Unicode不是唯一的编码,而一大堆编码的统称。但是Windows下Unicode(狭义)一般特指UCS2,也就是UTF-16/LE\nunicode作为字符集(ucs)是唯一的,编码方案(utf)才是有很多种\n将字符与字节的概念区分开来是很重要的。Java 一直就是这样,Python也开始这么做了,Ruby 貌似还在混乱当中。\n我也说两句。我对编码的研究相对比较深一些。因为工作中也经常遇到乱码,于是在05年,对编码专门做过研究,并在公司刊物上发过文章,最后形成了一个教材,每年在公司给新员工都讲一遍。于是项目中遇到乱码的问题就能很快的定位并解决了。\n理论上,从一个字符到具体的编码,会经过以下几个概念。\n字符集(Abstract character repertoire) 编码字符集(Coded character set) 字符编码方式(Character encoding form) 字符编码方案(Character encoding scheme) 字符集:就算一堆抽象的字符,如所有中文。字符集的定义是抽象的,与计算机无关。 编码字符集:是一个从整数集子集到字符集抽象元素的映射。即给抽象的字符编上数字。如gb2312中的定义的字符,每个字符都有个整数和它对应。一个整数只对应着一个字符。反过来,则不一定是。这里所说的映射关系,是数学意义上的映射关系。编码字符集也是与计算机无关的。unicode字符集也在这一层。 字符编码方式:这个开始与计算机有关了。编码字符集的编码点在计算机里的具体表现形式。通俗的说,意思就是怎么样才能将字符所对应的整数的放进计算机内存,或文件、或网络中。于是,不同人有不同的实现方式,所谓的万码奔腾,就是指这个。gb2312,utf-8,utf-16,utf-32等都在这一层。 字符编码方案:这个更加与计算机密切相关。具体是与操作系统密切相关。主要是解决大小字节序的问题。对于UTF-16和UTF-32 编码,Unicode都支持big-endian和little-endian两种编码方案。\n一般来说,我们所说的编码,都在第三层完成。具体到一个软件系统中,则很复杂。\n浏览器-apache-tomcat(包括tomcat内部的jsp编码、编译,文件读取)-数据库之间,只要存在数据交互,就有可能发生编码不一致,如果在读取数据时,没有正确的decode和encode,出现乱码就是家常便饭了。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/unicode%E7%BC%96%E7%A0%81%E4%B8%8Epython/","summary":"简介 这有篇很好的文章,可以明白这个问题: 为什么会报错“UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)”?本","title":"Unicode编码与Python"},{"content":"概述 如我们之前提到的,leveldb是典型的LSM树(Log Structured-Merge Tree)实现,即一次leveldb的写入过程并不是直接将数据持久化到磁盘文件中,而是将写操作首先写入日志文件中,其次将写操作应用在memtable上。\n当leveldb达到checkpoint点(memtable中的数据量超过了预设的阈值),会将当前memtable冻结成一个不可更改的内存数据库(immutable memory db),并且创建一个新的memtable供系统继续使用。\nimmutable memory db会在后台进行一次minor compaction,即将内存数据库中的数据持久化到磁盘文件中。\n在这里我们暂时不展开讨论minor compaction相关的内容,读者可以简单地理解为将内存中的数据持久化到文件\nleveldb(或者说LSM树)设计Minor Compaction的目的是为了:\n有效地降低内存的使用率; 避免日志文件过大,系统恢复时间过长; 当memory db的数据被持久化到文件中时,leveldb将以一定规则进行文件组织,这种文件格式成为sstable。在本文中将详细地介绍sstable的文件格式以及相关读写操作。\nSStable文件格式 物理结构 为了提高整体的读写效率,一个sstable文件按照固定大小进行块划分,默认每个块的大小为4KiB。每个Block中,除了存储数据以外,还会存储两个额外的辅助字段:\n压缩类型 CRC校验码 压缩类型说明了Block中存储的数据是否进行了数据压缩,若是,采用了哪种算法进行压缩。leveldb中默认采用Snappy算法进行压缩。 CRC校验码是循环冗余校验校验码,校验范围包括数据以及压缩类型。 逻辑结构 在逻辑上,根据功能不同,leveldb在逻辑上又将sstable分为:\ndata block: 用来存储key value数据对; filter block: 用来存储一些过滤器相关的数据(布隆过滤器),但是若用户不指定leveldb使用过滤器,leveldb在该block中不会存储任何内容; meta Index block: 用来存储filter block的索引信息(索引信息指在该sstable文件中的偏移量以及数据长度); index block:index block中用来存储每个data block的索引信息; footer: 用来存储meta index block及index block的索引信息; 注意,1-4类型的区块,其物理结构都是如1.1节所示,每个区块都会有自己的压缩信息以及CRC校验码信息。\ndata block结构 data block中存储的数据是leveldb中的keyvalue键值对。其中一个data block中的数据部分(不包括压缩类型、CRC校验码)按逻辑又以下图进行划分: 第一部分用来存储keyvalue数据。由于sstable中所有的keyvalue对都是严格按序存储的,为了节省存储空间,leveldb并不会为每一对keyvalue对都存储完整的key值,而是存储与上一个key非共享的部分,避免了key重复内容的存储。\n每间隔若干个keyvalue对,将为该条记录重新存储一个完整的key。重复该过程(默认间隔值为16),每个重新存储完整key的点称之为Restart point。\n每间隔若干个keyvalue对,将为该条记录重新存储一个完整的key。重复该过程(默认间隔值为16),每个重新存储完整key的点称之为Restart point。\n每个数据项的格式如下图所示: 一个entry分为5部分内容:\n与前一条记录key共享部分的长度; 与前一条记录key不共享部分的长度; value长度; 与前一条记录key非共享的内容; value内容; 例如:\n1 2 3 4 restart_interval=2 entry one : key=deck,value=v1 entry two : key=dock,value=v2 entry three: key=duck,value=v3 三组entry按上图的格式进行存储。值得注意的是restart_interval为2,因此每隔两个entry都会有一条数据作为restart point点的数据项,存储完整key值。因此entry3存储了完整的key。\n此外,第一个restart point为0(偏移量),第二个restart point为16,restart point共有两个,因此一个datablock数据段的末尾添加了下图所示的数据: 尾部数据记录了每一个restart point的值,以及所有restart point的个数。\nfilter block结构 讲完了data block,在这一章节将展开讲述filter block的结构。\n为了加快sstable中数据查询的效率,在直接查询datablock中的内容之前,leveldb首先根据filter block中的过滤数据判断指定的datablock中是否有需要查询的数据,若判断不存在,则无需对这个datablock进行数据查找。\nfilter block存储的是data block数据的一些过滤信息。这些过滤数据一般指代布隆过滤器的数据,用于加快查询的速度,关于布隆过滤器的详细内容,可以见《Leveldb源码分析 - 布隆过滤器》。 filter block存储的数据主要可以分为两部分:(1)过滤数据(2)索引数据。\n其中索引数据中,filter i offset表示第i个filter data在整个filter block中的起始偏移量,filter offset\u0026rsquo;s offset表示filter block的索引数据在filter block中的偏移量。\n在读取filter block中的内容时,可以首先读出filter offset\u0026rsquo;s offset的值,然后依次读取filter i offset,根据这些offset分别读出filter data。\nBase Lg默认值为11,表示每2KB的数据,创建一个新的过滤器来存放过滤数据。\n一个sstable只有一个filter block,其内存储了所有block的filter数据. 具体来说,filter_data_k 包含了所有起始位置处于 [basek, base(k+1)]范围内的block的key的集合的filter数据,按数据大小而非block切分主要是为了尽量均匀,以应对存在一些block的key很多,另一些block的key很少的情况。\nleveldb中,特殊的sstable文件格式设计简化了许多操作,例如: 索引和BloomFilter等元数据可随文件一起创建和销毁,即直接存在文件里,不用加载时动态计算,不用维护更新\nmeta index block结构 meta index block用来存储filter block在整个sstable中的索引信息。\nmeta index block只存储一条记录:\n该记录的key为:\u0026ldquo;filter.\u0026ldquo;与过滤器名字组成的常量字符串\n该记录的value为:filter block在sstable中的索引信息序列化后的内容,索引信息包括:(1)在sstable中的偏移量(2)数据长度。\nindex block结构 与meta index block类似,index block用来存储所有data block的相关索引信息。\nindexblock包含若干条记录,每一条记录代表一个data block的索引信息。\n一条索引包括以下内容:\ndata block i 中最大的key值; 该data block起始地址在sstable中的偏移量; 该data block的大小; 其中,data block i最大的key值还是index block中该条记录的key值。 如此设计的目的是,依次比较index block中记录信息的key值即可实现快速定位目标数据在哪个data block中。\nfooter结构 footer大小固定,为48字节,用来存储meta index block与index block在sstable中的索引信息,另外尾部还会存储一个magic word,内容为:\u0026ldquo;http://code.google.com/p/leveldb/\"字符串sha1哈希的前8个字节。 读写操作 在介绍完sstable文件具体的组织方式之后,我们再来介绍一下相关的读写操作。为了便于读者理解,将首先介绍写操作。\n写操作 sstable的写操作通常发生在:\nmemory db将内容持久化到磁盘文件中时,会创建一个sstable进行写入; leveldb后台进行文件compaction时,会将若干个sstable文件的内容重新组织,输出到若干个新的sstable文件中; 对sstable进行写操作的数据结构为tWriter,具体定义如下:\n1 2 3 4 5 6 7 8 9 10 11 // tWriter wraps the table writer. It keep track of file descriptor // and added key range. type tWriter struct { t *tOps fd storage.FileDesc // 文件描述符 w storage.Writer // 文件系统writer tw *table.Writer first, last []byte } 主要包括了一个sstable的文件描述符,底层文件系统的writer,该sstable中所有数据项最大最小的key值以及一个内嵌的tableWriter。\n一次sstable的写入为一次不断利用迭代器读取需要写入的数据,并不断调用tableWriter的Append函数,直至所有有效数据读取完毕,为该sstable文件附上元数据的过程。\n该迭代器可以是一个内存数据库的迭代器,写入情景对应着上述的第一种情况;\n该迭代器也可以是一个sstable文件的迭代器,写入情景对应着上述的第二种情况;\nsstable的元数据包括:(1)文件编码(2)大小(3)最大key值(4)最小key值\n故,理解tableWriter的Append函数是理解整个写入过程的关键。\ntableWriter 在介绍append函数之前,首先介绍一下tableWriter这个数据结构。主要的定义如下:\n1 2 3 4 5 6 7 8 9 10 11 12 13 // Writer is a table writer. type Writer struct { writer io.Writer // Options blockSize int // 默认是4KiB dataBlock blockWriter // data块Writer indexBlock blockWriter // indexBlock块Writer filterBlock filterWriter // filter块Writer pendingBH blockHandle offset uint64 nEntries int // key-value键值对个数 } 其中blockWriter与filterWriter表示底层的两种不同的writer,blockWriter负责写入data数据的写入,而filterWriter负责写入过滤数据。\npendingBH记录了上一个dataBlock的索引信息,当下一个dataBlock的数据开始写入时,将该索引信息写入indexBlock中。\nAppend 一次append函数的主要逻辑如下:\n若本次写入为新dataBlock的第一次写入,则将上一个dataBlock的索引信息写入; 将keyvalue数据写入datablock; 将过滤信息写入filterBlock; 若datablock中的数据超过预定上限,则标志着本次datablock写入结束,将内容刷新到磁盘文件中; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func (w *Writer) Append(key, value []byte) error { w.flushPendingBH(key) // Append key/value pair to the data block. w.dataBlock.append(key, value) // Add key to the filter block. w.filterBlock.add(key) // Finish the data block if block size target reached. if w.dataBlock.bytesLen() \u0026gt;= w.blockSize { if err := w.finishBlock(); err != nil { w.err = err return w.err } } w.nEntries++ return nil } dataBlock.append 该函数将编码后的kv数据写入到dataBlock对应的buffer中,编码的格式如上文中提到的数据项的格式。此外,在写入的过程中,若该数据项为restart点,则会添加相应的restart point信息。\nfilterBlock.append 该函数将kv数据项的key值加入到过滤信息中,具体可见《Leveldb源码解析 - 布隆过滤器》\nfinishBlock 若一个datablock中的数据超过了固定上限,则需要将相关数据写入到磁盘文件中。\n在写入时,需要做以下工作:\n封装dataBlock,记录restart point的个数; 若dataBlock的数据需要进行压缩(例如snappy压缩算法),则对dataBlock中的数据进行压缩; 计算checksum; 封装dataBlock索引信息(offset,length); 将datablock的buffer中的数据写入磁盘文件; 利用这段时间里维护的过滤信息生成过滤数据,放入filterBlock对用的buffer中; Close 当迭代器取出所有数据并完成写入后,调用tableWriter的Close函数完成最后的收尾工作:\n若buffer中仍有未写入的数据,封装成一个datablock写入; 将filterBlock的内容写入磁盘文件; 将filterBlock的索引信息写入metaIndexBlock中,写入到磁盘文件; 写入indexBlock的数据; 写入footer数据; 至此为止,所有的数据已经被写入到一个sstable中了,由于一个sstable是作为一个memory db或者Compaction的结果原子性落地的,因此在sstable写入完成之后,将进行更为复杂的leveldb的版本更新,将在接下来的文章中继续介绍。\n读操作 读操作作为写操作的逆过程,充分理解了写操作,将会帮助理解读操作。\n下图为在一个sstable中查找某个数据项的流程图: 大致流程为:\n首先判断“文件句柄”cache中是否有指定sstable文件的文件句柄,若存在,则直接使用cache中的句柄;否则打开该sstable文件,按规则读取该文件的元数据,将新打开的句柄存储至cache中; 利用sstable中的index block进行快速的数据项位置定位,得到该数据项有可能存在的两个data block; 利用index block中的索引信息,首先打开第一个可能的data block; 利用filter block中的过滤信息,判断指定的数据项是否存在于该data block中,若存在,则创建一个迭代器对data block中的数据进行迭代遍历,寻找数据项;若不存在,则结束该data block的查找; 若在第一个data block中找到了目标数据,则返回结果;若未查找成功,则打开第二个data block,重复步骤4; 若在第二个data block中找到了目标数据,则返回结果;若未查找成功,则返回Not Found错误信息; 缓存 在leveldb中,使用cache来缓存两类数据:\nsstable文件句柄及其元数据; data block中的数据; 因此在打开文件之前,首先判断能够在cache中命中sstable的文件句柄,避免重复读取的开销。 元数据读取 由于sstable复杂的文件组织格式,因此在打开文件后,需要读取必要的元数据,才能访问sstable中的数据。\n元数据读取的过程可以分为以下几个步骤:\n读取文件的最后48字节的利用,即Footer数据; 读取Footer数据中维护的(1) Meta Index Block(2) Index Block两个部分的索引信息并记录,以提高整体的查询效率; 利用meta index block的索引信息读取该部分的内容; 遍历meta index block,查看是否存在“有用”的filter block的索引信息,若有,则记录该索引信息;若没有,则表示当前sstable中不存在任何过滤信息来提高查询效率; 数据项的快速定位 sstable中存在多个data block,倘若依次进行“遍历”显然是不可取的。但是由于一个sstable中所有的数据项都是按序排列的,因此可以利用有序性已经index block中维护的索引信息快速定位目标数据项可能存在的data block。\n一个index block的文件结构示意图如下: index block是由一系列的键值对组成,每一个键值对表示一个data block的索引信息。\n键值对的key为该data block中数据项key的最大值,value为该data block的索引信息(offset, length)。\n因此若需要查找目标数据项,仅仅需要依次比较index block中的这些索引信息,倘若目标数据项的key大于某个data block中最大的key值,则该data block中必然不存在目标数据项。故通过这个步骤的优化,可以直接确定目标数据项落在哪个data block的范围区间内。\n值得注意的是,与data block一样,index block中的索引信息同样也进行了key值截取,即第二个索引信息的key并不是存储完整的key,而是存储与前一个索引信息的key不共享的部分,区别在于data block中这种范围的划分粒度为16,而index block中为2 。 也就是说,index block连续两条索引信息会被作为一个最小的“比较单元“,在查找的过程中,若第一个索引信息的key小于目标数据项的key,则紧接着会比较第三条索引信息的key。 这就导致最终目标数据项的范围区间为某”两个“data block。\n过滤data block 若sstable存有每一个data block的过滤数据,则可以利用这些过滤数据对data block中的内容进行判断,“确定”目标数据是否存在于data block中。\n过滤的原理为:\n若过滤数据显示目标数据不存在于data block中,则目标数据一定不存在于data block中; 若过滤数据显示目标数据存在于data block中,则目标数据可能存在于data block中; 具体的原理可能参见《布隆过滤器》。 因此利用过滤数据可以过滤掉部分data block,避免发生无谓的查找。\n查找data block 在data block中查找目标数据项是一个简单的迭代遍历过程。虽然data block中所有数据项都是按序排序的,但是作者并没有采用“二分查找”来提高查找的效率,而是使用了更大的查找单元进行快速定位。\n与index block的查找类似,data block中,以16条记录为一个查找单元,若entry 1的key小于目标数据项的key,则下一条比较的是entry 17。\n因此查找的过程中,利用更大的查找单元快速定位目标数据项可能存在于哪个区间内,之后依次比较判断其是否存在与data block中。\n可以看到,sstable很多文件格式设计(例如restart point, index block,filter block,max key)在查找的过程中,都极大地提升了整体的查找效率。\n文件特点 只读性 sstable文件为compaction的结果原子性的产生,在其余时间是只读的。\n完整性 一个sstable文件,其辅助数据:\n索引数据 过滤数据 都直接存储于同一个文件中。当读取是需要使用这些辅助数据时,无须额外的磁盘读取;当sstable文件需要删除时,无须额外的数据删除。简要地说,辅助数据随着文件一起创建和销毁。\n并发访问友好性 由于sstable文件具有只读性,因此不存在同一个文件的读写冲突。\nleveldb采用引用计数维护每个文件的引用情况,当一个文件的计数值大于0时,对此文件的删除动作会等到该文件被释放时才进行,因此实现了无锁情况下的并发访问。\nCache一致性 sstable文件为只读的,因此cache中的数据永远于sstable文件中的数据保持一致。\n参考: https://leveldb-handbook.readthedocs.io/zh/latest/sstable.html\n","permalink":"https://reid00.github.io/en/posts/storage/rocksdb-sstable/","summary":"概述 如我们之前提到的,leveldb是典型的LSM树(Log Structured-Merge Tree)实现,即一次leveldb的写入过程并不是直接将数据持久化到磁盘文件","title":"RocksDB Sstable"},{"content":"TCP/IP 协议族 通常我说 TCP/IP 是指 TCP/IP 协议族。它是基于 TCP 和 IP 这两个最初的协议之上的不同的通信协议的大集合。 例如:http、https、ftp、icmp、arp、rarp、smtp(简单邮件传输协议)\n当输入 xxxxHub 后,到网页显示,其间发生了什么?这问题被面试官问了五六十次,熬夜赶出这篇文章\nhttps://mp.weixin.qq.com/s/ESJ8Zt0GBVXHKj3KICoqjg\n一个网络请求是怎么传输的? 我们拿访问浏览器举个栗子,如图所示:\nTCP、UDP有什么区别?各有什么优劣? TCP 面向连接,提供可靠交付。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。相对 UDP 开销大 UDP 面向无连接,不保证可靠交付。无拥塞控制,支持一对一、一对多、多对多,开销小。\n关于 TCP 协议 确认 ACK - ACKnowledgement 仅当ACK = 1 时,确认才有效。简单来说,就是确认收到数据。 复位 RST - ReSet 标明 TCP 出现严重差错时,必须释放连接,重新建立连接。 同步 SYN - SYNchronization 在建立连接时,用来同步序号。当 SYN = 1,ACK = 0 时,表名这是一个连接请求报文。SYN = 1,ACK = 1 表示这是一个同意请求报文。 终止 FNI - FINis(表示终、完)用来释放连接。当 FNI = 1 表示此段报文发送方已发送完毕。 关于 UDP 协议 解释三次握手 确认号 ack 期望收到对方下一个报文的序列号\n序列号 seq\nSYN = 1 请求同步序列号,A 的序列号为:x SYN = 1 ACK = 1,表示确认请求。B 发送的数据的序列号为:y,期望收到 下一个 A 的数据的序列号为:x + 1 ACK = 1 ,表示确认请求。A 发送的数据的序列号为:x + 1,期望收到下一个 B 的数据的序列号为:y + 1 说说TCP三次握手?为什么不两次? 如果发送两次就可以建立连接话,那么只要客户端发送一个连接请求,服务端接收到并发送了确认,就会建立一个连接。\n可能出现的问题:如果一个连接请求在网络中跑的慢,超时了,这时客户端会从发请求,但是这个跑的慢的请求最后还是跑到了,然后服务端就接收了两个连接请求,然后全部回应就会创建两个连接,浪费资源!\n如果加了第三次客户端确认,客户端在接受到一个服务端连接确认请求后,后面再接收到的连接确认请求就可以抛弃不管了。\n说说TCP四次挥手?为什么不是三次? 据传输结束后,通信的双方都可以释放连接。现在 A 和 B 都处于 ESTABLISHED 状态。\n第一次挥手:A 的应用进程先向其 TCP 发出连接释放报文段,并停止再发送数据,主动关闭 TCP 连接。A 把连接释放报文段首部的终止控制位 FIN 置 1,其序号 seq = u(等于前面已传送过的数据的最后一个字节的序号加 1),这时 A 进入 FIN-WAIT-1(终止等待1)状态,等待 B 的确认。请注意:TCP 规定,FIN 报文段即使不携带数据,也将消耗掉一个序号。\n第二次挥手:B 收到连接释放报文段后立即发出确认,确认号是 ack = u + 1,而这个报文段自己的序号是 v(等于 B 前面已经传送过的数据的最后一个字节的序号加1),然后 B 就进入 CLOSE-WAIT(关闭等待)状态。TCP 服务端进程这时应通知高层应用进程,因而从 A 到 B 这个方向的连接就释放了,这时的 TCP 连接处于半关闭(half-close)状态,即 A 已经没有数据要发送了,但 B 若发送数据,A 仍要接收。也就是说,从 B 到 A 这个方向的连接并未关闭,这个状态可能会持续一段时间。A 收到来自 B 的确认后,就进入 FIN-WAIT-2(终止等待2)状态,等待 B 发出的连接释放报文段。\n第三次挥手:若 B 已经没有要向 A 发送的数据,其应用进程就通知 TCP 释放连接。这时 B 发出的连接释放报文段必须使 FIN = 1。假定 B 的序号为 w(在半关闭状态,B 可能又发送了一些数据)。B 还必须重复上次已发送过的确认号 ack = u + 1。这时 B 就进入 LAST-ACK(最后确认)状态,等待 A 的确认。\n第四次挥手:A 在收到 B 的连接释放报文后,必须对此发出确认。在确认报文段中把 ACK 置 1,确认号 ack = w + 1,而自己的序号 seq = u + 1(前面发送的 FIN 报文段要消耗一个序号)。然后进入 TIME-WAIT(时间等待) 状态。请注意,现在 TCP 连接还没有释放掉。必须经过时间等待计时器设置的时间 2MSL(MSL:最长报文段寿命)后,A 才能进入到 CLOSED 状态,然后撤销传输控制块,结束这次 TCP 连接。当然如果 B 一收到 A 的确认就进入 CLOSED 状态,然后撤销传输控制块。所以在释放连接时,B 结束 TCP 连接的时间要早于 A。\n什么是拥塞控制? 简单来说,就是通过网络的拥塞情况来调整 TCP 发送端发送的数据量。发送量先由 1 指数级递增,到一定量时(65535 个字节)开始慢下来,这个时候还是递增的。等到开始丢包时,又开始降低发送速度。\n什么是流量控制? 简单来说,就是 TCP 的接受端处理不过来,让 TCP 的发送端发送慢一点。接收端会维护一个处理窗口,即是接收端所能处理数据的能力。接收端将这个处理能力不断反馈给发送端,以此来让发送端调整发送的数据量的多少。\n参考: https://mp.weixin.qq.com/s/xe3dEu17mGTqM46LRFxzhg\n","permalink":"https://reid00.github.io/en/posts/os_network/tcp-ip%E5%8D%8F%E8%AE%AE/","summary":"TCP/IP 协议族 通常我说 TCP/IP 是指 TCP/IP 协议族。它是基于 TCP 和 IP 这两个最初的协议之上的不同的通信协议的大集合。 例如:http、https、ftp、icmp、a","title":"TCP IP协议"},{"content":"一、 python 的多线程不能利用多核CPU 因为GIL (全局解释器锁), Pyhton 只有一个GIL, 在运行Python 时, 就要拿到这个锁,才能运行,在遇到I/O 操作时,会释放这把锁。\n如果是纯计算型的程序,没有I/O 操作,解释器会每隔100 次操作就释放这把锁,让别的线程有机会执行(这个次数可以通sys.setcheckinterval来调整), 同一时间内,有且仅会只有一个线程获得GIL 在运行,其他线程都处于等待状态。\n如果是CPU 密集型的代码比如,循环,计算等,由于计算量多和大,计算很快就会达到100次,然后触发GIL 的释放与竞争,多个线程来回切换损耗资源,所以在多线程遇到CPU密集型的代码时,效率远远不如单线程高 如果是I/O 密集型代码(文件处理,网络爬虫), 开启多线程实际上是并发,IO操作会进行IO等待,在线程A等待时,自动切换到线程B,这样就提升了效率。 面向I/O的(会调用内建的操作系统C代码的)程序来说,GIL会在这个I/O调用之前被释放,以允许其他线程在这个线程等待I/O的时候运行。如果某线程并未使用很多I/O操作,它会在自己的时间片内一直占用处理器和GIL。 也就是说,I/O密集型的Python程序比计算密集型的Python程序更能充分利用多线程的好处。我们都知道,比方我有一个4核的CPU,那么这样一来,在单位时间内每个核只能跑一个线程,然后时间片轮转切换。 但是Python不一样,它不管你有几个核,单位时间多个核只能跑一个线程,然后时间片轮转。看起来很不可思议?但是这就是GIL搞的鬼。任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁, 让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。\n二、解决办法 就如此?我们没有办法在Python中利用多核?当然可以!刚才的多进程算是一种解决方案,还有一种就是调用C语言的链接库。对所有面向I/O的(会调用内建的操作系统C代码的)程序来说,GIL会在这个I/O调用之前被释放,以允许其他线程在这个线程等待I/O的时候运行。我们可以把一些 计算密集型任务用C语言编写,然后把.so链接库内容加载到Python中,因为执行C代码,GIL锁会释放,这样一来,就可以做到每个核都跑一个线程的目的! 可能有的小伙伴不太理解什么是计算密集型任务,什么是I/O密集型任务?\n计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。\n计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。\n第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。\nIO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。\n综上,Python多线程相当于单核多线程,多线程有两个好处:CPU并行,IO并行,单核多线程相当于自断一臂。所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。\n三、其他解释 在我们回过头看下那句经典的话\u0026quot;因为GIL的存在,python的多线程不能利用多核CPU\u0026quot;,这句话很容易让人理解成GIL会让python在一个核心上运行,有了今天的例子我们再来重新理解这句话,GIL的存在让python在同一时刻只能有一个线程在运行,这毋庸置疑,但是它并没有给线程锁死或者说指定只能在某个cpu上运行,另外我需要说明一点的是GIL是与进程对应的,每个进程都有一个GIL。python线程的执行流程我的理解是这样的 线程 ——\u0026gt;抢GIL——\u0026gt;CPU 这种执行流程导致了CPU密集型的多线程程序虽然能够利用多核cpu时跟单核cpu是差不多的,并且由于多个线程抢GIL这个环节导致运行效率\u0026lt;=单线程。看到这可能会让人产生一种错觉,有了GIL后python是线程安全的,好像根本不需要线程锁,而实际情况是线程拿到CPU资源后并不是一直执行的,python解释器在执行了该线程100条字节码(注意是字节码不是代码)时会释放掉该线程的GIL,如果这时候没有加锁那么其他线程就可能修改该线程用到的资源; 另外一个问题是遇到IO也会释放GIL\n最后结论是,因为GIL的存在,python的多线程虽然可以利用多核CPU,但并不能让多个核同时工作。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/python%E5%A4%9A%E7%BA%BF%E7%A8%8B%E5%A4%9A%E8%BF%9B%E7%A8%8B/","summary":"一、 python 的多线程不能利用多核CPU 因为GIL (全局解释器锁), Pyhton 只有一个GIL, 在运行Python 时, 就要拿到这个锁,才能运行,在遇到I/O 操","title":"Python多线程多进程"},{"content":"简介 RocksDB 是由 Facebook 基于 LevelDB 开发的一款提供键值存储与读写功能的 LSM-tree 架构引擎。用户写入的键值对会先写入磁盘上的 WAL (Write Ahead Log),然后再写入内存中的跳表(SkipList,这部分结构又被称作 MemTable)。LSM-tree 引擎由于将用户的随机修改(插入)转化为了对 WAL 文件的顺序写,因此具有比 B 树类存储引擎更高的写吞吐。\n内存中的数据达到一定阈值后,会刷到磁盘上生成 SST 文件 (Sorted String Table),SST 又分为多层(默认至多 6 层),每一层的数据达到一定阈值后会挑选一部分 SST 合并到下一层,每一层的数据是上一层的 10 倍(因此 90% 的数据存储在最后一层)。\nRocksDB 允许用户创建多个 ColumnFamily ,这些 ColumnFamily 各自拥有独立的内存跳表以及 SST 文件,但是共享同一个 WAL 文件,这样的好处是可以根据应用特点为不同的 ColumnFamily 选择不同的配置,但是又没有增加对 WAL 的写次数。\nrocksdb 和 leveldb对比优势 Leveldb是单线程合并文件,Rocksdb可以支持多线程合并文件,充分利用多核的特性,加快文件合并的速度,避免文件合并期间引起系统停顿 Leveldb只有一个Memtable,若Memtable满了还没有来得及持久化,则会引起系统停顿,Rocksdb可以根据需要开辟多个Memtable; Leveldb只能获取单个K-V,Rocksdb支持一次获取多个K-V。 Levledb不支持备份,Rocksdb支持全量和备份。 架构 RocksDB 是基于 LSM-Tree 的。Rocksdb结构图如下: LSM-Tree 能将离散的随机写请求都转换成批量的顺序写请求(WAL + Compaction),以此提高写性能。 sst文件是在硬盘上的。SST files按照key 排序,且每个文件的key range互相不重叠。为了check一个key可能存在于哪一个一个SST file中,RocksDB并没有依次遍历每一个SST file,然后去检查key是否在这个file的key range 内,而是执行二分搜索算法(FileMetaData.largest )去定位这个SST file。 任何的写入都会先写到 WAL,然后在写入 Memory Table(Memtable)。当然为了性能,也可以不写入 WAL,但这样就可能面临崩溃丢失数据的风险。 当一个 Memtable 写满了之后,就会变成 immutable 的 Memtable,RocksDB 在后台会通过一个 flush 线程将这个 Memtable flush 到磁盘,生成一个 Sorted String Table(SST) 文件,放在 Level 0 层。当 Level 0 层的 SST 文件个数超过阈值之后,就会通过 Compaction 策略将其放到 Level 1 层,以此类推。 这里关键就是 Compaction,如果没有 Compaction,那么写入是非常快的,但会造成读性能降低,同样也会造成很严重的空间放大问题。对于 RocksDB 来说,他有三种 Compaction 策略,一种就是默认的 Leveled Compaction,另一种就是 Universal Compaction,也就是常说的 Size-Tired Compaction,还有一种就是 FIFO Compaction。对于 FIFO 来说,它的策略非常的简单,所有的 SST 都在 Level 0,如果超过了阈值,就从最老的 SST 开始删除,其实可以看到,这套机制非常适合于存储时序数据。 实际对于 RocksDB 来说,它其实用的是一种 Hybrid 的策略,在 Level 0 层,它其实是一个 Size-Tired 的,而在其他层就是 Leveled 的。 RocksDB收到写入请求时,会直接将数据写入内存即RocksDB定义的区域Memtable,以及WAL(Write Ahead Logging,防止服务重置导致的数据丢失)中,当写入Memtable的数据达到阈值,则转为不可写入状态Immutable,同时申请新的Memtable供上层应用继续写入。RocksDB会通过异步的方式将数据Flush到SST数据文件中,RocksDB对SST文件的编排就采用了LSM树的管理方式,每层SST到达阈值,RocksDB会启动异步线程进行Compaction操作,将文件内的末端节点数据进行合并到下一层SST的文件中。 写入流程 首先当一条数据写入rocksdb时, 会将这条记录封装成一个batch, 也可以是多条记录一个batch,由batch来保证原子操作。就是一个batch里的数据要么全部成功要么全部失败。 第一步先以日志的形式落地磁盘,记write ahead log -\u0026gt; .wal 文件。 落地成功后再写入memtable。 1.这里记录wal的原因就是防止重启时内存中的数据丢失。所以在db重新打开时会先从wal恢复内存中的memtable. 可配置WAL保存在可靠的存储里。 2.这里的memtable是在内存中的一个跳表结构(skiplist)。每一个节点都是存储着一个key, value. 跳表可使查找的复杂度为logn, 同时插入数据非常简单。每个batch独占memtable的写锁。这个是为了避免多线程写造成的数据错乱。\n当memtable的数据大小超过阈值(write_buffer_size)后,会新生成一个memtable继续写,将前一个memtable保存为只读memtable -\u0026gt; immutabel. 当只读memtable的数量超过阈值后,会将所有的只读memtable合并并flush到磁盘生成一个SST文件。这里的SST属于level0, level0中的每个SST有序,整个level0不一定有序。 当level0的sst文件数超过阈值或者总大小超过阈值,会触发compaction操作,将level0中的数据合并到level1中。同样level1的文件数超过阈值或者总大小超过阈值,也会触发compaction操作, 这时候随机选择一个sst合并到更高层的level中。 1:level1 及其以上的level都整体有序。每个sst存储一个范围的数据互不交叉互不重合; 2: level1 以上的 compaction操作可以多线程执行,前提是每个线程所操作的数据互不交叉。\n读取流程 RocksDB中的每一条记录(KeyValue)都有一个lsn(LogSequenceNumber),从最初的0开始,每次写入加1。lsn在memtable中单调递增。\n首先读操作先访问memtable。跳表的时间复杂度可达到logn。 如果不存在会访问level0, 而level0整体不是有序的, 所以会按创建时间由新到老依次访问每一个sst文件。所以时间复杂度为m*logn。 如果仍不存在,则继续访问level1,由于level1及其以上的level都整体有序,所以只需要访问一个sst文件即可。 直到查找到最高层或者找到这个key。所以读操作可能会被放大好多倍。 总结: 读取的顺序为memtable-\u0026gt;immutable memtable-\u0026gt;level 0 SST-\u0026gt;…-\u0026gt;level n SST。其中,memtable和immutable memtable采用了跳表特性进行查询,SST文件中有过滤器(布隆过滤器)决定是否包含某个key再加载至内存,基于有序KV进行二分查找。同时,在memtable和SST之上还设置了Block Cache,提高查询性能。\nrocksdb的compaction 读放大(Read Amplification)。读取数据时实际读取的数据量大于真正的数据量。LSM-Tree 的读操作需要从新到旧(从上到下)一层一层查找,直到找到想要的数据。这个过程可能需要不止一次 I/O。特别是 range query 的情况,影响很明显。 空间放大(Space Amplification)。数据实际占用的磁盘空间比数据的真正大小更多。因为所有的写入都是顺序写(append-only)的,不是 in-place update ,所以过期数据不会马上被清理掉。 写放大。写入数据时实际写入的数据量大于真正的数据量。实际写入 HDD/SSD 的数据大小和程序要求写入数据大小之比。正常情况下,HDD/SSD 观察到的写入数据多于上层程序写入的数据。 compaction特性: RocksDB 和 LevelDB 通过后台的 compaction 来减少读放大(减少 SST 文件数量)和空间放大(清理过期数据)。 写放大(Write Amplification) 的问题。compaction其实就是以写放大作为代价,换取更好的读取性能。\n在 HDD 作为主流存储的时代,RocksDB 的 compaction 带来的写放大问题并没有非常明显。这是因为:\nHDD 顺序读写性能远远优于随机读写性能,足以抵消写放大带来的开销。 HDD 的写入量基本不影响其使用寿命。 现在 SSD 逐渐成为主流存储,compaction 带来的写放大问题显得越来越严重:\nSSD 顺序读写性能比随机读写性能好一些,但是差距并没有 HDD 那么大。所以,顺序写相比随机写带来的好处,能不能抵消写放大带来的开销,这是个问题。 SSD 的使用寿命和其写入量有关,写放大太严重会大大缩短 SSD 的使用寿命。因为 SSD 不支持覆盖写,必须先擦除(erase)再写入。而每个 SSD block(block 是 SSD 擦除操作的基本单位) 的平均擦除次数是有限的。\nrocksdb做了几点优化: 一点是为每个SST提供一个可配置的bloomfilter. 每个level的配置不一样。这样可以快速的确认一个key在不在某个SST中,这点以牺牲磁盘空间来换取时间。 另一点是提供可配置的cache, 用于保存访问过的key在内存中, 它缓存的是某个key在SST文件中的整个block里的记录。 ","permalink":"https://reid00.github.io/en/posts/storage/rocksdb/","summary":"简介 RocksDB 是由 Facebook 基于 LevelDB 开发的一款提供键值存储与读写功能的 LSM-tree 架构引擎。用户写入的键值对会先写入磁盘上的 WAL (Write Ahead Log),然后再写入内存中的跳表(Sk","title":"RocksDB"},{"content":"本文主要受众为开发人员,所以不涉及到MySQL的服务部署等操作,且内容较多,大家准备好耐心和瓜子矿泉水。\n前一阵系统的学习了一下MySQL,也有一些实际操作经验,偶然看到一篇和MySQL相关的面试文章,发现其中的一些问题自己也回答不好,虽然知识点大部分都知道,但是无法将知识串联起来。\n因此决定搞一个MySQL灵魂100问,试着用回答问题的方式,让自己对知识点的理解更加深入一点。\n此文不会事无巨细的从select的用法开始讲解mysql,主要针对的是开发人员需要知道的一些MySQL的知识点\n主要包括索引,事务,优化等方面,以在面试中高频的问句形式给出答案。\nMySQL 重要笔记 三万字、91道MySQL面试题(收藏版)\nhttps://mp.weixin.qq.com/s/KRWyl-zU1Cd6sxbya4dP_g\n书写高质量SQL的30条建议\nhttps://mp.weixin.qq.com/s/nM6fwEyi2VZeRMWtdZGpGQ\n数据分析面试必备SQL语句+语法\nhttps://mp.weixin.qq.com/s/8UZAaDyB38gsZANPLxNKgg\n索引相关 关于MySQL的索引,曾经进行过一次总结,文章链接在这里 Mysql索引原理及其优化.\n1. 什么是索引?\n索引是一种数据结构,可以帮助我们快速的进行数据的查找.\n2. 索引是个什么样的数据结构呢?\n索引的数据结构和具体存储引擎的实现有关, 在MySQL中使用较多的索引有Hash索引,B+树索引等,而我们经常使用的InnoDB存储引擎的默认索引实现为:B+树索引.\n3. Hash索引和B+树所有有什么区别或者说优劣呢?\n首先要知道Hash索引和B+树索引的底层实现原理:\nhash索引底层就是hash表,进行查找时,调用一次hash函数就可以获取到相应的键值,之后进行回表查询获得实际数据.B+树底层实现是多路平衡查找树.\n对于每一次的查询都是从根节点出发,查找到叶子节点方可以获得所查键值,然后根据查询判断是否需要回表查询数据.\n那么可以看出他们有以下的不同:\nhash索引进行等值查询更快(一般情况下),但是却无法进行范围查询. 因为在hash索引中经过hash函数建立索引之后,索引的顺序与原顺序无法保持一致,不能支持范围查询.\n而B+树的的所有节点皆遵循(左节点小于父节点,右节点大于父节点,多叉树也类似),天然支持范围.\nhash索引不支持使用索引进行排序,原理同上.\nhash索引不支持模糊查询以及多列索引的最左前缀匹配.原理也是因为hash函数的不可预测.AAAA和AAAAB的索引没有相关性.\nhash索引任何时候都避免不了回表查询数据,而B+树在符合某些条件(聚簇索引,覆盖索引等)的时候可以只通过索引完成查询.\nhash索引虽然在等值查询上较快,但是不稳定.性能不可预测,当某个键值存在大量重复的时候,发生hash碰撞,此时效率可能极差.而B+树的查询效率比较稳定,对于所有的查询都是从根节点到叶子节点,且树的高度较低.\n因此,在大多数情况下,直接选择B+树索引可以获得稳定且较好的查询速度.而不需要使用hash索引.\n4. 上面提到了B+树在满足聚簇索引和覆盖索引的时候不需要回表查询数据,什么是聚簇索引?\n在B+树的索引中,叶子节点可能存储了当前的key值,也可能存储了当前的key值以及整行的数据,这就是聚簇索引和非聚簇索引.\n在InnoDB中,只有主键索引是聚簇索引,如果没有主键,则挑选一个唯一键建立聚簇索引.如果没有唯一键,则隐式的生成一个键来建立聚簇索引.\n当查询使用聚簇索引时,在对应的叶子节点,可以获取到整行数据,因此不用再次进行回表查询.\n5. 非聚簇索引一定会回表查询吗?\n不一定,这涉及到查询语句所要求的字段是否全部命中了索引,如果全部命中了索引,那么就不必再进行回表查询.\n举个简单的例子,假设我们在员工表的年龄上建立了索引,那么当进行select age from employee where age \u0026lt; 20的查询时,在索引的叶子节点上,已经包含了age信息,不会再次进行回表查询.\n6. 在建立索引的时候,都有哪些需要考虑的因素呢?\n建立索引的时候一般要考虑到字段的使用频率,经常作为条件进行查询的字段比较适合.如果需要建立联合索引的话,还需要考虑联合索引中的顺序.\n此外也要考虑其他方面,比如防止过多的所有对表造成太大的压力.这些都和实际的表结构以及查询方式有关.\n7. 联合索引是什么?为什么需要注意联合索引中的顺序?\nMySQL可以使用多个字段同时建立一个索引,叫做联合索引.在联合索引中,如果想要命中索引,需要按照建立索引时的字段顺序挨个使用,否则无法命中索引.\n具体原因为:\nMySQL使用索引时需要索引有序,假设现在建立了\u0026quot;name,age,school\u0026quot;的联合索引\n那么索引的排序为: 先按照name排序,如果name相同,则按照age排序,如果age的值也相等,则按照school进行排序.\n当进行查询时,此时索引仅仅按照name严格有序,因此必须首先使用name字段进行等值查询,之后对于匹配到的列而言,其按照age字段严格有序,此时可以使用age字段用做索引查找,以此类推.\n因此在建立联合索引的时候应该注意索引列的顺序,一般情况下,将查询需求频繁或者字段选择性高的列放在前面.此外可以根据特例的查询或者表结构进行单独的调整.\n8. 创建的索引有没有被使用到?或者说怎么才可以知道这条语句运行很慢的原因?\nMySQL提供了explain命令来查看语句的执行计划,MySQL在执行某个语句之前,会将该语句过一遍查询优化器,之后会拿到对语句的分析,也就是执行计划,其中包含了许多信息.\n可以通过其中和索引有关的信息来分析是否命中了索引,例如possilbe_key,key,key_len等字段,分别说明了此语句可能会使用的索引,实际使用的索引以及使用的索引长度.\n9. 那么在哪些情况下会发生针对该列创建了索引但是在查询的时候并没有使用呢?\n使用不等于查询\n列参与了数学运算或者函数\n在字符串like时左边是通配符.类似于\u0026rsquo;%aaa'.\n当mysql分析全表扫描比使用索引快的时候不使用索引.\n当使用联合索引,前面一个条件为范围查询,后面的即使符合最左前缀原则,也无法使用索引.\n以上情况,MySQL无法使用索引.\n9. 那MySQL索引使用有哪些注意事项呢?\n索引哪些情况会失效\n查询条件包含or,可能导致索引失效 如何字段类型是字符串,where时一定用引号括起来,否则索引失效 like通配符可能导致索引失效。 联合索引,查询时的条件列不是联合索引中的第一个列,索引失效。 在索引列上使用mysql的内置函数,索引失效。 对索引列运算(如,+、-、*、/),索引失效。 索引字段上使用(!= 或者 \u0026lt; \u0026gt;,not in)时,可能会导致索引失效。 索引字段上使用is null, is not null,可能导致索引失效。 左连接查询或者右连接查询查询关联的字段编码格式不一样,可能导致索引失效。 mysql估计使用全表扫描要比使用索引快,则不使用索引。 后端程序员必备:索引失效的十大杂症\n索引不适合哪些场景\n数据量少的不适合加索引 更新比较频繁的也不适合加索引 区分度低的字段不适合加索引(如性别) 索引的一些潜规则\n覆盖索引 回表 索引数据结构(B+树) 最左前缀原则 索引下推 MySQL遇到过死锁问题吗,你是如何解决的?\n我排查死锁的一般步骤是酱紫的:\n查看死锁日志show engine innodb status; 找出死锁Sql 分析sql加锁情况 模拟死锁案发 分析死锁日志 分析死锁结果 可以看我这两篇文章哈:\n手把手教你分析Mysql死锁问题 Mysql死锁如何排查:insert on duplicate死锁一次排查分析过程 日常工作中你是怎么优化SQL的?\n可以从这几个维度回答这个问题:\n加索引 避免返回不必要的数据 适当分批量进行 优化sql结构 分库分表 读写分离 可以看我这篇文章哈:后端程序员必备:书写高质量SQL的30条建议\n数据库索引的原理,为什么要用B+树,为什么不用二叉树?\n可以从几个维度去看这个问题,查询是否够快,效率是否稳定,存储数据多少,以及查找磁盘次数,为什么不是二叉树,为什么不是平衡二叉树,为什么不是B树,而偏偏是B+树呢?\n为什么不是一般二叉树?\n如果二叉树特殊化为一个链表,相当于全表扫描。平衡二叉树相比于二叉查找树来说,查找效率更稳定,总体的查找速度也更快。\n为什么不是平衡二叉树呢?\n我们知道,在内存比在磁盘的数据,查询效率快得多。如果树这种数据结构作为索引,那我们每查找一次数据就需要从磁盘中读取一个节点,也就是我们说的一个磁盘块,但是平衡二叉树可是每个节点只存储一个键值和数据的,如果是B树,可以存储更多的节点数据,树的高度也会降低,因此读取磁盘的次数就降下来啦,查询效率就快啦。\n那为什么不是B树而是B+树呢?\n1)B+树非叶子节点上是不存储数据的,仅存储键值,而B树节点中不仅存储键值,也会存储数据。innodb中页的默认大小是16KB,如果不存储数据,那么就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的IO次数有会再次减少,数据查询的效率也会更快。\n2)B+树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的,链表连着的。那么B+树使得范围查找,排序查找,分组查找以及去重查找变得异常简单。\n可以看这篇文章哈:再有人问你为什么MySQL用B+树做索引,就把这篇文章发给她\ndetails link : https://mp.weixin.qq.com/s/Ctz6yB2131ZIzCOFrsgfMw\n事务相关 1. 什么是事务?\n理解什么是事务最经典的就是转账的栗子,相信大家也都了解,这里就不再说一边了.\n事务是一系列的操作,他们要符合ACID特性.最常见的理解就是:事务中的操作要么全部成功,要么全部失败.但是只是这样还不够的.\n2. ACID是什么?可以详细说一下吗?\nA=Atomicity\n原子性,就是上面说的,要么全部成功,要么全部失败.不可能只执行一部分操作.\nC=Consistency\n系统(数据库)总是从一个一致性的状态转移到另一个一致性的状态,不会存在中间状态.\nI=Isolation\n隔离性: 通常来说:一个事务在完全提交之前,对其他事务是不可见的.注意前面的通常来说加了红色,意味着有例外情况.\nD=Durability\n持久性,一旦事务提交,那么就永远是这样子了,哪怕系统崩溃也不会影响到这个事务的结果.\n3. 同时有多个事务在进行会怎么样呢?\n多事务的并发进行一般会造成以下几个问题:\n脏读: A事务读取到了B事务未提交的内容,而B事务后面进行了回滚.\n不可重复读: 当设置A事务只能读取B事务已经提交的部分,会造成在A事务内的两次查询,结果竟然不一样,因为在此期间B事务进行了提交操作.\n幻读: A事务读取了一个范围的内容,而同时B事务在此期间插入了一条数据.造成\u0026quot;幻觉\u0026quot;.\n4. 怎么解决这些问题呢?MySQL的事务隔离级别了解吗?\nMySQL的四种隔离级别如下:\n未提交读(READ UNCOMMITTED) 这就是上面所说的例外情况了,这个隔离级别下,其他事务可以看到本事务没有提交的部分修改.因此会造成脏读的问题(读取到了其他事务未提交的部分,而之后该事务进行了回滚).\n这个级别的性能没有足够大的优势,但是又有很多的问题,因此很少使用.\n已提交读(READ COMMITTED) 其他事务只能读取到本事务已经提交的部分.这个隔离级别有 不可重复读的问题,在同一个事务内的两次读取,拿到的结果竟然不一样,因为另外一个事务对数据进行了修改.\nREPEATABLE READ(可重复读) 可重复读隔离级别解决了上面不可重复读的问题(看名字也知道),但是仍然有一个新问题,就是幻读\n当你读取id\u0026gt; 10 的数据行时,对涉及到的所有行加上了读锁,此时例外一个事务新插入了一条id=11的数据,因为是新插入的,所以不会触发上面的锁的排斥\n那么进行本事务进行下一次的查询时会发现有一条id=11的数据,而上次的查询操作并没有获取到,再进行插入就会有主键冲突的问题.\nSERIALIZABLE(可串行化) 这是最高的隔离级别,可以解决上面提到的所有问题,因为他强制将所以的操作串行执行,这会导致并发性能极速下降,因此也不是很常用.\n5. Innodb使用的是哪种隔离级别呢?\nInnoDB默认使用的是可重复读隔离级别.\n6. 对MySQL的锁了解吗?\n当数据库有并发事务的时候,可能会产生数据的不一致,这时候需要一些机制来保证访问的次序,锁机制就是这样的一个机制.\n就像酒店的房间,如果大家随意进出,就会出现多人抢夺同一个房间的情况,而在房间上装上锁,申请到钥匙的人才可以入住并且将房间锁起来,其他人只有等他使用完毕才可以再次使用.\n7. MySQL都有哪些锁呢?像上面那样子进行锁定岂不是有点阻碍并发效率了?\n从锁的类别上来讲,有共享锁和排他锁.\n共享锁: 又叫做读锁. 当用户要进行数据的读取时,对数据加上共享锁.共享锁可以同时加上多个.\n排他锁: 又叫做写锁. 当用户要进行数据的写入时,对数据加上排他锁.排他锁只可以加一个,他和其他的排他锁,共享锁都相斥.\n用上面的例子来说就是用户的行为有两种,一种是来看房,多个用户一起看房是可以接受的. 一种是真正的入住一晚,在这期间,无论是想入住的还是想看房的都不可以.\n锁的粒度取决于具体的存储引擎,InnoDB实现了行级锁,页级锁,表级锁.\n他们的加锁开销从大大小,并发能力也是从大到小.\n表结构设计 说说分库与分表的设计\n分库分表方案,分库分表中间件,分库分表可能遇到的问题\n分库分表方案:\n水平分库:以字段为依据,按照一定策略(hash、range等),将一个库中的数据拆分到多个库中。 水平分表:以字段为依据,按照一定策略(hash、range等),将一个表中的数据拆分到多个表中。 垂直分库:以表为依据,按照业务归属不同,将不同的表拆分到不同的库中。 垂直分表:以字段为依据,按照字段的活跃性,将表中字段拆到不同的表(主表和扩展表)中。 常用的分库分表中间件:\nsharding-jdbc(当当) Mycat TDDL(淘宝) Oceanus(58同城数据库中间件) vitess(谷歌开发的数据库中间件) Atlas(Qihoo 360) InnoDB与MyISAM的区别\nInnoDB支持事务,MyISAM不支持事务 InnoDB支持外键,MyISAM不支持外键 InnoDB 支持 MVCC(多版本并发控制),MyISAM 不支持 select count(*) from table时,MyISAM更快,因为它有一个变量保存了整个表的总行数,可以直接读取,InnoDB就需要全表扫描。 Innodb不支持全文索引,而MyISAM支持全文索引(5.7以后的InnoDB也支持全文索引) InnoDB支持表、行级锁,而MyISAM支持表级锁。 InnoDB表必须有主键,而MyISAM可以没有主键 Innodb表需要更多的内存和存储,而MyISAM可被压缩,存储空间较小,。 Innodb按主键大小有序插入,MyISAM记录插入顺序是,按记录插入顺序保存。 InnoDB 存储引擎提供了具有提交、回滚、崩溃恢复能力的事务安全,与 MyISAM 比 InnoDB 写的效率差一些,并且会占用更多的磁盘空间以保留数据和索引 1. 为什么要尽量设定一个主键?\n主键是数据库确保数据行在整张表唯一性的保障,即使业务上本张表没有主键,也建议添加一个自增长的ID列作为主键.\n设定了主键之后,在后续的删改查的时候可能更加快速以及确保操作数据范围安全.\n2. 主键使用自增ID还是UUID?\n推荐使用自增ID,不要使用UUID.\n因为在InnoDB存储引擎中,主键索引是作为聚簇索引存在的\n也就是说,主键索引的B+树叶子节点上存储了主键索引以及全部的数据(按照顺序)\n如果主键索引是自增ID,那么只需要不断向后排列即可,如果是UUID,由于到来的ID与原来的大小不确定,会造成非常多的数据插入,数据移动,然后导致产生很多的内存碎片,进而造成插入性能的下降.\n总之,在数据量大一些的情况下,用自增主键性能会好一些.\n图片来源于《高性能MySQL》: 其中默认后缀为使用自增ID,_uuid为使用UUID为主键的测试,测试了插入100w行和300w行的性能.\n关于主键是聚簇索引,如果没有主键,InnoDB会选择一个唯一键来作为聚簇索引,如果没有唯一键,会生成一个隐式的主键.\nIf you define a PRIMARY KEY on your table, InnoDB uses it as the clustered index.\nIf you do not define a PRIMARY KEY for your table, MySQL picks the first UNIQUE index that has only NOT NULL columns as the primary key and InnoDB uses it as the clustered index.\n3. 字段为什么要求定义为not null?\nMySQL官网这样介绍:\nNULL columns require additional space in the rowto record whether their values are NULL. For MyISAM tables, each NULL columntakes one bit extra, rounded up to the nearest byte.\nnull值会占用更多的字节,且会在程序中造成很多与预期不符的情况.\n4. 如果要存储用户的密码散列,应该使用什么字段进行存储?\n密码散列,盐,用户身份证号等固定长度的字符串应该使用char而不是varchar来存储,这样可以节省空间且提高检索效率.\n存储引擎相关 1. MySQL支持哪些存储引擎?\nMySQL支持多种存储引擎,比如InnoDB,MyISAM,Memory,Archive等等.\n在大多数的情况下,直接选择使用InnoDB引擎都是最合适的,InnoDB也是MySQL的默认存储引擎.\nInnoDB和MyISAM有什么区别? InnoDB支持事物,而MyISAM不支持事物\nInnoDB支持行级锁,而MyISAM支持表级锁\nInnoDB支持MVCC, 而MyISAM不支持\nInnoDB支持外键,而MyISAM不支持\nInnoDB不支持全文索引,而MyISAM支持。\n零散问题 1. MySQL中的varchar和char有什么区别.\nchar是一个定长字段,假如申请了char(10)的空间,那么无论实际存储多少内容.该字段都占用10个字符,而varchar是变长的\n也就是说申请的只是最大长度,占用的空间为实际字符长度+1,最后一个字符存储使用了多长的空间.\n在检索效率上来讲,char \u0026gt; varchar,因此在使用中,如果确定某个字段的值的长度,可以使用char,否则应该尽量使用varchar.例如存储用户MD5加密后的密码,则应该使用char.\n2. varchar(10)和int(10)代表什么含义?\nvarchar的10代表了申请的空间长度,也是可以存储的数据的最大长度,而int的10只是代表了展示的长度,不足10位以0填充.\n也就是说,int(1)和int(10)所能存储的数字大小以及占用的空间都是相同的,只是在展示时按照长度展示.\n3. MySQL的binlog有有几种录入格式?分别有什么区别?\n有三种格式,statement,row和mixed.\nstatement模式下,记录单元为语句.即每一个sql造成的影响会记录.由于sql的执行是有上下文的,因此在保存的时候需要保存相关的信息,同时还有一些使用了函数之类的语句无法被记录复制.\nrow级别下,记录单元为每一行的改动,基本是可以全部记下来但是由于很多操作,会导致大量行的改动(比如alter table),因此这种模式的文件保存的信息太多,日志量太大.\nmixed. 一种折中的方案,普通操作使用statement记录,当无法使用statement的时候使用row.\n此外,新版的MySQL中对row级别也做了一些优化,当表结构发生变化的时候,会记录语句而不是逐行记录.\n4. 超大分页怎么处理?\n超大的分页一般从两个方向上来解决.\n数据库层面,这也是我们主要集中关注的(虽然收效没那么大)\n类似于select * from table where age \u0026gt; 20 limit 1000000,10这种查询其实也是有可以优化的余地的.\n这条语句需要load1000000数据然后基本上全部丢弃,只取10条当然比较慢.\n我们可以修改为select * from table where id in (select id from table where age \u0026gt; 20 limit 1000000,10)\n这样虽然也load了一百万的数据,但是由于索引覆盖,要查询的所有字段都在索引中,所以速度会很快.\n同时如果ID连续的好,我们还可以select * from table where id \u0026gt; 1000000 limit 10,效率也是不错的\n优化的可能性有许多种,但是核心思想都一样,就是减少load的数据.\n从需求的角度减少这种请求….主要是不做类似的需求(直接跳转到几百万页之后的具体某一页.只允许逐页查看或者按照给定的路线走,这样可预测,可缓存)以及防止ID泄漏且连续被人恶意攻击.\n解决超大分页,其实主要是靠缓存,可预测性的提前查到内容,缓存至redis等k-V数据库中,直接返回即可.\n在阿里巴巴《Java开发手册》中,对超大分页的解决办法是类似于上面提到的第一种.\n5. 关心过业务系统里面的sql耗时吗?统计过慢查询吗?对慢查询都怎么优化过?\n在业务系统中,除了使用主键进行的查询,其他的我都会在测试库上测试其耗时,慢查询的统计主要由运维在做,会定期将业务中的慢查询反馈给我们.\n慢查询的优化首先要搞明白慢的原因是什么? 是查询条件没有命中索引?是load了不需要的数据列?还是数据量太大?\n所以优化也是针对这三个方向来的,\n首先分析语句,看看是否load了额外的数据,可能是查询了多余的行并且抛弃掉了,可能是加载了许多结果中并不需要的列,对语句进行分析以及重写.\n分析语句的执行计划,然后获得其使用索引的情况,之后修改语句或者修改索引,使得语句可以尽可能的命中索引.\n如果对语句的优化已经无法进行,可以考虑表中的数据量是否太大,如果是的话可以进行横向或者纵向的分表.\n6. 上面提到横向分表和纵向分表,可以分别举一个适合他们的例子吗?\n横向分表是按行分表.假设我们有一张用户表,主键是自增ID且同时是用户的ID.数据量较大,有1亿多条,那么此时放在一张表里的查询效果就不太理想.\n我们可以根据主键ID进行分表,无论是按尾号分,或者按ID的区间分都是可以的.\n假设按照尾号0-99分为100个表,那么每张表中的数据就仅有100w.这时的查询效率无疑是可以满足要求的.\n纵向分表是按列分表.假设我们现在有一张文章表.包含字段id-摘要-内容.而系统中的展示形式是刷新出一个列表,列表中仅包含标题和摘要\n当用户点击某篇文章进入详情时才需要正文内容.此时,如果数据量大,将内容这个很大且不经常使用的列放在一起会拖慢原表的查询速度.\n我们可以将上面的表分为两张.id-摘要,id-内容.当用户点击详情,那主键再来取一次内容即可.而增加的存储量只是很小的主键字段.代价很小.\n当然,分表其实和业务的关联度很高,在分表之前一定要做好调研以及benchmark.不要按照自己的猜想盲目操作.\n**7. 什么是存储过程?**有哪些优缺点?\n存储过程是一些预编译的SQL语句。\n1、更加直白的理解:存储过程可以说是一个记录集,它是由一些T-SQL语句组成的代码块\n这些T-SQL语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后再给这个代码块取一个名字,在用到这个功能的时候调用他就行了。\n2、存储过程是一个预编译的代码块,执行效率比较高,一个存储过程替代大量T_SQL语句 ,可以降低网络通信量,提高通信速率,可以一定程度上确保数据安全\n但是,在互联网项目中,其实是不太推荐存储过程的,比较出名的就是阿里的《Java开发手册》中禁止使用存储过程\n我个人的理解是,在互联网项目中,迭代太快,项目的生命周期也比较短,人员流动相比于传统的项目也更加频繁\n在这样的情况下,存储过程的管理确实是没有那么方便,同时,复用性也没有写在服务层那么好.\n8. 说一说三个范式\n第一范式: 每个列都不可以再拆分.\n第二范式: 非主键列完全依赖于主键,而不能是依赖于主键的一部分.\n第三范式: 非主键列只依赖于主键,不依赖于其他非主键.\n在设计数据库结构的时候,要尽量遵守三范式,如果不遵守,必须有足够的理由.比如性能. 事实上我们经常会为了性能而妥协数据库的设计.\n9. MyBatis 中的 #\n乱入了一个奇怪的问题…..我只是想单独记录一下这个问题,因为出现频率太高了.\n# 会将传入的内容当做字符串,而$会直接将传入值拼接在sql语句中.\n所以#可以在一定程度上预防sql注入攻击。\n为什么不要用 SELECT * 不需要的列会增加数据传输时间和网络开销 用“SELECT * ”数据库需要解析更多的对象、字段、权限、属性等相关内容,在 SQL 语句复杂,硬解析较多的情况下,会对数据库造成沉重的负担。 增大网络开销;* 有时会误带上如log、IconMD5之类的无用且大文本字段,数据传输size会几何增涨。如果DB和应用程序不在同一台机器,这种开销非常明显\n即使 mysql 服务器和客户端是在同一台机器上,使用的协议还是 tcp,通信也是需要额外的时间。\n对于无用的大字段,如 varchar、blob、text,会增加 io 操作 准确来说,长度超过 728 字节的时候,会先把超出的数据序列化到另外一个地方,因此读取这条记录会增加一次 io 操作。(MySQL InnoDB)\n失去MySQL优化器“覆盖索引”策略优化的可能性 SELECT * 杜绝了覆盖索引的可能性,而基于MySQL优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式。\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E9%AB%98%E9%A2%91%E9%9D%A2%E8%AF%95%E9%97%AE%E9%A2%98/","summary":"本文主要受众为开发人员,所以不涉及到MySQL的服务部署等操作,且内容较多,大家准备好耐心和瓜子矿泉水。 前一阵系统的学习了一下MySQL,也","title":"MySql高频面试问题"},{"content":"假设有如下目录结构:\n1 2 3 4 5 6 7 -- dir0 | file1.py | file2.py | dir3 | file3.py | dir4 | file4.py dir0文件夹下有file1.py、file2.py两个文件和dir3、dir4两个子文件夹,dir3中有file3.py文件,dir4中有file4.py文件。\n1.导入同级模块 python导入同级模块(在同一个文件夹中的py文件)直接导入即可。\n1 import xxx 如在file1.py中想导入file2.py,注意无需加后缀\u0026quot;.py\u0026quot;:\n1 2 3 import file2 # 使用file2中函数时需加上前缀\u0026#34;file2.\u0026#34;,即: # file2.fuction_name() 2.导入下级模块 导入下级目录模块也很容易,需在下级目录中新建一个空白的__init__.py文件再导入:\n1 from dirname import xxx 如在file1.py中想导入dir3下的file3.py,首先要在dir3中新建一个空白的__init__.py文件。\n1 2 3 4 5 6 7 8 -- dir0 | file1.py | file2.py | dir3 | __init__.py | file3.py | dir4 | file4.py 再使用如下语句:\n1 2 #plan A from dir3 import file3 或者\n1 2 3 4 #plan B import dir3.file3 #or # import dir3.file3 as df3 但使用第二种方式则下文需要一直带着路径dir3书写,较为累赘,建议可以另起一个别名。\n3.导入上级模块 要导入上级目录下模块,可以使用sys.path: 1 2 3 import sys sys.path.append(\u0026#39;..\u0026#39;) import xxx 如在file4.py中想引入import上级目录下的file1.py:\n1 2 3 import sys sys.path.append(\u0026#34;..\u0026#34;) import file1 **sys.path的作用:**当使用import语句导入模块时,解释器会搜索当前模块所在目录以及sys.path指定的路径去找需要import的模块,所以这里是直接把上级目录加到了sys.path里。\n**“..”的含义:**等同于linux里的‘..’,表示当前工作目录的上级目录。实际上python中的‘.’也和linux中一致,表示当前目录。\n4.导入隔壁文件夹下的模块 如在file4.py中想引入import在dir3目录下的file3.py。\n这其实是前面两个操作的组合,其思路本质上是将上级目录加到sys.path里,再按照对下级目录模块的方式导入。\n同样需要被引文件夹也就是dir3下有空的__init__.py文件。\n1 2 3 4 5 6 7 8 -- dir | file1.py | file2.py | dir3 | __init__.py | file3.py | dir4 | file4.py 同时也要将上级目录加到sys.path里:\n1 2 3 import sys sys.path.append(\u0026#34;..\u0026#34;) from dir3 import file3 文件夹作为package需要满足如下两个条件:\n文件夹中必须存在有__init__.py文件,可以为空。 不能作为顶层模块来执行该文件夹中的py文件。 ","permalink":"https://reid00.github.io/en/posts/langs_linux/python-import%E5%AF%BC%E5%85%A5%E4%B8%8A%E7%BA%A7%E7%9B%AE%E5%BD%95%E6%96%87%E4%BB%B6/","summary":"假设有如下目录结构: 1 2 3 4 5 6 7 -- dir0 | file1.py | file2.py | dir3 | file3.py | dir4 | file4.py dir0文件夹下有file1.py、file2.py两个文件和dir3、dir","title":"Python Import导入上级目录文件"},{"content":"什么是索引,索引的作用 当我们要在新华字典里查某个字(如「先」)具体含义的时候,通常都会拿起一本新华字典来查,你可以先从头到尾查询每一页是否有「先」这个字,这样做(对应数据库中的全表扫描)确实能找到,但效率无疑是非常低下的,更高效的方相信大家也都知道,就是在首页的索引里先查找「先」对应的页数,然后直接跳到相应的页面查找,这样查询时候大大减少了,可以为是 O(1)。\n数据库中的索引也是类似的,通过索引定位到要读取的页,大大减少了需要扫描的行数,能极大的提升效率,简而言之,索引主要有以下几个作用:\n即上述所说,索引能极大地减少扫描行数 索引可以帮助服务器避免排序和临时表 索引可以将随机 IO 变成顺序 IO MySQL中索引的存储类型有两种:BTREE和HASH,具体和表的存储引擎相关;\nMyISAM和InnoDB存储引擎只支持BTREE索引,MEMORY/HEAP存储引擎可以支持HASH和BTREE索引。\n第一点上文已经解释了,我们来看下第二点和第三点\n先来看第二点,假设我们不用索引,试想运行如下语句\n1 select * from user order by age desc 则 MySQL 的流程是这样的,扫描所有行,把所有行加载到内存后,再按 age 排序生成一张临时表,再把这表排序后将相应行返回给客户端,更糟的,如果这张临时表的大小大于 tmp_table_size 的值(默认为 16 M),内存临时表会转为磁盘临时表,性能会更差,如果加了索引,索引本身是有序的 ,所以从磁盘读的行数本身就是按 age 排序好的,也就不会生成临时表,就不用再额外排序 ,无疑提升了性能。\n再来看随机 IO 和顺序 IO。先来解释下这两个概念。\n相信不少人应该吃过旋转火锅,服务员把一盘盘的菜放在旋转传输带上,然后等到这些菜转到我们面前,我们就可以拿到菜了,假设装一圈需要 4 分钟,则最短等待时间是 0(即菜就在你跟前),最长等待时间是 4 分钟(菜刚好在你跟前错过),那么平均等待时间即为 2 分钟,假设我们现在要拿四盘菜,这四盘菜随机分配在传输带上,则可知拿到这四盘菜的平均等待时间是 8 分钟(随机 IO),如果这四盘菜刚好紧邻着排在一起,则等待时间只需 2 分钟(顺序 IO)。\n上述中传输带就类比磁道,磁道上的菜就类比扇区(sector)中的信息,磁盘块(block)是由多个相邻的扇区组成的,是操作系统读取的最小单元,这样如果信息能以 block 的形式聚集在一起,就能极大减少磁盘 IO 时间,这就是顺序 IO 带来的性能提升,下文中我们将会看到 B+ 树索引就起到这样的作用。\n如图示:多个扇区组成了一个 block,如果要读的信息都在这个 block 中,则只需一次 IO 读\n而如果信息在一个磁道中, 分散地分布在各个扇区中,或者分布在不同磁道的扇区上(寻道时间是随机IO主要瓶颈所在),将会造成随机 IO,影响性能。\n我们来看一下一个随机 IO 的时间分布:\nseek Time: 寻道时间,磁头移动到扇区所在的磁道 Rotational Latency:完成步骤 1 后,磁头移动到同一磁道扇区对应的位置所需求时间 Transfer Time 从磁盘读取信息传入内存时间 这其中寻道时间占据了绝大多数的时间(大概占据随机 IO 时间的占 40%)。\n随机 IO 和顺序 IO 大概相差百倍 (随机 IO:10 ms/ page, 顺序 IO 0.1ms / page),可见顺序 IO 性能之高,索引带来的性能提升显而易见!\n索引的种类 索引主要分为以下几类\nB+树索引 哈希索引 B+树索引 B+ 树索引之前在此文中详细阐述过,强烈建议大家看一遍,对理解 B+ 树有很大的帮助,简单回顾一下吧\nB+ 树是以 N 叉树的形式存在的,这样有效降低了树的高度,查找数据也不需要全表扫描了,顺着根节点层层往下查找能很快地找到我们的目标数据。 每个节点的大小即一个页的大小,一次 IO 会将一个页(每页包含多个磁盘块)的数据都读入(即磁盘预读,程序局部性原理:读到了某个值,很大可能这个值周围的数据也会被用到,干脆一起读入内存),叶子节点通过指针的相互指向连接,能有效减少顺序遍历时的随机 IO,而且我们也可以看到,叶子节点都是按索引的顺序排序好的,这也意味着根据索引查找或排序都是排序好了的,不会再在内存中形成临时表。\n详解:Mysql设计利用了磁盘预读原理,将一个B+Tree节点大小设为一个页大小,在新建节点时直接申请一个页的空间,这样就能保证一个节点物理上存储在一个页里,加之计算机存储分配都是按页对齐的,这样就实现了每个Node节点只需要一次I/O操作。\n一般来说B+Tree比BTree更适合实现外存的索引结构,因为存储引擎的设计专家巧妙的利用了外存(磁盘)的存储结构,即磁盘的最小存储单位是扇区(sector),而操作系统的块(block)通常是整数倍的sector,操作系统以页(page)为单位管理内存,一页(page)通常默认为4K,数据库的页通常设置为操作系统页的整数倍16K,因此索引结构的节点被设计为一个页的大小,然后利用外存的“预读取”原则,每次读取的时候,把整个节点的数据读取到内存中,然后在内存中查找,已知内存的读取速度是外存读取I/O速度的几百倍,那么提升查找速度的关键就在于尽可能少的磁盘I/O,那么可以知道,每个节点中的key个数越多,那么树的高度越小,需要I/O的次数越少,因此一般来说B+Tree比BTree更快,因为B+Tree的非叶节点中不存储data,就可以存储更多的key。\nB树和B+树的区别,数据库为什么使用B+树而不是B树? 在B树中,键和值即存放在内部节点又存放在叶子节点;在B+树中,内部节点只存键,叶子节点则同时存放键和值。 B+树的叶子节点有一条链相连,而B树的叶子节点各自独立的。 B+树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的,链表连着的。那么B+树使得范围查找,排序查找,分组查找以及去重查找变得异常简单。 B+树非叶子节点上是不存储数据的,仅存储键值,而B树节点中不仅存储键值,也会存储数据。innodb中页的默认大小是16KB,如果不存储数据,那么就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的IO次数有会再次减少,数据查询的效率也会更快. 因为B树不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少(有些资料也称为扇出),指针少的情况下要保存大量数据,只能增加树的高度,导致IO操作变多,查询性能变低;B 树进行范围查询,会产生大量随机IO 在B+树中叶子节点存放数据,非叶子节点存放键值+指针。 哈希索引 哈希索引基本散列表实现,散列表(也称哈希表)是根据关键码值(Key value)而直接进行访问的数据结构,它让码值经过哈希函数的转换映射到散列表对应的位置上,查找效率非常高。假设我们对名字建立了哈希索引,则查找过程如下图所示:\n对于每一行数据,存储引擎都会对所有的索引列(上图中的 name 列)计算一个哈希码(上图散列表的位置),散列表里的每个元素指向数据行的指针,由于索引自身只存储对应的哈希值,所以索引的结构十分紧凑,这让哈希索引查找速度非常快!\n当然了哈希表的劣势也是比较明显的,不支持区间查找,不支持排序,所以更多的时候哈希表是与 B Tree等一起使用的,在 InnoDB 引擎中就有一种名为「自适应哈希索引」的特殊索引,当 innoDB 注意到某些索引值使用非常频繁时,就会内存中基于 B-Tree 索引之上再创建哈希索引,这样也就让 B+ 树索引也有了哈希索引的快速查找等优点,这是完全自动,内部的行为,用户无法控制或配置,不过如果有必要,可以关闭该功能。\ninnoDB 引擎本身是不支持显式创建哈希索引的,我们可以在 B+ 树的基础上创建一个伪哈希索引,它与真正的哈希索引不是一回事,它是以哈希值而非键本身来进行索引查找的,这种伪哈希索引的使用场景是怎样的呢,假设我们在 db 某张表中有个 url 字段,我们知道每个 url 的长度都很长,如果以 url 这个字段创建索引,无疑要占用很大的存储空间,如果能通过哈希(比如CRC32)把此 url 映射成 4 个字节,再以此哈希值作索引 ,索引占用无疑大大缩短!不过在查询的时候要记得同时带上 url 和 url_crc,主要是为了避免哈希冲突,导致 url_crc 的值可能一样\n1 SELECT id FROM url WHERE url = \u0026#34;http://www.baidu.com\u0026#34; AND url_crc = CRC32(\u0026#34;http://www.baidu.com\u0026#34;) 这样做把基于 url 的字符串索引改成了基于 url_crc 的整型索引,效率更高,同时索引占用的空间也大大减少,一举两得,当然人可能会说需要手动维护索引太麻烦了,那可以改进触发器实现。\n除了上文说的两个索引 ,还有空间索引(R-Tree),全文索引等,由生产中不是很常用,这里不作过多阐述\n高性能索引策略 不同的索引设计选择能对性能产生很大的影响,有人可能会发现生产中明明加了索引却不生效,有时候加了虽然生效但对搜索性能并没有提升多少,对于多列联合索引,哪列在前,哪列在后也是有讲究的,我们一起来看看\n加了索引,为何却不生效 加了索引却不生效可能会有以下几种原因\n1、索引列是表示式的一部分,或是函数的一部分 1 SELECT book_id FROM BOOK WHERE book_id + 1 = 5; 或者\n1 SELECT book_id FROM BOOK WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(gmt_create) \u0026lt;= 10 上述两个 SQL 虽然在列 book_id 和 gmt_create 设置了索引 ,但由于它们是表达式或函数的一部分,导致索引无法生效,最终导致全表扫描。\n2、隐式类型转换 以上两种情况相信不少人都知道索引不能生效,但下面这种隐式类型转换估计会让不少人栽跟头,来看下下面这个例子:\n假设有以下表:\n1 2 3 4 5 6 7 8 9 CREATE TABLE `tradelog` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `operator` int(11) DEFAULT NULL, `t_modified` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`), KEY `t_modified` (`t_modified`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 执行 SQL 语句\n1 SELECT * FROM tradelog WHERE tradeid=110717; 交易编号 tradeid 上有索引,但用 EXPLAIN 执行却发现使用了全表扫描,为啥呢,tradeId 的类型是 varchar(32), 而此 SQL 用 tradeid 一个数字类型进行比较,发生了隐形转换,会隐式地将字符串转成整型,如下:\n1 SELECT * FROM tradelog WHERE CAST(tradid AS signed int) = 110717; 这样也就触发了上文中第一条的规则 ,即:索引列不能是函数的一部分。\n3、隐式编码转换 这种情况非常隐蔽,来看下这个例子\n1 2 3 4 5 6 7 CREATE TABLE `trade_detail` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `trade_step` int(11) DEFAULT NULL, /*操作步骤*/ `step_info` varchar(32) DEFAULT NULL, /*步骤信息*/ PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; trade_detail 是交易详情, tradelog 是操作此交易详情的记录,现在要查询 id=2 的交易的所有操作步骤信息,则我们会采用如下方式\n1 SELECT d.* FROM tradelog l, trade_detail d WHERE d.tradeid=l.tradeid AND l.id=2; 由于 tradelog 与 trade_detail 这两个表的字符集不同,且 tradelog 的字符集是 utf8mb4,而 trade_detail 字符集是 utf8, utf8mb4 是 utf8 的超集,所以会自动将 utf8 转成 utf8mb4。即上述语句会发生如下转换:\n1 SELECT d.* FROM tradelog l, trade_detail d WHERE (CONVERT(d.traideid USING utf8mb4)))=l.tradeid AND l.id=2; 自然也就触发了 「索引列不能是函数的一部分」这条规则。怎么解决呢,第一种方案当然是把两个表的字符集改成一样,如果业务量比较大,生产上不方便改的话,还有一种方案是把 utf8mb4 转成 utf8,如下\n1 SELECT d.* FROM tradelog l , trade_detail d WHERE d.tradeid=CONVERT(l.tradeid USING utf8) AND l.id=2; 这样索引列就生效了。\n4、使用 order by 造成的全表扫描 1 SELECT * FROM user ORDER BY age DESC 上述语句在 age 上加了索引,但依然造成了全表扫描,这是因为我们使用了 SELECT *,导致回表查询,MySQL 认为回表的代价比全表扫描更大,所以不选择使用索引,如果想使用到 age 的索引,我们可以用覆盖索引来代替:\n1 SELECT age FROM user ORDER BY age DESC 或者加上 limit 的条件(数据比较小)\n1 SELECT * FROM user ORDER BY age DESC limit 10 这样就能利用到索引。\n无法避免对索引列使用函数,怎么使用索引 时候我们无法避免对索引列使用函数,但这样做会导致全表索引,是否有更好的方式呢。\n比如我现在就是想记录 2016 ~ 2018 所有年份 7月份的交易记录总数\n1 SELECT count(*) FROM tradelog WHERE month(t_modified)=7; 由于索引列是函数的参数,所以显然无法用到索引,我们可以将它改造成基本字段区间的查找如下\n1 2 3 4 SELECT count(*) FROM tradelog WHERE -\u0026gt; (t_modified \u0026gt;= \u0026#39;2016-7-1\u0026#39; AND t_modified\u0026lt;\u0026#39;2016-8-1\u0026#39;) or -\u0026gt; (t_modified \u0026gt;= \u0026#39;2017-7-1\u0026#39; AND t_modified\u0026lt;\u0026#39;2017-8-1\u0026#39;) or -\u0026gt; (t_modified \u0026gt;= \u0026#39;2018-7-1\u0026#39; AND t_modified\u0026lt;\u0026#39;2018-8-1\u0026#39;); 前缀索引与索引选择性 之前我们说过,如于长字符串的字段(如 url),我们可以用伪哈希索引的形式来创建索引,以避免索引变得既大又慢,除此之外其实还可以用前缀索引(字符串的部分字符)的形式来达到我们的目的,那么这个前缀索引应该如何选取呢,这叫涉及到一个叫索引选择性的概念\n索引选择性:不重复的索引值(也称为基数,cardinality)和数据表的记录总数的比值,比值越高,代表索引的选择性越好,唯一索引的选择性是最好的,比值是 1。\n画外音:我们可以通过 SHOW INDEXES FROM table 来查看每个索引 cardinality 的值以评估索引设计的合理性\n怎么选择这个比例呢,我们可以分别取前 3,4,5,6,7 的前缀索引,然后再比较下选择这几个前缀索引的选择性,执行以下语句\n1 2 3 4 5 6 7 SELECT COUNT(DISTINCT LEFT(city,3))/COUNT(*) as sel3, COUNT(DISTINCT LEFT(city,4))/COUNT(*) as sel4, COUNT(DISTINCT LEFT(city,5))/COUNT(*) as sel5, COUNT(DISTINCT LEFT(city,6))/COUNT(*) as sel6, COUNT(DISTINCT LEFT(city,7))/COUNT(*) as sel7 FROM city_demo 得结果如下\nsel3 sel4 sel5 sel6 sel7 0.0239 0.0293 0.0305 0.0309 0.0310 可以看到当前缀长度为 7 时,索引选择性提升的比例已经很小了,也就是说应该选择 city 的前六个字符作为前缀索引,如下\n1 ALTER TABLE city_demo ADD KEY(city(6)) 我们当前是以平均选择性为指标的,有时候这样是不够的,还得考虑最坏情况下的选择性,以这个 demo 为例,可能一些人看到选择 4,5 的前缀索引与选择 6,7 的选择性相差不大,那就得看下选择 4,5 的前缀索引分布是否均匀了\n1 2 3 4 SELECT COUNT(*) AS cnt, LEFT(city, 4) AS pref FROM city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 5 可能会出现以下结果\ncnt pref 305 Sant 200 Toul 90 Chic 20 Chan 可以看到分布极不均匀,以 Sant,Toul 为前缀索引的数量极多,这两者的选择性都不是很理想,所以要选择前缀索引时也要考虑最差的选择性的情况。\n前缀索引虽然能实现索引占用空间小且快的效果,但它也有明显的弱点,MySQL 无法使用前缀索引做 ORDER BY 和 GROUP BY ,而且也无法使用前缀索引做覆盖扫描,前缀索引也有可能增加扫描行数。\n假设有以下表数据及要执行的 SQL\nd email 1 zhangssxyz@163.com 2 zhangs1@163.com 3 zhangs1@163.com 4 zhangs1@163.com 1 SELECT id,email FROM user WHERE email=\u0026#39;zhangssxyz@xxx.com\u0026#39;; 如果我们针对 email 设置的是整个字段的索引,则上表中根据 「zhangssxyz@163.com」查询到相关记记录后,再查询此记录的下一条记录,发现没有,停止扫描。此时可知只扫描一行记录,如果我们以前六个字符(即 email(6))作为前缀索引,则显然要扫描四行记录,并且获得行记录后不得不回到主键索引再判断 email 字段的值,所以使用前缀索引要评估它带来的这些开销。\n另外有一种情况我们可能需要考虑一下,如果前缀基本都是相同的该怎么办,比如现在我们为某市的市民建立一个人口信息表,则这个市人口的身份证虽然不同,但身份证前面的几位数都是相同的,这种情况该怎么建立前缀索引呢。\n一种方式就是我们上文说的,针对身份证建立哈希索引,另一种方式比较巧妙,将身份证倒序存储,查的时候可以按如下方式查询:\n1 SELECT field_list FROM t WHERE id_card = reverse(\u0026#39;input_id_card_string\u0026#39;); 这样就可以用身份证的后六位作前缀索引了,是不是很巧妙 ^_^\n实际上上文所述的索引选择性同样适用于联合索引的设计,如果没有特殊情况,我们一般建议在建立联合索引时,把选择性最高的列放在最前面,比如,对于以下语句:\n1 SELECT * FROM payment WHERE staff_id = xxx AND customer_id = xxx; 单就这个语句而言, (staff_id,customer_id) 和 (customer_id, staff_id) 这两个联合索引我们应该建哪一个呢,可以统计下这两者的选择性。\n1 2 3 4 5 SELECT COUNT(DISTINCT staff_id)/COUNT(*) as staff_id_selectivity, COUNT(DISTINCT customer_id)/COUNT(*) as customer_id_selectivity, COUNT(*) FROM payment 结果为:\n1 2 3 staff_id_selectivity: 0.0001 customer_id_selectivity: 0.0373 COUNT(*): 16049 从中可以看出 customer_id 的选择性更高,所以应该选择 customer_id 作为第一列。\n索引设计准则:三星索引 上文我们得出了一个索引列顺序的经验 法则:将选择性最高的列放在索引的最前列,这种建立在某些场景可能有用,但通常不如避免随机 IO 和 排序那么重要,这里引入索引设计中非常著名的一个准则:三星索引。\n如果一个查询满足三星索引中三颗星的所有索引条件,理论上可以认为我们设计的索引是最好的索引。什么是三星索引\n第一颗星:WHERE 后面参与查询的列可以组成了单列索引或联合索引 第二颗星:避免排序,即如果 SQL 语句中出现 order by colulmn,那么取出的结果集就已经是按照 column 排序好的,不需要再生成临时表 第三颗星:SELECT 对应的列应该尽量是索引列,即尽量避免回表查询。 所以对于如下语句:\n1 SELECT age, name, city where age = xxx and name = xxx order by age 设计的索引应该是 (age, name,city) 或者 (name, age,city)\n当然 了三星索引是一个比较理想化的标准,实际操作往往只能满足期望中的一颗或两颗星,考虑如下语句:\n1 SELECT age, name, city where age \u0026gt;= 10 AND age \u0026lt;= 20 and city = xxx order by name desc 假设我们分别为这三列建了联合索引,则显然它符合第三颗星(使用了覆盖索引),如果索引是(city, age, name),则虽然满足了第一颗星,但排序无法用到索引,不满足第二颗星,如果索引是 (city, name, age),则第二颗星满足了,但此时 age 在 WHERE 中的搜索条件又无法满足第一星,\n另外第三颗星(尽量使用覆盖索引)也无法完全满足,试想我要 SELECT 多列,要把这多列都设置为联合索引吗,这对索引的维护是个问题,因为每一次表的 CURD 都伴随着索引的更新,很可能频繁伴随着页分裂与页合并。\n综上所述,三星索引只是给我们构建索引提供了一个参考,索引设计应该尽量靠近三星索引的标准,但实际场景我们一般无法同时满足三星索引,一般我们会优先选择满足第三颗星(因为回表代价较大)至于第一,二颗星就要依赖于实际的成本及实际的业务场景考虑。\n为什么要一定要设置主键? from: https://zhuanlan.zhihu.com/p/116866170\n其实这个不是一定的,有些场景下,小系统或者没什么用的表,不设置主键也没关系,mysql最好是用自增主键,主要是以下两个原因:果定义了主键,那么InnoDB会选择主键作为聚集索引、如果没有显式定义主键,则innodb 会选择第一个不包含有NULL值的唯一索引作为主键索引、如果也没有这样的唯一索引,则innodb 会选择内置6字节长的ROWID作为隐含的聚集索引。所以,反正都要生成一个主键,那你还不如自己指定一个主键,提高查询效率!\n前面我写了几篇关于 mysql 索引的文章,索引是 mysql 非常重要的一部分。你也可能经常会看到一些关于 mysql 军规、mysql 查询优化的文章,其实这些操作的背后都是基于一定的原理的,你要想明白这些原理,首先就得知道 mysql 底层的一些东西。\n我在这里举几个例子吧。\n我们都知道表的主键一般都要使用自增 id,不建议使用业务 id ,是因为使用自增 id 可以避免页分裂。这个其实可以相当于一个结论,你都可以直接记住这个结论就可以了。\n但是如果你要弄明白什么是页分裂,或者什么情况下会页分裂,这个时候你就需要对 mysql 的底层数据结构要有一定的理解了。\n我这里也稍微解释一下页分裂,mysql (注意本文讲的 mysql 默认为InnoDB 引擎)底层数据结构是 B+ 树,所谓的索引其实就是一颗 B+ 树,一个表有多少个索引就会有多少颗 B+ 树,mysql 中的数据都是按顺序保存在 B+ 树上的(所以说索引本身是有序的)。\n然后 mysql 在底层又是以数据页为单位来存储数据的,一个数据页大小默认为 16k,当然你也可以自定义大小,也就是说如果一个数据页存满了,mysql 就会去申请一个新的数据页来存储数据。\n如果主键为自增 id 的话,mysql 在写满一个数据页的时候,直接申请另一个新数据页接着写就可以了。\n如果主键是非自增 id,为了确保索引有序,mysql 就需要将每次插入的数据都放到合适的位置上。\n当往一个快满或已满的数据页中插入数据时,新插入的数据会将数据页写满,mysql 就需要申请新的数据页,并且把上个数据页中的部分数据挪到新的数据页上。\n这就造成了页分裂,这个大量移动数据的过程是会严重影响插入效率的。\n其实对主键 id 还有一个小小的要求,在满足业务需求的情况下,尽量使用占空间更小的主键 id,因为普通索引的叶子节点上保存的是主键 id 的值,如果主键 id 占空间较大的话,那将会成倍增加 mysql 空间占用大小。\n主键是用自增还是UUID 最好是用自增主键,主要是以下两个原因:\n1. 如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。 2. 如果使用非自增主键(如uuid),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到索引页的随机某个位置,此时MySQL为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成索引碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面。\n不过,也不是所有的场景下都得使用自增主键,可能场景下,主键必须自己生成,不在乎那些性能的开销。那也没有问题。\n自增主键用完了怎么办? 在mysql中,Int整型的范围(-2147483648~2147483648),约20亿!因此不用考虑自增ID达到最大值这个问题。而且数据达到千万级的时候就应该考虑分库分表了。\n主键为什么不推荐有业务含义? 最好是主键是无意义的自增ID,然后另外创建一个业务主键ID,\n因为任何有业务含义的列都有改变的可能性,主键一旦带上了业务含义,那么主键就有可能发生变更。主键一旦发生变更,该数据在磁盘上的存储位置就会发生变更,有可能会引发页分裂,产生空间碎片。\n还有就是,带有业务含义的主键,不一定是顺序自增的。那么就会导致数据的插入顺序,并不能保证后面插入数据的主键一定比前面的数据大。如果出现了,后面插入数据的主键比前面的小,就有可能引发页分裂,产生空间碎片。\n货币字段用什么类型 货币字段一般都用 Decimal类型, float和double是以二进制存储的,数据大的时候,可能存在误差。\n以下是FLOAT和DOUBLE的区别:\n浮点数以8位精度存储在FLOAT中,并且有四个字节。\n浮点数存储在DOUBLE中,精度为18位,有八个字节。\n表中有大字段X(例如:text类型),且字段X不会经常更新,以读为主,那么是拆成子表好?还是放一起好? 其实各有利弊,拆开带来的问题:连接消耗;不拆可能带来的问题:查询性能,所以要看你的实际情况,如果表数据量比较大,最好还是拆开为好。这样查询速度更快。\n字段为什么要定义为NOT NULL? 一般情况,都会设置一个默认值,不会出现字段里面有null,又有空的情况。主要有以下几个原因:\n索引性能不好,Mysql难以优化引用可空列查询,它会使索引、索引统计和值更加复杂。可空列需要更多的存储空间,还需要mysql内部进行特殊处理。可空列被索引后,每条记录都需要一个额外的字节,还能导致MYisam 中固定大小的索引变成可变大小的索引。\n如果某列存在null的情况,可能导致count() 等函数执行不对的情况。\nsql 语句写着也麻烦,既要判断是否为空,又要判断是否为null等。\n总结 本文简述了索引的基本原理,索引的几种类型,以及分析了一下设计索引尽量应该遵循的一些准则,相信我们对索引的理解又更深了一步。另外强烈建议大家去学习一下附录中的几本书。文中的挺多例子都是在文末的参考资料中总结出来的,读经典书籍,相信大家会受益匪浅!\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E7%B4%A2%E5%BC%95%E4%BB%8B%E7%BB%8D/","summary":"什么是索引,索引的作用 当我们要在新华字典里查某个字(如「先」)具体含义的时候,通常都会拿起一本新华字典来查,你可以先从头到尾查询每一页是否有","title":"MySql索引介绍"},{"content":"数据库表结构:\n1 2 3 4 5 6 7 8 9 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name) )engine=innodb; select id,name where name=\u0026#39;shenjian\u0026#39; select id,name,sex where name=\u0026#39;shenjian\u0026#39; 多查询了一个属性,为何检索过程完全不同?\n什么是回表查询?\n什么是索引覆盖?\n如何实现索引覆盖?\n哪些场景,可以利用索引覆盖来优化SQL?\n一、什么是回表查询? 这先要从InnoDB的索引实现说起,InnoDB有两大类索引:\n聚集索引(clustered index) 普通索引(secondary index) **InnoDB聚集索引和普通索引有什么差异?\n**\nInnoDB聚集索引的叶子节点存储行记录,因此, InnoDB必须要有,且只有一个聚集索引:\n(1)如果表定义了PK,则PK就是聚集索引;\n(2)如果表没有定义PK,则第一个not NULL unique列是聚集索引;\n(3)否则,InnoDB会创建一个隐藏的row-id作为聚集索引;\n画外音:所以PK查询非常快,直接定位行记录。\nInnoDB普通索引的叶子节点存储主键值。\n画外音:注意,不是存储行记录头指针,MyISAM的索引叶子节点存储记录指针。\n举个栗子,不妨设有表:\nt(id PK, name KEY, sex, flag);\n画外音:id是聚集索引,name是普通索引。\n表中有四条记录:\n1, shenjian, m, A\n3, zhangsan, m, A\n5, lisi, m, A\n9, wangwu, f, B\n两个B+树索引分别如上图:\n(1)id为PK,聚集索引,叶子节点存储行记录;\n(2)name为KEY,普通索引,叶子节点存储PK值,即id;\n既然从普通索引无法直接定位行记录,那普通索引的查询过程是怎么样的呢?\n通常情况下,需要扫描两遍索引树。\n例如:\n1 select` `* ``from` `t ``where` `name``=``\u0026#39;lisi\u0026#39;``; 是如何执行的呢?\n如粉红色路径,需要扫码两遍索引树:\n(1)先通过普通索引定位到主键值id=5;\n(2)在通过聚集索引定位到行记录;\n这就是所谓的回表查询,先定位主键值,再定位行记录,它的性能较扫一遍索引树更低。\n二、什么是索引覆盖Covering index)? MySQL官网,类似的说法出现在explain查询计划优化章节,即explain的输出结果Extra字段为Using index时,能够触发索引覆盖。\n不管是SQL-Server官网,还是MySQL官网,都表达了:只需要在一棵索引树上就能获取SQL所需的所有列数据,无需回表,速度更快。\n三、如何实现索引覆盖? 常见的方法是:将被查询的字段,建立到联合索引里去。\n仍是之前中的例子:\n1 2 3 4 5 6 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name) )engine=innodb; 第一个SQL语句:\n1 select id, name from user where name=\u0026#39;shenjian\u0026#39; 能够命中name索引,索引叶子节点存储了主键id,通过name的索引树即可获取id和name,无需回表,符合索引覆盖,效率较高。\n画外音,Extra:Using index。\n第二个SQL语句:\n1 select id, name from user where name=\u0026#39;shenjian\u0026#39; 能够命中name索引,索引叶子节点存储了主键id,但sex字段必须回表查询才能获取到,不符合索引覆盖,需要再次通过id值扫码聚集索引获取sex字段,效率会降低。\n画外音,Extra:Using index condition。\n如果把(name)单列索引升级为**联合索引(name, sex)**就不同了。\n1 2 3 4 5 6 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name, sex) )engine=innodb; 可以看到:\n1 2 3 select id,name ... where name=\u0026#39;shenjian\u0026#39;; select id,name,sex ... where name=\u0026#39;shenjian\u0026#39;; 都能够命中索引覆盖,无需回表。\n画外音,Extra:Using index。\n四、哪些场景可以利用索引覆盖来优化SQL? 场景1:全表count查询优化 原表为:\nuser(PK id, name, sex);\n1 select count(name) from user; 不能利用索引覆盖。\n添加索引:\n1 alter table user add key(name); 就能够利用索引覆盖提效。\ncount(1)、count(*) 与 count(列名) 的区别?\ncount(*)包括了所有的列,相当于行数,在统计结果的时候,不会忽略列值为NULL count(1)包括了忽略所有列,用1代表代码行,在统计结果的时候,不会忽略列值为NULL count(列名)只包括列名那一列,在统计结果的时候,会忽略列值为空(这里的空不是只空字符串或者0,而是表示null)的计数,即某个字段值为NULL时,不统计。 场景2:列查询回表优化 1 select id,name,sex ... where name=\u0026#39;shenjian\u0026#39;; 这个例子不再赘述,将单列索引(name)升级为联合索引(name, sex),即可避免回表。\n场景3:分页查询 1 select id,name,sex ... order by name limit 500,100; 将单列索引(name)升级为联合索引(name, sex),也可以避免回表。\n参考: https://mp.weixin.qq.com/s/T7LnqldlD9sCH37gWUHVfQ\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E7%B4%A2%E5%BC%95%E4%BC%98%E5%8C%96/","summary":"数据库表结构: 1 2 3 4 5 6 7 8 9 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name) )engine=innodb; select id,name where name=\u0026#39;shenjian\u0026#39; select id,name,sex where name=\u0026#39;shenjian\u0026#39; 多查询了一个属性,为何检索过程完全不同? 什么是回表查询? 什么是索","title":"MySql索引优化"},{"content":"『浅入深出』MySQL 中事务的实现 https://draveness.me/mysql-transaction/\nMySQL 中如何实现事务隔离 https://www.cnblogs.com/fengzheng/p/12557762.html\n详解一条 SQL 的执行过程\nhttps://juejin.cn/post/6931606328129355790\n首先说读未提交,它是性能最好,也可以说它是最野蛮的方式,因为它压根儿就不加锁,所以根本谈不上什么隔离效果,可以理解为没有隔离。\n再来说串行化。读的时候加共享锁,也就是其他事务可以并发读,但是不能写。写的时候加排它锁,其他事务不能并发写也不能并发读。\n最后说读提交和可重复读。这两种隔离级别是比较复杂的,既要允许一定的并发,又想要兼顾的解决问题。\n实现可重复读 为了解决不可重复读,或者为了实现可重复读,MySQL 采用了 MVVC (多版本并发控制) 的方式。\n我们在数据库表中看到的一行记录可能实际上有多个版本,每个版本的记录除了有数据本身外,还要有一个表示版本的字段,记为 row trx_id,而这个字段就是使其产生的事务的 id,事务 ID 记为 transaction id,它在事务开始的时候向事务系统申请,按时间先后顺序递增。\n按照上面这张图理解,一行记录现在有 3 个版本,每一个版本都记录这使其产生的事务 ID,比如事务A的transaction id 是100,那么版本1的row trx_id 就是 100,同理版本2和版本3。\n在上面介绍读提交和可重复读的时候都提到了一个词,叫做快照,学名叫做一致性视图,这也是可重复读和不可重复读的关键,可重复读是在事务开始的时候生成一个当前事务全局性的快照,而读提交则是每次执行语句的时候都重新生成一次快照。\n对于一个快照来说,它能够读到那些版本数据,要遵循以下规则:\n当前事务内的更新,可以读到; 版本未提交,不能读到; 版本已提交,但是却在快照创建后提交的,不能读到; 版本已提交,且是在快照创建前提交的,可以读到; 利用上面的规则,再返回去套用到读提交和可重复读的那两张图上就很清晰了。还是要强调,两者主要的区别就是在快照的创建上,可重复读仅在事务开始是创建一次,而读提交每次执行语句的时候都要重新创建一次。\n并发写问题 存在这的情况,两个事务,对同一条数据做修改。最后结果应该是哪个事务的结果呢,肯定要是时间靠后的那个对不对。并且更新之前要先读数据,这里所说的读和上面说到的读不一样,更新之前的读叫做“当前读”,总是当前版本的数据,也就是多版本中最新一次提交的那版。\n假设事务A执行 update 操作, update 的时候要对所修改的行加行锁,这个行锁会在提交之后才释放。而在事务A提交之前,事务B也想 update 这行数据,于是申请行锁,但是由于已经被事务A占有,事务B是申请不到的,此时,事务B就会一直处于等待状态,直到事务A提交,事务B才能继续执行,如果事务A的时间太长,那么事务B很有可能出现超时异常。如下图所示。\n加锁的过程要分有索引和无索引两种情况,比如下面这条语句\n1 update user set age=11 where id = 1 id 是这张表的主键,是有索引的情况,那么 MySQL 直接就在索引数中找到了这行数据,然后干净利落的加上行锁就可以了。\n而下面这条语句\n1 update user set age=11 where age=10 表中并没有为 age 字段设置索引,所以, MySQL 无法直接定位到这行数据。那怎么办呢,当然也不是加表锁了。MySQL 会为这张表中所有行加行锁,没错,是所有行。但是呢,在加上行锁后,MySQL 会进行一遍过滤,发现不满足的行就释放锁,最终只留下符合条件的行。虽然最终只为符合条件的行加了锁,但是这一锁一释放的过程对性能也是影响极大的。所以,如果是大表的话,建议合理设计索引,如果真的出现这种情况,那很难保证并发度。\n解决幻读 上面介绍可重复读的时候,那张图里标示着出现幻读的地方实际上在 MySQL 中并不会出现,MySQL 已经在可重复读隔离级别下解决了幻读的问题。\n前面刚说了并发写问题的解决方式就是行锁,而解决幻读用的也是锁,叫做间隙锁,MySQL 把行锁和间隙锁合并在一起,解决了并发写和幻读的问题,这个锁叫做 Next-Key锁。\n假设现在表中有两条记录,并且 age 字段已经添加了索引,两条记录 age 的值分别为 10 和 30。\n此时,在数据库中会为索引维护一套B+树,用来快速定位行记录。B+索引树是有序的,所以会把这张表的索引分割成几个区间。\n如图所示,分成了3 个区间,(负无穷,10]、(10,30]、(30,正无穷],在这3个区间是可以加间隙锁的。\n之后,我用下面的两个事务演示一下加锁过程。\n在事务A提交之前,事务B的插入操作只能等待,这就是间隙锁起得作用。当事务A执行update user set name='风筝2号’ where age = 10; 的时候,由于条件 where age = 10 ,数据库不仅在 age =10 的行上添加了行锁,而且在这条记录的两边,也就是(负无穷,10]、(10,30]这两个区间加了间隙锁,从而导致事务B插入操作无法完成,只能等待事务A提交。不仅插入 age = 10 的记录需要等待事务A提交,age\u0026lt;10、10\u0026lt;age\u0026lt;30 的记录页无法完成,而大于等于30的记录则不受影响,这足以解决幻读问题了。\n这是有索引的情况,如果 age 不是索引列,那么数据库会为整个表加上间隙锁。所以,如果是没有索引的话,不管 age 是否大于等于30,都要等待事务A提交才可以成功插入。\n总结 MySQL 的 InnoDB 引擎才支持事务,其中可重复读是默认的隔离级别。\n读未提交和串行化基本上是不需要考虑的隔离级别,前者不加锁限制,后者相当于单线程执行,效率太差。\n读提交解决了脏读问题,行锁解决了并发更新的问题。并且 MySQL 在可重复读级别解决了幻读问题,是通过行锁和间隙锁的组合 Next-Key 锁实现的。\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E4%BA%8B%E5%8A%A1/","summary":"『浅入深出』MySQL 中事务的实现 https://draveness.me/mysql-transaction/ MySQL 中如何实现事务隔离 https://www.cnblogs.com/fengzheng/p/12557762.html 详解一条 SQL 的执行过程 https://juejin.cn/post/6931606328129355790 首先说读未提交,它是性能最好,也可以说它是最野蛮的方式,因为","title":"MySql事务"},{"content":"一,SQL语句性能优化 对查询进行优化,应尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。\n应尽量避免在 where 子句中对字段进行 null 值判断,创建表时NULL是默认值,但大多数时候应该使用NOT NULL,或者使用一个特殊的值,如0,-1作为默 认值。\n应尽量避免在 where 子句中使用!=或\u0026lt;\u0026gt;操作符, MySQL只有对以下操作符才使用索引:\u0026lt;,\u0026lt;=,=,\u0026gt;,\u0026gt;=,BETWEEN,IN,以及某些时候的LIKE\n应尽量避免在 where 子句中使用 or 来连接条件, 否则将导致引擎放弃使用索引而进行全表扫描, 可以 使用UNION合并查询: select id from t where num=10 union all select id from t where num=20\nin 和 not in 也要慎用,否则会导致全表扫描,对于连续的数值,能用 between 就不要用 in 了:Select id from t where num between 1 and 3\n下面的查询也将导致全表扫描:select id from t where name like ‘%abc%’ 或者select id from t where name like ‘%abc’若要提高效率,可以考虑全文检索。而select id from t where name like ‘abc%’ 才用到索引\n如果在 where 子句中使用参数,也会导致全表扫描。\n应尽量避免在 where 子句中对字段进行表达式操作,应尽量避免在where子句中对字段进行函数操作\n很多时候用 exists 代替 in 是一个好的选择: select num from a where num in(select num from b).用下面的语句替换: select num from a where exists(select 1 from b where num=a.num)\n索引固然可以提高相应的 select 的效率,但同时也降低了 insert 及 update 的效率,因为 insert 或 update 时有可能会重建索引,所以怎样建索引需要慎重考虑,视具体情况而定。一个表的索引数最好不要超过6个,若太多则应考虑一些不常使用到的列上建的索引是否有必要\n应尽可能的避免更新 clustered 索引数据列, 因为 clustered 索引数据列的顺序就是表记录的物理存储顺序,一旦该列值改变将导致整个表记录的顺序的调整,会耗费相当大的资源。若应用系统需要频繁更新 clustered 索引数据列,那么需要考虑是否应将该索引建为 clustered 索引。\n尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。\n尽可能的使用 varchar/nvarchar 代替 char/nchar , 因为首先变长字段存储空间小,可以节省存储空间,其次对于查询来说,在一个相对较小的字段内搜索效率显然要高些。\n最好不要使用”“返回所有: select * from t ,用具体的字段列表代替“*”,不要返回用不到的任何字段。\n尽量避免向客户端返回大数据量,若数据量过大,应该考虑相应需求是否合理。\n使用表的别名(Alias):当在SQL语句中连接多个表时,请使用表的别名并把别名前缀于每个Column上.这样一来,就可以减少解析的时间并减少那些由Column歧义引起的语法错误。\n使用“临时表”暂存中间结果 简化SQL语句的重要方法就是采用临时表暂存中间结果,但是,临时表的好处远远不止这些,将临时结果暂存在临时表,后面的查询就在tempdb中了,这可以避免程序中多次扫描主表,也大大减少了程序执行中“共享锁”阻塞“更新锁”,减少了阻塞,提高了并发性能。\n一些SQL查询语句应加上nolock,读、写是会相互阻塞的,为了提高并发性能,对于一些查询,可以加上nolock,这样读的时候可以允许写,但缺点是可能读到未提交的脏数据。使用 nolock有3条原则。查询的结果用于“插、删、改”的不能加nolock !查询的表属于频繁发生页分裂的,慎用nolock !使用临时表一样可以保存“数据前影”,起到类似Oracle的undo表空间的功能,能采用临时表提高并发性能的,不要用nolock 。\n常见的简化规则如下:不要有超过5个以上的表连接(JOIN),考虑使用临时表或表变量存放中间结果。少用子查询,视图嵌套不要过深,一般视图嵌套不要超过2个为宜。\n将需要查询的结果预先计算好放在表中,查询的时候再Select。这在SQL7.0以前是最重要的手段。例如医院的住院费计算。\n尽量使用exists代替select count(1)来判断是否存在记录,count函数只有在统计表中所有行数时使用,而且count(1)比count(*)更有效率。\n尽量使用“\u0026gt;=”,不要使用“\u0026gt;”\n索引的使用规范:索引的创建要与应用结合考虑,建议大的OLTP表不要超过6个索引;尽可能的使用索引字段作为查询条件,尤其是聚簇索引,必要时可以通过index index_name来强制指定索引;避免对大表查询时进行table scan,必要时考虑新建索引;在使用索引字段作为条件时,如果该索引是联合索引,那么必须使用到该索引中的第一个字段作为条件时才能保证系统使用该索引,否则该索引将不会被使用;要注意索引的维护,周期性重建索引,重新编译存储过程。\n二、索引问题 法则:不要在建立的索引的数据列上进行下列操作:\n​\t◆避免对索引字段进行计算操作\n​\t◆避免在索引字段上使用not,\u0026lt;\u0026gt;,!=\n​\t◆避免在索引列上使用IS NULL和IS NOT NULL\n​\t◆避免在索引列上出现数据类型转换\n​\t◆避免在索引字段上使用函数\n​\t◆避免建立索引的列中使用空值。\n1. 什么是最左前缀原则? 如果我们按照 name 字段来建立索引的话,采用B+树的结构,大概的索引结构如下:\n如果我们要进行模糊查找,查找name 以“张\u0026quot;开头的所有人的ID,即 sql 语句为\n1 select ID from table where name like \u0026#39;张%\u0026#39; 由于在B+树结构的索引中,索引项是按照索引定义里面出现的字段顺序排序的,索引在查找的时候,可以快速定位到 ID 为 100的张一,然后直接向右遍历所有张开头的人,直到条件不满足为止。\n也就是说,我们找到第一个满足条件的人之后,直接向右遍历就可以了,由于索引是有序的,所有满足条件的人都会聚集在一起。\n而这种定位到最左边,然后向右遍历寻找,就是我们所说的最左前缀原则。\n2. 为什么用 B+ 树做索引而不用哈希表做索引? 1、哈希表是把索引字段映射成对应的哈希码然后再存放在对应的位置,这样的话,如果我们要进行模糊查找的话,显然哈希表这种结构是不支持的,只能遍历这个表。而B+树则可以通过最左前缀原则快速找到对应的数据\n2、如果我们要进行范围查找,例如查找ID为100 ~ 400的人,哈希表同样不支持,只能遍历全表。\n3、索引字段通过哈希映射成哈希码,如果很多字段都刚好映射到相同值的哈希码的话,那么形成的索引结构将会是一条很长的链表,这样的话,查找的时间就会大大增加。\n3. 主键索引和非主键索引有什么区别? 例如对于下面这个表(其实就是上面的表中增加了一个k字段),且ID是主键。\n主键索引和非主键索引的示意图如下:\n从图中不难看出,主键索引和非主键索引的区别是:非主键索引的叶子节点存放的是主键的值,而主键索引的叶子节点存放的是整行数据,其中非主键索引也被称为二级索引,而主键索引也被称为聚簇索引。\n图中左边表示主键索引,右边表示非主键索引,图中的R1,R2等都表示整行的数据内容。从图中可以看出,主键索引保存的都是整行的数据内容,而非主键索引则保存的都是所在行的行id。 这也就是说,当查询时,以主键索引查询,会直接返回主键索引对应的整行数据;而以非主键索引查询时,会先返回当前索引对应的行id,然后根据行id去查询对应的整行数据。 所以以主键索引当查询条件会比比非主键索引当查询条件快。最后无论是主键索引还是非主键索引,查询速度都会比用普通字段快。\n根据这两种结构我们来进行下查询,看看他们在查询上有什么区别。\n1、如果查询语句是 select * from table where ID = 100,即主键查询的方式,则只需要搜索 ID 这棵 B+树。\n2、如果查询语句是 select * from table where k = 1,即非主键的查询方式,则先搜索k索引树,得到ID=100,再到ID索引树搜索一次,这个过程也被称为回表。\n4. 为什么建议使用主键自增的索引? 对于这颗主键索引的树\n如果我们插入 ID = 650 的一行数据,那么直接在最右边插入就可以了\n但是如果插入的是 ID = 350 的一行数据,由于 B+ 树是有序的,那么需要将下面的叶子节点进行移动,腾出位置来插入 ID = 350 的数据,这样就会比较消耗时间,如果刚好 R4 所在的数据页已经满了,需要进行页分裂操作,这样会更加糟糕。\n但是,如果我们的主键是自增的,每次插入的 ID 都会比前面的大,那么我们每次只需要在后面插入就行, 不需要移动位置、分裂等操作,这样可以提高性能。也就是为什么建议使用主键自增的索引。\n三、表的设计 0、必须使用默认的InnoDB存储引擎\u0026ndash;支持事务、行级锁、并发性能好、CPU及内存缓存页优化使得资源利用率高 1、表和字段使用中文注释\u0026ndash;便于后人理解 2、使用默认utf8mb4字符集\u0026ndash;标准、万国码、无乱码风险、无需转码 3、禁止使用触发器、视图、存储过程和event 4、禁止使用外键\u0026ndash;外键导致表之间的耦合,update和delete操作都会涉及相关表,影响性能 \u0026ndash;架构方向:对数据库性能影响较大的特性少用;应将计算集中在服务层,解放数据库CPU;数据库擅长索引和存储,勿让数据库背负重负 5、禁止存大文件或者照片\u0026ndash;在数据库里存储URI 字段: 6、必须把字段定义为NOT NULL并设置默认值\u0026ndash;null值需要更多的存储空间; 字段中有null值的话,name != \u0026lsquo;san\u0026rsquo; 查询结果中不包含name is null的记录 7、禁止使用TEXT/BOLB字段类型\u0026ndash;浪费磁盘和内存空间,非必要的大量的大字段查询导致内存命中率降低,影响数据库性能 索引: 8、单表索引控制在5个以内 9、单索引不超过5个字段\u0026ndash;超过5个以及起不到有效过滤数据的效果 10、建立组合索引,必须把区分度高的字段放在前边\u0026ndash;更加有效的过滤数据 11、数据区分度不大的字段不易使用索引\u0026ndash;例如:性别只有男,女,订单状态,每次过滤数据很少\n四、SQL查询规范: 1、禁止使用select *,只获取需要的字段\u0026ndash;查询很多无用字段,增加CPU/IO/NET消耗;不能有效的利用覆盖索引;增删字段易出bug 2、禁止使用属性的隐式转换select * from customer where phone=123123\u0026ndash;会导致全表扫描,不能命中索引 3、禁止在where条件上使用函数和计算 4、禁止负向查询(NOT != \u0026lt;\u0026gt; !\u0026lt; !\u0026gt; MOT IN NOT LIKE)和%开头的like(前导模糊查询)\u0026ndash;会导致全表扫描 5、禁止大表使用JOIN查询和子查询\u0026ndash;会产生临时表,消耗较多CPU和内存,影响数据库性能 6、在属性上进行计算不能命中索引\u0026ndash;如 select * from order where YEAR(date) \u0026lt;= \u0026lsquo;2017\u0026rsquo;不能命中索引导致全表扫描 7、复合索引最左前缀\u0026ndash;例如user 表建立了(userid,phone)的联合索引 有如下几种写法: (1)select * from user where userid = ? and phone = ? (2)select * from user where phone=? and userid= ? (3)select * from user where phone = ? (4)select * from user where userid = ? 其中(1)(2)(4)可以命中索引,(3)会导致全表扫描\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E8%AF%AD%E5%8F%A5%E4%BC%98%E5%8C%96/","summary":"一,SQL语句性能优化 对查询进行优化,应尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。 应尽量避免在 where 子句中对字段进行 null 值判断,创","title":"MySql语句优化"},{"content":"常见的性能检测工具 TOP top是最常用的Linux性能监测工具之一。通过top工具可以监视进程和系统整体性能。\n常见命令一览 安装方式 系统自带,无需安装\n使用方法 使用top命令统计整体CPU、内存资源消耗。 CPU项:显示当前总的CPU时间使用分布。 us表示用户态程序占用的CPU时间百分比。 sy表示内核态程序所占用的CPU时间百分比。 wa表示等待IO等待占用的CPU时间百分比。 hi表示硬中断所占用的CPU时间百分比。 si表示软中断所占用的CPU时间百分比。 通过这些参数我们可以分析CPU时间的分布,是否有较多的IO等待。在执行完调优步骤后,我们也可以对CPU使用时间进行前后对比。如果在运行相同程序、业务情况下CPU使用时间降低,说明性能有提升。\nKiB Mem:表示服务器的总内存大小以及使用情况。 KiB Swap:表示当前所使用的Swap空间的大小。Swap空间即当内存不足的时候,把一部分硬盘空间虚拟成内存使用。如果当前所使用的Swap空间大于0,可以考虑优化应用的内存占用或增加物理内存。 在top命令执行后按1,查看每个CPU core的使用情况。 通过该命令可以查看单个CPU core的使用情况,如果CPU占用集中在某几个CPU core上,可以结合业务分析触发原因,从而找到优化思路。 选中top命令的P选项,查看线程运行在哪些 CPU core上。 在top命令执行后按F,可以进入top命令管理界面。在该界面通过上下键移动光标到P选项,通过空格键选中后按Esc退出,即可显示出线程运行的CPU核。观察一段时间,若业务线程在不同NUMA节点内的CPU core上运行,则说明存在较多的跨NUMA访问,可通过NUMA绑核进行优化。(top -\u0026gt; F -\u0026gt; up/down -\u0026gt; 空格 -\u0026gt; ESC) 使用top -p $PID -H命令观察进程中每个线程的CPU资源使用。 “-p”后接的参数为待观察的进程ID。通过该命令可以找出消耗资源多的线程,随后可根据线程号分析线程中的热点函数、调用过程等情况。 Perf Perf工具是非常强大的Linux性能分析工具,可以通过该工具获得进程内的调用情况、资源消耗情况并查找分析热点函数。\n常见命令一览 安装方式 centos 为例\n1 yum -y install perf 使用方式 通过perf top命令查找热点函数。 该命令统计各个函数在某个性能事件上的热度,默认显示CPU占用率,可以通过“-e”监控其它事件。 Overhead表示当前事件在全部事件中占的比例。 Shared Object表示当前事件生产者,如kernel、perf命令、C语言库函数等。 Symbol则表示热点事件对应的函数名称。 通过热点函数,我们可以找到消耗资源较多的行为,从而有针对性的进行优化。 收集一段时间内的线程调用. perf sched record命令用于记录一段时间内,进程的调用情况。“-p”后接进程号,“sleep”后接统计时长,单位为秒。收集到的信息自动存放在当前目录下,文件名为perf.data。 解析收集到的线程调度信息。 perf sched latency命令可以解析当前目录下的perf.data文件。“-s”表示进行排序,后接参数“max”表示按照最大延迟时间大小排序。 numactl numactl工具可用于查看当前服务器的NUMA节点配置、状态,可通过该工具将进程绑定到指定CPU core,由指定CPU core来运行对应进程。\n常见命令一览 安装方式 以centos 为例\n1 yum -y install numactl numastat 使用方法 通过numactl查看当前服务器的NUMA配置。 从numactl执行结果可以看到,示例服务器共划分为4个NUMA节点。每个节点包含16个CPU core,每个节点的内存大小约为64GB。同时,该命令还给出了不同节点间的距离,距离越远,跨NUMA内存访问的延时越大。应用程序运行时应减少跨NUMA访问内存。 通过numactl将进程绑定到指定CPU core。 通过 numactl -C 0-15 top 命令即是将进程“top”绑定到0~15 CPU core上执行。 通过numastat查看当前NUMA节点的内存访问命中率。 numa_hit表示节点内CPU核访问本地内存的次数。 numa_miss表示节点内核访问其他节点内存的次数。跨节点的内存访问会存在高延迟从而降低性能,因此,numa_miss的值应当越低越好,如果过高,则应当考虑绑核。 ","permalink":"https://reid00.github.io/en/posts/langs_linux/linux%E6%80%A7%E8%83%BD%E6%A3%80%E6%B5%8B/","summary":"常见的性能检测工具 TOP top是最常用的Linux性能监测工具之一。通过top工具可以监视进程和系统整体性能。 常见命令一览 安装方式 系统自带,无需","title":"Linux性能检测"},{"content":"简介LSM Tree MySQL、etcd 等存储系统都是面向读多写少场景的,其底层大都采用 B-Tree 及其变种数据结构。而 LSM-Tree 则解决了另一个应用场景——写多读少时面临的问题。在面对亿级的海量数据的存储和检索的场景下,我们通常选择强力的 NoSQL 数据库,如 Hbase、RocksDB 等,它们的文件组织方式,都是仿照 LSM-Tree 实现的。 reference\nLSM-Tree 全称是 Log Structured Merge Tree,是一种分层、有序、面向磁盘的数据结构,其核心思想是充分利用磁盘的顺序写性能要远高于随机写性能这一特性,将批量的随机写转化为一次性的顺序写。\n从上图可以直观地看出,磁盘的顺序访问速度至少比随机 I/O 快三个数量级,甚至顺序访问磁盘比随机访问主内存还要快。这意味着要尽可能避免随机 I/O 操作,顺序访问非常值得我们去探讨与设计。\nLSM-Tree 围绕这一原理进行设计和优化,通过消去随机的更新操作来达到这个目的,以此让写性能达到最优,同时为那些长期具有高更新频率的文件提供低成本的索引机制,减少查询时的开销。\nTwo-Component LSM-Tree LSM-Tree 可以由两个或多个类树的数据结构组件构成,本小节我们先介绍较为简单的两组件情况。 两组件 LSM-Tree(Two-Component LSM-Tree)在内存中有一个 C0 组件,它可以是 AVL 或 SkipList 等结构,所有写入首先写到 C0 中。而磁盘上有一个 C1 组件,当 C0 组件的大小达到阈值时,就需要进行 Rolling Merge,将内存中的内容合并到 C1 中。两组件 LSM-Tree 的写操作流程如下:\n当有写操作时,会先将数据追加写到日志文件中,以备必要时恢复; 然后将数据写入位于内存的 C0 组件,通过某种数据结构保持 Key 有序; 内存中的数据定时或按固定大小刷新到磁盘,更新操作只写到内存,并不更新磁盘上已有文件; 随着写操作越来越多,磁盘上积累的文件也越来越多,这些文件不可写但有序,所以我们定时对文件进行合并(Compaction)操作,消除冗余数据,减少文件数量。 类似于普通的日志写入方式,这种数据结构的写入,全部都是以Append的模式追加,不存在删除和修改。对于任何应用来说,那些会导致索引值发生变化的数据更新都是繁琐且耗时的,但是这样的更新却可以被 LSM-Tree 轻松地解决,将该更新操作看做是一个删除操作加上一个插入操作。\nC1 组件是为顺序性的磁盘访问优化过的,可以是 B-Tree 一类的数据结构(LevelDB 中的实现是 SSTable),所有的节点都是 100% 填充,为了有效利用磁盘,在根节点之下的所有的单页面节点都会被打包放到连续的多页面磁盘块(Multi-Page Block)上。对于 Rolling Merge 和长区间检索的情况将会使用 Multi-Page Block I/O,这样就可以有效减少磁盘旋臂的移动;而在匹配性的查找中会使用 Single-Page I/O,以最小化缓存量。通常根节点只有一个单页面,而其它节点使用 256KB 的 Multi-Page Block。\n在一个两组件 LSM-Tree 中,只要 C0 组件足够大,那么就会有一个批量处理效果。例如,如果一条数据记录的大小是 16Bytes,在一个 4KB 的节点中将会有 250 条记录;如果 C0 组件的大小是 C1 的 1/25,那么在每个合并操作新产生的具有 250 条记录的 C1 节点中,将会有 10 条是从 C0 合并而来的新记录。也就是说用户新写入的数据暂时存储到内存的 C0 中,然后再批量延迟写入磁盘,相当于将用户之前的 10 次写入合并为一次写入。显然地,由于只需要一次随机写就可以写入多条数据,LSM-Tree 的写效率比 B-Tree 等数据结构更高,而 Rolling Merge 过程则是其中的关键。\nRolling Merge 我们可以把两组件 LSM-Tree 的 Rolling Merge 过程类比为一个具有一定步长的游标循环往复地穿越在 C0 和 C1 的键值对上,不断地从C0 中取出数据放入到磁盘上的 C1 中。 该游标在 C1树的叶子节点和索引节点上都有一个逻辑位置,在每个层级上,所有正在参与合并的 Multi-Page Blocks 将会被分成两种类型:Emptying Block的内部记录正在被移出,但是还有一些数据是游标所未到达的,Filling Block则存储着合并后的结果。类似地,该游标也会定义出Emptying Node和Filling Node,这两个节点都被缓存在内存中。为了可以进行并发访问,每个层级上的 Block 包含整数个节点,这样在对执行节点进行重组合并过程中,针对这些节点内部记录的访问将会被阻塞,但是同一 Block 中其它节点依然可以正常访问。 合并后的新 Blocks 会被写入到新的磁盘位置上,这样旧的 Blocks 就不会被覆盖,在发生 crash 后依然可以进行数据恢复。同时需要在索引节点中建立新的索引信息,为了进行恢复还需要产生一条日志记录。那些可能在恢复过程中需要的旧的 Block 暂时还不会被删除,只有当后续的写入提供了足够信息时它们才可以宣告失效。\nC1中的父目录节点也会被缓存在内存中,实时更新以反映出叶子节点的变动,同时父节点还会在内存中停留一段时间以最小化 I/O。当合并步骤完成后,C1 中的旧叶子节点就会变为无效状态,随后会被从 C1 目录结构中删除。为了减少崩溃后的数据恢复时间,合并过程需要进行周期性的 checkpoint,强制将缓存信息写入磁盘。\n为了让 LSM 读取速度相对较快,管理文件数量非常重要,因此我们要对文件进行合并压缩。在 LevelDB 中,合并后的大文件会进入下一个 Level 中。 例如我们的 Level-0 中每个文件有 10 条数据,每 5 个 Level-0 文件合并到 1 个 Level1 文件中,每单个 Level1 文件中有 50 条数据(可能会略少一些)。而每 5 个 Level1 文件合并到 1 个 Level2 文件中,该过程会持续创建越来越大的文件,越旧的数据 Level 级数也会越来越高。\n由于文件已排序,因此合并文件的过程非常快速,但是在等级越高的数据查询速度也越慢。在最坏的情况下,我们需要单独搜索所有文件才能读取结果。\n数据读取 当在 LSM-Tree 上执行一个精确匹配查询或者范围查询时,首先会到 C0 中查找所需的值,如果在 C0 中没有找到,再去 C1 中查找。这意味着与 B-Tree 相比,会有一些额外的 CPU 开销,因为现在需要去两个目录中搜索。虽然每个文件都保持排序,可以通过比较该文件的最大/最小键值对来判断是否需要进行搜索。但是,随着文件数量的增加,每个文件都需要检查,读取还是会变得越来越慢。\n因此,LSM-Tree 的读取速度比其它数据结构更慢。但是我们可以使用一些索引技巧进行优化。LevelDB 会在每个文件末尾保留块索引来加快查询速度,这比直接二进制搜索更好,因为它允许使用变长字段,并且更适合压缩数据。详细的内容会在 SSTable 小节中介绍。\n我们还可以针对删除操作进行一些优化,高效地更新索引。例如通过断言式删除(Predicate Deletion)过程,只要简单地声明一个断言,就可以执行批量删除的操作方式。例如删除那些时间戳在 20 天前的所有的索引值,当位于 C1 组件的记录通过正常过的数据合并过程被加载到内存中时,就可以它们直接丢弃来实现删除。\n除此之外,考虑到各种因素,针对 LSM-Tree 的并发访问方法必须解决如下三种类型的物理冲突:\n查询操作不能同时去访问另一个进程的 Rolling Merge 正在修改的磁盘组件的节点内容; 针对 C0 组件的查询和插入操作也不能与正在进行的 Rolling Merge 的同时对树的相同部分进行访问; 在多组件 LSM-Tree 中,从 Ci-1 到 Ci 的 Rolling Merge 游标有时需要越过从 Ci 到 Ci+1 的 Rolling Merge 游标,因为数据从 Ci-1 移出速率 \u0026gt;= 从 Ci 移出的速率,这意味着 Ci-1 所关联的游标的循环周期要更快。因此无论如何,所采用的并发访问机制必须允许这种交错发生,而不能强制要求在交会点,移入数据到 Ci 的线程必须阻塞在从 Ci 移出数据的线程之后。 Multi-Component LSM-Tree 为了保证 C0 的大小维持在在阈值范围内,这要求 Rolling Merge 将数据合并到 C1 的速度必须不低于用户的写入速度,此时 C0 的不同大小会对整体性能造成不同的结果:\nC0 非常小:此时一条数据的插入都会使 C0 变满,从而触发 Rolling Merge,最坏的情况下,C0 的每一次插入都会导致 C1 的全部叶子节点被读进内存又写回磁盘,I/O 开销非常高; C0 非常大:此时基本没有 I/O 开销,但需要很大的内存空间,也不易进行数据恢复。 为了进一步缩小两组件 LSM-Tree 的开销平衡点,多组件 LSM-Tree 在 C0 和 C1 之间引入一组新的 Component,大小介于两者之间,逐级增长,这样 C0 就不用每次和 C1 进行 Rolling Merge,而是先和中间的组件进行合并,当中间的组件到达其大小限制后再和 C1 做 Rolling Merge,这样就可以在减少 C0 内存开销的同时减少磁盘 I/O 开销。有些类似于我们的多级缓存结构。\n小节 LSM-Tree 的实现思路与常规存储系统采取的措施不太相同,其将随机写转化为顺序写,尽量保持日志型数据库的写性能优势,并提供相对较好的读性能。在大量写入场景下 LSM-Tree 之所以比 B-Tree、Hash 要好,得益于以下两个原因:\nBatch Write:由于采用延迟写,LSM-Tree 可以在 Rolling Merge 过程中,通过一次 I/O 批量向 C1 写入多条数据,那么这多条数据就均摊了这一次 I/O,减少磁盘的 I/O 开销; Multi-Page Block:LSM-Tree 的批量写可以有效地利用 Multi-Page Block,在 Rolling Merge 的过程中,一次从 C1 中读出多个连续的数据页与 C0 合并,然后一次向 C1 写回这些连续页面,这样只需要单次 I/O 就可以完成多个 Pages 的读写。 LSM Tree 组件介绍 如上图所示,LSM树有以下三个重要组成部分:\nMemTable MemTable是在内存中的数据结构,用于保存最近更新的数据,会按照Key有序地组织这些数据,LSM树对于具体如何组织有序地组织数据并没有明确的数据结构定义,例如Hbase使跳跃表来保证内存中key的有序。因为数据暂时保存在内存中,内存并不是可靠存储,如果断电会丢失数据,因此通常会通过WAL(Write-ahead logging,预写式日志)的方式来保证数据的可靠性。\nImmutable MemTable 当 MemTable达到一定大小后,会转化成Immutable MemTable。Immutable MemTable是将转MemTable变为SSTable的一种中间状态。写操作由新的MemTable处理,在转存过程中不阻塞数据更新操作。\nSSTable(Sorted String Table) 有序键值对集合,是LSM树组在磁盘中的数据结构。为了加快SSTable的读取,可以通过建立key的索引以及布隆过滤器来加快key的查找。 这里需要关注一个重点,LSM树(Log-Structured-Merge-Tree)正如它的名字一样,LSM树会将所有的数据插入、修改、删除等操作记录(注意是操作记录)保存在内存之中,当此类操作达到一定的数据量后,再批量地顺序写入到磁盘当中。这与B+树不同,B+树数据的更新会直接在原数据所在处修改对应的值,但是LSM数的数据更新是日志式的,当一条数据更新是直接append一条更新记录完成的。这样设计的目的就是为了顺序写,不断地将Immutable MemTable flush到持久化存储即可,而不用去修改之前的SSTable中的key,保证了顺序写。\n因此当MemTable达到一定大小flush到持久化存储变成SSTable后,在不同的SSTable中,可能存在相同Key的记录,当然最新的那条记录才是准确的。这样设计的虽然大大提高了写性能,但同时也会带来一些问题:\n1 冗余存储,对于某个key,实际上除了最新的那条记录外,其他的记录都是冗余无用的,但是仍然占用了存储空间。因此需要进行Compact操作(合并多个SSTable)来清除冗余的记录。 2 读取时需要从最新的倒着查询,直到找到某个key的记录。最坏情况需要查询完所有的SSTable,这里可以通过前面提到的索引/布隆过滤器来优化查找速度。\nLSM树的Compact策略 从上面可以看出,Compact操作是十分关键的操作,否则SSTable数量会不断膨胀。在Compact策略上,主要介绍两种基本策略:size-tiered和leveled。 不过在介绍这两种策略之前,先介绍三个比较重要的概念,事实上不同的策略就是围绕这三个概念之间做出权衡和取舍。\n读放大:读取数据时实际读取的数据量大于真正的数据量。例如在LSM树中需要先在MemTable查看当前key是否存在,不存在继续从SSTable中寻找。 写放大:写入数据时实际写入的数据量大于真正的数据量。例如在LSM树中写入时可能触发Compact操作,导致实际写入的数据量远大于该key的数据量。 空间放大:数据实际占用的磁盘空间比数据的真正大小更多。上面提到的冗余存储,对于一个key来说,只有最新的那条记录是有效的,而之前的记录都是可以被清理回收的。\n1) size-tiered 策略 size-tiered策略保证每层SSTable的大小相近,同时限制每一层SSTable的数量。如上图,每层限制SSTable为N,当每层SSTable达到N后,则触发Compact操作合并这些SSTable,并将合并后的结果写入到下一层成为一个更大的sstable。 由此可以看出,当层数达到一定数量时,最底层的单个SSTable的大小会变得非常大。并且size-tiered策略会导致空间放大比较严重。即使对于同一层的SSTable,每个key的记录是可能存在多份的,只有当该层的SSTable执行compact操作才会消除这些key的冗余记录。\n2) leveled策略 leveled策略也是采用分层的思想,每一层限制总文件的大小。 但是跟size-tiered策略不同的是,leveled会将每一层切分成多个大小相近的SSTable。这些SSTable是这一层是全局有序的,意味着一个key在每一层至多只有1条记录,不存在冗余记录。之所以可以保证全局有序,是因为合并策略和size-tiered不同,接下来会详细提到。\n假设存在以下这样的场景:\nL1的总大小超过L1本身大小限制: 此时会从L1中选择至少一个文件,然后把它跟L2有交集的部分(非常关键)进行合并。生成的文件会放在L2: 如上图所示,此时L1第二SSTable的key的范围覆盖了L2中前三个SSTable,那么就需要将L1中第二个SSTable与L2中前三个SSTable执行Compact操作。\n如果L2合并后的结果仍旧超出L2的阈值大小,需要重复之前的操作 —— 选至少一个文件然后把它合并到下一层: 需要注意的是,多个不相干的合并是可以并发进行的: leveled策略相较于size-tiered策略来说,每层内key是不会重复的,即使是最坏的情况,除开最底层外,其余层都是重复key,按照相邻层大小比例为10来算,冗余占比也很小。因此空间放大问题得到缓解。但是写放大问题会更加突出。举一个最坏场景,如果LevelN层某个SSTable的key的范围跨度非常大,覆盖了LevelN+1层所有key的范围,那么进行Compact时将涉及LevelN+1层的全部数据。\n","permalink":"https://reid00.github.io/en/posts/storage/lsm-tree/","summary":"简介LSM Tree MySQL、etcd 等存储系统都是面向读多写少场景的,其底层大都采用 B-Tree 及其变种数据结构。而 LSM-Tree 则解决了另一个应用场景——写多读少时","title":"LSM Tree"},{"content":"一、概述 1.1 基本概念: Docker是一个虚拟环境容器,可以将你的开发环境、代码、配置文件等一并打包到这个容器中,并发布和应用到任意平台中。比如,你在本地用Python开发网站后台,开发测试完成后,就可以将Python3及其依赖包、Flask及其各种插件、Mysql、Nginx等打包到一个容器中,然后部署到任意你想部署到的环境。\n1.2 对比虚拟机与Docker Docker守护进程可以直接与主操作系统进行通信,为各个Docker容器分配资源;它还可以将容器与主操作系统隔离,并将各个容器互相隔离。虚拟机启动需要数分钟,而Docker容器可以在数毫秒内启动。由于没有臃肿的从操作系统,Docker可以节省大量的磁盘空间以及其他系统资源。\n说了这么多Docker的优势,大家也没有必要完全否定虚拟机技术,因为两者有不同的使用场景。虚拟机更擅长于彻底隔离整个运行环境。例如,云服务提供商通常采用虚拟机技术隔离不同的用户。而Docker通常用于隔离不同的应用,例如前端,后端以及数据库。\n1.3 与传统VM特性对比: 特性 容器 虚拟机 启动速度 秒级 分钟级 硬盘使用 一般为MB 一般为GB 性能 接近原生 弱于原生 系统支持量 单机支持上千个容器 一般几十个 隔离性 安全隔离 完全隔离 1.4 Docker组件 docker Client客户端————\u0026gt;向docker服务器进程发起请求,如:创建、停止、销毁容器等操作\ndocker Server服务器进程—–\u0026gt;处理所有docker的请求,管理所有容器\ndocker Registry镜像仓库——\u0026gt;镜像存放的中央仓库,可看作是存放二进制的scm\n1.5 Docker的三个概念 镜像(Image):类似于虚拟机中的镜像,是一个包含有文件系统的面向Docker引擎的只读模板。任何应用程序运行都需要环境,而镜像就是用来提供这种运行环境的。例如一个Ubuntu镜像就是一个包含Ubuntu操作系统环境的模板,同理在该镜像上装上Apache软件,就可以称为Apache镜像。 容器(Container):类似于一个轻量级的沙盒,可以将其看作一个极简的Linux系统环境(包括root权限、进程空间、用户空间和网络空间等),以及运行在其中的应用程序。Docker引擎利用容器来运行、隔离各个应用。容器是镜像创建的应用实例,可以创建、启动、停止、删除容器,各个容器之间是是相互隔离的,互不影响。注意:镜像本身是只读的,容器从镜像启动时,Docker在镜像的上层创建一个可写层,镜像本身不变。 仓库(Repository):类似于代码仓库,这里是镜像仓库,是Docker用来集中存放镜像文件的地方。注意与注册服务器(Registry)的区别:注册服务器是存放仓库的地方,一般会有多个仓库;而仓库是存放镜像的地方,一般每个仓库存放一类镜像,每个镜像利用tag进行区分,比如Ubuntu仓库存放有多个版本(12.04、14.04等)的Ubuntu镜像。 二、安装Docker 2.1 Ubuntu 旧版本的 Docker 称为 docker 或者 docker-engine,使用以下命令卸载旧版本:\n1 $ sudo apt-get remove docker docker-engine docker.io 使用 APT 安装 1 2 3 $ sudo apt-get update $ sudo apt-get install apt-transport-https ca-certificates curl software-properties-common Docker CE 镜像源站 使用官方安装脚本自动安装 (仅适用于公网环境) 1 curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun 手动安装帮助 (阿里云ECS可以通过内网安装,见注释部分内容) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # step 1: 安装必要的一些系统工具 sudo apt-get update sudo apt-get -y install apt-transport-https ca-certificates curl software-properties-common # step 2: 安装GPG证书 curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add - # Step 3: 写入软件源信息 sudo add-apt-repository \u0026#34;deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable\u0026#34; # Step 4: 更新并安装 Docker-CE sudo apt-get -y update sudo apt-get -y install docker-ce 注意:其他注意事项在下面的注释中 # 安装指定版本的Docker-CE: # Step 1: 查找Docker-CE的版本: # apt-cache madison docker-ce # docker-ce | 17.03.1~ce-0~ubuntu-xenial | http://mirrors.aliyun.com/docker-ce/linux/ubuntu xenial/stable amd64 Packages # docker-ce | 17.03.0~ce-0~ubuntu-xenial | http://mirrors.aliyun.com/docker-ce/linux/ubuntu xenial/stable amd64 Packages # Step 2: 安装指定版本的Docker-CE: (VERSION 例如上面的 17.03.1~ce-0~ubuntu-xenial) # sudo apt-get -y install docker-ce=[VERSION] # 通过经典网络、VPC网络内网安装时,用以下命令替换Step 2、Step 3中的命令 # 经典网络: # curl -fsSL http://mirrors.aliyuncs.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add - # sudo add-apt-repository \u0026#34;deb [arch=amd64] http://mirrors.aliyuncs.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable\u0026#34; # VPC网络: # curl -fsSL http://mirrors.cloud.aliyuncs.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add - # sudo add-apt-repository \u0026#34;deb [arch=amd64] http://mirrors.cloud.aliyuncs.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable\u0026#34;\t2.2 CentOS 7 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 # step 1: 安装必要的一些系统工具 sudo yum install -y yum-utils device-mapper-persistent-data lvm2 # Step 2: 添加软件源信息 sudo yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo # Step 3: 更新并安装 Docker-CE sudo yum makecache fast sudo yum -y install docker-ce # Step 4: 开启Docker服务 sudo service docker start 注意:其他注意事项在下面的注释中 # 官方软件源默认启用了最新的软件,您可以通过编辑软件源的方式获取各个版本的软件包。例如官方并没有将测试版本的软件源置为可用,你可以通过以下方式开启。同理可以开启各种测试版本等。 # vim /etc/yum.repos.d/docker-ce.repo # 将 [docker-ce-test] 下方的 enabled=0 修改为 enabled=1 # # 安装指定版本的Docker-CE: # Step 1: 查找Docker-CE的版本: # yum list docker-ce.x86_64 --showduplicates | sort -r # Loading mirror speeds from cached hostfile # Loaded plugins: branch, fastestmirror, langpacks # docker-ce.x86_64 17.03.1.ce-1.el7.centos docker-ce-stable # docker-ce.x86_64 17.03.1.ce-1.el7.centos @docker-ce-stable # docker-ce.x86_64 17.03.0.ce-1.el7.centos docker-ce-stable # Available Packages # Step2 : 安装指定版本的Docker-CE: (VERSION 例如上面的 17.03.0.ce.1-1.el7.centos) # sudo yum -y install docker-ce-[VERSION] # 注意:在某些版本之后,docker-ce安装出现了其他依赖包,如果安装失败的话请关注错误信息。例如 docker-ce 17.03 之后,需要先安装 docker-ce-selinux。 # yum list docker-ce-selinux- --showduplicates | sort -r # sudo yum -y install docker-ce-selinux-[VERSION] # 通过经典网络、VPC网络内网安装时,用以下命令替换Step 2中的命令 # 经典网络: # sudo yum-config-manager --add-repo http://mirrors.aliyuncs.com/docker-ce/linux/centos/docker-ce.repo # VPC网络: # sudo yum-config-manager --add-repo http://mirrors.could.aliyuncs.com/docker-ce/linux/centos/docker-ce.repo 2.3 安装校验 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 root@iZbp12adskpuoxodbkqzjfZ:$ docker version Client: Version: 17.03.0-ce API version: 1.26 Go version: go1.7.5 Git commit: 3a232c8 Built: Tue Feb 28 07:52:04 2017 OS/Arch: linux/amd64 Server: Version: 17.03.0-ce API version: 1.26 (minimum version 1.12) Go version: go1.7.5 Git commit: 3a232c8 Built: Tue Feb 28 07:52:04 2017 OS/Arch: linux/amd64 Experimental: false 三、镜像加速器 网易云加速器 https://hub-mirror.c.163.com\n百度云加速器 https://mirror.baidubce.com\n阿里云加速器(需登录账号获取)\n3.1 Ubuntu 16.04+、Debian 8+、CentOS 7 对于使用 systemd 的系统,请在 /etc/docker/daemon.json 中写入如下内容(如果文件不存在请新建该文件)\n1 2 3 4 5 6 { \u0026#34;registry-mirrors\u0026#34;: [ \u0026#34;https://hub-mirror.c.163.com\u0026#34;, \u0026#34;https://mirror.baidubce.com\u0026#34; ] } 之后重新启动服务。\n1 2 $ sudo systemctl daemon-reload $ sudo systemctl restart docker 四、命令整理 4.1 容器操作 1 2 3 4 5 6 7 8 9 docker create # 创建一个容器但是不启动它 docker run # 创建并启动一个容器 docker stop # 停止容器运行,发送信号SIGTERM docker start # 启动一个停止状态的容器 docker restart # 重启一个容器 docker rm # 删除一个容器 docker kill # 发送信号给容器,默认SIGKILL docker attach # 连接(进入)到一个正在运行的容器 docker wait # 阻塞一个容器,直到容器停止运行 4.2 获取容器信息 1 2 3 4 5 6 7 8 docker ps # 显示状态为运行(Up)的容器 docker ps -a # 显示所有容器,包括运行中(Up)的和退出的(Exited) docker inspect # 深入容器内部获取容器所有信息 docker logs # 查看容器的日志(stdout/stderr) docker events # 得到docker服务器的实时的事件 docker port # 显示容器的端口映射 docker top # 显示容器的进程信息 docker diff # 显示容器文件系统的前后变化 4.3 导出容器 1 2 docker cp # 从容器里向外拷贝文件或目录 docker export # 将容器整个文件系统导出为一个tar包,不带layers、tag等信息 4.4 执行 1 docker exec # 在容器里执行一个命令,可以执行bash进入交互式 4.5 镜像操作 1 2 3 4 5 6 7 8 9 docker images # 显示本地所有的镜像列表 docker import # 从一个tar包创建一个镜像,往往和export结合使用 docker build # 使用Dockerfile创建镜像(推荐) docker commit # 从容器创建镜像 docker rmi or docker image rm [name:tag] # 删除一个镜像 docker load # 从一个tar包创建一个镜像,和save配合使用 docker save # 将一个镜像保存为一个tar包,带layers和tag信息 docker history # 显示生成一个镜像的历史命令 docker tag # 为镜像起一个别名 4.6 镜像仓库(registry)操作 1 2 3 4 docker login # 登录到一个registry docker search # 从registry仓库搜索镜像 docker pull # 从仓库下载镜像到本地 docker push # 将一个镜像push到registry仓库中 4.7 数据券操作 1 2 3 4 5 # docker -it -v /宿主机绝对路径:/容器内的目录 镜像名称 docker run -it -v /myDataVolume:/dataVolumeContainter centos # docker -it -v /宿主机绝对路径:/容器内的目录:ro 镜像名称 容器内的文件只读权限, 不可以写文件 docker run -it -v /myDataVolume:/dataVolumeContainter:ro centos 五、实例操作 source:https://yeasy.gitbook.io/docker_practice/install/mirror\n现在让我们以定制一个 Web 服务器为例子,来讲解镜像是如何构建的。\n1 docker run --name webserver -d -p 80:80 nginx 这条命令会用 nginx 镜像启动一个容器,命名为 webserver,并且映射了 80 端口,这样我们可以用浏览器去访问这个 nginx 服务器。\n如果是在 Linux 本机运行的 Docker,或者如果使用的是 Docker Desktop for Mac/Windows,那么可以直接访问:http://localhost;如果使用的是 Docker Toolbox,或者是在虚拟机、云服务器上安装的 Docker,则需要将 localhost 换为虚拟机地址或者实际云服务器地址。\n直接用浏览器访问的话,我们会看到默认的 Nginx 欢迎页面。\n现在,假设我们非常不喜欢这个欢迎页面,我们希望改成欢迎 Docker 的文字,我们可以使用 docker exec 命令进入容器,修改其内容。\n1 2 3 4 $ docker exec -it webserver bash root@3729b97e8226:/# echo \u0026#39;\u0026lt;h1\u0026gt;Hello, Docker!\u0026lt;/h1\u0026gt;\u0026#39; \u0026gt; /usr/share/nginx/html/index.html root@3729b97e8226:/# exit exit 我们以交互式终端方式进入 webserver 容器,并执行了 bash 命令,也就是获得一个可操作的 Shell。\n然后,我们用 Hello, Docker! 覆盖了 /usr/share/nginx/html/index.html 的内容。\n现在我们再刷新浏览器的话,会发现内容被改变了。\n我们修改了容器的文件,也就是改动了容器的存储层。我们可以通过 docker diff 命令看到具体的改动。\n1 docker diff webserver 现在我们定制好了变化,我们希望能将其保存下来形成镜像。\n要知道,当我们运行一个容器的时候(如果不使用卷的话),我们做的任何文件修改都会被记录于容器存储层里。而 Docker 提供了一个 docker commit 命令,可以将容器的存储层保存下来成为镜像。换句话说,就是在原有镜像的基础上,再叠加上容器的存储层,并构成新的镜像。以后我们运行这个新镜像的时候,就会拥有原有容器最后的文件变化。\ndocker commit 的语法格式为:\n1 docker commit [选项] \u0026lt;容器ID或容器名\u0026gt; [\u0026lt;仓库名\u0026gt;[:\u0026lt;标签\u0026gt;]] 我们可以用下面的命令将容器保存为镜像:\n1 2 3 4 5 6 $ docker commit \\ --author \u0026#34;Tao Wang \u0026lt;twang2218@gmail.com\u0026gt;\u0026#34; \\ --message \u0026#34;修改了默认网页\u0026#34; \\ webserver \\ nginx:v2 sha256:07e33465974800ce65751acc279adc6ed2dc5ed4e0838f8b86f0c87aa1795214 其中 --author 是指定修改的作者,而 --message 则是记录本次修改的内容。这点和 git 版本控制相似,不过这里这些信息可以省略留空。\n我们可以在 docker image ls 中看到这个新定制的镜像:\n1 docker images or docker image ls 我们还可以用 docker history 具体查看镜像内的历史记录,如果比较 nginx:latest 的历史记录,我们会发现新增了我们刚刚提交的这一层.\n1 2 3 4 5 6 7 8 9 10 11 $ docker history nginx:v2 IMAGE CREATED CREATED BY SIZE COMMENT 07e334659748 54 seconds ago nginx -g daemon off; 95 B 修改了默认网页 e43d811ce2f4 4 weeks ago /bin/sh -c #(nop) CMD [\u0026#34;nginx\u0026#34; \u0026#34;-g\u0026#34; \u0026#34;daemon 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) EXPOSE 443/tcp 80/tcp 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c ln -sf /dev/stdout /var/log/nginx/ 22 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c apt-key adv --keyserver hkp://pgp. 58.46 MB \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) ENV NGINX_VERSION=1.11.5-1 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) MAINTAINER NGINX Docker Ma 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) CMD [\u0026#34;/bin/bash\u0026#34;] 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) ADD file:23aa4f893e3288698c 123 MB 新的镜像定制好后,我们可以来运行这个镜像。\n1 docker run --name web2 -d -p 81:80 nginx:v2 这里我们命名为新的服务为 web2,并且映射到 81 端口。如果是 Docker Desktop for Mac/Windows 或 Linux 桌面的话,我们就可以直接访问 http://localhost:81 看到结果,其内容应该和之前修改后的 webserver 一样。\n至此,我们第一次完成了定制镜像,使用的是 docker commit 命令,手动操作给旧的镜像添加了新的一层,形成新的镜像,对镜像多层存储应该有了更直观的感觉。\n慎用 docker commit 使用 docker commit 命令虽然可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用。\n首先,如果仔细观察之前的 docker diff webserver 的结果,你会发现除了真正想要修改的 /usr/share/nginx/html/index.html 文件外,由于命令的执行,还有很多文件被改动或添加了。这还仅仅是最简单的操作,如果是安装软件包、编译构建,那会有大量的无关内容被添加进来,如果不小心清理,将会导致镜像极为臃肿。\n此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为 黑箱镜像,换句话说,就是除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。而且,即使是这个制作镜像的人,过一段时间后也无法记清具体的操作。这种黑箱镜像的维护工作是非常痛苦的。\n而且,回顾之前提及的镜像所使用的分层存储的概念,除当前层外,之前的每一层都是不会发生改变的,换句话说,任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。如果使用 docker commit 制作镜像,以及后期修改的话,每一次修改都会让镜像更加臃肿一次,所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无法访问到。这会让镜像更加臃肿。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/docker%E7%AC%94%E8%AE%B0/","summary":"一、概述 1.1 基本概念: Docker是一个虚拟环境容器,可以将你的开发环境、代码、配置文件等一并打包到这个容器中,并发布和应用到任意平台中。比如","title":"Docker笔记"},{"content":"ElasticSearch面试题 1.为什么要使用Elasticsearch? 因为在我们商城中的数据,将来会非常多,所以采用以往的模糊查询,模糊查询前置配置,会放弃索引,导致商品查询是全表扫面,在百万级别的数据库中,效率非常低下,而我们使用ES做一个全文索引,我们将经常查询的商品的某些字段,比如说商品名,描述、价格还有id这些字段我们放入我们索引库里,可以提高查询速度。\n2.Elasticsearch是如何实现Master选举的? Elasticsearch的选主是ZenDiscovery模块负责的,主要包含Ping(节点之间通过这个RPC来发现彼此)和Unicast(单播模块包含一个主机列表以控制哪些节点需要ping通)这两部分;\n对所有可以成为master的节点(node.master: true)根据nodeId字典排序,每次选举每个节点都把自己所知道节点排一次序,然后选出第一个(第0位)节点,暂且认为它是master节点。 如果对某个节点的投票数达到一定的值(可以成为master节点数n/2+1)并且该节点自己也选举自己,那这个节点就是master。否则重新选举一直到满足上述条件。 补充:master节点的职责主要包括集群、节点和索引的管理,不负责文档级别的管理;data节点可以关闭http功能。 3.Elasticsearch中的节点(比如共20个),其中的10个选了一个master,另外10个选了另一个master,怎么办? 当集群master候选数量不小于3个时,可以通过设置最少投票通过数量(discovery.zen.minimum_master_nodes)超过所有候选节点一半以上来解决脑裂问题; 当候选数量为两个时,只能修改为唯一的一个master候选,其他作为data节点,避免脑裂问题。\n4.详细描述一下Elasticsearch索引文档的过程。 协调节点默认使用文档ID参与计算(也支持通过routing),以便为路由提供合适的分片。 shard = hash(document_id) % (num_of_primary_shards) 当分片所在的节点接收到来自协调节点的请求后,会将请求写入到Memory Buffer,然后定时(默认是每隔1秒)写入到Filesystem Cache,这个从Momery Buffer到Filesystem Cache的过程就叫做refresh; 当然在某些情况下,存在Momery Buffer和Filesystem Cache的数据可能会丢失,ES是通过translog的机制来保证数据的可靠性的。其实现机制是接收到请求后,同时也会写入到translog中,当Filesystem cache中的数据写入到磁盘中时,才会清除掉,这个过程叫做flush; 在flush过程中,内存中的缓冲将被清除,内容被写入一个新段,段的fsync将创建一个新的提交点,并将内容刷新到磁盘,旧的translog将被删除并开始一个新的translog。 flush触发的时机是定时触发(默认30分钟)或者translog变得太大(默认为512M)时;\n5.详细描述一下Elasticsearch更新和删除文档的过程 删除和更新也都是写操作,但是Elasticsearch中的文档是不可变的,因此不能被删除或者改动以展示其变更; 磁盘上的每个段都有一个相应的.del文件。当删除请求发送后,文档并没有真的被删除,而是在.del文件中被标记为删除。该文档依然能匹配查询,但是会在结果中被过滤掉。当段合并时,在.del文件中被标记为删除的文档将不会被写入新段。 在新的文档被创建时,Elasticsearch会为该文档指定一个版本号,当执行更新时,旧版本的文档在.del文件中被标记为删除,新版本的文档被索引到一个新段。旧版本的文档依然能匹配查询,但是会在结果中被过滤掉。\n6.详细描述一下Elasticsearch搜索的过程 搜索被执行成一个两阶段过程,我们称之为 Query Then Fetch; 在初始查询阶段时,查询会广播到索引中每一个分片拷贝(主分片或者副本分片)。 每个分片在本地执行搜索并构建一个匹配文档的大小为 from + size 的优先队列。PS:在搜索的时候是会查询Filesystem Cache的,但是有部分数据还在Memory Buffer,所以搜索是近实时的。 每个分片返回各自优先队列中 所有文档的 ID 和排序值 给协调节点,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。 接下来就是 取回阶段,协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。每个分片加载并 丰富 文档,如果有需要的话,接着返回文档给协调节点。一旦所有的文档都被取回了,协调节点返回结果给客户端。 补充:Query Then Fetch的搜索类型在文档相关性打分的时候参考的是本分片的数据,这样在文档数量较少的时候可能不够准确,DFS Query Then Fetch增加了一个预查询的处理,询问Term和Document frequency,这个评分更准确,但是性能会变差。\n9.Elasticsearch对于大数据量(上亿量级)的聚合如何实现? Elasticsearch 提供的首个近似聚合是cardinality 度量。它提供一个字段的基数,即该字段的distinct或者unique值的数目。它是基于HLL算法的。HLL 会先对我们的输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。其特点是:可配置的精度,用来控制内存的使用(更精确 = 更多内存);小的数据集精度是非常高的;我们可以通过配置参数,来设置去重需要的固定内存使用量。无论数千还是数十亿的唯一值,内存使用量只与你配置的精确度相关 .\n10.在并发情况下,Elasticsearch如果保证读写一致? 可以通过版本号使用乐观并发控制,以确保新版本不会被旧版本覆盖,由应用层来处理具体的冲突; 另外对于写操作,一致性级别支持quorum/one/all,默认为quorum,即只有当大多数分片可用时才允许写操作。但即使大多数可用,也可能存在因为网络等原因导致写入副本失败,这样该副本被认为故障,分片将会在一个不同的节点上重建。 对于读操作,可以设置replication为sync(默认),这使得操作在主分片和副本分片都完成后才会返回;如果设置replication为async时,也可以通过设置搜索请求参数_preference为primary来查询主分片,确保文档是最新版本。\n14.ElasticSearch中的集群、节点、索引、文档、类型是什么? 群集是一个或多个节点(服务器)的集合,它们共同保存您的整个数据,并提供跨所有节点的联合索引和搜索功能。群集由唯一名称标识,默认情况下为“elasticsearch”。此名称很重要,因为如果节点设置为按名称加入群集,则该节点只能是群集的一部分。 节点是属于集群一部分的单个服务器。它存储数据并参与群集索引和搜索功能。 索引就像关系数据库中的“数据库”。它有一个定义多种类型的映射。索引是逻辑名称空间,映射到一个或多个主分片,并且可以有零个或多个副本分片。 MySQL =\u0026gt;数据库 ElasticSearch =\u0026gt;索引 文档类似于关系数据库中的一行。不同之处在于索引中的每个文档可以具有不同的结构(字段),但是对于通用字段应该具有相同的数据类型。 MySQL =\u0026gt; Databases =\u0026gt; Tables =\u0026gt; Columns / Rows ElasticSearch =\u0026gt; Indices =\u0026gt; Types =\u0026gt;具有属性的文档 类型是索引的逻辑类别/分区,其语义完全取决于用户。\n15.ElasticSearch中的分片是什么? 在大多数环境中,每个节点都在单独的盒子或虚拟机上运行。\n索引 - 在Elasticsearch中,索引是文档的集合。 分片 -因为Elasticsearch是一个分布式搜索引擎,所以索引通常被分割成分布在多个节点上的被称为分片的元素。\n问题四:\nElasticSearch中的集群、节点、索引、文档、类型是什么?\n群集是一个或多个节点(服务器)的集合,它们共同保存您的整个数据,并提供跨所有节点的联合索引和搜索功能。群集由唯一名称标识,默认情况下为“elasticsearch”。此名称很重要,因为如果节点设置为按名称加入群集,则该节点只能是群集的一部分。 节点是属于集群一部分的单个服务器。它存储数据并参与群集索引和搜索功能。 索引就像关系数据库中的“数据库”。它有一个定义多种类型的映射。索引是逻辑名称空间,映射到一个或多个主分片,并且可以有零个或多个副本分片。 MySQL =\u0026gt;数据库 ElasticSearch =\u0026gt;索引 文档类似于关系数据库中的一行。不同之处在于索引中的每个文档可以具有不同的结构(字段),但是对于通用字段应该具有相同的数据类型。 MySQL =\u0026gt; Databases =\u0026gt; Tables =\u0026gt; Columns / Rows ElasticSearch =\u0026gt; Indices =\u0026gt; Types =\u0026gt;具有属性的文档 类型是索引的逻辑类别/分区,其语义完全取决于用户。 问题五:\nElasticSearch是否有架构?\nElasticSearch可以有一个架构。架构是描述文档类型以及如何处理文档的不同字段的一个或多个字段的描述。Elasticsearch中的架构是一种映射,它描述了JSON文档中的字段及其数据类型,以及它们应该如何在Lucene索引中进行索引。因此,在Elasticsearch术语中,我们通常将此模式称为“映射”。\nElasticsearch具有架构灵活的能力,这意味着可以在不明确提供架构的情况下索引文档。如果未指定映射,则默认情况下,Elasticsearch会在索引期间检测文档中的新字段时动态生成一个映射。\n问题六:\nElasticSearch中的分片是什么?\n在大多数环境中,每个节点都在单独的盒子或虚拟机上运行。\n索引 - 在Elasticsearch中,索引是文档的集合。 分片 -因为Elasticsearch是一个分布式搜索引擎,所以索引通常被分割成分布在多个节点上的被称为分片的元素。 问题七:\nElasticSearch中的副本是什么?\n一个索引被分解成碎片以便于分发和扩展。副本是分片的副本。一个节点是一个属于一个集群的ElasticSearch的运行实例。一个集群由一个或多个共享相同集群名称的节点组成。\n问题八:\nElasticSearch中的分析器是什么?\n在ElasticSearch中索引数据时,数据由为索引定义的Analyzer在内部进行转换。 分析器由一个Tokenizer和零个或多个TokenFilter组成。编译器可以在一个或多个CharFilter之前。分析模块允许您在逻辑名称下注册分析器,然后可以在映射定义或某些API中引用它们。\nElasticsearch附带了许多可以随时使用的预建分析器。或者,您可以组合内置的字符过滤器,编译器和过滤器器来创建自定义分析器。\n问题九:\n什么是ElasticSearch中的编译器?\n编译器用于将字符串分解为术语或标记流。一个简单的编译器可能会将字符串拆分为任何遇到空格或标点的地方。Elasticsearch有许多内置标记器,可用于构建自定义分析器。\n问题十一:\n启用属性,索引和存储的用途是什么?\nenabled属性适用于各类ElasticSearch特定/创建领域,如index和size。用户提供的字段没有“已启用”属性。 存储意味着数据由Lucene存储,如果询问,将返回这些数据。\n存储字段不一定是可搜索的。默认情况下,字段不存储,但源文件是完整的。因为您希望使用默认值(这是有意义的),所以不要设置store属性 该指数属性用于搜索。\n索引属性只能用于搜索。只有索引域可以进行搜索。差异的原因是在分析期间对索引字段进行了转换,因此如果需要的话,您不能检索原始数据。\n(网络搜集-博客园)\n第二部分面试题 es 写入数据的工作原理是什么啊?es 查询数据的工作原理是什么啊?底层的 lucene 介绍一下呗?倒排索引了解吗?\n面试官心理分析 问这个,其实面试官就是要看看你了解不了解 es 的一些基本原理,因为用 es 无非就是写入数据,搜索数据。你要是不明白你发起一个写入和搜索请求的时候,es 在干什么,那你真的是\u0026hellip;\u0026hellip;对 es 基本就是个黑盒,你还能干啥?你唯一能干的就是用 es 的 api 读写数据了。要是出点什么问题,你啥都不知道,那还能指望你什么呢?\nes 写数据过程 客户端选择一个 node 发送请求过去,这个 node 就是 coordinating node(协调节点)。 coordinating node 对 document 进行路由,将请求转发给对应的 node(有 primary shard)。[路由的算法是?] 实际的 node 上的 primary shard 处理请求,然后将数据同步到 replica node。 coordinating node 如果发现 primary node 和所有 replica node 都搞定之后,就返回响应结果给客户端。 es 读数据过程 可以通过 doc id 来查询,会根据 doc id 进行 hash,判断出来当时把 doc id 分配到了哪个 shard 上面去,从那个 shard 去查询。\n客户端发送请求到任意一个 node,成为 coordinate node。 coordinate node 对 doc id 进行哈希路由,将请求转发到对应的 node,此时会使用 round-robin随机轮询算法,在 primary shard 以及其所有 replica 中随机选择一个,让读请求负载均衡。 接收请求的 node 返回 document 给 coordinate node。 coordinate node 返回 document 给客户端。 写请求是写入 primary shard,然后同步给所有的 replica shard;读请求可以从 primary shard 或 replica shard 读取,采用的是随机轮询算法。\nes 搜索数据过程[是指search?search和普通docid get的背后逻辑不一样?] es 最强大的是做全文检索,就是比如你有三条数据:\njava真好玩儿啊 java好难学啊 j2ee特别牛 你根据 java 关键词来搜索,将包含 java的 document 给搜索出来。es 就会给你返回:java真好玩儿啊,java好难学啊。\n客户端发送请求到一个 coordinate node。 协调节点将搜索请求转发到所有的 shard 对应的 primary shard 或 replica shard,都可以。 query phase:每个 shard 将自己的搜索结果(其实就是一些 doc id)返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果。 fetch phase:接着由协调节点根据 doc id 去各个节点上拉取实际的 document 数据,最终返回给客户端。 写数据底层原理 1)document先写入导内存buffer中,同时写translog日志\n2))https://www.elastic.co/guide/cn/elasticsearch/guide/current/near-real-time.html\nrefresh操作所以近实时搜索:写入和打开一个新段(一个追加的倒排索引)的轻量的过程叫做 *refresh* 。每隔一秒钟把buffer中的数据创建一个新的segment,这里新段会被先写入到文件系统缓存\u0026ndash;这一步代价会比较低,稍后再被刷新到磁盘\u0026ndash;这一步代价比较高。不过只要文件已经在缓存中, 就可以像其它文件一样被打开和读取了,内存buffer被清空。此时,新segment 中的文件就可以被搜索了,这就意味着document从被写入到可以被搜索需要一秒种,如果要更改这个属性,可以执行以下操作\nPUT /my_index { \u0026ldquo;settings\u0026rdquo;: { \u0026ldquo;refresh_interval\u0026rdquo;: \u0026ldquo;30s\u0026rdquo; } } 3)https://www.elastic.co/guide/cn/elasticsearch/guide/current/translog.html\nflush操作导致持久化变更:执行一个提交并且截断 translog 的行为在 Elasticsearch 被称作一次 *flush。*刷新(refresh)完成后, 缓存被清空但是事务日志不会。translog日志也会越来越多,当translog日志大小大于一个阀值时候或30分钟,会出发flush操作。\n所有在内存缓冲区的文档都被写入一个新的段。 缓冲区被清空。 一个提交点被写入硬盘。(表明有哪些segment commit了) 文件系统缓存通过 fsync 到磁盘。 老的 translog 被删除。 分片每30分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。也可以用_flush命令手动执行。\ntranslog每隔5秒会被写入磁盘(所以如果这5s,数据在cache而且log没持久化会丢失)。在一次增删改操作之后translog只有在replica和primary shard都成功才会成功,如果要提高操作速度,可以设置成异步的\nPUT /my_index { \u0026ldquo;settings\u0026rdquo;: { \u0026ldquo;index.translog.durability\u0026rdquo;: \u0026ldquo;async\u0026rdquo; ,\n\u0026ldquo;index.translog.sync_interval\u0026rdquo;:\u0026ldquo;5s\u0026rdquo; } }\n所以总结是有三个批次操作,一秒做一次refresh保证近实时搜索,5秒做一次translog持久化保证数据未持久化前留底,30分钟做一次数据持久化。\n2.基于translog和commit point的数据恢复\n在磁盘上会有一个上次持久化的commit point,translog上有一个commit point,根据这两个commit point,会把translog中的变更记录进行回放,重新执行之前的操作\n3.不变形下的删除和更新原理\nhttps://www.elastic.co/guide/cn/elasticsearch/guide/current/dynamic-indices.html#deletes-and-updates\n一个文档被 “删除” 时,它实际上只是在 .del 文件中被 标记 删除。一个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。\n文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。\n段合并的时候会将那些旧的已删除文档 从文件系统中清除。 被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。\n4.merge操作,段合并\nhttps://www.elastic.co/guide/cn/elasticsearch/guide/current/merge-process.html\n由于每秒会把buffer刷到segment中,所以segment会很多,为了防止这种情况出现,es内部会不断把一些相似大小的segment合并,并且物理删除del的segment。\n当然也可以手动执行\nPOST /my_index/_optimize?max_num_segments=1,尽量不要手动执行,让它自动默认执行就可以了\n5.当你正在建立一个大的新索引时(相当于直接全部写入buffer,先不refresh,写完再refresh),可以先关闭自动刷新,待开始使用该索引时,再把它们调回来:\n1 2 3 4 5 PUT /my_logs/_settings { \u0026#34;refresh_interval\u0026#34;: -1 } PUT /my_logs/_settings { \u0026#34;refresh_interval\u0026#34;: \u0026#34;1s\u0026#34; } 底层 lucene 简单来说,lucene 就是一个 jar 包,里面包含了封装好的各种建立倒排索引的算法代码。我们用 Java 开发的时候,引入 lucene jar,然后基于 lucene 的 api 去开发就可以了。\n通过 lucene,我们可以将已有的数据建立索引,lucene 会在本地磁盘上面,给我们组织索引的数据结构。\n倒排索引 在搜索引擎中,每个文档都有一个对应的文档 ID,文档内容被表示为一系列关键词的集合。例如,文档 1 经过分词,提取了 20 个关键词,每个关键词都会记录它在文档中出现的次数和出现位置。\n那么,倒排索引就是关键词到文档 ID 的映射,每个关键词都对应着一系列的文件,这些文件中都出现了关键词。\n举个栗子。\n有以下文档:\n对文档进行分词之后,得到以下倒排索引。\n另外,实用的倒排索引还可以记录更多的信息,比如文档频率信息,表示在文档集合中有多少个文档包含某个单词。\n那么,有了倒排索引,搜索引擎可以很方便地响应用户的查询。比如用户输入查询 Facebook,搜索系统查找倒排索引,从中读出包含这个单词的文档,这些文档就是提供给用户的搜索结果。\n要注意倒排索引的两个重要细节:\n倒排索引中的所有词项对应一个或多个文档 倒排索引中的词项根据字典顺序升序排列 上面只是一个简单的例子,并没有严格按照字典顺序升序排列。\n参考:\nhttps://zhuanlan.zhihu.com/p/139762008 https://zhuanlan.zhihu.com/p/102500311 ","permalink":"https://reid00.github.io/en/posts/storage/es%E9%9D%A2%E8%AF%95%E9%A2%98/","summary":"ElasticSearch面试题 1.为什么要使用Elasticsearch? 因为在我们商城中的数据,将来会非常多,所以采用以往的模糊查询,模","title":"ES面试题"},{"content":"一、DockerHub 官网链接 https://hub.docker.com/\n二、Dockerfile 关键字 注意: dockerfile 的关键字必须都是大写才能使用\nFROM\n指定基础镜像,当前新镜像是基于哪个镜像的。其中,scratch是个空镜像,这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像,当前镜像没有依赖于其他镜像\n1 FROM scratch MAINTAINTER\n镜像维护者的姓名和邮箱地址\n1 MAINTAINER Sixah \u0026lt;sixah@163.com\u0026gt; RUN\n容器构建时需要运行的命令\n1 RUN echo \u0026#39;Hello, Docker!\u0026#39; EXPOSE\n当前容器对外暴露出的端口\n1 EXPOSE 8080 注意:\n-p 和 expose 区别\n-p 80:8080\n外部80 端口转向 向外暴露是 8080 端口的 Docker 容器。如果只写 -p 80 ,那么当作是 -p 80:80。也就是说,容器之间可以访问该 暴露8080端口的容器,其他用户也可以访问\nexposes 80\n​ 表示 容器之间可以访问该 暴露80端口的容器,但是其他用户不可以可以访问。这样其实就是做到了 封闭。\nWORKDIR\n指定在创建容器后,终端默认登陆进来的工作目录,一个落脚点\n1 WORKDIR /home/ ENV\n用来在构建镜像过程中设置环境变量\n1 ENV MY_PATH /usr/mytest 这个环境变量可以在后续的任何RUN指令中使用,这就如同在命令前面指定了环境变量前缀一样;当然,也可以在其他指令中直接使用这些环境变量,比如:WORKDIR $MY_PATH\nADD\n将宿主机目录下的文件拷贝进镜像且ADD命令会自动处理URL和解压tar压缩包\n1 ADD Linux_amd64.tar.gz COPY\n类似于ADD,拷贝文件和目录到镜像中,将从构建上下文目录中\u0026lt;源路径\u0026gt;的文件/目录复制到新的一层镜像内的\u0026lt;目标路径\u0026gt;位置\nCOPY 能实现的ADD 都可以实现,ADD 可以处理URL, 还可以自动解压,COPY不可以\n1 COPY . /go/src/app VOLUME\n容器数据卷,用于数据保存和持久化工作\n1 VOLUME /data CMD\n指定一个容器启动时要运行的命令。Dockerfile中可以有多个CMD指令,但只有最后一个生效,CMD会被docker run之后的参数替换\n1 CMD [\u0026#34;/bin/bash\u0026#34;] 注意:\n1 CMD -i 将代替 CMD [\u0026#34;/bin/bash\u0026#34;] 而CMD -i 无意义 而ENTRYPOINT ,可以在后面追加参数\n如果dockerfile 最后是\nENTRYPOINT curl [\u0026ldquo;s\u0026rdquo;,\u0026ldquo;baidu.com\u0026rdquo;]\n1 DOCKER run centos -i 意味着 ENTRYPOINT curl [\u0026#34;s\u0026#34;,\u0026#34;-i\u0026#34;,\u0026#34;baidu.com\u0026#34;] ENTRYPOINT\n指定一个容器启动是要运行的命令。ENTRYPOINT的目的和CMD一样,都是在指定容器启动程序及参数 ONBUILD\n当构建一个被继承的Dockerfile时运行的命令,父镜像在被子镜像继承后,父镜像的ONBUILD指令被触发 三、 给基础的CentOS 添加基础功能 编写dockerfile 1 2 3 4 5 6 7 8 9 10 11 12 13 FROM CENTOS MAINTAINER zzz zzz@163.com ENV MYPATH /usr/local WORKDIR $MYPATH RUN yum -y install vim RUN yum -y install net-tools EXPOSE 80 CMD echo $MYPATH CMD echo \u0026#34;success -----ok\u0026#34; CMD /bin/bash 构建 build 注意: 最后面有个path 此处用的. 代表当前路径 1 docker build -f dockerfile路径 -t mycentos:v1.3 . Push 1 2 docker push registry仓库中/name:version docker push harbor.ld-hadoop.com/nebula/supply:v7 如果docker push 出现Auth 相关的错误,安装下面方式解决:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 ➜ contact_radar_space_incre git:(master) ✗ docker push harbor.ld-hadoop.com/nebula/backup_radar_incre:v1 The push refers to a repository [harbor.ld-hadoop.com/nebula/backup_radar_incre] 770f8dde0bf3: Preparing de824f01aabe: Preparing e68ba2bf9675: Preparing aa4c808c19f6: Preparing 8ba9f690e8ba: Preparing 3e607d59ef9f: Waiting 1e18e7e1fcc2: Waiting c3a0d593ed24: Waiting 26a504e63be4: Waiting 8bf42db0de72: Waiting 31892cc314cb: Waiting 11936051f93b: Waiting unauthorized: unauthorized to access repository: nebula/backup_radar_incre, action: push: unauthorized to access repository: nebula/backup_radar_incre, action: push ➜ contact_radar_space_incre git:(master) ✗ mkdir /root/.docker ➜ contact_radar_space_incre git:(master) ✗ vim /root/.docker/config.json # 添加下面的认真json # { # \u0026#34;auths\u0026#34;: { # \u0026#34;harbor.ld-hadoop.com\u0026#34;: { # \u0026#34;auth\u0026#34;: \u0026#34;bGVvbjpraWxsanVoZQ==\u0026#34; # } # } # } ➜ contact_radar_space_incre git:(master) ✗ docker push harbor.ld-hadoop.com/nebula/backup_radar_incre:v1 The push refers to a repository [harbor.ld-hadoop.com/nebula/backup_radar_incre] 770f8dde0bf3: Pushed de824f01aabe: Pushed e68ba2bf9675: Pushed aa4c808c19f6: Pushed 8ba9f690e8ba: Pushed 3e607d59ef9f: Pushed 1e18e7e1fcc2: Pushed c3a0d593ed24: Pushed 26a504e63be4: Pushed 8bf42db0de72: Pushed 31892cc314cb: Pushed 11936051f93b: Pushed v1: digest: sha256:2cb5bf1b68e635556f27a4c2371f513c41fe0d89de06d9898fb0e47cef036cc4 size: 2846 运行\n1 docker run -it 新镜像名:TAG 列出镜像的变更历史\n1 docker history 镜像名 ","permalink":"https://reid00.github.io/en/posts/langs_linux/dockerfile%E6%A1%88%E4%BE%8B/","summary":"一、DockerHub 官网链接 https://hub.docker.com/ 二、Dockerfile 关键字 注意: dockerfile 的关键字必须都是大写才能使用 FROM 指定基础镜像,当前新镜像是基于哪个镜像的","title":"Dockerfile案例"},{"content":"安装 Windows 此处下载 双击exe 一直下一步安装\nLinux Linux 默认安装的Git 版本一般为1.8*, 可以通过以下方式升级\n首先,把老版本的 Git 卸掉。 1 2 sudo yum -y remove git sudo yum -y remove git-* 添加 End Point 到 CentOS 7 仓库 yum -y install https://packages.endpointdev.com/rhel/7/os/x86_64/endpoint-repo.x86_64.rpm yum -y install git check version git version 配置Git Set your name. git config --global user.name \u0026quot;Your Name\u0026quot; Set your email address. git config --global user.email \u0026quot;user@exmample.com\u0026quot; Verify the settings. git config --list Git 配置SSH key 连接Github HTTPS URL 和 SSH URL 在使用 git clone 项目时,可以使用仓库的 HTTPS URL 也可以 使用 SSH URL HTTPS URL,例如:https://github.com/\u0026lt;username\u0026gt;/\u0026lt;repo name\u0026gt;.git SSH URL,例如:git@github.com:\u0026lt;username\u0026gt;/\u0026lt;repo name\u0026gt;.git\n这两种方式的主要区别在于:使用 HTTPS URL 克隆时,每次 fetch 和 push 代码都需要输入账号和密码(可以通过下面缓存的方式避免),而使用 SSH URL 在配置好 SSH Key 后,每次 fetch 和 push 代码都不需要输入账号和密码。\n初次运行 Git 前的配置 Git 环境变量 Git 提供了一个叫做 git config 的工具,专门用来配置或读取相应的工作环境变量。而正是由这些环境变量,决定了 Git 在各个环节的具体工作方式和行为。这些变量可以存放在以下三个不同的地方:\n/etc/gitconfig 文件:系统中对所有用户都普遍适用的配置。若使用 git config 时用 \u0026ndash;system 选项,读写的就是这个文件。 ~/.gitconfig 文件:用户目录下的配置文件只适用于该用户。若使用 git config 时用 \u0026ndash;global 选项,读写的就是这个文件。 当前项目的 Git 目录中的配置文件(也就是工作目录中的 .git/config 文件):这里的配置仅仅针对当前项目有效。每一个级别的配置都会覆盖上层的相同配置,所以 .git/config 里的配置会覆盖 /etc/gitconfig 中的同名变量 配置用户信息 初次运行 Git 前需要配置用户信息,一个是你个人的用户名称,一个是你的电子邮件地址。这两条配置很重要,每次 Git 提交时都会引用这两条信息,说明是谁提交了更新,所以会随更新内容一起被永久纳入历史记录:\n1 2 $ git config --global user.name \u0026#34;Reid\u0026#34; $ git config --global user.email reid@example.com 如果用了 \u0026ndash;global 选项,那么更改的配置文件就是位于你用户主目录下的 ~/.gitconfig 文件,以后你所有的项目都会默认使用这里配置的用户信息。如果要在某个特定的项目中使用其他名字或者邮箱,只要去掉 \u0026ndash;global 选项重新配置即可,新的设定保存在当前项目的 .git/config 文件里。\n查看配置信息 要检查已有的配置信息,可以使用 git config \u0026ndash;list 命令:\n1 2 3 4 5 6 7 8 (base) [root@zhangbl-c7 .git]# git config --list user.name=Reid user.email=reid@example.com core.repositoryformatversion=0 core.filemode=true core.bare=false core.logallrefupdates=true ... 有时候会看到重复的变量名,那就说明它们来自不同的配置文件(比如 /etc/gitconfig 和 ~/.gitconfig ),不过最终 Git 实际采用的是最后一个。\n也可以直接查阅某个环境变量的设定,只要把特定的环境变量名称跟在后面即可,例如: git config user.name\n检查是否已有 SSH Key 看是否存在 id_rsa 和 id_rsa.pub 文件(或者是其它文件名),如果存在说明已有 ssh key,可以直接跳过生成密钥,其中 id_rsa 为私钥,id_rsa.pub 为公钥。 ls ~/.ssh/\n生成 SSH key ssh-keygen -t rsa -C \u0026quot;reid@example.com\u0026quot;\n-t : 指定密钥类型,默认是rsa,可以省略\n-C: 设置注释文字,比如邮箱\n-f: 指定密钥文件存储文件名\n以上代码省略了 -f 参数,因此在运行上面那条命令后会让你输入一个文件名,用于保存刚才生成的 SSH key,例如:\n1 2 3 $ ssh-keygen -t rsa -C \u0026#34;reid@example.com\u0026#34; Generating public/private rsa key pair. Enter file in which to save the key (~/.ssh/id_rsa): 当然,你也可以不输入文件名,直接回车使用默认文件名(推荐),那么就会生成 id_rsa 和 id_rsa.pub 两个密钥文件。后续的一些配置也可以使用默认参数。\n添加SSH到Github 登录 github,点击头像,点击 Settings 进入设置页面。\n然后点击菜单栏的 SSH and GPG keys 进入页面添加 SSH Key。 点击 New SSH Key 按钮后进行 Key 的填写,其中 Title 随意, Key 为刚刚生成的公钥,公钥在文件 id_rsa.pub 文件中,直接 copy 文件中的内容粘贴即可。\n测试 SSH key 在终端 输入 ssh -T git@github.com, 出现\n1 2 3 The authenticity of host \u0026#39;github.com (207.97.227.239)\u0026#39; can\u0026#39;t be established. # RSA key fingerprint is 16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48. # Are you sure you want to continue connecting (yes/no)? 这是正常的,直接输入 yes 回车既可。如果你创建 SSH key 的时候设置了密码,接下来就会提示你输入密码,如:Enter passphrase for key '~/.ssh/id_rsa': 成功显示: Hi username! You've successfully authenticated, but GitHub does not provide shell access.\n如果用户名是正确的,你已经成功设置 SSH 密钥。如果你看到 \u0026ldquo;access denied\u0026rdquo; ,者表示拒绝访问,那么你就需要使用 HTTPS 去访问,而不是 SSH 。\nGit 多用户配置(个人和公司) 公司和github,经常会遇到要多用户使用git的情况,以下为配置信息,以下拿 zhangs \u0026amp; zhangs2 举例\n设置ssh-key ssh-keygen -t rsa -C \u0026quot;zhangs@mail.com\u0026quot;\n会提示存储的文件名,输入, 第二份ssh-key 生成是一定一定要输入 防止覆盖 如果需要push时确认的密码,可在该步骤输入 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 (base) [root@-c7 go-project]# ssh-keygen -t rsa -C \u0026#34;zhangs@mail.com\u0026#34; Generating public/private rsa key pair. Enter file in which to save the key (/root/.ssh/id_rsa): /root/.ssh/zhangs_id_rsa # 注意此处重命名 防止覆盖 Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /root/.ssh/zhangs_id_rsa. Your public key has been saved in /root/.ssh/zhangs_id_rsa.pub. The key fingerprint is: SHA256:t/XljH8zYCLFcDgTxzrAjUQ7VwSobCPobUhB6ImBcvA zhangs@mail.com The key\u0026#39;s randomart image is: +---[RSA 2048]----+ |=o +o+o*+ | |=o. =.*oo | |+oE . .o..B | |.= . = oo o | |o o o . S + . .| | o o o + + = | | . o o + o| | +.| | =| +----[SHA256]-----+ 上传到GitHub或者GitLab参考上面测试添加SSH-Key到Github SSH 配置文件 使用 ~/.ssh/config 作为我们的配置文件,如果文件不存在,我们就创建它。 vim ~/.ssh/config\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 # github email address Host github # 别名,用于区分多个 git 账号,可随意 HostName github.com # 要连接的服务器的主机名, 可以为IP 也可以为域名 User zhangs PreferredAuthentications publickey IdentityFile ~/.ssh/zhangs_id_rsa # ssh 连接使用的私钥 # gitlab email address # # 公司内网地址 Host gitlab HostName gitlab.xx.com User zhangs2 PreferredAuthentications publickey IdentityFile ~/.ssh/zhangs2_id_rsa 配置文件中的 HostName 是远程仓库的访问地址,这里可以是 IP,也可以是域名。Host 是用来拉取的仓库的别名,配不配置都行。如果 HostName 没配置的话,那就必须把 Host 配置为仓库 IP 地址或者域名,而非别名。\n其实这里的User并不会有我们预期的效果,比如你在公司的gitlab用户名一般会取实名的名字,而github是一个随意的昵称。这里并不会让你以后推送代码到gitlab时取 你在这里配置的 gitlab用户名,同样也不会推送到github时取你在这里配置的 github用户名。因为这个其实只是针对ssh key的配置的User,并不会影响你之前通过 git config --global user.name \u0026ldquo;公司gitlab用户名\u0026rdquo; 设置的git账户名\n可以分别测试一下了你的ssh 是否能连通了\n1 2 3 4 5 6 7 8 9 10 11 ssh -T git@gitlab # @后面可以是Host 名称 也可以是HostName ssh -T git@github.com ssh -T git@github Hi Reid00! You\u0026#39;ve successfully authenticated, but GitHub does not provide shell access. ssh -T git@github.com Hi Reid00! You\u0026#39;ve successfully authenticated, but GitHub does not provide shell access. 比如把github.com 的Host 设置成`Personal` 也成功 ssh -T git@personal Hi Reid00! You\u0026#39;ve successfully authenticated, but GitHub does not provide shell access. 设置user.name 前面提到 ~/.ssh/config 文件中的User 并不等同于我们的git账户名。 有可能你之前设置过 git config --global user.name \u0026quot;公司gitlab实名\u0026quot; 然后你发现你传代码到github的时候,也是显示的这个实名,让你觉得有点不爽。\n你可以继续到你本地的github仓库项目文件夹下去设置一个本地的用户名 git config --local user.name \u0026quot;github用户名\u0026quot; 再推送,就可以显示对应的用户名了。 邮箱同样git config --local user.email \u0026quot;github 邮箱\u0026quot;\n这里什么时候用global 什么时候用local 其实取决于你自己用哪个账户用得多一点,比如你在公司的电脑上,你就可以把公司的gitlab用户名加 \u0026ndash;global 配置,而自己个人的github加 \u0026ndash;local。如果你是在你自己家里的电脑上,就可以是相反的操作了。\n问题 由于公司Gitlab 不是走标准的21端口 而是走18888 端口,导致用上述方式配置好之后,在测试SSH Key 的时候无效。 1 2 ➜ .ssh ssh -T git@gitlab ssh: Could not resolve hostname gitlab.****.com:18888: Name or service not known 此时需要联系运维做域名映射,如下把 gitlab.xx.com:18888 映射到21 端口,比如proxygitlab.xx.com, 把~/.ssh/config下面的HostName 改为映射后的即可。\n1 2 3 4 5 Host gitlab HostName proxygitlab.xx.com User zhangs2 PreferredAuthentications publickey IdentityFile ~/.ssh/zhangs2_id_rsa 1 2 3 4 5 ➜ .ssh ssh -T git@gitlab The authenticity of host \u0026#39;proxygitlab.xx.com (172.16.51.198)\u0026#39; can\u0026#39;t be established. ECDSA key fingerprint is SHA256:AWmWq+kB2GUSTuPORU4hvRHTc1vhaFwvdNVBNXTMxbk. ECDSA key fingerprint is MD5:cb:b3:95:61:20:05:aa:8e:51:61:68:c0:14:bb:f2:e7. Are you sure you want to continue connecting (yes/no)? yes 用https方式 git clone 项目代码时用户名和密码的问题 换了新的开发机,通过上述方式配置好之后,clone github 上项目没有问题,可以clone 公司的项目时,发现由于ssh url 不能用(端口不标准),使用https url 的时候 一直需要输入账号密码很烦。\n听同事说要执行一个命令:git credential-manager uninstall,这个命令的作用是 清除掉缓存在git中的用户名和密码。\n在我机器上没有作用,出现了 git: \u0026lsquo;credential-manager\u0026rsquo; is not a git command. See \u0026lsquo;git \u0026ndash;help\u0026rsquo;. 的错误\n清除掉缓存在git中的用户名和密码后,以后每次用 https 方式拉取代码都需要输入用户名和密码。执行下面的命令可以解决这个问题。\n1 2 git config --system --unset credential.helper # 或者 git config --global --unset credential.helper git config --global credential.helper store 第一个命令清除凭证助手,使用 git config \u0026ndash;list 命令这是展示配置属性,只要不存在credential.helper表示清除成功 第二个配置凭证助手(命令将密码明文保存在~/.git-credentials)\nGithub 配置多用户 参考此处\nClone 新的仓库 1 2 3 4 5 6 7 8 9 10 11 12 # github: wylu, email: wylu@gmail.com # the default config Host github.com HostName github.com User git IdentityFile ~/.ssh/id_rsa # github: 15wylu, email: 15wylu@gmail.com Host 15wylu.github.com HostName github.com User git IdentityFile ~/.ssh/15wylu_id_rsa 这里以上面的配置为例,假设要克隆 15wylu 账号的一个项目,原来使用的命令如下: git@github.com:15wylu/15wylu.github.io.git 但是经过配置,我们已经将 15wylu 的 Host 设为了 15wylu.github.com,而不再是原来的 github.com,所以相应地 clone 的命令也变成如下: git@15wylu.github.com:15wylu/15wylu.github.io.git\n已经Clone 下来的仓库 首先使用 git remote -v 列出本地仓库对应的远程库,检查该 URL 是否与要使用的 GitHub 主机匹配,否则更新远程原始 URL, 以 15wylu 账号的仓库为例: git remote set-url origin git@15wylu.github.com:15wylu/15wylu.github.io.git\n对于本地创建新的仓库 在项目文件夹中使用 git init 中初始化目录为一个 Git 仓库。然后在 GitHub 帐户中创建新的仓库,将其作为远程库添加到本地仓库中: 同样以 15wylu 账号为例: git remote add origin git@15wylu.github.com:15wylu/remote_repo_name.git 确保 @ 和 : 之间的字符串与我们在 SSH 配置中指定的主机(Host)匹配。将初始提交推送到 GitHub 仓库。\nGit 常见操作记录 git remote 相关 git remote add \u0026lt;repo name\u0026gt; \u0026lt;repo url\u0026gt; repo name 为远程仓库的本地名称,可自定义,相当于给 url 对应的仓库起了个别名,对于 git clone 的仓库在本地的名称默认为 origin git remote -v 查看上传协议是 SSH/HTTPS git remote 查看远程仓库名称 git remote show origin 查看远程仓库名为origin 的详情 git remote rm \u0026lt;repo name\u0026gt; 删除远程仓库 git remote set-url [--push|--add|--delete] \u0026lt;repo name\u0026gt; \u0026lt;new url\u0026gt; [\u0026lt;old url\u0026gt;] 修改远程仓库, 例如 git remote set-url origin git@github.com:username/reponame.git 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 $ git remote -v origin https://github.com/Reid00/git-test.git (fetch) origin https://github.com/Reid00/git-test.git (push) (base) zhangbl@DESKTOP-8NHU8UF MINGW64 /c/test (main) $ git remote origin (base) zhangbl@DESKTOP-8NHU8UF MINGW64 /c/test (main) $ git remote show origin * remote origin Fetch URL: https://github.com/Reid00/git-test.git Push URL: https://github.com/Reid00/git-test.git HEAD branch: dev Remote branches: dev tracked main tracked Local branch configured for \u0026#39;git pull\u0026#39;: main merges with remote main Local ref configured for \u0026#39;git push\u0026#39;: main pushes to main (up to date) (base) git merge git merge dev 把dev 分支合并到当前所在分支。注意,需要先checkout 到某个分支上在执行。\ngit submodule (子模块) 添加Submodule Git 子模块(Git submodules)允许你将 git repo 保留为另一个 git repo 的子目录。Git 子模块只是在特定时间快照上对另一个 repo 的引用。Git 子模块使 Git repo 能够合并和跟踪外部代码的版本历史。\n命令 git submodule add \u0026lt;repo url\u0026gt; [submodule path] =\u0026gt; git submodule add https://github.com/****/hugo-PaperMod themes/PaperMod\n默认情况下,如果没有指定子模块存放路径,子模块将会放到一个与仓库同名的目录中。如果你想要放到其他地方,那么可以在命令结尾添加一个不同的路径,本例中子模块将会 clone 到 \u0026ldquo;themes/next\u0026rdquo; 目录下。\n命令执行完成后,会在当前工作仓库根目录下生成 .gitmodules 文件,内容如下:\n1 2 3 4 [submodule \u0026#34;themes/PaperMod\u0026#34;] path = themes/PaperMod url = https://github.com/adityatelange/hugo-PaperMod.git ignore = dirty 该文件保存了项目 URL 与已经拉取的本地目录之间的映射,如果有多个子模块,该文件中就会有多条记录。 要重点注意的是,该文件应像 .gitignore 文件一样受到(通过)版本控制,和该项目的其他部分一同被拉取推送。有了映射关系,克隆该项目的人就知道去哪获得子模块了。\n添加子模块完成后,当在父仓库时,Git 仍然不会跟踪 submodule 的文件, 而是将它看作该仓库中的一个特殊提交。\n推送到远程仓库后,远程仓库中 submodule 会和指定的 commit 关联起来。如果需要指定分支,可以在 \u0026ldquo;.gitmodules\u0026rdquo; 文件中加上 branch 配置,如 branch = develop。\n克隆含有submodule的项目 接下来我们将会克隆(clone)一个含有子模块的项目。 当你在克隆这样的项目时,默认会包含该子模块目录,但其中还没有任何文件,你需要执行两个命令以拉取子模块:\n1 2 git submodule init git submodule update git submodule init 用来初始化本地配置文件,而 git submodule update 则从子项目中抓取所有数据并检出父项目中列出的合适的提交。\n或者:\n1 git clone --recursive \u0026lt;parent repo url\u0026gt; 删除子模块 把子模块从版本控制系统中移除 git rm --cached \u0026lt;submodule path\u0026gt; 删除子模块目录 rm -rf \u0026lt;submodule path\u0026gt; 编辑 \u0026ldquo;.gitmodules\u0026rdquo;,移除相应 submodule 节点内容 编辑 \u0026ldquo;.git/config\u0026rdquo;,移除相应 submodule 配置 如果有 \u0026ldquo;.git/modules\u0026rdquo; 目录,还应删除其下的相应子模块的目录 例子:\n1 2 git rm --cached themes/PaperMod rm -rf themes/PaperMod 然后删除 \u0026ldquo;.gitmodules\u0026rdquo; 中如下内容:\n1 2 3 4 [submodule \u0026#34;themes/PaperMod\u0026#34;] path = themes/PaperMod url = https://github.com/adityatelange/hugo-PaperMod.git ignore = dirty 最后删除 \u0026ldquo;.git/config\u0026rdquo; 中如下内容:\n1 2 3 [submodule \u0026#34;themes/next\u0026#34;] url = https://github.com/wylu/hexo-theme-next active = true 要把此次修改同步到远程库,还需要 push 一下。\n","permalink":"https://reid00.github.io/en/posts/other/git-%E5%AE%89%E8%A3%85%E5%92%8C%E5%A4%9A%E7%94%A8%E6%88%B7%E9%85%8D%E7%BD%AE/","summary":"安装 Windows 此处下载 双击exe 一直下一步安装 Linux Linux 默认安装的Git 版本一般为1.8*, 可以通过以下方式升级 首先,把老版本的 Git 卸掉。 1 2 sudo yum -y remove git sudo yum","title":"Git 安装和多用户配置"},{"content":"哈希 哈希(Hash)也称为散列,是把任意长度的输入通过哈希算法变换为固定长度的输出,这个输出值也就是散列值。\n哈希表是根据键值对(key value)而直接进行访问的数据结构,通过将键值对映射到表中一个位置来访问记录,以加快查询速度。映射函数又称为散列函数,存放记录的数组叫做哈希表。\n如果两个输入串的哈希函数的值相同则发生了碰撞(Collision),既然把任意较长字符串转化为固定长度且较短的字符串,因此必有一个输出串对应多个输入串,碰撞是必然存在的。这种碰撞又称为哈希冲突。\n散列函数 哈希算法是一种广义的算法,也可以认为是一种思想,使用哈希算法可提高存储空间的利用率和数据查询效率。\n哈希函数又称为散列函数,采用散列算法。 哈希函数是一种从任何一种数据中创建小的数字“指纹”的方法。 哈希函数将数据打乱混合,重新创建一个叫做散列值的“指纹”。 哈希函数会将消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。 Go 接口 Golang的hash包提供多种散列算法,比如crc32/64, adler32, fnv\u0026hellip;\n1 2 3 4 5 6 7 type Hash interface{ io.Writer //嵌入io.Writer接口,向执行中的hash加入更多数据。 Sum(b []byte) []byte//将当前hash追加到字节数组b后面,不会改变当前hash状态。 Reset()//重置hash到初始化状态 Size() int//返回hash结果返回的字节数 BlockSize() int//返回hash的集成块大小,为提高效率,Write方法传入的字节数最好是block size的倍数。 } MD5 MD5消息摘要算法,是一种被广泛使用的密码散列函数,可以产出一个128位(16子节)的散列值。\nMD5已被证实无法防止碰撞,已经不算是很安全的算法,因此不适用于安全性认证,比如SSL公开密钥认证或数字签名等用途。\n对于需要高度安全性的数据,一般建议改用其他算法,比如SHA256。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 input := \u0026#34;123456\u0026#34; hash := md5.New() //创建散列值 n, err := hash.Write([]byte(input)) //写入待处理字节 if err != nil { fmt.Println(err, n) os.Exit(-1) } //bytes := hash.Sum([]byte(\u0026#34;\u0026#34;)) bytes := hash.Sum(nil) //获取最终散列值的字符切片 fmt.Printf(\u0026#34;%v\\n\u0026#34;, bytes) //[225 10 220 57 73 186 89 171 190 86 224 87 242 15 136 62] fmt.Printf(\u0026#34;%x\\n\u0026#34;, bytes) //以16进制字符串格式化字符切片 //e10adc3949ba59abbe56e057f20f883e MD5和SHA256是非常常用的两种单向散列函数\n1 2 3 4 5 6 7 8 9 10 11 12 import ( \u0026#34;crypto/md5\u0026#34; \u0026#34;encoding/hex\u0026#34; \u0026#34;testing\u0026#34; ) func MD5(input string) string { c := md5.New() c.Write([]byte(input)) bytes := c.Sum(nil) return hex.EncodeToString(bytes) } SHA-1 1 2 3 4 5 6 7 password := \u0026#34;123456\u0026#34; ins := sha1.New() ins.Write([]byte(password)) result := ins.Sum([]byte(\u0026#34;\u0026#34;)) fmt.Printf(\u0026#34;%x\\n\u0026#34;, result) //7c4a8d09ca3762af61e59520943dc26494f8941b 1 2 3 4 5 6 7 8 9 10 11 12 import ( \u0026#34;crypto/sha1\u0026#34; \u0026#34;encoding/hex\u0026#34; \u0026#34;testing\u0026#34; ) func SHA1(input string) string { c := sha1.New() c.Write([]byte(input)) bytes := c.Sum(nil) return hex.EncodeToString(bytes) } CRC32 CRC即Cyclic Redundancy Check循环冗余校验码 CRC是实现32位循环冗余校验或CRC-32校验和 在远距离数据通信中,为确保高效而无差错地传送数据,必须对数据进行校验即差错控制。 CRC(Cyclic Redundancy Check/Code)循环冗余校验是对一个传送数据块进行校验,是一种高效的差错控制方法。 ChecksumIEEE使用IEEE多项式返回数据的CRC-32校验和\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package test import ( \u0026#34;hash/crc32\u0026#34; \u0026#34;testing\u0026#34; ) func CRC32(input string) uint32 { bytes := []byte(input) return crc32.ChecksumIEEE(bytes) } func TestHash(t *testing.T) { input := \u0026#34;123456\u0026#34; t.Log(CRC32(input)) //158520161 } MurMur 我们有时候想将一段内容(比如字符串)转换成一个随机整数值,这里我们使用murmur3 hash算法可以达到这个目的 1)hash算法有可能发生碰撞,即不同的输入转换出的hash值是一样的,好的算法当然发生碰撞的概率会很小。 2)murmur3算法是非加密哈希算法\n加密哈希函数旨在保证安全性,很难找到碰撞。即:给定的散列h很难找到的消息m;很难找到产生相同的哈希值的消息m1和m2。 非加密哈希函数只是试图避免非恶意输入的冲突。作为较弱担保的交换,它们通常更快。如果数据量小,或者不太在意哈希碰撞的频率,甚至可以选择生成哈希值小的哈希算法,占用更小的空间。 示例代码:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;github.com/spaolacci/murmur3\u0026#34; ) func main() { originalStr := \u0026#34;pwww.google.com\u0026#34; // 注意:生成的hash值有三种取值,uint32,uint64,uint128,分别对应方法Sum32,Sum64,Sum128 // 下面例子以Sum64为例 // 1、使用默认种子,生成哈希值 // 默认种子,其实是seed=0 hValue1 := murmur3.Sum64([]byte(originalStr)) fmt.Printf(\u0026#34;hValue1 is %d\\n\u0026#34;, hValue1) // hValue1 is 13092418635223121727 // 默认返回值是uint64, 转化为int64 hValue1 := murmur3.Sum64([]byte(originalStr)) fmt.Printf(\u0026#34;hValue1 is %d\\n\u0026#34;, hValue1) // hValue1 is -5354325438486429889 // 2、使用指定种子,生成哈希值 seed := uint32(0000) hValue2 := murmur3.Sum64WithSeed([]byte(originalStr), seed) fmt.Printf(\u0026#34;hValue2 is %d\\n\u0026#34;, hValue2) // hValue2 is 13092418635223121727 // 3、使用指定种子,生成哈希值,2的另一种写法 h := murmur3.New64WithSeed(seed) h.Write([]byte(originalStr)) hValue3 := h.Sum64() fmt.Printf(\u0026#34;hValue3 is %d\\n\u0026#34;, hValue3) // hValue3 is 13092418635223121727 // 如果使用h继续计算其他值,则需要首先调用Reset,引为write这里是追加写 h.Reset() h.Write([]byte(originalStr)) hValue4 := h.Sum64() fmt.Printf(\u0026#34;hValue4 is %d\\n\u0026#34;, hValue4) // hValue4 is 13092418635223121727 } ","permalink":"https://reid00.github.io/en/posts/langs_linux/golang-murmur3/","summary":"哈希 哈希(Hash)也称为散列,是把任意长度的输入通过哈希算法变换为固定长度的输出,这个输出值也就是散列值。 哈希表是根据键值对(key val","title":"Golang MurMur3"},{"content":"Linux修改主机名修改hostname的方法 临时修改Linux主机名的方法 hostname newname 执行命令后发现没有变化。重新开终端即可显示,你也可以通过uname -n命令来查看当前的主机名。\n永久修改Linux主机名的方法\n使用 hostnamectl 来改变主机名称 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 [root@nebula3-01 ~]# hostnamectl Static hostname: nebula3-01 Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 717058195e934eb88f4631adf25ab163 Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 [root@nebula-test02 ~]# hostnamectl set-hostname nebula3-02 [root@nebula-test02 ~]# hostnamectl Static hostname: nebula3-02 Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 6b836dcf9c274ef48f334e6b53f8e296 Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 [root@nebula-test02 ~]# 退出后,重新登录即可\n通过修改/etc/hostname 文件,本质和上面一样 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 [root@nebula-test03 ~]# hostnamectl Static hostname: nebula-test03.novalocal Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 683f9e34bce149659226bcdfc0dce6ed Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 [root@nebula-test03 ~]# cat /etc/hostname nebula-test03.novalocal [root@nebula-test03 ~]# echo \u0026#34;nebula3-03\u0026#34; \u0026gt; /etc/hostname [root@nebula-test03 ~]# hostnamectl Static hostname: nebula3-03 Transient hostname: nebula-test03.novalocal Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 683f9e34bce149659226bcdfc0dce6ed Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 通过机器名ping 通彼此 修改/etc/hosts 文件,添加 ip 域名 即可。 vim /etc/hosts\n1 2 3 4 172.18.163.124 test-server-01 172.18.163.115 test-server-02 172.18.163.114 test-server-03 172.18.163.85 test-server-04 查看服务器是否为SSD 方法一 判断cat /sys/block/*/queue/rotational 的返回值(其中*为你的硬盘设备名称,例如sda等等),如果返回1则表示磁盘可旋转,那么就是HDD了;反之,如果返回0,则表示磁盘不可以旋转,那么就有可能是SSD了。\n方法二 lsblk -d -o name,rota 命令\n1 2 3 [root@nebula3-04 ~]# lsblk -d -o name,rota NAME ROTA vda 1 划分分区并挂载磁盘 本操作以该场景为例,当云服务器挂载了一块新的数据盘时,使用fdisk分区工具将该数据盘设为主分区,分区形式默认设置为MBR,文件系统设为ext4格式,挂载在“/mnt/sdc”下,并设置开机启动自动挂载。\nfdisk -l 显示信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [root@ecs-test-0001 ~]# fdisk -l Disk /dev/vda: 42.9 GB, 42949672960 bytes, 83886080 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk label type: dos Disk identifier: 0x000bcb4e Device Boot Start End Blocks Id System /dev/vda1 * 2048 83886079 41942016 83 Linux Disk /dev/vdb: 107.4 GB, 107374182400 bytes, 209715200 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes 表示当前的云服务器有两块磁盘,“/dev/vda”是系统盘,“/dev/vdb”是新增数据盘。\n执行以下命令,进入fdisk分区工具,开始对新增数据盘执行分区操作。 fdisk 新增数据盘 以新挂载的数据盘“/dev/vdb”为例: fdisk /dev/vdb 1 2 3 4 5 6 7 8 9 10 [root@ecs-test-0001 ~]# fdisk /dev/vdb Welcome to fdisk (util-linux 2.23.2). Changes will remain in memory only, until you decide to write them. Be careful before using the write command. Device does not contain a recognized partition table Building a new DOS disklabel with disk identifier 0x38717fc1. Command (m for help): 输入“n”,按“Enter”,开始新建分区。 1 2 3 4 Command (m for help): n Partition type: p primary (0 primary, 0 extended, 4 free) e extended 表示磁盘有两种分区类型:\n“p”表示主分区。 “e”表示扩展分区。 磁盘使用MBR分区形式,最多可以创建4个主分区,或者3个主分区加1个扩展分区,扩展分区不可以直接使用,需要划分成若干个逻辑分区才可以使用。 磁盘使用GPT分区形式时,没有主分区、扩展分区以及逻辑分区之分。\n以创建一个主要分区为例,输入“p”,按“Enter”,开始创建一个主分区。 1 2 Select (default p): p Partition number (1-4, default 1): “Partition number”表示主分区编号,可以选择1-4。\n以分区编号选择“1”为例,输入主分区编号“1”,按“Enter”。 1 2 Partition number (1-4, default 1): 1 First sector (2048-209715199, default 2048): “First sector”表示起始磁柱值,可以选择2048-209715199,默认为2048。\n以选择默认起始磁柱值2048为例,按“Enter” 系统会自动提示分区可用空间的起始磁柱值和截止磁柱值,可以在该区间内自定义,或者使用默认值。起始磁柱值必须小于分区的截止磁柱值。 1 2 3 First sector (2048-209715199, default 2048): Using default value 2048 Last sector, +sectors or +size{K,M,G} (2048-209715199, default 209715199): “Last sector”表示截止磁柱值,可以选择2048-209715199,默认为209715199。\n以选择默认截止磁柱值209715199为例,按“Enter”。 系统会自动提示分区可用空间的起始磁柱值和截止磁柱值,可以在该区间内自定义,或者使用默认值。起始磁柱值必须小于分区的截止磁柱值。 1 2 3 4 5 Last sector, +sectors or +size{K,M,G} (2048-209715199, default 209715199): Using default value 209715199 Partition 1 of type Linux and of size 100 GiB is set Command (m for help): 表示分区完成,即为数据盘新建了1个分区。\n输入“p”,按“Enter”,查看新建分区的详细信息。 1 2 3 4 5 6 7 8 9 10 11 12 13 Command (m for help): p Disk /dev/vdb: 107.4 GB, 107374182400 bytes, 209715200 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk label type: dos Disk identifier: 0x38717fc1 Device Boot Start End Blocks Id System /dev/vdb1 2048 209715199 104856576 83 Linux Command (m for help): 表示新建分区“/dev/vdb1”的详细信息。\n输入“w”,按“Enter”,将分区结果写入分区表中。 1 2 3 4 5 Command (m for help): w The partition table has been altered! Calling ioctl() to re-read partition table. Syncing disks. 表示分区创建完成。 如果之前分区操作有误,请输入“q”,则会退出fdisk分区工具,之前的分区结果将不会被保留。\n执行partprobe命令,将新的分区表变更同步至操作系统。\n执行mkfs -t ext4 /dev/vdb1命令,将新建分区文件系统设为系统所需格式。\n执行mkdir 挂载目录 =\u0026gt; mkdir /mnt/sdc 命令,新建挂载目录。\n执行mount /dev/vdb1 /mnt/sdc命令,将新建分区挂载到12中创建的目录下\n查看挂载结果 df -TH\n1 2 3 4 5 6 7 8 9 [root@ecs-test-0001 ~]# df -TH Filesystem Type Size Used Avail Use% Mounted on /dev/vda1 ext4 43G 1.9G 39G 5% / devtmpfs devtmpfs 2.0G 0 2.0G 0% /dev tmpfs tmpfs 2.0G 0 2.0G 0% /dev/shm tmpfs tmpfs 2.0G 9.1M 2.0G 1% /run tmpfs tmpfs 2.0G 0 2.0G 0% /sys/fs/cgroup tmpfs tmpfs 398M 0 398M 0% /run/user/0 /dev/vdb1 ext4 106G 63M 101G 1% /mnt/sdc 表示新建分区“/dev/vdb1”已挂载至“/mnt/sdc”。\n设置系统给服务Systemd 以前使用Ubuntu和CentOS,一般使用SysV init(就是以前使用的service)进行进程的开机自启和进程守护。 但是,现在更多地使用systemd来实现进程的管理。\nSystemd Systemd(系统管理守护进程),最开始以GNU GPL协议授权开发,现在已转为使用GNU LGPL协议。字母d是daemon的缩写 它取替并兼容传统的SysV init。事实上,CentOS和Debian,现在默认都是使用Systemd:\nCentOS 7开始预设并使用Systemd Ubuntu 15.04开始并预设使用Systemd\n使用Systemd的优点:\n按需启动进程,减少系统资源消耗 并行启动进程,提高系统启动速度 查看systemd和systemctl程序相关的目录:\n1 2 3 4 [root@nebula3-01 node_exporter]# whereis systemd systemd: /usr/lib/systemd /etc/systemd /usr/share/systemd /usr/share/man/man1/systemd.1.gz [root@nebula3-01 node_exporter]# whereis systemctl systemctl: /usr/bin/systemctl /usr/share/man/man1/systemctl.1.gz Systemctl Unit Systemd引入了一个核心配置:Unit(单元配置)。事实上,Systemd管理的每个进程,都是一个Unit。相当于任务块。一个有12种模式:\nService unit:系统服务 Target unit:多个Unit构成的一个组 Device Unit:硬件设备 Mount Unit:文件系统的挂载点 Automount Unit:自动挂载点 Path Unit:文件或路径 Scope Unit:不是由 Systemd 启动的外部进程 Slice Unit:进程组 Snapshot Unit:Systemd 快照,可以切回某个快照 Socket Unit:进程间通信的 socket Swap Unit:swap 文件 Timer Unit:定时器 创建配置文件 如果我们要创建一个Unit服务,我们应该如何创建配置文件呢? 我们自己配置Unit服务(后续使用Systemctl进行启动和管理),可以配置到:\n/usr/lib/systemd/system/:推荐地址。 /run/systemd/system/:系统执行过程中所产生的服务脚本,这些脚本的优先级比上面的高。 /etc/systemd/system/:管理员根据主机系统的需求所建立的执行脚本,优先级比上面的高。 创建编写配置文件 vim /usr/lib/systemd/system/node_exporter.service 新增\n1 2 3 4 5 6 7 8 9 10 11 12 [Unit] Description=node_exporter After=network.target [Service] User=root Type=simple ExecStart=/root/node_exporter/node_exporter PrivateTmp=true [Install] WantedBy=multi-user.target 一些解释:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 - Unit - Description,服务的描述 - Documentation,文档介绍 - After,该服务要在什么服务启动之后启动,比如Mysql需要在network和syslog启动之后再启动 - Install - WantedBy,值是一个或多个Target,当前Unit激活时(enable)符号链接会放入/etc/systemd/system目录下面以Target名+.wants后缀构成的子目录中 - RequiredBy,它的值是一个或多个Target,当前Unit激活(enable)时,符号链接会放入/etc/systemd/system目录下面以Target名+.required后缀构成的子目录中 - Alias,当前Unit可用于启动的别名 - Also,当前Unit激活(enable)时,会被同时激活的其他Unit - Service - Type,定义启动时的进程行为。它有以下几种值。 - Type=simple,默认值,执行ExecStart指定的命令,启动主进程 - Type=forking,以 fork 方式从父进程创建子进程,创建后父进程会立即退出 - Type=oneshot,一次性进程,Systemd 会等当前服务退出,再继续往下执行 - Type=dbus,当前服务通过D-Bus启动 - Type=notify,当前服务启动完毕,会通知Systemd,再继续往下执行 - Type=idle,若有其他任务执行完毕,当前服务才会运行 - ExecStart,启动当前服务的命令 - ExecStartPre,启动当前服务之前执行的命令 - ExecStartPost,启动当前服务之后执行的命令 - ExecReload,重启当前服务时执行的命令 - ExecStop,停止当前服务时执行的命令 - ExecStopPost,停止当其服务之后执行的命令 - RestartSec,自动重启当前服务间隔的秒数 - Restart,定义何种情况 Systemd 会自动重启当前服务,可能的值包括always(总是重启)、on-success、on-failure、on-abnormal、on-abort、on-watchdog - TimeoutSec,定义 Systemd 停止当前服务之前等待的秒数 - Environment,指定环境变量 重载配置 1 systemctl daemon-reload 启动服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 root@nebula3-01 node_exporter]# systemctl daemon-reload [root@nebula3-01 node_exporter]# systemctl status node_exporter ● node_exporter.service - node_exporter Loaded: loaded (/usr/lib/systemd/system/node_exporter.service; disabled; vendor preset: disabled) Active: inactive (dead) [root@nebula3-01 node_exporter]# systemctl start node_exporter [root@nebula3-01 node_exporter]# systemctl status node_exporter ● node_exporter.service - node_exporter Loaded: loaded (/usr/lib/systemd/system/node_exporter.service; disabled; vendor preset: disabled) Active: active (running) since Thu 2023-03-02 14:04:43 CST; 2s ago Main PID: 15015 (node_exporter) CGroup: /system.slice/node_exporter.service └─15015 /root/node_exporter/node_exporter Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=thermal_zone Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=time Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=timex Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=udp_queues Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=uname Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=vmstat Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=xfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=zfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:232 level=info msg=\u0026#34;Listening on\u0026#34; address=[::]:9100 Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:235 level=info msg=\u0026#34;TLS is disabled.\u0026#34; http2=false ...::]:9100 Hint: Some lines were ellipsized, use -l to show in full. 开机自启 1 2 [root@nebula3-01 node_exporter]# systemctl enable node_exporter Created symlink from /etc/systemd/system/multi-user.target.wants/node_exporter.service to /usr/lib/systemd/system/node_exporter.service. 查看是否开机自启, 比上面多了 enabled; vendor preset: disabled\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 [root@nebula3-01 node_exporter]# systemctl status node_exporter ● node_exporter.service - node_exporter Loaded: loaded (/usr/lib/systemd/system/node_exporter.service; enabled; vendor preset: disabled) Active: active (running) since Thu 2023-03-02 14:04:43 CST; 11min ago Main PID: 15015 (node_exporter) CGroup: /system.slice/node_exporter.service └─15015 /root/node_exporter/node_exporter Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=thermal_zone Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=time Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=timex Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=udp_queues Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=uname Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=vmstat Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=xfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=zfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:232 level=info msg=\u0026#34;Listening on\u0026#34; address=[::]:9100 Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:235 level=info msg=\u0026#34;TLS is disabled.\u0026#34; http2=false ...::]:9100 Hint: Some lines were ellipsized, use -l to show in full. 查看Systemd 服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 [root@nebula3-01 node_exporter]# systemctl UNIT LOAD ACTIVE SUB DESCRIPTION proc-sys-fs-binfmt_misc.automount loaded active running Arbitrary Executable File Formats File System Automount Point sys-devices-pci0000:00-0000:00:03.0-virtio0-net-eth0.device loaded active plugged Virtio network device sys-devices-pci0000:00-0000:00:04.0-virtio1-virtio\\x2dports-vport1p1.device loaded active plugged /sys/devices/pci0000:00/0000:00:04.0/virtio1/virtio-ports/v sys-devices-pci0000:00-0000:00:05.0-virtio2-block-vda-vda1.device loaded active plugged /sys/devices/pci0000:00/0000:00:05.0/virtio2/block/vda/vda1 sys-devices-pci0000:00-0000:00:05.0-virtio2-block-vda-vda2.device loaded active plugged /sys/devices/pci0000:00/0000:00:05.0/virtio2/block/vda/vda2 sys-devices-pci0000:00-0000:00:05.0-virtio2-block-vda.device loaded active plugged /sys/devices/pci0000:00/0000:00:05.0/virtio2/block/vda sys-devices-platform-serial8250-tty-ttyS1.device loaded active plugged /sys/devices/platform/serial8250/tty/ttyS1 sys-devices-platform-serial8250-tty-ttyS2.device loaded active plugged /sys/devices/platform/serial8250/tty/ttyS2 sys-devices-platform-serial8250-tty-ttyS3.device loaded active plugged /sys/devices/platform/serial8250/tty/ttyS3 sys-devices-pnp0-00:00-tty-ttyS0.device loaded active plugged /sys/devices/pnp0/00:00/tty/ttyS0 sys-module-configfs.device loaded active plugged /sys/module/configfs sys-subsystem-net-devices-eth0.device loaded active plugged Virtio network device -.mount loaded active mounted / boot.mount loaded active mounted /boot dev-hugepages.mount loaded active mounted Huge Pages File System dev-mqueue.mount loaded active mounted POSIX Message Queue File System ... 你可以配合grep命令操作\n1 2 3 [root@nebula3-01 node_exporter]# systemctl | grep node kmod-static-nodes.service loaded active exited Create list of required static device nodes for the current kernel node_exporter.service loaded active running node_exporter 查看Linux 的基本信息 硬件 uname -a 查看内核/操作系统/CPU信息 head -n 1 /etc/issue 查看操作系统版本 cat /proc/cpuinfo 查看CPU信息 hostname 查看计算机名 lspci -tv 列出所有PCI设备 lsusb -tv 列出所有USB设备 lsmod 列出加载的内核模块 env 查看环境变量 资源 free -m 查看内存使用量和交换区使用量 df -h 查看各分区使用情况 du -sh \u0026lt;目录名\u0026gt; 查看指定目录的大小 grep MemTotal /proc/meminfo 查看内存总量 grep MemFree /proc/meminfo `查看空闲内存量 uptime 查看系统运行时间、用户数、负载 cat /proc/loadavg 查看系统负载 磁盘和分区 mount | column -t 查看挂接的分区状态` fdisk -l 查看所有分区,扇区大小 swapon -s 查看所有交换分区 hdparm -i /dev/hda 查看磁盘参数(仅适用于IDE设备) dmesg | grep IDE 查看启动时IDE设备检测状况 stat /boot/ 查看硬盘块情况,块大小 getconf PAGE_SIZE 查看页大小 网络 ifconfig 查看所有网络接口的属性 iptables -L 查看防火墙设置 route -n 查看路由表 netstat -lntp 查看所有监听端口 netstat -antp 查看所有已经建立的连接 netstat -s 查看网络统计信息 进程 ps -ef 查看所有进程 top 实时显示进程状态 用户 w 查看活动用户 id \u0026lt;用户名\u0026gt; 查看指定用户信息 last 查看用户登录日志 cut -d: -f1 /etc/passwd 查看系统所有用户 cut -d: -f1 /etc/group 查看系统所有组 crontab -l 查看当前用户的计划任务 服务 chkconfig --list 列出所有系统服务 chkconfig --list | grep on 列出所有启动的系统服务 程序 rpm -qa 查看所有安装的软件包 安装Golang, Minicoda, Git Golang 下载合适的版本 输入tar -C /usr/local/ -xzf go1.20.2.linux-amd64.tar.gz 解压到合适的位置, -C 指定位置 设置GOPATH echo $PATH 先查看$PATH 用vim 或者其他工具打开$HOME/.profile, 输入export PATH=$PATH:/usr/local/go/bin 输入 source $HOME/.profile 是上面profile 生效 输入 go version 检查是否成功 修改go proxy 为功能镜像 go env -w GOPROXY=https://goproxy.cn,direct 输入 go env 确认 GOPROXY Minicode 从此处下载Minicoda run bash Miniconda3-latest-Linux-x86_64.sh 根据提示安装,可以都选择默认 为了使配置生效,关闭Terminal 重新打开 输入conda list 或者 机器名前面有个base 意味着Terminal 输入python 默认为Python3,想要使用Python2, 输入Python2 即可 如果conda 没有被识别,需要把conda 加入环境变量。 1 2 3 4 vim /etc/profile # 输入 在文件末尾添加一行:export PATH=/root/miniconda3/bin:$PATH, /root/miniconda3 是miniconda 安装的路径 # :wq 保存退出。然后 source /etc/profile 激活配置 Git 首先,把老版本的 Git 卸掉。 1 2 sudo yum -y remove git sudo yum -y remove git-* 添加 End Point 到 CentOS 7 仓库 yum -y install https://packages.endpointdev.com/rhel/7/os/x86_64/endpoint-repo.x86_64.rpm yum -y install git check version git version 配置Git Set your name. git config --global user.name \u0026quot;Your Name\u0026quot; Set your email address. git config --global user.email \u0026quot;user@exmample.com\u0026quot; Verify the settings. git config --list ","permalink":"https://reid00.github.io/en/posts/langs_linux/linux-%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%99%BB%E5%BD%95%E5%90%8E%E7%9A%84%E5%B8%B8%E8%A7%81%E6%93%8D%E4%BD%9C/","summary":"Linux修改主机名修改hostname的方法 临时修改Linux主机名的方法 hostname newname 执行命令后发现没有变化。重新开终端即可显示,你也可以通过un","title":"Linux 服务器登录后的常见操作"},{"content":"ShardedKV 介绍 有关 shardkv,其可以算是一个 multi-raft 的实现,只是缺少了物理节点的抽象概念。在实际的生产系统中,不同 raft 组的成员可能存在于一个物理节点上,而且一般情况下都是一个物理节点拥有一个状态机,不同 raft 组使用不同地命名空间或前缀来操作同一个状态机。基于此,下文所提到的的节点都代指 raft 组的某个成员,而不代指某个物理节点。比如节点宕机代指 raft 组的某个成员被 kill 掉,而不是指某个物理节点宕机,从而可能影响多个 raft 的成员。\n在本实验中,我们将构建一个带分片的KV存储系统,即一组副本组上的键。每一个分片都是KV对的子集,例如,所有以“a”开头的键可能是一个分片,所有以“b”开头的键可能是另一个分片。 也可以用range 或者Hash 之后分区。 分片的原因是性能。每个replica group只处理几个分片的 put 和 get,并且这些组并行操作;因此,系统总吞吐量(每单位时间的投入和获取)与组数成比例增加。\n我们的整个系统有两个基本组件:shard controller 和 shard group。整个系统有一个 controller 和多个 group,controller 单独一个 raft 集群,每一个 shard group 是由 kvraft 实例构成的集群。shard controller 负责调度,客户端向 shard controller 发送请求,controller 会根据配置(config)来告知客户端服务这个 key 的是哪个 group。 每个 group 负责部分 shard。\n1 2 3 4 5 type Config struct { Num int // config number, version also Shards [NShards]int // shard -\u0026gt; gid Groups map[int][]string // gid -\u0026gt; servers[] } 三个参数分别对应的版本的配置号,分片所对应的组(Group)信息(实验中的分片为10个),每个组对应的服务器映射名称列表(也就是组信息)。\nGroup表示一个Leader-Followers集群,Gid为它的标识,Shard表示所有数据的一个子集,Config表示一个划分方案。此次实验中,所有数据分为NShards = 10份,Server给测试程序提供四个接口。 下图中每个Shard 都有其他对应的副本未画出。对Client 以Group 为单位进行服务。相当于一个物理节点上 有若干个Group 可以对外服务。\n分片存储系统必须能够在replica group之间移动分片,因为某些组可能比其他组负载更多,因此需要移动分片以平衡负载;而且replica group可能会加入和离开系统,可能会添加新的副本组以增加容量,或者可能会使现有的副本组脱机以进行修复或报废。\nLab4A 实现 本实验的主要挑战是处理重新配置——移动分片所属。在单个副本组中,所有组成员必须就何时发生与客户端 Put/Append/Get 请求相关的重新配置达成一致。例如,Put 可能与重新配置大约同时到达,导致副本组停止对该Put包含的key的分片负责。组中的所有副本必须就 Put 发生在重新配置之前还是之后达成一致。如果之前,Put 应该生效,分片的新所有者将看到它的效果;如果之后,Put 将不会生效,客户端必须在新所有者处重新尝试。推荐的方法是让每个副本组使用 Raft 不仅记录 Puts、Appends 和 Gets 的顺序,还记录重新配置的顺序。您需要确保在任何时候最多有一个副本组为每个分片提供请求。\n重新配置还需要副本组之间的交互。例如,在配置 10 中,组 G1 可能负责分片 S1。在配置 11 中,组 G2 可能负责分片 S1。在从 10 到 11 的重新配置过程中,G1 和 G2 必须使用 RPC 将分片 S1(键/值对)的内容从 G1 移动到 G2。\nLab4的内容就是将数据按照某种方式分开存储到不同的RAFT集群(Group)上的分片(shard)上。保证相应数据请求引流到对应的集群,降低单一集群的压力,提供更为高效、更为健壮的服务。 具体的lab4要实现一个支持 multi-raft分片 、分片数据动态迁移的线性一致性分布式 KV 存储服务。 shard表示互不相交并且组成完整数据库的每一个数据库子集。group表示shard的集合,包含一个或多个shard。一个shard只可属于一个group,一个group可包含(管理)多个shard。 lab4A实现ShardCtrler服务,作用:提供高可用的集群配置管理服务,实现分片的负载均衡,并尽可能少地移动分片。记录了每组(Group) ShardKVServer 的集群信息和每个分片(shard)服务于哪组(Group)ShardKVServer。 具体实现通过Raft维护 一个Configs数组,单个config具体内容如下: Num:config number,Num=0表示configuration无效,边界条件, 即是version 的作用 Shards:shard -\u0026gt; gid,分片位置信息,Shards[3]=2,说明分片序号为3的分片负贵的集群是Group2(gid=2) Groups:gid -\u0026gt; servers[], 集群成员信息,Group[3]=[\u0026lsquo;server1\u0026rsquo;,\u0026lsquo;server2\u0026rsquo;],说明gid = 3的集群Group3包含两台名称为server1 \u0026amp; server2的机器 RPC Query RPC。查询配置,参数是一个配置号, shardctrler 回复具有该编号的配置。如果该数字为 -1 或大于已知的最大配置数字,则 shardctrler 应回复最新配置。 Query(-1) 的结果应该反映 shardctrler 在收到 Query(-1) RPC 之前完成处理的每个 Join、Leave 或 Move RPC;\nJoin RPC 。添加新的replica group,它的参数是一组从唯一的非零副本组标识符 (GID) 到服务器名称列表的映射。 shardctrler 应该通过创建一个包含新副本组的新配置来做出反应。新配置应在所有组中尽可能均匀地分配分片,并应移动尽可能少的分片以实现该目标。如果 GID 不是当前配置的一部分,则 shardctrler 应该允许重新使用它(即,应该允许 GID 加入,然后离开,然后再次加入)\n新加入的Group信息,要求在每一个group平衡分布shard,即任意两个group之间的shard数目相差不能为1,具体实现每一次找出含有shard数目最多的和最少的,最多的给最少的一个,循环直到满足条件为止。坑为:GID = 0 是无效配置,一开始所有分片分配给GID=0,需要优先分配;map的迭代时无序的,不确定顺序的话,同一个命令在不同节点上计算出来的新配置不一致,按sort排序之后遍历即可。且 map 是引用对象,需要用深拷贝做复制。\n对于 Join,可以通过多次平均地方式来达到这个目的:每次选择一个拥有 shard 数最多的 raft 组和一个拥有 shard 数最少的 raft,将前者管理的一个 shard 分给后者,周而复始,直到它们之前的差值小于等于 1 且 0 raft 组无 shard 为止。对于 Leave,如果 Leave 后集群中无 raft 组,则将分片所属 raft 组都置为无效的 0;否则将删除 raft 组的分片均匀地分配给仍然存在的 raft 组。通过这样的分配,可以将 shard 分配地十分均匀且产生了几乎最少的迁移任务。\nLeave RPC。删除指定replica group, 参数是以前加入的组的 GID 列表。 shardctrler 应该创建一个不包括这些组的新配置,并将这些组的分片分配给剩余的组。新配置应在组之间尽可能均匀地划分分片,并应移动尽可能少的分片以实现该目标;\nMove RPC。移动分片,的参数是一个分片号和一个 GID。 shardctrler 应该创建一个新配置,其中将分片分配给组。 Move 的目的是让我们能够测试您的软件。移动后的加入或离开可能会取消移动,因为加入和离开会重新平衡。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 // Join according to new Group(gid -\u0026gt; servers) to change the Config func (cf *MemoryConfigStateMachine) Join(groups map[int][]string) Err { lastConfig := cf.Configs[len(cf.Configs)-1] newConfig := Config{ Num: len(cf.Configs), Shards: lastConfig.Shards, Groups: deepCopy(lastConfig.Groups), } for gid, servers := range groups { if _, ok := newConfig.Groups[gid]; !ok { newServers := make([]string, len(servers)) copy(newServers, servers) newConfig.Groups[gid] = newServers } } // 找到group 中shard 最大和最小的组,将数据进行move =\u0026gt; reblance g2s := Group2Shards(newConfig) for { src, dst := GetGIDWIthMaxShards(g2s), GetGIDWithMinShards(g2s) if src != 0 \u0026amp;\u0026amp; len(g2s[src])-len(g2s[dst]) \u0026lt;= 1 { break } g2s[dst] = append(g2s[dst], g2s[src][0]) g2s[src] = g2s[src][1:] } var newShards [NShards]int for gid, shards := range g2s { for _, shard := range shards { newShards[shard] = gid } } newConfig.Shards = newShards cf.Configs = append(cf.Configs, newConfig) return OK } // Leave some group leave the cluster func (cf *MemoryConfigStateMachine) Leave(gids []int) Err { lastConfig := cf.Configs[len(cf.Configs)-1] newConfig := Config{ Num: len(cf.Configs), Shards: lastConfig.Shards, Groups: deepCopy(lastConfig.Groups), } g2s := Group2Shards(newConfig) orphanShards := make([]int, 0) for _, gid := range gids { delete(newConfig.Groups, gid) if shards, ok := g2s[gid]; ok { orphanShards = append(orphanShards, shards...) delete(g2s, gid) } } var newShards [NShards]int if len(newConfig.Groups) != 0 { // reblance for _, shard := range orphanShards { target := GetGIDWithMinShards(g2s) g2s[target] = append(g2s[target], shard) } // update Shards: share -\u0026gt; gid for gid, shards := range g2s { for _, shard := range shards { newShards[shard] = gid } } } newConfig.Shards = newShards cf.Configs = append(cf.Configs, newConfig) return OK } // Move move No.shard to No.gid func (cf *MemoryConfigStateMachine) Move(shard, gid int) Err { lastConfig := cf.Configs[len(cf.Configs)-1] newConfig := Config{ Num: len(cf.Configs), Shards: lastConfig.Shards, Groups: lastConfig.Groups, } newConfig.Shards[shard] = gid cf.Configs = append(cf.Configs, newConfig) return OK } // Query return the version of num config func (cf *MemoryConfigStateMachine) Query(num int) (Config, Err) { if num \u0026lt; 0 || num \u0026gt;= len(cf.Configs) { return cf.Configs[len(cf.Configs)-1], OK } return cf.Configs[num], OK } Lab4B ShardKV 实验提示:\n服务器不需要调用分片控制器的Join(),tester 才会去调用; 服务器将需要定期轮询 shardctrler 以监听新的配置。预期大约每100毫秒轮询一次;可以更频繁,但过少可能会导致 bug。 服务器需要互相发送rpc,以便在配置更改期间传输分片。shardctrler的Config结构包含服务器名,一个 Server 需要一个labrpc.ClientEnd,以便发送RPC。使用make_end()函数传给StartServer()函数将服务器名转换为ClientEnd。shardkv /client.go需要实现这些逻辑。 在server.go中添加代码去周期性从 shardctrler 拉取最新的配置,并且当请求分片不属于自身时,拒绝请求 当被请求到错误分片时,需要返回ErrWrongGroup给客户端,并确保Get, Put, Append在面临并发重配置时能正确作出决定 重配置需要按流程执行唯一一次 labgob 的提示错误不能忽视,它可能导致实验不过 分片重分配的请求也需要做重复请求检测 若客户端收到ErrWrongGroup,是否更改请求序列号? 若服务器执行请求时返回ErrWrongGroup,是否更新客户端信息? 当服务器转移到新配置后,它可以继续存储它不再负责的分片(生产环境中这是不允许的),但这个可以简化实现 当 G1 在配置变更时需要来自 G2 的分片数据,G2 处理日志条目的哪个时间点将分片发送给 G1 是最好的? 你可以在整个 rpc 请求或回复中发送整个 map,这可以简化分片传输 map 是引用类型,所以在发送 map 的时候,建议先拷贝一次,避免 data race(在 labrpc 框架下,接收 map 时也需要拷贝) 在配置更改期间,一对组可能需要互相传送分片,这可能会发生死锁 Challenge 如果想达到生产环境系统级别,如下两个挑战是需要实现的 Challenge1:Garbage collection of state 当一个副本组失去一个分片的所有权时,副本组需要删除该分片数据。但这给迁移带来一些问题,考虑两个组G1 和 G2,并且新配置C 将分片从 G1 移动到 G2,若 G1 在转换配置到C时删除了数据库中的分片,当G2 转换到C时,如何获取 G1 的数据 实验要求 使每个副本组保留旧分片的时长不再是无限时长,即使副本组(如上面的G1)中的所有服务器崩溃并恢复正常,解决方案也必须工作。如果您通过TestChallenge1Delete,您就完成了这个挑战。 解决方案 分片迁移成功之后,立马进行分片 GC 了,GC 完毕后再进入到配置更新阶段。 chanllenge2:Client requests during configuration changes 配置更改期间最简单的方式是禁止所有客户端操作直到转换完成,虽然简单但是不满足于生产环境要求,这将导致客户端长时间停滞,最好可以继续为不受当前配置更改的分片提供服务 上述优化还能更好,若 G3 在过渡到配置C时,需要来自G1 的分片S1 和 G2 的分片S2。希望 G3 能在收到其中一个分片后可以立即开始接收针对该分片的请求。如G1宕机了,G3在收到G2的分片数据后,可以立即为 S2 分片提供服务,而不需要等待 C 配置转换完全完成 实验要求 修改您的解决方案,以便在配置更改期间继续执行不受影响的分片中的 key 的客户端操作。当您通过 TestChallenge2Unaffected 测试时,您已经完成了这个挑战。 修改您的解决方案,在配置转换进行中,副本组也可以立即开始提供分片服务。当您通过TestChallenge2Partial测试时,您已经完成了这个挑战。 解决方案 分片迁移以 group 为单位,这样即使一个 group挂了,也不会影响到另一个 group中的分片迁移。 上面的实验ShardCtrler 集群组实现了配置更新,分片均匀分配等任务,ShardKVServer则需要承载所有分片的读写任务,相比于MIT 6.824 Lab3 RaftKV的提供基础的读写服务,还需要功能为配置更新,分片数据迁移,分片数据清理,空日志检测。\n实验逻辑 我们可以首先明确系统的运行方式:一开始系统会创建一个 shardctrler 组来负责配置更新,分片分配等任务,接着系统会创建多个 raft 组来承载所有分片的读写任务。此外,raft 组增删,节点宕机,节点重启,网络分区等各种情况都可能会出现。\n对于集群内部,我们需要保证所有分片能够较为均匀的分配在所有 raft 组上,还需要能够支持动态迁移和容错。\n对于集群外部,我们需要向用户保证整个集群表现的像一个永远不会挂的单节点 KV 服务一样,即具有线性一致性。\nlab4b 的基本测试要求了上述属性,challenge1 要求及时清理不再属于本分片的数据,challenge2 不仅要求分片迁移时不影响未迁移分片的读写服务,还要求不同地分片数据能够独立迁移,即如果一个配置导致当前 raft 组需要向其他两个 raft 组拉取数据时,即使一个被拉取数据的 raft 组全挂了,也不能导致另一个未挂的被拉取数据的 raft 组分片始终不能在当前 raft 组提供服务。\nStartServer: 启动Raft 节点和 Group configureAction: 监听是否由配置变化, 配置符合要求后执行NewConfigurationCommand RPC Apply 操作记录到日志中 migrationAction: 监听configureAction 结束后 根据最新配置进行数据迁移。会发送GetShardsData RPC pull shard 数据到resp *ShardOperationResponse 中,相当于拉到本地节点的某个变量中,这个过程可能会有大量数据的传输。此RPC 结束之后,发送InsertShardsCommand RPC 进行真实的数据迁移, 同样经过Raft 层多数节点同意后,应用到本地的状态机上。并把 需要Shard 状态修改好。Pulling 改为GCing 为下部做准备 gcAction: 在上面一步的applyInsertShards 中会把已经Pulling 的远程的Shard 改为Gcing。 在这个Goroutine 中,调用DeleteShardsData RPC, 会把ShardOperationRequest 中的Shard 通过发送 NewDeleteShardsCommand RPC 把状态为GCing 的Shards 改为Server,状态为BePulling 的重置。与此同时,DeleteShardsData RPC 结束OK 后,本节点也需要发送一遍NewDeleteShardsCommand RPC Command,把GCing 的Shards 改为默认状态。 架构图 1 2 3 4 5 6 7 8 9 2023/03/03 19:58:49 [StartServer]-{Node: 0}-{Group: 100} has started 2023/03/03 19:58:49 [StartServer]-{Node: 1}-{Group: 100} has started 2023/03/03 19:58:49 [StartServer]-{Node: 2}-{Group: 100} has started 2023/03/03 19:58:49 [StartServer]-{Node: 0}-{Group: 101} has started 2023/03/03 19:58:49 [StartServer]-{Node: 1}-{Group: 101} has started 2023/03/03 19:58:49 [StartServer]-{Node: 2}-{Group: 101} has started 2023/03/03 19:58:49 [StartServer]-{Node: 0}-{Group: 102} has started 2023/03/03 19:58:49 [StartServer]-{Node: 1}-{Group: 102} has started 2023/03/03 19:58:49 [StartServer]-{Node: 2}-{Group: 102} has started 根据Log 可以看出,集群以Group 为单位,初始化三个Group 管理十个Shard和三台节点。Shard 是真实存储数据的单位。 每个节点都有一个Raft 共识层,同一个Group 构成一个Raft Group, 整体形成Multi Raft Group, 以Group 为单位对应用层提供服务。Group 内部用Raft 保持数据的一致性。每个Group 有多少个Shard 由ShardCtrller 决定。Shard如果由副本也在各自Group 的各个节点上管理。\nNebula Graph 中 每个Shard及其副本构成一个Raft Group,Shard 的数量决定了Group 的数量。 本实验中,确定了只有最多三个Group\n客户端Clerk 主要请求逻辑:\n使用key2shard()去找到一个 key 对应哪个ShardShard; 根据Shard从当前配置config中获取的 gid; 根据gid从当前配置config中获取 group 信息; 在group循环查找leaderId,直到返回请求成功、ErrWrongGroup或整个 group 都遍历请求过; Query 最新的配置,回到步骤1循环重复; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 type Clerk struct { sc *shardctrler.Clerk config shardctrler.Config makeEnd func(string) *labrpc.ClientEnd // You will have to modify this struct. leaderIds map[int]int // {groupid: leader if hardid of this groupid} clientId int64 commandId int64 //clientId + commandId define unique operation } // 省略一些方法 func (ck *Clerk) Command(req *CommandRequest) string { req.ClientId, req.CommandId = ck.clientId, ck.commandId for { shard := key2shard(req.Key) gid := ck.config.Shards[shard] if servers, ok := ck.config.Groups[gid]; ok { // 找到Group 对应的LeaderId, 如果没有从Id 0 开始轮询 if _, ok := ck.leaderIds[gid]; !ok { ck.leaderIds[gid] = 0 } oldLeaderId := ck.leaderIds[gid] newLeaderId := oldLeaderId for { var resp CommandResponse ok := ck.makeEnd(servers[newLeaderId]).Call(\u0026#34;ShardKV.Command\u0026#34;, req, \u0026amp;resp) if ok \u0026amp;\u0026amp; (resp.Err == OK || resp.Err == ErrNoKey) { ck.commandId++ return resp.Value } else if ok \u0026amp;\u0026amp; resp.Err == ErrWrongGroup { break } else { // Err is ErrWrongLeader ErrOutDated ErrTimeout ErrNotReady newLeaderId = (newLeaderId + 1) % len(servers) if newLeaderId == oldLeaderId { // 所有server 轮询一遍之后退出,避免raft 集群处于无leader 状态中一直重试 break } continue } } } time.Sleep(100 * time.Millisecond) ck.config = ck.sc.Query(-1) } } 服务端Server 主要逻辑:\n客户端首先和ShardCtrler交互,获取最新的配置,根据最新配置找到对应key的shard,请求该shard的group。 服务端ShardKVServer会创建多个 raft 组来承载所有分片的读写任务。 服务端ShardKVServer需要定期和ShardCtrler交互,保证更新到最新配置(monitor)。 服务端ShardKVServer需要根据最新配置完成配置更新,分片数据迁移,分片数据清理,空日志检测等功- 能。 结构体 首先ShardKVServer给出结构体,相比于MIT 6.824 Lab3 RaftKV的多了currentConfig和lastConfig数据,这样其他协程便能够通过其计算需要需要向谁拉取分片或者需要让谁去删分片。 同时底层的StateMachine 也由MemeoryKV 变为Shard 承接。并给Shard 添加了状态信息。\n启动了五个协程:apply 协程,配置更新协程,数据迁移协程,数据清理协程,空日志检测协程来实现功能。四个协程都需要 leader 来执行,因此抽象出了一个简单地周期执行函数 Monitor。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 type Shard struct { KV map[string]string Status ShardStatus } type ShardKV struct { mu sync.RWMutex me int rf *raft.Raft dead int32 applyCh chan raft.ApplyMsg makeEnd func(string) *labrpc.ClientEnd gid int sc *shardctrler.Clerk maxRaftState int // snapshot if log grows this big lastApplied int // 记录applied Index 防止状态机apply 小的index lastConfig shardctrler.Config currentConfig shardctrler.Config stateMachine map[int]*Shard // {shardId: shard of KV} lastOperations map[int64]OperationContext // {clientId: ctx} notifyChans map[int]chan *CommandResponse // {commitIndex: commandResp} } func StartServer(servers []*labrpc.ClientEnd, me int, persister *raft.Persister, maxraftstate int, gid int, ctrlers []*labrpc.ClientEnd, make_end func(string) *labrpc.ClientEnd) *ShardKV { // call labgob.Register on structures you want // Go\u0026#39;s RPC library to marshall/unmarshall. labgob.Register(Command{}) labgob.Register(CommandRequest{}) labgob.Register(shardctrler.Config{}) labgob.Register(ShardOperationRequest{}) labgob.Register(ShardOperationResponse{}) applyCh := make(chan raft.ApplyMsg) kv := \u0026amp;ShardKV{ me: me, rf: raft.Make(servers, me, persister, applyCh), dead: 0, applyCh: applyCh, makeEnd: make_end, gid: gid, sc: shardctrler.MakeClerk(ctrlers), maxRaftState: maxraftstate, lastApplied: 0, lastConfig: shardctrler.DefaultConfig(), currentConfig: shardctrler.DefaultConfig(), stateMachine: make(map[int]*Shard), lastOperations: make(map[int64]OperationContext), notifyChans: make(map[int]chan *CommandResponse), } kv.restoreSnapshot(persister.ReadSnapshot()) // start applier goroutine to apply committed logs to stateMachine go kv.applier() // start configuration monitor goroutine to fetch latest configuration go kv.Monitor(kv.configureAction, ConfigureMonitorTimeout) // start migration monitor goroutine to pull related shards go kv.Monitor(kv.migrationAction, MigrationMonitorTimeout) // start gc monitor goroutine to delete useless shards in remote groups go kv.Monitor(kv.gcAction, GCMonitorTimeout) // start entry-in-currentTerm monitor goroutine to advance commitIndex by // appending empty entries in current term periodically to avoid live locks go kv.Monitor(kv.checkEntryIncurrentTermAction, EmptyEntryDetectorTimeout) DPrintf(\u0026#34;[StartServer]-{Node: %v}-{Group: %v} has started\u0026#34;, kv.me, kv.gid) return kv } 分片状态 每个分片共有 4 种状态:\nServing:分片的默认状态,如果当前 raft 组在当前 config 下负责管理此分片,则该分片可以提供读写服务,否则该分片暂不可以提供读写服务,但不会阻塞配置更新协程拉取新配置。\nPulling:表示当前 raft 组在当前 config 下负责管理此分片,暂不可以提供读写服务,需要当前 raft 组从上一个配置该分片所属 raft 组拉数据过来之后才可以提供读写服务,系统会有一个分片迁移协程检测所有分片的 Pulling 状态,接着以 raft 组为单位去对应远端 raft 组拉取数据,接着尝试重放该分片的所有数据到本地并将分片状态置为 Serving,以继续提供服务。\nBePulling:表示当前 raft 组在当前 config 下不负责管理此分片,不可以提供读写服务,但当前 raft 组在上一个 config 时负责管理此分片,因此当前 config 下负责管理此分片的 raft 组拉取完数据后会向本 raft 组发送分片清理的 rpc,接着本 raft 组将数据清空并重置为 serving 状态即可。\nGCing:表示当前 raft 组在当前 config 下负责管理此分片,可以提供读写服务,但需要清理掉上一个配置该分片所属 raft 组的数据。系统会有一个分片清理协程检测所有分片的 GCing 状态,接着以 raft 组为单位去对应远端 raft 组删除数据,一旦远程 raft 组删除数据成功,则本地会尝试将相关分片的状态置为 Serving。\n日志类型 在 lab3 中,客户端的请求会被包装成一个 Op 传给 Raft 层,则在 lab4 中,不难想到,Servers 之间的交互,也可以看做是包装成 Op 传给 Raft 层;定义了五种类型的日志:\nOperation:客户端传来的读写操作日志,有 Put,Get,Append 等请求。\nConfiguration:配置更新日志,包含一个配置。\nInsertShards:分片更新日志,包含至少一个分片的数据和配置版本。\nDeleteShards:分片删除日志,包含至少一个分片的 id 和配置版本。\nEmptyEntry:空日志,Data 为空,使得状态机达到最新。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 type Command struct { Op CommandType Data interface{} } func (cmd Command) String() string { return fmt.Sprintf(\u0026#34;{Op: %v, Data: %v}\u0026#34;, cmd.Op, cmd.Data) } func NewOperationCommand(req *CommandRequest) Command { return Command{ Op: Operation, Data: *req, } } func NewConfigurationCommand(config *shardctrler.Config) Command { return Command{ Op: Configuration, Data: *config, } } func NewInsertShardsCommand(response *ShardOperationResponse) Command { return Command{InsertShards, *response} } func NewDeleteShardsCommand(request *ShardOperationRequest) Command { return Command{DeleteShards, *request} } func NewEmptyEntryCommand() Command { return Command{EmptyEntry, nil} } // ------------------------------------------------------------- type CommandType uint8 const ( Operation CommandType = iota Configuration InsertShards DeleteShards EmptyEntry ) var ctmap = [...]string{ \u0026#34;Operation\u0026#34;, \u0026#34;Configuration\u0026#34;, \u0026#34;InsertShards\u0026#34;, \u0026#34;DeleteShards\u0026#34;, \u0026#34;EmptyEntry\u0026#34;, } func (ct CommandType) String() string { return ctmap[ct] } 读写服务 读写操作的基本逻辑相比于MIT 6.824 Lab3 RaftKV基本一致,需要增加分片状态判断。根据上述定义,分片的状态为 Serving 或 GCing,当前 raft 组在当前 config 下负责管理此分片,本 raft 组才可以为该分片提供读写服务,否则返回 ErrWrongGroup 让客户端重新拉取最新的 config 并重试即可。\ncanServe 的判断需要在向 raft 提交前和 apply 时都检测一遍以保证正确性并尽可能提升性能。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 // canServe 判断shard 的状态是否可以对外服务 // Serving 默认初始状态, GCing 表示该shard 的数据刚刚拉取完毕,但是需要清除 // 远端 该shardId 数据 func (kv *ShardKV) canServe(ShardId int) bool { return kv.currentConfig.Shards[ShardId] == kv.gid \u0026amp;\u0026amp; (kv.stateMachine[ShardId].Status == Serving || kv.stateMachine[ShardId].Status == GCing) } func (kv *ShardKV) Command(req *CommandRequest, resp *CommandResponse) { kv.mu.RLock() if req.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicateRequest(req.ClientId, req.CommandId) { lastResp := kv.lastOperations[req.ClientId].LastResponse resp.Err = lastResp.Err resp.Value = lastResp.Value kv.mu.RUnlock() return } // return ErrWrongGroup directly to let client fetch latest configuration // and perform a retry if this key can\u0026#39;t be served by this shard at present if !kv.canServe(key2shard(req.Key)) { resp.Err = ErrWrongGroup resp.Value = \u0026#34;\u0026#34; kv.mu.RUnlock() return } kv.mu.RUnlock() kv.Execute(NewOperationCommand(req), resp) } // Execute shardKV 执行相关的RPC req func (kv *ShardKV) Execute(command Command, resp *CommandResponse) { // do not hold lock to improve throughput // when KVServer holds the lock to take snapshot, underlying raft can still commit raft logs index, _, isLeader := kv.rf.Start(command) if !isLeader { resp.Err = ErrWrongLeader return } defer DPrintf(\u0026#34;[Execute]-{Node: %v}-{Group: %v} process Command %v with CommandResponse %v\u0026#34;, kv.me, kv.gid, command, resp) kv.mu.Lock() ch := kv.getNotifyChan(index) kv.mu.Unlock() select { case res := \u0026lt;-ch: resp.Value, resp.Err = res.Value, res.Err case \u0026lt;-time.After(ExecuteTimeout): resp.Err = ErrTimeout } go func() { kv.mu.Lock() kv.deleteOutdatedNotifyChan(index) kv.mu.Unlock() }() } // applyOperation 对状态机的操作, Get, Put, Append func (kv *ShardKV) applyOperation(msg *raft.ApplyMsg, req *CommandRequest) *CommandResponse { var resp *CommandResponse shardId := key2shard(req.Key) if kv.canServe(shardId) { if req.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicateRequest(req.ClientId, req.CommandId) { DPrintf(\u0026#34;[applyOperation]-{Node: %v}-{Group: %v} doesn\u0026#39;t apply duplicated message %v to stateMachine because maxAppliedCommandId is %v for client %v\u0026#34;, kv.me, kv.gid, kv.lastOperations[req.ClientId], kv.lastApplied, req.ClientId) lastResp := kv.lastOperations[req.ClientId].LastResponse return lastResp } resp = kv.applyLogToStateMachines(req, shardId) if req.Op != OpGet { // save max command resp kv.lastOperations[req.ClientId] = OperationContext{ MaxAppliedCommandId: req.CommandId, LastResponse: resp, } } return resp } return \u0026amp;CommandResponse{ErrWrongGroup, \u0026#34;\u0026#34;} } 配置更新 配置更新协程负责定时检测所有分片的状态,一旦存在至少一个分片的状态不为默认状态,则预示其他协程仍然还没有完成任务,那么此时需要阻塞新配置的拉取和提交。\n在 apply 配置更新日志时需要保证幂等性:\n不同版本的配置更新日志:apply 时仅可逐步递增的去更新配置,否则返回失败。 相同版本的配置更新日志:由于配置更新日志仅由配置更新协程提交,而配置更新协程只有检测到比本地更大地配置时才会提交配置更新日志,所以该情形不会出现。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // configureAction kvctrller execute apply configuration func (kv *ShardKV) configureAction() { canPerformNextConfig := true kv.mu.RLock() for _, shard := range kv.stateMachine { if shard.Status != Serving { canPerformNextConfig = false DPrintf(\u0026#34;[configureAction]-{Node: %v}-{Group: %v} will not try to fetch latest configuration because shards status are %v when currentConfig is %v\u0026#34;, kv.me, kv.gid, kv.getShardStatus(), kv.currentConfig) break } } currentConfigNum := kv.currentConfig.Num kv.mu.RUnlock() if canPerformNextConfig { nextConfig := kv.sc.Query(currentConfigNum + 1) if nextConfig.Num == currentConfigNum+1 { DPrintf(\u0026#34;[configureAction]-{Node: %v}-{Group: %v} fetches latest configuration %v when currentConfigNum is %v\u0026#34;, kv.me, kv.gid, nextConfig, currentConfigNum) kv.Execute(NewConfigurationCommand(\u0026amp;nextConfig), \u0026amp;CommandResponse{}) } } } // applyConfiguration 对kv controller 的配置进行更新 func (kv *ShardKV) applyConfiguration(conf *shardctrler.Config) *CommandResponse { if conf.Num == kv.currentConfig.Num+1 { DPrintf(\u0026#34;[applyConfiguration]-{Node: %v}-{Group: %v} updates currentConfig from %v to %v\u0026#34;, kv.me, kv.gid, kv.currentConfig, conf) kv.updateShardStatus(conf) kv.lastConfig = kv.currentConfig kv.currentConfig = *conf return \u0026amp;CommandResponse{OK, \u0026#34;\u0026#34;} } DPrintf(\u0026#34;[applyConfiguration]-{Node: %v}-{Group: %v} rejects outdated config %v when currentConfig is %v\u0026#34;, kv.me, kv.gid, conf, kv.currentConfig) return \u0026amp;CommandResponse{ErrOutDated, \u0026#34;\u0026#34;} } 分片迁移 分片迁移协程负责定时检测分片的 Pulling 状态,利用 lastConfig 计算出对应 raft 组的 gid 和要拉取的分片,然后并行地去拉取数据。\n注意这里使用了 waitGroup 来保证所有独立地任务完成后才会进行下一次任务。此外 wg.Wait() 一定要在释放读锁之后,否则无法满足 challenge2 的要求。\n在拉取分片的 handler 中,首先仅可由 leader 处理该请求,其次如果发现请求中的配置版本大于本地的版本,那说明请求拉取的是未来的数据,则返回 ErrNotReady 让其稍后重试,否则将分片数据和去重表都深度拷贝到 response 即可。\n在 apply 分片更新日志时需要保证幂等性:\n不同版本的配置更新日志:仅可执行与当前配置版本相同地分片更新日志,否则返回 ErrOutDated。 相同版本的配置更新日志:仅在对应分片状态为 Pulling 时为第一次应用,此时覆盖状态机即可并修改状态为 GCing,以让分片清理协程检测到 GCing 状态并尝试删除远端的分片。否则说明已经应用过,直接 break 即可。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 // migrationAction shard migration data when in Pulling status func (kv *ShardKV) migrationAction() { kv.mu.RLock() gid2shardIds := kv.getShardIdsByStatus(Pulling) var wg sync.WaitGroup for gid, shardIds := range gid2shardIds { DPrintf(\u0026#34;[migrationAction]-{Node: %v}-{Group: %v} starts a PullTask to get shards %v from group %v when config is %v\u0026#34;, kv.me, kv.gid, shardIds, gid, kv.currentConfig) wg.Add(1) go func(servers []string, configNum int, shardIds []int) { defer wg.Done() PullTaskRequest := ShardOperationRequest{ ConfigNum: configNum, ShardIDs: shardIds, } // 本Node 向Pulling Status 的Shard 所在Group 的全部Server // 发送RPC 但是只有Leader 有响应,其他忽略 for _, server := range servers { var pullTaskResp ShardOperationResponse srv := kv.makeEnd(server) DPrintf(\u0026#34;[migrationAction]-{Node: %v}-{Group: %v} server call %v\u0026#34;, kv.me, kv.gid, server) if srv.Call(\u0026#34;ShardKV.GetShardsData\u0026#34;, \u0026amp;PullTaskRequest, \u0026amp;pullTaskResp) \u0026amp;\u0026amp; pullTaskResp.Err == OK { DPrintf(\u0026#34;[migrationAction]-{Node: %v}-{Group: %v} gets a PullTaskResponse %v and tries to commit it when currentConfigNum is %v\u0026#34;, kv.me, kv.gid, pullTaskResp, configNum) kv.Execute(NewInsertShardsCommand(\u0026amp;pullTaskResp), \u0026amp;CommandResponse{}) } } }(kv.lastConfig.Groups[gid], kv.currentConfig.Num, shardIds) } kv.mu.RUnlock() wg.Wait() } // RPC GetShardsData ConfigOperation 生效之后数据迁移, 将request 中shardId的数据,迁移到resp中 返回给调用方 func (kv *ShardKV) GetShardsData(req *ShardOperationRequest, resp *ShardOperationResponse) { // ... } func (kv *ShardKV) applyInsertShards(shardsInfo *ShardOperationResponse) *CommandResponse { // ... } 分片清理 分片清理协程负责定时检测分片的 GCing 状态,利用 lastConfig 计算出对应 raft 组的 gid 和要拉取的分片,然后并行地去删除分片。\n注意这里使用了 waitGroup 来保证所有独立地任务完成后才会进行下一次任务。此外 wg.Wait() 一定要在释放读锁之后,否则无法满足 challenge2 的要求。\n在删除分片的 handler 中,首先仅可由 leader 处理该请求,其次如果发现请求中的配置版本小于本地的版本,那说明该请求已经执行过,否则本地的 config 也无法增大,此时直接返回 OK 即可,否则在本地提交一个删除分片的日志。\n在 apply 分片删除日志时需要保证幂等性:\n不同版本的配置更新日志:仅可执行与当前配置版本相同地分片删除日志,否则已经删除过,直接返回 OK 即可。 相同版本的配置更新日志:如果分片状态为 GCing,说明是本 raft 组已成功删除远端 raft 组的数据,现需要更新分片状态为默认状态以支持配置的进一步更新;否则如果分片状态为 BePulling,则说明本 raft 组第一次删除该分片的数据,此时直接重置分片即可。否则说明该请求已经应用过,直接 break 返回 OK 即可。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 // gcAction Pulling 数据之后把状态改为GCing,调用RPC 删除远端的该ShardId 的数据 func (kv *ShardKV) gcAction() { kv.mu.RLock() gid2shardIds := kv.getShardIdsByStatus(GCing) var wg sync.WaitGroup for gid, shardIds := range gid2shardIds { DPrintf(\u0026#34;[gcAction]-{Node: %v}-{Group: %v} starts a GCTask to delete shards %v in group %v when config is %v\u0026#34;, kv.me, kv.gid, shardIds, gid, kv.currentConfig) wg.Add(1) go func(servers []string, configNum int, shardIds []int) { defer wg.Done() gcTaskReq := ShardOperationRequest{ConfigNum: configNum, ShardIDs: shardIds} for _, server := range servers { var gcTaskResp ShardOperationResponse srv := kv.makeEnd(server) // 远端执行删除数据的逻辑 if srv.Call(\u0026#34;ShardKV.DeleteShardsData\u0026#34;, \u0026amp;gcTaskReq, \u0026amp;gcTaskResp) \u0026amp;\u0026amp; gcTaskResp.Err == OK { DPrintf(\u0026#34;[gcAction]-{Node: %v}-{Group: %v} deletes shards %v in remote group successfully when currentConfigNum is %v\u0026#34;, kv.me, kv.gid, shardIds, configNum) // 远端删除完数据之后,Raft 本节点同样需要 把GCing 状态恢复为Server 状态 kv.Execute(NewDeleteShardsCommand(\u0026amp;gcTaskReq), \u0026amp;CommandResponse{}) } } }(kv.lastConfig.Groups[gid], kv.currentConfig.Num, shardIds) } kv.mu.RUnlock() wg.Wait() } // RPC DeleteShardsData 删除迁移之后的 shard 中的数据 func (kv *ShardKV) DeleteShardsData(req *ShardOperationRequest, resp *ShardOperationResponse) { if _, isLeader := kv.rf.GetState(); !isLeader { resp.Err = ErrWrongLeader return } defer DPrintf(\u0026#34;[DeleteShardsData]-{Node: %v}-{Group: %v} processes GCTaskRequest %v with response %v\u0026#34;, kv.me, kv.gid, req, resp) kv.mu.RLock() if kv.currentConfig.Num \u0026gt; req.ConfigNum { DPrintf(\u0026#34;[DeleteShardsData]-{Node: %v}-{Group: %v} encounters duplicated shards deletions %v when currentConfig is %v\u0026#34;, kv.me, kv.gid, req, kv.currentConfig) resp.Err = OK kv.mu.RUnlock() return } kv.mu.RUnlock() var commandResp CommandResponse kv.Execute(NewDeleteShardsCommand(req), \u0026amp;commandResp) resp.Err = commandResp.Err } 空日志检测 分片清理协程负责定时检测 raft 层的 leader 是否拥有当前 term 的日志,如果没有则提交一条空日志,这使得新 leader 的状态机能够迅速达到最新状态,从而避免多 raft 组间的活锁状态。\n1 2 3 4 5 6 7 8 9 10 func (kv *ShardKV) checkEntryIncurrentTermAction() { if !kv.rf.HasLogInCurrentTerm() { kv.Execute(NewEmptyEntryCommand(), \u0026amp;CommandResponse{}) } } func (kv *ShardKV) applyEmptyEntry() *CommandResponse { return \u0026amp;CommandResponse{OK, \u0026#34;\u0026#34;} } 问题 出现随机的get(x) != expect(x)这种错误,并且发生情况在raft stop后,applyInsertShards之后,看起来像apply一个duplicated的msg,从而产生了get(x)=\u0026ldquo;xabb” != expect(x)=\u0026ldquo;xab” 这个分析了很久,并且重构了日志,让每个gid在一个logfile里。最后发现原因:raft重启后,会重新apply所有logs,这时config change,开始pull shards,待insertShards完成后。raft收到落后的command,导致又apply了duplicate的数据。\n解决方案,在applyInsertShards同时,将reply中的lastOperation来更新自己的session。因此,当下次applyMsg来的时候,就可以根据client的SequenceNum来判断 是否接受这个applyMsg,防止了duplicated的msg。\n","permalink":"https://reid00.github.io/en/posts/storage/20230214-mit6.824-2022-lab4-shardedkv/","summary":"ShardedKV 介绍 有关 shardkv,其可以算是一个 multi-raft 的实现,只是缺少了物理节点的抽象概念。在实际的生产系统中,不同 raft 组的成员可能存在于一个物理节点上,","title":"20230214 MIT6.824 2022 Lab4 ShardedKV"},{"content":"介绍 在lab2的Raft函数库之上,搭建一个能够容错的key/value存储服务,需要提供强一致性保证。\n强一致性介绍 对于单个请求,整个服务需要表现得像个单机服务,并且对状态机的修改基于之前所有的请求。对于并发的请求,返回的值和最终的状态必须相同,就好像所有请求都是串行的一样。即使有些请求发生在了同一时间,那么也应当一个一个响应。此外,在一个请求被执行之前,这之前的请求都必须已经被完成(在技术上我们也叫着线性化(linearizability))。 kv服务支持三种操作:Put, Append, Get。通过在内存维护一个简单的键/值对数据库,键和值都是字符串;\n整体架构 简化来看 在正式开始前,要了解论文-extend-version中section 7和8的内容。\n相关的RPC 在Raft 作者的博士论文中的6.3- Implementing linearizable semantics 小结有很详细的介绍,建议先阅读。\nRPC Lab3A - 不需要日志压缩的Key/Value服务 考虑这样一个场景,客户端向服务端提交了一条日志,服务端将其在 raft 组中进行了同步并成功 commit,接着在 apply 后返回给客户端执行结果。然而不幸的是,该 rpc 在传输中发生了丢失,客户端并没有收到写入成功的回复。因此,客户端只能进行重试直到明确地写入成功或失败为止,这就可能会导致相同地命令被执行多次,从而违背线性一致性。\n有人可能认为,只要写请求是幂等的,那重复执行多次也是可以满足线性一致性的,实际上则不然。考虑这样一个例子:对于一个仅支持 put 和 get 接口的 raftKV 系统,其每个请求都具有幂等性。设 x 的初始值为 0,此时有两个并发客户端,客户端 1 执行 put(x,1),客户端 2 执行 get(x) 再执行 put(x,2),问(客户端 2 读到的值,x 的最终值)是多少。对于线性一致的系统,答案可以是 (0,1),(0,2) 或 (1,2)。然而,如果客户端 1 执行 put 请求时发生了上段描述的情况,然后客户端 2 读到 x 的值为 1 并将 x 置为了 2,最后客户端 1 超时重试且再次将 x 置为 1。对于这种场景,答案是 (1,1),这就违背了线性一致性。归根究底还是由于幂等的 put(x,1) 请求在状态机上执行了两次,有两个 LZ 点。因此,即使写请求的业务语义能够保证幂等,不进行额外的处理让其重复执行多次也会破坏线性一致性。当然,读请求由于不改变系统的状态,重复执行多次是没问题的。\n对于这个问题,raft 作者介绍了想要实现线性化语义,就需要保证日志仅被执行一次,即它可以被 commit 多次,但一定只能 apply 一次。其解决方案原文如下:\nThe solution is for clients to assign unique serial numbers to every command. Then, the state machine tracks the latest serial number processed for each client, along with the associated response. If it receives a command whose serial number has already been executed, it responds immediately without re-executing the request.\n思路可以是:\n每个 client 都需要一个唯一的标识符,它的每个不同命令需要有一个顺序递增的 commandId,clientId 和这个 commandId,clientId 可以唯一确定一个不同的命令,从而使得各个 raft 节点可以记录保存各命令是否已应用以及应用以后的结果。 也可以参考此处dragonboat 作者讨论\n为什么要记录应用的结果?因为通过这种方式同一个命令的多次 apply 最终只会实际应用到状态机上一次,之后相同命令 apply 的时候实际上是不应用到状态机上的而是直接从保存的结果中返回的。\n如果默认一个客户端只能串行执行请求的话,服务端这边只需要记录一个 map,其 key 是 clientId,其 value 是该 clientId 执行的最后一条日志的 commandId 和状态机的输出即可CommandResponse。\n客户端 一个 client 可以通过为其处理的每条命令递增 commandId 的方式来确保不同的命令一定有不同的 commandId,当然,同一条命令的 commandId 在没有处理完毕之前,即明确收到服务端的写入成功或失败之前是不能改变的。\n代码如下:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 package kvraft import ( \u0026#34;crypto/rand\u0026#34; \u0026#34;math/big\u0026#34; \u0026#34;6.824/labrpc\u0026#34; ) type Clerk struct { servers []*labrpc.ClientEnd // You will have to modify this struct. leaderId int64 // generated by nrand(), it would be better to use some distributed ID // generation algorithm that guarantees no conflicts clientId int64 commandId int64 // (clientId, commandId) defines a operation uniquely } func nrand() int64 { max := big.NewInt(int64(1) \u0026lt;\u0026lt; 62) bigx, _ := rand.Int(rand.Reader, max) x := bigx.Int64() return x } func MakeClerk(servers []*labrpc.ClientEnd) *Clerk { return \u0026amp;Clerk{ servers: servers, leaderId: 0, clientId: nrand(), commandId: 0, } } // fetch the current value for a key. // returns \u0026#34;\u0026#34; if the key does not exist. // keeps trying forever in the face of all other errors. // // you can send an RPC with code like this: // ok := ck.servers[i].Call(\u0026#34;KVServer.Get\u0026#34;, \u0026amp;args, \u0026amp;reply) // // the types of args and reply (including whether they are pointers) // must match the declared types of the RPC handler function\u0026#39;s // arguments. and reply must be passed as a pointer. func (ck *Clerk) Get(key string) string { // You will have to modify this function. return ck.Command(\u0026amp;CommandRequest{ Key: key, Op: OpGet, ClientId: ck.clientId, CommandId: ck.commandId, }) } func (ck *Clerk) Put(key string, value string) { ck.Command(\u0026amp;CommandRequest{ Key: key, Value: value, Op: OpPut, ClientId: ck.clientId, CommandId: ck.commandId, }) } func (ck *Clerk) Append(key string, value string) { ck.Command(\u0026amp;CommandRequest{ Key: key, Value: value, Op: OpAppend, ClientId: ck.clientId, CommandId: ck.commandId, }) } func (ck *Clerk) Command(req *CommandRequest) string { // req.ClientId, req.CommandId = ck.clientId, ck.commandId for { var resp CommandResponse if !ck.servers[ck.leaderId].Call(\u0026#34;KVServer.Command\u0026#34;, req, \u0026amp;resp) || resp.Err == ErrWrongLeader || resp.Err == ErrTimeout { // 不知leader 轮询所有的server 尝试发出请求 ck.leaderId = (ck.leaderId + 1) % int64(len(ck.servers)) continue } ck.commandId++ return resp.Value } } 服务端 整体请求逻辑如下: Server结构体与初始化代码实现:\n一个存储kv的map,即状态机,但这里实现一个基于内存版本KV即可的,但实际生产环境下必然不可能把数据全部存在内存当中,系统往往采用的是 LSM 的架构,例如 RocksDB 等,抽象成KVStateMachine 的接口。 一个能记录某一个客户端最后一次操作序号和应用结果的map lastOperations (类比Nebula 中的session 作用) 一个能记录每个raft同步操作结果的map notifyChans 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 type KVServer struct { mu sync.RWMutex me int rf *raft.Raft applyCh chan raft.ApplyMsg dead int32 // set by Kill() maxRaftState int // snapshot if log grows this big // Your definitions here. lastApplied int // record the lastApplied index to prevent stateMachine from rollback stateMachine KVStateMachine // KV stateMachine // 客户端id最后的命令id和回复内容 (clientId,{最后的commdId,最后的LastReply}) lastOperations map[int64]OperationContext // Leader回复给客户端的响应(LogIndex, CommandResponse notifyChans map[int]chan *CommandResponse } 应用到状态机的流程 kv.applier协程:单独开一个goroutine来远程监听 Raft 的apply channel,一旦底层的Raft commit一个到apply channel,状态机就立马执行且通过 commandIndex(即raft 中的CommitIndex) 通知到该客户端的NotifyChan, Command函数取消阻塞返回给客户端。\n要点:\nraft同步完成后,也需要判断请求是否为重复请求。因为同一请求可能由于重试会被同步多次。 对于客户端的请求,rpc 框架也会生成一个协程去处理逻辑。因此,需要考虑清楚这些协程之间的通信关系。为此,我的实现是客户端协程将日志放入 raft 层去同步后即注册一个 channel 去阻塞等待,接着 apply 协程监控 applyCh,在得到 raft 层已经 commit 的日志后,apply 协程首先将其 apply 到状态机中,接着根据 index 得到对应的 channel ,最后将状态机执行的结果 push 到 channel 中,这使得客户端协程能够解除阻塞并回复结果给客户端 为了保证强一致性,仅对当前 term 日志的 notifyChan 进行通知,让之前 term 的客户端协程都超时重试。避免leader 降级为 follower 后又迅速重新当选了 leader,而此时依然有客户端协程未超时在阻塞等待,那么此时 apply 日志后,根据 index 获得 channel 并向其中 push 执行结果就可能出错,因为可能并不对应。 在目前的实现中,读(Get)请求也会生成一条 raft 日志去同步,最简单粗暴的方式保证线性一致性,即LogRead方法。但是,这样子实现的读性能会相当的差,实际生产级别的 raft 读请求实现一般都采用了 Read Index 或者 Lease Read 的方式,具体原理可以参考此线性一致性博客,具体实现可以参照 SOFAJRaft 的实现博客。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 func (kv *KVServer) applier() { for !kv.killed() { for msg := range kv.applyCh { DPrintf(\u0026#34;[applier] - {Node: %v} tries to apply message %v\u0026#34;, kv.rf.Me(), msg) if msg.CommandValid { kv.mu.Lock() if msg.CommandIndex \u0026lt;= kv.lastApplied { DPrintf(\u0026#34;[applier] - {Node: %v} discards outdated message %v since a newer snapshot which lastapplied is %v has been restored\u0026#34;, kv.rf.Me(), msg, kv.lastApplied) kv.mu.Unlock() continue } kv.lastApplied = msg.CommandIndex var resp = new(CommandResponse) command := msg.Command.(Command) if command.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicatedReq(command.ClientId, command.CommandId) { DPrintf(\u0026#34;[applier] - {Node: %v} doesn\u0026#39;t apply duplicated message %v to state machine since maxAppliedCommandId is %v for client %v\u0026#34;, kv.rf.Me(), msg, kv.lastOperations[command.ClientId], command.ClientId) resp = kv.lastOperations[command.ClientId].LastResponse } else { resp = kv.applyLogToStateMachine(command) if command.Op != OpGet { kv.lastOperations[command.ClientId] = OperationContext{ MaxAppliedCommandId: command.CommandId, LastResponse: resp, } } } // 记录每个idx apply 到state machine 的 CommandResponse // 为了保证强一致性,仅对当前 term 日志的 notifyChan 进行通知, // 让之前 term 的客户端协程都超时重试。避免leader 降级为 follower // 后又迅速重新当选了 leader,而此时依然有客户端协程未超时在阻塞等待, // 那么此时 apply 日志后,根据 index 获得 channel 并向其中 push 执行结果就可能出错,因为可能并不对应 if currentTerm, isLeader := kv.rf.GetState(); isLeader \u0026amp;\u0026amp; msg.CommandTerm == currentTerm { ch := kv.getNotifyChan(msg.CommandIndex) ch \u0026lt;- resp } // part 2 needSnapshot := kv.needSnapshot() if needSnapshot { kv.takeSnapshot(msg.CommandIndex) } kv.mu.Unlock() } else if msg.SnapshotValid { kv.mu.Lock() if kv.rf.CondInstallSnapshot(msg.SnapshotTerm, msg.SnapshotIndex, msg.Snapshot) { kv.restoreSnapshot(msg.Snapshot) kv.lastApplied = msg.SnapshotIndex } kv.mu.Unlock() } else { panic(fmt.Sprintf(\u0026#34;unexpected Message: %v\u0026#34;, msg)) } } } } leader 比 follower 多出一个 notifyChan 环节,是因为 leader 需要处理 rpc 请求响应,而 follower 不用,一个很简单的流程其实就是 client -\u0026gt; kvservice -\u0026gt; Start() -\u0026gt; applyCh -\u0026gt; kvservice -\u0026gt; client,但是applyCh是逐个 commit 一个一个返回,所以需要明确返回的 commit 对应的是哪一个请求,即通过 commitIndex唯一确定一个请求,然后通知该请求执行流程可以返回了。\n对于读请求,由于其不影响系统状态,所以直接去状态机执行即可,当然,其结果也不需要再记录到去重的数据结构中。\nCommandRPC 逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // Command 客户端调用的RPC方法 func (kv *KVServer) Command(req *CommandRequest, resp *CommandResponse) { defer DPrintf(\u0026#34;[Command]- {Node: %v} processes CommandReq %v with CommandResp %v\u0026#34;, kv.rf.Me(), req, resp) // 如果请求是重复的,直接在 OperationContext 中拿到之前的结果返回 kv.mu.RLock() if req.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicatedReq(req.ClientId, req.CommandId) { lastResp := kv.lastOperations[req.ClientId].LastResponse resp.Value, resp.Err = lastResp.Value, lastResp.Err kv.mu.RUnlock() return } kv.mu.RUnlock() idx, _, isLeader := kv.rf.Start(Command{req}) if !isLeader { resp.Err = ErrWrongLeader return } kv.mu.Lock() ch := kv.getNotifyChan(idx) kv.mu.Unlock() select { case result := \u0026lt;-ch: resp.Value, resp.Err = result.Value, result.Err case \u0026lt;-time.After(ExecuteTimeOut): resp.Err = ErrTimeout } go func() { kv.mu.Lock() kv.removeOutdatedNotifyChan(idx) kv.mu.Unlock() }() } Lab3B - 日志压缩 首先,日志的 snapshot 不仅需要包含状态机的状态,还需要包含用来去重的 lastOperations 哈希表。\n其次,apply 协程负责持锁阻塞式的去生成 snapshot,幸运的是,此时 raft 框架是不阻塞的,依然可以同步并提交日志,只是不 apply 而已。如果这里还想进一步优化的话,可以将状态机搞成 MVCC 等能够 COW 的机制,这样应该就可以不阻塞状态机的更新了\n优化: 项目中 LastOperations 和 NotifyChan 都是使用map 不能并发安全,用了一张大锁保平安。 实际上可以使用Sync.Map 然后将锁的粒度细化来优化这块\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-lab3-raftkv/","summary":"介绍 在lab2的Raft函数库之上,搭建一个能够容错的key/value存储服务,需要提供强一致性保证。 强一致性介绍 对于单个请求,整个服务需","title":"MIT6.824 2022 Lab3 RaftKV"},{"content":"介绍 linearizable read 简单的说就是不返回 stale 数据,具体可以参考Strong consistency models\nRead Index 机制就是 Leader 在收到读请求时进行如下几步:\n如果 Leader 在当前任期还没有提交过日志,先提交一条空日志 Leader 保存记录当前 commit index 作为 readIndex 通过心跳,询问成员自己还是不是 Leader,如果收到过半的确认,则可确信自己仍是 Leader 等待 Apply Index 超过 readIndex 读取数据,响应 Client etcd不仅实现了leader上的read only query,同时也实现了follower上的read only query,原理是一样的,只不过读请求到达follower时,commit index是需要向leader去要的,leader返回commit index给follower之前,同样,需要走上面的ReadIndex流程,因为leader同样需要check自己到底还是不是leader\nReadIndex 思路 在论文中 第八节,page13 有提到过大概思路:\nRead-only operations can be handled without writing anything into the log. However, with no additional measures, this would run the risk of returning stale data, since the leader responding to the request might have been superseded by a newer leader of which it is unaware. Linearizable reads must not return stale data, and Raft needs two extra precautions to guarantee this without using the log. First, a leader must have the latest information on which entries are committed. The Leader Completeness Property guarantees that a leader has all committed entries, but at the start of its term, it may not know which those are. To find out, it needs to commit an entry from its term. Raft handles this by having each leader commit a blank no-op entry into the log at the start of its term. Second, a leader must check whether it has been deposed before processing a read-only request (its information may be stale if a more recent leader has been elected). Raft handles this by having the leader exchange heartbeat messages with a majority of the cluster before responding to read-only requests. Alternatively, the leader could rely on the heartbeat mechanism to provide a form of lease [9], but this would rely on timing for safety (it assumes bounded clock skew).\n在收到读请求时,leader 节点保存下当前的 commit index,并往 peers 发送心跳。如果确定该节点依然是 leader,则只需要等到该 commit index 的 log entry 被 apply 到状态机时就可以返回客户端结果。\nLeaseRead 思路 LeaseRead 与 ReadIndex 类似,但更进一步,不仅省去了 Log,还省去了网络交互。它可以大幅提升读的吞吐也能显著降低延时。基本的思路是 Leader 取一个比 Election Timeout 小的租期,在租期不会发生选举,确保 Leader 不会变,所以可以跳过 ReadIndex 的第二步,也就降低了延时。 LeaseRead 的正确性和时间挂钩,因此时间的实现至关重要,如果漂移严重,这套机制就会有问题。\nWait Free 到此为止 Lease 省去了 ReadIndex 的第二步,实际能再进一步,省去第 3 步。这样的 LeaseRead 在收到请求后会立刻进行读请求,不取 commit index 也不等状态机。由于 Raft 的强 Leader 特性,在租期内的 Client 收到的 Resp 由 Leader 的状态机产生,所以只要状态机满足线性一致,那么在 Lease 内,不管何时发生读都能满足线性一致性。有一点需要注意,只有在 Leader 的状态机应用了当前 term 的第一个 Log 后才能进行 LeaseRead。因为新选举产生的 Leader,它虽然有全部 committed Log,但它的状态机可能落后于之前的 Leader,状态机应用到当前 term 的 Log 就保证了新 Leader 的状态机一定新于旧 Leader,之后肯定不会出现 stale read。 可以参考post\nEtcd 源码实现 先留个坑,暂时没时间,后续有时间再分析。\n","permalink":"https://reid00.github.io/en/posts/storage/raft-etcd-%E4%B9%8B-linearizable-read/","summary":"介绍 linearizable read 简单的说就是不返回 stale 数据,具体可以参考Strong consistency models Read Index 机制就是 Leader 在收到读请求时进行如下几步: 如果 Leader 在当前任期还没有提交过日志,先","title":"Raft Etcd 之 Linearizable Read"},{"content":"如果允许提交之前任期的日志,将导致什么问题? 我们将论文中的上图展开:\n(a): S1 是leader,将黄色的日志2同步到了S2,然后S1崩溃。 (b): S5 在任期 3 里通过 S3、S4 和自己的选票赢得选举,将蓝色日志3存储到本地,然后崩溃了。 (c): S1重新启动,选举成功。注意在这时,如果允许提交之前任期的日志,将首先开始同步过往任期的日志,即将S1上的本地黄色的日志2同步到了S3。这时黄色的节点2已经同步到了集群多数节点,然后S1写了一条新日志4,然后S1又崩溃了。 接下来会出现两种不同的情况: (d1): S5重新当选,如果允许提交之前任期的日志,就开始同步往期日志,将本地的蓝色日志3同步到所有的节点。结果已经被同步到半数以上节点的黄色日志2被覆盖了。这说明,如果允许“提交之前任期的日志”,会可能出现即便已经同步到半数以上节点的日志被覆盖,这是不允许的。 (d2): 反之,如果在崩溃之前,S1不去同步往期的日志,而是首先同步自己任期内的日志4到所有节点,就不会导致黄色日志2被覆盖。因为leader同步日志的流程中,会通过不断的向后重试的方式,将日志同步到其他所有follower,只要日志4被复制成功,在它之前的日志2就会被复制成功。(d2)是想说明:不能直接提交过往任期的日志,即便已经被多数通过,但是可以先同步一条自己任内的日志,如果这条日志通过,就能带着前面的日志一起通过,这是(c)和(d2)两个图的区别。图(c)中,S1先去提交过往任期的日志2,图(d2)中,S1先去提交自己任内的日志4。 假如 s1 提交的话,则 index 为 2,term 为 2 的 entry 就被应用到状态机中了,是不可改变了,此时 s1 如果挂了,来到 term5,s5 是可以被选为 leader 的,因为按照之前的 log 比对策略来说,s5 的最后一个 log 的 term 是 3 比 s2 s3 s4 的最后一个 log 的 term 都大。一旦 s5 被选举为 leader,即 d 场景,s5 会复制 index 为 2,term 为 3 的 entry 到上述机器上,这时候就会造成之前 s1 已经提交的 index 为 2 的位置被重新覆盖,因此违背了一致性。\n假如 s1 不提交,而是等到 term4 中有过半的 entry 了,然后再将之前的 term 的 entry 一起提交(这就是所谓的间接提交,即使满足过半,但是必须要等到当前 term 中有过半的 entry 才能跟着一起提交),即处于 e 场景,s1 此时挂的话,s5 就不能被选为 leader 了,因为 s2 s3 的最后一个 log 的 term 为 4 比 s5 的 3 大,所以 s5 获取不到投票,进而 s5 就不可能去覆盖上述的提交\n我们可以看到的是,如果允许提交之前任期的日志这么做,那么:\n(c)中, S1恢复之后,又再次提交在任期2中的黄色日志2。但是,从后面可以看到,即便这个之前任期中的黄色日志2,提交到大部分节点,如果允许提交之前任期的日志,仍然存在被覆盖的可能性,因为: (d1)中,S5恢复之后,也会提交在自己本地上保存的之前任期3的蓝色日志,这会导致覆盖了前面已经到半数以上节点的黄色日志2。 所以,如果允许提交之前任期的日志,即如同(c)和(d1)演示的那样:重新当选之后,马上提交自己本地保存的、之前任期的日志,就会可能导致即便已经同步到半数以上节点的日志,被覆盖的情况。\n而已同步到半数以上节点的日志,一定在新当选leader上(否则这个节点不可能成为新leader)且达成了一致可提交,即不允许被覆盖。\n这就是矛盾的地方,即允许提交之前任期的日志,最终导致了违反协议规则的情况。\n那么,如何确保新当选的leader节点,其本地的未提交日志被正确提交呢?图(d2)展示了正常的情况:即当选之后,不要首先提交本地已有的黄色日志2,而是首先提交一条新日志4,如果这条新日志被提交成功,那么按照Raft日志的匹配规则(log matching property):日志4如果能提交,它前面的日志也提交了。\n可是,新的问题又出现了,如果在(d2)中,S1重新当选之后,客户端写入没有这条新的日志4,那么前面的日志2是不是永远无法提交了?为了解决这个问题,raft要求每个leader新当选之后,马上写入一条只有任期号和索引、而没有内容的所谓“no-op”日志,以这条日志来驱动在它之前的日志达成一致。\n这就是论文中这部分内容想要表达的。这部分内容之所以比较难理解,是因为经常忽略了这个图示展示的是错误的情况,允许提交之前任期的日志可能导致的问题。\n(c)和(d2) 有什么区别? 看起来,(c)和(d2)一样,S1当选后都提交了日志1、2、4,那么两者的区别在哪里? 虽然两个场景中,提交的日志都是一样的,但是日志达成一致的顺序并不一致:\n(c):S1成为leader之后,先提交过往任期、本地的日志2,再提交日志4。这就是提交之前任期日志的情况。 (d2):S1成为leader之后,先提交本次任期的日志4,如果日志4能提交成功,那么它前面的日志2就能提交成功了。 关于(d2)的这个场景,有可能又存在着下一个疑问: 如何理解(d2)中,“本任期的日志4提交成功,那么它前面的日志2也能提交成功了”?\n这是由raft日志的Log Matching Property决定的:\nIf two entries in different logs have the same index and term, then they store the same command. If two entries in different logs have the same index and term, then the logs are identical in all preceding entries.\nIf two entries in different logs have the same index and term, then the logs are identical in all preceding entries\n第一条性质,说明的是在不同节点上的已提交的日志,如果任期号、索引一样,那么它们的内容肯定一样。这是由leader节点的安全性和leader上的日志只能添加不能覆盖来保证的,这样leader就永远不会在同一个任期,创建两个相同索引的日志。\n第二条性质,说明的是在不同节点上的日志中,如果其中有同样的一条日志(即相同任期和索引)已经达成了一致,那么在这不同节点上在这条日志之前的所有日志都是一样的。\n第二条性质是由leader节点向follower节点上根据AppendEntries消息同步日志上保证的。leader在AppendEntries消息中会携带新添加entries之前日志的term和index 即PrevLogTerm, PrevLogIndex,follower会判断在log中是否存在拥有此term和index的消息,如果没有就会拒绝。\nleader为每一个follower维护一个nextIndex,表示待发送的下一个日志的index。初始化为日志长度。 leader在follower拒绝AppendEntries之后会对nextIndex减一,然后继续重试AppendEntries直到两者一致。 于是,回到我们开始的问题,(d2)场景中,在添加本任期日志4的时候,会发现有一些节点上并不存在过往任期的日志2,这时候就会相应地计算不同节点的nextIndex索引,来驱动同步日志2到这些节点上。\n总而言之,根据日志的性质,只要本任期的日志4能达成一致,上一条日志2就能达成一致。\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-%E4%B8%BA%E4%BB%80%E4%B9%88raft%E5%8D%8F%E8%AE%AE%E4%B8%8D%E8%83%BD%E6%8F%90%E4%BA%A4%E4%B9%8B%E5%89%8D%E4%BB%BB%E6%9C%9F%E7%9A%84%E6%97%A5%E5%BF%97/","summary":"如果允许提交之前任期的日志,将导致什么问题? 我们将论文中的上图展开: (a): S1 是leader,将黄色的日志2同步到了S2,然后S1崩溃。 (b): S5 在任期","title":"MIT6.824 2022 Raft 为什么Raft协议不能提交之前任期的日志"},{"content":"Mulit Raft Group 通过对 Raft 协议的描述我们知道:用户在对一组 Raft 系统进行更新操作时必须先经过 Leader,再由 Leader 同步给大多数 Follower。而在实际运用中,一组 Raft 的 Leader 往往存在单点的流量瓶颈,流量高便无法承载,同时每个节点都是全量数据,所以会受到节点的存储限制而导致容量瓶颈,无法扩展。\nMulit Raft Group 正是通过把整个数据从横向做切分,分为多个 Region 来解决磁盘瓶颈,然后每个 Region 都对应有独立的 Leader 和一个或多个 Follower 的 Raft 组进行横向扩展,此时系统便有多个写入的节点,从而分担写入压力,图如下: 具体细节可以参考TiKV 的文章\nMulti-Raft需要解决的一些核心问题: 数据何如分片 分片中的数据越来越大,需要分裂产生更多的分片,组成更多Raft-Group 分片的调度,让负载在系统中更平均(分片副本的迁移,补全,Leader切换等等) 一个节点上,所有的Raft-Group复用链接(否则Raft副本之间两两建链,链接爆炸了) 如何处理stale的请求(例如Proposal和Apply的时候,当前的副本不是Leader、分裂了、被销毁了等等) Snapshot如何管理(限制Snapshot,避免带宽、CPU、IO资源被过度占用) 数据何如分片 通常的数据分片算法就是 Hash 和 Range,TiKV 使用的 Range 来对数据进行数据分片。为什么使用 Range,主要原因是能更好的将相同前缀的 key 聚合在一起,便于 scan 等操作,这个 Hash 是没法支持的,当然,在 split/merge 上面 Range 也比 Hash 好处理很多,很多时候只会涉及到元信息的修改,都不用大范围的挪动数据。\n当然,Range 有一个问题在于很有可能某一个 Region 会因为频繁的操作成为性能热点,当然也有一些优化的方式,譬如通过 PD 将这些 Region 调度到更好的机器上面,提供 Follower 分担读压力等。\n总之,在 TiKV 里面,我们使用 Range 来对数据进行切分,将其分成一个一个的 Raft Group,每一个 Raft Group,我们使用 Region 来表示。\n分片如何调度 Elasticell实现细节 作为参考: 这部分的思路就和TiKV完全一致了。PD负责调度指令的下发,PD通过心跳收集调度需要的数据,这些数据包括:节点上的分片的个数,分片中leader的个数,节点的存储空间,剩余存储空间等等。一些最基本的调度:\nPD发现分片的副本数目缺少了,寻找一个合适的节点,把副本补全 PD发现系统中节点之间的分片数相差较多,就会转移一些分片的副本,保持系统中所有节点的分片数目大致相同(存储均衡) PD发现系统中节点之间分片的Leader数目不太一致,就会转移一些副本的Leader,保持系统中所有节点的分片副本的Leader数目大致相同(读写请求均衡) 新的分片如何形成Raft-Group 假设这个分片1有三个副本分别运行在Node1,Node2,Node3三台机器上,其中Node1机器上的副本是Leader,分片的大小限制是1GB。\n当分片1管理的数据量超过1GB的时候,分片1就会分裂成2个分片,分裂后,分片1修改数据范围,更新Epoch,继续服务。\n分片2形也有三个副本,分别也在Node1,Node2,Node3上,这些是元信息,但是只有在Node1上存在真正被创建的副本实例,Node2,Node3并不知道这个信息。这个时候Node1上的副本会立即进行Campaign Leader的操作,这个时候,Node2和Node3会收到来自分片2的Vote的Raft消息(整个描述指的是Leader Election),Node2,Node3发现分片2在自己的节点上并没有副本,那么就会检查这个消息的合法性和正确性,通过后,立即创建分片2的副本,刚创建的副本没有任何数据,创建完成后会响应这个Vote消息,也一定会选择Node1的副本为Leader,选举完成后,Node1的分片2的Leader会给Node2,Node3的副本直接发送Snapshot,最终这个新的Raft-Group形成并且对外服务。\n按照Raft的协议,分片2在Node1 的副本成为Leader后不应该直接给Node2,Node3发送snapshot,但是这里我们沿用了TiKV的设计,Raft初始化的Log Index是5,那么按照Raft协议,Node1上的副本需要给Node2,Node3发送AppendEntries,这个时候Node1上的副本发现Log Index小于5的Raft Log不存在,所以就会转为直接发送Snapshot。\nSnapshot如何管理 我们的底层存储引擎使用的是RocksDB,这是一个LSM的实现,支持对一个范围的数据进行Snapshot和Apply Snapshot,我们基于这个特性来做。Raft中有一个RPC用于发送Snapshot数据,但是如果把所有的数据放在这个RPC里面,那么会有很多问题:\n一个RPC的数据量太大(取决于一个分片管理的数据,可能上GB,内存吃不消) 如果失败,整体重试代价太大 难以流控 我们修改为这样:\nRaft的snapshot RPC中的数据存放,snapshot文件的元信息(包括分片的ID,当前Raft的Term,Index,Epoch等信息) 发送Raft snapshot的RPC后,异步发送具体数据文件 数据文件分Chunk发送,重试的代价小 发送 Chunk的链接和Raft RPC的链接不复用 限制并行发送的Chunk个数,避免snapshot文件发送影响正常的Raft RPC 接收Raft snapshot的分片副本阻塞,直到接收完毕完整的snapshot数据文件 如何处理stale的请求 由于分片的副本会被调度(转移,销毁),分片自身也会分裂(分裂后分片所管理的数据范围发生了变化),所以在Raft的Proposal和Apply的时候,我们需要检查Stale请求,如何做呢?其实还是蛮简单的,TiKV使用Epoch的概念,我们沿用了下来。一个分片的副本有2个Epoch,一个在分片的副本成员发生变化的时候递增,一个在分片数据范围发生变化的时候递增,在请求到来的时候记录当前的Epoch,在Proposal和Apply的阶段检查Epoch,让客户端重试Stale的请求。\n","permalink":"https://reid00.github.io/en/posts/storage/multi-raft/","summary":"Mulit Raft Group 通过对 Raft 协议的描述我们知道:用户在对一组 Raft 系统进行更新操作时必须先经过 Leader,再由 Leader 同步给大多数 Follower。而在实际运用中","title":"Multi Raft"},{"content":"介绍 对Raft Figure2 中需要持久化的字段进行保存。\n完成persist()和readPersist()函数,编码方式参照注释 优化nextIndex[]回退方式,否则无法通过所有测试 提示:\n需要持久化的部分包括currentTerm、votedFor、log。 有关nextIndex[]回退优化 逻辑如下: 若 follower 没有 prevLogIndex 处的日志,则直接置 conflictIndex = len(log),conflictTerm = None; leader 收到返回体后,肯定找不到对应的 term,则设置nextIndex = conflictIndex; 其实就是 leader 对应的 nextIndex 直接回退到该 follower 的日志条目末尾处,因为 prevLogIndex 超前了 若 follower 有 prevLogIndex 处的日志,但是 term 不匹配;则设置 conlictTerm为 prevLogIndex 处的 term,且肯定可以找到日志中该 term出现的第一个日志条目的下标,并置conflictIndex = firstIndexWithTerm; leader 收到返回体后,有可能找不到对应的 term,即 leader 和 follower 在conflictIndex处以及之后的日志都有冲突,都不能要了,直接置nextIndex = conflictIndex 若找到了对应的term,则找到对应term出现的最后一个日志条目的下一个日志条目,即置nextIndex = lastIndexWithTerm+1;这里其实是默认了若 leader 和 follower 同时拥有该 term 的日志,则不会有冲突,直接取下一个 term 作为日志发起就好,是源自于 5.4 safety 的安全性保证 如果还有冲突,leader 和 follower 会一直根据以上规则回溯 nextIndex\n持久化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 func (rf *Raft) persist() { // Your code here (2C). // Example: // w := new(bytes.Buffer) // e := labgob.NewEncoder(w) // e.Encode(rf.xxx) // e.Encode(rf.yyy) // data := w.Bytes() // rf.persister.SaveRaftState(data) rf.persister.SaveRaftState(rf.encodeState()) } func (rf *Raft) encodeState() []byte { buf := new(bytes.Buffer) enc := labgob.NewEncoder(buf) // figure2 Persistent state on all servers enc.Encode(rf.currentTerm) enc.Encode(rf.votedFor) enc.Encode(rf.logs) return buf.Bytes() } // restore previously persisted state. func (rf *Raft) readPersist(data []byte) { if len(data) \u0026lt; 1 { return } buf := bytes.NewBuffer(data) dec := labgob.NewDecoder(buf) var currentTerm, votedFor int var logs []Entry if dec.Decode(\u0026amp;currentTerm) != nil || dec.Decode(\u0026amp;votedFor) != nil || dec.Decode(\u0026amp;logs) != nil { DPrintf(\u0026#34;[readPersist] - {Node: %v} restore persisted data failed\u0026#34;, rf.me) } rf.currentTerm, rf.votedFor, rf.logs = currentTerm, votedFor, logs rf.lastApplied, rf.commitIndex = rf.logs[0].Index, rf.logs[0].Index // Your code here (2C). // Example: // r := bytes.NewBuffer(data) // d := labgob.NewDecoder(r) // var xxx // var yyy // if d.Decode(\u0026amp;xxx) != nil || // d.Decode(\u0026amp;yyy) != nil { // error... // } else { // rf.xxx = xxx // rf.yyy = yyy // } } nextIndex 优化 Lab 2B 中对于失败的AppendEntries请求,让nextIndex自减,这样效率是比较慢的。方法上可以以Term 为单位返回,不在一个一个Index 自减。 这需要添加 ConflicTerm, ConflictIndex 字段 去记录出现冲突的位置和任期。然后在 HanleAppendEntries RPC 中,在 Leader 的log 中检查 ConflictIndex 位置的日志一致性。\n优化点1 如果follower.log不存在prevLog,让Leader下一次从follower.log的末尾开始同步日志。 优化点2 如果是因为prevLog.Term不匹配,记follower.prevLog.Term为conflictTerm。 如果leader.log找不到Term为conflictTerm的日志,则下一次从follower.log中conflictTerm的第一个log的位置开始同步日志。 如果leader.log找到了Term为conflictTerm的日志,则下一次从leader.log中conflictTerm的最后一个log的下一个位置开始同步日志。 nextIndex的正确位置可能依旧需要多次RPC才能找到,改进的流程只是加快了找到正确nextIndex的速度。\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2c-log-compaction/","summary":"介绍 对Raft Figure2 中需要持久化的字段进行保存。 完成persist()和readPersist()函数,编码方式参照注释 优化nextIndex[","title":"MIT6.824 2022 Raft Lab2C Log Compaction"},{"content":"介绍 snapshot是状态机某一时刻的副本,具体格式依赖存储引擎的实现,比如说:B+树、LSM、哈希表等,6.824是实现一个键值数据库,所以我们采用的是哈希表,在Lab 3可以看到实现。\nraft通过日志来实现多副本的数据一致,但是日志会不断膨胀,带来两个缺点:数据量大、恢复时间长,因此需要定期压缩一下,生成snapshot。\n快照由上层应用触发。当上层应用认为可以将一些已提交的 entry 压缩成 snapshot 时,其会调用节点的 Snapshot()函数,将需要压缩的状态机的状态数据传递给节点,作为快照。\n在正常情况下,仅由上层应用命令节点进行快照即可。但如果节点出现落后或者崩溃,情况则变得更加复杂。考虑一个日志非常落后的节点 i,当 Leader 向其发送 AppendEntries RPC 时,nextIndex[i] 对应的 entry 已被丢弃,压缩在快照中。这种情况下, Leader 就无法对其进行 AppendEntries。取而代之的是,这里我们应该实现一个新的 InstallSnapshot RPC,将 Leader 当前的快照直接发送给非常落后的 Follower。\n何时快照?\n服务端触发的日志压缩:上层应用发送快照数据给Raft实例。 leader 发送来的 InstallSnapshot:领导者发送快照RPC请求给追随者。当raft收到其他节点的压缩请求后,先把请求上报给上层应用,然后上层应用调用rf.CondInstallSnapshot()来决定是否安装快照 流程梳理 快照是状态机中的概念,需要在状态机中加载快照,因此要通过applyCh将快照发送给状态机,但是发送后Raft并不立即保存快照,而是等待状态机调用 CondInstallSnapshot(),如果从收到InstallSnapshot()后到收到CondInstallSnapshot()前,没有新的日志提交到状态机,则Raft返回True,Raft和状态机保存快照,否则Raft返回False,两者都不保存快照。\n如此保证了Raft和状态机保存快照是一个原子操作(SaveStateAndSnapshot)。当然在InstallSnapshot()将快照发送给状态机后再将快照保存到Raft,令CondInstallSnap()永远返回True,也可以保证原子操作,但是这样做必须等待快照发送给状态机完成,但是rf.applyCh \u0026lt;- ApplyMsg是有可能阻塞的,由于InstallSnapshot()需要持有全局的互斥锁,这可能导致整个节点无法工作。\n服务端触发的日志压缩: 上层应用发送快照数据给Raft实例。 leader 发送来的 InstallSnapshot: Leader发送快照RPC请求给Follower。当raft收到其他节点的压缩请求后,先把请求上报给上层应用,然后上层应用调用rf.CondInstallSnapshot()来决定是否安装快照(SaveStateAndSnapshot) 相关函数解析 服务端触发的Log Compact func (rf *Raft) Snapshot(index int, snapshot []byte) 应用程序将index(包括)之前的所有日志都打包为了快照,即参数snapshot [] byte。那么对于Raft要做的就是,将打包为快照的日志直接删除,并且要将快照保存起来,因为将来可能会发现某些节点大幅度落后于leader的日志,那么leader就直接发送快照给它,让他的日志“跟上来”。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func (rf *Raft) Snapshot(index int, snapshot []byte) { // Your code here (2D). rf.mu.Lock() defer rf.mu.Unlock() lastSnapshotIndex := rf.getFirstLog().Index // 当前节点的firstLogIndex 比要添加的Snapshot LastIncludedIndex 大,说明已经存在了Snapshot 包含了更多的log if index \u0026lt;= lastSnapshotIndex { DPrintf(\u0026#34;[Snapshot] - {Node %v} rejects replacing log with snapshotIndex %v as current lastSnapshotIndex %v is larger in term %v\u0026#34;, rf.me, index, lastSnapshotIndex, rf.currentTerm) return } // 新的日志索引包含了 LastIncludedIndex 这个位置,因为要把它作为dummpy index rf.logs = shrinkEntriesArray(rf.logs[index-lastSnapshotIndex:]) rf.logs[0].Command = nil rf.persister.SaveStateAndSnapshot(rf.encodeState(), snapshot) DPrintf(\u0026#34;[Snapshot] - {Node: %v}\u0026#39;s state is {state %v, term %v, commitIndex %v, lastApplied %v, firstLog %v, lastLogLog %v} after replacing log with snapshotIndex %v as lastSnapshotIndex %v is smaller\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), index, lastSnapshotIndex) } 由 Leader 发送来的 InstallSnapshot func (rf *Raft) InstallSnapshot(req *InstallSnapshotReq, resp *InstallSnapshotResp)\n对于 leader 发过来的 InstallSnapshot,只需要判断 term 是否正确,如果无误则 follower 只能无条件接受。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 func (rf *Raft) InstallSnapshot(req *InstallSnapshotReq, resp *InstallSnapshotResp) { rf.mu.Lock() defer rf.mu.Unlock() defer DPrintf(\u0026#34;[InstallSnapshot] - {Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} before processing InstallSnapshotRequest %v and reply InstallSnapshotResponse %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) resp.Term = rf.currentTerm if req.Term \u0026lt; rf.currentTerm { return } if req.Term \u0026gt; rf.currentTerm { rf.currentTerm, rf.votedFor = req.Term, -1 rf.persist() } rf.ChangeState(StateFollower) rf.electionTimer.Reset(RandomizedElectionTimeout()) // outdated snapshot // snapshot 的 lastIncludedIndex 小于等于本地的 commitIndex, // 那说明本地已经包含了该 snapshot 所有的数据信息,尽管可能状态机还没有这个 snapshot 新, // 即 lastApplied 还没更新到 commitIndex,但是 applier 协程也一定尝试在 apply 了, // 此时便没必要再去用 snapshot 更换状态机了。对于更新的 snapshot,这里通过异步的方式将其 // push 到 applyCh 中。 if req.LastIncludedIndex \u0026lt;= rf.commitIndex { return } go func() { rf.applyCh \u0026lt;- ApplyMsg{ SnapshotValid: true, Snapshot: req.Data, SnapshotTerm: req.LastIncludedTerm, SnapshotIndex: req.LastIncludedIndex, } }() } Follower 收到 InstallSnapshot RPC 后 func (rf *Raft) CondInstallSnapshot(lastIncludedTerm int, lastIncludedIndex int, snapshot []byte) bool\nFollower接收到snapshot后不能够立刻应用并截断日志,raft和状态机都需要应用snapshot,这需要考虑原子性。如果raft应用成功但状态机应用snapshot失败,那么在接下来的时间里客户端读到的数据是不完整的。如果状态机应用snapshot成功但raft应用失败,那么raft会要求重传,状态机应用成功也没啥意义。因此CondInstallSnapshot是异步于raft的,并由应用层调用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 func (rf *Raft) CondInstallSnapshot(lastIncludedTerm int, lastIncludedIndex int, snapshot []byte) bool { // Your code here (2D). rf.mu.Lock() defer rf.mu.Unlock() DPrintf(\u0026#34;[CondInstallSnapshot] - {Node %v} service calls CondInstallSnapshot with lastIncludedTerm %v and lastIncludedIndex %v to check whether snapshot is still valid in term %v\u0026#34;, rf.me, lastIncludedTerm, lastIncludedIndex, rf.currentTerm) // outdated snapshot if lastIncludedIndex \u0026lt;= rf.commitIndex { DPrintf(\u0026#34;[CondInstallSnapshot] - {Node %v} rejects the snapshot which lastIncludedIndex is %v because commitIndex %v is larger\u0026#34;, rf.me, lastIncludedIndex, rf.commitIndex) return false } if lastIncludedIndex \u0026gt; rf.getLastLog().Index { rf.logs = make([]Entry, 1) } else { rf.logs = shrinkEntriesArray(rf.logs[lastIncludedIndex-rf.getFirstLog().Index:]) rf.logs[0].Command = nil } rf.logs[0].Term, rf.logs[0].Index = lastIncludedTerm, lastIncludedIndex rf.lastApplied, rf.commitIndex = lastIncludedIndex, lastIncludedIndex rf.persister.SaveStateAndSnapshot(rf.encodeState(), snapshot) DPrintf(\u0026#34;[CondInstallSnapshot] - {Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} after accepting the snapshot which lastIncludedTerm is %v, lastIncludedIndex is %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), lastIncludedTerm, lastIncludedIndex) return true } 假设有一个节点一直是 crash 的,然后复活了,leader 发现其落后的太多,于是发送 InstallSnapshot() RPC 到落后的节点上面。落后节点收到 InstallSnapshot() 中的 snapshot 后,通过 rf.applyCh 发送给上层 service 。上层的 service 收到 snapshot 时,调用节点的 CondInstallSnapshot() 方法。节点如果在该 snapshot 之后有新的 commit,则拒绝安装此 snapshot CondInstallSnapshot 中的 lastIncludedIndex \u0026lt;= rf.commitIndex,service 也会放弃本次安装。反之如果在该 snapshot 之后没有新的 commit,那么节点会安装此 snapshot 并返回 true,service 收到后也同步安装。 在实验大纲中指出不能直接通过rf.logs[idx:] 的方式去做日志的截取保存,防止GC 不能及时回收。\nRaft must discard old log entries in a way that allows the Go garbage collector to free and re-use the memory; this requires that there be no reachable references (pointers) to the discarded log entries.\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2d-log-persistence/","summary":"介绍 snapshot是状态机某一时刻的副本,具体格式依赖存储引擎的实现,比如说:B+树、LSM、哈希表等,6.824是实现一个键值数据库,所","title":"MIT6.824 2022 Raft Lab2D Log Persistence"},{"content":"前言 论文 博士论文 博士论文翻译 官网 动画展示 Students\u0026rsquo; Guide to Raft (重要) MIT6.824 本篇是实验的前言, 先对论文里面提到的RPC做个大概的梳理和介绍。 Raft 原理可以参考这篇Raft\nFigure2 Raft 实现的核心在这个图,想要正确实现Raft 必须对这个图有深刻理解,在这里我们对图上的各个RPC 进行介绍和阐述。\nState Persistent state for all servers 所有Raft 节点都需要维护的持久化状态: currentTerm: 此节点当前的任期。保证重启后任期不丢失。启动时初始值为0(无意义状态),单调递增 (Lab 2A) votedFor: 当前任期内,此节点将选票给了谁。 一个任期内,节点只能将选票投给某个节点。需要持久化,从而避免节点重启后重复投票。(Lab 2A) logs: 日志条目, 每条 Entry 包含一条待施加至状态机的命令。Entry 也要记录其被发送至 Leader 时,Leader 当时的任期。Lab2B 中,在内存存储日志即可,不用担心 server 会 down 掉,测试中仅会模拟网络挂掉的情景。初始Index从1开始,0为dummy index。 为什么 currentTerm 和 votedFor 需要持久化?\nvotedFor 保证每个任期最多只有一个Leader!\n考虑如下一种场景: 因为在Raft协议中每个任期内有且仅有一个Leader。现假设有几个Raft节点在当前任期下投票给了Raft节点A,并且Raft A顺利成为了Leader。现故障系统被重启,重启后如果收到一个相同任期的Raft节点B的投票请求,由于每个节点并没有记录其投票状态,那么这些节点就有可能投票给Raft B,并使B成为Leader。此时,在同一个任期内就会存在两个Leader,与Raft的要求不符。\n保证每个Index位置只会有一个Term! (也等价于每个任期内最多有一个Leader)\n在这里例子中,S1关机了,S2和S3会尝试选举一个新的Leader。它们需要证据证明,正确的任期号是8,而不是6。如果仅仅是S2和S3为彼此投票,它们不知道当前的任期号,它们只能查看自己的Log,它们或许会认为下一个任期是6(因为Log里的上一个任期是5)。如果它们这么做了,那么它们会从任期6开始添加Log。但是接下来,就会有问题了,因为我们有了两个不同的任期6(另一个在S1中)。这就是为什么currentTerm需要被持久化存储的原因,因为它需要用来保存已经被使用过的任期号。\n这些数据需要在每次你修改它们的时候存储起来。所以可以确定的是,安全的做法是每次你添加一个Log条目,更新currentTerm或者更新votedFor,你或许都需要持久化存储这些数据。在一个真实的Raft服务器上,这意味着将数据写入磁盘,所以你需要一些文件来记录这些数据。如果你发现,直到服务器与外界通信时,才有可能持久化存储数据,那么你可以通过一些批量操作来提升性能。例如,只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。\nVolatile state on all servers 每一个节点都应该有的非持久化状态: commitIndex: 已提交的最大 index。被提交的定义为,当 Leader 成功在大部分 server 上复制了一条 Entry,那么这条 Entry 就是一条已提交的 Entry。leader 节点重启后可以通过 appendEntries rpc 逐渐得到不同节点的 matchIndex,从而确认 commitIndex,follower 只需等待 leader 传递过来的 commitIndex 即可。(初始值为0,单调递增) lastApplied: 已被状态机应用的最大 index。已提交和已应用是不同的概念,已应用指这条 Entry 已经被运用到状态机上。已提交先于已应用。同时需要注意的是,Raft 保证了已提交的 Entry 一定会被应用(通过对选举过程增加一些限制,下面会提到)。raft 算法假设了状态机本身是易失的,所以重启后状态机的状态可以通过 log[] (部分 log 可以压缩为 snapshot) 来恢复。(初始值为0,单调递增) commitIndex 和 lastApplied 分别维护 log 已提交和已应用的状态,当节点发现 commitIndex \u0026gt; lastApplied 时,代表着 commitIndex 和 lastApplied 间的 entries 处于已提交,未应用的状态。因此应将其间的 entries 按序应用至状态机。\n对于 Follower,commitIndex 通过 Leader AppendEntries RPC 的参数 leaderCommit 更新。对于 Leader,commitIndex 通过其维护的 matchIndex 数组更新。\nVolatile state on leaders leader 的非持久化状态: nextIndex[]: 由 Leader 维护,nextIndex[i] 代表需要同步给 peer[i] 的下一个 entry 的 index。在 Leader 当选后,重新初始化为 Leader 的 lastLogIndex + 1。 matchIndex[]: 由 Leader 维护,matchIndex[i] 代表 Leader 已知的已在 peer[i] 上成功复制的最高 entry index。在 Leader 当选后,重新初始化为 0。 每次选举后,leader 的此两个数组都应该立刻重新初始化并开始探测。\n不能简单地认为 matchIndex = nextIndex - 1。\nnextIndex 是对追加位置的一种猜测,是乐观的估计。因此,当 Leader 上任时,会将 nextIndex 全部初始化为 lastLogIndex + 1,即乐观地估计所有 Follower 的 log 已经与自身相同。AppendEntries PRC 中,Leader 会根据 nextIndex 来决定向 Follower 发送哪些 entry。当返回失败时,则会将 nextIndex 减一,猜测仅有一条 entry 不一致,再次乐观地尝试。实际上,使用 nextIndex 是为了提升性能,仅向 Follower 发送不一致的 entry,减小 RPC 传输量。\nmatchIndex 则是对同步情况的保守确认,为了保证安全性。matchIndex 及此前的 entry 一定都成功地同步。matchIndex 的作用是帮助 Leader 更新自身的 commitIndex。当 Leader 发现一个 Index N 值,N 大于过半数的 matchIndex,则可将其 commitIndex 更新为 N(需要注意任期号的问题,后文会提到)。matchIndex 在 Leader 上任时被初始化为 0。\nnextIndex 是最乐观的估计,被初始化为最大可能值;matchIndex 是最悲观的估计,被初始化为最小可能值。在一次次心跳中,nextIndex 不断减小,matchIndex 不断增大,直至 matchIndex = nextIndex - 1,则代表该 Follower 已经与 Leader 成功同步。\nRequestVote RPC Invoked by candidates to gather votes (§5.2). 会被 Candidate 调用,以此获取选票。\nArgs\nterm: Candidate 的任期 (Lab 2A) candidateId: 发起投票请求的候选人id (Lab 2A) lastLogIndex: 候选人最新的日志条目索引, Candidate 最后一个 entry 的 index,是投票的额外判据 lastLogTerm: 候选人最新日志条目对应的任期号 Reply\nterm: 收到RequestVote RPC Raft节点的任期。假如 Candidate 发现 Follower 的任期高于自己,则会放弃 Candidate 身份并更新自己的任期 voteGranted: 是否同意 Candidate 当选。 Receiver Implementation 接收日志的follower需要实现的\n当 Candidate 任期小于当前节点任期时,返回 false。 如果 votedFor 为 null(即当前任期内此节点还未投票, Go 代码中用-1)或者 votedFor为 candidateId(即当前任期内此节点已经向此 Candidate 投过票),则同意投票;否则拒绝投票(Lab 2A 只需要实现到这个程度)。 事实上还要: 只有 Candidate 的 log 至少与 Receiver 的 log 一样新(up-to-date)时,才同意投票。Raft 通过两个日志的最后一个 entry 来判断哪个日志更 up-to-date。假如两个 entry 的 term 不同,term 更大的更新。term 相同时,index 更大的更新。 这里投票的额外限制(up-to-date)是为了保证已经被 commit 的 entry 一定不会被覆盖。仅有当 Candidate 的 log 包含所有已提交的 entry,才有可能当选为 Leader。\nAppendEntries RPC Invoked by leader to replicate log entries (§5.3); also used as heartbeat (§5.2). 在领导选举的过程中,AppendEntries RPC 用来实现 Leader 的心跳机制。节点的 AppendEntries RPC 会被 Leader 定期调用。正常存在Leader 时,用来进行Log Replacation。\nArgs\nterm: Leader 任期 (Lab 2A) leadId: Client 可能将请求发送至 Follower 节点,得知 leaderId 后 Follower 可将 Client 的请求重定位至 Leader 节点。因为 Raft 的请求信息必须先经过 Leader 节点,再由 Leader 节点流向其他节点进行同步,信息是单向流动的。在选主过程中,leaderId暂时只有 debug 的作用 (Lab 2A) prevLogIndex: 添加 Entries 的前一条 Entry 的 index prevLogTerm: prevLogIndex 对应 entry 的 term entries[]: 需要同步的 entries。若为空,则代表是一次 heartbeat。需要注意的是,不需要特别判断是否为 heartbeat,即使是 heartbeat,也需要进行一系列的检查。因此本文也不再区分心跳和 AppendEntries RPC leaderCommit: Leader 的 commitIndex,帮助 Follower 更新自身的 commitIndex Reply\nterm: 此节点的任期。假如 Leader 发现 Follower 的任期高于自己,则会放弃 Leader 身份并更新自己的任期。 success: 此节点是否认同 Leader 发送的RPC。 Receiver Implementation 接收日志的follower需要实现的\n当 Leader 任期小于当前节点任期时,返回 false。 若 Follower 在 prevLogIndex 位置的 entry 的 term 与 Args 中的 prevLogTerm 不同(或者 prevLogIndex 的位置没有 entry),返回 false。 如果 Follower 的某一个 entry 与需要同步的 entries 中的一个 entry 冲突,则需要删除冲突 entry 及其之后的所有 entry。需要特别注意的是,假如没有冲突,不能删除任何 entry。因为存在 Follower 的 log 更 up-to-date 的可能。 添加 Log 中不存在的新 entry。 如果 leaderCommit \u0026gt; commitIndex,令 commitIndex = min(leaderCommit, index of last new entry)。此即 Follower 更新 commitIndex 的方式。 Rules for Servers All Servers 如果commitIndex \u0026gt; lastApplied, 那么将lastApplied自增, 并把对应日志log[lastApplied]应用到状态机 如果来自其他节点的 RPC 请求(RequestVote, AppendEntries, InstallSnapshot)中,或发给其他节点的 RPC 的回复中,包含一个term T大于currentTerm, 那么将currentTerm赋值为T并立即切换状态为 Follower。(Lab 2A) Followers 响应来自 Candidate 和 Leader 的 RPC 请求。(Lab 2A) 如果在 election timeout 到期时,Follower 未收到来自当前 Leader 的 AppendEntries RPC,也没有收到来自 Candidate 的 RequestVote RPC,则转变为 Candidate。(Lab 2A) Candidate 转变 Candidate时,开始一轮选举:(Lab 2A) currentTerm ++ 为自己投票, votedFor = me 重置 election timer 向其他所有节点并行发送 RequestVote RPC 如果收到了大多数节点的选票(voteCnt \u0026gt; n/2),当选 Leader。(Lab 2A) 在选举过程中,如果收到了来自新 Leader 的 AppendEntries RPC,停止选举,转变为 Follower。(Lab 2A) 如果 election timer 超时时,还未当选 Leader,则放弃此轮选举,开启新一轮选举。(Lab 2A) Leader 刚上任时,向所有节点发送一轮心跳信息(empty AppendEntries)。此后,每隔一段固定时间,向所有节点发送一轮心跳信息,重置其他节点的 election timer,以维持自己 Leader 的身份。(Lab 2A) 如果收到了来自 client 的 command,将 command 以 entry 的形式添加到日志。在收到大多数响应后将该条目应用到状态机并回复响应给客户端。在 lab2B 中,client 通过 Start() 函数传入 command。 如果 lastLogIndex \u0026gt;= nextIndex[i],向 peer[i] 发送 AppendEntries RPC,RPC 中包含从 nextIndex[i] 开始的日志。 如果返回值为 true,更新 nextIndex[i] 和 matchIndex[i]。 如果因为 entry 冲突,RPC 返回值为 false,则将 nextIndex[i] 减1并重试。这里的重试不一定代表需要立即重试,实际上可以仅将 nextIndex[i] 减1,下次心跳时则是以新值重试。 如果存在 index 值 N 满足:N \u0026gt; commitIndex \u0026amp;\u0026amp; 过半数 matchIndex[i] \u0026gt;= N \u0026amp;\u0026amp; log[N].term == currentTerm, 则令commitIndex = N。 这里最后一条是 Leader 更新 commitIndex 的方式。前两个要求都比较好理解,第三个要求是 Raft 的一个特性,即 Leader 仅会直接提交其任期内的 entry。存在这样一种情况,Leader 上任时,其最新的一些条目可能被认为处于未被提交的状态(但这些条目实际已经成功同步到了大部分节点上)。Leader 在上任时并不会检查这些 entry 是不是实际上已经可以被提交,而是通过提交此后的 entry 来间接地提交这些 entry。这种做法能够 work 的基础是 Log Matching Property:\nLog Matching: if two logs contain an entry with the same index and term, then the logs are identical in all entries up through the given index.\nInstallSnapshot PRC invoked by leader to send chunks of a snapshot to a follower.Leaders always send chunks in order. 虽然多数情况都是每个服务器独立创建快照, 但是leader有时候必须发送快照给一些落后太多的follower, 这通常发生在leader已经丢弃了下一条要发给该follower的日志条目(Log Compaction时清除掉了)的情况下。\nArgs\nterm: Leader 任期。同样,InstallSnapshot RPC 也要遵循 Figure 2 中的规则。如果节点发现自己的任期小于 Leader 的任期,就要及时更新 leaderId: 用于重定向 client lastIncludedIndex: 快照中包含的最后一个 entry 的 index lastIncludedTerm: 快照中包含的最后一个 entry 的 index 对应的 term offset: 分块在快照中的偏移量 data[]: 快照数据 done: 如果是最后一块数据则为真 Reply\nterm: 节点的任期。Leader 发现高于自己任期的节点时,更新任期并转变为 Follower Receiver Implementation 接收日志的follower需要实现的\n如果 term \u0026lt; currentTerm,直接返回 如果是第一个分块 (offset为0) 则创建新的快照 在指定的偏移量写入数据 如果done为false, 则回复并继续等待之后的数据 保存快照文件, 丢弃所有已存在的或者部分有着更小索引号的快照 如果现存的日志拥有相同的最后任期号和索引值, 则后面的数据继续保留并且回复 丢弃全部日志 能够使用快照来恢复状态机 (并且装载快照中的集群配置) 一次请求 Raft 需要做如下流程: Leader (Follower 收到会定向给Leader)收到 client 的请求; Leader 把 entry 写入持久存储; Leader 发送 log replication message(AppendEntries RPC) 给 Follower; Follower 接收之后,把 entry 写入持久存储,然后给 Leader 发送响应; Leader 等待 Follower 的响应,若 majority 节点接收了,则 apply; Leader 将结果返回给 client。 ","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-0-%E4%BB%8B%E7%BB%8D/","summary":"前言 论文 博士论文 博士论文翻译 官网 动画展示 Students\u0026rsquo; Guide to Raft (重要) MIT6.824 本篇是实验的前言, 先对论文里面提到的RPC做个大概的梳理和介绍。 Raft 原理可以参考这篇","title":"MIT6.824 2022 Raft 0 介绍"},{"content":"介绍 查看Raft0 流程梳理 整体逻辑, 从 ticker goroutine 开始, 集群开始的时候,所有节点均为Follower, 它们依靠ticker()成为Candidate。ticker 协程会定期收到两个 timer 的到期事件,如果是 election timer 到期,则发起一轮选举;如果是 heartbeat timer 到期且节点是 leader,则发起一轮心跳。\nElectionTimer 和 HeartbeatTimer. 如果某个raft 节点election timeout,则会触发leader election, 调用StartElection 方法。 StartElection 中发送 RequestVote RPC, 根据ReqestVote Response 判断是否收到选票,决定是否成为Leader。\n如果某个节点,收到大多数节点的选票,成为Leader 要通过发送Heartbeat 即空LogEntry 的AppendEntries RPC 来告诉其他节点自己的 Leader 地位。\n所以Lab2A 中,主要实现 RequestVote, AppendEntries 的逻辑。\n服务器状态 服务器在任意时间只能处于以下三种状态之一:\nLeader:处理所有客户端请求、日志同步、心跳维持领导权。同一时刻最多只能有一个可行的 Leader Follower:所有服务器的初始状态,功能为:追随领导者,接收领导者日志并实时同步,特性:完全被动的(不发送 RPC,只响应收到的 RPC) Candidate:用来选举新的 Leader,处于 Leader 和 Follower 之间的暂时状态,如Follower 一定时间内未收到来自Leader的心跳包,Follower会自动切换为Candidate,并开始选举操作,向集群中的其它节点发送投票请求,待收到半数以上的选票时,协调者升级成为领导者。 系统正常运行时,只有一个 Leader,其余都是 Followers。Leader拥有绝对的领导力,不断向Followers同步日志且发送心跳状态。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 type Raft struct { mu sync.RWMutex // Lock to protect shared access to this peer\u0026#39;s state peers []*labrpc.ClientEnd // RPC end points of all peers persister *Persister // Object to hold this peer\u0026#39;s persisted state me int // this peer\u0026#39;s index into peers[] dead int32 // set by Kill() // Your data here (2A, 2B, 2C). // Look at the paper\u0026#39;s Figure 2 for a description of what // state a Raft server must maintain. // 2A state NodeState currentTerm int votedFor int electionTimer *time.Timer heartbeatTimer *time.Timer // 2B logs []Entry // the first is dummy entry which contains LastSnapshotTerm, LastSnapshotIndex and nil Command commitIndex int lastApplied int nextIndex []int matchIndex []int applyCh chan ApplyMsg applyCond *sync.Cond // used to wakeup applier goroutine after committing new entries replicatorCond []*sync.Cond // used to signal replicator goroutine to batch replicating entries } 启动 集群所有节点初始状态均为Follower Follower 被动地接受 Leader 或 Candidate 的 RPC; 所以,如果 Leader 想要保持权威,必须向集群中的其它节点发送心跳包(空的 AppendEntries RPC) 等待选举超时(electionTimeout,一般在 100~500ms)后,Follower 没有收到任何 RPC Follower 认为集群中没有 Leader 开始新的一轮选举 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 func Make(peers []*labrpc.ClientEnd, me int, persister *Persister, applyCh chan ApplyMsg) *Raft { rf := \u0026amp;Raft{ peers: peers, persister: persister, me: me, dead: 0, applyCh: applyCh, replicatorCond: make([]*sync.Cond, len(peers)), state: StateFollower, currentTerm: 0, votedFor: -1, logs: make([]Entry, 1), nextIndex: make([]int, len(peers)), matchIndex: make([]int, len(peers)), heartbeatTimer: time.NewTimer(StableHeartbeatTimeout()), electionTimer: time.NewTimer(RandomizedElectionTimeout()), } // Your initialization code here (2A, 2B, 2C). // initialize from state persisted before a crash rf.readPersist(persister.ReadRaftState()) rf.applyCond = sync.NewCond(\u0026amp;rf.mu) lastLog := rf.getLastLog() for i := 0; i \u0026lt; len(peers); i++ { rf.matchIndex[i], rf.nextIndex[i] = 0, lastLog.Index+1 if i != rf.me { rf.replicatorCond[i] = sync.NewCond(\u0026amp;sync.Mutex{}) // start replicator goroutine to replicate entries in batch go rf.replicator(i) } } // start ticker goroutine to start elections go rf.ticker() // start applier goroutine to push committed logs into applyCh exactly once go rf.applier() return rf } 集群开始的时候,所有节点均为Follower, 它们依靠ticker()成为Candidate。ticker 协程会定期收到两个 timer 的到期事件,如果是 election timer 到期,则发起一轮选举;如果是 heartbeat timer 到期且节点是 leader,则发起一轮心跳。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // ticker The ticker go routine starts a new election if this peer hasn\u0026#39;t received // heartsbeats recently. func (rf *Raft) ticker() { // for rf.killed() == false { for !rf.killed() { // Your code here to check if a leader election should // be started and to randomize sleeping time using // time.Sleep(). select { case \u0026lt;-rf.electionTimer.C: // start election DPrintf(\u0026#34;{Node: %v} election timeout\u0026#34;, rf.me) rf.mu.Lock() rf.ChangeState(StateCandidate) rf.currentTerm += 1 rf.StartElection() rf.electionTimer.Reset(RandomizedElectionTimeout()) rf.mu.Unlock() case \u0026lt;-rf.heartbeatTimer.C: // 领导者发送心跳维持领导力, 2A 可以先不实现 rf.mu.Lock() if rf.state == StateLeader { rf.BroadcastHeartbeat(true) rf.heartbeatTimer.Reset(StableHeartbeatTimeout()) } rf.mu.Unlock() } } } 选举与投票 当一个节点开始竞选:\n增加自己的 currentTerm 转为 Candidate 状态,其目标是获取超过半数节点的选票,让自己成为 Leader 先给自己投一票 并行地向集群中其它节点发送 RequestVote RPC 索要选票,如果没有收到指定节点的响应,它会反复尝试,直到发生以下三种情况之一: 获得超过半数的选票:成为 Leader,并向其它节点发送 AppendEntries 心跳; 收到来自 Leader 的 RPC:转为 Follower; 其它两种情况都没发生,没人能够获胜(electionTimeout 已过):增加 currentTerm,开始新一轮选举; Candidate 选举程序与投票统计\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 unc (rf *Raft) StartElection() { req := rf.genRequestVoteReq() DPrintf(\u0026#34;{Note: %v} starts election with RequestVoteReq: %v\u0026#34;, rf.me, req) // Closure grantedVote := 1 // elect for itself rf.votedFor = rf.me rf.persist() for peer := range rf.peers { if peer == rf.me { continue } go func(peer int) { resp := new(RequestVoteResponse) if rf.sendRequestVote(peer, req, resp) { rf.mu.Lock() defer rf.mu.Unlock() DPrintf(\u0026#34;[RequestVoteResp]-{Node: %v} receives RequestVoteResponse %v from {Node: %v} after sending RequestVoteRequest %v in term %v\u0026#34;, rf.me, resp, peer, req, rf.currentTerm) // rf.currentTerm == req.Term 为了抛弃过期的RequestVote RPC if rf.currentTerm == req.Term \u0026amp;\u0026amp; rf.state == StateCandidate { // Candidate node if resp.VoteGranted { grantedVote += 1 if grantedVote \u0026gt; len(rf.peers)/2 { DPrintf(\u0026#34;{Node: %v} receives majority votes in term %v\u0026#34;, rf.me, rf.currentTerm) rf.ChangeState(StateLeader) rf.BroadcastHeartbeat(true) } } else if resp.Term \u0026gt; rf.currentTerm { // candidate 发现有term 比自己大的,立刻转为follower DPrintf(\u0026#34;{Node %v} finds a new leader {Node %v} with term %v and steps down in term %v\u0026#34;, rf.me, peer, resp.Term, rf.currentTerm) rf.ChangeState(StateFollower) rf.currentTerm, rf.votedFor = resp.Term, -1 rf.persist() } } } }(peer) } } 发起投票需要异步进行,从而不阻塞ticker线程,这样candidate 再次 election timeout 之后才能自增 term 继续发起新一轮选举。 投票统计:可以在函数内定义一个变量并利用 go 的闭包来实现,也可以在结构体中维护一个 votes 变量来实现。为了 raft 结构体更干净,我选择了前者。 抛弃过期请求的回复:对于过期请求的回复,直接抛弃就行,不要做任何处理,这一点 guidance 里面也有介绍到 RequestVote 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 func (rf *Raft) RequestVote(req *RequestVoteRequest, resp *RequestVoteResponse) { // Your code here (2A, 2B). // 2A rf.mu.Lock() defer rf.mu.Unlock() defer rf.persist() defer DPrintf(\u0026#34;[RequestVote]-{Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} before processing requestVoteRequest %v and reply requestVoteResponse %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) if req.Term \u0026lt; rf.currentTerm || (req.Term == rf.currentTerm \u0026amp;\u0026amp; rf.votedFor != -1 \u0026amp;\u0026amp; rf.votedFor != req.CandidateId) { resp.Term, resp.VoteGranted = rf.currentTerm, false return } if req.Term \u0026gt; rf.currentTerm { rf.ChangeState(StateFollower) rf.currentTerm, rf.votedFor = req.Term, -1 } // 2A 可以先不实现 if !rf.isLogUpToDate(req.LastLogTerm, req.LastLogIndex) { resp.Term, resp.VoteGranted = rf.currentTerm, false return } rf.votedFor = req.CandidateId rf.electionTimer.Reset(RandomizedElectionTimeout()) resp.Term, resp.VoteGranted = rf.currentTerm, true } 「任期」表示节点的逻辑时钟,任期高的节点拥有更高的话语权。在RequestVote这个函数中,如果请求者的任期小于当前节点任期,则拒绝投票;如果请求者任期大于当前节点人气,那么当前节点立马成为追随者。即任期大的节点对任期小的拥有绝对的话语权,一旦发现任期大的节点,立马成为其追随者。\n注意,节点的选举随机时间和心跳时间的选择很重要\n节点随机选择超时时间,通常在 [T, 2T] 之间(T = electionTimeout) 这样,节点不太可能再同时开始竞选,先竞选的节点有足够的时间来索要其他节点的选票 T \u0026raquo; broadcast time(T 远大于广播时间)时效果更佳 1 2 3 4 5 6 7 8 9 10 11 12 13 const ( HeartbeatTimeout = 125 ElectionTimeout = 1000 ) func StableHeartbeatTimeout() time.Duration { // return time.Duration(HeartbeatTimeout) * time.Millisecond return HeartbeatTimeout * time.Millisecond } func RandomizedElectionTimeout() time.Duration { return time.Duration(ElectionTimeout+globalRand.Intn(ElectionTimeout)) * time.Millisecond } 总结 领导者选举主要工作可总结如下:\n三个状态,三个状态之间的转换。 1个loop——ticker。 1个RPC请求和处理,用于投票。 另外,ticker会一直运行,直到节点被kill,因此集群领导者并非唯一,一旦领导者出现了宕机、网络故障等问题,其它节点都能第一时间感知,并迅速做出重新选举的反应,从而维持集群的正常运行,毕竟Raft集群一旦失去了领导者,就无法工作。\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2a-leader-election/","summary":"介绍 查看Raft0 流程梳理 整体逻辑, 从 ticker goroutine 开始, 集群开始的时候,所有节点均为Follower, 它们依靠ticker()成为Candidate","title":"MIT6.824 2022 Raft Lab2A Leader Election"},{"content":"流程梳理 相关的RPC 在Raft0 中已经介绍, 这里不再赘述。 启动的Goroutine:\nticker 一个,用于监听 Election Timeout 或者Heartbeat Timeout applier 一个,监听 leader commit 之后,把log 发送到ApplyCh,然后从applyCh 中持久化到本地 replicator n-1 个,每一个对应一个 peer。监听心跳广播命令,仅在节点为 Leader 时工作, 唤醒条件变量。接收到命令后,向对应的 peer 发送 AppendEntries RPC。 日志结构 每个节点存储自己的日志副本(log[]),每条日志记录包含:\n索引:该记录在日志中的位置 任期号:该记录首次被创建时的任期号 命令 1 2 3 4 5 type Entry struct { Index int Term int Command interface{} } 日志「已提交」与「已应用」概念:\n已提交:committed, 数据在本地raft 日志中记录,没有应用到状态机 已应用:真正的数据变化。提交到大多数节点之后,应用到各自本地的状态机中。 已提交的日志被应用后才会生效\n日志同步: 日志同步是Leader独有的权利,Leader向Follower发送日志,Follower同步日志。\n日志同步要解决如下两个问题:\nLeader发送心跳宣示自己的主权,Follower不会发起选举。 Leader将自己的日志数据同步到Follower,达到数据备份的效果。 运行流程 客户端向 Leader 发送命令,希望该命令被所有状态机执行;\nLeader 先将该命令追加到自己的日志中; Leader 并行地向其它节点发送 AppendEntries RPC,等待响应; 收到超过半数节点的响应,则认为新的日志记录是被提交的: Leader 将命令传给自己的状态机,然后向客户端返回响应 一旦 Leader 知道一条记录被提交了,将在后续的 AppendEntries RPC 中通知已经提交记录的 Followers Follower 将已提交的命令传给自己的状态机 如果 Follower 宕机/超时:Leader 将反复尝试发送 RPC; 性能优化:Leader 不必等待每个 Follower 做出响应,只需要超过半数的成功响应(确保日志记录已经存储在超过半数的节点上)——一个很慢的节点不会使系统变慢,因为 Leader 不必等他;\nAppendEntries RPC 具体介绍参考此处文章\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 type AppendEntriesReq struct { Term int LeaderId int PrevLogIndex int PrevLogTerm int LeaderComment int Entries []Entry } func (req AppendEntriesReq) String() string { return fmt.Sprintf(\u0026#34;{Term: %d, LeaderId: %v, PreVoteLogIndex: %v, PreVoteLogTerm: %v, LeaderComment: %v, Entries: %v}\u0026#34;, req.Term, req.LeaderId, req.PrevLogIndex, req.PrevLogTerm, req.LeaderComment, req.Entries) } type AppendEntriesResp struct { Term int Success bool // for fast backup https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/lecture-07-raft2/7.3-hui-fu-jia-su-backup-acceleration ConflictIndex int ConflictTerm int ConflictLen int } func (resp AppendEntriesResp) String() string { return fmt.Sprintf(\u0026#34;{Term:%v,Success:%v,ConflictIndex:%v,ConflictTerm:%v}\u0026#34;, resp.Term, resp.Success, resp.ConflictIndex, resp.ConflictTerm) } AppendEntries RPC\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 func (rf *Raft) AppendEntries(req *AppendEntriesReq, resp *AppendEntriesResp) { rf.mu.Lock() defer rf.mu.Unlock() defer rf.persist() defer DPrintf(\u0026#34;[AppendEntries]- {Node: %v}\u0026#39;s state is {state %v, term %v, commitIndex %v, lastApplied %v, firstLog %v, lastLog %v} before processing AppendEntriesRequest %v and reply AppendEntries %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) // 如果发现来自leader的rpc中的term比当前peer要小, // 说明是该RPC 来自旧的term(leader),|| 或者 当前leader 需要更新 不处理 if req.Term \u0026lt; rf.currentTerm { resp.Term, resp.Success = rf.currentTerm, false return } // 一般来讲,在vote的时候已经将currentTerm和leader同步 // 不过,有些peer暂时的掉线或者其他一些情况重连以后,会发现term和leader不一样 // 以收到大于自己的term的rpc也是第一时间同步.而且要将votefor重新设置为-1 // 等待将来选举 (说明这个peer 不是之前election 中投的的marjority) if req.Term \u0026gt; rf.currentTerm { rf.currentTerm, rf.votedFor = req.Term, -1 } rf.ChangeState(StateFollower) rf.electionTimer.Reset(RandomizedElectionTimeout()) // PrevLogIndex 比rf 当前的第一个Log index 还要小 if req.PrevLogIndex \u0026lt; rf.getFirstLog().Index { resp.Term, resp.Success = 0, false DPrintf(\u0026#34;[AppendEntries] - {Node: %v} receives unexpected AppendEntriesRequest %v from {Node: %v} because prevLogIndex %v \u0026lt; firstLogIndex %v\u0026#34;, rf.me, req, req.LeaderId, req.PrevLogIndex, rf.getFirstLog().Index) return } if !rf.matchLog(req.PrevLogTerm, req.PrevLogIndex) { // 日志的一致性检查失败后,递归找到需要追加日志的位置 resp.Term, resp.Success = rf.currentTerm, false lastIndex := rf.getLastLog().Index if lastIndex \u0026lt; req.PrevLogIndex { // lastIndex 和 nextIndex[peer] 之间有空洞 scenario3 // follower 在nextIndex[peer] 没有log resp.ConflictTerm = -1 resp.ConflictIndex = lastIndex + 1 } else { // scenario2, 1 // 以任期为单位进行回退 firstIndex := rf.getFirstLog().Index resp.ConflictTerm = rf.logs[req.PrevLogIndex-firstIndex].Term index := req.PrevLogIndex - 1 for index \u0026gt;= firstIndex \u0026amp;\u0026amp; rf.logs[index-firstIndex].Term == resp.ConflictTerm { index-- } resp.ConflictIndex = index } return } firstIndex := rf.getFirstLog().Index for i, entry := range req.Entries { // mergeLog // 添加的日志索引位置 比Follower 日志相同 直接添加 此处用大于等于,实际只有== // || 要添加的日志索引位置在 Follower 中的任期和AE RPC 中的Term 冲突 if entry.Index-firstIndex \u0026gt;= len(rf.logs) || rf.logs[entry.Index-firstIndex].Term != entry.Term { rf.logs = shrinkEntriesArray(append(rf.logs[:entry.Index-firstIndex], req.Entries[i:]...)) break } } rf.advanceCommitIndexForFollower(req.LeaderComment) resp.Term, resp.Success = rf.currentTerm, true } 复制模型(log replication) 对于复制模型,很直观的方式是:包装一个 BroadcastHeartbeat() 函数,其负责向所有 follower 发送一轮同步。不论是心跳超时还是上层服务传进来一个新 command,都去调一次这个函数来发起一轮同步。\n以上方式是可以 work 的,我最开始的实现也是这样的,然而在测试过程中,我发现这种方式有很大的资源浪费。比如上层服务连续调用了几十次 Start() 函数,由于每一次调用 Start() 函数都会触发一轮日志同步,则最终导致发送了几十次日志同步。一方面,这些请求包含的 entries 基本都一样,甚至有 entry 连续出现在几十次 rpc 中,这样的实现多传输了一些数据,存在一定浪费;另一方面,每次发送 rpc 都不论是发送端还是接收端都需要若干次系统调用和内存拷贝,rpc 次数过多也会对 CPU 造成不必要的压力。总之,这种资源浪费的根本原因就在于:将日志同步的触发与上层服务提交新指令强绑定,从而导致发送了很多重复的 rpc。\n为此,参考了 sofajraft 的日志复制实现 。每个 peer 在启动时会为除自己之外的每个 peer 都分配一个 replicator 协程。对于 follower 节点,该协程利用条件变量执行 wait 来避免耗费 cpu,并等待变成 leader 时再被唤醒;对于 leader 节点,该协程负责尽最大地努力去向对应 follower 发送日志使其同步,直到该节点不再是 leader 或者该 follower 节点的 matchIndex 大于等于本地的 lastIndex。\n这样的实现方式能够将日志同步的触发和上层服务提交新指令解耦,能够大幅度减少传输的数据量,rpc 次数和系统调用次数。由于 6.824 的测试能够展示测试过程中的传输 rpc 次数和数据量,因此我进行了前后的对比测试,结果显示:这样的实现方式相比直观方式的实现,不同测试数据传输量的减少倍数在 1-20 倍之间。当然,这样的实现也只是实现了粗粒度的 batching,并没有流量控制,而且也没有实现 pipeline,有兴趣的同学可以去了解 sofajraft, etcd 或者 tikv 的实现,他们对于复制过程进行了更细粒度的控制。\n此外,虽然 leader 对于每一个节点都有一个 replicator 协程去同步日志,但其目前同时最多只能发送一个 rpc,而这个 rpc 很可能超时或丢失从而触发集群换主。因此,对于 heartbeat timeout 触发的 BroadcastHeartbeat,我们需要立即发出日志同步请求而不是让 replicator 去发。这也就是我的 BroadcastHeartbeat 函数有两种行为的真正原因。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 // handleAppendEntriesResponse peer handle AppendEntries RPC func (rf *Raft) handleAppendEntriesResponse(peer int, req *AppendEntriesReq, resp *AppendEntriesResp) { defer DPrintf(\u0026#34;[handleAppendEntriesResponse]-{Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} after handling AppendEntriesResponse %v for AppendEntriesRequest %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), resp, req) if rf.state == StateLeader \u0026amp;\u0026amp; rf.currentTerm == req.Term { if resp.Success { // 更新matchIndex, nextIndex rf.matchIndex[peer] = req.PrevLogIndex + len(req.Entries) rf.nextIndex[peer] = rf.matchIndex[peer] + 1 rf.advanceCommitIndexForLeader() } else { // term 太小而失败 if resp.Term \u0026gt; rf.currentTerm { rf.ChangeState(StateFollower) rf.currentTerm, rf.votedFor = resp.Term, -1 rf.persist() } else if resp.Term == rf.currentTerm { // 日志不匹配而失败 rf.nextIndex[peer] = resp.ConflictIndex // 1. 如果在Leader 中能找到和Follower 有相同的ConflictTerm, // 返回该Leader Term 的最后一个Log 作为nextIndex[peer] // 2. 如果找不到相同的Term,返回Follower 中的ConflictTerm 的第一个日志,即ConflictIndex if resp.ConflictTerm != -1 { firstIndex := rf.getFirstLog().Index for i := req.PrevLogIndex; i \u0026gt;= firstIndex; i-- { if rf.logs[i-firstIndex].Term == resp.ConflictTerm { rf.nextIndex[peer] = i break } } } } } } } func (rf *Raft) replicator(peer int) { rf.replicatorCond[peer].L.Lock() defer rf.replicatorCond[peer].L.Unlock() for !rf.killed() { // if there is no need to replicate entries for this peer, // just release CPU and wait other goroutine\u0026#39;s signal if service adds new Command // if this peer needs replicating entries, this goroutine will call // replicateOneRound(peer) multiple times until this peer catches up, and then wait // Only Leader 可以Invoke 这个方法,通过.Singal 唤醒各个peer, 不是Leader 不生效 for !rf.needReplicating(peer) { rf.replicatorCond[peer].Wait() } // maybe a pipeline mechanism is better to trade-off the memory usage and catch up time rf.replicateOneRound(peer) } } 日志应用 异步 applier 的 exactly once Raft论文的说话,一旦发现commitIndex大于lastApplied,应该立马将可应用的日志应用到状态机中。Raft节点本身是没有状态机实现的,状态机应该由Raft的上层应用来实现,因此我们不会谈论如何实现状态机,只需将日志发送给applyCh这个通道即可。\n对于异步 apply,其触发方式无非两种,leader 提交了新的日志或者 follower 通过 leader 发来的 leaderCommit 来更新 commitIndex。很多人实现的时候可能顺手就在这两处异步启一个协程把 [lastApplied + 1, commitIndex] 的 entry push 到 applyCh 中,但其实这样子是可能重复发送 entry 的,原因是 push applyCh 的过程不能够持锁,那么这个 lastApplied 在没有 push 完之前就无法得到更新,从而可能被多次调用。虽然只要上层服务可以保证不重复 apply 相同 index 的日志到状态机就不会有问题,但我个人认为这样的做法是不优雅的。考虑到异步 apply 时最耗时的步骤是 apply channel 和 apply 日志到状态机,其他的都不怎么耗费时间。因此我们完全可以只用一个 applier 协程,让其不断的把 [lastApplied + 1, commitIndex] 区间的日志 push 到 applyCh 中去。这样既可保证每一条日志只会被 exactly once 地 push 到 applyCh 中,也可以使得日志 apply 到状态机和 raft 提交新日志可以真正的并行。我认为这是一个较为优雅的异步 apply 实现。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 // applier a dedicated applier goroutine to guarantee that each log will be push into // applyCh exactly once, ensuring that service\u0026#39;s applying entries and raft\u0026#39;s // committing entries can be parallel func (rf *Raft) applier() { for !rf.killed() { rf.mu.Lock() // if there is no need to apply entries, // just release CPU and wait other goroutine\u0026#39;s signal if they commit new entries for rf.lastApplied \u0026gt;= rf.commitIndex { rf.applyCond.Wait() } firstIndex, commitIndex, lastApplied := rf.getFirstLog().Index, rf.commitIndex, rf.lastApplied entries := make([]Entry, commitIndex-lastApplied) copy(entries, rf.logs[lastApplied+1-firstIndex:commitIndex+1-firstIndex]) rf.mu.Unlock() for _, entry := range entries { rf.applyCh \u0026lt;- ApplyMsg{ CommandValid: true, Command: entry.Command, CommandTerm: entry.Term, CommandIndex: entry.Index, } } rf.mu.Lock() DPrintf(\u0026#34;{Node %v} applies entries %v-%v in term %v\u0026#34;, rf.me, rf.lastApplied, commitIndex, rf.currentTerm) rf.lastApplied = Max(rf.lastApplied, commitIndex) rf.mu.Unlock() } } 需要注意以下两点:\n引用之前的 commitIndex:push applyCh 结束之后更新 lastApplied 的时候一定得用之前的 commitIndex 而不是 rf.commitIndex,因为后者很可能在 push channel 期间发生了改变。 防止与 installSnapshot 并发导致 lastApplied 回退:需要注意到,applier 协程在 push channel 时,中间可能夹杂有 snapshot 也在 push channel。如果该 snapshot 有效,那么在 CondInstallSnapshot 函数里上层状态机和 raft 模块就会原子性的发生替换,即上层状态机更新为 snapshot 的状态,raft 模块更新 log, commitIndex, lastApplied 等等,此时如果这个 snapshot 之后还有一批旧的 entry 在 push channel,那上层服务需要能够知道这些 entry 已经过时,不能再 apply,同时 applier 这里也应该加一个 Max 自身的函数来防止 lastApplied 出现回退。 快速恢复(Fast Backup) 在前面(7.1)介绍的日志恢复机制中,如果Log有冲突,Leader每次会回退一条Log条目。 这在许多场景下都没有问题。但是在某些现实的场景中,至少在Lab2的测试用例中,每次只回退一条Log条目会花费很长很长的时间。所以,现实的场景中,可能一个Follower关机了很长时间,错过了大量的AppendEntries消息。这时,Leader重启了。按照Raft论文中的图2,如果一个Leader重启了,它会将所有Follower的nextIndex设置为Leader本地Log记录的下一个槽位(7.1有说明)。所以,如果一个Follower关机并错过了1000条Log条目,Leader重启之后,需要每次通过一条RPC来回退一条Log条目来遍历1000条Follower错过的Log记录。这种情况在现实中并非不可能发生。在一些不正常的场景中,假设我们有5个服务器,有1个Leader,这个Leader和另一个Follower困在一个网络分区。但是这个Leader并不知道它已经不再是Leader了。它还是会向它唯一的Follower发送AppendEntries,因为这里没有过半服务器,所以没有一条Log会commit。在另一个有多数服务器的网络分区中,系统选出了新的Leader并继续运行。旧的Leader和它的Follower可能会记录无限多的旧的任期的未commit的Log。当旧的Leader和它的Follower重新加入到集群中时,这些Log需要被删除并覆盖。可能在现实中,这不是那么容易发生,但是你会在Lab2的测试用例中发现这个场景。\n所以,为了更快的恢复日志,Raft论文在5.3结尾处,对这种方法有了一些模糊的描述。原文有些晦涩,在这里我会以一种更好的方式,尝试解释论文中有关快速恢复的方法。大致思想是,让Follower返回足够多的信息给Leader,这样Leader可以以任期(Term)为单位来回退,而不用每次只回退一条Log条目。所以现在,在恢复Follower的Log时,如果Leader和Follower的Log不匹配,Leader只需要对不同任期发生一条AEs,而不需要对每个不通Log条目发送一条AEs。这是一种加速策略,当然也可以有别的日志恢复的加速策略。\n我将可能出现的场景分成3类,为了简化,这里只画出一个Leader(S2)和一个Follower(S1),S2将要发送一条任期号为6的AppendEntries消息给Follower。\n场景1:S1(Follower)没有任期6的任何Log,因此我们需要回退一整个任期的Log。 场景2:S1收到了任期4的旧Leader的多条Log,但是作为新Leader,S2只收到了一条任期4的Log。所以这里,我们需要覆盖S1中有关旧Leader的一些Log。 场景3: S1与S2的Log不冲突,但是S1缺失了部分S2中的Log 可以让Follower在回复Leader的AppendEntries消息中,携带3个额外的信息,来加速日志的恢复。这里的回复是指,Follower因为Log信息不匹配,拒绝了Leader的AppendEntries之后的回复。这里的三个信息是指:\nXTerm: 这个是Follower中与Leader冲突的Log对应的任期号。在之前(7.1)有介绍Leader会在prevLogTerm中带上本地Log记录中,前一条Log的任期号。如果Follower在对应位置的任期号不匹配,它会拒绝Leader的AppendEntries消息,并将自己的任期号放在XTerm中。如果Follower在对应位置没有Log,那么这里会返回 -1。 XIndex: 这个是Follower中,对应任期号为XTerm的第一条Log条目的槽位号。 XLen: 如果Follower在对应位置没有Log,那么XTerm会返回-1,XLen表示空白的Log槽位数。 我们再来看这些信息是如何在上面3个场景中,帮助Leader快速回退到适当的Log条目位置。\n场景1: Follower(S1)会返回XTerm=5,XIndex=2。Leader(S2)发现自己没有任期5的日志,它会将自己本地记录的,S1的nextIndex设置到XIndex,也就是S1中,任期5的第一条Log对应的槽位号。所以,如果Leader完全没有XTerm的任何Log,那么它应该回退到XIndex对应的位置(这样,Leader发出的下一条AppendEntries就可以一次覆盖S1中所有XTerm对应的Log) 场景2: Follower(S1)会返回XTerm=4,XIndex=1。Leader(S2)发现自己其实有任期4的日志,它会将自己本地记录的S1的nextIndex设置到本地在XTerm位置的Log条目后面,也就是槽位2。下一次Leader发出下一条AppendEntries时,就可以一次覆盖S1中槽位2和槽位3对应的Log。 场景3: Follower(S1)会返回XTerm=-1,XLen=2。这表示S1中日志太短了,以至于在冲突的位置没有Log条目,Leader应该回退到Follower最后一条Log条目的下一条,也就是槽位2,并从这开始发送AppendEntries消息。槽位2可以从XLen中的数值计算得到。 在本次的实现中以Term 为单位返回,不在一个一个Index 自减。这需要添加 ConflicTerm, ConflictIndex 字段 去记录出现冲突的位置和任期。然后在 HanleAppendEntries RPC 中,在 Leader 的log 中检查 ConflictIndex 位置的日志一致性。\n为什么Raft协议不能提交之前任期的日志? 查看\n函数解析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 func (rf *Raft) AppendEntries(req *AppendEntriesReq, resp *AppendEntriesResp) { rf.mu.Lock() defer rf.mu.Unlock() defer rf.persist() defer DPrintf(\u0026#34;[AppendEntries]- {Node: %v}\u0026#39;s state is {state %v, term %v, commitIndex %v, lastApplied %v, firstLog %v, lastLog %v} before processing AppendEntriesRequest %v and reply AppendEntries %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) // 如果发现来自leader的rpc中的term比当前peer要小, // 说明是该RPC 来自旧的term(leader),|| 或者 当前leader 需要更新 不处理 if req.Term \u0026lt; rf.currentTerm { resp.Term, resp.Success = rf.currentTerm, false return } // 一般来讲,在vote的时候已经将currentTerm和leader同步 // 不过,有些peer暂时的掉线或者其他一些情况重连以后,会发现term和leader不一样 // 以收到大于自己的term的rpc也是第一时间同步.而且要将votefor重新设置为-1 // 等待将来选举 (说明这个peer 不是之前election 中投的的marjority) if req.Term \u0026gt; rf.currentTerm { rf.currentTerm, rf.votedFor = req.Term, -1 } rf.ChangeState(StateFollower) rf.electionTimer.Reset(RandomizedElectionTimeout()) // PrevLogIndex 比rf 当前的第一个Log index 还要小 if req.PrevLogIndex \u0026lt; rf.getFirstLog().Index { resp.Term, resp.Success = 0, false DPrintf(\u0026#34;[AppendEntries] - {Node: %v} receives unexpected AppendEntriesRequest %v from {Node: %v} because prevLogIndex %v \u0026lt; firstLogIndex %v\u0026#34;, rf.me, req, req.LeaderId, req.PrevLogIndex, rf.getFirstLog().Index) return } if !rf.matchLog(req.PrevLogTerm, req.PrevLogIndex) { // 日志的一致性检查失败后,递归找到需要追加日志的位置 resp.Term, resp.Success = rf.currentTerm, false lastIndex := rf.getLastLog().Index if lastIndex \u0026lt; req.PrevLogIndex { // lastIndex 和 nextIndex[peer] 之间有空洞 scenario3 // follower 在nextIndex[peer] 没有log resp.ConflictTerm = -1 resp.ConflictIndex = lastIndex + 1 } else { // scenario2, 1 // 以任期为单位进行回退 firstIndex := rf.getFirstLog().Index resp.ConflictTerm = rf.logs[req.PrevLogIndex-firstIndex].Term index := req.PrevLogIndex - 1 for index \u0026gt;= firstIndex \u0026amp;\u0026amp; rf.logs[index-firstIndex].Term == resp.ConflictTerm { index-- } resp.ConflictIndex = index } return } firstIndex := rf.getFirstLog().Index for i, entry := range req.Entries { // mergeLog // 添加的日志索引位置 比Follower 日志相同 直接添加 此处用大于等于,实际只有== // || 要添加的日志索引位置在 Follower 中的任期和AE RPC 中的Term 冲突 if entry.Index-firstIndex \u0026gt;= len(rf.logs) || rf.logs[entry.Index-firstIndex].Term != entry.Term { rf.logs = shrinkEntriesArray(append(rf.logs[:entry.Index-firstIndex], req.Entries[i:]...)) break } } rf.advanceCommitIndexForFollower(req.LeaderComment) resp.Term, resp.Success = rf.currentTerm, true } ","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2b-log-replication/","summary":"流程梳理 相关的RPC 在Raft0 中已经介绍, 这里不再赘述。 启动的Goroutine: ticker 一个,用于监听 Election Timeout 或者Heartbeat Timeout applier 一个,监听","title":"MIT6.824 2022 Raft Lab2B Log Replication"},{"content":"介绍 Go 语言没有构造函数,一般通过定义 New 函数来充当构造函数。然而,如果结构有较多字段,要初始化这些字段,有很多种方式,但有一种方式认为是最好的,这就是函数式选项模式(Functional Options Pattern)。\n函数式选项模式是一种在 Go 中构造结构体的模式,它通过设计一组非常有表现力和灵活的 API 来帮助配置和初始化结构体。\n在 Uber 的 Go 语言规范 中提到了该模式:\nFunctional options 是一种模式,在该模式中,你可以声明一个不透明的 Option 类型,该类型在某些内部结构中记录信息。你接受这些可变数量的选项,并根据内部结构上的选项记录的完整信息进行操作。 将此模式用于构造函数和其他公共 API 中的可选参数,你预计这些参数需要扩展,尤其是在这些函数上已经有三个或更多参数的情况下。\nDemo 为了更好的理解该模式,我们通过一个例子来讲解。\n定义一个 Server 结构体\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 package main type Server struct { host string port int } func New(host string, port int) *Server { return \u0026amp;Server{host, port} } func (s *Server) Start() error { } 使用\n1 2 3 4 5 6 7 8 9 10 11 12 13 package main import ( \u0026#34;log\u0026#34; \u0026#34;server\u0026#34; ) func main() { svr := New(\u0026#34;localhost\u0026#34;, 1234) if err := svr.Start(); err != nil { log.Fatal(err) } } 但如果要扩展 Server 的配置选项,如何做?通常有三种做法:\n为每个不同的配置选项声明一个新的构造函数 定义一个新的 Config 结构体来保存配置信息 使用 Functional Option Pattern 做法 1:为每个不同的配置选项声明一个新的构造函数 1 2 3 4 5 6 type Server struct { host string port int timeout time.Duration maxConn int } 一般来说,host 和 port 是必须的字段,而 timeout 和 maxConn 是可选的,所以,可以保留原来的构造函数,而这两个字段给默认值:\n1 2 3 func New(host string, port int) *Server { return \u0026amp;Server{host, port, time.Minute, 100} } 然后针对 timeout 和 maxConn 额外提供两个构造函数:\n1 2 3 4 5 6 7 func NewWithTimeout(host string, port int, timeout time.Duration) *Server { return \u0026amp;Server{host, port, timeout} } func NewWithTimeoutAndMaxConn(host string, port int, timeout time.Duration, maxConn int) *Server { return \u0026amp;Server{host, port, timeout, maxConn} } 这种方式配置较少且不太会变化的情况,否则每次你需要为新配置创建新的构造函数。在 Go 语言标准库中,有这种方式的应用。比如 net 包中的 Dial 和 DialTimeout:\n1 2 func Dial(network, address string) (Conn, error) func DialTimeout(network, address string, timeout time.Duration) (Conn, error) 做法 2:使用专门的配置结构体 这种方式也是很常见的,特别是当配置选项很多时。通常可以创建一个 Config 结构体,其中包含 Server 的所有配置选项。这种做法,即使将来增加更多配置选项,也可以轻松的完成扩展,不会破坏 Server 的 API。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 type Server struct { cfg Config } type Config struct { Host string Port int Timeout time.Duration MaxConn int } func New(cfg Config) *Server { return \u0026amp;Server{cfg} } 在使用时,需要先构造 Config 实例,对这个实例,又回到了前面 Server 的问题上,因为增加或删除选项,需要对 Config 有较大的修改。如果将 Config 中的字段改为私有,可能需要定义 Config 的构造函数。。。\n做法 3:使用 Functional Option Pattern 一个更好的解决方案是使用 Functional Option Pattern。\n在这个模式中,我们定义一个 Option 函数类型:\n1 type Option func(*Server) Option 类型是一个函数类型,它接收一个参数:*Server。然后,Server 的构造函数接收一个 Option 类型的不定参数:\n1 2 3 4 5 6 7 func New(options ...Option) *Server { svr := \u0026amp;Server{} for _, f := range options { f(svr) } return svr } 那选项如何起作用?需要定义一系列相关返回 Option 的函数:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func WithHost(host string) Option { return func(s *Server) { s.host = host } } func WithPort(port int) Option { return func(s *Server) { s.port = port } } func WithTimeout(timeout time.Duration) Option { return func(s *Server) { s.timeout = timeout } } func WithMaxConn(maxConn int) Option { return func(s *Server) { s.maxConn = maxConn } } 针对这种模式,客户端类似这么使用:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package main import ( \u0026#34;log\u0026#34; \u0026#34;server\u0026#34; ) func main() { svr := New( WithHost(\u0026#34;localhost\u0026#34;), WithPort(8080), WithTimeout(time.Minute), WithMaxConn(120), ) if err := svr.Start(); err != nil { log.Fatal(err) } } 将来增加选项,只需要增加对应的 WithXXX 函数即可。\n这种模式,在第三方库中使用挺多,比如 github.com/gocolly/colly:\n1 2 3 4 5 6 7 8 9 10 11 12 type Collector { // 省略... } func NewCollector(options ...CollectorOption) *Collector // 定义了一系列 CollectorOpiton type CollectorOption{ // 省略... } func AllowURLRevisit() CollectorOption func AllowedDomains(domains ...string) CollectorOption ... 不过 Uber 的 Go 语言编程规范中提到该模式时,建议定义一个 Option 接口,而不是 Option 函数类型。该 Option 接口有一个未导出的方法,然后通过一个未导出的 options 结构来记录各选项。\nOption Interface 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 // 需要添加的配置参数字段放到options field 中 type options struct { cache bool logger *zap.Logger } type Option interface { apply(*options) } // 新定义一个类型,用来重置options 里面对应的字段 type cacheOption bool // apply 将类型c 的值,赋给options 对应的field func (c cacheOption) apply(opts *options) { opts.cache = bool(c) } // with开头新建一个 上面定义的类型 func WithCache(c bool) Option { return cacheOption(c) } // 新定义的类型中,包含了需要重中options 的logger field type loggerOption struct { Log *zap.Logger } // apply 将新类型的Log field 给options 的对应field func (l loggerOption) apply(opts *options) { opts.logger = l.Log } // with开头新建一个 上面定义的类型 func WithLogger(log *zap.Logger) Option { return loggerOption{Log: log} } // Open creates a connection. func Open(addr string, opts ...Option) (*Connection, error) { // 新建一个options 的配置类型 options := options{ cache: defaultCache, logger: zap.NewNop(), } // 遍历Option 接口类型,调用apply 方法,将o的field 赋值给options 配置 for _, o := range opts { o.apply(\u0026amp;options) } // ... } ","permalink":"https://reid00.github.io/en/posts/langs_linux/go-function-option-%E5%87%BD%E6%95%B0%E9%80%89%E9%A1%B9%E6%A8%A1%E5%BC%8F/","summary":"介绍 Go 语言没有构造函数,一般通过定义 New 函数来充当构造函数。然而,如果结构有较多字段,要初始化这些字段,有很多种方式,但有一种方式认为是最好的","title":"Go Function Option 函数选项模式"},{"content":"介绍 Join大致包括三个要素:Join方式、Join条件以及过滤条件。其中过滤条件也可以通过AND语句放在Join条件中。 Spark支持的Join 包括:\ninner join left outer join right outer join full outer join left semi join left anti join Join 的基本流程 总体上来说,Join的基本实现流程如下图所示,Spark将参与Join的两张表抽象为流式遍历表(streamIter)和查找表(buildIter),通常streamIter为大表,buildIter为小表,我们不用担心哪个表为streamIter,哪个表为buildIter,这个spark会根据join语句自动帮我们完成。 在实际计算时,spark会基于streamIter来遍历,每次取出streamIter中的一条记录rowA,根据Join条件计算keyA,然后根据该keyA去buildIter中查找所有满足Join条件(keyB==keyA)的记录rowBs,并将rowBs中每条记录分别与rowAjoin得到join后的记录,最后根据过滤条件得到最终join的记录。\n从上述计算过程中不难发现,对于每条来自streamIter的记录,都要去buildIter中查找匹配的记录,所以buildIter一定要是查找性能较优的数据结构 如Hash Table。spark提供了三种join实现:sort merge join、broadcast join以及hash join。\nHash join实现 spark提供了hash join实现方式,在shuffle read阶段不对记录排序,反正来自两格表的具有相同key的记录会在同一个分区,只是在分区内不排序,将来自buildIter的记录放到hash表中,以便查找,如下图所示。\n由于Spark是一个分布式的计算引擎,可以通过分区的形式将大批量的数据划分成n份较小的数据集进行并行计算。这种思想应用到Join上便是Shuffle Hash Join了。利用key相同必然分区相同的这个原理,SparkSQL将较大表的join分而治之,先将表划分成n个分区,在对buildlter查找表和streamlter表进行Hash Join。 Shuffle Hash Join分为两步: 对两张表分别按照join keys进行重分区,即shuffle,目的是为了让有相同join keys值的记录分到对应的分区中 对 对应分区中的数据进行join,此处先将小表分区构造为一张hash表,然后根据大表分区中记录的join keys值拿出来进行匹配 不难发现,要将来自buildIter的记录放到hash表中,那么每个分区来自buildIter的记录不能太大,否则就存不下,默认情况下hash join的实现是关闭状态,如果要使用hash join,必须满足以下四个条件:\nbuildIter总体估计大小超过spark.sql.autoBroadcastJoinThreshold设定的值,即不满足broadcast join条件 开启尝试使用hash join的开关,spark.sql.join.preferSortMergeJoin=false 每个分区的平均大小不超过spark.sql.autoBroadcastJoinThreshold设定的值,即shuffle read阶段每个分区来自buildIter的记录要能放到内存中 streamIter的大小是buildIter三倍以上 Sort Merge Join 实现 上面介绍的实现对于一定大小的表比较适用,但当两个表都非常大时,显然无论适用哪种都会对计算内存造成很大压力。这是因为join时两者采取的都是hash join,是将一侧的数据完全加载到内存中,使用hash code取join keys值相等的记录进行连接。\n要让两条记录能join到一起,首先需要将具有相同key的记录在同一个分区,所以通常来说,需要做一次shuffle,map阶段根据join条件确定每条记录的key,基于该key做shuffle write,将可能join到一起的记录分到同一个分区中,这样在shuffle read阶段就可以将两个表中具有相同key的记录拉到同一个分区处理。前面我们也提到,对于buildIter一定要是查找性能较优的数据结构,通常我们能想到hash表,但是对于一张较大的表来说,不可能将所有记录全部放到hash表中,SparkSQL采用了一种全新的方案来对表进行Join,即Sort Merge Join。这种实现方式不用将一侧数据全部加载后再进行hash join,但需要在join前将数据排序,如下图所示: 三个步骤: shuffle阶段:或者说shuffle write 阶段,将两张大表根据join key进行重新分区,两张表数据会分布到整个集群,以便分布式并行处理 sort阶段:对单个分区节点的两表数据,分别进行排序 merge阶段:或者说shuffle read 阶段,对排好序的两张分区表数据执行join操作。join操作很简单,分别遍历两个有序序列,碰到相同join key就merge输出,否则取更小一边\n在shuffle read阶段,分别对streamIter和buildIter进行merge sort,在遍历streamIter时,对于每条记录,都采用顺序查找的方式从buildIter查找对应的记录,由于两个表都是排序的,每次处理完streamIter的一条记录后,对于streamIter的下一条记录,只需从buildIter中上一次查找结束的位置开始查找,所以说每次在buildIter中查找不必重头开始,整体上来说,查找性能还是较优的。\n仔细分析的话会发现,sort-merge join的代价并不比shuffle hash join小,反而是多了很多。那为什么SparkSQL还会在两张大表的场景下选择使用sort-merge join算法呢?这和Spark的shuffle实现有关,目前spark的shuffle实现都适用sort-based shuffle算法,因此在经过shuffle之后partition数据都是按照key排序的。因此理论上可以认为数据经过shuffle之后是不需要sort的,可以直接merge。\nBroadcast Join实现 为了能具有相同key的记录分到同一个分区,我们通常是做shuffle,而shuffle在Spark中是比较耗时的操作,我们应该尽可能的设计Spark应用使其避免大量的shuffle。。那么如果buildIter是一个非常小的表,那么其实就没有必要大动干戈做shuffle了,直接将buildIter广播到每个计算节点,然后将buildIter放到hash表中,如下图所示。 在执行上,主要可以分为以下两步:\nbroadcast阶段:将小表广播分发到大表所在的所有主机。分发方式可以有driver分发,或者采用p2p方式。 hash join阶段:在每个executor上执行单机版hash join,小表映射,大表试探; Broadcast Join的条件有以下几个:\n被广播的表需要小于spark.sql.autoBroadcastJoinThreshold所配置的值,默认是10M (或者加了broadcast join的hint) 基表不能被广播,比如left outer join时,只能广播右表 Hive Join Hive中的Join可分为Common Join(Reduce阶段完成join)和Map Join(Map阶段完成join)。\nHive Common Join 如果不指定MapJoin或者不符合MapJoin的条件,那么Hive解析器会默认把执行Common Join,即在Reduce阶段完成join。整个过程包含Map、Shuffle、Reduce阶段。\nMap阶段 读取源表的数据,Map输出时候以Join on条件中的列为key,如果Join有多个关联键,则以这些关联键的组合作为key;Map输出的value为join之后所关心的(select或者where中需要用到的)列,同时在value中还会包含表的Tag信息,用于标明此value对应哪个表。\nShuffle阶段 根据key的值进行hash,并将key/value按照hash值推送至不同的reduce中,这样确保两个表中相同的key位于同一个reduce中。\nReduce阶段 根据key的值完成join操作,期间通过Tag来识别不同表中的数据。\n1 2 3 SELECT a.id,a.dept,b.age FROM a join b ON (a.id = b.id); Hive Map Join MapJoin通常用于一个很小的表和一个大表进行join的场景,具体小表有多小,由参数hive.mapjoin.smalltable.filesize来决定,默认值为25M。满足条件的话Hive在执行时候会自动转化为MapJoin,或使用hint提示 /*+ mapjoin(table) */执行MapJoin。 如上图中的流程,首先Task A在客户端本地执行,负责扫描小表b的数据,将其转换成一个HashTable的数据结构,并写入本地的文件中,之后将该文件加载到DistributeCache中。 接下来的Task B任务是一个没有Reduce的MapReduce,启动MapTasks扫描大表a,在Map阶段,根据a的每一条记录去和DistributeCache中b表对应的HashTable关联,并直接输出结果,因为没有Reduce,所以有多少个Map Task,就有多少个结果文件。 注意:Map JOIN不适合FULL/RIGHT OUTER JOIN。\n","permalink":"https://reid00.github.io/en/posts/computation/spark-join-%E5%8E%9F%E7%90%86%E8%AF%A6%E8%A7%A3/","summary":"介绍 Join大致包括三个要素:Join方式、Join条件以及过滤条件。其中过滤条件也可以通过AND语句放在Join条件中。 Spark支持的J","title":"Spark Join 原理详解"},{"content":"前言 刚工作那会,有一次,上游调用我服务的老哥说,你的服务报\u0026quot;502错误了,快去看看是为什么吧\u0026quot;。\n当时那个服务里正好有个调用日志,平时会记录各种200,4xx状态码的信息。于是我跑到服务日志里去搜索了一下502这个数字,毫无发现。于是跟老哥说,\u0026quot;服务日志里并没有502的记录,你是不是搞错啦?\u0026quot;\n现在想来,多少有些不好意思。\n不知道有多少老哥是跟当时的我是一样的,这篇文章,就来聊聊502错误是什么?\n我们从状态码是什么开始聊起。\nHTTP状态码 我们平时在浏览器里逛的某宝和某度,其实都是一个个前端网页。 一般来说,前端并不存储太多数据,大部分时候都需要从后端服务器那获取数据。 于是前后端之间需要通过TCP协议去建立连接,然后在TCP的基础上传输数据。\n而TCP是基于数据流的协议,传输数据时,并不会为每个消息加入数据边界,直接使用裸的TCP进行数据传输会有\u0026quot;粘包\u0026quot;问题。\n因此需要用特地的协议格式去对数据进行解析。于是在此基础上设计了HTTP协议。详细的内容可以看我之前写的《既然有HTTP协议,为什么还要有RPC》。\n比如,我想要看某个商品的具体信息,其实就是前端发的HTTP请求中传入商品的id,后端返回的HTTP响应中返回商品的价格,商店名,发货地址的信息等。\n这样,表面上,我们是在刷着各种网页,实际上背后正有多次HTTP消息在不断进行收发。\n但问题就来了,上面提到的都是正常情况,如果有异常情况呢,比如前端发的数据,根本就不是个商品id,而是一张图片,这对于后端服务端来说是不可能给出正常响应的,于是就需要设计一套HTTP状态码,用来标识这次HTTP请求响应流程是否正常。通过这个可以影响浏览器的行为。\n比方说一切正常,那服务端返回个200状态码,前端收到后,可以放心使用响应的数据。但如果服务端发现客户端发的东西异常,就响应个4xx状态码,意思是这是个客户端的错误,4xx里头的xx可以根据错误的类型,再细分成各种码,比如401是客户端没权限,404是客户端请求了一个根本不存在的网页。反过来,如果是服务器有问题,就返回5xx状态码。\n但问题就来了。 服务端都有问题了,搞严重点,服务器可能直接就崩溃了,那它还怎么给你返回状态码? 是的,这种情况,服务端是不可能给客户端返回状态码的。所以说,一般情况下5xx的状态码其实并不是服务器返回给客户端的。 它们是由网关返回的,常见的网关,比如nginx。\nnginx的作用 回到前后端交互数据的话题上,如果前端用户少,那后端处理起请求来,游刃有余。但随着用户越来越多,后端服务器受资源限制,cpu或者内存都可能会严重不足,这时候解决方案也很简单,多搞几台一样的服务器,这样就能将这些前端请求均摊给几个服务器,从而提升处理能力。\n但要实现这样的效果,前端就得知道后端具体有哪些个服务器,并一一跟他们建立TCP连接。\n也不是不行,但就是麻烦。\n但这时候如果能有个中间层挡在它们中间就好了,这样客户端只需要跟中间层连接,中间层再和服务器建立连接。\n于是,这个中间层就成了这帮服务器的一个代理人一样,客户端有啥事都找代理人,只管发出自己的请求,再由代理人去找某个服务器去完成响应。整个过程下来,客户端只知道自己的请求被代理人帮忙搞定了,但代理人具体找了那个服务器去完成,客户端并不知道,也不需要知道。\n像这种,屏蔽掉具体有哪些服务器的代理方式就是所谓的反向代理。\n反过来,屏蔽掉具体有哪些客户端的代理方式,就是所谓的正向代理。\n而这个中间层的角色,一般由nginx这类网关来充当。\n另外,由于背后的服务器可能性能配置各不相同,有些4核8G,有些2核4G,nginx能为它们加上不同的访问权重,权重高的多转发点请求,通过这个方式实现不同的负载均衡策略。\nnginx返回5xx状态码 有了nginx这一中间层后,客户端从直连服务端,变成客户端直连nginx,再由nginx直连服务端。从一个TCP连接变成两个TCP连接。\n于是,当服务器发生异常时,nginx发送给服务器的那条TCP连接就不能正常响应,nginx在得到这一信息后,就会返回5xx错误码给客户端,也就是说5xx的报错,其实是由nginx识别出来,并返回给客户端的,服务端本身,并不会有5xx的日志信息。所以才会出现文章开头的一幕,上游收到了我服务的502报错,但我在自己的服务日志里却搜索不到这一信息。\n产生502的常见原因 在rfc7231中有关于502错误码的官方解释是\n1 2 502 Bad Gateway The 502 (Bad Gateway) status code indicates that the server, while acting as a gateway or proxy, received an invalid response from an inbound server it accessed while attempting to fulfill the request. 翻译一下就是,502 (Bad Gateway) 状态代码表示服务器在充当网关或代理时,在尝试满足请求时从它访问的入站服务器接收到无效响应。\n汝听,人言否?\n这对于大部分编程小白来说,不仅没解释到问题,反而只会冒出更多的问号。比如,这上面提到的无效响应到底指的是什么??\n我来解释下,它其实是说,502其实是由网关代理(nginx)发出的,是因为网关代理把客户端的请求转发给了服务端,但服务端却发出了无效响应,而这里的无效响应,一般是指TCP的RST报文或四次挥手的FIN报文。\n四次挥手估计大家背的很熟了,所以略过,我们来重点说下RST报文是什么。\nRST是什么? 我们都知道TCP正常情况下断开连接是用四次挥手,那是正常时候的优雅做法。\n但异常情况下,收发双方都不一定正常,连挥手这件事本身都可能做不到,所以就需要一个机制去强行关闭连接。\nRST 就是用于这种情况,一般用来异常地关闭一个连接。它是TCP包头中的一个标志位,在收到置这个标志位的数据包后,连接就会被关闭,此时接收到 RST的一方,在应用层会看到一个 connection reset 或 connection refused 的报错。\n而之所以发出RST报文,一般有两个常见原因。\n服务端过早断开连接 nginx与服务端之间有一条TCP连接,在nginx将客户端请求转发给服务端时,他两之间按道理会一直保持这条连接,直到服务端将结果正常返回后,再断开连接。\n但如果服务端过早断开连接,而nginx却还继续发消息过去,nginx就会收到服务端内核返回的RST报文或四次挥手的FIN报文,迫使nginx那边的连接结束。\n过早断开连接的原因常见的有两个。\n第一个是,服务端设置的超时时间过短。不管是用的哪种编程语言,一般都有现成的HTTP库,服务端一般都会有几个timeout参数,比如golang的HTTP服务框架里有个写超时(WriteTimeout),假设设置了2s,那它的含义就是,服务端在收到请求后需要在2s内处理完并将结果写到响应中,如果等不到,就会将连接给断掉。\n比如你的接口处理时间是5s,而你的WriteTimeout却只有2s,在没等到响应写完之前,HTTP框架就会主动将连接给断开。nginx此时就有可能收到四次挥手的FIN报文(有些框架也可能发RST报文),然后断开连接,于是客户端就会收到一个502报错。\n遇到这种问题,将WriteTimeout的时间调大一些就好了。\n第二个原因,也是造成502状态码最常见的原因,就是服务端应用进程崩了(crash)。\n服务端崩了,也就是当前没有一个进程在监听服务器端口,而此时你却尝试向一个不存在的端口发数据,服务器的linux内核协议栈就会响应一个RST数据包。同样,这时候nginx也会给客户端一个502。\n在开发过程中,这种情况是最常见的。\n现在我们大部分的服务器都会将挂掉的服务重启,因此我们需要判断下服务是否曾经崩溃过。\n如果你有对服务端的cpu或者内存做过监控,可以看下CPU或内存的监控图是否出现过断崖式的突然下跌。如果有,十有八九百,就是你的服务端应用程序曾经崩溃过。\n除此之外你还通过下面的命令,看下进程上次的启动时间是什么时候。\n1 ps -o lstart {pid} 比如我要看的进程id是13515,命令就需要像下面这样。\n1 2 3 # ps -o lstart 13515 STARTED Wed Aug 31 14:28:53 2022 可以看到它上次的启动时间是8月31日,这个时间如果跟你印象中的操作时间有差距,那说明进程可能是崩了之后被重新拉起了。\n遇到这种问题,最重要的是找出崩溃的原因,崩溃的原因就多种多样了,比如,对未初始化的内存地址进行写操作,或者内存访问越界(数组arr长度明明只有2,代码却读arr[3])。\n这种情况几乎都是程序有代码逻辑问题,崩溃一般也会留下代码堆栈,可以根据堆栈报错去排查问题,修复之后就好了。比如下面这张图是golang的报错堆栈信息,其他语言的也类似。\n不打印堆栈的情况 但有一些情况,有时候根本不留下堆栈。\n比如内存泄露导致进程占用内存越来越多,最后导致超过服务器的最大内存限制,触发OOM(out of memory), 进程直接就被操作系统kill掉。\n还有更隐蔽的,代码逻辑里隐藏了主动退出进程的操作。比如golang的日志打印里有个方法叫log.Fatalln(),打印完日志还会顺便执行os.Exit()直接退出进程,对源码不了解的新手很容易犯这个错。\n如果你很明确,你的服务没有崩过。那继续往下看。\n网关将请求打到了一个不存在的IP上 nginx是通过配置的形式来代理多个服务器。这个配置一般是放在 /etc/nginx/nginx.conf 中。\n打开它,你可能会看到类似下面这样的信息。\n1 2 3 4 5 6 upstream xiaobaidebug.top { server 10.14.12.19:9235 weight=2; server 10.14.16.13:8145 weight=5; server 10.14.12.133:9702 weight=8; server 10.14.11.15:7035 weight=10; } 上面配置的含义是,如果客户端访问xiaobaidebug.top域名,nginx就会将客户端的请求转发到下面的4个服务器ip上,ip边上还有个weight权重,权重越高,被转发到的次数就越多。\n可以看出,nginx具有相当丰富的配置能力。但要注意的是,这些个文件是需要自己手动配置的。对于服务器少,且不怎么变化的情况,这当然没问题。\n但现在已经是云原生时代了,很多公司内部都有自己的云产品,服务自然也会上云。一般来说每次更新服务,都可能会将服务部署到一台新的机器上。而这个ip也会随着改变,难道每发布一次服务,都需要手动去nginx上改配置吗?这显然不现实。\n如果能在服务启动时,让服务主动将自己的ip告诉nginx,然后nginx自己生成这样的一个配置并重新加载,那事情就简单多了。\n为了实现这样一个服务注册的功能,不少公司都会基于nginx进行二次开发。\n但如果这个服务注册功能有问题,比方说服务启动后,新服务没注册上,但老服务已经被销毁了。这时候nginx还将请求打到老服务的IP上,由于老服务所在的机器已经没有这个服务了,所以服务器内核就会响应RST,nginx收到RST后回复502给客户端。\n要排查这种问题也不难。\n这个时候,你可以看下nginx侧是否有打印相关的日志,看下转发的IP端口是否符合预期。\n如果不符合预期,可以去找找做这个基础组件的同事,进行一波友好的交流。\n总结 HTTP状态码用来表示响应结果的状态,其中200是正常响应,4xx是客户端错误,5xx是服务端错误。 客户端和服务端之间加入nginx,可以起到反向代理和负载均衡的作用,客户端只管向nginx请求数据,并不关心这个请求具体由哪个服务器来处理。 后端服务端应用如果发生崩溃,nginx在访问服务端时会收到服务端返回的RST报文,然后给客户端返回502报错。502并不是服务端应用发出的,而是nginx发出的。因此发生502时,后端服务端很可能没有没有相关的502日志,需要在nginx侧才能看到这条502日志。 如果发现502,优先通过监控排查服务端应用是否发生过崩溃重启,如果是的话,再看下是否留下过崩溃堆栈日志,如果没有日志,看下是否可能是oom或者是其他原因导致进程主动退出。如果进程也没崩溃过,去排查下nginx的日志,看下是否将请求打到了某个不知名IP端口上。 参考 原文 ","permalink":"https://reid00.github.io/en/posts/os_network/http-502-%E9%97%AE%E9%A2%98-%E6%8E%92%E6%9F%A5/","summary":"前言 刚工作那会,有一次,上游调用我服务的老哥说,你的服务报\u0026quot;502错误了,快去看看是为什么吧\u0026quot;。 当时那个服务里正好有个调","title":"Http 502 问题 排查"},{"content":"介绍 事实上,这两个完全是两样不同东西,实现的层面也不同:\nHTTP 的 Keep-Alive,是由应用层(用户态) 实现的,称为 HTTP 长连接; TCP 的 Keepalive,是由 TCP 层(内核态) 实现的,称为 TCP 保活机制; 接下来,分别说说它们。\nHTTP 的 Keep-Alive HTTP 协议采用的是「请求-应答」的模式,也就是客户端发起了请求,服务端才会返回响应,一来一回这样子。\n由于 HTTP 是基于 TCP 传输协议实现的,客户端与服务端要进行 HTTP 通信前,需要先建立 TCP 连接,然后客户端发送 HTTP 请求,服务端收到后就返回响应,至此「请求-应答」的模式就完成了,随后就会释放 TCP 连接。\n如果每次请求都要经历这样的过程:建立 TCP -\u0026gt; 请求资源 -\u0026gt; 响应资源 -\u0026gt; 释放连接,那么此方式就是 HTTP 短连接,如下图:\n这样实在太累人了,一次连接只能请求一次资源。\n能不能在第一个 HTTP 请求完后,先不断开 TCP 连接,让后续的 HTTP 请求继续使用此连接?\n当然可以,HTTP 的 Keep-Alive 就是实现了这个功能,可以使用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,避免了连接建立和释放的开销,这个方法称为 HTTP 长连接。\nHTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。\n怎么才能使用 HTTP 的 Keep-Alive 功能?\n在 HTTP 1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的包头中添加:\n1 Connection: Keep-Alive 然后当服务器收到请求,作出回应的时候,它也添加一个头在响应中:\n1 Connection: Keep-Alive 这样做,连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个连接。这一直继续到客户端或服务器端提出断开连接。\n从 HTTP 1.1 开始, 就默认是开启了 Keep-Alive,如果要关闭 Keep-Alive,需要在 HTTP 请求的包头里添加:\n1 Connection:close 现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。\nHTTP 长连接不仅仅减少了 TCP 连接资源的开销,而且这给 HTTP 流水线技术提供了可实现的基础。\n所谓的 HTTP 流水线,是客户端可以先一次性发送多个请求,而在发送过程中不需先等待服务器的回应,可以减少整体的响应时间。\n举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。HTTP 流水线机制则允许客户端同时发出 A 请求和 B 请求。\n但是服务器还是按照顺序响应,先回应 A 请求,完成后再回应 B 请求。\n而且要等服务器响应完客户端第一批发送的请求后,客户端才能发出下一批的请求,也就说如果服务器响应的过程发生了阻塞,那么客户端就无法发出下一批的请求,此时就造成了「队头阻塞」的问题。\n可能有的同学会问,如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗?\n对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。\n比如设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。\nTCP 的 Keepalive TCP 的 Keepalive 这东西其实就是 TCP 的保活机制,它的工作原理我之前的文章写过,这里就直接贴下以前的内容。\n如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。\n如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。 如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活,这个工作是在内核完成的。\n注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。\n总结 HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。\nTCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接。\n原文\n","permalink":"https://reid00.github.io/en/posts/os_network/http%E9%95%BF%E8%BF%9E%E6%8E%A5%E5%92%8Ctcp%E9%95%BF%E8%BF%9E%E6%8E%A5%E7%9A%84%E5%8C%BA%E5%88%AB/","summary":"介绍 事实上,这两个完全是两样不同东西,实现的层面也不同: HTTP 的 Keep-Alive,是由应用层(用户态) 实现的,称为 HTTP 长连接; TCP 的 Keepal","title":"Http长连接和TCP长连接的区别"},{"content":"1. Raft 算法简介 1.1 Raft 背景 在分布式系统中,一致性算法至关重要。在所有一致性算法中,Paxos 最负盛名,它由莱斯利·兰伯特(Leslie Lamport)于 1990 年提出,是一种基于消息传递的一致性算法,被认为是类似算法中最有效的。\nPaxos 算法虽然很有效,但复杂的原理使它实现起来非常困难,截止目前,实现 Paxos 算法的开源软件很少,比较出名的有 Chubby、LibPaxos。此外,Zookeeper 采用的 ZAB(Zookeeper Atomic Broadcast)协议也是基于 Paxos 算法实现的,不过 ZAB 对 Paxos 进行了很多改进与优化,两者的设计目标也存在差异——ZAB 协议主要用于构建一个高可用的分布式数据主备系统,而 Paxos 算法则是用于构建一个分布式的一致性状态机系统。\n由于 Paxos 算法过于复杂、实现困难,极大地制约了其应用,而分布式系统领域又亟需一种高效而易于实现的分布式一致性算法,在此背景下,Raft 算法应运而生。\nRaft 算法在斯坦福 Diego Ongaro 和 John Ousterhout 于 2013 年发表的《In Search of an Understandable Consensus Algorithm》中提出。相较于 Paxos,Raft 通过逻辑分离使其更容易理解和实现,目前,已经有十多种语言的 Raft 算法实现框架,较为出名的有 etcd、Consul 。\n本文基于论文In Search of an Understandable Consensus Algorithm对raft协议进行分析,当然,还是建议读者直接看论文。\n相关链接:\n论文 官网 动画展示 分布式共识算法核心理论基础 在正式谈raft之前,还需要简单介绍下分布式共识算法所基于的理论工具。分布式共识协议在复制状态机的背景下产生的。在该方法中,一组服务器上的状态机计算相同的副本,即便某台机器宕机依然会继续运行。复制状态机是基于日志实现的。在这里有必要唠叨两句日志的特性。日志可以看做一个简单的存储抽象,append only,按照时间完全有序,注意这里面的日志并不是log4j或是syslog打出来的业务日志,那个我们称之为应用日志,这里的日志是用于程序访问的存储结构。有了上面的限制,使用日志就能够保证这样一件事。如图所示 我有一个日志,里面存储的是一系列的对数据的操作,此时系统外部有一系列输入数据,输入到这个日志中,经过日志中一系列command操作,由于日志的确定性和有序性,保证最后得到的输出序列也应该是确定的。扩展到分布式的场景,此时每台机器上所有了这么一个日志,此时我需要做的事情就是保证这几份日志是完全一致的。详细步骤就引出了论文中的那张经典的复制状态机的示意图 如图所示,server中的共识模块负责接收由client发送过来的请求,将请求中对应的操作记录到自己的日志中,同时通知给其他机器,让他们也进行同样的操作最终保证所有的机器都在日志中写入了这条操作。然后返回给客户端写入成功。复制状态机用于解决分布式中系统中的各种容错问题,例如master的高可用,例如Chubby以及ZK都是复制状态机,\n分布式一致性算法,通常满足以下性质: 在非拜占庭错误下,保证安全性(不会返回不正确的结果) 大多数机器运行,系统就可以正常运行,发生故障的机器在恢复正常后可以重新正常的加入到集群中 不依赖时序来保证日志的一致性 通常情况下,大多数机器就可以做出响应了,少数慢节点并不会拉低整个系统的性能 1.2 Raft 基本概念 首先我将整体串一遍raft,然后抽提出里面的相关概念进行说明。\n一个raft协议通常包含若干个节点,通常5个(2n+1),它最多允许其中的n个节点挂掉。在任何时刻,任何节点都会处在下列三种状态之一,Leader,Follower以及Candidate。在系统正常运行的过程中,系统中会有一个Leader,其他节点都处于Follower状态,Leader负责处理所有来自客户端的请求,Follower如果收到请求会将请求路由到Leader,Follower只是被动的接收leader和candidate的请求,它自己不会对外发出请求。而candidate是用于做leader选举的状态。正常情况下leader会向follower汇报心跳,证明自己是当前系统的leader,这样所有follower就会老老实实负责同步leader的日志内容变更。当一段时间(随机时间)follower收不到leader的心跳信息时,会认为此时系统处于无leader状态,那么自己会转换到candidate状态并发起leader选举。\nraft将整个时间分为若干个长度不一的片段,每一个段叫做一个任期(term),一次新的选主操作会触发一次term的更新,这里term就可以理解为逻辑时钟的概念。raft规定,一个term最多只有一个leader,可能没有,这是因为可能多个follower同时发现没有leader同时发起选主,瓜分选票。节点间在通信过程中会交换term,这样做的目的是为了唯一确认当前应该是谁在当政。如果某个candidate或leader发现自己的term小于当前的就会自觉地退到follower状态。同样的如果某个节点收到包含过期term的请求,则会直接拒绝该请求。\nraft节点间使用RPC进行通信,基本的有关一致性算法的有两种基本RPC类型,分别为请求投票(RequestVotes)以及追加日志(AppendEntries),在日志压缩方面还有另外一种RPC类型(InstallSnapshot),后面会详细说明。其中日志中由若干个条目组成,每个条目都有一个Index标识。\n至此raft的所有概念都出来了,我简单列举一下:\n1 2 3 4 5 6 7 8 Leader:节点状态一种,用于处理所有来自client请求,并将自己的日志追加行为广播到所有的follower上 Follower:节点状态一种,用于接收leader心跳,将leader的日志变更同步到自己的状态机日志中,在选举时给candidate投票 Candidate:节点状态一种,当follower发现没有leader时发起选主请求,极有可能成为下一任leader term:用于标记当前leader/请求的有效性,一种逻辑时钟 Index:用于表示复制状态机中日志的条目 RequestVotes:candidate要求选主发送给Follower的RPC请求 AppendEntries:leader给follower发送的添加日志条目(心跳)的请求 InstallSnapshot:生成日志快照的RPC请求 1.3 Raft协议核心特性 文章中列举了五条raft的核心特性,也可以说这是raft设计的原则\n选举安全(Election Safety):在一个term中最多只能存在一个leader leader日志追加(Leader Append-Only):leader不能覆盖或删除日志中的内容,只能新增日志 日志匹配(Log Matching):如果两个日志有相同的index和任期,那么在这个任期前的所有日志条目全部相同 Leader强制完成(Leader Completeness):如果再某个任期内提交了某条日志条目,那么这个任期前面的日志也是确认被提交的 状态机确定性(State Machine Safety):如果一台服务器将某一个index的日志条目应用到自己的状态机上,那么其他服务器不可能在同一个index上应用不同的日志条目 1.4 Raft 角色 根据官方文档解释,一个 Raft 集群包含若干节点,Raft 把这些节点分为三种状态:Leader、 Follower、Candidate,每种状态负责的任务也是不一样的。正常情况下,集群中的节点只存在 Leader 与 Follower 两种状态。\nLeader(领导者):负责日志的同步管理,处理来自客户端的请求,与Follower保持heartBeat的联系; Follower(追随者):响应 Leader 的日志同步请求,响应Candidate的邀票请求,以及把客户端请求到Follower的事务转发(重定向)给Leader; Candidate(候选者):负责选举投票,集群刚启动或者Leader宕机时,状态为Follower的节点将转为Candidate并发起选举,选举胜出(获得超过半数节点的投票)后,从Candidate转为Leader状态。 1.5 Raft 三个子问题 通常,Raft 集群中只有一个 Leader,其它节点都是 Follower。Follower 都是被动的,不会发送任何请求,只是简单地响应来自 Leader 或者 Candidate 的请求。Leader 负责处理所有的客户端请求(如果一个客户端和 Follower 联系,那么 Follower 会把请求重定向给 Leader)。\n为简化逻辑和实现,Raft 将一致性问题分解成了三个相对独立的子问题。\n选举(Leader Election):当 Leader 宕机或者集群初创时,一个新的 Leader 需要被选举出来; 日志复制(Log Replication):Leader 接收来自客户端的请求并将其以日志条目的形式复制到集群中的其它节点,并且强制要求其它节点的日志和自己保持一致; 安全性(Safety):如果有任何的服务器节点已经应用了一个确定的日志条目到它的状态机中,那么其它服务器节点不能在同一个日志索引位置应用一个不同的指令。 2. Raft 算法之 Leader Election 原理 根据 Raft 协议,一个应用 Raft 协议的集群在刚启动时,所有节点的状态都是 Follower。由于没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),因此,Followers 会认为 Leader 已经下线,进而转为 Candidate 状态。然后,Candidate 将向集群中其它节点请求投票,同意自己升级为 Leader。如果 Candidate 收到超过半数节点的投票(N/2 + 1),它将获胜成为 Leader。\n第一阶段:所有节点都是 Follower。 上面提到,一个应用 Raft 协议的集群在刚启动(或 Leader 宕机)时,所有节点的状态都是 Follower,初始 Term(任期)为 0。同时启动选举定时器,每个节点的选举定时器超时时间都在 100~500 毫秒之间且并不一致(避免同时发起选举)。 第二阶段:Follower 转为 Candidate 并发起投票。 没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),节点启动后在一个选举定时器周期内未收到心跳和投票请求,则状态转为候选者 Candidate 状态,且 Term 自增,并向集群中所有节点发送投票请求并且重置选举定时器。\n注意,由于每个节点的选举定时器超时时间都在 100-500 毫秒之间,且彼此不一样,以避免所有 Follower 同时转为 Candidate 并同时发起投票请求。换言之,最先转为 Candidate 并发起投票请求的节点将具有成为 Leader 的“先发优势”。 第三阶段:投票策略。 节点收到投票请求后会根据以下情况决定是否接受投票请求:\n请求节点的 Term 大于自己的 Term,且自己尚未投票给其它节点,则接受请求,把票投给它; 请求节点的 Term 小于自己的 Term,且自己尚未投票,则拒绝请求,将票投给自己。 第四阶段:Candidate 转为 Leader。 一轮选举过后,正常情况下,会有一个 Candidate 收到超过半数节点(N/2 + 1)的投票,它将胜出并升级为 Leader。然后定时发送心跳给其它的节点,其它节点会转为 Follower 并与 Leader 保持同步,到此,本轮选举结束。\n注意:有可能一轮选举中,没有 Candidate 收到超过半数节点投票,那么将进行下一轮选举。 3. Raft 算法之 Log Replication 原理 在一个 Raft 集群中,只有 Leader 节点能够处理客户端的请求(如果客户端的请求发到了 Follower,Follower 将会把请求重定向到 Leader),客户端的每一个请求都包含一条被复制状态机执行的指令。Leader 把这条指令作为一条新的日志条目(Entry)附加到日志中去,然后并行得将附加条目发送给 Followers,让它们复制这条日志条目。\n当这条日志条目被 Followers 安全复制,Leader 会将这条日志条目应用到它的状态机中,然后把执行的结果返回给客户端。如果 Follower 崩溃或者运行缓慢,再或者网络丢包,Leader 会不断得重复尝试附加日志条目(尽管已经回复了客户端)直到所有的 Follower 都最终存储了所有的日志条目,确保强一致性。\n第一阶段:客户端请求提交到 Leader。 如下图所示,Leader 收到客户端的请求,比如存储数据 5。Leader 在收到请求后,会将它作为日志条目(Entry)写入本地日志中。需要注意的是,此时该 Entry 的状态是未提交(Uncommitted),Leader 并不会更新本地数据,因此它是不可读的。 第二阶段:Leader 将 Entry 发送到其它 Follower Leader 与 Floolwers 之间保持着心跳联系,随心跳 Leader 将追加的 Entry(AppendEntries)并行地发送给其它的 Follower,并让它们复制这条日志条目,这一过程称为复制(Replicate)。\n有几点需要注意:\n为什么 Leader 向 Follower 发送的 Entry 是 AppendEntries 呢? 因为 Leader 与 Follower 的心跳是周期性的,而一个周期间 Leader 可能接收到多条客户端的请求,因此,随心跳向 Followers 发送的大概率是多个 Entry,即 AppendEntries。当然,在本例中,我们假设只有一条请求,自然也就是一个Entry了。\nLeader 向 Followers 发送的不仅仅是追加的 Entry(AppendEntries)。 在发送追加日志条目的时候,Leader 会把新的日志条目紧接着之前条目的索引位置(prevLogIndex), Leader 任期号(Term)也包含在其中。如果 Follower 在它的日志中找不到包含相同索引位置和任期号的条目,那么它就会拒绝接收新的日志条目,因为出现这种情况说明 Follower 和 Leader 不一致。\n如何解决 Leader 与 Follower 不一致的问题? 在正常情况下,Leader 和 Follower 的日志保持一致,所以追加日志的一致性检查从来不会失败。然而,Leader 和 Follower 一系列崩溃的情况会使它们的日志处于不一致状态。Follower可能会丢失一些在新的 Leader 中有的日志条目,它也可能拥有一些 Leader 没有的日志条目,或者两者都发生。丢失或者多出日志条目可能会持续多个任期。\n要使 Follower 的日志与 Leader 恢复一致,Leader 必须找到最后两者达成一致的地方(说白了就是回溯,找到两者最近的一致点),然后删除从那个点之后的所有日志条目,发送自己的日志给 Follower。所有的这些操作都在进行附加日志的一致性检查时完成。\nLeader 为每一个 Follower 维护一个 nextIndex,它表示下一个需要发送给 Follower 的日志条目的索引地址。当一个 Leader 刚获得权力的时候,它初始化所有的 nextIndex 值,为自己的最后一条日志的 index 加 1。如果一个 Follower 的日志和 Leader 不一致,那么在下一次附加日志时一致性检查就会失败。在被 Follower 拒绝之后,Leader 就会减小该 Follower 对应的 nextIndex 值并进行重试。最终 nextIndex 会在某个位置使得 Leader 和 Follower 的日志达成一致。当这种情况发生,附加日志就会成功,这时就会把 Follower 冲突的日志条目全部删除并且加上 Leader 的日志。一旦附加日志成功,那么 Follower 的日志就会和 Leader 保持一致,并且在接下来的任期继续保持一致。 第三阶段:Leader 等待 Followers 回应。 Followers 接收到 Leader 发来的复制请求后,有两种可能的回应:\n写入本地日志中,返回 Success; 一致性检查失败,拒绝写入,返回 False,原因和解决办法上面已做了详细说明。 需要注意的是,此时该 Entry 的状态也是未提交(Uncommitted)。完成上述步骤后,Followers 会向 Leader 发出 Success 的回应,当 Leader 收到大多数 Followers 的回应后,会将第一阶段写入的 Entry 标记为提交状态(Committed),并把这条日志条目应用到它的状态机中。 第四阶段:Leader 回应客户端。 完成前三个阶段后,Leader会向客户端回应 OK,表示写操作成功。 第五阶段,Leader 通知 Followers Entry 已提交 Leader 回应客户端后,将随着下一个心跳通知 Followers,Followers 收到通知后也会将 Entry 标记为提交状态。至此,Raft 集群超过半数节点已经达到一致状态,可以确保强一致性。\n需要注意的是,由于网络、性能、故障等各种原因导致“反应慢”、“不一致”等问题的节点,最终也会与 Leader 达成一致。 4. Raft 算法之安全性 前面描述了 Raft 算法是如何选举 Leader 和复制日志的。然而,到目前为止描述的机制并不能充分地保证每一个状态机会按照相同的顺序执行相同的指令。例如,一个 Follower 可能处于不可用状态,同时 Leader 已经提交了若干的日志条目;然后这个 Follower 恢复(尚未与 Leader 达成一致)而 Leader 故障;如果该 Follower 被选举为 Leader 并且覆盖这些日志条目,就会出现问题,即不同的状态机执行不同的指令序列。\n鉴于此,在 Leader 选举的时候需增加一些限制来完善 Raft 算法。这些限制可保证任何的 Leader 对于给定的任期号(Term),都拥有之前任期的所有被提交的日志条目(所谓 Leader 的完整特性)。关于这一选举时的限制,下文将详细说明。\n4.1 选举限制 在所有基于 Leader 机制的一致性算法中,Leader 都必须存储所有已经提交的日志条目。为了保障这一点,Raft 使用了一种简单而有效的方法,以保证所有之前的任期号中已经提交的日志条目在选举的时候都会出现在新的 Leader 中。换言之,日志条目的传送是单向的,只从 Leader 传给 Follower,并且 Leader 从不会覆盖自身本地日志中已经存在的条目。\nRaft 使用投票的方式来阻止一个 Candidate 赢得选举,除非这个 Candidate 包含了所有已经提交的日志条目。Candidate 为了赢得选举必须联系集群中的大部分节点。这意味着每一个已经提交的日志条目肯定存在于至少一个服务器节点上。如果 Candidate 的日志至少和大多数的服务器节点一样新(这个新的定义会在下面讨论),那么它一定持有了所有已经提交的日志条目(多数派的思想)。投票请求的限制中请求中包含了 Candidate 的日志信息,然后投票人会拒绝那些日志没有自己新的投票请求。\nRaft 通过比较两份日志中最后一条日志条目的索引值和任期号,确定谁的日志比较新。如果两份日志最后条目的任期号不同,那么任期号大的日志更加新。如果两份日志最后的条目任期号相同,那么日志比较长的那个就更加新。\n总结 - 选举时: 保证新的 Leader 拥有所有已经提交的日志\n每个 Follower 节点在投票时会检查 Candidate 的日志索引,并拒绝为日志不完整的 Candidate 投赞成票 半数以上的 Follower 节点都投了赞成票,意味着 Candidate 中包含了所有可能已经被提交的日志 4.2 提交之前任期内的日志条目 如同 4.1 节介绍的那样,Leader 知道一条当前任期内的日志记录是可以被提交的,只要它被复制到了大多数的 Follower 上(多数派的思想)。如果一个 Leader 在提交日志条目之前崩溃了,继任的 Leader 会继续尝试复制这条日志记录。然而,一个 Leader 并不能断定被保存到大多数 Follower 上的一个之前任期里的日志条目 就一定已经提交了。这很明显,从日志复制的过程可以看出。\n鉴于上述情况,Raft 算法不会通过计算副本数目的方式去提交一个之前任期内的日志条目。只有 Leader 当前任期里的日志条目通过计算副本数目可以被提交;一旦当前任期的日志条目以这种方式被提交,那么由于日志匹配特性,之前的日志条目也都会被间接的提交。在某些情况下,Leader 可以安全地知道一个老的日志条目是否已经被提交(只需判断该条目是否存储到所有节点上),但是 Raft 为了简化问题使用了一种更加保守的方法。\n当 Leader 复制之前任期里的日志时,Raft 会为所有日志保留原始的任期号,这在提交规则上产生了额外的复杂性。但是,这种策略更加容易辨别出日志,即使随着时间和日志的变化,日志仍维护着同一个任期编号。此外,该策略使得新 Leader 只需要发送较少日志条目。\n总结 - 提交日志时: Leader 只主动提交自己任期内产生的日志\n如果记录是当前 Leader 所创建的,那么当这条记录被复制到大多数节点上时,Leader 就可以提交这条记录以及之前的记录 如果记录是之前 Leader 所创建的,则只有当前 Leader 创建的记录被提交后,才能提交这些由之前 Leader 创建的日志 5. 集群成员变更 到目前为止,我们都假设集群的配置(加入到一致性算法的服务器集合)是固定不变的。但是在实践中,偶尔是会改变集群的配置的,例如替换那些宕机的机器或者改变复制级别。尽管可以通过暂停整个集群,更新所有配置,然后重启整个集群的方式来实现,但是在更改的时候集群会不可用。另外,如果存在手工操作步骤,那么就会有操作失误的风险。为了避免这样的问题,我们决定自动化配置改变并且将其纳入到 Raft 一致性算法中来。 为了让配置修改机制能够安全,那么在转换的过程中不能够存在任何时间点使得两个领导人同时被选举成功在同一个任期里。不幸的是,任何服务器直接从旧的配置直接转换到新的配置的方案都是不安全的。一次性原子地转换所有服务器是不可能的,所以在转换期间整个集群存在划分成两个独立的大多数群体的可能性(见图)。 直接从一种配置转到新的配置是十分不安全的,因为各个机器可能在任何的时候进行转换。在这个例子中,集群配额从 3 台机器变成了 5 台。不幸的是,存在这样的一个时间点,两个不同的领导人在同一个任期里都可以被选举成功。一个是通过旧的配置,一个通过新的配置。\n为了保证安全性,配置更改必须使用两阶段方法。目前有很多种两阶段的实现。例如,有些系统在第一阶段停掉旧的配置所以集群就不能处理客户端请求;然后在第二阶段在启用新的配置。在 Raft 中,集群先切换到一个过渡的配置,我们称之为共同一致;一旦共同一致已经被提交了,那么系统就切换到新的配置上。共同一致是老配置和新配置的结合:\n日志条目被复制给集群中新、老配置的所有服务器。 新、旧配置的服务器都可以成为领导人。 达成一致(针对选举和提交)需要分别在两种配置上获得大多数的支持。 共同一致允许独立的服务器在不影响安全性的前提下,在不同的时间进行配置转换过程。此外,共同一致可以让集群在配置转换的过程中依然响应客户端的请求。 集群配置在复制日志中以特殊的日志条目来存储和通信;图 11 展示了配置转换的过程。当一个领导人接收到一个改变配置从 C-old 到 C-new 的请求,他会为了共同一致存储配置(图中的 C-old,new),以前面描述的日志条目和副本的形式。一旦一个服务器将新的配置日志条目增加到它的日志中,他就会用这个配置来做出未来所有的决定(服务器总是使用最新的配置,无论他是否已经被提交)。这意味着领导人要使用 C-old,new 的规则来决定日志条目 C-old,new 什么时候需要被提交。如果领导人崩溃了,被选出来的新领导人可能是使用 C-old 配置也可能是 C-old,new 配置,这取决于赢得选举的候选人是否已经接收到了 C-old,new 配置。在任何情况下, C-new 配置在这一时期都不会单方面的做出决定。\n一旦 C-old,new 被提交,那么无论是 C-old 还是 C-new,在没有经过他人批准的情况下都不可能做出决定,并且领导人完全特性保证了只有拥有 C-old,new 日志条目的服务器才有可能被选举为领导人。这个时候,领导人创建一条关于 C-new 配置的日志条目并复制给集群就是安全的了。再者,每个服务器在见到新的配置的时候就会立即生效。当新的配置在 C-new 的规则下被提交,旧的配置就变得无关紧要,同时不使用新的配置的服务器就可以被关闭了。如图 11,C-old 和 C-new 没有任何机会同时做出单方面的决定;这保证了安全性。 图 11:一个配置切换的时间线。虚线表示已经被创建但是还没有被提交的配置日志条目,实线表示最后被提交的配置日志条目。领导人首先创建了 C-old,new 的配置条目在自己的日志中,并提交到 C-old,new 中(C-old 的大多数和 C-new 的大多数)。然后他创建 C-new 条目并提交到 C-new 中的大多数。这样就不存在 C-new 和 C-old 可以同时做出决定的时间点。\n在关于重新配置还有三个问题需要提出。 第一个问题是,新的服务器可能初始化没有存储任何的日志条目。当这些服务器以这种状态加入到集群中,那么他们需要一段时间来更新追赶,这时还不能提交新的日志条目。为了避免这种可用性的间隔时间,Raft 在配置更新之前使用了一种额外的阶段,在这个阶段,新的服务器以没有投票权身份加入到集群中来(领导人复制日志给他们,但是不考虑他们是大多数)。一旦新的服务器追赶上了集群中的其他机器,重新配置可以像上面描述的一样处理。\n第二个问题是,集群的领导人可能不是新配置的一员。在这种情况下,领导人就会在提交了 C-new 日志之后退位(回到跟随者状态)。这意味着有这样的一段时间,领导人管理着集群,但是不包括他自己;他复制日志但是不把他自己算作是大多数之一。当 C-new 被提交时,会发生领导人过渡,因为这时是最早新的配置可以独立工作的时间点(将总是能够在 C-new 配置下选出新的领导人)。在此之前,可能只能从 C-old 中选出领导人。\n第三个问题是,移除不在 C-new 中的服务器可能会扰乱集群。这些服务器将不会再接收到心跳,所以当选举超时,他们就会进行新的选举过程。他们会发送拥有新的任期号的请求投票 RPCs,这样会导致当前的领导人回退成跟随者状态。新的领导人最终会被选出来,但是被移除的服务器将会再次超时,然后这个过程会再次重复,导致整体可用性大幅降低。\n为了避免这个问题,当服务器确认当前领导人存在时,服务器会忽略请求投票 RPCs。特别的,当服务器在当前最小选举超时时间内收到一个请求投票 RPC,他不会更新当前的任期号或者投出选票。这不会影响正常的选举,每个服务器在开始一次选举之前,至少等待一个最小选举超时时间。然而,这有利于避免被移除的服务器扰乱:如果领导人能够发送心跳给集群,那么他就不会被更大的任期号废黜。\n6. 日志压缩 Raft 的日志在正常操作中不断的增长,但是在实际的系统中,日志不能无限制的增长。随着日志不断增长,他会占用越来越多的空间,花费越来越多的时间来重置。如果没有一定的机制去清除日志里积累的陈旧的信息,那么会带来可用性问题。\n快照是最简单的压缩方法。在快照系统中,整个系统的状态都以快照的形式写入到稳定的持久化存储中,然后到那个时间点之前的日志全部丢弃。快照技术被使用在 Chubby 和 ZooKeeper 中,接下来的章节会介绍 Raft 中的快照技术。\n增量压缩的方法,例如日志清理或者日志结构合并树,都是可行的。这些方法每次只对一小部分数据进行操作,这样就分散了压缩的负载压力。首先,他们先选择一个已经积累的大量已经被删除或者被覆盖对象的区域,然后重写那个区域还活跃的对象,之后释放那个区域。和简单操作整个数据集合的快照相比,需要增加复杂的机制来实现。状态机可以实现 LSM tree 使用和快照相同的接口,但是日志清除方法就需要修改 Raft 了。 图 12:一个服务器用新的快照替换了从 1 到 5 的条目,快照值存储了当前的状态。快照中包含了最后的索引位置和任期号。\n图 12 展示了 Raft 中快照的基础思想。每个服务器独立的创建快照,只包括已经被提交的日志。主要的工作包括将状态机的状态写入到快照中。Raft 也包含一些少量的元数据到快照中:最后被包含索引指的是被快照取代的最后的条目在日志中的索引值(状态机最后应用的日志),最后被包含的任期指的是该条目的任期号。保留这些数据是为了支持快照后紧接着的第一个条目的附加日志请求时的一致性检查,因为这个条目需要前一日志条目的索引值和任期号。为了支持集群成员更新(第 5 节),快照中也将最后的一次配置作为最后一个条目存下来。一旦服务器完成一次快照,他就可以删除最后索引位置之前的所有日志和快照了。\n尽管通常服务器都是独立的创建快照,但是领导人必须偶尔的发送快照给一些落后的跟随者。这通常发生在当领导人已经丢弃了下一条需要发送给跟随者的日志条目的时候。幸运的是这种情况不是常规操作:一个与领导人保持同步的跟随者通常都会有这个条目。然而一个运行非常缓慢的跟随者或者新加入集群的服务器(第5节)将不会有这个条目。这时让这个跟随者更新到最新的状态的方式就是通过网络把快照发送给他们。\n安装快照 RPC: 由领导人调用以将快照的分块发送给跟随者。领导者总是按顺序发送分块。\n参数 解释 term 领导人的任期号 leaderId 领导人的 Id,以便于跟随者重定向请求 lastIncludedIndex 快照中包含的最后日志条目的索引值 lastIncludedTerm 快照中包含的最后日志条目的任期号 offset 分块在快照中的字节偏移量 data[] 从偏移量开始的快照分块的原始字节 done 如果这是最后一个分块则为 true 结果 解释 term 当前任期号(currentTerm),便于领导人更新自己 接收者实现:\n如果term \u0026lt; currentTerm就立即回复 如果是第一个分块(offset 为 0)就创建一个新的快照 在指定偏移量写入数据 如果 done 是 false,则继续等待更多的数据 保存快照文件,丢弃具有较小索引的任何现有或部分快照 如果现存的日志条目与快照中最后包含的日志条目具有相同的索引值和任期号,则保留其后的日志条目并进行回复 丢弃整个日志 使用快照重置状态机(并加载快照的集群配置) 在这种情况下领导人使用一种叫做安装快照的新的 RPC 来发送快照给太落后的跟随者;见图 13。当跟随者通过这种 RPC 接收到快照时,他必须自己决定对于已经存在的日志该如何处理。通常快照会包含没有在接收者日志中存在的信息。在这种情况下,跟随者丢弃其整个日志;它全部被快照取代,并且可能包含与快照冲突的未提交条目。如果接收到的快照是自己日志的前面部分(由于网络重传或者错误),那么被快照包含的条目将会被全部删除,但是快照后面的条目仍然有效,必须保留。\n这种快照的方式背离了 Raft 的强领导人原则,因为跟随者可以在不知道领导人情况下创建快照。但是我们认为这种背离是值得的。领导人的存在,是为了解决在达成一致性的时候的冲突,但是在创建快照的时候,一致性已经达成,这时不存在冲突了,所以没有领导人也是可以的。数据依然是从领导人传给跟随者,只是跟随者可以重新组织他们的数据了。\n我们考虑过一种替代的基于领导人的快照方案,即只有领导人创建快照,然后发送给所有的跟随者。但是这样做有两个缺点。第一,发送快照会浪费网络带宽并且延缓了快照处理的时间。每个跟随者都已经拥有了所有产生快照需要的信息,而且很显然,自己从本地的状态中创建快照比通过网络接收别人发来的要经济。第二,领导人的实现会更加复杂。例如,领导人需要发送快照的同时并行的将新的日志条目发送给跟随者,这样才不会阻塞新的客户端请求。\n还有两个问题影响了快照的性能。首先,服务器必须决定什么时候应该创建快照。如果快照创建的过于频繁,那么就会浪费大量的磁盘带宽和其他资源;如果创建快照频率太低,他就要承受耗尽存储容量的风险,同时也增加了从日志重建的时间。一个简单的策略就是当日志大小达到一个固定大小的时候就创建一次快照。如果这个阈值设置的显著大于期望的快照的大小,那么快照对磁盘压力的影响就会很小了。\n第二个影响性能的问题就是写入快照需要花费显著的一段时间,并且我们还不希望影响到正常操作。解决方案是通过写时复制的技术,这样新的更新就可以被接收而不影响到快照。例如,具有函数式数据结构的状态机天然支持这样的功能。另外,操作系统的写时复制技术的支持(如 Linux 上的 fork)可以被用来创建完整的状态机的内存快照(我们的实现就是这样的)。\n","permalink":"https://reid00.github.io/en/posts/storage/raft-%E4%BB%8B%E7%BB%8D/","summary":"1. Raft 算法简介 1.1 Raft 背景 在分布式系统中,一致性算法至关重要。在所有一致性算法中,Paxos 最负盛名,它由莱斯利·兰伯特(Leslie Lampor","title":"Raft 介绍"},{"content":"1. 概述 Spark应用在yarn运行模式下,其以Executor Container的形式存在,container能申请到的最大内存受yarn.scheduler.maximum-allocation-mb限制。下面说的大部分内容其实与yarn等没有多少直接关系,知识均为通用的。\nSpark应用运行过程中的内存可以分为堆内内存与堆外内存,其中堆内内存onheap由spark.executor.memory指定,堆外内存offheap由spark.yarn.executor.memoryOverhead参数指定,默认为executorMemory*0.1,最小384M。堆内内存executorMemory是spark使用的主要部分,其大小通过-Xmx参数传给jvm,内部有300M的保留资源不被executor使用。这里的堆外内存部分主要用于JVM自身,如字符串、NIO Buffer等开销,此部分用户代码及spark都无法直接操作。\nexecutor执行的时候,用的内存可能会超过executor-memory,所以会为executor额外预留一部分内存,spark.yarn.executor.memoryOverhead即代表这部分内存。\n另外还有部分堆外内存由spark.memory.offHeap.enabled及spark.memory.offHeap.size控制的堆外内存,这部分也归offheap,但主要是供统一内存管理使用的。 2. 堆内内存 1 2 3 4 5 6 7 object UnifiedMemoryManager { // Set aside a fixed amount of memory for non-storage, non-execution purposes. // This serves a function similar to `spark.memory.fraction`, but guarantees that we reserve // sufficient memory for the system even for small heaps. E.g. if we have a 1GB JVM, then // the memory used for execution and storage will be (1024 - 300) * 0.6 = 434MB by default. private val RESERVED_SYSTEM_MEMORY_BYTES = 300 * 1024 * 1024 堆内内存有300M的保留资源,此外的可用内存usableMemory被分为spark管理的内存和用户管理的内存两部分,spark管理的内存通过spark.memory.fraction进行控制,默认0.6。\nSpark管理的统一内存: 在设置了executor memory为3G时,debug代码 其各部分值如下:\nsystemMemory=3087007744 //container的JVM最多可用的内存 reservedMemory=314572800 //保留的300M minSystemMemory=471859200 //300M*1.5 executorMemory=3221225472 // 通过spark.executor.memory指定的值3g usableMemory=2772434944 //为systemMemory-reservedMemory 由上,spark可管理的内存大小为 1 2 3 注意: usableMemory 不是User Memory(有些也叫做other Memory) 实际为spark-submit 提交时申请的exector-memory 大小 - reservedMemory usableMemory * memoryFraction=2772434944 *0.6=1,663,460,966 这块内存在spark中被称为unified region(代号M)或统一内存或可用内存,其进一步被分为执行内存ExecutionMemory和StorageMemory,见上图。其中storage memory(代号R)是M的一个subregion,其的大小占比受spark.memory.storageFraction控制,默认为0.5,即默认占usableMemory的 0.6*0.5=0.3。我们用onHeapStorageRegionSize来表示storage这部分的大小。\nExecutionMemory执行内存:主要存储Shuffle、Join、Sort、Aggregation等计算过程中的临时数据; StorageMemory存储内存:主要存储spark的cache数据,如RDD.cache RDD.persist在调用时的数据存储,用户自定义变量及系统的广播变量等 这两块内存在当前默认的UnifiedMemoryManager(Spark1.6引入)下是可以互相动态侵占的,即Execution内存不足时可以占用Storage的内存,反之亦然,其详细规则如下:\nExecution内存不足且onHeapStorageRegionSize有空闲时,可以向Storage Memory借用内存,- 但借用后storage不能将execution占用的部分驱逐evict出去,只能等着Execution自己释放。 Storage内存不足时可以借用Execution的内存,且当Execution又有内存资源需求时可以驱逐Storage占用的部分,但只能驱逐StorageMemory-onHeapStorageRegionSize的大小,原来划定的onHeapStorageRegionSize且在使用的不可被抢占。 在spark的WebUI下,我们会看到Executors的信息如下图所示 我指定的executor-memory=5g,此处显示的StorageMemory其实是Spark的可用内存,包括Execution和Storage部分。(5G - 300M) * 0.6 = 2.7 用户管理的内存(Other): 上面说了占可用内存spark.memory.fraction(0.6)的spark 统一内存,另外0.4的用户内存用于存储用户代码生成的对象及RDD依赖等,用户在处理partition中的记录时,其遍历到的记录可以看做存储在Other区,当需要将RDD缓存时,将会序列化或不序列化的方式以Block的形式存储到Storage内存中。 3. 堆外内存 前面说了,堆外内存有的是参数spark.yarn.executor.memoryOverhead控制,有的是参数spark.memory.offHeap.size控制,这个都算offheap内存,不过前者主要用于JVM运行自身,字符串, NIO Buffer等开销,而后者主要是供统一内存管理用作Execution Memory及Storage Memory的用途。\nspark.yarn.executor.memoryOverhead设置的内存默认为executor.memory的0.1倍,最低384M,这个始终存在的,在采用yarn时,这块内存是包含在申请的容器内的,即申请容器大小大于spark.executor.memory+spark.yarn.executor.memoryOverhead。\n而通过spark.memory.offHeap.enable/size申请的内存不在JVM内,Spark利用TungSten技术直接操作管理JVM外的原生内存。主要是为了解决Java对象开销大和GC的问题。 1 2 3 4 5 6 protected[this] val maxOffHeapMemory = conf.get(MEMORY_OFFHEAP_SIZE) protected[this] val offHeapStorageMemory = (maxOffHeapMemory * conf.getDouble(\u0026#34;spark.memory.storageFraction\u0026#34;, 0.5)).toLong offHeapExecutionMemoryPool.incrementPoolSize(maxOffHeapMemory - offHeapStorageMemory) offHeapStorageMemoryPool.incrementPoolSize(offHeapStorageMemory) 其中MEMORY_OFFHEAP_SIZE为spark.memory.offHeap.size,这部分offHeap内存被spark.memory.storageFraction分为storage与execution用途供统一内存管理使用。\n统一内存管理UnifiedMemoryManager会管理堆内堆外的execution和storage内存,定义了四个内存池分别为:onHeapStorageMemoryPool, offHeapStorageMemoryPool, onHeapExecutionMemoryPool, offHeapExecutionMemoryPool,在spark内部申请内存时会指定MemoryMode为ON_HEAP或OFF_HEAP决定从哪部分申请内存。\n我们在WebUI看到的executors信息中Storage是包括了统一内存管理控制的堆内堆外区域的。\n下面的5.9G中包括了2.7G的堆内和3.2G(3g按1000算为3.221G,非1024算) 对大的几个RDD进行cache并action后,立马看会看到存储占用了堆内2.7G的大部分,即把execution的抢占了,仍然不够时已经有些序列化到磁盘中了。稍等一会execution会将storage抢占的这部分驱逐并序列化到disk中,如上将会变成下面的状况 按前面所说,这种均是在堆内内存存储的,我们查看被缓存的RDD的信息也可看到。 序列化存储级别怎么存到堆外?尤其是那些不希望被GC的长期存在的RDD,例如常驻内存的名单库等。我们可以使用persist时设置level为StorageLevel.OFF_HEAP,此种情况下只能用内存,不能同时存储到其他地方。 注意: 默认情况下Off-heap模式的内存并不启用,可以通过“spark.memory.offHeap.enabled”参数开启,并由spark.memory.offHeap.size指定堆外内存的大小(占用的空间划归JVM OffHeap内存)。\n4. 任务内存管理(Task Memory Manager) Executor中任务以线程的方式执行,各线程共享JVM的资源,任务之间的内存资源没有强隔离(任务没有专用的Heap区域)。因此,可能会出现这样的情况:先到达的任务可能占用较大的内存,而后到的任务因得不到足够的内存而挂起。\n在Spark任务内存管理中,使用HashMap存储任务与其消耗内存的映射关系。每个任务可占用的内存大小为潜在可使用计算内存的[1/2n, 1/n], 当剩余内存为小于1/2n时,任务将被挂起,直至有其他任务释放执行内存,而满足内存下限1/2n,任务被唤醒,其中n为当前Executor中活跃的任务数。\n任务执行过程中,如果需要更多的内存,则会进行申请,如果,存在空闲内存,则自动扩容成功,否则,将抛出OutOffMemroyError。\n5. 相关调优 什么时候需要调节Executor的堆外内存大小? 当出现一下异常时:shuffle file cannot find,executor lost、task lost,out of memory\n出现这种问题的现象大致有这么两种情况:\nExecutor挂掉了,对应的Executor上面的block manager也挂掉了,找不到对应的shuffle map output文件,Reducer端不能够拉取数据 Executor并没有挂掉,而是在拉取数据的过程出现了问题。 上述情况下,就可以去考虑调节一下executor的堆外内存。也许就可以避免报错;此外,有时,堆外内存调节的比较大的时候,对于性能来说,也会带来一定的提升。这个executor跑着跑着,突然内存不足了,堆外内存不足了,可能会OOM,挂掉。block manager也没有了,数据也丢失掉了。\n如果此时,stage0的executor挂了,BlockManager也没有了;此时,stage1的executor的task,虽然通过 Driver的MapOutputTrakcer获取到了自己数据的地址;但是实际上去找对方的BlockManager获取数据的 时候,是获取不到的。\n此时,就会在spark-submit运行作业(jar),client(standalone client、yarn client),在本机就会打印出log:shuffle output file not found。。。DAGScheduler,resubmitting task,一直会挂掉。反复挂掉几次,反复报错几次,整个spark作业就崩溃了\n1 2 3 4 5 --conf spark.yarn.executor.memoryOverhead=2048 spark-submit脚本里面,去用--conf的方式,去添加配置;一定要注意!!!切记, 不是在你的spark作业代码中,用new SparkConf().set()这种方式去设置,不要这样去设置,是没有用的! 一定要在spark-submit脚本中去设置。 调节等待时长 executor,优先从自己本地关联的BlockManager中获取某份数据\n如果本地BlockManager没有的话,那么会通过TransferService,去远程连接其他节点上executor 的BlockManager去获取,尝试建立远程的网络连接,并且去拉取数据,task创建的对象特别大,特别多频繁的让JVM堆内存满溢,进行垃圾回收。正好碰到那个exeuctor的JVM在垃圾回收。\n处于垃圾回收过程中,所有的工作线程全部停止;相当于只要一旦进行垃圾回收,spark / executor停止工作,无法提供响应,此时呢,就会没有响应,无法建立网络连接,会卡住;ok,spark默认的网络连接的超时时长,是60s,如果卡住60s都无法建立连接的话,那么就宣告失败了。碰到一种情况,偶尔,偶尔,偶尔!!!没有规律!!!某某file。一串file id。uuid(dsfsfd-2342vs\u0026ndash;sdf\u0026ndash;sdfsd)。not found。file lost。这种情况下,很有可能是有那份数据的executor在jvm gc。所以拉取数据的时候,建立不了连接。然后超过默认60s以后,直接宣告失败。报错几次,几次都拉取不到数据的话,可能会导致spark作业的崩溃。也可能会导致DAGScheduler,反复提交几次stage。TaskScheduler,反复提交几次task。大大延长我们的spark作业的运行时间。\n可以考虑调节连接的超时时长。\n1 2 --conf spark.core.connection.ack.wait.timeout=300 spark-submit脚本,切记,不是在new SparkConf().set()这种方式来设置的。spark.core.connection.ack.wait.timeout(spark core,connection,连接,ack,wait timeout,建立不上连接的时候,超时等待时长)调节这个值比较大以后,通常来说,可以避免部分的偶尔出现的某某文件拉取失败,某某文件lost掉了。。。 executor-memory 设置建议 如果设置小了,会发生什么:\n频繁GC,GC超限,CPU大部分时间用来做GC而回首的内存又很少,也就是executor堆内存不足。(通常gc 时间建议不超过task 时间的5%) 如果发生OOM或者GC耗时过长,考虑提高executor-memory或降低executor-core\n2. java.lang.OutOfMemoryError内存溢出,这和程序实现强相关,例如内存排序等,通常是要放入内存的数据量太大,内存空间不够引起的。 3. 数据频繁spill到磁盘,如果是I/O密集型的应用,响应时间就会显著延长。\n具体怎么样算调整到位呢? TimeLine显示状态合理(通通绿条),GC时长合理(占比很小),系统能够稳定运行。 当然内存给太大了也是浪费资源,合理的临界值是在内存给到一定程度,对运行效率已经没有帮助了的时候,就可以了。\n增加executor内存量以后,性能的提升: 如果需要对RDD进行cache,那么更多的内存,就可以缓存更多的数据,将更少的数据写入磁盘,甚至不写入磁盘。减少了磁盘IO。 对于shuffle操作,reduce端,会需要内存来存放拉取的数据并进行聚合。如果内存不够,也会写入磁盘。如果给executor分配更多内存以后,就有更少的数据,需要写入磁盘,甚至不需要写入磁盘。减少了磁盘IO,提升了性能。 对于task的执行,可能会创建很多对象。如果内存比较小,可能会频繁导致JVM堆内存满了,然后频繁GC,垃圾回收,minor GC和full GC。(速度很慢)。内存加大以后,带来更少的GC,垃圾回收,避免了速度变慢,性能提升。 在给定执行内存 M、线程池大小 N 和数据总量 D 的时候,想要有效地提升 CPU 利用率,我们就要计算出最佳并行度 P,计算方法是让数据分片的平均大小 D/P 坐落在(M/N*2, M/N)区间,让每个Task能够拿到并处理适量的数据。怎么理解适量呢?D/P是原始数据的尺寸,真正到内存里去,是会翻倍的,至于翻多少倍,这个和文件格式有关系。不过,不管他翻多少倍,只要原始的D/P和M/N在一个当量,那么我们大概率就能避开OOM的问题,不至于某些Tasks需要处理的数据分片过大而OOM。Shuffle过后每个Reduce Task也会产生数据分片,spark.sql.shuffle.partitions 控制Joins之中的Shuffle Reduce阶段并行度,spark.sql.shuffle.partitions = 估算结果文件大小 / [128M,256M],确保shuffle 后的数据分片大小在[128M,256M]区间。PS: 核心思路是,根据“定下来的”,去调整“未定下来的”,就可以去设置每一个参数了。\n假定Spark读取分布式文件,总大小512M,HDFS的分片是128M,那么并行度 = 512M / 128M = 4 Executor 并发度=1,那么Executor 内存 M 应在 128M 到 256M 之间。 Executor 并发度=2,那么Executor 内存 M 应在 256M 到 512M 之间。\n","permalink":"https://reid00.github.io/en/posts/computation/spark%E5%86%85%E5%AD%98%E7%A9%BA%E9%97%B4%E7%AE%A1%E7%90%86/","summary":"1. 概述 Spark应用在yarn运行模式下,其以Executor Container的形式存在,container能申请到的最大内存受yarn.","title":"Spark内存空间管理"},{"content":"话说,UDP比TCP快吗?\n相信就算不是八股文老手,也会下意识的脱口而出:\u0026ldquo;是\u0026rdquo;。\n这要追问为什么,估计大家也能说出个大概。\n但这也让人好奇,用UDP就一定比用TCP快吗?什么情况下用UDP会比用TCP慢?\n我们今天就来聊下这个话题。\n使用socket进行数据传输 作为一个程序员,假设我们需要在A电脑的进程发一段数据到B电脑的进程,我们一般会在代码里使用socket进行编程。\nsocket就像是一个电话或者邮箱(邮政的信箱)。当你想要发送消息的时候,拨通电话或者将信息塞到邮箱里,socket内核会自动完成将数据传给对方的这个过程。\n基于socket我们可以选择使用TCP或UDP协议进行通信。\n对于TCP这样的可靠性协议,每次消息发出后都能明确知道对方收没收到,就像打电话一样,只要\u0026quot;喂喂\u0026quot;两下就能知道对方有没有在听。\n而UDP就像是给邮政的信箱寄信一样,你寄出去的信,根本就不知道对方有没有正常收到,丢了也是有可能的。\n这让我想起了大概17年前,当时还没有现在这么发达的网购,想买一本《掌机迷》杂志,还得往信封里塞钱,然后一等就是一个月,好几次都怀疑信是不是丢了。我至今印象深刻,因为那是我和我哥攒了好久的钱。。。\n回到socket编程的话题上。\n创建socket的方式就像下面这样。\n1 fd = socket(AF_INET, 具体协议,0); 注意上面的\u0026quot;具体协议\u0026quot;,如果传入的是SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP协议。 TCP: 面向连接的 可靠的 基于字节流 如果传入的是SOCK_DGRAM,是指使用数据报传输数据,也就是UDP协议。 UDP: 无连接 不可靠 基于消息报\n返回的fd是指socket句柄,可以理解为socket的身份证号。通过这个fd你可以在内核中找到唯一的socket结构。\n如果想要通过这个socket发消息,只需要操作这个fd就行了,比如执行 send(fd, msg, \u0026hellip;),内核就会通过这个fd句柄找到socket然后进行发数据的操作。\n如果一切顺利,此时对方执行接收消息的操作,也就是 recv(fd, msg, \u0026hellip;),就能拿到你发的消息。 对于异常情况的处理 但如果不顺利呢?\n比如消息发到一半,丢包了呢?\n那UDP和TCP的态度就不太一样了。\nUDP表示,\u0026ldquo;哦,是吗?然后呢?关我x事\u0026rdquo;\nTCP态度就截然相反了,\u0026ldquo;啊?那可不行,是不是我发太快了呢?是不是链路太堵被别人影响到了呢?不过你放心,我肯定给你补发\u0026rdquo;\nTCP老实人石锤了。我们来看下这个老实人在背后都默默做了哪些事情。\n重传机制 对于TCP,它会给发出的消息打上一个编号(sequence),接收方收到后回一个确认(ack)。发送方可以通过ack的数值知道接收方收到了哪些sequence的包。\n如果长时间等不到对方的确认,TCP就会重新发一次消息,这就是所谓的重传机制。 流量控制机制 但重传这件事本身对性能影响是比较严重的,所以是下下策。\n于是TCP就需要思考有没有办法可以尽量避免重传。\n因为数据发送方和接收方处理数据能力可能不同,因此如果可以根据双方的能力去调整发送的数据量就好了,于是就有了发送和接收窗口,基本上从名字就能看出它的作用,比如接收窗口的大小就是指,接收方当前能接收的数据量大小,发送窗口的大小就指发送方当前能发的数据量大小。TCP根据窗口的大小去控制自己发送的数据量,这样就能大大减少丢包的概率。 滑动窗口机制 接收方的接收到数据之后,会不断处理,处理能力也不是一成不变的,有时候处理的快些,那就可以收多点数据,处理的慢点那就希望对方能少发点数据。毕竟发多了就有可能处理不过来导致丢包,丢包会导致重传,这可是下下策。因此我们需要动态的去调节这个接收窗口的大小,于是就有了滑动窗口机制。\n看到这里大家可能就有点迷了,流量控制和滑动窗口机制貌似很像,它们之间是啥关系?我总结一下。其实现在TCP是通过滑动窗口机制来实现流量控制机制的。 拥塞控制机制 但这还不够,有时候发生丢包,并不是因为发送方和接收方的处理能力问题导致的。而是跟网络环境有关,大家可以将网络想象为一条公路。马路上可能堵满了别人家的车,只留下一辆车的空间。那就算你家有5辆车,目的地也正好有5个停车位,你也没办法同时全部一起上路。于是TCP希望能感知到外部的网络环境,根据网络环境及时调整自己的发包数量,比如马路只够两辆车跑,那我就只发两辆车。但外部环境这么复杂,TCP是怎么感知到的呢?\nTCP会先慢慢试探的发数据,不断加码数据量,越发越多,先发一个,再发2个,4个…。直到出现丢包,这样TCP就知道现在当前网络大概吃得消几个包了,这既是所谓的拥塞控制机制。\n不少人会疑惑流量控制和拥塞控制的关系。我这里小小的总结下。流量控制针对的是单个连接数据处理能力的控制,拥塞控制针对的是整个网络环境数据处理能力的控制。\n分段机制 但上面提到的都是怎么降低重传的概率,似乎重传这个事情就是无法避免的,那如果确实发生了,有没有办法降低它带来的影响呢?\n有。当我们需要发送一个超大的数据包时,如果这个数据包丢了,那就得重传同样大的数据包。但如果我能将其分成一小段一小段,那就算真丢了,那我也就只需要重传那一小段就好了,大大减小了重传的压力,这就是TCP的分段机制。\n而这个所谓的一小段的长度,在传输层叫MSS(Maximum Segment Size),数据包长度大于MSS则会分成N个小于等于MSS的包。 而在网络层,如果数据包还大于MTU(Maximum Transmit Unit),那还会继续分包。 一般情况下,MSS=MTU-40Byte,所以TCP分段后,到了IP层大概率就不会再分片了。 乱序重排机制 既然数据包会被分段,链路又这么复杂还会丢包,那数据包乱序也就显得不奇怪了。比如发数据包1,2,3。1号数据包走了其他网络路径,2和3数据包先到,1数据包后到,于是数据包顺序就成了2,3,1。这一点TCP也考虑到了,依靠数据包的sequence,接收方就能知道数据包的先后顺序。\n后发的数据包先到是吧,那就先放到专门的乱序队列中,等数据都到齐后,重新整理好乱序队列的数据包顺序后再给到用户,这就是乱序重排机制。 连接机制 前面提到,UDP是无连接的,而TCP是面向连接的。\n这里提到的连接到底是啥?\nTCP通过上面提到的各种机制实现了数据的可靠性。这些机制背后是通过一个个数据结构来实现的逻辑。而为了实现这套逻辑,操作系统内核需要在两端代码里维护一套复杂的状态机(三次握手,四次挥手,RST,closing等异常处理机制),这套状态机其实就是所谓的\u0026quot;连接\u0026quot;。这其实就是TCP的连接机制,而UDP用不上这套状态机,因此它是\u0026quot;无连接\u0026quot;的。\n网络环境链路很长,还复杂,数据丢包是很常见的。\n我们平常用TCP做各种数据传输,完全对这些事情无感知。\n哪有什么岁月静好,是TCP替你负重前行。\n这就是TCP三大特性\u0026quot;面向连接、可靠的、基于字节流\u0026quot;中\u0026quot;可靠\u0026quot;的含义。\n不信你改用UDP试试,丢包那就是真丢了,丢到你怀疑人生。\n用UDP就一定比用TCP快吗? 这时候UDP就不服了:\u0026ldquo;正因为没有这些复杂的TCP可靠性机制,所以我很快啊\u0026rdquo;\n嗯,这也是大部分人认为UDP比TCP快的原因。 实际上大部分情况下也确实是这样的。这话没毛病。\n那问题就来了。有没有用了UDP但却比TCP慢的情况呢?\n其实也有。 在回答这个问题前,我需要先说下UDP的用途。\n实际上,大部分人也不会尝试直接拿裸udp放到生产环境中去做项目。\n那UDP的价值在哪?\n在我看来,UDP的存在,本质是内核提供的一个最小网络传输功能。\n很多时候,大家虽然号称自己用了UDP,但实际上都很忌惮它的丢包问题,所以大部分情况下都会在UDP的基础上做各种不同程度的应用层可靠性保证。比如王者农药用的KCP,以及最近很火的QUIC(HTTP3.0),其实都在UDP的基础上做了重传逻辑,实现了一套类似TCP那样的可靠性机制。\n教科书上最爱提UDP适合用于音视频传输,因为这些场景允许丢包。但其实也不是什么包都能丢的,比如重要的关键帧啥的,该重传还得重传。除此之外,还有一些乱序处理机制。举个例子吧。\n打音视频电话的时候,你可能遇到过丢失中间某部分信息的情况,但应该从来没遇到过乱序的情况吧。\n比如对方打网络电话给你,说了:\u0026ldquo;我好想给小白来个点赞在看!\u0026rdquo;\n这时候网络信号不好,你可能会听到\u0026quot;我….点赞在看\u0026quot;。\n但却从来没遇到过\u0026quot;在看小白好想赞\u0026quot;这样的乱序场景吧?\n所以说,虽然选择了使用UDP,但一般还是会在应用层上做一些重传机制的。\n于是问题就来了,如果现在我需要传一个特别大的数据包。\n在TCP里,它内部会根据MSS的大小分段,这时候进入到IP层之后,每个包大小都不会超过MTU,因此IP层一般不会再进行分片。这时候发生丢包了,只需要重传每个MSS分段就够了。\n但对于UDP,其本身并不会分段,如果数据过大,到了IP层,就会进行分片。此时发生丢包的话,再次重传,就会重传整个大数据包。\n对于上面这种情况,使用UDP就比TCP要慢。\n当然,解决起来也不复杂。这里的关键点在于是否实现了数据分段机制,使用UDP的应用层如果也实现了分段机制的话,那就不会出现上述的问题了。\n总结 TCP为了实现可靠性,引入了重传机制、流量控制、滑动窗口、拥塞控制、分段以及乱序重排机制。而UDP则没有实现,因此一般来说TCP比UDP慢。\nTCP是面向连接的协议,而UDP是无连接的协议。这里的\u0026quot;连接\u0026quot;其实是,操作系统内核在两端代码里维护的一套复杂状态机。\n大部分项目,会在基于UDP的基础上,模仿TCP,实现不同程度的可靠性机制。比如王者农药用的KCP其实就在基于UDP在应用层里实现了一套重传机制。\n对于UDP+重传的场景,如果要传超大数据包,并且没有实现分段机制的话,那数据就会在IP层分片,一旦丢包,那就需要重传整个超大数据包。而TCP则不需要考虑这个,内部会自动分段,丢包重传分段就行了。这种场景下,其实TCP更快。\n","permalink":"https://reid00.github.io/en/posts/os_network/udp%E5%B0%B1%E4%B8%80%E5%AE%9A%E6%AF%94tcp%E5%BF%AB%E5%90%97/","summary":"话说,UDP比TCP快吗? 相信就算不是八股文老手,也会下意识的脱口而出:\u0026ldquo;是\u0026rdquo;。 这要追问为什么,估计大家也能说出个大","title":"UDP就一定比TCP快吗"},{"content":"简介 最近使用Gin 框架写接口,总是会出现一些write: connection reset by peer 或者 write: broken pipe 的错误, 在查询资料的时候,发现TCP的下面的情况可以触发一下两种错误。 另外Gin 的出现这个错误的原因这边有个分析Gin-RST 大概原因就是DB 连接池太小,有大量请求排队等待空闲链接,排队时间越长积压的请求越多,请求处理耗时越大,直到积压请求太多把句柄打满,出现了死锁。\nwrite: broken pipe 触发原因:\n服务器接收第一个客户端字节并关闭连接。已关闭的服务端 在收到 客户端的下一个字节写入 将导致服务器用 RST 数据包进行应答。当向接收 RST 的 socket 发送更多字节时,该socket将返回broken pipe。这就是客户机向服务器发送最后一个字节时发生的情况。\n经过测试: 向一个已经关闭的socket 写入数据,(无论buffer 是否写满) 都会出现第一次返回RST, 第二次写入出现broken pipe error, 读的话是EOF\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 package main import ( \u0026#34;errors\u0026#34; \u0026#34;log\u0026#34; \u0026#34;net\u0026#34; \u0026#34;os\u0026#34; \u0026#34;syscall\u0026#34; \u0026#34;time\u0026#34; ) func server() { listener, err := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;:8080\u0026#34;) if err != nil { log.Fatal(err) } defer listener.Close() conn, err := listener.Accept() if err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) os.Exit(1) } data := make([]byte, 1) if _, err := conn.Read(data); err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) } conn.Close() } func client() { conn, err := net.Dial(\u0026#34;tcp\u0026#34;, \u0026#34;localhost:8080\u0026#34;) if err != nil { log.Fatal(\u0026#34;client\u0026#34;, err) } // write to make the connection closed on the server side if _, err := conn.Write([]byte(\u0026#34;a\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) } time.Sleep(1 * time.Second) // write to generate an RST packet if _, err := conn.Write([]byte(\u0026#34;b\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) } time.Sleep(1 * time.Second) // write to generate the broken pipe error if _, err := conn.Write([]byte(\u0026#34;c\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) if errors.Is(err, syscall.EPIPE) { log.Print(\u0026#34;This is broken pipe error\u0026#34;) } } } func main() { go server() time.Sleep(3 * time.Second) // wait for server to run client() } connection reset by peer 触发原因: 如果服务器用socket接收缓冲区中剩余的字节关闭连接,那么将向客户端发送一个 RST 数据包。当客户端尝试从这样一个关闭的连接中读取时,它将通过对等错误获得连接重置。\n经过测试: 当向一个写满了缓冲区,并关闭的socket 进行read 或者write 操作都会导致connection reset by peer\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 package main import ( \u0026#34;errors\u0026#34; \u0026#34;log\u0026#34; \u0026#34;net\u0026#34; \u0026#34;os\u0026#34; \u0026#34;syscall\u0026#34; \u0026#34;time\u0026#34; ) func server() { listener, err := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;:8080\u0026#34;) if err != nil { log.Fatal(err) } defer listener.Close() conn, err := listener.Accept() if err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) os.Exit(1) } data := make([]byte, 2) if _, err := conn.Read(data); err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) } conn.Close() } func client() { conn, err := net.Dial(\u0026#34;tcp\u0026#34;, \u0026#34;localhost:8080\u0026#34;) if err != nil { log.Fatal(\u0026#34;client\u0026#34;, err) } if _, err := conn.Write([]byte(\u0026#34;abc\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) } time.Sleep(1 * time.Second) // wait for close on the server side // 下面的操作第一次read /write 都会 出现ECONNRESET reset by peer // 第二次的读则是EOF, 如果是写则是`write: broken pipe` // if _, err := conn.Write([]byte(\u0026#34;ab\u0026#34;)); err != nil { // log.Printf(\u0026#34;client: %v\u0026#34;, err) // } data := make([]byte, 1) if _, err := conn.Read(data); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) if errors.Is(err, syscall.ECONNRESET) { log.Print(\u0026#34;This is connection reset by peer error\u0026#34;) } } } func main() { go server() time.Sleep(3 * time.Second) // wait for server to run client() } ","permalink":"https://reid00.github.io/en/posts/langs_linux/gin-error-connection-write-broken-pipe/","summary":"简介 最近使用Gin 框架写接口,总是会出现一些write: connection reset by peer 或者 write: broken pipe 的错误, 在查询资料的时候,发现TCP的下面的情况可以触发一下两种错","title":"Gin Error Connection Write Broken Pipe"},{"content":"简介 总体上来说,Spark的流程和MapReduce的思想很类似,只是实现的细节方面会有很多差异。 首先澄清2个容易被混淆的概念:\nSpark是基于内存计算的框架 Spark比Hadoop快100倍 第一个问题是个伪命题。 任何程序都需要通过内存来执行,不论是单机程序还是分布式程序。 Spark会被称为 基于内存计算的框架 ,主要原因在于其和之前的分布式计算框架很大不同的一点是,Shuffle的数据集不需要通过读写磁盘来进行交换,而是直接通过内存交换数据得到。效率比读写磁盘的MapReduce高上好多倍,所以很多人称之为 基于内存的计算框架,其实更应该称为 基于内存进行数据交换的计算框架。\n至于第二个问题,有同学说,Spark官网 就是这么介绍的呀,Spark run workloads 100x faster than Hadoop。\n这点没什么问题,但是请注意官网用来比较的 workload 是 Logistic regresstion。 注意到了吗,这是一个需要反复迭代计算的机器学习算法,Spark是非常擅长在这种需要反复迭代计算的场景中(见问题1),而Hadoop MapReduce每次迭代都需要读写一次HDFS。以己之长,击人之短 差距可向而知。\n如果都只是跑一个简单的过滤场景的 workload,那么性能差距不会有这么多,总体上是一个级别的耗时。\n所以千万不要在任何场景中都说 Spark是基于内存的计算、Spark比Hadoop快100倍,这都是不严谨的说法。\n逻辑执行图 1. 弹性分布式数据集 RDD是Spark中的核心概念,直译过来叫做 弹性分布式数据集。\n所有的RDD要么是从外部数据源创建的,要么是从其他RDD转换过来的。RDD有两种产生方式:\n从外部数据源中创建 从一个RDD中转换而来 你可以把它当做一个List,但是这个List里面的元素是分布在不同机器上的,对List的所有操作都将被分发到不同的机器上执行。 RDD就是我们需要操作的数据集,并解决了 数据在哪儿 这个问题。 有了数据之后,我们需要定义在数据集上的操作(即业务逻辑)。 回想一下我们之前经历的流程:\n一开始我们什么都没有,只有分散在各个服务器上的日志数据,并且通过一个简单的脚本遍历连接服务器,执行相关的统计逻辑 我们接触了MapReduce计算框架,并定义了Map和Reduce的函数接口来实现计算逻辑,从而用户不比关心计算逻辑拆分与分发等底层问题 虽然MapReduce已经解决了我们分布式计算的需求,但是其编程范式只有map和reduce两个接口,使用不灵活。\n在Spark中,RDD提供了比MapReduce编程模型丰富得多的编程接口,如:filter、map、groupBy等都可以直接调用实现(这些操作本质上也划分为Map和Reduce两种类型)。\n现在,统计PV的例子中实现计算逻辑的伪代码可以这么写:\n1 2 3 4 5 6 7 8 9 10 // 从外部数据源中创建RDD,即读取日志数据 val rdd = sc.textFile(\u0026#34;...\u0026#34;) // 解析日志中的ip rdd.map(...) // 根据ip分组 .groupBy(\u0026#34;ip\u0026#34;) // 根据分组结果统计数量 .map(x=\u0026gt; (x._1, x._2.size)) // 保存到外部数据源 .saveAsTextFile(\u0026#34;...\u0026#34;) 在RDD进行操作行为可以划分为两种:\nTransformation:如filter、map、groupBy等,将会产生另外一个RDD Action:如count、saveAsTextFile等,触发整个逻辑图的计算流程 一个Spark程序可以看做是 一个或者多个RDD的完整生命周期,从诞生到发展,到变换,再到输出之后销毁。 2. 依赖关系 现在你可能会问,使用MapReduce,通过指定数据源定义了操作数据集,通过Map和Reduce两个函数接口划分了 能够分发到各个节点上并行执行的 和 需要经过一定量的结果合并之后才能够继续执行的 两种任务,并基于这两种接口类型的任务去拆分和分发计算逻辑。\n那么Spark中是如何做的呢?\nSpark中通过RDD定义了 分布式数据集,通过RDD的编程接口定义了计算逻辑,但是Spark是如何根据RDD中定义的逻辑来划分 能够分发到各个节点上并行执行的 和 需要经过一定量的结果合并之后才能够继续执行的 任务,从而实现计算逻辑的拆分和分发呢?\n其实和MapReduce一样,Spark中虽然提供了丰富的算子给用户实现计算逻辑,但是这些算子最终仍然会被归为两类:Map和Reduce。\n前面我们说过,在RDD上执行Transformation操作会产生另外一个RDD,随即,RDD之间将会产生依赖关系和父子RDD关系。\n而RDD中的依赖关系分为两种:\n1、完全依赖:又称为窄依赖,父RDD中一个分区的数据只被子RDD中对应的一个分区使用(1对1) 2、部分依赖:又称为宽依赖,父RDD中一个分区的数据会被子RDD中对应的多个分区使用(1对多 or 多对多)\n看到了吗,最后RDD通过依赖关系又回到了我们之前讨论的话题:能够分发到各个节点上并行执行的 和 需要经过一定量的结果合并之后才能够继续执行的 两种任务。\n对于完全依赖来说,各个分区之间的任务是互不影响的,所以其能够发到各个节点上并行执行。\n对于部分依赖来说,子RDD的某个分区可能依赖于父RDD的多个分区,所以其需要经过一定量的结果合并(依赖的所有父RDD分区)之后才能够继续执行。\n定义了这两种类型的任务之后,Spark就可以根据依赖关系进行 计算逻辑的拆分与分发 。\n那么RDD上的哪些操作是宽依赖,哪些操作是窄依赖呢?\n其实仔细想想很好区分,对于map、filter这种不需要Shuffle的操作都是窄依赖,而groupBy、reduceBy等需要Shuffle聚合的操作都是宽依赖。\n通过Transformation操作,RDD之间将会产生依赖关系,基于RDD上的操作与依赖关系 将会形成一张逻辑执行图 来描述本次任务的计算过程。\n什么意思呢?\n在RDD上进行的Transformation操作都是惰性执行的,意思就是只有数据真正用到的时候(Action操作)才会进行Transformation操作。\n例如以下RDD的操作:\n1 2 3 4 //rdd1只保留了从rdd中计算而来的路径,没有真正执行计算 val rdd1 = rdd.map.map.filter.map //直到有action操作才会触发计算任务 rdd1.count 也就是说,count之前,我们写的计算逻辑其实只是在 画一个逻辑图,只有真正使用到了count的时候,整个逻辑图才会被触发并执行计算逻辑。\n这么做的原因要归咎到RDD的计算模型,当rdd中出现action操作的时候,spark将会生成一个job,并根据rdd的依赖关系画出一张逻辑执行图。\n费劲心机画出了逻辑图之后再划分物理图时将会有最关键的作用。\n3.物理执行图 从RDD上得到逻辑执行图之后,执行计算任务前期的准备工作就都完成了,现在我们来详细讨论一下Spark是如何 拆分、分发计算逻辑的。\nSpark将会划分逻辑图从而生成物理执行图,表现形式为 DAG有向无环图,RDD的执行模型将根据物理图的划分而展开。\n现在我们知道,基于逻辑执行图,由于RDD之间的依赖关系被明显的划分为了两种:\n对于完全依赖窄依赖,可以完全不管其他RDD或者其他分区的执行进度,直接一条走到底的 对于部分依赖宽依赖,需要父RDD不同分区中的数据,所以他 一定是等到所有父RDD计算完毕之后才会执行的 基于逻辑执行图和对应的依赖关系,Spark可以明显的 划分出Stage:\n从逻辑图的最后方开始创建Stage 遇到完全依赖则加入当前Stage 遇到部分依赖则新建一个Stage 由此对整个逻辑图进行Stage的划分。这就是Spark对于计算逻辑的组织和拆分方式。\n那么这么做有什么好处呢?\n基于Stage的独立性,Spark实现了 Pipeline的计算方式。且由于 Stage内部的操作只有完全依赖,它可以毫无顾忌的建立 回溯机制:当一个分区数据计算失败或者丢失,可以直接从父RDD对应的分区中恢复,而不是重新计算整个父RDD。\n如果所有操作都是立即执行的话,那么处理流程应该是这样子的:\n1 2 3 4 5 6 7 8 //读取数据 list1 = readAllFromHDFS //将所有数据进行对应的map转换操作 list2 = list1.map //将所有数据进行对应的map转换操作 list3 = list2.map //将所有数据进行对应的filter过滤操作 list4 = list3.filter 注意,这种模式下,每个步骤都需要将 全量的数据集加载到内存中操作 这是毋庸置疑的,每个操作都要等待前一个操作全部处理完毕。\n作为对比,我们再来看Pipeline的计算模式:\n1 2 3 4 //读取数据 data = readOneLineFromHDFS //读取一条处理一条,每条数据经过管道执行到末端 data.map.map.filter 数据是作为流一条条从管道的开始一路走到结束,每个Stage都是一条独立的管道。最为直观的好处就是:不需要加载全量数据集,上一次的计算结果可以马上丢弃。\n全量数据集其实是一个很恐怖的东西,全世界都在避免它。所以某种意义上来看,如果没有Shuffle过程,Spark所需要内存其实非常小,一条数据又能占多大空间。\n第二,如果不是Pipeline的方式,而是马上触发全量操作,势必需要一个中间容器来保存结果,其实这里就又回到MapReduce的老路,效率很低。\n现在我们来考虑 不根据RDD的依赖关系来划分Stage的前提下,两种比较极端的情况: 1、整个逻辑图作为一个Stage\n一个Job只包含一个Stage,数据一路从头走到尾,什么中间结果都不需要保存 如果RDD之间都是 完全依赖 的话这是最完美的场景 缺陷: 在Shuffle操作符处(即部分依赖的产生处),只能通过一个Task来处理所有分区的数据 多个Task情况下没有办法各自感知Shuffle过程中所需要的数据状态 严重影响计算效率 2、每个RDD的操作都作为一个Stage\n各个操作都需要进行全量计算,其实就相当于MapReduce 缺陷:严重影响计算效率 可以看到,Spark通过RDD之间的依赖关系来划分逻辑执行图形成一个个独立的Stage,并通过Stage来实现Pipeline的计算模式。\n计算逻辑拆分后,通过Pipeline的执行将计算逻辑分发到各个节点,并最大程度保证计算的效率。\n综上,基于逻辑执行图能做的事情有: 1、划分Stage 2、执行Pipeline 3、建立回溯机制\n根据RDD之间的依赖关系来划分Stage解决了以下问题: 1、实现Pipeline,不需要保留中间计算结果 2、计算保持高效,Task分布均衡\n至此,Spark主要的 计算逻辑拆分与分发 步骤大概介绍完毕。\n与之相对的,一段Spark代码,或者一个Spark程序,运行起来之后是什么样子的,代码是如何被调度执行的,应该在开发的阶段就能在脑子里形成一个执行图。\n充分了解程序运行的背后发生了什么是保证系统稳定高效运行的关键,这点放在哪里都是真理。\nShuffle过程与管理 1. Shuffle总览 和之前看到的MapReduce Shuffle过程相对比。二者在高级别上来看别没有多大区别,都是将mapper中的数据进行partition之后送到不同的reducer中,reducer以内存为缓存边拉取数据边计算。\n但是在具体实现的低级别角度上两者区别还是比较大的,MapReduce阶段划分明显,Spark中没有明显的划分。\nMapReduce中的Mapper即为Spark中的 ShuffleMapTask,而Reduce对应的可能是ShuffleMapTask或者ResultTask。\nSpark各个阶段通过RDD的算子体现出来,具体Shuffle过程可以分为:\nShuffle Write Shuffle Read Write过程其实很简单,根据之前划分的Stage,每个Stage的final task的结果将会写磁盘,和MapReduce一样,有多少个分区数就会写多少个文件。\n后续的Stage将会通过网络来fatch各自对应的数据文件。\nRead过程需要解决几个问题:\n什么时候fetch数据:依赖的stage中所有ShuffleMapTask都执行完之后才进行fetch,迎合pipeline的思想 何获得数据位置:ShuffleMapTask结束之后都会想Driver端汇报数据存放位置,ResultTaskfetch数据时都会向Driver查询需要fetch的数据在哪里,Driver端有比较复杂的实现机制 fetch的数据怎么存:刚fetch过来的数据存放在softBuffer中,计算之后的数据可以根据策略选择存放在内存或者内存+磁盘中 和fetch过程的计算和MapReduce也不一样:\nSpark:边fetch边计算,因为是无序的,所有没有必要要求所有数据都获取之后才进行计算 MapReduce:MapReduce中强制要求数据有序之后才进行reduce操作,所以MapReduce是 一次性fetch所有数据之后才计算 总结一下,与MapReduce相比:\nHeight Level:无太大区别,将mapper中的数据进行partition之后送到不同的reducer中 Low Level:实现差别较大,MapReduce阶段划分明显,Spark中没有明显的划分 2. Shuffle Manage Spark 中负责 Shuffle 过程管理的是 ShuffleManager,它接管了 Shuffle Read、Shuffle Write 过程中的 执行、计算和处理 相关的实现细节。\n比如 Write 过程中怎么组织数据写入磁盘,Read 过程中怎么拉取数据和保存数据。\nShuffleManager 是一个接口,主要有两种实现:\nHashShuffleManager:Spark 1.2之前默认使用,会产生大量的中间磁盘文件,进而由大量的磁盘IO操作影响了性能。 SortShuffleManager:每个Task在进行Shuffle操作时,虽然也会产生较多的临时磁盘文件,但是最后会将所有的临时文件合并(merge)成一个磁盘文件,因此每个Task就只有一个磁盘文件。在下一个stage的Shuffle read task拉取自己的数据时,只要根据索引读取每个磁盘文件中的部分数据即可。 HashShuffleManager 为了简单的说明,这里假设我们的 Executor 可用的CPU核心数只有一个,无论 Executor 上有分配了多少个Task,同一时间只能执行一个Task。\n在 Shuffle Write 阶段,Executor 的在依次执行每个Task时,HashShuffleManager 都会对Task 中的所有数据的key执行相应的hash运算,hash的参数是下游Stage的 Task数量。通过这hash映射之后,每个key都会有一个对应的结果值,根据hash的结果值来写文件(这个过程中会经过一段内存的缓冲区,缓冲区满了之后写入磁盘),相同结果的数据写到一个文件中。\n这样一来,上游每个Task中,都会根据下游Stage的Task数量 产生对应数量的文件,相同key的数据肯定在同一份文件中,一份文件中可能会有多个key的数据。下游stage计算数据时,只需要拉取这个文件的所有数据即可进行计算。在这个过程中,会边拉取边计算,每个Task也会有自己的缓冲区,每次只取buffer大小的数据通过内存中的Map进行聚合,反复操作直至数据获取完毕。\n么描述大家可能还会觉得合情合理,那么我们从产生的总文件数的角度来看呢?\n假设当前Stage有200个Task需要执行,下游Stage有100个Task,按照我们刚刚描述的过程来看,产生的总文件数为:200 * 100 = 20000 个。\n这是一个非常惊人的数字,我们都知道 磁盘的IO 一直是程序执行的瓶颈之一,我们在执行程序的时候都会尽可能的避免写磁盘操作。而现在,一个 Shuffle Write 过程就会产生成千上万个文件,注定了这个程序不会快到哪里去。\n工作流程如下图所示: 那么有没有优化方式呢?肯定是有的。\n在使用 HashShuffleManager 的时候,我们可以设置一个参数 spark.Shuffle.consolidateFiles 该参数默认值为false,将其设置为true即可开启优化机制。强烈建议设置为true,为啥呢?我们来解释解释。\nconsolidate机制最重要的功能就是 同一个CPU 允许不同的task复用同一批磁盘文件,这样就可以有效将多个task的磁盘文件进行一定程度上的合并,从而大幅度减少磁盘文件的数量,进而提升Shuffle write的性能。\n之前的流程中,每个Task都会创建n个文件,Task之间是互相隔离的。而在 consolidate机制 中Task之间是可以复用文件的,因为同一个key的数据可能是分布在不同的Task上处理的。\n简单来说,因为Task之间的数据文件可以复用,一个cpu核心只会创建和下游Stage的Task数量一样多的文件数,同一个cpu核心中处理的所有task都会重复使用同一批文件。 总结为:\nconsolidate=false:文件数量由 上游Stage任务数(不同的任务可能会被同一个cpu处理) * 下游Stage任务数 决定 consolidate=true:文件数量由 上游处理任务的CPU核心数(一个cpu可能会处理多个任务) * 下游Stage任务数 决定 还是我们之前举的例子,假设当前Stage有200个Task需要执行,下游Stage有100个Task,如果此时我们有10个Executor(每个1core),那么总文件数为: 10(cpu核心数)* 100(下游Task数量) = 200 个。\n由此可见,当开启了 consolidate 机制后,Executor 的cpu核心数越多,在提供处理并行度的同时,Shuffle Write 产生的文件数就越多,这点需要注意。\nShuffle Read 阶段并没有变化,都是直接拉取自己所需要的那份数据进行计算。\nconsolidate机制下,工作流程如下: SortShuffleManager 经过前面的介绍之后,我们知道使用 HashShuffleManager 时开启consolidate机制可以减少很多文件的产生,提高 Shuffle Write 效率。\n无论 consolidate机制 是否开启,HashShuffleManager 所产生的文件数都与下游Stage的Task数量有关系。\n现在我们再来看另外一种 Shuffle管理机制,SortShuffleManager。\n通过 SortShuffleManager 这个名字大家可以知道,这是一个排序的Shuffle管理器(HashShuffleManager为无序)。\nShuffle Write 的具体执行过程如下:\n每个Task将Shuffle的数据写入自己的buffer内存缓冲区中,每条数据写入时都会判断是否超出阈值 超出使用阈值则触发刷写,将数据一批批的写入磁盘中 写入磁盘前会根据key对内存中的数据进行排序 排完序后的数据根据批次大小(默认10000)依次写入磁盘中 Task数据处理结束后,将之前刷写的所有文件读取,合并之后重新写入一个大文件中 因为一个Task处理的数据可能对应下游多个Task需要处理的数据 所以此过程会创建索引文件标记下游各个Task对应的数据在文件中的start offset与end offset 由于需要标记下游各个Task所需要的数据偏移量,所以需要进行sort排序之后才可写入 从以上过程中可以看出,和 HashShuffleManager 一样 SortShuffleManager 的每个Task也会创建很多文件,不同的是 HashShuffleManager 中每个Task创建的文件数和下游的Stage任务数一致,而 SortShuffleManager 则是 按照自己的buffer内存空间大小刷写的文件块,并且最后还会做一次大合并,一个Task只对应一个文件。\n文件数量由 上游Stage的Task数量 决定。 执行流程如下: 除此之外,SortShuffleManager 还有另外一种 bypass 的执行模式。\n当 Shuffle map task数量小于 spark.Shuffle.sort.bypassMergeThreshold 的值,且不是聚合类的Shuffle算子(比如reduceByKey),比如 join 等操作时将会触发。\n此时task会为每个下游task都创建一个临时磁盘文件,并将数据按key进行hash然后根据key的hash值,将key写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。\n最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并 创建一个单独的索引文件。\n该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的HashShuffleManager来说,Shuffle read的性能会更好。\n而该机制与普通SortShuffleManager运行机制的不同在于:\n第一,磁盘写机制不同; 第二,不会进行排序。 也就是说,启用该机制的最大好处在于,Shuffle write过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。\n内存模块 Spark是用Scala开发完成的,也是一个运行在JVM体系上的系统性框架,所以 Spark的内存模型也是基于Java虚拟机来的。\n基本模型就是:堆、栈、静态代码块和全局空间,在虚拟机的内存模型上Spark将内存做了二次划分。\n作为一个严重依赖内存进行数据计算的系统来说,内存管理模块 是Spark中极其重要的一部分。\n1. 内存划分 从性质上看,Executor 可使用的内存空间分为两种:堆内、堆外。\n堆内内存即直接通过 spark.executor.memory 或者 -–executor-memory 设置分配得到,属于一定会有值的强制性配置。\n而堆外内存则是一种可选性配置,默认不使用,通过配置 spark.memory.offHeap.enabled 参数启用,由 spark.memory.offHeap.size 参数设定堆外空间的大小。\n堆外内存将会 存储经过序列化的二进制数据。一定程度上会减少不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。\n从内存区域上看,内存大致可以划分为三个模块(堆外内存没有 Execution 的空间):\nStorage:RDD缓存、Broadcast等数据空间。 Execution:Shuffle过程使用的内存。 Other:用户定义的数据结构、Spark内部元数据等其他内存空间。 2. 内存管理 静态内存管理 静态内存管理为 Spark 1.6 之前默认使用的管理方式。 忽略\n统一内存管理 在内存结构总体不变的情况下,Spark 1.6 之后引入了新的内存管理机制,统一内存管理。 和静态内存管理相比,统一内存管理主要的变化点在于:\n增加系统预留的内存空间 各个区域初始分配的默认值重新调整 Storage和Execution两个区域之间不再是固定大小,而是 动态调节 总堆内内存的基础上,先扣除 给系统的预留空间(默认300M),剩下的为可用内存总大小。\n注意: spark.memory.storageFraction 统一内存管理的参数 spark.storage.memoryFraction 静态内存管理的参数 (deprecated) This is read only if spark.memory.useLegacyMode is enabled. Fraction of Java heap to use for Spark\u0026rsquo;s memory cache. This should not be larger than the \u0026ldquo;old\u0026rdquo; generation of objects in the JVM, which by default is given 0.6 of the heap, but you can increase it if you configure your own old generation size.\n在可用内存总大小的基础上,划分两大块:\n统一内存(Unified Memory):Storage、Execution共同使用的内存,默认值 0.75(2.0以后为0.6),由 spark.memory.fraction 控制 Storage:默认为0.5,占统一内存的50%,由 spark.memory.storageFraction 控制。主要用于存储 spark 的 cache 数据,例如RDD的缓存、unroll数据; Execution:默认为0.5,占统一内存的50%,等于 1 - spark.memory.storageFraction。主要用于存放 Shuffle、Join、Sort、Aggregation 等计算过程中的临时数据; 用户内存(User Memory):默认为0.4,占可用总内存的40%, 等于 1 - spark.memory.fraction。主要用于存储 RDD 转换操作所需要的数据,例如 RDD 依赖等信息; 预留内存(Reserved Memory): 系统预留内存,会用来存储Spark内部对象。 可以看到,在统一内存管理中,Storage和Execution被划入一块大内存池中进行统一管理。 这样做的好处是,Storage和Execution 的内存空间用户可以不用自己那么操心去优化、调整。 当有一方的内存不够用时,将会到另外一方去「借」一些内存回来用,达到 动态内存分配与调整 的效果。\n在 Spark 1.6 之后的版本中默认不再使用 静态内存管理 的方式,但是可以通过设置 spark.memory.userLegacyMode 的值(true/false,默认false)来选择内存管理方式。\n其中最重要的优化在于动态占用机制,其规则如下:\n设定基本的存储内存和执行内存区域(spark.storage.storageFraction参数),该设定确定了双方各自拥有的空间的范围。 双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间;(存储空间不足是指不足以放下一个完整的Block)。 执行内存的空间被对方占用后,可让对方将占用的部分转存到硬盘,然后“归还”借用的空间。 存储内存的空间被对方占用后,无法让对方“归还”,因为需要考虑Shuffle过程中的很多因素,实现起来较为复杂。 凭借统一内存管理机制,Spark在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护Spark内存的难度,但并不意味着开发者可以高枕无忧。 譬如,所以如果存储内存的空间太大或者说缓存的数据过多,反而会导致频繁的全量垃圾回收,降低任务执行时的性能,因为缓存的RDD数据通常都是长期驻留内存的。所以要想充分发挥Spark的性能,需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理。\nSpark性能优化 1. 开发调优 代码开发,是执行Spark任务的第一步,同时也是优化Spark任务的第一个入手点,良好的 RDD lineage、高性能的算子操作、不同高级特性的组合使用,都能够给Spark任务带来巨大的提升空间。\n开发出优秀的Spark程序,需要你熟悉Spark的各种API和特性。其中最重要的一点我们在逻辑执行图小节中提到过:开发Spark程序其实就是在画图。\n如何能够把这个图快速画出来的同时还能画好看,就是你需要考虑的,这就是考验Spark开发的基本功。\nRDD复用 和其他任何程序中 变量复用 一样,在Spark程序中创建并使用RDD也要贯彻这个思想。\n在编码的时候,RDD和任何单机程序一样,本身只是作为一个普通的变量对象存在,不同的是单机变量的创建会消耗内存,而RDD的创建会 消耗磁盘、内存与算力等更多方面的资源(想想RDD创建之后的使用过程,是不是这样呢)。\n所以要把RDD的创建和使用当做一个 需要消耗高昂费用的动作 来谨慎使用,从代码的源头节约与优化程序空间与效率。\n有的同学在开发Spark程序的时候,可能在业务逻辑1创建了一个RDD1,经过各种Transformation以及最后的Action操作之后,开始处理业务逻辑2,又在相同的数据源上创建了RDD2,然后继续写业务逻辑代码。\n一般来讲,相同的数据源的RDD 只允许创建一次,不要创建相同的RDD,保证代码的整洁性。\n在RDD的lineage过程中,如果有多个业务重复使用某个lineage的计算过程,则 应该将其抽出作为一个独立的中间RDD使用,尽可能复用相同的RDD。\n无论是数据源RDD还是中间RDD,如果被反复多次使用,则应该考虑将其做 缓存持久化操作。\n可以看到,如果没有对业务逻辑有比较清晰的了解,开发人员很难从繁杂的计算过程中提取出可以复用甚至进行缓存操作的代码块,无法优化到点。\n另外,在考虑对RDD持久化操作时,应该针对 可用硬件资源、RDD数据量、程序时效性等要求 选择不同的缓存策略(详见「内存模块」小节)。\n总结:\n相同的数据源的RDD 只允许创建一次 多个业务逻辑反复使用同一个lineage 应该将其抽出作为一个独立的中间RDD使用 任何被多次重复使用的RDD应该考虑将其做 缓存持久化操作 例子:\n避免创建重复的RDD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 需要对名为“hello.txt”的HDFS文件进行一次map操作,再进行一次reduce操作。也就是说,需要对一份数据执行两次算子操作。 // 错误的做法:对于同一份数据执行多次算子操作时,创建多个RDD。 // 这里执行了两次textFile方法,针对同一个HDFS文件,创建了两个RDD出来,然后分别对每个RDD都执行了一个算子操作。 // 这种情况下,Spark需要从HDFS上两次加载hello.txt文件的内容,并创建两个单独的RDD;第二次加载HDFS文件以及创建RDD的性能开销,很明显是白白浪费掉的。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;) rdd1.map(...) val rdd2 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;) rdd2.reduce(...) // 正确的用法:对于一份数据执行多次算子操作时,只使用一个RDD。 // 这种写法很明显比上一种写法要好多了,因为我们对于同一份数据只创建了一个RDD,然后对这一个RDD执行了多次算子操作。 // 但是要注意到这里为止优化还没有结束,由于rdd1被执行了两次算子操作,第二次执行reduce操作的时候,还会再次从源头处重新计算一次rdd1的数据,因此还是会有重复计算的性能开销。 // 要彻底解决这个问题,必须结合“原则三:对多次使用的RDD进行持久化”,才能保证一个RDD被多次使用时只被计算一次。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;) rdd1.map(...) rdd1.reduce(...) 尽可能复用同一个RDD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // 错误的做法。 // 有一个\u0026lt;Long, String\u0026gt;格式的RDD,即rdd1。 // 接着由于业务需要,对rdd1执行了一个map操作,创建了一个rdd2,而rdd2中的数据仅仅是rdd1中的value值而已,也就是说,rdd2是rdd1的子集。 JavaPairRDD\u0026lt;Long, String\u0026gt; rdd1 = ... JavaRDD\u0026lt;String\u0026gt; rdd2 = rdd1.map(...) // 分别对rdd1和rdd2执行了不同的算子操作。 rdd1.reduceByKey(...) rdd2.map(...) // 正确的做法。 // 上面这个case中,其实rdd1和rdd2的区别无非就是数据格式不同而已,rdd2的数据完全就是rdd1的子集而已,却创建了两个rdd,并对两个rdd都执行了一次算子操作。 // 此时会因为对rdd1执行map算子来创建rdd2,而多执行一次算子操作,进而增加性能开销。 // 其实在这种情况下完全可以复用同一个RDD。 // 我们可以使用rdd1,既做reduceByKey操作,也做map操作。 // 在进行第二个map操作时,只使用每个数据的tuple._2,也就是rdd1中的value值,即可。 JavaPairRDD\u0026lt;Long, String\u0026gt; rdd1 = ... rdd1.reduceByKey(...) rdd1.map(tuple._2...) // 第二种方式相较于第一种方式而言,很明显减少了一次rdd2的计算开销。 // 但是到这里为止,优化还没有结束,对rdd1我们还是执行了两次算子操作,rdd1实际上还是会被计算两次。 // 因此还需要配合“原则三:对多次使用的RDD进行持久化”进行使用,才能保证一个RDD被多次使用时只被计算一次。 对多次使用的RDD进行持久化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 如果要对一个RDD进行持久化,只要对这个RDD调用cache()和persist()即可。 // 正确的做法。 // cache()方法表示:使用非序列化的方式将RDD中的数据全部尝试持久化到内存中。 // 此时再对rdd1执行两次算子操作时,只有在第一次执行map算子时,才会将这个rdd1从源头处计算一次。 // 第二次执行reduce算子时,就会直接从内存中提取数据进行计算,不会重复计算一个rdd。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;).cache() rdd1.map(...) rdd1.reduce(...) // persist()方法表示:手动选择持久化级别,并使用指定的方式进行持久化。 // 比如说,StorageLevel.MEMORY_AND_DISK_SER表示,内存充足时优先持久化到内存中,内存不充足时持久化到磁盘文件中。 // 而且其中的_SER后缀表示,使用序列化的方式来保存RDD数据,此时RDD中的每个partition都会序列化成一个大的字节数组,然后再持久化到内存或磁盘中。 // 序列化的方式可以减少持久化的数据对内存/磁盘的占用量,进而避免内存被持久化数据占用过多,从而发生频繁GC。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;).persist(StorageLevel.MEMORY_AND_DISK_SER) rdd1.map(...) rdd1.reduce(...) 尽量避免使用shuffle类算子 如果有可能的话,要尽量避免使用shuffle类算子。因为Spark作业运行过程中,最消耗性能的地方就是shuffle过程。shuffle过程,简单来说,就是将分布在集群中多个节点上的同一个key,拉取到同一个节点上,进行聚合或join等操作。比如reduceByKey、join等算子,都会触发shuffle操作。\nshuffle过程中,各个节点上的相同key都会先写入本地磁盘文件中,然后其他节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的key过多,导致内存不够存放,进而溢写到磁盘文件中。因此在shuffle过程中,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作。磁盘IO和网络数据传输也是shuffle性能较差的主要原因。\n因此在我们的开发过程中,能避免则尽可能避免使用reduceByKey、join、distinct、repartition等会进行shuffle的算子,尽量使用map类的非shuffle算子。这样的话,没有shuffle操作或者仅有较少shuffle操作的Spark作业,可以大大减少性能开销。 Broadcast与map进行join代码示例\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 传统的join操作会导致shuffle操作。 // 因为两个RDD中,相同的key都需要通过网络拉取到一个节点上,由一个task进行join操作。 val rdd3 = rdd1.join(rdd2) // Broadcast+map的join操作,不会导致shuffle操作。 // 使用Broadcast将一个数据量较小的RDD作为广播变量。 val rdd2Data = rdd2.collect() val rdd2DataBroadcast = sc.broadcast(rdd2Data) // 在rdd1.map算子中,可以从rdd2DataBroadcast中,获取rdd2的所有数据。 // 然后进行遍历,如果发现rdd2中某条数据的key与rdd1的当前数据的key是相同的,那么就判定可以进行join。 // 此时就可以根据自己需要的方式,将rdd1当前数据与rdd2中可以连接的数据,拼接在一起(String或Tuple)。 val rdd3 = rdd1.map(rdd2DataBroadcast...) // 注意,以上操作,建议仅仅在rdd2的数据量比较少(比如几百M,或者一两G)的情况下使用。 // 因为每个Executor的内存中,都会驻留一份rdd2的全量数据。 使用map-side预聚合的shuffle操作 如果因为业务需要,一定要使用shuffle操作,无法用map类的算子来替代,那么尽量使用可以map-side预聚合的算子。 所谓的map-side预聚合,说的是在每个节点本地对相同的key进行一次聚合操作,类似于MapReduce中的本地combiner。map-side预聚合之后,每个节点本地就只会有一条相同的key,因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘IO以及网络传输开销。通常来说,在可能的情况下,建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。因为reduceByKey和aggregateByKey算子都会使用用户自定义的函数对每个节点本地的相同key进行预聚合。而groupByKey算子是不会进行预聚合的,全量的数据会在集群的各个节点之间分发和传输,性能相对来说比较差。 比如如下两幅图,就是典型的例子,分别基于reduceByKey和groupByKey进行单词计数。其中第一张图是groupByKey的原理图,可以看到,没有进行任何本地聚合时,所有数据都会在集群节点之间传输;第二张图是reduceByKey的原理图,可以看到,每个节点本地的相同key数据,都进行了预聚合,然后才传输到其他节点上进行全局聚合。\n使用高性能的算子\n使用reduceByKey/aggregateByKey替代groupByKey 详情见“原则五:使用map-side预聚合的shuffle操作”。\n使用mapPartitions替代普通map mapPartitions类的算子,一次函数调用会处理一个partition所有的数据,而不是一次函数调用处理一条,性能相对来说会高一些。但是有的时候,使用mapPartitions会出现OOM(内存溢出)的问题。因为单次函数调用就要处理掉一个partition所有的数据,如果内存不够,垃圾回收时是无法回收掉太多对象的,很可能出现OOM异常。所以使用这类操作时要慎重!\n使用foreachPartitions替代foreach 原理类似于“使用mapPartitions替代map”,也是一次函数调用处理一个partition的所有数据,而不是一次函数调用处理一条数据。在实践中发现,foreachPartitions类的算子,对性能的提升还是很有帮助的。比如在foreach函数中,将RDD中所有数据写MySQL,那么如果是普通的foreach算子,就会一条数据一条数据地写,每次函数调用可能就会创建一个数据库连接,此时就势必会频繁地创建和销毁数据库连接,性能是非常低下;但是如果用foreachPartitions算子一次性处理一个partition的数据,那么对于每个partition,只要创建一个数据库连接即可,然后执行批量插入操作,此时性能是比较高的。实践中发现,对于1万条左右的数据量写MySQL,性能可以提升30%以上。\n使用filter之后进行coalesce操作 通常对一个RDD执行filter算子过滤掉RDD中较多数据后(比如30%以上的数据),建议使用coalesce算子,手动减少RDD的partition数量,将RDD中的数据压缩到更少的partition中去。因为filter之后,RDD的每个partition中都会有很多数据被过滤掉,此时如果照常进行后续的计算,其实每个task处理的partition中的数据量并不是很多,有一点资源浪费,而且此时处理的task越多,可能速度反而越慢。因此用coalesce减少partition数量,将RDD中的数据压缩到更少的partition之后,只要使用更少的task即可处理完所有的partition。在某些场景下,对于性能的提升会有一定的帮助。\n使用repartitionAndSortWithinPartitions替代repartition与sort类操作 repartitionAndSortWithinPartitions是Spark官网推荐的一个算子,官方建议,如果需要在repartition重分区之后,还要进行排序,建议直接使用repartitionAndSortWithinPartitions算子。因为该算子可以一边进行重分区的shuffle操作,一边进行排序。shuffle与sort两个操作同时进行,比先shuffle再sort来说,性能可能是要高的。\n广播大变量\n使用Kryo优化序列化性能\n优化数据结构\n2. 资源调优 了解完了Spark作业运行的基本原理之后,对资源相关的参数就容易理解了。所谓的Spark资源参数调优,其实主要就是对Spark运行过程中各个使用资源的地方,通过调节各种参数,来优化资源使用的效率,从而提升Spark作业的执行性能。以下参数就是Spark中主要的资源参数,每个参数都对应着作业运行原理中的某个部分,我们同时也给出了一个调优的参考值。\nnum-executors 参数说明:该参数用于设置Spark作业总共要用多少个Executor进程来执行。Driver在向YARN集群管理器申请资源时,YARN集群管理器会尽可能按照你的设置来在集群的各个工作节点上,启动相应数量的Executor进程。这个参数非常之重要,如果不设置的话,默认只会给你启动少量的Executor进程,此时你的Spark作业的运行速度是非常慢的。 参数调优建议:每个Spark作业的运行一般设置50~100个左右的Executor进程比较合适,设置太少或太多的Executor进程都不好。设置的太少,无法充分利用集群资源;设置的太多的话,大部分队列可能无法给予充分的资源。 executor-memory 参数说明:该参数用于设置每个Executor进程的内存。Executor内存的大小,很多时候直接决定了Spark作业的性能,而且跟常见的JVM OOM异常,也有直接的关联。 参数调优建议:每个Executor进程的内存设置4G~8G较为合适。但是这只是一个参考值,具体的设置还是得根据不同部门的资源队列来定。可以看看自己团队的资源队列的最大内存限制是多少,num-executors乘以executor-memory,是不能超过队列的最大内存量的。此外,如果你是跟团队里其他人共享这个资源队列,那么申请的内存量最好不要超过资源队列最大总内存的1/3~1/2,避免你自己的Spark作业占用了队列所有的资源,导致别的同学的作业无法运行。 executor-cores 参数说明:该参数用于设置每个Executor进程的CPU core数量。这个参数决定了每个Executor进程并行执行task线程的能力。因为每个CPU core同一时间只能执行一个task线程,因此每个Executor进程的CPU core数量越多,越能够快速地执行完分配给自己的所有task线程。 参数调优建议:Executor的CPU core数量设置为2~4个较为合适。同样得根据不同部门的资源队列来定,可以看看自己的资源队列的最大CPU core限制是多少,再依据设置的Executor数量,来决定每个Executor进程可以分配到几个CPU core。同样建议,如果是跟他人共享这个队列,那么num-executors * executor-cores不要超过队列总CPU core的1/3~1/2左右比较合适,也是避免影响其他同学的作业运行。 driver-memory 参数说明:该参数用于设置Driver进程的内存。 参数调优建议:Driver的内存通常来说不设置,或者设置1G左右应该就够了。唯一需要注意的一点是,如果需要使用collect算子将RDD的数据全部拉取到Driver上进行处理,那么必须确保Driver的内存足够大,否则会出现OOM内存溢出的问题。 spark.default.parallelism 参数说明:该参数用于设置每个stage的默认task数量。这个参数极为重要,如果不设置可能会直接影响你的Spark作业性能。 参数调优建议:Spark作业的默认task数量为500~1000个较为合适。很多同学常犯的一个错误就是不去设置这个参数,那么此时就会导致Spark自己根据底层HDFS的block数量来设置task的数量,默认是一个HDFS block对应一个task。通常来说,Spark默认设置的数量是偏少的(比如就几十个task),如果task数量偏少的话,就会导致你前面设置好的Executor的参数都前功尽弃。试想一下,无论你的Executor进程有多少个,内存和CPU有多大,但是task只有1个或者10个,那么90%的Executor进程可能根本就没有task执行,也就是白白浪费了资源!因此Spark官网建议的设置原则是,设置该参数为num-executors * executor-cores的2~3倍较为合适,比如Executor的总CPU core数量为300个,那么设置1000个task是可以的,此时可以充分地利用Spark集群的资源。 spark.memory.memoryFraction 注意: 一下两个参数是在spark1.6 静态内存管理的时候有效,现在spark2 以上的版本使用的是同意内存管理,参数已经失效,spark 会自己动态关系,storage execution 内存。\n参数说明:该参数用于设置RDD持久化数据在Executor内存中能占的比例,默认是0.6。也就是说,默认Executor 60%的内存,可以用来保存持久化的RDD数据。根据你选择的不同的持久化策略,如果内存不够时,可能数据就不会持久化,或者数据会写入磁盘。 参数调优建议:如果Spark作业中,有较多的RDD持久化操作,该参数的值可以适当提高一些,保证持久化的数据能够容纳在内存中。避免内存不够缓存所有的数据,导致数据只能写入磁盘中,降低了性能。但是如果Spark作业中的shuffle类操作比较多,而持久化操作比较少,那么这个参数的值适当降低一些比较合适。此外,如果发现作业由于频繁的gc导致运行缓慢(通过spark web ui可以观察到作业的gc耗时),意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。 spark.shuffle.memoryFraction [deprecated after spark1.6] 参数说明:该参数用于设置shuffle过程中一个task拉取到上个stage的task的输出后,进行聚合操作时能够使用的Executor内存的比例,默认是0.2。也就是说,Executor默认只有20%的内存用来进行该操作。shuffle操作在进行聚合时,如果发现使用的内存超出了这个20%的限制,那么多余的数据就会溢写到磁盘文件中去,此时就会极大地降低性能。 参数调优建议:如果Spark作业中的RDD持久化操作较少,shuffle操作较多时,建议降低持久化操作的内存占比,提高shuffle操作的内存占比比例,避免shuffle过程中数据过多时内存不够用,必须溢写到磁盘上,降低了性能。此外,如果发现作业由于频繁的gc导致运行缓慢,意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。 资源参数的调优,没有一个固定的值,需要同学们根据自己的实际情况(包括Spark作业中的shuffle操作数量、RDD持久化操作数量以及spark web ui中显示的作业gc情况),同时参考本篇文章中给出的原理以及调优建议,合理地设置上述参数。 demo 1 2 3 4 5 6 7 8 9 ./bin/spark-submit \\ --master yarn-cluster \\ --num-executors 100 \\ --executor-memory 6G \\ --executor-cores 4 \\ --driver-memory 1G \\ --conf spark.default.parallelism=1000 \\ --conf spark.storage.memoryFraction=0.5 \\ --conf spark.shuffle.memoryFraction=0.3 \\ 3. 数据倾斜调优 代码写好了,程序跑的资源也经过精心调配之后设置好了,没有其他意外情况的话,你的Spark程序已经能够正常的跑在集群上。\n但是别以为这样就结束了,这仅仅是Spark程序生命周期的开始。\n为了给你的程序保驾护航,你还需要时刻关注 新上线的应用程序的执行情况是否健康、是否如你所愿如你所想。\n应用程序的执行情况你都可以在 Spark或者Yarn的WebUI 上查看到,有非常详细的执行信息。\n我们现在来讨论一个 可能是导致程序执行缓慢甚至异常 的最大罪魁祸首: 数据倾斜。\n什么是数据倾斜呢? 就是 绝大多数数据(比如80%以上甚至更多)都被分配到 绝少数的节点 上执行(比如20%甚至更少)。\n这么一来,意味着剩下绝大多数的节点都没处理或者没怎么处理数据,处于空闲状态。而 少数节点则一直处理非常忙碌的状态,任务处理需要排队,节点完成计算任务耗时非常长,其他完成任务的节点就在旁边看热闹,但是 只有等所有节点都完成了计算任务整个程序才能算完成。\n例如,总共有1000个task,990个task都在10分钟之内执行完了,但是剩余10个task却要三、四个小时,整个程序的执行时间由最长的那个task决定(反过来的木桶效应)。\n同时,因为某些节点上处理的数据量太多,根据不同的业务代码操作,可能还会出现某些节点在Shuffle过程或者数据处理过程出现OOM异常导致程序失败。\n简单来讲,就是 几颗老鼠屎坏了一锅粥。\n导致数据倾斜的原因有很多,但是其本质都是一样的:在Shuffle等需要通过网络读写数据的过程中,因为数据key分布不均匀,导致大部分数据被集中获取到少部分节点上。\n数据倾斜的情况可以在WebUI上的Stages、Executors页面中观察到,这也就是为什么我们要求对于初次上线的应用,需要时刻关注新上线的应用程序的执行情况是否健康、是否如你所愿如你所想。\n在Web界面中,有哪些Stage,Stage中有哪些Task,各个Task处理的数据量和执行计算的时间等等,这些你都可以很清晰的看到。\n如果发现你的应用中有存在有 几个Task处理的数据量明显比其他Task要大很多,而且还在不停的处理数据,而其他Task已经执行完毕,那么你就是遇到了数据倾斜的问题。\n那么如果我们确定了程序中存在数据倾斜的情况,该如何处理呢?\n根据数据倾斜产生的原因,我们可以在 不同的切入点使用不同的处理策略。\n定位代码与数据问题 在Web界面上,我们可以直观的获得 发生数据倾斜的Stage对应的代码行数,但这个行数并不能精准直接定位到发生数据倾斜的代码,因为它显示的是当前Stage开始执行的代码行数。\n由于数据倾斜只有可能在Shuffle过程中发生,所以 导致数据倾斜的一定是会产生Shuffle过程的算子,比如groupByKey、reduceByKey、aggregateByKey、join、distinct、repartition等等。\n所以,你只需要在Stage所在的行数向上查找Shuffle操作符,那么其就是导致数据倾斜的罪魁祸首。\n找到问题代码之后,需要做的事情很明显了吧?优化之。\n此时我们需要统计一下该Shuffle操作符所使用的数据源,观察各个数据源的 key分布情况(如每个key有多少数据量),以及导致数据倾斜的key在哪里、都有哪些。\n根据数据情况与你对业务的理解,使用「开发调优」中算子优化提到的技巧,尽量这个Shuffle操作的影响降到最低。\n处理源头数据 如果该Shuffle操作符无法避免,代码层面上无法做太多优化,那么此时可以考虑 预先处理数据源。\n先根据数据源key的分布情况或者分区分布情况,针对性的做一次repartition操作,重新存储,后续所有用到该数据源的程序都不会有数据倾斜的问题。\n但是重分区预处理过程中仍然会存在数据倾斜问题而导致预处理过程缓慢。\n如果该数据源只有当前程序使用,那么这个重分区预处理的操作就相当于在读取数据源的时候调用了repartition重分区、或者使用类似reduceByKey(500)调整并行度,实际上并没有起到多少作用。\n所以,重分区预处理的方式只有在 一个数据源被n多个程序使用的时候比较有价值,使用的程序越多性价比越高,否则就是治标不治本,效果有限。\n预聚合 如果前面两种方式都无法解决你的问题,而且 产生数据倾斜的Shuffle操作符是聚合类的(group、reduce、distinct)等,那么你可以尝试使用 预聚合 的方式。\n还记得Mapreduce的Combiner吗,还记得reduceByKey和groupByKey的区别吗,不记得的话建议浏览一下「开发调优」中算子优化技巧。\nMapreduce的Combiner和Spark中的reduceByKey都会在各个节点的本地做一次预聚合。\n类似的,如果存在某个key占据了绝大部分的数据量的话,我们也可以 手动采用预聚合的方式来分散热点数据并执行本地预聚合。\n假设我们现在有1000w的数据,其中800w的数据都是相同的key,此时我们要做聚合操作,默认情况下800w数据会到同一个Task中处理,这肯定是无法接受的。\n怎么手动做预聚合呢? 首先我们可以在这800w的key之前 根据任意hash算法添加固定长度的随机前缀。\n在第一轮聚合时,这个热点key将会被打散到各个节点上去计算。\n之后将key上的固定长度前缀去除,执行第二轮聚合操作。\n因为经过第一轮聚合之后 热点key的数据已经被处理很多了,所以在第二轮聚合的时候可以比较轻松的处理。\n当然如果在第二轮聚合的时候仍然有很大的热点问题,那么理论上可以 继续无限做预聚合处理。\n但是预聚合的缺陷也很明显,只能优化聚合类的操作,如果是join等关联类的Shuffle操作则无法优化。\n使用广播变量代替join 那么碰到join类的算子且发生了数据倾斜该如何处理呢?\n其实我们在「开发调优」中已经提到过解决方式了,就是 使用广播变量来代替join操作。\n但是这个方法也有很多限制,就是 只能应用于大表 join 小表的情况。\n多种方案组合使用 如果以上的方案都没有能够解决你的问题,那么你可以尝试着将多种方案整合起来一起使用,因为在复杂的业务中,Shuffle操作符可能有很多,那么对应的可能产生数据倾斜的地方也有很多。\n所以需要开发人员能够根据 业务逻辑、数据状态、代码编写 等方面能够根据不同的情况组合不同的方案来实施优化。\n4. shuffle调优 在上一节中我们着重介绍了如何针对「数据倾斜」这一情况进行优化。\n除了数据倾斜可以优化之外,Shuffle过程中仍然有许多地方可以优化。但是要记住,影响Spark程序性能的主要因素还是在于 代码开发、资源参数与数据倾斜等,对Shuffle的调整优化可能仅仅是 锦上添花 而不是雪中送炭。\n所以开发人员的重点应该放在前面几个部分,都优化完了之后可以考虑对Shuffle过程进行优化。\n对Shuffle的优化主要是通过调整一些Shuffle相关的参数来实现,你可以根据你的使用情况和经验对以下参数进行调整:\nspark.Shuffle.file.buffer 默认值:32k 参数说明:用于设置Shuffle write task的BufferedOutputStream的buffer缓冲大小。将数据写到磁盘文件之前,会先写入buffer缓冲中,待缓冲写满之后,才会溢写到磁盘。 调优建议:如果作业可用的 内存资源较为充足 的话,可以 适当增加这个参数的大小,从而减少Shuffle write过程中溢写磁盘文件的次数,也就可以减少磁盘IO次数,进而提升性能。 spark.reducer.maxSizeInFlight 默认值:48m 参数说明:用于设置Shuffle read task的buffer缓冲大小,而这个buffer缓冲决定了每次能够拉取多少数据。 调优建议:如果作业可用的 内存资源较为充足 的话,可以 适当增加这个参数的大小,从而减少拉取数据的次数,也就可以减少网络传输的次数,进而提升性能。 spark.Shuffle.io.maxRetries 默认值:3 参数说明:Shuffle read task从Shuffle write task所在节点拉取属于自己的数据时,如果因为网络异常导致拉取失败,是会自动进行重试的。该参数就代表了可以重试的最大次数。如果在指定次数之内拉取还是没有成功,就可能会导致作业执行失败。 调优建议:对于那些包含了 特别耗时的Shuffle操作的作业,建议 增加重试最大次数(比如60次),以避免 由于JVM的full gc或者网络不稳定等因素导致的数据拉取失败,主要提升大型任务的执行稳定性。 spark.Shuffle.io.retryWait 默认值:5s 参数说明:具体解释同上,该参数代表了每次重试拉取数据的等待间隔,默认是5s。 调优建议:建议 加大间隔时长(比如60s),以增加Shuffle操作的稳定性。 spark.Shuffle.manager 默认值:sort 参数说明:该参数用于设置ShuffleManager的类型。 调优建议:由「Shuffle过程与管理」中可以知道,SortShuffleManager默认会对数据进行排序,如果程序中需要排序,那么使用默认即可;如果程序中不需要排序,那么建议 增大bypass的阈值以触发bypass机制或者将manager调整为hash,避免排序带来的开销,同时提供较好的磁盘读写性能。 spark.shuffle.sort.bypassMergeThreshold 默认值:200 参数说明:当manager为sort,且Shuffle read task的数量小于这个阈值时,将会使用bypass机制。 调优建议:使用sort manager时,如果不需要排序,那么就适当增加这个值,大于Shuffle read task的数量。 spark.shuffle.consolidateFiles 默认值:false 参数说明:如果使用hash manager,该参数有效。如果设置为true,那么就会开启 consolidate机制,可以极大地减少磁盘IO开销,提升性能。 调优建议:在不需要排序的情况下,除了使用sort manager触发bypass机制外,使用 hash manager + consolidate机制也是一个高性能的选择,建议使用此组合。 更多参考: 美团spark调优 ","permalink":"https://reid00.github.io/en/posts/computation/spark-%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5%E6%8C%87%E5%8D%97/","summary":"简介 总体上来说,Spark的流程和MapReduce的思想很类似,只是实现的细节方面会有很多差异。 首先澄清2个容易被混淆的概念: Spark是","title":"Spark 最佳实践指南"},{"content":"基础篇 sparksql 如何加载metadata 任何的SQL引擎都是需要加载元数据的,不然,连执行计划都生成不了。 加载元数据总的来说分为两步:\n加载元数据 创建会话连接Hive MetaStore 首先,Spark检测到我们没有设置spark.sql.warehouse.dir,然后就开始找我们在hite-site.xml中配置的hive.metastore.warehouse.dir。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.metastore.uris\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;thrift://test-3:9083,thrift://test-4:9083\u0026lt;/value\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.metastore.client.socket.timeout\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;300\u0026lt;/value\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.metastore.warehouse.dir\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;/data/hive/warehouse\u0026lt;/value\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.warehouse.subdir.inherit.perms\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;true\u0026lt;/value\u0026gt; 然后,SparkSession在HDFS临时位置创建了下面目录。\n1 2 Moved: \u0026#39;hdfs://nn1/data/hive/warehouse/pyspark_test.db/tb_name/part-00000-c46bc573-0d1d-4ac4-8a69-2359dff82485-c000\u0026#39; to trash at: hdfs://nn1/user/hive/.Trash/Current Moved: \u0026#39;hdfs://nn1/data/hive/warehouse/pyspark_test.db/tb_name/part-00001-c46bc573-0d1d-4ac4-8a69-2359dff82485-c000\u0026#39; to trash at: hdfs://nn1/user/hive/.Trash/Current 最后,Spark开始通过thrift RPC去连接Hive的MetaStore Server。\n进阶篇 Spark为什么这么快 Spark是一个基于内存的,用于大规模数据处理的统一分析引擎,其运算速度可以达到Mapreduce的10-100倍。具有如下特点:\n内存计算。Spark优先将数据加载到内存中,数据可以被快速处理,并可启用缓存。 shuffle过程优化。和Mapreduce的shuffle过程中间文件频繁落盘不同,Spark对Shuffle机制进行了优化,降低中间文件的数量并保证内存优先。 RDD计算模型。Spark具有高效的DAG调度算法,同时将RDD计算结果存储在内存中,避免重复计算。 如何理解DAGScheduler的Stage划分算法 官网的RDD执行流程图: 1 rdd1.join(rdd2).groupBy().filter() 针对一段应用代码(如上),Driver会以Action算子为边界生成DAG调度图。DAGScheduler从DAG末端开始遍历划分Stage,封装成一系列的tasksets移交TaskScheduler,后者根据调度算法, 将taskset分发到相应worker上的Executor中执行。\nDAGSchduler的工作原理 DAGScheduler是一个面向stage调度机制的高级调度器,为每个job计算stage的DAG(有向无环图),划分stage并提交taskset给TaskScheduler。 追踪每个RDD和stage的物化情况,处理因shuffle过程丢失的RDD,重新计算和提交。 查找rdd partition 是否cache/checkpoint。提供优先位置给TaskScheduler,等待后续TaskScheduler的最佳位置划分 Stage划分算法 从触发action操作的算子开始,从后往前遍历DAG。 为最后一个rdd创建finalStage。 遍历过程中如果发现该rdd是宽依赖,则为其生成一个新的stage,与旧stage分隔而开,此时该rdd是新stage的最后一个rdd。 如果该rdd是窄依赖,将该rdd划分为旧stage内,继续遍历,以此类推,继续遍历直至DAG完成。 如何理解TaskScheduler的Task分配算法 TaskScheduler负责Spark中的task任务调度工作。TaskScheduler内部使用TasksetPool调度池机制存放task任务。TasksetPool分为FIFO(先进先出调度)和FAIR(公平调度)。 FIFO调度: 基于队列思想,使用先进先出原则顺序调度taskset FAIR调度: 根据权重值调度,一般选取资源占用率作为标准,可人为设定 TaskScheduler的工作原理 负责Application在Cluster Manager上的注册 根据不同策略创建TasksetPool资源调度池,初始化pool大小 根据task分配算法发送Task到Executor上执行 Task分配算法 首先获取所有的executors,包含executors的ip和port等信息 将所有的executors根据shuffle算法进行打散 遍历executors。在程序中依次尝试本地化级别,最终选择每个task的最优位置(结合DAGScheduler优化位置策略) 序列化task分配结果,并发送RPC消息等待Executor响应 Spark的本地化级别有哪几种?怎么调优 移动计算 or 移动数据?这是一个问题。在分布式计算的核心思想中,移动计算永远比移动数据要合算得多,如何合理利用本地化数据计算是值得思考的一个问题。\nTaskScheduler在进行task任务分配时,需要根据本地化级别计算最优位置,一般是遵循就近原则,选择最近位置和缓存。Spark中的本地化级别在TaskManager中定义,分为五个级别。\nSpark本地化级别 PROCESS_LOCAL(进程本地化) partition和task在同一个executor中,task分配到本地Executor进程。 NODE_LOCAL(节点本地化) partition和task在同一个节点的不同Executor进程中,可能发生跨进程数据传输 NO_PREF(无位置) 没有最佳位置的要求,比如Spark读取JDBC的数据\nRACK_LOCAL(机架本地化) partition和task在同一个机架的不同worker节点上,可能需要跨机器数据传输 ANY(跨机架): 数据在不同机架上,速度最慢\nSpark本地化调优 在task最佳位置的选择上,DAGScheduler先判断RDD是否有cache/checkpoint,即缓存优先;否则TaskScheduler进行本地级别选择等待发送task。 TaskScheduler首先会根据最高本地化级别发送task,如果在尝试5次并等待3s内还是无法执行,则认为当前资源不足,即降低本地化级别,按照PROCESS-\u0026gt;NODE-\u0026gt;RACK等顺序。\n调优1:加大spark.locality.wait 全局等待时长 调优2:加大spark.locality.wait.xx等待时长(进程、节点、机架) 调优3:加大重试次数(根据实际情况微调) 说说Spark和Mapreduce中Shuffle的区别 Spark中的shuffle很多过程与MapReduce的shuffle类似,都有Map输出端、Reduce端,shuffle过程通过将Map端计算结果分区、排序并发送到Reducer端。\n1. Hadoop Mapreduce Shuffle MapReduce的shuffle需要依赖大量磁盘操作,数据会频繁落盘产生大量IO,同时产生大量小文件冗余。虽然缓存buffer区中启用了缓存机制,但是阈值较低且内存空间小。\n读取输入数据,并根据split大小切分为map任务 map任务在分布式节点中执行map()计算 每个map task维护一个环形的buffer缓存区,存储map输出结果,分区且排序 当buffer区域达到阈值时,开始溢写到临时文件中。map task任务结束时进行临时文件合并。此时,整合shuffle map端执行完成 mapreduce根据partition数启动reduce任务,copy拉取数据 merge合并拉取的文件 reduce()函数聚合计算,整个过程完成 2. Spark的Shuffle机制 默认的shuffle计算引擎是HashShuffleManager,此种Shuffle产生大量的中间磁盘文件,消耗磁盘IO性能。在Spark1.2后续版本中,默认的ShuffleManager改成了SortShuffleManager,通过索引机制和合并临时文件的优化操作,大幅提高shuffle性能。 HashShuffleManager HashShuffleManager的运行机制主要分成两种,一种是普通运行机制,另一种是合并的运行机制。合并机制主要是通过复用buffer来优化Shuffle过程中产生的小文件的数量,Hash shuffle本身不排序。开启合并机制后,同一个Executor共用一组core,文件个数为cores * reduces。 SortShuffleManager SortShuffleManager的运行机制分成两种,普通运行机制和bypass运行机制。当shuffletask的数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值时(默认200),会启用bypass机制。\n普通运行机制 在该模式下,数据会先写入一个内存数据结构中,此时根据不同的 shuffle 算子,可能选用不同的数据结构。如果是 reduceByKey 这种聚合类的 shuffle 算子,那么会选用 Map 数据结构,一边通过 Map 进行聚合,一边写入内存;如果是 join 这种普通的 shuffle 算子,那么会选用 Array 数据结构,直接写入内存。接着,每写一条数据进入内存数据结构之后,就会判断一下,是否达到了某个临界阈值。如果达到临界阈值的话,那么就会尝试将内存数据结构中的数据溢写到磁盘,然后清空内存数据结构。\n在溢写到磁盘文件之前,会先根据 key 对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。默认的 batch 数量是 10000 条,也就是说,排序好的数据,会以每批 1 万条数据的形式分批写入磁盘文件。写入磁盘文件是通过 Java 的 BufferedOutputStream 实现的。BufferedOutputStream 是 Java 的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这样可以减少磁盘 IO 次数,提升性能。\n一个 task 将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写操作,也就会产生多个临时文件。最后会将之前所有的临时磁盘文件都进行合并,这就是merge 过程,此时会将之前所有临时磁盘文件中的数据读取出来,然后依次写入最终的磁盘文件之中。此外,由于一个 task 就只对应一个磁盘文件,也就意味着该 task 为下游 stage 的 task 准备的数据都在这一个文件中,因此还会单独写一份索引文件,其中标识了下游各个 task 的数据在文件中的 start offset 与 end offset。\nSortShuffleManager 由于有一个磁盘文件 merge 的过程,因此大大减少了文件数量。比如第一个 stage 有 50 个 task,总共有 10 个 Executor,每个 Executor 执行 5 个 task,而第二个 stage 有 100 个 task。由于每个 task 最终只有一个磁盘文件,因此此时每个 Executor 上只有 5 个磁盘文件,所有 Executor 只有 50 个磁盘文件。 普通运行机制的 SortShuffleManager 工作原理如下图所示: bypass运行机制 Reducer 端任务数比较少的情况下,基于 Hash Shuffle 实现机制明显比基于 Sort Shuffle 实现机制要快,因此基于 Sort Shuffle 实现机制提供了一个带 Hash 风格的回退方案,就是 bypass 运行机制。对于 Reducer 端任务数少于配置属性spark.shuffle.sort.bypassMergeThreshold设置的个数时,使用带 Hash 风格的回退计划。\npass 运行机制的触发条件如下: shuffle map task 数量小于spark.shuffle.sort.bypassMergeThreshold=200参数的值。不是聚合类的 shuffle 算子。\n此时,每个 task 会为每个下游 task 都创建一个临时磁盘文件,并将数据按 key 进行 hash 然后根据 key 的 hash 值,将 key 写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。\n该过程的磁盘写机制其实跟未经优化的 HashShuffleManager 是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的 HashShuffleManager 来说,shuffle read 的性能会更好。\n而该机制与普通 SortShuffleManager 运行机制的不同在于:\n第一,磁盘写机制不同; 第二,不会进行排序。 也就是说,启用该机制的最大好处在于,shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。 Tungsten Sort Shuffle 运行机制 Tungsten Sort 是对普通 Sort 的一种优化,Tungsten Sort 会进行排序,但排序的不是内容本身,而是内容序列化后字节数组的指针(元数据),把数据的排序转变为了指针数组的排序,实现了直接对序列化后的二进制数据进行排序。由于直接基于二进制数据进行操作,所以在这里面没有序列化和反序列化的过程。内存的消耗大大降低,相应的,会极大的减少的 GC 的开销。\nSpark 提供了配置属性,用于选择具体的 Shuffle 实现机制,但需要说明的是,虽然默认情况下 Spark 默认开启的是基于 SortShuffle 实现机制,但实际上,参考 Shuffle 的框架内核部分可知基于 SortShuffle 的实现机制与基于 Tungsten Sort Shuffle 实现机制都是使用\nSortShuffleManager,而内部使用的具体的实现机制,是通过提供的两个方法进行判断的: 对应非基于 Tungsten Sort 时,通过 SortShuffleWriter.shouldBypassMergeSort 方法判断是否需要回退到 Hash 风格的 Shuffle 实现机制,当该方法返回的条件不满足时,则通过 SortShuffleManager.canUseSerializedShuffle方法判断是否需要采用基于 Tungsten Sort Shuffle 实现机制,而当这两个方法返回都为 false,即都不满足对应的条件时,会自动采用普通运行机制。\n因此,当设置了spark.shuffle.manager=tungsten-sort时,也不能保证就一定采用基于 Tungsten Sort 的 Shuffle 实现机制。\n要实现 Tungsten Sort Shuffle 机制需要满足以下条件:\nShuffle 依赖中不带聚合操作或没有对输出进行排序的要求。 Shuffle 的序列化器支持序列化值的重定位(当前仅支持 KryoSerializer Spark SQL 框架自定义的序列化器)。 Shuffle 过程中的输出分区个数少于 16777216 个。 实际上,使用过程中还有其他一些限制,如引入 Page 形式的内存管理模型后,内部单条记录的长度不能超过 128 MB (具体内存模型可以参考 PackedRecordPointer 类)。另外,分区个数的限制也是该内存模型导致的。\n所以,目前使用基于 Tungsten Sort Shuffle 实现机制条件还是比较苛刻的。\n3. Spark Shuffle 历史: 为什么 Spark 最终还是放弃了 HashShuffle ,使用了 Sorted-Based Shuffle? 我们可以从 Spark 最根本要优化和迫切要解决的问题中找到答案,使用 HashShuffle 的 Spark 在 Shuffle 时产生大量的文件。当数据量越来越多时,产生的文件量是不可控的,这严重制约了 Spark 的性能及扩展能力,所以 Spark 必须要解决这个问题,减少 Mapper 端 ShuffleWriter 产生的文件数量,这样便可以让 Spark 从几百台集群的规模瞬间变成可以支持几千台,甚至几万台集群的规模。\n但使用 Sorted-Based Shuffle 就完美了吗,答案是否定的,Sorted-Based Shuffle 也有缺点,其缺点反而是它排序的特性,它强制要求数据在 Mapper 端必须先进行排序,所以导致它排序的速度有点慢。好在出现了 Tungsten-Sort Shuffle ,它对排序算法进行了改进,优化了排序的速度。Tungsten-SortShuffle 已经并入了 Sorted-Based Shuffle,Spark 的引擎会自动识别程序需要的是 Sorted-BasedShuffle,还是 Tungsten-Sort Shuffle。\nSpark SQL和Hive SQL的区别 Hive SQL是Hive提供的SQL查询引擎,底层由MapReduce实现。Hive根据输入的SQL语句执行词法分析、语法树构建、编译、逻辑计划、优化逻辑计划以及物理计划等过程,转化为Map Task和Reduce Task最终交由Mapreduce引擎执行。\n执行引擎。具有mapreduce的一切特性,适合大批量数据离线处理,相较于Spark而言,速度较慢且IO操作频繁 有完整的hql语法,支持基本sql语法、函数和udf 对表数据存储格式有要求,不同存储、压缩格式性能不同 Checkpoint 检查点机制 应用场景:当spark应用程序特别复杂,从初始的RDD开始到最后整个应用程序完成有很多的步骤,而且整个应用运行时间特别长,这种情况下就比较适合使用checkpoint功能。\n原因:对于特别复杂的Spark应用,会出现某个反复使用的RDD,即使之前持久化过但由于节点的故障导致数据丢失了,没有容错机制,所以需要重新计算一次数据。\nCheckpoint首先会调用SparkContext的setCheckPointDIR()方法,设置一个容错的文件系统的目录,比如说HDFS;然后对RDD调用checkpoint()方法。之后在RDD所处的job运行结束之后,会启动一个单独的job,来将checkpoint过的RDD数据写入之前设置的文件系统,进行高可用、容错的类持久化操作。\n检查点机制是我们在spark streaming中用来保障容错性的主要机制,它可以使spark streaming阶段性的把应用数据存储到诸如HDFS等可靠存储系统中,以供恢复时使用。具体来说基于以下两个目的服务:\n控制发生失败时需要重算的状态数。Spark streaming可以通过转化图的谱系图来重算状态,检查点机制则可以控制需要在转化图中回溯多远。 提供驱动器程序容错。如果流计算应用中的驱动器程序崩溃了,你可以重启驱动器程序并让驱动器程序从检查点恢复,这样spark streaming就可以读取之前运行的程序处理数据的进度,并从那里继续\ncheckpoint和持久化机制的区别 最主要的区别在于持久化只是将数据保存在BlockManager中,但是RDD的lineage(血缘关系,依赖关系)是不变的。但是checkpoint执行完之后,rdd已经没有之前所谓的依赖rdd了,而只有一个强行为其设置的checkpointRDD,checkpoint之后rdd的lineage就改变了。 持久化的数据丢失的可能性更大,因为节点的故障会导致磁盘、内存的数据丢失。但是checkpoint的数据通常是保存在高可用的文件系统中,比如HDFS中,所以数据丢失可能性比较低。\nSpark shuffle 参数优化 spark.shuffle.file.buffer:主要是设置的Shuffle过程中写文件的缓冲,默认32k,如果内存足够,可以适当调大,来减少写入磁盘的数量。 spark.reducer.maxSizeInFight:主要是设置Shuffle过程中读文件的缓冲区,一次能够读取多少数据,默认48m, 如果内存足够,可以适当扩大,减少整个网络传输次数。 spark.shuffle.io.maxRetries:主要是设置网络连接失败时,重试次数,默认3次, 适当调大能够增加稳定性。 spark.shuffle.io.retryWait:主要设置每次重试之间的间隔时间,可以适当调大,默认5s, 增加程序稳定性。 spark.shuffle.memoryFraction:该参数代表了Executor内存中,分配给shuffle read task进行聚合操作的内存比例,默认是20%。Shuffle过程中的内存占用,如果程序中较多使用了Shuffle操作,那么可以适当调大该区域。 [deprecated], 旧版本的静态内存管理策略生效,新版本统一内存管理此参数无效。用的是 spark.storage.memoryFraction 中的内存 spark.shuffle.manager:Hash和Sort方式,Sort是默认,Hash在reduce数量 比较少的时候,效率会很高。 spark.shuffle.sort.bypassMergeThreshold:设置的是Sort方式中,默认200,启用Hash输出方式的临界值,如果你的程序数据不需要排序,而且reduce数量比较少,那推荐可以适当增大临界值。 spark.shuffle.cosolidateFiles:如果你使用Hash shuffle方式,推荐打开该配置,实现更少的文件输出。如果设置为true,那么就会开启consolidate机制,会大幅度合并shuffle write的输出文件,对于shuffle read task数量特别多的情况下,这种方法可以极大地减少磁盘IO开销,提升性能。调优建议:如果的确不需要SortShuffleManager的排序机制,那么除了使用bypass机制,还可以尝试将spark.shuffle.manager参数手动指定为hash,使用HashShuffleManager,同时开启consolidate机制。在实践中尝试过,发现其性能比开启了bypass机制的SortShuffleManager要高出10%~30%。 spark.sql.adaptive.shuffle.targetPostShuffleInputSize: default 67108864(64M) 动态调整reduce个数的partition大小依据,动态合并reducer的partition。map端多个partition 合并后数据阈值,小于阈值会合并。如设置64MB则reduce阶段每个task最少处理64MB的数据 Spark Locality 参数 参数想 默认值 参数解释 spark.locality.wait 3000(毫秒) 数据本地性降级的等待时间 spark.locality.wait.process spark.locality.wait 多长时间等不到PROCESS_LOCAL就降 spark.locality.wait.node spark.locality.wait 多长时间等不到NODE_LOCAL就降 spark.locality.wait.rack spark.locality.wait 多长时间等不到RACK_LOCAL就降级 1 2 3 4 new SparkConf().set(\u0026#34;spark.locality.wait\u0026#34;, \u0026#34;10\u0026#34;) spark.locality.wait.process//建议60s spark.locality.wait.node//建议30s spark.locality.wait.rack//建议20s ","permalink":"https://reid00.github.io/en/posts/computation/spark-%E9%9D%A2%E8%AF%95%E6%B3%A8%E6%84%8F%E7%82%B9/","summary":"基础篇 sparksql 如何加载metadata 任何的SQL引擎都是需要加载元数据的,不然,连执行计划都生成不了。 加载元数据总的来说分为两步: 加载元数据 创建","title":"Spark 面试注意点"},{"content":"简介 当一个Spark应用提交到集群上运行时,应用架构包含了两个部分:\nDriver Program(资源申请和调度Job执行) Executors(运行Job中Task任务和缓存数据),两个都是JVM Process进程 Driver程序运行的位置可以通过–deploy-mode 来指定:\nDriver指的是The process running the main() function of the application and creating the SparkContext 运行应用程序的main()函数并创建SparkContext的进程\nclient: 表示Driver运行在提交应用的Client上(默认) cluster: 表示Driver运行在集群中(Standalone:Worker,YARN:NodeManager) cluster和client模式最最本质的区别是:Driver程序运行在哪里。 企业实际生产环境中使用cluster 为主要模式。 1. Client(客户端)模式 DeployMode为Client,表示应用Driver Program运行在提交应用Client主机上。 示意图: 1 2 3 4 5 6 7 8 9 10 11 SPARK_HOME=/export/server/spark ${SPARK_HOME}/bin/spark-submit \\ --master yarn \\ --deploy-mode client \\ --driver-memory 512m \\ --executor-memory 512m \\ --num-executors 1 \\ --total-executor-cores 2 \\ --class org.apache.spark.examples.SparkPi \\ ${SPARK_HOME}/examples/jars/spark-examples_2.11-2.4.5.jar \\ 10 2.Cluster(集群)模式,生产环境用 DeployMode为Cluster,表示应用Driver Program运行在集群从节点某台机器上. 1 2 3 4 5 6 7 8 9 10 11 SPARK_HOME=/export/server/spark ${SPARK_HOME}/bin/spark-submit \\ --master yarn \\ --deploy-mode cluster \\ --driver-memory 512m \\ --executor-memory 512m \\ --num-executors 1 \\ --total-executor-cores 2 \\ --class org.apache.spark.examples.SparkPi \\ ${SPARK_HOME}/examples/jars/spark-examples_2.11-2.4.5.jar \\ 10 总结: Client模式和Cluster模式最最本质的区别是:Driver程序运行在哪里。\nClient模式:测试时使用,开发不用,了解即可 Driver运行在Client上,和集群的通信成本高 Driver输出结果会在客户端显示 Cluster模式:生产环境中使用该模式 Driver程序在YARN集群中,和集群的通信成本低 Driver输出结果不能在客户端显示 该模式下Driver运行ApplicattionMaster这个节点上,由Yarn管理,如果出现问题,yarn会重启ApplicattionMaster(Driver) 3. 两种模式的详细流程图 Client模式图示: 在YARN Client模式下,Driver在任务提交的本地机器上运行。 Driver在任务提交的本地机器上运行,Driver启动后会和ResourceManager通讯申请启动ApplicationMaster 1 2 3 --master yarn \\ --deploy-mode client \\ --driver-memory 512m \\ 随后ResourceManager分配Container,在合适的NodeManager上启动ApplicationMaster,此时的ApplicationMaster的功能相当于一个ExecutorLaucher,只负责向ResourceManager申请Executor内存 1 2 3 --executor-memory 512m \\ --executor-cores 2 \\ --num-executors 1 \\ ResourceManager接到ApplicationMaster的资源申请后会分配Container,然后ApplicationMaster在资源分配指定的NodeManager上启动Executor进程; Executor进程启动后会向Driver反向注册,Executor全部注册完成后Driver开始执行main函数; 之后执行到Action算子时,触发一个Job,并根据宽依赖开始划分Stage,每个Stage生成对应的TaskSet,之后将Task分发到各个Executor上执行。 Cluster 模式示意图 在YARN Cluster模式下,Driver运行在NodeManager Contanier中,此时Driver与AppMaster合为一体。 Driver在任务提交的本地机器上运行,Driver启动后会和ResourceManager通讯申请启动ApplicationMaster 1 2 3 --master yarn \\ --deploy-mode cluster \\ --driver-memory 512m \\ 随后ResourceManager分配Container,在合适的NodeManager上启动ApplicationMaster,此时的ApplicationMaster的功能相当于一个ExecutorLaucher,只负责向ResourceManager申请Executor内存 1 2 3 --executor-memory 512m \\ --executor-cores 2 \\ --num-executors 1 \\ ResourceManager接到ApplicationMaster的资源申请后会分配Container,然后ApplicationMaster在资源分配指定的NodeManager上启动Executor进程; Executor进程启动后会向Driver反向注册,Executor全部注册完成后Driver开始执行main函数; 之后执行到Action算子时,触发一个Job,并根据宽依赖开始划分Stage,每个Stage生成对应的TaskSet,之后将Task分发到各个Executor上执行。 4. 运行中涉及到的名词 Application: Appliction都是指用户编写的Spark应用程序,其中包括一个Driver功能的代码和分布在集群中多个节点上运行的Executor代码 Driver: Spark中的Driver即运行上述Application的main函数并创建SparkContext,创建- SparkContext的目的是为了准备Spark应用程序的运行环境,当Executor部分运行完毕后,Driver同时负责将SparkContext关闭,通常用SparkContext代表Driver AppMaster: 控制yarn app运行和任务资源 Executor: 某个Application运行在worker节点上的一个进程, 该进程负责运行某些Task, 并且负责将数据存到内存或磁盘上,每个Application都有各自独立的一批Executor Worker: 集群中任何可以运行Application代码的节点,在Standalone模式中指的是通过slave文件配置的Worker节点,在Spark on Yarn模式下就是NodeManager节点 Task: 被送到某个Executor上的工作单元,但hadoopMR中的MapTask和ReduceTask概念一样,是运行Application的基本单位,多个Task组成一个Stage,而Task的调度和管理等是由TaskScheduler负责 Job: 包含多个Task组成的并行计算,往往由Spark Action触发生成, 一个Application中往往会产生多个Job Stage: 每个Job会被拆分成多组Task, 作为一个TaskSet, 其名称为Stage,Stage的划分和调度是有DAGScheduler来负责的,Stage有非最终的Stage(Shuffle Map Stage)和最终的Stage(Result Stage)两种,Stage的边界就是发生shuffle的地方 DAGScheduler: 根据Job构建基于Stage的DAG(Directed Acyclic Graph有向无环图),并提交Stage给TASkScheduler。 其划分Stage的依据是RDD之间的依赖的关系找出开销最小的调度方法 TASKSedulter: 将TaskSet提交给worker运行,每个Executor运行什么Task就是在此处分配的. TaskScheduler维护所有TaskSet,当Executor向Driver发生心跳时,TaskScheduler会根据资源剩余情况分配相应的Task。另外TaskScheduler还维护着所有Task的运行标签,重试失败的Task Spark集群中的角色 Driver: 是一个JVM Process 进程,编写的Spark应用程序就运行在Driver上,由Driver进程执行; Master(ResourceManager): 是一个JVM Process 进程,主要负责资源的调度和分配,并进行集群的监控等职责; Worker(NodeManager): 是一个JVM Process 进程,一个Worker运行在集群中的一台服务器上,主要负责两个职责,一个是用自己的内存存储RDD的某个或某些partition;另一个是启动其他进程和线程(Executor),对RDD上的partition进行并行的处理和计算。 Executor: 是一个JVM Process 进程,一个Worker(NodeManager)上可以运行多个Executor,Executor通过启动多个线程(task)来执行对RDD的partition进行并行计算,也就是执行我们对RDD定义的例如map、flatMap、reduce等算子操作。\n","permalink":"https://reid00.github.io/en/posts/computation/spark-on-yarn-%E6%89%A7%E8%A1%8C%E6%B5%81%E7%A8%8B%E8%A7%A3%E6%9E%90/","summary":"简介 当一个Spark应用提交到集群上运行时,应用架构包含了两个部分: Driver Program(资源申请和调度Job执行) Executors(运行Jo","title":"Spark on Yarn 执行流程解析"},{"content":"概述 在spark程序中,当一个传递给Spark操作(例如map和reduce)的函数在远程节点上面运行时,Spark操作实际上操作的是这个函数所用变量的一个独立副本。这些变量会被复制到每台机器上,并且这些变量在远程机器上的所有更新都不会传递回驱动程序。通常跨任务的读写变量是低效的,但是,Spark还是为两种常见的使用模式提供了两种有限的共享变量:广播变(broadcast variable)和累加器(accumulator)\n为什么需要广播变量 如果我们要在分布式计算里面分发大对象,例如:字典,集合,黑白名单等,这个都会由Driver端进行分发,一般来讲,如果这个变量不是广播变量,那么每个task就会分发一份,这在task数目十分多的情况下Driver的带宽会成为系统的瓶颈,而且会大量消耗task服务器上的资源,如果将这个变量声明为广播变量,那么知识每个executor拥有一份,这个executor启动的task会共享这个变量,节省了通信的成本和服务器的资源。\n图解广播变量 不使用广播变量 使用广播变量 可知: 如果使用广播变量,一个executor 只有一个driver 变量的副本,节省资源,而不是用的话,同一个executor 的不同task 都会有这个变量的副本,网络IO就会成为瓶颈。\n如何定义广播变量 1 2 3 4 5 6 7 8 val data = List(1, 2, 3, 4, 5, 6) val bdata = sc.broadcast(data) val rdd = sc.parallelize(1 to 6, 2) val observedSizes = rdd.map(_ =\u0026gt; bdata.value.size) 取 value val c = broadcast.value 注意点 变量一旦被定义为一个广播变量,那么这个变量只能读,不能修改\n1、能不能将一个RDD使用广播变量广播出去?\n不能,因为RDD是不存储数据的。可以将RDD的结果广播出去。 2、 广播变量只能在Driver端定义,不能在Executor端定义。\n3、 在Driver端可以修改广播变量的值,在Executor端无法修改广播变量的值。\n4、如果executor端用到了Driver的变量,如果不使用广播变量在Executor有多少task就有多少Driver端的变量副本。\n5、如果Executor端用到了Driver的变量,如果使用广播变量在每个Executor中只有一份Driver端的变量副本。\n为什么需要累加器 在spark应用程序中,我们经常会有这样的需求,如异常监控,调试,记录符合某特性的数据的数目,这种需求都需要用到计数器,如果一个变量不被声明为一个累加器,那么它将在被改变时不会再driver端进行全局汇总,即在分布式运行时每个task运行的只是原始变量的一个副本,并不能改变原始变量的值,但是当这个变量被声明为累加器后,该变量就会有分布式计数的功能。\n图解累加器 不使用累加器 使用累加器 如何定义一个累加器? 1 2 3 4 val a = sc.accumulator(0) 取值 val b = a.value 注意点 1、 累加器在Driver端定义赋初始值,累加器只能在Driver端读取最后的值,在Excutor端更新。\n2、累加器不是一个调优的操作,因为如果不这样做,结果是错的\n哪些变量在Drive 端,哪些在Executor 端 driver \u0026amp; executor driver是运行用户编写Application 的main()函数的地方,具体负责DAG的构建、任务的划分、task的生成与调度等。job,stage,task生成都离不开rdd自身,rdd的相关的操作不能缺少driver端的sparksession/sparkcontext。\nexecutor是真正执行task地方,而task执行离不开具体的数据,这些task运行的结果可以是shuffle中间结果,也可以持久化到外部存储系统。一般都是将结果、状态等汇集到driver。但是,目前executor之间不能互相通信,只能借助第三方来实现数据的共享或者通信。\n那么,编写的Spark程序代码,运行在driver端还是executor端呢? 通常我们在本地测试程序的时候,要打印RDD中的数据。\n在本地模式下,直接使用rdd.foreach(println)或rdd.map(println)在单台机器上,能够按照预期打印并输出所有RDD的元素。\n但是,在集群模式下,由executor执行输出写入的是executor的stdout,而不是driver上的stdout,所以driver的stdout不会显示这些!\n要想在driver端打印所有元素,可以使用collect()方法先将RDD数据带到driver节点,然后在调用foreach(println)(但需要注意一点,由于会把RDD中所有元素都加载到driver端,可能引起driver端内存不足导致OOM。如果你只是想获取RDD中的部分元素,可以考虑使用take或者top方法)\n总之,在这里RDD中的元素即为具体的数据,对这些数据的操作都是由负责task执行的executor处理的,所以想在driver端输出这些数据就必须先将数据加载到driver端进行处理。\n最后做个总结:所有对RDD具体数据的操作都是在executor上执行的,所有对rdd自身的操作都是在driver上执行的。比如foreach、foreachPartition都是针对rdd内部数据进行处理的,所以我们传递给这些算子的函数都是执行于executor端的。但是像foreachRDD、transform则是对RDD本身进行一列操作,所以它的参数函数是执行在driver端的,那么它内部是可以使用外部变量,比如在Spark Streaming程序中操作offset、动态更新广播变量等。\n","permalink":"https://reid00.github.io/en/posts/computation/spark-%E5%B9%BF%E6%92%AD%E5%8F%98%E9%87%8F/","summary":"概述 在spark程序中,当一个传递给Spark操作(例如map和reduce)的函数在远程节点上面运行时,Spark操作实际上操作的是这个函","title":"Spark 广播变量"},{"content":"介绍 Slidev 使用一种扩展的 Markdown 格式,在一个纯文本文件中存储和组织你的幻灯片。这让你专注于制作内容。而且由于内容和样式是分开的,这也使得在不同的主题之间切换变得更加容易。\n官网 GitHub\n如何使用 Node.js 的安装 参考Node 安装合适的版本\nSlidev 安装简介 本地创建 快速开始最好的方式就是使用官方的初始模板。\n使用 NPM: 可以本地创建一个slidev 的文件夹,然后在此文件夹下目录的命令行中输入下面的命令: 1 npm install slidev 安装完成之后会生成一个 slidev 的文件夹,里面有一个demo 的md 文件。\n使用 Yarn: 1 yarn create slid 命令行界面 创建之后,按 ctrl + c 结束demo, 如果想要再次打开,可以使用 npx slidev\n全局安装 你可以使用如下命令在全局安装 Slidev:\n1 npm i -g @slidev/cli 然后即可在任何地方使用 slidev,而无需每次都创建一个项目。\n1 slidev xx.md 查看相关命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 $ slidev --help slidev [args] 命令: slidev [entry] Start a local server for Slidev [默认值] slidev build [entry] Build hostable SPA slidev format [entry] Format the markdown file slidev theme [subcommand] Theme related operations slidev export [entry] Export slides to PDF slidev export-notes [entry] Export slide notes to PDF 位置: entry path to the slides markdown entry [字符串] [默认值: \u0026#34;slides.md\u0026#34;] 选项: -t, --theme override theme [字符串] -p, --port port [数字] -o, --open open in browser [布尔] [默认值: false] --remote listen public host and enable remote control [字符串] --tunnel open localtunnel to make Slidev available on the internet [布尔] [默认值: false] --log log level [字符串] [可选值: \u0026#34;error\u0026#34;, \u0026#34;warn\u0026#34;, \u0026#34;info\u0026#34;, \u0026#34;silent\u0026#34;] [默认值: \u0026#34;warn\u0026#34;] --inspect enable the inspect plugin for debugging [布尔] [默认值: false] -f, --force force the optimizer to ignore the cache and re-bundle [布尔] [默认值: false] -h, --help 显示帮助信息 [布尔] -v, --version 显示版本号 [布尔] Demo 输入slidev xx.md 如果第一次创建会提示不存在,会你是否创建,输入Y\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 zhangbl@DESKTOP-8NHU8UF MINGW64 /c/slidev $ slidev kg.md √ Entry file \u0026#34;kg.md\u0026#34; does not exist, do you want to create it? ... yes ●■▲ Slidev v0.40.3 (global) theme @slidev/theme-seriph entry C:\\slidev\\kg.md public slide show \u0026gt; http://localhost:3030/ presenter mode \u0026gt; http://localhost:3030/presenter/ remote control \u0026gt; pass --remote to enable shortcuts \u0026gt; restart | open | edit 在下面 shortcuts 出告诉你,你可以输入restart,open,edit 两个单词的首字母r,o,e 分别对应重新加载这个markdown, 在浏览器中打开这个markdown(以PPT 播放的模式), 或者在编辑器中编辑这个markdown。\n更改主题 打开markdown 文件,在头部改为: 1 2 # try also \u0026#39;default\u0026#39; to start simple theme: default 就会重新加载,如果不存在就会询问是否下载。 更多主题访问这里\n简单语法\n用 --- 来分割,表示下一页 如果需要远程访, 可以用slidev xx.md --remote 在remote control 中可以看到访问的url 1 2 3 4 5 6 7 8 9 10 11 12 13 14 zhangbl@DESKTOP-8NHU8UF MINGW64 /c/slidev $ slidev kg.md --remote ●■▲ Slidev v0.40.3 (global) theme @slidev/theme-default entry C:\\slidev\\kg.md public slide show \u0026gt; http://localhost:3030/ presenter mode \u0026gt; http://localhost:3030/presenter/ remote control \u0026gt; http://10.10.63.148:3030/presenter/ shortcuts \u0026gt; restart | open | edit | qrcode 直接使用主题创建\n1 slidev -t vuetiful kg.md 问题 [vite] Internal server error 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Element is missing end tag. 16:55:28 [vite] Internal server error: Element is missing end tag. Plugin: vite:vue File: /@slidev/slides/1.md:10:4 8 | \u0026lt;/span\u0026gt; 9 | \u0026lt;/div\u0026gt; 10 | \u0026lt;p\u0026gt;\u0026lt;a href=\u0026#34;https://github.com/slidevjs/slidev\u0026#34; target=\u0026#34;_blank\u0026#34; alt=\u0026#34;GitHub\u0026#34; | ^ 11 | class=\u0026#34;abs-br m-6 text-xl slidev-icon-btn opacity-50 !border-none !hover:text-white\u0026#34;\u0026gt;\u0026lt;/p\u0026gt; 12 | \u0026lt;carbon-logo-github /\u0026gt;\u0026lt;/a\u0026gt; at createCompilerError (C:\\Users\\ld\\AppData\\Roaming\\npm\\node_modules\\@slidev\\cli\\node_modules\\@vue\\compiler-core\\dist\\compiler-core.cjs.js:19:19) at emitError (C:\\Users\\ld\\AppData\\Roaming\\npm\\node_modules\\@slidev\\cli\\node_modules\\@vue\\compiler-core\\dist\\compiler-core.cjs.js:1613:29) at parseElement ... github 上有这个issue 解决方式:\n1 A temporary solution is to delete the enter after alt=\u0026#34;Github\u0026#34;. 删除xx.md 文件中alt=\u0026ldquo;Github\u0026rdquo; 后面的换行\n","permalink":"https://reid00.github.io/en/posts/other/slidev-markdown-%E8%BD%ACppt/","summary":"介绍 Slidev 使用一种扩展的 Markdown 格式,在一个纯文本文件中存储和组织你的幻灯片。这让你专注于制作内容。而且由于内容和样式是分开的,这也使得在不同的主题之","title":"Slidev Markdown 转PPT"}] \ No newline at end of file +[{"content":"介绍 这是我博客 Blog 的地址 和 Github Repositroy。\n本博客是用Hugo 来生成静态网站。 Hugo GitHub\n并通过 GitHub Action 来自动化部署到 GitHub Pages。\n搭建步骤 创建代码仓库 首先按照文档创建 GitHub Pages 站点。该仓库可见性必须是 Public。\n另外创建一个仓库用来存放 Hugo 的源文件,名称随意,这里假设仓库名叫 .github.io.source。建议将仓库可见性设置成 Private 以保护好你的源代码。\n创建完毕后你的账户下将存在以下两个代码仓库:\nhttps://github.com/\u0026lt;YourName\u0026gt;/\u0026lt;YourName\u0026gt;.github.io (公开的)\nhttps://github.com/\u0026lt;YourName\u0026gt;/\u0026lt;YourName\u0026gt;.github.io.source(私有的)\n生成Hugo 网站 安装Hugo For Windows\n到Github Release 下载最新版本,用hugo version 或者extended version (部分主题需要extended version 才能使用)\n安装步骤参考官方提供\n在C盘新建Hugo/sites 目录用于 生成hugo 项目\n在C盘新建Hugo/bin 目录,用来存放上面解压后的hugo 二进制文件\n添加C:\\Hugo\\bin 到系统环境变量中\n添加完成后,在cmd 或者其他console 中输入hugo version检查 环境变量是否添加成功。\n出现下面的表示成功。注意:环境变量添加成功后,记得重启console\nFor mac/linux\n可以只用用命令下载,此处不多讲了。\nHugo 生成网站 在/c/Hugo/sites 目录下使用命令hugo new site siteName生成网站\n1 hugo new site hello-hugo 执行成功后,Hugo 会给出温馨的提示:\nJust a few more steps and you’re ready to go:\nDownload a theme into the same-named folder. Choose a theme from https://themes.gohugo.io/ or create your own with the “hugo new theme ” command. Perhaps you want to add some content. You can add single files with “hugo new .”. Start the built-in live server via “hugo server”.\n先看看执行完 hugo new site 命令后,Hugo 为我们做了什么。\n进入 hello-hugo 目录,Hugo 生成的内容如下图所示:\n这些大致作用如下:\narchetypes:存放博客的模板,默认提供了一个 default.md 作为所有博客的模板。 data:存放一些数据,如 xml、json 等。 layouts:与博客页面布局相关的内容,如博客网页中的 header、footer 等。 static:存放静态资源,如图标、图片等。 themes:主题相关。 config.toml:站点、主题等相关内容的配置文件,它支持 yaml、toml 和 json 格式,后续将会一直和这个文件打交道。 建议config.toml 改为config.yaml 语法看着更舒服点。\nHugo 主题使用 根据提示,要使用 Hugo,我们必须先下载 主题,这里我选择自己比较喜欢的 PaperMod。\n根据文档安装 hugo 主题。\n文档提供了三种方式,建议使用第一种或者第二种。\n安装主题之后,在项目 theme 文件夹下生成了主题名称的文件夹。\n1 2 3 4 5 6 7 8 9 PS C:\\Hugo\\sites\\Reid00.github.io.source\\themes\u0026gt; ls 目录: C:\\Hugo\\sites\\Reid00.github.io.source\\themes Mode LastWriteTime Length Name ---- ------------- ------ ---- d----- 2022/6/6 19:39 PaperMod 建议修改archetypes/defeault.md, 以后hugo new post/1.md 新建文档的时候就会使用改模板\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 --- title: \u0026#34;{{ replace .Name \u0026#34;-\u0026#34; \u0026#34; \u0026#34; | title }}\u0026#34; date: {{ .Date }} lastmod: {{ .Date }} author: [\u0026#34;Reid\u0026#34;] categories: - tags: - series: - description: \u0026#34;\u0026#34; weight: # 输入1可以顶置文章,用来给文章展示排序,不填就默认按时间排序 slug: \u0026#34;\u0026#34; draft: false # 是否为草稿 comments: true showToc: true # 显示目录 TocOpen: true # 自动展开目录 hidemeta: false # 是否隐藏文章的元信息,如发布日期、作者等 disableShare: true # 底部不显示分享栏 showbreadcrumbs: true #顶部显示当前路径 isCJKLanguage: true cover: image: \u0026#34;\u0026#34; caption: \u0026#34;\u0026#34; alt: \u0026#34;\u0026#34; relative: false --- 修改项目配置config.yaml 我的配置如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 baseURL: \u0026#34;https://reid00.github.io/\u0026#34; # 绑定的域名 languageCode: zh-cn # en-us title: \u0026#34;Reid\u0026#39;s Blog\u0026#34; theme: PaperMod # 主题名字,和themes文件夹下的一致 enableInlineShortcodes: true enableRobotsTXT: true # 允许爬虫抓取到搜索引擎,建议 true buildDrafts: false buildFuture: false buildExpired: false enableEmoji: true # 允许使用 Emoji 表情,建议 true pygmentsUseClasses: true googleAnalytics: UA-123-45 # 谷歌统计 Copyright: hasCJKLanguage: true # 自动检测是否包含 中文日文韩文 如果文章中使用了很多中文引号的话可以开启 paginate: 10 # 首页每页显示的文章数 minify: disableXML: true # minifyOutput: true defaultContentLanguage: en # 最顶部首先展示的语言页面 defaultContentLanguageInSubdir: true languages: en: languageName: \u0026#34;English\u0026#34; weight: 1 taxonomies: category: categories tag: tags series: series menu: main: - name: Archive url: archives weight: 5 - name: Search url: search/ weight: 7 - name: Categorys url: categories/ weight: 10 - name: Tags url: tags/ weight: 10 outputs: home: - HTML - RSS - JSON params: env: production # to enable google analytics, opengraph, twitter-cards and schema. description: \u0026#34;Reid\u0026#39;s Personal Notes -- https://github.com/Reid00\u0026#34; author: Reid # author: [\u0026#34;Me\u0026#34;, \u0026#34;You\u0026#34;] # multiple authors defaultTheme: auto # disableThemeToggle: true DateFormat: \u0026#34;2006-01-02\u0026#34; ShowShareButtons: false ShowReadingTime: true # disableSpecial1stPost: true displayFullLangName: true ShowPostNavLinks: true ShowBreadCrumbs: true ShowCodeCopyButtons: true ShowRssButtonInSectionTermList: true ShowLastMod: true # 显示文章更新时间 ShowToc: true # 显示目录 TocOpen: true # 自动展开目录 comments: true images: [\u0026#34;https://i.loli.net/2021/09/26/3OMGXylm8HUYJ6p.png\u0026#34;] # profileMode: # enabled: false # title: PaperMod # imageUrl: \u0026#34;#\u0026#34; # imageTitle: my image # # imageWidth: 120 # # imageHeight: 120 # buttons: # - name: Archives # url: archives # - name: Tags # url: tags homeInfoParams: Title: \u0026#34;Hi there \\U0001F44B\u0026#34; Content: \u0026gt; Welcome to My Blog. - **Blog** 是我个人的一些笔记 - 包含Go, Python, 机器学习, KV 存储引擎的一些相关笔记, 方便以后复习 - [GitHub主页](https://github.com/Reid00) socialIcons: - name: github url: \u0026#34;https://github.com/Reid00\u0026#34; - name: twitter url: \u0026#34;https://twitter.com\u0026#34; - name: RsS url: \u0026#34;index.xml\u0026#34; # editPost: # URL: \u0026#34;https://github.com/adityatelange/hugo-PaperMod/tree/exampleSite/content\u0026#34; # Text: \u0026#34;Suggest Changes\u0026#34; # edit text # appendFilePath: true # to append file path to Edit link # label: # text: \u0026#34;Home\u0026#34; # icon: icon.png # iconHeight: 35 # analytics: # google: # SiteVerificationTag: \u0026#34;XYZabc\u0026#34; # assets: # favicon: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # favicon16x16: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # favicon32x32: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # apple_touch_icon: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; # safari_pinned_tab: \u0026#34;\u0026lt;link / abs url\u0026gt;\u0026#34; cover: responsiveImages: false # 仅仅用在Page Bundle情况下,此处不讨论 hidden: false # hide everywhere but not in structured data hiddenInList: false # hide on list pages and home hiddenInSingle: false # hide on single page # fuseOpts: # isCaseSensitive: false # shouldSort: true # location: 0 # distance: 1000 # threshold: 0.4 # minMatchCharLength: 0 # keys: [\u0026#34;title\u0026#34;, \u0026#34;permalink\u0026#34;, \u0026#34;summary\u0026#34;, \u0026#34;content\u0026#34;] markup: goldmark: renderer: unsafe: true highlight: # anchorLineNos: true codeFences: true guessSyntax: true lineNos: true noClasses: false style: monokai privacy: vimeo: disabled: false simple: true twitter: disabled: false enableDNT: true simple: true instagram: disabled: false simple: true youtube: disabled: false privacyEnhanced: true services: instagram: disableInlineCSS: true twitter: disableInlineCSS: true 新建hugo 网页 这时候主题已经激活了,我们先往博客中添加一篇文章,hugo new post/first.md:\n1 2 hugo new post/first.md hugo new post/second.md 此处观看content 目录,会生成posts 文件夹\n1 2 3 4 5 6 7 8 9 10 11 12 13 PS C:\\Hugo\\sites\\Reid00.github.io.source\\content\u0026gt; tree /F 文件夹 PATH 列表 卷序列号为 E2D5-548C C:. │ archives.md │ search.md │ └─post 4th.md first.md second.md test.md third.md 另外要使用 Archive 和 Search,需要进行以下操作:\n在 content 下增加 archives.md 文件,具体位置如下:\n1 2 3 4 5 6 7 . ├── content/ │ ├── archives.md \u0026lt;--- Create archive.md here │ └── posts/ ├── static/ └── themes/ └── PaperMod/ archives.md 内容为:\n1 2 3 4 5 6 --- title: \u0026#34;Archive\u0026#34; layout: \u0026#34;archives\u0026#34; url: \u0026#34;/archives\u0026#34; summary: \u0026#34;archives\u0026#34; --- 同样在 content 新增一个 search.md,内容如下:\n1 2 3 4 5 6 7 --- title: \u0026#34;Search\u0026#34; # in any language you want layout: \u0026#34;search\u0026#34; # is necessary # url: \u0026#34;/archive\u0026#34; description: \u0026#34;Search part\u0026#34; summary: \u0026#34;search\u0026#34; --- 查看网站效果: 在项目目录c/hugo/sites/projectName 目录下输入hugo server -D\n看到一下输出, 表示可以访问 http://localhost:1313/ 查看效果,如果有其他不满意的地方,可以自行查找其他资料修改配置。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 Start building sites … hugo v0.100.0-27b077544d8efeb85867cb4cfb941747d104f765+extended windows/amd64 BuildDate=2022-05-31T08:37:12Z VendorInfo=gohugoio | EN -------------------+----- Pages | 20 Paginator pages | 0 Non-page files | 0 Static files | 0 Processed images | 0 Aliases | 2 Sitemaps | 1 Cleaned | 0 Built in 98 ms Watching for changes in C:\\Hugo\\sites\\Reid00.github.io.source\\{archetypes,content,data,layouts,static,themes} Watching for config changes in C:\\Hugo\\sites\\Reid00.github.io.source\\config.yaml Environment: \u0026#34;development\u0026#34; Serving pages from memory Running in Fast Render Mode. For full rebuilds on change: hugo server --disableFastRender Web Server is available at http://localhost:1313/ (bind address 127.0.0.1) Press Ctrl+C to stop 效果如下:\n部署到GitHub pages 两种方式:\nhugo 命令后生成public 文件夹,将public 单独push 到.github.io 仓库 是将整个项目部署到.github.io.source 仓库,通过github action 自动部署 SSH 这里需要重新生成一对密钥,使用之前用来配置过 Github 的密钥不能在这里使用啦,会报错提示已经被使用过啦。\n1 2 3 4 5 6 7 8 9 10 ssh-keygen -t rsa - -C \u0026#34;$(git config user.email)\u0026#34; # 注意:这次不要直接回车,以免覆盖之前生成的 # 确认秘钥的保存路径(如果不需要改路径则直接回车);如果已经有秘钥文件,则需要换一个路径,避免覆盖掉,如我更改之后的路径为 /home/kearney/.ssh_action/id_rsa; # 创建密码(如果不需要密码则直接回车); # 确认密码(如果不需要密码则直接回车),生成结束; # 查看公钥,路径需要改为你上面的设置 cat ~/.ssh_action/id_rsa.pub # 查看私钥 cat ~/.ssh_action/id_rsa Page 仓库 Reid00/ Reid00.github.io 中,点击 Setting - Deploy keys - Add deploy key,名称随意,粘贴进去刚生成的公钥,务必勾选 Allow write access 。点击保存。\n源码仓库 Reid00/ Reid00.github.io.source 。点击 Setting - Secrets - New repo secrets ,名称务必设置为 ACTIONS_DEPLOY_KEY, 添加刚刚生成的私钥(id_rsa),变量名称是要在 action 的配置文件中使用的,因此要保持统一,可修改为别的名称,但要相同即可。\n将项目hello-hugo 推送到 Reid00.github.io.source 仓库 讲到这里突然发现还没有讲,把项目推送到github,方式如下:\n此处本质就是用git 创建项目,推送到Github 如果出现错误,请自行搜索排查。\n1 2 3 4 5 6 git init git remote add origin git@github.com:Reid00/Reid00.github.io.source.git git add . git commit -m \u0026#39;init\u0026#39; git push -f --set-upstream origin master 配置 Github Action 在源码跟目录下新建 /.github/workflows/github-actions-demo.yml,内容如下,需要更改的地方为 Page 仓库、分支。\nyml push 上去之后,再更新下文章,直接 push 源码,后台就会自动生成并发布到 Page 仓库。\n测试良好,但也发现了一些问题,本人对 Action 了解不够深,猜测是 yml 没抄好。一个问题是源码仓库中 public 指向的网页仓库的 commit 标志未更新,但实际上对于的网页已经更新到 Page 中了,不过这不影响网页发布,暂不考虑解决这个问题。\n其次是我的 submodule 主题中有个在 gitee 中,需要移回到 github 并设置,还有 public 也是,那么干脆就将这些 submodule 取消掉的了,直接并入源码仓库,也不要考虑啥 commit 指针标记了。\n遇到问题 网站css 错乱\n参考此处 Fixing the CSS Integrity Digest Error in Hugo\nwindows git add 的时候LF 会用CRLF 替换,需要对git 进行设置。\n参考文章第二种方法解决\n单独 将public push 到github pages 仓库可以用,但是Github Action 推送后,Github pages 主页变成了index.xml 不是index.html,不可用?\n原因:public 里面又github pages 的.git 文件夹,会导致workflow那边submodules 出错。\n删除.git 文件夹 在与config.yaml 同级目录下添加.gitmodules 1 2 3 4 5 6 7 8 C:. │ .gitmodules │ config.yaml │ README.md [submodule \u0026#34;themes/PaperMod\u0026#34;] path = themes/PaperMod url = https://github.com/adityatelange/hugo-PaperMod.git ","permalink":"https://reid00.github.io/en/posts/other/hugo%E6%90%AD%E5%BB%BA%E5%8D%9A%E5%AE%A2%E5%B9%B6%E7%94%A8githubaction%E9%83%A8%E7%BD%B2/","summary":"介绍 这是我博客 Blog 的地址 和 Github Repositroy。 本博客是用Hugo 来生成静态网站。 Hugo GitHub 并通过 GitHub Action 来自动化部署到 GitHub Pages。 搭建步骤 创建代码","title":"Hugo搭建博客并用GitHubAction部署"},{"content":"0-1 背包 背包问题整体分为以下几种,情况比较复杂,但是对于面试的话,掌握01背包和完全背包,就差不多了,本篇主要介绍01背包和完全背包。 题干 有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。 比如:\n1 2 3 weight = [1, 3, 4] // 三个物品 value = [15, 20, 30] // 对应该的价值 bagweight = 4 // 背包的容量为4 问背包能背的物品最大价值是多少?\n解法一 二维数组 确定dp数组以及下标的含义 使用二维数组,即dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大dp[i][j]。\n确定状态转移方程 在遍历到i 的时候,有两种情况来确定价值\n不放物品i;即背包容量为j,里面不放物品i的最大价值,此时dp[i][j] 就等于dp[i - 1][j]。(其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。) 放物品i;dp[i - 1][j - weight[i]] 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值 由此可得: dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]) 数组初始化 首先从dp[i][j]的定义出发,如果背包容量j为0的话,即dp[i][0],无论是选取哪些物品,背包价值总和一定为0。 状态转移方程 dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 可以看出i 是由 i-1 推导出来,那么i为0的时候就一定要初始化。 dp[0][j],即:i为0,存放编号0的物品的时候,各个容量的背包所能存放的最大价值。 那么很明显当 j \u0026lt; weight[0]的时候,dp[0][j] 应该是 0,因为背包容量比编号0的物品重量还小。 当j \u0026gt;= weight[0]时,dp[0][j] 应该是value[0],因为背包容量放足够放编号0物品。\n1 2 3 4 5 6 7 8 9 10 // 第一列初始化为0 for (int j = 0 ; j \u0026lt; weight[0]; j++) { // 当然这一步,如果把dp数组预先初始化为0了,这一步就可以省略 dp[0][j] = 0; } // 第一行初始化为value[0] // 正序遍历 for (int j = weight[0]; j \u0026lt;= bagweight; j++) { dp[0][j] = value[0]; } 遍历顺序 由状态转移方程: dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); dp[i-1][j]和dp[i - 1][j - weight[i]] 都在dp[i][j]的左上角方向(包括正上方向),那么先遍历物品,再遍历背包的过程 和 先遍历背包,再遍历物品 都可以让 (i-1,j) 和 (i-1, j-weight[i]) 先与(i,j) 所以这个遍历顺序都可以。但是建议还是先遍历物品,在遍历背包,符合大部分情况下的解法。\n代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 // weightBag func weightBag(weight, value []int, bagWeight int) int { // dp[i][j] 二维,第一维i 表示选择的物品,从weight 中找 // 第二维 j 表示背包能放下的最大重量 // dp[i][j] 表示从下标为[0-i]的物品里任意取(包含i),放进容量为j的背包,价值总和最大是多少 // dp[i][j] = // 不放物品i:由dp[i - 1][j]推出,即背包容量为j,里面不放物品i的最大价值,此时dp[i][j]就是dp[i - 1][j]。 // (其实就是当物品i的重量大于背包j的重量时,物品i无法放进背包中,所以背包内的价值依然和前面相同。) // 放物品i:由dp[i - 1][j - weight[i]]推出,dp[i - 1][j - weight[i]] // 为背包容量为j - weight[i]的时候不放物品i的最大价值,那么dp[i - 1][j - weight[i]] + value[i] (物品i的价值),就是背包放物品i得到的最大价值 n := len(weight) dp := make([][]int, n) // 第一维物品的个数 for i := 0; i \u0026lt; n; i++ { dp[i] = make([]int, bagWeight+1) // 第二维,背包能放下的最大重量,包含bagWeight 本身 } // 初始化dp // 当j为零的时候,价值最大为0 for i := 0; i \u0026lt; n; i++ { dp[i][0] = 0 } // 当i为零的时候,价值为value[0] // i 开始是weight[0] weight 是递增的 for i := weight[0]; i \u0026lt; bagWeight+1; i++ { dp[0][i] = value[0] } // 为什么从1开始遍历,因为状态转移公式 for i := 1; i \u0026lt; n; i++ { // 遍历物品 for j := 0; j \u0026lt; bagWeight+1; j++ { // 遍历背包容量 if j \u0026lt; weight[i] { // 物品不能放到背包中 dp[i][j] = dp[i-1][j] } else { dp[i][j] = max(dp[i-1][j], dp[i][j-weight[i]]+value[i]) } } } return dp[n-1][bagWeight] } 解法二 一维数组 背包问题依赖分析 dp[i][j] = max(dp[i-1][j], dp[i-1][j-weight[i]] + value[i]) 有状态转移方程,看下图 从上图看我们在计算第i的数据的时候我们只依赖第i - 1行,我们在第i行从后往前遍历并不会破坏动态转移公式的要求。 我们已经很清楚了我们在计算dp数据的时候进行计算的时候只使用了两行数据,那么我们只需要申请两行的空间即可,不需要申请那么大的数组空间,计算的时候反复在两行数据当中交替计算既可。比如说我们已经计算好第一行的数据了(初始化),那么我们可以根据第一行得到的结果得到第二行,然后根据第二行,将计算的结结果重新存储到第一行,如此交替反复,像这种方法叫做滚动数组。\n我们还能继续压缩空间吗🤣?我们在进行空间问题的优化的时候只要不破坏动态转移公式,只需要我们进行的优化能够满足dp[i][j]的计算在它所依赖的数据之后计算即可。\n根据动态转移公式dp[i][j] = max(dp[i - 1][j - v[i]] + w[i], dp[i - 1][j])我们知道,第i行的第j个数据只依赖第i - 1行的前j个数据,跟第j个数据之后的数据没有关系。因此我们在使用一维数组的时候可以从后往前计算(且只能从后往前计算,如果从前往后计算会破坏动态转移公式,因为第j个数据跟他前面的数据有依赖关系,跟他后面的数据没有依赖关系)就能够满足我们的动态转移公式。 如果我们从在使用单行数组的时候从前往后计算,那么会使得一维数据前面部分数据的状态从i - 1行的状态变成第i行的状态,像下面这样 但是一维数组当中后部分的数据还是i - 1行的状态,当我们去更新他们的时候他们依赖前面部分数据的i - 1行的状态,但是他们已经更新到第i的状态了,因此破坏了动态规划的转移方程,但是如果我们从后往前进行遍历那么前面的状态始终是第i - 1行的状态,因此没有破坏动态规划的转移方程,因此我们需要从后往前遍历。 一维数组具体分析 确定dp数组的定义 dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是dp[i][j] 的值。 用一维数组之后dp[j]: 容量为j的背包,所背的物品价值可以最大为dp[j]\n状态转移方程 不放物品i: 一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j] 放物品i: dp[j - weight[i]] + value[i] 表示 容量为 j - 物品i重量 的背包 加上 物品i的价值。(也就是容量为j的背包,放入物品i了之后的价值即:dp[j]) 所以 dp[j] = max(dp[j], dp[j-weight[i]] + value[i]) 一维dp数组如何初始化 dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j],那么dp[0]就应该是0,因为背包容量为0所背的物品的最大价值就是0。 那么dp数组除了下标0的位置,初始为0,其他下标应该初始化多少呢? 看一下递归公式:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); dp数组在推导的时候一定是取价值最大的数,如果题目给的价值都是正整数那么非0下标都初始化为0就可以了。 这样才能让dp数组在递归公式的过程中取的最大的价值,而不是被初始值覆盖了。\n遍历顺序 结果如下 1 2 3 4 5 6 7 for i := 0; i \u0026lt; n; i++ { //遍历物品 // 需要注意的是,j 一定要从大到小,防止物品0 被重复计算 for j := bagWeight; j \u0026gt;= weight[i]; j-- { //遍历背包容量 dp[j] = max(dp[j], dp[j-weight[i]]+value[i]) } } 二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。\n倒序遍历是为了保证物品i只被放入一次! 但如果一旦正序遍历了,那么物品0就会被重复加入多次!\n举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15 如果正序遍历 dp[1] = dp[1 - weight[0]] + value[0] = 15 dp[2] = dp[2 - weight[0]] + value[0] = 30 此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。\n为什么倒序遍历,就可以保证物品只放入一次呢? 倒序就是先算dp[2] dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0) dp[1] = dp[1 - weight[0]] + value[0] = 15\n那么问题又来了,为什么二维dp数组遍历的时候不用倒序呢? 因为对于二维dp,dp[i][j]都是通过上一层即dp[i - 1][j]计算而来,本层的dp[i][j]并不会被覆盖!\n再来看看两个嵌套for循环的顺序,代码中是先遍历物品嵌套遍历背包容量,那可不可以先遍历背包容量嵌套遍历物品呢? 不可以! 因为一维dp的写法,背包容量一定是要倒序遍历(原因上面已经讲了),如果遍历背包容量放在上一层,那么每个dp[j]就只会放入一个物品,即:背包里只放入了一个物品。\n倒序遍历的原因是,本质上还是一个对二维数组的遍历,并且右下角的值依赖上一层左上角的值,因此需要保证左边的值仍然是上一层的,从右向左覆盖。\n代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // 背包优化 // dp + 滚动数组 func weightBag2(weight, value []int, bagWeight int) int { // dp[j] 表示 容量为j的背包,所背的物品价值可以最大为dp[j] // dp[j] = max(dp[j], dp[j-weight[i]] + val[i]) // 此时dp[j]有两个选择,一个是取自己dp[j] 相当于 二维dp数组中的dp[i-1][j], // 即不放物品i,一个是取dp[j - weight[i]] + value[i],即放物品i,指定是取最大的,毕竟是求最大价值, dp := make([]int, bagWeight+1) // 初始化,背包容量为0,的时候价值都为0 // dp 上面声明的时候,已经初始化 n := len(weight) for i := 0; i \u0026lt; n; i++ { //遍历物品 // 需要注意的是,j 一定要从大到小,防止物品0 被重复计算 for j := bagWeight; j \u0026gt;= weight[i]; j-- { //遍历背包容量 dp[j] = max(dp[j], dp[j-weight[i]]+value[i]) } } return dp[bagWeight] } ","permalink":"https://reid00.github.io/en/posts/algo/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E4%B9%8B01%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98/","summary":"0-1 背包 背包问题整体分为以下几种,情况比较复杂,但是对于面试的话,掌握01背包和完全背包,就差不多了,本篇主要介绍01背包和完全背包。 题干 有n","title":"动态规划之01背包问题"},{"content":"完全背包 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。\n完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。\n题干解析 有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。 比如:\n1 2 3 weight = [1, 3, 4] // 三个物品 value = [15, 20, 30] // 对应该的价值 bagweight = 4 // 背包的容量为4 问背包能背的物品最大价值是多少?\n01背包和完全背包唯一不同就是体现在遍历顺序上,我们直接分析。\n首先再回顾一下01背包的核心代码:\n1 2 3 4 5 6 7 for i := 0; i \u0026lt; n; i++ { //遍历物品 // 需要注意的是,j 一定要从大到小,防止物品0 被重复计算 for j := bagWeight; j \u0026gt;= weight[i]; j-- { //遍历背包容量 dp[j] = max(dp[j], dp[j-weight[i]]+value[i]) } } 我们知道01背包内嵌的循环是从大到小遍历,为了保证每个物品仅被添加一次。\n而完全背包的物品是可以添加多次的,所以要从小到大去遍历,即:\n1 2 3 4 5 6 7 for i :=0; i \u0026lt; n; i ++ {\t//遍历物品 // 完全背包,i 可以重复添加,要从小到大遍历 for j := weight[i]; j \u0026lt;= bagWeight; j ++ { dp[j] = max(dp[j], dp[j-weight[i]] + value[i]) } } 其实还有一个很重要的问题,为什么遍历物品在外层循环,遍历背包容量在内层循环? 难道就不能遍历背包容量在外层,遍历物品在内层? 01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。 在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!\n因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。\n在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的! 因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。\n应用 讲解了纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。\n但是仅仅是纯完全背包的遍历顺序是这样的,题目稍有变化,两个for循环的先后顺序就不一样了。\n如果求组合数就是外层for循环遍历物品,内层for遍历背包。 如果求排列数就是外层for遍历背包,内层for循环遍历物品。\n求组合数\n518.零钱兑换II\n求排列数\n377. 组合总和 Ⅳ\n70. 爬楼梯\n最小数\n那么两层for循环的先后顺序就无所谓了\n322. 零钱兑换 279. 完全平方数\n背包总结 问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); 对应题目如下:\n416.分割等和子集\n1049.最后一块石头的重量 II\n问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下:\n494.目标和\n518.零钱兑换II\n377. 组合总和 Ⅳ\n70. 爬楼梯\n问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); 对应题目如下:\n474.一和零\n问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); 对应题目如下:\n322. 零钱兑换\n279. 完全平方数\n","permalink":"https://reid00.github.io/en/posts/algo/%E5%8A%A8%E6%80%81%E8%A7%84%E5%88%92%E4%B9%8B%E5%AE%8C%E5%85%A8%E8%83%8C%E5%8C%85%E9%97%AE%E9%A2%98/","summary":"完全背包 有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就","title":"动态规划之完全背包问题"},{"content":"Rust LinkedList 定义 Leetcode: rust 如下:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // Definition for singly-linked list. #[derive(PartialEq, Eq, Clone, Debug)] pub struct ListNode { pub val: i32, pub next: Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt;, } impl ListNode { #[inline] fn new(val: i32) -\u0026gt; Self { ListNode { next: None, val } } } /// 单链表 #[derive(Debug)] struct LinkedList\u0026lt;T\u0026gt; { head: Option\u0026lt;Box\u0026lt;Node\u0026lt;T\u0026gt;\u0026gt;\u0026gt;, } Go 如下:\n1 2 3 4 type ListNode struct { Val int Next *ListNode } 问题 1: 为什么 Rust 有ListNode之后还有LinkedList 定义? 与其他语言不通,Rust 中有所有权概念,在 Option\u0026lt;Box\u0026gt;的实现中,next 节点不存在引用类型,暗含的意思就是:链表头是整个链表的拥有者,负责整个链表所占据内存的管理(包括最终销)。进一步说,Rust 中这样实现的链表和用 C++实现的链表是完全不同的:每个节点不再是独立存在的了,而是被先驱节点所管理,同时也管理着它的 next 字段后所有的后驱节点。 问题 2: 为什么 next 是 Option\u0026lt;Box\u0026lt;LisNode\u0026gt;\u0026gt; 类型,而不是 ListNode?\n节点的 next 指向下一个节点,可能为空,故类型为 Option; 避免编译器无法计算节点大小,用 Box 包裹 Node; next 实际上是用 Option 包裹的指向下个节点的 Box 指针,并且拥有下个节点的所有权; Box 的作用 在 Rust 中,Box\u0026lt;T\u0026gt; 是一个“箱子”类型,它在堆上存储数据。你可以把它看作是一个智能指针,用来封装和管理堆上的内存。\n在这个 Node 结构体中,Box 是一个指向堆上的 Node 实例的指针。Option\u0026lt;Box\u0026gt; 则意味着这个指针可能有值(即 Some(Box(Node))),也可能没有值(即 None)。\n以下是 Box\u0026lt;T\u0026gt; 在这个上下文中的一些主要用途:\n递归数据结构:在定义递归或者链接的数据结构时,比如链表、树等,Box\u0026lt;T\u0026gt; 是必要的。在你的例子中,Node 结构体是一个链表节点,它有一个 next 字段指向下一个 Node。这个 next 字段需要使用 Box,因为 Rust 需要知道 Node 结构体的确切大小,而 Box 是一个固定大小的指针。 转移所有权:当你需要将值的所有权转移到其他函数或变量时,可以使用 Box\u0026lt;T\u0026gt;。Box\u0026lt;T\u0026gt; 拥有它所指向的值的所有权,当 Box\u0026lt;T\u0026gt; 被销毁时,它所指向的值也会被销毁。 大对象和内存管理:如果你有一个很大的对象或者需要手动管理内存,Box\u0026lt;T\u0026gt; 是一个很好的工具。因为 Box\u0026lt;T\u0026gt; 在堆上分配内存,当你不再需要这个值时,Box\u0026lt;T\u0026gt; 会自动回收它所使用的内存。 以上就是 Box\u0026lt;T\u0026gt; 的一些主要用途。在你的例子中,Box\u0026lt;T\u0026gt; 主要用于定义递归的链表数据结构。\n不使用 Box 可行? 在定义递归数据结构时,如链表或树,Rust 语言要求必须使用 Box 或者其他形式的间接寻址(如 Rc、Arc 等)。这是由 Rust 的所有权和内存安全性规则决定的。\nRust 编译器需要在编译时就知道每种类型的确切大小。对于递归类型,例如链表节点 Node,如果不使用 Box 或其他形式的指针,Node 类型会有一个无限的大小。这是因为每个 Node 都包含另一个 Node,这形成了一个无限的嵌套。这会导致编译时错误,因为 Rust 无法确定类型的确切大小。\nBox(以及 Rc、Arc 等)是一个固定大小的指针类型,它在堆上存储数据。这意味着,对于 Box 类型,无论 Node 的实际大小如何,Box 类型总是有一个固定的大小。这使得在 Rust 中定义递归类型成为可能。\n所以,对于你的例子,如果你要在 Node 中定义一个指向另一个 Node 的字段,你必须使用 Box 或其他形式的指针。否则,你会得到一个编译错误。\n和 Rc\u0026lt;RefCell\u0026gt; 的区别 Box 和 Rc\u0026lt;RefCell\u0026lt;T\u0026gt;\u0026gt; 都是 Rust 中的智能指针类型,但是它们的用途和行为不同。\nBox\u0026lt;T\u0026gt; 提供了在堆上分配一个值的能力,并拥有这个值的所有权。当 Box 被丢弃(drop)时,它包含的值也会被丢弃。Box 只有一个所有者。 Rc 是一个引用计数类型,可以让一个值有多个所有者。每当你克隆一个 Rc 指针,引用计数就会增加。当一个 Rc 指针被丢弃时,引用计数就会减少。只有当引用计数为 0 时,值才会被丢弃。 RefCell 提供了内部可变性。在 Rust 中,我们不能同时拥有一个值的可变引用和不可变引用。然而,有时我们可能需要在运行时改变一个值,即使我们拥有的是一个不可变引用。这就是 RefCell 发挥作用的地方。RefCell 允许我们在运行时借用和改变值,但是如果我们违反了借用规则(例如,同时拥有可变引用和不可变引用),RefCell 就会导致程序 panic。 当你看到 Option\u0026lt;Rc\u0026lt;RefCell\u0026raquo; 时,这是因为在一些情况下(例如,在树或图结构中),我们需要一个节点有多个所有者,或者我们需要修改一个被多个地方引用的值。在这种情况下,我们就需要使用 Rc 和 RefCell。\n然而,请注意,Rc\u0026lt;RefCell\u0026gt; 在运行时执行借用检查,可能会引发 panic,而且引用计数会增加运行时开销。在可以确定一个值只有一个所有者,并且不需要在运行时修改的情况下,使用 Box\u0026lt;T\u0026gt; 会更简单、更安全、更高效。\n反转链表实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 pub fn reverse_list(head: Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt;) -\u0026gt; Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt; { let mut prev = None; let mut cur = head; while let Some(mut node) = cur { let next = node.next; node.next = prev; cur = next; prev = Some(node); } prev } let mut list = ListNode::new(1); list.next = Some(Box::new(ListNode::new(2))); list.next.as_mut().unwrap().next = Some(Box::new(ListNode::new(3))); list.next.as_mut().unwrap().next.as_mut().unwrap().next = Some(Box::new(ListNode::new(4))); list.next .as_mut() .unwrap() .next .as_mut() .unwrap() .next .as_mut() .unwrap() .next = Some(Box::new(ListNode::new(5))); let reversed_list = reverse_linked_list(Option::from(Box::from(list))); assert_eq!(reversed_list.as_ref().unwrap().value, 5); assert_eq!( reversed_list.as_ref().unwrap().next.as_ref().unwrap().value, 4 ); as_mut().unwrap(),.as_ref().unwrap() 的作用 在 Rust 中,.as_mut() 和 .as_ref() 方法通常被用来转换 Option 或 Result 类型。\n.as_mut() 是 Option 和 Result 类型的一个方法,用来将 Option 或 Result\u0026lt;T, E\u0026gt; 转换成 Option\u0026lt;\u0026amp;mut T\u0026gt; 或 Result\u0026lt;\u0026amp;mut T, \u0026amp;mut E\u0026gt;。也就是说,它返回一个包含可变引用的新的 Option 或 Result。\n.unwrap() 是 Option 和 Result 类型的一个方法,用来获取它们包含的值。如果 Option 是 Some(v),它返回 v,如果 Option 是 None,它会 panic(意味着程序会立即停止,并给出一个错误信息)。对于 Result,如果它是 Ok(v),它返回 v,如果它是 Err(e),它也会 panic。\n.as_ref() 是 Option 和 Result 类型的一个方法,用来将 Option 或 Result\u0026lt;T, E\u0026gt; 转换成 Option\u0026lt;\u0026amp;T\u0026gt; 或 Result\u0026lt;\u0026amp;T, \u0026amp;E\u0026gt;。也就是说,它返回一个包含不可变引用的新的 Option 或 Result。\nas_mut().unwrap() 和 as_ref().unwrap() 都被用来获取 Option\u0026lt;Box\u0026gt; 中的 Node。这样,你就可以修改或访问 Node 中的 value 和 next。因为 Option\u0026lt;Box\u0026gt; 中可能没有 Node(也就是它可能是 None),所以需要使用 unwrap() 来获取 Node。但是请注意,如果 Option\u0026lt;Box\u0026gt; 是 None,unwrap() 会引发 panic。为了避免这种情况,你需要在调用 unwrap() 之前确保 Option 不是 None。\nunwrap() 不能调用多次,其实是所有权问题 你遇到的这个错误是由于在 Rust 中的所有权规则所引起的。当你对 Option 类型的值调用 unwrap() 方法时,它会获取 Option 的所有权,这意味着原来的 Option 值将不能再使用。\n这就是为什么你在 while 循环中多次使用 head.unwrap().next 时会遇到问题。在第一次调用 unwrap() 之后,head 就被移动(move)了,也就是说它的所有权被转移走了,所以你不能再次对 head 调用 unwrap()。\nOption::from 和 Some 是一样的? 是的,你完全正确。在 Rust 中,Some(value) 和 Option::from(value) 是等价的。它们都会创建一个包含 value 的 Option 枚举值。\nSome(value) 是更常见的使用方式,它直接创建了一个 Option::Some 枚举变量。例如:3\n1 let some_number = Some(5); Option::from(value) 是 From trait 的一部分,这个 trait 用于定义如何将一种类型转换成另一种类型。Option::from 的定义如下:\n1 2 3 4 5 impl\u0026lt;T\u0026gt; From\u0026lt;T\u0026gt; for Option\u0026lt;T\u0026gt; { fn from(value: T) -\u0026gt; Option\u0026lt;T\u0026gt; { Some(value) } } 因此,Option::from(value) 实际上就是返回 Some(value)。例如:\n1 let some_number = Option::from(5); 在大多数情况下,直接使用 Some(value) 会更简单和直观。\ntake 函数 1 2 3 4 let mut x = Some(2); let y = x.take(); assert_eq!(x, None); assert_eq!(y, Some(2)); 这段代码中的 x.take() 方法是 Option 类型特有的方法。take() 方法将 Option 置为 None 并返回原来的值。\n当你调用 x.take() 时,它会把 x 里面的值取出并返回,此时 x 的值就变为了 None,因为你已经取走了它的值。所以,在 x.take() 后,x 的值就是 None 了。\n而 y 的值是 x.take() 的返回值,即 x 原来的值 Some(2),所以 y 的值就是 Some(2)。\n简单地说,x.take() 做了两件事:\n将 x 设为 None。 返回 x 原来的值。 因此,x 在调用 take 之后的值是 None,而 y 的值就是 x 在调用 take 之前的值,即 Some(2)。\n这就是为什么在 assert_eq!(x, None) 和 assert_eq!(y, Some(2)) 的断言语句都能通过,因为它们分别测试的是 x 和 y 的值,这些值都符合预期。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/rust-leetcode%E9%93%BE%E8%A1%A8%E5%AE%9E%E7%8E%B0/","summary":"Rust LinkedList 定义 Leetcode: rust 如下: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // Definition for singly-linked list. #[derive(PartialEq, Eq, Clone, Debug)] pub struct ListNode { pub val: i32, pub next: Option\u0026lt;Box\u0026lt;ListNode\u0026gt;\u0026gt;, } impl ListNode { #[inline] fn new(val: i32) -\u0026gt; Self { ListNode { next: None, val } } } /// 单链表","title":"Rust Leetcode链表实现"},{"content":"一道面试题 1 2 3 4 5 6 7 8 9 int test(int n) { int fact = 1, num = n+1; for(int i =1; i\u0026lt;num; i++) { fact *= 1; } return fact; } 面试官:这段求阶乘的代码怎么样? 答:挺简洁的,简单易懂。不过如果参数 n 值比较大的话,会导致 fact 溢出,结果是错的。 面试官:嗯,是的。不过,咱们先不考虑溢出的问题,你觉得这段代码的性能怎么样? 答:时间复杂度是 O(n),而且代码比较精炼,性能应该还挺不错的吧?(心虚 ing\u0026hellip;) 面试官:你能想办法把它优化一下,让性能更好吗? 思考 ing\u0026hellip; 答:在多 CPU 系统上,如果 n 的值比较大的话,可以考虑用多线程来实现。 面试官:嗯,这是一个思路。如果是单 CPU 呢? 再次思考 ing\u0026hellip; 答:用 GCC 编译的话,可以加上优化选项-O3,应该能提高性能。 面试官:嗯,还有吗? 答:没了。 面试官:好了,感谢来参加面试,回去等通知吧! 思考一下,如果是你的话,会怎么回答呢? 下面,来深入讲解一下,隐藏在这道题背后的深层次知识! 本文较长,且涉及到 CPU 内部很底层的知识,请耐心看完,一定会有收获!\n测试程序 测试程序 test.c 非常简单,计算 1000000000 的阶乘:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i++) { fact *= 1; } return fact; } int main() { return cacl(1000000000); } 为方便分析,函数 calc()前面加上attribute((noinline)),禁止 GCC 把 calc 内联在 main()中。此外,calc()中,fact 类型是 int,main()中调用 calc(1000000000),会导致 fact 溢出,但不影响测试,不用管它。\n然后,把程序稍微改一下,命名为 test_2.c:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact0=1, fact1=1, fact2=1, fact3=1; for(int i =1; i\u0026lt;n; i += 4) { fact0 *= i; fact1 *= i+1; fact2 *= i+2; fact3 *= i+3; } return fact0 * fact1 * fact2 * fact3; } int main() { return cacl(1000000000); } 注意:这里为方便讲解,假设 n 总是 4 的倍数。如果要处理 n 不是 4 的倍数的情况,只需要在主循环体外,增加一个小的循环单独处理最后的 n%4 个数,也就是最多 3 个数即可,对整体性能影响几乎为 0.\n运行耗时从原来的 3.29 秒降到了 1 秒,性能提升了 200%!你以为这就完了?\n这还不是最终的结果,因为我们还有一个优化技巧还没加上,最终优化后的结果是 0.3 秒!文末会讲!先不着急,咱们一个一个来讲!\n优化: 关于循环展开:你真的理解吗? 看到这里,有人会说,不就是循环展开嘛,很简单的,没什么好研究的,而且加了优化选项之后,编译器会自动进行循环展开的,没必要手动去展开,也就没有研究的价值了!\n真的是这样吗?先尝试回答下面几个问题:\n循环展开为什么能提高程序性能,其背后的深层次原理是什么? 循环随便怎么展开都一定可以提高性能吗? 用了优化选项,编译器一定会帮我们自动进行循环展开优化吗? 第一个问题后面会详细讲解,我们先用实例回答下第 2 个和第 3 个问题。\n先看第 2 个问题。\n循环随便展开都能提高性能吗? 答案是否定的。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i += 4) { fact *= i; fact *= i+1; fact *= i+2; fact *= i+3; } return fact; } int main() { return cacl(1000000000); } 仍然是循环展开,只不过把循环展开的方式稍微改了一下。再编译一下,用 time 命令测量下运行耗时: 和 test.c 相比运行耗时只减少了 0.2 秒!为什么同样是循环展开,test_2.c 只需要 1.6 秒,而 test_3.c 却要 3 秒,为什么性能差异这么大呢?别着急,后面细讲。\n再看第三个问题,加了优化选项,编译器一定会帮我们自动进行循环展开优化吗?一试便知\n-O3,编译器一定会循环展开吗? 重新编译下 test.c, test_2.c, 和 test_3.c,只不过,这次我们加上-O3 优化选项,然后分别用 time 命令再测量下运行时间。\n先是 test.c: 加了-O3 优化后,程序耗时从原来的 3.29 秒降到了 1.07 秒,性能提升确实非常明显!是否好奇,-O3 选项对 test.c 做了什么样的优化,能够把程序耗时降到三分之一?这个后面再讲。\n现在,我们先试下 test_2.c: 同样,加了-O3 后,程序耗时从原来的 1 秒降到了 0.368 秒!此外,在同样加了-O3 的情况下,使用了循环展开的 test_2.c,程序耗时仍然是 test.c 的三分之一!可见,编译器确实优化了一些东西,但是,无论是否加-O3 优化选项,进行手动循环展开的 test.c 仍然是性能最好的!\n最后,再试下 test_3.c: 看到了吧?同样加了-O3 优化选项的前提下,性能仍然与 test_2.c 相差甚远!\n小结一下我们现在得到的几组测试结果: 在解释这些性能差异的原因之前,必须要先补充一些 CPU 相关的基础知识,否则无法真正理解这背后的原因!所以,请务必认真看完!\n这会涉及到 CPU 内部实现细节的知识,相对比较底层,而且对绝大多数程序员是透明的,因此很多人甚至都没听说过这些概念。不过,也不用担心,跟之前一样,我会尽量用通俗易懂的语言来解释这些概念。\n背景知识:CPU 内部架构 指令流水线(pipeline) 所谓流水线,是把指令的执行过程分成多个阶段,每个阶段使用 CPU 内部不同的硬件资源来完成。以经典的 5 级流水线为例,一条指令的执行被分为 5 个阶段:\n取指(IF):从内存中取出一条指令。 译码(ID):对指令进行解码,确定该指令要执行的操作。 执行(EX):执行该指令要执行的操作。 访存(MEM):进行内存访问操作。 写回(WB):把执行的结果写回寄存器或内存。 在时钟信号的驱动下,CPU 依次来执行这些步骤,这就构成了指令流水线(pipeline)。如下图所示: 在CPU内部,执行每个阶段使用不同的硬件资源,从而可以让多条指令的执行时间相互重叠。当第一条指令完成取指,进入译码阶段时,第二条指令就可以进入取指阶段了。以此类推,在一个 5 级流水线中,理想情况下,可以有 5 条不同的指令的不同阶段在同时执行,因此每个时钟周期都会有一条指令完成执行,从而大大提高了 CPU 执行指令的吞吐率,从而提高 CPU 整体性能。这就叫做 ILP - 指令级并行(Instruction Level Parallelism)。如下图所示: 通过把指令执行分为多个阶段,CPU 每个时钟周期只处理一个阶段的工作,这样大大简化了 CPU 内部负责每个阶段的功能单元,每个时钟周期要做的事情少了,提高时钟频率也变得简单了。\n前面说过,有了流水线技术,理想情况下,每个时钟周期,CPU 可以完成一条指令的执行。那有没有什么方法,可以让 CPU 在每个时钟周期,完成多条指令的执行呢,这岂不是会大大提高 CPU 整体性能吗?\n当然有!这就是 Superscalar 技术!(除此之外还有 VLIW,不是本文重点,不再展开讨论。)\n超标量(Superscalar) Superscalar,通过在 CPU 内部实现多条指令流水线,可以真正实现多条命令并行执行,也被称为多发射数据通路技术。以双发射流水线为例,每个时钟周期,CPU 可以同时读取两条指令,然后同时对这两条指令进行译码,同时执行,然后同时写回。如下图所示: 流水线冲突 大家可能注意到了,前面多次强调过,“在理想状态下”,为什么呢? 现实中程序的指令序列之间往往存在各种各样的依赖和相关性,而 CPU 为了解决这种指令间的依赖和相关性,有时候不得不“停顿”下来,直到这些依赖得到解决,这就导致 CPU 指令流水线无法总是保持“全速运行”。\n这种现象被称之为 Pipeline Hazard,很多资料翻译为“流水线冒险”,我觉得“流水线冲突”更为贴切易懂。\n归结起来,有三种情况:\n数据冲突(Data Hazard) 控制冲突(Control Hazard) 结构冲突(Structure Hazard) 下面分别举例解释这三种类型的冲突。\n数据冲突 所谓数据冲突,简单讲,就是两条在流水线中并行执行的指令,第二条指令需要用到第一条指令的执行结果,因此第二条指令的执行不得不暂停,一直到可以获取到第一条指令的执行结果为止。\n比如,用伪代码举例:\n1 2 x = 1; y = x; 要对 y 进行赋值,必须要先得到 x 的值,因此这两条语句无法完全并行执行。 这只是其中的一种典型情况,其他情况不再赘述。\n控制冲突 所谓控制冲突,简单讲,就是在 CPU 在执行分支跳转时,无法预知下一条要执行的指令。 比如:\n1 2 3 4 5 if(a \u0026gt; 100) { x = 1; } else { y = 2; } 在 CPU 计算出 a \u0026gt; 100 这个条件是否成立之前,无法确定接下来是应该执行 x = 1 还是执行 y = 2。\n为了解决这个问题,CPU 可以简单的让流水线停顿一直到确定下一条要执行的指令,也可以采取如分支预测(branch prediction)和推测执行(speculation execution)等手段,但是,预测失败的话,流水线往往会受到比较严重的性能惩罚。之后会有专门的文章分析这个问题,感兴趣的话,可以右上角关注一下!\n结构冲突 结构冲突,简单来说,就是多条指令同时竞争同一个硬件资源,由于硬件资源短缺,无法同时满足所有指令的执行请求。如两条并行执行的命令需要同时访问内存,而内存地址译码单元可能只有一个,这就产生了结构冲突。\n有了上面这些基础知识做铺垫,接下来就可以开始真正分析这个问题了。\ntest.c 为什么性能最差? 对于计算阶乘,test.c 可能是最简单直观、可读性最强的算法。不过可惜的是,它也是性能最差的。\n我们再看一下 test.c 的源码:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i++) { fact *= 1; } return fact; } int main() { return cacl(1000000000); } 说它性能最差,主要有三点原因:\n热点路径无用指令太多。 热点路径跳转指令太多。 热点路径内存访问太多。 注意,这里说的无用指令,是指对计算阶乘本身不产生直接影响的指令,但是它们对整个算法的正确性仍然是必不可少的!\n为例方便理解,我们来分别看下 test.c 不加优化选项和加了-O3 编译之后的汇编代码。\ntest.c 不加优化选项时 绿色方框标注出来的 8 ~ 14 行是 for 循环,也就是主循环体。其中,蓝色方框标注出来的 8 ~ 11 行是真正计算阶乘的代码,12 ~ 14 行是循环控制代码,对计算阶乘来说,则是无用代码。\n不难看出:\n热点路径上,也就是循环体内无用指令占比是 3/7 = 42%!即便在不考虑其他因素的情况下,CPU 单单用来执行这些无用的指令,也是一笔不小的开销! 整个阶乘计算过程中,循环体内需要执行 1000000000 次条件跳转指令!条件跳转又会造成控制冲突,使得流水线无法全速运行,从而造成巨大的性能损失。 整个函数一共有 10 个内存访问操作,而循环体内就有 6 个内存操作!尽管很多时候可以通过 Cache 来缓解,但相对于 CPU 计算速度来说,内存操作仍然是非常慢的,而且容易造成流水线冲突! 那加了-O3 优化选项之后,编译器能不能帮我们解决这些问题呢?\ntest.c 加了-O3 优化选项后 首先,不得不感叹,现在的编译器的优化真的是太强大了!直接把整个 for 循环优化成了 4 条指令!\n不难看出,对于 test.c 而言,加了-O3 之后,GCC 做的最大的优化是把所有变量存放在寄存器中,消除了所有的内存访问操作!\n可以回过头去看下优化之前的汇编代码,整个函数一共有 10 个内存访问操作,其中 6 个是在循环体内,而加了-O3 之后,整个函数没有任何内存访问操作!难怪-O3 编译后性能提升那么多!由此可见,内存访问相对寄存器访问的开销实在是太大了!当然,即便不使用-O3,也有优化内存操作的办法,这个后面再讲。\n但是,也不难看出,对于其他两个问题,GCC 并没有帮我们解决:现在无用指令占比是 2/4 = 50%! 整个阶乘计算过程,仍然需要执行 1000000000 次条件跳转指令,仍然无法充分发挥流水线和 superscalar 的指令并行执行能力!\n知道了 test.c 性能差的原因之后,现在我们来看看,通过手动循环展开,test_2.c 又帮我们解决什么问题呢?\ntest_2.c 性能提升原因 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact0=1, fact1=1, fact2=1, fact3=1; for(int i =1; i\u0026lt;n; i += 4) { fact0 *= i; fact1 *= i+1; fact2 *= i+2; fact3 *= i+3; } return fact0 * fact1 * fact2 * fact3; } int main() { return cacl(1000000000); } 通过对循环进行 4 次展开,之前每次循环执行 1 次乘法,现在每次循环执行 4 次,这就带来了三点很重要的变化:\n循环次数减少 75%,无用指令减少了,相应的 CPU 执行这些指令本身的开销也少了。 计算过程中,热点路径的条件跳转指令少了 75%,这样就减少了由于控制相关引起的流水线冲突,提升了流水线执行的效率。 提升了指令的并行度,使得 CPU superscalar 的技术得到更充分的发挥,提高了每个时钟周期并行执行指令的条数。 这也就是为什么在使用同样的编译选项时,test_2.c 比 test.c 的性能提升了 200%!不过,热点路径上内存访问操作太多的问题仍然存在。其实,这个其实很好解决,我会在下文给出解决方法。我们先把注意力放在这里所说的三点变化上。\n对于第 1 点和第 2 点,有了前面介绍的指令流水线的背景知识,即便从 C 语言的角度也很好理解,不需要过多解释。\n至于第 3 点,为了便于理解,我们和 test_3.c 对比来看。\ntest_3.c 性能差的原因 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { int fact = 1; for(int i =1; i\u0026lt;n; i += 4) { fact *= i; fact *= i+1; fact *= i+2; fact *= i+3; } return fact; } int main() { return cacl(1000000000); } 很明显,后面一条指令执行前,必须要先知道前面一条语句计算的结果。还记得前面讲过的造成流水线冲突的三个原因吗?这就是典型的数据依赖,会造成流水线冲突!\n可见,虽然 test_3.c 也通过循环展开,减少了无用指令,也减少了热点路径上分支跳转引起的流水线控制冲突,但它同时引入了数据依赖,进而导致流水线冲突,仍然无法发挥流水线和superscalar的指令级并行执行的能力!\n这就是为什么,用同样的选项编译时,test_3.c 虽然比未经过循环展开的 test.c 性能稍微提升了一点点,但相比同样循环展开且没有引入数据相关性的 test_2.c 来说,性能仍然是非常差的!\n讲到这里,本想演示下用 perf 测量出来的性能指标的,但由于篇幅过长,就不再展开讨论了,以后会专门更新文章介绍 perf 相关工具的使用!\n最后,来看一下前面遗留的那个问题:不加优化选项的情况下,怎么解决热点路径内存访问过多的问题。\n杀手锏:优化热点路径内存访问 其实很简单,只需要把 test_2.c 中定义局部变量的时候加上register关键字就可以了:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 __attribute__((noinline)) int cacl(int n) { register int fact0=1, fact1=1, fact2=1, fact3=1; for(register int i =1; i\u0026lt;n; i += 4) { fact0 *= i; fact1 *= i+1; fact2 *= i+2; fact3 *= i+3; } return fact0 * fact1 * fact2 * fact3; } int main() { return cacl(1000000000); } C语言中,register关键字的作用是建议编译器,尽可能地把变量存放在寄存器中,以加快其访问速度。\n我们现在看下,加了 register 关键字后,test_2.c 的性能如何呢?\n加了 register 后,几乎达到了和加-O3 优化选项一样的性能!\n当然,register 的使用还有很多限制,而且它只是给编译器的一种建议,不是强制要求,编译器只能尽量满足,当变量太多,寄存器不够用的时候,还是不得不把变量放到栈中,这和-O3 的行为是一样的。\nregister 不是本文重点,限于篇幅,不再赘述。\n小结 循环展开是一种非常重要的优化方法,也是编译器后端中常用的一种优化方式,它可以通过减少热点路径上的“无用指令”以及分支指令的个数,来更好地发挥 CPU 指令流水线的指令并行执行能力,从而提高程序整体性能。\n很多时候,我们可以借助编译器来帮我们实现这种优化,但编译器也有失效的时候,比如文中这个例子。这时,我们就不得不手动来进行循环展开来优化程序性能。循环展开时,必须尽量减少语句间的相互依赖。\n此外,循环展开的次数并没有一个固定的公式,需要根据具体代码和CPU来决定,通常需要多次尝试来找到一个最优值。\n不过,手动循环展开往往是以牺牲代码可读性为代价的,因此使用时也做好取舍。此外,循环展开还会在一定程度上增加程序代码段的大小,还可能会影响到程序局部性,对 cache 产生影响,因此使用时候,要仔细权衡。\n","permalink":"https://reid00.github.io/en/posts/os_network/for%E5%BE%AA%E7%8E%AF%E8%80%97%E6%97%B6%E4%BB%8E3.2%E7%A7%92%E9%99%8D%E5%88%B00.3%E7%A7%92/","summary":"一道面试题 1 2 3 4 5 6 7 8 9 int test(int n) { int fact = 1, num = n+1; for(int i =1; i\u0026lt;num; i++) { fact *= 1; } return fact; } 面试官:这段求阶乘的代码怎么样? 答:挺简洁的,简单易懂。不过如果","title":"For循环耗时从3.2秒降到0.3秒"},{"content":"前言 首先,为什么要总结B树、B+树的知识呢?最近在学习数据库索引调优相关知识,数据库系统普遍采用B-/+Tree作为索引结构(例如mysql的InnoDB引擎使用的B+树),理解不透彻B树,则无法理解数据库的索引机制;接下来将用最简洁直白的内容来了解B树、B+树的数据结构\n另外,B-树,即为B树。因为B树的原英文名称为B-tree,而国内很多人喜欢把B-tree译作B-树,其实,这是个非常不好的直译,很容易让人产生误解。如人们可能会以为B-树是一种树,而B树又是一种树。而事实上是,B-tree就是指的B树,目前理解B的意思为平衡\nB树的出现是为了弥合不同的存储级别之间的访问速度上的巨大差异,实现高效的 I/O。平衡二叉树的查找效率是非常高的,并可以通过降低树的深度来提高查找的效率。但是当数据量非常大,树的存储的元素数量是有限的,这样会导致二叉查找树结构由于树的深度过大而造成磁盘I/O读写过于频繁,进而导致查询效率低下。另外数据量过大会导致内存空间不够容纳平衡二叉树所有结点的情况。B树是解决这个问题的很好的结构\n概念 首先,B树不要和二叉树混淆,在计算机科学中,B树是一种自平衡树数据结构,它维护有序数据并允许以对数时间进行搜索,顺序访问,插入和删除。B树是二叉搜索树的一般化,因为节点可以有两个以上的子节点。[1]与其他自平衡二进制搜索树不同,B树非常适合读取和写入相对较大的数据块(如光盘)的存储系统。它通常用于数据库和文件系统。\n定义 B树是一种平衡的多叉树,通常我们说m阶的B树,它必须满足如下条件:\n每个节点最多只有m个子节点。 每个非叶子节点(除了根)具有至少⌈ m/2⌉子节点。 如果根不是叶节点,则根至少有两个子节点。 具有k个子节点的非叶节点包含k -1个键。 所有叶子都出现在同一水平,没有任何信息(高度一致)。 什么是B树的阶 ? B树中一个节点的子节点数目的最大值,用m表示,假如最大值为4,则为4阶,如图 所有节点中,节点【13,16,19】拥有的子节点数目最多,四个子节点(灰色节点),所以可以定义上面的图片为4阶B树,现在懂什么是阶了吧\n什么是根节点 ? 节点【10】即为根节点,特征:根节点拥有的子节点数量的上限和内部节点相同,如果根节点不是树中唯一节点的话,至少有俩个子节点(不然就变成单支了)。在m阶B树中(根节点非树中唯一节点),那么有关系式2\u0026lt;= M \u0026lt;=m,M为子节点数量;包含的元素数量 1\u0026lt;= K \u0026lt;=m-1,K为元素数量。\n什么是内部节点 ? 节点【13,16,19】、节点【3,6】都为内部节点,特征:内部节点是除叶子节点和根节点之外的所有节点,拥有父节点和子节点。假定m阶B树的内部节点的子节点数量为M,则一定要符合(m/2)\u0026lt;= M \u0026lt;=m关系式,包含元素数量M-1;包含的元素数量 (m/2)-1\u0026lt;= K \u0026lt;=m-1,K为元素数量。m/2向上取整。\n什么是叶子节点? 节点【1,2】、节点【11,12】等最后一层都为叶子节点,叶子节点对元素的数量有相同的限制,但是没有子节点,也没有指向子节点的指针。特征:在m阶B树中叶子节点的元素符合(m/2)-1\u0026lt;= K \u0026lt;=m-1。\n插入 针对m阶高度h的B树,插入一个元素时,首先在B树中是否存在,如果不存在,即在叶子结点处结束,然后在叶子结点中插入该新的元素。\n若该节点元素个数小于m-1,直接插入; 若该节点元素个数等于m-1,引起节点分裂;以该节点中间元素为分界,取中间元素(偶数个数,中间两个随机选取)插入到父节点中; 重复上面动作,直到所有节点符合B树的规则;最坏的情况一直分裂到根节点,生成新的根节点,高度增加1; 上面三段话为插入动作的核心,接下来以5阶B树为例,详细讲解插入的动作;\n5阶B树关键点:\n2\u0026lt;=根节点子节点个数\u0026lt;=5 3\u0026lt;=内节点子节点个数\u0026lt;=5 1\u0026lt;=根节点元素个数\u0026lt;=4 2\u0026lt;=非根节点元素个数\u0026lt;=4 插入8 图(1)插入元素【8】后变为图(2),此时根节点元素个数为5,不符合 1\u0026lt;=根节点元素个数\u0026lt;=4,进行分裂(真实情况是先分裂,然后插入元素,这里是为了直观而先插入元素,下面的操作都一样,不再赘述),取节点中间元素【7】,加入到父节点,左右分裂为2个节点,如图(3) 接着插入元素【5】,【11】,【17】时,不需要任何分裂操作,如图(4) 插入元素【13】 节点元素超出最大数量,进行分裂,提取中间元素【13】,插入到父节点当中,如图(6) 接着插入元素【6】,【12】,【20】,【23】时,不需要任何分裂操作,如图(7) 插入【26】时,最右的叶子结点空间满了,需要进行分裂操作,中间元素【20】上移到父节点中,注意通过上移中间元素,树最终还是保持平衡,分裂结果的结点存在2个关键字元素。 插入【4】时,导致最左边的叶子结点被分裂,【4】恰好也是中间元素,上移到父节点中,然后元素【16】,【18】,【24】,【25】陆续插入不需要任何分裂操作 最后,当插入【19】时,含有【14】,【16】,【17】,【18】的结点需要分裂,把中间元素【17】上移到父节点中,但是情况来了,父节点中空间已经满了,所以也要进行分裂,将父节点中的中间元素【13】上移到新形成的根结点中,这样具体插入操作的完成。 删除 首先查找B树中需删除的元素,如果该元素在B树中存在,则将该元素在其结点中进行删除;删除该元素后,首先判断该元素是否有左右孩子结点,如果有,则上移孩子结点中的某相近元素(“左孩子最右边的节点”或“右孩子最左边的节点”)到父节点中,然后是移动之后的情况;如果没有,直接删除。\n某结点中元素数目小于(m/2)-1,(m/2)向上取整,则需要看其某相邻兄弟结点是否丰满; 如果丰满(结点中元素个数大于(m/2)-1),则向父节点借一个元素来满足条件; 如果其相邻兄弟都不丰满,即其结点数目等于(m/2)-1,则该结点与其相邻的某一兄弟结点进行“合并”成一个结点; 接下来还以5阶B树为例,详细讲解删除的动作;\n关键要领,元素个数小于 2(m/2 -1)就合并,大于4(m-1)就分裂 如图依次删除依次删除【8】,【20】,【18】,【5】 首先删除元素【8】,当然首先查找【8】,【8】在一个叶子结点中,删除后该叶子结点元素个数为2,符合B树规则,操作很简单,咱们只需要移动【11】至原来【8】的位置,移动【12】至【11】的位置(也就是结点中删除元素后面的元素向前移动) 下一步,删除【20】,因为【20】没有在叶子结点中,而是在中间结点中找到,咱们发现他的继承者【23】(字母升序的下个元素),将【23】上移到【20】的位置,然后将孩子结点中的【23】进行删除,这里恰好删除后,该孩子结点中元素个数大于2,无需进行合并操作。 下一步删除【18】,【18】在叶子结点中,但是该结点中元素数目为2,删除导致只有1个元素,已经小于最小元素数目2,而由前面我们已经知道:如果其某个相邻兄弟结点中比较丰满(元素个数大于ceil(5/2)-1=2),则可以向父结点借一个元素,然后将最丰满的相邻兄弟结点中上移最后或最前一个元素到父节点中,在这个实例中,右相邻兄弟结点中比较丰满(3个元素大于2),所以先向父节点借一个元素【23】下移到该叶子结点中,代替原来【19】的位置,【19】前移;然【24】在相邻右兄弟结点中上移到父结点中,最后在相邻右兄弟结点中删除【24】,后面元素前移。 最后一步删除【5】, 删除后会导致很多问题,因为【5】所在的结点数目刚好达标,刚好满足最小元素个数(ceil(5/2)-1=2),而相邻的兄弟结点也是同样的情况,删除一个元素都不能满足条件,所以需要该节点与某相邻兄弟结点进行合并操作;首先移动父结点中的元素(该元素在两个需要合并的两个结点元素之间)下移到其子结点中,然后将这两个结点进行合并成一个结点。所以在该实例中,咱们首先将父节点中的元素【4】下移到已经删除【5】而只有【6】的结点中,然后将含有【4】和【6】的结点和含有【1】,【3】的相邻兄弟结点进行合并成一个结点。 也许你认为这样删除操作已经结束了,其实不然,在看看上图,对于这种特殊情况,你立即会发现父节点只包含一个元素【7】,没达标(因为非根节点包括叶子结点的元素K必须满足于2=\u0026lt; K \u0026lt;=4, 而此处的K=1),这是不能够接受的。如果这个问题结点的相邻兄弟比较丰满,则可以向父结点借一个元素。而此时兄弟节点元素刚好为2,刚刚满足,只能进行合并,而根结点中的唯一元素【13】下移到子结点,这样,树的高度减少一层。 磁盘IO与预读 计算机存储设备一般分为两种:内存储器(main memory)和外存储器(external memory)。\n内存储器为内存,内存存取速度快,但容量小,价格昂贵,而且不能长期保存数据(在不通电情况下数据会消失)。\n外存储器即为磁盘读取,磁盘读取数据靠的是机械运动,每次读取数据花费的时间可以分为寻道时间、旋转延迟、传输时间三个部分,寻道时间指的是磁臂移动到指定磁道所需要的时间,主流磁盘一般在5ms以下;旋转延迟就是我们经常听说的磁盘转速,比如一个磁盘7200转,表示每分钟能转7200次,也就是说1秒钟能转120次,旋转延迟就是1/120/2 = 4.17ms;传输时间指的是从磁盘读出或将数据写入磁盘的时间,一般在零点几毫秒,相对于前两个时间可以忽略不计。那么访问一次磁盘的时间,即一次磁盘IO的时间约等于5+4.17 = 9ms左右,听起来还挺不错的,但要知道一台500 -MIPS的机器每秒可以执行5亿条指令,因为指令依靠的是电的性质,换句话说执行一次IO的时间可以执行40万条指令,数据库动辄十万百万乃至千万级数据,每次9毫秒的时间,显然是个灾难。下图是计算机硬件延迟的对比图,供大家参考: 考虑到磁盘IO是非常高昂的操作,计算机操作系统做了一些优化,当一次IO时,不光把当前磁盘地址的数据,而是把相邻的数据也都读取到内存缓冲区内,因为局部预读性原理告诉我们,当计算机访问一个地址的数据的时候,与其相邻的数据也会很快被访问到。每一次IO读取的数据我们称之为一页(page)。具体一页有多大数据跟操作系统有关,一般为4k或8k,也就是我们读取一页内的数据时候,实际上才发生了一次IO,这个理论对于索引的数据结构设计非常有帮助。\n事实1 : 不同容量的存储器,访问速度差异悬殊。\n磁盘(ms级别) \u0026laquo; 内存(ns级别), 100000倍 若内存访问需要1s,则一次外存访问需要一天 为了避免1次外存访问,宁愿访问内存100次\u0026hellip;所以将最常用的数据存储在最快的存储器中 事实2 : 从磁盘中读 1 B,与读写 1KB 的时间成本几乎一样\n从以上数据中可以总结出一个道理,索引查询的数据主要受限于硬盘的I/O速度,查询I/O次数越少,速度越快,所以B树的结构才应需求而生;B树的每个节点的元素可以视为一次I/O读取,树的高度表示最多的I/O次数,在相同数量的总元素个数下,每个节点的元素个数越多,高度越低,查询所需的I/O次数越少;假设,一次硬盘一次I/O数据为8K,索引用int(4字节)类型数据建立,理论上一个节点最多可以为2000个元素,200020002000=8000000000,80亿条的数据只需3次I/O(理论值),可想而知,B树做为索引的查询效率有多高;\n另外也可以看出同样的总元素个数,查询效率和树的高度密切相关\nB树的高度 一棵含有N个总关键字数的m阶的B树的最大高度是多少? log(m/2)(N+1)/2 + 1 ,log以(m/2)为低,(N+1)/2的对数再加1 B+树 B+树是应文件系统所需而产生的B树的变形树,那么可能一定会想到,既然有了B树,又出一个B+树,那B+树必然是有很多优点的\nB+树的特征:\n有m个子树的中间节点包含有m个元素(B树中是k-1个元素),每个元素不保存数据,只用来索引; 所有的叶子结点中包含了全部关键字的信息,及指向含有这些关键字记录的指针,且叶子结点本身依关键字的大小自小而大的顺序链接。 (而B 树的叶子节点并没有包括全部需要查找的信息); 所有的非终端结点可以看成是索引部分,结点中仅含有其子树根结点中最大(或最小)关键字。 (而B 树的非终节点也包含需要查找的有效信息); 为什么说B+树比B树更适合数据库索引? B+树的磁盘读写代价更低 B+树的内部结点并没有指向关键字具体信息的指针。因此其内部结点相对B 树更小。如果把所有同一内部结点的关键字存放在同一盘块中,那么盘块所能容纳的关键字数量也越多。一次性读入内存中的需要查找的关键字也就越多。相对来说IO读写次数也就降低了;\nB+树查询效率更加稳定 由于非终结点并不是最终指向文件内容的结点,而只是叶子结点中关键字的索引。所以任何关键字的查找必须走一条从根结点到叶子结点的路。所有关键字查询的路径长度相同,导致每一个数据的查询效率相当;\nB+树便于范围查询(最重要的原因,范围查找是数据库的常态) B树在提高了IO性能的同时并没有解决元素遍历的我效率低下的问题,正是为了解决这个问题,B+树应用而生。B+树只需要去遍历叶子节点就可以实现整棵树的遍历。而且在数据库中基于范围的查询是非常频繁的,而B树不支持这样的操作或者说效率太低;不懂可以看看这篇解读-》范围查找\n补充:B树的范围查找用的是中序遍历,而B+树用的是在链表上遍历;\nB+Tree 补充 ","permalink":"https://reid00.github.io/en/posts/storage/b+%E6%A0%91/","summary":"前言 首先,为什么要总结B树、B+树的知识呢?最近在学习数据库索引调优相关知识,数据库系统普遍采用B-/+Tree作为索引结构(例如mysql","title":"B+树"},{"content":"分布式事务初探 分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit)。\n之所以提及分布式事务,是因为对于拥有大量数据的人来说,他们通常会将数据进行分割或者分片到许多不同的服务器上。假设你运行了一个银行,你一半用户的账户在一个服务器,另一半用户的账户在另一个服务器,这样的话可以同时满足负载分担和存储空间的要求。对于其他的场景也有类似的分片,比如说对网站上文章的投票,或许有上亿篇文章,那么可以在一个服务器上对一半的文章进行投票,在另一个服务器对另一半进行投票。\n对于一些操作,可能会要求从多个服务器上修改或者读取数据。比如说我们从一个账户到另一个账户完成银行转账,这两个账户可能在不同的服务器上。因此,为了完成转账,我们必须要读取并修改两个服务器的数据。\n一种构建系统的方式,我们在后面的课程也会看到,就是尝试向应用程序的开发人员,隐藏将数据分割在多个服务器上带来的复杂度。在过去的几十年间,这都是设计数据库需要考虑的问题,所以很多现在的材料的介绍都是基于数据库。但是这种方式(隐藏数据分片在多个服务器),现在在一些与传统数据库不相关的分布式系统也在广泛应用。 人们通常将并发控制和原子提交放在一起,当做事务。有关事务,我们之前介绍过。\n可以这么理解事务:程序员有一些不同的操作,或许针对数据库不同的记录,他们希望所有这些操作作为一个整体,不会因为失败而被分割,也不会被其他活动看到中间状态。事务处理系统要求程序员对这些读操作、写操作标明起始和结束,这样才能知道事务的起始和结束。事务处理系统可以保证在事务的开始和结束之间的行为是可预期的。 例如,假设我们运行了一个银行,我们想从用户Y转账到用户X,这两个账户最开始都有10块钱,这里的X,Y都是数据库的记录。\n这里有两个交易,第一个是从Y转账1块钱到X,另一个是对于所有的银行账户做审计,确保总的钱数不会改变,因为毕竟在账户间转钱不会改变所有账户的总钱数。我们假设这两个交易同时发生。为了用事务来描述这里的交易,我们需要有两个事务,第一个事务称为T1,程序员会标记它的开始,我们称之为BEGIN_X,之后是对于两个账户的操作,我们会对账户X加1,对账户Y加-1。之后我们需要标记事务的结束,我们称之为END_X。 同时,我们还有一个事务,会检查所有的账户,对所有账户进行审计,确保尽管可能存在转账,但是所有账户的金额加起来总数是不变的。所以,第二个事务是审计事务,我们称为T2。我们也需要为事务标记开始和结束。这一次我们只是读数据,所以这是一个只读事务。我们需要获取所有账户的当前余额,因为现在我们只有两个账户,所以我们使用两个临时的变量,第一个是用来读取并存放账户X的余额,第二个用来读取并存放账户Y的余额,之后我们将它们都打印出来,最后是事务的结束。\n这里的问题是,这两个事务的合法结果是什么?这是我们首先想要确定的事情。最初的状态是,两个账户都是10块钱,但是在同时运行完两个事务之后,最终结果可能是什么?我们需要一个概念来定义什么是正确的结果。一旦我们知道了这个概念,我们需要构建能执行这些事务的机制,在可能存在并发和失败的前提下,仍然得到正确的结果。 所以,首先,什么是正确性?数据库通常对于正确性有一个概念称为ACID。分别代表:\nAtomic,原子性。它意味着,事务可能有多个步骤,比如说写多个数据记录,尽管可能存在故障,但是要么所有的写数据都完成了,要么没有写数据能完成。不应该发生类似这种情况:在一个特定的时间发生了故障,导致事务中一半的写数据完成并可见,另一半的写数据没有完成,这里要么全有,要么全没有(All or Nothing)。 Consistent,一致性。我们实际上不会担心这一条,它通常是指数据库会强制某些应用程序定义的数据不变,这不是我们今天要考虑的点。 Isolated,隔离性。这一点还比较重要。这是一个属性,它表明两个同时运行的事务,在事务结束前,能不能看到彼此的更新,能不能看到另一个事务中间的临时的更新。目标是不能。隔离在技术上的具体体现是,事务需要串行执行,我之后会再解释这一条。但是总结起来,事务不能看到彼此之间的中间状态,只能看到完成的事务结果。 Durable,持久化的。这意味着,在事务提交之后,在客户端或者程序提交事务之后,并从数据库得到了回复说,yes,我们执行了你的事务,那么这时,在数据库中的修改是持久化的,它们不会因为一些错误而被擦除。在实际中,这意味着数据需要被写入到一些非易失的存储(Non-Volatile Storage),持久化的存储,例如磁盘。 今天的课程会讨论,在考虑到错误,考虑到多个并发行为的前提下,什么才是正确的行为,并确保数据在出现故障之后,仍然存在。这里对我们来说最有意思的部分是有关隔离性或者串行的具体定义。我会首先介绍这一点,之后再介绍如何执行上面例子中的两个事务。 通常来说,隔离性(Isolated)意味着可序列化(Serializable)。它的定义是如果在同一时间并行的执行一系列的事务,那么可以生成一系列的结果。这里的结果包括两个方面:由任何事务中的修改行为产生的数据库记录的修改;和任何事务生成的输出。所以前面例子中的两个事务,T1的结果是修改数据库记录,T2的结果是打印出数据。\n我们说可序列化是指,并行的执行一些事物得到的结果,与按照某种串行的顺序来执行这些事务,可以得到相同的结果。实际的执行过程或许会有大量的并行处理,但是这里要求得到的结果与按照某种顺序一次一个事务的串行执行结果是一样的。所以,如果你要检查一个并发事务执行是否是可序列化的,你查看结果,并看看是否可以找到对于同一些事务,存在一次只执行一个事务的顺序,按照这个顺序执行可以生成相同的结果。(存在穿行执行得结果和并发执行事务的结果相同)。\n隔离性(Isolated) 所以,我们刚刚例子中的事务,只有两种一次一个的串行顺序,要么是T1,T2,要么是T2,T1。我们可以看一下这两种串行执行生成的结果。 我们先执行T1,再执行T2,我们得到X=11,Y=9,因为T1先执行,T2中的打印,可以看到这两个更新过后的数据,所以这里会打印字符串“11,9”。\n另一种可能的顺序是,先执行T2,再执行T1,这种情况下,T2可以看到更新之前的数据,但是更新仍然会在T1中发生,所以最后的结果是X=11,Y=9。但是这一次,T2打印的是字符串“10,10”。 所以,这是两种串行执行的合法结果。如果我们同时执行这两个事务,看到了这两种结果之外的结果,那么我们运行的数据库不能提供序列化执行的能力(也就是不具备隔离性 Isolated)。所以,实际上,我们在考虑问题的时候,可以认为这是唯二可能的结果,我们最好设计我们的系统,并让系统只输出两个结果中的一个。\n如果你同时提交两个事务,你不知道是T1,T2的顺序,还是T2,T1的顺序,所以你需要预期可能会有超过一个合法的结果。当你同时运行了更多的事务,结果也会更加复杂,可能会有很多不同的正确的结果,这些结果都是可序列化的,因为这里对于事务存在许多顺序,可以被用来满足序列化的要求。 现在我们对于正确性有了一个定义,我们甚至知道了可能的结果是什么。我们可以提出几个有关执行顺序的假设。\n例如,假设系统实际上这么执行,开始执行T2,并执行到读X,之后执行了T1。在T1结束之后,T2再继续执行。 如果不是T2这样的读事务,最后的结果可能也是合法的。但是现在,我们想知道如果按照这种方式执行,我们得到的结果是否是之前的两种结果之一。在这里,T2事务中的变量t1可以看到10,t2会看到减Y之后的结果所以是9,最后的打印将会是字符串“10,9”。这不符合之前的两种结果,所以这里描述的执行方式不是可序列化的,它不合法。\n另一个有趣的问题是,如果我们一开始执行事务T1,然后在执行完第一个add时,执行了整个事务T2 这意味着,在T2执行的点,T2可以读到X为11,Y为10,之后打印字符串“11,10”。这也不是之前的两种合法结果之一。所以对于这两个事务,这里的执行过程也不合法。\n可序列化是一个应用广泛且实用的定义,背后的原因是,它定义了事务执行过程的正确性。它是一个对于程序员来说是非常简单的编程模型,作为程序员你可以写非常复杂的事务而不用担心系统同时在运行什么,或许有许多其他的事务想要在相同的时间读写相同的数据,或许会发生错误,这些你都不需要关心。可序列化特性确保你可以安全的写你的事务,就像没有其他事情发生一样。因为系统最终的结果必须表现的就像,你的事务在这种一次一个的顺序中是独占运行的。这是一个非常简单,非常好的编程模型。\n可序列化的另一方面优势是,只要事务不使用相同的数据,它可以允许真正的并行执行事务。我们之前的例子之所以有问题,是因为T1和T2都读取了数据X和Y。但是如果它们使用完全没有交集的数据库记录,那么这两个事务可以完全并行的执行。在一个分片的系统中,不同的数据在不同的机器上,你可以获得真正的并行速度提升,因为可能一个事务只会在第一个机器的第一个分片上执行,而另一个事务并行的在第二个机器上执行。所以,这里有可能可以获得更高的并发性能。\n在我详细介绍可序列化的事务之前,我还想提出一个小点。有一件场景我们需要能够应付,事务可能会因为这样或那样的原因在执行的过程中失败或者决定失败,通常这被称为Abort。对于大部分的事务系统,我们需要能够处理,例如当一个事务尝试访问一个不存在的记录,或者除以0,又或者是,某些事务的实现中使用了锁,一些事务触发了死锁,而解除死锁的唯一方式就是干掉一个或者多个参与死锁的事务,类似这样的场景。所以在事务执行的过程中,如果事务突然决定不能继续执行,这时事务可能已经修改了部分数据库记录,我们需要能够回退这些事务,并撤回任何已经做了的修改。\n实现事务的策略,我会划分成两块,在这门课程中我都会介绍它们,先来简单的看一下这两块。\n第一个大的有关实现的话题是并发控制(Concurrency Control)。这是我们用来提供可序列化的主要工具。所以并发控制就是可序列化的别名。通过与其他尝试使用相同数据的并发事务进行隔离,可以实现可序列化。 另一个有关实现的大的话题是原子提交(Atomic Commit)。它帮助我们处理类似这样的可能场景:前面例子中的事务T1在执行过程中可能已经修改了X的值,突然事务涉及的一台服务器出现错误了,我们需要能从这种场景恢复。所以,哪怕事务涉及的机器只有部分还在运行,我们需要具备能够从部分故障中恢复的能力。这里我们使用的工具就是原子提交。我们后面会介绍。 并发控制 在并发控制中,主要有两种策略:\n第一种主要策略是悲观并发控制(Pessimistic Concurrency Control)。 这里通常涉及到锁, 实际上,数据库的事务处理系统也会使用锁。这里的想法或许你已经非常熟悉了,那就是在事务使用任何数据之前,它需要获得数据的锁。如果一些其他的事务已经在使用这里的数据,锁会被它们持有,当前事务必须等待这些事务结束,之后当前事务才能获取到锁。在悲观系统中,如果有锁冲突,比如其他事务持有了锁,就会造成延时等待。所以这里需要为正确性而牺牲性能。\n第二种主要策略是乐观并发控制(Optimistic Concurrency Control) 这里的基本思想是,你不用担心其他的事务是否正在读写你要使用的数据,你直接继续执行你的读写操作,通常来说这些执行会在一些临时区域,只有在事务最后的时候,你再检查是不是有一些其他的事务干扰了你。如果没有这样的其他事务,那么你的事务就完成了,并且你也不需要承受锁带来的性能损耗,因为操作锁的代价一般都比较高;但是如果有一些其他的事务在同一时间修改了你关心的数据,并造成了冲突,那么你必须要Abort当前事务,并重试。这就是乐观并发控制。\n实际,这两种策略哪个更好取决于不同的环境。如果冲突非常频繁,你或许会想要使用悲观并发控制,因为如果冲突非常频繁的话,在乐观并发控制中你会有大量的Abort操作。如果冲突非常少,那么乐观并发控制可以更快,因为它完全避免了锁带来的性能损耗。今天我们只会介绍悲观并发控制。几周之后的论文,我们会讨论一种乐观并发控制的方法。\n所以,今天讨论悲观并发控制,这里涉及到的基本上就是锁机制。这里的锁是两阶段锁(Two-Phase Locking),这是一种最常见的锁。\n两阶段锁(Two-Phase Locking) 对于两阶段锁来说,当事务需要使用一些数据记录时,例如前面例子中的X,Y,第一个规则是在使用任何数据之前,在执行任何数据的读写之前,先获取锁。 第二个对于事务的规则是,事务必须持有任何已经获得的锁,直到事务提交或者Abort,你不允许在事务的中间过程释放锁。你必须要持有所有的锁,并不断的累积你持有的锁,直到你的事务完成了。所以,这里的规则是,持有锁直到事务结束。\n所以,这就是两阶段锁的两个阶段,第一个阶段获取锁,第二个阶段是在事务结束前一直持有锁。 为什么两阶段锁能起作用呢?虽然有很多的变种,在一个典型的锁系统中,每一个数据库中的记录(每个Table中的每一行)都有一个独立的锁(虽然实际中粒度可能更大)。一个事务,例如前面例子中的T1,最开始的时候不持有任何锁,当它第一次使用X记录时,在它真正使用数据前,它需要获得对于X的锁,这里或许需要等待。当它第一次使用Y记录时,它需要获取另一个对于Y的锁,当它结束之后,它会释放这两个锁。如果我们同时运行之前例子中的两个事务,它们会同时竞争对于X的锁。任何一个事务先获取了X的锁,它会继续执行,最后结束并提交。同时,另一个没有获得X的锁,它会等待锁,在对X进行任何修改之前,它需要先获取锁。所以,如果T2先获取了锁,它会获取X,Y的数值,打印,结束事务,之后释放锁。只有在这时,事务T1才能获得对于X的锁。\n如你所见的,这里基本上迫使事务串行执行,在刚刚的例子中,两阶段锁迫使执行顺序是T2,T1。所以这里显式的迫使事务的执行遵循可序列化的定义,因为实际上就是T2完成之后,再执行T1。所以我们可以获得正确的执行结果。 这里有一个问题是,为什么需要在事务结束前一直持有锁?你或许会认为,你可以只在使用数据的时候持有锁,这样也会更有效率。在刚刚的例子中,或许只在T2获取记录X的数值时持有对X的锁,或许只在T1执行对X加1操作的时候持有对于X的锁,之后立即释放锁,虽然这样违反了两阶段锁的规则,但是如果立刻释放对于数据的锁,另一个事务可以早一点执行,我们就可以有更多的并发度,进而获得更高的性能。所以,两阶段锁必然对于性能来说很糟糕,所以我们才需要确认,它对于正确性来说是必要的。\n如果事务尽可能早的释放锁,会发生什么呢?假设T2读取了X,然后立刻释放了锁,那么在这个位置,T2不持有任何锁,因为它刚刚释放了对于X的锁。\n因为T2不持有任何锁,这意味着T1可以完全在这个位置执行。从前面的反例我们已经知道,这样的执行是错误的(因为T2会打印“10,9”),因为它没能生成正确结果。 类似的,如果T1在执行完对X加1之后,就释放了对X的锁,这会使得整个T2有可能在这个位置执行。\n我们之前也看到了,这会导致非法的结果。\n如果在修改完数据之后就释放锁,还会有额外的问题。如果T1在执行完对X加1之后释放锁,它允许T2看到修改之后的X,之后T2会打印出这个结果。但是如果T1之后Abort了,或许因为银行账户Y并不存在,或许账户Y存在,但是余额为0,而我们不允许对于余额为0的账户再做减法,这样会造成透支。所以T1有可能会修改X,然后Abort。Abort的一部分工作就是要撤回对于X的修改,这样才能维持原子性。这意味着,如果T1释放了对于X的锁,事务T2会看到X的虚假数值11,这个数值最终不存在,因为T1中途Abort了,T2会看到一个永远不曾存在的数值。T2的结果最好是看起来就像是T2自己在运行,并没有T1的存在。但是这里,T2会看到X加1,然后打印出11,这与数据库的任何状态都对应不上。\n所以,使用了两阶段锁可以避免这两种违反可序列化特性的场景。 对于这些规则,还有一些需要知道的事情。首先是,这里非常容易产生死锁。例如我们有两个事务,T1读取记录X,之后再读取记录Y,T2读取记录Y,之后再读取记录X。如果它们同时运行,这里就是个死锁。\n每个事务都获取了第一个读取数据的锁,直到事务结束了,它们都不会释放这个锁。所以接下来,它们都会等待另一个事务持有的锁,除非数据库足够聪明,这里会永远死锁。实际上,事务有各种各样的策略,包括了判断循环,超时来判断它们是不是陷入到这样一个场景中。如果是的话,数据库会Abort其中一个事务,撤回它所有的操作,并表现的像这个事务从来没有发生一样。 所以这就是使用两阶段锁的并发控制。这是一个完全标准的数据库行为,在一个单主机的数据库中是这样,在一个分布式数据库也是这样,不过会更加的有趣。\n两阶段提交(Two-Phase Commit) 在一个分布式环境中,数据被分割在多台机器上,如何构建数据库或存储系统以支持事务。所以这个话题是,如何构建分布式事务(Distributed Transaction)。具体来说,如何应付错误,甚至是由多台机器中的一台引起的部分错误。这种部分错误在分布式系统中很常见。所以,在分布式事务之外,我们也要确保出现错误时,数据库仍然具有可序列化和某种程度的All-or-Nothing原子性。 一个场景是,我们或许有两个服务器,服务器S1保存了X的记录,服务器S2保存了Y的记录,它们的初始值都是10。 接下来我们要运行之前的两个事务。事务T1同时修改了X和Y,相应的我们需要向数据库发送消息说对X加1,对Y减1。但是如果我们不够小心,我们很容易就会陷入到这个场景中:我们告诉服务器S1去对X加1, 但是,之后出现了一些故障,或许持有Y记录的服务器S2故障了,使得我们没有办法完成更新的第二步。所以,这是一个问题:某个局部的故障会导致事务被分割成两半。如果我们不够小心,我们会导致一个事务中只有一半指令会生效。 甚至服务器没有崩溃都可能触发这里的场景。如果X完成了在事务中的工作,并且在服务器S2上,收到了对Y减1的请求,但是服务器S2发现Y记录并不存在。\n或者存在,但是账户余额等于0。这时,不能对Y减1。\n不管怎样,服务器2不能完成它在事务中应该做的那部分工作。但是服务器1又完成了它在事务中的那部分工作。所以这也是一种需要处理的问题。 这里我们想要的特性,我之前也提到过,就是,要么系统中的每一部分都完成它们在事务中的工作,要么系统中的所有部分都不完成它们在事务中的工作。在前面,我们违反的规则是,在故障时没有保证原子性。\n原子性是指,事务的每一个部分都执行,或者任何一个部分都不执行。很多时候,我们看到的解决方案是原子提交协议(Atomic Commit Protocols)。通常来说,原子提交协议的风格是:假设你有一批计算机,每一台都执行一个大任务的不同部分,原子提交协议将会帮助计算机来决定,它是否能够执行它对应的工作,它是否执行了对应的工作,又或者,某些事情出错了,所有计算机都要同意,没有一个会执行自己的任务。 这里的挑战是,如何应对各种各样的故障,机器故障,消息缺失。同时,还要考虑性能。原子提交协议在今天的阅读内容中有介绍,其中一种是两阶段提交(Two-Phase Commit)。\n两阶段提交不仅被分布式数据库所使用,同时也被各种看起来不像是传统数据库的分布式系统所使用。通常情况下,我们需要执行的任务会以某种方式分包在多个服务器上,每个服务器需要完成任务的不同部分。所以,在前一个例子中,实际上是数据被分割在不同的服务器上,所以相应的任务(为X加1,为Y减1)也被分包在不同的服务器上。我们将会假设,有一个计算机会用来管理事务,它被称为事务协调者(Transaction Coordinator)。事务协调者有很多种方法用来管理事务,我们这里就假设它是一个实际运行事务的计算机。在一个计算机上,事务协调者以某种形式运行事务的代码,例如Put/Get/Add,它向持有了不同数据的其他计算机发送消息,其他计算机再执行事务的不同部分。 所以,在我们的配置中,我们有一个计算机作为事务协调者(TC),然后还有服务器S1,S2,分别持有X,Y的记录。\n事务协调者会向服务器S1发消息说,请对X加1,向服务器S2发消息说,请对Y减1。 之后会有更多消息来确认,要么两个服务器都执行了操作,要么两个服务器都没有执行操作。这就是两阶段提交的实现框架。 有些事情你需要记住,在一个完整的系统中,或许会有很多不同的并发运行事务,也会有许多个事务协调者在执行它们各自的事务。在这个架构里的各个组成部分,都需要知道消息对应的是哪个事务。它们都会记录状态。每个持有数据的服务器会维护一个锁的表单,用来记录锁被哪个事务所持有。所以对于事务,需要有事务ID(Transaction ID),简称为TID。\n虽然不是很确定,这里假设系统中的每一个消息都被打上唯一的事务ID作为标记。这里的ID在事务开始的时候,由事务协调器来分配。这样事务协调器会发出消息说:这个消息是事务95的。同时事务协调器会在本地记录事务95的状态,对事务的参与者(例如服务器S1,S2)打上事务ID的标记。 这就是一些相关的术语,我们有事务协调者,我们还有其他的服务器执行部分的事务,这些服务器被称为参与者(Participants)。\n接下来,让我画出两阶段提交协议的一个参考执行过程。我们将Two-Phase Commit简称为2PC。参与者有:事务协调者(TC),我们假设只有两个参与者(A,B),两个参与者就是持有数据的两个不同的服务器。\n事务协调者运行了整个事务,它会向A,B发送Put和Get,告诉它们读取X,Y的数值,对X加1等等。所以,在事务的最开始,TC会向参与者A发送Get请求并得到回复,之后再向参与者B发送一个Put请求并得到回复。 这里只是举个例子,如果有一个复杂的事务,可能会有一个更长的请求序列。 之后,当事务协调者到达了事务的结束并想要提交事务,这样才能:\n释放所有的锁, 并使得事务的结果对于外部是可见的, 再向客户端回复。 我们假设有一个外部的客户端C,它在最最开始的时候会向TC发请求说,请运行这个事务。并且之后这个客户端会等待回复。 在开始执行事务时,TC需要确保,所有的事务参与者能够完成它们在事务中的那部分工作。更具体的,如果在事务中有任何Put请求,我们需要确保,执行Put的参与者仍然能执行Put。TC为了确保这一点,会向所有的参与者发送Prepare消息。\n当A或者B收到了Prepare消息,它们就知道事务要执行但是还没执行的内容,它们会查看自身的状态并决定它们实际上能不能完成事务。或许它们需要Abort这个事务因为这个事务会引起死锁,或许它们在故障重启过程中并完全忘记了这个事务因此不能完成事务。所以,A和B会检查自己的状态并说,我有能力或者我没能力完成这个事务,它们会向TC回复Yes或者No。\n事务协调者会等待来自于每一个参与者的这些Yes/No投票。如果所有的参与者都回复Yes,那么事务可以提交,不会发生错误。之后事务协调者会发出一个Commit消息,给每一个事务的参与者,之后,事务参与者通常会回复ACK说,我们知道了要commit。当事务协调者发出Prepare消息时,如果所有的参与者都回复Yes,那么事务可以commit。如果任何一个参与者回复了No,表明自己不能完成这个事务,或许是因为错误,或许有不一致性,或许丢失了记录,那么事务协调者不会发送commit消息,它会发送一轮Abort消息给所有的参与者说,请撤回这个事务。\n在事务Commit之后,会发生两件事情。首先,事务协调者会向客户端发送代表了事务输出的内容,表明事务结束了,事务没有被Abort并且被持久化保存起来了。另一个有意思的事情是,为了遵守前面的锁规则(两阶段锁),事务参与者会释放锁(这里不论Commit还是Abort都会释放锁)。\n实际上,为了遵循两阶段锁规则,每个事务参与者在参与事务时,会对任何涉及到的数据加锁。所以我们可以想象,在每个参与者中都会有个表单,表单会记录数据当前是为哪个事务加的锁。当收到Commit或者Abort消息时,事务参与者会对数据解锁,之后其他的事务才可以使用相应的数据。这里的解锁操作会解除对于其他事务的阻塞。这实际上是可序列化机制的一部分。\n目前来说,还没有问题,因为架构中的每一个成员都遵循了协议,没有错误,两个参与者只会一起Commit,如果其中一个需要Abort,那么它们两个都会Abort。所以,基于刚刚描述的协议,如果没有错误的话,我们得到了这种All-or-Noting的原子特性。\n故障恢复(Crash Recovery) 现在,我们需要在脑中设想各种可能发生的错误,并确认这里的两阶段提交协议是否仍然可以提供All-or-Noting的原子特性。如果不能的话,我们该如何调整或者扩展协议?\n事务参与者崩溃 第一个我想考虑的错误是故障重启。我的意思是类似于断电,服务器会突然中断执行,当电力恢复之后,作为事务处理系统的一部分,服务器会运行一些恢复软件。这里实际上有两个场景需要考虑。 第一个场景是,参与者B可能在回复事务协调者的Prepare消息之前的崩溃了,所以,B在回复Yes之前就崩溃了。从TC的角度来看,B没有回复Yes,TC也就不能Commit,因为它需要等待所有的参与者回复Yes。\n如果B发现自己不可能发送Yes,比如说在发送Yes之前自己就故障了,那么B被授权可以单方面的Abort事务。因为B知道自己没有发送Yes,那么它也知道事务协调者不可能Commit事务。这里有很多种方法可以实现,其中一种方法是,因为B故障重启了,内存中的数据都会清除,所以B中所有有关事务的信息都不能活过故障,所以,故障之后B不知道任何有关事务的信息,也不知道给谁回复过Yes。之后,如果事务协调者发送了一个Prepare消息过来,因为B不知道事务,B会回复No,并要求Abort事务。\n第二个场景是 当然,B也可能在回复了Yes给事务协调者的Prepare消息之后崩溃的。B可能开心的回复给事务协调者说好的,我将会commit。但是在B收到来自事务协调者的commit消息之前崩溃了。\n现在我们有了一个完全不同的场景。现在B承诺可以commit,因为它回复了Yes。接下来极有可能发生的事情是,事务协调者从所有的参与者获得了Yes的回复,并将Commit消息发送给了A,所以A实际上会执行事务分包给它的那一部分,持久化存储结果,并释放锁。这样的话,为了确保All-or-Nothing原子性,我们需要确保B在故障恢复之后,仍然能完成事务分包给它的那一部分。在B故障的时候,不知道事务是否能Commit,因为它还没有收到Commit消息。但是B还是需要做好Commit的准备。这意味着,在故障重启的时候,B不能丢失对于事务的状态记录。\n在B回复Prepare之前,它必须确保记住当前事务的中间状态,记住所有要做的修改,记住事务持有的所有的锁,这些信息必须在磁盘上持久化存储。通常来说,这些信息以Log的形式在磁盘上存储。所以在B回复Yes给Prepare消息之前,它首先要将相应的Log写入磁盘,并在Log中记录所有有关提交事务必须的信息。这包括了所有由Put创建的新的数值,和锁的完整列表。之后,B才会回复Yes。 之后,如果B在发送完Yes之后崩溃了,当它重启恢复时,通过查看自己的Log,它可以发现自己正在一个事务的中间,并且对一个事务的Prepare消息回复了Yes。Log里有Commit需要做的所有的修改,和事务持有的所有的锁。之后,当B最终收到了Commit而不是Abort,通过读取Log,B就知道如何完成它在事务中的那部分工作。\n所以,这里是我之前在介绍协议的时候遗漏的一点。B在这个时间点(回复Yes给TC的Prepare消息之前),必须将Log写入到自己的磁盘中。这里会使得两阶段提交稍微有点慢,因为这里要持久化存储数据。\n第三个场景最后一个可能崩溃的地方是,B可能在收到Commit之后崩溃了。但是这样的话,B就完成了修改,并将数据持久化存储在磁盘上了。这样的话,故障重启就不需要做任何事情,因为事务已经完成了。\n因为没有收到ACK,事务协调者会再次发送Commit消息。当B重启之后,收到了Commit消息时,它可能已经将Log中的修改写入到自己的持久化存储中、释放了锁、并删除了有关事务的Log。所以我们需要关心,如果B收到了同一个Commit消息两次,该怎么办?这里B可以记住事务的信息,但是这会消耗内存,所以实际上B会完全忘记已经在磁盘上持久化存储的事务的信息。对于一个它不知道事务的Commit消息,B会简单的ACK这条消息。这一点在后面的一些介绍中非常重要。\n上面是事务的参与者在各种奇怪的时间点崩溃的场景。那对于事务协调者呢?它只是一个计算机,如果它出现故障,也会是个问题。\n事务协调者崩溃 如果事务的任何一个参与者可能已经提交了,或者事务协调者可能已经回复给客户端了,那么我们不能忽略事务。比如,如果事务协调者已经向A发送了Commit消息,但是还没来得及向B发送Commit消息就崩溃了,那么事务协调者必须在重启的时候准备好向B重发Commit消息,以确保两个参与者都知道事务已经提交了。所以,事务协调者在哪个时间点崩溃了非常重要。\n如果事务协调者在发送Commit消息之前就崩溃了,那就无所谓了,因为没有一个参与者会Commit事务。也就是说,如果事务协调者在崩溃前没有发送Commit消息,它可以直接Abort事务。因为参与者可以在自己的Log中看到事务,但是又从来没有收到Commit消息,事务的参与者会向事务协调者查询事务,事务协调者会发现自己不认识这个事务,它必然是之前崩溃的时候Abort的事务。所以这就是事务协调者在Commit之前就崩溃了的场景。\n如果事务协调者在发送完一个或者多个Commit消息之后崩溃,那么就不允许它忘记相关的事务。这意味着,在崩溃的时间点,也就是事务协调者决定要Commit而不是Abort事务,并且在发送任何Commit消息之前,它必须先将事务的信息写入到自己的Log,并存放在例如磁盘的持久化存储中,这样计算故障重启了,信息还会存在。\n所以,事务协调者在收到所有对于Prepare消息的Yes/No投票后,会将结果和事务ID写入存在磁盘中的Log,之后才会开始发送Commit消息。之后,可能在发送完第一个Commit消息就崩溃了,也可能发送了所有的Commit消息才崩溃,不管在哪,当事务协调者故障重启时,恢复软件查看Log可以发现哪些事务执行了一半,哪些事务已经Commit了,哪些事务已经Abort了。作为恢复流程的一部分,对于执行了一半的事务,事务协调者会向所有的参与者重发Commit消息或者Abort消息,以防在崩溃前没有向参与者发送这些消息。这就是为什么参与者需要准备好接收重复的Commit消息的一个原因。\n这些就是主要的服务器崩溃场景。我们还需要担心如果消息在网络传输的时候丢失了怎么办?或许你发送了一个消息,但是消息永远也没有送达。或许你发送了一个消息,并且在等待回复,或许回复发出来了,但是之后被丢包了。这里的任何一个消息都有可能丢包,我们必须想清楚在这样的场景下该怎么办?\n举个例子,事务协调者发送了Prepare消息,但是并没有收到所有的Yes/No消息,事务协调者这时该怎么做呢? 其中一个选择是,事务协调者重新发送一轮Prepare消息,表明自己没有收到全部的Yes/No回复。事务协调者可以持续不断的重发Prepare消息。但是如果其中一个参与者要关机很长时间,我们将会在持有锁的状态下一直等待。假设A不响应了,但是B还在运行,因为我们还没有Commit或者Abort,B仍然为事务持有了锁,这会导致其他的事务等待。所以,如果可以避免的话,我们不想永远等待。\n在事务协调者没有收到Yes/No回复一段时间之后,它可以单方面的Abort事务。因为它知道它没有得到完整的Yes/No消息,当然它也不可能发送Commit消息,所以没有一个参与者会Commit事务,所以总是可以Abort事务。事务的协调者在等待完整的Yes/No消息时,如果因为消息丢包或者某个参与者崩溃了,而超时了,它可以直接决定Abort这个事务,并发送一轮Abort消息。\n之后,如果一个崩溃了的参与者重启了,向事务协调者发消息说,我并没有收到来自你的有关事务95的消息,事务协调者会发现自己并不知道到事务95的存在,因为它在之前就Abort了这个事务并删除了有关这个事务的记录。这时,事务协调者会告诉参与者说,你也应该Abort这个事务。 类似的,如果参与者等待Prepare消息超时了,那意味着它必然还没有回复Yes消息,进而意味着事务协调者必然还没有发送Commit消息。所以如果一个参与者在这个位置因为等待Prepare消息而超时,那么它也可以决定Abort事务。在之后的时间里,如果事务协调者上线了,再次发送Prepare消息,B会说我不知道有关事务的任何事情并回复No。这也没问题,因为这个事务在这个时间也不可能在任何地方Commit了。所以,如果网络某个地方出现了问题,或者事务协调器挂了一会,事务参与者仍然在等待Prepare消息,总是可以允许事务参与者Abort事务,并释放锁,这样其他事务才可以继续。这在一个负载高的系统中可能会非常重要。\n但是,假设B收到了Prepare消息,并回复了Yes。大概在下图的位置中, 这个时候参与者没有收到Commit消息,它接下来怎么也等不到Commit消息。或许网络出现问题了,或许事务协调器的网络连接中断了,或者事务协调器断电了,不管什么原因,B等了很长时间都没有收到Commit消息。这段时间里,B一直持有事务涉及到数据的锁,这意味着,其他事务可能也在等待这些锁的释放。所以,这里我们应该尽早的Abort事务,并释放锁。所以这里的问题是,如果B收到了Prepare消息,并回复了Yes,在等待了10秒钟或者10分钟之后还没有收到Commit消息,它能单方面的决定Abort事务吗? 很不幸的是,这里的答案不行。\n在回复Yes给Prepare消息之后,并在收到Commit消息之前这个时间区间内,参与者会等待Commit消息。如果等待Commit消息超时了,参与者不允许Abort事务,它必须无限的等待Commit消息,这里通常称为Block。\n这里的原因是,因为B对Prepare消息回复了Yes,这意味着事务协调者可能收到了来自于所有参与者的Yes,并且可能已经向部分参与者发送Commit消息。这意味着A可能已经看到了Commit消息,Commit事务,持久化存储事务的结果并释放锁。所以在上面的区间里,B不能单方面的决定Abort事务,它必须无限等待事务协调者的Commit消息。如果事务协调者故障了,最终会有人来修复它,它在恢复过程中会读取Log,并重发Commit消息。\n就像不能单方面的决定Abort事务一样,这里B也不能单方面的决定Commit事务。因为A可能对Prepare消息回复了No,但是B没有收到相应的Abort消息。所以,在上面的区间中,B既不能Commit,也不能Abort事务。\n这里的Block行为是两阶段提交里非常重要的一个特性,并且它不是一个好的属性。因为它意味着,在特定的故障中,你会很容易的陷入到一个需要等待很长时间的场景中,在等待过程中,你会一直持有锁,并阻塞其他的事务。所以,人们总是尝试在两阶段提交中,将这个区间尽可能快的完成,这样可能造成Block的时间窗口也会尽可能的小。所以人们尽量会确保协议中这部分尽可能轻量化,甚至对于一些变种的协议,对于一些特定的场景都不用等待。\n这就是基本的协议。为什么这里的两阶段提交协议能构建一个A和B要么全Commit,要么全Abort的系统?其中一个原因是,决策是在一个单一的实例,也就是事务协调者完成的。A或者B不能决定Commit还是不Commit事务,A和B之间不会交互来达成一致并完成事务的Commit,相反的只有事务协调者可以做决定。事务协调者是一个单一的实例,它会通知其他的部分这是我的决定,请执行它。但是,使用一个单一实例的事务协调者的缺点是,在某个时间点你需要Block并等待事务协调者告诉你决策是什么。\n一个进一步的问题是,我们知道事务协调者必然在它的Log中记住了事务的信息,那么它在什么时候可以删除Log中有关事务的信息?这里的答案是,如果事务协调者成功的得到了所有参与者的ACK,那么它就知道所有的参与者知道了事务已经Commit或者Abort,所有参与者必然也完成了它们在事务中相应的工作,并且永远也不会需要知道事务相关的信息。所以当事务协调者得到了所有的ACK,它可以擦除所有有关事务的记忆。\n类似的,当一个参与者收到了Commit或者Abort消息,完成了它们在事务中的相应工作,持久化存储事务结果并释放锁,那么在它发送完ACK之后,参与者也可以完全忘记相关的事务。 当然事务协调者或许不能收到ACK,这时它会假设丢包了并重发Commit消息。这时,如果一个参与者收到了一个Commit消息,但是它并不知道对应的事务,因为它在之前回复ACK之后就忘记了这个事务,那么参与者会再次回复一个ACK。因为如果参与者收到了一个自己不知道的事务的Commit消息,那么必然是因为它之前已经完成对这个事务的Commit或者Abort,然后选择忘记这个事务了。\n总结 这就是两阶段提交,它实现了原子提交。两阶段提交在大量的将数据分割在多个服务器上的分片数据库或者存储系统中都有使用。两阶段提交可以支持读写多条记录,一些更特殊的存储系统不允许你在多条记录上支持事务。对于这些不支持事务中包含多条数据的系统,你就不需要两阶段提交。但是如果你需要在事务中支持多条数据,并且你将数据分片在多台服务器之上,那么你必须支持两阶段提交。\n然而,两阶段提交有着极差的名声。其中一个原因是,因为有多轮消息的存在,它非常的慢。在上面的图中,各个组成部分之间着大量的交互。另一个原因是,这里有大量的写磁盘操作,比如说B在回复Yes给Prepare消息之后不仅要向磁盘写入数据,还需要等待磁盘写入结束,如果你使用一个机械硬盘,这会花费10毫秒来完成Log数据的写入,这决定了事务的参与者能够以多快的速度处理事务。10毫秒完成Log写磁盘,那么最快就是每秒处理100个事务,这是一个非常慢的结果。同时,事务协调者也需要写磁盘,在收到所有Prepare消息的Yes回复之后,它也需要将Log写入磁盘,并等待磁盘写入结束。之后它才能发送Commit消息,这里又有了10毫秒。在这两个10毫秒内,锁都被参与者持有者,其他使用相关数据的事务都会被阻塞。\n这里我持续的在介绍性能,但是它的确非常重要,因为在一个繁忙的事务处理系统中,存在大量的事务,许多事务都会等待相同的数据,我们希望不要在一个长时间内持有锁。但是两阶段提交迫使我们在各个阶段都做等待。\n进一步的问题是,如果任何地方出错了,消息丢了,某台机器崩溃了,如果你不够幸运进入到Block区间,参与者需要在持有锁的状态下等待一段长时间。\n因此,你只会在一个小的环境中看到两阶段提交,比如说在一个组织的一个机房里面。你不会在不同的银行之间转账看到它,你或许可以在银行内部的系统中看见两阶段提交,但是你永远也不会在物理分隔的不同组织之间看见两阶段提交,因为它可能会陷入到Block区间中。你不会想将你的数据库的命运寄托在其他的数据库不在错误的时间崩溃,从而使得你的数据库被迫在很长一段时间持有锁。 因为两阶段提交很慢,有很多很多的研究都是关于如何让它变得更快,比如以各种方式放松这里的规则进而使得它变得更快,又比如对于一些特定的场景做一些定制化从而避免一些消息,我们在这门课中会看到很多这种定制。\n两阶段提交的架构中,本质上是有一个Leader(事务协调者),将消息发送给Follower(事务参与者),Leader只能在收到了足够多Follower的回复之后才能继续执行。这与Raft非常像,但是,这里协议的属性与Raft又非常的不一样。这两个协议解决的是完全不同的问题。\n使用Raft可以通过将数据复制到多个参与者得到高可用。Raft的意义在于,即使部分参与的服务器故障了或者不可达,系统仍然能工作。Raft能做到这一点是因为所有的服务器都在做相同的事情,所以我们不需要所有的服务器都参与,我们只需要过半服务器参与。然而两阶段提交,参与者完全没有在做相同的事情,每个参与者都在做事务中的不同部分,比如A可能在对X加1,B可能在对Y减1。所以在两阶段提交中,所有的参与者都在做不同的事情。所有的参与者都必须完成自己那部分工作,这样事务才能结束,所以这里需要等待所有的参与者。\n所以,Raft通过复制可以不用每一个参与者都在线,而两阶段提交每个参与者都做了不同的工作,并且每个参与者的工作都必须完成,所以两阶段提交对于可用性没有任何帮助。Raft完全就是可用性,而两阶段提交完全不是高可用的,系统中的任何一个部分出错了,系统都有可能等待直到这个部分修复。比如事务协调者在错误的时间崩溃了,我们需要等待它上线并读取它的Log再重发Commit消息。如果一个参与者在错误的时间崩溃了,如果我们足够幸运,我们只需要Abort事务。所以实际上,两阶段提交的可用性非常低,因为任何一个部分崩溃都有可能阻止整个系统的运行。Raft并不需要确保所有的参与者执行操作,它只需要过半服务器执行操作,或许少数的服务器完全没有执行操作也没关系。这里的原因是Raft系统中,所有的参与者都在做相同的事情,我们不必等待所有的参与者。这就是为什么Raft有更高的可用性。所以这是两个完全不同的协议。\n然而,是有可能结合这两种协议的。两阶段提交对于故障来说是非常脆弱的,在故障时它可以有正确的结果,但是不具备可用性。所以,这里的问题是,是否可以构建一个合并的系统,同时具备Raft的高可用性,但同时又有两阶段提交的能力将事务分包给不同的参与者。这里的结构实际上是,通过Raft或者Paxos或者其他协议,来复制两阶段提交协议里的每一个组成部分。\n所以,在前面的例子中,我们会有三个不同的集群,事务协调器会是一个复制的服务,包含了三个服务器,我们在这3个服务器上运行Raft,其中一个服务器会被选为Leader,它们会有复制的状态,它们有Log来帮助它们复制,我们只需要等待过半服务器响应就可以执行事务协调器的指令。事务协调器还是会执行两阶段提交里面的各个步骤,并将这些步骤记录在自己的Raft集群的Log中。 每个事务参与者也同样是一个Raft集群。最终,消息会在这些集群之间传递。 不得不承认,这里很复杂,但是它展示了你可以结合两种思想来同时获得高可用和原子提交。在Lab4,我们会构建一个类似的系统,实际上就是个分片的数据库,每个分片以这种形式进行复制,同时还有一个配置管理器,来允许将分片的数据从一个Raft集群移到另一个Raft集群。除此之外,我们还会读一篇论文叫做Spanner,它描述了Google使用的一种数据库,Spanner也使用了这里的结构来实现事务写。\n","permalink":"https://reid00.github.io/en/posts/storage/%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1/","summary":"分布式事务初探 分布式事务主要有两部分组成。第一个是并发控制(Concurrency Control)第二个是原子提交(Atomic Commit","title":"分布式事务"},{"content":"CPU缓存 CPU缓存(CPU Cache)的目的是为了提高访问内存(RAM)的效率,这虽然已经涉及到硬件的领域,但它仍然与我们息息相关,了解了它的一些原理,能让我们写出更高效的程序,另外在多线程程序中,一些不可思议的问题也与缓存有关。\n现代多核处理器,一个CPU由多个核组成,每个核又可以有多个硬件线程,比如我们说4核8线程,就是指有4个核,每个核2个线程,这在OS看来就像8个并行处理器一样。\nCPU缓存有多级缓存,比如L1, L2, L3等:\nL1容量最小,速度最快,每个核都有L1缓存,L1又专门针对指令和数据分成L1d(数据缓存),L1i(指令缓存)。 L2容量比L1大,速度比L1慢,每个核都有L2缓存。 L3容量最大,速度最慢,多个核共享一个L3缓存。 有些CPU可能还有L4缓存,不过不常见;此外还有其他类型的缓存,比如TLB(translation lookaside buffer),用于物理地址和虚拟地址转译,这不是我们关心的缓存。\n下图展示了缓存和CPU的关系: Linux用下面命令可以查看CPU缓存的信息:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 [root@server-6 import]# getconf -a | grep CACHE LEVEL1_ICACHE_SIZE 32768 LEVEL1_ICACHE_ASSOC 8 LEVEL1_ICACHE_LINESIZE 64 LEVEL1_DCACHE_SIZE 32768 LEVEL1_DCACHE_ASSOC 8 LEVEL1_DCACHE_LINESIZE 64 LEVEL2_CACHE_SIZE 1048576 LEVEL2_CACHE_ASSOC 16 LEVEL2_CACHE_LINESIZE 64 LEVEL3_CACHE_SIZE 37486592 LEVEL3_CACHE_ASSOC 11 LEVEL3_CACHE_LINESIZE 64 LEVEL4_CACHE_SIZE 0 LEVEL4_CACHE_ASSOC 0 LEVEL4_CACHE_LINESIZE 0 上面显示CPU只有3级缓存,L4都为0。 L1的数据缓存和指令缓存分别是32KB;L2为256KB;L3为30MB。 在缓存和主存之间,数据是按固定大小的块传输的 该块称为缓存行(cache line),这里显示每行的大小为64Bytes。 ASSOC表示主存地址映射到缓存的策略,这里L1是8路组相联,L2是16路组联,L3是11路组相联,稍后解释是什么意思。 缓存结构 一块CPU缓存可以看成是一个数组,数组元素是缓存项(cache entry),一个缓存项的内容大概是这样的:\n1 2 3 +-------------------------------------------+ | tag | data block(cache line) | flag | +-------------------------------------------+ data block就是从内存中拷贝过来的数据,也就是我们说的cache line,从上面信息可知大小是64字节。 tag 保存了内存地址的一部分,是用来验证是否缓存命中的。 flag 是一些标志位,比如缓存是否失效,写dirty等等。 实际上LEVEL1_ICACHE_SIZE这个数据,是用data block来算的,并不包括tag和flag占用的大小,比如64 x 512 = 32768,表示LEVEL1_ICACHE_SIZE可以缓存512个cache line。 缓存首先要解决的问题是:怎么映射内存地址和缓存地址?比如CPU要检查一个内存值是否已经缓存,那么它首先要能算出这个内存地址对应的缓存地址,然后才能检查。\n为了解决这个问题,缓存将一个内存地址分成下面几个部分:\n1 2 3 +-------------------------------------------+ | tag | index | offset | +-------------------------------------------+ tag和缓存项中的tag对应,用来验证是否缓存命中的。 index 缓存项数组中的索引。 offset 缓存块(cache line)中的偏移,因为缓存块是64字节,而内存值可能只有4个字节,一个缓存块可以保存多个连续的内存值。这个offset实际上就是指明内存值在cache line中的位置。 直接映射缓存 现在我们举一个具体的例子,说明内存和缓存是如何映射的:\n假如缓存的大小是32768B(32KB),缓存块大小是64Bytes,那么缓存项数组就有 32768/64=512 个。 CPU要访问一个内存地址0x1CAABBDD,它首先检查这个内存地址是否在缓存中,检查过程是这样的: 内存地址的二进制形式是(低位在前面): 1 2 | tag | index | offset | 0 0 0 1 1 1 0 0 1 0 1 0 1 0 1 0 1 0 1 1 1 0 1 1 1 1 0 1 1 1 0 1 先计算内存在cache line中的偏移,因为缓存块是64字节,那么offset需要占6位(2^6=64),即offset=011101=29。 -= 接着要计算缓存项的索引,因为缓存项数组是512个,所以index需要占9位(2^9=512),即index=011101111=239。 现在我们通过offset和index已经找到缓存块的具体位置了,但是因为内存要远比缓存大很多,所以多个内存块是可以映射到同一个位置的,怎么判断这个缓存块位置存的就是这个内存的值呢?答案就是tag:内存地址去掉index和offset的部分,剩下的就是tag=00011100101010101=0x3955。 通过index找到缓存项,比较缓存项中的tag是否与内存地址中的tag相同,如果相同表示命中,就直接取缓存块中的值;如果不同表示未命中,CPU需要将内存值拷贝到缓存(替换掉老的) 这种映射方式就称为直接映射(Direct mapped),它的缺点就是多个内存地址会映射到同一个缓存地址,拿上面的内存地址来看,只要offset和index相同的内存地址,就一定会映射到同一个地方,比如:\n1 2 3 00011100101010100 011101111 011101 00011100101010110 011101111 011101 00011100101010111 011101111 011101 如果同时访问上面3个地址,就会一直替换缓存的值,也就是一直出现缓存冲突,这可能比没有缓存还要慢,因为除了访问内存外,还多一个拷贝内存值到缓存的操作。\nN路组联 为了解决上面的问题,我试着把缓存项数组分成2个数组(2路),比如分成2个256的数组,如下图所示: 查找过程和上面其实一样的:\n先通过index找到数组索引,只不过因为是2路,所以存在2个数组。 然后通过内存tag依次比较2个缓存顶的tag,如果其中一个tag相等,说明这个数组缓存命中;如果两个都不相等,说明缓存不命中,CPU会拷贝内存值到缓存中,但是现在有2个位置,要拷贝进哪个呢?我的理解CPU应该是随机选1路拷贝。 offset这个其实无关紧要,因为它是cache line中的偏移。 那这个和直接映射相比,好在哪里呢,因为一个内存值会随机拷贝到2路中的1个,所以缓存冲突(多个内存地址映射到同一个缓存地址)的概率会降低一半;如果把缓存项数组分成4个数组,这就是4路组相联。\n上面LEVEL1_ICACHE_ASSOC的值等于8,表明是8路组相联。分组越多,缓存冲突率越低,但是CPU要遍历的数组就越多,这是一个权衡的问题。\n通过观察也可以发现,其实直接映射就是1路组相联。如果直接分成512个数组,那每个数组只有1项,这种就是全相联,CPU直接遍历512个数组,判断内存地址在哪1个。\n缓存分配策略和更新策略 当CPU从内存读数据时,如果该数据没有在缓存中(read miss),CPU会把数据拷贝到缓存。\n当CPU往内存写数据时: 有多种写策略:\n如果在写的时候数在缓存中\nWrite through 更新缓存的数据,同时更新内存的数据。 Write back 只更新缓存的数据,同时在缓存项设置一个drity标志位,内存的数据只会在某个时刻更新(比如替换cache line时)。 如果在写的时候数据没有在缓存中(write miss),也有两种策略:\nWrite allocate 在写之前先把数据加载到缓存,然后再实施上面的写策略。 No-write allocate 不加载缓存,直接把数据写到内存。数据只有在 read miss 时才会加载到缓存。 虽然上面两组策略可以任意搭配,但通常情况下是 No-write allocate 和 Write through 一起使用,而 Write allocate 则和 Write back 一起使用,下面是 wikipedia 的两张流程图。\nNo-write allocate方式的 Write through Cache: Write allocate 方式的 Write back Cache 从上面描述我们知道,当我们向一个内存写数据时,内存中的数据可能不马上被更新,这个新数据可能还在cache line呆着。因为每个核都有自己的缓存,如果CPU不做处理,可以想象一定会出问题的:比如核1改了数据,核2去读同一个数据,此时数据还在核1的缓存中,核2读到的就是老的数据。CPU为了处理多核间的缓存同步,有一套复杂的一致性协议。关于这个后面再来学习。\n其他:Cache Aside(旁路缓存)策略 我们来考虑一种最简单的业务场景,比方说在你的电商系统中有一个用户表,表中只有 ID 和年龄两个字段,缓存中我们以 ID 为 Key 存储用户的年龄信息。那么当我们要把 ID 为 1 的用户的年龄从 19 变更为 20,要如何做呢?\n你可能会产生这样的思路:先更新数据库中 ID 为 1 的记录,再更新缓存中 Key 为 1 的数 据。 这个思路会造成缓存和数据库中的数据不一致。比如,A 请求将数据库中 ID 为 1 的用户年 龄从 19 变更为 20,与此同时,请求 B 也开始更新 ID 为 1 的用户数据,它把数据库中记 录的年龄变更为 21,然后变更缓存中的用户年龄为 21。紧接着,A 请求开始更新缓存数 据,它会把缓存中的年龄变更为 20。此时,数据库中用户年龄是 21,而缓存中的用户年龄 却是 20。 为什么产生这个问题呢?因为变更数据库和变更缓存是两个独立的操作,而我们并 没有对操作做任何的并发控制。那么当两个线程并发更新它们的时候,就会因为写入顺序的不 同造成数据的不一致。\n另外,直接更新缓存还存在另外一个问题就是丢失更新。还是以我们的电商系统为例,假如 电商系统中的账户表有三个字段:ID、户名和金额,这个时候缓存中存储的就不只是金额 信息,而是完整的账户信息了。当更新缓存中账户金额时,你需要从缓存中查询完整的账户 数据,把金额变更后再写入到缓存中。\n这个过程中也会有并发的问题,比如说原有金额是 20,A 请求从缓存中读到数据,并且把 金额加 1,变更成 21,在未写入缓存之前又有请求 B 也读到缓存的数据后把金额也加 1, 也变更成 21,两个请求同时把金额写回缓存,这时缓存里面的金额是 21,但是我们实际上 预期是金额数加 2,这也是一个比较大的问题。\n那我们要如何解决这个问题呢?其实,我们可以在更新数据时不更新缓存,而是删除缓存中 的数据,在读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存 中。\n这个策略就是我们使用缓存最常见的策略,Cache Aside 策略(也叫旁路缓存策略),这 个策略数据以数据库中的数据为准,缓存中的数据是按需加载的。它可以分为读策略和写策 略,其中读策略的步骤是: 从缓存中读取数据 如果缓存命中,则直接返回数据; 如果缓存不命中,则从数据库中查询数据; 查询到数据后,将数据写入到缓存中,并且返回给用户。 写策略的步骤是: 更新数据库中的记录; 删除缓存记录。 你也许会问了,在写策略中,能否先删除缓存,后更新数据库呢?答案是不行的,因为这样 也有可能出现缓存数据不一致的问题,我以用户表的场景为例解释一下。\n假设某个用户的年龄是 20,请求 A 要更新用户年龄为 21,所以它会删除缓存中的内容。 这时,另一个请求 B 要读取这个用户的年龄,它查询缓存发现未命中后,会从数据库中读取 到年龄为 20,并且写入到缓存中,然后请求 A 继续更改数据库,将用户的年龄更新为 21,这就造成了缓存和数据库的不一致。\n那么像 Cache Aside 策略这样先更新数据库,后删除缓存就没有问题了吗?其实在理论上 还是有缺陷的。假如某个用户数据在缓存中不存在,请求 A 读取数据时从数据库中查询到 年龄为 20,在未写入缓存中时另一个请求 B 更新数据。它更新数据库中的年龄为 21,并 且清空缓存。这时请求 A 把从数据库中读到的年龄为 20 的数据写入到缓存中,造成缓存 和数据库数据不一致。 不过这种问题出现的几率并不高,原因是缓存的写入通常远远快于数据库的写入,所以在实 际中很难出现请求 B 已经更新了数据库并且清空了缓存,请求 A 才更新完缓存的情况。而一 旦请求 A 早于请求 B 清空缓存之前更新了缓存,那么接下来的请求就会因为缓存为空而 从数据库中重新加载数据,所以不会出现这种不一致的情况。\nCache Aside 策略是我们日常开发中最经常使用的缓存策略,不过我们在使用时也要学会 依情况而变。比如说当新注册一个用户,按照这个更新策略,你要写数据库,然后清理缓存 (当然缓存中没有数据给你清理)。可当我注册用户后立即读取用户信息,并且数据库主从 分离时,会出现因为主从延迟所以读不到用户信息的情况。\n而解决这个问题的办法恰恰是在插入新数据到数据库之后写入缓存,这样后续的读请求就会 从缓存中读到数据了。并且因为是新注册的用户,所以不会出现并发更新用户信息的情况。 Cache Aside 存在的最大的问题是当写入比较频繁时,缓存中的数据会被频繁地清理,这 样会对缓存的命中率有一些影响。如果你的业务对缓存命中率有严格的要求,那么可以考虑 两种解决方案:\n一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样 在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能 会有一些影响;\n另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样 即使出现缓存不一致的情况,缓存的数据也会很快地过期,对业务的影响也是可以接受。 当然了,除了这个策略,在计算机领域还有其他几种经典的缓存策略,它们也有各自适用的 使用场景。\n","permalink":"https://reid00.github.io/en/posts/storage/cpu%E7%BC%93%E5%AD%98%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/","summary":"CPU缓存 CPU缓存(CPU Cache)的目的是为了提高访问内存(RAM)的效率,这虽然已经涉及到硬件的领域,但它仍然与我们息息相关,了解了","title":"CPU缓存基础知识"},{"content":"Zookpeer 的先行一致性介绍 Zookeeper的确有一些一致性的保证,用来帮助那些使用基于Zookeeper开发应用程序的人,来理解他们的应用程序,以及理解当他们运行程序时,会发生什么。与线性一致一样,这些保证与序列有关。Zookeeper有两个主要的保证,它们在论文的2.3有提及。\n写请求是线性一致的。 现在,你可以发现,它(Zookeeper)对于线性一致的定义与我的不太一样,因为Zookeeper只考虑写,不考虑读。这里的意思是,尽管客户端可以并发的发送写请求,然后Zookeeper表现的就像以某种顺序,一次只执行一个写请求,并且也符合写请求的实际时间。所以如果一个写请求在另一个写请求开始前就结束了,那么Zookeeper实际上也会先执行第一个写请求,再执行第二个写请求。所以,这里不包括读请求,单独看写请求是线性一致的。 Zookeeper并不是一个严格的读写系统。写请求通常也会跟着读请求。对于这种混合的读写请求,任何更改状态的操作相比其他更改状态的操作,都是线性一致的。\nFIFO(First In First Out)客户端序列。 Zookeeper的另一个保证是,任何一个客户端的请求,都会按照客户端指定的顺序来执行,论文里称之为FIFO(First In First Out)客户端序列。\n这里的意思是,如果一个特定的客户端发送了一个写请求之后是一个读请求或者任意请求,那么首先,所有的写请求会以这个客户端发送的相对顺序,加入到所有客户端的写请求中(满足保证1)。所以,如果一个客户端说,先完成这个写操作,再完成另一个写操作,之后是第三个写操作,那么在最终整体的写请求的序列中,可以看到这个客户端的写请求以相同顺序出现(虽然可能不是相邻的)。所以,对于写请求,最终会以客户端确定的顺序执行。\n这里实际上是服务端需要考虑的问题,因为客户端是可以发送异步的写请求,也就是说客户端可以发送多个写请求给Zookeeper Leader节点,而不用等任何一个请求完成。Zookeeper论文并没有明确说明,但是可以假设,为了让Leader可以实际的按照客户端确定的顺序执行写请求,我设想,客户端实际上会对它的写请求打上序号,表明它先执行这个,再执行这个,第三个是这个,而Zookeeper Leader节点会遵从这个顺序。这里由于有这些异步的写请求变得非常有意思。\n读请求 对于读请求,这里会更加复杂一些。我之前说过,读请求不需要经过Leader,只有写请求经过Leader,读请求只会到达某个副本。所以,读请求只能看到那个副本的Log对应的状态。对于读请求,我们应该这么考虑FIFO客户端序列,客户端会以某种顺序读某个数据,之后读第二个数据,之后是第三个数据,对于那个副本上的Log来说,每一个读请求必然要在Log的某个特定的点执行,或者说每个读请求都可以在Log一个特定的点观察到对应的状态。\n然后,后续的读请求,必须要在不早于当前读请求对应的Log点执行。也就是一个客户端发起了两个读请求,如果第一个读请求在Log中的一个位置执行,那么第二个读请求只允许在第一个读请求对应的位置或者更后的位置执行。 第二个读请求不允许看到之前的状态,第二个读请求至少要看到第一个读请求的状态。这是一个极其重要的事实,我们会用它来实现正确的Zookeeper应用程序。\n这里特别有意思的是,如果一个客户端正在与一个副本交互,客户端发送了一些读请求给这个副本,之后这个副本故障了,客户端需要将读请求发送给另一个副本。这时,尽管客户端切换到了一个新的副本,FIFO客户端序列仍然有效。所以这意味着,如果你知道在故障前,客户端在一个副本执行了一个读请求并看到了对应于Log中这个点的状态,\n客户端请求副本发生变化 当客户端切换到了一个新的副本并且发起了另一个读请求,假设之前的读请求在这里执行, 那么尽管客户端切换到了一个新的副本,客户端的在新的副本的读请求,必须在Log这个点或者之后的点执行。\n这里工作的原理是,每个Log条目都会被Leader打上zxid的标签,这些标签就是Log对应的条目号。任何时候一个副本回复一个客户端的读请求,首先这个读请求是在Log的某个特定点执行的,其次回复里面会带上zxid,对应的就是Log中执行点的前一条Log条目号。客户端会记住最高的zxid,当客户端发出一个请求到一个相同或者不同的副本时,它会在它的请求中带上这个最高的zxid。这样,其他的副本就知道,应该至少在Log中这个点或者之后执行这个读请求。这里有个有趣的场景,如果第二个副本并没有最新的Log,当它从客户端收到一个请求,客户端说,上一次我的读请求在其他副本Log的这个位置执行,\n那么在获取到对应这个位置的Log之前,这个副本不能响应客户端请求。\n我不是很清楚这里具体怎么工作,但是要么副本阻塞了对于客户端的响应,要么副本拒绝了客户端的读请求并说:我并不了解这些信息,去问问其他的副本,或者过会再来问我。 最终,如果这个副本连上了Leader,它会更新上最新的Log,到那个时候,这个副本就可以响应读请求了。好的,所以读请求都是有序的,它们的顺序与时间正相关。\n更进一步,FIFO客户端请求序列是对一个客户端的所有读请求,写请求生效。所以,如果我发送一个写请求给Leader,在Leader commit这个请求之前需要消耗一些时间,所以我现在给Leader发了一个写请求,而Leader还没有处理完它,或者commit它。之后,我发送了一个读请求给某个副本。这个读请求需要暂缓一下,以确保FIFO客户端请求序列。读请求需要暂缓,直到这个副本发现之前的写请求已经执行了。这是FIFO客户端请求序列的必然结果,(对于某个特定的客户端)读写请求是线性一致的。\n最明显的理解这种行为的方式是,如果一个客户端写了一份数据,例如向Leader发送了一个写请求,之后立即读同一份数据,并将读请求发送给了某一个副本,那么客户端需要看到自己刚刚写入的值。如果我写了某个变量为17,那么我之后读这个变量,返回的不是17,这会很奇怪,这表明系统并没有执行我的请求。因为如果执行了的话,写请求应该在读请求之前执行。所以,副本必然有一些有意思的行为来暂缓客户端,比如当客户端发送一个读请求说,我上一次发送给Leader的写请求对应了zxid是多少,这个副本必须等到自己看到对应zxid的写请求再执行读请求。\n学生提问 学生提问:也就是说,从Zookeeper读到的数据不能保证是最新的? Robert教授:完全正确。我认为你说的是,从一个副本读取的或许不是最新的数据,所以Leader或许已经向过半服务器发送了C,并commit了,过半服务器也执行了这个请求。但是这个副本并不在Leader的过半服务器中,所以或许这个副本没有最新的数据。这就是Zookeeper的工作方式,它并不保证我们可以看到最新的数据。Zookeeper可以保证读写有序,但是只针对一个客户端来说。所以,如果我发送了一个写请求,之后我读取相同的数据,Zookeeper系统可以保证读请求可以读到我之前写入的数据。但是,如果你发送了一个写请求,之后我读取相同的数据,并没有保证说我可以看到你写入的数据。这就是Zookeeper可以根据副本的数量加速读请求的基础。\n学生提问:那么Zookeeper究竟是不是线性一致呢? Robert教授:我认为Zookeeper不是线性一致的,但是又不是完全的非线性一致。首先,所有客户端发送的请求以一个特定的序列执行,所以,某种意义上来说,所有的写请求是线性一致的。同时,每一个客户端的所有请求或许也可以认为是线性一致的。尽管我不是很确定,Zookeeper的一致性保证的第二条可以理解为,单个客户端的请求是线性一致的。\n学生提问:zxid必须要等到写请求执行完成才返回吗? Robert教授:实际上,我不知道它具体怎么工作,但是这是个合理的假设。当我发送了异步的写请求,系统并没有执行这些请求,但是系统会回复我说,好的,我收到了你的写请求,如果它最后commit了,这将会是对应的zxid。所以这里是一个合理的假设,我实际上不知道这里怎么工作。之后如果客户端执行读请求,就可以告诉一个副本说,这个zxid是我之前发送的一个写请求。\n学生提问:Log中的zxid怎么反应到key-value数据库的状态呢? Robert教授:如果你向一个副本发送读请求,理论上,客户端会认为副本返回的实际上是Table中的值。所以,客户端说,我只想从这个Table读这一行,这个副本会将其当前状态中Table中对应的值和上次更新Table的zxid返回给客户端。 我不太确定,这里有两种可能,我认为任何一种都可以。第一个是,每个服务器可以跟踪修改每一行Table数值的写请求对应的zxid(这样可以读哪一行就返回相应的zxid);另一个是,服务器可以为所有的读请求返回Log中最近一次commit的zxid,不论最近一次请求是不是更新了当前读取的Table中的行。因为,我们只需要确认客户端请求在Log中的执行点是一直向前推进,所以对于读请求,我们只需要返回大于修改了Table中对应行的写请求对应的zxid即可。\n好的,这些是Zookeeper的一致性保证。\nZookeeper API Zookeeper的API设计使得它可以成为一个通用的服务,从而分担一个分布式系统所需要的大量工作。那么为什么Zookeeper的API是一个好的设计?具体来看,因为它实现了一个值得去了解的概念:mini-transaction.\n我们回忆一下Zookeeper的特点:\nZookeeper基于(类似于)Raft框架,所以我们可以认为它是,当然它的确是容错的,它在发生网络分区的时候,也能有正确的行为。 当我们在分析各种Zookeeper的应用时,我们也需要记住Zookeeper有一些性能增强,使得读请求可以在任何副本被处理,因此,可能会返回旧数据。 另一方面,Zookeeper可以确保一次只处理一个写请求,并且所有的副本都能看到一致的写请求顺序。这样,所有副本的状态才能保证是一致的(写请求会改变状态,一致的写请求顺序可以保证状态一致)。 由一个客户端发出的所有读写请求会按照客户端发出的顺序执行。 一个特定客户端的连续请求,后来的请求总是能看到相比较于前一个请求相同或者更晚的状态(详见8.5 FIFO客户端序列)。 detail\n","permalink":"https://reid00.github.io/en/posts/storage/zookeeper%E4%B8%80%E8%87%B4%E4%BF%9D%E8%AF%81/","summary":"Zookpeer 的先行一致性介绍 Zookeeper的确有一些一致性的保证,用来帮助那些使用基于Zookeeper开发应用程序的人,来理解他们的应用程序,以","title":"Zookeeper一致保证"},{"content":"介绍 我曾经有过的所有这些对生命周期的误解,现在有很多初学者也深陷于此。 我用到的术语可能不是标准的,所以下面列了一个表格来解释它们的用意。\n短语 意为 T 包含了所有可能类型的集合 或 这个集合中的类型 所有权类型 不含引用的类型, 例如 i32, String, Vec, 等 借用类型 或 引用类型 不考虑可变性的引用类型, 例如 \u0026amp;i32, \u0026amp;mut i32 等 可变引用 或 独占引用 独占的可变引用, 即 \u0026amp;mut T 不可变引用 或 共享引用 共享的不可变引用, 即 \u0026amp;T 误解项 简而言之:变量的生命周期指的是这个变量所指的数据可以被编译器静态验证的、在当前内存地址有效期的长度。\n误解1: T 只包含所有权类型 这个误解比起说生命周期,它和泛型更相关,但在Rust中泛型和生命周期是紧密联系在一起的,不可只谈其一。\n当我刚开始学习Rust的时候,我理解i32,\u0026amp;i32,和\u0026amp;mut i32是不同的类型,也明白泛型变量T代表着所有可能类型的集合。 但尽管这二者分开都懂,当它们结合在一起的时候我却陷入困惑。在我这个Rust初学者的眼中,泛型是这样的运作的:\n类型变量 T \u0026amp;T \u0026amp;mut T 例子 i32 \u0026amp;i32 \u0026amp;mut i32 T 包含一切所有权类型; \u0026amp;T 包含一切不可变借用类型; \u0026amp;mut T 包含一切可变借用类型。 T, \u0026amp;T, 和 \u0026amp;mut T 是不相交的有限集。 简洁明了,符合直觉,但却完全错误。 下面这才是泛型真正的运作方式: 类型变量 T \u0026amp;T \u0026amp;mut T 例子 i32, \u0026amp;i32, \u0026amp;mut i32, \u0026amp;\u0026amp;i32, \u0026amp;mut \u0026amp;mut i32, \u0026hellip; \u0026amp;i32, \u0026amp;\u0026amp;i32, \u0026amp;\u0026amp;mut i32, \u0026hellip; \u0026amp;mut i32, \u0026amp;mut \u0026amp;mut i32, \u0026amp;mut \u0026amp;i32, \u0026hellip; T, \u0026amp;T, 和 \u0026amp;mut T 都是无限集, 因为你可以无限借用一个类型。 T 是 \u0026amp;T 和 \u0026amp;mut T的超集. \u0026amp;T 和 \u0026amp;mut T 是不相交的集合。 让我们用几个例子来检验一下这些概念:\n1 2 3 4 5 6 7 trait Trait {} impl\u0026lt;T\u0026gt; Trait for T {} impl\u0026lt;T\u0026gt; Trait for \u0026amp;T {} // 编译错误 impl\u0026lt;T\u0026gt; Trait for \u0026amp;mut T {} // 编译错误 上面的代码并不能如愿编译:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 error[E0119]: conflicting implementations of trait `Trait` for type `\u0026amp;_`: --\u0026gt; src/lib.rs:5:1 | 3 | impl\u0026lt;T\u0026gt; Trait for T {} | ------------------- first implementation here 4 | 5 | impl\u0026lt;T\u0026gt; Trait for \u0026amp;T {} | ^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `\u0026amp;_` error[E0119]: conflicting implementations of trait `Trait` for type `\u0026amp;mut _`: --\u0026gt; src/lib.rs:7:1 | 3 | impl\u0026lt;T\u0026gt; Trait for T {} | ------------------- first implementation here ... 7 | impl\u0026lt;T\u0026gt; Trait for \u0026amp;mut T {} | ^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `\u0026amp;mut _ 编译器不允许我们为\u0026amp;T和\u0026amp;mut T实现Trait,因为这样会与为T实现的Trait冲突, T本身已经包含了所有\u0026amp;T和\u0026amp;mut T。下面的代码能够如愿编译,因为\u0026amp;T和\u0026amp;mut T是不相交的:\n1 2 3 4 5 trait Trait {} impl\u0026lt;T\u0026gt; Trait for \u0026amp;T {} // 编译通过 impl\u0026lt;T\u0026gt; Trait for \u0026amp;mut T {} // 编译通过 要点:\nT 是 \u0026amp;T 和 \u0026amp;mut T的超集 \u0026amp;T 和 \u0026amp;mut T 是不相交的集合 误解2: 如果 T: \u0026lsquo;static 那么 T 必须在整个程序运行中都是有效的 误解推论\nT: \u0026lsquo;static 应该被看作 \u0026quot; T 拥有 \u0026lsquo;static 生命周期 \u0026quot; \u0026amp;\u0026lsquo;static T 和 T: \u0026lsquo;static 没有区别 如果 T: \u0026lsquo;static 那么 T 必须为不可变的 如果 T: \u0026lsquo;static 那么 T 只能在编译期创建 大部分Rust初学者是从类似下面这个代码示例中接触到 \u0026lsquo;static 生命周期的:\n1 2 3 fn main() { let str_literal: \u0026amp;\u0026#39;static str = \u0026#34;str literal\u0026#34;; } 他们被告知 \u0026ldquo;str literal\u0026rdquo; 是硬编码在编译出来的二进制文件中的, 并会在运行时被加载到只读内存,所以必须是不可变的且在整个程序的运行中都是有效的, 这就是它成为 \u0026lsquo;static 的原因。 而这些观念又进一步被用 static 关键字来定义静态变量的规则所加强。\n1 2 3 4 5 6 7 8 9 10 11 static BYTES: [u8; 3] = [1, 2, 3]; static mut MUT_BYTES: [u8; 3] = [1, 2, 3]; fn main() { MUT_BYTES[0] = 99; // 编译错误,修改静态变量是unsafe的 unsafe { MUT_BYTES[0] = 99; assert_eq!(99, MUT_BYTES[0]); } } 认为静态变量\n只可以在编译期创建 必须是不可变的,修改它们是unsafe的 在整个程序的运行过程中都是有效的 \u0026lsquo;static 生命周期大概是以静态变量的默认生命周期命名的,对吧? 那么有理由认为\u0026rsquo;static生命周期也应该遵守相同的规则,不是吗?\n是的,但拥有'static生命周期的类型与'static约束的类型是不同的。 后者能在运行时动态分配,可以安全地、自由地修改,可以被drop, 还可以有任意长度的生命周期。\n在这个点,很重要的是要区分 \u0026amp;'static T 和 T: 'static。\n\u0026amp;'static T 是对某个T的不可变引用,这个引用可以被无限期地持有直到程序结束。 这只可能发生在T本身不可变且不会在引用被创建后移动的情况下。 T并不需要在编译期就被创建,因为我们可以在运行时动态生成随机数据, 然后以内存泄漏为代价返回\u0026rsquo;static引用,例如:\n1 2 3 4 5 6 7 use rand; // 在运行时生成随机\u0026amp;\u0026#39;static str fn rand_str_generator() -\u0026gt; \u0026amp;\u0026#39;static str { let rand_string = rand::random::\u0026lt;u64\u0026gt;().to_string(); Box::leak(rand_string.into_boxed_str()) } T: 'static 是指T可以被无限期安全地持有直到程序结束。 T: \u0026lsquo;static包括所有\u0026amp;\u0026lsquo;static T,此外还包括所有的所有权类型,比如String, Vec等。 数据的所有者能够保证数据只要还被持有就不会失效,因此所有者可以无限期安全地持有该数据直到程序结束。 T: 'static应该被看作T受'static生命周期 \u0026quot;约束\u0026quot; 而非 T有着'static生命周期。 这段代码能帮我们阐释这些概念:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 use rand; fn drop_static\u0026lt;T: \u0026#39;static\u0026gt;(t: T) { std::mem::drop(t); } fn main() { let mut strings: Vec\u0026lt;String\u0026gt; = Vec::new(); for _ in 0..10 { if rand::random() { // 所有字符串都是随机生成的 // 并且是在运行时动态申请的 let string = rand::random::\u0026lt;u64\u0026gt;().to_string(); strings.push(string); } } // 这些字符串都是所有权类型,所以它们满足\u0026#39;static约束 for mut string in strings { // 这些字符串都是可以修改的 string.push_str(\u0026#34;a mutation\u0026#34;); // 这些字符串都是可以被drop的 drop_static(string); // 编译通过 } // 这些字符串都在程序结束之前失效 println!(\u0026#34;i am the end of the program\u0026#34;); } 要点:\nT: \u0026lsquo;static 应该被看作 “T受\u0026rsquo;static生命周期约束” 如果 T: \u0026lsquo;static 那么T可以是有着\u0026rsquo;static生命周期的借用类型 由于 T: \u0026lsquo;static 包括了所有权类型,这意味着T 可以在运行时动态分配 不一定要在整个程序的运行过程中都有效 可以被安全地、自由地修改 可以在运行时被动态drop掉 可以有不同长度的生命周期 误解3: \u0026amp;\u0026lsquo;a T 和 T: \u0026lsquo;a 是相同的 这个误解是上一个的泛化版本。\n\u0026amp;\u0026lsquo;a T 不光要求,同时也隐含着 T: \u0026lsquo;a, 因为如果T本身都不能在'a内有效, 那对T的有'a生命周期的引用也不可能是有效的。 例如,Rust编译器从来不会允许创建\u0026amp;\u0026lsquo;static Ref\u0026lt;\u0026lsquo;a, T\u0026gt;这个类型,因为如果Ref只在\u0026rsquo;a内有效,我们不可能弄出一个对它的\u0026rsquo;static的引用。\nT: 'a包括了所有\u0026amp;'a T,但反过来不对。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // 只接受以\u0026#39;a约束的引用类型 fn t_ref\u0026lt;\u0026#39;a, T: \u0026#39;a\u0026gt;(t: \u0026amp;\u0026#39;a T) {} // 接受所有以\u0026#39;a约束的类型 fn t_bound\u0026lt;\u0026#39;a, T: \u0026#39;a\u0026gt;(t: T) {} // 包含引用的所有权类型 struct Ref\u0026lt;\u0026#39;a, T: \u0026#39;a\u0026gt;(\u0026amp;\u0026#39;a T); fn main() { let string = String::from(\u0026#34;string\u0026#34;); t_bound(\u0026amp;string); // 编译通过 t_bound(Ref(\u0026amp;string)); // 编译通过 t_bound(\u0026amp;Ref(\u0026amp;string)); // 编译通过 t_ref(\u0026amp;string); // 编译通过 t_ref(Ref(\u0026amp;string)); // 编译错误, 期待接收一个引用,但收到一个结构体 t_ref(\u0026amp;Ref(\u0026amp;string)); // 编译通过 // string变量是以\u0026#39;static约束的,也满足\u0026#39;a约束 t_bound(string); // 编译通过 } 要点:\nT: \u0026lsquo;a 比起 \u0026amp;\u0026lsquo;a T更泛化也更灵活 T: \u0026lsquo;a 接受所有权类型、包含引用的所有权类型以及引用 \u0026amp;\u0026lsquo;a T 只接受引用 如果 T: \u0026lsquo;static 那么 T: \u0026lsquo;a, 因为对于所有\u0026rsquo;a都有\u0026rsquo;static \u0026gt;= \u0026lsquo;a 误解4: 我的代码没用到泛型,也不含生命周期 误解推论:\n避免使用泛型和生命周期是可能的 这种安慰性的误解的存在是由于Rust的生命周期省略规则, 这些规则让你能够在函数中省略掉生命周期记号, 因为Rust的借用检查器能根据以下规则将它们推导出来:\n每个传入的引用都会有一个单独的生命周期 如果只有一个传入的生命周期,那么它将被应用到所有输出的引用上 如果有多个传入的生命周期,但其中一个是\u0026amp;self或者\u0026amp;mut self,那么这个生命周期将会被应用到所有输出的引用上 除此之外的输出的生命周期都必须显示标注出来 如果一时间难以想明白这么多东西,那让我们来看一些例子:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // 省略 fn print(s: \u0026amp;str); // 展开 fn print\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str); // 省略 fn trim(s: \u0026amp;str) -\u0026gt; \u0026amp;str; // 展开 fn trim\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; // 不合法,无法确定输出的生命周期,因为没有输入的 fn get_str() -\u0026gt; \u0026amp;str; // 显式的写法包括 fn get_str\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str; // 泛型版本 fn get_str() -\u0026gt; \u0026amp;\u0026#39;static str; // \u0026#39;static 版本 // 不合法,无法确定输出的生命周期,因为有多个输入 fn overlap(s: \u0026amp;str, t: \u0026amp;str) -\u0026gt; \u0026amp;str; // 显式(但仍有部分省略)的写法包括 fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输出生命周期不能长于s fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;str, t: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输出生命周期不能长于t fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输出生命周期不能长于s和t fn overlap(s: \u0026amp;str, t: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;static str; // 输出生命周期可以长于s和t fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;str, t: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;a str; // 输入和输出的生命周期无关 // 展开 fn overlap\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;a str; fn overlap\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;b str; fn overlap\u0026lt;\u0026#39;a\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str; fn overlap\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;static str; fn overlap\u0026lt;\u0026#39;a, \u0026#39;b, \u0026#39;c\u0026gt;(s: \u0026amp;\u0026#39;a str, t: \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;c str; // 省略 fn compare(\u0026amp;self, s: \u0026amp;str) -\u0026gt; \u0026amp;str; // 展开 fn compare\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt;(\u0026amp;\u0026#39;a self, \u0026amp;\u0026#39;b str) -\u0026gt; \u0026amp;\u0026#39;a str; 如果你曾写过\n结构体方法 接收引用的函数 返回引用的函数 泛型函数 trait object(后面会有更详细的讨论) 闭包(后面会有更详细的讨论) 那么你的代码就有被省略的泛型生命周期记号。 要点:\n几乎所有Rust代码都是泛型代码,到处都有被省略的生命周期记号 误解5: 如果编译能通过,那么我的生命周期标注就是正确的 误解推论\nRust对函数的的生命周期省略规则总是正确的 Rust的借用检查器在技术上和语义上总是正确的 Rust比我更了解我的程序的语义 Rust程序是有可能在技术上能通过编译,但语义上仍然是错的。来看一下这个例子: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct ByteIter\u0026lt;\u0026#39;a\u0026gt; { remainder: \u0026amp;\u0026#39;a [u8] } impl\u0026lt;\u0026#39;a\u0026gt; ByteIter\u0026lt;\u0026#39;a\u0026gt; { fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;\u0026amp;u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } fn main() { let mut bytes = ByteIter { remainder: b\u0026#34;1\u0026#34; }; assert_eq!(Some(\u0026amp;b\u0026#39;1\u0026#39;), bytes.next()); assert_eq!(None, bytes.next()); } ByteIter 是在字节切片上迭代的迭代器,为了简洁我们跳过对 Iterator trait的实现。 这看起来没什么问题,但如果我们想同时检查多个字节呢?\n1 2 3 4 5 6 7 8 fn main() { let mut bytes = ByteIter { remainder: b\u0026#34;1123\u0026#34; }; let byte_1 = bytes.next(); let byte_2 = bytes.next(); if byte_1 == byte_2 { // 做点什么 } } 啊哦!编译错误:\n1 2 3 4 5 6 7 8 9 10 error[E0499]: cannot borrow `bytes` as mutable more than once at a time --\u0026gt; src/main.rs:20:18 | 19 | let byte_1 = bytes.next(); | ----- first mutable borrow occurs here 20 | let byte_2 = bytes.next(); | ^^^^^ second mutable borrow occurs here 21 | if byte_1 == byte_2 { | ------ first borrow later used here 我觉得我们可以拷贝每一个字节。拷贝在我们处理字节的时候是可行的, 但当我们从 ByteIter 转向泛型切片迭代器用来迭代任意 \u0026amp;'a [T] 的时候 我们也会想到将来可能它会被应用到那些拷贝/克隆的代价很昂贵或根本不可能的类型上。 噢,我想我们对这没什么办法,代码能过编译,那么生命周期标记必然是对的不是吗?\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct ByteIter\u0026lt;\u0026#39;a\u0026gt; { remainder: \u0026amp;\u0026#39;a [u8] } impl\u0026lt;\u0026#39;a\u0026gt; ByteIter\u0026lt;\u0026#39;a\u0026gt; { fn next\u0026lt;\u0026#39;b\u0026gt;(\u0026amp;\u0026#39;b mut self) -\u0026gt; Option\u0026lt;\u0026amp;\u0026#39;b u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } 这一点帮助都没有,我仍然搞不明白。这里有个只有Rust专家才知道的小窍门: 给你的生命周期标记取个有描述性的名字。我们再试一次:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { remainder: \u0026amp;\u0026#39;remainder [u8] } impl\u0026lt;\u0026#39;remainder\u0026gt; ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { fn next\u0026lt;\u0026#39;mut_self\u0026gt;(\u0026amp;\u0026#39;mut_self mut self) -\u0026gt; Option\u0026lt;\u0026amp;\u0026#39;mut_self u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } 每个返回的字节都被用'mut_self标记了,但这些字节显然是来自于'remainder的, 让我们来改一下。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { remainder: \u0026amp;\u0026#39;remainder [u8] } impl\u0026lt;\u0026#39;remainder\u0026gt; ByteIter\u0026lt;\u0026#39;remainder\u0026gt; { fn next(\u0026amp;mut self) -\u0026gt; Option\u0026lt;\u0026amp;\u0026#39;remainder u8\u0026gt; { if self.remainder.is_empty() { None } else { let byte = \u0026amp;self.remainder[0]; self.remainder = \u0026amp;self.remainder[1..]; Some(byte) } } } fn main() { let mut bytes = ByteIter { remainder: b\u0026#34;1123\u0026#34; }; let byte_1 = bytes.next(); let byte_2 = bytes.next(); std::mem::drop(bytes); // 我们甚至可以在这里把迭代器drop掉! if byte_1 == byte_2 { // 编译通过 // 做点什么 } } 现在让我们回顾一下,我们前一版的程序显然是错误的,但为什么Rust仍然允许它通过编译呢? 答案很简单:这么做是内存安全的。\nRust的借用检查器对程序的生命周期标记只要求到能够以静态的方式验证程序的内存安全。 Rust会爽快地编译一个程序,即使它的生命周期标记有语义上的错误, 这带来的结果就是程序会变得过于受限。\n来看一个与前一个相反的例子:Rust的生命周期省略规则恰好在这个例子上语义是正确的, 但我们却无意中用了一些多余的显式生命周期标记写了个非常受限的方法。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #[derive(Debug)] struct NumRef\u0026lt;\u0026#39;a\u0026gt;(\u0026amp;\u0026#39;a i32); impl\u0026lt;\u0026#39;a\u0026gt; NumRef\u0026lt;\u0026#39;a\u0026gt; { // 我的结构体是在\u0026#39;a上泛型的,所以我同样也要 // 标记一下我的self参数,对吗?(答案是:不,不对) fn some_method(\u0026amp;\u0026#39;a mut self) {} } fn main() { let mut num_ref = NumRef(\u0026amp;5); num_ref.some_method(); // 可变借用num_ref直到它剩余的生命周期结束 num_ref.some_method(); // 编译错误 println!(\u0026#34;{:?}\u0026#34;, num_ref); // 同样编译错误 } 如果我们有一个在 \u0026lsquo;a 上的泛型,我们几乎永远不会想要写一个接收\u0026amp;'a mut self的方法。 因为这意味着我们告诉Rust,这个方法会可变借用这个结构体直到整个结构体生命周期结束。 这也就告诉Rust的借用检查器最多只允许 some_method 被调用一次, 在这之后这个结构体将会被永久性地可变借用走,也就变得不可用了。 这样的用例非常非常少,但处于困惑中的初学者非常容易写出这种代码,并能通过编译。 正确的做法是不要添加这些多余的显式生命周期标记,让Rust的生命周期省略规则来处理它:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 #[derive(Debug)] struct NumRef\u0026lt;\u0026#39;a\u0026gt;(\u0026amp;\u0026#39;a i32); impl\u0026lt;\u0026#39;a\u0026gt; NumRef\u0026lt;\u0026#39;a\u0026gt; { // 去掉mut self前面的\u0026#39;a fn some_method(\u0026amp;mut self) {} // 上一段代码脱掉语法糖后变为 fn some_method_desugared\u0026lt;\u0026#39;b\u0026gt;(\u0026amp;\u0026#39;b mut self){} } fn main() { let mut num_ref = NumRef(\u0026amp;5); num_ref.some_method(); num_ref.some_method(); // 编译通过 println!(\u0026#34;{:?}\u0026#34;, num_ref); // 编译通过 } 要点:\nRust的函数生命周期省略规则并不总是对所有情况都正确的 Rust对你的程序的语义了解并不比你多 给你的生命周期标记起一个更有描述性的名字 在你使用显式生命周期标记的时候要想清楚它们应该被用在哪以及为什么要这么用 误解6: 装箱的trait对象没有生命周期 早前我们讨论了Rust对函数的生命周期省略规则。Rust同样有着对于trait对象的生命周期省略规则,它们是:\n如果一个trait对象作为一个类型参数传递到泛型中,那么它的生命约束会从它包含的类型中推断 如果包含的类型中有唯一的约束,那么就使用这个约束。 如果包含的类型中有超过一个约束,那么必须显式指定约束。 如果以上都不适用,那么:\n如果trait是以单个生命周期约束定义的,那么就使用这个约束 如果所有生命周期约束都是 \u0026lsquo;static 的,那么就使用 \u0026lsquo;static 作为约束 如果trait没有生命周期约束,那么它的生命周期将会从表达式中推断,如果不在表达式中,那么就是 \u0026lsquo;static 的 这么多东西听起来超级复杂,但我们可以简单地总结为 \u0026ldquo;trait对象的生命周期约束是从上下文中推断出来的。\u0026rdquo; 在我们看过几个例子后,我们会发现生命周期约束推断其实是很符合直觉的,我们不需要去记这些很正式的规则。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 use std::cell::Ref; trait Trait {} // 省略 type T1 = Box\u0026lt;dyn Trait\u0026gt;; // 展开,Box\u0026lt;T\u0026gt;对T没有生命周期约束,所以被推断为\u0026#39;static type T2 = Box\u0026lt;dyn Trait + \u0026#39;static\u0026gt;; // 省略 impl dyn Trait {} // 展开 impl dyn Trait + \u0026#39;static {} // 省略 type T3\u0026lt;\u0026#39;a\u0026gt; = \u0026amp;\u0026#39;a dyn Trait; // 展开, 因为\u0026amp;\u0026#39;a T 要求 T: \u0026#39;a, 所以推断为 \u0026#39;a type T4\u0026lt;\u0026#39;a\u0026gt; = \u0026amp;\u0026#39;a (dyn Trait + \u0026#39;a); // 省略 type T5\u0026lt;\u0026#39;a\u0026gt; = Ref\u0026lt;\u0026#39;a, dyn Trait\u0026gt;; // 展开, 因为Ref\u0026lt;\u0026#39;a, T\u0026gt; 要求 T: \u0026#39;a, 所以推断为 \u0026#39;a type T6\u0026lt;\u0026#39;a\u0026gt; = Ref\u0026lt;\u0026#39;a, dyn Trait + \u0026#39;a\u0026gt;; trait GenericTrait\u0026lt;\u0026#39;a\u0026gt;: \u0026#39;a {} // 省略 type T7\u0026lt;\u0026#39;a\u0026gt; = Box\u0026lt;dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt;\u0026gt;; // 展开 type T8\u0026lt;\u0026#39;a\u0026gt; = Box\u0026lt;dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt; + \u0026#39;a\u0026gt;; // 省略 impl\u0026lt;\u0026#39;a\u0026gt; dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt; {} // 展开 impl\u0026lt;\u0026#39;a\u0026gt; dyn GenericTrait\u0026lt;\u0026#39;a\u0026gt; + \u0026#39;a {} 实现了某个trait的具体的类型可以包含引用,因此它们同样拥有生命周期约束,且对应的trait对象也有生命周期约束。 你也可以直接为引用实现trait,而引用显然有生命周期约束。\n1 2 3 4 5 6 7 8 trait Trait {} struct Struct {} struct Ref\u0026lt;\u0026#39;a, T\u0026gt;(\u0026amp;\u0026#39;a T); impl Trait for Struct {} impl Trait for \u0026amp;Struct {} // 直接在引用类型上实现Trait impl\u0026lt;\u0026#39;a, T\u0026gt; Trait for Ref\u0026lt;\u0026#39;a, T\u0026gt; {} // 在包含引用的类型上实现Trait 不管怎样,这都值得我们仔细研究,因为新手们经常在将一个使用trait对象的函数重构成使用泛型的函数(或者反过来)的时候感到困惑。 我们来看看这个例子:\n1 2 3 4 5 6 7 8 9 10 11 12 13 use std::fmt::Display; fn dynamic_thread_print(t: Box\u0026lt;dyn Display + Send\u0026gt;) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } fn static_thread_print\u0026lt;T: Display + Send\u0026gt;(t: T) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } 这会抛出下面的编译错误:\n1 2 3 4 5 6 7 8 9 10 11 12 13 error[E0310]: the parameter type `T` may not live long enough --\u0026gt; src/lib.rs:10:5 | 9 | fn static_thread_print\u0026lt;T: Display + Send\u0026gt;(t: T) { | -- help: consider adding an explicit lifetime bound...: `T: \u0026#39;static +` 10 | std::thread::spawn(move || { | ^^^^^^^^^^^^^^^^^^ | note: ...so that the type `[closure@src/lib.rs:10:24: 12:6 t:T]` will meet its required lifetime bounds --\u0026gt; src/lib.rs:10:5 | 10 | std::thread::spawn(move || { | ^^^^^^^^^^^^^^^^^^ 很好,编译器告诉了我们怎么解决这个问题,我们来试试。\n1 2 3 4 5 6 7 8 9 10 11 12 13 use std::fmt::Display; fn dynamic_thread_print(t: Box\u0026lt;dyn Display + Send\u0026gt;) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } fn static_thread_print\u0026lt;T: Display + Send + \u0026#39;static\u0026gt;(t: T) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } 编译通过,但这两个函数放在一块儿看起来有点怪,为什么第二个函数对 T 有 \u0026lsquo;static 约束,而第一个没有? 这个问题很刁钻。根据生命周期省略规则,Rust自动为第一个函数推断出 \u0026lsquo;static 约束,所以两个函数实际上都有 \u0026lsquo;static 约束。 在Rust编译器的眼中是这样的:\n1 2 3 4 5 6 7 8 9 10 11 12 13 use std::fmt::Display; fn dynamic_thread_print(t: Box\u0026lt;dyn Display + Send + \u0026#39;static\u0026gt;) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } fn static_thread_print\u0026lt;T: Display + Send + \u0026#39;static\u0026gt;(t: T) { std::thread::spawn(move || { println!(\u0026#34;{}\u0026#34;, t); }).join(); } 要点:\n所有trait对象都有着默认推断的生命周期约束 误解7: 编译器报错信息会告诉我怎么修改我的代码 误解推论\nRust编译器对于trait objects的生命周期省略规则总是对的 Rust编译器比我更懂我代码的语义 这个误解是前两个误解的合二为一的例子:\n1 2 3 4 5 use std::fmt::Display; fn box_displayable\u0026lt;T: Display\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display\u0026gt; { Box::new(t) } 抛出如下错误:\n1 2 3 4 5 6 7 8 9 10 11 12 13 error[E0310]: the parameter type `T` may not live long enough --\u0026gt; src/lib.rs:4:5 | 3 | fn box_displayable\u0026lt;T: Display\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display\u0026gt; { | -- help: consider adding an explicit lifetime bound...: `T: \u0026#39;static +` 4 | Box::new(t) | ^^^^^^^^^^^ | note: ...so that the type `T` will meet its required lifetime bounds --\u0026gt; src/lib.rs:4:5 | 4 | Box::new(t) | ^^^^^^^^^^^ 好吧,让我们照着编译器告诉我们的方式修改它,别在意这种改法基于了一个没有告知的事实: 编译器自动为我们的boxed trait object推断了一个\u0026rsquo;static的生命周期约束。\n1 2 3 4 5 use std::fmt::Display; fn box_displayable\u0026lt;T: Display + \u0026#39;static\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display\u0026gt; { Box::new(t) } 现在编译通过了,但这真的是我们想要的吗?可能是,也可能不是,编译去并没有告诉我们其它解决方法 但这个也许合适。\n1 2 3 4 5 use std::fmt::Display; fn box_displayable\u0026lt;\u0026#39;a, T: Display + \u0026#39;a\u0026gt;(t: T) -\u0026gt; Box\u0026lt;dyn Display + \u0026#39;a\u0026gt; { Box::new(t) } 这个函数接收的参数和前一个版本一样,但多了不少东西。这样写能让它更好吗?不一定, 这取决于我们的程序的要求和约束。这个例子有些抽象,让我们来看看更简单明了的情况。\n1 2 3 fn return_first(a: \u0026amp;str, b: \u0026amp;str) -\u0026gt; \u0026amp;str { a } 报错:\n1 2 3 4 5 6 7 8 9 10 11 error[E0106]: missing lifetime specifier --\u0026gt; src/lib.rs:1:38 | 1 | fn return_first(a: \u0026amp;str, b: \u0026amp;str) -\u0026gt; \u0026amp;str { | ---- ---- ^ expected named lifetime parameter | = help: this function\u0026#39;s return type contains a borrowed value, but the signature does not say whether it is borrowed from `a` or `b` help: consider introducing a named lifetime parameter | 1 | fn return_first\u0026lt;\u0026#39;a\u0026gt;(a: \u0026amp;\u0026#39;a str, b: \u0026amp;\u0026#39;a str) -\u0026gt; \u0026amp;\u0026#39;a str { | ^^^^ ^^^^^^^ ^^^^^^^ ^^^ 这个错误信息建议我们给输入和输出打上相同的生命周期标记。 这么做虽然能使得编译通过,但却过度限制了返回类型。 我们真正想要的是这个:\n1 2 3 fn return_first\u0026lt;\u0026#39;a\u0026gt;(a: \u0026amp;\u0026#39;a str, b: \u0026amp;str) -\u0026gt; \u0026amp;\u0026#39;a str { a } 要点:\nRust对trait object的生命周期省略规则并不是在所有情况下都正确。 Rust不见得比你更懂你代码的语义。 Rust编译错误信息给出的修改建议可能能让你的代码编译通过,但这不一定是最符合你的要求的。 误解8: 生命周期可以在运行时变长缩短 误解推论\n容器类型可以通过更换引用在运行时更改自己的生命周期 Rust的借用检查会进行深入的控制流分析 这过不了编译:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 struct Has\u0026lt;\u0026#39;lifetime\u0026gt; { lifetime: \u0026amp;\u0026#39;lifetime str, } fn main() { let long = String::from(\u0026#34;long\u0026#34;); let mut has = Has { lifetime: \u0026amp;long }; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); { let short = String::from(\u0026#34;short\u0026#34;); // 换成短生命周期 has.lifetime = \u0026amp;short; assert_eq!(has.lifetime, \u0026#34;short\u0026#34;); // 换回长生命周期(并不行) has.lifetime = \u0026amp;long; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); // `short`在这里析构 } // 编译错误,`short`在析构后仍处于借用状态 assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); } 报错:\n1 2 3 4 5 6 7 8 9 10 error[E0597]: `short` does not live long enough --\u0026gt; src/main.rs:11:24 | 11 | has.lifetime = \u0026amp;short; | ^^^^^^ borrowed value does not live long enough ... 15 | } | - `short` dropped here while still borrowed 16 | assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); | --------------------------------- borrow later used here 下面这个代码同样过不了编译,报的错和上面一样。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 struct Has\u0026lt;\u0026#39;lifetime\u0026gt; { lifetime: \u0026amp;\u0026#39;lifetime str, } fn main() { let long = String::from(\u0026#34;long\u0026#34;); let mut has = Has { lifetime: \u0026amp;long }; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); // 这个代码块不会被执行 if false { let short = String::from(\u0026#34;short\u0026#34;); // 换成短生命周期 has.lifetime = \u0026amp;short; assert_eq!(has.lifetime, \u0026#34;short\u0026#34;); // 换回长生命周期(并不行) has.lifetime = \u0026amp;long; assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); // `short`在这里析构 } // 仍旧编译错误,`short`在析构后仍处于借用状态 assert_eq!(has.lifetime, \u0026#34;long\u0026#34;); } 生命周期只会在编译期被静态验证,并且Rust的借用检查只能做到基本的控制流分析, 它假设每个if-else中的代码块和match的每个分支都会被执行, 并且其中的每一个变量都能被指定一个最短的生命周期。 一旦变量被指定了一个生命周期,它就一直受到这个生命周期约束。变量的生命周期只能缩短, 并且所有缩短都会在编译器被确定。\n要点:\n生命周期是在编译期静态验证的 生命周期不能在运行时变长、缩短或者改变 Rust的借用检查总是会为所有变量指定一个最短可能的生命周期,并且假定所有代码路径都会被执行 误解9: 将可变引用降级为共享引用是安全的 误解推论\n重新借用一个引用会终止它的生命周期并且开始一个新的 你可以向一个接收共享引用的函数传递一个可变引用,因为Rust会隐式将可变引用重新借用为不可变引用:\n1 2 3 4 5 6 7 fn takes_shared_ref(n: \u0026amp;i32) {} fn main() { let mut a = 10; takes_shared_ref(\u0026amp;mut a); // 编译通过 takes_shared_ref(\u0026amp;*(\u0026amp;mut a)); // 上一行的显式写法 } 直觉上这没问题,将一个可变引用重新借用为不可变引用,应该不会有什么害处不是吗? 然而并非如此,下面的代码过不了编译。\n1 2 3 4 5 6 fn main() { let mut a = 10; let b: \u0026amp;i32 = \u0026amp;*(\u0026amp;mut a); // 重新借用为不可变 let c: \u0026amp;i32 = \u0026amp;a; dbg!(b, c); // 编译错误 } 报错:\n1 2 3 4 5 6 7 8 9 error[E0502]: cannot borrow `a` as immutable because it is also borrowed as mutable --\u0026gt; src/main.rs:4:19 | 3 | let b: \u0026amp;i32 = \u0026amp;*(\u0026amp;mut a); | -------- mutable borrow occurs here 4 | let c: \u0026amp;i32 = \u0026amp;a; | ^^ immutable borrow occurs here 5 | dbg!(b, c); | - mutable borrow later used here 可变借用出现后立即重新借用为不可变引用,然后可变引用自身析构。 为什么Rust会认为这个不可变的重新借用仍具有可变引用的独占生命周期? 虽然上面这个例子没什么问题,但允许将可变引用降级为共享引用实际上引入了潜在的内存安全问题。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 use std::sync::Mutex; struct Struct { mutex: Mutex\u0026lt;String\u0026gt; } impl Struct { // 将self的可变引用降级为str的共享引用 fn get_string(\u0026amp;mut self) -\u0026gt; \u0026amp;str { self.mutex.get_mut().unwrap() } fn mutate_string(\u0026amp;self) { // 如果Rust允许将可变引用降级为共享引用, // 那么下面这行代码会使得所有从get_string中得到的共享引用失效 *self.mutex.lock().unwrap() = \u0026#34;surprise!\u0026#34;.to_owned(); } } fn main() { let mut s = Struct { mutex: Mutex::new(\u0026#34;string\u0026#34;.to_owned()) }; let str_ref = s.get_string(); // 可变引用降级为共享引用 s.mutate_string(); // str_ref失效,变为悬空指针 dbg!(str_ref); // 编译错误,和我们预期的一样 } 这里的问题在于,当你将一个可变引用重新借用为共享引用,你会遇到一点麻烦: 即使可变引用已经析构,重新借用出来的共享引用还是会将可变引用的生命周期延长到和自己一样长。 这种重新借用出来的共享引用非常难用,因为它不能与其它共享引用共存。 它有着可变引用和不可变引用的所有缺点,却没有它们各自的优点。 我认为将可变引用重新借用为共享引用应该被认为是Rust的反模式(anti-pattern)。 对这种反模式保持警惕很重要,这可以让你在看到下面这样的代码的时候更容易发现它:\n1 2 3 4 5 6 7 8 9 10 11 12 // 将T的可变引用降级为共享引用 fn some_function\u0026lt;T\u0026gt;(some_arg: \u0026amp;mut T) -\u0026gt; \u0026amp;T; struct Struct; impl Struct { // 将self的可变引用降级为self共享引用 fn some_method(\u0026amp;mut self) -\u0026gt; \u0026amp;self; // 将self的可变引用降级为T的共享引用 fn other_method(\u0026amp;mut self) -\u0026gt; \u0026amp;T; } 即使你避免了函数和方法签名中的重新借用,Rust仍然会自动隐式重新借用, 所以很容易无意中遇到这样的问题:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 use std::collections::HashMap; type PlayerID = i32; #[derive(Debug, Default)] struct Player { score: i32, } fn start_game(player_a: PlayerID, player_b: PlayerID, server: \u0026amp;mut HashMap\u0026lt;PlayerID, Player\u0026gt;) { // 从服务器中获取player,如果不存在则创建并插入一个新的 let player_a: \u0026amp;Player = server.entry(player_a).or_default(); let player_b: \u0026amp;Player = server.entry(player_b).or_default(); // 用player做点什么 dbg!(player_a, player_b); // 编译错误 } 上面的代码编译失败。因为 or_default() 返回一个 \u0026amp;mut Player, 而我们的显式类型标注 \u0026amp;Player 使得这个 \u0026amp;mut Player 被隐式重新借用为 \u0026amp;Player 。 为了通过编译,我们不得不这样写:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 use std::collections::HashMap; type PlayerID = i32; #[derive(Debug, Default)] struct Player { score: i32, } fn start_game(player_a: PlayerID, player_b: PlayerID, server: \u0026amp;mut HashMap\u0026lt;PlayerID, Player\u0026gt;) { // 因为我们不能把它们放在一起用,所以这里把返回的Player可变引用析构掉 server.entry(player_a).or_default(); server.entry(player_b).or_default(); // 再次获取这些Player,这次以不可变的方式,避免出现隐式重新借用 let player_a = server.get(\u0026amp;player_a); let player_b = server.get(\u0026amp;player_b); // 用Player做点什么 dbg!(player_a, player_b); // 编译通过 } 虽然有点尴尬和笨重,但这也算是为内存安全做出的牺牲。\n要点:\n尽量不要把可变引用重新借用为共享引用,不然你会遇到不少麻烦 重新借用一个可变引用不会使得它的生命周期终结,即使这个可变引用已经析构 误解10: 闭包遵循和函数相同的生命周期省略规则 比起误解,这更像是Rust的一个小陷阱。\n闭包,虽然也是个函数,但是它并不遵循和函数相同的生命周期省略规则。\n1 2 3 4 5 6 7 fn function(x: \u0026amp;i32) -\u0026gt; \u0026amp;i32 { x } fn main() { let closure = |x: \u0026amp;i32| x; } 报错:\n1 2 3 4 5 6 7 8 error: lifetime may not live long enough --\u0026gt; src/main.rs:6:29 | 6 | let closure = |x: \u0026amp;i32| x; | - - ^ returning this value requires that `\u0026#39;1` must outlive `\u0026#39;2` | | | | | return type of closure is \u0026amp;\u0026#39;2 i32 | let\u0026#39;s call the lifetime of this reference `\u0026#39;1` 去掉语法糖后:\n1 2 3 4 5 6 7 8 9 10 // 输入的生命周期应用到输出上 fn function\u0026lt;\u0026#39;a\u0026gt;(x: \u0026amp;\u0026#39;a i32) -\u0026gt; \u0026amp;\u0026#39;a i32 { x } fn main() { // 输入和输出有它们自己独有的生命周期 let closure = for\u0026lt;\u0026#39;a, \u0026#39;b\u0026gt; |x: \u0026amp;\u0026#39;a i32| -\u0026gt; \u0026amp;\u0026#39;b i32 { x }; // 注意:上面这行代码不是合法的语法,但可以表达出我们的意思 } 出现这种差异并没有一个好的理由。闭包最早的实现用的类型推断语义和函数不同, 现在变得没法改了,因为将它们统一起来会造成一个不兼容的改动。 那么我们要怎么样显式标注闭包的类型呢?我们可选的办法有:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 fn main() { // 转成trait object,变成不定长类型,编译错误 let identity: dyn Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 = |x: \u0026amp;i32| x; // 可以通过将它分配在堆上来绕过这个错误,但这样很笨重 let identity: Box\u0026lt;dyn Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32\u0026gt; = Box::new(|x: \u0026amp;i32| x); // 也可以跳过分配,直接创建一个静态的引用 let identity: \u0026amp;dyn Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 = \u0026amp;|x: \u0026amp;i32| x; // 上一行去掉语法糖之后:) let identity: \u0026amp;\u0026#39;static (dyn for\u0026lt;\u0026#39;a\u0026gt; Fn(\u0026amp;\u0026#39;a i32) -\u0026gt; \u0026amp;\u0026#39;a i32 + \u0026#39;static) = \u0026amp;|x: \u0026amp;i32| -\u0026gt; \u0026amp;i32 { x }; // 理想中的写法是这样的,但这不是有效的语法 let identity: impl Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 = |x: \u0026amp;i32| x; // 这样也不错,但也不是有效的语法 let identity = for\u0026lt;\u0026#39;a\u0026gt; |x: \u0026amp;\u0026#39;a i32| -\u0026gt; \u0026amp;\u0026#39;a i32 { x }; // \u0026#34;impl trait\u0026#34;可以写在函数返回的位置,我们也可以这样写 fn return_identity() -\u0026gt; impl Fn(\u0026amp;i32) -\u0026gt; \u0026amp;i32 { |x| x } let identity = return_identity(); // 前一种解决方案更泛化的写法 fn annotate\u0026lt;T, F\u0026gt;(f: F) -\u0026gt; F where F: Fn(\u0026amp;T) -\u0026gt; \u0026amp;T { f } let identity = annotate(|x: \u0026amp;i32| x); } 相信你已经注意到,在上面的例子中,当闭包类型使用trait约束的时候会遵循一般函数的生命周期省略规则。\n这里没有什么真正的教训和洞察,只是它就是这样的而已。\n要点:\n每一门语言都有自己的小陷阱 🤷 误解11: \u0026lsquo;static 引用总能强制转换为 \u0026lsquo;a 引用 我前面给出了这个例子:\n1 2 fn get_str\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str; // 泛型版本 fn get_str() -\u0026gt; \u0026amp;\u0026#39;static str; // \u0026#39;static版本 有的读者问我这两个在实践中是否有区别。一开始我也不太确定,但不幸的是, 在经过一段时间的研究之后我发现它们在实践中确实存在着区别。\n所以一般来说,在操作值得时候我们可以使用 \u0026lsquo;static 引用来替换 \u0026lsquo;a 引用, 因为Rust会自动将 \u0026lsquo;static 引用强制转换到 \u0026lsquo;a 引用。 直觉上来讲,这没毛病,在一个要求较短生命周期引用的地方使用一个有着更长的生命周期的引用不会造成内存安全问题。 下面的代码和我们想的一样编译通过:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 use rand; fn generic_str_fn\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str { \u0026#34;str\u0026#34; } fn static_str_fn() -\u0026gt; \u0026amp;\u0026#39;static str { \u0026#34;str\u0026#34; } fn a_or_b\u0026lt;T\u0026gt;(a: T, b: T) -\u0026gt; T { if rand::random() { a } else { b } } fn main() { let some_string = \u0026#34;string\u0026#34;.to_owned(); let some_str = \u0026amp;some_string[..]; let str_ref = a_or_b(some_str, generic_str_fn()); // 编译通过 let str_ref = a_or_b(some_str, static_str_fn()); // 编译通过 } 然而,这种强制转换并不会在引用作为函数的类型签名的一部分的时候出现,所以下面这段代码无法通过编译:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 use rand; fn generic_str_fn\u0026lt;\u0026#39;a\u0026gt;() -\u0026gt; \u0026amp;\u0026#39;a str { \u0026#34;str\u0026#34; } fn static_str_fn() -\u0026gt; \u0026amp;\u0026#39;static str { \u0026#34;str\u0026#34; } fn a_or_b_fn\u0026lt;T, F\u0026gt;(a: T, b_fn: F) -\u0026gt; T where F: Fn() -\u0026gt; T { if rand::random() { a } else { b_fn() } } fn main() { let some_string = \u0026#34;string\u0026#34;.to_owned(); let some_str = \u0026amp;some_string[..]; let str_ref = a_or_b_fn(some_str, generic_str_fn); // 编译通过 let str_ref = a_or_b_fn(some_str, static_str_fn); // 编译错误 } 报错:\n1 2 3 4 5 6 7 8 9 10 error[E0597]: `some_string` does not live long enough --\u0026gt; src/main.rs:23:21 | 23 | let some_str = \u0026amp;some_string[..]; | ^^^^^^^^^^^ borrowed value does not live long enough ... 25 | let str_ref = a_or_b_fn(some_str, static_str_fn); | ---------------------------------- argument requires that `some_string` is borrowed for `\u0026#39;static` 26 | } | - `some_string` dropped here while still borrowed 这算不算Rust的小陷阱还有待商榷,因为这不是 \u0026amp;\u0026lsquo;static str 到 \u0026amp;\u0026lsquo;a str 简单直接的强制转换, 而是 for Fn() -\u0026gt; \u0026amp;\u0026lsquo;static T 到 for\u0026lt;\u0026lsquo;a, T\u0026gt; Fn() -\u0026gt; \u0026amp;\u0026lsquo;a T 这种更复杂的情况。 前者是值之间的强制转换,后者是类型之间的强制转换。\n要点:\n签名为 for\u0026lt;\u0026lsquo;a, T\u0026gt; Fn() -\u0026gt; \u0026amp;\u0026lsquo;a T 的函数要比签名为 for fn() -\u0026gt; \u0026amp;\u0026lsquo;static T 的函数更为灵活,并且能用在更多场景下 总结: T 是 \u0026amp;T 和 \u0026amp;mut T 的超集 \u0026amp;T 和 \u0026amp;mut T 是不相交的集合 T: \u0026lsquo;static 应该被读作 \u0026ldquo;T 受 \u0026lsquo;static 生命周期约束\u0026rdquo; 如果 T: \u0026lsquo;static 那么 T 可以是一个有着 \u0026lsquo;static 生命周期的借用类型,或是一个所有权类型 既然 T: \u0026lsquo;static 包含了所有权类型,那么意味着 T 可以在运行时动态分配 不必在整个程序中都是有效的 可以被安全地任意修改 可以在运行时动态析构 可以有不同长度的生命周期 T: \u0026lsquo;a 比 \u0026amp;\u0026lsquo;a T 更泛化、灵活 T: \u0026lsquo;a 接收所有权类型、带引用的所有权类型,以及引用 \u0026amp;\u0026lsquo;a T 只接收引用 如果 T: \u0026lsquo;static 那么 T: \u0026lsquo;a,因为对于所有 \u0026lsquo;a 都有 \u0026lsquo;static \u0026gt;= \u0026lsquo;a 几乎所有Rust代码都是泛型的,到处都有省略的生命周期 Rust的生命周期省略规则并不是在任何情况下都对 Rust并不比你更了解你程序的语义 给生命周期标记起一个有描述性的名字 考虑清楚哪里需要显式写出生命周期标记,以及为什么要这么写 所有trait object都有默认推断的生命周期约束 Rust的编译错误信息可以让你的代码通过编译,但不一定是最符合你代码要求的 生命周期是在编译期静态验证的 生命周期不会以任何方式在运行时变长缩短 Rust的借用检查总会为每个变量选择一个最短可能的生命周期,并且假定每条代码路径都会被执行 尽量避免将可变引用重新借用为不可变引用,不然你会遇到不少麻烦 重新借用一个可变引用不会终止它的生命周期,即使这个可变引用已经析构 每个语言都有自己的小陷阱 ","permalink":"https://reid00.github.io/en/posts/langs_linux/rust%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E5%B8%B8%E8%A7%81%E8%AF%AF%E5%8C%BA/","summary":"介绍 我曾经有过的所有这些对生命周期的误解,现在有很多初学者也深陷于此。 我用到的术语可能不是标准的,所以下面列了一个表格来解释它们的用意。 短语","title":"Rust生命周期常见误区"},{"content":"RDF和属性图 首先来介绍 RDF 和属性图。大家知道世界万物是普遍联系的,Internet 带来了信息的连通,IoT 带来了设备的连通,像微信、微博、抖音、快手这些 APP 带来了人际关系的连通。随着社交、零售、金融、电信、物流等行业的快速发展,当今社会支起了一张庞大而复杂的关系网,在人们的生产和生活过程中,每时每刻都产生着大量的数据。随着技术的发展,我们对这些数据的分析和使用也不再局限于从统计的角度进行一些相关性的分析,而是希望从关联的角度揭示数据的一些因果联系。这里的关联,指的是相互连接的 connectivity,而不是统计意义上的 correlation。\n关联分析的场景也非常多,覆盖我们生活的方方面面。比如从社交网络分析里,我们可以做精准营销、好友推荐、舆情追踪等等;金融领域可以做信用卡反欺诈的分析,资金流向识别;零售领域,我们可以做用户 360 画像做商品实时推荐,返薅羊毛;电力领域,可以做电网的调度仿真、故障分析、电台因子计算;电信领域,可以做电信防骚扰,电信防诈骗;政企领域,可以做道路规划、智能交通,还有疫情精准防控;在制造业,我们可以做供应链管理、物流优化、产品溯源等;网络安全行业,可以做攻击溯源、调用链分析等等。\n在做关联分析的时候,我们往往需要一个图模型来描述。常见的图模型分为 RDF 和属性图两种。RDF 图中用点来表示唯一标识的资源或者是字面量的值,边则用来表示谓词。点和边之间组成一个 SPO 的三元组。属性图中,点表示实体,边表示关系,属性是点或边上的一个键值对。\n相比之下,RDF 的优势是可以支持多值属性,因为它的属性也是一个点,所以一个点连出去,可以有多值的属性。也可以通过四元组的方式前面加上一个图的描述,来实现动态图。并且 RDF 开始的比较早,所以有一个比较统一的标准。\n属性图的优势在于它两点之间可以表示同类型的多条边,因为它在边上是可以有区分属性的,边上的属性值也能让边上的表达能力更丰富。并且它支持复杂的属性类型,比如 list、set、map 等。\n随着行业的发展,我们看到越来越多的可能。知识图谱的表示在逐渐用属性图来完成。当然也有少量的图数据库是用 RDF 模型来做的,但是未来更多的新型图数据库都会用属性图模型。\n图数据库存储的核心目标 完成一个图查询或者图分析的核心操作,就是邻居的迭代遍历。\n单独的访问点或者边,或者上面的属性并不是这里的关键。仅仅是单独访问,使用传统的数据库也可以提供很好的性能。在关联分析当中,不论是从一个起始点若干跳数内的邻域网络进行分析,还是对全图进行一些完整的计算,最核心的操作都是迭代遍历某个点的所有边,也就是所谓邻居的迭代遍历。在关系型数据库中是依赖外键,通过建立索引等方式来完成的。\n在图数据库中,会直接存储边数据,也就是所谓的实现 index-free adjacency。写入的时候,保证一个点和它直接相连的边总是存储在一起。查询的时候,迭代遍历一个点的所有邻居可以直接进行,不需要依赖于其它数据结构,从而可以大幅提升邻居迭代遍历的性能。\n这里是跟关系型数据库做的一个深点查询的性能对比,用的是 who-trust-whom 的一个公开数据集,这个数据集也不是很大,约 7.5 万点,50 万边。我们想知道一个信任的人这样一个多跳关联的查询结果。使用关键性数据库的时候,对比了加索引和不加索引的情况。可以看出 2 跳的时候加索引可以明显提升关系型数据库的查询速度,到 3 跳的时候提升就不多了, 4 跳以上的时候加不加索引都会变得很慢。而使用图数据库,查询性能一直会保持在一个非常快的水平。这就是图数据库的 index-free adjacency 的特性,能够大幅提升邻居查询的速度。\n图数据库的分类 根据实现免索引连接的方式,可以把图数据库分成三类。\n第一类是使用原生图存储的方式,它的数据存储层就直接实现了免索引连接。上面的处理计算层和业务层都是以完全图的结构来描述,并且也不依赖于第三方存储组件,所以这种实现免索引连接的性能是最高效的。 第二种方式是非原生存储,数据存储层使用的是一个第三方的开源存储组件,但是它在处理过程中实现了近似免索引连接,在大多数情况下也能提供不错的性能。它的问题是由于使用了第三方存储组件,在某些场景下可能做得不是最优化。 第三种方式就是完全非原生的存储,底下可能是一个关系型数据库,或者是一个文档型或者其它类型的数据库,它的存储层其实并不是真正地实现了免索引连接,而是处理成通过索引或者一些其它技术手段,向上表达了一个图模型的查询接口。这种其实只是在接口层上实现了图的一个语义,而底下的存储和计算层都不是完全地使用免索引连接,所以它的性能也会相对低一些。 图数据库存储的主流技术方案 前文中已经明确了数据库存储的核心目标就是实现免索引连接。那么接下来就来看一些具体实现免索引连接的主流技术方案。\n数组存储 首先我们能想到的最直接的一个方案,就是用一个数组把每个点上的边按照顺序一起存储。在这一存储方案中,点文件就是由一系列的点数据组成的。每个点的存储内容包括点的 ID、点的 Meta 信息,以及这个点的一系列属性。在边文件中,是按照起始点的顺序存储点上对应的边,每条边存储的内容包括终止点 ID、边的 Meta 信息、边的一系列属性。这里所谓的 Meta 信息包括点边的类型、方向,还有一些为了实现事务的额外字段,这对于整体的存储来说不是特别重要,在这里就不详细展开了。在这个存储方案中,可以直接从起始点开始遍历相邻边的所有数据,读取性能是非常高的。 数组存储劣势 这种存储需要处理的一个比较棘手的问题,就是数组变长的情况。这里的变长是由很多因素导致,比如两个点可能属性数量不一样,属性本身如果是字符串,长度也会不一样。属性长度不一样会导致每条边的存储空间也不一样,这样在边文件中就不能用一个简单的数组来进行寻址了。如果仅仅是属性导致的变长,还是有比较简单的解决方案的,比如可以把属性单独的再放到另一个存储文件中,这样点文件和边文件里面的内容,是不是定长的呢?其实也不一定,因为每个点上边的数量也是不一样的,所以在边文件里面,每个点触发的边序列的总长度也是不一样的。所以还是要处理数组变长的问题。\n解决思路一般是两种:\n一种是使用额外的一个 offset 的记录,相当于是用一个偏移量记录,来记录每一个点或者边的起始位置。这个记录本身就可以是定长的了,因为它是个 offset 值。或者是提前划分好一些额外的区域,来预留给它增长的空间。 为了解决这种数组存储变长的问题,我们自然也可以想到用类似链表的方式来存储。在链表方式的存储模式中,点和边全部存的都是 ID,包括点 ID、边 ID、属性 ID 等等。通过属性 ID ,可以在另外一个属性存储里面找到它的位置以及具体的值。因为存的都是 ID,所以每个点和每条边的数据长度就是固定的了。通过 ID 可以直接计算出偏移量,然后用偏移量的位置去读取数据。所以每个数据本身也不需要保存自身的ID,因为偏移量的位置是能够反推出来自身 ID 的。 链表存储 这是一个链表存储下进行边迭代的例子: 假设有一个起始点 A,需要迭代它的所有边。首先在点文件中找到点 A 的首个边,α。然后去边文件中找到 α 对应偏移量的位置,就可以读出这条边的数据。可以看到,是一个从点 A 到点 B 的边,A 是一个起始点,我们就去找起始点下一条边的 ID,就找到边 θ。然后去边 θ 的位置,找到偏移量,就找到边 θ。这里我们看到它是一个 C 到 A 的边,A 是终止点。我们就去找终止点的下一条边,是 ω。再去找到边 ω 的位置,看到是起始点 A 终止点 D,通过这样的方式就可以不断地去迭代边。 我们看到,用链表存储的方式很好地解决了数组变长的问题,因为新增边的时候,只需要新增固定长度的结构组成链表即可。每一次迭代也是在 O(1) 的时间内直接找到了下一条边,也不依赖于外部的索引或者其它结构。\n链表存储的劣势 这看似是一个比较好的方案,但实际的使用中,也存在着一些问题。不要忘记,现在讨论的是一个存储格式,而不是一个内存结构。存储格式意味着最终是要在磁盘 IO 上进行读写的。在链表存储方案下,每一次边迭代的时候,由于边 offset 的位置是随机的,所以会有大量的随机读操作。而磁盘对于随机读操作并不是很友好。 所以虽然这里理论上的迭代邻居找到下一条边的复杂度是 O(1),但 O(1) 的单位时间是磁盘随机读的时间,而不是顺序读的时间,这两者在性能上是会有非常大的差别的。所以使用这种链表的存储方式,通常来说会依赖一个非常高效实现的缓存机制,需要把大量的磁盘数据放到内存缓存中来读,在内存中进行随机访问的性能就会提升很多。 除了基于数组和链表的方法,还有其它一些格式可以实现 O(1) 时间的边迭代。比如,使用 LSM-Tree 的存储结构,这个结构是一种顺序写盘多层结构的 KV 存储。这里只简单介绍一下它的工作原理。\nLSM 存储 这个图忽略了像写 WAL 这样的细节,是 LSM 树读写的核心操作流程。 LSM 树是一种常用的键值存储结构,处理写请求的性能很高。它的读写操作流程如下:当一个请求进来的时候,直接写入内存中的一个 MemTable,如果 MemTable 没写满,就直接返回请求。因此它处理写请求的性能是很高的。当 MemTable 满的时候,会生成一个不可写的、只读的 Immutable MemTable,同时生成新的可写的 MemTable,以供后续使用。然后 Immutable MemTable 就会写到磁盘上,形成一个 SST 文件。SST 文件在写盘的时候,会根据 Key 排序,从而实现顺序的边迭代。其落盘结构的 SST 文件也是分层来组织的。从内存中直接写出来的第 0 层达到一定数据量大小的时候,或者触发某种条件的时候,就会进行一个归并排序,归并排序就是一个 Compaction 的过程。合并出来的第一层的 SST 文件,都是按照 Key 的顺序写排的。读取的时候是先去内存中的 MemTable 查找,找到了就返回,如果没有找到就去第 0 层的 SST 文件中查找,找不到再去第 1 层,这样逐层查找,一直到找到需要读取的 Key 为止。\n使用 SST 文件进行存储的一个关键就是设计边的 Key。因为在 SST 文件中,Key 是有序排列的,所以我们需要通过 LSMTree 来实现免索引连接的能力。关键点就是合理地设计边的 Key,使一个点所有边在排序后是相邻的。说起来比较拗口,其实实现起来并不难。\n我们看一下这个例子。只要把边 Key 的最高位放起始点 ID,那么后面无论是边的其它什么信息,都可以让从起始点 ID 出发的边自然地排序排在一起。这里也可以加入一个编号的字段,因为两点之间,起始点和终止点 Meta 这些是固定的,编号字段加入之后,就可以支持在两点之间同类型的多条边共存。因为这是一个 KV 结构。如果只有起始点、终止点和 Meta,两点之间同类型的边只能存在一条。所以比如转账交易或者是访问记录这些具有事件性质的边要存多条,可以加一个编号。当然也不一定都是必须从起始点开始来做边的 Key。 比如在例 2 中,把 type 边类型放在高位。它就可以先以 type 进行划分,后面才是起始点。这种方法也比较适合在分布式场景下按类型做分片,这样同一类型的边就会排在相邻的分片中,有利于提高分布式查询的性能。使用这个方式,有非常高的写入性能,并且读取的时候也能提供免索引连接的能力。\nnebula 点 nebula 边 nebula 解读 上图以最简单的两个点和一条边为例,起点 SrcVertex 通过边 EdgeA 连接目的点 DstVertex,形成路径(SrcVertex)-[EdgeA]-\u0026gt;(DstVertex)。这两个点和一条边会以 6 个键值对的形式保存在存储层的两个不同分片,即 Partition x 和 Partition y 中,详细说明如下:\n点 SrcVertex 的键值保存在 Partition x 中。 边 EdgeA 的第一份键值,这里用 EdgeA_Out 表示,与 SrcVertex 一同保存在 Partition x 中。key 的字段有 Type、PartID(x)、VID(Src,即点 SrcVertex 的 ID)、EdgeType(符号为正,代表边方向为出)、Rank(0)、VID(Dst,即点 DstVertex 的 ID)和 PlaceHolder。SerializedValue 即 Value,是序列化的边属性。 点 DstVertex 的键值保存在 Partition y 中。 边 EdgeA 的第二份键值,这里用 EdgeA_In 表示,与 DstVertex 一同保存在 Partition y 中。key 的字段有 Type、PartID(y)、VID(Dst,即点 DstVertex 的 ID)、EdgeType(符号为负,代表边方向为入)、Rank(0)、VID(Src,即点 SrcVertex 的 ID)和 PlaceHolder。SerializedValue 即 Value,是序列化的边属性,与 EdgeA_Out 中该部分的完全相同。 EdgeA_Out 和 EdgeA_In 以方向相反的两条边的形式存在于存储层,二者组合成了逻辑上的一条边 EdgeA。EdgeA_Out 用于从起点开始的遍历请求,例如(a)-[]-\u0026gt;();EdgeA_In 用于指向目的点的遍历请求,或者说从目的点开始,沿着边的方向逆序进行的遍历请求,例如例如()-[]-\u0026gt;(a)。\n如 EdgeA_Out 和 EdgeA_In 一样,NebulaGraph冗余了存储每条边的信息,导致存储边所需的实际空间翻倍。因为边对应的 key 占用的硬盘空间较小,但 value 占用的空间与属性值的长度和数量成正比,所以,当边的属性值较大或数量较多时候,硬盘空间占用量会比较大。\nnebula 的存储如何迭代边 NebulaGraph 的点和边在同一个 partition,虽然没有存在同一个 key-value 中,但是永远在同一个地方,所以: 遍历1hop的出入边(不需要点属性信息的时候),就是一个 prefix scan(只需要给边上左边的那个 id ,两个方向的边都能高效扫出来,边多存了一次换来这里) 获取点与边的属性的情况下,prefix scan 之后获取了点边信息,在用点边的vertex id, get_value(key_of_id) LSM 的劣势 首先是读性能,在读的时候,如果内存没有命中,下面是一个逐层的 SST 文件,去找 Key 的最坏情况,可能要把所有层的 SST 文件全部找完,才能找到合适的 Key。所以它的免索引连接是比较依赖于Compaction 操作的。只有在理想情况下,比如在一个完整的 Compaction 完成的情况下,它才能真正实现免索引连接,否则会在各个 SST 文件内部去查找。在整体上,它并没有完整地达到不去利用其它结构就能够进行快速的领域迭代。\n而做 Compaction 又是一个有比较大的磁盘 IO 的操作,并且如果使用的是第三方的存储结构,那么做 Compaction 的操作是不受图数据库本身控制的,可能是由一些其它的机制触发的,比如是在前台负载压力比较大的情况下触发了 Compaction,这样实际在使用的时候会出现一些瓶颈,所以必须要对第三方存储进行比较深度的改动,才能够更好地优化。\n可以看到,各种实现免索引连接的存储方式都不是一劳永逸的,而是有各自的优势和短板。通过数组的方式读取速度快,但是写入因为涉及到变长的问题,可能会比较慢。通过 LSM 树的方式写入速度快,但是读的时候又依赖于 Compaction 操作,在 Compaction 没有完成的情况下,它的读取速度也比较慢。通过链表的方式读取和写入速度都不占优,但是它的灵活性却最高,因为它是以 offset 形式的指针来实现的。\n在实际商业图数据库的实现过程中,需要根据设计理念去做取舍。也可以结合两种或者多种方案的优点,在不同的数据形式下,灵活地实现不同类型的存储。还有一些其它的问题,比如分区分片、反向边一致性、如何支持事务、数据索引怎么做、数据过期等等,都是要解决的问题,实现起来还是比较复杂的。\n","permalink":"https://reid00.github.io/en/posts/storage/%E7%9F%A5%E8%AF%86%E5%9B%BE%E8%B0%B1%E5%AD%98%E5%82%A8%E6%8A%80%E6%9C%AF/","summary":"RDF和属性图 首先来介绍 RDF 和属性图。大家知道世界万物是普遍联系的,Internet 带来了信息的连通,IoT 带来了设备的连通,像微信、微博、抖","title":"知识图谱存储技术"},{"content":"WebAssembly 简介 WebAssembly (有时缩写为 Wasm)为可执行程序定义了一种可移植的二进制代码格式和相应的文本格式以及软件接口,用于促进这些程序与其宿主环境之间的交互。 WebAssembly 的主要目标是在网页上启用高性能的应用程序,“但是它不会做出任何特定于 Web 的假设或提供特定于 Web 的特性,因此它也可以在其他环境中使用。”它是一个开放标准 ,旨在支持任何操作系统上的任何语言,实际上,所有最流行的语言都至少有一定程度的支持。 from wasm\nWebAssembly (sometimes abbreviated Wasm) defines a portable binary-code format and a corresponding text format for executable programs as well as software interfaces for facilitating interactions between such programs and their host environment. The main goal of WebAssembly is to enable high-performance applications on web pages, \u0026ldquo;but it does not make any Web-specific assumptions or provide Web-specific features, so it can be employed in other environments as well.\u0026quot;[7] It is an open standard[8][9] and aims to support any language on any operating system,[10] and in practice all of the most popular languages already have at least some level of support.\nWebAssembly 是一种二进制编码格式,而不是一门新的语言。 WebAssembly 不是为了取代 JavaScript,而是一种补充(至少现阶段是这样),结合 WebAssembly 的性能优势,很大可能集中在对性能要求高(例如游戏,AI),或是对交互体验要求高(例如移动端)的场景。 C/C++ 等语言可以编译 WebAssembly 的目标文件,也就是说,其他语言可以通过编译器支持,而写出能够在浏览器前端运行的代码。 Rust 如何使用WASM 完整项目看此处\n安装 安装Rust, 参考官网 install wasm-pack cargo install wasm-pack [可选] install cargo-generate cargo install cargo-generate Python3 用于启动一个简单的 HTTP 服务器 案例 创建一个rust lib 项目 cargo new --lib hello-wasm 打开项目,结构如下 1 2 3 4 5 6 -rw-r--r-- 1 zhangbl 197121 3203 Aug 16 15:41 Cargo.lock -rw-r--r-- 1 zhangbl 197121 224 Aug 16 15:51 Cargo.toml -rw-r--r-- 1 zhangbl 197121 314 Aug 16 17:35 index.html # 后面新建的 drwxr-xr-x 1 zhangbl 197121 0 Aug 16 18:13 pkg/ # 后面生成的 drwxr-xr-x 1 zhangbl 197121 0 Aug 16 15:27 src/ drwxr-xr-x 1 zhangbl 197121 0 Aug 16 16:01 target/ 打开src/lib.rs, 删除原先内容,用以下内容取代:\n1 2 3 4 5 6 7 8 9 10 11 use wasm_bindgen::prelude::*; #[wasm_bindgen] extern { pub fn alert(s: \u0026amp;str); } #[wasm_bindgen] pub fn greet(name: \u0026amp;str) { alert(\u0026amp;format!(\u0026#34;Hello, {}!\u0026#34;, name)); } 同时在cargo.toml 新增下面内容:\n1 2 3 4 5 [lib] crate-type = [\u0026#34;cdylib\u0026#34;] [dependencies] wasm-bindgen=\u0026#34;0.2\u0026#34; 代码解析 use wasm_bindgen::prelude::*;:导入了 wasm_bindgen 库的预导入(prelude)模块,它包含了一些常用的宏和类型。这样可以方便地在代码中使用 wasm_bindgen 提供的功能。 #[wasm_bindgen]:这是一个属性宏,用于标记下面的函数和外部接口需要与 JavaScript 进行绑定 extern 块:在 extern 块内部使用 pub fn alert(s: \u0026amp;str) 定义了一个外部函数接口。这个函数声明了一个参数 s,类型为字符串引用(\u0026amp;str)。 从 Rust 调用 JavaScript 中的外部函数 pub fn greet(name: \u0026amp;str):这是一个公共函数 greet 的定义,它接受一个字符串引用参数 name。生成 JavaScript 可以调用的 Rust 函数 alert(\u0026amp;format!(\u0026quot;Hello, {}!\u0026quot;, name)):在 greet 函数中调用了外部接口函数 alert。它使用 format! 宏将传入的 name 参数插入到格式化的字符串中,然后调用 alert 函数显示警告框,内容为拼接好的问候信息。 toml 解析 [lib] 告诉编译器,将要编译的类型是c 语言的动态类型 库\n编译 wasm-pack build --target web 请注意这步很慢,耗时会很久,请去喝茶。 编译完成之后,出现pkg 包,里面提供了我们项目名的mywasm_bg.wasm\n1 2 3 4 5 6 7 total 38 -rw-r--r-- 1 zhangbl 197121 1116 Aug 16 17:39 mywasm.d.ts -rw-r--r-- 1 zhangbl 197121 4910 Aug 16 17:39 mywasm.js -rw-r--r-- 1 zhangbl 197121 2503 Aug 16 16:03 mywasm_bg.js -rw-r--r-- 1 zhangbl 197121 17307 Aug 16 18:13 mywasm_bg.wasm -rw-r--r-- 1 zhangbl 197121 287 Aug 16 17:39 mywasm_bg.wasm.d.ts -rw-r--r-- 1 zhangbl 197121 213 Aug 16 18:13 package.json 在项目目录下,新建index.html 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;hello-wasm example\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import init, { greet } from \u0026#34;./pkg/mywasm.js\u0026#34;; init().then(() =\u0026gt; { greet(\u0026#34;WebAssembly\u0026#34;) }); \u0026lt;/script\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 运行 python -m http.server 启动一个简单的web 服务, 让后访问 http://localhost:8000 Go 如何使用WASM (未完待续) Hello world 新建main.go, 使用 js.Global().get(‘alert’) 获取全局的 alert 对象,通过 Invoke 方法调用。等价于在 js 中调用 window.alert(\u0026ldquo;Hello World\u0026rdquo;)。 1 2 3 4 5 6 7 8 9 package main import \u0026#34;syscall/js\u0026#34; func main() { alert := js.Global().Get(\u0026#34;alert\u0026#34;) alert.Invoke(\u0026#34;Hello World!\u0026#34;) } 将 main.go 编译为 static/main.wasm GOOS=js GOARCH=wasm go build -o static/main.wasm\n拷贝 wasm_exec.js (JavaScript 支持文件,加载 wasm 文件时需要) 到 static 文件夹 cp \u0026quot;$(go env GOROOT)/misc/wasm/wasm_exec.js\u0026quot; static\n创建 index.html,引用 static/main.wasm 和 static/wasm_exec.js。\n1 2 3 4 5 6 7 8 9 \u0026lt;html\u0026gt; \u0026lt;script src=\u0026#34;static/wasm_exec.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; \u0026lt;script\u0026gt; const go = new Go(); WebAssembly.instantiateStreaming(fetch(\u0026#34;static/main.wasm\u0026#34;), go.importObject) .then((result) =\u0026gt; go.run(result.instance)); \u0026lt;/script\u0026gt; \u0026lt;/html\u0026gt; ","permalink":"https://reid00.github.io/en/posts/langs_linux/webassemblywasm-%E6%95%99%E7%A8%8B/","summary":"WebAssembly 简介 WebAssembly (有时缩写为 Wasm)为可执行程序定义了一种可移植的二进制代码格式和相应的文本格式以及软件接口,用于促进这些程序与其宿主环境之间的交","title":"WebAssembly(Wasm) 教程"},{"content":"磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化的目的就是为了提高系统的吞吐量,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数。\n这次,我们就以「文件传输」作为切入点,来分析 I/O 工作方式,以及如何优化传输文件的性能。\n为什么要有 DMA 技术? 在没有 DMA 技术前,I/O 的过程是这样的:\nCPU 发出对应的指令给磁盘控制器,然后返回; 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断; CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。 为了方便你理解,我画了一副图: 可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。\n简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。\n计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。\n什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。\n那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。 具体过程:\n用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态; 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务; DMA 进一步将 I/O 请求发送给磁盘; 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满; DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务; 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU; CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回; 可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。\n早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。\n传统的文件传输有多糟糕? 如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。\n传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。\n代码通常如下,一般会需要两个系统调用:\n1 2 read(file, tmp_buf, len); write(socket, tmp_buf, len); 代码很简单,虽然就两行代码,但是这里面发生了不少的事情。 首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。\n上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。\n其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:\n第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。 我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。\n这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。\n所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数。\n如何优化文件传输的性能? 如何减少「用户态与内核态的上下文切换」的次数呢? 读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。\n而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。\n所以,要想减少上下文切换到次数,就要减少系统调用的次数。\n如何减少「数据拷贝」的次数 在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。\n因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。\n如何实现零拷贝? 零拷贝技术实现的方式通常有 2 种:\nmmap + write sendfile 下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。 mmap + write 在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。\n1 2 buf = mmap(file, len); write(sockfd, buf, len); mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。 具体过程如下:\n应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。 我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。\n但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。\nsendfile 在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:\n1 2 #include \u0026lt;sys/socket.h\u0026gt; ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count); 它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。\n首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。\n其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图: 但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。\n你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:\n1 2 $ ethtool -k eth0 | grep scatter-gather scatter-gather: on 于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:\n第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里; 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝; 所以,这个过程之中,只进行了 2 次数据拷贝,如下图: 这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。\n零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。\n所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。\n使用零拷贝技术的项目 事实上,Kafka 这个开源项目,就利用了「零拷贝」技术,从而大幅提升了 I/O 的吞吐率,这也是 Kafka 在处理海量数据为什么这么快的原因之一。\n如果你追溯 Kafka 文件传输的代码,你会发现,最终它调用了 Java NIO 库里的 transferTo 方法:\n1 2 3 4 @Overridepublic long transferFrom(FileChannel fileChannel, long position, long count) throws IOException { return fileChannel.transferTo(position, count, socketChannel); } 如果 Linux 系统支持 sendfile() 系统调用,那么 transferTo() 实际上最后就会使用到 sendfile() 系统调用函数。\n曾经有大佬专门写过程序测试过,在同样的硬件条件下,传统文件传输和零拷拷贝文件传输的性能差异,你可以看到下面这张测试数据图,使用了零拷贝能够缩短 65% 的时间,大幅度提升了机器传输数据的吞吐量。\n另外,Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:\n1 2 3 4 5 http { ... sendfile on ... } sendfile 配置的具体意思:\n设置为 on 表示,使用零拷贝技术来传输文件:sendfile ,这样只需要 2 次上下文切换,和 2 次数据拷贝。 设置为 off 表示,使用传统的文件传输技术:read + write,这时就需要 4 次上下文切换,和 4 次数据拷贝。 当然,要使用 sendfile,Linux 内核版本必须要 2.1 以上的版本。 PageCache 有什么作用? 回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。\n由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。\n读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。\n但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。\n那问题来了,选择哪些磁盘数据拷贝到内存呢?\n我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。\n所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。\n还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」。\n比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。\n所以,PageCache 的优点主要是两个:\n缓存最近被访问的数据; 预读功能; 这两个做法,将大大提高读写磁盘的性能。\n但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能\n这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。\n另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:\nPageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了; PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次; 所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。\n大文件传输用什么方式实现? 那针对大文件的传输,我们应该使用什么方式呢?\n我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图: 具体过程:\n当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好; 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里; 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。\n对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图: 它把读操作分为两部分:\n前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务; 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据; 而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。 绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。\n前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。\n于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术。\n直接 I/O 应用场景常见的两种:\n应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启; 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。 另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:\n内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作; 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作; 于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。\n所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:\n传输大文件的时候,使用「异步 I/O + 直接 I/O」; 传输小文件的时候,则使用「零拷贝技术」; 在 nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:\n1 2 3 4 5 location /video/ { sendfile on; aio on; directio 1024m; } 当文件大小大于 directio 值后,使用「异步 I/O + 直接 I/O」,否则使用「零拷贝技术」。\n总结 早期 I/O 操作,内存与磁盘的数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。\n于是,为了解决这一问题,DMA 技术就出现了,每个 I/O 设备都有自己的 DMA 控制器,通过这个 DMA 控制器,CPU 只需要告诉 DMA 控制器,我们要传输什么数据,从哪里来,到哪里去,就可以放心离开了。后续的实际数据传输工作,都会由 DMA 控制器来完成,CPU 不需要参与数据传输的工作。\n传统 IO 的工作方式,从硬盘读取数据,然后再通过网卡向外发送,我们需要进行 4 上下文切换,和 4 次数据拷贝,其中 2 次数据拷贝发生在内存里的缓冲区和对应的硬件设备之间,这个是由 DMA 完成,另外 2 次则发生在内核态和用户态之间,这个数据搬移工作是由 CPU 完成的。\n为了提高文件传输的性能,于是就出现了零拷贝技术,它通过一次系统调用(sendfile 方法)合并了磁盘读取与网络发送两个操作,降低了上下文切换次数。另外,拷贝数据都是发生在内核中的,天然就降低了数据拷贝的次数。\nKafka 和 Nginx 都有实现零拷贝技术,这将大大提高文件传输的性能。\n零拷贝技术是基于 PageCache 的,PageCache 会缓存最近访问的数据,提升了访问缓存数据的性能,同时,为了解决机械硬盘寻址慢的问题,它还协助 I/O 调度算法实现了 IO 合并与预读,这也是顺序读比随机读性能好的原因。这些优势,进一步提升了零拷贝的性能。\n需要注意的是,零拷贝技术是不允许进程对文件内容作进一步的加工的,比如压缩数据再发送。\n另外,当传输大文件时,不能使用零拷贝,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,并且大文件的缓存命中率不高,这时就需要使用「异步 IO + 直接 IO 」的方式。\n在 Nginx 里,可以通过配置,设定一个文件大小阈值,针对大文件使用异步 IO 和直接 IO,而对小文件使用零拷贝。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E9%9B%B6%E6%8B%B7%E8%B4%9D%E6%8A%80%E6%9C%AF/","summary":"磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化","title":"零拷贝技术"},{"content":"别小看这两个东西,特别是 Reactor 模式,市面上常见的开源软件很多都采用了这个方案,比如 Redis、Nginx、Netty 等等,所以学好这个模式设计的思想,有助于我们理解很多开源软件。\n演进 如果要让服务器服务多个客户端,那么最直接的方式就是为每一条连接创建线程。\n其实创建进程也是可以的,原理是一样的,进程和线程的区别在于线程比较轻量级些,线程的创建和线程间切换的成本要小些,为了描述简述,后面都以线程为例。\n处理完业务逻辑后,随着连接关闭后线程也同样要销毁了,但是这样不停地创建和销毁线程,不仅会带来性能开销,也会造成浪费资源,而且如果要连接几万条连接,创建几万个线程去应对也是不现实的。\n要这么解决这个问题呢?我们可以使用「资源复用」的方式。\n也就是不用再为每个连接创建线程,而是创建一个「线程池」,将连接分配给线程,然后一个线程可以处理多个连接的业务。\n不过,这样又引来一个新的问题,线程怎样才能高效地处理多个连接的业务?\n当一个连接对应一个线程时,线程一般采用「read -\u0026gt; 业务处理 -\u0026gt; send」的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O),不过这种阻塞方式并不影响其他线程。\n但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。\n要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 read 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题,但是解决的方式比较粗暴,因为轮询是要消耗 CPU 的,而且随着一个 线程处理的连接越多,轮询的效率就会越低。\n上面的问题在于,线程并不知道当前连接是否有数据可读,从而需要每次通过 read 去试探。\n那有没有办法在只有当连接上有数据的时候,线程才去发起读请求呢?答案是有的,实现这一技术的就是 I/O 多路复用。\nI/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接。\n我们熟悉的 select/poll/epoll 就是内核提供给用户态的多路复用系统调用,线程可以通过一个系统调用函数从内核中获取多个事件。\nselect/poll/epoll 是如何获取网络事件的呢?\n在获取事件时,先把我们要关心的连接传给内核,再由内核检测:\n如果没有事件发生,线程只需阻塞在这个系统调用,而无需像前面的线程池方案那样轮训调用 read 操作来判断是否有数据。 如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。 当下开源软件能做到网络高性能的原因就是 I/O 多路复用吗?\n是的,基本是基于 I/O 多路复用,用过 I/O 多路复用接口写网络程序的同学,肯定知道是面向过程的方式写代码的,这样的开发的效率不高。\n于是,大佬们基于面向对象的思想,对 I/O 多路复用作了一层封装,让使用者不用考虑底层网络 API 的细节,只需要关注应用代码的编写。\n大佬们还为这种模式取了个让人第一时间难以理解的名字:Reactor 模式。\nReactor 翻译过来的意思是「反应堆」,可能大家会联想到物理学里的核反应堆,实际上并不是的这个意思。\n这里的反应指的是「对事件反应」,也就是来了一个事件,Reactor 就有相对应的反应/响应。\n事实上,Reactor 模式也叫 Dispatcher 模式,我觉得这个名字更贴合该模式的含义,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程。\nReactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:\nReactor 负责监听和分发事件,事件类型包含连接事件、读写事件; 处理资源池负责处理事件,如 read -\u0026gt; 业务逻辑 -\u0026gt; send; Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:\nReactor 的数量可以只有一个,也可以有多个; 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程; 将上面的两个因素排列组设一下,理论上就可以有 4 种方案选择:\n单 Reactor 单进程 / 线程; 单 Reactor 多进程 / 线程; 多 Reactor 单进程 / 线程; 多 Reactor 多进程 / 线程; 其中,「多 Reactor 单进程 / 线程」实现方案相比「单 Reactor 单进程 / 线程」方案,不仅复杂而且也没有性能优势,因此实际中并没有应用。\n剩下的 3 个方案都是比较经典的,且都有应用在实际的项目中:\n单 Reactor 单进程 / 线程; 单 Reactor 多线程 / 进程; 多 Reactor 多进程 / 线程; 方案具体使用进程还是线程,要看使用的编程语言以及平台有关: Java 语言一般使用线程,比如 Netty; C 语言使用进程和线程都可以,例如 Nginx 使用的是进程,Memcache 使用的是线程。 接下来,分别介绍这三个经典的 Reactor 方案。\nReactor 单 Reactor 单进程 / 线程 一般来说,C 语言实现的是「单 Reactor 单进程」的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。\n而 Java 语言实现的是「单 Reactor 单线程」的方案,因为 Java 程序是跑在 Java 虚拟机这个进程上面的,虚拟机中有很多线程,我们写的 Java 程序只是其中的一个线程而已。\n我们来看看「单 Reactor 单进程」的方案示意图: 可以看到进程里有 Reactor、Acceptor、Handler 这三个对象:\nReactor 对象的作用是监听和分发事件; Acceptor 对象的作用是获取连接; Handler 对象的作用是处理业务; 对象里的 select、accept、read、send 是系统调用函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。\n接下来,介绍下「单 Reactor 单进程」这个方案:\nReactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型; 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件; 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应; Handler 对象通过 read -\u0026gt; 业务处理 -\u0026gt; send 的流程来完成完整的业务流程。 单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。\n但是,这种方案存在 2 个缺点:\n第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能; 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟; 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能; 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟; 所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。\nRedis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis 业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处理是单进程的方案。\n单 Reactor 多线程 / 多进程 如果要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了单 Reactor 多线程 / 多进程的方案。\n闻其名不如看其图,先来看看「单 Reactor 多线程」方案的示意图如下: 详细说一下这个方案:\nReactor 对象通过 select (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型; 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件; 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应; 上面的三个步骤和单 Reactor 单线程方案是一样的,接下来的步骤就开始不一样了:\nHandler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理; 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client; 单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的能力,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。\n例如,子线程完成业务处理后,要把结果传递给主线程的 Handler 进行发送,这里涉及共享数据的竞争。\n要避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。\n聊完单 Reactor 多线程的方案,接着来看看单 Reactor 多进程的方案。\n事实上,单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 \u0026lt;-\u0026gt; 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。\n而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式。\n另外,「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。\n多 Reactor 多进程 / 线程 要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多进程 / 线程的方案。\n老规矩,闻其名不如看其图。多 Reactor 多进程 / 线程方案的示意图如下(以线程为例): 方案详细说明如下:\n主线程中的 MainReactor 对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程; 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 select 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。 Handler 对象通过 read -\u0026gt; 业务处理 -\u0026gt; send 的流程来完成完整的业务流程。 多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:\n主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。 大名鼎鼎的两个开源软件 Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案。\n采用了「多 Reactor 多进程」方案的开源软件是 Nginx,不过方案与标准的多 Reactor 多进程有些差异。\n具体差异表现在主进程中仅仅用来初始化 socket,并没有创建 mainReactor 来 accept 连接,而是由子进程的 Reactor 来 accept 连接,通过锁来控制一次只有一个子进程进行 accept(防止出现惊群现象),子进程 accept 新连接后就放到自己的 Reactor 进行处理,不会再分配给其他子进程。\nProactor 前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式。\n这里先给大家复习下阻塞、非阻塞、同步、异步 I/O 的概念。\n先来看看阻塞 I/O,当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。\n注意,阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。过程如下图: 知道了阻塞 I/O ,来看看非阻塞 I/O,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。过程如下图: 注意,这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。\n举个例子,如果 socket 设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。\n因此,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。\n而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。\n当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图: 举个你去饭堂吃饭的例子,你好比应用程序,饭堂好比操作系统。 阻塞 I/O 好比,你去饭堂吃饭,但是饭堂的菜还没做好,然后你就一直在那里等啊等,等了好长一段时间终于等到饭堂阿姨把菜端了出来(数据准备的过程),但是你还得继续等阿姨把菜(内核空间)打到你的饭盒里(用户空间),经历完这两个过程,你才可以离开。\n非阻塞 I/O 好比,你去了饭堂,问阿姨菜做好了没有,阿姨告诉你没,你就离开了,过几十分钟,你又来饭堂问阿姨,阿姨说做好了,于是阿姨帮你把菜打到你的饭盒里,这个过程你是得等待的。\n异步 I/O 好比,你让饭堂阿姨将菜做好并把菜打到饭盒里后,把饭盒送到你面前,整个过程你都不需要任何等待。\n很明显,异步 I/O 比同步 I/O 性能更好,因为异步 I/O 在「内核数据准备好」和「数据从内核空间拷贝到用户空间」这两个过程都不用等待。\nProactor 正是采用了异步 I/O 技术,所以被称为异步网络模型。\n现在我们再来理解 Reactor 和 Proactor 的区别,就比较清晰了。\nReactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。 Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。 因此,Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。\n举个实际生活中的例子,Reactor 模式就是快递员在楼下,给你打电话告诉你快递到你家小区了,你需要自己下楼来拿快递。而在 Proactor 模式下,快递员直接将快递送到你家门口,然后通知你。\n无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。\n接下来,一起看看 Proactor 模式的示意图: 介绍一下 Proactor 模式的工作流程:\nProactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核; Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作; Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor; Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理; Handler 完成业务处理; 可惜的是,在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案。\n而 Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案。\n总结 常见的 Reactor 实现方案有三种。\n第一种方案单 Reactor 单进程 / 线程,不用考虑进程间通信以及数据同步的问题,因此实现起来比较简单,这种方案的缺陷在于无法充分利用多核 CPU,而且处理业务逻辑的时间不能太长,否则会延迟响应,所以不适用于计算机密集型的场景,适用于业务处理快速的场景,比如 Redis(6.0之前 ) 采用的是单 Reactor 单进程的方案。 第二种方案单 Reactor 多线程,通过多线程的方式解决了方案一的缺陷,但它离高并发还差一点距离,差在只有一个 Reactor 对象来承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。 第三种方案多 Reactor 多进程 / 线程,通过多个 Reactor 来解决了方案二的缺陷,主 Reactor 只负责监听事件,响应事件的工作交给了从 Reactor,Netty 和 Memcache 都采用了「多 Reactor 多线程」的方案,Nginx 则采用了类似于 「多 Reactor 多进程」的方案。 Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。\n因此,真正的大杀器还是 Proactor,它是采用异步 I/O 实现的异步网络模型,感知的是已完成的读写事件,而不需要像 Reactor 感知到事件后,还需要调用 read 来从内核中获取数据。\n不过,无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E9%AB%98%E6%80%A7%E8%83%BD%E7%BD%91%E7%BB%9C%E6%A8%A1%E5%BC%8F-reactor-proactor/","summary":"别小看这两个东西,特别是 Reactor 模式,市面上常见的开源软件很多都采用了这个方案,比如 Redis、Nginx、Netty 等等,所以学好这个模式设计的","title":"高性能网络模式: Reactor Proactor"},{"content":"最基本的 Socket 模型 要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。\nSocket 的中文名叫作插口,咋一看还挺迷惑的。事实上,双方要进行网络通信前,各自得创建一个 Socket,这相当于客户端和服务器都开了一个“口子”,双方读取和发送数据的时候,都通过这个“口子”。这样一看,是不是觉得很像弄了一根网线,一头插在客户端,一头插在服务端,然后进行通信。\n创建 Socket 的时候,可以指定网络层使用的是 IPv4 还是 IPv6,传输层使用的是 TCP 还是 UDP。\nUDP 的 Socket 编程相对简单些,这里我们只介绍基于 TCP 的 Socket 编程。\n服务器的程序要先跑起来,然后等待客户端的连接和数据,我们先来看看服务端的 Socket 编程过程是怎样的。\n服务端首先调用 socket() 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口,绑定这两个的目的是什么?\n绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们。 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们; 绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听。\n服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。\n那客户端是怎么发起连接的呢?客户端在创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。\n在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:\n一个是「还没完全建立」连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态; 一个是「已经建立」连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态; 当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。\n注意,监听的 Socket 和真正用来传数据的 Socket 是两个:\n一个叫作监听 Socket; 一个叫作已连接 Socket; 连接建立后,客户端和服务端就开始相互传输数据了,双方都可以通过 read() 和 write() 函数来读写数据。 至此, TCP 协议的 Socket 程序的调用过程就结束了,整个过程如下图: 看到这,不知道你有没有觉得读写 Socket 的方式,好像读写文件一样。\n是的,基于 Linux 一切皆文件的理念,在内核中 Socket 也是以「文件」的形式存在的,也是有对应的文件描述符。\n内核部分 - 文件描述符 文件描述符的作用是什么?每一个进程都有一个数据结构 task_struct,该结构体里有一个指向「文件描述符数组」的成员指针。该数组里列出这个进程打开的所有文件的文件描述符。数组的下标是文件描述符,是一个整数,而数组的内容是一个指针,指向内核中所有打开的文件的列表,也就是说内核可以通过文件描述符找到对应打开的文件。\n然后每个文件都有一个 inode,Socket 文件的 inode 指向了内核中的 Socket 结构,在这个结构体里有两个队列,分别是发送队列和接收队列,这个两个队列里面保存的是一个个 struct sk_buff,用链表的组织形式串起来。\nsk_buff 可以表示各个层的数据包,在应用层数据包叫 data,在 TCP 层我们称为 segment,在 IP 层我们叫 packet,在数据链路层称为 frame。\n你可能会好奇,为什么全部数据包只用一个结构体来描述呢?协议栈采用的是分层结构,上层向下层传递数据时需要增加包头,下层向上层数据时又需要去掉包头,如果每一层都用一个结构体,那在层之间传递数据的时候,就要发生多次拷贝,这将大大降低 CPU 效率。\n于是,为了在层级之间传递数据时,不发生拷贝,只用 sk_buff 一个结构体来描述所有的网络包,那它是如何做到的呢?是通过调整 sk_buff 中 data 的指针,比如:\n当接收报文时,从网卡驱动开始,通过协议栈层层往上传送数据报,通过增加 skb-\u0026gt;data 的值,来逐步剥离协议首部。\n当要发送报文时,创建 sk_buff 结构体,数据缓存区的头部预留足够的空间,用来填充各层首部,在经过各下层协议时,通过减少 skb-\u0026gt;data 的值来增加协议首部。 你可以从下面这张图看到,当发送报文时,data 指针的移动过程。 如何服务更多的用户? 前面提到的 TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。\n可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。\n在改进网络 I/O 模型前,我先来提一个问题,你知道服务器单机理论最大能连接多少个客户端?\n相信你知道 TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口。\n服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数。\n对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。\n这个理论值相当“丰满”,但是服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:\n文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目; 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的; 那如果服务器的内存只有 2 GB,网卡是千兆的,能支持并发 1 万请求吗? 并发 1 万请求,也就是经典的 C10K 问题 ,C 是 Client 单词首字母缩写,C10K 就是单机同时处理 1 万个请求的问题。\n从硬件资源角度看,对于 2GB 内存千兆网卡的服务器,如果每个请求处理占用不到 200KB 的内存和 100Kbit 的网络带宽就可以满足并发 1 万个请求。\n不过,要想真正实现 C10K 的服务器,要考虑的地方在于服务器的网络 I/O 模型,效率低的模型,会加重系统开销,从而会离 C10K 的目标越来越远。\n多进程模型 基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。\n服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。\n这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。\n正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,\n可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。\n下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。 另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。\n因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数。\n这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。\n进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。\n多线程模型 既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型。\n线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。\n当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。\n如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。\n那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。 需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。\n上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。\nI/O 多路复用 既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。 一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。\n我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。\nselect/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。\nselect/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。\nselect/poll select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。\n所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。\nselect 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。\npoll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。\n但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。\nepoll 先复习下 epoll 的用法。如下的代码中,先用e poll_create 创建一个 epoll 对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。\n1 2 3 4 5 6 7 8 9 10 11 12 13 int s = socket(AF_INET, SOCK_STREAM, 0); bind(s, ...); listen(s, ...) int epfd = epoll_create(...); epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中 while(1) { int n = epoll_wait(...); for(接收到数据的socket){ //处理 } } epoll 通过两个方面,很好解决了 select/poll 的问题。\n第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。 第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。 从下图你可以看到 epoll 相关的接口作用: epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器。\n插个题外话,网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。\n这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。 好了,这个题外话就说到这了,我们继续!\n边缘触发和水平触发 epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。\n这两个术语还挺抽象的,其实它们的区别还是很好理解的。\n使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完; 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取; 举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。\n这就是两者的区别: 水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。 如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。\n如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 read 和 write)返回错误,错误类型为 EAGAIN 或 EWOULDBLOCK。 一般来说,边缘触发的效率比水平触发的效率要高`,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。\nselect/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。\n另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用,Linux 手册关于 select 的内容中有如下说明:\nUnder Linux, select() may report a socket file descriptor as \u0026ldquo;ready for reading\u0026rdquo;, while nevertheless a subsequent read blocks. This could for example happen when data has arrived but upon examination has wrong checksum and is discarded. There may be other circumstances in which a file descriptor is spuriously reported as ready. Thus it may be safer to use O_NONBLOCK on sockets that should not block.\n我谷歌翻译的结果:\n在Linux下,select() 可能会将一个 socket 文件描述符报告为 \u0026ldquo;准备读取\u0026rdquo;,而后续的读取块却没有。例如,当数据已经到达,但经检查后发现有错误的校验和而被丢弃时,就会发生这种情况。也有可能在其他情况下,文件描述符被错误地报告为就绪。因此,在不应该阻塞的 socket 上使用 O_NONBLOCK 可能更安全。\n简单点理解,就是多路复用 API 返回的事件并不一定可读写的,如果使用阻塞 I/O, 那么在调用 read/write 时则会发生程序阻塞,因此最好搭配非阻塞 I/O,以便应对极少数的特殊情况。\n总结 最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。\n比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。\n为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是:select、poll、epoll。\nselect 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。\n在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。\n很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。\nepoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。\nepoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。 epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。 而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。\n","permalink":"https://reid00.github.io/en/posts/os_network/io-%E5%A4%9A%E8%B7%AF%E5%A4%8D%E7%94%A8/","summary":"最基本的 Socket 模型 要想客户端和服务器能在网络中通信,那必须得使用 Socket 编程,它是进程间通信里比较特别的方式,特别之处在于它是可以跨主机间通信。 Socket 的中","title":"IO 多路复用"},{"content":"文件系统 文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。\n文件系统的基本数据单位是文件,它的目的是对磁盘上的文件进行组织管理,那组织的方式不同,就会形成不同的文件系统。\nLinux 最经典的一句话是:「一切皆文件」,不仅普通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。\nLinux 文件系统会为每个文件分配两个数据结构:Inode(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。\nInode,也就是inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。Inode是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以Inode同样占用磁盘空间。\n目录项,也就是dentry,用来记录文件的名字、Inode指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与Inode不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存。\n由于Inode唯一标识一个文件,而目录项记录着文件的名,所以目录项和Inode的关系是多对一,也就是说,一个文件可以有多个目录。比如,硬链接的实现就是多个目录项中的Inode指向同一个文件。\n注意,目录也是文件,也是用Inode唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。\n目录项和目录是一个东西吗? 虽然名字很相近,但是它们不是一个东西,目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。\n如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数据结构缓存在内存,下次再次读到相同的目录时,只需从内存读就可以,大大提高了文件系统的效率。\n注意,目录项这个数据结构不只是表示目录,也是可以表示文件的。\n## 文件数据是如何存储在磁盘的呢? 磁盘读写的最小单位是扇区,扇区的大小只有 512字节,那么如果数据大于512字节时候,磁盘需要不停地移动磁头来查找数据,我们知道一般的文件很容易超过512字节那么如果把多个扇区合并为一个块,那么磁盘就可以提高效率了。那么磁头一次读取多个扇区就为一个块“block”(Linux上称为块,Windows上称为簇)。所以,文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区,这将大大提高了磁盘的读写的效率。 sector size \u0026lt;= block size \u0026lt;= memory page size\n文件系统记录的数据,除了其自身外,还有数据的权限信息,所有者等属性,这些信息都保存在inode中,那么谁来记录inode信息和文件系统本身的信息呢,比如说文件系统的格式,inode与data的数量呢?那么就有一个超级区块(supper block)来记录这些信息了。\nsuperblock:记录此 filesystem 的整体信息,包括inode/block的总量、使用量、剩余量, 以及文件系统的格式与相关信息等 inode:记录文件的属性信息,可以使用stat命令查看inode信息。 block:实际文件的内容,如果一个文件大于一个块时候,那么将占用多个block,但是一个块只能存放一个文件。(因为数据是由inode指向的,如果有两个文件的数据存放在同一个块中,就会乱套了) Inode用来指向数据block,那么只要找到inode,再由inode找到block编号,那么实际数据就能找出来了。\nInode是存储在硬盘上的数据,为了加速文件的访问,通常会把Inode加载到内存中。我们不可能把超级块和Inode区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:\n超级块:当文件系统挂载时进入内存; Inode区:当文件被访问时进入内存; 虚拟文件系统 文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(Virtual File System,VFS)。VFS 定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解 VFS 提供的统一接口即可。在 Linux 文件系统中,用户空间、系统调用、虚拟机文件系统、缓存、文件系统以及存储之间的关系如下图: Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类:\n磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的/proc 和 /sys 文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据。 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。 文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录。\nLinux 采用为分层的体系结构,将用户接口层、文件系统实现和存储设备的驱动程序分隔开,进而兼容不同的文件系统。虚拟文件系统(Virtual File System, VFS)是 Linux 内核中的软件层,它在内核中提供了一组标准的、抽象的文件操作,允许不同的文件系统实现共存,并向用户空间程序提供统一的文件系统接口。下面这张图展示了 Linux 虚拟文件系统的整体结构: 从上图可以看出,用户空间的应用程序直接、或是通过编程语言提供的库函数间接调用内核提供的 System Call 接口(如open()、write()等)执行文件操作。System Call 接口再将应用程序的参数传递给虚拟文件系统进行处理。\n每个文件系统都为 VFS 实现了一组通用接口,具体的文件系统根据自己对磁盘上数据的组织方式操作相应的数据。当应用程序操作某个文件时,VFS 会根据文件路径找到相应的挂载点,得到具体的文件系统信息,然后调用该文件系统的对应操作函数。\nVFS 提供了两个针对文件系统对象的缓存 INode Cache 和 DEntry Cache,它们缓存最近使用过的文件系统对象,用来加快对 INode 和 DEntry 的访问。Linux 内核还提供了 Buffer Cache 缓冲区,用来缓存文件系统和相关块设备之间的请求,减少访问物理设备的次数,加快访问速度。Buffer Cache 以 LRU 列表的形式管理缓冲区。\nVFS 的好处是实现了应用程序的文件操作与具体的文件系统的解耦,使得编程更加容易:\n应用层程序只要使用 VFS 对外提供的read()、write()等接口就可以执行文件操作,不需要关心底层文件系统的实现细节; 文件系统只需要实现 VFS 接口就可以兼容 Linux,方便移植与维护; 无需关注具体的实现细节,就实现跨文件系统的文件操作。 了解 Linux 文件系统的整体结构后,下面主要分析 Linux VFS 的技术原理。由于文件系统与设备驱动的实现非常复杂,笔者也未接触过这方面的内容,因此文中不会涉及具体文件系统的实现。\nVFS 接口 Linux 以一组通用对象的角度看待所有文件系统,每一级对象之间的关系如下图所示: fd 和 file 每个进程都持有一个fd[]数组,数组里面存放的是指向file结构体的指针,同一进程的不同fd可以指向同一个file对象;\nfile是内核中的数据结构,表示一个被进程打开的文件,和进程相关联。当应用程序调用open()函数的时候,VFS 就会创建相应的file对象。它会保存打开文件的状态,例如文件权限、路径、偏移量等等。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L936 结构体已删减 struct file { struct path f_path; struct inode *f_inode; const struct file_operations *f_op; unsigned int f_flags; fmode_t f_mode; loff_t f_pos; struct fown_struct f_owner; } // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/path.h#L8 struct path { struct vfsmount *mnt; struct dentry *dentry; } 从上面的代码可以看出,文件的路径实际上是一个指向 DEntry 结构体的指针,VFS 通过 DEntry 索引到文件的位置。\n除了文件偏移量f_pos是进程私有的数据外,其他的数据都来自于 INode 和 DEntry,和所有进程共享。不同进程的file对象可以指向同一个 DEntry 和 Inode,从而实现文件的共享。\nDEntry Inode Linux文件系统会为每个文件都分配两个数据结构,目录项(DEntry, Directory Entry)和索引节点(INode, Index Node)。\nDEntry 用来保存文件路径和 INode 之间的映射,从而支持在文件系统中移动。DEntry 由 VFS 维护,所有文件系统共享,不和具体的进程关联。dentry对象从根目录“/”开始,每个dentry对象都会持有自己的子目录和文件,这样就形成了文件树。举例来说,如果要访问”/home/beihai/a.txt”文件并对他操作,系统会解析文件路径,首先从“/”根目录的dentry对象开始访问,然后找到”home/“目录,其次是“beihai/”,最后找到“a.txt”的dentry结构体,该结构体里面d_inode字段就对应着该文件。\n1 2 3 4 5 6 7 8 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/dcache.h#L89 结构体已删减 struct dentry { struct dentry *d_parent; // 父目录 struct qstr d_name; // 文件名称 struct inode *d_inode; // 关联的 inode struct list_head d_child; // 父目录中的子目录和文件 struct list_head d_subdirs; // 当前目录中的子目录和文件 } 每一个dentry对象都持有一个对应的inode对象,表示 Linux 中一个具体的目录项或文件。INode 包含管理文件系统中的对象所需的所有元数据,以及可以在该文件对象上执行的操作。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L628 结构体已删减 struct inode { umode_t i_mode; // 文件权限及类型 kuid_t i_uid; // user id kgid_t i_gid; // group id const struct inode_operations *i_op; // inode 操作函数,如 create,mkdir,lookup,rename 等 struct super_block *i_sb; // 所属的 SuperBlock loff_t i_size; // 文件大小 struct timespec i_atime; // 文件最后访问时间 struct timespec i_mtime; // 文件最后修改时间 struct timespec i_ctime; // 文件元数据最后修改时间(包括文件名称) const struct file_operations *i_fop; // 文件操作函数,open、write 等 void *i_private; // 文件系统的私有数据 } 虚拟文件系统维护了一个 DEntry Cache 缓存,用来保存最近使用的 DEntry,加速查询操作。当调用open()函数打开一个文件时,内核会第一时间根据文件路径到 DEntry Cache 里面寻找相应的 DEntry,找到了就直接构造一个file对象并返回。如果该文件不在缓存中,那么 VFS 会根据找到的最近目录一级一级地向下加载,直到找到相应的文件。期间 VFS 会缓存所有被加载生成的dentry。\nINode 存储的数据存放在磁盘上,由具体的文件系统进行组织,当需要访问一个 INode 时,会由文件系统从磁盘上加载相应的数据并构造 INode。一个 INode 可能被多个 DEntry 所关联,即相当于为某一文件创建了多个文件路径(通常是为文件建立硬链接)。\n目录项介绍 Ext4文件系统目录项有两种实现方式:\n线性方式 该方式的目录项以ext4_dir_entry_2的结构一个接连一个直接存储在目录结点所指向的block块中。(缺省配置使用ext4_dir_entry_2这个结构) Hash树的方式 若目录下的文件数量很多,则若按照线性方式查找对应文件名的信息则会很低效。Hash树的方式,则可以用文件名来做hash计算,从而定位到对应文件的目录项结构所在的block,从而缩小查找范围、加快查找效率。 查看文件系统是否开启了方式二的目录管理方式 1 2 ➜ nebula-tool tune2fs -l /dev/vda1 | grep dir_index Filesystem features: has_journal ext_attr resize_inode dir_index filetype needs_recovery extent 64bit flex_bg sparse_super huge_file uninit_bg dir_nlink extra_isize hash树管理方式的打开和关闭 1 2 3 4 5 6 7 8 9 10 11 12 13 root@f303server:~# tune2fs -O ^dir_index /dev/sde3 tune2fs 1.42.11 (09-Jul-2014) root@f303server:~# tune2fs -l /dev/sde3 | grep dir_index # 查找不到了 root@f303server:~# tune2fs -O dir_index /dev/sde3 tune2fs 1.42.11 (09-Jul-2014) root@f303server:~# tune2fs -l /dev/sde3 | grep dir_index Filesystem features: has_journal ext_attr resize_inode dir_index filetype needs_recovery extent flex_bg sparse_super large_file huge_file uninit_bg dir_nlink extra_isize 怎么判断某目录是否以hash树的方式管理 文件系统开启了hash树管理目录结点的方式并不意味着所有的目录都按树形结构组织管理。在文件系统中,系统会根据某个目录底下文件的多少来自动进行目录管理方式的选择,只有当文件数量大于某个数时,才会采用hash树管理方式。\n怎么判断某目录的管理方式是那种? 若不是hash树的管理方式,则htree中debugfs中则会有如下提示\n1 2 3 4 5 ➜ nebula-tool debugfs debugfs 1.42.9 (28-Dec-2013) debugfs: htree htree: Filesystem not open debugfs: 若是hash 树,则展示:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Root node dump: Reserved zero: 0 Hash Version: 1 Info length: 8 Indirect levels: 0 Flags: 0 Number of entries (count): 2 Number of entries (limit): 508 Entry #0: Hash 0x00000000, block 1 Entry #1: Hash 0x775173ee, block 2 Entry #0: Hash 0x00000000, block 1 Reading directory block 1, phys 3154297 791969 0x33788c78-7df72ede (20) rtmutex.c 791971 0x12e2688e-f00920c3 (28) .latencytop.o.cmd 791973 0x6d76fd8a-f7dad208 (16) futex.o 791974 0x1f1d389c-0beb6325 (20) ns_cgroup.c 791975 0x21f726a2-367f43fb (24) .built-in.o.cmd 791977 0x2a43c4ba-ae0695eb (16) itimer.o 791978 0x6139ce78-4032f3c2 (16) user.c ext4_dir_entry_2 struct 参数 上表我们可以看到该结构中共5个数据项,前四项占8byte, 通过根目录为例,通过hexdump查看其二进制代码,则“目录项的长度(rec_len)= 文件名长度(name_len) + 8 ”。但是在很多情况下rec_len \u0026gt; = name_len + 8。原因是因为目录项每一项的起始位置必须按照后两位 00 对齐。故有时候会浪费几个字节。\n由图可知,在目录文件的数据块中存储了其下的文件名、目录名、目录本身的相对名称\u0026quot;.\u0026quot;和上级目录的相对名称\u0026quot;..\u0026quot;,还存储了这些文件名对应的inode号、目录项长度rec_len、文件名长度name_len和文件类型file_type。注意到除了文件本身的inode记录了文件类型,其所在的目录的数据块也记录了文件类型。由于rec_len只能是4的倍数,所以需要使用\u0026quot;\\0\u0026quot;来填充name_len不够凑满4倍数的部分。至于rec_len具体是什么,只需知道它是一种偏移即可。\n需要注意的是,inode table中的inode自身并没有存储每个inode的inode号,它是存储在目录的data block中的,通过inode号可以计算并索引到inode table中该inode号对应的inode记录,可以认为这个inode号是一个inode指针 (当然,并非真的是指针,但有助于理解通过inode号索引找到对应inode的这个过程,后文将在需要的时候使用inode指针这个词来表示inode号。至此,已经知道了两种指针:一种是inode table中每个inode记录指向其对应data block的block指针,一个此处的“inode指针”)。\n除了inode号,目录的data block中还使用数字格式记录了文件类型,数字格式和文件类型的对应关系如下图。 注意到目录的data block中前两行存储的是目录本身的相对名称\u0026quot;.\u0026ldquo;和上级目录的相对名称\u0026rdquo;..\u0026quot;,它们实际上是目录本身的硬链接和上级目录的硬链接。硬链接的本质后面说明。\n前面提到过,inode结构自身并没有保存inode号(同样,也没有保存文件名),那么inode号保存在哪里呢?目录的data block中保存了该目录中每个文件的inode号。\n另一个问题,既然inode中没有inode号,那么如何根据目录data block中的inode号找到inode table中对应的inode呢?\n实际上,只要有了inode号,就可以计算出inode表中对应该inode号的inode结构。在创建文件系统的时候,每个块组中的起始inode号以及inode table的起始地址都已经确定了,所以只要知道inode号,就能知道这个inode号和该块组起始inode号的偏移数量,再根据每个inode结构的大小(256字节或其它大小),就能计算出来对应的inode结构。\n所以,目录的data block中的inode number和inode table中的inode是通过计算的方式一一映射起来的。从另一个角度上看,目录data block中的inode number是找到inode table中对应inode记录的唯一方式。\n考虑一种比较特殊的情况:目录data block的记录已经删除,但是该记录对应的inode结构仍然存在于inode table中。这种inode称为孤儿inode(orphan inode):存在于inode table中,但却无法再索引到它。因为目录中已经没有该inode对应的文件记录了,所以其它进程将无法找到该inode,也就无法根据该inode找到该文件之前所占用的data block,这正是创建便删除所实现的真正临时文件,该临时文件只有当前进程和子进程才能访问。\nSuperBlock SuperBlock 表示特定加载的文件系统,用于描述和维护文件系统的状态,由 VFS 定义,但里面的数据根据具体的文件系统填充。每个 SuperBlock 代表了一个具体的磁盘分区,里面包含了当前磁盘分区的信息,如文件系统类型、剩余空间等。SuperBlock 的一个重要成员是链表s_list,包含所有修改过的 INode,使用该链表很容易区分出来哪个文件被修改过,并配合内核线程将数据写回磁盘。SuperBlock 的另一个重要成员是s_op,定义了针对其 INode 的所有操作方法,例如标记、释放索引节点等一系列操作。\n1 2 3 4 5 6 7 8 9 10 11 // https://elixir.bootlin.com/linux/v5.4.93/source/include/linux/fs.h#L1425 结构体已删减 struct super_block { struct list_head s_list; // 指向链表的指针 dev_t s_dev; // 设备标识符 unsigned long s_blocksize; // 以字节为单位的块大小 loff_t s_maxbytes; // 文件大小上限 struct file_system_type *s_type; // 文件系统类型 const struct super_operations *s_op; // SuperBlock 操作函数,write_inode、put_inode 等 const struct dquot_operations *dq_op; // 磁盘限额函数 struct dentry *s_root; // 根目录 } SuperBlock 是一个非常复杂的结构,通过 SuperBlock 我们可以将一个实体文件系统挂载到 Linux 上,或者对 INode 进行增删改查操作。所以一般文件系统都会在磁盘上存储多份 SuperBlock,防止数据意外损坏导致整个分区无法读取。\nInode Inode包含很多的文件元信息,但不包含文件名,例如:字节数、属主UserID、属组GroupID、读写执行权限、时间戳等。而文件名存放在目录当中,但Linux系统内部不使用文件名,而是使用inode号码识别文件。对于系统来说文件名只是inode号码便于识别的别称。\nStat 查看Inode\n1 2 3 4 5 6 7 8 9 10 11 12 [root@localhost ~]# mkdir test [root@localhost ~]# echo \u0026#34;this is test file\u0026#34; \u0026gt; test.txt [root@localhost ~]# stat test.txt File: ‘test.txt’ Size: 18 Blocks: 8 IO Block: 4096 regular file Device: fd00h/64768d Inode: 33574994 Links: 1 Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root) Context: unconfined_u:object_r:admin_home_t:s0 Access: 2019-08-28 19:55:05.920240744 +0800 Modify: 2019-08-28 19:55:05.920240744 +0800 Change: 2019-08-28 19:55:05.920240744 +0800 Birth: - 三个主要的时间属性:\nctime:change time是最后一次改变文件或目录(属性)的时间,例如执行chmod,chown等命令。 atime:access time是最后一次访问文件或目录的时间。 mtime:modify time是最后一次修改文件或目录(内容)的时间。 file 1 2 3 4 [root@localhost ~]# file test test: directory [root@localhost ~]# file test.txt test.txt: ASCII text Inode Number 表面上,用户通过文件名打开文件,实际上,系统内部将这个过程分为三步:\n系统找到这个文件名对应的inode号码; 通过inode号码,获取inode信息; 根据inode信息,找到文件数据所在的block,并读出数据。 其实系统还要根据inode信息,看用户是否具有访问的权限,有就指向对应的数据block,没有就返回权限拒绝。\n直接查看文件i节点号,也可以通过stat查看文件inode信息查看i节点号。\n1 2 [root@localhost ~]# ls -i 33574991 anaconda-ks.cfg 2086 test 33574994 test.txt Inode 大小 inode也会消耗硬盘空间,所以格式化的时候,操作系统自动将硬盘分成两个区域。一个是数据区,存放文件数据;另一个是inode区,存放inode所包含的信息。每个inode的大小,一般是128字节或256字节。通常情况下不需要关注单个inode的大小,而是需要重点关注inode总数。inode总数在格式化的时候就确定了。\ndf -i 查看硬盘分区的inode总数和已使用情况\n1 2 3 4 5 6 7 8 9 10 11 [root@***]# df -i Filesystem Inodes IUsed IFree IUse% Mounted on devtmpfs 16219999 413 16219586 1% /dev tmpfs 16222589 2 16222587 1% /dev/shm tmpfs 16222589 602 16221987 1% /run tmpfs 16222589 16 16222573 1% /sys/fs/cgroup /dev/vda1 2621440 122606 2498834 5% / /dev/vdb1 131072000 134633 130937367 1% /mnt tmpfs 16222589 22 16222567 1% /run/user/0 overlay 2621440 122606 2498834 5% /var/lib/docker/overlay2/2e836ead8a69c7413ec89faecf1357479a6df9ba1e515056d9c89bb121e6fba1/merged shm 16222589 1 16222588 1% /var/lib/docker/containers/1713b72ff979243ef1d36d0a5aaf6c79989a75b267531d341665a7e432fd5a09/shm 文件的读写 文件系统在打开一个文件时,要做的有:\n系统找到这个文件名对应的inode:在目录表中查找该文件名对应的项,由此得到该文件相对应的 inode 号 通过inode号,获取到磁盘中的inode信息,其中最重要的内容是磁盘地址表 通过inode信息中的磁盘地址表,文件系统把分散存放的文件物理块链接成文件的逻辑结构。在磁盘地址表中有 13 个块号,文件将以块号在磁盘地址表中出现的顺序依次读取相应的块。找到文件数据所在的block,读出数据。 根据以上流程,我们可以发现,inode应该是有一个专门的存储区域的,以方便系统快速查找。事实上,一块磁盘创建的时候,操作系统自动将硬盘分成两个区域:存放文件数据的数据区,与存放inode信息的inode区(inode table)。\n每个inode的大小一般是128B或者256B。inode节点的总数,在格式化时就给定,一般是每1KB或每2KB就设置一个inode。假定在一块1GB的硬盘中,每个inode节点的大小为128字节,每1KB就设置一个inode,那么inode table的大小就会达到128MB,占整块硬盘的12.8%。\n也就是说,每个分区的inode总数从格式化之后就固定了,因此有可能会出现存储空间没有占满,但因为小文件太多而耗尽了inode的情况。这个时候就只能清除inode占用高的文件或者目录或修改inode数量了,当然,inode的调整需要重新格式化磁盘,需要确保数据已经得到有效备份后,再进行此操作。\n这时候又产生了新的问题:文件创建时要为文件分配哪一个inode号呢?即如何保证分配的inode号没有被占用? 既然是”是否被占用”的问题,使用位图是最佳方案,像bmap记录block的占用情况一样。标识inode号是否被分配的位图称为inodemap简称为imap。这时要为一个文件分配inode号只需扫描imap即可知道哪一个inode号是空闲的。\n(位图法就是bitmap的缩写。所谓bitmap,就是用每一位来存放某种状态,适用于大规模数据,但数据状态又不是很多的情况。) 类似bmap块位图一样,inode号是预先规划好的。inode号分配后,文件删除也会释放inode号。分配和释放的inode号,像是在一个地图上挖掉一块,用完再补回来一样。 imap存在着和bmap和inode table一样需要解决的问题:如果文件系统比较大,imap本身就会很大,每次存储文件都要进行扫描,会导致效率不够高。同样,优化的方式是将文件系统占用的block划分成块组,每个块组有自己的imap范围,以减少检索时间。\nBlock Group Ext4文件系统将磁盘空间划分为若干组,以这一组为单位管理磁盘空间,这个组叫做块组(Block Group)。那么为什么要划分为块组呢?其主要原因是方便对磁盘的管理,由于磁盘被划分为若干组,因此上层访问数据时碰撞的概率就会大大减小,从而提升文件系统的整体性能。简单来说,块组就是一块磁盘区域,而同时其内部有元数据来管理这部分区域的磁盘。\n文件系统使用block group来组织block的原因有以下几点:\n把每个区进一步分为多个块组 (block group),每个块组有独立的inode/block体系 如果文件系统高达数百 GB 时,把所有的 inode 和block 通通放在一起会因为 inode 和 block的数量太庞大,不容易管理 这其实很好理解,因为分区是用户的分区,实际计算机管理时还有个最适合的大小,于是计算机会进一步的在分区中分块 (但这样岂不是可能出现大文件放不了的问题?有什么机制善后吗?) 每个块组实际还会分为分为6个部分,除了inode table 和 data block外还有4个附属模块,起到优化和完善系统性能的作用 利用df -i命令可以查看inode数量方面的信息\nEXT4 disk layout EXT4 是由多个块组(block group)组成的,每个块组的layout如下图所示:EXT4 是由多个块组(block group)组成的,每个块组的layout如下图所示: EXT4上承EXT3和EXT2,将大量的存储空间分成块组(Block Group),从上图看出,一个块组用1个block来存放inode的位图和block的位图,这就决定了块组的最大大小。以默认的4K为例,4KB=32K bit,因此,最多也就能记录32K个块的分配情况。因此一个块组是32K*4KB=128MB。\n一般而言,一个block的size总是4KB,很少需要调整,但是如果缺失需要调整block的大小,那么可以通过mkfs的 -b选项来指定block的大小。但是需要注意到,一旦block-ize发生了变化,那么块组的大小也就发生了变化。这个影响是两方面的,不仅仅是块大小变化了,而且因为一个块的bit发生了变化,由于位图,直接影响了块组容纳的块的个数。后面我们都以4096字节作为block-size\n对于EXT4文件系统而言,上图中的超级快并非每一个块组都要存在,但也不是只有一个super block块。如果只有一个superblock 块组,那么一旦损坏,文件系统也就不能用了,如果每个块组都要分配一个block,空间上有点浪费。因此mkfs的时候,有一个默认的选项sparse_super。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 ➜ nebula-tool cat /etc/mke2fs.conf [defaults] base_features = sparse_super,filetype,resize_inode,dir_index,ext_attr default_mntopts = acl,user_xattr enable_periodic_fsck = 0 blocksize = 4096 inode_size = 256 inode_ratio = 16384 [fs_types] ext3 = { features = has_journal } ext4 = { features = has_journal,extent,huge_file,flex_bg,uninit_bg,dir_nlink,extra_isize,64bit inode_size = 256 } ext4dev = { features = has_journal,extent,huge_file,flex_bg,uninit_bg,dir_nlink,extra_isize inode_size = 256 options = test_fs=1 } small = { blocksize = 1024 inode_size = 128 inode_ratio = 4096 } floppy = { blocksize = 1024 inode_size = 128 inode_ratio = 8192 } big = { inode_ratio = 32768 } huge = { inode_ratio = 65536 } news = { inode_ratio = 4096 } largefile = { inode_ratio = 1048576 blocksize = -1 } largefile4 = { inode_ratio = 4194304 blocksize = -1 } hurd = { blocksize = 4096 inode_size = 128 } 该选项的含义是,将superblock 稀疏地分散在文件系统中:既不是每个块组都有superblock,也不是一共只有一个superblock。那么哪些块组会有superblock呢?如果在用了sparse_super选项(默认选项),超级快位于满足一下条件的块组上\n块组0 块组id为3 或5 或7的幂(注意,块组#1是3的0次幂,因此也有backup superblock)。 从下面输出中不难看出:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 #1 32768 = (32876) *3^0 #3 98304 = (32768) *3^1 #5 163840 = (32768) *5^1 #7 229376 = (32768) *7^1 #9 294912 = (32768) *3^2 #25 ... root@node-1:~# dumpe2fs /dev/sdb2|grep super dumpe2fs 1.42 (29-Nov-2011) Filesystem features: has_journal ext_attr resize_inode dir_index filetype needs_recovery extent flex_bg sparse_super large_file huge_file uninit_bg dir_nlink extra_isize Primary superblock at 0, Group descriptors at 1-464 Backup superblock at 32768, Group descriptors at 32769-33232 Backup superblock at 98304, Group descriptors at 98305-98768 Backup superblock at 163840, Group descriptors at 163841-164304 Backup superblock at 229376, Group descriptors at 229377-229840 Backup superblock at 294912, Group descriptors at 294913-295376 Backup superblock at 819200, Group descriptors at 819201-819664 Backup superblock at 884736, Group descriptors at 884737-885200 Backup superblock at 1605632, Group descriptors at 1605633-1606096 Backup superblock at 2654208, Group descriptors at 2654209-2654672 Backup superblock at 4096000, Group descriptors at 4096001-4096464 Backup superblock at 7962624, Group descriptors at 7962625-7963088 Backup superblock at 11239424, Group descriptors at 11239425-11239888 Backup superblock at 20480000, Group descriptors at 20480001-20480464 Backup superblock at 23887872, Group descriptors at 23887873-23888336 Backup superblock at 71663616, Group descriptors at 71663617-71664080 Backup superblock at 78675968, Group descriptors at 78675969-78676432 Backup superblock at 102400000, Group descriptors at 102400001-102400464 Backup superblock at 214990848, Group descriptors at 214990849-214991312 除了sparse_super选项,EXT4支持一种新的选项 sparse_super2来备份super block,这个comment的大意是,纵然Primary superblock损坏了,那么位于block group #1处的super block 也足以恢复,很少有情况需要用到位于其它位置的备用super block。因此,只提供了2个超级快的备用super block,分别位于block group #1和最后一个block group。引入这种方案,好处不仅仅是节省了磁盘空间,更重要的是使元数据分布的更灵活,比如支持后面提到的packed_meta_blocks扩展选项,将所有元数据固定在存储空间的开始位置。\ninode table 另外一个比较有意思的话题是inode table的长度。对于EXT4的默认情况,一个inode的大小是256字节,inode是EXT4最重要的元数据信息。\n尽管inode bitmap是32K个bit,但是并不意味着每个块组一定要分配32K个inode,因为128M的空间里,存放32K个inode太浪费了,只有几乎所有的文件的大小都小于4K的情况下,才会需要这么多的inode。因此,一个块组预先分配多少个inode,反应的是文件系统对系统内文件平均大小的预期。如果文件系统内存放的文件几乎全是1G以上的大文件,那么分配太多的inode,会浪费宝贵的存储空间。\n1 2 3 4 5 ➜ nebula-tool tune2fs -l /dev/vda1 | grep Inode Inode count: 128016 Inodes per group: 2032 Inode blocks per group: 254 Inode size: 128 从上面的内容不难看出,每个Inode的大小为256字节,一个块组有4096个inode,所有的inode消耗了256个block。这个情况表明,该文件系统一个块组128M的空间,预期文件个数不会超过4096个,即创建文件系统的人认为,文件系统的文件的平均大小不低于32K。如果该文件系统中所有的文件均是1K或者几KB的小文件,就会出现,磁盘空间还有大量的剩余,但是inode已经分配光的情况。这种情况下,再次创建文件就会有No Space之类的报错。本周,我来看到一个这种错误,同事问我df -h明明有大量的空间,为何报这种错误。如何发现文件系统Inode的使用情况呢:\n1 2 3 4 5 6 7 8 9 ➜ nebula-tool df -ih Filesystem Inodes IUsed IFree IUse% Mounted on /dev/vda2 10M 641K 9.3M 7% / devtmpfs 2.0M 368 2.0M 1% /dev tmpfs 2.0M 1 2.0M 1% /dev/shm tmpfs 2.0M 549 2.0M 1% /run tmpfs 2.0M 16 2.0M 1% /sys/fs/cgroup /dev/vda1 126K 335 125K 1% /boot tmpfs 2.0M 61 2.0M 1% /run/user/0 EXT4文件系统mkfs提供了一个 -i的选项,用来调节每个块组inode的个数。该参数的含义是 bytes-per-inode,即格式化的时候,提醒下系统,你认为你该文件系统每个文件的平均大小。使用该值的时候,注意该值不要比block-size小,如果比block-size还要小,意味着很多inode根本没有机会分配出去,纯属浪费。\nflex_bg 上面讲述的是经典的EXT4布局。从EXT4开始,内核引入了flexible block groups的概念。这个弹性块组群是个什么概念呢。就是打破128MB一个块组,块组之间泾渭分明的界限,让多个块组形成一个战斗小组。\n用更确切的话说就是多个块组,将block bitmap聚合在一起,inode bitmap聚合在一起,同时inode table 也聚合在一起,形成一个逻辑块组。这些信息连续的好处是,如果客户连续读,就减少因为inode或bitmap不连续而不得不寻道带来的额外effort。\n该格式化选项,默认是开着的,执行dubugfs -R stats /dev/loop0可以看到如下的参数:\n1 2 3 Inodes per group: 8192 Inode blocks per group: 512 Flex block group size: 16 也就说16个块组组成了一个战斗小组逻辑块组,这16个块组的inode位图,block位图,以及inode table是连续的,如下所示:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 roup 0: block bitmap at 1025, inode bitmap at 1041, inode table at 1057 23513 free blocks, 8181 free inodes, 2 used directories, 8181 unused inodes [Checksum 0x8fd1] Group 1: block bitmap at 1026, inode bitmap at 1042, inode table at 1569 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0xff08] Group 2: block bitmap at 1027, inode bitmap at 1043, inode table at 2081 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xebd4] Group 3: block bitmap at 1028, inode bitmap at 1044, inode table at 2593 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0x89d6] Group 4: block bitmap at 1029, inode bitmap at 1045, inode table at 3105 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xa182] Group 5: block bitmap at 1030, inode bitmap at 1046, inode table at 3617 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0xfc62] Group 6: block bitmap at 1031, inode bitmap at 1047, inode table at 4129 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x79ac] Group 7: block bitmap at 1032, inode bitmap at 1048, inode table at 4641 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0x646a] Group 8: block bitmap at 1033, inode bitmap at 1049, inode table at 5153 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xa43c] Group 9: block bitmap at 1034, inode bitmap at 1050, inode table at 5665 31743 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0xf9dc] Group 10: block bitmap at 1035, inode bitmap at 1051, inode table at 6177 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xed00] Group 11: block bitmap at 1036, inode bitmap at 1052, inode table at 6689 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x6dc8] Group 12: block bitmap at 1037, inode bitmap at 1053, inode table at 7201 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xa756] Group 13: block bitmap at 1038, inode bitmap at 1054, inode table at 7713 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x187c] Group 14: block bitmap at 1039, inode bitmap at 1055, inode table at 8225 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x1d5f] Group 15: block bitmap at 1040, inode bitmap at 1056, inode table at 8737 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes 32768 blocks per group, 32768 fragments per group Group 16: block bitmap at 524288, inode bitmap at 524304, inode table at 524320 24544 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Checksum 0x2f61] Group 17: block bitmap at 524289, inode bitmap at 524305, inode table at 524832 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0xb41c] Group 18: block bitmap at 524290, inode bitmap at 524306, inode table at 525344 32768 free blocks, 8192 free inodes, 0 used directories, 8192 unused inodes [Inode not init, Block not init, Checksum 0x1572] 我们不难看出 Group 0~Group 15组成了战斗小组,这个战斗小组metadata信息是连续的,后面的Group 16~Group 31也是一样的,依次类推。\n1025~1040 block bitmap 1041~1056 inode bitmap 1057~8737+512 inode table EXT4文件系统有一个控制选项inode_readahead_blks,该参数是指定了inode 预读的块数,如果不启用flex_bg,纵然inode_readahead_blks设置的很大,比如4096,但是因为块组之间inode不连续(比如单个块组 inode table只占用了256个块),这种是没有意义的。 root@node-1:/# cat /sys/fs/ext4/sdb2/inode_readahead_blks 32\n对于连续读的场景,flex_bg配合较大的inode_readahead_blks,能提升连续读的性能。《Linux内核精髓:精通Linux内核必会的75个绝技》一书中提到,当inode_readahead_blks 等于1和等于4096时对比,读取内核所有源码文件有6%左右的提升。\nExtent or indirect blocks 在Ext4之前,也就是Ext2和Ext3文件系统中,都是通过间接块的方式存储大文件的数据的。具体如下图所示,文件数据的位置通过inode中i_block成员(15个32为整数成员的数组)指出,其前面12个成员直接指向12个数据块,第13个成员(block12)指向的磁盘块存储的不是文件数据,而是一个指向数据块的指针列表,我们称为一级块,一级间接块最多有block size / 4个指针,block size就是数据块的大小,因为一个索引是4个字节,所以除以4。以此类推,block13通过二级间接块指向具体的数据,而block14则通过三级间接块指向具体的数据。通过这种间接指向的方式实现对大文件的管理。\n文件大小: 按照上述方式计算下来,最大的文件可以使用的总块数为:12 + (block size/4) + (block size/4)^2 + (block size/4)^3,如果block size大小为4K,则为(12 + 2^10 + 2^20 + 2^30) * 2^12 约等于4T。 Ext4文件数据管理方式 Ext4文件系统有两种数据管理方式,一种是inline的方式,可以将数据存储在inode节点内部,另一种是通过extent的方式,将文件数据组织成为一个B树。当然,为了兼容Ext3及之前的文件系统,Ext4也实现了间接块的方式。\nExt4文件系统文件数据管理参考了现代文件系统的实现方式,也即extent方式。如下图所示,其数据管理的入口仍然是inode节点的i_block成员。差异是此时i_block并非一个32位整数数组,而是一个描述B树结构的数据结构(包含ext4_extent_header和ext4_extent_idx)。在该数据结构中,只有叶子节点中存储的数据包含文件逻辑地址与磁盘物理地址的映射关系。在数据管理中有3个关键的数据结构,分别是ext4_extent_header、ext4_extent_idx和ext4_extent。\next4_extent_header 该数据结构在一个磁盘逻辑块的最开始的位置,描述该磁盘逻辑块的B树属性,也即该逻辑块中数据的类型(例如是否为叶子节点)和数量。如果eh_depth为0,则该逻辑块中数据项为B树的叶子节点,此时其中存储的是ext4_extent数据结构实例,如果eh_depth\u0026gt;0,则其中存储的是非叶子节点,也即ext4_extent_idx,用于存储指向下一级的索引。\n1 2 3 4 5 6 7 struct ext4_extent_header { __le16 eh_magic; /* 魔数 */ __le16 eh_entries; /* 可用的项目的数量 */ __le16 eh_max; /* 本区域可以存储最大项目数量 */ __le16 eh_depth; /* 当前层树的深度 */ __le32 eh_generation; }; ext4_extent_idx 该数据结构是B树中的索引节点,该数据结构用于指向下一级,下一级可以仍然是索引节点,或者叶子节点。\n1 2 3 4 5 6 struct ext4_extent_idx { __le32 ei_block; /* 索引覆盖的逻辑块的数量,以块为单位 */ __le32 ei_leaf_lo; /* 指向下一级物理块的位置,*/ __le16 ei_leaf_hi; /* 物理块位置的高16位 */ __u16 ei_unused; }; ext4_extent 描述了文件逻辑地址与磁盘物理地址的关系。通过该数据结构,可以找到文件某个偏移的一段数据在磁盘的具体位置。\n1 2 3 4 5 6 struct ext4_extent { __le32 ee_block; /* 该extent覆盖的第一个逻辑地址,以块为单位 */ __le16 ee_len; /* 该extent覆盖的逻辑块的位置 */ __le16 ee_start_hi; /* 物理块的高16位 */ __le32 ee_start_lo; /* 物理块的低16位 */ }; 上图是一个示意图,表达了通过若干级索引指向磁盘物理块的关系。实际情况是未必有这么多级,可能比这个多,也可能比这个少。如果文件特别小,可能没有索引层,而是i_block中直接是ext4_extent,直接指向磁盘物理块的位置。\n文件操作 系统对文件的操作会可能影响inode:\n复制:创建一个包含全部数据与新inode号的新文件 移动:在同一磁盘下移动时,所在目录改变,inode号与实际数据存储的块的位置都不会变化。跨磁盘移动当然会删除本磁盘的数据并创建一条新的数据在另一块磁盘中。 硬链接: 同一个inode号代表的文件有多个文件名,即可以用不同的文件名访问同一份数据,但是它们指向的inode编号是相同的,并且文件元数据中链接数会增加。不可以对目录创建硬链接。 软链接: 软链接的本质是一个链接文件,其中存储的了对另一个文件的指针。所以对一个文件创建软链接,inode号不相同,创建软链接文件的链接数不会增加。可以对目录创建软链接。 删除:当删除文件时,会先检查inode中的链接数。如果链接数大于1,就只会删掉一个硬链接,不影响数据。如果链接数等于1,那么这个inode就会被释放掉,对应的inode指向的块也会被标记为空闲的(数据不会被置零,所以硬盘数据被误删除后,若没有新数据写入可恢复)。如果是软链接,原文件被删除后链接文件就变成了悬挂链接(dangling link),无法正常访问了。 利用inode还可以删除一些文件名中有转义字符或控制字符的文件,最典型的就是开头为减号-的文件。这种无法直接用rm命令来搞,就可以先查出它们的inode编号再删除: find ./ -inum 10086 -exec rm {} \\\n特有现象 由于inode号码与文件名分离,导致一些Unix/Linux系统具备以下几种特有的现象。\n文件名包含特殊字符,可能无法正常删除。这时直接删除inode,能够起到删除文件的作用;find ./* -inum 节点号 -delete 移动文件或重命名文件,只是改变文件名,不影响inode号码; 打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。 这种情况使得软件更新变得简单,可以在不关闭软件的情况下进行更新,不需要重启。因为系统通过inode号码,识别运行中的文件,不通过文件名。更新的时候,新版文件以同样的文件名,生成一个新的inode,不会影响到运行中的文件。等到下一次运行这个软件的时候,文件名就自动指向新版文件,旧版文件的inode则被回收。\ninode 耗尽故障 由于硬盘分区的inode总数在格式化后就已经固定,而每个文件必须有一个inode,因此就有可能发生inode节点用光,但硬盘空间还剩不少,却无法创建新文件。同时这也是一种攻击的方式,所以一些公用的文件系统就要做磁盘限额,以防止影响到系统的正常运行。至于修复,很简单,只要找出哪些大量占用i节点的文件删除就可以了。\n硬链接和软链接 Linux系统中有一种比较特殊的文件称之为链接(link)。通俗地说,链接就是从一个文件指向另外一个文件的路径。linux中链接分为俩种,硬链接和软链接。简单来说,硬链接相当于源文件和链接文件在磁盘和内存中共享一个inode,因此,链接文件和源文件有不同的dentry,因此,这个特性决定了硬链接无法跨越文件系统,而且我们无法为目录创建硬链接。软链接和硬链接不同,首先软链接可以跨越文件系统,其次,链接文件和源文件有着不同的inode和dentry,因此,两个文件的属性和内容也截然不同,软链接文件的文件内容是源文件的文件名。 硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件。 软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已。 软硬链接实现的原理不同\n硬链接是建立一个目录项,包含文件名和文件的inode,但inode是原来文件的inode号,并不建立其所对应得数据。所以硬链接并不占用inode。 软链接也创建一个目录项,也包含文件名和文件的inode,但它的inode指向的并不是原来文件名所指向的数据的inode,而是新建一个inode,并建立数据,数据指向的是原来文件名,所以原来文件名的字符数,即为软链接所占字节数 软硬链接所能创建的目标有区别\n因为每个分区各有一套不同的inode表,所以硬链接不能跨分区创建而软链接可以,因为软链接指向的是文件名。 硬链接不能指向目录 如果说目录有硬链接那么可能引入死循环,但是你可能会疑问软链接也会陷入循环啊,答案当然不是,因为软链接是存在自己的数据的,可以查看自己的文件属性,既然可以判断出来软链接,那么自然不会陷入循环,并且系统在连续遇到8个符号链接后就停止遍历。但是硬链接可就不行了,因为他的inode号一致,所以就判断不出是硬链接,所以就会陷入死循环了。\n相关概念在硬盘上的图示 Super Block, Group Descriptor Inode 展示的是ext2 的inode ext4 block 用的是extent tree 方式,不是现在展示的indirect 方式 Directory File Size Comparision of File System 文件描述符和Inode 的关系 概念 Linux 系统中,把一切都看做是文件,当进程打开现有文件或创建新文件时,内核向进程返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文件,所有执行I/O操作的系统调用都会通过文件描述符。\n文件描述符与文件、进程的关系 1 2 3 4 5 6 fd = open(pathname, flags, mode) // 返回了该文件的fd rlen = read(fd, buf, count) // IO操作均需要传入该文件的fd值 wlen = write(fd, buf, count) status = close(fd) 每当进程用open()函数打开一个文件,内核便会返回该文件的文件描述符(一个非负的整形值),此后所有对该文件的操作,都会以返回的fd文件描述符为参数。\n文件描述符可以理解为进程文件描述表这个表的索引,或者把文件描述表看做一个数组的话,文件描述符可以看做是数组的下标。当需要进行I/O操作的时候,会传入fd作为参数,先从进程文件描述符表查找该fd对应的那个条目,取出对应的那个已经打开的文件的句柄,根据文件句柄指向,去系统fd表中查找到该文件指向的inode,从而定位到该文件的真正位置,从而进行I/O操作。\n每个文件描述符会与一个打开的文件相对应 不同的文件描述符也可能指向同一个文件 相同的文件可以被不同的进程打开,也可以在同一个进程被多次打开 文件描述符相关表 进程级的文件描述符表 系统级的文件描述符表 文件系统的i-node表 进程级别的文件描述表 linux内核会为每一个进程创建一个task_truct结构体来维护进程信息,称之为 进程描述符,该结构体中 指针struct files_struct *files 指向一个名称为file_struct的结构体,该结构体即 进程级别的文件描述表。 它的每一个条目记录的是单个文件描述符的相关信息:\nfd控制标志,前内核仅定义了一个,即close-on-exec 文件描述符所打开的文件句柄的引用 文件句柄这里可以理解为文件名,或者文件的全路径名,因为linux文件系统文件名和文件是独立的,以此与inode区分\n系统级别的文件描述符表 内核对系统中所有打开的文件维护了一个描述符表,也被称之为 【打开文件表】,表格中的每一项被称之为 【打开文件句柄】,一个【打开文件句柄】 描述了一个打开文件的全部信息。 主要包括:\n当前文件偏移量(调用read()和write()时更新,或使用lseek()直接修改) 打开文件时所使用的状态标识(即,open()的flags参数) 文件访问模式(如调用open()时所设置的只读模式、只写模式或读写模式) 与信号驱动相关的设置 对该文件i-node对象的引用 文件类型(例如:常规文件、套接字或FIFO)和访问权限 一个指针,指向该文件所持有的锁列表 文件的各种属性,包括文件大小以及与不同类型操作相关的时间戳 Inode表 每个文件系统会为存储于其上的所有文件(包括目录)维护一个i-node表,单个i-node包含以下信息:\n文件类型(file type),可以是常规文件、目录、套接字或FIFO 访问权限 文件锁列表(file locks) 文件大小 等等 i-node存储在磁盘设备上,内核在内存中维护了一个副本,这里的i-node表为后者。副本除了原有信息,还包括:引用计数(从打开文件描述体)、所在设备号以及一些临时属性,例如文件锁。 在进程A中,文件描述符1和30都指向了同一个打开的文件句柄(标号23)。这可能是通过调用dup()、dup2()、fcntl()或者对同一个文件多次调用了open()函数而形成的。 dup(),也称之为文件描述符复制函数,在某些场景下非常有用,比如:标准输入/输出重定向。在shell下,完成这个操作非常简单,大部分人都会,但是极少人思考过背后的原理。\n大概描述一下需要的几个步骤,以标准输出(文件描述符为1)重定向为例:\n打开目标文件,返回文件描述符n; 关闭文件描述符1; 调用dup将文件描述符n复制到1; 关闭文件描述符n; 进程A的文件描述符2和进程B的文件描述符2都指向了同一个打开的文件句柄(标号73)。这种情形可能是在调用fork()后出现的(即,进程A、B是父子进程关系) 子进程会继承父进程的文件描述符表,也就是子进程继承父进程打开的文件 这句话的由来。 或者当某进程通过UNIX域套接字将一个打开的文件描述符传递给另一个进程时,也会发生。再者是不同的进程独自去调用open函数打开了同一个文件,此时进程内部的描述符正好分配到与其他进程打开该文件的描述符一样。\n此外,进程A的描述符0和进程B的描述符3分别指向不同的打开文件句柄,但这些句柄均指向i-node表的相同条目(1976),换言之,指向同一个文件。发生这种情况是因为每个进程各自对同一个文件发起了open()调用。同一个进程两次打开同一个文件,也会发生类似情况。\n文件描述符限制 有资源的地方就有战争,“文件描述符”也是一种资源,系统中的每个进程都需要有“文件描述符”才能进行改变世界的宏图霸业。世界需要秩序,于是就有了“文件描述符限制”的规定。\n如下表: 永久修改用户级限制时有三种设置类型:\nsoft 指的是当前系统生效的设置值 hard 指的是系统中所能设定的最大值 “-” 指的是同时设置了 soft 和 hard 的值 ","permalink":"https://reid00.github.io/en/posts/os_network/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E4%B9%8B%E6%96%87%E4%BB%B6%E7%B3%BB%E7%BB%9F/","summary":"文件系统 文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会","title":"操作系统之文件系统"},{"content":"什么是内存 最直观的,我们买手机,电脑,内存条,都会标明内存是多大,例如途中的8G,16G,128G都指的内存大小。 我们应该都听说过 RAM 存储器,它是一种半导体存储器件。RAM 是英文单词 Random Access Memory 的缩写,即“随机”的意思。所以 RAM 存储器也称为“随机存储器”。\n那么 RAM 存储器和内存有什么关系呢?内存就是许多 RAM 存储器的集合,就是将许多 RAM 存储器集成在一起的电路板。RAM 存储器的优点是存取速度快、读写方便,所以内存的速度当然也就快了。\n操作系统发展历史 稍微了解操作系统历史的人,都知道没有操作系统的裸机-\u0026gt;一次只能运行一个程序的单道批处理系统-\u0026gt;多道批处理系统-\u0026gt;分时系统这个发展历程。\n裸机时代 主要是人工操作,程序员将对应用程序和数据的已穿孔的纸带(或卡片)装入输入机,然后启动输入机把程序和数据输入计算机内存,接着通过控制台开关启动程序针对数据运行;计算完毕,打印机输出计算结果;用户取走结果并卸下纸带(或卡片)后,才让下一个用户上机。\n人机矛盾:手工操作的慢速度和计算机的高速度之间形成了尖锐矛盾,手工操作方式已严重损害了系统资源的利用率(使资源利用率降为百分之几,甚至更低),不能容忍。唯一的解决办法:只有摆脱人的手工操作,实现作业的自动过渡。这样就出现了成批处理。\n单道批处理系统 特点是一次只能运行一个进程,只有运行完毕后才能将下一个进程加载到内存里面,所以进程的数据都是直接放在物理内存上的,因此CPU是直接操作内存的物理地址,这个时候不存在虚拟逻辑地址,因为一次只能运行一个程序。\n矛盾:每次主机内存中仅存放一道作业,每当它运行期间发出输入/输出(I/O)请求后,高速的CPU便处于等待低速的I/O完成状态,致使CPU空闲。\n多道批处理系统 到后来发展出了多道程序系统,它要求在计算机中存在着多个进程,处理器需要在多个进程间进行切换,当一道程序因I/O请求而暂停运行时,CPU便立即转去运行另一道程序。\n问题来了,这么多进程,内存不够用怎么办,各个进程同时运行时内存地址互相覆盖怎么办?\n这时候就出现问题了,链接器在链接一个可执行文件的时候,总是默认程序的起始地址为0x0,但物理内存上只有一个0x0的地址呀?也许你会说:”没关系,我们可以在程序装入内存的时候再次动态改变它的地址.”好吧我忍了。但如果我的物理内存大小只有1G,而现在某一个程序需要超过1G的空间怎么办呢?你还能用刚才那句话解释吗?\n操作系统的发展,包括后面的分时系统,其实都是在解决协调各个环节速度不匹配的矛盾。\nCPU比磁盘速度快太多 存储器层次之间的作用和关联为金字塔形状,CPU不可以直接操控磁盘,是通过操控内存来进行工作的,因为磁盘的速度远远小于CPU的速度,跟不上,需要中间的内存层进行缓冲。\n内存速度比硬盘速度快的原理: 内存的速度之所以比硬盘的速度快(不是快一点,而是快很多),是因为它们的存储原理和读取方式不一样。\n硬盘是机械结构,通过磁头的转动读取数据。一般情况下台式机的硬盘为每分钟 7200 转,而笔记本的硬盘为每分钟 5400 转。 而内存是没有机械结构的,内存是通过电存取数据的。\n内存通过电存取数据,本质上就是因为 RAM 存储器是通过电存储数据的。但也正因为它们是通过电存储数据的,所以一旦断电数据就都丢失了。因此内存只是供数据暂时逗留的空间,而硬盘是永久的,断电后数据也不会消失。\n小结:程序执行前需要先放到内存中才能被CPU处理,因此内存的主要作用就是缓和CPU与硬盘之间的速度矛盾。\n程序运行过程 在多道程序环境下,系统中会有多个程序并发执行,也就是说会有多个程序的数据需要同时放到内存中。那么,如何区分各个程序的数据是放在什么地方的呢?\n方案: 给内存的存储单元编地址。 程序运行过程如下: 编译: 把高级语言翻译为机器语言;\n链接: 由链接程序将编译后形成的一组目标模块,以及所需库函数链接在一起,形成一个完整的装入模块;\n装入(装载): 由装入程序将装入模块装入内存运行; 三种链接方式 静态链接 在程序运行之前,先将各目标模块及它们所需的库函数链接成一个完整的可执行文件(装入模块),即得到完整的逻辑地址,之后不再拆开。 装入时动态链接 运行前边装入边链接的链接方式。 运行时动态链接 运行时该目标模块时,才对它进行链接,用不到的模块不需要装入内存。其优点是便于修改和更新,便于实现对目标模块的共享。 可以看到运行时动态链接,不需要一次性将模块全部装入内存,可以等到运行时需要的时候再动态的连接进去,这样一来就就提供了内存不够用的问题的解决思路,还可以这样,用到了再链接进去\n三种装入方式 绝对装入 编译或汇编时得到绝对地址,即内存物理地址,直接存到对应的物理地址。 单道处理系统就是直接操作物理地址,因此绝对装入只适用于单道程序环境。\n静态重定位装入 又称可重定位装入,这里引入逻辑地址,装入时将逻辑地址重定位转化为物理地址,多道批处理系统的使用方式。 静态重定位的特点是在一个作业装入内存时,必须分配其要求的全部内存空间,如果没有足够的内存,就不能装入该作业。作业一旦进入内存后,在运行期间就不能再移动,也不能再申请内存空间。\n动态重定位装入 又称动态运行时装入,运行时将逻辑地址重定位转化为物理地址,这种方式需要一个重定位寄存器的支持,当然现代操作系统使用的都是这种。\n逻辑地址都是从0开始的,假设装入的起始物理地址为100,动态重定位装入如下图: 内存管理的职责 内存管理的概念,包含三部分:\n1 内存空间的分配和回收 2 内存空间的扩充 3 地址转化 4 存储保护 内存空间的分配和回收 - 连续内存管理方式 单一连续分配方式 固定分区分配 动态分区分配 单一连续分配方式 在单一连续分配方式中,内存被分为系统区和用户区。系统区通常位于内存的低地址部分,用于存放操作系统相关数据;用户区用于存放用户进程相关数据。内存中只能有一道用户程序,用户程序独占整个用户区空间。\n优点:实现简单;无外部碎片;\n缺点:只能用于单用户、单任务的操作系统中;有内部碎片;存储器利用率极低。 固定分区分配 将整个用户空间划分为若干个固定大小的分区,在每个分区中只装入一道作业,这样就形成了最早的、最简单的一种可运行多道程序的内存管理方式。\n操作系统需要建立一个数据结构——分区说明表,来实现各个分区的分配与回收。每个表项对应一个分区,通常按分区大小排列。每个表项包括对应分区的 大小、起始地址、状态(是否已分配),\n当某用户程序要装入内存时,由操作系统内核程序根据用户程序大小检索该表,从中找到一个能满足大小的、未分配的分区,将之分配给该程序,然后修改状态为“已分配”。\n优点: 实现简单,无外部碎片。\n缺点: 会产生内部碎片,内存利用率低。\n动态分区分配 动态分区分配又称为可变分区分配。这种分配方式不会预先划分内存分区,而是在进程装入内存时, 根据进程的大小动态地建立分区,并使分区的大小正好适合进程的需要。因此系统分区的大小和数 目是可变的。(eg:假设某计算机内存大小为 64MB,系统区 8MB,用户区共 56 MB\u0026hellip;)\n产生三个问题:\n系统要用什么样的数据结构记录内 存的使用情况? (常用的 空闲分区表和空闲分区链) 当很多个空闲分区都能满足需求时, 应该选择哪个分区进行分配? 如何进行分区的分配与回收操作? 动态分区分配又称为可变分区分配。这种分配方式不会预先划分内存分区,而是在进程装入内存时,根据进程的大小动态地建立分区,并使分区的大小正好适合进程的需要。因此系统分区的大小和数目是可变的。\n缺点:动态分区分配没有内部碎片,但是有外部碎片。\n内部碎片:分配给某进程的内存区域中,有些部分没有用上。 外部碎片:是指内存中的某些空闲分区由于太小而难以利用。\n如果内存中空闲空间的总和本来可以满足某进程的要求, 但由于进程需要的是一整块连续的内存空间,因此这些 “碎片”不能满足进程的需求。 可以通过紧凑(拼凑,Compaction)技术来解决外部碎片。 动态分区分配算法 首次适应算法: 每次都从低地址开始查找,找到第一个能满足大小的空闲分区\n最佳适应算法:由于动态分区分配是一种连续分配方式,为各进程分配的空间必须是连续的一整片区域。因此为了保证当“大进程”到来时能有连续的大片空间,可以尽可能多地留下大片的空闲区,即,优先使用更小的空闲区\n最坏适应算法:为了解决最佳适应算法的问题——即留下太多难以利用的小碎片,可以在每次分配时 优先使用最大的连续空闲区,这样分配后剩余的空闲区就不会太小,更方便使用\n邻近适应算法:首次适应算法每次都从链头开始查找的。这可能会导致低地址部分出现很多小的空闲分区,而每次分配查找时,都要经过这些分区,因此也增加了查找的开销。如果每次都从上次查找结束的位置开始检索,就能解决上述问题。 内存空间的分配与回收 - 非连续分配管理方式 连续分配:为用户进程分配的必须是一个连续的内存空间。 非连续分配:为用户进程分配的可以是一些分散的内存空间。\n基本分页存储管理 基本分段存储管理 段页式存储管理 分页-什么是基本分页存储 将内存空间分为一个个大小相等的分区(比如:每个分区 4KB),每个分区就是一个“页框”(页框=页帧=内存块=物理块=物理页面)。每个页框有一个编号,即“页框号”(页框号=页帧号=内存块号=物理块号=物理页号),页框号从0开始。 将进程的逻辑地址空间也分为与页框大小相等的一个个部分,每个部分称为一个“页”或“页面” 。每个页面也有一个编号,即“页号”,页号也是从0开始。\n操作系统以页框为单位为各个进程分配内存空间。进程的每个页面分别放入一个页框中。也就是说,进程的页面与内存的页框有一一对应的关系。各个页面不必连续存放,可以放到不相邻的各个页框中。\n注: 进程的最后一个页面可能没有一个页框那么大。也就是第 16K-1 内存,分页存储有可能产生内部碎片,因此页框不能太大,否则可能产生过大的内部碎片造成浪费。\n分页-页表 为了能知道进程的每个页面在内存中存放的位置,操作系统要为每个进程建立一张页表,页表通常存在PCB(进程控制块)中。 分页-分页之后的地址转换 页号 = 逻辑地址 / 页面长度 (取除法的整数部分) 页内偏移量 = 逻辑地址 % 页面长度(取除法的余数部分)\n如何实现地址转换:\n计算出逻辑地址对应的 逻辑页号和逻辑页内偏移量 查页表, 找到对应页面在物理内存的位置 - 页框(内存块) 物理地址 = 物理内存块地址 + 业内偏移量 基本地址变换 基本地址变换机构可以借助进程的页表将逻辑地址转换为物理地址。 通常会在系统中设置一个页表寄存器(PTR),存放页表在内存中的起始地址F 和页表长度M。 进程未执行时,页表的始址 和 页表长度 放在进程控制块(PCB)中,当进程被调度时,操作系统内核会把它们放到页表寄存器中。\n快表地址变换 快表,又称联想寄存器(TLB, translation lookaside buffer ),是一种访问速度比内存快很多的高速缓存(TLB不是内存!),用来存放最近访问的页表项的副本,可以加速地址变换的速度。与此对应,内存中的页表常称为慢表。\n注:TLB 和 普通 Cache 的区别——TLB 中只有页表项的副本,而普通 Cache 中可能会有其他各种数据的副本 快表快多少? 例:某系统使用基本分页存储管理,并采用了具有快表的地址变换机构。访问一次快表耗时 1us,访问一次内存耗时 100us。若快表的命中率为 90%,那么访问一个逻辑地址的平均耗时是多少? (1+100) * 0.9 + (1+100+100) * 0.1 = 111 us\n有的系统支持快表和慢表同时查找,如果是这样,平均耗时应该是 (1+100) * 0.9 + (100+100) * 0.1 = 110.9 us\n若未采用快表机制,则访问一个逻辑地址需要 100+100 = 200us 显然,引入快表机制后,访问一个逻辑地址的速度快多了。\n分页-两级页表 单级页表的问题: 问题一: 根据页号查询页表的方法:K 号页对应的页表项存放位置 = 页表始址 + K * 4 ,页表必须连续存放,因此当页表很大时,需要占用很多个连续的页框;\n问题二:没有必要让整个页表常驻内存,因为进程在一段时间内可能只需要访问某几个特定的页面。\n解决办法:把页表再分页并离散存储,然后再建立一张页表记录页表各个部分的存放位置,称为页目录表,或称外层页表,或称顶层页表。 分页-多级页表 分段-什么是分段 进程的地址空间:按照程序自身的逻辑关系划分为若干个段,每个段都有一个段名(在低级语言 中,程序员使用段名来编程),每段从0开始编址。\n内存分配规则: 以段为单位进行分配,每个段在内存中占据连续空间,但各段之间可以不相邻。 分段-段表 分段-地址转换 分段 VS 分页 1.1 页是信息的物理单位。分页的主要目的是为了实现离散分配,提高内存利用率。分页仅仅是系统管理上的需要,完全是系统行为,对用户是不可见的。 1.2 段是信息的逻辑单位。分段的主要目的是更好地满足用户需求。一个段通常包含着一组属于一个逻辑模块的信息。\n2.1 分段对用户是可见的,用户编程时需要显式地给出段名。 2.2 页的大小固定且由系统决定。段的长度却不固定,决定于用户编写的程序。\n3.1 分页的用户进程地址空间是一维的,程序员只需给出一个记忆符即可表示一个地址。 3.2 分段的用户进程地址空间是二维的,程序员在标识一个地址时,既要给出段名,也要给出段内地址。 4.1 分段比分页更容易实现信息的共享和保护。 不能被修改的代码称为纯代码或可重入代码(不属于临界资源),这样的代码是可以共享的。可修改的代码是不能共享的(比如,有一个代码段中有很多变量,各进程并发地同时访问可能造成数据不一致) 分段小结 段页式 分页管理 优点: 内存空间利用率高,不会产生外部碎片,只会有少量的页内碎片 缺点: 不方便按照逻辑模块实现信息的共享和保护\n分段管理 优点: 很方便按照逻辑模块实现信息的共享和保护 缺点: 如果段长过大,为其分配很大的连续空间会很不方便。另外,段式管理会产生外部碎片\n段页式-什么是段页式 每个段对应一个段表项,每个段表项由段号、页表长度、页表存放块号(页表起始 地址)组成。 每个段表项长度相等,段号是隐含的。 内存每个页面对应一个页表项,每个页表项由页号、页面存放的内存块号组成。每个页表项长度相等,页号是隐含的。\n段表式页表 段页式地址转换 段页式小结 内存空间的扩充 很多游戏的大小超过 60GB,按理来说这个游戏程序运行之前需要把 60GB 数据全部放入内存。然而,实际我的电脑内存才 8GB,我还要开着微信浏览器等别的进程,但为什么这个游戏可以顺利运行呢?\n利用虚拟技术(操作系统的虚拟性) 时间局部性: 如果执行了程序中的某条指令,那么不久后这条指令很有可能再次执行;如果某个数据被访问过,不久之后该数据很可能再次被访问。(因为程序中存在大量的循环);\n空间局部性: 一旦程序访问了某个存储单元,在不久之后,其附近的存储单元也很有可能被访问。 (因为很多数据在内存中都是连续存放的,并且程序的指令也是顺序地在内存中存放的)\n这个程序执行时,会频繁访问10号 和23号页面\n1 2 3 4 5 6 7 int i = 0; int a[100]; while (i \u0026lt; 100) { a[i] = i; i ++ ; } 虚拟内存大小是多少?\n虚拟内存的最大容量是由计算机的地址结构(CPU寻址范围)确定的,虚拟内存的实际容量 = min(内存和外存容量之和,CPU寻址范围)\n如:某计算机地址结构为32位,按字节编址,内存大小为512MB,外存大小为2GB。\n则虚拟内存的最大容量为 2^32 B = 4GB;\n虚拟内存的实际容量 = min (2^32 B, 512MB+2GB) = 2GB+512MB;\n虚拟内存的实现 请求分页管理 请求分页存储管理与基本分页存储管理的主要区别: 请求调页:在程序执行过程中,当所访问的信息不在内存时,由操作系统负责将所需信息从外存调入内存,然后继续执行程序。\n页面置换:若内存空间不够,由操作系统负责将内存中暂时用不到的信息换出到外存。\n请求分页-缺页中断 缺页中断是因为当前执行的指令想要访问的目标页面未调入内存而产生的,因此属于内中断 一条指令在执行期间,可能产生多次缺页中断。(如:copy A to B,即将逻辑地址A中的数据复制到 逻辑地址B,而A、B属于不同的页面,则有可能产生两次中断)\n只有“写指令”才需要修改“修改位”。并且,一般来说只需修改快表中的数据,只有要将快表项删除时才需要写回内存中的慢表。这样可以减少访存次数。\n和普通的中断处理一样,缺页中断处理依然需要保留CPU现场。\n需要用某种“页面置换算法”来决定一个换出页面(下节内容)\n换入/换出页面都需要启动慢速的I/O操作,可见,如果换入/ 换出太频繁,会有很大的开销。\n页面调入内存后,需要修改慢表,同时也需要将表项复制到快表中\n小结: 请求分页-页面置换 页面的换入、换出需要磁盘 I/O,会有较大的开销,因此好的页面置换算法应该追求更少的缺页率\n最佳置换算法(OPT) 先进先出置换算法(FIFO) 最近最久未使用置换算法(LRU) 时钟置换算法(CLOCK) 改进型的时钟置换算法 内存管理的职责-地址转换 为了使编程更方便,程序员写程序时应该只需要关注指令、数据的逻辑地址。而逻辑地址到物理地址的转换(这个过程称为地址重定位)应该由操作系统负责,这样就保证了程序员写程序时不需要关注物理内存的实际情况。\n具体的地址转化方式如上。\n内存管理的职责-存储保护 ","permalink":"https://reid00.github.io/en/posts/os_network/%E6%93%8D%E4%BD%9C%E7%B3%BB%E7%BB%9F%E4%B9%8B%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86/","summary":"什么是内存 最直观的,我们买手机,电脑,内存条,都会标明内存是多大,例如途中的8G,16G,128G都指的内存大小。 我们应该都听说过 RAM 存储器,","title":"操作系统之内存管理"},{"content":"一、XGBoost和GBDT xgboost是一种集成学习算法,属于3类常用的集成方法(bagging,boosting,stacking)中的boosting算法类别。它是一个加法模型,基模型一般选择树模型,但也可以选择其它类型的模型如逻辑回归等。\nxgboost属于梯度提升树(GBDT)模型这个范畴,GBDT的基本想法是让新的基模型(GBDT以CART分类回归树为基模型)去拟合前面模型的偏差,从而不断将加法模型的偏差降低。\n相比于经典的GBDT,xgboost做了一些改进,从而在效果和性能上有明显的提升(划重点面试常考)。\n第一,GBDT将目标函数泰勒展开到一阶,而xgboost将目标函数泰勒展开到了二阶。保留了更多有关目标函数的信息,对提升效果有帮助。\n第二,GBDT是给新的基模型寻找新的拟合标签(前面加法模型的负梯度),而xgboost是给新的基模型寻找新的目标函数(目标函数关于新的基模型的二阶泰勒展开)。\n第三,xgboost加入了和叶子权重的L2正则化项,因而有利于模型获得更低的方差。\n**第四,xgboost增加了自动处理缺失值特征的策略。**通过把带缺失值样本分别划分到左子树或者右子树,比较两种方案下目标函数的优劣,从而自动对有缺失值的样本进行划分,无需对缺失特征进行填充预处理。\n此外,xgboost还支持候选分位点切割,特征并行等,可以提升性能。\n二、XGBoost原理概述 面从假设空间,目标函数,优化算法3个角度对xgboost的原理进行概括性的介绍。\n1,假设空间\n2,目标函数\n3,优化算法\n基本思想:贪心法,逐棵树进行学习,每棵树拟合之前模型的偏差。\n三、第t棵树学什么? 要完成构建xgboost模型,我们需要确定以下一些事情。\n1,如何boost? 如果已经得到了前面t-1棵树构成的加法模型,如何确定第t棵树的学习目标?\n2,如何生成树?已知第t棵树的学习目标的前提下,如何学习这棵树?具体又包括是否进行分裂?选择哪个特征进行分裂?选择什么分裂点位?分裂的叶子节点如何取值?\n我们首先考虑如何boost的问题,顺便解决分裂的叶子节点如何取值的问题。\n四、如何生成第t棵树? xgboost采用二叉树,开始的时候,全部样本都在一个叶子节点上。然后叶子节点不断通过二分裂,逐渐生成一棵树。\nxgboost使用levelwise的生成策略,即每次对同一层级的全部叶子节点尝试进行分裂。\n对叶子节点分裂生成树的过程有几个基本的问题:是否要进行分裂?选择哪个特征进行分裂?在特征的什么点位进行分裂?以及分裂后新的叶子上取什么值?\n叶子节点的取值问题前面已经解决了。我们重点讨论几个剩下的问题。\n1,是否要进行分裂? 根据树的剪枝策略的不同,这个问题有两种不同的处理。如果是预剪枝策略,那么只有当存在某种分裂方式使得分裂后目标函数发生下降,才会进行分裂。\n但如果是后剪枝策略,则会无条件进行分裂,等树生成完成后,再从上而下检查树的各个分枝是否对目标函数下降产生正向贡献从而进行剪枝。\nxgboost采用预剪枝策略,只有分裂后的增益大于0才会进行分裂。\n2,选择什么特征进行分裂?\nxgboost采用特征并行的方法进行计算选择要分裂的特征,即用多个线程,尝试把各个特征都作为分裂的特征,找到各个特征的最优分割点,计算根据它们分裂后产生的增益,选择增益最大的那个特征作为分裂的特征。\n3,选择什么分裂点位?\nxgboost选择某个特征的分裂点位的方法有两种,一种是全局扫描法,另一种是候选分位点法。 全局扫描法将所有样本该特征的取值按从小到大排列,将所有可能的分裂位置都试一遍,找到其中增益最大的那个分裂点,其计算复杂度和叶子节点上的样本特征不同的取值个数成正比。 而候选分位点法是一种近似算法,仅选择常数个(如256个)候选分裂位置,然后从候选分裂位置中找出最优的那个。\n五、XGBoost算法原理小结 XGBoost(eXtreme Gradient Boosting)全名叫极端梯度提升,XGBoost是集成学习方法的王牌,在Kaggle数据挖掘比赛中,大部分获胜者用了XGBoost,XGBoost在绝大多数的回归和分类问题上表现的十分顶尖,本文较详细的介绍了XGBoost的算法原理。\n目录\n最优模型的构建方法\nBoosting的回归思想\nXGBoost的目标函数推导\nXGBoost的回归树构建方法\nXGBoost与GDBT的区别\n最优模型的构建方法\n构建最优模型的一般方法是最小化训练数据的损失函数,我们用字母 L表示,如下式:\n式(1)称为经验风险最小化,训练得到的模型复杂度较高。当训练数据较小时,模型很容易出现过拟合问题。\n因此,为了降低模型的复杂度,常采用下式:\n其中J(f)为模型的复杂度,式(2)称为结构风险最小化,结构风险最小化的模型往往对训练数据以及未知的测试数据都有较好的预测 。\n应用:决策树的生成和剪枝分别对应了经验风险最小化和结构风险最小化,XGBoost的决策树生成是结构风险最小化的结果,后续会详细介绍。\nBoosting方法的回归思想\nBoosting法是结合多个弱学习器给出最终的学习结果,不管任务是分类或回归,我们都用回归任务的思想来构建最优Boosting模型 。\n回归思想:把每个弱学习器的输出结果当成连续值,这样做的目的是可以对每个弱学习器的结果进行累加处理,且能更好的利用损失函数来优化模型。\n假设\n是第 t 轮弱学习器的输出结果,\n是模型的输出结果,\n是实际输出结果,表达式如下:\n上面两式就是加法模型,都默认弱学习器的输出结果是连续值。因为回归任务的弱学习器本身是连续值,所以不做讨论,下面详细介绍分类任务的回归思想。\n分类任务的回归思想:\n根据2.1式的结果,得到最终的分类器:\n分类的损失函数一般选择指数函数或对数函数,这里假设损失函数为对数函数,学习器的损失函数是\n若实际输出结果yi=1,则:\n求(2.5)式对\n的梯度,得:\n负梯度方向是损失函数下降最快的方向,(2.6)式取反的值大于0,因此弱学习器是往增大\n的方向迭代的,图形表示为:\n如上图,当样本的实际标记 yi 是 1 时,模型输出结果\n随着迭代次数的增加而增加(红线箭头),模型的损失函数相应的减小;当样本的实际标记 yi 是 -1时,模型输出结果\n随着迭代次数的增加而减小(红线箭头),模型的损失函数相应的减小 。这就是加法模型的原理所在,通过多次的迭代达到减小损失函数的目的。\n小结:Boosting方法把每个弱学习器的输出看成是连续值,使得损失函数是个连续值,因此可以通过弱学习器的迭代达到优化模型的目的,这也是集成学习法加法模型的原理所在 。\nXGBoost算法的目标函数推导\n目标函数,即损失函数,通过最小化损失函数来构建最优模型,由第一节可知, 损失函数应加上表示模型复杂度的正则项,且XGBoost对应的模型包含了多个CART树,因此,模型的目标函数为:\n(3.1)式是正则化的损失函数,等式右边第一部分是模型的训练误差,第二部分是正则化项,这里的正则化项是K棵树的正则化项相加而来的。\nCART树的介绍:\n上图为第K棵CART树,确定一棵CART树需要确定两部分,第一部分就是树的结构,这个结构将输入样本映射到一个确定的叶子节点上,记为\n。第二部分就是各个叶子节点的值,q(x)表示输出的叶子节点序号,\n表示对应叶子节点序号的值。由定义得:\n树的复杂度定义\nXGBoost法对应的模型包含了多棵cart树,定义每棵树的复杂度:\n其中T为叶子节点的个数,||w||为叶子节点向量的模 。γ表示节点切分的难度,λ表示L2正则化系数。\n如下例树的复杂度表示:\n目标函数推导\n根据(3.1)式,共进行t次迭代的学习模型的目标函数为:\n泰勒公式的二阶导近似表示:\n令\n为Δx,则(3.5)式的二阶近似展开:\n其中:\n表示前t-1棵树组成的学习模型的预测误差,gi和hi分别表示预测误差对当前模型的一阶导和二阶导 ,当前模型往预测误差减小的方向进行迭代。\n忽略(3.8)式常数项,并结合(3.4)式,得:\n通过(3.2)式简化(3.9)式:\n(3.10)式第一部分是对所有训练样本集进行累加,因为所有样本都是映射为树的叶子节点,我们换种思维,从叶子节点出发,对所有的叶子节点进行累加,得:\n令\nGj 表示映射为叶子节点 j 的所有输入样本的一阶导之和,同理,Hj表示二阶导之和。\n得:\n对于第 t 棵CART树的某一个确定结构(可用q(x)表示),其叶子节点是相互独立的,Gj和Hj是确定量,因此,(3.12)可以看成是关于叶子节点的一元二次函数 。最小化(3.12)式,得:\n得到最终的目标函数:\n(3.14)也称为打分函数(scoring function),它是衡量树结构好坏的标准,值越小,代表这样的结构越好 。我们用打分函数选择最佳切分点,从而构建CART树。\nCART回归树的构建方法\n上节推导得到的打分函数是衡量树结构好坏的标准,因此,可用打分函数来选择最佳切分点。首先确定样本特征的所有切分点,对每一个确定的切分点进行切分,切分好坏的标准如下:\nGain表示单节点obj与切分后的两个节点的树obj之差,遍历所有特征的切分点,找到最大Gain的切分点即是最佳分裂点,根据这种方法继续切分节点,得到CART树。若 γ 值设置的过大,则Gain为负,表示不切分该节点,因为切分后的树结构变差了。γ值越大,表示对切分后obj下降幅度要求越严,这个值可以在XGBoost中设定。\nXGBoost与GDBT的区别\nXGBoost生成CART树考虑了树的复杂度,GDBT未考虑,GDBT在树的剪枝步骤中考虑了树的复杂度。\nXGBoost是拟合上一轮损失函数的二阶导展开,GDBT是拟合上一轮损失函数的一阶导展开,因此,XGBoost的准确性更高,且满足相同的训练效果,需要的迭代次数更少。\nXGBoost与GDBT都是逐次迭代来提高模型性能,但是XGBoost在选取最佳切分点时可以开启多线程进行,大大提高了运行速度。\n参考: https://mp.weixin.qq.com/s/cMgd-wBlzjacL21FPK2y7Q https://www.cnblogs.com/pinard/p/10979808.html\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Bxgboost/","summary":"一、XGBoost和GBDT xgboost是一种集成学习算法,属于3类常用的集成方法(bagging,boosting,stacking)中","title":"集成学习之xgboost"},{"content":"Boosting算法的工作机制 用初始权重D(1)从数据集中训练出一个弱学习器1 根据弱学习1的学习误差率表现来更新训练样本的权重D(2),使得之前弱学习器1学习误差率高的样本点的权重变高,使得这些误差率高的点在后面的弱学习器2中得到更多的重视。 然后基于调整权重后的训练集来训练弱学习器2 如此重复进行,直到弱学习器数达到事先指定的数目T,最终将这T个弱学习器通过集合策略进行整合,得到最终的强学习器。 现如今已经有很多的提升方法了,但最著名的就是Adaboost(适应性提升,是Adaptive Boosting的简称)和Gradient Boosting(梯度提升)。让我们先从 Adaboost 说起。\n什么是AdaBoost AdaBoost是一个具有里程碑意义的算法,其中,适应性(adaptive)是指:后续的分类器为更好地支持被先前分类器分类错误的样本实例而进行调整。通过对之前分类结果不对的训练实例多加关注,使新的预测因子越来越多地聚焦于之前错误的情况。\n具体说来,整个AdaBoost迭代算法就3步:\n初始化训练数据的权值分布。如果有N个样本,则每一个训练样本最开始时都被赋予相同的权值:。 训练弱分类器。具体训练过程中,如果某个样本点已经被准确地分类,那么在构造下一个训练集中,它的权值就被降低;相反,如果某个样本点没有被准确地分类,那么它的权值就得到提高。然后,权值更新过的样本集被用于训练下一个分类器,整个训练过程如此迭代地进行下去。 将各个训练得到的弱分类器组合成强分类器。各个弱分类器的训练过程结束后,加大分类误差率小的弱分类器的权重,使其在最终的分类函数中起着较大的决定作用,而降低分类误差率大的弱分类器的权重,使其在最终的分类函数中起着较小的决定作用。换言之,误差率低的弱分类器在最终分类器中占的权重较大,否则较小。 加法模型与前向分布 在学习AdaBoost之前需要了解两个数学问题,这两个数学问题可以帮助我们更好地理解AdaBoost算法,并且在面试官问你算法原理时不至于发懵。下面我们就来看看加法模型与前向分布。\n什么是加法模型 当别人问你“什么是加法模型”时,你应当知道:加法模型顾名思义就是把各种东西加起来求和。如果想要更严谨的定义,不妨用数学公式来表达: 这个公式看上去可能有些糊涂,如果我们套用到提升树模型中就比较容易理解一些。FM(x)表示最终生成的最好的提升树,其中M表示累加的树的个数。b(x;ym)表示一个决策树,$阿尔法m$ 表示第m个决策树的权重,ym表示决策树的参数(如叶节点的个数)。\n什么是前向分布 那么什么是前向分布算法呢?在损失函数 的条件下,加法模型FM(x)成为一个经验风险极小化问题,即使得损失函数极小化: 前向分布算法就是求解这个优化问题的一个思想:因为学习的是加法模型,如果能够从前向后,每一步只学习一个基函数(一棵决策树)及其权重,利用残差逐步逼近优化问题,那么就可以简化优化的复杂度。从而得到前向分布算法为:\n套用在提升树模型中进行理解就是:$fm-1(x)$是前一棵提升树(之前树的累加),在其基础上再加上一棵树$Bxi, Ym$乘上它的权重系数,用这棵树去拟合的残差!$阿尔法m$(观察值与估计值之间的差),再将这两棵树合在一起就得到了新的提升树。实际上就是让下一个基分类器去拟合当前分类器学习出来的残差。\n前向分布与Adaboost损失函数优化的关系 现在了解了加法模型与前向分布。那这两个概念与Adaboost又有什么关系呢?\nAdaboost可以认为其模型是加法模型、损失函数为指数函数、学习算法为前向分步算法的二类分类学习方法。我们可以使用前向分布算法作为框架,推导出Adaboost算法的损失函数优化问题的。\n在Adaboost中,各个基本分类器就相当于加法模型中的基函数$fm-1(x)$,且其损失函数为指数函数$b(xi;ym)$。\n即,需要优化的问题如下: 如果我们令,则上述公式可以改写成为: 因为与要么相等、要么不等。所以可以将其拆成两部分相加的形式:\n算法中需要关注的内容 首先看看算法中都关注了哪些内容: 首先,我们假设训练样本为$(x1,y1), (x2, y2)\u0026hellip;(xn, yn)$\n由于AdaBoost是由一个个的弱分类器迭代训练得到一个强分类器的,因此我们有如下定义:\n弱分类器表达式:$Ht(x)$ 先以二分类为例,它输出的值为1或-1,则有:$Ht(x) ∈{-1, 1}$\n首先,我们假设训练样本为 由于AdaBoost是由一个个的弱分类器迭代训练得到一个强分类器的,因此我们有如下定义:\n弱分类器表达式: 公式推导(通过Z最小化训练误差) Adaboost算法之所以称为十大算法之一,有一个重要原因就是它有完美的数学推导过程,其参数不是人工设定的,而是有解析解的,并且可以证明其误差上界越来越小,趋近于零;且可以推导出来。下面就来看一下公式推导。\n权重公式: 首先要把模型的误差表示出来,只有用数学公式表示出来,才能够讲模型的优化。\n先看第i个样本在t+1个弱学习器的权重是怎样的? 模型误差上限 模型误差上限最小化与Z 求出Z 既然最小化Zt就等同于最小化模型误差上界,那我们得先知道Zt长什么样,然后才能去最小化它。\n我们在前面已经说过,为了保证所有样本的权重加起来等于1。因此需要对每个权重除以归一化系数。即Zt实际上就是t+1时刻所有样本原始权重和,也就是时刻的各点权重乘以调整幅度再累加:\n求出使得Z最小的参数a AdaBoost计算步骤梳理及优缺点 理论上任何学习器都可以用于Adaboost。但一般来说,使用最广泛的Adaboost弱学习器是决策树和神经网络。对于决策树,Adaboost分类用了CART分类树,而Adaboost回归用了CART回归树。\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Badaboost/","summary":"Boosting算法的工作机制 用初始权重D(1)从数据集中训练出一个弱学习器1 根据弱学习1的学习误差率表现来更新训练样本的权重D(2),使得","title":"集成学习之AdaBoost"},{"content":"生成子模型的两种取样方式 那么为了造成子模型之间的差距,每个子模型只看样本中的一部分,这就涉及到两种取样方式:\n放回取样:Bagging,在统计学中也被称为bootstrap。 不放回取样:Boosting 在集成学习中我们通常采用 Bagging 的方式,具体原因如下:\n因为取样后放回,所以不受样本数据量的限制,允许对同一种分类器上对训练集进行进行多次采样,可以训练更多的子模型。 在 train_test_split 时,不那么强烈的依赖随机;而 Boosting的方式,会受到随机的影响; Boosting的随机问题:Pasting 的方式等同于将 500 个样本分成 5 份,每份 100 个样本,怎么分,将对子模型有较大影响,进而对集成系统的准确率有较大影响。 什么是Bagging Bagging,即bootstrap aggregating的缩写,每个训练集称为bootstrap。\nBagging是一种根据均匀概率分布从数据中重复抽样(有放回)的技术 。\nBagging能提升机器学习算法的稳定性和准确性,它可以减少模型的方差从而避免overfitting。它通常应用在决策树方法中,其实它可以应用到任何其它机器学习算法中。\nBagging方法在不稳定模型(unstable models)集合中表现比较好。这里说的不稳定的模型,即在训练数据发生微小变化时产生不同泛化行为的模型(高方差模型),如决策树和神经网络。\n但是Bagging在过于简单模型集合中表现并不好,因为Bagging是从总体数据集随机选取样本来训练模型,过于简单的模型可能会产生相同的预测结果,失去了多样性。\n总结一下Bagging方法:\nBagging通过降低基分类器的方差,改善了泛化误差 其性能依赖于基分类器的稳定性;如果基分类器不稳定,bagging有助于降低训练数据的随机波动导致的误差;如果稳定,则集成分类器的误差主要由基分类器的偏差引起 由于每个样本被选中的概率相同,因此bagging并不侧重于训练数据集中的任何特定实例 Bagging的使用 sklearn为Bagging提供了一个简单的API:BaggingClassifier类(回归是BaggingRegressor)。首先需要传入一个模型作为参数,可以使用决策树;然后需要传入参数n_estimator即集成多少个子模型;参数max_samples表示每次从数据集中取多少样本;参数bootstrap设置为True表示使用有放回取样Bagging,设置为False表示使用无放回取样Pasting。可以通过n_jobs参数来分配训练所需CPU核的数量,-1表示会使用所有空闲核(集成学习思路,极易并行化处理)。\nbagging是不能减小模型的偏差的,因此我们要选择具有低偏差的分类器来集成,例如:没有修剪的决策树。\nBootstrap 在每个预测器被训练的子集中引入了更多的分集,所以 Bagging 结束时的偏差比 Pasting 更高,但这也意味着预测因子最终变得不相关,从而减少了集合的方差。总体而言,Bagging 通常会导致更好的模型,这就解释了为什么它通常是首选的。然而,如果你有空闲时间和 CPU 功率,可以使用交叉验证来评估 Bagging 和 Pasting 哪一个更好。\nOut-of-Bag 对于Bagging来说,一些实例可能被一些分类器重复采样,但其他的有可能不会被采样。由于每个bootstrap的M个样本是有放回随机选取的,因此每个样本不被选中的概率为。当N和M都非常大时,比如N=M=10000,一个样本不被选中的概率p = 36.8%。因此一个bootstrap约包含原样本63.2%,约36.8%的样本未被选中。这些没有被采样的训练实例就叫做Out-of-Bag实例。但注意对于每一个的分类器来说,它们各自的未选中部分不是相同的。\n那么这些未选中的样本有什么用呢?\n因为在训练中分类器从来没有看到过Out-of-Bag实例,所以它可以在这些样本上进行预测,就不用分样本测试集和测试数据集了。\n在sklearn中,可以在训练后需要创建一个BaggingClassifier时设置oob_score=True来进行自动评估。\n1 2 3 4 5 bagging_clf = BaggingClassifier(DecisionTreeClassifier(), n_estimators=5000, max_samples=100, bootstrap=True, oob_score=True) bagging_clf.fit(X, y) bagging_clf.oob_score_ 另一种差异化方式:针对特征取样 我们上面提到的,都是通过对样本进行取样,来得到差异化的子模型。除了取部分样本以外,还可以针对特征进行随机取样。尤其是在样本特征非常多的情况下时,如图像领域中,每个像素点都是一个特征,则Bagging时可以对特征进行取样。\n在BaggingClassifier中有两个超参数,max_features表示每次取多少特征;bootstrap_features设置为True则开启。\n在Bagging中,所有的分类器都可以是并行训练的。与之相对应的,串行训练的Boosting也是集成训练中的一大类别。接下来我们会介绍Boosting算法。\nBoosting思想 Boosting是增强、推动的意思。它是一种迭代的方法,各个子模型彼此之间不是独立的关系,而是相互增强(Boosting)的关系。每一次训练的时候都更加关心分类错误的样例,给这些分类错误的样例增加更大的权重,下一次迭代的目标就是能够更容易辨别出上一轮分类错误的样例。每个模型都在尝试增强整体的效果,最终将这些弱分类器进行加权相加。\n因此与Bagging中各学习器并行处理不同的是:Boosting是串行的,环环相扣且有先后顺序的\nBoosting工作机制 Boosting的工作流程:\n现有原始数据集,首先挑出一些数据,然后在上训练分类器得到。然后用在原始数据集上测试一下,看哪些样本分类对了,哪些样本分类错了。然后把分错的和分对的分别挑出一部分,组成新的数据集(也就是说,刻意筛出有对有错的数据集)。再使用某分类器训练数据集,即专门地、有目的性地学习D1数据集哪些学对了、哪些学错了,得到C2.\n有了C1, C2 两个分类器都在原始数据集D上进行测试,目的是找到C1、C2结果不一致的样本,组成一个新的数据集D3,再用一个分类器训练D3,得到(专门用来解决C1、C2的争端)。\n其算法流程为:\n先从初始训练集训练出一个弱学习器; 再根据基学习器的表现对训练样本分布进行调整,使得先前基学习器做错的训练样本在后续受到更多关注; 然后基于调整后的样本分布来训练下一个基学习器; 如此重复进行,直至基学习器数目达到事先指定的值,最终将这个基学习器进行加权结合。使后续模型应该能够补偿早期模型所造成的错误。 先训练一个分类器,根据这个分类器的误差将训练样本重新调整,也就是加权重。原来的每个样本有同样的机会去作为训练样本,在某个样本上犯的错误越多,其权重也就越大。这很好理解,就是让后面的分类器去重点学习前面分错的样本。\nBoosting和Bagging的区别(重要总结) 1、样本选择上:\nBagging:训练集是在原始集中有放回选取的,从原始集中选出的各轮训练集之间是独立的。 Boosting:每一轮的训练集不变,只是训练集中每个样例在分类器中的权重发生变化。而权值是根据上一轮的分类结果进行调整。 2、样例权重:\nBagging:使用均匀取样,每个样例的权重相等。 Boosting:根据错误率不断调整样例的权值,错误率越大则权重越大。 3、分类器权重:\nBagging:所有分类器的权重相等。 Boosting:每个弱分类器都有相应的权重,对于分类误差小的分类器会有更大的权重。 4、并行\u0026amp;串行:\nBagging:各个预测函数可以并行生成。 Boosting:各个预测函数只能顺序生成,因为后一个模型参数需要前一轮模型的结果。 偏差和方差(重要!) 偏差(bias)衡量了模型的预测值与实际值之间的偏离关系,反映了模型本身的拟合能力;方差(variance)描述的是训练数据在不同迭代阶段的训练模型中,预测值的变化波动情况(或称之为离散情况)。\nBagging用多个分类器进行并行训练的目的就是降低方差。因为相互独立的分类器多了,就会让目标值更聚合。\n而对于Boosting来说,每一轮都针对于上一轮进行学习,力求准确,即可以降低偏差(残差)。\n因此,在实际使用时,我们就要考虑模型的特性。对于方差低的Bagging(随机森林)来说,采用深度较深且不剪枝的决策树,以此降低其偏差。对于偏差低的Boosting(GBDT)要选简单的、深度浅的决策树。\n在Bagging方法中各个基体学习器之间不存在依赖关系,集成多个模型,综合有差异的子模型,融合出比较好的模型,且可以并行处理。如随机森林算法(RF)等。\n而Boosting方法必须串行生成,各个基学习器存在依赖关系,基于前面模型的训练结果误差生成新的模型,代表的算法有:Adaboost、GBDT、XGBoost等。\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Bbaggingboosting/","summary":"生成子模型的两种取样方式 那么为了造成子模型之间的差距,每个子模型只看样本中的一部分,这就涉及到两种取样方式: 放回取样:Bagging,在统计","title":"集成学习之Bagging,Boosting"},{"content":"什么是GBDT 到底什么是梯度提升树?所谓的GBDT实际上就是:\nGBDT = Gradient Descent + Boosting + Desicion Tree\n与Adaboost算法类似,GBDT也是使用了前向分布算法的加法模型。只不过弱学习器限定了只能使用CART回归树模型,同时迭代思路和Adaboost也有所不同。\n在Adaboost算法中,我们是利用前一轮迭代弱学习器的误差率来更新训练集的权重。而Gradient Boosting是通过算梯度(gradient)来定位模型的不足。\nhttps://mp.weixin.qq.com/s/rmStKvdHq-BOCJo8ZuvgfQ\n最常用的决策树算法: RF, Adaboost, GBDT\nhttps://mp.weixin.qq.com/s/tUl3zhVxLfUd7o06_1Zg2g\nXgboost 的优势和原理 原理: https://www.jianshu.com/p/920592e8bcd2\n​\thttps://www.jianshu.com/p/ac1c12f3fba1\n优势: https://snaildove.github.io/2018/10/02/get-started-XGBoost/\nLightGBM 详解 https://blog.csdn.net/VariableX/article/details/106242202\nGBDT分类算法流程 GBDT的分类算法从思想上和GBDT的回归算法没有区别,但是由于样本输出不是连续的值,而是离散的类别,导致我们无法直接从输出类别去拟合类别输出的误差。\n为了解决这个问题,主要有两个方法:\n用指数损失函数,此时GBDT退化为Adaboost算法。 用类似于逻辑回归的对数似然损失函数的方法。也就是说,我们用的是类别的预测概率值和真实概率值的差来拟合损失。 下面我们用对数似然损失函数的GBDT分类。而对于对数似然损失函数,又有二元分类和多元分类的区别。\nsklearn中的GBDT调参大法 https://mp.weixin.qq.com/s/756Xsy0uhnb8_rheySqLLg\nBoosting重要参数 分类和回归算法的参数大致相同,不同之处会指出。\nn_estimators: 弱学习器的个数。个数太小容易欠拟合,个数太大容易过拟合。默认是100,在实际调参的过程中,常常将n_estimators和参数learning_rate一起考虑。\nlearning_rate: 每个弱学习器的权重缩减系数,也称作步长。如果我们在强学习器的迭代公式加上了正则化项:,则通过learning_rate来控制其权重。对于同样的训练集拟合效果,较小的learning_rate意味着需要更多的弱学习器。通常用二者一起决定算法的拟合效果。所以两个参数n_estimators和learning_rate要一起调参。一般来说,可以从一个小一点的补偿开始调参,默认是1。\nsubsample: 不放回抽样的子采样,取值为(0,1]。如果取值为1,则全部样本都使用,等于没有使用子采样。如果取值小于1,则只有一部分样本会去做GBDT的决策树拟合。选择小于1的比例可以减少方差,即防止过拟合,但是会增加样本拟合的偏差,因此取值不能太低。推荐在[0.5, 0.8]之间,默认是1.0,即不使用子采样。\ninit: 初始化时的弱学习器,即。如果我们对数据有先验知识,或者之前做过一些拟合,可以用init参数提供的学习器做初始化分类回归预测。一般情况下不输入,直接用训练集样本来做样本集的初始化分类回归预测。\nloss: GBDT算法中的损失函数。分类模型和回归模型的损失函数是不一样。\n对于回归模型,可以使用均方误差ls,绝对损失lad,Huber损失huber和分位数损失quantile,默认使用均方误差ls。如果数据的噪音点不多,用默认的均方差ls比较好;如果噪音点较多,则推荐用抗噪音的损失函数huber;而如果需要对训练集进行分段预测,则采用quantile。 对于分类模型,可以使用对数似然损失函数deviance和指数损失函数exponential。默认是对数似然损失函数deviance。在原理篇中对这些分类损失函数有详细的介绍。一般来说,推荐使用默认的\u0026quot;deviance\u0026quot;。它对二元分离和多元分类各自都有比较好的优化。而指数损失函数等于把我们带到了Adaboost算法。 alpha: 这个参数只有回归算法有,当使用Huber损失huber和分位数损失quantile时,需要指定分位数的值。默认是0.9,如果噪音点较多,可以适当降低这个分位数的值。\n弱学习器参数 GBDT使用了CART回归决策树,因此它的参数基本和决策树类似。\nmax_features: 划分时考虑的最大特征数,默认是\u0026quot;None\u0026quot;。默认时表示划分时考虑所有的特征数;如果是\u0026quot;log2\u0026quot;意味着划分时最多考虑个log2N特征;如果是\u0026quot;sqrt\u0026quot;或者\u0026quot;auto\u0026quot;意味着划分时最多考虑根号N个特征。如果是整数,代表考虑的特征绝对数。如果是浮点数,代表考虑特征百分比,即考虑(百分比*N)取整后的特征数。其中N为样本总特征数。一般来说,如果样本特征数不多,比如小于50,我们用默认的\u0026quot;None\u0026quot;就可以了,如果特征数非常多,可以灵活控制划分时考虑的最大特征数,以控制决策树的生成时间。 max_depth: 决策树最大深度。如果不输入,默认值是3。一般来说,数据少或者特征少的时候可以不管这个值。如果模型样本量多,特征也多的情况下,推荐限制这个最大深度,具体的取值取决于数据的分布。 min_samples_split: 内部节点再划分所需最小样本数。限制子树继续划分的条件,如果某节点的样本数少于min_samples_split,则不会继续再尝试选择最优特征来进行划分。默认是2,如果样本量数量级非常大,则增大这个值。 min_samples_leaf: 叶子节点最少样本数。限制叶子节点最少的样本数,如果某叶子节点数目小于样本数,则会和兄弟节点一起被剪枝。默认是1,可以输入最少的样本数的整数,或者最少样本数占样本总数的百分比。如果样本量不大,不需要管这个值。如果样本量数量级非常大,则推荐增大这个值。 min_weight_fraction_leaf: 叶子节点最小的样本权重和这个值限制了叶子节点所有样本权重和的最小值,如果小于这个值,则会和兄弟节点一起被剪枝。默认是0,就是不考虑权重问题。一般来说,如果我们有较多样本有缺失值,或者分类树样本的分布类别偏差很大,就会引入样本权重,这时我们就要注意这个值了。 max_leaf_nodes: 最大叶子节点数。通过限制最大叶子节点数,可以防止过拟合,默认是None,即不限制最大的叶子节点数。如果加了限制,算法会建立在最大叶子节点数内最优的决策树。如果特征不多,可以不考虑这个值,但是如果特征分成多的话,可以加以限制,具体的值可以通过交叉验证得到。 min_impurity_split: 节点划分最小不纯度。这个值限制了决策树的增长,如果某节点的不纯度(基于基尼系数,均方差)小于这个阈值,则该节点不再生成子节点。即为叶子节点 。一般不推荐改动默认值1e-7。 GBDT有很多优点:\n可以灵活处理连续值和离散值等各种类型的数据 可以少做一点特征工程部分 能够处理字段缺失的数据 能够自动组合多个特征,不用关心特征间是否依赖 能够自动处理特征间的交互,处理多种类型的异构数据 可以通过选择损失函数,来增强对异常值的鲁棒性,如:Huber损失函数和Quantile损失函数 GBDT也有一些局限性:\n在高维稀疏数据集上,表现不如神经网络和SVM Boosting家族算法,基学习器需要串行训练,只能通过局部并行提高速度(自采样的SGBT) ","permalink":"https://reid00.github.io/en/posts/ml/%E9%9B%86%E6%88%90%E5%AD%A6%E4%B9%A0%E4%B9%8Bgbdt/","summary":"什么是GBDT 到底什么是梯度提升树?所谓的GBDT实际上就是: GBDT = Gradient Descent + Boosting + Desicion Tree 与Adaboost算法类似,GBDT也是使用了前向分布算法的","title":"集成学习之GBD"},{"content":"1.简介 逻辑回归是面试当中非常喜欢问到的一个机器学习算法,因为表面上看逻辑回归形式上很简单,很好掌握,但是一问起来就容易懵逼。所以在面试的时候给大家的第一个建议不要说自己精通逻辑回归,非常容易被问倒,从而减分。下面总结了一些平常我在作为面试官面试别人和被别人面试的时候,经常遇到的一些问题。\nRegression问题的常规步骤为:\n寻找h函数(即假设估计的函数); 构造J函数(损失函数); 想办法使得J函数最小并求得回归参数(θ); 数据拟合问题 2.正式介绍 如何凸显你是一个对逻辑回归已经非常了解的人呢。那就是用一句话概括它!逻辑回归假设数据服从伯努利分布,通过极大化似然函数的方法,运用梯度下降来求解参数,来达到将数据二分类的目的。\n这里面其实包含了5个点 1:逻辑回归的假设,2:逻辑回归的损失函数,3:逻辑回归的求解方法,4:逻辑回归的目的,5:逻辑回归如何分类。这些问题是考核你对逻辑回归的基本了解。\n逻辑回归的基本假设 任何的模型都是有自己的假设,在这个假设下模型才是适用的。逻辑回归的第一个基本假设是**假设数据服从伯努利分布。**伯努利分布有一个简单的例子是抛硬币,抛中为正面的概率是pp,抛中为负面的概率是1−p1−p.在逻辑回归这个模型里面是假设 hθ(x)hθ(x) 为样本为正的概率,1−hθ(x)1−hθ(x)为样本为负的概率。那么整个模型可以描述为\nhθ(x;θ)=phθ(x;θ)=p\n逻辑回归的第二个假设是假设样本为正的概率是\np=11+e−θTxp=11+e−θTx\n所以逻辑回归的最终形式\nhθ(x;θ)=11+e−θTx\n逻辑回归的求解方法 由于该极大似然函数无法直接求解,我们一般通过对该函数进行梯度下降来不断逼急最优解。在这个地方其实会有个加分的项,考察你对其他优化方法的了解。因为就梯度下降本身来看的话就有随机梯度下降,批梯度下降,small batch 梯度下降三种方式,面试官可能会问这三种方式的优劣以及如何选择最合适的梯度下降方式。\n简单来说 批梯度下降会获得全局最优解,缺点是在更新每个参数的时候需要遍历所有的数据,计算量会很大,并且会有很多的冗余计算,导致的结果是当数据量大的时候,每个参数的更新都会很慢。\n随机梯度下降是以高方差频繁更新,优点是使得sgd(随机梯度下降)会跳到新的和潜在更好的局部最优解,缺点是使得收敛到局部最优解的过程更加的复杂。\n如果使用梯度下降法(批量梯度下降法),那么每次迭代过程中都要对 个样本进行求梯度,所以开销非常大,随机梯度下降的思想就是随机采样一个样本 来更新参数,那么计算开销就从 下降到 。\n随机梯度下降虽然提高了计算效率,降低了计算开销,但是由于每次迭代只随机选择一个样本,因此随机性比较大,所以下降过程中非常曲折\n可以看到多了随机两个字,随机也就是说我们用样本中的一个例子来近似我所有的样本,来调整θ,因而随机梯度下降是会带来一定的问题,因为计算得到的并不是准确的一个梯度,**对于最优化问题,凸问题,**虽然不是每次迭代得到的损失函数都向着全局最优方向, 但是大的整体的方向是向全局最优解的,最终的结果往往是在全局最优解附近。\n小批量梯度下降结合了sgd和batch gd的优点,每次更新的时候使用n个样本。减少了参数更新的次数,可以达到更加稳定收敛结果,一般在深度学习当中我们采用这种方法。小批量梯度下降的开销为 其中 是批量大小。\n其实这里还有一个隐藏的更加深的加分项,看你了不了解诸如Adam,动量法等优化方法。因为上述方法其实还有两个致命的问题。 第一个是如何对模型选择合适的学习率。自始至终保持同样的学习率其实不太合适。因为一开始参数刚刚开始学习的时候,此时的参数和最优解隔的比较远,需要保持一个较大的学习率尽快逼近最优解。但是学习到后面的时候,参数和最优解已经隔的比较近了,你还保持最初的学习率,容易越过最优点,在最优点附近来回振荡,通俗一点说,就很容易学过头了,跑偏了。 第二个是如何对参数选择合适的学习率。在实践中,对每个参数都保持的同样的学习率也是很不合理的。有些参数更新频繁,那么学习率可以适当小一点。有些参数更新缓慢,那么学习率就应该大一点。这里我们不展开,有空我会专门出一个专题介绍。 逻辑回归的目的 该函数的目的便是将数据二分类,提高准确率。 逻辑回归如何分类 逻辑回归作为一个回归(也就是y值是连续的),如何应用到分类上去呢。y值确实是一个连续的变量。逻辑回归的做法是划定一个阈值,y值大于这个阈值的是一类,y值小于这个阈值的是另外一类。阈值具体如何调整根据实际情况选择。一般会选择0.5做为阈值来划分。 逻辑回归的损失函数为什么要使用极大似然函数作为损失函数? 损失函数一般有四种,平方损失函数,对数损失函数,HingeLoss0-1损失函数,绝对值损失函数。将极大似然函数取对数以后等同于对数损失函数。在逻辑回归这个模型下,对数损失函数的训练求解参数的速度是比较快的。至于原因大家可以求出这个式子的梯度更新\n这个式子的更新速度只和相关。和sigmod函数本身的梯度是无关的。这样更新的速度是可以自始至终都比较的稳定。\n为什么不选平方损失函数的呢?其一是因为如果你使用平方损失函数,你会发现梯度更新的速度和sigmod函数本身的梯度是很相关的。sigmod函数在它在定义域内的梯度都不大于0.25。这样训练会非常的慢。\n逻辑回归在训练的过程当中,如果有很多的特征高度相关或者说有一个特征重复了100遍,会造成怎样的影响? 先说结论,如果在损失函数最终收敛的情况下,其实就算有很多特征高度相关也不会影响分类器的效果。\n但是对特征本身来说的话,假设只有一个特征,在不考虑采样的情况下,你现在将它重复100遍。训练以后完以后,数据还是这么多,但是这个特征本身重复了100遍,实质上将原来的特征分成了100份,每一个特征都是原来特征权重值的百分之一\n如果在随机采样的情况下,其实训练收敛完以后,还是可以认为这100个特征和原来那一个特征扮演的效果一样,只是可能中间很多特征的值正负相消了。\n为什么我们还是会在训练的过程当中将高度相关的特征去掉? 去掉高度相关的特征会让模型的可解释性更好 可以大大提高训练的速度。如果模型当中有很多特征高度相关的话,就算损失函数本身收敛了,但实际上参数是没有收敛的,这样会拉低训练的速度。其次是特征多了,本身就会增大训练的时间。 4.逻辑回归的优缺点总结 优点\n形式简单,模型的可解释性非常好。从特征的权重可以看到不同的特征对最后结果的影响,某个特征的权重值比较高,那么这个特征最后对结果的影响会比较大。\n模型效果不错。在工程上是可以接受的(作为baseline),如果特征工程做的好,效果不会太差,并且特征工程可以大家并行开发,大大加快开发的速度。\n训练速度较快。分类的时候,计算量仅仅只和特征的数目相关。并且逻辑回归的分布式优化sgd发展比较成熟,训练的速度可以通过堆机器进一步提高,这样我们可以在短时间内迭代好几个版本的模型。\n资源占用小,尤其是内存。因为只需要存储各个维度的特征值,。\n方便输出结果调整。逻辑回归可以很方便的得到最后的分类结果,因为输出的是每个样本的概率分数,我们可以很容易的对这些概率分数进行cutoff,也就是划分阈值(大于某个阈值的是一类,小于某个阈值的是一类)。\n但是逻辑回归本身也有许多的缺点:\n准确率并不是很高。因为形式非常的简单(非常类似线性模型),很难去拟合数据的真实分布。\n很难处理数据不平衡的问题。举个例子:如果我们对于一个正负样本非常不平衡的问题比如正负样本比 10000:1.我们把所有样本都预测为正也能使损失函数的值比较小。但是作为一个分类器,它对正负样本的区分能力不会很好。\n处理非线性数据较麻烦。逻辑回归在不引入其他方法的情况下,只能处理线性可分的数据,或者进一步说,处理二分类的问题 。\n逻辑回归本身无法筛选特征。有时候,我们会用gbdt来筛选特征,然后再上逻辑回归。\n怎么防止过拟合? 通过正则化方法。正则化方法是指在进行目标函数或代价函数优化时,在目标函数或代价函数后面加上一个正则项,一般有L1正则与L2正则等\n为什么正则化可以防止过拟合? 过拟合表现在训练数据上的误差非常小,而在测试数据上误差反而增大。其原因一般是模型过于复杂,过分得去拟合数据的噪声**。正则化则是对模型参数添加先验,使得模型复杂度较小,对于噪声扰动相对较小**。\n最简单的解释就是加了先验。在数据少的时候,先验知识可以防止过拟合。\n举个例子:\n硬币,推断正面朝上的概率。如果只能抛5次,很可能5次全正面朝上,这样你就得出错误的结论:正面朝上的概率是1\u0026mdash;\u0026mdash;\u0026ndash;过拟合!如果你在模型里加正面朝上概率是0.5的先验,结果就不会那么离谱。这其实就是正则\nL1正则和L2正则有什么区别? 相同点:都用于避免过拟合\n不同点:L2与L1的区别在于,L1正则是拉普拉斯先验,而L2正则则是高斯先验。L1可以产生稀疏解,可以让一部分特征的系数缩小到0,从而间接实现特征选择。所以L1适用于特征之间有关联的情况。L2让所有特征的系数都缩小,但是不会减为0,它会使优化求解稳定快速。所以L2适用于特征之间没有关联的情况\n因为L1和服从拉普拉斯分布,所以L1在0点处不可导,难以计算,这个方法可以使用Proximal Algorithms或者ADMM来解决。\n如何用LR解决非线性问题? 将特征离散成高维的01特征可以解决分类模型的非线性问题\n3. 逻辑回归是线性模型么,说下原因? 狭义线性模型的前提是因变量误差是正态分布,但很多情况下这并不满足,比如对足球比分的预测显然用泊松分布是更好的选择。而广义的”广”在于引入了联系函数,于是误差变成了只要满足指数分布族就行了,因此适用性更强。\n​ 简单来说广义线性模型分为两个部分,第一个部分是描述了自变量和因变量的系统关系,也就是”线性”所在;第二个部分是描述了因变量的误差,这可以建模成各种满足指数分布族的分布。而联系函数就是把这两个部分连接起来的桥梁,也就是把因变量的期望表示为了自变量线性组合的函数。而像逻辑回归这样的简单广义线性模型,实际是将自变量的线性组合变成了联系函数的自然参数,这类联系函数也可以叫做正则联系函数。\n4. 逻辑回归算法为什么用的是sigmoid函数而不用阶跃函数? 阶跃函数虽然能够直观刻画分类的错误率,但是由于其非凸、非光滑的特点,使得算法很难直接对该函数进行优化。而sigmoid函数本身的特征(光滑无限阶可导),以及完美的映射到概率空间,就用于逻辑回归了。解释上可从三个方面:- 最大熵定理- 伯努利分布假设- 贝叶斯理论\n参考: https://fengxc.me/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E7%82%B9-%E6%8C%81%E7%BB%AD%E6%9B%B4%E6%96%B0%E4%B8%AD.html\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%80%BB%E8%BE%91%E5%9B%9E%E5%BD%92%E7%9A%84%E5%B8%B8%E8%A7%81%E9%9D%A2%E8%AF%95%E9%A2%98%E6%80%BB%E7%BB%93/","summary":"1.简介 逻辑回归是面试当中非常喜欢问到的一个机器学习算法,因为表面上看逻辑回归形式上很简单,很好掌握,但是一问起来就容易懵逼。所以在面试的时","title":"逻辑回归的常见面试题总结"},{"content":"调参 ★ 在 scikit-learn 中,Random Forest(以下简称RF)的分类类是 RandomForestClassifier,回归类是 RandomForestRegressor。\nRF 需要调参的参数也包括两部分,第一部分是 Bagging 框架的参数,第二部分是 CART 决策树的参数。下面我们就对这些参数做一个介绍。\nRF 框架参数 首先我们关注于 RF 的 Bagging 框架的参数。这里可以和 GBDT 对比来学习。GBDT 的框架参数比较多,重要的有最大迭代器个数,步长和子采样比例,调参起来比较费力。但是 RF 则比较简单,这是因为 bagging 框架里的各个弱学习器之间是没有依赖关系的,这减小的调参的难度。换句话说,达到同样的调参效果,RF 调参时间要比 GBDT 少一些。\n下面我来看看 RF 重要的 Bagging 框架的参数,由于 RandomForestClassifier 和 RandomForestRegressor 参数绝大部分相同,这里会将它们一起讲,不同点会指出。\nn_estimators:也就是弱学习器的最大迭代次数,或者说最大的弱学习器的个数。一般来说 n_estimators 太小,容易欠拟合,n_estimators 太大,计算量会太大,并且 n_estimators 到一定的数量后,再增大 n_estimators 获得的模型提升会很小,所以一般选择一个适中的数值。默认是 100 。\noob_score:即是否采用袋外样本来评估模型的好坏。默认识 False 。个人推荐设置为 True ,因为袋外分数反应了一个模型拟合后的泛化能力。\ncriterion: 即 CART 树做划分时对特征的评价标准。分类模型和回归模型的损失函数是不一样的。分类 RF 对应的 CART 分类树默认是基尼系数 gini ,另一个可选择的标准是信息增益。回归 RF 对应的 CART 回归树默认是均方差 mse ,另一个可以选择的标准是绝对值差 mae 。一般来说选择默认的标准就已经很好的。\n从上面可以看出, RF 重要的框架参数比较少,主要需要关注的是 n_estimators,即 RF 最大的决策树个数。\nRF 决策树参数 RF 划分时考虑的最大特征数 max_features:\n可以使用很多种类型的值,默认是 auto ,意味着划分时最多考虑 $\\sqrt {N}$ 个特征;如果是 log2 意味着划分时最多考虑 $log_2N$ 个特征;如果是 sqrt 或者 auto 意味着划分时最多考虑$\\sqrt {N}$ 个特征。如果是整数,代表考虑的特征绝对数。如果是浮点数,代表考虑特征百分比,即考虑(百分比 x $N$)取整后的特征数。其中 $N$ 为样本总特征数。一般我们用默认的 auto 就可以了,如果特征数非常多,我们可以灵活使用刚才描述的其他取值来控制划分时考虑的最大特征数,以控制决策树的生成时间。\n决策树最大深度 max_depth:\n默认可以不输入,如果不输入的话,决策树在建立子树的时候不会限制子树的深度。一般来说,数据少或者特征少的时候可以不管这个值。如果模型样本量多,特征也多的情况下,推荐限制这个最大深度,具体的取值取决于数据的分布。常用的可以取值 10-100 之间。\n内部节点再划分所需最小样本数 min_samples_split:\n这个值限制了子树继续划分的条件,如果某节点的样本数少于 min_samples_split,则不会继续再尝试选择最优特征来进行划分。默认是 2,如果样本量不大,不需要管这个值。如果样本量数量级非常大,则推荐增大这个值。\n叶子节点最少样本数 min_samples_leaf:\n这个值限制了叶子节点最少的样本数,如果某叶子节点数目小于样本数,则会和兄弟节点一起被剪枝。 默认是 1,可以输入最少的样本数的整数,或者最少样本数占样本总数的百分比。如果样本量不大,不需要管这个值。如果样本量数量级非常大,则推荐增大这个值。\n叶子节点最小的样本权重 min_weight_fraction_leaf:\n这个值限制了叶子节点所有样本权重和的最小值,如果小于这个值,则会和兄弟节点一起被剪枝。默认是 0,就是不考虑权重问题。一般来说,如果我们有较多样本有缺失值,或者分类树样本的分布类别偏差很大,就会引入样本权重,这时我们就要注意这个值了。\n最大叶子节点数 max_leaf_nodes:\n通过限制最大叶子节点数,可以防止过拟合,默认是 None ,即不限制最大的叶子节点数。如果加了限制,算法会建立在最大叶子节点数内最优的决策树。如果特征不多,可以不考虑这个值,但是如果特征分成多的话,可以加以限制,具体的值可以通过交叉验证得到。\n节点划分最小不纯度 min_impurity_split:\n这个值限制了决策树的增长,如果某节点的不纯度(基于基尼系数,均方差)小于这个阈值,则该节点不再生成子节点。即为叶子节点 。一般不推荐改动,默认值 1e-7。\n上面决策树参数中最重要的包括最大特征数 max_features, 最大深度 max_depth, 内部节点再划分所需最小样本数 min_samples_split 和叶子节点最少样本数 min_samples_leaf\n调参实例 我们使用社交网络数据集为例,利用 sklearn.grid_search 中的 GridSearchCV 类进行网格搜索最佳参数,现有两种调参思路。\n串行调参思路:调整一个或几个参数,固定其他参数,得到所调整参数的最优。重复以上步骤,使得每个参数都得打最优 并行调参思路:参数同时进行调整,不过计算量比较大,但是一次性能够找到最好的参数 载入需要的库 1 2 from sklearn.model_selection import GridSearchCV from sklearn.ensemble import RandomForestClassifier 调整参数 n_estimators 1 2 3 4 5 6 7 8 param_test1 = {\u0026#39;n_estimators\u0026#39;: list(range(10,71,10))} classifier = RandomForestClassifier(n_estimators=10, min_samples_split=20,min_samples_leaf=2,max_depth=9,criterion=\u0026#39;entropy\u0026#39;, oob_score=True,random_state=0) gsearch1= GridSearchCV(estimator=classifier,param_grid=param_test1,scoring=\u0026#39;roc_auc\u0026#39;,cv=5) gsearch1.fit(X_train,y_train) print(gsearch1.cv_results_) print(gsearch1.best_params_) print(gsearch1.best_score_) 调整参数 max_depth 得到了最佳的弱学习器迭代次数为 30 ,我们将相应参数进行修改,接着我们对决策树最大深度 max_depth 和内部节点再划分所需最小样本数 min_samples_split 进行网格搜索。\n1 2 3 4 5 6 7 8 9 10 param_test2 = {\u0026#39;max_depth\u0026#39;: list(range(3, 12, 1)), \u0026#39;min_samples_split\u0026#39;: list(range(5, 101, 5))} classifier = RandomForestClassifier(n_estimators=30, min_samples_leaf=2, criterion=\u0026#39;entropy\u0026#39;, oob_score=True, random_state=0) gsearch2 = GridSearchCV(estimator=classifier, param_grid=param_test2, scoring=\u0026#39;roc_auc\u0026#39;, iid=False, cv=5) gsearch2.fit(X_train, y_train) print(gsearch2.best_params_) print(gsearch2.best_score_) Result\n1 2 {\u0026#39;max_depth\u0026#39;: 7, \u0026#39;min_samples_split\u0026#39;: 20} 0.9426982890941702 调整参数 min_samples_split 和 min_samples_split 对于内部节点再划分所需最小样本数 min_samples_split ,我们暂时不能一起定下来,因为这个还和决策树其他的参数存在关联。下面我们再对内部节点再划分所需最小样本数 min_samples_split 和叶子节点最少样本数 min_samples_leaf 一起调参。\n1 2 3 4 5 6 7 8 9 10 param_test3 = {\u0026#39;min_samples_split\u0026#39;: list(range(5, 51, 5)), \u0026#39;min_samples_leaf\u0026#39;: list(range(5, 51, 5))} classifier = RandomForestClassifier(n_estimators=30, max_depth=7, criterion=\u0026#39;entropy\u0026#39;, oob_score=True, random_state=0) gsearch3 = GridSearchCV(estimator=classifier, param_grid=param_test3, scoring=\u0026#39;roc_auc\u0026#39;, iid=False, cv=5) gsearch3.fit(X_train, y_train) print(gsearch3.best_params_) print(gsearch3.best_score_) Result:\n1 2 {\u0026#39;min_samples_leaf\u0026#39;: 5, \u0026#39;min_samples_split\u0026#39;: 30} 0.9419350440517489 至此相关参数已调整完毕,不过这种串行调优方式并不能一次性找到最好的解。我们可以将参数可选的值一次性的导入网格,使得所有参数调优同步进行,但是这样会降低程序运行的效率,可能需要大量的计算资源。如果算力强大,还是值得一试的。\n","permalink":"https://reid00.github.io/en/posts/ml/%E9%9A%8F%E6%9C%BA%E6%A3%AE%E6%9E%97%E5%9B%9E%E5%BD%92%E6%A0%91%E6%A8%A1%E5%9E%8B/","summary":"调参 ★ 在 scikit-learn 中,Random Forest(以下简称RF)的分类类是 RandomForestClassifier,回归类是 RandomFores","title":"随机森林(回归树)模型"},{"content":"随机森林算法思想 随机森林(Random Forest)使用多个CART决策树作为弱学习器,不同决策树之间没有关联。当我们进行分类任务时,新的输入样本进入,就让森林中的每一棵决策树分别进行判断和分类,每个决策树会得到一个自己的分类结果,决策树的分类结果中哪一个分类最多,那么随机森林就会把这个结果当做最终的结果。\n随机森林在生成决策树的时候用随机选择的特征,即使用Bagging方法。这么做的原因是:如果训练集中的某几个特征对输出的结果有很强的预测性,那么这些特征会被每个决策树所应用,这样会导致树之间具有相关性,这样并不会减小模型的方差。\n随机森林对决策树的建立做了一些改进:\n随机森林不会像普通决策树一样选择最优特征进行子树的划分,而是随机选择节点上的一部分样本特征:Nsub(子集),然后在随机挑选出来的集合Nsub中,选择一个最优的特征来做决策树的左右子树划分。一般情况下,推荐子集Nsub内特征的个数为log2d个。这样进一步增强了模型的泛化能力。\n如果Nsub=N,则此时随机森林的CART决策树和普通的CART决策树没有区别。Nsub越小,则模型越健壮。当然此时对于训练集的拟合程度会变差。也就是说Nsub越小,模型的方差会减小,但是偏差会增大。在实际案例中,一般会通过交叉验证调参获取一个合适的的Nsub值。\n随机森林有一个缺点:不像决策树一样有很好地解释性。但是,随机森林有更好地准确性,同时也并不需要修剪随机森林。对于随机森林来说,只需要选择一个参数,生成决策树的个数。通常情况下,决策树的个数越多,性能越好,但是,计算开销同时也增大了。\n随机森林建立过程 第一步:原始训练集D中有N个样本,且每个样本有W维特征。从数据集D中有放回的随机抽取x个样本(Bootstraping方法)组成训练子集Dsub,一共进行w次采样,即生成w个训练子集Dsub。\n第二步:每个训练子集Dsub形成一棵决策树,形成了一共w棵决策树。而每一次未被抽到的样本则组成了w个oob(用来做预估)。\n第三步:对于单个决策树,树的每个节点处从M个特征中随机挑选m(m\u0026lt;M)个特征,按照结点不纯度最小原则进行分裂。每棵树都一直这样分裂下去,直到该节点的所有训练样例都属于同一类。在决策树的分裂过程中不需要剪枝。\n第四步:根据生成的多个决策树分类器对需要进行预测的数据进行预测。根据每棵决策树的投票结果,如果是分类树的话,最后取票数最高的一个类别;如果是回归树的话,利用简单的平均得到最终结果。\n随机森林算法优缺点总结及面试问题 随机森林是Bagging的一个扩展变体,是在以决策树为基学习器构建Bagging集成的基础上,进一步在决策树的训练过程中引入了随机属性选择。\n随机森林简单、容易实现、计算开销小,在很多实际应用中都变现出了强大的性能,被誉为“代表集成学习技术水平的方法”。可以看出,随机森林对Bagging只做了小改动。并且,Bagging满足差异性的方法是对训练集进行采样;而随机森林不但对训练集进行随机采样,而且还随机选择特征子集,这就使最终集成的泛化性进一步提升。\n随着基学习器数目的增加,随机森林通常会收敛到更低的泛化误差,并且训练效率是优于Bagging的。\n总结一下随机森林的优缺点:\n优点:\n训练可以高度并行化,对于大数据时代的大样本训练速度有优势。个人觉得这是的最主要的优点。 由于可以随机选择决策树节点划分特征,这样在样本特征维度很高的时候,仍然能高效的训练模型。 在训练后,可以给出各个特征对于输出的重要性。 由于采用了随机采样,训练出的模型的方差小,泛化能力强。 相对于Boosting系列的Adaboost和GBDT, RF实现比较简单。 对部分特征缺失不敏感。 缺点有:\n在某些噪音比较大的样本集上,RF模型容易陷入过拟合。 取值划分比较多的特征容易对RF的决策产生更大的影响,从而影响拟合的模型的效果。 下面看几个面试问题:\n1、为什么要有放回的抽样?保证样本集间有重叠,若不放回,每个训练样本集及其分布都不一样,可能导致训练的各决策树差异性很大,最终多数表决无法 “求同”,即最终多数表决相当于“求同”过程。\n2、为什么RF的训练效率优于bagging?因为在个体决策树的构建过程中,Bagging使用的是“确定型”决策树,bagging在选择划分属性时要对每棵树是对所有特征进行考察;而随机森林仅仅考虑一个特征子集。\n3、随机森林需要剪枝吗?不需要,后剪枝是为了避免过拟合,随机森林随机选择变量与树的数量,已经避免了过拟合,没必要去剪枝了。一般rf要控制的是树的规模,而不是树的置信度,剩下的每棵树需要做的就是尽可能的在自己所对应的数据(特征)集情况下尽可能的做到最好的预测结果。剪枝的作用其实被集成方法消解了,所以用处不大。\nExtra-Tree及其与RF的区别 Extra-Tree是随机森林的一个变种, 原理几乎和随机森林一模一样,可以称为:“极其随机森林”,即决策树在节点的划分上,使用随机的特征和随机的阈值。\n特征和阈值提供了额外随机性,抑制了过拟合,再一次用高偏差换低方差。它还使得 Extra-Tree 比规则的随机森林更快地训练,因为在每个节点上找到每个特征的最佳阈值是生长树最耗时的任务之一。\nExtra-Tree与随机森林的区别有以下两点:\n对于每个决策树的训练集,随机森林采用的是随机采样bootstrap来选择采样集作为每个决策树的训练集,而Extra-Tree一般不采用随机采样,即每个决策树采用原始训练集。 在选定了划分特征后,随机森林的决策树会基于基尼系数,均方差之类的原则,选择一个最优的特征值划分点,这和传统的决策树相同。但是Extra-Tree比较的激进,他会随机的选择一个特征值来划分决策树。 从第二点可以看出,由于随机选择了特征值的划分点位,而不是最优点位,这样会导致生成的决策树的规模一般会大于随机森林所生成的决策树。也就是说,模型的方差相对于随机森林进一步减少,但是偏倚相对于随机森林进一步增大。在某些时候,Extra-Tree的泛化能力比随机森林更好。\nRF评估特征重要性 在实际业务场景中,我们会关系如何在高维数据中选择对结果影响最大的前n个特征。我们可以使用PCA、LASSO等方法,当然也可以用RF算法来进行特征选择。感兴趣的话。\nRF算法的有一个典型的应用:评估单个特征变量的重要性并进行特征选择。\n举一个具体的应用场景:银行贷款业务中能否正确的评估企业的信用度,关系到能否有效地回收贷款。但是信用评估模型的数据特征有很多,其中不乏有很多噪音,所以需要计算出每一个特征的重要性并对这些特征进行一个排序,进而可以从所有特征中选择出重要性靠前的特征。\n下面我们来看看评估特征重要性的步骤:\n对于RF中的每一棵决策树,选择OOB数据计算模型的预测错误率,记为Error1。(在随机森林算法中不需要再进行交叉验证来获取测试集误差的无偏估计)\n然后在OOB中所有样本的特征A上加入随机噪声,接着再次用OOB数据计算模型预测错误率,记为Error2。\n若森林中有N棵树,则特征A的重要性为 求和(Error2-Error1/N)。\n我们细品:在某一特征A上增加了噪音,那么就有理由相信错误率Error2要大于Error1,Error2越大说明特征A重要。\n可以这么理解,小A从公司离职了,这个公司倒闭了,说明小A很重要;如果小A走了,公司没变化,说明小A也没啥用。\n在sklearn中我们可以这么做:\n1 2 3 4 5 6 7 8 from sklearn.cross_validation import train_test_split from sklearn.ensemble import RandomForestClassifier (处理数据) rf_clf = RandomForestClassifier(n_estimators=1000, random_state=666) rf_clf.fit(x_train, y_train) importances = rf_clf.feature_importances_ 从重要性到特征选择 我们已经知道如何使用RF算法评估特征重要性了,那么在此基础上做特征选择就很简单了:\n对每一个特征都计算其特征重要性。 将这些特征按照重要性从大到小排序。 设置要删除的特征的比率,删除重要性最小的那些特征。 剩下的那些特征,继续计算每个每个特征的重要性,然后循环回第一步,直到剩余特征数达到我们的要求。 承接上面的代码,我们进行特征选择:\n1 2 threshold = [某个阈值] x_selected = x_train[:, importances \u0026gt; threshold] ","permalink":"https://reid00.github.io/en/posts/ml/%E9%9A%8F%E6%9C%BA%E6%A3%AE%E6%9E%97%E7%AE%97%E6%B3%95%E5%8F%8A%E5%85%B6%E5%9C%A8%E7%89%B9%E5%BE%81%E9%80%89%E6%8B%A9%E4%B8%AD%E7%9A%84%E5%BA%94%E7%94%A8/","summary":"随机森林算法思想 随机森林(Random Forest)使用多个CART决策树作为弱学习器,不同决策树之间没有关联。当我们进行分类任务时,新的输","title":"随机森林算法及其在特征选择中的应用"},{"content":"什么是生成模型和判别模型? 从本质上讲,生成模型和判别模型是解决分类问题的两类基本思路。首先,您得先了解,分类问题,就是给定一个数据x,要判断它对应的标签y(这么naive的东西都要解释下,求面试官此时内心的阴影面积,嘎嘎)。生成模型就是要学习x和y的联合概率分布P(x,y),然后根据贝叶斯公式来求得条件概率P(y|x),预测条件概率最大的y。贝叶斯公式这么简单的知识相信您也了解,我就不啰嗦了。判别模型就是直接学习条件概率分布P(y|x)。\n举个栗子 例子1 假设你从来没有见过大象和猫,连听都没有听过,这时,给你看了一张大象的照片和一张猫的照片。如下所示:\n然后牵来我家的大象(面试官:你家开动物园的吗?),让你判断这是大象还是猫。你咋办?\n你开始回想刚刚看过的照片,大概记起来,大象和猫比起来,有个长鼻子,而眼前这个家伙也有个长鼻子,所以,你兴奋地说:“这是大象!”恭喜你答对了!\n你也有可能这样做,你努力回想刚才的两张照片,然后用笔把它们画在了纸上,拿着纸和我家的大象做比较,你发现,眼前的动物更像是大象。于是,你惊喜地宣布:“这玩意是大象!”恭喜你又答对了!\n在这个问题中,第一个解决问题的思路就是判别模型,因为你只记住了大象和猫之间的不同之处。第二个解决问题的思路就是生成模型,因为你实际上学习了什么是大象,什么是猫。\n例子2 来来来,看一下这四个形式为(x,y)的样本。(1,0), (1,0), (2,0), (2, 1)。假设,我们想从这四个样本中,学习到如何通过x判断y的模型。用生成模型,我们要学习P(x,y)。如下所示:\n我们学习到了四个概率值,它们的和是1,这就是P(x,y)。\n我们也可以用判别模型,我们要学习P(y|x),如下所示:\n我们同样学习到了四个概率值,但是,这次,是每一行的两个概率值的和为1了。让我们具体来看一下,如何使用这两个模型做判断。\n假设 x=1。\n对于生成模型, 我们会比较:\nP(x=1,y=0) = 1/2 P(x=1,y=1) = 0 我们发现P(x=1,y=0)的概率要比P(x=1,y=1)的概率大,所以,我们判断:x=1时,y=0。\n对于判别模型,我们会比较:\nP(y=0|x=1) = 1 P(y=1|x=1) = 0 同样,P(y=0|x=1)要比P(y=1|x=1)大,所以,我们判断:x=1时,y=0。\n我们看到,虽然最后预测的结果一样,但是得出结果的逻辑却是完全不同的。两个栗子说完,你心里感到很痛快,面试官脸上也露出了赞赏的微笑,但是,他突然问了一个问题。\n生成模型为啥叫生成模型 这个问题着实让你没想到,不过,聪明的你略加思考,应该就可以想到。生成模型之所以叫生成模型,是因为,它背后的思想是,x是特征,y是标签,什么样的标签就会生成什么样的特征。好比说,标签是大象,那么可能生成的特征就有大耳朵,长鼻子等等。\n当我们来根据x来判断y时,我们实际上是在比较,什么样的y标签更可能生成特征x,我们预测的结果就是更可能生成x特征的y标签。\n常见的生成模型和判别模型有哪些呢 生成模型\nHMM\n朴素贝叶斯\n判别模型\n逻辑回归\nSVM\nCRF\n最近邻\n一般的神经网络\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%94%9F%E6%88%90%E6%A8%A1%E5%9E%8Bvs%E5%88%A4%E5%88%AB%E6%A8%A1%E5%9E%8B/","summary":"什么是生成模型和判别模型? 从本质上讲,生成模型和判别模型是解决分类问题的两类基本思路。首先,您得先了解,分类问题,就是给定一个数据x,要判断","title":"生成模型vs判别模型"},{"content":"介绍 称函数为效用函数 线性回归模型看起来非常简单,简单到让人怀疑其是否有研究价值以及使用价值。但实际上,线性回归模型可以说是最重要的数学模型之一,很多模型都是建立在它的基础之上,可以被称为是“模型之母”。\n1.1 什么是简单线性回归 所谓简单,是指只有一个样本特征,即只有一个自变量;所谓线性,是指方程是线性的;所谓回归,是指用方程来模拟变量之间是如何关联的。\n简单线性回归,其思想简单,实现容易(与其背后强大的数学性质相关。同时也是许多强大的非线性模型(多项式回归、逻辑回归、SVM)的基础。并且其结果具有很好的可解释性。\n1.2 一种基本推导思 我们所谓的建模过程,其实就是找到一个模型,最大程度的拟合我们的数据。 在简单线回归问题中,模型就是我们的直线方程:y = ax + b 。\n要想最大的拟合数据,本质上就是找到没有拟合的部分,也就是损失的部分尽量小,就是损失函数(loss function)(也有算法是衡量拟合的程度,称函数为效用函数(utility function)):\n因此,推导思路为:\n通过分析问题,确定问题的损失函数或者效用函数; 然后通过最优化损失函数或者效用函数,获得机器学习的模型 近乎所有参数学习算法都是这样的套路,区别是模型不同,建立的目标函数不同,优化的方式也不同。\n回到简单线性回归问题,目标:\n已知训练数据样本、 ,找到和的值,使 尽可能小\n这是一个典型的最小二乘法问题(最小化误差的平方)\n通过最小二乘法可以求出a、b的表达式:\n最小二乘法 2.1 由损失函数引出一堆“风险” 2.1.1 损失函数 在机器学习中,所有的算法模型其实都依赖于最小化或最大化某一个函数,我们称之为“目标函数”。\n最小化的这组函数被称为“损失函数”。什么是损失函数呢?\n损失函数描述了单个样本预测值和真实值之间误差的程度。用来度量模型一次预测的好坏。\n损失函数是衡量预测模型预测期望结果表现的指标。损失函数越小,模型的鲁棒性越好。。\n常用损失函数有:\n0-1损失函数:用来表述分类问题,当预测分类错误时,损失函数值为1,正确为 平方损失函数:用来描述回归问题,用来表示连续性变量,为预测值与真实值差值的平方。(误差值越大、惩罚力度越强,也就是对差值敏感)\n绝对损失函数:用在回归模型,用距离的绝对值来衡量 对数损失函数:是预测值Y和条件概率之间的衡量。事实上,该损失函数用到了极大似然估计的思想。P(Y|X)通俗的解释就是:在当前模型的基础上,对于样本X,其预测值为Y,也就是预测正确的概率。由于概率之间的同时满足需要使用乘法,为了将其转化为加法,我们将其取对数。最后由于是损失函数,所以预测正确的概率越高,其损失值应该是越小,因此再加个负号取个反。 以上损失函数是针对于单个样本的,但是一个训练数据集中存在N个样本,N个样本给出N个损失,如何进行选择呢?\n这就引出了风险函数。\n2.1.2 期望风险 期望风险是损失函数的期望,用来表达理论上模型f(X)关于联合分布P(X,Y)的平均意义下的损失。又叫期望损失/风险函数。\n2.1.3 经验风险 模型f(X)关于训练数据集的平均损失,称为经验风险或经验损失。\n其公式含义为:模型关于训练集的平均损失(每个样本的损失加起来,然后平均一下)\n经验风险最小的模型为最优模型。在训练集上最小经验风险最小,也就意味着预测值和真实值尽可能接近,模型的效果越好。公式含义为取训练样本集中对数损失函数平均值的最小。\n2.1.4 经验风险最小化和结构风险最小化 期望风险是模型关于联合分布的期望损失,经验风险是模型关于训练样本数据集的平均损失。根据大数定律,当样本容量N趋于无穷时,经验风险趋于期望风险。\n因此很自然地想到用经验风险去估计期望风险。但是由于训练样本个数有限,可能会出现过度拟合的问题,即决策函数对于训练集几乎全部拟合,但是对于测试集拟合效果过差。因此需要对其进行矫正:\n结构风险最小化:当样本容量不大的时候,经验风险最小化容易产生“过拟合”的问题,为了“减缓”过拟合问题,提出了结构风险最小理论。结构风险最小化为经验风险与复杂度同时较小。 通过公式可以看出,结构风险:在经验风险上加上一个正则化项(regularizer),或者叫做罚项(penalty) 。正则化项是J(f)是函数的复杂度再乘一个权重系数(用以权衡经验风险和复杂度)\n2.1.5 小结 1、损失函数:单个样本预测值和真实值之间误差的程度。\n2、期望风险:是损失函数的期望,理论上模型f(X)关于联合分布P(X,Y)的平均意义下的损失。\n3、经验风险:模型关于训练集的平均损失(每个样本的损失加起来,然后平均一下)。\n4、结构风险:在经验风险上加上一个正则化项,防止过拟合的策略。\n2.2 最小二乘法 2.2.1 什么是最小二乘法 言归正传,进入最小二乘法的部分。\n大名鼎鼎的最小二乘法,虽然听上去挺高大上,但是思想还是挺朴素的,符合大家的直觉。\n最小二乘法源于法国数学家阿德里安的猜想:\n对于测量值来说,让总的误差的平方最小的就是真实值。这是基于,如果误差是随机的,应该围绕真值上下波动。\n即:\n那么为了求出这个二次函数的最小值,对其进行求导,导数为0的时候取得最小值:\n进而:\n正好是算数平均数(算数平均数是最小二乘法的特例)。\n这就是最小二乘法,所谓“二乘”就是平方的意思。\n(高斯证明过:如果误差的分布是正态分布,那么最小二乘法得到的就是最有可能的值。)\n2.2.2 线性回归中的应用 我们在第一章中提到:\n目标是,找到a和b,使得损失函数:尽可能的小。\n这里,将简单线性问题转为最优化问题。下面对函数的各个位置分量求导,导数为0的地方就是极值:\n对 进行求导:\n然后mb提到等号前面,两边同时除以m,等号右面的每一项相当于均值。\n现在 对 进行求导:\n此时将对 进行求导得到的结果 代入上式中,得到:\n将上式进行整理,得到\n将上式继续进行整理:\n这样在实现的时候简单很多。\n最终我们通过最小二乘法得到a、b的表达式:\n总结 本章中,我们从数学的角度了解了简单线性回归,从中总结出一类机器学习算法的基本思路:\n通过分析问题,确定问题的损失函数或者效用函数; 然后通过最优化损失函数或者效用函数,获得机器学习的模型。 理解了损失函数的概念,并列举出了常见损失函数,并引出了一堆“风险”。最后为了求出最小的损失函数,学习了最小二乘法,并进行了完整的数学推导。\n下一篇,我们将会实现简单线性回归,并添加到我们自己的工程文件里。\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%BA%BF%E6%80%A7%E5%9B%9E%E5%BD%92/","summary":"介绍 称函数为效用函数 线性回归模型看起来非常简单,简单到让人怀疑其是否有研究价值以及使用价值。但实际上,线性回归模型可以说是最重要的数学模型之","title":"线性回归"},{"content":"一、线性模型预测一个样本的损失量 损失量:模型对样本的预测结果和该样本对应的实际结果的差距;\n1)为什么会想到用 y = -log(x) 函数? (该函数称为 惩罚函数:预测结果与实际值的偏差越大,惩罚越大) y = 1(p ≥ 0.5)时,cost = -log(p),p 越小,样本发生概率越小(最小为 0),则损失函数越大,分类预测值和实际值的偏差越大;相反,p 越大,样本发生概率越大(最大为 0.5),则损失函数越小,则预测值和实际值的偏差越小; y = 0(p ≤ 0.5)时,cost = -log(1-p),p 越小,样本发生概率越小(最小为 0.5),则损失函数越大,分类预测值和实际值的偏差越大;相反,p 越大,样本发生概率越大(最大为 1),则损失函数越小,则预测值和实际值的偏差越小; 2)求一个样本的损失量 由于逻辑回归解决的是分类问题,而且是二分类,因此定义损失函数时也要有两类\n惩罚函数变形:\n惩罚函数作用:计算预测结果针对实际值的损失量;\n已知样本发生的概率 p(也可以相应求出预测值),以及该样本的实际分类结果,得出此次预测结果针对真值的损失量是多少; 二、求数据集的损失函数 模型变形,得到数据集的损失函数:数据集中的所有样本的损失值的和; 最终的损失函数模型 该模型不能优化成简单的数学表达式(或者说是正规方程解:线性回归算法找那个的fit_normal() 方法),只能使用梯度下降法求解; 该函数为凸函数,没有局部最优解,只存在全局最优解; 三、逻辑回归损失函数的梯度 损失函数: 1)σ(t) 函数的导数 2)log(σ(t)) 函数的导数 变形:\n3)log(1 - σ(t)) 函数的导数 4)对损失函数 J(θ) 的其中某一项(第 i 行,第 j 列)求导 两式相加: 5)损失函数 J(θ) 的梯度 与线性回归梯度对比\n注:两者的预测值 ý 不同; 梯度向量化处理 四、代码实现逻辑回归算法 逻辑回归算法是在线性回归算法的基础上演变的;\n1)代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 import numpy as np from .metrics import accuracy_score # accuracy_score方法:查看准确率 class LogisticRegression: def __init__(self): \u0026#34;\u0026#34;\u0026#34;初始化Logistic Regression模型\u0026#34;\u0026#34;\u0026#34; self.coef_ = None self.intercept_ = None self._theta = None def _sigmiod(self, t): \u0026#34;\u0026#34;\u0026#34;函数名首部为\u0026#39;_\u0026#39;,表明该函数为私有函数,其它模块不能调用\u0026#34;\u0026#34;\u0026#34; return 1. / (1. + np.exp(-t)) def fit(self, X_train, y_train, eta=0.01, n_iters=1e4): \u0026#34;\u0026#34;\u0026#34;根据训练数据集X_train, y_train, 使用梯度下降法训练Logistic Regression模型\u0026#34;\u0026#34;\u0026#34; assert X_train.shape[0] == y_train.shape[0], \\ \u0026#34;the size of X_train must be equal to the size of y_train\u0026#34; def J(theta, X_b, y): y_hat = self._sigmiod(X_b.dot(theta)) try: return - np.sum(y*np.log(y_hat) + (1-y)*np.log(1-y_hat)) / len(y) except: return float(\u0026#39;inf\u0026#39;) def dJ(theta, X_b, y): return X_b.T.dot(self._sigmiod(X_b.dot(theta)) - y) / len(X_b) def gradient_descent(X_b, y, initial_theta, eta, n_iters=1e4, epsilon=1e-8): theta = initial_theta cur_iter = 0 while cur_iter \u0026lt; n_iters: gradient = dJ(theta, X_b, y) last_theta = theta theta = theta - eta * gradient if (abs(J(theta, X_b, y) - J(last_theta, X_b, y)) \u0026lt; epsilon): break cur_iter += 1 return theta X_b = np.hstack([np.ones((len(X_train), 1)), X_train]) initial_theta = np.zeros(X_b.shape[1]) self._theta = gradient_descent(X_b, y_train, initial_theta, eta, n_iters) self.intercept_ = self._theta[0] self.coef_ = self._theta[1:] return self def predict_proda(self, X_predict): \u0026#34;\u0026#34;\u0026#34;给定待预测数据集X_predict,返回 X_predict 中的样本的发生的概率向量\u0026#34;\u0026#34;\u0026#34; assert self.intercept_ is not None and self.coef_ is not None, \\ \u0026#34;must fit before predict!\u0026#34; assert X_predict.shape[1] == len(self.coef_), \\ \u0026#34;the feature number of X_predict must be equal to X_train\u0026#34; X_b = np.hstack([np.ones((len(X_predict), 1)), X_predict]) return self._sigmiod(X_b.dot(self._theta)) def predict(self, X_predict): \u0026#34;\u0026#34;\u0026#34;给定待预测数据集X_predict,返回表示X_predict的分类结果的向量\u0026#34;\u0026#34;\u0026#34; assert self.intercept_ is not None and self.coef_ is not None, \\ \u0026#34;must fit before predict!\u0026#34; assert X_predict.shape[1] == len(self.coef_), \\ \u0026#34;the feature number of X_predict must be equal to X_train\u0026#34; proda = self.predict_proda(X_predict) # proda:单个待预测样本的发生概率 # proda \u0026gt;= 0.5:返回元素为布尔类型的向量; # np.array(proda \u0026gt;= 0.5, dtype=\u0026#39;int\u0026#39;):将布尔数据类型的向量转化为元素为 int 型的数组,则该数组中的 0 和 1 代表两种不同的分类类别; return np.array(proda \u0026gt;= 0.5, dtype=\u0026#39;int\u0026#39;) def score(self, X_test, y_test): \u0026#34;\u0026#34;\u0026#34;根据测试数据集 X_test 和 y_test 确定当前模型的准确度\u0026#34;\u0026#34;\u0026#34; y_predict = self.predict(X_test) # 分类问题的化,查看标准是分类的准确度:accuracy_score(y_test, y_predict) return accuracy_score(y_test, y_predict) def __repr__(self): \u0026#34;\u0026#34;\u0026#34;实例化类之后,输出显示 LogisticRegression()\u0026#34;\u0026#34;\u0026#34; return \u0026#34;LogisticRegression()\u0026#34; 2)使用自己的算法(Jupyter NoteBook 中使用) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 import numpy as np import matplotlib.pyplot as plt from sklearn import datasets iris = datasets.load_iris() X = iris.data y = iris.target X = X[y\u0026lt;2, :2] y = y[y\u0026lt;2] from playML.train_test_split import train_test_split X_train, X_test, y_train, y_test = train_test_split(X, y, seed=666) from playML.LogisticRegression import LogisticRegression log_reg = LogisticRegression() log_reg.fit(X_train, y_train) log_reg.score(X_test, y_test) # 输出:1.0 # 查看测试数据集的样本发生的概率 log_reg.predict_proda(X_test) # 输出:array([0.92972035, 0.98664939, 0.14852024, 0.17601199, 0.0369836 , 0.0186637 , 0.04936918, 0.99669244, 0.97993941, 0.74524655, 0.04473194, 0.00339285, 0.26131273, 0.0369836 , 0.84192923, 0.79892262, 0.82890209, 0.32358166, 0.06535323, 0.20735334]) ","permalink":"https://reid00.github.io/en/posts/ml/%E9%80%BB%E8%BE%91%E5%9B%9E%E5%BD%92/","summary":"一、线性模型预测一个样本的损失量 损失量:模型对样本的预测结果和该样本对应的实际结果的差距; 1)为什么会想到用 y = -log(x) 函数? (该函数称为 惩罚函数","title":"逻辑回归"},{"content":"Summary 本文将从一个下山的场景开始,先提出梯度下降算法的基本思想,进而从数学上解释梯度下降算法的原理,最后实现一个简单的梯度下降算法的实例!\n梯度下降的场景假设 梯度下降法的基本思想可以类比为一个下山的过程。假设这样一个场景:一个人被困在山上,需要从山上下来(i.e. 找到山的最低点,也就是山谷)。但此时山上的浓雾很大,导致可视度很低。因此,下山的路径就无法确定,他必须利用自己周围的信息去找到下山的路径。这个时候,他就可以利用梯度下降算法来帮助自己下山。具体来说就是,以他当前的所处的位置为基准,寻找这个位置最陡峭的地方,然后朝着山的高度下降的地方走,同理,如果我们的目标是上山,也就是爬到山顶,那么此时应该是朝着最陡峭的方向往上走。然后每走一段距离,都反复采用同一个方法,最后就能成功的抵达山谷。\n我们同时可以假设这座山最陡峭的地方是无法通过肉眼立马观察出来的,而是需要一个复杂的工具来测量,同时,这个人此时正好拥有测量出最陡峭方向的能力。所以,此人每走一段距离,都需要一段时间来测量所在位置最陡峭的方向,这是比较耗时的。那么为了在太阳下山之前到达山底,就要尽可能的减少测量方向的次数。这是一个两难的选择,如果测量的频繁,可以保证下山的方向是绝对正确的,但又非常耗时,如果测量的过少,又有偏离轨道的风险。所以需要找到一个合适的测量方向的频率,来确保下山的方向不错误,同时又不至于耗时太多!\n梯度下降 首先,我们有一个可微分的函数。这个函数就代表着一座山。我们的目标就是找到这个函数的最小值,也就是山底。根据之前的场景假设,最快的下山的方式就是找到当前位置最陡峭的方向,然后沿着此方向向下走,对应到函数中,就是找到给定点的梯度 ,然后朝着梯度相反的方向,就能让函数值下降的最快!因为梯度的方向就是函数之变化最快的方向(在后面会详细解释) 所以,我们重复利用这个方法,反复求取梯度,最后就能到达局部的最小值,这就类似于我们下山的过程。而求取梯度就确定了最陡峭的方向,也就是场景中测量方向的手段。那么为什么梯度的方向就是最陡峭的方向呢?接下来,我们从微分开始讲起\n微分 看待微分的意义,可以有不同的角度,最常用的两种是:\n函数图像中,某点的切线的斜率\n函数的变化率 几个微分的例子:\n上面的例子都是单变量的微分,当一个函数有多个变量的时候,就有了多变量的微分,即分别对每个变量进行求微分\n梯度 梯度实际上就是多变量微分的一般化。 下面这个例子:\n我们可以看到,梯度就是分别对每个变量进行微分,然后用逗号分割开,梯度是用\u0026lt;\u0026gt;包括起来,说明梯度其实一个向量。\n梯度是微积分中一个很重要的概念,之前提到过梯度的意义\n在单变量的函数中,梯度其实就是函数的微分,代表着函数在某个给定点的切线的斜率 在多变量函数中,梯度是一个向量,向量有方向,梯度的方向就指出了函数在给定点的上升最快的方向 这也就说明了为什么我们需要千方百计的求取梯度!我们需要到达山底,就需要在每一步观测到此时最陡峭的地方,梯度就恰巧告诉了我们这个方向。梯度的方向是函数在给定点上升最快的方向,那么梯度的反方向就是函数在给定点下降最快的方向,这正是我们所需要的。所以我们只要沿着梯度的方向一直走,就能走到局部的最低点!\n梯度下降算法的数学解释 上面我们花了大量的篇幅介绍梯度下降算法的基本思想和场景假设,以及梯度的概念和思想。下面我们就开始从数学上解释梯度下降算法的计算过程和思想! 此公式的意义是:J是关于Θ的一个函数,我们当前所处的位置为Θ0点,要从这个点走到J的最小值点,也就是山底。首先我们先确定前进的方向,也就是梯度的反向,然后走一段距离的步长,也就是α,走完这个段步长,就到达了Θ1这个点!\n下面就这个公式的几个常见的疑问:\nα是什么含义? α在梯度下降算法中被称作为学习率或者步长,意味着我们可以通过α来控制每一步走的距离,以保证不要步子跨的太大扯着蛋,哈哈,其实就是不要走太快,错过了最低点。同时也要保证不要走的太慢,导致太阳下山了,还没有走到山下。所以α的选择在梯度下降法中往往是很重要的!α不能太大也不能太小,太小的话,可能导致迟迟走不到最低点,太大的话,会导致错过最低点! 为什么要梯度要乘以一个负号? 梯度前加一个负号,就意味着朝着梯度相反的方向前进!我们在前文提到,梯度的方向实际就是函数在此点上升最快的方向!而我们需要朝着下降最快的方向走,自然就是负的梯度的方向,所以此处需要加上负号\n梯度下降算法的实例 我们已经基本了解了梯度下降算法的计算过程,那么我们就来看几个梯度下降算法的小实例,首先从单变量的函数开始\n单变量函数的梯度下降 我们假设有一个单变量的函数\n函数的微分 初始化,起点为 学习率为 根据梯度下降的计算公式\n我们开始进行梯度下降的迭代计算过程:\nimage.png\n如图,经过四次的运算,也就是走了四步,基本就抵达了函数的最低点,也就是山底\n多变量函数的梯度下降 我们假设有一个目标函数\n现在要通过梯度下降法计算这个函数的最小值。我们通过观察就能发现最小值其实就是 (0,0)点。但是接下来,我们会从梯度下降算法开始一步步计算到这个最小值! 我们假设初始的起点为:\n初始的学习率为:\n函数的梯度为:\n进行多次迭代:\n我们发现,已经基本靠近函数的最小值点\n梯度下降算法的实现 下面我们将用python实现一个简单的梯度下降算法。场景是一个简单的线性回归的例子:假设现在我们有一系列的点,如下图所示\n我们将用梯度下降法来拟合出这条直线!\n首先,我们需要定义一个代价函数,在此我们选用均方误差代价函数\n此公式中\nm是数据集中点的个数\n½是一个常量,这样是为了在求梯度的时候,二次方乘下来就和这里的½抵消了,自然就没有多余的常数系数,方便后续的计算,同时对结果不会有影响\ny 是数据集中每个点的真实y坐标的值\nh 是我们的预测函数,根据每一个输入x,根据Θ 计算得到预测的y值,即\n我们可以根据代价函数看到,代价函数中的变量有两个,所以是一个多变量的梯度下降问题,求解出代价函数的梯度,也就是分别对两个变量进行微分\n明确了代价函数和梯度,以及预测的函数形式。我们就可以开始编写代码了。但在这之前,需要说明一点,就是为了方便代码的编写,我们会将所有的公式都转换为矩阵的形式,python中计算矩阵是非常方便的,同时代码也会变得非常的简洁。\n为了转换为矩阵的计算,我们观察到预测函数的形式\n我们有两个变量,为了对这个公式进行矩阵化,我们可以给每一个点x增加一维,这一维的值固定为1,这一维将会乘到Θ0上。这样就方便我们统一矩阵化的计算\n然后我们将代价函数和梯度转化为矩阵向量相乘的形式\ncoding time 首先,我们需要定义数据集和学习率\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import numpy as np # Size of the points dataset. m = 20 # Points x-coordinate and dummy value (x0, x1). X0 = np.ones((m, 1)) X1 = np.arange(1, m+1).reshape(m, 1) X = np.hstack((X0, X1)) # Points y-coordinate y = np.array([ 3, 4, 5, 5, 2, 4, 7, 8, 11, 8, 12, 11, 13, 13, 16, 17, 18, 17, 19, 21 ]).reshape(m, 1) # The Learning Rate alpha. alpha = 0.01 接下来我们以矩阵向量的形式定义代价函数和代价函数的梯度\n1 2 3 4 5 6 7 8 9 def error_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Error function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./2*m) * np.dot(np.transpose(diff), diff) def gradient_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Gradient of the function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./m) * np.dot(np.transpose(X), diff) 最后就是算法的核心部分,梯度下降迭代计算\n1 2 3 4 5 6 7 8 def gradient_descent(X, y, alpha): \u0026#39;\u0026#39;\u0026#39;Perform gradient descent.\u0026#39;\u0026#39;\u0026#39; theta = np.array([1, 1]).reshape(2, 1) gradient = gradient_function(theta, X, y) while not np.all(np.absolute(gradient) \u0026lt;= 1e-5): theta = theta - alpha * gradient gradient = gradient_function(theta, X, y) return theta 当梯度小于1e-5时,说明已经进入了比较平滑的状态,类似于山谷的状态,这时候再继续迭代效果也不大了,所以这个时候可以退出循环!\n完整的代码如下\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import numpy as np # Size of the points dataset. m = 20 # Points x-coordinate and dummy value (x0, x1). X0 = np.ones((m, 1)) X1 = np.arange(1, m+1).reshape(m, 1) X = np.hstack((X0, X1)) # Points y-coordinate y = np.array([ 3, 4, 5, 5, 2, 4, 7, 8, 11, 8, 12, 11, 13, 13, 16, 17, 18, 17, 19, 21 ]).reshape(m, 1) # The Learning Rate alpha. alpha = 0.01 def error_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Error function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./2*m) * np.dot(np.transpose(diff), diff) def gradient_function(theta, X, y): \u0026#39;\u0026#39;\u0026#39;Gradient of the function J definition.\u0026#39;\u0026#39;\u0026#39; diff = np.dot(X, theta) - y return (1./m) * np.dot(np.transpose(X), diff) def gradient_descent(X, y, alpha): \u0026#39;\u0026#39;\u0026#39;Perform gradient descent.\u0026#39;\u0026#39;\u0026#39; theta = np.array([1, 1]).reshape(2, 1) gradient = gradient_function(theta, X, y) while not np.all(np.absolute(gradient) \u0026lt;= 1e-5): theta = theta - alpha * gradient gradient = gradient_function(theta, X, y) return theta optimal = gradient_descent(X, y, alpha) print(\u0026#39;optimal:\u0026#39;, optimal) print(\u0026#39;error function:\u0026#39;, error_function(optimal, X, y)[0,0]) 运行代码,计算得到的结果如下\n所拟合出的直线如下\n小结 至此,我们就基本介绍完了梯度下降法的基本思想和算法流程,并且用python实现了一个简单的梯度下降算法拟合直线的案例! 最后,我们回到文章开头所提出的场景假设: 这个下山的人实际上就代表了反向传播算法,下山的路径其实就代表着算法中一直在寻找的参数Θ,山上当前点的最陡峭的方向实际上就是代价函数在这一点的梯度方向,场景中观测最陡峭方向所用的工具就是微分 。在下一次观测之前的时间就是有我们算法中的学习率α所定义的。 可以看到场景假设和梯度下降算法很好的完成了对应!\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%A2%AF%E5%BA%A6%E4%B8%8B%E9%99%8D%E5%8E%9F%E7%90%86%E4%BB%8B%E7%BB%8D/","summary":"Summary 本文将从一个下山的场景开始,先提出梯度下降算法的基本思想,进而从数学上解释梯度下降算法的原理,最后实现一个简单的梯度下降算法的实例! 梯度下","title":"梯度下降原理介绍"},{"content":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说特征工程是机器学习成功的关键。\n什么是特征工程 特征工程又包含了Data PreProcessing(数据预处理)、Feature Extraction(特征提取)、Feature Selection(特征选择)和Feature construction(特征构造)等子问题,本章内容主要讨论数据预处理的方法及实现。 特征工程是机器学习中最重要的起始步骤,数据预处理是特征工程的最重要的起始步骤,而数据清洗是数据预处理的重要组成部分,会直接影响机器学习的效果。\n数据清洗整体介绍 1. 箱线图分析异常值 箱线图提供了识别异常值的标准,如果一个数下雨 QL-1.5IQR or 大于OU + 1.5 IQR, 则这个值被称为异常值。\nQL 下四分位数,表示四分之一的数据值比它小 QU 上四分位数,表示四分之一的数据值比它大 IRQ 四分位距,是QU-QL 的差值,包含了全部关差值的一般 2. 数据的光滑处理 除了检测出异常值然后再处理异常值外,还可以使用以下方法对异常数据进行光滑处理。\n2.1. 变量分箱(即变量离散化) 离散特征的增加和减少都很容易,易于模型的快速迭代; 稀疏向量内积乘法运算速度快,计算结果方便存储,容易扩展; 离散化后的特征对异常数据有很强的鲁棒性:比如一个特征是年龄\u0026gt;30是1,否则0。如果特征没有离散化,一个异常数据“年龄300岁”会给模型造成很大的干扰; 逻辑回归属于广义线性模型,表达能力受限;单变量离散化为N个后,每个变量有单独的权重,相当于为模型引入了非线性,能够提升模型表达能力,加大拟合; 离散化后可以进行特征交叉,由M+N个变量变为M*N个变量,进一步引入非线性,提升表达能力; 特征离散化后,模型会更稳定,比如如果对用户年龄离散化,20-30作为一个区间,不会因为一个用户年龄长了一岁就变成一个完全不同的人。当然处于区间相邻处的样本会刚好相反,所以怎么划分区间是门学问; 特征离散化以后,起到了简化了逻辑回归模型的作用,降低了模型过拟合的风险。 可以将缺失作为独立的一类带入模型。 将所有变量变换到相似的尺度上。 2.1.0 变量分箱的方法 2.1.1 无序变量分箱 举个例子,在实际模型建立当中,有个 job 职业的特征,取值为(“国家机关人员”,“专业技术人员”,“商业服务人员”),对于这一类变量,如果我们将其依次赋值为(国家机关人员=1;专业技术人员=2;商业服务人员=3),就很容易产生一个问题,不同种类的职业在数据层面上就有了大小顺序之分,国家机关人员和商业服务人员的差距是2,专业技术人员和商业服务人员的之间的差距是1,而我们原来的中文分类中是不存在这种先后顺序关系的。所以这么简单的赋值是会使变量失去原来的衡量效果。\n怎么处理这个问题呢? “一位有效编码” (one-hot Encoding)可以解决这个问题,通常叫做虚变量或者哑变量(dummpy variable):比如职业特征有3个不同变量,那么将其生成个2哑变量,分别是“是否国家党政职业人员”,“是否专业技术人员” ,每个虚变量取值(1,0)。 为什么2个哑变量而非3个? 在模型中引入多个虚拟变量时,虚拟变量的个数应按下列原则确定: 回归模型有截距:一般的,若该特征下n个属性均互斥(如,男/女;儿童/青年/中年/老年),在生成虚拟变量时,应该生成 n-1个虚变量,这样可以避免产生多重共线性 回归模型无截距项:有n个特征,设置n个虚拟变量 python 实现方法pd.get_dummies() 2.1.2 有序变量分箱 有序多分类变量是很常见的变量形式,通常在变量中有多个可能会出现的取值,各取值之间还存在等级关系。比如高血压分级(0=正常,1=正常高值,2=1级高血压,3=2级高血压,4=3级高血压)这类变量处理起来简直不要太省心,使用 pandas 中的 map()替换相应变量就行。\n1 2 3 4 5 import pandas as pd df= pd.DataFrame([\u0026#39;正常\u0026#39;,\u0026#39;3级高血压\u0026#39;,\u0026#39;正常\u0026#39;,\u0026#39;2级高血压\u0026#39;,\u0026#39;正常\u0026#39;,\u0026#39;正常高值\u0026#39;,\u0026#39;1级高血压\u0026#39;],columns=[\u0026#39;blood_pressure\u0026#39;]) dic_blood = {\u0026#39;正常\u0026#39;:0,\u0026#39;正常高值\u0026#39;:1,\u0026#39;1级高血压\u0026#39;:2,\u0026#39;2级高血压\u0026#39;:3,\u0026#39;3级高血压\u0026#39;:4} df[\u0026#39;blood_pressure_enc\u0026#39;] = df[\u0026#39;blood_pressure\u0026#39;].map(dic_blood) print(df) 2.1.3 连续变量的分箱方式 等宽划分:按照相同宽度将数据分成几等份。缺点是受到异常值的影响比较大。 pandas.cut方法可以进行等宽划分。 等频划分:将数据分成几等份,每等份数据里面的个数是一样的。pandas.qcut方法可以进行等频划分。 1 2 3 4 5 6 import pandas as pd df = pd.DataFrame([[22,1],[13,1],[33,1],[52,0],[16,0],[42,1],[53,1],[39,1],[26,0],[66,0]],columns=[\u0026#39;age\u0026#39;,\u0026#39;Y\u0026#39;]) #print(df) df[\u0026#39;age_bin_1\u0026#39;] = pd.qcut(df[\u0026#39;age\u0026#39;],3) #新增一列存储等频划分的分箱特征 df[\u0026#39;age_bin_2\u0026#39;] = pd.cut(df[\u0026#39;age\u0026#39;],3) #新增一列存储等距划分的分箱特征 print(df) 2.1.4 有监督学习分箱方法 最小熵法分箱 假设因变量为分类变量,可取值1,… ,J。令pijpij表示第i个分箱内因变量取值为j的观测的比例,i=1,…,k,j=1,…,J;那么第i个分箱的熵值为∑Jj=0−pij×logpij∑j=0J−pij×logpij。如果第i个分箱内因变量各类别的比例相等,即p11=p12=p1J=1/Jp11=p12=p1J=1/J,那么第i个分箱的熵值达到最大值;如果第i个分箱内因变量只有一种取值,即某个pijpij等于1而其他类别的比例等于0,那么第i个分箱的熵值达到最小值。 令riri表示第i个分箱的观测数占所有观测数的比例;那么总熵值为∑ki=0∑Jj=0(−pij×logpij)∑i=0k∑j=0J(−pij×logpij)。需要使总熵值达到最小,也就是使分箱能够最大限度地区分因变量的各类别。 卡方分箱 (常用) 自底向上的(即基于合并的)数据离散化方法。 它依赖于卡方检验:具有最小卡方值的相邻区间合并在一起,直到满足确定的停止准则。 基本思想: 对于精确的离散化,相对类频率在一个区间内应当完全一致。因此,如果两个相邻的区间具有非常类似的类分布,则这两个区间可以合并;否则,它们应当保持分开。而低卡方值表明它们具有相似的类分布。 2.2 无量纲化 无量纲化使不同规格的数据转换到同一规格。常见的无量纲化方法有标准化和区间缩放法。标准化的前提是特征值服从正态分布,标准化后,其转换成标准正态分布。区间缩放法利用了边界值信息,将特征的取值区间缩放到某个特点的范围,例如[0, 1]等。\n2.2.1 标准化 标准化需要计算特征的均值和标准差,公式表达为:\n使用preproccessing库的StandardScaler类对数据进行标准化的代码如下:\n1 2 3 4 from sklearn.preprocessing import StandardScaler #标准化,返回值为标准化后的数据 StandardScaler().fit_transform(iris.data) 2.2.2 区间缩放法 区间缩放法的思路有多种,常见的一种为利用两个最值进行缩放,公式表达为:\n使用preproccessing库的MinMaxScaler类对数据进行区间缩放的代码如下:\n1 2 3 4 from sklearn.preprocessing import MinMaxScaler #区间缩放,返回值为缩放到[0, 1]区间的数据 MinMaxScaler().fit_transform(iris.data) 2.1.3 标准化与归一化的区别 简单来说,标准化是依照特征矩阵的列处理数据,其通过求z-score的方法,将样本的特征值转换到同一量纲下。归一化是依照特征矩阵的行处理数据,其目的在于样本向量在点乘运算或其他核函数计算相似性时,拥有统一的标准,也就是说都转化为“单位向量”。规则为l2的归一化公式如下:\n什么时候需要进行归一化?\n归一化后加快了梯度下降求最优解的速度 归一化有可能提高精度 什么时候需要进行归一化?\n通常在需要用到梯度下降法的时候。 包括线性回归、逻辑回归、支持向量机、神经网络等模型。\n决策树模型就不适用 例如 C4.5 ,主要根据信息增益比来分裂,归一化不会改变样本在特征 x 上的信息增益\n比较概率大小分布即可,不需要。\n使用preproccessing库的Normalizer类对数据进行归一化的代码如下:\n1 2 3 4 from sklearn.preprocessing import Normalizer #归一化,返回值为归一化后的数据 Normalizer().fit_transform(iris.data) 2.3 对定量特征二值化 定量特征二值化的核心在于设定一个阈值,大于阈值的赋值为1,小于等于阈值的赋值为0,公式表达如下:\n使用preproccessing库的Binarizer类对数据进行二值化的代码如下:\n1 2 3 4 from sklearn.preprocessing import Binarizer #二值化,阈值设置为3,返回值为二值化后的数据 Binarizer(threshold=3).fit_transform(iris.data) 2.4 对定性特征哑编码 由于IRIS数据集的特征皆为定量特征,故使用其目标值进行哑编码(实际上是不需要的)。使用preproccessing库的OneHotEncoder类对数据进行哑编码的代码如下:\n1 2 3 4 from sklearn.preprocessing import OneHotEncoder #哑编码,对IRIS数据集的目标值,返回值为哑编码后的数据 OneHotEncoder().fit_transform(iris.target.reshape((-1,1))) 2.5 缺失值计算 由于IRIS数据集没有缺失值,故对数据集新增一个样本,4个特征均赋值为NaN,表示数据缺失。使用preproccessing库的Imputer类对数据进行缺失值计算的代码如下:\n1 2 3 4 5 6 7 from numpy import vstack, array, nan from sklearn.preprocessing import Imputer #缺失值计算,返回值为计算缺失值后的数据 #参数missing_value为缺失值的表示形式,默认为NaN #参数strategy为缺失值填充方式,默认为mean(均值) Imputer().fit_transform(vstack((array([nan, nan, nan, nan]), iris.data))) 2.6 数据变换 常见的数据变换有基于多项式的、基于指数函数的、基于对数函数的。4个特征,度为2的多项式转换公式如下:\n使用preproccessing库的PolynomialFeatures类对数据进行多项式转换的代码如下:\n1 2 3 4 5 from sklearn.preprocessing import PolynomialFeatures #多项式转换 #参数degree为度,默认值为2 PolynomialFeatures().fit_transform(iris.data) 基于单变元函数的数据变换可以使用一个统一的方式完成,使用preproccessing库的FunctionTransformer对数据进行对数函数转换的代码如下:\n1 2 3 4 5 6 from numpy import log1p from sklearn.preprocessing import FunctionTransformer #自定义转换函数为对数函数的数据变换 #第一个参数是单变元函数 FunctionTransformer(log1p).fit_transform(iris.data) 2.7 回归 可以用一个函数(如回归函数)拟合数据来光滑数据。线性回归涉及找出拟合两个属性(或变量)的“最佳”线,是的一个属性可以用来预测另一个。多元线性回归是线性回归的扩展,其中涉及的属性多于两个,并且数据拟合到一个多维曲面。\n3. 异常值处理方法 删除含有异常值的记录; 某些筛选出来的异常样本是否真的是不需要的异常特征样本,最好找懂业务的再确认一下,防止我们将正常的样本过滤掉了。 将异常值视为缺失值,交给缺失值处理方法来处理; 使用均值/中位数/众数来修正; 不处理。 4. 什么是组合特征?如何处理高维组合特征? 为了提高复杂关系的拟合能力,在特征工程中经常会把一阶离散特征两两组合成高阶特征,构成交互特征(Interaction Feature)。以广告点击预估问题为例,如图1所示,原始数据有语言和类型两种离散特征。为了提高拟合能力,语言和类型可以组成二阶特征。\n5. 类别型特征 什么是类别型特征?\n例如:性别(男、女)、血型(A、B、AB、O)\n通常是字符串形式,需要转化成数值型,传递给模型\n如何处理类别型特征?\n序号编码(Ordinal Encoding) 例如学习成绩有高中低三档,也就是不同类别之间关系。\n这时可以用321来表示,保留了大小关系。\n独热编码(One-hot Encoding) 例如血型,它的类别没有大小关系。A 型血表示为(1, 0, 0, 0),B 型血表示为(0, 1, 0, 0)……\n二进制编码(Binary Encoding) 第一步,先用序号编码给每个类别编码\n第二步,将类别 ID 转化为相应的二进制\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%89%B9%E5%BE%81%E5%B7%A5%E7%A8%8B%E4%B9%8B%E6%95%B0%E6%8D%AE%E9%A2%84%E5%A4%84%E7%90%86/","summary":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说","title":"特征工程之数据预处理"},{"content":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说特征工程是机器学习成功的关键。\n那特征工程是什么?\n​\t特征工程是利用数据领域的相关知识来创建能够使机器学习算法达到最佳性能的特征的过程。\n特征工程又包含了Feature Selection(特征选择)、Feature Extraction(特征提取)和Feature construction(特征构造)等子问题,本章内容主要讨论特征选择相关的方法及实现。\n在实际项目中,我们可能会有大量的特征可使用,有的特征携带的信息丰富,有的特征携带的信息有重叠,有的特征则属于无关特征,如果所有特征不经筛选地全部作为训练特征,经常会出现维度灾难问题,甚至会降低模型的准确性。因此,我们需要进行特征筛选,排除无效/冗余的特征,把有用的特征挑选出来作为模型的训练数据。\n特征选择介绍 特征按重要性分类 相关特征\n对于学习任务(例如分类问题)有帮助,可以提升学习算法的效果\n无关特征\n对于我们的算法没有任何帮助,不会给算法的效果带来任何提升\n冗余特征\n不会对我们的算法带来新的信息,或者这种特征的信息可以由其他的特征推断出\n特征选择的目的 对于一个特定的学习算法来说,哪一个特征是有效的是未知的。因此,需要从所有特征中选择出对于学习算法有益的相关特征。而且在实际应用中,经常会出现维度灾难问题。如果只选择所有特征中的部分特征构建模型,那么可以大大减少学习算法的运行时间,也可以增加模型的可解释性\n特征选择的原则 获取尽可能小的特征子集,不显著降低分类精度、不影响分类分布以及特征子集应具有稳定、适应性强等特点\n特征选择的方法 Filter 方法(过滤式) 先进行特征选择,然后去训练学习器,所以特征选择的过程与学习器无关。相当于先对特征进行过滤操作,然后用特征子集来训练分类器。\n**主要思想:**对每一维特征“打分”,即给每一维的特征赋予权重,这样的权重就代表着该特征的重要性,然后依据权重排序。\n主要方法:\n卡方检验 信息增益 相关系数 优点: 运行速度快,是一种非常流行的特征选择方法。\n**缺点:**无法提供反馈,特征选择的标准/规范的制定是在特征搜索算法中完成,学习算法无法向特征搜索算法传递对特征的需求。另外,可能处理某个特征时由于任意原因表示该特征不重要,但是该特征与其他特征结合起来则可能变得很重要。\nWrapper 方法 (封装式) 直接把最后要使用的分类器作为特征选择的评价函数,对于特定的分类器选择最优的特征子集。\n主要思想: 将子集的选择看作是一个搜索寻优问题,生成不同的组合,对组合进行评价,再与其他的组合进行比较。这样就将子集的选择看作是一个优化问题,这里有很多的优化算法可以解决,尤其是一些启发式的优化算法,如GA、PSO(如:优化算法-粒子群算法)、DE、ABC(如:优化算法-人工蜂群算法)等。\n主要方法:\n递归特征消除算法 优点: 对特征进行搜索时围绕学习算法展开的,对特征选择的标准/规范是在学习算法的需求中展开的,能够考虑学习算法所属的任意学习偏差,从而确定最佳子特征,真正关注的是学习问题本身。由于每次尝试针对特定子集时必须运行学习算法,所以能够关注到学习算法的学习偏差/归纳偏差,因此封装能够发挥巨大的作用。\n缺点: 运行速度远慢于过滤算法,实际应用用封装方法没有过滤方法流行。\nEmbedded 方法(嵌入式) 将特征选择嵌入到模型训练当中,其训练可能是相同的模型,但是特征选择完成后,还能给予特征选择完成的特征和模型训练出的超参数,再次训练优化。\n主要思想: 在模型既定的情况下学习出对提高模型准确性最好的特征。也就是在确定模型的过程中,挑选出那些对模型的训练有重要意义的特征。\n主要方法: 用带有L1正则化的项完成特征选择(也可以结合L2惩罚项来优化)、随机森林平均不纯度减少法/平均精确度减少法。\n优点: 对特征进行搜索时围绕学习算法展开的,能够考虑学习算法所属的任意学习偏差。训练模型的次数小于Wrapper方法,比较节省时间。\n缺点: 运行速度慢\n特征选择的实现方法 从两个方面考虑来选择特征: 特征是否发散: 如果一个特征不发散,例如方差接近于0,也就是说样本在这个特征上基本上没有差异,这个特征对于样本的区分并没有什么用。\n假设某特征的特征值只有0和1,并且在所有输入样本中,95%的实例的该特征取值都是1,那就可以认为这个特征作用不大。如果100%都是1,那这个特征就没意义了。\n**特征与目标的相关性:**这点比较显见,与目标相关性高的特征,应当优选选择。除方差法外,本文介绍的其他方法均从相关性考虑。\nFilter: 卡方检验 经典的卡方检验是检验定性自变量对定性因变量的相关性。假设自变量有N种取值,因变量有M种取值,考虑自变量等于i且因变量等于j的样本频数的观察值与期望的差距,构建统计量:\n不难发现,这个统计量的含义简而言之就是自变量对因变量的相关性。用feature_selection库的SelectKBest类结合卡方检验来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectKBest from sklearn.feature_selection import chi2 #选择K个最好的特征,返回选择特征后的数据 SelectKBest(chi2, k=2).fit_transform(iris.data, iris.target) 方差选择 使用方差选择法,先要计算各个特征的方差,然后根据阈值,选择方差大于阈值的特征。使用feature_selection库的VarianceThreshold类来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import VarianceThreshold #方差选择法,返回值为特征选择后的数据 #参数threshold为方差的阈值 VarianceThreshold(threshold=3).fit_transform(iris.data) 相关系数 使用相关系数法,先要计算各个特征对目标值的相关系数以及相关系数的P值。用feature_selection库的SelectKBest类结合相关系数来选择特征的代码如下:\n1 2 3 4 5 6 7 from sklearn.feature_selection import SelectKBest from scipy.stats import pearsonr #选择K个最好的特征,返回选择特征后的数据 #第一个参数为计算评估特征是否好的函数,该函数输入特征矩阵和目标向量,输出二元组(评分,P值)的数组,数组第i项为第i个特征的评分和P值。在此定义为计算相关系数 #参数k为选择的特征个数 SelectKBest(lambda X, Y: array(map(lambda x:pearsonr(x, Y), X.T)).T, k=2).fit_transform(iris.data, iris.target) 互信息法 经典的互信息也是评价定性自变量对定性因变量的相关性的,互信息计算公式如下:\n为了处理定量数据,最大信息系数法被提出,使用feature_selection库的SelectKBest类结合最大信息系数法来选择特征的代码如下:\n1 2 3 4 5 6 7 8 9 10 11 from sklearn.feature_selection import SelectKBest from minepy import MINE #由于MINE的设计不是函数式的,定义mic方法将其为函数式的,返回一个二元组,二元组的第2项设置成固定的P值0.5 def mic(x, y): m = MINE() m.compute_score(x, y) return (m.mic(), 0.5) #选择K个最好的特征,返回特征选择后的数据 SelectKBest(lambda X, Y: array(map(lambda x:mic(x, Y), X.T)).T, k=2).fit_transform(iris.data, iris.target) Wrapper: 递归特征消除法 递归消除特征法使用一个基模型来进行多轮训练,每轮训练后,消除若干权值系数的特征,再基于新的特征集进行下一轮训练。使用feature_selection库的RFE类来选择特征的代码如下:\n1 2 3 4 5 6 7 from sklearn.feature_selection import RFE from sklearn.linear_model import LogisticRegression #递归特征消除法,返回特征选择后的数据 #参数estimator为基模型 #参数n_features_to_select为选择的特征个数 RFE(estimator=LogisticRegression(), n_features_to_select=2).fit_transform(iris.data, iris.target) Embedded : 基于惩罚项的特征选择法 使用带惩罚项的基模型,除了筛选出特征外,同时也进行了降维。使用feature_selection库的SelectFromModel类结合带L1惩罚项的逻辑回归模型,来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectFromModel from sklearn.linear_model import LogisticRegression #带L1惩罚项的逻辑回归作为基模型的特征选择 SelectFromModel(LogisticRegression(penalty=\u0026#34;l1\u0026#34;, C=0.1)).fit_transform(iris.data, iris.target) 实际上,L1惩罚项降维的原理在于保留多个对目标值具有同等相关性的特征中的一个,所以没选到的特征不代表不重要。故,可结合L2惩罚项来优化。具体操作为:若一个特征在L1中的权值为1,选择在L2中权值差别不大且在L1中权值为0的特征构成同类集合,将这一集合中的特征平分L1中的权值,故需要构建一个新的逻辑回归模型:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 from sklearn.linear_model import LogisticRegression class LR(LogisticRegression): def __init__(self, threshold=0.01, dual=False, tol=1e-4, C=1.0, fit_intercept=True, intercept_scaling=1, class_weight=None, random_state=None, solver=\u0026#39;liblinear\u0026#39;, max_iter=100, multi_class=\u0026#39;ovr\u0026#39;, verbose=0, warm_start=False, n_jobs=1): #权值相近的阈值 self.threshold = threshold LogisticRegression.__init__(self, penalty=\u0026#39;l1\u0026#39;, dual=dual, tol=tol, C=C, fit_intercept=fit_intercept, intercept_scaling=intercept_scaling, class_weight=class_weight, random_state=random_state, solver=solver, max_iter=max_iter, multi_class=multi_class, verbose=verbose, warm_start=warm_start, n_jobs=n_jobs) #使用同样的参数创建L2逻辑回归 self.l2 = LogisticRegression(penalty=\u0026#39;l2\u0026#39;, dual=dual, tol=tol, C=C, fit_intercept=fit_intercept, intercept_scaling=intercept_scaling, class_weight = class_weight, random_state=random_state, solver=solver, max_iter=max_iter, multi_class=multi_class, verbose=verbose, warm_start=warm_start, n_jobs=n_jobs) def fit(self, X, y, sample_weight=None): #训练L1逻辑回归 super(LR, self).fit(X, y, sample_weight=sample_weight) self.coef_old_ = self.coef_.copy() #训练L2逻辑回归 self.l2.fit(X, y, sample_weight=sample_weight) cntOfRow, cntOfCol = self.coef_.shape #权值系数矩阵的行数对应目标值的种类数目 for i in range(cntOfRow): for j in range(cntOfCol): coef = self.coef_[i][j] #L1逻辑回归的权值系数不为0 if coef != 0: idx = [j] #对应在L2逻辑回归中的权值系数 coef1 = self.l2.coef_[i][j] for k in range(cntOfCol): coef2 = self.l2.coef_[i][k] #在L2逻辑回归中,权值系数之差小于设定的阈值,且在L1中对应的权值为0 if abs(coef1-coef2) \u0026lt; self.threshold and j != k and self.coef_[i][k] == 0: idx.append(k) #计算这一类特征的权值系数均值 mean = coef / len(idx) self.coef_[i][idx] = mean return self 使用feature_selection库的SelectFromModel类结合带L1以及L2惩罚项的逻辑回归模型,来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectFromModel #带L1和L2惩罚项的逻辑回归作为基模型的特征选择 #参数threshold为权值系数之差的阈值 SelectFromModel(LR(threshold=0.5, C=0.1)).fit_transform(iris.data, iris.target) 基于树模型的特征选择法\n树模型中GBDT也可用来作为基模型进行特征选择,使用feature_selection库的SelectFromModel类结合GBDT模型,来选择特征的代码如下:\n1 2 3 4 5 from sklearn.feature_selection import SelectFromModel from sklearn.ensemble import GradientBoostingClassifier #GBDT作为基模型的特征选择 SelectFromModel(GradientBoostingClassifier()).fit_transform(iris.data, iris.target) 降维 当特征选择完成后,可以直接训练模型了,但是可能由于特征矩阵过大,导致计算量大,训练时间长的问题,因此降低特征矩阵维度也是必不可少的。常见的降维方法除了以上提到的基于L1惩罚项的模型以外,另外还有主成分分析法(PCA)和线性判别分析(LDA),线性判别分析本身也是一个分类模型。PCA和LDA有很多的相似点,其本质是要将原始的样本映射到维度更低的样本空间中,但是PCA和LDA的映射目标不一样:PCA是为了让映射后的样本具有最大的发散性;而LDA是为了让映射后的样本有最好的分类性能。所以说PCA是一种无监督的降维方法,而LDA是一种有监督的降维方法。\n主成分分析法(PCA) 使用decomposition库的PCA类选择特征的代码如下:\n1 2 3 4 5 from sklearn.decomposition import PCA #主成分分析法,返回降维后的数据 #参数n_components为主成分数目 PCA(n_components=2).fit_transform(iris.data) 线性判别分析法(LDA) 使用lda库的LDA类选择特征的代码如下:\n1 2 3 4 5 from sklearn.lda import LDA #线性判别分析法,返回降维后的数据 #参数n_components为降维后的维数 LDA(n_components=2).fit_transform(iris.data, iris.target) 什么是特征选择,为什么要进行特征选择,以及如何进行? 特征选择是通过选择旧属性的子集得到新属性,是一种维规约方式。\nWhy: 应用方面:提升准确率,特征选择能够删除冗余不相关的特征并降低噪声,避免维灾难。在许多数据挖掘算法中,维度较低,效果更好;\n执行方面:维度越少,运行效率越高,同时内存需求越少。\nHow: 过滤方法,独立于算法,在算法运行前进行特征选择。如可以选择属性的集合,集合内属性对之间的相关度尽可能低。常用对特征重要性(方差,互信息,相关系数,卡方检验)排序选择;可结合别的算法(随机森林,GBDT等)进行特征重要性提取,过滤之后再应用于当前算法。 包装方法,算法作为黑盒,在确定模型和评价准则之后,对特征空间的不同子集做交叉验证,进而搜索最佳特征子集。深度学习具有自动化包装学习的特性。 总之,特征子集选择是搜索所有可能的特性子集的过程,可以使用不同的搜索策略,但是搜索策略的效率要求比较高,并且应当找到最优或近似最优的特征子集。 嵌入方法,算法本身决定使用哪些属性和忽略哪些属性。即特征选择与训练过程融为一体,比如L1正则、决策树等; 参考: https://blog.csdn.net/Dream_angel_Z/article/details/49388733\n","permalink":"https://reid00.github.io/en/posts/ml/%E7%89%B9%E5%BE%81%E5%B7%A5%E7%A8%8B%E4%B9%8B%E7%89%B9%E5%BE%81%E9%80%89%E6%8B%A9/","summary":"Summary 数据和特征决定了机器学习的上限,而模型和算法只是逼近这个上限而已。由此可见,特征工程在机器学习中占有相当重要的地位。在实际应用当中,可以说","title":"特征工程之特征选择"},{"content":"简介 损失函数用来评价模型的预测值和真实值不一样的程度,损失函数越好,通常模型的性能越好。不同的模型用的损失函数一般也不一样。\n损失函数分为经验风险损失函数和结构风险损失函数。经验风险损失函数指预测结果和实际结果的差别,结构风险损失函数是指经验风险损失函数加上正则项。\n常见的损失函数以及其优缺点如下:\n1. 0-1损失函数(zero-one loss) 0-1损失是指预测值和目标值不相等为1, 否则为0:\n特点:\n(1) 0-1损失函数直接对应分类判断错误的个数,但是它是一个非凸函数,不太适用.\n(2) 感知机就是用的这种损失函数。但是相等这个条件太过严格,因此可以放宽条件,即满足 时认为相等,\n2. 绝对值损失函数 绝对值损失函数是计算预测值与目标值的差的绝对值:\n3. log对数损失函数 log对数损失函数的标准形式如下:\n特点:\n(1) log对数损失函数能非常好的表征概率分布,在很多场景尤其是多分类,如果需要知道结果属于每个类别的置信度,那它非常适合。\n(2) 健壮性不强,相比于hinge loss对噪声更敏感。\n(3) 辑回归的损失函数就是log对数损失函数。\n4. 平方损失函数 平方损失函数标准形式如下:\n特点:\n(1)经常应用与回归问题\n5. 指数损失函数(exponential loss) 指数损失函数的标准形式如下:\n特点:\n(1)对离群点、噪声非常敏感。经常用在AdaBoost算法中。\n6. Hinge 损失函数 Hinge损失函数标准形式如下:\n特点:\n(1) hinge损失函数表示如果被分类正确,损失为0,否则损失就为 。SVM就是使用这个损失函数。\n(2) 一般的 是预测值,在-1到1之间, 是目标值(-1或1)。其含义是, 的值在-1和+1之间就可以了,并不鼓励 ,即并不鼓励分类器过度自信,让某个正确分类的样本距离分割线超过1并不会有任何奖励,从而使分类器可以更专注于整体的误差。\n(3) 健壮性相对较高,对异常点、噪声不敏感,但它没太好的概率解释。\n7. 感知损失(perceptron loss)函数 感知损失函数的标准形式如下:\n特点:\n(1)是Hinge损失函数的一个变种,Hinge loss对判定边界附近的点(正确端)惩罚力度很高。而perceptron loss只要样本的判定类别正确的话,它就满意,不管其判定边界的距离。它比Hinge loss简单,因为不是max-margin boundary,所以模型的泛化能力没 hinge loss强。\n8. 交叉熵损失函数 (Cross-entropy loss function) 交叉熵损失函数的标准形式如下:\n注意公式中 表示样本, 表示实际的标签, 表示预测的输出, 表示样本总数量。\n特点:\n(1)本质上也是一种对数似然函数,可用于二分类和多分类任务中。\n二分类问题中的loss函数(输入数据是softmax或者sigmoid函数的输出):\n多分类问题中的loss函数(输入数据是softmax或者sigmoid函数的输出):\n(2)当使用sigmoid作为激活函数的时候,常用交叉熵损失函数而不用均方误差损失函数,因为它可以完美解决平方损失函数权重更新过慢的问题,具有“误差大的时候,权重更新快;误差小的时候,权重更新慢”的良好性质。\n最后奉献上交叉熵损失函数的实现代码:cross_entropy.\n这里需要更正一点,对数损失函数和交叉熵损失函数应该是等价的!!!(此处感谢\n@Areshyy\n的指正,下面说明也是由他提供)\n下面来具体说明:\n相关高频问题: 1.交叉熵函数与最大似然函数的联系和区别? 区别:交叉熵函数使用来描述模型预测值和真实值的差距大小,越大代表越不相近;似然函数的本质就是衡量在某个参数下,整体的估计和真实的情况一样的概率,越大代表越相近。\n联系:交叉熵函数可以由最大似然函数在伯努利分布的条件下推导出来,或者说最小化交叉熵函数的本质就是对数似然函数的最大化。\n怎么推导的呢?我们具体来看一下。\n设一个随机变量 满足伯努利分布,\n则 的概率密度函数为:\n因为我们只有一组采样数据 ,我们可以统计得到 和 的值,但是 的概率是未知的,接下来我们就用极大似然估计的方法来估计这个 值。\n对于采样数据 ,其对数似然函数为:\n可以看到上式和交叉熵函数的形式几乎相同,极大似然估计就是要求这个式子的最大值。而由于上面函数的值总是小于0,一般像神经网络等对于损失函数会用最小化的方法进行优化,所以一般会在前面加一个负号,得到交叉熵函数(或交叉熵损失函数):\n这个式子揭示了交叉熵函数与极大似然估计的联系,最小化交叉熵函数的本质就是对数似然函数的最大化。\n现在我们可以用求导得到极大值点的方法来求其极大似然估计,首先将对数似然函数对 进行求导,并令导数为0,得到\n消去分母,得:\n所以:\n这就是伯努利分布下最大似然估计求出的概率 。\n2. 在用sigmoid作为激活函数的时候,为什么要用交叉熵损失函数,而不用均方误差损失函数? 其实这个问题求个导,分析一下两个误差函数的参数更新过程就会发现原因了。\n对于均方误差损失函数,常常定义为:\n其中 是我们期望的输出, 为神经元的实际输出( )。在训练神经网络的时候我们使用梯度下降的方法来更新 和 ,因此需要计算代价函数对 和 的导数:\n然后更新参数 和 :\n因为sigmoid的性质,导致 在 取大部分值时会很小(如下图标出来的两端,几乎接近于平坦),这样会使得 很小,导致参数 和 更新非常慢。\n那么为什么交叉熵损失函数就会比较好了呢?同样的对于交叉熵损失函数,计算一下参数更新的梯度公式就会发现原因。交叉熵损失函数一般定义为:\n其中 是我们期望的输出, 为神经元的实际输出( )。同样可以看看它的导数:\n另外,\n所以有:\n所以参数更新公式为:\n可以看到参数更新公式中没有 这一项,权重的更新受 影响,受到误差的影响,所以当误差大的时候,权重更新快;当误差小的时候,权重更新慢。这是一个很好的性质。\n所以当使用sigmoid作为激活函数的时候,常用交叉熵损失函数而不用均方误差损失函数。\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E4%B9%8B%E5%B8%B8%E8%A7%81%E6%8D%9F%E5%A4%B1%E5%87%BD%E6%95%B0/","summary":"简介 损失函数用来评价模型的预测值和真实值不一样的程度,损失函数越好,通常模型的性能越好。不同的模型用的损失函数一般也不一样。 损失函数分为经验","title":"机器学习之常见损失函数"},{"content":"1. 无监督和有监督的区别? 有监督学习:对具有概念标记(分类)的训练样本进行学习,以尽可能对训练样本集外的数据进行标记(分类)预测。这里,所有的标记(分类)是已知的。因此,训练样本的岐义性低。\n无监督学习:对没有概念标记(分类)的训练样本进行学习,以发现训练样本集中的结构性知识。这里,所有的标记(分类)是未知的。因此,训练样本的岐义性高。聚类就是典型的无监督学习。\n2. SVM 的推导,特性?多分类怎么处理? SVM是最大间隔分类器,几何间隔和样本的误分次数之间存在关系, ,其中 从线性可分情况下,原问题,特征转换后的dual问题,引入kernel(线性kernel,多项式,高斯),最后是soft margin。\n线性:简单,速度快,但是需要线性可分。\n多项式:比线性核拟合程度更强,知道具体的维度,但是高次容易出现数值不稳定,参数选择比较多。\n高斯:拟合能力最强,但是要注意过拟合问题。不过只有一个参数需要调整。\n多分类问题,一般将二分类推广到多分类的方式有三种,一对一,一对多,多对多。\n一对一:将N个类别两两配对,产生N(N-1)/2个二分类任务,测试阶段新样本同时交给所有的分类器,最终结果通过投票产生。\n一对多:每一次将一个例作为正例,其他的作为反例,训练N个分类器,测试时如果只有一个分类器预测为正类,则对应类别为最终结果,如果有多个,则一般选择置信度最大的。从分类器角度一对一更多,但是每一次都只用了2个类别,因此当类别数很多的时候一对一开销通常更小(只要训练复杂度高于O(N)即可得到此结果)。\n多对多:若干各类作为正类,若干个类作为反类。注意正反类必须特殊的设计。\n3. LR 的推导,特性? LR的优点在于实现简单,并且计算量非常小,速度很快,存储资源低,缺点就是因为模型简单,对于复杂的情况下会出现欠拟合,并且只能处理2分类问题(可以通过一般的二元转换为多元或者用softmax回归)。\n4. 决策树的特性? 决策树基于树结构进行决策,与人类在面临问题的时候处理机制十分类似。其特点在于需要选择一个属性进行分支,在分支的过程中选择信息增益最大的属性,定义如下 在划分中我们希望决策树的分支节点所包含的样本属于同一类别,即节点的纯度越来越高。决策树计算量简单,可解释性强,比较适合处理有缺失属性值的样本,能够处理不相关的特征,但是容易过拟合,需要使用剪枝或者随机森林。信息增益是熵减去条件熵,代表信息不确定性较少的程度,信息增益越大,说明不确定性降低的越大,因此说明该特征对分类来说很重要。由于信息增益准则会对数目较多的属性有所偏好,因此一般用信息增益率(c4.5)\n其中分母可以看作为属性自身的熵。取值可能性越多,属性的熵越大。\nCart决策树使用基尼指数来选择划分属性,直观的来说,Gini(D)反映了从数据集D中随机抽取两个样本,其类别标记不一致的概率,因此基尼指数越小数据集D的纯度越高,一般为了防止过拟合要进行剪枝,有预剪枝和后剪枝,一般用cross validation集进行剪枝。\n连续值和缺失值的处理,对于连续属性a,将a在D上出现的不同的取值进行排序,基于划分点t将D分为两个子集。一般对每一个连续的两个取值的中点作为划分点,然后根据信息增益选择最大的。与离散属性不同,若当前节点划分属性为连续属性,该属性还可以作为其后代的划分属性。\n5. SVM,LR,决策树对比? SVM既可以用于分类问题,也可以用于回归问题,并且可以通过核函数快速的计算,LR实现简单,训练速度非常快,但是模型较为简单,决策树容易过拟合,需要进行剪枝等。从优化函数上看,soft margin的SVM用的是hinge loss,而带L2正则化的LR对应的是cross entropy loss,另外adaboost对应的是exponential loss。所以LR对远点敏感,但是SVM对outlier不太敏感,因为只关心support vector,SVM可以将特征映射到无穷维空间,但是LR不可以,一般小数据中SVM比LR更优一点,但是LR可以预测概率,而SVM不可以,SVM依赖于数据测度,需要先做归一化,LR一般不需要,对于大量的数据LR使用更加广泛,LR向多分类的扩展更加直接,对于类别不平衡SVM一般用权重解决,即目标函数中对正负样本代价函数不同,LR可以用一般的方法,也可以直接对最后结果调整(通过阈值),一般小数据下样本维度比较高的时候SVM效果要更优一些。\n6. GBDT 和随机森林的区别? 随机森林采用的是bagging的思想,bagging又称为bootstrap aggreagation,通过在训练样本集中进行有放回的采样得到多个采样集,基于每个采样集训练出一个基学习器,再将基学习器结合。随机森林在对决策树进行bagging的基础上,在决策树的训练过程中引入了随机属性选择。传统决策树在选择划分属性的时候是在当前节点属性集合中选择最优属性,而随机森林则是对结点先随机选择包含k个属性的子集,再选择最有属性,k作为一个参数控制了随机性的引入程度。\n另外,GBDT训练是基于Boosting思想,每一迭代中根据错误更新样本权重,因此是串行生成的序列化方法,而随机森林是bagging的思想,因此是并行化方法。\n7. 如何判断函数凸或非凸?什么是凸优化? 首先定义凸集,如果x,y属于某个集合C,并且所有的 也属于c,那么c为一个凸集,进一步,如果一个函数其定义域是凸集,并且\n则该函数为凸函数。上述条件还能推出更一般的结果,\n如果函数有二阶导数,那么如果函数二阶导数为正,或者对于多元函数,Hessian矩阵半正定则为凸函数。\n(也可能引到SVM,或者凸函数局部最优也是全局最优的证明,或者上述公式期望情况下的Jessen不等式)\n8. 如何解决类别不平衡问题? 有些情况下训练集中的样本分布很不平衡,例如在肿瘤检测等问题中,正样本的个数往往非常的少。从线性分类器的角度,在用 对新样本进行分类的时候,事实上在用预测出的y值和一个y值进行比较,例如常常在y\u0026gt;0.5的时候判为正例,否则判为反例。几率 反映了正例可能性和反例可能性的比值,阈值0.5恰好表明分类器认为正反的可能性相同。在样本不均衡的情况下,应该是分类器的预测几率高于观测几率就判断为正例,因此应该是 时预测为正例,这种策略称为rebalancing。但是训练集并不一定是真实样本总体的无偏采样,通常有三种做法,一种是对训练集的负样本进行欠采样,第二种是对正例进行升采样,第三种是直接基于原始训练集进行学习,在预测的时候再改变阈值,称为阈值移动。注意过采样一般通过对训练集的正例进行插值产生额外的正例,而欠采样将反例划分为不同的集合供不同的学习器使用。\n9. 解释对偶的概念。 一个优化问题可以从两个角度进行考察,一个是primal 问题,一个是dual 问题,就是对偶问题,一般情况下对偶问题给出主问题最优值的下界,在强对偶性成立的情况下由对偶问题可以得到主问题的最优下界,对偶问题是凸优化问题,可以进行较好的求解,SVM中就是将primal问题转换为dual问题进行求解,从而进一步引入核函数的思想。\n10. 如何进行特征选择 ? 特征选择是一个重要的数据预处理过程,主要有两个原因,首先在现实任务中我们会遇到维数灾难的问题(样本密度非常稀疏),若能从中选择一部分特征,那么这个问题能大大缓解,另外就是去除不相关特征会降低学习任务的难度,增加模型的泛化能力。冗余特征指该特征包含的信息可以从其他特征中推演出来,但是这并不代表该冗余特征一定没有作用,例如在欠拟合的情况下也可以用过加入冗余特征,增加简单模型的复杂度。\n在理论上如果没有任何领域知识作为先验假设那么只能遍历所有可能的子集。但是这显然是不可能的,因为需要遍历的数量是组合爆炸的。一般我们分为子集搜索和子集评价两个过程,子集搜索一般采用贪心算法,每一轮从候选特征中添加或者删除,分别成为前向和后先搜索。或者两者结合的双向搜索。子集评价一般采用信息增益,对于连续数据往往排序之后选择中点作为分割点。\n常见的特征选择方式有过滤式,包裹式和嵌入式,filter,wrapper和embedding。Filter类型先对数据集进行特征选择,再训练学习器。Wrapper直接把最终学习器的性能作为特征子集的评价准则,一般通过不断候选子集,然后利用cross-validation过程更新候选特征,通常计算量比较大。嵌入式特征选择将特征选择过程和训练过程融为了一体,在训练过程中自动进行了特征选择,例如L1正则化更易于获得稀疏解,而L2正则化更不容易过拟合。L1正则化可以通过PGD,近端梯度下降进行求解。\n11. 为什么会产生过拟合,有哪些方法可以预防或克服过拟合? 一般在机器学习中,将学习器在训练集上的误差称为训练误差或者经验误差,在新样本上的误差称为泛化误差。显然我们希望得到泛化误差小的学习器,但是我们事先并不知道新样本,因此实际上往往努力使经验误差最小化。然而,当学习器将训练样本学的太好的时候,往往可能把训练样本自身的特点当做了潜在样本具有的一般性质。这样就会导致泛化性能下降,称之为过拟合,相反,欠拟合一般指对训练样本的一般性质尚未学习好,在训练集上仍然有较大的误差。\n欠拟合:一般来说欠拟合更容易解决一些,例如增加模型的复杂度,增加决策树中的分支,增加神经网络中的训练次数等等。根本的原因是特征维度过少,导致拟合的函数无法满足训练集,误差较大。\n欠拟合问题可以通过增加特征维度来解决。可以考虑加入进特征组合、高次特征,来增大假设空间;\n添加多项式特征,这个在机器学习算法里面用的很普遍,例如将线性模型通过添加二次项或者三次项使模型泛化能力更强\n减少正则化参数,正则化的目的是用来防止过拟合的,但是现在模型出现了欠拟合,则需要减少正则化参数\n使用非线性模型,比如核SVM 、决策树、深度学习等模型\n过拟合:一般认为过拟合是无法彻底避免的,因为机器学习面临的问题一般是np-hard,但是一个有效的解一定要在多项式内可以工作,所以会牺牲一些泛化能力。过拟合的解决方案一般有增加样本数量,对样本进行降维,降低模型复杂度,利用先验知识(L1,L2正则化),利用cross-validation,early stopping等等。根本的原因则是特征维度过多,导致拟合的函数完美的经过训练集,但是对新数据的预测结果则较差。\n其他原因:\n训练数据集样本单一,样本不足。如果训练样本只有负样本,然后那生成的模型去预测正样本,这肯定预测不准。所以训练样本要尽可能的全面,覆盖所有的数据类型。 训练数据中噪声干扰过大。噪声指训练数据中的干扰数据。过多的干扰会导致记录了很多噪声特征,忽略了真实输入和输出之间的关系。 **模型过于复杂。**模型太复杂,已经能够“死记硬背”记下了训练数据的信息,但是遇到没有见过的数据的时候不能够变通,泛化能力太差。我们希望模型对不同的模型都有稳定的输出。模型太复杂是过拟合的重要因素。 获取和使用更多的数据(数据集增强)——解决过拟合的根本性方法\n减少特征维度; 可以人工选择保留的特征,或者模型选择算法\n重新清洗数据,导致过拟合的一个原因也有可能是数据不纯导致的,如果出现了过拟合就需要我们重新清洗数据。\n采用正则化方法。正则化方法包括L0正则、L1正则和L2正则,而正则一般是在目标函数之后加上对于的范数。但是在机器学习中一般使用L2正则,下面看具体的原因。\nL0范数是指向量中非0的元素的个数。L1范数是指向量中各个元素绝对值之和,也叫“稀疏规则算子”(Lasso regularization)。两者都可以实现稀疏性,既然L0可以实现稀疏,为什么不用L0,而要用L1呢?个人理解一是因为L0范数很难优化求解(NP难问题),二是L1范数是L0范数的最优凸近似,而且它比L0范数要容易优化求解。所以大家才把目光和万千宠爱转于L1范数。 L2范数是指向量各元素的平方和然后求平方根。可以使得W的每个元素都很小,都接近于0,但与L1范数不同,它不会让它等于0,而是接近于0。L2正则项起到使得参数w变小加剧的效果,但是为什么可以防止过拟合呢?一个通俗的理解便是:更小的参数值w意味着模型的复杂度更低,对训练数据的拟合刚刚好(奥卡姆剃刀),不会过分拟合训练数据,从而使得不会过拟合,以提高模型的泛化能力。还有就是看到有人说L2范数有助于处理 condition number不好的情况下矩阵求逆很困难的问题。 采用dropout方法。这个方法在神经网络里面很常用。 12. 什么是偏差与方差? 泛化误差可以分解成偏差的平方加上方差加上噪声。偏差度量了学习算法的期望预测和真实结果的偏离程度,刻画了学习算法本身的拟合能力,方差度量了同样大小的训练集的变动所导致的学习性能的变化,刻画了数据扰动所造成的影响,噪声表达了当前任务上任何学习算法所能达到的期望泛化误差下界,刻画了问题本身的难度。偏差和方差一般称为bias和variance,一般训练程度越强,偏差越小,方差越大,泛化误差一般在中间有一个最小值,如果偏差较大,方差较小,此时一般称为欠拟合,而偏差较小,方差较大称为过拟合。\n偏差: 方差: 13. 神经网络的原理,如何进行训练? 神经网络自发展以来已经是一个非常庞大的学科,一般而言认为神经网络是由单个的神经元和不同神经元之间的连接构成,不够的结构构成不同的神经网络。最常见的神经网络一般称为多层前馈神经网络,除了输入和输出层,中间隐藏层的个数被称为神经网络的层数。BP算法是训练神经网络中最著名的算法,其本质是梯度下降和链式法则。\n14. 介绍卷积神经网络,和 DBN 有什么区别? 卷积神经网络的特点是卷积核,CNN中使用了权共享,通过不断的上采用和卷积得到不同的特征表示,采样层又称为pooling层,基于局部相关性原理进行亚采样,在减少数据量的同时保持有用的信息。DBN是深度信念网络,每一层是一个RBM,整个网络可以视为RBM堆叠得到,通常使用无监督逐层训练,从第一层开始,每一层利用上一层的输入进行训练,等各层训练结束之后再利用BP算法对整个网络进行训练\n15. 采用 EM 算法求解的模型有哪些,为什么不用牛顿法或梯度下降法? 用EM算法求解的模型一般有GMM或者协同过滤,k-means其实也属于EM。EM算法一定会收敛,但是可能收敛到局部最优。由于求和的项数将随着隐变量的数目指数上升,会给梯度计算带来麻烦。\n16. 用 EM 算法推导解释 Kmeans k-means算法是高斯混合聚类在混合成分方差相等,且每个样本仅指派一个混合成分时候的特例。注意k-means在运行之前需要进行归一化处理,不然可能会因为样本在某些维度上过大导致距离计算失效。k-means中每个样本所属的类就可以看成是一个隐变量,在E步中,我们固定每个类的中心,通过对每一个样本选择最近的类优化目标函数,在M步,重新更新每个类的中心点,该步骤可以通过对目标函数求导实现,最终可得新的类中心就是类中样本的均值。\n17. 用过哪些聚类算法,解释密度聚类算法。 k-means算法,聚类性能的度量一般分为两类,一类是聚类结果与某个参考模型比较(外部指标),另外是直接考察聚类结果(内部指标)。后者通常有DB指数和DI,DB指数是对每个类,找出类内平均距离/类间中心距离最大的类,然后计算上述值,并对所有的类求和,越小越好。类似k-means的算法仅在类中数据构成簇的情况下表现较好,密度聚类算法从样本密度的角度考察样本之间的可连接性,并基于可连接样本不断扩展聚类蔟得到最终结果。DBSCAN(density-based spatial clustering of applications with noise)是一种著名的密度聚类算法,基于一组邻域参数 进行刻画,包括邻域,核心对象(邻域内至少包含 个对象),密度直达(j由i密度直达,表示j在i的邻域内,且i是一个核心对象),密度可达(j由i密度可达,存在样本序列使得每一对都密度直达),密度相连(xi,xj存在k,i,j均有k可达),先找出样本中所有的核心对象,然后以任一核心对象作为出发点,找出由其密度可达的样本生成聚类蔟,直到所有核心对象被访问过为止。\n18. 聚类算法中的距离度量有哪些? 聚类算法中的距离度量一般用闽科夫斯基距离,在p取不同的值下对应不同的距离,例如p=1的时候对应曼哈顿距离,p=2的情况下对应欧式距离,p=inf的情况下变为切比雪夫距离,还有jaccard距离,幂距离(闽科夫斯基的更一般形式),余弦相似度,加权的距离,马氏距离(类似加权)作为距离度量需要满足非负性,同一性,对称性和直递性,闽科夫斯基在p\u0026gt;=1的时候满足读来那个性质,对于一些离散属性例如{飞机,火车,轮船}则不能直接在属性值上计算距离,这些称为无序属性,可以用VDM(Value Diffrence Metrix),属性u上两个离散值a,b之间的VDM距离定义为\n其中 表示在第i个簇中属性u上a的样本数,样本空间中不同属性的重要性不同的时候可以采用加权距离,一般如果认为所有属性重要性相同则要对特征进行归一化。一般来说距离需要的是相似性度量,距离越大,相似度越小,用于相似性度量的距离未必一定要满足距离度量的所有性质,例如直递性。比如人马和人,人马和马的距离较近,然后人和马的距离可能就很远。\n19. 解释贝叶斯公式和朴素贝叶斯分类。 贝叶斯公式:\n最小化分类错误的贝叶斯最优分类器等价于最大化后验概率。\n基于贝叶斯公式来估计后验概率的主要困难在于,条件概率 是所有属性上的联合概率,难以从有限的训练样本直接估计得到。朴素贝叶斯分类器采用了属性条件独立性假设,对于已知的类别,假设所有属性相互独立。这样,朴素贝叶斯分类则定义为\n如果有足够多的独立同分布样本,那么 可以根据每个类中的样本数量直接估计出来。在离散情况下先验概率可以利用样本数量估计或者离散情况下根据假设的概率密度函数进行最大似然估计。朴素贝叶斯可以用于同时包含连续变量和离散变量的情况。如果直接基于出现的次数进行估计,会出现一项为0而乘积为0的情况,所以一般会用一些平滑的方法,例如拉普拉斯修正,\n这样既可以保证概率的归一化,同时还能避免上述出现的现象。\n20. 解释L1和L2正则化的作用。 L1正则化是在代价函数后面加上 ,L2正则化是在代价函数后面增加了 ,两者都起到一定的过拟合作用,两者都对应一定的先验知识,L1对应拉普拉斯分布,L2对应高斯分布,L1偏向于参数稀疏性,L2偏向于参数分布较为稠\n21. TF-IDF是什么? TF指Term frequecy,代表词频,IDF代表inverse document frequency,叫做逆文档频率,这个算法可以用来提取文档的关键词,首先一般认为在文章中出现次数较多的词是关键词,词频就代表了这一项,然而有些词是停用词,例如的,是,有这种大量出现的词,首先需要进行过滤,比如过滤之后再统计词频出现了中国,蜜蜂,养殖且三个词的词频几乎一致,但是中国这个词出现在其他文章的概率比其他两个词要高不少,因此我们应该认为后两个词更能表现文章的主题,IDF就代表了这样的信息,计算该值需要一个语料库,如果一个词在语料库中出现的概率越小,那么该词的IDF应该越大,一般来说TF计算公式为(某个词在文章中出现次数/文章的总词数),这样消除长文章中词出现次数多的影响,IDF计算公式为log(语料库文章总数/(包含该词的文章数)+1)。将两者乘乘起来就得到了词的TF-IDF。传统的TF-IDF对词出现的位置没有进行考虑,可以针对不同位置赋予不同的权重进行修正,注意这些修正之所以是有效的,正是因为人观测过了大量的信息,因此建议了一个先验估计,人将这个先验估计融合到了算法里面,所以使算法更加的有效\n22. 文本中的余弦距离是什么,有哪些作用? 余弦距离是两个向量的距离的一种度量方式,其值在-1~1之间,如果为1表示两个向量同相,0表示两个向量正交,-1表示两个向量反向。使用TF-IDF和余弦距离可以寻找内容相似的文章,例如首先用TF-IDF找出两篇文章的关键词,然后每个文章分别取出k个关键词(10-20个),统计这些关键词的词频,生成两篇文章的词频向量,然后用余弦距离计算其相似度。\n参考:\n应聘机器学习工程师?这是你需要知道的12个基础面试问题 Python机器学习Sklearn专题文章集锦 ","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E9%9D%A2%E8%AF%95%E9%A2%98/","summary":"1. 无监督和有监督的区别? 有监督学习:对具有概念标记(分类)的训练样本进行学习,以尽可能对训练样本集外的数据进行标记(分类)预测。这里,所有的","title":"机器学习面试题"},{"content":"正则化也是校招中常考的题目之一,在去年的校招中,被问到了多次:\n1、过拟合的解决方式有哪些,l1和l2正则化都有哪些不同,各自有什么优缺点(爱奇艺) 2、L1和L2正则化来避免过拟合是大家都知道的事情,而且我们都知道L1正则化可以得到稀疏解,L2正则化可以得到平滑解,这是为什么呢? 3、L1和L2有什么区别,从数学角度解释L2为什么能提升模型的泛化能力。(美团) 4、L1和L2的区别,以及各自的使用场景(头条)\n接下来,咱们就针对上面的几个问题,进行针对性回答!\nLink: https://mp.weixin.qq.com/s/t4vRBZXhc0LBST8WGzftgg\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%80%E5%B8%B8%E8%80%83%E7%9A%84%E6%AD%A3%E5%88%99%E9%97%AE%E9%A2%98l1l2/","summary":"正则化也是校招中常考的题目之一,在去年的校招中,被问到了多次: 1、过拟合的解决方式有哪些,l1和l2正则化都有哪些不同,各自有什么优缺点(爱","title":"最常考的正则问题L1L2"},{"content":"贝叶斯准备知识 贝叶斯决策论是概率框架下实施决策的基本方法。要了解贝叶斯决策论,首先得先了解以下几个概念:先验概率、条件概率、后验概率、误判损失、条件风险、贝叶斯判别准则\n先验概率: 所谓先验概率,就是根据以往的经验或者现有数据的分析所得到的概率。如,随机扔一枚硬币,则p(正面) = p(反面) = 1/2,这是我们根据已知的知识所知道的信息,即p(正面) = 1/2为先验概率。\n条件概率: 所谓条件概率是指事件A在另一事件B发生的条件下发送的概率。用数学符号表示为:P(B\\|A),即B在A发生的条件下发生的概率。举个栗子,你早上误喝了一瓶过期了的牛奶(A),那我们来算一下你今天拉肚子的概率(B),这个就叫做条件概率。即P(拉肚子\\|喝了过期牛奶), 易见,条件概率是有因求果(知道原因推测结果)。\n后验概率: 后验概率跟条件概率的表达形式有点相似。数学表达式为p(A\\|B), 即A在B发生的条件下发生的概率。以误喝牛奶的例子为例,现在知道了你今天拉肚子了(B),算一下你早上误喝了一瓶过期了的牛奶(A)的概率, 即P(A|B),这就是后验概率,后验概率是有果求因(知道结果推出原因)\n误判损失: 数学表达式:L(j|i), 判别损失表示把一个标记为i类的样本误分类为j类所造成的损失。 比如,当你去参加体检时,明明你各项指标都是正常的,但是医生却把你分为癌症病人,这就造成了误判损失,用数学表示为:L(癌症|正常)。\n条件风险: 是指基于后验概率P(i|x)可获得将样本x分类为i所产生的期望损失,公式为:R(i|x) = ∑L(i|j)P(j|x)。(其实就是所有判别损失的加权和,而这个权就是样本判为j类的概率,样本本来应该含有P(j|x)的概率判为j类,但是却判为了i类,这就造成了错判损失,而将所有的错判损失与正确判断的概率的乘积相加,就能得到样本错判为i类的平均损失,即条件风险。)\n举个栗子,假设把癌症病人判为正常人的误判损失是100,把正常人判为癌症病人的误判损失是10,把感冒病人判为癌症的误判损失是8,即L(正常|癌症) = 100, L(癌症|正常) = 10,L(癌症|感冒) = 8, 现在,我们经过计算知道有一个来体检的员工的后验概率分别为:p(正常|各项指标) = 0.2, p(感冒|各项指标) = 0.4, p( 癌症|各项指标)=0.4。假如我们需要计算将这个员工判为癌症的条件风险,则:R(癌症|各项指标) = L(癌症|正常) p(正常|各项指标) + L(癌症|感冒) * p(感冒|各项指标) = 5.2。*\n贝叶斯判别准则:\n贝叶斯判别准则是找到一个使条件风险达到最小的判别方法。即,将样本判为哪一类,所得到的条件风险R(i|x)(或者说平均判别损失)最小,那就将样本归为那个造成平均判别损失最小的类。\n此时:h*(x) = argminR(i|x) 就称为 贝叶斯最优分类器。\n总结:贝叶斯决策论是基于先验概率求解后验概率的方法,其核心是寻找一个判别准则使得条件风险达到最小。而在最小化分类错误率的目标下,贝叶斯最优分类器又可以转化为求后验概率达到最大的类别标记,即 h*(x) = argmaxP(i|x)。(此时,L(i|j) = 0, if i = j;L(i|j) = 1, otherwise)\n简单说说朴素贝叶斯 朴素贝叶斯采用 属性条件独立性 的假设,对于给定的待分类观测数据X,计算在X出现的条件下,各个目标类出现的概率(即后验概率),将该后验概率最大的类作为X所属的类。而计算后验概率的贝叶斯公式为:p(A|B) =[ p(A) * p(B|A)]/p(B),因为p(B)表示观测数据X出现的概率,它在所有关于X的分类计算公式中都是相同的,所以我们可以把p(B)忽略,则 p(A|B)= p(A) * p(B|A)。\n举个栗子,公司里面男性有60人,女性有40人,男性穿皮鞋的人数有25人,穿运动鞋的人数有35人,女性穿皮鞋的人数有10人,穿高跟鞋的人数有30人。现在你只知道有一个人穿了皮鞋,这时候你就需要推测他的性别是什么。如果推测出他是男性的概率大于女性,那么就认为他是男性,否则认为他是女性。(如果此时条件允许,你可以现场给面试官演示一下怎么计算, 计算过程如下:\n1 p(性别 = 男性) = 0.6p(性别 = 女性) = 0.4p(穿皮鞋\\|男性) = 0.417p(穿皮鞋\\|女性) = 0.25p(穿皮鞋\\|男性) * p(性别 = 男性) = 0.2502p(穿皮鞋\\|女性) * p(性别 = 女性) = 0.1 朴素贝叶斯中的朴素怎么理解 素贝叶斯中的朴素可以理解为是“简单、天真”的意思,因为“朴素”是假设了特征之间是同等重要、相互独立、互不影响的,但是在我们的现实社会中,属性之间并不是都是互相独立的,有些属性也会存在性,所以说朴素贝叶斯是一种很“朴素”的算法。\n素贝叶斯的工作流程 可以分为三个阶段进行,分别是准备阶段、分类器训练阶段和应用阶段。\n**准备阶段:**这个阶段的任务是为朴素贝叶斯分类做必要的准备,主要工作是根据具体情况确定特征属性,并对每个特征属性进行适当划分,去除高度相关性的属性(如果两个属性具有高度相关性的话,那么该属性将会在模型中发挥了2次作用,会使得朴素贝叶斯所预测的结果向该属性所希望的方向偏离,导致分类出现偏差),然后由人工对一部分待分类项进行分类,形成训练样本集合。这一阶段的输入是所有待分类数据,输出是特征属性和训练样本。(这一阶段是整个朴素贝叶斯分类中唯一需要人工完成的阶段,其质量对整个过程将有重要影响。)\n分类器训练阶段:这个阶段的任务就是生成分类器,主要工作是计算每个类别在训练样本中的出现频率及每个特征属性划分对每个类别的条件概率估计,并将结果记录。其输入是特征属性和训练样本,输出是分类器。这一阶段是机械性阶段,根据前面讨论的公式可以由程序自动计算完成。\n**应用阶段:**这个阶段的任务是使用分类器对待分类项进行分类,其输入是分类器和待分类项,输出是待分类项与类别的映射关系。这一阶段也是机械性阶段,由程序完成。\n朴素贝叶斯有什么优缺点 优点 朴素贝叶斯模型发源于古典数学理论,有稳定的分类效率。\n对缺失数据不太敏感,算法也比较简单,常用于文本分类。\n分类准确度高,速度快。\n对小规模的数据表现很好,能处理多分类任务,适合增量式训练,当数据量超出内存时,我们可以一批批的去增量训练(朴素贝叶斯在训练过程中只需要计算各个类的概率和各个属性的类条件概率,这些概率值可以快速地根据增量数据进行更新,无需重新全量计算)。\n缺点 对训练数据的依赖性很强,如果训练数据误差较大,那么预测出来的效果就会不佳。对输入数据的表达形式很敏感(离散、连续,值极大极小之类的)\n理论上,朴素贝叶斯模型与其他分类方法相比具有最小的误差率。\n但是在实际中,因为朴素贝叶斯“朴素,”的特点,导致在属性个数比较多或者属性之间相关性较大时,分类效果不好。\n而在属性相关性较小时,朴素贝叶斯性能最为良好。\n对于这一点,有半朴素贝叶斯之类的算法通过考虑部分关联性适度改进。\n需要知道先验概率,且先验概率很多时候是基于假设或者已有的训练数据所得的,这在某些时候可能会因为假设先验概率的原因出现分类决策上的错误。\n“朴素”是朴素贝叶斯在进行预测时候的缺点,那么有这么一个明显的假设缺点在,为什么朴素贝叶斯的预测仍然可以取得较好的效果? 对于分类任务来说,只要各个条件概率之间的排序正确,那么就可以通过比较概率大小来进行分类,不需要知道精确的概率值(朴素贝叶斯分类的核心思想是找出后验概率最大的那个类,而不是求出其精确的概率) 如果属性之间的相互依赖对所有类别的影响相同,或者相互依赖关系可以互相抵消,那么属性条件独立性的假设在降低计算开销的同时不会对分类结果产生不良影响。 什么是拉普拉斯平滑法? 在估计条件概率P(X|Y)时出现概率为0的情况怎么办?\n简单来说:引入λ,当λ=1时称为拉普拉斯平滑。\n拉普拉斯平滑法是朴素贝叶斯中处理零概率问题的一种修正方式。在进行分类的时候,可能会出现某个属性在训练集中没有与某个类同时出现过的情况,如果直接基于朴素贝叶斯分类器的表达式进行计算的话就会出现零概率现象。为了避免其他属性所携带的信息被训练集中未出现过的属性值“抹去”,所以才使用拉普拉斯估计器进行修正。具体的方法是:在分子上加1,对于先验概率,在分母上加上训练集中可能的类别数;对于条件概率,则在分母上加上第i个属性可能的取值数\n朴素贝叶斯中有没有超参数可以调? 朴素贝叶斯是没有超参数可以调的,所以它不需要调参,朴素贝叶斯是根据训练集进行分类,分类出来的结果基本上就是确定了的,拉普拉斯估计器不是朴素贝叶斯中的参数,不能通过拉普拉斯估计器来对朴素贝叶斯调参。\n朴素贝叶斯中有多少种模型? 朴素贝叶斯含有3种模型,分别是高斯模型,对连续型数据进行处理;多项式模型,对离散型数据进行处理,计算数据的条件概率(使用拉普拉斯估计器进行平滑的一个模型);伯努利模型,伯努利模型的取值特征是布尔型,即出现为ture,不出现为false,在进行文档分类时,就是一个单词有没有在一个文档中出现过。\n你知道朴素贝叶斯有哪些应用吗? 知道(肯定得知道啊,不然不就白学了吗?) 朴素贝叶斯的应用最广的应该就是在文档分类、垃圾文本过滤(如垃圾邮件、垃圾信息等)、情感分析(微博、论坛上的积极、消极等情绪判别)这些方面,除此之外还有多分类实时预测、推荐系统(贝叶斯与协同过滤组合使用)、拼写矫正(当你输入一个错误单词时,可以通过文档库中出现的概率对你的输入进行矫正)等。\n你觉得朴素贝叶斯对异常值敏不敏感? 朴素贝叶斯对异常值不敏感。所以在进行数据处理时,我们可以不去除异常值,因为保留异常值可以保持朴素贝叶斯算法的整体精度,而去除异常值则可能在进行预测的过程中由于失去部分异常值导致模型的泛化能力下降。\n朴素贝叶斯是高方差还是低方差模型? 朴素贝叶斯是低方差模型。(误差 = 偏差 + 方差)对于复杂模型来说,由于复杂模型充分拟合了部分数据,使得它们的偏差变小,但由于对部分数据过分拟合,这就导致预测的方差会变大。因为朴素贝叶斯假设了各个属性之间是相互的,算是一个简单的模型。对于简单的模型来说,则恰恰相反,简单模型的偏差会更大,相对的,方差就会较小。(偏差是模型输出值与真实值的误差,也就是模型的精准度,方差是预测值与模型输出期望的的误差,即模型的稳定性,也就是数据的集中性的一个指标)\nNavie Bayes和Logistic回归区别是什么? 前者是生成式模型,后者是判别式模型,二者的区别就是生成式模型与判别式模型的区别。\n1)首先,Navie Bayes通过已知样本求得先验概率P(Y), 及条件概率P(X|Y), 对于给定的实例,计算联合概率,进而求出后验概率。也就是说,它尝试去找到底这个数据是怎么生成的(产生的),然后再进行分类。哪个类别最有可能产生这个信号,就属于那个类别。\n优点:样本容量增加时,收敛更快;隐变量存在时也可适用。\n缺点:时间长;需要样本多;浪费计算资源\n2)相比之下,Logistic回归不关心样本中类别的比例及类别下出现特征的概率,它直接给出预测模型的式子。设每个特征都有一个权重,训练样本数据更新权重w,得出最终表达式。梯度法。\n优点:直接预测往往准确率更高;简化问题;可以反应数据的分布情况,类别的差异特征;适用于较多类别的识别。\n缺点:收敛慢;不适用于有隐变量的情况。\n参考:https://mp.weixin.qq.com/s/xzJDNRv8ipJY9hTo8WXoCw\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%B4%E7%B4%A0%E8%B4%9D%E5%8F%B6%E6%96%AF/","summary":"贝叶斯准备知识 贝叶斯决策论是概率框架下实施决策的基本方法。要了解贝叶斯决策论,首先得先了解以下几个概念:先验概率、条件概率、后验概率、误判损","title":"朴素贝叶斯"},{"content":"在调整模型更新权重和偏差参数的方式时,你是否考虑过哪种优化算法能使模型产生更好且更快的效果?应该用梯度下降,随机梯度下降,还是Adam方法?\n这篇文章介绍了不同优化算法之间的主要区别,以及如何选择最佳的优化方法。\n梯度: 是多元函数对当前给定点,上升最快的方向。梯度是一组向量,所以带有方向;\n梯度下降流程: https://zhuanlan.zhihu.com/p/68468520 w, b 每轮是每个样本的权重梯度向量和偏差梯度向量的平均值;\n梯度下降本质是沿着负梯度值方向寻找损失函数Loss的最小值解 时的参数w,b , 从而得出对样本数据拟合最好的参数w,b。 https://www.jianshu.com/p/c7e642877b0e\n什么是优化算法? 优化算法的功能,是通过改善训练方式,来最小化(或最大化)损失函数E(x)。\n模型内部有些参数,是用来计算测试集中目标值Y的真实值和预测值的偏差程度的,基于这些参数,就形成了损失函数E(x)。\n比如说,权重(W)和偏差(b)就是这样的内部参数,一般用于计算输出值,在训练神经网络模型时起到主要作用。\n**在有效地训练模型并产生准确结果时,模型的内部参数起到了非常重要的作用。**这也是为什么我们应该用各种优化策略和算法,来更新和计算影响模型训练和模型输出的网络参数,使其逼近或达到最优值。\n优化算法分为两大类:\n1. 一阶优化算法\n这种算法使用各参数的梯度值来最小化或最大化损失函数E(x)。最常用的一阶优化算法是梯度下降。\n函数梯度:导数dy/dx的多变量表达式,用来表示y相对于x的瞬时变化率。往往为了计算多变量函数的导数时,会用梯度取代导数,并使用偏导数来计算梯度。梯度和导数之间的一个主要区别是函数的梯度形成了一个向量场。\n因此,对单变量函数,使用导数来分析;而梯度是基于多变量函数而产生的。更多理论细节在这里不再进行详细解释。\n2. 二阶优化算法\n二阶优化算法使用了二阶导数(也叫做Hessian方法)来最小化或最大化损失函数。由于二阶导数的计算成本很高,所以这种方法并没有广泛使用。\n详解各种神经网络优化算法 梯度下降 在训练和优化智能系统时,梯度下降是一种最重要的技术和基础。梯度下降的功能是:\n通过寻找最小值,控制方差,更新模型参数,最终使模型收敛。\n网络更新参数的公式为:θ=θ−η×∇(θ).J(θ) ,其中η是学习率,∇(θ).J(θ)是损失函数J(θ)的梯度。\n这是在神经网络中最常用的优化算法。\n如今,梯度下降主要用于在神经网络模型中进行权重更新,即在一个方向上更新和调整模型的参数,来最小化损失函数。\n2006年引入的反向传播技术,使得训练深层神经网络成为可能。反向传播技术是先在前向传播中计算输入信号的乘积及其对应的权重,然后将激活函数作用于这些乘积的总和。这种将输入信号转换为输出信号的方式,是一种对复杂非线性函数进行建模的重要手段,并引入了非线性激活函数,使得模型能够学习到几乎任意形式的函数映射。然后,在网络的反向传播过程中回传相关误差,使用梯度下降更新权重值,通过计算误差函数E相对于权重参数W的梯度,在损失函数梯度的相反方向上更新权重参数。\n**图1:**权重更新方向与梯度方向相反 图1显示了权重更新过程与梯度矢量误差的方向相反,其中U形曲线为梯度。要注意到,当权重值W太小或太大时,会存在较大的误差,需要更新和优化权重,使其转化为合适值,所以我们试图在与梯度相反的方向找到一个局部最优值。\n梯度下降的变体 传统的批量梯度下降将计算整个数据集梯度,但只会进行一次更新,因此在处理大型数据集时速度很慢且难以控制,甚至导致内存溢出。\n权重更新的快慢是由学习率η决定的,并且可以在凸面误差曲面中收敛到全局最优值,在非凸曲面中可能趋于局部最优值。\n使用标准形式的批量梯度下降还有一个问题,就是在训练大型数据集时存在冗余的权重更新。\n标准梯度下降的上述问题在随机梯度下降方法中得到了解决。\n1. 随机梯度下降(SDG)\n随机梯度下降(Stochastic gradient descent,SGD)对每个训练样本进行参数更新,每次执行都进行一次更新,且执行速度更快。\nθ=θ−η⋅∇(θ) × J(θ;x(i);y(i)),其中x(i)和y(i)为训练样本。\n频繁的更新使得参数间具有高方差,损失函数会以不同的强度波动。这实际上是一件好事,因为它有助于我们发现新的和可能更优的局部最小值,而标准梯度下降将只会收敛到某个局部最优值。\n但SGD的问题是,由于频繁的更新和波动,最终将收敛到最小限度,并会因波动频繁存在超调量。\n虽然已经表明,当缓慢降低学习率η时,标准梯度下降的收敛模式与SGD的模式相同。\n**图2:**每个训练样本中高方差的参数更新会导致损失函数大幅波动,因此我们可能无法获得给出损失函数的最小值。 另一种称为“小批量梯度下降”的变体,则可以解决高方差的参数更新和不稳定收敛的问题。\n2. 小批量梯度下降\n为了避免SGD和标准梯度下降中存在的问题,一个改进方法为小批量梯度下降(Mini Batch Gradient Descent),因为对每个批次中的n个训练样本,这种方法只执行一次更新。\n使用小批量梯度下降的优点是:\n1) 可以减少参数更新的波动,最终得到效果更好和更稳定的收敛。\n2) 还可以使用最新的深层学习库中通用的矩阵优化方法,使计算小批量数据的梯度更加高效。\n3) 通常来说,小批量样本的大小范围是从50到256,可以根据实际问题而有所不同。\n4) 在训练神经网络时,通常都会选择小批量梯度下降算法。\n这种方法有时候还是被成为SGD。\n使用梯度下降及其变体时面临的挑战 1. 很难选择出合适的学习率。太小的学习率会导致网络收敛过于缓慢,而学习率太大可能会影响收敛,并导致损失函数在最小值上波动,甚至出现梯度发散。\n2. 此外,相同的学习率并不适用于所有的参数更新。如果训练集数据很稀疏,且特征频率非常不同,则不应该将其全部更新到相同的程度,但是对于很少出现的特征,应使用更大的更新率。\n3. 在神经网络中,最小化非凸误差函数的另一个关键挑战是避免陷于多个其他局部最小值中。实际上,问题并非源于局部极小值,而是来自鞍点,即一个维度向上倾斜且另一维度向下倾斜的点。这些鞍点通常被相同误差值的平面所包围,这使得SGD算法很难脱离出来,因为梯度在所有维度上接近于零。\n进一步优化梯度下降 现在我们要讨论用于进一步优化梯度下降的各种算法。\n1. 动量\nSGD方法中的高方差振荡使得网络很难稳定收敛,所以有研究者提出了一种称为动量(Momentum)的技术,通过优化相关方向的训练和弱化无关方向的振荡,来加速SGD训练。换句话说,这种新方法将上个步骤中更新向量的分量’γ’添加到当前更新向量。\nV(t)=γV(t−1)+η∇(θ).J(θ)\n最后通过θ=θ−V(t)来更新参数。\n动量项γ通常设定为0.9,或相近的某个值。\n这里的动量与经典物理学中的动量是一致的,就像从山上投出一个球,在下落过程中收集动量,小球的速度不断增加。\n在参数更新过程中,其原理类似:\n1) 使网络能更优和更稳定的收敛;\n2) 减少振荡过程。\n当其梯度指向实际移动方向时,动量项γ增大;当梯度与实际移动方向相反时,γ减小。这种方式意味着动量项只对相关样本进行参数更新,减少了不必要的参数更新,从而得到更快且稳定的收敛,也减少了振荡过程。\n2. Nesterov梯度加速法\n一位名叫Yurii Nesterov研究员,认为动量方法存在一个问题:\n如果一个滚下山坡的球,盲目沿着斜坡下滑,这是非常不合适的。一个更聪明的球应该要注意到它将要去哪,因此在上坡再次向上倾斜时小球应该进行减速。\n实际上,当小球达到曲线上的最低点时,动量相当高。由于高动量可能会导致其完全地错过最小值,因此小球不知道何时进行减速,故继续向上移动。\nYurii Nesterov在1983年发表了一篇关于解决动量问题的论文,因此,我们把这种方法叫做Nestrov梯度加速法。\n在该方法中,他提出先根据之前的动量进行大步跳跃,然后计算梯度进行校正,从而实现参数更新。这种预更新方法能防止大幅振荡,不会错过最小值,并对参数更新更加敏感。\nNesterov梯度加速法(NAG)是一种赋予了动量项预知能力的方法,通过使用动量项γV(t−1)来更改参数θ。通过计算θ−γV(t−1),得到下一位置的参数近似值,这里的参数是一个粗略的概念。因此,我们不是通过计算当前参数θ的梯度值,而是通过相关参数的大致未来位置,来有效地预知未来:\nV(t)=γV(t−1)+η∇(θ)J( θ−γV(t−1) ),然后使用θ=θ−V(t)来更新参数。\n现在,我们通过使网络更新与误差函数的斜率相适应,并依次加速SGD,也可根据每个参数的重要性来调整和更新对应参数,以执行更大或更小的更新幅度。\n3. Adagrad方法\nAdagrad方法是通过参数来调整合适的学习率η,对稀疏参数进行大幅更新和对频繁参数进行小幅更新。因此,Adagrad方法非常适合处理稀疏数据。\n在时间步长中,Adagrad方法基于每个参数计算的过往梯度,为不同参数θ设置不同的学习率。\n先前,每个参数θ(i)使用相同的学习率,每次会对所有参数θ进行更新。在每个时间步t中,Adagrad方法为每个参数θ选取不同的学习率,更新对应参数,然后进行向量化。为了简单起见,我们把在t时刻参数θ(i)的损失函数梯度设为g(t,i)。\n**图3:**参数更新公式 Adagrad方法是在每个时间步中,根据过往已计算的参数梯度,来为每个参数θ(i)修改对应的学习率η。\nAdagrad方法的主要好处是,不需要手工来调整学习率。大多数参数使用了默认值0.01,且保持不变。\nAdagrad方法的主要缺点是,学习率η总是在降低和衰减。\n因为每个附加项都是正的,在分母中累积了多个平方梯度值,故累积的总和在训练期间保持增长。这反过来又导致学习率下降,变为很小数量级的数字,该模型完全停止学习,停止获取新的额外知识。\n因为随着学习速度的越来越小,模型的学习能力迅速降低,而且收敛速度非常慢,需要很长的训练和学习,即学习速度降低。\n另一个叫做Adadelta的算法改善了这个学习率不断衰减的问题。\n4. AdaDelta方法\n这是一个AdaGrad的延伸方法,它倾向于解决其学习率衰减的问题。Adadelta不是累积所有之前的平方梯度,而是将累积之前梯度的窗口限制到某个固定大小w。\n与之前无效地存储w先前的平方梯度不同,梯度的和被递归地定义为所有先前平方梯度的衰减平均值。作为与动量项相似的分数γ,在t时刻的滑动平均值Eg²仅仅取决于先前的平均值和当前梯度值。\nEg²=γ.Eg²+(1−γ).g²(t),其中γ设置为与动量项相近的值,约为0.9。\nΔθ(t)=−η⋅g(t,i).\nθ(t+1)=θ(t)+Δθ(t)\n**图4:**参数更新的最终公式 AdaDelta方法的另一个优点是,已经不需要设置一个默认的学习率。\n目前已完成的改进 1) 为每个参数计算出不同学习率;\n2) 也计算了动量项momentum;\n3) 防止学习率衰减或梯度消失等问题的出现。\n还可以做什么改进? 在之前的方法中计算了每个参数的对应学习率,但是为什么不计算每个参数的对应动量变化并独立存储呢?这就是Adam算法提出的改良点。\nAdam算法\nAdam算法即自适应时刻估计方法(Adaptive Moment Estimation),能计算每个参数的自适应学习率。这个方法不仅存储了AdaDelta先前平方梯度的指数衰减平均值,而且保持了先前梯度M(t)的指数衰减平均值,这一点与动量类似:\nM(t)为梯度的第一时刻平均值,V(t)为梯度的第二时刻非中心方差值。\n**图5:**两个公式分别为梯度的第一个时刻平均值和第二个时刻方差 则参数更新的最终公式为:\n**图6:**参数更新的最终公式 其中,β1设为0.9,β2设为0.9999,ϵ设为10-8。\n在实际应用中,Adam方法效果良好。与其他自适应学习率算法相比,其收敛速度更快,学习效果更为有效,而且可以纠正其他优化技术中存在的问题,如学习率消失、收敛过慢或是高方差的参数更新导致损失函数波动较大等问题。\n对优化算法进行可视化\n**图8:**对鞍点进行SGD优化 从上面的动画可以看出,自适应算法能很快收敛,并快速找到参数更新中正确的目标方向;而标准的SGD、NAG和动量项等方法收敛缓慢,且很难找到正确的方向。\n结论 我们应该使用哪种优化器?\n在构建神经网络模型时,选择出最佳的优化器,以便快速收敛并正确学习,同时调整内部参数,最大程度地最小化损失函数。\nAdam在实际应用中效果良好,超过了其他的自适应技术。\n**如果输入数据集比较稀疏,SGD、NAG和动量项等方法可能效果不好。**因此对于稀疏数据集,应该使用某种自适应学习率的方法,且另一好处为不需要人为调整学习率,使用默认参数就可能获得最优值。\n如果想使训练深层网络模型快速收敛或所构建的神经网络较为复杂,则应该使用Adam或其他自适应学习速率的方法,因为这些方法的实际效果更优。\n希望你能通过这篇文章,很好地理解不同优化算法间的特性差异。\n相关链接: 二阶优化算法: https://web.stanford.edu/class/msande311/lecture13.pdf\nNesterov梯度加速法:http://cs231n.github.io/neural-networks-3/\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%BA%E5%99%A8%E5%AD%A6%E4%B9%A0%E4%B9%8B%E4%BC%98%E5%8C%96%E7%AE%97%E6%B3%95/","summary":"在调整模型更新权重和偏差参数的方式时,你是否考虑过哪种优化算法能使模型产生更好且更快的效果?应该用梯度下降,随机梯度下降,还是Adam方法?","title":"机器学习之优化算法"},{"content":"机器学习常见距离介绍 1. 欧式距离 2. 曼哈顿距离 我们可以定义曼哈顿距离的正式意义为L1-距离或城市区块距离,也就是在欧几里得空间的固定直角坐标系上两点所形成的线段对轴产生的投影的距离总和。例如在平面上,坐标(x1, y1)的点P1与坐标(x2, y2)的点P2的曼哈顿距离为:,要注意的是,曼哈顿距离依赖座标系统的转度,而非系统在座标轴上的平移或映射。 通俗来讲,想象你在曼哈顿要从一个十字路口开车到另外一个十字路口,驾驶距离是两点间的直线距离吗?显然不是,除非你能穿越大楼。而实际驾驶距离就是这个“曼哈顿距离”,此即曼哈顿距离名称的来源, 同时,曼哈顿距离也称为城市街区距离(City Block distance)。\n3. 切比雪夫距离 若二个向量或二个点p 、and q,其座标分别为p1,p2 4. 闵可夫斯基距离(Minkowski Distance) 闵氏距离不是一种距离,而是一组距离的定义.\n(1) 闵氏距离的定义 两个n维变量a(x11,x12,…,x1n)与 b(x21,x22,…,x2n)间的闵可夫斯基距离定义为: 其中p是一个变参数。 当p=1时,就是曼哈顿距离 当p=2时,就是欧氏距离 当p→∞时,就是切比雪夫距离 根据变参数的不同,闵氏距离可以表示一类的距离。\n5. 标准化欧氏距离 (Standardized Euclidean distance ) 标准化欧氏距离是针对简单欧氏距离的缺点而作的一种改进方案。标准欧氏距离的思路:既然数据各维分量的分布不一样,那先将各个分量都“标准化”到均值、方差相等。至于均值和方差标准化到多少,先复习点统计学知识。\n假设样本集X的数学期望或均值(mean)为m,标准差(standard deviation,方差开根)为s,那么X的“标准化变量”X*表示为:(X-m)/s,而且标准化变量的数学期望为0,方差为1。\n即,样本集的标准化过程(standardization)用公式描述就是: 标准化后的值 = ( 标准化前的值 - 分量的均值 ) /分量的标准差 经过简单的推导就可以得到两个n维向量a(x11,x12,…,x1n)与 b(x21,x22,…,x2n)间的标准化欧氏距离的公式: 如果将方差的倒数看成是一个权重,这个公式可以看成是一种加权欧氏距离(Weighted Euclidean distance)。\n6. 马氏距离(Mahalanobis Distance) 有M个样本向量X1~Xm,协方差矩阵记为S,均值记为向量μ,则其中样本向量X到u的马氏距离表示为: (协方差矩阵中每个元素是各个矢量元素之间的协方差Cov(X,Y),Cov(X,Y) = E{ [X-E(X)] [Y-E(Y)]},其中E为数学期望)\n而其中向量Xi与Xj之间的马氏距离定义为:\n若协方差矩阵是单位矩阵(各个样本向量之间独立同分布),则公式就成了: 也就是欧氏距离了。 若协方差矩阵是对角矩阵,公式变成了标准化欧氏距离。\n马氏距离的优缺点:量纲无关,排除变量之间的相关性的干扰。 「微博上的seafood高清版点评道:原来马氏距离是根据协方差矩阵演变,一直被老师误导了,怪不得看Killian在05年NIPS发表的LMNN论文时候老是看到协方差矩阵和半正定,原来是这回事」 7.巴氏距离(Bhattacharyya Distance) 在统计中,Bhattacharyya距离测量两个离散或连续概率分布的相似性。它与衡量两个统计样品或种群之间的重叠量的Bhattacharyya系数密切相关。Bhattacharyya距离和Bhattacharyya系数以20世纪30年代曾在印度统计研究所工作的一个统计学家A. Bhattacharya命名。同时,Bhattacharyya系数可以被用来确定两个样本被认为相对接近的,它是用来测量中的类分类的可分离性。\n8.汉明距离(Hamming distance) 两个等长字符串s1与s2之间的汉明距离定义为将其中一个变为另外一个所需要作的最小替换次数。例如字符串“1111”与“1001”之间的汉明距离为2。应用:信息编码(为了增强容错性,应使得编码间的最小汉明距离尽可能大)。\n9.夹角余弦(Cosine) 几何中夹角余弦可用来衡量两个向量方向的差异,机器学习中借用这一概念来衡量样本向量之间的差异。 0. 杰卡德相似系数(Jaccard similarity coefficient) (1) 杰卡德相似系数\n两个集合A和B的交集元素在A,B的并集中所占的比例,称为两个集合的杰卡德相似系数,用符号J(A,B)表示。 杰卡德相似系数是衡量两个集合的相似度一种指标。\n(2) 杰卡德距离\n与杰卡德相似系数相反的概念是杰卡德距离(Jaccard distance)。 杰卡德距离可用如下公式表示: 杰卡德距离用两个集合中不同元素占所有元素的比例来衡量两个集合的区分度。\n(3) 杰卡德相似系数与杰卡德距离的应用\n可将杰卡德相似系数用在衡量样本的相似度上。 举例:样本A与样本B是两个n维向量,而且所有维度的取值都是0或1,例如:A(0111)和B(1011)。我们将样本看成是一个集合,1表示集合包含该元素,0表示集合不包含该元素。\nM11 :样本A与B都是1的维度的个数\n​M01:样本A是0,样本B是1的维度的个数\n​M10:样本A是1,样本B是0 的维度的个数\n​M00:样本A与B都是0的维度的个数\n11.皮尔逊系数(Pearson Correlation Coefficient) 在具体阐述皮尔逊相关系数之前,有必要解释下什么是相关系数 ( Correlation coefficient )与相关距离(Correlation distance)。\n相关系数 ( Correlation coefficient )的定义是:\n(其中,E为数学期望或均值,D为方差,D开根号为标准差,E{ [X-E(X)] [Y-E(Y)]}称为随机变量X与Y的协方差,记为Cov(X,Y),即Cov(X,Y) = E{ [X-E(X)] [Y-E(Y)]},而两个变量之间的协方差和标准差的商则称为随机变量X与Y的相关系数,记为Pxy) 相关系数衡量随机变量X与Y相关程度的一种方法,相关系数的取值范围是[-1,1]。相关系数的绝对值越大,则表明X与Y相关度越高。当X与Y线性相关时,相关系数取值为1(正线性相关)或-1(负线性相关)。 具体的,如果有两个变量:X、Y,最终计算出的相关系数的含义可以有如下理解:\n当相关系数为0时,X和Y两变量无关系。 当X的值增大(减小),Y值增大(减小),两个变量为正相关,相关系数在0.00与1.00之间。 当X的值增大(减小),Y值减小(增大),两个变量为负相关,相关系数在-1.00与0.00之间。 (2)皮尔逊相关系数的适用范围 当两个变量的标准差都不为零时,相关系数才有定义,皮尔逊相关系数适用于:\n两个变量之间是线性关系,都是连续数据。 两个变量的总体是正态分布,或接近正态的单峰分布。 两个变量的观测值是成对的,每对观测值之间相互独立。 (3)皮尔逊相关的约束条件\n从以上解释, 也可以理解皮尔逊相关的约束条件:\n1 两个变量间有线性关系 2 变量是连续变量 3 变量均符合正态分布,且二元分布也符合正态分布 4 两变量独立 在实践统计中,一般只输出两个系数,一个是相关系数,也就是计算出来的相关系数大小,在-1到1之间;另一个是独立样本检验系数,用来检验样本一致性\nSummary: 简单说来,各种“距离”的应用场景简单概括为,\n空间:欧氏距离,\n路径:曼哈顿距离,\n国际象棋国王:切比雪夫距离,\n以上三种的统一形式:闵可夫斯基距离,\n加权:标准化欧氏距离,\n排除量纲和依存:马氏距离,\n向量差距:夹角余弦,\n编码差别:汉明距离,\n集合近似度:杰卡德类似系数与距离,\n相关:相关系数与相关距离。\n","permalink":"https://reid00.github.io/en/posts/ml/%E5%B8%B8%E8%A7%81%E8%B7%9D%E7%A6%BB%E7%9A%84%E4%BB%8B%E7%BB%8D/","summary":"机器学习常见距离介绍 1. 欧式距离 2. 曼哈顿距离 我们可以定义曼哈顿距离的正式意义为L1-距离或城市区块距离,也就是在欧几里得空间的固定直角坐标系上","title":"常见距离的介绍"},{"content":"Summary PCA 是无监督学习中最常见的数据降维方法,但是实际上问题特征很多的情况,PCA通常会预处理来减少特征个数。\n将维的意义: 通过降维提高算法的效率 通过降维更方便数据的可视化,通过可视化我们可以更好的理解数据\n相关统计概念 均值: 述的是样本集合的中间点。 方差: 概率论和统计方差衡量随机变量或一组数据时离散程度的度量。 标准差:而标准差给我们描述的是样本集合的各个样本点到均值的距离之平均。方差开根号。 标准差和方差一般是用来描述一维数据的 协方差: (多维)度量两个随机变量关系的统计量,来度量各个维度偏离其均值的程度。 协方差矩阵: (多维)度量各个维度偏离其均值的程度 当 cov(X, Y)\u0026gt;0时,表明X与Y正相关(X越大,Y也越大;X越小Y,也越小。) 当 cov(X, Y)\u0026lt;0时,表明X与Y负相关; 当 cov(X, Y)=0时,表明X与Y不相关。 cov协方差=[(x1-x均值)(y1-y均值)+(x2-x均值)(y2-y均值)+\u0026hellip;+(xn-x均值)*(yn-y均值)]/(n-1) PCA 思想 对数据进行归一化处理(代码中并非这么做的,而是直接减去均值) 计算归一化后的数据集的协方差矩阵 计算协方差矩阵的特征值和特征向量 将特征值排序 保留前N个最大的特征值对应的特征向量 将数据转换到上面得到的N个特征向量构建的新空间中(实现了特征压缩) 简述主成分分析PCA工作原理,以及PCA的优缺点? PCA旨在找到数据中的主成分,并利用这些主成分表征原始数据,从而达到降维的目的。\n​ 工作原理可由两个角度解释,第一个是最大化投影方差(让数据在主轴上投影的方差尽可能大);第二个是最小化平方误差(样本点到超平面的垂直距离足够近)。\n​ 做法是数据中心化之后,对样本数据协方差矩阵进行特征分解,选取前d个最大的特征值对应的特征向量,即可将数据从原来的p维降到d维,也可根据奇异值分解来求解主成分。\n优点: 1.计算简单,易于实现\n2.各主成分之间正交,可消除原始数据成分间的相互影响的因素\n3.仅仅需要以方差衡量信息量,不受数据集以外的因素影响\n4.降维维数木有限制,可根据需要制定\n缺点: 1.无法利用类别的先验信息\n2.降维后,只与数据有关,主成分各个维度的含义模糊,不易于解释\n3.方差小的非主成分也可能含有对样本差异的重要信息,因降维丢弃可能对后续数据处理有影响\n4.线性模型,对于复杂数据集难以处理(可用核映射方式改进)\nPCA中有第一主成分、第二主成分,它们分别是什么,又是如何确定的? 主成分分析是设法将原来众多具有一定相关性(比如P个指标),重新组合成一组新的互相无关的综合指标来代替原来的指标。主成分分析,是考察多个变量间相关性一种多元统计方法,研究如何通过少数几个主成分来揭示多个变量间的内部结构,即从原始变量中导出少数几个主成分,使它们尽可能多地保留原始变量的信息,且彼此间互不相关,通常数学上的处理就是将原来P个指标作线性组合,作为新的综合指标。\n​ 最经典的做法就是用F1(选取的第一个线性组合,即第一个综合指标)的方差来表达,即Var(F1)越大,表示F1包含的信息越多。因此在所有的线性组合中选取的F1应该是方差最大的,故称F1为第一主成分。如果第一主成分不足以代表原来P个指标的信息,再考虑选取F2即选第二个线性组合,为了有效地反映原来信息,F1已有的信息就不需要再出现在F2中,用数学语言表达就是要求Cov(F1, F2)=0,则称F2为第二主成分,依此类推可以构造出第三、第四,……,第P个主成分。\nLDA与PCA都是常用的降维方法,二者的区别 它其实是对数据在高维空间下的一个投影转换,通过一定的投影规则将原来从一个角度看到的多个维度映射成较少的维度。到底什么是映射,下面的图就可以很好地解释这个问题——正常角度看是两个半椭圆形分布的数据集,但经过旋转(映射)之后是两条线性分布数据集。\nLDA与PCA都是常用的降维方法,二者的区别在于:\n**出发思想不同。**PCA主要是从特征的协方差角度,去找到比较好的投影方式,即选择样本点投影具有最大方差的方向( 在信号处理中认为信号具有较大的方差,噪声有较小的方差,信噪比就是信号与噪声的方差比,越大越好。);而LDA则更多的是考虑了分类标签信息,寻求投影后不同类别之间数据点距离更大化以及同一类别数据点距离最小化,即选择分类性能最好的方向。\n**学习模式不同。**PCA属于无监督式学习,因此大多场景下只作为数据处理过程的一部分,需要与其他算法结合使用,例如将PCA与聚类、判别分析、回归分析等组合使用;LDA是一种监督式学习方法,本身除了可以降维外,还可以进行预测应用,因此既可以组合其他模型一起使用,也可以独立使用。\n**降维后可用维度数量不同。**LDA降维后最多可生成C-1维子空间(分类标签数-1),因此LDA与原始维度N数量无关,只有数据标签分类数量有关;而PCA最多有n维度可用,即最大可以选择全部可用维度。\n线性判别分析LDA算法由于其简单有效性在多个领域都得到了广泛地应用,是目前机器学习、数据挖掘领域经典且热门的一个算法;但是算法本身仍然存在一些局限性:\n当样本数量远小于样本的特征维数,样本与样本之间的距离变大使得距离度量失效,使LDA算法中的类内、类间离散度矩阵奇异,不能得到最优的投影方向,在人脸识别领域中表现得尤为突出\nLDA不适合对非高斯分布的样本进行降维\nLDA在样本分类信息依赖方差而不是均值时,效果不好\nLDA可能过度拟合数据\n主成分分析 PCA 详解 原理及对应操作 主成分分析顾名思义是对主成分进行分析,那么找出主成分应该是key点。PCA的基本思想就是将初始数据集中的n维特征映射至k维上,得到的k维特征就可以被称作主成分,k维不是在n维中挑选出来的,而是以n维特征为基础重构出来的。\nPCA会在已知数据的基础上重构坐标轴,它的原理是要最大化保留样本间的方差,两个特征之间方差越大不就代表相关性越差嘛。比如:\n第一个新坐标轴就是原始数据中方差最大的方向。 第二个新坐标轴要是与第一个新坐标轴正交的平面中方差最大的方向。 第三个新坐标轴要是与第一、第二新坐标轴正交的平面中方差最大的方向。 第四、第五\u0026hellip;依次类推直到第n个新坐标轴(对应n维)。 为了加深这部分理解,以二维平面先举一个例子,二维平面中依据原始数据新建坐标轴如下图:\n为了更直观的理解,若将方差换一个说法,那么第一个新坐标轴就是覆盖样本最多的一条(斜向右上),第二个新坐标轴需要与第一新坐标轴正交且覆盖样本最多(斜向左上),依次类推。\n覆盖的样本多少并不是以坐标轴穿过多少样本点评判的,而是通过样本点垂直映射至该轴的个数有多少,具体如下图:\n回到之前的n维重构坐标轴,由于顺序是依据方差的大小依次排序的,所以越到后面方差越小,而方差越小代表特征之间相关性越强,那么这类特征就可以删去,只保留前k个坐标轴(对应k维),这就相当于保留了含有数据集绝大部分方差的特征,而除去方差几乎为0的特征。\n那么问题来了,二维、三维可以根据样本点的分布画出重构坐标轴,但是更高维人的大脑是不接受的,我们不得不通过计算的方式求得特征之间的方差,进而得到这些新坐标轴的的方向。\n具体方法就是通过计算原始数据矩阵对应的协方差矩阵,然后可以得到协方差矩阵对应的特征值和特征向量,选取特征值最大的前k个特征向量组成的矩阵,通过特征矩阵就可以将原始数据矩阵从n维空间映射至k维空间,从而实现特征降维。\n方差、协方差及协方差矩阵 如果你曾经接触过线性代数可能对这三个概念很熟悉,可能间隔时间太久有些模糊,下面再帮大家温习一下:\n方差(Variance)一般用来描述样本集合中的各个样本点到样本均值的差的平方和的均值:\n协方差(Covariance)目的是度量两个变量(只能为两个)线性相关的程度:\n为可以说明两个变量线性无关,但不能证明两个变量相互独立,当时,二者呈正相关,时,二者呈负相关。\n协方差矩阵就是由两两变量之间的协方差组成,有以下特点:\n协方差矩阵可以处理多维度问题。 协方差矩阵是一个对称的矩阵,而且对角线是各个维度上的方差。 协方差矩阵计算的是不同维度之间的协方差,而不是不同样本之间的。 样本矩阵中若每行是一个样本,则每列为一个维度。 假设数据是3维的,那么对应协方差矩阵为:\n这里简要概括一下协方差矩阵是怎么求得的,假设一个数据集有3维特征、每个特征有m个变量,这个数据集对应的数据矩阵如下:\n若假设他们的均值都为0,可以得到下面等式:\n可以看到对角线上为每个特征方差,其余位置为两个特征之间的协方差,求得的就为协方差矩阵。\n这里叙述的有些简略,感兴趣的小伙伴可以自行查询相关知识。\n特征值和特征向量 得到了数据矩阵的协方差矩阵,下面就应该求协方差矩阵的特征值和特征向量,先了解一下这两个概念,如果一个向量v是矩阵A的特征向量,那么一定存在下列等式:\n其中A可视为数据矩阵对应的协方差矩阵,是特征向量v的特征值。数据矩阵的主成分就是由其对应的协方差矩阵的特征向量,按照对应的特征值由大到小排序得到的。最大的特征值对应的特征向量就为第一主成分,第二大的特征值对应的特征向量就为第二主成分,依次类推,如果由n维映射至k维就截取至第k主成分。\n实例操作 通过上述部分总结一下PCA降维操作的步骤:\n去均值化 依据数据矩阵计算协方差矩阵 计算协方差矩阵的特征值和特征向量 将特征值从大到小排序 保留前k个特征值对应的特征向量 将原始数据的n维映射至k维中 公式手推降维 原始数据集矩阵,每行代表一个特征:\n对每个特征去均值化:\n计算对应的协方差矩阵:\n依据协方差矩阵计算特征值和特征向量,套入公式:\n拆开计算如下:\n可以求得两特征值:\n,\n当时,对应向量应该满足如下等式:\n对应的特征向量可取为:\n同理当时,对应特征向量可取为:\n这里我就不对两个特征向量进行标准化处理了,直接合并两个特征向量可以得到矩阵P:\n选取大的特征值对应的特征向量乘以原数据矩阵后可得到降维后的矩阵A:\n综上步骤就是通过PCA手推公式实现二维降为一维的操作。\nnumpy实现降维 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 import numpy as np def PCA(X,k): \u0026#39;\u0026#39;\u0026#39; X:输入矩阵 k:需要降低到的维数 \u0026#39;\u0026#39;\u0026#39; X = np.array(X) #转为矩阵 sample_num ,feature_num = X.shape #样本数和特征数 meanVals = np.mean(X,axis = 0) #求每个特征的均值 X_mean = X-meanVals #去均值化 Cov = np.dot(X_mean.T,X_mean)/sample_num #求协方差矩阵 feature_val,feature_vec = np.linalg.eig(Cov) #将特征值和特征向量打包 val_sort = [(np.abs(feature_val[i]),feature_vec[:,i]) for i in range(feature_num)] val_sort.sort(reverse=True) #按特征值由大到小排列 #截取至前k个特征向量组成特征矩阵 feature_mat = [feature[1] for feature in val_sort[:k]] # 降n维映射至k维 PCA_mat = np.dot(X_mean,np.array(feature_mat).T) return PCA_mat if __name__ == \u0026#34;__main__\u0026#34;: X = [[1, 1], [1, 3], [2, 3], [4, 4], [2, 4]] print(PCA(X,1)) 运行截图如下:\n代码部分就是公式的套用,每一步后都有注释,不再过多解释。可以看到得到的结果和上面手推公式得到的有些出入,上文曾提过特征向量是可以随意缩放的,这也是导致两个结果不同的原因,可以在运行代码时打印一下特征向量feature_vec,观察一下这个特点。\nsklearn库实现降维 1 2 3 4 5 6 7 8 from sklearn.decomposition import PCA import numpy as np X = [[1, 1], [1, 3], [2, 3], [4, 4], [2, 4]] X = np.array(X) pca = PCA(n_components=1) PCA_mat = pca.fit_transform(X) print(PCA_mat) 这里只说一下参数n_components,如果输入的是整数,代表数据集需要映射的维数,比如输入3代表最后要映射至3维;如果输入的是小数,则代表映射的维数为原数据维数的占比,比如输入0.3,如果原数据20维,就将其映射至6维。\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%95%B0%E6%8D%AE%E9%99%8D%E7%BB%B4%E4%B9%8B%E4%B8%BB%E6%88%90%E5%88%86%E5%88%86%E6%9E%90-pca/","summary":"Summary PCA 是无监督学习中最常见的数据降维方法,但是实际上问题特征很多的情况,PCA通常会预处理来减少特征个数。 将维的意义: 通过降维提高算法的效率 通","title":"数据降维之主成分分析 PCA"},{"content":"问题目录: 1、决策树的实现、ID3、C4.5、CART(贝壳) 2、CART回归树是怎么实现的?(贝壳) 3、CART分类树和ID3以及C4.5有什么区别(贝壳) 4、剪枝有哪几种方式(贝壳) 5、树集成模型有哪几种实现方式?(贝壳)boosting和bagging的区别是什么?(知乎、阿里) 6、随机森林的随机体现在哪些方面(贝壳、阿里) 7、AdaBoost是如何改变样本权重,GBDT分类树的基模型是?(贝壳) 8、gbdt,xgboost,lgbm的区别(百度、滴滴、阿里,头条) 9、bagging为什么能减小方差?(知乎)\n其他问题: 10、关于AUC的另一种解释:是挑选一个正样本和一个负样本,正样本排在负样本前面的概率?如何理解? 11、校招是集中时间刷题好,还是每天刷一点好呢? 12、现在推荐在工业界基本都用match+ranking的架构,但是学术界论文中的大多算法算是没有区分吗?end-to-end的方式,还是算是召回? 13、内推刷简历严重么?没有实习经历,也没有牛逼的竞赛和论文,提前批有面试机会么?提前批影响正式批么? 14、除了自己项目中的模型了解清楚,还需要准备哪些?看了群主的面经大概知道了一些,能否大致描述下?\n1、决策树的实现、ID3、C4.5、CART(贝壳) 这道题主要是要求把公式写一下,所以决策树的公式大家要理解,并且能熟练地写出来。这里咱们简单回顾一下吧。主要参考统计学习方法就好了。\nID3使用信息增益来指导树的分裂: C4.5通过信息增益比来指导树的分裂: CART的话既可以是分类树,也可以是回归树。当是分类树时,使用基尼系数来指导树的分裂: 当是回归树时,则使用的是平方损失最小: 2、CART回归树是怎么实现的?(贝壳) CART回归树的实现包含两个步骤: 1)决策树生成:基于训练数据生成决策树、生成的决策树要尽量大 2)决策树剪枝:用验证数据集对已生成的树进行剪枝并选择最优子树,这时用损失函数最小作为剪枝的标准。\n这部分的知识,可以看一下《统计学习方法》一书。\n3、CART分类树和ID3以及C4.5有什么区别(贝壳) 1)首先是决策规则的区别,CART分类树使用基尼系数、ID3使用的是信息增益,而C4.5使用的是信息增益比。 2)ID3和C4.5可以是多叉树,但是CART分类树只能是二叉树(这是我当时主要回答的点)\n4、剪枝有哪几种方式(贝壳) 前剪枝和后剪枝,参考周志华《机器学习》。\n5、树集成模型有哪几种实现方式?(贝壳)boosting和bagging的区别是什么?(知乎、阿里) 树集成模型主要有两种实现方式,分别是Bagging和Boosting。二者的区别主要有以下四点: 1)样本选择上: Bagging:训练集是在原始集中有放回选取的,从原始集中选出的各轮训练集之间是独立的. Boosting:每一轮的训练集不变,只是训练集中每个样例在分类器中的权重发生变化.而权值是根据上一轮的分类结果进行调整. 2)样例权重: Bagging:使用均匀取样,每个样例的权重相等 Boosting:根据错误率不断调整样例的权值,错误率越大则权重越大. 3)预测函数: Bagging:所有预测函数的权重相等. Boosting:每个弱分类器都有相应的权重,对于分类误差小的分类器会有更大的权重. 4)并行计算: Bagging:各个预测函数可以并行生成 Boosting:各个预测函数只能顺序生成,因为后一个模型参数需要前一轮模型的结果.\n6、随机森林的随机体现在哪些方面(贝壳、阿里) 随机森林的随机主要体现在两个方面:一个是建立每棵树时所选择的特征是随机选择的;二是生成每棵树的样本也是通过有放回抽样产生的。\n7、AdaBoost是如何改变样本权重,GBDT分类树的基模型是?(贝壳) AdaBoost改变样本权重:增加分类错误的样本的权重,减小分类正确的样本的权重。\n最后一个问题是我在面试之前没有了解到的,GBDT无论做分类还是回归问题,使用的都是CART回归树。\n8、gbdt,xgboost,lgbm的区别(百度、滴滴、阿里,头条) 首先来看GBDT和Xgboost,二者的区别如下:\n1)传统 GBDT 以 CART 作为基分类器,xgboost 还支持线性分类器,这个时候 xgboost 相当于带 L1 和 L2 正则化项的逻辑斯蒂回归(分类问题)或者线性回归(回归问题)。 2)传统 GBDT 在优化时只用到一阶导数信息,xgboost 则对代价函数进行了二阶泰勒展开,同时用到了一阶和二阶导数。顺便 一下,xgboost 工具支持自定义代价函数,只要函数可一阶和二阶求导。 3)xgboost 在代价函数里加入了正则项,用于控制模型的复杂度。正则项里包含了树的叶子节点个数、每个叶子节点上输出的 score 的 L2 模的平方和。从 Bias-variance tradeoff 角度来讲,正则项降低了模型的variance,使学习出来的模型更加简单,防止过拟合,这也是 xgboost 优于传统GBDT 的一个特性。 4)Shrinkage(缩减),相当于学习速率(xgboost 中的eta)。xgboost 在进行完一次迭代后,会将叶子节点的权重乘上该系数,主要是为了削 弱每棵树的影响,让后面有更大的学习空间。实际应用中,一般把 eta 设置得小一点,然后迭代次数设置得大一点。(补充:传统 GBDT 的实现 也有学习速率) 5)列抽样(column subsampling)。xgboost 借鉴了随机森林的做法,支 持列抽样,不仅能降低过拟合,还能减少计算,这也是 xgboost 异于传 统 gbdt 的一个特性。 6)对缺失值的处理。对于特征的值有缺失的样本,xgboost 可以自动学习 出它的分裂方向。 7)xgboost 工具支持并行。boosting 不是一种串行的结构吗?怎么并行的? 注意 xgboost 的并行不是 tree 粒度的并行,xgboost 也是一次迭代完才能进行下一次迭代的(第 t 次迭代的代价函数里包含了前面 t-1 次迭代 的预测值)。xgboost 的并行是在特征粒度上的。我们知道,决策树的学习最耗时的一个步骤就是对特征的值进行排序(因为要确定最佳分割点),xgboost在训练之前,预先对数据进行了排序,然后保存为 block 结构,后面的迭代中重复地使用这个结构,大大减小计算量。这个block结构也使得并行成为了可能,在进行节点的分裂时,需要计算每 个特征的增益,最终选增益最大的那个特征去做分裂,那么各个特征的增益计算就可以开多线程进行。 8)可并行的近似直方图算法。树节点在进行分裂时,我们需要计算每个特征的每个分割点对应的增益,即用贪心法枚举所有可能的分割点。当数据无法一次载入内存或者在分布式情况下,贪心算法效率就会变得很低,所以 xgboost 还 出了一种可并行的近似直方图算法,用于高效地生成候选的分割点。\n再来看Xgboost和LightGBM,二者的区别如下:\n1)由于在决策树在每一次选择节点特征的过程中,要遍历所有的属性的所有取 值并选择一个较好的。XGBoost 使用的是近似算法算法,先对特征值进行排序 Pre-sort,然后根据二阶梯度进行分桶,能够更精确的找到数据分隔点;但是 复杂度较高。LightGBM 使用的是 histogram 算法,这种只需要将数据分割成不同的段即可,不需要进行预先的排序。占用的内存更低,数据分隔的复杂度更低。 2)决策树生长策略,我们刚才介绍过了,XGBoost采用的是 Level-wise 的树 生长策略,LightGBM 采用的是 leaf-wise 的生长策略。 3)并行策略对比,XGBoost 的并行主要集中在特征并行上,而 LightGBM 的并 行策略分特征并行,数据并行以及投票并行。\n9、bagging为什么能减小方差?(知乎) 这个当时也没有答上来,可以参考一下博客:https://blog.csdn.net/shenxiaoming77/article/details/53894973\n树模型相关的题目以上就差不多了。接下来整理一些最近群友提出的问题,我觉得有一些可能作为面试题,有一些是准备校招过程中的经验:\n10、关于AUC的另一种解释:是挑选一个正样本和一个负样本,正样本排在负样本前面的概率?如何理解? 我们都知道AUC是ROC曲线下方的面积,ROC曲线的横轴是真正例率,纵轴是假正例率。我们可以按照如下的方式理解一下:首先偷换一下概念,意思还是一样的,任意给定一个负样本,所有正样本的score中有多大比例是大于该负类样本的score?那么对每个负样本来说,有多少的正样本的score比它的score大呢?是不是就是当结果按照score排序,阈值恰好为该负样本score时的真正例率TPR?理解到这一层,二者等价的关系也就豁然开朗了。ROC曲线下的面积或者说AUC的值 与 测试任意给一个正类样本和一个负类样本,正类样本的score有多大的概率大于负类样本的score是等价的。\n11、校招是集中时间刷题好,还是每天刷一点好呢? 我的建议是平时每天刷3~5道,然后临近校招的时候集中刷。另外就是根据每次面试中被问到的问题,如果有没答上来的,就针对这一类的题型多刷刷。\n12、现在推荐在工业界基本都用match+ranking的架构,但是学术界论文中的大多算法算是没有区分吗?end-to-end的方式,还是算是召回? 学术界论文往往不针对整个推荐系统,而只针对match或者ranking阶段的某一种方法进行研究。比如DeepFM、Wide \u0026amp; Deep,只针对ranking阶段。而阿里有几篇介绍embedding的论文,只介绍match阶段的方法。end-to-end的方式,在match和ranking阶段都有吧。\n","permalink":"https://reid00.github.io/en/posts/ml/%E6%9C%80%E5%B8%B8%E8%80%83%E7%9A%84%E6%A0%91%E6%A8%A1%E5%9E%8B%E9%97%AE%E9%A2%98/","summary":"问题目录: 1、决策树的实现、ID3、C4.5、CART(贝壳) 2、CART回归树是怎么实现的?(贝壳) 3、CART分类树和ID3以及C4.5","title":"最常考的树模型问题"},{"content":"简述决策树原理? 决策树是一种自上而下,对样本数据进行树形分类的过程,由节点和有向边组成。节点分为内部节点和叶节点,其中每个内部节点表示一个特征或属性,叶节点表示类别。从顶部节点开始,所有样本聚在一起,经过根节点的划分,样本被分到不同的子节点中,再根据子节点的特征进一步划分,直至所有样本都被归到某个类别。\n为什么要对决策树进行减枝?如何进行减枝? 剪枝是决策树解决过拟合问题的方法。在决策树学习过程中,为了尽可能正确分类训练样本,结点划分过程将不断重复,有时会造成决策树分支过多,于是可能将训练样本学得太好,以至于把训练集自身的一些特点当作所有数据共有的一般特点而导致测试集预测效果不好,出现了过拟合现象。因此,可以通过剪枝来去掉一些分支来降低过拟合的风险。\n决策树剪枝的基本策略有“预剪枝”和“后剪枝”。预剪枝是指在决策树生成过程中,对每个结点在划分前先进行估计,若当前结点的划分不能带来决策树泛化性能提升,则停止划分并将当前结点标记为叶结点;后剪枝则是先从训练集生成一棵完整的决策树,然后自底向上地对非叶结点进行考察,若将该结点对应的子树替换为叶结点能带来决策树泛化性能提升,则将该子树替换为叶结点。\n预剪枝使得决策树的很多分支都没有\u0026quot;展开”,这不仅降低了过拟合的风险,还显著减少了决策树的训练时间开销和测试时间开销。但另一方面,有些分支的当前划分虽不能提升泛化性能、甚至可能导致泛化性能暂时下降?但在其基础上进行的后续划分却有可能导致性能显著提高;预剪枝基于\u0026quot;贪心\u0026quot;本质禁止这些分支展开,给预剪枝决策树带来了欠拟含的风险。\n后剪枝决策树通常比预剪枝决策树保留了更多的分支,一般情形下后剪枝决策树的欠拟合风险很小,泛化性能往往优于预剪枝决策树 。但后剪枝过程是在生成完全决策树之后进行的 并且要白底向上对树中的所有非叶结点进行逐 考察,因此其训练时间开销比未剪枝决策树和预剪枝决策树都要大得多。\n简述决策树的生成策略? 决策树主要有ID3、C4.5、CART,算法的适用略有不同,但它们有个总原则,即在选择特征、向下分裂、树生成中,它们都是为了让信息更“纯”。\n举一个简单例子,通过三个特征:是否有喉结、身高、体重,判断人群中的男女,是否有喉结把人群分为两部分,一边全是男性、一边全是女性,达到理想结果,纯度最高。 通过身高或体重,人群会有男有女。 上述三种算法,信息增益、增益率、基尼系数对“纯”的不同解读。如下详细阐述:\n​ 综上,ID3采用信息增益作为划分依据,会倾向于取值较多的特征,因为信息增益反映的是给定条件以后不确定性减少的程度,特征取值越多就意味着不确定性更高。C4.5对ID3进行优化,通过引入信息增益率,对特征取值较多的属性进行惩罚。\n随机森林 Bagging(套袋法) bagging的算法过程如下:\n从原始样本集中使用Bootstraping方法随机抽取n个训练样本,共进行k轮抽取,得到k个训练集。(k个训练集之间相互独立,元素可以有重复) 对于k个训练集,我们训练k个模型(这k个模型可以根据具体问题而定,比如决策树,knn等) 对于分类问题:由投票表决产生分类结果;对于回归问题:由k个模型预测结果的均值作为最后预测结果。(所有模型的重要性相同) Boosting(提升法) boosting的算法过程如下:\n对于训练集中的每个样本建立权值wi,表示对每个样本的关注度。当某个样本被误分类的概率很高时,需要加大对该样本的权值。 进行迭代的过程中,每一步迭代都是一个弱分类器。我们需要用某种策略将其组合,作为最终模型。(例如AdaBoost给每个弱分类器一个权值,将其线性组合最为最终分类器。误差越小的弱分类器,权值越大) 提升就是指每一步我都产生一个弱预测模型,然后加权累加到总模型中,然后每一步弱预测模型生成的的依据都是损失函数的负梯度方向,这样若干步以后就可以达到逼近损失函数局部最小值的目标。 Bagging,Boosting的主要区别 样本选择上:Bagging采用的是Bootstrap随机有放回抽样;而Boosting每一轮的训练集是不变的,改变的只是每一个样本的权重。\n每轮训练过后如何调整样本权重 ?\n如何确定最后各学习器的权重 这两个问题可由加法模型和指数损失函数推导出来。\n样本权重:Bagging使用的是均匀取样,每个样本权重相等;Boosting根据错误率调整样本权重,错误率越大的样本权重越大。\n预测函数:Bagging所有的预测函数的权重相等;Boosting中误差越小的预测函数其权重越大。\n并行计算:Bagging各个预测函数可以并行生成;Boosting各个预测函数必须按顺序迭代生成。\n下面是将决策树与这些算法框架进行结合所得到的新的算法: 1)Bagging + 决策树 = 随机森林 2)AdaBoost + 决策树 = 提升树 (自适应提升(AdaBoost)) 3)Gradient Boosting + 决策树 = GBDT 梯度下降提升树(GDBT)\n首先既然是树,那么它的基函数肯定就是决策树啦,而损失函数则是根据我们具体的问题去分析,但方法都一样,最终都走上了梯度下降的老路,比如说进行到第m步的时候,首先计算残差\n有了残差之后,我们再用(xi,rim)去拟合第m个基函数,假设这棵树把输入空间划分成j个空间R1m,R2m……,Rjm,假设它在每个空间上的输出为bjm,这样的话,第m棵树可以表示如下:\n下一步,对树的每个区域分别用线性搜索的方式寻找最佳步长,这个步长可以和上面的区域预测值bjm进行合并,最后就得到了第m步的目标函数\n当然了,对于GDBT比较容易出现过拟合的情况,所以有必要增加一点正则项,比如叶节点的数目或叶节点预测值的平方和,进而限制模型复杂度的过度提升,这里在下面的实践中的参数设置我们可以继续讨论。\n构造随机森林的 4 个步骤: 假如有N个样本,则有放回的随机选择N个样本(每次随机选择一个样本,然后返回继续选择)。这选择好了的N个样本用来训练一个决策树,作为决策树根节点处的样本。\n当每个样本有M个属性时,在决策树的每个节点需要分裂时,随机从这M个属性中选取出m个属性,满足条件m \u0026laquo; M。然后从这m个属性中采用某种策略(比如说信息增益)来选择1个属性作为该节点的分裂属性。\n策树形成过程中每个节点都要按照步骤2来分裂(很容易理解,如果下一次该节点选出来的那一个属性是刚刚其父节点分裂时用过的属性,则该节点已经达到了叶子节点,无须继续分裂了)。一直到不能够再分裂为止。注意整个决策树形成过程中没有进行剪枝。\n按照步骤1~3建立大量的决策树,这样就构成了随机森林了。\n随机森林的优缺点 优点 它可以出来很高维度(特征很多)的数据,并且不用降维,无需做特征选择 它可以判断特征的重要程度 可以判断出不同特征之间的相互影响 不容易过拟合 训练速度比较快,容易做成并行方法 实现起来比较简单 对于不平衡的数据集来说,它可以平衡误差。 如果有很大一部分的特征遗失,仍可以维持准确度。 缺点 随机森林已经被证明在某些噪音较大的分类或回归问题上会过拟合。 对于有不同取值的属性的数据,取值划分较多的属性会对随机森林产生更大的影响,所以随机森林在这种数据上产出的属性权值是不可信的 ","permalink":"https://reid00.github.io/en/posts/ml/%E5%86%B3%E7%AD%96%E6%A0%91%E5%88%B0%E9%9A%8F%E6%9C%BA%E6%A3%AE%E6%9E%97/","summary":"简述决策树原理? 决策树是一种自上而下,对样本数据进行树形分类的过程,由节点和有向边组成。节点分为内部节点和叶节点,其中每个内部节点表示一个特","title":"决策树到随机森林"},{"content":"Summary “所有模型都是坏的,但有些模型是有用的”。我们建立模型之后,接下来就要去评估模型,确定这个模型是否‘有用’。当你费尽全力去建立完模型后,你会发现仅仅就是一些单个的数值或单个的曲线去告诉你你的模型到底是否能够派上用场。\n​ 在实际情况中,我们会用不同的度量去评估我们的模型,而度量的选择,完全取决于模型的类型和模型以后要做的事。下面我们就会学习到一些用于评价模型的常用度量和图表以及它们各自的使用场景。\n模型评估这部分会介绍以下几方面的内容:\n性能度量 模型评估方法 泛化能力 过拟合、欠拟合 超参数调优 本文会首先介绍性能度量方面的内容,主要是分类问题和回归问题的性能指标,包括以下几个方法的介绍:\n准确率和错误率 精确率、召回率以及 F1 ROC 曲线 和 AUC 代价矩阵 回归问题的性能度量 其他评价指标,如计算速度、鲁棒性等 1. 性能度量 性能度量就是指对模型泛化能力衡量的评价标准。\n1.1 准确率和错误率 分类问题中最常用的两个性能度量标准\u0026ndash; 准确率和错误率。\n准确率: 指的是分类正确的样本数量占样本总数的比例,定义如下:\n错误率:指分类错误的样本占样本总数的比例,定义如下:\n错误率也是损失函数为 0-1 损失时的误差。\n这两种评价标准是分类问题中最简单也是最直观的评价指标。但它们都存在一个问题,在类别不平衡的情况下,它们都无法有效评价模型的泛化能力。即如果此时有 99% 的负样本,那么模型预测所有样本都是负样本的时候,可以得到 99% 的准确率。\n这种情况就是在类别不平衡的时候,占比大的类别往往成为影响准确率的最主要因素!\n这种时候,其中一种解决方法就是更换评价指标,比如采用更为有效的平均准确率(每个类别的样本准确率的算术平均),即:\n其中 m 是类别的数量。\n对于准确率和错误率,用 Python 代码实现如下图所示:\n1 2 3 4 5 6 def accuracy(y_true,y_pred): return sum(y==y_p for y,y_p in zip(y_true,y_pred))/len(y_true def error(y_true, y_pred): return sum(y != y_p for y, y_p in zip(y_true, y_pred)) / len(y_true) 一个简单的二分类测试样例:\n1 2 3 4 5 6 7 y_true = [1, 0, 1, 0, 1] y_pred = [0, 0, 1, 1, 0] acc = accuracy(y_true, y_pred) err = error(y_true, y_pred) print(\u0026#39;accuracy=\u0026#39;, acc) print(\u0026#39;error=\u0026#39;, err) 输出结果如下:\n1 2 accuracy= 0.4 error= 0.6 1.2 精确率、召回率、P-R 曲线和 F1 精确率,也被称作查准率,是指所有预测为正类的结果中,真正的正类的比例。公式如下:\n召回率,也被称作查全率,是指所有正类中,被分类器找出来的比例。公式如下:\n对于上述两个公式的符号定义,是在二分类问题中,我们将关注的类别作为正类,其他类别作为负类别,因此,定义:\nTP(True Positive):真正正类的数量,即分类为正类,实际也是正类的样本数量; FP(False Positive):假正类的数量,即分类为正类,但实际是负类的样本数量; FN(False Negative):假负类的数量,即分类为负类,但实际是正类的样本数量; TN(True Negative):真负类的数量,即分类是负类,实际也负类的样本数量。 更形象的说明,可以参考下表,也是混淆矩阵的定义:\n预测:正类 预测:负类 实际:正类 TP FN 实际:负类 FP TN 精确率和召回率是一对矛盾的度量,通常精确率高时,召回率往往会比较低;而召回率高时,精确率则会比较低,原因如下:\n精确率越高,代表预测为正类的比例更高,而要做到这点,通常就是只选择有把握的样本。最简单的就是只挑选最有把握的一个样本,此时 FP=0,P=1,但 FN 必然非常大(没把握的都判定为负类),召回率就非常低了; 召回率要高,就是需要找到所有正类出来,要做到这点,最简单的就是所有类别都判定为正类,那么 FN=0 ,但 FP 也很大,所有精确率就很低了。 而且不同的问题,侧重的评价指标也不同,比如:\n对于推荐系统,侧重的是精确率。也就是希望推荐的结果都是用户感兴趣的结果,即用户感兴趣的信息比例要高,因为通常给用户展示的窗口有限,一般只能展示 5 个,或者 10 个,所以更要求推荐给用户真正感兴趣的信息; 对于医学诊断系统,侧重的是召回率。即希望不漏检任何疾病患者,如果漏检了,就可能耽搁患者治疗,导致病情恶化。 精确率和召回率的代码简单实现如下,这是基于二分类的情况\n1 2 3 4 5 6 7 8 def precision(y_true, y_pred): true_positive = sum(y and y_p for y, y_p in zip(y_true, y_pred)) predicted_positive = sum(y_pred) return true_positive / predicted_positive def recall(y_true, y_pred): true_positive = sum(y and y_p for y, y_p in zip(y_true, y_pred)) real_positive = sum(y_true) return true_positive / real_positive 结果\n1 2 3 4 5 6 7 8 y_true = [1, 0, 1, 0, 1] y_pred = [0, 0, 1, 1, 0] precisions = precision(y_true, y_pred) recalls = recall(y_true, y_pred) print(\u0026#39;precisions=\u0026#39;, precisions) # 输出为0.5 print(\u0026#39;recalls=\u0026#39;, recalls) # 输出为 0.3333 1.2.2 P-R 曲线和 F1 预测结果其实就是分类器对样本判断为某个类别的置信度,我们可以选择不同的阈值来调整分类器对某个样本的输出结果,比如设置阈值是 0.9,那么只有置信度是大于等于 0.9 的样本才会最终判定为正类,其余的都是负类。\n我们设置不同的阈值,自然就会得到不同的正类数量和负类数量,依次计算不同情况的精确率和召回率,然后我们可以以精确率为纵轴,召回率为横轴,绘制一条“P-R曲线”,如下图所示:\n当然,以上这个曲线是比较理想情况下的,未来绘图方便和美观,实际情况如下图所示:\n对于 P-R 曲线,有:\n1.曲线从左上角 (0,1) 到右下角 (1,0) 的走势,正好反映了精确率和召回率是一对矛盾的度量,一个高另一个低的特点:\n开始是精确率高,因为设置阈值很高,只有第一个样本(分类器最有把握是正类)被预测为正类,其他都是负类,所以精确率高,几乎是 1,而召回率几乎是 0,仅仅找到 1 个正类。 右下角时候就是召回率很高,精确率很低,此时设置阈值就是 0,所以类别都被预测为正类,所有正类都被找到了,召回率很高,而精确率非常低,因为大量负类被预测为正类。 2.P-R 曲线可以非常直观显示出分类器在样本总体上的精确率和召回率。所以可以对比两个分类器在同个测试集上的 P-R 曲线来比较它们的分类能力:\n如果分类器 B 的 P-R 曲线被分类器 A 的曲线完全包住,如下左图所示,则可以说,A 的性能优于 B; 如果是下面的右图,两者的曲线有交叉,则很难直接判断两个分类器的优劣,只能根据具体的精确率和召回率进行比较: 一个合理的依据是比较 P-R 曲线下方的面积大小,它在一定程度上表征了分类器在精确率和召回率上取得“双高”的比例,但这个数值不容易计算; 另一个比较就是平衡点(Break-Event Point, BEP),它是精确率等于召回率时的取值,如下右图所示,而且可以判定,平衡点较远的曲线更好。 当然了,平衡点还是过于简化,于是有了 F1 值这个新的评价标准,它是精确率和召回率的调和平均值,定义为:\nF1 还有一个更一般的形式: ,能让我们表达出对精确率和召回率的不同偏好,定义如下:\n其中 度量了召回率对精确率的相对重要性,当 ,就是 F1;如果 ,召回率更加重要;如果 ,则是精确率更加重要。\n1.2.3 宏精确率/微精确率、宏召回率/微召回率以及宏 F1 / 微 F1 很多时候,我们会得到不止一个二分类的混淆矩阵,比如多次训练/测试得到多个混淆矩阵,在多个数据集上进行训练/测试来估计算法的“全局”性能,或者是执行多分类任务时对类别两两组合得到多个混淆矩阵。\n总之,我们希望在 n 个二分类混淆矩阵上综合考察精确率和召回率。这里一般有两种方法来进行考察:\n1.第一种是直接在各个混淆矩阵上分别计算出精确率和召回率,记为 ,接着计算平均值,就得到宏精确率(macro-P)、宏召回率(macro-R)以及宏 F1(macro-F1) , 定义如下:\n2.第二种则是对每个混淆矩阵的对应元素进行平均,得到 TP、FP、TN、FN 的平均值,再基于这些平均值就就得到微精确率(micro-P)、微召回率(micro-R)以及微 F1(micro-F1) , 定义如下:\n1.3 ROC 与 AUC 1.3.1 ROC 曲线 ROC 曲线的 Receiver Operating Characteristic 曲线的简称,中文名是“受试者工作特征”,起源于军事领域,后广泛应用于医学领域。\n它的横坐标是假正例率(False Positive Rate, FPR),纵坐标是真正例率(True Positive Rate, TPR),两者的定义分别如下:\nTPR 表示正类中被分类器预测为正类的概率,刚好就等于正类的召回率;\nFPR 表示负类中被分类器预测为正类的概率,它等于 1 减去负类的召回率,负类的召回率如下,称为真反例率(True Negative Rate, TNR), 也被称为特异性,表示负类被正确分类的比例。\n第二种更直观地绘制 ROC 曲线的方法,首先统计出正负样本的数量,假设分别是 P 和 N,接着,将横轴的刻度间隔设置为 1/N,纵轴的刻度间隔设置为 1/P。然后根据模型输出的概率对样本排序,并按顺序遍历样本,从零点开始绘制 ROC 曲线,每次遇到一个正样本就沿纵轴方向绘制一个刻度间隔的曲线,遇到一个负样本就沿横轴绘制一个刻度间隔的曲线,直到遍历完所有样本,曲线最终停留在 (1,1) 这个点,此时就完成了 ROC 曲线的绘制了。\n当然,更一般的 ROC 曲线是如下图所示的,会更加的平滑,上图是由于样本数量有限才导致的。\n对于 ROC 曲线,有以下几点特性:\n1.ROC 曲线通常都是从左下角 (0,0) 开始,到右上角 (1,1) 结束。\n开始时候,\n第一个样本被预测为正类\n,其他都是预测为负类别;\nTPR 会很低,几乎是 0,上述例子就是 0.1,此时大量正类没有被分类器找出来; FPR 也很低,可能就是0,上述例子就是 0,这时候被预测为正类的样本可能实际也是正类,所以几乎没有预测错误的正类样本。 结束时候,\n所有样本都预测为正类.\nTPR 几乎就是 1,因为所有样本都预测为正类,那肯定就找出所有的正类样本了; FPR 也是几乎为 1,因为所有负样本都被错误判断为正类。 2.ROC 曲线中:\n对角线对应于随机猜想模型,即概率为 0.5; 点 (0,1) 是理想模型,因为此时 TPR=1,FPR=0,也就是正类都预测出来,并且没有预测错误; 通常,ROC 曲线越接近点 (0, 1) 越好。 3.同样可以根据 ROC 曲线来判断两个分类器的性能:\n如果分类器 A 的 ROC 曲线被分类器 B 的曲线完全包住,可以说 B 的性能好过 A,这对应于上一条说的 ROC 曲线越接近点 (0, 1) 越好; 如果两个分类器的 ROC 曲线发生了交叉,则同样很难直接判断两者的性能优劣,需要借助 ROC 曲线下面积大小来做判断,而这个面积被称为 AUC:Area Under ROC Curve。 1.3.2 ROC 和 P-R 曲线的对比 相同点\n1.两者刻画的都是阈值的选择对分类度量指标的影响。虽然每个分类器对每个样本都会输出一个概率,也就是置信度,但通常我们都会人为设置一个阈值来影响分类器最终判断的结果,比如设置一个很高的阈值\u0026ndash;0.95,或者比较低的阈值\u0026ndash;0.3。\n如果是偏向于精确率,则提高阈值,保证只把有把握的样本判断为正类,此时可以设置阈值为 0.9,或者更高; 如果偏向于召回率,那么降低阈值,保证将更多的样本判断为正类,更容易找出所有真正的正样本,此时设置阈值是 0.5,或者更低。 2.两个曲线的每个点都是对应某个阈值的选择,该点是在该阈值下的 (精确率,召回率) / (TPR, FPR)。然后沿着横轴方向对应阈值的下降。\n不同\n相比较 P-R 曲线,ROC 曲线有一个特点,就是正负样本的分布发生变化时,它的曲线形状能够基本保持不变。如下图所示:\n分别比较了增加十倍的负样本后, P-R 和 ROC 曲线的变化,可以看到 ROC 曲线的形状基本不变,但 P-R 曲线发生了明显的变化。\n所以 ROC 曲线的这个特点可以降低不同测试集带来的干扰,更加客观地评估模型本身的性能,因此它适用的场景更多,比如排序、推荐、广告等领域。\n这也是由于现实场景中很多问题都会存在正负样本数量不平衡的情况,比如计算广告领域经常涉及转化率模型,正样本的数量往往是负样本数量的千分之一甚至万分之一,这时候选择 ROC 曲线更加考验反映模型本身的好坏。\n当然,如果希望看到模型在特定数据集上的表现,P-R 曲线会更直观地反映其性能。所以还是需要具体问题具体分析。\n1.3.3 AUC 曲线 AUC 是 ROC 曲线的面积,其物理意义是:从所有正样本中随机挑选一个样本,模型将其预测为正样本的概率是 ;从所有负样本中随机挑选一个样本,模型将其预测为正样本的概率是 。 的概率就是 AUC。\nAUC 曲线有以下几个特点:\n如果完全随机地对样本进行分类,那么 的概率是 0.5,则 AUC=0.5;\nAUC 在样本不平衡的条件下依然适用。\n如:在反欺诈场景下,假设正常用户为正类(设占比 99.9%),欺诈用户为负类(设占比 0.1%)。\n如果使用准确率评估,则将所有用户预测为正类即可获得 99.9%的准确率。很明显这并不是一个很好的预测结果,因为欺诈用户全部未能找出。\n如果使用 AUC 评估,则此时 FPR=1,TPR=1,对应的 AUC=0.5 。因此 AUC 成功的指出了这并不是一个很好的预测结果。\nAUC 反应的是模型对于样本的排序能力(根据样本预测为正类的概率来排序)。如:AUC=0.8 表示:给定一个正样本和一个负样本,在 80% 的情况下,模型对正样本预测为正类的概率大于对负样本预测为正类的概率。\nAUC 对于均匀采样不敏感。如:上述反欺诈场景中,假设对正常用户进行均匀的降采样。任意给定一个负样本 n,设模型对其预测为正类的概率为 Pn 。降采样前后,由于是均匀采样,因此预测为正类的概率大于 Pn 和小于 Pn 的真正样本的比例没有发生变化。因此 AUC 保持不变。\n但是如果是非均匀的降采样,则预测为正类的概率大于 Pn 和小于 Pn 的真正样本的比例会发生变化,这也会导致 AUC 发生变化。\n正负样本之间的预测为正类概率之间的差距越大,则 AUC 越高。因为这表明正负样本之间排序的把握越大,区分度越高。\n如:在电商场景中,点击率模型的 AUC 要低于购买转化模型的 AUC 。因为点击行为的成本低于购买行为的成本,所以点击率模型中正负样本的差别要小于购买转化模型中正负样本的差别。\nAUC 的计算可以通过对 ROC 曲线下各部分的面积求和而得。假设 ROC 曲线是由坐标为下列这些点按顺序连接而成的:\n那么 AUC 可以这样估算:\n1.4 代价矩阵 前面介绍的性能指标都有一个隐式的前提,错误都是均等代价。但实际应用过程中,不同类型的错误所造成的后果是不同的。比如将健康人判断为患者,与患者被判断为健康人,代价肯定是不一样的,前者可能就是需要再次进行检查,而后者可能错过治疗的最佳时机。\n因此,为了衡量不同类型所造成的不同损失,可以为错误赋予非均等代价(unequal cost)。\n对于一个二类分类问题,可以设定一个代价矩阵(cost matrix),其中 表示将第 i 类样本预测为第 j 类样本的代价,而预测正确的代价是 0 。如下表所示:\n预测:第 0 类 预测:第 1 类 真实:第 0 类 0 真实: 第 1 类 0 在非均等代价下,希望找到的不再是简单地最小化错误率的模型,而是希望找到最小化总体代价 total cost 的模型。\n在非均等代价下,ROC 曲线不能直接反映出分类器的期望总体代价,此时需要使用代价曲线 cost curve\n代价曲线的横轴是正例概率代价,如下所示,其中 p 是正例(第 0 类)的概率 代价曲线的纵轴是归一化代价,如下所示:\n其中,假正例率 FPR 表示模型将负样本预测为正类的概率,定义如下:\n假负例率 FNR 表示将正样本预测为负类的概率,定义如下:\n代价曲线如下图所示:\n1.5 回归问题的性能度量 对于回归问题,常用的性能度量标准有:\n1.均方误差(Mean Square Error, MSE),定义如下:\n2.均方根误差(Root Mean Squared Error, RMSE),定义如下:\n3.均方根对数误差(Root Mean Squared Logarithmic Error, RMSLE),定义如下\n4.平均绝对误差(Mean Absolute Error, MAE),定义如下:\n这四个标准中,比较常用的第一个和第二个,即 MSE 和 RMSE,这两个标准一般都可以很好反映回归模型预测值和真实值的偏离程度,但如果遇到个别偏离程度非常大的离群点时,即便数量很少,也会让这两个指标变得很差。\n遇到这种情况,有三种解决思路:\n将离群点作为噪声点来处理,即数据预处理部分需要过滤掉这些噪声点; 从模型性能入手,提高模型的预测能力,将这些离群点产生的机制建模到模型中,但这个方法会比较困难; 采用其他指标,比如第三个指标 RMSLE,它关注的是预测误差的比例,即便存在离群点,也可以降低这些离群点的影响;或者是 MAPE,平均绝对百分比误差(Mean Absolute Percent Error),定义为: RMSE 的简单代码实现如下所示:\n1 2 3 4 5 6 7 8 def rmse(predictions, targets): # 真实值和预测值的误差 differences = predictions - targets differences_squared = differences ** 2 mean_of_differences_squared = differences_squared.mean() # 取平方根 rmse_val = np.sqrt(mean_of_differences_squared) return rmse_val 1.6 其他评价指标 计算速度:模型训练和预测需要的时间; 鲁棒性:处理缺失值和异常值的能力; 可拓展性:处理大数据集的能力; 可解释性:模型预测标准的可理解性,比如决策树产生的规则就很容易理解,而神经网络被称为黑盒子的原因就是它的大量参数并不好理解。 ","permalink":"https://reid00.github.io/en/posts/ml/%E5%A6%82%E4%BD%95%E8%AF%84%E4%BB%B7%E6%A8%A1%E5%9E%8B%E5%A5%BD%E5%9D%8F/","summary":"Summary “所有模型都是坏的,但有些模型是有用的”。我们建立模型之后,接下来就要去评估模型,确定这个模型是否‘有用’。当你费尽全力去建立完模型后,你","title":"如何评价模型好坏"},{"content":"简介 常用的Normalization方法主要有:Batch Normalization(BN,2015年)、Layer Normalization(LN,2016年)、Instance Normalization(IN,2017年)、Group Normalization(GN,2018年)。它们都是从激活函数的输入来考虑、做文章的,以不同的方式对激活函数的输入进行 Norm 的。\n我们将输入的 feature map shape 记为**[N, C, H, W]**,其中N表示batch size,即N个样本;C表示通道数;H、W分别表示特征图的高度、宽度。这几个方法主要的区别就是在:\nBN是在batch上,对N、H、W做归一化,而保留通道 C 的维度。BN对较小的batch size效果不好。BN适用于固定深度的前向神经网络,如CNN,不适用于RNN;\nLN在通道方向上,对C、H、W归一化,主要对RNN效果明显;\nIN在图像像素上,对H、W做归一化,用在风格化迁移;\nGN将channel分组,然后再做归一化。\n每个子图表示一个特征图,其中N为批量,C为通道,(H,W)为特征图的高度和宽度。通过蓝色部分的值来计算均值和方差,从而进行归一化。\n如果把特征图比喻成一摞书,这摞书总共有 N 本,每本有 C 页,每页有 H 行,每行 有W 个字符。\nBN 求均值时,相当于把这些书按页码一一对应地加起来(例如第1本书第36页,第2本书第36页\u0026hellip;\u0026hellip;),再除以每个页码下的字符总数:N×H×W,因此可以把 BN 看成求“平均书”的操作(注意这个“平均书”每页只有一个字),求标准差时也是同理。\nLN 求均值时,相当于把每一本书的所有字加起来,再除以这本书的字符总数:C×H×W,即求整本书的“平均字”,求标准差时也是同理。\nIN 求均值时,相当于把一页书中所有字加起来,再除以该页的总字数:H×W,即求每页书的“平均字”,求标准差时也是同理。\nGN 相当于把一本 C 页的书平均分成 G 份,每份成为有 C/G 页的小册子,求每个小册子的“平均字”和字的“标准差”。\n参考:\nhttps://mp.weixin.qq.com/s/dDMPBYjPeilivSA8J8W7lA https://zhuanlan.zhihu.com/p/72589565 ","permalink":"https://reid00.github.io/en/posts/ml/%E5%B8%B8%E7%94%A8normalization%E6%96%B9%E6%B3%95%E7%9A%84%E6%80%BB%E7%BB%93%E4%B8%8E%E6%80%9D%E8%80%83/","summary":"简介 常用的Normalization方法主要有:Batch Normalization(BN,2015年)、Layer Normalizatio","title":"常用Normalization方法的总结与思考"},{"content":"1. SVM SVM的应用 SVM在很多诸如文本分类,图像分类,生物序列分析和生物数据挖掘,手写字符识别等领域有很多的应用,但或许你并没强烈的意识到,SVM可以成功应用的领域远远超出现在已经在开发应用了的领域。\n通常人们会从一些常用的核函数中选择(根据问题和数据的不同,选择不同的参数,实际上就是得到了不同的核函数),例如:多项式核、高斯核、线性核。\nSVM是一种二类分类模型。它的基本模型是在特征空间中寻找间隔最大化的分离超平面的线性分类器。(间隔最大是它有别于感知机)\n(1)当训练样本线性可分时,通过硬间隔最大化,学习一个线性分类器,即线性可分支持向量机;\n(2)当训练数据近似线性可分时,引入松弛变量,通过软间隔最大化,学习一个线性分类器,即线性支持向量机;\n(3)当训练数据线性不可分时,通过使用核技巧及软间隔最大化,学习非线性支持向量机。\n注:以上各SVM的数学推导应该熟悉:硬间隔最大化(几何间隔)\u0026mdash;学习的对偶问题\u0026mdash;软间隔最大化(引入松弛变量)\u0026mdash;非线性支持向量机(核技巧)。\n读者可能还是没明白核函数到底是个什么东西?我再简要概括下,即以下三点:\n实际中,我们会经常遇到线性不可分的样例,此时,我们的常用做法是把样例特征映射到高维空间中去(映射到高维空间后,相关特征便被分开了,也就达到了分类的目的); 但进一步,如果凡是遇到线性不可分的样例,一律映射到高维空间,那么这个维度大小是会高到可怕的。那咋办呢? 此时,核函数就隆重登场了,核函数的价值在于它虽然也是将特征进行从低维到高维的转换,但核函数绝就绝在它事先在低维上进行计算,而将实质上的分类效果表现在了高维上,避免了直接在高维空间中的复杂计算 2. SVM的一些问题 SVM为什么采用间隔最大化? 当训练数据线性可分时,存在无穷个分离超平面可以将两类数据正确分开。\n感知机利用误分类最小策略,求得分离超平面,不过此时的解有无穷多个。\n线性可分支持向量机利用间隔最大化求得最优分离超平面,这时,解是唯一的。另一方面,此时的分隔超平面所产生的分类结果是最鲁棒的,对未知实例的泛化能力最强。\n然后应该借此阐述,几何间隔,函数间隔,及从函数间隔—\u0026gt;求解最小化1/2 ||w||^2 时的w和b。即线性可分支持向量机学习算法—最大间隔法的由来。\nSVM如何处理多分类问题?** 一般有两种做法:一种是直接法,直接在目标函数上修改,将多个分类面的参数求解合并到一个最优化问题里面。看似简单但是计算量却非常的大。\n另外一种做法是间接法:对训练器进行组合。其中比较典型的有一对一,和一对多。\n一对多,就是对每个类都训练出一个分类器,由svm是二分类,所以将此而分类器的两类设定为目标类为一类,其余类为另外一类。这样针对k个类可以训练出k个分类器,当有一个新的样本来的时候,用这k个分类器来测试,那个分类器的概率高,那么这个样本就属于哪一类。这种方法效果不太好,bias比较高。\nsvm一对一法(one-vs-one),针对任意两个类训练出一个分类器,如果有k类,一共训练出C(2,k) 个分类器,这样当有一个新的样本要来的时候,用这C(2,k) 个分类器来测试,每当被判定属于某一类的时候,该类就加一,最后票数最多的类别被认定为该样本的类。\n是否存在一组参数使SVM训练误差为0? Y\n训练误差为0的SVM分类器一定存在吗? 一定存在\n加入松弛变量的SVM的训练误差可以为0吗? 如果数据中出现了离群点outliers,那么就可以使用松弛变量来解决。\n使用SMO算法训练的线性分类器并不一定能得到训练误差为0的模型。这是由 于我们的优化目标改变了,并不再是使训练误差最小。\n带核的SVM为什么能分类非线性问题? 核函数的本质是两个函数的內积,通过核函数将其隐射到高维空间,在高维空间非线性问题转化为线性问题, SVM得到超平面是高维空间的线性分类平面。其分类结果也视为低维空间的非线性分类结果, 因而带核的SVM就能分类非线性问题。\n如何选择核函数? 如果特征的数量大到和样本数量差不多,则选用LR或者线性核的SVM; 如果特征的数量小,样本的数量正常,则选用SVM+高斯核函数; 如果特征的数量小,而样本的数量很大,则需要手工添加一些特征从而变成第一种情况。 3. LR和SVM的联系与区别 相同点 都是线性分类器。本质上都是求一个最佳分类超平面。\n都是监督学习算法\n都是判别模型。判别模型不关心数据是怎么生成的,它只关心信号之间的差别,然后用差别来简单对给定的一个信号进行分类。常见的判别模型有:KNN、SVM、LR,常见的生成模型有:朴素贝叶斯,隐马尔可夫模型。\n不同点 LR是参数模型,svm是非参数模型,linear和rbf则是针对数据线性可分和不可分的区别\n从目标函数来看,区别在于逻辑回归采用的是logistical loss,SVM采用的是hinge loss,这两个损失函数的目的都是增加对分类影响较大的数据点的权重,减少与分类关系较小的数据点的权重。\nSVM的处理方法是只考虑support vectors,也就是和分类最相关的少数点,去学习分类器。而逻辑回归通过非线性映射,大大减小了离分类平面较远的点的权重,相对提升了与分类最相关的数据点的权重。\n逻辑回归相对来说模型更简单,好理解,特别是大规模线性分类时比较方便。而SVM的理解和优化相对来说复杂一些,SVM转化为对偶问题后,分类只需要计算与少数几个支持向量的距离,这个在进行复杂核函数计算时优势很明显,能够大大简化模型和计算。\nlogic 能做的 svm能做,但可能在准确率上有问题,svm能做的logic有的做不了。\n4. 线性分类器与非线性分类器的区别以及优劣 线性和非线性是针对模型参数和输入特征来讲的;比如输入x,模型y=ax+ax^2 那么就是非线性模型,如果输入是x和X^2则模型是线性的。\n线性分类器可解释性好,计算复杂度较低,不足之处是模型的拟合效果相对弱些。\nLR,贝叶斯分类,单层感知机、线性回归\n非线性分类器效果拟合能力较强,不足之处是数据量不足容易过拟合、计算复杂度高、可解释性不好。\n决策树、RF、GBDT、多层感知机\nSVM两种都有(看线性核还是高斯核 即RBF )\n线性核:主要用于线性可分的情形,参数少,速度快,对于一般数据,分类效果已经很理想了。\nRBF 核:主要用于线性不可分的情形,参数多,分类结果非常依赖于参数。有很多人是通过训练数据的交叉验证来寻找合适的参数,不过这个过程比较耗时。 如果 Feature 的数量很大,跟样本数量差不多,这时候选用线性核的 SVM。 如果 Feature 的数量比较小,样本数量一般,不算大也不算小,选用高斯核的 SVM。\n*为什么要转为对偶问题?(阿里面试)*\n(a) 目前处理的模型严重依赖于数据集的维度d,如果维度d太高就会严重提升运算时间;\n(b) 对偶问题事实上把SVM从依赖d个维度转变到依赖N个数据点,考虑到在最后计算时只有支持向量才有意义,所以这个计算量实际上比N小很多。\n一、是对偶问题往往更易求解(当我们寻找约束存在时的最优点的时候,约束的存在虽然减小了需要搜寻的范围,但是却使问题变得更加复杂。为了使问题变得易于处理,我们的方法是把目标函数和约束全部融入一个新的函数,即拉格朗日函数,再通过这个函数来寻找最优点。)\n二、自然引入核函数,进而推广到非线性分类问题\n参考: https://cloud.tencent.com/developer/article/1541701\n","permalink":"https://reid00.github.io/en/posts/ml/svm/","summary":"1. SVM SVM的应用 SVM在很多诸如文本分类,图像分类,生物序列分析和生物数据挖掘,手写字符识别等领域有很多的应用,但或许你并没强烈的意识到,S","title":"SVM"},{"content":"Word2vec 介绍 Word2Vec是google在2013年推出的一个NLP工具,它的特点是能够将单词转化为向量来表示。首先,word2vec可以在百万数量级的词典和上亿的数据集上进行高效地训练;其次,该工具得到的训练结果——词向量(word embedding),可以很好地度量词与词之间的相似性。随着深度学习(Deep Learning)在自然语言处理中应用的普及,很多人误以为word2vec是一种深度学习算法。其实word2vec算法的背后是一个浅层神经网络(有一个隐含层的神经元网络)。另外需要强调的一点是,word2vec是一个计算word vector的开源工具。当我们在说word2vec算法或模型的时候,其实指的是其背后用于计算word vector的CBOW模型和Skip-gram模型。很多人以为word2vec指的是一个算法或模型,这也是一种谬误。\n用词向量来表示词并不是Word2Vec的首创,在很久之前就出现了。最早的词向量采用One-Hot编码,又称为一位有效编码,每个词向量维度大小为整个词汇表的大小,对于每个具体的词汇表中的词,将对应的位置置为1。转化为N维向量。\n采用One-Hot编码方式来表示词向量非常简单,但缺点也是显而易见的,一方面我们实际使用的词汇表很大,经常是百万级以上,这么高维的数据处理起来会消耗大量的计算资源与时间。另一方面,One-Hot编码中所有词向量之间彼此正交,没有体现词与词之间的相似关系。\nWord2vec 是 Word Embedding 方式之一,属于 NLP 领域。他是将词转化为「可计算」「结构化」的向量的过程。本文将讲解 Word2vec 的原理和优缺点。\n什么是 Word2vec ? 什么是 Word Embedding ? 在说明 Word2vec 之前,需要先解释一下 Word Embedding。 它就是将「不可计算」「非结构化」的词转化为「可计算」「结构化」的向量。\n这一步解决的是”将现实问题转化为数学问题“,是人工智能非常关键的一步。 将现实问题转化为数学问题只是第一步,后面还需要求解这个数学问题。所以 Word Embedding 的模型本身并不重要,重要的是生成出来的结果——词向量。因为在后续的任务中会直接用到这个词向量。\n什么是 Word2vec ? Word2vec 是 Word Embedding 的方法之一。他是 2013 年由谷歌的 Mikolov 提出了一套新的词嵌入方法。\nWord2vec 在整个 NLP 里的位置可以用下图表示: Word2vec 的 2 种训练模式 CBOW(Continuous Bag-of-Words Model)和Skip-gram (Continuous Skip-gram Model),是Word2vec 的两种训练模式。CBOW适合于数据集较小的情况,而Skip-Gram在大型语料中表现更好。下面简单做一下解释:\n词向量训练的预处理步骤:\n1. 对输入的文本生成一个词汇表,每个词统计词频,按照词频从高到低排序,取最频繁的V个词,构成一个词汇表。每个词存在一个one-hot向量,向量的维度是V,如果该词在词汇表中出现过,则向量中词汇表中对应的位置为1,其他位置全为0。如果词汇表中不出现,则向量为全0 2. 将输入文本的每个词都生成一个one-hot向量,此处注意保留每个词的原始位置,因为是上下文相关的 3. 确定词向量的维数N CBOW 通过上下文来预测当前值。相当于一句话中扣掉一个词,让你猜这个词是什么。 CBOW的处理步骤:\n确定窗口大小window,对每个词生成2*window个训练样本,(i-window, i),(i-window+1, i),\u0026hellip;,(i+window-1, i),(i+window, i) 确定batch_size,注意batch_size的大小必须是2*window的整数倍,这确保每个batch包含了一个词汇对应的所有样本 训练算法有两种:层次 Softmax 和 Negative Sampling 神经网络迭代训练一定次数,得到输入层到隐藏层的参数矩阵,矩阵中每一行的转置即是对应词的词向量 Skip-gram 用当前词来预测上下文。相当于给你一个词,让你猜前面和后面可能出现什么词。 Skip-gram处理步骤:\n确定窗口大小window,对每个词生成2*window个训练样本,(i, i-window),(i, i-window+1),\u0026hellip;,(i, i+window-1),(i, i+window) 确定batch_size,注意batch_size的大小必须是2*window的整数倍,这确保每个batch包含了一个词汇对应的所有样本 训练算法有两种:层次 Softmax 和 Negative Sampling 神经网络迭代训练一定次数,得到输入层到隐藏层的参数矩阵,矩阵中每一行的转置即是对应词的词向量 我们先来看个最简单的例子。上面说到, y 是 x 的上下文,所以 y 只取上下文里一个词语的时候,语言模型就变成: 用当前词 x 预测它的下一个词 y 但如上面所说,一般的数学模型只接受数值型输入,这里的 x 该怎么表示呢? 显然不能用 Word2vec,因为这是我们训练完模型的产物,现在我们想要的是 x 的一个原始输入形式。\n答案是:one-hot encoder\n所谓 one-hot encoder,其思想跟特征工程里处理类别变量的 one-hot 一样。本质上是用一个只含一个 1、其他都是 0 的向量来唯一表示词语。\n我举个例子,假设全世界所有的词语总共有 V 个,这 V 个词语有自己的先后顺序,假设『吴彦祖』这个词是第1个词,『我』这个单词是第2个词,那么『吴彦祖』就可以表示为一个 V 维全零向量、把第1个位置的0变成1,而『我』同样表示为 V 维全零向量、把第2个位置的0变成1。这样,每个词语都可以找到属于自己的唯一表示。\nOK,那我们接下来就可以看看 Skip-gram 的网络结构了,x 就是上面提到的 one-hot encoder 形式的输入,y 是在这 V 个词上输出的概率,我们希望跟真实的 y 的 one-hot encoder 一样。 首先说明一点:隐层的激活函数其实是线性的,相当于没做任何处理(这也是 Word2vec 简化之前语言模型的独到之处),我们要训练这个神经网络,用反向传播算法,本质上是链式求导,在此不展开说明了,\n首先说明一点:隐层的激活函数其实是线性的,相当于没做任何处理(这也是 Word2vec 简化之前语言模型的独到之处),我们要训练这个神经网络,用反向传播算法,本质上是链式求导,在此不展开说明了,\n当模型训练完后,最后得到的其实是神经网络的权重,比如现在输入一个 x 的 one-hot encoder: [1,0,0,…,0],对应刚说的那个词语『吴彦祖』,则在输入层到隐含层的权重里,只有对应 1 这个位置的权重被激活,这些权重的个数,跟隐含层节点数是一致的,从而这些权重组成一个向量 vx 来表示x,而因为每个词语的 one-hot encoder 里面 1 的位置是不同的,所以,这个向量 vx 就可以用来唯一表示 x。\n所以 Word2vec 本质上是一种降维操作——把词语从 one-hot encoder 形式的表示降维到 Word2vec 形式的表示。\n隐层细节 假如词汇表长度为10000,首先使用one-hot形式表示每一个单词,经过隐层300个神经元计算,最后使用Softmax层对单词概率输出。每一对单词组,前者作为x输入,后者作为y标签。\n假如我们想要学习的词向量维度为300,则需要将隐层的神经元个数设置为300(300是Google在其发布的训练模型中使用的维度,可调)。\n隐层的权重矩阵就是词向量,我们模型学习到的就是隐层的权重矩阵。 之所以这样,来看一下one-hot输入后与隐层的计算就明白了。 当使用One-hot去乘以矩阵的时候,会将某一行选择出来,即查表操作,所以权重矩阵是所有词向量组成的列表。\nCBOW 详解: CBOW 是 Continuous Bag-of-Words 的缩写,与神经网络语言模型不同的是,CBOW去掉了最耗时的非线性隐藏层\n从图中可以看出,CBOW模型预测的是 ,由于图中目标词 前后只取了各两个词,所以窗口的总大小是2。假设目标词 前后各取k个词,即窗口的大小是k,那么CBOW模型预测的将是 输入层到隐藏层\n以图2为例,输入层是四个词的one-hot向量表示,分别为 (维度都为V x 1,V是模型的训练本文中所有词的个数),记输入层到隐藏层的权重矩阵为 (维度为V x d,d是认为给定的词向量维度),隐藏层的向量为 (维度为d x 1),那么\n其实这里就是一个简单地求和平均。\n隐藏层到输出层\n记隐藏层到输出层的权重矩阵为 (维度为d x V),输出层的向量为 (维度为V x 1),那么\n注意,输出层的向量 与输入层的向量为 虽然维度是一样的,但是 并不是one-hot向量,并且向量 中的每个元素都是有意义的。例如,我们假设训练样本只有一句话“I like to eat apple”,此刻我们正在使用 I、like、eat、apple 四个词来预测 to ,输出层的结果如图3所示。\n图3 向量y的例子\n向量y中的每个元素表示我用 I、like、eat、apple 四个词预测出来的词是当元素对应的词的概率,比如是like的概率为0.05,是to的概率是0.80。由于我们想让模型预测出来的词是to,那么我们就要尽量让to的概率尽可能的大,所以我们目标是最大化函数 有了最大化的目标函数,我们接下来要做的就是求解这个目标函数,首先求 ,然后求梯度,再梯度下降,具体细节在此省略,因为这种方法涉及到softmax层,softmax每次计算都要遍历整个词表,代价十分昂贵,所以实现的时候我们不用这种方法,次softmax或者负采样来替换掉输出层,降低复杂度。\n优化方法 为了提高速度,Word2vec 经常采用 2 种加速方式:\nNegative Sample(负采样)\n本质是预测总体类别的一个子集 Hierarchical Softmax (层次Softmax, huffman树)\n本质是把 N 分类问题变成 log(N)次二分类 Word2vec 的优缺点 需要说明的是:Word2vec 是上一代的产物(18 年之前), 18 年之后想要得到最好的效果,已经不使用 Word Embedding 的方法了,所以也不会用到 Word2vec。\n优点: 由于 Word2vec 会考虑上下文,跟之前的 Embedding 方法相比,效果要更好(但不如 18 年之后的方法) 比之前的 Embedding方 法维度更少,所以速度更快 通用性很强,可以用在各种 NLP 任务中 缺点: 由于词和向量是一对一的关系,所以多义词的问题无法解决。 Word2vec 是一种静态的方式,虽然通用性强,但是无法针对特定任务做动态优化 问题 假如使用词向量维度为300,词汇量为10000个单词,那么神经网络输入层与隐层,隐层与输出层的参数量会达到惊人的300x10000=300万!训练如词庞大的神经网络需要庞大的数据量,还要避免过拟合。因此,Google在其第二篇论文中说明了训练的trick,其创新点如下:\n将常用词对或短语视为模型中的单个”word”。 对频繁的词进行子采样以减少训练样例的数量。 在损失函数中使用”负采样(Negative Sampling)”的技术,使每个训练样本仅更新模型权重的一小部分。 子采样和负采样技术不仅降低了计算量,还提升了词向量的效果。\n对频繁词子采样 在以上例子中,可以看到频繁单词’the’的两个问题:\n对于单词对(‘fox’,’the’),其对单词’fox’的语义表达并没有什么有效帮助,’the’在每个单词的上下文中出现都非常频繁。 预料中有很多单词对(‘the’,…),我们应更好的学习单词’the’ Word2vec使用子采样技术来解决以上问题,根据单词的频次来削减该单词的采样率。以window size为10为例子,我们删除’the’:\n当我们训练其余单词时候,’the’不会出现在他们的上下文中。 当中心词为’the’时,训练样本数量少于10。 负采样(Negative Sampling) 训练一个网络是说,计算训练样本然后轻微调整所有的神经元权重来提高准确率。换句话说,每一个训练样本都需要更新所有神经网络的权重。\n就像如上所说,当词汇表特别大的时候,如此多的神经网络参数在如此大的数据量下,每次都要进行权重更新,负担很大。\n在每个样本训练时,只修改部分的网络参数,负采样是通过这种方式来解决这个问题的。\n当我们的神经网络训练到单词组(‘fox’, ‘quick’)时候,得到的输出或label都是一个one-hot向量,也就是说,在表示’quick’的位置数值为1,其它全为0。\n负采样是随机选择较小数量的’负(Negative)’单词(比如5个),来做参数更新。这里的’负’表示的是网络输出向量种位置为0表示的单词。当然,’正(Positive)’(即正确单词’quick’)权重也会更新。\n论文中表述,小数量级上采用5-20,大数据集使用2-5个单词。\n我们的模型权重矩阵为300x10000,更新的单词为5个’负’词和一个’正’词,共计1800个参数,这是输出层全部3M参数的0.06%!!\n负采样的选取是和频次相关的,频次越高,负采样的概率越大: $$P(w_i) = \\frac{f(w_i)^{3/4}}{\\sum_{j=0}^n(f(w_j)^{3/4})}$$ 论文选择0.75作为指数是因为实验效果好。C语言实现的代码很有意思:首先用索引值填充多次填充词汇表中的每个单词,单词索引出现的次数为$P(w_i) * \\text{table_size}$。然后负采样只需要生成一个1到100M的整数,并用于索引表中数据。由于概率高的单词在表中出现的次数多,很可能会选择这些词。\nGloVe 模型 模型目标:进行词的向量化表示,使得向量之间尽可能多地蕴含语义和语法的信息。\n输入:语料库\n输出:词向量\n方法概述:首先基于语料库构建词的共现矩阵,然后基于共现矩阵和GloVe模型学习词向量。\nGlobal Vector融合了矩阵分解的全局统计信息和上下文信息\n常见的问题 1、文本表示哪些方法? 基于 one-hot、tf-idf、textrank 等的 bag-of-words; 主题模型:LSA(SVD)、pLSA、LDA; 基于词向量的固定表征:Word2vec、FastText、GloVe 基于词向量的动态表征:ELMo、GPT、BERT 2、怎么从语言模型理解词向量?怎么理解分布式假设? 上面给出的 4 个类型也是 nlp 领域最为常用的文本表示了,文本是由每个单词构成的,而谈起词向量,one-hot 是可认为是最为简单的词向量,但存在维度灾难和语义鸿沟等问题;通过构建共现矩阵并利用 SVD 求解构建词向量,则计算复杂度高;而早期词向量的研究通常来源于语言模型,比如 NNLM 和 RNNLM,其主要目的是语言模型,而词向量只是一个副产物。\n所谓分布式假设,用一句话可以表达:相同上下文语境的词有似含义。而由此引申出了 Word2vec、FastText,在此类词向量中,虽然其本质仍然是语言模型,但是它的目标并不是语言模型本身,而是词向量,其所作的一系列优化,都是为了更快更好的得到词向量。GloVe 则是基于全局语料库、并结合上下文语境构建词向量,结合了 LSA 和 Word2vec 的优点。\n3、传统的词向量有什么问题?怎么解决?各种词向量的特点是什么? 上述方法得到的词向量是固定表征的,无法解决一词多义等问题,如“川普”。为此引入基于语言模型的动态表征方法:ELMo、GPT、BERT。\n各种词向量的特点:\nOne-hot 表示 :维度灾难、语义鸿沟; 分布式表示 (distributed representation) 矩阵分解(LSA):利用全局语料特征,但 SVD 求解计算复杂度大; 基于 NNLM/RNNLM 的词向量:词向量为副产物,存在效率不高等问题; Word2vec、FastText:优化效率高,但是基于局部语料; GloVe:基于全局预料,结合了 LSA 和 Word2vec 的优点; ELMo、GPT、BERT:动态特征; 4、Word2vec 和 NNLM 对比有什么区别?(Word2vecvs NNLM) 1)其本质都可以看作是语言模型;\n2)词向量只不过 NNLM 一个产物,Word2vec 虽然其本质也是语言模型,但是其专注于词向量本身,因此做了许多优化来提高计算效率:\n与 NNLM 相比,词向量直接 sum,不再拼接,并舍弃隐层; 考虑到 sofmax 归一化需要遍历整个词汇表,采用 hierarchical softmax 和 negative sampling 进行优化,hierarchical softmax 实质上生成一颗带权路径最小的哈夫曼树,让高频词搜索路劲变小;negative sampling 更为直接,实质上对每一个样本中每一个词都进行负例采样; 5、Word2vec 和 FastText 对比有什么区别?(Word2vec vs FastText) 1)都可以无监督学习词向量, FastText 训练词向量时会考虑 subword;\n2) FastText 还可以进行有监督学习进行文本分类,其主要特点:\n结构与 CBOW 类似,但学习目标是人工标注的分类结果; 采用 hierarchical softmax 对输出的分类标签建立哈夫曼树,样本中标签多的类别被分配短的搜寻路径 引入 N-gram,考虑词序特征 引入 subword 来处理长词,处理未登陆词问题; 6、GloVe 和 Word2vec、 LSA 对比有什么区别?(Word2vecvs GloVe vs LSA) 1)GloVe vs LSA\nLSA(Latent Semantic Analysis)可以基于 co-occurance matrix 构建词向量,实质上是基于全局语料采用 SVD 进行矩阵分解,然而 SVD 计算复杂度高;\nGloVe 可看作是对 LSA 一种优化的高效矩阵分解算法,采用 Adagrad 对最小平方损失进行优化;\n2)Word2vecvs GloVe\nWord2vec 是局部语料库训练的,其特征提取是基于滑窗的;而 GloVe 的滑窗是为了构建 co-occurance matrix,是基于全局语料的,可见 GloVe 需要事先统计共现概率;因此,Word2vec 可以进行在线学习,GloVe 则需要统计固定语料信息。\nWord2vec 是无监督学习,同样由于不需要人工标注;GloVe 通常被认为是无监督学习,但实际上 GloVe 还是有 label 的,即共现次数 [公式]。\nWord2vec 损失函数实质上是带权重的交叉熵,权重固定;GloVe 的损失函数是最小平方损失函数,权重可以做映射变换。\n总体来看,GloVe 可以被看作是更换了目标函数和权重函数的全局 Word2vec。\n7、 Word2vec 的两种优化方法是什么?它们的目标函数怎样确定的?训练过程又是怎样的? 不经过优化的 CBOW 和 Skip-gram 中 , 在每个样本中每个词的训练过程都要遍历整个词汇表,也就是都需要经过 softmax 归一化,计算误差向量和梯度以更新两个词向量矩阵(这两个词向量矩阵实际上就是最终的词向量,可认为初始化不一样),当语料库规模变大、词汇表增长时,训练变得不切实际。为了解决这个问题,Word2vec 支持两种优化方法:hierarchical softmax 和 negative sampling。\n(1)基于 hierarchical softmax 的 CBOW 和 Skip-gram\nhierarchical softmax 使用一颗二叉树表示词汇表中的单词,每个单词都作为二叉树的叶子节点。对于一个大小为 V 的词汇表,其对应的二叉树包含 V-1 非叶子节点。假如每个非叶子节点向左转标记为 1,向右转标记为 0,那么每个单词都具有唯一的从根节点到达该叶子节点的由{0 1}组成的代号(实际上为哈夫曼编码,为哈夫曼树,是带权路径长度最短的树,哈夫曼树保证了词频高的单词的路径短,词频相对低的单词的路径长,这种编码方式很大程度减少了计算量)。\n(2)基于 negative sampling 的 CBOW 和 Skip-gram\nnegative sampling 是一种不同于 hierarchical softmax 的优化策略,相比于 hierarchical softmax,negative sampling 的想法更直接——为每个训练实例都提供负例。\n负采样算法实际上就是一个带权采样过程,负例的选择机制是和单词词频联系起来的。\n具体做法是以 N+1 个点对区间 [0,1] 做非等距切分,并引入的一个在区间 [0,1] 上的 M 等距切分,其中 M \u0026raquo; N。源码中取 M = 10^8。然后对两个切分做投影,得到映射关系:采样时,每次生成一个 [1, M-1] 之间的整数 i,则 Table(i) 就对应一个样本;当采样到正例时,跳过(拒绝采样)。\n参考: https://zhuanlan.zhihu.com/p/44599645\n","permalink":"https://reid00.github.io/en/posts/ml/word2vec/","summary":"Word2vec 介绍 Word2Vec是google在2013年推出的一个NLP工具,它的特点是能够将单词转化为向量来表示。首先,word2vec可以在百万","title":"Word2vec"},{"content":"决策树 决策树(decision tree)是一个树结构(可以是二叉树或非二叉树)。其每个非叶节点表示一个特征属性上的测试,每个分支代表这个特征属性在某个值域上的输出,而每个叶节点存放一个类别。使用决策树进行决策的过程就是从根节点开始,测试待分类项中相应的特征属性,并按照其值选择输出分支,直到到达叶子节点,将叶子节点存放的类别作为决策结果[1]。 下面先来看一个小例子,看看决策树到底是什么概念(这个例子来源于[2])。\n决策树的训练数据往往就是这样的表格形式,表中的前三列(ID不算)是数据样本的属性,最后一列是决策树需要做的分类结果。通过该数据,构建的决策树如下:\n有了这棵树,我们就可以对新来的用户数据进行是否可以偿还的预测了。\n决策树最重要的是决策树的构造。所谓决策树的构造就是进行属性选择度量确定各个特征属性之间的拓扑结构。构造决策树的关键步骤是分裂属性。所谓分裂属性就是在某个节点处按照某一特征属性的不同划分构造不同的分支,其目标是让各个分裂子集尽可能地“纯”。尽可能“纯”就是尽量让一个分裂子集中待分类项属于同一类别。分裂属性分为三种不同的情况[1]: 1、属性是离散值且不要求生成二叉决策树。此时用属性的每一个划分作为一个分支。 2、属性是离散值且要求生成二叉决策树。此时使用属性划分的一个子集进行测试,按照“属于此子集”和“不属于此子集”分成两个分支。 3、属性是连续值。此时确定一个值作为分裂点split_point,按照\u0026gt;split_point和\u0026lt;=split_point生成两个分支。\n决策树的属性分裂选择是”贪心“算法,也就是没有回溯的。\nID3.5 好了,接下来说一下教科书上提到最多的决策树ID3.5算法(是最基本的模型,简单实用,但是在某些场合下也有缺陷)。\n信息论中有熵(entropy)的概念,表示状态的混乱程度,熵越大越混乱。熵的变化可以看做是信息增益,决策树ID3算法的核心思想是以信息增益度量属性选择,选择分裂后信息增益最大的属性进行分裂。\n设D为用(输出)类别对训练元组进行的划分,则D的熵表示为: info(D)=−∑i=1mpilog2(pi)info(D)=−∑i=1mpilog2⁡(pi)\n其中pipi表示第i个类别在整个训练元组中出现的概率,一般来说会用这个类别的样本数量占总量的占比来作为概率的估计;熵的实际意义表示是D中元组的类标号所需要的平均信息量。熵的含义可以看我前面写的PRML ch1.6 信息论的介绍。 如果将训练元组D按属性A进行划分,则A对D划分的期望信息为: infoA(D)=∑j=1v|Dj||D|info(Dj) infoA(D)=∑j=1v|Dj||D|info(Dj) 于是,信息增益就是两者的差值: gain(A)=info(D)−infoA(D) gain(A)=info(D)−infoA(D) ID3决策树算法就用到上面的信息增益,在每次分裂的时候贪心选择信息增益最大的属性,作为本次分裂属性。每次分裂就会使得树长高一层。这样逐步生产下去,就一定可以构建一颗决策树。(基本原理就是这样,但是实际中,为了防止过拟合,以及可能遇到叶子节点类别不纯的情况,需要有一些特殊的trick,这些留到最后讲)\nOK,借鉴一下[1]中的一个小例子,来看一下信息增益的计算过程。\n这个例子是这样的:输入样本的属性有三个——日志密度(L),好友密度(F),以及是否使用真实头像(H);样本的标记是账号是否真实yes or no。\n然后可以一次计算每一个属性的信息增益,比如日致密度的信息增益是0.276。\n同理可得H和F的信息增益为0.033和0.553。因为F具有最大的信息增益,所以第一次分裂选择F为分裂属性,分裂后的结果如下图表示:\n上面为了简便,将特征属性离散化了,其实日志密度和好友密度都是连续的属性。对于特征属性为连续值,可以如此使用ID3算法:先将D中元素按照特征属性排序,则每两个相邻元素的中间点可以看做潜在分裂点,从第一个潜在分裂点开始,分裂D并计算两个集合的期望信息,具有最小期望信息的点称为这个属性的最佳分裂点,其信息期望作为此属性的信息期望。\nC4.5 ID3有一些缺陷,就是选择的时候容易选择一些比较容易分纯净的属性,尤其在具有像ID值这样的属性,因为每个ID都对应一个类别,所以分的很纯净,ID3比较倾向找到这样的属性做分裂。\nC4.5算法定义了分裂信息,表示为: split_infoA(D)=−∑j=1v|Dj||D|log2(|Dj||D|) split_infoA(D)=−∑j=1v|Dj||D|log2⁡(|Dj||D|) 很容易理解,这个也是一个熵的定义,pi=|Dj||D|pi=|Dj||D|,可以看做是属性分裂的熵,分的越多就越混乱,熵越大。定义信息增益率: gain_ratio(A)=gain(A)split_info(A) gain_ratio(A)=gain(A)split_info(A)\nC4.5就是选择最大增益率的属性来分裂,其他类似ID3.5。\nCART CART(Classification And Regression Tree)算法既可以用于创建分类树,也可以用于创建回归树。CART算法的重要特点包含以下三个方面:\n二分(Binary Split):在每次判断过程中,都是对样本数据进行二分。CART算法是一种二分递归分割技术,把当前样本划分为两个子样本,使得生成的每个非叶子结点都有两个分支,因此CART算法生成的决策树是结构简洁的二叉树。由于CART算法构成的是一个二叉树,它在每一步的决策时只能是“是”或者“否”,即使一个feature有多个取值,也是把数据分为两部分 单变量分割(Split Based on One Variable):每次最优划分都是针对单个变量。 剪枝策略:CART算法的关键点,也是整个Tree-Based算法的关键步骤。剪枝过程特别重要,所以在最优决策树生成过程中占有重要地位。有研究表明,剪枝过程的重要性要比树生成过程更为重要,对于不同的划分标准生成的最大树(Maximum Tree),在剪枝之后都能够保留最重要的属性划分,差别不大。反而是剪枝方法对于最优树的生成更为关键。 CART分类决策树 GINI指数 CART的分支标准建立在GINI指数这个概念上,GINI指数主要是度量数据划分的不纯度,是介于0~1之间的数。GINI值越小,表明样本集合的纯净度越高;GINI值越大表明样本集合的类别越杂乱\nCART分类时,使用基尼指数(Gini)来选择最好的数据分割的特征,gini描述的是纯度,与信息熵的含义相似。CART中每一次迭代都会降低GINI系数。最好的划分就是使得GINI_Gain最小的划分。\n停止条件 决策树的构建过程是一个递归的过程,所以需要确定停止条件,否则过程将不会结束。一种最直观的方式是当每个子节点只有一种类型的记录时停止,但是这样往往会使得树的节点过多,导致过拟合问题(Overfitting)。另一种可行的方法是当前节点中的记录数低于一个最小的阀值,那么就停止分割,将max(P(i))对应的分类作为当前叶节点的分类。\n过度拟合 采用上面算法生成的决策树在事件中往往会导致过度拟合。也就是该决策树对训练数据可以得到很低的错误率,但是运用到测试数据上却得到非常高的错误率。过渡拟合的原因有以下几点: •噪音数据:训练数据中存在噪音数据,决策树的某些节点有噪音数据作为分割标准,导致决策树无法代表真实数据。 •缺少代表性数据:训练数据没有包含所有具有代表性的数据,导致某一类数据无法很好的匹配,这一点可以通过观察混淆矩阵(Confusion Matrix)分析得出。 •多重比较(Mulitple Comparision):举个列子,股票分析师预测股票涨或跌。假设分析师都是靠随机猜测,也就是他们正确的概率是0.5。每一个人预测10次,那么预测正确的次数在8次或8次以上的概率为 ,C810∗(0.5)10+C910∗(0.5)10+C1010∗(0.5)10C108∗(0.5)10+C109∗(0.5)10+C1010∗(0.5)10只有5%左右,比较低。但是如果50个分析师,每个人预测10次,选择至少一个人得到8次或以上的人作为代表,那么概率为 1−(1−0.0547)50=0.93991−(1−0.0547)50=0.9399,概率十分大,随着分析师人数的增加,概率无限接近1。但是,选出来的分析师其实是打酱油的,他对未来的预测不能做任何保证。上面这个例子就是多重比较。这一情况和决策树选取分割点类似,需要在每个变量的每一个值中选取一个作为分割的代表,所以选出一个噪音分割标准的概率是很大的。\n优化方案1:修剪枝叶 决策树过渡拟合往往是因为太过“茂盛”,也就是节点过多,所以需要裁剪(Prune Tree)枝叶。裁剪枝叶的策略对决策树正确率的影响很大。主要有两种裁剪策略。\n前置裁剪 (PrePrune:预剪枝)在构建决策树的过程时,提前停止。那么,会将切分节点的条件设置的很苛刻,导致决策树很短小。结果就是决策树无法达到最优。实践证明这中策略无法得到较好的结果。\n后置裁剪(PostPrune:后剪枝) 决策树构建好后,然后才开始裁剪。采用两种方法:1)用单一叶节点代替整个子树,叶节点的分类采用子树中最主要的分类;2)将一个字数完全替代另外一颗子树。后置裁剪有个问题就是计算效率,有些节点计算后就被裁剪了,导致有点浪费。\n剪枝可以分为两种:预剪枝(Pre-Pruning)和后剪枝(Post-Pruning),下面我们来详细学习下这两种方法: PrePrune:预剪枝,及早的停止树增长,方法可以参考见上面树停止增长的方法。 PostPrune:后剪枝,在已生成过拟合决策树上进行剪枝,可以得到简化版的剪枝决策树。\n优化方案2:K-Fold Cross Validation 首先计算出整体的决策树T,叶节点个数记作N,设i属于[1,N]。对每个i,使用K-Fold Validataion方法计算决策树,并裁剪到i个节点,计算错误率,最后求出平均错误率。(意思是说对每一个可能的i,都做K次,然后取K次的平均错误率。)这样可以用具有最小错误率对应的i作为最终决策树的大小,对原始决策树进行裁剪,得到最优决策树。\n优化方案3:Random Forest Random Forest是用训练数据随机的计算出许多决策树,形成了一个森林。然后用这个森林对未知数据进行预测,选取投票最多的分类。实践证明,此算法的错误率得到了经一步的降低。这种方法背后的原理可以用“三个臭皮匠定一个诸葛亮”这句谚语来概括。一颗树预测正确的概率可能不高,但是集体预测正确的概率却很高。RF是非常常用的分类算法,效果一般都很好。\nOK,决策树就讲到这里,商用的决策树C5.0了解不是很多;还有分类回归树CART也很常用。\n","permalink":"https://reid00.github.io/en/posts/ml/%E5%86%B3%E7%AD%96%E6%A0%91/","summary":"决策树 决策树(decision tree)是一个树结构(可以是二叉树或非二叉树)。其每个非叶节点表示一个特征属性上的测试,每个分支代表这个特征","title":"决策树"},{"content":"Summary 简单的说,k-近邻算法采用测量不同特征值之间的距离方法进行分类。 它的思路是:如果一个样本在特征空间中的k个最相似(即特征空间中最邻近)的样本中的大多数属于某一个类别,则该样本也属于这个类别,其中K通常是不大于20的整数。KNN算法中,所选择的邻居都是已经正确分类的对象。该方法在定类决策上只依据最邻近的一个或者几个样本的类别来决定待分样本所属的类别。\n优点:精度高、对异常值不敏感、无数据输入假定。\n缺点:计算复杂度高、空间复杂度高。\n适用数据范围:数值型和标称型。\n详细介绍 下面通过一个简单的例子说明一下:如下图,绿色圆要被决定赋予哪个类,是红色三角形还是蓝色四方形?如果K=3,由于红色三角形所占比例为2/3,绿色圆将被赋予红色三角形那个类,如果K=5,由于蓝色四方形比例为3/5,因此绿色圆被赋予蓝色四方形类。\n由此也说明了KNN算法的结果很大程度取决于K的选择。\n在KNN中,通过计算对象间距离来作为各个对象之间的非相似性指标,避免了对象之间的匹配问题,在这里距离一般使用欧氏距离或曼哈顿距离:\n**接下来对KNN算法的思想总结一下:**就是在训练集中数据和标签已知的情况下,输入测试数据,将测试数据的特征与训练集中对应的特征进行相互比较,找到训练集中与之最为相似的前K个数据,则该测试数据对应的类别就是K个数据中出现次数最多的那个分类,其算法的描述为:\n1)计算测试数据与各个训练数据之间的距离;\n2)按照距离的递增关系进行排序;\n3)选取距离最小的K个点;\n4)确定前K个点所在类别的出现频率;\n5)返回前K个点中出现频率最高的类别作为测试数据的预测分类。\n常见问题 1. K值设定为多大? K太小,分类结果易受噪声点影响;k太大,近邻中又可能包含太多的其它类别的点。(对距离加权,可以降低k值设定的影响) k值通常是采用交叉检验来确定(以k=1为基准) 经验规则:k一般低于训练样本数的平方根\n2. 类别如何判定最合适? 投票法没有考虑近邻的距离的远近,距离更近的近邻也许更应该决定最终的分类,所以加权投票法更恰当一些。\n3. 如何选择合适的距离衡量? 高维度对距离衡量的影响:众所周知当变量数越多,欧式距离的区分能力就越差。 变量值域对距离的影响:值域越大的变量常常会在距离计算中占据主导作用,因此应先对变量进行标准化。\n4. 训练样本是否要一视同仁? 在训练集中,有些样本可能是更值得依赖的。 可以给不同的样本施加不同的权重,加强依赖样本的权重,降低不可信赖样本的影响。\n5. 性能问题? KNN是一种懒惰算法,平时不好好学习,考试(对测试样本分类)时才临阵磨枪(临时去找k个近邻)。 懒惰的后果:构造模型很简单,但在对测试样本分类地的系统开销大,因为要扫描全部训练样本并计算距离。 已经有一些方法提高计算的效率,例如压缩训练样本量等。\n6. 能否大幅减少训练样本量,同时又保持分类精度? 浓缩技术(condensing) 编辑技术(editing)\n算法实例 如scikit-learn中的KNN算法使用:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 #coding:utf-8 from sklearn import datasets #sk-learn 内置数据库 import numpy as np \u0026#39;\u0026#39;\u0026#39;KNN算法\u0026#39;\u0026#39;\u0026#39; iris = datasets.load_iris() #内置的鸢尾花卉数据集 #数据集包含150个数据集,分为3类,每类50个数据, #可通过花萼长度,花萼宽度,花瓣长度,花瓣宽度4个特征预测鸢尾花卉属于 #(Setosa,Versicolour,Virginica)三个种类中的哪一类 iris_X,iris_y = iris.data,iris.target #数据集及其对应的分类标签 # 将数据集随机分为训练数据集和测试数据集 np.random.seed(0) indices = np.random.permutation(len(iris_X)) #用于训练模型 iris_X_train = iris_X[indices[:-10]] iris_y_train = iris_y[indices[:-10]] #用于测试模型 iris_X_test = iris_X[indices[-10:]] iris_y_test = iris_y[indices[-10:]] from sklearn.neighbors import KNeighborsClassifier knn = KNeighborsClassifier() knn.fit(iris_X_train,iris_y_train) prediction = knn.predict(iris_X_test) score = knn.score(iris_X_test,iris_y_test) print \u0026#39;真实分类标签:\u0026#39;+str(iris_y_test) print \u0026#39;模型分类结果:\u0026#39;+str(prediction)+\u0026#39;\\n算法准确度:\u0026#39;+str(score) 输出结果:\n1 2 3 真实分类标签:[1 1 1 0 0 0 2 1 2 0] 模型分类结果:[1 2 1 0 0 0 2 1 2 0] 算法准确度:0.9 ","permalink":"https://reid00.github.io/en/posts/ml/knn%E7%AE%97%E6%B3%95/","summary":"Summary 简单的说,k-近邻算法采用测量不同特征值之间的距离方法进行分类。 它的思路是:如果一个样本在特征空间中的k个最相似(即特征空间中最邻近)的样","title":"KNN算法"},{"content":"概念 L0:计算非零个数,用于产生稀疏性,但是在实际研究中很少用,因为L0范数很难优化求解,是一个NP-hard问题,因此更多情况下我们是使用L1范数 L1:计算绝对值之和,用以产生稀疏性,因为它是L0范式的一个最优凸近似,容易优化求解 L2:计算平方和再开根号,L2范数更多是防止过拟合,并且让优化求解变得稳定很快速(这是因为加入了L2范式之后,满足了强凸)。\nL1范数(Lasso Regularization):向量中各个元素绝对值的和。\nL2范数(Ridge Regression):向量中各元素平方和再求平方根。\n作用 L1正则化可以产生稀疏权值矩阵,即产生一个稀疏模型,可以用于特征选择\nL2正则化可以防止模型过拟合(overfitting);一定程度上,L1也可以防止过拟合\nL1正则化是在代价函数后面加上 L2正则化是在代价函数后面增加了 两者都起到一定的过拟合作用,两者都对应一定的先验知识,L1对应拉普拉斯分布,L2对应高斯分布,L1偏向于参数稀疏性,L2偏向于参数分布较为稠。\n","permalink":"https://reid00.github.io/en/posts/ml/l1l2%E6%AD%A3%E5%88%99/","summary":"概念 L0:计算非零个数,用于产生稀疏性,但是在实际研究中很少用,因为L0范数很难优化求解,是一个NP-hard问题,因此更多情况下我们是使用","title":"L1L2正则"},{"content":"Refer :https://blog.csdn.net/shenfuli/article/details/106523650\nMulti-Head Attention: https://blog.csdn.net/qq_37394634/article/details/102679096\n","permalink":"https://reid00.github.io/en/posts/ml/self-attention/","summary":"Refer :https://blog.csdn.net/shenfuli/article/details/106523650 Multi-Head Attention: https://blog.csdn.net/qq_37394634/article/details/102679096","title":"Self Attention"},{"content":"概述 GBDT的加入,是为了弥补LR难以实现特征组合的缺点。\nLR LR作为一个线性模型,以概率形式输出结果,在工业上得到了十分广泛的应用。 其具有简单快速高效,结果可解释,可以分布式计算。搭配L1,L2正则,可以有很好地鲁棒性以及挑选特征的能力。\n但由于其简单,也伴随着拟合能力不足,无法做特征组合的缺点。\n通过梯度下降法可以优化参数\n可以称之上是 CTR 预估模型的开山鼻祖,也是工业界使用最为广泛的 CTR 预估模型\n但是在CTR领域,单纯的LR虽然可以快速处理海量高维离散特征,但是由于线性模型的局限性,其在特征组合方面仍有不足,所以后续才发展出了FM来引入特征交叉。在此之前,业界也有使用GBDT来作为特征组合的工具,其结果输出给LR。\nLR 优缺点 优点:由于 LR 模型简单,训练时便于并行化,在预测时只需要对特征进行线性加权,所以性能比较好,往往适合处理海量 id 类特征,用 id 类特征有一个很重要的好处,就是防止信息损失(相对于范化的 CTR 特征),对于头部资源会有更细致的描述。\n缺点:LR 的缺点也很明显,首先对连续特征的处理需要先进行离散化,如上文所说,人工分桶的方式会引入多种问题。另外 LR 需要进行人工特征组合,这就需要开发者有非常丰富的领域经验,才能不走弯路。这样的模型迁移起来比较困难,换一个领域又需要重新进行大量的特征工程。\nGBDT+LR 首先,GBDT是一堆树的组合,假设有k棵树 。 对于第i棵树 ,其存在 个叶子节点。而从根节点到叶子节点,可以认为是一条路径,这条路径是一些特征的组合,例如从根节点到某一个叶子节点的路径可能是“ ”这就是一组特征组合。到达这个叶子节点的样本都拥有这样的组合特征,而这个组合特征使得这个样本得到了GBDT的预测结果。 所以对于GBDT子树 ,会返回一个 维的one-hot向量 对于整个GBDT,会返回一个 维的向量 ,这个向量由0-1组成。\n然后,这个 ,会作为输入,送进LR模型,最终输出结果\n模型大致如图所示。上图中由两棵子树,分别有3和2个叶子节点。对于一个样本x,最终可以落入第一棵树的某一个叶子和第二棵树的某一个叶子,得到两个独热编码的结果例如 [0,0,1],[1,0]组合得[0,0,1,1,0]输入到LR模型最后输出结果。\n由于LR善于处理离散特征,GBDT善于处理连续特征。所以也可以交由GBDT处理连续特征,输出结果拼接上离散特征一起输入LR。\n讨论 至于GBDT为何不善于处理高维离散特征?\nhttps://cloud.tencent.com/developer/article/1005416\n缺点:对于海量的 id 类特征,GBDT 由于树的深度和棵树限制(防止过拟合),不能有效的存储;另外海量特征在也会存在性能瓶颈,经笔者测试,当 GBDT 的 one hot 特征大于 10 万维时,就必须做分布式的训练才能保证不爆内存。所以 GBDT 通常配合少量的反馈 CTR 特征来表达,这样虽然具有一定的范化能力,但是同时会有信息损失,对于头部资源不能有效的表达。\nhttps://www.zhihu.com/question/35821566\n后来思考后发现原因是因为现在的模型普遍都会带着正则项,而 lr 等线性模型的正则项是对权重的惩罚,也就是 W1一旦过大,惩罚就会很大,进一步压缩 W1的值,使他不至于过大,而树模型则不一样,树模型的惩罚项通常为叶子节点数和深度等,而我们都知道,对于上面这种 case,树只需要一个节点就可以完美分割9990和10个样本,惩罚项极其之小. 这也就是为什么在高维稀疏特征的时候,线性模型会比非线性模型好的原因了:带正则化的线性模型比较不容易对稀疏特征过拟合。\nGBDT当树深度\u0026gt;2时,其实组合的是多元特征了,而且由于子树规模的限制,导致其特征组合的能力并不是很强,所以才有了后续FM,FFM的发展x\nGBDT + LR 改进 Facebook 的方案在实际使用中,发现并不可行,因为广告系统往往存在上亿维的 id 类特征(用户 guid10 亿维,广告 aid 上百万维),而 GBDT 由于树的深度和棵树的限制,无法存储这么多 id 类特征,导致信息的损失。有如下改进方案供读者参考:\n**方案一:**GBDT 训练除 id 类特征以外的所有特征,其他 id 类特征在 LR 阶段再加入。这样的好处很明显,既利用了 GBDT 对连续特征的自动离散化和特征组合,同时 LR 又有效利用了 id 类离散特征,防止信息损失。\n**方案二:**GBDT 分别训练 id 类树和非 id 类树,并把组合特征传入 LR 进行二次训练。对于 id 类树可以有效保留头部资源的信息不受损失;对于非 id 类树,长尾资源可以利用其范化信息(反馈 CTR 等)。但这样做有一个缺点是,介于头部资源和长尾资源中间的一部分资源,其有效信息即包含在范化信息(反馈 CTR) 中,又包含在 id 类特征中,而 GBDT 的非 id 类树只存的下头部的资源信息,所以还是会有部分信息损失。\n优缺点:\n优点:GBDT 可以自动进行特征组合和离散化,LR 可以有效利用海量 id 类离散特征,保持信息的完整性。\n缺点:LR 预测的时候需要等待 GBDT 的输出,一方面 GBDT在线预测慢于单 LR,另一方面 GBDT 目前不支持在线算法,只能以离线方式进行更新。\n","permalink":"https://reid00.github.io/en/posts/ml/gbdt+lr/","summary":"概述 GBDT的加入,是为了弥补LR难以实现特征组合的缺点。 LR LR作为一个线性模型,以概率形式输出结果,在工业上得到了十分广泛的应用。 其具有简","title":"GBDT+LR"},{"content":"一、概述 网络表示学习(Representation Learning on Network),一般说的就是向量化(Embedding)技术,简单来说,就是将网络中的结构(节点、边或者子图),通过一系列过程,变成一个多维向量,通过这样一层转化,能够将复杂的网络信息变成结构化的多维特征,从而利用机器学习方法实现更方便的算法应用\n主流的KG embedding的方法包括基于平移的模型(典型代表:TransE),基于矩阵分解的模型(典型代表:RESCAL),基于神经网络的模型(典型代表:NTN)和基于图神经网络的模型(典型代表:RGCN)。\n我们开始介绍知识表示学习的几个代表模型,包括:结构向量模型、语义匹配能量模型、隐变量模型、神经张量网络模型、矩阵分解模型和平移模型,等等。\n但是传统的KG embedding模型存在一些不足,例如大多数方法完全依赖于知识图谱中的三元组数据,知识图谱表示学习过程缺乏可解释性。针对完全依赖于三元组数据的问题,一类有效的方案是引入知识图谱图结构中存在的路径信息,经典的基于路径的KG embedding的方法是PTransE,对于由关系路径中的所有关系的向量表示,PTtransE通过求和、乘积和RNN三种策略进行路径的组合。然而,现有的基于路径的知识图谱表示学习模型的路径表示过程中完全基于数据驱动,缺乏可解释性。同时,PTransE,PathRNN等完全数据驱动的方法在表示路径的过程中会造成误差累积并进一步限制路径表示的精度。\n目前提到图算法一般指:\n经典数据结构与算法层面的:最小生成树(Prim,Kruskal,\u0026hellip;),最短路(Dijkstra,Floyed,\u0026hellip;),拓扑排序,关键路径等\n概率图模型,涉及图的表示,推断和学习,详细可以参考Koller的书或者公开课\n图神经网络,主要包括Graph Embedding(基于随机游走)和Graph CNN(基于邻居汇聚)两部分。\n二、Trans 系列 现在主要介绍知识表示学习的一个最简单也是最有效的方案,叫TransE。在这个模型中,每个实体和关系都表示成低维向量。那么如何怎么学习这些低维向量呢?我们需要设计一个学习目标,这个目标就是,给定任何一个三元组,我们都将中间的relation看成是从head到tail的一个翻译过程,也就是说把head的向量加上relation的向量,要让它尽可能地等于tail向量。在学习过程中,通过不断调整、更新实体和关系向量的取值,使这些等式尽可能实现。\n些实体和关系的表示可以用来做什么呢?一个直观的应用就是Entity Prediction(实体预测)。就是说,如果给一个head entity,再给一个relation,那么可以利用刚才学到的向量表示,去预测它的tail entity可能是什么。思想非常简单,直接把h r,然后去找跟h r向量最相近的tail向量就可以了。实际上,我们也用这个任务来判断不同表示模型的效果。我们可以看到,以TransE为代表的翻译模型,需要学习的参数数量要小很多,但同时能够达到非常好的预测准确率。\ntrans 系列详解: http://aiblog.top/2019/07/08/Trans%E7%B3%BB%E5%88%97%E6%A8%A1%E5%9E%8B%E8%AF%A6%E8%A7%A3/\n这里举一些例子。首先,利用TransE学到的实体表示,我们可以很容易地计算出跟某个实体最相似的实体。大家可以看到\n,关于中国、奥巴马、苹果,通过TransE向量得到的相似实体能够非常好地反映这些实体的关联。\n如果已知head entity和relation,我们可以用TransE模型判断对应的tail entity是什么。比如说与中国相邻的国家或者地区,可以看到比较靠前的实体均比较相关。比如说奥巴马曾经入学的学校,虽然前面的有些并不准确,但是基本上也都是大学或教育机构。\n很多情况下TransE关于h r=t的假设其实本身并不符合实际。为什么呢?假如头实体是美国,关系是总统,而美国总统其实有非常多,我们拿出任意两个实体来,比如奥巴马和布什,这两个人都可以跟USA构成同样的关系。在这种情况下,对这两个三元组学习TransE模型,就会发现,它倾向于让奥巴马和布什在空间中变得非常接近。而这其实不太符合常理,因为奥巴马和布什虽然都是美国总统,但是在其他方面有千差万别。这其实就是涉及到复杂关系的处理问题,即所谓的1对N,N对1、N对N这些关系。刚才例子就是典型的1对N关系,就是一个USA可能会对应多个tail entity。为了解决TransE在处理复杂关系时的不足,研究者提出很多扩展模型,基本思想是,首先把实体按照关系进行映射,然后与该关系构建翻译等式。\n1 - 1 transE 效果很好,但是1-N, N-1, N-N 这些复杂情况比较难。\nTransH和TransR均为代表扩展模型之一,其中TransH由MSRA研究者提出,TransR由我们实验室提出。可以看到,TransE在实体预测任务能够达到47.1的准确率,而采用TransH和TransR,特别是TransR可以达到20%的提升。对于知识图谱复杂关系的处理,还有很多工作需要做。这里只是简介了一些初步尝试。\n对于TransH和TransR的效果我们给出一些例子。比如对于《泰坦尼克号》电影,想看它的电影风格是什么,TransE得到的效果比TransH和TransR都要差一些。再如剑桥大学的杰出校友有哪些?我们可以看到对这种典型的1对N关系,TransR和TransH均做得更好一些。\nTrans 系列Github: https://github.com/thunlp/OpenKE\n考虑知识图谱复杂关系: 按照知识图谱中关系两端连接实体的对应数目,我们可以将关系划分为一对一、一对多、多对一和多对多四种类型。类型关系指的是,该类型关系中的一个左侧实体会平均对应多个右侧实体。 现有知识表示学习算法在处理四种类型关系时的性能差异较大。针对这个问题,我们提出了基于空间转移的 TransR 模型对不同的知识/关系的结构类型进行精细建模。\n考虑知识图谱复杂路径: 在知识图谱中,有些多步关系路径也能够反映实体之间的关系。为了突破现有知识表示学习模型孤立学习每个三元组的局限性,我们将借鉴循环神经网络(Recursive Neural Networks)的学术思想,提出考虑关系路径的表示学习方法。我们以平移模型 TransE 作为基础进行扩展,提出 Path-based TransE(PTransE)模型对知识图谱中的复杂关系路径进行建模。\n考虑知识图谱复杂属性: 现有知识表示学习模型将所有关系都表示为向量,这在极大程度上限制了对关系的语义的表示能力。这种局限性在属性知识的表示上尤为突出。我们面向属性知识,研究利用分类模型表示属性关系,通 过学习分类器建立实体与属性之间的关系,在既有知识图谱关系表示方案的基础上,探索具有更强表示能力的表示方案。\n二、DeepWalk DeepWalk的思想类似word2vec,使用图中节点与节点的共现关系来学习节点的向量表示。那么关键的问题就是如何来描述节点与节点的共现关系,DeepWalk给出的方法是使用随机游走(RandomWalk)的方式在图中进行节点采样。\nRandomWalk是一种可重复访问已访问节点的深度优先遍历算法。给定当前访问起始节点,从其邻居中随机采样节点作为下一个访问节点,重复此过程,直到访问序列长度满足预设条件。\n获取足够数量的节点访问序列后,使用skip-gram model 进行向量学习。\nDeepWalk 核心代码 DeepWalk算法主要包括两个步骤,第一步为随机游走采样节点序列,第二步为使用skip-gram modelword2vec学习表达向量。\n①构建同构网络,从网络中的每个节点开始分别进行Random Walk 采样,得到局部相关联的训练数据;\n②对采样数据进行SkipGram训练,将离散的网络节点表示成向量化,最大化节点共现,使用Hierarchical Softmax来做超大规模分类的分类器\nRandom Walk 我们可以通过并行的方式加速路径采样,在采用多进程进行加速时,相比于开一个进程池让每次外层循环启动一个进程,我们采用固定为每个进程分配指定数量的num_walks的方式,这样可以最大限度减少进程频繁创建与销毁的时间开销。\ndeepwalk_walk方法对应上一节伪代码中第6行,_simulate_walks对应伪代码中第3行开始的外层循环。最后的Parallel为多进程并行时的任务分配操作。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 def deepwalk_walk(self, walk_length, start_node): walk = [start_node] while len(walk) \u0026lt; walk_length: cur = walk[-1] cur_nbrs = list(self.G.neighbors(cur)) if len(cur_nbrs) \u0026gt; 0: walk.append(random.choice(cur_nbrs)) else: break return walk def _simulate_walks(self, nodes, num_walks, walk_length,): walks = [] for _ in range(num_walks): random.shuffle(nodes) for v in nodes: walks.append(self.deepwalk_walk(alk_length=walk_length, start_node=v)) return walks results = Parallel(n_jobs=workers, verbose=verbose, )( delayed(self._simulate_walks)(nodes, num, walk_length) for num in partition_num(num_walks, workers)) walks = list(itertools.chain(*results)) Word2vec 这里就偷个懒直接用gensim里的Word2Vec了。\n1 2 from gensim.models import Word2Vec w2v_model = Word2Vec(walks,sg=1,hs=1) DeepWalk应用 这里简单的用DeepWalk算法在wiki数据集上进行节点分类任务和可视化任务。 wiki数据集包含 2,405 个网页和17,981条网页之间的链接关系,以及每个网页的所属类别。\n本例中的训练,评测和可视化的完整代码在下面的git仓库中,后面还会陆续更新line,node2vec,sdne,struc2vec等graph embedding算法以及一些GCN算法\n1 2 3 4 5 6 7 8 G = nx.read_edgelist(\u0026#39;../data/wiki/Wiki_edgelist.txt\u0026#39;,create_using=nx.DiGraph(),nodetype=None,data=[(\u0026#39;weight\u0026#39;,int)]) model = DeepWalk(G,walk_length=10,num_walks=80,workers=1) model.train(window_size=5,iter=3) embeddings = model.get_embeddings() evaluate_embeddings(embeddings) plot_embeddings(embeddings) 分类任务结果 micro-F1 : 0.6674\nmacro-F1 : 0.5768\n","permalink":"https://reid00.github.io/en/posts/ml/kg%E8%A1%A8%E7%A4%BA%E5%AD%A6%E4%B9%A0/","summary":"一、概述 网络表示学习(Representation Learning on Network),一般说的就是向量化(Embedding)技术,简单来说,就是将网络中","title":"KG表示学习"},{"content":"聚类与分类的区别 分类:类别是已知的,通过对已知分类的数据进行训练和学习,找到这些不同类的特征,再对未分类的数据进行分类。属于监督学习。\n聚类:事先不知道数据会分为几类,通过聚类分析将数据聚合成几个群体。聚类不需要对数据进行训练和学习。属于无监督学习。\n关于监督学习和无监督学习,这里给一个简单的介绍:是否有监督,就看输入数据是否有标签,输入数据有标签,则为有监督学习,否则为无监督学习。\nk-means 聚类 聚类算法有很多种,K-Means 是聚类算法中的最常用的一种,算法最大的特点是简单,好理解,运算速度快,但是只能应用于连续型的数据,并且一定要在聚类前需要手工指定要分成几类。\nK-Means 聚类算法的大致意思就是“物以类聚,人以群分”:\n首先输入 k 的值,即我们指定希望通过聚类得到 k 个分组; 从数据集中随机选取 k 个数据点作为初始大佬(质心); 对集合中每一个小弟,计算与每一个大佬的距离,离哪个大佬距离近,就跟定哪个大佬。 这时每一个大佬手下都聚集了一票小弟,这时候召开选举大会,每一群选出新的大佬(即通过算法选出新的质心)。 如果新大佬和老大佬之间的距离小于某一个设置的阈值(表示重新计算的质心的位置变化不大,趋于稳定,或者说收敛),可以认为我们进行的聚类已经达到期望的结果,算法终止。 如果新大佬和老大佬距离变化很大,需要迭代3~5步骤。 用Python 代码实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # dataSet样本点,k 簇的个数 # disMeas距离量度,默认为欧几里得距离 # createCent,初始点的选取 def kMeans(dataSet, k, distMeas=distEclud, createCent=randCent): m = shape(dataSet)[0] #样本数 clusterAssment = mat(zeros((m,2))) #m*2的矩阵 centroids = createCent(dataSet, k) #初始化k个中心 clusterChanged = True while clusterChanged: #当聚类不再变化 clusterChanged = False for i in range(m): minDist = inf; minIndex = -1 for j in range(k): #找到最近的质心 distJI = distMeas(centroids[j,:],dataSet[i,:]) if distJI \u0026lt; minDist: minDist = distJI; minIndex = j if clusterAssment[i,0] != minIndex: clusterChanged = True # 第1列为所属质心,第2列为距离 clusterAssment[i,:] = minIndex,minDist**2 print centroids # 更改质心位置 for cent in range(k): ptsInClust = dataSet[nonzero(clusterAssment[:,0].A==cent)[0]] centroids[cent,:] = mean(ptsInClust, axis=0) return centroids, clusterAssment 重点理解一下:\n1 2 3 for cent in range(k): ptsInClust = dataSet[nonzero(clusterAssment[:,0].A==cent)[0]] centroids[cent,:] = mean(ptsInClust, axis=0) 循环每一个质心,找到属于当前质心的所有点,然后根据这些点去更新当前的质心。 nonzero()返回的是一个二维的数组,其表示非0的元素位置。\n1 2 3 4 5 6 7 8 \u0026gt;\u0026gt;\u0026gt; from numpy import * \u0026gt;\u0026gt;\u0026gt; a=array([[1,0,0],[0,1,2],[2,0,0]]) \u0026gt;\u0026gt;\u0026gt; a array([[1, 0, 0], [0, 1, 2], [2, 0, 0]]) \u0026gt;\u0026gt;\u0026gt; nonzero(a) (array([0, 1, 1, 2]), array([0, 1, 2, 0])) K-Means算法的缺陷 k均值算法非常简单且使用广泛,但是其有主要的两个缺陷:\nK值需要预先给定,属于预先知识,很多情况下K值的估计是非常困难的,对于像计算全部微信用户的交往圈这样的场景就完全的没办法用K-Means进行。对于可以确定K值不会太大但不明确精确的K值的场景,可以进行迭代运算,然后找出Cost Function最小时所对应的K值,这个值往往能较好的描述有多少个簇类。 K-Means算法对初始选取的聚类中心点是敏感的,不同的随机种子点得到的聚类结果完全不同 K均值算法并不是很所有的数据类型。它不能处理非球形簇、不同尺寸和不同密度的簇,银冠指定足够大的簇的个数是他通常可以发现纯子簇。 对离群点的数据进行聚类时,K均值也有问题,这种情况下,离群点检测和删除有很大的帮助 KMeans 的时间复杂度: O(knt)\n","permalink":"https://reid00.github.io/en/posts/ml/kmeans%E8%81%9A%E7%B1%BB%E5%88%86%E6%9E%90/","summary":"聚类与分类的区别 分类:类别是已知的,通过对已知分类的数据进行训练和学习,找到这些不同类的特征,再对未分类的数据进行分类。属于监督学习。 聚类:","title":"KMeans聚类分析"},{"content":"01 全连接网络 全连接、密集和线性网络是最基本但功能强大的架构这是机器学习的直接扩展,将神经网络与单个隐藏层结合使用。全连接层充当所有架构的最后一部分,用于获得使用下方深度网络所得分数的概率分布。\n**如其名称所示,全连接网络将其上一层和下一层中的所有神经元相互连接。**网络可能最终通过设置权重来关闭一些神经元,但在理想情况下,最初所有神经元都参与训练。\n02 编码器和解码器 编码器和解码器可能是深度学习另一个最基本的架构之一。所有网络都有一个或多个编码器–解码器层。你可以将全连接层中的隐藏层视为来自编码器的编码形式,将输出层视为解码器,它将隐藏层解码并作为输出。通常,编码器将输入编码到中间状态,其中输入为向量,然后解码器网络将该中间状态解码为我们想要的输出形式。\n编码器–解码器网络的一个规范示例是序列到序列 (seq2seq)网络(图1.11),可用于机器翻译。一个句子将被编码为中间向量表示形式,其中整个句子以一些浮点数字的形式表示,解码器根据中间向量解码以生成目标语言的句子作为输出。\n▲图1.11 seq2seq 网络\n自动编码器(图1.12)是一种特殊的编码器–解码器网络,属于无监督学习范畴。自动编码器尝试从未标记的数据中进行学习,将目标值设置为输入值。\n例如,如果输入一个大小为100×100的图像,则输入向量的维度为10 000。因此,输出的大小也将为 10 000,但隐藏层的大小可能为 500。简而言之,你正在尝试将输入转换为较小的隐藏状态表示形式,从隐藏状态重新生成相同的输入。\n图1.12 自动编码器的结构\n你如果能够训练一个可以做到这一点的神经网络,就会找到一个好的压缩算法,其可以将高维输入变为低维向量,这具有数量级收益。\n如今,自动编码器正被广泛应用于不同的情景和行业。\n03 循环神经网络 循环神经网络(RNN)是**最常见的深度学习算法之一,它席卷了整个世界。**我们现在在自然语言处理或理解方面几乎所有最先进的性能都归功于RNN的变体。在循环网络中,你尝试识别数据中的最小单元,并使数据成为一组这样的单元。\n在自然语言的示例中,最常见的方法是将一个单词作为一个单元,并在处理句子时将句子视为一组单词。你在整个句子上展开RNN,一次处理一个单词(图1.13)。RNN 具有适用于不同数据集的变体,有时我们会根据效率选择变体。长短期记忆 (LSTM)和门控循环单元(GRU)是最常见的 RNN 单元。\n图1.13 循环网络中单词的向量表示形式\n04 递归神经网络 顾名思义,递归神经网络是一种树状网络,用于理解序列数据的分层结构。递归网络被研究者(尤其是 Salesforce 的首席科学家理查德·索彻和他的团队)广泛用于自然语言处理。\n字向量能够有效地将一个单词的含义映射到一个向量空间,但当涉及整个句子的含义时,却没有像word2vec这样针对单词的首选解决方案。递归神经网络是此类应用最常用的算法之一。 递归网络可以创建解析树和组合向量,并映射其他分层关系(图1.14),这反过来又帮助我们找到组合单词和形成句子的规则。斯坦福自然语言推理小组开发了一种著名的、使用良好的算法,称为SNLI,这是应用递归网络的一个好例子。\n▲图1.14 递归网络中单词的向量表示形式\n05 卷积神经网络 卷积神经网络(CNN)(图1.15)使我们能够在计算机视觉中获得超人的性能,它在2010年代早期达到了人类的精度,而且其精度仍在逐年提高。\n卷积网络是最容易理解的网络,因为它有可视化工具来显示每一层正在做什么。\nFacebook AI研究(FAIR)负责人Yann LeCun早在20世纪90年代就发明了CNN。人们当时无法使用它,因为并没有足够的数据集和计算能力。CNN像滑动窗口一样扫描输入并生成中间表征,然后在它到达末端的全连接层之前对其进行逐层抽象。CNN也已成功应用于非图像数据集。\n▲图1.15 典型的 CNN\nFacebook的研究小组发现了一个基于卷积神经网络的先进自然语言处理系统,其卷积网络优于RNN,而后者被认为是任何序列数据集的首选架构。虽然一些神经科学家和人工智能研究人员不喜欢CNN(因为他们认为大脑不会像CNN那样做),但基于CNN的网络正在击败所有现有的网络实现。\n06 生成对抗网络 生成对抗网络(GAN)由 Ian Goodfellow 于 2014 年发明,自那时起,它颠覆了整个 AI 社群。它是最简单、最明显的实现之一,但其能力吸引了全世界的注意。GAN的配置如图1.16所示。\n▲图1.16 GAN配置 两个网络相互竞争,最终达到一种平衡,即生成网络可以生成数据,而鉴别网络很难将其与实际图像区分开。\n一个真实的例子就是警察和造假者之间的斗争:假设一个造假者试图制造假币,而警察试图识破它。最初,造假者没有足够的知识来制造看起来真实的假币。随着时间的流逝,造假者越来越善于制造看起来更像真实货币的假币。这时,警察起初未能识别假币,但最终他们会再次成功识别。\n这种生成–对抗过程最终会形成一种平衡。GAN 具有极大的优势。\n07 强化学习 通过互动进行学习是人类智力的基础,强化学习是领导我们朝这个方向前进的方法。过去强化学习是一个完全不同的领域,它认为人类通过试错进行学习。然而,随着深度学习的推进,另一个领域出现了“深度强化学习”,它结合了深度学习与强化学习。\n现代强化学习使用深度网络来进行学习,而不是由人们显式编码这些规则。我们将研究Q学习和深度Q学习,展示结合深度学习的强化学习与不结合深度学习的强化学习之间的区别。\n强化学习被认为是通向一般智能的途径之一,其中计算机或智能体通过与现实世界、物体或实验互动或者通过反馈来进行学习。**训练强化学习智能体和训练狗很像,它们都是通过正、负激励进行的。**当你因为狗捡到球而奖励它一块饼干或者因为狗没捡到球而对它大喊大叫时,你就是在通过积极和消极的奖励向狗的大脑中强化知识。\n**我们对AI智能体也做了同样的操作,但正奖励将是一个正数,负奖励将是一个负数。**尽管我们不能将强化学习视为与 CNN/RNN 等类似的另一种架构,但这里将其作为使用深度神经网络来解决实际问题的另一种方法,其配置如图1.17所示。\n","permalink":"https://reid00.github.io/en/posts/ml/cnn-rnn-gan/","summary":"01 全连接网络 全连接、密集和线性网络是最基本但功能强大的架构这是机器学习的直接扩展,将神经网络与单个隐藏层结合使用。全连接层充当所有架构的最后","title":"CNN RNN GAN"},{"content":"简介 在推荐、搜索、广告等领域,CTR(click-through rate)预估是一项非常核心的技术,这里引用阿里妈妈资深算法专家朱小强大佬的一句话:“它(CTR预估)是镶嵌在互联网技术上的明珠”。\n本篇文章主要是对CTR预估中的常见模型进行梳理与总结,并分成模块进行概述。每个模型都会从「模型结构」、「优势」、「不足」三个方面进行探讨,在最后对所有模型之间的关系进行比较与总结。本篇文章讨论的模型如下图所示(原创图),这个图中展示了本篇文章所要讲述的算法以及之间的关系,在文章的最后总结会对这张图进行详细地说明。\n一. 分布式线性模型 Logistic Regression Logistic Regression是每一位算法工程师再也熟悉不过的基本算法之一了,毫不夸张地说,LR作为最经典的统计学习算法几乎统治了早期工业机器学习时代。这是因为其具备简单、时间复杂度低、可大规模并行化等优良特性。在早期的CTR预估中,算法工程师们通过手动设计交叉特征以及特征离散化等方式,赋予LR这样的线性模型对数据集的非线性学习能力,高维离散特征+手动交叉特征构成了CTR预估的基础特征。LR在工程上易于大规模并行化训练恰恰适应了这个时代的要求。\n模型结构:\n优势:\n模型简单,具备一定可解释性 计算时间复杂度低 工程上可大规模并行化 不足:\n依赖于人工大量的特征工程,例如需要根据业务背知识通过特征工程融入模型 特征交叉难以穷尽 对于训练集中没有出现的交叉特征无法进行参数学习 二. 自动化特征工程 GBDT + LR(2014)—— 特征自动化时代的初探索 Facebook在2014年提出了GBDT+LR的组合模型来进行CTR预估,其本质上是通过Boosting Tree模型本身的特征组合能力来替代原先算法工程师们手动组合特征的过程。GBDT等这类Boosting Tree模型本身具备了特征筛选能力(每次分裂选取增益最大的分裂特征与分裂点)以及高阶特征组合能力(树模型天然优势)对应树的一条路径(用叶子节点来表示),因此通过GBDT来自动生成特征向量就成了一个非常自然的思路。注意这里虽然是两个模型的组合,但实际并非是端到端的模型,而是两阶段的、解耦的,即先通过GBDT训练得到特征向量后,再作为下游LR的输入,LR的在训练过程中并不会对GBDT进行更新。\n模型结构:\n通过GBDT训练模型,得到组合的特征向量。例如训练了两棵树,每棵树有5个叶子结点,对于某个特定样本来说,落在了第一棵树的第3个结点,此时我们可以得到向量 ;落在第二棵树的第4个结点,此时的到向量 ;那么最终通过concat所有树的向量,得到这个样本的最终向量 。将这个向量作为下游LR模型的inputs,进行训练。\n优势:\n特征工程自动化,通过Boosting Tree模型的天然优势自动探索特征组合 不足:\n两阶段的、非端到端的模型 CTR预估场景涉及到大量高维稀疏特征,树模型并不适合处理(因此实际上会将dense特征或者低维的离散特征给GBDT,剩余高维稀疏特征在LR阶段进行训练) GBDT模型本身比较复杂,无法做到online learning,模型对数据的感知相对较滞后(必须提高离线模型的更新频率) 由于LR善于处理离散特征,GBDT善于处理连续特征。所以也可以交由GBDT处理连续特征,输出结果拼接上离散特征一起输入LR。\n三. FM模型以及变体 (1)FM:Factorization Machines, 2010 —— 隐向量学习提升模型表达 FM是在2010年提出的一种可以学习二阶特征交叉的模型,通过在原先线性模型的基础上,枚举了所有特征的二阶交叉信息后融入模型,提高了模型的表达能力。但不同的是,模型在二阶交叉信息的权重学习上,采用了隐向量内积(也可看做embedding)的方式进行学习。\nFM和基于树的模型(e.g. GBDT)都能够自动学习特征交叉组合。基于树的模型适合连续中低度稀疏数据,容易学到高阶组合。但是树模型却不适合学习高度稀疏数据的特征组合,一方面高度稀疏数据的特征维度一般很高,这时基于树的模型学习效率很低,甚至不可行;另一方面树模型也不能学习到训练数据中很少或没有出现的特征组合。相反,FM模型因为通过隐向量的内积来提取特征组合,对于训练数据中很少或没有出现的特征组合也能够学习到。例如,特征 和特征 在训练数据中从来没有成对出现过,但特征 经常和特征 成对出现,特征 也经常和特征 成对出现,因而在FM模型中特征 和特征 也会有一定的相关性。毕竟所有包含特征 的训练样本都会导致模型更新特征 的隐向量 ,同理,所有包含特征 的样本也会导致模型更新隐向量 ,这样 就不太可能为0。\n模型结构:\nFM的公式包含了一阶线性部分与二阶特征交叉部分:\n在LR中,一般是通过手动构造交叉特征后,喂给模型进行训练,例如我们构造性别与广告类别的交叉特征: (gender=’女’ \u0026amp; ad_category=’美妆’),此时我们会针对这个交叉特征学习一个参数 。但是在LR中,参数梯度更新公式与该特征取值 关系密切: ,当 取值为0时,参数 就无法得到更新,而 要非零就要求交叉特征的两项都要非零,但实际在数据高度稀疏,一旦两个特征只要有一个取0,参数 不能得到有效更新;除此之外,对于训练集中没有出现的交叉特征,也没办法学习这类权重,泛化性能不够好。\n另外,在FM中通过将特征隐射到k维空间求内积的方式,打破了交叉特征权重间的隔离性(break the independence of the interaction parameters),增加模型在稀疏场景下学习交叉特征的能力。一个交叉特征参数的估计,可以帮助估计其他相关的交叉特征参数。例如,假设我们有交叉特征gender=male \u0026amp; movie_genre=war,我们需要估计这个交叉特征前的参数 ,FM通过将 分解为 的方式进行估计,那么对于每次更新male或者war的隐向量 时,都会影响其他与male或者war交叉的特征参数估计,使得特征权重的学习不再互相独立。这样做的好处是,对于traindata set中没有出现过的交叉特征,FM仍然可以给到一个较好的非零预估值。\n优势:\n可以有效处理稀疏场景下的特征学习 具有线性时间复杂度(化简思路: ) 对训练集中未出现的交叉特征信息也可进行泛化 不足: 2-way的FM仅枚举了所有特征的二阶交叉信息,没有考虑高阶特征的信息 FFM(Field-aware Factorization Machine)是Yuchin Juan等人在2015年的比赛中提出的一种对FM改进算法,主要是引入了field概念,即认为每个feature对于不同field的交叉都有不同的特征表达。FFM相比于FM的计算时间复杂度更高,但同时也提高了本身模型的表达能力。FM也可以看成只有一个field的FFM,这里不做过多赘述。\n(2)AFM:Attentional Factorization Machines, 2017 —— 引入Attention机制的FM AFM全称Attentional Factorization Machines,顾名思义就是引入Attention机制的FM模型。我们知道FM模型枚举了所有的二阶交叉特征(second-order interactions),即 ,实际上有一些交叉特征可能与我们的预估目标关联性不是很大;AFM就是通过Attention机制来学习不同二阶交叉特征的重要性(这个思路与FFM中不同field特征交叉使用不同的embedding实际上是一致的,都是通过引入额外信息来表达不同特征交叉的重要性)。\n举例来说,在预估用户是否会点击广告时,我们假设有用户性别、广告版位尺寸大小、广告类型三个特征,分别对应三个embedding: , , ,对于用户“是否点击”这一目标 来说,显然性别与ad_size的交叉特征对于 的相关度不大,但性别与ad_category的交叉特征(如gender=女性\u0026amp;category=美妆)就会与 更加相关;换句话说,我们认为当性别与ad_category交叉时,重要性应该要高于性别与ad_size的交叉;FFM中通过引入Field-aware的概念来量化这种与不同特征交叉时的重要性,AFM则是通过加入Attention机制,赋予重要交叉特征更高的重要性。\n模型结构:\nAFM在FM的二阶交叉特征上引入Attention权重,公式如下:\n其中 代表element-wise的向量相乘,下同。\n其中, 是模型所学习到的 与 特征交叉的重要性,其公式如下:\n我们可以看到这里的权重 实际是通过输入 和 训练了一个一层隐藏层的NN网络,让模型自行去学习这个权重。\n对比AFM和FM的公式我们可以发现,AFM实际上是FM的更加泛化的一种形式。当我们令向量 ,权重 时,AFM就会退化成FM模型。\n优势:\n在FM的二阶交叉项上引入Attention机制,赋予不同交叉特征不同的重要度,增加了模型的表达能力 Attention的引入,一定程度上增加了模型的可解释性 不足:\n仍然是一种浅层模型,模型没有学习到高阶的交叉特征 四. Embedding+MLP结构下的浅层改造 本章所介绍的都是具备Embedding+MLP这样结构的模型,之所以称作浅层改造,主要原因在于这些模型都是在embedding层进行的一些改变,例如FNN的预训练Embedding、PNN的Product layer、NFM的Bi-Interaction Layer等等,这些改变背后的思路可以归纳为:使用复杂的操作让模型在浅层尽可能包含更多的信息,降低后续下游MLP的学习负担。\n(1)FNN: Factorisation Machine supported Neural Network, 2016 —— 预训练Embedding的NN模型 FNN是2016年提出的一种基于FM预训练Embedding的NN模型,其思路也比较简单;FM本身具备学习特征Embedding的能力,DNN具备高阶特征交叉的能力,因此将两者结合是很直接的思路。FM预训练的Embedding可以看做是“先验专家知识”,直接将专家知识输入NN来进行学习。注意,FNN本质上也是两阶段的模型,与Facebook在2014年提出GBDT+LR模型在思想上一脉相承。\n模型结构:\nFNN本身在结构上并不复杂,如上图所示,就是将FM预训练好的Embedding向量直接喂给下游的DNN模型,让DNN来进行更高阶交叉信息的学习。\n优势:\n离线训练FM得到embedding,再输入NN,相当于引入先验专家经验 加速模型的训练和收敛 NN模型省去了学习feature embedding的步骤,训练开销低 不足:\n非端到端的两阶段模型,不利于online learning 预训练的Embedding受到FM模型的限制 FNN中只考虑了特征的高阶交叉,并没有保留低阶特征信息 (2)PNN:Product-based Neural Network, 2016 —— 引入不同Product操作的Embedding层 PNN是2016年提出的一种在NN中引入Product Layer的模型,其本质上和FNN类似,都属于Embedding+MLP结构。作者认为,在DNN中特征Embedding通过简单的concat或者add都不足以学习到特征之间复杂的依赖信息,因此PNN通过引入Product Layer来进行更复杂和充分的特征交叉关系的学习。PNN主要包含了IPNN和OPNN两种结构,分别对应特征之间Inner Product的交叉计算和Outer Product的交叉计算方式。\n模型结构:\nPNN结构显示通过Embedding Lookup得到每个field的Embedding向量,接着将这些向量输入Product Layer,在Product Layer中包含了两部分,一部分是左边的 ,就是将特征原始的Embedding向量直接保留;另一部分是右侧的 ,即对应特征之间的product操作;可以看到PNN相比于FNN一个优势就是保留了原始的低阶embedding特征。\n在PNN中,由于引入Product操作,会使模型的时间和空间复杂度都进一步增加。这里以IPNN为例,其中 是pair-wise的特征交叉向量,假设我们共有N个特征,每个特征的embedding信息 ;在Inner Product的情况下,通过交叉项公式 会得到 (其中 是对称矩阵),此时从Product层到 层(假设 层有 个结点),对于 层的每个结点我们有: ,因此这里从product layer到L1层参数空间复杂度为 ;作者借鉴了FM的思想对参数 进行了矩阵分解: ,此时L1层每个结点的计算可以化简为: ,空间复杂度退化 。\n优势:\nPNN通过 保留了低阶Embedding特征信息 通过Product Layer引入更复杂的特征交叉方式, 不足:\n计算时间复杂度相对较高 (3)NFM:Neural Factorization Machines, 2017 —— 引入Bi-Interaction Pooling结构的NN模型 NFM全程为Neural Factorization Machines,它与FNN一样,都属于将FM与NN进行结合的模型。但不同的是NFM相比于FNN是一种端到端的模型。NFM与PNN也有很多相似之出,本质上也属于Embedding+MLP结构,只是在浅层的特征交互上采用了不同的结构。NFM将PNN的Product Layer替换成了Bi-interaction Pooling结构来进行特征交叉的学习。\n模型结构:\nNFM的整个模型公式为:\n其中 是Bi-Interaction Pooling+NN部分的输出结果。我们重点关注NFM中的Bi-Interaction Pooling层:\nNFM的结构如上图所示,通过对特征Embedding之后,进入Bi-Interaction Pooling层。这里注意一个小细节,NFM的对Dense Feature,Embedding方式于AFM相同,将Dense Feature Embedding以后再用dense feature原始的数据进行了scale,即 。\nNFM的Bi-Interaction Pooling层是对两两特征的embedding进行element-wise的乘法,公式如下:\n假设我们每个特征Embedding向量的维度为 ,则 ,Bi-Interaction Pooling的操作简单来说就是将所有二阶交叉的结果向量进行sum pooling后再送入NN进行训练。对比AFM的Attention层,Bi-Interaction Pooling层采用直接sum的方式,缺少了Attention机制;对比FM莫明星,NFM如果将后续DNN隐藏层删掉,就会退化为一个FM模型。\nNFM在输入层以及Bi-Interaction Pooling层后都引入了BN层,也加速了模型了收敛。\n优势:\n相比于Embedding的concat操作,NFM在low level进行interaction可以提高模型的表达能力 具备一定高阶特征交叉的能力 Bi-Interaction Pooling的交叉具备线性计算时间复杂度 不足:\n直接进行sum pooling操作会损失一定的信息,可以参考AFM引入Attention (4)ONN:Operation-aware Neural Network, 2019 —— FFM与NN的结合体 ONN是2019年发表的CTR预估,我们知道PNN通过引入不同的Product操作来进行特征交叉,ONN认为针对不同的特征交叉操作,应该用不同的Embedding,如果用同样的Embedding,那么各个不同操作之间就会互相影响而最终限制了模型的表达。\n我们会发现ONN的思路在本质上其实和FFM、AFM都有异曲同工之妙,这三个模型都是通过引入了额外的信息来区分不同field之间的交叉应该具备不同的信息表达。总结下来:\nFFM:引入Field-aware,对于field a来说,与field b交叉和field c交叉应该用不同的embedding AFM:引入Attention机制,a与b的交叉特征重要度与a与c的交叉重要度不同 ONN:引入Operation-aware,a与b进行内积所用的embedding,不同于a与b进行外积用的embedding 对比上面三个模型,本质上都是给模型增加更多的表达能力,个人觉得ONN就是FFM与NN的结合。\n模型结构:\nONN沿袭了Embedding+MLP结构。在Embedding层采用Operation-aware Embedding,可以看到对于一个feature,会得到多个embedding结果;在图中以红色虚线为分割,第一列的embedding是feature本身的embedding信息,从第二列开始往后是当前特征与第n个特征交叉所使用的embedding。\n在Embedding features层中,我们可以看到包含了两部分:\n左侧部分为每个特征本身的embedding信息,其代表了一阶特征信息 右侧部分是与FFM相同的二阶交叉特征部分 这两部分concat之后接入MLP得到最后的预测结果。\n优势:\n引入Operation-aware,进一步增加了模型的表达能力 同时包含了特征一阶信息与高阶交叉信息 不足:\n模型复杂度相对较高,每个feature对应多个embedding结果 五. 双路并行的模型组合 这一部分将介绍双路并行的模型结构,之所以称为双路并行,是因为在这一部分的模型中,以Wide\u0026amp;Deep和DeepFM为代表的模型架构都是采用了双路的结构。例如Wide\u0026amp;Deep的左路为Embedding+MLP,右路为Cross Feature LR;DeepFM的左路为FM,右路为Embedding+MLP。这类模型通过使用不同的模型进行联合训练,不同子模型之间互相弥补,增加整个模型信息表达和学习的多样性。\n(1)WDL:Wide and Deep Learning, 2016 —— Memorization与Generalization的信息互补 Wide And Deep是2016年Google提出的用于Google Play app推荐业务的一种算法。其核心思想是通过结合Wide线性模型的记忆性(memorization)和Deep深度模型的泛化性(generalization)来对用户行为信息进行学习建模。\n模型结构:\n优势:\nWide层与Deep层互补互利,Deep层弥补Memorization层泛化性不足的问题 wide和deep的joint training可以减小wide部分的model size(即只需要少数的交叉特征) 可以同时学习低阶特征交叉(wide部分)和高阶特征交叉(deep部分) 不足: 仍需要手动设计交叉特征 (2)DeepFM:Deep Factorization Machines, 2017 —— FM基础上引入NN隐式高阶交叉信息 我们知道FM只能够去显式地捕捉二阶交叉信息,而对于高阶的特征组合却无能为力。DeepFM就是在FM模型的基础上,增加DNN部分,进而提高模型对于高阶组合特征的信息提取。DeepFM能够做到端到端的、自动的进行高阶特征组合,并且不需要人工干预。\n模型结构:\nDeepFM包含了FM和NN两部分,这两部分共享了Embedding层:\n左侧FM部分就是2-way的FM:包含了线性部分和二阶交叉部分右侧NN部分与FM共享Embedding,将所有特征的embedding进行concat之后作为NN部分的输入,最终通过NN得到。\n优势:\n模型具备同时学习低阶与高阶特征的能力 共享embedding层,共享了特征的信息表达 不足:\nDNN部分对于高阶特征的学习仍然是隐式的 参考:\nhttps://wqw547243068.github.io/2020/08/02/CTR/ https://zhuanlan.zhihu.com/p/35465875 CTR 预估模型的进化之路 ","permalink":"https://reid00.github.io/en/posts/ml/ctr%E5%8F%91%E5%B1%95/","summary":"简介 在推荐、搜索、广告等领域,CTR(click-through rate)预估是一项非常核心的技术,这里引用阿里妈妈资深算法专家朱小强大佬的","title":"CTR发展"},{"content":"介绍 FM和FMM模型在数据量比较大并且特征稀疏的情况下,仍然有优秀的性能表现,在CTR/CVR任务上尤其突出。\n本文包括:\n- FM 模型 - FFM 模型 - Deep FM 模型 - Deep FFM模型 FM模型的引入-广告特征的稀疏性 FM(Factorization machines)模型由Steffen Rendle于2010年提出,目的是解决稀疏数据下的特征组合问题。\n在介绍FM模型之前,来看看稀疏数据的训练问题。\n以广告CTR(click-through rate)点击率预测任务为例,假设有如下数据\nClicked? Country Day Ad_type 1 USA 26/11/15 Movie 0 China 19/2/15 Game 1 China 26/11/15 Game 第一列Clicked是类别标记,标记用户是否点击了该广告,而其余列则是特征(这里的三个特征都是类别类型),一般的,我们会对数据进行One-hot编码将类别特征转化为数值特征,转化后数据如下:\nClicked? Country=USA Country=China Day=26/11/15 Day=19/2/15 Ad_type=Movie Ad_type=Game 1 1 0 1 0 1 0 0 0 1 0 1 0 1 1 0 1 1 0 0 1 经过One-hot编码后,特征空间是十分稀疏的。特别的,某类别特征有m种不同的取值,则one-hot编码后就会被变为m维!当类别特征越多、类别特征的取值越多,其特征空间就更加稀疏。\n此外,往往我们会将特征进行两两的组合,这是因为:\n通过观察大量的样本数据可以发现,某些特征经过关联之后,与label之间的相关性就会提高。例如,“USA”与“Thanksgiving”、“China”与“Chinese New Year”这样的关联特征,对用户的点击有着正向的影响。换句话说,来自“China”的用户很可能会在“Chinese New Year”有大量的浏览、购买行为,而在“Thanksgiving”却不会有特别的消费行为。这种关联特征与label的正向相关性在实际问题中是普遍存在的,如“化妆品”类商品与“女”性,“球类运动配件”的商品与“男”性,“电影票”的商品与“电影”品类偏好等。\n再比如,用户更常在饭点的时间下载外卖app,因此,引入两个特征的组合是非常有意义的。\n如何表示两个特征的组合呢?一种直接的方法就是采用多项式模型来表示两个特征的组合,xixi为第ii个特征的取值(注意和以往表示第ii个样本的特征向量的区别),xixjxixj表示特征xixi和xjxj的特征组合,其系数wijwij即为我们学习的参数,也是xixjxixj组合的重要程度:\n式1-1也可以称为Poly2(degree-2 poly-nomial mappings)模型。注意到式子1-1中参数的个数是非常多的!一次项有d+1个,二次项(即组合特征的参数)共有d(d−1)2d(d−1)2个,而参数与参数之间彼此独立,在稀疏场景下,二次项的训练是很困难的。因为要训练wijwij,需要有大量的xixi和xjxj都非零的样本(只有非零组合才有意义)。而样本本身是稀疏的,满足xixj≠0xixj≠0的样本会非常少,样本少则难以估计参数wijwij,训练出来容易导致模型的过拟合。\n为此,Rendle于2010年提出FM模型,它能很好的求解式1-1,其特点如下:\nFM模型可以在非常稀疏的情况下进行参数估计 FM模型是线性时间复杂度的,可以直接使用原问题进行求解,而且不用像SVM一样依赖支持向量。 FM模型是一个通用的模型,其训练数据的特征取值可以是任意实数。而其它最先进的分解模型对输入数据有严格的限制。FMs可以模拟MF、SVD++、PITF或FPMC模型。 FM模型 前面提到过,式1-1的参数难以训练时因为训练数据的稀疏性。对于不同的特征对xi,xjxi,xj和xi,xkxi,xk,式1-1认为是完全独立的,对参数wijwij和wikwik分别进行训练。而实际上并非如此,不同的特征之间进行组合并非完全独立,如下图所示:\n回想矩阵分解,一个rating可以分解为user矩阵和item矩阵,如下图所示:\n分解后得到user和item矩阵的维度分别为nknk和kmkm,(k一般由用户指定),相比原来的rating矩阵,空间占用得到降低,并且分解后的user矩阵暗含着user偏好,Item矩阵暗含着item的属性,而user矩阵乘上item矩阵就是rating矩阵中用户对item的评分。\n因此,参考矩阵分解的过程,FM模型也将式1-1的二次项参数wijwij进行分解:\n其中vivi是第ii维特征的隐向量,其长度为k(k≪d)k(k≪d)。 (vi⋅vj)(vi⋅vj)为内积,其乘积为原来的wijwij,即 ^wij=(vi⋅vj)=∑kf=1vi,f⋅vj,fw^ij=(vi⋅vj)=∑f=1kvi,f⋅vj,f\n为了方便说明,考虑下面的数据集(实际中应该进行one-hot编码,但并不影响此处的说明):\n数据集 Clicked? Publisher Advertiser Poly2参数 FM参数 训练集 1 NBC Nike wNBC,NikewNBC,Nike VNBC⋅VNikeVNBC⋅VNike 训练集 0 EPSN Adidas wEPSN,AdidaswEPSN,Adidas VEPSN⋅VAdidasVEPSN⋅VAdidas 测试集 ? NBC Adidas wNBC,AdidaswNBC,Adidas VNBC⋅VAdidas 对于上面的训练集,没有(NBC,Adidas)组合,因此,Poly2模型就无法学习到参数wNBC,AdidaswNBC,Adidas。而FM模型可以通过特征组合(NBC,Nike)、(EPSN,Adidas) 分别学习到隐向量VNBCVNBC和VAdidasVAdidas,这样使得在测试集中得以进行预测。\n更一般的,经过分解,式2-1的参数个数减少为kdkd个,对比式1-1,参数个数大大减少。使用小的k,使得模型能够提高在稀疏情况下的泛化性能。此外,将wijwij进行分解,使得不同的特征对不再是完全独立的,而它们的关联性可以用隐式因子表示,这将使得有更多的数据可以用于模型参数的学习。比如xi,xjxi,xj与xi,xkxi,xk的参数分别为:⟨vi,vj⟩⟨vi,vj⟩和⟨vi,vk⟩⟨vi,vk⟩,它们都可以用来学习vivi,更一般的,包含xixj≠0\u0026amp;i≠jxixj≠0\u0026amp;i≠j的所有样本都能用来学习vivi,很大程度上避免了数据稀疏性的影响。\n此外,式2-1的复杂度可以从O(kd2)O(kd2)优化到O(kd)O(kd):\n可以看出,FM模型可以在线性的时间做出预测。\nFM模型学习 在2-4式中,∑dj=1vj,fxj∑j=1dvj,fxj只与ff有关而与ii无关,在每次迭代过程中,可以预先对所有ff的∑dj=1vj,fxj∑j=1dvj,fxj进行计算,复杂度O(kd)O(kd),就能在常数时间O(1)O(1)内得到vi,fvi,f的梯度。而对于其它参数w0w0和wiwi,显然也是在常数时间内计算梯度。此外,更新参数只需要O(1)O(1), 一共有1+d+kd1+d+kd个参数,因此FM参数训练的复杂度也是O(kd)O(kd)。\n所以说,FM模型是一种高效的模型,是线性时间复杂度的,可以在线性的时间做出训练和预测。\nFFM模型 考虑下面的数据集:\nClicked? Publisher(P) Advertiser(A) Gender(G) 1 EPSN Nike Male 0 NBC Adidas Female 对于第一条数据来说,FM模型的二次项为:wEPSN⋅wNike+wEPSN⋅wMale+wNike⋅wMalewEPSN⋅wNike+wEPSN⋅wMale+wNike⋅wMale。(这里只是把上面的v符合改成了w)每个特征只用一个隐向量来学习和其它特征的潜在影响。对于上面的例子中,Nike是广告主,Male是用户的性别,描述(EPSN,Nike)和(EPSN,Male)特征组合,FM模型都用同一个wESPNwESPN,而实际上,ESPN作为广告商,其对广告主和用户性别的潜在影响可能是不同的。\n因此,Yu-Chin Juan借鉴Michael Jahrer的论文(Ensemble of collaborative filtering and feature engineered models for click through rate prediction),将field概念引入FM模型。\nfield是什么呢?即相同性质的特征放在一个field。比如EPSN、NBC都是属于广告商field的,Nike、Adidas都是属于广告主field,Male、Female都是属于性别field的。简单的说,同一个类别特征进行one-hot编码后生成的数值特征都可以放在同一个field中,比如最开始的例子中Day=26/11/15 Day=19/2/15可以放于同一个field中。如果是数值特征而非类别,可以直接作为一个field。\n引入了field后,对于刚才的例子来说,二次项变为:\n对于特征组合(EPSN,Nike)来说,其隐向量采用的是wEPSN,AwEPSN,A和wNike,PwNike,P,对于wEPSN,AwEPSN,A这是因为Nike属于广告主(Advertiser)的field,而第二项wNike,PwNike,P则是EPSN是广告商(Publisher)的field。 再举个例子,对于特征组合(EPSN,Male)来说,wEPSN,GwEPSN,G 是因为Male是用户性别(Gender)的field,而第二项wMale,PwMale,P是因为EPSN是广告商(Publisher)的field。 下面的图来自criteo,很好的表示了三个模型的区别\nFFM 数学公式 因此,FFM的数学公式表示为:\n其中fifi和fjfj分别代表第i个特征和第j个特征所属的field。若field有ff个,隐向量的长度为k,则二次项系数共有dfkdfk个,远多于FM模型的dkdk个。此外,隐向量和field相关,并不能像FM模型一样将二次项化简,计算的复杂度是d2kd2k。\n通常情况下,每个隐向量只需要学习特定field的表示,所以有kFFM≪kFMkFFM≪kFM。\nFFM 模型学习 为了方便推导,这里省略FFM的一次项和常数项,公式为:\n注意到∂Lerr∂ϕ∂Lerr∂ϕ和参数无关,每次更新模型时,只需要计算一次,之后直接调用结果即可。对于总共有dfkdfk个模型参数的计算来说,使用这种方式能极大提升运算效率。\n第二个trick是FFM的学习率是随迭代次数变化的,具体的是采用AdaGrad算法,这里进行简单的介绍。\nAdagrad算法能够在训练中自动的调整学习率,对于稀疏的参数增加学习率,而稠密的参数则降低学习率。因此,Adagrad非常适合处理稀疏数据。\n设gt,jgt,j为第t轮第j个参数的梯度,则SGD和采用Adagrad的参数更新公式分别如下:\n可以看出,Adagrad在学习率ηη上还除以一项√Gt,jj+ϵGt,jj+ϵ,这是什么意思呢?ϵϵ为平滑项,防止分母为0,Gt,jj=∑tι=1g2ι,jjGt,jj=∑ι=1tgι,jj2即Gt,jjGt,jj为对角矩阵,每个对角线位置j,jj,j的值为参数wjwj每一轮的平方和,可以看出,随着迭代的进行,每个参数的历史梯度累加到一起,使得每个参数的学习率逐渐减小。\n实现的trick 除了上面提到的梯度分步计算和自适应学习率两个trick外,还有:\nOpenMP多核并行计算。OpenMP是用于共享内存并行系统的多处理器程序设计的编译方案,便于移植和多核扩展[12]。FFM的源码采用了OpenMP的API,对参数训练过程SGD进行了多线程扩展,支持多线程编译。因此,OpenMP技术极大地提高了FFM的训练效率和多核CPU的利用率。在训练模型时,输入的训练参数ns_threads指定了线程数量,一般设定为CPU的核心数,便于完全利用CPU资源。 SSE3指令并行编程。SSE3全称为数据流单指令多数据扩展指令集3,是CPU对数据层并行的关键指令,主要用于多媒体和游戏的应用程序中[13]。SSE3指令采用128位的寄存器,同时操作4个单精度浮点数或整数。SSE3指令的功能非常类似于向量运算。例如,a和b采用SSE3指令相加(a和b分别包含4个数据),其功能是a种的4个元素与b中4个元素对应相加,得到4个相加后的值。采用SSE3指令后,向量运算的速度更加快捷,这对包含大量向量运算的FFM模型是非常有利的。 除了上面的技巧之外,FFM的实现中还有很多调优技巧需要探索。例如,代码是按field和特征的编号申请参数空间的,如果选取了非连续或过大的编号,就会造成大量的内存浪费;在每个样本中加入值为1的新特征,相当于引入了因子化的一次项,避免了缺少一次项带来的模型偏差等。\n适用范围和使用技巧 在FFM原论文中,作者指出,FFM模型对于one-hot后类别特征十分有效,但是如果数据不够稀疏,可能相比其它模型提升没有稀疏的时候那么大,此外,对于数值型的数据效果不是特别的好。\n在Github上有FFM的开源实现,要使用FFM模型,特征需要转化为“field_id:feature_id:value”格式,相比LibSVM的格式多了field_id,即特征所属的field的编号,feature_id是特征编号,value为特征的值。\n此外,美团点评的文章中,提到了训练FFM时的一些注意事项:\n第一,样本归一化。FFM默认是进行样本数据的归一化的 。若不进行归一化,很容易造成数据inf溢出,进而引起梯度计算的nan错误。因此,样本层面的数据是推荐进行归一化的。\n第二,特征归一化。CTR/CVR模型采用了多种类型的源特征,包括数值型和categorical类型等。但是,categorical类编码后的特征取值只有0或1,较大的数值型特征会造成样本归一化后categorical类生成特征的值非常小,没有区分性。例如,一条用户-商品记录,用户为“男”性,商品的销量是5000个(假设其它特征的值为零),那么归一化后特征“sex=male”(性别为男)的值略小于0.0002,而“volume”(销量)的值近似为1。特征“sex=male”在这个样本中的作用几乎可以忽略不计,这是相当不合理的。因此,将源数值型特征的值归一化到[0,1]是非常必要的。\n第三,省略零值特征。从FFM模型的表达式(3-1)可以看出,零值特征对模型完全没有贡献。包含零值特征的一次项和组合项均为零,对于训练模型参数或者目标值预估是没有作用的。因此,可以省去零值特征,提高FFM模型训练和预测的速度,这也是稀疏样本采用FFM的显著优势。\n参考: https://www.hrwhisper.me/machine-learning-fm-ffm-deepfm-deepffm/\n","permalink":"https://reid00.github.io/en/posts/ml/fm-ffm-deepfm/","summary":"介绍 FM和FMM模型在数据量比较大并且特征稀疏的情况下,仍然有优秀的性能表现,在CTR/CVR任务上尤其突出。 本文包括: - FM 模型 - FFM 模型 - Deep","title":"FM FFM DeepFM"},{"content":"前言 先来看看一则小故事\n我们写好的一行行代码,为了让其工作起来,我们还得把它送进城(进程)里,那既然进了城里,那肯定不能胡作非为了。\n城里人有城里人的规矩,城中有个专门管辖你们的城管(操作系统),人家让你休息就休息,让你工作就工作,毕竟摊位(CPU)就一个,每个人都要占这个摊位来工作,城里要工作的人多着去了。\n所以城管为了公平起见,它使用一种策略(调度)方式,给每个人一个固定的工作时间(时间片),时间到了就会通知你去休息而换另外一个人上场工作。\n另外,在休息时候你也不能偷懒,要记住工作到哪了,不然下次到你工作了,你忘记工作到哪了,那还怎么继续?\n有的人,可能还进入了县城(线程)工作,这里相对轻松一些,在休息的时候,要记住的东西相对较少,而且还能共享城里的资源。\n“哎哟,难道本文内容是进程和线程?”\n可以,聪明的你猜出来了,也不枉费我瞎编乱造的故事了。\n进程和线程对于写代码的我们,真的天天见、日日见了,但见的多不代表你就熟悉它们,比如简单问你一句,你知道它们的工作原理和区别吗?\n不知道没关系,今天就要跟大家讨论操作系统的进程和线程。\n提纲\n正文 进程 我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」。\n现在我们考虑有一个会读取硬盘文件数据的程序被执行了,那么当运行到读取文件的指令时,就会去从硬盘读取数据,但是硬盘的读写速度是非常慢的,那么在这个时候,如果 CPU 傻傻的等硬盘返回数据的话,那 CPU 的利用率是非常低的。\n做个类比,你去煮开水时,你会傻傻的等水壶烧开吗?很明显,小孩也不会傻等。我们可以在水壶烧开之前去做其他事情。当水壶烧开了,我们自然就会听到“嘀嘀嘀”的声音,于是再把烧开的水倒入到水杯里就好了。\n所以,当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个中断,于是 CPU 再继续运行这个进程。\n进程 1 与进程 2 切换\n这种多个程序、交替执行的思想,就有 CPU 管理多个进程的初步想法。\n对于一个支持多进程的系统,CPU 会从一个进程快速切换至另一个进程,其间每个进程各运行几十或几百个毫秒。\n虽然单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。\n并发和并行有什么区别?\n一图胜千言。\n并发与并行\n进程与程序的关系的类比\n到了晚饭时间,一对小情侣肚子都咕咕叫了,于是男生见机行事,就想给女生做晚饭,所以他就在网上找了辣子鸡的菜谱,接着买了一些鸡肉、辣椒、香料等材料,然后边看边学边做这道菜。\n突然,女生说她想喝可乐,那么男生只好把做菜的事情暂停一下,并在手机菜谱标记做到哪一个步骤,把状态信息记录了下来。\n然后男生听从女生的指令,跑去下楼买了一瓶冰可乐后,又回到厨房继续做菜。\n这体现了,CPU 可以从一个进程(做菜)切换到另外一个进程(买可乐),在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行。\n所以,可以发现进程有着「运行 - 暂停 - 运行」的活动规律。\n进程的状态 在上面,我们知道了进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。\n它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。\n所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。\n进程的三种基本状态\n上图中各个状态的意义:\n运行状态(Runing):该时刻进程占用 CPU; 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止; 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行; 当然,进程另外两个基本状态:\n创建状态(new):进程正在被创建时的状态; 结束状态(Exit):进程正在从系统中消失时的状态; 于是,一个完整的进程状态的变迁如下图:\n进程五种状态的变迁\n再来详细说明一下进程的状态变迁:\nNULL -\u0026gt; 创建状态:一个新进程被创建时的第一个状态; 创建状态 -\u0026gt; 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的; 就绪态 -\u0026gt; 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程; 运行状态 -\u0026gt; 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理; 运行状态 -\u0026gt; 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行; 运行状态 -\u0026gt; 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件; 阻塞状态 -\u0026gt; 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态; 另外,还有一个状态叫挂起状态,它表示进程没有占有物理内存空间。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。\n由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。\n虚拟内存管理-换入换出\n挂起状态可以分为两种:\n阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现; 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行; 这两种挂起状态加上前面的五种状态,就变成了七种状态变迁(留给我的颜色不多了),见如下图:\n七种状态变迁\n进程的控制结构 在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。\n那 PCB 是什么呢?打开知乎搜索你就会发现这个东西并不是那么简单。\n知乎搜 PCB 的提示\n打住打住,我们是个正经的人,怎么会去看那些问题呢?是吧,回来回来。\nPCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。\nPCB 具体包含什么信息呢?\n进程描述信息:\n进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符; 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务; 进程控制和管理信息:\n进程当前状态,如 new、ready、running、waiting 或 blocked 等; 进程优先级:进程抢占 CPU 时的优先级; 资源分配清单:\n有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。 CPU 相关信息:\nCPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。 可见,PCB 包含信息还是比较多的。\n每个 PCB 是如何组织的呢?\n通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:\n将所有处于就绪状态的进程链在一起,称为就绪队列; 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列; 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。 那么,就绪队列和阻塞队列链表的组织形式如下图:\n就绪队列和阻塞队列\n除了链接的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的 PCB,不同状态对应不同的索引表。\n一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活的插入和删除。\n进程的控制 我们熟知了进程的状态变迁和进程的数据结构 PCB 后,再来看看进程的创建、终止、阻塞、唤醒的过程,这些过程也就是进程的控制。\n01 创建进程\n操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源,当子进程被终止时,其在父进程处继承的资源应当还给父进程。同时,终止父进程时同时也会终止其所有的子进程。\n创建进程的过程如下:\n为新进程分配一个唯一的进程标识号,并申请一个空白的 PCB,PCB 是有限的,若申请失败则创建失败; 为进程分配资源,此处如果资源不足,进程就会进入等待状态,以等待资源; 初始化 PCB; 如果进程的调度队列能够接纳新进程,那就将进程插入到就绪队列,等待被调度运行; 02 终止进程\n进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。\n终止进程的过程如下:\n查找需要终止的进程的 PCB; 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程; 如果其还有子进程,则应将其所有子进程终止; 将该进程所拥有的全部资源都归还给父进程或操作系统; 将其从 PCB 所在队列中删除; 03 阻塞进程\n当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。\n阻塞进程的过程如下:\n找到将要被阻塞进程标识号对应的 PCB; 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行; 将该 PCB 插入的阻塞队列中去; 04 唤醒进程\n进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。\n如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。\n唤醒进程的过程如下:\n在该事件的阻塞队列中找到相应进程的 PCB; 将其从阻塞队列中移出,并置其状态为就绪状态; 把该 PCB 插入到就绪队列中,等待调度程序调度; 进程的阻塞和唤醒是一对功能相反的语句,如果某个进程调用了阻塞语句,则必有一个与之对应的唤醒语句。\n进程的上下文切换 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。\n在详细说进程上下文切换前,我们先来看看 CPU 上下文切换\n大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。\n任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。\n所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。\nCPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快的多。\n再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。\n所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。\n既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。\nCPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。\n系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。\n上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。\n进程的上下文切换到底是切换什么呢?\n进程是由内核管理和调度的,所以进程的切换只能发生在内核态。\n所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。\n通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:\n进程上下文切换\n大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。\n发生进程上下文切换有哪些场景?\n为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行; 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行; 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度; 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行; 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序; 以上,就是发生进程上下文切换的常见场景了。\n线程 在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。\n为什么使用线程? 我们举个例子,假设你要编写一个视频播放器软件,那么该软件功能的核心模块有三个:\n从视频文件当中读取数据; 对读取的数据进行解压缩; 把解压缩后的视频数据播放出来; 对于单进程的实现方式,我想大家都会是以下这个方式:\n单进程实现方式\n对于单进程的这种方式,存在以下问题:\n播放出来的画面和声音会不连贯,因为当 CPU 能力不够强的时候,Read 的时候可能进程就等在这了,这样就会导致等半天才进行数据解压和播放; 各个函数之间不是并发执行,影响资源的使用效率; 那改进成多进程的方式:\n多进程实现方式\n对于多进程的这种方式,依然会存在问题:\n进程之间如何通信,共享数据? 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,回收资源、撤销 PCB;进程切换时,保存当前进程的状态信息; 那到底如何解决呢?需要有一种新的实体,满足以下特性:\n实体之间可以并发运行; 实体之间共享相同的地址空间; 这个新的实体,就是线程( *Thread* ),线程之间可以并发运行且共享相同的地址空间。\n什么是线程? 线程是进程当中的一条执行流程。\n同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程都有独立一套的寄存器和栈,这样可以确保线程的控制流是相对独立的。\n多线程\n线程的优缺点?\n线程的优点:\n一个进程中可以同时存在多个线程; 各个线程之间可以并发执行; 各个线程之间可以共享地址空间和文件等资源; 线程的缺点:\n当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃。 举个例子,对于游戏的用户设计,则不应该使用多线程的方式,否则一个用户挂了,会影响其他同个进程的线程。\n线程与进程的比较 线程与进程的比较如下:\n进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位; 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈; 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系; 线程能减少并发执行的时间和空间开销; 对于,线程相比进程能减少开销,体现在:\n线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们; 线程的终止时间比进程快,因为线程释放的资源相比进程少很多; 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的; 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了; 所以,线程比进程不管是时间效率,还是空间效率都要高。\n线程的上下文切换 在前面我们知道了,线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。\n所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。\n对于线程和进程,我们可以这么理解:\n当进程只有一个线程时,可以认为进程就等于线程; 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的; 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。\n线程上下文切换的是什么?\n这还得看线程是不是属于同一个进程:\n当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样; 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据; 所以,线程的上下文切换相比进程,开销要小很多。\n线程的实现 主要有三种线程的实现方式:\n用户线程(*User Thread*):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理; 内核线程(*Kernel Thread*):在内核中实现的线程,是由内核管理的线程; 轻量级进程(*LightWeight Process*):在内核中来支持用户线程; 那么,这还需要考虑一个问题,用户线程和内核线程的对应关系。\n首先,第一种关系是多对一的关系,也就是多个用户线程对应同一个内核线程:\n多对一\n第二种是一对一的关系,也就是一个用户线程对应一个内核线程:\n一对一\n第三种是多对多的关系,也就是多个用户线程对应到多个内核线程:\n多对多\n用户线程如何理解?存在什么优势和缺陷?\n用户线程是基于用户态的线程管理库来实现的,那么线程控制块(*Thread Control Block, TCB*) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。\n所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。\n用户级线程的模型,也就类似前面提到的多对一的关系,即多个用户线程对应同一个内核线程,如下图所示:\n用户级线程模型\n用户线程的优点:\n每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统; 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快; 用户线程的缺点:\n由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢; 以上,就是用户线程的优缺点了。\n那内核线程如何理解?存在什么优势和缺陷?\n内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。\n内核线程的模型,也就类似前面提到的一对一的关系,即一个用户线程对应一个内核线程,如下图所示:\n内核线程模型\n内核线程的优点:\n在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行; 分配给线程,多线程的进程获得更多的 CPU 运行时间; 内核线程的缺点:\n在支持内核线程的操作系统中,由内核来维护进程和线程的上下问信息,如 PCB 和 TCB; 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大; 以上,就是内核线的优缺点了。\n最后的轻量级进程如何理解?\n轻量级进程(*Light-weight process,LWP*)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。\n另外,LWP 只能由内核管理并像普通进程一样被调度,Linux 内核是支持 LWP 的典型例子。\n在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。\n在 LWP 之上也是可以使用用户线程的,那么 LWP 与用户线程的对应关系就有三种:\n1 : 1,即一个 LWP 对应 一个用户线程; N : 1,即一个 LWP 对应多个用户线程; N : N,即多个 LMP 对应多个用户线程; 接下来针对上面这三种对应关系说明它们优缺点。先下图的 LWP 模型:\nLWP 模型\n1 : 1 模式\n一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。\n优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP; 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。 N : 1 模式\n多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。\n优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高; 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。 M : N 模式\n根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。\n优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。 组合模式\n如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。开发人员可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。\n调度 进程都希望自己能够占用 CPU 进行工作,那么这涉及到前面说过的进程上下文切换。\n一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。\n选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序(scheduler)。\n那到底什么时候调度进程,或以什么原则来调度进程呢?\n调度时机 在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。\n比如,以下状态的变化都会触发操作系统的调度:\n从就绪态 -\u0026gt; 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行; 从运行态 -\u0026gt; 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须另外一个进程运行; 从运行态 -\u0026gt; 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行; 因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。\n另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:\n非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制。 调度原则 原则一:如果运行的程序,发生了 I/O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回。这样的过程,势必会造成 CPU 突然的空闲。所以,为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。\n原则二:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低。所以,要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。\n原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。\n原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。\n原则五:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。\n五种调度原则\n针对上面的五种调度原则,总结成如下:\nCPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率; 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量; 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好; 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意; 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。 说白了,这么多调度原则,目的就是要使得进程要「快」。\n调度算法 不同的调度算法适用的场景也是不同的。\n接下来,说说在单核 CPU 系统中常见的调度算法。\n01 先来先服务调度算法\n最简单的一个调度算法,就是非抢占式的先来先服务(*First Come First Severd, FCFS*)算法了。\nFCFS 调度算法\n顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。\n这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。\nFCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。\n02 最短作业优先调度算法\n最短作业优先(*Shortest Job First, SJF*)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。\nSJF 调度算法\n这显然对长作业不利,很容易造成一种极端现象。\n比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。\n03 高响应比优先调度算法\n前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。\n那么,高响应比优先 (*Highest Response Ratio Next, HRRN*)调度算法主要是权衡了短作业和长作业。\n每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:\n从上面的公式,可以发现:\n如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行; 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会; 04 时间片轮转调度算法\n最古老、最简单、最公平且使用最广的算法就是时间片轮转(*Round Robin, RR*)调度算法。 。\nRR 调度算法\n每个进程被分配一个时间段,称为时间片(*Quantum*),即允许该进程在该时间段中运行。\n如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程; 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换; 另外,时间片的长度就是一个很关键的点:\n如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率; 如果设得太长又可能引起对短作业进程的响应时间变长。将 通常时间片设为 20ms~50ms 通常是一个比较合理的折中值。\n05 最高优先级调度算法\n前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。\n但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(*Highest Priority First,HPF*)调度算法。\n进程的优先级可以分为,静态优先级或动态优先级:\n静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化; 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。 该算法也有两种处理优先级高的方法,非抢占式和抢占式:\n非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。 但是依然有缺点,可能会导致低优先级的进程永远不会运行。\n06 多级反馈队列调度算法\n多级反馈队列(*Multilevel Feedback Queue*)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。\n顾名思义:\n「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列; 多级反馈队列\n来看看,它是如何工作的:\n设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短; 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成; 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行; 可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。\n看的迷迷糊糊?那我拿去银行办业务的例子,把上面的调度算法串起来,你还不懂,你锤我!\n办理业务的客户相当于进程,银行窗口工作人员相当于 CPU。\n现在,假设这个银行只有一个窗口(单核 CPU ),那么工作人员一次只能处理一个业务。\n银行办业务\n那么最简单的处理方式,就是先来的先处理,后面来的就乖乖排队,这就是先来先服务(*FCFS*)调度算法。但是万一先来的这位老哥是来贷款的,这一谈就好几个小时,一直占用着窗口,这样后面的人只能干等,或许后面的人只是想简单的取个钱,几分钟就能搞定,却因为前面老哥办长业务而要等几个小时,你说气不气人?\n先来先服务\n有客户抱怨了,那我们就要改进,我们干脆优先给那些几分钟就能搞定的人办理业务,这就是短作业优先(*SJF*)调度算法。听起来不错,但是依然还是有个极端情况,万一办理短业务的人非常的多,这会导致长业务的人一直得不到服务,万一这个长业务是个大客户,那不就捡了芝麻丢了西瓜\n最短作业优先\n那就公平起见,现在窗口工作人员规定,每个人我只处理 10 分钟。如果 10 分钟之内处理完,就马上换下一个人。如果没处理完,依然换下一个人,但是客户自己得记住办理到哪个步骤了。这个也就是时间片轮转(*RR*)调度算法。但是如果时间片设置过短,那么就会造成大量的上下文切换,增大了系统开销。如果时间片过长,相当于退化成退化成 FCFS 算法了。\n时间片轮转\n既然公平也可能存在问题,那银行就对客户分等级,分为普通客户、VIP 客户、SVIP 客户。只要高优先级的客户一来,就第一时间处理这个客户,这就是最高优先级(*HPF*)调度算法。但依然也会有极端的问题,万一当天来的全是高级客户,那普通客户不是没有被服务的机会,不把普通客户当人是吗?那我们把优先级改成动态的,如果客户办理业务时间增加,则降低其优先级,如果客户等待时间增加,则升高其优先级。\n最高优先级(静态)\n那有没有兼顾到公平和效率的方式呢?这里介绍一种算法,考虑的还算充分的,多级反馈队列(*MFQ*)调度算法,它是时间片轮转算法和优先级算法的综合和发展。它的工作方式:\n多级反馈队列\n银行设置了多个排队(就绪)队列,每个队列都有不同的优先级,各个队列优先级从高到低,同时每个队列执行时间片的长度也不同,优先级越高的时间片越短。 新客户(进程)来了,先进入第一级队列的末尾,按先来先服务原则排队等待被叫号(运行)。如果时间片用完客户的业务还没办理完成,则让客户进入到下一级队列的末尾,以此类推,直至客户业务办理完成。 当第一级队列没人排队时,就会叫号二级队列的客户。如果客户办理业务过程中,有新的客户加入到较高优先级的队列,那么此时办理中的客户需要停止办理,回到原队列的末尾等待再次叫号,因为要把窗口让给刚进入较高优先级队列的客户。 可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端的现象,可以说是综合上面几种算法的优点。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E8%BF%9B%E7%A8%8B%E4%B8%8E%E7%BA%BF%E7%A8%8B%E5%9F%BA%E7%A1%80%E7%9F%A5%E8%AF%86/","summary":"前言 先来看看一则小故事 我们写好的一行行代码,为了让其工作起来,我们还得把它送进城(进程)里,那既然进了城里,那肯定不能胡作非为了。 城里人有城","title":"进程与线程基础知识"},{"content":"介绍 什么是高并发,从字面上理解,就是在某一时刻产生大量的请求,那么多少量称为大量,业界并没有标准的衡量范围。原因非常简单,不同的业务处理复杂度不一样。\n而我所理解的高并发,它并不只是一个数字,而更是一种架构思维模式,它让你在面对不同的复杂情况下,从容地选择不同的技术手段,来提升应用系统的处理能力。\n但是,并不意味应用系统从诞生的那一刻,就需要具备强大的处理能力,这种做法并不提倡。要知道,脱离实际情况的技术,会显得毫无价值,甚至是一种浪费的表现。\n言归正传,那高并发到底是一种怎样的架构思维模式,它对架构设计又有什么影响,以及如何通过它来驱动架构演进,让我们接着往下读,慢慢去体会这其中的精髓。\n性能是一种基础 在架构设计的过程中,思考固然重要,但目标更为关键。通过目标的牵引力,可以始终确保推进方向,不会脱离成功的轨道。那高并发的目标是什么,估计你的第一反应就是性能。\n没错,性能是高并发的目标之一,它不可或缺,但并不代表所有。而我将它视为是高并发的一种基础能力,它的能力高低将会直接影响到其他能力的取舍。例如:服务可用性,数据一致性等。\n性能在软件研发过程中无处不在,不管是在非功能性需求中,还是在性能测试报告中,都能见到它的身影。那么如何来衡量它的高低呢,先来看看常用的性能指标。\n每秒处理事务数(TPS) 每秒能够处理的事务数,其中T(Transactions)可以定义不同的含义,它可以是完整的一笔业务,也可以是单个的接口请求。\n每秒请求数(RPS) 每秒请求数量,也可以叫做QPS,但它与TPS有所不同,前者注重请求能力,后者注重处理能力。不过,若所有请求都在得到响应后再次发起,那么RPS基本等于TPS。\n响应时长(RT) 从发出请求到得到响应的耗时,一般可以采用毫秒单位来表示,而在一些对RT比较敏感的业务场景下,可以使用精度更高的微秒来表示。\n并发用户数(VU) 同时请求的用户数,很多人将它与并发数画上等号,但两者稍有不同,前者关注客户端,后者关注服务端,除非每个用户仅发送一笔请求,且请求从客户端到服务端没有延迟,同时服务端有足够的处理线程。\n以上都是些常用的性能指标,基本可以覆盖80%以上的性能衡量要求。但千万不要以单个指标的高低来衡量性能。比如:订单查询TPS=100万就认为性能很高,但RT=10秒。\n这显然毫无意义。因此,建议同时观察多个指标的方式来衡量性能的高低,大多数情况下主要会关注TPS和RT,而你可以将TPS视为一种水平能力,注重并行处理能力,将RT视为一种垂直能力,注重单笔处理能力,两者缺一不可。\n接触过性能测试的同学,可能会见过如下这种性能测试结果图,图中包含了刚才提到过的三个性能指标,其中横坐标为VU,纵坐标分别为TPS和RT。 图中的两条曲线,在不断增加VU的情况下,TPS不断上升,但RT保持稳定,但当VU增加到一定量级的时候,TPS开始趋于稳定,而RT不断上升。\n如果你仔细观察,还会发现一个奇妙的地方,当RT=25ms时,它们三者存在着某种关系,即:TPS=VU/RT。但当RT\u0026gt;25ms时,这种关系似乎被打破了,这里暂时先卖个关子,稍后再说。\n根据表格中的数据,性能测试报告结论:最大TPS=65000,当RT=25ms(最短)时,最大可承受VU=1500。\n感觉有点不对劲,用刚才的公式来验证一下,1500/0.025s=60000,但最大却是TPS=65000。那是因为,当VU=1500时,应用系统的使用资源还有空间。\n再来观察一下表格中的数据,VU从1500增加到1750时,TPS继续上升,且到了最大值65000。此时,你是不是会理解为当VU增加到1750时,使用资源被耗尽了。话虽没错,但不严谨。\n注:使用资源不一定是指硬件资源,也可能是其他方面,例如:应用系统设置的最大处理线程。\n其实在VU增加到1750前,使用资源就已饱和,那如何来测算VU的临界值呢。你可以将最大TPS作为已知条件,即:VU=TPS * RT,65000*0.025s=1625。也就是说,当VU=1625时,使用资源将出现瓶颈。\n调整性能测试报告结论:最大TPS=65000,当RT=25ms(最短)时,最大可承受VU=1625。\n有人会问,表格中的RT是不是平均值,首先回答为是。不过,高并发场景对RT会特别敏感,所以除了要考虑RT的平均值外,建议还要考虑它的分位值,例如:P99。\n举例:假设1000笔请求,其中900笔RT=23ms,50笔RT=36ms,50笔RT=50ms\n平均值 P99值 P95 P90 25ms 50ms 36ms 23ms P99的计算方式,是将1000笔请求的RT从小到大进行排序,然后取排在第99%位的数值,基于以上举例数据来进行计算,P99=50ms,其他分位值的计算方式类似。\n再次调整性能测试报告结论:最大TPS=65000,当RT(平均)=25ms(最短)时,最大可承受VU=1625,RT(P99)=50ms,RT(P95)=36ms,RT(P90)=23ms。\n在非功能性需求中,你可能会看到这样的需求,性能指标要求:RT(平均)\u0026lt;=30。结合刚才的性能测试报告结论,当RT(平均)=25ms(最短)时,最大可承受VU=1625。那就等于在RT上还有5ms的容忍时间。\n既然是这样的话,那我们不妨就继续尝试增加VU,不过RT(平均)会出现上升,但只要控制不要上升到30ms即可,这是一种通过牺牲耗时(RT)来换取并发用户数(VU)的行为。但请不要把它理解为每笔请求耗时都会上升5ms,这将是一个严重的误区。\nRT(平均)的增加,完全可能是由于应用系统当前没有足够的使用资源来处理请求所造成的,例如:处理线程。如果没有可用线程可以分配给请求时,就会将这请求先放入队列,等前面的请求处理完成并释放线程后,就可以继续处理队列中的请求了。\n那也就是说,没有进入队列的请求并不会增加额外的耗时,而只有进入队列的请求会增加。那么进入队列的请求会增加多少耗时呢,在理想情况下(RT恒定),可能会是正常处理一笔请求耗时的倍数,而倍数的大小又取决于并发请求的数量。\n假设最大处理线程=1625,若每个用户仅发送一笔请求,且请求从客户端到服务端没有延迟的条件下,当并发用户数=1625时,能够保证RT=25ms,但当并发用户数\u0026gt;1625时,因为线程只能分配给1625笔请求,那多余的请求就无法保证RT=25ms。\n超过1625笔的请求会先放入队列,等前面1625笔请求处理完成后,再从队列中拿出最多1625笔请求进行下一批处理,如果队列中还有剩余请求,那就继续按照这种方式循环处理。\n进入队列的请求,每等待一批就需要增加前一批的处理耗时。在理想情况下,每一批都是RT=25ms,如果这笔请求在队列中等待了两批,那就要额外增加50ms的耗时。\n因此,并不能简单通过VU=TPS* RT=65000*0.03=1950来计算最大可承受VU。而是需要引入一种叫做科特尔法则(Little’s Law)的排队模型来估算,不过由于这个法则比较复杂,这里暂时不做展开。\n通过粗略估算后,VU大约在2032,我们再对这个值用上述表格中再反向验算一下。 最终调整性能测试报告结论:最大TPS=65000,当RT(平均)=25(最短)时,最大可承受VU=1625,RT(P99)=50,RT(P95)=36,RT(P90)=23;当RT(平均)=30(容忍)时,(理想情况)最大可承受VU=2032,RT(P99)=RT(P95)=50,RT(P90)=25。\n这就解释了为什么当RT\u0026gt;25ms时,VU=TPS*RT会不成立的原因。不过,这些都是在理想情况下推演出来的,实际情况会比这要复杂得多。\n所以,还是尽量采用多轮性能测试来得到性能指标,这样也更具备真实性。毕竟影响性能的因素实在大多且很难完全掌控,任何细微变化都将影响性能指标的变化。\n到这里,我们已经了解了可以用哪些指标来衡量性能的高低。不过,这里更想强调的是,性能是高并发的基础能力,是实现高并发的基础条件,并且你需要有侧重性地提升不同维度的性能指标,而非仅关注某一项。\n限制是一种设计 上文说到,性能是高并发的目标之一。追求性能没有错,但并非永无止境。想要提升性能,势必投入成本,不过它们并不是一直成正比,而是随着成本不断增加,性能提升幅度逐渐衰减,甚至可能不再提升。所以,有时间我们要懂得适可而止。\n思考一下,追求性能是为了解决什么问题,至少有一点,是为了让应用系统能够应对突发请求。换言之,如果能解决这个问题,是不是也算实现了高并发的目标。\n而有时候,我们在解决问题时,不要总是习惯做加法,还可以尝试做减法,架构设计同样如此。那么,如何通过做减法的方式,来解决应对突发请求的问题呢。让我们来讲讲限制。\n限制,从狭义上可以理解为是一种约束或控制能力。在软件领域中,它可以针对功能性或非功能性,而在高并发的场景中,它更偏向于非功能性。\n限制应用系统的处理能力,并不代表要降低应用系统的处理能力,而是通过某些控制手段,让突发请求能够被平滑地处理,同时起到应用系统的保护能力,避免瘫痪,还能将应用系统的资源进行合理分配,避免浪费。\n那么,到底有哪些控制手段,既能实现以上这些能力,又能减少对客户体验上的影响,下面就来介绍几种常用的控制手段。\n第一招:限流 限流,是在一个时间窗口内,对请求进行速率控制。若请求达到提前设定的阈值时,则对请求进行排队或拒绝。常用的限流算法有两种:漏桶算法和令牌桶算法。\n漏桶算法 所有请求先进入漏桶,然后按照一个恒定的速率对漏桶里的请求进行处理,是一种控制处理速率的限流方式,用于平滑突发请求速率。\n它的优点是,能够确保资源不会瞬间耗尽,避免请求处理发生阻塞现象,另外,还能够保护被应用系统所调用的外部服务,也免受突发请求的冲击。\n它的缺点是,对于突发请求仍然会以一个恒定的速率来进行处理,其灵活性会较弱一点,容易发生突发请求超过漏桶的容量,导致后续请求直接被丢弃。\n令牌桶算法 应用系统会以一个恒定的速率往桶里放入令牌,请求处理前,会从桶里获取令牌,当桶里没有令牌可取时,则拒绝服务,是一种平均流入速率的限流方式。\n它的优点是,在限制平均流入速率的同时,还能在面对突发请求的情况下,确保资源被充分利用,不会被闲置或浪费。\n它的缺点是,舍弃了处理速率的强控制能力,那么如果某些功能依赖外部服务,可能将会让外部服务无法承受压力,导致无法正常返回,而且还浪费了这次获取的令牌。\n综上,两种算法并没有绝对的好坏,而是需要根据实际的情况,选择合适的方式,从而在发挥限流作用的同时不会引发其他问题。但在一些秒杀活动中,软件党的高频请求,会很容易触发限流,导致大量正常请求被误杀的问题。\n虽然在请求被限流后,会返回友好话术,减轻对客户体验的影响,但也有可能他们的请求,会一直无法得到有效处理,这时候耐心再好的客户也会离开及抱怨。\n所以,我们除了使用限流这招外,还得搭配其他的招数组合一起使用,从而让应用系统能够对资源进行合理分配,避免资源浪费,减少正常请求被误杀的情况。\n第二招:降频 降频,是在一个时间窗口内,对同一特征的请求进行速率控制。若请求达到提前设定的阈值时,则会对请求进行拒绝。\n虽然和限流有点类似,但存在着细微的差别。对限流而言,它并不关心请求方,而只对服务端的速率进行控制,而对降频而言,它会基于某种特征,对请求方的请求速率进行控制。\n而降频的目的,是为了减少应用系统资源被不正常的请求所消耗,而导致正常的请求因限流被拒绝的情况发生。它的实现方式也有多种,而且在前端和后端都可以使用。\n识别不正常的请求是降频的第一步,也是最关键的一步。一般会制定某种特征+某段时间+请求数量这种三段式的识别规则。\n特征可以是账号、会话、IP地址、设备号等,时间一般会是1秒,也可以设置更长。账号+1秒+5笔,意思就是同一个账号在1秒内可以发生5笔请求,但是这里请求数量与限流的设定参考依据不同。\n限流大小主要依据性能来决定,而降频中的请求数量,一般会以正常人的交互速率作为参考。所以,并不能因为性能好,就设定账号+1秒+100笔这种识别规则,这不但不科学还会浪费资源。\n接下来,有了识别规则还得搭配对应的处置手段,常见的有两种模式:挑战和拒绝。 限流会发生误杀,难道降频就不会吗,其实也会发生,特别是用户的网络环境是一个出口IP地址时。所以,如果是基于IP地址特征的识别规则,请求数量建议适当放大。\n在降频策略方面,建议配置多层+渐进式的方式,识别规则较为严格的采用挑战模式,识别规则较为宽松的采用拒绝模式,减少因降频而引发的误杀情况,参考如下: 降频确实可以使应用系统的资源,被合理地分配给请求方,但并不能保证万无一失,特别对于那些技术高超的软件党们,他们仍然可以通过其他方式绕开这种控制手段。\n不过,你可以将此视为一种攻防战,通过增强防守的方式,来提高攻击者成本,而攻击者一定会权衡成本和收益,当成本大于收益时,可能就不会有攻击,毕竟没有人会这么无聊透顶。\n虽然有了限流和降频这两招,但仍可能无法应对高并发的场景,况且在初期,限流和降频的策略,也无法设计得非常完美。所以,有些时候还得使出最后一招。\n第三招:降级 降级,是当应用系统处理超载时,对其服务进行裁剪的一种机制。常见的是应用系统处理阻塞时,会关闭非核心服务,并将资源给到核心服务,从而确保核心服务正常。\n经常有人将它与熔断混为一谈,但并非一回事。降级主要是针对应用系统本身,若处理能力不足则可触发,而熔断主要是针对应用系统所调用的外部服务,若外部服务不稳定时则可触发。\n当然,两者也有一定的关系,因为当发生熔断时,也可以触发降级机制,比如当同步调用外部服务出现性能问题时,可以降级为异步调用,避免造成线程阻塞而瘫痪。\n不过在降级前,必须得先梳理应用系统中的核心服务,可以采用经典的二八原则,将服务划分为20%核心服务+80%非核心服务。而这种分法的意图,是希望让你找到真正重要的核心服务,不然,你会觉得都很重要。\n在梳理过程中,建议通过多个维度来进行综合评判,如下是我经常采用的一种梳理方法,你可以将此作为一种参考,并结合自己的服务分类标准进行调整。\n首先,可以设计一张类似如下的矩阵图,请尽量地简约它,将应用系统中的各类服务,按照矩阵所设定的不同属性进行分门别类。 然后,将业务类+操作类的挑选出来作为核心服务,你会不会认为这就结束了。不好意思,游戏才刚刚开始。不过你可以试想一下,假设仅保留这些核心服务,会出现什么问题。\n用户登录不了无法订单支付,订单查询不了无法订单退货。所以,我们还需引入服务关键路径的概念,可以理解为在使用某个服务前,还必须要使用的其他服务。\n分别对挑选出来的核心服务,进行服务关键路径的梳理。 路径1:用户登录——\u0026gt;商品查询——\u0026gt;订单下单 路径2:用户登录——\u0026gt;商品查询——\u0026gt;订单下单——\u0026gt;订单支付 路径3:用户登录——\u0026gt;订单查询——\u0026gt;订单退货\n待服务关键路径梳理完成后,再对路径上的所有服务进行合并及去重,将会得到一组新的核心服务:用户登录/商品查询/订单下单/订单支付/订单查询/订单退货。\n来计算下核心服务的占比,所有服务14个/核心服务6个,占42.86%,远远超过了20%。所以,建议继续从这些核心服务中,识别更核心的部分,但仍然以服务关键路径为整体。\n相比订单下单/订单支付/订单退货这三条服务关键路径,我想订单支付可能会更有价值。最后,我们可以仅将订单支付这条服务关键路径上的服务作为核心服务。\n重新再来计算下核心服务的占比,所有服务14个/核心服务4个,占28.57%,虽然还是超过了20%,但这并不是重点,重点是我们已经找到了最核心的服务。\n其余的核心服务,可以降级为准核心服务,重组后得到如下这份服务重要程度清单。 当拥有这份清单后,若应用系统处理阻塞时,就可以按照非核心服务\u0026gt;准核心服务\u0026gt;核心服务这个顺序依次进行降级。不过,降级不一定要拒绝请求,也可以是限流请求,这样可以减少对服务能力的裁剪力度。\n以上只是一种相对较粗的降级策略,如果你想要制定更精细化的降级策略,还需要对每个服务进行优先级的设定,高低依据可以结合自身需要来制定,例如:历史服务使用情况。\n当有了限流、降频、降级这三招,基本就能够在资源有限的情况下,让突发请求能够被平滑地处理,将应用系统的资源能够被合理地分配,以及当应用系统处理堵塞时,确保核心服务正常。\n取舍是一种权衡 现在,我们已经基本了解了如何衡量性能的指标,以及大致掌握了如何保护应用系统的招数。但这些就是高并发全部了吗,我想说这仅仅只是入门级别。\n上述内容,主要是为了让你能够清晰地看到应用系统的性能水位在哪里,以及在资源有限下,当面对突发请求时可以采取哪些招数,能让应用系统安全地存活下来。\n存活,即代表着可用性,它也是高并发的一个特性,而且是我认为相对比较重要的特性。设想一下,如果你的应用系统连可用性都无法保证,那再高的性能又有何意义。\n对于大部分应用系统而言,大家都会比较关注应用系统的可用性,99.9%不够那就99.99%,甚至还有想做到99.999%的,毕竟可用性的不足会直接影响到业务运作。\n但对于一个想成为高并发的应用系统而言,仅单方面关注高可用,肯定无法称得上这个头衔,它仍然还需要在其他特性上具备极佳的表现。\n让我们拉回到最初的目标,性能是高并发的目标之一,不过,这里我们不再谈论性能指标,而是来研究如何来提升性能。因为,高性能是高并发的另外一个重要特性。\n想要提升性能,势必投入成本。随着成本不断增加,性能提升幅度逐渐衰减。这两句话,是不是觉得有点耳熟,但不管你是否还记得,先让我在这里打个问号再说。\n在架构设计的过程中,你是否经常会听到“取舍”的这个词,它是通过牺牲一种能力来换取另外一种能力的方式,这些能力可以是性能、可用性、数据一致性或是其他能力。\n等等,你是不是突然有意识到,提升性能并不只有投入成本这种方式,至少是在硬件资源方面,我们还可以通过牺牲一种能力去换取。那到底选择牺牲哪种能力呢,牺牲可用性,一般不会第一时间考虑,那是不是可以考虑牺牲数据一致性。\n但在考虑前得先声明一下,所谓牺牲数据一致性,并不是完全不要,而是将数据强一致性降级为最终一致性。而对于数据最终一致性的理解,就是在数据更新后,要过一段时间后才能看到,而时间的长短就代表着牺牲了多少。\n但并不是说,所有情况都必须牺牲数据一致性来提升性能,有些时候也可以考虑牺牲其他能力。但在取舍前,得先弄清楚当前要什么,但更需要弄清楚当前可以失去什么,不合理的取舍,不但无法换取收益,反而还会引来更多的问题。\n情况1:数据缓存 缓存,是高并发架构设计中一种不可或缺的能力,一般是指那些经常被访问的热点数据,可以将它放入缓存中,从而提升数据被读取的效率。\n但是否所有的数据都适合放入缓存中,如果是静态数据,那么你可以很安心地放入。原因很简单,静态数据不会更新,那么缓存和数据源始终保持一致,而且就算缓存中的数据丢失了,至少还有一份在数据源。\n通过将静态数据缓存,可以很轻易地提升静态数据的访问性能,甚至可能是几十倍的效果。但应用系统中还有大量的动态数据,仅提升静态数据可能对总体的提升并不一定显著。\n你是不是想说,那就把动态数据也放入缓存中不就行了。在下这个决定前,建议你先想一下,动态数据是会更新的,这就意味着动态数据在放入缓存后,当数据源中的数据被更新后,再次访问返回的都是更新前的数据,这种效果你是否可以接受。\n我想应该没有人会接受吧,而你是不是又想说,设置下缓存会过期不就能解决了。没错,但得等过期后才能解决,那还没过期前呢,这种方式只能缓解,但并不能根治,而且还会引入一个新的问题,请问过期设置多久才合适。\n设置缓存5秒钟过期,可能永远无法命中缓存,而且不但没有提升性能,还增加了代码复杂度,有点画蛇添足的感觉。设置缓存5分钟过期,命中缓存的几率可能会提高,但缓存后在5分钟内的如果数据更新,要从缓存开始往后推5分钟才能看到。\n即:第1分钟缓存,第1-6分钟内的任何数据更新,要第6分钟后才能看到。\n所以,如果你无法容忍这种情况,请你不要滥用缓存,虽然性能提高了,但问题可能也出现了。反之,如果你可以容忍这种情况,那就可以这么操作,而至于过期设置多久,可以结合业务场景及使用频率综合来评估,毕竟不同的业务容忍度是不同的。\n对于是否要将动态数据进行缓存,本质上,其实就是一种取舍,是一种性能与数据一致性的权衡,而缓存的过期时长,就像是保持这种平衡的支点,从而让这种牺牲变得更有意义。\n情况2:单机限流 限流,前面已经有介绍过,它有两种常用的限流算法,漏桶算法和令牌桶算法。不过,这两种算法都仅支持单机限流,不支持全局限流。\n单机限流,就是对单节点设定一个限流阈值,如果单节点上的请求到达阈值,则会拒绝请求。例如:限流阈值=每秒100次请求,如果在1秒内单节点上,有第101次的请求则拒绝。\n全局限流,就是对一组节点设定一个限流总阈值,如果这组节点上的汇总请求到达阈值,则会拒绝请求。例如:10个节点的限流总阈值=1000次请求,如果在1秒内这组节点上,汇总有第1001次请求则拒绝,不过单节点上有超过第100次的请求也会接受。\n这么看下来,感觉单机限流控制能力更厉害一点,它能保证单节点的请求不会超过100次。而全局限流在极端情况下,单节点都有可能在1秒内会接受1000次请求。当然,这种情况的可能性比较低,比如在突发请求时,9个节点同时宕机。\n既然如此,那全局限流有存在的意义吗,难道这就是漏桶算法和令牌桶算法都不支持全局限流的原因。全局限流就真的没有存在的意义吗。存在即合理,既然存在,那就一定有它存在的道理。\n换个情况,还是将10个节点为一组,不过这次换成采用单机限流。问题来了,每个节点的限流阈值该如何设定,如果采用平均分配,则限流阈值=每秒100次请求,让我们来测试一下,在1秒内依次发出1000次请求,会发生什么现象。\n结果是在第100次请求后,从第101次到第1000次的请求中,可能有些请求会发生被拒绝的情况,而且请求一会儿成功一会儿拒绝,没有任何规律。原因可能是10个节点请求负载不均所引发的,导致某个节点提前超过了100次请求。\n基于以上情况,最终1000次请求没有全部成功,这种情况等同于降低了应用系统的吞吐能力。而在实际情况中,就算采用轮询的负载算法,请求数不均的可能性仍然还是会存在的。这么一看,单机限流好像也有缺陷。\n估计你已经被我说晕了吧,让我们整体再重新梳理一遍,并对两种不同限流模式的影响进行对比。不过,这次还加上每秒不同的请求数量。 两种限流对比下来,单机限流更强调单机的控制范围,但可能会造成额外的请求拒绝,但对单节点不会造成性能压力,而全局限流更强调整体的控制范围,虽不会造成额外的请求拒绝,但可能会对单节点造成性能压力,引发性能过载。\n除此之外,全局限流还是一种采用中心化的设计思路,因此在网络开销方面,还会产生额外的性能损耗,这种损耗在请求量少的时候估计还可以容忍,但在高并发的情况下可能是场灾难,因为在每次限流判断前,还会产生一次网络开销。\n所以,不能为了想要实现更精准的限流,就盲目地采用全局限流,它将在高并发的情况下损耗更多的性能。而单机限流所额外造成的少量请求拒绝,在某些情况下,可以考虑采用某些技术手段进行补偿。\n不过,不管是单机限流还是全局限流,似乎都和数据一致性没有关系。但事实上,全局限流这种精准限流的方式,也可以视为另一种一致性的表现,而单机限流就是通过对这种一致性的牺牲,来减少性能损耗,何尝不是提升性能的另一种方式。\n以上,只是简单列举了两种不同情况下的取舍,而在高并发架构上,可取舍的地方远不止这些。你得知道,高并发的每一处设计或每一份设计方案的背后,都曾是通过不断地取舍所获得的,而没有取舍的高并发架构决策,将会显得毫无说服力。\n取舍不但可以作为高并发架构决策的有力武器,也将是驱动架构演进最合理的一种方式。但要切记,取舍的方向并不是一成不变的,而是会随着外界环境的变化而变化,它将是一种独特的艺术。\n写在最后 高并发的魅力之处,就在于它没有唯一的答案,而答案是需要我们以不同的业务场景作为线索去不断地寻找,这种寻找的过程也是一种不断思考的过程,这就是我为什么说高并发是一种架构思维模式。\n本文从浅到深依次讲述了性能是实现高并发的基础条件,控制是实现资源最大化利用的方式,以及如何通过取舍来换取当前应用系统更所需的能力,但这些仅仅只是高并发世界里的一个角落。因篇幅有限,今天就暂告一段落。\n最后想说,高并发其实并不可怕,可怕的是你知其然而不知其所以然。对于追求技术的你,需要不断地拓宽你的技术深度与广度,才能更好地掌握高并发,以及运用高并发的思维模式来提升应用系统处理能力。\n","permalink":"https://reid00.github.io/en/posts/os_network/%E9%AB%98%E5%B9%B6%E5%8F%91%E6%9E%B6%E6%9E%84/","summary":"介绍 什么是高并发,从字面上理解,就是在某一时刻产生大量的请求,那么多少量称为大量,业界并没有标准的衡量范围。原因非常简单,不同的业务处理复杂","title":"高并发架构"},{"content":"板瓦工以及产品介绍 该商家隶属于美国IT7公司旗下的一款便宜年付KVM架构的VPS主机商家,从2013年开始推出低价VPS主机配置进入市场,确实受到广大网友的喜欢,且在最近几年开始改变策略,取消低价配置,然后以高配置和优化线路速度。以前我们搬瓦工VPS主机的用户感觉可能并不是特别适应,因为以前喜欢他们是因为便宜,如今价格比较高,但是线路和速度比较好。\n搬瓦工VPS商家支持支付宝、微信、PAYPAL、银联以及信用卡多种付款方式,这个也是很多国内用户选择的原因之一。目前最低配置是年付49.99美元,价格上肯定没有早年便宜,但是性价比在同行中还是具有一定优势的。搬瓦工VPS主机的特点在于线路还是不错的,而且带宽最高10Gbps,支持切换到其他机房,全部是自己操作。我们一定要正规使用。\n**搬瓦工VPS当前库存查看列表:**https://www.laozuo.org/go/bandwagonhost-cart\n搬瓦工vps主机方案分享 CN2 GIA ECOMMERCE(推荐) CPU:2核 内存:1GB 硬盘:20GB SSD 流量:1000GB 端口:2.5Gbps 架构:KVM+KiwiVM面板 IP数:1独立IP 系统:Linux 价格:$65.99/半年(购买) CPU:3核 内存:2GB 硬盘:40GB SSD 流量:2000GB 端口:2.5Gbps 架构:KVM+KiwiVM面板 IP数:1独立IP 系统:Linux 价格:$69.99/季度(购买) 这个配置方案,我们可以看到2.5Gbps带宽起步,最高达到10Gbps,同时我们可以看到一共有7个方案,根据配置不同有区别的。相比一般的配置方案,我们可以看到带宽确实比较高,而且是CN2 GIA优化线路,如果有需要大带宽方案的可以选择,而且这个方案可以切换到其他机房。\n2、KVM普通线路(8机房可切CN2 GT)\nCPU:2核\n内存:1024MB\n硬盘:20GB SSD\n流量:1000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$49.99/年(购买)\nCPU:3核\n内存:2048MB\n硬盘:40GB SSD\n流量:2000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$27.99/季度(购买)\nKVM普通方案有目前最低年付49.99方案,2018年12月份下架原来年付19.99方案。入门VPS可选方案,有8个机房可以切换,可以切换至单程CN2 GT线路。\n3、CN2 GIA优化线路(三网直连双程CN2)\nCPU:1核心\n内存:512MB\n硬盘:10GB SSD\n流量:300GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$39.99/年(限量缺货)\nCPU:2核心\n内存:1024MB\n硬盘:20GB SSD\n流量:1000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$25.99/季度(CN2 GIA)\n目前中美线路中较好的就是CN2 GIA线路机房方案,双程CN2,且三网直连,特价限量方案容易缺货,季付19.99方案好像一直有货。方案购买之后可以切换10个机房,但是一般我们肯定用CN2 GIA,毕竟买它就是追求速度和稳定的。\n4、CN2 GT(单程优化线路CN2)\nCPU:1核心\n内存:512MB\n硬盘:10GB SSD\n流量:500GB\n端口:1Gbps\n架构:KVM\n系统:Linux\nIP数:1独立IP\n价格:$29.99/年(下架缺货)\nCPU:1核心\n内存:1024MB\n硬盘:20GB SSD\n流量:1000GB\n端口:1Gbps\n架构:KVM\n系统:Linux\nIP数:1独立IP\n价格:$49.99/年(CN2 GT)\nCN2 GT线路白天基本上没有多大差别,老左测试后发现晚上相对CN2 GIA还是有点诧异的。采用单程CN2线路,这个方案套餐有9个机房可以切换,不可以切换到CN2 GIA。\n5、香港机房(PCCW)\nCPU:2核\n内存:2GB\n硬盘:40GB\n流量:500GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$89.99/月(香港机房)\nCPU:4核\n内存:4GB\n硬盘:80GB\n流量:1000GB\n端口:1Gbps\n架构:KVM+KiwiVM面板\nIP数:1独立IP\n系统:Linux\n价格:$159.99/月(香港机房)\n搬瓦工VPS主机香港机房,不可以切换到其他机房,其他配置也不可以切换到香港机房。线路是PCCW,速度确实还是不错,但是价格成本也高,毕竟是1Gbps带宽,适合预算比较充足的用户和速度用户。\n搬瓦工VPS优惠码/活动 通过上面介绍和整理的搬瓦工当前已有方案配置,我们可以看到有各种机房可以选择,但是之间还是有差异的。目前大部分方案都是多机房可选的,但是之间也有不可以互通切换,早期如果我们购买的单机房的,是不可以切换多机房的。如果我们需要购买可以顺带使用BWH3HYATVBJW(目前优惠力度最大的6.58%)优惠码,如果点击看到是OUT OF STOCK就表示无货。\n购买教程 (推荐购买SPECIAL 20G KVM PROMO V3 - LOS ANGELES - CN2)\n注册搬瓦工 https://bwh88.net/index.php\n方案推荐\n方案 内存 CPU 硬盘 流量/月 带宽 价格 机房 购买 CN2 (最便宜) 1GB 1核 20GB 1TB 1Gbps $49.99/年 DC8 CN2 DC3 CN2 购买 CN2 2GB 1核 40GB 2TB 1Gbps $52.99/半年 $99.99/年 DC8 CN2 DC3 CN2 购买 CN2 GIA-E (最推荐) 1GB 2核 20GB 1TB 2.5Gbps $65.99/半年 $119.99/年 DC6 CN2 GIA-E DC9 CN2 GIA 购买 (缺货) CN2 GIA-E 2GB 3核 40GB 2TB 2.5Gbps $69.99/季度 $229.99/年 DC6 CN2 GIA-E DC9 CN2 GIA 购买 CN2 GIA 4GB 4核 80GB 3TB 1Gbps $32.99/月 $339.99/年 DC9 CN2 GIA 购买 (缺货) CN2 GIA 8GB 6核 160GB 5TB 1Gbps $62.99/月 $645.99/年 DC9 CN2 GIA 购买 (缺货) HK 2GB 2核 40GB 0.5TB 1Gbps $89.99/月 $899.99/年 香港 PCCW 购买 HK 4GB 4核 80GB 1TB 1Gbps $155.99/月 $1559.99/年 香港 PCCW 购买 点击上面的“直达通道”或者购买链接进入购买页。(其他套餐,请在下方表格自行选择,并分别点击表格中的购买链接进入。否则会出现无法看到验证码的问题)\n购买之后如下截图\n购买完之后会收到下面的邮件,告诉你root 的账号密码,端口等。 点击此页面查看VPS 的更多信息: https://bandwagonhost.com/clientarea.php?action=products\n用Xshell 远程VPS SSH 协议 ​\t具体教程可以搜索Xshell remote VPS\n​\thttps://www.bwgblog.net/bandwagonhost-xshell-ssh.html\n搭建Shadowsocks 安装Shadowsock\nwget \u0026ndash;no-check-certificate -O shadowsocks-all.sh https://raw.githubusercontent.com/teddysun/shadowsocks_install/master/shadowsocks-all.sh\nif promote no weget , run below commad:\n1 yum -y install wget 增加执行权限\n1 chmod +x shadowsocks-all.sh 配置并运行\n./shadowsocks-all.sh 2\u0026gt;\u0026amp;1 | tee shadowsocks-all.log 期间会有很多选择的配置,包括shadowsock 版本,密码,端口设置,协议加密方式等,自行选择并设置。如果出现以下截图即为成功。 如果需要卸载 shadowsocks:\n./shadowsocks-libev.sh uninstall shadowsock 相关命令:\nShadowsocks-Python 版: /etc/init.d/shadowsocks-python start | stop | restart | status\nShadowsocksR 版: /etc/init.d/shadowsocks-r start | stop | restart | status\nShadowsocks-Go 版: /etc/init.d/shadowsocks-go start | stop | restart | status\nShadowsocks-libev 版: /etc/init.d/shadowsocks-libev start | stop | restart | status\n客户端配置Shadowsocks 自行百度,可有: https://ssr.tools/386\n或者\nhttps://crifan.github.io/scientific_network_summary/website/server_client_mode/ss_client/ss_clients/ss_windows.html\n如若下载速度太慢,可以到我的网盘下载:\n链接: https://pan.baidu.com/s/1eJr7KlJSB_exVyXZeLseng 提取码: buei\n安装BBR加速 关闭防火\n1 2 systemctl disable firewalld systemctl stop firewalld BBR安装脚本\n1 2 3 4 5 6 7 8 9 10 11 wget https://raw.githubusercontent.com/kuoruan/shell-scripts/master/ovz-bbr/ovz-bbr-installer.sh chmod +x ovz-bbr-installer.sh ./ovz-bbr-installer.sh 或者: sed -i \u0026#39;/net.core.default_qdisc/d\u0026#39; /etc/sysctl.conf sed -i \u0026#39;/net.ipv4.tcp_congestion_control/d\u0026#39; /etc/sysctl.conf echo \u0026#34;net.core.default_qdisc = fq\u0026#34; \u0026gt;\u0026gt; /etc/sysctl.conf echo \u0026#34;net.ipv4.tcp_congestion_control = bbr\u0026#34; \u0026gt;\u0026gt; /etc/sysctl.conf sysctl -p reboot 安装过程中会提示加速端口,最后判断BBR 是否正常工作\n验证当前TCP控制算法的命令: sysctl net.ipv4.tcp_available_congestion_control\n返回值一般为: net.ipv4.tcp_available_congestion_control = bbr cubic reno 或者为: net.ipv4.tcp_available_congestion_control = reno cubic bbr\n验证BBR是否已经启动\nsysctl net.ipv4.tcp_congestion_control\n返回值一般为: net.ipv4.tcp_congestion_control = bbr\n​\tlsmod | grep bbr\n​ 返回值有 tcp_bbr 模块即说明 bbr 已启动。 注意:并不是所有的 VPS 都会有此返回值,若没有也属正常。\n","permalink":"https://reid00.github.io/en/posts/other/%E6%9D%BF%E7%93%A6%E5%B7%A5%E6%90%AD%E5%BB%BAvps%E6%90%AD%E5%BB%BAvpn/","summary":"板瓦工以及产品介绍 该商家隶属于美国IT7公司旗下的一款便宜年付KVM架构的VPS主机商家,从2013年开始推出低价VPS主机配置进入市场,确","title":"板瓦工搭建VPS搭建vpn"},{"content":"一直以来,编码问题像幽灵一般,不少开发人员都受过它的困扰。\n试想你请求一个数据,却得到一堆乱码,丈二和尚摸不着头脑。有同事质疑你的数据是乱码,虽然你很确定传了 UTF-8 ,却也无法自证清白,更别说帮同事 debug 了。\n有时,靠着百度和一手瞎调的手艺,乱码也能解决。尽管如此,还是很羡慕那些骨灰级程序员。为什么他们每次都能犀利地指出问题,并快速修复呢?原因在于,他们早就把编码问题背后的各种来龙去脉搞清楚了。\n本文从 ASCII 码说起,带你扒一扒编码背后那些事。相信搞清编码的原理后,你将不再畏惧任何编码问题。\n从 ASCII 码说起 现代计算机技术从英文国家兴起,最先遇到的也是英文文本。英文文本一般由 26 个字母、 10 个数字以及若干符号组成,总数也不过 100 左右。\n计算机中最基本的存储单位为 字节 ( byte ),由 8 个比特位( bit )组成,也叫做 八位字节 ( octet )。8 个比特位可以表示 $ 2^8 = 256 $ 个字符,看上去用字节来存储英文字符即可?\n计算机先驱们也是这么想的。他们为每个英文字符编号,再加上一些控制符,形成了我们所熟知的 ASCII 码表。实际上,由于英文字符不多,他们只用了字节的后 7 位而已。\n根据 ASCII 码表,由 01000001 这 8 个比特位组成的八位字节,代表字母 A 。\n顺便提一下,比特本身没有意义,比特 在 上下文 ( context )中才构成信息。举个例子,对于内存中一个字节 01000001 ,你将它看做一个整数,它就是 65 ;将它作为一个英文字符,它就是字母 A ;你看待比特的方式,就是所谓的上下文。\n所以,猜猜下面这个程序输出啥?\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #include \u0026lt;stdio.h\u0026gt; int main(int argc, char *argv[]) { char value = 0x41; // as a number, value is 65 or 0x41 in hexadecimal printf(\u0026#34;%d\\n\u0026#34;, value); // as a ASCII character, value is alphabet A printf(\u0026#34;%c\\n\u0026#34;, value); return 0; } latin1 西欧人民来了,他们主要使用拉丁字母语言。与英语类似,拉丁字母数量并不大,大概也就是几十个。于是,西欧人民打起 ASCII 码表那个未用的比特位( b8 )的主意。\n还记得吗?ASCII 码表总共定义了 128 个字符,范围在 0~127 之间,字节最高位 b8 暂未使用。于是,西欧人民将拉丁字母和一些辅助符号(如欧元符号)定义在 128~255 之间。这就构成了 latin1 ,它是一个 8 位字符集,定义了以下字符:\n图中绿色部分是不可打印的( unprintable )控制字符,左半部分是 ASCII 码。因此,latin1 字符集是 ASCII 码的超集:\n一个字节掰成两半,欧美两兄弟各用一半。至此,欧美人民都玩嗨了,东亚人民呢?\nGB2312、GBK和GB18030 由于受到汉文化的影响,东亚地区主要是汉字圈,我们便以中文为例展开介绍。\n汉字有什么特点呢?—— 光常用汉字就有几千个,这可不是一个字节能胜任的。一个字节不够就两个呗。道理虽然如此,操作起来却未必这么简单。\n首先,将需要编码的汉字和 ASCII 码整理成一个字符集,例如 GB2312 。为什么需要 ASCII 码呢?因为,在计算机世界,不可避免要跟数字、英文字母打交道。至于拉丁字母,重要性就没那么大,也就无所谓了。\nGB2312 字符集总共收录了 6 千多个汉字,用两个字节来表示足矣,但事情远没有这么简单。同样的数字字符,在 GB2312 中占用 2 个字节,在 ASCII 码中占用 1 个字节,这不就不兼容了吗?计算机里太多东西涉及 ASCII 码了,看看一个 http 请求:\n1 2 GET / HTTP/1.1 Host: www.example.com 那么,怎么兼容 GB2312 和 ASCII 码呢?天无绝人之路, 变长 编码方案应运而生。\n变长编码方案,字符由长度不一的字节表示,有些字符只需 1 字节,有些需要 2 字节,甚至有些需要更多字节。GB2312 中的 ASCII 码与原来保持一致,还是用一个字节来表示,这样便解决了兼容问题。\n在 GB2312 中,如果一个字节最高位 b8 为 0 ,该字节便是单字节编码,即 ASCII 码。如果字节最高位 b8 为 1 ,它就是双字节编码的首字节,与其后字节一起表示一个字符。\n变长编码方案目的在于兼容 ASCII 码,但也带来一个问题:由于字节编码长度不一,定位第 N 个字符只能通过遍历实现,时间复杂度从 $ O(1) $ 退化到 $ O(N) $ 。好在这种操作场景并不多见,因此影响可以忽略。\nGB2312 收录的汉字个数只有常用的 6 千多个,遇到生僻字还是无能为力。因此,后来又推出了 GBK 和 GB18030 字符集。GBK 是 GB2312 的超集,完全兼容 GB2312 ;而 GB18030 又是 GBK 的超集,完全兼容 GBK 。\n因此,对中文编码文本进行解码,指定 GB18030 最为健壮:\n1 2 3 \u0026gt;\u0026gt;\u0026gt; raw = b\u0026#39;\\xfd\\x88\\xb5\\xc4\\xb4\\xab\\xc8\\xcb\u0026#39; \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;gb18030\u0026#39;) \u0026#39;龍的传人\u0026#39; 指定 GBK 或 GB2312 就只好看运气了,GBK 多半还没事:\n1 2 \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;gbk\u0026#39;) \u0026#39;龍的传人\u0026#39; GB2312 经常直接抛锚不商量:\n1 2 3 4 \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;gb2312\u0026#39;) Traceback (most recent call last): File \u0026#34;\u0026lt;stdin\u0026gt;\u0026#34;, line 1, in \u0026lt;module\u0026gt; UnicodeDecodeError: \u0026#39;gb2312\u0026#39; codec can\u0026#39;t decode byte 0xfd in position 0: illegal multibyte sequence chardet 是一个不错的文本编码检测库,用起来很方便,但对中文编码支持不是很好。经常中文编码的文本进去,检测出来的结果是 GB2312 ,但一用 GB2312 解码就跪:\n1 2 3 4 5 6 7 8 \u0026gt;\u0026gt;\u0026gt; import chardet \u0026gt;\u0026gt;\u0026gt; raw = b\u0026#39;\\xd6\\xd0\\xb9\\xfa\\xc8\\xcb\\xca\\xc7\\xfd\\x88\\xb5\\xc4\\xb4\\xab\\xc8\\xcb\u0026#39; \u0026gt;\u0026gt;\u0026gt; chardet.detect(raw) {\u0026#39;encoding\u0026#39;: \u0026#39;GB2312\u0026#39;, \u0026#39;confidence\u0026#39;: 0.99, \u0026#39;language\u0026#39;: \u0026#39;Chinese\u0026#39;} \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;GB2312\u0026#39;) Traceback (most recent call last): File \u0026#34;\u0026lt;stdin\u0026gt;\u0026#34;, line 1, in \u0026lt;module\u0026gt; UnicodeDecodeError: \u0026#39;gb2312\u0026#39; codec can\u0026#39;t decode byte 0xfd in position 8: illegal multibyte sequence 掌握 GB2312 、 GBK 、 GB18030 三者的关系后,我们可以略施小计。如果 chardet 检测出来结果是 GB2312 ,就用 GB18030 去解码,大概率可以成功!\n1 2 \u0026gt;\u0026gt;\u0026gt; raw.decode(\u0026#39;GB18030\u0026#39;) \u0026#39;中国人是龍的传人\u0026#39; Unicode GB2312 、 GBK 与 GB18030 都是中文编码字符集。虽然 GB18030 也包含日韩表意文字,算是国际字符集,但毕竟是以中文为主,无法适应全球化应用。\n在计算机发展早期,不同国家都推出了自己的字符集和编码方案,互不兼容。中文编码的文本在使用日文编码的系统上是无法显示的,这就给国际交往带来障碍。\n这时,英雄出现了。统一码联盟 站出来说要发展一个通用的字符集,收录世界上所有字符,这就是 Unicode 。经过多年发展, Unicode 已经成为世界上最通用的字符集,也是计算机科学领域的业界标准。\nUnicode 已经收录的字符数量已经超过 13 万个,每个字符需占用超过 2 字节。由于常用编程语言一般没有 24 位数字类型,因此一般用 32 位数字表示一个字符。这样一来,同样的一个英文字母,在 ASCII 中只需占用 1 字节,在 Unicode 则需要占用 4 字节!英美人民都要哭了,试想你磁盘中的文件大小都增大了 4 倍是什么感受!\nUTF-8 为了兼容 ASCII 并优化文本空间占用,我们需要一种变长字节编码方案,这就是著名的 UTF-8 。与 GB2312 等中文编码一样,UTF-8 用不固定的字节数来表示字符:\nASCII 字符 Unicode 码位由 U+0000 至 U+007F ,用 1 个字节编码,最高位为 0 ; 码位由 U+0080 至 U+07FF 的字符,用 2 个字节编码,首字节以 110 开头,其余字节以 10 开头; 码位由 U+0800 至 U+FFFF 的字符,用 3 个字节编码,首字节以 1110 开头,其余字节同样以 10 开头; 4 至 6 字节编码的情况以此类推; 如图,以 0 开头的字节为 单字节 编码,总共 7 个有效编码位,编码范围为 U+0000 至 U+007F ,刚好对应 ASCII 码所有字符。以 110 开头的字节为 双字节 编码,总共 11 个有效编码位,最大值是 0x7FF ,因此编码范围为 U+0080 至 U+07FF ;以 1110 开头的字节为 三字节 编码,总共 16 个有效编码位,最大值是 0xFFFF 因此编码范围为 U+0800 至 U+FFFF 。\n根据开头不同, UTF-8 流中的字节,可以分为以下几类:\n| 字节最高位 | 类别 | 有效位 | |:\u0026mdash;\u0026mdash;\u0026ndash; |:\u0026mdash;\u0026ndash;|:\u0026mdash;\u0026mdash;| | 0 | 单字节编码 | 7 | | 10 | 多字节编码非首字节 | | | 110 | 双字节编码首字节 | 11 | | 1110 | 三字节编码首字节 | 16 | | 11110 | 四字节编码首字节 | 21 | | 111110 | 五字节编码首字节 | 26 | | 1111110 | 六字节编码首字节 | 31 |\n至此,我们已经具备了读懂 UTF-8 编码字节流的能力,不信来看一个例子:\n概念回顾 一直以来,字符集 和 编码 这两个词一直是混着用的。现在,我们总算有能力厘清这两者间的关系了。\n字符集 顾名思义,就是由一定数量字符组成的集合,每个字符在集合中有唯一编号。前文提及的 ASCII 、 latin1 、 GB2312 、GBK 、GB18030 以及 Unicode 等,无一例外,都是字符集。\n计算机存储和网络通讯的基本单位都是 字节 ,因此文本必须以 字节序列 的形式进行存储或传输。那么,字符编号如何转化成字节呢?这就是 编码 要回答的问题。\n在 ASCII 码和 latin 中,字符编号与字节一一对应,这是一种编码方式。GB2312 则采用变长字节,这是另一种编码方式。而 Unicode 则存在多种编码方式,除了 最常用的 UTF-8 编码,还有 UTF-16 等。实际上,UTF-16 编码效率比 UTF-8 更高,但由于无法兼容 ASCII ,应用范围受到很大制约。\n最佳实践 认识文本编码的前世今生之后,应该如何规避编码问题呢?是否存在一些最佳实践呢?答案是肯定的。\n编码选择 项目开始前,需要选择一种适应性广的编码方案,UTF-8 是首选,好处多多:\nUnicode 是业界标准,编码字符数量最多,天然支持国际化; UTF-8 完全兼容 ASCII 码,这是硬性指标; UTF-8 目前应用最广; 如因历史原因,不得不使用中文编码方案,则优先选择 GB18030 。这个标准最新,涵盖字符最多,适应性最强。尽量避免采用 GBK ,特别是 GB2312 等老旧编码标准。\n编程习惯 如果你使用的编程语言,字符串类型支持 Unicode ,那问题就简单了。由于 Unicode 字符串肯定不会导致诸如乱码等编码问题,你只需在输入和输出环节稍加留意。\n举个例子,Python 从 3 以后, str 就是 Unicode 字符串了,而 bytes 则是 字节序列 。因此,在 Python 3 程序中,核心逻辑应该统一用 str 类型,避免使用 bytes 。文本编码、解码操作则统一在程序的输入、输出层中进行。\n假如你正在开发一个 API 服务,数据库数据编码是 GBK ,而用户却使用 UTF-8 编码。那么,在程序 输入层 , GBK 数据从数据库读出后,解码转换成 Unicode 数据,再进入核心层处理。在程序 核心层 ,数据以 Unicode 形式进行加工处理。由于核心层处理逻辑可能很复杂,统一采用 Unicode 可以减少问题的发生。最后,在程序的 输出层 将数据以 UTF-8 编码,再返回给客户端。\n整个过程伪代码大概如下:\n1 2 3 4 5 6 7 8 9 10 11 # input # read gbk data from database and decode it to unicode data = read_from_database().decode(\u0026#39;gbk\u0026#39;) # core # process unicode data only result = process(data) # output # encoding unicode data into utf8 response_to_user(result.encode(\u0026#39;utf8\u0026#39;)) 这样的程序结构看起来跟个三明治一样,非常形象:\n当然了,还有很多编程语言字符串还不支持 Unicode 。Python 2 中的 str 对象,跟 Python 3 中的 bytes 比较像,只是字节序列;C 语言中的字符串甚至更原始。\n这都无关紧要,好的编程习惯是相通的:程序核心层统一使用某种编码,输入输出层则负责编码转换。至于核心层使用何种编码,主要看程序中哪种编码使用最多,一般是跟数据库编码保持一致即可。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/%E7%BC%96%E7%A0%81%E9%82%A3%E4%BA%9B%E4%BA%8B/","summary":"一直以来,编码问题像幽灵一般,不少开发人员都受过它的困扰。 试想你请求一个数据,却得到一堆乱码,丈二和尚摸不着头脑。有同事质疑你的数据是乱码,","title":"编码那些事"},{"content":"前言 我们每天都在用 Google, 百度这些搜索引擎,那大家有没想过搜索引擎是如何实现的呢,看似简单的搜索其实技术细节非常复杂,说搜索引擎是 IT 皇冠上的明珠也不为过,今天我们来就来简单过一下搜索引擎的原理,看看它是如何工作的,当然搜索引擎博大精深,一篇文章不可能完全介绍完,我们只会介绍它最重要的几个步骤,不过万变不离其宗,搜索引擎都离开这些重要步骤,剩下的无非是在其上添砖加瓦,所以掌握这些「关键路径」,能很好地达到观一斑而窥全貎的目的。\n本文将会从以下几个部分来介绍搜索引擎,会深度剖析搜索引擎的工作原理及其中用到的一些经典数据结构和算法,相信大家看了肯定有收获。\n搜索引擎系统架构图\n搜索引擎工作原理详细剖析\n搜索引擎系统架构图 搜索引擎整体架构图如下图所示,大致可以分为搜集,预处理,索引,查询这四步,每一步的技术细节都很多,我们将在下文中详细分析每一步的工作原理。 搜索引擎工作原理详细剖析 一、搜索 爬虫一开始是不知道该从哪里开始爬起的,所以我们可以给它一组优质种子网页的链接,比如新浪主页,腾讯主页等,这些主页比较知名,在 Alexa 排名上也非常靠前,拿到这些优质种子网页后,就对这些网页通过广度优先遍历不断遍历这些网页,爬取网页内容,提取出其中的链接,不断将其将入到待爬取队列,然后爬虫不断地从 url 的待爬取队列里提取出 url 进行爬取,重复以上过程\u0026hellip;\n当然了,只用一个爬虫是不够的,可以启动多个爬虫并行爬取,这样速度会快很多。\n1、待爬取的 url 实现 待爬取 url 我们可以把它放到 Redis 里,保证了高性能,需要注意的是,Redis要开启持久化功能,这样支持断点续爬,如果 Redis 挂掉了,重启之后由于有持续久功能,可以从上一个待爬的 url 开始重新爬。\n2、如何判重 如何避免网页的重复爬取呢,我们需要对 url 进行去重操作,去重怎么实现?可能有人说用散列表,将每个待抓取 url 存在散列表里,每次要加入待爬取 url 时都通过这个散列表来判断一下是否爬取过了,这样做确实没有问题,但我们需要注意到的是这样需要会出巨大的空间代价,有多大,我们简单算一下,假设有 10 亿 url (不要觉得 10 亿很大,像 Google, 百度这样的搜索引擎,它们要爬取的网页量级比 10 亿大得多),放在散列表里,需要多大存储空间呢?\n我们假设每个网页 url 平均长度 64 字节,则 10 亿个 url 大约需要 60 G 内存,如果用散列表实现的话,由于散列表为了避免过多的冲突,需要较小的装载因子(假设哈希表要装载 10 个元素,实际可能要分配 20 个元素的空间,以避免哈希冲突),同时不管是用链式存储还是用红黑树来处理冲突,都要存储指针,各种这些加起来所需内存可能会超过 100 G,再加上冲突时需要在链表中比较字符串,性能上也是一个损耗,当然 100 G 对大型搜索引擎来说不是什么大问题,但其实还有一种方案可以实现远小于 100 G 的内存:布隆过滤器。\n针对 10 亿个 url,我们分配 100 亿个 bit,大约 1.2 G, 相比 100 G 内存,提升了近百倍!可见技术方案的合理选择能很好地达到降本增效的效果。\n当然有人可能会提出疑问,布隆过滤器可能会存在误判的情况,即某个值经过布隆过滤器判断不存在,那这个值肯定不存在,但如果经布隆过滤器判断存在,那这个值不一定存在,针对这种情况我们可以通过调整布隆过滤器的哈希函数或其底层的位图大小来尽可能地降低误判的概率,但如果误判还是发生了呢,此时针对这种 url 就不爬好了,毕竟互联网上这么多网页,少爬几个也无妨。\n3、网页的存储文件: doc_raw.bin 爬完网页,网页该如何存储呢,有人说一个网页存一个文件不就行了,如果是这样,10 亿个网页就要存 10 亿个文件,一般的文件系统是不支持的,所以一般是把网页内容存储在一个文件(假设为 doc_raw.bin)中,如下\n当然一般的文件系统对单个文件的大小也是有限制的,比如 1 G,那在文件超过 1 G 后再新建一个好了。\n图中网页 id 是怎么生成的,显然一个 url 对应一个网页 id,所以我们可以增加一个发号器,每爬取完一个网页,发号器给它分配一个 id,将网页 id 与 url 存储在一个文件里,假设命名为 doc_id.bin,如下\n二、预处理 爬取完一个网页后我们需要对其进行预处理,我们拿到的是网页的 html 代码,需要把\u0026lt;script\u0026gt;,\u0026lt;style\u0026gt;,\u0026lt;option\u0026gt; 这些无用的标签及标签包含的内容给去掉,怎么查找是个学问,可能有人会说用 BF,KMP 等算法,这些算法确实可以,不过这些算法属于单模式串匹配算法,查询单个字段串效率确实不错,但我们想要一次性查出\u0026lt;script\u0026gt;,\u0026lt;style\u0026gt;,\u0026lt;option\u0026gt;这些字段串,有啥好的方法不,答案是用AC 自动机多模式串匹配算法,可以高效一次性找出几个待查找的字段串,有多高效,时间复杂度接近 0(n)!关于 AC 自动机多模式匹配算法的原理不展开介绍,大家可以去网上搜搜看, 这里只是给大家介绍一下思路。\n找到这些标签的起始位置后,剩下的就简单了,接下来对每个这些标签都查找其截止标签 \u0026lt;/script\u0026gt;,\u0026lt;/style\u0026gt;,\u0026lt;/option\u0026gt;,找到之后,把起始终止标签及其中的内容全部去掉即可。\n做完以上步骤后,我们也要把其它的 html 标签去掉(标签里的内容保留),因为我们最终要处理的是纯内容(内容里面包含用户要搜索的关键词)\n三、分词并创建倒排索引 拿到上述步骤处理过的内容后,我们需要将这些内容进行分词,啥叫分词呢,就是将一段文本切分成一个个的词。比如 「I am a chinese」分词后,就有 「I」,「am」,「a」,「chinese」这四个词,从中也可以看到,英文分词相对比较简单,每个单词基本是用空格隔开的,只要以空格为分隔符切割字符串基本可达到分词效果,但是中文不一样,词与词之类没有空格等字符串分割,比较难以分割。以「我来到北京清华大学」为例,不同的模式产生的分词结果不一样,以 github 上有名的 jieba 分词开源库以例,它有如下几种分词模式\n【全模式】: 我/ 来到/ 北京/ 清华/ 清华大学/ 华大/ 大学 【精确模式】: 我/ 来到/ 北京/ 清华大学 【新词识别】:他, 来到, 了, 网易, 杭研, 大厦 【搜索引擎模式】: 小明, 硕士, 毕业, 于, 中国, 科学, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造\n分词一般是根据现成的词库来进行匹配,比如词库中有「中国」这个词,用处理过的网页文本进行匹配即可。当然在分词之前我们要把一些无意义的停止词如「的」,「地」,「得」先给去掉。\n经过分词之后我们得到了每个分词与其文本的关系,如下\n这样我们在搜「大学」的时候找到「大学」对应的行,就能找到所有包含有「大学」的文档 id 了。\n看到以上「分词」+「倒排索引」的处理流程,大家想到了什么?没错,这不就是 ElasticSearch 搜索引擎干的事吗,也是 ES 能达到毫秒级响应的关键!\n这里还有一个问题,根据某个词语获取得了一组网页的 id 之后,在结果展示上,哪些网页应该排在最前面呢,为啥我们在 Google 上搜索一般在第一页的前几条就能找到我们想要的答案。这就涉及到搜索引擎涉及到的另一个重要的算法: PageRank,它是 Google 对网页排名进行排名的一种算法,它以网页之间的超链接个数和质量作为主要因素粗略地分析网页重要性以便对其进行打分。我们一般在搜问题的时候,前面一两个基本上都是 stackoverflow 网页,说明 Google 认为这个网页的权重很高,因为这个网页被全世界几乎所有的程序员使用着,也就是说有无数个网页指向此网站的链接,根据 PageRank 算法,自然此网站权重就啦,恩,可以简单地这么认为,实际上 PageRank 的计算需要用到大量的数学知识,毕竟此算法是 Google 的立身之本,大家如果有兴趣,可以去网上多多了解一下。\n完成以上步骤,搜索引擎对网页的处理就完了,那么用户输入关键词搜索引擎又是怎么给我们展示出结果的呢。\n四、查询 用户输入关键词后,首先肯定是要经过分词器的处理。比如我输入「中国人民」,假设分词器分将其分为「中国」,「人民」两个词,接下来就用这个两词去倒排索引里查相应的文档\n得到网页 id 后,我们分别去 doc_id.bin,doc_raw.bin 里提取出网页的链接和内容,按权重从大到小排列即可。\n这里的权重除了和上文说的 PageRank 算法有关外,还与另外一个「 TF-IDF 」(https://zh.wikipedia.org/wiki/Tf-idf)算法有关,大家可以去了解一下。\n另外相信大家在搜索框输入搜索词的时候,都会注意到底下会出现一串搜索提示词,\n如何实现的,这就不得不提到一种树形结构:Trie 树。Trie 树又叫字典树、前缀树(Prefix Tree)、单词查找树,是一种多叉树结构,如下图所示:\n这颗多叉树表示了关键字集合 [\u0026ldquo;to\u0026rdquo;,\u0026ldquo;tea\u0026rdquo;,\u0026ldquo;ted\u0026rdquo;,\u0026ldquo;ten\u0026rdquo;,\u0026ldquo;a\u0026rdquo;,\u0026ldquo;i\u0026rdquo;,\u0026ldquo;in\u0026rdquo;, \u0026ldquo;inn\u0026rdquo;]。从中可以看出 Trie 树具有以下性质:\n根节点不包含字符,除根节点外的每一个子节点都包含一个字符 从根节点到某一个节点,路径上经过的字符连接起来,为该节点对应的字符串 每个节点的所有子节点包含的字符互不相同 通常在实现的时候,会在节点结构中设置一个标志,用来标记该结点处是否构成一个单词(关键字)。\n另外我们不难发现一个规律,具有公共前缀的关键字(单词),它们前缀部分在 Trie 树中是相同的,这也是 Trie 树被称为前缀树的原因,有了这个思路,我们不难设计出上文所述搜索时展示一串搜索提示词的思路:\n一般搜索引擎会维护一个词库,假设这个词库由所有搜索次数大于某个阈值(如 1000)的字符串组成,我们就可以用这个词库构建一颗 Trie 树,这样当用户输入字母的时候,就可以以这个字母作为前缀去 Trie 树中查找,以上文中提到的 Trie 树为例,则我们输入「te」时,由于以「te」为前缀的单词有 [\u0026ldquo;tea\u0026rdquo;,\u0026ldquo;ted\u0026rdquo;,\u0026ldquo;ted\u0026rdquo;,\u0026ldquo;ten\u0026rdquo;],则在搜索引擎的搜索提示框中就可以展示这几个字符串以供用户选择。\n五、寻找热门搜索字符串 Trie 树除了作为前缀树来实现搜索提示词的功能外,还可以用来辅助寻找热门搜索字符串,只要对 Trie 树稍加改造即可。假设我们要寻找最热门的 10 个搜索字符串,则具体实现思路如下:\n一般搜索引擎都会有专门的日志来记录用户的搜索词,我们用用户的这些搜索词来构建一颗 Trie 树,但要稍微对 Trie 树进行一下改造,上文提到,Trie 树实现的时候,可以在节点中设置一个标志,用来标记该结点处是否构成一个单词,也可以把这个标志改成以节点为终止字符的搜索字符串个数,每个搜索字符串在 Trie 树遍历,在遍历的最后一个结点上把字符串个数加 1,即可统计出每个字符串被搜索了多少次(根节点到结点经过的路径即为搜索字符串),然后我们再维护一个有 10 个节点的小顶堆(堆顶元素比所有其他元素值都小,如下图示)\n依次遍历 Trie 树的节点,将节点(字符串+次数)传给小顶堆,根据搜索次数不断调整小顶堆,这样遍历完 Trie 树的节点后,小顶堆里的 10 个节点对应的字符串即是最热门的搜索字符串。\n总结 本文简述了搜索引擎的工作原理,相信大家看完后对其工作原理应该有了比较清醒的认识,我们可以看到,搜索引擎中用到了很多经典的数据结构和算法,所以现在大家应该能明白为啥 Google, 百度这些公司对候选人的算法要求这么高了。\n","permalink":"https://reid00.github.io/en/posts/algo/%E6%90%9C%E7%B4%A2%E5%BC%95%E6%93%8E%E8%83%8C%E5%90%8E%E7%9A%84%E7%BB%8F%E5%85%B8%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E5%92%8C%E7%AE%97%E6%B3%95/","summary":"前言 我们每天都在用 Google, 百度这些搜索引擎,那大家有没想过搜索引擎是如何实现的呢,看似简单的搜索其实技术细节非常复杂,说搜索引擎是 IT 皇冠上的明珠也","title":"搜索引擎背后的经典数据结构和算法"},{"content":"常见反直觉定理 生日悖论 假设房间里有23人,那么两个人生日是同天的概率将大于50%。我们很容易得出,任何一个特定的日子里某人过生日的概率是1/365。\n所以这个理论看似是无法成立,但理论与现实差异正源自于:我们的唯一要求是两个人彼此拥有同一天生日即可,不限定在特定的一天。 否则,如果换做某人在某特定日期生日,例如2月19日,那么23个人中概率便仅为6.12%。\n另一方面如果你在有23个人的房间挑选一人问他:“有人和你同一天生日吗?”答案很可能是否定的。 但如果重复询问其余22人,每问一次,你便会有更大机会得到肯定答复,最终这个概率是50.7%。 结论 当房间里有23人,那么存在生日相同的概率超过50%, 如果有60人,则超过99%\n生日悖论的应用 日悖论普遍的应用于检测哈希函数:N 位长度的哈希表可能发生碰撞测试次数不是 2N 次而是只有 2N/2 次。这一结论被应用到破解密码哈希函数 (cryptographic hash function) 的 “生日攻击” 中。 生日问题所隐含的理论已经在 [Schnabel 1938] 名字叫做 “标记重捕法” (capture-recapture) 的统计试验得到应用,来估计湖里鱼的数量。 巴拿赫-塔尔斯基悖论(分球定理) 数学中,有一条极其基本的公理,叫做选择公理,许多数学内容都要基于这条定理才得以成立。 在1924年,数学家斯特·巴拿赫和阿尔弗莱德·塔斯基根据选择公理,得到一个奇怪的推论——分球定理。 该定理指出,一个三维实心球分成有限份,然后可以根据旋转和平移,组成和原来完全相同的两个实心球。没错,每一个和原来的一模一样。 分球定理太违反直觉,但它就是选择公理的严格推论,而且不容置疑的,除非你抛弃选择公理,但数学家会为此付出更大的代价。\n在现实生活中我们没有任何办法能将一个物体凭空复制成两个。但事实上他却是成立的,这个结果似乎挑战了物理中的质量守恒定律,但似乎又是在说一个物体的质量可以凭空变为原来的两倍? 但如若原质量是无限的话,翻倍后还是无限大,那么从这一层面出发来看这一理论也并没有打破物理法则。\n有不同层次的无穷大(无穷大也有等级大小) 你可能从来想象不到,有一些无穷大比其他的无穷更大。无穷大应该被称为基数,并且一个无穷大如果比另一个无穷大拥有更大的基数,则说它比另一个无穷大要大。\n在二十世纪以前,数学家们遇到无穷大都避而让之,认为要么哪里出了问题,要么结果是没有意义的。 直到1895年,康托尔建立超穷数理论,人们才得知无穷大也是有等级的,比如实数个数的无穷,就比整数个数的无穷的等级高。 还有许多关于无穷大的基数大大出乎我们的意料。举一个非常经典的例子:整数比奇数多吗?你可能会毫不犹豫的回答,那是当然! 因为整数多出了一系列的偶数。但答案是否定的,他们拥有相同的基数,因而整数并不比奇数多。知道了这个道理,就不难回答这个问题了吧:有理数多于整数吗?不,有理数与整数相同多。 实数通常被认为是连续统,并且至今并能完全知道,是否有介于整数基数和连续统基数的无穷大?这个猜想被称为连续统猜想。\n这也太违反直觉了,我们从来不把无穷大当作数,但是无穷大在超穷数理论中,却存在不同的等级。\n哥德尔不完备定理 “可证”和“真”不是等价的 1931年,奥地利数学家哥德尔,提出一条震惊学术界的定理——哥德尔不完备定理。 该定理指出,我们目前的数学系统中,必定存在不能被证明也不能被证伪的定理。该定理一出,就粉碎了数学家几千年的梦想——即建立完善的数学系统,从一些基本的公理出发,推导出一切数学的定理和公式。\n它的逻辑是这样的:\n任何一个足够强的系统都存在一个命题,既不能被证明也不能被证伪(例如连续统假设) 任何一个足够强的系统都不能证明它自身是不推出矛盾,即便它不能被推出矛盾 以上两条定义即著名的哥德尔不完备定理。他的意义并不仅仅局限于数学,也给了我们深深地哲学启迪。\n蒙提霍尔问题 三门问题亦称为蒙提霍尔问题,大致出自美国的电视游戏节目Let\u0026rsquo;s Make a Deal。问题名字来自该节目的主持人蒙提·霍尔。 参赛者会看见三扇关闭了的门,其中一扇的后面有一辆汽车,选中后面有车的那扇门可赢得该汽车,另外两扇门后面则各藏有一只山羊。 当参赛者选定了一扇门,但未去开启它的时候,节目主持人开启剩下两扇门的其中一扇,露出其中一只山羊。主持人其后会问参赛者要不要换另一扇仍然关上的门。 问题是:换另一扇门会否增加参赛者赢得汽车的机会率? 不换门的话,赢得汽车的几率是1/3。换门的话,赢得汽车的几率是2/3。\n这个问题亦被叫做蒙提霍尔悖论:虽然该问题的答案在逻辑上并不自相矛盾,但十分违反直觉。\n巴塞尔问题 将自然数各自平方取倒数加在一起等于π²/6。 一般人都会觉得,左边这一坨自然数似乎和π(圆的周长与直径的比值)不会存在任何联系!然而它就这么发生了!\n阿贝尔不可解定理 曼德勃罗集 德勃罗集是一个复数集,考虑函数f(z)=z²+c,c为复常数,在这为参数。 若从z=0开始不断的利用f(z)进行迭代,则凡是使得迭代结果不会跑向无穷大的c组成的集合被称为曼德勃罗集。规则不复杂,但你可能没预料到会得到这么复杂的图像。 当你放大曼德勃罗集时,你会又发现无限个小的曼德勃罗集,其中每个又亦是如此\u0026hellip;(这种性质是分形所特有的) 这真的很契合那句俗话“大中有大,小中有小”,下面有一个关于放大他的视频,我想这绝对令人兴奋不已。 一维可以和二维甚至更高维度一一对应 按照我们的常识,二维比一维等级高,三维比四维等级高,比如线是一维的,所以线不能一一对应于面积。 但事实并非如此,康托尔证明了一维是可以一一对应高维的,也就是说一条线上的点,可以和一块面积甚至体积的点一一对应,或者说他们包含的点一样多。 证明: 在1890年,意大利数学家皮亚诺,就发明了一个函数,使得函数在实轴[0,1]上的取值,可以一一对应于单位正方形上的所有点,这条曲线叫做皮亚诺曲线。 这个性质的发现,暗示着人类对维度的主观认识,很可能是存在缺陷的。\n希尔伯特的旅店 希尔伯特有一个旅店,旅店里有无限多间房间。有一天所有房间都住满了人。然后又来了一名旅客。希尔伯特说:很抱歉,房间都住满了。旅客说:没关系。你可以让第一间房间的人住第二间房,第二间房间的人住第三间房,如此类推,我就可以住第一间房间了。也可以让第一间房间的人住第二间房,第二间房间的人住第四间房,以此类推,我也可以住第一间房间。\n甚至,按照这种做法,无论再来多少客人,只要客人是有限个,这个住满的酒店都可以继续空出房间来给他们安排。 他的说法很反直觉,但是从数学角度上,他是正确的。\n为了讨论这个问题,我们首先要问:全体正整数和全体正偶数谁多? 大部分人的直觉都是:整数多。因为整数包含奇数和偶数,所以整数多。\n但是,正整数集合和正偶数集合都是无限多个元素。在数学上,无限多个元素的集合比较多少时要使用“势”的概念。也就是:如果两个集合可以建立一个一一对应的关系,那么这两个集合的元素个数就是一样多的。 所以,如果我们令x表示正整数,y表示正偶数,建立对应关系y=2x,那么正整数和正偶数之间就建立了一一对应的关系,所以正整数集合和正偶数集合是等势的,或者说全体正整数和全体正偶数一样多。 按照这种观点,我们可以继而可以证明全体正整数数和全体非负整数一样多:建立一一对应关系:y=x-1,x属于正整数,y属于非负整数。 所以,客人来到希尔伯特的酒店住宿,提出的两种方案都是合理的。第一种方案基于全体正整数(房间)和全体非负整数(客人)是一样多的,第二种方案是基于全体正偶数(房间)和全体正整数(客人)是一样多的。\n有理点 我们把平面上横坐标和纵坐标都为有理数的点称为 有理点. 显然,平面上的有理点有无穷多个,甚至不难证明它们在整个平面内是稠密的,即密密麻麻地铺满了整个平面. 设想一下,如果我们在平面上随手画一条曲线,直觉上我们可能认为这根曲线应该会经过无限多个有理点,也就是说曲线上有无限多个有理点. 但事实上并非一定如此!比如我们非常熟悉的曲线 y=e^x,它除了(0, 1)外没有其他任何有理点,也就是说它非常神奇地避开了处处稠密的有理点,这就挺反直觉的. 买彩票 买彩票中奖的概率问题。同一天内买30注不同的号码中一等奖概率高还是连续30期每次买一注至少有一次中大奖的概率高。我想当然得人为是后者概率高,但是实际上经过计算后,是前者概率高,并且两者的中奖奖金期望值是相同的。\n","permalink":"https://reid00.github.io/en/posts/other/%E6%95%B0%E5%AD%A6%E4%B8%AD%E7%9A%84%E5%8D%81%E5%A4%A7%E6%82%96%E8%AE%BA/","summary":"常见反直觉定理 生日悖论 假设房间里有23人,那么两个人生日是同天的概率将大于50%。我们很容易得出,任何一个特定的日子里某人过生日的概率是1/","title":"数学中的十大悖论"},{"content":"1. 位运算概述 从现代计算机中所有的数据二进制的形式存储在设备中。即 0、1 两种状态,计算机对二进制数据进行的运算(+、-、*、/)都是叫位运算,即将符号位共同参与运算的运算。\n1 2 3 int a = 35; int b = 47; int c = a + b; 实际上运算如下: 计算两个数的和,因为在计算机中都是以二进制来进行运算,所以上面我们所给的 int 变量会在机器内部先转换为二进制在进行相加:\n1 2 3 4 35: 0 0 1 0 0 0 1 1 47: 0 0 1 0 1 1 1 1 ———————————————————— 82: 0 1 0 1 0 0 1 0 所以,相比在代码中直接使用(+、-、*、/)运算符,合理的运用位运算更能显著提高代码在机器上的执行效率。\n2. 位运算概览 3. 按位与运算符 定义:参加运算的两个数据,按二进制位进行\u0026quot;与\u0026quot;运算。 运算规则:\n1 0\u0026amp;0=0 0\u0026amp;1=0 1\u0026amp;0=0 1\u0026amp;1=1 ==总结:两位同时为1,结果才为1,否则结果为0。==\n例如:3\u0026amp;5 即 0000 0011\u0026amp; 0000 0101 = 0000 0001,因此 3\u0026amp;5 的值得1。 注意:负数按补码形式参加按位与运算。\n与运算 \u0026amp; 的用途: 1)清零 如果想将一个单元清零,即使其全部二进制位为0,只要与一个各位都为零的数值相与,结果为零。\n2)取一个数的指定位 比如取数 X=1010 1110 的低4位,只需要另找一个数Y,令Y的低4位为1,其余位为0,即Y=0000 1111,然后将X与Y进行按位与运算(X\u0026amp;Y=0000 1110)即可得到X的指定位。\n3)判断奇偶 只要根据最未位是0还是1来决定,为0就是偶数,为1就是奇数。因此可以用if ((a \u0026amp; 1) == 0)代替if (a % 2 == 0)来判断a是不是偶数。\n4. 异或运算符(^) 定义:参加运算的两个数据,按二进制位进行\u0026quot;异或\u0026quot;运算。\n1 0^0=0 0^1=1 1^0=1 1^1=0 ==总结:参加运算的两个对象,如果两个相应位相同为0,相异为1。==\n异或的几条性质:\n交换律 结合律 (a^b)^c == a^(b^c) 对于任何数x,都有 x^x=0,x^0=x 自反性: a^b^b=a^0=a; 与运算 ^ 的用途: 1)翻转指定位 比如将数 X=1010 1110 的低4位进行翻转,只需要另找一个数Y,令Y的低4位为1,其余位为0,即Y=0000 1111,然后将X与Y进行异或运算(X^Y=1010 0001)即可得到。\n2)与0相异或值不变 例如:1010 1110 ^ 0000 0000 = 1010 1110\n3)常见操作\n1 2 3 4 5 6 7 a=0^a=a^0 0=a^a 由上面两个推导出:a=a^b^b 获取最后一个 1 diff=(n\u0026amp;(n-1))^n 4) 交换两个数\n1 2 3 4 5 void swap(int \u0026amp;a, int \u0026amp;b) { a ^= b; b ^= a; a ^= b; } 5) n \u0026amp; n -1\n1 2 3 4 5 6 7 8 9 移除最后一个 1 a=n\u0026amp;(n-1) # 用 O(1) 时间检测整数 n 是否是 2 的幂次 N如果是2的幂次,则N满足两个条件。 1.N \u0026gt;0 2.N的二进制表示中只有一个1 因为N的二进制表示中只有一个1,所以使用N \u0026amp; (N - 1)将N唯一的一个1消去,应该返回0。 5. 左移运算符(\u0026laquo;) 定义:将一个运算对象的各二进制位全部左移若干位(左边的二进制位丢弃,右边补0)。\n设 a=1010 1110,a = a\u0026laquo; 2 将a的二进制位左移2位、右补0,即得a=1011 1000。\n若左移时舍弃的高位不包含1,则每左移一位,相当于该数乘以2。\n6. 右移运算符(\u0026raquo;) 定义:将一个数的各二进制位全部右移若干位,正数左补0,负数左补1,右边丢弃。\n例如:a=a\u0026raquo;2 将a的二进制位右移2位,左补0 或者 左补1得看被移数是正还是负。\n操作数每右移一位,相当于该数除以2。\n应用 位操作统计二进制中 1 的个数\n1 2 3 4 5 count = 0 for a \u0026gt; 0{ a = a \u0026amp; (a - 1); count++; } 用一 数组中,只有一个数出现一次,剩下都出现两次,找出出现一次的数\n1 a := a^b^b ","permalink":"https://reid00.github.io/en/posts/algo/%E5%B8%B8%E8%A7%81%E7%9A%84%E4%BA%8C%E8%BF%9B%E4%BD%8D%E8%BF%90%E7%AE%97%E6%8A%80%E5%B7%A7/","summary":"1. 位运算概述 从现代计算机中所有的数据二进制的形式存储在设备中。即 0、1 两种状态,计算机对二进制数据进行的运算(+、-、*、/)都是叫位运算,","title":"常见的二进位运算技巧"},{"content":"背景 今天,聊一个有趣的问题:拔掉网线几秒,再插回去,原本的 TCP 连接还存在吗?\n可能有的同学会说,网线都被拔掉了,那说明物理层被断开了,那在上层的传输层理应也会断开,所以原本的 TCP 连接就不会存在了。就好像, 我们拨打有线电话的时候,如果某一方的电话线被拔了,那么本次通话就彻底断了。\n真的是这样吗?\n上面这个逻辑就有问题。问题在于,错误地认为拔掉网线这个动作会影响传输层,事实上并不会影响。\n实际上,TCP 连接在 Linux 内核中是一个名为 struct socket 的结构体,该结构体的内容包含 TCP 连接的状态等信息。当拔掉网线的时候,操作系统并不会变更该结构体的任何内容,所以 TCP 连接的状态也不会发生改变。\n我在我的电脑上做了个小实验,我用 ssh 终端连接了我的云服务器,然后我通过断开 wifi 的方式来模拟拔掉网线的场景,此时查看 TCP 连接的状态没有发生变化,还是处于 ESTABLISHED 状态。 通过上面这个实验结果,我们知道了,拔掉网线这个动作并不会影响 TCP 连接的状态。 接下来,要看拔掉网线后,双方做了什么动作。 针对这个问题,要分场景来讨论:\n拔掉网线后,有数据传输; 拔掉网线后,没有数据传输。 拔掉网线后,有数据传输 在客户端拔掉网线后,服务端向客户端发送的数据报文会得不到任何的响应,在等待一定时长后,服务端就会触发超时重传机制,重传未得到响应的数据报文。\n如果在服务端重传报文的过程中,客户端刚好把网线插回去了,由于拔掉网线并不会改变客户端的 TCP 连接状态,并且还是处于 ESTABLISHED 状态,所以这时客户端是可以正常接收服务端发来的数据报文的,然后客户端就会回 ACK 响应报文。\n此时,客户端和服务端的 TCP 连接依然存在,就感觉什么事情都没有发生。\n但是,如果在服务端重传报文的过程中,客户端一直没有将网线插回去,服务端超时重传报文的次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,于是服务端的 TCP 连接就会断开。\n而等客户端插回网线后,如果客户端向服务端发送了数据,由于服务端已经没有与客户端相同四元组的 TCP 连接了,因此服务端内核就会回复 RST 报文,客户端收到后就会释放该 TCP 连接。\n此时,客户端和服务端的 TCP 连接都已经断开了。\n那 TCP 的数据报文具体重传几次呢? 在 Linux 系统中,提供了一个叫 tcp_retries2 配置项,默认值是 15,如下:\n1 2 [root@nebula-server-6 shell]# cat /proc/sys/net/ipv4/tcp_retries2 15 这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。\n不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,内核还会基于「最大超时时间」来判定。\n每一轮的超时时间都是倍数增长的,比如第一次触发超时重传是在 2s 后,第二次则是在 4s 后,第三次则是 8s 后,以此类推。\n内核会根据 tcp_retries2 设置的值,计算出一个最大超时时间。\n在重传报文且一直没有收到对方响应的情况时,先达到「最大重传次数」或者「最大超时时间」这两个的其中一个条件后,就会停止重传,然后就会断开 TCP 连接。\n拔掉网线后,没有数据传输。 针对拔掉网线后,没有数据传输的场景,还得看是否开启了 TCP keepalive 机制 (TCP 保活机制)。\n如果没有开启 TCP keepalive 机制,在客户端拔掉网线后,并且双方都没有进行数据传输,那么客户端和服务端的 TCP 连接将会一直保持存在。\n而如果开启了 TCP keepalive 机制,在客户端拔掉网线后,即使双方都没有进行数据传输,在持续一段时间后,TCP 就会发送探测报文:\n如果对端是正常工作的。当 TCP 保活的探测报文发送给对端,对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。 如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活。 TCP keepalive 机制具体是怎么样的? 这个机制的原理是这样的: 定义一个时间段,在这个时间段内,如果没有任何连接相关的活动,TCP 保活机制会开始作用,每隔一个时间间隔,发送一个探测报文,该探测报文包含的数据非常少,如果连续几个探测报文都没有得到响应,则认为当前的 TCP 连接已经死亡,系统内核将错误信息通知给上层应用程序。\n在 Linux 内核有对应的参数可以设置保活时间、保活探测的次数、保活探测的时间间隔,以下都为默认值:\n1 2 3 net.ipv4.tcp_keepalive_time=7200 net.ipv4.tcp_keepalive_intvl=75 net.ipv4.tcp_keepalive_probes=9 tcp_keepalive_time=7200:表示保活时间是 7200 秒(2小时),也就 2 小时内如果没有任何连接相关的活动,则会启动保活机制; tcp_keepalive_intvl=75:表示每次检测间隔 75 秒; tcp_keepalive_probes=9:表示检测 9 次无响应,认为对方是不可达的,从而中断本次的连接。 也就是说在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个「死亡」连接。\ntcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes ==\u0026gt; 7200 + 75 * 9 =7879s (2h11min15s)\n注意,应用程序若想使用 TCP 保活机制,需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。\nTCP keepalive 机制探测的时间也太长了吧? 对的,是有点长。\nTCP keepalive 是 TCP 层(内核态) 实现的,它是给所有基于 TCP 传输协议的程序一个兜底的方案。\n实际上,我们应用层可以自己实现一套探测机制,可以在较短的时间内,探测到对方是否存活。\n比如,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。如果设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在发完一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。 总结 客户端拔掉网线后,并不会直接影响 TCP 连接状态。所以,拔掉网线后,TCP 连接是否还会存在,关键要看拔掉网线之后,有没有进行数据传输。\n有数据传输的情况:\n在客户端拔掉网线后,如果服务端发送了数据报文,那么在服务端重传次数没有达到最大值之前,客户端就插回了网线,那么双方原本的 TCP 连接还是能正常存在,就好像什么事情都没有发生。\n在客户端拔掉网线后,如果服务端发送了数据报文,在客户端插回网线之前,服务端重传次数达到了最大值时,服务端就会断开 TCP 连接。等到客户端插回网线后,向服务端发送了数据,因为服务端已经断开了与客户端相同四元组的 TCP 连接,所以就会回 RST 报文,客户端收到后就会断开 TCP 连接。至此, 双方的 TCP 连接都断开了。\n没有数据传输的情况:\n如果双方都没有开启 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,那么客户端和服务端的 TCP 连接状态将会一直保持存在。 如果双方都开启了 TCP keepalive 机制,那么在客户端拔掉网线后,如果客户端一直不插回网线,TCP keepalive 机制会探测到对方的 TCP 连接没有存活,于是就会断开 TCP 连接。而如果在 TCP 探测期间,客户端插回了网线,那么双方原本的 TCP 连接还是能正常存在。 除了客户端拔掉网线的场景,还有客户端「宕机和杀死进程」的两种场景。\n第一个场景,客户端宕机这件事跟拔掉网线是一样无法被服务端感知的,所以如果在没有数据传输,并且没有开启 TCP keepalive 机制时,服务端的 TCP 连接将会一直处于 ESTABLISHED 连接状态,直到服务端重启进程。\n所以,我们可以得知一个点。在没有使用 TCP 保活机制,且双方不传输数据的情况下,一方的 TCP 连接处在 ESTABLISHED 状态时,并不代表另一方的 TCP 连接还一定是正常的。\n第二个场景,杀死客户端的进程后,客户端的内核就会向服务端发送 FIN 报文,与客户端进行四次挥手。\n所以,即使没有开启 TCP keepalive,且双方也没有数据交互的情况下,如果其中一方的进程发生了崩溃,这个过程操作系统是可以感知得到的,于是就会发送 FIN 报文给对方,然后与对方进行 TCP 四次挥手。\n扩展 TCP重置报文段及RST常见场景分析 RST表示连接重置,用于关闭那些已经没有必要继续存在的连接。一般情况下表示异常关闭连接,区别与四次分手正常关闭连接。\n我们知道TCP建立连接的时候需要三次连接,TCP释放连接的时候需要四次挥手,在这个过程中,出现了很多特殊的标志报文段,例如SYN ACK FIN,在TCP协议中,除了上面说了那些标志报文段之外,还有其他的报文段,如PUSH标志报文段以及今天需要重点讲解的RST报文段。\nRST:(Reset the connection)用于复位因某种原因引起出现的错误连接,也用来拒绝非法数据和请求。如果接收到RST位时候,通常发生了某些错误;\n发送RST包关闭连接时,不必等缓冲区的包都发出去,直接就丢弃缓冲区中的包,发送RST;接收端收到RST包后,也不必发送ACK包来确认。\n“Connection reset”的原因是服务器关闭了Connection[调用了Socket.close()方法]。大家可能有疑问了:服务器关闭了Connection为什么会返回“RST”而不是返回“FIN”标志。原因在于Socket.close()方法的语义和TCP的“FIN”标志语义不一样: 发送TCP的“FIN”标志表示我不再发送数据了,而Socket.close()表示我不在发送也不接受数据了。问题就出在“我不接受数据” 上,如果此时客户端还往服务器发送数据,服务器内核接收到数据,但是发现此时Socket已经close了,则会返回“RST”标志给客户端。当然,此时客户端就会提示:“Connection reset”。\n产生RST的三个条件是:\n目的地 为某端口的SYN到达,然而在该端口上并没有正在监听的服务器; TCP想取消一个已有连接; TCP接收到一个根本不存在的连接上的分节。 Connection reset 与 Connection reset by peer 服务器返回了 “RST” 时,如果此时客户端正在从 Socket 套接字的输出流中读数据则会提示 Connection reset ; A向B发起连接,但B之上并未监听相应的端口,这时B操作系统上的 TCP 处理程序会发 RST 包。\n服务器返回了 “RST” 时,如果此时客户端正在往 Socket 套接字的输入流中写数据则会提示 Connection reset by peer 。 AB正常建立连接了,正在通讯时,A向B发送了FIN包要求关连接,B发送ACK后,网断了,A通过若干原因放弃了这个连接(例如进程重启)。 等网络恢复之后,B又开始发数据包(客户端并不知道,服务器已经忘记三次握手了),A收到后表示压力很大,不知道这野连接哪来的,就发了个RST包强制把连接关了,B收到后会出现 connect reset by peer 错误。\n需要注意的是,服务端有两种情况不会发送RST:\n服务器关机: 会断开 TCP 连接,会发送 FIN 数据报\n服务器主机崩溃的状态 如果,客户端和服务器已经建立了连接的时候,此时服务器崩溃(达到这一标准可以把服务器的网线拔掉,这个时候,服务器就不能发送 FIN 数据报了,和关机不一样的)\n此时如果客户端向服务器发送数据的时候,因为服务器已经不存在了,那么客户端就不能接受到服务器给客户端的 ack 信息,这个时候,客户端建立的是 TCP 连接,就会重发数据报,发送多少次之后就会返回超时,也就是 ETIMEOUT 。\nETIMEOUT:当connect调用的时候会进行三次握手,如果客户端没有收到服务器对SYN的ACK数据报,就会返回ETIMEOUT(客户端在返回这个错误之前会重发SYN数据报)\n前面谈到了导致 “Connection reset” 的原因,而具体的解决方案有如下几种:\n出错了重试; 客户端和服务器统一使用TCP长连接; 客户端和服务器统一使用TCP短连接。 首先是出错了重试:这种方案可以简单防止 “Connection reset” 错误,然后如果服务不是 “幂等” 的则不能使用该方法;比如提交订单操作就不是幂等的,如果使用重试则可能造成重复提单。\n然后是客户端和服务器统一使用 TCP 长连接:客户端使用 TCP 长连接很容易配置(直接设置HttpClient就好),而服务器配置长连接就比较麻烦了,就拿tomcat来说,需要设置 tomcat 的 maxKeepAliveRequests 、connectionTimeout 等参数。另外如果使用了 nginx 进行反向代理或负载均衡,此时也需要配置 nginx 以支持长连接(nginx默认是对客户端使用长连接,对服务器使用短连接,详见 keepalived 相关指令)。\n使用长连接可以避免每次建立 TCP 连接的三次握手而节约一定的时间,但是我这边由于是内网,客户端和服务器的 3 次握手很快,大约只需1ms。ping一下大约0.93ms(一次往返);三次握手也是一次往返(第三次握手不用返回)。根据80/20原理,1ms可以忽略不计;又考虑到长连接的扩展性不如短连接好、修改nginx和tomcat的配置代价很大(所有后台服务都需要修改);所以这里并没有使用长连接。\n小结\nConnection reset,远程主机没有监听这个端口、连接,可以是: 服务端已关闭,客户端仍旧请求,服务端返回Rst; 服务端未监听该端口,客户端请求,服务端返回Rst; Connection reset by peer,是远程主机强迫关闭了一个现有的连接,可以是: 客户端断网重连,服务端返回Rst; 服务端进程崩溃后重启,向先前的客户端返回Rst,并等待下次重新与客户端建连; ","permalink":"https://reid00.github.io/en/posts/os_network/%E6%8B%94%E6%8E%89%E7%BD%91%E7%BA%BF%E5%90%8E%E5%8E%9F%E6%9C%AC%E7%9A%84tcp%E8%BF%9E%E6%8E%A5%E8%BF%98%E5%AD%98%E5%9C%A8%E5%90%97/","summary":"背景 今天,聊一个有趣的问题:拔掉网线几秒,再插回去,原本的 TCP 连接还存在吗? 可能有的同学会说,网线都被拔掉了,那说明物理层被断开了,那在上层的","title":"拔掉网线后,原本的TCP连接还存在吗?"},{"content":"安装 Utterances 首先要有一个 GitHub 仓库。如果是用 GitHub Page 托管网站就可以不需要额外创建,就用你的GitHub Page repositroy 如:.github.io 仓库, 当然也可以自己重新创建一个,用来存放评论。但是需要注意的是这个仓库必须是Public 的。 比如我的为https://github.com/Reid00/hugo-blog-talks\n然后去 https://github.com/apps/utterances 安装 utterances。\n在打开的页面中选择Only select repositories,并在下拉框中选择自己的博客仓库(比如我就是 Reid00/hugo-blog-talks,也可以安装到其他仓库, 也可以所有仓库,但是不推荐),然后点击 Install。 配置Hugo 复制以下代码,repo 要修改成自己的仓库,repo 为你存放评论的仓库。\n1 2 3 4 5 6 7 8 \u0026lt;script src=\u0026#34;https://utteranc.es/client.js\u0026#34; repo=\u0026#34;Reid00/hugo-blog-talks\u0026#34; issue-term=\u0026#34;pathname\u0026#34; label=\u0026#34;Comment\u0026#34; theme=\u0026#34;github-light\u0026#34; crossorigin=\u0026#34;anonymous\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; 在主题配置目录下创建 layouts/partials/comments.html 文件,并添加上述内容\n1 2 3 4 5 6 7 8 9 10 11 {{- /* Comments area start */ -}} {{- /* to add comments read =\u0026gt; https://gohugo.io/content-management/comments/ */ -}} \u0026lt;script src=\u0026#34;https://utteranc.es/client.js\u0026#34; repo=\u0026#34;Reid00/hugo-blog-talks\u0026#34; issue-term=\u0026#34;pathname\u0026#34; label=\u0026#34;Comment\u0026#34; theme=\u0026#34;github-light\u0026#34; crossorigin=\u0026#34;anonymous\u0026#34; async\u0026gt; \u0026lt;/script\u0026gt; {{- /* Comments area end */ -}} 然后根据 PaperMod 文档,打开 config.yml 文件,添加以下内容\n1 2 params: comments: true 问题 如果你用GitHub Action 部署的GitHub Page, 并且你的workflow 里面写了\n1 2 3 4 5 - name: Check out repository code uses: actions/checkout@v3 with: submodules: recursive # Fetch Hugo themes (true OR recursive) fetch-depth: 0 这个时候,theme 主题会自动pull 源主题的repo,覆盖layouts/partials/comments.html 文件, 此时,你可以在项目的layouts 创建改文件和内容即可。\n","permalink":"https://reid00.github.io/en/posts/other/utterances-%E7%BB%99-hugo-papermod-%E4%B8%BB%E9%A2%98%E6%B7%BB%E5%8A%A0%E8%AF%84%E8%AE%BA%E7%B3%BB%E7%BB%9F/","summary":"安装 Utterances 首先要有一个 GitHub 仓库。如果是用 GitHub Page 托管网站就可以不需要额外创建,就用你的GitHub Page repositroy 如:.github.io 仓库, 当然也可以自己重新","title":"Utterances 给 Hugo PaperMod 主题添加评论系统"},{"content":"准备vscode 插件 在vs code的扩展商店中搜索remote-ssh, install 配置remmote-ssh 插件 使用快捷点, ctrl + shift + P 输入config 选择第一个,在.ssh 目录的config文件 按照以下格式配置\n1 2 3 4 5 Host Personal HostName 172.16.1.1 User root Port 22 IdentityFile C:\\Users\\ld\\.ssh\\id_rsa Host: 自定义的服务器名称,用于个人区分 HostName: 需要远程的服务器的IP 地址 User: 远程服务器用的账号 Port: 默认ssh 端口22 IdentityFile: 免登录的id_rsa路径 注意: 多次实验加入IdentityFile 都不能做到通过跳板机免密码,最后把客户机的id_rsa.pub添加到target 才免密, 相当于直接可以连接target机器了。\n如果通过跳板机连接服务器 有时候我们需要跳板机来连接服务器,也即先连接一台跳板机服务器,然后通过这台跳板机所在的内网再次跳转到目标服务器。 最简单的做法就是按上述方法连接到跳板机,然后在跳板机的终端用ssh指令跳转到目标服务器,但这样跳转后,我们无法在VScode中打开服务器的文件目录,操作起来很不方便。我们可以把config的设置改成如下,就可以通过c00跳板机跳转到c01了\n1 2 3 4 5 6 Host BackupCluster HostName 1.16.1.1 User root Port 22 ProxyCommand C:\\Windows\\System32\\OpenSSH\\ssh.exe -W %h:%p -q Personal IdentityFile C:\\Users\\ld\\.ssh\\id_rsa ProxyCommand: openssh的安装目录(我这里是C:\\Windows\\System32\\OpenSSH\\ssh.exe) -W表示stdio forwarding模式 接着后面的%h是一个占位符,表示要连接的目标机,也就是Hostname指定的ip或者主机名 %p同样也是占位符,表示要连接到目标机的端口。 %h, %p这里可以直接写死固定值,但是使用%h和%p可以保证在Hostname和Port变化的情况下ProxyCommand这行不用跟着变化 openssh 的安装方法\n以管理员身份运行window Powershell,然后键入如下两条命令\n1 Get-WindowsCapability -Online | ? Name -like \u0026#39;OpenSSH*\u0026#39; 这条是用来检测是否有适合安装的openssh软件,正常情况下应有如下返回:\n1 2 3 4 Name : OpenSSH.Client~~~~0.0.1.0 State : NotPresent Name : OpenSSH.Server~~~~0.0.1.0 State : NotPresent 第二条命令:\n1 Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0 如果安装完成应有如下返回:\n1 2 3 Path : Online : True RestartNeeded : False 免密码登录 如从A登录B,A=\u0026gt;B 本机创建ssh密钥, 生成在~/.ssh目录下\n1 ssh-keygen -t rsa 说明:\nauthorized_keys:其实就是存放各个机器公钥的地方, id_rsa : 生成的私钥文件, id_rsa.pub : 生成的公钥文件, know_hosts : 已知的主机公钥清单, 设置互信其实就是将id_rsa.pub公钥信息发送到需要被信任的机器上的authorized_keys文件中即可,也就是A发送到B上authorized_keys文件中\n发送密钥 在A 机上运行\n1 ssh-copy-id root@192.168.x.x 如果ssh-copy-id执行不了的话(没有ssh默认库的情况),可以使用scp进行发送,即:\n1 scp -p ~/.ssh/id_rsa.pub root@192.168.x.x:/root/.ssh/authorized_keys 通过以上命令, 即可将公钥发送过去,发送过去之后可以登录B机器查看authorized_keys文件,可以看到了机器A的公钥信息,如过是多个机器发送给B的话则保存多个公钥信息,如下 发送成功之后,再次ssh登录,从A机器登录到B 机器。\n","permalink":"https://reid00.github.io/en/posts/other/vscode%E8%BF%9C%E7%A8%8B%E5%BC%80%E5%8F%91%E9%85%8D%E7%BD%AE/","summary":"准备vscode 插件 在vs code的扩展商店中搜索remote-ssh, install 配置remmote-ssh 插件 使用快捷点, ctrl + shift + P 输入confi","title":"Vscode远程开发配置"},{"content":"在做接口测试时,经常会碰到请求参数为token的类型,但是可能大部分测试人员对token,cookie,session的区别还是一知半解。\nCookie cookie 是一个非常具体的东西,指的就是浏览器里面能永久存储的一种数据,仅仅是浏览器实现的一种数据存储功能。\ncookie由服务器生成,发送给浏览器,浏览器把cookie以kv形式保存到某个目录下的文本文件内,下一次请求同一网站时会把该cookie发送给服务器。由于cookie是存在客户端上的,所以浏览器加入了一些限制确保cookie不会被恶意使用,同时不会占据太多磁盘空间,所以每个域的cookie数量是有限的。\nSession session 从字面上讲,就是会话。这个就类似于你和一个人交谈,你怎么知道当前和你交谈的是张三而不是李四呢?对方肯定有某种特征(长相等)表明他就是张三。\nsession 也是类似的道理,服务器要知道当前发请求给自己的是谁。为了做这种区分,服务器就要给每个客户端分配不同的“身份标识”,然后客户端每次向服务器发请求的时候,都带上这个“身份标识”,服务器就知道这个请求来自于谁了。至于客户端怎么保存这个“身份标识”,可以有很多种方式,对于浏览器客户端,大家都默认采用 cookie 的方式。\n服务器使用session把用户的信息临时保存在了服务器上,用户离开网站后session会被销毁。这种用户信息存储方式相对cookie来说更安全,可是session有一个缺陷:如果web服务器做了负载均衡,那么下一个操作请求到了另一台服务器的时候session会丢失。\nToken Token的引入:Token是在客户端频繁向服务端请求数据,服务端频繁的去数据库查询用户名和密码并进行对比,判断用户名和密码正确与否,并作出相应提示,在这样的背景下,Token便应运而生。\nToken的定义:Token是服务端生成的一串字符串,以作客户端进行请求的一个令牌,当第一次登录后,服务器生成一个Token便将此Token返回给客户端,以后客户端只需带上这个Token前来请求数据即可,无需再次带上用户名和密码。最简单的token组成:uid(用户唯一的身份标识)、time(当前时间的时间戳)、sign(签名,由token的前几位+盐以哈希算法压缩成一定长的十六进制字符串,可以防止恶意第三方拼接token请求服务器)。\n使用Token的目的:Token的目的是为了减轻服务器的压力,减少频繁的查询数据库,使服务器更加健壮。\n传统身份验证 HTTP 是一种没有状态的协议,也就是它并不知道是谁是访问应用。这里我们把用户看成是客户端,客户端使用用户名还有密码通过了身份验证,不过下回这个客户端再发送请求时候,还得再验证一下。\n解决的方法就是,当用户请求登录的时候,如果没有问题,我们在服务端生成一条记录,这个记录里可以说明一下登录的用户是谁,然后把这条记录的 ID 号发送给客户端,客户端收到以后把这个 ID 号存储在 Cookie 里,下次这个用户再向服务端发送请求的时候,可以带着这个 Cookie ,这样服务端会验证一个这个 Cookie 里的信息,看看能不能在服务端这里找到对应的记录,如果可以,说明用户已经通过了身份验证,就把用户请求的数据返回给客户端。\n上面说的就是 Session,我们需要在服务端存储为登录的用户生成的 Session ,这些 Session 可能会存储在内存,磁盘,或者数据库里。我们可能需要在服务端定期的去清理过期的 Session 。\n基于 Token 的身份验证 使用基于 Token 的身份验证方法,在服务端不需要存储用户的登录记录。大概的流程是这样的:\n客户端使用用户名跟密码请求登录 服务端收到请求,去验证用户名与密码 验证成功后,服务端会签发一个 Token,再把这个 Token 发送给客户端 客户端收到 Token 以后可以把它存储起来,比如放在 Cookie 里或者 Local Storage 里 客户端每次向服务端请求资源的时候需要带着服务端签发的 Token 服务端收到请求,然后去验证客户端请求里面带着的 Token,如果验证成功,就向客户端返回请求的数据 APP登录的时候发送加密的用户名和密码到服务器,服务器验证用户名和密码,如果成功,以某种方式比如随机生成32位的字符串作为token,存储到服务器中,并返回token到APP,以后APP请求时,凡是需要验证的地方都要带上该token,然后服务器端验证token,成功返回所需要的结果,失败返回错误信息,让他重新登录。其中服务器上token设置一个有效期,每次APP请求的时候都验证token和有效期。\n那么我的问题来了:1.服务器上的token存储到数据库中,每次查询会不会很费时。如果不存储到数据库,应该存储到哪里呢。2.客户端得到的token肯定要加密存储的,发送token的时候再解密。存储到数据库还是配置文件呢?\ntoken是个易失数据,丢了无非让用户重新登录一下,新浪微博动不动就让我重新登录,反正这事儿我是无所谓啦。 所以如果你觉得普通的数据库表撑不住了,可以放到 MSSQL/MySQL 的内存表里(不过据说mysql的内存表性能提升有限),可以放到 Memcache里(讲真,这个是挺常见的策略),可以放到redis里(我做过这样的实现),甚至可以放到 OpenResty 的变量字典里(只要你有信心不爆内存)。\ntoken是个凭条,不过它比门票温柔多了,门票丢了重新花钱买,token丢了重新操作下认证一个就可以了,因此token丢失的代价是可以忍受的——前提是你别丢太频繁,要是让用户隔三差五就认证一次那就损失用户体验了。\n基于这个出发点,如果你认为用数据库来保持token查询时间太长,会成为你系统的瓶颈或者隐患,可以放在内存当中。 比如memcached、redis,KV方式很适合你对token查询的需求。 这个不会太占内存,比如你的token是32位字符串,要是你的用户量在百万级或者千万级,那才多少内存。 要是数据量真的大到单机内存扛不住,或者觉得一宕机全丢风险大,只要这个token生成是足够均匀的,高低位切一下分到不同机器上就行,内存绝对不会是问题。\n客户端方面这个除非你有一个非常安全的办法,比如操作系统提供的隐私数据存储,那token肯定会存在泄露的问题。比如我拿到你的手机,把你的token拷出来,在过期之前就都可以以你的身份在别的地方登录。 解决这个问题的一个简单办法 1、在存储的时候把token进行对称加密存储,用时解开。 2、将请求URL、时间戳、token三者进行合并加盐签名,服务端校验有效性。 这两种办法的出发点都是:窃取你存储的数据较为容易,而反汇编你的程序hack你的加密解密和签名算法是比较难的。然而其实说难也不难,所以终究是防君子不防小人的做法。话说加密存储一个你要是被人扒开客户端看也不会被喷明文存储…… 方法1它拿到存储的密文解不开、方法2它不知道你的签名算法和盐,两者可以结合食用。 但是如果token被人拷走,他自然也能植入到自己的手机里面,那到时候他的手机也可以以你的身份来用着,这你就瞎了。 于是可以提供一个让用户可以主动expire一个过去的token类似的机制,在被盗的时候能远程止损。\n在网络层面上token明文传输的话会非常的危险,所以建议一定要使用HTTPS,并且把token放在post body里。\n补充 cookie与session的区别 1、cookie数据存放在客户端上,session数据放在服务器上。\n2、cookie不是很安全,别人可以分析存放在本地的COOKIE并进行COOKIE欺骗 考虑到安全应当使用session。\n3、session会在一定时间内保存在服务器上。当访问增多,会比较占用你服务器的性能 考虑到减轻服务器性能方面,应当使用COOKIE。\n4、单个cookie保存的数据不能超过4K,很多浏览器都限制一个站点最多保存20个cookie。\n5、所以个人建议: 将登陆信息等重要信息存放为SESSION 其他信息如果需要保留,可以放在COOKIE中\nsession与token的区别 session 和 oauth token并不矛盾,作为身份认证 token安全性比session好,因为每个请求都有签名还能防止监听以及重放攻击,而session就必须靠链路层来保障通讯安全了。如上所说,如果你需要实现有状态的会话,仍然可以增加session来在服务器端保存一些状态\nApp通常用restful api跟server打交道。Rest是stateless的,也就是app不需要像browser那样用cookie来保存session,因此用session token来标示自己就够了,session/state由api server的逻辑处理。 如果你的后端不是stateless的rest api, 那么你可能需要在app里保存session.可以在app里嵌入webkit,用一个隐藏的browser来管理cookie session.\nSession 是一种HTTP存储机制,目的是为无状态的HTTP提供的持久机制。所谓Session 认证只是简单的把User 信息存储到Session 里,因为SID 的不可预测性,暂且认为是安全的。这是一种认证手段。 而Token ,如果指的是OAuth Token 或类似的机制的话,提供的是 认证 和 授权 ,认证是针对用户,授权是针对App 。其目的是让 某App有权利访问 某用户 的信息。这里的 Token是唯一的。不可以转移到其它 App上,也不可以转到其它 用户 上。 转过来说Session 。Session只提供一种简单的认证,即有此 SID,即认为有此 User的全部权利。是需要严格保密的,这个数据应该只保存在站方,不应该共享给其它网站或者第三方App。 所以简单来说,如果你的用户数据可能需要和第三方共享,或者允许第三方调用 API 接口,用 Token 。如果永远只是自己的网站,自己的 App,用什么就无所谓了。\n打破误解: “只要关闭浏览器 ,session就消失了?”\n不对。对session来说,除非程序通知服务器删除一个session,否则服务器会一直保留,程序一般都是在用户做log off的时候发个指令去删除session。\n然而浏览器从来不会主动在关闭之前通知服务器它将要关闭,因此服务器根本不会有机会知道浏览器已经关闭,之所以会有这种错觉,是大部分session机制都使用会话cookie来保存session id,而关闭浏览器后这个session id就消失了,再次连接服务器时也就无法找到原来的session。如果服务器设置的cookie被保存在硬盘上,或者使用某种手段改写浏览器发出的HTTP请求头,把原来的session id发送给服务器,则再次打开浏览器仍然能够打开原来的session.\n恰恰是**由于关闭浏览器不会导致session被删除,迫使服务器为session设置了一个失效时间,当距离客户端上一次使用session的时间超过这个失效时间时,服务器就可以以为客户端已经停止了活动,才会把session删除以节省存储空间。**\n","permalink":"https://reid00.github.io/en/posts/os_network/token-cookie-session%E5%8C%BA%E5%88%AB/","summary":"在做接口测试时,经常会碰到请求参数为token的类型,但是可能大部分测试人员对token,cookie,session的区别还是一知半解。 Cookie","title":"Token Cookie Session区别"},{"content":"简介 这有篇很好的文章,可以明白这个问题:\n为什么会报错“UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)”?本文就来研究一下这个问题。\n字符串在Python内部的表示是unicode编码,因此,在做编码转换时,通常需要以unicode作为中间编码,即先将其他编码的字符串解码(decode)成unicode,再从unicode编码(encode)成另一种编码。\ndecode的作用是将其他编码的字符串转换成unicode编码,如str1.decode('gb2312'),表示将gb2312编码的字符串str1转换成unicode编码。\nencode的作用是将unicode编码转换成其他编码的字符串,如str2.encode('gb2312'),表示将unicode编码的字符串str2转换成gb2312编码。\n因此,转码的时候一定要先搞明白,字符串str是什么编码,然后decode成unicode,然后再encode成其他编码\n代码中字符串的默认编码与代码文件本身的编码一致。\n如:s=\u0026lsquo;中文\u0026rsquo;\n如果是在utf8的文件中,该字符串就是utf8编码,如果是在gb2312的文件中,则其编码为gb2312。这种情况下,要进行编码转换,都需 要先用decode方法将其转换成unicode编码,再使用encode方法将其转换成其他编码。通常,在没有指定特定的编码方式时,都是使用的系统默 认编码创建的代码文件。\n如果字符串是这样定义:s=u\u0026rsquo;中文'\n则该字符串的编码就被指定为unicode了,即python的内部编码,而与代码文件本身的编码无关。因此,对于这种情况做编码转换,只需要直接使用encode方法将其转换成指定编码即可。\n如果一个字符串已经是unicode了,再进行解码则将出错,因此通常要对其编码方式是否为unicode进行判断:\nisinstance(s, unicode) #用来判断是否为unicode\n用非unicode编码形式的str来encode会报错\n如何获得系统的默认编码?\n1 2 3 4 5 6 7 #!/usr/bin/env python #coding=utf-8 import sys print sys.getdefaultencoding() 该段程序在英文WindowsXP上输出为:ascii\n在某些IDE中,字符串的输出总是出现乱码,甚至错误,其实是由于IDE的结果输出控制台自身不能显示字符串的编码,而不是程序本身的问题。\n如在UliPad中运行如下代码:\n1 2 3 s=u\u0026#34;中文\u0026#34; print s 会提示:UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)。这是因为UliPad在英文WindowsXP上的控制台信息输出窗口是按照ascii编码输出的(英文系统的默认编码是 ascii),而上面代码中的字符串是Unicode编码的,所以输出时产生了错误。\n将最后一句改为:print s.encode('gb2312')\n则能正确输出“中文”两个字。\n若最后一句改为:print s.encode('utf8')\n则输出:\\xe4\\xb8\\xad\\xe6\\x96\\x87,这是控制台信息输出窗口按照ascii编码输出utf8编码的字符串的结果。\nunicode(str,'gb2312')与str.decode('gb2312')是一样的,都是将gb2312编码的str转为unicode编码\n使用str.__class__可以查看str的编码形式\n1 2 3 4 5 \u0026gt;\u0026gt;\u0026gt;\u0026gt;\u0026gt; groups.google.com/group/python-cn/browse_thread/thread/be4e4e0d4c3272dd ----- python是个容易出现编码问题的语言。所以,我按照我的理解写下下面这些文字。\n1. 首先,要了解几个概念。 字节:计算机数据的表示。8位二进制。可以表示无符号整数:0-255。下文,用“字节流”表示“字节”组成的串。\n字符:英文字符“abc”,或者中文字符“你我他”。字符本身不知道如何在计算机中保存。下文中,会避免使用“字符串”这个词,而用“文本”来表\n示“字符”组成的串。\n编码(动词):按照某种规则(这个规则称为:编码(名词))将“文本”转换为“字节流”。(在python中:unicode变成str)\n解码(动词):将“字节流”按照某种规则转换成“文本”。(在Python中:str变成unicode)\n实际上,任何东西在计算机中表示,都需要编码。例如,视频要编码然后保存在文件中,播放的时候需要解码才能观看。\nunicode:unicode定义了,一个“字符”和一个“数字”的对应,但是并没有规定这个“数字”在计算机中怎么保存。(就像在C中,一个整数既\n可以是int,也可以是short。unicode没有规定用int还是用short来表示一个“字符”)\nutf8:unicode实现。它使用unicode定义的“字符”“数字”映射,进而规定了,如何在计算机中保存这个数字。其它的utf16等都是 unicode实现。\ngbk:类似utf8这样的“编码”。但是它没有使用unicode定义的“字符”“数字”映射,而是使用了另一套的映射方法。而且,它还定义了如何在计算机中保存。\n2. python中的encode,decode方法 首先,要知道encode是 unicode转换成str。decode是str转换成unicode。\n下文中,u代表unicode类型的变量,s代表str类型的变量。\nu.encode('...')基本上总是能成功的,只要你填写了正确的编码。就像任何文件都可以压缩成zip文件。\ns.decode('...')经常是会出错的,因为str是什么“编码”取决于上下文,当你解码的时候需要确保s是用什么编码的。就像,打开zip文件的时候,你要确保它确实是zip文件,而不仅仅是伪造了扩展名的zip文件。\nu.decode(),s.encode()不建议使用,s.encode相当于s.decode().encode()首先用默认编码(一般是ascii)转换成unicode在进行encode。\n3. 关于#coding=utf8= 当你在py文件的第一行中,写了这句话,并确实按照这个编码保存了文本的话,那么这句话有以下几个功能。\n1.使得词法分析器能正常运作,对于注释中的中文不报错了。\n2.对于u\u0026quot;中文\u0026quot;这样literal string能知道两个引号中的内容是utf8编码的,然后能正确转换成unicode\n3.\u0026ldquo;中文\u0026quot;对于这样的literal string你会知道,这中间的内容是utf8编码,然后就可以正确转换成其它编码或unicode了。\n4. Python编码和Windows控制台 我发现,很多初学者出错的地方都在print语句,这牵涉到控制台的输出。我不了解linux,所以只说控制台的。\n首先,Windows的控制台确实是unicode(utf16_le编码)的,或者更准确的说使用字符为单位输出文本的。\n但是,程序的执行是可以被重定向到文件的,而文件的单位是“字节”。\n所以,对于C运行时的函数printf之类的,输出必须有一个编码,把文本转换成字节。可能是为了兼容95,98,\n没有使用unicode的编码,而是mbcs(不是gbk之类的)。\nwindows的mbcs,也就是ansi,它会在不同语言的windows中使用不同的编码,在中文的windows中就是gb系列的编码。\n这造成了同一个文本,在不同语言的windows中是不兼容的。\n现在我们知道了,如果你要在windows的控制台中输出文本,它的编码一定要是“mbcs”。\n对于python的unicode变量,使用print输出的话,会使用sys.getfilesystemencoding()返回的编码,把它变成str。\n如果是一个utf8编码str变量,那么就需要 print s.decode(\u0026lsquo;utf8\u0026rsquo;).encode(\u0026lsquo;mbcs\u0026rsquo;)\n最后,对于str变量,file文件读取的内容,urllib得到的网络上的内容,都是以“字节”形式的。\n它们如果确实是一段“文本”,比如你想print出来看看。那么你必须知道它们的编码。然后decode成unicode。\n如何知道它们的编码:\n事先约定。(比如这个文本文件就是你自己用utf8编码保存的) 协议。(python文件第一行的#coding=utf8,html中的等) 猜。 这个非常好,但还不是很明白将“文本”转换为“字节流”。(在python中:unicode变成str)\n\u0026ldquo;最后,对于str变量,file文件读取的内容,urllib得到的网络上的内容,都是以“字节”形式的。\u0026rdquo;\n虽然文件或者网页是文本的,但是在保存或者传输时已经被编码成bytes了,所以用\u0026quot;rb\u0026quot;打开的file和从socket读取的流是基于字节的.\n\u0026ldquo;它们如果确实是一段“文本”,比如你想print出来看看。那么你必须知道它们的编码。然后decode成unicode。\u0026rdquo;\n这里的加引号的\u0026quot;文本\u0026rdquo;,其实还是字节流(bytes),而不是真正的文本(unicode),只是说明我们知道他是可以解码成文本的.\n在解码的时候,如果是基于约定的,那就可以直接从指定地方读取如BOM或者python文件的指定coding或者网页的meta,就可以正确解码,\n但是现在很多文件/网页虽然指定了编码,但是文件格式实际却使用了其他的编码(比如py文件指定了coding=utf8,但是你还是可以保存成ansi\u0026ndash;记事本的默认编码),这种情况下真实的编码就需要去猜了\n解码了的文本只存在运行环境中,如果你需要打印/保存/输出给数据库/网络传递,就又需要一次编码过程,这个编码与上面的编码没有关系,只是依赖于你的选择,但是这个编码也不是可以随便选择的,因为编码后的bytes如果又需要传递给其他人/环境,那么如果你的编码也不遵循约定,又给下一个人/环境造成了困扰,于是递归之~~~~\n主要有一条非常容易误解:\n一般人会认为Unicode(广义)统一了编码,其实不然。Unicode不是唯一的编码,而一大堆编码的统称。但是Windows下Unicode(狭义)一般特指UCS2,也就是UTF-16/LE\nunicode作为字符集(ucs)是唯一的,编码方案(utf)才是有很多种\n将字符与字节的概念区分开来是很重要的。Java 一直就是这样,Python也开始这么做了,Ruby 貌似还在混乱当中。\n我也说两句。我对编码的研究相对比较深一些。因为工作中也经常遇到乱码,于是在05年,对编码专门做过研究,并在公司刊物上发过文章,最后形成了一个教材,每年在公司给新员工都讲一遍。于是项目中遇到乱码的问题就能很快的定位并解决了。\n理论上,从一个字符到具体的编码,会经过以下几个概念。\n字符集(Abstract character repertoire) 编码字符集(Coded character set) 字符编码方式(Character encoding form) 字符编码方案(Character encoding scheme) 字符集:就算一堆抽象的字符,如所有中文。字符集的定义是抽象的,与计算机无关。 编码字符集:是一个从整数集子集到字符集抽象元素的映射。即给抽象的字符编上数字。如gb2312中的定义的字符,每个字符都有个整数和它对应。一个整数只对应着一个字符。反过来,则不一定是。这里所说的映射关系,是数学意义上的映射关系。编码字符集也是与计算机无关的。unicode字符集也在这一层。 字符编码方式:这个开始与计算机有关了。编码字符集的编码点在计算机里的具体表现形式。通俗的说,意思就是怎么样才能将字符所对应的整数的放进计算机内存,或文件、或网络中。于是,不同人有不同的实现方式,所谓的万码奔腾,就是指这个。gb2312,utf-8,utf-16,utf-32等都在这一层。 字符编码方案:这个更加与计算机密切相关。具体是与操作系统密切相关。主要是解决大小字节序的问题。对于UTF-16和UTF-32 编码,Unicode都支持big-endian和little-endian两种编码方案。\n一般来说,我们所说的编码,都在第三层完成。具体到一个软件系统中,则很复杂。\n浏览器-apache-tomcat(包括tomcat内部的jsp编码、编译,文件读取)-数据库之间,只要存在数据交互,就有可能发生编码不一致,如果在读取数据时,没有正确的decode和encode,出现乱码就是家常便饭了。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/unicode%E7%BC%96%E7%A0%81%E4%B8%8Epython/","summary":"简介 这有篇很好的文章,可以明白这个问题: 为什么会报错“UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128)”?本","title":"Unicode编码与Python"},{"content":"概述 如我们之前提到的,leveldb是典型的LSM树(Log Structured-Merge Tree)实现,即一次leveldb的写入过程并不是直接将数据持久化到磁盘文件中,而是将写操作首先写入日志文件中,其次将写操作应用在memtable上。\n当leveldb达到checkpoint点(memtable中的数据量超过了预设的阈值),会将当前memtable冻结成一个不可更改的内存数据库(immutable memory db),并且创建一个新的memtable供系统继续使用。\nimmutable memory db会在后台进行一次minor compaction,即将内存数据库中的数据持久化到磁盘文件中。\n在这里我们暂时不展开讨论minor compaction相关的内容,读者可以简单地理解为将内存中的数据持久化到文件\nleveldb(或者说LSM树)设计Minor Compaction的目的是为了:\n有效地降低内存的使用率; 避免日志文件过大,系统恢复时间过长; 当memory db的数据被持久化到文件中时,leveldb将以一定规则进行文件组织,这种文件格式成为sstable。在本文中将详细地介绍sstable的文件格式以及相关读写操作。\nSStable文件格式 物理结构 为了提高整体的读写效率,一个sstable文件按照固定大小进行块划分,默认每个块的大小为4KiB。每个Block中,除了存储数据以外,还会存储两个额外的辅助字段:\n压缩类型 CRC校验码 压缩类型说明了Block中存储的数据是否进行了数据压缩,若是,采用了哪种算法进行压缩。leveldb中默认采用Snappy算法进行压缩。 CRC校验码是循环冗余校验校验码,校验范围包括数据以及压缩类型。 逻辑结构 在逻辑上,根据功能不同,leveldb在逻辑上又将sstable分为:\ndata block: 用来存储key value数据对; filter block: 用来存储一些过滤器相关的数据(布隆过滤器),但是若用户不指定leveldb使用过滤器,leveldb在该block中不会存储任何内容; meta Index block: 用来存储filter block的索引信息(索引信息指在该sstable文件中的偏移量以及数据长度); index block:index block中用来存储每个data block的索引信息; footer: 用来存储meta index block及index block的索引信息; 注意,1-4类型的区块,其物理结构都是如1.1节所示,每个区块都会有自己的压缩信息以及CRC校验码信息。\ndata block结构 data block中存储的数据是leveldb中的keyvalue键值对。其中一个data block中的数据部分(不包括压缩类型、CRC校验码)按逻辑又以下图进行划分: 第一部分用来存储keyvalue数据。由于sstable中所有的keyvalue对都是严格按序存储的,为了节省存储空间,leveldb并不会为每一对keyvalue对都存储完整的key值,而是存储与上一个key非共享的部分,避免了key重复内容的存储。\n每间隔若干个keyvalue对,将为该条记录重新存储一个完整的key。重复该过程(默认间隔值为16),每个重新存储完整key的点称之为Restart point。\n每间隔若干个keyvalue对,将为该条记录重新存储一个完整的key。重复该过程(默认间隔值为16),每个重新存储完整key的点称之为Restart point。\n每个数据项的格式如下图所示: 一个entry分为5部分内容:\n与前一条记录key共享部分的长度; 与前一条记录key不共享部分的长度; value长度; 与前一条记录key非共享的内容; value内容; 例如:\n1 2 3 4 restart_interval=2 entry one : key=deck,value=v1 entry two : key=dock,value=v2 entry three: key=duck,value=v3 三组entry按上图的格式进行存储。值得注意的是restart_interval为2,因此每隔两个entry都会有一条数据作为restart point点的数据项,存储完整key值。因此entry3存储了完整的key。\n此外,第一个restart point为0(偏移量),第二个restart point为16,restart point共有两个,因此一个datablock数据段的末尾添加了下图所示的数据: 尾部数据记录了每一个restart point的值,以及所有restart point的个数。\nfilter block结构 讲完了data block,在这一章节将展开讲述filter block的结构。\n为了加快sstable中数据查询的效率,在直接查询datablock中的内容之前,leveldb首先根据filter block中的过滤数据判断指定的datablock中是否有需要查询的数据,若判断不存在,则无需对这个datablock进行数据查找。\nfilter block存储的是data block数据的一些过滤信息。这些过滤数据一般指代布隆过滤器的数据,用于加快查询的速度,关于布隆过滤器的详细内容,可以见《Leveldb源码分析 - 布隆过滤器》。 filter block存储的数据主要可以分为两部分:(1)过滤数据(2)索引数据。\n其中索引数据中,filter i offset表示第i个filter data在整个filter block中的起始偏移量,filter offset\u0026rsquo;s offset表示filter block的索引数据在filter block中的偏移量。\n在读取filter block中的内容时,可以首先读出filter offset\u0026rsquo;s offset的值,然后依次读取filter i offset,根据这些offset分别读出filter data。\nBase Lg默认值为11,表示每2KB的数据,创建一个新的过滤器来存放过滤数据。\n一个sstable只有一个filter block,其内存储了所有block的filter数据. 具体来说,filter_data_k 包含了所有起始位置处于 [basek, base(k+1)]范围内的block的key的集合的filter数据,按数据大小而非block切分主要是为了尽量均匀,以应对存在一些block的key很多,另一些block的key很少的情况。\nleveldb中,特殊的sstable文件格式设计简化了许多操作,例如: 索引和BloomFilter等元数据可随文件一起创建和销毁,即直接存在文件里,不用加载时动态计算,不用维护更新\nmeta index block结构 meta index block用来存储filter block在整个sstable中的索引信息。\nmeta index block只存储一条记录:\n该记录的key为:\u0026ldquo;filter.\u0026ldquo;与过滤器名字组成的常量字符串\n该记录的value为:filter block在sstable中的索引信息序列化后的内容,索引信息包括:(1)在sstable中的偏移量(2)数据长度。\nindex block结构 与meta index block类似,index block用来存储所有data block的相关索引信息。\nindexblock包含若干条记录,每一条记录代表一个data block的索引信息。\n一条索引包括以下内容:\ndata block i 中最大的key值; 该data block起始地址在sstable中的偏移量; 该data block的大小; 其中,data block i最大的key值还是index block中该条记录的key值。 如此设计的目的是,依次比较index block中记录信息的key值即可实现快速定位目标数据在哪个data block中。\nfooter结构 footer大小固定,为48字节,用来存储meta index block与index block在sstable中的索引信息,另外尾部还会存储一个magic word,内容为:\u0026ldquo;http://code.google.com/p/leveldb/\"字符串sha1哈希的前8个字节。 读写操作 在介绍完sstable文件具体的组织方式之后,我们再来介绍一下相关的读写操作。为了便于读者理解,将首先介绍写操作。\n写操作 sstable的写操作通常发生在:\nmemory db将内容持久化到磁盘文件中时,会创建一个sstable进行写入; leveldb后台进行文件compaction时,会将若干个sstable文件的内容重新组织,输出到若干个新的sstable文件中; 对sstable进行写操作的数据结构为tWriter,具体定义如下:\n1 2 3 4 5 6 7 8 9 10 11 // tWriter wraps the table writer. It keep track of file descriptor // and added key range. type tWriter struct { t *tOps fd storage.FileDesc // 文件描述符 w storage.Writer // 文件系统writer tw *table.Writer first, last []byte } 主要包括了一个sstable的文件描述符,底层文件系统的writer,该sstable中所有数据项最大最小的key值以及一个内嵌的tableWriter。\n一次sstable的写入为一次不断利用迭代器读取需要写入的数据,并不断调用tableWriter的Append函数,直至所有有效数据读取完毕,为该sstable文件附上元数据的过程。\n该迭代器可以是一个内存数据库的迭代器,写入情景对应着上述的第一种情况;\n该迭代器也可以是一个sstable文件的迭代器,写入情景对应着上述的第二种情况;\nsstable的元数据包括:(1)文件编码(2)大小(3)最大key值(4)最小key值\n故,理解tableWriter的Append函数是理解整个写入过程的关键。\ntableWriter 在介绍append函数之前,首先介绍一下tableWriter这个数据结构。主要的定义如下:\n1 2 3 4 5 6 7 8 9 10 11 12 13 // Writer is a table writer. type Writer struct { writer io.Writer // Options blockSize int // 默认是4KiB dataBlock blockWriter // data块Writer indexBlock blockWriter // indexBlock块Writer filterBlock filterWriter // filter块Writer pendingBH blockHandle offset uint64 nEntries int // key-value键值对个数 } 其中blockWriter与filterWriter表示底层的两种不同的writer,blockWriter负责写入data数据的写入,而filterWriter负责写入过滤数据。\npendingBH记录了上一个dataBlock的索引信息,当下一个dataBlock的数据开始写入时,将该索引信息写入indexBlock中。\nAppend 一次append函数的主要逻辑如下:\n若本次写入为新dataBlock的第一次写入,则将上一个dataBlock的索引信息写入; 将keyvalue数据写入datablock; 将过滤信息写入filterBlock; 若datablock中的数据超过预定上限,则标志着本次datablock写入结束,将内容刷新到磁盘文件中; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 func (w *Writer) Append(key, value []byte) error { w.flushPendingBH(key) // Append key/value pair to the data block. w.dataBlock.append(key, value) // Add key to the filter block. w.filterBlock.add(key) // Finish the data block if block size target reached. if w.dataBlock.bytesLen() \u0026gt;= w.blockSize { if err := w.finishBlock(); err != nil { w.err = err return w.err } } w.nEntries++ return nil } dataBlock.append 该函数将编码后的kv数据写入到dataBlock对应的buffer中,编码的格式如上文中提到的数据项的格式。此外,在写入的过程中,若该数据项为restart点,则会添加相应的restart point信息。\nfilterBlock.append 该函数将kv数据项的key值加入到过滤信息中,具体可见《Leveldb源码解析 - 布隆过滤器》\nfinishBlock 若一个datablock中的数据超过了固定上限,则需要将相关数据写入到磁盘文件中。\n在写入时,需要做以下工作:\n封装dataBlock,记录restart point的个数; 若dataBlock的数据需要进行压缩(例如snappy压缩算法),则对dataBlock中的数据进行压缩; 计算checksum; 封装dataBlock索引信息(offset,length); 将datablock的buffer中的数据写入磁盘文件; 利用这段时间里维护的过滤信息生成过滤数据,放入filterBlock对用的buffer中; Close 当迭代器取出所有数据并完成写入后,调用tableWriter的Close函数完成最后的收尾工作:\n若buffer中仍有未写入的数据,封装成一个datablock写入; 将filterBlock的内容写入磁盘文件; 将filterBlock的索引信息写入metaIndexBlock中,写入到磁盘文件; 写入indexBlock的数据; 写入footer数据; 至此为止,所有的数据已经被写入到一个sstable中了,由于一个sstable是作为一个memory db或者Compaction的结果原子性落地的,因此在sstable写入完成之后,将进行更为复杂的leveldb的版本更新,将在接下来的文章中继续介绍。\n读操作 读操作作为写操作的逆过程,充分理解了写操作,将会帮助理解读操作。\n下图为在一个sstable中查找某个数据项的流程图: 大致流程为:\n首先判断“文件句柄”cache中是否有指定sstable文件的文件句柄,若存在,则直接使用cache中的句柄;否则打开该sstable文件,按规则读取该文件的元数据,将新打开的句柄存储至cache中; 利用sstable中的index block进行快速的数据项位置定位,得到该数据项有可能存在的两个data block; 利用index block中的索引信息,首先打开第一个可能的data block; 利用filter block中的过滤信息,判断指定的数据项是否存在于该data block中,若存在,则创建一个迭代器对data block中的数据进行迭代遍历,寻找数据项;若不存在,则结束该data block的查找; 若在第一个data block中找到了目标数据,则返回结果;若未查找成功,则打开第二个data block,重复步骤4; 若在第二个data block中找到了目标数据,则返回结果;若未查找成功,则返回Not Found错误信息; 缓存 在leveldb中,使用cache来缓存两类数据:\nsstable文件句柄及其元数据; data block中的数据; 因此在打开文件之前,首先判断能够在cache中命中sstable的文件句柄,避免重复读取的开销。 元数据读取 由于sstable复杂的文件组织格式,因此在打开文件后,需要读取必要的元数据,才能访问sstable中的数据。\n元数据读取的过程可以分为以下几个步骤:\n读取文件的最后48字节的利用,即Footer数据; 读取Footer数据中维护的(1) Meta Index Block(2) Index Block两个部分的索引信息并记录,以提高整体的查询效率; 利用meta index block的索引信息读取该部分的内容; 遍历meta index block,查看是否存在“有用”的filter block的索引信息,若有,则记录该索引信息;若没有,则表示当前sstable中不存在任何过滤信息来提高查询效率; 数据项的快速定位 sstable中存在多个data block,倘若依次进行“遍历”显然是不可取的。但是由于一个sstable中所有的数据项都是按序排列的,因此可以利用有序性已经index block中维护的索引信息快速定位目标数据项可能存在的data block。\n一个index block的文件结构示意图如下: index block是由一系列的键值对组成,每一个键值对表示一个data block的索引信息。\n键值对的key为该data block中数据项key的最大值,value为该data block的索引信息(offset, length)。\n因此若需要查找目标数据项,仅仅需要依次比较index block中的这些索引信息,倘若目标数据项的key大于某个data block中最大的key值,则该data block中必然不存在目标数据项。故通过这个步骤的优化,可以直接确定目标数据项落在哪个data block的范围区间内。\n值得注意的是,与data block一样,index block中的索引信息同样也进行了key值截取,即第二个索引信息的key并不是存储完整的key,而是存储与前一个索引信息的key不共享的部分,区别在于data block中这种范围的划分粒度为16,而index block中为2 。 也就是说,index block连续两条索引信息会被作为一个最小的“比较单元“,在查找的过程中,若第一个索引信息的key小于目标数据项的key,则紧接着会比较第三条索引信息的key。 这就导致最终目标数据项的范围区间为某”两个“data block。\n过滤data block 若sstable存有每一个data block的过滤数据,则可以利用这些过滤数据对data block中的内容进行判断,“确定”目标数据是否存在于data block中。\n过滤的原理为:\n若过滤数据显示目标数据不存在于data block中,则目标数据一定不存在于data block中; 若过滤数据显示目标数据存在于data block中,则目标数据可能存在于data block中; 具体的原理可能参见《布隆过滤器》。 因此利用过滤数据可以过滤掉部分data block,避免发生无谓的查找。\n查找data block 在data block中查找目标数据项是一个简单的迭代遍历过程。虽然data block中所有数据项都是按序排序的,但是作者并没有采用“二分查找”来提高查找的效率,而是使用了更大的查找单元进行快速定位。\n与index block的查找类似,data block中,以16条记录为一个查找单元,若entry 1的key小于目标数据项的key,则下一条比较的是entry 17。\n因此查找的过程中,利用更大的查找单元快速定位目标数据项可能存在于哪个区间内,之后依次比较判断其是否存在与data block中。\n可以看到,sstable很多文件格式设计(例如restart point, index block,filter block,max key)在查找的过程中,都极大地提升了整体的查找效率。\n文件特点 只读性 sstable文件为compaction的结果原子性的产生,在其余时间是只读的。\n完整性 一个sstable文件,其辅助数据:\n索引数据 过滤数据 都直接存储于同一个文件中。当读取是需要使用这些辅助数据时,无须额外的磁盘读取;当sstable文件需要删除时,无须额外的数据删除。简要地说,辅助数据随着文件一起创建和销毁。\n并发访问友好性 由于sstable文件具有只读性,因此不存在同一个文件的读写冲突。\nleveldb采用引用计数维护每个文件的引用情况,当一个文件的计数值大于0时,对此文件的删除动作会等到该文件被释放时才进行,因此实现了无锁情况下的并发访问。\nCache一致性 sstable文件为只读的,因此cache中的数据永远于sstable文件中的数据保持一致。\n参考: https://leveldb-handbook.readthedocs.io/zh/latest/sstable.html\n","permalink":"https://reid00.github.io/en/posts/storage/rocksdb-sstable/","summary":"概述 如我们之前提到的,leveldb是典型的LSM树(Log Structured-Merge Tree)实现,即一次leveldb的写入过程并不是直接将数据持久化到磁盘文件","title":"RocksDB Sstable"},{"content":"TCP/IP 协议族 通常我说 TCP/IP 是指 TCP/IP 协议族。它是基于 TCP 和 IP 这两个最初的协议之上的不同的通信协议的大集合。 例如:http、https、ftp、icmp、arp、rarp、smtp(简单邮件传输协议)\n当输入 xxxxHub 后,到网页显示,其间发生了什么?这问题被面试官问了五六十次,熬夜赶出这篇文章\nhttps://mp.weixin.qq.com/s/ESJ8Zt0GBVXHKj3KICoqjg\n一个网络请求是怎么传输的? 我们拿访问浏览器举个栗子,如图所示:\nTCP、UDP有什么区别?各有什么优劣? TCP 面向连接,提供可靠交付。通过 TCP 连接传输的数据,无差错、不丢失、不重复、并且按序到达。相对 UDP 开销大 UDP 面向无连接,不保证可靠交付。无拥塞控制,支持一对一、一对多、多对多,开销小。\n关于 TCP 协议 确认 ACK - ACKnowledgement 仅当ACK = 1 时,确认才有效。简单来说,就是确认收到数据。 复位 RST - ReSet 标明 TCP 出现严重差错时,必须释放连接,重新建立连接。 同步 SYN - SYNchronization 在建立连接时,用来同步序号。当 SYN = 1,ACK = 0 时,表名这是一个连接请求报文。SYN = 1,ACK = 1 表示这是一个同意请求报文。 终止 FNI - FINis(表示终、完)用来释放连接。当 FNI = 1 表示此段报文发送方已发送完毕。 关于 UDP 协议 解释三次握手 确认号 ack 期望收到对方下一个报文的序列号\n序列号 seq\nSYN = 1 请求同步序列号,A 的序列号为:x SYN = 1 ACK = 1,表示确认请求。B 发送的数据的序列号为:y,期望收到 下一个 A 的数据的序列号为:x + 1 ACK = 1 ,表示确认请求。A 发送的数据的序列号为:x + 1,期望收到下一个 B 的数据的序列号为:y + 1 说说TCP三次握手?为什么不两次? 如果发送两次就可以建立连接话,那么只要客户端发送一个连接请求,服务端接收到并发送了确认,就会建立一个连接。\n可能出现的问题:如果一个连接请求在网络中跑的慢,超时了,这时客户端会从发请求,但是这个跑的慢的请求最后还是跑到了,然后服务端就接收了两个连接请求,然后全部回应就会创建两个连接,浪费资源!\n如果加了第三次客户端确认,客户端在接受到一个服务端连接确认请求后,后面再接收到的连接确认请求就可以抛弃不管了。\n说说TCP四次挥手?为什么不是三次? 据传输结束后,通信的双方都可以释放连接。现在 A 和 B 都处于 ESTABLISHED 状态。\n第一次挥手:A 的应用进程先向其 TCP 发出连接释放报文段,并停止再发送数据,主动关闭 TCP 连接。A 把连接释放报文段首部的终止控制位 FIN 置 1,其序号 seq = u(等于前面已传送过的数据的最后一个字节的序号加 1),这时 A 进入 FIN-WAIT-1(终止等待1)状态,等待 B 的确认。请注意:TCP 规定,FIN 报文段即使不携带数据,也将消耗掉一个序号。\n第二次挥手:B 收到连接释放报文段后立即发出确认,确认号是 ack = u + 1,而这个报文段自己的序号是 v(等于 B 前面已经传送过的数据的最后一个字节的序号加1),然后 B 就进入 CLOSE-WAIT(关闭等待)状态。TCP 服务端进程这时应通知高层应用进程,因而从 A 到 B 这个方向的连接就释放了,这时的 TCP 连接处于半关闭(half-close)状态,即 A 已经没有数据要发送了,但 B 若发送数据,A 仍要接收。也就是说,从 B 到 A 这个方向的连接并未关闭,这个状态可能会持续一段时间。A 收到来自 B 的确认后,就进入 FIN-WAIT-2(终止等待2)状态,等待 B 发出的连接释放报文段。\n第三次挥手:若 B 已经没有要向 A 发送的数据,其应用进程就通知 TCP 释放连接。这时 B 发出的连接释放报文段必须使 FIN = 1。假定 B 的序号为 w(在半关闭状态,B 可能又发送了一些数据)。B 还必须重复上次已发送过的确认号 ack = u + 1。这时 B 就进入 LAST-ACK(最后确认)状态,等待 A 的确认。\n第四次挥手:A 在收到 B 的连接释放报文后,必须对此发出确认。在确认报文段中把 ACK 置 1,确认号 ack = w + 1,而自己的序号 seq = u + 1(前面发送的 FIN 报文段要消耗一个序号)。然后进入 TIME-WAIT(时间等待) 状态。请注意,现在 TCP 连接还没有释放掉。必须经过时间等待计时器设置的时间 2MSL(MSL:最长报文段寿命)后,A 才能进入到 CLOSED 状态,然后撤销传输控制块,结束这次 TCP 连接。当然如果 B 一收到 A 的确认就进入 CLOSED 状态,然后撤销传输控制块。所以在释放连接时,B 结束 TCP 连接的时间要早于 A。\n什么是拥塞控制? 简单来说,就是通过网络的拥塞情况来调整 TCP 发送端发送的数据量。发送量先由 1 指数级递增,到一定量时(65535 个字节)开始慢下来,这个时候还是递增的。等到开始丢包时,又开始降低发送速度。\n什么是流量控制? 简单来说,就是 TCP 的接受端处理不过来,让 TCP 的发送端发送慢一点。接收端会维护一个处理窗口,即是接收端所能处理数据的能力。接收端将这个处理能力不断反馈给发送端,以此来让发送端调整发送的数据量的多少。\n参考: https://mp.weixin.qq.com/s/xe3dEu17mGTqM46LRFxzhg\n","permalink":"https://reid00.github.io/en/posts/os_network/tcp-ip%E5%8D%8F%E8%AE%AE/","summary":"TCP/IP 协议族 通常我说 TCP/IP 是指 TCP/IP 协议族。它是基于 TCP 和 IP 这两个最初的协议之上的不同的通信协议的大集合。 例如:http、https、ftp、icmp、a","title":"TCP IP协议"},{"content":"一、 python 的多线程不能利用多核CPU 因为GIL (全局解释器锁), Pyhton 只有一个GIL, 在运行Python 时, 就要拿到这个锁,才能运行,在遇到I/O 操作时,会释放这把锁。\n如果是纯计算型的程序,没有I/O 操作,解释器会每隔100 次操作就释放这把锁,让别的线程有机会执行(这个次数可以通sys.setcheckinterval来调整), 同一时间内,有且仅会只有一个线程获得GIL 在运行,其他线程都处于等待状态。\n如果是CPU 密集型的代码比如,循环,计算等,由于计算量多和大,计算很快就会达到100次,然后触发GIL 的释放与竞争,多个线程来回切换损耗资源,所以在多线程遇到CPU密集型的代码时,效率远远不如单线程高 如果是I/O 密集型代码(文件处理,网络爬虫), 开启多线程实际上是并发,IO操作会进行IO等待,在线程A等待时,自动切换到线程B,这样就提升了效率。 面向I/O的(会调用内建的操作系统C代码的)程序来说,GIL会在这个I/O调用之前被释放,以允许其他线程在这个线程等待I/O的时候运行。如果某线程并未使用很多I/O操作,它会在自己的时间片内一直占用处理器和GIL。 也就是说,I/O密集型的Python程序比计算密集型的Python程序更能充分利用多线程的好处。我们都知道,比方我有一个4核的CPU,那么这样一来,在单位时间内每个核只能跑一个线程,然后时间片轮转切换。 但是Python不一样,它不管你有几个核,单位时间多个核只能跑一个线程,然后时间片轮转。看起来很不可思议?但是这就是GIL搞的鬼。任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁, 让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。\n二、解决办法 就如此?我们没有办法在Python中利用多核?当然可以!刚才的多进程算是一种解决方案,还有一种就是调用C语言的链接库。对所有面向I/O的(会调用内建的操作系统C代码的)程序来说,GIL会在这个I/O调用之前被释放,以允许其他线程在这个线程等待I/O的时候运行。我们可以把一些 计算密集型任务用C语言编写,然后把.so链接库内容加载到Python中,因为执行C代码,GIL锁会释放,这样一来,就可以做到每个核都跑一个线程的目的! 可能有的小伙伴不太理解什么是计算密集型任务,什么是I/O密集型任务?\n计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。\n计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适合计算密集型任务。对于计算密集型任务,最好用C语言编写。\n第二种任务的类型是IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。\nIO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用Python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高(代码量最少)的语言,脚本语言是首选,C语言最差。\n综上,Python多线程相当于单核多线程,多线程有两个好处:CPU并行,IO并行,单核多线程相当于自断一臂。所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。\n三、其他解释 在我们回过头看下那句经典的话\u0026quot;因为GIL的存在,python的多线程不能利用多核CPU\u0026quot;,这句话很容易让人理解成GIL会让python在一个核心上运行,有了今天的例子我们再来重新理解这句话,GIL的存在让python在同一时刻只能有一个线程在运行,这毋庸置疑,但是它并没有给线程锁死或者说指定只能在某个cpu上运行,另外我需要说明一点的是GIL是与进程对应的,每个进程都有一个GIL。python线程的执行流程我的理解是这样的 线程 ——\u0026gt;抢GIL——\u0026gt;CPU 这种执行流程导致了CPU密集型的多线程程序虽然能够利用多核cpu时跟单核cpu是差不多的,并且由于多个线程抢GIL这个环节导致运行效率\u0026lt;=单线程。看到这可能会让人产生一种错觉,有了GIL后python是线程安全的,好像根本不需要线程锁,而实际情况是线程拿到CPU资源后并不是一直执行的,python解释器在执行了该线程100条字节码(注意是字节码不是代码)时会释放掉该线程的GIL,如果这时候没有加锁那么其他线程就可能修改该线程用到的资源; 另外一个问题是遇到IO也会释放GIL\n最后结论是,因为GIL的存在,python的多线程虽然可以利用多核CPU,但并不能让多个核同时工作。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/python%E5%A4%9A%E7%BA%BF%E7%A8%8B%E5%A4%9A%E8%BF%9B%E7%A8%8B/","summary":"一、 python 的多线程不能利用多核CPU 因为GIL (全局解释器锁), Pyhton 只有一个GIL, 在运行Python 时, 就要拿到这个锁,才能运行,在遇到I/O 操","title":"Python多线程多进程"},{"content":"简介 RocksDB 是由 Facebook 基于 LevelDB 开发的一款提供键值存储与读写功能的 LSM-tree 架构引擎。用户写入的键值对会先写入磁盘上的 WAL (Write Ahead Log),然后再写入内存中的跳表(SkipList,这部分结构又被称作 MemTable)。LSM-tree 引擎由于将用户的随机修改(插入)转化为了对 WAL 文件的顺序写,因此具有比 B 树类存储引擎更高的写吞吐。\n内存中的数据达到一定阈值后,会刷到磁盘上生成 SST 文件 (Sorted String Table),SST 又分为多层(默认至多 6 层),每一层的数据达到一定阈值后会挑选一部分 SST 合并到下一层,每一层的数据是上一层的 10 倍(因此 90% 的数据存储在最后一层)。\nRocksDB 允许用户创建多个 ColumnFamily ,这些 ColumnFamily 各自拥有独立的内存跳表以及 SST 文件,但是共享同一个 WAL 文件,这样的好处是可以根据应用特点为不同的 ColumnFamily 选择不同的配置,但是又没有增加对 WAL 的写次数。\nrocksdb 和 leveldb对比优势 Leveldb是单线程合并文件,Rocksdb可以支持多线程合并文件,充分利用多核的特性,加快文件合并的速度,避免文件合并期间引起系统停顿 Leveldb只有一个Memtable,若Memtable满了还没有来得及持久化,则会引起系统停顿,Rocksdb可以根据需要开辟多个Memtable; Leveldb只能获取单个K-V,Rocksdb支持一次获取多个K-V。 Levledb不支持备份,Rocksdb支持全量和备份。 架构 RocksDB 是基于 LSM-Tree 的。Rocksdb结构图如下: LSM-Tree 能将离散的随机写请求都转换成批量的顺序写请求(WAL + Compaction),以此提高写性能。 sst文件是在硬盘上的。SST files按照key 排序,且每个文件的key range互相不重叠。为了check一个key可能存在于哪一个一个SST file中,RocksDB并没有依次遍历每一个SST file,然后去检查key是否在这个file的key range 内,而是执行二分搜索算法(FileMetaData.largest )去定位这个SST file。 任何的写入都会先写到 WAL,然后在写入 Memory Table(Memtable)。当然为了性能,也可以不写入 WAL,但这样就可能面临崩溃丢失数据的风险。 当一个 Memtable 写满了之后,就会变成 immutable 的 Memtable,RocksDB 在后台会通过一个 flush 线程将这个 Memtable flush 到磁盘,生成一个 Sorted String Table(SST) 文件,放在 Level 0 层。当 Level 0 层的 SST 文件个数超过阈值之后,就会通过 Compaction 策略将其放到 Level 1 层,以此类推。 这里关键就是 Compaction,如果没有 Compaction,那么写入是非常快的,但会造成读性能降低,同样也会造成很严重的空间放大问题。对于 RocksDB 来说,他有三种 Compaction 策略,一种就是默认的 Leveled Compaction,另一种就是 Universal Compaction,也就是常说的 Size-Tired Compaction,还有一种就是 FIFO Compaction。对于 FIFO 来说,它的策略非常的简单,所有的 SST 都在 Level 0,如果超过了阈值,就从最老的 SST 开始删除,其实可以看到,这套机制非常适合于存储时序数据。 实际对于 RocksDB 来说,它其实用的是一种 Hybrid 的策略,在 Level 0 层,它其实是一个 Size-Tired 的,而在其他层就是 Leveled 的。 RocksDB收到写入请求时,会直接将数据写入内存即RocksDB定义的区域Memtable,以及WAL(Write Ahead Logging,防止服务重置导致的数据丢失)中,当写入Memtable的数据达到阈值,则转为不可写入状态Immutable,同时申请新的Memtable供上层应用继续写入。RocksDB会通过异步的方式将数据Flush到SST数据文件中,RocksDB对SST文件的编排就采用了LSM树的管理方式,每层SST到达阈值,RocksDB会启动异步线程进行Compaction操作,将文件内的末端节点数据进行合并到下一层SST的文件中。 写入流程 首先当一条数据写入rocksdb时, 会将这条记录封装成一个batch, 也可以是多条记录一个batch,由batch来保证原子操作。就是一个batch里的数据要么全部成功要么全部失败。 第一步先以日志的形式落地磁盘,记write ahead log -\u0026gt; .wal 文件。 落地成功后再写入memtable。 1.这里记录wal的原因就是防止重启时内存中的数据丢失。所以在db重新打开时会先从wal恢复内存中的memtable. 可配置WAL保存在可靠的存储里。 2.这里的memtable是在内存中的一个跳表结构(skiplist)。每一个节点都是存储着一个key, value. 跳表可使查找的复杂度为logn, 同时插入数据非常简单。每个batch独占memtable的写锁。这个是为了避免多线程写造成的数据错乱。\n当memtable的数据大小超过阈值(write_buffer_size)后,会新生成一个memtable继续写,将前一个memtable保存为只读memtable -\u0026gt; immutabel. 当只读memtable的数量超过阈值后,会将所有的只读memtable合并并flush到磁盘生成一个SST文件。这里的SST属于level0, level0中的每个SST有序,整个level0不一定有序。 当level0的sst文件数超过阈值或者总大小超过阈值,会触发compaction操作,将level0中的数据合并到level1中。同样level1的文件数超过阈值或者总大小超过阈值,也会触发compaction操作, 这时候随机选择一个sst合并到更高层的level中。 1:level1 及其以上的level都整体有序。每个sst存储一个范围的数据互不交叉互不重合; 2: level1 以上的 compaction操作可以多线程执行,前提是每个线程所操作的数据互不交叉。\n读取流程 RocksDB中的每一条记录(KeyValue)都有一个lsn(LogSequenceNumber),从最初的0开始,每次写入加1。lsn在memtable中单调递增。\n首先读操作先访问memtable。跳表的时间复杂度可达到logn。 如果不存在会访问level0, 而level0整体不是有序的, 所以会按创建时间由新到老依次访问每一个sst文件。所以时间复杂度为m*logn。 如果仍不存在,则继续访问level1,由于level1及其以上的level都整体有序,所以只需要访问一个sst文件即可。 直到查找到最高层或者找到这个key。所以读操作可能会被放大好多倍。 总结: 读取的顺序为memtable-\u0026gt;immutable memtable-\u0026gt;level 0 SST-\u0026gt;…-\u0026gt;level n SST。其中,memtable和immutable memtable采用了跳表特性进行查询,SST文件中有过滤器(布隆过滤器)决定是否包含某个key再加载至内存,基于有序KV进行二分查找。同时,在memtable和SST之上还设置了Block Cache,提高查询性能。\nrocksdb的compaction 读放大(Read Amplification)。读取数据时实际读取的数据量大于真正的数据量。LSM-Tree 的读操作需要从新到旧(从上到下)一层一层查找,直到找到想要的数据。这个过程可能需要不止一次 I/O。特别是 range query 的情况,影响很明显。 空间放大(Space Amplification)。数据实际占用的磁盘空间比数据的真正大小更多。因为所有的写入都是顺序写(append-only)的,不是 in-place update ,所以过期数据不会马上被清理掉。 写放大。写入数据时实际写入的数据量大于真正的数据量。实际写入 HDD/SSD 的数据大小和程序要求写入数据大小之比。正常情况下,HDD/SSD 观察到的写入数据多于上层程序写入的数据。 compaction特性: RocksDB 和 LevelDB 通过后台的 compaction 来减少读放大(减少 SST 文件数量)和空间放大(清理过期数据)。 写放大(Write Amplification) 的问题。compaction其实就是以写放大作为代价,换取更好的读取性能。\n在 HDD 作为主流存储的时代,RocksDB 的 compaction 带来的写放大问题并没有非常明显。这是因为:\nHDD 顺序读写性能远远优于随机读写性能,足以抵消写放大带来的开销。 HDD 的写入量基本不影响其使用寿命。 现在 SSD 逐渐成为主流存储,compaction 带来的写放大问题显得越来越严重:\nSSD 顺序读写性能比随机读写性能好一些,但是差距并没有 HDD 那么大。所以,顺序写相比随机写带来的好处,能不能抵消写放大带来的开销,这是个问题。 SSD 的使用寿命和其写入量有关,写放大太严重会大大缩短 SSD 的使用寿命。因为 SSD 不支持覆盖写,必须先擦除(erase)再写入。而每个 SSD block(block 是 SSD 擦除操作的基本单位) 的平均擦除次数是有限的。\nrocksdb做了几点优化: 一点是为每个SST提供一个可配置的bloomfilter. 每个level的配置不一样。这样可以快速的确认一个key在不在某个SST中,这点以牺牲磁盘空间来换取时间。 另一点是提供可配置的cache, 用于保存访问过的key在内存中, 它缓存的是某个key在SST文件中的整个block里的记录。 ","permalink":"https://reid00.github.io/en/posts/storage/rocksdb/","summary":"简介 RocksDB 是由 Facebook 基于 LevelDB 开发的一款提供键值存储与读写功能的 LSM-tree 架构引擎。用户写入的键值对会先写入磁盘上的 WAL (Write Ahead Log),然后再写入内存中的跳表(Sk","title":"RocksDB"},{"content":"本文主要受众为开发人员,所以不涉及到MySQL的服务部署等操作,且内容较多,大家准备好耐心和瓜子矿泉水。\n前一阵系统的学习了一下MySQL,也有一些实际操作经验,偶然看到一篇和MySQL相关的面试文章,发现其中的一些问题自己也回答不好,虽然知识点大部分都知道,但是无法将知识串联起来。\n因此决定搞一个MySQL灵魂100问,试着用回答问题的方式,让自己对知识点的理解更加深入一点。\n此文不会事无巨细的从select的用法开始讲解mysql,主要针对的是开发人员需要知道的一些MySQL的知识点\n主要包括索引,事务,优化等方面,以在面试中高频的问句形式给出答案。\nMySQL 重要笔记 三万字、91道MySQL面试题(收藏版)\nhttps://mp.weixin.qq.com/s/KRWyl-zU1Cd6sxbya4dP_g\n书写高质量SQL的30条建议\nhttps://mp.weixin.qq.com/s/nM6fwEyi2VZeRMWtdZGpGQ\n数据分析面试必备SQL语句+语法\nhttps://mp.weixin.qq.com/s/8UZAaDyB38gsZANPLxNKgg\n索引相关 关于MySQL的索引,曾经进行过一次总结,文章链接在这里 Mysql索引原理及其优化.\n1. 什么是索引?\n索引是一种数据结构,可以帮助我们快速的进行数据的查找.\n2. 索引是个什么样的数据结构呢?\n索引的数据结构和具体存储引擎的实现有关, 在MySQL中使用较多的索引有Hash索引,B+树索引等,而我们经常使用的InnoDB存储引擎的默认索引实现为:B+树索引.\n3. Hash索引和B+树所有有什么区别或者说优劣呢?\n首先要知道Hash索引和B+树索引的底层实现原理:\nhash索引底层就是hash表,进行查找时,调用一次hash函数就可以获取到相应的键值,之后进行回表查询获得实际数据.B+树底层实现是多路平衡查找树.\n对于每一次的查询都是从根节点出发,查找到叶子节点方可以获得所查键值,然后根据查询判断是否需要回表查询数据.\n那么可以看出他们有以下的不同:\nhash索引进行等值查询更快(一般情况下),但是却无法进行范围查询. 因为在hash索引中经过hash函数建立索引之后,索引的顺序与原顺序无法保持一致,不能支持范围查询.\n而B+树的的所有节点皆遵循(左节点小于父节点,右节点大于父节点,多叉树也类似),天然支持范围.\nhash索引不支持使用索引进行排序,原理同上.\nhash索引不支持模糊查询以及多列索引的最左前缀匹配.原理也是因为hash函数的不可预测.AAAA和AAAAB的索引没有相关性.\nhash索引任何时候都避免不了回表查询数据,而B+树在符合某些条件(聚簇索引,覆盖索引等)的时候可以只通过索引完成查询.\nhash索引虽然在等值查询上较快,但是不稳定.性能不可预测,当某个键值存在大量重复的时候,发生hash碰撞,此时效率可能极差.而B+树的查询效率比较稳定,对于所有的查询都是从根节点到叶子节点,且树的高度较低.\n因此,在大多数情况下,直接选择B+树索引可以获得稳定且较好的查询速度.而不需要使用hash索引.\n4. 上面提到了B+树在满足聚簇索引和覆盖索引的时候不需要回表查询数据,什么是聚簇索引?\n在B+树的索引中,叶子节点可能存储了当前的key值,也可能存储了当前的key值以及整行的数据,这就是聚簇索引和非聚簇索引.\n在InnoDB中,只有主键索引是聚簇索引,如果没有主键,则挑选一个唯一键建立聚簇索引.如果没有唯一键,则隐式的生成一个键来建立聚簇索引.\n当查询使用聚簇索引时,在对应的叶子节点,可以获取到整行数据,因此不用再次进行回表查询.\n5. 非聚簇索引一定会回表查询吗?\n不一定,这涉及到查询语句所要求的字段是否全部命中了索引,如果全部命中了索引,那么就不必再进行回表查询.\n举个简单的例子,假设我们在员工表的年龄上建立了索引,那么当进行select age from employee where age \u0026lt; 20的查询时,在索引的叶子节点上,已经包含了age信息,不会再次进行回表查询.\n6. 在建立索引的时候,都有哪些需要考虑的因素呢?\n建立索引的时候一般要考虑到字段的使用频率,经常作为条件进行查询的字段比较适合.如果需要建立联合索引的话,还需要考虑联合索引中的顺序.\n此外也要考虑其他方面,比如防止过多的所有对表造成太大的压力.这些都和实际的表结构以及查询方式有关.\n7. 联合索引是什么?为什么需要注意联合索引中的顺序?\nMySQL可以使用多个字段同时建立一个索引,叫做联合索引.在联合索引中,如果想要命中索引,需要按照建立索引时的字段顺序挨个使用,否则无法命中索引.\n具体原因为:\nMySQL使用索引时需要索引有序,假设现在建立了\u0026quot;name,age,school\u0026quot;的联合索引\n那么索引的排序为: 先按照name排序,如果name相同,则按照age排序,如果age的值也相等,则按照school进行排序.\n当进行查询时,此时索引仅仅按照name严格有序,因此必须首先使用name字段进行等值查询,之后对于匹配到的列而言,其按照age字段严格有序,此时可以使用age字段用做索引查找,以此类推.\n因此在建立联合索引的时候应该注意索引列的顺序,一般情况下,将查询需求频繁或者字段选择性高的列放在前面.此外可以根据特例的查询或者表结构进行单独的调整.\n8. 创建的索引有没有被使用到?或者说怎么才可以知道这条语句运行很慢的原因?\nMySQL提供了explain命令来查看语句的执行计划,MySQL在执行某个语句之前,会将该语句过一遍查询优化器,之后会拿到对语句的分析,也就是执行计划,其中包含了许多信息.\n可以通过其中和索引有关的信息来分析是否命中了索引,例如possilbe_key,key,key_len等字段,分别说明了此语句可能会使用的索引,实际使用的索引以及使用的索引长度.\n9. 那么在哪些情况下会发生针对该列创建了索引但是在查询的时候并没有使用呢?\n使用不等于查询\n列参与了数学运算或者函数\n在字符串like时左边是通配符.类似于\u0026rsquo;%aaa'.\n当mysql分析全表扫描比使用索引快的时候不使用索引.\n当使用联合索引,前面一个条件为范围查询,后面的即使符合最左前缀原则,也无法使用索引.\n以上情况,MySQL无法使用索引.\n9. 那MySQL索引使用有哪些注意事项呢?\n索引哪些情况会失效\n查询条件包含or,可能导致索引失效 如何字段类型是字符串,where时一定用引号括起来,否则索引失效 like通配符可能导致索引失效。 联合索引,查询时的条件列不是联合索引中的第一个列,索引失效。 在索引列上使用mysql的内置函数,索引失效。 对索引列运算(如,+、-、*、/),索引失效。 索引字段上使用(!= 或者 \u0026lt; \u0026gt;,not in)时,可能会导致索引失效。 索引字段上使用is null, is not null,可能导致索引失效。 左连接查询或者右连接查询查询关联的字段编码格式不一样,可能导致索引失效。 mysql估计使用全表扫描要比使用索引快,则不使用索引。 后端程序员必备:索引失效的十大杂症\n索引不适合哪些场景\n数据量少的不适合加索引 更新比较频繁的也不适合加索引 区分度低的字段不适合加索引(如性别) 索引的一些潜规则\n覆盖索引 回表 索引数据结构(B+树) 最左前缀原则 索引下推 MySQL遇到过死锁问题吗,你是如何解决的?\n我排查死锁的一般步骤是酱紫的:\n查看死锁日志show engine innodb status; 找出死锁Sql 分析sql加锁情况 模拟死锁案发 分析死锁日志 分析死锁结果 可以看我这两篇文章哈:\n手把手教你分析Mysql死锁问题 Mysql死锁如何排查:insert on duplicate死锁一次排查分析过程 日常工作中你是怎么优化SQL的?\n可以从这几个维度回答这个问题:\n加索引 避免返回不必要的数据 适当分批量进行 优化sql结构 分库分表 读写分离 可以看我这篇文章哈:后端程序员必备:书写高质量SQL的30条建议\n数据库索引的原理,为什么要用B+树,为什么不用二叉树?\n可以从几个维度去看这个问题,查询是否够快,效率是否稳定,存储数据多少,以及查找磁盘次数,为什么不是二叉树,为什么不是平衡二叉树,为什么不是B树,而偏偏是B+树呢?\n为什么不是一般二叉树?\n如果二叉树特殊化为一个链表,相当于全表扫描。平衡二叉树相比于二叉查找树来说,查找效率更稳定,总体的查找速度也更快。\n为什么不是平衡二叉树呢?\n我们知道,在内存比在磁盘的数据,查询效率快得多。如果树这种数据结构作为索引,那我们每查找一次数据就需要从磁盘中读取一个节点,也就是我们说的一个磁盘块,但是平衡二叉树可是每个节点只存储一个键值和数据的,如果是B树,可以存储更多的节点数据,树的高度也会降低,因此读取磁盘的次数就降下来啦,查询效率就快啦。\n那为什么不是B树而是B+树呢?\n1)B+树非叶子节点上是不存储数据的,仅存储键值,而B树节点中不仅存储键值,也会存储数据。innodb中页的默认大小是16KB,如果不存储数据,那么就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的IO次数有会再次减少,数据查询的效率也会更快。\n2)B+树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的,链表连着的。那么B+树使得范围查找,排序查找,分组查找以及去重查找变得异常简单。\n可以看这篇文章哈:再有人问你为什么MySQL用B+树做索引,就把这篇文章发给她\ndetails link : https://mp.weixin.qq.com/s/Ctz6yB2131ZIzCOFrsgfMw\n事务相关 1. 什么是事务?\n理解什么是事务最经典的就是转账的栗子,相信大家也都了解,这里就不再说一边了.\n事务是一系列的操作,他们要符合ACID特性.最常见的理解就是:事务中的操作要么全部成功,要么全部失败.但是只是这样还不够的.\n2. ACID是什么?可以详细说一下吗?\nA=Atomicity\n原子性,就是上面说的,要么全部成功,要么全部失败.不可能只执行一部分操作.\nC=Consistency\n系统(数据库)总是从一个一致性的状态转移到另一个一致性的状态,不会存在中间状态.\nI=Isolation\n隔离性: 通常来说:一个事务在完全提交之前,对其他事务是不可见的.注意前面的通常来说加了红色,意味着有例外情况.\nD=Durability\n持久性,一旦事务提交,那么就永远是这样子了,哪怕系统崩溃也不会影响到这个事务的结果.\n3. 同时有多个事务在进行会怎么样呢?\n多事务的并发进行一般会造成以下几个问题:\n脏读: A事务读取到了B事务未提交的内容,而B事务后面进行了回滚.\n不可重复读: 当设置A事务只能读取B事务已经提交的部分,会造成在A事务内的两次查询,结果竟然不一样,因为在此期间B事务进行了提交操作.\n幻读: A事务读取了一个范围的内容,而同时B事务在此期间插入了一条数据.造成\u0026quot;幻觉\u0026quot;.\n4. 怎么解决这些问题呢?MySQL的事务隔离级别了解吗?\nMySQL的四种隔离级别如下:\n未提交读(READ UNCOMMITTED) 这就是上面所说的例外情况了,这个隔离级别下,其他事务可以看到本事务没有提交的部分修改.因此会造成脏读的问题(读取到了其他事务未提交的部分,而之后该事务进行了回滚).\n这个级别的性能没有足够大的优势,但是又有很多的问题,因此很少使用.\n已提交读(READ COMMITTED) 其他事务只能读取到本事务已经提交的部分.这个隔离级别有 不可重复读的问题,在同一个事务内的两次读取,拿到的结果竟然不一样,因为另外一个事务对数据进行了修改.\nREPEATABLE READ(可重复读) 可重复读隔离级别解决了上面不可重复读的问题(看名字也知道),但是仍然有一个新问题,就是幻读\n当你读取id\u0026gt; 10 的数据行时,对涉及到的所有行加上了读锁,此时例外一个事务新插入了一条id=11的数据,因为是新插入的,所以不会触发上面的锁的排斥\n那么进行本事务进行下一次的查询时会发现有一条id=11的数据,而上次的查询操作并没有获取到,再进行插入就会有主键冲突的问题.\nSERIALIZABLE(可串行化) 这是最高的隔离级别,可以解决上面提到的所有问题,因为他强制将所以的操作串行执行,这会导致并发性能极速下降,因此也不是很常用.\n5. Innodb使用的是哪种隔离级别呢?\nInnoDB默认使用的是可重复读隔离级别.\n6. 对MySQL的锁了解吗?\n当数据库有并发事务的时候,可能会产生数据的不一致,这时候需要一些机制来保证访问的次序,锁机制就是这样的一个机制.\n就像酒店的房间,如果大家随意进出,就会出现多人抢夺同一个房间的情况,而在房间上装上锁,申请到钥匙的人才可以入住并且将房间锁起来,其他人只有等他使用完毕才可以再次使用.\n7. MySQL都有哪些锁呢?像上面那样子进行锁定岂不是有点阻碍并发效率了?\n从锁的类别上来讲,有共享锁和排他锁.\n共享锁: 又叫做读锁. 当用户要进行数据的读取时,对数据加上共享锁.共享锁可以同时加上多个.\n排他锁: 又叫做写锁. 当用户要进行数据的写入时,对数据加上排他锁.排他锁只可以加一个,他和其他的排他锁,共享锁都相斥.\n用上面的例子来说就是用户的行为有两种,一种是来看房,多个用户一起看房是可以接受的. 一种是真正的入住一晚,在这期间,无论是想入住的还是想看房的都不可以.\n锁的粒度取决于具体的存储引擎,InnoDB实现了行级锁,页级锁,表级锁.\n他们的加锁开销从大大小,并发能力也是从大到小.\n表结构设计 说说分库与分表的设计\n分库分表方案,分库分表中间件,分库分表可能遇到的问题\n分库分表方案:\n水平分库:以字段为依据,按照一定策略(hash、range等),将一个库中的数据拆分到多个库中。 水平分表:以字段为依据,按照一定策略(hash、range等),将一个表中的数据拆分到多个表中。 垂直分库:以表为依据,按照业务归属不同,将不同的表拆分到不同的库中。 垂直分表:以字段为依据,按照字段的活跃性,将表中字段拆到不同的表(主表和扩展表)中。 常用的分库分表中间件:\nsharding-jdbc(当当) Mycat TDDL(淘宝) Oceanus(58同城数据库中间件) vitess(谷歌开发的数据库中间件) Atlas(Qihoo 360) InnoDB与MyISAM的区别\nInnoDB支持事务,MyISAM不支持事务 InnoDB支持外键,MyISAM不支持外键 InnoDB 支持 MVCC(多版本并发控制),MyISAM 不支持 select count(*) from table时,MyISAM更快,因为它有一个变量保存了整个表的总行数,可以直接读取,InnoDB就需要全表扫描。 Innodb不支持全文索引,而MyISAM支持全文索引(5.7以后的InnoDB也支持全文索引) InnoDB支持表、行级锁,而MyISAM支持表级锁。 InnoDB表必须有主键,而MyISAM可以没有主键 Innodb表需要更多的内存和存储,而MyISAM可被压缩,存储空间较小,。 Innodb按主键大小有序插入,MyISAM记录插入顺序是,按记录插入顺序保存。 InnoDB 存储引擎提供了具有提交、回滚、崩溃恢复能力的事务安全,与 MyISAM 比 InnoDB 写的效率差一些,并且会占用更多的磁盘空间以保留数据和索引 1. 为什么要尽量设定一个主键?\n主键是数据库确保数据行在整张表唯一性的保障,即使业务上本张表没有主键,也建议添加一个自增长的ID列作为主键.\n设定了主键之后,在后续的删改查的时候可能更加快速以及确保操作数据范围安全.\n2. 主键使用自增ID还是UUID?\n推荐使用自增ID,不要使用UUID.\n因为在InnoDB存储引擎中,主键索引是作为聚簇索引存在的\n也就是说,主键索引的B+树叶子节点上存储了主键索引以及全部的数据(按照顺序)\n如果主键索引是自增ID,那么只需要不断向后排列即可,如果是UUID,由于到来的ID与原来的大小不确定,会造成非常多的数据插入,数据移动,然后导致产生很多的内存碎片,进而造成插入性能的下降.\n总之,在数据量大一些的情况下,用自增主键性能会好一些.\n图片来源于《高性能MySQL》: 其中默认后缀为使用自增ID,_uuid为使用UUID为主键的测试,测试了插入100w行和300w行的性能.\n关于主键是聚簇索引,如果没有主键,InnoDB会选择一个唯一键来作为聚簇索引,如果没有唯一键,会生成一个隐式的主键.\nIf you define a PRIMARY KEY on your table, InnoDB uses it as the clustered index.\nIf you do not define a PRIMARY KEY for your table, MySQL picks the first UNIQUE index that has only NOT NULL columns as the primary key and InnoDB uses it as the clustered index.\n3. 字段为什么要求定义为not null?\nMySQL官网这样介绍:\nNULL columns require additional space in the rowto record whether their values are NULL. For MyISAM tables, each NULL columntakes one bit extra, rounded up to the nearest byte.\nnull值会占用更多的字节,且会在程序中造成很多与预期不符的情况.\n4. 如果要存储用户的密码散列,应该使用什么字段进行存储?\n密码散列,盐,用户身份证号等固定长度的字符串应该使用char而不是varchar来存储,这样可以节省空间且提高检索效率.\n存储引擎相关 1. MySQL支持哪些存储引擎?\nMySQL支持多种存储引擎,比如InnoDB,MyISAM,Memory,Archive等等.\n在大多数的情况下,直接选择使用InnoDB引擎都是最合适的,InnoDB也是MySQL的默认存储引擎.\nInnoDB和MyISAM有什么区别? InnoDB支持事物,而MyISAM不支持事物\nInnoDB支持行级锁,而MyISAM支持表级锁\nInnoDB支持MVCC, 而MyISAM不支持\nInnoDB支持外键,而MyISAM不支持\nInnoDB不支持全文索引,而MyISAM支持。\n零散问题 1. MySQL中的varchar和char有什么区别.\nchar是一个定长字段,假如申请了char(10)的空间,那么无论实际存储多少内容.该字段都占用10个字符,而varchar是变长的\n也就是说申请的只是最大长度,占用的空间为实际字符长度+1,最后一个字符存储使用了多长的空间.\n在检索效率上来讲,char \u0026gt; varchar,因此在使用中,如果确定某个字段的值的长度,可以使用char,否则应该尽量使用varchar.例如存储用户MD5加密后的密码,则应该使用char.\n2. varchar(10)和int(10)代表什么含义?\nvarchar的10代表了申请的空间长度,也是可以存储的数据的最大长度,而int的10只是代表了展示的长度,不足10位以0填充.\n也就是说,int(1)和int(10)所能存储的数字大小以及占用的空间都是相同的,只是在展示时按照长度展示.\n3. MySQL的binlog有有几种录入格式?分别有什么区别?\n有三种格式,statement,row和mixed.\nstatement模式下,记录单元为语句.即每一个sql造成的影响会记录.由于sql的执行是有上下文的,因此在保存的时候需要保存相关的信息,同时还有一些使用了函数之类的语句无法被记录复制.\nrow级别下,记录单元为每一行的改动,基本是可以全部记下来但是由于很多操作,会导致大量行的改动(比如alter table),因此这种模式的文件保存的信息太多,日志量太大.\nmixed. 一种折中的方案,普通操作使用statement记录,当无法使用statement的时候使用row.\n此外,新版的MySQL中对row级别也做了一些优化,当表结构发生变化的时候,会记录语句而不是逐行记录.\n4. 超大分页怎么处理?\n超大的分页一般从两个方向上来解决.\n数据库层面,这也是我们主要集中关注的(虽然收效没那么大)\n类似于select * from table where age \u0026gt; 20 limit 1000000,10这种查询其实也是有可以优化的余地的.\n这条语句需要load1000000数据然后基本上全部丢弃,只取10条当然比较慢.\n我们可以修改为select * from table where id in (select id from table where age \u0026gt; 20 limit 1000000,10)\n这样虽然也load了一百万的数据,但是由于索引覆盖,要查询的所有字段都在索引中,所以速度会很快.\n同时如果ID连续的好,我们还可以select * from table where id \u0026gt; 1000000 limit 10,效率也是不错的\n优化的可能性有许多种,但是核心思想都一样,就是减少load的数据.\n从需求的角度减少这种请求….主要是不做类似的需求(直接跳转到几百万页之后的具体某一页.只允许逐页查看或者按照给定的路线走,这样可预测,可缓存)以及防止ID泄漏且连续被人恶意攻击.\n解决超大分页,其实主要是靠缓存,可预测性的提前查到内容,缓存至redis等k-V数据库中,直接返回即可.\n在阿里巴巴《Java开发手册》中,对超大分页的解决办法是类似于上面提到的第一种.\n5. 关心过业务系统里面的sql耗时吗?统计过慢查询吗?对慢查询都怎么优化过?\n在业务系统中,除了使用主键进行的查询,其他的我都会在测试库上测试其耗时,慢查询的统计主要由运维在做,会定期将业务中的慢查询反馈给我们.\n慢查询的优化首先要搞明白慢的原因是什么? 是查询条件没有命中索引?是load了不需要的数据列?还是数据量太大?\n所以优化也是针对这三个方向来的,\n首先分析语句,看看是否load了额外的数据,可能是查询了多余的行并且抛弃掉了,可能是加载了许多结果中并不需要的列,对语句进行分析以及重写.\n分析语句的执行计划,然后获得其使用索引的情况,之后修改语句或者修改索引,使得语句可以尽可能的命中索引.\n如果对语句的优化已经无法进行,可以考虑表中的数据量是否太大,如果是的话可以进行横向或者纵向的分表.\n6. 上面提到横向分表和纵向分表,可以分别举一个适合他们的例子吗?\n横向分表是按行分表.假设我们有一张用户表,主键是自增ID且同时是用户的ID.数据量较大,有1亿多条,那么此时放在一张表里的查询效果就不太理想.\n我们可以根据主键ID进行分表,无论是按尾号分,或者按ID的区间分都是可以的.\n假设按照尾号0-99分为100个表,那么每张表中的数据就仅有100w.这时的查询效率无疑是可以满足要求的.\n纵向分表是按列分表.假设我们现在有一张文章表.包含字段id-摘要-内容.而系统中的展示形式是刷新出一个列表,列表中仅包含标题和摘要\n当用户点击某篇文章进入详情时才需要正文内容.此时,如果数据量大,将内容这个很大且不经常使用的列放在一起会拖慢原表的查询速度.\n我们可以将上面的表分为两张.id-摘要,id-内容.当用户点击详情,那主键再来取一次内容即可.而增加的存储量只是很小的主键字段.代价很小.\n当然,分表其实和业务的关联度很高,在分表之前一定要做好调研以及benchmark.不要按照自己的猜想盲目操作.\n**7. 什么是存储过程?**有哪些优缺点?\n存储过程是一些预编译的SQL语句。\n1、更加直白的理解:存储过程可以说是一个记录集,它是由一些T-SQL语句组成的代码块\n这些T-SQL语句代码像一个方法一样实现一些功能(对单表或多表的增删改查),然后再给这个代码块取一个名字,在用到这个功能的时候调用他就行了。\n2、存储过程是一个预编译的代码块,执行效率比较高,一个存储过程替代大量T_SQL语句 ,可以降低网络通信量,提高通信速率,可以一定程度上确保数据安全\n但是,在互联网项目中,其实是不太推荐存储过程的,比较出名的就是阿里的《Java开发手册》中禁止使用存储过程\n我个人的理解是,在互联网项目中,迭代太快,项目的生命周期也比较短,人员流动相比于传统的项目也更加频繁\n在这样的情况下,存储过程的管理确实是没有那么方便,同时,复用性也没有写在服务层那么好.\n8. 说一说三个范式\n第一范式: 每个列都不可以再拆分.\n第二范式: 非主键列完全依赖于主键,而不能是依赖于主键的一部分.\n第三范式: 非主键列只依赖于主键,不依赖于其他非主键.\n在设计数据库结构的时候,要尽量遵守三范式,如果不遵守,必须有足够的理由.比如性能. 事实上我们经常会为了性能而妥协数据库的设计.\n9. MyBatis 中的 #\n乱入了一个奇怪的问题…..我只是想单独记录一下这个问题,因为出现频率太高了.\n# 会将传入的内容当做字符串,而$会直接将传入值拼接在sql语句中.\n所以#可以在一定程度上预防sql注入攻击。\n为什么不要用 SELECT * 不需要的列会增加数据传输时间和网络开销 用“SELECT * ”数据库需要解析更多的对象、字段、权限、属性等相关内容,在 SQL 语句复杂,硬解析较多的情况下,会对数据库造成沉重的负担。 增大网络开销;* 有时会误带上如log、IconMD5之类的无用且大文本字段,数据传输size会几何增涨。如果DB和应用程序不在同一台机器,这种开销非常明显\n即使 mysql 服务器和客户端是在同一台机器上,使用的协议还是 tcp,通信也是需要额外的时间。\n对于无用的大字段,如 varchar、blob、text,会增加 io 操作 准确来说,长度超过 728 字节的时候,会先把超出的数据序列化到另外一个地方,因此读取这条记录会增加一次 io 操作。(MySQL InnoDB)\n失去MySQL优化器“覆盖索引”策略优化的可能性 SELECT * 杜绝了覆盖索引的可能性,而基于MySQL优化器的“覆盖索引”策略又是速度极快,效率极高,业界极为推荐的查询优化方式。\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E9%AB%98%E9%A2%91%E9%9D%A2%E8%AF%95%E9%97%AE%E9%A2%98/","summary":"本文主要受众为开发人员,所以不涉及到MySQL的服务部署等操作,且内容较多,大家准备好耐心和瓜子矿泉水。 前一阵系统的学习了一下MySQL,也","title":"MySql高频面试问题"},{"content":"假设有如下目录结构:\n1 2 3 4 5 6 7 -- dir0 | file1.py | file2.py | dir3 | file3.py | dir4 | file4.py dir0文件夹下有file1.py、file2.py两个文件和dir3、dir4两个子文件夹,dir3中有file3.py文件,dir4中有file4.py文件。\n1.导入同级模块 python导入同级模块(在同一个文件夹中的py文件)直接导入即可。\n1 import xxx 如在file1.py中想导入file2.py,注意无需加后缀\u0026quot;.py\u0026quot;:\n1 2 3 import file2 # 使用file2中函数时需加上前缀\u0026#34;file2.\u0026#34;,即: # file2.fuction_name() 2.导入下级模块 导入下级目录模块也很容易,需在下级目录中新建一个空白的__init__.py文件再导入:\n1 from dirname import xxx 如在file1.py中想导入dir3下的file3.py,首先要在dir3中新建一个空白的__init__.py文件。\n1 2 3 4 5 6 7 8 -- dir0 | file1.py | file2.py | dir3 | __init__.py | file3.py | dir4 | file4.py 再使用如下语句:\n1 2 #plan A from dir3 import file3 或者\n1 2 3 4 #plan B import dir3.file3 #or # import dir3.file3 as df3 但使用第二种方式则下文需要一直带着路径dir3书写,较为累赘,建议可以另起一个别名。\n3.导入上级模块 要导入上级目录下模块,可以使用sys.path: 1 2 3 import sys sys.path.append(\u0026#39;..\u0026#39;) import xxx 如在file4.py中想引入import上级目录下的file1.py:\n1 2 3 import sys sys.path.append(\u0026#34;..\u0026#34;) import file1 **sys.path的作用:**当使用import语句导入模块时,解释器会搜索当前模块所在目录以及sys.path指定的路径去找需要import的模块,所以这里是直接把上级目录加到了sys.path里。\n**“..”的含义:**等同于linux里的‘..’,表示当前工作目录的上级目录。实际上python中的‘.’也和linux中一致,表示当前目录。\n4.导入隔壁文件夹下的模块 如在file4.py中想引入import在dir3目录下的file3.py。\n这其实是前面两个操作的组合,其思路本质上是将上级目录加到sys.path里,再按照对下级目录模块的方式导入。\n同样需要被引文件夹也就是dir3下有空的__init__.py文件。\n1 2 3 4 5 6 7 8 -- dir | file1.py | file2.py | dir3 | __init__.py | file3.py | dir4 | file4.py 同时也要将上级目录加到sys.path里:\n1 2 3 import sys sys.path.append(\u0026#34;..\u0026#34;) from dir3 import file3 文件夹作为package需要满足如下两个条件:\n文件夹中必须存在有__init__.py文件,可以为空。 不能作为顶层模块来执行该文件夹中的py文件。 ","permalink":"https://reid00.github.io/en/posts/langs_linux/python-import%E5%AF%BC%E5%85%A5%E4%B8%8A%E7%BA%A7%E7%9B%AE%E5%BD%95%E6%96%87%E4%BB%B6/","summary":"假设有如下目录结构: 1 2 3 4 5 6 7 -- dir0 | file1.py | file2.py | dir3 | file3.py | dir4 | file4.py dir0文件夹下有file1.py、file2.py两个文件和dir3、dir","title":"Python Import导入上级目录文件"},{"content":"什么是索引,索引的作用 当我们要在新华字典里查某个字(如「先」)具体含义的时候,通常都会拿起一本新华字典来查,你可以先从头到尾查询每一页是否有「先」这个字,这样做(对应数据库中的全表扫描)确实能找到,但效率无疑是非常低下的,更高效的方相信大家也都知道,就是在首页的索引里先查找「先」对应的页数,然后直接跳到相应的页面查找,这样查询时候大大减少了,可以为是 O(1)。\n数据库中的索引也是类似的,通过索引定位到要读取的页,大大减少了需要扫描的行数,能极大的提升效率,简而言之,索引主要有以下几个作用:\n即上述所说,索引能极大地减少扫描行数 索引可以帮助服务器避免排序和临时表 索引可以将随机 IO 变成顺序 IO MySQL中索引的存储类型有两种:BTREE和HASH,具体和表的存储引擎相关;\nMyISAM和InnoDB存储引擎只支持BTREE索引,MEMORY/HEAP存储引擎可以支持HASH和BTREE索引。\n第一点上文已经解释了,我们来看下第二点和第三点\n先来看第二点,假设我们不用索引,试想运行如下语句\n1 select * from user order by age desc 则 MySQL 的流程是这样的,扫描所有行,把所有行加载到内存后,再按 age 排序生成一张临时表,再把这表排序后将相应行返回给客户端,更糟的,如果这张临时表的大小大于 tmp_table_size 的值(默认为 16 M),内存临时表会转为磁盘临时表,性能会更差,如果加了索引,索引本身是有序的 ,所以从磁盘读的行数本身就是按 age 排序好的,也就不会生成临时表,就不用再额外排序 ,无疑提升了性能。\n再来看随机 IO 和顺序 IO。先来解释下这两个概念。\n相信不少人应该吃过旋转火锅,服务员把一盘盘的菜放在旋转传输带上,然后等到这些菜转到我们面前,我们就可以拿到菜了,假设装一圈需要 4 分钟,则最短等待时间是 0(即菜就在你跟前),最长等待时间是 4 分钟(菜刚好在你跟前错过),那么平均等待时间即为 2 分钟,假设我们现在要拿四盘菜,这四盘菜随机分配在传输带上,则可知拿到这四盘菜的平均等待时间是 8 分钟(随机 IO),如果这四盘菜刚好紧邻着排在一起,则等待时间只需 2 分钟(顺序 IO)。\n上述中传输带就类比磁道,磁道上的菜就类比扇区(sector)中的信息,磁盘块(block)是由多个相邻的扇区组成的,是操作系统读取的最小单元,这样如果信息能以 block 的形式聚集在一起,就能极大减少磁盘 IO 时间,这就是顺序 IO 带来的性能提升,下文中我们将会看到 B+ 树索引就起到这样的作用。\n如图示:多个扇区组成了一个 block,如果要读的信息都在这个 block 中,则只需一次 IO 读\n而如果信息在一个磁道中, 分散地分布在各个扇区中,或者分布在不同磁道的扇区上(寻道时间是随机IO主要瓶颈所在),将会造成随机 IO,影响性能。\n我们来看一下一个随机 IO 的时间分布:\nseek Time: 寻道时间,磁头移动到扇区所在的磁道 Rotational Latency:完成步骤 1 后,磁头移动到同一磁道扇区对应的位置所需求时间 Transfer Time 从磁盘读取信息传入内存时间 这其中寻道时间占据了绝大多数的时间(大概占据随机 IO 时间的占 40%)。\n随机 IO 和顺序 IO 大概相差百倍 (随机 IO:10 ms/ page, 顺序 IO 0.1ms / page),可见顺序 IO 性能之高,索引带来的性能提升显而易见!\n索引的种类 索引主要分为以下几类\nB+树索引 哈希索引 B+树索引 B+ 树索引之前在此文中详细阐述过,强烈建议大家看一遍,对理解 B+ 树有很大的帮助,简单回顾一下吧\nB+ 树是以 N 叉树的形式存在的,这样有效降低了树的高度,查找数据也不需要全表扫描了,顺着根节点层层往下查找能很快地找到我们的目标数据。 每个节点的大小即一个页的大小,一次 IO 会将一个页(每页包含多个磁盘块)的数据都读入(即磁盘预读,程序局部性原理:读到了某个值,很大可能这个值周围的数据也会被用到,干脆一起读入内存),叶子节点通过指针的相互指向连接,能有效减少顺序遍历时的随机 IO,而且我们也可以看到,叶子节点都是按索引的顺序排序好的,这也意味着根据索引查找或排序都是排序好了的,不会再在内存中形成临时表。\n详解:Mysql设计利用了磁盘预读原理,将一个B+Tree节点大小设为一个页大小,在新建节点时直接申请一个页的空间,这样就能保证一个节点物理上存储在一个页里,加之计算机存储分配都是按页对齐的,这样就实现了每个Node节点只需要一次I/O操作。\n一般来说B+Tree比BTree更适合实现外存的索引结构,因为存储引擎的设计专家巧妙的利用了外存(磁盘)的存储结构,即磁盘的最小存储单位是扇区(sector),而操作系统的块(block)通常是整数倍的sector,操作系统以页(page)为单位管理内存,一页(page)通常默认为4K,数据库的页通常设置为操作系统页的整数倍16K,因此索引结构的节点被设计为一个页的大小,然后利用外存的“预读取”原则,每次读取的时候,把整个节点的数据读取到内存中,然后在内存中查找,已知内存的读取速度是外存读取I/O速度的几百倍,那么提升查找速度的关键就在于尽可能少的磁盘I/O,那么可以知道,每个节点中的key个数越多,那么树的高度越小,需要I/O的次数越少,因此一般来说B+Tree比BTree更快,因为B+Tree的非叶节点中不存储data,就可以存储更多的key。\nB树和B+树的区别,数据库为什么使用B+树而不是B树? 在B树中,键和值即存放在内部节点又存放在叶子节点;在B+树中,内部节点只存键,叶子节点则同时存放键和值。 B+树的叶子节点有一条链相连,而B树的叶子节点各自独立的。 B+树索引的所有数据均存储在叶子节点,而且数据是按照顺序排列的,链表连着的。那么B+树使得范围查找,排序查找,分组查找以及去重查找变得异常简单。 B+树非叶子节点上是不存储数据的,仅存储键值,而B树节点中不仅存储键值,也会存储数据。innodb中页的默认大小是16KB,如果不存储数据,那么就会存储更多的键值,相应的树的阶数(节点的子节点树)就会更大,树就会更矮更胖,如此一来我们查找数据进行磁盘的IO次数有会再次减少,数据查询的效率也会更快. 因为B树不管叶子节点还是非叶子节点,都会保存数据,这样导致在非叶子节点中能保存的指针数量变少(有些资料也称为扇出),指针少的情况下要保存大量数据,只能增加树的高度,导致IO操作变多,查询性能变低;B 树进行范围查询,会产生大量随机IO 在B+树中叶子节点存放数据,非叶子节点存放键值+指针。 哈希索引 哈希索引基本散列表实现,散列表(也称哈希表)是根据关键码值(Key value)而直接进行访问的数据结构,它让码值经过哈希函数的转换映射到散列表对应的位置上,查找效率非常高。假设我们对名字建立了哈希索引,则查找过程如下图所示:\n对于每一行数据,存储引擎都会对所有的索引列(上图中的 name 列)计算一个哈希码(上图散列表的位置),散列表里的每个元素指向数据行的指针,由于索引自身只存储对应的哈希值,所以索引的结构十分紧凑,这让哈希索引查找速度非常快!\n当然了哈希表的劣势也是比较明显的,不支持区间查找,不支持排序,所以更多的时候哈希表是与 B Tree等一起使用的,在 InnoDB 引擎中就有一种名为「自适应哈希索引」的特殊索引,当 innoDB 注意到某些索引值使用非常频繁时,就会内存中基于 B-Tree 索引之上再创建哈希索引,这样也就让 B+ 树索引也有了哈希索引的快速查找等优点,这是完全自动,内部的行为,用户无法控制或配置,不过如果有必要,可以关闭该功能。\ninnoDB 引擎本身是不支持显式创建哈希索引的,我们可以在 B+ 树的基础上创建一个伪哈希索引,它与真正的哈希索引不是一回事,它是以哈希值而非键本身来进行索引查找的,这种伪哈希索引的使用场景是怎样的呢,假设我们在 db 某张表中有个 url 字段,我们知道每个 url 的长度都很长,如果以 url 这个字段创建索引,无疑要占用很大的存储空间,如果能通过哈希(比如CRC32)把此 url 映射成 4 个字节,再以此哈希值作索引 ,索引占用无疑大大缩短!不过在查询的时候要记得同时带上 url 和 url_crc,主要是为了避免哈希冲突,导致 url_crc 的值可能一样\n1 SELECT id FROM url WHERE url = \u0026#34;http://www.baidu.com\u0026#34; AND url_crc = CRC32(\u0026#34;http://www.baidu.com\u0026#34;) 这样做把基于 url 的字符串索引改成了基于 url_crc 的整型索引,效率更高,同时索引占用的空间也大大减少,一举两得,当然人可能会说需要手动维护索引太麻烦了,那可以改进触发器实现。\n除了上文说的两个索引 ,还有空间索引(R-Tree),全文索引等,由生产中不是很常用,这里不作过多阐述\n高性能索引策略 不同的索引设计选择能对性能产生很大的影响,有人可能会发现生产中明明加了索引却不生效,有时候加了虽然生效但对搜索性能并没有提升多少,对于多列联合索引,哪列在前,哪列在后也是有讲究的,我们一起来看看\n加了索引,为何却不生效 加了索引却不生效可能会有以下几种原因\n1、索引列是表示式的一部分,或是函数的一部分 1 SELECT book_id FROM BOOK WHERE book_id + 1 = 5; 或者\n1 SELECT book_id FROM BOOK WHERE TO_DAYS(CURRENT_DATE) - TO_DAYS(gmt_create) \u0026lt;= 10 上述两个 SQL 虽然在列 book_id 和 gmt_create 设置了索引 ,但由于它们是表达式或函数的一部分,导致索引无法生效,最终导致全表扫描。\n2、隐式类型转换 以上两种情况相信不少人都知道索引不能生效,但下面这种隐式类型转换估计会让不少人栽跟头,来看下下面这个例子:\n假设有以下表:\n1 2 3 4 5 6 7 8 9 CREATE TABLE `tradelog` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `operator` int(11) DEFAULT NULL, `t_modified` datetime DEFAULT NULL, PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`), KEY `t_modified` (`t_modified`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; 执行 SQL 语句\n1 SELECT * FROM tradelog WHERE tradeid=110717; 交易编号 tradeid 上有索引,但用 EXPLAIN 执行却发现使用了全表扫描,为啥呢,tradeId 的类型是 varchar(32), 而此 SQL 用 tradeid 一个数字类型进行比较,发生了隐形转换,会隐式地将字符串转成整型,如下:\n1 SELECT * FROM tradelog WHERE CAST(tradid AS signed int) = 110717; 这样也就触发了上文中第一条的规则 ,即:索引列不能是函数的一部分。\n3、隐式编码转换 这种情况非常隐蔽,来看下这个例子\n1 2 3 4 5 6 7 CREATE TABLE `trade_detail` ( `id` int(11) NOT NULL, `tradeid` varchar(32) DEFAULT NULL, `trade_step` int(11) DEFAULT NULL, /*操作步骤*/ `step_info` varchar(32) DEFAULT NULL, /*步骤信息*/ PRIMARY KEY (`id`), KEY `tradeid` (`tradeid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8; trade_detail 是交易详情, tradelog 是操作此交易详情的记录,现在要查询 id=2 的交易的所有操作步骤信息,则我们会采用如下方式\n1 SELECT d.* FROM tradelog l, trade_detail d WHERE d.tradeid=l.tradeid AND l.id=2; 由于 tradelog 与 trade_detail 这两个表的字符集不同,且 tradelog 的字符集是 utf8mb4,而 trade_detail 字符集是 utf8, utf8mb4 是 utf8 的超集,所以会自动将 utf8 转成 utf8mb4。即上述语句会发生如下转换:\n1 SELECT d.* FROM tradelog l, trade_detail d WHERE (CONVERT(d.traideid USING utf8mb4)))=l.tradeid AND l.id=2; 自然也就触发了 「索引列不能是函数的一部分」这条规则。怎么解决呢,第一种方案当然是把两个表的字符集改成一样,如果业务量比较大,生产上不方便改的话,还有一种方案是把 utf8mb4 转成 utf8,如下\n1 SELECT d.* FROM tradelog l , trade_detail d WHERE d.tradeid=CONVERT(l.tradeid USING utf8) AND l.id=2; 这样索引列就生效了。\n4、使用 order by 造成的全表扫描 1 SELECT * FROM user ORDER BY age DESC 上述语句在 age 上加了索引,但依然造成了全表扫描,这是因为我们使用了 SELECT *,导致回表查询,MySQL 认为回表的代价比全表扫描更大,所以不选择使用索引,如果想使用到 age 的索引,我们可以用覆盖索引来代替:\n1 SELECT age FROM user ORDER BY age DESC 或者加上 limit 的条件(数据比较小)\n1 SELECT * FROM user ORDER BY age DESC limit 10 这样就能利用到索引。\n无法避免对索引列使用函数,怎么使用索引 时候我们无法避免对索引列使用函数,但这样做会导致全表索引,是否有更好的方式呢。\n比如我现在就是想记录 2016 ~ 2018 所有年份 7月份的交易记录总数\n1 SELECT count(*) FROM tradelog WHERE month(t_modified)=7; 由于索引列是函数的参数,所以显然无法用到索引,我们可以将它改造成基本字段区间的查找如下\n1 2 3 4 SELECT count(*) FROM tradelog WHERE -\u0026gt; (t_modified \u0026gt;= \u0026#39;2016-7-1\u0026#39; AND t_modified\u0026lt;\u0026#39;2016-8-1\u0026#39;) or -\u0026gt; (t_modified \u0026gt;= \u0026#39;2017-7-1\u0026#39; AND t_modified\u0026lt;\u0026#39;2017-8-1\u0026#39;) or -\u0026gt; (t_modified \u0026gt;= \u0026#39;2018-7-1\u0026#39; AND t_modified\u0026lt;\u0026#39;2018-8-1\u0026#39;); 前缀索引与索引选择性 之前我们说过,如于长字符串的字段(如 url),我们可以用伪哈希索引的形式来创建索引,以避免索引变得既大又慢,除此之外其实还可以用前缀索引(字符串的部分字符)的形式来达到我们的目的,那么这个前缀索引应该如何选取呢,这叫涉及到一个叫索引选择性的概念\n索引选择性:不重复的索引值(也称为基数,cardinality)和数据表的记录总数的比值,比值越高,代表索引的选择性越好,唯一索引的选择性是最好的,比值是 1。\n画外音:我们可以通过 SHOW INDEXES FROM table 来查看每个索引 cardinality 的值以评估索引设计的合理性\n怎么选择这个比例呢,我们可以分别取前 3,4,5,6,7 的前缀索引,然后再比较下选择这几个前缀索引的选择性,执行以下语句\n1 2 3 4 5 6 7 SELECT COUNT(DISTINCT LEFT(city,3))/COUNT(*) as sel3, COUNT(DISTINCT LEFT(city,4))/COUNT(*) as sel4, COUNT(DISTINCT LEFT(city,5))/COUNT(*) as sel5, COUNT(DISTINCT LEFT(city,6))/COUNT(*) as sel6, COUNT(DISTINCT LEFT(city,7))/COUNT(*) as sel7 FROM city_demo 得结果如下\nsel3 sel4 sel5 sel6 sel7 0.0239 0.0293 0.0305 0.0309 0.0310 可以看到当前缀长度为 7 时,索引选择性提升的比例已经很小了,也就是说应该选择 city 的前六个字符作为前缀索引,如下\n1 ALTER TABLE city_demo ADD KEY(city(6)) 我们当前是以平均选择性为指标的,有时候这样是不够的,还得考虑最坏情况下的选择性,以这个 demo 为例,可能一些人看到选择 4,5 的前缀索引与选择 6,7 的选择性相差不大,那就得看下选择 4,5 的前缀索引分布是否均匀了\n1 2 3 4 SELECT COUNT(*) AS cnt, LEFT(city, 4) AS pref FROM city_demo GROUP BY pref ORDER BY cnt DESC LIMIT 5 可能会出现以下结果\ncnt pref 305 Sant 200 Toul 90 Chic 20 Chan 可以看到分布极不均匀,以 Sant,Toul 为前缀索引的数量极多,这两者的选择性都不是很理想,所以要选择前缀索引时也要考虑最差的选择性的情况。\n前缀索引虽然能实现索引占用空间小且快的效果,但它也有明显的弱点,MySQL 无法使用前缀索引做 ORDER BY 和 GROUP BY ,而且也无法使用前缀索引做覆盖扫描,前缀索引也有可能增加扫描行数。\n假设有以下表数据及要执行的 SQL\nd email 1 zhangssxyz@163.com 2 zhangs1@163.com 3 zhangs1@163.com 4 zhangs1@163.com 1 SELECT id,email FROM user WHERE email=\u0026#39;zhangssxyz@xxx.com\u0026#39;; 如果我们针对 email 设置的是整个字段的索引,则上表中根据 「zhangssxyz@163.com」查询到相关记记录后,再查询此记录的下一条记录,发现没有,停止扫描。此时可知只扫描一行记录,如果我们以前六个字符(即 email(6))作为前缀索引,则显然要扫描四行记录,并且获得行记录后不得不回到主键索引再判断 email 字段的值,所以使用前缀索引要评估它带来的这些开销。\n另外有一种情况我们可能需要考虑一下,如果前缀基本都是相同的该怎么办,比如现在我们为某市的市民建立一个人口信息表,则这个市人口的身份证虽然不同,但身份证前面的几位数都是相同的,这种情况该怎么建立前缀索引呢。\n一种方式就是我们上文说的,针对身份证建立哈希索引,另一种方式比较巧妙,将身份证倒序存储,查的时候可以按如下方式查询:\n1 SELECT field_list FROM t WHERE id_card = reverse(\u0026#39;input_id_card_string\u0026#39;); 这样就可以用身份证的后六位作前缀索引了,是不是很巧妙 ^_^\n实际上上文所述的索引选择性同样适用于联合索引的设计,如果没有特殊情况,我们一般建议在建立联合索引时,把选择性最高的列放在最前面,比如,对于以下语句:\n1 SELECT * FROM payment WHERE staff_id = xxx AND customer_id = xxx; 单就这个语句而言, (staff_id,customer_id) 和 (customer_id, staff_id) 这两个联合索引我们应该建哪一个呢,可以统计下这两者的选择性。\n1 2 3 4 5 SELECT COUNT(DISTINCT staff_id)/COUNT(*) as staff_id_selectivity, COUNT(DISTINCT customer_id)/COUNT(*) as customer_id_selectivity, COUNT(*) FROM payment 结果为:\n1 2 3 staff_id_selectivity: 0.0001 customer_id_selectivity: 0.0373 COUNT(*): 16049 从中可以看出 customer_id 的选择性更高,所以应该选择 customer_id 作为第一列。\n索引设计准则:三星索引 上文我们得出了一个索引列顺序的经验 法则:将选择性最高的列放在索引的最前列,这种建立在某些场景可能有用,但通常不如避免随机 IO 和 排序那么重要,这里引入索引设计中非常著名的一个准则:三星索引。\n如果一个查询满足三星索引中三颗星的所有索引条件,理论上可以认为我们设计的索引是最好的索引。什么是三星索引\n第一颗星:WHERE 后面参与查询的列可以组成了单列索引或联合索引 第二颗星:避免排序,即如果 SQL 语句中出现 order by colulmn,那么取出的结果集就已经是按照 column 排序好的,不需要再生成临时表 第三颗星:SELECT 对应的列应该尽量是索引列,即尽量避免回表查询。 所以对于如下语句:\n1 SELECT age, name, city where age = xxx and name = xxx order by age 设计的索引应该是 (age, name,city) 或者 (name, age,city)\n当然 了三星索引是一个比较理想化的标准,实际操作往往只能满足期望中的一颗或两颗星,考虑如下语句:\n1 SELECT age, name, city where age \u0026gt;= 10 AND age \u0026lt;= 20 and city = xxx order by name desc 假设我们分别为这三列建了联合索引,则显然它符合第三颗星(使用了覆盖索引),如果索引是(city, age, name),则虽然满足了第一颗星,但排序无法用到索引,不满足第二颗星,如果索引是 (city, name, age),则第二颗星满足了,但此时 age 在 WHERE 中的搜索条件又无法满足第一星,\n另外第三颗星(尽量使用覆盖索引)也无法完全满足,试想我要 SELECT 多列,要把这多列都设置为联合索引吗,这对索引的维护是个问题,因为每一次表的 CURD 都伴随着索引的更新,很可能频繁伴随着页分裂与页合并。\n综上所述,三星索引只是给我们构建索引提供了一个参考,索引设计应该尽量靠近三星索引的标准,但实际场景我们一般无法同时满足三星索引,一般我们会优先选择满足第三颗星(因为回表代价较大)至于第一,二颗星就要依赖于实际的成本及实际的业务场景考虑。\n为什么要一定要设置主键? from: https://zhuanlan.zhihu.com/p/116866170\n其实这个不是一定的,有些场景下,小系统或者没什么用的表,不设置主键也没关系,mysql最好是用自增主键,主要是以下两个原因:果定义了主键,那么InnoDB会选择主键作为聚集索引、如果没有显式定义主键,则innodb 会选择第一个不包含有NULL值的唯一索引作为主键索引、如果也没有这样的唯一索引,则innodb 会选择内置6字节长的ROWID作为隐含的聚集索引。所以,反正都要生成一个主键,那你还不如自己指定一个主键,提高查询效率!\n前面我写了几篇关于 mysql 索引的文章,索引是 mysql 非常重要的一部分。你也可能经常会看到一些关于 mysql 军规、mysql 查询优化的文章,其实这些操作的背后都是基于一定的原理的,你要想明白这些原理,首先就得知道 mysql 底层的一些东西。\n我在这里举几个例子吧。\n我们都知道表的主键一般都要使用自增 id,不建议使用业务 id ,是因为使用自增 id 可以避免页分裂。这个其实可以相当于一个结论,你都可以直接记住这个结论就可以了。\n但是如果你要弄明白什么是页分裂,或者什么情况下会页分裂,这个时候你就需要对 mysql 的底层数据结构要有一定的理解了。\n我这里也稍微解释一下页分裂,mysql (注意本文讲的 mysql 默认为InnoDB 引擎)底层数据结构是 B+ 树,所谓的索引其实就是一颗 B+ 树,一个表有多少个索引就会有多少颗 B+ 树,mysql 中的数据都是按顺序保存在 B+ 树上的(所以说索引本身是有序的)。\n然后 mysql 在底层又是以数据页为单位来存储数据的,一个数据页大小默认为 16k,当然你也可以自定义大小,也就是说如果一个数据页存满了,mysql 就会去申请一个新的数据页来存储数据。\n如果主键为自增 id 的话,mysql 在写满一个数据页的时候,直接申请另一个新数据页接着写就可以了。\n如果主键是非自增 id,为了确保索引有序,mysql 就需要将每次插入的数据都放到合适的位置上。\n当往一个快满或已满的数据页中插入数据时,新插入的数据会将数据页写满,mysql 就需要申请新的数据页,并且把上个数据页中的部分数据挪到新的数据页上。\n这就造成了页分裂,这个大量移动数据的过程是会严重影响插入效率的。\n其实对主键 id 还有一个小小的要求,在满足业务需求的情况下,尽量使用占空间更小的主键 id,因为普通索引的叶子节点上保存的是主键 id 的值,如果主键 id 占空间较大的话,那将会成倍增加 mysql 空间占用大小。\n主键是用自增还是UUID 最好是用自增主键,主要是以下两个原因:\n1. 如果表使用自增主键,那么每次插入新的记录,记录就会顺序添加到当前索引节点的后续位置,当一页写满,就会自动开辟一个新的页。 2. 如果使用非自增主键(如uuid),由于每次插入主键的值近似于随机,因此每次新纪录都要被插到索引页的随机某个位置,此时MySQL为了将新记录插到合适位置而移动数据,甚至目标页面可能已经被回写到磁盘上而从缓存中清掉,此时又要从磁盘上读回来,这增加了很多开销,同时频繁的移动、分页操作造成索引碎片,得到了不够紧凑的索引结构,后续不得不通过OPTIMIZE TABLE来重建表并优化填充页面。\n不过,也不是所有的场景下都得使用自增主键,可能场景下,主键必须自己生成,不在乎那些性能的开销。那也没有问题。\n自增主键用完了怎么办? 在mysql中,Int整型的范围(-2147483648~2147483648),约20亿!因此不用考虑自增ID达到最大值这个问题。而且数据达到千万级的时候就应该考虑分库分表了。\n主键为什么不推荐有业务含义? 最好是主键是无意义的自增ID,然后另外创建一个业务主键ID,\n因为任何有业务含义的列都有改变的可能性,主键一旦带上了业务含义,那么主键就有可能发生变更。主键一旦发生变更,该数据在磁盘上的存储位置就会发生变更,有可能会引发页分裂,产生空间碎片。\n还有就是,带有业务含义的主键,不一定是顺序自增的。那么就会导致数据的插入顺序,并不能保证后面插入数据的主键一定比前面的数据大。如果出现了,后面插入数据的主键比前面的小,就有可能引发页分裂,产生空间碎片。\n货币字段用什么类型 货币字段一般都用 Decimal类型, float和double是以二进制存储的,数据大的时候,可能存在误差。\n以下是FLOAT和DOUBLE的区别:\n浮点数以8位精度存储在FLOAT中,并且有四个字节。\n浮点数存储在DOUBLE中,精度为18位,有八个字节。\n表中有大字段X(例如:text类型),且字段X不会经常更新,以读为主,那么是拆成子表好?还是放一起好? 其实各有利弊,拆开带来的问题:连接消耗;不拆可能带来的问题:查询性能,所以要看你的实际情况,如果表数据量比较大,最好还是拆开为好。这样查询速度更快。\n字段为什么要定义为NOT NULL? 一般情况,都会设置一个默认值,不会出现字段里面有null,又有空的情况。主要有以下几个原因:\n索引性能不好,Mysql难以优化引用可空列查询,它会使索引、索引统计和值更加复杂。可空列需要更多的存储空间,还需要mysql内部进行特殊处理。可空列被索引后,每条记录都需要一个额外的字节,还能导致MYisam 中固定大小的索引变成可变大小的索引。\n如果某列存在null的情况,可能导致count() 等函数执行不对的情况。\nsql 语句写着也麻烦,既要判断是否为空,又要判断是否为null等。\n总结 本文简述了索引的基本原理,索引的几种类型,以及分析了一下设计索引尽量应该遵循的一些准则,相信我们对索引的理解又更深了一步。另外强烈建议大家去学习一下附录中的几本书。文中的挺多例子都是在文末的参考资料中总结出来的,读经典书籍,相信大家会受益匪浅!\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E7%B4%A2%E5%BC%95%E4%BB%8B%E7%BB%8D/","summary":"什么是索引,索引的作用 当我们要在新华字典里查某个字(如「先」)具体含义的时候,通常都会拿起一本新华字典来查,你可以先从头到尾查询每一页是否有","title":"MySql索引介绍"},{"content":"数据库表结构:\n1 2 3 4 5 6 7 8 9 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name) )engine=innodb; select id,name where name=\u0026#39;shenjian\u0026#39; select id,name,sex where name=\u0026#39;shenjian\u0026#39; 多查询了一个属性,为何检索过程完全不同?\n什么是回表查询?\n什么是索引覆盖?\n如何实现索引覆盖?\n哪些场景,可以利用索引覆盖来优化SQL?\n一、什么是回表查询? 这先要从InnoDB的索引实现说起,InnoDB有两大类索引:\n聚集索引(clustered index) 普通索引(secondary index) **InnoDB聚集索引和普通索引有什么差异?\n**\nInnoDB聚集索引的叶子节点存储行记录,因此, InnoDB必须要有,且只有一个聚集索引:\n(1)如果表定义了PK,则PK就是聚集索引;\n(2)如果表没有定义PK,则第一个not NULL unique列是聚集索引;\n(3)否则,InnoDB会创建一个隐藏的row-id作为聚集索引;\n画外音:所以PK查询非常快,直接定位行记录。\nInnoDB普通索引的叶子节点存储主键值。\n画外音:注意,不是存储行记录头指针,MyISAM的索引叶子节点存储记录指针。\n举个栗子,不妨设有表:\nt(id PK, name KEY, sex, flag);\n画外音:id是聚集索引,name是普通索引。\n表中有四条记录:\n1, shenjian, m, A\n3, zhangsan, m, A\n5, lisi, m, A\n9, wangwu, f, B\n两个B+树索引分别如上图:\n(1)id为PK,聚集索引,叶子节点存储行记录;\n(2)name为KEY,普通索引,叶子节点存储PK值,即id;\n既然从普通索引无法直接定位行记录,那普通索引的查询过程是怎么样的呢?\n通常情况下,需要扫描两遍索引树。\n例如:\n1 select` `* ``from` `t ``where` `name``=``\u0026#39;lisi\u0026#39;``; 是如何执行的呢?\n如粉红色路径,需要扫码两遍索引树:\n(1)先通过普通索引定位到主键值id=5;\n(2)在通过聚集索引定位到行记录;\n这就是所谓的回表查询,先定位主键值,再定位行记录,它的性能较扫一遍索引树更低。\n二、什么是索引覆盖Covering index)? MySQL官网,类似的说法出现在explain查询计划优化章节,即explain的输出结果Extra字段为Using index时,能够触发索引覆盖。\n不管是SQL-Server官网,还是MySQL官网,都表达了:只需要在一棵索引树上就能获取SQL所需的所有列数据,无需回表,速度更快。\n三、如何实现索引覆盖? 常见的方法是:将被查询的字段,建立到联合索引里去。\n仍是之前中的例子:\n1 2 3 4 5 6 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name) )engine=innodb; 第一个SQL语句:\n1 select id, name from user where name=\u0026#39;shenjian\u0026#39; 能够命中name索引,索引叶子节点存储了主键id,通过name的索引树即可获取id和name,无需回表,符合索引覆盖,效率较高。\n画外音,Extra:Using index。\n第二个SQL语句:\n1 select id, name from user where name=\u0026#39;shenjian\u0026#39; 能够命中name索引,索引叶子节点存储了主键id,但sex字段必须回表查询才能获取到,不符合索引覆盖,需要再次通过id值扫码聚集索引获取sex字段,效率会降低。\n画外音,Extra:Using index condition。\n如果把(name)单列索引升级为**联合索引(name, sex)**就不同了。\n1 2 3 4 5 6 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name, sex) )engine=innodb; 可以看到:\n1 2 3 select id,name ... where name=\u0026#39;shenjian\u0026#39;; select id,name,sex ... where name=\u0026#39;shenjian\u0026#39;; 都能够命中索引覆盖,无需回表。\n画外音,Extra:Using index。\n四、哪些场景可以利用索引覆盖来优化SQL? 场景1:全表count查询优化 原表为:\nuser(PK id, name, sex);\n1 select count(name) from user; 不能利用索引覆盖。\n添加索引:\n1 alter table user add key(name); 就能够利用索引覆盖提效。\ncount(1)、count(*) 与 count(列名) 的区别?\ncount(*)包括了所有的列,相当于行数,在统计结果的时候,不会忽略列值为NULL count(1)包括了忽略所有列,用1代表代码行,在统计结果的时候,不会忽略列值为NULL count(列名)只包括列名那一列,在统计结果的时候,会忽略列值为空(这里的空不是只空字符串或者0,而是表示null)的计数,即某个字段值为NULL时,不统计。 场景2:列查询回表优化 1 select id,name,sex ... where name=\u0026#39;shenjian\u0026#39;; 这个例子不再赘述,将单列索引(name)升级为联合索引(name, sex),即可避免回表。\n场景3:分页查询 1 select id,name,sex ... order by name limit 500,100; 将单列索引(name)升级为联合索引(name, sex),也可以避免回表。\n参考: https://mp.weixin.qq.com/s/T7LnqldlD9sCH37gWUHVfQ\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E7%B4%A2%E5%BC%95%E4%BC%98%E5%8C%96/","summary":"数据库表结构: 1 2 3 4 5 6 7 8 9 create table user ( id int primary key, name varchar(20), sex varchar(5), index(name) )engine=innodb; select id,name where name=\u0026#39;shenjian\u0026#39; select id,name,sex where name=\u0026#39;shenjian\u0026#39; 多查询了一个属性,为何检索过程完全不同? 什么是回表查询? 什么是索","title":"MySql索引优化"},{"content":"『浅入深出』MySQL 中事务的实现 https://draveness.me/mysql-transaction/\nMySQL 中如何实现事务隔离 https://www.cnblogs.com/fengzheng/p/12557762.html\n详解一条 SQL 的执行过程\nhttps://juejin.cn/post/6931606328129355790\n首先说读未提交,它是性能最好,也可以说它是最野蛮的方式,因为它压根儿就不加锁,所以根本谈不上什么隔离效果,可以理解为没有隔离。\n再来说串行化。读的时候加共享锁,也就是其他事务可以并发读,但是不能写。写的时候加排它锁,其他事务不能并发写也不能并发读。\n最后说读提交和可重复读。这两种隔离级别是比较复杂的,既要允许一定的并发,又想要兼顾的解决问题。\n实现可重复读 为了解决不可重复读,或者为了实现可重复读,MySQL 采用了 MVVC (多版本并发控制) 的方式。\n我们在数据库表中看到的一行记录可能实际上有多个版本,每个版本的记录除了有数据本身外,还要有一个表示版本的字段,记为 row trx_id,而这个字段就是使其产生的事务的 id,事务 ID 记为 transaction id,它在事务开始的时候向事务系统申请,按时间先后顺序递增。\n按照上面这张图理解,一行记录现在有 3 个版本,每一个版本都记录这使其产生的事务 ID,比如事务A的transaction id 是100,那么版本1的row trx_id 就是 100,同理版本2和版本3。\n在上面介绍读提交和可重复读的时候都提到了一个词,叫做快照,学名叫做一致性视图,这也是可重复读和不可重复读的关键,可重复读是在事务开始的时候生成一个当前事务全局性的快照,而读提交则是每次执行语句的时候都重新生成一次快照。\n对于一个快照来说,它能够读到那些版本数据,要遵循以下规则:\n当前事务内的更新,可以读到; 版本未提交,不能读到; 版本已提交,但是却在快照创建后提交的,不能读到; 版本已提交,且是在快照创建前提交的,可以读到; 利用上面的规则,再返回去套用到读提交和可重复读的那两张图上就很清晰了。还是要强调,两者主要的区别就是在快照的创建上,可重复读仅在事务开始是创建一次,而读提交每次执行语句的时候都要重新创建一次。\n并发写问题 存在这的情况,两个事务,对同一条数据做修改。最后结果应该是哪个事务的结果呢,肯定要是时间靠后的那个对不对。并且更新之前要先读数据,这里所说的读和上面说到的读不一样,更新之前的读叫做“当前读”,总是当前版本的数据,也就是多版本中最新一次提交的那版。\n假设事务A执行 update 操作, update 的时候要对所修改的行加行锁,这个行锁会在提交之后才释放。而在事务A提交之前,事务B也想 update 这行数据,于是申请行锁,但是由于已经被事务A占有,事务B是申请不到的,此时,事务B就会一直处于等待状态,直到事务A提交,事务B才能继续执行,如果事务A的时间太长,那么事务B很有可能出现超时异常。如下图所示。\n加锁的过程要分有索引和无索引两种情况,比如下面这条语句\n1 update user set age=11 where id = 1 id 是这张表的主键,是有索引的情况,那么 MySQL 直接就在索引数中找到了这行数据,然后干净利落的加上行锁就可以了。\n而下面这条语句\n1 update user set age=11 where age=10 表中并没有为 age 字段设置索引,所以, MySQL 无法直接定位到这行数据。那怎么办呢,当然也不是加表锁了。MySQL 会为这张表中所有行加行锁,没错,是所有行。但是呢,在加上行锁后,MySQL 会进行一遍过滤,发现不满足的行就释放锁,最终只留下符合条件的行。虽然最终只为符合条件的行加了锁,但是这一锁一释放的过程对性能也是影响极大的。所以,如果是大表的话,建议合理设计索引,如果真的出现这种情况,那很难保证并发度。\n解决幻读 上面介绍可重复读的时候,那张图里标示着出现幻读的地方实际上在 MySQL 中并不会出现,MySQL 已经在可重复读隔离级别下解决了幻读的问题。\n前面刚说了并发写问题的解决方式就是行锁,而解决幻读用的也是锁,叫做间隙锁,MySQL 把行锁和间隙锁合并在一起,解决了并发写和幻读的问题,这个锁叫做 Next-Key锁。\n假设现在表中有两条记录,并且 age 字段已经添加了索引,两条记录 age 的值分别为 10 和 30。\n此时,在数据库中会为索引维护一套B+树,用来快速定位行记录。B+索引树是有序的,所以会把这张表的索引分割成几个区间。\n如图所示,分成了3 个区间,(负无穷,10]、(10,30]、(30,正无穷],在这3个区间是可以加间隙锁的。\n之后,我用下面的两个事务演示一下加锁过程。\n在事务A提交之前,事务B的插入操作只能等待,这就是间隙锁起得作用。当事务A执行update user set name='风筝2号’ where age = 10; 的时候,由于条件 where age = 10 ,数据库不仅在 age =10 的行上添加了行锁,而且在这条记录的两边,也就是(负无穷,10]、(10,30]这两个区间加了间隙锁,从而导致事务B插入操作无法完成,只能等待事务A提交。不仅插入 age = 10 的记录需要等待事务A提交,age\u0026lt;10、10\u0026lt;age\u0026lt;30 的记录页无法完成,而大于等于30的记录则不受影响,这足以解决幻读问题了。\n这是有索引的情况,如果 age 不是索引列,那么数据库会为整个表加上间隙锁。所以,如果是没有索引的话,不管 age 是否大于等于30,都要等待事务A提交才可以成功插入。\n总结 MySQL 的 InnoDB 引擎才支持事务,其中可重复读是默认的隔离级别。\n读未提交和串行化基本上是不需要考虑的隔离级别,前者不加锁限制,后者相当于单线程执行,效率太差。\n读提交解决了脏读问题,行锁解决了并发更新的问题。并且 MySQL 在可重复读级别解决了幻读问题,是通过行锁和间隙锁的组合 Next-Key 锁实现的。\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E4%BA%8B%E5%8A%A1/","summary":"『浅入深出』MySQL 中事务的实现 https://draveness.me/mysql-transaction/ MySQL 中如何实现事务隔离 https://www.cnblogs.com/fengzheng/p/12557762.html 详解一条 SQL 的执行过程 https://juejin.cn/post/6931606328129355790 首先说读未提交,它是性能最好,也可以说它是最野蛮的方式,因为","title":"MySql事务"},{"content":"一,SQL语句性能优化 对查询进行优化,应尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。\n应尽量避免在 where 子句中对字段进行 null 值判断,创建表时NULL是默认值,但大多数时候应该使用NOT NULL,或者使用一个特殊的值,如0,-1作为默 认值。\n应尽量避免在 where 子句中使用!=或\u0026lt;\u0026gt;操作符, MySQL只有对以下操作符才使用索引:\u0026lt;,\u0026lt;=,=,\u0026gt;,\u0026gt;=,BETWEEN,IN,以及某些时候的LIKE\n应尽量避免在 where 子句中使用 or 来连接条件, 否则将导致引擎放弃使用索引而进行全表扫描, 可以 使用UNION合并查询: select id from t where num=10 union all select id from t where num=20\nin 和 not in 也要慎用,否则会导致全表扫描,对于连续的数值,能用 between 就不要用 in 了:Select id from t where num between 1 and 3\n下面的查询也将导致全表扫描:select id from t where name like ‘%abc%’ 或者select id from t where name like ‘%abc’若要提高效率,可以考虑全文检索。而select id from t where name like ‘abc%’ 才用到索引\n如果在 where 子句中使用参数,也会导致全表扫描。\n应尽量避免在 where 子句中对字段进行表达式操作,应尽量避免在where子句中对字段进行函数操作\n很多时候用 exists 代替 in 是一个好的选择: select num from a where num in(select num from b).用下面的语句替换: select num from a where exists(select 1 from b where num=a.num)\n索引固然可以提高相应的 select 的效率,但同时也降低了 insert 及 update 的效率,因为 insert 或 update 时有可能会重建索引,所以怎样建索引需要慎重考虑,视具体情况而定。一个表的索引数最好不要超过6个,若太多则应考虑一些不常使用到的列上建的索引是否有必要\n应尽可能的避免更新 clustered 索引数据列, 因为 clustered 索引数据列的顺序就是表记录的物理存储顺序,一旦该列值改变将导致整个表记录的顺序的调整,会耗费相当大的资源。若应用系统需要频繁更新 clustered 索引数据列,那么需要考虑是否应将该索引建为 clustered 索引。\n尽量使用数字型字段,若只含数值信息的字段尽量不要设计为字符型,这会降低查询和连接的性能,并会增加存储开销。\n尽可能的使用 varchar/nvarchar 代替 char/nchar , 因为首先变长字段存储空间小,可以节省存储空间,其次对于查询来说,在一个相对较小的字段内搜索效率显然要高些。\n最好不要使用”“返回所有: select * from t ,用具体的字段列表代替“*”,不要返回用不到的任何字段。\n尽量避免向客户端返回大数据量,若数据量过大,应该考虑相应需求是否合理。\n使用表的别名(Alias):当在SQL语句中连接多个表时,请使用表的别名并把别名前缀于每个Column上.这样一来,就可以减少解析的时间并减少那些由Column歧义引起的语法错误。\n使用“临时表”暂存中间结果 简化SQL语句的重要方法就是采用临时表暂存中间结果,但是,临时表的好处远远不止这些,将临时结果暂存在临时表,后面的查询就在tempdb中了,这可以避免程序中多次扫描主表,也大大减少了程序执行中“共享锁”阻塞“更新锁”,减少了阻塞,提高了并发性能。\n一些SQL查询语句应加上nolock,读、写是会相互阻塞的,为了提高并发性能,对于一些查询,可以加上nolock,这样读的时候可以允许写,但缺点是可能读到未提交的脏数据。使用 nolock有3条原则。查询的结果用于“插、删、改”的不能加nolock !查询的表属于频繁发生页分裂的,慎用nolock !使用临时表一样可以保存“数据前影”,起到类似Oracle的undo表空间的功能,能采用临时表提高并发性能的,不要用nolock 。\n常见的简化规则如下:不要有超过5个以上的表连接(JOIN),考虑使用临时表或表变量存放中间结果。少用子查询,视图嵌套不要过深,一般视图嵌套不要超过2个为宜。\n将需要查询的结果预先计算好放在表中,查询的时候再Select。这在SQL7.0以前是最重要的手段。例如医院的住院费计算。\n尽量使用exists代替select count(1)来判断是否存在记录,count函数只有在统计表中所有行数时使用,而且count(1)比count(*)更有效率。\n尽量使用“\u0026gt;=”,不要使用“\u0026gt;”\n索引的使用规范:索引的创建要与应用结合考虑,建议大的OLTP表不要超过6个索引;尽可能的使用索引字段作为查询条件,尤其是聚簇索引,必要时可以通过index index_name来强制指定索引;避免对大表查询时进行table scan,必要时考虑新建索引;在使用索引字段作为条件时,如果该索引是联合索引,那么必须使用到该索引中的第一个字段作为条件时才能保证系统使用该索引,否则该索引将不会被使用;要注意索引的维护,周期性重建索引,重新编译存储过程。\n二、索引问题 法则:不要在建立的索引的数据列上进行下列操作:\n​\t◆避免对索引字段进行计算操作\n​\t◆避免在索引字段上使用not,\u0026lt;\u0026gt;,!=\n​\t◆避免在索引列上使用IS NULL和IS NOT NULL\n​\t◆避免在索引列上出现数据类型转换\n​\t◆避免在索引字段上使用函数\n​\t◆避免建立索引的列中使用空值。\n1. 什么是最左前缀原则? 如果我们按照 name 字段来建立索引的话,采用B+树的结构,大概的索引结构如下:\n如果我们要进行模糊查找,查找name 以“张\u0026quot;开头的所有人的ID,即 sql 语句为\n1 select ID from table where name like \u0026#39;张%\u0026#39; 由于在B+树结构的索引中,索引项是按照索引定义里面出现的字段顺序排序的,索引在查找的时候,可以快速定位到 ID 为 100的张一,然后直接向右遍历所有张开头的人,直到条件不满足为止。\n也就是说,我们找到第一个满足条件的人之后,直接向右遍历就可以了,由于索引是有序的,所有满足条件的人都会聚集在一起。\n而这种定位到最左边,然后向右遍历寻找,就是我们所说的最左前缀原则。\n2. 为什么用 B+ 树做索引而不用哈希表做索引? 1、哈希表是把索引字段映射成对应的哈希码然后再存放在对应的位置,这样的话,如果我们要进行模糊查找的话,显然哈希表这种结构是不支持的,只能遍历这个表。而B+树则可以通过最左前缀原则快速找到对应的数据\n2、如果我们要进行范围查找,例如查找ID为100 ~ 400的人,哈希表同样不支持,只能遍历全表。\n3、索引字段通过哈希映射成哈希码,如果很多字段都刚好映射到相同值的哈希码的话,那么形成的索引结构将会是一条很长的链表,这样的话,查找的时间就会大大增加。\n3. 主键索引和非主键索引有什么区别? 例如对于下面这个表(其实就是上面的表中增加了一个k字段),且ID是主键。\n主键索引和非主键索引的示意图如下:\n从图中不难看出,主键索引和非主键索引的区别是:非主键索引的叶子节点存放的是主键的值,而主键索引的叶子节点存放的是整行数据,其中非主键索引也被称为二级索引,而主键索引也被称为聚簇索引。\n图中左边表示主键索引,右边表示非主键索引,图中的R1,R2等都表示整行的数据内容。从图中可以看出,主键索引保存的都是整行的数据内容,而非主键索引则保存的都是所在行的行id。 这也就是说,当查询时,以主键索引查询,会直接返回主键索引对应的整行数据;而以非主键索引查询时,会先返回当前索引对应的行id,然后根据行id去查询对应的整行数据。 所以以主键索引当查询条件会比比非主键索引当查询条件快。最后无论是主键索引还是非主键索引,查询速度都会比用普通字段快。\n根据这两种结构我们来进行下查询,看看他们在查询上有什么区别。\n1、如果查询语句是 select * from table where ID = 100,即主键查询的方式,则只需要搜索 ID 这棵 B+树。\n2、如果查询语句是 select * from table where k = 1,即非主键的查询方式,则先搜索k索引树,得到ID=100,再到ID索引树搜索一次,这个过程也被称为回表。\n4. 为什么建议使用主键自增的索引? 对于这颗主键索引的树\n如果我们插入 ID = 650 的一行数据,那么直接在最右边插入就可以了\n但是如果插入的是 ID = 350 的一行数据,由于 B+ 树是有序的,那么需要将下面的叶子节点进行移动,腾出位置来插入 ID = 350 的数据,这样就会比较消耗时间,如果刚好 R4 所在的数据页已经满了,需要进行页分裂操作,这样会更加糟糕。\n但是,如果我们的主键是自增的,每次插入的 ID 都会比前面的大,那么我们每次只需要在后面插入就行, 不需要移动位置、分裂等操作,这样可以提高性能。也就是为什么建议使用主键自增的索引。\n三、表的设计 0、必须使用默认的InnoDB存储引擎\u0026ndash;支持事务、行级锁、并发性能好、CPU及内存缓存页优化使得资源利用率高 1、表和字段使用中文注释\u0026ndash;便于后人理解 2、使用默认utf8mb4字符集\u0026ndash;标准、万国码、无乱码风险、无需转码 3、禁止使用触发器、视图、存储过程和event 4、禁止使用外键\u0026ndash;外键导致表之间的耦合,update和delete操作都会涉及相关表,影响性能 \u0026ndash;架构方向:对数据库性能影响较大的特性少用;应将计算集中在服务层,解放数据库CPU;数据库擅长索引和存储,勿让数据库背负重负 5、禁止存大文件或者照片\u0026ndash;在数据库里存储URI 字段: 6、必须把字段定义为NOT NULL并设置默认值\u0026ndash;null值需要更多的存储空间; 字段中有null值的话,name != \u0026lsquo;san\u0026rsquo; 查询结果中不包含name is null的记录 7、禁止使用TEXT/BOLB字段类型\u0026ndash;浪费磁盘和内存空间,非必要的大量的大字段查询导致内存命中率降低,影响数据库性能 索引: 8、单表索引控制在5个以内 9、单索引不超过5个字段\u0026ndash;超过5个以及起不到有效过滤数据的效果 10、建立组合索引,必须把区分度高的字段放在前边\u0026ndash;更加有效的过滤数据 11、数据区分度不大的字段不易使用索引\u0026ndash;例如:性别只有男,女,订单状态,每次过滤数据很少\n四、SQL查询规范: 1、禁止使用select *,只获取需要的字段\u0026ndash;查询很多无用字段,增加CPU/IO/NET消耗;不能有效的利用覆盖索引;增删字段易出bug 2、禁止使用属性的隐式转换select * from customer where phone=123123\u0026ndash;会导致全表扫描,不能命中索引 3、禁止在where条件上使用函数和计算 4、禁止负向查询(NOT != \u0026lt;\u0026gt; !\u0026lt; !\u0026gt; MOT IN NOT LIKE)和%开头的like(前导模糊查询)\u0026ndash;会导致全表扫描 5、禁止大表使用JOIN查询和子查询\u0026ndash;会产生临时表,消耗较多CPU和内存,影响数据库性能 6、在属性上进行计算不能命中索引\u0026ndash;如 select * from order where YEAR(date) \u0026lt;= \u0026lsquo;2017\u0026rsquo;不能命中索引导致全表扫描 7、复合索引最左前缀\u0026ndash;例如user 表建立了(userid,phone)的联合索引 有如下几种写法: (1)select * from user where userid = ? and phone = ? (2)select * from user where phone=? and userid= ? (3)select * from user where phone = ? (4)select * from user where userid = ? 其中(1)(2)(4)可以命中索引,(3)会导致全表扫描\n","permalink":"https://reid00.github.io/en/posts/storage/mysql%E8%AF%AD%E5%8F%A5%E4%BC%98%E5%8C%96/","summary":"一,SQL语句性能优化 对查询进行优化,应尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引。 应尽量避免在 where 子句中对字段进行 null 值判断,创","title":"MySql语句优化"},{"content":"常见的性能检测工具 TOP top是最常用的Linux性能监测工具之一。通过top工具可以监视进程和系统整体性能。\n常见命令一览 安装方式 系统自带,无需安装\n使用方法 使用top命令统计整体CPU、内存资源消耗。 CPU项:显示当前总的CPU时间使用分布。 us表示用户态程序占用的CPU时间百分比。 sy表示内核态程序所占用的CPU时间百分比。 wa表示等待IO等待占用的CPU时间百分比。 hi表示硬中断所占用的CPU时间百分比。 si表示软中断所占用的CPU时间百分比。 通过这些参数我们可以分析CPU时间的分布,是否有较多的IO等待。在执行完调优步骤后,我们也可以对CPU使用时间进行前后对比。如果在运行相同程序、业务情况下CPU使用时间降低,说明性能有提升。\nKiB Mem:表示服务器的总内存大小以及使用情况。 KiB Swap:表示当前所使用的Swap空间的大小。Swap空间即当内存不足的时候,把一部分硬盘空间虚拟成内存使用。如果当前所使用的Swap空间大于0,可以考虑优化应用的内存占用或增加物理内存。 在top命令执行后按1,查看每个CPU core的使用情况。 通过该命令可以查看单个CPU core的使用情况,如果CPU占用集中在某几个CPU core上,可以结合业务分析触发原因,从而找到优化思路。 选中top命令的P选项,查看线程运行在哪些 CPU core上。 在top命令执行后按F,可以进入top命令管理界面。在该界面通过上下键移动光标到P选项,通过空格键选中后按Esc退出,即可显示出线程运行的CPU核。观察一段时间,若业务线程在不同NUMA节点内的CPU core上运行,则说明存在较多的跨NUMA访问,可通过NUMA绑核进行优化。(top -\u0026gt; F -\u0026gt; up/down -\u0026gt; 空格 -\u0026gt; ESC) 使用top -p $PID -H命令观察进程中每个线程的CPU资源使用。 “-p”后接的参数为待观察的进程ID。通过该命令可以找出消耗资源多的线程,随后可根据线程号分析线程中的热点函数、调用过程等情况。 Perf Perf工具是非常强大的Linux性能分析工具,可以通过该工具获得进程内的调用情况、资源消耗情况并查找分析热点函数。\n常见命令一览 安装方式 centos 为例\n1 yum -y install perf 使用方式 通过perf top命令查找热点函数。 该命令统计各个函数在某个性能事件上的热度,默认显示CPU占用率,可以通过“-e”监控其它事件。 Overhead表示当前事件在全部事件中占的比例。 Shared Object表示当前事件生产者,如kernel、perf命令、C语言库函数等。 Symbol则表示热点事件对应的函数名称。 通过热点函数,我们可以找到消耗资源较多的行为,从而有针对性的进行优化。 收集一段时间内的线程调用. perf sched record命令用于记录一段时间内,进程的调用情况。“-p”后接进程号,“sleep”后接统计时长,单位为秒。收集到的信息自动存放在当前目录下,文件名为perf.data。 解析收集到的线程调度信息。 perf sched latency命令可以解析当前目录下的perf.data文件。“-s”表示进行排序,后接参数“max”表示按照最大延迟时间大小排序。 numactl numactl工具可用于查看当前服务器的NUMA节点配置、状态,可通过该工具将进程绑定到指定CPU core,由指定CPU core来运行对应进程。\n常见命令一览 安装方式 以centos 为例\n1 yum -y install numactl numastat 使用方法 通过numactl查看当前服务器的NUMA配置。 从numactl执行结果可以看到,示例服务器共划分为4个NUMA节点。每个节点包含16个CPU core,每个节点的内存大小约为64GB。同时,该命令还给出了不同节点间的距离,距离越远,跨NUMA内存访问的延时越大。应用程序运行时应减少跨NUMA访问内存。 通过numactl将进程绑定到指定CPU core。 通过 numactl -C 0-15 top 命令即是将进程“top”绑定到0~15 CPU core上执行。 通过numastat查看当前NUMA节点的内存访问命中率。 numa_hit表示节点内CPU核访问本地内存的次数。 numa_miss表示节点内核访问其他节点内存的次数。跨节点的内存访问会存在高延迟从而降低性能,因此,numa_miss的值应当越低越好,如果过高,则应当考虑绑核。 ","permalink":"https://reid00.github.io/en/posts/langs_linux/linux%E6%80%A7%E8%83%BD%E6%A3%80%E6%B5%8B/","summary":"常见的性能检测工具 TOP top是最常用的Linux性能监测工具之一。通过top工具可以监视进程和系统整体性能。 常见命令一览 安装方式 系统自带,无需","title":"Linux性能检测"},{"content":"简介LSM Tree MySQL、etcd 等存储系统都是面向读多写少场景的,其底层大都采用 B-Tree 及其变种数据结构。而 LSM-Tree 则解决了另一个应用场景——写多读少时面临的问题。在面对亿级的海量数据的存储和检索的场景下,我们通常选择强力的 NoSQL 数据库,如 Hbase、RocksDB 等,它们的文件组织方式,都是仿照 LSM-Tree 实现的。 reference\nLSM-Tree 全称是 Log Structured Merge Tree,是一种分层、有序、面向磁盘的数据结构,其核心思想是充分利用磁盘的顺序写性能要远高于随机写性能这一特性,将批量的随机写转化为一次性的顺序写。\n从上图可以直观地看出,磁盘的顺序访问速度至少比随机 I/O 快三个数量级,甚至顺序访问磁盘比随机访问主内存还要快。这意味着要尽可能避免随机 I/O 操作,顺序访问非常值得我们去探讨与设计。\nLSM-Tree 围绕这一原理进行设计和优化,通过消去随机的更新操作来达到这个目的,以此让写性能达到最优,同时为那些长期具有高更新频率的文件提供低成本的索引机制,减少查询时的开销。\nTwo-Component LSM-Tree LSM-Tree 可以由两个或多个类树的数据结构组件构成,本小节我们先介绍较为简单的两组件情况。 两组件 LSM-Tree(Two-Component LSM-Tree)在内存中有一个 C0 组件,它可以是 AVL 或 SkipList 等结构,所有写入首先写到 C0 中。而磁盘上有一个 C1 组件,当 C0 组件的大小达到阈值时,就需要进行 Rolling Merge,将内存中的内容合并到 C1 中。两组件 LSM-Tree 的写操作流程如下:\n当有写操作时,会先将数据追加写到日志文件中,以备必要时恢复; 然后将数据写入位于内存的 C0 组件,通过某种数据结构保持 Key 有序; 内存中的数据定时或按固定大小刷新到磁盘,更新操作只写到内存,并不更新磁盘上已有文件; 随着写操作越来越多,磁盘上积累的文件也越来越多,这些文件不可写但有序,所以我们定时对文件进行合并(Compaction)操作,消除冗余数据,减少文件数量。 类似于普通的日志写入方式,这种数据结构的写入,全部都是以Append的模式追加,不存在删除和修改。对于任何应用来说,那些会导致索引值发生变化的数据更新都是繁琐且耗时的,但是这样的更新却可以被 LSM-Tree 轻松地解决,将该更新操作看做是一个删除操作加上一个插入操作。\nC1 组件是为顺序性的磁盘访问优化过的,可以是 B-Tree 一类的数据结构(LevelDB 中的实现是 SSTable),所有的节点都是 100% 填充,为了有效利用磁盘,在根节点之下的所有的单页面节点都会被打包放到连续的多页面磁盘块(Multi-Page Block)上。对于 Rolling Merge 和长区间检索的情况将会使用 Multi-Page Block I/O,这样就可以有效减少磁盘旋臂的移动;而在匹配性的查找中会使用 Single-Page I/O,以最小化缓存量。通常根节点只有一个单页面,而其它节点使用 256KB 的 Multi-Page Block。\n在一个两组件 LSM-Tree 中,只要 C0 组件足够大,那么就会有一个批量处理效果。例如,如果一条数据记录的大小是 16Bytes,在一个 4KB 的节点中将会有 250 条记录;如果 C0 组件的大小是 C1 的 1/25,那么在每个合并操作新产生的具有 250 条记录的 C1 节点中,将会有 10 条是从 C0 合并而来的新记录。也就是说用户新写入的数据暂时存储到内存的 C0 中,然后再批量延迟写入磁盘,相当于将用户之前的 10 次写入合并为一次写入。显然地,由于只需要一次随机写就可以写入多条数据,LSM-Tree 的写效率比 B-Tree 等数据结构更高,而 Rolling Merge 过程则是其中的关键。\nRolling Merge 我们可以把两组件 LSM-Tree 的 Rolling Merge 过程类比为一个具有一定步长的游标循环往复地穿越在 C0 和 C1 的键值对上,不断地从C0 中取出数据放入到磁盘上的 C1 中。 该游标在 C1树的叶子节点和索引节点上都有一个逻辑位置,在每个层级上,所有正在参与合并的 Multi-Page Blocks 将会被分成两种类型:Emptying Block的内部记录正在被移出,但是还有一些数据是游标所未到达的,Filling Block则存储着合并后的结果。类似地,该游标也会定义出Emptying Node和Filling Node,这两个节点都被缓存在内存中。为了可以进行并发访问,每个层级上的 Block 包含整数个节点,这样在对执行节点进行重组合并过程中,针对这些节点内部记录的访问将会被阻塞,但是同一 Block 中其它节点依然可以正常访问。 合并后的新 Blocks 会被写入到新的磁盘位置上,这样旧的 Blocks 就不会被覆盖,在发生 crash 后依然可以进行数据恢复。同时需要在索引节点中建立新的索引信息,为了进行恢复还需要产生一条日志记录。那些可能在恢复过程中需要的旧的 Block 暂时还不会被删除,只有当后续的写入提供了足够信息时它们才可以宣告失效。\nC1中的父目录节点也会被缓存在内存中,实时更新以反映出叶子节点的变动,同时父节点还会在内存中停留一段时间以最小化 I/O。当合并步骤完成后,C1 中的旧叶子节点就会变为无效状态,随后会被从 C1 目录结构中删除。为了减少崩溃后的数据恢复时间,合并过程需要进行周期性的 checkpoint,强制将缓存信息写入磁盘。\n为了让 LSM 读取速度相对较快,管理文件数量非常重要,因此我们要对文件进行合并压缩。在 LevelDB 中,合并后的大文件会进入下一个 Level 中。 例如我们的 Level-0 中每个文件有 10 条数据,每 5 个 Level-0 文件合并到 1 个 Level1 文件中,每单个 Level1 文件中有 50 条数据(可能会略少一些)。而每 5 个 Level1 文件合并到 1 个 Level2 文件中,该过程会持续创建越来越大的文件,越旧的数据 Level 级数也会越来越高。\n由于文件已排序,因此合并文件的过程非常快速,但是在等级越高的数据查询速度也越慢。在最坏的情况下,我们需要单独搜索所有文件才能读取结果。\n数据读取 当在 LSM-Tree 上执行一个精确匹配查询或者范围查询时,首先会到 C0 中查找所需的值,如果在 C0 中没有找到,再去 C1 中查找。这意味着与 B-Tree 相比,会有一些额外的 CPU 开销,因为现在需要去两个目录中搜索。虽然每个文件都保持排序,可以通过比较该文件的最大/最小键值对来判断是否需要进行搜索。但是,随着文件数量的增加,每个文件都需要检查,读取还是会变得越来越慢。\n因此,LSM-Tree 的读取速度比其它数据结构更慢。但是我们可以使用一些索引技巧进行优化。LevelDB 会在每个文件末尾保留块索引来加快查询速度,这比直接二进制搜索更好,因为它允许使用变长字段,并且更适合压缩数据。详细的内容会在 SSTable 小节中介绍。\n我们还可以针对删除操作进行一些优化,高效地更新索引。例如通过断言式删除(Predicate Deletion)过程,只要简单地声明一个断言,就可以执行批量删除的操作方式。例如删除那些时间戳在 20 天前的所有的索引值,当位于 C1 组件的记录通过正常过的数据合并过程被加载到内存中时,就可以它们直接丢弃来实现删除。\n除此之外,考虑到各种因素,针对 LSM-Tree 的并发访问方法必须解决如下三种类型的物理冲突:\n查询操作不能同时去访问另一个进程的 Rolling Merge 正在修改的磁盘组件的节点内容; 针对 C0 组件的查询和插入操作也不能与正在进行的 Rolling Merge 的同时对树的相同部分进行访问; 在多组件 LSM-Tree 中,从 Ci-1 到 Ci 的 Rolling Merge 游标有时需要越过从 Ci 到 Ci+1 的 Rolling Merge 游标,因为数据从 Ci-1 移出速率 \u0026gt;= 从 Ci 移出的速率,这意味着 Ci-1 所关联的游标的循环周期要更快。因此无论如何,所采用的并发访问机制必须允许这种交错发生,而不能强制要求在交会点,移入数据到 Ci 的线程必须阻塞在从 Ci 移出数据的线程之后。 Multi-Component LSM-Tree 为了保证 C0 的大小维持在在阈值范围内,这要求 Rolling Merge 将数据合并到 C1 的速度必须不低于用户的写入速度,此时 C0 的不同大小会对整体性能造成不同的结果:\nC0 非常小:此时一条数据的插入都会使 C0 变满,从而触发 Rolling Merge,最坏的情况下,C0 的每一次插入都会导致 C1 的全部叶子节点被读进内存又写回磁盘,I/O 开销非常高; C0 非常大:此时基本没有 I/O 开销,但需要很大的内存空间,也不易进行数据恢复。 为了进一步缩小两组件 LSM-Tree 的开销平衡点,多组件 LSM-Tree 在 C0 和 C1 之间引入一组新的 Component,大小介于两者之间,逐级增长,这样 C0 就不用每次和 C1 进行 Rolling Merge,而是先和中间的组件进行合并,当中间的组件到达其大小限制后再和 C1 做 Rolling Merge,这样就可以在减少 C0 内存开销的同时减少磁盘 I/O 开销。有些类似于我们的多级缓存结构。\n小节 LSM-Tree 的实现思路与常规存储系统采取的措施不太相同,其将随机写转化为顺序写,尽量保持日志型数据库的写性能优势,并提供相对较好的读性能。在大量写入场景下 LSM-Tree 之所以比 B-Tree、Hash 要好,得益于以下两个原因:\nBatch Write:由于采用延迟写,LSM-Tree 可以在 Rolling Merge 过程中,通过一次 I/O 批量向 C1 写入多条数据,那么这多条数据就均摊了这一次 I/O,减少磁盘的 I/O 开销; Multi-Page Block:LSM-Tree 的批量写可以有效地利用 Multi-Page Block,在 Rolling Merge 的过程中,一次从 C1 中读出多个连续的数据页与 C0 合并,然后一次向 C1 写回这些连续页面,这样只需要单次 I/O 就可以完成多个 Pages 的读写。 LSM Tree 组件介绍 如上图所示,LSM树有以下三个重要组成部分:\nMemTable MemTable是在内存中的数据结构,用于保存最近更新的数据,会按照Key有序地组织这些数据,LSM树对于具体如何组织有序地组织数据并没有明确的数据结构定义,例如Hbase使跳跃表来保证内存中key的有序。因为数据暂时保存在内存中,内存并不是可靠存储,如果断电会丢失数据,因此通常会通过WAL(Write-ahead logging,预写式日志)的方式来保证数据的可靠性。\nImmutable MemTable 当 MemTable达到一定大小后,会转化成Immutable MemTable。Immutable MemTable是将转MemTable变为SSTable的一种中间状态。写操作由新的MemTable处理,在转存过程中不阻塞数据更新操作。\nSSTable(Sorted String Table) 有序键值对集合,是LSM树组在磁盘中的数据结构。为了加快SSTable的读取,可以通过建立key的索引以及布隆过滤器来加快key的查找。 这里需要关注一个重点,LSM树(Log-Structured-Merge-Tree)正如它的名字一样,LSM树会将所有的数据插入、修改、删除等操作记录(注意是操作记录)保存在内存之中,当此类操作达到一定的数据量后,再批量地顺序写入到磁盘当中。这与B+树不同,B+树数据的更新会直接在原数据所在处修改对应的值,但是LSM数的数据更新是日志式的,当一条数据更新是直接append一条更新记录完成的。这样设计的目的就是为了顺序写,不断地将Immutable MemTable flush到持久化存储即可,而不用去修改之前的SSTable中的key,保证了顺序写。\n因此当MemTable达到一定大小flush到持久化存储变成SSTable后,在不同的SSTable中,可能存在相同Key的记录,当然最新的那条记录才是准确的。这样设计的虽然大大提高了写性能,但同时也会带来一些问题:\n1 冗余存储,对于某个key,实际上除了最新的那条记录外,其他的记录都是冗余无用的,但是仍然占用了存储空间。因此需要进行Compact操作(合并多个SSTable)来清除冗余的记录。 2 读取时需要从最新的倒着查询,直到找到某个key的记录。最坏情况需要查询完所有的SSTable,这里可以通过前面提到的索引/布隆过滤器来优化查找速度。\nLSM树的Compact策略 从上面可以看出,Compact操作是十分关键的操作,否则SSTable数量会不断膨胀。在Compact策略上,主要介绍两种基本策略:size-tiered和leveled。 不过在介绍这两种策略之前,先介绍三个比较重要的概念,事实上不同的策略就是围绕这三个概念之间做出权衡和取舍。\n读放大:读取数据时实际读取的数据量大于真正的数据量。例如在LSM树中需要先在MemTable查看当前key是否存在,不存在继续从SSTable中寻找。 写放大:写入数据时实际写入的数据量大于真正的数据量。例如在LSM树中写入时可能触发Compact操作,导致实际写入的数据量远大于该key的数据量。 空间放大:数据实际占用的磁盘空间比数据的真正大小更多。上面提到的冗余存储,对于一个key来说,只有最新的那条记录是有效的,而之前的记录都是可以被清理回收的。\n1) size-tiered 策略 size-tiered策略保证每层SSTable的大小相近,同时限制每一层SSTable的数量。如上图,每层限制SSTable为N,当每层SSTable达到N后,则触发Compact操作合并这些SSTable,并将合并后的结果写入到下一层成为一个更大的sstable。 由此可以看出,当层数达到一定数量时,最底层的单个SSTable的大小会变得非常大。并且size-tiered策略会导致空间放大比较严重。即使对于同一层的SSTable,每个key的记录是可能存在多份的,只有当该层的SSTable执行compact操作才会消除这些key的冗余记录。\n2) leveled策略 leveled策略也是采用分层的思想,每一层限制总文件的大小。 但是跟size-tiered策略不同的是,leveled会将每一层切分成多个大小相近的SSTable。这些SSTable是这一层是全局有序的,意味着一个key在每一层至多只有1条记录,不存在冗余记录。之所以可以保证全局有序,是因为合并策略和size-tiered不同,接下来会详细提到。\n假设存在以下这样的场景:\nL1的总大小超过L1本身大小限制: 此时会从L1中选择至少一个文件,然后把它跟L2有交集的部分(非常关键)进行合并。生成的文件会放在L2: 如上图所示,此时L1第二SSTable的key的范围覆盖了L2中前三个SSTable,那么就需要将L1中第二个SSTable与L2中前三个SSTable执行Compact操作。\n如果L2合并后的结果仍旧超出L2的阈值大小,需要重复之前的操作 —— 选至少一个文件然后把它合并到下一层: 需要注意的是,多个不相干的合并是可以并发进行的: leveled策略相较于size-tiered策略来说,每层内key是不会重复的,即使是最坏的情况,除开最底层外,其余层都是重复key,按照相邻层大小比例为10来算,冗余占比也很小。因此空间放大问题得到缓解。但是写放大问题会更加突出。举一个最坏场景,如果LevelN层某个SSTable的key的范围跨度非常大,覆盖了LevelN+1层所有key的范围,那么进行Compact时将涉及LevelN+1层的全部数据。\n","permalink":"https://reid00.github.io/en/posts/storage/lsm-tree/","summary":"简介LSM Tree MySQL、etcd 等存储系统都是面向读多写少场景的,其底层大都采用 B-Tree 及其变种数据结构。而 LSM-Tree 则解决了另一个应用场景——写多读少时","title":"LSM Tree"},{"content":"一、概述 1.1 基本概念: Docker是一个虚拟环境容器,可以将你的开发环境、代码、配置文件等一并打包到这个容器中,并发布和应用到任意平台中。比如,你在本地用Python开发网站后台,开发测试完成后,就可以将Python3及其依赖包、Flask及其各种插件、Mysql、Nginx等打包到一个容器中,然后部署到任意你想部署到的环境。\n1.2 对比虚拟机与Docker Docker守护进程可以直接与主操作系统进行通信,为各个Docker容器分配资源;它还可以将容器与主操作系统隔离,并将各个容器互相隔离。虚拟机启动需要数分钟,而Docker容器可以在数毫秒内启动。由于没有臃肿的从操作系统,Docker可以节省大量的磁盘空间以及其他系统资源。\n说了这么多Docker的优势,大家也没有必要完全否定虚拟机技术,因为两者有不同的使用场景。虚拟机更擅长于彻底隔离整个运行环境。例如,云服务提供商通常采用虚拟机技术隔离不同的用户。而Docker通常用于隔离不同的应用,例如前端,后端以及数据库。\n1.3 与传统VM特性对比: 特性 容器 虚拟机 启动速度 秒级 分钟级 硬盘使用 一般为MB 一般为GB 性能 接近原生 弱于原生 系统支持量 单机支持上千个容器 一般几十个 隔离性 安全隔离 完全隔离 1.4 Docker组件 docker Client客户端————\u0026gt;向docker服务器进程发起请求,如:创建、停止、销毁容器等操作\ndocker Server服务器进程—–\u0026gt;处理所有docker的请求,管理所有容器\ndocker Registry镜像仓库——\u0026gt;镜像存放的中央仓库,可看作是存放二进制的scm\n1.5 Docker的三个概念 镜像(Image):类似于虚拟机中的镜像,是一个包含有文件系统的面向Docker引擎的只读模板。任何应用程序运行都需要环境,而镜像就是用来提供这种运行环境的。例如一个Ubuntu镜像就是一个包含Ubuntu操作系统环境的模板,同理在该镜像上装上Apache软件,就可以称为Apache镜像。 容器(Container):类似于一个轻量级的沙盒,可以将其看作一个极简的Linux系统环境(包括root权限、进程空间、用户空间和网络空间等),以及运行在其中的应用程序。Docker引擎利用容器来运行、隔离各个应用。容器是镜像创建的应用实例,可以创建、启动、停止、删除容器,各个容器之间是是相互隔离的,互不影响。注意:镜像本身是只读的,容器从镜像启动时,Docker在镜像的上层创建一个可写层,镜像本身不变。 仓库(Repository):类似于代码仓库,这里是镜像仓库,是Docker用来集中存放镜像文件的地方。注意与注册服务器(Registry)的区别:注册服务器是存放仓库的地方,一般会有多个仓库;而仓库是存放镜像的地方,一般每个仓库存放一类镜像,每个镜像利用tag进行区分,比如Ubuntu仓库存放有多个版本(12.04、14.04等)的Ubuntu镜像。 二、安装Docker 2.1 Ubuntu 旧版本的 Docker 称为 docker 或者 docker-engine,使用以下命令卸载旧版本:\n1 $ sudo apt-get remove docker docker-engine docker.io 使用 APT 安装 1 2 3 $ sudo apt-get update $ sudo apt-get install apt-transport-https ca-certificates curl software-properties-common Docker CE 镜像源站 使用官方安装脚本自动安装 (仅适用于公网环境) 1 curl -fsSL https://get.docker.com | bash -s docker --mirror Aliyun 手动安装帮助 (阿里云ECS可以通过内网安装,见注释部分内容) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 # step 1: 安装必要的一些系统工具 sudo apt-get update sudo apt-get -y install apt-transport-https ca-certificates curl software-properties-common # step 2: 安装GPG证书 curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add - # Step 3: 写入软件源信息 sudo add-apt-repository \u0026#34;deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable\u0026#34; # Step 4: 更新并安装 Docker-CE sudo apt-get -y update sudo apt-get -y install docker-ce 注意:其他注意事项在下面的注释中 # 安装指定版本的Docker-CE: # Step 1: 查找Docker-CE的版本: # apt-cache madison docker-ce # docker-ce | 17.03.1~ce-0~ubuntu-xenial | http://mirrors.aliyun.com/docker-ce/linux/ubuntu xenial/stable amd64 Packages # docker-ce | 17.03.0~ce-0~ubuntu-xenial | http://mirrors.aliyun.com/docker-ce/linux/ubuntu xenial/stable amd64 Packages # Step 2: 安装指定版本的Docker-CE: (VERSION 例如上面的 17.03.1~ce-0~ubuntu-xenial) # sudo apt-get -y install docker-ce=[VERSION] # 通过经典网络、VPC网络内网安装时,用以下命令替换Step 2、Step 3中的命令 # 经典网络: # curl -fsSL http://mirrors.aliyuncs.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add - # sudo add-apt-repository \u0026#34;deb [arch=amd64] http://mirrors.aliyuncs.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable\u0026#34; # VPC网络: # curl -fsSL http://mirrors.cloud.aliyuncs.com/docker-ce/linux/ubuntu/gpg | sudo apt-key add - # sudo add-apt-repository \u0026#34;deb [arch=amd64] http://mirrors.cloud.aliyuncs.com/docker-ce/linux/ubuntu $(lsb_release -cs) stable\u0026#34;\t2.2 CentOS 7 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 # step 1: 安装必要的一些系统工具 sudo yum install -y yum-utils device-mapper-persistent-data lvm2 # Step 2: 添加软件源信息 sudo yum-config-manager --add-repo http://mirrors.aliyun.com/docker-ce/linux/centos/docker-ce.repo # Step 3: 更新并安装 Docker-CE sudo yum makecache fast sudo yum -y install docker-ce # Step 4: 开启Docker服务 sudo service docker start 注意:其他注意事项在下面的注释中 # 官方软件源默认启用了最新的软件,您可以通过编辑软件源的方式获取各个版本的软件包。例如官方并没有将测试版本的软件源置为可用,你可以通过以下方式开启。同理可以开启各种测试版本等。 # vim /etc/yum.repos.d/docker-ce.repo # 将 [docker-ce-test] 下方的 enabled=0 修改为 enabled=1 # # 安装指定版本的Docker-CE: # Step 1: 查找Docker-CE的版本: # yum list docker-ce.x86_64 --showduplicates | sort -r # Loading mirror speeds from cached hostfile # Loaded plugins: branch, fastestmirror, langpacks # docker-ce.x86_64 17.03.1.ce-1.el7.centos docker-ce-stable # docker-ce.x86_64 17.03.1.ce-1.el7.centos @docker-ce-stable # docker-ce.x86_64 17.03.0.ce-1.el7.centos docker-ce-stable # Available Packages # Step2 : 安装指定版本的Docker-CE: (VERSION 例如上面的 17.03.0.ce.1-1.el7.centos) # sudo yum -y install docker-ce-[VERSION] # 注意:在某些版本之后,docker-ce安装出现了其他依赖包,如果安装失败的话请关注错误信息。例如 docker-ce 17.03 之后,需要先安装 docker-ce-selinux。 # yum list docker-ce-selinux- --showduplicates | sort -r # sudo yum -y install docker-ce-selinux-[VERSION] # 通过经典网络、VPC网络内网安装时,用以下命令替换Step 2中的命令 # 经典网络: # sudo yum-config-manager --add-repo http://mirrors.aliyuncs.com/docker-ce/linux/centos/docker-ce.repo # VPC网络: # sudo yum-config-manager --add-repo http://mirrors.could.aliyuncs.com/docker-ce/linux/centos/docker-ce.repo 2.3 安装校验 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 root@iZbp12adskpuoxodbkqzjfZ:$ docker version Client: Version: 17.03.0-ce API version: 1.26 Go version: go1.7.5 Git commit: 3a232c8 Built: Tue Feb 28 07:52:04 2017 OS/Arch: linux/amd64 Server: Version: 17.03.0-ce API version: 1.26 (minimum version 1.12) Go version: go1.7.5 Git commit: 3a232c8 Built: Tue Feb 28 07:52:04 2017 OS/Arch: linux/amd64 Experimental: false 三、镜像加速器 网易云加速器 https://hub-mirror.c.163.com\n百度云加速器 https://mirror.baidubce.com\n阿里云加速器(需登录账号获取)\n3.1 Ubuntu 16.04+、Debian 8+、CentOS 7 对于使用 systemd 的系统,请在 /etc/docker/daemon.json 中写入如下内容(如果文件不存在请新建该文件)\n1 2 3 4 5 6 { \u0026#34;registry-mirrors\u0026#34;: [ \u0026#34;https://hub-mirror.c.163.com\u0026#34;, \u0026#34;https://mirror.baidubce.com\u0026#34; ] } 之后重新启动服务。\n1 2 $ sudo systemctl daemon-reload $ sudo systemctl restart docker 四、命令整理 4.1 容器操作 1 2 3 4 5 6 7 8 9 docker create # 创建一个容器但是不启动它 docker run # 创建并启动一个容器 docker stop # 停止容器运行,发送信号SIGTERM docker start # 启动一个停止状态的容器 docker restart # 重启一个容器 docker rm # 删除一个容器 docker kill # 发送信号给容器,默认SIGKILL docker attach # 连接(进入)到一个正在运行的容器 docker wait # 阻塞一个容器,直到容器停止运行 4.2 获取容器信息 1 2 3 4 5 6 7 8 docker ps # 显示状态为运行(Up)的容器 docker ps -a # 显示所有容器,包括运行中(Up)的和退出的(Exited) docker inspect # 深入容器内部获取容器所有信息 docker logs # 查看容器的日志(stdout/stderr) docker events # 得到docker服务器的实时的事件 docker port # 显示容器的端口映射 docker top # 显示容器的进程信息 docker diff # 显示容器文件系统的前后变化 4.3 导出容器 1 2 docker cp # 从容器里向外拷贝文件或目录 docker export # 将容器整个文件系统导出为一个tar包,不带layers、tag等信息 4.4 执行 1 docker exec # 在容器里执行一个命令,可以执行bash进入交互式 4.5 镜像操作 1 2 3 4 5 6 7 8 9 docker images # 显示本地所有的镜像列表 docker import # 从一个tar包创建一个镜像,往往和export结合使用 docker build # 使用Dockerfile创建镜像(推荐) docker commit # 从容器创建镜像 docker rmi or docker image rm [name:tag] # 删除一个镜像 docker load # 从一个tar包创建一个镜像,和save配合使用 docker save # 将一个镜像保存为一个tar包,带layers和tag信息 docker history # 显示生成一个镜像的历史命令 docker tag # 为镜像起一个别名 4.6 镜像仓库(registry)操作 1 2 3 4 docker login # 登录到一个registry docker search # 从registry仓库搜索镜像 docker pull # 从仓库下载镜像到本地 docker push # 将一个镜像push到registry仓库中 4.7 数据券操作 1 2 3 4 5 # docker -it -v /宿主机绝对路径:/容器内的目录 镜像名称 docker run -it -v /myDataVolume:/dataVolumeContainter centos # docker -it -v /宿主机绝对路径:/容器内的目录:ro 镜像名称 容器内的文件只读权限, 不可以写文件 docker run -it -v /myDataVolume:/dataVolumeContainter:ro centos 五、实例操作 source:https://yeasy.gitbook.io/docker_practice/install/mirror\n现在让我们以定制一个 Web 服务器为例子,来讲解镜像是如何构建的。\n1 docker run --name webserver -d -p 80:80 nginx 这条命令会用 nginx 镜像启动一个容器,命名为 webserver,并且映射了 80 端口,这样我们可以用浏览器去访问这个 nginx 服务器。\n如果是在 Linux 本机运行的 Docker,或者如果使用的是 Docker Desktop for Mac/Windows,那么可以直接访问:http://localhost;如果使用的是 Docker Toolbox,或者是在虚拟机、云服务器上安装的 Docker,则需要将 localhost 换为虚拟机地址或者实际云服务器地址。\n直接用浏览器访问的话,我们会看到默认的 Nginx 欢迎页面。\n现在,假设我们非常不喜欢这个欢迎页面,我们希望改成欢迎 Docker 的文字,我们可以使用 docker exec 命令进入容器,修改其内容。\n1 2 3 4 $ docker exec -it webserver bash root@3729b97e8226:/# echo \u0026#39;\u0026lt;h1\u0026gt;Hello, Docker!\u0026lt;/h1\u0026gt;\u0026#39; \u0026gt; /usr/share/nginx/html/index.html root@3729b97e8226:/# exit exit 我们以交互式终端方式进入 webserver 容器,并执行了 bash 命令,也就是获得一个可操作的 Shell。\n然后,我们用 Hello, Docker! 覆盖了 /usr/share/nginx/html/index.html 的内容。\n现在我们再刷新浏览器的话,会发现内容被改变了。\n我们修改了容器的文件,也就是改动了容器的存储层。我们可以通过 docker diff 命令看到具体的改动。\n1 docker diff webserver 现在我们定制好了变化,我们希望能将其保存下来形成镜像。\n要知道,当我们运行一个容器的时候(如果不使用卷的话),我们做的任何文件修改都会被记录于容器存储层里。而 Docker 提供了一个 docker commit 命令,可以将容器的存储层保存下来成为镜像。换句话说,就是在原有镜像的基础上,再叠加上容器的存储层,并构成新的镜像。以后我们运行这个新镜像的时候,就会拥有原有容器最后的文件变化。\ndocker commit 的语法格式为:\n1 docker commit [选项] \u0026lt;容器ID或容器名\u0026gt; [\u0026lt;仓库名\u0026gt;[:\u0026lt;标签\u0026gt;]] 我们可以用下面的命令将容器保存为镜像:\n1 2 3 4 5 6 $ docker commit \\ --author \u0026#34;Tao Wang \u0026lt;twang2218@gmail.com\u0026gt;\u0026#34; \\ --message \u0026#34;修改了默认网页\u0026#34; \\ webserver \\ nginx:v2 sha256:07e33465974800ce65751acc279adc6ed2dc5ed4e0838f8b86f0c87aa1795214 其中 --author 是指定修改的作者,而 --message 则是记录本次修改的内容。这点和 git 版本控制相似,不过这里这些信息可以省略留空。\n我们可以在 docker image ls 中看到这个新定制的镜像:\n1 docker images or docker image ls 我们还可以用 docker history 具体查看镜像内的历史记录,如果比较 nginx:latest 的历史记录,我们会发现新增了我们刚刚提交的这一层.\n1 2 3 4 5 6 7 8 9 10 11 $ docker history nginx:v2 IMAGE CREATED CREATED BY SIZE COMMENT 07e334659748 54 seconds ago nginx -g daemon off; 95 B 修改了默认网页 e43d811ce2f4 4 weeks ago /bin/sh -c #(nop) CMD [\u0026#34;nginx\u0026#34; \u0026#34;-g\u0026#34; \u0026#34;daemon 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) EXPOSE 443/tcp 80/tcp 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c ln -sf /dev/stdout /var/log/nginx/ 22 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c apt-key adv --keyserver hkp://pgp. 58.46 MB \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) ENV NGINX_VERSION=1.11.5-1 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) MAINTAINER NGINX Docker Ma 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) CMD [\u0026#34;/bin/bash\u0026#34;] 0 B \u0026lt;missing\u0026gt; 4 weeks ago /bin/sh -c #(nop) ADD file:23aa4f893e3288698c 123 MB 新的镜像定制好后,我们可以来运行这个镜像。\n1 docker run --name web2 -d -p 81:80 nginx:v2 这里我们命名为新的服务为 web2,并且映射到 81 端口。如果是 Docker Desktop for Mac/Windows 或 Linux 桌面的话,我们就可以直接访问 http://localhost:81 看到结果,其内容应该和之前修改后的 webserver 一样。\n至此,我们第一次完成了定制镜像,使用的是 docker commit 命令,手动操作给旧的镜像添加了新的一层,形成新的镜像,对镜像多层存储应该有了更直观的感觉。\n慎用 docker commit 使用 docker commit 命令虽然可以比较直观的帮助理解镜像分层存储的概念,但是实际环境中并不会这样使用。\n首先,如果仔细观察之前的 docker diff webserver 的结果,你会发现除了真正想要修改的 /usr/share/nginx/html/index.html 文件外,由于命令的执行,还有很多文件被改动或添加了。这还仅仅是最简单的操作,如果是安装软件包、编译构建,那会有大量的无关内容被添加进来,如果不小心清理,将会导致镜像极为臃肿。\n此外,使用 docker commit 意味着所有对镜像的操作都是黑箱操作,生成的镜像也被称为 黑箱镜像,换句话说,就是除了制作镜像的人知道执行过什么命令、怎么生成的镜像,别人根本无从得知。而且,即使是这个制作镜像的人,过一段时间后也无法记清具体的操作。这种黑箱镜像的维护工作是非常痛苦的。\n而且,回顾之前提及的镜像所使用的分层存储的概念,除当前层外,之前的每一层都是不会发生改变的,换句话说,任何修改的结果仅仅是在当前层进行标记、添加、修改,而不会改动上一层。如果使用 docker commit 制作镜像,以及后期修改的话,每一次修改都会让镜像更加臃肿一次,所删除的上一层的东西并不会丢失,会一直如影随形的跟着这个镜像,即使根本无法访问到。这会让镜像更加臃肿。\n","permalink":"https://reid00.github.io/en/posts/langs_linux/docker%E7%AC%94%E8%AE%B0/","summary":"一、概述 1.1 基本概念: Docker是一个虚拟环境容器,可以将你的开发环境、代码、配置文件等一并打包到这个容器中,并发布和应用到任意平台中。比如","title":"Docker笔记"},{"content":"ElasticSearch面试题 1.为什么要使用Elasticsearch? 因为在我们商城中的数据,将来会非常多,所以采用以往的模糊查询,模糊查询前置配置,会放弃索引,导致商品查询是全表扫面,在百万级别的数据库中,效率非常低下,而我们使用ES做一个全文索引,我们将经常查询的商品的某些字段,比如说商品名,描述、价格还有id这些字段我们放入我们索引库里,可以提高查询速度。\n2.Elasticsearch是如何实现Master选举的? Elasticsearch的选主是ZenDiscovery模块负责的,主要包含Ping(节点之间通过这个RPC来发现彼此)和Unicast(单播模块包含一个主机列表以控制哪些节点需要ping通)这两部分;\n对所有可以成为master的节点(node.master: true)根据nodeId字典排序,每次选举每个节点都把自己所知道节点排一次序,然后选出第一个(第0位)节点,暂且认为它是master节点。 如果对某个节点的投票数达到一定的值(可以成为master节点数n/2+1)并且该节点自己也选举自己,那这个节点就是master。否则重新选举一直到满足上述条件。 补充:master节点的职责主要包括集群、节点和索引的管理,不负责文档级别的管理;data节点可以关闭http功能。 3.Elasticsearch中的节点(比如共20个),其中的10个选了一个master,另外10个选了另一个master,怎么办? 当集群master候选数量不小于3个时,可以通过设置最少投票通过数量(discovery.zen.minimum_master_nodes)超过所有候选节点一半以上来解决脑裂问题; 当候选数量为两个时,只能修改为唯一的一个master候选,其他作为data节点,避免脑裂问题。\n4.详细描述一下Elasticsearch索引文档的过程。 协调节点默认使用文档ID参与计算(也支持通过routing),以便为路由提供合适的分片。 shard = hash(document_id) % (num_of_primary_shards) 当分片所在的节点接收到来自协调节点的请求后,会将请求写入到Memory Buffer,然后定时(默认是每隔1秒)写入到Filesystem Cache,这个从Momery Buffer到Filesystem Cache的过程就叫做refresh; 当然在某些情况下,存在Momery Buffer和Filesystem Cache的数据可能会丢失,ES是通过translog的机制来保证数据的可靠性的。其实现机制是接收到请求后,同时也会写入到translog中,当Filesystem cache中的数据写入到磁盘中时,才会清除掉,这个过程叫做flush; 在flush过程中,内存中的缓冲将被清除,内容被写入一个新段,段的fsync将创建一个新的提交点,并将内容刷新到磁盘,旧的translog将被删除并开始一个新的translog。 flush触发的时机是定时触发(默认30分钟)或者translog变得太大(默认为512M)时;\n5.详细描述一下Elasticsearch更新和删除文档的过程 删除和更新也都是写操作,但是Elasticsearch中的文档是不可变的,因此不能被删除或者改动以展示其变更; 磁盘上的每个段都有一个相应的.del文件。当删除请求发送后,文档并没有真的被删除,而是在.del文件中被标记为删除。该文档依然能匹配查询,但是会在结果中被过滤掉。当段合并时,在.del文件中被标记为删除的文档将不会被写入新段。 在新的文档被创建时,Elasticsearch会为该文档指定一个版本号,当执行更新时,旧版本的文档在.del文件中被标记为删除,新版本的文档被索引到一个新段。旧版本的文档依然能匹配查询,但是会在结果中被过滤掉。\n6.详细描述一下Elasticsearch搜索的过程 搜索被执行成一个两阶段过程,我们称之为 Query Then Fetch; 在初始查询阶段时,查询会广播到索引中每一个分片拷贝(主分片或者副本分片)。 每个分片在本地执行搜索并构建一个匹配文档的大小为 from + size 的优先队列。PS:在搜索的时候是会查询Filesystem Cache的,但是有部分数据还在Memory Buffer,所以搜索是近实时的。 每个分片返回各自优先队列中 所有文档的 ID 和排序值 给协调节点,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。 接下来就是 取回阶段,协调节点辨别出哪些文档需要被取回并向相关的分片提交多个 GET 请求。每个分片加载并 丰富 文档,如果有需要的话,接着返回文档给协调节点。一旦所有的文档都被取回了,协调节点返回结果给客户端。 补充:Query Then Fetch的搜索类型在文档相关性打分的时候参考的是本分片的数据,这样在文档数量较少的时候可能不够准确,DFS Query Then Fetch增加了一个预查询的处理,询问Term和Document frequency,这个评分更准确,但是性能会变差。\n9.Elasticsearch对于大数据量(上亿量级)的聚合如何实现? Elasticsearch 提供的首个近似聚合是cardinality 度量。它提供一个字段的基数,即该字段的distinct或者unique值的数目。它是基于HLL算法的。HLL 会先对我们的输入作哈希运算,然后根据哈希运算的结果中的 bits 做概率估算从而得到基数。其特点是:可配置的精度,用来控制内存的使用(更精确 = 更多内存);小的数据集精度是非常高的;我们可以通过配置参数,来设置去重需要的固定内存使用量。无论数千还是数十亿的唯一值,内存使用量只与你配置的精确度相关 .\n10.在并发情况下,Elasticsearch如果保证读写一致? 可以通过版本号使用乐观并发控制,以确保新版本不会被旧版本覆盖,由应用层来处理具体的冲突; 另外对于写操作,一致性级别支持quorum/one/all,默认为quorum,即只有当大多数分片可用时才允许写操作。但即使大多数可用,也可能存在因为网络等原因导致写入副本失败,这样该副本被认为故障,分片将会在一个不同的节点上重建。 对于读操作,可以设置replication为sync(默认),这使得操作在主分片和副本分片都完成后才会返回;如果设置replication为async时,也可以通过设置搜索请求参数_preference为primary来查询主分片,确保文档是最新版本。\n14.ElasticSearch中的集群、节点、索引、文档、类型是什么? 群集是一个或多个节点(服务器)的集合,它们共同保存您的整个数据,并提供跨所有节点的联合索引和搜索功能。群集由唯一名称标识,默认情况下为“elasticsearch”。此名称很重要,因为如果节点设置为按名称加入群集,则该节点只能是群集的一部分。 节点是属于集群一部分的单个服务器。它存储数据并参与群集索引和搜索功能。 索引就像关系数据库中的“数据库”。它有一个定义多种类型的映射。索引是逻辑名称空间,映射到一个或多个主分片,并且可以有零个或多个副本分片。 MySQL =\u0026gt;数据库 ElasticSearch =\u0026gt;索引 文档类似于关系数据库中的一行。不同之处在于索引中的每个文档可以具有不同的结构(字段),但是对于通用字段应该具有相同的数据类型。 MySQL =\u0026gt; Databases =\u0026gt; Tables =\u0026gt; Columns / Rows ElasticSearch =\u0026gt; Indices =\u0026gt; Types =\u0026gt;具有属性的文档 类型是索引的逻辑类别/分区,其语义完全取决于用户。\n15.ElasticSearch中的分片是什么? 在大多数环境中,每个节点都在单独的盒子或虚拟机上运行。\n索引 - 在Elasticsearch中,索引是文档的集合。 分片 -因为Elasticsearch是一个分布式搜索引擎,所以索引通常被分割成分布在多个节点上的被称为分片的元素。\n问题四:\nElasticSearch中的集群、节点、索引、文档、类型是什么?\n群集是一个或多个节点(服务器)的集合,它们共同保存您的整个数据,并提供跨所有节点的联合索引和搜索功能。群集由唯一名称标识,默认情况下为“elasticsearch”。此名称很重要,因为如果节点设置为按名称加入群集,则该节点只能是群集的一部分。 节点是属于集群一部分的单个服务器。它存储数据并参与群集索引和搜索功能。 索引就像关系数据库中的“数据库”。它有一个定义多种类型的映射。索引是逻辑名称空间,映射到一个或多个主分片,并且可以有零个或多个副本分片。 MySQL =\u0026gt;数据库 ElasticSearch =\u0026gt;索引 文档类似于关系数据库中的一行。不同之处在于索引中的每个文档可以具有不同的结构(字段),但是对于通用字段应该具有相同的数据类型。 MySQL =\u0026gt; Databases =\u0026gt; Tables =\u0026gt; Columns / Rows ElasticSearch =\u0026gt; Indices =\u0026gt; Types =\u0026gt;具有属性的文档 类型是索引的逻辑类别/分区,其语义完全取决于用户。 问题五:\nElasticSearch是否有架构?\nElasticSearch可以有一个架构。架构是描述文档类型以及如何处理文档的不同字段的一个或多个字段的描述。Elasticsearch中的架构是一种映射,它描述了JSON文档中的字段及其数据类型,以及它们应该如何在Lucene索引中进行索引。因此,在Elasticsearch术语中,我们通常将此模式称为“映射”。\nElasticsearch具有架构灵活的能力,这意味着可以在不明确提供架构的情况下索引文档。如果未指定映射,则默认情况下,Elasticsearch会在索引期间检测文档中的新字段时动态生成一个映射。\n问题六:\nElasticSearch中的分片是什么?\n在大多数环境中,每个节点都在单独的盒子或虚拟机上运行。\n索引 - 在Elasticsearch中,索引是文档的集合。 分片 -因为Elasticsearch是一个分布式搜索引擎,所以索引通常被分割成分布在多个节点上的被称为分片的元素。 问题七:\nElasticSearch中的副本是什么?\n一个索引被分解成碎片以便于分发和扩展。副本是分片的副本。一个节点是一个属于一个集群的ElasticSearch的运行实例。一个集群由一个或多个共享相同集群名称的节点组成。\n问题八:\nElasticSearch中的分析器是什么?\n在ElasticSearch中索引数据时,数据由为索引定义的Analyzer在内部进行转换。 分析器由一个Tokenizer和零个或多个TokenFilter组成。编译器可以在一个或多个CharFilter之前。分析模块允许您在逻辑名称下注册分析器,然后可以在映射定义或某些API中引用它们。\nElasticsearch附带了许多可以随时使用的预建分析器。或者,您可以组合内置的字符过滤器,编译器和过滤器器来创建自定义分析器。\n问题九:\n什么是ElasticSearch中的编译器?\n编译器用于将字符串分解为术语或标记流。一个简单的编译器可能会将字符串拆分为任何遇到空格或标点的地方。Elasticsearch有许多内置标记器,可用于构建自定义分析器。\n问题十一:\n启用属性,索引和存储的用途是什么?\nenabled属性适用于各类ElasticSearch特定/创建领域,如index和size。用户提供的字段没有“已启用”属性。 存储意味着数据由Lucene存储,如果询问,将返回这些数据。\n存储字段不一定是可搜索的。默认情况下,字段不存储,但源文件是完整的。因为您希望使用默认值(这是有意义的),所以不要设置store属性 该指数属性用于搜索。\n索引属性只能用于搜索。只有索引域可以进行搜索。差异的原因是在分析期间对索引字段进行了转换,因此如果需要的话,您不能检索原始数据。\n(网络搜集-博客园)\n第二部分面试题 es 写入数据的工作原理是什么啊?es 查询数据的工作原理是什么啊?底层的 lucene 介绍一下呗?倒排索引了解吗?\n面试官心理分析 问这个,其实面试官就是要看看你了解不了解 es 的一些基本原理,因为用 es 无非就是写入数据,搜索数据。你要是不明白你发起一个写入和搜索请求的时候,es 在干什么,那你真的是\u0026hellip;\u0026hellip;对 es 基本就是个黑盒,你还能干啥?你唯一能干的就是用 es 的 api 读写数据了。要是出点什么问题,你啥都不知道,那还能指望你什么呢?\nes 写数据过程 客户端选择一个 node 发送请求过去,这个 node 就是 coordinating node(协调节点)。 coordinating node 对 document 进行路由,将请求转发给对应的 node(有 primary shard)。[路由的算法是?] 实际的 node 上的 primary shard 处理请求,然后将数据同步到 replica node。 coordinating node 如果发现 primary node 和所有 replica node 都搞定之后,就返回响应结果给客户端。 es 读数据过程 可以通过 doc id 来查询,会根据 doc id 进行 hash,判断出来当时把 doc id 分配到了哪个 shard 上面去,从那个 shard 去查询。\n客户端发送请求到任意一个 node,成为 coordinate node。 coordinate node 对 doc id 进行哈希路由,将请求转发到对应的 node,此时会使用 round-robin随机轮询算法,在 primary shard 以及其所有 replica 中随机选择一个,让读请求负载均衡。 接收请求的 node 返回 document 给 coordinate node。 coordinate node 返回 document 给客户端。 写请求是写入 primary shard,然后同步给所有的 replica shard;读请求可以从 primary shard 或 replica shard 读取,采用的是随机轮询算法。\nes 搜索数据过程[是指search?search和普通docid get的背后逻辑不一样?] es 最强大的是做全文检索,就是比如你有三条数据:\njava真好玩儿啊 java好难学啊 j2ee特别牛 你根据 java 关键词来搜索,将包含 java的 document 给搜索出来。es 就会给你返回:java真好玩儿啊,java好难学啊。\n客户端发送请求到一个 coordinate node。 协调节点将搜索请求转发到所有的 shard 对应的 primary shard 或 replica shard,都可以。 query phase:每个 shard 将自己的搜索结果(其实就是一些 doc id)返回给协调节点,由协调节点进行数据的合并、排序、分页等操作,产出最终结果。 fetch phase:接着由协调节点根据 doc id 去各个节点上拉取实际的 document 数据,最终返回给客户端。 写数据底层原理 1)document先写入导内存buffer中,同时写translog日志\n2))https://www.elastic.co/guide/cn/elasticsearch/guide/current/near-real-time.html\nrefresh操作所以近实时搜索:写入和打开一个新段(一个追加的倒排索引)的轻量的过程叫做 *refresh* 。每隔一秒钟把buffer中的数据创建一个新的segment,这里新段会被先写入到文件系统缓存\u0026ndash;这一步代价会比较低,稍后再被刷新到磁盘\u0026ndash;这一步代价比较高。不过只要文件已经在缓存中, 就可以像其它文件一样被打开和读取了,内存buffer被清空。此时,新segment 中的文件就可以被搜索了,这就意味着document从被写入到可以被搜索需要一秒种,如果要更改这个属性,可以执行以下操作\nPUT /my_index { \u0026ldquo;settings\u0026rdquo;: { \u0026ldquo;refresh_interval\u0026rdquo;: \u0026ldquo;30s\u0026rdquo; } } 3)https://www.elastic.co/guide/cn/elasticsearch/guide/current/translog.html\nflush操作导致持久化变更:执行一个提交并且截断 translog 的行为在 Elasticsearch 被称作一次 *flush。*刷新(refresh)完成后, 缓存被清空但是事务日志不会。translog日志也会越来越多,当translog日志大小大于一个阀值时候或30分钟,会出发flush操作。\n所有在内存缓冲区的文档都被写入一个新的段。 缓冲区被清空。 一个提交点被写入硬盘。(表明有哪些segment commit了) 文件系统缓存通过 fsync 到磁盘。 老的 translog 被删除。 分片每30分钟被自动刷新(flush),或者在 translog 太大的时候也会刷新。也可以用_flush命令手动执行。\ntranslog每隔5秒会被写入磁盘(所以如果这5s,数据在cache而且log没持久化会丢失)。在一次增删改操作之后translog只有在replica和primary shard都成功才会成功,如果要提高操作速度,可以设置成异步的\nPUT /my_index { \u0026ldquo;settings\u0026rdquo;: { \u0026ldquo;index.translog.durability\u0026rdquo;: \u0026ldquo;async\u0026rdquo; ,\n\u0026ldquo;index.translog.sync_interval\u0026rdquo;:\u0026ldquo;5s\u0026rdquo; } }\n所以总结是有三个批次操作,一秒做一次refresh保证近实时搜索,5秒做一次translog持久化保证数据未持久化前留底,30分钟做一次数据持久化。\n2.基于translog和commit point的数据恢复\n在磁盘上会有一个上次持久化的commit point,translog上有一个commit point,根据这两个commit point,会把translog中的变更记录进行回放,重新执行之前的操作\n3.不变形下的删除和更新原理\nhttps://www.elastic.co/guide/cn/elasticsearch/guide/current/dynamic-indices.html#deletes-and-updates\n一个文档被 “删除” 时,它实际上只是在 .del 文件中被 标记 删除。一个被标记删除的文档仍然可以被查询匹配到, 但它会在最终结果被返回前从结果集中移除。\n文档更新也是类似的操作方式:当一个文档被更新时,旧版本文档被标记删除,文档的新版本被索引到一个新的段中。 可能两个版本的文档都会被一个查询匹配到,但被删除的那个旧版本文档在结果集返回前就已经被移除。\n段合并的时候会将那些旧的已删除文档 从文件系统中清除。 被删除的文档(或被更新文档的旧版本)不会被拷贝到新的大段中。\n4.merge操作,段合并\nhttps://www.elastic.co/guide/cn/elasticsearch/guide/current/merge-process.html\n由于每秒会把buffer刷到segment中,所以segment会很多,为了防止这种情况出现,es内部会不断把一些相似大小的segment合并,并且物理删除del的segment。\n当然也可以手动执行\nPOST /my_index/_optimize?max_num_segments=1,尽量不要手动执行,让它自动默认执行就可以了\n5.当你正在建立一个大的新索引时(相当于直接全部写入buffer,先不refresh,写完再refresh),可以先关闭自动刷新,待开始使用该索引时,再把它们调回来:\n1 2 3 4 5 PUT /my_logs/_settings { \u0026#34;refresh_interval\u0026#34;: -1 } PUT /my_logs/_settings { \u0026#34;refresh_interval\u0026#34;: \u0026#34;1s\u0026#34; } 底层 lucene 简单来说,lucene 就是一个 jar 包,里面包含了封装好的各种建立倒排索引的算法代码。我们用 Java 开发的时候,引入 lucene jar,然后基于 lucene 的 api 去开发就可以了。\n通过 lucene,我们可以将已有的数据建立索引,lucene 会在本地磁盘上面,给我们组织索引的数据结构。\n倒排索引 在搜索引擎中,每个文档都有一个对应的文档 ID,文档内容被表示为一系列关键词的集合。例如,文档 1 经过分词,提取了 20 个关键词,每个关键词都会记录它在文档中出现的次数和出现位置。\n那么,倒排索引就是关键词到文档 ID 的映射,每个关键词都对应着一系列的文件,这些文件中都出现了关键词。\n举个栗子。\n有以下文档:\n对文档进行分词之后,得到以下倒排索引。\n另外,实用的倒排索引还可以记录更多的信息,比如文档频率信息,表示在文档集合中有多少个文档包含某个单词。\n那么,有了倒排索引,搜索引擎可以很方便地响应用户的查询。比如用户输入查询 Facebook,搜索系统查找倒排索引,从中读出包含这个单词的文档,这些文档就是提供给用户的搜索结果。\n要注意倒排索引的两个重要细节:\n倒排索引中的所有词项对应一个或多个文档 倒排索引中的词项根据字典顺序升序排列 上面只是一个简单的例子,并没有严格按照字典顺序升序排列。\n参考:\nhttps://zhuanlan.zhihu.com/p/139762008 https://zhuanlan.zhihu.com/p/102500311 ","permalink":"https://reid00.github.io/en/posts/storage/es%E9%9D%A2%E8%AF%95%E9%A2%98/","summary":"ElasticSearch面试题 1.为什么要使用Elasticsearch? 因为在我们商城中的数据,将来会非常多,所以采用以往的模糊查询,模","title":"ES面试题"},{"content":"一、DockerHub 官网链接 https://hub.docker.com/\n二、Dockerfile 关键字 注意: dockerfile 的关键字必须都是大写才能使用\nFROM\n指定基础镜像,当前新镜像是基于哪个镜像的。其中,scratch是个空镜像,这个镜像是虚拟的概念,并不实际存在,它表示一个空白的镜像,当前镜像没有依赖于其他镜像\n1 FROM scratch MAINTAINTER\n镜像维护者的姓名和邮箱地址\n1 MAINTAINER Sixah \u0026lt;sixah@163.com\u0026gt; RUN\n容器构建时需要运行的命令\n1 RUN echo \u0026#39;Hello, Docker!\u0026#39; EXPOSE\n当前容器对外暴露出的端口\n1 EXPOSE 8080 注意:\n-p 和 expose 区别\n-p 80:8080\n外部80 端口转向 向外暴露是 8080 端口的 Docker 容器。如果只写 -p 80 ,那么当作是 -p 80:80。也就是说,容器之间可以访问该 暴露8080端口的容器,其他用户也可以访问\nexposes 80\n​ 表示 容器之间可以访问该 暴露80端口的容器,但是其他用户不可以可以访问。这样其实就是做到了 封闭。\nWORKDIR\n指定在创建容器后,终端默认登陆进来的工作目录,一个落脚点\n1 WORKDIR /home/ ENV\n用来在构建镜像过程中设置环境变量\n1 ENV MY_PATH /usr/mytest 这个环境变量可以在后续的任何RUN指令中使用,这就如同在命令前面指定了环境变量前缀一样;当然,也可以在其他指令中直接使用这些环境变量,比如:WORKDIR $MY_PATH\nADD\n将宿主机目录下的文件拷贝进镜像且ADD命令会自动处理URL和解压tar压缩包\n1 ADD Linux_amd64.tar.gz COPY\n类似于ADD,拷贝文件和目录到镜像中,将从构建上下文目录中\u0026lt;源路径\u0026gt;的文件/目录复制到新的一层镜像内的\u0026lt;目标路径\u0026gt;位置\nCOPY 能实现的ADD 都可以实现,ADD 可以处理URL, 还可以自动解压,COPY不可以\n1 COPY . /go/src/app VOLUME\n容器数据卷,用于数据保存和持久化工作\n1 VOLUME /data CMD\n指定一个容器启动时要运行的命令。Dockerfile中可以有多个CMD指令,但只有最后一个生效,CMD会被docker run之后的参数替换\n1 CMD [\u0026#34;/bin/bash\u0026#34;] 注意:\n1 CMD -i 将代替 CMD [\u0026#34;/bin/bash\u0026#34;] 而CMD -i 无意义 而ENTRYPOINT ,可以在后面追加参数\n如果dockerfile 最后是\nENTRYPOINT curl [\u0026ldquo;s\u0026rdquo;,\u0026ldquo;baidu.com\u0026rdquo;]\n1 DOCKER run centos -i 意味着 ENTRYPOINT curl [\u0026#34;s\u0026#34;,\u0026#34;-i\u0026#34;,\u0026#34;baidu.com\u0026#34;] ENTRYPOINT\n指定一个容器启动是要运行的命令。ENTRYPOINT的目的和CMD一样,都是在指定容器启动程序及参数 ONBUILD\n当构建一个被继承的Dockerfile时运行的命令,父镜像在被子镜像继承后,父镜像的ONBUILD指令被触发 三、 给基础的CentOS 添加基础功能 编写dockerfile 1 2 3 4 5 6 7 8 9 10 11 12 13 FROM CENTOS MAINTAINER zzz zzz@163.com ENV MYPATH /usr/local WORKDIR $MYPATH RUN yum -y install vim RUN yum -y install net-tools EXPOSE 80 CMD echo $MYPATH CMD echo \u0026#34;success -----ok\u0026#34; CMD /bin/bash 构建 build 注意: 最后面有个path 此处用的. 代表当前路径 1 docker build -f dockerfile路径 -t mycentos:v1.3 . Push 1 2 docker push registry仓库中/name:version docker push harbor.ld-hadoop.com/nebula/supply:v7 如果docker push 出现Auth 相关的错误,安装下面方式解决:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 ➜ contact_radar_space_incre git:(master) ✗ docker push harbor.ld-hadoop.com/nebula/backup_radar_incre:v1 The push refers to a repository [harbor.ld-hadoop.com/nebula/backup_radar_incre] 770f8dde0bf3: Preparing de824f01aabe: Preparing e68ba2bf9675: Preparing aa4c808c19f6: Preparing 8ba9f690e8ba: Preparing 3e607d59ef9f: Waiting 1e18e7e1fcc2: Waiting c3a0d593ed24: Waiting 26a504e63be4: Waiting 8bf42db0de72: Waiting 31892cc314cb: Waiting 11936051f93b: Waiting unauthorized: unauthorized to access repository: nebula/backup_radar_incre, action: push: unauthorized to access repository: nebula/backup_radar_incre, action: push ➜ contact_radar_space_incre git:(master) ✗ mkdir /root/.docker ➜ contact_radar_space_incre git:(master) ✗ vim /root/.docker/config.json # 添加下面的认真json # { # \u0026#34;auths\u0026#34;: { # \u0026#34;harbor.ld-hadoop.com\u0026#34;: { # \u0026#34;auth\u0026#34;: \u0026#34;bGVvbjpraWxsanVoZQ==\u0026#34; # } # } # } ➜ contact_radar_space_incre git:(master) ✗ docker push harbor.ld-hadoop.com/nebula/backup_radar_incre:v1 The push refers to a repository [harbor.ld-hadoop.com/nebula/backup_radar_incre] 770f8dde0bf3: Pushed de824f01aabe: Pushed e68ba2bf9675: Pushed aa4c808c19f6: Pushed 8ba9f690e8ba: Pushed 3e607d59ef9f: Pushed 1e18e7e1fcc2: Pushed c3a0d593ed24: Pushed 26a504e63be4: Pushed 8bf42db0de72: Pushed 31892cc314cb: Pushed 11936051f93b: Pushed v1: digest: sha256:2cb5bf1b68e635556f27a4c2371f513c41fe0d89de06d9898fb0e47cef036cc4 size: 2846 运行\n1 docker run -it 新镜像名:TAG 列出镜像的变更历史\n1 docker history 镜像名 ","permalink":"https://reid00.github.io/en/posts/langs_linux/dockerfile%E6%A1%88%E4%BE%8B/","summary":"一、DockerHub 官网链接 https://hub.docker.com/ 二、Dockerfile 关键字 注意: dockerfile 的关键字必须都是大写才能使用 FROM 指定基础镜像,当前新镜像是基于哪个镜像的","title":"Dockerfile案例"},{"content":"安装 Windows 此处下载 双击exe 一直下一步安装\nLinux Linux 默认安装的Git 版本一般为1.8*, 可以通过以下方式升级\n首先,把老版本的 Git 卸掉。 1 2 sudo yum -y remove git sudo yum -y remove git-* 添加 End Point 到 CentOS 7 仓库 yum -y install https://packages.endpointdev.com/rhel/7/os/x86_64/endpoint-repo.x86_64.rpm yum -y install git check version git version 配置Git Set your name. git config --global user.name \u0026quot;Your Name\u0026quot; Set your email address. git config --global user.email \u0026quot;user@exmample.com\u0026quot; Verify the settings. git config --list Git 配置SSH key 连接Github HTTPS URL 和 SSH URL 在使用 git clone 项目时,可以使用仓库的 HTTPS URL 也可以 使用 SSH URL HTTPS URL,例如:https://github.com/\u0026lt;username\u0026gt;/\u0026lt;repo name\u0026gt;.git SSH URL,例如:git@github.com:\u0026lt;username\u0026gt;/\u0026lt;repo name\u0026gt;.git\n这两种方式的主要区别在于:使用 HTTPS URL 克隆时,每次 fetch 和 push 代码都需要输入账号和密码(可以通过下面缓存的方式避免),而使用 SSH URL 在配置好 SSH Key 后,每次 fetch 和 push 代码都不需要输入账号和密码。\n初次运行 Git 前的配置 Git 环境变量 Git 提供了一个叫做 git config 的工具,专门用来配置或读取相应的工作环境变量。而正是由这些环境变量,决定了 Git 在各个环节的具体工作方式和行为。这些变量可以存放在以下三个不同的地方:\n/etc/gitconfig 文件:系统中对所有用户都普遍适用的配置。若使用 git config 时用 \u0026ndash;system 选项,读写的就是这个文件。 ~/.gitconfig 文件:用户目录下的配置文件只适用于该用户。若使用 git config 时用 \u0026ndash;global 选项,读写的就是这个文件。 当前项目的 Git 目录中的配置文件(也就是工作目录中的 .git/config 文件):这里的配置仅仅针对当前项目有效。每一个级别的配置都会覆盖上层的相同配置,所以 .git/config 里的配置会覆盖 /etc/gitconfig 中的同名变量 配置用户信息 初次运行 Git 前需要配置用户信息,一个是你个人的用户名称,一个是你的电子邮件地址。这两条配置很重要,每次 Git 提交时都会引用这两条信息,说明是谁提交了更新,所以会随更新内容一起被永久纳入历史记录:\n1 2 $ git config --global user.name \u0026#34;Reid\u0026#34; $ git config --global user.email reid@example.com 如果用了 \u0026ndash;global 选项,那么更改的配置文件就是位于你用户主目录下的 ~/.gitconfig 文件,以后你所有的项目都会默认使用这里配置的用户信息。如果要在某个特定的项目中使用其他名字或者邮箱,只要去掉 \u0026ndash;global 选项重新配置即可,新的设定保存在当前项目的 .git/config 文件里。\n查看配置信息 要检查已有的配置信息,可以使用 git config \u0026ndash;list 命令:\n1 2 3 4 5 6 7 8 (base) [root@zhangbl-c7 .git]# git config --list user.name=Reid user.email=reid@example.com core.repositoryformatversion=0 core.filemode=true core.bare=false core.logallrefupdates=true ... 有时候会看到重复的变量名,那就说明它们来自不同的配置文件(比如 /etc/gitconfig 和 ~/.gitconfig ),不过最终 Git 实际采用的是最后一个。\n也可以直接查阅某个环境变量的设定,只要把特定的环境变量名称跟在后面即可,例如: git config user.name\n检查是否已有 SSH Key 看是否存在 id_rsa 和 id_rsa.pub 文件(或者是其它文件名),如果存在说明已有 ssh key,可以直接跳过生成密钥,其中 id_rsa 为私钥,id_rsa.pub 为公钥。 ls ~/.ssh/\n生成 SSH key ssh-keygen -t rsa -C \u0026quot;reid@example.com\u0026quot;\n-t : 指定密钥类型,默认是rsa,可以省略\n-C: 设置注释文字,比如邮箱\n-f: 指定密钥文件存储文件名\n以上代码省略了 -f 参数,因此在运行上面那条命令后会让你输入一个文件名,用于保存刚才生成的 SSH key,例如:\n1 2 3 $ ssh-keygen -t rsa -C \u0026#34;reid@example.com\u0026#34; Generating public/private rsa key pair. Enter file in which to save the key (~/.ssh/id_rsa): 当然,你也可以不输入文件名,直接回车使用默认文件名(推荐),那么就会生成 id_rsa 和 id_rsa.pub 两个密钥文件。后续的一些配置也可以使用默认参数。\n添加SSH到Github 登录 github,点击头像,点击 Settings 进入设置页面。\n然后点击菜单栏的 SSH and GPG keys 进入页面添加 SSH Key。 点击 New SSH Key 按钮后进行 Key 的填写,其中 Title 随意, Key 为刚刚生成的公钥,公钥在文件 id_rsa.pub 文件中,直接 copy 文件中的内容粘贴即可。\n测试 SSH key 在终端 输入 ssh -T git@github.com, 出现\n1 2 3 The authenticity of host \u0026#39;github.com (207.97.227.239)\u0026#39; can\u0026#39;t be established. # RSA key fingerprint is 16:27:ac:a5:76:28:2d:36:63:1b:56:4d:eb:df:a6:48. # Are you sure you want to continue connecting (yes/no)? 这是正常的,直接输入 yes 回车既可。如果你创建 SSH key 的时候设置了密码,接下来就会提示你输入密码,如:Enter passphrase for key '~/.ssh/id_rsa': 成功显示: Hi username! You've successfully authenticated, but GitHub does not provide shell access.\n如果用户名是正确的,你已经成功设置 SSH 密钥。如果你看到 \u0026ldquo;access denied\u0026rdquo; ,者表示拒绝访问,那么你就需要使用 HTTPS 去访问,而不是 SSH 。\nGit 多用户配置(个人和公司) 公司和github,经常会遇到要多用户使用git的情况,以下为配置信息,以下拿 zhangs \u0026amp; zhangs2 举例\n设置ssh-key ssh-keygen -t rsa -C \u0026quot;zhangs@mail.com\u0026quot;\n会提示存储的文件名,输入, 第二份ssh-key 生成是一定一定要输入 防止覆盖 如果需要push时确认的密码,可在该步骤输入 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 (base) [root@-c7 go-project]# ssh-keygen -t rsa -C \u0026#34;zhangs@mail.com\u0026#34; Generating public/private rsa key pair. Enter file in which to save the key (/root/.ssh/id_rsa): /root/.ssh/zhangs_id_rsa # 注意此处重命名 防止覆盖 Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /root/.ssh/zhangs_id_rsa. Your public key has been saved in /root/.ssh/zhangs_id_rsa.pub. The key fingerprint is: SHA256:t/XljH8zYCLFcDgTxzrAjUQ7VwSobCPobUhB6ImBcvA zhangs@mail.com The key\u0026#39;s randomart image is: +---[RSA 2048]----+ |=o +o+o*+ | |=o. =.*oo | |+oE . .o..B | |.= . = oo o | |o o o . S + . .| | o o o + + = | | . o o + o| | +.| | =| +----[SHA256]-----+ 上传到GitHub或者GitLab参考上面测试添加SSH-Key到Github SSH 配置文件 使用 ~/.ssh/config 作为我们的配置文件,如果文件不存在,我们就创建它。 vim ~/.ssh/config\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 # github email address Host github # 别名,用于区分多个 git 账号,可随意 HostName github.com # 要连接的服务器的主机名, 可以为IP 也可以为域名 User zhangs PreferredAuthentications publickey IdentityFile ~/.ssh/zhangs_id_rsa # ssh 连接使用的私钥 # gitlab email address # # 公司内网地址 Host gitlab HostName gitlab.xx.com User zhangs2 PreferredAuthentications publickey IdentityFile ~/.ssh/zhangs2_id_rsa 配置文件中的 HostName 是远程仓库的访问地址,这里可以是 IP,也可以是域名。Host 是用来拉取的仓库的别名,配不配置都行。如果 HostName 没配置的话,那就必须把 Host 配置为仓库 IP 地址或者域名,而非别名。\n其实这里的User并不会有我们预期的效果,比如你在公司的gitlab用户名一般会取实名的名字,而github是一个随意的昵称。这里并不会让你以后推送代码到gitlab时取 你在这里配置的 gitlab用户名,同样也不会推送到github时取你在这里配置的 github用户名。因为这个其实只是针对ssh key的配置的User,并不会影响你之前通过 git config --global user.name \u0026ldquo;公司gitlab用户名\u0026rdquo; 设置的git账户名\n可以分别测试一下了你的ssh 是否能连通了\n1 2 3 4 5 6 7 8 9 10 11 ssh -T git@gitlab # @后面可以是Host 名称 也可以是HostName ssh -T git@github.com ssh -T git@github Hi Reid00! You\u0026#39;ve successfully authenticated, but GitHub does not provide shell access. ssh -T git@github.com Hi Reid00! You\u0026#39;ve successfully authenticated, but GitHub does not provide shell access. 比如把github.com 的Host 设置成`Personal` 也成功 ssh -T git@personal Hi Reid00! You\u0026#39;ve successfully authenticated, but GitHub does not provide shell access. 设置user.name 前面提到 ~/.ssh/config 文件中的User 并不等同于我们的git账户名。 有可能你之前设置过 git config --global user.name \u0026quot;公司gitlab实名\u0026quot; 然后你发现你传代码到github的时候,也是显示的这个实名,让你觉得有点不爽。\n你可以继续到你本地的github仓库项目文件夹下去设置一个本地的用户名 git config --local user.name \u0026quot;github用户名\u0026quot; 再推送,就可以显示对应的用户名了。 邮箱同样git config --local user.email \u0026quot;github 邮箱\u0026quot;\n这里什么时候用global 什么时候用local 其实取决于你自己用哪个账户用得多一点,比如你在公司的电脑上,你就可以把公司的gitlab用户名加 \u0026ndash;global 配置,而自己个人的github加 \u0026ndash;local。如果你是在你自己家里的电脑上,就可以是相反的操作了。\n问题 由于公司Gitlab 不是走标准的21端口 而是走18888 端口,导致用上述方式配置好之后,在测试SSH Key 的时候无效。 1 2 ➜ .ssh ssh -T git@gitlab ssh: Could not resolve hostname gitlab.****.com:18888: Name or service not known 此时需要联系运维做域名映射,如下把 gitlab.xx.com:18888 映射到21 端口,比如proxygitlab.xx.com, 把~/.ssh/config下面的HostName 改为映射后的即可。\n1 2 3 4 5 Host gitlab HostName proxygitlab.xx.com User zhangs2 PreferredAuthentications publickey IdentityFile ~/.ssh/zhangs2_id_rsa 1 2 3 4 5 ➜ .ssh ssh -T git@gitlab The authenticity of host \u0026#39;proxygitlab.xx.com (172.16.51.198)\u0026#39; can\u0026#39;t be established. ECDSA key fingerprint is SHA256:AWmWq+kB2GUSTuPORU4hvRHTc1vhaFwvdNVBNXTMxbk. ECDSA key fingerprint is MD5:cb:b3:95:61:20:05:aa:8e:51:61:68:c0:14:bb:f2:e7. Are you sure you want to continue connecting (yes/no)? yes 用https方式 git clone 项目代码时用户名和密码的问题 换了新的开发机,通过上述方式配置好之后,clone github 上项目没有问题,可以clone 公司的项目时,发现由于ssh url 不能用(端口不标准),使用https url 的时候 一直需要输入账号密码很烦。\n听同事说要执行一个命令:git credential-manager uninstall,这个命令的作用是 清除掉缓存在git中的用户名和密码。\n在我机器上没有作用,出现了 git: \u0026lsquo;credential-manager\u0026rsquo; is not a git command. See \u0026lsquo;git \u0026ndash;help\u0026rsquo;. 的错误\n清除掉缓存在git中的用户名和密码后,以后每次用 https 方式拉取代码都需要输入用户名和密码。执行下面的命令可以解决这个问题。\n1 2 git config --system --unset credential.helper # 或者 git config --global --unset credential.helper git config --global credential.helper store 第一个命令清除凭证助手,使用 git config \u0026ndash;list 命令这是展示配置属性,只要不存在credential.helper表示清除成功 第二个配置凭证助手(命令将密码明文保存在~/.git-credentials)\nGithub 配置多用户 参考此处\nClone 新的仓库 1 2 3 4 5 6 7 8 9 10 11 12 # github: wylu, email: wylu@gmail.com # the default config Host github.com HostName github.com User git IdentityFile ~/.ssh/id_rsa # github: 15wylu, email: 15wylu@gmail.com Host 15wylu.github.com HostName github.com User git IdentityFile ~/.ssh/15wylu_id_rsa 这里以上面的配置为例,假设要克隆 15wylu 账号的一个项目,原来使用的命令如下: git@github.com:15wylu/15wylu.github.io.git 但是经过配置,我们已经将 15wylu 的 Host 设为了 15wylu.github.com,而不再是原来的 github.com,所以相应地 clone 的命令也变成如下: git@15wylu.github.com:15wylu/15wylu.github.io.git\n已经Clone 下来的仓库 首先使用 git remote -v 列出本地仓库对应的远程库,检查该 URL 是否与要使用的 GitHub 主机匹配,否则更新远程原始 URL, 以 15wylu 账号的仓库为例: git remote set-url origin git@15wylu.github.com:15wylu/15wylu.github.io.git\n对于本地创建新的仓库 在项目文件夹中使用 git init 中初始化目录为一个 Git 仓库。然后在 GitHub 帐户中创建新的仓库,将其作为远程库添加到本地仓库中: 同样以 15wylu 账号为例: git remote add origin git@15wylu.github.com:15wylu/remote_repo_name.git 确保 @ 和 : 之间的字符串与我们在 SSH 配置中指定的主机(Host)匹配。将初始提交推送到 GitHub 仓库。\nGit 常见操作记录 git remote 相关 git remote add \u0026lt;repo name\u0026gt; \u0026lt;repo url\u0026gt; repo name 为远程仓库的本地名称,可自定义,相当于给 url 对应的仓库起了个别名,对于 git clone 的仓库在本地的名称默认为 origin git remote -v 查看上传协议是 SSH/HTTPS git remote 查看远程仓库名称 git remote show origin 查看远程仓库名为origin 的详情 git remote rm \u0026lt;repo name\u0026gt; 删除远程仓库 git remote set-url [--push|--add|--delete] \u0026lt;repo name\u0026gt; \u0026lt;new url\u0026gt; [\u0026lt;old url\u0026gt;] 修改远程仓库, 例如 git remote set-url origin git@github.com:username/reponame.git 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 $ git remote -v origin https://github.com/Reid00/git-test.git (fetch) origin https://github.com/Reid00/git-test.git (push) (base) zhangbl@DESKTOP-8NHU8UF MINGW64 /c/test (main) $ git remote origin (base) zhangbl@DESKTOP-8NHU8UF MINGW64 /c/test (main) $ git remote show origin * remote origin Fetch URL: https://github.com/Reid00/git-test.git Push URL: https://github.com/Reid00/git-test.git HEAD branch: dev Remote branches: dev tracked main tracked Local branch configured for \u0026#39;git pull\u0026#39;: main merges with remote main Local ref configured for \u0026#39;git push\u0026#39;: main pushes to main (up to date) (base) git merge git merge dev 把dev 分支合并到当前所在分支。注意,需要先checkout 到某个分支上在执行。\ngit submodule (子模块) 添加Submodule Git 子模块(Git submodules)允许你将 git repo 保留为另一个 git repo 的子目录。Git 子模块只是在特定时间快照上对另一个 repo 的引用。Git 子模块使 Git repo 能够合并和跟踪外部代码的版本历史。\n命令 git submodule add \u0026lt;repo url\u0026gt; [submodule path] =\u0026gt; git submodule add https://github.com/****/hugo-PaperMod themes/PaperMod\n默认情况下,如果没有指定子模块存放路径,子模块将会放到一个与仓库同名的目录中。如果你想要放到其他地方,那么可以在命令结尾添加一个不同的路径,本例中子模块将会 clone 到 \u0026ldquo;themes/next\u0026rdquo; 目录下。\n命令执行完成后,会在当前工作仓库根目录下生成 .gitmodules 文件,内容如下:\n1 2 3 4 [submodule \u0026#34;themes/PaperMod\u0026#34;] path = themes/PaperMod url = https://github.com/adityatelange/hugo-PaperMod.git ignore = dirty 该文件保存了项目 URL 与已经拉取的本地目录之间的映射,如果有多个子模块,该文件中就会有多条记录。 要重点注意的是,该文件应像 .gitignore 文件一样受到(通过)版本控制,和该项目的其他部分一同被拉取推送。有了映射关系,克隆该项目的人就知道去哪获得子模块了。\n添加子模块完成后,当在父仓库时,Git 仍然不会跟踪 submodule 的文件, 而是将它看作该仓库中的一个特殊提交。\n推送到远程仓库后,远程仓库中 submodule 会和指定的 commit 关联起来。如果需要指定分支,可以在 \u0026ldquo;.gitmodules\u0026rdquo; 文件中加上 branch 配置,如 branch = develop。\n克隆含有submodule的项目 接下来我们将会克隆(clone)一个含有子模块的项目。 当你在克隆这样的项目时,默认会包含该子模块目录,但其中还没有任何文件,你需要执行两个命令以拉取子模块:\n1 2 git submodule init git submodule update git submodule init 用来初始化本地配置文件,而 git submodule update 则从子项目中抓取所有数据并检出父项目中列出的合适的提交。\n或者:\n1 git clone --recursive \u0026lt;parent repo url\u0026gt; 删除子模块 把子模块从版本控制系统中移除 git rm --cached \u0026lt;submodule path\u0026gt; 删除子模块目录 rm -rf \u0026lt;submodule path\u0026gt; 编辑 \u0026ldquo;.gitmodules\u0026rdquo;,移除相应 submodule 节点内容 编辑 \u0026ldquo;.git/config\u0026rdquo;,移除相应 submodule 配置 如果有 \u0026ldquo;.git/modules\u0026rdquo; 目录,还应删除其下的相应子模块的目录 例子:\n1 2 git rm --cached themes/PaperMod rm -rf themes/PaperMod 然后删除 \u0026ldquo;.gitmodules\u0026rdquo; 中如下内容:\n1 2 3 4 [submodule \u0026#34;themes/PaperMod\u0026#34;] path = themes/PaperMod url = https://github.com/adityatelange/hugo-PaperMod.git ignore = dirty 最后删除 \u0026ldquo;.git/config\u0026rdquo; 中如下内容:\n1 2 3 [submodule \u0026#34;themes/next\u0026#34;] url = https://github.com/wylu/hexo-theme-next active = true 要把此次修改同步到远程库,还需要 push 一下。\n","permalink":"https://reid00.github.io/en/posts/other/git-%E5%AE%89%E8%A3%85%E5%92%8C%E5%A4%9A%E7%94%A8%E6%88%B7%E9%85%8D%E7%BD%AE/","summary":"安装 Windows 此处下载 双击exe 一直下一步安装 Linux Linux 默认安装的Git 版本一般为1.8*, 可以通过以下方式升级 首先,把老版本的 Git 卸掉。 1 2 sudo yum -y remove git sudo yum","title":"Git 安装和多用户配置"},{"content":"哈希 哈希(Hash)也称为散列,是把任意长度的输入通过哈希算法变换为固定长度的输出,这个输出值也就是散列值。\n哈希表是根据键值对(key value)而直接进行访问的数据结构,通过将键值对映射到表中一个位置来访问记录,以加快查询速度。映射函数又称为散列函数,存放记录的数组叫做哈希表。\n如果两个输入串的哈希函数的值相同则发生了碰撞(Collision),既然把任意较长字符串转化为固定长度且较短的字符串,因此必有一个输出串对应多个输入串,碰撞是必然存在的。这种碰撞又称为哈希冲突。\n散列函数 哈希算法是一种广义的算法,也可以认为是一种思想,使用哈希算法可提高存储空间的利用率和数据查询效率。\n哈希函数又称为散列函数,采用散列算法。 哈希函数是一种从任何一种数据中创建小的数字“指纹”的方法。 哈希函数将数据打乱混合,重新创建一个叫做散列值的“指纹”。 哈希函数会将消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。 Go 接口 Golang的hash包提供多种散列算法,比如crc32/64, adler32, fnv\u0026hellip;\n1 2 3 4 5 6 7 type Hash interface{ io.Writer //嵌入io.Writer接口,向执行中的hash加入更多数据。 Sum(b []byte) []byte//将当前hash追加到字节数组b后面,不会改变当前hash状态。 Reset()//重置hash到初始化状态 Size() int//返回hash结果返回的字节数 BlockSize() int//返回hash的集成块大小,为提高效率,Write方法传入的字节数最好是block size的倍数。 } MD5 MD5消息摘要算法,是一种被广泛使用的密码散列函数,可以产出一个128位(16子节)的散列值。\nMD5已被证实无法防止碰撞,已经不算是很安全的算法,因此不适用于安全性认证,比如SSL公开密钥认证或数字签名等用途。\n对于需要高度安全性的数据,一般建议改用其他算法,比如SHA256。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 input := \u0026#34;123456\u0026#34; hash := md5.New() //创建散列值 n, err := hash.Write([]byte(input)) //写入待处理字节 if err != nil { fmt.Println(err, n) os.Exit(-1) } //bytes := hash.Sum([]byte(\u0026#34;\u0026#34;)) bytes := hash.Sum(nil) //获取最终散列值的字符切片 fmt.Printf(\u0026#34;%v\\n\u0026#34;, bytes) //[225 10 220 57 73 186 89 171 190 86 224 87 242 15 136 62] fmt.Printf(\u0026#34;%x\\n\u0026#34;, bytes) //以16进制字符串格式化字符切片 //e10adc3949ba59abbe56e057f20f883e MD5和SHA256是非常常用的两种单向散列函数\n1 2 3 4 5 6 7 8 9 10 11 12 import ( \u0026#34;crypto/md5\u0026#34; \u0026#34;encoding/hex\u0026#34; \u0026#34;testing\u0026#34; ) func MD5(input string) string { c := md5.New() c.Write([]byte(input)) bytes := c.Sum(nil) return hex.EncodeToString(bytes) } SHA-1 1 2 3 4 5 6 7 password := \u0026#34;123456\u0026#34; ins := sha1.New() ins.Write([]byte(password)) result := ins.Sum([]byte(\u0026#34;\u0026#34;)) fmt.Printf(\u0026#34;%x\\n\u0026#34;, result) //7c4a8d09ca3762af61e59520943dc26494f8941b 1 2 3 4 5 6 7 8 9 10 11 12 import ( \u0026#34;crypto/sha1\u0026#34; \u0026#34;encoding/hex\u0026#34; \u0026#34;testing\u0026#34; ) func SHA1(input string) string { c := sha1.New() c.Write([]byte(input)) bytes := c.Sum(nil) return hex.EncodeToString(bytes) } CRC32 CRC即Cyclic Redundancy Check循环冗余校验码 CRC是实现32位循环冗余校验或CRC-32校验和 在远距离数据通信中,为确保高效而无差错地传送数据,必须对数据进行校验即差错控制。 CRC(Cyclic Redundancy Check/Code)循环冗余校验是对一个传送数据块进行校验,是一种高效的差错控制方法。 ChecksumIEEE使用IEEE多项式返回数据的CRC-32校验和\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 package test import ( \u0026#34;hash/crc32\u0026#34; \u0026#34;testing\u0026#34; ) func CRC32(input string) uint32 { bytes := []byte(input) return crc32.ChecksumIEEE(bytes) } func TestHash(t *testing.T) { input := \u0026#34;123456\u0026#34; t.Log(CRC32(input)) //158520161 } MurMur 我们有时候想将一段内容(比如字符串)转换成一个随机整数值,这里我们使用murmur3 hash算法可以达到这个目的 1)hash算法有可能发生碰撞,即不同的输入转换出的hash值是一样的,好的算法当然发生碰撞的概率会很小。 2)murmur3算法是非加密哈希算法\n加密哈希函数旨在保证安全性,很难找到碰撞。即:给定的散列h很难找到的消息m;很难找到产生相同的哈希值的消息m1和m2。 非加密哈希函数只是试图避免非恶意输入的冲突。作为较弱担保的交换,它们通常更快。如果数据量小,或者不太在意哈希碰撞的频率,甚至可以选择生成哈希值小的哈希算法,占用更小的空间。 示例代码:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;github.com/spaolacci/murmur3\u0026#34; ) func main() { originalStr := \u0026#34;pwww.google.com\u0026#34; // 注意:生成的hash值有三种取值,uint32,uint64,uint128,分别对应方法Sum32,Sum64,Sum128 // 下面例子以Sum64为例 // 1、使用默认种子,生成哈希值 // 默认种子,其实是seed=0 hValue1 := murmur3.Sum64([]byte(originalStr)) fmt.Printf(\u0026#34;hValue1 is %d\\n\u0026#34;, hValue1) // hValue1 is 13092418635223121727 // 默认返回值是uint64, 转化为int64 hValue1 := murmur3.Sum64([]byte(originalStr)) fmt.Printf(\u0026#34;hValue1 is %d\\n\u0026#34;, hValue1) // hValue1 is -5354325438486429889 // 2、使用指定种子,生成哈希值 seed := uint32(0000) hValue2 := murmur3.Sum64WithSeed([]byte(originalStr), seed) fmt.Printf(\u0026#34;hValue2 is %d\\n\u0026#34;, hValue2) // hValue2 is 13092418635223121727 // 3、使用指定种子,生成哈希值,2的另一种写法 h := murmur3.New64WithSeed(seed) h.Write([]byte(originalStr)) hValue3 := h.Sum64() fmt.Printf(\u0026#34;hValue3 is %d\\n\u0026#34;, hValue3) // hValue3 is 13092418635223121727 // 如果使用h继续计算其他值,则需要首先调用Reset,引为write这里是追加写 h.Reset() h.Write([]byte(originalStr)) hValue4 := h.Sum64() fmt.Printf(\u0026#34;hValue4 is %d\\n\u0026#34;, hValue4) // hValue4 is 13092418635223121727 } ","permalink":"https://reid00.github.io/en/posts/langs_linux/golang-murmur3/","summary":"哈希 哈希(Hash)也称为散列,是把任意长度的输入通过哈希算法变换为固定长度的输出,这个输出值也就是散列值。 哈希表是根据键值对(key val","title":"Golang MurMur3"},{"content":"Linux修改主机名修改hostname的方法 临时修改Linux主机名的方法 hostname newname 执行命令后发现没有变化。重新开终端即可显示,你也可以通过uname -n命令来查看当前的主机名。\n永久修改Linux主机名的方法\n使用 hostnamectl 来改变主机名称 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 [root@nebula3-01 ~]# hostnamectl Static hostname: nebula3-01 Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 717058195e934eb88f4631adf25ab163 Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 [root@nebula-test02 ~]# hostnamectl set-hostname nebula3-02 [root@nebula-test02 ~]# hostnamectl Static hostname: nebula3-02 Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 6b836dcf9c274ef48f334e6b53f8e296 Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 [root@nebula-test02 ~]# 退出后,重新登录即可\n通过修改/etc/hostname 文件,本质和上面一样 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 [root@nebula-test03 ~]# hostnamectl Static hostname: nebula-test03.novalocal Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 683f9e34bce149659226bcdfc0dce6ed Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 [root@nebula-test03 ~]# cat /etc/hostname nebula-test03.novalocal [root@nebula-test03 ~]# echo \u0026#34;nebula3-03\u0026#34; \u0026gt; /etc/hostname [root@nebula-test03 ~]# hostnamectl Static hostname: nebula3-03 Transient hostname: nebula-test03.novalocal Icon name: computer-vm Chassis: vm Machine ID: 1d8987d66da0c7cd7960ca4e5aefe30f Boot ID: 683f9e34bce149659226bcdfc0dce6ed Virtualization: kvm Operating System: CentOS Linux 7 (Core) CPE OS Name: cpe:/o:centos:centos:7 Kernel: Linux 3.10.0-1160.el7.x86_64 Architecture: x86-64 通过机器名ping 通彼此 修改/etc/hosts 文件,添加 ip 域名 即可。 vim /etc/hosts\n1 2 3 4 172.18.163.124 test-server-01 172.18.163.115 test-server-02 172.18.163.114 test-server-03 172.18.163.85 test-server-04 查看服务器是否为SSD 方法一 判断cat /sys/block/*/queue/rotational 的返回值(其中*为你的硬盘设备名称,例如sda等等),如果返回1则表示磁盘可旋转,那么就是HDD了;反之,如果返回0,则表示磁盘不可以旋转,那么就有可能是SSD了。\n方法二 lsblk -d -o name,rota 命令\n1 2 3 [root@nebula3-04 ~]# lsblk -d -o name,rota NAME ROTA vda 1 划分分区并挂载磁盘 本操作以该场景为例,当云服务器挂载了一块新的数据盘时,使用fdisk分区工具将该数据盘设为主分区,分区形式默认设置为MBR,文件系统设为ext4格式,挂载在“/mnt/sdc”下,并设置开机启动自动挂载。\nfdisk -l 显示信息 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [root@ecs-test-0001 ~]# fdisk -l Disk /dev/vda: 42.9 GB, 42949672960 bytes, 83886080 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk label type: dos Disk identifier: 0x000bcb4e Device Boot Start End Blocks Id System /dev/vda1 * 2048 83886079 41942016 83 Linux Disk /dev/vdb: 107.4 GB, 107374182400 bytes, 209715200 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes 表示当前的云服务器有两块磁盘,“/dev/vda”是系统盘,“/dev/vdb”是新增数据盘。\n执行以下命令,进入fdisk分区工具,开始对新增数据盘执行分区操作。 fdisk 新增数据盘 以新挂载的数据盘“/dev/vdb”为例: fdisk /dev/vdb 1 2 3 4 5 6 7 8 9 10 [root@ecs-test-0001 ~]# fdisk /dev/vdb Welcome to fdisk (util-linux 2.23.2). Changes will remain in memory only, until you decide to write them. Be careful before using the write command. Device does not contain a recognized partition table Building a new DOS disklabel with disk identifier 0x38717fc1. Command (m for help): 输入“n”,按“Enter”,开始新建分区。 1 2 3 4 Command (m for help): n Partition type: p primary (0 primary, 0 extended, 4 free) e extended 表示磁盘有两种分区类型:\n“p”表示主分区。 “e”表示扩展分区。 磁盘使用MBR分区形式,最多可以创建4个主分区,或者3个主分区加1个扩展分区,扩展分区不可以直接使用,需要划分成若干个逻辑分区才可以使用。 磁盘使用GPT分区形式时,没有主分区、扩展分区以及逻辑分区之分。\n以创建一个主要分区为例,输入“p”,按“Enter”,开始创建一个主分区。 1 2 Select (default p): p Partition number (1-4, default 1): “Partition number”表示主分区编号,可以选择1-4。\n以分区编号选择“1”为例,输入主分区编号“1”,按“Enter”。 1 2 Partition number (1-4, default 1): 1 First sector (2048-209715199, default 2048): “First sector”表示起始磁柱值,可以选择2048-209715199,默认为2048。\n以选择默认起始磁柱值2048为例,按“Enter” 系统会自动提示分区可用空间的起始磁柱值和截止磁柱值,可以在该区间内自定义,或者使用默认值。起始磁柱值必须小于分区的截止磁柱值。 1 2 3 First sector (2048-209715199, default 2048): Using default value 2048 Last sector, +sectors or +size{K,M,G} (2048-209715199, default 209715199): “Last sector”表示截止磁柱值,可以选择2048-209715199,默认为209715199。\n以选择默认截止磁柱值209715199为例,按“Enter”。 系统会自动提示分区可用空间的起始磁柱值和截止磁柱值,可以在该区间内自定义,或者使用默认值。起始磁柱值必须小于分区的截止磁柱值。 1 2 3 4 5 Last sector, +sectors or +size{K,M,G} (2048-209715199, default 209715199): Using default value 209715199 Partition 1 of type Linux and of size 100 GiB is set Command (m for help): 表示分区完成,即为数据盘新建了1个分区。\n输入“p”,按“Enter”,查看新建分区的详细信息。 1 2 3 4 5 6 7 8 9 10 11 12 13 Command (m for help): p Disk /dev/vdb: 107.4 GB, 107374182400 bytes, 209715200 sectors Units = sectors of 1 * 512 = 512 bytes Sector size (logical/physical): 512 bytes / 512 bytes I/O size (minimum/optimal): 512 bytes / 512 bytes Disk label type: dos Disk identifier: 0x38717fc1 Device Boot Start End Blocks Id System /dev/vdb1 2048 209715199 104856576 83 Linux Command (m for help): 表示新建分区“/dev/vdb1”的详细信息。\n输入“w”,按“Enter”,将分区结果写入分区表中。 1 2 3 4 5 Command (m for help): w The partition table has been altered! Calling ioctl() to re-read partition table. Syncing disks. 表示分区创建完成。 如果之前分区操作有误,请输入“q”,则会退出fdisk分区工具,之前的分区结果将不会被保留。\n执行partprobe命令,将新的分区表变更同步至操作系统。\n执行mkfs -t ext4 /dev/vdb1命令,将新建分区文件系统设为系统所需格式。\n执行mkdir 挂载目录 =\u0026gt; mkdir /mnt/sdc 命令,新建挂载目录。\n执行mount /dev/vdb1 /mnt/sdc命令,将新建分区挂载到12中创建的目录下\n查看挂载结果 df -TH\n1 2 3 4 5 6 7 8 9 [root@ecs-test-0001 ~]# df -TH Filesystem Type Size Used Avail Use% Mounted on /dev/vda1 ext4 43G 1.9G 39G 5% / devtmpfs devtmpfs 2.0G 0 2.0G 0% /dev tmpfs tmpfs 2.0G 0 2.0G 0% /dev/shm tmpfs tmpfs 2.0G 9.1M 2.0G 1% /run tmpfs tmpfs 2.0G 0 2.0G 0% /sys/fs/cgroup tmpfs tmpfs 398M 0 398M 0% /run/user/0 /dev/vdb1 ext4 106G 63M 101G 1% /mnt/sdc 表示新建分区“/dev/vdb1”已挂载至“/mnt/sdc”。\n设置系统给服务Systemd 以前使用Ubuntu和CentOS,一般使用SysV init(就是以前使用的service)进行进程的开机自启和进程守护。 但是,现在更多地使用systemd来实现进程的管理。\nSystemd Systemd(系统管理守护进程),最开始以GNU GPL协议授权开发,现在已转为使用GNU LGPL协议。字母d是daemon的缩写 它取替并兼容传统的SysV init。事实上,CentOS和Debian,现在默认都是使用Systemd:\nCentOS 7开始预设并使用Systemd Ubuntu 15.04开始并预设使用Systemd\n使用Systemd的优点:\n按需启动进程,减少系统资源消耗 并行启动进程,提高系统启动速度 查看systemd和systemctl程序相关的目录:\n1 2 3 4 [root@nebula3-01 node_exporter]# whereis systemd systemd: /usr/lib/systemd /etc/systemd /usr/share/systemd /usr/share/man/man1/systemd.1.gz [root@nebula3-01 node_exporter]# whereis systemctl systemctl: /usr/bin/systemctl /usr/share/man/man1/systemctl.1.gz Systemctl Unit Systemd引入了一个核心配置:Unit(单元配置)。事实上,Systemd管理的每个进程,都是一个Unit。相当于任务块。一个有12种模式:\nService unit:系统服务 Target unit:多个Unit构成的一个组 Device Unit:硬件设备 Mount Unit:文件系统的挂载点 Automount Unit:自动挂载点 Path Unit:文件或路径 Scope Unit:不是由 Systemd 启动的外部进程 Slice Unit:进程组 Snapshot Unit:Systemd 快照,可以切回某个快照 Socket Unit:进程间通信的 socket Swap Unit:swap 文件 Timer Unit:定时器 创建配置文件 如果我们要创建一个Unit服务,我们应该如何创建配置文件呢? 我们自己配置Unit服务(后续使用Systemctl进行启动和管理),可以配置到:\n/usr/lib/systemd/system/:推荐地址。 /run/systemd/system/:系统执行过程中所产生的服务脚本,这些脚本的优先级比上面的高。 /etc/systemd/system/:管理员根据主机系统的需求所建立的执行脚本,优先级比上面的高。 创建编写配置文件 vim /usr/lib/systemd/system/node_exporter.service 新增\n1 2 3 4 5 6 7 8 9 10 11 12 [Unit] Description=node_exporter After=network.target [Service] User=root Type=simple ExecStart=/root/node_exporter/node_exporter PrivateTmp=true [Install] WantedBy=multi-user.target 一些解释:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 - Unit - Description,服务的描述 - Documentation,文档介绍 - After,该服务要在什么服务启动之后启动,比如Mysql需要在network和syslog启动之后再启动 - Install - WantedBy,值是一个或多个Target,当前Unit激活时(enable)符号链接会放入/etc/systemd/system目录下面以Target名+.wants后缀构成的子目录中 - RequiredBy,它的值是一个或多个Target,当前Unit激活(enable)时,符号链接会放入/etc/systemd/system目录下面以Target名+.required后缀构成的子目录中 - Alias,当前Unit可用于启动的别名 - Also,当前Unit激活(enable)时,会被同时激活的其他Unit - Service - Type,定义启动时的进程行为。它有以下几种值。 - Type=simple,默认值,执行ExecStart指定的命令,启动主进程 - Type=forking,以 fork 方式从父进程创建子进程,创建后父进程会立即退出 - Type=oneshot,一次性进程,Systemd 会等当前服务退出,再继续往下执行 - Type=dbus,当前服务通过D-Bus启动 - Type=notify,当前服务启动完毕,会通知Systemd,再继续往下执行 - Type=idle,若有其他任务执行完毕,当前服务才会运行 - ExecStart,启动当前服务的命令 - ExecStartPre,启动当前服务之前执行的命令 - ExecStartPost,启动当前服务之后执行的命令 - ExecReload,重启当前服务时执行的命令 - ExecStop,停止当前服务时执行的命令 - ExecStopPost,停止当其服务之后执行的命令 - RestartSec,自动重启当前服务间隔的秒数 - Restart,定义何种情况 Systemd 会自动重启当前服务,可能的值包括always(总是重启)、on-success、on-failure、on-abnormal、on-abort、on-watchdog - TimeoutSec,定义 Systemd 停止当前服务之前等待的秒数 - Environment,指定环境变量 重载配置 1 systemctl daemon-reload 启动服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 root@nebula3-01 node_exporter]# systemctl daemon-reload [root@nebula3-01 node_exporter]# systemctl status node_exporter ● node_exporter.service - node_exporter Loaded: loaded (/usr/lib/systemd/system/node_exporter.service; disabled; vendor preset: disabled) Active: inactive (dead) [root@nebula3-01 node_exporter]# systemctl start node_exporter [root@nebula3-01 node_exporter]# systemctl status node_exporter ● node_exporter.service - node_exporter Loaded: loaded (/usr/lib/systemd/system/node_exporter.service; disabled; vendor preset: disabled) Active: active (running) since Thu 2023-03-02 14:04:43 CST; 2s ago Main PID: 15015 (node_exporter) CGroup: /system.slice/node_exporter.service └─15015 /root/node_exporter/node_exporter Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=thermal_zone Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=time Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=timex Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=udp_queues Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=uname Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=vmstat Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=xfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=zfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:232 level=info msg=\u0026#34;Listening on\u0026#34; address=[::]:9100 Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:235 level=info msg=\u0026#34;TLS is disabled.\u0026#34; http2=false ...::]:9100 Hint: Some lines were ellipsized, use -l to show in full. 开机自启 1 2 [root@nebula3-01 node_exporter]# systemctl enable node_exporter Created symlink from /etc/systemd/system/multi-user.target.wants/node_exporter.service to /usr/lib/systemd/system/node_exporter.service. 查看是否开机自启, 比上面多了 enabled; vendor preset: disabled\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 [root@nebula3-01 node_exporter]# systemctl status node_exporter ● node_exporter.service - node_exporter Loaded: loaded (/usr/lib/systemd/system/node_exporter.service; enabled; vendor preset: disabled) Active: active (running) since Thu 2023-03-02 14:04:43 CST; 11min ago Main PID: 15015 (node_exporter) CGroup: /system.slice/node_exporter.service └─15015 /root/node_exporter/node_exporter Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=thermal_zone Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=time Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=timex Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=udp_queues Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=uname Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=vmstat Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=xfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.268Z caller=node_exporter.go:117 level=info collector=zfs Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:232 level=info msg=\u0026#34;Listening on\u0026#34; address=[::]:9100 Mar 02 14:04:43 nebula3-01 node_exporter[15015]: ts=2023-03-02T06:04:43.269Z caller=tls_config.go:235 level=info msg=\u0026#34;TLS is disabled.\u0026#34; http2=false ...::]:9100 Hint: Some lines were ellipsized, use -l to show in full. 查看Systemd 服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 [root@nebula3-01 node_exporter]# systemctl UNIT LOAD ACTIVE SUB DESCRIPTION proc-sys-fs-binfmt_misc.automount loaded active running Arbitrary Executable File Formats File System Automount Point sys-devices-pci0000:00-0000:00:03.0-virtio0-net-eth0.device loaded active plugged Virtio network device sys-devices-pci0000:00-0000:00:04.0-virtio1-virtio\\x2dports-vport1p1.device loaded active plugged /sys/devices/pci0000:00/0000:00:04.0/virtio1/virtio-ports/v sys-devices-pci0000:00-0000:00:05.0-virtio2-block-vda-vda1.device loaded active plugged /sys/devices/pci0000:00/0000:00:05.0/virtio2/block/vda/vda1 sys-devices-pci0000:00-0000:00:05.0-virtio2-block-vda-vda2.device loaded active plugged /sys/devices/pci0000:00/0000:00:05.0/virtio2/block/vda/vda2 sys-devices-pci0000:00-0000:00:05.0-virtio2-block-vda.device loaded active plugged /sys/devices/pci0000:00/0000:00:05.0/virtio2/block/vda sys-devices-platform-serial8250-tty-ttyS1.device loaded active plugged /sys/devices/platform/serial8250/tty/ttyS1 sys-devices-platform-serial8250-tty-ttyS2.device loaded active plugged /sys/devices/platform/serial8250/tty/ttyS2 sys-devices-platform-serial8250-tty-ttyS3.device loaded active plugged /sys/devices/platform/serial8250/tty/ttyS3 sys-devices-pnp0-00:00-tty-ttyS0.device loaded active plugged /sys/devices/pnp0/00:00/tty/ttyS0 sys-module-configfs.device loaded active plugged /sys/module/configfs sys-subsystem-net-devices-eth0.device loaded active plugged Virtio network device -.mount loaded active mounted / boot.mount loaded active mounted /boot dev-hugepages.mount loaded active mounted Huge Pages File System dev-mqueue.mount loaded active mounted POSIX Message Queue File System ... 你可以配合grep命令操作\n1 2 3 [root@nebula3-01 node_exporter]# systemctl | grep node kmod-static-nodes.service loaded active exited Create list of required static device nodes for the current kernel node_exporter.service loaded active running node_exporter 查看Linux 的基本信息 硬件 uname -a 查看内核/操作系统/CPU信息 head -n 1 /etc/issue 查看操作系统版本 cat /proc/cpuinfo 查看CPU信息 hostname 查看计算机名 lspci -tv 列出所有PCI设备 lsusb -tv 列出所有USB设备 lsmod 列出加载的内核模块 env 查看环境变量 资源 free -m 查看内存使用量和交换区使用量 df -h 查看各分区使用情况 du -sh \u0026lt;目录名\u0026gt; 查看指定目录的大小 grep MemTotal /proc/meminfo 查看内存总量 grep MemFree /proc/meminfo `查看空闲内存量 uptime 查看系统运行时间、用户数、负载 cat /proc/loadavg 查看系统负载 磁盘和分区 mount | column -t 查看挂接的分区状态` fdisk -l 查看所有分区,扇区大小 swapon -s 查看所有交换分区 hdparm -i /dev/hda 查看磁盘参数(仅适用于IDE设备) dmesg | grep IDE 查看启动时IDE设备检测状况 stat /boot/ 查看硬盘块情况,块大小 getconf PAGE_SIZE 查看页大小 网络 ifconfig 查看所有网络接口的属性 iptables -L 查看防火墙设置 route -n 查看路由表 netstat -lntp 查看所有监听端口 netstat -antp 查看所有已经建立的连接 netstat -s 查看网络统计信息 进程 ps -ef 查看所有进程 top 实时显示进程状态 用户 w 查看活动用户 id \u0026lt;用户名\u0026gt; 查看指定用户信息 last 查看用户登录日志 cut -d: -f1 /etc/passwd 查看系统所有用户 cut -d: -f1 /etc/group 查看系统所有组 crontab -l 查看当前用户的计划任务 服务 chkconfig --list 列出所有系统服务 chkconfig --list | grep on 列出所有启动的系统服务 程序 rpm -qa 查看所有安装的软件包 安装Golang, Minicoda, Git Golang 下载合适的版本 输入tar -C /usr/local/ -xzf go1.20.2.linux-amd64.tar.gz 解压到合适的位置, -C 指定位置 设置GOPATH echo $PATH 先查看$PATH 用vim 或者其他工具打开$HOME/.profile, 输入export PATH=$PATH:/usr/local/go/bin 输入 source $HOME/.profile 是上面profile 生效 输入 go version 检查是否成功 修改go proxy 为功能镜像 go env -w GOPROXY=https://goproxy.cn,direct 输入 go env 确认 GOPROXY Minicode 从此处下载Minicoda run bash Miniconda3-latest-Linux-x86_64.sh 根据提示安装,可以都选择默认 为了使配置生效,关闭Terminal 重新打开 输入conda list 或者 机器名前面有个base 意味着Terminal 输入python 默认为Python3,想要使用Python2, 输入Python2 即可 如果conda 没有被识别,需要把conda 加入环境变量。 1 2 3 4 vim /etc/profile # 输入 在文件末尾添加一行:export PATH=/root/miniconda3/bin:$PATH, /root/miniconda3 是miniconda 安装的路径 # :wq 保存退出。然后 source /etc/profile 激活配置 Git 首先,把老版本的 Git 卸掉。 1 2 sudo yum -y remove git sudo yum -y remove git-* 添加 End Point 到 CentOS 7 仓库 yum -y install https://packages.endpointdev.com/rhel/7/os/x86_64/endpoint-repo.x86_64.rpm yum -y install git check version git version 配置Git Set your name. git config --global user.name \u0026quot;Your Name\u0026quot; Set your email address. git config --global user.email \u0026quot;user@exmample.com\u0026quot; Verify the settings. git config --list ","permalink":"https://reid00.github.io/en/posts/langs_linux/linux-%E6%9C%8D%E5%8A%A1%E5%99%A8%E7%99%BB%E5%BD%95%E5%90%8E%E7%9A%84%E5%B8%B8%E8%A7%81%E6%93%8D%E4%BD%9C/","summary":"Linux修改主机名修改hostname的方法 临时修改Linux主机名的方法 hostname newname 执行命令后发现没有变化。重新开终端即可显示,你也可以通过un","title":"Linux 服务器登录后的常见操作"},{"content":"ShardedKV 介绍 有关 shardkv,其可以算是一个 multi-raft 的实现,只是缺少了物理节点的抽象概念。在实际的生产系统中,不同 raft 组的成员可能存在于一个物理节点上,而且一般情况下都是一个物理节点拥有一个状态机,不同 raft 组使用不同地命名空间或前缀来操作同一个状态机。基于此,下文所提到的的节点都代指 raft 组的某个成员,而不代指某个物理节点。比如节点宕机代指 raft 组的某个成员被 kill 掉,而不是指某个物理节点宕机,从而可能影响多个 raft 的成员。\n在本实验中,我们将构建一个带分片的KV存储系统,即一组副本组上的键。每一个分片都是KV对的子集,例如,所有以“a”开头的键可能是一个分片,所有以“b”开头的键可能是另一个分片。 也可以用range 或者Hash 之后分区。 分片的原因是性能。每个replica group只处理几个分片的 put 和 get,并且这些组并行操作;因此,系统总吞吐量(每单位时间的投入和获取)与组数成比例增加。\n我们的整个系统有两个基本组件:shard controller 和 shard group。整个系统有一个 controller 和多个 group,controller 单独一个 raft 集群,每一个 shard group 是由 kvraft 实例构成的集群。shard controller 负责调度,客户端向 shard controller 发送请求,controller 会根据配置(config)来告知客户端服务这个 key 的是哪个 group。 每个 group 负责部分 shard。\n1 2 3 4 5 type Config struct { Num int // config number, version also Shards [NShards]int // shard -\u0026gt; gid Groups map[int][]string // gid -\u0026gt; servers[] } 三个参数分别对应的版本的配置号,分片所对应的组(Group)信息(实验中的分片为10个),每个组对应的服务器映射名称列表(也就是组信息)。\nGroup表示一个Leader-Followers集群,Gid为它的标识,Shard表示所有数据的一个子集,Config表示一个划分方案。此次实验中,所有数据分为NShards = 10份,Server给测试程序提供四个接口。 下图中每个Shard 都有其他对应的副本未画出。对Client 以Group 为单位进行服务。相当于一个物理节点上 有若干个Group 可以对外服务。\n分片存储系统必须能够在replica group之间移动分片,因为某些组可能比其他组负载更多,因此需要移动分片以平衡负载;而且replica group可能会加入和离开系统,可能会添加新的副本组以增加容量,或者可能会使现有的副本组脱机以进行修复或报废。\nLab4A 实现 本实验的主要挑战是处理重新配置——移动分片所属。在单个副本组中,所有组成员必须就何时发生与客户端 Put/Append/Get 请求相关的重新配置达成一致。例如,Put 可能与重新配置大约同时到达,导致副本组停止对该Put包含的key的分片负责。组中的所有副本必须就 Put 发生在重新配置之前还是之后达成一致。如果之前,Put 应该生效,分片的新所有者将看到它的效果;如果之后,Put 将不会生效,客户端必须在新所有者处重新尝试。推荐的方法是让每个副本组使用 Raft 不仅记录 Puts、Appends 和 Gets 的顺序,还记录重新配置的顺序。您需要确保在任何时候最多有一个副本组为每个分片提供请求。\n重新配置还需要副本组之间的交互。例如,在配置 10 中,组 G1 可能负责分片 S1。在配置 11 中,组 G2 可能负责分片 S1。在从 10 到 11 的重新配置过程中,G1 和 G2 必须使用 RPC 将分片 S1(键/值对)的内容从 G1 移动到 G2。\nLab4的内容就是将数据按照某种方式分开存储到不同的RAFT集群(Group)上的分片(shard)上。保证相应数据请求引流到对应的集群,降低单一集群的压力,提供更为高效、更为健壮的服务。 具体的lab4要实现一个支持 multi-raft分片 、分片数据动态迁移的线性一致性分布式 KV 存储服务。 shard表示互不相交并且组成完整数据库的每一个数据库子集。group表示shard的集合,包含一个或多个shard。一个shard只可属于一个group,一个group可包含(管理)多个shard。 lab4A实现ShardCtrler服务,作用:提供高可用的集群配置管理服务,实现分片的负载均衡,并尽可能少地移动分片。记录了每组(Group) ShardKVServer 的集群信息和每个分片(shard)服务于哪组(Group)ShardKVServer。 具体实现通过Raft维护 一个Configs数组,单个config具体内容如下: Num:config number,Num=0表示configuration无效,边界条件, 即是version 的作用 Shards:shard -\u0026gt; gid,分片位置信息,Shards[3]=2,说明分片序号为3的分片负贵的集群是Group2(gid=2) Groups:gid -\u0026gt; servers[], 集群成员信息,Group[3]=[\u0026lsquo;server1\u0026rsquo;,\u0026lsquo;server2\u0026rsquo;],说明gid = 3的集群Group3包含两台名称为server1 \u0026amp; server2的机器 RPC Query RPC。查询配置,参数是一个配置号, shardctrler 回复具有该编号的配置。如果该数字为 -1 或大于已知的最大配置数字,则 shardctrler 应回复最新配置。 Query(-1) 的结果应该反映 shardctrler 在收到 Query(-1) RPC 之前完成处理的每个 Join、Leave 或 Move RPC;\nJoin RPC 。添加新的replica group,它的参数是一组从唯一的非零副本组标识符 (GID) 到服务器名称列表的映射。 shardctrler 应该通过创建一个包含新副本组的新配置来做出反应。新配置应在所有组中尽可能均匀地分配分片,并应移动尽可能少的分片以实现该目标。如果 GID 不是当前配置的一部分,则 shardctrler 应该允许重新使用它(即,应该允许 GID 加入,然后离开,然后再次加入)\n新加入的Group信息,要求在每一个group平衡分布shard,即任意两个group之间的shard数目相差不能为1,具体实现每一次找出含有shard数目最多的和最少的,最多的给最少的一个,循环直到满足条件为止。坑为:GID = 0 是无效配置,一开始所有分片分配给GID=0,需要优先分配;map的迭代时无序的,不确定顺序的话,同一个命令在不同节点上计算出来的新配置不一致,按sort排序之后遍历即可。且 map 是引用对象,需要用深拷贝做复制。\n对于 Join,可以通过多次平均地方式来达到这个目的:每次选择一个拥有 shard 数最多的 raft 组和一个拥有 shard 数最少的 raft,将前者管理的一个 shard 分给后者,周而复始,直到它们之前的差值小于等于 1 且 0 raft 组无 shard 为止。对于 Leave,如果 Leave 后集群中无 raft 组,则将分片所属 raft 组都置为无效的 0;否则将删除 raft 组的分片均匀地分配给仍然存在的 raft 组。通过这样的分配,可以将 shard 分配地十分均匀且产生了几乎最少的迁移任务。\nLeave RPC。删除指定replica group, 参数是以前加入的组的 GID 列表。 shardctrler 应该创建一个不包括这些组的新配置,并将这些组的分片分配给剩余的组。新配置应在组之间尽可能均匀地划分分片,并应移动尽可能少的分片以实现该目标;\nMove RPC。移动分片,的参数是一个分片号和一个 GID。 shardctrler 应该创建一个新配置,其中将分片分配给组。 Move 的目的是让我们能够测试您的软件。移动后的加入或离开可能会取消移动,因为加入和离开会重新平衡。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 // Join according to new Group(gid -\u0026gt; servers) to change the Config func (cf *MemoryConfigStateMachine) Join(groups map[int][]string) Err { lastConfig := cf.Configs[len(cf.Configs)-1] newConfig := Config{ Num: len(cf.Configs), Shards: lastConfig.Shards, Groups: deepCopy(lastConfig.Groups), } for gid, servers := range groups { if _, ok := newConfig.Groups[gid]; !ok { newServers := make([]string, len(servers)) copy(newServers, servers) newConfig.Groups[gid] = newServers } } // 找到group 中shard 最大和最小的组,将数据进行move =\u0026gt; reblance g2s := Group2Shards(newConfig) for { src, dst := GetGIDWIthMaxShards(g2s), GetGIDWithMinShards(g2s) if src != 0 \u0026amp;\u0026amp; len(g2s[src])-len(g2s[dst]) \u0026lt;= 1 { break } g2s[dst] = append(g2s[dst], g2s[src][0]) g2s[src] = g2s[src][1:] } var newShards [NShards]int for gid, shards := range g2s { for _, shard := range shards { newShards[shard] = gid } } newConfig.Shards = newShards cf.Configs = append(cf.Configs, newConfig) return OK } // Leave some group leave the cluster func (cf *MemoryConfigStateMachine) Leave(gids []int) Err { lastConfig := cf.Configs[len(cf.Configs)-1] newConfig := Config{ Num: len(cf.Configs), Shards: lastConfig.Shards, Groups: deepCopy(lastConfig.Groups), } g2s := Group2Shards(newConfig) orphanShards := make([]int, 0) for _, gid := range gids { delete(newConfig.Groups, gid) if shards, ok := g2s[gid]; ok { orphanShards = append(orphanShards, shards...) delete(g2s, gid) } } var newShards [NShards]int if len(newConfig.Groups) != 0 { // reblance for _, shard := range orphanShards { target := GetGIDWithMinShards(g2s) g2s[target] = append(g2s[target], shard) } // update Shards: share -\u0026gt; gid for gid, shards := range g2s { for _, shard := range shards { newShards[shard] = gid } } } newConfig.Shards = newShards cf.Configs = append(cf.Configs, newConfig) return OK } // Move move No.shard to No.gid func (cf *MemoryConfigStateMachine) Move(shard, gid int) Err { lastConfig := cf.Configs[len(cf.Configs)-1] newConfig := Config{ Num: len(cf.Configs), Shards: lastConfig.Shards, Groups: lastConfig.Groups, } newConfig.Shards[shard] = gid cf.Configs = append(cf.Configs, newConfig) return OK } // Query return the version of num config func (cf *MemoryConfigStateMachine) Query(num int) (Config, Err) { if num \u0026lt; 0 || num \u0026gt;= len(cf.Configs) { return cf.Configs[len(cf.Configs)-1], OK } return cf.Configs[num], OK } Lab4B ShardKV 实验提示:\n服务器不需要调用分片控制器的Join(),tester 才会去调用; 服务器将需要定期轮询 shardctrler 以监听新的配置。预期大约每100毫秒轮询一次;可以更频繁,但过少可能会导致 bug。 服务器需要互相发送rpc,以便在配置更改期间传输分片。shardctrler的Config结构包含服务器名,一个 Server 需要一个labrpc.ClientEnd,以便发送RPC。使用make_end()函数传给StartServer()函数将服务器名转换为ClientEnd。shardkv /client.go需要实现这些逻辑。 在server.go中添加代码去周期性从 shardctrler 拉取最新的配置,并且当请求分片不属于自身时,拒绝请求 当被请求到错误分片时,需要返回ErrWrongGroup给客户端,并确保Get, Put, Append在面临并发重配置时能正确作出决定 重配置需要按流程执行唯一一次 labgob 的提示错误不能忽视,它可能导致实验不过 分片重分配的请求也需要做重复请求检测 若客户端收到ErrWrongGroup,是否更改请求序列号? 若服务器执行请求时返回ErrWrongGroup,是否更新客户端信息? 当服务器转移到新配置后,它可以继续存储它不再负责的分片(生产环境中这是不允许的),但这个可以简化实现 当 G1 在配置变更时需要来自 G2 的分片数据,G2 处理日志条目的哪个时间点将分片发送给 G1 是最好的? 你可以在整个 rpc 请求或回复中发送整个 map,这可以简化分片传输 map 是引用类型,所以在发送 map 的时候,建议先拷贝一次,避免 data race(在 labrpc 框架下,接收 map 时也需要拷贝) 在配置更改期间,一对组可能需要互相传送分片,这可能会发生死锁 Challenge 如果想达到生产环境系统级别,如下两个挑战是需要实现的 Challenge1:Garbage collection of state 当一个副本组失去一个分片的所有权时,副本组需要删除该分片数据。但这给迁移带来一些问题,考虑两个组G1 和 G2,并且新配置C 将分片从 G1 移动到 G2,若 G1 在转换配置到C时删除了数据库中的分片,当G2 转换到C时,如何获取 G1 的数据 实验要求 使每个副本组保留旧分片的时长不再是无限时长,即使副本组(如上面的G1)中的所有服务器崩溃并恢复正常,解决方案也必须工作。如果您通过TestChallenge1Delete,您就完成了这个挑战。 解决方案 分片迁移成功之后,立马进行分片 GC 了,GC 完毕后再进入到配置更新阶段。 chanllenge2:Client requests during configuration changes 配置更改期间最简单的方式是禁止所有客户端操作直到转换完成,虽然简单但是不满足于生产环境要求,这将导致客户端长时间停滞,最好可以继续为不受当前配置更改的分片提供服务 上述优化还能更好,若 G3 在过渡到配置C时,需要来自G1 的分片S1 和 G2 的分片S2。希望 G3 能在收到其中一个分片后可以立即开始接收针对该分片的请求。如G1宕机了,G3在收到G2的分片数据后,可以立即为 S2 分片提供服务,而不需要等待 C 配置转换完全完成 实验要求 修改您的解决方案,以便在配置更改期间继续执行不受影响的分片中的 key 的客户端操作。当您通过 TestChallenge2Unaffected 测试时,您已经完成了这个挑战。 修改您的解决方案,在配置转换进行中,副本组也可以立即开始提供分片服务。当您通过TestChallenge2Partial测试时,您已经完成了这个挑战。 解决方案 分片迁移以 group 为单位,这样即使一个 group挂了,也不会影响到另一个 group中的分片迁移。 上面的实验ShardCtrler 集群组实现了配置更新,分片均匀分配等任务,ShardKVServer则需要承载所有分片的读写任务,相比于MIT 6.824 Lab3 RaftKV的提供基础的读写服务,还需要功能为配置更新,分片数据迁移,分片数据清理,空日志检测。\n实验逻辑 我们可以首先明确系统的运行方式:一开始系统会创建一个 shardctrler 组来负责配置更新,分片分配等任务,接着系统会创建多个 raft 组来承载所有分片的读写任务。此外,raft 组增删,节点宕机,节点重启,网络分区等各种情况都可能会出现。\n对于集群内部,我们需要保证所有分片能够较为均匀的分配在所有 raft 组上,还需要能够支持动态迁移和容错。\n对于集群外部,我们需要向用户保证整个集群表现的像一个永远不会挂的单节点 KV 服务一样,即具有线性一致性。\nlab4b 的基本测试要求了上述属性,challenge1 要求及时清理不再属于本分片的数据,challenge2 不仅要求分片迁移时不影响未迁移分片的读写服务,还要求不同地分片数据能够独立迁移,即如果一个配置导致当前 raft 组需要向其他两个 raft 组拉取数据时,即使一个被拉取数据的 raft 组全挂了,也不能导致另一个未挂的被拉取数据的 raft 组分片始终不能在当前 raft 组提供服务。\nStartServer: 启动Raft 节点和 Group configureAction: 监听是否由配置变化, 配置符合要求后执行NewConfigurationCommand RPC Apply 操作记录到日志中 migrationAction: 监听configureAction 结束后 根据最新配置进行数据迁移。会发送GetShardsData RPC pull shard 数据到resp *ShardOperationResponse 中,相当于拉到本地节点的某个变量中,这个过程可能会有大量数据的传输。此RPC 结束之后,发送InsertShardsCommand RPC 进行真实的数据迁移, 同样经过Raft 层多数节点同意后,应用到本地的状态机上。并把 需要Shard 状态修改好。Pulling 改为GCing 为下部做准备 gcAction: 在上面一步的applyInsertShards 中会把已经Pulling 的远程的Shard 改为Gcing。 在这个Goroutine 中,调用DeleteShardsData RPC, 会把ShardOperationRequest 中的Shard 通过发送 NewDeleteShardsCommand RPC 把状态为GCing 的Shards 改为Server,状态为BePulling 的重置。与此同时,DeleteShardsData RPC 结束OK 后,本节点也需要发送一遍NewDeleteShardsCommand RPC Command,把GCing 的Shards 改为默认状态。 架构图 1 2 3 4 5 6 7 8 9 2023/03/03 19:58:49 [StartServer]-{Node: 0}-{Group: 100} has started 2023/03/03 19:58:49 [StartServer]-{Node: 1}-{Group: 100} has started 2023/03/03 19:58:49 [StartServer]-{Node: 2}-{Group: 100} has started 2023/03/03 19:58:49 [StartServer]-{Node: 0}-{Group: 101} has started 2023/03/03 19:58:49 [StartServer]-{Node: 1}-{Group: 101} has started 2023/03/03 19:58:49 [StartServer]-{Node: 2}-{Group: 101} has started 2023/03/03 19:58:49 [StartServer]-{Node: 0}-{Group: 102} has started 2023/03/03 19:58:49 [StartServer]-{Node: 1}-{Group: 102} has started 2023/03/03 19:58:49 [StartServer]-{Node: 2}-{Group: 102} has started 根据Log 可以看出,集群以Group 为单位,初始化三个Group 管理十个Shard和三台节点。Shard 是真实存储数据的单位。 每个节点都有一个Raft 共识层,同一个Group 构成一个Raft Group, 整体形成Multi Raft Group, 以Group 为单位对应用层提供服务。Group 内部用Raft 保持数据的一致性。每个Group 有多少个Shard 由ShardCtrller 决定。Shard如果由副本也在各自Group 的各个节点上管理。\nNebula Graph 中 每个Shard及其副本构成一个Raft Group,Shard 的数量决定了Group 的数量。 本实验中,确定了只有最多三个Group\n客户端Clerk 主要请求逻辑:\n使用key2shard()去找到一个 key 对应哪个ShardShard; 根据Shard从当前配置config中获取的 gid; 根据gid从当前配置config中获取 group 信息; 在group循环查找leaderId,直到返回请求成功、ErrWrongGroup或整个 group 都遍历请求过; Query 最新的配置,回到步骤1循环重复; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 type Clerk struct { sc *shardctrler.Clerk config shardctrler.Config makeEnd func(string) *labrpc.ClientEnd // You will have to modify this struct. leaderIds map[int]int // {groupid: leader if hardid of this groupid} clientId int64 commandId int64 //clientId + commandId define unique operation } // 省略一些方法 func (ck *Clerk) Command(req *CommandRequest) string { req.ClientId, req.CommandId = ck.clientId, ck.commandId for { shard := key2shard(req.Key) gid := ck.config.Shards[shard] if servers, ok := ck.config.Groups[gid]; ok { // 找到Group 对应的LeaderId, 如果没有从Id 0 开始轮询 if _, ok := ck.leaderIds[gid]; !ok { ck.leaderIds[gid] = 0 } oldLeaderId := ck.leaderIds[gid] newLeaderId := oldLeaderId for { var resp CommandResponse ok := ck.makeEnd(servers[newLeaderId]).Call(\u0026#34;ShardKV.Command\u0026#34;, req, \u0026amp;resp) if ok \u0026amp;\u0026amp; (resp.Err == OK || resp.Err == ErrNoKey) { ck.commandId++ return resp.Value } else if ok \u0026amp;\u0026amp; resp.Err == ErrWrongGroup { break } else { // Err is ErrWrongLeader ErrOutDated ErrTimeout ErrNotReady newLeaderId = (newLeaderId + 1) % len(servers) if newLeaderId == oldLeaderId { // 所有server 轮询一遍之后退出,避免raft 集群处于无leader 状态中一直重试 break } continue } } } time.Sleep(100 * time.Millisecond) ck.config = ck.sc.Query(-1) } } 服务端Server 主要逻辑:\n客户端首先和ShardCtrler交互,获取最新的配置,根据最新配置找到对应key的shard,请求该shard的group。 服务端ShardKVServer会创建多个 raft 组来承载所有分片的读写任务。 服务端ShardKVServer需要定期和ShardCtrler交互,保证更新到最新配置(monitor)。 服务端ShardKVServer需要根据最新配置完成配置更新,分片数据迁移,分片数据清理,空日志检测等功- 能。 结构体 首先ShardKVServer给出结构体,相比于MIT 6.824 Lab3 RaftKV的多了currentConfig和lastConfig数据,这样其他协程便能够通过其计算需要需要向谁拉取分片或者需要让谁去删分片。 同时底层的StateMachine 也由MemeoryKV 变为Shard 承接。并给Shard 添加了状态信息。\n启动了五个协程:apply 协程,配置更新协程,数据迁移协程,数据清理协程,空日志检测协程来实现功能。四个协程都需要 leader 来执行,因此抽象出了一个简单地周期执行函数 Monitor。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 type Shard struct { KV map[string]string Status ShardStatus } type ShardKV struct { mu sync.RWMutex me int rf *raft.Raft dead int32 applyCh chan raft.ApplyMsg makeEnd func(string) *labrpc.ClientEnd gid int sc *shardctrler.Clerk maxRaftState int // snapshot if log grows this big lastApplied int // 记录applied Index 防止状态机apply 小的index lastConfig shardctrler.Config currentConfig shardctrler.Config stateMachine map[int]*Shard // {shardId: shard of KV} lastOperations map[int64]OperationContext // {clientId: ctx} notifyChans map[int]chan *CommandResponse // {commitIndex: commandResp} } func StartServer(servers []*labrpc.ClientEnd, me int, persister *raft.Persister, maxraftstate int, gid int, ctrlers []*labrpc.ClientEnd, make_end func(string) *labrpc.ClientEnd) *ShardKV { // call labgob.Register on structures you want // Go\u0026#39;s RPC library to marshall/unmarshall. labgob.Register(Command{}) labgob.Register(CommandRequest{}) labgob.Register(shardctrler.Config{}) labgob.Register(ShardOperationRequest{}) labgob.Register(ShardOperationResponse{}) applyCh := make(chan raft.ApplyMsg) kv := \u0026amp;ShardKV{ me: me, rf: raft.Make(servers, me, persister, applyCh), dead: 0, applyCh: applyCh, makeEnd: make_end, gid: gid, sc: shardctrler.MakeClerk(ctrlers), maxRaftState: maxraftstate, lastApplied: 0, lastConfig: shardctrler.DefaultConfig(), currentConfig: shardctrler.DefaultConfig(), stateMachine: make(map[int]*Shard), lastOperations: make(map[int64]OperationContext), notifyChans: make(map[int]chan *CommandResponse), } kv.restoreSnapshot(persister.ReadSnapshot()) // start applier goroutine to apply committed logs to stateMachine go kv.applier() // start configuration monitor goroutine to fetch latest configuration go kv.Monitor(kv.configureAction, ConfigureMonitorTimeout) // start migration monitor goroutine to pull related shards go kv.Monitor(kv.migrationAction, MigrationMonitorTimeout) // start gc monitor goroutine to delete useless shards in remote groups go kv.Monitor(kv.gcAction, GCMonitorTimeout) // start entry-in-currentTerm monitor goroutine to advance commitIndex by // appending empty entries in current term periodically to avoid live locks go kv.Monitor(kv.checkEntryIncurrentTermAction, EmptyEntryDetectorTimeout) DPrintf(\u0026#34;[StartServer]-{Node: %v}-{Group: %v} has started\u0026#34;, kv.me, kv.gid) return kv } 分片状态 每个分片共有 4 种状态:\nServing:分片的默认状态,如果当前 raft 组在当前 config 下负责管理此分片,则该分片可以提供读写服务,否则该分片暂不可以提供读写服务,但不会阻塞配置更新协程拉取新配置。\nPulling:表示当前 raft 组在当前 config 下负责管理此分片,暂不可以提供读写服务,需要当前 raft 组从上一个配置该分片所属 raft 组拉数据过来之后才可以提供读写服务,系统会有一个分片迁移协程检测所有分片的 Pulling 状态,接着以 raft 组为单位去对应远端 raft 组拉取数据,接着尝试重放该分片的所有数据到本地并将分片状态置为 Serving,以继续提供服务。\nBePulling:表示当前 raft 组在当前 config 下不负责管理此分片,不可以提供读写服务,但当前 raft 组在上一个 config 时负责管理此分片,因此当前 config 下负责管理此分片的 raft 组拉取完数据后会向本 raft 组发送分片清理的 rpc,接着本 raft 组将数据清空并重置为 serving 状态即可。\nGCing:表示当前 raft 组在当前 config 下负责管理此分片,可以提供读写服务,但需要清理掉上一个配置该分片所属 raft 组的数据。系统会有一个分片清理协程检测所有分片的 GCing 状态,接着以 raft 组为单位去对应远端 raft 组删除数据,一旦远程 raft 组删除数据成功,则本地会尝试将相关分片的状态置为 Serving。\n日志类型 在 lab3 中,客户端的请求会被包装成一个 Op 传给 Raft 层,则在 lab4 中,不难想到,Servers 之间的交互,也可以看做是包装成 Op 传给 Raft 层;定义了五种类型的日志:\nOperation:客户端传来的读写操作日志,有 Put,Get,Append 等请求。\nConfiguration:配置更新日志,包含一个配置。\nInsertShards:分片更新日志,包含至少一个分片的数据和配置版本。\nDeleteShards:分片删除日志,包含至少一个分片的 id 和配置版本。\nEmptyEntry:空日志,Data 为空,使得状态机达到最新。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 type Command struct { Op CommandType Data interface{} } func (cmd Command) String() string { return fmt.Sprintf(\u0026#34;{Op: %v, Data: %v}\u0026#34;, cmd.Op, cmd.Data) } func NewOperationCommand(req *CommandRequest) Command { return Command{ Op: Operation, Data: *req, } } func NewConfigurationCommand(config *shardctrler.Config) Command { return Command{ Op: Configuration, Data: *config, } } func NewInsertShardsCommand(response *ShardOperationResponse) Command { return Command{InsertShards, *response} } func NewDeleteShardsCommand(request *ShardOperationRequest) Command { return Command{DeleteShards, *request} } func NewEmptyEntryCommand() Command { return Command{EmptyEntry, nil} } // ------------------------------------------------------------- type CommandType uint8 const ( Operation CommandType = iota Configuration InsertShards DeleteShards EmptyEntry ) var ctmap = [...]string{ \u0026#34;Operation\u0026#34;, \u0026#34;Configuration\u0026#34;, \u0026#34;InsertShards\u0026#34;, \u0026#34;DeleteShards\u0026#34;, \u0026#34;EmptyEntry\u0026#34;, } func (ct CommandType) String() string { return ctmap[ct] } 读写服务 读写操作的基本逻辑相比于MIT 6.824 Lab3 RaftKV基本一致,需要增加分片状态判断。根据上述定义,分片的状态为 Serving 或 GCing,当前 raft 组在当前 config 下负责管理此分片,本 raft 组才可以为该分片提供读写服务,否则返回 ErrWrongGroup 让客户端重新拉取最新的 config 并重试即可。\ncanServe 的判断需要在向 raft 提交前和 apply 时都检测一遍以保证正确性并尽可能提升性能。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 // canServe 判断shard 的状态是否可以对外服务 // Serving 默认初始状态, GCing 表示该shard 的数据刚刚拉取完毕,但是需要清除 // 远端 该shardId 数据 func (kv *ShardKV) canServe(ShardId int) bool { return kv.currentConfig.Shards[ShardId] == kv.gid \u0026amp;\u0026amp; (kv.stateMachine[ShardId].Status == Serving || kv.stateMachine[ShardId].Status == GCing) } func (kv *ShardKV) Command(req *CommandRequest, resp *CommandResponse) { kv.mu.RLock() if req.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicateRequest(req.ClientId, req.CommandId) { lastResp := kv.lastOperations[req.ClientId].LastResponse resp.Err = lastResp.Err resp.Value = lastResp.Value kv.mu.RUnlock() return } // return ErrWrongGroup directly to let client fetch latest configuration // and perform a retry if this key can\u0026#39;t be served by this shard at present if !kv.canServe(key2shard(req.Key)) { resp.Err = ErrWrongGroup resp.Value = \u0026#34;\u0026#34; kv.mu.RUnlock() return } kv.mu.RUnlock() kv.Execute(NewOperationCommand(req), resp) } // Execute shardKV 执行相关的RPC req func (kv *ShardKV) Execute(command Command, resp *CommandResponse) { // do not hold lock to improve throughput // when KVServer holds the lock to take snapshot, underlying raft can still commit raft logs index, _, isLeader := kv.rf.Start(command) if !isLeader { resp.Err = ErrWrongLeader return } defer DPrintf(\u0026#34;[Execute]-{Node: %v}-{Group: %v} process Command %v with CommandResponse %v\u0026#34;, kv.me, kv.gid, command, resp) kv.mu.Lock() ch := kv.getNotifyChan(index) kv.mu.Unlock() select { case res := \u0026lt;-ch: resp.Value, resp.Err = res.Value, res.Err case \u0026lt;-time.After(ExecuteTimeout): resp.Err = ErrTimeout } go func() { kv.mu.Lock() kv.deleteOutdatedNotifyChan(index) kv.mu.Unlock() }() } // applyOperation 对状态机的操作, Get, Put, Append func (kv *ShardKV) applyOperation(msg *raft.ApplyMsg, req *CommandRequest) *CommandResponse { var resp *CommandResponse shardId := key2shard(req.Key) if kv.canServe(shardId) { if req.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicateRequest(req.ClientId, req.CommandId) { DPrintf(\u0026#34;[applyOperation]-{Node: %v}-{Group: %v} doesn\u0026#39;t apply duplicated message %v to stateMachine because maxAppliedCommandId is %v for client %v\u0026#34;, kv.me, kv.gid, kv.lastOperations[req.ClientId], kv.lastApplied, req.ClientId) lastResp := kv.lastOperations[req.ClientId].LastResponse return lastResp } resp = kv.applyLogToStateMachines(req, shardId) if req.Op != OpGet { // save max command resp kv.lastOperations[req.ClientId] = OperationContext{ MaxAppliedCommandId: req.CommandId, LastResponse: resp, } } return resp } return \u0026amp;CommandResponse{ErrWrongGroup, \u0026#34;\u0026#34;} } 配置更新 配置更新协程负责定时检测所有分片的状态,一旦存在至少一个分片的状态不为默认状态,则预示其他协程仍然还没有完成任务,那么此时需要阻塞新配置的拉取和提交。\n在 apply 配置更新日志时需要保证幂等性:\n不同版本的配置更新日志:apply 时仅可逐步递增的去更新配置,否则返回失败。 相同版本的配置更新日志:由于配置更新日志仅由配置更新协程提交,而配置更新协程只有检测到比本地更大地配置时才会提交配置更新日志,所以该情形不会出现。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 // configureAction kvctrller execute apply configuration func (kv *ShardKV) configureAction() { canPerformNextConfig := true kv.mu.RLock() for _, shard := range kv.stateMachine { if shard.Status != Serving { canPerformNextConfig = false DPrintf(\u0026#34;[configureAction]-{Node: %v}-{Group: %v} will not try to fetch latest configuration because shards status are %v when currentConfig is %v\u0026#34;, kv.me, kv.gid, kv.getShardStatus(), kv.currentConfig) break } } currentConfigNum := kv.currentConfig.Num kv.mu.RUnlock() if canPerformNextConfig { nextConfig := kv.sc.Query(currentConfigNum + 1) if nextConfig.Num == currentConfigNum+1 { DPrintf(\u0026#34;[configureAction]-{Node: %v}-{Group: %v} fetches latest configuration %v when currentConfigNum is %v\u0026#34;, kv.me, kv.gid, nextConfig, currentConfigNum) kv.Execute(NewConfigurationCommand(\u0026amp;nextConfig), \u0026amp;CommandResponse{}) } } } // applyConfiguration 对kv controller 的配置进行更新 func (kv *ShardKV) applyConfiguration(conf *shardctrler.Config) *CommandResponse { if conf.Num == kv.currentConfig.Num+1 { DPrintf(\u0026#34;[applyConfiguration]-{Node: %v}-{Group: %v} updates currentConfig from %v to %v\u0026#34;, kv.me, kv.gid, kv.currentConfig, conf) kv.updateShardStatus(conf) kv.lastConfig = kv.currentConfig kv.currentConfig = *conf return \u0026amp;CommandResponse{OK, \u0026#34;\u0026#34;} } DPrintf(\u0026#34;[applyConfiguration]-{Node: %v}-{Group: %v} rejects outdated config %v when currentConfig is %v\u0026#34;, kv.me, kv.gid, conf, kv.currentConfig) return \u0026amp;CommandResponse{ErrOutDated, \u0026#34;\u0026#34;} } 分片迁移 分片迁移协程负责定时检测分片的 Pulling 状态,利用 lastConfig 计算出对应 raft 组的 gid 和要拉取的分片,然后并行地去拉取数据。\n注意这里使用了 waitGroup 来保证所有独立地任务完成后才会进行下一次任务。此外 wg.Wait() 一定要在释放读锁之后,否则无法满足 challenge2 的要求。\n在拉取分片的 handler 中,首先仅可由 leader 处理该请求,其次如果发现请求中的配置版本大于本地的版本,那说明请求拉取的是未来的数据,则返回 ErrNotReady 让其稍后重试,否则将分片数据和去重表都深度拷贝到 response 即可。\n在 apply 分片更新日志时需要保证幂等性:\n不同版本的配置更新日志:仅可执行与当前配置版本相同地分片更新日志,否则返回 ErrOutDated。 相同版本的配置更新日志:仅在对应分片状态为 Pulling 时为第一次应用,此时覆盖状态机即可并修改状态为 GCing,以让分片清理协程检测到 GCing 状态并尝试删除远端的分片。否则说明已经应用过,直接 break 即可。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 // migrationAction shard migration data when in Pulling status func (kv *ShardKV) migrationAction() { kv.mu.RLock() gid2shardIds := kv.getShardIdsByStatus(Pulling) var wg sync.WaitGroup for gid, shardIds := range gid2shardIds { DPrintf(\u0026#34;[migrationAction]-{Node: %v}-{Group: %v} starts a PullTask to get shards %v from group %v when config is %v\u0026#34;, kv.me, kv.gid, shardIds, gid, kv.currentConfig) wg.Add(1) go func(servers []string, configNum int, shardIds []int) { defer wg.Done() PullTaskRequest := ShardOperationRequest{ ConfigNum: configNum, ShardIDs: shardIds, } // 本Node 向Pulling Status 的Shard 所在Group 的全部Server // 发送RPC 但是只有Leader 有响应,其他忽略 for _, server := range servers { var pullTaskResp ShardOperationResponse srv := kv.makeEnd(server) DPrintf(\u0026#34;[migrationAction]-{Node: %v}-{Group: %v} server call %v\u0026#34;, kv.me, kv.gid, server) if srv.Call(\u0026#34;ShardKV.GetShardsData\u0026#34;, \u0026amp;PullTaskRequest, \u0026amp;pullTaskResp) \u0026amp;\u0026amp; pullTaskResp.Err == OK { DPrintf(\u0026#34;[migrationAction]-{Node: %v}-{Group: %v} gets a PullTaskResponse %v and tries to commit it when currentConfigNum is %v\u0026#34;, kv.me, kv.gid, pullTaskResp, configNum) kv.Execute(NewInsertShardsCommand(\u0026amp;pullTaskResp), \u0026amp;CommandResponse{}) } } }(kv.lastConfig.Groups[gid], kv.currentConfig.Num, shardIds) } kv.mu.RUnlock() wg.Wait() } // RPC GetShardsData ConfigOperation 生效之后数据迁移, 将request 中shardId的数据,迁移到resp中 返回给调用方 func (kv *ShardKV) GetShardsData(req *ShardOperationRequest, resp *ShardOperationResponse) { // ... } func (kv *ShardKV) applyInsertShards(shardsInfo *ShardOperationResponse) *CommandResponse { // ... } 分片清理 分片清理协程负责定时检测分片的 GCing 状态,利用 lastConfig 计算出对应 raft 组的 gid 和要拉取的分片,然后并行地去删除分片。\n注意这里使用了 waitGroup 来保证所有独立地任务完成后才会进行下一次任务。此外 wg.Wait() 一定要在释放读锁之后,否则无法满足 challenge2 的要求。\n在删除分片的 handler 中,首先仅可由 leader 处理该请求,其次如果发现请求中的配置版本小于本地的版本,那说明该请求已经执行过,否则本地的 config 也无法增大,此时直接返回 OK 即可,否则在本地提交一个删除分片的日志。\n在 apply 分片删除日志时需要保证幂等性:\n不同版本的配置更新日志:仅可执行与当前配置版本相同地分片删除日志,否则已经删除过,直接返回 OK 即可。 相同版本的配置更新日志:如果分片状态为 GCing,说明是本 raft 组已成功删除远端 raft 组的数据,现需要更新分片状态为默认状态以支持配置的进一步更新;否则如果分片状态为 BePulling,则说明本 raft 组第一次删除该分片的数据,此时直接重置分片即可。否则说明该请求已经应用过,直接 break 返回 OK 即可。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 // gcAction Pulling 数据之后把状态改为GCing,调用RPC 删除远端的该ShardId 的数据 func (kv *ShardKV) gcAction() { kv.mu.RLock() gid2shardIds := kv.getShardIdsByStatus(GCing) var wg sync.WaitGroup for gid, shardIds := range gid2shardIds { DPrintf(\u0026#34;[gcAction]-{Node: %v}-{Group: %v} starts a GCTask to delete shards %v in group %v when config is %v\u0026#34;, kv.me, kv.gid, shardIds, gid, kv.currentConfig) wg.Add(1) go func(servers []string, configNum int, shardIds []int) { defer wg.Done() gcTaskReq := ShardOperationRequest{ConfigNum: configNum, ShardIDs: shardIds} for _, server := range servers { var gcTaskResp ShardOperationResponse srv := kv.makeEnd(server) // 远端执行删除数据的逻辑 if srv.Call(\u0026#34;ShardKV.DeleteShardsData\u0026#34;, \u0026amp;gcTaskReq, \u0026amp;gcTaskResp) \u0026amp;\u0026amp; gcTaskResp.Err == OK { DPrintf(\u0026#34;[gcAction]-{Node: %v}-{Group: %v} deletes shards %v in remote group successfully when currentConfigNum is %v\u0026#34;, kv.me, kv.gid, shardIds, configNum) // 远端删除完数据之后,Raft 本节点同样需要 把GCing 状态恢复为Server 状态 kv.Execute(NewDeleteShardsCommand(\u0026amp;gcTaskReq), \u0026amp;CommandResponse{}) } } }(kv.lastConfig.Groups[gid], kv.currentConfig.Num, shardIds) } kv.mu.RUnlock() wg.Wait() } // RPC DeleteShardsData 删除迁移之后的 shard 中的数据 func (kv *ShardKV) DeleteShardsData(req *ShardOperationRequest, resp *ShardOperationResponse) { if _, isLeader := kv.rf.GetState(); !isLeader { resp.Err = ErrWrongLeader return } defer DPrintf(\u0026#34;[DeleteShardsData]-{Node: %v}-{Group: %v} processes GCTaskRequest %v with response %v\u0026#34;, kv.me, kv.gid, req, resp) kv.mu.RLock() if kv.currentConfig.Num \u0026gt; req.ConfigNum { DPrintf(\u0026#34;[DeleteShardsData]-{Node: %v}-{Group: %v} encounters duplicated shards deletions %v when currentConfig is %v\u0026#34;, kv.me, kv.gid, req, kv.currentConfig) resp.Err = OK kv.mu.RUnlock() return } kv.mu.RUnlock() var commandResp CommandResponse kv.Execute(NewDeleteShardsCommand(req), \u0026amp;commandResp) resp.Err = commandResp.Err } 空日志检测 分片清理协程负责定时检测 raft 层的 leader 是否拥有当前 term 的日志,如果没有则提交一条空日志,这使得新 leader 的状态机能够迅速达到最新状态,从而避免多 raft 组间的活锁状态。\n1 2 3 4 5 6 7 8 9 10 func (kv *ShardKV) checkEntryIncurrentTermAction() { if !kv.rf.HasLogInCurrentTerm() { kv.Execute(NewEmptyEntryCommand(), \u0026amp;CommandResponse{}) } } func (kv *ShardKV) applyEmptyEntry() *CommandResponse { return \u0026amp;CommandResponse{OK, \u0026#34;\u0026#34;} } 问题 出现随机的get(x) != expect(x)这种错误,并且发生情况在raft stop后,applyInsertShards之后,看起来像apply一个duplicated的msg,从而产生了get(x)=\u0026ldquo;xabb” != expect(x)=\u0026ldquo;xab” 这个分析了很久,并且重构了日志,让每个gid在一个logfile里。最后发现原因:raft重启后,会重新apply所有logs,这时config change,开始pull shards,待insertShards完成后。raft收到落后的command,导致又apply了duplicate的数据。\n解决方案,在applyInsertShards同时,将reply中的lastOperation来更新自己的session。因此,当下次applyMsg来的时候,就可以根据client的SequenceNum来判断 是否接受这个applyMsg,防止了duplicated的msg。\n","permalink":"https://reid00.github.io/en/posts/storage/20230214-mit6.824-2022-lab4-shardedkv/","summary":"ShardedKV 介绍 有关 shardkv,其可以算是一个 multi-raft 的实现,只是缺少了物理节点的抽象概念。在实际的生产系统中,不同 raft 组的成员可能存在于一个物理节点上,","title":"20230214 MIT6.824 2022 Lab4 ShardedKV"},{"content":"介绍 在lab2的Raft函数库之上,搭建一个能够容错的key/value存储服务,需要提供强一致性保证。\n强一致性介绍 对于单个请求,整个服务需要表现得像个单机服务,并且对状态机的修改基于之前所有的请求。对于并发的请求,返回的值和最终的状态必须相同,就好像所有请求都是串行的一样。即使有些请求发生在了同一时间,那么也应当一个一个响应。此外,在一个请求被执行之前,这之前的请求都必须已经被完成(在技术上我们也叫着线性化(linearizability))。 kv服务支持三种操作:Put, Append, Get。通过在内存维护一个简单的键/值对数据库,键和值都是字符串;\n整体架构 简化来看 在正式开始前,要了解论文-extend-version中section 7和8的内容。\n相关的RPC 在Raft 作者的博士论文中的6.3- Implementing linearizable semantics 小结有很详细的介绍,建议先阅读。\nRPC Lab3A - 不需要日志压缩的Key/Value服务 考虑这样一个场景,客户端向服务端提交了一条日志,服务端将其在 raft 组中进行了同步并成功 commit,接着在 apply 后返回给客户端执行结果。然而不幸的是,该 rpc 在传输中发生了丢失,客户端并没有收到写入成功的回复。因此,客户端只能进行重试直到明确地写入成功或失败为止,这就可能会导致相同地命令被执行多次,从而违背线性一致性。\n有人可能认为,只要写请求是幂等的,那重复执行多次也是可以满足线性一致性的,实际上则不然。考虑这样一个例子:对于一个仅支持 put 和 get 接口的 raftKV 系统,其每个请求都具有幂等性。设 x 的初始值为 0,此时有两个并发客户端,客户端 1 执行 put(x,1),客户端 2 执行 get(x) 再执行 put(x,2),问(客户端 2 读到的值,x 的最终值)是多少。对于线性一致的系统,答案可以是 (0,1),(0,2) 或 (1,2)。然而,如果客户端 1 执行 put 请求时发生了上段描述的情况,然后客户端 2 读到 x 的值为 1 并将 x 置为了 2,最后客户端 1 超时重试且再次将 x 置为 1。对于这种场景,答案是 (1,1),这就违背了线性一致性。归根究底还是由于幂等的 put(x,1) 请求在状态机上执行了两次,有两个 LZ 点。因此,即使写请求的业务语义能够保证幂等,不进行额外的处理让其重复执行多次也会破坏线性一致性。当然,读请求由于不改变系统的状态,重复执行多次是没问题的。\n对于这个问题,raft 作者介绍了想要实现线性化语义,就需要保证日志仅被执行一次,即它可以被 commit 多次,但一定只能 apply 一次。其解决方案原文如下:\nThe solution is for clients to assign unique serial numbers to every command. Then, the state machine tracks the latest serial number processed for each client, along with the associated response. If it receives a command whose serial number has already been executed, it responds immediately without re-executing the request.\n思路可以是:\n每个 client 都需要一个唯一的标识符,它的每个不同命令需要有一个顺序递增的 commandId,clientId 和这个 commandId,clientId 可以唯一确定一个不同的命令,从而使得各个 raft 节点可以记录保存各命令是否已应用以及应用以后的结果。 也可以参考此处dragonboat 作者讨论\n为什么要记录应用的结果?因为通过这种方式同一个命令的多次 apply 最终只会实际应用到状态机上一次,之后相同命令 apply 的时候实际上是不应用到状态机上的而是直接从保存的结果中返回的。\n如果默认一个客户端只能串行执行请求的话,服务端这边只需要记录一个 map,其 key 是 clientId,其 value 是该 clientId 执行的最后一条日志的 commandId 和状态机的输出即可CommandResponse。\n客户端 一个 client 可以通过为其处理的每条命令递增 commandId 的方式来确保不同的命令一定有不同的 commandId,当然,同一条命令的 commandId 在没有处理完毕之前,即明确收到服务端的写入成功或失败之前是不能改变的。\n代码如下:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 package kvraft import ( \u0026#34;crypto/rand\u0026#34; \u0026#34;math/big\u0026#34; \u0026#34;6.824/labrpc\u0026#34; ) type Clerk struct { servers []*labrpc.ClientEnd // You will have to modify this struct. leaderId int64 // generated by nrand(), it would be better to use some distributed ID // generation algorithm that guarantees no conflicts clientId int64 commandId int64 // (clientId, commandId) defines a operation uniquely } func nrand() int64 { max := big.NewInt(int64(1) \u0026lt;\u0026lt; 62) bigx, _ := rand.Int(rand.Reader, max) x := bigx.Int64() return x } func MakeClerk(servers []*labrpc.ClientEnd) *Clerk { return \u0026amp;Clerk{ servers: servers, leaderId: 0, clientId: nrand(), commandId: 0, } } // fetch the current value for a key. // returns \u0026#34;\u0026#34; if the key does not exist. // keeps trying forever in the face of all other errors. // // you can send an RPC with code like this: // ok := ck.servers[i].Call(\u0026#34;KVServer.Get\u0026#34;, \u0026amp;args, \u0026amp;reply) // // the types of args and reply (including whether they are pointers) // must match the declared types of the RPC handler function\u0026#39;s // arguments. and reply must be passed as a pointer. func (ck *Clerk) Get(key string) string { // You will have to modify this function. return ck.Command(\u0026amp;CommandRequest{ Key: key, Op: OpGet, ClientId: ck.clientId, CommandId: ck.commandId, }) } func (ck *Clerk) Put(key string, value string) { ck.Command(\u0026amp;CommandRequest{ Key: key, Value: value, Op: OpPut, ClientId: ck.clientId, CommandId: ck.commandId, }) } func (ck *Clerk) Append(key string, value string) { ck.Command(\u0026amp;CommandRequest{ Key: key, Value: value, Op: OpAppend, ClientId: ck.clientId, CommandId: ck.commandId, }) } func (ck *Clerk) Command(req *CommandRequest) string { // req.ClientId, req.CommandId = ck.clientId, ck.commandId for { var resp CommandResponse if !ck.servers[ck.leaderId].Call(\u0026#34;KVServer.Command\u0026#34;, req, \u0026amp;resp) || resp.Err == ErrWrongLeader || resp.Err == ErrTimeout { // 不知leader 轮询所有的server 尝试发出请求 ck.leaderId = (ck.leaderId + 1) % int64(len(ck.servers)) continue } ck.commandId++ return resp.Value } } 服务端 整体请求逻辑如下: Server结构体与初始化代码实现:\n一个存储kv的map,即状态机,但这里实现一个基于内存版本KV即可的,但实际生产环境下必然不可能把数据全部存在内存当中,系统往往采用的是 LSM 的架构,例如 RocksDB 等,抽象成KVStateMachine 的接口。 一个能记录某一个客户端最后一次操作序号和应用结果的map lastOperations (类比Nebula 中的session 作用) 一个能记录每个raft同步操作结果的map notifyChans 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 type KVServer struct { mu sync.RWMutex me int rf *raft.Raft applyCh chan raft.ApplyMsg dead int32 // set by Kill() maxRaftState int // snapshot if log grows this big // Your definitions here. lastApplied int // record the lastApplied index to prevent stateMachine from rollback stateMachine KVStateMachine // KV stateMachine // 客户端id最后的命令id和回复内容 (clientId,{最后的commdId,最后的LastReply}) lastOperations map[int64]OperationContext // Leader回复给客户端的响应(LogIndex, CommandResponse notifyChans map[int]chan *CommandResponse } 应用到状态机的流程 kv.applier协程:单独开一个goroutine来远程监听 Raft 的apply channel,一旦底层的Raft commit一个到apply channel,状态机就立马执行且通过 commandIndex(即raft 中的CommitIndex) 通知到该客户端的NotifyChan, Command函数取消阻塞返回给客户端。\n要点:\nraft同步完成后,也需要判断请求是否为重复请求。因为同一请求可能由于重试会被同步多次。 对于客户端的请求,rpc 框架也会生成一个协程去处理逻辑。因此,需要考虑清楚这些协程之间的通信关系。为此,我的实现是客户端协程将日志放入 raft 层去同步后即注册一个 channel 去阻塞等待,接着 apply 协程监控 applyCh,在得到 raft 层已经 commit 的日志后,apply 协程首先将其 apply 到状态机中,接着根据 index 得到对应的 channel ,最后将状态机执行的结果 push 到 channel 中,这使得客户端协程能够解除阻塞并回复结果给客户端 为了保证强一致性,仅对当前 term 日志的 notifyChan 进行通知,让之前 term 的客户端协程都超时重试。避免leader 降级为 follower 后又迅速重新当选了 leader,而此时依然有客户端协程未超时在阻塞等待,那么此时 apply 日志后,根据 index 获得 channel 并向其中 push 执行结果就可能出错,因为可能并不对应。 在目前的实现中,读(Get)请求也会生成一条 raft 日志去同步,最简单粗暴的方式保证线性一致性,即LogRead方法。但是,这样子实现的读性能会相当的差,实际生产级别的 raft 读请求实现一般都采用了 Read Index 或者 Lease Read 的方式,具体原理可以参考此线性一致性博客,具体实现可以参照 SOFAJRaft 的实现博客。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 func (kv *KVServer) applier() { for !kv.killed() { for msg := range kv.applyCh { DPrintf(\u0026#34;[applier] - {Node: %v} tries to apply message %v\u0026#34;, kv.rf.Me(), msg) if msg.CommandValid { kv.mu.Lock() if msg.CommandIndex \u0026lt;= kv.lastApplied { DPrintf(\u0026#34;[applier] - {Node: %v} discards outdated message %v since a newer snapshot which lastapplied is %v has been restored\u0026#34;, kv.rf.Me(), msg, kv.lastApplied) kv.mu.Unlock() continue } kv.lastApplied = msg.CommandIndex var resp = new(CommandResponse) command := msg.Command.(Command) if command.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicatedReq(command.ClientId, command.CommandId) { DPrintf(\u0026#34;[applier] - {Node: %v} doesn\u0026#39;t apply duplicated message %v to state machine since maxAppliedCommandId is %v for client %v\u0026#34;, kv.rf.Me(), msg, kv.lastOperations[command.ClientId], command.ClientId) resp = kv.lastOperations[command.ClientId].LastResponse } else { resp = kv.applyLogToStateMachine(command) if command.Op != OpGet { kv.lastOperations[command.ClientId] = OperationContext{ MaxAppliedCommandId: command.CommandId, LastResponse: resp, } } } // 记录每个idx apply 到state machine 的 CommandResponse // 为了保证强一致性,仅对当前 term 日志的 notifyChan 进行通知, // 让之前 term 的客户端协程都超时重试。避免leader 降级为 follower // 后又迅速重新当选了 leader,而此时依然有客户端协程未超时在阻塞等待, // 那么此时 apply 日志后,根据 index 获得 channel 并向其中 push 执行结果就可能出错,因为可能并不对应 if currentTerm, isLeader := kv.rf.GetState(); isLeader \u0026amp;\u0026amp; msg.CommandTerm == currentTerm { ch := kv.getNotifyChan(msg.CommandIndex) ch \u0026lt;- resp } // part 2 needSnapshot := kv.needSnapshot() if needSnapshot { kv.takeSnapshot(msg.CommandIndex) } kv.mu.Unlock() } else if msg.SnapshotValid { kv.mu.Lock() if kv.rf.CondInstallSnapshot(msg.SnapshotTerm, msg.SnapshotIndex, msg.Snapshot) { kv.restoreSnapshot(msg.Snapshot) kv.lastApplied = msg.SnapshotIndex } kv.mu.Unlock() } else { panic(fmt.Sprintf(\u0026#34;unexpected Message: %v\u0026#34;, msg)) } } } } leader 比 follower 多出一个 notifyChan 环节,是因为 leader 需要处理 rpc 请求响应,而 follower 不用,一个很简单的流程其实就是 client -\u0026gt; kvservice -\u0026gt; Start() -\u0026gt; applyCh -\u0026gt; kvservice -\u0026gt; client,但是applyCh是逐个 commit 一个一个返回,所以需要明确返回的 commit 对应的是哪一个请求,即通过 commitIndex唯一确定一个请求,然后通知该请求执行流程可以返回了。\n对于读请求,由于其不影响系统状态,所以直接去状态机执行即可,当然,其结果也不需要再记录到去重的数据结构中。\nCommandRPC 逻辑 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 // Command 客户端调用的RPC方法 func (kv *KVServer) Command(req *CommandRequest, resp *CommandResponse) { defer DPrintf(\u0026#34;[Command]- {Node: %v} processes CommandReq %v with CommandResp %v\u0026#34;, kv.rf.Me(), req, resp) // 如果请求是重复的,直接在 OperationContext 中拿到之前的结果返回 kv.mu.RLock() if req.Op != OpGet \u0026amp;\u0026amp; kv.isDuplicatedReq(req.ClientId, req.CommandId) { lastResp := kv.lastOperations[req.ClientId].LastResponse resp.Value, resp.Err = lastResp.Value, lastResp.Err kv.mu.RUnlock() return } kv.mu.RUnlock() idx, _, isLeader := kv.rf.Start(Command{req}) if !isLeader { resp.Err = ErrWrongLeader return } kv.mu.Lock() ch := kv.getNotifyChan(idx) kv.mu.Unlock() select { case result := \u0026lt;-ch: resp.Value, resp.Err = result.Value, result.Err case \u0026lt;-time.After(ExecuteTimeOut): resp.Err = ErrTimeout } go func() { kv.mu.Lock() kv.removeOutdatedNotifyChan(idx) kv.mu.Unlock() }() } Lab3B - 日志压缩 首先,日志的 snapshot 不仅需要包含状态机的状态,还需要包含用来去重的 lastOperations 哈希表。\n其次,apply 协程负责持锁阻塞式的去生成 snapshot,幸运的是,此时 raft 框架是不阻塞的,依然可以同步并提交日志,只是不 apply 而已。如果这里还想进一步优化的话,可以将状态机搞成 MVCC 等能够 COW 的机制,这样应该就可以不阻塞状态机的更新了\n优化: 项目中 LastOperations 和 NotifyChan 都是使用map 不能并发安全,用了一张大锁保平安。 实际上可以使用Sync.Map 然后将锁的粒度细化来优化这块\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-lab3-raftkv/","summary":"介绍 在lab2的Raft函数库之上,搭建一个能够容错的key/value存储服务,需要提供强一致性保证。 强一致性介绍 对于单个请求,整个服务需","title":"MIT6.824 2022 Lab3 RaftKV"},{"content":"介绍 linearizable read 简单的说就是不返回 stale 数据,具体可以参考Strong consistency models\nRead Index 机制就是 Leader 在收到读请求时进行如下几步:\n如果 Leader 在当前任期还没有提交过日志,先提交一条空日志 Leader 保存记录当前 commit index 作为 readIndex 通过心跳,询问成员自己还是不是 Leader,如果收到过半的确认,则可确信自己仍是 Leader 等待 Apply Index 超过 readIndex 读取数据,响应 Client etcd不仅实现了leader上的read only query,同时也实现了follower上的read only query,原理是一样的,只不过读请求到达follower时,commit index是需要向leader去要的,leader返回commit index给follower之前,同样,需要走上面的ReadIndex流程,因为leader同样需要check自己到底还是不是leader\nReadIndex 思路 在论文中 第八节,page13 有提到过大概思路:\nRead-only operations can be handled without writing anything into the log. However, with no additional measures, this would run the risk of returning stale data, since the leader responding to the request might have been superseded by a newer leader of which it is unaware. Linearizable reads must not return stale data, and Raft needs two extra precautions to guarantee this without using the log. First, a leader must have the latest information on which entries are committed. The Leader Completeness Property guarantees that a leader has all committed entries, but at the start of its term, it may not know which those are. To find out, it needs to commit an entry from its term. Raft handles this by having each leader commit a blank no-op entry into the log at the start of its term. Second, a leader must check whether it has been deposed before processing a read-only request (its information may be stale if a more recent leader has been elected). Raft handles this by having the leader exchange heartbeat messages with a majority of the cluster before responding to read-only requests. Alternatively, the leader could rely on the heartbeat mechanism to provide a form of lease [9], but this would rely on timing for safety (it assumes bounded clock skew).\n在收到读请求时,leader 节点保存下当前的 commit index,并往 peers 发送心跳。如果确定该节点依然是 leader,则只需要等到该 commit index 的 log entry 被 apply 到状态机时就可以返回客户端结果。\nLeaseRead 思路 LeaseRead 与 ReadIndex 类似,但更进一步,不仅省去了 Log,还省去了网络交互。它可以大幅提升读的吞吐也能显著降低延时。基本的思路是 Leader 取一个比 Election Timeout 小的租期,在租期不会发生选举,确保 Leader 不会变,所以可以跳过 ReadIndex 的第二步,也就降低了延时。 LeaseRead 的正确性和时间挂钩,因此时间的实现至关重要,如果漂移严重,这套机制就会有问题。\nWait Free 到此为止 Lease 省去了 ReadIndex 的第二步,实际能再进一步,省去第 3 步。这样的 LeaseRead 在收到请求后会立刻进行读请求,不取 commit index 也不等状态机。由于 Raft 的强 Leader 特性,在租期内的 Client 收到的 Resp 由 Leader 的状态机产生,所以只要状态机满足线性一致,那么在 Lease 内,不管何时发生读都能满足线性一致性。有一点需要注意,只有在 Leader 的状态机应用了当前 term 的第一个 Log 后才能进行 LeaseRead。因为新选举产生的 Leader,它虽然有全部 committed Log,但它的状态机可能落后于之前的 Leader,状态机应用到当前 term 的 Log 就保证了新 Leader 的状态机一定新于旧 Leader,之后肯定不会出现 stale read。 可以参考post\nEtcd 源码实现 先留个坑,暂时没时间,后续有时间再分析。\n","permalink":"https://reid00.github.io/en/posts/storage/raft-etcd-%E4%B9%8B-linearizable-read/","summary":"介绍 linearizable read 简单的说就是不返回 stale 数据,具体可以参考Strong consistency models Read Index 机制就是 Leader 在收到读请求时进行如下几步: 如果 Leader 在当前任期还没有提交过日志,先","title":"Raft Etcd 之 Linearizable Read"},{"content":"如果允许提交之前任期的日志,将导致什么问题? 我们将论文中的上图展开:\n(a): S1 是leader,将黄色的日志2同步到了S2,然后S1崩溃。 (b): S5 在任期 3 里通过 S3、S4 和自己的选票赢得选举,将蓝色日志3存储到本地,然后崩溃了。 (c): S1重新启动,选举成功。注意在这时,如果允许提交之前任期的日志,将首先开始同步过往任期的日志,即将S1上的本地黄色的日志2同步到了S3。这时黄色的节点2已经同步到了集群多数节点,然后S1写了一条新日志4,然后S1又崩溃了。 接下来会出现两种不同的情况: (d1): S5重新当选,如果允许提交之前任期的日志,就开始同步往期日志,将本地的蓝色日志3同步到所有的节点。结果已经被同步到半数以上节点的黄色日志2被覆盖了。这说明,如果允许“提交之前任期的日志”,会可能出现即便已经同步到半数以上节点的日志被覆盖,这是不允许的。 (d2): 反之,如果在崩溃之前,S1不去同步往期的日志,而是首先同步自己任期内的日志4到所有节点,就不会导致黄色日志2被覆盖。因为leader同步日志的流程中,会通过不断的向后重试的方式,将日志同步到其他所有follower,只要日志4被复制成功,在它之前的日志2就会被复制成功。(d2)是想说明:不能直接提交过往任期的日志,即便已经被多数通过,但是可以先同步一条自己任内的日志,如果这条日志通过,就能带着前面的日志一起通过,这是(c)和(d2)两个图的区别。图(c)中,S1先去提交过往任期的日志2,图(d2)中,S1先去提交自己任内的日志4。 假如 s1 提交的话,则 index 为 2,term 为 2 的 entry 就被应用到状态机中了,是不可改变了,此时 s1 如果挂了,来到 term5,s5 是可以被选为 leader 的,因为按照之前的 log 比对策略来说,s5 的最后一个 log 的 term 是 3 比 s2 s3 s4 的最后一个 log 的 term 都大。一旦 s5 被选举为 leader,即 d 场景,s5 会复制 index 为 2,term 为 3 的 entry 到上述机器上,这时候就会造成之前 s1 已经提交的 index 为 2 的位置被重新覆盖,因此违背了一致性。\n假如 s1 不提交,而是等到 term4 中有过半的 entry 了,然后再将之前的 term 的 entry 一起提交(这就是所谓的间接提交,即使满足过半,但是必须要等到当前 term 中有过半的 entry 才能跟着一起提交),即处于 e 场景,s1 此时挂的话,s5 就不能被选为 leader 了,因为 s2 s3 的最后一个 log 的 term 为 4 比 s5 的 3 大,所以 s5 获取不到投票,进而 s5 就不可能去覆盖上述的提交\n我们可以看到的是,如果允许提交之前任期的日志这么做,那么:\n(c)中, S1恢复之后,又再次提交在任期2中的黄色日志2。但是,从后面可以看到,即便这个之前任期中的黄色日志2,提交到大部分节点,如果允许提交之前任期的日志,仍然存在被覆盖的可能性,因为: (d1)中,S5恢复之后,也会提交在自己本地上保存的之前任期3的蓝色日志,这会导致覆盖了前面已经到半数以上节点的黄色日志2。 所以,如果允许提交之前任期的日志,即如同(c)和(d1)演示的那样:重新当选之后,马上提交自己本地保存的、之前任期的日志,就会可能导致即便已经同步到半数以上节点的日志,被覆盖的情况。\n而已同步到半数以上节点的日志,一定在新当选leader上(否则这个节点不可能成为新leader)且达成了一致可提交,即不允许被覆盖。\n这就是矛盾的地方,即允许提交之前任期的日志,最终导致了违反协议规则的情况。\n那么,如何确保新当选的leader节点,其本地的未提交日志被正确提交呢?图(d2)展示了正常的情况:即当选之后,不要首先提交本地已有的黄色日志2,而是首先提交一条新日志4,如果这条新日志被提交成功,那么按照Raft日志的匹配规则(log matching property):日志4如果能提交,它前面的日志也提交了。\n可是,新的问题又出现了,如果在(d2)中,S1重新当选之后,客户端写入没有这条新的日志4,那么前面的日志2是不是永远无法提交了?为了解决这个问题,raft要求每个leader新当选之后,马上写入一条只有任期号和索引、而没有内容的所谓“no-op”日志,以这条日志来驱动在它之前的日志达成一致。\n这就是论文中这部分内容想要表达的。这部分内容之所以比较难理解,是因为经常忽略了这个图示展示的是错误的情况,允许提交之前任期的日志可能导致的问题。\n(c)和(d2) 有什么区别? 看起来,(c)和(d2)一样,S1当选后都提交了日志1、2、4,那么两者的区别在哪里? 虽然两个场景中,提交的日志都是一样的,但是日志达成一致的顺序并不一致:\n(c):S1成为leader之后,先提交过往任期、本地的日志2,再提交日志4。这就是提交之前任期日志的情况。 (d2):S1成为leader之后,先提交本次任期的日志4,如果日志4能提交成功,那么它前面的日志2就能提交成功了。 关于(d2)的这个场景,有可能又存在着下一个疑问: 如何理解(d2)中,“本任期的日志4提交成功,那么它前面的日志2也能提交成功了”?\n这是由raft日志的Log Matching Property决定的:\nIf two entries in different logs have the same index and term, then they store the same command. If two entries in different logs have the same index and term, then the logs are identical in all preceding entries.\nIf two entries in different logs have the same index and term, then the logs are identical in all preceding entries\n第一条性质,说明的是在不同节点上的已提交的日志,如果任期号、索引一样,那么它们的内容肯定一样。这是由leader节点的安全性和leader上的日志只能添加不能覆盖来保证的,这样leader就永远不会在同一个任期,创建两个相同索引的日志。\n第二条性质,说明的是在不同节点上的日志中,如果其中有同样的一条日志(即相同任期和索引)已经达成了一致,那么在这不同节点上在这条日志之前的所有日志都是一样的。\n第二条性质是由leader节点向follower节点上根据AppendEntries消息同步日志上保证的。leader在AppendEntries消息中会携带新添加entries之前日志的term和index 即PrevLogTerm, PrevLogIndex,follower会判断在log中是否存在拥有此term和index的消息,如果没有就会拒绝。\nleader为每一个follower维护一个nextIndex,表示待发送的下一个日志的index。初始化为日志长度。 leader在follower拒绝AppendEntries之后会对nextIndex减一,然后继续重试AppendEntries直到两者一致。 于是,回到我们开始的问题,(d2)场景中,在添加本任期日志4的时候,会发现有一些节点上并不存在过往任期的日志2,这时候就会相应地计算不同节点的nextIndex索引,来驱动同步日志2到这些节点上。\n总而言之,根据日志的性质,只要本任期的日志4能达成一致,上一条日志2就能达成一致。\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-%E4%B8%BA%E4%BB%80%E4%B9%88raft%E5%8D%8F%E8%AE%AE%E4%B8%8D%E8%83%BD%E6%8F%90%E4%BA%A4%E4%B9%8B%E5%89%8D%E4%BB%BB%E6%9C%9F%E7%9A%84%E6%97%A5%E5%BF%97/","summary":"如果允许提交之前任期的日志,将导致什么问题? 我们将论文中的上图展开: (a): S1 是leader,将黄色的日志2同步到了S2,然后S1崩溃。 (b): S5 在任期","title":"MIT6.824 2022 Raft 为什么Raft协议不能提交之前任期的日志"},{"content":"Mulit Raft Group 通过对 Raft 协议的描述我们知道:用户在对一组 Raft 系统进行更新操作时必须先经过 Leader,再由 Leader 同步给大多数 Follower。而在实际运用中,一组 Raft 的 Leader 往往存在单点的流量瓶颈,流量高便无法承载,同时每个节点都是全量数据,所以会受到节点的存储限制而导致容量瓶颈,无法扩展。\nMulit Raft Group 正是通过把整个数据从横向做切分,分为多个 Region 来解决磁盘瓶颈,然后每个 Region 都对应有独立的 Leader 和一个或多个 Follower 的 Raft 组进行横向扩展,此时系统便有多个写入的节点,从而分担写入压力,图如下: 具体细节可以参考TiKV 的文章\nMulti-Raft需要解决的一些核心问题: 数据何如分片 分片中的数据越来越大,需要分裂产生更多的分片,组成更多Raft-Group 分片的调度,让负载在系统中更平均(分片副本的迁移,补全,Leader切换等等) 一个节点上,所有的Raft-Group复用链接(否则Raft副本之间两两建链,链接爆炸了) 如何处理stale的请求(例如Proposal和Apply的时候,当前的副本不是Leader、分裂了、被销毁了等等) Snapshot如何管理(限制Snapshot,避免带宽、CPU、IO资源被过度占用) 数据何如分片 通常的数据分片算法就是 Hash 和 Range,TiKV 使用的 Range 来对数据进行数据分片。为什么使用 Range,主要原因是能更好的将相同前缀的 key 聚合在一起,便于 scan 等操作,这个 Hash 是没法支持的,当然,在 split/merge 上面 Range 也比 Hash 好处理很多,很多时候只会涉及到元信息的修改,都不用大范围的挪动数据。\n当然,Range 有一个问题在于很有可能某一个 Region 会因为频繁的操作成为性能热点,当然也有一些优化的方式,譬如通过 PD 将这些 Region 调度到更好的机器上面,提供 Follower 分担读压力等。\n总之,在 TiKV 里面,我们使用 Range 来对数据进行切分,将其分成一个一个的 Raft Group,每一个 Raft Group,我们使用 Region 来表示。\n分片如何调度 Elasticell实现细节 作为参考: 这部分的思路就和TiKV完全一致了。PD负责调度指令的下发,PD通过心跳收集调度需要的数据,这些数据包括:节点上的分片的个数,分片中leader的个数,节点的存储空间,剩余存储空间等等。一些最基本的调度:\nPD发现分片的副本数目缺少了,寻找一个合适的节点,把副本补全 PD发现系统中节点之间的分片数相差较多,就会转移一些分片的副本,保持系统中所有节点的分片数目大致相同(存储均衡) PD发现系统中节点之间分片的Leader数目不太一致,就会转移一些副本的Leader,保持系统中所有节点的分片副本的Leader数目大致相同(读写请求均衡) 新的分片如何形成Raft-Group 假设这个分片1有三个副本分别运行在Node1,Node2,Node3三台机器上,其中Node1机器上的副本是Leader,分片的大小限制是1GB。\n当分片1管理的数据量超过1GB的时候,分片1就会分裂成2个分片,分裂后,分片1修改数据范围,更新Epoch,继续服务。\n分片2形也有三个副本,分别也在Node1,Node2,Node3上,这些是元信息,但是只有在Node1上存在真正被创建的副本实例,Node2,Node3并不知道这个信息。这个时候Node1上的副本会立即进行Campaign Leader的操作,这个时候,Node2和Node3会收到来自分片2的Vote的Raft消息(整个描述指的是Leader Election),Node2,Node3发现分片2在自己的节点上并没有副本,那么就会检查这个消息的合法性和正确性,通过后,立即创建分片2的副本,刚创建的副本没有任何数据,创建完成后会响应这个Vote消息,也一定会选择Node1的副本为Leader,选举完成后,Node1的分片2的Leader会给Node2,Node3的副本直接发送Snapshot,最终这个新的Raft-Group形成并且对外服务。\n按照Raft的协议,分片2在Node1 的副本成为Leader后不应该直接给Node2,Node3发送snapshot,但是这里我们沿用了TiKV的设计,Raft初始化的Log Index是5,那么按照Raft协议,Node1上的副本需要给Node2,Node3发送AppendEntries,这个时候Node1上的副本发现Log Index小于5的Raft Log不存在,所以就会转为直接发送Snapshot。\nSnapshot如何管理 我们的底层存储引擎使用的是RocksDB,这是一个LSM的实现,支持对一个范围的数据进行Snapshot和Apply Snapshot,我们基于这个特性来做。Raft中有一个RPC用于发送Snapshot数据,但是如果把所有的数据放在这个RPC里面,那么会有很多问题:\n一个RPC的数据量太大(取决于一个分片管理的数据,可能上GB,内存吃不消) 如果失败,整体重试代价太大 难以流控 我们修改为这样:\nRaft的snapshot RPC中的数据存放,snapshot文件的元信息(包括分片的ID,当前Raft的Term,Index,Epoch等信息) 发送Raft snapshot的RPC后,异步发送具体数据文件 数据文件分Chunk发送,重试的代价小 发送 Chunk的链接和Raft RPC的链接不复用 限制并行发送的Chunk个数,避免snapshot文件发送影响正常的Raft RPC 接收Raft snapshot的分片副本阻塞,直到接收完毕完整的snapshot数据文件 如何处理stale的请求 由于分片的副本会被调度(转移,销毁),分片自身也会分裂(分裂后分片所管理的数据范围发生了变化),所以在Raft的Proposal和Apply的时候,我们需要检查Stale请求,如何做呢?其实还是蛮简单的,TiKV使用Epoch的概念,我们沿用了下来。一个分片的副本有2个Epoch,一个在分片的副本成员发生变化的时候递增,一个在分片数据范围发生变化的时候递增,在请求到来的时候记录当前的Epoch,在Proposal和Apply的阶段检查Epoch,让客户端重试Stale的请求。\n","permalink":"https://reid00.github.io/en/posts/storage/multi-raft/","summary":"Mulit Raft Group 通过对 Raft 协议的描述我们知道:用户在对一组 Raft 系统进行更新操作时必须先经过 Leader,再由 Leader 同步给大多数 Follower。而在实际运用中","title":"Multi Raft"},{"content":"介绍 对Raft Figure2 中需要持久化的字段进行保存。\n完成persist()和readPersist()函数,编码方式参照注释 优化nextIndex[]回退方式,否则无法通过所有测试 提示:\n需要持久化的部分包括currentTerm、votedFor、log。 有关nextIndex[]回退优化 逻辑如下: 若 follower 没有 prevLogIndex 处的日志,则直接置 conflictIndex = len(log),conflictTerm = None; leader 收到返回体后,肯定找不到对应的 term,则设置nextIndex = conflictIndex; 其实就是 leader 对应的 nextIndex 直接回退到该 follower 的日志条目末尾处,因为 prevLogIndex 超前了 若 follower 有 prevLogIndex 处的日志,但是 term 不匹配;则设置 conlictTerm为 prevLogIndex 处的 term,且肯定可以找到日志中该 term出现的第一个日志条目的下标,并置conflictIndex = firstIndexWithTerm; leader 收到返回体后,有可能找不到对应的 term,即 leader 和 follower 在conflictIndex处以及之后的日志都有冲突,都不能要了,直接置nextIndex = conflictIndex 若找到了对应的term,则找到对应term出现的最后一个日志条目的下一个日志条目,即置nextIndex = lastIndexWithTerm+1;这里其实是默认了若 leader 和 follower 同时拥有该 term 的日志,则不会有冲突,直接取下一个 term 作为日志发起就好,是源自于 5.4 safety 的安全性保证 如果还有冲突,leader 和 follower 会一直根据以上规则回溯 nextIndex\n持久化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 func (rf *Raft) persist() { // Your code here (2C). // Example: // w := new(bytes.Buffer) // e := labgob.NewEncoder(w) // e.Encode(rf.xxx) // e.Encode(rf.yyy) // data := w.Bytes() // rf.persister.SaveRaftState(data) rf.persister.SaveRaftState(rf.encodeState()) } func (rf *Raft) encodeState() []byte { buf := new(bytes.Buffer) enc := labgob.NewEncoder(buf) // figure2 Persistent state on all servers enc.Encode(rf.currentTerm) enc.Encode(rf.votedFor) enc.Encode(rf.logs) return buf.Bytes() } // restore previously persisted state. func (rf *Raft) readPersist(data []byte) { if len(data) \u0026lt; 1 { return } buf := bytes.NewBuffer(data) dec := labgob.NewDecoder(buf) var currentTerm, votedFor int var logs []Entry if dec.Decode(\u0026amp;currentTerm) != nil || dec.Decode(\u0026amp;votedFor) != nil || dec.Decode(\u0026amp;logs) != nil { DPrintf(\u0026#34;[readPersist] - {Node: %v} restore persisted data failed\u0026#34;, rf.me) } rf.currentTerm, rf.votedFor, rf.logs = currentTerm, votedFor, logs rf.lastApplied, rf.commitIndex = rf.logs[0].Index, rf.logs[0].Index // Your code here (2C). // Example: // r := bytes.NewBuffer(data) // d := labgob.NewDecoder(r) // var xxx // var yyy // if d.Decode(\u0026amp;xxx) != nil || // d.Decode(\u0026amp;yyy) != nil { // error... // } else { // rf.xxx = xxx // rf.yyy = yyy // } } nextIndex 优化 Lab 2B 中对于失败的AppendEntries请求,让nextIndex自减,这样效率是比较慢的。方法上可以以Term 为单位返回,不在一个一个Index 自减。 这需要添加 ConflicTerm, ConflictIndex 字段 去记录出现冲突的位置和任期。然后在 HanleAppendEntries RPC 中,在 Leader 的log 中检查 ConflictIndex 位置的日志一致性。\n优化点1 如果follower.log不存在prevLog,让Leader下一次从follower.log的末尾开始同步日志。 优化点2 如果是因为prevLog.Term不匹配,记follower.prevLog.Term为conflictTerm。 如果leader.log找不到Term为conflictTerm的日志,则下一次从follower.log中conflictTerm的第一个log的位置开始同步日志。 如果leader.log找到了Term为conflictTerm的日志,则下一次从leader.log中conflictTerm的最后一个log的下一个位置开始同步日志。 nextIndex的正确位置可能依旧需要多次RPC才能找到,改进的流程只是加快了找到正确nextIndex的速度。\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2c-log-compaction/","summary":"介绍 对Raft Figure2 中需要持久化的字段进行保存。 完成persist()和readPersist()函数,编码方式参照注释 优化nextIndex[","title":"MIT6.824 2022 Raft Lab2C Log Compaction"},{"content":"介绍 snapshot是状态机某一时刻的副本,具体格式依赖存储引擎的实现,比如说:B+树、LSM、哈希表等,6.824是实现一个键值数据库,所以我们采用的是哈希表,在Lab 3可以看到实现。\nraft通过日志来实现多副本的数据一致,但是日志会不断膨胀,带来两个缺点:数据量大、恢复时间长,因此需要定期压缩一下,生成snapshot。\n快照由上层应用触发。当上层应用认为可以将一些已提交的 entry 压缩成 snapshot 时,其会调用节点的 Snapshot()函数,将需要压缩的状态机的状态数据传递给节点,作为快照。\n在正常情况下,仅由上层应用命令节点进行快照即可。但如果节点出现落后或者崩溃,情况则变得更加复杂。考虑一个日志非常落后的节点 i,当 Leader 向其发送 AppendEntries RPC 时,nextIndex[i] 对应的 entry 已被丢弃,压缩在快照中。这种情况下, Leader 就无法对其进行 AppendEntries。取而代之的是,这里我们应该实现一个新的 InstallSnapshot RPC,将 Leader 当前的快照直接发送给非常落后的 Follower。\n何时快照?\n服务端触发的日志压缩:上层应用发送快照数据给Raft实例。 leader 发送来的 InstallSnapshot:领导者发送快照RPC请求给追随者。当raft收到其他节点的压缩请求后,先把请求上报给上层应用,然后上层应用调用rf.CondInstallSnapshot()来决定是否安装快照 流程梳理 快照是状态机中的概念,需要在状态机中加载快照,因此要通过applyCh将快照发送给状态机,但是发送后Raft并不立即保存快照,而是等待状态机调用 CondInstallSnapshot(),如果从收到InstallSnapshot()后到收到CondInstallSnapshot()前,没有新的日志提交到状态机,则Raft返回True,Raft和状态机保存快照,否则Raft返回False,两者都不保存快照。\n如此保证了Raft和状态机保存快照是一个原子操作(SaveStateAndSnapshot)。当然在InstallSnapshot()将快照发送给状态机后再将快照保存到Raft,令CondInstallSnap()永远返回True,也可以保证原子操作,但是这样做必须等待快照发送给状态机完成,但是rf.applyCh \u0026lt;- ApplyMsg是有可能阻塞的,由于InstallSnapshot()需要持有全局的互斥锁,这可能导致整个节点无法工作。\n服务端触发的日志压缩: 上层应用发送快照数据给Raft实例。 leader 发送来的 InstallSnapshot: Leader发送快照RPC请求给Follower。当raft收到其他节点的压缩请求后,先把请求上报给上层应用,然后上层应用调用rf.CondInstallSnapshot()来决定是否安装快照(SaveStateAndSnapshot) 相关函数解析 服务端触发的Log Compact func (rf *Raft) Snapshot(index int, snapshot []byte) 应用程序将index(包括)之前的所有日志都打包为了快照,即参数snapshot [] byte。那么对于Raft要做的就是,将打包为快照的日志直接删除,并且要将快照保存起来,因为将来可能会发现某些节点大幅度落后于leader的日志,那么leader就直接发送快照给它,让他的日志“跟上来”。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 func (rf *Raft) Snapshot(index int, snapshot []byte) { // Your code here (2D). rf.mu.Lock() defer rf.mu.Unlock() lastSnapshotIndex := rf.getFirstLog().Index // 当前节点的firstLogIndex 比要添加的Snapshot LastIncludedIndex 大,说明已经存在了Snapshot 包含了更多的log if index \u0026lt;= lastSnapshotIndex { DPrintf(\u0026#34;[Snapshot] - {Node %v} rejects replacing log with snapshotIndex %v as current lastSnapshotIndex %v is larger in term %v\u0026#34;, rf.me, index, lastSnapshotIndex, rf.currentTerm) return } // 新的日志索引包含了 LastIncludedIndex 这个位置,因为要把它作为dummpy index rf.logs = shrinkEntriesArray(rf.logs[index-lastSnapshotIndex:]) rf.logs[0].Command = nil rf.persister.SaveStateAndSnapshot(rf.encodeState(), snapshot) DPrintf(\u0026#34;[Snapshot] - {Node: %v}\u0026#39;s state is {state %v, term %v, commitIndex %v, lastApplied %v, firstLog %v, lastLogLog %v} after replacing log with snapshotIndex %v as lastSnapshotIndex %v is smaller\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), index, lastSnapshotIndex) } 由 Leader 发送来的 InstallSnapshot func (rf *Raft) InstallSnapshot(req *InstallSnapshotReq, resp *InstallSnapshotResp)\n对于 leader 发过来的 InstallSnapshot,只需要判断 term 是否正确,如果无误则 follower 只能无条件接受。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 func (rf *Raft) InstallSnapshot(req *InstallSnapshotReq, resp *InstallSnapshotResp) { rf.mu.Lock() defer rf.mu.Unlock() defer DPrintf(\u0026#34;[InstallSnapshot] - {Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} before processing InstallSnapshotRequest %v and reply InstallSnapshotResponse %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) resp.Term = rf.currentTerm if req.Term \u0026lt; rf.currentTerm { return } if req.Term \u0026gt; rf.currentTerm { rf.currentTerm, rf.votedFor = req.Term, -1 rf.persist() } rf.ChangeState(StateFollower) rf.electionTimer.Reset(RandomizedElectionTimeout()) // outdated snapshot // snapshot 的 lastIncludedIndex 小于等于本地的 commitIndex, // 那说明本地已经包含了该 snapshot 所有的数据信息,尽管可能状态机还没有这个 snapshot 新, // 即 lastApplied 还没更新到 commitIndex,但是 applier 协程也一定尝试在 apply 了, // 此时便没必要再去用 snapshot 更换状态机了。对于更新的 snapshot,这里通过异步的方式将其 // push 到 applyCh 中。 if req.LastIncludedIndex \u0026lt;= rf.commitIndex { return } go func() { rf.applyCh \u0026lt;- ApplyMsg{ SnapshotValid: true, Snapshot: req.Data, SnapshotTerm: req.LastIncludedTerm, SnapshotIndex: req.LastIncludedIndex, } }() } Follower 收到 InstallSnapshot RPC 后 func (rf *Raft) CondInstallSnapshot(lastIncludedTerm int, lastIncludedIndex int, snapshot []byte) bool\nFollower接收到snapshot后不能够立刻应用并截断日志,raft和状态机都需要应用snapshot,这需要考虑原子性。如果raft应用成功但状态机应用snapshot失败,那么在接下来的时间里客户端读到的数据是不完整的。如果状态机应用snapshot成功但raft应用失败,那么raft会要求重传,状态机应用成功也没啥意义。因此CondInstallSnapshot是异步于raft的,并由应用层调用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 func (rf *Raft) CondInstallSnapshot(lastIncludedTerm int, lastIncludedIndex int, snapshot []byte) bool { // Your code here (2D). rf.mu.Lock() defer rf.mu.Unlock() DPrintf(\u0026#34;[CondInstallSnapshot] - {Node %v} service calls CondInstallSnapshot with lastIncludedTerm %v and lastIncludedIndex %v to check whether snapshot is still valid in term %v\u0026#34;, rf.me, lastIncludedTerm, lastIncludedIndex, rf.currentTerm) // outdated snapshot if lastIncludedIndex \u0026lt;= rf.commitIndex { DPrintf(\u0026#34;[CondInstallSnapshot] - {Node %v} rejects the snapshot which lastIncludedIndex is %v because commitIndex %v is larger\u0026#34;, rf.me, lastIncludedIndex, rf.commitIndex) return false } if lastIncludedIndex \u0026gt; rf.getLastLog().Index { rf.logs = make([]Entry, 1) } else { rf.logs = shrinkEntriesArray(rf.logs[lastIncludedIndex-rf.getFirstLog().Index:]) rf.logs[0].Command = nil } rf.logs[0].Term, rf.logs[0].Index = lastIncludedTerm, lastIncludedIndex rf.lastApplied, rf.commitIndex = lastIncludedIndex, lastIncludedIndex rf.persister.SaveStateAndSnapshot(rf.encodeState(), snapshot) DPrintf(\u0026#34;[CondInstallSnapshot] - {Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} after accepting the snapshot which lastIncludedTerm is %v, lastIncludedIndex is %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), lastIncludedTerm, lastIncludedIndex) return true } 假设有一个节点一直是 crash 的,然后复活了,leader 发现其落后的太多,于是发送 InstallSnapshot() RPC 到落后的节点上面。落后节点收到 InstallSnapshot() 中的 snapshot 后,通过 rf.applyCh 发送给上层 service 。上层的 service 收到 snapshot 时,调用节点的 CondInstallSnapshot() 方法。节点如果在该 snapshot 之后有新的 commit,则拒绝安装此 snapshot CondInstallSnapshot 中的 lastIncludedIndex \u0026lt;= rf.commitIndex,service 也会放弃本次安装。反之如果在该 snapshot 之后没有新的 commit,那么节点会安装此 snapshot 并返回 true,service 收到后也同步安装。 在实验大纲中指出不能直接通过rf.logs[idx:] 的方式去做日志的截取保存,防止GC 不能及时回收。\nRaft must discard old log entries in a way that allows the Go garbage collector to free and re-use the memory; this requires that there be no reachable references (pointers) to the discarded log entries.\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2d-log-persistence/","summary":"介绍 snapshot是状态机某一时刻的副本,具体格式依赖存储引擎的实现,比如说:B+树、LSM、哈希表等,6.824是实现一个键值数据库,所","title":"MIT6.824 2022 Raft Lab2D Log Persistence"},{"content":"前言 论文 博士论文 博士论文翻译 官网 动画展示 Students\u0026rsquo; Guide to Raft (重要) MIT6.824 本篇是实验的前言, 先对论文里面提到的RPC做个大概的梳理和介绍。 Raft 原理可以参考这篇Raft\nFigure2 Raft 实现的核心在这个图,想要正确实现Raft 必须对这个图有深刻理解,在这里我们对图上的各个RPC 进行介绍和阐述。\nState Persistent state for all servers 所有Raft 节点都需要维护的持久化状态: currentTerm: 此节点当前的任期。保证重启后任期不丢失。启动时初始值为0(无意义状态),单调递增 (Lab 2A) votedFor: 当前任期内,此节点将选票给了谁。 一个任期内,节点只能将选票投给某个节点。需要持久化,从而避免节点重启后重复投票。(Lab 2A) logs: 日志条目, 每条 Entry 包含一条待施加至状态机的命令。Entry 也要记录其被发送至 Leader 时,Leader 当时的任期。Lab2B 中,在内存存储日志即可,不用担心 server 会 down 掉,测试中仅会模拟网络挂掉的情景。初始Index从1开始,0为dummy index。 为什么 currentTerm 和 votedFor 需要持久化?\nvotedFor 保证每个任期最多只有一个Leader!\n考虑如下一种场景: 因为在Raft协议中每个任期内有且仅有一个Leader。现假设有几个Raft节点在当前任期下投票给了Raft节点A,并且Raft A顺利成为了Leader。现故障系统被重启,重启后如果收到一个相同任期的Raft节点B的投票请求,由于每个节点并没有记录其投票状态,那么这些节点就有可能投票给Raft B,并使B成为Leader。此时,在同一个任期内就会存在两个Leader,与Raft的要求不符。\n保证每个Index位置只会有一个Term! (也等价于每个任期内最多有一个Leader)\n在这里例子中,S1关机了,S2和S3会尝试选举一个新的Leader。它们需要证据证明,正确的任期号是8,而不是6。如果仅仅是S2和S3为彼此投票,它们不知道当前的任期号,它们只能查看自己的Log,它们或许会认为下一个任期是6(因为Log里的上一个任期是5)。如果它们这么做了,那么它们会从任期6开始添加Log。但是接下来,就会有问题了,因为我们有了两个不同的任期6(另一个在S1中)。这就是为什么currentTerm需要被持久化存储的原因,因为它需要用来保存已经被使用过的任期号。\n这些数据需要在每次你修改它们的时候存储起来。所以可以确定的是,安全的做法是每次你添加一个Log条目,更新currentTerm或者更新votedFor,你或许都需要持久化存储这些数据。在一个真实的Raft服务器上,这意味着将数据写入磁盘,所以你需要一些文件来记录这些数据。如果你发现,直到服务器与外界通信时,才有可能持久化存储数据,那么你可以通过一些批量操作来提升性能。例如,只在服务器回复一个RPC或者发送一个RPC时,服务器才进行持久化存储,这样可以节省一些持久化存储的操作。\nVolatile state on all servers 每一个节点都应该有的非持久化状态: commitIndex: 已提交的最大 index。被提交的定义为,当 Leader 成功在大部分 server 上复制了一条 Entry,那么这条 Entry 就是一条已提交的 Entry。leader 节点重启后可以通过 appendEntries rpc 逐渐得到不同节点的 matchIndex,从而确认 commitIndex,follower 只需等待 leader 传递过来的 commitIndex 即可。(初始值为0,单调递增) lastApplied: 已被状态机应用的最大 index。已提交和已应用是不同的概念,已应用指这条 Entry 已经被运用到状态机上。已提交先于已应用。同时需要注意的是,Raft 保证了已提交的 Entry 一定会被应用(通过对选举过程增加一些限制,下面会提到)。raft 算法假设了状态机本身是易失的,所以重启后状态机的状态可以通过 log[] (部分 log 可以压缩为 snapshot) 来恢复。(初始值为0,单调递增) commitIndex 和 lastApplied 分别维护 log 已提交和已应用的状态,当节点发现 commitIndex \u0026gt; lastApplied 时,代表着 commitIndex 和 lastApplied 间的 entries 处于已提交,未应用的状态。因此应将其间的 entries 按序应用至状态机。\n对于 Follower,commitIndex 通过 Leader AppendEntries RPC 的参数 leaderCommit 更新。对于 Leader,commitIndex 通过其维护的 matchIndex 数组更新。\nVolatile state on leaders leader 的非持久化状态: nextIndex[]: 由 Leader 维护,nextIndex[i] 代表需要同步给 peer[i] 的下一个 entry 的 index。在 Leader 当选后,重新初始化为 Leader 的 lastLogIndex + 1。 matchIndex[]: 由 Leader 维护,matchIndex[i] 代表 Leader 已知的已在 peer[i] 上成功复制的最高 entry index。在 Leader 当选后,重新初始化为 0。 每次选举后,leader 的此两个数组都应该立刻重新初始化并开始探测。\n不能简单地认为 matchIndex = nextIndex - 1。\nnextIndex 是对追加位置的一种猜测,是乐观的估计。因此,当 Leader 上任时,会将 nextIndex 全部初始化为 lastLogIndex + 1,即乐观地估计所有 Follower 的 log 已经与自身相同。AppendEntries PRC 中,Leader 会根据 nextIndex 来决定向 Follower 发送哪些 entry。当返回失败时,则会将 nextIndex 减一,猜测仅有一条 entry 不一致,再次乐观地尝试。实际上,使用 nextIndex 是为了提升性能,仅向 Follower 发送不一致的 entry,减小 RPC 传输量。\nmatchIndex 则是对同步情况的保守确认,为了保证安全性。matchIndex 及此前的 entry 一定都成功地同步。matchIndex 的作用是帮助 Leader 更新自身的 commitIndex。当 Leader 发现一个 Index N 值,N 大于过半数的 matchIndex,则可将其 commitIndex 更新为 N(需要注意任期号的问题,后文会提到)。matchIndex 在 Leader 上任时被初始化为 0。\nnextIndex 是最乐观的估计,被初始化为最大可能值;matchIndex 是最悲观的估计,被初始化为最小可能值。在一次次心跳中,nextIndex 不断减小,matchIndex 不断增大,直至 matchIndex = nextIndex - 1,则代表该 Follower 已经与 Leader 成功同步。\nRequestVote RPC Invoked by candidates to gather votes (§5.2). 会被 Candidate 调用,以此获取选票。\nArgs\nterm: Candidate 的任期 (Lab 2A) candidateId: 发起投票请求的候选人id (Lab 2A) lastLogIndex: 候选人最新的日志条目索引, Candidate 最后一个 entry 的 index,是投票的额外判据 lastLogTerm: 候选人最新日志条目对应的任期号 Reply\nterm: 收到RequestVote RPC Raft节点的任期。假如 Candidate 发现 Follower 的任期高于自己,则会放弃 Candidate 身份并更新自己的任期 voteGranted: 是否同意 Candidate 当选。 Receiver Implementation 接收日志的follower需要实现的\n当 Candidate 任期小于当前节点任期时,返回 false。 如果 votedFor 为 null(即当前任期内此节点还未投票, Go 代码中用-1)或者 votedFor为 candidateId(即当前任期内此节点已经向此 Candidate 投过票),则同意投票;否则拒绝投票(Lab 2A 只需要实现到这个程度)。 事实上还要: 只有 Candidate 的 log 至少与 Receiver 的 log 一样新(up-to-date)时,才同意投票。Raft 通过两个日志的最后一个 entry 来判断哪个日志更 up-to-date。假如两个 entry 的 term 不同,term 更大的更新。term 相同时,index 更大的更新。 这里投票的额外限制(up-to-date)是为了保证已经被 commit 的 entry 一定不会被覆盖。仅有当 Candidate 的 log 包含所有已提交的 entry,才有可能当选为 Leader。\nAppendEntries RPC Invoked by leader to replicate log entries (§5.3); also used as heartbeat (§5.2). 在领导选举的过程中,AppendEntries RPC 用来实现 Leader 的心跳机制。节点的 AppendEntries RPC 会被 Leader 定期调用。正常存在Leader 时,用来进行Log Replacation。\nArgs\nterm: Leader 任期 (Lab 2A) leadId: Client 可能将请求发送至 Follower 节点,得知 leaderId 后 Follower 可将 Client 的请求重定位至 Leader 节点。因为 Raft 的请求信息必须先经过 Leader 节点,再由 Leader 节点流向其他节点进行同步,信息是单向流动的。在选主过程中,leaderId暂时只有 debug 的作用 (Lab 2A) prevLogIndex: 添加 Entries 的前一条 Entry 的 index prevLogTerm: prevLogIndex 对应 entry 的 term entries[]: 需要同步的 entries。若为空,则代表是一次 heartbeat。需要注意的是,不需要特别判断是否为 heartbeat,即使是 heartbeat,也需要进行一系列的检查。因此本文也不再区分心跳和 AppendEntries RPC leaderCommit: Leader 的 commitIndex,帮助 Follower 更新自身的 commitIndex Reply\nterm: 此节点的任期。假如 Leader 发现 Follower 的任期高于自己,则会放弃 Leader 身份并更新自己的任期。 success: 此节点是否认同 Leader 发送的RPC。 Receiver Implementation 接收日志的follower需要实现的\n当 Leader 任期小于当前节点任期时,返回 false。 若 Follower 在 prevLogIndex 位置的 entry 的 term 与 Args 中的 prevLogTerm 不同(或者 prevLogIndex 的位置没有 entry),返回 false。 如果 Follower 的某一个 entry 与需要同步的 entries 中的一个 entry 冲突,则需要删除冲突 entry 及其之后的所有 entry。需要特别注意的是,假如没有冲突,不能删除任何 entry。因为存在 Follower 的 log 更 up-to-date 的可能。 添加 Log 中不存在的新 entry。 如果 leaderCommit \u0026gt; commitIndex,令 commitIndex = min(leaderCommit, index of last new entry)。此即 Follower 更新 commitIndex 的方式。 Rules for Servers All Servers 如果commitIndex \u0026gt; lastApplied, 那么将lastApplied自增, 并把对应日志log[lastApplied]应用到状态机 如果来自其他节点的 RPC 请求(RequestVote, AppendEntries, InstallSnapshot)中,或发给其他节点的 RPC 的回复中,包含一个term T大于currentTerm, 那么将currentTerm赋值为T并立即切换状态为 Follower。(Lab 2A) Followers 响应来自 Candidate 和 Leader 的 RPC 请求。(Lab 2A) 如果在 election timeout 到期时,Follower 未收到来自当前 Leader 的 AppendEntries RPC,也没有收到来自 Candidate 的 RequestVote RPC,则转变为 Candidate。(Lab 2A) Candidate 转变 Candidate时,开始一轮选举:(Lab 2A) currentTerm ++ 为自己投票, votedFor = me 重置 election timer 向其他所有节点并行发送 RequestVote RPC 如果收到了大多数节点的选票(voteCnt \u0026gt; n/2),当选 Leader。(Lab 2A) 在选举过程中,如果收到了来自新 Leader 的 AppendEntries RPC,停止选举,转变为 Follower。(Lab 2A) 如果 election timer 超时时,还未当选 Leader,则放弃此轮选举,开启新一轮选举。(Lab 2A) Leader 刚上任时,向所有节点发送一轮心跳信息(empty AppendEntries)。此后,每隔一段固定时间,向所有节点发送一轮心跳信息,重置其他节点的 election timer,以维持自己 Leader 的身份。(Lab 2A) 如果收到了来自 client 的 command,将 command 以 entry 的形式添加到日志。在收到大多数响应后将该条目应用到状态机并回复响应给客户端。在 lab2B 中,client 通过 Start() 函数传入 command。 如果 lastLogIndex \u0026gt;= nextIndex[i],向 peer[i] 发送 AppendEntries RPC,RPC 中包含从 nextIndex[i] 开始的日志。 如果返回值为 true,更新 nextIndex[i] 和 matchIndex[i]。 如果因为 entry 冲突,RPC 返回值为 false,则将 nextIndex[i] 减1并重试。这里的重试不一定代表需要立即重试,实际上可以仅将 nextIndex[i] 减1,下次心跳时则是以新值重试。 如果存在 index 值 N 满足:N \u0026gt; commitIndex \u0026amp;\u0026amp; 过半数 matchIndex[i] \u0026gt;= N \u0026amp;\u0026amp; log[N].term == currentTerm, 则令commitIndex = N。 这里最后一条是 Leader 更新 commitIndex 的方式。前两个要求都比较好理解,第三个要求是 Raft 的一个特性,即 Leader 仅会直接提交其任期内的 entry。存在这样一种情况,Leader 上任时,其最新的一些条目可能被认为处于未被提交的状态(但这些条目实际已经成功同步到了大部分节点上)。Leader 在上任时并不会检查这些 entry 是不是实际上已经可以被提交,而是通过提交此后的 entry 来间接地提交这些 entry。这种做法能够 work 的基础是 Log Matching Property:\nLog Matching: if two logs contain an entry with the same index and term, then the logs are identical in all entries up through the given index.\nInstallSnapshot PRC invoked by leader to send chunks of a snapshot to a follower.Leaders always send chunks in order. 虽然多数情况都是每个服务器独立创建快照, 但是leader有时候必须发送快照给一些落后太多的follower, 这通常发生在leader已经丢弃了下一条要发给该follower的日志条目(Log Compaction时清除掉了)的情况下。\nArgs\nterm: Leader 任期。同样,InstallSnapshot RPC 也要遵循 Figure 2 中的规则。如果节点发现自己的任期小于 Leader 的任期,就要及时更新 leaderId: 用于重定向 client lastIncludedIndex: 快照中包含的最后一个 entry 的 index lastIncludedTerm: 快照中包含的最后一个 entry 的 index 对应的 term offset: 分块在快照中的偏移量 data[]: 快照数据 done: 如果是最后一块数据则为真 Reply\nterm: 节点的任期。Leader 发现高于自己任期的节点时,更新任期并转变为 Follower Receiver Implementation 接收日志的follower需要实现的\n如果 term \u0026lt; currentTerm,直接返回 如果是第一个分块 (offset为0) 则创建新的快照 在指定的偏移量写入数据 如果done为false, 则回复并继续等待之后的数据 保存快照文件, 丢弃所有已存在的或者部分有着更小索引号的快照 如果现存的日志拥有相同的最后任期号和索引值, 则后面的数据继续保留并且回复 丢弃全部日志 能够使用快照来恢复状态机 (并且装载快照中的集群配置) 一次请求 Raft 需要做如下流程: Leader (Follower 收到会定向给Leader)收到 client 的请求; Leader 把 entry 写入持久存储; Leader 发送 log replication message(AppendEntries RPC) 给 Follower; Follower 接收之后,把 entry 写入持久存储,然后给 Leader 发送响应; Leader 等待 Follower 的响应,若 majority 节点接收了,则 apply; Leader 将结果返回给 client。 ","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-0-%E4%BB%8B%E7%BB%8D/","summary":"前言 论文 博士论文 博士论文翻译 官网 动画展示 Students\u0026rsquo; Guide to Raft (重要) MIT6.824 本篇是实验的前言, 先对论文里面提到的RPC做个大概的梳理和介绍。 Raft 原理可以参考这篇","title":"MIT6.824 2022 Raft 0 介绍"},{"content":"介绍 查看Raft0 流程梳理 整体逻辑, 从 ticker goroutine 开始, 集群开始的时候,所有节点均为Follower, 它们依靠ticker()成为Candidate。ticker 协程会定期收到两个 timer 的到期事件,如果是 election timer 到期,则发起一轮选举;如果是 heartbeat timer 到期且节点是 leader,则发起一轮心跳。\nElectionTimer 和 HeartbeatTimer. 如果某个raft 节点election timeout,则会触发leader election, 调用StartElection 方法。 StartElection 中发送 RequestVote RPC, 根据ReqestVote Response 判断是否收到选票,决定是否成为Leader。\n如果某个节点,收到大多数节点的选票,成为Leader 要通过发送Heartbeat 即空LogEntry 的AppendEntries RPC 来告诉其他节点自己的 Leader 地位。\n所以Lab2A 中,主要实现 RequestVote, AppendEntries 的逻辑。\n服务器状态 服务器在任意时间只能处于以下三种状态之一:\nLeader:处理所有客户端请求、日志同步、心跳维持领导权。同一时刻最多只能有一个可行的 Leader Follower:所有服务器的初始状态,功能为:追随领导者,接收领导者日志并实时同步,特性:完全被动的(不发送 RPC,只响应收到的 RPC) Candidate:用来选举新的 Leader,处于 Leader 和 Follower 之间的暂时状态,如Follower 一定时间内未收到来自Leader的心跳包,Follower会自动切换为Candidate,并开始选举操作,向集群中的其它节点发送投票请求,待收到半数以上的选票时,协调者升级成为领导者。 系统正常运行时,只有一个 Leader,其余都是 Followers。Leader拥有绝对的领导力,不断向Followers同步日志且发送心跳状态。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 type Raft struct { mu sync.RWMutex // Lock to protect shared access to this peer\u0026#39;s state peers []*labrpc.ClientEnd // RPC end points of all peers persister *Persister // Object to hold this peer\u0026#39;s persisted state me int // this peer\u0026#39;s index into peers[] dead int32 // set by Kill() // Your data here (2A, 2B, 2C). // Look at the paper\u0026#39;s Figure 2 for a description of what // state a Raft server must maintain. // 2A state NodeState currentTerm int votedFor int electionTimer *time.Timer heartbeatTimer *time.Timer // 2B logs []Entry // the first is dummy entry which contains LastSnapshotTerm, LastSnapshotIndex and nil Command commitIndex int lastApplied int nextIndex []int matchIndex []int applyCh chan ApplyMsg applyCond *sync.Cond // used to wakeup applier goroutine after committing new entries replicatorCond []*sync.Cond // used to signal replicator goroutine to batch replicating entries } 启动 集群所有节点初始状态均为Follower Follower 被动地接受 Leader 或 Candidate 的 RPC; 所以,如果 Leader 想要保持权威,必须向集群中的其它节点发送心跳包(空的 AppendEntries RPC) 等待选举超时(electionTimeout,一般在 100~500ms)后,Follower 没有收到任何 RPC Follower 认为集群中没有 Leader 开始新的一轮选举 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 func Make(peers []*labrpc.ClientEnd, me int, persister *Persister, applyCh chan ApplyMsg) *Raft { rf := \u0026amp;Raft{ peers: peers, persister: persister, me: me, dead: 0, applyCh: applyCh, replicatorCond: make([]*sync.Cond, len(peers)), state: StateFollower, currentTerm: 0, votedFor: -1, logs: make([]Entry, 1), nextIndex: make([]int, len(peers)), matchIndex: make([]int, len(peers)), heartbeatTimer: time.NewTimer(StableHeartbeatTimeout()), electionTimer: time.NewTimer(RandomizedElectionTimeout()), } // Your initialization code here (2A, 2B, 2C). // initialize from state persisted before a crash rf.readPersist(persister.ReadRaftState()) rf.applyCond = sync.NewCond(\u0026amp;rf.mu) lastLog := rf.getLastLog() for i := 0; i \u0026lt; len(peers); i++ { rf.matchIndex[i], rf.nextIndex[i] = 0, lastLog.Index+1 if i != rf.me { rf.replicatorCond[i] = sync.NewCond(\u0026amp;sync.Mutex{}) // start replicator goroutine to replicate entries in batch go rf.replicator(i) } } // start ticker goroutine to start elections go rf.ticker() // start applier goroutine to push committed logs into applyCh exactly once go rf.applier() return rf } 集群开始的时候,所有节点均为Follower, 它们依靠ticker()成为Candidate。ticker 协程会定期收到两个 timer 的到期事件,如果是 election timer 到期,则发起一轮选举;如果是 heartbeat timer 到期且节点是 leader,则发起一轮心跳。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 // ticker The ticker go routine starts a new election if this peer hasn\u0026#39;t received // heartsbeats recently. func (rf *Raft) ticker() { // for rf.killed() == false { for !rf.killed() { // Your code here to check if a leader election should // be started and to randomize sleeping time using // time.Sleep(). select { case \u0026lt;-rf.electionTimer.C: // start election DPrintf(\u0026#34;{Node: %v} election timeout\u0026#34;, rf.me) rf.mu.Lock() rf.ChangeState(StateCandidate) rf.currentTerm += 1 rf.StartElection() rf.electionTimer.Reset(RandomizedElectionTimeout()) rf.mu.Unlock() case \u0026lt;-rf.heartbeatTimer.C: // 领导者发送心跳维持领导力, 2A 可以先不实现 rf.mu.Lock() if rf.state == StateLeader { rf.BroadcastHeartbeat(true) rf.heartbeatTimer.Reset(StableHeartbeatTimeout()) } rf.mu.Unlock() } } } 选举与投票 当一个节点开始竞选:\n增加自己的 currentTerm 转为 Candidate 状态,其目标是获取超过半数节点的选票,让自己成为 Leader 先给自己投一票 并行地向集群中其它节点发送 RequestVote RPC 索要选票,如果没有收到指定节点的响应,它会反复尝试,直到发生以下三种情况之一: 获得超过半数的选票:成为 Leader,并向其它节点发送 AppendEntries 心跳; 收到来自 Leader 的 RPC:转为 Follower; 其它两种情况都没发生,没人能够获胜(electionTimeout 已过):增加 currentTerm,开始新一轮选举; Candidate 选举程序与投票统计\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 unc (rf *Raft) StartElection() { req := rf.genRequestVoteReq() DPrintf(\u0026#34;{Note: %v} starts election with RequestVoteReq: %v\u0026#34;, rf.me, req) // Closure grantedVote := 1 // elect for itself rf.votedFor = rf.me rf.persist() for peer := range rf.peers { if peer == rf.me { continue } go func(peer int) { resp := new(RequestVoteResponse) if rf.sendRequestVote(peer, req, resp) { rf.mu.Lock() defer rf.mu.Unlock() DPrintf(\u0026#34;[RequestVoteResp]-{Node: %v} receives RequestVoteResponse %v from {Node: %v} after sending RequestVoteRequest %v in term %v\u0026#34;, rf.me, resp, peer, req, rf.currentTerm) // rf.currentTerm == req.Term 为了抛弃过期的RequestVote RPC if rf.currentTerm == req.Term \u0026amp;\u0026amp; rf.state == StateCandidate { // Candidate node if resp.VoteGranted { grantedVote += 1 if grantedVote \u0026gt; len(rf.peers)/2 { DPrintf(\u0026#34;{Node: %v} receives majority votes in term %v\u0026#34;, rf.me, rf.currentTerm) rf.ChangeState(StateLeader) rf.BroadcastHeartbeat(true) } } else if resp.Term \u0026gt; rf.currentTerm { // candidate 发现有term 比自己大的,立刻转为follower DPrintf(\u0026#34;{Node %v} finds a new leader {Node %v} with term %v and steps down in term %v\u0026#34;, rf.me, peer, resp.Term, rf.currentTerm) rf.ChangeState(StateFollower) rf.currentTerm, rf.votedFor = resp.Term, -1 rf.persist() } } } }(peer) } } 发起投票需要异步进行,从而不阻塞ticker线程,这样candidate 再次 election timeout 之后才能自增 term 继续发起新一轮选举。 投票统计:可以在函数内定义一个变量并利用 go 的闭包来实现,也可以在结构体中维护一个 votes 变量来实现。为了 raft 结构体更干净,我选择了前者。 抛弃过期请求的回复:对于过期请求的回复,直接抛弃就行,不要做任何处理,这一点 guidance 里面也有介绍到 RequestVote 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 func (rf *Raft) RequestVote(req *RequestVoteRequest, resp *RequestVoteResponse) { // Your code here (2A, 2B). // 2A rf.mu.Lock() defer rf.mu.Unlock() defer rf.persist() defer DPrintf(\u0026#34;[RequestVote]-{Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} before processing requestVoteRequest %v and reply requestVoteResponse %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) if req.Term \u0026lt; rf.currentTerm || (req.Term == rf.currentTerm \u0026amp;\u0026amp; rf.votedFor != -1 \u0026amp;\u0026amp; rf.votedFor != req.CandidateId) { resp.Term, resp.VoteGranted = rf.currentTerm, false return } if req.Term \u0026gt; rf.currentTerm { rf.ChangeState(StateFollower) rf.currentTerm, rf.votedFor = req.Term, -1 } // 2A 可以先不实现 if !rf.isLogUpToDate(req.LastLogTerm, req.LastLogIndex) { resp.Term, resp.VoteGranted = rf.currentTerm, false return } rf.votedFor = req.CandidateId rf.electionTimer.Reset(RandomizedElectionTimeout()) resp.Term, resp.VoteGranted = rf.currentTerm, true } 「任期」表示节点的逻辑时钟,任期高的节点拥有更高的话语权。在RequestVote这个函数中,如果请求者的任期小于当前节点任期,则拒绝投票;如果请求者任期大于当前节点人气,那么当前节点立马成为追随者。即任期大的节点对任期小的拥有绝对的话语权,一旦发现任期大的节点,立马成为其追随者。\n注意,节点的选举随机时间和心跳时间的选择很重要\n节点随机选择超时时间,通常在 [T, 2T] 之间(T = electionTimeout) 这样,节点不太可能再同时开始竞选,先竞选的节点有足够的时间来索要其他节点的选票 T \u0026raquo; broadcast time(T 远大于广播时间)时效果更佳 1 2 3 4 5 6 7 8 9 10 11 12 13 const ( HeartbeatTimeout = 125 ElectionTimeout = 1000 ) func StableHeartbeatTimeout() time.Duration { // return time.Duration(HeartbeatTimeout) * time.Millisecond return HeartbeatTimeout * time.Millisecond } func RandomizedElectionTimeout() time.Duration { return time.Duration(ElectionTimeout+globalRand.Intn(ElectionTimeout)) * time.Millisecond } 总结 领导者选举主要工作可总结如下:\n三个状态,三个状态之间的转换。 1个loop——ticker。 1个RPC请求和处理,用于投票。 另外,ticker会一直运行,直到节点被kill,因此集群领导者并非唯一,一旦领导者出现了宕机、网络故障等问题,其它节点都能第一时间感知,并迅速做出重新选举的反应,从而维持集群的正常运行,毕竟Raft集群一旦失去了领导者,就无法工作。\n","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2a-leader-election/","summary":"介绍 查看Raft0 流程梳理 整体逻辑, 从 ticker goroutine 开始, 集群开始的时候,所有节点均为Follower, 它们依靠ticker()成为Candidate","title":"MIT6.824 2022 Raft Lab2A Leader Election"},{"content":"流程梳理 相关的RPC 在Raft0 中已经介绍, 这里不再赘述。 启动的Goroutine:\nticker 一个,用于监听 Election Timeout 或者Heartbeat Timeout applier 一个,监听 leader commit 之后,把log 发送到ApplyCh,然后从applyCh 中持久化到本地 replicator n-1 个,每一个对应一个 peer。监听心跳广播命令,仅在节点为 Leader 时工作, 唤醒条件变量。接收到命令后,向对应的 peer 发送 AppendEntries RPC。 日志结构 每个节点存储自己的日志副本(log[]),每条日志记录包含:\n索引:该记录在日志中的位置 任期号:该记录首次被创建时的任期号 命令 1 2 3 4 5 type Entry struct { Index int Term int Command interface{} } 日志「已提交」与「已应用」概念:\n已提交:committed, 数据在本地raft 日志中记录,没有应用到状态机 已应用:真正的数据变化。提交到大多数节点之后,应用到各自本地的状态机中。 已提交的日志被应用后才会生效\n日志同步: 日志同步是Leader独有的权利,Leader向Follower发送日志,Follower同步日志。\n日志同步要解决如下两个问题:\nLeader发送心跳宣示自己的主权,Follower不会发起选举。 Leader将自己的日志数据同步到Follower,达到数据备份的效果。 运行流程 客户端向 Leader 发送命令,希望该命令被所有状态机执行;\nLeader 先将该命令追加到自己的日志中; Leader 并行地向其它节点发送 AppendEntries RPC,等待响应; 收到超过半数节点的响应,则认为新的日志记录是被提交的: Leader 将命令传给自己的状态机,然后向客户端返回响应 一旦 Leader 知道一条记录被提交了,将在后续的 AppendEntries RPC 中通知已经提交记录的 Followers Follower 将已提交的命令传给自己的状态机 如果 Follower 宕机/超时:Leader 将反复尝试发送 RPC; 性能优化:Leader 不必等待每个 Follower 做出响应,只需要超过半数的成功响应(确保日志记录已经存储在超过半数的节点上)——一个很慢的节点不会使系统变慢,因为 Leader 不必等他;\nAppendEntries RPC 具体介绍参考此处文章\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 type AppendEntriesReq struct { Term int LeaderId int PrevLogIndex int PrevLogTerm int LeaderComment int Entries []Entry } func (req AppendEntriesReq) String() string { return fmt.Sprintf(\u0026#34;{Term: %d, LeaderId: %v, PreVoteLogIndex: %v, PreVoteLogTerm: %v, LeaderComment: %v, Entries: %v}\u0026#34;, req.Term, req.LeaderId, req.PrevLogIndex, req.PrevLogTerm, req.LeaderComment, req.Entries) } type AppendEntriesResp struct { Term int Success bool // for fast backup https://mit-public-courses-cn-translatio.gitbook.io/mit6-824/lecture-07-raft2/7.3-hui-fu-jia-su-backup-acceleration ConflictIndex int ConflictTerm int ConflictLen int } func (resp AppendEntriesResp) String() string { return fmt.Sprintf(\u0026#34;{Term:%v,Success:%v,ConflictIndex:%v,ConflictTerm:%v}\u0026#34;, resp.Term, resp.Success, resp.ConflictIndex, resp.ConflictTerm) } AppendEntries RPC\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 func (rf *Raft) AppendEntries(req *AppendEntriesReq, resp *AppendEntriesResp) { rf.mu.Lock() defer rf.mu.Unlock() defer rf.persist() defer DPrintf(\u0026#34;[AppendEntries]- {Node: %v}\u0026#39;s state is {state %v, term %v, commitIndex %v, lastApplied %v, firstLog %v, lastLog %v} before processing AppendEntriesRequest %v and reply AppendEntries %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) // 如果发现来自leader的rpc中的term比当前peer要小, // 说明是该RPC 来自旧的term(leader),|| 或者 当前leader 需要更新 不处理 if req.Term \u0026lt; rf.currentTerm { resp.Term, resp.Success = rf.currentTerm, false return } // 一般来讲,在vote的时候已经将currentTerm和leader同步 // 不过,有些peer暂时的掉线或者其他一些情况重连以后,会发现term和leader不一样 // 以收到大于自己的term的rpc也是第一时间同步.而且要将votefor重新设置为-1 // 等待将来选举 (说明这个peer 不是之前election 中投的的marjority) if req.Term \u0026gt; rf.currentTerm { rf.currentTerm, rf.votedFor = req.Term, -1 } rf.ChangeState(StateFollower) rf.electionTimer.Reset(RandomizedElectionTimeout()) // PrevLogIndex 比rf 当前的第一个Log index 还要小 if req.PrevLogIndex \u0026lt; rf.getFirstLog().Index { resp.Term, resp.Success = 0, false DPrintf(\u0026#34;[AppendEntries] - {Node: %v} receives unexpected AppendEntriesRequest %v from {Node: %v} because prevLogIndex %v \u0026lt; firstLogIndex %v\u0026#34;, rf.me, req, req.LeaderId, req.PrevLogIndex, rf.getFirstLog().Index) return } if !rf.matchLog(req.PrevLogTerm, req.PrevLogIndex) { // 日志的一致性检查失败后,递归找到需要追加日志的位置 resp.Term, resp.Success = rf.currentTerm, false lastIndex := rf.getLastLog().Index if lastIndex \u0026lt; req.PrevLogIndex { // lastIndex 和 nextIndex[peer] 之间有空洞 scenario3 // follower 在nextIndex[peer] 没有log resp.ConflictTerm = -1 resp.ConflictIndex = lastIndex + 1 } else { // scenario2, 1 // 以任期为单位进行回退 firstIndex := rf.getFirstLog().Index resp.ConflictTerm = rf.logs[req.PrevLogIndex-firstIndex].Term index := req.PrevLogIndex - 1 for index \u0026gt;= firstIndex \u0026amp;\u0026amp; rf.logs[index-firstIndex].Term == resp.ConflictTerm { index-- } resp.ConflictIndex = index } return } firstIndex := rf.getFirstLog().Index for i, entry := range req.Entries { // mergeLog // 添加的日志索引位置 比Follower 日志相同 直接添加 此处用大于等于,实际只有== // || 要添加的日志索引位置在 Follower 中的任期和AE RPC 中的Term 冲突 if entry.Index-firstIndex \u0026gt;= len(rf.logs) || rf.logs[entry.Index-firstIndex].Term != entry.Term { rf.logs = shrinkEntriesArray(append(rf.logs[:entry.Index-firstIndex], req.Entries[i:]...)) break } } rf.advanceCommitIndexForFollower(req.LeaderComment) resp.Term, resp.Success = rf.currentTerm, true } 复制模型(log replication) 对于复制模型,很直观的方式是:包装一个 BroadcastHeartbeat() 函数,其负责向所有 follower 发送一轮同步。不论是心跳超时还是上层服务传进来一个新 command,都去调一次这个函数来发起一轮同步。\n以上方式是可以 work 的,我最开始的实现也是这样的,然而在测试过程中,我发现这种方式有很大的资源浪费。比如上层服务连续调用了几十次 Start() 函数,由于每一次调用 Start() 函数都会触发一轮日志同步,则最终导致发送了几十次日志同步。一方面,这些请求包含的 entries 基本都一样,甚至有 entry 连续出现在几十次 rpc 中,这样的实现多传输了一些数据,存在一定浪费;另一方面,每次发送 rpc 都不论是发送端还是接收端都需要若干次系统调用和内存拷贝,rpc 次数过多也会对 CPU 造成不必要的压力。总之,这种资源浪费的根本原因就在于:将日志同步的触发与上层服务提交新指令强绑定,从而导致发送了很多重复的 rpc。\n为此,参考了 sofajraft 的日志复制实现 。每个 peer 在启动时会为除自己之外的每个 peer 都分配一个 replicator 协程。对于 follower 节点,该协程利用条件变量执行 wait 来避免耗费 cpu,并等待变成 leader 时再被唤醒;对于 leader 节点,该协程负责尽最大地努力去向对应 follower 发送日志使其同步,直到该节点不再是 leader 或者该 follower 节点的 matchIndex 大于等于本地的 lastIndex。\n这样的实现方式能够将日志同步的触发和上层服务提交新指令解耦,能够大幅度减少传输的数据量,rpc 次数和系统调用次数。由于 6.824 的测试能够展示测试过程中的传输 rpc 次数和数据量,因此我进行了前后的对比测试,结果显示:这样的实现方式相比直观方式的实现,不同测试数据传输量的减少倍数在 1-20 倍之间。当然,这样的实现也只是实现了粗粒度的 batching,并没有流量控制,而且也没有实现 pipeline,有兴趣的同学可以去了解 sofajraft, etcd 或者 tikv 的实现,他们对于复制过程进行了更细粒度的控制。\n此外,虽然 leader 对于每一个节点都有一个 replicator 协程去同步日志,但其目前同时最多只能发送一个 rpc,而这个 rpc 很可能超时或丢失从而触发集群换主。因此,对于 heartbeat timeout 触发的 BroadcastHeartbeat,我们需要立即发出日志同步请求而不是让 replicator 去发。这也就是我的 BroadcastHeartbeat 函数有两种行为的真正原因。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 // handleAppendEntriesResponse peer handle AppendEntries RPC func (rf *Raft) handleAppendEntriesResponse(peer int, req *AppendEntriesReq, resp *AppendEntriesResp) { defer DPrintf(\u0026#34;[handleAppendEntriesResponse]-{Node %v}\u0026#39;s state is {state %v,term %v,commitIndex %v,lastApplied %v,firstLog %v,lastLog %v} after handling AppendEntriesResponse %v for AppendEntriesRequest %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), resp, req) if rf.state == StateLeader \u0026amp;\u0026amp; rf.currentTerm == req.Term { if resp.Success { // 更新matchIndex, nextIndex rf.matchIndex[peer] = req.PrevLogIndex + len(req.Entries) rf.nextIndex[peer] = rf.matchIndex[peer] + 1 rf.advanceCommitIndexForLeader() } else { // term 太小而失败 if resp.Term \u0026gt; rf.currentTerm { rf.ChangeState(StateFollower) rf.currentTerm, rf.votedFor = resp.Term, -1 rf.persist() } else if resp.Term == rf.currentTerm { // 日志不匹配而失败 rf.nextIndex[peer] = resp.ConflictIndex // 1. 如果在Leader 中能找到和Follower 有相同的ConflictTerm, // 返回该Leader Term 的最后一个Log 作为nextIndex[peer] // 2. 如果找不到相同的Term,返回Follower 中的ConflictTerm 的第一个日志,即ConflictIndex if resp.ConflictTerm != -1 { firstIndex := rf.getFirstLog().Index for i := req.PrevLogIndex; i \u0026gt;= firstIndex; i-- { if rf.logs[i-firstIndex].Term == resp.ConflictTerm { rf.nextIndex[peer] = i break } } } } } } } func (rf *Raft) replicator(peer int) { rf.replicatorCond[peer].L.Lock() defer rf.replicatorCond[peer].L.Unlock() for !rf.killed() { // if there is no need to replicate entries for this peer, // just release CPU and wait other goroutine\u0026#39;s signal if service adds new Command // if this peer needs replicating entries, this goroutine will call // replicateOneRound(peer) multiple times until this peer catches up, and then wait // Only Leader 可以Invoke 这个方法,通过.Singal 唤醒各个peer, 不是Leader 不生效 for !rf.needReplicating(peer) { rf.replicatorCond[peer].Wait() } // maybe a pipeline mechanism is better to trade-off the memory usage and catch up time rf.replicateOneRound(peer) } } 日志应用 异步 applier 的 exactly once Raft论文的说话,一旦发现commitIndex大于lastApplied,应该立马将可应用的日志应用到状态机中。Raft节点本身是没有状态机实现的,状态机应该由Raft的上层应用来实现,因此我们不会谈论如何实现状态机,只需将日志发送给applyCh这个通道即可。\n对于异步 apply,其触发方式无非两种,leader 提交了新的日志或者 follower 通过 leader 发来的 leaderCommit 来更新 commitIndex。很多人实现的时候可能顺手就在这两处异步启一个协程把 [lastApplied + 1, commitIndex] 的 entry push 到 applyCh 中,但其实这样子是可能重复发送 entry 的,原因是 push applyCh 的过程不能够持锁,那么这个 lastApplied 在没有 push 完之前就无法得到更新,从而可能被多次调用。虽然只要上层服务可以保证不重复 apply 相同 index 的日志到状态机就不会有问题,但我个人认为这样的做法是不优雅的。考虑到异步 apply 时最耗时的步骤是 apply channel 和 apply 日志到状态机,其他的都不怎么耗费时间。因此我们完全可以只用一个 applier 协程,让其不断的把 [lastApplied + 1, commitIndex] 区间的日志 push 到 applyCh 中去。这样既可保证每一条日志只会被 exactly once 地 push 到 applyCh 中,也可以使得日志 apply 到状态机和 raft 提交新日志可以真正的并行。我认为这是一个较为优雅的异步 apply 实现。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 // applier a dedicated applier goroutine to guarantee that each log will be push into // applyCh exactly once, ensuring that service\u0026#39;s applying entries and raft\u0026#39;s // committing entries can be parallel func (rf *Raft) applier() { for !rf.killed() { rf.mu.Lock() // if there is no need to apply entries, // just release CPU and wait other goroutine\u0026#39;s signal if they commit new entries for rf.lastApplied \u0026gt;= rf.commitIndex { rf.applyCond.Wait() } firstIndex, commitIndex, lastApplied := rf.getFirstLog().Index, rf.commitIndex, rf.lastApplied entries := make([]Entry, commitIndex-lastApplied) copy(entries, rf.logs[lastApplied+1-firstIndex:commitIndex+1-firstIndex]) rf.mu.Unlock() for _, entry := range entries { rf.applyCh \u0026lt;- ApplyMsg{ CommandValid: true, Command: entry.Command, CommandTerm: entry.Term, CommandIndex: entry.Index, } } rf.mu.Lock() DPrintf(\u0026#34;{Node %v} applies entries %v-%v in term %v\u0026#34;, rf.me, rf.lastApplied, commitIndex, rf.currentTerm) rf.lastApplied = Max(rf.lastApplied, commitIndex) rf.mu.Unlock() } } 需要注意以下两点:\n引用之前的 commitIndex:push applyCh 结束之后更新 lastApplied 的时候一定得用之前的 commitIndex 而不是 rf.commitIndex,因为后者很可能在 push channel 期间发生了改变。 防止与 installSnapshot 并发导致 lastApplied 回退:需要注意到,applier 协程在 push channel 时,中间可能夹杂有 snapshot 也在 push channel。如果该 snapshot 有效,那么在 CondInstallSnapshot 函数里上层状态机和 raft 模块就会原子性的发生替换,即上层状态机更新为 snapshot 的状态,raft 模块更新 log, commitIndex, lastApplied 等等,此时如果这个 snapshot 之后还有一批旧的 entry 在 push channel,那上层服务需要能够知道这些 entry 已经过时,不能再 apply,同时 applier 这里也应该加一个 Max 自身的函数来防止 lastApplied 出现回退。 快速恢复(Fast Backup) 在前面(7.1)介绍的日志恢复机制中,如果Log有冲突,Leader每次会回退一条Log条目。 这在许多场景下都没有问题。但是在某些现实的场景中,至少在Lab2的测试用例中,每次只回退一条Log条目会花费很长很长的时间。所以,现实的场景中,可能一个Follower关机了很长时间,错过了大量的AppendEntries消息。这时,Leader重启了。按照Raft论文中的图2,如果一个Leader重启了,它会将所有Follower的nextIndex设置为Leader本地Log记录的下一个槽位(7.1有说明)。所以,如果一个Follower关机并错过了1000条Log条目,Leader重启之后,需要每次通过一条RPC来回退一条Log条目来遍历1000条Follower错过的Log记录。这种情况在现实中并非不可能发生。在一些不正常的场景中,假设我们有5个服务器,有1个Leader,这个Leader和另一个Follower困在一个网络分区。但是这个Leader并不知道它已经不再是Leader了。它还是会向它唯一的Follower发送AppendEntries,因为这里没有过半服务器,所以没有一条Log会commit。在另一个有多数服务器的网络分区中,系统选出了新的Leader并继续运行。旧的Leader和它的Follower可能会记录无限多的旧的任期的未commit的Log。当旧的Leader和它的Follower重新加入到集群中时,这些Log需要被删除并覆盖。可能在现实中,这不是那么容易发生,但是你会在Lab2的测试用例中发现这个场景。\n所以,为了更快的恢复日志,Raft论文在5.3结尾处,对这种方法有了一些模糊的描述。原文有些晦涩,在这里我会以一种更好的方式,尝试解释论文中有关快速恢复的方法。大致思想是,让Follower返回足够多的信息给Leader,这样Leader可以以任期(Term)为单位来回退,而不用每次只回退一条Log条目。所以现在,在恢复Follower的Log时,如果Leader和Follower的Log不匹配,Leader只需要对不同任期发生一条AEs,而不需要对每个不通Log条目发送一条AEs。这是一种加速策略,当然也可以有别的日志恢复的加速策略。\n我将可能出现的场景分成3类,为了简化,这里只画出一个Leader(S2)和一个Follower(S1),S2将要发送一条任期号为6的AppendEntries消息给Follower。\n场景1:S1(Follower)没有任期6的任何Log,因此我们需要回退一整个任期的Log。 场景2:S1收到了任期4的旧Leader的多条Log,但是作为新Leader,S2只收到了一条任期4的Log。所以这里,我们需要覆盖S1中有关旧Leader的一些Log。 场景3: S1与S2的Log不冲突,但是S1缺失了部分S2中的Log 可以让Follower在回复Leader的AppendEntries消息中,携带3个额外的信息,来加速日志的恢复。这里的回复是指,Follower因为Log信息不匹配,拒绝了Leader的AppendEntries之后的回复。这里的三个信息是指:\nXTerm: 这个是Follower中与Leader冲突的Log对应的任期号。在之前(7.1)有介绍Leader会在prevLogTerm中带上本地Log记录中,前一条Log的任期号。如果Follower在对应位置的任期号不匹配,它会拒绝Leader的AppendEntries消息,并将自己的任期号放在XTerm中。如果Follower在对应位置没有Log,那么这里会返回 -1。 XIndex: 这个是Follower中,对应任期号为XTerm的第一条Log条目的槽位号。 XLen: 如果Follower在对应位置没有Log,那么XTerm会返回-1,XLen表示空白的Log槽位数。 我们再来看这些信息是如何在上面3个场景中,帮助Leader快速回退到适当的Log条目位置。\n场景1: Follower(S1)会返回XTerm=5,XIndex=2。Leader(S2)发现自己没有任期5的日志,它会将自己本地记录的,S1的nextIndex设置到XIndex,也就是S1中,任期5的第一条Log对应的槽位号。所以,如果Leader完全没有XTerm的任何Log,那么它应该回退到XIndex对应的位置(这样,Leader发出的下一条AppendEntries就可以一次覆盖S1中所有XTerm对应的Log) 场景2: Follower(S1)会返回XTerm=4,XIndex=1。Leader(S2)发现自己其实有任期4的日志,它会将自己本地记录的S1的nextIndex设置到本地在XTerm位置的Log条目后面,也就是槽位2。下一次Leader发出下一条AppendEntries时,就可以一次覆盖S1中槽位2和槽位3对应的Log。 场景3: Follower(S1)会返回XTerm=-1,XLen=2。这表示S1中日志太短了,以至于在冲突的位置没有Log条目,Leader应该回退到Follower最后一条Log条目的下一条,也就是槽位2,并从这开始发送AppendEntries消息。槽位2可以从XLen中的数值计算得到。 在本次的实现中以Term 为单位返回,不在一个一个Index 自减。这需要添加 ConflicTerm, ConflictIndex 字段 去记录出现冲突的位置和任期。然后在 HanleAppendEntries RPC 中,在 Leader 的log 中检查 ConflictIndex 位置的日志一致性。\n为什么Raft协议不能提交之前任期的日志? 查看\n函数解析 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 func (rf *Raft) AppendEntries(req *AppendEntriesReq, resp *AppendEntriesResp) { rf.mu.Lock() defer rf.mu.Unlock() defer rf.persist() defer DPrintf(\u0026#34;[AppendEntries]- {Node: %v}\u0026#39;s state is {state %v, term %v, commitIndex %v, lastApplied %v, firstLog %v, lastLog %v} before processing AppendEntriesRequest %v and reply AppendEntries %v\u0026#34;, rf.me, rf.state, rf.currentTerm, rf.commitIndex, rf.lastApplied, rf.getFirstLog(), rf.getLastLog(), req, resp) // 如果发现来自leader的rpc中的term比当前peer要小, // 说明是该RPC 来自旧的term(leader),|| 或者 当前leader 需要更新 不处理 if req.Term \u0026lt; rf.currentTerm { resp.Term, resp.Success = rf.currentTerm, false return } // 一般来讲,在vote的时候已经将currentTerm和leader同步 // 不过,有些peer暂时的掉线或者其他一些情况重连以后,会发现term和leader不一样 // 以收到大于自己的term的rpc也是第一时间同步.而且要将votefor重新设置为-1 // 等待将来选举 (说明这个peer 不是之前election 中投的的marjority) if req.Term \u0026gt; rf.currentTerm { rf.currentTerm, rf.votedFor = req.Term, -1 } rf.ChangeState(StateFollower) rf.electionTimer.Reset(RandomizedElectionTimeout()) // PrevLogIndex 比rf 当前的第一个Log index 还要小 if req.PrevLogIndex \u0026lt; rf.getFirstLog().Index { resp.Term, resp.Success = 0, false DPrintf(\u0026#34;[AppendEntries] - {Node: %v} receives unexpected AppendEntriesRequest %v from {Node: %v} because prevLogIndex %v \u0026lt; firstLogIndex %v\u0026#34;, rf.me, req, req.LeaderId, req.PrevLogIndex, rf.getFirstLog().Index) return } if !rf.matchLog(req.PrevLogTerm, req.PrevLogIndex) { // 日志的一致性检查失败后,递归找到需要追加日志的位置 resp.Term, resp.Success = rf.currentTerm, false lastIndex := rf.getLastLog().Index if lastIndex \u0026lt; req.PrevLogIndex { // lastIndex 和 nextIndex[peer] 之间有空洞 scenario3 // follower 在nextIndex[peer] 没有log resp.ConflictTerm = -1 resp.ConflictIndex = lastIndex + 1 } else { // scenario2, 1 // 以任期为单位进行回退 firstIndex := rf.getFirstLog().Index resp.ConflictTerm = rf.logs[req.PrevLogIndex-firstIndex].Term index := req.PrevLogIndex - 1 for index \u0026gt;= firstIndex \u0026amp;\u0026amp; rf.logs[index-firstIndex].Term == resp.ConflictTerm { index-- } resp.ConflictIndex = index } return } firstIndex := rf.getFirstLog().Index for i, entry := range req.Entries { // mergeLog // 添加的日志索引位置 比Follower 日志相同 直接添加 此处用大于等于,实际只有== // || 要添加的日志索引位置在 Follower 中的任期和AE RPC 中的Term 冲突 if entry.Index-firstIndex \u0026gt;= len(rf.logs) || rf.logs[entry.Index-firstIndex].Term != entry.Term { rf.logs = shrinkEntriesArray(append(rf.logs[:entry.Index-firstIndex], req.Entries[i:]...)) break } } rf.advanceCommitIndexForFollower(req.LeaderComment) resp.Term, resp.Success = rf.currentTerm, true } ","permalink":"https://reid00.github.io/en/posts/storage/mit6.824-2022-raft-lab2b-log-replication/","summary":"流程梳理 相关的RPC 在Raft0 中已经介绍, 这里不再赘述。 启动的Goroutine: ticker 一个,用于监听 Election Timeout 或者Heartbeat Timeout applier 一个,监听","title":"MIT6.824 2022 Raft Lab2B Log Replication"},{"content":"介绍 Go 语言没有构造函数,一般通过定义 New 函数来充当构造函数。然而,如果结构有较多字段,要初始化这些字段,有很多种方式,但有一种方式认为是最好的,这就是函数式选项模式(Functional Options Pattern)。\n函数式选项模式是一种在 Go 中构造结构体的模式,它通过设计一组非常有表现力和灵活的 API 来帮助配置和初始化结构体。\n在 Uber 的 Go 语言规范 中提到了该模式:\nFunctional options 是一种模式,在该模式中,你可以声明一个不透明的 Option 类型,该类型在某些内部结构中记录信息。你接受这些可变数量的选项,并根据内部结构上的选项记录的完整信息进行操作。 将此模式用于构造函数和其他公共 API 中的可选参数,你预计这些参数需要扩展,尤其是在这些函数上已经有三个或更多参数的情况下。\nDemo 为了更好的理解该模式,我们通过一个例子来讲解。\n定义一个 Server 结构体\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 package main type Server struct { host string port int } func New(host string, port int) *Server { return \u0026amp;Server{host, port} } func (s *Server) Start() error { } 使用\n1 2 3 4 5 6 7 8 9 10 11 12 13 package main import ( \u0026#34;log\u0026#34; \u0026#34;server\u0026#34; ) func main() { svr := New(\u0026#34;localhost\u0026#34;, 1234) if err := svr.Start(); err != nil { log.Fatal(err) } } 但如果要扩展 Server 的配置选项,如何做?通常有三种做法:\n为每个不同的配置选项声明一个新的构造函数 定义一个新的 Config 结构体来保存配置信息 使用 Functional Option Pattern 做法 1:为每个不同的配置选项声明一个新的构造函数 1 2 3 4 5 6 type Server struct { host string port int timeout time.Duration maxConn int } 一般来说,host 和 port 是必须的字段,而 timeout 和 maxConn 是可选的,所以,可以保留原来的构造函数,而这两个字段给默认值:\n1 2 3 func New(host string, port int) *Server { return \u0026amp;Server{host, port, time.Minute, 100} } 然后针对 timeout 和 maxConn 额外提供两个构造函数:\n1 2 3 4 5 6 7 func NewWithTimeout(host string, port int, timeout time.Duration) *Server { return \u0026amp;Server{host, port, timeout} } func NewWithTimeoutAndMaxConn(host string, port int, timeout time.Duration, maxConn int) *Server { return \u0026amp;Server{host, port, timeout, maxConn} } 这种方式配置较少且不太会变化的情况,否则每次你需要为新配置创建新的构造函数。在 Go 语言标准库中,有这种方式的应用。比如 net 包中的 Dial 和 DialTimeout:\n1 2 func Dial(network, address string) (Conn, error) func DialTimeout(network, address string, timeout time.Duration) (Conn, error) 做法 2:使用专门的配置结构体 这种方式也是很常见的,特别是当配置选项很多时。通常可以创建一个 Config 结构体,其中包含 Server 的所有配置选项。这种做法,即使将来增加更多配置选项,也可以轻松的完成扩展,不会破坏 Server 的 API。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 type Server struct { cfg Config } type Config struct { Host string Port int Timeout time.Duration MaxConn int } func New(cfg Config) *Server { return \u0026amp;Server{cfg} } 在使用时,需要先构造 Config 实例,对这个实例,又回到了前面 Server 的问题上,因为增加或删除选项,需要对 Config 有较大的修改。如果将 Config 中的字段改为私有,可能需要定义 Config 的构造函数。。。\n做法 3:使用 Functional Option Pattern 一个更好的解决方案是使用 Functional Option Pattern。\n在这个模式中,我们定义一个 Option 函数类型:\n1 type Option func(*Server) Option 类型是一个函数类型,它接收一个参数:*Server。然后,Server 的构造函数接收一个 Option 类型的不定参数:\n1 2 3 4 5 6 7 func New(options ...Option) *Server { svr := \u0026amp;Server{} for _, f := range options { f(svr) } return svr } 那选项如何起作用?需要定义一系列相关返回 Option 的函数:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 func WithHost(host string) Option { return func(s *Server) { s.host = host } } func WithPort(port int) Option { return func(s *Server) { s.port = port } } func WithTimeout(timeout time.Duration) Option { return func(s *Server) { s.timeout = timeout } } func WithMaxConn(maxConn int) Option { return func(s *Server) { s.maxConn = maxConn } } 针对这种模式,客户端类似这么使用:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package main import ( \u0026#34;log\u0026#34; \u0026#34;server\u0026#34; ) func main() { svr := New( WithHost(\u0026#34;localhost\u0026#34;), WithPort(8080), WithTimeout(time.Minute), WithMaxConn(120), ) if err := svr.Start(); err != nil { log.Fatal(err) } } 将来增加选项,只需要增加对应的 WithXXX 函数即可。\n这种模式,在第三方库中使用挺多,比如 github.com/gocolly/colly:\n1 2 3 4 5 6 7 8 9 10 11 12 type Collector { // 省略... } func NewCollector(options ...CollectorOption) *Collector // 定义了一系列 CollectorOpiton type CollectorOption{ // 省略... } func AllowURLRevisit() CollectorOption func AllowedDomains(domains ...string) CollectorOption ... 不过 Uber 的 Go 语言编程规范中提到该模式时,建议定义一个 Option 接口,而不是 Option 函数类型。该 Option 接口有一个未导出的方法,然后通过一个未导出的 options 结构来记录各选项。\nOption Interface 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 // 需要添加的配置参数字段放到options field 中 type options struct { cache bool logger *zap.Logger } type Option interface { apply(*options) } // 新定义一个类型,用来重置options 里面对应的字段 type cacheOption bool // apply 将类型c 的值,赋给options 对应的field func (c cacheOption) apply(opts *options) { opts.cache = bool(c) } // with开头新建一个 上面定义的类型 func WithCache(c bool) Option { return cacheOption(c) } // 新定义的类型中,包含了需要重中options 的logger field type loggerOption struct { Log *zap.Logger } // apply 将新类型的Log field 给options 的对应field func (l loggerOption) apply(opts *options) { opts.logger = l.Log } // with开头新建一个 上面定义的类型 func WithLogger(log *zap.Logger) Option { return loggerOption{Log: log} } // Open creates a connection. func Open(addr string, opts ...Option) (*Connection, error) { // 新建一个options 的配置类型 options := options{ cache: defaultCache, logger: zap.NewNop(), } // 遍历Option 接口类型,调用apply 方法,将o的field 赋值给options 配置 for _, o := range opts { o.apply(\u0026amp;options) } // ... } ","permalink":"https://reid00.github.io/en/posts/langs_linux/go-function-option-%E5%87%BD%E6%95%B0%E9%80%89%E9%A1%B9%E6%A8%A1%E5%BC%8F/","summary":"介绍 Go 语言没有构造函数,一般通过定义 New 函数来充当构造函数。然而,如果结构有较多字段,要初始化这些字段,有很多种方式,但有一种方式认为是最好的","title":"Go Function Option 函数选项模式"},{"content":"介绍 Join大致包括三个要素:Join方式、Join条件以及过滤条件。其中过滤条件也可以通过AND语句放在Join条件中。 Spark支持的Join 包括:\ninner join left outer join right outer join full outer join left semi join left anti join Join 的基本流程 总体上来说,Join的基本实现流程如下图所示,Spark将参与Join的两张表抽象为流式遍历表(streamIter)和查找表(buildIter),通常streamIter为大表,buildIter为小表,我们不用担心哪个表为streamIter,哪个表为buildIter,这个spark会根据join语句自动帮我们完成。 在实际计算时,spark会基于streamIter来遍历,每次取出streamIter中的一条记录rowA,根据Join条件计算keyA,然后根据该keyA去buildIter中查找所有满足Join条件(keyB==keyA)的记录rowBs,并将rowBs中每条记录分别与rowAjoin得到join后的记录,最后根据过滤条件得到最终join的记录。\n从上述计算过程中不难发现,对于每条来自streamIter的记录,都要去buildIter中查找匹配的记录,所以buildIter一定要是查找性能较优的数据结构 如Hash Table。spark提供了三种join实现:sort merge join、broadcast join以及hash join。\nHash join实现 spark提供了hash join实现方式,在shuffle read阶段不对记录排序,反正来自两格表的具有相同key的记录会在同一个分区,只是在分区内不排序,将来自buildIter的记录放到hash表中,以便查找,如下图所示。\n由于Spark是一个分布式的计算引擎,可以通过分区的形式将大批量的数据划分成n份较小的数据集进行并行计算。这种思想应用到Join上便是Shuffle Hash Join了。利用key相同必然分区相同的这个原理,SparkSQL将较大表的join分而治之,先将表划分成n个分区,在对buildlter查找表和streamlter表进行Hash Join。 Shuffle Hash Join分为两步: 对两张表分别按照join keys进行重分区,即shuffle,目的是为了让有相同join keys值的记录分到对应的分区中 对 对应分区中的数据进行join,此处先将小表分区构造为一张hash表,然后根据大表分区中记录的join keys值拿出来进行匹配 不难发现,要将来自buildIter的记录放到hash表中,那么每个分区来自buildIter的记录不能太大,否则就存不下,默认情况下hash join的实现是关闭状态,如果要使用hash join,必须满足以下四个条件:\nbuildIter总体估计大小超过spark.sql.autoBroadcastJoinThreshold设定的值,即不满足broadcast join条件 开启尝试使用hash join的开关,spark.sql.join.preferSortMergeJoin=false 每个分区的平均大小不超过spark.sql.autoBroadcastJoinThreshold设定的值,即shuffle read阶段每个分区来自buildIter的记录要能放到内存中 streamIter的大小是buildIter三倍以上 Sort Merge Join 实现 上面介绍的实现对于一定大小的表比较适用,但当两个表都非常大时,显然无论适用哪种都会对计算内存造成很大压力。这是因为join时两者采取的都是hash join,是将一侧的数据完全加载到内存中,使用hash code取join keys值相等的记录进行连接。\n要让两条记录能join到一起,首先需要将具有相同key的记录在同一个分区,所以通常来说,需要做一次shuffle,map阶段根据join条件确定每条记录的key,基于该key做shuffle write,将可能join到一起的记录分到同一个分区中,这样在shuffle read阶段就可以将两个表中具有相同key的记录拉到同一个分区处理。前面我们也提到,对于buildIter一定要是查找性能较优的数据结构,通常我们能想到hash表,但是对于一张较大的表来说,不可能将所有记录全部放到hash表中,SparkSQL采用了一种全新的方案来对表进行Join,即Sort Merge Join。这种实现方式不用将一侧数据全部加载后再进行hash join,但需要在join前将数据排序,如下图所示: 三个步骤: shuffle阶段:或者说shuffle write 阶段,将两张大表根据join key进行重新分区,两张表数据会分布到整个集群,以便分布式并行处理 sort阶段:对单个分区节点的两表数据,分别进行排序 merge阶段:或者说shuffle read 阶段,对排好序的两张分区表数据执行join操作。join操作很简单,分别遍历两个有序序列,碰到相同join key就merge输出,否则取更小一边\n在shuffle read阶段,分别对streamIter和buildIter进行merge sort,在遍历streamIter时,对于每条记录,都采用顺序查找的方式从buildIter查找对应的记录,由于两个表都是排序的,每次处理完streamIter的一条记录后,对于streamIter的下一条记录,只需从buildIter中上一次查找结束的位置开始查找,所以说每次在buildIter中查找不必重头开始,整体上来说,查找性能还是较优的。\n仔细分析的话会发现,sort-merge join的代价并不比shuffle hash join小,反而是多了很多。那为什么SparkSQL还会在两张大表的场景下选择使用sort-merge join算法呢?这和Spark的shuffle实现有关,目前spark的shuffle实现都适用sort-based shuffle算法,因此在经过shuffle之后partition数据都是按照key排序的。因此理论上可以认为数据经过shuffle之后是不需要sort的,可以直接merge。\nBroadcast Join实现 为了能具有相同key的记录分到同一个分区,我们通常是做shuffle,而shuffle在Spark中是比较耗时的操作,我们应该尽可能的设计Spark应用使其避免大量的shuffle。。那么如果buildIter是一个非常小的表,那么其实就没有必要大动干戈做shuffle了,直接将buildIter广播到每个计算节点,然后将buildIter放到hash表中,如下图所示。 在执行上,主要可以分为以下两步:\nbroadcast阶段:将小表广播分发到大表所在的所有主机。分发方式可以有driver分发,或者采用p2p方式。 hash join阶段:在每个executor上执行单机版hash join,小表映射,大表试探; Broadcast Join的条件有以下几个:\n被广播的表需要小于spark.sql.autoBroadcastJoinThreshold所配置的值,默认是10M (或者加了broadcast join的hint) 基表不能被广播,比如left outer join时,只能广播右表 Hive Join Hive中的Join可分为Common Join(Reduce阶段完成join)和Map Join(Map阶段完成join)。\nHive Common Join 如果不指定MapJoin或者不符合MapJoin的条件,那么Hive解析器会默认把执行Common Join,即在Reduce阶段完成join。整个过程包含Map、Shuffle、Reduce阶段。\nMap阶段 读取源表的数据,Map输出时候以Join on条件中的列为key,如果Join有多个关联键,则以这些关联键的组合作为key;Map输出的value为join之后所关心的(select或者where中需要用到的)列,同时在value中还会包含表的Tag信息,用于标明此value对应哪个表。\nShuffle阶段 根据key的值进行hash,并将key/value按照hash值推送至不同的reduce中,这样确保两个表中相同的key位于同一个reduce中。\nReduce阶段 根据key的值完成join操作,期间通过Tag来识别不同表中的数据。\n1 2 3 SELECT a.id,a.dept,b.age FROM a join b ON (a.id = b.id); Hive Map Join MapJoin通常用于一个很小的表和一个大表进行join的场景,具体小表有多小,由参数hive.mapjoin.smalltable.filesize来决定,默认值为25M。满足条件的话Hive在执行时候会自动转化为MapJoin,或使用hint提示 /*+ mapjoin(table) */执行MapJoin。 如上图中的流程,首先Task A在客户端本地执行,负责扫描小表b的数据,将其转换成一个HashTable的数据结构,并写入本地的文件中,之后将该文件加载到DistributeCache中。 接下来的Task B任务是一个没有Reduce的MapReduce,启动MapTasks扫描大表a,在Map阶段,根据a的每一条记录去和DistributeCache中b表对应的HashTable关联,并直接输出结果,因为没有Reduce,所以有多少个Map Task,就有多少个结果文件。 注意:Map JOIN不适合FULL/RIGHT OUTER JOIN。\n","permalink":"https://reid00.github.io/en/posts/computation/spark-join-%E5%8E%9F%E7%90%86%E8%AF%A6%E8%A7%A3/","summary":"介绍 Join大致包括三个要素:Join方式、Join条件以及过滤条件。其中过滤条件也可以通过AND语句放在Join条件中。 Spark支持的J","title":"Spark Join 原理详解"},{"content":"前言 刚工作那会,有一次,上游调用我服务的老哥说,你的服务报\u0026quot;502错误了,快去看看是为什么吧\u0026quot;。\n当时那个服务里正好有个调用日志,平时会记录各种200,4xx状态码的信息。于是我跑到服务日志里去搜索了一下502这个数字,毫无发现。于是跟老哥说,\u0026quot;服务日志里并没有502的记录,你是不是搞错啦?\u0026quot;\n现在想来,多少有些不好意思。\n不知道有多少老哥是跟当时的我是一样的,这篇文章,就来聊聊502错误是什么?\n我们从状态码是什么开始聊起。\nHTTP状态码 我们平时在浏览器里逛的某宝和某度,其实都是一个个前端网页。 一般来说,前端并不存储太多数据,大部分时候都需要从后端服务器那获取数据。 于是前后端之间需要通过TCP协议去建立连接,然后在TCP的基础上传输数据。\n而TCP是基于数据流的协议,传输数据时,并不会为每个消息加入数据边界,直接使用裸的TCP进行数据传输会有\u0026quot;粘包\u0026quot;问题。\n因此需要用特地的协议格式去对数据进行解析。于是在此基础上设计了HTTP协议。详细的内容可以看我之前写的《既然有HTTP协议,为什么还要有RPC》。\n比如,我想要看某个商品的具体信息,其实就是前端发的HTTP请求中传入商品的id,后端返回的HTTP响应中返回商品的价格,商店名,发货地址的信息等。\n这样,表面上,我们是在刷着各种网页,实际上背后正有多次HTTP消息在不断进行收发。\n但问题就来了,上面提到的都是正常情况,如果有异常情况呢,比如前端发的数据,根本就不是个商品id,而是一张图片,这对于后端服务端来说是不可能给出正常响应的,于是就需要设计一套HTTP状态码,用来标识这次HTTP请求响应流程是否正常。通过这个可以影响浏览器的行为。\n比方说一切正常,那服务端返回个200状态码,前端收到后,可以放心使用响应的数据。但如果服务端发现客户端发的东西异常,就响应个4xx状态码,意思是这是个客户端的错误,4xx里头的xx可以根据错误的类型,再细分成各种码,比如401是客户端没权限,404是客户端请求了一个根本不存在的网页。反过来,如果是服务器有问题,就返回5xx状态码。\n但问题就来了。 服务端都有问题了,搞严重点,服务器可能直接就崩溃了,那它还怎么给你返回状态码? 是的,这种情况,服务端是不可能给客户端返回状态码的。所以说,一般情况下5xx的状态码其实并不是服务器返回给客户端的。 它们是由网关返回的,常见的网关,比如nginx。\nnginx的作用 回到前后端交互数据的话题上,如果前端用户少,那后端处理起请求来,游刃有余。但随着用户越来越多,后端服务器受资源限制,cpu或者内存都可能会严重不足,这时候解决方案也很简单,多搞几台一样的服务器,这样就能将这些前端请求均摊给几个服务器,从而提升处理能力。\n但要实现这样的效果,前端就得知道后端具体有哪些个服务器,并一一跟他们建立TCP连接。\n也不是不行,但就是麻烦。\n但这时候如果能有个中间层挡在它们中间就好了,这样客户端只需要跟中间层连接,中间层再和服务器建立连接。\n于是,这个中间层就成了这帮服务器的一个代理人一样,客户端有啥事都找代理人,只管发出自己的请求,再由代理人去找某个服务器去完成响应。整个过程下来,客户端只知道自己的请求被代理人帮忙搞定了,但代理人具体找了那个服务器去完成,客户端并不知道,也不需要知道。\n像这种,屏蔽掉具体有哪些服务器的代理方式就是所谓的反向代理。\n反过来,屏蔽掉具体有哪些客户端的代理方式,就是所谓的正向代理。\n而这个中间层的角色,一般由nginx这类网关来充当。\n另外,由于背后的服务器可能性能配置各不相同,有些4核8G,有些2核4G,nginx能为它们加上不同的访问权重,权重高的多转发点请求,通过这个方式实现不同的负载均衡策略。\nnginx返回5xx状态码 有了nginx这一中间层后,客户端从直连服务端,变成客户端直连nginx,再由nginx直连服务端。从一个TCP连接变成两个TCP连接。\n于是,当服务器发生异常时,nginx发送给服务器的那条TCP连接就不能正常响应,nginx在得到这一信息后,就会返回5xx错误码给客户端,也就是说5xx的报错,其实是由nginx识别出来,并返回给客户端的,服务端本身,并不会有5xx的日志信息。所以才会出现文章开头的一幕,上游收到了我服务的502报错,但我在自己的服务日志里却搜索不到这一信息。\n产生502的常见原因 在rfc7231中有关于502错误码的官方解释是\n1 2 502 Bad Gateway The 502 (Bad Gateway) status code indicates that the server, while acting as a gateway or proxy, received an invalid response from an inbound server it accessed while attempting to fulfill the request. 翻译一下就是,502 (Bad Gateway) 状态代码表示服务器在充当网关或代理时,在尝试满足请求时从它访问的入站服务器接收到无效响应。\n汝听,人言否?\n这对于大部分编程小白来说,不仅没解释到问题,反而只会冒出更多的问号。比如,这上面提到的无效响应到底指的是什么??\n我来解释下,它其实是说,502其实是由网关代理(nginx)发出的,是因为网关代理把客户端的请求转发给了服务端,但服务端却发出了无效响应,而这里的无效响应,一般是指TCP的RST报文或四次挥手的FIN报文。\n四次挥手估计大家背的很熟了,所以略过,我们来重点说下RST报文是什么。\nRST是什么? 我们都知道TCP正常情况下断开连接是用四次挥手,那是正常时候的优雅做法。\n但异常情况下,收发双方都不一定正常,连挥手这件事本身都可能做不到,所以就需要一个机制去强行关闭连接。\nRST 就是用于这种情况,一般用来异常地关闭一个连接。它是TCP包头中的一个标志位,在收到置这个标志位的数据包后,连接就会被关闭,此时接收到 RST的一方,在应用层会看到一个 connection reset 或 connection refused 的报错。\n而之所以发出RST报文,一般有两个常见原因。\n服务端过早断开连接 nginx与服务端之间有一条TCP连接,在nginx将客户端请求转发给服务端时,他两之间按道理会一直保持这条连接,直到服务端将结果正常返回后,再断开连接。\n但如果服务端过早断开连接,而nginx却还继续发消息过去,nginx就会收到服务端内核返回的RST报文或四次挥手的FIN报文,迫使nginx那边的连接结束。\n过早断开连接的原因常见的有两个。\n第一个是,服务端设置的超时时间过短。不管是用的哪种编程语言,一般都有现成的HTTP库,服务端一般都会有几个timeout参数,比如golang的HTTP服务框架里有个写超时(WriteTimeout),假设设置了2s,那它的含义就是,服务端在收到请求后需要在2s内处理完并将结果写到响应中,如果等不到,就会将连接给断掉。\n比如你的接口处理时间是5s,而你的WriteTimeout却只有2s,在没等到响应写完之前,HTTP框架就会主动将连接给断开。nginx此时就有可能收到四次挥手的FIN报文(有些框架也可能发RST报文),然后断开连接,于是客户端就会收到一个502报错。\n遇到这种问题,将WriteTimeout的时间调大一些就好了。\n第二个原因,也是造成502状态码最常见的原因,就是服务端应用进程崩了(crash)。\n服务端崩了,也就是当前没有一个进程在监听服务器端口,而此时你却尝试向一个不存在的端口发数据,服务器的linux内核协议栈就会响应一个RST数据包。同样,这时候nginx也会给客户端一个502。\n在开发过程中,这种情况是最常见的。\n现在我们大部分的服务器都会将挂掉的服务重启,因此我们需要判断下服务是否曾经崩溃过。\n如果你有对服务端的cpu或者内存做过监控,可以看下CPU或内存的监控图是否出现过断崖式的突然下跌。如果有,十有八九百,就是你的服务端应用程序曾经崩溃过。\n除此之外你还通过下面的命令,看下进程上次的启动时间是什么时候。\n1 ps -o lstart {pid} 比如我要看的进程id是13515,命令就需要像下面这样。\n1 2 3 # ps -o lstart 13515 STARTED Wed Aug 31 14:28:53 2022 可以看到它上次的启动时间是8月31日,这个时间如果跟你印象中的操作时间有差距,那说明进程可能是崩了之后被重新拉起了。\n遇到这种问题,最重要的是找出崩溃的原因,崩溃的原因就多种多样了,比如,对未初始化的内存地址进行写操作,或者内存访问越界(数组arr长度明明只有2,代码却读arr[3])。\n这种情况几乎都是程序有代码逻辑问题,崩溃一般也会留下代码堆栈,可以根据堆栈报错去排查问题,修复之后就好了。比如下面这张图是golang的报错堆栈信息,其他语言的也类似。\n不打印堆栈的情况 但有一些情况,有时候根本不留下堆栈。\n比如内存泄露导致进程占用内存越来越多,最后导致超过服务器的最大内存限制,触发OOM(out of memory), 进程直接就被操作系统kill掉。\n还有更隐蔽的,代码逻辑里隐藏了主动退出进程的操作。比如golang的日志打印里有个方法叫log.Fatalln(),打印完日志还会顺便执行os.Exit()直接退出进程,对源码不了解的新手很容易犯这个错。\n如果你很明确,你的服务没有崩过。那继续往下看。\n网关将请求打到了一个不存在的IP上 nginx是通过配置的形式来代理多个服务器。这个配置一般是放在 /etc/nginx/nginx.conf 中。\n打开它,你可能会看到类似下面这样的信息。\n1 2 3 4 5 6 upstream xiaobaidebug.top { server 10.14.12.19:9235 weight=2; server 10.14.16.13:8145 weight=5; server 10.14.12.133:9702 weight=8; server 10.14.11.15:7035 weight=10; } 上面配置的含义是,如果客户端访问xiaobaidebug.top域名,nginx就会将客户端的请求转发到下面的4个服务器ip上,ip边上还有个weight权重,权重越高,被转发到的次数就越多。\n可以看出,nginx具有相当丰富的配置能力。但要注意的是,这些个文件是需要自己手动配置的。对于服务器少,且不怎么变化的情况,这当然没问题。\n但现在已经是云原生时代了,很多公司内部都有自己的云产品,服务自然也会上云。一般来说每次更新服务,都可能会将服务部署到一台新的机器上。而这个ip也会随着改变,难道每发布一次服务,都需要手动去nginx上改配置吗?这显然不现实。\n如果能在服务启动时,让服务主动将自己的ip告诉nginx,然后nginx自己生成这样的一个配置并重新加载,那事情就简单多了。\n为了实现这样一个服务注册的功能,不少公司都会基于nginx进行二次开发。\n但如果这个服务注册功能有问题,比方说服务启动后,新服务没注册上,但老服务已经被销毁了。这时候nginx还将请求打到老服务的IP上,由于老服务所在的机器已经没有这个服务了,所以服务器内核就会响应RST,nginx收到RST后回复502给客户端。\n要排查这种问题也不难。\n这个时候,你可以看下nginx侧是否有打印相关的日志,看下转发的IP端口是否符合预期。\n如果不符合预期,可以去找找做这个基础组件的同事,进行一波友好的交流。\n总结 HTTP状态码用来表示响应结果的状态,其中200是正常响应,4xx是客户端错误,5xx是服务端错误。 客户端和服务端之间加入nginx,可以起到反向代理和负载均衡的作用,客户端只管向nginx请求数据,并不关心这个请求具体由哪个服务器来处理。 后端服务端应用如果发生崩溃,nginx在访问服务端时会收到服务端返回的RST报文,然后给客户端返回502报错。502并不是服务端应用发出的,而是nginx发出的。因此发生502时,后端服务端很可能没有没有相关的502日志,需要在nginx侧才能看到这条502日志。 如果发现502,优先通过监控排查服务端应用是否发生过崩溃重启,如果是的话,再看下是否留下过崩溃堆栈日志,如果没有日志,看下是否可能是oom或者是其他原因导致进程主动退出。如果进程也没崩溃过,去排查下nginx的日志,看下是否将请求打到了某个不知名IP端口上。 参考 原文 ","permalink":"https://reid00.github.io/en/posts/os_network/http-502-%E9%97%AE%E9%A2%98-%E6%8E%92%E6%9F%A5/","summary":"前言 刚工作那会,有一次,上游调用我服务的老哥说,你的服务报\u0026quot;502错误了,快去看看是为什么吧\u0026quot;。 当时那个服务里正好有个调","title":"Http 502 问题 排查"},{"content":"介绍 事实上,这两个完全是两样不同东西,实现的层面也不同:\nHTTP 的 Keep-Alive,是由应用层(用户态) 实现的,称为 HTTP 长连接; TCP 的 Keepalive,是由 TCP 层(内核态) 实现的,称为 TCP 保活机制; 接下来,分别说说它们。\nHTTP 的 Keep-Alive HTTP 协议采用的是「请求-应答」的模式,也就是客户端发起了请求,服务端才会返回响应,一来一回这样子。\n由于 HTTP 是基于 TCP 传输协议实现的,客户端与服务端要进行 HTTP 通信前,需要先建立 TCP 连接,然后客户端发送 HTTP 请求,服务端收到后就返回响应,至此「请求-应答」的模式就完成了,随后就会释放 TCP 连接。\n如果每次请求都要经历这样的过程:建立 TCP -\u0026gt; 请求资源 -\u0026gt; 响应资源 -\u0026gt; 释放连接,那么此方式就是 HTTP 短连接,如下图:\n这样实在太累人了,一次连接只能请求一次资源。\n能不能在第一个 HTTP 请求完后,先不断开 TCP 连接,让后续的 HTTP 请求继续使用此连接?\n当然可以,HTTP 的 Keep-Alive 就是实现了这个功能,可以使用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,避免了连接建立和释放的开销,这个方法称为 HTTP 长连接。\nHTTP 长连接的特点是,只要任意一端没有明确提出断开连接,则保持 TCP 连接状态。\n怎么才能使用 HTTP 的 Keep-Alive 功能?\n在 HTTP 1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的包头中添加:\n1 Connection: Keep-Alive 然后当服务器收到请求,作出回应的时候,它也添加一个头在响应中:\n1 Connection: Keep-Alive 这样做,连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个连接。这一直继续到客户端或服务器端提出断开连接。\n从 HTTP 1.1 开始, 就默认是开启了 Keep-Alive,如果要关闭 Keep-Alive,需要在 HTTP 请求的包头里添加:\n1 Connection:close 现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。\nHTTP 长连接不仅仅减少了 TCP 连接资源的开销,而且这给 HTTP 流水线技术提供了可实现的基础。\n所谓的 HTTP 流水线,是客户端可以先一次性发送多个请求,而在发送过程中不需先等待服务器的回应,可以减少整体的响应时间。\n举例来说,客户端需要请求两个资源。以前的做法是,在同一个 TCP 连接里面,先发送 A 请求,然后等待服务器做出回应,收到后再发出 B 请求。HTTP 流水线机制则允许客户端同时发出 A 请求和 B 请求。\n但是服务器还是按照顺序响应,先回应 A 请求,完成后再回应 B 请求。\n而且要等服务器响应完客户端第一批发送的请求后,客户端才能发出下一批的请求,也就说如果服务器响应的过程发生了阻塞,那么客户端就无法发出下一批的请求,此时就造成了「队头阻塞」的问题。\n可能有的同学会问,如果使用了 HTTP 长连接,如果客户端完成一个 HTTP 请求后,就不再发起新的请求,此时这个 TCP 连接一直占用着不是挺浪费资源的吗?\n对没错,所以为了避免资源浪费的情况,web 服务软件一般都会提供 keepalive_timeout 参数,用来指定 HTTP 长连接的超时时间。\n比如设置了 HTTP 长连接的超时时间是 60 秒,web 服务软件就会启动一个定时器,如果客户端在完后一个 HTTP 请求后,在 60 秒内都没有再发起新的请求,定时器的时间一到,就会触发回调函数来释放该连接。\nTCP 的 Keepalive TCP 的 Keepalive 这东西其实就是 TCP 的保活机制,它的工作原理我之前的文章写过,这里就直接贴下以前的内容。\n如果两端的 TCP 连接一直没有数据交互,达到了触发 TCP 保活机制的条件,那么内核里的 TCP 协议栈就会发送探测报文。\n如果对端程序是正常工作的。当 TCP 保活的探测报文发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下一个 TCP 保活时间的到来。 如果对端主机崩溃,或对端由于其他原因导致报文不可达。当 TCP 保活的探测报文发送给对端后,石沉大海,没有响应,连续几次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。 所以,TCP 保活机制可以在双方没有数据交互的情况,通过探测报文,来确定对方的 TCP 连接是否存活,这个工作是在内核完成的。\n注意,应用程序若想使用 TCP 保活机制需要通过 socket 接口设置 SO_KEEPALIVE 选项才能够生效,如果没有设置,那么就无法使用 TCP 保活机制。\n总结 HTTP 的 Keep-Alive 也叫 HTTP 长连接,该功能是由「应用程序」实现的,可以使得用同一个 TCP 连接来发送和接收多个 HTTP 请求/应答,减少了 HTTP 短连接带来的多次 TCP 连接建立和释放的开销。\nTCP 的 Keepalive 也叫 TCP 保活机制,该功能是由「内核」实现的,当客户端和服务端长达一定时间没有进行数据交互时,内核为了确保该连接是否还有效,就会发送探测报文,来检测对方是否还在线,然后来决定是否要关闭该连接。\n原文\n","permalink":"https://reid00.github.io/en/posts/os_network/http%E9%95%BF%E8%BF%9E%E6%8E%A5%E5%92%8Ctcp%E9%95%BF%E8%BF%9E%E6%8E%A5%E7%9A%84%E5%8C%BA%E5%88%AB/","summary":"介绍 事实上,这两个完全是两样不同东西,实现的层面也不同: HTTP 的 Keep-Alive,是由应用层(用户态) 实现的,称为 HTTP 长连接; TCP 的 Keepal","title":"Http长连接和TCP长连接的区别"},{"content":"1. Raft 算法简介 1.1 Raft 背景 在分布式系统中,一致性算法至关重要。在所有一致性算法中,Paxos 最负盛名,它由莱斯利·兰伯特(Leslie Lamport)于 1990 年提出,是一种基于消息传递的一致性算法,被认为是类似算法中最有效的。\nPaxos 算法虽然很有效,但复杂的原理使它实现起来非常困难,截止目前,实现 Paxos 算法的开源软件很少,比较出名的有 Chubby、LibPaxos。此外,Zookeeper 采用的 ZAB(Zookeeper Atomic Broadcast)协议也是基于 Paxos 算法实现的,不过 ZAB 对 Paxos 进行了很多改进与优化,两者的设计目标也存在差异——ZAB 协议主要用于构建一个高可用的分布式数据主备系统,而 Paxos 算法则是用于构建一个分布式的一致性状态机系统。\n由于 Paxos 算法过于复杂、实现困难,极大地制约了其应用,而分布式系统领域又亟需一种高效而易于实现的分布式一致性算法,在此背景下,Raft 算法应运而生。\nRaft 算法在斯坦福 Diego Ongaro 和 John Ousterhout 于 2013 年发表的《In Search of an Understandable Consensus Algorithm》中提出。相较于 Paxos,Raft 通过逻辑分离使其更容易理解和实现,目前,已经有十多种语言的 Raft 算法实现框架,较为出名的有 etcd、Consul 。\n本文基于论文In Search of an Understandable Consensus Algorithm对raft协议进行分析,当然,还是建议读者直接看论文。\n相关链接:\n论文 官网 动画展示 分布式共识算法核心理论基础 在正式谈raft之前,还需要简单介绍下分布式共识算法所基于的理论工具。分布式共识协议在复制状态机的背景下产生的。在该方法中,一组服务器上的状态机计算相同的副本,即便某台机器宕机依然会继续运行。复制状态机是基于日志实现的。在这里有必要唠叨两句日志的特性。日志可以看做一个简单的存储抽象,append only,按照时间完全有序,注意这里面的日志并不是log4j或是syslog打出来的业务日志,那个我们称之为应用日志,这里的日志是用于程序访问的存储结构。有了上面的限制,使用日志就能够保证这样一件事。如图所示 我有一个日志,里面存储的是一系列的对数据的操作,此时系统外部有一系列输入数据,输入到这个日志中,经过日志中一系列command操作,由于日志的确定性和有序性,保证最后得到的输出序列也应该是确定的。扩展到分布式的场景,此时每台机器上所有了这么一个日志,此时我需要做的事情就是保证这几份日志是完全一致的。详细步骤就引出了论文中的那张经典的复制状态机的示意图 如图所示,server中的共识模块负责接收由client发送过来的请求,将请求中对应的操作记录到自己的日志中,同时通知给其他机器,让他们也进行同样的操作最终保证所有的机器都在日志中写入了这条操作。然后返回给客户端写入成功。复制状态机用于解决分布式中系统中的各种容错问题,例如master的高可用,例如Chubby以及ZK都是复制状态机,\n分布式一致性算法,通常满足以下性质: 在非拜占庭错误下,保证安全性(不会返回不正确的结果) 大多数机器运行,系统就可以正常运行,发生故障的机器在恢复正常后可以重新正常的加入到集群中 不依赖时序来保证日志的一致性 通常情况下,大多数机器就可以做出响应了,少数慢节点并不会拉低整个系统的性能 1.2 Raft 基本概念 首先我将整体串一遍raft,然后抽提出里面的相关概念进行说明。\n一个raft协议通常包含若干个节点,通常5个(2n+1),它最多允许其中的n个节点挂掉。在任何时刻,任何节点都会处在下列三种状态之一,Leader,Follower以及Candidate。在系统正常运行的过程中,系统中会有一个Leader,其他节点都处于Follower状态,Leader负责处理所有来自客户端的请求,Follower如果收到请求会将请求路由到Leader,Follower只是被动的接收leader和candidate的请求,它自己不会对外发出请求。而candidate是用于做leader选举的状态。正常情况下leader会向follower汇报心跳,证明自己是当前系统的leader,这样所有follower就会老老实实负责同步leader的日志内容变更。当一段时间(随机时间)follower收不到leader的心跳信息时,会认为此时系统处于无leader状态,那么自己会转换到candidate状态并发起leader选举。\nraft将整个时间分为若干个长度不一的片段,每一个段叫做一个任期(term),一次新的选主操作会触发一次term的更新,这里term就可以理解为逻辑时钟的概念。raft规定,一个term最多只有一个leader,可能没有,这是因为可能多个follower同时发现没有leader同时发起选主,瓜分选票。节点间在通信过程中会交换term,这样做的目的是为了唯一确认当前应该是谁在当政。如果某个candidate或leader发现自己的term小于当前的就会自觉地退到follower状态。同样的如果某个节点收到包含过期term的请求,则会直接拒绝该请求。\nraft节点间使用RPC进行通信,基本的有关一致性算法的有两种基本RPC类型,分别为请求投票(RequestVotes)以及追加日志(AppendEntries),在日志压缩方面还有另外一种RPC类型(InstallSnapshot),后面会详细说明。其中日志中由若干个条目组成,每个条目都有一个Index标识。\n至此raft的所有概念都出来了,我简单列举一下:\n1 2 3 4 5 6 7 8 Leader:节点状态一种,用于处理所有来自client请求,并将自己的日志追加行为广播到所有的follower上 Follower:节点状态一种,用于接收leader心跳,将leader的日志变更同步到自己的状态机日志中,在选举时给candidate投票 Candidate:节点状态一种,当follower发现没有leader时发起选主请求,极有可能成为下一任leader term:用于标记当前leader/请求的有效性,一种逻辑时钟 Index:用于表示复制状态机中日志的条目 RequestVotes:candidate要求选主发送给Follower的RPC请求 AppendEntries:leader给follower发送的添加日志条目(心跳)的请求 InstallSnapshot:生成日志快照的RPC请求 1.3 Raft协议核心特性 文章中列举了五条raft的核心特性,也可以说这是raft设计的原则\n选举安全(Election Safety):在一个term中最多只能存在一个leader leader日志追加(Leader Append-Only):leader不能覆盖或删除日志中的内容,只能新增日志 日志匹配(Log Matching):如果两个日志有相同的index和任期,那么在这个任期前的所有日志条目全部相同 Leader强制完成(Leader Completeness):如果再某个任期内提交了某条日志条目,那么这个任期前面的日志也是确认被提交的 状态机确定性(State Machine Safety):如果一台服务器将某一个index的日志条目应用到自己的状态机上,那么其他服务器不可能在同一个index上应用不同的日志条目 1.4 Raft 角色 根据官方文档解释,一个 Raft 集群包含若干节点,Raft 把这些节点分为三种状态:Leader、 Follower、Candidate,每种状态负责的任务也是不一样的。正常情况下,集群中的节点只存在 Leader 与 Follower 两种状态。\nLeader(领导者):负责日志的同步管理,处理来自客户端的请求,与Follower保持heartBeat的联系; Follower(追随者):响应 Leader 的日志同步请求,响应Candidate的邀票请求,以及把客户端请求到Follower的事务转发(重定向)给Leader; Candidate(候选者):负责选举投票,集群刚启动或者Leader宕机时,状态为Follower的节点将转为Candidate并发起选举,选举胜出(获得超过半数节点的投票)后,从Candidate转为Leader状态。 1.5 Raft 三个子问题 通常,Raft 集群中只有一个 Leader,其它节点都是 Follower。Follower 都是被动的,不会发送任何请求,只是简单地响应来自 Leader 或者 Candidate 的请求。Leader 负责处理所有的客户端请求(如果一个客户端和 Follower 联系,那么 Follower 会把请求重定向给 Leader)。\n为简化逻辑和实现,Raft 将一致性问题分解成了三个相对独立的子问题。\n选举(Leader Election):当 Leader 宕机或者集群初创时,一个新的 Leader 需要被选举出来; 日志复制(Log Replication):Leader 接收来自客户端的请求并将其以日志条目的形式复制到集群中的其它节点,并且强制要求其它节点的日志和自己保持一致; 安全性(Safety):如果有任何的服务器节点已经应用了一个确定的日志条目到它的状态机中,那么其它服务器节点不能在同一个日志索引位置应用一个不同的指令。 2. Raft 算法之 Leader Election 原理 根据 Raft 协议,一个应用 Raft 协议的集群在刚启动时,所有节点的状态都是 Follower。由于没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),因此,Followers 会认为 Leader 已经下线,进而转为 Candidate 状态。然后,Candidate 将向集群中其它节点请求投票,同意自己升级为 Leader。如果 Candidate 收到超过半数节点的投票(N/2 + 1),它将获胜成为 Leader。\n第一阶段:所有节点都是 Follower。 上面提到,一个应用 Raft 协议的集群在刚启动(或 Leader 宕机)时,所有节点的状态都是 Follower,初始 Term(任期)为 0。同时启动选举定时器,每个节点的选举定时器超时时间都在 100~500 毫秒之间且并不一致(避免同时发起选举)。 第二阶段:Follower 转为 Candidate 并发起投票。 没有 Leader,Followers 无法与 Leader 保持心跳(Heart Beat),节点启动后在一个选举定时器周期内未收到心跳和投票请求,则状态转为候选者 Candidate 状态,且 Term 自增,并向集群中所有节点发送投票请求并且重置选举定时器。\n注意,由于每个节点的选举定时器超时时间都在 100-500 毫秒之间,且彼此不一样,以避免所有 Follower 同时转为 Candidate 并同时发起投票请求。换言之,最先转为 Candidate 并发起投票请求的节点将具有成为 Leader 的“先发优势”。 第三阶段:投票策略。 节点收到投票请求后会根据以下情况决定是否接受投票请求:\n请求节点的 Term 大于自己的 Term,且自己尚未投票给其它节点,则接受请求,把票投给它; 请求节点的 Term 小于自己的 Term,且自己尚未投票,则拒绝请求,将票投给自己。 第四阶段:Candidate 转为 Leader。 一轮选举过后,正常情况下,会有一个 Candidate 收到超过半数节点(N/2 + 1)的投票,它将胜出并升级为 Leader。然后定时发送心跳给其它的节点,其它节点会转为 Follower 并与 Leader 保持同步,到此,本轮选举结束。\n注意:有可能一轮选举中,没有 Candidate 收到超过半数节点投票,那么将进行下一轮选举。 3. Raft 算法之 Log Replication 原理 在一个 Raft 集群中,只有 Leader 节点能够处理客户端的请求(如果客户端的请求发到了 Follower,Follower 将会把请求重定向到 Leader),客户端的每一个请求都包含一条被复制状态机执行的指令。Leader 把这条指令作为一条新的日志条目(Entry)附加到日志中去,然后并行得将附加条目发送给 Followers,让它们复制这条日志条目。\n当这条日志条目被 Followers 安全复制,Leader 会将这条日志条目应用到它的状态机中,然后把执行的结果返回给客户端。如果 Follower 崩溃或者运行缓慢,再或者网络丢包,Leader 会不断得重复尝试附加日志条目(尽管已经回复了客户端)直到所有的 Follower 都最终存储了所有的日志条目,确保强一致性。\n第一阶段:客户端请求提交到 Leader。 如下图所示,Leader 收到客户端的请求,比如存储数据 5。Leader 在收到请求后,会将它作为日志条目(Entry)写入本地日志中。需要注意的是,此时该 Entry 的状态是未提交(Uncommitted),Leader 并不会更新本地数据,因此它是不可读的。 第二阶段:Leader 将 Entry 发送到其它 Follower Leader 与 Floolwers 之间保持着心跳联系,随心跳 Leader 将追加的 Entry(AppendEntries)并行地发送给其它的 Follower,并让它们复制这条日志条目,这一过程称为复制(Replicate)。\n有几点需要注意:\n为什么 Leader 向 Follower 发送的 Entry 是 AppendEntries 呢? 因为 Leader 与 Follower 的心跳是周期性的,而一个周期间 Leader 可能接收到多条客户端的请求,因此,随心跳向 Followers 发送的大概率是多个 Entry,即 AppendEntries。当然,在本例中,我们假设只有一条请求,自然也就是一个Entry了。\nLeader 向 Followers 发送的不仅仅是追加的 Entry(AppendEntries)。 在发送追加日志条目的时候,Leader 会把新的日志条目紧接着之前条目的索引位置(prevLogIndex), Leader 任期号(Term)也包含在其中。如果 Follower 在它的日志中找不到包含相同索引位置和任期号的条目,那么它就会拒绝接收新的日志条目,因为出现这种情况说明 Follower 和 Leader 不一致。\n如何解决 Leader 与 Follower 不一致的问题? 在正常情况下,Leader 和 Follower 的日志保持一致,所以追加日志的一致性检查从来不会失败。然而,Leader 和 Follower 一系列崩溃的情况会使它们的日志处于不一致状态。Follower可能会丢失一些在新的 Leader 中有的日志条目,它也可能拥有一些 Leader 没有的日志条目,或者两者都发生。丢失或者多出日志条目可能会持续多个任期。\n要使 Follower 的日志与 Leader 恢复一致,Leader 必须找到最后两者达成一致的地方(说白了就是回溯,找到两者最近的一致点),然后删除从那个点之后的所有日志条目,发送自己的日志给 Follower。所有的这些操作都在进行附加日志的一致性检查时完成。\nLeader 为每一个 Follower 维护一个 nextIndex,它表示下一个需要发送给 Follower 的日志条目的索引地址。当一个 Leader 刚获得权力的时候,它初始化所有的 nextIndex 值,为自己的最后一条日志的 index 加 1。如果一个 Follower 的日志和 Leader 不一致,那么在下一次附加日志时一致性检查就会失败。在被 Follower 拒绝之后,Leader 就会减小该 Follower 对应的 nextIndex 值并进行重试。最终 nextIndex 会在某个位置使得 Leader 和 Follower 的日志达成一致。当这种情况发生,附加日志就会成功,这时就会把 Follower 冲突的日志条目全部删除并且加上 Leader 的日志。一旦附加日志成功,那么 Follower 的日志就会和 Leader 保持一致,并且在接下来的任期继续保持一致。 第三阶段:Leader 等待 Followers 回应。 Followers 接收到 Leader 发来的复制请求后,有两种可能的回应:\n写入本地日志中,返回 Success; 一致性检查失败,拒绝写入,返回 False,原因和解决办法上面已做了详细说明。 需要注意的是,此时该 Entry 的状态也是未提交(Uncommitted)。完成上述步骤后,Followers 会向 Leader 发出 Success 的回应,当 Leader 收到大多数 Followers 的回应后,会将第一阶段写入的 Entry 标记为提交状态(Committed),并把这条日志条目应用到它的状态机中。 第四阶段:Leader 回应客户端。 完成前三个阶段后,Leader会向客户端回应 OK,表示写操作成功。 第五阶段,Leader 通知 Followers Entry 已提交 Leader 回应客户端后,将随着下一个心跳通知 Followers,Followers 收到通知后也会将 Entry 标记为提交状态。至此,Raft 集群超过半数节点已经达到一致状态,可以确保强一致性。\n需要注意的是,由于网络、性能、故障等各种原因导致“反应慢”、“不一致”等问题的节点,最终也会与 Leader 达成一致。 4. Raft 算法之安全性 前面描述了 Raft 算法是如何选举 Leader 和复制日志的。然而,到目前为止描述的机制并不能充分地保证每一个状态机会按照相同的顺序执行相同的指令。例如,一个 Follower 可能处于不可用状态,同时 Leader 已经提交了若干的日志条目;然后这个 Follower 恢复(尚未与 Leader 达成一致)而 Leader 故障;如果该 Follower 被选举为 Leader 并且覆盖这些日志条目,就会出现问题,即不同的状态机执行不同的指令序列。\n鉴于此,在 Leader 选举的时候需增加一些限制来完善 Raft 算法。这些限制可保证任何的 Leader 对于给定的任期号(Term),都拥有之前任期的所有被提交的日志条目(所谓 Leader 的完整特性)。关于这一选举时的限制,下文将详细说明。\n4.1 选举限制 在所有基于 Leader 机制的一致性算法中,Leader 都必须存储所有已经提交的日志条目。为了保障这一点,Raft 使用了一种简单而有效的方法,以保证所有之前的任期号中已经提交的日志条目在选举的时候都会出现在新的 Leader 中。换言之,日志条目的传送是单向的,只从 Leader 传给 Follower,并且 Leader 从不会覆盖自身本地日志中已经存在的条目。\nRaft 使用投票的方式来阻止一个 Candidate 赢得选举,除非这个 Candidate 包含了所有已经提交的日志条目。Candidate 为了赢得选举必须联系集群中的大部分节点。这意味着每一个已经提交的日志条目肯定存在于至少一个服务器节点上。如果 Candidate 的日志至少和大多数的服务器节点一样新(这个新的定义会在下面讨论),那么它一定持有了所有已经提交的日志条目(多数派的思想)。投票请求的限制中请求中包含了 Candidate 的日志信息,然后投票人会拒绝那些日志没有自己新的投票请求。\nRaft 通过比较两份日志中最后一条日志条目的索引值和任期号,确定谁的日志比较新。如果两份日志最后条目的任期号不同,那么任期号大的日志更加新。如果两份日志最后的条目任期号相同,那么日志比较长的那个就更加新。\n总结 - 选举时: 保证新的 Leader 拥有所有已经提交的日志\n每个 Follower 节点在投票时会检查 Candidate 的日志索引,并拒绝为日志不完整的 Candidate 投赞成票 半数以上的 Follower 节点都投了赞成票,意味着 Candidate 中包含了所有可能已经被提交的日志 4.2 提交之前任期内的日志条目 如同 4.1 节介绍的那样,Leader 知道一条当前任期内的日志记录是可以被提交的,只要它被复制到了大多数的 Follower 上(多数派的思想)。如果一个 Leader 在提交日志条目之前崩溃了,继任的 Leader 会继续尝试复制这条日志记录。然而,一个 Leader 并不能断定被保存到大多数 Follower 上的一个之前任期里的日志条目 就一定已经提交了。这很明显,从日志复制的过程可以看出。\n鉴于上述情况,Raft 算法不会通过计算副本数目的方式去提交一个之前任期内的日志条目。只有 Leader 当前任期里的日志条目通过计算副本数目可以被提交;一旦当前任期的日志条目以这种方式被提交,那么由于日志匹配特性,之前的日志条目也都会被间接的提交。在某些情况下,Leader 可以安全地知道一个老的日志条目是否已经被提交(只需判断该条目是否存储到所有节点上),但是 Raft 为了简化问题使用了一种更加保守的方法。\n当 Leader 复制之前任期里的日志时,Raft 会为所有日志保留原始的任期号,这在提交规则上产生了额外的复杂性。但是,这种策略更加容易辨别出日志,即使随着时间和日志的变化,日志仍维护着同一个任期编号。此外,该策略使得新 Leader 只需要发送较少日志条目。\n总结 - 提交日志时: Leader 只主动提交自己任期内产生的日志\n如果记录是当前 Leader 所创建的,那么当这条记录被复制到大多数节点上时,Leader 就可以提交这条记录以及之前的记录 如果记录是之前 Leader 所创建的,则只有当前 Leader 创建的记录被提交后,才能提交这些由之前 Leader 创建的日志 5. 集群成员变更 到目前为止,我们都假设集群的配置(加入到一致性算法的服务器集合)是固定不变的。但是在实践中,偶尔是会改变集群的配置的,例如替换那些宕机的机器或者改变复制级别。尽管可以通过暂停整个集群,更新所有配置,然后重启整个集群的方式来实现,但是在更改的时候集群会不可用。另外,如果存在手工操作步骤,那么就会有操作失误的风险。为了避免这样的问题,我们决定自动化配置改变并且将其纳入到 Raft 一致性算法中来。 为了让配置修改机制能够安全,那么在转换的过程中不能够存在任何时间点使得两个领导人同时被选举成功在同一个任期里。不幸的是,任何服务器直接从旧的配置直接转换到新的配置的方案都是不安全的。一次性原子地转换所有服务器是不可能的,所以在转换期间整个集群存在划分成两个独立的大多数群体的可能性(见图)。 直接从一种配置转到新的配置是十分不安全的,因为各个机器可能在任何的时候进行转换。在这个例子中,集群配额从 3 台机器变成了 5 台。不幸的是,存在这样的一个时间点,两个不同的领导人在同一个任期里都可以被选举成功。一个是通过旧的配置,一个通过新的配置。\n为了保证安全性,配置更改必须使用两阶段方法。目前有很多种两阶段的实现。例如,有些系统在第一阶段停掉旧的配置所以集群就不能处理客户端请求;然后在第二阶段在启用新的配置。在 Raft 中,集群先切换到一个过渡的配置,我们称之为共同一致;一旦共同一致已经被提交了,那么系统就切换到新的配置上。共同一致是老配置和新配置的结合:\n日志条目被复制给集群中新、老配置的所有服务器。 新、旧配置的服务器都可以成为领导人。 达成一致(针对选举和提交)需要分别在两种配置上获得大多数的支持。 共同一致允许独立的服务器在不影响安全性的前提下,在不同的时间进行配置转换过程。此外,共同一致可以让集群在配置转换的过程中依然响应客户端的请求。 集群配置在复制日志中以特殊的日志条目来存储和通信;图 11 展示了配置转换的过程。当一个领导人接收到一个改变配置从 C-old 到 C-new 的请求,他会为了共同一致存储配置(图中的 C-old,new),以前面描述的日志条目和副本的形式。一旦一个服务器将新的配置日志条目增加到它的日志中,他就会用这个配置来做出未来所有的决定(服务器总是使用最新的配置,无论他是否已经被提交)。这意味着领导人要使用 C-old,new 的规则来决定日志条目 C-old,new 什么时候需要被提交。如果领导人崩溃了,被选出来的新领导人可能是使用 C-old 配置也可能是 C-old,new 配置,这取决于赢得选举的候选人是否已经接收到了 C-old,new 配置。在任何情况下, C-new 配置在这一时期都不会单方面的做出决定。\n一旦 C-old,new 被提交,那么无论是 C-old 还是 C-new,在没有经过他人批准的情况下都不可能做出决定,并且领导人完全特性保证了只有拥有 C-old,new 日志条目的服务器才有可能被选举为领导人。这个时候,领导人创建一条关于 C-new 配置的日志条目并复制给集群就是安全的了。再者,每个服务器在见到新的配置的时候就会立即生效。当新的配置在 C-new 的规则下被提交,旧的配置就变得无关紧要,同时不使用新的配置的服务器就可以被关闭了。如图 11,C-old 和 C-new 没有任何机会同时做出单方面的决定;这保证了安全性。 图 11:一个配置切换的时间线。虚线表示已经被创建但是还没有被提交的配置日志条目,实线表示最后被提交的配置日志条目。领导人首先创建了 C-old,new 的配置条目在自己的日志中,并提交到 C-old,new 中(C-old 的大多数和 C-new 的大多数)。然后他创建 C-new 条目并提交到 C-new 中的大多数。这样就不存在 C-new 和 C-old 可以同时做出决定的时间点。\n在关于重新配置还有三个问题需要提出。 第一个问题是,新的服务器可能初始化没有存储任何的日志条目。当这些服务器以这种状态加入到集群中,那么他们需要一段时间来更新追赶,这时还不能提交新的日志条目。为了避免这种可用性的间隔时间,Raft 在配置更新之前使用了一种额外的阶段,在这个阶段,新的服务器以没有投票权身份加入到集群中来(领导人复制日志给他们,但是不考虑他们是大多数)。一旦新的服务器追赶上了集群中的其他机器,重新配置可以像上面描述的一样处理。\n第二个问题是,集群的领导人可能不是新配置的一员。在这种情况下,领导人就会在提交了 C-new 日志之后退位(回到跟随者状态)。这意味着有这样的一段时间,领导人管理着集群,但是不包括他自己;他复制日志但是不把他自己算作是大多数之一。当 C-new 被提交时,会发生领导人过渡,因为这时是最早新的配置可以独立工作的时间点(将总是能够在 C-new 配置下选出新的领导人)。在此之前,可能只能从 C-old 中选出领导人。\n第三个问题是,移除不在 C-new 中的服务器可能会扰乱集群。这些服务器将不会再接收到心跳,所以当选举超时,他们就会进行新的选举过程。他们会发送拥有新的任期号的请求投票 RPCs,这样会导致当前的领导人回退成跟随者状态。新的领导人最终会被选出来,但是被移除的服务器将会再次超时,然后这个过程会再次重复,导致整体可用性大幅降低。\n为了避免这个问题,当服务器确认当前领导人存在时,服务器会忽略请求投票 RPCs。特别的,当服务器在当前最小选举超时时间内收到一个请求投票 RPC,他不会更新当前的任期号或者投出选票。这不会影响正常的选举,每个服务器在开始一次选举之前,至少等待一个最小选举超时时间。然而,这有利于避免被移除的服务器扰乱:如果领导人能够发送心跳给集群,那么他就不会被更大的任期号废黜。\n6. 日志压缩 Raft 的日志在正常操作中不断的增长,但是在实际的系统中,日志不能无限制的增长。随着日志不断增长,他会占用越来越多的空间,花费越来越多的时间来重置。如果没有一定的机制去清除日志里积累的陈旧的信息,那么会带来可用性问题。\n快照是最简单的压缩方法。在快照系统中,整个系统的状态都以快照的形式写入到稳定的持久化存储中,然后到那个时间点之前的日志全部丢弃。快照技术被使用在 Chubby 和 ZooKeeper 中,接下来的章节会介绍 Raft 中的快照技术。\n增量压缩的方法,例如日志清理或者日志结构合并树,都是可行的。这些方法每次只对一小部分数据进行操作,这样就分散了压缩的负载压力。首先,他们先选择一个已经积累的大量已经被删除或者被覆盖对象的区域,然后重写那个区域还活跃的对象,之后释放那个区域。和简单操作整个数据集合的快照相比,需要增加复杂的机制来实现。状态机可以实现 LSM tree 使用和快照相同的接口,但是日志清除方法就需要修改 Raft 了。 图 12:一个服务器用新的快照替换了从 1 到 5 的条目,快照值存储了当前的状态。快照中包含了最后的索引位置和任期号。\n图 12 展示了 Raft 中快照的基础思想。每个服务器独立的创建快照,只包括已经被提交的日志。主要的工作包括将状态机的状态写入到快照中。Raft 也包含一些少量的元数据到快照中:最后被包含索引指的是被快照取代的最后的条目在日志中的索引值(状态机最后应用的日志),最后被包含的任期指的是该条目的任期号。保留这些数据是为了支持快照后紧接着的第一个条目的附加日志请求时的一致性检查,因为这个条目需要前一日志条目的索引值和任期号。为了支持集群成员更新(第 5 节),快照中也将最后的一次配置作为最后一个条目存下来。一旦服务器完成一次快照,他就可以删除最后索引位置之前的所有日志和快照了。\n尽管通常服务器都是独立的创建快照,但是领导人必须偶尔的发送快照给一些落后的跟随者。这通常发生在当领导人已经丢弃了下一条需要发送给跟随者的日志条目的时候。幸运的是这种情况不是常规操作:一个与领导人保持同步的跟随者通常都会有这个条目。然而一个运行非常缓慢的跟随者或者新加入集群的服务器(第5节)将不会有这个条目。这时让这个跟随者更新到最新的状态的方式就是通过网络把快照发送给他们。\n安装快照 RPC: 由领导人调用以将快照的分块发送给跟随者。领导者总是按顺序发送分块。\n参数 解释 term 领导人的任期号 leaderId 领导人的 Id,以便于跟随者重定向请求 lastIncludedIndex 快照中包含的最后日志条目的索引值 lastIncludedTerm 快照中包含的最后日志条目的任期号 offset 分块在快照中的字节偏移量 data[] 从偏移量开始的快照分块的原始字节 done 如果这是最后一个分块则为 true 结果 解释 term 当前任期号(currentTerm),便于领导人更新自己 接收者实现:\n如果term \u0026lt; currentTerm就立即回复 如果是第一个分块(offset 为 0)就创建一个新的快照 在指定偏移量写入数据 如果 done 是 false,则继续等待更多的数据 保存快照文件,丢弃具有较小索引的任何现有或部分快照 如果现存的日志条目与快照中最后包含的日志条目具有相同的索引值和任期号,则保留其后的日志条目并进行回复 丢弃整个日志 使用快照重置状态机(并加载快照的集群配置) 在这种情况下领导人使用一种叫做安装快照的新的 RPC 来发送快照给太落后的跟随者;见图 13。当跟随者通过这种 RPC 接收到快照时,他必须自己决定对于已经存在的日志该如何处理。通常快照会包含没有在接收者日志中存在的信息。在这种情况下,跟随者丢弃其整个日志;它全部被快照取代,并且可能包含与快照冲突的未提交条目。如果接收到的快照是自己日志的前面部分(由于网络重传或者错误),那么被快照包含的条目将会被全部删除,但是快照后面的条目仍然有效,必须保留。\n这种快照的方式背离了 Raft 的强领导人原则,因为跟随者可以在不知道领导人情况下创建快照。但是我们认为这种背离是值得的。领导人的存在,是为了解决在达成一致性的时候的冲突,但是在创建快照的时候,一致性已经达成,这时不存在冲突了,所以没有领导人也是可以的。数据依然是从领导人传给跟随者,只是跟随者可以重新组织他们的数据了。\n我们考虑过一种替代的基于领导人的快照方案,即只有领导人创建快照,然后发送给所有的跟随者。但是这样做有两个缺点。第一,发送快照会浪费网络带宽并且延缓了快照处理的时间。每个跟随者都已经拥有了所有产生快照需要的信息,而且很显然,自己从本地的状态中创建快照比通过网络接收别人发来的要经济。第二,领导人的实现会更加复杂。例如,领导人需要发送快照的同时并行的将新的日志条目发送给跟随者,这样才不会阻塞新的客户端请求。\n还有两个问题影响了快照的性能。首先,服务器必须决定什么时候应该创建快照。如果快照创建的过于频繁,那么就会浪费大量的磁盘带宽和其他资源;如果创建快照频率太低,他就要承受耗尽存储容量的风险,同时也增加了从日志重建的时间。一个简单的策略就是当日志大小达到一个固定大小的时候就创建一次快照。如果这个阈值设置的显著大于期望的快照的大小,那么快照对磁盘压力的影响就会很小了。\n第二个影响性能的问题就是写入快照需要花费显著的一段时间,并且我们还不希望影响到正常操作。解决方案是通过写时复制的技术,这样新的更新就可以被接收而不影响到快照。例如,具有函数式数据结构的状态机天然支持这样的功能。另外,操作系统的写时复制技术的支持(如 Linux 上的 fork)可以被用来创建完整的状态机的内存快照(我们的实现就是这样的)。\n","permalink":"https://reid00.github.io/en/posts/storage/raft-%E4%BB%8B%E7%BB%8D/","summary":"1. Raft 算法简介 1.1 Raft 背景 在分布式系统中,一致性算法至关重要。在所有一致性算法中,Paxos 最负盛名,它由莱斯利·兰伯特(Leslie Lampor","title":"Raft 介绍"},{"content":"1. 概述 Spark应用在yarn运行模式下,其以Executor Container的形式存在,container能申请到的最大内存受yarn.scheduler.maximum-allocation-mb限制。下面说的大部分内容其实与yarn等没有多少直接关系,知识均为通用的。\nSpark应用运行过程中的内存可以分为堆内内存与堆外内存,其中堆内内存onheap由spark.executor.memory指定,堆外内存offheap由spark.yarn.executor.memoryOverhead参数指定,默认为executorMemory*0.1,最小384M。堆内内存executorMemory是spark使用的主要部分,其大小通过-Xmx参数传给jvm,内部有300M的保留资源不被executor使用。这里的堆外内存部分主要用于JVM自身,如字符串、NIO Buffer等开销,此部分用户代码及spark都无法直接操作。\nexecutor执行的时候,用的内存可能会超过executor-memory,所以会为executor额外预留一部分内存,spark.yarn.executor.memoryOverhead即代表这部分内存。\n另外还有部分堆外内存由spark.memory.offHeap.enabled及spark.memory.offHeap.size控制的堆外内存,这部分也归offheap,但主要是供统一内存管理使用的。 2. 堆内内存 1 2 3 4 5 6 7 object UnifiedMemoryManager { // Set aside a fixed amount of memory for non-storage, non-execution purposes. // This serves a function similar to `spark.memory.fraction`, but guarantees that we reserve // sufficient memory for the system even for small heaps. E.g. if we have a 1GB JVM, then // the memory used for execution and storage will be (1024 - 300) * 0.6 = 434MB by default. private val RESERVED_SYSTEM_MEMORY_BYTES = 300 * 1024 * 1024 堆内内存有300M的保留资源,此外的可用内存usableMemory被分为spark管理的内存和用户管理的内存两部分,spark管理的内存通过spark.memory.fraction进行控制,默认0.6。\nSpark管理的统一内存: 在设置了executor memory为3G时,debug代码 其各部分值如下:\nsystemMemory=3087007744 //container的JVM最多可用的内存 reservedMemory=314572800 //保留的300M minSystemMemory=471859200 //300M*1.5 executorMemory=3221225472 // 通过spark.executor.memory指定的值3g usableMemory=2772434944 //为systemMemory-reservedMemory 由上,spark可管理的内存大小为 1 2 3 注意: usableMemory 不是User Memory(有些也叫做other Memory) 实际为spark-submit 提交时申请的exector-memory 大小 - reservedMemory usableMemory * memoryFraction=2772434944 *0.6=1,663,460,966 这块内存在spark中被称为unified region(代号M)或统一内存或可用内存,其进一步被分为执行内存ExecutionMemory和StorageMemory,见上图。其中storage memory(代号R)是M的一个subregion,其的大小占比受spark.memory.storageFraction控制,默认为0.5,即默认占usableMemory的 0.6*0.5=0.3。我们用onHeapStorageRegionSize来表示storage这部分的大小。\nExecutionMemory执行内存:主要存储Shuffle、Join、Sort、Aggregation等计算过程中的临时数据; StorageMemory存储内存:主要存储spark的cache数据,如RDD.cache RDD.persist在调用时的数据存储,用户自定义变量及系统的广播变量等 这两块内存在当前默认的UnifiedMemoryManager(Spark1.6引入)下是可以互相动态侵占的,即Execution内存不足时可以占用Storage的内存,反之亦然,其详细规则如下:\nExecution内存不足且onHeapStorageRegionSize有空闲时,可以向Storage Memory借用内存,- 但借用后storage不能将execution占用的部分驱逐evict出去,只能等着Execution自己释放。 Storage内存不足时可以借用Execution的内存,且当Execution又有内存资源需求时可以驱逐Storage占用的部分,但只能驱逐StorageMemory-onHeapStorageRegionSize的大小,原来划定的onHeapStorageRegionSize且在使用的不可被抢占。 在spark的WebUI下,我们会看到Executors的信息如下图所示 我指定的executor-memory=5g,此处显示的StorageMemory其实是Spark的可用内存,包括Execution和Storage部分。(5G - 300M) * 0.6 = 2.7 用户管理的内存(Other): 上面说了占可用内存spark.memory.fraction(0.6)的spark 统一内存,另外0.4的用户内存用于存储用户代码生成的对象及RDD依赖等,用户在处理partition中的记录时,其遍历到的记录可以看做存储在Other区,当需要将RDD缓存时,将会序列化或不序列化的方式以Block的形式存储到Storage内存中。 3. 堆外内存 前面说了,堆外内存有的是参数spark.yarn.executor.memoryOverhead控制,有的是参数spark.memory.offHeap.size控制,这个都算offheap内存,不过前者主要用于JVM运行自身,字符串, NIO Buffer等开销,而后者主要是供统一内存管理用作Execution Memory及Storage Memory的用途。\nspark.yarn.executor.memoryOverhead设置的内存默认为executor.memory的0.1倍,最低384M,这个始终存在的,在采用yarn时,这块内存是包含在申请的容器内的,即申请容器大小大于spark.executor.memory+spark.yarn.executor.memoryOverhead。\n而通过spark.memory.offHeap.enable/size申请的内存不在JVM内,Spark利用TungSten技术直接操作管理JVM外的原生内存。主要是为了解决Java对象开销大和GC的问题。 1 2 3 4 5 6 protected[this] val maxOffHeapMemory = conf.get(MEMORY_OFFHEAP_SIZE) protected[this] val offHeapStorageMemory = (maxOffHeapMemory * conf.getDouble(\u0026#34;spark.memory.storageFraction\u0026#34;, 0.5)).toLong offHeapExecutionMemoryPool.incrementPoolSize(maxOffHeapMemory - offHeapStorageMemory) offHeapStorageMemoryPool.incrementPoolSize(offHeapStorageMemory) 其中MEMORY_OFFHEAP_SIZE为spark.memory.offHeap.size,这部分offHeap内存被spark.memory.storageFraction分为storage与execution用途供统一内存管理使用。\n统一内存管理UnifiedMemoryManager会管理堆内堆外的execution和storage内存,定义了四个内存池分别为:onHeapStorageMemoryPool, offHeapStorageMemoryPool, onHeapExecutionMemoryPool, offHeapExecutionMemoryPool,在spark内部申请内存时会指定MemoryMode为ON_HEAP或OFF_HEAP决定从哪部分申请内存。\n我们在WebUI看到的executors信息中Storage是包括了统一内存管理控制的堆内堆外区域的。\n下面的5.9G中包括了2.7G的堆内和3.2G(3g按1000算为3.221G,非1024算) 对大的几个RDD进行cache并action后,立马看会看到存储占用了堆内2.7G的大部分,即把execution的抢占了,仍然不够时已经有些序列化到磁盘中了。稍等一会execution会将storage抢占的这部分驱逐并序列化到disk中,如上将会变成下面的状况 按前面所说,这种均是在堆内内存存储的,我们查看被缓存的RDD的信息也可看到。 序列化存储级别怎么存到堆外?尤其是那些不希望被GC的长期存在的RDD,例如常驻内存的名单库等。我们可以使用persist时设置level为StorageLevel.OFF_HEAP,此种情况下只能用内存,不能同时存储到其他地方。 注意: 默认情况下Off-heap模式的内存并不启用,可以通过“spark.memory.offHeap.enabled”参数开启,并由spark.memory.offHeap.size指定堆外内存的大小(占用的空间划归JVM OffHeap内存)。\n4. 任务内存管理(Task Memory Manager) Executor中任务以线程的方式执行,各线程共享JVM的资源,任务之间的内存资源没有强隔离(任务没有专用的Heap区域)。因此,可能会出现这样的情况:先到达的任务可能占用较大的内存,而后到的任务因得不到足够的内存而挂起。\n在Spark任务内存管理中,使用HashMap存储任务与其消耗内存的映射关系。每个任务可占用的内存大小为潜在可使用计算内存的[1/2n, 1/n], 当剩余内存为小于1/2n时,任务将被挂起,直至有其他任务释放执行内存,而满足内存下限1/2n,任务被唤醒,其中n为当前Executor中活跃的任务数。\n任务执行过程中,如果需要更多的内存,则会进行申请,如果,存在空闲内存,则自动扩容成功,否则,将抛出OutOffMemroyError。\n5. 相关调优 什么时候需要调节Executor的堆外内存大小? 当出现一下异常时:shuffle file cannot find,executor lost、task lost,out of memory\n出现这种问题的现象大致有这么两种情况:\nExecutor挂掉了,对应的Executor上面的block manager也挂掉了,找不到对应的shuffle map output文件,Reducer端不能够拉取数据 Executor并没有挂掉,而是在拉取数据的过程出现了问题。 上述情况下,就可以去考虑调节一下executor的堆外内存。也许就可以避免报错;此外,有时,堆外内存调节的比较大的时候,对于性能来说,也会带来一定的提升。这个executor跑着跑着,突然内存不足了,堆外内存不足了,可能会OOM,挂掉。block manager也没有了,数据也丢失掉了。\n如果此时,stage0的executor挂了,BlockManager也没有了;此时,stage1的executor的task,虽然通过 Driver的MapOutputTrakcer获取到了自己数据的地址;但是实际上去找对方的BlockManager获取数据的 时候,是获取不到的。\n此时,就会在spark-submit运行作业(jar),client(standalone client、yarn client),在本机就会打印出log:shuffle output file not found。。。DAGScheduler,resubmitting task,一直会挂掉。反复挂掉几次,反复报错几次,整个spark作业就崩溃了\n1 2 3 4 5 --conf spark.yarn.executor.memoryOverhead=2048 spark-submit脚本里面,去用--conf的方式,去添加配置;一定要注意!!!切记, 不是在你的spark作业代码中,用new SparkConf().set()这种方式去设置,不要这样去设置,是没有用的! 一定要在spark-submit脚本中去设置。 调节等待时长 executor,优先从自己本地关联的BlockManager中获取某份数据\n如果本地BlockManager没有的话,那么会通过TransferService,去远程连接其他节点上executor 的BlockManager去获取,尝试建立远程的网络连接,并且去拉取数据,task创建的对象特别大,特别多频繁的让JVM堆内存满溢,进行垃圾回收。正好碰到那个exeuctor的JVM在垃圾回收。\n处于垃圾回收过程中,所有的工作线程全部停止;相当于只要一旦进行垃圾回收,spark / executor停止工作,无法提供响应,此时呢,就会没有响应,无法建立网络连接,会卡住;ok,spark默认的网络连接的超时时长,是60s,如果卡住60s都无法建立连接的话,那么就宣告失败了。碰到一种情况,偶尔,偶尔,偶尔!!!没有规律!!!某某file。一串file id。uuid(dsfsfd-2342vs\u0026ndash;sdf\u0026ndash;sdfsd)。not found。file lost。这种情况下,很有可能是有那份数据的executor在jvm gc。所以拉取数据的时候,建立不了连接。然后超过默认60s以后,直接宣告失败。报错几次,几次都拉取不到数据的话,可能会导致spark作业的崩溃。也可能会导致DAGScheduler,反复提交几次stage。TaskScheduler,反复提交几次task。大大延长我们的spark作业的运行时间。\n可以考虑调节连接的超时时长。\n1 2 --conf spark.core.connection.ack.wait.timeout=300 spark-submit脚本,切记,不是在new SparkConf().set()这种方式来设置的。spark.core.connection.ack.wait.timeout(spark core,connection,连接,ack,wait timeout,建立不上连接的时候,超时等待时长)调节这个值比较大以后,通常来说,可以避免部分的偶尔出现的某某文件拉取失败,某某文件lost掉了。。。 executor-memory 设置建议 如果设置小了,会发生什么:\n频繁GC,GC超限,CPU大部分时间用来做GC而回首的内存又很少,也就是executor堆内存不足。(通常gc 时间建议不超过task 时间的5%) 如果发生OOM或者GC耗时过长,考虑提高executor-memory或降低executor-core\n2. java.lang.OutOfMemoryError内存溢出,这和程序实现强相关,例如内存排序等,通常是要放入内存的数据量太大,内存空间不够引起的。 3. 数据频繁spill到磁盘,如果是I/O密集型的应用,响应时间就会显著延长。\n具体怎么样算调整到位呢? TimeLine显示状态合理(通通绿条),GC时长合理(占比很小),系统能够稳定运行。 当然内存给太大了也是浪费资源,合理的临界值是在内存给到一定程度,对运行效率已经没有帮助了的时候,就可以了。\n增加executor内存量以后,性能的提升: 如果需要对RDD进行cache,那么更多的内存,就可以缓存更多的数据,将更少的数据写入磁盘,甚至不写入磁盘。减少了磁盘IO。 对于shuffle操作,reduce端,会需要内存来存放拉取的数据并进行聚合。如果内存不够,也会写入磁盘。如果给executor分配更多内存以后,就有更少的数据,需要写入磁盘,甚至不需要写入磁盘。减少了磁盘IO,提升了性能。 对于task的执行,可能会创建很多对象。如果内存比较小,可能会频繁导致JVM堆内存满了,然后频繁GC,垃圾回收,minor GC和full GC。(速度很慢)。内存加大以后,带来更少的GC,垃圾回收,避免了速度变慢,性能提升。 在给定执行内存 M、线程池大小 N 和数据总量 D 的时候,想要有效地提升 CPU 利用率,我们就要计算出最佳并行度 P,计算方法是让数据分片的平均大小 D/P 坐落在(M/N*2, M/N)区间,让每个Task能够拿到并处理适量的数据。怎么理解适量呢?D/P是原始数据的尺寸,真正到内存里去,是会翻倍的,至于翻多少倍,这个和文件格式有关系。不过,不管他翻多少倍,只要原始的D/P和M/N在一个当量,那么我们大概率就能避开OOM的问题,不至于某些Tasks需要处理的数据分片过大而OOM。Shuffle过后每个Reduce Task也会产生数据分片,spark.sql.shuffle.partitions 控制Joins之中的Shuffle Reduce阶段并行度,spark.sql.shuffle.partitions = 估算结果文件大小 / [128M,256M],确保shuffle 后的数据分片大小在[128M,256M]区间。PS: 核心思路是,根据“定下来的”,去调整“未定下来的”,就可以去设置每一个参数了。\n假定Spark读取分布式文件,总大小512M,HDFS的分片是128M,那么并行度 = 512M / 128M = 4 Executor 并发度=1,那么Executor 内存 M 应在 128M 到 256M 之间。 Executor 并发度=2,那么Executor 内存 M 应在 256M 到 512M 之间。\n","permalink":"https://reid00.github.io/en/posts/computation/spark%E5%86%85%E5%AD%98%E7%A9%BA%E9%97%B4%E7%AE%A1%E7%90%86/","summary":"1. 概述 Spark应用在yarn运行模式下,其以Executor Container的形式存在,container能申请到的最大内存受yarn.","title":"Spark内存空间管理"},{"content":"话说,UDP比TCP快吗?\n相信就算不是八股文老手,也会下意识的脱口而出:\u0026ldquo;是\u0026rdquo;。\n这要追问为什么,估计大家也能说出个大概。\n但这也让人好奇,用UDP就一定比用TCP快吗?什么情况下用UDP会比用TCP慢?\n我们今天就来聊下这个话题。\n使用socket进行数据传输 作为一个程序员,假设我们需要在A电脑的进程发一段数据到B电脑的进程,我们一般会在代码里使用socket进行编程。\nsocket就像是一个电话或者邮箱(邮政的信箱)。当你想要发送消息的时候,拨通电话或者将信息塞到邮箱里,socket内核会自动完成将数据传给对方的这个过程。\n基于socket我们可以选择使用TCP或UDP协议进行通信。\n对于TCP这样的可靠性协议,每次消息发出后都能明确知道对方收没收到,就像打电话一样,只要\u0026quot;喂喂\u0026quot;两下就能知道对方有没有在听。\n而UDP就像是给邮政的信箱寄信一样,你寄出去的信,根本就不知道对方有没有正常收到,丢了也是有可能的。\n这让我想起了大概17年前,当时还没有现在这么发达的网购,想买一本《掌机迷》杂志,还得往信封里塞钱,然后一等就是一个月,好几次都怀疑信是不是丢了。我至今印象深刻,因为那是我和我哥攒了好久的钱。。。\n回到socket编程的话题上。\n创建socket的方式就像下面这样。\n1 fd = socket(AF_INET, 具体协议,0); 注意上面的\u0026quot;具体协议\u0026quot;,如果传入的是SOCK_STREAM,是指使用字节流传输数据,说白了就是TCP协议。 TCP: 面向连接的 可靠的 基于字节流 如果传入的是SOCK_DGRAM,是指使用数据报传输数据,也就是UDP协议。 UDP: 无连接 不可靠 基于消息报\n返回的fd是指socket句柄,可以理解为socket的身份证号。通过这个fd你可以在内核中找到唯一的socket结构。\n如果想要通过这个socket发消息,只需要操作这个fd就行了,比如执行 send(fd, msg, \u0026hellip;),内核就会通过这个fd句柄找到socket然后进行发数据的操作。\n如果一切顺利,此时对方执行接收消息的操作,也就是 recv(fd, msg, \u0026hellip;),就能拿到你发的消息。 对于异常情况的处理 但如果不顺利呢?\n比如消息发到一半,丢包了呢?\n那UDP和TCP的态度就不太一样了。\nUDP表示,\u0026ldquo;哦,是吗?然后呢?关我x事\u0026rdquo;\nTCP态度就截然相反了,\u0026ldquo;啊?那可不行,是不是我发太快了呢?是不是链路太堵被别人影响到了呢?不过你放心,我肯定给你补发\u0026rdquo;\nTCP老实人石锤了。我们来看下这个老实人在背后都默默做了哪些事情。\n重传机制 对于TCP,它会给发出的消息打上一个编号(sequence),接收方收到后回一个确认(ack)。发送方可以通过ack的数值知道接收方收到了哪些sequence的包。\n如果长时间等不到对方的确认,TCP就会重新发一次消息,这就是所谓的重传机制。 流量控制机制 但重传这件事本身对性能影响是比较严重的,所以是下下策。\n于是TCP就需要思考有没有办法可以尽量避免重传。\n因为数据发送方和接收方处理数据能力可能不同,因此如果可以根据双方的能力去调整发送的数据量就好了,于是就有了发送和接收窗口,基本上从名字就能看出它的作用,比如接收窗口的大小就是指,接收方当前能接收的数据量大小,发送窗口的大小就指发送方当前能发的数据量大小。TCP根据窗口的大小去控制自己发送的数据量,这样就能大大减少丢包的概率。 滑动窗口机制 接收方的接收到数据之后,会不断处理,处理能力也不是一成不变的,有时候处理的快些,那就可以收多点数据,处理的慢点那就希望对方能少发点数据。毕竟发多了就有可能处理不过来导致丢包,丢包会导致重传,这可是下下策。因此我们需要动态的去调节这个接收窗口的大小,于是就有了滑动窗口机制。\n看到这里大家可能就有点迷了,流量控制和滑动窗口机制貌似很像,它们之间是啥关系?我总结一下。其实现在TCP是通过滑动窗口机制来实现流量控制机制的。 拥塞控制机制 但这还不够,有时候发生丢包,并不是因为发送方和接收方的处理能力问题导致的。而是跟网络环境有关,大家可以将网络想象为一条公路。马路上可能堵满了别人家的车,只留下一辆车的空间。那就算你家有5辆车,目的地也正好有5个停车位,你也没办法同时全部一起上路。于是TCP希望能感知到外部的网络环境,根据网络环境及时调整自己的发包数量,比如马路只够两辆车跑,那我就只发两辆车。但外部环境这么复杂,TCP是怎么感知到的呢?\nTCP会先慢慢试探的发数据,不断加码数据量,越发越多,先发一个,再发2个,4个…。直到出现丢包,这样TCP就知道现在当前网络大概吃得消几个包了,这既是所谓的拥塞控制机制。\n不少人会疑惑流量控制和拥塞控制的关系。我这里小小的总结下。流量控制针对的是单个连接数据处理能力的控制,拥塞控制针对的是整个网络环境数据处理能力的控制。\n分段机制 但上面提到的都是怎么降低重传的概率,似乎重传这个事情就是无法避免的,那如果确实发生了,有没有办法降低它带来的影响呢?\n有。当我们需要发送一个超大的数据包时,如果这个数据包丢了,那就得重传同样大的数据包。但如果我能将其分成一小段一小段,那就算真丢了,那我也就只需要重传那一小段就好了,大大减小了重传的压力,这就是TCP的分段机制。\n而这个所谓的一小段的长度,在传输层叫MSS(Maximum Segment Size),数据包长度大于MSS则会分成N个小于等于MSS的包。 而在网络层,如果数据包还大于MTU(Maximum Transmit Unit),那还会继续分包。 一般情况下,MSS=MTU-40Byte,所以TCP分段后,到了IP层大概率就不会再分片了。 乱序重排机制 既然数据包会被分段,链路又这么复杂还会丢包,那数据包乱序也就显得不奇怪了。比如发数据包1,2,3。1号数据包走了其他网络路径,2和3数据包先到,1数据包后到,于是数据包顺序就成了2,3,1。这一点TCP也考虑到了,依靠数据包的sequence,接收方就能知道数据包的先后顺序。\n后发的数据包先到是吧,那就先放到专门的乱序队列中,等数据都到齐后,重新整理好乱序队列的数据包顺序后再给到用户,这就是乱序重排机制。 连接机制 前面提到,UDP是无连接的,而TCP是面向连接的。\n这里提到的连接到底是啥?\nTCP通过上面提到的各种机制实现了数据的可靠性。这些机制背后是通过一个个数据结构来实现的逻辑。而为了实现这套逻辑,操作系统内核需要在两端代码里维护一套复杂的状态机(三次握手,四次挥手,RST,closing等异常处理机制),这套状态机其实就是所谓的\u0026quot;连接\u0026quot;。这其实就是TCP的连接机制,而UDP用不上这套状态机,因此它是\u0026quot;无连接\u0026quot;的。\n网络环境链路很长,还复杂,数据丢包是很常见的。\n我们平常用TCP做各种数据传输,完全对这些事情无感知。\n哪有什么岁月静好,是TCP替你负重前行。\n这就是TCP三大特性\u0026quot;面向连接、可靠的、基于字节流\u0026quot;中\u0026quot;可靠\u0026quot;的含义。\n不信你改用UDP试试,丢包那就是真丢了,丢到你怀疑人生。\n用UDP就一定比用TCP快吗? 这时候UDP就不服了:\u0026ldquo;正因为没有这些复杂的TCP可靠性机制,所以我很快啊\u0026rdquo;\n嗯,这也是大部分人认为UDP比TCP快的原因。 实际上大部分情况下也确实是这样的。这话没毛病。\n那问题就来了。有没有用了UDP但却比TCP慢的情况呢?\n其实也有。 在回答这个问题前,我需要先说下UDP的用途。\n实际上,大部分人也不会尝试直接拿裸udp放到生产环境中去做项目。\n那UDP的价值在哪?\n在我看来,UDP的存在,本质是内核提供的一个最小网络传输功能。\n很多时候,大家虽然号称自己用了UDP,但实际上都很忌惮它的丢包问题,所以大部分情况下都会在UDP的基础上做各种不同程度的应用层可靠性保证。比如王者农药用的KCP,以及最近很火的QUIC(HTTP3.0),其实都在UDP的基础上做了重传逻辑,实现了一套类似TCP那样的可靠性机制。\n教科书上最爱提UDP适合用于音视频传输,因为这些场景允许丢包。但其实也不是什么包都能丢的,比如重要的关键帧啥的,该重传还得重传。除此之外,还有一些乱序处理机制。举个例子吧。\n打音视频电话的时候,你可能遇到过丢失中间某部分信息的情况,但应该从来没遇到过乱序的情况吧。\n比如对方打网络电话给你,说了:\u0026ldquo;我好想给小白来个点赞在看!\u0026rdquo;\n这时候网络信号不好,你可能会听到\u0026quot;我….点赞在看\u0026quot;。\n但却从来没遇到过\u0026quot;在看小白好想赞\u0026quot;这样的乱序场景吧?\n所以说,虽然选择了使用UDP,但一般还是会在应用层上做一些重传机制的。\n于是问题就来了,如果现在我需要传一个特别大的数据包。\n在TCP里,它内部会根据MSS的大小分段,这时候进入到IP层之后,每个包大小都不会超过MTU,因此IP层一般不会再进行分片。这时候发生丢包了,只需要重传每个MSS分段就够了。\n但对于UDP,其本身并不会分段,如果数据过大,到了IP层,就会进行分片。此时发生丢包的话,再次重传,就会重传整个大数据包。\n对于上面这种情况,使用UDP就比TCP要慢。\n当然,解决起来也不复杂。这里的关键点在于是否实现了数据分段机制,使用UDP的应用层如果也实现了分段机制的话,那就不会出现上述的问题了。\n总结 TCP为了实现可靠性,引入了重传机制、流量控制、滑动窗口、拥塞控制、分段以及乱序重排机制。而UDP则没有实现,因此一般来说TCP比UDP慢。\nTCP是面向连接的协议,而UDP是无连接的协议。这里的\u0026quot;连接\u0026quot;其实是,操作系统内核在两端代码里维护的一套复杂状态机。\n大部分项目,会在基于UDP的基础上,模仿TCP,实现不同程度的可靠性机制。比如王者农药用的KCP其实就在基于UDP在应用层里实现了一套重传机制。\n对于UDP+重传的场景,如果要传超大数据包,并且没有实现分段机制的话,那数据就会在IP层分片,一旦丢包,那就需要重传整个超大数据包。而TCP则不需要考虑这个,内部会自动分段,丢包重传分段就行了。这种场景下,其实TCP更快。\n","permalink":"https://reid00.github.io/en/posts/os_network/udp%E5%B0%B1%E4%B8%80%E5%AE%9A%E6%AF%94tcp%E5%BF%AB%E5%90%97/","summary":"话说,UDP比TCP快吗? 相信就算不是八股文老手,也会下意识的脱口而出:\u0026ldquo;是\u0026rdquo;。 这要追问为什么,估计大家也能说出个大","title":"UDP就一定比TCP快吗"},{"content":"简介 最近使用Gin 框架写接口,总是会出现一些write: connection reset by peer 或者 write: broken pipe 的错误, 在查询资料的时候,发现TCP的下面的情况可以触发一下两种错误。 另外Gin 的出现这个错误的原因这边有个分析Gin-RST 大概原因就是DB 连接池太小,有大量请求排队等待空闲链接,排队时间越长积压的请求越多,请求处理耗时越大,直到积压请求太多把句柄打满,出现了死锁。\nwrite: broken pipe 触发原因:\n服务器接收第一个客户端字节并关闭连接。已关闭的服务端 在收到 客户端的下一个字节写入 将导致服务器用 RST 数据包进行应答。当向接收 RST 的 socket 发送更多字节时,该socket将返回broken pipe。这就是客户机向服务器发送最后一个字节时发生的情况。\n经过测试: 向一个已经关闭的socket 写入数据,(无论buffer 是否写满) 都会出现第一次返回RST, 第二次写入出现broken pipe error, 读的话是EOF\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 package main import ( \u0026#34;errors\u0026#34; \u0026#34;log\u0026#34; \u0026#34;net\u0026#34; \u0026#34;os\u0026#34; \u0026#34;syscall\u0026#34; \u0026#34;time\u0026#34; ) func server() { listener, err := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;:8080\u0026#34;) if err != nil { log.Fatal(err) } defer listener.Close() conn, err := listener.Accept() if err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) os.Exit(1) } data := make([]byte, 1) if _, err := conn.Read(data); err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) } conn.Close() } func client() { conn, err := net.Dial(\u0026#34;tcp\u0026#34;, \u0026#34;localhost:8080\u0026#34;) if err != nil { log.Fatal(\u0026#34;client\u0026#34;, err) } // write to make the connection closed on the server side if _, err := conn.Write([]byte(\u0026#34;a\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) } time.Sleep(1 * time.Second) // write to generate an RST packet if _, err := conn.Write([]byte(\u0026#34;b\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) } time.Sleep(1 * time.Second) // write to generate the broken pipe error if _, err := conn.Write([]byte(\u0026#34;c\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) if errors.Is(err, syscall.EPIPE) { log.Print(\u0026#34;This is broken pipe error\u0026#34;) } } } func main() { go server() time.Sleep(3 * time.Second) // wait for server to run client() } connection reset by peer 触发原因: 如果服务器用socket接收缓冲区中剩余的字节关闭连接,那么将向客户端发送一个 RST 数据包。当客户端尝试从这样一个关闭的连接中读取时,它将通过对等错误获得连接重置。\n经过测试: 当向一个写满了缓冲区,并关闭的socket 进行read 或者write 操作都会导致connection reset by peer\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 package main import ( \u0026#34;errors\u0026#34; \u0026#34;log\u0026#34; \u0026#34;net\u0026#34; \u0026#34;os\u0026#34; \u0026#34;syscall\u0026#34; \u0026#34;time\u0026#34; ) func server() { listener, err := net.Listen(\u0026#34;tcp\u0026#34;, \u0026#34;:8080\u0026#34;) if err != nil { log.Fatal(err) } defer listener.Close() conn, err := listener.Accept() if err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) os.Exit(1) } data := make([]byte, 2) if _, err := conn.Read(data); err != nil { log.Fatal(\u0026#34;server\u0026#34;, err) } conn.Close() } func client() { conn, err := net.Dial(\u0026#34;tcp\u0026#34;, \u0026#34;localhost:8080\u0026#34;) if err != nil { log.Fatal(\u0026#34;client\u0026#34;, err) } if _, err := conn.Write([]byte(\u0026#34;abc\u0026#34;)); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) } time.Sleep(1 * time.Second) // wait for close on the server side // 下面的操作第一次read /write 都会 出现ECONNRESET reset by peer // 第二次的读则是EOF, 如果是写则是`write: broken pipe` // if _, err := conn.Write([]byte(\u0026#34;ab\u0026#34;)); err != nil { // log.Printf(\u0026#34;client: %v\u0026#34;, err) // } data := make([]byte, 1) if _, err := conn.Read(data); err != nil { log.Printf(\u0026#34;client: %v\u0026#34;, err) if errors.Is(err, syscall.ECONNRESET) { log.Print(\u0026#34;This is connection reset by peer error\u0026#34;) } } } func main() { go server() time.Sleep(3 * time.Second) // wait for server to run client() } ","permalink":"https://reid00.github.io/en/posts/langs_linux/gin-error-connection-write-broken-pipe/","summary":"简介 最近使用Gin 框架写接口,总是会出现一些write: connection reset by peer 或者 write: broken pipe 的错误, 在查询资料的时候,发现TCP的下面的情况可以触发一下两种错","title":"Gin Error Connection Write Broken Pipe"},{"content":"简介 总体上来说,Spark的流程和MapReduce的思想很类似,只是实现的细节方面会有很多差异。 首先澄清2个容易被混淆的概念:\nSpark是基于内存计算的框架 Spark比Hadoop快100倍 第一个问题是个伪命题。 任何程序都需要通过内存来执行,不论是单机程序还是分布式程序。 Spark会被称为 基于内存计算的框架 ,主要原因在于其和之前的分布式计算框架很大不同的一点是,Shuffle的数据集不需要通过读写磁盘来进行交换,而是直接通过内存交换数据得到。效率比读写磁盘的MapReduce高上好多倍,所以很多人称之为 基于内存的计算框架,其实更应该称为 基于内存进行数据交换的计算框架。\n至于第二个问题,有同学说,Spark官网 就是这么介绍的呀,Spark run workloads 100x faster than Hadoop。\n这点没什么问题,但是请注意官网用来比较的 workload 是 Logistic regresstion。 注意到了吗,这是一个需要反复迭代计算的机器学习算法,Spark是非常擅长在这种需要反复迭代计算的场景中(见问题1),而Hadoop MapReduce每次迭代都需要读写一次HDFS。以己之长,击人之短 差距可向而知。\n如果都只是跑一个简单的过滤场景的 workload,那么性能差距不会有这么多,总体上是一个级别的耗时。\n所以千万不要在任何场景中都说 Spark是基于内存的计算、Spark比Hadoop快100倍,这都是不严谨的说法。\n逻辑执行图 1. 弹性分布式数据集 RDD是Spark中的核心概念,直译过来叫做 弹性分布式数据集。\n所有的RDD要么是从外部数据源创建的,要么是从其他RDD转换过来的。RDD有两种产生方式:\n从外部数据源中创建 从一个RDD中转换而来 你可以把它当做一个List,但是这个List里面的元素是分布在不同机器上的,对List的所有操作都将被分发到不同的机器上执行。 RDD就是我们需要操作的数据集,并解决了 数据在哪儿 这个问题。 有了数据之后,我们需要定义在数据集上的操作(即业务逻辑)。 回想一下我们之前经历的流程:\n一开始我们什么都没有,只有分散在各个服务器上的日志数据,并且通过一个简单的脚本遍历连接服务器,执行相关的统计逻辑 我们接触了MapReduce计算框架,并定义了Map和Reduce的函数接口来实现计算逻辑,从而用户不比关心计算逻辑拆分与分发等底层问题 虽然MapReduce已经解决了我们分布式计算的需求,但是其编程范式只有map和reduce两个接口,使用不灵活。\n在Spark中,RDD提供了比MapReduce编程模型丰富得多的编程接口,如:filter、map、groupBy等都可以直接调用实现(这些操作本质上也划分为Map和Reduce两种类型)。\n现在,统计PV的例子中实现计算逻辑的伪代码可以这么写:\n1 2 3 4 5 6 7 8 9 10 // 从外部数据源中创建RDD,即读取日志数据 val rdd = sc.textFile(\u0026#34;...\u0026#34;) // 解析日志中的ip rdd.map(...) // 根据ip分组 .groupBy(\u0026#34;ip\u0026#34;) // 根据分组结果统计数量 .map(x=\u0026gt; (x._1, x._2.size)) // 保存到外部数据源 .saveAsTextFile(\u0026#34;...\u0026#34;) 在RDD进行操作行为可以划分为两种:\nTransformation:如filter、map、groupBy等,将会产生另外一个RDD Action:如count、saveAsTextFile等,触发整个逻辑图的计算流程 一个Spark程序可以看做是 一个或者多个RDD的完整生命周期,从诞生到发展,到变换,再到输出之后销毁。 2. 依赖关系 现在你可能会问,使用MapReduce,通过指定数据源定义了操作数据集,通过Map和Reduce两个函数接口划分了 能够分发到各个节点上并行执行的 和 需要经过一定量的结果合并之后才能够继续执行的 两种任务,并基于这两种接口类型的任务去拆分和分发计算逻辑。\n那么Spark中是如何做的呢?\nSpark中通过RDD定义了 分布式数据集,通过RDD的编程接口定义了计算逻辑,但是Spark是如何根据RDD中定义的逻辑来划分 能够分发到各个节点上并行执行的 和 需要经过一定量的结果合并之后才能够继续执行的 任务,从而实现计算逻辑的拆分和分发呢?\n其实和MapReduce一样,Spark中虽然提供了丰富的算子给用户实现计算逻辑,但是这些算子最终仍然会被归为两类:Map和Reduce。\n前面我们说过,在RDD上执行Transformation操作会产生另外一个RDD,随即,RDD之间将会产生依赖关系和父子RDD关系。\n而RDD中的依赖关系分为两种:\n1、完全依赖:又称为窄依赖,父RDD中一个分区的数据只被子RDD中对应的一个分区使用(1对1) 2、部分依赖:又称为宽依赖,父RDD中一个分区的数据会被子RDD中对应的多个分区使用(1对多 or 多对多)\n看到了吗,最后RDD通过依赖关系又回到了我们之前讨论的话题:能够分发到各个节点上并行执行的 和 需要经过一定量的结果合并之后才能够继续执行的 两种任务。\n对于完全依赖来说,各个分区之间的任务是互不影响的,所以其能够发到各个节点上并行执行。\n对于部分依赖来说,子RDD的某个分区可能依赖于父RDD的多个分区,所以其需要经过一定量的结果合并(依赖的所有父RDD分区)之后才能够继续执行。\n定义了这两种类型的任务之后,Spark就可以根据依赖关系进行 计算逻辑的拆分与分发 。\n那么RDD上的哪些操作是宽依赖,哪些操作是窄依赖呢?\n其实仔细想想很好区分,对于map、filter这种不需要Shuffle的操作都是窄依赖,而groupBy、reduceBy等需要Shuffle聚合的操作都是宽依赖。\n通过Transformation操作,RDD之间将会产生依赖关系,基于RDD上的操作与依赖关系 将会形成一张逻辑执行图 来描述本次任务的计算过程。\n什么意思呢?\n在RDD上进行的Transformation操作都是惰性执行的,意思就是只有数据真正用到的时候(Action操作)才会进行Transformation操作。\n例如以下RDD的操作:\n1 2 3 4 //rdd1只保留了从rdd中计算而来的路径,没有真正执行计算 val rdd1 = rdd.map.map.filter.map //直到有action操作才会触发计算任务 rdd1.count 也就是说,count之前,我们写的计算逻辑其实只是在 画一个逻辑图,只有真正使用到了count的时候,整个逻辑图才会被触发并执行计算逻辑。\n这么做的原因要归咎到RDD的计算模型,当rdd中出现action操作的时候,spark将会生成一个job,并根据rdd的依赖关系画出一张逻辑执行图。\n费劲心机画出了逻辑图之后再划分物理图时将会有最关键的作用。\n3.物理执行图 从RDD上得到逻辑执行图之后,执行计算任务前期的准备工作就都完成了,现在我们来详细讨论一下Spark是如何 拆分、分发计算逻辑的。\nSpark将会划分逻辑图从而生成物理执行图,表现形式为 DAG有向无环图,RDD的执行模型将根据物理图的划分而展开。\n现在我们知道,基于逻辑执行图,由于RDD之间的依赖关系被明显的划分为了两种:\n对于完全依赖窄依赖,可以完全不管其他RDD或者其他分区的执行进度,直接一条走到底的 对于部分依赖宽依赖,需要父RDD不同分区中的数据,所以他 一定是等到所有父RDD计算完毕之后才会执行的 基于逻辑执行图和对应的依赖关系,Spark可以明显的 划分出Stage:\n从逻辑图的最后方开始创建Stage 遇到完全依赖则加入当前Stage 遇到部分依赖则新建一个Stage 由此对整个逻辑图进行Stage的划分。这就是Spark对于计算逻辑的组织和拆分方式。\n那么这么做有什么好处呢?\n基于Stage的独立性,Spark实现了 Pipeline的计算方式。且由于 Stage内部的操作只有完全依赖,它可以毫无顾忌的建立 回溯机制:当一个分区数据计算失败或者丢失,可以直接从父RDD对应的分区中恢复,而不是重新计算整个父RDD。\n如果所有操作都是立即执行的话,那么处理流程应该是这样子的:\n1 2 3 4 5 6 7 8 //读取数据 list1 = readAllFromHDFS //将所有数据进行对应的map转换操作 list2 = list1.map //将所有数据进行对应的map转换操作 list3 = list2.map //将所有数据进行对应的filter过滤操作 list4 = list3.filter 注意,这种模式下,每个步骤都需要将 全量的数据集加载到内存中操作 这是毋庸置疑的,每个操作都要等待前一个操作全部处理完毕。\n作为对比,我们再来看Pipeline的计算模式:\n1 2 3 4 //读取数据 data = readOneLineFromHDFS //读取一条处理一条,每条数据经过管道执行到末端 data.map.map.filter 数据是作为流一条条从管道的开始一路走到结束,每个Stage都是一条独立的管道。最为直观的好处就是:不需要加载全量数据集,上一次的计算结果可以马上丢弃。\n全量数据集其实是一个很恐怖的东西,全世界都在避免它。所以某种意义上来看,如果没有Shuffle过程,Spark所需要内存其实非常小,一条数据又能占多大空间。\n第二,如果不是Pipeline的方式,而是马上触发全量操作,势必需要一个中间容器来保存结果,其实这里就又回到MapReduce的老路,效率很低。\n现在我们来考虑 不根据RDD的依赖关系来划分Stage的前提下,两种比较极端的情况: 1、整个逻辑图作为一个Stage\n一个Job只包含一个Stage,数据一路从头走到尾,什么中间结果都不需要保存 如果RDD之间都是 完全依赖 的话这是最完美的场景 缺陷: 在Shuffle操作符处(即部分依赖的产生处),只能通过一个Task来处理所有分区的数据 多个Task情况下没有办法各自感知Shuffle过程中所需要的数据状态 严重影响计算效率 2、每个RDD的操作都作为一个Stage\n各个操作都需要进行全量计算,其实就相当于MapReduce 缺陷:严重影响计算效率 可以看到,Spark通过RDD之间的依赖关系来划分逻辑执行图形成一个个独立的Stage,并通过Stage来实现Pipeline的计算模式。\n计算逻辑拆分后,通过Pipeline的执行将计算逻辑分发到各个节点,并最大程度保证计算的效率。\n综上,基于逻辑执行图能做的事情有: 1、划分Stage 2、执行Pipeline 3、建立回溯机制\n根据RDD之间的依赖关系来划分Stage解决了以下问题: 1、实现Pipeline,不需要保留中间计算结果 2、计算保持高效,Task分布均衡\n至此,Spark主要的 计算逻辑拆分与分发 步骤大概介绍完毕。\n与之相对的,一段Spark代码,或者一个Spark程序,运行起来之后是什么样子的,代码是如何被调度执行的,应该在开发的阶段就能在脑子里形成一个执行图。\n充分了解程序运行的背后发生了什么是保证系统稳定高效运行的关键,这点放在哪里都是真理。\nShuffle过程与管理 1. Shuffle总览 和之前看到的MapReduce Shuffle过程相对比。二者在高级别上来看别没有多大区别,都是将mapper中的数据进行partition之后送到不同的reducer中,reducer以内存为缓存边拉取数据边计算。\n但是在具体实现的低级别角度上两者区别还是比较大的,MapReduce阶段划分明显,Spark中没有明显的划分。\nMapReduce中的Mapper即为Spark中的 ShuffleMapTask,而Reduce对应的可能是ShuffleMapTask或者ResultTask。\nSpark各个阶段通过RDD的算子体现出来,具体Shuffle过程可以分为:\nShuffle Write Shuffle Read Write过程其实很简单,根据之前划分的Stage,每个Stage的final task的结果将会写磁盘,和MapReduce一样,有多少个分区数就会写多少个文件。\n后续的Stage将会通过网络来fatch各自对应的数据文件。\nRead过程需要解决几个问题:\n什么时候fetch数据:依赖的stage中所有ShuffleMapTask都执行完之后才进行fetch,迎合pipeline的思想 何获得数据位置:ShuffleMapTask结束之后都会想Driver端汇报数据存放位置,ResultTaskfetch数据时都会向Driver查询需要fetch的数据在哪里,Driver端有比较复杂的实现机制 fetch的数据怎么存:刚fetch过来的数据存放在softBuffer中,计算之后的数据可以根据策略选择存放在内存或者内存+磁盘中 和fetch过程的计算和MapReduce也不一样:\nSpark:边fetch边计算,因为是无序的,所有没有必要要求所有数据都获取之后才进行计算 MapReduce:MapReduce中强制要求数据有序之后才进行reduce操作,所以MapReduce是 一次性fetch所有数据之后才计算 总结一下,与MapReduce相比:\nHeight Level:无太大区别,将mapper中的数据进行partition之后送到不同的reducer中 Low Level:实现差别较大,MapReduce阶段划分明显,Spark中没有明显的划分 2. Shuffle Manage Spark 中负责 Shuffle 过程管理的是 ShuffleManager,它接管了 Shuffle Read、Shuffle Write 过程中的 执行、计算和处理 相关的实现细节。\n比如 Write 过程中怎么组织数据写入磁盘,Read 过程中怎么拉取数据和保存数据。\nShuffleManager 是一个接口,主要有两种实现:\nHashShuffleManager:Spark 1.2之前默认使用,会产生大量的中间磁盘文件,进而由大量的磁盘IO操作影响了性能。 SortShuffleManager:每个Task在进行Shuffle操作时,虽然也会产生较多的临时磁盘文件,但是最后会将所有的临时文件合并(merge)成一个磁盘文件,因此每个Task就只有一个磁盘文件。在下一个stage的Shuffle read task拉取自己的数据时,只要根据索引读取每个磁盘文件中的部分数据即可。 HashShuffleManager 为了简单的说明,这里假设我们的 Executor 可用的CPU核心数只有一个,无论 Executor 上有分配了多少个Task,同一时间只能执行一个Task。\n在 Shuffle Write 阶段,Executor 的在依次执行每个Task时,HashShuffleManager 都会对Task 中的所有数据的key执行相应的hash运算,hash的参数是下游Stage的 Task数量。通过这hash映射之后,每个key都会有一个对应的结果值,根据hash的结果值来写文件(这个过程中会经过一段内存的缓冲区,缓冲区满了之后写入磁盘),相同结果的数据写到一个文件中。\n这样一来,上游每个Task中,都会根据下游Stage的Task数量 产生对应数量的文件,相同key的数据肯定在同一份文件中,一份文件中可能会有多个key的数据。下游stage计算数据时,只需要拉取这个文件的所有数据即可进行计算。在这个过程中,会边拉取边计算,每个Task也会有自己的缓冲区,每次只取buffer大小的数据通过内存中的Map进行聚合,反复操作直至数据获取完毕。\n么描述大家可能还会觉得合情合理,那么我们从产生的总文件数的角度来看呢?\n假设当前Stage有200个Task需要执行,下游Stage有100个Task,按照我们刚刚描述的过程来看,产生的总文件数为:200 * 100 = 20000 个。\n这是一个非常惊人的数字,我们都知道 磁盘的IO 一直是程序执行的瓶颈之一,我们在执行程序的时候都会尽可能的避免写磁盘操作。而现在,一个 Shuffle Write 过程就会产生成千上万个文件,注定了这个程序不会快到哪里去。\n工作流程如下图所示: 那么有没有优化方式呢?肯定是有的。\n在使用 HashShuffleManager 的时候,我们可以设置一个参数 spark.Shuffle.consolidateFiles 该参数默认值为false,将其设置为true即可开启优化机制。强烈建议设置为true,为啥呢?我们来解释解释。\nconsolidate机制最重要的功能就是 同一个CPU 允许不同的task复用同一批磁盘文件,这样就可以有效将多个task的磁盘文件进行一定程度上的合并,从而大幅度减少磁盘文件的数量,进而提升Shuffle write的性能。\n之前的流程中,每个Task都会创建n个文件,Task之间是互相隔离的。而在 consolidate机制 中Task之间是可以复用文件的,因为同一个key的数据可能是分布在不同的Task上处理的。\n简单来说,因为Task之间的数据文件可以复用,一个cpu核心只会创建和下游Stage的Task数量一样多的文件数,同一个cpu核心中处理的所有task都会重复使用同一批文件。 总结为:\nconsolidate=false:文件数量由 上游Stage任务数(不同的任务可能会被同一个cpu处理) * 下游Stage任务数 决定 consolidate=true:文件数量由 上游处理任务的CPU核心数(一个cpu可能会处理多个任务) * 下游Stage任务数 决定 还是我们之前举的例子,假设当前Stage有200个Task需要执行,下游Stage有100个Task,如果此时我们有10个Executor(每个1core),那么总文件数为: 10(cpu核心数)* 100(下游Task数量) = 200 个。\n由此可见,当开启了 consolidate 机制后,Executor 的cpu核心数越多,在提供处理并行度的同时,Shuffle Write 产生的文件数就越多,这点需要注意。\nShuffle Read 阶段并没有变化,都是直接拉取自己所需要的那份数据进行计算。\nconsolidate机制下,工作流程如下: SortShuffleManager 经过前面的介绍之后,我们知道使用 HashShuffleManager 时开启consolidate机制可以减少很多文件的产生,提高 Shuffle Write 效率。\n无论 consolidate机制 是否开启,HashShuffleManager 所产生的文件数都与下游Stage的Task数量有关系。\n现在我们再来看另外一种 Shuffle管理机制,SortShuffleManager。\n通过 SortShuffleManager 这个名字大家可以知道,这是一个排序的Shuffle管理器(HashShuffleManager为无序)。\nShuffle Write 的具体执行过程如下:\n每个Task将Shuffle的数据写入自己的buffer内存缓冲区中,每条数据写入时都会判断是否超出阈值 超出使用阈值则触发刷写,将数据一批批的写入磁盘中 写入磁盘前会根据key对内存中的数据进行排序 排完序后的数据根据批次大小(默认10000)依次写入磁盘中 Task数据处理结束后,将之前刷写的所有文件读取,合并之后重新写入一个大文件中 因为一个Task处理的数据可能对应下游多个Task需要处理的数据 所以此过程会创建索引文件标记下游各个Task对应的数据在文件中的start offset与end offset 由于需要标记下游各个Task所需要的数据偏移量,所以需要进行sort排序之后才可写入 从以上过程中可以看出,和 HashShuffleManager 一样 SortShuffleManager 的每个Task也会创建很多文件,不同的是 HashShuffleManager 中每个Task创建的文件数和下游的Stage任务数一致,而 SortShuffleManager 则是 按照自己的buffer内存空间大小刷写的文件块,并且最后还会做一次大合并,一个Task只对应一个文件。\n文件数量由 上游Stage的Task数量 决定。 执行流程如下: 除此之外,SortShuffleManager 还有另外一种 bypass 的执行模式。\n当 Shuffle map task数量小于 spark.Shuffle.sort.bypassMergeThreshold 的值,且不是聚合类的Shuffle算子(比如reduceByKey),比如 join 等操作时将会触发。\n此时task会为每个下游task都创建一个临时磁盘文件,并将数据按key进行hash然后根据key的hash值,将key写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。\n最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并 创建一个单独的索引文件。\n该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的HashShuffleManager来说,Shuffle read的性能会更好。\n而该机制与普通SortShuffleManager运行机制的不同在于:\n第一,磁盘写机制不同; 第二,不会进行排序。 也就是说,启用该机制的最大好处在于,Shuffle write过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。\n内存模块 Spark是用Scala开发完成的,也是一个运行在JVM体系上的系统性框架,所以 Spark的内存模型也是基于Java虚拟机来的。\n基本模型就是:堆、栈、静态代码块和全局空间,在虚拟机的内存模型上Spark将内存做了二次划分。\n作为一个严重依赖内存进行数据计算的系统来说,内存管理模块 是Spark中极其重要的一部分。\n1. 内存划分 从性质上看,Executor 可使用的内存空间分为两种:堆内、堆外。\n堆内内存即直接通过 spark.executor.memory 或者 -–executor-memory 设置分配得到,属于一定会有值的强制性配置。\n而堆外内存则是一种可选性配置,默认不使用,通过配置 spark.memory.offHeap.enabled 参数启用,由 spark.memory.offHeap.size 参数设定堆外空间的大小。\n堆外内存将会 存储经过序列化的二进制数据。一定程度上会减少不必要的内存开销,以及频繁的 GC 扫描和回收,提升了处理性能。\n从内存区域上看,内存大致可以划分为三个模块(堆外内存没有 Execution 的空间):\nStorage:RDD缓存、Broadcast等数据空间。 Execution:Shuffle过程使用的内存。 Other:用户定义的数据结构、Spark内部元数据等其他内存空间。 2. 内存管理 静态内存管理 静态内存管理为 Spark 1.6 之前默认使用的管理方式。 忽略\n统一内存管理 在内存结构总体不变的情况下,Spark 1.6 之后引入了新的内存管理机制,统一内存管理。 和静态内存管理相比,统一内存管理主要的变化点在于:\n增加系统预留的内存空间 各个区域初始分配的默认值重新调整 Storage和Execution两个区域之间不再是固定大小,而是 动态调节 总堆内内存的基础上,先扣除 给系统的预留空间(默认300M),剩下的为可用内存总大小。\n注意: spark.memory.storageFraction 统一内存管理的参数 spark.storage.memoryFraction 静态内存管理的参数 (deprecated) This is read only if spark.memory.useLegacyMode is enabled. Fraction of Java heap to use for Spark\u0026rsquo;s memory cache. This should not be larger than the \u0026ldquo;old\u0026rdquo; generation of objects in the JVM, which by default is given 0.6 of the heap, but you can increase it if you configure your own old generation size.\n在可用内存总大小的基础上,划分两大块:\n统一内存(Unified Memory):Storage、Execution共同使用的内存,默认值 0.75(2.0以后为0.6),由 spark.memory.fraction 控制 Storage:默认为0.5,占统一内存的50%,由 spark.memory.storageFraction 控制。主要用于存储 spark 的 cache 数据,例如RDD的缓存、unroll数据; Execution:默认为0.5,占统一内存的50%,等于 1 - spark.memory.storageFraction。主要用于存放 Shuffle、Join、Sort、Aggregation 等计算过程中的临时数据; 用户内存(User Memory):默认为0.4,占可用总内存的40%, 等于 1 - spark.memory.fraction。主要用于存储 RDD 转换操作所需要的数据,例如 RDD 依赖等信息; 预留内存(Reserved Memory): 系统预留内存,会用来存储Spark内部对象。 可以看到,在统一内存管理中,Storage和Execution被划入一块大内存池中进行统一管理。 这样做的好处是,Storage和Execution 的内存空间用户可以不用自己那么操心去优化、调整。 当有一方的内存不够用时,将会到另外一方去「借」一些内存回来用,达到 动态内存分配与调整 的效果。\n在 Spark 1.6 之后的版本中默认不再使用 静态内存管理 的方式,但是可以通过设置 spark.memory.userLegacyMode 的值(true/false,默认false)来选择内存管理方式。\n其中最重要的优化在于动态占用机制,其规则如下:\n设定基本的存储内存和执行内存区域(spark.storage.storageFraction参数),该设定确定了双方各自拥有的空间的范围。 双方的空间都不足时,则存储到硬盘;若己方空间不足而对方空余时,可借用对方的空间;(存储空间不足是指不足以放下一个完整的Block)。 执行内存的空间被对方占用后,可让对方将占用的部分转存到硬盘,然后“归还”借用的空间。 存储内存的空间被对方占用后,无法让对方“归还”,因为需要考虑Shuffle过程中的很多因素,实现起来较为复杂。 凭借统一内存管理机制,Spark在一定程度上提高了堆内和堆外内存资源的利用率,降低了开发者维护Spark内存的难度,但并不意味着开发者可以高枕无忧。 譬如,所以如果存储内存的空间太大或者说缓存的数据过多,反而会导致频繁的全量垃圾回收,降低任务执行时的性能,因为缓存的RDD数据通常都是长期驻留内存的。所以要想充分发挥Spark的性能,需要开发者进一步了解存储内存和执行内存各自的管理方式和实现原理。\nSpark性能优化 1. 开发调优 代码开发,是执行Spark任务的第一步,同时也是优化Spark任务的第一个入手点,良好的 RDD lineage、高性能的算子操作、不同高级特性的组合使用,都能够给Spark任务带来巨大的提升空间。\n开发出优秀的Spark程序,需要你熟悉Spark的各种API和特性。其中最重要的一点我们在逻辑执行图小节中提到过:开发Spark程序其实就是在画图。\n如何能够把这个图快速画出来的同时还能画好看,就是你需要考虑的,这就是考验Spark开发的基本功。\nRDD复用 和其他任何程序中 变量复用 一样,在Spark程序中创建并使用RDD也要贯彻这个思想。\n在编码的时候,RDD和任何单机程序一样,本身只是作为一个普通的变量对象存在,不同的是单机变量的创建会消耗内存,而RDD的创建会 消耗磁盘、内存与算力等更多方面的资源(想想RDD创建之后的使用过程,是不是这样呢)。\n所以要把RDD的创建和使用当做一个 需要消耗高昂费用的动作 来谨慎使用,从代码的源头节约与优化程序空间与效率。\n有的同学在开发Spark程序的时候,可能在业务逻辑1创建了一个RDD1,经过各种Transformation以及最后的Action操作之后,开始处理业务逻辑2,又在相同的数据源上创建了RDD2,然后继续写业务逻辑代码。\n一般来讲,相同的数据源的RDD 只允许创建一次,不要创建相同的RDD,保证代码的整洁性。\n在RDD的lineage过程中,如果有多个业务重复使用某个lineage的计算过程,则 应该将其抽出作为一个独立的中间RDD使用,尽可能复用相同的RDD。\n无论是数据源RDD还是中间RDD,如果被反复多次使用,则应该考虑将其做 缓存持久化操作。\n可以看到,如果没有对业务逻辑有比较清晰的了解,开发人员很难从繁杂的计算过程中提取出可以复用甚至进行缓存操作的代码块,无法优化到点。\n另外,在考虑对RDD持久化操作时,应该针对 可用硬件资源、RDD数据量、程序时效性等要求 选择不同的缓存策略(详见「内存模块」小节)。\n总结:\n相同的数据源的RDD 只允许创建一次 多个业务逻辑反复使用同一个lineage 应该将其抽出作为一个独立的中间RDD使用 任何被多次重复使用的RDD应该考虑将其做 缓存持久化操作 例子:\n避免创建重复的RDD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 需要对名为“hello.txt”的HDFS文件进行一次map操作,再进行一次reduce操作。也就是说,需要对一份数据执行两次算子操作。 // 错误的做法:对于同一份数据执行多次算子操作时,创建多个RDD。 // 这里执行了两次textFile方法,针对同一个HDFS文件,创建了两个RDD出来,然后分别对每个RDD都执行了一个算子操作。 // 这种情况下,Spark需要从HDFS上两次加载hello.txt文件的内容,并创建两个单独的RDD;第二次加载HDFS文件以及创建RDD的性能开销,很明显是白白浪费掉的。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;) rdd1.map(...) val rdd2 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;) rdd2.reduce(...) // 正确的用法:对于一份数据执行多次算子操作时,只使用一个RDD。 // 这种写法很明显比上一种写法要好多了,因为我们对于同一份数据只创建了一个RDD,然后对这一个RDD执行了多次算子操作。 // 但是要注意到这里为止优化还没有结束,由于rdd1被执行了两次算子操作,第二次执行reduce操作的时候,还会再次从源头处重新计算一次rdd1的数据,因此还是会有重复计算的性能开销。 // 要彻底解决这个问题,必须结合“原则三:对多次使用的RDD进行持久化”,才能保证一个RDD被多次使用时只被计算一次。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;) rdd1.map(...) rdd1.reduce(...) 尽可能复用同一个RDD 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 // 错误的做法。 // 有一个\u0026lt;Long, String\u0026gt;格式的RDD,即rdd1。 // 接着由于业务需要,对rdd1执行了一个map操作,创建了一个rdd2,而rdd2中的数据仅仅是rdd1中的value值而已,也就是说,rdd2是rdd1的子集。 JavaPairRDD\u0026lt;Long, String\u0026gt; rdd1 = ... JavaRDD\u0026lt;String\u0026gt; rdd2 = rdd1.map(...) // 分别对rdd1和rdd2执行了不同的算子操作。 rdd1.reduceByKey(...) rdd2.map(...) // 正确的做法。 // 上面这个case中,其实rdd1和rdd2的区别无非就是数据格式不同而已,rdd2的数据完全就是rdd1的子集而已,却创建了两个rdd,并对两个rdd都执行了一次算子操作。 // 此时会因为对rdd1执行map算子来创建rdd2,而多执行一次算子操作,进而增加性能开销。 // 其实在这种情况下完全可以复用同一个RDD。 // 我们可以使用rdd1,既做reduceByKey操作,也做map操作。 // 在进行第二个map操作时,只使用每个数据的tuple._2,也就是rdd1中的value值,即可。 JavaPairRDD\u0026lt;Long, String\u0026gt; rdd1 = ... rdd1.reduceByKey(...) rdd1.map(tuple._2...) // 第二种方式相较于第一种方式而言,很明显减少了一次rdd2的计算开销。 // 但是到这里为止,优化还没有结束,对rdd1我们还是执行了两次算子操作,rdd1实际上还是会被计算两次。 // 因此还需要配合“原则三:对多次使用的RDD进行持久化”进行使用,才能保证一个RDD被多次使用时只被计算一次。 对多次使用的RDD进行持久化 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // 如果要对一个RDD进行持久化,只要对这个RDD调用cache()和persist()即可。 // 正确的做法。 // cache()方法表示:使用非序列化的方式将RDD中的数据全部尝试持久化到内存中。 // 此时再对rdd1执行两次算子操作时,只有在第一次执行map算子时,才会将这个rdd1从源头处计算一次。 // 第二次执行reduce算子时,就会直接从内存中提取数据进行计算,不会重复计算一个rdd。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;).cache() rdd1.map(...) rdd1.reduce(...) // persist()方法表示:手动选择持久化级别,并使用指定的方式进行持久化。 // 比如说,StorageLevel.MEMORY_AND_DISK_SER表示,内存充足时优先持久化到内存中,内存不充足时持久化到磁盘文件中。 // 而且其中的_SER后缀表示,使用序列化的方式来保存RDD数据,此时RDD中的每个partition都会序列化成一个大的字节数组,然后再持久化到内存或磁盘中。 // 序列化的方式可以减少持久化的数据对内存/磁盘的占用量,进而避免内存被持久化数据占用过多,从而发生频繁GC。 val rdd1 = sc.textFile(\u0026#34;hdfs://192.168.0.1:9000/hello.txt\u0026#34;).persist(StorageLevel.MEMORY_AND_DISK_SER) rdd1.map(...) rdd1.reduce(...) 尽量避免使用shuffle类算子 如果有可能的话,要尽量避免使用shuffle类算子。因为Spark作业运行过程中,最消耗性能的地方就是shuffle过程。shuffle过程,简单来说,就是将分布在集群中多个节点上的同一个key,拉取到同一个节点上,进行聚合或join等操作。比如reduceByKey、join等算子,都会触发shuffle操作。\nshuffle过程中,各个节点上的相同key都会先写入本地磁盘文件中,然后其他节点需要通过网络传输拉取各个节点上的磁盘文件中的相同key。而且相同key都拉取到同一个节点进行聚合操作时,还有可能会因为一个节点上处理的key过多,导致内存不够存放,进而溢写到磁盘文件中。因此在shuffle过程中,可能会发生大量的磁盘文件读写的IO操作,以及数据的网络传输操作。磁盘IO和网络数据传输也是shuffle性能较差的主要原因。\n因此在我们的开发过程中,能避免则尽可能避免使用reduceByKey、join、distinct、repartition等会进行shuffle的算子,尽量使用map类的非shuffle算子。这样的话,没有shuffle操作或者仅有较少shuffle操作的Spark作业,可以大大减少性能开销。 Broadcast与map进行join代码示例\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 传统的join操作会导致shuffle操作。 // 因为两个RDD中,相同的key都需要通过网络拉取到一个节点上,由一个task进行join操作。 val rdd3 = rdd1.join(rdd2) // Broadcast+map的join操作,不会导致shuffle操作。 // 使用Broadcast将一个数据量较小的RDD作为广播变量。 val rdd2Data = rdd2.collect() val rdd2DataBroadcast = sc.broadcast(rdd2Data) // 在rdd1.map算子中,可以从rdd2DataBroadcast中,获取rdd2的所有数据。 // 然后进行遍历,如果发现rdd2中某条数据的key与rdd1的当前数据的key是相同的,那么就判定可以进行join。 // 此时就可以根据自己需要的方式,将rdd1当前数据与rdd2中可以连接的数据,拼接在一起(String或Tuple)。 val rdd3 = rdd1.map(rdd2DataBroadcast...) // 注意,以上操作,建议仅仅在rdd2的数据量比较少(比如几百M,或者一两G)的情况下使用。 // 因为每个Executor的内存中,都会驻留一份rdd2的全量数据。 使用map-side预聚合的shuffle操作 如果因为业务需要,一定要使用shuffle操作,无法用map类的算子来替代,那么尽量使用可以map-side预聚合的算子。 所谓的map-side预聚合,说的是在每个节点本地对相同的key进行一次聚合操作,类似于MapReduce中的本地combiner。map-side预聚合之后,每个节点本地就只会有一条相同的key,因为多条相同的key都被聚合起来了。其他节点在拉取所有节点上的相同key时,就会大大减少需要拉取的数据数量,从而也就减少了磁盘IO以及网络传输开销。通常来说,在可能的情况下,建议使用reduceByKey或者aggregateByKey算子来替代掉groupByKey算子。因为reduceByKey和aggregateByKey算子都会使用用户自定义的函数对每个节点本地的相同key进行预聚合。而groupByKey算子是不会进行预聚合的,全量的数据会在集群的各个节点之间分发和传输,性能相对来说比较差。 比如如下两幅图,就是典型的例子,分别基于reduceByKey和groupByKey进行单词计数。其中第一张图是groupByKey的原理图,可以看到,没有进行任何本地聚合时,所有数据都会在集群节点之间传输;第二张图是reduceByKey的原理图,可以看到,每个节点本地的相同key数据,都进行了预聚合,然后才传输到其他节点上进行全局聚合。\n使用高性能的算子\n使用reduceByKey/aggregateByKey替代groupByKey 详情见“原则五:使用map-side预聚合的shuffle操作”。\n使用mapPartitions替代普通map mapPartitions类的算子,一次函数调用会处理一个partition所有的数据,而不是一次函数调用处理一条,性能相对来说会高一些。但是有的时候,使用mapPartitions会出现OOM(内存溢出)的问题。因为单次函数调用就要处理掉一个partition所有的数据,如果内存不够,垃圾回收时是无法回收掉太多对象的,很可能出现OOM异常。所以使用这类操作时要慎重!\n使用foreachPartitions替代foreach 原理类似于“使用mapPartitions替代map”,也是一次函数调用处理一个partition的所有数据,而不是一次函数调用处理一条数据。在实践中发现,foreachPartitions类的算子,对性能的提升还是很有帮助的。比如在foreach函数中,将RDD中所有数据写MySQL,那么如果是普通的foreach算子,就会一条数据一条数据地写,每次函数调用可能就会创建一个数据库连接,此时就势必会频繁地创建和销毁数据库连接,性能是非常低下;但是如果用foreachPartitions算子一次性处理一个partition的数据,那么对于每个partition,只要创建一个数据库连接即可,然后执行批量插入操作,此时性能是比较高的。实践中发现,对于1万条左右的数据量写MySQL,性能可以提升30%以上。\n使用filter之后进行coalesce操作 通常对一个RDD执行filter算子过滤掉RDD中较多数据后(比如30%以上的数据),建议使用coalesce算子,手动减少RDD的partition数量,将RDD中的数据压缩到更少的partition中去。因为filter之后,RDD的每个partition中都会有很多数据被过滤掉,此时如果照常进行后续的计算,其实每个task处理的partition中的数据量并不是很多,有一点资源浪费,而且此时处理的task越多,可能速度反而越慢。因此用coalesce减少partition数量,将RDD中的数据压缩到更少的partition之后,只要使用更少的task即可处理完所有的partition。在某些场景下,对于性能的提升会有一定的帮助。\n使用repartitionAndSortWithinPartitions替代repartition与sort类操作 repartitionAndSortWithinPartitions是Spark官网推荐的一个算子,官方建议,如果需要在repartition重分区之后,还要进行排序,建议直接使用repartitionAndSortWithinPartitions算子。因为该算子可以一边进行重分区的shuffle操作,一边进行排序。shuffle与sort两个操作同时进行,比先shuffle再sort来说,性能可能是要高的。\n广播大变量\n使用Kryo优化序列化性能\n优化数据结构\n2. 资源调优 了解完了Spark作业运行的基本原理之后,对资源相关的参数就容易理解了。所谓的Spark资源参数调优,其实主要就是对Spark运行过程中各个使用资源的地方,通过调节各种参数,来优化资源使用的效率,从而提升Spark作业的执行性能。以下参数就是Spark中主要的资源参数,每个参数都对应着作业运行原理中的某个部分,我们同时也给出了一个调优的参考值。\nnum-executors 参数说明:该参数用于设置Spark作业总共要用多少个Executor进程来执行。Driver在向YARN集群管理器申请资源时,YARN集群管理器会尽可能按照你的设置来在集群的各个工作节点上,启动相应数量的Executor进程。这个参数非常之重要,如果不设置的话,默认只会给你启动少量的Executor进程,此时你的Spark作业的运行速度是非常慢的。 参数调优建议:每个Spark作业的运行一般设置50~100个左右的Executor进程比较合适,设置太少或太多的Executor进程都不好。设置的太少,无法充分利用集群资源;设置的太多的话,大部分队列可能无法给予充分的资源。 executor-memory 参数说明:该参数用于设置每个Executor进程的内存。Executor内存的大小,很多时候直接决定了Spark作业的性能,而且跟常见的JVM OOM异常,也有直接的关联。 参数调优建议:每个Executor进程的内存设置4G~8G较为合适。但是这只是一个参考值,具体的设置还是得根据不同部门的资源队列来定。可以看看自己团队的资源队列的最大内存限制是多少,num-executors乘以executor-memory,是不能超过队列的最大内存量的。此外,如果你是跟团队里其他人共享这个资源队列,那么申请的内存量最好不要超过资源队列最大总内存的1/3~1/2,避免你自己的Spark作业占用了队列所有的资源,导致别的同学的作业无法运行。 executor-cores 参数说明:该参数用于设置每个Executor进程的CPU core数量。这个参数决定了每个Executor进程并行执行task线程的能力。因为每个CPU core同一时间只能执行一个task线程,因此每个Executor进程的CPU core数量越多,越能够快速地执行完分配给自己的所有task线程。 参数调优建议:Executor的CPU core数量设置为2~4个较为合适。同样得根据不同部门的资源队列来定,可以看看自己的资源队列的最大CPU core限制是多少,再依据设置的Executor数量,来决定每个Executor进程可以分配到几个CPU core。同样建议,如果是跟他人共享这个队列,那么num-executors * executor-cores不要超过队列总CPU core的1/3~1/2左右比较合适,也是避免影响其他同学的作业运行。 driver-memory 参数说明:该参数用于设置Driver进程的内存。 参数调优建议:Driver的内存通常来说不设置,或者设置1G左右应该就够了。唯一需要注意的一点是,如果需要使用collect算子将RDD的数据全部拉取到Driver上进行处理,那么必须确保Driver的内存足够大,否则会出现OOM内存溢出的问题。 spark.default.parallelism 参数说明:该参数用于设置每个stage的默认task数量。这个参数极为重要,如果不设置可能会直接影响你的Spark作业性能。 参数调优建议:Spark作业的默认task数量为500~1000个较为合适。很多同学常犯的一个错误就是不去设置这个参数,那么此时就会导致Spark自己根据底层HDFS的block数量来设置task的数量,默认是一个HDFS block对应一个task。通常来说,Spark默认设置的数量是偏少的(比如就几十个task),如果task数量偏少的话,就会导致你前面设置好的Executor的参数都前功尽弃。试想一下,无论你的Executor进程有多少个,内存和CPU有多大,但是task只有1个或者10个,那么90%的Executor进程可能根本就没有task执行,也就是白白浪费了资源!因此Spark官网建议的设置原则是,设置该参数为num-executors * executor-cores的2~3倍较为合适,比如Executor的总CPU core数量为300个,那么设置1000个task是可以的,此时可以充分地利用Spark集群的资源。 spark.memory.memoryFraction 注意: 一下两个参数是在spark1.6 静态内存管理的时候有效,现在spark2 以上的版本使用的是同意内存管理,参数已经失效,spark 会自己动态关系,storage execution 内存。\n参数说明:该参数用于设置RDD持久化数据在Executor内存中能占的比例,默认是0.6。也就是说,默认Executor 60%的内存,可以用来保存持久化的RDD数据。根据你选择的不同的持久化策略,如果内存不够时,可能数据就不会持久化,或者数据会写入磁盘。 参数调优建议:如果Spark作业中,有较多的RDD持久化操作,该参数的值可以适当提高一些,保证持久化的数据能够容纳在内存中。避免内存不够缓存所有的数据,导致数据只能写入磁盘中,降低了性能。但是如果Spark作业中的shuffle类操作比较多,而持久化操作比较少,那么这个参数的值适当降低一些比较合适。此外,如果发现作业由于频繁的gc导致运行缓慢(通过spark web ui可以观察到作业的gc耗时),意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。 spark.shuffle.memoryFraction [deprecated after spark1.6] 参数说明:该参数用于设置shuffle过程中一个task拉取到上个stage的task的输出后,进行聚合操作时能够使用的Executor内存的比例,默认是0.2。也就是说,Executor默认只有20%的内存用来进行该操作。shuffle操作在进行聚合时,如果发现使用的内存超出了这个20%的限制,那么多余的数据就会溢写到磁盘文件中去,此时就会极大地降低性能。 参数调优建议:如果Spark作业中的RDD持久化操作较少,shuffle操作较多时,建议降低持久化操作的内存占比,提高shuffle操作的内存占比比例,避免shuffle过程中数据过多时内存不够用,必须溢写到磁盘上,降低了性能。此外,如果发现作业由于频繁的gc导致运行缓慢,意味着task执行用户代码的内存不够用,那么同样建议调低这个参数的值。 资源参数的调优,没有一个固定的值,需要同学们根据自己的实际情况(包括Spark作业中的shuffle操作数量、RDD持久化操作数量以及spark web ui中显示的作业gc情况),同时参考本篇文章中给出的原理以及调优建议,合理地设置上述参数。 demo 1 2 3 4 5 6 7 8 9 ./bin/spark-submit \\ --master yarn-cluster \\ --num-executors 100 \\ --executor-memory 6G \\ --executor-cores 4 \\ --driver-memory 1G \\ --conf spark.default.parallelism=1000 \\ --conf spark.storage.memoryFraction=0.5 \\ --conf spark.shuffle.memoryFraction=0.3 \\ 3. 数据倾斜调优 代码写好了,程序跑的资源也经过精心调配之后设置好了,没有其他意外情况的话,你的Spark程序已经能够正常的跑在集群上。\n但是别以为这样就结束了,这仅仅是Spark程序生命周期的开始。\n为了给你的程序保驾护航,你还需要时刻关注 新上线的应用程序的执行情况是否健康、是否如你所愿如你所想。\n应用程序的执行情况你都可以在 Spark或者Yarn的WebUI 上查看到,有非常详细的执行信息。\n我们现在来讨论一个 可能是导致程序执行缓慢甚至异常 的最大罪魁祸首: 数据倾斜。\n什么是数据倾斜呢? 就是 绝大多数数据(比如80%以上甚至更多)都被分配到 绝少数的节点 上执行(比如20%甚至更少)。\n这么一来,意味着剩下绝大多数的节点都没处理或者没怎么处理数据,处于空闲状态。而 少数节点则一直处理非常忙碌的状态,任务处理需要排队,节点完成计算任务耗时非常长,其他完成任务的节点就在旁边看热闹,但是 只有等所有节点都完成了计算任务整个程序才能算完成。\n例如,总共有1000个task,990个task都在10分钟之内执行完了,但是剩余10个task却要三、四个小时,整个程序的执行时间由最长的那个task决定(反过来的木桶效应)。\n同时,因为某些节点上处理的数据量太多,根据不同的业务代码操作,可能还会出现某些节点在Shuffle过程或者数据处理过程出现OOM异常导致程序失败。\n简单来讲,就是 几颗老鼠屎坏了一锅粥。\n导致数据倾斜的原因有很多,但是其本质都是一样的:在Shuffle等需要通过网络读写数据的过程中,因为数据key分布不均匀,导致大部分数据被集中获取到少部分节点上。\n数据倾斜的情况可以在WebUI上的Stages、Executors页面中观察到,这也就是为什么我们要求对于初次上线的应用,需要时刻关注新上线的应用程序的执行情况是否健康、是否如你所愿如你所想。\n在Web界面中,有哪些Stage,Stage中有哪些Task,各个Task处理的数据量和执行计算的时间等等,这些你都可以很清晰的看到。\n如果发现你的应用中有存在有 几个Task处理的数据量明显比其他Task要大很多,而且还在不停的处理数据,而其他Task已经执行完毕,那么你就是遇到了数据倾斜的问题。\n那么如果我们确定了程序中存在数据倾斜的情况,该如何处理呢?\n根据数据倾斜产生的原因,我们可以在 不同的切入点使用不同的处理策略。\n定位代码与数据问题 在Web界面上,我们可以直观的获得 发生数据倾斜的Stage对应的代码行数,但这个行数并不能精准直接定位到发生数据倾斜的代码,因为它显示的是当前Stage开始执行的代码行数。\n由于数据倾斜只有可能在Shuffle过程中发生,所以 导致数据倾斜的一定是会产生Shuffle过程的算子,比如groupByKey、reduceByKey、aggregateByKey、join、distinct、repartition等等。\n所以,你只需要在Stage所在的行数向上查找Shuffle操作符,那么其就是导致数据倾斜的罪魁祸首。\n找到问题代码之后,需要做的事情很明显了吧?优化之。\n此时我们需要统计一下该Shuffle操作符所使用的数据源,观察各个数据源的 key分布情况(如每个key有多少数据量),以及导致数据倾斜的key在哪里、都有哪些。\n根据数据情况与你对业务的理解,使用「开发调优」中算子优化提到的技巧,尽量这个Shuffle操作的影响降到最低。\n处理源头数据 如果该Shuffle操作符无法避免,代码层面上无法做太多优化,那么此时可以考虑 预先处理数据源。\n先根据数据源key的分布情况或者分区分布情况,针对性的做一次repartition操作,重新存储,后续所有用到该数据源的程序都不会有数据倾斜的问题。\n但是重分区预处理过程中仍然会存在数据倾斜问题而导致预处理过程缓慢。\n如果该数据源只有当前程序使用,那么这个重分区预处理的操作就相当于在读取数据源的时候调用了repartition重分区、或者使用类似reduceByKey(500)调整并行度,实际上并没有起到多少作用。\n所以,重分区预处理的方式只有在 一个数据源被n多个程序使用的时候比较有价值,使用的程序越多性价比越高,否则就是治标不治本,效果有限。\n预聚合 如果前面两种方式都无法解决你的问题,而且 产生数据倾斜的Shuffle操作符是聚合类的(group、reduce、distinct)等,那么你可以尝试使用 预聚合 的方式。\n还记得Mapreduce的Combiner吗,还记得reduceByKey和groupByKey的区别吗,不记得的话建议浏览一下「开发调优」中算子优化技巧。\nMapreduce的Combiner和Spark中的reduceByKey都会在各个节点的本地做一次预聚合。\n类似的,如果存在某个key占据了绝大部分的数据量的话,我们也可以 手动采用预聚合的方式来分散热点数据并执行本地预聚合。\n假设我们现在有1000w的数据,其中800w的数据都是相同的key,此时我们要做聚合操作,默认情况下800w数据会到同一个Task中处理,这肯定是无法接受的。\n怎么手动做预聚合呢? 首先我们可以在这800w的key之前 根据任意hash算法添加固定长度的随机前缀。\n在第一轮聚合时,这个热点key将会被打散到各个节点上去计算。\n之后将key上的固定长度前缀去除,执行第二轮聚合操作。\n因为经过第一轮聚合之后 热点key的数据已经被处理很多了,所以在第二轮聚合的时候可以比较轻松的处理。\n当然如果在第二轮聚合的时候仍然有很大的热点问题,那么理论上可以 继续无限做预聚合处理。\n但是预聚合的缺陷也很明显,只能优化聚合类的操作,如果是join等关联类的Shuffle操作则无法优化。\n使用广播变量代替join 那么碰到join类的算子且发生了数据倾斜该如何处理呢?\n其实我们在「开发调优」中已经提到过解决方式了,就是 使用广播变量来代替join操作。\n但是这个方法也有很多限制,就是 只能应用于大表 join 小表的情况。\n多种方案组合使用 如果以上的方案都没有能够解决你的问题,那么你可以尝试着将多种方案整合起来一起使用,因为在复杂的业务中,Shuffle操作符可能有很多,那么对应的可能产生数据倾斜的地方也有很多。\n所以需要开发人员能够根据 业务逻辑、数据状态、代码编写 等方面能够根据不同的情况组合不同的方案来实施优化。\n4. shuffle调优 在上一节中我们着重介绍了如何针对「数据倾斜」这一情况进行优化。\n除了数据倾斜可以优化之外,Shuffle过程中仍然有许多地方可以优化。但是要记住,影响Spark程序性能的主要因素还是在于 代码开发、资源参数与数据倾斜等,对Shuffle的调整优化可能仅仅是 锦上添花 而不是雪中送炭。\n所以开发人员的重点应该放在前面几个部分,都优化完了之后可以考虑对Shuffle过程进行优化。\n对Shuffle的优化主要是通过调整一些Shuffle相关的参数来实现,你可以根据你的使用情况和经验对以下参数进行调整:\nspark.Shuffle.file.buffer 默认值:32k 参数说明:用于设置Shuffle write task的BufferedOutputStream的buffer缓冲大小。将数据写到磁盘文件之前,会先写入buffer缓冲中,待缓冲写满之后,才会溢写到磁盘。 调优建议:如果作业可用的 内存资源较为充足 的话,可以 适当增加这个参数的大小,从而减少Shuffle write过程中溢写磁盘文件的次数,也就可以减少磁盘IO次数,进而提升性能。 spark.reducer.maxSizeInFlight 默认值:48m 参数说明:用于设置Shuffle read task的buffer缓冲大小,而这个buffer缓冲决定了每次能够拉取多少数据。 调优建议:如果作业可用的 内存资源较为充足 的话,可以 适当增加这个参数的大小,从而减少拉取数据的次数,也就可以减少网络传输的次数,进而提升性能。 spark.Shuffle.io.maxRetries 默认值:3 参数说明:Shuffle read task从Shuffle write task所在节点拉取属于自己的数据时,如果因为网络异常导致拉取失败,是会自动进行重试的。该参数就代表了可以重试的最大次数。如果在指定次数之内拉取还是没有成功,就可能会导致作业执行失败。 调优建议:对于那些包含了 特别耗时的Shuffle操作的作业,建议 增加重试最大次数(比如60次),以避免 由于JVM的full gc或者网络不稳定等因素导致的数据拉取失败,主要提升大型任务的执行稳定性。 spark.Shuffle.io.retryWait 默认值:5s 参数说明:具体解释同上,该参数代表了每次重试拉取数据的等待间隔,默认是5s。 调优建议:建议 加大间隔时长(比如60s),以增加Shuffle操作的稳定性。 spark.Shuffle.manager 默认值:sort 参数说明:该参数用于设置ShuffleManager的类型。 调优建议:由「Shuffle过程与管理」中可以知道,SortShuffleManager默认会对数据进行排序,如果程序中需要排序,那么使用默认即可;如果程序中不需要排序,那么建议 增大bypass的阈值以触发bypass机制或者将manager调整为hash,避免排序带来的开销,同时提供较好的磁盘读写性能。 spark.shuffle.sort.bypassMergeThreshold 默认值:200 参数说明:当manager为sort,且Shuffle read task的数量小于这个阈值时,将会使用bypass机制。 调优建议:使用sort manager时,如果不需要排序,那么就适当增加这个值,大于Shuffle read task的数量。 spark.shuffle.consolidateFiles 默认值:false 参数说明:如果使用hash manager,该参数有效。如果设置为true,那么就会开启 consolidate机制,可以极大地减少磁盘IO开销,提升性能。 调优建议:在不需要排序的情况下,除了使用sort manager触发bypass机制外,使用 hash manager + consolidate机制也是一个高性能的选择,建议使用此组合。 更多参考: 美团spark调优 ","permalink":"https://reid00.github.io/en/posts/computation/spark-%E6%9C%80%E4%BD%B3%E5%AE%9E%E8%B7%B5%E6%8C%87%E5%8D%97/","summary":"简介 总体上来说,Spark的流程和MapReduce的思想很类似,只是实现的细节方面会有很多差异。 首先澄清2个容易被混淆的概念: Spark是","title":"Spark 最佳实践指南"},{"content":"基础篇 sparksql 如何加载metadata 任何的SQL引擎都是需要加载元数据的,不然,连执行计划都生成不了。 加载元数据总的来说分为两步:\n加载元数据 创建会话连接Hive MetaStore 首先,Spark检测到我们没有设置spark.sql.warehouse.dir,然后就开始找我们在hite-site.xml中配置的hive.metastore.warehouse.dir。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.metastore.uris\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;thrift://test-3:9083,thrift://test-4:9083\u0026lt;/value\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.metastore.client.socket.timeout\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;300\u0026lt;/value\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.metastore.warehouse.dir\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;/data/hive/warehouse\u0026lt;/value\u0026gt; \u0026lt;/property\u0026gt; \u0026lt;property\u0026gt; \u0026lt;name\u0026gt;hive.warehouse.subdir.inherit.perms\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt;true\u0026lt;/value\u0026gt; 然后,SparkSession在HDFS临时位置创建了下面目录。\n1 2 Moved: \u0026#39;hdfs://nn1/data/hive/warehouse/pyspark_test.db/tb_name/part-00000-c46bc573-0d1d-4ac4-8a69-2359dff82485-c000\u0026#39; to trash at: hdfs://nn1/user/hive/.Trash/Current Moved: \u0026#39;hdfs://nn1/data/hive/warehouse/pyspark_test.db/tb_name/part-00001-c46bc573-0d1d-4ac4-8a69-2359dff82485-c000\u0026#39; to trash at: hdfs://nn1/user/hive/.Trash/Current 最后,Spark开始通过thrift RPC去连接Hive的MetaStore Server。\n进阶篇 Spark为什么这么快 Spark是一个基于内存的,用于大规模数据处理的统一分析引擎,其运算速度可以达到Mapreduce的10-100倍。具有如下特点:\n内存计算。Spark优先将数据加载到内存中,数据可以被快速处理,并可启用缓存。 shuffle过程优化。和Mapreduce的shuffle过程中间文件频繁落盘不同,Spark对Shuffle机制进行了优化,降低中间文件的数量并保证内存优先。 RDD计算模型。Spark具有高效的DAG调度算法,同时将RDD计算结果存储在内存中,避免重复计算。 如何理解DAGScheduler的Stage划分算法 官网的RDD执行流程图: 1 rdd1.join(rdd2).groupBy().filter() 针对一段应用代码(如上),Driver会以Action算子为边界生成DAG调度图。DAGScheduler从DAG末端开始遍历划分Stage,封装成一系列的tasksets移交TaskScheduler,后者根据调度算法, 将taskset分发到相应worker上的Executor中执行。\nDAGSchduler的工作原理 DAGScheduler是一个面向stage调度机制的高级调度器,为每个job计算stage的DAG(有向无环图),划分stage并提交taskset给TaskScheduler。 追踪每个RDD和stage的物化情况,处理因shuffle过程丢失的RDD,重新计算和提交。 查找rdd partition 是否cache/checkpoint。提供优先位置给TaskScheduler,等待后续TaskScheduler的最佳位置划分 Stage划分算法 从触发action操作的算子开始,从后往前遍历DAG。 为最后一个rdd创建finalStage。 遍历过程中如果发现该rdd是宽依赖,则为其生成一个新的stage,与旧stage分隔而开,此时该rdd是新stage的最后一个rdd。 如果该rdd是窄依赖,将该rdd划分为旧stage内,继续遍历,以此类推,继续遍历直至DAG完成。 如何理解TaskScheduler的Task分配算法 TaskScheduler负责Spark中的task任务调度工作。TaskScheduler内部使用TasksetPool调度池机制存放task任务。TasksetPool分为FIFO(先进先出调度)和FAIR(公平调度)。 FIFO调度: 基于队列思想,使用先进先出原则顺序调度taskset FAIR调度: 根据权重值调度,一般选取资源占用率作为标准,可人为设定 TaskScheduler的工作原理 负责Application在Cluster Manager上的注册 根据不同策略创建TasksetPool资源调度池,初始化pool大小 根据task分配算法发送Task到Executor上执行 Task分配算法 首先获取所有的executors,包含executors的ip和port等信息 将所有的executors根据shuffle算法进行打散 遍历executors。在程序中依次尝试本地化级别,最终选择每个task的最优位置(结合DAGScheduler优化位置策略) 序列化task分配结果,并发送RPC消息等待Executor响应 Spark的本地化级别有哪几种?怎么调优 移动计算 or 移动数据?这是一个问题。在分布式计算的核心思想中,移动计算永远比移动数据要合算得多,如何合理利用本地化数据计算是值得思考的一个问题。\nTaskScheduler在进行task任务分配时,需要根据本地化级别计算最优位置,一般是遵循就近原则,选择最近位置和缓存。Spark中的本地化级别在TaskManager中定义,分为五个级别。\nSpark本地化级别 PROCESS_LOCAL(进程本地化) partition和task在同一个executor中,task分配到本地Executor进程。 NODE_LOCAL(节点本地化) partition和task在同一个节点的不同Executor进程中,可能发生跨进程数据传输 NO_PREF(无位置) 没有最佳位置的要求,比如Spark读取JDBC的数据\nRACK_LOCAL(机架本地化) partition和task在同一个机架的不同worker节点上,可能需要跨机器数据传输 ANY(跨机架): 数据在不同机架上,速度最慢\nSpark本地化调优 在task最佳位置的选择上,DAGScheduler先判断RDD是否有cache/checkpoint,即缓存优先;否则TaskScheduler进行本地级别选择等待发送task。 TaskScheduler首先会根据最高本地化级别发送task,如果在尝试5次并等待3s内还是无法执行,则认为当前资源不足,即降低本地化级别,按照PROCESS-\u0026gt;NODE-\u0026gt;RACK等顺序。\n调优1:加大spark.locality.wait 全局等待时长 调优2:加大spark.locality.wait.xx等待时长(进程、节点、机架) 调优3:加大重试次数(根据实际情况微调) 说说Spark和Mapreduce中Shuffle的区别 Spark中的shuffle很多过程与MapReduce的shuffle类似,都有Map输出端、Reduce端,shuffle过程通过将Map端计算结果分区、排序并发送到Reducer端。\n1. Hadoop Mapreduce Shuffle MapReduce的shuffle需要依赖大量磁盘操作,数据会频繁落盘产生大量IO,同时产生大量小文件冗余。虽然缓存buffer区中启用了缓存机制,但是阈值较低且内存空间小。\n读取输入数据,并根据split大小切分为map任务 map任务在分布式节点中执行map()计算 每个map task维护一个环形的buffer缓存区,存储map输出结果,分区且排序 当buffer区域达到阈值时,开始溢写到临时文件中。map task任务结束时进行临时文件合并。此时,整合shuffle map端执行完成 mapreduce根据partition数启动reduce任务,copy拉取数据 merge合并拉取的文件 reduce()函数聚合计算,整个过程完成 2. Spark的Shuffle机制 默认的shuffle计算引擎是HashShuffleManager,此种Shuffle产生大量的中间磁盘文件,消耗磁盘IO性能。在Spark1.2后续版本中,默认的ShuffleManager改成了SortShuffleManager,通过索引机制和合并临时文件的优化操作,大幅提高shuffle性能。 HashShuffleManager HashShuffleManager的运行机制主要分成两种,一种是普通运行机制,另一种是合并的运行机制。合并机制主要是通过复用buffer来优化Shuffle过程中产生的小文件的数量,Hash shuffle本身不排序。开启合并机制后,同一个Executor共用一组core,文件个数为cores * reduces。 SortShuffleManager SortShuffleManager的运行机制分成两种,普通运行机制和bypass运行机制。当shuffletask的数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值时(默认200),会启用bypass机制。\n普通运行机制 在该模式下,数据会先写入一个内存数据结构中,此时根据不同的 shuffle 算子,可能选用不同的数据结构。如果是 reduceByKey 这种聚合类的 shuffle 算子,那么会选用 Map 数据结构,一边通过 Map 进行聚合,一边写入内存;如果是 join 这种普通的 shuffle 算子,那么会选用 Array 数据结构,直接写入内存。接着,每写一条数据进入内存数据结构之后,就会判断一下,是否达到了某个临界阈值。如果达到临界阈值的话,那么就会尝试将内存数据结构中的数据溢写到磁盘,然后清空内存数据结构。\n在溢写到磁盘文件之前,会先根据 key 对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。默认的 batch 数量是 10000 条,也就是说,排序好的数据,会以每批 1 万条数据的形式分批写入磁盘文件。写入磁盘文件是通过 Java 的 BufferedOutputStream 实现的。BufferedOutputStream 是 Java 的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这样可以减少磁盘 IO 次数,提升性能。\n一个 task 将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写操作,也就会产生多个临时文件。最后会将之前所有的临时磁盘文件都进行合并,这就是merge 过程,此时会将之前所有临时磁盘文件中的数据读取出来,然后依次写入最终的磁盘文件之中。此外,由于一个 task 就只对应一个磁盘文件,也就意味着该 task 为下游 stage 的 task 准备的数据都在这一个文件中,因此还会单独写一份索引文件,其中标识了下游各个 task 的数据在文件中的 start offset 与 end offset。\nSortShuffleManager 由于有一个磁盘文件 merge 的过程,因此大大减少了文件数量。比如第一个 stage 有 50 个 task,总共有 10 个 Executor,每个 Executor 执行 5 个 task,而第二个 stage 有 100 个 task。由于每个 task 最终只有一个磁盘文件,因此此时每个 Executor 上只有 5 个磁盘文件,所有 Executor 只有 50 个磁盘文件。 普通运行机制的 SortShuffleManager 工作原理如下图所示: bypass运行机制 Reducer 端任务数比较少的情况下,基于 Hash Shuffle 实现机制明显比基于 Sort Shuffle 实现机制要快,因此基于 Sort Shuffle 实现机制提供了一个带 Hash 风格的回退方案,就是 bypass 运行机制。对于 Reducer 端任务数少于配置属性spark.shuffle.sort.bypassMergeThreshold设置的个数时,使用带 Hash 风格的回退计划。\npass 运行机制的触发条件如下: shuffle map task 数量小于spark.shuffle.sort.bypassMergeThreshold=200参数的值。不是聚合类的 shuffle 算子。\n此时,每个 task 会为每个下游 task 都创建一个临时磁盘文件,并将数据按 key 进行 hash 然后根据 key 的 hash 值,将 key 写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件。\n该过程的磁盘写机制其实跟未经优化的 HashShuffleManager 是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的 HashShuffleManager 来说,shuffle read 的性能会更好。\n而该机制与普通 SortShuffleManager 运行机制的不同在于:\n第一,磁盘写机制不同; 第二,不会进行排序。 也就是说,启用该机制的最大好处在于,shuffle write 过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销。 Tungsten Sort Shuffle 运行机制 Tungsten Sort 是对普通 Sort 的一种优化,Tungsten Sort 会进行排序,但排序的不是内容本身,而是内容序列化后字节数组的指针(元数据),把数据的排序转变为了指针数组的排序,实现了直接对序列化后的二进制数据进行排序。由于直接基于二进制数据进行操作,所以在这里面没有序列化和反序列化的过程。内存的消耗大大降低,相应的,会极大的减少的 GC 的开销。\nSpark 提供了配置属性,用于选择具体的 Shuffle 实现机制,但需要说明的是,虽然默认情况下 Spark 默认开启的是基于 SortShuffle 实现机制,但实际上,参考 Shuffle 的框架内核部分可知基于 SortShuffle 的实现机制与基于 Tungsten Sort Shuffle 实现机制都是使用\nSortShuffleManager,而内部使用的具体的实现机制,是通过提供的两个方法进行判断的: 对应非基于 Tungsten Sort 时,通过 SortShuffleWriter.shouldBypassMergeSort 方法判断是否需要回退到 Hash 风格的 Shuffle 实现机制,当该方法返回的条件不满足时,则通过 SortShuffleManager.canUseSerializedShuffle方法判断是否需要采用基于 Tungsten Sort Shuffle 实现机制,而当这两个方法返回都为 false,即都不满足对应的条件时,会自动采用普通运行机制。\n因此,当设置了spark.shuffle.manager=tungsten-sort时,也不能保证就一定采用基于 Tungsten Sort 的 Shuffle 实现机制。\n要实现 Tungsten Sort Shuffle 机制需要满足以下条件:\nShuffle 依赖中不带聚合操作或没有对输出进行排序的要求。 Shuffle 的序列化器支持序列化值的重定位(当前仅支持 KryoSerializer Spark SQL 框架自定义的序列化器)。 Shuffle 过程中的输出分区个数少于 16777216 个。 实际上,使用过程中还有其他一些限制,如引入 Page 形式的内存管理模型后,内部单条记录的长度不能超过 128 MB (具体内存模型可以参考 PackedRecordPointer 类)。另外,分区个数的限制也是该内存模型导致的。\n所以,目前使用基于 Tungsten Sort Shuffle 实现机制条件还是比较苛刻的。\n3. Spark Shuffle 历史: 为什么 Spark 最终还是放弃了 HashShuffle ,使用了 Sorted-Based Shuffle? 我们可以从 Spark 最根本要优化和迫切要解决的问题中找到答案,使用 HashShuffle 的 Spark 在 Shuffle 时产生大量的文件。当数据量越来越多时,产生的文件量是不可控的,这严重制约了 Spark 的性能及扩展能力,所以 Spark 必须要解决这个问题,减少 Mapper 端 ShuffleWriter 产生的文件数量,这样便可以让 Spark 从几百台集群的规模瞬间变成可以支持几千台,甚至几万台集群的规模。\n但使用 Sorted-Based Shuffle 就完美了吗,答案是否定的,Sorted-Based Shuffle 也有缺点,其缺点反而是它排序的特性,它强制要求数据在 Mapper 端必须先进行排序,所以导致它排序的速度有点慢。好在出现了 Tungsten-Sort Shuffle ,它对排序算法进行了改进,优化了排序的速度。Tungsten-SortShuffle 已经并入了 Sorted-Based Shuffle,Spark 的引擎会自动识别程序需要的是 Sorted-BasedShuffle,还是 Tungsten-Sort Shuffle。\nSpark SQL和Hive SQL的区别 Hive SQL是Hive提供的SQL查询引擎,底层由MapReduce实现。Hive根据输入的SQL语句执行词法分析、语法树构建、编译、逻辑计划、优化逻辑计划以及物理计划等过程,转化为Map Task和Reduce Task最终交由Mapreduce引擎执行。\n执行引擎。具有mapreduce的一切特性,适合大批量数据离线处理,相较于Spark而言,速度较慢且IO操作频繁 有完整的hql语法,支持基本sql语法、函数和udf 对表数据存储格式有要求,不同存储、压缩格式性能不同 Checkpoint 检查点机制 应用场景:当spark应用程序特别复杂,从初始的RDD开始到最后整个应用程序完成有很多的步骤,而且整个应用运行时间特别长,这种情况下就比较适合使用checkpoint功能。\n原因:对于特别复杂的Spark应用,会出现某个反复使用的RDD,即使之前持久化过但由于节点的故障导致数据丢失了,没有容错机制,所以需要重新计算一次数据。\nCheckpoint首先会调用SparkContext的setCheckPointDIR()方法,设置一个容错的文件系统的目录,比如说HDFS;然后对RDD调用checkpoint()方法。之后在RDD所处的job运行结束之后,会启动一个单独的job,来将checkpoint过的RDD数据写入之前设置的文件系统,进行高可用、容错的类持久化操作。\n检查点机制是我们在spark streaming中用来保障容错性的主要机制,它可以使spark streaming阶段性的把应用数据存储到诸如HDFS等可靠存储系统中,以供恢复时使用。具体来说基于以下两个目的服务:\n控制发生失败时需要重算的状态数。Spark streaming可以通过转化图的谱系图来重算状态,检查点机制则可以控制需要在转化图中回溯多远。 提供驱动器程序容错。如果流计算应用中的驱动器程序崩溃了,你可以重启驱动器程序并让驱动器程序从检查点恢复,这样spark streaming就可以读取之前运行的程序处理数据的进度,并从那里继续\ncheckpoint和持久化机制的区别 最主要的区别在于持久化只是将数据保存在BlockManager中,但是RDD的lineage(血缘关系,依赖关系)是不变的。但是checkpoint执行完之后,rdd已经没有之前所谓的依赖rdd了,而只有一个强行为其设置的checkpointRDD,checkpoint之后rdd的lineage就改变了。 持久化的数据丢失的可能性更大,因为节点的故障会导致磁盘、内存的数据丢失。但是checkpoint的数据通常是保存在高可用的文件系统中,比如HDFS中,所以数据丢失可能性比较低。\nSpark shuffle 参数优化 spark.shuffle.file.buffer:主要是设置的Shuffle过程中写文件的缓冲,默认32k,如果内存足够,可以适当调大,来减少写入磁盘的数量。 spark.reducer.maxSizeInFight:主要是设置Shuffle过程中读文件的缓冲区,一次能够读取多少数据,默认48m, 如果内存足够,可以适当扩大,减少整个网络传输次数。 spark.shuffle.io.maxRetries:主要是设置网络连接失败时,重试次数,默认3次, 适当调大能够增加稳定性。 spark.shuffle.io.retryWait:主要设置每次重试之间的间隔时间,可以适当调大,默认5s, 增加程序稳定性。 spark.shuffle.memoryFraction:该参数代表了Executor内存中,分配给shuffle read task进行聚合操作的内存比例,默认是20%。Shuffle过程中的内存占用,如果程序中较多使用了Shuffle操作,那么可以适当调大该区域。 [deprecated], 旧版本的静态内存管理策略生效,新版本统一内存管理此参数无效。用的是 spark.storage.memoryFraction 中的内存 spark.shuffle.manager:Hash和Sort方式,Sort是默认,Hash在reduce数量 比较少的时候,效率会很高。 spark.shuffle.sort.bypassMergeThreshold:设置的是Sort方式中,默认200,启用Hash输出方式的临界值,如果你的程序数据不需要排序,而且reduce数量比较少,那推荐可以适当增大临界值。 spark.shuffle.cosolidateFiles:如果你使用Hash shuffle方式,推荐打开该配置,实现更少的文件输出。如果设置为true,那么就会开启consolidate机制,会大幅度合并shuffle write的输出文件,对于shuffle read task数量特别多的情况下,这种方法可以极大地减少磁盘IO开销,提升性能。调优建议:如果的确不需要SortShuffleManager的排序机制,那么除了使用bypass机制,还可以尝试将spark.shuffle.manager参数手动指定为hash,使用HashShuffleManager,同时开启consolidate机制。在实践中尝试过,发现其性能比开启了bypass机制的SortShuffleManager要高出10%~30%。 spark.sql.adaptive.shuffle.targetPostShuffleInputSize: default 67108864(64M) 动态调整reduce个数的partition大小依据,动态合并reducer的partition。map端多个partition 合并后数据阈值,小于阈值会合并。如设置64MB则reduce阶段每个task最少处理64MB的数据 Spark Locality 参数 参数想 默认值 参数解释 spark.locality.wait 3000(毫秒) 数据本地性降级的等待时间 spark.locality.wait.process spark.locality.wait 多长时间等不到PROCESS_LOCAL就降 spark.locality.wait.node spark.locality.wait 多长时间等不到NODE_LOCAL就降 spark.locality.wait.rack spark.locality.wait 多长时间等不到RACK_LOCAL就降级 1 2 3 4 new SparkConf().set(\u0026#34;spark.locality.wait\u0026#34;, \u0026#34;10\u0026#34;) spark.locality.wait.process//建议60s spark.locality.wait.node//建议30s spark.locality.wait.rack//建议20s ","permalink":"https://reid00.github.io/en/posts/computation/spark-%E9%9D%A2%E8%AF%95%E6%B3%A8%E6%84%8F%E7%82%B9/","summary":"基础篇 sparksql 如何加载metadata 任何的SQL引擎都是需要加载元数据的,不然,连执行计划都生成不了。 加载元数据总的来说分为两步: 加载元数据 创建","title":"Spark 面试注意点"},{"content":"简介 当一个Spark应用提交到集群上运行时,应用架构包含了两个部分:\nDriver Program(资源申请和调度Job执行) Executors(运行Job中Task任务和缓存数据),两个都是JVM Process进程 Driver程序运行的位置可以通过–deploy-mode 来指定:\nDriver指的是The process running the main() function of the application and creating the SparkContext 运行应用程序的main()函数并创建SparkContext的进程\nclient: 表示Driver运行在提交应用的Client上(默认) cluster: 表示Driver运行在集群中(Standalone:Worker,YARN:NodeManager) cluster和client模式最最本质的区别是:Driver程序运行在哪里。 企业实际生产环境中使用cluster 为主要模式。 1. Client(客户端)模式 DeployMode为Client,表示应用Driver Program运行在提交应用Client主机上。 示意图: 1 2 3 4 5 6 7 8 9 10 11 SPARK_HOME=/export/server/spark ${SPARK_HOME}/bin/spark-submit \\ --master yarn \\ --deploy-mode client \\ --driver-memory 512m \\ --executor-memory 512m \\ --num-executors 1 \\ --total-executor-cores 2 \\ --class org.apache.spark.examples.SparkPi \\ ${SPARK_HOME}/examples/jars/spark-examples_2.11-2.4.5.jar \\ 10 2.Cluster(集群)模式,生产环境用 DeployMode为Cluster,表示应用Driver Program运行在集群从节点某台机器上. 1 2 3 4 5 6 7 8 9 10 11 SPARK_HOME=/export/server/spark ${SPARK_HOME}/bin/spark-submit \\ --master yarn \\ --deploy-mode cluster \\ --driver-memory 512m \\ --executor-memory 512m \\ --num-executors 1 \\ --total-executor-cores 2 \\ --class org.apache.spark.examples.SparkPi \\ ${SPARK_HOME}/examples/jars/spark-examples_2.11-2.4.5.jar \\ 10 总结: Client模式和Cluster模式最最本质的区别是:Driver程序运行在哪里。\nClient模式:测试时使用,开发不用,了解即可 Driver运行在Client上,和集群的通信成本高 Driver输出结果会在客户端显示 Cluster模式:生产环境中使用该模式 Driver程序在YARN集群中,和集群的通信成本低 Driver输出结果不能在客户端显示 该模式下Driver运行ApplicattionMaster这个节点上,由Yarn管理,如果出现问题,yarn会重启ApplicattionMaster(Driver) 3. 两种模式的详细流程图 Client模式图示: 在YARN Client模式下,Driver在任务提交的本地机器上运行。 Driver在任务提交的本地机器上运行,Driver启动后会和ResourceManager通讯申请启动ApplicationMaster 1 2 3 --master yarn \\ --deploy-mode client \\ --driver-memory 512m \\ 随后ResourceManager分配Container,在合适的NodeManager上启动ApplicationMaster,此时的ApplicationMaster的功能相当于一个ExecutorLaucher,只负责向ResourceManager申请Executor内存 1 2 3 --executor-memory 512m \\ --executor-cores 2 \\ --num-executors 1 \\ ResourceManager接到ApplicationMaster的资源申请后会分配Container,然后ApplicationMaster在资源分配指定的NodeManager上启动Executor进程; Executor进程启动后会向Driver反向注册,Executor全部注册完成后Driver开始执行main函数; 之后执行到Action算子时,触发一个Job,并根据宽依赖开始划分Stage,每个Stage生成对应的TaskSet,之后将Task分发到各个Executor上执行。 Cluster 模式示意图 在YARN Cluster模式下,Driver运行在NodeManager Contanier中,此时Driver与AppMaster合为一体。 Driver在任务提交的本地机器上运行,Driver启动后会和ResourceManager通讯申请启动ApplicationMaster 1 2 3 --master yarn \\ --deploy-mode cluster \\ --driver-memory 512m \\ 随后ResourceManager分配Container,在合适的NodeManager上启动ApplicationMaster,此时的ApplicationMaster的功能相当于一个ExecutorLaucher,只负责向ResourceManager申请Executor内存 1 2 3 --executor-memory 512m \\ --executor-cores 2 \\ --num-executors 1 \\ ResourceManager接到ApplicationMaster的资源申请后会分配Container,然后ApplicationMaster在资源分配指定的NodeManager上启动Executor进程; Executor进程启动后会向Driver反向注册,Executor全部注册完成后Driver开始执行main函数; 之后执行到Action算子时,触发一个Job,并根据宽依赖开始划分Stage,每个Stage生成对应的TaskSet,之后将Task分发到各个Executor上执行。 4. 运行中涉及到的名词 Application: Appliction都是指用户编写的Spark应用程序,其中包括一个Driver功能的代码和分布在集群中多个节点上运行的Executor代码 Driver: Spark中的Driver即运行上述Application的main函数并创建SparkContext,创建- SparkContext的目的是为了准备Spark应用程序的运行环境,当Executor部分运行完毕后,Driver同时负责将SparkContext关闭,通常用SparkContext代表Driver AppMaster: 控制yarn app运行和任务资源 Executor: 某个Application运行在worker节点上的一个进程, 该进程负责运行某些Task, 并且负责将数据存到内存或磁盘上,每个Application都有各自独立的一批Executor Worker: 集群中任何可以运行Application代码的节点,在Standalone模式中指的是通过slave文件配置的Worker节点,在Spark on Yarn模式下就是NodeManager节点 Task: 被送到某个Executor上的工作单元,但hadoopMR中的MapTask和ReduceTask概念一样,是运行Application的基本单位,多个Task组成一个Stage,而Task的调度和管理等是由TaskScheduler负责 Job: 包含多个Task组成的并行计算,往往由Spark Action触发生成, 一个Application中往往会产生多个Job Stage: 每个Job会被拆分成多组Task, 作为一个TaskSet, 其名称为Stage,Stage的划分和调度是有DAGScheduler来负责的,Stage有非最终的Stage(Shuffle Map Stage)和最终的Stage(Result Stage)两种,Stage的边界就是发生shuffle的地方 DAGScheduler: 根据Job构建基于Stage的DAG(Directed Acyclic Graph有向无环图),并提交Stage给TASkScheduler。 其划分Stage的依据是RDD之间的依赖的关系找出开销最小的调度方法 TASKSedulter: 将TaskSet提交给worker运行,每个Executor运行什么Task就是在此处分配的. TaskScheduler维护所有TaskSet,当Executor向Driver发生心跳时,TaskScheduler会根据资源剩余情况分配相应的Task。另外TaskScheduler还维护着所有Task的运行标签,重试失败的Task Spark集群中的角色 Driver: 是一个JVM Process 进程,编写的Spark应用程序就运行在Driver上,由Driver进程执行; Master(ResourceManager): 是一个JVM Process 进程,主要负责资源的调度和分配,并进行集群的监控等职责; Worker(NodeManager): 是一个JVM Process 进程,一个Worker运行在集群中的一台服务器上,主要负责两个职责,一个是用自己的内存存储RDD的某个或某些partition;另一个是启动其他进程和线程(Executor),对RDD上的partition进行并行的处理和计算。 Executor: 是一个JVM Process 进程,一个Worker(NodeManager)上可以运行多个Executor,Executor通过启动多个线程(task)来执行对RDD的partition进行并行计算,也就是执行我们对RDD定义的例如map、flatMap、reduce等算子操作。\n","permalink":"https://reid00.github.io/en/posts/computation/spark-on-yarn-%E6%89%A7%E8%A1%8C%E6%B5%81%E7%A8%8B%E8%A7%A3%E6%9E%90/","summary":"简介 当一个Spark应用提交到集群上运行时,应用架构包含了两个部分: Driver Program(资源申请和调度Job执行) Executors(运行Jo","title":"Spark on Yarn 执行流程解析"},{"content":"概述 在spark程序中,当一个传递给Spark操作(例如map和reduce)的函数在远程节点上面运行时,Spark操作实际上操作的是这个函数所用变量的一个独立副本。这些变量会被复制到每台机器上,并且这些变量在远程机器上的所有更新都不会传递回驱动程序。通常跨任务的读写变量是低效的,但是,Spark还是为两种常见的使用模式提供了两种有限的共享变量:广播变(broadcast variable)和累加器(accumulator)\n为什么需要广播变量 如果我们要在分布式计算里面分发大对象,例如:字典,集合,黑白名单等,这个都会由Driver端进行分发,一般来讲,如果这个变量不是广播变量,那么每个task就会分发一份,这在task数目十分多的情况下Driver的带宽会成为系统的瓶颈,而且会大量消耗task服务器上的资源,如果将这个变量声明为广播变量,那么知识每个executor拥有一份,这个executor启动的task会共享这个变量,节省了通信的成本和服务器的资源。\n图解广播变量 不使用广播变量 使用广播变量 可知: 如果使用广播变量,一个executor 只有一个driver 变量的副本,节省资源,而不是用的话,同一个executor 的不同task 都会有这个变量的副本,网络IO就会成为瓶颈。\n如何定义广播变量 1 2 3 4 5 6 7 8 val data = List(1, 2, 3, 4, 5, 6) val bdata = sc.broadcast(data) val rdd = sc.parallelize(1 to 6, 2) val observedSizes = rdd.map(_ =\u0026gt; bdata.value.size) 取 value val c = broadcast.value 注意点 变量一旦被定义为一个广播变量,那么这个变量只能读,不能修改\n1、能不能将一个RDD使用广播变量广播出去?\n不能,因为RDD是不存储数据的。可以将RDD的结果广播出去。 2、 广播变量只能在Driver端定义,不能在Executor端定义。\n3、 在Driver端可以修改广播变量的值,在Executor端无法修改广播变量的值。\n4、如果executor端用到了Driver的变量,如果不使用广播变量在Executor有多少task就有多少Driver端的变量副本。\n5、如果Executor端用到了Driver的变量,如果使用广播变量在每个Executor中只有一份Driver端的变量副本。\n为什么需要累加器 在spark应用程序中,我们经常会有这样的需求,如异常监控,调试,记录符合某特性的数据的数目,这种需求都需要用到计数器,如果一个变量不被声明为一个累加器,那么它将在被改变时不会再driver端进行全局汇总,即在分布式运行时每个task运行的只是原始变量的一个副本,并不能改变原始变量的值,但是当这个变量被声明为累加器后,该变量就会有分布式计数的功能。\n图解累加器 不使用累加器 使用累加器 如何定义一个累加器? 1 2 3 4 val a = sc.accumulator(0) 取值 val b = a.value 注意点 1、 累加器在Driver端定义赋初始值,累加器只能在Driver端读取最后的值,在Excutor端更新。\n2、累加器不是一个调优的操作,因为如果不这样做,结果是错的\n哪些变量在Drive 端,哪些在Executor 端 driver \u0026amp; executor driver是运行用户编写Application 的main()函数的地方,具体负责DAG的构建、任务的划分、task的生成与调度等。job,stage,task生成都离不开rdd自身,rdd的相关的操作不能缺少driver端的sparksession/sparkcontext。\nexecutor是真正执行task地方,而task执行离不开具体的数据,这些task运行的结果可以是shuffle中间结果,也可以持久化到外部存储系统。一般都是将结果、状态等汇集到driver。但是,目前executor之间不能互相通信,只能借助第三方来实现数据的共享或者通信。\n那么,编写的Spark程序代码,运行在driver端还是executor端呢? 通常我们在本地测试程序的时候,要打印RDD中的数据。\n在本地模式下,直接使用rdd.foreach(println)或rdd.map(println)在单台机器上,能够按照预期打印并输出所有RDD的元素。\n但是,在集群模式下,由executor执行输出写入的是executor的stdout,而不是driver上的stdout,所以driver的stdout不会显示这些!\n要想在driver端打印所有元素,可以使用collect()方法先将RDD数据带到driver节点,然后在调用foreach(println)(但需要注意一点,由于会把RDD中所有元素都加载到driver端,可能引起driver端内存不足导致OOM。如果你只是想获取RDD中的部分元素,可以考虑使用take或者top方法)\n总之,在这里RDD中的元素即为具体的数据,对这些数据的操作都是由负责task执行的executor处理的,所以想在driver端输出这些数据就必须先将数据加载到driver端进行处理。\n最后做个总结:所有对RDD具体数据的操作都是在executor上执行的,所有对rdd自身的操作都是在driver上执行的。比如foreach、foreachPartition都是针对rdd内部数据进行处理的,所以我们传递给这些算子的函数都是执行于executor端的。但是像foreachRDD、transform则是对RDD本身进行一列操作,所以它的参数函数是执行在driver端的,那么它内部是可以使用外部变量,比如在Spark Streaming程序中操作offset、动态更新广播变量等。\n","permalink":"https://reid00.github.io/en/posts/computation/spark-%E5%B9%BF%E6%92%AD%E5%8F%98%E9%87%8F/","summary":"概述 在spark程序中,当一个传递给Spark操作(例如map和reduce)的函数在远程节点上面运行时,Spark操作实际上操作的是这个函","title":"Spark 广播变量"},{"content":"介绍 Slidev 使用一种扩展的 Markdown 格式,在一个纯文本文件中存储和组织你的幻灯片。这让你专注于制作内容。而且由于内容和样式是分开的,这也使得在不同的主题之间切换变得更加容易。\n官网 GitHub\n如何使用 Node.js 的安装 参考Node 安装合适的版本\nSlidev 安装简介 本地创建 快速开始最好的方式就是使用官方的初始模板。\n使用 NPM: 可以本地创建一个slidev 的文件夹,然后在此文件夹下目录的命令行中输入下面的命令: 1 npm install slidev 安装完成之后会生成一个 slidev 的文件夹,里面有一个demo 的md 文件。\n使用 Yarn: 1 yarn create slid 命令行界面 创建之后,按 ctrl + c 结束demo, 如果想要再次打开,可以使用 npx slidev\n全局安装 你可以使用如下命令在全局安装 Slidev:\n1 npm i -g @slidev/cli 然后即可在任何地方使用 slidev,而无需每次都创建一个项目。\n1 slidev xx.md 查看相关命令 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 $ slidev --help slidev [args] 命令: slidev [entry] Start a local server for Slidev [默认值] slidev build [entry] Build hostable SPA slidev format [entry] Format the markdown file slidev theme [subcommand] Theme related operations slidev export [entry] Export slides to PDF slidev export-notes [entry] Export slide notes to PDF 位置: entry path to the slides markdown entry [字符串] [默认值: \u0026#34;slides.md\u0026#34;] 选项: -t, --theme override theme [字符串] -p, --port port [数字] -o, --open open in browser [布尔] [默认值: false] --remote listen public host and enable remote control [字符串] --tunnel open localtunnel to make Slidev available on the internet [布尔] [默认值: false] --log log level [字符串] [可选值: \u0026#34;error\u0026#34;, \u0026#34;warn\u0026#34;, \u0026#34;info\u0026#34;, \u0026#34;silent\u0026#34;] [默认值: \u0026#34;warn\u0026#34;] --inspect enable the inspect plugin for debugging [布尔] [默认值: false] -f, --force force the optimizer to ignore the cache and re-bundle [布尔] [默认值: false] -h, --help 显示帮助信息 [布尔] -v, --version 显示版本号 [布尔] Demo 输入slidev xx.md 如果第一次创建会提示不存在,会你是否创建,输入Y\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 zhangbl@DESKTOP-8NHU8UF MINGW64 /c/slidev $ slidev kg.md √ Entry file \u0026#34;kg.md\u0026#34; does not exist, do you want to create it? ... yes ●■▲ Slidev v0.40.3 (global) theme @slidev/theme-seriph entry C:\\slidev\\kg.md public slide show \u0026gt; http://localhost:3030/ presenter mode \u0026gt; http://localhost:3030/presenter/ remote control \u0026gt; pass --remote to enable shortcuts \u0026gt; restart | open | edit 在下面 shortcuts 出告诉你,你可以输入restart,open,edit 两个单词的首字母r,o,e 分别对应重新加载这个markdown, 在浏览器中打开这个markdown(以PPT 播放的模式), 或者在编辑器中编辑这个markdown。\n更改主题 打开markdown 文件,在头部改为: 1 2 # try also \u0026#39;default\u0026#39; to start simple theme: default 就会重新加载,如果不存在就会询问是否下载。 更多主题访问这里\n简单语法\n用 --- 来分割,表示下一页 如果需要远程访, 可以用slidev xx.md --remote 在remote control 中可以看到访问的url 1 2 3 4 5 6 7 8 9 10 11 12 13 14 zhangbl@DESKTOP-8NHU8UF MINGW64 /c/slidev $ slidev kg.md --remote ●■▲ Slidev v0.40.3 (global) theme @slidev/theme-default entry C:\\slidev\\kg.md public slide show \u0026gt; http://localhost:3030/ presenter mode \u0026gt; http://localhost:3030/presenter/ remote control \u0026gt; http://10.10.63.148:3030/presenter/ shortcuts \u0026gt; restart | open | edit | qrcode 直接使用主题创建\n1 slidev -t vuetiful kg.md 问题 [vite] Internal server error 1 2 3 4 5 6 7 8 9 10 11 12 13 14 Element is missing end tag. 16:55:28 [vite] Internal server error: Element is missing end tag. Plugin: vite:vue File: /@slidev/slides/1.md:10:4 8 | \u0026lt;/span\u0026gt; 9 | \u0026lt;/div\u0026gt; 10 | \u0026lt;p\u0026gt;\u0026lt;a href=\u0026#34;https://github.com/slidevjs/slidev\u0026#34; target=\u0026#34;_blank\u0026#34; alt=\u0026#34;GitHub\u0026#34; | ^ 11 | class=\u0026#34;abs-br m-6 text-xl slidev-icon-btn opacity-50 !border-none !hover:text-white\u0026#34;\u0026gt;\u0026lt;/p\u0026gt; 12 | \u0026lt;carbon-logo-github /\u0026gt;\u0026lt;/a\u0026gt; at createCompilerError (C:\\Users\\ld\\AppData\\Roaming\\npm\\node_modules\\@slidev\\cli\\node_modules\\@vue\\compiler-core\\dist\\compiler-core.cjs.js:19:19) at emitError (C:\\Users\\ld\\AppData\\Roaming\\npm\\node_modules\\@slidev\\cli\\node_modules\\@vue\\compiler-core\\dist\\compiler-core.cjs.js:1613:29) at parseElement ... github 上有这个issue 解决方式:\n1 A temporary solution is to delete the enter after alt=\u0026#34;Github\u0026#34;. 删除xx.md 文件中alt=\u0026ldquo;Github\u0026rdquo; 后面的换行\n","permalink":"https://reid00.github.io/en/posts/other/slidev-markdown-%E8%BD%ACppt/","summary":"介绍 Slidev 使用一种扩展的 Markdown 格式,在一个纯文本文件中存储和组织你的幻灯片。这让你专注于制作内容。而且由于内容和样式是分开的,这也使得在不同的主题之","title":"Slidev Markdown 转PPT"}] \ No newline at end of file diff --git "a/en/posts/algo/\345\212\250\346\200\201\350\247\204\345\210\222\344\271\213\345\256\214\345\205\250\350\203\214\345\214\205\351\227\256\351\242\230/index.html" "b/en/posts/algo/\345\212\250\346\200\201\350\247\204\345\210\222\344\271\213\345\256\214\345\205\250\350\203\214\345\214\205\351\227\256\351\242\230/index.html" index 18346e4f7..8a6104085 100644 --- "a/en/posts/algo/\345\212\250\346\200\201\350\247\204\345\210\222\344\271\213\345\256\214\345\205\250\350\203\214\345\214\205\351\227\256\351\242\230/index.html" +++ "b/en/posts/algo/\345\212\250\346\200\201\350\247\204\345\210\222\344\271\213\345\256\214\345\205\250\350\203\214\345\214\205\351\227\256\351\242\230/index.html" @@ -1,5 +1,5 @@ 动态规划之完全背包问题 | Reid's Blog -

动态规划之完全背包问题

动态规划之完全背包问题

完全背包

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。

题干解析

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。 +

动态规划之完全背包问题

动态规划之完全背包问题

完全背包

有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。

完全背包和01背包问题唯一不同的地方就是,每种物品有无限件。

题干解析

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。 bag 比如:

1
 2
@@ -40,22 +40,8 @@
 01背包中二维dp数组的两个for遍历的先后循序是可以颠倒了,一维dp数组的两个for循环先后循序一定是先遍历物品,再遍历背包容量。
 在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的!

因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。

在完全背包中,对于一维dp数组来说,其实两个for循环嵌套顺序是无所谓的! 因为dp[j] 是根据 下标j之前所对应的dp[j]计算出来的。 只要保证下标j之前的dp[j]都是经过计算的就可以了。

应用

讲解了纯完全背包的一维dp数组实现,先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。

但是仅仅是纯完全背包的遍历顺序是这样的,题目稍有变化,两个for循环的先后顺序就不一样了。

如果求组合数就是外层for循环遍历物品,内层for遍历背包。 -如果求排列数就是外层for遍历背包,内层for循环遍历物品。

背包总结

问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); 对应题目如下: -416.分割等和子集 -1049.最后一块石头的重量 II

问装满背包有几种方法:dp[j] += dp[j - nums[i]] ,对应题目如下: -494.目标和 -518.零钱兑换II -377. 组合总和 Ⅳ -70. 爬楼梯

问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]); 对应题目如下: -474.一和零

问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); 对应题目如下: -322. 零钱兑换 -279. 完全平方数