diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..0904e6753 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,9 @@ +当任务涉及“提取笔记”“梳理文档”“整理成博客文章”“把零散记录改成结构化 Markdown”时,优先使用 `.github/skills/document-refinement/SKILL.md` 中定义的流程与检查清单。 + +整理本仓库里的文档时,默认遵守这些约束: + +- 保留原始信息密度,但重组为更易回看的章节结构 +- 优先沿用仓库现有 Jekyll 文章 front matter、标题层级与中文写作风格 +- 能归纳成“当前保留内容 / 后续可补的方向”时,优先使用这一结构 +- 涉及命令、代码、配置时补齐语言标记、占位符与安全提醒,避免泄露真实密钥 +- 不为了“润色”而虚构原文没有提供的事实 diff --git a/.github/skills/document-refinement/SKILL.md b/.github/skills/document-refinement/SKILL.md new file mode 100644 index 000000000..5cc5a46a3 --- /dev/null +++ b/.github/skills/document-refinement/SKILL.md @@ -0,0 +1,47 @@ +--- +name: document-refinement +description: '提取、梳理、整理仓库中的零散笔记、草稿、速记和技术文档时使用。适用于把原始记录改写成结构化 Markdown、Jekyll 文章、摘要清单或可继续补充的文档。关键词:文档梳理、笔记整理、提取、结构化、总结、博客文章、Markdown。' +user-invocable: true +--- + +# Document Refinement + +用于把这个仓库里的原始笔记、草稿和碎片化记录整理成可长期维护的文档。 + +先阅读 [repo-style](./references/repo-style.md) ,再开始实际整理。 + +## When to Use + +- 用户要求“提取 / 梳理 / 整理”已有文档或笔记 +- 需要把零散命令、代码块、截图说明、问号占位内容改成结构化文章 +- 需要把仓库中的草稿整理成 Jekyll 博客文章 +- 需要在不虚构事实的前提下,提高文档可读性、可回看性和复用性 + +## Procedure + +1. 先确认原始材料属于哪一类:流程记录、速查清单、读书摘录、设计说明、杂项混合笔记。 +2. 提取“真正值得保留”的信息:顺序、结论、约束、示例、排查点、后续待补项。 +3. 选择最贴近仓库现状的结构: + - 混合碎片或占位笔记:`当前保留内容` + `后续可补的方向` + - 明确流程型内容:按 `1 / 2 / 3 ...` 顺序分节 + - 读书/摘要型内容:按主题清单分组,尽量保留原意 +4. 套用仓库既有 Jekyll front matter 与中文标题风格,避免另起一套格式。 +5. 补最少但必要的上下文:说明原始笔记是什么、这次按什么维度重组、读者回看时该先记住什么。 +6. 对代码、命令、配置做最小安全整理: + - 声明代码块语言 + - 把真实路径、密钥、令牌改成占位符 + - 对明显高风险内容补一条安全提醒 +7. 如果原始内容混杂多个主题,优先按主题拆节;只有在确实不相关时才建议拆成多篇文档。 +8. 完成后自检: + - 标题层级是否清晰 + - 是否保留了原始结论 + - 是否删除了无意义噪音 + - 是否新增了原文并未支持的事实 + +## Output Rules + +- 优先复用仓库现有表达,而不是引入陌生模板 +- 以“方便以后自己回看”为第一目标 +- 能清单化就清单化,能顺序化就顺序化 +- 不堆砌空泛总结,不强行扩写 +- 后续待补内容统一收束到“后续可补的方向” diff --git a/.github/skills/document-refinement/references/repo-style.md b/.github/skills/document-refinement/references/repo-style.md new file mode 100644 index 000000000..1af54f301 --- /dev/null +++ b/.github/skills/document-refinement/references/repo-style.md @@ -0,0 +1,89 @@ +# 仓库文档整理参考 + +这个仓库已经有一套比较稳定的“笔记整理成文章”风格,整理时优先复用。 + +## 1. Jekyll 文章外壳 + +大多数正式文章都使用: + +- `layout: post` +- `title` +- `subtitle` +- `date` +- `author` +- `header-img` +- `catalog: true` +- `tags` + +可参考: + +- `_posts/tools/2026-04-25-gpt-codex-agent.md` +- `_posts/tools/2026-04-25-baidu_ocr.md` +- `_posts/notes/2026-04-25-复盘步骤.md` + +## 2. 常见开场方式 + +正文开头常用一段引用块交代: + +- 原始笔记长什么样 +- 这次按什么维度整理 +- 哪些内容保持原样,哪些只是重组 + +例如: + +- 混合笔记按主题拆节:`_posts/tools/2026-04-25-gpt-codex-agent.md` +- 流程记录按调用链路重排:`_posts/tools/2026-04-25-baidu_ocr.md` +- 摘抄清单转正常列表:`_posts/notes/2026-04-25-复盘步骤.md` + +## 3. 优先选择的正文结构 + +### A. 零散占位 / 碎片笔记 + +优先整理成: + +- `## 当前保留内容` +- `## 后续可补的方向` + +适用场景: + +- 原始信息不完整,但已有可保留片段 +- 需要先形成“可继续补”的最小版本 + +### B. 流程 / 排查 / 操作文档 + +优先整理成编号章节: + +- `## 1. 先做什么` +- `## 2. 再确认什么` +- `## 3. 最小示例` +- `## 4. 排查顺序` + +适用场景: + +- API 调用 +- 环境搭建 +- 故障定位 +- 操作步骤 + +### C. 读书 / 摘要 / 经验清单 + +优先按主题分组,保留短句、列表和引用,不强行改写成长文。 + +## 4. 与草稿模板的关系 + +仓库已有两个可复用参考: + +- `_drafts/文档模板.md`:偏设计文档结构 +- `_drafts/markdown-示例文档.md`:偏 Markdown 书写规范 + +整理博客型内容时: + +- 结构组织参考 `_drafts/文档模板.md` 中“目标 / 背景 / 总体设计 / 详细设计”的思想,但不要机械照抄 +- Markdown 细节参考 `_drafts/markdown-示例文档.md`,尤其是标题、列表、代码块语言、链接写法 + +## 5. 安全与质量边界 + +- 不把真实密钥、token、密码、私有地址写回文档 +- 对示例值统一改成占位符 +- 不凭空补全原始笔记没有提供的事实 +- 如果只是“为了以后回看”,优先保留结论、顺序、排查点,而不是追求面面俱到 diff --git a/.github/workflows/site-build.yml b/.github/workflows/site-build.yml new file mode 100644 index 000000000..38315169a --- /dev/null +++ b/.github/workflows/site-build.yml @@ -0,0 +1,69 @@ +name: Site build + +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: site-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + env: + NOKOGIRI_USE_SYSTEM_LIBRARIES: "true" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Configure Pages + uses: actions/configure-pages@v5 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2.3" + + - name: Install Jekyll dependencies + run: gem install jekyll:4.4.1 jekyll-paginate:1.1.0 + + - name: Build site + run: jekyll build + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + report_build_status: + needs: build + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Report build result + run: | + echo "Build result: ${{ needs.build.result }}" + test "${{ needs.build.result }}" = "success" + + deployment: + needs: build + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && needs.build.result == 'success' }} + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..57510a2be --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +_site/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 000000000..9a3f47e3c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: ruby +env: + global: + - NOKOGIRI_USE_SYSTEM_LIBRARIES=true +install: + - gem install jekyll + - gem install jekyll-paginate +script: + - jekyll build +after_success: + - bash <(curl -s https://codecov.io/bash) + + diff --git a/404.html b/404.html new file mode 100644 index 000000000..0c105b14f --- /dev/null +++ b/404.html @@ -0,0 +1,25 @@ +--- +layout: default +description: "你来到了没有知识的荒原 🙊" +header-img: "img/404-bg.jpg" +permalink: /404.html +--- + + + +
+
+
+
+
+

404

+ {{ page.description }} +
+
+
+
+
+ + diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 000000000..92a7adf28 --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,73 @@ +module.exports = function(grunt) { + + // Project configuration. + grunt.initConfig({ + pkg: grunt.file.readJSON('package.json'), + uglify: { + main: { + src: 'js/<%= pkg.name %>.js', + dest: 'js/<%= pkg.name %>.min.js' + } + }, + less: { + expanded: { + options: { + paths: ["css"] + }, + files: { + "css/<%= pkg.name %>.css": "less/<%= pkg.name %>.less" + } + }, + minified: { + options: { + paths: ["css"], + cleancss: true + }, + files: { + "css/<%= pkg.name %>.min.css": "less/<%= pkg.name %>.less" + } + } + }, + banner: '/*!\n' + + ' * <%= pkg.title %> v<%= pkg.version %> (<%= pkg.homepage %>)\n' + + ' * Copyright <%= grunt.template.today("yyyy") %> <%= pkg.author %>\n' + + ' */\n', + usebanner: { + dist: { + options: { + position: 'top', + banner: '<%= banner %>' + }, + files: { + src: ['css/<%= pkg.name %>.css', 'css/<%= pkg.name %>.min.css', 'js/<%= pkg.name %>.min.js'] + } + } + }, + watch: { + scripts: { + files: ['js/<%= pkg.name %>.js'], + tasks: ['uglify'], + options: { + spawn: false, + }, + }, + less: { + files: ['less/*.less'], + tasks: ['less'], + options: { + spawn: false, + } + }, + }, + }); + + // Load the plugins. + grunt.loadNpmTasks('grunt-contrib-uglify'); + grunt.loadNpmTasks('grunt-contrib-less'); + grunt.loadNpmTasks('grunt-banner'); + grunt.loadNpmTasks('grunt-contrib-watch'); + + // Default task(s). + grunt.registerTask('default', ['uglify', 'less', 'usebanner']); + +}; diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..7460c3121 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 BY + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 10a21a334..8b1378917 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# 20083017.github.io \ No newline at end of file + diff --git a/_config.yml b/_config.yml new file mode 100644 index 000000000..efe6cf964 --- /dev/null +++ b/_config.yml @@ -0,0 +1,114 @@ +# Site settings +title: 止于至善 +SEOTitle: 20083017的博客 | 止于至善 +header-img: img/post-bg-desk.jpg +email: 65176340@qq.com +description: "Every failure is leading towards success." +keyword: "20083017的博客" +url: "http://20083017.github.io" # your host, for absolute URL +baseurl: "" # for example, '/blog' if your blog hosted on 'host/blog' +github_repo: "https://github.com/20083017/20083017.github.io.git" # you code repository + +# Sidebar settings +sidebar: true # whether or not using Sidebar. +sidebar-about-description: "Goals determine what you going to be!" +sidebar-avatar: /img/about-BY-gentle.jpg # use absolute URL, seeing it's used in both `/` and `/about/` + + + +# SNS settings +RSS: false +# weibo_username: 20083017 +#zhihu_username: 20083017 +github_username: 20083017 +#facebook_username: 20083017.qiu.7 +#jianshu_username: 20083017 +#twitter_username: 20083017 + + + + +# Build settings +# from 2022, 'pygments' is unsupported on GitHub Pages. Use 'rouge' for highlighting instead. +permalink: pretty +paginate: 10 +exclude: ["less","node_modules","Gruntfile.js","package.json","README.md","tools"] +anchorjs: true # if you want to customize anchor. check out line:181 of `post.html` + + + +# Gems +# from PR#40, to support local preview for Jekyll 3.0 +gems: [jekyll-paginate] + + + + +# Markdown settings +# replace redcarpet to kramdown, +# although redcarpet can auto highlight code, the lack of header-id make the catalog impossible, so I switch to kramdown +# document: http://jekyllrb.com/docs/configuration/#kramdown +markdown: kramdown +highlighter: rouge +kramdown: + input: GFM # use Github Flavored Markdown !important + + + +# 评论系统 +# Disqus(https://disqus.com/) +# disqus_username: quan liu + +# Gitalk +#gitalk: +# enable: true #是否开启Gitalk评论 +# clientID: f2c84e7629bb1446c1a4 #生成的clientID +# clientSecret: ca6d6139d1e1b8c43f8b2e19492ddcac8b322d0d #生成的clientSecret +# repo: 20083017.github.io #仓库名称 +# owner: 20083017 #github用户名 +# admin: 20083017 +# distractionFreeMode: true #是否启用类似FB的阴影遮罩 + + +# 统计 + +# Analytics settings +# Baidu Analytics +#ba_track_id: b50bf2b12b5338a1845e33832976fd68 + +# Google Analytics +#ga_track_id: 'UA-90855596-1' # Format: UA-xxxxxx-xx +#ga_domain: auto # 默认的是 auto, 这里我是自定义了的域名,你如果没有自己的域名,需要改成auto + + + + + +# Featured Tags +featured-tags: true # 是否使用首页标签 +featured-condition-size: 1 # 相同标签数量大于这个数,才会出现在首页 + + + +# Progressive Web Apps +chrome-tab-theme-color: "#000000" +service-worker: true + + + +# Friends +friends: [ + { +# title: "WY", +# href: "http://zhengwuyang.com" + },{ +# title: "简书·BY", +# href: "http://www.jianshu.com/u/e71990ada2fd" + },{ +# title: "Apple", +# href: "https://apple.com" + },{ +# title: "Apple Developer", +# href: "https://developer.apple.com/" + } +] diff --git a/_data/post_folders.yml b/_data/post_folders.yml new file mode 100644 index 000000000..2ad3d901b --- /dev/null +++ b/_data/post_folders.yml @@ -0,0 +1,42 @@ +android: + title: Android + description: Android 开发与问题记录 +build: + title: 构建工具 + description: 编译、构建与工程化笔记 +cpp: + title: C++ + description: C++ 语法、工程与实践记录 +debug: + title: 调试排障 + description: 调试手段与问题定位笔记 +devops: + title: DevOps + description: 运维、部署与平台工具记录 +jenkins: + title: Jenkins + description: Jenkins 设计、使用与排障 +leetcode: + title: LeetCode + description: 算法题与刷题笔记 +life: + title: 生活随笔 + description: 日常记录与杂谈 +misc: + title: 杂项记录 + description: 难归类但有用的技术笔记 +network: + title: 网络编程 + description: 网络、并发与通信相关内容 +notes: + title: 学习笔记 + description: 零散知识点与学习摘记 +perf: + title: 性能优化 + description: 性能分析与优化相关笔记 +python: + title: Python + description: Python 开发与脚本实践 +tools: + title: 工具技巧 + description: 常用工具、编辑器与效率技巧 diff --git a/_drafts/England_vote_speech.md b/_drafts/England_vote_speech.md new file mode 100644 index 000000000..bcbece62d --- /dev/null +++ b/_drafts/England_vote_speech.md @@ -0,0 +1,102 @@ +``` +We meet in a week that could change the United Kingdom for ever. Indeed, it could end the United Kingdom as we know it. +On Thursday, Scotland votes, of the future of our country is at stake. On Friday, people could be living in a different country with a different place in the world and the different future ahead of it. +This is the decision that could break up our family of nations and rips Scotland from rest of the United Kingdom. And we must be very clear, there is no going back from this. No re-run. This is a once and for all decision. +If Scots vote 'yes', the U.K. will split and we will go on separate ways forever. When people vote on Thursday, they are not just voting for themselves, but for their children and grandchildren and the generations beyond. +So I want to speak very directly to the people this country today about what is at stake. +I believe I speak for millions of people across England, Wales, and North Ireland and many in Scotland too who would be utterly heart-broken by the break-up of the United Kingdom, utterly heart-broken to wake up on Friday morning, to the end of the country we love, to know that Scots will no longer join with the English, welsh-man, Irish, in the army, navy, and air force. [???] U.K. white celebrations and commemorations [???] U.K.'s fourteen teams in the Olympics to the British lines. The United Kingdom, would be no more, no U.K. pension, no U.K. passports, no U.K. pound. +The greatest example of the [???] in the world have ever known of openness of people of different nationalities and face coming together as one, would be no more. It would be the end of the country that lodged the enlightenment(?) and abolished the slavery and drove the industrial revolution and defeated fascism. The end of a country that people around the world respect and admire. The end of a country that all of us call home. And you know what, we built this home together. +It's only become Great Britain, because of the greatness of Scotland because of the thinkers the writers the artists the leaders the soldiers and inventors that make this country what it is. +It's Alexander Fleming and David Hume J.K. Rowling Andy Murray and all the millions of people who play the part in this extraordinarily success story. The Scots who let the charge on pensions and the [???] and social justice we did all this together. +For the people of Scotland to walk away now, we will be like painstakingly building a home and then [???] the door and throwing away the keys. So I would say that everyone voting on Thursday, please remember this is just [???] country. +This is the United Kingdom. This is our country. +And you know what makes it a truly great country, it is not a [???]. It is our values. British values. Fan of freedom, justice. +The values that say wherever you are whoever you are, your life has dignity and worth. The values that say we don't work on by when the people are sick. We do not ask for your credit card in the hospital. +We don't turn our backs when you get old and frail. We don't turn a blind eye or cold heart to people around the world from repair and cry and for help. This is what Britain means. +This is what makes our country 'Yes the greatest on Earth'! And it is why millions of us could not bear to see that country ending for good for ever on Friday. Now i know that many people across Scotland were planning to vote yes. +I understand why this might sound appealing. It is the promise of something different. +I also know that the people who running the 'yes' campaign painting a picture of Scotland that is better than everywhere. And they can be good painting the picture. +But when something looks too good to be true, that is usually because it is. +And it's my duty to be clear about the lightly consequences of a 'yes' vote. Independence, would not be a trial separation but would be a painful divorce. +As a Prime Minister, I have to tell you what that would mean. It would mean we no longer share the same currency. +It would mean the arm forces we built up together over centuries being splitted up for ever. It would mean our pension funds being sliced up at some cost. +It would mean the borders we have would become international and may no longer be so easily crossed. +It would mean the automatic support that you currently get from British embassies when you are traveling around world the that would come to end. +It would mean over half of Scottish [???] suddenly from one day to the next begin provided by banks in a foreign country. +It would mean the interest rates in Scotland are no longer set by the bank with England's robust ability and security that promises. +And it would mean for many banks that remain in Scotland, if they ever gotten to travel, it will be Scotish taxpayers and Scottish taxpayers and loan, that will bare the costs. +It would mean that we no longer pull exhausted across the whole of out United Kingdom to pay for institutions [???] national health service or our welfare system. +This is what guess work, there are no question marks, no maybe this, or maybe that. +The nationalists want to break up U.K. funding on pensions, the U.K. funding on health care, the U.K. funding and comprehensive protection on national security. +These are the facts. +This is what would happen and end to the things that we should share together. +And people in Scotland must know these facts before they make this once and for all decision. +And one of the consequences is not to scare [???]. +It is like warning a friend about the decision that they might take that will affect the rest of their lives and the lives of their children. +I [???] this because I don't want the people of the Scotland to be sold a dream that disappears. +Now I know that some people say we have heard about the risks and uncertainties, but we still want change. +And look, the United Kingdom is not a perfect country. +The country is, of course, constantly changing and improve people's lives. +No one is content when there are still children living in poverty. +No one is content when there are people struggling and the young people not reaching their full potential. +And yes, of course, every political party is different, but all of us, all of us, conservatives, labor, [???], nationalists, we are all on a constant mission to change our country for the better. +The question is, how to you get that change. +And for me, it is simple. +You don't get the change you want, by ripping your country apart. +You don't get change by underlying your economy and damaging your businesses and diminishing your place in the world. +But you can't get real concrete change on Thursday. If you vote 'no', businesses usual is not on the [???] paper. +The status clown is gone. The campaign has swepted away. There is no going back to the way things were. +A vote for 'no', means real change. If we [???] that change in practical terms, with a plan and a process. +If we gotta know vote on Thursday that will trigger a major unpresidentive program of devolution with the [???] powers for the Scottish [???]. Major new powers will protest spending. +Some welfare services will have agreed that time table for that stronger Scottish [???], a timetable for bringing that new powers that will go ahead in frozen [???]. +White paper by November put into draft [???] by January, this is the timetable that is now agreed by all the main political parties in [???]. And I am prepared to work with all the main parties to deliver this during 2015. +So a 'no' vote actually means faster, fairly, safer and better change. And this is vital point. Scotland is not a observer in this fairs of this country. Scotland is shaping and changing the United Kingdom for the better. +Also did they [???] at any point in the last three hundred years. +And Scotland will continue to help shape the constitution of our country. And Scottish people who enjoys the [???] without losing the U.K. pension, the U.K. pound and the U.K. passport. Real change is Scotland's for the taking. +The [???] and make [???] decisions but with this security and being in the United Kingdom and with idol risks of [???] alone. It is the both well or both worse. +The Scotland's identity is your [???] strong Scottish culture, strong Scottish art, and strong [???] Scotland. And in the last fifteen years, you built a strong British [???]. +Not all [???] institutions but a permanent one. +So the vote on Thursday, it is not about whether Scotland is a nation. +Scotland is a proud strong successful nation. +The vote on Thursday is about two competing nations for Scotland's future. +And that's the nationalist's vision. Narrowing down, going alone, breaking all ties with the United Kingdom. +Or there is the patriotic vision, they strongs Scottish nation allied to the rest of the United Kingdom with its own strong Scottish Parliament. And it's hard. +And with the benefit of working together in the U.K on [???] on pensions on health care funding, currency and interest rates. It is really the best of both wills. +And it's the best way to get real change and secure a better future for your children and your grandchildren which all this vital debate is all about. +[Clap Drink Water] And speaking of family, there is quite simply how I feel about all of this. +We're a family. The United Kingdom is not one nation. We are four nations in a single country. That could be difficult but it is wonderful. +Scotland, England, Welsh, North Ireland, different nations with individual identities competing with each other, even in time in raging each other. But It still mean so much stronger together. +We're a family of nations. And why should the generations of that family be forced to choose whether to identiy only with their emperor or only with London. +Why should they have to choose which embassy they want to go to when they are in trouble abroad. Or pack that passport when they go to see friends, loved ones and family. +A family is not a compromise or second best. It is the magical identity that makes us more together than we can ever be apart. So please, do not break this family apart. +In human relations, it is almost no good thing to turn away from each other to put up walls or score new lines on the map. Why should we take one Great Britain and turn it into separate smaller nations. +What is that an answer to? +How will that help ambitious young people who wanna make the mock on the world. Or [???] just want security. +Or the family relying on jobs that will made in the U.K. Let no one fool you, that 'yes' is a positive vision. +It is about dividing people. It is about closing doors. It is about making forigners our friends and family. +That is not a optimistic vision. [Clap] The optimistic vision is about our family of nations staying together. +Therefore, each other in the hard times, coming through the better times, we're just pulled through the great reception together. +If we're not moving forward together, the road had been longer but it is finally lead upwards. +And that's why I ask you to vote 'no' to walking away. +Vote 'no' & you are voting for a bigger and broader and better future of Scotland. +And you are investing in the future for your children and grandchildren. +So this is our message to the people of Scotland: we want you to stay. +Head hard and sow we want you to stay. +Please don't mix up the temporary and permanent. +Please don't think I am frustrated with politics right now and I just walk out of the door and never come back. +If you don't like me, I won't be here for ever. +If you don't like this government, it won't last for ever. +But if you leave the United Kingdom, that will be for ever. Yes, the different parts of the U.K. do not always see eye to eye. +Yes we need change, we'll deliver it. +But to get that change together bright future would don't need to tear our country apart. +[Yeah and Clap] In two days' time, this long campaign will be to an end. +And you stand on the [???] of the billing board. I hope you ask yourself this: will my family and I truly be better off? By going alone? Will we really be more save and secure? +Do I really want to turn my back on the rest of Britain? And why it is that so many people across the world asking why would Scotland want to do that? Why? +And if you don't know the answers of these questions, then please vote 'no'. At the end of the day, all the eyes on this campaign can be rejuiced to a single fact. +We are better together. +As you reach your final decision, please, please, don't let anyone tell you you can't be a pride Scot and a pribe Brit. [Clap] Please, don't lose faith in what this country is and what we can be. Don't forget what a great United Kingdom you are a part of. +Don't turn you back to what is the best family in the nations in the world. +And the best hope for your family in this world. So please from all of us, vote to stick together, vote to stay, vote to save our United Kingdom. +Thank you. [Clap and The End]. + +``` diff --git a/_drafts/IM/01-Gateway-Design-Spec.md b/_drafts/IM/01-Gateway-Design-Spec.md new file mode 100755 index 000000000..ae2bce27e --- /dev/null +++ b/_drafts/IM/01-Gateway-Design-Spec.md @@ -0,0 +1,1468 @@ +# Gateway 详细设计文档 v1.0 + +> 长连接接入网关 - 千万并发 IM 系统的"大门" +> 关键词:连接管理 / 协议解析 / QUIC 迁移 / 限流 + +## 目录 +1. [职责与定位](#1-职责与定位) +2. [整体架构](#2-整体架构) +3. [连接管理](#3-连接管理) +4. [协议解析](#4-协议解析) +5. [QUIC 连接迁移](#5-quic-连接迁移) +6. [限流细节](#6-限流细节) +7. [路由与投递](#7-路由与投递) +8. [心跳与保活](#8-心跳与保活) +9. [安全设计](#9-安全设计) +10. [故障与容灾](#10-故障与容灾) +11. [性能与调优](#11-性能与调优) +12. [关键数据结构](#12-关键数据结构) + +13. [客户端负载均衡与服务发现](#13-客户端负载均衡与服务发现) + +--- + +# 1. 职责与定位 + +## 1.1 核心职责 + +``` +┌──────────────────────────────────────┐ +│ Gateway 职责 │ +├──────────────────────────────────────┤ +│ 1. 长连接终结 (TLS/QUIC/WS) │ +│ 2. 协议解析 (二进制 frame) │ +│ 3. 鉴权认证 (Token 校验) │ +│ 4. 限流防护 (本地令牌桶) │ +│ 5. 消息路由 (上行→业务/下行→客户端) │ +│ 6. 在线状态维护 (上报状态服务) │ +│ 7. 心跳保活 │ +│ 8. 连接迁移 (QUIC CID) │ +└──────────────────────────────────────┘ +``` + +## 1.2 不该做的事 +- ❌ 不做业务逻辑(消息内容处理、群成员管理等) +- ❌ 不直接读写主 DB +- ❌ 不做复杂风控判定(只做粗筛) +- ❌ 不缓存业务数据(除连接相关) + +--- + +# 2. 整体架构 + +``` + ┌─────────────────────┐ + │ Client │ + └──────────┬──────────┘ + │ + ┌──────────▼──────────┐ + │ L4 LB (DPVS/ │ + │ Katran/eBPF) │ ← QUIC CID 路由 + └──────────┬──────────┘ + │ + ┌──────────▼──────────┐ + │ Gateway │ + ├─────────────────────┤ + │ ┌─────────────────┐ │ + │ │ Acceptor 线程池 │ │ + │ ├─────────────────┤ │ + │ │ IO 多路复用 │ │ + │ ├─────────────────┤ │ + │ │ 协议解析层 │ │ + │ ├─────────────────┤ │ + │ │ 限流/鉴权 │ │ + │ ├─────────────────┤ │ + │ │ 路由分发器 │ │ + │ ├─────────────────┤ │ + │ │ 连接表 (本地) │ │ + │ └─────────────────┘ │ + └────┬────────────┬───┘ + │ │ + ┌──────────▼──┐ ┌────▼──────────┐ + │ 业务服务集群 │ │ 状态服务 │ + │ (gRPC) │ │ (Redis) │ + └────────────┘ └───────────────┘ +``` + +## 2.1 部署规格 + +| 项 | 配置 | +|---|---| +| 实例规格 | 16 核 / 32GB / 万兆网卡 | +| 单实例连接 | 50K~100K | +| 单实例 QPS | 50K (上行) / 100K (下行) | +| OS | Linux 5.x,开启 BBR | +| 运行时 | Go 1.22+ / C++ 自研 | +| 部署 | K8s StatefulSet 或裸金属 | + +## 2.2 关键依赖 + +| 依赖 | 用途 | +|---|---| +| etcd | 服务发现 + 主备状态 + 配置 | +| Redis | 在线状态上报 | +| Kafka | 异步事件(连接事件、行为日志) | +| 业务服务 | gRPC 调用 | +| 监控 | Prometheus + OpenTelemetry | + +--- + +# 3. 连接管理 + +### 3.x 连接重连与指数退避 + +客户端在连接断开或异常时,建议采用指数退避策略进行重连: + +- 首次失败:1s 后重试 +- 连续失败:指数退避 1s → 2s → 4s → 8s → 16s(最大 60s),每次带 ±20% 抖动 +- 总次数不限,但应在 UI 层提示用户 +- 网络变化/前后台切换时可立即重试 + +服务端可通过 RECONNECT_HINT 等协议建议客户端重连时机与间隔。 + +## 3.1 连接表设计 + +```go +// 全局连接表(每个 Gateway 实例一份) +type ConnectionManager struct { + // 主索引:connId → conn + connsByID sync.Map // map[uint64]*Conn + + // 用户索引:userId → []connId + connsByUser sync.Map // map[int64][]uint64 + + // 设备索引:(userId, deviceId) → connId + connsByDevice sync.Map // map[string]uint64 + + // 统计 + totalConns atomic.Int64 +} + +type Conn struct { + ID uint64 // 单机递增 + UserID int64 + DeviceID string + Protocol Protocol // ws/quic + + Socket net.Conn // 底层连接 + + // 状态 + State atomic.Int32 // 0:init 1:authed 2:closing + LoginAt int64 + LastActive atomic.Int64 + + // 限流(每连接独立) + msgBucket *TokenBucket + sigBucket *TokenBucket + + // 写队列 + writeChan chan []byte // 缓冲 256 + + // 元数据 + ClientIP string + UserAgent string + AppVersion string + + // QUIC 专属 + CID []byte // 当前活跃 CID + Migrated bool // 是否发生过迁移 +} +``` + +## 3.2 连接生命周期 + +``` +[Accept] + ↓ +[TLS/QUIC 握手] ← 5s 超时 + ↓ +[读取 LOGIN 帧] ← 10s 超时 + ↓ +[Token 校验] (调用 Auth Service) ← 100ms 超时 + ↓ +[挤号检测] ← 同一 device 已存在则踢 + ↓ +[加入连接表] + ↓ +[上报 Presence] ← 异步 + ↓ +[发送 LOGIN_ACK] + ↓ +[正常通信] ←─── 心跳续约 + ↓ +[CLOSE / ERROR / TIMEOUT] + ↓ +[清理连接表] + ↓ +[上报 Logout (Presence)] ← 异步 +``` + +## 3.3 单机连接上限 + +```go +// 启动时配置 +const ( + MaxConnsPerInstance = 100_000 + MaxConnsPerIP = 50 + MaxConnsPerUser = 5 // 多端登录 +) + +// 接受新连接前检查 +func (cm *ConnectionManager) CanAccept(ip string) error { + if cm.totalConns.Load() >= MaxConnsPerInstance { + return ErrInstanceFull + } + if cm.connsByIP(ip) >= MaxConnsPerIP { + return ErrIPFull + } + return nil +} +``` + +## 3.4 多端登录与挤号 + +```go +func (cm *ConnectionManager) Login(userId int64, deviceId string, conn *Conn) error { + key := fmt.Sprintf("%d:%s", userId, deviceId) + + // CAS 替换:同 device 已有连接则踢掉 + if oldConnId, ok := cm.connsByDevice.Load(key); ok { + oldConn := cm.connsByID[oldConnId] + oldConn.SendKick(KickReasonOtherDeviceLogin) + oldConn.Close() + } + + // 检查同用户连接数 + userConns := cm.GetUserConns(userId) + if len(userConns) >= MaxConnsPerUser { + // 踢掉最老的 + oldest := findOldest(userConns) + oldest.SendKick(KickReasonTooManyDevices) + oldest.Close() + } + + cm.connsByDevice.Store(key, conn.ID) + cm.connsByUser.Append(userId, conn.ID) + cm.connsByID.Store(conn.ID, conn) + + return nil +} +``` + +## 3.5 异地登录策略 + +``` +策略选项(业务可配): +A. 互不影响:多端可同时在线(推荐) +B. 单端互踢:手机登录 → PC 下线 +C. 平台分组:手机+PC 各保留一个 + +实现: + 在 Login 时按策略检查 connsByUser + 踢出时发 KICK 帧 + 原因码 +``` + +## 3.6 优雅关闭 + +```go +func (cm *ConnectionManager) GracefulShutdown(ctx context.Context) { + // 1. 停止接受新连接 + cm.stopAccepting() + + // 2. 通知所有连接重连 + cm.connsByID.Range(func(_, v interface{}) bool { + conn := v.(*Conn) + conn.SendReconnect(ReasonShutdown, suggestedDelay=2) + return true + }) + + // 3. 等待客户端主动断开(最多 30s) + deadline := time.After(30 * time.Second) + for cm.totalConns.Load() > 0 { + select { + case <-deadline: + cm.forceCloseAll() + return + case <-time.After(100 * time.Millisecond): + } + } +} +``` + +--- + +# 4. 协议解析 + +## 4.1 协议栈 + +``` +应用层: IM Frame (Protobuf) +传输层: WebSocket / QUIC stream +TLS: 1.3 +传输: TCP / UDP +``` + +## 4.2 帧格式(详细) + +``` +偏移 字段 长度 说明 +───────────────────────────────────── +0 Magic 1B 0x4D ("M") +1 Version 1B 协议版本号 +2-3 Cmd 2B 指令类型 (网络字节序) +4-5 Flags 2B 位标志 +6-13 SeqId 8B 请求 ID +14-17 BodyLen 4B Body 长度 +18.. Body 变长 Protobuf 编码 + +Flags 位: + bit 0: 是否压缩 (1=zstd) + bit 1: 是否加密 (业务层加密) + bit 2: 是否需要 ACK + bit 3: 优先级 (0=normal 1=high) + bit 4-15: 保留 +``` + +## 4.3 Cmd 编码表 + +``` +0x00xx 连接管理类 + 0x0001 LOGIN + 0x0002 LOGOUT + 0x0003 HEARTBEAT + 0x0004 HEARTBEAT_ACK + 0x0005 KICK + 0x0006 RECONNECT_HINT + +0x01xx 消息类 + 0x0101 SEND_MSG + 0x0102 SEND_ACK + 0x0103 PUSH_MSG + 0x0104 RECALL + 0x0105 EDIT + 0x0106 READ_REPORT + 0x0107 READ_NOTIFY + 0x0108 TYPING + +0x02xx 同步类 + 0x0201 SYNC_PULL + 0x0202 SYNC_NOTIFY + 0x0203 GET_HISTORY + +0x03xx 会话类 + 0x0301 GET_CONV_LIST + 0x0302 GET_UNREAD + +0x04xx 群组类 + 0x0401 JOIN_GROUP + 0x0402 LEAVE_GROUP + ... + +0xFFxx 系统/调试 + 0xFF01 PING + 0xFF02 SERVER_NOTICE +``` + +## 4.4 解析流程 + +```go +func (g *Gateway) HandleFrame(conn *Conn, raw []byte) error { + // 1. 长度校验 + if len(raw) < HeaderSize { + return ErrFrameTooShort + } + + // 2. Magic 校验 + if raw[0] != MagicByte { + return ErrBadMagic + } + + // 3. 解析头 + header := parseHeader(raw[:HeaderSize]) + + // 4. 版本兼容 + if header.Version > MaxSupportedVersion { + return ErrUnsupportedVersion + } + + // 5. Body 长度校验 + if header.BodyLen > MaxFrameSize { // 1MB + return ErrFrameTooLarge + } + if len(raw) < HeaderSize + int(header.BodyLen) { + return ErrFrameIncomplete + } + + body := raw[HeaderSize:HeaderSize+header.BodyLen] + + // 6. 解压(如果有) + if header.Flags & FlagCompressed != 0 { + body = zstdDecompress(body) + } + + // 7. 限流检查 + if !conn.msgBucket.TryAcquire(1) { + return g.sendRateLimited(conn, header.SeqId) + } + + // 8. 鉴权检查(除了 LOGIN/HEARTBEAT) + if header.Cmd != CmdLogin && header.Cmd != CmdHeartbeat { + if conn.State.Load() != StateAuthed { + return ErrNotAuthed + } + } + + // 9. 路由分发 + return g.dispatch(conn, header, body) +} +``` + +## 4.5 拆包/粘包处理 + +```go +// 基于长度前缀的拆包 +type FrameDecoder struct { + buf bytes.Buffer +} + +func (d *FrameDecoder) Decode(data []byte) ([][]byte, error) { + d.buf.Write(data) + + var frames [][]byte + for { + if d.buf.Len() < HeaderSize { + break // 不够头长度,等下次 + } + + header := peekHeader(d.buf.Bytes()) + totalLen := HeaderSize + int(header.BodyLen) + + if d.buf.Len() < totalLen { + break // 不够整帧 + } + + frame := make([]byte, totalLen) + d.buf.Read(frame) + frames = append(frames, frame) + } + + return frames, nil +} +``` + +## 4.6 写路径优化 + +```go +// 写合并:批量发送减少 syscall +func (c *Conn) WriteLoop() { + var batch [][]byte + timer := time.NewTimer(5 * time.Millisecond) + + for { + select { + case data := <-c.writeChan: + batch = append(batch, data) + if len(batch) >= 10 { + c.flushBatch(batch) + batch = nil + timer.Reset(5 * time.Millisecond) + } + case <-timer.C: + if len(batch) > 0 { + c.flushBatch(batch) + batch = nil + } + timer.Reset(5 * time.Millisecond) + case <-c.closeChan: + return + } + } +} + +func (c *Conn) flushBatch(batch [][]byte) { + // 用 net.Buffers.WriteTo 一次性写 + buffers := net.Buffers(batch) + buffers.WriteTo(c.Socket) +} +``` + +## 4.7 客户端 SDK 协议处理(参考) + +``` +SDK 层职责: + 1. 维护本地 SeqId → callback 映射 + 2. ACK 超时重试 (3 次, 指数退避) + 3. 收到 PUSH_MSG 后回 ACK (避免重复推送) + 4. 收到 KICK 处理重登流程 + 5. 收到 RECONNECT_HINT 走重连 +``` + +--- + +# 5. QUIC 连接迁移 + +## 5.1 CID 编码格式 + +``` +| 1B Version | 2B ServerID | 1B Generation | 4B Salt | 8B Encrypted Random | + ↑ AES 加密整体 + +Version: CID 格式版本 (升级用) +ServerID: 网关节点 ID (1-65535) +Generation: 节点代际 (扩缩容时区分) +Salt: 与 Random 一起加密的混淆数据 +Random: 真随机数,防追踪 +``` + +## 5.2 CID 加解密(LB ↔ Gateway 共享密钥) + +```go +var cidKey = [32]byte{...} // 从配置中心拉取,定期轮转 + +func EncodeCID(serverID uint16, generation uint8) []byte { + cid := make([]byte, 16) + cid[0] = CIDVersion + binary.BigEndian.PutUint16(cid[1:3], serverID) + cid[3] = generation + + // 4-15 用随机数 + 盐 + rand.Read(cid[4:16]) + + // AES-128 加密 4-15 字节 + block, _ := aes.NewCipher(cidKey[:16]) + block.Encrypt(cid[4:16], cid[4:16]) // 简化,实际用 AES-GCM/CTR + + return cid +} + +func DecodeCID(cid []byte) (serverID uint16, gen uint8, ok bool) { + if len(cid) < 16 || cid[0] != CIDVersion { + return 0, 0, false + } + return binary.BigEndian.Uint16(cid[1:3]), cid[3], true +} +``` + +## 5.3 LB 路由实现(XDP/eBPF 伪代码) + +```c +// XDP 程序:内核态解析 UDP 包,提取 CID,转发到对应后端 +SEC("xdp") +int quic_router(struct xdp_md *ctx) { + void *data = (void *)(long)ctx->data; + void *data_end = (void *)(long)ctx->data_end; + + // 解析以太网/IP/UDP头 + struct ethhdr *eth = data; + struct iphdr *ip = data + sizeof(*eth); + struct udphdr *udp = data + sizeof(*eth) + sizeof(*ip); + + // UDP payload 是 QUIC 包 + void *quic = (void *)(udp + 1); + + // 解析 QUIC short header (1 byte flag + DCID) + if (quic + 1 + CID_LEN > data_end) return XDP_DROP; + + __u8 first_byte = *(__u8*)quic; + if ((first_byte & 0x80) != 0) { + // long header (握手包) → 哈希分配 + return hash_route(ip, udp); + } + + // short header → 解 DCID + __u8 *dcid = quic + 1; + + // 查路由表(BPF map) + __u16 server_id = lookup_server_id(dcid); + if (server_id == 0) return XDP_DROP; + + // 重写目标 MAC 转发 + return redirect_to_server(server_id); +} +``` + +## 5.4 连接迁移完整流程 + +``` +[握手阶段] + Server → Client: SCID = encoded_cid_pool[1..8] + NEW_CONNECTION_ID × 8 + +[正常通信] + Client/Server 用 CID 通信 + +[网络切换检测] + Client SDK 监听 NetworkChange + ↓ + 立即从新地址发包(用未使用的备用 CID) + +[服务端处理] + 收到来自新四元组的包 + ↓ + CID 已知 → 启动路径验证 + ↓ + Send PATH_CHALLENGE (随机 8B) + ↓ + Recv PATH_RESPONSE → 验证通过 + ↓ + 切换活跃路径 + ↓ + RETIRE_CONNECTION_ID 旧 CID + ↓ + 发放新备用 CID (NEW_CONNECTION_ID) +``` + +## 5.5 业务层无感处理 + +```go +// QUIC 库回调 +func (g *Gateway) OnPathMigrated(connID uint64, oldAddr, newAddr net.Addr) { + conn := g.connMgr.Get(connID) + if conn == nil { return } + + conn.Migrated = true + conn.RemoteAddr = newAddr + + // 不需要: + // - 不重新登录 + // - 不重置心跳 + // - 不通知业务 + // 业务层完全无感 + + // 上报埋点 + metrics.MigrationSuccess.Inc() + log.Info("connection migrated", + "connID", connID, + "oldAddr", oldAddr, + "newAddr", newAddr) +} +``` + +## 5.6 CID 池管理 + +```go +type CIDPool struct { + pool [][]byte // 已发出的 CID + retired [][]byte // 待清理的 CID + minPool int // 最少预留数量 +} + +// 客户端使用了一个 CID,服务端补充 +func (p *CIDPool) OnCIDUsed(usedCID []byte) { + p.markActive(usedCID) + + if p.activeCIDs() < p.minPool { + // 补发 + newCID := EncodeCID(serverID, generation) + sendNewConnectionID(newCID) + } +} + +// 收到 RETIRE_CONNECTION_ID +func (p *CIDPool) OnRetire(cid []byte) { + p.retired = append(p.retired, cid) + // 30s 后真正释放(避免乱序包) +} +``` + +## 5.7 节点宕机处理 + +``` +场景: ServerID=42 节点宕机 + +1. 健康检查发现 → 从 LB BPF map 移除 server_id=42 +2. 后续指向 42 的 CID 包 → BPF 查询失败 → 丢包 +3. 客户端检测到无响应 (3s) +4. 客户端尝试 0-RTT 恢复连接 +5. 0-RTT 失败则全新握手 +6. 新连接被 LB 哈希到健康节点 +``` + +--- + +# 6. 限流细节 + +## 6.1 限流维度(Gateway 层) + +| 维度 | 实现 | 默认阈值 | +|---|---|---| +| 单连接消息频率 | 令牌桶 (本地) | 10/s, 突发 30 | +| 单连接信令频率 | 令牌桶 (本地) | 50/s | +| 单连接出口带宽 | 滑动窗口 | 100KB/s | +| 单 IP 连接数 | 计数 | 50 | +| 单 IP 建连频率 | 令牌桶 (本地) | 10/s | +| 单 IP 消息频率 | 令牌桶 (本地+共享) | 100/s | +| 全局新连接 | 全实例计数 | 1000/s | + +## 6.2 令牌桶实现(高性能) + +```go +type TokenBucket struct { + capacity int64 + rate int64 // tokens per second + tokens atomic.Int64 + lastRefill atomic.Int64 // unix nano +} + +func (tb *TokenBucket) TryAcquire(n int64) bool { + now := time.Now().UnixNano() + + for { + last := tb.lastRefill.Load() + elapsed := now - last + + // CAS 推进 lastRefill + if elapsed > 0 { + if !tb.lastRefill.CompareAndSwap(last, now) { + continue // 被其他线程抢先,重试 + } + + // 补充令牌 + add := elapsed * tb.rate / 1e9 + for { + old := tb.tokens.Load() + new := min(old + add, tb.capacity) + if tb.tokens.CompareAndSwap(old, new) { + break + } + } + } + + // 尝试扣减 + for { + old := tb.tokens.Load() + if old < n { + return false + } + if tb.tokens.CompareAndSwap(old, old - n) { + return true + } + } + } +} +``` + +## 6.3 分级限流策略 + +```go +// 同一连接遭遇限流: +const ( + SoftLimit = 1.5 // 阈值的 1.5 倍 → 丢弃 + 警告 + HardLimit = 3.0 // 阈值的 3 倍 → 断开连接 +) + +func (g *Gateway) OnRateLimited(conn *Conn, severity float64) { + switch { + case severity < SoftLimit: + // 软限:丢包,回 RATE_LIMITED 错误 + g.sendError(conn, ErrRateLimited, "slow down") + + case severity < HardLimit: + // 中等:本连接加入"减速名单",所有阈值 ×0.5 + conn.applyHalfSpeed(60 * time.Second) + + default: + // 硬限:直接断开 + log.Warn("hard rate limit, closing", "userId", conn.UserID) + conn.Close() + // 上报风控 + g.reportToRisk(conn, "hard_rate_limit") + } +} +``` + +## 6.4 IP 层限流 + +```go +type IPLimiter struct { + // 全 Gateway 实例共享(基于 sync.Map) + counters sync.Map // ip → *atomic.Int64 + buckets sync.Map // ip → *TokenBucket +} + +// 接受连接前 +func (l *IPLimiter) CheckConnect(ip string) error { + cnt := l.getOrCreateCounter(ip) + if cnt.Load() >= MaxConnsPerIP { + return ErrIPConnFull + } + + bucket := l.getOrCreateBucket(ip, 10, 30) // 10/s, 突发 30 + if !bucket.TryAcquire(1) { + return ErrIPConnRateLimit + } + + cnt.Add(1) + return nil +} + +// 连接关闭时 +func (l *IPLimiter) Release(ip string) { + if cnt, ok := l.counters.Load(ip); ok { + cnt.(*atomic.Int64).Add(-1) + } +} +``` + +## 6.5 跨 Gateway 共享限流(高维度) + +某些限流(如单用户全局消息频率)需要跨实例共享: + +```go +// 用户级限流走 Redis +func (g *Gateway) CheckUserRate(userID int64) bool { + key := fmt.Sprintf("rate:user:%d:msg", userID) + return redisLimiter.Check(key, 200, 60) // 200/min +} + +// 优化:本地缓存 1s +type LocalCache struct { + blocked sync.Map // userId → blockedUntil(unix) +} + +func (g *Gateway) CheckUserRateOptimized(userID int64) bool { + if until, ok := g.blockedCache.Load(userID); ok { + if time.Now().Unix() < until.(int64) { + return false // 1s 内不再查 Redis + } + } + + if !g.CheckUserRate(userID) { + g.blockedCache.Store(userID, time.Now().Unix() + 1) + return false + } + return true +} +``` + +## 6.6 限流响应处理 + +```protobuf +message RateLimitedError { + int32 code = 1; // 错误码 + string reason = 2; // 可读原因 + int32 retry_after = 3; // 秒 + int32 limit = 4; // 当前阈值 + int32 current = 5; // 当前计数 +} +``` + +客户端收到后: +- 显示 toast(可选) +- 按 retry_after 退避 +- 不要立即重试 + +--- + +# 7. 路由与投递 + +## 7.1 上行路由(客户端 → 业务) + +```go +func (g *Gateway) dispatch(conn *Conn, header *Header, body []byte) error { + switch header.Cmd { + case CmdSendMsg: + return g.routeToBiz("msg-write", conn, body, header.SeqId) + case CmdRecall: + return g.routeToBiz("msg-recall", conn, body, header.SeqId) + case CmdSyncPull: + return g.routeToBiz("msg-sync", conn, body, header.SeqId) + case CmdReadReport: + return g.routeToBiz("counter-svc", conn, body, header.SeqId) + // ... + } +} + +func (g *Gateway) routeToBiz(svc string, conn *Conn, body []byte, seqId uint64) error { + // gRPC 调用业务服务 + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + // 注入元数据 + ctx = metadata.AppendToOutgoingContext(ctx, + "user-id", strconv.FormatInt(conn.UserID, 10), + "device-id", conn.DeviceID, + "client-ip", conn.ClientIP, + "trace-id", traceIdFromCtx(ctx), + ) + + resp, err := g.bizClient(svc).Invoke(ctx, body) + if err != nil { + // 失败回包 + return g.sendError(conn, mapError(err), seqId) + } + + // 成功回包 + return g.sendResponse(conn, resp, seqId) +} +``` + +## 7.2 下行投递(业务 → 客户端) + +下行投递有两种触发: + +### 模式 A:业务服务主动 RPC 推送 +```go +// Deliver 服务调用 Gateway 的 Push API +func (g *Gateway) Push(ctx context.Context, req *PushReq) (*PushResp, error) { + conn := g.connMgr.GetByDevice(req.UserID, req.DeviceID) + if conn == nil { + return &PushResp{Code: ROUTE_NOT_FOUND}, nil + } + + if conn.State.Load() != StateAuthed { + return &PushResp{Code: NOT_AUTHED}, nil + } + + frame := buildFrame(CmdPushMsg, req.Body) + select { + case conn.writeChan <- frame: + return &PushResp{Code: OK}, nil + case <-time.After(100 * time.Millisecond): + return &PushResp{Code: WRITE_TIMEOUT}, nil + } +} +``` + +### 模式 B:通过 Kafka 消费推送 +```go +// Gateway 也可以订阅 Kafka,自己消费消息 +// 优点:解耦 +// 缺点:每个 Gateway 都要消费,浪费资源 +// 通常用模式 A +``` + +## 7.3 投递失败处理 + +```go +func (d *Deliver) DeliverWithRetry(serverMsgId int64, userID int64) { + devices := d.queryUserDevices(userID) + + for _, dev := range devices { + gateway := d.statusSvc.GetGateway(userID, dev.DeviceID) + if gateway == "" { + // 离线 → 写 inbox + push + continue + } + + resp, err := d.gatewayClient(gateway).Push(...) + switch { + case err != nil || resp.Code == GATEWAY_DOWN: + // 网关挂 → 重新查路由 + d.refreshRoute(userID, dev.DeviceID) + d.retry(serverMsgId, userID, dev.DeviceID) + + case resp.Code == ROUTE_NOT_FOUND: + // 用户已不在该网关 → 用户已掉线 → 走离线 + d.writeOffline(serverMsgId, userID, dev.DeviceID) + + case resp.Code == OK: + metrics.DeliverSuccess.Inc() + } + } +} +``` + +--- + +# 8. 心跳与保活 + +## 8.1 心跳协议 + +``` +客户端 → 服务端: HEARTBEAT (空 body 或 包含 last_received_seq) +服务端 → 客户端: HEARTBEAT_ACK (server_time + 可携带通知) + +间隔: 客户端 30s 发送一次 +超时: 服务端 60s 未收到 → 关闭连接 +``` + +## 8.2 自适应心跳(移动端节电) + +```go +// 客户端逻辑 +func (sdk *SDK) AdaptiveHeartbeat() { + interval := 30 * time.Second + + for { + select { + case <-time.After(interval): + sdk.sendHeartbeat() + case <-sdk.networkChange: + // 网络变化重置 + interval = 30 * time.Second + } + + // 根据网络质量调整 + if sdk.networkQuality == Poor { + interval = 15 * time.Second + } else if sdk.foreground == false { + interval = 60 * time.Second // 后台延长 + } + } +} +``` + +## 8.3 服务端心跳超时 + +```go +func (g *Gateway) heartbeatChecker() { + ticker := time.NewTicker(10 * time.Second) + for range ticker.C { + now := time.Now().Unix() + g.connMgr.connsByID.Range(func(_, v interface{}) bool { + conn := v.(*Conn) + if now - conn.LastActive.Load() > 60 { + log.Info("heartbeat timeout", "userID", conn.UserID) + conn.Close() // 触发清理 + } + return true + }) + } +} +``` + +## 8.4 心跳带 piggyback + +心跳是高频信令,可以携带: +- 客户端最大已收 server_msg_id(用于服务端清理待 ACK 队列) +- 已读批量上报 +- 网络质量统计 + +```protobuf +message Heartbeat { + int64 last_received_msg_id = 1; + repeated ReadReport reads = 2; + NetworkInfo net_info = 3; +} +``` + +--- + +# 9. 安全设计 + +## 9.1 TLS 配置 + +```yaml +# Nginx / Envoy 前置 TLS +ssl_protocols: TLSv1.3 TLSv1.2 +ssl_ciphers: ECDHE+AESGCM:ECDHE+CHACHA20 +ssl_prefer_server_ciphers: on +ssl_session_cache: shared:SSL:50m +ssl_session_timeout: 1d +ssl_session_tickets: off +ocsp_stapling: on +``` + +## 9.2 鉴权流程 + +``` +LOGIN 帧: + { + user_id, device_id, app_id, + token, ← 由独立 Auth 服务签发的 JWT + timestamp, ← 防重放 + nonce, ← 一次性 + sign ← HMAC(token + timestamp + nonce, secret) + } + +Gateway 处理: + 1. 校验 timestamp 与服务器时间差 < 60s + 2. 校验 nonce 未使用 (Redis SET NX, 60s TTL) + 3. 校验 token 签名(公钥) + 4. 校验 token 过期 + 5. 校验 token claim (user_id 一致) + 6. 通过 → State = Authed +``` + +## 9.3 防 DDoS + +| 手段 | 措施 | +|---|---| +| SYN Flood | SYN Cookie + RST 限速 | +| 假连接 | 5s 内必须完成 TLS 握手 | +| 半开连接 | 单 IP 半开数限制 | +| 慢速攻击 | 读超时 30s | +| 资源耗尽 | 单实例最大连接数硬限 | +| 反射放大 | QUIC 路径验证强制开启 | + +## 9.4 数据加密 + +``` +传输层: TLS 1.3 强制 +端到端 (可选): 业务层加密 + - 群: 协商组密钥 + - 私聊: Signal 协议 (X3DH + Double Ratchet) +媒体: 文件上传 OSS 时单独加密 +``` + +## 9.5 防伪造与重放 + +``` +所有上行请求必须带: + client_msg_id (重放识别) + timestamp (时间窗口校验) + +服务端: + Redis SETNX 当 client_msg_id 已存在 → 重放或重试 + 时间窗口 ±5min 之外 → 拒绝 +``` + +--- + +# 10. 故障与容灾 + +## 10.1 故障矩阵 + +| 故障 | 影响 | 处理 | +|---|---|---| +| 单 Gateway 进程崩溃 | 该机连接全断 | K8s 重启 + 客户端重连 | +| 单 Gateway 慢 | 部分用户体验差 | LB 健康检查降权 | +| 业务服务挂 | 上行失败 | 客户端 retry,最多 3 次 | +| Redis 挂 | 状态查询失败 | 本地缓存兜底,30s 后清除 | +| LB 挂 | 整体不可达 | 多 LB 集群,DNS 切换 | +| 整集群挂 | 区域不可用 | 客户端 fallback 到其他集群 | + +## 10.2 优雅降级 + +```go +// 配置中心动态开关 +var ( + KillTyping atomic.Bool // 关闭"输入中" + KillReadNotify atomic.Bool // 关闭已读回执 + ReducedQPS atomic.Bool // 整体限流减半 +) + +func (g *Gateway) handleTyping(...) { + if KillTyping.Load() { + return nil // 直接丢弃,不报错 + } + // ... +} +``` + +## 10.3 客户端重连策略 + +``` +首次失败: 1s 后重试 +连续失败: 指数退避 1s → 2s → 4s → 8s → 16s (max 60s) ++ 抖动: ±20% random ++ 总次数限: 无限重试,但提示用户 ++ 触发条件: 网络变化 / 后台前台切换 → 立即重试 +``` + +## 10.4 Gateway 自我保护 + +```go +// 内存压力大 → 拒绝新连接 +func (g *Gateway) memoryPressureCheck() { + var stats runtime.MemStats + runtime.ReadMemStats(&stats) + + if stats.Alloc > MemThreshold { + g.rejectNewConn.Store(true) + log.Warn("memory pressure, rejecting new conns") + } +} + +// CPU 满载 → 减少处理 +func (g *Gateway) cpuPressureCheck() { + if cpuUsage() > 0.85 { + g.rateLimitFactor.Store(0.5) // 阈值减半 + } +} +``` + +--- + +# 11. 性能与调优 + +## 11.x Socket Options 优化建议 + +为提升高并发下的连接性能,建议在 Gateway 启动时设置如下 socket 选项: + +- TCP_NODELAY:关闭 Nagle 算法,降低延迟 +- SO_REUSEADDR / SO_REUSEPORT:端口复用,提升重启与多进程能力 +- SO_KEEPALIVE:检测死连接,建议设置较长间隔 +- TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT:细化 keepalive 策略 +- QUIC 场景下,增大 UDP rmem/wmem buffer +- epoll 相关参数优化 + +具体参数见 11.1 OS 调优章节。 + +## 11.1 OS 调优 + +```bash +# /etc/sysctl.conf +net.core.somaxconn = 65535 +net.core.netdev_max_backlog = 65535 +net.ipv4.tcp_max_syn_backlog = 65535 +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_fin_timeout = 15 +net.ipv4.tcp_keepalive_time = 600 +net.ipv4.ip_local_port_range = 1024 65535 +fs.file-max = 2000000 +fs.nr_open = 2000000 + +# 用户限制 +* soft nofile 1000000 +* hard nofile 1000000 + +# BBR 拥塞控制 +net.ipv4.tcp_congestion_control = bbr +net.core.default_qdisc = fq + +# UDP buffer (QUIC) +net.core.rmem_max = 16777216 +net.core.wmem_max = 16777216 +``` + +## 11.2 Go 运行时调优 + +```go +// main.go +func init() { + runtime.GOMAXPROCS(runtime.NumCPU()) + debug.SetGCPercent(50) // 更激进 GC + debug.SetMemoryLimit(28 << 30) // 28GB 软上限 +} +``` + +## 11.3 关键性能指标 + +| 指标 | 目标 | +|---|---| +| 单实例连接数 | 100K | +| 单实例 QPS(处理) | 50K | +| 单连接消息延迟(Gateway 内) | < 1ms | +| 单连接 CPU 占用 | < 0.001% | +| 内存/连接 | < 200KB | +| TLS 握手延迟 P99 | < 100ms | +| QUIC 0-RTT 比例 | > 50% | + +## 11.4 性能压测脚本(参考) + +```yaml +# 工具:k6 / locust / 自研 +场景: + - 100K 长连接保活测试 + - 50K QPS 消息上行 + - 100K QPS 消息下行 + - QUIC 切网络迁移延迟测试 + - 重连风暴模拟 + - 慢速攻击检测 +``` + +--- + +# 12. 关键数据结构 + +--- + +# 13. 客户端负载均衡与服务发现 + +## 13.1 Server IP 获取方式 + +客户端可通过多种方式获取 Gateway server IP: + +- 标准 DNS 解析(A/AAAA 记录) +- HTTPDNS(API 拉取,防劫持) +- 配置下发(如业务下发 server 列表) + +建议优先采用 HTTPDNS,失败时 fallback 到本地 DNS,或多通道并发解析。 + +## 13.2 负载均衡与优选策略 + +### 13.2.3 Server 热切换(Hot Switch) + +#### 设计目标 +- 保证客户端在 server 异常、网络变化、配置刷新等场景下,能无感知或低感知地切换到可用 server,提升可用性与体验。 + +#### 典型触发条件 +- 当前 server 检测到连接断开、心跳超时、消息收发异常等 +- 网络切换(如 WiFi/4G 切换、IP 变化) +- server 地址列表有更新(如 HTTPDNS 拉取、配置下发变更) +- 服务端主动下发 RECONNECT_HINT、KICK 等信令 + +#### 热切换流程 +1. 检测到切换条件,立即暂停当前连接的消息收发 +2. 选择下一个健康 server,发起新连接(可并发尝试多个) +3. 新连接建立后,完成鉴权/登录/同步等初始化,确保新连接完全可用 +4. 仅在新连接鉴权成功后,将消息收发切换到新连接,必要时补发未确认消息 +5. 关闭旧连接,释放资源 +6. 记录切换事件,便于埋点与问题追踪 + +> 注意:应保证热切换时,用户优先登录到与上次一致的 server 集群(如同一大区/region),除非该集群整体不可用。只有在原集群不可用时,才考虑切换到其他集群,避免频繁跨集群迁移带来的会话同步、漫游等一致性问题。 + +#### 注意事项 +- 切换过程需保证消息不丢失、不重复(可结合消息队列、重发机制) +- 切换期间 UI 层可适当提示用户(如“正在重连”) +- 支持多路并发连接尝试,优先用最快建立的 server +- 切换后需重新同步会话、未读、离线消息等 + +#### 伪代码示例 +```python +def hot_switch(selector, current_ip): + # 1. 标记当前 server 异常 + selector.mark_bad(current_ip) + # 2. 选择新 server + new_ip = selector.pick() + # 3. 发起新连接 + if connect(new_ip): + # 4. 初始化(鉴权/同步) + login(new_ip) + sync_state(new_ip) + # 5. 恢复消息收发 + resume_message_queue() + # 6. 关闭旧连接 + close(current_ip) + log('hot switch success', new_ip) + else: + # 失败可重试或 fallback + log('hot switch failed', new_ip) +``` + +> 实际实现需结合平台网络库、消息队列、状态同步等机制,保证切换平滑。 + +- 支持多 server 地址优选,按地理位置、权重、健康状态等排序 +- 支持 server IP 缓存与健康探测,自动切换异常节点 +- 支持 server 优先级、权重、地理亲和等策略 + +### 13.2.1 客户端负载均衡典型流程 + +1. 启动时拉取 server 列表(HTTPDNS、配置下发、DNS 解析等) +2. 按优先级/权重/地理位置等排序 server 列表 +3. 依次尝试连接 server,优先健康节点 +4. 连接建立后,定期健康探测(如心跳、ping) +5. 检测到 server 异常时,自动切换到下一个健康 server +6. 支持 server 地址动态刷新与热切换 + +### 13.2.2 客户端伪代码示例 + +```python +class ServerSelector: + def __init__(self, server_list): + self.server_list = server_list # [(ip, weight, region, ...)] + self.health = {ip: True for ip, *_ in server_list} + + def pick(self): + # 按健康、权重、地理优先排序 + healthy = [s for s in self.server_list if self.health[s[0]]] + if not healthy: + healthy = self.server_list + # 简单轮询或加权随机 + return healthy[0][0] + + def mark_bad(self, ip): + self.health[ip] = False + + def refresh(self, new_list): + self.server_list = new_list + for ip, *_ in new_list: + if ip not in self.health: + self.health[ip] = True + +# 使用示例 +selector = ServerSelector([("1.1.1.1", 10, "east"), ("2.2.2.2", 5, "west")]) +ip = selector.pick() +try: + connect(ip) +except Exception: + selector.mark_bad(ip) + ip = selector.pick() + connect(ip) +``` + +> 实际实现可结合平台特性(如 Android/iOS 网络库),支持并发探测、优选、自动切换等。 + +## 13.3 选型说明 + +- DNS 方案简单,易于维护,但易被劫持 +- HTTPDNS 可提升可靠性,适合移动端 +- 配置下发适合特殊场景(如企业专线) + +## 13.4 相关配置与扩展 + +- 支持 server 地址动态刷新与热切换 +- 支持服务端通过 RECONNECT_HINT 等协议建议切换 server + +--- + +## 12.1 服务发现注册 + +```yaml +# etcd: /im/gateway/instances/{instance_id} +{ + "instance_id": "gw-east-001", + "server_id": 42, # CID 编码用 + "host": "10.1.2.3", + "port_quic": 443, + "port_ws": 443, + "region": "east", + "az": "east-1a", + "version": "v1.2.3", + "started_at": 1710000000, + "max_conns": 100000, + "current_conns": 75432, + "status": "healthy" +} +TTL: 30s, 心跳续约 +``` + +## 12.2 投递路由元数据 + +```yaml +# Redis: presence:user:{userId} +{ + "ios:DEV001": { + "gateway": "gw-east-001", + "conn_id": 12345, + "login_at": 1710000000, + "last_active": 1710001234 + }, + "android:DEV002": {...} +} +TTL: 30s +``` + +## 12.3 配置项 + +```yaml +gateway: + listen: + quic: ":443" + ws: ":443" + tls: + cert: "/etc/im/cert.pem" + key: "/etc/im/key.pem" + limits: + max_conns_per_instance: 100000 + max_conns_per_ip: 50 + max_conns_per_user: 5 + max_frame_size: 1048576 + msg_rate_per_conn: 10 + sig_rate_per_conn: 50 + timeouts: + tls_handshake: 5s + login: 10s + heartbeat: 60s + write: 5s + upstream: + msg_write: "msg-write.svc:9000" + msg_sync: "msg-sync.svc:9000" + counter: "counter.svc:9000" + cid: + server_id: 42 + generation: 1 + rotation_interval: 24h + observability: + metrics_port: 9090 + pprof_port: 6060 + log_level: info +``` + +--- + +**文档结束** + +*Version 1.0 | Gateway 详细设计* \ No newline at end of file diff --git a/_drafts/IM/01-Gateway-Design-v1.0_Version4.md b/_drafts/IM/01-Gateway-Design-v1.0_Version4.md new file mode 100755 index 000000000..2228d65a2 --- /dev/null +++ b/_drafts/IM/01-Gateway-Design-v1.0_Version4.md @@ -0,0 +1,1080 @@ +# Gateway 详细设计文档 v1.0 + +> 上游文档:IM 系统技术设计规范 +> 适用范围:长连接接入网关(WebSocket / QUIC) +> 单实例目标:10 万并发连接 / 5 万 QPS 消息转发 + +--- + +## 目录 + +1. 总体架构 +2. 连接生命周期管理 +3. 协议解析与编解码 +4. QUIC 连接迁移 +5. 限流细节 +6. 心跳与保活 +7. 消息路由 +8. 多端互踢与会话管理 +9. 容错与故障处理 +10. 监控指标 +11. 性能优化 +12. 部署与配置 + +--- + +# 1. 总体架构 + +## 1.1 模块划分 + +``` +┌─────────────────────────────────────────────┐ +│ Gateway 进程 │ +├─────────────────────────────────────────────┤ +│ Listener (TCP/QUIC) │ +│ ├─ TLS Handshake │ +│ └─ Protocol Detect │ +├─────────────────────────────────────────────┤ +│ Connection Manager │ +│ ├─ Connection Table │ +│ ├─ Auth Module │ +│ └─ Heartbeat Module │ +├─────────────────────────────────────────────┤ +│ Protocol Codec │ +│ ├─ Frame Parser │ +│ ├─ Protobuf Decoder │ +│ └─ Compression │ +├─────────────────────────────────────────────┤ +│ Rate Limiter (Local) │ +├─────────────────────────────────────────────┤ +│ Router │ +│ ├─ Upstream RPC Client │ +│ └─ Downstream Push │ +├─────────────────────────────────────────────┤ +│ Metrics / Tracing / Log │ +└─────────────────────────────────────────────┘ +``` + +## 1.2 进程模型 + +- 主进程:监听 + accept +- IO 线程池:N = CPU 核数(处理读写) +- Worker 线程池:M = 2×CPU(处理业务转发) +- 单连接绑定到单 IO 线程(避免锁竞争) + +## 1.3 关键数据结构 + +```go +type Connection struct { + ConnID uint64 // 连接全局 ID + UserID int64 + DeviceID string + AppID int32 + Protocol Protocol // WS/QUIC + Socket net.Conn // 或 quic.Stream + + LoginTime int64 + LastHeartbeat int64 + Status ConnStatus // CONNECTING/AUTHED/CLOSED + + SendChan chan []byte // 发送队列(带背压) + + // 限流 + MsgBucket *TokenBucket + SignalBucket *TokenBucket + InBytes *SlidingWindow + + // 业务 + SessionInfo *Session + Subs []int64 // 订阅的 conv_id(按需) + + mu sync.RWMutex +} + +type ConnectionManager struct { + byConnID *sync.Map // ConnID → *Connection + byUser *sync.Map // UserID → []*Connection (多端) + + counters Counters +} +``` + +--- + +# 2. 连接生命周期管理 + +## 2.1 状态机 + +``` +[INIT] + │ accept + ▼ +[CONNECTED] + │ TLS handshake OK + ▼ +[TLS_DONE] + │ LOGIN frame + ▼ +[AUTHENTICATING] + │ token verified + ▼ +[AUTHED] ←──────┐ + │ │ heartbeat / msg + ├─────────────┘ + │ LOGOUT / timeout / error + ▼ +[CLOSING] + │ flush / cleanup + ▼ +[CLOSED] +``` + +## 2.2 接入流程 + +``` +1. accept 连接 (TCP/QUIC) +2. TLS handshake (1-RTT 或 0-RTT) +3. 等待客户端 LOGIN frame (10s 超时) +4. 鉴权: + - 解析 token (JWT/自有) + - 调用 auth-service 验证 + - 返回 user_id / device_id +5. 注册到 ConnectionManager + - byConnID[connId] = conn + - byUser[userId] += conn +6. 上报到 status shard: + SET presence:dev:{user}:{device} {gw,connId} EX 30 +7. 通知其他端 (多端登录) +8. 进入消息处理循环 +``` + +## 2.3 鉴权细节 + +```go +func (g *Gateway) authenticate(req *LoginReq) (*Identity, error) { + // 1. 本地缓存 + if id := g.authCache.Get(req.Token); id != nil { + return id, nil + } + + // 2. 远程验证(带超时) + ctx, cancel := context.WithTimeout(g.ctx, 500*time.Millisecond) + defer cancel() + + id, err := g.authClient.Verify(ctx, req.Token) + if err != nil { + return nil, err + } + + // 3. 缓存(短 TTL,token 撤销时仍有窗口) + g.authCache.Set(req.Token, id, 60*time.Second) + return id, nil +} +``` + +## 2.4 多端登录策略 + +| 策略 | 说明 | +|---|---| +| **同设备类型互踢** | 新 iOS 登录踢旧 iOS | +| **不同设备共存** | iOS + Android + PC 可同时在线 | +| **PC 与 Web 互踢** | PC 在线时 Web 受限 | + +实现: + +```go +func (m *ConnectionManager) Register(c *Connection) { + existing := m.byUser.Load(c.UserID) + + for _, old := range existing { + if shouldKick(old, c) { + old.Send(KickFrame{Reason: "new_login"}) + old.Close() + } + } + + m.byUser.Append(c.UserID, c) +} +``` + +## 2.5 连接关闭 + +### 主动关闭(正常) +``` +1. 收到 LOGOUT frame +2. 发送 LOGOUT_ACK +3. 标记 status = CLOSING +4. flush SendChan +5. 关闭底层 socket +6. 从 ConnectionManager 移除 +7. DELETE presence:dev:{user}:{device} +8. 异步通知 status shard +``` + +### 异常关闭 +``` +触发条件: + - 心跳超时 (60s) + - 读写错误 + - 协议错误 + - 客户端 RST + +处理: + - 不发 LOGOUT(已断) + - 走清理流程 + - 记录原因到 metric +``` + +### 优雅下线(运维) +``` +1. 配置中心下发 drain 标记 +2. Gateway 停止接受新连接(LB 摘除) +3. 现有连接发 RECONNECT 提示 +4. 等待 30s 让客户端切换 +5. 强制关闭剩余连接 +6. 进程退出 +``` + +--- + +# 3. 协议解析与编解码 + +## 3.1 帧格式 + +``` ++----+--------+--------+----------+----------+------------------+ +| M | Ver | Cmd | Flags | SeqId | Length | +| 1B | 1B | 2B | 2B | 8B | 4B | ++----+--------+--------+----------+----------+------------------+ +| Body (Protobuf, max 1MB) | ++---------------------------------------------------------------+ + +总头部: 18 字节 +``` + +### Flags 位定义 +``` +bit 0: 压缩 (0=none, 1=gzip) +bit 1: 加密 (0=none, 1=app-level) +bit 2: 优先级 (0=normal, 1=high) +bit 3-7: 保留 +``` + +## 3.2 解析流程 + +```go +func (c *Connection) readLoop() { + reader := bufio.NewReaderSize(c.socket, 64*1024) + + for { + // 1. 读头部 18 字节 + var header [18]byte + if _, err := io.ReadFull(reader, header[:]); err != nil { + c.closeWithError(err) + return + } + + // 2. 校验 Magic + if header[0] != 0x4D { + c.closeWithError(ErrBadMagic) + return + } + + // 3. 解析头部 + ver := header[1] + cmd := binary.BigEndian.Uint16(header[2:4]) + flags := binary.BigEndian.Uint16(header[4:6]) + seqId := binary.BigEndian.Uint64(header[6:14]) + length := binary.BigEndian.Uint32(header[14:18]) + + // 4. 校验长度 + if length > MaxFrameSize { + c.closeWithError(ErrFrameTooLarge) + return + } + + // 5. 读 body + body := make([]byte, length) + if _, err := io.ReadFull(reader, body); err != nil { + c.closeWithError(err) + return + } + + // 6. 解压 + if flags & FlagCompressed != 0 { + body = decompress(body) + } + + // 7. 限流(消息维度) + if !c.MsgBucket.Allow(1) { + c.sendErr(seqId, ErrRateLimited) + continue + } + + // 8. 投递到业务处理 + c.dispatch(cmd, seqId, body) + } +} +``` + +## 3.3 写入流程 + +```go +func (c *Connection) writeLoop() { + writer := bufio.NewWriterSize(c.socket, 256*1024) + flushTimer := time.NewTicker(5 * time.Millisecond) + + for { + select { + case msg, ok := <-c.SendChan: + if !ok { + return // 已关闭 + } + if _, err := writer.Write(msg); err != nil { + c.closeWithError(err) + return + } + + // 累积发送(小消息合并) + if len(c.SendChan) == 0 || writer.Buffered() > 32*1024 { + writer.Flush() + } + + case <-flushTimer.C: + if writer.Buffered() > 0 { + writer.Flush() + } + + case <-c.closeChan: + writer.Flush() + return + } + } +} +``` + +## 3.4 背压控制 + +```go +SendChan capacity = 256 frames + +// 发送时检查 +func (c *Connection) Send(frame []byte) error { + select { + case c.SendChan <- frame: + return nil + case <-time.After(100 * time.Millisecond): + // 发送队列堵塞,可能客户端慢 + return ErrSendBlocked + } +} + +// 慢客户端策略 +当 SendChan 堵塞超过 3 次 → 关闭连接 +避免单个慢客户端拖累内存 +``` + +## 3.5 大消息处理 + +``` +单帧最大 1MB +> 1MB 的消息: + - 文件/图片走 HTTP 上传到对象存储 + - IM 消息只带 URL + 元数据 + - Gateway 不传输大文件 +``` + +--- + +# 4. QUIC 连接迁移 + +## 4.1 CID 设计 + +``` +Connection ID 格式 (16 bytes): ++-------+----------+------------+----------------+ +| Ver | ServerID | Generation | Encrypted | +| 1B | 2B | 1B | 12B | ++-------+----------+------------+----------------+ + +整体用 AES-128 加密(LB 与 Gateway 共享密钥) +``` + +## 4.2 CID 生成 + +```go +func (g *Gateway) generateCID() ConnectionID { + plaintext := make([]byte, 16) + plaintext[0] = ProtocolVersion + binary.BigEndian.PutUint16(plaintext[1:3], g.serverID) + plaintext[3] = g.generation + rand.Read(plaintext[4:]) // 12 字节随机 + + // 加密 + ciphertext := g.aesBlock.Encrypt(plaintext) + return ciphertext +} +``` + +## 4.3 LB 解析(eBPF/XDP) + +```c +// 简化的 eBPF 程序逻辑 +SEC("xdp") +int route_quic(struct xdp_md *ctx) { + // 1. 解析 UDP 包 + void *data = (void *)(long)ctx->data; + void *data_end = (void *)(long)ctx->data_end; + + // 2. 跳到 UDP payload + struct quic_header *qh = parse_quic(data); + + // 3. 提取 DCID (long header 或 short header) + __u8 dcid[16]; + extract_dcid(qh, dcid); + + // 4. 解密 (AES 在 eBPF 不易,可由 LB 代理做) + // 或:使用未加密的 server_id 字段(牺牲一点隐私) + __u16 server_id = decrypt_and_extract(dcid); + + // 5. 查路由表 + __u32 *backend_ip = bpf_map_lookup_elem(&route_map, &server_id); + + // 6. 重定向 + return bpf_redirect(*backend_ip, 0); +} +``` + +实际实现可参考: +- Cloudflare quiche + Katran +- Envoy QUIC + 自定义 cluster + +## 4.4 备用 CID 池 + +```go +// 握手完成后立即发送备用 CID +func (s *Session) sendNewConnectionIDs(count int) { + for i := 0; i < count; i++ { + cid := s.gateway.generateCID() + token := generateStatelessResetToken(cid) + s.cidPool[cid] = token + + s.queue.Send(&NewConnectionIDFrame{ + SequenceNumber: s.nextCIDSeq, + ConnectionID: cid, + ResetToken: token, + }) + s.nextCIDSeq++ + } +} + +// 启动时发 8 个备用 CID +const InitialCIDPoolSize = 8 +``` + +## 4.5 路径验证 + +```go +func (s *Session) handlePacketFromNewPath(addr net.Addr, frame Frame) { + if s.activePath.Equal(addr) { + s.handleNormal(frame) + return + } + + // 检测到新路径 + if !s.knownCIDs.Contains(frame.DCID) { + return // 未知 CID,丢弃 + } + + // 启动路径验证 + challenge := generateRandom8Bytes() + s.pendingPaths[addr] = &PendingPath{ + Challenge: challenge, + Started: time.Now(), + } + + s.sendOnPath(addr, &PathChallengeFrame{Data: challenge}) + + // 超时 5s 未收到 response → 验证失败 +} + +func (s *Session) handlePathResponse(addr net.Addr, resp *PathResponseFrame) { + pending := s.pendingPaths[addr] + if pending == nil { + return + } + + if !bytes.Equal(pending.Challenge, resp.Data) { + return + } + + // 验证通过,切换路径 + s.activePath = addr + delete(s.pendingPaths, addr) + + // 通知业务层"路径变更但连接不变" + s.metrics.PathMigration.Inc() + + // 弃用旧 CID + s.retireOldCIDs() +} +``` + +## 4.6 防 NAT Rebinding 误判 + +```go +// NAT rebinding:IP 不变,端口变了 +// 处理: +// - 视为路径变更,走路径验证 +// - 但不增加迁移计数(区别于真实迁移) + +func (s *Session) classifyMigration(oldAddr, newAddr net.Addr) string { + if oldAddr.IP.Equal(newAddr.IP) { + return "nat_rebinding" + } + if sameSubnet(oldAddr.IP, newAddr.IP) { + return "minor_migration" + } + return "full_migration" // 真正的网络切换 +} +``` + +--- + +# 5. 限流细节 + +## 5.1 多层限流 + +``` +连接级: + ├─ 消息频率 (10/s, 突发 30) + ├─ 信令频率 (50/s) + ├─ 入流量带宽 (50KB/s) + └─ 出流量带宽 (100KB/s) + +实例级: + ├─ 总连接数 (100K) + ├─ 建连速率 (1000/s) + └─ 总消息 QPS (50K) + +IP 级 (本地缓存): + ├─ 同 IP 连接数 (50) + └─ 同 IP 建连速率 (10/s) +``` + +## 5.2 令牌桶实现 + +```go +type TokenBucket struct { + capacity int64 + rate int64 // tokens per second + tokens int64 + lastRefill int64 // unix nano + mu sync.Mutex +} + +func (tb *TokenBucket) Allow(n int64) bool { + tb.mu.Lock() + defer tb.mu.Unlock() + + now := time.Now().UnixNano() + elapsed := now - tb.lastRefill + + // 补充令牌 + tb.tokens = min(tb.capacity, tb.tokens + elapsed*tb.rate/1e9) + tb.lastRefill = now + + if tb.tokens >= n { + tb.tokens -= n + return true + } + return false +} +``` + +## 5.3 IP 限流(本地 LRU) + +```go +type IPLimiter struct { + cache *lru.Cache // IP → *TokenBucket + + // 配置 + connRate int64 + connBurst int64 + msgRate int64 + msgBurst int64 +} + +func (l *IPLimiter) AllowConnect(ip string) bool { + bucket := l.getOrCreate(ip + ":conn", l.connRate, l.connBurst) + return bucket.Allow(1) +} + +// 内存: 100万 IP × 100B = 100MB,足够 +// LRU 清理 30 分钟未活跃 IP +``` + +## 5.4 限流响应 + +```protobuf +message ErrorResp { + int32 code = 1; + string message = 2; + int32 retry_after_ms = 3; // 建议重试间隔 +} +``` + +```go +func (c *Connection) sendRateLimited(seqId uint64, retryAfter time.Duration) { + c.Send(&ErrorFrame{ + SeqId: seqId, + Code: ERR_RATE_LIMITED, + Message: "rate limited", + RetryAfterMs: int32(retryAfter.Milliseconds()), + }) +} +``` + +## 5.5 连续超限处置 + +```go +连续 10 次超限 (1 分钟内): + → 触发"风控提示"事件 + → 上报 user.behavior topic + → 服务端可能临时封禁 + +连续 100 次超限: + → 立即关闭连接 + → 1 分钟内拒绝该 IP/User 重连 +``` + +## 5.6 全局保护开关 + +```go +// 通过配置中心(etcd)下发 +type RateLimitConfig struct { + Enabled bool + GlobalQPSLimit int64 + PerUserMsgRate int64 + PerConnMsgRate int64 + EmergencyMode bool // 紧急模式:阈值减半 +} + +// Watch 配置变化 +go g.config.Watch("rate_limit", func(c *RateLimitConfig) { + g.applyRateLimit(c) +}) +``` + +--- + +# 6. 心跳与保活 + +## 6.1 心跳协议 + +``` +客户端 → 服务端: HEARTBEAT (Cmd=0x0003) + 带 client_time, last_recv_seq + +服务端 → 客户端: HEARTBEAT_ACK + 带 server_time, server_msgs_pending +``` + +## 6.2 心跳间隔 + +| 网络环境 | 间隔 | +|---|---| +| WiFi 良好 | 60s | +| 移动网络 | 45s | +| 弱网 | 30s | +| 后台 | 平台限制(iOS ~30min) | + +客户端自适应调整,服务端 60s 超时。 + +## 6.3 服务端检测 + +```go +// 每个 connection 关联心跳定时器 +func (c *Connection) heartbeatLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + elapsed := time.Now().UnixNano() - c.LastHeartbeat + if elapsed > 60*int64(time.Second) { + c.closeWithError(ErrHeartbeatTimeout) + return + } + + case <-c.closeChan: + return + } + } +} +``` + +## 6.4 心跳与 NAT + +``` +NAT 超时通常 30s ~ 5min +心跳间隔 < NAT 超时 / 2 + +iOS 后台限制: + - 普通 socket 几分钟就被回收 + - 用 silent push 唤醒应用 + - 或用 VoIP push(受限) +``` + +## 6.5 携带数据 + +心跳可以 piggyback 一些低优数据: + +```protobuf +message Heartbeat { + int64 client_time = 1; + int64 last_recv_seq = 2; // 已收到的最大 seq + repeated int64 read_reports = 3; // 批量已读 + string typing_in_conv = 4; // 正在输入 +} +``` + +--- + +# 7. 消息路由 + +## 7.1 上行消息(C → S) + +```go +func (g *Gateway) handleSendMsg(c *Connection, req *SendMsgReq) { + // 1. 风控前置 + if g.risk.IsBlocked(c.UserID) { + c.sendErr(ERR_BLOCKED) + return + } + + // 2. 业务校验 + if err := validate(req); err != nil { + c.sendErr(ERR_INVALID) + return + } + + // 3. 转发到 MsgWrite 服务 + ctx, _ := context.WithTimeout(g.ctx, 2*time.Second) + resp, err := g.msgWriteClient.Send(ctx, &SendReq{ + UserID: c.UserID, + DeviceID: c.DeviceID, + ClientMsgID: req.ClientMsgID, + ConvID: req.ConvID, + Content: req.Content, + }) + + // 4. 返回 ACK + if err != nil { + c.sendErr(ERR_INTERNAL) + return + } + c.Send(&SendMsgResp{ + ClientMsgID: req.ClientMsgID, + ServerMsgID: resp.ServerMsgID, + Seq: resp.VisibleSeq, + }) +} +``` + +## 7.2 下行推送(S → C) + +```go +// Deliver 服务调用 Gateway gRPC +service Gateway { + rpc Push(PushReq) returns (PushResp); +} + +func (g *Gateway) Push(ctx context.Context, req *PushReq) (*PushResp, error) { + // 1. 查找连接 + conns := g.connMgr.GetByUser(req.UserID) + if len(conns) == 0 { + return &PushResp{Status: "NOT_FOUND"}, nil + } + + // 2. 按设备过滤 + var targets []*Connection + for _, c := range conns { + if matchDevice(c, req.TargetDevices) { + targets = append(targets, c) + } + } + + // 3. 推送 + success := 0 + for _, c := range targets { + if err := c.Send(req.Frame); err != nil { + continue + } + success++ + } + + if success == 0 { + return &PushResp{Status: "ALL_FAILED"}, nil + } + return &PushResp{Status: "OK", DeliveredTo: int32(success)}, nil +} +``` + +## 7.3 路由失效处理 + +```go +// Deliver 收到 NOT_FOUND/ALL_FAILED: +// → 强制刷新 status shard +// → 若仍无在线 → 进 inbox + push +``` + +--- + +# 8. 多端互踢与会话管理 + +## 8.1 设备类型 + +``` +device_type: "ios" | "android" | "pc" | "web" | "mac" +``` + +## 8.2 互踢规则 + +```go +func shouldKick(old, new *Connection) bool { + if old.DeviceType == new.DeviceType { + // 同类型互踢 + return true + } + + // PC 与 Web 互斥(业务规则) + if (old.DeviceType == "pc" && new.DeviceType == "web") || + (old.DeviceType == "web" && new.DeviceType == "pc") { + return true + } + + return false +} +``` + +## 8.3 踢人流程 + +``` +1. 旧连接发 KICK frame (reason="new_login_same_device") +2. 客户端收到 → 显示"账号在其他设备登录" +3. 旧连接 5s 后自动关闭 +4. 状态服务清理旧设备记录 +``` + +--- + +# 9. 容错与故障处理 + +## 9.1 上游 RPC 故障 + +```go +// 重试策略 +type RetryConfig struct { + MaxAttempts: 2 + InitialBackoff: 50ms + MaxBackoff: 200ms + RetryableErrors: [UNAVAILABLE, DEADLINE_EXCEEDED] +} + +// 熔断 +type CircuitBreaker struct { + Threshold: 50% // 失败率 + Window: 10s + MinRequests: 20 + OpenTimeout: 5s +} +``` + +## 9.2 内存压力 + +``` +连接数 > 80%: 拒绝新连接,返回 SERVICE_UNAVAILABLE +连接数 > 90%: 主动关闭最不活跃的 1% 连接 +内存 > 80%: 触发 GC,警告 +内存 > 90%: 拒绝新连接 +``` + +## 9.3 panic 隔离 + +```go +func (c *Connection) safeDispatch(fn func()) { + defer func() { + if r := recover(); r != nil { + log.Errorf("panic in conn %d: %v", c.ConnID, r) + c.metrics.Panic.Inc() + c.closeWithError(ErrInternal) + } + }() + fn() +} +``` + +## 9.4 优雅关闭 + +```go +func (g *Gateway) Shutdown(ctx context.Context) error { + // 1. 标记 not ready (LB 摘除) + g.health.SetNotReady() + + // 2. 等待 LB 摘除生效 (10s) + time.Sleep(10 * time.Second) + + // 3. 通知所有连接迁移 + g.connMgr.Each(func(c *Connection) { + c.Send(&ReconnectFrame{Reason: "graceful_shutdown"}) + }) + + // 4. 等待客户端切换 (30s) + time.Sleep(30 * time.Second) + + // 5. 强制关闭剩余连接 + g.connMgr.CloseAll() + + // 6. 关闭 listener + g.listener.Close() + + return nil +} +``` + +--- + +# 10. 监控指标 + +## 10.1 关键指标 + +| 指标 | 类型 | 用途 | +|---|---|---| +| `gateway_connections_active` | Gauge | 当前连接数 | +| `gateway_connections_total` | Counter | 总建连数 | +| `gateway_handshake_duration` | Histogram | 握手耗时 | +| `gateway_auth_duration` | Histogram | 鉴权耗时 | +| `gateway_msg_recv_total{cmd}` | Counter | 接收消息数 | +| `gateway_msg_send_total` | Counter | 发送消息数 | +| `gateway_msg_latency` | Histogram | 端到端消息延迟 | +| `gateway_rate_limited_total{reason}` | Counter | 限流触发 | +| `gateway_quic_migration{result}` | Counter | QUIC 迁移结果 | +| `gateway_send_blocked_total` | Counter | 发送背压 | +| `gateway_heartbeat_timeout_total` | Counter | 心跳超时 | +| `gateway_panic_total` | Counter | panic 次数 | + +## 10.2 SLO + +``` +连接成功率 > 99.9% +单机 P99 延迟 < 5ms (网关内部) +QUIC 迁移成功率 > 95% +心跳超时率 < 0.1% +``` + +--- + +# 11. 性能优化 + +## 11.1 内存优化 + +``` +- sync.Pool 复用 frame buffer +- 连接对象池化 +- 避免 string ↔ []byte 转换 +- 使用 unsafe.String (Go 1.20+) +``` + +## 11.2 网络优化 + +``` +- TCP_NODELAY = true(禁 Nagle) +- SO_REUSEPORT 多进程监听 +- 写入合并(5ms flush) +- 零拷贝 sendfile(大对象) +``` + +## 11.3 锁优化 + +``` +- 连接表分桶(256 个 sync.Map) +- 连接级状态用 atomic +- 避免长时间持锁 +- 读多写少用 sync.RWMutex +``` + +## 11.4 GC 调优 + +``` +GOGC=200 (默认 100,降低 GC 频率) +GOMEMLIMIT=24GiB +``` + +--- + +# 12. 部署与配置 + +## 12.1 资源规格 + +```yaml +resources: + cpu: 16 + memory: 32Gi + network: 10Gbps + +limits: + max_connections: 100000 + max_qps_per_conn: 50 +``` + +## 12.2 配置文件 + +```yaml +gateway: + listen: + quic: 0.0.0.0:443 + ws: 0.0.0.0:443 + tls: + cert: /etc/im/tls.crt + key: /etc/im/tls.key + + auth: + timeout: 500ms + cache_ttl: 60s + + heartbeat: + timeout: 60s + + rate_limit: + msg_rate: 10 + msg_burst: 30 + signal_rate: 50 + ip_conn_limit: 50 + ip_conn_rate: 10 + + quic: + cid_pool_size: 8 + max_idle_timeout: 60s + + upstream: + msg_write: msg-write-svc:9090 + auth: auth-svc:9090 + presence: presence-svc:9090 +``` + +## 12.3 健康检查 + +``` +GET /health + - 200: ready + - 503: not_ready (drain) + +GET /metrics (Prometheus) +GET /debug/pprof/ (only internal) +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/02-Storage-Sharding-Design.md b/_drafts/IM/02-Storage-Sharding-Design.md new file mode 100755 index 000000000..d044b66ec --- /dev/null +++ b/_drafts/IM/02-Storage-Sharding-Design.md @@ -0,0 +1,912 @@ +# 消息存储分库分表方案 v1.0 + +> 千万并发 IM 系统的存储分片、迁移、扩容与对账方案 +> 关键词:分片策略 / 在线扩容 / 数据迁移 / 一致性对账 + +## 目录 +1. [设计目标与原则](#1-设计目标与原则) +2. [分片策略](#2-分片策略) +3. [核心表分片设计](#3-核心表分片设计) +4. [中间件选型](#4-中间件选型) +5. [扩容方案](#5-扩容方案) +6. [数据迁移](#6-数据迁移) +7. [对账机制](#7-对账机制) +8. [冷热分离与归档](#8-冷热分离与归档) +9. [备份与恢复](#9-备份与恢复) +10. [跨地域复制](#10-跨地域复制) +11. [性能基线](#11-性能基线) + +--- + +# 1. 设计目标与原则 + +## 1.1 容量目标 + +| 指标 | 目标 | +|---|---| +| 日新增消息 | 20 亿条 | +| 总消息量(3 年) | ~2 万亿 | +| 单消息平均大小 | 500B(含元数据) | +| 总数据量(3 年) | ~1PB | +| 写入峰值 QPS | 50 万 | +| 查询峰值 QPS | 200 万 | +| 单查询 P99 | < 50ms | + +## 1.2 设计原则 + +1. **分片键先行**:每张表必须明确分片键,避免跨片查询 +2. **唯一索引必须包含分片键**:保证唯一性可在单分片内验证 +3. **无 join**:跨分片不做 join,业务层组装 +4. **冷热分离**:30 天内热数据 + 历史数据分层 +5. **可扩容**:使用一致性 hash 或 slot 模型,平滑扩容 +6. **可对账**:定期校验分片间一致性 + +## 1.3 分片维度选择 + +| 业务表 | 分片键 | 理由 | +|---|---|---| +| `im_message` | `conv_id` | 按会话查询是主路径 | +| `mention_index` | `user_id` | 按用户查 @我 是主路径 | +| `inbox` | `user_id` | 按用户拉离线消息 | +| `user_conv_cursor` | `user_id` | 按用户查游标 | +| `conversation_meta` | `conv_id` | 单条记录 | +| `group_member` | `group_id` | 按群查成员 | +| `user` | `user_id` | 按用户查 | +| `outbox_event` | `id` (range) | 顺序消费 | + +--- + +# 2. 分片策略 + +## 2.1 三种主流策略对比 + +| 策略 | 优点 | 缺点 | 适用 | +|---|---|---|---| +| **HASH 取模** | 简单,均匀 | 扩容要 rehash 全部数据 | 不推荐生产 | +| **一致性 HASH** | 扩容只迁移部分 | 实现复杂,热点风险 | 中等规模 | +| **SLOT (类 Redis Cluster)** | 灵活,可手动 rebalance | 需要路由表 | **推荐** | +| **Range** | 范围查询好 | 容易热点 | 时序数据 | + +## 2.2 推荐方案:SLOT 模型 + +``` +1. 全局 16384 个 slot +2. 每个 slot 映射到一个物理分片(库.表) +3. 路由: slot = CRC32(shard_key) % 16384 +4. 物理分片数可变 (16, 32, 64, 128 ...) +5. slot → shard 映射存配置中心 (etcd) +6. 扩容时按 slot 迁移 +``` + +### 路由表示例 + +```yaml +# etcd: /im/sharding/im_message +slots: + - range: [0, 511] → shard: shard_00 + - range: [512, 1023] → shard: shard_01 + - range: [1024, 1535] → shard: shard_02 + ... + - range: [16128, 16383] → shard: shard_31 +shards: + shard_00: + db_host: "mysql-001:3306" + db_name: "im_msg_00" + table_count: 16 # 单库分 16 表 + ... +``` + +### 路由代码 + +```go +func ResolveShard(convID int64) (db, table string) { + slot := crc32.ChecksumIEEE([]byte(strconv.FormatInt(convID, 10))) % 16384 + shard := slotToShard(slot) + + // 库内再分 16 表(按 conv_id 取模) + tableIdx := convID % 16 + + return shard.DBName, fmt.Sprintf("im_message_%02d", tableIdx) +} +``` + +## 2.3 双层分片:分库 + 分表 + +``` +分库: 决定数据落在哪台 MySQL 实例(slot 模型) +分表: 决定数据落在该实例的哪张表 + +例: + 16 个 slot 一组 → 1 个库 + 16 张表 = 1 个库内 + + 64 个分片库 × 16 张表 = 1024 张物理表 + 支持 1024 × 5亿(MySQL推荐上限) = 5000亿行 +``` + +## 2.4 分片键无法覆盖的查询 + +某些查询不走分片键,需要: + +``` +场景: 按 server_msg_id 查消息(撤回、引用) +方案 A: server_msg_id 编码 conv_id(雪花 ID 中预留位) +方案 B: 建二级映射表 server_msg_id → (conv_id, ...) +方案 C: 全分片广播查询(仅低频管理操作) + +推荐 A: 雪花 ID 中嵌入 conv_id 的 hash 信息 +``` + +### 雪花 ID 嵌入分片信息 + +``` +| 1bit 0 | 41bit 时间戳 | 4bit shard_hint | 6bit machine | 12bit seq | + +shard_hint = (conv_id % 16384) >> 10 // 低 4 位作为分片提示 + +通过 server_msg_id 反推时: + shard_hint = (id >> 18) & 0xF + 全分片中只有 1/16 可能匹配 → 缩小搜索范围 +``` + +--- + +# 3. 核心表分片设计 + +## 3.1 `im_message` 消息主表 + +### 物理布局 +``` +分库数: 32 +单库表数: 16 +总表数: 512 +分片键: conv_id + +库命名: im_msg_{00..31} +表命名: im_message_{00..15} +``` + +### 完整定义 + +```sql +CREATE TABLE im_message_00 ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + app_id INT NOT NULL, + conv_id BIGINT NOT NULL, + sender_id BIGINT NOT NULL, + client_msg_id VARCHAR(64) NOT NULL, + server_msg_id BIGINT NOT NULL, + global_seq BIGINT NOT NULL, + visible_seq BIGINT, + msg_type TINYINT NOT NULL, + content JSON NOT NULL, + mention_all TINYINT DEFAULT 0, + mention_count SMALLINT DEFAULT 0, + reply_to_id BIGINT, + status TINYINT DEFAULT 0, + version INT DEFAULT 1, + send_time BIGINT, + created_at BIGINT NOT NULL, + updated_at BIGINT, + + UNIQUE KEY uk_client (app_id, sender_id, conv_id, client_msg_id), + UNIQUE KEY uk_server (server_msg_id), + UNIQUE KEY uk_seq (conv_id, global_seq), + KEY idx_visible (conv_id, visible_seq), + KEY idx_created (conv_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +### 容量估算 + +``` +单表行数: 5 亿(MySQL 推荐上限) +单库表数: 16 → 单库容纳 80 亿行 +总库数: 32 → 总容纳 2560 亿行 +单消息大小: 500B → 总数据 ~125TB + +按日新增 20 亿 / 总 512 表 = 单表日增 ~400 万 +单表寿命: 5 亿 / 400 万 = 125 天 + +→ 30 天后开始归档热数据,单表稳定在 1.2 亿行内 +``` + +## 3.2 `mention_index` @ 索引 + +``` +分库数: 16 +单库表数: 16 +总表数: 256 +分片键: user_id + +为何按 user_id:主查询是"我被 @ 了什么" +``` + +```sql +CREATE TABLE mention_index_00 ( + user_id BIGINT NOT NULL, + conv_id BIGINT NOT NULL, + msg_seq BIGINT NOT NULL, + server_msg_id BIGINT NOT NULL, + sender_id BIGINT NOT NULL, + mention_type TINYINT NOT NULL, + status TINYINT DEFAULT 0, + created_at BIGINT NOT NULL, + + PRIMARY KEY (user_id, conv_id, msg_seq), + KEY idx_user_time (user_id, created_at DESC), + KEY idx_msg (server_msg_id) +) ENGINE=InnoDB; +``` + +## 3.3 `inbox` 离线收件箱 + +``` +分库数: 32 +单库表数: 16 +总表数: 512 +分片键: user_id +``` + +```sql +CREATE TABLE inbox_00 ( + user_id BIGINT NOT NULL, + conv_id BIGINT NOT NULL, + msg_seq BIGINT NOT NULL, + server_msg_id BIGINT NOT NULL, + created_at BIGINT NOT NULL, + + PRIMARY KEY (user_id, conv_id, msg_seq), + KEY idx_user_time (user_id, created_at) +) ENGINE=InnoDB; +``` + +## 3.4 `user_conv_cursor` 游标 + +``` +分库数: 16 +单库表数: 16 +分片键: user_id +``` + +## 3.5 `group_member` 群成员 + +``` +分库数: 16 +分片键: group_id + +注意: 单条记录的另一查询路径"用户在哪些群" + 建二级索引表 user_groups (user_id) 异步维护 +``` + +```sql +CREATE TABLE user_groups ( + user_id BIGINT, + group_id BIGINT, + joined_at BIGINT, + PRIMARY KEY (user_id, group_id) +) PARTITION BY HASH(user_id) PARTITIONS 64; +``` + +## 3.6 `outbox_event` 事件表 + +``` +不分库分表(单库主备) +原因: + - 顺序消费(Worker 按 ID 递增扫描) + - 数��量小(事件发完即归档) + - 单库 QPS 5 万足够 + +容量管理: + - 已发送的 event 24h 后归档 + - 主表保持 < 1000 万行 +``` + +--- + +# 4. 中间件选型 + +## 4.1 选型对比 + +| 中间件 | 类型 | 优点 | 缺点 | +|---|---|---|---| +| **ShardingSphere** | Java SDK/Proxy | 灵活、SQL 兼容 | 需要 SDK 集成 | +| **Vitess** | YouTube 开源 | 生产验证、自动 rebalance | 复杂度高 | +| **TiDB** | 分布式 SQL | 原生分布式、强一致 | 资源占用大 | +| **MyCat** | Proxy | 简单 | 社区一般 | +| **自研路由** | 业务层 | 完全可控 | 维护成本 | + +## 4.2 推荐方案 + +``` +方案 A (推荐): TiDB + - 完全兼容 MySQL 协议 + - 自动分片 (Region) + 自动 rebalance + - 强一致 + 高可用 + - 单消息表无需手动分库分表 + - 适合中大型 IM + +方案 B: MySQL + ShardingSphere + - 成熟度高 + - 团队 MySQL 经验丰富 + - 需手动维护分片 + - 适合存量 MySQL 体系 + +方案 C: 混合 + - 消息热数据: MySQL 分库分表 (写入快) + - 历史归档: HBase / Cassandra (大容量) + - 索引: Elasticsearch (搜索) +``` + +## 4.3 客户端 SDK 路由(自研) + +```go +type ShardingClient struct { + routeMap map[int]*sql.DB // slot → DB + routes *RouteTable // 从 etcd 加载 +} + +func (c *ShardingClient) Query(table string, shardKey int64, sql string, args ...interface{}) { + slot := hash(shardKey) % 16384 + shardName := c.routes.GetShard(table, slot) + db := c.routeMap[shardName] + + realTable := buildTableName(table, shardKey) + realSQL := strings.Replace(sql, "{table}", realTable, -1) + + db.Query(realSQL, args...) +} + +// 监听 etcd 路由变更 +func (c *ShardingClient) WatchRoutes() { + etcdClient.Watch("/im/sharding/", func(events []Event) { + c.routes.Reload() + }) +} +``` + +--- + +# 5. 扩容方案 + +## 5.1 扩容触发条件 + +``` +- 单库存储 > 70% +- 单库 QPS > 设计容量 70% +- 慢查询比例上升 +- CPU 持续 > 70% +``` + +## 5.2 扩容步骤(SLOT 模式) + +``` +当前: 32 库 → 扩容到 64 库 + +阶段 1: 准备 + 1.1 新建 32 个空库 (shard_32 ~ shard_63) + 1.2 配置主从、备份、监控 + +阶段 2: 数据迁移 + 2.1 选择要迁移的 slot 范围 + 原 shard_00 (slots 0-511) 拆分: + slots 0-255 → 留 shard_00 + slots 256-511 → 迁到 shard_32 + 2.2 全量同步 (binlog 起点记录) + 2.3 增量同步 (CDC tail binlog) + 2.4 双写阶段 (新写入两边都写) + 2.5 校验数据一致性 + +阶段 3: 切换 + 3.1 更新 etcd 路由 (slot 256-511 → shard_32) + 3.2 路由热更新(< 5s 收敛) + 3.3 停止旧库的 256-511 slot 写入 + 3.4 删除旧库的 256-511 slot 数据(延迟 7 天) + +阶段 4: 验证 + 4.1 监控新库 QPS / 错误率 + 4.2 对账 +``` + +## 5.3 在线迁移工具链 + +``` +[Source MySQL] + ↓ binlog +[Canal/Debezium] ← CDC 工具 + ↓ Kafka topic +[Migration Worker] + ↓ +[Target MySQL] + ↓ +[Diff Worker] ← 对账 +``` + +## 5.4 双写降级方案 + +``` +状态: + WRITING_BOTH: 新旧双写,读旧 + READING_NEW: 双写,读新(验证期) + STOPPED_OLD: 仅写新 + +切换步骤: + Day 1: WRITING_BOTH (24h 验证) + Day 2: READING_NEW (24h 灰度) + Day 3: STOPPED_OLD + +回滚: + 任何阶段发现问题 → 立即回退到 READING_OLD +``` + +## 5.5 客户端零停机切换 + +```go +// 路由热加载 +func (c *ShardingClient) ReloadRoutes() { + newRoutes := loadFromEtcd() + + // CAS 替换 + atomic.StorePointer(&c.routes, unsafe.Pointer(newRoutes)) + + log.Info("routes reloaded", "version", newRoutes.Version) +} +``` + +--- + +# 6. 数据迁移 + +## 6.1 迁移类型 + +| 类型 | 场景 | +|---|---| +| 扩容迁移 | 增加分片 | +| 降配合并 | 减少分片 | +| 跨地域迁移 | 用户主区域变更 | +| 冷数据归档 | 30 天前数据归档 | +| 跨存储迁移 | MySQL → TiDB / HBase | + +## 6.2 全量 + 增量迁移流程 + +### 步骤 1: binlog 起点记录 + +```sql +-- 在源库执行 +SHOW MASTER STATUS; ++--------------+-----------+ +| File | Position | ++--------------+-----------+ +| binlog.0001 | 12345678 | ++--------------+-----------+ + +-- 记录此 GTID 作为增量起点 +``` + +### 步骤 2: 全量同步 + +```bash +# 工具: mydumper + myloader +mydumper -h source-mysql -u root -p ... \ + --regex 'im_msg_00\.im_message_.*' \ + --rows 100000 \ + -t 16 \ + -o /backup/full/ + +myloader -h target-mysql -u root -p ... \ + -d /backup/full/ \ + -t 16 +``` + +### 步骤 3: 增量同步 + +```yaml +# Canal 配置 +canal.instance.master.address: source-mysql:3306 +canal.instance.master.position: 12345678 +canal.instance.master.journal.name: binlog.0001 +canal.instance.filter.regex: im_msg_00\\.im_message_.* + +# 输出到 Kafka +canal.mq.topic: db_migration_events +``` + +### 步骤 4: 应用层消费 + +```go +func (m *Migrator) Consume(event *BinlogEvent) error { + // 解析 row 事件 + row := parseRow(event) + + // 路由到目标分片 + targetShard := m.targetRoutes.GetShard(row.ConvID) + + // 写入 + return m.writeToTarget(targetShard, row) +} +``` + +### 步骤 5: 数据对账(见第 7 节) + +## 6.3 迁移性能优化 + +``` +- 并发: 按表/分区并行迁移 +- 批量: 1000 行一批 INSERT +- 关键索引后建: 数据导入后再 CREATE INDEX +- 关闭非必要约束: 临时关闭外键检查 +- 限流: 不超过源库 30% IO +- 时段: 业务低峰期 (凌晨 2-6 点) +``` + +## 6.4 大表迁移分段策略 + +``` +单表 5 亿行迁移: + 按主键 ID 分 100 段 + 每段 500 万行 + 10 个并发 worker 同时跑 + 预计耗时: 6-12 小时 +``` + +```sql +-- 分段查询 +SELECT * FROM im_message_00 +WHERE id BETWEEN ? AND ? +ORDER BY id +LIMIT 10000; +``` + +--- + +# 7. 对账机制 + +## 7.1 对账维度 + +| 维度 | 频率 | 工具 | +|---|---|---| +| 行数对账 | 每小时 | SQL COUNT | +| 关键字段对账 | 每天 | Diff 工具 | +| 业务逻辑对账 | 每天 | 自研脚本 | +| 跨地域对账 | 每天 | 全局对账服务 | +| 缓存与 DB | 实时(采样) | 旁路 | + +## 7.2 行数对账(最简) + +```sql +-- 源库 +SELECT COUNT(*) FROM im_message_00 +WHERE created_at BETWEEN ? AND ?; + +-- 目标库 +SELECT COUNT(*) FROM im_message_00 +WHERE created_at BETWEEN ? AND ?; + +-- 一致 → OK +-- 不一致 → 进入字段对账 +``` + +## 7.3 字段对账(CRC 校验) + +```sql +-- 计算分段 checksum +SELECT + COUNT(*) as cnt, + BIT_XOR(CRC32(CONCAT_WS('|', id, conv_id, content, status))) as chk +FROM im_message_00 +WHERE id BETWEEN ? AND ?; +``` + +CRC 一致 → 数据一致;不一致 → 二分查找差异行。 + +## 7.4 业务对账 + +```python +# 关键业务规则 +对账项: + 1. 每会话 visible_seq 是否连续 + SELECT conv_id, COUNT(DISTINCT visible_seq), MAX(visible_seq), MIN(visible_seq) + FROM im_message + WHERE visible_seq IS NOT NULL + GROUP BY conv_id + HAVING COUNT(*) != MAX - MIN + 1 + + 2. 每用户 inbox 与 message 一致 + 用户 U 的 inbox 数 == U 加入会话后的 visible 消息数 + + 3. 撤回消息状态一致 + status=1 的消息必须有 message_recall 记录 + + 4. mention_index 与 message.mentions 一致 + 展开 message.content.mentions 与 mention_index 行数相等 +``` + +## 7.5 对账工具实现 + +```go +type Reconciler struct { + sourceDB *sql.DB + targetDB *sql.DB +} + +func (r *Reconciler) RunCheck(table string, timeRange [2]int64) error { + // 分段对账 + const segmentSize = 100000 + + minID := r.queryMinID(table, timeRange) + maxID := r.queryMaxID(table, timeRange) + + var diffs []DiffRecord + for start := minID; start <= maxID; start += segmentSize { + end := min(start + segmentSize - 1, maxID) + + sourceChecksum := r.checksumSegment(r.sourceDB, table, start, end) + targetChecksum := r.checksumSegment(r.targetDB, table, start, end) + + if sourceChecksum != targetChecksum { + // 详细 diff + diffs = append(diffs, r.detailDiff(table, start, end)...) + } + } + + return r.report(diffs) +} +``` + +## 7.6 自动修复 + +``` +检测到差异: + Level 1 (< 100 行): 自动补写 + Level 2 (100-10K): 通知 SRE,半自动 + Level 3 (> 10K): 告警,人工介入 + +修复策略: + - 以源库为准(迁移期) + - 以最新 updated_at 为准(双向写时) + - 业务规则裁决(如最大 seq 为准) +``` + +--- + +# 8. 冷热分离与归档 + +## 8.1 分层存储 + +``` +┌──────────────────────────────────────────────┐ +│ 热层 (Hot) : 最近 30 天 │ +│ MySQL 主表 : 高 QPS, 低延迟 │ +└──────────────────────────────────────────────┘ + ↓ 归档 +┌──────────────────────────────────────────────┐ +│ 温层 (Warm) : 30 天 - 1 年 │ +│ MySQL 归档表 / TiDB : 中等查询 │ +└──────────────────────────────────────────────┘ + ↓ 归档 +┌──────────────────────────────────────────────┐ +│ 冷层 (Cold) : 1 年以上 │ +│ HBase / OSS : 大容量, 低成本 │ +└──────────────────────────────────────────────┘ +``` + +## 8.2 归档调度 + +``` +每日凌晨 02:00 执行: + 1. 查询 30 天前的数据 + 2. 写入归档表 + 3. 校验 + 4. 删除原表 + +INSERT INTO im_message_archive +SELECT * FROM im_message +WHERE created_at < UNIX_TIMESTAMP() - 30*86400; + +DELETE FROM im_message +WHERE created_at < UNIX_TIMESTAMP() - 30*86400 +LIMIT 10000; -- 分批删,防长事务 +``` + +## 8.3 归档查询 + +```go +func (s *MsgService) GetMessage(serverMsgID int64) (*Message, error) { + // 1. 先查热表 + msg, err := s.queryHot(serverMsgID) + if err == nil { + return msg, nil + } + + // 2. 查归档 + msg, err = s.queryArchive(serverMsgID) + if err == nil { + return msg, nil + } + + // 3. 查冷存储 (HBase) + return s.queryHBase(serverMsgID) +} +``` + +## 8.4 HBase RowKey 设计 + +``` +RowKey: reverse(conv_id) + visible_seq + +为何 reverse: 防止热点(连续 conv_id 集中在一个 region) + +查询: + 按会话查: scan rowkey prefix + 按 ID 查: 走 server_msg_id 二级索引 +``` + +## 8.5 归档保留策略 + +``` +普通用户: 1 年 +VIP / 企业: 3 年 +法律合规: 7 年(部分场景) +彻底删除: 用户主动注销 → 30 天后 GDPR 删除 +``` + +--- + +# 9. 备份与恢复 + +## 9.1 备份策略 + +| 类型 | 频率 | 保留 | 介质 | +|---|---|---|---| +| 全量备份 | 每周 | 4 周 | OSS | +| 增量备份 | 每天 | 7 天 | OSS | +| binlog 备份 | 实时 | 30 天 | OSS | +| 异地备份 | 每天 | 30 天 | 跨区域 OSS | + +## 9.2 备份工具 + +```bash +# 物理备份: xtrabackup +xtrabackup --backup --target-dir=/backup/$(date +%F) \ + --user=backup --password=... + +# 逻辑备份: mydumper +mydumper -h ... --regex '...' -o /backup/dump-$(date +%F)/ + +# binlog 持续上传 +mysqlbinlog --read-from-remote-server ... | gzip | aws s3 cp - s3://... +``` + +## 9.3 恢复演练 + +``` +每季度演练: + 1. 选定备份点 + 2. 在隔离环境恢复 + 3. 验证数据完整性 + 4. 测试业务可用性 + 5. 记录 RTO / RPO + +RTO 目标: + - 单表恢复: < 1 小时 + - 单库恢复: < 4 小时 + - 整集群恢复: < 12 小时 + +RPO 目标: < 1 分钟 +``` + +## 9.4 误删除应对 + +``` +应急流程: + 1. 立即停止写入相关表 (kill 开关) + 2. 确认误删范围 (binlog 分析) + 3. 从最近全备 + binlog 重放到误删前 + 4. 导出受影响数据 + 5. 写回主库 + 6. 业务核对 + +工具: gh-ost (binlog 反向应用) +``` + +--- + +# 10. 跨地域复制 + +## 10.1 复制拓扑 + +``` +华东 (主) 华南 (从) 美西 (从) + │ │ │ + ├── MySQL 主 ─binlog→ MySQL 异步从 ─→ MySQL 异步从 + ├── Redis ──────────→ Redis (双向同步) + └── Kafka ─Mirror→ Kafka ─Mirror→ Kafka +``` + +## 10.2 一致性级别 + +| 数据 | 一致性 | 同步方式 | +|---|---|---| +| 用户主区消息 | 强一致 | 同步写主区 | +| 跨区消息 | 最终一致 | Kafka MirrorMaker (50-200ms) | +| 用户资料 | 最终一致 | binlog 异步 | +| 群成员 | 最终一致 | 主区���准 | +| 在线状态 | 区域内 | 不同步 | +| 配置 | 强一致 | etcd 跨区集群 | + +## 10.3 跨区写冲突处理 + +``` +冲突场景: 用户在两个区都做了"修改昵称" + 时间戳大的胜出 + 或 LWW (Last Write Wins) 策略 + +冲突场景: 用户在两个区都登录 + 按 device_id 区分会话 + 禁止同 device_id 双登 +``` + +## 10.4 网络分区处理 + +``` +华东 ⇸ 华南 网络断: + 双方各自服务(CP 模式: 拒绝跨区操作) + 恢复后: 异步合并 + 冲突按规则裁决 + +监控: + 跨区延迟 > 1s 告警 + 跨区中断 > 30s 触发降级 +``` + +--- + +# 11. 性能基线 + +## 11.1 单实例性能 + +| 操作 | QPS | 延迟 P99 | +|---|---|---| +| 单行 INSERT | 8,000 | 5ms | +| 单行点查 | 30,000 | 2ms | +| 范围扫描 (100 行) | 5,000 | 10ms | +| 单事务(多语句) | 3,000 | 15ms | + +## 11.2 集群整体(32 库) + +| 操作 | QPS | +|---|---| +| 写入 | 25 万 | +| 点查 | 100 万 | +| 范围查询 | 15 万 | + +## 11.3 容量规划公式 + +``` +分库数 = ceil(峰值写 QPS / 单库写 QPS × 安全系数) + = ceil(50万 / 8千 × 2) + = 125 + → 选 128(2 的幂) + + 实际 32 也够用,因为多数库不会同时打满 +``` + +## 11.4 慢查询管控 + +``` +监控: + - slow_query_log 阈值 100ms + - 每日 slow query 数 < 总 QPS 万分之一 + - 慢查询 TOP 10 邮件日报 + +治理: + - 缺索引 → 加索引 (online DDL) + - 不走分片键 → 业务改造 + - 大事务 → 拆分 +``` + +--- + +**文档结束** + +*Version 1.0 | 消息存储分库分表方案* \ No newline at end of file diff --git a/_drafts/IM/02-Storage-Sharding-v1.0_Version4.md b/_drafts/IM/02-Storage-Sharding-v1.0_Version4.md new file mode 100755 index 000000000..0deb215f8 --- /dev/null +++ b/_drafts/IM/02-Storage-Sharding-v1.0_Version4.md @@ -0,0 +1,578 @@ +# 消息存储分库分表方案 v1.0 + +> 适用:消息主表 / 提及索引 / 收件箱 / 游标 等核心表 +> 目标:单表 5 亿行内、查询 P99 < 50ms、平滑扩容 + +--- + +## 目录 + +1. 分片策略 +2. 分片键选型 +3. 路由层设计 +4. 扩容方案 +5. 数据迁移流程 +6. 对账机制 +7. 冷热分离 +8. 跨分片查询 +9. 备份与恢复 + +--- + +# 1. 分片策略 + +## 1.1 总体方案 + +| 表 | 分片键 | 分库 | 分表 | 总分片 | +|---|---|---|---|---| +| `im_message` | `conv_id` | 32 | 16 | 512 | +| `mention_index` | `user_id` | 32 | 16 | 512 | +| `user_conv_cursor` | `user_id` | 32 | 16 | 512 | +| `inbox` | `user_id` | 32 | 16 | 512 | +| `group_member` | `group_id` | 16 | 16 | 256 | +| `conversation_meta` | `conv_id` | 16 | 8 | 128 | +| `outbox_event` | `id` (range) | 4 | - | 4 | + +## 1.2 分片函数 + +```python +def shard_route(table_name, shard_key): + if table_name == "im_message": + db_idx = hash(shard_key) % 32 + tbl_idx = (hash(shard_key) >> 8) % 16 + return f"db_msg_{db_idx:02d}.im_message_{tbl_idx:02d}" + + if table_name == "mention_index": + db_idx = hash(shard_key) % 32 + tbl_idx = (hash(shard_key) >> 8) % 16 + return f"db_mention_{db_idx:02d}.mention_index_{tbl_idx:02d}" + + # ... +``` + +注意:**db_idx 与 tbl_idx 用 hash 的不同部分**,避免 db 内分布不均。 + +## 1.3 hash 函数选择 + +``` +推荐: CRC32 / MurmurHash3 +不推荐: hash() (Python 内置不稳定)、MD5 (慢) + +要求: + - 均匀分布 + - 跨语言一致(Java/Go/Python 同结果) + - 速度快 +``` + +--- + +# 2. 分片键选型 + +## 2.1 选型原则 + +``` +1. 主查询路径必须命中分片键 (避免跨分片) +2. 写入分布均匀 (避免热点) +3. 同一业务实体的数据落同一分片 (便于事务/join) +4. 长期稳定 (不会频繁变更) +``` + +## 2.2 各表分片键决策 + +### `im_message` → `conv_id` +- ✅ 主查询:按会话拉历史 / 按 seq 范围 +- ✅ 同会话所有消息一起,便于按 seq 查询 +- ✅ 唯一键 `(conv_id, global_seq)` 仅在分片内验证 +- ⚠️ 单会话热点(万人群)→ 用大群独立 topic 缓解 + +**不选 user_id**:群消息会写一份,但用户查群历史要跨分片 +**不选 server_msg_id**:跨系统引用方便,但会话内查询要扫全表 + +### `mention_index` → `user_id` +- ✅ 主查询:"我被@的消息" `WHERE user_id=?` +- ✅ 用户级查询永远落单分片 +- ⚠️ 一条消息@N人 → 写N个不同分片(可接受,N 通常 < 10) + +### `user_conv_cursor` → `user_id` +- ✅ 用户上线拉所有会话游标 +- ✅ 单用户分片局部性好 + +### `inbox` → `user_id` (recipient) +- ✅ 用户上线拉收件箱 +- ⚠️ 写入时一条群消息要给 N 个用户写 inbox(多分片) + +### `group_member` → `group_id` +- ✅ 群消息发送时拉成员列表 +- ⚠️ "用户加入哪些群"是次要查询,用辅助表 + +## 2.3 复合分片键 + +某些表分片键不够稳定,用组合: + +``` +user_session 表: + 分片键 = hash(user_id, app_id) + +防止单用户跨 app 数据失衡 +``` + +--- + +# 3. 路由层设计 + +## 3.1 应用层路由 + +推荐:**应用代码自己计算分片**,不依赖中间件代理。 + +```java +public class ShardRouter { + private final int dbCount; + private final int tblCount; + + public Shard route(String table, Object shardKey) { + long hash = hashFunction(shardKey); + int dbIdx = (int)(hash % dbCount); + int tblIdx = (int)((hash >>> 8) % tblCount); + return new Shard(table, dbIdx, tblIdx); + } +} + +// 使用 +Shard s = router.route("im_message", convId); +String sql = "INSERT INTO " + s.fullTable() + " (...) VALUES (...)"; +db.connect(s.dbName()).execute(sql); +``` + +## 3.2 中间件代理(备选) + +ShardingSphere / Vitess / TDDL: +- 优点:业务无感 +- 缺点:性能损耗、复杂查询易踩坑、运维成本高 + +**推荐应用层路由**,简单直接。 + +## 3.3 路由元数据 + +```yaml +shard_config: + im_message: + db_count: 32 + tbl_count: 16 + db_pattern: "db_msg_%02d" + tbl_pattern: "im_message_%02d" + nodes: + db_msg_00: + master: 10.0.1.10:3306 + slaves: + - 10.0.1.11:3306 + - 10.0.1.12:3306 + # ... +``` + +存放在配置中心(etcd),变更时 Watch 更新。 + +## 3.4 连接池 + +``` +单业务节点连接池 = N (业务实例数) × M (每节点连接数) +建议: M = 10 ~ 20 + +64 业务节点 × 32 DB × 10 连接 = 20480 连接 +单 DB 接收连接: 64 × 10 = 640 (可控) +``` + +--- + +# 4. 扩容方案 + +## 4.1 扩容时机 + +``` +触发条件 (任一): + - 单库存储 > 70% 容量 + - 单库 QPS > 80% 容量 + - 预测 6 个月内会满 +``` + +## 4.2 扩容方式 + +### 方式 A:双倍扩容(推荐) + +``` +原: 32 库 × 16 表 = 512 分片 +新: 64 库 × 16 表 = 1024 分片 + +迁移规则: + 原 db_msg_00 → 拆分为 db_msg_00 + db_msg_32 + 按 hash 高位决定数据归属 +``` + +**优点**:迁移规则简单,hash 一致性好 + +### 方式 B:增加分表数 + +``` +原: 32 库 × 16 表 = 512 +新: 32 库 × 32 表 = 1024 + +只在库内增加表,不跨库迁移 +``` + +**优点**:迁移代价小(库内 INSERT...SELECT) +**缺点**:库容量瓶颈未解 + +### 方式 C:一致性哈希 + +``` +路由用 jump consistent hash +扩容时只迁移 1/N 数据 +``` + +**优点**:迁移最少 +**缺点**:分片号不连续,运维复杂 + +**推荐方式 A**,运维清晰。 + +## 4.3 扩容前置条件 + +``` +[ ] 新硬件资源就绪 +[ ] 测试环境演练通过 +[ ] 回滚预案准备 +[ ] 业务低峰期进行 +[ ] 通知相关方 +``` + +--- + +# 5. 数据迁移流程 + +## 5.1 双写迁移(推荐) + +``` +阶段 1: 准备 + - 部署新分片 + - 路由层支持双写 + +阶段 2: 双写 + - 业务同时写老分片 + 新分片 + - 新��据进入两边 + - 持续 N 天确认稳定 + +阶段 3: 历史数据迁移 + - 启动迁移任务,按时间段批量复制 + - 用主键范围分页(避免锁表) + - 速率控制(不影响生产) + +阶段 4: 数据校验 + - 对账(见第 6 节) + - 修复差异 + +阶段 5: 切读 + - 灰度切读(1% → 10% → 100%) + - 老分片仍双写 + +阶段 6: 停老 + - 停老分片写入 + - 保留 30 天 + - 删除老数据 +``` + +## 5.2 迁移工具 + +```python +def migrate_table(src_shard, dst_router, batch_size=1000): + last_id = 0 + while True: + rows = src_shard.query( + "SELECT * FROM im_message WHERE id > %s ORDER BY id LIMIT %s", + last_id, batch_size + ) + if not rows: + break + + for row in rows: + dst_shard = dst_router.route("im_message", row.conv_id) + dst_shard.upsert(row) + + last_id = rows[-1].id + + # 限速 + time.sleep(0.01) # 100 batch/s = 100k rows/s + + # 进度 + progress.report(last_id) +``` + +## 5.3 迁移性能 + +``` +单线程: ~10K rows/s +多线程: 按分片并行,N × 10K +全量迁移 100 亿行: 100亿 / 100K/s = 100,000 秒 ≈ 28 小时 +``` + +实际通过分片并行可压缩到 6~12 小时。 + +## 5.4 数据校验 + +```python +def verify_shard(src, dst, sample_rate=0.01): + """抽样校验""" + total = src.query("SELECT COUNT(*) FROM ...") + sample_size = int(total * sample_rate) + + samples = src.query( + "SELECT * FROM ... ORDER BY RAND() LIMIT %s", + sample_size + ) + + diffs = [] + for row in samples: + dst_row = dst.query("SELECT * FROM ... WHERE id = %s", row.id) + if not row_equal(row, dst_row): + diffs.append(row.id) + + return diffs +``` + +--- + +# 6. 对账机制 + +## 6.1 对账类型 + +### 实时对账 +``` +双写后: 比较两边的 server_msg_id 集合 +差异 → 自动修复 + 告警 +``` + +### 定时对账 +``` +每小时 / 每天: + 1. 抽样校验 + 2. 边界数据校验(最近 1 小时) + 3. 关键 ID 范围校验 + +每周: + 全量哈希校验(按分片) +``` + +## 6.2 对账实现 + +```sql +-- 老分片 +SELECT + DATE_FORMAT(created_at, '%Y-%m-%d %H') as hour_bucket, + COUNT(*) as cnt, + SUM(server_msg_id) as checksum -- 简单校验和 +FROM im_message_old +WHERE conv_id BETWEEN 0 AND 1000 +GROUP BY hour_bucket; + +-- 新分片 +SELECT + DATE_FORMAT(created_at, '%Y-%m-%d %H') as hour_bucket, + COUNT(*) as cnt, + SUM(server_msg_id) as checksum +FROM im_message_new +WHERE conv_id BETWEEN 0 AND 1000 +GROUP BY hour_bucket; + +-- 对比 +``` + +## 6.3 差异处理 + +``` +策略: + 1. 老分片有,新分片无 → 补写新分片 + 2. 老分片无,新分片有 → 调查原因(双写时机) + 3. 两边都有但内容不同 → 以老为准(更可靠) +``` + +## 6.4 在线对账(持续) + +``` +binlog 订阅: + 老分片 binlog → 对账服务 + 对账服务 → 查新分片 → 比对 + 差异 → 告警/修复 +``` + +--- + +# 7. 冷热分离 + +## 7.1 分级策略 + +| 时间 | 存储 | 查询模式 | +|---|---|---| +| 0~7 天 | MySQL 热表 | 实时查询 | +| 7~30 天 | MySQL 温表 | 偶尔查询 | +| 30~365 天 | MySQL 冷表 / TiKV | 历史回溯 | +| > 1 年 | HBase / OSS | 极少查询 | + +## 7.2 冷热分离实现 + +### 方式 A:分区表 +```sql +CREATE TABLE im_message ( + ... +) PARTITION BY RANGE (TO_DAYS(created_at)) ( + PARTITION p_recent VALUES LESS THAN (TO_DAYS('2026-05-01')), + PARTITION p_old VALUES LESS THAN (TO_DAYS('2026-04-01')), + PARTITION p_archive VALUES LESS THAN MAXVALUE +); +``` + +### 方式 B:独立表 + 归档任务 +``` +im_message_hot (近 30 天) +im_message_archive (> 30 天) + +定时任务每天凌晨: + INSERT INTO archive SELECT FROM hot WHERE created_at < NOW() - 30 DAY + DELETE FROM hot WHERE created_at < NOW() - 30 DAY +``` + +### 方式 C:冷数据导出 HBase +``` +每周: + 按 conv_id 把 1 年前数据导出到 HBase + RowKey: reverse(conv_id) + global_seq + MySQL 删除 +``` + +## 7.3 查询路由 + +```python +def query_messages(conv_id, since_seq, limit): + # 优先查热表 + rows = hot_table.query(...) + + if len(rows) < limit: + # 不够,查冷表 + cold_rows = cold_table.query(...) + rows.extend(cold_rows) + + if len(rows) < limit and need_archive: + # 仍不够,查归档 + archive_rows = hbase.query(...) + rows.extend(archive_rows) + + return rows +``` + +--- + +# 8. 跨分片查询 + +## 8.1 避免跨分片 + +设计时让 95% 查询命中分片键。 + +## 8.2 必要的跨分片场景 + +### 场景 1:用户查所有会话 +``` +SELECT DISTINCT conv_id FROM user_conv_cursor +WHERE user_id = ? + +→ 命中 user_id 分片,单库查询 +``` + +### 场景 2:管理后台查询 +``` +SELECT * FROM im_message WHERE created_at > ? +→ 跨所有分片 + +实现: scatter-gather + - 并行查每个分片 + - 应用层合并、排序、分页 +``` + +### 场景 3:全文搜索 +``` +不走 MySQL,走 ES +ES 索引按时间分片 +``` + +## 8.3 scatter-gather 实现 + +```python +def query_all_shards(table, condition, limit): + futures = [] + for shard in all_shards(table): + futures.append( + executor.submit(shard.query, condition, limit) + ) + + results = [] + for f in futures: + results.extend(f.result()) + + # 全局排序(按业务字段) + results.sort(key=lambda x: x.created_at, reverse=True) + + return results[:limit] +``` + +**注意**:跨分片分页有性能陷阱,深分页时每个分片都要返回大量数据。 +解决:用游标分页(按 created_at + id)。 + +--- + +# 9. 备份与恢复 + +## 9.1 备份策略 + +``` +全量: 每周 1 次(周日凌晨) +增量: 每 5 分钟 binlog 同步 + +存储: + 本地 SSD: 7 天 + 对象存储: 90 天 + 异地: 灾备中心 +``` + +## 9.2 备份方式 + +``` +mysqldump (小库): + mysqldump --single-transaction --master-data=2 ... + +xtrabackup (大库): + innobackupex --slave-info /backup/ + +物理备份 + binlog 增量: + RPO = 5 分钟 + RTO = 30 分钟 +``` + +## 9.3 恢复流程 + +``` +1. 选择恢复点 +2. 恢复全量备份到新实例 +3. apply binlog 到目标点 +4. 数据校验 +5. 切换业务 +``` + +## 9.4 误删除恢复 + +``` +binlog 闪回: + - 解析 binlog 找到 DELETE 事件 + - 反向生成 INSERT + - 在原库执行 + +时间窗口: binlog 保留期 (7 天) +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/03-SRE-Operations-Manual.md b/_drafts/IM/03-SRE-Operations-Manual.md new file mode 100755 index 000000000..6c51a88c8 --- /dev/null +++ b/_drafts/IM/03-SRE-Operations-Manual.md @@ -0,0 +1,1034 @@ +# SRE 运维手册 v1.0 + +> 千万并发 IM 系统 - 故障 Runbook、监控大盘、应急预案 +> 关键词:可观测 / 应急响应 / 容量管理 / 故障演练 + +## 目录 +1. [SRE 工作框架](#1-sre-工作框架) +2. [监控大盘](#2-监控大盘) +3. [告警规范](#3-告警规范) +4. [常见故障 Runbook](#4-常见故障-runbook) +5. [应急响应流程](#5-应急响应流程) +6. [容量管理](#6-容量管理) +7. [变更管理](#7-变更管理) +8. [故障演练](#8-故障演练) +9. [应急工具箱](#9-应急工具箱) +10. [复盘与改进](#10-复盘与改进) + +--- + +# 1. SRE 工作框架 + +## 1.1 SLI/SLO 体系 + +``` +SLI (Service Level Indicator): 实际度量值 +SLO (Service Level Objective): 目标值 +SLA (Service Level Agreement): 对外承诺 +``` + +### 核心 SLO + +| 服务 | SLI | SLO | 错误预算 | +|---|---|---|---| +| 接入可用性 | 连接成功率 | 99.95% | 21min/月 | +| 消息送达率 | 端到端成功率 | 99.99% | 4min/月 | +| 消息延迟 | P99 (同区) | < 500ms | 见监控 | +| 消息丢失率 | 丢失 / 总数 | < 0.001% | - | +| 数据持久性 | 数据丢失率 | 99.999999% | - | + +### 错误预算策略 + +``` +本月已用 70% 预算 → 暂停非关键变更 +本月已用 100% 预算 → 全面冻结,专注稳定性 +连续 3 月达标 → 可以加速创新 +``` + +## 1.2 工程角色与职责 + +| 角色 | 职责 | +|---|---| +| On-Call SRE | 7×24 应急响应、值班 | +| 服务负责人 | 服务设计、容量规划 | +| Tech Lead | 架构决策、跨服务协调 | +| 安全工程师 | 安全事件、合规审计 | +| 数据库 DBA | 数据层运维、备份恢复 | + +--- + +# 2. 监控大盘 + +## 2.1 大盘分层 + +``` +┌────────────────────────────────────────┐ +│ Tier 1: 业务大盘 (CEO/PM 视角) │ +│ DAU, 消息总量, 收入, 投诉 │ +├────────────────────────────────────────┤ +│ Tier 2: SLO 大盘 (SRE 主视图) │ +│ SLI 实时值, 错误预算, 趋势 │ +├────────────────────────────────────────┤ +│ Tier 3: 服务大盘 (每个服务一个) │ +│ QPS, 延迟, 错误率, 资源 │ +├────────────────────────────────────────┤ +│ Tier 4: 基础设施大盘 │ +│ DB, Redis, Kafka, K8s, 网络 │ +└────────────────────────────────────────┘ +``` + +## 2.2 SLO 大盘(核心) + +### 关键面板 + +``` +┌──────────────────────────────────────┐ +│ 消息送达率 (last 1h) │ +│ ████████████░ 99.991% │ +│ 目标: 99.99% ✅ │ +├──────────────────────────────────────┤ +│ 消息延迟 P50/P99/P999 │ +│ P50: 45ms P99: 380ms │ +│ P999: 850ms │ +├──────────────────────────────────────┤ +│ 错误预算 (本月剩余) │ +│ ████████████░ 78% │ +├─────────────────────────────────────��┤ +│ 按服务错误率 │ +│ Gateway: 0.001% │ +│ MsgWrite: 0.005% │ +│ Deliver: 0.012% ⚠️ │ +└──────────────────────────────────────┘ +``` + +## 2.3 Gateway 监控 + +| 指标 | 单位 | 告警阈值 | +|---|---|---| +| 在线连接数 | 万 | > 80% 容量 | +| 新连接 QPS | qps | > 5K/s | +| 上行消息 QPS | qps | > 50K/实例 | +| 下行投递 QPS | qps | > 100K/实例 | +| TLS 握手 P99 | ms | > 200 | +| 心跳超时率 | % | > 0.1% | +| QUIC 迁移成功率 | % | < 95% | +| CPU 使用率 | % | > 75% | +| 内存使用率 | % | > 80% | +| 网络流入 | Mbps | > 80% 网卡 | + +### 关键 PromQL 示例 + +```promql +# 在线连接数 +sum(gateway_active_connections) by (region) + +# 消息延迟 P99 +histogram_quantile(0.99, + sum(rate(msg_e2e_latency_bucket[1m])) by (le, region)) + +# 错误率 +sum(rate(gateway_request_total{code!~"2.."}[1m])) +/ sum(rate(gateway_request_total[1m])) +``` + +## 2.4 数据库监控 + +``` +MySQL/TiDB: + - QPS / TPS + - 慢查询数 + - 连接数 / 连接池等待 + - 主从延迟 (binlog lag) + - InnoDB 缓冲池命中率 + - 锁等待 / 死锁 + - 磁盘 IOPS / 使用率 + +Redis: + - QPS / 连接数 + - 内存使用率 / 淘汰数 + - 命中率 + - 慢查询 + - 主从延迟 + - cluster slots ok + +Kafka: + - Broker 状态 + - Topic Partition Lag (按 consumer group) + - ISR shrink + - Under-Replicated Partitions + - 磁盘使用率 + - Producer / Consumer QPS +``` + +## 2.5 业务大盘 + +``` +日活/月活: DAU/MAU 趋势 +消息量: 按 1min/5min/1h 聚合 +新增用户: 注册量 +群活跃: 活跃群数 +异常事件: 风控封禁数 / 客诉数 +推送送达: APNs/FCM 成功率 +存储增长: GB/天 +``` + +## 2.6 大盘工具栈 + +``` +数据采集: Prometheus + node_exporter + 业务 SDK +日志: ELK / Loki +追踪: OpenTelemetry + Jaeger / Tempo +可视化: Grafana +告警: AlertManager + 自研告警平台 +事件管理: PagerDuty / 自研 +``` + +--- + +# 3. 告警规范 + +## 3.1 告警分级 + +| 级别 | 含义 | 响应 SLA | 通知方式 | +|---|---|---|---| +| **P0** | 业务大面积不可用 | 5 min | 电话 + 短信 + IM + 邮件 | +| **P1** | 部分功能不可用 | 15 min | 短信 + IM + 邮件 | +| **P2** | 性能下降,未影响 SLO | 1 h | IM + 邮件 | +| **P3** | 趋势异常,需关注 | 4 h | 邮件 | +| **P4** | 信息提醒 | - | 仅记录 | + +## 3.2 告警规则模板 + +```yaml +# Prometheus AlertManager +groups: +- name: im_critical + rules: + - alert: MessageDeliveryRateCritical + expr: | + (sum(rate(msg_delivery_success[5m])) + / sum(rate(msg_delivery_total[5m]))) < 0.999 + for: 2m + labels: + severity: P0 + service: deliver + annotations: + summary: "消息送达率跌破 99.9%" + runbook: "https://wiki.../runbook/msg-delivery" + + - alert: GatewayConnectionDrop + expr: | + delta(gateway_active_connections[1m]) < -10000 + for: 1m + labels: + severity: P0 + annotations: + summary: "Gateway 连接数 1 分钟掉 1 万+" +``` + +## 3.3 告警规范 + +``` +✅ 必须: + - 描述清晰(一眼看出是什么问题) + - 关联 Runbook 链接 + - 有所有者(service owner) + - 可操作(不是"CPU 60%"这种没用的) + +❌ 禁止: + - 单纯阈值告警没有 for 条件 + - 重复告警(多个规则同一问题) + - "万一可能"型告警 + - 没人响应的告警 +``` + +## 3.4 告警治理 + +``` +每周告警 review: + - 误报率 > 30% → 调整规则 + - 噪音告警 → 合并或删除 + - 漏报案例 → 补充规则 + - 响应时长 → 优化 Runbook +``` + +--- + +# 4. 常见故障 Runbook + +> 每个 Runbook 必须包含:现象 → 影响 → 排查步骤 → 应急处置 → 根因记录 + +## 4.1 Gateway 大量掉线 + +### 现象 +- `gateway_active_connections` 短时间下跌 +- 客户端报"连接断开" +- 重连风暴 + +### 排查步骤 + +``` +1. 看告警源: + - 哪些实例掉?(单实例 / 一区域 / 全部) + +2. 看实例状态: + kubectl get pods -l app=gateway + +3. 看实例日志: + kubectl logs gateway-xxx --tail=100 + 关键字: panic, OOM, connection refused + +4. 看 LB 状态: + - 健康检查通过率 + - 是否摘除节点 + +5. 看上游依赖: + - 业务服务是否健康 + - Redis 是否正常 + - DB 是否正常 + +6. 看资源: + - CPU / 内存 / 网络是否打满 +``` + +### 应急处置 + +``` +情况 A: 单实例 OOM/Panic + → K8s 自动重启 + → 客户端重连到其他实例 + → 排查代码问题,下次发布修复 + +情况 B: 一个 AZ 网络故障 + → LB 自动摘除该 AZ + → DNS 切流到其他 AZ + → 等待网络恢复 + +情况 C: 全部实例打满 + → 扩容 (kubectl scale) + → 临时调高单实例容量 + → 启用排队接入 + +情况 D: 重连风暴 + → 配置中心下发"接入限流"开关 + → Dispatcher 按比例放量 + → 逐步恢复 +``` + +### 根因记录模板 + +```markdown +## 故障:Gateway 大量掉线 +- 时间:2026-XX-XX HH:MM +- 持续:N 分钟 +- 影响:M 用户掉线 +- 根因:... +- 改进:... +``` + +## 4.2 消息延迟突增 + +### 现象 +- 消息延迟 P99 > 1s 持续 +- 用户反馈"消息发不出" +- 客户端显示"发送中"长时间 + +### 排查链路 + +``` +1. 全链路 Trace 找瓶颈: + client → gateway → msg-write → DB → kafka → deliver + ↑ + 看哪一段最慢 + +2. 常见瓶颈: + a. DB 慢查询 → 看 slow_log + b. Redis 卡 → 看 INFO slowlog + c. Kafka 积压 → 看 consumer lag + d. 网络抖动 → 看 RTT + e. GC 长停顿 → 看 GC 日志 +``` + +### 应急处置 + +``` +DB 慢: + - 临时杀死慢 query: KILL + - 切到只读副本 + - 启用查询缓存 + +Kafka 积压: + - 扩容 consumer + - 提高 batch size + - 临时降级低优 topic + +Redis 卡: + - 关闭非必要查询(如已读回执) + - 切换到本地缓存 +``` + +## 4.3 消息丢失 + +### 现象 +- 用户反馈"消息没收到" +- `msg_loss_rate` > 0.001% +- 客户端 `client_msg_id` 在服务端查不到 + +### 排查步骤 + +``` +1. 拿到 client_msg_id 和时间 +2. 查 Gateway 日志: + - 是否到达 Gateway? +3. 查 MsgWrite 日志: + - 是否写库成功? +4. 查 DB: + - 是否真的有这条记录? +5. 查 Outbox: + - 是否进入 outbox? +6. 查 Kafka: + - 是否被 produce / consume? +7. 查 Deliver/Inbox: + - 是否成功投递 / 写 inbox? +``` + +### 常见根因 + +``` +- 客户端没真的发出(网络问题) +- Gateway 限流丢弃但客户端未感知 +- DB 写入成功但 Outbox 失败(应用层 bug) +- Kafka 消息被错误删除 +- 接收端 inbox 写失败但被吞掉 +- 多端同步逻辑 bug +``` + +### 应急处置 + +``` +小范围: + - 人工补偿(从其他副本/日志重发) + +大范围(严重事故): + - 立即冻结写入 + - 启动应急通道 + - 从 Kafka / binlog 重放 + - 数据修复后恢复 +``` + +## 4.4 Redis 集群异常 + +### 现象 +- Redis 命中率突降 +- Redis 报错激增 +- 业务依赖 Redis 的接口超时 + +### 处置 + +``` +1. 看节点状态: + redis-cli --cluster check redis-host:port + +2. 主从切换: + - 自动切换是否成功 + - 失败 → 手动 cluster failover + +3. 内存压力: + - INFO memory + - 清理大 key (SCAN + DEL) + - 调整 maxmemory-policy + +4. 慢查���: + - SLOWLOG GET 100 + - 找出热 key + +5. 网络问题: + - 看 PING 延迟 + - 看连接数 + +6. 兜底: + - 业务降级到本地缓存 + - 关闭非核心 Redis 依赖 +``` + +## 4.5 Kafka 严重 Lag + +### 现象 +- consumer group lag > 10 万 +- 消息延迟激增 +- 下游处理不过来 + +### 处置 + +``` +1. 找出 lag 最严重的 topic 和 partition: + kafka-consumer-groups.sh --describe \ + --group cg-deliver-normal + +2. 分析原因: + a. 消费者太少 → 扩容 + b. 单条处理慢 → 优化代码 / 加并发 + c. 单 partition 热点 → 加盐 / 分流 + d. 下游卡 → 修下游 + e. Broker 慢 → 看 broker 监控 + +3. 临时方案: + - 临时跳过不重要消息(修改 offset) + - 启用 backup consumer 池 + - 关闭低优 consumer 让位 + +4. 紧急丢弃: + - 极端情况: 重置 offset 到 latest + - 仅适用于"过期就没意义"的消息(如 typing) +``` + +## 4.6 数据库主从延迟 + +### 现象 +- `Seconds_Behind_Master` > 60s +- 读从库的业务出现"幻读" + +### 处置 + +``` +1. 看从库状态: + SHOW SLAVE STATUS\G + +2. 常见原因: + a. 大事务 → 拆分 + b. 从库 IO 满 → 升级硬件 + c. 网络问题 → 排查 + d. 锁等待 → 杀死阻塞 + +3. 应急: + - 业务读切回主库 + - 关闭非核心读 + - 等待追上后切回 +``` + +## 4.7 跨区网络故障 + +### 现象 +- 跨区延迟激增 +- MirrorMaker lag +- 跨区调用超时 + +### 处置 + +``` +1. 确认网络问题: + - traceroute 跨区 + - 联系网络团队 + +2. 业务降级: + - 跨区调用改为最终一致 + - 关闭非必要跨区同步 + +3. 流量切换: + - DNS 切到健康区 + - 用户重连本地区 +``` + +## 4.8 Push 大面积失败 + +### 现象 +- APNs/FCM 成功率突降 +- 用户反馈"收不到推送" + +### 处置 + +``` +1. 看哪个厂商: + - APNs / FCM / 华为 / 小米 + +2. 厂商状态页: + - https://developer.apple.com/system-status/ + +3. 切换通道: + - 自动切到备用厂商 + - 失败的进入重试队列 + +4. 异常 token: + - 批量 token 失效 → 客户端重新注册 +``` + +--- + +# 5. 应急响应流程 + +## 5.1 故障响应阶段 + +``` +[发现] → [评估] → [止损] → [修复] → [复盘] + ↓ ↓ ↓ ↓ ↓ + 5 min 5 min 立即 根据 P 级 24h 内 +``` + +## 5.2 故障应急流程图 + +``` +告警触发 + ↓ +On-Call SRE 接警 + ↓ +1. ��认告警是否真实 (5 min) + ↓ +2. 评估影响范围与级别 + ↓ +3. 是否需要升级? + ├ P0/P1 → 立即拉群(IC + Tech Lead) + └ P2/P3 → SRE 自行处理 + ↓ +4. 按 Runbook 处置 + ↓ +5. 沟通透明: + - 客服群同步状态 + - 状态页更新 + - 内部群通报 + ↓ +6. 故障恢复验证 + ↓ +7. 复盘 (24-72h 内) +``` + +## 5.3 战时模式(IC 模式) + +``` +P0 故障启动战时模式: + IC (Incident Commander): 统筹指挥 + Ops Lead: 实际操作 + Comms Lead: 对外沟通 + Scribe: 记录时间线 + +会议室或 Zoom 持续在线 +所有操作必须在群里通报 +``` + +## 5.4 故障沟通模板 + +``` +【故障通报 #001】2026-XX-XX HH:MM +现象: XXX 服务 XX% 用户受影响 +影响: 无法发送消息 / 部分群组 +范围: 华东区域 +状态: 定位中 / 处置中 / 已恢复 +预计恢复: 30 分钟内 +负责人: @xxx +下次更新: HH:MM +``` + +## 5.5 客户沟通规范 + +``` +对外发声原则: + - 不要技术细节(敏感) + - 透明承认问题 + - 明确恢复时间 + - 致歉 + +模板: + "您好,我们检测到部分用户出现 XXX 问题, + 工程师正在紧急处理,预计 XX 分钟内恢复。 + 给您带来的不便深表歉意。" +``` + +--- + +# 6. 容量管理 + +## 6.1 容量基线 + +每月更新一次容量基线: + +```yaml +gateway: + max_capacity: 100K conns/instance + current_peak: 75K + utilization: 75% + next_action: monitor (>80% 加机器) + +mysql_msg: + max_qps: 8K/instance + current_peak: 5.5K + utilization: 69% + +kafka: + max_throughput: 500MB/s + current_peak: 300MB/s + utilization: 60% +``` + +## 6.2 容量预测 + +```python +# 月度容量评估 +预计 3 月后: + DAU 增长: 1.2x + 消息量增长: 1.5x (单用户活跃度提升) + → 需要扩容: Gateway + MsgWrite + Inbox + +行动项: + - 提前 1 月采购硬件 + - 准备扩容预案 + - 压测验证 +``` + +## 6.3 扩容触发 + +| 资源 | 黄线 (规划扩) | 橙线 (执行扩) | 红线 (紧急扩) | +|---|---|---|---| +| 连接数 | 70% | 80% | 90% | +| QPS | 70% | 80% | 90% | +| 存储 | 70% | 80% | 95% | +| Kafka 磁盘 | 70% | 80% | 90% | +| Redis 内存 | 70% | 80% | 90% | + +## 6.4 压测演练 + +``` +季度压测: + - 模拟峰值流量 1.5 倍 + - 全链路压测 + - 验证扩容方案 + - 验证降级方案 + +工具: 自研压测平台 / k6 / locust +``` + +--- + +# 7. 变更管理 + +## 7.1 变更分级 + +| 级别 | 定义 | 审批 | +|---|---|---| +| L1 | 配置项调整、扩容 | SRE Lead | +| L2 | 服务发布、参数变更 | SRE Lead + Tech Lead | +| L3 | 架构变更、DB 变更 | 架构组 + 总监 | +| L4 | 高风险(迁移、协议) | CTO | + +## 7.2 变更窗口 + +``` +推荐时段: 工作日 10:00 - 17:00 +禁止时段: + - 周五下午(防周末爆雷) + - 节假日前后 + - 大型活动期(如 618、双十一、春节) + - 上午 9-10 点(高峰) + - 晚 8-10 点(高峰) + +紧急修复: 7×24 (但需 P1 批准) +``` + +## 7.3 变更流程 + +``` +[提议] → [评审] → [审批] → [灰度] → [全量] → [验证] + ↓ + 风险评估 + 回滚预案 + 监控就绪 + 通知客服 +``` + +## 7.4 变更检查清单 + +``` +[ ] 设计文档已 review +[ ] 测试通过(单元/集成/性能) +[ ] 监控/告警已配置 +[ ] 灰度计划明确 +[ ] 回滚预案验证 +[ ] 业务方已知晓 +[ ] 客服话术已准备 +[ ] On-Call 已 brief +[ ] 变更窗口合规 +``` + +--- + +# 8. 故障演练 + +## 8.1 演练类型 + +| 类型 | 频率 | 范围 | +|---|---|---| +| Chaos 实验 | 每周 | 单服务 | +| AZ 故障演练 | 每月 | 单 AZ | +| Region 故障演练 | 每季 | 单区域 | +| 全链路故障 | 每半年 | 全系统 | + +## 8.2 Chaos 工具 + +``` +- Chaos Mesh / Chaos Monkey +- 注入故障类型: + * Pod kill + * 网络分区 + * 网络延迟 + * 磁盘满 + * CPU/内存压力 + * 时钟漂移 + * DNS 失败 +``` + +## 8.3 演练剧本示例 + +```yaml +演练: Redis 主节点宕机 +背景: 验证主从切换 + 业务降级 +准备: + - 选择非高峰时段 + - 通知所有相关方 + - 准备恢复脚本 + +执行: + T+0: kill Redis 主进程 + T+5s: 观察 sentinel 切主 + T+30s: 验证业务可用性 + T+1m: 恢复原主,验证回切 + +验证点: + - 主从切换 < 30s + - 业务无明显影响 + - 监控告警准确 + - 业务降级生效 + +后续: + - 总结暴露问题 + - 输出改进项 +``` + +## 8.4 GameDay + +每半年组织"故障日": + +``` +全员参与: + - 红队: 注入故障 + - 蓝队: 应急响应 + - 观察员: 记录评估 + +考核: + - MTTR (修复时间) + - 决策正确性 + - 沟通有效性 + +奖励: + - 优秀响应表彰 + - 改进项归档 +``` + +--- + +# 9. 应急工具箱 + +## 9.1 一键开关(配置中心) + +| 开关 | 用途 | +|---|---| +| `kill.typing` | 关闭"输入中" | +| `kill.read_receipt` | 关闭已读回执 | +| `kill.large_group_fanout` | 大群停止 fanout | +| `kill.search` | 关闭搜索 | +| `kill.history_pull` | 关闭历史拉取 | +| `kill.media_message` | 关闭文件/图片消息 | +| `force.read_only` | 全局只读 | +| `rate.global.qps` | 全局 QPS 上限 | +| `gateway.reject_new` | 拒绝新连接 | + +## 9.2 紧急脚本库 + +```bash +# 杀慢查询 +mysql_kill_slow.sh --threshold 5s + +# 重启 Gateway 实例 +gateway_rolling_restart.sh --batch 5% + +# 清理 Redis 大 key +redis_cleanup_bigkey.sh --threshold 1MB + +# Kafka 重置 offset +kafka_reset_offset.sh --group cg-xx --to latest + +# 强制下线 Pod +k8s_force_evict.sh --pod xxx +``` + +## 9.3 排查工具 + +``` +日志: kibana / loki +追踪: jaeger +监控: grafana +DB 慢日志: pt-query-digest +压测: k6 / locust +抓包: tcpdump / wireshark +性能: pprof / perf +``` + +## 9.4 联系人清单 + +```yaml +on_call: + primary: "@sre-oncall" + secondary: "@sre-backup" + +escalation: + L1: SRE Lead + L2: Tech Lead + L3: 架构组 + L4: CTO + +vendors: + cloud: "云厂商支持热线" + cdn: "CDN 厂商" + sms: "短信厂商" + push: "厂商通道" + +internal: + pm: "@pm-lead" + cs: "@customer-service" + pr: "@public-relations" +``` + +--- + +# 10. 复盘与改进 + +## 10.1 复盘原则 + +``` +✅ 对事不对人 +✅ 寻找系统性问题 +✅ 改进可落地 +✅ 时间线清晰 + +❌ 追责 +❌ 推卸 +❌ 流于形式 +``` + +## 10.2 复盘文档模板 + +```markdown +# 故障复盘:YYYY-MM-DD-XX + +## 一、事件概览 +- 标题: +- 影响时间: +- 影响范围: +- 影响用户数: +- 故障级别: P0/P1/P2 + +## 二、时间线 +- HH:MM 现象出现 +- HH:MM 告警触发 +- HH:MM 接警响应 +- HH:MM 定位根因 +- HH:MM 处置完成 +- HH:MM 业务恢复 +- HH:MM 完全恢复 + +## 三、根因分析(5 Why) +直接原因: +- 第二层: +- 第三层: +- 第四层: +- 根本原因: + +## 四、做得好的 +- ... +- ... + +## 五、需改进的 +- 监控盲区: +- 响应延迟: +- 沟通失误: +- 工具缺失: + +## 六、改进项 +| 项 | 负责人 | 截止 | 状态 | +|---|---|---|---| +| 加监控 XX | @xxx | DD | 进行中 | +| 优化告警 | @xxx | DD | 待开始 | + +## 七、其他 +- 需要的资源 +- 风险预估 +``` + +## 10.3 改进项跟踪 + +``` +所有改进项进入 Jira 跟踪 +每�� review 进度 +逾期未完成 → 升级到 Tech Lead +重大改进 → 季度 review + +完成率目标: 80%/月 +``` + +## 10.4 知识沉淀 + +``` +故障 Runbook 必须更新: + - 新故障 → 新 Runbook + - 已有 Runbook 不准确 → 修订 + - 季度 review 所有 Runbook + +知识库结构: + /runbook/ + /gateway/ + /msg-write/ + /database/ + /redis/ + /kafka/ + /... + /postmortem/ + /2026/ + /YYYY-MM-DD-incident-name.md +``` + +--- + +# 附录:值班与排班 + +## A. 值班规范 + +``` +值班周期: 1 周 +轮换: 周一 10:00 交接 +覆盖: 7×24 +最大连续: 2 周 + +要求: + - 30 min 响应(电话) + - 1h 内到达办公室或远程接入 + - 处置期间专注故障 + - 交接 brief 充分 +``` + +## B. 值班准备 + +``` +[ ] 确认应急联系人通畅 +[ ] 测试 VPN / 监控访问 +[ ] 准备值班机 +[ ] 阅读最近 Runbook 更新 +[ ] 了解近期变更 +[ ] 与上一班交接 +``` + +## C. 值班补贴 + +略(按公司政策) + +--- + +**文档结束** + +*Version 1.0 | SRE 运维手册* \ No newline at end of file diff --git a/_drafts/IM/03-SRE-Runbook-v1.0_Version4.md b/_drafts/IM/03-SRE-Runbook-v1.0_Version4.md new file mode 100755 index 000000000..6f988ca45 --- /dev/null +++ b/_drafts/IM/03-SRE-Runbook-v1.0_Version4.md @@ -0,0 +1,846 @@ +# IM 系统 SRE 运维手册 v1.0 + +> 适用:值班 SRE / On-Call 工程师 +> 目标:5 分钟定位问题,30 分钟恢复服务 + +--- + +## 目录 + +1. 监控大盘 +2. 告警分级与响应 +3. 常见故障 Runbook +4. 应急预案 +5. 容量管理 +6. 变更管理 +7. 故障复盘流程 + +--- + +# 1. 监控大盘 + +## 1.1 大盘分层 + +``` +L0 总览大盘: 系统健康概览,1 张图看全貌 +L1 业务大盘: 消息/连接/用户核心指标 +L2 服务大盘: 每个微服务一张 +L3 中间件大盘: Redis/Kafka/MySQL/ES +L4 基础设施大盘: 主机/网络/K8s +``` + +## 1.2 L0 总览大盘内容 + +``` +顶部红绿灯: + ┌─────┬─────┬─────┬─────┬─────┐ + │接入 │消息 │推送 │存储 │风控 │ + │ 🟢 │ 🟢 │ 🟡 │ 🟢 │ 🟢 │ + └─────┴─────┴─────┴─────┴─────┘ + +核心指标 (实时): + - 在线用户数 + - 消息 QPS(写入/投递) + - 端到端延迟 P99 + - 错误率 + - SLA 余额(本月) + +异常区域: + - 当前进行中的告警 + - 最近 1 小时变更 +``` + +## 1.3 L1 业务大盘 + +``` +连接: + - 实时连接数(按区域/网关分组) + - 建连速率 + - 连接失败率 + - QUIC 迁移成功率 + +消息: + - 发送 QPS(按 msg_type) + - 入库延迟分布 + - Outbox 堆积 + - Kafka lag (按 topic / partition) + - 投递成功率 + - 撤回率 + +推送: + - 推送 QPS + - 各厂商成功率(APNs/FCM/华为/小米/...) + - 推送时延 + - DLQ 堆积 + +未读: + - 未读 cursor 更新 QPS + - 计算延迟 +``` + +## 1.4 大盘工具 + +- Grafana + Prometheus(指标) +- Kibana(日志) +- Jaeger / SkyWalking(trace) +- 自研业务大盘(核心 KPI) + +## 1.5 关键 SLI + +| SLI | 计算 | 目标 | +|---|---|---| +| 接入可用性 | 1 - (失败建连/总建连) | > 99.9% | +| 消息成功率 | 成功入库/总请求 | > 99.99% | +| 投递成功率 | 投递成功/已入库 | > 99.95% | +| 端到端延迟 P99 | A 发送到 B 收到 | < 500ms | +| 推送时延 P99 | 入库到 push | < 5s | + +--- + +# 2. 告警分级与响应 + +## 2.1 告警分级 + +| 级别 | 含义 | 响应时间 | 通知方式 | +|---|---|---|---| +| **P0** | 核心服务不可用,影响 > 10% 用户 | 5 分钟 | 电话 + IM + 邮件 | +| **P1** | 重要服务异常,影响部分用户 | 15 分钟 | IM + 邮件 | +| **P2** | 服务降级,但可用 | 1 小时 | IM | +| **P3** | 异常但不紧急 | 4 小时 | 邮件 | + +## 2.2 P0 告警示例 + +``` +- 接入成功率 < 99% (连续 3 分钟) +- 消息丢失率 > 0.01% +- 主集群整体不可用 +- 数据库主从延迟 > 60s +- Kafka 集群不可写 +- Redis 集群不可用 +``` + +## 2.3 告警响应流程 + +``` +告警触发 + ▼ +On-Call 接收 (5min 内 ack) + ▼ +初步定位 (查大盘 / 日志) + ▼ +是否需要升级? + ├─ 是 → 拉群(业务/SRE Lead) + └─ 否 → 处理 + ▼ +执行 Runbook + ▼ +确认恢复 + ▼ +解除告警 + 简单总结 + ▼ +24h 内输出 RCA +``` + +## 2.4 告警值班 + +``` +轮值: 7×24,每班 12 小时 +人数: 主备 2 人 +工具: PagerDuty / OpsGenie / 自研 +要求: + - Ack 时间 < 5 min + - 处理时间 < SLA + - 每月轮值表提前发布 +``` + +--- + +# 3. 常见故障 Runbook + +## 3.1 [P0] 接入大量失败 + +### 症状 +- 连接成功率骤降 +- 客户端反馈连不上 +- Gateway 错误日志增多 + +### 排查步骤 + +``` +[1] 查 LB 健康 + - LB 健康检查通过�� + - LB CPU/内存 + +[2] 查 Gateway + - 实例数量是否正常 + - CPU/内存/连接数 + - 错误日志关键字: "auth_failed", "tls_failed" + +[3] 查依赖 + - auth-service 是否健康 + - presence shard 是否健康 + +[4] 查网络 + - LB 到 Gateway 网络 + - DNS 解析 +``` + +### 处置动作 + +``` +快速恢复: + - LB 故障 → 切换到备用 LB + - Gateway 实例不足 → HPA 扩容 + - auth 慢 → 临时延长 auth 缓存 TTL + - 全部失败 → DNS 切到备用集群 + +根因排查: + - 看变更窗口是否有发布 + - 看依赖服务变更 + - 看证书是否过期 +``` + +### 升级条件 +- 5 分钟内未止血 +- 影响 > 30% 用户 + +--- + +## 3.2 [P0] 消息延迟增加 + +### 症状 +- 端到端 P99 > 1s +- 用户反馈"消息晚到" + +### 排查步骤 + +``` +[1] 定位慢在哪一段 + - 客户端 → Gateway: 网络问题 + - Gateway → MsgWrite: 上游慢 + - MsgWrite → DB: DB 问题 + - Outbox → Kafka: Kafka 问题 + - Kafka → Deliver: Consumer lag + - Deliver → Gateway: 状态查询慢 + +[2] 看 Kafka lag + kafka-consumer-groups.sh --describe --group cg-deliver + +[3] 看 DB 慢查询 + SHOW PROCESSLIST; + SELECT * FROM information_schema.innodb_trx; + +[4] 看 Redis 慢日志 + SLOWLOG GET 10 +``` + +### 处置动作 + +``` +Kafka lag: + - 扩 Consumer 实例 + - 提高 fetch.max.bytes + - 检查是否有大消息卡住 + +DB 慢: + - 杀长查询: KILL + - 切只读到从库 + - 临时增加连接数 + +Redis 慢: + - 找 hot key + - 临时关闭非关键写入 + - 主从切换 +``` + +--- + +## 3.3 [P0] Kafka 集群异常 + +### 症状 +- Producer 写入失败 +- Outbox 堆积 +- ISR 收缩 + +### 排查步骤 + +``` +[1] kafka-topics --describe + - 看每个 topic 的 ISR + - 是否有 Under-Replicated + +[2] kafka-broker-api-versions + - 节点是否都在线 + +[3] 磁盘使用率 + - df -h /kafka-data + +[4] JVM 堆 + - jstat / GC log +``` + +### 处置动作 + +``` +Broker 挂: + - 重启 Broker + - 检查日志: log.recovery + +ISR 不全: + - 检查网络 + - 检查磁盘 IO + +磁盘满: + - 临时缩短 retention + - 删除老 topic + - 紧急扩容磁盘 + +完全不可用: + - 启用 Kafka 备集群 + - Producer 切到备集群 + - Outbox 暂存等恢复 +``` + +--- + +## 3.4 [P0] Redis 集群异常 + +### 症状 +- 缓存 miss 率飙升 +- 业务延迟增加 +- Redis 连接异常 + +### 排查步骤 + +``` +[1] redis-cli cluster info + - cluster_state: ok? + +[2] redis-cli cluster nodes + - 节点状态 + +[3] 内存使用 + INFO memory + used_memory / maxmemory + +[4] 慢日志 + SLOWLOG GET 20 +``` + +### 处置动作 + +``` +单节点挂: + - 等自动 failover (10~30s) + - 不行手动 failover: CLUSTER FAILOVER + +集群失联: + - 业务降级到 DB 兜底 + - 限流降低 QPS + - 分批重启节点 + +内存满: + - 检查 maxmemory-policy + - 紧急清理大 key + - 临时缩短 TTL +``` + +--- + +## 3.5 [P1] DB 主从延迟 + +### 症状 +- 从库 Seconds_Behind_Master > 60 +- 读从库的业务读到旧数据 + +### 排查步骤 + +``` +[1] SHOW SLAVE STATUS\G + - Seconds_Behind_Master + - Last_Error + - Slave_SQL_Running_State + +[2] 主库写入压力 + - QPS / IOPS + +[3] 从库 IO + - iostat +``` + +### 处置动作 + +``` +延迟增长中: + - 临时切读到主库 + - 减少主库写入压力 + +单从库慢: + - 重启该从库 IO 线程 + - 移除该从库出读集群 + +全部慢: + - 限制主库写入 + - 升级从库硬件 +``` + +--- + +## 3.6 [P1] 推送大量失败 + +### 症状 +- APNs/FCM 成功率下降 +- 用户收不到通知 + +### 排查步骤 + +``` +[1] 查厂商状态页 + - APNs: developer.apple.com/system-status/ + - FCM: status.firebase.google.com/ + +[2] 查推送服务日志 + - 错误码分布 + - 是否证书过期 + +[3] 查推送 DLQ + kafka-consumer-groups --describe msg.push.high.dlq +``` + +### 处置动作 + +``` +单厂商挂: + - 切到备用通道 + - 暂存等恢复 + +证书过期: + - 紧急更新证书 + - 重启推送服务 + +DLQ 大量堆积: + - 启动 DLQ 消费器 + - 降低重试间隔 +``` + +--- + +## 3.7 [P1] 单网关失联 + +### 症状 +- 该 Gateway 上的用户掉线 +- 监控显示该实例不可达 + +### 处置 + +``` +1. LB 摘除该实例 +2. 等待 K8s 重启 Pod +3. 用户自动重连到其他网关 +4. 30s 后状态自愈 + +如果是宿主机故障: +1. K8s 调度到其他节点 +2. 检查节点健康 +3. 上报硬件故障 +``` + +--- + +## 3.8 [P0] 整集群故障 + +### 症状 +- 一整个区域不可用 +- 多个核心服务同时告警 + +### 处置(区域切流) + +``` +Step 1: 确认范围 + - 单 AZ 还是整 region? + - 网络问题还是机房断电? + +Step 2: 启动切流 + - GSLB 切流量到其他 region + - 通知客户端重连 + - 数据库 standby 提升 + +Step 3: 通信 + - 内部通报 + - 客服通报(如必要) + +Step 4: 持续观察 + - 其他 region 是否扛住 + - 是否需要扩容 + +Step 5: 故障 region 恢复 + - 等待故障消除 + - 灰度引入流量 + - 全量恢复 +``` + +--- + +## 3.9 [P0] 消息大量丢失 + +### 症状 +- 用户反馈"消息没收到" +- 对账发现差异 + +### 排查步骤 + +``` +[1] 定位丢失位置 + - 客户端发出但服务端无: 网络或 Gateway + - 服务端入库但 Kafka 无: Outbox 未投递 + - Kafka 有但 Consumer 没消费: Consumer lag/异常 + - Consumer 处理但用户没收到: 投递失败 + +[2] 看 Outbox 堆积 + SELECT count(*) FROM outbox_event WHERE status=0 + +[3] 看 DLQ +[4] 看错误日志 +``` + +### 处置动作 + +``` +Outbox 卡住: + - 重启 Outbox Worker + - 手动触发处理 + +Consumer 异常: + - 重启 Consumer + - 从指定 offset 重消费 + +数据真丢失: + - 启动补偿任务 + - 通过 binlog 重放 + - 用户致歉/补偿 +``` + +--- + +# 4. 应急预案 + +## 4.1 战时模式 + +P0 级故障启动战时模式: + +``` +1. 拉作战群(IM 群 + 视频会议) +2. 指定 IC(Incident Commander) +3. 角色分工: + - IC: 决策与协调 + - SRE: 操作系统 + - 业务: 评估影响 + - 沟通: 内外部通报 + +4. 时间线记录 +5. 决策记录 +``` + +## 4.2 一键降级开关 + +```yaml +emergency_switches: + kill_typing_indicator: false # 关闭"正在输入" + kill_read_receipt: false # 关闭已读回执 + kill_search: false # 关闭消息搜索 + kill_history_load: false # 关闭历史漫游 + kill_large_group_fanout: false # 大群只读扩散 + + rate_limit_global_qps: 1000000 # 全局 QPS 上限 + rate_limit_user_msg: 200 # 用户消息频率 + + force_read_only: false # 全站只读 + reject_new_login: false # 拒绝新登录 +``` + +通过配置中心下发,秒级生效。 + +## 4.3 灾难恢复 + +### 数据中心丢失 +``` +RTO: 30 分钟 +RPO: 5 分钟 + +步骤: +1. GSLB 切流 +2. 备 region DB standby 提升为主 +3. 启动备 Kafka 集群 +4. Redis 重建(可接受丢失短期数据) +5. 业务验证 +6. 客户端重连 +``` + +### 数据库误删除 +``` +1. 立即停业务(避免覆盖) +2. 评估影响范围 +3. 选择恢复点 +4. 全量备份恢复 + binlog 增量 +5. 数据对比 +6. 切回业务 +``` + +## 4.4 演练机制 + +``` +周期: 每季度一次混沌工程演练 + +演练类型: + - 网关故障 + - DB 主切换 + - Kafka broker 挂 + - 区域切流 + - 全链路压测 + +工具: ChaosMesh / Chaos Monkey +``` + +--- + +# 5. 容量管理 + +## 5.1 容量监控 + +``` +水位告警: + - 黄: 70% + - 橙: 85% + - 红: 95% + +监控对象: + - Gateway 连接数 + - DB 存储 / QPS + - Redis 内存 + - Kafka 磁盘 / lag + - 带宽 +``` + +## 5.2 容量预测 + +``` +模型: 基于历史增长率 (LSTM / Prophet) +预测周期: 6 个月 +输出: + - 何时达到 70% + - 何时需要扩容 + - 扩容多少 +``` + +## 5.3 自动扩缩容 + +```yaml +HPA (K8s): + Gateway: + minReplicas: 20 + maxReplicas: 200 + metrics: + - cpu: 60% + - memory: 70% + - custom: connections_per_pod < 80000 + + MsgWrite: + minReplicas: 10 + maxReplicas: 100 + metrics: + - cpu: 60% + - kafka_lag: 1000 +``` + +## 5.4 季节性扩容 + +``` +春节: 提前 1 周扩容到 3x +双十一: 提前 1 周扩容到 2x +重大事件: 临时扩容 +``` + +--- + +# 6. 变更管理 + +## 6.1 变更分类 + +| 类型 | 审批 | 窗口 | +|---|---|---| +| 紧急修复 | On-Call 决策 | 任何时间 | +| 普通发布 | 主管审批 | 工作日 10-17 | +| 重大变更 | 架构评审 | 周二/周三 | +| 数据库变更 | DBA 审批 | 业务低峰 | + +## 6.2 变更流程 + +``` +1. 提交变更单 (CR) + - 描述 + - 影响范围 + - 测试结果 + - 回滚方案 + - 风险评估 + +2. 评审 + +3. 灰度执行 + - 1% → 10% → 50% → 100% + +4. 持续观察 + +5. 关闭变更单 +``` + +## 6.3 变更窗口禁令 + +``` +禁止变更时段: + - 周五下午 + - 节假日 + - 大型活动期 + - 灰度未结束的相邻变更 +``` + +--- + +# 7. 故障复盘流程 + +## 7.1 复盘原则 + +``` +1. 不追责,找根因 +2. 事实优先 +3. 改进可落地 +4. 时间内完成(24h 内初稿,1 周内定稿) +``` + +## 7.2 复盘模板 + +```markdown +# 故障复盘:[标题] + +## 基本信息 +- 时间:YYYY-MM-DD HH:MM ~ HH:MM +- 影响:影响用户数 / 业务损失 +- 级别:P0 / P1 / P2 +- 主要负责:XX + +## 时间线 +- HH:MM 告警触发 +- HH:MM On-Call 响应 +- HH:MM 定位为 XX +- HH:MM 执行 XX 操作 +- HH:MM 服务恢复 + +## 根本原因 +- 直接原因: +- 根本原因: +- 为什么 1: +- 为什么 2: +- 为什么 3: + +## 影响分析 +- 受影响用户: +- 受影响功能: +- 业务损失: + +## 处置评估 +- 做得好的: +- 不足之处: + +## 改进项 (Action Items) +| 改进 | 负责人 | 截止 | 状态 | +|---|---|---|---| +| XX | XX | YYYY-MM-DD | 进行中 | + +## 经验教训 +``` + +## 7.3 改进���跟踪 + +``` +所有 Action Item 必须有 owner + deadline +每周进度 review +未按期 → 升级 +``` + +--- + +# 附录:常用命令速查 + +## A.1 K8s + +```bash +# 查 Pod 状态 +kubectl get pods -n im -l app=gateway + +# 查 Pod 日志 +kubectl logs -f -n im gateway-xxx + +# 进入 Pod +kubectl exec -it gateway-xxx -- bash + +# 重启 Deployment +kubectl rollout restart deployment/gateway -n im + +# 扩容 +kubectl scale deployment/gateway --replicas=50 -n im +``` + +## A.2 Kafka + +```bash +# 列 topic +kafka-topics --bootstrap-server kafka:9092 --list + +# 描述 topic +kafka-topics --bootstrap-server kafka:9092 --describe --topic msg.fanout.normal + +# 查 consumer lag +kafka-consumer-groups --bootstrap-server kafka:9092 --describe --group cg-deliver-normal + +# 重置 offset +kafka-consumer-groups --bootstrap-server kafka:9092 --group cg-xxx --reset-offsets --to-earliest --execute --topic xxx +``` + +## A.3 Redis + +```bash +# 集群信息 +redis-cli -h xxx cluster info +redis-cli -h xxx cluster nodes + +# 慢日志 +redis-cli -h xxx SLOWLOG GET 20 + +# 监控 +redis-cli -h xxx --stat + +# 大 key 扫描 +redis-cli -h xxx --bigkeys +``` + +## A.4 MySQL + +```sql +-- 当前会话 +SHOW PROCESSLIST; + +-- 慢查询 +SELECT * FROM mysql.slow_log ORDER BY query_time DESC LIMIT 20; + +-- 主从状态 +SHOW SLAVE STATUS\G + +-- innodb 状态 +SHOW ENGINE INNODB STATUS\G + +-- 杀连接 +KILL ; +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/04-Client-SDK-Design-v1.0_Version4.md b/_drafts/IM/04-Client-SDK-Design-v1.0_Version4.md new file mode 100755 index 000000000..01ecdaa57 --- /dev/null +++ b/_drafts/IM/04-Client-SDK-Design-v1.0_Version4.md @@ -0,0 +1,974 @@ +# IM 客户端 SDK 设计文档 v1.0 + +> 适用平台:iOS / Android / PC / Web +> 目标:弱网体验、消息不丢、跨端一致 + +--- + +## 目录 + +1. SDK 架构 +2. 协议处理 +3. 本地存储 +4. 消息发送与重试 +5. 消息接收与去重 +6. 推拉协同同步 +7. 离线消息处理 +8. 多端协同 +9. 网络质量自适应 +10. 错误处理 +11. 安全与隐私 +12. SDK 接口设计 + +--- + +# 1. SDK 架构 + +## 1.1 模块结构 + +``` +┌─────────────────────────────────────────┐ +│ 应用层 API (公开接口) │ +│ IMClient / Conversation / Message │ +├─────────────────────────────────────────┤ +│ 业务层 │ +│ ConvManager / MsgManager / Sync │ +│ Mention / Read / Recall │ +├─────────────────────────────────────────┤ +│ 协议层 │ +│ Protocol Codec / Frame Builder │ +├─────────────────────────────────────────┤ +│ 传输层 │ +│ ConnManager / QUIC / WS / HTTP │ +│ Heartbeat / Reconnect │ +├─────────────────────────────────────────┤ +│ 存储层 │ +│ LocalDB / Cache / FilePool │ +├─────────────────────────────────────────┤ +│ 基础工具 │ +│ Logger / Metrics / Crypto / Codec │ +└─────────────────────────────────────────┘ +``` + +## 1.2 线程模型 + +``` +主线程: UI 回调 +IO 线程: Socket 读写 +DB 线程: 单线程串行操作 (避免锁) +业务线程池: 消息处理、合并、同步 +``` + +## 1.3 关键状态 + +```kotlin +enum class ConnectionState { + IDLE, // 未连接 + CONNECTING, // 连接中 + AUTHENTICATING, // 鉴权中 + CONNECTED, // 已连接 + RECONNECTING, // 重连中 + DISCONNECTED // 断开 +} + +enum class SyncState { + IDLE, + SYNCING, + UP_TO_DATE +} +``` + +--- + +# 2. 协议处理 + +## 2.1 协议栈选择 + +``` +默认: QUIC (UDP) +fallback: WebSocket (TCP) +最终 fallback: HTTPS 长轮询 +``` + +## 2.2 协议探测 + +```kotlin +class ConnectionStrategy { + suspend fun connect(): Connection { + // 1. 优先用上次成功的协议 + val lastProtocol = prefs.getString("last_protocol", "quic") + + // 2. Happy Eyeballs:并发尝试 + val results = parallelConnect( + quic(timeout = 3.seconds), + ws(timeout = 5.seconds) + ) + + // 3. 取先成功的 + val connection = results.firstSuccess() + + // 4. 缓存协议偏好 + prefs.put("last_protocol", connection.protocol) + + return connection + } +} +``` + +## 2.3 帧编解码 + +```kotlin +class FrameCodec { + fun encode(cmd: Int, seqId: Long, body: ByteArray): ByteArray { + val buf = ByteBuffer.allocate(18 + body.size) + buf.put(0x4D.toByte()) // Magic + buf.put(VERSION) + buf.putShort(cmd.toShort()) + buf.putShort(0) // flags + buf.putLong(seqId) + buf.putInt(body.size) + buf.put(body) + return buf.array() + } + + fun decode(stream: InputStream): Frame { + val header = stream.readNBytes(18) + // ... 解析 + return Frame(...) + } +} +``` + +## 2.4 请求-响应匹配 + +```kotlin +class RequestRegistry { + private val pending = ConcurrentHashMap>() + + suspend fun request(cmd: Int, body: ByteArray, timeout: Duration): Frame { + val seqId = nextSeqId() + val deferred = CompletableDeferred() + pending[seqId] = deferred + + try { + connection.send(encode(cmd, seqId, body)) + return withTimeout(timeout) { deferred.await() } + } finally { + pending.remove(seqId) + } + } + + fun onResponse(frame: Frame) { + pending.remove(frame.seqId)?.complete(frame) + } +} +``` + +--- + +# 3. 本地存储 + +## 3.1 数据库设计 (SQLite) + +### 消息表 +```sql +CREATE TABLE local_message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conv_id INTEGER NOT NULL, + server_msg_id INTEGER, + client_msg_id TEXT NOT NULL, + visible_seq INTEGER, + sender_id INTEGER NOT NULL, + msg_type INTEGER NOT NULL, + content BLOB NOT NULL, + status INTEGER NOT NULL, -- 0:sending 1:success 2:failed 3:recalled + send_time INTEGER, + create_time INTEGER, + is_outgoing INTEGER, + + UNIQUE (conv_id, visible_seq), + UNIQUE (server_msg_id), + INDEX idx_client (client_msg_id), + INDEX idx_conv_time (conv_id, create_time) +); +``` + +### 会话表 +```sql +CREATE TABLE local_conversation ( + conv_id INTEGER PRIMARY KEY, + conv_type INTEGER, + name TEXT, + avatar TEXT, + last_msg_id INTEGER, + last_msg_preview TEXT, + last_msg_time INTEGER, + + -- 游标 + max_visible_seq INTEGER DEFAULT 0, + read_visible_seq INTEGER DEFAULT 0, + read_mention_seq INTEGER DEFAULT 0, + + is_pinned INTEGER DEFAULT 0, + is_muted INTEGER DEFAULT 0, + cleared_seq INTEGER DEFAULT 0, + draft TEXT +); +``` + +### 同步进度 +```sql +CREATE TABLE local_sync_state ( + conv_id INTEGER PRIMARY KEY, + last_sync_seq INTEGER, + last_sync_time INTEGER +); +``` + +## 3.2 索引策略 + +``` +local_message: + PK: id + UK: (conv_id, visible_seq) -- 主要查询 + UK: server_msg_id -- 跨端定位 + IDX: client_msg_id -- 自己发的合并 + IDX: (conv_id, create_time) -- 历史滚动 + +local_conversation: + PK: conv_id + IDX: last_msg_time -- 会话列表排序 +``` + +## 3.3 加密 + +``` +- DB 整库加密: SQLCipher (AES-256) +- 密钥: Keychain (iOS) / Keystore (Android) +- 大文件: ��独加密(PBE) +``` + +## 3.4 容量管理 + +``` +- 默认保留 30 天本地消息 +- 超过自动清理 +- 用户可手动"清理本地缓存" +- 大文件按 LRU 清理 +``` + +--- + +# 4. 消息发送与重试 + +## 4.1 发送流程 + +```kotlin +suspend fun sendMessage(conv: Long, content: MessageContent): MessageResult { + // 1. 生成 client_msg_id + val clientMsgId = generateClientMsgId() + + // 2. 本地落库 (status=sending) + val localMsg = LocalMessage( + convId = conv, + clientMsgId = clientMsgId, + content = content, + status = SENDING, + sendTime = currentTimeMillis(), + isOutgoing = true + ) + db.insert(localMsg) + + // 3. UI 立即显示 + listeners.onMessageInserted(localMsg) + + // 4. 加入发送队列 + val result = sendQueue.send(localMsg) + + // 5. 更新本地状态 + when (result) { + is Success -> { + db.update(localMsg.copy( + serverMsgId = result.serverMsgId, + visibleSeq = result.seq, + status = SUCCESS + )) + } + is Failure -> { + db.update(localMsg.copy(status = FAILED)) + } + } + + listeners.onMessageStatusChanged(localMsg) + return result +} +``` + +## 4.2 发送队列 + +```kotlin +class SendQueue { + private val queue = Channel(capacity = 100) + + suspend fun start() { + for (msg in queue) { + sendWithRetry(msg) + } + } + + private suspend fun sendWithRetry(msg: LocalMessage): Result { + val maxAttempts = 3 + var attempt = 0 + + while (attempt < maxAttempts) { + try { + val resp = connection.request( + cmd = SEND_MSG, + body = msg.toProto(), + timeout = 10.seconds + ) + return Success(resp) + } catch (e: TimeoutException) { + attempt++ + delay(backoff(attempt)) + } catch (e: NetworkException) { + // 网络失败,等连接恢复 + connection.waitForConnected() + } catch (e: ProtocolException) { + // 业务错误,不重试 + return Failure(e) + } + } + + return Failure(MaxAttemptsExceeded) + } + + private fun backoff(attempt: Int): Duration { + return Duration.seconds(min(2.0.pow(attempt), 30.0).toLong()) + } +} +``` + +## 4.3 重试关键约束 + +``` +✅ client_msg_id 不变 +✅ 本地状态先持久化 +✅ 重启后从 DB 恢复未完成消息 +❌ 不要重新生成 ID +❌ 不要直接覆盖本地数据 +``` + +## 4.4 失败消息处理 + +```kotlin +// App 启动时检查未完成消息 +fun resumePendingMessages() { + val pending = db.query("SELECT * FROM local_message WHERE status = ?", SENDING) + for (msg in pending) { + if (currentTimeMillis() - msg.sendTime > 5.minutes) { + // 超过 5 分钟还在 sending,标记失败 + db.update(msg.copy(status = FAILED)) + } else { + // 还在窗口内,继续重试 + sendQueue.send(msg) + } + } +} +``` + +## 4.5 用户手动重发 + +```kotlin +fun retryMessage(localId: Long) { + val msg = db.get(localId) + if (msg.status != FAILED) return + + db.update(msg.copy( + status = SENDING, + sendTime = currentTimeMillis() + )) + sendQueue.send(msg) +} +``` + +--- + +# 5. 消息接收与去重 + +## 5.1 消息来源 + +``` +1. 实时通道 (在线推送) +2. 离线拉取 (上线同步) +3. 历史漫游 (滚动加载) +4. 多端同步 (其他端发的) +``` + +## 5.2 统一接收处理 + +```kotlin +fun onMessageReceived(msg: ReceivedMessage) { + // 1. 去重检查 + if (isDuplicate(msg)) { + return + } + + // 2. 自己发的消息:合并到 local_message + if (msg.senderId == myUserId) { + mergeOutgoingMessage(msg) + return + } + + // 3. 别人发的:插入新消息 + insertIncomingMessage(msg) + + // 4. 更新会话 + updateConversation(msg) + + // 5. 通知 UI + listeners.onMessageInserted(msg) +} + +fun isDuplicate(msg: ReceivedMessage): Boolean { + // 优先按 server_msg_id 去重 + if (db.exists("server_msg_id = ?", msg.serverMsgId)) { + return true + } + // 兜底按 (conv, seq) 去重 + if (db.exists("conv_id = ? AND visible_seq = ?", msg.convId, msg.visibleSeq)) { + return true + } + return false +} +``` + +## 5.3 自己发消息的合并 + +关键场景:手机发了一条消息,PC 端通过同步收到 → 不能再插一条新的。 + +```kotlin +fun mergeOutgoingMessage(msg: ReceivedMessage) { + // 按 client_msg_id 找本地 + val local = db.findByClientMsgId(msg.clientMsgId) + + if (local != null) { + // 合并:补充 server_msg_id 和 seq + db.update(local.copy( + serverMsgId = msg.serverMsgId, + visibleSeq = msg.visibleSeq, + status = SUCCESS + )) + } else { + // 本地没有(其他端发的):插入新记录 + db.insert(msg.toLocal()) + } +} +``` + +## 5.4 顺序保证 + +```kotlin +fun insertIncomingMessage(msg: ReceivedMessage) { + // 1. 检查 seq 连续性 + val maxLocalSeq = db.queryMaxSeq(msg.convId) + + if (msg.visibleSeq > maxLocalSeq + 1) { + // 有空洞,触发同步 + syncManager.syncRange( + convId = msg.convId, + from = maxLocalSeq + 1, + to = msg.visibleSeq - 1 + ) + } + + // 2. 插入 + db.insert(msg.toLocal()) +} +``` + +注意:实际上 IM 应该容忍 seq 空洞(撤回、不可见消息),不必每次都补拉。 +判断条件:本地已知 max_visible_seq vs 服务端 max_visible_seq。 + +--- + +# 6. 推拉协同同步 + +## 6.1 同步策略 + +``` +推(轻量通知)→ 拉(精确数据) +``` + +服务端实时通道只发 `latest_seq`,客户端按需拉取。 + +## 6.2 同步触发时机 + +``` +1. 应用启动 +2. 网络连接建立 +3. 收到 SYNC_NOTIFY 通知 +4. 用户切换会话 +5. 后台返回前台 +6. 主动刷新 +``` + +## 6.3 同步流程 + +```kotlin +class SyncManager { + suspend fun fullSync() { + // 1. 拉变更会话列表 + val changedConvs = api.getChangedConversations( + sinceVersion = lastSyncVersion + ) + + // 2. 按会话拉增量 + val tasks = changedConvs.map { conv -> + async { + syncConversation(conv.convId, conv.maxSeq) + } + } + + // 3. 并行同步(限制并发) + tasks.chunked(5).forEach { it.awaitAll() } + + // 4. 更新同步版本 + lastSyncVersion = response.version + } + + suspend fun syncConversation(convId: Long, serverMaxSeq: Long) { + val localMaxSeq = db.queryMaxSeq(convId) + if (localMaxSeq >= serverMaxSeq) return + + var fromSeq = localMaxSeq + 1 + while (fromSeq <= serverMaxSeq) { + val resp = api.pullMessages( + convId = convId, + sinceSeq = fromSeq - 1, + limit = 200 + ) + + db.batchInsert(resp.messages) + + if (!resp.hasMore) break + fromSeq = resp.messages.last().visibleSeq + 1 + } + } +} +``` + +## 6.4 历史漫游 + +```kotlin +class HistoryLoader { + suspend fun loadOlder(convId: Long, beforeSeq: Long, limit: Int = 30): List { + // 1. 先查本地 + val local = db.queryOlder(convId, beforeSeq, limit) + if (local.size >= limit) return local + + // 2. 不够,从服务端拉 + val needed = limit - local.size + val oldest = local.lastOrNull()?.visibleSeq ?: beforeSeq + + val remote = api.pullMessages( + convId = convId, + beforeSeq = oldest, + limit = needed + ) + + // 3. 落库 + 返回 + db.batchInsert(remote.messages) + return local + remote.messages + } +} +``` + +## 6.5 同步性能优化 + +``` +- 只同步活跃会话(最近 30 天有消息) +- 大群只拉最近 100 条,历史按需 +- 多会话并发,限制并发数 5 +- 失败重试,不阻塞其他 +``` + +--- + +# 7. 离线消息处理 + +## 7.1 推送唤醒 + +``` +1. App 后台/退出 +2. 服务端检测离线 +3. 调 APNs/FCM 推送轻量通知 +4. 客户端被唤醒(iOS 静默推送 / Android 服务) +5. 启动同步 +6. 必要时显示通知 +``` + +## 7.2 后台同步限制 + +### iOS +``` +- 静默推送有频率限制 +- BGAppRefreshTask 短任务(30s) +- VoIP 推送(受限) +``` + +### Android +``` +- FCM 高优先级推送 +- WorkManager 后台任务 +- 厂商通道(华为/小米等) +``` + +## 7.3 通知展示 + +```kotlin +fun showNotification(msg: ReceivedMessage) { + // 不打扰检查 + if (conv.isMuted && !msg.isMention) return + + // 内容预览(隐私设置) + val preview = if (settings.showPreview) { + msg.content.preview() + } else { + "你收到一条新消息" + } + + notificationManager.notify( + title = msg.conversationName, + body = preview, + category = if (msg.isMention) "high" else "normal" + ) +} +``` + +## 7.4 通知聚合 + +``` +1 个会话多条消息: "X 等 N 人发来 M 条新消息" +利用 thread_id (Android) / threadIdentifier (iOS) +``` + +--- + +# 8. 多端协同 + +## 8.1 已读同步 + +```kotlin +// 节流上报 +class ReadReporter { + private var pendingReports = mutableMapOf() // conv → maxRead + private val flushScope = CoroutineScope(Dispatchers.IO) + + fun reportRead(convId: Long, readSeq: Long) { + synchronized(pendingReports) { + pendingReports[convId] = max(pendingReports[convId] ?: 0, readSeq) + } + scheduleFlush() + } + + private fun scheduleFlush() { + flushScope.launch { + delay(2000) // 2s 节流 + val toReport = synchronized(pendingReports) { + val copy = pendingReports.toMap() + pendingReports.clear() + copy + } + if (toReport.isNotEmpty()) { + api.batchReportRead(toReport) + } + } + } +} + +// 接收其他端的已读同步 +fun onReadSync(convId: Long, readSeq: Long) { + val local = db.queryConv(convId) + if (readSeq > local.readVisibleSeq) { + db.updateConv(convId, readVisibleSeq = readSeq) + listeners.onUnreadChanged(convId) + } +} +``` + +## 8.2 草稿同步(可选) + +``` +可选功能,非核心 +通过自定义事件同步 +``` + +## 8.3 设备切换 + +``` +登录新设备: + 1. 服务端推送踢人消息(同设备类型) + 2. 老设备收到 → 显示提示 → 关闭连接 + 3. 新设备开始全量同步 +``` + +--- + +# 9. 网络质量自适应 + +## 9.1 网络监测 + +```kotlin +class NetworkMonitor { + fun startMonitoring() { + // Android: ConnectivityManager + // iOS: NWPathMonitor + + onNetworkChanged { type -> + when (type) { + WIFI -> { + heartbeatInterval = 60.seconds + syncMode = AGGRESSIVE + } + CELLULAR -> { + heartbeatInterval = 45.seconds + syncMode = CONSERVATIVE + } + NONE -> { + pauseSync() + } + } + } + } +} +``` + +## 9.2 弱网识别 + +``` +基于 RTT 和丢包率: + P50 RTT < 200ms: 优秀 + 200~500ms: 一般 + 500~1000ms: 较差 + > 1000ms 或丢包 > 5%: 弱网 + +弱网策略: + - 减小 batch size + - 增加超时 + - 优先小消息 + - 暂停文件上传 +``` + +## 9.3 网络切换 + +```kotlin +fun onNetworkChanged() { + // QUIC 连接迁移会自动处理 + // WS 连接需要重连 + + if (protocol == WS) { + connection.reconnect() + } else if (protocol == QUIC) { + // QUIC 自动迁移,无需重连 + // 但要重新订阅 / 触发同步 + sync.scheduleResync() + } +} +``` + +--- + +# 10. 错误处理 + +## 10.1 错误分类 + +| 类型 | 示例 | 处理 | +|---|---|---| +| 网络错误 | 超时/断开 | 自动重试 | +| 协议错误 | 解析失败 | 断开重连 | +| 业务错误 | 限流/封禁 | 提示用户 | +| 鉴权错误 | token 过期 | 重新登录 | +| 服务错误 | 5xx | 退避重试 | + +## 10.2 错误码 + +``` +0 成功 +1xxx 客户端错误 +2xxx 协议错误 +3xxx 鉴权错误 +4xxx 业务错误(限流、封禁、权限) +5xxx 服务错误 +9xxx 未知错误 +``` + +## 10.3 重试策略 + +```kotlin +fun shouldRetry(error: Throwable): Boolean { + return when (error) { + is NetworkTimeoutException -> true + is NetworkUnavailableException -> true + is ServerErrorException -> error.code in 500..599 + is RateLimitedException -> error.retryAfter > 0 + is BlockedException -> false + is AuthenticationException -> false + else -> false + } +} +``` + +## 10.4 用户提示 + +``` +不要把所有错误都弹给用户 +- 网络问题:状态栏小提示 +- 限流:消息上小图标 + tip +- 封禁:明确提示 +- 服务故障:toast +``` + +--- + +# 11. 安全与隐私 + +## 11.1 传输加密 + +``` +- TLS 1.3 +- 证书 pinning(防中间人) +- 应用层加密(敏感字段) +``` + +## 11.2 本地存储加密 + +``` +- SQLCipher 整库加密 +- 密钥用平台 KeyStore 保护 +- 不把密钥写文件 +``` + +## 11.3 敏感信息 + +``` +不要 log: + - 消息正文 + - token / 密钥 + - 用户隐私字段 + +允许 log: + - 元数据 (msg_id, seq, time) + - 错误码 + - 性能指标 +``` + +## 11.4 数据清除 + +``` +退出登录: + - 清空 token + - 关闭连接 + - 可选:清空本地消息 + +卸载: + - 系统自动清理 +``` + +--- + +# 12. SDK 接口设计 + +## 12.1 初始化 + +```kotlin +val config = IMConfig.Builder() + .appId("xxx") + .endpoint("im.example.com") + .deviceId(uniqueDeviceId) + .logLevel(LogLevel.INFO) + .build() + +IMClient.init(config) +``` + +## 12.2 登录 + +```kotlin +suspend fun login(token: String): LoginResult + +IMClient.login(token).onSuccess { user -> + // 登录成功 +}.onFailure { error -> + // 处理错误 +} +``` + +## 12.3 会话操作 + +```kotlin +val convs = IMClient.conversation + .listAll() // 所有会话 + .listRecent(20) // 最近 20 个 + .get(convId) // 单个会话 + +IMClient.conversation + .markAsRead(convId) + .pin(convId) + .mute(convId, until) + .clear(convId) + .delete(convId) +``` + +## 12.4 消息操作 + +```kotlin +// 发送 +val msg = MessageBuilder() + .text("hello @张三") + .mention(zhangsanId) + .build() + +IMClient.message.send(convId, msg) + +// 历史 +val messages = IMClient.message.loadHistory(convId, beforeSeq = 1000, limit = 30) + +// 撤回 +IMClient.message.recall(serverMsgId) + +// 编辑 +IMClient.message.edit(serverMsgId, newContent) +``` + +## 12.5 监听器 + +```kotlin +IMClient.addMessageListener { msg -> + // 新消息 +} + +IMClient.addConnectionListener { state -> + // 连接状态变化 +} + +IMClient.addConvUpdateListener { conv -> + // 会话更新(未读、最后消息等) +} +``` + +## 12.6 错误回调 + +```kotlin +IMClient.onError { error -> + when (error) { + is AuthExpired -> reLogin() + is RateLimited -> showTip(error.retryAfter) + else -> log(error) + } +} +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/04-Client-SDK-Design_Version4.md b/_drafts/IM/04-Client-SDK-Design_Version4.md new file mode 100755 index 000000000..ed283c076 --- /dev/null +++ b/_drafts/IM/04-Client-SDK-Design_Version4.md @@ -0,0 +1,919 @@ +# 客户端 SDK 设计文档 v1.0 + +> IM 客户端 SDK - 协议处理 / 本地存储 / 消息合并 / 推拉协同 +> 关键词:长连接管理 / 离线缓存 / 多端同步 / 弱网优化 + +## 目录 +1. [SDK 架构与职责](#1-sdk-架构与职责) +2. [模块设计](#2-模块设计) +3. [协议处理层](#3-协议处理层) +4. [连接管理](#4-连接管理) +5. [本地存储](#5-本地存储) +6. [消息收发与合并](#6-消息收发与合并) +7. [推拉协同同步](#7-推拉协同同步) +8. [离线与弱网](#8-离线与弱网) +9. [多端同步](#9-多端同步) +10. [API 设计](#10-api-设计) +11. [性能优化](#11-性能优化) +12. [跨平台实现](#12-跨平台实现) + +--- + +# 1. SDK 架构与职责 + +## 1.1 SDK 在系统中的定位 + +``` +┌──────────────────────────────────────┐ +│ 上层 App (UI) │ +└──────────┬───────────────────────────┘ + │ SDK API +┌──────────▼───────────────────────────┐ +│ IM SDK │ +│ ┌────────┐ ┌────────┐ ┌──────────┐ │ +│ │ 协议层 │ │ 业务层 │ │ 存储层 │ │ +│ └────────┘ └────────┘ └──────────┘ │ +│ ┌��───────┐ ┌────────┐ ┌──────────┐ │ +│ │ 网络层 │ │ 同步层 │ │ 工具层 │ │ +│ └────────┘ └────────┘ └──────────┘ │ +└──────────┬───────────────────────────┘ + │ TLS/QUIC/WS +┌──────────▼───────────────────────────┐ +│ IM Gateway │ +└──────────────────────────────────────┘ +``` + +## 1.2 核心职责 + +| 职责 | 说明 | +|---|---| +| 长连接管理 | 建连、保活、重连、迁移 | +| 协议编解码 | 二进制 frame ↔ Protobuf ↔ Model | +| 消息收发 | 上行发送 + 下行接收 | +| 本地存储 | SQLite/CoreData,离线消息缓存 | +| 同步引擎 | 推拉协同、增量同步、断点续传 | +| 多端一致 | clientMsgId 合并、seq 去重 | +| 推送整合 | APNs/FCM 唤醒 + 拉取 | +| 弱网优化 | 重试、退避、压缩、合并 | +| 安全 | 鉴权、E2E 加密(可选) | +| 可观测 | 埋点、日志、性能上报 | + +## 1.3 SDK 不该做的 + +- ❌ 不做 UI(保持业务无关) +- ❌ 不存储敏感凭证(明文) +- ❌ 不做业务规则(如群权限) +- ❌ 不直接访问第三方服务 + +--- + +# 2. 模块设计 + +## 2.1 模块图 + +``` +┌──────────────────────────────────────────────────┐ +│ Public API │ +└──┬──────────┬──────────┬──────────┬──────────────┘ + │ │ │ │ +┌──▼─────┐ ┌──▼──────┐ ┌─▼──────┐ ┌▼───────┐ +│ Auth │ │ Message │ │ Convo │ │ Group │ 业务模块 +│ Module │ │ Module │ │ Module │ │ Module │ +└──┬─────┘ └──┬──────┘ └─┬──────┘ └┬───────┘ + │ │ │ │ + └──────┬───┴──────────┴─────────┘ + │ +┌─────────▼──────────────┐ +│ Sync Engine │ ← 推拉协同核心 +└─────────┬──────────────┘ + │ +┌─────────▼──────────────┐ ┌─────────────────┐ +│ Connection Manager │←───│ Network Detector│ +└─────────┬──────────────┘ └─────────────────┘ + │ +┌─���───────▼──────────────┐ +│ Protocol Codec │ ← Frame 编解码 +└─────────┬──────────────┘ + │ +┌─────────▼──────────────┐ +│ Transport (WS/QUIC) │ +└────────────────────────┘ + +┌────────────────────────┐ +│ Local Storage │ ← SQLite/CoreData +└────────────────────────┘ + +┌────────────────────────┐ +│ Utilities │ 日志/加密/压缩 +└────────────────────────┘ +``` + +## 2.2 线程模型 + +``` +主线程 (UI): 不做 IO,只调用 SDK API +SDK 工作线程 (1 个): 事件循环、状态机 +网络 IO 线程 (1-2 个): Socket 读写 +DB 线程 (1 个): SQLite 写入串行化 +回调线程池 (4 个): 上层回调 + +线程间通信: 消息队列 + 锁 +``` + +## 2.3 状态机 + +``` +[Idle] ──login()──→ [Connecting] + │ + 握手成功 + ↓ + [Authenticating] + │ + 认证成功 + ↓ + [Connected] ←──┐ + │ │ 重连成功 + 心跳正常 │ + 断开/异常 │ + ↓ │ + [Reconnecting] ─┘ + │ + 超过最大重试 + ↓ + [Disconnected] + │ + logout() / 错误 + ↓ + [Idle] +``` + +--- + +# 3. 协议处理层 + +## 3.1 帧编解码 + +```kotlin +// Kotlin (Android) +class FrameCodec { + companion object { + const val MAGIC = 0x4D.toByte() + const val HEADER_SIZE = 18 + const val MAX_FRAME = 1024 * 1024 // 1MB + } + + fun encode(cmd: Int, seqId: Long, body: ByteArray, flags: Int = 0): ByteArray { + val buf = ByteBuffer.allocate(HEADER_SIZE + body.size) + buf.put(MAGIC) + buf.put(VERSION.toByte()) + buf.putShort(cmd.toShort()) + buf.putShort(flags.toShort()) + buf.putLong(seqId) + buf.putInt(body.size) + buf.put(body) + return buf.array() + } + + fun decode(data: ByteArray): Frame { + require(data.size >= HEADER_SIZE) { "frame too short" } + require(data[0] == MAGIC) { "bad magic" } + + val buf = ByteBuffer.wrap(data) + buf.position(1) + val version = buf.get() + val cmd = buf.short.toInt() and 0xFFFF + val flags = buf.short.toInt() and 0xFFFF + val seqId = buf.long + val bodyLen = buf.int + + require(bodyLen <= MAX_FRAME) { "frame too large" } + require(data.size >= HEADER_SIZE + bodyLen) { "incomplete" } + + var body = data.copyOfRange(HEADER_SIZE, HEADER_SIZE + bodyLen) + + // 解压 + if (flags and FLAG_COMPRESSED != 0) { + body = ZstdDecompressor.decompress(body) + } + + return Frame(version, cmd, flags, seqId, body) + } +} +``` + +## 3.2 拆包/粘包处理 + +```kotlin +class FrameDecoder { + private val buffer = ByteArrayOutputStream() + + @Synchronized + fun feed(data: ByteArray): List { + buffer.write(data) + val frames = mutableListOf() + + while (true) { + val bytes = buffer.toByteArray() + if (bytes.size < HEADER_SIZE) break + + val bodyLen = ByteBuffer.wrap(bytes, 14, 4).int + val totalLen = HEADER_SIZE + bodyLen + + if (bytes.size < totalLen) break // 不够整帧 + + frames.add(FrameCodec.decode(bytes.copyOf(totalLen))) + + // 重置 buffer 为剩余字节 + buffer.reset() + if (bytes.size > totalLen) { + buffer.write(bytes, totalLen, bytes.size - totalLen) + } + } + + return frames + } +} +``` + +## 3.3 SeqId → Callback 映射 + +```kotlin +class RequestRouter { + private val pending = ConcurrentHashMap() + private val seqGen = AtomicLong(1) + + fun send(cmd: Int, body: ByteArray, callback: (Result) -> Unit, timeoutMs: Long = 10000) { + val seqId = seqGen.incrementAndGet() + val frame = FrameCodec.encode(cmd, seqId, body) + + pending[seqId] = PendingRequest(callback, System.currentTimeMillis() + timeoutMs) + connection.write(frame) + } + + fun handleResponse(frame: Frame) { + val req = pending.remove(frame.seqId) + req?.callback?.invoke(Result.success(frame.body)) + } + + // 定时清理超时 + fun checkTimeout() { + val now = System.currentTimeMillis() + pending.entries.removeIf { (_, req) -> + if (now > req.expireAt) { + req.callback(Result.failure(TimeoutException())) + true + } else false + } + } +} +``` + +## 3.4 推送消息处理(无 SeqId) + +```kotlin +class PushHandler { + fun handlePush(frame: Frame) { + when (frame.cmd) { + CMD_PUSH_MSG -> { + val push = MsgPush.parseFrom(frame.body) + messageManager.onMessageReceived(push) + + // 发送 ACK + if (frame.flags and FLAG_NEED_ACK != 0) { + sendAck(push.serverMsgId) + } + } + CMD_SYNC_NOTIFY -> { + val notify = SyncNotify.parseFrom(frame.body) + syncEngine.onNotify(notify.convId, notify.latestSeq) + } + CMD_KICK -> { + val kick = KickReason.parseFrom(frame.body) + onKicked(kick.reason) + } + // ... + } + } +} +``` + +--- + +# 4. 连接管理 + +## 4.1 接入流程 + +```kotlin +suspend fun connect(): Result { + // 1. 拉取接入入口 + val endpoints = dispatcher.getEndpoints(userId, deviceId) + + // 2. Happy Eyeballs: 并行尝试 QUIC 和 WSS + val winner = parallelTry(endpoints) { endpoint -> + when (endpoint.protocol) { + "quic" -> connectQuic(endpoint) + "wss" -> connectWss(endpoint) + } + } + + if (winner == null) return Result.failure(...) + + // 3. 鉴权登录 + val token = authManager.getToken() + val loginResp = sendLogin(token) + + if (!loginResp.success) { + winner.close() + return Result.failure(...) + } + + // 4. 状态切换 + state = State.CONNECTED + + // 5. 启动心跳 + startHeartbeat() + + // 6. 触发增量同步 + syncEngine.triggerFullSync() + + return Result.success(Unit) +} +``` + +## 4.2 协议探测与降级 + +```kotlin +// QUIC 优先 + WSS 兜底 +class ConnectionStrategy { + suspend fun connect(): Connection { + // 1. 尝试缓存的最佳协议 + val cached = preferences.getString("preferred_protocol", "quic") + + try { + return when (cached) { + "quic" -> connectQuic() + else -> connectWss() + } + } catch (e: ConnectException) { + // 2. 降级 + return when (cached) { + "quic" -> { + log.warn("QUIC failed, fallback to WSS") + preferences.putString("preferred_protocol", "wss") + connectWss() + } + else -> throw e + } + } + } +} +``` + +## 4.3 重连策略 + +```kotlin +class ReconnectStrategy { + private var attempt = 0 + private val maxBackoff = 60_000L + + fun nextDelay(): Long { + val base = (1L shl attempt.coerceAtMost(6)) * 1000L // 1, 2, 4, 8, 16, 32, 64 + val capped = base.coerceAtMost(maxBackoff) + val jitter = (Math.random() * capped * 0.2).toLong() // ±20% + attempt++ + return capped + jitter + } + + fun reset() { + attempt = 0 + } +} + +// 使用 +suspend fun reconnectLoop() { + while (state == State.RECONNECTING) { + val delay = strategy.nextDelay() + delay(delay) + + try { + connect() + strategy.reset() + return + } catch (e: Exception) { + log.warn("reconnect failed", e) + } + } +} +``` + +## 4.4 网络变化触发 + +```kotlin +// Android +class NetworkMonitor(context: Context) { + private val cm = context.getSystemService(ConnectivityManager::class.java) + + init { + cm.registerDefaultNetworkCallback(object : NetworkCallback() { + override fun onAvailable(network: Network) { + onNetworkChanged() + } + override fun onLost(network: Network) { + onNetworkChanged() + } + }) + } + + private fun onNetworkChanged() { + // QUIC: 自动连接迁移,无需重连 + if (currentProtocol == "quic" && connection.isAlive()) { + return + } + + // WSS: 强制重连 + connectionManager.disconnect() + connectionManager.scheduleReconnect(0) // 立即 + } +} +``` + +## 4.5 心跳 + +```kotlin +class Heartbeat { + private var interval = 30_000L + + fun start() { + coroutineScope.launch { + while (isActive) { + delay(interval) + + try { + val rtt = measureTimeMillis { + sendHeartbeat() + } + + // 自适应调整 + interval = when { + rtt < 100 -> 60_000L // 网络好,降低频率 + rtt < 500 -> 30_000L + else -> 15_000L // 网络差,提高频率 + } + + if (isAppInBackground()) { + interval = (interval * 2).coerceAtMost(180_000L) // 后台延长 + } + } catch (e: Exception) { + onHeartbeatTimeout() + } + } + } + } +} +``` + +--- + +# 5. 本地存储 + +## 5.1 存储引擎选型 + +| 平台 | 推荐 | +|---|---| +| Android | SQLite (Room) / SQLDelight | +| iOS | SQLite (FMDB / GRDB) / CoreData | +| Web | IndexedDB | +| Desktop | SQLite | + +**SQLite 是跨平台一致性最佳选择**。 + +## 5.2 数据库 Schema + +```sql +-- 消息本地表 +CREATE TABLE local_message ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conv_id INTEGER NOT NULL, + client_msg_id TEXT NOT NULL, + server_msg_id INTEGER, + global_seq INTEGER, + visible_seq INTEGER, + sender_id INTEGER, + msg_type INTEGER, + content BLOB, -- Protobuf 序列化 + send_status INTEGER DEFAULT 0, -- 0:sending 1:sent 2:failed 3:received + read_status INTEGER DEFAULT 0, -- 0:unread 1:read + send_time INTEGER, + receive_time INTEGER, + + UNIQUE(conv_id, server_msg_id), + UNIQUE(client_msg_id) +); + +CREATE INDEX idx_conv_seq ON local_message(conv_id, visible_seq DESC); +CREATE INDEX idx_conv_time ON local_message(conv_id, send_time DESC); +CREATE INDEX idx_send_status ON local_message(send_status); + +-- 会话本地表 +CREATE TABLE local_conversation ( + conv_id INTEGER PRIMARY KEY, + conv_type INTEGER, + conv_name TEXT, + conv_avatar TEXT, + + last_msg_id INTEGER, + last_msg_preview TEXT, + last_msg_time INTEGER, + + max_visible_seq INTEGER DEFAULT 0, -- 本地最大已知 seq + read_visible_seq INTEGER DEFAULT 0, + read_mention_seq INTEGER DEFAULT 0, + joined_at_seq INTEGER DEFAULT 0, + + unread_count INTEGER DEFAULT 0, -- 缓存值,从 max-read 算 + unread_mention INTEGER DEFAULT 0, + + is_muted INTEGER DEFAULT 0, + is_pinned INTEGER DEFAULT 0, + draft TEXT, + + updated_at INTEGER +); + +CREATE INDEX idx_updated ON local_conversation(updated_at DESC); + +-- 用户本地表 +CREATE TABLE local_user ( + user_id INTEGER PRIMARY KEY, + nickname TEXT, + avatar TEXT, + remark TEXT, + updated_at INTEGER +); + +-- 群成员 +CREATE TABLE local_group_member ( + group_id INTEGER, + user_id INTEGER, + role INTEGER, + PRIMARY KEY (group_id, user_id) +); + +-- @我列表(缓存) +CREATE TABLE local_mention ( + server_msg_id INTEGER PRIMARY KEY, + conv_id INTEGER, + msg_seq INTEGER, + sender_id INTEGER, + preview TEXT, + is_read INTEGER DEFAULT 0, + created_at INTEGER +); + +CREATE INDEX idx_mention_unread ON local_mention(is_read, created_at DESC); + +-- 同步进度 +CREATE TABLE sync_state ( + key TEXT PRIMARY KEY, + value TEXT +); +-- key: "last_sync_event_seq", "global_max_event" +``` + +## 5.3 数据访问层(DAO) + +```kotlin +@Dao +interface MessageDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insert(msg: LocalMessage): Long + + @Update + suspend fun update(msg: LocalMessage) + + @Query("SELECT * FROM local_message WHERE conv_id = :convId ORDER BY visible_seq DESC LIMIT :limit OFFSET :offset") + suspend fun loadRecent(convId: Long, limit: Int, offset: Int): List + + @Query("SELECT * FROM local_message WHERE conv_id = :convId AND visible_seq > :sinceSeq ORDER BY visible_seq") + suspend fun loadAfter(convId: Long, sinceSeq: Long): List + + @Query("SELECT MAX(visible_seq) FROM local_message WHERE conv_id = :convId") + suspend fun getMaxSeq(convId: Long): Long? + + @Query("SELECT * FROM local_message WHERE client_msg_id = :clientMsgId LIMIT 1") + suspend fun findByClientMsgId(clientMsgId: String): LocalMessage? + + @Query("SELECT * FROM local_message WHERE send_status = 0 AND send_time < :before") + suspend fun findStuckSending(before: Long): List +} +``` + +## 5.4 写入串行化 + +SQLite 写入需要串行化(避免锁等待): + +```kotlin +class MessageRepository { + private val writeChannel = Channel(capacity = Channel.UNLIMITED) + + init { + coroutineScope.launch(Dispatchers.IO) { + for (op in writeChannel) { + op.execute() + } + } + } + + suspend fun insertMessage(msg: LocalMessage) { + val op = InsertOp(msg) + writeChannel.send(op) + op.await() + } +} +``` + +## 5.5 数据库迁移 + +```kotlin +// Room migration 例子 +val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE local_message ADD COLUMN reply_to_id INTEGER") + } +} +``` + +## 5.6 本地存储清理 + +```kotlin +class StorageCleaner { + // 按时间清理:保留最近 90 天 + suspend fun cleanByTime() { + val cutoff = System.currentTimeMillis() - 90L * 86400_000 + db.messageDao().deleteOlderThan(cutoff) + } + + // 按容量清理:超过 500MB 清理最老的 + suspend fun cleanBySize(targetBytes: Long = 500 * 1024 * 1024) { + val current = getDbSize() + if (current < targetBytes) return + + // 按会话保留最近 N 条 + val convs = db.conversationDao().getAll() + for (conv in convs) { + db.messageDao().keepRecent(conv.convId, limit = 1000) + } + } +} +``` + +## 5.7 加密存储 + +```kotlin +// SQLCipher (Android) +val passphrase = SQLiteDatabase.getBytes("user-key".toCharArray()) +val factory = SupportFactory(passphrase) +Room.databaseBuilder(...).openHelperFactory(factory).build() + +// iOS: SQLite + SQLCipher 类似 +``` + +--- + +# 6. 消息收发与合并 + +## 6.1 发送消息流程 + +```kotlin +suspend fun sendMessage(convId: Long, content: MessageContent): Result { + // 1. 生成 clientMsgId + val clientMsgId = generateClientMsgId() + + // 2. 本地落库 (sending 状态) + val localMsg = LocalMessage( + clientMsgId = clientMsgId, + convId = convId, + senderId = currentUserId, + content = content.toByteArray(), + sendStatus = SendStatus.SENDING, + sendTime = System.currentTimeMillis() + ) + val localId = db.messageDao().insert(localMsg) + + // 3. 立即触发 UI 刷新(乐观更新) + onMessageInserted(localMsg) + + // 4. 发送到服务端 + try { + val resp = withTimeout(10_000) { + connectionManager.sendRequest( + CMD_SEND_MSG, + SendMsgReq.newBuilder() + .setClientMsgId(clientMsgId) + .setConvId(convId) + .setContent(ByteString.copyFrom(content.toByteArray())) + .build() + ) + } + + // 5. 更新本地状态 + db.messageDao().update(localMsg.copy( + serverMsgId = resp.serverMsgId, + visibleSeq = resp.visibleSeq, + sendStatus = SendStatus.SENT + )) + + // 6. 通知 UI + onMessageSent(localMsg, resp) + return Result.success(resp.serverMsgId) + + } catch (e: Exception) { + // 7. 标记失败,进入重试队列 + db.messageDao().update(localMsg.copy(sendStatus = SendStatus.FAILED)) + retryQueue.enqueue(localMsg) + return Result.failure(e) + } +} +``` + +## 6.2 接收消息流程 + +```kotlin +fun onMessageReceived(push: MsgPush) { + coroutineScope.launch(Dispatchers.IO) { + // 1. 检查是否是自己发的(多端同步回流) + if (push.senderId == currentUserId) { + handleSelfMessageSync(push) + return@launch + } + + // 2. 去重检查 + val existing = db.messageDao().findByServerMsgId(push.serverMsgId) + if (existing != null) { + return@launch // 已存在,跳过 + } + + // 3. 检查 seq 是否连续 + val convId = push.convId + val localMaxSeq = db.messageDao().getMaxSeq(convId) ?: 0 + + if (push.visibleSeq > localMaxSeq + 1) { + // 有空洞,触发增量拉取 + syncEngine.triggerSync(convId, sinceSeq = localMaxSeq) + } + + // 4. 插入本地 + val msg = LocalMessage( + convId = push.convId, + serverMsgId = push.serverMsgId, + visibleSeq = push.visibleSeq, + senderId = push.senderId, + content = push.content.toByteArray(), + sendStatus = SendStatus.RECEIVED, + receiveTime = System.currentTimeMillis() + ) + db.messageDao().insert(msg) + + // 5. 更新会话 + updateConversation(convId, msg) + + // 6. 处理 mention + if (containsMention(push, currentUserId)) { + handleMention(push) + } + + // 7. 通知 UI + onMessageInserted(msg) + + // 8. 发送 ACK(可选) + sendAck(push.serverMsgId) + } +} +``` + +## 6.3 自己发的消息回流(多端同步) + +```kotlin +suspend fun handleSelfMessageSync(push: MsgPush) { + // 通过 clientMsgId 查找本地是否有 + val clientMsgId = push.clientMsgId + val existing = db.messageDao().findByClientMsgId(clientMsgId) + + if (existing != null) { + // 本地已有,更新服务端 ID + db.messageDao().update(existing.copy( + serverMsgId = push.serverMsgId, + visibleSeq = push.visibleSeq, + sendStatus = SendStatus.SENT + )) + } else { + // 本地没有(来自另一台设备),新建 + val msg = LocalMessage( + clientMsgId = clientMsgId, + convId = push.convId, + serverMsgId = push.serverMsgId, + visibleSeq = push.visibleSeq, + senderId = currentUserId, + content = push.content.toByteArray(), + sendStatus = SendStatus.SENT + ) + db.messageDao().insert(msg) + } +} +``` + +## 6.4 重试队列 + +```kotlin +class RetryQueue { + private val queue = ConcurrentLinkedQueue() + + init { + coroutineScope.launch { + while (isActive) { + delay(5000) + processQueue() + } + } + } + + suspend fun processQueue() { + if (!connectionManager.isConnected()) return + + val batch = mutableListOf() + repeat(10) { + queue.poll()?.let { batch.add(it) } + } + + for (msg in batch) { + try { + resendMessage(msg) + } catch (e: Exception) { + if (msg.retryCount < 3) { + queue.offer(msg.copy(retryCount = msg.retryCount + 1)) + } else { + // 永久失败,标记 + db.messageDao().update(msg.copy(sendStatus = SendStatus.FAILED_FINAL)) + } + } + } + } +} +``` + +## 6.5 消息合并显示(UI 层逻辑示意) + +``` +连续消息合并: + - 同一发送者 + 1 分钟内 + 连续 → 合并显示头像 + +时间分隔: + - 与上一条相隔 > 5 分钟 → 显示时间分隔线 + - 跨天 → 显示日期 + +撤回显示: + - "X 撤回了一条消息" + +引用回复: + - 显示原消息预览 +``` + +--- + +# 7. 推拉协同同步 + +## 7.1 同步模型 + +``` +推(Push): 服务端实时通知"有新消息"(轻量,可丢) +拉(Pull): 客户端按 seq 主动拉增量(可靠,最终一致) + +最终一致性靠拉,实时性靠推 +``` + +## 7.2 同步引擎核心逻辑 + +```kotlin +class SyncEngine { + + // 触发场景 + enum class Trigger { + APP_LAUNCH, // 启动 + APP_FOREGROUND, // 切前台 + RECONNECTED, // 重连成功 + PUSH_NOTIFY, // 收到推送通知 + SEQ_GAP, // 检测到 seq 空洞 + MANUAL, // 用户下拉 + } + + suspend fun triggerSync(trigger: Trigger) { + when (trigger) { + APP_LAUNCH, RECONNECTED -> fullSync() + APP_FOREGROUND -> incrementalSync() + PUSH_NOTIFY -> incrementalSync() + SEQ_GAP -> gapFill() + MANUAL -> incrementalSync() + } + } + + // 1. 全量同步:拉变更会* diff --git a/_drafts/IM/05-Risk-Control-v1.0_Version4.md b/_drafts/IM/05-Risk-Control-v1.0_Version4.md new file mode 100755 index 000000000..43e10b6df --- /dev/null +++ b/_drafts/IM/05-Risk-Control-v1.0_Version4.md @@ -0,0 +1,747 @@ +# IM 风控规则手册 v1.0 + +> 适用:风控引擎 / 反垃圾 / 内容安全 +> 目标:识别异常行为、保护用户体验、控制业务风险 + +--- + +## 目录 + +1. 风控总体框架 +2. 完整规则清单 +3. 特征工程 +4. 模型训练 +5. 决策与处置 +6. A/B 实验 +7. 反馈与迭代 + +--- + +# 1. 风控总体框架 + +## 1.1 风控目标 + +``` +1. 反垃圾:广告、营销、骚扰 +2. 反欺诈:钓鱼、诈骗、虚假身份 +3. 反作弊:脚本、批量操作 +4. 内容安全:违法、违规、未成年保护 +5. 业务保护:恶意刷接口、爬数据 +``` + +## 1.2 整体架构 + +``` +┌─────────────────────────────────────────────┐ +│ 业务侧 (Gateway / 业务服务) │ +│ - 同步快审 │ +│ - 上报行为事件 │ +└────────────────────┬─────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Kafka: user.behavior │ +└────────────────────┬─────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ 实时风控引擎 (Flink) │ +│ - 特征聚合 │ +│ - 规则匹配 │ +│ - 模型推理 │ +└────────────────────┬─────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ 决策中心 │ +│ - 命中规则 │ +│ - 风险等级 │ +│ - 处置动作 │ +└────────────────────┬─────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ Redis: risk:user:{uid} │ +│ 业务层查询消费 │ +└─────────────────────────────────────────────┘ +``` + +## 1.3 三层风控 + +``` +事前 (preventive): 注册校验、设备指纹 +事中 (real-time): 消息发送/接口调用拦截 +事后 (post-hoc): 离线分析、批量处理 +``` + +## 1.4 评估指标 + +| 指标 | 含义 | 目标 | +|---|---|---| +| 准确率 | 拦截中真坏比例 | > 95% | +| 召回率 | 真坏中被拦比例 | > 80% | +| 误伤率 | 好用户被拦比例 | < 0.1% | +| 时延 | 决策耗时 | < 50ms | + +--- + +# 2. 完整规则清单 + +## 2.1 行为频率规则 + +| ID | 规则 | 阈值 | 处置 | +|---|---|---|---| +| F001 | 单用户消息频率 | > 200/分钟 | Lv2 降速 | +| F002 | 单用户消息频率 | > 500/分钟 | Lv3 临封 1h | +| F003 | 单用户群发频率 | > 30 次/小时 | Lv3 临封 | +| F004 | 单用户加好友频率 | > 50/天 | Lv2 | +| F005 | 单用户建群频率 | > 10/小时 | Lv3 | +| F006 | 单用户拉人入群 | > 100/小时 | Lv3 | +| F007 | 单 IP 注册数 | > 5/天 | Lv4 | +| F008 | 单 IP 登录用户数 | > 20/天 | Lv3 | +| F009 | 单设备登录账号 | > 5 个 | Lv2 | +| F010 | 历史消息拉取频率 | > 30/分钟 | Lv2 | + +## 2.2 行为模式规则 + +| ID | 规则 | 触发条件 | 处置 | +|---|---|---|---| +| P001 | 短时大量私聊 | 30 分钟给 > 50 个陌生人发 | Lv3 | +| P002 | 同内容多发 | 1 小时同/相似内容发 > 10 次 | Lv3 | +| P003 | 加好友通过率低 | 24h 申请 > 30 通过 < 5% | Lv2 | +| P004 | 只发不收 | 7 天发送 > 100 接收 < 5 | Lv2 | +| P005 | 发后立删 | 发消息后 1min 内撤回 > 50% | Lv2 | +| P006 | 异常活跃时段 | 凌晨 2-5 点高频活动 | Lv1 加验证 | +| P007 | 频繁切设备 | 1 天切 > 5 个设备 | Lv2 | +| P008 | 异地登录 | 2 小时内异地切换 | Lv1 验证码 | +| P009 | 频繁踢人 | 群主 1h 踢 > 20 人 | Lv2 | +| P010 | 拉群轰炸 | 1h 拉 > 5 个群发同样内容 | Lv4 封号 | + +## 2.3 内容规则 + +| ID | 规则 | 检测方式 | 处置 | +|---|---|---|---| +| C001 | 关键词命中 | 黑名单匹配 | 直接 block | +| C002 | URL 黑名单 | 域名 + 完整 URL 双匹配 | block | +| C003 | 仿冒链接 | 域名相似度(编辑距离) | 警告 + Lv2 | +| C004 | 二维码(非好友) | 图片 OCR + 解码 | Lv2 | +| C005 | 联系方式(非好友) | 手机号/微信号正则 | Lv2 | +| C006 | 涉政 | NLP 模型 | block + Lv4 | +| C007 | 涉黄涉暴 | 文本 + 图片模型 | block + Lv4 | +| C008 | 钓鱼诈骗 | 多特征组合(链接+话术) | block + Lv4 | +| C009 | 营销话术 | NLP 分类 | Lv2 | +| C010 | 未成年保护 | 涉未成年敏感内容 | block + 上报 | + +## 2.4 关系链规则 + +| ID | 规则 | 条件 | 处置 | +|---|---|---|---| +| R001 | 新号高频加人 | 注册 < 7 天,加 > 30 人 | Lv2 | +| R002 | 单向关系链多 | 加了很多人,反向少 | Lv1 标记 | +| R003 | 异常社交图谱 | 关系密度异常(图算法) | Lv2 | +| R004 | 团伙特征 | 多账号互相加好友、互相点赞 | Lv3 | +| R005 | 假人特征 | 头像/昵称模式化 | Lv2 | + +## 2.5 设备/环境规则 + +| ID | 规则 | 条件 | 处置 | +|---|---|---|---| +| D001 | 模拟器 | 检测到 emulator | Lv2 | +| D002 | Root/越狱 | 检测到 root | Lv1 标记 | +| D003 | 改机工具 | 设备指纹异常 | Lv3 | +| D004 | 自动化框架 | 检测到 hook/auto-click | Lv4 | +| D005 | 机房 IP | IP 在机房段 | Lv3 | +| D006 | 代理/VPN | IP 异常 | Lv1 标记 | +| D007 | 无浏览器特征 | UA / 行为特征异常 | Lv2 | + +## 2.6 账号规则 + +| ID | 规则 | 条件 | 处置 | +|---|---|---|---| +| A001 | 新号 | 注册 < 24h 高频活动 | Lv2 | +| A002 | 长期不活跃突然活跃 | 30 天未登录后高频发 | Lv2 | +| A003 | 头像可疑 | 默认头像 / 网图 | Lv1 | +| A004 | 昵称可疑 | 命名模式(如 "用户12345") | Lv1 | +| A005 | 资料完整度低 | 无头像/无昵称/无简介 | Lv1 | +| A006 | 历史封禁 | 曾被封禁 | Lv2 | +| A007 | 关联可疑账号 | 同设备/同 IP 关联坏账号 | Lv2 | + +--- + +# 3. 特征工程 + +## 3.1 特征分类 + +``` +基础特征: 用户/设备/IP 静态信息 +统计特征: 各种维度的计数、比率 +时序特征: 时间窗口聚合 +关系特征: 社交图谱 +内容特征: 文本/图片/链接 + +实时特征: 秒/分钟级窗口 +准实时特征: 小时级 +离线特征: 天级 +``` + +## 3.2 核心特征列表 + +### 用户特征 +``` +- 注册时长 (天) +- 活跃天数 +- 总消息数 +- 会话数 +- 好友数 +- 加群数 +- 历史封禁次数 +- 最近 7/30/90 天活跃度 +- 设备数 +- 登录 IP 数 +``` + +### 行为特征(滑动窗口) +``` +窗口: 1min / 5min / 1h / 1d / 7d / 30d + +- 消息发送数 +- 消息接收数 +- 收发比 +- 群消息数 +- 私聊数 +- 加好友请求数 +- 加好友成功率 +- 撤回率 +- 编辑率 +- 历史拉取次数 +- 文件发送数 +``` + +### 内容特征 +``` +- 平均消息长度 +- 链接占比 +- 图片占比 +- 关键词命中数 +- 内容相似度(SimHash) +- 与历史消息相似度 +- 多收件人 + 同内容 → 群发指数 +``` + +### 关系特征 +``` +- 朋友数 +- 朋友的朋友数(二度) +- 社交密度 +- 单向关系数 +- 互动频率 +- 与坏账号的距离(图算法) +``` + +### 设备/环境特征 +``` +- 设备指纹(IMEI / IDFV / 自定义) +- IP 地理位置 +- IP 类型(家庭/机房/代理) +- 网络类型(WiFi/4G/5G) +- 系统版本 +- 应用版本 +- 模拟器标识 +- Root 标识 +``` + +## 3.3 特征存储 + +``` +实时特征: Redis ZSet / HyperLogLog + rate:msg:{uid}:1min 滑动窗口计数 + recipient:{uid}:1h HLL 去重收件人数 + +准实时: Redis Hash + 定时刷新 + user_stats:{uid} 各类计数 + +离线: HBase / Hive + user_features 全量特征表 +``` + +## 3.4 特征计算 + +### 实时窗口计数(Redis) + +```lua +-- sliding_window.lua +local key = KEYS[1] +local window = tonumber(ARGV[1]) +local now = tonumber(ARGV[2]) +local member = ARGV[3] + +redis.call('ZADD', key, now, member) +redis.call('ZREMRANGEBYSCORE', key, 0, now - window) +redis.call('EXPIRE', key, window / 1000) +return redis.call('ZCARD', key) +``` + +### 流式特征聚合(Flink) + +```java +DataStream events = kafkaSource(...); + +events + .keyBy(e -> e.userId) + .window(SlidingEventTimeWindows.of(Time.hours(1), Time.minutes(1))) + .aggregate(new UserStatsAggregator()) + .addSink(redisSink); +``` + +--- + +# 4. 模型训练 + +## 4.1 模型类型 + +| 模型 | 用途 | 算法 | +|---|---|---| +| 垃圾消息分类 | 文本是否垃圾 | BERT / FastText | +| 用户分群 | 用户类型识别 | XGBoost / LightGBM | +| 异常检测 | 行为异常 | Isolation Forest / AutoEncoder | +| 团伙挖掘 | 团伙账号识别 | Graph Neural Network | +| 图片审核 | 涉黄/涉暴 | CNN (预训练 + 迁移) | + +## 4.2 数据准备 + +### 标注数据来源 +``` +- 历史封禁账号(正样本) +- 用户举报 +- 人工审核 +- 内部红队测试 +- 公开数据集 +``` + +### 数据质量 +``` +- 标注一致性 > 95%(双盲标注) +- 正负样本平衡(重采样) +- 时间分布均匀 +- 避免 leakage +``` + +## 4.3 训练流程 + +``` +1. 数据采样 + - 时间窗口 + - 正负比例 1:5 ~ 1:10 + +2. 特征提取 + - 用户特征 + - 行为特征 + - 内容特征 + +3. 特征工程 + - 缺失值填充 + - 标准化 + - 离散化 + +4. 模型训练 + - 划分训练/验证/测试 (7:2:1) + - 交叉验证 + - 超参搜索 + +5. 模型评估 + - AUC / Precision / Recall / F1 + - 混淆矩阵 + - 业务指标 + +6. 上线发布 + - A/B 测试 + - 灰度 + - 全量 +``` + +## 4.4 模型上线 + +``` +模型存储: TensorFlow Serving / TorchServe / ONNX Runtime +推理服务: 独立部署,水平扩展 +推理延迟: P99 < 50ms +监控: + - QPS + - 延迟 + - 准确率(线上 sample 对比标注) + - drift(特征分布漂移) +``` + +## 4.5 模型迭代 + +``` +每周更新一次: + - 加新数据 + - 加新特征 + - 调超参 + +每月评估: + - 模型效果衰减 + - 是否需要重训 + +每季度大版本: + - 模型架构升级 + - 特征体系演进 +``` + +--- + +# 5. 决策与处置 + +## 5.1 决策流程 + +``` +事件输入 + │ + ▼ +[特征计算] + │ + ▼ +[规则引擎] ─→ 命中规则 → 计算分数 + │ │ + ▼ ▼ +[模型推理] [分数聚合] + │ │ + └──────────┬────────────┘ + │ + ▼ + [决策器] + │ + ▼ + [风险等级 0~5] + │ + ▼ + [处置动作] + │ + ▼ + [写入 risk:user:{uid}] +``` + +## 5.2 分数聚合 + +```python +def calc_risk_score(user, event): + score = 0 + + # 规则分 + for rule in matched_rules(user, event): + score += rule.weight + + # 模型分 + model_score = model.predict(features(user, event)) + score += model_score * 100 + + # 历史分(余热) + if user.recent_risk_score > 0: + score += user.recent_risk_score * 0.5 + + # 上下文调整 + if user.is_vip: + score *= 0.5 # VIP 容忍度高 + if user.is_new and score > 0: + score *= 1.5 # 新号严格 + + return min(score, 100) +``` + +## 5.3 风险等级映射 + +```python +def score_to_level(score): + if score < 20: return 0 # 正常 + if score < 40: return 1 # 低风险 + if score < 60: return 2 # 中风险 + if score < 80: return 3 # 高风险 + if score < 95: return 4 # 极高风险 + return 5 # 确认违规 +``` + +## 5.4 处置动作 + +| 等级 | 动作 | 持续 | 用户感知 | +|---|---|---|---| +| 0 | 无 | - | 无感 | +| 1 | 加验证码 / 二次确认 | 当次 | 轻 | +| 2 | 降速(限流减半) | 1h | 中 | +| 3 | 临时封禁发消息 | 1h~24h | 强 | +| 4 | 临时封号 | 1d~7d | 强 | +| 5 | 永久封号 | 永久 | 强 | + +## 5.5 处置组合 + +```python +def execute_action(user, level): + if level == 0: + return + + if level >= 1: + require_captcha(user, ttl=300) + + if level >= 2: + rate_limit(user, multiplier=0.5, ttl=3600) + + if level >= 3: + block_send(user, ttl=3600) + notify_user("您的账号存在异常行为...") + + if level >= 4: + block_account(user, ttl=86400) + send_to_review_queue(user) + + if level >= 5: + permanent_ban(user) + send_to_legal_queue(user) +``` + +## 5.6 业务集成 + +```python +# Gateway / 业务层查询 +def can_send_message(user_id): + risk = redis.hgetall(f"risk:user:{user_id}") + + level = int(risk.get("level", 0)) + expire_at = int(risk.get("expire_at", 0)) + + if level == 0 or now() > expire_at: + return True, None + + if level == 1: + return True, "captcha_required" + + if level >= 3: + return False, risk.get("reason") + + return True, "rate_limited" +``` + +## 5.7 申诉机制 + +``` +用户申诉 → 进入审核队列 → 人工复核 → 决策 + +复核通过: + - 解封 + - 调整模型样本(降低误伤) + +复核拒绝: + - 维持处置 + - 告知理由 +``` + +--- + +# 6. A/B 实验 + +## 6.1 实验目标 + +``` +1. 验证新规则效果 +2. 调整阈值 +3. 对比模型版本 +4. 评估处置策略 +``` + +## 6.2 实验设计 + +### 流量分配 +``` +按 user_id 分组: + control (50%): 旧规则 + treatment (50%): 新规则 + +确保同一用户始终在同一组 +``` + +### 实验时长 +``` +最少 1 周 +覆盖周末 + 工作日 +样本量足够(统计显著) +``` + +## 6.3 关键指标 + +``` +正向指标: + - 拦截违规数(增加) + - 召回率 + +负向指标 (须监控): + - 误伤率(上升 → 报警) + - 用户申诉量 + - DAU / 留存 + - 消息量 + +业务指标: + - 整体活跃度 + - 用户体验评分 +``` + +## 6.4 实验流程 + +``` +1. 设计实验 + - 假设 + - 指标 + - 流量 + - 时长 + +2. 灰度上线 + - 1% → 10% → 50% + +3. 监控 + - 实时大盘 + - 异常自动停止 + +4. 评估 + - 显著性检验 + - 业务影响 + +5. 决策 + - 全量推广 / 调整 / 放弃 + +6. 复盘 +``` + +## 6.5 实验工具 + +``` +平台: 公司内部 A/B 平台 / Optimizely / VWO +分流: 一致性 hash on user_id +日志: 实验组标记,便于离线分析 +``` + +--- + +# 7. 反馈与迭代 + +## 7.1 反馈来源 + +``` +1. 用户举报 +2. 用户申诉 +3. 客服反馈 +4. 业务方反馈 +5. 监控告警 +6. 离线对账 +``` + +## 7.2 标注闭环 + +``` +线上拦截结果 + │ + ▼ +抽样 → 人工审核 + │ + ├─ 误伤 → 加入"白样本" + │ 调整规则/模型 + │ + └─ 漏报 → 加入"黑样本" + 补充训练数据 +``` + +## 7.3 模型 drift 监控 + +``` +定期对比: + - 训练时特征分布 + - 线上特征分布 + +显著漂移 → 触发重训 +``` + +## 7.4 规则维护 + +``` +每月评审: + - 规则命中量 TOP + - 规则误伤率 + - 失效规则下线 + - 新增规则评估 + +每季度大版本: + - 规则体系重构 + - 特征体系升级 +``` + +## 7.5 红蓝对抗 + +``` +红队: + - 模拟攻击者 + - 寻找绕过方式 + - 报告漏洞 + +蓝队: + - 加固规则 + - 修复漏洞 + - 持续演进 +``` + +--- + +# 附录:风控数据表 + +## A.1 规则配置表 + +```sql +CREATE TABLE risk_rule ( + id BIGINT PRIMARY KEY, + rule_code VARCHAR(32) UNIQUE, + name VARCHAR(128), + description TEXT, + category VARCHAR(32), + expression TEXT, -- DSL 或 Groovy + weight INT, + enabled TINYINT, + priority INT, + created_at BIGINT, + updated_at BIGINT +); +``` + +## A.2 决策记录表 + +```sql +CREATE TABLE risk_decision ( + id BIGINT PRIMARY KEY, + user_id BIGINT, + event_id VARCHAR(64), + rules_hit JSON, -- 命中的规则 + model_score DECIMAL(5,2), + total_score DECIMAL(5,2), + level TINYINT, + action VARCHAR(64), + context JSON, + created_at BIGINT, + KEY idx_user_time (user_id, created_at) +) PARTITION BY RANGE (created_at); +``` + +## A.3 申诉表 + +```sql +CREATE TABLE risk_appeal ( + id BIGINT PRIMARY KEY, + user_id BIGINT, + decision_id BIGINT, + reason TEXT, + evidence JSON, + status TINYINT, -- 0:pending 1:approved 2:rejected + reviewer BIGINT, + review_note TEXT, + created_at BIGINT, + reviewed_at BIGINT +); +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/06-Outbox-Worker-v1.0_Version4.md b/_drafts/IM/06-Outbox-Worker-v1.0_Version4.md new file mode 100755 index 000000000..3049c823a --- /dev/null +++ b/_drafts/IM/06-Outbox-Worker-v1.0_Version4.md @@ -0,0 +1,1019 @@ +# Outbox Worker 详细设计与实现 v1.0 + +> 适用:保证业务 DB 写入与 Kafka 投递的最终一致性 +> 模式:Transactional Outbox Pattern +> 目标:消息不丢、不重复、低延迟、可水平扩展 + +--- + +## 目录 + +1. 设计背景与目标 +2. 架构设计 +3. Outbox 表设计 +4. Worker 实现详解 +5. 投递保证 +6. 性能优化 +7. 故障处理 +8. 监控指标 +9. 部署与运维 + +--- + +# 1. 设计背景与目标 + +## 1.1 解决的问题 + +### 双写不一致 +``` +错误做法 1: 先写 DB 再发 Kafka + DB 成功 + Kafka 失败 → 消息丢失 + +错误做法 2: 先发 Kafka 再写 DB + Kafka 成功 + DB 失败 → 幻影消息 + +错误做法 3: XA / 2PC + 性能差,运维复杂,跨系统不可靠 +``` + +### Outbox 模式 +``` +事务内: + INSERT 业务表 + INSERT outbox_event ← 同事务原子写入 + +事务外: + Worker 扫 outbox → 投 Kafka → 标记已发 +``` + +## 1.2 设计目标 + +| 指标 | 目标 | +|---|---| +| 消息丢失率 | 0 | +| 重复率 | < 0.01%(消费侧幂等兜底) | +| 投递延迟 P99 | < 500ms | +| 吞吐 | 10 万 events/s | +| 故障恢复 | < 30s | + +--- + +# 2. 架构设计 + +## 2.1 整体架构 + +``` +┌────────────────────────────────────────────┐ +│ 业务服务 (MsgWrite/Recall/...) │ +│ 事务内: INSERT msg + INSERT outbox │ +└──────────────────┬─────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────┐ +│ outbox_event 表 (按 shard 分布) │ +└──────────────────┬─────────────────────────┘ + │ + ▼ +┌────────────────────────���───────────────────┐ +│ Outbox Worker 集群 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Worker 1 │ │ Worker 2 │ │ Worker N │ │ +│ │ (持锁) │ │ (持锁) │ │ (持锁) │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└──────────────────┬─────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────┐ +│ Kafka 集群 │ +└────────────────────────────────────────────┘ +``` + +## 2.2 Worker 调度模型 + +每个 outbox 分片由**一个 Worker 实例独占处理**,避免重复扫描。 + +``` +shard 0 → worker-1 (持锁) +shard 1 → worker-2 (持锁) +shard 2 → worker-3 (持锁) +... +shard N → worker-M + +如果 worker-1 挂: + 锁过期 (30s) + 其他 worker 抢锁 + 接管 shard 0 +``` + +锁实现:etcd / Redis SET NX EX。 + +--- + +# 3. Outbox 表设计 + +## 3.1 表结构 + +```sql +CREATE TABLE outbox_event ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + shard_id INT NOT NULL, -- 分片 ID(0~N-1) + event_type VARCHAR(64) NOT NULL, + topic VARCHAR(128) NOT NULL, -- 目标 Kafka topic + partition_key VARCHAR(128) NOT NULL, -- Kafka 分区键 + payload MEDIUMBLOB NOT NULL, -- 事件正文(Protobuf/JSON) + + status TINYINT NOT NULL DEFAULT 0, -- 0:pending 1:sent 2:failed + retry_count INT NOT NULL DEFAULT 0, + next_retry_at BIGINT, -- 下次重试时间 + last_error VARCHAR(512), + + created_at BIGINT NOT NULL, + sent_at BIGINT, + + KEY idx_pending (shard_id, status, id), -- Worker 扫描索引 + KEY idx_retry (status, next_retry_at) -- 重试扫描 +) ENGINE=InnoDB + PARTITION BY RANGE (id) ( + PARTITION p_2026_01 VALUES LESS THAN (...), + PARTITION p_2026_02 VALUES LESS THAN (...), + ... + ); +``` + +### 关键设计 + +#### shard_id +- 业务写入时按 hash 决定(如 `hash(conv_id) % N`) +- 同一业务实体的事件落同一 shard,保证顺序 +- N 通常 = Worker 数 × 2(留扩缩容空间) + +#### status +- `0 pending`:待投递 +- `1 sent`:已投递 +- `2 failed`:超过重试次数,进 DLQ + +#### partition_key +- 写入业务事件时就确定,Worker 不再计算 +- 通常是 `conv_id` 或 `user_id` + +#### payload +- 推荐 Protobuf(小、快) +- 事件结构里**不放完整消息内容**,只放 ID + 元数据 +- 消费者需要正文时按 ID 查 DB + +### 已发记录处理 + +```sql +-- 选项 A: 保留 N 天后归档/删除 +DELETE FROM outbox_event +WHERE status = 1 AND sent_at < NOW() - INTERVAL 7 DAY; + +-- 选项 B: 投递成功立即删除(推荐) +-- 但失去审计能力 +``` + +**推荐选项 A**:保留 7 天,便于排查问题。 + +## 3.2 索引选择 + +```sql +KEY idx_pending (shard_id, status, id) +``` + +Worker 查询: + +```sql +SELECT * FROM outbox_event +WHERE shard_id = ? AND status = 0 +ORDER BY id +LIMIT 1000; +``` + +走 `idx_pending`,O(log N) 定位 + 范围扫描。 + +## 3.3 容量估算 + +``` +事件量: 10 万/秒 +保留 7 天: 10 万 × 86400 × 7 = 60 亿 +单事件大小: ~500 字节 +存储: 3 TB + +按 shard 分库: + 32 shard → 单库 100 GB + 按月分区,便于归档 +``` + +--- + +# 4. Worker 实现详解 + +## 4.1 Worker 主循环 + +```go +type OutboxWorker struct { + shardID int + db *sql.DB + producer *kafka.Producer + locker DistributedLock + batchSize int + pollInterval time.Duration + metrics *Metrics +} + +func (w *OutboxWorker) Run(ctx context.Context) error { + // 1. 抢锁 + lock, err := w.locker.Acquire(ctx, fmt.Sprintf("outbox:shard:%d", w.shardID), 30*time.Second) + if err != nil { + return fmt.Errorf("acquire lock: %w", err) + } + defer lock.Release() + + // 2. 启动锁续约 + go w.renewLock(ctx, lock) + + // 3. 主循环 + ticker := time.NewTicker(w.pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + if err := w.processBatch(ctx); err != nil { + w.metrics.ErrorTotal.Inc() + log.Errorf("processBatch: %v", err) + } + } + } +} +``` + +## 4.2 批量处理 + +```go +func (w *OutboxWorker) processBatch(ctx context.Context) error { + // 1. 拉一批待发 + events, err := w.fetchPending(ctx, w.batchSize) + if err != nil { + return err + } + if len(events) == 0 { + return nil + } + + w.metrics.BatchSize.Observe(float64(len(events))) + + // 2. 批量投递 + successful, failed := w.batchProduce(ctx, events) + + // 3. 批量更新状态 + if err := w.markSent(ctx, successful); err != nil { + return fmt.Errorf("mark sent: %w", err) + } + + if len(failed) > 0 { + w.markFailed(ctx, failed) + } + + w.metrics.SentTotal.Add(float64(len(successful))) + w.metrics.FailedTotal.Add(float64(len(failed))) + + return nil +} +``` + +## 4.3 拉取待发事件 + +```go +func (w *OutboxWorker) fetchPending(ctx context.Context, limit int) ([]*Event, error) { + query := ` + SELECT id, event_type, topic, partition_key, payload, retry_count + FROM outbox_event + WHERE shard_id = ? + AND status = 0 + AND (next_retry_at IS NULL OR next_retry_at <= ?) + ORDER BY id + LIMIT ? + ` + + rows, err := w.db.QueryContext(ctx, query, w.shardID, time.Now().UnixMilli(), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var events []*Event + for rows.Next() { + e := &Event{} + if err := rows.Scan(&e.ID, &e.EventType, &e.Topic, &e.PartitionKey, &e.Payload, &e.RetryCount); err != nil { + return nil, err + } + events = append(events, e) + } + return events, nil +} +``` + +## 4.4 批量投递 Kafka + +```go +func (w *OutboxWorker) batchProduce(ctx context.Context, events []*Event) (successful []int64, failed []*Event) { + // 用 channel 收集结果 + deliveryChan := make(chan kafka.Event, len(events)) + + // 1. 发送 + for _, e := range events { + msg := &kafka.Message{ + TopicPartition: kafka.TopicPartition{ + Topic: &e.Topic, + Partition: kafka.PartitionAny, + }, + Key: []byte(e.PartitionKey), + Value: e.Payload, + Headers: []kafka.Header{ + {Key: "event_type", Value: []byte(e.EventType)}, + {Key: "outbox_id", Value: []byte(strconv.FormatInt(e.ID, 10))}, + {Key: "trace_id", Value: []byte(extractTraceID(ctx))}, + }, + } + + if err := w.producer.Produce(msg, deliveryChan); err != nil { + // 本地队列满,标记失败 + failed = append(failed, e) + continue + } + } + + // 2. 等待 ack + timeout := time.After(10 * time.Second) + eventByID := make(map[int64]*Event) + for _, e := range events { + eventByID[e.ID] = e + } + + received := 0 + expected := len(events) - len(failed) + + for received < expected { + select { + case ev := <-deliveryChan: + received++ + msg := ev.(*kafka.Message) + outboxID, _ := strconv.ParseInt(string(getHeader(msg.Headers, "outbox_id")), 10, 64) + e := eventByID[outboxID] + + if msg.TopicPartition.Error != nil { + failed = append(failed, e) + e.LastError = msg.TopicPartition.Error.Error() + } else { + successful = append(successful, e.ID) + } + + case <-timeout: + log.Warn("kafka ack timeout, will retry") + // 超时未 ack 的事件下次重试 + return + } + } + + return +} +``` + +## 4.5 标记已发(关键事务) + +```go +func (w *OutboxWorker) markSent(ctx context.Context, ids []int64) error { + if len(ids) == 0 { + return nil + } + + // 批量 UPDATE + query := fmt.Sprintf(` + UPDATE outbox_event + SET status = 1, sent_at = ? + WHERE id IN (%s) + `, placeholders(len(ids))) + + args := []interface{}{time.Now().UnixMilli()} + for _, id := range ids { + args = append(args, id) + } + + _, err := w.db.ExecContext(ctx, query, args...) + return err +} +``` + +## 4.6 标记失败 + 重试退避 + +```go +func (w *OutboxWorker) markFailed(ctx context.Context, events []*Event) error { + for _, e := range events { + e.RetryCount++ + + if e.RetryCount >= MaxRetries { + // 进入 DLQ + w.sendToDLQ(ctx, e) + + _, err := w.db.ExecContext(ctx, ` + UPDATE outbox_event + SET status = 2, last_error = ?, retry_count = ? + WHERE id = ? + `, e.LastError, e.RetryCount, e.ID) + if err != nil { + return err + } + continue + } + + // 计算下次重试时间(指数退避) + backoff := w.calcBackoff(e.RetryCount) + nextRetry := time.Now().Add(backoff).UnixMilli() + + _, err := w.db.ExecContext(ctx, ` + UPDATE outbox_event + SET retry_count = ?, next_retry_at = ?, last_error = ? + WHERE id = ? + `, e.RetryCount, nextRetry, e.LastError, e.ID) + if err != nil { + return err + } + } + return nil +} + +func (w *OutboxWorker) calcBackoff(retry int) time.Duration { + base := time.Second + max := 5 * time.Minute + + backoff := base * time.Duration(1< max { + backoff = max + } + + // 加随机抖动 ±20% + jitter := time.Duration(rand.Int63n(int64(backoff) / 5)) + return backoff + jitter - backoff/10 +} + +const MaxRetries = 8 // ~ 累计 8 分钟重试 +``` + +## 4.7 锁续约 + +```go +func (w *OutboxWorker) renewLock(ctx context.Context, lock DistributedLock) { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := lock.Renew(ctx, 30*time.Second); err != nil { + log.Errorf("renew lock failed: %v", err) + // 锁丢失,停止处理(其他 worker 会接管) + return + } + } + } +} +``` + +## 4.8 Kafka Producer 配置 + +```go +producerConfig := &kafka.ConfigMap{ + "bootstrap.servers": "kafka:9092", + + // 可靠性 + "acks": "all", + "enable.idempotence": true, + "max.in.flight.requests.per.connection": 5, + "retries": 10, + "retry.backoff.ms": 100, + + // 性能 + "linger.ms": 5, + "batch.size": 65536, + "compression.type": "lz4", + "queue.buffering.max.messages": 100000, + "queue.buffering.max.kbytes": 1048576, + + // 超时 + "delivery.timeout.ms": 30000, + "request.timeout.ms": 10000, +} +``` + +--- + +# 5. 投递保证 + +## 5.1 不丢消息 + +``` +关键点 1: 业务事务内写 outbox + 业务 INSERT + outbox INSERT 在同一事务 + 事务提交 → 事件必然存在 + +关键点 2: 投递成功才标记 + Kafka ack=all + + producer 幂等 + + 标记 status=1 在 ack 后 + +关键点 3: Worker 故障可恢复 + 锁过期,其他 worker 接管 + status=0 的事件继续被扫到 +``` + +## 5.2 不重复投递 + +``` +难点: 投递成功了但更新 status 失败 + 下次扫到又投一次 + +解决: + 1. Producer 幂等(enable.idempotence) + 单 producer 实例内自动去重 + + 2. 消费者幂等 + 按 outbox_id / server_msg_id 去重 + 这是最终防线 +``` + +## 5.3 顺序保证 + +``` +同一 partition_key 的事件: + - 落同一 outbox shard + - 单 Worker 顺序处理 + - 投到 Kafka 同一 partition + +所以同一 conv_id / user_id 的事件在 Kafka 内严格有序 +``` + +--- + +# 6. 性能优化 + +## 6.1 批量优化 + +```go +// 批量大小调优 +batchSize: 1000 events + +// 单事件 500B → 一批 500KB +// 单 Worker 吞吐: 1000 events × 100 batch/s = 100K events/s +``` + +## 6.2 并发模型 + +每个 Worker 单线程串行扫描,但内部可以并发: + +```go +// 并发投递 +func (w *OutboxWorker) parallelProduce(events []*Event) { + // 按 partition_key 分组 + groups := groupByPartitionKey(events) + + var wg sync.WaitGroup + for _, group := range groups { + wg.Add(1) + go func(g []*Event) { + defer wg.Done() + for _, e := range g { + w.producer.Produce(e) // 异步发送 + } + }(group) + } + wg.Wait() +} +``` + +注意:**同一 partition_key 必须串行**,否则破坏顺序。 + +## 6.3 减少 DB 压力 + +### 索引选择 +确保 `(shard_id, status, id)` 索引被用上。 + +### 避免全表扫描 +```sql +-- 错误:全表扫 +SELECT * FROM outbox_event WHERE status = 0; + +-- 正确:分片扫 +SELECT * FROM outbox_event WHERE shard_id = ? AND status = 0 LIMIT 1000; +``` + +### 已发数据归档 +```sql +-- 每天凌晨 +DELETE FROM outbox_event +WHERE status = 1 AND sent_at < NOW() - INTERVAL 7 DAY +LIMIT 10000; + +-- 分批删除,避免大事务 +``` + +## 6.4 拉取优化 + +### 长轮询 vs 定时 +``` +定时轮询: 每 50ms 扫一次 + 优点: 简单 + 缺点: 空轮询多 + +通知触发: + 业务写 outbox 后发个轻量信号给 Worker + Worker 收到信号立即处理 + Channel + 定时兜底 +``` + +```go +type OutboxWorker struct { + notifyChan chan struct{} + ... +} + +// 业务侧 +func InsertOutbox(...) { + db.Insert(...) + select { + case worker.notifyChan <- struct{}{}: + default: // 已有信号,丢弃 + } +} + +// Worker 侧 +for { + select { + case <-w.notifyChan: + w.processBatch() + case <-time.After(50 * time.Millisecond): + w.processBatch() + } +} +``` + +## 6.5 性能基线 + +``` +单 Worker: + - 拉取: 5 ms (1000 行) + - 投递: 10 ms (Kafka batch) + - 标记: 5 ms (UPDATE) + - 总: 20 ms / batch + - 吞吐: 1000 / 0.02 = 50K events/s + +10 个 Worker: + - 总吞吐: 500K events/s +``` + +--- + +# 7. 故障处理 + +## 7.1 故障矩阵 + +| 故障 | 影响 | 恢复 | +|---|---|---| +| Worker 进程崩溃 | 该 shard 暂停 | 锁过期 30s → 其他 worker 接管 | +| DB 主挂 | 全部 worker 暂停 | DB 切主 → 自动恢复 | +| Kafka 不可用 | 投递失败累积 | Outbox 堆积,Kafka 恢复后追赶 | +| 锁服务(etcd)挂 | 抢锁失败 | 等待恢复(可短暂双扫,幂等兜底) | +| 网络分区 | 部分 worker 失联 | 锁过期,分区方接管 | + +## 7.2 双 Worker 抢锁 + +``` +worker-1 (持锁) 假死 + │ + ▼ 锁未过期但停止续约 + │ + ▼ 30s 后锁过期 + │ + ▼ worker-2 抢到锁 + │ + ▼ worker-1 恢复,发现锁丢失 + │ + ▼ worker-1 退出 +``` + +短暂的双 worker 处理是可能的,但: +- Producer 幂等 → 不会双投 +- 消费者幂等 → 兜底 + +## 7.3 DLQ 处理 + +```sql +-- 失败事件 +SELECT * FROM outbox_event WHERE status = 2; +``` + +```go +// DLQ 消费器 +func processDLQ() { + events := db.Query("SELECT * FROM outbox_event WHERE status=2") + for _, e := range events { + // 1. 人工分析失败原因 + // 2. 修复后可重置为 status=0 + // 3. 或者标记为 status=3 (人工已处理) + } +} +``` + +DLQ 告警: + +``` +SELECT count(*) FROM outbox_event WHERE status=2 +> 100 触发告警 +``` + +## 7.4 Outbox 堆积 + +``` +触发条件: status=0 行数 > 阈值 + +排查: + - Worker 是否在工作? + - Kafka 是否健康? + - DB 是否有锁? + +处置: + - 临时增加 Worker 实例 + - 增大 batchSize + - 关闭非关键事件类型 +``` + +## 7.5 数据修复 + +``` +场景: 业务事务提交了,但 outbox 记录被误删 + +恢复: + 1. 从业务表反向重建事件 + 2. INSERT INTO outbox_event SELECT FROM message ... + 3. 设置正确的 partition_key 和 topic +``` + +--- + +# 8. 监控指标 + +## 8.1 关键指标 + +| 指标 | 类型 | 说明 | +|---|---|---| +| `outbox_pending_count{shard}` | Gauge | 待发数量(堆积) | +| `outbox_sent_total{shard,topic}` | Counter | 已发总数 | +| `outbox_failed_total{shard,reason}` | Counter | 失败总数 | +| `outbox_dlq_count{shard}` | Gauge | DLQ 数量 | +| `outbox_batch_size` | Histogram | 批次大小 | +| `outbox_process_duration{shard}` | Histogram | 单批处理耗时 | +| `outbox_age_seconds{shard}` | Gauge | 最老 pending 事件年龄 | +| `outbox_worker_active{shard}` | Gauge | Worker 是否在线 | +| `outbox_lock_lost_total` | Counter | 锁丢失次数 | + +## 8.2 告警规则 + +```yaml +- alert: OutboxBacklog + expr: outbox_pending_count > 10000 + for: 1m + severity: P1 + +- alert: OutboxStale + expr: outbox_age_seconds > 60 + for: 1m + severity: P1 + +- alert: OutboxDLQGrowing + expr: rate(outbox_failed_total[5m]) > 1 + for: 5m + severity: P2 + +- alert: OutboxWorkerDown + expr: outbox_worker_active == 0 + for: 30s + severity: P0 +``` + +## 8.3 Grafana 大盘 + +``` +Panel 1: 实时吞吐 (events/s by topic) +Panel 2: 堆积量 (pending by shard) +Panel 3: 投递延迟 P50/P99 +Panel 4: 失败率 +Panel 5: DLQ 增长趋势 +Panel 6: Worker 健康 +``` + +--- + +# 9. 部署与运维 + +## 9.1 部署架构 + +```yaml +# K8s Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: outbox-worker +spec: + replicas: 16 # = shard 数 × 1.5(冗余) + template: + spec: + containers: + - name: worker + image: im/outbox-worker:v1.0 + env: + - name: WORKER_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: SHARD_COUNT + value: "32" + resources: + requests: + cpu: 1 + memory: 2Gi + limits: + cpu: 2 + memory: 4Gi +``` + +## 9.2 配置示例 + +```yaml +outbox: + shard_count: 32 + batch_size: 1000 + poll_interval: 50ms + max_retries: 8 + + lock: + backend: etcd # 或 redis + ttl: 30s + renew_interval: 10s + + kafka: + bootstrap_servers: "kafka:9092" + acks: all + idempotence: true + compression: lz4 + + db: + max_connections: 20 + query_timeout: 5s +``` + +## 9.3 滚动升级 + +``` +1. 新版本 Worker 启动 +2. 老 Worker 收到 SIGTERM +3. 老 Worker 处理完当前 batch +4. 老 Worker 释放锁 +5. 新 Worker 抢锁继续 +6. 全程不停机 +``` + +```go +func (w *OutboxWorker) shutdown() { + log.Info("graceful shutdown begin") + + // 1. 停止接收新批次 + w.cancel() + + // 2. 等待当前批次完成(最多 30s) + w.waitCurrentBatch(30 * time.Second) + + // 3. 释放锁 + w.lock.Release() + + log.Info("graceful shutdown done") +} +``` + +## 9.4 容量规划 + +``` +业务峰值: 50 万消息/秒 +每条消息: 1~5 个 outbox 事件(fanout/push/inbox) +峰值事件: 200 万 events/s + +单 Worker: 50K events/s +所需 Worker: 40 个 + +留余量: 64 个 Worker +对应 shard 数: 32(每 shard 配 2 个 worker,但只 1 个持锁) +``` + +## 9.5 故障演练 + +``` +每月一次演练: +- 杀掉某个 Worker → 看接管时间 +- 临时阻断 Kafka → 看堆积情况 +- DB 主切换 → 看自愈 +- 网络分区 → 看锁行为 +``` + +--- + +# 附录:完整代码骨架 + +```go +package outbox + +import ( + "context" + "database/sql" + "fmt" + "math/rand" + "time" + + "github.com/confluentinc/confluent-kafka-go/v2/kafka" +) + +type Event struct { + ID int64 + EventType string + Topic string + PartitionKey string + Payload []byte + RetryCount int + LastError string +} + +type OutboxWorker struct { + shardID int + db *sql.DB + producer *kafka.Producer + locker DistributedLock + config Config + metrics *Metrics + notifyChan chan struct{} +} + +type Config struct { + BatchSize int + PollInterval time.Duration + MaxRetries int +} + +func NewWorker(shardID int, db *sql.DB, producer *kafka.Producer, locker DistributedLock, cfg Config) *OutboxWorker { + return &OutboxWorker{ + shardID: shardID, + db: db, + producer: producer, + locker: locker, + config: cfg, + metrics: NewMetrics(), + notifyChan: make(chan struct{}, 1), + } +} + +func (w *OutboxWorker) Run(ctx context.Context) error { + lockKey := fmt.Sprintf("outbox:shard:%d", w.shardID) + lock, err := w.locker.AcquireWithRetry(ctx, lockKey, 30*time.Second) + if err != nil { + return err + } + defer lock.Release(ctx) + + go w.renewLock(ctx, lock) + + timer := time.NewTimer(w.config.PollInterval) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return nil + case <-w.notifyChan: + case <-timer.C: + } + + if err := w.processBatch(ctx); err != nil { + w.metrics.ErrorTotal.Inc() + } + + timer.Reset(w.config.PollInterval) + } +} + +// ... (其他方法见上文) +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/07-Push-Service-v1.0_Version4.md b/_drafts/IM/07-Push-Service-v1.0_Version4.md new file mode 100755 index 000000000..4f570440b --- /dev/null +++ b/_drafts/IM/07-Push-Service-v1.0_Version4.md @@ -0,0 +1,1098 @@ +# Push 服务多厂商对接 v1.0 + +> 适用:iOS/Android 离线消息推送 +> 厂商:APNs, FCM, 华为, 小米, OPPO, vivo, 魅族, 荣耀 +> 目标:高送达率、低延迟、容灾切换 + +--- + +## 目录 + +1. 总体架构 +2. 厂商通道对比 +3. 通道选择策略 +4. 各厂商对接细节 +5. Token 管理 +6. 推送内容设计 +7. 推送优先级与合并 +8. 失败处理与降级 +9. 配额与限流 +10. 监控指标 +11. 性能优化 + +--- + +# 1. 总体架构 + +## 1.1 架构图 + +``` +┌────────────────────────────────────────────┐ +│ Kafka: msg.push.high / msg.push.normal │ +└──────────────────┬─────────────────────────┘ + │ + ▼ +┌────────────────────────────────────────────┐ +│ Push Sender 集群 │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Sender 1 │ │ Sender 2 │ │ Sender N │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +└──────────────────┬─────────────────────────┘ + │ + ┌──────────┴───────────┐ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Channel │ │ Token │ +│ Router │ │ Manager │ +│ (选哪个厂商) │ │ (Token 存取) │ +└──────┬───────┘ └──────────────┘ + │ + ├──────────┬──────────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ + │APNs │ │FCM │ │华为 │ │小米 │ │OPPO │ + └─────┘ └─────┘ └─────┘ └─────┘ └─────┘ +``` + +## 1.2 核心模块 + +| 模块 | 职责 | +|---|---| +| **Push Sender** | 消费 Kafka,统一推送入口 | +| **Channel Router** | 选择最合适的推送通道 | +| **Channel Adapter** | 各厂商协议适配 | +| **Token Manager** | 设备 Token CRUD + 缓存 | +| **Rate Limiter** | 厂商配额控制 | +| **Retry Queue** | 失败重试 | +| **DLQ** | 死信队列 | + +--- + +# 2. 厂商通道对比 + +## 2.1 主流通道对比 + +| 通道 | 平台 | 协议 | QPS 限制 | 送达率 | 时延 | 备注 | +|---|---|---|---|---|---|---| +| **APNs** | iOS | HTTP/2 | 单连接高 | > 95% | < 1s | Apple 官方 | +| **FCM** | Android (海外) | HTTP/XMPP | 较高 | > 90% | 1~5s | Google 官方 | +| **华为 Push** | 华为机型 | REST | 5000/s | > 95% | < 3s | 国内首选 | +| **小米 Push** | 小米机型 | REST | 限频 | > 95% | < 3s | 国内 | +| **OPPO Push** | OPPO 机型 | REST | 限频 | > 90% | < 3s | 国内 | +| **vivo Push** | vivo 机型 | REST | 限频 | > 90% | < 3s | 国内 | +| **魅族 Push** | 魅族机型 | REST | 限频 | > 90% | < 3s | 国内 | +| **荣耀 Push** | 荣耀机型 | REST | 限频 | > 95% | < 3s | 国内 | +| **应用自有通道** | Android (App 在线) | 长连接 | 不限 | 100% | < 100ms | 退到后台失效 | + +## 2.2 国内 vs 国外 + +``` +国内: + - 华为/小米/OPPO/vivo 等系统通道(杀进程也能推) + - FCM 在国内不可用 + - 自建长连接(App 在线时) + +国外: + - FCM 是事实标准 + - APNs (iOS) + - Web Push (浏览器) +``` + +## 2.3 通道分级 + +``` +Tier 1 (高优先级,立即送达): + - APNs + - 厂商系统通道(华为/小米/OPPO/vivo/...) + - FCM high priority + +Tier 2 (普通): + - FCM normal + - 自有长连接 + +Tier 3 (营销): + - 营销专用通道 + - 限频严格 +``` + +--- + +# 3. 通道选择策略 + +## 3.1 决策流程 + +``` +推送请求 + │ + ▼ +[1] 用户在线? → 自有长连接 → 完成 + │ + ▼ 离线 +[2] iOS? → APNs → 完成 + │ + ▼ Android +[3] 设备品牌? + ├─ 华为 → 华为 Push + ├─ 小米 → 小米 Push + ├─ OPPO → OPPO Push + ├─ vivo → vivo Push + ├─ 魅族 → 魅族 Push + ├─ 荣耀 → 荣耀 Push + └─ 其他/Google 系 → FCM +``` + +## 3.2 厂商识别 + +```kotlin +// Android 客户端识别厂商 +fun detectVendor(): String { + val manufacturer = Build.MANUFACTURER.lowercase() + val brand = Build.BRAND.lowercase() + + return when { + "huawei" in manufacturer || "huawei" in brand -> "huawei" + "honor" in manufacturer || "honor" in brand -> "honor" + "xiaomi" in manufacturer || "redmi" in brand -> "xiaomi" + "oppo" in manufacturer || "realme" in brand -> "oppo" + "vivo" in manufacturer || "iqoo" in brand -> "vivo" + "meizu" in manufacturer -> "meizu" + else -> "fcm" + } +} +``` + +客户端注册时上报到服务端: + +```json +{ + "device_id": "...", + "platform": "android", + "vendor": "huawei", + "push_token": "...", + "app_version": "1.0.0" +} +``` + +## 3.3 多通道兜底 + +``` +主通道发送 → 失败/超时 + │ + ▼ 降级 +副通道 (FCM/自有长连接) + │ + ▼ 仍失败 +进入 DLQ +``` + +实现: + +```go +func (s *PushSender) sendWithFallback(ctx context.Context, req *PushRequest) error { + primary := s.router.Select(req.Device) + + err := s.send(ctx, primary, req) + if err == nil { + return nil + } + + // 降级到 FCM(如果设备支持) + if req.Device.HasGooglePlay { + err2 := s.send(ctx, ChannelFCM, req) + if err2 == nil { + return nil + } + } + + // 降级到自有通道(只在 App 在线时有用) + if s.isAppActive(req.Device) { + return s.sendInApp(ctx, req) + } + + return err +} +``` + +--- + +# 4. 各厂商对接细节 + +## 4.1 APNs (iOS) + +### 协议 +``` +HTTP/2 长连接 +端点: api.push.apple.com:443 (生产) + api.development.push.apple.com:443 (开发) +``` + +### 鉴权 +``` +方式 1: 证书 (.p12) +方式 2: Token-based (.p8) - 推荐 +``` + +### Token-based 鉴权代码 + +```go +import "github.com/sideshow/apns2" +import "github.com/sideshow/apns2/token" + +func newAPNSClient() *apns2.Client { + authKey, _ := token.AuthKeyFromFile("AuthKey_XXX.p8") + + apnsToken := &token.Token{ + AuthKey: authKey, + KeyID: "ABCDEF1234", + TeamID: "1234567890", + } + + return apns2.NewTokenClient(apnsToken).Production() +} + +func sendAPNS(client *apns2.Client, deviceToken string, payload []byte) error { + notification := &apns2.Notification{ + DeviceToken: deviceToken, + Topic: "com.example.app", + Payload: payload, + Priority: apns2.PriorityHigh, + PushType: apns2.PushTypeAlert, + CollapseID: "conv_123", // 同会话合并 + Expiration: time.Now().Add(24 * time.Hour), + } + + res, err := client.Push(notification) + if err != nil { + return err + } + + if res.Sent() { + return nil + } + return fmt.Errorf("apns reject: %s", res.Reason) +} +``` + +### Payload 格式 + +```json +{ + "aps": { + "alert": { + "title": "群聊名称", + "subtitle": "张三", + "body": "你好" + }, + "badge": 5, + "sound": "default", + "thread-id": "conv_123", + "category": "im_message", + "mutable-content": 1, + "content-available": 1 + }, + "ext": { + "conv_id": "c123", + "msg_id": "s888", + "type": "mention" + } +} +``` + +### 关键字段 + +| 字段 | 用途 | +|---|---| +| `apns-priority` | 10 (立即) / 5 (省电) | +| `apns-collapse-id` | 同 ID 替换旧推送 | +| `apns-expiration` | 过期时间 | +| `apns-push-type` | alert / background / voip | +| `thread-id` | 通知分组 | +| `mutable-content` | 触发 NSE 修改内容 | + +### 错误处理 + +```go +// 常见错误 +const ( + APNsBadDeviceToken = "BadDeviceToken" + APNsUnregistered = "Unregistered" + APNsExpiredToken = "ExpiredToken" + APNsTooManyRequests = "TooManyRequests" + APNsPayloadTooLarge = "PayloadTooLarge" +) + +func handleAPNSError(reason string, deviceToken string) { + switch reason { + case APNsBadDeviceToken, APNsUnregistered, APNsExpiredToken: + // 永久失败,删除 token + tokenMgr.Delete(deviceToken) + case APNsTooManyRequests: + // 临时失败,限流后重试 + time.Sleep(1 * time.Second) + } +} +``` + +## 4.2 FCM (Firebase Cloud Messaging) + +### 协议 +``` +HTTP/1 (Legacy) / HTTP v1 (推荐) +端点: fcm.googleapis.com/v1/projects/{project_id}/messages:send +``` + +### 鉴权 +``` +OAuth2 Service Account +``` + +### 代码示例 + +```go +import "firebase.google.com/go/v4/messaging" + +func sendFCM(client *messaging.Client, token string, msg *Message) error { + fcmMsg := &messaging.Message{ + Token: token, + Notification: &messaging.Notification{ + Title: msg.Title, + Body: msg.Body, + }, + Data: msg.Data, + Android: &messaging.AndroidConfig{ + Priority: "high", + CollapseKey: msg.ConvID, + TTL: ptr(24 * time.Hour), + }, + APNS: &messaging.APNSConfig{ + Headers: map[string]string{ + "apns-priority": "10", + }, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := client.Send(ctx, fcmMsg) + return err +} +``` + +### 错误处理 + +```go +const ( + FCMInvalidArgument = "INVALID_ARGUMENT" + FCMUnregistered = "UNREGISTERED" // token 失效 + FCMSenderIDMismatch = "SENDER_ID_MISMATCH" + FCMQuotaExceeded = "QUOTA_EXCEEDED" + FCMUnavailable = "UNAVAILABLE" +) +``` + +## 4.3 华为 Push + +### 端点 +``` +https://push-api.cloud.huawei.com/v1/{appId}/messages:send +``` + +### 鉴权 +``` +1. 客户端 Credential → 获取 access_token (有效期 1h) +2. 请求带 Authorization: Bearer {access_token} +``` + +### Token 获取 + +```go +func getHuaweiAccessToken() (string, error) { + resp, err := http.PostForm( + "https://oauth-login.cloud.huawei.com/oauth2/v3/token", + url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + }, + ) + if err != nil { + return "", err + } + + var result struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + } + json.NewDecoder(resp.Body).Decode(&result) + + // 缓存 token,提前 5min 刷新 + tokenCache.Set(result.AccessToken, time.Duration(result.ExpiresIn-300)*time.Second) + + return result.AccessToken, nil +} +``` + +### 推送请求 + +```json +POST /v1/{appId}/messages:send +Authorization: Bearer {access_token} +Content-Type: application/json + +{ + "validate_only": false, + "message": { + "notification": { + "title": "群聊名", + "body": "张三: 你好" + }, + "android": { + "collapse_key": -1, + "urgency": "HIGH", + "ttl": "86400s", + "category": "IM", + "notification": { + "title": "群聊名", + "body": "张三: 你好", + "click_action": { + "type": 1, + "intent": "intent://...", + "action": "..." + } + } + }, + "data": "{\"conv_id\":\"c123\",\"msg_id\":\"s888\"}", + "token": ["push_token_1"] + } +} +``` + +### 关键参数 + +| 参数 | 说明 | +|---|---| +| `urgency` | HIGH / NORMAL | +| `category` | IM (即时通讯,不限频) / VOIP / SOCIAL_COMMUNICATION | +| `ttl` | 离线消息存活 | +| `collapse_key` | 同 key 合并 | + +### 重要:分类申请 + +华为对推送有严格分类管理: +- **服务与通讯类(IM)** ← 即时通讯申请 +- **资讯营销类**:限频,每天限制条数 + +必须申请 IM 自分类权益才能不限频。 + +## 4.4 小米 Push + +### 端点 +``` +国内: https://api.xmpush.xiaomi.com/v3/message/regid +国际: https://api.xmpush.global.xiaomi.com/... +``` + +### 鉴权 +``` +HTTP Header: Authorization: key={AppSecret} +``` + +### 代码示例 + +```go +func sendXiaomi(req *XiaomiRequest) error { + form := url.Values{ + "registration_id": {req.Token}, + "title": {req.Title}, + "description": {req.Body}, + "payload": {req.PayloadJSON}, + "notify_type": {"-1"}, // 默认提示方式 + "pass_through": {"0"}, // 通知栏消息 + "extra.notify_foreground": {"1"}, + "extra.channel_id": {"im_high"}, // 通知分组 + } + + httpReq, _ := http.NewRequest("POST", endpoint, strings.NewReader(form.Encode())) + httpReq.Header.Set("Authorization", "key="+appSecret) + httpReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(httpReq) + // ... +} +``` + +### 通道分类 + +``` +默认通道: 限频 1000 条/秒 +IM 通道: 申请后不限频 +资讯营销通道: 限频 +``` + +## 4.5 OPPO Push + +### 端点 +``` +https://api.push.oppomobile.com/server/v1/auth # 鉴权 +https://api.push.oppomobile.com/server/v1/message/notification/unicast # 单推 +``` + +### 鉴权(每天获取一次 auth_token) + +```go +func getOPPOAuthToken() (string, error) { + timestamp := time.Now().UnixMilli() + sign := sha256(fmt.Sprintf("%s%d%s", appKey, timestamp, masterSecret)) + + // POST 拿 auth_token + // ... +} +``` + +## 4.6 vivo Push + +### 端点 +``` +https://api-push.vivo.com.cn/message/auth +https://api-push.vivo.com.cn/message/send +``` + +### 关键限制 +- 单设备每天 5 条普通消息 +- IM 类需申请白名单 + +## 4.7 各厂商共性总结 + +``` +都需要: + 1. 客户端 SDK 集成 + 2. 注册时获取 device token + 3. 上报服务端 + 4. 服务端按 token 推送 + 5. 鉴权 (token / signature) + 6. 错误处理(token 失效要清理) + +都支持: + - 通知栏消息 + - 透传消息(应用在线才收到) + - 角标 + - 折叠(collapse) + - TTL +``` + +--- + +# 5. Token 管理 + +## 5.1 Token 表设计 + +```sql +CREATE TABLE push_token ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT NOT NULL, + device_id VARCHAR(64) NOT NULL, + platform VARCHAR(16) NOT NULL, -- ios / android + vendor VARCHAR(32) NOT NULL, -- apns / fcm / huawei / xiaomi ... + token VARCHAR(512) NOT NULL, + app_version VARCHAR(32), + os_version VARCHAR(32), + language VARCHAR(8), + timezone VARCHAR(32), + status TINYINT DEFAULT 1, -- 0:invalid 1:active + last_active_at BIGINT, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL, + + UNIQUE KEY uk_device (user_id, device_id), + KEY idx_user (user_id), + KEY idx_token (token(64)) -- 反查用 +) PARTITION BY HASH(user_id) PARTITIONS 64; +``` + +## 5.2 Token 注册 + +```go +func RegisterPushToken(req *RegisterReq) error { + // 1. 校验 + if req.Token == "" || req.Vendor == "" { + return ErrInvalidArg + } + + // 2. UPSERT + db.Exec(` + INSERT INTO push_token (user_id, device_id, platform, vendor, token, ...) + VALUES (?, ?, ?, ?, ?, ...) + ON DUPLICATE KEY UPDATE + token = VALUES(token), + vendor = VALUES(vendor), + updated_at = VALUES(updated_at) + `, req.UserID, req.DeviceID, req.Platform, req.Vendor, req.Token) + + // 3. 缓存 + redis.HSet(fmt.Sprintf("push_tokens:%d", req.UserID), req.DeviceID, req.Token) + + return nil +} +``` + +## 5.3 Token 失效处理 + +```go +func MarkTokenInvalid(token string, reason string) { + db.Exec(` + UPDATE push_token + SET status = 0, updated_at = ? + WHERE token = ? + `, time.Now().UnixMilli(), token) + + // 清缓存 + // 上报指标 + metrics.TokenInvalid.WithLabelValues(reason).Inc() +} + +// 各厂商失败时调用 +case APNsBadDeviceToken, APNsUnregistered: + MarkTokenInvalid(token, "apns_unregistered") +case FCMUnregistered: + MarkTokenInvalid(token, "fcm_unregistered") +``` + +## 5.4 Token 缓存 + +``` +Key: push_tokens:{userId} +Type: Hash +Field: deviceId +Value: {vendor, token, ...} (JSON) +TTL: 1h +``` + +每次推送优先查 Redis,miss 回源 DB 并回填。 + +## 5.5 Token 清理 + +```sql +-- 每天清理 30 天未活跃的 token +DELETE FROM push_token +WHERE last_active_at < UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) * 1000; + +-- 或:长期失效的 +DELETE FROM push_token +WHERE status = 0 AND updated_at < UNIX_TIMESTAMP(NOW() - INTERVAL 7 DAY) * 1000; +``` + +--- + +# 6. 推送内容设计 + +## 6.1 内容结构 + +```protobuf +message PushPayload { + string title = 1; + string body = 2; + string subtitle = 3; + + // 显示 + string sound = 10; + int32 badge = 11; + string thread_id = 12; // 分组 + string category = 13; + + // 数据 + string conv_id = 20; + string msg_id = 21; + string sender_id = 22; + string msg_type = 23; // text / image / mention / call + + // 行为 + string click_action = 30; // 点击跳转 + + // 优先级 + int32 priority = 40; // 1:high 0:normal + string collapse_id = 41; // 合并 ID + int64 expiration = 42; // 过期时间 +} +``` + +## 6.2 隐私模式 + +```kotlin +fun buildPushBody(msg: Message, settings: PushSettings): String { + return when (settings.previewMode) { + FULL -> "${msg.sender}: ${msg.preview}" + SENDER_ONLY -> "${msg.sender} 发来一条消息" + HIDDEN -> "您有一条新消息" + } +} +``` + +## 6.3 多语言 + +服务端按用户语言生成不同内容: + +```go +func localizedPushBody(userID int64, msg *Message) string { + lang := getUserLanguage(userID) // zh-CN / en-US / ja-JP + + template := i18n.Get(lang, "push.body") + return fmt.Sprintf(template, msg.Sender, msg.Preview) +} +``` + +## 6.4 富文本推送(图片/卡片) + +``` +APNs: + - mutable-content: 1 + - 客户端 NSE 下载图片 + +Android: + - BigPictureStyle / BigTextStyle + - 各厂商支持不同 +``` + +--- + +# 7. 推送优先级与合并 + +## 7.1 优先级映射 + +| 业务场景 | 优先级 | 通道 | 合并 | +|---|---|---|---| +| @ 我的消息 | 高 | apns_high / fcm_high | 否 | +| 私聊 | 高 | apns_high / fcm_high | 否 | +| 引用回复 | 高 | apns_high / fcm_high | 否 | +| 普通群消息 | 中 | apns / fcm | 是(按会话合并) | +| 系统通知 | 中 | apns / fcm | 否 | +| 营销消息 | 低 | 营销专用通道 | 是 | + +## 7.2 合并策略 + +### 时间窗口聚合 + +``` +1 秒内同一 (userId, convId) 多条消息 +合并为 1 条: "X 条新消息" +``` + +实现: + +```go +type AggregateKey struct { + UserID int64 + ConvID int64 +} + +type Aggregator struct { + pending map[AggregateKey][]*Message + timer *time.Timer + mu sync.Mutex +} + +func (a *Aggregator) Add(msg *Message) { + a.mu.Lock() + defer a.mu.Unlock() + + key := AggregateKey{msg.UserID, msg.ConvID} + a.pending[key] = append(a.pending[key], msg) + + if a.timer == nil { + a.timer = time.AfterFunc(1*time.Second, a.flush) + } +} + +func (a *Aggregator) flush() { + a.mu.Lock() + pending := a.pending + a.pending = make(map[AggregateKey][]*Message) + a.timer = nil + a.mu.Unlock() + + for key, msgs := range pending { + if len(msgs) == 1 { + sendNormal(msgs[0]) + } else { + sendAggregated(key, msgs) + } + } +} +``` + +### 利用厂商合并能力 + +``` +APNs: apns-collapse-id +FCM: collapse_key +华为: collapse_key +小米: notify_id + +设置同 ID → 系统替换旧通知 +``` + +## 7.3 免打扰处理 + +```go +func shouldSendPush(userID int64, msg *Message) bool { + settings := getUserPushSettings(userID) + + // 全局免打扰 + if settings.GlobalMute && !msg.IsMention { + return false + } + + // 时间段免打扰 + if isInQuietHours(settings, time.Now()) && !msg.IsMention { + return false + } + + // 会话免打扰 + convSettings := getConvPushSettings(userID, msg.ConvID) + if convSettings.Muted { + // @ 我例外 + if msg.IsMention && convSettings.AllowMentionInMute { + return true + } + return false + } + + return true +} +``` + +--- + +# 8. 失败处理与降级 + +## 8.1 错误分类 + +| 类型 | 例子 | 处理 | +|---|---|---| +| 永久失败 | token 失效、不合法 | 删除 token,不重试 | +| 临时失败 | 网络抖动、限流 | 退避重试 | +| 配额耗尽 | 厂商 QPS 超限 | 降级到备用通道 | +| 内容拒绝 | payload 过大 | 缩减内容重发 | + +## 8.2 重试机制 + +```go +func (s *PushSender) sendWithRetry(ctx context.Context, req *PushRequest) error { + backoff := []time.Duration{ + 100 * time.Millisecond, + 500 * time.Millisecond, + 2 * time.Second, + } + + for i, delay := range backoff { + if i > 0 { + time.Sleep(delay) + } + + err := s.send(ctx, req) + if err == nil { + return nil + } + + if !isRetryable(err) { + return err // 永久失败 + } + } + + // 重试用尽,进 DLQ + return s.sendToDLQ(req) +} +``` + +## 8.3 DLQ 处理 + +``` +DLQ topic: msg.push.high.dlq + +消费者: + - 记录失败原因 + - 人工分析模式 + - 部分错误可补偿(如 token 刷新) +``` + +## 8.4 降级策略 + +```go +// 厂商熔断 +type ChannelHealth struct { + SuccessRate float64 + LastError time.Time +} + +func (s *PushSender) selectChannel(device *Device) Channel { + primary := primaryChannelFor(device) + + health := s.health[primary] + if health.SuccessRate < 0.5 && time.Since(health.LastError) < 1*time.Minute { + // 降级到备用 + return fallbackChannelFor(device) + } + + return primary +} +``` + +--- + +# 9. 配额与限流 + +## 9.1 厂商配额 + +``` +APNs: 单连接 ~1000 QPS(HTTP/2 多路复用) +FCM: ~6000 QPS / project +华为: 5000 QPS(默认),可申请提升 +小米: 3000 QPS(默认) +OPPO: 限频严格 +vivo: 限频严格 +``` + +## 9.2 限流实现 + +```go +type RateLimiter struct { + limiters map[Channel]*rate.Limiter +} + +func NewRateLimiter() *RateLimiter { + return &RateLimiter{ + limiters: map[Channel]*rate.Limiter{ + ChannelAPNs: rate.NewLimiter(rate.Limit(1000), 100), + ChannelFCM: rate.NewLimiter(rate.Limit(5000), 500), + ChannelHuawei: rate.NewLimiter(rate.Limit(4000), 400), + ChannelXiaomi: rate.NewLimiter(rate.Limit(2500), 250), + }, + } +} + +func (r *RateLimiter) Allow(ch Channel) bool { + return r.limiters[ch].Allow() +} +``` + +超限时排队: + +```go +func (s *PushSender) send(ctx context.Context, req *PushRequest) error { + // 等待限流(最多 5s) + waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + if err := s.rateLimiter.Wait(waitCtx, req.Channel); err != nil { + return ErrRateLimited + } + + return s.adapter.Send(ctx, req) +} +``` + +--- + +# 10. 监控指标 + +## 10.1 关键指标 + +| 指标 | 说明 | +|---|---| +| `push_sent_total{channel,result}` | 推送总数 | +| `push_latency{channel}` | 推送延迟 | +| `push_success_rate{channel}` | 成功率 | +| `push_token_invalid{channel,reason}` | Token 失效数 | +| `push_rate_limited{channel}` | 被限流次数 | +| `push_dlq_size{topic}` | DLQ 堆积 | +| `push_quota_remaining{channel}` | 配额余量 | + +## 10.2 告警 + +``` +P0: 任一厂商成功率 < 80% 持续 5 分钟 +P1: DLQ 增长率异常 +P1: 配额告罄 +P2: 单个 token 连续失败 +``` + +## 10.3 大盘 + +``` +- 各厂商 QPS / 成功率 +- 各错误码分布 +- Token 池规模 +- 端到端延迟(消息入库 → 推送送达) +``` + +--- + +# 11. 性能优化 + +## 11.1 连接复用 + +``` +APNs: HTTP/2 长连接,多路复用 +FCM: HTTP/2 +其他: HTTP/1.1 keepalive +``` + +## 11.2 批量发送 + +部分厂商支持批量: + +``` +FCM: multicast (一次最多 500 token) +APNs: 不支持批量,但 HTTP/2 高并发即可 +华为: batch send (一次最多 1000 token) +小米: regid_list (一次最多 1000 token) +``` + +## 11.3 异步并发 + +```go +// 同一推送任务给多个用户 +func (s *PushSender) batchSend(reqs []*PushRequest) { + sem := make(chan struct{}, 100) // 并发 100 + var wg sync.WaitGroup + + for _, req := range reqs { + wg.Add(1) + sem <- struct{}{} + go func(r *PushRequest) { + defer wg.Done() + defer func() { <-sem }() + s.send(context.Background(), r) + }(req) + } + + wg.Wait() +} +``` + +## 11.4 缓存 + +- Access Token 缓存(华为、OPPO、vivo 都需要) +- Token 缓存(Redis) +- 用户推送设置缓存 + +--- + +# 附录:各厂商对接 Checklist + +``` +[ ] 注册厂商开发者账号 +[ ] 创建 App / 获取 AppID/AppKey/AppSecret +[ ] 客户端集成 SDK +[ ] 客户端获取 Device Token +[ ] 服务端实现推送 API +[ ] 处理 Token 失效 +[ ] 申请 IM 自分类(华为/小米/OPPO/vivo) +[ ] 监控接入 +[ ] 测试覆盖(前台/后台/锁屏/灭屏) +[ ] 灰度上线 +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/08-Kafka-MirrorMaker-v1.0_Version4.md b/_drafts/IM/08-Kafka-MirrorMaker-v1.0_Version4.md new file mode 100755 index 000000000..c72127399 --- /dev/null +++ b/_drafts/IM/08-Kafka-MirrorMaker-v1.0_Version4.md @@ -0,0 +1,885 @@ +# 跨地域 Kafka MirrorMaker 详细配置 v1.0 + +> 适用:跨地域消息复制、灾备、活跃-活跃多地域部署 +> 版本:MirrorMaker 2.0 (Kafka 2.4+) +> 目标:低延迟、可控带宽、容错、可观测 + +--- + +## 目录 + +1. 设计背景与目标 +2. MM2 vs MM1 +3. 部署拓扑 +4. 配置详解 +5. Topic 复制策略 +6. 一致性保证 +7. 性能调优 +8. 故障处理 +9. 监控指标 +10. 跨地域 IM 实战 + +--- + +# 1. 设计背景与目标 + +## 1.1 为什么需要跨地域复制 + +``` +1. 多地域用户消息互通 +2. 灾备 (一个地域整体挂掉) +3. 流量就近接入 +4. 数据合规 (各地保留本地副本) +``` + +## 1.2 设计目标 + +| 指标 | 目标 | +|---|---| +| 复制延迟 P99 | < 200ms(大陆内)/ < 500ms(跨大陆) | +| 数据完整性 | 0 丢失(at-least-once) | +| 吞吐 | 100 MB/s 单连接 | +| 故障恢复 | < 30s | +| Topic 同步 | 自动 | +| ACL 同步 | 自动 | + +## 1.3 跨地域模式 + +### Active-Standby(主备) +``` +华东 (Active) ────→ 华南 (Standby) + ↑ + 所有写入 + +华东挂 → 切换到华南 +``` + +### Active-Active(双活) +``` +华东 (Active) ←──→ 华南 (Active) + 写本地 写本地 + 读本地+对端 读本地+对端 +``` + +IM 推荐 **Active-Active** + **就近写入**模式。 + +--- + +# 2. MM2 vs MM1 + +## 2.1 对比 + +| 特性 | MM1 | MM2 | +|---|---|---| +| 实现 | 独立进程 | Kafka Connect | +| Topic 自动创建 | ❌ | ✅ | +| ACL 同步 | ❌ | ✅ | +| Offset 同步 | ❌ | ✅ | +| 自动 failover | ❌ | ✅ | +| 多集群拓扑 | 难 | 简单 | +| 监控 | 弱 | 完善 | +| 推荐版本 | 弃用 | ✅ 必选 | + +**MM2 是唯一选择。** + +## 2.2 MM2 核心组件 + +``` +- MirrorSourceConnector: 跨集群复制数据 +- MirrorCheckpointConnector: 同步消费者 offset +- MirrorHeartbeatConnector: 集群间健康检查 +``` + +--- + +# 3. 部署拓扑 + +## 3.1 Active-Active 双地域 + +``` +┌──────────────────────────────────────────────────┐ +│ 华东 (East) 集群 │ +│ │ +│ Topic: msg.fanout.normal (本地写入) │ +│ Topic: msg.fanout.normal.east_to_south (复制来源)│ +│ Topic: south.msg.fanout.normal (来自华南的) │ +└────────┬─────────────────────────────────────────┘ + │ + │ MirrorMaker 2 (双向) + │ +┌────────▼─────────────────────────────────────────┐ +│ 华南 (South) 集群 │ +│ │ +│ Topic: msg.fanout.normal (本地写入) │ +│ Topic: east.msg.fanout.normal (来自华东的) │ +└──────────────────────────────────────────────────┘ +``` + +### 命名规则 +``` +{source_alias}.{topic_name} + +east.msg.fanout.normal ← 华南集群中,来自华东的副本 +south.msg.fanout.normal ← 华东集群中,来自华南的副本 +``` + +## 3.2 三地域 Mesh + +``` + ┌──────┐ + │ East │ + └──┬───┘ + │ + ┌──────┴──────┐ + │ │ +┌───▼───┐ ┌───▼───┐ +│ South │←──→│ US │ +└───────┘ └───────┘ +``` + +每个集群与其他集群双向复制。 + +## 3.3 MM2 部署模式 + +### 模式 1:Connect 集群部署(推荐) +``` +独立 Kafka Connect 集群 +运行 MM2 Connectors +- 单独的 worker 节点 +- 易于扩展 +- 可控 +``` + +### 模式 2:嵌入到 Source/Target Kafka +``` +不推荐,影响 Kafka 性能 +``` + +### 部署位置选择 + +``` +推荐: 部署在目标地域 +理由: + - 消费者在 target,写入更快 + - source 网络问题时不阻塞 + - 反压控制在 target + +例:复制 East → South + MM2 部署在 South 集群附近 +``` + +--- + +# 4. 配置详解 + +## 4.1 主配置文件 `mm2.properties` + +```properties +# ============================================ +# 集群定义 +# ============================================ +clusters = east, south + +east.bootstrap.servers = kafka-east-1:9092,kafka-east-2:9092,kafka-east-3:9092 +south.bootstrap.servers = kafka-south-1:9092,kafka-south-2:9092,kafka-south-3:9092 + +# 安全配置 +east.security.protocol = SASL_SSL +east.sasl.mechanism = PLAIN +east.sasl.jaas.config = org.apache.kafka.common.security.plain.PlainLoginModule required username="..." password="..."; + +south.security.protocol = SASL_SSL +south.sasl.mechanism = PLAIN +south.sasl.jaas.config = ... + +# ============================================ +# 复制流定义 (东 ←→ 南 双向) +# ============================================ +east->south.enabled = true +south->east.enabled = true + +# ============================================ +# Topic 复制规则 +# ============================================ +east->south.topics = msg\\..*, presence\\..*, user\\.behavior +east->south.topics.exclude = .*\\.internal\\.*, .*-changelog, .*-repartition + +south->east.topics = msg\\..*, presence\\..* +south->east.topics.exclude = .*\\.internal\\.* + +# ============================================ +# Consumer Group offset 同步 +# ============================================ +east->south.groups = .* +east->south.groups.exclude = console-consumer-.*, mirror-maker-.* + +south->east.groups = .* + +# ============================================ +# 复制因子 / 分区 +# ============================================ +replication.factor = 3 +checkpoints.topic.replication.factor = 3 +heartbeats.topic.replication.factor = 3 +offset-syncs.topic.replication.factor = 3 + +# ============================================ +# 复制策略 +# ============================================ +# 自动同步 source 的 ACL +sync.topic.acls.enabled = true +# 自动同步 source 的 topic 配置 +sync.topic.configs.enabled = true +# 是否启用 group offset 同步 +emit.checkpoints.enabled = true +emit.checkpoints.interval.seconds = 60 + +# 心跳间隔 +emit.heartbeats.enabled = true +emit.heartbeats.interval.seconds = 5 + +# ============================================ +# 复制重命名规则 (默认: source.topic_name) +# ============================================ +replication.policy.class = org.apache.kafka.connect.mirror.DefaultReplicationPolicy +# 或自定义: 不加前缀 (谨慎,可能导致循环复制) +# replication.policy.class = com.example.IdentityReplicationPolicy + +# ============================================ +# 性能配置 +# ============================================ +tasks.max = 16 + +# Source connector +east->south.tasks.max = 16 +south->east.tasks.max = 16 + +# Producer 配置 (写入 target) +east->south.producer.compression.type = lz4 +east->south.producer.acks = all +east->south.producer.batch.size = 65536 +east->south.producer.linger.ms = 5 +east->south.producer.max.in.flight.requests.per.connection = 5 +east->south.producer.enable.idempotence = true + +# Consumer 配置 (读取 source) +east->south.consumer.fetch.min.bytes = 65536 +east->south.consumer.fetch.max.wait.ms = 500 +east->south.consumer.max.poll.records = 5000 + +# ============================================ +# 限流(防止打爆带宽) +# ============================================ +east->south.replication.factor = 3 +east->south.target.cluster.alias = south +east->south.source.cluster.alias = east + +# 单 task 最大字节/秒 +east->south.producer.max.request.size = 10485760 +east->south.consumer.max.partition.fetch.bytes = 10485760 +``` + +## 4.2 Connect Worker 配置 `connect-distributed.properties` + +```properties +bootstrap.servers = kafka-south-1:9092,kafka-south-2:9092,kafka-south-3:9092 +group.id = mm2-connect-cluster +key.converter = org.apache.kafka.connect.converters.ByteArrayConverter +value.converter = org.apache.kafka.connect.converters.ByteArrayConverter + +# 内部 topic +config.storage.topic = mm2-configs +config.storage.replication.factor = 3 +offset.storage.topic = mm2-offsets +offset.storage.replication.factor = 3 +status.storage.topic = mm2-status +status.storage.replication.factor = 3 + +# REST API +listeners = HTTP://0.0.0.0:8083 +rest.advertised.host.name = mm2-worker-1 + +# 资源 +producer.buffer.memory = 67108864 +consumer.fetch.max.bytes = 52428800 +``` + +## 4.3 启动 + +```bash +# 方式 1: 命令行直接运行 MM2 +bin/connect-mirror-maker.sh mm2.properties + +# 方式 2: Connect 集群运行 (推荐生产环境) +bin/connect-distributed.sh connect-distributed.properties + +# 注册 connector +curl -X POST http://mm2-worker:8083/connectors \ + -H "Content-Type: application/json" \ + -d @mirror-source-connector.json +``` + +### Connector JSON 示例 + +```json +{ + "name": "east-to-south-source", + "config": { + "connector.class": "org.apache.kafka.connect.mirror.MirrorSourceConnector", + "tasks.max": "16", + + "source.cluster.alias": "east", + "target.cluster.alias": "south", + + "source.cluster.bootstrap.servers": "kafka-east-1:9092", + "target.cluster.bootstrap.servers": "kafka-south-1:9092", + + "source.cluster.security.protocol": "SASL_SSL", + "source.cluster.sasl.mechanism": "PLAIN", + "source.cluster.sasl.jaas.config": "...", + + "target.cluster.security.protocol": "SASL_SSL", + "target.cluster.sasl.mechanism": "PLAIN", + "target.cluster.sasl.jaas.config": "...", + + "topics": "msg\\..*, presence\\..*", + "topics.exclude": ".*\\.internal\\..*", + + "replication.factor": "3", + "sync.topic.configs.enabled": "true", + "sync.topic.acls.enabled": "true", + + "producer.override.compression.type": "lz4", + "producer.override.acks": "all", + "producer.override.enable.idempotence": "true", + "producer.override.max.in.flight.requests.per.connection": "5" + } +} +``` + +--- + +# 5. Topic 复制策略 + +## 5.1 哪些 Topic 需要复制 + +| Topic | 是否复制 | 备注 | +|---|---|---| +| `msg.fanout.normal` | ✅ | 跨地域用户消息 | +| `msg.fanout.large` | ✅ | 同上 | +| `msg.fanout.vip` | ✅ | 同上 | +| `msg.push.high` | ⚠️ | 视情况,push 通常本地处理 | +| `msg.recall` | ✅ | 撤回要全局生效 | +| `msg.read` | ⚠️ | 已读回执,可不跨域 | +| `presence.event` | ❌ | 在线状态本地用 | +| `user.behavior` | ✅ | 风控全局分析 | +| `audit.log` | ✅ | 合规审计 | +| `search.index` | ❌ | 搜索本地建索引 | +| `*.dlq` | ❌ | DLQ 不复制 | + +## 5.2 复制规则配置 + +```properties +# 使用正则 +east->south.topics = msg\\.fanout\\..*, msg\\.recall, user\\.behavior, audit\\..* + +# 排除 +east->south.topics.exclude = .*\\.dlq, presence\\..*, search\\..* + +# 黑名单优先级 > 白名单 +``` + +## 5.3 防止循环复制 + +**关键问题**:如果 East 和 South 互相复制 `msg.fanout.normal`,会不会产生无限循环? + +MM2 用 **DefaultReplicationPolicy** 自动重命名: + +``` +East 集群: + msg.fanout.normal ← 本地写入 + south.msg.fanout.normal ← 来自 South 的副本 + +South 集群: + msg.fanout.normal ← 本地写入 + east.msg.fanout.normal ← 来自 East 的副本 +``` + +复制 `east.msg.fanout.normal` 到 East? +→ 重命名后变成 `south.east.msg.fanout.normal` → 不是匹配的 topic → 不复制。 + +实际上 MM2 会**检测前缀**,避免复制已经是副本的 topic。 + +## 5.4 自定义重命名 + +如果想让副本 topic 名字干净(不带前缀),用 `IdentityReplicationPolicy`: + +```java +public class IdentityReplicationPolicy implements ReplicationPolicy { + @Override + public String formatRemoteTopic(String sourceClusterAlias, String topic) { + return topic; // 不重命名 + } + + @Override + public String topicSource(String topic) { + return null; // 无法识别源 + } +} +``` + +⚠️ 警告:使用 IdentityReplicationPolicy **必须确保不会循环复制**,否则灾难。 +做法:在配置里**单向只复制特定 topic**: + +```properties +east->south.topics = msg.fanout.normal +south->east.topics = "" # 反向不复制相同 topic +``` + +## 5.5 IM 推荐方案 + +**用 DefaultReplicationPolicy + 消费者订阅多 topic**: + +``` +消费者代码: +subscribe(["msg.fanout.normal", "east.msg.fanout.normal", "south.msg.fanout.normal"]) +``` + +或者用通配符: +``` +subscribe("*msg.fanout.normal") +``` + +业务侧透明合并本地和远程消息。 + +--- + +# 6. 一致性保证 + +## 6.1 复制语义 + +``` +MM2 默认: at-least-once + - 不丢 + - 可能重复 + +要求消费者幂等 +``` + +## 6.2 Offset 同步 + +MM2 用 `MirrorCheckpointConnector` 同步消费 offset: + +``` +Source 集群: + consumer-group-A 在 topic-X 的 offset = 1000 + +Checkpoint 写入: + topic: south.checkpoints.internal + msg: "consumer-group-A" → "topic-X" → offset 1000 + +Target 集群(消费者切换过来时): + 从 checkpoint 恢复 offset + 在 east.topic-X 上从对应 offset 继续消费 +``` + +注意:offset 在不同集群里**数值不同**,但 MM2 会维护映射关系。 + +## 6.3 Failover 流程 + +``` +场景: East 挂掉,业务切到 South + +Step 1: 消费者从 East 切换到 South + - 读取 South 的 checkpoint + - 恢复 East 集群消费 offset + - 在 South 集群对应 topic (east.topic-X) 上继续消费 + +Step 2: 生产者也切到 South + - 写本地 topic + - 等 East 恢复后再同步回去 +``` + +## 6.4 Failback + +``` +East 恢复后: + - South → East 复制照常 + - East 之前的副本 topic 仍存在 + - 业务可选择切回 East +``` + +--- + +# 7. 性能调优 + +## 7.1 吞吐瓶颈定位 + +``` +1. 网络带宽 +2. Producer 批次大小 +3. Consumer fetch 大小 +4. Task 并行度 +5. 压缩 +``` + +## 7.2 调优参数 + +### 增大批次 + +```properties +east->south.producer.batch.size = 524288 # 512KB +east->south.producer.linger.ms = 20 +east->south.producer.compression.type = lz4 +``` + +### 增加并行 + +```properties +east->south.tasks.max = 32 # 与 partition 数匹配 +``` + +每个 task 处理一组 partitions,task 数 ≤ 总 partition 数。 + +### 增大 fetch + +```properties +east->south.consumer.fetch.min.bytes = 1048576 # 1MB +east->south.consumer.fetch.max.wait.ms = 500 +east->south.consumer.max.partition.fetch.bytes = 10485760 +``` + +### 缓冲 + +```properties +producer.buffer.memory = 134217728 # 128MB +``` + +## 7.3 带宽控制 + +```properties +# 限制单个 producer 的字节/秒 +east->south.producer.max.request.size = 10485760 +``` + +或在网络层用 QoS 限制 MM2 worker 的出口带宽。 + +## 7.4 性能基线 + +``` +单 task: 30~50 MB/s +16 task: 500~800 MB/s +32 task: 800~1500 MB/s + +单 task 延迟: 50~100ms +P99 延迟: < 200ms (RTT < 50ms 的网络) +``` + +--- + +# 8. 故障处理 + +## 8.1 故障矩阵 + +| 故障 | 影响 | 恢复 | +|---|---|---| +| MM2 worker 挂 | 该 task 暂停 | Connect 自动调度到其他 worker | +| Source Kafka 挂 | 复制暂停 | 等待恢复,offset 保留,自动追赶 | +| Target Kafka 挂 | 复制失败 | 等待恢复 | +| 网络分区 | 复制延迟增加 | 网络恢复后追赶 | +| Topic 不存在 | 自动创建 | 默认行为 | + +## 8.2 复制延迟告警 + +``` +监控: kafka_consumer_lag (MM2 consumer) +告警: lag > 10000 持续 1 分钟 + +排查: + - 网络是否正常? + - Worker 是否健康? + - 目标 Kafka 是否能写入? + - 带宽是否足够? +``` + +## 8.3 数据校验 + +```bash +# 对比两端 topic 的 offset +kafka-consumer-groups --bootstrap-server east:9092 \ + --describe --group mm2-east-to-south + +kafka-run-class kafka.tools.GetOffsetShell \ + --broker-list south:9092 \ + --topic east.msg.fanout.normal +``` + +定期对账: + +```python +def verify_replication(): + east_total = count_messages("kafka-east", "msg.fanout.normal", time_range) + south_total = count_messages("kafka-south", "east.msg.fanout.normal", time_range) + + diff_pct = abs(east_total - south_total) / east_total + if diff_pct > 0.01: + alert("MM2 lag/loss detected: %.2f%%" % (diff_pct * 100)) +``` + +## 8.4 回填 + +如果 MM2 长时间故障导致数据丢失: + +``` +方案 1: 重置 offset 重新复制 + 缺点:会重复 + 适合:消费侧幂等 + +方案 2: 业务层重发 + 从 DB / 备份恢复 +``` + +--- + +# 9. 监控指标 + +## 9.1 关键指标 + +| 指标 | 含义 | +|---|---| +| `kafka_connect_mirror_source_connector_record_age_ms` | 复制延迟 | +| `kafka_connect_mirror_source_connector_record_rate` | 复制速率 | +| `kafka_connect_mirror_source_connector_byte_rate` | 复制带宽 | +| `kafka_connect_mirror_source_connector_replication_latency_ms` | 端到端延迟 | +| `kafka_connect_task_status` | Task 健康状态 | + +## 9.2 JMX 暴露 + +``` +mirror-source-connector → MBean: kafka.connect:type=mirror-source-connector-metrics +mirror-checkpoint-connector → MBean: kafka.connect:type=mirror-checkpoint-connector-metrics +``` + +## 9.3 Prometheus 抓取 + +```yaml +- job_name: 'mm2' + static_configs: + - targets: ['mm2-worker-1:8083', 'mm2-worker-2:8083'] + metrics_path: /metrics +``` + +## 9.4 Grafana 大盘 + +``` +Panel 1: 各 source-target 对的复制速率 +Panel 2: 复制延迟分布 +Panel 3: Task 健康状态 +Panel 4: 各 topic 复制 lag +Panel 5: 网络带宽利用率 +Panel 6: 错误率 +``` + +--- + +# 10. 跨地域 IM 实战 + +## 10.1 IM 复制需求 + +``` +1. 用户跨地域消息 +2. 撤回事件全局生效 +3. 离线消息存储跨地域备份 +4. 风控数据集中分析 +5. 合规审计 +``` + +## 10.2 拓扑设计 + +``` + ┌─────────────┐ + │ 全球路由层 │ + │ (DNS/GSLB) │ + └──────┬───────┘ + │ + ┌─────────────┼─────────────┐ + │ │ │ + ┌───▼───┐ ┌───▼───┐ ┌───▼───┐ + │ East │←──→│ South │←──→│ US │ + │ │ │ │ │ │ + │ Kafka │ │ Kafka │ │ Kafka │ + └───────┘ └───────┘ └───────┘ + ↑ ↑ ↑ + │ │ │ + ┌───┴───┐ ┌───┴───┐ ┌───┴───┐ + │ Users │ │ Users │ │ Users │ + └───────┘ └───────┘ └───────┘ +``` + +## 10.3 消息流 + +``` +场景: 上海用户 A 给广州用户 B 发消息 + +1. A 连接华东 Gateway +2. 消息写入华东 Kafka: msg.fanout.normal +3. 华东 MsgWrite 处理 +4. MM2 复制到华南: east.msg.fanout.normal +5. 华南 Deliver 消费 east.msg.fanout.normal +6. 投递到 B (在华南 Gateway) +``` + +## 10.4 撤回事件全局复制 + +``` +A 撤回消息 → East 写 msg.recall + +MM2: + East → South: msg.recall → south.msg.recall + East → US: msg.recall → us.msg.recall + +各地域消费者: + 消费本地 msg.recall + 远程副本 + 对所有地域生效 +``` + +## 10.5 用户归属与就近写入 + +``` +用户元数据: home_region (East/South/US) + +接入: + - GSLB 按 IP 路由到最近接入 + - 临时跨域接入(如出差)允许,但写入仍走主分片 + +消息存储: + - 用户 A 主分片在 East + - A 在 South 发消息,先写到 East(跨域 RPC) + - 或:写本地 + 异步同步 + +推荐: + - 私聊:发送方主区域写 + - 群聊:群主区域写 +``` + +## 10.6 灾备演练 + +``` +模拟 East 集群挂掉: +1. GSLB 切流到 South / US +2. 用户重连到其他地域 +3. 消费者从 South 集群消费 east.msg.fanout.normal 副本 +4. 写入也切到 South 本地 + +恢复后: +1. East 重新上线 +2. South → East MM2 把累积数据同步过去 +3. 灰度切回 East +``` + +## 10.7 合规与数据本地化 + +``` +GDPR 等法规要求数据不出境: + +方案: + - 用户数据按 home_region 分区 + - 跨域复制只复制必要 topic + - 敏感字段在跨域时脱敏 + - 审计日志各地域独立保留 +``` + +--- + +# 附录:完整部署示例 + +## A.1 Docker Compose + +```yaml +version: '3' +services: + mm2-worker: + image: confluentinc/cp-kafka-connect:7.5.0 + ports: + - "8083:8083" + environment: + CONNECT_BOOTSTRAP_SERVERS: kafka-south:9092 + CONNECT_REST_ADVERTISED_HOST_NAME: mm2-worker + CONNECT_GROUP_ID: mm2-cluster + CONNECT_CONFIG_STORAGE_TOPIC: mm2-configs + CONNECT_OFFSET_STORAGE_TOPIC: mm2-offsets + CONNECT_STATUS_STORAGE_TOPIC: mm2-status + CONNECT_KEY_CONVERTER: org.apache.kafka.connect.converters.ByteArrayConverter + CONNECT_VALUE_CONVERTER: org.apache.kafka.connect.converters.ByteArrayConverter + CONNECT_PLUGIN_PATH: /usr/share/java + volumes: + - ./jaas.conf:/etc/kafka/jaas.conf +``` + +## A.2 Kubernetes Deployment + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mm2-worker + namespace: kafka +spec: + replicas: 4 + selector: + matchLabels: + app: mm2-worker + template: + metadata: + labels: + app: mm2-worker + spec: + containers: + - name: mm2 + image: confluentinc/cp-kafka-connect:7.5.0 + ports: + - containerPort: 8083 + env: + - name: CONNECT_BOOTSTRAP_SERVERS + value: "kafka-south:9092" + - name: CONNECT_GROUP_ID + value: "mm2-cluster" + # ... + resources: + requests: + cpu: 4 + memory: 8Gi + limits: + cpu: 8 + memory: 16Gi +``` + +## A.3 注册脚本 + +```bash +#!/bin/bash + +CONNECT_URL="http://mm2-worker:8083" + +# East → South source connector +curl -X POST $CONNECT_URL/connectors \ + -H "Content-Type: application/json" \ + -d @east-to-south-source.json + +# East → South checkpoint connector +curl -X POST $CONNECT_URL/connectors \ + -H "Content-Type: application/json" \ + -d @east-to-south-checkpoint.json + +# East → South heartbeat connector +curl -X POST $CONNECT_URL/connectors \ + -H "Content-Type: application/json" \ + -d @east-to-south-heartbeat.json + +# 状态检查 +curl $CONNECT_URL/connectors/east-to-south-source/status +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/09-Message-Search-Engine-v1.0_Version4.md b/_drafts/IM/09-Message-Search-Engine-v1.0_Version4.md new file mode 100755 index 000000000..e02d23ce4 --- /dev/null +++ b/_drafts/IM/09-Message-Search-Engine-v1.0_Version4.md @@ -0,0 +1,1045 @@ +# 消息搜索引擎设计 v1.0 + +> 适用:IM 历史消息全文搜索、@我搜索、文件搜索 +> 选型:Elasticsearch 8.x +> 目标:千亿级消息、P99 < 200ms、相关性优秀 + +--- + +## 目录 + +1. 设计目标与挑战 +2. 整体架构 +3. ES 索引设计 +4. 写入链路 +5. 分片策略 +6. 查询优化 +7. 相关性排序 +8. 隐私与权限 +9. 容量与成本 +10. 监控与运维 + +--- + +# 1. 设计目标与挑战 + +## 1.1 业务需求 + +``` +1. 全局搜索: 用户搜自己所有消息 +2. 会话内搜索: 在某个群/会话内搜 +3. @ 我搜索: 找历史 @ 我的消息 +4. 文件搜索: 按文件名/类型 +5. 联系人搜索: 找消息里提到某人 +6. 高级筛选: 时间范围 / 发送者 / 类型 +``` + +## 1.2 挑战 + +``` +数据量: 千亿级消息,PB 级 +写入吞吐: 50 万消息/秒 +查询时延: P99 < 200ms +中文分词: IK / jieba +权限隔离: 用户只能搜自己有权访问的 +长尾用户: 有人有 10 万会话,有人有 10 个 +冷热不均: 新消息查得多,老消息查得少 +``` + +## 1.3 设计目标 + +| 指标 | 目标 | +|---|---| +| 写入吞吐 | 50 万 docs/s | +| 写入延迟 | P99 < 5s(消息→可搜) | +| 查询 P99 | < 200ms | +| 召回率 | > 95% | +| 准确率 | > 90% | +| 数据保留 | 1 年(可配置) | + +--- + +# 2. 整体架构 + +## 2.1 架构图 + +``` +┌──────────────────────────────────────────────┐ +│ 消息写入服务 (MsgWrite) │ +└───────────────┬──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Kafka: search.index │ +└───────────────┬──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Indexer 服务(消费 + 加工 + 写 ES) │ +│ - 内容预处理(分词、清洗) │ +│ - 富化(用户名、群名) │ +│ - 批量写入 │ +└───────────────┬──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Elasticsearch 集群(按时间+用户分片) │ +└───────────────┬──────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────┐ +│ Search API 服务 │ +│ - 权限校验 │ +│ - 查询构造 │ +│ - 结果排序与高亮 │ +└──────────────────────────────────────────────┘ + ▲ + │ + 客户端搜索请求 +``` + +## 2.2 核心组件 + +| 组件 | 职责 | +|---|---| +| **Indexer** | 消费 Kafka、文档加工、批量写 ES | +| **ES Cluster** | 倒排索引、分布式查询 | +| **Search API** | 查询接口、权限、聚合 | +| **Admin** | 索引管理、reindex、归档 | + +--- + +# 3. ES 索引设计 + +## 3.1 索引命名 + +按时间分索引,便于归档和滚动: + +``` +im_messages_2026_05 ← 2026年5月的消息 +im_messages_2026_04 ← 2026年4月 +im_messages_2026_03 +... +``` + +每月一个索引,共 12 个滚动。1 年前的归档/删除。 + +## 3.2 索引模板 + +```json +PUT _index_template/im_messages_template +{ + "index_patterns": ["im_messages_*"], + "template": { + "settings": { + "number_of_shards": 64, + "number_of_replicas": 1, + "refresh_interval": "5s", + "index.translog.durability": "async", + "index.translog.sync_interval": "30s", + "index.routing.allocation.total_shards_per_node": 4, + + "analysis": { + "analyzer": { + "ik_smart_pinyin": { + "type": "custom", + "tokenizer": "ik_smart", + "filter": ["lowercase", "pinyin_filter"] + }, + "ik_max_word_pinyin": { + "type": "custom", + "tokenizer": "ik_max_word", + "filter": ["lowercase", "pinyin_filter"] + } + }, + "filter": { + "pinyin_filter": { + "type": "pinyin", + "keep_first_letter": true, + "keep_full_pinyin": true, + "keep_original": true + } + } + } + }, + "mappings": { + "_source": { + "excludes": ["content_raw_blob"] + }, + "properties": { + "server_msg_id": { "type": "keyword" }, + "conv_id": { "type": "keyword" }, + "sender_id": { "type": "keyword" }, + "msg_type": { "type": "keyword" }, + "visible_seq": { "type": "long" }, + + "text": { + "type": "text", + "analyzer": "ik_max_word_pinyin", + "search_analyzer": "ik_smart_pinyin", + "fields": { + "keyword": { "type": "keyword", "ignore_above": 256 } + } + }, + + "sender_name": { + "type": "text", + "analyzer": "ik_max_word", + "fields": { + "keyword": { "type": "keyword" } + } + }, + + "conv_name": { + "type": "text", + "analyzer": "ik_max_word" + }, + + "mentioned_users": { "type": "keyword" }, + "has_mention_all": { "type": "boolean" }, + + "files": { + "type": "nested", + "properties": { + "name": { "type": "text", "analyzer": "ik_max_word" }, + "type": { "type": "keyword" }, + "size": { "type": "long" }, + "url": { "type": "keyword", "index": false } + } + }, + + "urls": { "type": "keyword" }, + "is_recalled": { "type": "boolean" }, + "is_edited": { "type": "boolean" }, + + "created_at": { "type": "date", "format": "epoch_millis" }, + + "members": { "type": "keyword" } // 该消息可见的用户 + } + }, + "aliases": { + "im_messages": {} + } + } +} +``` + +## 3.3 字段说明 + +| 字段 | 类型 | 用途 | +|---|---|---| +| `server_msg_id` | keyword | 唯一 ID(去重) | +| `conv_id` | keyword | 会话过滤 | +| `sender_id` | keyword | 发送者过滤 | +| `text` | text | 全文搜索主字段 | +| `sender_name` | text | 搜"张三发的消息" | +| `mentioned_users` | keyword | 搜 @我 | +| `files` | nested | 文件附件搜索 | +| `created_at` | date | 时间范围 | +| `members` | keyword | **权限过滤** | + +## 3.4 关键设计:members 字段 + +为了实现权限隔离,每条消息冗余一个 `members` 数组,包含**该消息可见的所有用户 ID**。 + +``` +私聊消息: members = [sender_id, receiver_id] +群消息: members = [所有当前群成员] +``` + +查询时: + +```json +{ + "bool": { + "must": [{ "match": { "text": "项目计划" } }], + "filter": [{ "term": { "members": "u_1001" } }] + } +} +``` + +只能搜到自己有权访问的消息。 + +### 群成员变更怎么办? + +**问题**:用户加入群后,能不能搜到加群之前的消息? + +``` +策略 A: 不能搜(推荐) + - members 字段记录消息发送时的成员 + - 新加入者不更新历史 members + - 简单、快、合规 + +策略 B: 能搜 + - 群成员变更时 reindex 历史消息 + - 代价大(万人群每次变更要更新所有历史) + - 不推荐 +``` + +实战推荐 A。 + +## 3.5 分词器选择 + +### 中文分词 +``` +ik_max_word: 最细粒度,索引用 +ik_smart: 粗粒度,搜索用 +``` + +### 拼音支持 +``` +"张三" 用户能搜到 "zhangsan" / "zs" +通过 pinyin filter 实现 +``` + +### 多语言 +``` +全球版需要其他语言: + 英文: standard analyzer + 日文: kuromoji + 韩文: nori + +按语言/字段选择分析器 +``` + +--- + +# 4. 写入链路 + +## 4.1 数据流 + +``` +MsgWrite + │ 写消息库 + Outbox + ▼ +Kafka: search.index + │ 消费 + ▼ +Indexer + │ 富化(查用户名、群名、成员) + │ 加工(分词预处理、敏感字段过滤) + │ 批量 + ▼ +Elasticsearch +``` + +## 4.2 事件结构 + +```json +{ + "event_type": "MESSAGE_CREATED", + "server_msg_id": "s_99001", + "conv_id": "c123", + "conv_type": "group", + "sender_id": "u_1001", + "msg_type": "text", + "content": { + "text": "@张三 看一下这个计划", + "mentions": [{ "user_id": "u_2001" }] + }, + "files": [], + "visible_seq": 850, + "created_at": 1710000000000 +} +``` + +## 4.3 Indexer 实现 + +```go +type Indexer struct { + consumer *kafka.Consumer + esClient *elasticsearch.Client + enricher *Enricher + batchSize int + flushPeriod time.Duration +} + +func (idx *Indexer) Run(ctx context.Context) error { + buffer := make([]*Document, 0, idx.batchSize) + timer := time.NewTimer(idx.flushPeriod) + + for { + select { + case <-ctx.Done(): + idx.flush(buffer) + return nil + + case msg := <-idx.consumer.Messages(): + event := decode(msg.Value) + + // 过滤不需要索引的 + if !idx.shouldIndex(event) { + continue + } + + // 富化 + doc := idx.enricher.Build(event) + buffer = append(buffer, doc) + + if len(buffer) >= idx.batchSize { + idx.flush(buffer) + buffer = buffer[:0] + timer.Reset(idx.flushPeriod) + } + + case <-timer.C: + if len(buffer) > 0 { + idx.flush(buffer) + buffer = buffer[:0] + } + timer.Reset(idx.flushPeriod) + } + } +} + +func (idx *Indexer) flush(docs []*Document) { + if len(docs) == 0 { + return + } + + // 构造 _bulk 请求 + var buf bytes.Buffer + for _, doc := range docs { + buf.WriteString(fmt.Sprintf(`{"index":{"_index":"%s","_id":"%s"}}`+"\n", + doc.IndexName(), doc.ID)) + buf.Write(doc.JSON()) + buf.WriteByte('\n') + } + + resp, err := idx.esClient.Bulk(bytes.NewReader(buf.Bytes())) + // 处理结果,部分失败重试 +} +``` + +## 4.4 富化(Enrichment) + +Indexer 需要补充原始消息没有的字段: + +```go +type Enricher struct { + userCache *UserCache + convCache *ConvCache + groupCache *GroupMemberCache +} + +func (e *Enricher) Build(event *MessageEvent) *Document { + sender, _ := e.userCache.Get(event.SenderID) + conv, _ := e.convCache.Get(event.ConvID) + + var members []string + if event.ConvType == "group" { + members, _ = e.groupCache.GetMembers(event.ConvID) + } else { + members = []string{event.SenderID, event.ReceiverID} + } + + return &Document{ + ID: event.ServerMsgID, + ConvID: event.ConvID, + SenderID: event.SenderID, + SenderName: sender.Name, + ConvName: conv.Name, + Text: extractText(event.Content), + MentionedUsers: extractMentions(event.Content), + HasMentionAll: hasMentionAll(event.Content), + Members: members, + CreatedAt: event.CreatedAt, + } +} +``` + +## 4.5 撤回 / 编辑 + +``` +撤回: + PARTIAL UPDATE: { "is_recalled": true, "text": "" } + 或:DELETE 文档(推荐) + +编辑: + PARTIAL UPDATE: { "text": "new content", "is_edited": true } +``` + +## 4.6 群成员变更 + +``` +新成员加入: 不动历史索引(策略 A) +成员退出: 不动历史索引(保留搜索权) + +或者: +成员退出后立即: + for doc in conv 的所有历史: + UPDATE doc.members 移除该用户 + 代价大,通常不做 +``` + +## 4.7 批量优化 + +``` +batch_size: 1000 docs +flush_period: 1s +``` + +单 Indexer 实例: + +``` +1000 docs/batch × 1 batch/s = 1K docs/s +扩展: 50 个实例 = 50K docs/s +``` + +不够时增加 Kafka 分区 + Indexer 实例。 + +--- + +# 5. 分片策略 + +## 5.1 时间分片(主分片维度) + +``` +im_messages_2026_01 +im_messages_2026_02 +... +``` + +好处: +- 老数据归档简单 +- 单索引大小可控 +- 查询时可按时间过滤索引 + +## 5.2 按月切分理由 + +``` +日切: 索引太多(365 个),元数据负担重 +月切: 12 个/年,合适 +年切: 单索引太大,扩容难 +``` + +## 5.3 主分片数 + +``` +单索引: 64 主分片 +单分片: 30~50GB +单月数据: 64 × 50GB = 3.2TB +``` + +## 5.4 副本 + +``` +number_of_replicas: 1 +高可用 + 读扩展 +``` + +## 5.5 路由(routing) + +将同一会话的消息路由到同一分片,加速会话内搜索: + +``` +PUT im_messages_2026_05/_doc/s_99001?routing=c123 +``` + +查询时也带 routing: + +``` +GET im_messages/_search?routing=c123 +{ "query": ... } +``` + +只查 1 个分片而不是 64 个,**性能提升 60 倍**。 + +## 5.6 分片热点 + +``` +大群 (万人群): 消息多,单分片热点 + +解决: + - 大群消息不带 routing,分散到所有分片 + - 或用 hash(conv_id, time_bucket) 加盐 +``` + +--- + +# 6. 查询优化 + +## 6.1 典型查询 + +### 全局搜索 + +```json +GET im_messages/_search +{ + "query": { + "bool": { + "must": [ + { + "multi_match": { + "query": "项目计划", + "fields": ["text^2", "sender_name", "files.name"] + } + } + ], + "filter": [ + { "term": { "members": "u_1001" } }, + { "term": { "is_recalled": false } } + ] + } + }, + "highlight": { + "fields": { "text": {} } + }, + "sort": [ + "_score", + { "created_at": "desc" } + ], + "size": 20 +} +``` + +### 会话内搜索 + +```json +GET im_messages/_search?routing=c123 +{ + "query": { + "bool": { + "must": [{ "match": { "text": "计划" } }], + "filter": [ + { "term": { "conv_id": "c123" } }, + { "term": { "members": "u_1001" } } + ] + } + } +} +``` + +### @ 我搜索 + +```json +GET im_messages/_search +{ + "query": { + "bool": { + "should": [ + { "term": { "mentioned_users": "u_1001" } }, + { "term": { "has_mention_all": true } } + ], + "minimum_should_match": 1, + "filter": [{ "term": { "members": "u_1001" } }] + } + }, + "sort": [{ "created_at": "desc" }] +} +``` + +### 时间范围 + +```json +{ + "filter": [ + { + "range": { + "created_at": { + "gte": 1710000000000, + "lte": 1712000000000 + } + } + } + ] +} +``` + +## 6.2 索引选择优化 + +后端根据时间范围选索引: + +```go +func selectIndices(from, to time.Time) []string { + var indices []string + cur := from + for !cur.After(to) { + indices = append(indices, fmt.Sprintf("im_messages_%04d_%02d", cur.Year(), cur.Month())) + cur = cur.AddDate(0, 1, 0) + } + return indices +} + +// 查询时只查命中的索引,而不是 alias +GET im_messages_2026_05,im_messages_2026_04/_search +``` + +不带时间范围的查询:默认查近 3 个月,超出范围提示用户加时间过滤。 + +## 6.3 查询分级 + +``` +快速搜索: 近 7 天 (热索引,SSD) +普通搜索: 近 30 天 +深度搜索: 30天 ~ 1 年 +归档搜索: > 1 年 (单独冷集群) +``` + +## 6.4 分页 + +### 浅分页(前 1 万条) +``` +from + size +``` + +### 深分页(搜索结果超过 1 万) +``` +search_after (推荐) + 上次最后一条的 sort 值作为下页 anchor + +GET /im_messages/_search +{ + "size": 20, + "sort": [ + { "created_at": "desc" }, + { "_id": "desc" } + ], + "search_after": [1710000000000, "s_99001"] +} +``` + +不要用 `scroll`,已被弃用。 + +## 6.5 缓存 + +### Filter cache +ES 内置,filter 子句自动缓存。**多用 filter 少用 must**。 + +### 应用层缓存 +``` +热门查询缓存 5min +"周报"、"计划" 等高频词 +``` + +### 用户最近查询 +``` +Redis: search:recent:{userId} +LRU 保留 20 条 +``` + +## 6.6 索引 warmup + +新索引刚创建时缓存冷,可预热: + +``` +GET /im_messages_2026_05/_search +{ + "query": { "match_all": {} }, + "size": 0 +} +``` + +--- + +# 7. 相关性排序 + +## 7.1 默认排序 + +ES 用 BM25 默认评分。 + +``` +score = ∑(IDF × TF × normalization) +``` + +## 7.2 自定义评分 + +IM 搜索除了文本相关性,还要考虑业务因素: + +```json +{ + "query": { + "function_score": { + "query": { + "multi_match": { + "query": "项目计划", + "fields": ["text^2", "sender_name"] + } + }, + "functions": [ + { + "filter": { "range": { "created_at": { "gte": "now-7d" } } }, + "weight": 2 + }, + { + "filter": { "term": { "is_recent_active_conv": true } }, + "weight": 1.5 + }, + { + "gauss": { + "created_at": { + "origin": "now", + "scale": "30d", + "decay": 0.5 + } + } + } + ], + "score_mode": "sum", + "boost_mode": "multiply" + } + } +} +``` + +## 7.3 排序维度 + +| 因子 | 权重 | +|---|---| +| 文本相关性 (BM25) | 1.0 | +| 时间衰减 (gauss) | 0.5 | +| 是否当前会话 | +1 | +| 是否高频联系人 | +0.5 | +| @ 我的消息 | +1 | +| 文件类型匹配 | +0.5 | + +## 7.4 个性化(高级) + +``` +用户 A 经常搜"周报" +→ "周报" 相关结果 boost +→ 个性化排序模型 (LTR) +``` + +实现:Learning to Rank 插件,基于用户点击行为训练模型。 + +## 7.5 排序模式 + +``` +默认: 按相关性 +最新: 按时间倒序 +最旧: 按时间正序 + +UI 提供切换 +``` + +--- + +# 8. 隐私与权限 + +## 8.1 权限模型 + +``` +能搜到的消息 = 自己有权访问的所有消息 +- 自己发的私聊消息 +- 收到的私聊消息 +- 加入的群里的消息(加入后的,看策略) +- 自己被 @ 的消息 +``` + +## 8.2 实现 + +通过 `members` 字段过滤: + +```json +"filter": [{ "term": { "members": "" } }] +``` + +后端 Search API 强制注入此过滤,客户端无法绕过。 + +## 8.3 退群后的搜索 + +``` +策略 A: 退群即不可搜(推荐合规) + - 退群事件 → 删除该用户在该群所有索引中的 members 项 + - 代价: 万人群退一个人要更新很多文档 + - 优化: 标记 ban_user 字段 + 查询时过滤 + +策略 B: 退群保留历史搜索权(用户体验好) + - 不动索引 + - 但合规风险 + +推荐 A,加优化: + - 不实时改 members + - 加 left_users 字段 + 退群时间 + - 查询时: members 包含 + (left_users 不包含 OR 消息时间 < 退群时间) +``` + +## 8.4 撤回消息 + +``` +撤回后不能搜 + - 撤回事件 → ES 标记 is_recalled=true + - 查询 filter: { "term": { "is_recalled": false } } +``` + +## 8.5 内容脱敏 + +``` +搜索结果高亮时: + - 手机号脱敏: 138****8888 + - 身份证脱敏 + - 银行卡脱敏 + +存储时已脱敏 vs 展示时脱敏: + 推荐展示时,保留原始全文便于精确搜索 +``` + +## 8.6 数据合规 + +``` +GDPR / 等保 要求: + - 用户注销 → 删除其所有索引数据 + - 数据出境 → 跨地域索引隔离 + - 审计 → 搜索行为日志 +``` + +--- + +# 9. 容量与成本 + +## 9.1 容量估算 + +``` +日消息量: 200 亿条 +单条索引大小: ~500 字节 +日索引大小: 10 TB +月索引: 300 TB (主分片) ++ 1 副本: 600 TB + +保留 1 年: 7.2 PB +``` + +## 9.2 成本优化 + +### 冷热分离 +``` +hot: 近 7 天 - SSD - 高 QPS +warm: 7~30 天 - SSD - 中 QPS +cold: 30~365 天 - HDD - 低 QPS +frozen: > 1 年 - 对象存储 - 极低 QPS +``` + +ILM (Index Lifecycle Management) 自动管理: + +```json +PUT _ilm/policy/im_messages_policy +{ + "policy": { + "phases": { + "hot": { + "min_age": "0ms", + "actions": { + "rollover": { "max_size": "100GB", "max_age": "30d" } + } + }, + "warm": { + "min_age": "7d", + "actions": { + "shrink": { "number_of_shards": 16 }, + "forcemerge": { "max_num_segments": 1 }, + "allocate": { "include": { "tier": "warm" } } + } + }, + "cold": { + "min_age": "30d", + "actions": { + "freeze": {}, + "allocate": { "include": { "tier": "cold" } } + } + }, + "delete": { + "min_age": "365d", + "actions": { "delete": {} } + } + } + } +} +``` + +### 不索引非搜索字段 +``` +url, file_url 等设 "index": false +减小索引大小 +``` + +### 压缩 +``` +"index.codec": "best_compression" +节省 30~50% 存储,代价是查询慢一点 +``` + +## 9.3 节点规格 + +``` +hot 节点: 32C / 128GB / 4TB SSD × 50 个 +warm 节点: 16C / 64GB / 8TB SSD × 30 个 +cold 节点: 16C / 32GB / 16TB HDD × 20 个 +master 节点: 8C / 32GB / 100GB SSD × 3 个 (专用) +``` + +--- + +# 10. 监控与运维 + +## 10.1 关键指标 + +| 指标 | 告警 | +|---|---| +| `cluster.status` | 非 green | +| `indices.indexing.index_current` | > 阈值 | +| `indices.search.query_time_in_millis` (P99) | > 200ms | +| `nodes.jvm.mem.heap_used_percent` | > 75% | +| `nodes.fs.available_in_bytes` | < 20% | +| `indices.refresh.total_time_in_millis` | 异常增长 | +| `indexer_lag` (Kafka) | > 10K | + +## 10.2 慢查询日志 + +```yaml +index.search.slowlog.threshold.query.warn: 1s +index.search.slowlog.threshold.query.info: 500ms +``` + +## 10.3 索引管理 + +```bash +# 查看索引 +GET _cat/indices/im_messages_*?v&s=index + +# 查看分片分布 +GET _cat/shards/im_messages_*?v + +# 强制 merge +POST im_messages_2026_03/_forcemerge?max_num_segments=1 + +# 手动 rollover +POST im_messages/_rollover +``` + +## 10.4 reindex + +字段变更时需要 reindex: + +```json +POST _reindex +{ + "source": { "index": "im_messages_2026_05_v1" }, + "dest": { "index": "im_messages_2026_05_v2" } +} +``` + +通过 alias 切换: + +```json +POST _aliases +{ + "actions": [ + { "remove": { "index": "im_messages_2026_05_v1", "alias": "im_messages" } }, + { "add": { "index": "im_messages_2026_05_v2", "alias": "im_messages" } } + ] +} +``` + +## 10.5 备份 + +``` +SLM (Snapshot Lifecycle Management): + - 每天快照到 S3/OSS + - 保留 30 天 + - 灾难恢复用 +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/09-Message-Search-v1.0.md b/_drafts/IM/09-Message-Search-v1.0.md new file mode 100755 index 000000000..50c772b2d --- /dev/null +++ b/_drafts/IM/09-Message-Search-v1.0.md @@ -0,0 +1,916 @@ +# 消息搜索引擎设计 v1.0 + +> 适用:IM 历史消息搜索、聊天记录全文检索、@我聚合查询 +> 选型:Elasticsearch 8.x +> 目标:万亿级消息、毫秒级查询、写入不阻塞主流程 + +--- + +## 目录 + +1. 设计目标与挑战 +2. 索引设计 +3. 分片策略 +4. 写入链路 +5. 查询优化 +6. 相关性排序 +7. 安全与隔离 +8. 容量规划 +9. 运维与监控 + +--- + +# 1. 设计目标与挑战 + +## 1.1 业务场景 + +``` +1. 会话内搜索: "在与张三的聊天里搜 'ES 设计'" +2. 全局搜索: "我的所有消息中搜 'kubectl'" +3. 联系人搜索: "搜张三发过的关于 Kafka 的消息" +4. 时间范围: "上周的所有 @我" +5. 消息类型筛选: "搜张三发的图片" +6. 高级语法: AND/OR/NOT/短语 +``` + +## 1.2 挑战 + +| 挑战 | 说明 | +|---|---| +| 数据量大 | 万亿级消息 | +| 写入高并发 | 50万 QPS | +| 多租户隔离 | 不能跨用户搜到 | +| 时效性 | 新消息秒级可搜 | +| 删除合规 | 撤回/封禁要清理 | +| 成本控制 | 不能每条都全字段索引 | + +## 1.3 设计目标 + +``` +写入延迟 P99: < 5s (消息可搜) +查询延迟 P99: < 200ms +查询召回率: > 95% +存储成本: 原始消息的 1.5x +``` + +--- + +# 2. 索引设计 + +## 2.1 索引拆分策略 + +### 按时间分索引(推荐) + +``` +msg_2026_05 ← 2026 年 5 月数据 +msg_2026_06 ← 6 月 +msg_2026_07 ← 7 月 +... +``` + +**优点**: +- 老数据可整体冷存储 / 删除 +- 查询时按时间范围只命中部分索引 +- 写入热点集中在最新索引 + +### 按用户分索引(不推荐) + +``` +msg_user_{shard}_{date} +``` + +每个用户独立 shard 太多,元数据爆炸。 + +### IM 推荐方案 + +``` +msg_{yyyy_MM} ← 按月分索引 +索引内 routing = userId ← 路由到特定 shard +``` + +兼顾时间分区和用户隔离。 + +## 2.2 字段映射 + +```json +PUT msg_2026_05 +{ + "settings": { + "number_of_shards": 32, + "number_of_replicas": 1, + "refresh_interval": "5s", + "index.codec": "best_compression", + "index.translog.durability": "async", + "index.translog.sync_interval": "5s", + + "analysis": { + "analyzer": { + "im_analyzer": { + "type": "custom", + "tokenizer": "ik_smart", + "filter": ["lowercase", "stop"] + }, + "im_search_analyzer": { + "type": "custom", + "tokenizer": "ik_max_word", + "filter": ["lowercase"] + } + } + } + }, + + "mappings": { + "properties": { + "server_msg_id": { + "type": "keyword" + }, + "conv_id": { + "type": "keyword" + }, + "sender_id": { + "type": "keyword" + }, + "recipient_id": { + "type": "keyword" // 私聊接收者,群消息为空 + }, + "owner_id": { + "type": "keyword" // 这条消息所属用户(搜索权限) + }, + "msg_type": { + "type": "keyword" + }, + "content_text": { + "type": "text", + "analyzer": "im_analyzer", + "search_analyzer": "im_search_analyzer", + "fields": { + "raw": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "mention_user_ids": { + "type": "keyword" + }, + "has_url": { + "type": "boolean" + }, + "has_image": { + "type": "boolean" + }, + "has_file": { + "type": "boolean" + }, + "file_name": { + "type": "text", + "analyzer": "im_analyzer" + }, + "send_time": { + "type": "date", + "format": "epoch_millis" + }, + "visible_seq": { + "type": "long" + }, + "status": { + "type": "byte" // 0:normal 1:recalled 2:deleted + } + } + } +} +``` + +## 2.3 关键设计点 + +### owner_id 字段(最重要) + +每条消息要给**每个能看到它的用户**建一个文档?这会爆炸。 + +**推荐方案**:消息只索引一份,**搜索时用 conv_id + 权限过滤**。 + +``` +索引一条消息: + conv_id = "c_123" + sender_id = "u_1001" + +搜索时: + user U 想搜: + 1. 先查 U 加入了哪些 conv (Redis 缓存) + 2. 在 ES 查: WHERE conv_id IN [...] AND content MATCHES "xxx" +``` + +**为什么不复制多份**: +- 万人群一条消息要写 1 万份索引 +- 写入放大严重 +- 撤回/编辑要更新所有副本 + +### mention_user_ids 字段 + +``` +"@我" 的快速过滤: + GET msg_*/_search + { + "query": { + "bool": { + "must": [ + {"term": {"mention_user_ids": "u_1002"}}, + {"range": {"send_time": {"gte": "now-7d"}}} + ] + } + } + } +``` + +### content_text 不存原文 + +``` +"index": true, // 建索引(搜索) +"store": false // 不存原文(节省) +``` + +显示时按 `server_msg_id` 回查 MySQL/HBase 拿原文。 + +## 2.4 索引模板 + +每月自动创建索引: + +```json +PUT _index_template/msg_template +{ + "index_patterns": ["msg_*"], + "template": { + "settings": { /* 同上 */ }, + "mappings": { /* 同上 */ } + }, + "data_stream": {} // 或不用 data stream,用 alias 管理 +} +``` + +--- + +# 3. 分片策略 + +## 3.1 分片数量 + +``` +单 shard 推荐大小: 30~50 GB +单月数据量预估: 32 shard × 50GB = 1.6TB + +算法: + shard 数 = 月数据量 / 50GB + +50亿消息/月 × 1KB = 5TB → 100 shards +``` + +## 3.2 副本数 + +``` +生产环境: 1~2 副本 +读多写少: 2 副本 +读写均衡: 1 副本 +``` + +## 3.3 Routing(路由) + +``` +PUT msg_2026_05/_doc/abc?routing=u_1001 +{ ... } +``` + +让同一用户的消息落同一 shard,**搜索时只查目标 shard**: + +``` +GET msg_*/_search?routing=u_1001 +``` + +减少 90% 的 shard 查询。 + +但群消息怎么办?群消息有多个相关用户... + +### 推荐方案:按 conv_id routing + +``` +routing = hash(conv_id) +``` + +- 私聊:双方 conv_id 一致 → 同 shard +- 群聊:群内所有消息同 shard +- 用户搜索:先查会话列表 → 多 routing 并行查 + +``` +GET msg_*/_search?routing=conv_1,conv_2,conv_3 +{ + "query": { + "bool": { + "must": [ + {"terms": {"conv_id": ["conv_1", "conv_2", "conv_3"]}}, + {"match": {"content_text": "xxx"}} + ] + } + } +} +``` + +## 3.4 索引生命周期管理(ILM) + +```json +PUT _ilm/policy/msg_policy +{ + "policy": { + "phases": { + "hot": { + "actions": { + "rollover": { + "max_size": "1.5TB", + "max_age": "30d" + } + } + }, + "warm": { + "min_age": "30d", + "actions": { + "shrink": {"number_of_shards": 1}, + "forcemerge": {"max_num_segments": 1}, + "allocate": {"include": {"box_type": "warm"}} + } + }, + "cold": { + "min_age": "180d", + "actions": { + "freeze": {}, + "allocate": {"include": {"box_type": "cold"}} + } + }, + "delete": { + "min_age": "365d", + "actions": {"delete": {}} + } + } + } +} +``` + +``` +hot: SSD, 最近 30 天 +warm: HDD, 30~180 天 +cold: 归档, 180~365 天 +delete: 删除, 1 年以上 +``` + +--- + +# 4. 写入链路 + +## 4.1 写入架构 + +``` +消息入库 + │ + ▼ +Kafka: search.index (msg.fanout 衍生) + │ + ▼ +SearchIndexer (Consumer) + │ + ├─ 内容提取 + ├─ 字段构造 + └─ Bulk 写 ES + │ + ▼ +ES Cluster +``` + +## 4.2 SearchIndexer 实现 + +```go +type SearchIndexer struct { + consumer *kafka.Consumer + esClient *elasticsearch.Client + bulkBuf []*Document + bulkSize int + flushTimer *time.Timer +} + +func (s *SearchIndexer) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + s.flush() + return nil + default: + msg, err := s.consumer.ReadMessage(100 * time.Millisecond) + if err != nil { + continue + } + + doc := s.buildDocument(msg) + s.bulkBuf = append(s.bulkBuf, doc) + + if len(s.bulkBuf) >= s.bulkSize { + s.flush() + } + } + } +} + +func (s *SearchIndexer) buildDocument(msg *kafka.Message) *Document { + var event MessageEvent + proto.Unmarshal(msg.Value, &event) + + // 内容提取(剥离格式,提取纯文本) + contentText := extractText(event.Content) + + return &Document{ + ID: event.ServerMsgID, + Index: indexName(event.SendTime), // msg_2026_05 + Routing: event.ConvID, + Body: map[string]interface{}{ + "server_msg_id": event.ServerMsgID, + "conv_id": event.ConvID, + "sender_id": event.SenderID, + "msg_type": event.MsgType, + "content_text": contentText, + "mention_user_ids": event.MentionUserIDs, + "has_url": hasURL(contentText), + "has_image": event.MsgType == "image", + "has_file": event.MsgType == "file", + "file_name": event.FileName, + "send_time": event.SendTime, + "visible_seq": event.VisibleSeq, + "status": 0, + }, + } +} + +func (s *SearchIndexer) flush() error { + if len(s.bulkBuf) == 0 { + return nil + } + + var bulkBody bytes.Buffer + for _, doc := range s.bulkBuf { + meta := map[string]map[string]interface{}{ + "index": { + "_index": doc.Index, + "_id": doc.ID, + "routing": doc.Routing, + }, + } + json.NewEncoder(&bulkBody).Encode(meta) + json.NewEncoder(&bulkBody).Encode(doc.Body) + } + + resp, err := s.esClient.Bulk(bytes.NewReader(bulkBody.Bytes())) + if err != nil { + return err + } + defer resp.Body.Close() + + // 检查每个 item 的错误 + var bulkResp BulkResponse + json.NewDecoder(resp.Body).Decode(&bulkResp) + if bulkResp.Errors { + s.handlePartialFailure(bulkResp) + } + + s.consumer.CommitMessages(...) + s.bulkBuf = s.bulkBuf[:0] + return nil +} +``` + +## 4.3 关键参数 + +``` +bulk size: 500~1000 docs / batch +flush interval: 1 秒(保证时效) +parallel writers: 16 (匹配 ES shard) +retry policy: 指数退避,3 次 +``` + +## 4.4 撤回 / 删除处理 + +```go +// 撤回事件 → 更新文档 status +func (s *SearchIndexer) handleRecall(event *RecallEvent) { + s.esClient.Update( + indexName(event.SendTime), + event.ServerMsgID, + map[string]interface{}{ + "doc": map[string]interface{}{"status": 1}, + }, + ) +} + +// 永久删除 → DELETE +func (s *SearchIndexer) handleDelete(event *DeleteEvent) { + s.esClient.Delete( + indexName(event.SendTime), + event.ServerMsgID, + ) +} +``` + +## 4.5 消息内容预处理 + +```go +func extractText(content *MessageContent) string { + switch content.Type { + case "text": + return content.Text + case "image": + return content.Caption // 图片描述 + case "file": + return content.FileName + case "rich_text": + return stripFormatting(content.Blocks) + case "card": + return content.Title + " " + content.Subtitle + default: + return "" + } +} +``` + +## 4.6 重建索引 + +``` +场景: 索引结构变更,需重建 + +方法 1: Reindex API +POST _reindex +{ + "source": {"index": "msg_2026_05"}, + "dest": {"index": "msg_2026_05_v2"} +} + +方法 2: 从 Kafka / DB 重新消费 + Kafka 设置较长 retention + 或从 DB 反向重建 +``` + +--- + +# 5. 查询优化 + +## 5.1 典型查询 + +### 会话内搜索 + +```json +GET msg_*/_search?routing=conv_123 +{ + "query": { + "bool": { + "filter": [ + {"term": {"conv_id": "conv_123"}}, + {"term": {"status": 0}} + ], + "must": [ + {"match": {"content_text": "kubectl"}} + ] + } + }, + "sort": [ + {"send_time": "desc"} + ], + "size": 20, + "highlight": { + "fields": {"content_text": {}} + } +} +``` + +### 全局搜索(用户视角) + +```json +GET msg_*/_search?routing=conv_1,conv_2,...,conv_N +{ + "query": { + "bool": { + "filter": [ + {"terms": {"conv_id": ["conv_1", "conv_2", ...]}}, + {"term": {"status": 0}}, + {"range": {"send_time": {"gte": "now-90d"}}} + ], + "must": [ + { + "multi_match": { + "query": "kubernetes", + "fields": ["content_text^2", "file_name"], + "type": "best_fields" + } + } + ] + } + }, + "size": 20 +} +``` + +### @我搜索 + +```json +GET msg_*/_search +{ + "query": { + "bool": { + "filter": [ + {"term": {"mention_user_ids": "u_1002"}}, + {"range": {"send_time": {"gte": "now-30d"}}} + ] + } + }, + "sort": [{"send_time": "desc"}], + "size": 50 +} +``` + +## 5.2 查询优化技巧 + +### 1. filter 优于 must + +``` +filter: 无评分,可缓存,快 +must: 有评分,慢 + +只在需要相关性的字段用 must +``` + +### 2. 限制时间范围 + +``` +默认: 最近 90 天 +深度: 最近 1 年 +极深: 全部 (单独入口,慢) +``` + +### 3. 限制返回字段 + +```json +"_source": ["server_msg_id", "conv_id", "send_time"] +``` + +只返回必要字段,详情按 ID 回查 DB。 + +### 4. 避免深分页 + +``` +错误: from=10000, size=20 → 性能爆炸 + +正确: search_after +{ + "sort": [{"send_time": "desc"}, {"server_msg_id": "desc"}], + "search_after": [1710000000000, "s_888"] +} +``` + +### 5. 利用查询缓存 + +``` +filter context 自动缓存 +高频 filter 用 term/terms,不用 range +``` + +## 5.3 高亮 + +```json +"highlight": { + "pre_tags": [""], + "post_tags": [""], + "fields": { + "content_text": { + "fragment_size": 100, + "number_of_fragments": 1 + } + } +} +``` + +## 5.4 模糊查询 / 拼写纠错 + +```json +{ + "match": { + "content_text": { + "query": "kuberntes", // 拼错 + "fuzziness": "AUTO" + } + } +} +``` + +## 5.5 短语查询 + +```json +{ + "match_phrase": { + "content_text": { + "query": "Elasticsearch 设计", + "slop": 2 + } + } +} +``` + +--- + +# 6. 相关性排序 + +## 6.1 默认排序 + +``` +按时间倒序: 最新消息优先(IM 主流场景) +``` + +## 6.2 综合排序(function_score) + +```json +{ + "query": { + "function_score": { + "query": { + "match": {"content_text": "kubectl"} + }, + "functions": [ + { + "exp": { + "send_time": { + "origin": "now", + "scale": "30d", + "decay": 0.5 + } + }, + "weight": 2 + }, + { + "filter": {"term": {"sender_id": "u_friend"}}, + "weight": 1.5 + } + ], + "score_mode": "sum", + "boost_mode": "multiply" + } + } +} +``` + +## 6.3 排序维度 + +``` +1. 文本相关性 (BM25 默认) +2. 时间衰减 (越新越好) +3. 发送者亲密度 (常聊的人优先) +4. 会话活跃度 +5. 消息类型 (文本 > 图片 > 文件) +``` + +## 6.4 个性化 + +``` +用户最近搜过/点过的关键词 → boost 相关结果 +基于用户行为微调 +``` + +--- + +# 7. 安全与隔离 + +## 7.1 多租户隔离 + +``` +1. 索引按 app_id 隔离 (大客户独立索引) +2. 查询必须带 app_id filter +3. ES 用户权限按 app 分配 +``` + +## 7.2 用户权限 + +``` +查询前置: + 1. 鉴权用户身份 + 2. 查询用户加入的 conv_ids + 3. 过滤条件强制带 conv_ids + 4. 不允许跨用户搜索 +``` + +## 7.3 敏感内容 + +``` +合规要求: + - 涉政/涉黄消息不入索引 + - 写入前过 内容审核 + - 已索引的违规消息触发删除 +``` + +--- + +# 8. 容量规划 + +## 8.1 容量预估 + +``` +日消息量: 20 亿 +单文档大小: 500 字节 (索引) +日索引增量: 20亿 × 500B = 1TB +月增量: 30TB +保留 1 年: 360TB + +副本 ×2: 720TB +``` + +## 8.2 节点规划 + +``` +Hot 节点: + - 数据: 最近 30 天 (30TB × 2 = 60TB) + - SSD, 16C 64G + - 单节点 5TB → 12 节点 + +Warm 节点: + - 数据: 30~180 天 (180TB) + - HDD, 16C 64G + - 单节点 10TB → 18 节点 + +Cold 节点: + - 数据: 180~365 天 + - HDD, 低规格 + - 单节点 20TB → 18 节点 + +Master 节点: 3 个 (奇数) +Coordinator: 4 个 (查询专用) + +总计: ~55 节点 +``` + +## 8.3 内存 + +``` +heap: 31GB (不超过 32GB) +filesystem cache: 32GB +``` + +--- + +# 9. 运维与监控 + +## 9.1 关键指标 + +``` +- index rate +- search rate +- indexing latency +- search latency p99 +- JVM heap usage +- GC time +- queue rejection +- shard 健康 +- 节点数 +- 磁盘水位 +``` + +## 9.2 告警 + +``` +P0: 集群 red +P0: 写入失败率 > 5% +P1: 查询延迟 P99 > 1s +P1: 单节点磁盘 > 85% +P2: 索引未及时滚动 +``` + +## 9.3 常见故障 + +### shard 分配失败 + +```bash +GET _cluster/allocation/explain + +# 常见原因: +# - 磁盘水位 +# - 分片限制 +# - 节点过滤 +``` + +### 慢查询 + +``` +GET _nodes/hot_threads +GET _tasks?actions=*search*&detailed +``` + +### 集群升级 + +``` +1. 滚动升级 (一个一个节点) +2. 关闭 shard 重平衡 +3. 升级 +4. 启动并加入 +5. 等 yellow → green +6. 下一个 +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/09-Message-Search-v1.0_Version0.md b/_drafts/IM/09-Message-Search-v1.0_Version0.md new file mode 100755 index 000000000..50c772b2d --- /dev/null +++ b/_drafts/IM/09-Message-Search-v1.0_Version0.md @@ -0,0 +1,916 @@ +# 消息搜索引擎设计 v1.0 + +> 适用:IM 历史消息搜索、聊天记录全文检索、@我聚合查询 +> 选型:Elasticsearch 8.x +> 目标:万亿级消息、毫秒级查询、写入不阻塞主流程 + +--- + +## 目录 + +1. 设计目标与挑战 +2. 索引设计 +3. 分片策略 +4. 写入链路 +5. 查询优化 +6. 相关性排序 +7. 安全与隔离 +8. 容量规划 +9. 运维与监控 + +--- + +# 1. 设计目标与挑战 + +## 1.1 业务场景 + +``` +1. 会话内搜索: "在与张三的聊天里搜 'ES 设计'" +2. 全局搜索: "我的所有消息中搜 'kubectl'" +3. 联系人搜索: "搜张三发过的关于 Kafka 的消息" +4. 时间范围: "上周的所有 @我" +5. 消息类型筛选: "搜张三发的图片" +6. 高级语法: AND/OR/NOT/短语 +``` + +## 1.2 挑战 + +| 挑战 | 说明 | +|---|---| +| 数据量大 | 万亿级消息 | +| 写入高并发 | 50万 QPS | +| 多租户隔离 | 不能跨用户搜到 | +| 时效性 | 新消息秒级可搜 | +| 删除合规 | 撤回/封禁要清理 | +| 成本控制 | 不能每条都全字段索引 | + +## 1.3 设计目标 + +``` +写入延迟 P99: < 5s (消息可搜) +查询延迟 P99: < 200ms +查询召回率: > 95% +存储成本: 原始消息的 1.5x +``` + +--- + +# 2. 索引设计 + +## 2.1 索引拆分策略 + +### 按时间分索引(推荐) + +``` +msg_2026_05 ← 2026 年 5 月数据 +msg_2026_06 ← 6 月 +msg_2026_07 ← 7 月 +... +``` + +**优点**: +- 老数据可整体冷存储 / 删除 +- 查询时按时间范围只命中部分索引 +- 写入热点集中在最新索引 + +### 按用户分索引(不推荐) + +``` +msg_user_{shard}_{date} +``` + +每个用户独立 shard 太多,元数据爆炸。 + +### IM 推荐方案 + +``` +msg_{yyyy_MM} ← 按月分索引 +索引内 routing = userId ← 路由到特定 shard +``` + +兼顾时间分区和用户隔离。 + +## 2.2 字段映射 + +```json +PUT msg_2026_05 +{ + "settings": { + "number_of_shards": 32, + "number_of_replicas": 1, + "refresh_interval": "5s", + "index.codec": "best_compression", + "index.translog.durability": "async", + "index.translog.sync_interval": "5s", + + "analysis": { + "analyzer": { + "im_analyzer": { + "type": "custom", + "tokenizer": "ik_smart", + "filter": ["lowercase", "stop"] + }, + "im_search_analyzer": { + "type": "custom", + "tokenizer": "ik_max_word", + "filter": ["lowercase"] + } + } + } + }, + + "mappings": { + "properties": { + "server_msg_id": { + "type": "keyword" + }, + "conv_id": { + "type": "keyword" + }, + "sender_id": { + "type": "keyword" + }, + "recipient_id": { + "type": "keyword" // 私聊接收者,群消息为空 + }, + "owner_id": { + "type": "keyword" // 这条消息所属用户(搜索权限) + }, + "msg_type": { + "type": "keyword" + }, + "content_text": { + "type": "text", + "analyzer": "im_analyzer", + "search_analyzer": "im_search_analyzer", + "fields": { + "raw": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "mention_user_ids": { + "type": "keyword" + }, + "has_url": { + "type": "boolean" + }, + "has_image": { + "type": "boolean" + }, + "has_file": { + "type": "boolean" + }, + "file_name": { + "type": "text", + "analyzer": "im_analyzer" + }, + "send_time": { + "type": "date", + "format": "epoch_millis" + }, + "visible_seq": { + "type": "long" + }, + "status": { + "type": "byte" // 0:normal 1:recalled 2:deleted + } + } + } +} +``` + +## 2.3 关键设计点 + +### owner_id 字段(最重要) + +每条消息要给**每个能看到它的用户**建一个文档?这会爆炸。 + +**推荐方案**:消息只索引一份,**搜索时用 conv_id + 权限过滤**。 + +``` +索引一条消息: + conv_id = "c_123" + sender_id = "u_1001" + +搜索时: + user U 想搜: + 1. 先查 U 加入了哪些 conv (Redis 缓存) + 2. 在 ES 查: WHERE conv_id IN [...] AND content MATCHES "xxx" +``` + +**为什么不复制多份**: +- 万人群一条消息要写 1 万份索引 +- 写入放大严重 +- 撤回/编辑要更新所有副本 + +### mention_user_ids 字段 + +``` +"@我" 的快速过滤: + GET msg_*/_search + { + "query": { + "bool": { + "must": [ + {"term": {"mention_user_ids": "u_1002"}}, + {"range": {"send_time": {"gte": "now-7d"}}} + ] + } + } + } +``` + +### content_text 不存原文 + +``` +"index": true, // 建索引(搜索) +"store": false // 不存原文(节省) +``` + +显示时按 `server_msg_id` 回查 MySQL/HBase 拿原文。 + +## 2.4 索引模板 + +每月自动创建索引: + +```json +PUT _index_template/msg_template +{ + "index_patterns": ["msg_*"], + "template": { + "settings": { /* 同上 */ }, + "mappings": { /* 同上 */ } + }, + "data_stream": {} // 或不用 data stream,用 alias 管理 +} +``` + +--- + +# 3. 分片策略 + +## 3.1 分片数量 + +``` +单 shard 推荐大小: 30~50 GB +单月数据量预估: 32 shard × 50GB = 1.6TB + +算法: + shard 数 = 月数据量 / 50GB + +50亿消息/月 × 1KB = 5TB → 100 shards +``` + +## 3.2 副本数 + +``` +生产环境: 1~2 副本 +读多写少: 2 副本 +读写均衡: 1 副本 +``` + +## 3.3 Routing(路由) + +``` +PUT msg_2026_05/_doc/abc?routing=u_1001 +{ ... } +``` + +让同一用户的消息落同一 shard,**搜索时只查目标 shard**: + +``` +GET msg_*/_search?routing=u_1001 +``` + +减少 90% 的 shard 查询。 + +但群消息怎么办?群消息有多个相关用户... + +### 推荐方案:按 conv_id routing + +``` +routing = hash(conv_id) +``` + +- 私聊:双方 conv_id 一致 → 同 shard +- 群聊:群内所有消息同 shard +- 用户搜索:先查会话列表 → 多 routing 并行查 + +``` +GET msg_*/_search?routing=conv_1,conv_2,conv_3 +{ + "query": { + "bool": { + "must": [ + {"terms": {"conv_id": ["conv_1", "conv_2", "conv_3"]}}, + {"match": {"content_text": "xxx"}} + ] + } + } +} +``` + +## 3.4 索引生命周期管理(ILM) + +```json +PUT _ilm/policy/msg_policy +{ + "policy": { + "phases": { + "hot": { + "actions": { + "rollover": { + "max_size": "1.5TB", + "max_age": "30d" + } + } + }, + "warm": { + "min_age": "30d", + "actions": { + "shrink": {"number_of_shards": 1}, + "forcemerge": {"max_num_segments": 1}, + "allocate": {"include": {"box_type": "warm"}} + } + }, + "cold": { + "min_age": "180d", + "actions": { + "freeze": {}, + "allocate": {"include": {"box_type": "cold"}} + } + }, + "delete": { + "min_age": "365d", + "actions": {"delete": {}} + } + } + } +} +``` + +``` +hot: SSD, 最近 30 天 +warm: HDD, 30~180 天 +cold: 归档, 180~365 天 +delete: 删除, 1 年以上 +``` + +--- + +# 4. 写入链路 + +## 4.1 写入架构 + +``` +消息入库 + │ + ▼ +Kafka: search.index (msg.fanout 衍生) + │ + ▼ +SearchIndexer (Consumer) + │ + ├─ 内容提取 + ├─ 字段构造 + └─ Bulk 写 ES + │ + ▼ +ES Cluster +``` + +## 4.2 SearchIndexer 实现 + +```go +type SearchIndexer struct { + consumer *kafka.Consumer + esClient *elasticsearch.Client + bulkBuf []*Document + bulkSize int + flushTimer *time.Timer +} + +func (s *SearchIndexer) Run(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + s.flush() + return nil + default: + msg, err := s.consumer.ReadMessage(100 * time.Millisecond) + if err != nil { + continue + } + + doc := s.buildDocument(msg) + s.bulkBuf = append(s.bulkBuf, doc) + + if len(s.bulkBuf) >= s.bulkSize { + s.flush() + } + } + } +} + +func (s *SearchIndexer) buildDocument(msg *kafka.Message) *Document { + var event MessageEvent + proto.Unmarshal(msg.Value, &event) + + // 内容提取(剥离格式,提取纯文本) + contentText := extractText(event.Content) + + return &Document{ + ID: event.ServerMsgID, + Index: indexName(event.SendTime), // msg_2026_05 + Routing: event.ConvID, + Body: map[string]interface{}{ + "server_msg_id": event.ServerMsgID, + "conv_id": event.ConvID, + "sender_id": event.SenderID, + "msg_type": event.MsgType, + "content_text": contentText, + "mention_user_ids": event.MentionUserIDs, + "has_url": hasURL(contentText), + "has_image": event.MsgType == "image", + "has_file": event.MsgType == "file", + "file_name": event.FileName, + "send_time": event.SendTime, + "visible_seq": event.VisibleSeq, + "status": 0, + }, + } +} + +func (s *SearchIndexer) flush() error { + if len(s.bulkBuf) == 0 { + return nil + } + + var bulkBody bytes.Buffer + for _, doc := range s.bulkBuf { + meta := map[string]map[string]interface{}{ + "index": { + "_index": doc.Index, + "_id": doc.ID, + "routing": doc.Routing, + }, + } + json.NewEncoder(&bulkBody).Encode(meta) + json.NewEncoder(&bulkBody).Encode(doc.Body) + } + + resp, err := s.esClient.Bulk(bytes.NewReader(bulkBody.Bytes())) + if err != nil { + return err + } + defer resp.Body.Close() + + // 检查每个 item 的错误 + var bulkResp BulkResponse + json.NewDecoder(resp.Body).Decode(&bulkResp) + if bulkResp.Errors { + s.handlePartialFailure(bulkResp) + } + + s.consumer.CommitMessages(...) + s.bulkBuf = s.bulkBuf[:0] + return nil +} +``` + +## 4.3 关键参数 + +``` +bulk size: 500~1000 docs / batch +flush interval: 1 秒(保证时效) +parallel writers: 16 (匹配 ES shard) +retry policy: 指数退避,3 次 +``` + +## 4.4 撤回 / 删除处理 + +```go +// 撤回事件 → 更新文档 status +func (s *SearchIndexer) handleRecall(event *RecallEvent) { + s.esClient.Update( + indexName(event.SendTime), + event.ServerMsgID, + map[string]interface{}{ + "doc": map[string]interface{}{"status": 1}, + }, + ) +} + +// 永久删除 → DELETE +func (s *SearchIndexer) handleDelete(event *DeleteEvent) { + s.esClient.Delete( + indexName(event.SendTime), + event.ServerMsgID, + ) +} +``` + +## 4.5 消息内容预处理 + +```go +func extractText(content *MessageContent) string { + switch content.Type { + case "text": + return content.Text + case "image": + return content.Caption // 图片描述 + case "file": + return content.FileName + case "rich_text": + return stripFormatting(content.Blocks) + case "card": + return content.Title + " " + content.Subtitle + default: + return "" + } +} +``` + +## 4.6 重建索引 + +``` +场景: 索引结构变更,需重建 + +方法 1: Reindex API +POST _reindex +{ + "source": {"index": "msg_2026_05"}, + "dest": {"index": "msg_2026_05_v2"} +} + +方法 2: 从 Kafka / DB 重新消费 + Kafka 设置较长 retention + 或从 DB 反向重建 +``` + +--- + +# 5. 查询优化 + +## 5.1 典型查询 + +### 会话内搜索 + +```json +GET msg_*/_search?routing=conv_123 +{ + "query": { + "bool": { + "filter": [ + {"term": {"conv_id": "conv_123"}}, + {"term": {"status": 0}} + ], + "must": [ + {"match": {"content_text": "kubectl"}} + ] + } + }, + "sort": [ + {"send_time": "desc"} + ], + "size": 20, + "highlight": { + "fields": {"content_text": {}} + } +} +``` + +### 全局搜索(用户视角) + +```json +GET msg_*/_search?routing=conv_1,conv_2,...,conv_N +{ + "query": { + "bool": { + "filter": [ + {"terms": {"conv_id": ["conv_1", "conv_2", ...]}}, + {"term": {"status": 0}}, + {"range": {"send_time": {"gte": "now-90d"}}} + ], + "must": [ + { + "multi_match": { + "query": "kubernetes", + "fields": ["content_text^2", "file_name"], + "type": "best_fields" + } + } + ] + } + }, + "size": 20 +} +``` + +### @我搜索 + +```json +GET msg_*/_search +{ + "query": { + "bool": { + "filter": [ + {"term": {"mention_user_ids": "u_1002"}}, + {"range": {"send_time": {"gte": "now-30d"}}} + ] + } + }, + "sort": [{"send_time": "desc"}], + "size": 50 +} +``` + +## 5.2 查询优化技巧 + +### 1. filter 优于 must + +``` +filter: 无评分,可缓存,快 +must: 有评分,慢 + +只在需要相关性的字段用 must +``` + +### 2. 限制时间范围 + +``` +默认: 最近 90 天 +深度: 最近 1 年 +极深: 全部 (单独入口,慢) +``` + +### 3. 限制返回字段 + +```json +"_source": ["server_msg_id", "conv_id", "send_time"] +``` + +只返回必要字段,详情按 ID 回查 DB。 + +### 4. 避免深分页 + +``` +错误: from=10000, size=20 → 性能爆炸 + +正确: search_after +{ + "sort": [{"send_time": "desc"}, {"server_msg_id": "desc"}], + "search_after": [1710000000000, "s_888"] +} +``` + +### 5. 利用查询缓存 + +``` +filter context 自动缓存 +高频 filter 用 term/terms,不用 range +``` + +## 5.3 高亮 + +```json +"highlight": { + "pre_tags": [""], + "post_tags": [""], + "fields": { + "content_text": { + "fragment_size": 100, + "number_of_fragments": 1 + } + } +} +``` + +## 5.4 模糊查询 / 拼写纠错 + +```json +{ + "match": { + "content_text": { + "query": "kuberntes", // 拼错 + "fuzziness": "AUTO" + } + } +} +``` + +## 5.5 短语查询 + +```json +{ + "match_phrase": { + "content_text": { + "query": "Elasticsearch 设计", + "slop": 2 + } + } +} +``` + +--- + +# 6. 相关性排序 + +## 6.1 默认排序 + +``` +按时间倒序: 最新消息优先(IM 主流场景) +``` + +## 6.2 综合排序(function_score) + +```json +{ + "query": { + "function_score": { + "query": { + "match": {"content_text": "kubectl"} + }, + "functions": [ + { + "exp": { + "send_time": { + "origin": "now", + "scale": "30d", + "decay": 0.5 + } + }, + "weight": 2 + }, + { + "filter": {"term": {"sender_id": "u_friend"}}, + "weight": 1.5 + } + ], + "score_mode": "sum", + "boost_mode": "multiply" + } + } +} +``` + +## 6.3 排序维度 + +``` +1. 文本相关性 (BM25 默认) +2. 时间衰减 (越新越好) +3. 发送者亲密度 (常聊的人优先) +4. 会话活跃度 +5. 消息类型 (文本 > 图片 > 文件) +``` + +## 6.4 个性化 + +``` +用户最近搜过/点过的关键词 → boost 相关结果 +基于用户行为微调 +``` + +--- + +# 7. 安全与隔离 + +## 7.1 多租户隔离 + +``` +1. 索引按 app_id 隔离 (大客户独立索引) +2. 查询必须带 app_id filter +3. ES 用户权限按 app 分配 +``` + +## 7.2 用户权限 + +``` +查询前置: + 1. 鉴权用户身份 + 2. 查询用户加入的 conv_ids + 3. 过滤条件强制带 conv_ids + 4. 不允许跨用户搜索 +``` + +## 7.3 敏感内容 + +``` +合规要求: + - 涉政/涉黄消息不入索引 + - 写入前过 内容审核 + - 已索引的违规消息触发删除 +``` + +--- + +# 8. 容量规划 + +## 8.1 容量预估 + +``` +日消息量: 20 亿 +单文档大小: 500 字节 (索引) +日索引增量: 20亿 × 500B = 1TB +月增量: 30TB +保留 1 年: 360TB + +副本 ×2: 720TB +``` + +## 8.2 节点规划 + +``` +Hot 节点: + - 数据: 最近 30 天 (30TB × 2 = 60TB) + - SSD, 16C 64G + - 单节点 5TB → 12 节点 + +Warm 节点: + - 数据: 30~180 天 (180TB) + - HDD, 16C 64G + - 单节点 10TB → 18 节点 + +Cold 节点: + - 数据: 180~365 天 + - HDD, 低规格 + - 单节点 20TB → 18 节点 + +Master 节点: 3 个 (奇数) +Coordinator: 4 个 (查询专用) + +总计: ~55 节点 +``` + +## 8.3 内存 + +``` +heap: 31GB (不超过 32GB) +filesystem cache: 32GB +``` + +--- + +# 9. 运维与监控 + +## 9.1 关键指标 + +``` +- index rate +- search rate +- indexing latency +- search latency p99 +- JVM heap usage +- GC time +- queue rejection +- shard 健康 +- 节点数 +- 磁盘水位 +``` + +## 9.2 告警 + +``` +P0: 集群 red +P0: 写入失败率 > 5% +P1: 查询延迟 P99 > 1s +P1: 单节点磁盘 > 85% +P2: 索引未及时滚动 +``` + +## 9.3 常见故障 + +### shard 分配失败 + +```bash +GET _cluster/allocation/explain + +# 常见原因: +# - 磁盘水位 +# - 分片限制 +# - 节点过滤 +``` + +### 慢查询 + +``` +GET _nodes/hot_threads +GET _tasks?actions=*search*&detailed +``` + +### 集群升级 + +``` +1. 滚动升级 (一个一个节点) +2. 关闭 shard 重平衡 +3. 升级 +4. 启动并加入 +5. 等 yellow → green +6. 下一个 +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/10-Group-System-v1.0_Version4.md b/_drafts/IM/10-Group-System-v1.0_Version4.md new file mode 100755 index 000000000..0d2ea79f8 --- /dev/null +++ b/_drafts/IM/10-Group-System-v1.0_Version4.md @@ -0,0 +1,646 @@ +# 群组系统详细设计 v1.0 + +> 适用:千人群、万人群、十万人群、百万订阅频道 +> 目标:成员管理高效、消息分发可控、权限严格 + +--- + +## 目录 + +1. 群组分级 +2. 数据模型 +3. 成员管理 +4. 权限模型 +5. 消息分发策略 +6. 大群优化 +7. 频道与超大群 +8. 群操作流程 + +--- + +# 1. 群组分级 + +## 1.1 分级定义 + +| 等级 | 成员数 | 称呼 | 分发模式 | 典型场景 | +|---|---|---|---|---| +| L1 | ≤ 200 | 普通群 | 写扩散 | 朋友群 | +| L2 | 201~2000 | 中群 | 写扩散到活跃 | 部门群 | +| L3 | 2001~10000 | 大群 | 混合 | 兴趣群 | +| L4 | 10001~100000 | 超大群 | 读扩散 | 社区群 | +| L5 | > 100000 | 频道 | 订阅 + 推送 | 公告频道 | + +## 1.2 分级策略影响 + +``` +L1: 全员写 inbox + 实时推送 +L2: 活跃成员写 inbox + 实时推送 +L3: 不写 inbox,客户端拉取 + 实时推送活跃 +L4: 完全读扩散,不主动推 +L5: 订阅模型,主播推送给订阅者 +``` + +--- + +# 2. 数据模型 + +## 2.1 群表 + +```sql +CREATE TABLE im_group ( + group_id BIGINT PRIMARY KEY, + app_id INT NOT NULL, + name VARCHAR(64) NOT NULL, + avatar VARCHAR(256), + description TEXT, + + group_type TINYINT NOT NULL, -- 1:normal 2:channel 3:bot + group_level TINYINT NOT NULL, -- L1~L5 + member_count INT DEFAULT 0, + max_member INT NOT NULL, + + owner_id BIGINT NOT NULL, + + -- 设置 + join_mode TINYINT, -- 0:open 1:approval 2:invite + msg_mode TINYINT, -- 0:all_can_send 1:only_admin + + -- 状态 + status TINYINT DEFAULT 0, -- 0:normal 1:disbanded 2:frozen + + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +``` + +## 2.2 群成员表 + +```sql +CREATE TABLE group_member ( + group_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + + role TINYINT DEFAULT 0, -- 0:member 1:admin 2:owner + nickname VARCHAR(64), -- 群昵称 + + joined_seq BIGINT, -- 加入时的 visible_seq + joined_at BIGINT NOT NULL, + + -- 状态 + status TINYINT DEFAULT 0, -- 0:active 1:muted 2:left + mute_until BIGINT, -- 禁言到期 + + -- 用户视角 + is_pinned TINYINT DEFAULT 0, + is_muted TINYINT DEFAULT 0, -- 用户对群的免打扰 + + PRIMARY KEY (group_id, user_id), + KEY idx_user (user_id, status) +) PARTITION BY HASH(group_id) PARTITIONS 64; +``` + +## 2.3 用户加入的群(反向索引) + +```sql +CREATE TABLE user_groups ( + user_id BIGINT NOT NULL, + group_id BIGINT NOT NULL, + joined_at BIGINT NOT NULL, + PRIMARY KEY (user_id, group_id) +) PARTITION BY HASH(user_id) PARTITIONS 256; +``` + +## 2.4 Redis 缓存 + +``` +group:meta:{group_id} Hash 群基本信息 +group:members:{group_id} Set 成员列表(小群) +group:admins:{group_id} Set 管理员 +group:active:{group_id} ZSet 活跃成员(按最后活跃时间) +user:groups:{user_id} Set 用户加入的群 +``` + +--- + +# 3. 成员管理 + +## 3.1 加入群组 + +### 主动加入(开放群) + +```python +def join_group(user_id, group_id): + # 1. 校验 + group = get_group(group_id) + if group.status != NORMAL: + raise GroupNotAvailable + if group.member_count >= group.max_member: + raise GroupFull + if group.join_mode == APPROVAL: + return submit_join_request(user_id, group_id) + + # 2. 风控 + if risk.is_blocked(user_id): + raise Blocked + + # 3. 加入 + with db.transaction(): + max_seq = get_max_visible_seq(group_id) + db.insert("group_member", { + "group_id": group_id, + "user_id": user_id, + "role": MEMBER, + "joined_seq": max_seq, + "joined_at": now() + }) + db.insert("user_groups", {...}) + db.update("im_group SET member_count = member_count + 1") + + # 4. 发系统消息 + send_system_message(group_id, f"{user_name} 加入了群聊") + + # 5. 刷缓存 + redis.sadd(f"group:members:{group_id}", user_id) + redis.sadd(f"user:groups:{user_id}", group_id) +``` + +### 被邀请加入 + +```python +def invite_to_group(inviter_id, group_id, invitee_ids): + # 1. 权限校验 + if not can_invite(inviter_id, group_id): + raise NoPermission + + # 2. 限流 + rate_limit_check(f"invite:{inviter_id}", 100, "1h") + + # 3. 批量邀请 + for invitee_id in invitee_ids: + if group.join_mode == INVITE_REQUIRES_APPROVAL: + send_invite_card(invitee_id, group_id) + else: + join_group_directly(invitee_id, group_id) +``` + +### 批量加入(万人群批量导入) + +```python +def batch_join(group_id, user_ids): + # 分批,避免单次事务过大 + batch_size = 100 + for batch in chunk(user_ids, batch_size): + with db.transaction(): + db.batch_insert("group_member", batch_records) + db.batch_insert("user_groups", batch_records) + db.update(f"im_group SET member_count = member_count + {len(batch)}") + + # 系统消息合并(不要每人一条) + + # 1 条合并的系统消息 + send_system_message( + group_id, + f"{inviter} 邀请 {len(user_ids)} 人加入了群聊" + ) +``` + +## 3.2 退出群组 + +```python +def leave_group(user_id, group_id): + with db.transaction(): + db.delete("group_member WHERE group_id=? AND user_id=?") + db.delete("user_groups WHERE ...") + db.update("im_group SET member_count = member_count - 1") + + redis.srem(f"group:members:{group_id}", user_id) + redis.srem(f"user:groups:{user_id}", group_id) + + # 系统消息 + send_system_message(group_id, f"{user_name} 退出了群聊") +``` + +## 3.3 踢出成员 + +```python +def kick_member(operator_id, group_id, target_id): + # 权限: 仅 owner/admin 可踢 + if not is_admin(operator_id, group_id): + raise NoPermission + + # admin 不能踢 admin (除非 owner) + if is_admin(target_id, group_id) and not is_owner(operator_id, group_id): + raise NoPermission + + leave_group(target_id, group_id) + send_system_message(group_id, f"{target} 被 {operator} 移出群聊") +``` + +## 3.4 成员列表查询 + +### 小群(< 1000) + +```python +def get_members_small(group_id): + return redis.smembers(f"group:members:{group_id}") +``` + +### 大群 + +```python +def get_members_large(group_id, page=0, size=50): + # Redis ZSet 按角色 + 加入时间分页 + return redis.zrange( + f"group:members_zset:{group_id}", + page * size, + (page + 1) * size - 1 + ) +``` + +### 万人群 + +``` +不返回完整列表,只返回: + - 总人数 + - 管理员列表 + - 当前用户附近的成员 + - 搜索接口(按昵称) +``` + +## 3.5 成员搜索 + +```python +def search_members(group_id, keyword): + # 大群用 ES + return es.search( + index="group_member_index", + body={ + "query": { + "bool": { + "filter": [{"term": {"group_id": group_id}}], + "must": [{"match": {"nickname": keyword}}] + } + } + } + ) +``` + +--- + +# 4. 权限模型 + +## 4.1 角色 + +| 角色 | 权限 | +|---|---| +| **Owner** (群主) | 全部权限,唯一 | +| **Admin** (管理员) | 除 解散群、转让、修改群主外 | +| **Member** (普通成员) | 发消息、退群 | + +## 4.2 权限矩阵 + +| 操作 | Member | Admin | Owner | +|---|:---:|:---:|:---:| +| 发消息 | ✅ | ✅ | ✅ | +| 邀请新人 | ✅ * | ✅ | ✅ | +| 踢人 | ❌ | ✅ ** | ✅ | +| @所有人 | ❌ | ✅ | ✅ | +| 修改群名 | ❌ | ✅ | ✅ | +| 修改群头像 | ❌ | ✅ | ✅ | +| 设置公告 | ❌ | ✅ | ✅ | +| 禁言成员 | ❌ | ✅ ** | ✅ | +| 全员禁言 | ❌ | ✅ | ✅ | +| 任命管理员 | ❌ | ❌ | ✅ | +| 转让群主 | ❌ | ❌ | ✅ | +| 解散群 | ❌ | ❌ | ✅ | + +``` +* 视群设置而定 +** 不能操作其他 Admin +``` + +## 4.3 权限校验 + +```python +class PermissionChecker: + def can_send_message(self, user_id, group_id): + member = get_member(group_id, user_id) + if not member or member.status == LEFT: + return False + if member.status == MUTED: + if member.mute_until > now(): + return False + if group.msg_mode == ONLY_ADMIN and member.role == MEMBER: + return False + if group.all_muted and member.role == MEMBER: + return False + return True + + def can_kick(self, operator_id, group_id, target_id): + op = get_member(group_id, operator_id) + target = get_member(group_id, target_id) + + if op.role == MEMBER: + return False + if target.role == OWNER: + return False + if target.role == ADMIN and op.role != OWNER: + return False + return True +``` + +## 4.4 禁言 + +### 单人禁言 + +```python +def mute_member(operator_id, group_id, target_id, duration_seconds): + if not can_mute(operator_id, group_id, target_id): + raise NoPermission + + mute_until = now() + duration_seconds * 1000 + + db.update("group_member SET status=1, mute_until=? WHERE ...") + redis.zadd(f"group:muted:{group_id}", mute_until, target_id) + + send_system_message(...) +``` + +### 全员禁言 + +```python +def mute_all(operator_id, group_id, enabled): + if not is_admin(operator_id, group_id): + raise NoPermission + + db.update("im_group SET all_muted=? WHERE group_id=?", enabled) + redis.set(f"group:all_muted:{group_id}", enabled) +``` + +--- + +# 5. 消息分发策略 + +## 5.1 分发模式总览 + +``` +小群 (L1): 全员写扩散 + 实时推送 +中群 (L2): 活跃写扩散 + 实时推送活跃 + 离线只 push +大群 (L3): 不写扩散 + 客户端拉 + 实时推送活跃 +超大群 (L4): 完全读扩散 + 仅在线成员通知 +频道 (L5): 订阅模型,主播推送 +``` + +## 5.2 写扩散(L1 小群) + +``` +A 发消息到 100 人小群: + 1. 消息入库 (im_message) + 2. Kafka: msg.fanout + 3. InboxWriter 给 100 个成员各写一条 inbox + 4. Deliver 给在线成员实时推送 + 5. Push 给离线成员发 push +``` + +## 5.3 写扩散到活跃成员(L2 中群) + +``` +A 发消息到 1000 人中群: + 1. 消息入库 + 2. 活跃成员 (近 7 天访问) 写 inbox + - 通常活跃成员 < 30% + 3. 不活跃成员: 不写 inbox + - 上线后主动拉取 + 4. 在线成员实时推 +``` + +```python +def fanout_medium_group(group_id, msg): + active = redis.zrangebyscore( + f"group:active:{group_id}", + now() - 7*86400*1000, + now() + ) + + for batch in chunk(active, 100): + kafka.send("msg.inbox", batch_payload) + + # 在线推送 + online = filter_online(active) + for user in online: + push_to_gateway(user, msg) +``` + +## 5.4 读扩散(L4 超大群) + +``` +A 发消息到 5 万人大群: + 1. 消息入库 (im_message) + 2. 更新 group_meta.max_visible_seq + 3. 不写 inbox + 4. Kafka: notify_active_members (只通知) + 5. 在线成员: Deliver 实时推送 + 6. 离线成员: 上线后通过 max_seq 对比发现新消息 + 7. 客户端按 group_id + sinceSeq 拉取消息 +``` + +## 5.5 客户端拉取协议 + +``` +客户端打开群聊页面: + GET /messages?group_id=G&since_seq=1000&limit=30 + +返回: + { + "messages": [...], + "max_seq": 1050, + "has_more": true + } +``` + +## 5.6 推送优化 + +### 大群推送限速 + +``` +万人群消息频率上限: 5 条/秒 +超过 → 后续消息排队/丢弃 +``` + +### 大群 push 抑制 + +``` +普通群: 给离线成员发 push +万人群: 默认免打扰,不发 push(除非 @) +百万人群/频道: 完全不发 push(订阅成功才发) +``` + +### push 合并 + +``` +1 秒窗口内同群多条消息: + 合并为 1 个 push: "X 条新消息" +``` + +--- + +# 6. 大群优化 + +## 6.1 成员列表分页 + +``` +不返回完整列表,只: + - 总人数 + - 管理员 + - 最近活跃 100 人 + - 搜索接口 +``` + +## 6.2 在线成员统计 + +``` +不要 GET 全量在线成员 +: + GET 在线 admin + GET 在线总数(基于在线状态服务统计) +``` + +## 6.3 @所有人 不展开 + +``` +@all 标记 mention_all=true +不写 mention_index +查询时 UNION +``` + +## 6.4 成员加群 seq 锚定 + +``` +新成员只能看加群之后的消息 +SELECT * FROM message WHERE group_id=? AND visible_seq > joined_seq +``` + +## 6.5 系统消息合并 + +``` +1000 人在 1 分钟内加群: + 不要发 1000 条 "X 加入了群聊" + 合并为: "1000 人加入了群聊" +``` + +## 6.6 群成员变更同步 + +``` +小群: 实时全量推送 +大群: 通知变更类型,客户端按需拉 +``` + +--- + +# 7. 频道与超大群 + +## 7.1 频道(L5) + +``` +特点: + - 百万订阅者 + - 仅频道主/管理员可发 + - 单向传播 + - 严格防刷 +``` + +## 7.2 订阅模型 + +```sql +CREATE TABLE channel_subscriber ( + channel_id BIGINT, + user_id BIGINT, + subscribed_at BIGINT, + notification_level TINYINT, -- 0:silent 1:default 2:high + PRIMARY KEY (channel_id, user_id) +) PARTITION BY HASH(user_id); +``` + +## 7.3 频道消息分发 + +``` +两阶段 fanout: + +Stage 1 (轻量): + 写入消息 → Kafka: channel.broadcast + +Stage 2 (分页 fanout): + 消费 channel.broadcast + 分页拉订阅者列表 (1000/页) + 每页生成 N 条 push 事件 (加盐分区) + 逐页推到下一级 topic (msg.push.channel) +``` + +```python +def broadcast_channel_msg(channel_id, msg): + cursor = 0 + while True: + subs, cursor = scan_subscribers(channel_id, cursor, limit=1000) + if not subs: + break + + for batch in chunk(subs, 100): + kafka.send("msg.push.channel", { + "channel_id": channel_id, + "msg_id": msg.id, + "users": batch + }) + + # 限速,避免一条消息把集群打爆 + time.sleep(0.1) +``` + +## 7.4 频道推送策略 + +``` +高优 (notification_level=2): 实时推 +默认 (notification_level=1): 折叠合并 +静音 (notification_level=0): 不推 +``` + +--- + +# 8. 群操作流程 + +## 8.1 创建群 + +``` +1. 校验创建权限/限流 +2. 分配 group_id (雪花) +3. INSERT im_group +4. INSERT group_member (创建者 = owner) +5. 邀请初始成员(异步) +6. 发系统消息 "群聊创建" +7. 返回 group_id +``` + +## 8.2 转让群主 + +``` +仅 Owner 可操作: + 1. 校验目标在群内 + 2. 事务: + UPDATE group_member SET role=ADMIN WHERE owner + UPDATE group_member SET role=OWNER WHERE target + UPDATE im_group SET owner_id = target + 3. 系统消息: "群主已转让给 X" +``` + +## 8.3 解散群 + +``` +仅 Owner 可操作: + 1. 软删除: UPDATE im_group SET status=1 + 2. 异步清理 member, inbox + 3. 推送通知: "群已解散" + 4. 群消息保留 N 天供取证 +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/11-E2EE-Design-v1.0_Version4.md b/_drafts/IM/11-E2EE-Design-v1.0_Version4.md new file mode 100755 index 000000000..9d4d64318 --- /dev/null +++ b/_drafts/IM/11-E2EE-Design-v1.0_Version4.md @@ -0,0 +1,566 @@ +# 端到端加密(E2EE)设计 v1.0 + +> 适用:私聊 / 群聊 端到端加密 +> 协议:Signal Protocol (Double Ratchet + X3DH + Sender Keys) +> 目标:服务端不可见消息内容、前向安全、后向安全 + +--- + +## 目录 + +1. 安全目标 +2. Signal 协议总览 +3. 密钥协商 (X3DH) +4. 双棘轮 (Double Ratchet) +5. 群组加密 (Sender Keys) +6. 服务端职责 +7. 多设备同步 +8. 实现注意事项 + +--- + +# 1. 安全目标 + +## 1.1 安全属性 + +| 属性 | 含义 | +|---|---| +| **机密性** | 服务端无法解密 | +| **完整性** | 消息不可篡改 | +| **认证性** | 确认发送者身份 | +| **前向安全** | 长期密钥泄露不影响历史消息 | +| **后向安全** | 单条消息密钥泄露不影响后续消息 | +| **否认性** | 无��证明某条消息确实是某人发的 | + +## 1.2 威胁模型 + +``` +假设: + - 服务端可被攻陷 + - 网络可被监听 + - 客户端在用户设备上是可信的 + +不防: + - 客户端被入侵 + - 用户被胁迫 + - 元数据(谁和谁聊、何时聊) +``` + +--- + +# 2. Signal 协议总览 + +## 2.1 三大组件 + +``` +1. X3DH (Extended Triple Diffie-Hellman) + → 密钥协商,建立初始会话 + +2. Double Ratchet + → 持续密钥更新,前向 + 后向安全 + +3. Sender Keys + → 群组加密优化 +``` + +## 2.2 整体流程 + +``` +首次会话: + X3DH → 共享密钥 SK + SK → 初始化 Double Ratchet + +后续消息: + Double Ratchet → 每条消息独立密钥 + +群组: + 每个发送者维护 Sender Key Chain + 对群成员一对一发送 Sender Key + 群消息只用 Sender Key 加密一次 +``` + +--- + +# 3. 密钥协商 (X3DH) + +## 3.1 密钥类型 + +每个用户维护: + +| 密钥 | 长期/短期 | 用途 | +|---|---|---| +| Identity Key (IK) | 长期 | 身份认证 | +| Signed Pre Key (SPK) | 中期 (周/月轮换) | 用 IK 签名 | +| One-Time Pre Keys (OPK) | 一次性 | 一次性 | + +## 3.2 服务端 Pre-Key Bundle + +``` +用户上传到服务端: +{ + IK_pub: 长期身份公钥 + SPK_pub: 签名预共享公钥 + SPK_sig: SPK 用 IK 签名 + OPKs: [OPK1_pub, OPK2_pub, ...] 100 个 +} + +服务端职责: + - 存储 bundle + - 给请求方分发 SPK + 一个 OPK + - 删除已用 OPK + - 客户端补充 OPK (低于 10 时通知) +``` + +## 3.3 X3DH 协商流程 + +``` +A 想给 B 发消息(首次): + +1. A 从服务端获取 B 的 PreKeyBundle: + - IK_B + - SPK_B + SPK_sig_B + - OPK_B (可选) + +2. A 验证 SPK_sig_B (用 IK_B) + +3. A 生成临时密钥 EK_A + +4. A 计算 4 个 DH: + DH1 = DH(IK_A, SPK_B) + DH2 = DH(EK_A, IK_B) + DH3 = DH(EK_A, SPK_B) + DH4 = DH(EK_A, OPK_B) # 如有 OPK + + SK = KDF(DH1 || DH2 || DH3 || DH4) + +5. A 构造首条消息: + - IK_A_pub + - EK_A_pub + - 用 SK 派生的密钥加密的消息 + - OPK_id (告诉 B 用哪个 OPK) + +6. A 发送给服务端 + +7. B 收到: + - 取出对应 OPK 私钥 + - 计算同样的 4 个 DH (用自己私钥 + A 的公钥) + - 派生相同 SK + - 解密 +``` + +## 3.4 关键安全性 + +``` +只有 IK_B 泄露 → 不影响(需要 SPK 和 OPK) +只有 SPK_B 泄露 → 不影响(需要 IK 和 OPK) +所有都泄露 → 历史消息可解密(防止此场景靠定期轮换) +``` + +--- + +# 4. 双棘轮 (Double Ratchet) + +## 4.1 核心思想 + +每条消息用不同的密钥加密。 +密钥不断"棘轮式"前进,不可逆。 + +## 4.2 两个棘轮 + +### DH 棘轮(外层) + +``` +每次发送方有新消息要发 → + 生成新 DH 密钥对 + 消息携带新公钥 + 接收方收到后用自己当前 DH 私钥 + 新公钥 → 计算共享密钥 → 派生新 root key +``` + +### Symmetric 棘轮(内层) + +``` +对称密钥不断 KDF 派生: + CK_0 → CK_1 → CK_2 → ... + +每个 CK 派生一个 message key: + MK_i = KDF(CK_i) + +用完即弃,无法回推 +``` + +## 4.3 状态机 + +每个会话维护: + +``` +RootKey (RK) +SendingChainKey (CKs) +ReceivingChainKey (CKr) + +DHs (本端 DH 密钥对) +DHr (对端 DH 公钥) + +Ns (本端发送计数) +Nr (本端接收计数) +PN (上一棘轮的发送计数) + +MKSKIPPED (跳过的 message keys,处理乱序) +``` + +## 4.4 发送消息 + +```python +def encrypt_message(state, plaintext): + # 派生 message key + state.CKs, MK = KDF_CK(state.CKs) + + # 加密 + ciphertext = AES_GCM(MK, plaintext, header) + + # 构造 header + header = { + "DH": state.DHs.public, + "PN": state.PN, + "N": state.Ns + } + + state.Ns += 1 + + return header, ciphertext +``` + +## 4.5 接收消息 + +```python +def decrypt_message(state, header, ciphertext): + # 处理跳过的 keys (乱序到达) + plaintext = try_skipped_keys(state, header, ciphertext) + if plaintext: return plaintext + + # 检查是否是新 DH ratchet + if header.DH != state.DHr: + # 跳过当前 chain 中剩余的 keys + skip_message_keys(state, header.PN) + # 执行 DH ratchet + DH_ratchet(state, header) + + # 跳过到当前 N + skip_message_keys(state, header.N) + + # 派生 message key + state.CKr, MK = KDF_CK(state.CKr) + state.Nr += 1 + + # 解密 + return AES_GCM_Decrypt(MK, ciphertext, header) + + +def DH_ratchet(state, header): + state.PN = state.Ns + state.Ns = 0 + state.Nr = 0 + state.DHr = header.DH + + # 用对端新公钥更新 root key + state.RK, state.CKr = KDF_RK(state.RK, DH(state.DHs.private, state.DHr)) + + # 生成新 DH 密钥对 + state.DHs = generate_DH() + state.RK, state.CKs = KDF_RK(state.RK, DH(state.DHs.private, state.DHr)) +``` + +## 4.6 处理乱序 + +``` +A 发送: msg1 (N=0), msg2 (N=1), msg3 (N=2) +B 收到顺序: msg1, msg3, msg2 + +收到 msg3 时: + N=2, 当前期望 N=1 + 把 N=1 的 message key 算出来存到 MKSKIPPED + 解密 msg3 + +收到 msg2 时: + 从 MKSKIPPED 找到 N=1 的 key + 解密 + 删除已用 key +``` + +`MKSKIPPED` 上限(如 1000),防止 DoS。 + +--- + +# 5. 群组加密 (Sender Keys) + +## 5.1 难点 + +``` +N 人群, 每条消息要给 N 个人加密 → O(N) 次加密 + 巨大流量 + +解决: Sender Keys +``` + +## 5.2 Sender Key 模型 + +``` +每个发送者在每个群里维护一个 Sender Key Chain: + SK_chain (对称密钥) + +每条群消息: + 用 SK_chain 当前 message key 加密 + 广播给所有成员 + +新成员加入: + 发送者用 1对1 (Double Ratchet) 把当前 SK_chain 状态发给新成员 +``` + +## 5.3 流程 + +### 初始化 + +``` +A 建群 / 加入群: + 生成 Sender Key (随机) + 对每个群成员用 1对1 加密 (用各自的 Double Ratchet 会话) 发送 Sender Key + +群成员收到 Sender Key: + 存储 (groupId, senderId) → SenderKey +``` + +### 发送 + +``` +A 在群里发消息: + 用自己的 Sender Key 派生 message key + 加密消息 + 广播 (服务端只看到密文) + +A 推进自己的 Sender Key Chain +``` + +### 接收 + +``` +B 收到群消息: + 根据 senderId 找对应的 Sender Key + 派生同样的 message key + 解密 +``` + +### 成员变更 + +``` +新成员加入: + 现有成员把当前 Sender Key 状态发给新成员 + (新成员只能看到加入之后的消息) + +成员退出: + 剩余成员重新生成各自的 Sender Key + 分发给剩余成员 + (退出成员的 Sender Key 失效) +``` + +## 5.4 Sender Key 轮换 + +``` +触发: + - 成员变化 + - 一定时间 (如 7 天) + - 一定消息数 (如 1000 条) +``` + +## 5.5 大群挑战 + +``` +万人群每次成员变化要 1对1 给 9999 人发新 SK → 不可行 + +折中: + - 大群放弃严格 E2EE + - 用群密钥 (服务端中转加密) + - 或 MLS 协议(IETF 标准化中,支持大群) +``` + +--- + +# 6. 服务端职责 + +## 6.1 服务端能做什么 + +``` +✅ 存储 PreKey Bundle +✅ 中转密文消息 +✅ 元数据(who-when-to-whom) +✅ 验证身份 +✅ 路由 +``` + +## 6.2 服务端不能做什么 + +``` +❌ 解密消息内容 +❌ 修改消息内容 +❌ 知道密钥 +``` + +## 6.3 元数据保护 + +``` +即使内容加密,元数据也很敏感: + - 谁和谁聊 + - 何时聊 + - 频率 + +进阶保护: + - Sealed Sender (Signal): 隐藏发送者 + - Mixnets / Onion Routing + - 需要权衡性能 +``` + +--- + +# 7. 多设备同步 + +## 7.1 难点 + +``` +用户在手机和 PC 都登录 +B 给用户发消息 → 两个设备都要能解密 + +但每个设备有独立的密钥对 +``` + +## 7.2 方案 A:每设备独立会话 + +``` +每个设备用自己的 Identity Key 注册 +A 给"用户 B"发消息 = 给 B 的所有设备分别加密 +N 个设备 = N 次加密 +``` + +Signal 用此方案。 + +## 7.3 方案 B:主设备 + 链接设备 + +``` +主设备生成主密钥 +副设备扫码后,主设备把密钥传给副设备 +所有设备共享同一密钥 +``` + +WhatsApp 早期方案。 + +## 7.4 历史消息同步 + +``` +新设备登录 → 历史消息无法解密(密钥不同) + +解决: + 方案 1: 不同步历史 (Signal) + 方案 2: 主设备解密后用其他方式 (备份密钥) 同步 +``` + +--- + +# 8. 实现注意事项 + +## 8.1 库选择 + +``` +推荐: + libsignal-protocol (官方, C/Java/Swift) + libolm (Matrix) + +不要自己实现密码学 +``` + +## 8.2 密钥存储 + +``` +设备密钥: + iOS: Keychain + Android: KeyStore + Web: 加密 IndexedDB (用密码派生) + +绝不上传服务端 +``` + +## 8.3 验证身份(防 MITM) + +``` +密钥协商不防中间人 (服务端可换 IK) +需要带外验证: + - 安全码 (Safety Number) + - 二维码扫描 + - 比对指纹 +``` + +## 8.4 备份与恢复 + +``` +难点: E2EE 与备份矛盾 +方案: + - iCloud Keychain (Apple) + - 用户密码加密的云备份 (Signal) + - PIN 码恢复 +``` + +## 8.5 性能 + +``` +加密性能 ≈ 普通 AES +影响: + - 消息体增加 (header + 签名) + - 离线同步慢一点 + - 群组大时密钥分发开销 +``` + +## 8.6 服务端改动 + +``` +- 增加 PreKey Bundle 接口 +- 不能服务端搜索消息内容 +- 不能服务端做内容审核(改为客户端举报) +- Push 内容只能是 "新消息" +``` + +## 8.7 不兼容场景 + +``` +- 服务端搜索 (改为客户端搜索) +- 内容审核 (依赖举报) +- 监管要求 (E2EE 与监管冲突) +- 多设备历史 (受限) +``` + +--- + +# 附录:MLS(下一代群组 E2EE) + +## A.1 简介 + +``` +MLS = Messaging Layer Security +IETF 标准 (RFC 9420) +设计目标: 大群 E2EE +``` + +## A.2 优势 + +``` +- O(log N) 成员变更 +- 支持大群 +- 形式化验证 +- 异步运行 +``` + +## A.3 状态 + +``` +- WhatsApp 已迁移 +- 其他 IM 也在跟进 +- 取代 Sender Keys 是趋势 +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/12-Multi-Device-Sync-v1.0_Version3.md b/_drafts/IM/12-Multi-Device-Sync-v1.0_Version3.md new file mode 100755 index 000000000..5a5739ddc --- /dev/null +++ b/_drafts/IM/12-Multi-Device-Sync-v1.0_Version3.md @@ -0,0 +1,790 @@ +# 多设备消息同步详细方案 v1.0 + +> 适用:iOS / Android / PC / Web 多端同步 +> 目标:跨端一致、断点续传、不重不漏 + +--- + +## 目录 + +1. 设计目标 +2. 同步模型 +3. 增量同步协议 +4. 全量同步与首次登录 +5. 推拉协同 +6. 冲突解决 +7. 断点续传 +8. 多端状态同步(已读/草稿) +9. 性能优化 +10. 异常场景 + +--- + +# 1. 设计目标 + +| 指标 | 目标 | +|---|---| +| 跨端最终一致性 | 100% | +| 同步延迟(在线)| < 1s | +| 同步延迟(上线)| < 5s | +| 不重复消息 | 100% | +| 不丢消息 | 100% | +| 弱网可恢复 | ✅ | +| 历史漫游 | 按需 | + +--- + +# 2. 同步模型 + +## 2.1 设备视角 + +每个设备维护自己的"已知世界": + +``` +device_state { + per_conv_max_seq: Map // 已经收到的最大 seq + per_conv_read_seq: Map + global_sync_version: Long // 跨会话同步水位 +} +``` + +## 2.2 服务端视角 + +服务端是事实源: + +``` +ServerState { + conv_max_seq: Map + user_active_convs: Map> + user_cursor: Map<(UserId, ConvId), Cursor> + inbox: (per_user inbox, 仅写扩散场景) +} +``` + +## 2.3 同步对象分层 + +``` +L1: 会话列表 (用户加入哪些会话) +L2: 会话元数据 (名字、最后消息、未读数) +L3: 消息内容 (按需拉取) +L4: 用户状态 (已读、草稿、置顶) +``` + +--- + +# 3. 增量同步协议 + +## 3.1 协议设计原则 + +``` +1. 只传变化(diff),不传全量 +2. 客户端带版本号,服务端返回比该版本新的数据 +3. 同步是幂等的(重复请求返回相同结果) +4. 支持分页 + 断点续传 +``` + +## 3.2 同步接口 + +### 接口 1:会话变更同步 + +```http +POST /sync/conversations +Body: +{ + "since_version": 12345 +} + +Response: +{ + "version": 12567, # 新的版本号 + "has_more": false, + "changed_conversations": [ + { + "conv_id": 123, + "max_seq": 1050, # 服务端最新 seq + "max_seq_time": 1710000000, + "last_msg_preview": "...", # 客户端展示用 + "operation": "MODIFIED" # ADDED / MODIFIED / DELETED + } + ] +} +``` + +### 接口 2:消息增量同步 + +```http +POST /sync/messages +Body: +{ + "conv_id": 123, + "since_seq": 1000, + "limit": 200 +} + +Response: +{ + "messages": [...], + "has_more": true, + "next_seq": 1200 +} +``` + +### 接口 3:全量游标同步 + +```http +POST /sync/cursors +Body: {} + +Response: +{ + "cursors": [ + { + "conv_id": 123, + "read_visible_seq": 1040, + "read_mention_seq": 1020, + "joined_at_seq": 100 + }, + ... + ] +} +``` + +## 3.3 版本号设计 + +``` +全局版本号: int64, 单调递增 + 每次会话元数据变更 +1 + +分会话版本号: + 内嵌在 max_seq 中,自然递增 +``` + +实现: + +```sql +CREATE TABLE conv_change_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, -- 这就是 version + user_id BIGINT, + conv_id BIGINT, + change_type VARCHAR(32), + payload JSON, + created_at BIGINT, + + KEY idx_user_id (user_id, id) +) PARTITION BY HASH(user_id) PARTITIONS 64; +``` + +同步: + +```sql +SELECT * FROM conv_change_log +WHERE user_id = ? AND id > ? +ORDER BY id LIMIT 500; +``` + +## 3.4 增量同步流程 + +```mermaid +sequenceDiagram + participant C as 客户端 + participant S as 服务端 + participant DB as 变更日志 + + C->>S: sync/conversations
since_version=12345 + S->>DB: SELECT WHERE id > 12345 LIMIT 500 + DB-->>S: 变更列表 + S-->>C: changes + new_version + + C->>C: 应用变更
更新本地版本 + + Note over C: 对每个 changed conv
检查是否需要拉消息 + + loop 对每个有新消息的会话 + C->>S: sync/messages
conv=X, since=local_max_seq + S-->>C: messages + C->>C: 落库 + 触发 UI 更新 + end +``` + +--- + +# 4. 全量同步与首次登录 + +## 4.1 首次登录策略 + +``` +新设备登录: + 1. 拉用户最近 30 天活跃会话列表(限 500 个) + 2. 每个会话拉最近 50 条消息 + 3. 拉所有会话的游标 + 4. 显示给用户 + +后续: + 按需拉历史 +``` + +## 4.2 接口 + +```http +POST /sync/initial +Body: +{ + "limit_convs": 500, + "limit_msg_per_conv": 50 +} + +Response: +{ + "version": 12345, + "conversations": [ + { + "conv_id": 123, + "meta": {...}, + "cursor": {...}, + "recent_messages": [...] # 最近 50 条 + } + ] +} +``` + +## 4.3 大用户(万级会话) + +``` +某些用户加了几千个群: + +策略: + - 只返回最近 30 天有消息的活跃会话 + - 不活跃会话按需加载(用户滚动到末尾时) + - 异步后台同步剩余会话 +``` + +## 4.4 历史漫游 + +```http +POST /sync/history +Body: +{ + "conv_id": 123, + "before_seq": 950, + "limit": 30 +} + +Response: +{ + "messages": [...], + "has_more": true +} +``` + +客户端按需触发,向上滚动加载历史。 + +--- + +# 5. 推拉协同 + +## 5.1 推:实时通知 + +服务端通过长连接推送轻量通知: + +```protobuf +message SyncNotify { + int64 latest_version = 1; // 服务端最新版本 + repeated ConvNotify convs = 2; + + message ConvNotify { + int64 conv_id = 1; + int64 latest_seq = 2; + int64 latest_time = 3; + } +} +``` + +客户端收到 → 决定是否拉。 + +## 5.2 拉:按需精确 + +收到推送后客户端: + +```python +def on_sync_notify(notify): + # 1. 检查是否需要拉 + if notify.latest_version <= local_version: + return # 已经是最新 + + # 2. 拉变更 + sync_conversations(since=local_version) + + # 3. 对有新消息的会话拉消息 + for conv in changed: + if conv.latest_seq > local_max_seq[conv.id]: + sync_messages(conv.id, since=local_max_seq[conv.id]) +``` + +## 5.3 推送优化 + +``` +单条消息推送: + msg 直接送到客户端(含正文) + +批量通知: + 多条变更聚合一次推送 + +仅版本号通知: + 服务端只推 latest_version + 客户端按需拉 +``` + +混合策略: + +``` +- 实时消息: 直接推送正文(在线时) +- 离线后上线: 推送版本号 → 客户端拉 +- 大量消息: 推送版本号 → 客户端批量拉 +``` + +## 5.4 消息直推(在线热路径) + +``` +用户在线: + 服务端 Deliver → Gateway → 直接推消息正文 + +客户端: + 收到消息 → 落库 → 更新 max_seq + 无需主动拉取 +``` + +但仍要定期对账(防漏推): + +``` +每 5 分钟 / 后台返回前台: + 调 sync/conversations 校验 + 本地 max_seq < 服务端 max_seq → 触发拉取 +``` + +--- + +# 6. 冲突解决 + +## 6.1 冲突类型 + +``` +1. 同一消息多端收到(重复) +2. 自己发的消息又被同步回来 +3. 已读 seq 倒退 +4. 设置冲突(多端同时修改群免打扰) +``` + +## 6.2 重复消息 + +按 `server_msg_id` 全局去重: + +```sql +CREATE UNIQUE INDEX uk_server_msg ON local_message(server_msg_id); +INSERT OR IGNORE ... +``` + +或按 `(conv_id, visible_seq)` 唯一约束。 + +## 6.3 自己发消息合并 + +A 在手机发消息 → PC 端通过同步收到: + +```python +def merge_outgoing(msg): + # 按 client_msg_id 找本地 + local = db.find("client_msg_id = ?", msg.client_msg_id) + + if local: + # 本地已有,补充服务端字段 + db.update(local, { + "server_msg_id": msg.server_msg_id, + "visible_seq": msg.visible_seq, + "status": "SUCCESS" + }) + else: + # 其��端发的,新插 + db.insert(msg) +``` + +## 6.4 已读 seq 单调 + +```python +def update_read_seq(conv, new_seq): + local = db.get_cursor(conv) + if new_seq > local.read_visible_seq: + db.update_cursor(conv, read_visible_seq=new_seq) + # 否则忽略(旧请求迟到) +``` + +## 6.5 设置冲突(LWW) + +群免打扰、置顶等用户设置: + +``` +策略: Last-Write-Wins +比较时间戳 + version 号 +``` + +```python +def update_setting(conv, setting, version): + local = db.get_setting(conv) + if version > local.version: + db.update_setting(conv, setting, version) +``` + +更复杂的设置(如群成员角色)走业务层强一致。 + +--- + +# 7. 断点续传 + +## 7.1 同步状态持久化 + +```sql +CREATE TABLE local_sync_state ( + id INTEGER PRIMARY KEY, + global_version INTEGER NOT NULL, + last_sync_at INTEGER +); + +CREATE TABLE local_sync_progress ( + conv_id INTEGER PRIMARY KEY, + max_seq INTEGER NOT NULL, + syncing INTEGER DEFAULT 0, -- 当前是否在同步 + last_attempt_at INTEGER +); +``` + +每次成功拉取后立即持久化。 + +## 7.2 中断恢复 + +```python +def resume_sync(): + # 1. 检查是否有未完成的会话同步 + pending = db.query("SELECT * FROM local_sync_progress WHERE syncing = 1") + + for conv in pending: + # 从上次的 max_seq 继续 + sync_messages(conv.conv_id, since=conv.max_seq) + + # 2. 全局增量 + state = db.get_sync_state() + sync_conversations(since=state.global_version) +``` + +## 7.3 分批拉取的容错 + +```python +def sync_messages(conv_id, since): + while True: + try: + resp = api.sync_messages(conv_id, since, limit=200) + except NetworkError: + # 等连接恢复后继续,不用重置 since + wait_for_network() + continue + + # 落库 + db.batch_insert(resp.messages) + + # 更新进度 + if resp.messages: + since = resp.messages[-1].visible_seq + db.update_progress(conv_id, max_seq=since) + + if not resp.has_more: + break + + db.mark_done(conv_id) +``` + +## 7.4 大量历史的渐进式同步 + +``` +应用启动: + Phase 1 (前台): 拉 500 个活跃会话的元数据 (5s 内完成) + Phase 2 (前台): 显示 UI + Phase 3 (后台): 慢慢拉每个会话最近消息 + Phase 4 (闲时): 拉历史 (按需) + +避免阻塞 UI +``` + +--- + +# 8. 多端状态同步 + +## 8.1 已读同步 + +```mermaid +sequenceDiagram + participant M as 📱 手机 + participant S as 服务端 + participant K as Kafka
read.event + participant P as 💻 PC + + M->>S: report_read(conv, seq=1050) + S->>S: GREATEST 更新 cursor + S->>K: produce read.event + + K->>P: 实时推送 read 事件 + P->>P: 更新本地 cursor
未读数变化 +``` + +## 8.2 草稿同步(可选) + +``` +用户在 A 端写了一半,切到 B 端继续 + +实现: + - 客户端定期上报 draft(节流) + - 服务端存 user_draft 表 + - 推送给其他端 + +注意: + - 草稿是用户隐私 → 不要服务端日志 + - 不要太频繁同步(输入时不同步,停顿后同步) +``` + +## 8.3 输入状态(typing) + +``` +仅在线时同步,不持久化 +通过专用 topic 短暂广播 +``` + +## 8.4 置顶 / 免打扰同步 + +```sql +CREATE TABLE user_conv_settings ( + user_id BIGINT, + conv_id BIGINT, + is_pinned TINYINT, + is_muted TINYINT, + mute_until BIGINT, + version INT, -- 用于多端冲突解决 + updated_at BIGINT, + PRIMARY KEY (user_id, conv_id) +); +``` + +设置变更走 `read.event` 类似的事件流推到所有端。 + +## 8.5 会话级状态变更日志 + +```sql +CREATE TABLE user_state_change_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT, + type VARCHAR(32), -- read / pin / mute / clear + conv_id BIGINT, + payload JSON, + created_at BIGINT, + + KEY idx_user_id (user_id, id) +); +``` + +客户端拉取: + +```sql +SELECT * FROM user_state_change_log +WHERE user_id = ? AND id > ? +LIMIT 500; +``` + +--- + +# 9. 性能优化 + +## 9.1 客户端 + +``` +- 使用本地数据库(SQLite)批量写入 +- 同步任务后台运行,不阻塞 UI +- 网络空闲时拉历史 +- 预拉常用会话 +- 内存中缓存最近会话 +``` + +## 9.2 服务端 + +``` +- 变更日志按 user_id 分片 +- 长连接推 + 短链拉 +- 拉取走只读副本 +- 大用户特殊路径(Hot Cache) +``` + +## 9.3 减少同步开销 + +``` +- 增量同步代替全量 +- 只同步活跃会话 +- 不活跃会话懒加载 +- 推送只带版本号,按需拉 +``` + +## 9.4 网络优化 + +``` +- HTTP/2 / QUIC 多路复用 +- 同步请求合并(一次拉多个会话) +- 压缩(gzip/zstd) +``` + +## 9.5 批量拉取 + +```http +POST /sync/messages/batch +Body: +{ + "requests": [ + { "conv_id": 1, "since_seq": 100, "limit": 30 }, + { "conv_id": 2, "since_seq": 200, "limit": 30 }, + ... + ] +} +``` + +一次 RPC 拉 N 个会话的消息,减少 RTT。 + +--- + +# 10. 异常场景 + +## 10.1 长时间未上线 + +``` +用户 30 天没登录: + 本地 version 远落后 + +策略: + 全量重建(重新走首次登录流程) + 或限制拉取范围(最近 30 天) +``` + +## 10.2 设备重置 + +``` +本地数据全丢: + global_version = 0 + 全量同步 + +注意: + 服务端 conv_change_log 可能有 GC(保留 90 天) + 超出保留期 → 必须全量 +``` + +## 10.3 时钟错乱 + +``` +设备时钟错误 → 影响? + - 不影响 seq 比较(seq 是服务端分配) + - 影响 timestamp 显示(客户端可参考服务端时间) + - 影响草稿过期(次要) +``` + +## 10.4 拉取过快导致服务端压力 + +``` +单用户疯狂拉历史: + 限频: 5 次/秒 + 返回 429 + Retry-After + 客户端退避 +``` + +## 10.5 服务端 conv_change_log 缺失 + +``` +客户端 since_version=100,但服务端最早是 200: + 返回错误 INVALID_VERSION + 客户端走全量同步 +``` + +--- + +# 附录:完整客户端同步流程 + +```kotlin +class SyncManager { + suspend fun start() { + // 1. 应用启动后立即调用 + resumePending() + + // 2. 增量同步 + incrementalSync() + + // 3. 监听实时通知 + onSyncNotify { notify -> + handleNotify(notify) + } + + // 4. 周期性对账 + launch { + while (isActive) { + delay(5.minutes) + reconcile() + } + } + } + + suspend fun incrementalSync() { + val state = db.getSyncState() + + while (true) { + val resp = api.syncConversations( + sinceVersion = state.globalVersion + ) + + // 应用变更 + for (change in resp.changes) { + applyChange(change) + } + + // 更新版本 + state.globalVersion = resp.newVersion + db.saveSyncState(state) + + if (!resp.hasMore) break + } + + // 对每个 changed conv 拉消息 + val convsToSync = state.dirtyConvs + for (chunk in convsToSync.chunked(5)) { + chunk.map { async { syncMessages(it) } }.awaitAll() + } + } + + suspend fun syncMessages(convId: Long) { + var since = db.getMaxSeq(convId) + while (true) { + val resp = api.syncMessages(convId, since, limit = 200) + db.batchInsert(resp.messages) + + if (resp.messages.isNotEmpty()) { + since = resp.messages.last().visibleSeq + db.updateProgress(convId, since) + } + + if (!resp.hasMore) break + } + } + + suspend fun reconcile() { + // 对账:服务端 max vs 本地 max + val resp = api.getConvSummary() + for (s in resp.summaries) { + val localMax = db.getMaxSeq(s.convId) + if (localMax < s.serverMaxSeq) { + syncMessages(s.convId) + } + } + } +} +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/12-MultiDevice-Sync-v1.0_Version0.md b/_drafts/IM/12-MultiDevice-Sync-v1.0_Version0.md new file mode 100755 index 000000000..fd80b9dad --- /dev/null +++ b/_drafts/IM/12-MultiDevice-Sync-v1.0_Version0.md @@ -0,0 +1,534 @@ +# 多设备消息同步详细方案 v1.0 + +> 适用:用户多端登录(手机/PC/Web)的消息同步 +> 目标:跨端一致、增量高效、断点续传、不丢不重 + +--- + +## 目录 + +1. 设计目标 +2. 同步模型 +3. 增量同步协议 +4. 冲突解决 +5. 断点续传 +6. 多端状态同步 +7. 历史消息漫游 +8. 性能优化 + +--- + +# 1. 设计目标 + +## 1.1 用户期望 + +``` +1. 任何设备发的消息,所有设备都能看到 +2. 任何设备的已读,其他设备同步 +3. 切换设备无感 +4. 离线一段时间上线后能自动补齐 +5. 不会重复或丢失 +``` + +## 1.2 技术目标 + +``` +- 同步延迟 P99 < 1s +- 支持 100 万会话/用户 +- 离线 30 天可恢复 +- 弱网友好 +``` + +--- + +# 2. 同步模型 + +## 2.1 三大同步对象 + +| 对象 | 说明 | 同步方式 | +|---|---|---| +| 消息 | 收到/发出的消息 | 增量 seq | +| 已读游标 | read_seq | 推 + 拉 | +| 会话状态 | 置顶/免打扰/草稿 | 版本号 | + +## 2.2 服务端权威 + +``` +原则: 服务端是 source of truth +客户端: + - 本地缓存 + - 本地修改先尝试,后向服务端校对 + - 冲突时服务端为准 +``` + +## 2.3 同步触发时机 + +``` +1. 应用启动 +2. 长连接建立 +3. 收到 SYNC_NOTIFY (服务端推送) +4. 后台 → 前台 +5. 网络从断到通 +6. 用户主动下拉刷新 +``` + +--- + +# 3. 增量同步协议 + +## 3.1 同步标识 + +每个用户维护一个全局**同步 token**: + +``` +sync_token = (server_version, last_sync_time) + +server_version: 单调递增的版本号 + 每次会话变更/消息变更 +1 +``` + +## 3.2 同步流程 + +``` +Client Server + │ │ + │ ─── sync(token) ─────────→│ + │ │ + │ ←── changes + new_token ──│ + │ │ + │ apply changes │ + │ save new_token │ +``` + +## 3.3 同步接口设计 + +### 请求 + +```protobuf +message SyncRequest { + int64 user_id = 1; + string device_id = 2; + + // 同步 token (上次返回的) + string sync_token = 3; + + // 限制 + int32 max_conv = 4; // 最多返回多少会话 + int32 max_msg_per_conv = 5; // 单会话最多消息 +} +``` + +### 响应 + +```protobuf +message SyncResponse { + string new_sync_token = 1; + + // 变更的会话列表(含元数据) + repeated ConvChange conv_changes = 2; + + // 新消息(按会话归并) + repeated ConvMessages messages = 3; + + // 新游标 + repeated CursorUpdate cursors = 4; + + // 还有更多变更 + bool has_more = 5; +} + +message ConvChange { + int64 conv_id = 1; + int32 change_type = 2; // 1:added 2:updated 3:removed + ConvMeta meta = 3; + int64 max_visible_seq = 4; +} +``` + +## 3.4 服务端实现 + +```python +def sync(user_id, sync_token): + parsed = parse_token(sync_token) + last_version = parsed.server_version + + # 1. 查变更的会话 + changed_convs = query_changed_convs(user_id, last_version) + + # 2. 查每个会话的新消息(按 read_seq ~ max_seq) + messages = [] + for conv in changed_convs: + cursor = get_cursor(user_id, conv.conv_id) + msgs = query_messages_for_user( + conv_id=conv.conv_id, + user_id=user_id, + from_seq=cursor.last_synced_seq + 1, + to_seq=conv.max_visible_seq, + limit=50 + ) + messages.append(ConvMessages(conv.conv_id, msgs)) + + # 3. 查游标变更 + cursors = query_cursor_changes(user_id, last_version) + + # 4. 构造新 token + new_version = current_max_version_for_user(user_id) + new_token = build_token(new_version) + + return SyncResponse( + new_sync_token=new_token, + conv_changes=changed_convs, + messages=messages, + cursors=cursors, + has_more=(len(messages) >= MAX_BATCH) + ) +``` + +## 3.5 客户端实现 + +```kotlin +class SyncManager { + suspend fun fullSync() { + val token = prefs.getString("sync_token", "") + + var hasMore = true + while (hasMore) { + val resp = api.sync(SyncRequest( + userId = currentUser.id, + deviceId = deviceId, + syncToken = token, + maxConv = 100, + maxMsgPerConv = 50 + )) + + applySync(resp) + + prefs.put("sync_token", resp.newSyncToken) + hasMore = resp.hasMore + } + } + + private fun applySync(resp: SyncResponse) { + db.transaction { + // 应用会话变更 + for (change in resp.convChanges) { + when (change.changeType) { + ADDED -> insertConv(change.meta) + UPDATED -> updateConv(change.meta) + REMOVED -> deleteConv(change.convId) + } + } + + // 应用消息 + for (convMsgs in resp.messages) { + for (msg in convMsgs.msgs) { + upsertMessage(msg) // 按 server_msg_id 幂等 + } + } + + // 应用游标 + for (cursor in resp.cursors) { + upsertCursor(cursor) // 取 GREATEST + } + } + + // 通知 UI + notifyChanges() + } +} +``` + +## 3.6 单会话增量 + +主同步只拉**变更的会话列表**,单个会话进入时再拉详细消息: + +``` +GET /messages?conv_id=C&since_seq=1000&limit=30 +``` + +避免一次拉太多。 + +--- + +# 4. 冲突解决 + +## 4.1 可能的冲突 + +| 场景 | 冲突 | 解决 | +|---|---|---| +| A 端读到 seq 100, B 端读到 seq 50 | read_seq 不同 | GREATEST | +| A 端置顶, B 端取消置顶 | 设置冲突 | LWW (last write wins) | +| A 端编辑消息, B 端撤回 | 操作冲突 | 服务端按时间排序 | +| A 端发消息但本地未同步 | 本地有 server 没 | 重发 (clientMsgId 幂等) | + +## 4.2 单调推进 + +``` +read_seq: GREATEST (取大) +max_seq: GREATEST (取大) +sync_token: 按 version 比较,取大 +``` + +## 4.3 LWW(最后写入获胜) + +``` +会话设置 (置顶/免打扰): + 每次更新带 timestamp + 服务端: WHERE updated_at > current_updated_at + 避免旧请求覆盖新请求 +``` + +## 4.4 操作冲突 + +``` +A 端撤回, B 端编辑同一条消息: + 服务端按到达顺序处理 + 后到达的可能失败 (前置条件不满足) + 例: 已撤回的不能编辑 +``` + +## 4.5 客户端时钟问题 + +``` +不依赖客户端时间 +所有 timestamp 用服务端 +客户端时间仅作显示参考 +``` + +--- + +# 5. 断点续传 + +## 5.1 离线场景 + +``` +用户离线 30 天 → 重新上线 +本地 sync_token 很老 +服务端要返回 30 天变更? +``` + +## 5.2 分页同步 + +``` +首次同步: + 返回最多 100 个变更会话 + has_more=true + +循环拉取: + 直到 has_more=false + +每页用新 token,失败可重试 +``` + +## 5.3 完整重置 + +``` +sync_token 失效(如太久未同步): + 服务端返回 INVALID_TOKEN + 客户端清空本地,全量同步 +``` + +```python +def sync(user_id, sync_token): + if not is_valid_token(sync_token): + raise InvalidToken + + if token_age > 90 days: + raise TokenTooOld # 客户端走全量 +``` + +## 5.4 全量同步 + +``` +客户端无 token / token 失效: + 1. 拉会话列表 (最近 N 个) + 2. 每个会话拉最近 M 条消息 + 3. 拉所有游标 + 4. 设置新 token +``` + +## 5.5 进度持久化 + +``` +每页同步成功 → 立即保存 token +中断后下次启动从最新 token 继续 +``` + +--- + +# 6. 多端状态同步 + +## 6.1 已读同步 + +``` +A 手机读到 seq=1050: + POST /report_read {conv: C, seq: 1050} + +服务端: + GREATEST 更新 cursor + Kafka: read.event + +B PC 收到: + 实时通道推送 read sync + 本地更新 cursor +``` + +详见前文未读计数设计。 + +## 6.2 设备登录通知 + +``` +A 手机登录: + 服务端记录设备 + 推送给其他端: "新设备登录" + +其他端 UI 显示 +``` + +## 6.3 草稿同步(可选) + +``` +A 手机输入草稿: + 本地保存 + 节流 5s 上报服务端 + +B PC 拉到草稿,显示"在 A 端正在输入..." +``` + +非关键功能,按需。 + +## 6.4 设备列表 + +``` +GET /devices +返回: + 当前所有登录设备 + 各自的最后活跃时间 + 当前设备标记 + +用户可: 远程登出某设备 +``` + +--- + +# 7. 历史消息漫游 + +## 7.1 需求 + +``` +新设备登录 → 看到历史消息 (云端拉) +切换设备 → 历史一致 +本地清空缓存 → 可重新拉 +``` + +## 7.2 漫游协议 + +``` +GET /history?conv=C&before_seq=1000&limit=30 + +返回: + [seq=970~999] (倒序) + has_more: true/false +``` + +## 7.3 漫游 vs 同步 + +``` +同步 (sync): 拉新增/变更 +漫游 (roam): 拉历史 + +漫游不影响 sync_token +漫游消息按 seq 范围查 +``` + +## 7.4 漫游限制 + +``` +- 默认保留 30 天 / 1 年(业务定) +- 单次 limit ≤ 200 +- 频率限制 +``` + +## 7.5 实现 + +```python +def get_history(user_id, conv_id, before_seq, limit): + # 权限校验 + if not is_member(user_id, conv_id): + raise NoPermission + + # 用户加群前的消息不可见 + cursor = get_cursor(user_id, conv_id) + + msgs = db.query(""" + SELECT * FROM im_message + WHERE conv_id = ? + AND visible_seq < ? + AND visible_seq >= ? + AND status = 0 + ORDER BY visible_seq DESC + LIMIT ? + """, conv_id, before_seq, cursor.joined_at_seq, limit) + + return msgs +``` + +--- + +# 8. 性能优化 + +## 8.1 同步频率 + +``` +长连接在线: + 服务端有变更主动推 SYNC_NOTIFY + 客户端响应式拉 + +无长连接: + 定时拉 (每 30s) + 应用启动拉 +``` + +## 8.2 推送压缩 + +``` +SYNC_NOTIFY 只通知"有变更" +不带详细内容 +客户端按需拉 +``` + +## 8.3 批量优化 + +``` +sync 接口返回: + - 多会话合并 + - 单会话多消息合并 + - gzip 压缩 +``` + +## 8.4 服务端缓存 + +``` +近期变更走 Redis (latest_changes:{userId}) +冷数据走 DB +``` + +## 8.5 客户端去重 + +``` +按 server_msg_id 唯一性插入 (UNIQUE KEY) +重复推送/同步不会插入两次 +``` + +## 8.6 弱网处理 + +``` +- 减小 batch +- 增加超时 +- 失败重试 +- 部分成功保留 token +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/12-MultiDevice-Sync-v1.0_Version4.md b/_drafts/IM/12-MultiDevice-Sync-v1.0_Version4.md new file mode 100755 index 000000000..fd80b9dad --- /dev/null +++ b/_drafts/IM/12-MultiDevice-Sync-v1.0_Version4.md @@ -0,0 +1,534 @@ +# 多设备消息同步详细方案 v1.0 + +> 适用:用户多端登录(手机/PC/Web)的消息同步 +> 目标:跨端一致、增量高效、断点续传、不丢不重 + +--- + +## 目录 + +1. 设计目标 +2. 同步模型 +3. 增量同步协议 +4. 冲突解决 +5. 断点续传 +6. 多端状态同步 +7. 历史消息漫游 +8. 性能优化 + +--- + +# 1. 设计目标 + +## 1.1 用户期望 + +``` +1. 任何设备发的消息,所有设备都能看到 +2. 任何设备的已读,其他设备同步 +3. 切换设备无感 +4. 离线一段时间上线后能自动补齐 +5. 不会重复或丢失 +``` + +## 1.2 技术目标 + +``` +- 同步延迟 P99 < 1s +- 支持 100 万会话/用户 +- 离线 30 天可恢复 +- 弱网友好 +``` + +--- + +# 2. 同步模型 + +## 2.1 三大同步对象 + +| 对象 | 说明 | 同步方式 | +|---|---|---| +| 消息 | 收到/发出的消息 | 增量 seq | +| 已读游标 | read_seq | 推 + 拉 | +| 会话状态 | 置顶/免打扰/草稿 | 版本号 | + +## 2.2 服务端权威 + +``` +原则: 服务端是 source of truth +客户端: + - 本地缓存 + - 本地修改先尝试,后向服务端校对 + - 冲突时服务端为准 +``` + +## 2.3 同步触发时机 + +``` +1. 应用启动 +2. 长连接建立 +3. 收到 SYNC_NOTIFY (服务端推送) +4. 后台 → 前台 +5. 网络从断到通 +6. 用户主动下拉刷新 +``` + +--- + +# 3. 增量同步协议 + +## 3.1 同步标识 + +每个用户维护一个全局**同步 token**: + +``` +sync_token = (server_version, last_sync_time) + +server_version: 单调递增的版本号 + 每次会话变更/消息变更 +1 +``` + +## 3.2 同步流程 + +``` +Client Server + │ │ + │ ─── sync(token) ─────────→│ + │ │ + │ ←── changes + new_token ──│ + │ │ + │ apply changes │ + │ save new_token │ +``` + +## 3.3 同步接口设计 + +### 请求 + +```protobuf +message SyncRequest { + int64 user_id = 1; + string device_id = 2; + + // 同步 token (上次返回的) + string sync_token = 3; + + // 限制 + int32 max_conv = 4; // 最多返回多少会话 + int32 max_msg_per_conv = 5; // 单会话最多消息 +} +``` + +### 响应 + +```protobuf +message SyncResponse { + string new_sync_token = 1; + + // 变更的会话列表(含元数据) + repeated ConvChange conv_changes = 2; + + // 新消息(按会话归并) + repeated ConvMessages messages = 3; + + // 新游标 + repeated CursorUpdate cursors = 4; + + // 还有更多变更 + bool has_more = 5; +} + +message ConvChange { + int64 conv_id = 1; + int32 change_type = 2; // 1:added 2:updated 3:removed + ConvMeta meta = 3; + int64 max_visible_seq = 4; +} +``` + +## 3.4 服务端实现 + +```python +def sync(user_id, sync_token): + parsed = parse_token(sync_token) + last_version = parsed.server_version + + # 1. 查变更的会话 + changed_convs = query_changed_convs(user_id, last_version) + + # 2. 查每个会话的新消息(按 read_seq ~ max_seq) + messages = [] + for conv in changed_convs: + cursor = get_cursor(user_id, conv.conv_id) + msgs = query_messages_for_user( + conv_id=conv.conv_id, + user_id=user_id, + from_seq=cursor.last_synced_seq + 1, + to_seq=conv.max_visible_seq, + limit=50 + ) + messages.append(ConvMessages(conv.conv_id, msgs)) + + # 3. 查游标变更 + cursors = query_cursor_changes(user_id, last_version) + + # 4. 构造新 token + new_version = current_max_version_for_user(user_id) + new_token = build_token(new_version) + + return SyncResponse( + new_sync_token=new_token, + conv_changes=changed_convs, + messages=messages, + cursors=cursors, + has_more=(len(messages) >= MAX_BATCH) + ) +``` + +## 3.5 客户端实现 + +```kotlin +class SyncManager { + suspend fun fullSync() { + val token = prefs.getString("sync_token", "") + + var hasMore = true + while (hasMore) { + val resp = api.sync(SyncRequest( + userId = currentUser.id, + deviceId = deviceId, + syncToken = token, + maxConv = 100, + maxMsgPerConv = 50 + )) + + applySync(resp) + + prefs.put("sync_token", resp.newSyncToken) + hasMore = resp.hasMore + } + } + + private fun applySync(resp: SyncResponse) { + db.transaction { + // 应用会话变更 + for (change in resp.convChanges) { + when (change.changeType) { + ADDED -> insertConv(change.meta) + UPDATED -> updateConv(change.meta) + REMOVED -> deleteConv(change.convId) + } + } + + // 应用消息 + for (convMsgs in resp.messages) { + for (msg in convMsgs.msgs) { + upsertMessage(msg) // 按 server_msg_id 幂等 + } + } + + // 应用游标 + for (cursor in resp.cursors) { + upsertCursor(cursor) // 取 GREATEST + } + } + + // 通知 UI + notifyChanges() + } +} +``` + +## 3.6 单会话增量 + +主同步只拉**变更的会话列表**,单个会话进入时再拉详细消息: + +``` +GET /messages?conv_id=C&since_seq=1000&limit=30 +``` + +避免一次拉太多。 + +--- + +# 4. 冲突解决 + +## 4.1 可能的冲突 + +| 场景 | 冲突 | 解决 | +|---|---|---| +| A 端读到 seq 100, B 端读到 seq 50 | read_seq 不同 | GREATEST | +| A 端置顶, B 端取消置顶 | 设置冲突 | LWW (last write wins) | +| A 端编辑消息, B 端撤回 | 操作冲突 | 服务端按时间排序 | +| A 端发消息但本地未同步 | 本地有 server 没 | 重发 (clientMsgId 幂等) | + +## 4.2 单调推进 + +``` +read_seq: GREATEST (取大) +max_seq: GREATEST (取大) +sync_token: 按 version 比较,取大 +``` + +## 4.3 LWW(最后写入获胜) + +``` +会话设置 (置顶/免打扰): + 每次更新带 timestamp + 服务端: WHERE updated_at > current_updated_at + 避免旧请求覆盖新请求 +``` + +## 4.4 操作冲突 + +``` +A 端撤回, B 端编辑同一条消息: + 服务端按到达顺序处理 + 后到达的可能失败 (前置条件不满足) + 例: 已撤回的不能编辑 +``` + +## 4.5 客户端时钟问题 + +``` +不依赖客户端时间 +所有 timestamp 用服务端 +客户端时间仅作显示参考 +``` + +--- + +# 5. 断点续传 + +## 5.1 离线场景 + +``` +用户离线 30 天 → 重新上线 +本地 sync_token 很老 +服务端要返回 30 天变更? +``` + +## 5.2 分页同步 + +``` +首次同步: + 返回最多 100 个变更会话 + has_more=true + +循环拉取: + 直到 has_more=false + +每页用新 token,失败可重试 +``` + +## 5.3 完整重置 + +``` +sync_token 失效(如太久未同步): + 服务端返回 INVALID_TOKEN + 客户端清空本地,全量同步 +``` + +```python +def sync(user_id, sync_token): + if not is_valid_token(sync_token): + raise InvalidToken + + if token_age > 90 days: + raise TokenTooOld # 客户端走全量 +``` + +## 5.4 全量同步 + +``` +客户端无 token / token 失效: + 1. 拉会话列表 (最近 N 个) + 2. 每个会话拉最近 M 条消息 + 3. 拉所有游标 + 4. 设置新 token +``` + +## 5.5 进度持久化 + +``` +每页同步成功 → 立即保存 token +中断后下次启动从最新 token 继续 +``` + +--- + +# 6. 多端状态同步 + +## 6.1 已读同步 + +``` +A 手机读到 seq=1050: + POST /report_read {conv: C, seq: 1050} + +服务端: + GREATEST 更新 cursor + Kafka: read.event + +B PC 收到: + 实时通道推送 read sync + 本地更新 cursor +``` + +详见前文未读计数设计。 + +## 6.2 设备登录通知 + +``` +A 手机登录: + 服务端记录设备 + 推送给其他端: "新设备登录" + +其他端 UI 显示 +``` + +## 6.3 草稿同步(可选) + +``` +A 手机输入草稿: + 本地保存 + 节流 5s 上报服务端 + +B PC 拉到草稿,显示"在 A 端正在输入..." +``` + +非关键功能,按需。 + +## 6.4 设备列表 + +``` +GET /devices +返回: + 当前所有登录设备 + 各自的最后活跃时间 + 当前设备标记 + +用户可: 远程登出某设备 +``` + +--- + +# 7. 历史消息漫游 + +## 7.1 需求 + +``` +新设备登录 → 看到历史消息 (云端拉) +切换设备 → 历史一致 +本地清空缓存 → 可重新拉 +``` + +## 7.2 漫游协议 + +``` +GET /history?conv=C&before_seq=1000&limit=30 + +返回: + [seq=970~999] (倒序) + has_more: true/false +``` + +## 7.3 漫游 vs 同步 + +``` +同步 (sync): 拉新增/变更 +漫游 (roam): 拉历史 + +漫游不影响 sync_token +漫游消息按 seq 范围查 +``` + +## 7.4 漫游限制 + +``` +- 默认保留 30 天 / 1 年(业务定) +- 单次 limit ≤ 200 +- 频率限制 +``` + +## 7.5 实现 + +```python +def get_history(user_id, conv_id, before_seq, limit): + # 权限校验 + if not is_member(user_id, conv_id): + raise NoPermission + + # 用户加群前的消息不可见 + cursor = get_cursor(user_id, conv_id) + + msgs = db.query(""" + SELECT * FROM im_message + WHERE conv_id = ? + AND visible_seq < ? + AND visible_seq >= ? + AND status = 0 + ORDER BY visible_seq DESC + LIMIT ? + """, conv_id, before_seq, cursor.joined_at_seq, limit) + + return msgs +``` + +--- + +# 8. 性能优化 + +## 8.1 同步频率 + +``` +长连接在线: + 服务端有变更主动推 SYNC_NOTIFY + 客户端响应式拉 + +无长连接: + 定时拉 (每 30s) + 应用启动拉 +``` + +## 8.2 推送压缩 + +``` +SYNC_NOTIFY 只通知"有变更" +不带详细内容 +客户端按需拉 +``` + +## 8.3 批量优化 + +``` +sync 接口返回: + - 多会话合并 + - 单会话多消息合并 + - gzip 压缩 +``` + +## 8.4 服务端缓存 + +``` +近期变更走 Redis (latest_changes:{userId}) +冷数据走 DB +``` + +## 8.5 客户端去重 + +``` +按 server_msg_id 唯一性插入 (UNIQUE KEY) +重复推送/同步不会插入两次 +``` + +## 8.6 弱网处理 + +``` +- 减小 batch +- 增加超时 +- 失败重试 +- 部分成功保留 token +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/13-Presence-Service-v1.0_Version3.md b/_drafts/IM/13-Presence-Service-v1.0_Version3.md new file mode 100755 index 000000000..f7369ab95 --- /dev/null +++ b/_drafts/IM/13-Presence-Service-v1.0_Version3.md @@ -0,0 +1,1111 @@ +# 在线状态服务详细设计 v1.0 + +> 模块名:Presence Service +> 适用:千万在线、百万 QPS 状态查询、亚秒级感知 +> 目标:高吞吐、低延迟、跨地域、最终一致 + +--- + +## 目录 + +1. 设计目标与挑战 +2. 状态模型 +3. 总体架构 +4. 数据分片 +5. 心跳机制 +6. TTL 与过期策略 +7. 路由查询 +8. 订阅模型(在线/离线通知) +9. 跨地域设计 +10. 容错与一致性 +11. 性能优化 +12. 监控指标 + +--- + +# 1. 设计目标与挑战 + +## 1.1 业务诉求 + +| 场景 | 要求 | +|---|---| +| 投递消息 | 查"用户在哪个 Gateway" | +| 显示在线 | 查"好友是否在线" | +| 状态推送 | 好友上下线实时通知 | +| 多端管理 | 知道用户有几个设备在线 | +| 多端互踢 | 同设备类型踢人 | + +## 1.2 量级估算 + +``` +在线用户: 100 万 +心跳频率: 30~60s/次 → 1.6~3 万 QPS 心跳写 +状态查询: 50 万 QPS(消息投递时查) +状态变更事件: 1 万/s(上下线) +订阅查询: 每用户 200 好友 × 部分订阅 → 大量 +``` + +## 1.3 关键挑战 + +``` +1. 心跳写压力大(持续不停) +2. 查询热点(大V好友很多) +3. 跨地域如何保证投递准确 +4. 节点切主时状态丢失 +5. 订阅放大(1 个用户上线,N 个好友收到通知) +``` + +## 1.4 设计目标 + +| 指标 | 目标 | +|---|---| +| 状态写入 QPS | 5 万/s | +| 状态查询 QPS | 100 万/s | +| 查询延迟 P99 | < 5ms | +| 状态过期检测 | < 60s | +| 上下线通知延迟 | < 3s | +| 单地域故障 | 状态自愈 < 2min | + +--- + +# 2. 状态模型 + +## 2.1 状态层级 + +``` +用户级状态: + online / offline / away / busy / invisible + +设备级状态: + 每个 (userId, deviceId) 独立维护 + 路由信息: gatewayId / connId + +聚合规则: + 用户 online ⇔ 至少一个设备 online +``` + +## 2.2 数据结构 + +### 设备级(核心) + +``` +key: presence:dev:{userId}:{deviceId} +value: { + gatewayId: "gw-east-12", + connId: 12345, + loginTime: 1710000000, + lastBeat: 1710000050, + deviceType: "ios", + appVersion: "1.0.0", + ip: "1.2.3.4", + region: "east" +} +TTL: 90s (心跳续约) +``` + +### 用户级聚合 + +``` +key: presence:user:{userId} +value: Hash { + deviceId1: gatewayId1, + deviceId2: gatewayId2, + ... +} +TTL: 不设(随设备级变化) +``` + +### Gateway 级反查 + +``` +key: presence:gw:{gatewayId} +value: Set { (userId, deviceId), ... } +用途: Gateway 挂掉时反查清理 +``` + +## 2.3 用户自定义状态 + +``` +key: presence:custom:{userId} +value: { status: "busy", emoji: "📚", expire: ... } +``` + +业务可见的"状态",不影响投递路由。 + +--- + +# 3. 总体架构 + +## 3.1 模块划分 + +``` +┌──────────────────────────────────────────┐ +│ Gateway (心跳来源) │ +└──────────┬───────────────────────────────┘ + │ 心跳上报 + ▼ +┌──────────────────────────────────────────┐ +│ Presence Service (无状态) │ +│ ├─ Heartbeat Handler │ +│ ├─ Query Handler │ +│ ├─ Subscription Manager │ +│ └─ Expire Detector │ +└──────────┬───────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ Redis Cluster (presence shard) │ +│ 按 userId 分片 │ +└──────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────┐ +│ Kafka: presence.event │ +│ (上下线广播) │ +└──────────────────────────────────────────┘ +``` + +## 3.2 关键交互 + +``` +1. 心跳: + Gateway → Presence → Redis + +2. 查询路由: + Deliver → Presence → Redis → 返回 gatewayId + +3. 订阅通知: + Presence Expire Detector → Kafka → Subscription Manager → 推送 + +4. Gateway 主动上下线: + Gateway → Presence → Redis (立即) + Kafka 事件 +``` + +--- + +# 4. 数据分片 + +## 4.1 分片策略 + +``` +按 userId hash 分片到 Redis Cluster + +slot = CRC16(userId) % 16384 +node = slot_to_node(slot) +``` + +同一用户的所有设备落同一槽,原子操作友好。 + +## 4.2 防热点 + +### 大 V 问题 +某些用户被大量查询(明星、客服)→ 单 slot 热点。 + +### 解决方案 + +#### 方案 A:本地缓存 +``` +Presence 服务节点本地 LRU 缓存: + user → presence (TTL 1s) + +热点用户 99% 查询命中本地,不打 Redis +``` + +#### 方案 B:副本扩散 +``` +key: presence:user:{userId}:{0..15} +查询时随机选副本 +写入时多副本写 +``` + +通常**方案 A 足够**。 + +## 4.3 Redis 集群规划 + +``` +节点数: 16 主 + 16 从 +单节点内存: 16 GB +单节点 QPS: 每节点 8 万 (主+从负载) +总容量: 100 万在线 × 200B = 200 MB(极轻) +总 QPS: 16 × 8万 = 128 万 + +实际瓶颈不是容量,是 QPS +预留: 3 倍容量 +``` + +--- + +# 5. 心跳机制 + +## 5.1 心跳路径 + +``` +客户端 → Gateway (TCP/QUIC ping) + │ + ▼ +Gateway 本地维护 lastBeat + │ + ▼ 每 N 秒批量 +Gateway → Presence Service (RPC) + │ + ▼ +Presence → Redis EXPIRE / SET (Lua) +``` + +## 5.2 心跳协议 + +### 客户端到 Gateway +``` +客户端每 30s 发 PING (协议层) +Gateway 收到 → 更新本地 lastBeat +``` + +### Gateway 到 Presence + +不是每次心跳都打 Redis,会被打爆。 + +**做法:批量续约** + +```go +// Gateway 内每 10 秒执行一次 +func (g *Gateway) renewPresenceBatch() { + activeConns := g.connMgr.GetActiveSince(time.Now().Add(-15 * time.Second)) + + batch := make([]*RenewItem, 0, len(activeConns)) + for _, c := range activeConns { + batch = append(batch, &RenewItem{ + UserID: c.UserID, + DeviceID: c.DeviceID, + ConnID: c.ConnID, + }) + } + + g.presenceClient.BatchRenew(batch) +} +``` + +### Presence 批量处理 + +```go +func (p *Presence) BatchRenew(items []*RenewItem) { + pipe := redis.Pipeline() + for _, item := range items { + key := fmt.Sprintf("presence:dev:%d:%s", item.UserID, item.DeviceID) + pipe.Expire(key, 90*time.Second) + } + pipe.Exec() +} +``` + +**性能**:1 万 conn / 10s = 1 千 RPC/s,每个 RPC 携带 100 个续约 → 10 万 EXPIRE/s 通过 pipeline 高效完成。 + +## 5.3 首次登录写入 + +```lua +-- presence_login.lua +local userKey = KEYS[1] -- presence:user:{uid} +local devKey = KEYS[2] -- presence:dev:{uid}:{did} +local gwKey = KEYS[3] -- presence:gw:{gw} +local devId = ARGV[1] +local gwId = ARGV[2] +local conn = ARGV[3] +local now = ARGV[4] +local ttl = tonumber(ARGV[5]) + +-- 写设备级 +redis.call('HMSET', devKey, + 'gw', gwId, + 'conn', conn, + 'login', now, + 'beat', now) +redis.call('EXPIRE', devKey, ttl) + +-- 写用户级聚合 +redis.call('HSET', userKey, devId, gwId) +redis.call('EXPIRE', userKey, ttl + 30) + +-- 写 gateway 反查 +redis.call('SADD', gwKey, devId .. '#' .. ARGV[1]) +redis.call('EXPIRE', gwKey, 600) + +return 1 +``` + +## 5.4 心跳超时处理 + +``` +Redis TTL 自动过期 → 设备记录消失 +但 presence:user:{uid} 的 hash 字段不会自动清理 +``` + +需要主动清理:见**第 6 节 TTL 与过期**。 + +--- + +# 6. TTL 与过期策略 + +## 6.1 TTL 设置 + +``` +presence:dev:{uid}:{did} TTL = 90s +presence:user:{uid} TTL = 120s(略长,配合清理) +presence:gw:{gw} TTL = 600s +``` + +心跳间隔 30s,3 次失败容忍 = 90s。 + +## 6.2 主动过期检测 + +仅靠 Redis TTL 不够,因为: +- `presence:user:{uid}` 是 Hash,里面的 device 字段不会随 device key 一起消失 +- 需要业务感知"用户离线了" + +**方案:Expire Detector 服务** + +```go +// 每秒扫描一批用户级 hash +func (e *ExpireDetector) Run() { + for { + users := e.scanRecentlyActive(1000) + + for _, userId := range users { + devices, _ := redis.HGetAll(fmt.Sprintf("presence:user:%d", userId)) + + stillOnline := []string{} + offlineDevs := []string{} + + for devId, _ := range devices { + exists, _ := redis.Exists(fmt.Sprintf("presence:dev:%d:%s", userId, devId)) + if exists { + stillOnline = append(stillOnline, devId) + } else { + offlineDevs = append(offlineDevs, devId) + } + } + + // 清理 user hash 里的死设备 + for _, devId := range offlineDevs { + redis.HDel(fmt.Sprintf("presence:user:%d", userId), devId) + e.publishOfflineEvent(userId, devId) + } + + // 用户全部离线 + if len(stillOnline) == 0 && len(offlineDevs) > 0 { + redis.Del(fmt.Sprintf("presence:user:%d", userId)) + e.publishUserOfflineEvent(userId) + } + } + + time.Sleep(1 * time.Second) + } +} +``` + +## 6.3 替代方案:Keyspace Notifications + +Redis 原生支持过期通知: + +``` +notify-keyspace-events Ex +``` + +订阅 `__keyevent@0__:expired`,收到 device key 过期事件 → 立即清理 user hash。 + +**优点**:实时 +**缺点**:通知不可靠(重启丢失),需配合扫描兜底 + +## 6.4 分布式扫描 + +百万用户单进程扫不完,分片扫: + +``` +Detector 实例数 = N +每个实例扫 hash(userId) % N == myInstanceId 的用户 + +按 Redis SCAN 游标 + 业务过滤 +``` + +## 6.5 节点切换边界场景 + +**场景**:用户在 East 在线,East 崩溃,State 仍认为在线 → Deliver 投递到不存在的 Gateway。 + +**解决**: +1. Gateway 优雅退出时主动 DEL +2. Deliver 投递失败 → 强制刷新状态 → 重新查询 +3. Expire Detector 兜底 + +--- + +# 7. 路由查询 + +## 7.1 单用户查询 + +```go +func (p *Presence) GetUser(userId int64) (*UserPresence, error) { + // 1. 本地缓存 + if cached := p.localCache.Get(userId); cached != nil { + return cached, nil + } + + // 2. Redis + devices, err := redis.HGetAll(fmt.Sprintf("presence:user:%d", userId)) + if err != nil { + return nil, err + } + + if len(devices) == 0 { + return &UserPresence{Online: false}, nil + } + + result := &UserPresence{ + UserID: userId, + Online: true, + Devices: make([]*DevicePresence, 0, len(devices)), + } + + // 3. 批量取设备详情(pipeline) + pipe := redis.Pipeline() + cmds := make(map[string]*redis.MapStringStringCmd) + for devId := range devices { + key := fmt.Sprintf("presence:dev:%d:%s", userId, devId) + cmds[devId] = pipe.HGetAll(key) + } + pipe.Exec() + + for devId, cmd := range cmds { + if data, err := cmd.Result(); err == nil && len(data) > 0 { + result.Devices = append(result.Devices, parseDevice(devId, data)) + } + } + + // 4. 写本地缓存(短 TTL) + p.localCache.Set(userId, result, 1*time.Second) + + return result, nil +} +``` + +## 7.2 批量查询(推荐 API) + +消息广播时一次查多个用户: + +```go +func (p *Presence) BatchGet(userIds []int64) (map[int64]*UserPresence, error) { + // 1. 本地缓存命中 + result := make(map[int64]*UserPresence) + miss := []int64{} + + for _, uid := range userIds { + if cached := p.localCache.Get(uid); cached != nil { + result[uid] = cached + } else { + miss = append(miss, uid) + } + } + + if len(miss) == 0 { + return result, nil + } + + // 2. Redis pipeline 批查 + pipe := redis.Pipeline() + cmds := make([]*redis.MapStringStringCmd, len(miss)) + for i, uid := range miss { + cmds[i] = pipe.HGetAll(fmt.Sprintf("presence:user:%d", uid)) + } + pipe.Exec() + + for i, uid := range miss { + devices, _ := cmds[i].Result() + if len(devices) > 0 { + up := &UserPresence{Online: true, ...} + result[uid] = up + p.localCache.Set(uid, up, 1*time.Second) + } else { + result[uid] = &UserPresence{Online: false} + } + } + + return result, nil +} +``` + +## 7.3 投递路径优化 + +``` +Deliver 服务接收 fanout 事件 (多个 recipient) + │ + ▼ +BatchGet(recipients) → Map[uid → presence] + │ + ▼ +按 gatewayId 分组: + gw-1: [uid1, uid2, uid3] + gw-2: [uid4, uid5] + │ + ▼ +向每个 Gateway 单次 RPC(批量推送) +``` + +减少 RPC 次数。 + +## 7.4 缓存策略 + +``` +本地缓存: + Type: LRU + TTL + Size: 100 万条 + TTL: 1 秒 + +意义: + 消息突发时同一用户被查多次 + 用户状态变化不频繁 + 1 秒延迟可接受 +``` + +--- + +# 8. 订阅模型 + +## 8.1 业务场景 + +``` +- 好友列表显示在线状态 +- 群成员在线状态 +- 客服系统:客户上线提醒 +- 多端同步:另一端登录通知 +``` + +## 8.2 订阅 vs 轮询 + +### 轮询(简单) +``` +客户端定期拉所有好友状态 +- 优点:简单 +- 缺点:QPS 高、不实时 +``` + +### 订阅(推荐) +``` +客户端订阅好友列表 +状态变化时服务端主动推送 +- 优点:实时、省 QPS +- 缺点:复杂度高 +``` + +实战:**关键场景订阅 + 兜底轮询**。 + +## 8.3 订阅架构 + +``` +┌────────────────────────────┐ +│ Client │ +│ - 订阅好友列表 │ +│ - 接收状态变更 │ +└──────────┬─────────────────┘ + │ subscribe(friend_ids) + ▼ +┌────────────────────────────┐ +│ Subscription Manager │ +│ - 维护 user → subscribers │ +│ - 维护 subscriber → users │ +└──────────┬─────────────────┘ + │ + ▼ +┌────────────────────────────┐ +│ Kafka: presence.event │ +│ - 上下线事件流 │ +└──────────┬─────────────────┘ + │ + ▼ +┌────────────────────────────┐ +│ Subscription Dispatcher │ +│ - 消费事件 │ +│ - 查找订阅者 │ +│ - 推送给在线订阅者 │ +└────────────────────────────┘ +``` + +## 8.4 订阅数据结构 + +### 用户的订阅列表 +``` +key: presence:sub:{userId} +type: Set +value: { friendId1, friendId2, ... } +TTL: 跟随用户在线 +``` + +### 用户的关注者(反向索引) +``` +key: presence:watcher:{userId} +type: Set +value: { subscriber1, subscriber2, ... } +TTL: 长期保持 +``` + +## 8.5 订阅流程 + +```go +// 用户上线时上报订阅 +func (s *Subscription) Subscribe(userId int64, friendIds []int64) { + // 1. 写正向 + redis.SAdd(fmt.Sprintf("presence:sub:%d", userId), friendIds) + redis.Expire(fmt.Sprintf("presence:sub:%d", userId), 1*time.Hour) + + // 2. 写反向 + pipe := redis.Pipeline() + for _, fid := range friendIds { + pipe.SAdd(fmt.Sprintf("presence:watcher:%d", fid), userId) + pipe.Expire(fmt.Sprintf("presence:watcher:%d", fid), 7*24*time.Hour) + } + pipe.Exec() + + // 3. 立即返回当前状态 + current, _ := s.presence.BatchGet(friendIds) + s.notifyClient(userId, current) +} +``` + +## 8.6 状态变更广播 + +```go +// 用户上线 +func (p *Presence) OnUserOnline(userId int64, deviceInfo *DeviceInfo) { + // 1. 写状态 + p.writeOnline(userId, deviceInfo) + + // 2. 发事件到 Kafka + p.kafka.Publish("presence.event", &PresenceEvent{ + Type: "online", + UserID: userId, + Device: deviceInfo.DeviceID, + Timestamp: time.Now().UnixMilli(), + }) +} + +// Subscription Dispatcher 消费 +func (d *Dispatcher) onPresenceEvent(evt *PresenceEvent) { + // 1. 查谁订阅了 + watchers, _ := redis.SMembers(fmt.Sprintf("presence:watcher:%d", evt.UserID)) + + if len(watchers) == 0 { + return + } + + // 2. 过滤在线订阅者 + presence, _ := d.presence.BatchGet(watchers) + onlineWatchers := []int64{} + for _, w := range watchers { + if presence[w].Online { + onlineWatchers = append(onlineWatchers, w) + } + } + + // 3. 按 Gateway 分组推送 + grouped := groupByGateway(onlineWatchers, presence) + for gw, uids := range grouped { + d.gatewayClient(gw).PushPresence(uids, evt) + } +} +``` + +## 8.7 订阅放大问题 + +``` +大 V 上下线 → 1000 万订阅者 → 推送爆炸 +``` + +### 解决方案 + +#### 1. 大 V 不主动推送 +``` +大 V 状态默认隐藏 +订阅者轮询查询(5 分钟一次) +``` + +#### 2. 优先级订阅 +``` +近期互动过的好友: 实时推送 +其他好友: 延迟批量推送 +``` + +#### 3. 限流 +``` +单用户状态变更: 1 分钟内最多通知 1 次 +避免抖动场景频繁推送 +``` + +#### 4. 去重抖动 +``` +用户 30 秒内反复上下线 → 合并为最后一次状态 +``` + +```go +// debounce +func (d *Dispatcher) onPresenceEvent(evt *PresenceEvent) { + key := fmt.Sprintf("debounce:%d", evt.UserID) + + // 30 秒内只触发一次 + if !redis.SetNX(key, "1", 30*time.Second) { + // 已有 pending 通知 + return + } + + time.AfterFunc(2*time.Second, func() { + // 发送时取最终状态 + finalState := d.presence.GetUser(evt.UserID) + d.broadcast(evt.UserID, finalState) + }) +} +``` + +--- + +# 9. 跨地域设计 + +## 9.1 跨地域查询难题 + +``` +用户 A 在 East 在线 +用户 B 在 South,给 A 发消息 +South 的 Deliver 怎么知道 A 在 East? +``` + +## 9.2 方案对比 + +### 方案 A:全局状态中心 +``` +所有地域写一个全局 Redis +缺点:跨地域延迟、单点 +``` + +### 方案 B:状态跨地域复制 +``` +每地域写本地,异步复制到其他地域 +缺点:复制延迟期间状态不一致 +``` + +### 方案 C:用户主区域路由(推荐) +``` +用户主区域 (home_region) 全局已知 +查询路由优先到主区域 +状态只在主区域权威存储 +``` + +## 9.3 推荐方案:主区域路由 + 转发 + +### 数据布局 + +``` +全局元数据 (etcd / 全局 DB): + user_id → home_region + +各地域 Presence: + 仅维护主区域用户的状态 +``` + +### 用户漫游接入 + +``` +用户 home_region = East +出差到深圳,连接 South Gateway + +South Gateway: + 收到登录请求 + 查 user_id → home_region = East + 转发登录到 East Presence + East Presence 写状态: device.gateway = "gw-south-X" +``` + +### 查询流程 + +``` +South Deliver 要投递给 user A + ↓ +查 user_A → home_region = East + ↓ +RPC 到 East Presence + ↓ +返回 gateway = "gw-south-X" (实际在南方接入) + ↓ +South Deliver 直接调用 gw-south-X +``` + +## 9.4 跨地域 RPC 优化 + +``` +South → East 查询: 50ms RTT +高频查询不可接受 + +优化: + - South 本地缓存(1 秒 TTL) + - 批量查询 + - 仅消息投递时查 +``` + +## 9.5 主区域故障 + +``` +East 整个挂掉 + +降级: + - 用户重连到 South + - South 临时接管,写本地 Presence + - 标记 user.home_region = "south" (临时) + +East 恢复: + - 异步合并状态 + - 用户重连后修正 +``` + +--- + +# 10. 容错与一致性 + +## 10.1 一致性级别 + +``` +Presence 不需要强一致 +最终一致即可: + - 上下线感知 < 90s + - 状态变更通知 < 3s + - 投递失败可触发刷新 +``` + +## 10.2 失效传播 + +``` +Gateway 挂掉 (整个进程) + ↓ +该 Gateway 上所有用户状态 90s 后过期 + ↓ +但消息可能投递到这个失效 Gateway → 失败 +``` + +### 加速恢复 + +``` +监控系统检测 Gateway 心跳停止 + ↓ +立即调用 Presence: ForceCleanGateway(gwId) + ↓ +SMEMBERS presence:gw:{gw} → 所有 device + ↓ +批量 DEL 设备记录 + ↓ +触发 offline 事件 +``` + +```go +func (p *Presence) ForceCleanGateway(gwId string) { + devices, _ := redis.SMembers(fmt.Sprintf("presence:gw:%s", gwId)) + + for _, dev := range devices { + userId, deviceId := parse(dev) + + redis.Del(fmt.Sprintf("presence:dev:%d:%s", userId, deviceId)) + redis.HDel(fmt.Sprintf("presence:user:%d", userId), deviceId) + + p.publishOfflineEvent(userId, deviceId) + } + + redis.Del(fmt.Sprintf("presence:gw:%s", gwId)) +} +``` + +## 10.3 投递失败的状态修正 + +```go +// Deliver 收到 Gateway NOT_FOUND +func (d *Deliver) onPushFailed(userId int64, gwId string, err error) { + if err == ErrConnNotFound { + // 强制刷新 + d.presence.Invalidate(userId) + + // 重新查询 + fresh, _ := d.presence.GetUser(userId) + if fresh.Online && fresh.Gateway != gwId { + d.retryPush(userId, fresh.Gateway) + } else { + // 真离线 → 进 inbox + push 通知 + d.handleOffline(userId) + } + } +} +``` + +## 10.4 双写竞态 + +``` +场景: 同设备两次登录消息几乎同时到 +旧登录: gw-A +新登录: gw-B + +如果新先到,旧后到 → 状态变成 gw-A (错) +``` + +### 解决:版本号 + +```lua +-- Lua 脚本: 版本号 + 比较 +local key = KEYS[1] +local newVer = tonumber(ARGV[1]) +local newGw = ARGV[2] + +local oldVer = tonumber(redis.call('HGET', key, 'ver') or 0) +if newVer > oldVer then + redis.call('HMSET', key, 'ver', newVer, 'gw', newGw, ...) + return 1 +else + return 0 -- 旧请求被忽略 +end +``` + +版本号用客户端登录时间或递增 ID。 + +--- + +# 11. 性能优化 + +## 11.1 心跳合并 + +Gateway 不每次心跳都打 Presence,而是聚合 10s 一次。 + +``` +1 万 Gateway × 10 万用户 / 30s 心跳 = 3.3 万 QPS +聚合后: 1 万 RPC / 10s = 1 千 RPC/s (每个含 3.3 万续约) +通过 pipeline → Redis QPS 仍是 3.3 万 EXPIRE,但减少了网络往返 +``` + +## 11.2 本地缓存 + +``` +Presence 服务节点: + 本地 LRU 100 万条 + TTL 1 秒 + +命中率: > 80% +减少 Redis QPS: 80% +``` + +## 11.3 Redis Pipeline + +批量 HGET / HMSET / EXPIRE 用 pipeline。 + +``` +单 RTT: 1ms +100 个命令逐个: 100ms +100 个命令 pipeline: 1.5ms +``` + +## 11.4 读写分离 + +``` +心跳写: 主节点 +查询读: 从节点(容忍 < 1s 延迟) +``` + +## 11.5 数据压缩 + +device 信息用紧凑序列化(Protobuf / MessagePack)。 + +``` +JSON: 250 字节 +Protobuf: 80 字节 +``` + +--- + +# 12. 监控指标 + +## 12.1 关键指标 + +| 指标 | 类型 | 目标 | +|---|---|---| +| `presence_online_users` | Gauge | 当前在线 | +| `presence_heartbeat_qps` | Counter | 心跳速率 | +| `presence_query_qps{type}` | Counter | 查询速率 | +| `presence_query_latency` | Histogram | 查询延迟 | +| `presence_cache_hit_ratio` | Gauge | 本地缓存命中 | +| `presence_redis_lag` | Gauge | Redis 主从延迟 | +| `presence_event_publish_qps` | Counter | 事件发布速率 | +| `presence_subscribers_total` | Gauge | 订阅总数 | +| `presence_orphan_devices` | Gauge | 孤立设备记录 | + +## 12.2 告警 + +``` +- presence_query_latency P99 > 50ms (5min) +- presence_cache_hit_ratio < 50% (突降) +- presence_orphan_devices > 1000 (Detector 异常) +- Redis 不可用 +``` + +## 12.3 大盘 + +``` +Panel 1: 在线用户曲线 +Panel 2: 心跳/查询 QPS +Panel 3: 查询延迟分布 +Panel 4: 缓存命中率 +Panel 5: 各 Gateway 在线分布 +Panel 6: 上下线事件速率 +``` + +--- + +# 附录:状态服务 API 设计 + +## A.1 RPC 接口 + +```protobuf +service PresenceService { + // 上线 + rpc Online(OnlineReq) returns (OnlineResp); + + // 下线 + rpc Offline(OfflineReq) returns (OfflineResp); + + // 批量心跳续约 + rpc BatchRenew(BatchRenewReq) returns (BatchRenewResp); + + // 单用户查询 + rpc GetUser(GetUserReq) returns (UserPresence); + + // 批量查询 + rpc BatchGet(BatchGetReq) returns (BatchGetResp); + + // 订阅 + rpc Subscribe(SubscribeReq) returns (SubscribeResp); + rpc Unsubscribe(UnsubscribeReq) returns (UnsubscribeResp); + + // 强制清理(运维) + rpc ForceCleanGateway(ForceCleanReq) returns (ForceCleanResp); +} +``` + +## A.2 数据结构 + +```protobuf +message UserPresence { + int64 user_id = 1; + bool online = 2; + repeated DevicePresence devices = 3; + int64 last_active = 4; +} + +message DevicePresence { + string device_id = 1; + string device_type = 2; + string gateway_id = 3; + int64 conn_id = 4; + string region = 5; + int64 login_time = 6; + int64 last_beat = 7; +} +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/14-DB-Scaling-Playbook-v1.0_Version3.md b/_drafts/IM/14-DB-Scaling-Playbook-v1.0_Version3.md new file mode 100755 index 000000000..72e02eec0 --- /dev/null +++ b/_drafts/IM/14-DB-Scaling-Playbook-v1.0_Version3.md @@ -0,0 +1,1169 @@ +# 数据库扩容实战手册 v1.0 + +> 适用:MySQL / TiDB 分库分表的生产环境扩容 +> 场景:从 32 库扩到 64 库 / 单库扩容 / 跨集群迁移 +> 目标:在线扩容、零数据丢失、可回滚 + +--- + +## 目录 + +1. 扩容场景分类 +2. 扩容前准备 +3. 双写迁移完整流程 +4. 数据迁移工具 +5. 对账校验完整脚本 +6. 切流方案 +7. 回滚方案 +8. 故障处理 +9. 性能与限速 +10. Checklist + +--- + +# 1. 扩容场景分类 + +## 1.1 三种扩容场景 + +| 场景 | 复杂度 | 时长 | 影响 | +|---|---|---|---| +| **垂直扩容**(升级硬件) | 低 | 1~2h | 主备切换抖动 | +| **水平扩分库**(32 → 64 库) | 高 | 数天 | 全程双写 | +| **水平扩分表**(库内 16 → 32 表) | 中 | 1~2 天 | 单库内迁移 | + +本文档重点是**水平扩分库**(最复杂的场景)。 + +## 1.2 扩容触发条件 + +``` +任一满足: + - 单库存储 > 70% + - 单库 QPS 持续 > 80% + - 单库连接数 > 80% + - 预测 6 个月内会满 +``` + +## 1.3 扩容方式选择 + +### 双倍扩容(推荐) +``` +原 32 库 → 64 库 +hash(key) % 64 + +迁移规则: + 原 db_00 → db_00 + db_32 + 按 hash 高位决定 + +优点: hash 一致性好,迁移规则清晰 +缺点: 必须 2 倍 +``` + +### 一致性 hash +``` +扩容时只迁移 1/N 数据 +缺点: 实现复杂、运维难 +``` + +**推荐双倍扩容**。 + +--- + +# 2. 扩容前准备 + +## 2.1 资源准备 + +``` +[ ] 新硬件机器到位 +[ ] 新数据库实例搭建 +[ ] 主从复制配置 +[ ] 备份策略一致 +[ ] 监控告警接入 +[ ] DNS / VIP 准备 +``` + +## 2.2 容量评估 + +``` +当前: + 数据量: 32 × 500GB = 16TB + QPS: 32 × 5K = 160K + +扩容后: + 数据量: 64 × 250GB = 16TB(每库减半) + QPS: 64 × 2.5K = 160K + +预留 1 年: + 数据量翻倍 → 64 × 500GB + QPS 翻倍 → 64 × 5K = 320K +``` + +## 2.3 工具准备 + +``` +[ ] 数据迁移工具(DTS / 自研) +[ ] 对账工具 +[ ] 双写中间件(应用层路由开关) +[ ] 限速控制 +[ ] 监控大盘 +[ ] 回滚脚本 +``` + +## 2.4 演练 + +**生产前必须在测试环境完整演练**: + +``` +1. 测试环境部署同样规模 +2. 灌入 1% 生产数据 +3. 完整跑一遍流程 +4. 模拟故障 +5. 验证回滚 +``` + +--- + +# 3. 双写迁移完整流程 + +## 3.1 总体阶段 + +``` +┌──────────────────┐ +│ 阶段 1: 准备 │ 路由层支持双写开关 +└────────┬─────────┘ + │ +┌────────▼─────────┐ +│ 阶段 2: 双写 │ 业务同时写新老分片 +└────────┬─────────┘ + │ +┌────────▼─────────┐ +│ 阶段 3: 历史迁移 │ 后台迁移老数据到新分片 +└────────┬─────────┘ + │ +┌────────▼─────────┐ +│ 阶段 4: 校验 │ 对账,修复差异 +└────────┬─────────┘ + │ +┌────────▼─────────┐ +│ 阶段 5: 切读 │ 灰度切读到新分片 +└────────┬─────────┘ + │ +┌────────▼─────────┐ +│ 阶段 6: 停老写 │ 关闭老分片写入 +└────────┬─────────┘ + │ +┌────────▼─────────┐ +│ 阶段 7: 清理 │ 删除老数据 +└──────────────────┘ +``` + +## 3.2 路由层改造 + +### 配置版本 + +```yaml +# 老配置 +shard_v1: + count: 32 + hash: "hash(key) % 32" + +# 新配置 +shard_v2: + count: 64 + hash: "hash(key) % 64" + +# 双写开关 +dual_write: + enabled: true + read_from: "v1" # 切换值: v1 / v2 + write_to: ["v1", "v2"] +``` + +### 路由代码 + +```python +class ShardRouter: + def __init__(self, config): + self.v1 = ShardConfig(count=32) + self.v2 = ShardConfig(count=64) + self.dual_write = config.dual_write + + def route_write(self, table, key): + targets = [] + if "v1" in self.dual_write.write_to: + targets.append(self.v1.route(table, key)) + if "v2" in self.dual_write.write_to: + targets.append(self.v2.route(table, key)) + return targets + + def route_read(self, table, key): + if self.dual_write.read_from == "v2": + return self.v2.route(table, key) + return self.v1.route(table, key) +``` + +### 双写实现 + +```python +def insert_message(msg): + targets = router.route_write("im_message", msg.conv_id) + + primary = targets[0] + secondary = targets[1] if len(targets) > 1 else None + + # 主写 + primary_db.insert(msg) + + # 副写(异步,失败不阻塞主流程) + if secondary: + try: + secondary_db.insert(msg) + except Exception as e: + log.warn(f"secondary write failed: {e}") + # 写入修复队列 + repair_queue.send(msg) +``` + +## 3.3 阶段 1:准备(1 周) + +``` +[ ] 部署新分片基础设施 +[ ] 路由层支持 v1 / v2 双配置 +[ ] 双写开关 OFF(默认) +[ ] 灰度部署带新代码的应用 +[ ] 监控大盘准备好 +``` + +## 3.4 阶段 2:开启双写(1~3 天观察) + +``` +1. 灰度开启双写(1% → 10% → 100%) +2. 观察: + - 业务延迟是否上升 + - 新分片写入是否成功 + - 数据是否一致 + +3. 持续 1~3 天确认稳定 +``` + +### 监控点 + +``` +- 主��延迟 +- 副写延迟 +- 副写失败率 +- 修复队列堆积 +``` + +## 3.5 阶段 3:历史数据迁移(数小时~数天) + +见**第 4 节**。 + +## 3.6 阶段 4:对账校验 + +见**第 5 节**。 + +## 3.7 阶段 5:切读 + +``` +配置变更: + read_from: v1 → v2 + +灰度: + 1% (5 分钟) → 10% (10 分钟) → 50% (30 分钟) → 100% + +监控: + - 业务错误率 + - 查询延迟 + - 业务异常告警 +``` + +### 切读代码 + +```python +def route_read(table, key, request_id): + # 灰度判断 + bucket = hash(request_id) % 100 + if bucket < CURRENT_GRAY_PERCENT: + return v2.route(table, key) + return v1.route(table, key) +``` + +## 3.8 阶段 6:停止老写入 + +``` +配置变更: + write_to: ["v1", "v2"] → ["v2"] + +观察 7~30 天 +确认 v1 不再被使用 +``` + +## 3.9 阶段 7:清理 + +``` +30 天后: + - 备份 v1 数据到归档 + - 删除 v1 分片 + - 释放硬件 +``` + +--- + +# 4. 数据迁移工具 + +## 4.1 工具选型 + +### 选项 A:DTS(云厂商工具) +``` +阿里云 DTS / AWS DMS +- 优点: 开箱即用、增量同步、断点续传 +- 缺点: 成本高、定制性差 +``` + +### 选项 B:自研迁移工具 +``` +- 优点: 灵活、可控 +- 缺点: 开发成本 +``` + +### 选项 C:开源工具 +``` +gh-ost / pt-online-schema-change (单库) +shardingsphere-scaling (分库) +``` + +## 4.2 自研迁移工具核心实现 + +### 整体设计 + +``` +┌──────────────┐ +│ 迁移控制器 │ ← 任务调度、进度管理、限速 +└──────┬───────┘ + │ + ┌───┴────┐ + ▼ ▼ +┌──────┐ ┌──────┐ +│Worker│ │Worker│ ... 多 worker 并行 +└───┬──┘ └───┬──┘ + ▼ ▼ + v1 v2 +``` + +### 任务表 + +```sql +CREATE TABLE migration_task ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + table_name VARCHAR(64), + src_shard VARCHAR(64), + dst_shard VARCHAR(64), + start_pk BIGINT, + end_pk BIGINT, + current_pk BIGINT, + status VARCHAR(16), -- pending/running/done/failed + rows_total BIGINT, + rows_done BIGINT, + speed INT, -- rows/s + started_at BIGINT, + finished_at BIGINT, + error TEXT +); +``` + +### Worker 实现 + +```python +class MigrationWorker: + def __init__(self, task_id, src_db, dst_router, batch_size=1000): + self.task = self.load_task(task_id) + self.src = src_db + self.router = dst_router + self.batch_size = batch_size + self.rate_limiter = RateLimiter(rows_per_sec=10000) + + def run(self): + try: + self.task.status = "running" + self.task.save() + + current = self.task.current_pk or self.task.start_pk + + while current < self.task.end_pk: + rows = self.fetch_batch(current, self.batch_size) + if not rows: + break + + self.write_batch(rows) + + current = rows[-1].id + self.task.current_pk = current + self.task.rows_done += len(rows) + self.task.save() + + # 限速 + self.rate_limiter.wait(len(rows)) + + # 检查停止信号 + if self.should_stop(): + self.task.status = "paused" + return + + self.task.status = "done" + self.task.finished_at = now() + self.task.save() + + except Exception as e: + self.task.status = "failed" + self.task.error = str(e) + self.task.save() + raise + + def fetch_batch(self, start_pk, limit): + return self.src.query( + f"SELECT * FROM {self.task.table_name} " + f"WHERE id > %s AND id <= %s " + f"ORDER BY id LIMIT %s", + start_pk, self.task.end_pk, limit + ) + + def write_batch(self, rows): + # 按 dst shard 分组 + grouped = defaultdict(list) + for row in rows: + shard_key = self.get_shard_key(row) + dst = self.router.route(self.task.table_name, shard_key) + grouped[dst].append(row) + + # 批量插入 + for dst, group in grouped.items(): + dst.batch_insert(self.task.table_name, group) +``` + +### 批量插入(幂等) + +```python +def batch_insert_idempotent(table, rows): + if not rows: + return + + columns = list(rows[0].keys()) + values_sql = ",".join(["(" + ",".join(["%s"] * len(columns)) + ")"] * len(rows)) + + sql = f""" + INSERT INTO {table} ({','.join(columns)}) + VALUES {values_sql} + ON DUPLICATE KEY UPDATE id=id -- 幂等 + """ + + args = [] + for row in rows: + args.extend([row[c] for c in columns]) + + db.execute(sql, args) +``` + +## 4.3 任务切分 + +```python +def split_tasks(table_name, src_shards, dst_router, slice_size=1000000): + tasks = [] + + for src in src_shards: + # 拿到该 shard 的主键范围 + max_id = src.query(f"SELECT MAX(id) FROM {table_name}").scalar() + min_id = src.query(f"SELECT MIN(id) FROM {table_name}").scalar() + + # 按 slice_size 切分 + current = min_id + while current < max_id: + end = min(current + slice_size, max_id) + tasks.append(MigrationTask( + table_name=table_name, + src_shard=src.name, + start_pk=current, + end_pk=end, + rows_total=slice_size + )) + current = end + + return tasks +``` + +## 4.4 限速 + +```python +class RateLimiter: + def __init__(self, rows_per_sec): + self.rate = rows_per_sec + self.tokens = rows_per_sec + self.last_refill = time.time() + self.lock = threading.Lock() + + def wait(self, n): + with self.lock: + now = time.time() + elapsed = now - self.last_refill + self.tokens = min(self.rate, self.tokens + elapsed * self.rate) + self.last_refill = now + + if self.tokens >= n: + self.tokens -= n + return + + wait_time = (n - self.tokens) / self.rate + + time.sleep(wait_time) + self.wait(n) +``` + +## 4.5 性能基线 + +``` +单 Worker: 1~5 万 rows/s +10 Worker: 50 万 rows/s +30 Worker: 100 万 rows/s(受网络/DB 限制) + +100 亿行迁移: + 100 亿 / 100 万/s = 10000 秒 ≈ 3 小时 + +实际 6~12 小时(含限速) +``` + +## 4.6 监控 + +``` +- 任务进度(done/total) +- 迁移速度(rows/s) +- 错误率 +- 源 / 目标 DB 负载 +- 网络带宽 +``` + +--- + +# 5. 对账校验完整脚本 + +## 5.1 对账层级 + +``` +1. 行数对账(最快) +2. 边界对账(最近数据) +3. 抽样对账 +4. 全量哈希对账(最准) +``` + +## 5.2 行数对账脚本 + +```python +def count_compare(table, time_range): + """按小时桶对比行数""" + sql = f""" + SELECT + UNIX_TIMESTAMP(DATE_FORMAT(FROM_UNIXTIME(created_at/1000), '%Y-%m-%d %H:00:00')) * 1000 as bucket, + COUNT(*) as cnt + FROM {table} + WHERE created_at BETWEEN {time_range.start} AND {time_range.end} + GROUP BY bucket + ORDER BY bucket + """ + + src_buckets = {} + for shard in v1_shards: + for row in shard.query(sql): + src_buckets[row.bucket] = src_buckets.get(row.bucket, 0) + row.cnt + + dst_buckets = {} + for shard in v2_shards: + for row in shard.query(sql): + dst_buckets[row.bucket] = dst_buckets.get(row.bucket, 0) + row.cnt + + diffs = [] + for bucket in set(src_buckets.keys()) | set(dst_buckets.keys()): + src = src_buckets.get(bucket, 0) + dst = dst_buckets.get(bucket, 0) + if src != dst: + diffs.append({ + "bucket": bucket, + "src_count": src, + "dst_count": dst, + "diff": dst - src + }) + + return diffs +``` + +## 5.3 抽样对账 + +```python +def sample_compare(table, sample_size=10000): + """随机抽样对比每行""" + diffs = [] + + for shard in v1_shards: + sample = shard.query(f""" + SELECT * FROM {table} + WHERE id IN ( + SELECT id FROM {table} + ORDER BY RAND() LIMIT {sample_size // len(v1_shards)} + ) + """) + + for row in sample: + shard_key = get_shard_key(row, table) + dst_shard = v2_router.route(table, shard_key) + + dst_row = dst_shard.query( + f"SELECT * FROM {table} WHERE id = %s", row.id + ).first() + + if not dst_row: + diffs.append({"id": row.id, "issue": "missing_in_dst"}) + elif not row_equal(row, dst_row): + diffs.append({ + "id": row.id, + "issue": "content_diff", + "src": row, + "dst": dst_row + }) + + return diffs + +def row_equal(a, b, ignore_fields=("updated_at",)): + for k in a.keys(): + if k in ignore_fields: + continue + if a[k] != b[k]: + return False + return True +``` + +## 5.4 边界对账(增量) + +```python +def boundary_compare(table, lookback=3600): + """对比最近 N 秒数据""" + end_time = int(time.time() * 1000) + start_time = end_time - lookback * 1000 + + src_rows = [] + for shard in v1_shards: + rows = shard.query(f""" + SELECT id, server_msg_id, conv_id, created_at, status + FROM {table} + WHERE created_at BETWEEN %s AND %s + """, start_time, end_time) + src_rows.extend(rows) + + dst_rows = [] + for shard in v2_shards: + rows = shard.query(f""" + SELECT id, server_msg_id, conv_id, created_at, status + FROM {table} + WHERE created_at BETWEEN %s AND %s + """, start_time, end_time) + dst_rows.extend(rows) + + # 按 server_msg_id 去重对比 + src_set = {r.server_msg_id: r for r in src_rows} + dst_set = {r.server_msg_id: r for r in dst_rows} + + only_src = set(src_set.keys()) - set(dst_set.keys()) + only_dst = set(dst_set.keys()) - set(src_set.keys()) + + return { + "missing_in_dst": list(only_src), + "extra_in_dst": list(only_dst), + "src_count": len(src_set), + "dst_count": len(dst_set) + } +``` + +## 5.5 全量哈希对账 + +```python +def hash_compare(table, partition_field="conv_id"): + """每个分片求哈希后对比""" + + def shard_hash(shard, where_clause): + return shard.query(f""" + SELECT + MD5(GROUP_CONCAT( + CONCAT(id, server_msg_id, status) + ORDER BY id + )) as hash, + COUNT(*) as cnt + FROM {table} + WHERE {where_clause} + """).first() + + diffs = [] + + # 按 partition_field 分桶(如每 1000 个 conv_id 一组) + for bucket_start in range(0, MAX_KEY, 1000): + bucket_end = bucket_start + 1000 + where = f"{partition_field} BETWEEN {bucket_start} AND {bucket_end}" + + src_data = aggregate_shards(v1_shards, where) + dst_data = aggregate_shards(v2_shards, where) + + if src_data.hash != dst_data.hash or src_data.cnt != dst_data.cnt: + diffs.append({ + "bucket": (bucket_start, bucket_end), + "src": src_data, + "dst": dst_data + }) + + return diffs +``` + +## 5.6 差异修复 + +```python +def repair_diff(diff): + """修复差异""" + issue = diff["issue"] + + if issue == "missing_in_dst": + # 老库有,新库无 → 补写 + src_row = fetch_from_src(diff["id"]) + shard_key = get_shard_key(src_row) + dst = v2_router.route(table, shard_key) + dst.insert_idempotent(src_row) + + elif issue == "content_diff": + # 内容不一致 → 以老为准(更可靠) + src_row = diff["src"] + shard_key = get_shard_key(src_row) + dst = v2_router.route(table, shard_key) + dst.update(src_row.id, src_row) + + elif issue == "extra_in_dst": + # 新库多 → 排查(可能是双写时机问题) + log.warn(f"extra row in dst: {diff}") + # 通常不删除,可能是新数据 +``` + +## 5.7 持续对账(binlog) + +```python +def continuous_verify(): + """订阅 binlog 持续对账""" + consumer = BinlogConsumer(v1_shards) + + for event in consumer.stream(): + if event.type == "INSERT": + # 等 100ms 让双写到达 + time.sleep(0.1) + + shard_key = get_shard_key_from_event(event) + dst = v2_router.route(event.table, shard_key) + + dst_row = dst.query( + f"SELECT * FROM {event.table} WHERE id = %s", + event.row.id + ).first() + + if not dst_row: + metrics.diff_missing.inc() + repair_queue.send(event.row) + elif not row_equal(event.row, dst_row): + metrics.diff_content.inc() + repair_queue.send(event.row) + else: + metrics.consistent.inc() +``` + +--- + +# 6. 切流方案 + +## 6.1 切读灰度 + +``` +小时 1: 1% +小时 2: 5% +小时 3: 20% +小时 4: 50% +小时 5: 100% + +每个阶段必须满足: + - 业务错误率不上升 + - 延迟不上升 > 10% + - 业务核心指标稳定 +``` + +## 6.2 灰度规则 + +```python +def should_use_v2(request): + if not features.dual_read_enabled: + return False + + # 按用户 ID 灰度(一致性) + bucket = hash(request.user_id) % 100 + return bucket < features.v2_gray_percent +``` + +## 6.3 监控 + +``` +切流监控: + - 错误率(v1 vs v2) + - 延迟分布(v1 vs v2) + - 业务异常计数 +``` + +## 6.4 自动熔断 + +```python +def auto_circuit_breaker(): + """灰度期间自动监控""" + while True: + v2_error_rate = metrics.error_rate("v2", "5min") + baseline = metrics.error_rate("v1", "5min") + + if v2_error_rate > baseline * 2: + log.error("v2 error rate too high, rollback") + features.v2_gray_percent = 0 + send_alarm("auto_rollback_triggered") + + time.sleep(30) +``` + +--- + +# 7. 回滚方案 + +## 7.1 各阶段回滚 + +| 阶段 | 回滚方式 | 风险 | +|---|---|---| +| 双写 | 关闭副写开关 | 低 | +| 历史迁移中 | 暂停任务 | 低 | +| 切读灰度 | 切读回 v1 | 低 | +| 切读完成 | 切读回 v1(v1 仍在双写) | 低 | +| 停老写 | 重新打开 v1 写 + 反向同步增量 | 中 | +| 清理后 | 几乎无法回滚 | 高 | + +## 7.2 切读回滚 + +```python +def rollback_read(): + """秒级回滚切读""" + config_center.set("dual_write.read_from", "v1") + config_center.set("v2_gray_percent", 0) +``` + +## 7.3 紧急回滚(双写阶段) + +```bash +# 1. 关闭副写 +curl -X POST configcenter/dual_write \ + -d '{"write_to": ["v1"]}' + +# 2. 关闭新读 +curl -X POST configcenter/dual_read \ + -d '{"read_from": "v1", "v2_gray_percent": 0}' + +# 3. 暂停迁移任务 +curl -X POST migration/pause + +# 4. 通知相关方 +``` + +## 7.4 数据脏污修复 + +``` +回滚后,v2 可能残留部分数据 +处置: + - 短期: 不影响(v1 仍是权威) + - 长期: 清空 v2 重新开始,或下次扩容继续用 +``` + +--- + +# 8. 故障处理 + +## 8.1 副写失败 + +``` +症状: secondary write 失败率上升 + +排查: + - 新分片连接数 + - 新分片磁盘空间 + - 新分片 QPS 上限 + - 网络 + +处置: + - 写入修复队列 + - 后台异步重试 + - 严重时关闭副写,等修复 +``` + +## 8.2 迁移阻塞 + +``` +症状: 任务长期卡住 + +排查: + - 锁等待 + - 主键空洞太大(COUNT 慢) + - DB 负载高 + +处置: + - 拆分任务(小区间) + - 降低速度 + - 业务低峰期跑 +``` + +## 8.3 对账差异大 + +``` +症状: 抽样发现 1% 以上差异 + +排查: + - 双写时机(业务事务边界) + - 迁移工具 bug + - 时区问题 + +处置: + - 先暂停切读 + - 修复源头问题 + - 重新对账 + 修复差异 +``` + +## 8.4 切读后业务异常 + +``` +症状: 切读到 v2 后查询变慢/出错 + +排查: + - v2 索引缺失? + - v2 数据不全? + - SQL 兼容性? + +处置: + - 立即回滚(秒级) + - 修复后再切 +``` + +--- + +# 9. 性能与限速 + +## 9.1 迁移速度控制 + +```yaml +migration: + rate_limit: + business_hours: 5000 rows/s # 工作时间慢 + night: 50000 rows/s # 夜间快 + + worker_count: 20 + batch_size: 1000 + + pause_on: + src_cpu: > 80% + dst_cpu: > 80% + src_repl_lag: > 30s + dst_repl_lag: > 30s +``` + +## 9.2 业务影响评估 + +``` +双写延迟增加: + 正常: < 10ms 增加 + 异常: > 50ms 增加 → 调查 + +副写失败: + 容忍: < 0.1% + 超出: 关闭副写 +``` + +## 9.3 资源监控 + +``` +- src DB CPU/IO/连接 +- dst DB CPU/IO/连接 +- 主从延迟 +- 应用层延迟 +``` + +--- + +# 10. Checklist + +## 10.1 预发布 + +``` +[ ] 容量评估完成 +[ ] 资源就位 +[ ] 双写代码上线(开关关闭) +[ ] 迁移工具测试通过 +[ ] 对账工具测试通过 +[ ] 测试环境完整演练 +[ ] 应急预案准备 +[ ] 通知相关方 +[ ] 业务低峰窗口确认 +``` + +## 10.2 双写阶段 + +``` +[ ] 灰度开启双写(1% → 100%) +[ ] 观察 24h 稳定 +[ ] 副写失败率 < 0.1% +[ ] 业务延迟未恶化 +[ ] 修复队列清零 +``` + +## 10.3 迁移阶段 + +``` +[ ] 任务拆分完成 +[ ] Worker 启动 +[ ] 速度受控 +[ ] 进度大盘可见 +[ ] 异常告警就绪 +``` + +## 10.4 对账阶段 + +``` +[ ] 行数对账通过 +[ ] 抽样对账通过(差异 < 0.01%) +[ ] 边界对账通过 +[ ] 持续对账启动 +[ ] 修复队列清零 +``` + +## 10.5 切读阶段 + +``` +[ ] 灰度切读 1%(观察 30 分钟) +[ ] 灰度切读 10% +[ ] 灰度切读 50% +[ ] 灰度切读 100% +[ ] 持续观察 7 天 +``` + +## 10.6 收尾 + +``` +[ ] 关闭老库写入 +[ ] 观察 30 天 +[ ] 备份老库 +[ ] 删除老库 +[ ] 释放硬件 +[ ] 总结复盘 +``` + +--- + +# 附录:迁移控制脚本骨架 + +```python +#!/usr/bin/env python3 +"""数据库扩容迁移控制器""" + +import argparse +import sys + +class MigrationController: + def __init__(self, config_path): + self.config = load_config(config_path) + self.tables = self.config.tables + + def cmd_prepare(self): + """准备阶段""" + for table in self.tables: + tasks = split_tasks( + table.name, + self.config.src_shards, + self.config.dst_router, + slice_size=table.slice_size + ) + insert_tasks(tasks) + print(f"created {len(tasks)} tasks for {table.name}") + + def cmd_start(self, table=None): + """启动迁移""" + scheduler = TaskScheduler( + workers=self.config.worker_count, + rate_limit=self.config.rate_limit + ) + scheduler.run(table_filter=table) + + def cmd_pause(self): + """暂停""" + set_global_flag("migration_paused", True) + + def cmd_resume(self): + """恢复""" + set_global_flag("migration_paused", False) + + def cmd_status(self): + """查进度""" + for table in self.tables: + stats = get_table_progress(table.name) + print(f"{table.name}: {stats.done}/{stats.total} ({stats.percent:.1f}%)") + + def cmd_verify(self, table, mode="sample"): + """对账""" + if mode == "count": + diffs = count_compare(table) + elif mode == "sample": + diffs = sample_compare(table) + elif mode == "boundary": + diffs = boundary_compare(table) + elif mode == "full": + diffs = hash_compare(table) + + if diffs: + print(f"{len(diffs)} differences found") + save_diffs(diffs) + else: + print("all consistent") + + def cmd_repair(self, diff_file): + """修复差异""" + diffs = load_diffs(diff_file) + for diff in diffs: + repair_diff(diff) + + def cmd_switch_read(self, percent): + """切读灰度""" + config_center.set("v2_gray_percent", percent) + print(f"switched read to v2: {percent}%") + + def cmd_rollback(self): + """紧急回滚""" + config_center.set("v2_gray_percent", 0) + config_center.set("dual_write.write_to", ["v1"]) + print("rolled back to v1") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=[ + "prepare", "start", "pause", "resume", "status", + "verify", "repair", "switch-read", "rollback" + ]) + parser.add_argument("--config", default="migration.yml") + parser.add_argument("--table") + parser.add_argument("--percent", type=int) + parser.add_argument("--mode", default="sample") + + args = parser.parse_args() + ctrl = MigrationController(args.config) + + cmd_method = getattr(ctrl, f"cmd_{args.command.replace('-', '_')}") + cmd_method(**{k: v for k, v in vars(args).items() if v is not None and k != "command"}) +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/14-DB-Scaling-Runbook-v1.0_Version0.md b/_drafts/IM/14-DB-Scaling-Runbook-v1.0_Version0.md new file mode 100755 index 000000000..7596d9b22 --- /dev/null +++ b/_drafts/IM/14-DB-Scaling-Runbook-v1.0_Version0.md @@ -0,0 +1,850 @@ +# 数据库扩容实战手册 v1.0 + +> 适用:MySQL/TiDB 在线扩容、分片重平衡 +> 目标:业务无感、零数据丢失、可回滚 + +--- + +## 目录 + +1. 扩容触发条件 +2. 扩容方式选型 +3. 双写迁移完整流程 +4. 数据迁移工具 +5. 对账校验脚本 +6. 切读流程 +7. 回滚预案 +8. 演练与发布 + +--- + +# 1. 扩容触发条件 + +## 1.1 触发指标 + +``` +存储水位: + - 单库 > 70% → 黄色预警 + - 单库 > 85% → 启动扩容 + - 单库 > 95% → 紧急扩容 + +性能水位: + - QPS > 80% 容量 + - 慢查询比例 > 5% + - 连接数 > 80% + +业务预估: + - 6 个月内将达到水位 +``` + +## 1.2 评估文档模板 + +``` +# 扩容评估单 +当前规模: + - 库数: 32 + - 单库容量: 800GB / 1TB + - 总数据: 25TB + - 月增长: 3TB + +扩容目标: + - 库数: 64 + - 单库目标: < 50% + - 余量: 24 个月 + +影响: + - 业务停机: 0 + - 风险: ... + - 回滚: ... +``` + +--- + +# 2. 扩容方式选型 + +## 2.1 方式对比 + +| 方式 | 适用 | 复杂度 | 停机 | 数据迁移量 | +|---|---|---|---|---| +| **垂直扩容** | 短期顶 | 低 | 短 | 0 | +| **加 slave** | 读瓶颈 | 低 | 0 | 0 | +| **双倍扩容** | 容量瓶颈 | 高 | 0 | 50% | +| **加分表** | 单库容量 | 中 | 0 | 0 | +| **一致性 hash** | 热点 | 高 | 0 | 1/N | + +## 2.2 推荐:双倍扩容 + +``` +原: 32 库 +新: 64 库 + +迁移规则: + 按 hash 高位: + db_msg_00 旧 → db_msg_00 新(保留一半)+ db_msg_32 新(迁移一半) + +具体: + 原: shard = hash(key) % 32 + 新: shard = hash(key) % 64 + + 原 shard 0 上的数据: + hash % 32 == 0 + → 在新模式下: + hash % 64 == 0 (留 db_msg_00) + hash % 64 == 32 (迁到 db_msg_32) +``` + +每个原库的数据 1/2 留下,1/2 迁出。 + +--- + +# 3. 双写迁移完整流程 + +## 3.1 总览 + +``` +Phase 1: 准备 (1 周) + ├─ 部署新分片 + ├─ 路由层支持双写 + └─ 灰度环境验证 + +Phase 2: 双写 (1 周) + ├─ 业务双写 + ├─ 持续观察 + └─ 对账验证 + +Phase 3: 历史迁移 (按数据量) + ├─ 批量复制 + ├─ 增量同步 + └─ 对账 + +Phase 4: 切读 (1 天) + ├─ 灰度切读 + ├─ 全量切读 + └─ 观察 + +Phase 5: 停老 (30 天后) + ├─ 停老库写入 + ├─ 数据保留 30 天 + └─ 删除老库 +``` + +## 3.2 Phase 1: 准备 + +### 部署新分片 +```bash +# 创建新库 +for i in $(seq 32 63); do + mysql -h dbops -e "CREATE DATABASE db_msg_$(printf %02d $i)" + + # 创建表(与老库 schema 一致) + for j in $(seq 0 15); do + mysql -h db_msg_$(printf %02d $i) < schema/im_message.sql + done +done +``` + +### 路由层支持双写 + +```java +public class DualWriteRouter { + private boolean dualWriteEnabled; + + public void insert(String table, Object key, Map data) { + // 1. 写老分片 + Shard oldShard = oldRouter.route(table, key); + oldShard.insert(data); + + // 2. 双写新分片(异步,失败记录) + if (dualWriteEnabled) { + try { + Shard newShard = newRouter.route(table, key); + newShard.insert(data); + } catch (Exception e) { + // 记录失败,不影响主路径 + dualWriteFailureLog.record(table, key, data, e); + } + } + } +} +``` + +### 灰度开关 +```yaml +# 配置中心 (etcd) +dual_write: + im_message: false # 各表独立开关 + mention_index: false + inbox: false +``` + +## 3.3 Phase 2: 双写 + +### 启用双写 + +```bash +# 配置中心下发 +etcdctl put /config/dual_write/im_message true +``` + +业务侧实时生效。 + +### 监控双写一致性 + +```python +# 每分钟检查 +def monitor_dual_write(): + last_id = get_last_checked_id("im_message") + new_rows = old_db.query( + "SELECT * FROM im_message WHERE id > ? LIMIT 1000", + last_id + ) + + diffs = [] + for row in new_rows: + new_shard = new_router.route("im_message", row.conv_id) + new_row = new_shard.query("SELECT * FROM im_message WHERE id = ?", row.id) + if not new_row or not row_equal(row, new_row): + diffs.append(row.id) + + if diffs: + alert(f"Dual-write inconsistency: {len(diffs)} rows") + repair(diffs) + + save_last_checked_id("im_message", new_rows[-1].id) +``` + +### 修复失败 + +```python +def repair(ids): + for id in ids: + row = old_db.query("SELECT * FROM im_message WHERE id = ?", id) + new_shard = new_router.route("im_message", row.conv_id) + new_shard.upsert(row) +``` + +## 3.4 Phase 3: 历史迁移 + +### 迁移工具(核心代码) + +```python +import threading +import time +from queue import Queue + +class MigrationTool: + def __init__(self, old_router, new_router, table, parallelism=8): + self.old_router = old_router + self.new_router = new_router + self.table = table + self.parallelism = parallelism + self.queue = Queue(maxsize=1000) + + def migrate_shard(self, old_shard_id, batch_size=1000): + last_id = self._load_progress(old_shard_id) + old_shard = self.old_router.get_shard(old_shard_id) + + while True: + rows = old_shard.query( + f"SELECT * FROM {self.table} WHERE id > %s ORDER BY id LIMIT %s", + last_id, batch_size + ) + if not rows: + break + + # 按新分片归类 + by_new_shard = {} + for row in rows: + new_shard = self.new_router.route(self.table, row.conv_id) + by_new_shard.setdefault(new_shard, []).append(row) + + # 批量写入 + for new_shard, batch in by_new_shard.items(): + new_shard.batch_upsert(batch) + + last_id = rows[-1].id + self._save_progress(old_shard_id, last_id) + + # 限速 + self._rate_limit() + + # 进度 + print(f"Shard {old_shard_id}: migrated up to {last_id}") + + def _rate_limit(self): + # 限制 10K rows/s per shard + time.sleep(0.1) + + def run(self): + threads = [] + for shard_id in range(32): + t = threading.Thread(target=self.migrate_shard, args=(shard_id,)) + t.start() + threads.append(t) + + for t in threads: + t.join() +``` + +### 迁移性能 + +``` +单线程: 10K rows/s +32 线程: 30W rows/s (受限于 DB IO) + +100 亿行迁移: + 100亿 / 30万 = 33,000 秒 ≈ 9 小时 +``` + +实际控制在业务低峰期分多次完成。 + +### 增量补漏 + +``` +迁移开始时记录 watermark = max(id) +迁移结束后: + 补漏: 把 watermark 之后到现在的数据再扫一遍 + +循环直到无新增 (双写期间) +``` + +## 3.5 Phase 4: 切读 + +### 灰度切读 + +```yaml +# 配置中心 +read_strategy: + im_message: + new_shard_percent: 0 # 默认 0% +``` + +```java +public Object query(String table, Object key) { + int percent = config.getInt("read_strategy.im_message.new_shard_percent"); + if (random() * 100 < percent) { + return newShard.query(...); + } + return oldShard.query(...); +} +``` + +切读节奏: + +``` +T+0: percent=0 → 1 +T+1h: percent=1 → 5 +T+1d: percent=5 → 20 +T+2d: percent=20 → 50 +T+3d: percent=50 → 100 +``` + +每个阶段观察: +- 错误率 +- 延迟 +- 业务功能正常 + +### 全量切读 + +```yaml +read_strategy: + im_message: + new_shard_percent: 100 +``` + +老分片仍在双写,但不再读。 + +## 3.6 Phase 5: 停老 + +``` +T+30d (切读后 30 天): + 关闭对老分片的写入 + 保留数据再 30 天 + 最终 DROP DATABASE +``` + +为什么等这么久?防止发现问题需要回滚。 + +--- + +# 4. 数据迁移工具 + +## 4.1 工具特性要求 + +``` +- 支持断点续传 +- 限速可配 +- 多线程并行 +- 进度可见 +- 失败重试 +- 一致性校验 +``` + +## 4.2 完整工具实现 + +```python +#!/usr/bin/env python3 +""" +DB Migration Tool +Usage: python migrate.py --table im_message --shards 0-31 --rate 10000 +""" + +import argparse +import json +import logging +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +class Migrator: + def __init__(self, config): + self.config = config + self.old_db = self._connect_old() + self.new_db = self._connect_new() + self.progress_file = config['progress_file'] + self.progress = self._load_progress() + self.lock = threading.Lock() + + def _load_progress(self): + try: + with open(self.progress_file) as f: + return json.load(f) + except FileNotFoundError: + return {} + + def _save_progress(self): + with self.lock: + with open(self.progress_file, 'w') as f: + json.dump(self.progress, f) + + def migrate_shard(self, shard_id): + progress_key = f"{self.config['table']}:shard:{shard_id}" + last_id = self.progress.get(progress_key, 0) + + old_conn = self.old_db.connect(shard_id) + + total = 0 + start = time.time() + + while True: + rows = self._fetch_batch(old_conn, last_id) + if not rows: + break + + self._write_batch(rows) + + last_id = rows[-1]['id'] + self.progress[progress_key] = last_id + + total += len(rows) + + # 进度 + if total % 100000 == 0: + elapsed = time.time() - start + rate = total / elapsed + logging.info(f"Shard {shard_id}: {total} rows, {rate:.0f} rows/s") + self._save_progress() + + # 限速 + self._rate_limit(len(rows)) + + self._save_progress() + logging.info(f"Shard {shard_id} done: {total} rows") + + def _fetch_batch(self, conn, last_id): + sql = f""" + SELECT * FROM {self.config['table']} + WHERE id > %s + ORDER BY id + LIMIT %s + """ + return conn.query(sql, last_id, self.config['batch_size']) + + def _write_batch(self, rows): + # 按新分片分组 + groups = {} + for row in rows: + new_shard_id = self._calc_new_shard(row) + groups.setdefault(new_shard_id, []).append(row) + + for new_shard_id, batch in groups.items(): + new_conn = self.new_db.connect(new_shard_id) + new_conn.upsert_batch(self.config['table'], batch) + + def _calc_new_shard(self, row): + shard_key = self.config['shard_key'] # e.g. 'conv_id' + return hash(row[shard_key]) % self.config['new_shard_count'] + + def _rate_limit(self, n): + delay = n / self.config['rate'] + time.sleep(delay) + + def run(self, shards): + with ThreadPoolExecutor(max_workers=self.config['parallelism']) as executor: + futures = [executor.submit(self.migrate_shard, s) for s in shards] + for f in futures: + f.result() + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--config', required=True) + parser.add_argument('--shards', required=True) + args = parser.parse_args() + + config = json.load(open(args.config)) + shards = parse_range(args.shards) + + Migrator(config).run(shards) +``` + +## 4.3 配置示例 + +```json +{ + "table": "im_message", + "shard_key": "conv_id", + "new_shard_count": 64, + "old_shard_count": 32, + "batch_size": 1000, + "parallelism": 16, + "rate": 100000, + "progress_file": "/var/lib/migration/progress.json", + "old_db": { + "hosts": ["db_msg_old:3306"], + "user": "...", + "password": "..." + }, + "new_db": { + "hosts": ["db_msg_new:3306"], + "user": "...", + "password": "..." + } +} +``` + +## 4.4 进度可视��� + +``` +启动 web 界面 / Prometheus 暴露指标: + +migration_total_rows{table, shard} +migration_processed_rows{table, shard} +migration_rate_rows_per_sec{table, shard} +migration_eta_seconds{table, shard} +``` + +--- + +# 5. 对账校验脚本 + +## 5.1 抽样对账 + +```python +def sample_verify(table, sample_count=10000): + diffs = [] + + for shard_id in range(old_shard_count): + old_conn = old_db.connect(shard_id) + + # 随机抽样 + rows = old_conn.query(f""" + SELECT * FROM {table} + ORDER BY RAND() + LIMIT {sample_count // old_shard_count} + """) + + for row in rows: + new_shard_id = calc_new_shard(row) + new_conn = new_db.connect(new_shard_id) + new_row = new_conn.query( + f"SELECT * FROM {table} WHERE id = %s", row['id'] + ) + + if not new_row or not row_equal(row, new_row): + diffs.append({ + 'id': row['id'], + 'old': row, + 'new': new_row + }) + + return diffs +``` + +## 5.2 全量哈希对账 + +```python +def full_verify_by_hash(table): + """按时间分桶比对哈希值""" + + for hour_bucket in time_buckets(): + old_hash = old_db.query(f""" + SELECT + COUNT(*) as cnt, + SUM(CRC32(CONCAT(id, content, sender_id))) as checksum + FROM {table} + WHERE created_at BETWEEN %s AND %s + """, hour_bucket.start, hour_bucket.end) + + new_hash = new_db.query(f""" + SELECT + COUNT(*) as cnt, + SUM(CRC32(CONCAT(id, content, sender_id))) as checksum + FROM {table} + WHERE created_at BETWEEN %s AND %s + """, hour_bucket.start, hour_bucket.end) + + if old_hash != new_hash: + print(f"DIFF at {hour_bucket}: old={old_hash} new={new_hash}") + drill_down(hour_bucket) + +def drill_down(hour_bucket): + """缩小范围定位差异""" + for minute in minute_buckets(hour_bucket): + # ... 递归 +``` + +## 5.3 行级对账 + +```python +def row_level_verify(table, time_range): + """逐行对比""" + + old_iter = old_db.iter(f""" + SELECT * FROM {table} + WHERE created_at BETWEEN %s AND %s + ORDER BY id + """, time_range.start, time_range.end) + + new_iter = new_db.iter_all_shards(f""" + SELECT * FROM {table} + WHERE created_at BETWEEN %s AND %s + ORDER BY id + """) + + diffs = [] + while True: + old_row = next(old_iter, None) + new_row = next(new_iter, None) + + if old_row is None and new_row is None: + break + + if not old_row or not new_row or old_row.id != new_row.id: + diffs.append((old_row, new_row)) + elif not row_equal(old_row, new_row): + diffs.append((old_row, new_row)) + + return diffs +``` + +## 5.4 自动修复 + +```python +def auto_repair(diffs): + for old_row, new_row in diffs: + if old_row and not new_row: + # 新库缺,补 + new_shard = calc_new_shard(old_row) + new_db.upsert(table, old_row, new_shard) + elif new_row and not old_row: + # 老库缺(不应发生),告警 + alert(f"Row in new but not old: {new_row.id}") + else: + # 内容不一致,老为准 + new_shard = calc_new_shard(old_row) + new_db.upsert(table, old_row, new_shard) +``` + +## 5.5 持续对账(双写期间) + +```python +# 每 5 分钟扫一次最近的双写数据 +def continuous_verify(): + while True: + last_check = load_last_check_time() + now = time.time() + + diffs = row_level_verify(table, TimeRange(last_check, now)) + + if diffs: + metrics.dual_write_diff.inc(len(diffs)) + auto_repair(diffs) + + save_last_check_time(now) + time.sleep(300) +``` + +--- + +# 6. 切读流程 + +## 6.1 灰度配置 + +```python +def query(table, key): + config = get_config(f"read_strategy.{table}") + + if should_use_new(key, config.percent): + return new_router.query(table, key) + return old_router.query(table, key) + +def should_use_new(key, percent): + # 用 key 的 hash 决定,确保同一 key 始终走同一边 + return (hash(key) % 100) < percent +``` + +## 6.2 双读对比 + +切读初期,可双读对比: + +```python +def query_with_compare(table, key): + old_result = old_router.query(table, key) + new_result = new_router.query(table, key) + + if old_result != new_result: + log_diff(table, key, old_result, new_result) + + return old_result # 返回老库为准 +``` + +只在抽样比例(如 1%)做双读,避免性能 2x。 + +## 6.3 切读节奏 + +``` +建议节奏: + T+0: 1% (观察 1 小时) + T+1h: 5% (观察 4 小时) + T+1d: 20% (观察 1 天) + T+2d: 50% (观察 1 天) + T+3d: 100% + +每阶段观察: + - 错误率 + - 延迟 P99 + - 业务功能正常 + - 对账无差异 +``` + +--- + +# 7. 回滚预案 + +## 7.1 各阶段回滚 + +### Phase 1 准备阶段 +``` +回滚: 删除新分片,无影响 +``` + +### Phase 2 双写阶段 +``` +回滚: 关闭双写开关 + etcdctl put /config/dual_write/im_message false + +老分片不受影响 +新分片数据废弃 +``` + +### Phase 3 迁移阶段 +``` +回滚: 暂停迁移工具 + 迁移已完成的数据保留 + 老分片是 source of truth + +重启后从断点续传 +``` + +### Phase 4 切读阶段 +``` +回滚: 切读比例���回 0 + etcdctl put /config/read_strategy/im_message/new_shard_percent 0 + +立即生效 +老分片提供查询 +``` + +### Phase 5 停老阶段 +``` +30 天内可回滚: + 重新启用双写 + 重新切读到老 + +30 天后: + 老分片删除,无法回滚 + 必须从新分片反向迁移 +``` + +## 7.2 紧急回滚流程 + +``` +1. 确认问题 +2. 配置中心切回老路由 (秒级) +3. 业务恢复正常 +4. 排查问题 +5. 修复后重新尝试 +``` + +--- + +# 8. 演练与发布 + +## 8.1 演练环境 + +``` +预生产环境: + - 真实数据规模(缩小 10x) + - 同样的迁移流程 + - 完整演练 Phase 1~5 + - 验证回滚 +``` + +## 8.2 演练 checklist + +``` +[ ] 部署新分片成功 +[ ] 双写成功率 > 99.99% +[ ] 历史迁移完成 +[ ] 抽样对账无差异 +[ ] 全量哈希对账无差异 +[ ] 灰度切读 1% 正常 +[ ] 全量切读正常 +[ ] 回滚演练成功 +[ ] 性能对比通过 +``` + +## 8.3 发布前评审 + +``` +- 容量数据 +- 迁移时间预估 +- 风险点 +- 应急预案 +- 值守安排 +- 回滚演练记录 +``` + +## 8.4 发布安排 + +``` +时间窗口: 业务低峰 +人员: SRE + DBA + 业务负责人 +工具: 实时大盘 + 操作记录 +通信: 战时群 + 视频会议 + +每 30 分钟同步进展 +``` + +## 8.5 发布后 + +``` +- 1 周内每日对账 +- 监控异常指标 +- 准备应急 +- 30 天后清理 +``` + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/15-Full-Stack-Stress-Test-v1.0_Version3.md b/_drafts/IM/15-Full-Stack-Stress-Test-v1.0_Version3.md new file mode 100755 index 000000000..188a1848c --- /dev/null +++ b/_drafts/IM/15-Full-Stack-Stress-Test-v1.0_Version3.md @@ -0,0 +1,998 @@ +# 全链路压测方案 v1.0 + +> 适用:千万 DAU IM 系统的容量评估、性能验证、故障演练 +> 目标:在生产环境模拟真实流量,发现瓶颈、验证 SLA、保障大促/活动稳定 + +--- + +## 目录 + +1. 压测目标与原则 +2. 压测分类 +3. 压测工具选型 +4. 数据准备 +5. 流量模型构建 +6. 流量回放 +7. 全链路改造(影子库/影子链路) +8. 执行流程 +9. 性能瓶颈定位 +10. 压测报告 +11. 大促压测实战 +12. 风险控制与回滚 +13. 常用脚本与命令 + +--- + +# 1. 压测目标与原则 + +## 1.1 压测目标 + +压测不是为了“把系统打挂”,而是为了回答下面这些问题: + +1. **系统当前容量上限是多少** +2. **在哪一层先成为瓶颈** +3. **瓶颈出现前的预警指标是什么** +4. **扩容后收益是否线性** +5. **在目标峰值下 SLA 能否达标** +6. **故障场景下是否还能维持核心服务** + +## 1.2 压测核心指标 + +| 维度 | 目标 | +|---|---| +| 接入建连成功率 | > 99.9% | +| 消息发送成功率 | > 99.99% | +| 同地域消息延迟 P99 | < 500ms | +| 跨地域消息延迟 P99 | < 1s | +| Kafka lag | 可控,不持续增长 | +| Redis 查询延迟 P99 | < 5ms | +| DB 写入延迟 P99 | < 20ms | +| Push 延迟 P99 | < 5s | + +## 1.3 压测原则 + +### 原则 1:逐层压,不要一上来全链路最大流量 +先单模块,再联调,再全链路。 + +### 原则 2:真实流量模型比纯 QPS 更重要 +IM 不是简单 API 压测,必须模拟: + +- 长连接 +- 心跳 +- 收发比 +- 单聊/群聊比例 +- 消息大小分布 +- 在线/离线用户比例 +- 多端在线比例 + +### 原则 3:能在预发完成的,不要直接上生产 +生产压测只做最终验证,不做首次发现问题。 + +### 原则 4:生产压测必须走影子链路或严格隔离 +避免污染真实用户数据。 + +--- + +# 2. 压测分类 + +## 2.1 按目标分类 + +### 1)基准压测(Benchmark) +测单服务在固定资源下的基线能力。 + +例如: + +- 单 Gateway 最大连接数 +- 单 MsgWrite 的写入能力 +- 单 Redis 节点 QPS +- 单 Kafka partition 吞吐 + +### 2)容量压测(Capacity Test) +逐步加流量,找系统容量拐点。 + +### 3)稳定性压测(Soak Test) +长时间(6h / 12h / 24h)持续跑,观察: + +- 内存泄漏 +- 线程堆积 +- Kafka lag 累积 +- 连接漂移 +- GC 异常 + +### 4)突刺压测(Spike Test) +模拟热点事件或瞬时洪峰。 + +例如: + +- 万人群短时间刷屏 +- 推送流量暴涨 +- 大 V 发消息后 fanout 激增 +- 服务恢复后重连风暴 + +### 5)故障压测(Chaos Test) +在压测过程中主动注入故障: + +- 杀 Gateway +- 切 Redis 主从 +- Kafka broker 下线 +- DB 主切换 +- 网络抖动/丢包 + +--- + +## 2.2 按链路分类 + +### A. 接入层压测 +验证: + +- 建连速度 +- 长连接数量 +- 心跳稳定性 +- QUIC/WS 切换能力 + +### B. 消息主链路压测 +验证: + +- 消息发送 +- 落库 +- outbox +- Kafka +- deliver +- 客户端接收 + +### C. 离线链路压测 +验证: + +- inbox 写入 +- 离线同步 +- push 发送 +- 冷启动拉消息 + +### D. 管理/辅助链路压测 +验证: + +- 在线状态 +- 已读回执 +- @消息 +- 撤回/编辑 +- 群成员变更 + +--- + +# 3. 压测工具选型 + +## 3.1 工具总览 + +| 场景 | 推荐工具 | 说明 | +|---|---|---| +| HTTP API | k6 / wrk2 / JMeter | 简单 REST 压测 | +| WebSocket 长连接 | k6 ws / Tsung / 自研 | 必须支持长连接与消息交互 | +| QUIC 压测 | 自研 / quic-go bench / h2load(h3) | QUIC 现成工具较少 | +| Kafka 压测 | kafkacat / kafka-producer-perf-test | topic 吞吐 | +| Redis 压测 | redis-benchmark / memtier_benchmark | cache QPS | +| MySQL 压测 | sysbench / go-sql-bench | 数据库读写 | +| 全链路压测 | 自研压测平台 | IM 最终还是要自研 | + +## 3.2 为什么 IM 需要自研压测平台 + +因为 IM 压测有这些特殊性: + +- 长连接保持 +- 双向消息交互 +- 心跳 +- 登录态 +- 连接断开重连 +- 订阅状态变化 +- 收件端真实消费 +- 推拉结合 +- 多种消息类型混合 + +这些用 JMeter/k6 只能覆盖一部分,**最终必须自研一个 IM 压测客户端集群**。 + +--- + +## 3.3 自研压测平台模块 + +``` +┌──────────────────────────────────────────┐ +│ 压测控制台 │ +│ - 配置场景 │ +│ - 设置用户规模 │ +│ - 设置消息模型 │ +│ - 实时观测 │ +└─────────────┬────────────────────────────┘ + │ + ▼ +┌───────────────���──────────────────────────┐ +│ 压测调度器 │ +│ - 任务下发 │ +│ - Agent 编排 │ +│ - 限流控制 │ +└─────────────┬────────────────────────────┘ + │ + ┌──────────┼──────────┐ + ▼ ▼ ▼ +┌──────┐ ┌──────┐ ┌──────┐ +│Agent1│ │Agent2│ │AgentN│ +│模拟用户│ │模拟用户│ │模拟用户│ +└──────┘ └──────┘ └──────┘ +``` + +### Agent 职责 +- 模拟用户登录 +- 建立长连接 +- 发送消息 +- 收消息 +- 上报结果 +- 模拟心跳 +- 模拟断网重连 + +--- + +# 4. 数据准备 + +## 4.1 为什么数据准备很重要 + +IM 系统性能和数据分布高度相关,不能只造“空用户”。 + +要准备: + +- 用户数据 +- 好友关系 +- 群关系 +- 会话分布 +- 消息历史 +- token / 鉴权信息 +- push token +- 大群 / 大 V 样本 + +## 4.2 用户模型 + +### 用户分层 +建议至少拆成 4 类: + +| 用户类型 | 比例 | 特征 | +|---|---|---| +| 轻度用户 | 60% | 少量会话,少发消息 | +| 中度用户 | 30% | 常规聊天 | +| 重度用户 | 9% | 高频收发 | +| 超级用户 | 1% | 大 V / 客服 / 群主 | + +### 数据字段 +```json +{ + "user_id": 100001, + "device_count": 2, + "region": "east", + "friend_count": 200, + "group_count": 30, + "role": "normal", + "is_vip": false +} +``` + +## 4.3 关系数据准备 + +### 好友关系 +- 每用户平均 100~300 好友 +- 长尾分布 +- 大 V 可有 10万+ 粉丝 + +### 群关系 +- 小群:3~20 人 +- 中群:20~200 人 +- 大群:200~5000 人 +- 超大群:5000~100000 人 + +### 分布建议 +| 群类型 | 比例 | +|---|---| +| 小群 | 80% | +| 中群 | 15% | +| 大群 | 4% | +| 超大群 | 1% | + +## 4.4 历史消息准备 + +为了验证同步、离线、搜索等功能,需要灌入历史数据: + +- 每个活跃会话 100~1000 条 +- 大群 1万~10万条历史 +- 包含多种消息类型: + - text + - image + - file + - reply + - mention + - recall + +## 4.5 测试账号与 token + +压测用户要提前生成: + +- user_id +- auth token +- device_id +- push token(可伪造/影子通道) +- 地域归属 + +压测框架启动前从账号池拉取。 + +--- + +# 5. 流量模型构建 + +## 5.1 IM 流量组成 + +压测不能只压“发送消息”,还要包含整套行为: + +| 行为 | 比例参考 | +|---|---| +| 心跳 | 所有在线连接持续 | +| 登录/重连 | 低频,但关键 | +| 发送消息 | 业务核心 | +| 接收消息 | 与发送成对出现 | +| 拉同步 | 断线/上线/补偿 | +| 已读上报 | 中频 | +| 正在输入 | 低频 | +| 撤回/编辑 | 低频 | +| @消息 | 中低频 | +| push | 离线链路需要 | + +## 5.2 消息类型分布 + +建议按真实业务比例: + +| 类型 | 比例 | +|---|---| +| 文本 | 75% | +| 图片 | 15% | +| 文件 | 3% | +| 语音 | 3% | +| 视频 | 1% | +| 自定义/卡片 | 3% | + +## 5.3 会话类型分布 + +| 会话类型 | 比例 | +|---|---| +| 单聊 | 70% | +| 小群 | 20% | +| 中群 | 7% | +| 大群 | 2% | +| 超大群 | 1% | + +## 5.4 在线/离线比例 + +压测中要区分: + +| 状态 | 比例 | +|---|---| +| 在线用户 | 30% | +| 后台用户 | 30% | +| 离线用户 | 40% | + +因为: + +- 在线用户走实时通道 +- 后台用户可能触发 push +- 离线用户走 inbox + push + 上线同步 + +## 5.5 多端分布 + +| 设备组合 | 比例 | +|---|---| +| 单设备在线 | 70% | +| 双设备在线 | 25% | +| 三设备在线 | 5% | + +用于验证多端同步和去重。 + +--- + +# 6. 流量回放 + +## 6.1 回放的来源 + +### 方案 A:线上真实流量采样(推荐) +从生产日志采样出匿名化流量: + +- 发送频率 +- 消息大小 +- 会话类型 +- 用户活跃时间分布 + +### 方案 B:人工构造流量模型 +适合早期没有生产数据的阶段。 + +## 6.2 真实流量回放流程 + +``` +生产日志 + │ + ▼ +脱敏处理 + - user_id 映射 + - conv_id 映射 + - 内容脱敏/替换 + │ + ▼ +回放事件文件 + │ + ▼ +压测 Agent 读取回放 + │ + ▼ +按原时间比例 / 加速比例发送 +``` + +## 6.3 脱敏规则 + +不能把真实用户数据直接压测使用。 + +建议脱敏: + +- `user_id -> hash 映射` +- `conv_id -> hash 映射` +- 文本内容 -> 模板化替换 +- 图片/file url -> 压测专用对象地址 +- push token -> 影子 token + +## 6.4 回放速度 + +### 1x 回放 +按真实时间速率回放。 + +### 2x / 5x / 10x 回放 +用于容量评估。 + +### 突刺模式 +把某个时间窗口的流量压缩到更短时间内。 + +例如: +- 原来 10 分钟的流量,1 分钟打完 + +## 6.5 回放实现示例 + +```python +class ReplayEngine: + def __init__(self, events): + self.events = events + + def run(self, speed=1.0): + start_wall = time.time() + start_event_ts = self.events[0]["ts"] + + for event in self.events: + target = (event["ts"] - start_event_ts) / speed + now = time.time() - start_wall + if target > now: + time.sleep(target - now) + + self.dispatch(event) + + def dispatch(self, event): + if event["type"] == "send_msg": + self.agent.send_message(event["user"], event["conv"], event["content"]) + elif event["type"] == "read": + self.agent.report_read(event["user"], event["conv"], event["seq"]) + elif event["type"] == "login": + self.agent.login(event["user"]) +``` + +--- + +# 7. 全链路改造(影子库 / 影子链路) + +## 7.1 为什么需要影子链路 + +生产压测如果直接打真实链路,会有风险: + +- 污染真实 DB +- 真实用户收到压测消息 +- Kafka topic 混入测试事件 +- push 被真实发出 + +所以需要**影子环境隔离**。 + +## 7.2 影子链路设计 + +### 方式 A:Header 标记(推荐) +压测请求带标记: + +```http +X-Pressure-Test: true +X-PT-Biz: im +``` + +服务端识别后: + +- 走影子 DB +- 走影子 Redis key 前缀 +- 走影子 Kafka topic +- 走影子 Push 通道 + +### 方式 B:独立域名/入口 +压测客户端走单独接入域名: +- `pt-im.example.com` + +缺点是与生产真实路径不完全一致。 + +--- + +## 7.3 影子资源规划 + +| 组件 | 生产 | 压测影子 | +|---|---|---| +| DB | `im_message` | `pt_im_message` 或独立库 | +| Redis | `presence:*` | `pt:presence:*` | +| Kafka | `msg.fanout.normal` | `pt.msg.fanout.normal` | +| Push | 真厂商 | Mock / 沙箱 | +| ES | `im_search` | `pt_im_search` | + +## 7.4 影子 Topic 设计 + +``` +生产: msg.fanout.normal +影子: pt.msg.fanout.normal +``` + +压测消费者只消费 `pt.*`。 + +## 7.5 影子 Push + +压测时不能触发真实 push。 + +方案: + +- APNs:sandbox 环境 +- FCM:测试项目 +- 厂商通道:mock server +- 或统一直接 mock + +--- + +# 8. 执行流程 + +## 8.1 标准压测流程 + +### Step 1:明确目标 +例如: +- 验证 50 万消息 QPS 是否可支撑 +- 验证 100 万在线用户是否稳定 +- 验证大群峰值 5 万 fanout/s 是否正常 + +### Step 2:准备数据 +- 构造账号池 +- 构造关系图 +- 灌历史数据 + +### Step 3:环境检查 +- 所有组件健康 +- 监控就位 +- 告警降噪 +- 影子链路就位 + +### Step 4:预热 +- 先建立连接 +- 先灌入基础在线状态 +- 让缓存热起来 + +### Step 5:逐步加压 +建议曲线: + +``` +10% → 30% → 50% → 70% → 100% → 120% +每阶段持续 10~30 分钟 +``` + +### Step 6:稳态观察 +在目标流量维持 30 分钟以上,观察: + +- CPU +- 延迟 +- lag +- error rate +- 内存 +- GC +- 连接稳定性 + +### Step 7:故障注入(可选) +在稳态流量下: + +- 杀 1 台 Gateway +- Kafka broker 下线 +- Redis 主切换 +- DB 主切换 + +验证系统是否自动恢复。 + +### Step 8:收尾 +- 结束压测 +- 清理影子数据 +- 导出报告 + +--- + +# 9. 性能瓶颈定位 + +全链路压测最重要的是定位**第一个瓶颈点**。 + +## 9.1 典型瓶颈层级 + +### 1)Gateway 先满 +现象: +- 建连变慢 +- 消息收发延迟上升 +- CPU 高 +- send queue 堵塞 + +定位指标: +- `gateway_connections_active` +- `gateway_send_blocked_total` +- 网络带宽 +- epoll wait + +### 2)MsgWrite 写入瓶颈 +现象: +- 发送 ACK 慢 +- DB 写入延迟增加 +- outbox 堆积 + +定位指标: +- DB QPS +- DB P99 latency +- thread pool queue +- 慢查询 + +### 3)Redis 瓶颈 +现象: +- 在线状态查询慢 +- 未读计算慢 +- 限流误触发 + +定位指标: +- Redis ops/s +- CPU +- hit ratio +- slowlog +- hot key + +### 4)Kafka 瓶颈 +现象: +- lag 持续增长 +- 消费延迟变大 +- 推送延迟增加 + +定位指标: +- partition lag +- producer retry +- broker network +- ISR 变化 + +### 5)Deliver / InboxWriter 瓶颈 +现象: +- 在线用户消息延迟增加 +- 离线消息入 inbox 慢 + +定位指标: +- deliver rpc latency +- inbox insert latency +- consumer backlog + +### 6)Push 瓶颈 +现象: +- push 延迟变大 +- 厂商错误码增多 + +定位指标: +- push success rate +- rate limited +- channel fallback ratio + +--- + +## 9.2 瓶颈定位方法论 + +### 方法 1:看延迟分布链路 +把消息拆成多个 span: + +- client_send → gateway_recv +- gateway_recv → db_commit +- db_commit → kafka_publish +- kafka_publish → deliver_consume +- deliver_consume → gateway_push +- gateway_push → client_recv + +哪一段 P99 开始突增,就是瓶颈点。 + +### 方法 2:看队列 +任何队列增长,都是瓶颈信号: + +- Gateway send queue +- RPC thread pool queue +- outbox pending +- Kafka lag +- inbox write queue +- push queue + +### 方法 3:看资源不是看平均值 +平均 CPU 50% 不代表没问题,可能某个分片已经 100%。 + +重点看: +- 按实例 +- 按 shard +- 按 partition +- 按 hot key + +### 方法 4:控制变量 +一次只调一个参数,比如: +- 增加 Gateway 副本数 +- 增加 Kafka partition +- 增加 Redis 节点数 + +看瓶颈是否后移。 + +--- + +# 10. 压测报告 + +## 10.1 报告模板 + +```markdown +# IM 全链路压测报告 + +## 一、目标 +- 验证 100 万在线 +- 验证 50 万 msg/s +- 验证大群 fanout + +## 二、环境 +- 集群规模 +- 版本 +- 压测时间 +- 压测工具 + +## 三、流量模型 +- 在线用户数 +- 单聊/群聊比例 +- 消息类型分布 +- 多端比例 +- 离线比例 + +## 四、结果 +### 4.1 核心指标 +- 连接成功率 +- 消息成功率 +- P50/P99 延迟 +- Kafka lag +- push 成功率 + +### 4.2 资源指标 +- CPU +- 内存 +- 网络 +- 存储 +- GC + +### 4.3 瓶颈点 +- 首个瓶颈组件 +- 临界流量 +- 触发条件 + +## 五、故障演练结果 +- 杀 Gateway +- Kafka broker 故障 +- DB 切主 +- Redis 切主 + +## 六、结论 +- 当前最大安全容量 +- 建议上线容量 +- 风险点 + +## 七、改进项 +| 改进项 | Owner | 截止时间 | +|---|---|---| +``` + +## 10.2 输出结论要明确 + +压测报告不能只说“系统正常”,必须给出: + +- **当前最大安全在线数** +- **当前最大安全 QPS** +- **哪一层先满** +- **扩容建议** +- **大促前建议冗余倍数** + +--- + +# 11. 大促压测实战 + +## 11.1 大促前目标 + +例如双十一前,要求: +- 平峰 3 倍容量 +- 峰值 1.5 倍冗余 +- 核心链路全部压过一遍 +- 关键故障演练至少 3 次 + +## 11.2 大促特征流量 + +- 系统通知多 +- 群消息多 +- push 多 +- 登录重连多 +- 客服/机器人多 + +所以压测要重点覆盖: + +1. 大群广播 +2. push 高峰 +3. 重连风暴 +4. Kafka 消费 lag 恢复 +5. Redis 热 key 抗性 + +## 11.3 推荐流程 + +``` +T-30 天:完成单模块压测 +T-21 天:完成全链路压测 +T-14 天:完成第一次故障演练 +T-7 天:完成复测 +T-3 天:冻结核心变更 +T-1 天:小流量拨测 + 监控确认 +``` + +--- + +# 12. 风险控制与回滚 + +## 12.1 压测风险 + +- 误入真实链路 +- 打爆共享中间件 +- 触发真实告警风暴 +- 厂商 push 误触发 +- 影子数据堆积影响存储 + +## 12.2 风险控制措施 + +1. 影子链路强隔离 +2. 压测前确认告警降噪 +3. 设置全局熔断阈值 +4. 压测客户端白名单 +5. 压测窗口提前报备 +6. SRE 现场值守 + +## 12.3 回滚/停止压测条件 + +满足任一立即停压: + +- 生产用户错误率上升 +- 核心服务 CPU > 90% 持续 5 分钟 +- Kafka lag 持续增长且不可恢复 +- Redis 命中率崩塌 +- DB 主从延迟失控 +- 误触真实 push + +## 12.4 一键停压 + +压测平台必须支持: +- 停止新请求 +- 逐步断开压测连接 +- 停止流量回放 +- 清理影子数据 + +--- + +# 13. 常用脚本与命令 + +## 13.1 Kafka 性能测试 + +```bash +kafka-producer-perf-test.sh \ + --topic pt.msg.fanout.normal \ + --num-records 1000000 \ + --record-size 512 \ + --throughput -1 \ + --producer-props bootstrap.servers=kafka:9092 acks=all compression.type=lz4 +``` + +## 13.2 Redis 压测 + +```bash +memtier_benchmark \ + --server=redis-host \ + --port=6379 \ + --protocol=redis \ + --clients=100 \ + --threads=8 \ + --test-time=60 \ + --ratio=1:10 +``` + +## 13.3 MySQL 压测 + +```bash +sysbench oltp_write_only \ + --mysql-host=db-host \ + --mysql-port=3306 \ + --mysql-user=test \ + --mysql-password=xxx \ + --mysql-db=im \ + --tables=16 \ + --table-size=1000000 \ + --threads=64 \ + --time=300 run +``` + +## 13.4 K6 WebSocket 示例 + +```javascript +import ws from 'k6/ws'; +import { check } from 'k6'; + +export default function () { + const url = 'wss://pt-im.example.com/ws'; + const res = ws.connect(url, {}, function (socket) { + socket.on('open', function () { + socket.send(JSON.stringify({ cmd: 'login', token: 'test-token' })); + socket.setInterval(function () { + socket.send(JSON.stringify({ cmd: 'heartbeat' })); + }, 30000); + + socket.setTimeout(function () { + socket.send(JSON.stringify({ + cmd: 'send_msg', + conv_id: 123, + content: 'hello' + })); + }, 1000); + }); + + socket.on('message', function (data) { + // 校验消息 + }); + + socket.on('close', function () { + // 连接关闭 + }); + }); + + check(res, { 'status is 101': (r) => r && r.status === 101 }); +} +``` + +## 13.5 自研 Agent 命令 + +```bash +./im-pt-agent \ + --dispatch=https://pt-dispatch.example.com \ + --users=100000 \ + --regions=east,south \ + --scenario=send_and_receive \ + --msg-rate=50000 \ + --group-ratio=0.3 \ + --offline-ratio=0.4 \ + --duration=30m +``` + +--- + +# 总结 + +> 全链路压测不是“把 QPS 打上去看看”,而是: +> +> - 用**真实流量模型**模拟真实 IM 业务 +> - 通过**影子链路**保证生产安全 +> - 覆盖**接入、写入、Kafka、投递、同步、push**全链路 +> - 用**延迟分段、队列堆积、资源水位**定位第一个瓶颈点 +> - 最终输出**容量边界、瓶颈组件、扩容建议、风险清单** +> +> 对千万并发 IM 来说,压测必须是一套**长期机制**,而不是上线前一次性动作。 + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git a/_drafts/IM/IM-Client-SDK-Design-v1.0.md b/_drafts/IM/IM-Client-SDK-Design-v1.0.md new file mode 100755 index 000000000..0385d91b8 --- /dev/null +++ b/_drafts/IM/IM-Client-SDK-Design-v1.0.md @@ -0,0 +1,1138 @@ +# IM 客户端 SDK 设计文档 v1.0 + +> 适用平台: iOS / Android / Web / Electron / 小程序 +> 设计目标: 协议无感、本地一致、弱网鲁棒、跨端体验一致 + +--- + +## 目录 + +1. [SDK 架构](#1-sdk-架构) +2. [协议处理层](#2-协议处理层) +3. [连接管理](#3-连接管理) +4. [本地存储](#4-本地存储) +5. [消息收发](#5-消息收发) +6. [推拉协同](#6-推拉协同) +7. [消息合并与去重](#7-消息合并与去重) +8. [离线与重连](#8-离线与重连) +9. [性能优化](#9-性能优化) +10. [API 设计](#10-api-设计) + +--- + +# 1. SDK 架构 + +## 1.1 分层 + +``` +┌─────────────────────────────────────────┐ +│ 应用层 API │ +│ send / on_message / fetch_history ... │ +└──────────────┬──────────────────────────┘ + │ +┌──────────────▼──────────────────────────┐ +│ 业务层 │ +│ Conversation / Message / Sync / Read │ +└──────────────┬──────────────────────────┘ + │ +┌──────────────▼──────────────────────────┐ +│ 本地存储 │ +│ SQLite / IndexedDB / Realm │ +└──────────────┬──────────────────────────┘ + │ +┌──────────────▼──────────────────────────┐ +│ 传输层 │ +│ Protocol Codec / WebSocket / QUIC │ +└──────────────┬──────────────────────────┘ + │ +┌──────────────▼──────────────────────────┐ +│ 网络层 │ +│ Connection / Reconnect / Heartbeat │ +└─────────────────────────────────────────┘ +``` + +## 1.2 核心模块 + +| 模块 | 职责 | +|---|---| +| `ConnectionManager` | 长连接管理、协议选择、重连 | +| `ProtocolCodec` | 编解码、加解密、压缩 | +| `MessageManager` | 消息收发、合并、去重 | +| `SyncManager` | 增量同步、补拉、对账 | +| `ConversationManager` | 会话管理、未读、置顶 | +| `LocalStore` | SQLite 持久化 | +| `EventBus` | 事件分发到 UI | +| `LogManager` | 日志收集与上报 | +| `ConfigManager` | 配置同步、灰度 | + +## 1.3 设计原则 + +- **本地优先**:UI 显示永远基于本地 DB +- **异步上报**:UI 操作立即响应,网络异步 +- **乐观更新**:发送即显示"sending",回执后更新状态 +- **去重为王**:每个去重点都不能省 +- **协议无关**:业务层不感知 WS/QUIC + +--- + +# 2. 协议处理层 + +## 2.1 协议帧编解码 + +参考主规范 §3.2 的协议格式。SDK 实现: + +```typescript +class ProtocolCodec { + encode(cmd: number, body: Uint8Array, opts?: EncodeOpts): Uint8Array { + const seqId = this.nextSeqId(); + let flags = 0; + let bodyToSend = body; + + if (opts?.compress && body.length > 4096) { + bodyToSend = zstdCompress(body); + flags |= FLAG_COMPRESSED; + } + + const buf = new ArrayBuffer(18 + bodyToSend.length); + const view = new DataView(buf); + view.setUint8(0, 0x4D); + view.setUint8(1, PROTO_VERSION); + view.setUint16(2, cmd); + view.setUint16(4, flags); + view.setBigUint64(6, BigInt(seqId)); + view.setUint32(14, bodyToSend.length); + new Uint8Array(buf, 18).set(bodyToSend); + return new Uint8Array(buf); + } + + decode(data: Uint8Array): Frame { + const view = new DataView(data.buffer); + if (view.getUint8(0) !== 0x4D) throw new Error("bad magic"); + + const version = view.getUint8(1); + const cmd = view.getUint16(2); + const flags = view.getUint16(4); + const seqId = view.getBigUint64(6); + const length = view.getUint32(14); + + let body = data.subarray(18, 18 + length); + if (flags & FLAG_COMPRESSED) { + body = zstdDecompress(body); + } + + return { version, cmd, flags, seqId, body }; + } +} +``` + +## 2.2 请求-响应匹配 + +```typescript +class RequestManager { + private pending = new Map(); + + async request(cmd: number, body: Uint8Array, timeout = 10000): Promise { + const seqId = this.codec.nextSeqId(); + const frame = this.codec.encode(cmd, body); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(seqId); + reject(new TimeoutError()); + }, timeout); + + this.pending.set(seqId, { resolve, reject, timer }); + this.connection.send(frame); + }); + } + + onResponse(frame: Frame) { + const req = this.pending.get(frame.seqId); + if (!req) return; + clearTimeout(req.timer); + this.pending.delete(frame.seqId); + req.resolve(this.parseResponse(frame)); + } +} +``` + +## 2.3 服务器推送处理 + +```typescript +class PushHandler { + onPush(frame: Frame) { + switch (frame.cmd) { + case CMD_MSG_PUSH: + this.messageManager.onMessage(decodeMsg(frame.body)); + break; + case CMD_SYNC_NOTIFY: + this.syncManager.onSyncNotify(decode(frame.body)); + break; + case CMD_READ_NOTIFY: + this.conversationManager.onReadUpdate(...); + break; + case CMD_KICK: + this.connectionManager.kicked(...); + break; + } + } +} +``` + +## 2.4 协议升级与兼容 + +```typescript +// 客户端在 LOGIN 时上报支持的版本 +{ + "client_version": "3.5.0", + "proto_version": 2, + "features": ["quic", "compression", "batch_send"] +} + +// 服务端返回当前支持的功能集 +{ + "proto_version": 2, + "enabled_features": [...] +} +``` + +客户端根据 `enabled_features` 决定走哪些功能。 + +--- + +# 3. 连接管理 + +## 3.1 协议选择策略 + +```typescript +class ConnectionStrategy { + async connect(): Promise { + // 1. 先查上次成功的协议 + const lastSuccess = this.storage.getLastProtocol(); + + // 2. 并行尝试 QUIC + WSS (Happy Eyeballs) + const promises = [ + this.tryQUIC(), + sleep(200).then(() => this.tryWSS()), // WSS 延后 200ms + ]; + + // 3. 谁先成功用谁 + const conn = await Promise.race(promises); + + // 4. 缓存成功协议 + this.storage.setLastProtocol(conn.protocol); + return conn; + } +} +``` + +## 3.2 重连策略 + +```typescript +class Reconnector { + private attempt = 0; + private maxBackoff = 30000; + + async reconnect() { + while (!this.connected) { + const delay = this.computeBackoff(); + await sleep(delay); + + try { + await this.connect(); + this.attempt = 0; + this.eventBus.emit('reconnected'); + } catch (e) { + this.attempt++; + } + } + } + + private computeBackoff(): number { + // 指数退避 + 抖动 + const base = Math.min( + this.maxBackoff, + 1000 * Math.pow(2, this.attempt) + ); + return base / 2 + Math.random() * base / 2; + } +} +``` + +## 3.3 心跳 + +```typescript +class HeartbeatManager { + private interval = 30000; + private timeoutTimer: any; + + start() { + this.timer = setInterval(() => this.ping(), this.interval); + } + + private async ping() { + try { + const start = Date.now(); + await this.codec.request(CMD_HEARTBEAT, encode({ + client_ts: start, + sequence: this.seq++ + })); + const rtt = Date.now() - start; + + // 自适应:RTT 高时降低间隔 + this.adjustInterval(rtt); + } catch (e) { + this.connection.close('heartbeat_failed'); + } + } + + private adjustInterval(rtt: number) { + if (rtt < 100) { + this.interval = 60000; // 网络好,降频 + } else if (rtt > 500) { + this.interval = 15000; // 弱网,加频 + } + } +} +``` + +## 3.4 连接状态机 + +``` +DISCONNECTED → CONNECTING → AUTHENTICATING → CONNECTED + ▲ │ + │ ▼ + └────────────────────────────────── DISCONNECTING + │ + ▼ + KICKED +``` + +每次状态变化通�� EventBus 通知 UI: +```typescript +eventBus.emit('connection_state', { from, to }); +``` + +## 3.5 多端互踢 + +```typescript +onPush(frame) { + if (frame.cmd === CMD_KICK) { + const reason = decode(frame.body).reason; + if (reason === 'logged_in_elsewhere') { + this.eventBus.emit('kicked_off', reason); + this.cleanup(); + // 不自动重连,等用户手动 + } + } +} +``` + +--- + +# 4. 本地存储 + +## 4.1 数据库表设计 + +### conversations 表 +```sql +CREATE TABLE conversations ( + conv_id INTEGER PRIMARY KEY, + conv_type INTEGER, -- 1:single 2:group 3:channel + name TEXT, + avatar TEXT, + last_msg_id INTEGER, + last_msg_preview TEXT, + last_msg_time INTEGER, + + max_visible_seq INTEGER DEFAULT 0, -- 已知最大 seq + read_visible_seq INTEGER DEFAULT 0, + read_mention_seq INTEGER DEFAULT 0, + + unread_count INTEGER DEFAULT 0, + mention_unread INTEGER DEFAULT 0, + + is_muted INTEGER DEFAULT 0, + is_pinned INTEGER DEFAULT 0, + + draft TEXT, + updated_at INTEGER +); + +CREATE INDEX idx_conv_updated ON conversations(updated_at DESC); +``` + +### messages 表 +```sql +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conv_id INTEGER NOT NULL, + visible_seq INTEGER, -- 服务端可见 seq + global_seq INTEGER, -- 全局 seq + server_msg_id INTEGER, -- 服务端 ID + client_msg_id TEXT, -- 客户端 ID + + sender_id INTEGER, + msg_type INTEGER, + content TEXT, -- JSON + + status INTEGER DEFAULT 0, -- 0:sending 1:success 2:failed 3:recalled + send_time INTEGER, + recv_time INTEGER, + + has_mention INTEGER DEFAULT 0, + is_mention_me INTEGER DEFAULT 0, + + reply_to_id INTEGER, + + UNIQUE(conv_id, visible_seq), + UNIQUE(server_msg_id) +); + +CREATE INDEX idx_msg_conv_seq ON messages(conv_id, visible_seq DESC); +CREATE INDEX idx_msg_client_id ON messages(client_msg_id); +CREATE INDEX idx_msg_status ON messages(status) WHERE status = 0; -- 找未发送 +``` + +### sync_state 表 +```sql +CREATE TABLE sync_state ( + key TEXT PRIMARY KEY, + value TEXT +); + +-- 例: +-- key='global_event_seq', value='12345' +-- key='last_sync_time', value='1710000000' +``` + +### local_outbox 表(本地待发消息) +```sql +CREATE TABLE local_outbox ( + client_msg_id TEXT PRIMARY KEY, + conv_id INTEGER, + payload TEXT, -- 完整请求 body + retry_count INTEGER DEFAULT 0, + next_retry_at INTEGER, + created_at INTEGER +); +``` + +## 4.2 存储引擎 + +| 平台 | 引擎 | +|---|---| +| iOS | SQLite + GRDB | +| Android | SQLite + Room | +| Web | IndexedDB(自封装) | +| Electron | better-sqlite3 | +| 小程序 | 平台 KV / IndexedDB | + +## 4.3 加密存储 + +```typescript +// 敏感字段(消息内容)AES 加密 +const encryptedContent = aesEncrypt(content, deviceKey); +db.insert('messages', { content: encryptedContent }); + +// 设备密钥本地 Keychain / Keystore 存储 +``` + +## 4.4 容量管理 + +```typescript +class StorageManager { + async cleanup() { + const dbSize = await this.getDBSize(); + if (dbSize > MAX_DB_SIZE) { + // 清理 30 天前的非置顶会话消息 + await db.exec(` + DELETE FROM messages + WHERE recv_time < ? + AND conv_id NOT IN (SELECT conv_id FROM conversations WHERE is_pinned=1) + `, [Date.now() - 30 * 86400 * 1000]); + + // VACUUM 回收空间 + await db.exec('VACUUM'); + } + } +} +``` + +--- + +# 5. 消息收发 + +## 5.1 发送消息 + +### 完整流程 + +```typescript +async sendMessage(convId: number, content: MessageContent): Promise { + // 1. 生成 clientMsgId + const clientMsgId = generateClientMsgId(); + + // 2. 构造消息对象 + const msg: Message = { + clientMsgId, + convId, + senderId: this.userId, + content, + status: 'sending', + sendTime: Date.now(), + }; + + // 3. 立即写本地 DB(乐观更新) + await this.store.insertMessage(msg); + + // 4. 通知 UI + this.eventBus.emit('message_added', msg); + + // 5. 写本地 outbox(防进程退出丢消息) + await this.store.insertOutbox(msg); + + // 6. 异步发送 + this.sendInternal(msg); + + return msg; +} + +private async sendInternal(msg: Message) { + try { + const resp = await this.codec.request(CMD_SEND_MSG, encode({ + client_msg_id: msg.clientMsgId, + conv_id: msg.convId, + content: msg.content, + }), 15000); + + // 更新本地状态 + await this.store.updateMessage(msg.clientMsgId, { + serverMsgId: resp.server_msg_id, + visibleSeq: resp.visible_seq, + status: 'success', + recvTime: resp.server_time, + }); + + await this.store.deleteOutbox(msg.clientMsgId); + this.eventBus.emit('message_updated', msg); + + } catch (e) { + if (e instanceof RateLimitError) { + await this.store.updateMessage(msg.clientMsgId, { + status: 'failed', + failReason: 'rate_limited' + }); + } else if (e instanceof TimeoutError || e instanceof NetworkError) { + // 超时不立即标失败,等重连后从 outbox 重发 + this.scheduleRetry(msg); + } else { + await this.store.updateMessage(msg.clientMsgId, { + status: 'failed', + failReason: e.message + }); + } + } +} +``` + +### clientMsgId 生成 + +```typescript +function generateClientMsgId(): string { + // 设备ID + 本地自增 + 时间戳 + const localSeq = ++this.localSeq; + return `${this.deviceId}-${Date.now()}-${localSeq}`; +} + +// localSeq 持久化到 IndexedDB / SQLite +// 进程重启后从 max + 1000 继续(防冲突) +``` + +### 重试机制 + +```typescript +class OutboxRetrier { + async retryAll() { + if (!this.connected) return; + + const pending = await this.store.getOutbox(now); + for (const item of pending) { + try { + await this.sendInternal(item); + } catch (e) { + item.retryCount++; + if (item.retryCount > 5) { + // 标失败,停止重试 + await this.store.updateMessage(item.clientMsgId, { + status: 'failed' + }); + await this.store.deleteOutbox(item.clientMsgId); + } else { + // 指数退避 + const delay = Math.min(60000, 1000 * Math.pow(2, item.retryCount)); + item.nextRetryAt = now + delay; + await this.store.updateOutbox(item); + } + } + } + } +} + +// 重连成功 → retryAll +// 每分钟扫描一次 outbox +``` + +## 5.2 接收消息 + +### 推送处理 + +```typescript +async onMessagePush(push: MsgPush) { + // 1. 去重 + if (await this.store.messageExists(push.serverMsgId)) { + return; // 已经有了 + } + + // 2. 检查是否是自己发的回流(多端同步) + if (push.senderId === this.userId && push.clientMsgId) { + const existing = await this.store.findByClientMsgId(push.clientMsgId); + if (existing) { + // 合并:把本地 sending 消息更新为 success + await this.store.updateMessage(push.clientMsgId, { + serverMsgId: push.serverMsgId, + visibleSeq: push.visibleSeq, + status: 'success', + }); + return; + } + } + + // 3. 检查 seq 连续性 + const conv = await this.store.getConversation(push.convId); + const expectedSeq = conv.maxVisibleSeq + 1; + + if (push.visibleSeq > expectedSeq) { + // 有空洞,触发补拉 + this.syncManager.fetchRange( + push.convId, + conv.maxVisibleSeq, + push.visibleSeq + ); + } + + // 4. 入库 + await this.store.insertMessage({ + ...push, + status: 'success', + recvTime: Date.now(), + }); + + // 5. 更新会话 + await this.store.updateConversation(push.convId, { + maxVisibleSeq: Math.max(conv.maxVisibleSeq, push.visibleSeq), + lastMsg: this.makePreview(push), + lastMsgTime: push.sendTime, + unreadCount: this.computeUnread(conv, push), + }); + + // 6. 通知 UI + this.eventBus.emit('message_received', push); + + // 7. @ 处理 + if (this.isMentionMe(push)) { + this.eventBus.emit('mentioned', push); + } +} +``` + +## 5.3 消息状态显示 + +| 状态 | UI | +|---|---| +| `sending` | 灰色转圈 | +| `success` | 已送达 ✓ | +| `read` | 已读 ✓✓ | +| `failed` | 红色感叹号 + 重发按钮 | +| `recalled` | "X 撤回了一条消息" | + +--- + +# 6. 推拉协同 + +## 6.1 协同模型 + +``` +[实时通道] [拉取通道] + │ │ + ▼ ▼ +推送通知 (轻量, conv+seq) 按需拉取 + │ │ + └────────┬─────────────────────────┘ + ▼ + 本地按 seq 去重合并 +``` + +## 6.2 拉取策略 + +### 启动时同步 +```typescript +async onLogin() { + // 1. 拉取活跃会话变更列表 + const lastEventSeq = await this.store.getSyncState('global_event_seq'); + const events = await this.codec.request(CMD_SYNC_PULL, encode({ + since_event_seq: lastEventSeq + })); + + // 2. 对比每个会话的 maxSeq + for (const ev of events.conversations) { + const local = await this.store.getConversation(ev.convId); + if (!local || ev.maxVisibleSeq > local.maxVisibleSeq) { + // 拉取该会话增量 + await this.fetchConvIncremental(ev.convId, local?.maxVisibleSeq || 0); + } + } + + // 3. 更新游标 + await this.store.setSyncState('global_event_seq', events.maxEventSeq); +} +``` + +### 进入会话时检查 +```typescript +async openConversation(convId: number) { + // 立即从本地加载 + const messages = await this.store.getRecentMessages(convId, 50); + this.eventBus.emit('conversation_opened', { convId, messages }); + + // 后台对账 + const local = await this.store.getConversation(convId); + const server = await this.codec.request(CMD_GET_CONV_META, encode({ + conv_id: convId + })); + + if (server.maxVisibleSeq > local.maxVisibleSeq) { + await this.fetchConvIncremental(convId, local.maxVisibleSeq); + } +} +``` + +### 增量拉取 +```typescript +async fetchConvIncremental(convId: number, sinceSeq: number, limit = 200) { + while (true) { + const resp = await this.codec.request(CMD_PULL_MSG, encode({ + conv_id: convId, + since_seq: sinceSeq, + limit + })); + + for (const msg of resp.messages) { + await this.processIncomingMessage(msg); + } + + if (!resp.hasMore || resp.messages.length === 0) break; + sinceSeq = resp.messages[resp.messages.length - 1].visibleSeq; + } +} +``` + +### 历史漫游 +```typescript +async loadHistory(convId: number, beforeSeq: number, limit = 30) { + // 先查本地 + const local = await this.store.getMessagesBefore(convId, beforeSeq, limit); + if (local.length >= limit) return local; + + // 不够则查云端 + const remote = await this.codec.request(CMD_HISTORY, encode({ + conv_id: convId, + before_seq: local.length > 0 + ? local[local.length - 1].visibleSeq + : beforeSeq, + limit: limit - local.length + })); + + for (const msg of remote.messages) { + await this.store.insertMessage(msg); + } + + return [...local, ...remote.messages]; +} +``` + +## 6.3 推送通知 vs 拉取的边界 + +| 场景 | 用什么 | +|---|---| +| 在线收新消息 | 推送(实时) | +| 推送丢失检测 | 心跳时对比 maxSeq,差则拉 | +| 启动同步 | 拉取(推不可靠) | +| 历史漫游 | 拉取 | +| 进入会话校对 | 拉取 | +| 多端同步 | 推送 + 拉取双保险 | + +--- + +# 7. 消息合并与去重 + +## 7.1 去重维度 + +```typescript +// 优先级 +const DEDUP_KEYS = [ + 'serverMsgId', // 服务端唯一 ID + 'convId+visibleSeq', // 会话内 seq + 'clientMsgId', // 自己发的消息 +]; +``` + +## 7.2 自己发消息的合并 + +``` +本地状态: + [clientMsgId=X, status=sending] + +服务端 ack 回来: + 按 clientMsgId=X 查找 + → 找到 → 更新为 success + 补充 serverMsgId/seq + → 没找到 → 直接插入(可能本地清过) + +多端同步推回来: + 按 clientMsgId=X 查找 + → 找到 → 跳过(已经有了) + → 没找到 → 按 serverMsgId 查 + → 找到 → 跳过 + → 没找到 → 插入 +``` + +## 7.3 通用插入逻辑 + +```typescript +async upsertMessage(msg: Message) { + // 1. 按 clientMsgId 找(自己发的) + if (msg.clientMsgId) { + const existing = await db.query( + 'SELECT * FROM messages WHERE client_msg_id = ?', + [msg.clientMsgId] + ); + if (existing) { + return this.mergeMessage(existing, msg); + } + } + + // 2. 按 serverMsgId 找 + const existing = await db.query( + 'SELECT * FROM messages WHERE server_msg_id = ?', + [msg.serverMsgId] + ); + if (existing) { + return this.mergeMessage(existing, msg); + } + + // 3. 都没有,插入 + await db.exec( + 'INSERT OR IGNORE INTO messages (...) VALUES (...)', + [...] + ); +} + +private mergeMessage(existing, incoming) { + // 服务端字段以 incoming 为准(有更全数据) + // 本地状态以 existing 为准(如已读状态) + return { + ...existing, + serverMsgId: incoming.serverMsgId || existing.serverMsgId, + visibleSeq: incoming.visibleSeq || existing.visibleSeq, + status: existing.status === 'sending' ? 'success' : existing.status, + // ... + }; +} +``` + +## 7.4 撤回处理 + +```typescript +async onRecall(serverMsgId: number, operatorId: number) { + await db.exec(` + UPDATE messages + SET status = 'recalled', + content = json_set(content, '$.recalled', json_object('by', ?)) + WHERE server_msg_id = ? + `, [operatorId, serverMsgId]); + + this.eventBus.emit('message_recalled', { serverMsgId }); +} +``` + +UI 渲染时 `status='recalled'` 显示为系统消息。 + +## 7.5 编辑处理 + +```typescript +async onEdit(serverMsgId: number, newContent: any, version: number) { + const existing = await db.query( + 'SELECT version FROM messages WHERE server_msg_id = ?', + [serverMsgId] + ); + + if (existing && existing.version >= version) { + return; // 已经是新版或更新版,忽略 + } + + await db.exec(` + UPDATE messages + SET content = ?, version = ?, edited = 1 + WHERE server_msg_id = ? + `, [newContent, version, serverMsgId]); +} +``` + +--- + +# 8. 离线与重连 + +## 8.1 离线检测 + +```typescript +class NetworkMonitor { + constructor() { + // iOS: NWPathMonitor + // Android: ConnectivityManager + // Web: navigator.onLine + visibilitychange + + this.subscribeNetworkChange((status) => { + if (status.online) { + this.connectionManager.reconnect(); + } else { + this.connectionManager.markOffline(); + } + }); + } +} +``` + +## 8.2 重连后同步 + +```typescript +async onReconnected() { + // 1. 拉取离线期间的会话变更 + await this.syncManager.syncAll(); + + // 2. 重发本地 outbox 里的消息 + await this.outboxRetrier.retryAll(); + + // 3. 重新订阅状态 + await this.subscribeOnline(); + + // 4. 通知 UI + this.eventBus.emit('back_online'); +} +``` + +## 8.3 弱网优化 + +```typescript +class WeakNetworkOptimizer { + onWeakNetwork() { + // 1. 心跳频率提高 + this.heartbeat.setInterval(15000); + + // 2. 启用消息批量发送 + this.batchSender.enable(); + + // 3. 暂停非关键请求(如已读上报合并) + this.readReporter.batchMode(); + + // 4. UI 提示"网络较慢" + this.eventBus.emit('weak_network'); + } +} +``` + +## 8.4 后台 / 前台切换 + +```typescript +class LifecycleManager { + onAppBackground() { + // 1. 心跳间隔加大 + this.heartbeat.setInterval(180000); + + // 2. 暂停非必要任�� + this.syncManager.pauseNonCritical(); + } + + onAppForeground() { + // 1. 立即同步 + this.syncManager.syncAll(); + + // 2. 心跳恢复 + this.heartbeat.setInterval(30000); + + // 3. 重连(如果断了) + if (!this.connected) this.reconnect(); + } +} +``` + +--- + +# 9. 性能优化 + +## 9.1 批量操作 + +### 批量插入 +```typescript +async batchInsertMessages(msgs: Message[]) { + await db.transaction(async (tx) => { + const stmt = tx.prepare('INSERT OR IGNORE INTO messages (...) VALUES (...)'); + for (const msg of msgs) { + stmt.run(...); + } + }); +} +``` + +### 已读批量上报 +```typescript +class ReadReporter { + private pending = new Map(); // convId → maxSeq + private timer: any; + + report(convId: number, seq: number) { + const cur = this.pending.get(convId) || 0; + this.pending.set(convId, Math.max(cur, seq)); + + if (!this.timer) { + this.timer = setTimeout(() => this.flush(), 2000); + } + } + + private async flush() { + const reports = Array.from(this.pending.entries()); + this.pending.clear(); + this.timer = null; + + await this.codec.request(CMD_READ_REPORT, encode({ + items: reports.map(([convId, seq]) => ({ convId, seq })) + })); + } +} +``` + +## 9.2 UI 渲染优化 + +- 虚拟列表(VirtualList) +- 图片懒加载 + 占位 +- 消息分页加载(每次 30 条) +- 滚动时不更新本地未读 + +## 9.3 内存管理 + +```typescript +class MessageCache { + private cache = new LRUCache({ max: 50 }); + + // 内存只缓存最近打开的 50 个会话的消息 + // 其他会话从 DB 按需加载 +} +``` + +## 9.4 启动优化 + +``` +1. 立即用本地数据渲染会话列表 (< 100ms) +2. UI 可交互后再启动同步 +3. 同步分阶段:先头 20 个活跃会话,再全量 +4. 大量历史消息懒加载 +``` + +--- + +# 10. API 设计 + +## 10.1 核心 API + +```typescript +class IMClient { + // 连接 + async login(token: string): Promise; + async logout(): Promise; + + // 消息 + async sendMessage(convId: number, content: any): Promise; + async recallMessage(serverMsgId: number): Promise; + async editMessage(serverMsgId: number, newContent: any): Promise; + async resendMessage(clientMsgId: string): Promise; + + // 会话 + async getConversations(opts?: GetConvOpts): Promise; + async openConversation(convId: number): Promise; + async closeConversation(convId: number): Promise; + async getMessages(convId: number, beforeSeq?: number, limit?: number): Promise; + + // 已读 + async markAsRead(convId: number, seq?: number): Promise; + async getUnreadCount(): Promise<{total: number, byConv: Map}>; + + // 群组 + async createGroup(opts: CreateGroupOpts): Promise; + async joinGroup(groupId: number): Promise; + async leaveGroup(groupId: number): Promise; + async getGroupMembers(groupId: number): Promise; + + // 文件 + async uploadFile(file: File, opts?: UploadOpts): Promise; + + // 事件 + on(event: string, handler: Function): void; + off(event: string, handler: Function): void; +} +``` + +## 10.2 事件清单 + +| 事件 | 时机 | +|---|---| +| `connection_state` | 连接状态变化 | +| `message_received` | 收到新消息 | +| `message_sent` | 自己消息发送成功 | +| `message_failed` | 发送失败 | +| `message_recalled` | 消息被撤回 | +| `message_edited` | 消息被编辑 | +| `mentioned` | 被 @ | +| `read_update` | 对方已读状态更新 | +| `conversation_updated` | 会话信息变更 | +| `unread_changed` | 未读数变化 | +| `group_member_changed` | 群成员变动 | +| `kicked_off` | 被多端踢下线 | +| `back_online` | 重连成功 | +| `weak_network` | 弱网检测 | + +## 10.3 错误码 + +```typescript +enum ErrorCode { + OK = 0, + NETWORK_ERROR = 1001, + TIMEOUT = 1002, + AUTH_FAILED = 2001, + TOKEN_EXPIRED = 2002, + KICKED = 2003, + RATE_LIMITED = 3001, + BLOCKED = 3002, + NOT_IN_GROUP = 4001, + PERMISSION_DENIED = 4002, + CONTENT_TOO_LONG = 5001, + CONTENT_REJECTED = 5002, +} +``` + +--- + +# 文档维护 + +- 文档负责人:客户端架构组 +- 评审周期:版本发布前 +- 关联文档:协议规范、API 接口文档 + +*Version 1.0 | 最后更新:2026-05-04* \ No newline at end of file diff --git a/_drafts/IM/IM-Gateway-Design-v1.0.md b/_drafts/IM/IM-Gateway-Design-v1.0.md new file mode 100755 index 000000000..e1508cc57 --- /dev/null +++ b/_drafts/IM/IM-Gateway-Design-v1.0.md @@ -0,0 +1,1055 @@ +# IM Gateway 详细设计文档 v1.0 + +> 适用范围:千万并发 IM 接入网关 +> 单实例承载:5~10 万长连接 +> 集群规模:50~200 实例 + +--- + +## 目录 + +1. [职责与定位](#1-职责与定位) +2. [整体架构](#2-整体架构) +3. [连接管理](#3-连接管理) +4. [协议解析](#4-协议解析) +5. [QUIC 与连接迁移](#5-quic-与连接迁移) +6. [限流细节](#6-限流细节) +7. [心跳与保活](#7-心跳与保活) +8. [推送与下行链路](#8-推送与下行链路) +9. [优雅启停与故障处理](#9-优雅启停与故障处理) +10. [安全与防攻击](#10-安全与防攻击) +11. [性能调优](#11-性能调优) +12. [关键数据结构](#12-关键数据结构) + +--- + +# 1. 职责与定位 + +## 1.1 核心职责 + +- 维护客户端长连接(WebSocket / QUIC) +- 协议解析、加解密、压缩 +- 用户鉴权、会话维护 +- 上行消息:解析后转发到业务层 +- 下行消息:从业务层接收并推送到对应连接 +- 连接级限流、防刷 +- 心跳维护、断线检测 +- QUIC 连接迁移 +- 上报在线状态 + +## 1.2 不做什么 + +- 不做消息持久化(业务层负责) +- 不做消息路由决策(业务层 / 投递服务负责) +- 不做权限/风控决策(仅作信号上报) +- 不做内容审核 + +## 1.3 设计原则 + +- **单连接 = 单 goroutine/线程**(用户态调度)或 **epoll + worker pool** +- **零拷贝**:协议帧尽量直接转发到下游 +- **本地优先**:限流/状态判定走本地内存 +- **快速失败**:异常立即断开,不阻塞主循环 +- **无状态可重启**:连接状态不持久化(断了就重连) + +--- + +# 2. 整体架构 + +## 2.1 单��例内部模块 + +``` +┌─────────────────────────────────────────────────┐ +│ 网络层 (netpoll / epoll / QUIC stack) │ +└──────────────┬──────────────────────────────────┘ + │ + ┌───────▼───────┐ + │ 连接接收器 │ ← TLS 握手 / QUIC 握手 + └───────┬───────┘ + │ + ┌───────▼───────┐ + │ 鉴权与会话建立 │ ← Login + Token 验证 + └───────┬───────┘ + │ + ┌───────▼───────────────────────────┐ + │ 连接管理器 ConnManager │ + │ ┌───────────┬───────────┐ │ + │ │ ConnTable │ UserIndex │ │ + │ └───────────┴───────────┘ │ + └───────┬───────────────────────────┘ + │ + ┌───────▼───────────────────────────┐ + │ 帧解析器 + 限流器 │ + └───────┬───────────────────────────┘ + │ + ┌───────▼───────────────────────────┐ + │ 上行调度 → 业务层 (RPC/MQ) │ + └───────────────────────────────────┘ + + ┌───────────────────────────────────┐ + │ 下行调度 ← 投递服务 (RPC/MQ) │ + └───────┬───────────────────────────┘ + │ + ┌───────▼───────────────────────────┐ + │ 推送编码器 + 写出 │ + └───────────────────────────────────┘ +``` + +## 2.2 进程模型 + +推荐 Go / Rust / C++ 实现: +- Go: `gnet` / `netpoll` + goroutine pool +- Rust: `tokio` + `quinn` (QUIC) +- C++: 自研 epoll + 线程池 + +## 2.3 部署形态 + +- 单实例:16C32G,处理 5~10 万连接 +- 端口:443 (TCP for WSS) + 443 (UDP for QUIC) +- 协议:WSS / HTTP3+WebTransport / 自研 QUIC + +--- + +# 3. 连接管理 + +## 3.1 连接生命周期 + +``` +[CONNECTING] → [HANDSHAKE] → [AUTHENTICATING] → [ESTABLISHED] + │ + ┌───────┼────────┐ + ▼ ▼ ▼ + [ACTIVE] [IDLE] [MIGRATING] + │ + ▼ + [CLOSING] → [CLOSED] +``` + +## 3.2 连接表数据结构 + +### 主表 `ConnTable` +```go +type Connection struct { + ConnID uint64 // 全局唯一连接 ID + UserID int64 + DeviceID string + Protocol uint8 // 1:WS 2:QUIC + RemoteAddr string + + Socket net.Conn // 或 quic.Stream + ReadBuf []byte + WriteBuf chan []byte // 异步写 + + LoginAt int64 + LastActiveAt int64 // 最后活跃时间 + LastPingAt int64 + + State uint8 // 状态机 + + // 限流 + MsgBucket *TokenBucket + SignalBucket *TokenBucket + + // 关闭 + closeCh chan struct{} + closeOnce sync.Once +} + +type ConnTable struct { + sync.RWMutex + conns map[uint64]*Connection // connID → conn +} +``` + +### 用户索引 `UserIndex` +```go +type UserIndex struct { + sync.RWMutex + // userId → [deviceId → connId] + byUser map[int64]map[string]uint64 +} +``` + +### 容量 +``` +单实例 10万连接: +- ConnTable: 10万 × 1KB = 100MB +- 缓冲区: 10万 × 64KB = 6.4GB(read+write buf) +- 总内存预算: 16~24GB(含 GC 余量) +``` + +## 3.3 连接 ID 生成 + +```go +ConnID = (instanceID << 48) | (timestampMS << 16) | sequence +``` + +- `instanceID` 16bit:网关实例 ID(来自服务发现) +- `timestampMS` 32bit:毫秒时间戳 +- `sequence` 16bit:实例内自增 + +保证: +- 全局唯一 +- 包含网关身份(便于排查) +- 有序 + +## 3.4 单用户多端策略 + +```go +// 同一 userId+deviceId 重复登录:踢老连接 +existing := userIndex.Get(userId, deviceId) +if existing != nil { + sendKick(existing, "logged_in_elsewhere") + closeConnection(existing) +} + +// 同一 userId 不同 device:共存 +// 限制:单用户最多 5 个设备 +if userIndex.DeviceCount(userId) >= 5 { + sendKick(oldestDevice, "too_many_devices") +} +``` + +## 3.5 连接关闭 + +```go +func (c *Connection) Close(reason string) { + c.closeOnce.Do(func() { + // 1. 状态机切到 CLOSING + c.State = CLOSING + + // 2. 通知业务层下线 + presenceClient.Offline(c.UserID, c.DeviceID, c.ConnID) + + // 3. 关闭网络 + c.Socket.Close() + + // 4. 移出表 + connTable.Remove(c.ConnID) + userIndex.Remove(c.UserID, c.DeviceID) + + // 5. 释放资源 + close(c.closeCh) + c.State = CLOSED + + // 6. 监控 + metrics.ConnClosed.Inc(reason) + }) +} +``` + +--- + +# 4. 协议解析 + +## 4.1 协议帧格式 + +``` ++----+--------+--------+----------+----------+------------------+ +| M | Ver | Cmd | Flags | SeqId | Length | +| 1B | 1B | 2B | 2B | 8B | 4B | ++----+--------+--------+----------+----------+------------------+ +| Body (Protobuf) | ++----------------------------------------------------------------+ + +总头长: 18 字节 +最大 Body: 1MB(超过断连) +``` + +## 4.2 Flags 位定义 + +``` +bit 0: COMPRESSED (1 = body 经 zstd 压缩) +bit 1: ENCRYPTED (1 = 业务层加密) +bit 2: PRIORITY (1 = 高优先级) +bit 3: ACK_REQUIRED (1 = 需要业务 ACK) +bit 4: BATCH (1 = body 包含多个子帧) +bit 5-15: 预留 +``` + +## 4.3 解析流程 + +```go +func parseFrame(reader io.Reader) (*Frame, error) { + header := make([]byte, 18) + if _, err := io.ReadFull(reader, header); err != nil { + return nil, err + } + + if header[0] != 0x4D { + return nil, ErrBadMagic + } + + f := &Frame{ + Version: header[1], + Cmd: binary.BigEndian.Uint16(header[2:4]), + Flags: binary.BigEndian.Uint16(header[4:6]), + SeqID: binary.BigEndian.Uint64(header[6:14]), + Length: binary.BigEndian.Uint32(header[14:18]), + } + + if f.Length > MAX_BODY_SIZE { + return nil, ErrBodyTooLarge + } + + f.Body = make([]byte, f.Length) + if _, err := io.ReadFull(reader, f.Body); err != nil { + return nil, err + } + + if f.Flags & FLAG_COMPRESSED != 0 { + f.Body, err = zstdDecompress(f.Body) + } + + return f, nil +} +``` + +## 4.4 错误处理 + +| 错误 | 处理 | +|---|---| +| Magic 错 | 立即断连 | +| Version 不支持 | 返回 ERR_VERSION,断连 | +| Body 超限 | 返回 ERR_TOO_LARGE,断连 | +| 解压失败 | 返回 ERR_DECOMPRESS,断连 | +| Protobuf 解析失败 | 返回 ERR_PROTOCOL,记录但不断连 | +| 限流命中 | 返回 ERR_RATE_LIMIT,不断连 | + +## 4.5 上下行队列 + +### 上行(接收) +```go +// 直接 RPC 转发到业务层(msg-write 服务) +func handleUplink(c *Connection, frame *Frame) { + switch frame.Cmd { + case CMD_SEND_MSG: + resp, err := msgWriteClient.Send(ctx, &SendMsgReq{...}) + sendDownlink(c, buildAck(frame.SeqID, resp)) + case CMD_HEARTBEAT: + sendPong(c) + case CMD_READ_REPORT: + // 异步转发,不等响应 + go counterClient.ReportRead(...) + } +} +``` + +### 下行(推送) +```go +// 单连接异步写队列 +func (c *Connection) sendAsync(data []byte) bool { + select { + case c.WriteBuf <- data: + return true + case <-time.After(100 * time.Millisecond): + // 写超时 → 慢消费者,断开 + c.Close("slow_consumer") + return false + } +} +``` + +写缓冲队列大小:单连接 256~1024 条。 +满了即认为客户端处理不过来,主动断开。 + +--- + +# 5. QUIC 与连接迁移 + +## 5.1 双协议支持 + +```go +type GatewayConfig struct { + WSPort int // 443 TCP + QUICPort int // 443 UDP +} + +// 同 443 端口:UDP 走 QUIC,TCP 走 WSS +``` + +## 5.2 QUIC 连接 ID 设计 + +``` +CID 格式 (16 bytes): ++--------+----------+------------+--------------------+ +| Ver | ServerID | Generation | Random + AES加密 | +| 1B | 2B | 1B | 12B | ++--------+----------+------------+--------------------+ + +ServerID: 网关实例 ID(用于 LB 路由) +Generation: CID 代际(扩缩容时区分) +后 12B: 加密随机数 +``` + +加密用 LB 与 Gateway 共享密钥,AES-128-ECB。 + +## 5.3 CID 生成与发放 + +```go +func newCID(serverID uint16, gen uint8) []byte { + cid := make([]byte, 16) + cid[0] = CID_VERSION + binary.BigEndian.PutUint16(cid[1:3], serverID) + cid[3] = gen + rand.Read(cid[4:16]) + + // 加密整体 (除 version) + aesEncrypt(sharedKey, cid[1:]) + return cid +} + +// 握手后发放 8 个备用 CID +func onHandshakeComplete(conn *quic.Connection) { + for i := 0; i < 8; i++ { + conn.SendNewConnectionID(newCID(...)) + } +} +``` + +## 5.4 迁移检测 + +```go +func onPacketReceived(conn *quic.Connection, pkt *Packet) { + if pkt.RemoteAddr != conn.RemoteAddr { + // 新地址 → 启动路径验证 + conn.StartPathValidation(pkt.RemoteAddr) + } +} + +func onPathValidated(conn *quic.Connection, newAddr net.Addr) { + log.Info("connection migrated", + "conn_id", conn.ConnID, + "old_addr", conn.RemoteAddr, + "new_addr", newAddr) + + conn.RemoteAddr = newAddr + + // 重要:业务状态保持不变 + // 不重新登录,不重新订阅 + metrics.MigrationSuccess.Inc() +} +``` + +## 5.5 LB 解 CID 路由(伪代码) + +```c +// eBPF/XDP 在 LB 内核态运行 +int xdp_quic_route(struct xdp_md *ctx) { + // 提取 UDP payload 第一个字节判断是否 QUIC + if (!is_quic(packet)) return XDP_PASS; + + // 提取 DCID + uint8_t dcid[16]; + extract_dcid(packet, dcid); + + // AES 解密 + aes_decrypt_ecb(shared_key, dcid + 1, dcid + 1); + + // 提取 server_id + uint16_t server_id = bpf_ntohs(*(uint16_t*)(dcid + 1)); + + // 查路由表 + struct backend *be = lookup_backend(server_id); + if (!be) return XDP_DROP; + + // 转发 + redirect_to(be); + return XDP_REDIRECT; +} +``` + +## 5.6 网关扩缩容时 CID 处理 + +### 扩容 +- 新网关启动后,分配新 ServerID +- 旧 CID 仍指向老网关,老连接不受影响 +- 新连接 CID 含新 ServerID,落到新网关 + +### 缩容 +1. 标记网关为"不接受新连接" +2. Dispatcher 不再返回该网关 +3. 等待存量连接自然消亡或心跳超时 +4. 30 分钟后强制下线 + +## 5.7 NAT Rebinding + +NAT 端口映射变更(IP 不变): + +```go +// 同 IP 不同端口 → 轻量验证 +func handleNATRebinding(conn *quic.Connection, newPort int) { + if conn.RemoteIP == newPort.IP { + // 简化路径验证 + conn.SendPathChallenge(quickValidation = true) + } +} +``` + +--- + +# 6. 限流细节 + +## 6.1 限流维度(Gateway 本地) + +| 维度 | 算法 | 阈值 | 超限处理 | +|---|---|---|---| +| 单 IP 连接数 | 计数 | 50 | 拒绝握手 | +| 单 IP 建连/秒 | 令牌桶 | 10/s | 拒绝握手 | +| 单连接消息频率 | 令牌桶 | 10/s 突发 30 | 丢包,10 次后断开 | +| 单连接信令频率 | 令牌桶 | 50/s | 丢包 | +| 单连接拉取频率 | 令牌桶 | 5/s | 返回 429 | +| 单连接出流量 | 滑动窗口 | 100KB/s | 限速 | +| 单连接入流量 | 滑动窗口 | 50KB/s | 断开 | + +## 6.2 令牌桶实现 + +```go +type TokenBucket struct { + capacity int64 + rate float64 // 令牌/秒 + tokens int64 // 当前令牌 + lastTime int64 // 上次更新 + mu sync.Mutex +} + +func (b *TokenBucket) TryAcquire(n int64) bool { + b.mu.Lock() + defer b.mu.Unlock() + + now := time.Now().UnixMilli() + elapsed := float64(now - b.lastTime) / 1000.0 + + b.tokens = min(b.capacity, b.tokens + int64(elapsed * b.rate)) + b.lastTime = now + + if b.tokens >= n { + b.tokens -= n + return true + } + return false +} +``` + +无锁版本(生产推荐): + +```go +type AtomicTokenBucket struct { + capacity int64 + rate int64 + state atomic.Uint64 // 高 32 位 tokens, 低 32 位 timestamp +} + +func (b *AtomicTokenBucket) TryAcquire(n int64) bool { + for { + old := b.state.Load() + tokens := int64(old >> 32) + last := int64(old & 0xFFFFFFFF) + + now := time.Now().Unix() + elapsed := now - last + tokens = min(b.capacity, tokens + elapsed * b.rate) + + if tokens < n { + return false + } + + newState := uint64(tokens - n) << 32 | uint64(now) + if b.state.CompareAndSwap(old, newState) { + return true + } + } +} +``` + +## 6.3 IP 限流 + +```go +type IPLimiter struct { + sync.RWMutex + connCount map[string]int // IP → 连接数 + connectRate map[string]*TokenBucket // IP → 建连令牌桶 +} + +func (l *IPLimiter) AllowConnect(ip string) bool { + l.RLock() + count := l.connCount[ip] + bucket := l.connectRate[ip] + l.RUnlock() + + if count >= MAX_CONN_PER_IP { + return false + } + if bucket != nil && !bucket.TryAcquire(1) { + return false + } + return true +} +``` + +## 6.4 限流命中后的处理 + +```go +func handleFrame(c *Connection, f *Frame) { + // 不同帧用不同桶 + var bucket *TokenBucket + switch f.Cmd { + case CMD_SEND_MSG: + bucket = c.MsgBucket + case CMD_HEARTBEAT, CMD_READ_REPORT: + bucket = c.SignalBucket + } + + if bucket != nil && !bucket.TryAcquire(1) { + c.RateLimitHit++ + sendError(c, f.SeqID, ERR_RATE_LIMITED) + + // 连续命中 10 次 → 断开(疑似攻击) + if c.RateLimitHit > 10 { + c.Close("rate_limit_abuse") + metrics.AbusiveConn.Inc() + } + return + } + + c.RateLimitHit = 0 + processFrame(c, f) +} +``` + +## 6.5 全局协调(防雪崩) + +接入层限流是本地,但要避免**集群层面的集体过载**: + +- 网关每秒上报本地 QPS 到中心 Prometheus +- 中央通过配置中心动态下调阈值 +- 紧急时全网关接收 `kill.rate_to=3` 配置,所有连接限速到 3/s + +--- + +# 7. 心跳与保活 + +## 7.1 心跳协议 + +```protobuf +message Heartbeat { + int64 client_ts = 1; // 客户端时间戳 + int32 sequence = 2; // 心跳序号 +} + +message HeartbeatAck { + int64 client_ts = 1; + int64 server_ts = 2; +} +``` + +## 7.2 心跳间隔策略 + +| 场景 | 客户端间隔 | 服务端超时 | +|---|---|---| +| WiFi 稳定 | 60s | 90s | +| 移动网络 | 30s | 45s | +| 弱网 | 15s | 30s | +| 后台 | 180~270s | 300s | + +客户端根据网络状况动态调整。 + +## 7.3 服务端心跳检测 + +```go +// 全局心跳扫描器,每秒扫一次 +func heartbeatChecker() { + ticker := time.NewTicker(1 * time.Second) + for range ticker.C { + now := time.Now().Unix() + connTable.Range(func(c *Connection) { + if now - c.LastActiveAt > IDLE_TIMEOUT { + c.Close("heartbeat_timeout") + } + }) + } +} +``` + +注意:用 **分桶轮询** 避免一次扫描全部连接: + +```go +// 把 10 万连接分到 60 个桶,每秒扫 1 个桶 +buckets[connID % 60].Range(...) +``` + +## 7.4 piggyback 优化 + +心跳包可以捎带: +- 已读上报(最大 read_seq) +- 客户端状态(前后台) +- 网络类型(WiFi/4G) + +减少独立请求次数。 + +--- + +# 8. 推送与下行链路 + +## 8.1 投递服务 → Gateway + +投递服务通过 RPC(如 gRPC)调用 Gateway: + +```protobuf +service GatewayPush { + rpc Push(PushRequest) returns (PushResponse); + rpc BatchPush(BatchPushRequest) returns (BatchPushResponse); +} + +message PushRequest { + int64 user_id = 1; + string device_id = 2; // 可空,空 = 推所有设备 + bytes frame = 3; // 已编码的帧 + int32 priority = 4; +} +``` + +## 8.2 Gateway 内部分发 + +```go +func (g *Gateway) Push(req *PushRequest) error { + // 1. 查本地连接表 + conns := userIndex.Get(req.UserID, req.DeviceID) + if len(conns) == 0 { + return ErrUserNotOnline // 投递服务收到后走离线流程 + } + + // 2. 推送到每个设备 + for _, conn := range conns { + if !conn.sendAsync(req.Frame) { + // 慢消费者已断开,下次会更新状态 + } + } + return nil +} +``` + +## 8.3 批量推送优化 + +群消息要给 1000 个在线成员推: + +```go +func (g *Gateway) BatchPush(req *BatchPushRequest) { + // 按设备并行推 + var wg sync.WaitGroup + sem := make(chan struct{}, 100) // 并发 100 + + for _, target := range req.Targets { + sem <- struct{}{} + wg.Add(1) + go func(t *Target) { + defer wg.Done() + defer func() { <-sem }() + g.PushOne(t) + }(target) + } + wg.Wait() +} +``` + +## 8.4 写缓冲管理 + +每个连接独立 channel: + +```go +WriteBuf chan []byte // size = 256 +``` + +写流程: + +```go +func (c *Connection) writeLoop() { + batch := make([][]byte, 0, 16) + timer := time.NewTimer(5 * time.Millisecond) + + for { + select { + case data := <-c.WriteBuf: + batch = append(batch, data) + // 批量聚合最多 5ms + if len(batch) >= 16 { + flush(c.Socket, batch) + batch = batch[:0] + } + case <-timer.C: + if len(batch) > 0 { + flush(c.Socket, batch) + batch = batch[:0] + } + timer.Reset(5 * time.Millisecond) + case <-c.closeCh: + return + } + } +} +``` + +## 8.5 慢消费者处理 + +``` +WriteBuf 满 → sendAsync 立即返回 false +连续 3 次满 → 断开连接 +监控指标: slow_consumer_count +``` + +--- + +# 9. 优雅启停与故障处理 + +## 9.1 启动流程 + +``` +1. 加载配置(端口、密钥、阈值) +2. 连接服务发现(etcd),注册自己 +3. 加载 SSL 证书 / QUIC 密钥 +4. 监听端口(443 TCP + 443 UDP) +5. 启动各 worker(连接 / 心跳 / 限流统计) +6. 健康检查 endpoint 返回 200 +7. 调度服务开始路由流量到本实例 +``` + +## 9.2 优雅关闭 + +``` +1. 健康检查返回 503(LB 摘除) +2. 拒绝新连接(仅 listen socket,已建连接不受影响) +3. 通知所有连接:发送 "server_shutdown" 提示 +4. 等待 30 秒(让客户端主动断开 + 重连到其他网关) +5. 关闭存量连接 +6. 等待业务请求结束(最多 60s) +7. 进程退出 +``` + +```go +func (g *Gateway) Shutdown(ctx context.Context) { + g.healthy = false + g.listener.Close() + + // 通知所有客户端 + connTable.Range(func(c *Connection) { + c.sendAsync(buildShutdownNotice()) + }) + + select { + case <-time.After(30 * time.Second): + case <-ctx.Done(): + } + + connTable.Range(func(c *Connection) { + c.Close("server_shutdown") + }) +} +``` + +## 9.3 故障自愈 + +| 故障 | 自愈机制 | +|---|---| +| 单连接卡死 | 心跳超时 → 关闭 | +| 写阻塞 | 写超时 100ms → 关闭 | +| 内存泄漏 | RSS 超阈值 → 触发 K8s 重启 | +| Goroutine 泄漏 | 监控 + alert | +| Socket 文件句柄耗尽 | ulimit + 监控 | + +## 9.4 panic recovery + +```go +func safeGo(fn func()) { + go func() { + defer func() { + if r := recover(); r != nil { + log.Error("panic", "stack", debug.Stack()) + metrics.PanicCount.Inc() + } + }() + fn() + }() +} +``` + +每个连接处理 goroutine 都用 safeGo 包裹。 + +--- + +# 10. 安全与防攻击 + +## 10.1 鉴权 + +``` +1. 客户端先调用 Login API(HTTPS)获取 access_token +2. WebSocket / QUIC 握手时 Header 携带 token +3. Gateway 验证 token(JWT 本地验签 / 调 auth 服务) +4. 验证通过 → ESTABLISHED + 失败 → 立即断连 +``` + +token 包含: +- userId +- deviceId +- expireAt +- signature + +## 10.2 防攻击措施 + +### TLS 强制 +``` +TLS 1.3 only +强制 HTTPS / WSS +QUIC 自带 TLS 1.3 +``` + +### DDoS 防御 +- L4 LB 前置防火墙 +- SYN cookies +- IP 限流 +- 异常行为加入临时黑名单 + +### CC 攻击 +- 单 IP 频率限流(建连 + 消息) +- 异常 token 惩罚(5 次失败 → IP 黑名单 1 小时) + +### 重放攻击 +- 业务层消息带 client_msg_id 幂等 +- 重要操作带 nonce + +### 协议混淆 +- Magic 字节校验 +- 版本号校验 +- 异常立即断开 + +## 10.3 黑名单 + +```go +type Blacklist struct { + sync.RWMutex + bannedIPs map[string]int64 // IP → expireAt + bannedUsers map[int64]int64 // userId → expireAt +} + +// 定期从风控系统同步 +func syncBlacklist() { + ticker := time.NewTicker(30 * time.Second) + for range ticker.C { + list, _ := riskClient.GetBlacklist() + blacklist.Replace(list) + } +} +``` + +--- + +# 11. 性能调优 + +## 11.1 关键参数 + +### Linux 内核 +```bash +# 文件句柄 +ulimit -n 1000000 + +# TCP 参数 +net.ipv4.tcp_keepalive_time = 60 +net.ipv4.tcp_keepalive_intvl = 10 +net.ipv4.tcp_keepalive_probes = 5 +net.core.somaxconn = 32768 +net.ipv4.tcp_max_syn_backlog = 32768 +net.core.netdev_max_backlog = 65535 + +# 端口范围 +net.ipv4.ip_local_port_range = 10000 65535 + +# UDP buffer (QUIC) +net.core.rmem_max = 16777216 +net.core.wmem_max = 16777216 +``` + +### Go 参数 +```go +runtime.GOMAXPROCS(runtime.NumCPU()) +debug.SetGCPercent(50) // 降低 GC 触发频率 +debug.SetMaxStack(8 * 1024 * 1024) +``` + +### JVM 参数(如用 Java/Netty) +``` +-XX:+UseG1GC +-XX:MaxGCPauseMillis=50 +-XX:+ParallelRefProcEnabled +-Xms16g -Xmx16g +``` + +## 11.2 性能基线 + +| 指标 | 目标 | +|---|---| +| 单实例连接数 | 5~10 万 | +| 单实例消息转发 QPS | 50K | +| CPU 使用率 | < 60% | +| 内存使用 | < 24GB | +| P99 转发延迟 | < 5ms | +| 心跳成功率 | > 99.9% | +| 连接成功率 | > 99% | + +## 11.3 Profiling + +- pprof:CPU / Heap / Goroutine +- 线上常态化采样 +- 异常时 dump + +--- + +# 12. 关键数据结构 + +## 12.1 Connection(已在 §3.2 给出) + +## 12.2 ConnTable 性能优化 + +千万连接的网关集群中,单实例 10 万连接的 map 操作要避免锁竞争。 + +### 分段锁 +```go +const SHARDS = 256 + +type ShardedConnTable struct { + shards [SHARDS]struct { + sync.RWMutex + m map[uint64]*Connection + } +} + +func (t *ShardedConnTable) Get(connID uint64) *Connection { + s := &t.shards[connID % SHARDS] + s.RLock() + defer s.RUnlock() + return s.m[connID] +} +``` + +## 12.3 内存池 + +```go +var bufPool = sync.Pool{ + New: func() interface{} { + return make([]byte, 4096) + }, +} + +// 读 buffer 复用,避免 GC 压力 +buf := bufPool.Get().([]byte) +defer bufPool.Put(buf) +``` + +--- + +# 文档维护 + +- 文档负责人:接入层架构组 +- 评审周期:季度 +- 关联文档:QUIC 网关运维手册、协议规范文档 + +*Version 1.0 | 最后更新:2026-05-04* \ No newline at end of file diff --git a/_drafts/IM/IM-RiskControl-Manual-v1.0_Version4.md b/_drafts/IM/IM-RiskControl-Manual-v1.0_Version4.md new file mode 100755 index 000000000..e35aa5d49 --- /dev/null +++ b/_drafts/IM/IM-RiskControl-Manual-v1.0_Version4.md @@ -0,0 +1,640 @@ +# IM 风控规则手册 v1.0 + +> 适用范围:消息层风控 +> 目标:识别恶意行为、保护用户体验、合规 + +--- + +## 目录 + +1. [风控总览](#1-风控总览) +2. [风险分类与威胁模型](#2-风险分类与威胁模型) +3. [完整规则清单](#3-完整规则清单) +4. [特征工程](#4-特征工程) +5. [模型设计](#5-模型设计) +6. [处置体系](#6-处置体系) +7. [A/B 实验](#7-ab-实验) +8. [运营管理](#8-运营管理) +9. [合规与审计](#9-合规与审计) + +--- + +# 1. 风控总览 + +## 1.1 风控目标 + +- 识别并拦截恶意账号 +- 减少用户骚扰 +- 保护平台合规 +- 不误伤正常用户(FP < 0.1%) + +## 1.2 风控架构 + +``` +业务事件 → Kafka (user.behavior) + │ + ├──→ 实时引擎 (Flink/Storm) + │ ├── 规则引擎 + │ └── 在线模型 + │ + ├──→ 离线引擎 (Spark) + │ ├── 特征聚合 + │ └── 模型训练 + │ + └──→ ES + ClickHouse (查询) + │ + └── 运营平台 +``` + +## 1.3 决策路径 + +``` +事件 → 特征提取 → 规则匹配 → 模型评分 → 决策融合 → 处置 → 反馈 + ↓ + 训练数据 +``` + +## 1.4 关键指标 + +| 指标 | 目标 | +|---|---| +| 召回率 | > 90% | +| 误伤率 (FP) | < 0.1% | +| 实时决策延迟 | P99 < 100ms | +| 规则生效时间 | < 1 分钟 | +| 模型迭代周期 | 周级 | + +--- + +# 2. 风险分类与威胁模型 + +## 2.1 风险分类 + +| 一级 | 二级 | 描述 | +|---|---|---| +| 滥发 | 群发广告 | 发送相同内容给大量用户 | +| 滥发 | 刷屏 | 短时间高频发消息 | +| 滥发 | 拉群轰炸 | 拉人进群发消息 | +| 骚扰 | 陌生人骚扰 | 给非好友高频发消息 | +| 骚扰 | 黄赌毒 | 涉黄涉赌涉毒内容 | +| 欺诈 | 钓鱼链接 | 诱导点击恶意链接 | +| 欺诈 | 冒充客服 | 伪装官方账号 | +| 欺诈 | 兼职刷单 | 诈骗类内容 | +| 暴恐 | 暴力 | 暴力血腥 | +| 暴恐 | 恐怖 | 涉恐内容 | +| 政治 | 涉政 | 违法违规政治内容 | +| 账号 | 盗号 | 异地异常登录 | +| 账号 | 养号 | 批量注册养号 | +| 账号 | 多账号 | 一人多号刷量 | +| 隐私 | 信息泄露 | 传播他人隐私 | +| 滥用 | 接口滥用 | 异常调用 API | + +## 2.2 威胁矩阵 + +| 威胁 | 频率 | 影响 | 防御优先级 | +|---|---|---|---| +| 群发广告 | 高 | 中 | P0 | +| 涉政涉黄 | 中 | 极高(合规) | P0 | +| 钓鱼诈骗 | 中 | 高(用户损失) | P0 | +| 刷屏 | 高 | 中 | P1 | +| 多账号协同 | 中 | 中 | P1 | +| 盗号 | 低 | 高 | P1 | +| 接口滥用 | 中 | 中 | P2 | +| 隐私泄露 | 低 | 高 | P2 | + +--- + +# 3. 完整规则清单 + +## 3.1 频率类规则 + +### R001: 单用户消息频率 +``` +触发条件: 单用户 1 分钟内发送 > 200 条消息 +处置: Level 2 降速 +窗口: 滑动 1min +``` + +### R002: 单用户对单收件人频率 +``` +触发条件: (sender, receiver) 1 分钟内 > 60 条 +处置: Level 2 降速 + 提示 +``` + +### R003: 单用户陌生人频率 +``` +触发条件: 单用户 10 分钟内主动给 > 20 个陌生人发消息 +处置: Level 3 临时封禁 1h +``` + +### R004: 加好友频率 +``` +触发条件: 单用户每天加好友 > 50 +处置: Level 2 限制 +``` + +### R005: 创建群频率 +``` +触发条件: 单用户每小时创建群 > 5 +处置: Level 2 限制 +``` + +### R006: 拉人入群频率 +``` +触发条件: 单用户每小时拉 > 100 人入群 +处置: Level 2 限制 +``` + +## 3.2 内容类规则 + +### R101: 关键词命中 +``` +触发条件: 消息含违禁词(黑名单库) +处置: 直接 BLOCK + Level 4 +分级: 政治/暴恐 → 永久;广告关键词 → 24h +``` + +### R102: URL 黑名单 +``` +触发条件: 消息含已知钓鱼/诈骗 URL +处置: BLOCK + Level 4 +``` + +### R103: 内容相似度 +``` +触发条件: 单用户 1 小时内发出相似度 > 90% 的消息 > 10 条给不同人 +算法: SimHash / MinHash +处置: Level 3 + 标记广告 +``` + +### R104: 联系方式诱导 +``` +触发条件: 消息含手机号 + 诱导加微信/QQ 模式 +处置: Level 2 + 人工审核 +``` + +### R105: 涉黄检测 +``` +触发条件: 文本/图片/视频涉黄 +模型: 文本 NLP / 图片 CNN +处置: BLOCK + Level 4 +``` + +### R106: 涉政检测 +``` +触发条件: 涉政内容 +处置: BLOCK + 立即上报 +``` + +### R107: 隐私信息扫描 +``` +触发条件: 消息含身份证号/银行卡/住址等 +处置: 提示用户 + 记录 +``` + +## 3.3 行为模式规则 + +### R201: 收发比异常 +``` +触发条件: 24h 内 (发送数 / 接收数) > 50 且发送 > 100 +含义: 只发不收 → 营销账号 +处置: Level 2 +``` + +### R202: 群发广告 +``` +触发条件: 1 小时内同一内容(相似度 > 80%)发给 > 10 个陌生人 +处置: Level 3 + 撤回所有相关消息 +``` + +### R203: 拉群轰炸 +``` +触发条件: 拉 > 30 人进群后 1 小时内有大量广告类消息 +处置: 解散群 + 创建者 Level 4 +``` + +### R204: 短时间多群活跃 +``` +触发条件: 5 分钟内在 > 20 个群发消息 +处置: Level 2 + 人工审核 +``` + +### R205: 时间分布异常 +``` +触发条件: 凌晨 0-5 点高频活动 > 7 天,且白天少 +含义: 机器人模式 +处置: Level 2 + 加验证码 +``` + +## 3.4 账号类规则 + +### R301: 异地登录 +``` +触发条件: 同一账号在 1 小时内出现在距离 > 1000km 的不同 IP +处置: 强制重新验证 +``` + +### R302: 同设备多账号 +``` +触发条件: 同一设备指纹登录过 > 5 个账号 +处置: Level 2 + 人工审核 +``` + +### R303: 批量注册 +``` +触发条件: 同一 IP 段 1 小时内注册 > 50 账号 +处置: 注册接口拦截 + 已注册账号 Level 3 +``` + +### R304: 新账号高频活动 +``` +触发条件: 注册 < 24h 内发消息 > 100 条 / 加好友 > 20 +处置: Level 3 +``` + +### R305: 设备风险标识 +``` +触发条件: 命中越狱/Root/模拟器/调试器 +处置: Level 2 + 风险评分加权 +``` + +### R306: 短时间多次登录失败 +``` +触发条件: 5 分钟内同账号失败 > 5 次 +处置: 锁定账号 30 分钟 +``` + +## 3.5 关系链规则 + +### R401: 异常加好友通过率 +``` +触发条件: 主动加好友 > 50 但通过率 < 5% +处置: Level 2 +``` + +### R402: 被拉黑率高 +``` +触发条件: 24h 内被 > 20 个不同用户拉黑 +处置: Level 3 +``` + +### R403: 被举报率高 +``` +触发条件: 24h 内被 > 10 个不同用户举报 +处置: 优先级人工审核 +``` + +### R404: 群内孤立 +``` +触发条件: 群内只发不互动 + 多次被踢 +处置: Level 1 标记 +``` + +## 3.6 IP / 网络规则 + +### R501: 机房 IP +``` +触发条件: 来自数据中心 IP 段 +处置: 风险加权 + 加验证码 +``` + +### R502: 代理 / VPN +``` +触发条件: 命中代理/VPN IP 库 +处置: 风险加权 +``` + +### R503: 高风险地区 +``` +触发条件: 来自高风险国家/地区 +处置: 风险加权 + 加强验证 +``` + +### R504: 单 IP 多账号 +``` +触发条件: 单 IP 同时活跃 > 10 个账号 +处置: Level 2 +``` + +## 3.7 接口滥用 + +### R601: API 调用异常 +``` +触发条件: 非 SDK UA / 异常调用模式 +处置: 接口拒绝 +``` + +### R602: 历史消息批量拉取 +``` +触发条件: 单账号每秒 > 5 次历史拉取 +处置: 接口限流 +``` + +### R603: 群成员爬取 +``` +触发条件: 短时间内查询 > 100 个群的成员 +处置: 接口拒绝 + Level 2 +``` + +--- + +# 4. 特征工程 + +## 4.1 特征体系 + +### 用户级特征 +``` +基础: + - 注册时长 + - 登录设备数 + - 设备类型分布 + - 主活跃地区 + - 实名状态 + +行为统计 (1h/24h/7d/30d): + - 消息发送量 + - 消息接收量 + - 收发比 + - 不同收件人数 + - 群活跃数 + - 加好友数 / 通过率 + - 被举报次数 + - 被拉黑次数 + +时序特征: + - 活跃时段分布 + - 消息间隔标准差 + - 活动密度 + +关系特征: + - 好友数 + - 群数 + - 二度关系数 + - 关系链密度 + +风险标签: + - 历史风险事件 + - 当前风险等级 + - 命中规则次数 +``` + +### 消息级特征 +``` +- 消息长度 +- 消息类型分布 +- 内容相似度(与该用户历史) +- URL 数量 +- @ 数量 +- 引用数 +- 是否含联系方式 +- 内容情感倾向 +- 关键词命中 +``` + +### 设备级特征 +``` +- 设备指纹(canvas/UA/分辨率/...) +- 是否模拟器/越狱 +- IP 类型 +- 设备使用账号数 +``` + +### 网络级特征 +``` +- IP 地理位置 +- IP 类型 (家宽/移动/机房) +- IP 关联账号数 +- IP 风险评分 +``` + +## 4.2 特征存储 + +| 类型 | 存储 | 更新频率 | +|---|---|---| +| 实时特征(1h 内) | Redis | 流式 | +| 短期聚合(1d) | Redis + ClickHouse | 准实时 | +| 长期特征(7d+) | ClickHouse + HBase | 离线 T+1 | +| 关系特征 | 图数据库 (Nebula) | 准实时 | + +## 4.3 特征工程实现 + +### 实时特征 (Flink) +``` +event → KeyedStream by userId + → SlidingWindow (1min, 1h) + → 聚合: count / distinct / sum + → 写 Redis: feature:{uid}:msg_count_1h +``` + +### 离线特征 (Spark) +```sql +-- 7 天行为统计 +SELECT + user_id, + COUNT(*) AS msg_7d, + COUNT(DISTINCT receiver_id) AS recv_7d, + AVG(content_length) AS avg_len_7d, + ... +FROM user_events +WHERE date BETWEEN d-7 AND d-1 +GROUP BY user_id; + +-- 写入 HBase +``` + +## 4.4 特征服务 + +``` +应用 → 特征服务 (gRPC) + ├── Redis (实时) + ├── HBase (离线) + └── 图查询 (关系) +``` + +性能:P99 < 50ms。 + +--- + +# 5. 模型设计 + +## 5.1 模型矩阵 + +| 场景 | 模型 | 训练数据 | 部署方式 | +|---|---|---|---| +| 营销账号识别 | XGBoost / LR | 历史封号样本 | 在线推理 | +| 钓鱼/诈骗内容 | BERT 微调 | 标注语料 | 在线推理 | +| 涉黄图片 | ResNet50 | 公开数据集+自建 | 在线推理 | +| 涉黄文本 | TextCNN | 自建语料 | 在线推理 | +| 多账号关联 | GraphSAGE | 行为图 | 离线 | +| 异常用户检测 | Isolation Forest | 全量行为 | 离线 | + +## 5.2 模型训练流程 + +``` +1. 数据准备 + - 正样本: 已确认违规的用户/消息 + - 负样本: 长期正常用户随机抽样 + - 比例 1:10 + +2. 特征处理 + - 离线特征 + 在线特征拼接 + - 缺失值填充 + - 归一化 + +3. 训练 + - XGBoost: 5 折交叉验证 + - 超参搜索: Bayesian Optimization + +4. 评估 + - AUC > 0.9 + - Precision@TopK + - 离线对比 baseline + +5. 上线 + - Shadow 模式 1 周 + - A/B 灰度 10% + - 持续监控 + - 全量 +``` + +## 5.3 在线推理 + +```python +# Triton / 自研推理服务 +def predict(features): + # 实时特征 + realtime = feature_svc.get_realtime(uid) + + # 离线特征 + offline = feature_svc.get_offline(uid) + + # 特征拼接 + x = concat(realtime, offline, msg_features) + + # 推理 + score = model.predict(x) + + # 决策 + if score > 0.95: return BLOCK + if score > 0.8: return CHALLENGE + if score > 0.5: return MONITOR + return PASS +``` + +## 5.4 模型监控 + +``` +- AUC / Precision / Recall (每日) +- 特征分布漂移 (每日) +- 推理延迟 (实时) +- 模型版本对比 +- 业务指标 (封号 / 误伤) +``` + +漂移检测触发重训:当主特征 PSI > 0.2 时报警。 + +--- + +# 6. 处置体系 + +## 6.1 处置等级 + +| 等级 | 名称 | 描述 | +|---|---|---| +| L0 | 监控 | 标记,不处置 | +| L1 | 提示 | 加验证码 | +| L2 | 降速 | 限流阈值减半 | +| L3 | 临时封禁 | 1h / 24h | +| L4 | 长期封禁 | 7d / 30d | +| L5 | 永久封号 | 不可恢复 | + +## 6.2 处置动作 + +### 消息层 +- BLOCK: 拒绝消息发送 +- DELAY: 延迟投递(疑似时观察) +- WITHHOLD: 暂存待审核 +- RECALL: 撤回已发出的消息 +- WARN: 发送警告系统消息 + +### 账号层 +- CAPTCHA: 加验证码 +- VERIFY: 强制实名/手机 +- LIMIT: 限制功能(不能加好友/建群) +- MUTE: 禁言 +- BAN: 封禁 + +### 关系层 +- BLOCK_FRIEND: 拒绝加好友 +- KICK_GROUP: 踢出群 +- DISBAND_GROUP: 解散群 + +## 6.3 决策融合 + +多个规则/模型同时命中时: + +```python +def merge_decisions(decisions): + # 取最严等级 + max_level = max(d.level for d in decisions) + + # 累加风险分 + total_score = sum(d.score for d in decisions) + + # 升级规则 + if len(decisions) >= 3 and max_level >= L2: + max_level = min(L5, max_level + 1) + + return Decision(level=max_level, score=total_score) +``` + +## 6.4 处置写入 + +``` +risk:user:{uid} = { + level: 3, + expire_at: timestamp, + rules_hit: ["R002", "R201"], + score: 0.85, + last_update: timestamp +} + +# 业务层每次操作前查询 +# Redis 命中即拦截 +``` + +## 6.5 申诉流程 + +``` +用户申诉 → 客服系统 → 风控复审 + ↓ + ┌───────┴───────┐ + ↓ ↓ + 解封 维持 + ↓ + 加入白名单 + (避免重复误伤) +``` + +--- + +# 7. A/B 实验 + +## 7.1 实验目的 + +- 验证新规则效果 +- 对比模型版本 +- 优化阈值 +- 评估处置策略 + +## 7.2 实验框架 + +``` +实验流量分桶: + hash(userId) % 100 + + 0-49: 对照组 (旧策略) + 50-99: 实验组 (新策略) + +实验组用户行为打标签: + ex diff --git a/_drafts/IM/IM-SRE-Runbook-v1.0.md b/_drafts/IM/IM-SRE-Runbook-v1.0.md new file mode 100755 index 000000000..fc45c3efc --- /dev/null +++ b/_drafts/IM/IM-SRE-Runbook-v1.0.md @@ -0,0 +1,743 @@ +# IM SRE 运维手册 v1.0 + +> 适用对象:SRE / 运维 / On-Call 工程师 +> 目的:快速响应、标准化处理 + +--- + +## 目录 + +1. [On-Call 制度](#1-on-call-制度) +2. [告警分级与响应](#2-告警分级与响应) +3. [监控大盘](#3-监控大盘) +4. [故障 Runbook](#4-故障-runbook) +5. [应急预案](#5-应急预案) +6. [变更管理](#6-变更管理) +7. [复盘机制](#7-复盘机制) +8. [常用工具与命令](#8-常用工具与命令) + +--- + +# 1. On-Call 制度 + +## 1.1 轮班制度 + +``` +On-Call 轮班: 每人 7 天 +主 On-Call: 1 人 (P0/P1 第一响应) +副 On-Call: 1 人 (主无响应时升级) +团队 Lead: P0 时介入 +``` + +## 1.2 响应 SLA + +| 级别 | 首次响应 | 介入处理 | 升级 | +|---|---|---|---| +| P0 | 5 min | 立即 | 主+副+Lead | +| P1 | 15 min | 30 min | 主+副 | +| P2 | 1 h | 4 h | 主 | +| P3 | 1 工作日 | 1 工作日 | 主 | + +## 1.3 工具准备 + +每个 On-Call 必备: +- 告警手机(24h 不静音) +- VPN 接入 +- 监控大盘账号 +- 跳板机权限 +- IM 群组(应急群常驻) +- 此手册(离线版) + +--- + +# 2. 告警分级与响应 + +## 2.1 P0 告警(业务大面积影响) + +| 触发条件 | 渠道 | +|---|---| +| 接入成功率 < 95% 持续 2 min | 电话 + IM + 邮件 | +| 消息送达率 < 99% 持续 5 min | 电话 + IM | +| P99 延迟 > 5s 持续 5 min | 电话 + IM | +| 主集群整体不可用 | 电话立即 | +| 数据库主库宕机 | 电话立即 | +| Kafka 不可用 | 电话立即 | + +### 响应动作 +``` +1. 5 min 内承认告警 + 进战时群 +2. 第一时间评估影响范围 +3. 决策: 修复 vs 回滚 vs 切流 +4. 同步业务 / 客服 / 高层 +5. 操作 + 验证 +6. 解除告警 +7. 启动复盘 +``` + +## 2.2 P1 告警(局部异常) + +| 触发条件 | +|---| +| 接入成功率 < 99% 持续 5 min | +| 单分片 DB 不可用 | +| 单 Kafka topic lag > 10万 | +| Redis 主从延迟 > 30s | +| Push 失败率 > 5% | + +## 2.3 P2 告警(潜在风险) + +| 触发条件 | +|---| +| 磁盘使用 > 80% | +| 内存使用 > 85% | +| 单实例连接数 > 90% | +| 慢查询数突增 | +| 异常封禁数突增 | + +## 2.4 告警自愈 + +可自愈场景: +- 单 Pod 异常 → K8s 自动重启 +- 单连接卡死 → 心跳超时关闭 +- Outbox 短暂滞留 → Worker 自动重试 +- Redis 主挂 → Sentinel 自动切主 + +不需人工介入,但要记录与统计。 + +--- + +# 3. 监控大盘 + +## 3.1 总览大盘(首页) + +``` +┌─────────────────────────────────────────────┐ +│ 实时业务指标 │ +│ 在线用户: 920万 消息 QPS: 45万 │ +│ P50: 80ms P99: 320ms │ +│ 送达率: 99.992% 错误率: 0.008% │ +└─────────────────────────────────────────────┘ +┌─────────────────────────────────────────────┐ +│ 各服务健康状态 (实例数 / 健康数) │ +│ Gateway: 50/50 ✓ │ +│ MsgWrite: 30/30 ✓ │ +│ Deliver: 20/20 ✓ │ +│ Push: 20/19 ⚠️ │ +│ ... │ +└─────────────────────────────────────────────┘ +┌─────────────────────────────────────────────┐ +│ 数据层水位 │ +│ MySQL: CPU 35% / IO 40% / Conn 50% │ +│ Redis: Mem 55% / Hit 99.2% │ +│ Kafka: Lag 1.2万 / Disk 60% │ +└─────────────────────────────────────────────┘ +``` + +## 3.2 核心子大盘 + +### 接入层大盘 +- 各 Gateway 连接数 +- 建连/秒 +- 协议分布(QUIC/WS) +- TLS 握手延迟 +- QUIC 迁移成功率 +- 心跳成功率 +- 各 Gateway CPU/内存/网络 + +### 消息链路大盘 +- 写入 QPS(按消息类型) +- 写入延迟分布 +- Outbox 堆积量 +- Kafka 各 topic 生产/消费速率 +- 各 partition lag +- 投递成功率(按目标网关) +- 离线收件箱写入速率 + +### 数据层大盘 +- MySQL:QPS、慢查询、连接数、主从延迟 +- Redis:QPS、内存、命中率、慢查询 +- Kafka:吞吐、ISR、Under-Replicated +- HBase:RegionServer 状态、Compaction + +### 业务大盘 +- DAU / 在线 +- 消息发送趋势 +- 群活跃度 +- @ 消息量 +- 撤回 / 编辑速率 +- Push 成功率(按厂商) +- 风控封禁数 + +### SLO 大盘 +- 各服务可用性(5min/1h/24h/7d) +- 错误预算消耗 +- 长期趋势 + +## 3.3 关键查询语句(PromQL) + +```promql +# 消息成功率 +sum(rate(msg_write_success[5m])) / sum(rate(msg_write_total[5m])) + +# 单 Gateway 连接数 TOP10 +topk(10, gateway_connection_count) + +# Kafka lag +sum by (topic, partition) (kafka_consumer_lag) + +# 各分片 MySQL CPU +mysql_global_status_threads_running{shard=~".*"} + +# Redis 命中率 +redis_keyspace_hits_total / (redis_keyspace_hits_total + redis_keyspace_misses_total) +``` + +--- + +# 4. 故障 Runbook + +## 4.1 Gateway 大量掉线 + +### 症状 +- 在线用户骤降 +- 客户端集中重连 +- 接入层 QPS 突增 + +### 排查 +``` +1. 看哪些 Gateway 异常 + kubectl get pods -l app=gateway | grep -v Running + +2. 看是否单 AZ / 单地域问题 + 按 AZ 分组的连接数监控 + +3. 看是否 LB 异常 + curl edge.im.example.com/health +``` + +### 处理 +``` +单 Pod 异常: K8s 自动重启,等待 +单 AZ 异常: 流量自动切到其他 AZ,无需操作 +LB 异常: 联系网络组 +全集群异常: 切流到其他区域,详见 §5.1 +``` + +## 4.2 消息延迟陡增 + +### 症状 +- P99 延迟 > 1s +- 客户端反馈"消息慢" + +### 排查 +``` +1. 链路打点:哪一段慢? + - Gateway → MsgWrite + - MsgWrite → DB + - Outbox → Kafka + - Kafka → Deliver + - Deliver → Gateway → Client + +2. 查 trace_id 追踪具体一条消息 + +3. 查各组件资源水位 +``` + +### 常见原因与处理 + +| 原因 | 处理 | +|---|---| +| DB 慢 | 看慢查询,KILL,优化 | +| DB 连接池满 | 加连接数 / 重启应用 | +| Kafka lag | 扩 consumer / 排查消费者 | +| Redis 慢 | 看 SLOWLOG,看大 key | +| GC 频繁 | dump heap,调 JVM | +| 网络丢包 | 联系网络组 | + +## 4.3 Kafka Topic Lag 飙升 + +### 症状 +- consumer group lag > 10万 +- 离线消息延迟、Push 延迟 + +### 排查 +```bash +# 看具体哪个 topic / partition lag +kafka-consumer-groups --describe --all-groups | sort -k5 -n | tail + +# 看消费者实例状态 +kubectl get pods -l app=consumer-xxx +``` + +### 处理 +``` +1. 消费实例不够 → 扩容 consumer + kubectl scale deploy consumer-xxx --replicas=20 + +2. 消费者代码慢 → 临时调大 max.poll.records + +3. 下游 DB 慢 → 看下游 + +4. 单 partition 热点 → 临时增加 partition (注意顺序) + +5. 实在追不上 → 临时跳过或降低消费精度 +``` + +## 4.4 数据库主库挂 + +### 症状 +- 写入失败 +- DB 监控告警 +- 应用大量超时 + +### 自动切主流程 +``` +MHA / Orchestrator 自动选主: +1. 探测主库不可用 (3 次失败 ≈ 30s) +2. 选取数据最新的从库 +3. 提升为新主 +4. 应用通过 DNS / VIP 切到新主 +5. 旧主修复后作为从加回 + +期间应用应自动重连 +``` + +### 手动切主(自动失败时) +```bash +# 1. 确认主库真的死了 +mysqladmin -h db15-master ping # connection refused +ssh db15-master 'systemctl status mysql' + +# 2. 选取新主(找最新的从) +mysql -h db15-slave1 -e "SHOW SLAVE STATUS\G" | grep Position + +# 3. 提升从为主 +mysql -h db15-slave1 -e "STOP SLAVE; RESET MASTER;" + +# 4. 修改其他从指向新主 +mysql -h db15-slave2 -e "CHANGE MASTER TO MASTER_HOST='db15-slave1', ..." + +# 5. 切换应用 DNS +update_dns "db15-master.im" → db15-slave1 +``` + +## 4.5 Redis 主挂 + +``` +Sentinel 自动切主: +1. 探测 30s 内 quorum 个 Sentinel 都认为主挂 +2. 选取从库提升为主 +3. 通知客户端 + +期间影响: +- 写入:30s 不可用 +- 读:从库仍可用 +``` + +应急:限流降级,DB 兜底。 + +## 4.6 大群消息洪水 + +### 症状 +- 单 conv 消息 QPS 异常飙升 +- 该 conv 的 partition lag 暴涨 +- 群成员投诉"消息延迟" + +### 处理 +``` +1. 临时提高该群消息限流到 2/s + curl config-center/api/set?key=rate.conv.{convId}&value=2 + +2. 该群移到大群专属 topic + curl config-center/api/set?key=large_conv:{convId}&value=true + +3. 必要时临时禁言群 + API: /admin/group/{convId}/mute?duration=10m +``` + +## 4.7 Push 大面积失败 + +### 症状 +- Push 失败率 > 10% +- 离线用户收不到通知 + +### 排查 +``` +1. 看是哪个厂商通道失败 + Push 监控按 channel 分组 + +2. 联系厂商确认(APNs/FCM/华为/小米/OPPO/vivo) +``` + +### 处理 +``` +- 单厂商挂 → 切到备用厂商 +- 多厂商挂(如海外 GFW 问题)→ 联系运营 +- token 过期批量 → 推动客户端升级 +``` + +## 4.8 攻击事件 + +### 症状 +- 单 IP 大量建连 +- 单用户高频发消息 +- 异常 token 请求 + +### 处理 +``` +1. 立即拉入黑名单 + curl risk/api/block_ip?ip=x.x.x.x&duration=24h + +2. 提高全局限流阈值(防扩散) + +3. 排查攻击模式 + 日志聚合分析 + +4. 上��安全组 +``` + +## 4.9 数据误删 / 误更新 + +``` +立即操作: +1. 停止应用写入相关表 +2. 记录误操作时间点 +3. 从 binlog 找到操作前状态 +4. 恢复数据 (基于备份+binlog重放) +5. 校验后恢复服务 + +工具: +- mysqlbinlog +- xtrabackup +- 自研回滚工具 +``` + +--- + +# 5. 应急预案 + +## 5.1 整地域切流 + +### 触发条件 +- 单地域 > 30% 服务不可用 +- 单地域机房断电 / 网络中断 +- 单地域不可恢复故障 > 30 min + +### 操作步骤 +``` +[决策] T+0 min + - On-Call 评估,与 Lead 确认 + - 通知业务方 + +[切流] T+5 min + - 修改 GSLB 配置 + 把 East 区流量切到 South 区 50% + - DNS TTL 已设 60s,1 分钟生效 + +[数据] T+10 min + - South 区 DB Standby 提升为主 + - 启动跨区数据同步追平 + +[观察] T+15 min + - 客户端重连成功率 + - 业务指标恢复 + +[完成] T+30 min + - 全量切完 + - 通知业务 + +恢复时反向操作。 +``` + +## 5.2 全站只读模式 + +### 触发条件 +- 写入链路严重故障 +- 数据一致性风险 +- 需要紧急维护 + +### 操作 +``` +1. 配置中心下发 force.read_only=true +2. 各服务收到后: + - 拒绝写消息(返回 503) + - 仍允许读 / 拉历史 +3. 客户端 UI 提示"系统维护中" +``` + +## 5.3 紧急限流降级 + +```bash +# 全局降级 +curl config-center/set \ + -d '{"key":"rate.global.qps","value":100000}' + +# 关闭非核心功能 +curl config-center/set -d '{"key":"kill.typing","value":true}' +curl config-center/set -d '{"key":"kill.read_receipt","value":true}' +curl config-center/set -d '{"key":"kill.large_group_fanout","value":true}' + +# 关闭历史搜索 +curl config-center/set -d '{"key":"kill.search","value":true}' +``` + +## 5.4 数据回填 / 补偿 + +某下游消费失败导致数据缺失: + +``` +1. 确认缺失范围 (时间窗 + 维度) +2. 从源(im_message / Kafka 历史)重放 +3. 重放时下游必须保证幂等 +4. 校验补齐成功 + +例:inbox 写入丢失 + Kafka 还在 → 重置 consumer offset 重新消费 + Kafka 已过期 → 从 im_message 扫描重新生成事件 +``` + +## 5.5 流量洪峰预案 + +大型活动 / 春节红包 / 突发热点: + +``` +赛前准备: +- 资源扩容 30~50% +- 限流阈值预调整 +- 大 V / 热点群预识别 +- 演练熔断/降级 + +赛中: +- 实时大盘观察 +- 必要时���动限流 +- 关闭非核心功能 + +赛后: +- 资源回收 +- 复盘 +``` + +--- + +# 6. 变更管理 + +## 6.1 变更分级 + +| 级别 | 影响 | 审批 | 窗口 | +|---|---|---|---| +| L0 | 配置 / 灰度 | 直接 | 任意 | +| L1 | 应用发布 | Team Lead | 工作时间 | +| L2 | DB 变更 | DBA + Lead | 业务低谷 | +| L3 | 架构变更 | 架构组 + 总监 | 计划窗口 | + +## 6.2 变更窗口 + +``` +✓ 推荐: 周二/周三 10:00-17:00 +⚠️ 谨慎: 周五下午(防止周末爆雷) +✗ 禁止: 节假日、大促前后、重大活动期间 +``` + +## 6.3 变更检查表 + +参考主规范文档 §14.6。 + +## 6.4 高风险变更(必须演练) + +- 协议升级 +- 数据库 schema 变更 +- Kafka topic 重组 +- 跨地域同步链路变更 +- 路由规则变更 + +--- + +# 7. 复盘机制 + +## 7.1 复盘原则 + +- 不追责,重总结 +- 时间线必须清晰 +- 根因分析(5 Why) +- 改进项可落地、可追踪 + +## 7.2 复盘模板 + +```markdown +# 故障复盘: {故障编号} + +## 基本信息 +- 时间: 2026-05-04 14:23 ~ 14:48 (25 min) +- 级别: P1 +- 影响: 华东区 5% 用户消息延迟 > 5s +- 责任团队: 消息组 + +## 时间线 +14:23 告警触发: msg_p99_latency > 5s +14:25 On-Call 进群 +14:28 定位: Kafka msg.fanout topic lag 暴涨 +14:32 发现: 某大群被刷消息 +14:35 执行: 群临时限流 +14:42 lag 恢复 +14:48 告警解除 + +## 根因 +- 直接原因: 大群 conv_id=xxx 被脚本刷消息 +- 根本原因: 大群限流阈值过高 (50/s) +- 触发条件: 攻击者使用慢速攻击绕过单用户限流 + +## 改进项 +| 编号 | 描述 | 负责人 | 截止 | +|---|---|---|---| +| 1 | 大群限流降至 20/s | @张三 | 2026-05-08 | +| 2 | 风控加入慢速刷消息检测 | @李四 | 2026-05-15 | +| 3 | Kafka 大群独立 topic | @王五 | 2026-05-30 | + +## 经验教训 +- 监控对单 conv 的消息速率不敏感,需补 +- 限流阈值需按群规模分级 +``` + +## 7.3 复盘频率 + +``` +P0: 24h 内复盘 +P1: 3 天内 +P2: 1 周 +重大变更: 必复盘 +``` + +--- + +# 8. 常用工具与命令 + +## 8.1 K8s + +```bash +# 看异常 Pod +kubectl get pods -A | grep -v Running + +# 查 Pod 日志 +kubectl logs -f gateway-xxx --tail=1000 + +# 进 Pod +kubectl exec -it gateway-xxx -- bash + +# 滚动重启 +kubectl rollout restart deploy/gateway + +# 扩容 +kubectl scale deploy/consumer --replicas=30 + +# 回滚 +kubectl rollout undo deploy/msg-write +``` + +## 8.2 MySQL + +```sql +-- 当前连接 +SHOW PROCESSLIST; + +-- 慢查询 +SELECT * FROM mysql.slow_log ORDER BY query_time DESC LIMIT 10; + +-- KILL 查询 +KILL ; + +-- 表大小 +SELECT table_name, table_rows, data_length/1024/1024 AS size_mb +FROM information_schema.tables +WHERE table_schema='im' ORDER BY size_mb DESC; + +-- 主从状态 +SHOW MASTER STATUS; +SHOW SLAVE STATUS\G +``` + +## 8.3 Redis + +```bash +# 监控 +redis-cli --stat + +# 慢查询 +redis-cli SLOWLOG GET 10 + +# 大 key 扫描 +redis-cli --bigkeys + +# 内存分析 +redis-cli MEMORY USAGE + +# 集群状态 +redis-cli CLUSTER INFO +redis-cli CLUSTER NODES +``` + +## 8.4 Kafka + +```bash +# topic 列表 +kafka-topics --list + +# topic 详情 +kafka-topics --describe --topic msg.fanout + +# consumer group lag +kafka-consumer-groups --describe --group cg-deliver + +# 重置 offset +kafka-consumer-groups --group cg-deliver \ + --topic msg.fanout --reset-offsets --to-datetime 2026-05-04T10:00:00.000 --execute + +# 看消息 +kafka-console-consumer --topic msg.fanout --from-beginning --max-messages 10 +``` + +## 8.5 网络排查 + +```bash +# 连接数 +ss -s +netstat -an | awk '/^tcp/ {S[$NF]++} END {for(a in S) print a, S[a]}' + +# 端口监听 +ss -tlnp + +# 抓包 +tcpdump -i eth0 -nn 'port 443' -w /tmp/cap.pcap + +# 网络延迟 +mtr edge.im.example.com +``` + +## 8.6 自研运维 API + +``` +# 切流 +POST /sre/api/switch_traffic + {"region":"east","target":"south","percent":50} + +# 应急开关 +POST /sre/api/emergency_switch + {"key":"kill.typing","value":true} + +# 强制踢用户 +POST /sre/api/kick_user + {"user_id":1001,"reason":"abuse"} + +# 黑名单 +POST /sre/api/blacklist + {"type":"ip","value":"x.x.x.x","duration":3600} +``` + +--- + +# 文档维护 + +- 文档负责人: SRE 组 +- 评审周期: 月度 +- 更新触发: 每次故障后必须更新对应 Runbook + +*Version 1.0 | 最后更新: 2026-05-04* \ No newline at end of file diff --git a/_drafts/IM/IM-Storage-Sharding-v1.0.md b/_drafts/IM/IM-Storage-Sharding-v1.0.md new file mode 100755 index 000000000..899d9cbcc --- /dev/null +++ b/_drafts/IM/IM-Storage-Sharding-v1.0.md @@ -0,0 +1,729 @@ +# IM 消息存储分库分表方案 v1.0 + +> 适用规模:日新增 100 亿消息 +> 存储介质:MySQL/TiDB(主存)+ HBase(冷存)+ ES(搜索) + +--- + +## 目录 + +1. [总体存储策略](#1-总体存储策略) +2. [分片策略](#2-分片策略) +3. [分片键选择](#3-分片键选择) +4. [冷热分离](#4-冷热分离) +5. [扩容方案](#5-扩容方案) +6. [数据迁移](#6-数据迁移) +7. [对账机制](#7-对账机制) +8. [备份与恢复](#8-备份与恢复) +9. [运维操作手册](#9-运维操作手册) + +--- + +# 1. 总体存储策略 + +## 1.1 数据分层 + +``` +┌──────────────────────────────────────────────┐ +│ 热数据 (0~7 天) │ +│ MySQL/TiDB 主表 │ +│ - 在线消息读写 │ +│ - 单消息 < 1KB │ +└──────────────────────────────────────────────┘ + │ 异步迁移 + ▼ +┌──────────────────────────────────────────────┐ +│ 温数据 (7~30 天) │ +│ MySQL/TiDB 归档表 + 缓存 │ +│ - 历史消息查询 │ +└──────────────────────────────────────────────┘ + │ 批量归档 + ▼ +┌──────────────────────────────────────────────┐ +│ 冷数据 (> 30 天) │ +│ HBase + 对象存储 │ +│ - 漫游 / 合规 / 审计 │ +└──────────────────────────────────────────────┘ + │ 索引同步 + ▼ +┌──────────────────────────────────────────────┐ +│ 搜索索引 │ +│ Elasticsearch │ +│ - 全文搜索 │ +└──────────────────────────────────────────────┘ +``` + +## 1.2 选型理由 + +| 层 | 选型 | 理由 | +|---|---|---| +| 热 | TiDB(推荐) | 自动分片、强一致、HTAP | +| 热 | MySQL(备选) | 成熟稳定、运维熟悉 | +| 温 | 同热层(不同表) | 避免冷数据撑爆主表 | +| 冷 | HBase | 海量、低成本、按 RowKey 顺序读 | +| 搜索 | Elasticsearch | 全文检索、聚合分析 | +| 文件 | OSS/S3 | 海量大文件、CDN | + +## 1.3 容量预估 + +``` +日消息量: 100 亿 +单消息平均: 512 字节 +日新增数据: 5TB(含索引) +30 天热+温: 150TB +1 年冷数据: 1.5PB + +单表上限 (MySQL): 5 亿行 +推荐单表 200GB 内 +``` + +--- + +# 2. 分片策略 + +## 2.1 分片维度 + +IM 消息有几个候选分片键: + +| 维度 | 优点 | 缺点 | +|---|---|---| +| `conv_id` | 同会话消息聚合,查询友好 | 大群单 conv 热点 | +| `user_id` | 用户消息聚合 | 群消息不直接归属用户 | +| `time` | 易归档 | 写热点(永远写最新分片) | +| `server_msg_id` | 完全打散 | 查询要全分片扫 | + +## 2.2 推荐方案:双分片键 + +主表按 `conv_id` 分片(业务读),收件箱按 `user_id` 分片(用户拉取)。 + +### 消息主表 `im_message`:按 `conv_id` 分片 + +``` +分库: 32 库 (db_0 ~ db_31) +分表: 每库 16 表 (msg_0 ~ msg_15) +总分片: 512 + +定位规则: + shard_key = hash(conv_id) % 512 + db_index = shard_key / 16 + table_idx = shard_key % 16 + +例: conv_id=12345 + hash(12345) = 0x7A3B → 0x7A3B % 512 = 251 + db_index = 251 / 16 = 15 → db_15 + table_idx = 251 % 16 = 11 → msg_11 + 实际表名: db_15.msg_11 +``` + +### 离线收件箱 `inbox`:按 `user_id` 分片 + +``` +分库: 32 库 +分表: 每库 16 表 +总分片: 512 + +shard_key = hash(user_id) % 512 +表名: inbox_ +``` + +## 2.3 时间维度二级分区 + +热表按 `created_at` 做 RANGE 分区: + +```sql +CREATE TABLE im_message_db15_msg11 ( + ... + created_at BIGINT, + PRIMARY KEY (id, created_at) +) +PARTITION BY RANGE (created_at) ( + PARTITION p202604 VALUES LESS THAN (1714521600000), + PARTITION p202605 VALUES LESS THAN (1717113600000), + PARTITION p202606 VALUES LESS THAN (1719792000000), + PARTITION p_max VALUES LESS THAN MAXVALUE +); +``` + +每月新建分区,30 天前的分区可快速 DROP(归档后)。 + +## 2.4 分片路由层 + +``` +应用层 → ShardingProxy → 后端 DB + ↑ + 基于 shard_key 路由 +``` + +实现方式: +- **客户端 SDK 直连**:性能好,扩容时 SDK 要更新 +- **中间件代理**(推荐)��ShardingSphere / MyCat / 自研 +- **TiDB**:原生分布式,应用层无感 + +## 2.5 路由表 + +``` +shard_meta 表 (全局,单点存储) ++----------+----------+----------+----------+ +| shard_id | db_url | status | weight | ++----------+----------+----------+----------+ +| 0 | db0:3306 | active | 100 | +| 1 | db0:3306 | active | 100 | +| ... | | | | +| 511 | db31:3306| active | 100 | ++----------+----------+----------+----------+ +``` + +存储:etcd / Apollo / 自研配置中心。 + +--- + +# 3. 分片键选择 + +## 3.1 不同表的分片键 + +| 表 | 分片键 | 理由 | +|---|---|---| +| `im_message` | `conv_id` | 会话内消息聚合查询 | +| `inbox` | `user_id` | 用户离线消息拉取 | +| `mention_index` | `user_id` | "我被@的消息"查询 | +| `user_conv_cursor` | `user_id` | 用户游标查询 | +| `conversation_meta` | `conv_id` | 会话元数据 | +| `group_member` | `group_id` | 群成员查询 | +| `outbox_event` | 时间 + 自增 ID | 顺序消费 | +| `user` | `user_id` | 用户基本信息 | + +## 3.2 跨分片查询的处理 + +### 查询某用户在所有会话的消息? +**正常不查**。如果一定要: +- 走 `inbox`(按 user_id 分片,单分片) +- 不走 `im_message`(要扫所有分片) + +### 查询某会话所有 @ 我的消息? +- 走 `mention_index`(按 user_id + conv_id 分片) + +### 查询某用户的活跃会话列表? +- 走 `user_conv_cursor`(按 user_id 分片) + +**核心原则**:让每个查询都能命中单分片。 + +## 3.3 热点分片处理 + +### 大群(万人群)→ 单 conv_id 写入热点 +- 识别后单独路由到独立分片 +- 该分片配置更高规格 + +### 大 V → 写扩散热点 +- 不写 inbox,改读扩散 +- 详见消息分发文档 + +--- + +# 4. 冷热分离 + +## 4.1 热表 → 温表(同库) + +每月底执行: +```sql +-- 切换分区,30 天前的数据进归档分区 +ALTER TABLE im_message_db15_msg11 + REORGANIZE PARTITION p_max INTO ( + PARTITION p202604 VALUES LESS THAN (...), + PARTITION p_max VALUES LESS THAN MAXVALUE + ); +``` + +应用层查询时优先查最新分区,不影响。 + +## 4.2 温表 → 冷表(HBase) + +### HBase 表设计 + +``` +表名: im_message_archive +RowKey: reverse(conv_id) + visible_seq + +ColumnFamily: + d: data + server_msg_id, sender_id, msg_type, content, send_time, status + m: meta + mention_all, mention_count, version +``` + +### 为什么 reverse(conv_id) +RowKey 顺序递增会导致 RegionServer 写热点。reverse 后 conv_id 散列到不同 Region。 + +### 归档作业 + +``` +每天凌晨 3:00 执行: + +1. 扫描 im_message 中 created_at < now-30d 的数据 +2. 按 conv_id 分批 (1000 条/批) +3. 写入 HBase +4. 校验 HBase 数据完整性 +5. DROP MySQL 对应分区 + +并发控制: 32 个分库并行,每库 4 线程 +单批耗时: 5~10s +日归档量: 500GB ~ 1TB +``` + +## 4.3 冷数据查询 + +```python +def query_history(conv_id, before_seq, limit): + # 先查热表 + rows = mysql.query(im_message, conv_id, before_seq, limit) + + if len(rows) >= limit: + return rows + + # 不够则补冷表 + cold_rows = hbase.scan( + rowkey_prefix=reverse(conv_id), + start=visible_seq_to_rowkey(before_seq), + limit=limit - len(rows) + ) + + return rows + cold_rows +``` + +## 4.4 文件消息冷分离 + +文件 / 图片 / 视频: +- **元数据**留 MySQL(含 OSS URL) +- **文件本体**永久放 OSS +- 30 天后元数据归档到 HBase +- 90 天后访问频率低的文件迁移到低频存储(成本降 80%) + +--- + +# 5. 扩容方案 + +## 5.1 扩容触发条件 + +任一指标命中: +- 单库存储 > 80% 容量 +- 单库 QPS > 80% 峰值能力 +- 单表行数 > 4 亿 +- CPU 持续 > 70% + +## 5.2 扩容方式对比 + +| 方式 | 适用 | 复杂度 | +|---|---|---| +| 垂直扩容 | 短期,CPU/内存瓶颈 | 低 | +| 加从库 | 读瓶颈 | 低 | +| 分片倍增(512 → 1024) | 长期,数据量瓶颈 | 高 | +| 一致性 Hash 加节点 | 灵活扩容 | 中 | +| 升级到 TiDB | 彻底解决 | 极高 | + +## 5.3 推荐扩容方案:双倍分片 + +将 512 分片扩成 1024: + +``` +原 hash(conv_id) % 512 = 251 +新 hash(conv_id) % 1024 = 251 或 763 + +规则: + - 251 (落原分片) 不动 + - 763 (新分片) 数据从 251 拷贝过来 + - 新写入按新规则路由 +``` + +### 扩容流程 + +``` +阶段 1: 准备 + - 创建新数据库实例 db_32 ~ db_63 + - 创建对应表结构 + - 启动数据同步链路 + +阶段 2: 双写 + - 应用层开关:双写新旧分片 + - 验证新分片数据正确性 + +阶段 3: 历史数据迁移 + - 后台 worker 按 conv_id 扫描 + - hash(conv_id) % 1024 落新分片的,拷贝过去 + - 校验完整性 + +阶段 4: 切流 + - 路由规则切换为 % 1024 + - 应用层停止双写 + - 流量观察 7 天 + +阶段 5: 清理 + - 旧分片中"应该在新分片"的数据删除 + - 释放空间 +``` + +## 5.4 平滑扩容关键点 + +### 1)应用层路由可热更新 +```go +type Router struct { + rule atomic.Value // *RoutingRule +} + +func (r *Router) Update(newRule *RoutingRule) { + r.rule.Store(newRule) // 无锁更新 +} +``` + +### 2)双写期间幂等 +新旧分片都用同样的唯一键,重复写入冲突即可。 + +### 3)迁移期间读策略 +``` +迁移中: 同时查新旧分片,结果合并 +迁移后: 只查新分片 +``` + +### 4)回滚预案 +- 路由规则可瞬间切回旧规则 +- 双写期数据完全一致,回滚无损 + +## 5.5 TiDB 扩容(推荐长期方案) + +TiDB 扩容简单: +``` +tiup cluster scale-out im-prod scale-out.yaml +``` + +无需停机、无需迁移,自动 rebalance Region。 +**长期看,TiDB 方案运维成本远低于 MySQL 分片。** + +--- + +# 6. 数据迁移 + +## 6.1 迁移工具选型 + +| 场景 | 工具 | +|---|---| +| MySQL → MySQL 增量 | Canal / Maxwell / Debezium | +| MySQL → MySQL 全量 | mysqldump / mydumper | +| MySQL → TiDB | TiDB DM (Data Migration) | +| MySQL → HBase | 自研 ETL / Flink CDC | +| 跨地域同步 | binlog 异步复制 | + +## 6.2 迁移流程模板 + +``` +1. 准备阶段 + - 评估数据量、停机窗口 + - 准备目标存储 + - 建立监控 + +2. 全量迁移 + - 选择空闲时段 + - 分批读取(避免锁库) + - 并行写入目标 + - 记录迁移点位 + +3. 增量同步 + - 启动 binlog 同步 + - 追平实时数据 + +4. 验证 + - 行数对比 + - 抽样校验 + - 业务双查对比 + +5. 切流 + - 应用层切到新存储 + - 灰度 1% → 100% + - 保留旧存储 7 天 + +6. 清理 + - 停止同步 + - 删除旧数据 +``` + +## 6.3 大表迁移 + +单表 5 亿行(约 200GB)的迁移策略: + +```python +# 按主键分段并行 +def migrate_table(table_name, start_id, end_id, batch_size=10000, workers=8): + ranges = split_range(start_id, end_id, workers) + + pool = ThreadPool(workers) + for r in ranges: + pool.submit(migrate_range, table_name, r.start, r.end, batch_size) + pool.join() + +def migrate_range(table, start, end, batch): + cursor = start + while cursor < end: + rows = src.query(f"SELECT * FROM {table} WHERE id >= ? AND id < ? LIMIT ?", + cursor, end, batch) + if not rows: + break + dst.batch_insert(table, rows) + cursor = rows[-1].id + 1 + sleep(0.01) # 限速防压垮源 +``` + +## 6.4 在线迁移注意事项 + +- **限速**:迁移流量 < 主流量 30% +- **避开高��**:通常凌晨 1~5 点 +- **断点续传**:记录已迁移位置 +- **失败重试**:单批失败 3 次后人工介入 +- **监控**:迁移速度、错误率、源/目标负载 + +--- + +# 7. 对账机制 + +对账目的:确保数据完整性、识别丢失/重复。 + +## 7.1 对账层级 + +| 层级 | 对账内容 | 频率 | +|---|---|---| +| 写入层 | DB ↔ Outbox | 实时 | +| 分发层 | Outbox ↔ Kafka | 5min | +| 消费层 | Kafka ↔ 各下游 DB | 1h | +| 跨地域 | 主区 ↔ 从区 | 1h | +| 冷热 | MySQL ↔ HBase | 每日 | + +## 7.2 实时对账:Outbox 滞留监控 + +```sql +SELECT COUNT(*) FROM outbox_event +WHERE status=0 AND created_at < UNIX_TIMESTAMP() * 1000 - 60000; +-- 超过 1 分钟未发送 → 告警 +``` + +## 7.3 准实时对账:消费 lag + +```bash +# Kafka consumer lag +kafka-consumer-groups --describe --group cg-inbox + +# 自动告警: lag > 10000 持续 5 分钟 +``` + +## 7.4 离线对账:每小时跑 + +### 上下游一致性 +```sql +-- 上游消息数(每小时窗口) +SELECT COUNT(*) FROM im_message +WHERE created_at BETWEEN ? AND ?; + +-- 下游 inbox 写入数 +SELECT SUM(count) FROM ( + SELECT COUNT(*) FROM inbox_0 WHERE ... + UNION ALL + ... +) ; + +-- 偏差 = 上游 - 下游 / 期望接收人数 +-- 偏差 > 0.01% → 告警 +``` + +### 抽样校验 +```python +def sample_check(hour): + # 随机抽 1000 条上游消息 + msgs = db.sample("im_message", where=hour, n=1000) + + for msg in msgs: + recipients = get_conv_members(msg.conv_id) + for uid in recipients: + # 检查每个接收人 inbox 是否有 + exists = db.exists("inbox", uid, msg.conv_id, msg.visible_seq) + if not exists: + report_missing(msg, uid) +``` + +## 7.5 跨地域对账 + +``` +华东 → 华南 同步检查: +1. 取 5 分钟前的时间窗 +2. 各自 SUM(count) GROUP BY conv_id +3. diff = 主区 - 从区 +4. diff > 阈值 → 报警 + 触发补偿同步 +``` + +## 7.6 对账失败处理 + +| 偏差 | 动作 | +|---|---| +| < 0.001% | 记录,不报警 | +| 0.001% ~ 0.01% | 邮件提醒 | +| 0.01% ~ 0.1% | IM 报警,人工查 | +| > 0.1% | 电话报警,启动应急 | + +--- + +# 8. 备份与恢复 + +## 8.1 备份策略 + +| 备份类型 | 频率 | 保留 | 存储 | +|---|---|---|---| +| 全量备份 | 每周日 02:00 | 4 周 | 异地 OSS | +| 增量 binlog | 实时 | 30 天 | 异地 OSS | +| 逻辑导出 | 每月 1 号 | 1 年 | 异地 OSS | +| 跨地域副本 | 实时同步 | 永久 | 异地 DC | + +## 8.2 备份执行 + +### MySQL +```bash +mydumper \ + --host=db15-master \ + --threads=8 \ + --compress \ + --less-locking \ + --outputdir=/backup/$(date +%Y%m%d) \ + --regex='^im_message' + +# 上传到 OSS +ossutil cp /backup/... oss://im-backup/... +``` + +### TiDB +```bash +br backup full \ + --pd "pd:2379" \ + --storage "s3://im-backup/$(date +%Y%m%d)" \ + --ratelimit 128 +``` + +## 8.3 恢复演练 + +每季度执行: +1. 选取一个分库的备份 +2. 恢复到隔离环境 +3. 校验数据完整性 +4. 模拟应用查询 +5. 记录 RTO + +目标 RTO:单库 < 30 分钟。 + +## 8.4 灾难恢复 + +### 单库丢失 +``` +1. 从最近全量备份恢复 +2. 应用 binlog 增量到故障点 +3. 校验数据 +4. 切流 +RTO: 1~2 小时 +RPO: < 1 分钟 +``` + +### 整集群丢失 +``` +1. 切流到异地副本 +2. 异地副本提升为主 +RTO: < 10 分钟 +RPO: < 30 秒 +``` + +--- + +# 9. 运维操作手册 + +## 9.1 日常巡检 + +``` +每日: +[ ] 各分片磁盘使用率 +[ ] 主从延迟 +[ ] 慢查询数量 +[ ] 死锁数量 +[ ] 备份成功状态 +[ ] 对账偏差报告 + +每周: +[ ] 容量增长趋势 +[ ] 索引使用情况 +[ ] 表碎片率 +[ ] 备份恢复演练(轮换分片) + +每月: +[ ] 容量规划复盘 +[ ] 成本分析 +[ ] 灾备演练 +``` + +## 9.2 紧急操作 + +### 单分片 CPU 爆满 +``` +1. 查 SHOW PROCESSLIST 找慢查询 +2. KILL 异常查询 +3. 排查应用是否有异常调用 +4. 紧急时降级该分片读流量 +``` + +### 单分片磁盘告急 +``` +1. 紧急加盘(如可热加) +2. 停止该分片归档(避免 I/O 加剧) +3. 加快冷数据归档 +4. 必要时主从切换到大盘从库 +``` + +### 主从延迟过大 +``` +1. 检查从库 IO/SQL 线程状态 +2. 排查从库慢 SQL +3. 临时禁用从库读 +4. 必要时重建从库 +``` + +### 数据误删 +``` +1. 立即停止应用写入 +2. 从 binlog 恢复 +3. 数据回填到主库 +4. 校验后恢复服务 +``` + +## 9.3 容量规划 + +``` +数据增长公式: + 日增 = DAU × 人均消息数 × 平均消息大小 + +例: 1000万 DAU × 50 条 × 512B = 250GB/天 + +3 个月预警: + 当前剩余 / 日增 < 90 天 → 触发扩容评估 +``` + +## 9.4 SQL 规范 + +### 必须 +- 所有查询带 LIMIT +- WHERE 必须命中索引 +- 大表分页用 cursor 不用 OFFSET +- 历史数据查询带时间范围 + +### 禁止 +- 跨分片 JOIN +- SELECT * +- 大事务(> 1000 行更���) +- 在线 DDL 不带 ALGORITHM=INPLACE + +--- + +# 文档维护 + +- 文档负责人:DBA + 数据架构组 +- 评审周期:季度 +- 关联文档:DB 容量预算文档、灾备演练手册 + +*Version 1.0 | 最后更新:2026-05-04* \ No newline at end of file diff --git a/_drafts/IM/IM-System-Design-Spec-v1.0.md b/_drafts/IM/IM-System-Design-Spec-v1.0.md new file mode 100755 index 000000000..9ad405025 --- /dev/null +++ b/_drafts/IM/IM-System-Design-Spec-v1.0.md @@ -0,0 +1,1424 @@ +# 千万并发 IM 系统技术设计规范 v1.0 + +> 适用规模:千万级 DAU / 百万级在线 / 单日百亿消息 +> 文档目标:作为架构、开发、SRE、安全的统一参考 +> 版本:1.0 +> 状态:设计基线(Baseline) + +--- + +## 目录 + +1. [整体架构设计](#1-整体架构设计) +2. [负载均衡与接入](#2-负载均衡与接入) +3. [消息协议设计](#3-消息协议设计) +4. [存储选型与配置](#4-存储选型与配置) +5. [IM 数据表设计](#5-im-数据表设计) +6. [Redis Key 设计规范](#6-redis-key-设计规范) +7. [Kafka Topic 设计](#7-kafka-topic-设计) +8. [幂等性设计](#8-幂等性设计) +9. [异常处理与 Fallback](#9-异常处理与-fallback) +10. [风控设计](#10-风控设计) +11. [流量控制与限流](#11-流量控制与限流) +12. [跨地域部署](#12-跨地域部署) +13. [可观测性与 SLA](#13-可观测性与-sla) +14. [灰度发布与上线](#14-灰度发布与上线) +15. [附录:容量规划与基线指标](#15-附录容量规划与基线指标) + +--- + +# 1. 整体架构设计 + +## 1.1 设计目标与约束 + +| 维度 | 目标 | +|---|---| +| 在线用户 | 百万级单地域,千万级全球 | +| 消息吞吐 | 写入 50 万 QPS / 投递 200 万 QPS(含 fanout) | +| 端到端延迟 | P99 < 500ms(同地域)/ P99 < 1s(跨地域) | +| 可用性 | 99.95%(年停机 < 4.4h) | +| 消息可靠性 | 不丢、不重(业务视角) | +| 单地域故障 | RTO < 5min,RPO < 30s | + +## 1.2 分层架构 + +``` +┌──────────────────────────────────────────────────┐ +│ 端层 (Client) │ +│ iOS / Android / Web / PC / 小程序 │ +└──────────────────────┬───────────────────────────┘ + │ WSS / QUIC / HTTPS +┌──────────────────────▼───────────────────────────┐ +│ 接入层 (Edge) │ +│ 调度服务 + 4层LB + 7层LB + 接入网关 (Gateway) │ +└──────────────────────┬───────────────────────────┘ + │ +┌──────────────────────▼───────────────────────────┐ +│ 业务层 (Logic) — 无状态 │ +│ 写入服务 / 投递服务 / 同步服务 / 状态服务 / 撤回... │ +└──────────────────────┬───────────────────────────┘ + │ +┌──────────────────────▼───────────────────────────┐ +│ 数据层 (Data) │ +│ Redis / MySQL/TiDB / Kafka / HBase / ES / OSS │ +└──────────────────────────────────────────────────┘ + │ +┌──────────────────────▼───────────────────────────┐ +│ 基础设施 (Infra) │ +│ K8s / 服务发现 (etcd) / 配置中心 / 监控 / 日志 │ +└──────────────────────────────────────────────────┘ +``` + +## 1.3 核心服务划分 + +| 服务 | 职责 | 状态 | +|---|---|---| +| **Dispatcher** | 接入调度(返回最优网关) | 无状态 | +| **Gateway** | 长连接维护、协议解析、限流 | 有状态(连接) | +| **MsgWrite** | 消息落库、分配 seq、写 outbox | 无状态 | +| **MsgSync** | 同步增量、历史拉取 | 无状态 | +| **Presence** | 在线状态查询/上报 | 弱状态 | +| **Deliver** | 在线消息投递 | 无状态 | +| **Push** | 离线 Push(APNs/FCM/厂商) | 无状态 | +| **InboxWriter** | 写离线收件箱 | 无状态 | +| **CounterSvc** | 未读计数 / 游标 | 无状态 | +| **MentionSvc** | @ 消息处理 | 无状态 | +| **GroupSvc** | 群成员管理 | 无状态 | +| **Recall** | 撤回 / 编辑 | 无状态 | +| **Risk** | 风控决策(异步) | 无状态 | +| **Audit** | 审计 / 合规 | 无状态 | +| **OutboxWorker** | 扫 outbox 投 Kafka | 有状态(任务) | + +## 1.4 关键设计原则 + +1. **接入层有状态,业务层无状态**:连接归属网关,业务可任意扩缩容 +2. **消息走"实时通道 + 拉取"双路径**:实时投递保即时性,拉取保最终一致 +3. **Outbox 模式保证写库与发 MQ 一致**:业务事务内写 outbox,Worker 异步投 Kafka +4. **多 seq 分离**:`global_seq`(全局唯一)+ `visible_seq`(连续,未读用) +5. **每层独立幂等**:从客户端到下游,5 道幂等防线 +6. **故障隔离**:大 V / 大群独立 topic + 独立 consumer +7. **任何节点都能挂**:连接重连、shard 主备、跨集群切流 + +--- + +# 2. 负载均衡与接入 + +## 2.1 接入调度(Dispatcher) + +客户端启动时,先请求调度服务获取最佳接入入口。 + +### 接口 + +```http +GET /v1/dispatch?user_id=...&device_id=...&client_ip=... +Response: +{ + "endpoints": [ + {"protocol": "quic", "host": "edge-east-1.im.example.com", "port": 443, "priority": 1}, + {"protocol": "wss", "host": "edge-east-2.im.example.com", "port": 443, "priority": 2} + ], + "ttl": 3600 +} +``` + +### 调度策略 + +| 维度 | 策略 | +|---|---| +| 地理位置 | GeoIP → 最近接入区域 | +| 集群健康 | 排除 unhealthy 集群 | +| 容量负载 | 按网关连接数加权 | +| 协议偏好 | QUIC 优先,WSS 兜底 | +| 用户粘性 | 同一用户优先返回上次成功的入口 | +| 灰度策略 | 按 user_id hash 分流 | + +## 2.2 多层 LB 架构 + +``` +Client + │ + ▼ +[DNS/HTTPDNS] ← 智能解析(按地理/网络) + │ + ▼ +[L4 LB: LVS/DPVS] ← 传输层负载(百万 QPS) + │ + ▼ +[L7 LB: Envoy/Nginx] ← TLS 卸载、HTTP/3、限流(仅短连接走) + │ + ▼ +[Gateway 集群] ← 长连接终结 +``` + +### 长连接路径 +长连接不走 L7(避免双重 TLS),直接 L4 LB → Gateway。 + +### QUIC 连接迁移 +- L4 LB 必须基于 **QUIC Connection ID** 路由(不是四元组) +- Gateway 生成 CID 时编码 `server_id`,LB 解 CID 提取后转发 +- 实现选型:eBPF/XDP (Katran) / DPVS 自研模块 + +详见 [QUIC 连接迁移设计文档]。 + +## 2.3 Gateway 容量与配置 + +| 配置项 | 值 | +|---|---| +| 单 Gateway 连接数 | 50K~100K | +| 单 Gateway CPU | 16 核 | +| 单 Gateway 内存 | 32GB | +| 心跳间隔 | 客户端 30s,服务端 60s 超时 | +| 单连接 KeepAlive | TCP_KEEPALIVE 60s | +| 单连接读 buffer | 64KB | +| 单连接写 buffer | 256KB | +| 单连接最大消息 | 1MB(超过走 HTTP 上传) | + +--- + +# 3. 消息协议设计 + +## 3.1 协议分层 + +``` +应用层: IM 业务协议 (Protobuf 二进制) +传输层: WebSocket / QUIC HTTP/3 +安全层: TLS 1.3 +``` + +## 3.2 协议帧结构 + +``` ++----+--------+--------+----------+----------+------------------+ +| M | Ver | Cmd | Flags | SeqId | Length | +| 1B | 1B | 2B | 2B | 8B | 4B | ++----+--------+--------+----------+----------+------------------+ +| Body (Protobuf) | ++---------------------------------------------------------------+ + +M: Magic 0x4D ("M") +Ver: 协议版本 +Cmd: 指令类型 +Flags: 位标志(压缩/加密/优先级) +SeqId: 请求 ID(回包匹配) +``` + +## 3.3 主要指令(Cmd) + +| Cmd | 名称 | 方向 | +|---|---|---| +| 0x0001 | LOGIN | C → S | +| 0x0002 | LOGOUT | C → S | +| 0x0003 | HEARTBEAT | 双向 | +| 0x0010 | SEND_MSG | C → S | +| 0x0011 | MSG_ACK | S → C | +| 0x0012 | MSG_PUSH | S → C | +| 0x0013 | MSG_RECALL | C → S | +| 0x0014 | MSG_EDIT | C → S | +| 0x0020 | SYNC_PULL | C → S | +| 0x0021 | SYNC_NOTIFY | S → C | +| 0x0030 | READ_REPORT | C → S | +| 0x0031 | READ_NOTIFY | S → C | +| 0x0040 | TYPING | C → S | +| 0x0050 | KICK | S → C | + +## 3.4 消息体协议(核心 Protobuf) + +```protobuf +// 发送消息 +message SendMsgReq { + string client_msg_id = 1; // 客户端幂等 ID + int64 conv_id = 2; + int32 msg_type = 3; // 1:text 2:image 3:file 4:audio 5:video 6:custom + bytes content = 4; // 序列化后的消息内容 + int64 send_time = 5; // 客户端时间戳(仅参考) + int32 priority = 6; // 0:normal 1:high + Mentions mentions = 7; +} + +message SendMsgResp { + string client_msg_id = 1; + int64 server_msg_id = 2; + int64 global_seq = 3; + int64 visible_seq = 4; + int64 server_time = 5; + int32 status = 6; // 0:success 1:blocked 2:rate_limited +} + +message Mentions { + repeated MentionItem items = 1; + bool all = 2; +} + +message MentionItem { + int64 user_id = 1; + int32 offset = 2; + int32 length = 3; + string name_at_send = 4; +} + +// 推送消息(服务端 → 客户端) +message MsgPush { + int64 conv_id = 1; + int64 global_seq = 2; + int64 visible_seq = 3; + int64 server_msg_id = 4; + int64 sender_id = 5; + int32 msg_type = 6; + bytes content = 7; + int64 send_time = 8; + Mentions mentions = 9; +} +``` + +## 3.5 协议优化 + +- **压缩**:消息体 > 4KB 启用 gzip/zstd +- **批量**:一次握手内发送多条消息(batch frame) +- **延迟 ACK**:客户端心跳 piggyback 已读上报 +- **二进制 ID**:所有 ID 用 int64,不用字符串 + +--- + +# 4. 存储选型与配置 + +## 4.1 存储矩阵 + +| 数据类型 | 存储 | 选型 | 理由 | +|---|---|---|---| +| 消息正文 | 分布式 SQL | TiDB / MySQL 分库分表 | 强一致、唯一约束、易运维 | +| 历史消息 | KV | HBase / Cassandra | 大容量、低成本 | +| 用户/群资料 | RDB | MySQL | 关系型、低频写 | +| 在线状态 | KV | Redis Cluster | 高频读写、TTL | +| 未读游标 | KV | Redis Cluster + DB 兜底 | 单值幂等更新 | +| @ 索引 | 分布式 SQL | TiDB | 关系查询 + 分片 | +| 文件/图片 | 对象存储 | OSS / S3 / COS | 海量、CDN 加速 | +| 全文搜索 | 倒排索引 | Elasticsearch | 历史消息搜索 | +| 事件流 | MQ | Kafka | 高吞吐、可重放 | +| 配置 | KV | etcd | 强一致、Watch | + +## 4.2 MySQL/TiDB 配置 + +### 容量规划 +``` +单表行数上限: 5 亿(MySQL)/ 无限(TiDB) +分库数: 32(按 conv_id hash) +分表数: 单库 16 张(共 512 张) +冷热分离: 30 天热表 → 归档表 +``` + +### 关键参数 +```ini +[mysqld] +innodb_buffer_pool_size = 32G +innodb_log_file_size = 2G +innodb_flush_log_at_trx_commit = 1 +sync_binlog = 1 +binlog_format = ROW +max_connections = 5000 +innodb_thread_concurrency = 32 +``` + +### 主从架构 +- 1 主 2 从(同地域) +- 1 异地灾备(异步) +- 自动切主:MHA / Orchestrator / TiDB 内置 + +## 4.3 Redis 配置 + +### 集群拓扑 +``` +Redis Cluster: 64 主 + 64 从 +单节点内存: 32GB +maxmemory-policy: volatile-lru +``` + +### 多实例分组 +| 集群 | 用途 | 节点数 | +|---|---|---| +| `redis-presence` | 在线状态 | 16 主 | +| `redis-counter` | 未读游标、计数 | 16 主 | +| `redis-cache` | 通用热缓存 | 32 主 | +| `redis-rate` | 限流计数 | 8 主 | +| `redis-mention` | @ 索引 | 8 主 | + +### 关键参数 +```ini +maxmemory-policy: volatile-lru +timeout: 0 +tcp-keepalive: 60 +appendonly: yes +appendfsync: everysec +cluster-enabled: yes +cluster-require-full-coverage: no +``` + +## 4.4 Kafka 配置 + +### 集群拓扑 +``` +Brokers: 12 节点(同地域),3 副本 +Zookeeper: 5 节点 / 或 KRaft 模式 +单 Broker: 16 核 / 64GB / 4TB SSD +``` + +### 关键参数 +```properties +# Broker +num.network.threads=8 +num.io.threads=16 +log.retention.hours=168 +log.segment.bytes=1073741824 +default.replication.factor=3 +min.insync.replicas=2 +unclean.leader.election.enable=false +auto.create.topics.enable=false + +# Producer +acks=all +enable.idempotence=true +max.in.flight.requests.per.connection=5 +compression.type=lz4 +linger.ms=10 +batch.size=65536 + +# Consumer +enable.auto.commit=false +isolation.level=read_committed +max.poll.records=500 +fetch.max.bytes=10485760 +``` + +## 4.5 HBase(历史消息冷存储) + +``` +RegionServer: 32 节点 +单 Region 大小: 10GB +RowKey 设计: reverse(conv_id) + visible_seq +压缩: SNAPPY +TTL: 永久 / 按业务策略 +``` + +--- + +# 5. IM 数据表设计 + +## 5.1 消息主表 `im_message` + +```sql +CREATE TABLE im_message ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + app_id INT NOT NULL, + conv_id BIGINT NOT NULL, + sender_id BIGINT NOT NULL, + client_msg_id VARCHAR(64) NOT NULL, + server_msg_id BIGINT NOT NULL, + global_seq BIGINT NOT NULL, + visible_seq BIGINT, -- NULL 表示不计未读 + msg_type TINYINT NOT NULL, + content JSON NOT NULL, + mention_all TINYINT DEFAULT 0, + mention_count SMALLINT DEFAULT 0, + reply_to_id BIGINT, -- 引用回复 + status TINYINT DEFAULT 0, -- 0:normal 1:recalled 2:edited 3:blocked + version INT DEFAULT 1, + send_time BIGINT, + created_at BIGINT NOT NULL, + + UNIQUE KEY uk_client (app_id, sender_id, conv_id, client_msg_id), + UNIQUE KEY uk_server (server_msg_id), + UNIQUE KEY uk_seq (conv_id, global_seq), + KEY idx_visible (conv_id, visible_seq), + KEY idx_mention_all (conv_id, mention_all, visible_seq), + KEY idx_created (conv_id, created_at) +) ENGINE=InnoDB + PARTITION BY HASH(conv_id) PARTITIONS 64; +``` + +## 5.2 用户会话游标 `user_conv_cursor` + +```sql +CREATE TABLE user_conv_cursor ( + user_id BIGINT NOT NULL, + conv_id BIGINT NOT NULL, + read_visible_seq BIGINT DEFAULT 0, + read_mention_seq BIGINT DEFAULT 0, + joined_at_seq BIGINT DEFAULT 0, + cleared_before_seq BIGINT DEFAULT 0, -- 单方面清空对话 + is_muted TINYINT DEFAULT 0, + is_pinned TINYINT DEFAULT 0, + updated_at BIGINT NOT NULL, + + PRIMARY KEY (user_id, conv_id), + KEY idx_updated (user_id, updated_at) +) PARTITION BY HASH(user_id) PARTITIONS 256; +``` + +## 5.3 提及索引 `mention_index` + +```sql +CREATE TABLE mention_index ( + user_id BIGINT NOT NULL, + conv_id BIGINT NOT NULL, + msg_seq BIGINT NOT NULL, + server_msg_id BIGINT NOT NULL, + sender_id BIGINT NOT NULL, + mention_type TINYINT NOT NULL, -- 1:@user 2:@all 3:reply + status TINYINT DEFAULT 0, + created_at BIGINT NOT NULL, + + PRIMARY KEY (user_id, conv_id, msg_seq), + KEY idx_user_time (user_id, created_at DESC), + KEY idx_msg (server_msg_id) +) PARTITION BY HASH(user_id) PARTITIONS 256; +``` + +## 5.4 会话元数据 `conversation_meta` + +```sql +CREATE TABLE conversation_meta ( + conv_id BIGINT PRIMARY KEY, + conv_type TINYINT NOT NULL, -- 1:single 2:group 3:channel + max_global_seq BIGINT DEFAULT 0, + max_visible_seq BIGINT DEFAULT 0, + max_seg_allocated BIGINT DEFAULT 0, -- seq 号段水位 + member_count INT DEFAULT 0, + is_large_group TINYINT DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +``` + +## 5.5 离线收件箱 `inbox` + +```sql +CREATE TABLE inbox ( + user_id BIGINT NOT NULL, + conv_id BIGINT NOT NULL, + msg_seq BIGINT NOT NULL, + server_msg_id BIGINT NOT NULL, + created_at BIGINT NOT NULL, + + PRIMARY KEY (user_id, conv_id, msg_seq), + KEY idx_user_time (user_id, created_at) +) PARTITION BY HASH(user_id) PARTITIONS 256; +``` + +## 5.6 群成员 `group_member` + +```sql +CREATE TABLE group_member ( + group_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + role TINYINT DEFAULT 0, -- 0:member 1:admin 2:owner + joined_seq BIGINT NOT NULL, + joined_at BIGINT NOT NULL, + status TINYINT DEFAULT 0, -- 0:active 1:muted 2:removed + + PRIMARY KEY (group_id, user_id), + KEY idx_user (user_id) +) PARTITION BY HASH(group_id) PARTITIONS 64; +``` + +## 5.7 Outbox 事件表 `outbox_event` + +```sql +CREATE TABLE outbox_event ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + event_type VARCHAR(32) NOT NULL, + partition_key VARCHAR(64) NOT NULL, + payload JSON NOT NULL, + status TINYINT DEFAULT 0, -- 0:pending 1:sent 2:failed + retry_count INT DEFAULT 0, + created_at BIGINT NOT NULL, + sent_at BIGINT, + + KEY idx_status (status, created_at) +) ENGINE=InnoDB; +``` + +## 5.8 撤回记录 `message_recall` + +```sql +CREATE TABLE message_recall ( + server_msg_id BIGINT PRIMARY KEY, + conv_id BIGINT NOT NULL, + operator_id BIGINT NOT NULL, + recall_reason VARCHAR(128), + recalled_at BIGINT NOT NULL, + KEY idx_conv (conv_id, recalled_at) +); +``` + +## 5.9 用户主表 `user` + +```sql +CREATE TABLE user ( + user_id BIGINT PRIMARY KEY, + app_id INT NOT NULL, + nickname VARCHAR(64), + avatar VARCHAR(256), + status TINYINT DEFAULT 0, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL +); +``` + +--- + +# 6. Redis Key 设���规范 + +## 6.1 命名规范 + +``` +{业务}:{对象}:{ID}[:{子维度}] +``` + +- 全部小写 +- 用 `:` 分隔 +- 必须以业务前缀开头 + +## 6.2 完整 Key 列表 + +### 在线状态 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `presence:user:{userId}` | Hash | 30s(续约) | userId → {deviceId, gateway, conn_id} | +| `presence:dev:{userId}:{deviceId}` | String | 30s | 设备级路由 | +| `presence:gw:{gatewayId}` | Set | 60s | 某网关上的所有用户(监控) | + +### 消息相关 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `msg:dedup:{convId}:{clientMsgId}` | String | 5min | 发送去重快路径 | +| `msg:result:{convId}:{clientMsgId}` | Hash | 5min | 重试返回首次结果 | +| `msg:recent:{convId}` | ZSet (by seq) | LRU | 最近消息缓存 | +| `msg:recall:{serverMsgId}:{reqId}` | String | 5min | 撤回去重 | + +### Seq 与游标 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `seq:max:{convId}` | String | 持久 | 会话最大 visible_seq | +| `seq:max_global:{convId}` | String | 持久 | 会话最大 global_seq | +| `seq:segment:{convId}` | Hash | 持久 | 号段水位(备份恢复) | +| `cursor:{userId}:{convId}` | Hash | 持久 | read_visible_seq, read_mention_seq, joined_at_seq | + +### @ 与计数 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `mention:{userId}:{convId}` | ZSet (by seq) | 30d | 单会话 @ 我列表 | +| `mention_inbox:{userId}` | ZSet (by time) | 30d | 全局 @ 我列表(最近 1000) | +| `conv_mention_all_max:{convId}` | String | 持久 | 该会话最大 @all seq | + +### 限流 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `rate:user:{userId}:msg` | String | 60s | 用户消息频率 | +| `rate:conv:{convId}:msg` | String | 60s | 会话消息频率 | +| `rate:ip:{ip}:conn` | String | 60s | IP 建连频率 | +| `rate:global:{shard}` | String | 60s | 全局 QPS 限流(分桶) | + +### 风控 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `risk:user:{userId}` | Hash | 1h | 风险等级与封禁状态 | +| `risk:hot_keys` | Set | 5min | 热点 key 名单(用于路由) | +| `risk:large_user` | Set | 1h | 大 V 名单 | +| `risk:large_conv` | Set | 1h | 大群名单 | + +### 群与会话 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `group:members:{groupId}` | Set | 1h | 群成员集合(小群) | +| `group:meta:{groupId}` | Hash | 1h | 群元数据 | + +### 同步 + +| Key | Type | TTL | 用途 | +|---|---|---|---| +| `syncbox:{userId}` | ZSet (by time) | 30d | 用户活跃会话列表 | + +## 6.3 分片策略 + +- 用户相关 key 用 `{userId}` hashtag 保��同槽 +- 会话相关用 `{convId}` hashtag +- 全局 key 加随机分桶 `:{0..15}` 防热点 + +## 6.4 内存预算 + +``` +presence: 2GB(每用户 ~200B × 1000万) +cursor: 20GB(每用户 1000 会话 × 100B) +recent msg: 50GB(每会话 100 条 × 1KB × 50 万活跃会话) +mention: 5GB +rate limit: 2GB +total ~ 80GB → 64 主节点 × 2GB used +``` + +--- + +# 7. Kafka Topic 设计 + +## 7.1 Topic 规划 + +| Topic | Partitions | Replication | Retention | Key | 用途 | +|---|---|---|---|---|---| +| `msg.fanout.normal` | 500 | 3 | 7d | conv_id | 普通消息分发 | +| `msg.fanout.large` | 200 | 3 | 7d | conv_id+salt | 大群消息(隔离) | +| `msg.fanout.vip` | 100 | 3 | 7d | recipient_id+salt | 大 V 推送 | +| `msg.system` | 50 | 3 | 3d | random | 系统消息广播 | +| `msg.push.high` | 200 | 3 | 1d | user_id | 高优 push(@/私聊) | +| `msg.push.normal` | 100 | 3 | 1d | user_id | 普通 push(合并) | +| `msg.push.low` | 50 | 3 | 1d | random | 营销/低优 | +| `msg.recall` | 50 | 3 | 7d | server_msg_id | 撤回事件 | +| `msg.read` | 100 | 3 | 1d | user_id | 已读回执 | +| `msg.edit` | 50 | 3 | 7d | server_msg_id | 编辑事件 | +| `msg.mention` | 100 | 3 | 7d | user_id | @ 专属事件流 | +| `msg.inbox` | 200 | 3 | 3d | recipient_id | 写离线收件箱 | +| `presence.event` | 100 | 3 | 1h | user_id | 在线状态变更 | +| `user.behavior` | 200 | 3 | 30d | user_id | 风控行为日志 | +| `audit.log` | 100 | 3 | 90d | random | 审计 | +| `search.index` | 100 | 3 | 1d | server_msg_id | 搜索索引更新 | + +## 7.2 分区与热点 + +### 普通消息 +- key = `conv_id` +- 同会话有序 + +### 大群消息 +- key = `conv_id + "#" + recipient_hash % 16` +- 按接收者加盐,接收者维度有序 + +### 大 V 推送 +- key = `recipient_id` +- 接收者维度有序,发送者维度无序 + +### 系统广播 +- key = random +- 完全打散 + +## 7.3 Consumer Group + +| Topic | Consumer Group | 实例数 | +|---|---|---| +| `msg.fanout.normal` | `cg-deliver-normal` | 50 | +| `msg.fanout.large` | `cg-deliver-large` | 20 | +| `msg.fanout.vip` | `cg-fanout-vip` | 30 | +| `msg.push.high` | `cg-push-high` | 30 | +| `msg.push.normal` | `cg-push-normal` | 20 | +| `msg.inbox` | `cg-inbox` | 40 | +| `msg.mention` | `cg-mention-push` | 10 | +| `search.index` | `cg-search` | 10 | +| `user.behavior` | `cg-risk` | 20 | + +## 7.4 死信队列(DLQ) + +每个关键 topic 配套 DLQ: + +``` +msg.push.high → msg.push.high.dlq +msg.fanout.normal → msg.fanout.normal.dlq +``` + +消费失败 5 次后进 DLQ,人工 / 定时任务处理。 + +## 7.5 跨地域同步 + +``` +华东 Kafka 华南 Kafka +msg.fanout.normal ─MirrorMaker─→ msg.fanout.normal.replicated + └─→ 华南消费者 +``` + +--- + +# 8. 幂等性设计 + +## 8.1 五道幂等防线 + +| 层 | 幂等键 | 实现 | +|---|---|---| +| 客户端 | `clientMsgId` | 重试不变更,本地持久化 | +| Redis 快路径 | `dedup:{conv}:{clientMsgId}` | SETNX + TTL | +| DB 唯一约束 | `(app, sender, conv, client_msg_id)` | UNIQUE KEY | +| Outbox | `outbox_event.id` | 主键去重 | +| Consumer | 业务键 | 各消费者独立去重 | + +## 8.2 ID 体系 + +| ID | 生成方 | 范围 | 特点 | +|---|---|---|---| +| `client_msg_id` | 客户端 | UUID/雪花 | 重试不变 | +| `server_msg_id` | 服务端 | 雪花 64bit | 全局唯一、近似有序 | +| `global_seq` | 服务端 | 会话内分片自增 | 允许空洞 | +| `visible_seq` | 服务端 | 会话内严格连续 | 未读用 | +| `outbox.id` | DB | 自增 | 投递去重 | + +## 8.3 关键场景幂等 + +### 发送消息 +``` +1. 客户端 clientMsgId 持久化,重试不变 +2. 服务端 SETNX msg:dedup:{conv}:{clientMsgId} +3. INSERT im_message ON DUPLICATE KEY → 返回原结果 +4. Outbox 事件 +5. Consumer 按 server_msg_id 幂等 +``` + +### 已读上报 +``` +GREATEST 推进游标: +read_seq = MAX(prev, reported_seq) +``` + +### 撤回 +``` +条件更新: +UPDATE im_message SET status=1 +WHERE server_msg_id=? AND status=0 +``` + +### 离线消息写入 +``` +UNIQUE KEY (user_id, conv_id, msg_seq) +INSERT IGNORE +``` + +### Push +``` +SETNX push:{serverMsgId}:{userId}:{channel} EX 86400 ++ 厂商 collapse_id +``` + +## 8.4 防 seq 回退 + +- visible_seq INSERT 成功后才确认 +- 节点切主:DB MAX + 1(visible)/ MAX + GAP(global) +- DB UNIQUE KEY (conv, seq) 兜底 + +--- + +# 9. 异常处理与 Fallback + +## 9.1 故障矩阵 + +| 故障 | 影响 | Fallback | +|---|---|---| +| Gateway 挂 | 该机连接断 | 客户端 1~5s 重连到新网关,状态 30s 自愈 | +| MsgWrite 挂 | 写入失败 | LB 切流到健康实例 | +| Redis 主挂 | 缓存不可用 | 读 DB,写降级延迟同步 | +| Redis 集群挂 | 全站缓存失败 | 限流降级,DB 兜底 | +| MySQL 主挂 | 写入失败 | 切主(10~30s),期间写入排队 | +| Kafka 挂 | 异步 fanout 卡 | Outbox 堆积,消息核心仍可入库 | +| Push 厂商挂 | push 失败 | 切换备用厂商 | +| 状态分片挂 | 路由查询失败 | 本地缓存 + 广播投递降级 | +| 整集群挂 | 区域不可用 | DNS 切到其他集群,状态切到 standby | + +## 9.2 Fallback 策略 + +### Redis 不可用 +``` +读: Redis miss → 查 DB → 不回填(避免雪崩) +写: 双写失败时,DB 成功即返回成功 + 异步重试写 Redis +``` + +### Kafka 不可用 +``` +Outbox 堆积,业务正常写入 +Worker 持续重试 +SLA: outbox 堆积 > 1 万触发告警 +``` + +### 状态服务不可用 +``` +策略 A: 广播投递(消息发到用户所属集群所有 Gateway) +策略 B: 本地缓存兜底(1 秒 TTL) +策略 C: 排队 1~3s 等切主完成 +``` + +### Gateway 路由失败 +``` +消息服务投递到 Gateway 收到 NOT_FOUND: +1. 强制刷新 status shard +2. 重投递 +3. 仍失败 → 进离线 inbox + 触发 push +``` + +## 9.3 客户端降级 + +- 实时通道断 → 自动切 WS(QUIC 失败) +- 双通道都断 → HTTP 轮询兜底 +- 服务返回 503 → 指数退避重试 +- 长期失联 → 本地草稿保留,恢复后发送 + +## 9.4 应急开关(一键降级) + +| 开关 | 默认 | 紧急时 | 配置中心 | +|---|---|---|---| +| `kill.typing` | off | on | etcd | +| `kill.read_receipt` | off | on | etcd | +| `kill.large_group_fanout` | off | on | etcd | +| `kill.search` | off | on | etcd | +| `rate.global.qps` | 1M | 100K | etcd | +| `force.read_only` | off | on | etcd | + +--- + +# 10. 风控设计 + +## 10.1 威胁模型 + +| 威胁 | 影响 | 检测手段 | +|---|---|---| +| 脚本刷消息 | 资源耗尽 | 频率检测 | +| 群发广告 | 用户骚扰 | 内容相似度 + 多收件人 | +| 拉群轰炸 | 用户体验 | 群成员变化速率 | +| 多账号协同 | 绕过限流 | 设备指纹 + IP 集群 | +| 暴力破解登录 | 安全 | 登录失败次数 | +| 爬取关系链 | 数据泄露 | 查询频率 | +| 钓鱼链接 | 用户损失 | URL 黑名单 + AI 识别 | +| 涉政/违规内容 | 合规风险 | 内容审核 | + +## 10.2 风控架构 + +``` +业务事件 ─→ Kafka (user.behavior) + │ + ▼ + ┌────────────────┐ + │ 实时风控引擎 │ ← 规则 + 模型 + │ (Flink) │ + └────┬───────────┘ + │ + ▼ + risk:user:{uid} ← 决策结果 + │ + ▼ + 业务层查询消费 +``` + +## 10.3 检测信号 + +``` +- 发送频率 (sliding window) +- 收件人多样性 (HLL) +- 内容相似度 (SimHash) +- 收发比 (ratio) +- 加好友通过率 +- 设备指纹 (canvas/UA/IP/...) +- 注册时间 (新号高风险) +- IP 类型 (机房 IP 段) +- 行为轨迹 (登录时间分布) +- 关系链密度 (异常连通) +``` + +## 10.4 决策与处置 + +| 等级 | 处置 | +|---|---| +| Level 0 | 正常 | +| Level 1 | 加验证码 | +| Level 2 | 降速(限流阈值减半) | +| Level 3 | 临时封禁 1h | +| Level 4 | 临时封禁 24h | +| Level 5 | 永久封号(人工审核) | + +## 10.5 内容审核 + +``` +消息发送 ─→ 同步快审(关键词/URL 黑名单) + │ + ├─ 通过 → 入库 + 异步深审 + └─ 拒绝 → 直接 block + +异步深审: + 图片: AI 鉴黄/暴恐/政治 + 文本: NLP 模型 + 音频: ASR + 文本审 + 视频: 抽帧 + 综合 +``` + +违规处置:撤回 + 下发警告 + 计入用户风险分。 + +--- + +# 11. 流量控制与限流 + +## 11.1 限流分层 + +| 层 | 维度 | 算法 | 实现 | +|---|---|---|---| +| 客户端 | 自限 | 节流/防抖 | SDK | +| Gateway | 单连接消息频率 | 令牌桶 | 本地内存 | +| Gateway | 单 IP 连接数 | 计数 | 本地内存 | +| Gateway | 单 IP 建连频率 | 令牌桶 | 本地内存 | +| 业务层 | 用户级消息频率 | 令牌桶 | Redis Lua | +| 业务层 | 会话级频率 | 令牌桶 | Redis Lua | +| 业务层 | 接口频率 | 滑动窗口 | Redis ZSet | +| 业务层 | 全局 QPS | 计数(分桶) | Redis | +| 下游 | 消费者池 | 信号量 | 本地 | +| 下游 | 大群 fanout | 漏桶 | 本地 | + +## 11.2 限流配置基线 + +| 维度 | 阈值 | +|---|---| +| 单连接消息频率 | 10/s, 突发 30 | +| 单连接信令 | 50/s | +| 单 IP 连接数 | 50 | +| 单 IP 建连 | 10/s | +| 单用户消息 | 200/min | +| 单会话消息 | 20/s | +| 私聊收发对 | 60/min | +| 加好友 | 50/d | +| 建群 | 5/h | +| 拉群成员 | 100/h | +| 历史消息拉取 | 5/s, limit ≤ 200 | +| 万人群消息 | 5/s | +| 文件消息 | 10/min | + +## 11.3 Redis Lua 令牌桶 + +```lua +local key = KEYS[1] +local rate = tonumber(ARGV[1]) +local capacity = tonumber(ARGV[2]) +local now = tonumber(ARGV[3]) +local n = tonumber(ARGV[4]) + +local last_tokens = tonumber(redis.call('hget', key, 'tokens')) or capacity +local last_time = tonumber(redis.call('hget', key, 'ts')) or now + +local elapsed = math.max(0, now - last_time) +local filled = math.min(capacity, last_tokens + elapsed * rate) + +local allowed = filled >= n +if allowed then + filled = filled - n +end + +redis.call('hmset', key, 'tokens', filled, 'ts', now) +redis.call('expire', key, 60) +return allowed and 1 or 0 +``` + +## 11.4 限流降级 + +- 限流命中 → 返回 429 + Retry-After +- 客户端指数退避 + 抖动 +- 紧急时全局阈值通过配置中心动态下调 + +## 11.5 重连风暴防御 + +``` +- 客户端: 指数退避 1s→2s→4s→8s + random(0~1s) +- Gateway: 单实例每秒最多接受 1000 新连接 +- Dispatcher: 优雅恢复,按比例放量 +``` + +--- + +# 12. 跨地域部署 + +## 12.1 部署拓扑 + +``` +┌──────────────────────────────────────────────┐ +│ 全球 GSLB (DNS) │ +└──────┬───────────┬─────────────┬─────────────┘ + │ │ │ + ┌───▼───┐ ┌────▼───┐ ┌─────▼────┐ + │ 华东 │ │ 华南 │ │ 美西 │ + │ 区域 │ │ 区域 │ │ 区域 │ + └───────┘ └────────┘ └──────────┘ +``` + +每个区域包含: +- 完整的接入层 + 业务层 + 数据层 +- 独立 Kafka 集群 +- 独立 Redis 集群 +- 独立 DB 主从(异地有 standby) + +## 12.2 用户归属 + +每个用户有"主区域"(home region),由: +- 注册地 +- 主要活动地 +决定。 + +``` +用户元数据: + user_id → home_region + 存储: 全局配置中心 / 全局 DB +``` + +## 12.3 跨区消息流 + +``` +A 在华东,B 在华南: + +1. A 发消息 → 华东 Gateway → 华东 MsgWrite +2. 写华东 DB(A 的会话主分片) +3. 写华东 Kafka +4. MirrorMaker 同步到华南 Kafka +5. 华南消费者投递给 B +``` + +延迟预算: +- 同区域 P99: 200ms +- 跨区域 P99: 500ms (含 100~150ms 网络) + +## 12.4 数据一致性 + +| 数据 | 一致性级别 | 同步方式 | +|---|---|---| +| 消息正文 | 最终一致 | Kafka MirrorMaker(异步) | +| 用户资料 | 最终一致 | DB binlog 异步复制 | +| 在线状态 | 区域内一致 | 不跨区同步 | +| 配置 | 强一致 | etcd 跨区集群 | +| 群成员 | 最终一致 | 主区域为准,异步同步 | + +## 12.5 容灾切换 + +### 单 AZ 故障 +- AZ 内自动切换(K8s 调度 + LB 摘除) +- RTO: < 30s + +### 整区域故障 +- DNS GSLB 切流到其他区域 +- 用户重连到新区域 +- 数据:DB standby 提升为主 +- RTO: < 5min, RPO: < 30s + +### 跨区域脑裂 +- 分区双方各自服务(CP) +- 网络恢复后异步合并 +- 冲突按 timestamp 解决 + +--- + +# 13. 可观测性与 SLA + +## 13.1 SLA 定义 + +| 指标 | 目标 | 测量 | +|---|---|---| +| 接入可用性 | 99.95% | 探针 + LB 健康率 | +| 消息送达率 | 99.99% | 端到端追踪 | +| 消息延迟 P99 | < 500ms(同区) | 全链路打点 | +| 消息延迟 P99 | < 1s(跨区) | 全链路打点 | +| 离线消息延迟 | < 5s | push 时间戳对比 | +| 消息丢失率 | < 0.001% | 对账 | +| API 成功率 | > 99.9% | LB / Gateway 日志 | + +## 13.2 监控分层 + +``` +基础设施: CPU/MEM/Disk/Network/IOPS +中间件: Redis/MySQL/Kafka/etcd 各项内置指标 +业务: QPS/延迟/错误率/业务计数 +端到端: 全链路 Trace(OpenTelemetry) +体验: 客户端上报(连接成功率、收发延迟) +``` + +## 13.3 关键监控指标 + +### Gateway +``` +- 连接数(当前/峰值) +- 建连/秒 +- 消息收发 QPS +- 消息延迟 (P50/P99) +- TLS 握手失败率 +- QUIC 迁移成功率 +- CPU / 内存 / 网络 +``` + +### 消息链路 +``` +- 写入 QPS / 延迟 +- Outbox 堆积量 +- Kafka 各 topic lag(按 partition) +- 投递成功率 +- 各下游消费者 lag +- 撤回 / 编辑速率 +``` + +### 数据层 +``` +- Redis: hit ratio / 内存 / 慢查询 +- MySQL: QPS / 慢查询 / 主从延迟 / 死锁 +- Kafka: ISR / Under-Replicated / 网络 +- DB 连接池使用率 +``` + +### 业务 +``` +- DAU / MAU +- 消息发送量 +- 群活跃数 +- @ 消息量 +- 异常封禁数(风控) +- Push 成功率(按厂商) +``` + +## 13.4 日志规范 + +### 结构化日志 +```json +{ + "ts": "2026-05-04T10:00:00.123Z", + "level": "INFO", + "service": "msg-write", + "trace_id": "...", + "span_id": "...", + "user_id": 1001, + "conv_id": 123, + "client_msg_id": "...", + "server_msg_id": 99001, + "event": "message_created", + "duration_ms": 45 +} +``` + +### 日志分级 +- DEBUG: 仅开发环境 +- INFO: 关键业务事件 +- WARN: 异常但可处理 +- ERROR: 失败需关注 +- FATAL: 触发告警 + +### 日志收集 +``` +应用 → Filebeat → Kafka → Logstash → ES +保留期: 错���日志 30 天 / INFO 7 天 +``` + +## 13.5 全链路追踪 + +OpenTelemetry 标准: +- 客户端发送 → 注入 trace_id +- Gateway 透传 +- 所有 RPC 携带 trace context +- Kafka 消息 header 携带 trace +- 全链路一个 trace 串起来 + +## 13.6 告警分级 + +| 级别 | 响应时间 | 渠道 | +|---|---|---| +| P0 | 5 分钟 | 电话 + IM + 邮件 | +| P1 | 15 分钟 | IM + 邮件 | +| P2 | 1 小时 | IM | +| P3 | 4 小时 | 邮件 | + +### P0 告警示例 +- 接入成功率 < 99% +- 消息丢失率 > 0.01% +- 主集群整体不可用 +- DB 主从延迟 > 60s + +## 13.7 容量监控 + +``` +- 连接数水位 +- QPS 水位 +- 存储水位 +- Redis 内存水位 +- Kafka 磁盘水位 +告警阈值: 70% (黄), 85% (橙), 95% (红) +``` + +--- + +# 14. 灰度发布与上线 + +## 14.1 发布策略矩阵 + +| 变更类型 | 策略 | 验证 | +|---|---|---| +| 客户端 | 应用商店阶段发布 | 1% → 10% → 50% → 100% | +| Gateway | 蓝绿 + 滚动 | 单实例验证 → 一区域 → 全量 | +| 业务层 | 滚动 | 单 AZ → 一区域 → 全量 | +| 协议变更 | 双协议兼容 | 长达数周 | +| 数据库 | 只增不删,分阶段 | 见 14.5 | + +## 14.2 灰度维度 + +``` +按用户: user_id % 100 < N (N=1,5,20,50,100) +按区域: 单 AZ → 一区域 +按租户: 白名单 app_id +按设备: iOS/Android 分批 +按版本: 强制升级旧版本 +``` + +## 14.3 灰度流程 + +``` +1. 内部环境验证 (开发/测试) +2. 预发布环境(生产数据镜像) +3. Canary(< 1% 流量)24h +4. 灰度 5% 24h +5. 灰度 20% 12h +6. 灰度 50% 12h +7. 全量 +8. 持续观察 7 天 +``` + +每个阶段必须满足: +- 错误率不上升 +- 延迟不上升 +- 业务核心指标不下降 + +## 14.4 回滚机制 + +- 一键回滚(≤ 5 分钟) +- 数据库变更:必须可回滚(增字段 / 不删字段) +- 协议变更:双向兼容期 ≥ 1 个月 + +## 14.5 数据库变更规范 + +``` +✅ 允许: + - 加字段 (DEFAULT NULL) + - 加索引 (online DDL) + - 加表 + +⚠️ 谨慎: + - 改字段类型 (要求兼容) + - 改索引 (双跑后切) + +❌ 禁止: + - 直接删字段 + - 直接删表 + - 字段重命名 + +变更步骤: + 1. 加新字段(兼容旧代码) + 2. 双写 + 3. 历史数据迁移 + 4. 切读 + 5. 停旧字段写入 + 6. 30 天后真删 +``` + +## 14.6 发布前检查清单 + +``` +[ ] Code Review 通过 +[ ] 单元测试覆盖 > 70% +[ ] 集成测试通过 +[ ] 性能测试达标 +[ ] 监控/告警就�� +[ ] 回滚预案文档 +[ ] 灰度计划评审 +[ ] 风险预案 +[ ] 变更通知 (业务/客服/SRE) +[ ] 应急联系人就位 +``` + +## 14.7 发布窗口 + +``` +工作日 10:00 - 17:00(优先) +重大变更: 周二/周三 +禁止时段: + - 周五下午(防周末爆雷) + - 节假日 + - 大型营销活动期间 +``` + +## 14.8 应急响应 + +``` +故障发现 → 5 分钟内决策回滚 / 修复 +P0 故障 → 立即拉群 + 战时模式 +事后复盘: + - 时间线 + - 根因 + - 改进项 + - 责任界定(不追责,但要总结) +``` + +--- + +# 15. 附录:容量规划与基线指标 + +## 15.1 容量预估(千万 DAU) + +``` +DAU: 10,000,000 +峰值在线: 1,000,000 +日消息量: 20 亿 +峰值消息 QPS: 50万 (写入) / 200万 (含 fanout) +日新增数据: 约 500GB(消息)+ 索引 +``` + +## 15.2 资源预算(参考) + +| 组件 | 数量 | 规格 | +|---|---|---| +| Gateway | 50 | 16C32G | +| MsgWrite | 30 | 16C32G | +| MsgSync | 20 | 16C32G | +| Deliver | 20 | 16C32G | +| InboxWriter | 30 | 16C32G | +| Push | 20 | 16C32G | +| MySQL | 32 主 + 32 从 | 32C128G + SSD | +| Redis | 64 主 + 64 从 | 8C32G | +| Kafka | 12 broker | 16C64G + 4TB | +| ES | 20 节点 | 16C64G + 2TB | +| HBase | 32 节点 | 16C64G + 8TB | + +## 15.3 性能基线(单实例) + +| 服务 | QPS | 延迟 P99 | +|---|---|---| +| Gateway 消息转发 | 50K | < 5ms | +| MsgWrite 入库 | 5K | < 50ms | +| Deliver 投递 | 20K | < 20ms | +| Redis 单实例 | 100K | < 1ms | +| MySQL 单实例 | 5K | < 10ms | +| Kafka 单 partition | 10K | < 10ms | + +## 15.4 SLA 总表 + +| 指标 | 目标 | +|---|---| +| 服务可用性 | 99.95% | +| 消息成功率 | 99.99% | +| 消息丢失率 | < 0.001% | +| P50 延迟 | < 100ms | +| P99 延迟 | < 500ms | +| RTO | < 5min | +| RPO | < 30s | + +--- + +# 文档维护 + +- 文档负责人: 架构组 +- 评审周期: 每季度 +- 变更流程: PR + 至少 2 人 review +- 版本控制: Git +- 关联文档: + - QUIC 接入网关详细设计 + - 消息存储分库分表方案 + - 风控规则手册 + - SRE 运维手册 + - 客户端 SDK 设计 + +--- + +**文档结束** + +*Version 1.0 | 最后更新: 2026-05-04* \ No newline at end of file diff --git a/_drafts/IM/service-center-etcd-zookeeper.md b/_drafts/IM/service-center-etcd-zookeeper.md new file mode 100755 index 000000000..a5102e435 --- /dev/null +++ b/_drafts/IM/service-center-etcd-zookeeper.md @@ -0,0 +1,323 @@ +# etcd + ZooKeeper 服务中心设计详解 + +> 适用:IM、大规模分布式服务注册发现、配置中心、选主、分布式锁、任务调度 +> 目标:高可用、强一致、大规模支撑 IM 或微服务架构 +> 最近更新:2026-05 + +--- + +## 1. 背景与选型 + +在 IM/微服务/分布式大促系统领域,常见的服务中心选型包括: + +- **ZooKeeper**(Apache):老一代分布式一致性协调中心,Paxos→ZAB协议,CP,写放大,连接压力大。 +- **etcd**(CoreOS/Cloud Native):后起之秀,Raft算法,云原生生态,性能好,API简洁,普及率高。 +- **Consul**、**Eureka** 等也常见但此处不重点分析。 + +### 为什么常同时存在? + +- **etcd 用于云原生/配置中心/服务发现/K8s 生态/高并发短连接** +- **ZooKeeper 用于老项目、任务调度、Kafka/HBase/Hadoop/Solr 依赖组件、分布式锁、强一致小数据协调** + +大中型公司常见“etcd + ZK 双中心”模式: +- 新增业��全部用 etcd,保留 ZK 作为兼容与部分强一致订阅需求、“重量级选主”组件。 +- 架构宜优先 etcd,逐步将ZK淘汰只保留Kafka/HBase系统本身可靠性一环,避免新业务依赖。 + +--- + +## 2. 场景与能力矩阵 + +| 能力/场景 | ZooKeeper | etcd | 推荐实践 | +|----------------|-----------|---------|--------------------------| +| 服务注册发现 | ✔️ | ✔️ | etcd 优先 | +| 配置中心 | ✔️ | ✔️ | etcd 优先 | +| 选主(分布式锁)| ✔️ | 单一锁弱 | ZK强一致,etcd 3.3+带lease支持 | +| 配置推送/订阅 | ✔️ | ✔️ | etcd v3 watch机制优越 | +| 节点监控 | ✔️ | ✔️ | etcd性能更优 | +| 队列/Barrier | ✔️ | × | 只ZK支持 | +| 复杂树结构 | ✔️ | Key-Value| ZK层级强,etcd前缀遍历 | + +--- + +## 3. 架构部署 + +### etcd 集群设计 + +- **奇数节点**,推荐 3、5、7 +- 部署区域策略:单region用3-5节点,跨region需网络稳定 +- 节点自选ID,静态peer配置 +- 内网通讯加密需配置证书 +- 生产只读节点请用 etcd proxy,不建议直接连主集群 + +### ZooKeeper 集群设计 + +- **奇数节点**,3/5/7(有failover);ZAB算法要求 +- 建议单机单实例,不混布 +- 服务发现注册各自路径隔离(如 /im /kafka /hbase) +- 限制单Client并发Session数,短连接谨慎 +- 自动快照清理 +- 跨IDC需关注Leader选举压制 + +--- + +## 4. 服务注册与发现 + +### etcd 实现(IM常用) + +1. 服务自注册 + +```bash +# 注册key +PUT /v3/kv/put +{ + "key": base64("services/gateway/10.0.1.2:8181"), + "value": base64("{\"weight\":100,\"zone\":\"east\"}"), + "lease": 1234 +} +``` +带 lease,注册即有自动过期。协同健康检查,微服务失活 key 自动丢。 + +2. 服务发现 + +```bash +# 获取所有gateway服务 +GET /v3/kv/range +{ + "key": base64("services/gateway/"), + "range_end": base64("services/gateway0") +} +``` +客户端可以 watch key前缀,自动感知上下线。 + +### ZK 实现(兼容遗留/分布式锁) + +1. 服务注册 + +利用临时节点,服务下线自动消失。 + +``` +/services/gateway + /10.0.1.2:8181 (ephemeral) + /10.0.1.3:8181 (ephemeral) +``` +2. 发现和监听 + +客户端 watch /services/gateway 下的children,节点变化收到通知。 + +--- + +## 5. 配置中心设计 + +### etcd 方式 + +- key 模型采用前缀树,比如 `/config/im/gateway.yaml`,按业务/环境/集群/功能多级分层 +- 支持热更新(watcher 监听配置key变化,自动推送到业务内存或重加载) +- 配置滚动升级可用短ttl+多版本key,客户端watch新key切流 + +### ZK 方式(次选) + +- key分层如 `/config/im/gateway` +- 服务监听节点值变化(getData/watch) +- 写大文件用 get/setChildren 拆key + +推荐新业务一律 etcd 配置,保证K8s原生兼容。 + +--- + +## 6. 分布式锁与选主 + +### etcd 分布式锁实现 + +- v3 Lease + CAS +- 典型 Go 代码如下(简化): + +```go +// 加锁 +lease, _ := client.Grant(context, 20) // 20s租期 +// 原子 put 如果不存在 +txn := client.Txn(context). + If(Compare(CreateRevision(lockKey), "=", 0)). + Then(OpPut(lockKey, ownerID, WithLease(lease.ID))) +if txn.Commit().Succeeded { + // 获得锁 +} +// 失败重试 or wait +``` +- 续约靠 Lease keepalive + +### ZK 分布式锁/选主实现 + +- 临时有序节点 `/lock/im/ + /lock/im/lock-00000001 (ephemeral, clientA) + /lock/im/lock-00000002 (ephemeral, clientB) +` +- 谁序号小谁为leader,其余client watch上一个节点 +- 选主算法最成熟可靠(kafka、hbase等核心依赖) + +IM场景一般推荐 etcd简化弱锁/leader,ZK留给系统核心状态协调。 + +--- + +## 7. 配置、服务中心高可用与运维 + +### etcd 高可用经验 + +- 严格**奇数节点**,部署在不同物理/虚拟机 +- 集群不要单点过大(3/5/7,9节点起raft性能剧降) +- 写压力大时可加只读proxy节点服务读流量 +- ETCD Quorum丢一半-1还能工作,网络分区则需主节点选举 +- 定期快照备份(避免数据丢失) + +### ZooKeeper 高可用经验 + +- 强依赖本地磁盘IO和网络(磁盘慢会影响整个集群) +- 生产部署应有3节点+,且主分布于不同机柜 +- 运维监控四大值:Session数,节点数,队列长度,磁盘使用 +- 配置自动快照清理,防止日志炸满盘 + +--- + +## 8. Watch/订阅机制 + +| | etcd | ZooKeeper | +|------|----------------|---------------| +| 订阅单位 | Key/Prefix | Node+children | +| 性能 | 优,push型 | 适中,触发频繁时延迟 | +| 断线重连| 自动续watch | 需重建watch | +| 响应方式| gRPC 推流 | 长连回复一次 | + +IM注册中心推荐分组/服务做前缀watch,配置中心单点/全量watch。 + +--- + +## 9. 典型API与最佳实践 + +### etcd API关键用法 + +- 服务注册:`PUT` + `lease` +- 服务发现:`GET` + `watch`前缀 +- 配置下发:`watch`配置key +- 锁/选主:`Txn` + `lease`/`revoke` +- 客户端推荐:官方Go/Java/Python客户端,gRPC native高性能 + +### ZooKeeper API核心用法 + +- 创建临时节点:`create -e` +- 子节点监听:`getChildren`+`watch` +- 数据节点监听:`getData`+`watch` +- 多client推荐Curator(Netflix Java库):自动重连、leader选举、分布式锁 + +--- + +## 10. 监控与可观测 + +### etcd 监控 + +- `etcdctl endpoint status --write-out=table` +- `/metrics` Prometheus +- 关注指标:leader changes、applied index、db size、raft term、watchers +- 告警:leader频繁切换,commit超时,落后节点,磁盘用量,丢失quorum + +### ZooKeeper 监控 + +- `mntr`四字命令 +- `srvr`命令 +- `zkruok`探活 +- 关注指标:zk_avg_latency,zk_packets_received,zk_outstanding_requests,zk_watch_count +- 告警:follower落后、磁盘空间临界、session耗尽 + +--- + +## 11. 服务治理典型难题与实践 + +### 11.1 雪崩/穿透问题 + +集中注销/重连接/瞬时压力高峰需限流、指数退避 + +### 11.2 大量watch + +分批订阅、合理分层、不能拿ZK/etcd当“分布式事件总线”用! + +### 11.3 分布式锁过期 + +保证租约续签可靠,否则锁提前失效会造成脏写 +两步锁写:Leader先拿锁再业务,“持有锁即责任” + +### 11.4 数据过大 + +- etcd每value最大1MB +- ZooKeeper每node最大1MB,节点数不宜过多 + +### 11.5 配置切换一致性 + +- 多Key批量更新用revision,etcd支持事务性变更,ZK推荐分批安插版本戳。 + +--- + +## 12. 线上案例推荐规范配置 + +### etcd 推荐关键配置 + +```ini +[member] +name: "etcd-1" +data-dir: "/data/etcd" +listen-peer-urls: "http://0.0.0.0:2380" +listen-client-urls: "http://0.0.0.0:2379" +advertise-client-urls: "http://10.0.1.1:2379" +initial-advertise-peer-urls: "http://10.0.1.1:2380" +initial-cluster: "etcd-1=http://10.0.1.1:2380,etcd-2=http://10.0.1.2:2380,etcd-3=http://10.0.1.3:2380" +auto-compaction-retention: 24 +quota-backend-bytes: 8589934592 # 8GB +max-txn-ops: 128 +max-request-bytes: 1572864 +``` + +### ZooKeeper 推荐关键配置 + +```ini +tickTime=2000 +initLimit=10 +syncLimit=5 +clientPort=2181 +maxClientCnxns=500 +autopurge.snapRetainCount=3 +autopurge.purgeInterval=1 +dataDir=/data/zk +server.1=10.0.1.1:2888:3888 +server.2=10.0.1.2:2888:3888 +server.3=10.0.1.3:2888:3888 +``` + +--- + +## 13. 运维与故障恢复 + +**etcd** + +- 三节点允许 1 节��挂,单节点写即刻告警 +- 日志快照每 30min/1h +- 备份 & 回滚训练 +- 定期升级至社区 LTS版,修复安全漏洞 + +**ZooKeeper** + +- 自动快照与日志清理,防止磁盘炸满 +- leader升降监控 +- 更换leader不会丢数据但会有连接抖动 + +--- + +## 14. 总结(IM & 云原生最佳实践) + +- **新业务首选 etcd,老业务兼容ZK**,逐步用etcd实现“配置中心+注册中心+分布式锁+选主”。 +- **服务注册发现、配置推送、选主、分布式锁**均推荐etcd,ZK仅对极低延迟、复杂树协同任务/Barrier场景下保留。 +- **强一致+单点支撑建议3/5节点,避免超大集群**,并和应用共用连接池,限流watch规模。 +- **结合K8s生态**,etcd一统天下(云原生最佳实践),ZK为大数据遗留系统兜底。 +- **定期归档watch/leader信息、自动告警**,问题早发现早恢复。 + +如需深入List-实现细节、Client代码模板、云原生(K8s/operator)无痛集成、分布式锁完整代码、灰度切换方案、请继续指定详细方向。 + +--- + +**文档结束** | Version 1.0 \ No newline at end of file diff --git "a/_drafts/markdown-\347\244\272\344\276\213\346\226\207\346\241\243.md" "b/_drafts/markdown-\347\244\272\344\276\213\346\226\207\346\241\243.md" new file mode 100644 index 000000000..041645311 --- /dev/null +++ "b/_drafts/markdown-\347\244\272\344\276\213\346\226\207\346\241\243.md" @@ -0,0 +1,351 @@ +# Markdown 风格指导 + +Markdown 之所以出色,主要是因为它能够编写纯文本并获得结果是出色的格式化输出。 +为了使后面的作者保持清晰状态,您的 Markdown 应该尽可能地简单以及与整个语料库可能一致。 + +我们寻求三个目标的平衡: + +1. *源文本可读且可移植。* +2. *Markdown 文件可随时间跨团队维护。* +3. *语法简单易记。* + +## 文档布局 + +通常,大多数文档都受益于以下布局的一些变化: + +```markdown +# 文档标题 + +简介。 + +[TOC] + +## 主题 + +内容。 + +## 参见 + +* https://link-to-more-info +``` + +1. `#文档标题`:第一个标题应该是个一级标题,并且理想情况下应与文件名相同或几乎相同。第一个一级标题用作页面``。 + +1. `作者`:*可选*。如果您想声明该文档的所有权,或者如果您对此引以为豪,请在标题下添加自己。然而,修订历史通常就足够了。 + +1. `简短介绍。` 用 1-3 个句子提供有关该主题的高级概述话题。想象自己是一个完全的新手,他进入了您的“扩展福报”文档,并且需要了解您视为理所当然的最基本假设。 +    “什么是福报?我为什么要扩展它?” + +1. `[TOC]`:如果您使用支持目录的代码托管服务,例如 Gitiles,在简短的介绍之后加上`[TOC]`。参见 +    [`[TOC]`文档](https://gerrit.googlesource.com/gitiles/+/master/Documentation/markdown.md#Table-of-contents)。 + +1. `## Topic`:其余标题应从 2 级开始。 + +1. `## 参见`:将其他链接放在底部,以方便想要了解更多或没有找到需要的信息的用户。 + +## 字符行限制 + +尽可能遵守项目的字符行限制。除了长网址和表格可能导致破例外。(标题也不能换行,但我们鼓励保持简短)。其余情况,就换行: + +```markdown +无人爱苦,亦无人寻之欲之,乃因其苦。事实中有痛苦可以带来巨大的,以一小例子为例, +有没有人剧烈运动,可以为他带来好处? + +* 邪恶克制的足球。在几乎不能说话迫害。我们觉得是很明智,即使这样,也不得不 + 保密。参见[福报文档](https://gerrit.googlesource.com/gitiles/+/master/Documentation/markdown.md)。 +``` + +通常,在长链接之前插入换行符可以保持可读性,同时最大程度地减少溢出: + +```markdown +无人爱苦,亦无人寻之欲之,乃因其苦。 参见 +[福报文档](https://gerrit.googlesource.com/gitiles/+/master/Documentation/markdown.md) +以了解详情。 +``` + +## 行尾空白 + +不要使用行尾的空格,而应该使用行尾的反斜杠。 + +[CommonMark规范](http://spec.commonmark.org/0.20/#hard-line-breaks)要求在行尾的两个空格应插入一个`<br />`标签。 +但是,很多目录中有对行尾随空白预提交检查,并且有许多 IDE 无论如何都会清理它。 + +最佳做法是完全避免使用`<br />`。Markdown 只需使用换行符即可为您创建段落标签:请习惯这种用法。 + +## 标题 + +### ATX 风格的标题 + +```markdown +## 标题 2 +``` + +带`=`或`-`下划线的标题可能很难维护,并且不符合其余的标题语法。 用户会问:`---`是指 H1 还是 H2? + +```markdown +标题 - 你记得住这是哪一级吗?**不要这样** +--------- +``` + +### 标题加空格 + +最好在 `#` 后加上空格,并在前后加上换行符: + +```markdown +……前面的文字 + +# 标题 1 + +后面的文字…… +``` + +缺少空格会使得源代码阅读起来有点困难: + +```markdown +……前面的文字 + +# 标题 1 +后面的文字…… **不要这样** +``` + +## 列表 + +### 对长列表使用“懒人编号” + +Markdown 足够聪明,可以让生成的 HTML 正确地呈现您的编号列表。对于可能会更改的较长列表,尤其是较长的嵌套列表,请使用“懒人”编号: + +```markdown +1. 福。 +1. 报。 + 1. 福福。 + 1. 报报。 +1. 爆炸 +``` + +但是,如果列表很小,并且您不怎么去改它,则最好使用完整编号的列表,因为在源代码中更好阅读: + +```markdown +1. 福。 +2. 报。 +3. 福报。 +``` + +### 嵌套列表加空格 + +当列表嵌套时,对有编号和无编号列表使用 4 个空格缩进: + +```markdown +1. 编号列表后空 2 格。 + 换行的话缩进 4 格。 +2. 又是空 2 格。 + +* 无编号列表后空 3 格。 + 换行的话缩进 4 格。 + 1. 编号列表后空 2 格。 + 嵌套列表中换行的话缩进 8 格。 + 2. 看起来很漂亮,是不是? +* 无编号列表后空 3 格。 +``` + +下面的写法能用,但很混乱: + +```markdown +* 一个空格 +换行没有缩进。 + 1. 不规则的嵌套…… **不要这样。** +``` + +即使没有嵌套,也可以用 4 个空格缩进,使得对于换行的文字保持一致的布局: + +```markdown +* 福, + 换行。 + +1. 2 个空格 + 以及缩进 4 个空格。 +2. 又是 2 空格。 +``` + +但是,当列表很小而且没有嵌套且只有一行时,两种列表都只需要空一格就够用了: + +```markdown +* 福 +* 报 +* 爆炸。 + +1. 福。 +2. 报。 +``` + +## 代码 + +### 内联代码 + +`反引号` 表示`内联代码`,会将所有括起来的内容原样展示。用于短代码引用和字段名称: + +```markdown +您将要运行 `really_cool_script.sh arg`。 + +请注意该表中的 `福报不断` 字段。 +``` + +当需要在抽象意义上指代某些文件类型,而不是某个具体的文件时,请使用内联代码: + +```markdown +请确保更新你的 `README.md`! +``` + +反引号是“转义” Markdown 元字符最常规的方法。在大多数情况下,需要转义,代码字体才有意义无论如何。 + +### 代码块 + +对于超过一行的代码引用,使用代码块: + +<pre> +```python +def Foo(self, bar): + """福报函数""" + self.bar = bar +``` +</pre> + +#### 声明语言 + +最好明确声明代码所用的语言,使得无论是语法高亮或着后续的修改者都无需猜测。 + +#### 缩进的代码块有时会更整洁 + +四个空格缩进也被解释为代码块。可以看起来更干净,更容易在源代码中阅读,但是无法指定语言。我们在编写许多简短代码片段时鼓励使用它们: + +```markdown +你需要运行: + + bazel run :thing -- --foo + +然后: + + bazel run :another_thing -- --bar + +接着: + + bazel run :yet_again -- --baz +``` + +#### 换行转义 + +读者希望大多数命令行片段都能直接复制粘贴到终端。因此最好对换行进行转义。可以用一个行尾的反斜杠: + +<pre> +```shell +bazel run :target -- --flag --foo=longlonglonglonglongvalue \ +--bar=anotherlonglonglonglonglonglonglonglonglonglongvalue +``` +</pre> + +#### 列表中的嵌套代码块 + +如果你需要在列表中放代码块,请确保将其正确缩进以免破坏列表: + +```markdown +* 列表项。 + + ```c++ + int foo; + ``` + +* 下一项。 +``` + +你还可以用 4 个空格来创建一个嵌套的代码块。只需要在列表缩进中再额外缩进 4 个空格即可: + +```markdown +* 列表项。 + + int foo; + +* 下一项。 +``` + +## 链接 + +长链接使 Markdown 源代码难以阅读并且破坏 80 个字符的换行规则。**请尽可能缩短链接**。 + +### 使用内容丰富的 Markdown 链接标题 + +就像在 HTML 一样,Markdown 链接语法也允许你设置链接标题。请用好它。 + +不要把链接标题标记为“链接”或“此处”。否则当读者快速扫视文档时,提供不了任何实际信息,并且也是对空间的浪费: + +```markdown +关于更多信息,请参见语法指导:[链接](syntax_guide.md)。 +或者,查看风格指导[这里](style_guide.md)。 +**不要这样。** +``` + +正确的做法是,自然地写出一句话,把带有链接的最恰当短语包含在里面: + +```markdown +关于更多信息,请参见[语法指导](syntax_guide.md)。 +或者,查看[风格指导](style_guide.md)。 +``` + +## 图像 + +尽量少用图像,并且尽量用简单的截屏。本指南的设计理念是,纯文本可以让用户更快地投入到工作交流中,减少读者分心和作者拖沓。 +但是,有时图像对显示您的意图也很有帮助。 + +参见[图像语法](https://gerrit.googlesource.com/gitiles/+/master/Documentation/markdown.md#Images). + +## 优先用列表而不是表格 + +任何 Markdown 中的表格都应该很小。复杂的大表格在源代码中很难阅读,最重要的是,**以后修改的痛苦**。 + +```markdown +水果 | 属性 | 备注 +---- | ---- | --- | --- +苹果 | [多汁](https://example.com/SomeReallyReallyReallyReallyReallyReallyReallyReallyLongQuery), 结实, 甘甜 | 苹果让你远离医生。 +香蕉 | [方便](https://example.com/SomeDifferentReallyReallyReallyReallyReallyReallyReallyReallyLongQuery), 软糯, 甘甜 | 与普遍的看法相反,大多数猿类更喜欢芒果。 + +**不要这样** +``` + +[列表](#列表)和副标题通常就足以展示相同的信息,尽管不太紧凑,却更易于编辑: + +```markdown +## 水果 + +### 苹果 + +* [多汁](https://SomeReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyReallyLongURL) +* 结实 +* 甘甜 + +苹果让你远离医生。 + +### 香蕉 + +* [方便](https://example.com/SomeDifferentReallyReallyReallyReallyReallyReallyReallyReallyLongQuery) +* 软糯 +* 甘甜 + +与普遍的看法相反,大多数猿类更喜欢芒果。 +``` + +但是,有些时候小的表格却更合适: + +```markdown + 名次| 是谁 | 原因 +---- | ---- | --- +季军 | 孙悟空 | 一个跟头十万八千里 +亚军 | 曹操 | 说曹操,曹操到 +冠军 | 香港记者 | 比其他的西方记者跑得还快 +``` + +## 优先使用 Markdown 语法而不是 HTML + +请尽可能使用标准的 Markdown 语法,并避免 HTML。如果你觉得似乎无法达到所需的目的,请重新考虑是否真的需要。除了[大表](#优先用列表而不是表格)外,Markdown 已经能满足几乎所有需求。 + +每多一点 HTML 或 Javascript,就会少一点可读性和可移植性。 +反过来,这也限制了与其他一些有用工具的整合,比如可以将源代码显示为纯文本或将其渲染的工具。参见[哲学](philosophy.md)。 + +Gitiles 不支持渲染 HTML。 diff --git "a/_drafts/\346\200\273\347\273\223\346\250\241\346\235\277.md" "b/_drafts/\346\200\273\347\273\223\346\250\241\346\235\277.md" new file mode 100644 index 000000000..32fbc7e95 --- /dev/null +++ "b/_drafts/\346\200\273\347\273\223\346\250\241\346\235\277.md" @@ -0,0 +1,34 @@ + +``` + +O2: +* 进展&数据: + * +* 问题/不足: + * + +O3:个人、团队以及基础工程能力提升 +包括和未来计划提升方面 +* 进展&数据: + * 个人方面:(个人成长和收获) + * 技术设计方面(质量、扩展性、代码风格、单测): + * 团队协作方面(团队内和团队外):是否有协作,协作 + * 团队方面:(团队贡献,包括协作上如何主动发现问题推动或者主动承担起了解决的成果、发现问题并推动形成机制、专利贡献、技术研究应用到项目等等) + * 发表专利情况(标题和链接列出来) + * 团队技术分享(标题和链接列出来) + * 外部合作(合作内容和结果) + * 工程方面:(工程能力方面数据:包括千行bug率、线上故障、稳定性问题等) + * 技术设计文档多少篇(文档链接贴进来): + * 月度千行bug率多少: + * 单测率覆盖: + * 完成交付多少需求Story(icafe链接贴出来): + * 处理**线上故障: + * 双周交付Story量(交付周期小于10天,自第一次绑定到上线时间): +* 问题/不足: + * 个人方面:(个人不足和未来提升的点:语言表达、自驱力、编码设计和架构能力) + * + * 团队方面:(团队贡献:专利、分享、人员协调、外部合作) + * + * 工程方面:(工程能力方面:技术设计的可扩展性以及抽象性和边界考虑、设计文档的质量和详细程度、) + * +``` diff --git "a/_drafts/\346\226\207\346\241\243\346\250\241\346\235\277.md" "b/_drafts/\346\226\207\346\241\243\346\250\241\346\235\277.md" new file mode 100644 index 000000000..5cbc38c08 --- /dev/null +++ "b/_drafts/\346\226\207\346\241\243\346\250\241\346\235\277.md" @@ -0,0 +1,220 @@ +Design Doc Template +目标 +“我们要解决什么问题?” + +用几句话说明该设计文档的关键目的,让读者能够一眼得知自己是否对该设计文档感兴趣。 + +如: + +“本文描述 Spanner 的顶层设计” + +继而,使用 Bullet Points 描述该设计试图达到的重要目标,如: + +可扩展性 +多版本 +全球分布 +同步复制 +非目标也可能很重要。 + +非目标并非单纯目标的否定形式,也不是与解决问题无关的其它目标,而是一些可能是读者非预期的、本可作为目标但并没有的目标,如: + +高可用性 +高可靠性 +如果可能,解释是基于哪些方面的考虑将之作为非目标。如: + +可维护性: 本服务只是过渡方案,预计寿命三个月,待 XX 上线运行后即可下线 +设计不是试图达到完美,而是试图达到平衡。 显式地声明哪些是目标,哪些是非目标,有助于帮助读者理解下文中设计决策的合理性,同时也有助于日后迭代设计时,检查最初的假设是否仍然成立。 + +背景 +“我们为什么要解决这个问题?” + +为设计文档的目标读者提供理解详细设计所需的背景信息。 + +按读者范围来提供背景。见上文关于目标读者的圈定。 + +设计文档应该是“自足的”(self-contained),即应该为读者提供足够的背景知识,使其无需进一步的查阅资料即可理解后文的设计。 + +保持简洁,通常以几段为宜,每段简要介绍即可。如果需要向读者提供进一步的信息,最好只提供链接。 + +警惕知识的诅咒。 知识的诅咒(Curse of knowledge)是一种认知偏差,指人在与他人交流的时候,下意识地假设对方拥有理解交流主题所需要的背景知识。 + +背景通常可以包括: + +需求动机以及可能的例子。 如,“ xxx微服务模式正在公司内变得流行,但是缺少一个通用的、封装了常用内部工具及服务接口的微服务框架”。 + +这是放置需求文档的链接的好地方。 +此前的版本以及它们的问题。 如,“yyy是之前的应用框架, 有以下特点,…………, 但是有以下局限性及历史遗留问题”。 + +其它已有方案, 如公司内其它方案或开源方案, “xxx vs. yyy vs. bbb vs. ccc” + +相关的项目,如 “xxx 框架中可能会对接的其它 系统” +不要在背景中写你的设计,或对问题的解决思路。 + +总体设计 +“我们如何解决这个问题?” + +用一页描述高层设计。 + +说明系统的主要组成部分,以及一些关键设计决策。应该说明该系统的模块和决策如何满足前文所列出的目标。 + +本设计文档的评审人应该能够根据该总体设计理解你的设计思路并做出评价。描述应该对一个新加入的、不在该项目工作的腾讯工程师而言是可以理解的。 + +推荐使用 系统关系图 描述设计。它可以使读者清晰地了解文中的新系统和已经熟悉的系统间的关系。它也可以包含新系统内部概要的组成模块。 + +注意:不要只放一个图而不做任何说明,请根据上面小节的要求用文字描述设计思想。 + +不要在这里描述细节,放在下一章节中; 不要在这里描述背景,放在上一章节中。 + +详细设计 +在这一节中,除了介绍设计方案的细节,还应该包括在产生最终方案过程中,主要的设计思想及权衡(tradeoff)。这一节的结构和内容因设计对象(系统,API,流程等)的不同可以自由决定,可以划分一些小节来更好地组织内容,尽可能以简洁明了的结构阐明整个设计。 + +不要过多写实现细节。就像我们不推荐添加只是为了说明代码做了什么的注释,我们也不推荐在设计文档中只说明你具体要怎么实现该系统。否则,为什么不直接实现呢? + +以下内容可能是实现细节例子,不适合在设计文档中讨论: + +API 的所有细节 +存储系统的 Data Schema +具体代码或伪代码 +该系统各模块代码的存放位置、各模块代码的布局 +该系统使用的编译器版本 +开发规范 +通常可以包含以下内容(注意,小节的命名可以更改为更清晰体现内容的标题): + +各子模块的设计 +阐明一些复杂模块内部的细节,可以包含一些模块图、流程图来帮助读者理解。可以借助时序图进行展现,如一次调用在各子模块中的运行过程。 + +每个子模块需要说明自己存在的意义。如无必要,勿添模块。 + +如果没有特殊情况(例如该设计文档是为了描述并实现一个核心算法),不要在系统设计加入代码或者伪代码。 + +API接口 +如果设计的系统会暴露 API 接口,那么简要地描述一下API会帮助读者理解系统的边界。 + +避免将整个接口复制粘贴到文档中,因为在特定编程语言中的接口通常包含一些语言细节而显得冗长,并且有一些细节也会很快变化。着重表现API接口跟设计最相关的主要部分即可。 + +存储 +介绍系统依赖的存储设计。该部分内容应该回答以下问题,如果答案并非显而易见: + +该系统对数据/存储有哪些要求? + +该系统会如何使用数据? +数据是什么类型的? +数据规模有多大? +读写比是多少?读写频率有多高? +对可扩展性是否有要求? +对原子性要求是什么? +对一致性要求是什么?是否需要支持事务? +对可用性要求是什么? +对性能的要求是什么? +………… +基于上面的事实,数据库应该如何选型? + +选用关系型数据库还是非关系型数据库?是否有合适的中间件可以使用? +如何分片?是否需要分库分表?是否需要副本? +是否需要异地容灾? +是否需要冷热分离? +………… +数据的抽象以及数据间关系的描述至关重要。可以借助 ER 图(Entity Relationshiop) 的方式展现数据关系。 + +回答上述问题时,尽可能提供数据,将数据作为答案或作为辅助。 不要回答“数据规模很大,读写频繁”,而是回答“预计数据规模为 300T, 3M 日读出, 0.3M 日写入, 巅峰 QPS 为 300”。这样才能为下一步的具体数据库造型提供详细的决策依据,并让读者信服。 + +注意:在选型时也应包括可能会造成显著影响的非技术因素,如费用。 + +避免将所有数据定义(data schema)复制粘贴到文档中,因为 data schema 更偏实现细节。 + +其他方案 +“我们为什么不这么解决这个问题?” + +在介绍了最终方案后,可以有一节介绍一下设计过程中考虑过的其他设计方案(Alternatives Considered)、它们各自的优缺点和权衡点、以及导致选择最终方案的原因等。通常,有经验的读者(尤其是方案的审阅者)会很自然地想到一些其他设计方案,如果这里的介绍描述了没有选择这些方案的原因,就避免读者带着疑问看完整个设计再来询问作者。这一节可以体现设计的严谨性和全面性。 + +交叉关注点 +基础设施 +如果基础设施的选用需要特殊考量,则应该列出。 + +如果该系统的实现需要对基础设施进行增强或变更,也应该在此讨论。 + +可扩展性 +你的系统如何扩展?横向扩展还是纵向扩展?注意数据存储量和流量都可能会需要扩展。 + +安全 & 隐私 +安全性通常需要在设计初期做设计。不同于其它部分是可选的,安全部分往往是必需的。即使你的系统不需要考虑安全和隐私,也需要显式地在本章说明为何是不必要的。 + +安全性如何保证? + +系统如何授权、鉴权和审计(Authorization, Authentication and Auditing, AAA)? + +是否需要破窗(break-glass)机制? + +有哪些已知漏洞和潜在的不安全依赖关系? + +是否应该与专业安全团队讨论安全性设计评审? + +…… + +数据完整性 +如何保证数据完整性(Data Integrity)? + +如何发现存储数据的损坏或丢失?如何恢复?由数据库保证即可,还是需要额外的安全措施? + +为了数据完整性,需要对稳定性、性能、可复用性、可维护性造成哪些影响? + +延迟 +声明延迟的预期目标。描述预期延迟可能造成的影响,以及相关的应对措施。 + +冗余 & 可靠性 +是否需要容灾?是否需要过载保护、有损降级、接口熔断、轻重分离? + +是否需要备份?备份策略是什么?如何修复?在数据丢失和恢复之间会发生什么? + +稳定性 +SLA 目标是什么? 如果监控?如何保证? + +稳定性设计清单(Checklist) +检查项目 已规划 不适用 其他 +乐观并发(Optimistic Concurrency) +事务失败补偿(Compensate transaction failure) +优雅降级(Graceful degrade) +减少健谈通信(Reduce chatty communication) +分布式跟踪(Distributed tracing) +卸载至后台(Background offloading) +卸载至网关(Gateway offloading) +命令查询责任分离(CQRS) +基础设施即代码(Infra as code) +处理瞬时故障(Handle transient failure) +复制备份(Replication) +断路器(Circuit breaker) +幂等(Idempotency) +异步消息(Async Messaging) +接口版本控制(API versioning) +支持混沌工程(Chaos Engineering) +支持独立部署(Independent deployment) +支持生产环境测试(Test in production) +故障转移(Failover) +数据保留策略(Rentention policy) +最终一致性(Eventual consistency) +特性开关(Feature toggles) +缓存策略(Caching policy) +舱壁隔离(Bulkhead) +负载均衡(Load balancing) +负载整形(Load leveling) +避免热点分区(Avoid hotspots) +配置即代码(Config as code) +限流(Thottling) +领域事件(Domain events) +领域封装(Domain encapuslation) +高内聚低耦合(HCLC) +黑灰白名单管理(Blacklist/Greylist/Whitelist) +外部依赖 +你的外部依赖的可靠性(如 SLA)如何?会对你的系统的可靠性造成何种影响? + +如果你的外部依赖不可用,会对你的系统造成何种影响? + +除了服务级的依赖外,不要忘记一些隐含的依赖,如 DNS 服务、时间协议服务、运行集群等。 + +实现计划 +描述时间及人力安排(如里程碑)。 这利于相关人员了解预期,调整工作计划。 + +未来计划 +未来可能的计划会方便读者更好地理解该设计以及其定位。 + +我们确实应该把设计限定在当前问题,但是该设计可能是更高层系统所要解决问题的一部分,或者只是阶段性方案。 读者可能会对方案的完整性有所疑问,会质疑到底问题是否得到完整解决,甚至会质疑该问题在更高层的系统中是否确实值得解决。 “背景(过去)– 当前方案 – 未来计划” 三者的结合会为读者提供更好的全景图。 diff --git "a/_drafts/\351\241\271\347\233\256\346\226\207\346\241\243\346\250\241\346\235\277.md" "b/_drafts/\351\241\271\347\233\256\346\226\207\346\241\243\346\250\241\346\235\277.md" new file mode 100644 index 000000000..6e10293c2 --- /dev/null +++ "b/_drafts/\351\241\271\347\233\256\346\226\207\346\241\243\346\250\241\346\235\277.md" @@ -0,0 +1,32 @@ + + +## 流程 + +流程图, uml + +## 项目参与人员、时间节点 + + +模块、参与人员、RD、qa、op、 排期、预计完成时间、实际完成时间、变更情况、上线情况 + + +## 接口定义 + +## 协议定义 + + +## 现状 + 注意: 现状的描述非常重要,首先在改动代码之前,首先需要,运行并熟悉现有情况,避免踩中本来就有坑!!!,防止出现需求之外的case,或者,原有功能本来就有问题!!! + +## 配置参数与测试点 + + +## 上线备忘录 + + +## 测试问题 + + +## 复盘 + + diff --git a/_includes/footer.html b/_includes/footer.html new file mode 100644 index 000000000..f31be98be --- /dev/null +++ b/_includes/footer.html @@ -0,0 +1,270 @@ +<!-- Footer --> +<footer> + <div class="container"> + <div class="row"> + <div class="col-lg-8 col-lg-offset-2 col-md-10 col-md-offset-1"> + <ul class="list-inline text-center"> + {% if site.RSS %} + <li> + <a href="{{ "/feed.xml" | prepend: site.baseurl }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-rss fa-stack-1x fa-inverse"></i> + </span> + </a> + </li> + {% endif %} + <!-- add jianshu add target = "_blank" to <a> by BY --> + {% if site.jianshu_username %} + <li> + <a target="_blank" href="https://www.jianshu.com/u/{{ site.jianshu_username }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-stack-1x fa-inverse">简</i> + </span> + </a> + </li> + {% endif %} + {% if site.twitter_username %} + <li> + <a href="https://twitter.com/{{ site.twitter_username }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-twitter fa-stack-1x fa-inverse"></i> + </span> + </a> + </li> + {% endif %} + + <!-- add Weibo, Zhihu by Hux, add target = "_blank" to <a> by Hux --> + {% if site.zhihu_username %} + <li> + <a target="_blank" href="https://www.zhihu.com/people/{{ site.zhihu_username }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-stack-1x fa-inverse">知</i> + </span> + </a> + </li> + {% endif %} + {% if site.weibo_username %} + <li> + <a target="_blank" href="http://weibo.com/{{ site.weibo_username }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-weibo fa-stack-1x fa-inverse"></i> + </span> + </a> + </li> + {% endif %} + + + {% if site.facebook_username %} + <li> + <a target="_blank" href="https://www.facebook.com/{{ site.facebook_username }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-facebook fa-stack-1x fa-inverse"></i> + </span> + </a> + </li> + {% endif %} + {% if site.github_username %} + <li> + <a target="_blank" href="https://github.com/{{ site.github_username }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-github fa-stack-1x fa-inverse"></i> + </span> + </a> + </li> + {% endif %} + {% if site.linkedin_username %} + <li> + <a target="_blank" href="https://www.linkedin.com/in/{{ site.linkedin_username }}"> + <span class="fa-stack fa-lg"> + <i class="fa fa-circle fa-stack-2x"></i> + <i class="fa fa-linkedin fa-stack-1x fa-inverse"></i> + </span> + </a> + </li> + {% endif %} + </ul> + <p class="copyright text-muted"> + Copyright © {{ site.title }} {{ site.time | date: '%Y' }} + <br> + Theme on <a href="{{ site.github_repo }}">GitHub</a> | + <iframe + style="margin-left: 2px; margin-bottom:-5px;" + frameborder="0" scrolling="0" width="100px" height="20px" + src="https://ghbtns.com/github-btn.html?user={{ site.github_username }}&repo={{ site.github_username }}.github.io&type=star&count=true" > + </iframe> + </p> + </div> + </div> + </div> +</footer> + +<!-- jQuery --> +<script src="{{ "/js/jquery.min.js " | prepend: site.baseurl }}"></script> + +<!-- Bootstrap Core JavaScript --> +<script src="{{ "/js/bootstrap.min.js " | prepend: site.baseurl }}"></script> + +<!-- Custom Theme JavaScript --> +<script src="{{ "/js/hux-blog.min.js " | prepend: site.baseurl }}"></script> + +<!-- Service Worker --> +{% if site.service-worker %} +<script type="text/javascript"> + if(navigator.serviceWorker){ + // For security reasons, a service worker can only control the pages that are in the same directory level or below it. That's why we put sw.js at ROOT level. + navigator.serviceWorker + .register('/sw.js') + .then((registration) => {console.log('Service Worker Registered. ', registration)}) + .catch((error) => {console.log('ServiceWorker registration failed: ', error)}) + } +</script> +{% endif %} + + +<!-- async load function --> +<script> + function async(u, c) { + var d = document, t = 'script', + o = d.createElement(t), + s = d.getElementsByTagName(t)[0]; + o.src = u; + if (c) { o.addEventListener('load', function (e) { c(null, e); }, false); } + s.parentNode.insertBefore(o, s); + } +</script> + +<!-- + Because of the native support for backtick-style fenced code blocks + right within the Markdown is landed in Github Pages, + From V1.6, There is no need for Highlight.js, + so Huxblog drops it officially. + + - https://github.com/blog/2100-github-pages-now-faster-and-simpler-with-jekyll-3-0 + - https://help.github.com/articles/creating-and-highlighting-code-blocks/ + - https://github.com/jneen/rouge/wiki/list-of-supported-languages-and-lexers +--> +<!-- + <script> + async("http://cdn.bootcss.com/highlight.js/8.6/highlight.min.js", function(){ + hljs.initHighlightingOnLoad(); + }) + </script> + <link href="http://cdn.bootcss.com/highlight.js/8.6/styles/github.min.css" rel="stylesheet"> +--> + + +<!-- jquery.tagcloud.js --> +<script> + // only load tagcloud.js in tag.html + if($('#tag_cloud').length !== 0){ + async('{{ "/js/jquery.tagcloud.js" | prepend: site.baseurl }}',function(){ + $.fn.tagcloud.defaults = { + //size: {start: 1, end: 1, unit: 'em'}, + color: {start: '#bbbbee', end: '#0085a1'}, + }; + $('#tag_cloud a').tagcloud(); + }) + } +</script> + +<!--fastClick.js --> +<script> + async("//cdnjs.cloudflare.com/ajax/libs/fastclick/1.0.6/fastclick.min.js", function(){ + var $nav = document.querySelector("nav"); + if($nav) FastClick.attach($nav); + }) +</script> + + +<!-- Google Analytics --> +{% if site.ga_track_id %} +<script> + // dynamic User by Hux + var _gaId = '{{ site.ga_track_id }}'; + var _gaDomain = '{{ site.ga_domain }}'; + + // Originial + (function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){ + (i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o), + m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m) + })(window,document,'script','//www.google-analytics.com/analytics.js','ga'); + + ga('create', _gaId, _gaDomain); + ga('send', 'pageview'); +</script> +{% endif %} + + +<!-- Baidu Tongji --> +{% if site.ba_track_id %} +<script> + // dynamic User by Hux + var _baId = '{{ site.ba_track_id }}'; + + // Originial + var _hmt = _hmt || []; + (function() { + var hm = document.createElement("script"); + hm.src = "//hm.baidu.com/hm.js?" + _baId; + var s = document.getElementsByTagName("script")[0]; + s.parentNode.insertBefore(hm, s); + })(); +</script> +{% endif %} + + + +<!-- Side Catalog --> +{% if page.catalog %} +<script type="text/javascript"> + function generateCatalog (selector) { + var P = $('div.post-container'),a,n,t,l,i,c; + a = P.find('h1,h2,h3,h4,h5,h6'); + a.each(function () { + n = $(this).prop('tagName').toLowerCase(); + i = "#"+$(this).prop('id'); + t = $(this).text(); + c = $('<a href="'+i+'" rel="nofollow">'+t+'</a>'); + l = $('<li class="'+n+'_nav"></li>').append(c); + $(selector).append(l); + }); + return true; + } + + generateCatalog(".catalog-body"); + + // toggle side catalog + $(".catalog-toggle").click((function(e){ + e.preventDefault(); + $('.side-catalog').toggleClass("fold") + })) + + /* + * Doc: https://github.com/davist11/jQuery-One-Page-Nav + * Fork by Hux to support padding + */ + async("{{ '/js/jquery.nav.js' | prepend: site.baseurl }}", function () { + $('.catalog-body').onePageNav({ + currentClass: "active", + changeHash: !1, + easing: "swing", + filter: "", + scrollSpeed: 700, + scrollOffset: 0, + scrollThreshold: .2, + begin: null, + end: null, + scrollChange: null, + padding: 80 + }); + }); +</script> +{% endif %} + diff --git a/_includes/head.html b/_includes/head.html new file mode 100644 index 000000000..fbf7a47cf --- /dev/null +++ b/_includes/head.html @@ -0,0 +1,55 @@ +<head> + <meta charset="utf-8"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="google-site-verification" content="xBT4GhYoi5qRD5tr338pgPM5OWHHIDR6mNg1a3euekI" /> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <meta name="description" content="{{ site.description }}"> + <meta name="keywords" content="{{ site.keyword }}"> + <meta name="theme-color" content="{{ site.chrome-tab-theme-color }}"> + + <title>{% if page.title %}{{ page.title }} - {{ site.SEOTitle }}{% else %}{{ site.SEOTitle }}{% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/_includes/nav.html b/_includes/nav.html new file mode 100644 index 000000000..33c15d375 --- /dev/null +++ b/_includes/nav.html @@ -0,0 +1,10 @@ + + diff --git a/_includes/post-folder-browser.html b/_includes/post-folder-browser.html new file mode 100644 index 000000000..1a93a8b6c --- /dev/null +++ b/_includes/post-folder-browser.html @@ -0,0 +1,48 @@ +{%- comment -%} + 从 `_posts//` 中取第一级 `` 分组; + 直接位于 `_posts/` 根目录的文章会以文件名作为分组名,包含扩展名中的 `.`,因此跳过。 + + 参数: + - mode:`cloud` 输出目录链接;`cards` 输出首页卡片;`list` 输出全部目录下的文章列表;`single` 输出单个目录的文章列表。 + - folder:当 mode 为 `single` 时,指定 `_posts//` 目录名。 +{%- endcomment -%} +{% assign folder_groups = site.posts | group_by_exp: "post", "post.path | remove_first: '_posts/' | split: '/' | first" | sort: "name" %} +{% for folder in folder_groups %} +{% if folder.name contains '.' %}{% continue %}{% endif %} +{% assign folder_meta = site.data.post_folders[folder.name] %} +{% assign folder_title = folder_meta.title | default: folder.name %} +{% capture folder_href %}{{ site.baseurl }}/folders/{{ folder.name }}/{% endcapture %} +{% assign folder_href = folder_href | replace: '//', '/' %} +{% if include.mode == 'single' and include.folder != folder.name %}{% continue %}{% endif %} +{% if include.mode == 'cards' %} + + {{ folder_title }} + {{ folder.items.size }} 篇文章 + +{% elsif include.mode == 'list' or include.mode == 'single' %} +
+ + {{ folder_title }} + + {% assign sorted_posts = folder.items | sort: "date" %} + {% for post in sorted_posts %} +
+ +

+ {{ post.title }} +

+ {% if post.subtitle %} +

+ {{ post.subtitle }} +

+ {% endif %} +
+ +
+
+ {% endfor %} +
+{% else %} +{{ folder_title }} ({{ folder.items.size }}) +{% endif %} +{% endfor %} diff --git a/_layouts/default.html b/_layouts/default.html new file mode 100644 index 000000000..6aad424a5 --- /dev/null +++ b/_layouts/default.html @@ -0,0 +1,22 @@ + + + +{% include head.html %} + + + + + {% include nav.html %} + + {{ content }} + + {% include footer.html %} + + + + + + + + + diff --git a/_layouts/keynote.html b/_layouts/keynote.html new file mode 100644 index 000000000..59f138127 --- /dev/null +++ b/_layouts/keynote.html @@ -0,0 +1,238 @@ +--- +layout: default +--- + + + + + + + +
+ +
+ + +
+
+
+ + +
+ + {{ content }} + +
+ + + + + + {% if site.gitalk.enable %} + + + + +
+ + {% endif %} + + + {% if site.disqus.enable %} + +
+
+ +
+
+ + {% endif %} + +
+ + + +
+
+
+ + + + + + +{% if site.disqus.enable %} + + + +{% endif %} + + +{% if site.anchorjs %} + + + + + +{% endif %} diff --git a/_layouts/page.html b/_layouts/page.html new file mode 100644 index 000000000..be065bddb --- /dev/null +++ b/_layouts/page.html @@ -0,0 +1,25 @@ +--- +layout: default +--- + + +
+
+
+
+
+

{% if page.title %}{{ page.title }}{% else %}{{ site.title }}{% endif %}

+
+
+
+
+
+ + +
+
+
+ {{ content }} +
+
+
diff --git a/_layouts/post.html b/_layouts/post.html new file mode 100644 index 000000000..bd43bc19d --- /dev/null +++ b/_layouts/post.html @@ -0,0 +1,239 @@ +--- +layout: default +--- + + + + + + + +
+
+
+
+
+
+
+ {%- comment -%} + 从 post 文件路径派生 category:`_posts//.md` 中的 ``。 + 如果 post 直接放在 `_posts/` 根目录下,去掉 `_posts/` 前缀后的路径 + 不再包含 `/`,此时 split 的第一段就是文件名本身、与原字符串相等, + 意味着没有所属子目录,因此跳过、不展示分类徽章。 + {%- endcomment -%} + {% assign post_relpath = page.path | remove_first: '_posts/' %} + {% assign post_category = post_relpath | split: '/' | first %} + {% if post_category != post_relpath and post_category != '' %} + {{ post_category }} + {% endif %} + {% for tag in page.tags %} + {{ tag }} + {% endfor %} +
+

{{ page.title }}

+ {% comment %} + always create a h2 for keeping the margin , Hux + {% endcomment %} + {% comment %} if page.subtitle {% endcomment %} +

{{ page.subtitle }}

+ {% comment %} endif {% endcomment %} + Posted by {% if page.author %}{{ page.author }}{% else %}{{ site.title }}{% endif %} on {{ page.date | date: "%B %-d, %Y" }} +
+
+
+
+
+ + +
+
+
+ + + {% if page.catalog %} + + {% endif %} + + +
+ + {{ content }} + +
+ + + + + + {% if site.gitalk.enable %} + + + +
+ + + + + {% endif %} + + + {% if site.disqus_username %} + +
+
+
+ + {% endif %} + +
+ + + +
+
+
+ + +{% if site.disqus_username %} + + + +{% endif %} + + +{% if site.anchorjs %} + + + + + +{% endif %} \ No newline at end of file diff --git "a/_posts/android/2026-04-24-adb\351\227\256\351\242\230\346\216\222\346\237\245.md" "b/_posts/android/2026-04-24-adb\351\227\256\351\242\230\346\216\222\346\237\245.md" new file mode 100644 index 000000000..47641a503 --- /dev/null +++ "b/_posts/android/2026-04-24-adb\351\227\256\351\242\230\346\216\222\346\237\245.md" @@ -0,0 +1,70 @@ +--- +layout: post +title: adb 端口占用排查 +subtitle: 针对 5037 端口冲突的最小检查流程 +date: 2026-04-24 +author: BY +header-img: img/post-bg-android.jpg +catalog: true +tags: + - Android + - adb + - Troubleshooting +--- + +>这篇记录只保留最常用的一条链路:先确认 adb server 状态,再定位 5037 端口是谁占了。 + +## 常见现象 + +遇到下面这类问题时,优先怀疑 adb server 没起来,或者 5037 端口被别的进程占用: + +- `adb devices` 卡住 +- `adb start-server` 启动失败 +- 提示无法绑定本地端口 + +## 排查步骤 + +### 1. 直接以前台方式启动 adb server + +```bash +adb nodaemon server +``` + +这一步的目的,是先看 adb 自身能不能正常启动,以及有没有更直接的报错信息。 + +### 2. 检查 5037 端口占用 + +Windows 下可以先看端口: + +```bash +netstat -ano | findstr "5037" +``` + +如果有输出,说明已经有进程占住了 adb 默认端口。 + +### 3. 根据 PID 反查进程名 + +```bash +tasklist | findstr "PID" +``` + +把上一步查到的 PID 替换进去,就能确认是哪个进程在占用端口。 + +### 4. 结束冲突进程后重试 + +确认确实不是自己需要保留的进程后,再结束它,然后重新执行: + +```bash +adb start-server +adb devices +``` + +## 处理建议 + +- 如果是旧 adb 进程残留,结束后重启 adb 即可 +- 如果是 Android Studio、模拟器或第三方工具占用,要先确认是否可以关闭对应程序 +- 不建议在没确认进程来源前直接批量 kill,避免误伤正在使用的调试工具 + +## 参考截图 + +![image](https://github.com/user-attachments/assets/897c2100-7f47-4dcc-94e3-e63b1919c913) diff --git a/_posts/android/2026-04-24-android_perfetto.md b/_posts/android/2026-04-24-android_perfetto.md new file mode 100644 index 000000000..ff2bf93d9 --- /dev/null +++ b/_posts/android/2026-04-24-android_perfetto.md @@ -0,0 +1,137 @@ +--- +layout: post +title: Android Perfetto 堆分析记录 +subtitle: heapprofd 与 java_hprof 采集时的环境要求和配置片段 +date: 2026-04-24 +author: BY +header-img: img/post-bg-android.jpg +catalog: true +tags: + - Android + - Perfetto + - Profiling +--- + +>把原先零散命令整理成“环境前提 + 采集步骤 + 配置片段”的形式,方便后续直接复用。 + +## 使用前提 + +当前记录主要针对 Linux / macOS 主机侧执行,设备侧结论如下: + +- Android 13:在 `user root` 类环境里可行 +- Android 14:兼容性不稳定,部分设备需要先在开发者选项里打开“系统跟踪”之类的开关 + +如果设备权限、ROM 类型或系统版本不满足,后面的命令即使执行成功,也可能抓不到有效数据。 + +## 采集前准备 + +### 1. 打开 traced 与 libc hook + +```bash +adb shell setprop persist.traced.enable 1 +adb shell setprop libc.debug.hooks.enable 1 +``` + +### 2. 放宽 SELinux(仅限确认环境可控时) + +```bash +adb shell su root setenforce 0 +``` + +这一步只适合调试环境,不应默认带到正式设备配置里。 + +## heapprofd 采集命令 + +原始笔记里的示例命令如下,输出目录为本地的 `wifi_data_release/`: + +```bash +python3 heap_profile.py \ + -n com.xiaomi.mi_connect_service:continuity \ + -c 3000 \ + -i 1024 \ + -o ./wifi_data_release/ +``` + +可以重点关注三个参数: + +- `-n`:目标进程名 +- `-c`:采样次数 / 总量相关控制 +- `-i`:采样间隔 + +## 直接使用 perfetto 命令时的注意点 + +曾尝试直接执行: + +```bash +./perfetto -c 1.perfetto.config --txt --out /data/misc/1 +``` + +原始结果备注为“无数据”。遇到这种情况,通常优先检查: + +1. 目标进程名是否写对 +2. 设备是否真的支持对应 data source +3. traced / hook / 开发者选项是否已经开启 +4. buffer 是否过小,导致 UI 解析或采集结果异常 + +## 配置片段示例 + +下面保留一份原始配置思路,核心用途是记录 heapprofd 与 java hprof 的字段结构。 + +```text +buffers: { + size_kb: 63488000 + fill_policy: DISCARD +} +buffers: { + size_kb: 63488000 + fill_policy: DISCARD +} +data_sources: { + config { + name: "android.packages_list" + target_buffer: 1 + } +} +data_sources: { + config { + name: "android.heapprofd" + target_buffer: 0 + heapprofd_config { + sampling_interval_bytes: 4096 + process_cmdline: "com.xiaomi.mi_connect_service:idm" + shmem_size_bytes: 8388608 + heaps: "com.android.art" + continuous_dump_config { + dump_phase_ms: 10000 + dump_interval_ms: 2000 + } + } + } +} +data_sources: { + config { + name: "android.java_hprof" + target_buffer: 0 + java_hprof_config { + process_cmdline: "com.xiaomi.mi_connect_service:idm" + continuous_dump_config { + dump_phase_ms: 10000 + dump_interval_ms: 2000 + } + } + } +} +duration_ms: 30000 +``` + +其中原始经验里有一条值得保留: + +>buffer 太小的时候,Perfetto UI 可能解析失败,因此当数据量较大时需要主动放大。 + +## 参考方向 + +原始笔记里顺手记过几篇外部资料,后续如果要继续深入看 Perfetto 配置或内存采样,可以沿着这些关键词继续搜: + +- `Perfetto heapprofd` +- `Perfetto java_hprof` +- `heap_profile.py` diff --git a/_posts/android/2026-04-24-android_simpleperf.md b/_posts/android/2026-04-24-android_simpleperf.md new file mode 100644 index 000000000..81420897a --- /dev/null +++ b/_posts/android/2026-04-24-android_simpleperf.md @@ -0,0 +1,163 @@ +--- +layout: post +title: Android simpleperf 使用记录 +subtitle: 从设备采样到生成火焰图的最小流程 +date: 2026-04-24 +author: BY +header-img: img/post-bg-android.jpg +catalog: true +tags: + - Android + - simpleperf + - Profiling +--- + +>把原始笔记中的命令、前提和出图步骤重新整理成一条完整链路。 + +## 环境结论 + +原始记录里的经验是: + +- `userdebug` ROM 上 profiler 不一定稳定 +- 某些 `user` 版本的 profiler 反而更容易直接工作,原笔记里的表述是“profiler build for user_build” + +因此在开始抓数据前,先确认目标设备的 ROM 类型和 simpleperf 二进制是否匹配。 + +## 构建前提 + +如果要分析自己的 App,先保证目标构建可调试: + +```gradle +buildTypes { + debug { + debuggable true + } +} +``` + +另外,符号化分析时需要准备带符号表的 so。原始笔记提到,可以把 `merged_native_libs` 下包含 symbol table 的 so 一并放到用于解析的目录中。 + +## 先看帮助 + +```bash +simpleperf --help +simpleperf record --help +``` + +## 直接抓进程 + +最小采样命令: + +```bash +simpleperf record -p 20510 --duration 10 +``` + +如果需要调用栈和符号目录: + +```bash +simpleperf record -g -p 20510 --duration 30 --symfs /sdcard/ +``` + +其中 `--symfs` 用来指定带符号 so 的位置。 + +## 生成火焰图 + +主机侧需要 Perl 环境,然后可以按下面方式折叠并出图: + +```bash +perl stackcollapse-perf.pl perf_script_output_file.txt | perl flamegraph.pl > a.html +``` + +如果本机还没准备环境,原始记录里提到至少要先准备: + +1. Perl 5 +2. `FlameGraph` +3. `simpleperf` 对应分析脚本目录 + +## 进程采样示例 + +原始记录里保留的一条常用命令如下: + +```bash +simpleperf record -g -p 20510 --duration 30 -f 12500 --call-graph fp -o perf.data +``` + +适合已经知道目标进程 PID、并希望直接拿到 `perf.data` 的场景。 + +## 线程级采样流程 + +如果要抓单个线程,可以按这条链路走: + +### 1. 把 simpleperf 推到设备 + +```bash +adb push ./simpleperf /data/simpleperf +adb shell chmod 777 /data/simpleperf +``` + +这里的 `./simpleperf` 指的是你已经从 Android 平台工具、AOSP 或 simpleperf 工具包里准备好的设备侧可执行文件。 + +原始示例里使用的是类似下面的 Windows 路径: + +```bash +adb push F:/simpleperf /data/simpleperf +``` + +### 2. 必要时放宽 SELinux(仅调试环境) + +```bash +adb shell setenforce 0 +``` + +### 3. 在设备上找线程 + +```bash +adb shell +top -H -O pid -d 1 +``` + +原始笔记这里是“以 vivo 手机为例”,核心动作不变:先进入 shell,再用 `top -H -O pid -d 1` 找到想看的线程。 + +找到目标线程后,使用对应 PID / TID 采样。 + +### 4. 在设备侧记录数据 + +```bash +simpleperf record -g -p n --duration 20 -f 12500 --call-graph fp -o /data/perf.data +``` + +把 `n` 替换成目标线程或进程标识。 + +### 5. 拉回主机分析 + +```bash +adb pull /data/perf.data +python report_sample.py > out.perf +perl stackcollapse-perf.pl out.perf > out.folded +perl flamegraph.pl out.folded > graph.svg +``` + +如果是按设备内交互流程操作,原始步骤里还包含: + +```bash +exit +adb pull /data/perf.data +``` + +其中: + +- `out.perf`:`report_sample.py` 转换后的文本结果 +- `out.folded`:供 FlameGraph 使用的折叠栈数据 +- `graph.svg`:最终生成的火焰图 + +## 使用时的几个提醒 + +- 设备侧抓得到数据,不代表主机侧一定能正确符号化;带符号 so 要提前准备好 +- `setenforce 0` 只适合调试机 +- 如果调用栈不完整,优先检查 `--call-graph fp` 是否适合当前编译方式 + +## 原始来源备注 + +原始笔记末尾引用过一篇 CSDN 文章,这里保留来源信息,便于后续回查: + +- 原文链接:`https://blog.csdn.net/zuimman/article/details/120510910` diff --git "a/_posts/android/2026-04-24-\346\211\213\346\234\272\346\212\223\345\214\205.md" "b/_posts/android/2026-04-24-\346\211\213\346\234\272\346\212\223\345\214\205.md" new file mode 100644 index 000000000..d43f7234e --- /dev/null +++ "b/_posts/android/2026-04-24-\346\211\213\346\234\272\346\212\223\345\214\205.md" @@ -0,0 +1,36 @@ +--- +layout: post +title: 手机抓包前的网卡设置 +subtitle: 360 随身 WiFi 配合 Wireshark 时的一个前置检查点 +date: 2026-04-24 +author: BY +header-img: img/post-bg-map.jpg +catalog: true +tags: + - Network + - Wireshark + - Android +--- + +>这是一条很短的排坑记录:装完 360 WiFi 后,如果网卡属性没配好,Wireshark 可能识别不到预期流量。 + +## 现象 + +使用 360 WiFi 或类似无线网卡做手机抓包时,Wireshark 无法正常识别流量,或者抓到的数据不完整。 + +## 处理方式 + +安装驱动或工具后,进入对应网卡属性页,确认需要的两个选项已经打开。 + +原始笔记只记录了这个关键结论,没有继续展开更细的操作步骤,因此这里先保留截图备忘: + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/20bdc3d6-dfe9-4c55-8b50-5b75754937c2) + +## 备注 + +如果后续还遇到抓不到包的问题,再继续从下面几个方向排查: + +- 网卡驱动是否正常 +- 是否开启了监控模式或对应抓包能力 +- Wireshark 选择的接口是否正确 +- 手机流量是否真的经过当前热点或网卡 diff --git "a/_posts/android/2026-04-24-\351\207\207\351\233\206dumpsys meminfo\346\233\262\347\272\277.md" "b/_posts/android/2026-04-24-\351\207\207\351\233\206dumpsys meminfo\346\233\262\347\272\277.md" new file mode 100644 index 000000000..64b721d83 --- /dev/null +++ "b/_posts/android/2026-04-24-\351\207\207\351\233\206dumpsys meminfo\346\233\262\347\272\277.md" @@ -0,0 +1,126 @@ +--- +layout: post +title: 采集 dumpsys meminfo 曲线 +subtitle: 用 `adb` 和 `gnuplot` 快速观察 Android 进程内存变化 +date: 2026-04-24 +author: BY +header-img: img/post-bg-android.jpg +catalog: true +tags: + - Android + - adb + - Memory + - gnuplot +--- + +>原始内容是一段随手记下的脚本,这里把它整理成“用途 + 前提 + 原脚本”的形式,方便后续继续复用。 + +## 用途 + +这段脚本的目标很简单: + +- 定期执行 `adb shell dumpsys meminfo ` +- 抽取 `Dalvik Heap` 相关字段 +- 用 `gnuplot` 直接画出一条变化曲线 + +适合做快速观察,不适合替代更正式的长期监控或完整内存分析。 + +## 使用前提 + +运行前至少要确认下面几项: + +1. 主机侧已经能正常执行 `adb` +2. 设备已连接,目标进程名可被 `dumpsys meminfo` 正确识别 +3. 本机已安装 `gnuplot` + +原始脚本里默认监控的进程名是: + +```bash +com.xiaomi.mi_connect_service +``` + +如果目标进程不同,先把脚本中的 `process_name` 改掉。 + +## 原始脚本 + +```bash +#!/bin/bash + +# 要采集的进程名称 +process_name="com.xiaomi.mi_connect_service" + +# 存储数据的数组 +data=() + +# 采集次数 +iterations=10 + +# 采集间隔(秒) +interval=3 + +# 存储时间的数组 +timestamps=() + +# 采集 meminfo +function collect_meminfo() { + # 采集当前时间戳 + timestamp=$(date +%s) + timestamps+=($timestamp) + + free=$(adb shell dumpsys meminfo "$process_name" | grep -i 'Dalvik Heap' | awk '{print $3}') + echo "free is ${free}" + data+=($free) +} + +# 绘制曲线 +function plot_curve() { + # 生成临时数据文件 + temp_data_file=$(mktemp) + echo "Generating temporary data file: $temp_data_file" + echo -e "\n" >> "$temp_data_file" + + # 将数据写入临时文件 + for value in "${data[@]}"; do + echo "$value" >> "$temp_data_file" + echo -n "$value " >> "$temp_data_file" + echo -e "\n" >> "$temp_data_file" + done + + # 生成绘图命令 + plot_cmd="plot '$temp_data_file' with lines title 'Memory Usage'" + echo "Generating plot command: $plot_cmd" + + # 执行绘图命令 + gnuplot -persist <<< "$plot_cmd" + + # 删除临时数据文件 + rm "$temp_data_file" +} + +# 循环采集数据并绘制曲线 +function collect_and_plot() { + echo "Collecting and plotting memory info for process: $process_name" + echo "Number of iterations: $iterations" + echo "Collection interval (seconds): $interval" + echo "Starting collection..." + + for ((i=1; i<=$iterations; i++)); do + echo "Iteration $i..." + collect_meminfo + for element in "${data[@]}"; do + echo "$element" + done + plot_curve + sleep "$interval" + done +} + +# 执行主函数 +collect_and_plot +``` + +## 使用时的几个提醒 + +- 这份脚本当前取的是 `Dalvik Heap` 行里的第 3 列,换 ROM 或 Android 版本后可能需要重新确认字段位置 +- `gnuplot -persist` 更适合手工观察;如果想保存图片,可以后续再补输出文件配置 +- 如果要做更稳定的趋势分析,建议把时间戳和采样值一起落盘,而不是只依赖临时文件 diff --git a/_posts/android/2026-04-25-android.md b/_posts/android/2026-04-25-android.md new file mode 100644 index 000000000..351050cc0 --- /dev/null +++ b/_posts/android/2026-04-25-android.md @@ -0,0 +1,85 @@ +--- +layout: post +title: Android 开发零散记录 +subtitle: 反编译、Gradle 对齐与 debug 构建配置的最小备忘 +date: 2026-04-25 +author: BY +header-img: img/post-bg-android.jpg +catalog: true +tags: + - Android + - Gradle + - Debug +--- + +>把原先几条零散备注整理成“工具入口 + 构建提醒 + 调试配置”三个部分,方便回看时快速定位。 + +## 1. 反编译工具入口 + +原始笔记只记了一条: + +- `jadx` + +因此这篇里把它保留成一个明确提醒:如果当前目的是快速看 APK / dex 的 Java 代码结构,先从 `jadx` 开始,而不是一上来就手动解包所有产物。 + +## 2. Gradle 版本先看兼容关系 + +原始记录里的结论是: + +> Gradle 版本需要与 XXX 版本对应 + +虽然当时没把具体版本对照表写下来,但这条提醒本身很有价值: +**只要 Android 工程在同步、构建或插件加载阶段报错,先确认 Gradle、Android Gradle Plugin 和 JDK 的组合是否匹配。** + +回看这条笔记时,优先检查: + +1. `gradle-wrapper.properties` 里的 Gradle 版本 +2. 工程使用的 Android Gradle Plugin 版本 +3. 当前本机或 CI 的 JDK 版本 + +很多看起来像“脚本写错了”的问题,根因其实是版本不兼容。 + +## 3. Gradle 脚本调试最小方法 + +原始笔记里给出的关键词只有一个: + +```gradle +println +``` + +可以把它理解成最小调试手段: +当你不确定某个变量、任务分支或配置块有没有生效时,先在 Gradle 脚本里用 `println` 打印关键值,快速确认执行路径。 + +它适合回答这类问题: + +- 当前脚本到底有没有被执行 +- 某个变量在配置阶段拿到的值是什么 +- 某段逻辑走的是哪条分支 + +## 4. 常用 debug 构建配置 + +原始记录保留的配置片段如下: + +```gradle +debug { + debuggable true + jniDebuggable true + minifyEnabled false + shrinkResources false +} +``` + +整理后可以把它理解成一组常见目标: + +- `debuggable true`:允许 Java / Kotlin 层调试 +- `jniDebuggable true`:允许 Native 层调试 +- `minifyEnabled false`:避免混淆影响排查 +- `shrinkResources false`:避免资源裁剪让问题现场失真 + +## 5. 什么时候先回看这篇 + +这篇笔记适合在下面几类场景里快速翻一下: + +1. 想先反编译看 APK 结构时 +2. Gradle 同步 / 构建异常,但一时看不出是脚本问题还是版本问题时 +3. 需要准备一个更适合调试的 Android debug 构建时 diff --git a/_posts/android/2026-04-25-apns_tools.md b/_posts/android/2026-04-25-apns_tools.md new file mode 100644 index 000000000..0a5a1bf13 --- /dev/null +++ b/_posts/android/2026-04-25-apns_tools.md @@ -0,0 +1,249 @@ +--- +layout: post +title: APNs 调试与鉴权记录 +subtitle: token-based 与 certificate-based 两种调试方式的最小备忘 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios10.jpg +catalog: true +tags: + - iOS + - APNs + - JWT + - curl +--- + +>把原始笔记里关于 APNs 的概念、脚本和 jwt-cpp 试验代码重新收拢成一篇可回看的调试记录。 + +## 1. 先记住 APNs 有两套常见鉴权方式 + +APNs 全称是 Apple Push Notification service。 + +这篇主要整理两类调试方式: + +1. **token-based** + - 使用 `.p8` 私钥 + - 需要自己生成 JWT + - 适合长期使用,密钥标识符可以持续复用 +2. **certificate-based** + - 使用证书和私钥文件 + - 更偏历史方案或兼容旧流程时使用 + +原始笔记里保留的一条结论值得继续保留: + +>早期 APNs 常见做法是证书方式;后来的 token-based 方式更适合长期维护,因为密钥标识符可持续使用,泄露时再单独吊销即可。 + +## 2. 调试前要先确认的几个量 + +无论用哪种方式,下面几个参数都要先确认清楚: + +- `TEAM_ID`:Apple Developer 团队 ID +- `AUTH_KEY_ID`:APNs key identifier +- `TOKEN_KEY_FILE_NAME`:对应的 `.p8` 私钥文件 +- `DEVICE_TOKEN`:目标设备 token +- `TOPIC`:App 的 bundle id +- `APNS_HOST_NAME`:调试环境主机名 + +常用主机: + +- 开发环境:`api.sandbox.push.apple.com` +- 正式环境:`api.push.apple.com` + +这里最容易混淆的是 `TOPIC` 和 `DEVICE_TOKEN`。 +`DEVICE_TOKEN` 是跟具体 App 标识绑定的,如果 `TOPIC` 写错,通常会得到 topic 与 device token 不匹配之类的错误。 + +## 3. 先确认本机 curl 是否支持 HTTP/2 + +APNs 请求需要 HTTP/2,因此在动手前先跑一遍: + +```bash +curl -V +``` + +如果输出里 `Features` 包含 `HTTP2`,说明当前 curl 可以直接拿来调试。例如: + +```text +curl 7.78.0 (x86_64-apple-darwin20.6.0) libcurl/7.78.0 OpenSSL/1.1.1l zlib/1.2.11 zstd/1.5.0 libidn2/2.3.2 libpsl/0.21.1 (+libidn2/2.3.2) nghttp2/1.45.1 +Protocols: dict file ftp ftps gopher gophers http https imap imaps mqtt pop3 pop3s rtsp smb smbs smtp smtps telnet tftp +Features: alt-svc AsynchDNS HSTS HTTP2 HTTPS-proxy IDN IPv6 Largefile libz NTLM NTLM_WB PSL SSL TLS-SRP UnixSockets zstd +``` + +## 4. token-based:用 shell 脚本直接测 APNs + +原始笔记里最有价值的部分,是这条可以直接打通链路的 shell 脚本。这里保留接近原始使用方式的版本: + +```zsh +#!/usr/bin/env zsh + +set -e + +TEAM_ID="YOUR_TEAM_ID" +AUTH_KEY_ID="YOUR_AUTH_KEY_ID" +TOKEN_KEY_FILE_NAME="/path/to/AuthKey_XXXXXXXXXX.p8" +TOPIC="com.example.app" +DEVICE_TOKEN="YOUR_DEVICE_TOKEN" +APNS_HOST_NAME="api.sandbox.push.apple.com" + +# openssl s_client -connect "${APNS_HOST_NAME}":443 + +JWT_ISSUE_TIME=$(date +%s) +JWT_HEADER=$(printf '{ "alg": "ES256", "kid": "%s" }' "${AUTH_KEY_ID}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d '=') +JWT_CLAIMS=$(printf '{ "iss": "%s", "iat": %d }' "${TEAM_ID}" "${JWT_ISSUE_TIME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d '=') +JWT_HEADER_CLAIMS="${JWT_HEADER}.${JWT_CLAIMS}" +JWT_SIGNED_HEADER_CLAIMS=$(printf "%s" "${JWT_HEADER_CLAIMS}" | openssl dgst -binary -sha256 -sign "${TOKEN_KEY_FILE_NAME}" | openssl base64 -e -A | tr -- '+/' '-_' | tr -d '=') +AUTHENTICATION_TOKEN="${JWT_HEADER}.${JWT_CLAIMS}.${JWT_SIGNED_HEADER_CLAIMS}" + +/usr/bin/curl -v \ + --header "apns-topic: ${TOPIC}" \ + --header "apns-push-type: alert" \ + --header "authorization: bearer ${AUTHENTICATION_TOKEN}" \ + --data '{"aps":{"alert":"test"}}' \ + --http2 "https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}" +``` + +### 这段脚本主要在做什么 + +1. 用 `TEAM_ID` 和 `AUTH_KEY_ID` 拼 JWT header / claims +2. 使用 `.p8` 私钥按 **ES256** 算法签名 +3. 把 JWT 放到 `authorization: bearer ...` 头里 +4. 用 curl 直接请求 APNs HTTP/2 接口 + +### 使用时优先检查的点 + +- `.p8` 文件路径是否正确 +- `TOPIC` 是否和 App bundle id 一致 +- `DEVICE_TOKEN` 是否来自同一套环境 +- 沙盒 token 不要拿去请求正式环境,反之亦然 + +## 5. jwt-cpp 试验代码:核心是 ES256 和密钥格式 + +原始记录里有一大段 jwt-cpp 实验代码,保留后真正有操作价值的部分主要有三类: + +1. APNs token 要用 **ES256** +2. jwt-cpp 这类库在本地实验时,通常更适合直接喂 **PEM** 格式私钥 +3. decode 结果可以反过来验证 shell 脚本生成的 token 结构 + +因此先把 `.p8` 转成 `.pem`: + +```bash +openssl pkcs8 -nocrypt -in AuthKey_XXXXXXXXXX.p8 -out AuthKey.pem +``` + +### 生成 token 的实验代码 + +```cpp +std::string ec_priv_key = R"(-----BEGIN PRIVATE KEY----- +YOUR_PRIVATE_KEY +-----END PRIVATE KEY-----)"; + +auto token = jwt::create() + .set_issuer("YOUR_TEAM_ID") // TEAM_ID + .set_key_id("YOUR_AUTH_KEY_ID") // AUTH_KEY_ID + .set_type("JWS") + .set_id("com.example.app") + .set_issued_at(std::chrono::system_clock::now()) + .set_expires_at(std::chrono::system_clock::now() + std::chrono::seconds{36000}) + .sign(jwt::algorithm::es256("", ec_priv_key, "", "")); + +std::cout << "token:\n" << token << std::endl; +``` + +原始笔记里还保留了对 decode 结果的观察,结论同样值得留下: + +### decode 已生成 token 的实验代码 + +```cpp +std::string token = "YOUR_JWT_TOKEN"; +auto decoded = jwt::decode(token); + +for (auto& e : decoded.get_payload_json()) { + std::cout << "hello jwt-cpp!" << std::endl; + std::cout << e.first << " = " << e.second << std::endl; + std::cout << "jwt-cpp decode success!" << std::endl; +} + +for (auto& e : decoded.get_header_json()) { + std::cout << e.first << " = " << e.second << std::endl; +} +``` + +对应观察到的输出大致如下: + +```text +hello jwt-cpp! +iat = 1674094299 +jwt-cpp decode success! +hello jwt-cpp! +iss = "YOUR_TEAM_ID" +jwt-cpp decode success! +alg = "ES256" +kid = "YOUR_AUTH_KEY_ID" +``` + +这说明 shell 脚本生成的 token,在结构上就是一个标准的 ES256 JWT。 + +### 回看这段实验代码时要注意 + +- `verify()` 走的是公钥校验思路 +- `create()` / `sign()` 走的是私钥签名思路 +- `decode()` 只是解码已有 token,不等于完成签名校验 +- APNs 的 token 鉴权重点不是“随便生成一个 JWT”,而是**按 Apple 要求生成 ES256 签名 JWT** + +原始记录里还尝试过 `hs256` 之类的代码路径,但对 APNs 场景并不适用,回看时可以直接忽略。 + +## 6. certificate-based:证书方式的 curl 备忘 + +如果要回查旧方案,可以保留下面这条接近原始使用方式的脚本: + +```zsh +#!/usr/bin/env zsh + +set -e + +TOPIC="com.example.app" +DEVICE_TOKEN="YOUR_DEVICE_TOKEN" +APNS_HOST_NAME="api.push.apple.com" + +CERTIFICATE_FILE_NAME="/path/to/certificate.pem" +CERTIFICATE_KEY_FILE_NAME="/path/to/private_key.pem" + +# openssl s_client -connect "${APNS_HOST_NAME}":443 + +/usr/bin/curl -v \ + --header "apns-topic: ${TOPIC}" \ + --header "apns-push-type: alert" \ + --cert "${CERTIFICATE_FILE_NAME}" --cert-type PEM \ + --key "${CERTIFICATE_KEY_FILE_NAME}" --key-type PEM \ + --data '{"aps":{"alert":"test hello"}}' \ + --http2 "https://${APNS_HOST_NAME}/3/device/${DEVICE_TOKEN}" +``` + +这套方式的重点不是 JWT,而是: + +- 证书格式是否正确(例如 `pem` / `cer`) +- 证书和私钥是否配套 +- 当前证书是否覆盖目标 App / 环境 + +## 7. 最后只保留几条最实用的排查提醒 + +如果 APNs 调试不通,优先按下面顺序看: + +1. curl 是否支持 HTTP/2 +2. 请求的是沙盒还是正式环境 +3. `TOPIC` 是否和 App bundle id 一致 +4. `DEVICE_TOKEN` 是否属于同一个 App 和同一环境 +5. token-based 场景里,JWT 是否确实按 ES256 生成 +6. certificate-based 场景里,证书和私钥是否匹配 + +## 8. 参考链接 + +- https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns +- https://forums.mbed.com/t/jwt-es256-token-using-ecdsa/13068 +- https://github.com/Thalhammer/jwt-cpp +- https://github.com/arun11299/cpp-jwt +- https://www.cnblogs.com/moodlxs/archive/2012/10/15/2724318.html +- https://eclipsesource.com/blogs/2016/09/07/tutorial-code-signing-and-verification-with-openssl/ +- https://0x90e.github.io/2017/02/12/verify_a_signature_with_certificate/ +- https://juejin.cn/post/6991476688345366564 +- https://www.cnblogs.com/tml839720759/p/3926006.html +- https://www.cnblogs.com/bohat/p/12482357.html diff --git a/_posts/android/2026-04-25-ndk.md b/_posts/android/2026-04-25-ndk.md new file mode 100644 index 000000000..83593ff28 --- /dev/null +++ b/_posts/android/2026-04-25-ndk.md @@ -0,0 +1,77 @@ +--- +layout: post +title: Android NDK 链接报错:file format not recognized +subtitle: 从输入文件格式、ABI 与产物完整性排查 +date: 2026-04-25 +author: BY +header-img: img/post-bg-android.jpg +catalog: true +tags: + - Android + - NDK + - Linker +--- + +>原始内容只留下了一张报错截图,这里把它整理成一个可回看的排查入口。 + +## 1. 关注的报错 + +原始记录对应的是这类链接错误: + +```text +linker file format not recognized +``` + +原笔记保留的截图如下: + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/eb4eacba-eeeb-4889-9f26-b1b566e6c2c1) + +## 2. 先把它当成什么问题看 + +整理后更适合把这类报错理解为: + +**链接器拿到的输入文件,不是当前工具链期望的目标格式。** + +常见方向通常不是“某一行 C++ 代码写错了”,而是下面几类输入有问题: + +1. 架构不匹配 + 例如把 `arm64-v8a` 的库喂给了 `armeabi-v7a` 目标,或反过来。 +2. 文件类型不对 + 例如把文本文件、压缩包、脚本,甚至错误下载的 HTML 文件当成 `.a` / `.so` 去链接。 +3. 产物损坏 + 下载不完整、拷贝中断、缓存污染,都可能导致格式异常。 +4. 主机库和目标库混用 + 例如把本机 Linux 库误当成 Android NDK 目标库参与链接。 + +## 3. 最小排查顺序 + +回看这条记录时,建议先按下面顺序确认: + +### 1. 看报错里点名的是哪个文件 + +先从完整链接日志里找到具体是哪个 `.o`、`.a` 或 `.so` 触发了报错,而不是只记住“链接失败”。 + +### 2. 确认文件真实类型 + +优先确认它到底是不是目标文件 / 静态库 / 动态库,而不是只看扩展名。 + +### 3. 确认 ABI 是否一致 + +重点核对: + +- 当前构建目标 ABI +- 依赖库实际 ABI +- 使用的 NDK 工具链前缀 + +### 4. 确认产物来源 + +如果这个文件来自第三方包、脚本下载或手工拷贝,优先怀疑: + +- 下载到了错误内容 +- 缓存里残留旧版本 +- 误用了 host 侧产物 + +## 4. 这条笔记真正想提醒什么 + +这篇记录虽然短,但核心价值是提醒自己: +**遇到 `file format not recognized` 时,先查输入产物和 ABI,不要先钻进源码细节。** diff --git "a/_posts/build/2024-09-06-\347\274\226\350\257\221\347\233\270\345\205\263.md" "b/_posts/build/2024-09-06-\347\274\226\350\257\221\347\233\270\345\205\263.md" new file mode 100644 index 000000000..6c319eba7 --- /dev/null +++ "b/_posts/build/2024-09-06-\347\274\226\350\257\221\347\233\270\345\205\263.md" @@ -0,0 +1,476 @@ +--- +layout: post +title: C/C++ 编译相关整理 +subtitle: 编译流程 / 静态动态库 / GCC 优化选项 / FDO / AutoFDO / LTO / BOLT / LLVM 笔记汇总 +date: 2024-09-06 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - GCC + - LLVM + - LTO + - FDO + - AutoFDO + - BOLT + - 编译优化 +--- + +>这一篇是把日常积累的「ELF 检查、强制 32 位、C++ 编译流程、静态/动态库、编译裁剪、各种编译优化(符号表 / BOLT / FDO ≡ PGO / LTO / AutoFDO / LLVM / Clang)、configure & cmake 小技巧」放在一起的笔记。原稿标题层级混乱、章节编号嵌在正文里、有些代码块没有围栏,这次只调整格式与层级、补上专有名词的解释,**不删原有正文**。 +> +>**术语速查(先放在最前面,后面正文里多次用到)**: +> +>- **ELF**:Executable and Linkable Format,Linux 下可执行文件 / `.so` / `.o` 的标准格式。 +>- **TU / 编译单元**:Translation Unit,一个 `.c` / `.cpp` 经过预处理后送给编译器的整体输入。 +>- **IR**:Intermediate Representation,编译器中间表示。GCC 的叫 GIMPLE,LLVM 的叫 LLVM IR。 +>- **AST**:Abstract Syntax Tree,前端语法分析后产生的抽象语法树。 +>- **CSE**:Common Subexpression Elimination,公共子表达式消除。 +>- **GCSE**:Global CSE,跨基本块 / 函数级别的 CSE。 +>- **PGO ≡ FDO**:Profile-Guided / Feedback-Directed Optimization,先采样跑一遍,把分支概率等真实数据回喂给编译器再编一次。 +>- **AutoFDO**:用 perf 采样代替插桩的 FDO,可以在生产环境直接取数据。 +>- **LTO**:Link-Time Optimization,把 IR 留到链接阶段做整体优化。 +>- **BOLT**:Binary Optimization and Layout Tool(Facebook),对已链接好的二进制做 post-link layout 优化。 +>- **Propeller**:Google 出品,思路与 BOLT 类似,效果接近。 +>- **gcov / .gcda**:GCC 自带的覆盖率 / 计数信息工具与文件。 +>- **perf / perf.data**:Linux 内核内置的性能剖析工具与采样数据文件。 +>- **strip**:去掉二进制里的符号表 / 调试信息以减小体积。 + +## 查看32位还是64位 + +``` +readelf 命令,参数为-h + +例如 文件名为python + +>>>readelf -h python + +得到的是ELF Header中的项Magic + +第五个数 02时为64位,01时为32位 +``` + +## 强制编译32位程序 +脚本中设置 USE_32BITS=1 +需要安装gcc-X-multilib g++-x-multilib 版本 +```cmake +if(USE_32BITS) + message(STATUS "using 32bits") + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -m32") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -m32") +else() +endif(USE_32BITS) +``` + + +## 【c++程序编译流程】 +预处理→ 编译 → 汇编 → 链接 + +![image](https://user-images.githubusercontent.com/8308226/235432846-a8548168-815f-4888-ab28-44f55dcf1813.png) + +具体的就是: +源代码(source code)→ 预处理器(processor)→ 编译器(compiler)→ 汇编程序(assembler)→ 目标程序(object code)→ 链接器(Linker)→ 可执行程序(executables) +下面详细介绍每个流程的具体事项: +1、预处理 :预处理相当于根据预处理处理指令组装新的C++程序。经过预处理,会产生一个没有宏定义,没有条件编译指令,没有特殊符号的输出文件,这个文件含义同原本的文件无异,只是内容上有所不同 +``` +读取C++源程序,对其中的伪指令(以#开头的指令)进行处理 +* 将所有的#define删除,并且展开所有的宏定义 +* 处理所有的条件编译指令,如“#if”、“#ifdef”、“#elif”、“#else”、“endif”等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉 +* 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置(可递归执行) +删除所有的注释 +添加行号和文件名标识 +* 以便于编译时编译器产生调试用的行号信息及用于编译时产生的编译错误或警告时能够显示行号 +保留所有的#pragma编译器指令 +* https://baike.baidu.com/item/%23pragma + +* #pragma once:只要在头文件的最开始加入这条指令就能够保证头文件被编译一次 +``` + +2、编译过程 +``` +将预处理完的文件进行一系列词法分析、语法分析、语义分析,在确认所有的指令都符合语法规则后,将其翻译成汇编代码文件。在这一步中,编译器会对代码进行检查优化,指出语法错误、重载决议错误及其他各种编译错误 +* 词法分析:编译过程的第一个阶段。这个阶段的任务是从左到右一个字符一个字符地读入源程序,即对构成源程序的字符流进行扫描然后根据构词规则识别单词(也称单词符号或符号) +* 语法分析:编译过程的一个逻辑阶段。语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等 +* 语义分析:编译过程的一个逻辑阶段。语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查, 进行类型审查 +# 源程序的结构是正确的,语义分析将审查类型并报告错误:不能在表达式中使用一个数组变量,赋值语句的右端和左端的类型不匹配 +``` + +3、汇编过程 + +``` +将编译完的汇编代码文件翻译成机器指令,并生成可重定位目标程序的.o文件,该文件为二进制文件。 +汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译即可。 +对于被翻译系统处理的每一个C++语言源程序,都将最终经过这一处理得到相应的目标文件。目标文件中所存放的也是与源程序等效的目标的机器语言代码 +[表格] +其中MOV指令表示传送字或字节 +``` + +4、链接过程 +``` +链接器利用编译器产生的目标文件,生成最终的可执行文件。 +在这一阶段,编译器将把上一阶段中编译器产生的各种目标文件链接起来,将未定义标识符的引用全部替换成它们对应的正确地址。没有把目标文件链接起来,就无法生成能够正常工作的程序——就像一页没有页码的目录一样,没什么用处。完成链接工作之后,链接器根据编译目的不同,把链接的结果生成为一个动态链接库,或是一个可执行文件。 +``` + +## 静态库和动态库 + 从编译的角度来看两者之间最主要的差别体现在是否发生了链接动作。静态库是.o文件的集合,.o文件并没有执行过链接动作,.o文件中引用其他文件的符号并没有经过解引用操作,其他文件可以是同一个代码库下其他的.o文件,也可以是其他模块的静态库,还可以是其他模块或操作系统的动态库。静态库是按需链接,链接的粒度是.o文件,不是大家常在编译命令中看到的.a文件,只有被引用的符号所在的.o 文件才会被写入应用程序。如果代码中没有用静态库中的函数,即便在编译命令中指定了该库,连接器也不会发生连接。 + 目标文件经过了链接处理便成为了动态库,从操作系统的角度而言,动态库包含了指令和数据,从文件结构上更接近于应用程序,给动态库增加入口函数,动态库也可以像应用程序一样正常运行。 + +## 编译裁剪 + debug、release + 线上移除不需要的lib文件,test二进制文件,提高部署速度 + +``` +LIBRARY micontinuity_sdk + +EXPORTS + +GetErrMsg; +extern "C++" { + "class::Get()"; + class::Packet::*; +}; +``` + + +## 参考链接 +llvm 寄存器,图着色 https://zhuanlan.zhihu.com/p/55287942 +字节 envovy 编译优化收益: http://www.it120.vip/yq/8534.html + +## 编译优化 + +### 符号表优化 +去除符号表信息 + +### bolt 链接后优化技术 (sample-guided-optimization) + fdo + pgo 后依然有效,全局角度 +https://zhuanlan.zhihu.com/p/550895670 + +### google 效果与bolt基本相同 +https://github.com/google/llvm-propeller + +### FDO(feedback-directed-optimization) == PGO (profile-guided-optimization) + +### LTO (link-time-optimization) +全局有效 + +``` + # -flto 开启后,可以减少so的体积,提高程序运行效率, apk 体积增加了 139k,140k + merge_native_libs 大小从129k降低至99k + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 -ffunction-sections -fdata-sections -flto") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -fno-exceptions -ffunction-sections -fdata-sections -flto") +``` + +### 3.0 优化方向 +#### 3.0.1 代码优化 + +1、公共子表达式消除:如果一个表达式e已经计算过了,并且从先前的计算到现在e中所有变量的值都没有发生变化,那么e的这次出现就成为公共子表达式。 +2、删除无用代码:永远不能被执行到的代码或者没有任何意义的代码会被清除掉 +3、常量传播:在编译优化时, 能够将计算出结果的变量直接替换为常量 + +4、常量折叠:在编译优化时,多个变量进行计算时,而且能够直接计算出结果,那么变量将有常量直接替换。 +5、复写传播:两个相同的变量可以用一个代替。 +6、数组范围检查消除:数组边界检查不是必须在运行期间一次不漏的检查,而是可以协商的。如果及时编译器能根据数据流分析出变量的取值范围在[0,max_length]之间,那么在循环期间就可以把数组的上下边界检查消除 +7、方法内联:方法内联就是把调用方函数代码"复制"到调用方函数中,减少因函数调用开销的技术 +8、逃逸分析:分析对象动态作用域,一旦确定对象不会发生方法逃逸和线程逃逸,就可以对这个变量进行高效的优化,比如栈上分配、同步消除、标量替换等。 +* 栈上分配:将堆分配转化为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。 +* 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。 +* 分配对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存储在内存,而是存储在CPU寄存器中 +#### 3.0.2 寄存器分配 +1、寄存器基础知识 + 一般computer中存在内存,内存就像仓库,我们不常用的东西分类放到仓库里边去。等到用的时候就会拿出来放在手边,手边的一些柜子书桌就是CPU中的寄存器。寄存器的位数和指令的位宽是一样的。我们说128位的指令位宽,那么对应的寄存器的位数就是128位,而CPU每次可以计算的数据的宽度最大也是128位。因为我们常用的数据达不到这样的宽度,这样每个指令周期就可以执行多个数据的计算。这就是所谓向量化计算 +2、寄存器分配 + 寄存器是位于CPU或GPU内部的少量的高速存储器,用于保存机器指令的操作数。由于其价格昂贵导致其数量有限,又由于存取速度快,使其不可或缺。因此,寄存器是计算机体系结构中的关键资源之一。在计算复杂表达式的过程中产生的中间结果也保存在寄存器中。更复杂的编译器会把经常使用的变量放在寄存器里,来避免反复地存取。如果是优化的编译器,会把公共子表达式消除或者循环不变量移动以后的重用值放在寄存器中。 + 在编译的代码生成阶段,程序中的变量会被编译器替换为寄存器。高级语言程序中使用的变量数量可以是几乎无限的,但CPU或GPU中的寄存器数量是有限的,寄存器分配器作为后端的一个模块要解决这对矛盾,控制寄存器的分配和使用。因此,寄存器分配是将程序中的数量无限的虚拟寄存器映射到数量有限的物理寄存器。寄存器分配可以工作在表达式、基本块、函数(也称全局)或整个程序级别。 +附:https://zhuanlan.zhihu.com/p/55287942 +#### 3.0.3 指令调度 +指令调度是编译优化中用于提高指令级并行,从而提高在计算机上指令流水线的性能。更直接的说,在没有改变原代码语义的情况下,它做了下面两件事: +``` +* 通过重排指令顺序避免指令流水线停顿 +* 避免非法或语义模糊的操作(涉及典型的细微的指令流水线时序问题或非互锁的资源) +``` + +### 3.1 gcc +#### 3.1.1 gcc介绍 + gcc的全称是GNU Compiler Collection,它是一个能够编译多种语言的编译器。最开始gcc是作为C语言的编译器(GNU C Compiler),现在除了c语言,还支持C++、java、Pascal等语言。gcc支持多种硬件平台。特点如下: +* gcc是一个可移植的编译器,支持多种硬件平台。例如ARM、X86等等。 +* gcc不仅是个本地编译器,它还能跨平台交叉编译。所谓的本地编译器,是指编译出来的程序只能够在本地环境进行运行。而gcc编译出来的程序能够在其他平台进行运行。例如嵌入式程序可在x86上编译,然后在arm上运行。 +* gcc有多种语言前端,用于解析不同的语言。 +* gcc是按模块化设计的,可以加入新语言和新CPU架构的支持。 +* gcc是自由软件。任何人都可以使用或更改这个软件。 +#### 3.1.2 gcc基本优化选项及举例 + gcc 提供了为了满足用户不同程度的的优化需要,提供了近百种优化选项,用来对{编译时间,目标文件长度,执行效率}这个三维模型进行不同的取舍和平衡。优化的方法不一而足,总体上将有以下几类:1)精简操作指令;2)尽量满足cpu的流水操作;3)通过对程序行为地猜测,重新调整代码的执行顺序;4)充分使用寄存器;5)对简单的调用进行展开等等。 +* -O0:不做任何优化,这是默认的编译选项 +* -O和-O1: 对程序做部分编译优化,对于大函数,优化编译占用稍微多的时间和相当大的内存。使用本项优化,编译器会尝试减小生成代码的尺寸,以及缩短执行时间,但并不执行需要占用大量编译时间的优化。打开的优化选项包括 +优化选项 +l -fdefer-pop:延迟栈的弹出时间。当完成一个函数调用,参数并不马上从栈中弹出,而是在多个函数被调用后,一次性弹出。 +l -fmerge-constants:尝试横跨编译单元合并同样的常量(string constants and floating point constants) +l -fthread-jumps:如果某个跳转分支的目的地存在另一个条件比较,而且该条件比较包含在前一个比较语句之内,那么执行本项优化.根据条件是true或者false,前面那条分支重定向到第二条分支的目的地或者紧跟在第二条分支后面. +l -floop-optimize:执行循环优化,将常量表达式从循环中移除,简化判断循环的条件,并且optionally do strength-reduction,或者将循环打开等。在大型复杂的循环中,这种优化比较显著。 +l -fif-conversion:尝试将条件跳转转换为等价的无分支型式。优化实现方式包括条件移动,min,max,设置标志,以及abs指令,以及一些算术技巧等。 +l -fif-conversion2基本意义相同,没有找到更多的解释。 +l -fdelayed-branch:这种技术试图根据指令周期时间重新安排指令。 它还试图把尽可能多的指令移动到条件分支前, 以便最充分的利用处理器的治理缓存。 +l -fguess-branch-probability:当没有可用的profiling feedback或__builtin_expect时,编译器采用随机模式猜测分支被执行的可能性,并移动对应汇编代码的位置,这有可能导致不同的编译器会编译出迥然不同的目标代码。 +l -fcprop-registers:因为在函数中把寄存器分配给变量, 所以编译器执行第二次检查以便减少调度依赖性(两个段要求使用相同的寄存器)并且删除不必要的寄存器复制操作。 + + +* -O2: 是比O1更高级的选项,进行更多的优化。Gcc将执行几乎所有的不包含时间和空间折中的优化。与O1比较而言,O2优化增加了编译时间的基础上,提高了生成代码的执行效率。 O2打开所有的O1选项,并打开以下选项 +优化选项 +l -fforce-mem:在做算术操作前,强制将内存数据copy到寄存器中以后再执行。这会使所有的内存引用潜在的共同表达式,进而产出更高效的代码,当没有共同的子表达式时,指令合并将排出个别的寄存器载入。这种优化对于只涉及单一指令的变量, 这样也许不会有很大的优化效果. 但是对于再很多指令(必须数学操作)中都涉及到的变量来说, 这会时很显著的优化, 因为和访问内存中的值相比 ,处理器访问寄存器中的值要快的多。 +l -foptimize-sibling-calls:优化相关的以及末尾递归的调用。通常, 递归的函数调用可以被展开为一系列一般的指令, 而不是使用分支。 这样处理器的指令缓存能够加载展开的指令并且处理他们, 和指令保持为需要分支操作的单独函数调用相比, 这样更快。 +l -fstrength-reduce:这种优化技术对循环执行优化并且删除迭代变量。 迭代变量是捆绑到循环计数器的变量, 比如使用变量, 然后使用循环计数器变量执行数学操作的for-next循环。 +l -fcse-follow-jumps:在公用子表达式消元时,当目标跳转不会被其他路径可达,则扫描整个的跳转表达式。例如,当公用子表达式消元时遇到if...else...语句时,当条为false时,那么公用子表达式消元会跟随着跳转。 +l -fcse-skip-blocks:与-fcse-follow-jumps类似,不同的是,根据特定条件,跟随着cse跳转的会是整个的blocks +l -frerun-cse-after-loop:在循环优化完成后,重新进行公用子表达式消元操作。 +l -frerun-loop-opt:两次运行循环优化 l -fgcse:执行全局公用子表达式消除pass。这个pass还执行全局常量和copy propagation。这些优化操作试图分析生成的汇编语言代码并且结合通用片段, 消除冗余的代码段。如果代码使用计算性的goto, gcc指令推荐使用-fno-gcse选项。 +l-fgcse-lm:全局公用子表达式消除将试图移动那些仅仅被自身存储kill的装载操作的位置。这将允许将循环内的load/store操作序列中的load转移到循环的外面(只需要装载一次),而在循环内改变成copy/store序列。在选中-fgcse后,默认打开。 +l -fgcse-sm:当一个存储操作pass在一个全局公用子表达式消除的后面,这个pass将试图将store操作转移到循环外面去。如果与-fgcse-lm配合使用,那么load/store操作将会转变为在循环前load,在循环后store,从而提高运行效率,减少不必要的操作。 +l -fgcse-las:全局公用子表达式消除pass将消除在store后面的不必要的load操作,这些load与store通常是同一块存储单元(全部或局部) +l-fdelete-null-pointer-checks:通过对全局数据流的分析,识别并排出无用的对空指针的检查。编译器假设间接引用空指针将停止程序。 如果在间接引用之后检查指针,它就不可能为空。 +l -fexpensive-optimizations:进行一些从编译的角度来说代价高昂的优化(这种优化据说对于程序执行未必有很大的好处,甚至有可能降低执行效率,具体不是很清楚) +l -fregmove:编译器试图重新分配move指令或者其他类似操作数等简单指令的寄存器数目,以便最大化的捆绑寄存器的数目。这种优化尤其对双操作数指令的机器帮助较大。 +l -fschedule-insns:编译器尝试重新排列指令,用以消除由于等待未准备好的数据而产生的延迟。这种优化将对慢浮点运算的机器以及需要load memory的指令的执行有所帮助,因为此时允许其他指令执行,直到load memory的指令完成,或浮点运算的指令再次需要cpu。 +l -fschedule-insns2:与-fschedule-insns相似。但是当寄存器分配完成后,会请求一个附加的指令计划pass。这种优化对寄存器较小,并且load memory操作时间大于一个时钟周期的机器有非常好的效果。 +l -fsched-interblock:这种技术使编译器能够跨越指令块调度指令。 这可以非常灵活地移动指令以便等待期间完成的工作最大化。 +l -fsched-spec-load:允许一些load指令进行一些投机性的动作。(具体不详)相同功能的还有-fsched-spec-load-dangerous,允许更多的load指令进行投机性操作。这两个选项在选中-fschedule-insns时默认打开。 +l -fcaller-saves:通过存储和恢复call调用周围寄存器的方式,使被call调用的value可以被分配给寄存器,这种只会在看上去能产生更好的代码的时候才被使用。(如果调用多个函数, 这样能够节省时间, 因为只进行一次寄存器的保存和恢复操作, 而不是在每个函数调用中都进行。) +l -fpeephole2:允许计算机进行特定的观察孔优化(这个不晓得是什么意思),-fpeephole与-fpeephole2的差别在于不同的编译器采用不同的方式,由的采用-fpeephole,有的采用-fpeephole2,也有两种都采用的。 +l -freorder-blocks:在编译函数的时候重新安排基本的块,目的在于减少分支的个数,提高代码的局部性。 +l -freorder-functions:在编译函数的时候重新安排基本的块,目的在于减少分支的个数,提高代码的局部性。这种优化的实施依赖特定的已存在的信息:.text.hot用于告知访问频率较高的函数,.text.unlikely用于告知基本不被执行的函数。 +l -fstrict-aliasing:这种技术强制实行高级语言的严格变量规则。 对于c和c++程序来说, 它确保不在数据类型之间共享变量. 例如, 整数变量不和单精度浮点变量使用相同的内存位置。 +l -funit-at-a-time:在代码生成前,先分析整个的汇编语言代码。这将使一些额外的优化得以执行,但是在编译器间需要消耗大量的内存。(有资料介绍说:这使编译器可以重新安排不消耗大量时间的代码以便优化指令缓存。) +l -falign-functions:这个选项用于使函数对准内存中特定边界的开始位置。 大多数处理器按照页面读取内存,并且确保全部函数代码位于单一内存页面内, 就不需要叫化代码所需的页面。 +l -falign-jumps:对齐分支代码到2的n次方边界。在这种情况下,无需执行傀儡指令(dummy operations) +l -falign-loops:对齐循环到2的n次幂边界。期望可以对循环执行多次,用以补偿运行dummy operations所花费的时间。 +l -falign-labels:对齐分支到2的n次幂边界。这种选项容易使代码速度变慢,原因是需要插入一些dummy operations当分支抵达usual flow of the code. +l -fcrossjumping:这是对跨越跳转的转换代码处理, 以便组合分散在程序各处的相同代码。 这样可以减少代码的长度, 但是也许不会对程序性能有直接影响。 + + +* -O3: 比O2更进一步的进行优化。在包含了O2所有的优化的基础上,又打开了以下优化选项 +优化选项 +l -finline-functions:内联简单的函数到被调用函数中。由编译器启发式的决定哪些函数足够简单可以做这种内联优化。默认情况下,编译器限制内联的尺寸,3.4.6中限制为600(具体含义不详,指令条数或代码size?)可以通过-finline-limit=n改变这个长度。这种优化技术不为函数创建单独的汇编语言代码, 而是把函数代码包含在调度程序的代码中。 对于多次被调用的函数来说, 为每次函数调用复制函数代码。 虽然这样对于减少代码长度不利, 但是通过最充分的利用指令缓存代码, 而不是在每次函数调用时进行分支操作, 可以提高性能。 +l -fweb:构建用于保存变量的伪寄存器网络。 伪寄存器包含数据, 就像他们是寄存器一样, 但是可以使用各种其他优化技术进行优化, 比如cse和loop优化技术。这种优化会使得调试变得更加的不可能,因为变量不再存放于原本的寄存器中。 +l -frename-registers:在寄存器分配后,通过使用registers left over来避免预定代码中的虚假依赖。这会使调试变得非常困难,因为变量不再存放于原本的寄存器中了。 +l -funswitch-loops:将无变化的条件分支移出循环,取而代之的将结果副本放入循环中。 + +这里我们使用各个优化参数选项进行实验对比数据如下: + +| 优化参数 | 耗时对比 | 备注(编译程序命令) | +| :------ | :------ | :--------------------------- | +| 不优化 | 3039 ms | `gcc sort.c -o sort` | +| -O1 | 1323 ms | `gcc -O1 sort.c -o sort.1` | +| -O2 | 1129 ms | `gcc -O2 sort.c -o sort.2` | +| -O3 | 1126 ms | `gcc -O3 sort.c -o sort.3` | + +### 3.2 perf + Perf(Performance Event)是内置于Linux内核源码树中的性能剖析(profiling)工具。它基于事件采样的原理,以性能事件为基础,支持针对处理器相关性能指标与操作系统相关性能指标的性能剖析。可用于性能瓶颈的查找和热点代码的定位。 + Perf的原理如下:每隔一个固定的时间,就在CPU上(每个核上都有)产生一个中断,在中断上可以看出,当前是哪个pid(进程id),哪个函数,然后给对应的pid和函数加一个统计值,这样我们就知道CPU有百分之几的时间在某个pid,或者某个函数了。原理图示如下。很明显可以看出,这是一种采样的模式,我们预期,运行时间越多的函数,被时钟中断击中的机会越大,从而推测,那个函数(或者pid)的CPU占用率越高。 +![image](https://user-images.githubusercontent.com/8308226/235433399-9ffdc538-54ea-4175-a5dc-519090c9e849.png) + +机器开启perf cpu时间监听权限: + +``` +root执行以下命令: +1.修改不重启机器,立即生效: +echo -1 > /proc/sys/kernel/perf_event_paranoid +2.设置永久生效(防止机器重启失效) +/etc/sysctl.conf +kernel.perf_event_paranoid = -1 +``` + +### 3.3 FDO +#### 3.3.1 基本原理 + FDO(Feedback-Directed Optimization),是gcc等编译器的一个特性,Feedback-Directed Optimization(link)。编译程序有一个很难处理的问题是如何判断代码的分支是跳转还是不跳转(这东西影响流水线),芯片OoO(Out-of-Order,预测执行)设计很大程度上也是为了解决这个问题。FDO的方法是编译器先编译一个Instrumented版本(加通过gcov技术),运行一次,收集到所有的跳转数据了(在.gcda文件中),用这个数据来判断跳转的可能性是怎么样的,然后再用这个数据生成一个优化过的版本,正式使用。 + 在GCC中,传统的反馈式编译优化使用插桩的方式(https://baike.baidu.com/item/%E7%A8%8B%E5%BA%8F%E6%8F%92%E6%A1%A9)来收集边和值的性能信息。GCC使用由基本块和边频率计数的性能信息来指导优化,如指令调度,基本块重排序,函数拆分,以及寄存器分配。目前,GCC中的反馈式编译优化主要包含以下几个步骤: +(1)生成一个测试版的程序,用来收集边和值的性能信息。 +(2)运行测试版的程序,收集程序执行时的性能信息。在这一步会产生很大的执行开销,程序会运行得比较慢,这是因为有部分用于收集信息的代码也要执行。 +(3)利用收集来的性能信息指导编译优化生成优化版的程序。 +#### 3.3.2 举例 + 在这个过程中,程序插桩和FDO是高度耦合的。GCC要求前后两次编译都使用相同的内联决策和相同的优化选项,以保证插桩后的控制流图和标记了性能信息的控制流图是一致的。 +针对该冒泡排序程序,操作如下: +``` +(1)用-fprofile-generate选项创建一个插桩过的二进制文件 +# gcc sort.c -o sort_instrumented -fprofile-generate +(2)运行该二进制文件,生成.gcda文件 +# ./sort_instrumented +Bubble sorting array of 30000 elements +3622 ms +(3)重新基于.gcda文件编译程序,并运行该程序 +# gcc -O3 sort.c -o sort_fdo -fprofile-use=sort.gcda +# ./sort_fdo +Bubble sorting array of 30000 elements +1161 ms +``` + +从结果中可以看到,使用FDO编译的程序比只使用O3选项编译的程序快了3.46%(1161→1123)。实验结果表明,FDO可使程序获得更好的性能。 +注意:在实际的开发过程中,FDO很少被使用,这是因为很难拿一个-fprofile的版本直接到工作环境里面去用,并且利用插桩的方式收集程序性能信息具有很高的运行时开销,另外程序在测试时,也很难生成有代表性的测试数据。但是AutoFDO解决了该问题。 + +### 3.4 AutoFDO +#### 3.4.1 基本原理 + 为克服传统FDO的局限性,AutoFDO被提了出来。AutoFDO最早由Google提出,现在已经集成到gcc中。与传统FDO使用插桩的方式来收集程序性能信息不同,AutoFDO使用`perf`来收集采样性能信息。然后使用一个独立的工具将`perf.data`转换为gcov格式(gcov介绍:https://www.jianshu.com/p/c69b7889e878)的数据,然后基于gcov数据重新编译文件进行优化。AutoFDO跳过了程序插桩的步骤,转而使用基于采样的性能收集器来收集程序性能信息,以指导反馈式编译优化。 +与FDO相比,AutoFDO有如下优点: +(1) 性能信息的收集可以在生产系统上完成。 +(2)开发和测试阶段的性能数据可用于编译优化二进制程序。 +(3)传统的FDO使用程序插桩的方式来收集程序性能信息,但这种方式并不适合于收集如操作系统内核代码这种时间关键型代码的性能信息。AutoFDO很好地解决了这个问题。 +(4)当前基于程序插桩的FDO不支持获取内核代码的执行计数信息。 +#### 3.4.2 举例 +AutoFDO主要有两个步骤: +(1)生成程序性能文件 +AutoFDO需要使用perf.data文件来提供处理器的BR_INST_RETIRED:TAKEN事件信息。这个事件会由于计算机体系结构的不同而在各种平台上有所不同,所以AutoFDO使用`ocperf`工具(pmu-tools项目的一部分)来收集相关信息,该工具会把所有需要的信息都放在一起并生成perf.data文件。用户可以免费使用这个工具,或者就是用perf工具。 + +``` +抓取perf数据 +# ocperf.py record -b -e br_inst_retired.near_taken:pp -- ./sort Bubble sorting array of 30000 elements 3731 ms [ perf record: Woken up 7 times to write data ] [ perf record: Captured and wrote 1.580 MB perf.data (3902 samples) ] +也可以使用perf抓取: perf record -e br_inst_retired:near_taken -b -o perf.data \ -- your_program +``` + +获得perf.data数据之后,还需使用一个独立的工具将perf.data转换为gcov格式的数据。AutoFDO工具集提供了工具create_gcov来完成这个任务。 + +``` +# create_gcov --binary=./sort --profile=perf.data --gcov=sort.gcov -gcov_version=1(注意gcov_version参数必须等于1,因为这是AutoFDO目前所支持的版本) +``` + +(2)使用性能信息指导编译优化 +在这一步中,GCC从程序对应的gcov文件中读取以下性能信息: +* 函数名和文件名。 +* 源文件级的性能信息,从内联栈到采样计数之间的映射。 +* 模块性能信息,模块到辅助模块的映射。 +为了读取性能信息文件,我们还需要重新编译源文件: + +``` +# gcc -O3 -fauto-profile=sort.gcov sort.c -o sort_autofdo +``` + +经过编译后,我们便得到了经过AutoFDO指导编译的程序sort_autofdo,测试结果如下: +``` +# ./sort_autofdo +Bubble sorting array of 30000 elements +1160 ms +``` +从结果中可看出,我们得到了与FDO相似的结果。 + +### 3.5 LTO(llvm ldd 链接器) + LTO: (link-time optimizations) 使整个程序在链接过程中实现二进程优化,降低目标码的体积,例如:一个LTO的内核可以减少超过10%的尺寸大小,并且内核优化后比常规的内核快百分之几,但是它目前的问题是需要占用更多的 系统内存 以及 更长的编译时间。 + LTO背后的理念是: 通过检查编译完单独文件后的整个程序,探索可能出现的任何优化机会。最重要的机会是小函数的inlining。编译器也可以更积极的检测和消除为使用的代码和数据。当源文件编译时,LTO将编译器中间表示(GIMPLE,与机器无关的中间表示)放入到目标文件中。实际LTO阶段加载所有的GIMPLE代码到一个单一核心的映像中,重写进一步优化的目标代码。LTO功能最开始在GCC4.5上出现,但是在4.7上才变得可用。 + LTO就是build settings中的一个编译选项,正如其名一样,Link Time Optimization,就是在链接的时候对程序进行了一些优化。 + + ![image](https://user-images.githubusercontent.com/8308226/235433677-9c082345-956c-4e02-9e27-2d9d6b187001.png) + +在开启LTO(Monolithic)后这些.o文件会附带一些优化信息,让它们在link的时候生成一个单一的整体的.o文件,再和需要的framework链接生成可执行文件。如下图: + +![image](https://user-images.githubusercontent.com/8308226/235433714-35410be8-0b19-44d7-ae15-768e1320359e.png) + + +开启LTO主要有这几点好处: +(1)将一些函数內联化 +(2)去除了一些无用代码 +(3)对程序有全局的优化作用 +区别于传统的优化,LTO的优势有: +(1)完成传统编译器无法实现的过程间优化;eg:常量传播、生存期分析等 +(2)可以针对库函数在特定上下文环境做进一步优化;eg:caller地方发现inline库函数更合适,就不在调用库函数了 +(3)LIR层级的优化:针对芯片指令集的优化,比如:cache相关(指令对齐d等) +(4)根据linke确定的地址信息做优化eg:内存L2→L1 +通常很多软件不用LTO的原因(待补充) +(1)大型软件分布式编译导致周期太长 +(2)钩子函数优化可能有风险 +(3)调试问题 + +### 3.6 LLVM +#### 3.6.1 基本原理 + LLVM是构建编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time)。 + 在理解LLVM时,我们可以认为它包括一个狭义的LLVM和广义的LLVM。广义的LLVM其实就是指整个LLVM编译器架构,包括了前端、后端、优化器、众多的库函数以及很多的模块;而狭义的LLVM其实就是聚焦于编译器后端功能(代码生成、代码优化等)的一系列模块和库。 + 传统的编译器分三个阶段:前端(Frontend)-- 优化器(Optimizer)-- 后端(Backend)。前端负责分析源代码,可以检查语法级错误,并构建针对语言的抽象语法树(AST);抽象语法树可以进一步转换优化,最终转换为新的表示方式,然后再让优化器和后端处理;最后由后端生成可执行的机器码。 + + ![image](https://user-images.githubusercontent.com/8308226/235433766-b79a9603-ee38-4834-beb9-1346a0e28516.png) + + + LLVM也分为三个阶段,但是设计上有些略微的差别,LLVM不同的就是对于不同的语言它都提供了同一种中间表示:前端可以使用不同的编译工具对代码文件做词法分析以形成抽象语法树AST,然后将分析好的代码转换成LLVM的中间表示IR(intermediate representation);中间部分的优化器只对中间表示IR操作,通过一系列的pass对IR做优化;后端负责将优化好的IR解释成对应平台的机器码。LLVM的优点在于,中间表示IR代码编写良好,而且不同的前端语言最终都转换成同一种的IR。 + + ![image](https://user-images.githubusercontent.com/8308226/235433797-05262777-917d-4bd6-9dec-e72e608ee2f9.png) + + +#### 3.6.2 什么是Clang +Clang是LLVM项目的一个子项目,基于LLVM架构的C/C++/Objective-C编译器前端。相比于GCC,Clang具有如下优点: +* 编译速度快:在某些平台上,Clang的编译速度显著的快过GCC(Debug模式下编译OC速度比GGC快3倍) +* 占用内存小:Clang生成的AST所占用的内存是GCC的五分之一左右 +* 模块化设计:Clang采用基于库的模块化设计,易于 IDE 集成及其他用途的重用 +* 诊断信息可读性强:在编译过程中,Clang 创建并保留了大量详细的元数据 (metadata),有利于调试和错误报告 +* 设计清晰简单,容易理解,易于扩展 +LLVM整体架构,前端用的是clang,广义的LLVM是指整个LLVM架构,一般狭义的LLVM指的是LLVM后端(包含代码优化和目标代码生成)。 +源代码(c/c++)经过clang--> 中间代码(经过一系列的优化,优化用的是Pass) --> 机器码 + +![image](https://user-images.githubusercontent.com/8308226/235433821-526ec144-730c-4d68-98b3-9a2c31353844.png) + + +### 3.7 编译后优化bolt +#### 3.7.1 优化流程 + 完整的编译过程分为多个阶段。而采样优化可以发生在各个阶段,比如AutoFDO是在编译阶段,LTO是链接阶段,Ispike是后链接阶段(post-link)。近年来的主要工作集中在编译和链接阶段的FDO技术。AutoFDO的一个主要问题是难以把收集到的数据映射到中间文件。对于后链接(post-link)技术来说,由于采样优化比较晚,受编译和链接优化的影响,因此精确度比较高。基于以上原因开发了一个静态二进制优化器,BOLT,主要可以优化代码的layout。需要说明的是BOLT与前面提到的多个工具是互补关系,可以根据不同场景来使用。 + BOLT是由Facebook推出的一种应用程序动态优化方案,通过perf采集程序运行数据,并使用采集数据对应用程序符号重新排列,提升cpu 指令的cache命中率,最终达到程序性能提升的目的。 + BOLT的运行步骤如下: + 1) 基于perf record收集分析数据,并将其记录在数据文件perf.data中。其中perf record用于记录一段时间内系统/进程的性能事件,默认性能事件为cycles(CPU周期数) + + ``` + ./perf record -F 4000 -e cycles:u -o perf.data -j any,u -p $pid -- sleep $sleep +# 其中参数说明如下: +-F:采样频次 +-e:选择性能事件,cycles:u表示 +-o:指定输出文件,默认为perf.data +-p:表示采集服务对应的进程id +-- sleep:表示采集时长 + ``` + + 2) 把收集到的分析数据转换为BOLT格式 + ``` + perf2bolt -p perf.data -o perf.fdata +#其中参数说明如下: +perf2bolt:二进制工具,用于将perf数据转换为bolt文件 +-p:输入的原始perf文件 +-o:转换后的目标文件 + ``` + + 3) 基于bolt文件,llvm生成优化后二进制程序转换 + + ``` + lvm-bolt bin -o bin.bolt -data=bin.fdata -align-macro-fusion=all -reorder-blocks=cache+ +-reorder-functions=hfsort+ -split-functions=3 -split-all-cold -split-eh -dyno-stats +-icf=1 -update-debug-sections +# 其中参数说明如下: +bin:原始bin服务 +bin.bolt:优化后的bin服务 +-data:转换的数据 + ``` + + 另外,如果目标程序有多个运行模式,那么可以把在各个模式下采集到的数据合并为一个,然后再优化。 + + merge-fdata *.fdata > combined.fdata +#其中参数说明: +merge-fdata:二进制工具,用于将多个fdata数据进行合并 + + +### configure 编译配置 +--host 编译工具链 bin文件夹下 prefix, 前缀部分例如 -gcc的前缀 +``` +CC=path/arm-linux-gcc ./configure --cache-file=cache_file_0 --prefix=/home/eatjpg/arm-bin --host=arm-fsl-linux-gnueabi +``` + +### cmake strip + +``` +function(utils_strip TARGET) + add_custom_command( + TARGET "${TARGET}" POST_BUILD + DEPENDS "${TARGET}" + COMMAND $<$:${CMAKE_STRIP}> + ARGS --strip-all $ + ) +endfunction() + +usage: + utils_strip(${mbedcrypto_target}) + +``` + +### exe release 版本 +好像会直接strip, +so 需要脚本strip才行 T_T + + diff --git a/_posts/build/2026-04-24-clang-tidy.md b/_posts/build/2026-04-24-clang-tidy.md new file mode 100644 index 000000000..9259da771 --- /dev/null +++ b/_posts/build/2026-04-24-clang-tidy.md @@ -0,0 +1,274 @@ +--- +layout: post +title: clang-tidy 使用记录 +subtitle: 安装、配置与批量检查脚本示例 +date: 2026-04-24 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - clang-tidy + - 静态分析 +--- + +>整理 clang-tidy 的安装方式、常用配置,以及批量检查脚本示例,便于后续复用。 + +clang +clangd +lldb +clang-tidy +sudo apt-get install clang-tools + + +``` +run-clang-tidy.py from llvm-project 从llvm-project中copy出来 +``` + +.clang-tidy +``` +--- +# 配置clang-tidy配置检测项,带'-'前缀的为disable对应的检测,否则为开启。这里主要是关闭一些用处不大,或者存在bug、假阳性的检查项 +Checks: '*, + -llvm-*, + -llvmlibc-*, + -altera-*, + -android-*, + -boost-*, + -darwin-*, + -fuchsia-*, + -linuxkernel-*, + -objc-*, + -portability-*, + -zircon-*, + -clang-analyzer-osx*, + -clang-analyzer-optin.cplusplus.UninitializedObject, + -clang-analyzer-optin.cplusplus.VirtualCall, + -clang-analyzer-core.NullDereference, + -clang-analyzer-cplusplus.NewDelete, + -clang-analyzer-cplusplus.PlacementNew, + -clang-analyzer-cplusplus.NewDeleteLeaks, + -clang-analyzer-cplusplus.Move, + -clang-diagnostic-unused-parameter, + -cppcoreguidelines-*, + cppcoreguidelines-explicit-virtual-functions, + cppcoreguidelines-special-member-functions, + -cert-err58-cpp, + -cert-env33-c, + -cert-dcl37-c, + -cert-dcl51-cpp, + -google-runtime-int, + -google-readability-casting, + -google-readability-function-size, + -google-readability-todo, + -google-readability-braces-around-statements, + -google-build-using-namespace, + -readability-magic-numbers, + -readability-implicit-bool-conversion, + -readability-function-cognitive-complexity, + -readability-isolate-declaration, + -readability-convert-member-functions-to-static, + -readability-container-size-empty, + -readability-function-size, + -readability-qualified-auto, + -readability-make-member-function-const, + -readability-named-parameter, + -modernize-use-trailing-return-type, + -modernize-avoid-c-arrays, + -modernize-use-nullptr, + -modernize-replace-disallow-copy-and-assign-macro, + -modernize-use-bool-literals, + -modernize-use-equals-default, + -modernize-use-default-member-init, + -modernize-use-auto, + -modernize-loop-convert, + -modernize-deprecated-headers, + -modernize-raw-string-literal, + -misc-no-recursion, + -misc-unused-parameters, + -misc-redundant-expression, + -misc-non-private-member-variables-in-classes, + -hicpp-*, + hicpp-exception-baseclass, + -performance-no-int-to-ptr, + -bugprone-easily-swappable-parameters, + -bugprone-implicit-widening-of-multiplication-result, + -bugprone-integer-division, + -bugprone-exception-escape, + -bugprone-reserved-identifier, + -bugprone-branch-clone, + -bugprone-narrowing-conversions, +' +# 将警告转为错误 +WarningsAsErrors: '*,-misc-non-private-member-variables-in-classes' +FormatStyle: file +# 过滤检查哪些头文件,clang-tidy会把源码依赖的头文件列出来都检查一遍,所以要屏蔽大量第三方库中的头文件 +# 参考 https://stackoverflow.com/questions/71797349/is-it-possible-to-ignore-a-header-with-clang-tidy +# 该正则表达式引擎为llvm::Regex,支持的表达式较少,(?!xx)负向查找等都不支持 +HeaderFilterRegex: '(xxx/include)*\.h$' +# 具体一些检查项的配置参数,可以参考的: +# https://github.com/envoyproxy/envoy/blob/main/.clang-tidy +# https://github.com/ClickHouse/ClickHouse/blob/d1d2f2c1a4979d17b7d58f591f56346bc79278f8/.clang-tidy +CheckOptions: + - key: readability-identifier-naming.ClassCase + value: CamelCase + - key: readability-identifier-naming.EnumCase + value: CamelCase + - key: readability-identifier-naming.LocalVariableCase + value: lower_case + - key: readability-identifier-naming.StaticConstantCase + value: aNy_CasE + - key: readability-identifier-naming.PrivateMemberCase + value: lower_case + - key: readability-identifier-naming.PrivateMemberSuffix + value: _ + - key: readability-identifier-naming.ProtectedMethodCase + value: lower_case + - key: readability-identifier-naming.ProtectedMethodSuffix + value: _ + - key: readability-braces-around-statements.ShortStatementLines + value: 2 + - key: readability-uppercase-literal-suffix.NewSuffixes + value: 'f;u;ul' + # Ignore GoogleTest function macros. + - key: readability-identifier-naming.FunctionIgnoredRegexp + value: '(TEST|TEST_F|TEST_P|INSTANTIATE_TEST_SUITE_P|MOCK_METHOD|TYPED_TEST)' + - key: performance-move-const-arg.CheckTriviallyCopyableMove + value: 0 + - key: cppcoreguidelines-special-member-functions.AllowSoleDefaultDtor + value: 1 + - key: cppcoreguidelines-special-member-functions.AllowMissingMoveFunctions + value: 1 + - key: cppcoreguidelines-special-member-functions.AllowMissingMoveFunctionsWhenCopyIsDeleted + value: 1 +``` + + +test_clang.sh +``` +#!/bin/bash + +function say() { + echo ">> $(date '+%Y-%m-%d %H:%M:%S') $*" +} + +function cmd() { + say "@$*" + # shellcheck disable=SC2068 + $@ 2>&1 +} + +function join_by() { + local IFS="$1" + shift + echo "$*" +} + +function auto_fix_simple_code() { + # 可以被自动修复的检查项,下面是一些能够稳定修复的常见错误 + AUTO_FIX_CHECKS_CFG=( + "-*" + "modernize-use-nullptr" + "modernize-use-override" + # "modernize-use-using" + "modernize-make-shared" + "boost-use-to-string" + "readability-container-size-empty" + "readability-redundant-access-specifiers" + "readability-redundant-string-cstr" + "readability-redundant-string-init" + "readability-redundant-smartptr-get" + "readability-redundant-control-flow" + "google-readability-namespace-comments" + "performance-unnecessary-copy-initialization" + "performance-for-range-copy" + "performance-noexcept-move-constructor" + "clang-analyzer-deadcode.DeadStores" + ) + echo "test 1" + AUTO_FIX_CHECKS=$(join_by "," "${AUTO_FIX_CHECKS_CFG[@]}") + # + echo "test 2" + ./run-clang-tidy.py -p "$BUILD_DIRECTORY" \ + -checks="$AUTO_FIX_CHECKS" \ + -fix $FILE \ + > /tmp/clang-tidy-fix.log 2>&1 + + echo "test 3" +# if [[ -n "${GITLAB_CI}" && "$(git status --short | wc -l)" != "0" ]]; then +# set -e +o pipefail +# # 存在被自动修复的变更,提交修复变更代码 +# cmd git add -u +# cmd git commit -m "自动修复常规问题" +# cmd git push "http://${CI_USER}:${CI_PRIVATE_TOKEN}@${CI_REPOSITORY_URL#*@}" "HEAD:${CI_MERGE_REQUEST_SOURCE_BRANCH_NAME}" +# exit 0 +# fi +} + +function clang_tidy_check_all() { + # 检查仍然存在的问题 + say ./run-clang-tidy.py -p="$BUILD_DIRECTORY" \ + -config-file="../.vscode/.clang-tidy" $FILE + ./run-clang-tidy.py -p="$BUILD_DIRECTORY" \ + -config-file="../.vscode/.clang-tidy" $FILE \ + > /tmp/clang-tidy-issue.log 2>&1 + + if [[ -n "${GITLAB_CI}" ]]; then + { + echo "clang-tidy 检测结果:" + echo '```' + grep -A 2 -E "error:.*\[.*\]" /tmp/clang-tidy-issue.log + echo '```' + # echo "详情请点击pipeline⭕️图标进行查看" + } > /tmp/clang-tidy-summary.log + if [[ $(wc -l < "/tmp/clang-tidy-summary.log") -gt 4 ]]; then + cmd add_comment "@/tmp/clang-tidy-summary.log" # add_comment 是CI中提供的一个命令,给对应MR中添加评论 + exit 255 # 使CI任务失败 + fi + else + { + echo "clang-tidy 检测结果:" + echo '```' + grep -E "error:.*\[.*\]" /tmp/clang-tidy-issue.log | grep -Eo "\[.*\]" | sort | uniq -c | sort -n + echo '```' + # echo "详情请点击pipeline⭕️图标进行查看" + } > /tmp/clang-tidy-summary.log + cat /tmp/clang-tidy-issue.log + fi +} + +BUILD_DIRECTORY="../../native/cmake-build-script/linux-debug/x64-gnu-lite/" # cmake执行目录 +# SOURCE_DIRECTORY=${CI_PROJECT_DIR:-$(pwd)} # 源码目录 +# say "build cmake in $BUILD_DIRECTORY ..." +# mkdir -p $BUILD_DIRECTORY +# # 执行cmake build,-DCMAKE_EXPORT_COMPILE_COMMANDS=ON 使cmake生成单文件编译依赖配置文件,后续clang-tidy执行需要依赖该配置 +# # 会在cmake build目录下生成一个 compile_commands.jso n文件 +# cmd cd "$BUILD_DIRECTORY" \ +# && cmd cmake "$SOURCE_DIRECTORY" -DCMAKE_BUILD_TYPE:STRING=RelWithDebInfo -DCMAKE_EXPORT_COMPILE_COMMANDS=ON +# cmd cd "$SOURCE_DIRECTORY" + +if [[ -n "${GITLAB_CI}" ]]; then # gitlab CI中会定义GITLAB_CI变量 + # 运行在CI中 + M_SHA1=$(git rev-parse origin/master) + # 过滤出本次MR中涉及修改的文件 + FILE=$(git diff --name-status "$M_SHA1" | grep -E "^(M|A)\s+(include|src)/.*\.(cc|cpp|h|hpp)$" | awk '!/tests/ { print $2 }') + [[ "$FILE" == "" ]] && exit 0 +else + # 手动执行 + # FILE='.*\.(?:h|cc)*' + # FILE='(?原始笔记只留下了几条命令,这里把它整理成一份最小回看版:先构建目标,再看依赖关系,最后补充常见编译选项。 + +## 1. 先直接构建目标 + +```bash +bazel build :protoc :protobuf --enable_bzlmod +``` + +这条命令适合先验证两件事: + +- 当前工作区能否正常启用 `bzlmod` +- `protoc` 和 `protobuf` 这两个目标是否能被顺利解析并构建 + +## 2. 需要看依赖关系时生成依赖图 + +```bash +bazel mod graph --output graph --enable_bzlmod +``` + +这一步适合快速回看模块依赖关系,尤其是在下面这些场景里比较有用: + +- 怀疑某个模块版本被意外拉进来 +- 想确认 `bzlmod` 解析后的依赖结构 +- 需要先理解依赖关系,再继续排查构建问题 + +## 3. 需要优化产物属性时追加编译参数 + +```bash +bazel build -c opt --copt '-fPIC' :protoc :protobuf --enable_bzlmod +``` + +这里保留的两个选项分别对应: + +- `-c opt`:按优化配置构建 +- `--copt '-fPIC'`:给编译阶段追加 `-fPIC` + +回看时要优先确认: + +- 目标是否真的需要位置无关代码 +- 这类 `--copt` 是临时排查用,还是应该沉到更稳定的构建配置里 + +## 4. 最小使用顺序 + +如果只是快速回忆,通常按下面顺序就够了: + +1. 先跑基础构建,确认目标能不能过 +2. 再看 `mod graph`,理解依赖是否符合预期 +3. 最后按需要补 `-c opt` 或 `-fPIC` 这类选项 diff --git a/_posts/build/2026-04-25-bloaty.md b/_posts/build/2026-04-25-bloaty.md new file mode 100644 index 000000000..45d2f0d82 --- /dev/null +++ b/_posts/build/2026-04-25-bloaty.md @@ -0,0 +1,76 @@ +--- +layout: post +title: Bloaty 体积分析备忘 +subtitle: 从符号、编译单元到运行时映射的最小排查入口 +date: 2026-04-25 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Linux + - ELF + - Bloaty +--- + +>把原始几条命令整理成一条最短分析链路:先看符号和编译单元,再结合段大小与进程映射判断体积主要花在哪。 + +## 1. 先按符号看体积占比 + +当你想先知道“到底是哪些函数 / 符号最占空间”时,可以直接看 `symbols` 维度: + +```bash +bloaty -d symbols -n 0 libmicontinuity.so > 2.txt +``` + +- `-d symbols`:按符号维度展开 +- `-n 0`:不限制输出条数 +- `2.txt`:把结果落盘,方便后续排序或对比 + +## 2. 再按编译单元看体积来源 + +如果你已经知道问题大概出在某个模块,而不是某个具体符号,更适合切到 `compileunits`: + +```bash +bloaty -d compileunits -n 0 libsimple_decoder.so > compileunits.txt +``` + +这一步适合回答两个问题: + +- 哪个源文件 / 编译单元贡献了最多体积 +- 是否某个模块整体被意外拉大了 + +## 3. 看各个段的占比 + +如果怀疑不是单个函数的问题,而是 `.text`、`.rodata`、`.data`、`.bss` 这类段整体偏大,可以直接看段级统计: + +```bash +~/.toolchain/sdk_package_MC01/toolchain/bin/aarch64-openwrt-linux-size -A ./libmicontinuity_sdk.so.1.0.4032716 +``` + +这一步更适合快速判断: + +- 代码段是否明显膨胀 +- 只读数据是否异常增大 +- 某些静态对象是否把数据段撑大了 + +## 4. 结合 smaps 看运行时占用 + +二进制文件本身体积和进程实际映射占用不完全是一回事。 +如果你要继续确认“进程里到底是哪几个库占得多”,可以再看: + +```bash +cat /proc/$pid/smaps +cat /proc/$pid/status +``` + +更适合关注: + +- 各个 so 的映射大小 +- RSS / PSS 等运行时占用 +- 进程整体内存状态是否和文件体积分析一致 + +## 5. 参考链接 + +```text +https://blog.csdn.net/weiwei9363/article/details/121475302 +``` diff --git a/_posts/build/2026-04-25-clang-format.md b/_posts/build/2026-04-25-clang-format.md new file mode 100644 index 000000000..62fcb341d --- /dev/null +++ b/_posts/build/2026-04-25-clang-format.md @@ -0,0 +1,620 @@ +--- +layout: post +title: clang-format 使用整理 +subtitle: 增量格式化、VS Code 接入与多份配置 demo +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - clang-format +--- + +> 这篇把原始笔记中的脚本、VS Code 接入步骤和多份 `.clang-format` 配置 demo 都整理在一起。多份配置(自用 / 详解版 / jemalloc / Microsoft / Google)都保留,方便对比与切换。 + +## clang-format + +### pre-commit hooks 只修改改动过的代码格式 + +vscode 配置方法,会改文件!!! + +```bash +#!/usr/bin/env bash +# =============================================================== +# pre-commit hook: clang-format 增量格式化 +# 仅格式化本次提交修改的 C/C++ 源文件行 +# =============================================================== + +set -e +set -o pipefail + +# ---------- 配置 ---------- +CLANG_FORMAT_BIN="clang-format" +EXT_PATTERN="\.(c|cc|cpp|cxx|h|hpp|hh|hxx)$" + +# ---------- 检查依赖 ---------- +if ! command -v $CLANG_FORMAT_BIN >/dev/null 2>&1; then + echo "[ERROR] clang-format not found. Please install it:" + echo " sudo apt install clang-format" + exit 1 +fi + +# ---------- 获取暂存文件 ---------- +FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E "$EXT_PATTERN" || true) + +if [ -z "$FILES" ]; then + echo "[pre-commit] No C/C++ files to format." + exit 0 +fi + +# ---------- 检查并格式化 ---------- +echo "[pre-commit] Running clang-format on modified lines..." + +# 对每个文件单独执行 clang-format-diff +for FILE in $FILES; do + if [ ! -f "$FILE" ]; then + continue + fi + + # 只格式化已暂存修改的行 + git diff -U0 --cached "$FILE" | $CLANG_FORMAT_BIN-diff -p1 -i + + # 若有更改,重新添加到暂存区 + if ! git diff --quiet "$FILE"; then + git add "$FILE" + echo " formatted: $FILE" + fi +done + +echo "[pre-commit] Done ✅" +exit 0 +``` + +### 安装与 VS Code 接入 + +```bash +sudo apt-get install clang-format +``` + +vscode 安装 clang-format 插件。 + +### 一个常用的 .clang-format 文件 + +```yaml +--- +BasedOnStyle: Google +AccessModifierOffset: -4 +AlignConsecutiveAssignments: true +AlignConsecutiveBitFields: true +AlignConsecutiveDeclarations: true +AlignConsecutiveMacros: true +AllowAllConstructorInitializersOnNextLine: false +AllowShortBlocksOnASingleLine: Empty +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AlwaysBreakBeforeMultilineStrings: false +BinPackArguments: true +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: All +BreakBeforeBraces: Custom +BreakConstructorInitializers: AfterColon +BreakInheritanceList: AfterColon +ColumnLimit: 120 +CompactNamespaces: true +ConstructorInitializerIndentWidth: 8 +IndentWidth: 4 +IndentWrappedFunctionNames: true +NamespaceIndentation: All +ReflowComments: false +SpaceAfterTemplateKeyword: false +SpacesBeforeTrailingComments: 4 +Standard: Latest +TabWidth: 4 +``` + +### clang-format 配置详解 + +```yaml +--- + # 语言: None, Cpp, Java, JavaScript, ObjC, Proto, TableGen, TextProto + Language: Cpp + # BasedOnStyle: LLVM + # 访问说明符(public、private等)的偏移 + AccessModifierOffset: -4 + # 开括号(开圆括号、开尖括号、开方括号)后的对齐: Align, DontAlign, AlwaysBreak(总是在开括号后换行) + AlignAfterOpenBracket: Align + # 连续赋值时,对齐所有等号 + AlignConsecutiveAssignments: true + # 连续声明时,对齐所有声明的变量名 + AlignConsecutiveDeclarations: true + # 左对齐逃脱换行(使用反斜杠换行)的反斜杠 + AlignEscapedNewlinesLeft: true + # 水平对齐二元和三元表达式的操作数 + AlignOperands: true + # 对齐连续的尾随的注释 + AlignTrailingComments: true + # 允许函数声明的所有参数在放在下一行 + AllowAllParametersOfDeclarationOnNextLine: true + # 允许短的块放在同一行 + AllowShortBlocksOnASingleLine: false + # 允许短的case标签放在同一行 + AllowShortCaseLabelsOnASingleLine: false + # 允许短的函数放在同一行: None, InlineOnly(定义在类中), Empty(空函数), Inline(定义在类中,空函数), All + AllowShortFunctionsOnASingleLine: Empty + # 允许短的if语句保持在同一行 + AllowShortIfStatementsOnASingleLine: false + # 允许短的循环保持在同一行 + AllowShortLoopsOnASingleLine: false + # 总是在定义返回类型后换行(deprecated) + AlwaysBreakAfterDefinitionReturnType: None + # 总是在返回类型后换行: None, All, TopLevel(顶级函数,不包括在类中的函数), + # AllDefinitions(所有的定义,不包括声明), TopLevelDefinitions(所有的顶级函数的定义) + AlwaysBreakAfterReturnType: None + # 总是在多行string字面量前换行 + AlwaysBreakBeforeMultilineStrings: false + # 总是在template声明后换行 + AlwaysBreakTemplateDeclarations: false + # false表示函数实参要么都在同一行,要么都各自一行 + BinPackArguments: true + # false表示所有形参要么都在同一行,要么都各自一行 + BinPackParameters: true + # 大括号换行,只有当BreakBeforeBraces设置为Custom时才有效 + BraceWrapping: + # class定义后面 + AfterClass: false + # 控制语句后面 + AfterControlStatement: false + # enum定义后面 + AfterEnum: false + # 函数定义后面 + AfterFunction: false + # 命名空间定义后面 + AfterNamespace: false + # ObjC定义后面 + AfterObjCDeclaration: false + # struct定义后面 + AfterStruct: false + # union定义后面 + AfterUnion: false + # catch之前 + BeforeCatch: true + # else之前 + BeforeElse: true + # 缩进大括号 + IndentBraces: false + # 在二元运算符前换行: None(在操作符后换行), NonAssignment(在非赋值的操作符前换行), All(在操作符前换行) + BreakBeforeBinaryOperators: NonAssignment + # 在大括号前换行: Attach(始终将大括号附加到周围的上下文), Linux(除函数、命名空间和类定义,与Attach类似), + # Mozilla(除枚举、函数、记录定义,与Attach类似), Stroustrup(除函数定义、catch、else,与Attach类似), + # Allman(总是在大括号前换行), GNU(总是在大括号前换行,并对于控制语句的大括号增加额外的缩进), WebKit(在函数前换行), Custom + # 注:这里认为语句块也属于函数 + BreakBeforeBraces: Custom + # 在三元运算符前换行 + BreakBeforeTernaryOperators: true + # 在构造函数的初始化列表的逗号前换行 + BreakConstructorInitializersBeforeComma: false + # 每行字符的限制,0表示没有限制 + ColumnLimit: 200 + # 描述具有特殊意义的注释的正则表达式,它不应该被分割为多行或以其它方式改变 + CommentPragmas: '^ IWYU pragma:' + # 构造函数的初始化列表要么都在同一行,要么都各自一行 + ConstructorInitializerAllOnOneLineOrOnePerLine: false + # 构造函数的初始化列表的缩进宽度 + ConstructorInitializerIndentWidth: 4 + # 延续的行的缩进宽度 + ContinuationIndentWidth: 4 + # 去除C++11的列表初始化的大括号{后和}前的空格 + Cpp11BracedListStyle: false + # 继承最常用的指针和引用的对齐方式 + DerivePointerAlignment: false + # 关闭格式化 + DisableFormat: false + # 自动检测函数的调用和定义是否被格式为每行一个参数(Experimental) + ExperimentalAutoDetectBinPacking: false + # 需要被解读为foreach循环而不是函数调用的宏 + ForEachMacros: [ foreach, Q_FOREACH, BOOST_FOREACH ] + # 对#include进行排序,匹配了某正则表达式的#include拥有对应的优先级,匹配不到的则默认优先级为INT_MAX(优先级越小排序越靠前), + # 可以定义负数优先级从而保证某些#include永远在最前面 + IncludeCategories: + - Regex: '^"(llvm|llvm-c|clang|clang-c)/' + Priority: 2 + - Regex: '^(<|"(gtest|isl|json)/)' + Priority: 3 + - Regex: '.*' + Priority: 1 + # 缩进case标签 + IndentCaseLabels: false + # 缩进宽度 + IndentWidth: 4 + # 函数返回类型换行时,缩进函数声明或函数定义的函数名 + IndentWrappedFunctionNames: false + # 保留在块开始处的空行 + KeepEmptyLinesAtTheStartOfBlocks: true + # 开始一个块的宏的正则表达式 + MacroBlockBegin: '' + # 结束一个块的宏的正则表达式 + MacroBlockEnd: '' + # 连续空行的最大数量 + MaxEmptyLinesToKeep: 1 + # 命名空间的缩进: None, Inner(缩进嵌套的命名空间中的内容), All + NamespaceIndentation: Inner + # 使用ObjC块时缩进宽度 + ObjCBlockIndentWidth: 4 + # 在ObjC的@property后添加一个空格 + ObjCSpaceAfterProperty: false + # 在ObjC的protocol列表前添加一个空格 + ObjCSpaceBeforeProtocolList: true + # 在call(后对函数调用换行的penalty + PenaltyBreakBeforeFirstCallParameter: 19 + # 在一个注释中引入换行的penalty + PenaltyBreakComment: 300 + # 第一次在<<前换行的penalty + PenaltyBreakFirstLessLess: 120 + # 在一个字符串字面量中引入换行的penalty + PenaltyBreakString: 1000 + # 对于每个在行字符数限制之外的字符的penalty + PenaltyExcessCharacter: 1000000 + # 将函数的返回类型放到它自己的行的penalty + PenaltyReturnTypeOnItsOwnLine: 60 + # 指针和引用的对齐: Left, Right, Middle + PointerAlignment: Left + # 允许重新排版注释 + ReflowComments: true + # 允许排序#include + SortIncludes: true + # 在C风格类型转换后添加空格 + SpaceAfterCStyleCast: false + # 在赋值运算符之前添加空格 + SpaceBeforeAssignmentOperators: true + # 开圆括号之前添加一个空格: Never, ControlStatements, Always + SpaceBeforeParens: ControlStatements + # 在空的圆括号中添加空格 + SpaceInEmptyParentheses: false + # 在尾随的评论前添加的空格数(只适用于//) + SpacesBeforeTrailingComments: 2 + # 在尖括号的<后和>前添加空格 + SpacesInAngles: true + # 在容器(ObjC和JavaScript的数组和字典等)字面量中添加空格 + SpacesInContainerLiterals: true + # 在C风格类型转换的括号中添加空格 + SpacesInCStyleCastParentheses: true + # 在圆括号的(后和)前添加空格 + SpacesInParentheses: true + # 在方括号的[后和]前添加空格,lamda表达式和未指明大小的数组的声明不受影响 + SpacesInSquareBrackets: true + # 标准: Cpp03, Cpp11, Auto + Standard: Cpp11 + # tab宽度 + TabWidth: 4 + # 使用tab字符: Never, ForIndentation, ForContinuationAndIndentation, Always + UseTab: Never + ... +``` + +### 配置生效 + +要求版本 vscode 1.70+ + +`.vscode/settings.json`: + +```json +"clang-format.fallbackStyle": "file", +"editor.formatOnSave": true, +"editor.formatOnSaveMode": "modifications", +``` + +### 配置检查 + +```bash +clang-format --dump-config +``` + +### jemalloc 风格 demo + +```yaml +# jemalloc targets clang-format version 8. We include every option it supports +# here, but comment out the ones that aren't relevant for us. +--- +# AccessModifierOffset: -2 +AlignAfterOpenBracket: DontAlign +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Right +AlignOperands: false +AlignTrailingComments: false +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: true +AllowShortCaseLabelsOnASingleLine: true +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: true +AllowShortLoopsOnASingleLine: true +AlwaysBreakAfterReturnType: AllDefinitions +AlwaysBreakBeforeMultilineStrings: true +# AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: true + AfterControlStatement: true + AfterEnum: true + AfterFunction: true + AfterNamespace: true + AfterObjCDeclaration: true + AfterStruct: true + AfterUnion: true + BeforeCatch: true + BeforeElse: true + IndentBraces: true +# BreakAfterJavaFieldAnnotations: true +BreakBeforeBinaryOperators: NonAssignment +BreakBeforeBraces: Allman +BreakBeforeTernaryOperators: true +# BreakConstructorInitializers: BeforeColon +# BreakInheritanceList: BeforeColon +BreakStringLiterals: false +ColumnLimit: 120 +# CommentPragmas: '' +# CompactNamespaces: true +# ConstructorInitializerAllOnOneLineOrOnePerLine: true +# ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: [ ql_foreach, qr_foreach, ] +# IncludeBlocks: Preserve +# IncludeCategories: +# - Regex: '^<.*\.h(pp)?>' +# Priority: 1 +# IncludeIsMainRegex: '' + + # 可以定义负数优先级从而保证某些#include永远在最前面 + IncludeCategories: + - Regex: '^"(llvm|llvm-c|clang|clang-c)/' + Priority: 2 + - Regex: '^(<|"(gtest|isl|json)/)' + Priority: 3 + - Regex: '.*' + Priority: 1 + +IndentCaseLabels: false +IndentPPDirectives: AfterHash +IndentWidth: 4 +IndentWrappedFunctionNames: false +# JavaImportGroups: [] +# JavaScriptQuotes: Leave +# JavaScriptWrapImports: True +KeepEmptyLinesAtTheStartOfBlocks: false +Language: Cpp +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +# NamespaceIndentation: None +# ObjCBinPackProtocolList: Auto +# ObjCBlockIndentWidth: 2 +# ObjCSpaceAfterProperty: false +# ObjCSpaceBeforeProtocolList: false + +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +# PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 60 +PointerAlignment: Right +# RawStringFormats: +# - Language: TextProto +# Delimiters: +# - 'pb' +# - 'proto' +# EnclosingFunctions: +# - 'PARSE_TEXT_PROTO' +# BasedOnStyle: google +# - Language: Cpp +# Delimiters: +# - 'cc' +# - 'cpp' +# BasedOnStyle: llvm +# CanonicalDelimiter: 'cc' +ReflowComments: true +SortIncludes: false +SpaceAfterCStyleCast: false +# SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +# SpaceBeforeCpp11BracedList: false +# SpaceBeforeCtorInitializerColon: true +# SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +# SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +# SpacesInContainerLiterals: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +# Standard: Cpp11 +# This is nominally supported in clang-format version 8, but not in the build +# used by some of the core jemalloc developers. +# StatementMacros: [] +TabWidth: 4 +UseTab: Never +... +``` + +### Microsoft 风格 demo + +```yaml +--- +BasedOnStyle: Microsoft +AccessModifierOffset: -2 +AlignAfterOpenBracket: Align +AllowAllArgumentsOnNextLine: false +AllowAllConstructorInitializersOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: Never +AllowShortCaseLabelsOnASingleLine: true +AllowShortFunctionsOnASingleLine: None +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: All +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: true +BreakBeforeBraces: Allman +BreakBeforeTernaryOperators: false +BreakInheritanceList: BeforeComma +BreakStringLiterals: false +ColumnLimit: 150 +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: false +Cpp11BracedListStyle: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +IncludeBlocks: Regroup +IndentCaseLabels: true +IndentPPDirectives: None +IndentWidth: 4 +Language: Cpp +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: All +ReflowComments: true +SortIncludes: true +SortUsingDeclarations: true +SpaceAfterCStyleCast: true +SpaceAfterTemplateKeyword: false +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpacesInParentheses: false +Standard: c++17 +StatementMacros: [ Q_UNUSED, LOG, DEBUG ] +TabWidth: 4 +UseTab: Never + +... +``` + +### Google 风格 demo + +```yaml +--- +Language: Cpp +# BasedOnStyle: Google +AccessModifierOffset: -4 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlinesLeft: true +AlignOperands: true +AlignTrailingComments: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: true +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Attach +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakAfterJavaFieldAnnotations: false +BreakStringLiterals: true +ColumnLimit: 80 +CommentPragmas: '^ IWYU pragma:' +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: true +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +ForEachMacros: [ foreach, Q_FOREACH, BOOST_FOREACH ] +IncludeCategories: + - Regex: '^<.*\.h>' + Priority: 1 + - Regex: '^<.*' + Priority: 2 + - Regex: '.*' + Priority: 3 +IncludeIsMainRegex: '([-_](test|unittest))?$' +IndentCaseLabels: true +IndentWidth: 4 +IndentWrappedFunctionNames: false +JavaScriptQuotes: Leave +JavaScriptWrapImports: true +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBlockIndentWidth: 4 +ObjCSpaceAfterProperty: false +ObjCSpaceBeforeProtocolList: false +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 200 +PointerAlignment: Left +ReflowComments: true +SortIncludes: true +SpaceAfterCStyleCast: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeParens: ControlStatements +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInContainerLiterals: true +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Auto +TabWidth: 8 +UseTab: Never +... +``` diff --git a/_posts/build/2026-04-25-cmake-cheat-sheet.md b/_posts/build/2026-04-25-cmake-cheat-sheet.md new file mode 100644 index 000000000..448b6bfaf --- /dev/null +++ b/_posts/build/2026-04-25-cmake-cheat-sheet.md @@ -0,0 +1,115 @@ +--- +layout: post +title: CMake 速查表 +subtitle: 编译选项、ccache/distcc、graphviz、version script 等常用片段 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - CMake + - 构建 + - C++ +--- + +>原始笔记是若干段零散的 CMake 片段,标题层级混乱、还有一段 ChatGPT 风格的中英混排说明。这里按"编译参数 / ccache / 自定义函数 / 符号导出 / 依赖图 / 强制动态库"分块整理,命令与示例原样保留。 + +## 当前保留内容 + +### 1. 并发编译 / 关闭异常与 RTTI + +cmake 关闭异常和 RTTI: + +``` +-fno-exceptions and -fno-rtti +``` + +下面这种写法实际**未生效**(仅作反例记录,需要确认是否在合适的目标和阶段被读入): + +``` + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -DOS_POSIX -DOS_ANDROID -DOS_LINUX -DMULTITHREADED_BUILD=4") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -DOS_POSIX -DOS_ANDROID -DOS_LINUX -DMULTITHREADED_BUILD=4") +``` + +### 2. 指定编译器 + +``` +cmake .. -DCMAKE_CXX_COMPILER=/usr/bin/clang++ -DCMAKE_C_COMPILER=/usr/bin/clang +``` + +### 3. ccache(Windows) + +安装 ccache 后将其添加到环境变量(`ccache` 所在目录),执行 `ccache --help` 自检即可。无需其他设置就已经生效。 + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/87df41e7-bd29-44ee-871e-4716b397fb81) + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/6fe0198f-e1a5-41eb-8c2e-152d8ab5a303) + +### 4. ccache + distcc + +`distcc` 是分布式编译工具,与 ccache 组合可以"本地缓存命中 + 未命中分发到远端"。 + +### 5. 自定义按扩展名收集源文件的函数 + +``` +# add only ext(.cpp .cxx .cc etc) files in the path +# Usage: +# lyra_aux_source_directory_ex( ) +function(lyra_aux_source_directory_ex ext PROTO_PATH OUT_SRCS) + file(GLOB SRC_FILES "${PROTO_PATH}/*${ext}") + list(APPEND ${OUT_SRCS} ${SRC_FILES}) + set(${OUT_SRCS} ${${OUT_SRCS}} PARENT_SCOPE) +endfunction() +``` + +### 6. 导出符号表(version script) + +参考: + +全局链接选项: + +``` +--version-script 全局链接选项 +set(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,--version-script=${CMAKE_SOURCE_DIR}/version.map") +``` + +对某个目标生效: + +``` +add_library(mylib SHARED mylib.c) +set_target_properties(mylib PROPERTIES LINK_FLAGS "-Wl,--version-script=${CMAKE_SOURCE_DIR}/version.map") +``` + +> 要在 CMake 中为特定目标启用 version script,可以使用 `set_target_properties` 命令并设置 `LINK_FLAGS` 属性。例如,假设有一个名为 `my_target` 的目标,并且想要使用名为 `my_version_script` 的版本脚本文件,则可以使用以下命令: + +``` +set_target_properties(my_target PROPERTIES LINK_FLAGS "-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/my_version_script") +``` + +> 这将为 `my_target` 目标设置链接标志,以便在链接时使用 `my_version_script` 版本脚本文件。`-Wl` 选项用于将选项传递给链接器。`CMAKE_CURRENT_SOURCE_DIR` 变量包含当前正在处理的 `CMakeLists.txt` 文件的目录路径。 + +### 7. graphviz:可视化目标依赖 + +``` +cmake --graphviz=foo.dot # 添加配置项 +dot -Tpng foo.dot -o foo.png # 转 png +``` + +### 8. 强制使用动态库 + +``` +add_dependencies(${TARGET_NAME} micontinuity) +if(TARGET micontinuity_so) + add_library(micontinuity_so SHARED IMPORTED) + message("CMAKE_INSTALL_LIBDIR is " + "${ROOT_PATH}/cmake-build-script/linux-release/router-rc01/runtime/services/libmicontinuity.so") + set_target_properties(micontinuity_so PROPERTIES IMPORTED_LOCATION + "${ROOT_PATH}/cmake-build-script/linux-release/router-rc01/runtime/services/libmicontinuity.so") +endif() +``` + +## 后续可补的方向 + +- 整理 `CMAKE_*_FLAGS`、`add_compile_options`、`target_compile_options` 在不同生效范围下的优先级与坑点。 +- 给出一个最小可复现的 graphviz 依赖图样例,配合截图说明如何快速找到环依赖。 +- 把 ccache + distcc 的一键脚本沉淀下来,记录命中率与远端节点配置经验。 diff --git a/_posts/build/2026-04-25-cmake_analyzer.md b/_posts/build/2026-04-25-cmake_analyzer.md new file mode 100644 index 000000000..60e21d0f1 --- /dev/null +++ b/_posts/build/2026-04-25-cmake_analyzer.md @@ -0,0 +1,239 @@ +--- +layout: post +title: CMake 依赖分析脚本 +subtitle: 扫描 target_link_libraries、检测循环依赖并导出 dot 图 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - CMake + - Python + - Graphviz +--- + +>原始内容是一段可直接运行的 Python 脚本,这里补上用途说明、输入参数和典型使用方式。 + +## 这段脚本做什么 + +它主要做三件事: + +1. 递归收集 `CMakeLists.txt` +2. 解析 `target_link_libraries(...)` 依赖关系 +3. 检测循环依赖,并导出 Graphviz `dot` 文件 + +如果你的工程模块很多、依赖关系已经开始难以靠肉眼判断,这类脚本很适合先把整体图拉出来。 + +## 使用方式 + +```bash +python3 cmake_analyzer.py <搜索路径或通配符> [选项] +``` + +常用场景: + +```bash +python3 cmake_analyzer.py . -o cmake_deps.dot +python3 cmake_analyzer.py . --highlight 10 +python3 cmake_analyzer.py . --top 30 -o pruned.dot +python3 cmake_analyzer.py . -I ./src --exclude ./third_party +``` + +## 支持的几个关键参数 + +- `-I, --include`:只分析这些目录 +- `--exclude`:排除某些目录 +- `-o, --output`:指定导出的 `dot` 文件名 +- `--highlight`:高亮入度大于等于 N 的节点 +- `--prune-in` / `--prune-out`:按入度或出度裁剪图 +- `--top`:只保留前 N 个关键节点 +- `--debug`:输出调试信息 + +## 脚本 + +```python +#!/usr/bin/env python3 +import os +import re +import glob +import argparse +from collections import defaultdict, Counter + + +def normalize_dirs(dirs): + """规范化目录路径,保证是绝对路径并以 / 结尾""" + if not dirs: + return None + result = [] + for d in dirs: + absd = os.path.abspath(d) + if not absd.endswith(os.sep): + absd += os.sep + result.append(absd) + return result + + +def expand_cmakelists(paths, include_dirs=None, exclude_dirs=None, debug=False): + """递归展开所有 CMakeLists.txt""" + files = [] + for p in paths: + matches = glob.glob(p, recursive=True) + + if not matches and os.path.isdir(p): + matches = glob.glob(os.path.join(p, "**/CMakeLists.txt"), recursive=True) + + for m in matches: + absm = os.path.abspath(m) + + if include_dirs and not any(absm.startswith(d) for d in include_dirs): + continue + + if exclude_dirs and any(absm.startswith(d) for d in exclude_dirs): + if debug: + print(f"[DEBUG] 排除: {absm}") + continue + + files.append(absm) + + if debug: + print(f"[DEBUG] 找到 {len(files)} 个 CMakeLists.txt") + return files + + +def parse_cmake(files, debug=False): + """解析 target_link_libraries 依赖""" + graph = defaultdict(list) + pattern = re.compile(r"target_link_libraries\s*\((\w+)\s+([^)]+)\)", re.IGNORECASE) + + for f in files: + if debug: + print(f"[DEBUG] 解析 {f}") + with open(f, "r", encoding="utf-8", errors="ignore") as fh: + for line in fh: + m = pattern.search(line) + if m: + target = m.group(1) + deps = re.split(r"[\s;]+", m.group(2).strip()) + deps = [d for d in deps if d and d.upper() not in ("PUBLIC", "PRIVATE", "INTERFACE")] + graph[target].extend(deps) + if debug: + print(f" {target} -> {deps}") + return graph + + +def detect_cycles(graph, max_cycles=20): + """DFS 检测循环依赖""" + visited, stack, cycles = set(), [], [] + + def dfs(node): + if node in stack: + idx = stack.index(node) + cycles.append(stack[idx:] + [node]) + return + if node in visited: + return + visited.add(node) + stack.append(node) + for dep in graph.get(node, []): + dfs(dep) + stack.pop() + + for n in graph: + dfs(n) + if len(cycles) >= max_cycles: + break + return cycles + + +def prune_graph(graph, counts_in, counts_out, top_n=None, prune_in=None, prune_out=None, debug=False): + """裁剪子图,只保留最关键部分""" + keep = set() + if top_n: + keep.update([n for n, _ in counts_in.most_common(top_n)]) + if prune_in: + keep.update([n for n, c in counts_in.items() if c >= prune_in]) + if prune_out: + keep.update([n for n, c in counts_out.items() if c >= prune_out]) + + if not keep: + return graph + + new_graph = {} + for n, deps in graph.items(): + if n in keep: + new_graph[n] = [d for d in deps if d in keep] + + if debug: + print(f"[DEBUG] 裁剪: 原始 {len(graph)} 节点 -> 剩余 {len(new_graph)} 节点") + return new_graph + + +def export_to_dot(graph, counts_in, highlight=None, out_file="cmake_deps.dot"): + """导出依赖关系为 Graphviz dot 文件""" + with open(out_file, "w", encoding="utf-8") as f: + f.write("digraph cmake_deps {\n") + f.write(" node [shape=box, style=filled, fillcolor=lightgray];\n") + for n, deps in graph.items(): + color = "red" if highlight and counts_in[n] >= highlight else "lightgray" + f.write(f" \"{n}\" [fillcolor={color}];\n") + for d in deps: + f.write(f" \"{n}\" -> \"{d}\";\n") + f.write("}\n") + print(f"[OK] 依赖图已导出: {out_file}") + + +def main(): + ap = argparse.ArgumentParser(description="CMake target 依赖循环检测工具") + ap.add_argument("sources", nargs="+", help="CMakeLists 搜索路径或通配符") + ap.add_argument("-I", "--include", action="append", help="只分析这些目录下的 CMakeLists") + ap.add_argument("--exclude", action="append", help="屏蔽某些目录") + ap.add_argument("-o", "--output", default="cmake_deps.dot", help="输出 dot 文件名") + ap.add_argument("--highlight", type=int, help="高亮依赖数 >= N 的节点") + ap.add_argument("--prune-in", type=int, help="保留入度 >= N 的节点") + ap.add_argument("--prune-out", type=int, help="保留出度 >= N 的节点") + ap.add_argument("--top", type=int, help="保留前 N 个入度最大的节点") + ap.add_argument("--debug", action="store_true", help="调试模式") + args = ap.parse_args() + + include_dirs = normalize_dirs(args.include) + exclude_dirs = normalize_dirs(args.exclude) + + files = expand_cmakelists(args.sources, include_dirs, exclude_dirs, args.debug) + graph = parse_cmake(files, args.debug) + + counts_in, counts_out = Counter(), Counter() + for n, deps in graph.items(): + counts_out[n] += len(deps) + for d in deps: + counts_in[d] += 1 + + cycles = detect_cycles(graph) + if cycles: + print("🔴 循环依赖检测到:") + for c in cycles: + print(" -> ".join(c)) + else: + print("✅ 没有发现循环依赖") + + graph = prune_graph( + graph, + counts_in, + counts_out, + top_n=args.top, + prune_in=args.prune_in, + prune_out=args.prune_out, + debug=args.debug, + ) + + export_to_dot(graph, counts_in, args.highlight, args.output) + + +if __name__ == "__main__": + main() +``` + +## 整理后的使用建议 + +- 先跑全量图,再做裁剪,不然容易一开始就漏掉关键依赖 +- 如果输出太大,优先用 `--top`、`--prune-in`、`--prune-out` +- 真正复杂的项目里,`target_link_libraries` 可能跨多行、含变量展开,这类脚本更适合做快速体检,而不是替代完整 CMake 语义解析 diff --git a/_posts/build/2026-04-25-from_post_clang-format.md b/_posts/build/2026-04-25-from_post_clang-format.md new file mode 100644 index 000000000..557dae5c7 --- /dev/null +++ b/_posts/build/2026-04-25-from_post_clang-format.md @@ -0,0 +1,320 @@ +--- +layout: post +title: .clang-format 中文注释版配置示例 +subtitle: 一份带中文说明的整 .clang-format 模板,方便回看每一项含义 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - clang-format +--- + +>原始文件只是一份没有任何上下文的 `.clang-format` 配置。这里把它整理成「这份配置是用来干嘛的 + 整段配置 + 维护提醒」三块。 +> +>更系统的 clang-format 使用方法(增量格式化、VS Code 接入、多份 demo 对比)放在另一篇 `clang-format.md` 里,本文只保留「带中文注释的完整配置 demo」这一个用途。 + +## 这份配置适合什么场景 + +- 想快速拿一份**比较激进、注释又齐全的 `.clang-format`** 套到自己工程里 +- 看 `clang-format.md` 主篇时想对照具体某一项的中文注释含义 +- 对官方文档的英文项不熟,先靠中文备注快速过一遍每一项干什么用 + +参考文档(出处在原始笔记里就有): + +- +- + +## 完整配置(带中文注释) + +下面这份直接整段拷到工程根目录的 `.clang-format` 即可。建议拷过去之后,至少先用代码评审里**短期最在意的几条**作为基准(例如 `ColumnLimit`、`IndentWidth`、`PointerAlignment`)。 + +```yaml +# 语言: None, Cpp, Java, JavaScript, ObjC, Proto, TableGen, TextProto +Language: Cpp + +BasedOnStyle: LLVM + +# 访问说明符(public、private 等)的偏移 +AccessModifierOffset: -4 + +# 左括号(左圆括号、左尖括号、左方括号)后的对齐: +# Align, DontAlign, AlwaysBreak(总是在左括号后换行) +AlignAfterOpenBracket: Align + +# 连续赋值时,对齐所有等号 +AlignConsecutiveAssignments: true + +# 连续声明时,对齐所有声明的变量名 +AlignConsecutiveDeclarations: true + +# 对齐连续位域字段的风格 +# AlignConsecutiveBitFields: AcrossEmptyLinesAndComments + +# 对齐连续宏定义的风格 +# AlignConsecutiveMacros: Consecutive # clang-format 12 + +# 用于在使用反斜杠换行中对齐反斜杠的选项 +AlignEscapedNewlines: Left + +# 水平对齐二元和三元表达式的操作数 +AlignOperands: Align + +# 对齐连续的尾随的注释 +AlignTrailingComments: true + +# 如果函数调用或带括号的初始化列表不适合全部在一行时 +# 允许将所有参数放到下一行,即使 BinPackArguments 为 false +AllowAllArgumentsOnNextLine: true + +# 允许构造函数的初始化参数放在下一行 +AllowAllConstructorInitializersOnNextLine: true + +# 允许函数声明的所有参数在放在下一行 +AllowAllParametersOfDeclarationOnNextLine: true + +# 允许短的块放在同一行(Always 总是将短块合并成一行,Empty 只合并空块) +AllowShortBlocksOnASingleLine: Empty + +# 允许短的 case 标签放在同一行 +AllowShortCaseLabelsOnASingleLine: true + +# 允许短的函数放在同一行: None, InlineOnly(定义在类中), Empty(空函数), +# Inline(定义在类中,空函数), All +AllowShortFunctionsOnASingleLine: Inline + +# 允许短的 if 语句保持在同一行 +AllowShortIfStatementsOnASingleLine: true + +# 允许短的循环保持在同一行 +AllowShortLoopsOnASingleLine: true + +# 总是在定义返回类型后换行 (deprecated) +AlwaysBreakAfterDefinitionReturnType: None + +# 总是在返回类型后换行: None, All, TopLevel(顶级函数,不包括在类中的函数), +# AllDefinitions(所有的定义,不包括声明), TopLevelDefinitions(所有顶级函数的定义) +AlwaysBreakAfterReturnType: None + +# 总是在多行 string 字面量前换行 +AlwaysBreakBeforeMultilineStrings: false + +# 总是在 template 声明后换行 +AlwaysBreakTemplateDeclarations: false + +# false 表示函数实参要么都在同一行,要么都各自一行 +BinPackArguments: false + +# false 表示所有形参要么都在同一行,要么都各自一行 +BinPackParameters: true + +# 大括号换行,只有当 BreakBeforeBraces 设置为 Custom 时才有效 +BraceWrapping: + AfterCaseLabel: true # case 语句后面 + AfterClass: true # class 定义后面 + AfterControlStatement: Never # 控制语句后面 + AfterEnum: true # enum 定义后面 + AfterFunction: true # 函数定义后面 + AfterNamespace: false # 命名空间定义后面 + AfterObjCDeclaration: false # ObjC 定义后面 + AfterStruct: true # struct 定义后面 + AfterUnion: true # union 定义后面 + AfterExternBlock: false # extern 导出块后面 + BeforeCatch: true # catch 之前 + BeforeElse: true # else 之前 + IndentBraces: false # 缩进大括号(整个大括号框起来的部分都缩进) + SplitEmptyFunction: false # 空函数的大括号是否可以在一行 + SplitEmptyRecord: false # 空记录体(struct/class/union)的大括号是否可以在一行 + SplitEmptyNamespace: false # 空名字空间的大括号是否可以在一行 + +# 在二元运算符前换行: +# None(在操作符后换行), NonAssignment(在非赋值的操作符前换行), All(在操作符前换行) +BreakBeforeBinaryOperators: None + +# 大括号的换行规则 +# Attach / Linux / Mozilla / Stroustrup / Allman / GNU / WebKit / Custom +BreakBeforeBraces: Custom + +# 三元运算操作符换行位置(? 和 : 在新行还是尾部) +BreakBeforeTernaryOperators: true + +# 在构造函数的初始化列表的逗号前换行 +BreakConstructorInitializersBeforeComma: false + +# 要使用的构造函数初始化式样式 +BreakConstructorInitializers: BeforeComma + +# 每行字符的限制,0 表示没有限制 +ColumnLimit: 100 + +# 描述具有特殊意义的注释的正则表达式,它不应该被分割为多行或以其它方式改变 +# CommentPragmas: '' + +# 如果为 true,则连续的名称空间声明将在同一行上 +CompactNamespaces: false + +# 构造函数的初始化列表要么都在同一行,要么都各自一行 +ConstructorInitializerAllOnOneLineOrOnePerLine: false + +# 构造函数的初始化列表的缩进宽度 +ConstructorInitializerIndentWidth: 4 + +# 延续的行的缩进宽度 +ContinuationIndentWidth: 4 + +# 去除 C++11 的列表初始化的大括号 { 后和 } 前的空格 +Cpp11BracedListStyle: true + +# 继承最常用的指针和引用的对齐方式 +DerivePointerAlignment: false + +# 关闭格式化 +DisableFormat: false + +# 自动检测函数的调用和定义是否被格式为每行一个参数 (Experimental) +ExperimentalAutoDetectBinPacking: false + +# 如果为 true,会自动给短名称空间补上结束注释,并修正错误的现有结束注释 +FixNamespaceComments: true + +# 需要被解读为 foreach 循环而不是函数调用的宏 +ForEachMacros: [foreach, Q_FOREACH, BOOST_FOREACH] + +# 对 #include 进行排序,匹配了某正则表达式的 #include 拥有对应优先级 +# 优先级数字越小排序越靠前;可以定义负数优先级,让某些 #include 永远排在最前 +IncludeCategories: + - Regex: '^"(llvm|llvm-c|clang|clang-c)/' + Priority: 2 + - Regex: '^(<|"(gtest|isl|json)/)' + Priority: 3 + - Regex: '.*' + Priority: 1 + +# 缩进 case 标签 +IndentCaseLabels: false + +# 要使用的预处理器指令缩进样式 +IndentPPDirectives: AfterHash + +# 缩进宽度 +IndentWidth: 4 + +# 函数返回类型换行时,缩进函数声明或函数定义的函数名 +IndentWrappedFunctionNames: false + +# 保留在块开始处的空行 +KeepEmptyLinesAtTheStartOfBlocks: true + +# 开始一个块的宏的正则表达式 +MacroBlockBegin: '' +# 结束一个块的宏的正则表达式 +MacroBlockEnd: '' + +# 连续空行的最大数量 +MaxEmptyLinesToKeep: 10 + +# 命名空间的缩进: None, Inner(缩进嵌套的命名空间中的内容), All +# NamespaceIndentation: Inner + +# 使用 ObjC 块时缩进宽度 +ObjCBlockIndentWidth: 4 +# 在 ObjC 的 @property 后添加一个空格 +ObjCSpaceAfterProperty: false +# 在 ObjC 的 protocol 列表前添加一个空格 +ObjCSpaceBeforeProtocolList: true + +# 在 call( 后对函数调用换行的 penalty +PenaltyBreakBeforeFirstCallParameter: 2 +# 在一个注释中引入换行的 penalty +PenaltyBreakComment: 300 +# 第一次在 << 前换行的 penalty +PenaltyBreakFirstLessLess: 120 +# 在一个字符串字面量中引入换行的 penalty +PenaltyBreakString: 1000 +# 对于每个在行字符数限制之外的字符的 penalty +PenaltyExcessCharacter: 1000000 +# 对每一个空格缩进字符的 penalty (相对于前导的非空格列计算) +# PenaltyIndentedWhitespace: 0 +# 将函数的返回类型放到它自己的行的 penalty +PenaltyReturnTypeOnItsOwnLine: 120 + +# 指针和引用的对齐: Left, Right, Middle +PointerAlignment: Left + +# 允许重新排版注释 +ReflowComments: true + +# 允许排序 #include +SortIncludes: true +# 允许排序 using 声明顺序 +SortUsingDeclarations: false + +# 在 C 风格类型转换后添加空格 +SpaceAfterCStyleCast: false +# 在逻辑非操作符 (!) 之后插入一个空格 +SpaceAfterLogicalNot: false +# 在 template 关键字后插入一个空格 +SpaceAfterTemplateKeyword: false + +# 定义在什么情况下在指针限定符之前或之后放置空格 +# SpaceAroundPointerQualifiers: Before + +# 在赋值运算符之前添加空格 +SpaceBeforeAssignmentOperators: true + +# 左圆括号之前添加一个空格: Never, ControlStatements, Always +SpaceBeforeParens: ControlStatements + +# 空格将在基于范围的 for 循环冒号之前被删除 +SpaceBeforeRangeBasedForLoopColon: true + +# [ 前是否添加空格(数组名和 [ 之间,Lambdas 不会受到影响) +# 连续多个 [ 只考虑第一个(嵌套数组、多维数组) +SpaceBeforeSquareBrackets: false + +# 在空的圆括号中添加空格 +SpaceInEmptyParentheses: false + +# 在尾随的评论前添加的空格数 (只适用于 //) +SpacesBeforeTrailingComments: 3 + +# 在尖括号的 < 后和 > 前添加空格 +SpacesInAngles: false + +# 在容器(ObjC 和 JavaScript 的数组和字典等)字面量中添加空格 +SpacesInContainerLiterals: false + +# 在 C 风格类型转换的括号中添加空格 +SpacesInCStyleCastParentheses: false + +# 如果为 true,会在 if/for/switch/while 条件括号前后插入空格 +SpacesInConditionalStatement: false + +# 在圆括号的 ( 后和 ) 前添加空格 +SpacesInParentheses: false + +# 在方括号的 [ 后和 ] 前添加空格,lambda 表达式和未指明大小的数组的声明不受影响 +SpacesInSquareBrackets: false + +# 标准: Cpp03, Cpp11, Auto +Standard: Cpp11 + +# tab 宽度 +TabWidth: 4 + +# 使用 tab 字符: Never, ForIndentation, ForContinuationAndIndentation, Always +UseTab: Never +``` + +## 维护这份配置时建议怎么用 + +- **不要直接拷整份就立刻全量格式化老仓库**,否则历史 diff 会被洗一遍。优先在新模块或新增文件上启用,老代码用「增量格式化」过渡。 +- 如果团队已有一份 `.clang-format`,把这份只当**对照表**用,逐条核对每项的取值差异。 +- 中文注释只是给自己看的备忘,真正的权威定义还是 LLVM 官方文档。 + +## 后续可补的方向 + +- 与 `clang-format.md` 主篇里的「自用 / Microsoft / Google / jemalloc」几份 demo 做差异对比 +- 在 CI 里启用 `clang-format --dry-run --Werror` 的最小配置 +- 与 pre-commit hook 的整合,做到只对改动行格式化 diff --git "a/_posts/build/2026-04-25-gcc \345\244\232\347\211\210\346\234\254.md" "b/_posts/build/2026-04-25-gcc \345\244\232\347\211\210\346\234\254.md" new file mode 100644 index 000000000..4ccab81ba --- /dev/null +++ "b/_posts/build/2026-04-25-gcc \345\244\232\347\211\210\346\234\254.md" @@ -0,0 +1,56 @@ +--- +layout: post +title: Ubuntu 上准备多版本 GCC 的源 +subtitle: 通过修改 apt 源拉取 gcc 7.3 / 7.5 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Ubuntu + - GCC + - apt +--- + +>原始笔记是几行散乱的 apt 源配置,这里把背景和步骤串起来,方便照着改。 + +## 背景 + +老项目偶尔需要指定 GCC 版本(比如 7.3、7.5)来复现编译/链接结果,但默认 Ubuntu 源里未必能直接装到。常见做法是临时换/补一份 apt 源再安装。 + +## 1. 编辑源列表 + +```bash +sudo vim /etc/apt/sources.list +``` + +## 2. 加入需要的源 + +下面两条是笔记里实际用过的镜像,按需选择: + +```text +# gcc 7.3:bionic(18.04)系列 +deb https://mirrors.cloud.tencent.com/ubuntu/ bionic main universe + +# gcc 7.5:focal(20.04)系列 +deb [arch=amd64] http://archive.ubuntu.com/ubuntu focal main universe +``` + +如果加的是非默认镜像,先导入对应的签名 key,否则 `apt update` 会报 NO_PUBKEY: + +```bash +sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 3B4FE6ACC0B21F32 +``` + +## 3. 更新并安装 + +```bash +sudo apt update +sudo apt install gcc-7 g++-7 +``` + +## 后续可补的方向 + +- `update-alternatives` 切换默认 gcc / g++ 版本的标准做法 +- 用 `ppa:ubuntu-toolchain-r/test` 的官方 PPA 装新版 GCC +- 容器 / conda / spack 等隔离方案的对比 diff --git a/_posts/build/2026-04-25-ninja.md b/_posts/build/2026-04-25-ninja.md new file mode 100644 index 000000000..03123b0da --- /dev/null +++ b/_posts/build/2026-04-25-ninja.md @@ -0,0 +1,56 @@ +--- +layout: post +title: Ninja 与 CMake 速记 +subtitle: 安装 Ninja 后,如何确认工程已切到 Ninja 生成器 +date: 2026-04-25 +author: BY +header-img: img/post-bg-unix-linux.jpg +catalog: true +tags: + - Ninja + - CMake + - Linux +--- + +>原始内容主要想记住一件事:装好 Ninja 之后,用 CMake 配置工程时可以直接生成 `.ninja` 构建文件。 + +## 1. 先确认关注点 + +这份笔记不是完整教程,核心只是为了回看时快速想起: + +- 环境:Ubuntu +- 编辑器场景:VSCode + clang +- 目标:让 CMake 使用 Ninja,而不是传统 Makefile + +## 2. 装好 Ninja 后怎么看是否生效 + +原始记录里的关键结论是: + +>安装完成 Ninja 后,直接用 CMake 配置工程,如果配置正确,`build` 目录下会生成 `.ninja` 相关文件。 + +也就是说,回看时最重要的不是背命令,而是记住“**生成结果**”: + +- 如果构建目录里出现 `build.ninja` +- 说明当前工程已经走到了 Ninja 生成器 + +## 3. 这篇记录真正想保留什么 + +当时留下的上下文比较少,但至少可以保留下面三个检查点: + +1. 本机已经安装 Ninja +2. CMake 配置阶段没有退回到别的生成器 +3. `build` 目录里确实生成了 `.ninja` 文件 + +## 4. 参考链接 + +```text +https://zhongpan.tech/2019/06/26/008-cmake-with-ninja/ +``` + +## 5. 环境备注 + +原始笔记里保留的版本信息是: + +```text +cmake 3.24.1 +``` diff --git "a/_posts/build/2026-04-25-so\350\243\201\345\211\252.md" "b/_posts/build/2026-04-25-so\350\243\201\345\211\252.md" new file mode 100644 index 000000000..2e0aad063 --- /dev/null +++ "b/_posts/build/2026-04-25-so\350\243\201\345\211\252.md" @@ -0,0 +1,104 @@ +--- +layout: post +title: so 裁剪笔记整理 +subtitle: 从编译选项到体积分析的最小排查链路 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - ELF + - Linker + - Performance +--- + +>把原始笔记里的编译选项、静态分析和运行时观察点整理成一条更容易复用的体积排查链路。 + +## 先做编译期裁剪 + +最常用的一组组合是: + +```bash +-ffunction-sections -fdata-sections -Wl,--gc-sections +``` + +它们的作用可以这样理解: + +- `-ffunction-sections`:每个函数单独放一个 section +- `-fdata-sections`:每个全局或静态变量单独放一个 section +- `-Wl,--gc-sections`:链接时回收没有被引用的 section + +如果目标是尽量减小最终可执行文件或共享库体积,这通常是第一步。 + +## 用 bloaty 看体积主要花在哪 + +```bash +bloaty -d compileunits -n 0 libmicontinuity.so > 1.txt +``` + +适合回答两个问题: + +1. 哪些编译单元最占体积 +2. 优化应该优先从哪里下手 + +## 用 `size -A` 做静态分析 + +```bash +~/.toolchain/sdk_package_MC01/toolchain/bin/aarch64-openwrt-linux-size -A ./libmicontinuity_sdk.so.1.0.4032716 +``` + +这一步更适合直接看各段大小,例如: + +- `.text` +- `.rodata` +- `.data` +- `.bss` + +## 再看运行时映射 + +如果文件体积和进程占用看起来不一致,再补上运行时观察: + +```bash +cat /proc/39625/status +cat /proc/39625/smaps +``` + +以及: + +```bash +pmap +``` + +`pmap` 能快速帮你看出进程的内存映射布局,适合和 `smaps` 交叉确认。 + +## 导出符号控制 + +如果目标是减小导出符号面,原始笔记里还提到 `version_script.map`: + +```map +extern "c++" { + "class::*"; +}; +``` + +另外可以配合: + +```bash +nm +c++filt +``` + +用途分别是: + +- `nm`:先看当前符号表里到底暴露了什么 +- `c++filt`:把 C++ 符号反解成人能读的名字 + +## 一条整理后的排查顺序 + +更适合回看的顺序是: + +1. 先加 section 级裁剪编译选项 +2. 用 `bloaty` 看体积热点 +3. 用 `size -A` 看段分布 +4. 用 `smaps` / `pmap` 看运行时映射 +5. 最后再考虑缩减导出符号和 ABI 暴露面 diff --git a/_posts/build/2026-04-25-windows-vcpkg.md b/_posts/build/2026-04-25-windows-vcpkg.md new file mode 100644 index 000000000..1a4872e9a --- /dev/null +++ b/_posts/build/2026-04-25-windows-vcpkg.md @@ -0,0 +1,60 @@ +--- +layout: post +title: Windows 下接入 vcpkg 备忘 +subtitle: PATH、toolchain 文件与 Boost 路径的最小记录 +date: 2026-04-25 +author: BY +header-img: img/post-bg-os-metro.jpg +catalog: true +tags: + - Windows + - vcpkg + - CMake +--- + +>原始笔记只记了几条路径,这里把它整理成一份最小接入说明:先让系统能找到 vcpkg,再让 CMake 明确使用它的 toolchain。 + +## 1. 先让命令行能找到 vcpkg + +原始记录保留的是这条思路: + +```text +把 D:\project\vcpkg 加到 PATH +``` + +这样做的目的,是让命令行里能直接调用 `vcpkg`,方便安装或查询包。 + +## 2. 在 CMake 里指定 toolchain 文件 + +如果工程本身走 CMake,原始笔记建议直接指定: + +```cmake +set(CMAKE_TOOLCHAIN_FILE "D:/project/vcpkg/scripts/buildsystems/vcpkg.cmake") +``` + +回看时要记住,这一步的重点不是“把路径写进去”本身,而是: + +- 告诉 CMake 当前工程依赖 vcpkg 提供的工具链配置 +- 让后续 `find_package` 等流程优先走 vcpkg 安装结果 + +## 3. Boost 相关路径记录 + +原始笔记里还单独保留了一个 Windows 目录: + +```text +D:\project\vcpkg\installed\x64-windows +``` + +这更像是当时为了 Boost 或其他库做路径确认时留下的备忘。回看时可优先用它来确认: + +- 目标 triplet 是否是 `x64-windows` +- 依赖是否真的装在预期目录下 +- 工程是不是把库路径指到了别的 triplet + +## 4. 最小排查顺序 + +如果 Windows 工程接不上 vcpkg,可以先按这个顺序看: + +1. `vcpkg` 命令本身能不能执行 +2. `CMAKE_TOOLCHAIN_FILE` 是否指向正确脚本 +3. 依赖库是否真的出现在 `installed/x64-windows` 下 diff --git "a/_posts/build/2026-04-25-\350\257\241\345\274\202\347\232\204O2\351\227\256\351\242\230.md" "b/_posts/build/2026-04-25-\350\257\241\345\274\202\347\232\204O2\351\227\256\351\242\230.md" new file mode 100644 index 000000000..70607af61 --- /dev/null +++ "b/_posts/build/2026-04-25-\350\257\241\345\274\202\347\232\204O2\351\227\256\351\242\230.md" @@ -0,0 +1,60 @@ +--- +layout: post +title: gcc12 + O2 下 protobuf required 字段的诡异问题 +subtitle: 一次"加日志"思路带来的 30 分钟反思 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - GCC + - Protobuf + - 排查 +--- + +>原始笔记是一篇"事故记录 + 心得",这里把"现象 / 思路 / 插曲"分节整理,原文细节都保留。 + +## 当前保留内容 + +### 1. 现象 + +升级到 gcc12 后,编译选项中加上 `-O2`,遇到 protobuf 的 `required` 字段: + +- `mutable_*` 返回的指针不为空; +- 紧跟着调用 `set_*` 之后,字段读出来却**没有值**。 + +### 2. 解决思路 + +按"先复现、后定位"的顺序尝试了两条路: + +1. **镜像回滚到 gcc82**:代码按照 gcc82 编译,发现**仍然不通过**(依赖库一并回滚后也复现不出来),说明问题不仅仅是编译器版本本身。 +2. **对比编译选项差异**:把 gcc82 / gcc12 两套构建用到的编译选项逐项对齐、对比,从中找出真正影响行为的那一项。 + +### 3. 解决方案插曲:大佬让我加日志 + +时间线很有意思,也很值得记下来: + +``` +大佬,让我加日志! + +18:46 大佬让我加日志。 +19:16 我才想明白,为啥让我加日志,怎么加日志?! + +大佬的潜台词: +怀疑是某个字段设置问题导致的,set_* 失败! +所以要"更精细"的日志——按字段一项项打出来,看到底哪个 set 没生效。 + +大佬的一句话 == 我的 30 分钟。 +``` + +![1](https://user-images.githubusercontent.com/8308226/229105186-a9817c1e-89aa-44d5-93a1-48cdb80a8c53.png) + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 最终定位到的根因和具体修复方法(编译选项 / protobuf 版本 / 代码本身) +- 一份 gcc8 → gcc12 升级后值得提前 review 的兼容性 checklist +- "如何加日志"这件事本身的方法论:粒度、字段级 dump、对照实验等 + +当前这篇先当作一个"事故复盘 + 方法论提醒"的占位条目。 diff --git "a/_posts/cpp/2022-09-08-C++\345\274\200\345\217\221\351\201\207\345\210\260\347\232\204\345\235\221.md" "b/_posts/cpp/2022-09-08-C++\345\274\200\345\217\221\351\201\207\345\210\260\347\232\204\345\235\221.md" new file mode 100644 index 000000000..640ae488c --- /dev/null +++ "b/_posts/cpp/2022-09-08-C++\345\274\200\345\217\221\351\201\207\345\210\260\347\232\204\345\235\221.md" @@ -0,0 +1,153 @@ +--- +layout: post +title: C++ 开发遇到的坑 +subtitle: 原始字符串、std::thread 崩溃、链接错误、循环依赖与静态对象初始化顺序 +date: 2022-09-08 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - Linker + - Thread + - Pitfalls +--- + +>原始笔记里把几条 C++ 踩坑记录散乱地堆在一起,这里按「语法 / 线程 / 链接 / 生命周期 / 初始化顺序」分节整理,原文结论尽量保留。 + +参考: + +- + +## 1. 原始字符串字面量 `R"()"` + +```cpp +const char* json = R"({ "key": "value with \"quote\"" })"; +``` + +括号里可以放任意格式的内容,**括号本身是必须的**。 +适合写正则、JSON、SQL 等需要大量转义的字符串字面量。 + +## 2. `std::thread` 构造函数崩溃 + +可能的原因: + +- so 库版本不一致,导致 `std::thread` 实现 ABI 不匹配 +- 同一个 `std::thread` 对象被重复赋值 +- `move` 之后又被重新使用,或者在还没 join 时就被赋新值 + +经验做法: + +- 在赋新线程之前先 `join()` 旧线程 +- **不要在线程自己里 `join()` 自己**,会立即抛异常 + +参考问题: + +## 3. `undefined reference` 链接错误 + +按下面顺序排查通常更快: + +1. 用 `nm` + `c++filt` 看符号是否真的存在、是不是被 mangling 成预期形式: + + ```bash + nm libfoo.a | c++filt + ``` + +2. 是不是 cmake / Makefile 漏链了某个 `.o` / `.a`。 +3. 是不是头文件循环包含 / 编译单元里没看到完整定义。 +4. 模板代码:定义是不是没放在头文件里,或没显式实例化。 + +## 4. placement new 的释放方式 + +`placement new` 在已分配好的内存上构造对象,析构必须**显式调用析构函数**,再释放原始内存: + +```cpp +char* x = (char*)malloc(10 * sizeof(char*)); +new (x) LogData(); +// ... use x ... +x->~LogData(); // 显式调用析构 +free(x); // 释放原始内存 +``` + +不要忘记析构,否则资源不会释放。 + +## 5. 构造 / 析构顺序的「四层境界」 + +>整理自 CSDN 博主 NXGG(CC 4.0 BY-SA): + +总原则一句:**构造与析构的顺序相反。** + +理解上可以分四层: + +1. **类内成员**:成员的构造顺序 = 在类中声明的顺序,与初始化列表里的顺序无关。 +2. **基类与派生类**:先基类再派生类构造,析构顺序相反。 +3. **函数内局部变量**:按声明顺序构造,按相反顺序析构。 +4. **静态变量**: + - **全局静态**:跨编译单元顺序由编译器决定(一种实现是按字母)。如果有依赖关系,按依赖关系决定。 + - **局部静态**:构造时机是「执行流第一次到达定义处」。编译器通常用一个全局静态标志保证「只构造一次」,并把析构函数指针压入一个由 `doexit` 维护的栈,进程结束时按栈相反顺序析构。 + +### 5.1 为什么这是个坑 + +局部静态变量的实际构造顺序取决于运行时执行路径: + +- 多线程 / 消息驱动模型下,路径不可预测 +- 它们分散在代码各处,彼此之间常被忽略而存在隐含依赖 + +### 5.2 应对思路 + +- 尽量**避免使用局部静态变量**,让生命周期由开发者显式控制。 +- 如果一定要用,确保它们之间**互不依赖**,可以按任意顺序构造和析构。 + +## 6. undefined symbol / cpp 文件生成顺序 + +```text +linux 命名空间问题,可能的原因:cmake cpp 文件生成顺序、循环引用? +-Wl,--whole-archive 可能会导致重定义 +``` + +- `--whole-archive` 会把静态库里所有 `.o` 都强制链入,方便注册类工厂等场景,但容易引发同名符号重定义。 +- cmake 里 `target_sources` / 静态库依赖顺序错位时,也会出现「明明声明了却 undefined」。 +- 排查时先用 `nm`、`readelf -s` 确认符号实际是从哪个目标文件来的。 + +## 7. C++ 头文件循环依赖 + +C++ 头文件是历史遗留问题,常见自律守则: + +0. 头文件做好 include guard,例如 `#pragma once`。 +1. 尽量使用前向声明(forward declaration): + - namespace 中的 class 都可以前向声明 + - 模板也可以前向声明,例如: + ```cpp + class Foo; + using FooPtr = std::shared_ptr; + ``` + - 但 nested class 无法前向声明。 +2. 保证每个对外接口的头文件**独立可用**。`class A` 的 cpp 文件第一个 include 应该是 `class A` 自己的头文件。 +3. 头文件 / cpp 文件配对,文件名与类名一致(模板除外)。 +4. include 时使用**从 VCS 根目录开始的绝对路径**,而不是相对当前文件的路径。 +5. include 按一定原则分组(本项目 / 公司库 / 第三方 C++ 库 / C++ 标准库 / 第三方 C 库 / libc),每组内按字母顺序排列。 +6. 做到第 3 点之后,可以用脚本 / Doxygen 自动画出头文件依赖图,循环依赖一目了然。 +7. **不要在头文件里埋雷**:不要修改 struct 默认对齐方式、不要修改编译器优化等级或警告等级。 + +> 这里说的「VCS 根目录」是指代码库(repository)的根目录。从根目录开始用绝对路径,可以保证不同环境下都能正确解析头文件位置。 + +## 8. 单例模式与静态变量初始化顺序 + +参考 ISO/IEC 14882-1998 §3.6.2 *Initialization of nonlocal objects*: + +- **同一个编译单元内**:静态变量初始化顺序就是定义顺序。 +- **跨编译单元**:未定义,具体顺序取决于编译器实现。 + +跨 so 的单例初始化最容易踩坑。常见做法: + +- 把初始化封装成一个接口,让所有依赖方走同一个入口 +- 把相关的静态变量集中到**同一个 so** 内初始化 +- 优先使用动态加载(`dlopen`)+ 显式调用初始化符号的方式,让顺序由调用方掌控 + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/a6ef22eb-7316-4114-ae24-88641fa5c124) + +## 9. 后续可补的方向 + +- 各类「构造 / 析构顺序」实战 case,每条配最小复现 +- 单例 + 共享 so 的几种典型实现对比(Meyers / call_once / dlopen) +- ABI 兼容相关坑:`std::string` SSO、libstdc++ dual ABI、`std::regex` diff --git "a/_posts/cpp/2026-04-25-c++\346\212\200\345\267\247.md" "b/_posts/cpp/2026-04-25-c++\346\212\200\345\267\247.md" new file mode 100644 index 000000000..0c598d90e --- /dev/null +++ "b/_posts/cpp/2026-04-25-c++\346\212\200\345\267\247.md" @@ -0,0 +1,98 @@ +--- +layout: post +title: C++ 常用小技巧三则 +subtitle: 读文件到 string、struct 与 ostream 互转、placement new +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - 技巧 + - 速查 +--- + +>原始笔记是三个并列的代码块(`# 标题 + 代码`),没有上下文。这里按三块用途整理,每段代码本身保持不动。 + +## 当前保留内容 + +### 1. 读文件内容到 std::string(C++11) + +``` +#include +#include + +std::string readFileIntoString2(const std::string& path) { + auto ss = std::ostringstream{}; + std::ifstream input_file(path); + if (!input_file.is_open()) { + LOGGER_DEBUG(gLogApns, + "desc=readFileIntoString2 failed!"); + } + ss << input_file.rdbuf(); + return ss.str(); +} +``` + +### 2. 把 struct 与 ostream / istream 串起来 + +通过重载 `operator<<` / `operator>>`,让自定义结构体可以直接走流式输出/输入;`operator>>` 里使用一个临时 `values` 做"先读全再赋值"的事务式写入。 + +``` +struct dHeader +{ + uint8_t blockID; + uint32_t blockLen; + uint32_t bodyNum; +}; + +std::ostream& operator<<(std::ostream& out, const dHeader& h) +{ + return out << h.blockID << " " << h.blockLen << " " << h.bodyNum; +} + +std::istream& operator>>(std::istream& in, dHeader& h) // non-const h +{ + dHeader values; // use extra instance, for setting result transactionally + bool read_ok = (in >> values.blockID >> values.blockLen >> values.bodyNum); + + if(read_ok /* todo: add here any validation of data in values */) + h = std::move(values); + /* note: this part is only necessary if you add extra validation above + else + in.setstate(std::ios_base::failbit); */ + return in; +} +``` + +### 3. placement new 的标准 4 步 + +预分配缓冲 → 在缓冲上 placement new → 使用对象 → 显式调用析构 → 释放缓冲: + +``` +#include + + void placement_demo() + { + //1. 预分配缓冲 + char * buff = new char [sizeof (Foo) ]; + + //2. 使用 placement new + Foo * pfoo = new (buff) Foo; + + //使用对象 + unsigned int length = pfoo->size(); + pfoo->resize(100, 200); + + //3. 显式调用析构函数 + pfoo->~Foo(); + + //4. 释放预定义的缓冲 + delete [] buff; + } +``` + +## 后续可补的方向 + +- "读文件到 string"补一份 C++17 `std::filesystem` + `std::stringstream` 等价写法 +- placement new 补对齐(`std::aligned_storage` / `alignas`)的注意事项 diff --git "a/_posts/cpp/2026-04-25-c++\350\257\255\346\263\225\351\227\256\351\242\230.md" "b/_posts/cpp/2026-04-25-c++\350\257\255\346\263\225\351\227\256\351\242\230.md" new file mode 100644 index 000000000..fea9f62bc --- /dev/null +++ "b/_posts/cpp/2026-04-25-c++\350\257\255\346\263\225\351\227\256\351\242\230.md" @@ -0,0 +1,95 @@ +--- +layout: post +title: 'C++ lambda 捕获与原始字符串整理' +subtitle: '捕获方式、内存对比、复现程序与 R"(..)" 写法' +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - lambda + - 内存 +--- + +>原始笔记是几条手写式提示加几张截图、一个测试程序,整体偏潦草。这里按"捕获方式 / 内存对比 / 复现程序 / 原始字符串"四块整理,截图和代码保持原样。 + +## 当前保留内容 + +### 1. lambda 捕获方式 + +- `[=]`:捕获作用域内**全部**变量(按值)。 +- `[a = a_]`:只捕获**单个**变量(按值,可重命名)。 +- 部分成员变量需要在 lambda 内修改时,需要使用 `&` 捕获,而不是 `=`。 + +### 2. 内存对比(Windows 下的快照测试) + +在 Windows 上对内存做快照:分别在 `thread` 创建前、lambda 表达式内、`return 0` 三个时间点拍照,观察内存使用情况。 + +结论是:**只捕获单个变量的 lambda,比捕获全部变量的 lambda 内存使用反而更多**(对应下面截图)。 + +#### `[=]` 捕获 + +![BwOgBBIbrm](https://github.com/20083017/20083017.github.io/assets/8308226/d039e8e6-9ac3-4886-a040-5b2dbdff91c6) + +#### `[mmp=mp]` 捕获 + +![5mSmqms8X9](https://github.com/20083017/20083017.github.io/assets/8308226/f61ef4e0-f156-479d-9902-d5f2bdf178d7) + +### 3. 测试程序 + +``` +#include +#include +#include +#include + +int main(int argc, const char *argv[]) { + + std::map mp; + for(int i = 0; i < 10000; ++i) + { + mp.emplace(i,std::to_string(i)); + } + + std::vector arr; + for(int i = 0; i < 10000; ++i) + { + arr.emplace_back(std::to_string(i)); + } + // test1 + std::thread t = std::thread([=](){ + + }); + // // test2 + //std::thread t = std::thread([mmp=mp](){ + + //}); + + if (t.joinable()) + { + t.join(); + } + + + for (int i = 0; i < 100; ++i) + { + std::cout << std::endl; + } +return 0; +} +``` + +### 4. 原始字符串(写 JSON 策略时) + +`R"(...)"` 不需要转义内嵌的双引号,写 JSON 字符串会非常方便: + +``` +std::string policy_json = R"((Your JSON policy here))"; + +``` + +## 后续可补的方向 + +- 把"`[=]` 反而比 `[mp=mp]` 内存少"的现象说清楚(编译器如何决定按值复制的实际范围、未引用捕获是否被优化掉) +- 用更小、可单步跟踪的样例对照 `-O0` / `-O2` 下的差异 diff --git a/_posts/cpp/2026-04-25-folly_helper.md b/_posts/cpp/2026-04-25-folly_helper.md new file mode 100644 index 000000000..b427c4cb5 --- /dev/null +++ b/_posts/cpp/2026-04-25-folly_helper.md @@ -0,0 +1,56 @@ +--- +layout: post +title: folly 阅读笔记:架构相关的几条记录 +subtitle: crc32 / memcpy / 编译入口与待补的 barrier.h +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - folly + - C++ + - Performance +--- + +>原始笔记是几条零散记录,这里只做分节整理,原始信息基本保留。 + +## crc32 的目标平台 + +```text +folly 主要针对 aarch64(Android、Linux)和 x86_64 进行优化。 +ceph 针对 arm、aarch64、x86_64 优化(见 simd.cmake 文件)。 +``` + +阅读时如果想找 SIMD 相关入口,可以从两边的 `simd.cmake` / 各自 arch 目录下手。 + +## memcpy / memset 的实现 + +```text +针对 x86_64 / aarch64 都做了优化: + +- x86_64 下用 memcpy.S,hook 了 memcpy。 +- aarch64 下用 memcpy_select_aarch64.cpp,hook 了 memcpy。 +``` + +## 编译 + +folly 推荐用自带的 `getdeps.py` 拉依赖并构建: + +```bash +python3 ./build/fbcode_builder/getdeps.py \ + --allow-system-packages \ + --scratch-path /home/ubuntu/folly_install \ + build +``` + +`--scratch-path` 指定中间产物和依赖落盘位置,方便清理。 + +## barrier.h + +待补:当时只标了文件名,还没整理细节。后续可以围绕 folly 的同步原语(`Barrier`、`Baton`、`SaturatingSemaphore` 等)一起整理。 + +## 后续可补的方向 + +- folly 中常用 helper(`Function`、`Optional`、`Expected` 等)的速查 +- folly + jemalloc / mimalloc 的搭配建议 +- 在自己工程里以源码方式集成 folly 的最小可行 CMake 配置 diff --git a/_posts/cpp/2026-04-25-hash.md b/_posts/cpp/2026-04-25-hash.md new file mode 100644 index 000000000..272e0eea7 --- /dev/null +++ b/_posts/cpp/2026-04-25-hash.md @@ -0,0 +1,83 @@ +--- +layout: post +title: 常见哈希算法速览 +subtitle: 加密哈希与查找型哈希的分类整理 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Hash + - Algorithm + - Cryptography +--- + +>原始笔记是一段连贯文字,把加密哈希和查找哈希的常见算法都铺开介绍。这里只做分类与排版,把原文的描述切成可以扫读的小节。 + +## 一、加密哈希算法 + +主要应用在三个方向: + +1. 文件校验 +2. 数字签名 +3. 鉴权协议 + +### 1. MD5 + +MD5(Message-Digest Algorithm 5),用于确保信息传输完整一致,是计算机广泛使用的杂凑算法之一,主流编程语言普遍已有 MD5 实现。它将不定长度信息运算为另一固定长度值(128-bit);基本流程是:求余、取余、调整长度、与链接变量循环运算得到结果。MD5 的前身有 MD2、MD3 和 MD4。 + +MD5 一度被广泛应用于安全领域。但在 2004 年王小云教授公布了 MD5、MD4、HAVAL-128、RIPEMD-128 等 Hash 函数的碰撞,使用其技术在数小时内就可以找到 MD5 碰撞,本算法不再适合当前的安全环境。目前 MD5 主要用于错误检查,例如一些 BitTorrent 下载会通过计算 MD5 校验下载到的碎片完整性。 + +### 2. SHA-1 + +SHA-1 曾经在许多安全协议中广为使用,包括 TLS、SSL、PGP、SSH、S/MIME 和 IPsec,曾被视为 MD5 的后继者。它是如今很常见的加密哈希算法,HTTPS 传输和软件签名认证都很喜欢它。但它毕竟是 1995 年由美国国安局(NSA)提出的老技术,已逐渐跟不上时代,被破解的速度越来越快。 + +微软在 2013 年的 Windows 8 系统里就改用了 SHA-2,Google、Mozilla 则宣布 2017 年 1 月 1 日起放弃 SHA-1。在普通民用场合(如校验下载软件),SHA-1 仍然可以继续用,就像早已被淘汰的 MD5 一样。 + +### 3. SHA-2 + +SHA-224、SHA-256、SHA-384 和 SHA-512 并称为 SHA-2。它们没有像 SHA-1 那样接受公众密码社区的详细检验,因此其密码安全性还没有被广泛信任。虽然至今尚未出现对 SHA-2 的有效攻击,但其算法跟 SHA-1 仍然相似,因此有人开始发展其他替代的哈希算法。 + +### 4. SHA-3 + +SHA-3 之前名为 Keccak 算法,是一种加密杂凑算法。由于 MD5 出现成功的破解,以及对 SHA-0 和 SHA-1 出现理论上破解的方法,NIST 认为需要一个与之前算法不同、可替换的加密杂凑算法,也就是现在的 SHA-3。 + +### 5. RIPEMD-160 + +RIPEMD-160 是一个 160 位加密哈希函数,旨在替代 128 位哈希函数 MD4、MD5 和 RIPEMD。它没有输入大小限制;处理速度比 SHA-2 慢,安全性也不如 SHA-256 和 SHA-512。 + +## 二、查找型哈希算法 + +### 1. lookup3 + +Bob Jenkins 在 1997 年发表了《A hash function for hash table lookup》,文章自发表以后在网上有更多扩展内容。文中广泛收录了很多已有的哈希函数,包括他自己的 “lookup2”。2006 年,Bob 发布了 lookup3。它实现了较好的散列均匀分布,但相对耗时;具有两个特性:(1) 抗篡改性,输入参数任何一位的更改都会带来一半以上位的变化;(2) 可逆性,但逆运算非常耗时。 + +### 2. Murmur3 + +murmurhash 是 Austin Appleby 于 2008 年创立的一种非加密哈希算法,适用于基于哈希进行查找的场景。最新版本是 MurmurHash3,支持 32 位、64 位及 128 位值的产生。MurMur 经常用在分布式环境中(如 Hadoop),特点是高效快速,缺点是分布不是很均匀。 + +### 3. FNV-1a + +FNV 又称 Fowler/Noll/Vo,来自 3 位算法设计者的名字(Glenn Fowler、Landon Curt Noll 和 Phong Vo)。FNV 有 3 种:FNV-0(已过时)、FNV-1、FNV-1a,后两者差别极小。FNV-1a 生成的哈希值是无符号整型;bit 数是 2 的 n 次方(32、64、128、256、512、1024),通常 32-bit 就能满足大多数应用。 + +### 4. CityHash + +2011 年 Google 发布 CityHash(由 Geoff Pike 和 Jyrki Alakuijala 编写),其性能好于 MurmurHash。后来 CityHash 被发现容易受到针对算法漏洞的攻击,该漏洞允许多个哈希冲突发生。 + +### 5. SpookyHash + +又是 Bob Jenkins 这位哈希牛人的作品,2011 年发布的新哈希函数性能优于 MurmurHash,但只给出 128 位输出;后续发布的 SpookyHash V2 提供了 64 位输出。 + +### 6. FarmHash + +FarmHash 由 Google 发布,是 CityHash 的后继,继承了许多技巧和技术,并声称从多个方面对 CityHash 做了改进。 + +### 7. xxhash + +xxhash 由 Yann Collet 发表,官网:。性能很好,被很多开源项目使用,是 Bloom Filter 的首选之一。 + +## 后续可补的方向 + +- 实测各算法在不同 key 长度下的吞吐对比 +- 使用场景速查表(密码存储、文件校验、HashMap、Bloom Filter 等分别选哪个) +- 抗碰撞性 / 抗长度扩展攻击的简要说明 diff --git a/_posts/cpp/2026-04-25-priority_queue.md b/_posts/cpp/2026-04-25-priority_queue.md new file mode 100644 index 000000000..bd885b2f1 --- /dev/null +++ b/_posts/cpp/2026-04-25-priority_queue.md @@ -0,0 +1,72 @@ +--- +layout: post +title: 用最小堆实现 Top-K(KthLargest) +subtitle: priority_queue + greater 的固定写法记录 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - STL + - 算法 +--- + +>原始笔记只有一段代码,没有任何上下文。这里把题意、思路、复杂度补上,代码本身保持原样。 + +## 当前保留内容 + +### 1. 题意 & 思路 + +求数据流中第 K 大的元素:维护一个大小为 `k` 的**最小堆**,堆顶就是当前流中第 K 大。 + +- 元素不足 K 个时直接入堆。 +- 满 K 个之后,新值若比堆顶大,则弹出堆顶并入堆;否则忽略。 + +`std::priority_queue` 默认是最大堆,要做最小堆需要把比较器换成 `std::greater`。 + +### 2. 实现 + +``` +//最小堆 +class KthLargest { +private: + std::priority_queue, std::greater> p; + int k; +public: + KthLargest(int k, vector& nums) { + this->k = k; + for(auto item: nums) + { + add(item); + } + } + + int add(int val) { + if(p.size() < k) + { + p.emplace(val); + return p.top(); + } + int t = p.top(); + // std::cout << t << std::endl; + if(val > t) + { + p.pop(); + p.emplace(val); + } + return p.top(); + } +}; +``` + +### 3. 复杂度 + +- 单次 `add`:`O(log k)` +- 空间:`O(k)` + +## 后续可补的方向 + +- 对照"维护最大堆"的反例,说明为什么这里必须是最小堆 +- 用 `multiset` / `nth_element` 的等价实现对比 +- 数据流非常大、k 也很大时的近似算法(如 Count-Min、Reservoir Sampling) diff --git a/_posts/cpp/2026-04-25-protobuf.md b/_posts/cpp/2026-04-25-protobuf.md new file mode 100644 index 000000000..edae771de --- /dev/null +++ b/_posts/cpp/2026-04-25-protobuf.md @@ -0,0 +1,332 @@ +--- +layout: post +title: Protobuf 使用笔记整理 +subtitle: 代码生成、lite 运行时、链接方式与 CMake 接入 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Protobuf + - C++ + - Build +--- + +> 这篇把原始笔记中分散的 protoc 命令、`protobuf-lite` 说明、动/静态链接讨论、自动生成脚本与 CMake 接入都汇总在一起,原始信息基本完整保留,只在排版上做了组织。 + +## 生成 pb.cc / pb.h + +`protoc -I=Proto文件路径 --cpp_out=指定输出.h和.cc的目录 Proto文件`,也可以使用 `protoc -h` 查看更多帮助。 + +格式: + +```bash +protoc -I= --cpp_out=<输出文件路径> +``` + +## fPIC + +```bash +bazel build -c opt --copt '-fPIC' :protobuf_nowkt --enable_bzlmod +``` + +## protobuf-lite + +```text +我在网上查了一下: + +option optimize_for = LITE_RUNTIME; + optimize_for是文件级别的选项,Protocol Buffer定义三种优化级别 SPEED / CODE_SIZE / LITE_RUNTIME。缺省情况下是 SPEED。 + + SPEED: 表示生成的代码运行效率高,但是由此生成的代码编译后会占用更多的空间。 + + CODE_SIZE: 和 SPEED 恰恰相反,代码运行效率较低,但是由此生成的代码编译后会占用更少的空间,通常用于资源有限的平台,如 Mobile。 + + LITE_RUNTIME: 生成的代码执行效率高,同时生成代码编译后的所占用的空间也是非常少。这是以牺牲 Protocol Buffer 提供的反射功能为代价的。 + 因此我们在 C++ 中链接 Protocol Buffer 库时仅需链接 libprotobuf-lite,而非 libprotobuf。 + 在 Java 中仅需包含 protobuf-java-2.4.1-lite.jar,而非 protobuf-java-2.4.1.jar。 + + SPEED 和 LITE_RUNTIME 相比,在于调试级别上,例如 msg.SerializeToString(&str) 在 SPEED 模式下会利用反射机制打印出详细字段和字段值, + 但是 LITE_RUNTIME 则仅仅打印字段值组成的字符串; + + 因此:可以在程序调试阶段使用 SPEED 模式,而上线以后使用提升性能使用 LITE_RUNTIME 模式优化。 +``` + +## 动态库 Or 静态库讨论 + +```text +Protobuf 是 Google 的一个开源项目,它的大部分代码是用 C++ 写的。当别的程序想要使用 protobuf 时, +既可以采用动态链接,也可以采用静态链接。Google 内部主要是采用静态链接为主。 +而在 Linux 的世界里,大部分发行版都把 Protobuf 编译成了动态库。 + +最佳实践 +如果你的 Project 本身是一个动态库,那么你应该避免在它的公开接口中用到任何 protobuf 的符号,并且采用静态链接到 protobuf 的方式。 +同时你应该在 dllmain 中调用 google::protobuf::ShutdownProtobufLibrary() 来清理 protobuf 使用过的内存。 + +如果你的 Project 本身是一个静态库,那么决定权不在你手里,而在最终把你的静态库编译成 PE/ELF 文件的那个人手里。 +但是你需要在你的 build system 中留出接口让他可以告知你这个信息。 + +如果你的 Project 本身是一个动态库,并且你公开接口中用到了 protobuf 的符号,那么你必须动态链接到 protobuf。 +否则当你跨 DLL 传送 protobuf 的对象时,如果这个对象在 A.DLL 中创建,但是在 B.DLL 中被销毁,那么就会导致程序崩溃。 +因为当你采用静态链接到 Protobuf 时,每个 DLL 内部都有一个 protobuf 的副本,并且 protobuf 内部有自己的内存池。 +跨 DLL 传输对象就会导致该对象可能在不属于自己的内存池中被释放。 + +动态链接的注意事项 +首先,不推荐在 Windows 上这么做。因为 protobuf 本身是基于 C++ 的,而 Windows 上 DLL 的导出符号应该都是 C 风格的, +不应含有任何 STL、std::string 这样的东西。如果你一定要这么做,那么你就会收到 C4251 警告。这是一个 level 1 的警告,属于最高严重等级。 + +如果你决定动态链接到 protobuf,并且目标平台是 Windows 操作系统,那么你应该在编译你的 project 的源代码的时候 #define PROTOBUF_USE_DLLS。 +这样链接器才知道应该使用 dllimport 的方式去寻找 protobuf 的符号。Linux 不需要这么做。但是 Linux 需要注意把 code 编译成 PIC 的。 +同时,在 Windows 上需要注意所有代码必须采用动态链接到 CRT,而不能采用静态链接。 +这条适用于 libprotobuf.dll 自身以及它的所有使用者。 + +无论是 Windows 还是 Linux,动态链接带来的另一个问题是:从 .proto 生成的那些 C/C++ 代码可能也需要被编译成动态库共享。 +因为 protobuf 本身有一个 global 的 registry。每个 message type 都需要去那里注册一下,而且不能重复注册。 +所以,假如你在 A.DLL 中定义了某些 message type,那么 B.DLL 就只能从 A.DLL 的 exported 的 DLL interface 中使用这些 message type, +而不能从 proto 文件中重新生成 C/C++ 代码并包含到 B.DLL 里去。并且 B.DLL 也不能私自地去修改、扩展这个 message type。 +据说换成 protobuf-lite 就能避免这个问题,但是 Google 官方并没有对此表态。 + +另外,protobuf 动态库自身不能被 unload 然后 reload。这个限制让我很意外,但是 Google 自己说他们在设计的时候从来没考虑过这样的使用场景。 +不过,在 Linux 上这其实是很常见的事情,GLIB 自身都不支持 unload。 + +糟糕的用例:Tensorflow +首先,tensorflow 作为一个 python 的 plugin,它必须是动态库,不能是静态库。 +Tensorflow 选择了静态链接到 protobuf。 +Tensorflow 想要支持动态加载 plugin。每个 plugin 是一个动态库。 +plugin 本身需要访问 Tensorflow 的接口,而这些接口常常又含有 protobuf 的符号。Tensorflow 会暴露 (provide) libprotobuf 的部分符号。 +如果这个 plugin 需要的符号恰好在 tensorflow 中都能找到,那么很好。但事情并非总是如此, +因为 Tensorflow 它只有一个 partial 的 libprotobuf,它只包含它自己所必须的那部分 protobuf 的 code。 +当这个 plugin 想要的超出了 tensorflow 所能提供的范畴,写 plugin 的人就会尝试把 protobuf 加到 link command 中。 +这样就会变得非常非常危险,程序随时会崩溃。因为它会在两个不同的 protobuf 副本之间传送 protobuf 的对象。 +所以,不要看到 "unresolved external symbol" 就不动脑子地把缺的库加上,有时候这个错误代表的是更深层次的问题。 + +糟糕的用例:cmake +cmake 3.16 做了一个火上浇油的事情:当你使用 find_package(Protobuf) 的时候,你需要提前知道你找到的究竟是动态库还是静态库, +如果是静态库那么你需要设置 Protobuf_USE_STATIC_LIBS 成 OFF,否则在 Windows 上链接会失败。 +请注意:不是 cmake 告诉你它找到的是什么,而是你要主动告诉它,它找到的会是什么。 +``` + +## auto generate 脚本 + +```bash +#!/usr/bin/env bash + +# Run this script to regenerate descriptor.pb.{h,cc} after the protocol +# compiler changes. Since these files are compiled into the protocol compiler +# itself, they cannot be generated automatically by a make rule. "make check" +# will fail if these files do not match what the protocol compiler would +# generate. +ROOT_PATH=$(pwd)/.. +echo "ROOT_PATH is" ${ROOT_PATH} + +echo "Set protoc tool ..." +if [ $# -eq 2 ]; then + PROTOC=${ROOT_PATH}/$1/protoc + echo "PROTOC PATH is " ${PROTOC} + echo "LIB Version is " $2 + # 匹配 cmakelists 中的 THIRD_PROTOBUF_PATH,替换为需要更新的版本 + sed -i "s/set(THIRD_PROTOBUF_PATH \"\${THIRD_PATH}\/protobuf-.*\")/set(THIRD_PROTOBUF_PATH \"\${THIRD_PATH}\/${2}\")/" ${ROOT_PATH}/CMakeLists.txt +else + echo "Please set protoc path exit!" + exit 1 +fi + +declare -a RUNTIME_PROTO_FILES=(\ +${ROOT_PATH}/runtime/services/core/networking/auth/src/auth.proto \ +${ROOT_PATH}/runtime/services/core/networking/proto/networking.proto ) + + +CORE_PROTO_IS_CORRECT=0 +PROCESS_ROUND=1 + +TMP=$(mktemp -d) +echo "tmp is $TMP" +echo "Generating descriptor protos..." +for PROTO_FILE in ${RUNTIME_PROTO_FILES[@]} ; do + echo " runtime proto files is ${PROTO_FILE}" + pathname=$(dirname "$PROTO_FILE") + basename=$(basename "$PROTO_FILE") + $PROTOC -I=$pathname --cpp_out=$TMP $basename +done + +echo "Updating descriptor protos..." + +for PROTO_FILE in "${RUNTIME_PROTO_FILES[@]}"; do + echo " runtime proto files is ${PROTO_FILE}" + filename=$(basename "$PROTO_FILE" .proto) + BASE_NAME="${PROTO_FILE%.*}" + + ! diff -q "${BASE_NAME}.pb.cc" "$TMP/${filename}.pb.cc" >/dev/null && \ + cp "$TMP/${filename}.pb.cc" "${BASE_NAME}.pb.cc" + + if [ "$filename" = "micontinuity_interface" ]; then + ! diff -q "${ROOT_PATH}/idl/ipc/include/micontinuity_interface.pb.h" "$TMP/${filename}.pb.h" >/dev/null && \ + cp "$TMP/${filename}.pb.h" "${ROOT_PATH}/idl/ipc/include/micontinuity_interface.pb.h" + else + ! diff -q "${BASE_NAME}.pb.h" "$TMP/${filename}.pb.h" >/dev/null && \ + cp "$TMP/${filename}.pb.h" "${BASE_NAME}.pb.h" + fi +done + +rm -rf $TMP +echo "Generating descriptor protos done..." + +# 回到 native 目录 +cd .. +``` + +## CMake 接入:基础版 + +```cmake +# Modification of standard 'lyra_protobuf_generate_cpp()' with protobuf-lite support +# Usage: +# lyra_protobuf_generate_cpp( ) +function(lyra_protobuf_generate_cpp TARGET_NAME CPP_OUT_PATH H_OUT_PATH PROTO_PATH) + + file(GLOB_RECURSE PROTO_FILES "${PROTO_PATH}/*.proto") + foreach(FILE ${PROTO_FILES}) + message("FILE is" ${FILE}) + # filename without extention + get_filename_component(FILE_WE ${FILE} NAME_WE) + + if(EXISTS ${H_OUT_PATH}/${FILE_WE}.pb.h) + file(REMOVE ${H_OUT_PATH}/${FILE_WE}.pb.h) + endif() + if(EXISTS ${H_OUT_PATH}/${FILE_WE}.pb.cc) + file(REMOVE ${H_OUT_PATH}/${FILE_WE}.pb.cc) + endif() + + add_custom_command( + OUTPUT ${H_OUT_PATH}/${FILE_WE}.pb.h + OUTPUT ${CPP_OUT_PATH}/${FILE_WE}.pb.cc + COMMAND ${PROTOBUF_PROTOC_EXECUTABLE} --proto_path=${PROTO_PATH} --cpp_out=${CPP_OUT_PATH} ${FILE_WE}.proto + ) + set_source_files_properties(${H_OUT_PATH}/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.cc PROPERTIES GENERATED TRUE) + target_sources(${TARGET_NAME} PRIVATE ${CPP_OUT_PATH}/${FILE_WE}.pb.cc) + endforeach() +endfunction() +``` + +## CMake 接入:GENERIC.CMAKE 完整版(带 temp + diff 复用) + +```cmake +### GENERIC.CMAKE + +# add only ext(.cpp .cxx .cc etc) files in the path +# Usage: +# lyra_aux_source_directory_ex( ) +function(lyra_aux_source_directory_ex SRCS_PATH EXT OUT_SRCS) + file(GLOB SRC_FILES "${SRCS_PATH}/*${EXT}") + list(APPEND ${OUT_SRCS} ${SRC_FILES}) + set(${OUT_SRCS} ${${OUT_SRCS}} PARENT_SCOPE) +endfunction() + +# lyra_protobuf_prepare_proto, remove then copy +# Usage: +# lyra_protobuf_prepare_proto( ) +function(lyra_protobuf_prepare_proto PROTO_SRC_PATH PROTO_PATH) + + file(GLOB PLATFORM_PROTO_FILES "${PROTO_SRC_PATH}/*.proto") + + foreach(PLATFORM_FILE ${PLATFORM_PROTO_FILES}) + # filename without extention + get_filename_component(PLATFORM_FILE_WE ${PLATFORM_FILE} NAME_WE) + message("PLATFORM_FILE is" ${PLATFORM_FILE}) + #TODO 进一步优化,文件存在且内容相同时就不再每次都拷贝 + if(EXISTS ${PROTO_PATH}/${PLATFORM_FILE_WE}.proto) + file(REMOVE ${PROTO_PATH}/${PLATFORM_FILE_WE}.proto) + endif() + file(COPY "${PLATFORM_FILE}" DESTINATION "${PROTO_PATH}") + endforeach() +endfunction() + +# Modification of standard 'lyra_protobuf_generate_cpp()' with protobuf-lite support +# Usage: +# lyra_protobuf_generate_cpp( ) +function(lyra_protobuf_generate_cpp TARGET_NAME CPP_OUT_PATH H_OUT_PATH PROTO_PATH) + + file(GLOB PROTO_SRCS "${PROTO_PATH}/*.pb.cc") + foreach(PROTO_SRC ${PROTO_SRCS}) + if(EXISTS ${PROTO_SRC}) + file(REMOVE ${PROTO_SRC}) + endif() + endforeach() + + file(GLOB PROTO_INCS "${PROTO_PATH}/*.pb.h") + foreach(PROTO_INC ${PROTO_INCS}) + if(EXISTS ${PROTO_INC}) + file(REMOVE ${PROTO_INC}) + endif() + endforeach() + + set(TEMP_DIR ${CPP_OUT_PATH}/temp) + file(MAKE_DIRECTORY ${TEMP_DIR}) + + file(GLOB PROTO_FILES "${PROTO_PATH}/*.proto") + foreach(FILE ${PROTO_FILES}) + message("FILE is" ${FILE}) + # filename without extention + get_filename_component(FILE_WE ${FILE} NAME_WE) + + execute_process( + COMMAND ${PROTOBUF_PROTOC_EXECUTABLE} --proto_path=${PROTO_PATH} --cpp_out=${CPP_OUT_PATH}/temp ${FILE_WE}.proto + ) + + execute_process( + COMMAND sh -c " if [ -f ${CPP_OUT_PATH}/${FILE_WE}.pb.h ];then ! diff -q ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.h >/dev/null && \ + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.h + else + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.h + fi" + ) + + execute_process( + COMMAND sh -c " if [ -f ${CPP_OUT_PATH}/${FILE_WE}.pb.cc ];then + ! diff -q ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.cc ${CPP_OUT_PATH}/${FILE_WE}.pb.cc >/dev/null && \ + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.cc ${CPP_OUT_PATH}/${FILE_WE}.pb.cc + else + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.cc ${CPP_OUT_PATH}/${FILE_WE}.pb.cc + fi" + ) + + set_source_files_properties(${H_OUT_PATH}/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.cc PROPERTIES GENERATED TRUE) + target_sources(${TARGET_NAME} PRIVATE ${CPP_OUT_PATH}/${FILE_WE}.pb.cc) + endforeach() + file(REMOVE_RECURSE ${CPP_OUT_PATH}/temp) +endfunction() +``` + +## Windows / Linux 分支:execute_process 片段 + +```cmake + if(${CMAKE_HOST_SYSTEM_NAME_} STREQUAL "windows") + execute_process( + COMMAND cmd /c " ${PROTOBUF_PROTOC_EXECUTABLE} --proto_path=${PROTO_PATH} --cpp_out=${CPP_OUT_PATH}/temp ${FILE_WE}.proto" + COMMAND cmd /c diff_pb.bat ${CPP_OUT_PATH}\\temp\\${FILE_WE}.pb.h ${CPP_OUT_PATH}\\${FILE_WE}.pb.h WORKING_DIRECTORY ${ROOT_PATH}/tools/cmake + COMMAND cmd /c diff_pb.bat ${CPP_OUT_PATH}\\temp\\${FILE_WE}.pb.cc ${CPP_OUT_PATH}\\${FILE_WE}.pb.cc WORKING_DIRECTORY ${ROOT_PATH}/tools/cmake + ) + else() + #COMMAND sh -c " ${ROOT_PATH}/tools/cmake/diff_pb.sh ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.cc ${CPP_OUT_PATH}/${FILE_WE}.pb.cc WORKING_DIRECTORY ${ROOT_PATH}/tools/cmake" not working + + execute_process( + COMMAND ${PROTOBUF_PROTOC_EXECUTABLE} --proto_path=${PROTO_PATH} --cpp_out=${CPP_OUT_PATH}/temp ${FILE_WE}.proto + ) + + execute_process( + COMMAND sh -c " if [ -f ${CPP_OUT_PATH}/${FILE_WE}.pb.h ];then ! diff -q ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.h >/dev/null && \ + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.h + else + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.h ${CPP_OUT_PATH}/${FILE_WE}.pb.h + fi" + ) + + execute_process( + COMMAND sh -c " if [ -f ${CPP_OUT_PATH}/${FILE_WE}.pb.cc ];then + ! diff -q ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.cc ${CPP_OUT_PATH}/${FILE_WE}.pb.cc >/dev/null && \ + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.cc ${CPP_OUT_PATH}/${FILE_WE}.pb.cc + else + cp ${CPP_OUT_PATH}/temp/${FILE_WE}.pb.cc ${CPP_OUT_PATH}/${FILE_WE}.pb.cc + fi" + ) + endif() +``` diff --git "a/_posts/cpp/2026-04-25-rest_rpc-\351\230\205\350\257\273\347\254\224\350\256\260.md" "b/_posts/cpp/2026-04-25-rest_rpc-\351\230\205\350\257\273\347\254\224\350\256\260.md" new file mode 100644 index 000000000..d2415527f --- /dev/null +++ "b/_posts/cpp/2026-04-25-rest_rpc-\351\230\205\350\257\273\347\254\224\350\256\260.md" @@ -0,0 +1,111 @@ +--- +layout: post +title: rest_rpc 协议与接口阅读笔记 +subtitle: req/res header & body 字段,以及 register_handler / call 用法 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - RPC + - rest_rpc + - 协议 +--- + +>原始笔记结构其实已经比较清晰,但缺少头图、front matter 和小结。这里只做"补全 front matter + 整理小标题层级 + 补一段开头说明",协议字段表、注释和示例代码全部保持原样。 + +07227419a14d2d4ed88aeb5322b851a8 + +## 当前保留内容 + +### 1. 协议总览 + +rest_rpc 是 C++ 下的一个轻量 RPC 库,下面分别是请求 / 响应在 header、body 上的字段定义。所有字段表保持原始记录。 + +#### 1.1 req_header + +```req_header +序号 | 类型 | name | 字节数 +1 | uint8_t | MAGIC_NUM | 0 +2 | uint8_t | req_type | 1 +3 | uint32_t | buffer_size | 2-5 +4 | uint64_t | req_id | 6-13 +5 | uint32_t | func_id | 14-17 +``` + +注释: + +* MAGIC_NUM 魔数,固定值 39 +* req_type ENUM {req_res,sub_pub} +* buffer_size body的大小 +* req_id client发出的第i个请求,i从0开始 +* func_id rpc_name的md5值,md5为一种哈希函数,此处采用hash32;rpc_name为register_handler的第一个参数,全局唯一 + +#### 1.2 req_body + +```req_body +序号 | 类型 | name | 字节数 +1 | uint64_t | req_id | 0 +2 | uint8_t | req_type | 1 +3 | string | content | +5 | uint32_t | func_id | +``` + +注释: + +* req_id client发出的第i个请求,i从0开始 +* req_type ENUM {req_res,sub_pub} +* content client.call 函数除第一个参数以外,其他参数的msgpack序列化的结果 +* func_id rpc_name的md5值,md5为一种哈希函数,此处采用hash32 + +#### 1.3 res_header + +```res_header +序号 | 类型 | name | 字节数 +1 | uint8_t | MAGIC_NUM | 0 +2 | uint8_t | req_type | 1 +3 | uint32_t | buffer_size | 2-5 +4 | uint64_t | req_id | 6-13 +``` + +注释: + +* MAGIC_NUM 魔数,固定值 39 +* req_type ENUM {req_res,sub_pub} +* buffer_size body的大小 +* req_id req_header中的req_id + +#### 1.4 res_body + +```req_body +序号 | 类型 | name | 字节数 +1 | uint64_t | req_id | 0 +2 | uint8_t | req_type | 1 +3 | string | content | +``` + +注释: + +* req_id req_header中的req_id +* req_type ENUM {req_res,sub_pub} +* content 失败: result_code::FAIL, "unknown function: " + get_name_by_key(key) + 成功: result_code::OK, result + +### 2. server.register_handler + +``` +void register_handler(std::string const &name, const Function &f); +``` + +`register_handler` 中第一个参数即为函数注册名 `rpc_name`。 + +### 3. client.call + +``` +auto result = client.call("add", 1, 2); +``` + +## 后续可补的方向 + +- 配上一张完整的 req → res 时序图,把 `req_id` / `func_id` 串起来 +- 补一段 `sub_pub` 模式(`req_type` 的另一种取值)的字段含义和典型用法 diff --git a/_posts/cpp/2026-04-25-seastar.md b/_posts/cpp/2026-04-25-seastar.md new file mode 100644 index 000000000..1d20ecd03 --- /dev/null +++ b/_posts/cpp/2026-04-25-seastar.md @@ -0,0 +1,226 @@ +--- +layout: post +title: Seastar 编译与 DPDK 记录整理 +subtitle: 编译依赖、WSL 限制与 DPDK 运行要点 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Seastar + - DPDK + - WSL +--- + +>把原始记录中的编译依赖、DPDK 配置和 WSL 限制整理成一条更容易复用的排查路径。 + +## 编译阶段先记住的几个问题 + +原始笔记里最核心的背景是: + +- 某些 gcc / clang 版本不太稳定 +- 一些 demo / test 需要裁掉才能先把主线编过 +- `fmt` 和 `gnutls` 版本会直接影响构建是否通过 + +## 依赖安装 + +```bash +sudo ./install-dependencies.sh +``` + +如果系统库版本不够,原始记录里还单独补了一套升级 GnuTLS 的流程。 + +## 升级 GnuTLS 的一套命令 + +```bash +sudo apt install build-essential pkg-config libgmp-dev tar wget libunistring-dev + +cd /tmp +wget https://ftp.gnu.org/gnu/nettle/nettle-3.9.1.tar.gz +tar -xzf nettle-3.9.1.tar.gz +cd nettle-3.9.1 +./configure --prefix=/usr/local --disable-openssl +make -j$(nproc) +sudo make install + +cd /tmp +wget https://www.gnupg.org/ftp/gcrypt/gnutls/v3.8/gnutls-3.8.6.tar.xz +tar -xf gnutls-3.8.6.tar.xz +cd gnutls-3.8.6 + +PKG_CONFIG_PATH="/usr/local/lib/pkgconfig" ./configure \ + --prefix=/usr/local \ + --with-included-libtasn1 \ + --without-p11-kit \ + --disable-doc + +make -j$(nproc) +sudo make install +sudo ldconfig +``` + +## 常用构建命令 + +```bash +sudo ./configure.py --mode=debug --cook=fmt +sudo ninja -C build/debug -j4 +``` + +如果还要导出编译数据库并打开 DPDK: + +```bash +sudo ./configure.py --mode=debug --cook=fmt --compile-commands-json --enable-dpdk +``` + +## TCP demo 测试 + +```bash +./tcp_sctp_server_demo +./tcp_sctp_client_demo --server 127.0.0.1:10000 --conn=2 --test=rxrx +``` + +原始笔记里的经验是:demo 往往需要 root 权限,而且连接数一上去就容易暴露网络栈或环境限制。 + +## DPDK 启动失败时先看什么 + +原始日志里最典型的报错是: + +```text +EAL: FATAL: Cannot get hugepage information. +EAL: Error - exiting with code: 1 +Cause: Cannot init EAL +``` + +这通常先指向两类问题: + +1. hugepage 没配好 +2. 当前环境(尤其是虚拟机 / WSL)并不适合直接按物理机场景跑 DPDK + +## 一个精简的 DPDK 配置文件 + +```ini +[binaries] +c = 'gcc' +cpp = 'g++' +pkgconfig = 'pkg-config' + +[host_machine] +system = 'linux' +cpu_family = 'x86_64' +cpu = 'x86_64' +endian = 'little' + +[options] +enable_drivers = 'net/virtio' +enable_docs = false +enable_kmods = false +enable_tests = false +``` + +## 一个常见的 CMake 配置思路 + +```bash +cmake .. \ + -DCMAKE_BUILD_TYPE=Release \ + -DSEASTAR_CXX_STANDARD=20 \ + -DSeastar_ENABLE_DPDK=ON \ + -DSeastar_DPDK_CONFIG=../dpdk-custom.conf \ + -DSeastar_ENABLE_APPS=OFF \ + -DSeastar_ENABLE_DEMOS=OFF \ + -DSeastar_ENABLE_TESTS=OFF \ + -DSeastar_ENABLE_SHARED=OFF \ + -DSeastar_ENABLE_HWLOC=OFF \ + -DSeastar_ENABLE_ALLOC_FAILURE_INJECTION=OFF \ + -DSeastar_ENABLE_EXCEPTIONS=ON \ + -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \ + -DCMAKE_PREFIX_PATH="/usr/local" +``` + +## hugepage 基本准备 + +```bash +cat /proc/meminfo | grep Huge + +echo 1024 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages +mkdir /mnt/huge +mount -t hugetlbfs nodev /mnt/huge +``` + +## WSL 下的限制 + +原始记录里还提到两类问题: + +- nested virtualization 相关配置 +- 网卡绑定和 `vfio-pci` 在虚拟化环境下常常不可用 + +所以更实际的经验是: + +- 在 WSL 里优先把它当作“验证构建和基础功能”的环境 +- 真正涉及 DPDK、hugepage、网卡绑定时,优先在更接近物理机的环境验证 +- 如果只是要通路打通,可先考虑 `pcap` 这类更容易启动的后端 + +## 按环境分工:本地 VM vs 云上裸金属 / SR-IOV + +不同环境能验证的事情不一样,混在一起跑容易把"环境限制"当成"代码 bug"。一个比较实用的分工是: + +### 本地 VirtualBox / WSL2:编译、单测、pcap 或 net/virtio demo + +定位是"验证业务逻辑能不能编出来、跑起来、行为对不对",不追性能。 + +- 编译 Seastar、跑 `ninja test` 单元测试 +- 业务代码用 posix 网络栈跑 demo,调试逻辑首选 +- 需要走 DPDK 路径时: + - VirtualBox 把网卡设成 paravirtualized,使用 `net/virtio` PMD + - WSL2 没有可绑定的真实网卡,只能用 `net/pcap` 后端。先 `sudo modprobe uio && sudo insmod igb_uio.ko` 装好基础内核模块,然后用 pcap 后端把 native 栈接到一张已有的接口上,例如: + + ```bash + sudo ./build/release/apps/httpd/tcp_httpd \ + --network-stack native \ + --dpdk-pmd pcap \ # 关键:改用 pcap,不走真实 PMD + --dpdk-pmd-args "iface=enp0s3" \ # 指定要劫持的接口名 + --dhcp 0 \ + --host-ipv4-addr 192.168.31.121 \ # 用 ifconfig 中的实际 IP + --netmask-ipv4-addr 255.255.255.0 \ + --collectd 0 + ``` + + 这条命令只用来打通 native 栈的代码路径,**不要用它跑性能数字**——pcap 走的是用户态收包,吞吐和延迟都没有参考价值。 +- DPDK 的 meson 配置里只留 `net/virtio,net/pcap`,不开 `net/ixgbe`、`net/i40e`、`net/mlx5` 这些物理网卡 PMD + +这一档**不要做**的事: + +- 不要绑 `vfio-pci`:VirtualBox 没 IOMMU 透传,WSL2 内核没有 vfio +- 不要看 pps / 延迟数字:virtio-net 的中断和拷贝路径会主导,没参考价值 +- 看到 `EAL: Cannot get hugepage information` 之类先查环境,别当业务 bug + +中间还有一档可选:本地 KVM + VT-d passthrough 把闲置网卡直通给 Guest,能跑通完整 `vfio-pci` 流程,是本地最接近物理机的调试环境。 + +### 云上裸金属 / SR-IOV 实例:性能基准、最终验收 + +定位是"复现真实物理机的 DPDK 路径,跑吞吐 / pps / 尾延迟,发布前验收"。 + +- 选实例先确认 DPDK 支持矩阵(云厂文档一般直接给 PMD 名字): + - AWS 裸金属 `*.metal` 或 ENA 增强网络 → `net/ena` + - Azure Accelerated Networking → `net/netvsc` + `net/failsafe`(双 PMD,热迁移回落到 synthetic) + - GCP gVNIC → `net/gve`(DPDK 22.11+) + - 阿里神龙裸金属 / g7ne、腾讯黑石 → 按 VF 类型选对应 PMD +- 启动参数加 hugepage 和 IOMMU(裸金属需要 `intel_iommu=on iommu=pt`,普通 SR-IOV VM 通常不用) +- 裸金属需要 `dpdk-devbind.py --bind=vfio-pci`,**留至少一张管理网卡给内核**,别把自己踢下线 +- SR-IOV VM 通常不绑 vfio-pci,PMD 直接接管,按云厂文档来 +- DPDK meson 配置打开实际用到的物理 PMD(`net/ena`、`net/mlx5` 等) + +跑基准时容易踩的坑: + +- AWS 等平台要关掉网卡的 source/dest check,否则 DPDK 发的非自身 MAC 流量会被云侧丢掉 +- `--smp` 和网卡 NUMA 节点对齐(看 `/sys/class/net/ethX/device/numa_node`) +- CPU 用 `isolcpus` / `nohz_full` 隔离,避免 reactor stall +- 性能不稳先排查云侧配额限速(如 AWS CloudWatch ENA 的 `bw_*_allowance_exceeded`),不一定是代码问题 + +验收清单:DPDK 启动日志识别到正确网卡、hugepage 实际占用 = 配置量、持续 1 小时压测无丢包无内存泄漏、云侧监控未触顶。 + +## 整理后的排查顺序 + +1. 先解决依赖库版本问题 +2. 再确认 `configure.py` 和编译器版本是否匹配 +3. 如果启用 DPDK,先看 hugepage 和驱动条件 +4. 在 WSL / 虚拟机里,不要默认把 DPDK 失败当成业务代码问题 diff --git a/_posts/cpp/2026-04-25-tricks.md b/_posts/cpp/2026-04-25-tricks.md new file mode 100644 index 000000000..e4996a960 --- /dev/null +++ b/_posts/cpp/2026-04-25-tricks.md @@ -0,0 +1,37 @@ +--- +layout: post +title: 日常使用小技巧 +subtitle: 暂时只记录强制 Chrome 进入 Dark Mode 的命令 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Tricks + - macOS + - Chrome +--- + +>原始笔记只有一条命令,这里整理成可继续补充的最小占位版本。 + +## 当前保留内容 + +### macOS 下强制 Chrome 使用 Dark Mode + +如果系统主题没切换、但希望 Chrome 自身界面以暗色显示,可以在终端里用启动参数打开: + +```bash +open -a Google\ Chrome --args --force-dark-mode +``` + +注意这只是强制 Chrome 自身 UI 走暗色,不会改变网页内容的渲染配色。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 其它常用浏览器/编辑器的暗色模式开关 +- macOS 上常用的 `open` / `defaults` 命令片段 +- Windows / Linux 上对应的小技巧 + +当前这篇先当作一个待扩充的零碎技巧合集占位。 diff --git a/_posts/cpp/2026-04-25-uuid.md b/_posts/cpp/2026-04-25-uuid.md new file mode 100644 index 000000000..35866d735 --- /dev/null +++ b/_posts/cpp/2026-04-25-uuid.md @@ -0,0 +1,49 @@ +--- +layout: post +title: UUID1 时间戳解析 +subtitle: 从 UUID 里反推出生成时间的最小 Python 备忘 +date: 2026-04-25 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Python + - UUID +--- + +>这条笔记主要记录一件事:如果拿到的是 `uuid1`,可以直接从时间字段反推出它的大致生成时间。 + +## 1. 先生成或读取一个 UUID + +```python +import uuid + +u = uuid.uuid1() + +u = uuid.UUID("2c1af3a6b7f511ed80c21554341098f8") +``` + +这里要注意,只有 `uuid1` 这类带时间字段的 UUID,才适合继续往下做时间解析。 + +## 2. 把 UUID 时间字段转换成 datetime + +```python +import datetime + +timestamp = (u.time - 0x01B21DD213814000) / 1e7 +dt = datetime.datetime.fromtimestamp(timestamp) +print(dt) +``` + +核心点只有两个: + +- `u.time` 的单位是 **100ns** +- `0x01B21DD213814000` 是 UUID 时间基准到 Unix 时间基准之间的偏移量 + +换算完成后,就能得到这个 `uuid1` 对应的大致生成时间。 + +## 3. 参考链接 + +```text +https://juejin.cn/post/6923014125652181000#heading-10 +``` diff --git "a/_posts/cpp/2026-04-25-x-class-\345\267\245\345\205\267\347\261\273.md" "b/_posts/cpp/2026-04-25-x-class-\345\267\245\345\205\267\347\261\273.md" new file mode 100644 index 000000000..9f5ec8742 --- /dev/null +++ "b/_posts/cpp/2026-04-25-x-class-\345\267\245\345\205\267\347\261\273.md" @@ -0,0 +1,73 @@ +--- +layout: post +title: hicc::debug::X 调试工具类 +subtitle: 一个用来观察 RVO / 拷贝省略 / 就地构造的埋点小类 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - 调试 + - RVO +--- + +>原始笔记是一段口头描述加一整段代码,结构很平。这里按"用途 / 实现"两块整理,类的实现保持原样。 + +## 当前保留内容 + +### 1. 用途 + +`hicc::debug::X` 是一个专门用来调试 RVO、In-place construction、Copy Elision 等等特性的工具类,它平平无奇,只不过是在若干位置埋点冰打印 stdout 文字而已,这可以让我们直观观察到哪些行为实际上发生了。 + +X-class 在构造函数的入参部分有相似的构造(默认构造、移动构造、拷贝构造,以及对应的 `operator=`),用以区分每条路径被走到的时机。 + +### 2. 实现 + +``` +namespace hicc::debug { + + class X { + std::string _str; + + void _ct(const char *leading) { + printf(" - %s: X[ptr=%p].str: %p, '%s'\n", leading, (void *) this, (void *) _str.c_str(), _str.c_str()); + } + + public: + X() { + _ct("ctor()"); + } + ~X() { + _ct("dtor"); + } + X(std::string &&s) + : _str(std::move(s)) { + _ct("ctor(s)"); + } + X(std::string const &s) + : _str(s) { + _ct("ctor(s(const&))"); + } + X &operator=(std::string &&s) { + _str = std::move(s); + _ct("operator=(&&s)"); + return (*this); + } + X &operator=(std::string const &s) { + _str = s; + _ct("operator=(const&s)"); + return (*this); + } + + const char *c_str() const { return _str.c_str(); } + operator const char *() const { return _str.c_str(); } + }; + +} // namespace hicc::debug +``` + +## 后续可补的方向 + +- 配套一组最小可复现 demo:分别调用按值返回、`emplace`、`std::move` 等,对照 stdout 看走到了哪个 `_ct` 分支 +- 加上 NRVO / pre-C++17 强制 RVO / C++17 复制省略的版本差异说明 diff --git "a/_posts/cpp/2026-04-25-\345\256\217\345\256\232\344\271\211-trick.md" "b/_posts/cpp/2026-04-25-\345\256\217\345\256\232\344\271\211-trick.md" new file mode 100644 index 000000000..e7c8d1990 --- /dev/null +++ "b/_posts/cpp/2026-04-25-\345\256\217\345\256\232\344\271\211-trick.md" @@ -0,0 +1,43 @@ +--- +layout: post +title: C/C++ 宏定义小技巧 +subtitle: 用 MARCO_EXPAND 解决 __VA_ARGS__ 嵌套被吞参数的问题 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - Macro + - Trick +--- + +>原始笔记只有一行宏定义和一张示意图,这里把背景和用法补出来,方便回看。 + +## 问题:`__VA_ARGS__` 在嵌套展开时会被当成单个参数 + +在 MSVC 等部分预处理器实现下,把 `__VA_ARGS__` 直接传给另一个宏时,可能会被整体看作 **一个** 参数,而不是按逗号拆开继续传递,导致下游宏拿到的参数个数和预期不符。 + +## 一个常见 workaround:再套一层展开 + +```cpp +#define MARCO_EXPAND(...) __VA_ARGS__ +``` + +把要传给下游宏的 `__VA_ARGS__` 先经过一次 `MARCO_EXPAND(...)`,强制再展开一次,逗号才会重新被识别为参数分隔符。 + +典型用法形如: + +```cpp +#define CALL(macro, args) MARCO_EXPAND(macro args) +``` + +笔记中附的示意图保留下来: + +image + +## 后续可补的方向 + +- 不同编译器(GCC / Clang / MSVC)对 `__VA_ARGS__` 展开顺序的差异 +- 计数宏 `PP_NARG`、`FOR_EACH` 等常见可变参宏的写法 +- 使用 `BOOST_PP_*` 替代手写宏的场景 diff --git a/_posts/debug/2022-09-12-gdb-cheatsheet.md b/_posts/debug/2022-09-12-gdb-cheatsheet.md new file mode 100644 index 000000000..3d2106347 --- /dev/null +++ b/_posts/debug/2022-09-12-gdb-cheatsheet.md @@ -0,0 +1,198 @@ +--- +layout: post +title: GDB 指令整理 +subtitle: core dump、内存问题、线程栈、so 调试与 .gdbinit 模板 +date: 2022-09-12 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - GDB + - C++ + - Debug + - Linux +--- + +>原始笔记把 core dump、内存越界、线程栈、so 调试和 `.gdbinit` 配置混在一起,这里按使用场景分节整理,原始命令基本保留。 + +## 1. 打开 core dump 文件 + +### 1.1 当前目录无法生成 core 文件 + +可以临时调整 core 文件命名规则: + +```bash +echo core.%e.%p > /proc/sys/kernel/core_pattern +``` + +这只是临时修改,重启后失效。 + +要长期生效,更稳妥的方式是: + +```bash +sudo /sbin/sysctl -w kernel.core_pattern=core.%e.%p +``` + +或者写入 `/etc/sysctl.d/*.conf` 后再 `sysctl --system`。生产环境不建议无脑全局打开 `ulimit -c unlimited`,否则连续崩溃可能写满磁盘。 + +### 1.2 修改 ulimit 后还是 `not a core dump` + +确认两件事: + +1. `ulimit -c unlimited` 已经在当前 shell 生效。 +2. 当前可执行文件**不要放在与 Windows 共享的目录里**(例如 WSL 下挂载的 `/mnt/c/...`)。 + 实践中遇到过这种情况:core 文件大小始终为 0,把可执行文件移到 Linux 原生目录(例如 `/home/user/...`)下再跑,core 文件就能正常生成。 + +## 2. 排查内存越界 / 重叠 / 重复释放 + +适合排查 `double allocate`、智能指针 `make_shared` 误用、字符串容量异常等问题: + +```gdb +# 让 GDB 不裁剪长容器/字符串内容 +set print elements 0 +set print pretty on + +# 启动 gdb +gdb binfile +set args --conf=../conf/**.conf + +# 添加源码搜索路径,否则 list 不会显示源代码 +directory src_code_dir +``` + +`src_code_dir` 既可以是绝对路径,也可以是相对路径,但**要求源文件路径与二进制文件中记录的路径一致**,否则 `list` 找不到源文件。 + +可以先确认二进制里记录的源文件路径是相对还是绝对: + +```bash +readelf -p .debug_str +``` + +设置断点时使用: + +```gdb +b filename:linenum +``` + +观察 `std::string` 内部容量(用于排查 SSO / 容量异常): + +```gdb +print ((size_t*)quit_command._M_dataplus)[-3] +``` + +## 3. 查看线程堆栈 + +把 GDB attach 到运行中的进程并把所有线程堆栈写到日志: + +```bash +gdb -p +``` + +进入 GDB 后: + +```gdb +set logging file mylog.txt +set logging on +thread apply all bt # 输出全部线程堆栈,最准确 +``` + +## 4. 程序 hang 住时 + +不要只盯着 `gdb`,先组合几条更便宜的命令: + +```bash +pstack +strace -p +cat /proc//stack # 当前内核栈 +cat /proc//wchan # 当前等待的内核函数(hang 在哪) +``` + +`strace` 是最后一道依据:能直接看到程序卡在哪个系统调用上。 + +## 5. 进程 / 线程状态 + +```gdb +info threads # 查看所有线程 +thread # 切换到 info threads 列出的某个线程 +``` + +## 6. 查看变量 + +```gdb +info variables # 全局/静态变量;可能很多,慎用 +info locals # 当前 stack frame 的局部变量 +info args # 当前 stack frame 的参数 +``` + +## 7. 调试动态库 (so) 时无法加载源码 + +测试可用的步骤如下: + +```gdb +(gdb) file <你的可执行文件> +(gdb) load <你的 so> # 可选 +(gdb) dir +(gdb) sharedlibrary <你的 so> # 把 so 的符号读进来 +(gdb) break +(gdb) run +``` + +常见原因可以按下面四类排查: + +1. **源代码位置**:用 `dir` 把源码目录加入 GDB 搜索路径。 +2. **so 符号**:用 `sharedlibrary` 显式加载 so 的符号。 +3. **so 加载**:必要时用 `load` 把 so 装入内存。 +4. **编译选项**:so 编译时要加 `-g`,否则 GDB 拿不到调试信息。 + +## 8. `.gdbinit` 常用模板 + +放在 `~/.gdbinit` 中,启动 GDB 时自动生效: + +```gdb +echo \nReading ~/.gdbinit...\n\n + +set print asm-demangle on +set print pretty on +set print object on +# set print static-members on # 打印对象太啰嗦,关掉 +set print static-members off +set print vtbl on +set print demangle on +set demangle-style gnu-v3 +# set demangle-style none + +# 让 emacs / 部分前端能跟上 gdb 的位置 +set annotate 1 + +set history size 9999999 +set history filename ~/.gdbhistory +set history save on + +define gdbkill + kill +end + +define gdbquit + quit +end + +set script-extension soft + +# 调试时如果不希望其它线程同时跑,可以打开下面这条 +# set scheduler-locking on + +# 让 eshell 自己处理分页 +set height 0 + +# 习惯性 alias +alias -a exit = quit + +source ~/etc/gdb/nopify.py +``` + +## 9. 参考 + +- strace 详解: +- 查看被优化掉的变量值: + +更系统的 core dump 排查流程见同目录 `core-dump调试技巧.md`。 diff --git "a/_posts/debug/2026-04-24-Address Sanitizer \347\224\250\346\263\225.md" "b/_posts/debug/2026-04-24-Address Sanitizer \347\224\250\346\263\225.md" new file mode 100644 index 000000000..61eee9686 --- /dev/null +++ "b/_posts/debug/2026-04-24-Address Sanitizer \347\224\250\346\263\225.md" @@ -0,0 +1,80 @@ +--- +layout: post +title: Address Sanitizer 使用笔记 +subtitle: Linux、WSL 与 Android 场景下的快速排查记录 +date: 2026-04-24 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Sanitizer + - C++ + - Android +--- + +>保留原始结论,并把零散备注整理成便于回看的排查清单。 + +## 基本链接方式 + +使用 ASan 时,链接参数里需要带上线程库和 ASan 库,原始记录里特别提到: + +```bash +-pthread -lasan +``` + +`-pthread` 建议放在前面,避免某些环境里链接顺序导致的问题。 + +## LeakSanitizer + +```text +Linux 或虚拟机环境通常可以直接查 leak; +WSL 下泄漏结果不一定稳定,可能看不到预期输出。 +``` + +因此如果主要目的是确认内存泄漏,优先在原生 Linux 环境复现。 + +## AddressSanitizer + +```text +WSL 更适合查 use-after-free、越界访问这类地址问题。 +``` + +也就是说: + +- 查地址非法访问:WSL 可以先快速验证 +- 查内存泄漏:更建议回到原生 Linux + +## Android ASan + +原始记录里的结论是: + +```text +32 位 Android 13 及以前,通常需要配合专门的 ROM 或调试环境; +部分机型本身就不再提供 32 位支持,需要先确认设备能力。 +``` + +整理后建议先确认三件事: + +1. 目标进程是 32 位还是 64 位 +2. 设备系统是否允许加载对应的 sanitizer 运行时 +3. ROM / root / 调试权限是否满足注入要求 + +## Android HWASan + +原始备注: + +```text +64 位 Android 14 及以后更适合走 HWASan; +某些场景不需要 `wrap.sh`,强行带上反而可能导致编译或启动失败。 +``` + +因此实际排查时不要默认照搬旧资料里的 `wrap.sh` 配置,先以当前 NDK、ROM 和构建链路验证为准。 + +## 建议的使用顺序 + +如果只是想尽快定位问题,可以按下面顺序尝试: + +1. Linux / WSL 先复现基础内存问题 +2. Linux 环境确认 leak +3. Android 侧再区分 ASan 还是 HWASan +4. 最后再补设备、ROM、ABI 兼容性验证 diff --git "a/_posts/debug/2026-04-24-core-dump\350\260\203\350\257\225\346\212\200\345\267\247.md" "b/_posts/debug/2026-04-24-core-dump\350\260\203\350\257\225\346\212\200\345\267\247.md" new file mode 100644 index 000000000..528c28459 --- /dev/null +++ "b/_posts/debug/2026-04-24-core-dump\350\260\203\350\257\225\346\212\200\345\267\247.md" @@ -0,0 +1,110 @@ +--- +layout: post +title: core dump 调试技巧 +subtitle: 从开启 core 到用 addr2line 和 gdb 定位崩溃点 +date: 2026-04-24 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Linux + - GDB + - Core Dump +--- + +>开启 core dump 前先确认磁盘空间、权限和落盘目录。生产环境不要无脑全局开启 `unlimited`,否则连续崩溃时可能把磁盘写满。 + +## 先开启 core dump + +当前 shell 临时开启: + +```bash +ulimit -c unlimited +``` + +临时指定 core 文件命名规则: + +```bash +sudo sysctl -w kernel.core_pattern=/tmp/core-%e-%p-%t +``` + +不要直接把 `core_pattern` 指向业务二进制目录或 root 私有目录,除非你已经确认对应服务账户有写权限、目录可清理,而且不会把敏感数据长期留在错误位置。需要持久化时,优先写入 `/etc/sysctl.d/*.conf` 后再 `sysctl --system`。 + +## 方法 1:dmesg + addr2line + +适合先快速确认大致崩溃位置。 + +1. 带调试信息编译程序: + + ```bash + gcc -g -o taogeSeg taogeSeg.c + ``` + +2. 运行后查看内核日志中的崩溃地址: + + ```bash + dmesg | grep taogeSeg + ``` + +3. 用 `addr2line` 把地址转换到源码行号: + + ```bash + addr2line -e taogeSeg 0x080483c9 + ``` + +如果系统开启了 `dmesg_restrict`,普通用户可能看不到完整日志,这时更适合直接拿 core 文件进 `gdb`。 + +## 方法 2:strace + addr2line + +当程序在崩溃前会进行复杂的文件、网络或进程调用时,`strace` 能帮助你看到最后几个系统调用,再配合 `addr2line` 缩小范围。 + +![image](https://user-images.githubusercontent.com/8308226/226785100-2fb3ca2d-a189-4f45-98a3-da96af8dcb15.png) + +## 方法 3:日志 + 二分缩小范围 + +如果没有稳定复现的 core 文件,最实用的仍然是: + +- 补充关键路径日志 +- 对可疑逻辑做二分开关 +- 缩小到最小复现输入 + +很多线上崩溃最终并不是靠“单步调试”解决,而是靠复现条件和日志上下文定位出来的。 + +## 方法 4:直接用 gdb 分析 core + +```bash +gdb /path/to/program /path/to/core-file +``` + +进入 `gdb` 后常用命令: + +```gdb +bt +frame 0 +info locals +info args +thread apply all bt +``` + +如果变量因为编译优化被折叠,可以结合这篇资料排查: + +https://www.qdcto.com/archives/1002#_%E6%9F%A5%E7%9C%8B%E8%A2%AB%E4%BC%98%E5%8C%96%E5%90%8E%E7%9A%84%E5%8F%98%E9%87%8F%E5%80%BC + +## 方法 5:反汇编当前函数 + +在 `gdb` 里查看当前函数的反汇编: + +```gdb +disassemble proc_conn_timeout_limited +disassemble /m proc_conn_timeout_limited +``` + +`/m` 会把源码和汇编混排显示,适合排查优化后的代码路径。 + +## 方法 6:查看寄存器 + +```gdb +info registers +``` + +当崩溃点涉及空指针、非法地址访问或调用约定问题时,寄存器值往往能直接提示是哪一个参数出了问题。 diff --git "a/_posts/debug/2026-04-24-glibc-\345\215\207\347\272\247.md" "b/_posts/debug/2026-04-24-glibc-\345\215\207\347\272\247.md" new file mode 100644 index 000000000..1d7d6becd --- /dev/null +++ "b/_posts/debug/2026-04-24-glibc-\345\215\207\347\272\247.md" @@ -0,0 +1,132 @@ +--- +layout: post +title: glibc 升级记录 +subtitle: 为什么不要直接替换系统 glibc,以及更稳妥的替代方案 +date: 2026-04-24 +author: BY +header-img: img/post-bg-unix-linux.jpg +catalog: true +tags: + - Linux + - glibc + - Deployment +--- + +安装较新的 TensorFlow 或其他预编译程序时,常见报错类似: + +```text +ImportError: /lib64/libc.so.6: version `GLIBC_2.17' not found +``` + +>结论先说:不要为了这个报错,直接在系统里执行 `--prefix=/usr` 的 glibc 安装,更不要手工替换 `/lib64/libc.so.6`。glibc 是系统最核心的运行时库,原地覆盖后,`ls`、`ssh`、`yum`、`systemd` 等基础命令都可能直接失效。 + +前一次整理时,为了避免误导,把不少“特殊处理”和“踩坑背景”也一起删掉了。这次补回来的思路是:**把有价值的上下文尽量保留,但把高风险动作明确标成历史做法或应急场景。** + +## 更优先的解决思路 + +比“原地升级 glibc”更稳妥的选择通常有: + +1. 直接换到更高版本的发行版或容器镜像 +2. 安装与当前系统兼容的软件版本 +3. 把目标 glibc 安装到**独立前缀**,只让特定程序显式使用它 + +如果只是为了跑某个 Python 包或二进制,优先考虑容器或新环境,成本通常比救一台被 glibc 覆盖坏的老机器低得多。 + +## 独立前缀安装 glibc + +下面只是“隔离安装”的思路示例,不是让系统全局切换到新 glibc: + +```bash +cd /usr/local +wget https://ftp.gnu.org/gnu/glibc/glibc-2.17.tar.gz +tar -zxvf glibc-2.17.tar.gz +cd glibc-2.17 + +mkdir build +cd build + +../configure --prefix=/opt/glibc-2.17 +make -j"$(nproc)" +sudo make install +``` + +glibc 必须 out-of-tree 编译,所以需要 `build` 目录,这一点是正常要求。 + +如果你的目标只是解决某个预编译程序的 `GLIBC_2.17 not found`,那这类“单独装一份”的做法通常已经足够;不必一上来就想着把系统 `/usr`、`/lib64` 一起换掉。 + +## 只让指定程序使用新 glibc + +不要修改系统的 `/lib64/libc.so.6`,而是用新安装目录下的 loader 启动目标程序: + +```bash +/opt/glibc-2.17/lib/ld-linux-x86-64.so.2 \ + --library-path /opt/glibc-2.17/lib:/opt/glibc-2.17/lib64 \ + /path/to/your_program +``` + +这样影响范围只在该进程内,出问题也更容易回滚。 + +很多“老系统跑新程序”的场景,本质上只是某一个进程需要更高版本的 glibc,而不是整台机器都要切换运行时。所以这类 loader 启动方式虽然麻烦一点,但通常比系统级替换更可控。 + +## 查看系统支持的 GLIBC 版本 + +```bash +strings /lib64/libc.so.6 | grep GLIBC_ +ldd --version +``` + +这两条命令适合确认当前系统最高支持到哪个符号版本,但它们不是“建议你去替换系统 libc”的前置动作。 + +## 不建议照搬的做法 + +下面这些都是高风险动作: + +- `./configure --prefix=/usr` +- `make install` 直接覆盖系统 glibc +- `rm /lib64/libc.so.6` +- 手工重建 `/lib64/libc.so.6` 软链接当成常规升级步骤 + +这些做法只要出一次错,往往就不是“应用启动失败”,而是整台机器进入半瘫痪状态。 + +## 历史做法(特殊场景记录,默认不推荐) + +之所以把下面这些内容重新写回来,是因为它们确实代表了很多人第一次处理 glibc 版本不匹配时的真实路径。 + +原始思路通常类似这样: + +```bash +mkdir build +cd build +../configure --prefix=/usr +make -j"$(nproc)" +sudo make install +``` + +有的人后面还会继续碰 `/lib64/libc.so.6` 的链接,试图让整个系统“立刻吃到”新版本。 + +这些做法看似直接,甚至在某些一次性环境、临时容器、完全可重建的测试机里也许能跑通;但放到长期使用的服务器上,风险非常高,原因包括: + +- glibc 影响范围不是单个应用,而是几乎所有动态链接程序 +- 失败时不是“某个业务二进制起不来”,而是基础命令都可能跟着崩 +- 一旦 SSH 会话也断掉,恢复成本会急剧上升 + +所以保留这段记录的目的,是帮助理解**为什么后来会出现 coredump、segmentation fault、libc.so.6 丢失**这一串后续问题,而不是把它当推荐方案。 + +## 特殊情况怎么判断是否值得继续折腾 + +如果你确实在评估“要不要让某台老机器继续兼容新程序”,至少先问自己几件事: + +1. 这台机器能不能直接换新系统或换容器? +2. 目标程序是否真的必须依赖更高版本 glibc? +3. 能不能只影响单个进程,而不是整个系统? +4. 机器是否允许出故障后通过控制台、快照、镜像快速回滚? + +如果这些问题里有一项答案偏向“不确定”,通常就不值得继续做系统级原地升级。 + +## 如果已经把系统搞坏了 + +如果你已经因为错误升级 glibc 导致命令无法运行,不要退出当前 SSH 会话;可以参考另一篇救援记录: + +- `glibc-升级coredump或者segmentation fault坑.md` + +那篇内容属于**应急恢复**,不是正常升级步骤。 diff --git "a/_posts/debug/2026-04-24-glibc-\345\215\207\347\272\247coredump\346\210\226\350\200\205segmentation fault\345\235\221.md" "b/_posts/debug/2026-04-24-glibc-\345\215\207\347\272\247coredump\346\210\226\350\200\205segmentation fault\345\235\221.md" new file mode 100644 index 000000000..5891e6a89 --- /dev/null +++ "b/_posts/debug/2026-04-24-glibc-\345\215\207\347\272\247coredump\346\210\226\350\200\205segmentation fault\345\235\221.md" @@ -0,0 +1,73 @@ +--- +layout: post +title: glibc 升级踩坑后的应急恢复 +subtitle: 出现 segmentation fault 或 libc.so.6 缺失时,先别退出 SSH +date: 2026-04-24 +author: BY +header-img: img/post-bg-unix-linux.jpg +catalog: true +tags: + - Linux + - glibc + - Troubleshooting +--- + +系统环境:CentOS 64 位。 + +先把最重要的话放在最前面: + +>千万不要把这篇文章当成“正常升级 glibc 的步骤”。这里记录的是**机器已经被错误升级搞坏之后的应急恢复办法**。如果你现在系统还是健康的,请回到上一篇,优先使用容器、新系统或独立前缀方案。 + +## 典型故障现象 + +错误升级 glibc 后,常见报错包括: + +```text +Segmentation fault +``` + +或者: + +```text +error while loading shared libraries: libc.so.6: cannot open shared object file: No such file or directory +``` + +遇到这类错误时,**不要急着退出当前 SSH 会话**。一旦当前 shell 也断掉,而系统命令已经起不来,恢复会更麻烦。 + +## 应急恢复思路 + +如果系统里仍然存在一份可用的旧版 `libc-*.so`,可以先借助 `LD_PRELOAD` 把命令临时拉起来,再修正 `libc.so.6` 链接: + +```bash +cd /lib64 +LD_PRELOAD=/lib64/libc-2.15.so ln -sf /lib64/libc-2.15.so libc.so.6 +``` + +这里的 `libc-2.15.so` 只是示例,实际要根据机器上还剩哪一个可用版本决定。可以先观察: + +```bash +ls -l /lib64/libc-*.so +``` + +如果有多个候选版本,逐个尝试通常比盲目重启更安全。 + +## 恢复后要做什么 + +软链接修回后,先确认基础命令是否恢复,再检查: + +```bash +ldd --version +strings /lib64/libc.so.6 | grep GLIBC_ +``` + +如果系统恢复了,也不要继续尝试“再升级一次碰碰运气”。更稳妥的做法是: + +- 停止对系统 glibc 的原地替换 +- 改用容器或新系统 +- 或者把目标 glibc 安装到独立前缀,只让特定程序显式使用 + +## 原理简述 + +Linux 加载动态库时,`LD_PRELOAD` 可以让指定 so 文件优先于系统默认搜索路径被加载。因此在系统半损坏但仍保有一份可用 libc 时,`LD_PRELOAD` 常常能帮你把“修链接”的那条命令先执行起来。 + +但这只是应急手段,不是长期运行方案。修完后应尽快把系统恢复到稳定、可维护的状态。 diff --git a/_posts/debug/2026-04-24-hwaddress.md b/_posts/debug/2026-04-24-hwaddress.md new file mode 100644 index 000000000..04b8007a3 --- /dev/null +++ b/_posts/debug/2026-04-24-hwaddress.md @@ -0,0 +1,64 @@ +--- +layout: post +title: 'strdup 崩溃线索与 wrap.sh 切换记录' +subtitle: 'HWASan / ASan 下的排查入口' +date: 2026-04-24 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Android + - HWASan + - Sanitizer +--- + +>把原始笔记里仅剩的两条结论整理出来,避免下次排查时只看到零散命令却想不起上下文。 + +## 1. `strdup` 相关崩溃记录 + +原始备注里保留的是一条与 `strdup` 相关的 crash 线索: + +```text +https://github.com/llvm/llvm-project/issues/5932 +``` + +因此如果在 HWASan / ASan 环境里看到和 `strdup` 接近的异常,可以先把它当作一个排查方向,而不是只盯着业务代码。 + +原始笔记里还给过一个临时替代写法: + +```c +#define MY_STRDUP(s) ({ \ + char *p = malloc(strlen(s) + 1); \ + if (p) strcpy(p, s); \ + p; \ +}) +``` + +这个替代方式的意义只是为了快速验证“问题是否和当前 `strdup` 路径有关”,不代表所有场景都应该长期用宏替换标准库接口。 + +## 2. `wrap.sh` 切换记录 + +原始命令只记了两行: + +```bash +cp hwasan.sh wrap.sh +cp asan.sh wrap.sh +``` + +可以把它理解为一条很短的实验记录:在不同 sanitizer 方案之间切换时,曾通过替换 `wrap.sh` 的内容来控制启动方式。 + +整理后保留的使用提醒是: + +- 如果当前验证目标是 HWASan,就确认 `wrap.sh` 实际对应的是 `hwasan.sh` +- 如果当前验证目标是 ASan,就确认 `wrap.sh` 没有残留 HWASan 的旧配置 +- 遇到启动失败时,优先检查包装脚本和当前构建链路是否一致 + +## 3. 回看这份笔记时优先确认什么 + +这篇记录本身很短,真正有用的是提醒自己先核对下面几项: + +1. 当前设备与 ABI 是否真的支持目标 sanitizer +2. 崩溃点是不是落在 libc / `strdup` 这类公共接口附近 +3. `wrap.sh` 是否和当前准备测试的 sanitizer 类型一致 + +如果需要更完整的背景,可以结合《Address Sanitizer 使用笔记》一起看。 diff --git a/_posts/debug/2026-04-25-cpu100.md b/_posts/debug/2026-04-25-cpu100.md new file mode 100644 index 000000000..f5ae190c1 --- /dev/null +++ b/_posts/debug/2026-04-25-cpu100.md @@ -0,0 +1,52 @@ +--- +layout: post +title: CPU / 内存飙升 100% 排查清单 +subtitle: 把 perf、top、strace、pstack 等常用排查命令归到一处 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 性能分析 + - Linux + - 排查 +--- + +>原始笔记是几条手记式的步骤+一段命令拼接,结构略乱。这里按"现象 / 排查命令 / 案例与脚本"三块整理,命令本身基本保持不动。 + +## 当前保留内容 + +### 1. 现象与初步定位 + +- **CPU 飙升 100%**:先用 `perf` 抓热点函数,确认 CPU 时间消耗在哪段代码上。 +- **内存飙升 100%**:先看 log 里出现频率最高的关键行,按行频反推到函数位置。 + - 一次实际案例:通过 log 行频定位到代码逻辑后,发现 Redis 上存在一个 20M 的大 key。 + +### 2. 常用排查命令 + +| 命令 | 作用 | +| --- | --- | +| `top` | 进程视图,看是哪个进程吃满 CPU / 内存 | +| `top -Hp ` | 进入线程视图,看是哪一根线程吃 CPU | +| `strace -p ` | 观察该进程的内核函数调用情况 | +| `pstack ` | 打印线程当前调用栈 | +| `cat /proc//limits` | 查看进程的 fd 数量及上限(示例:`cat /proc/267/limits`) | + +### 3. 计算各进程拉起以来的平均 CPU 使用率 + +参考: + +``` +uptime=`awk '{print $1}' /proc/uptime` # why is it too slow indocker? +hertz=`zcat /proc/config.gz | grep CONFIG_HZ= |awk -F"=" '{print $2}'` +awk -v uptime=$uptime -v hertz=$hertz -- '{printf("%d\t%s\t%11.3f\n", $1, $2, (100 *($14 + $15) / (hertz * uptime - $22)));}' /proc/*/stat 2> /dev/null | sort -gr -k +3 | head -n 20 +``` + +> 在 Docker 里读 `/proc/uptime` 偶尔会比较慢,原因待补。 + +## 后续可补的方向 + +- 配套各命令的典型输出截图与判读要点 +- `perf` 火焰图的生成脚本与常用筛选条件 +- 内存类问题的排查命令补全(`pmap`、`/proc//status`、`smem` 等) +- 与监控告警(Prometheus / 自研监控)联动的排查 SOP diff --git "a/_posts/debug/2026-04-25-fd\346\263\204\346\274\217\346\216\222\346\237\245.md" "b/_posts/debug/2026-04-25-fd\346\263\204\346\274\217\346\216\222\346\237\245.md" new file mode 100644 index 000000000..1c350966b --- /dev/null +++ "b/_posts/debug/2026-04-25-fd\346\263\204\346\274\217\346\216\222\346\237\245.md" @@ -0,0 +1,63 @@ +--- +layout: post +title: fd 泄漏排查入口 +subtitle: 先用 KOOM 找范围,再按需用 strace 跟打开路径 +date: 2026-04-25 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Linux + - Android + - fd + - Troubleshooting +--- + +>原始笔记只记了两个关键词:`koom` 和 `strace`。这里把它整理成一条最短可执行链路。 + +## 1. 优先工具 + +原始内容是: + +1. `koom` +2. `strace` + +整理后可以把两者分成两个阶段使用。 + +## 2. 第一阶段:先用 KOOM 缩小范围 + +如果当前环境里已经接入 `KOOM` 或类似 fd 监控能力,优先用它回答两个问题: + +1. fd 数量是否真的持续增长 +2. 增长主要集中在哪类对象或哪段业务路径附近 + +它更适合做“先发现问题、先缩小范围”,尤其是在 Android App 或长期运行进程里。 + +## 3. 第二阶段:再用 `strace` 跟系统调用 + +当你已经确认存在 fd 泄漏,或者需要进一步看“是谁打开了但没关”,再上 `strace`。 + +它更适合回答: + +- 哪个线程 / 进程在频繁调用 `open`、`socket`、`accept` +- 某些 fd 有没有对应的 `close` +- 泄漏是否和某个固定文件、目录、socket 路径有关 + +## 4. 一个最小排查思路 + +把原始记录展开后,实际可以按下面顺序回看: + +1. 先确认现象 + 例如进程 fd 数量持续上涨,或已经逼近系统上限。 +2. 用 KOOM 或现有监控判断增长趋势 + 先确认是不是稳定复现,以及大概从哪个功能进入异常区间。 +3. 在可复现场景下补 `strace` + 重点跟 `openat`、`close`、`socket`、`accept` 这类系统调用。 +4. 对照业务动作回放 + 看是否每做一次相同操作,就会新增一批没有释放的 fd。 + +## 5. 回看这篇时的提醒 + +- 监控工具负责“发现和缩小范围” +- `strace` 负责“确认系统调用层面的打开 / 关闭行为” +- 如果线上环境不适合直接 `strace`,优先在可复现的测试环境复刻 diff --git a/_posts/debug/2026-04-25-templight.md b/_posts/debug/2026-04-25-templight.md new file mode 100644 index 000000000..40b6fc45f --- /dev/null +++ b/_posts/debug/2026-04-25-templight.md @@ -0,0 +1,86 @@ +--- +layout: post +title: Templight 使用整理 +subtitle: 编译期模板元编程调试的最小准备记录 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - Clang + - Template +--- + +>原始笔记只记了几条编译命令和一个常见报错,这里整理成“准备环境 + 编译 + 使用提醒”的最小版本。 + +## 它是干什么的 + +Templight 更适合在下面这种场景里使用: + +- 模板实例化特别深 +- 编译时间异常长 +- 你想看清楚到底是哪一段模板元编程把编译器拖慢了 + +## 编译准备 + +原始记录里的路径是: + +1. 下载 `llvm-project` +2. 进入 `clang/tools` 目录 +3. 下载 `templight` 工程 +4. 回到 `llvm-project` 根目录开始配置和编译 + +## 配置命令 + +```bash +cmake -S llvm -B build -DLLVM_ENABLE_PROJECTS=clang -DCMAKE_BUILD_TYPE=Release +``` + +如果要显式指定 clang: + +```bash +cmake -S llvm -B build \ + -DLLVM_ENABLE_PROJECTS=clang \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=/usr/bin/clang \ + -DCMAKE_CXX_COMPILER=/usr/bin/clang++ +``` + +然后进入构建目录: + +```bash +cd build +make clang +``` + +## 一个常见报错 + +原始记录里保留的典型错误是: + +```text +clang: error: unknown argument: '-fno-lifetime-dse' +make[2]: *** [TemplightAction.cpp.o] Error 1 +make[1]: *** [obj.clangTemplight.dir/all] Error 2 +make: *** [all] Error 2 +``` + +这类问题通常说明: + +- 当前编译器版本和工程期望的不一致 +- 某些参数只被特定 clang / gcc 版本支持 +- 混用了系统编译器和自己想要的工具链 + +## 原始经验里最值得保留的一点 + +指定 `ninja` 编译失败后,清理工程,再改回 `make` 重新编译,有时反而更容易过。 + +也就是说,这篇里真正的经验不是“必须用 make”,而是:**先把工具链和参数版本对齐,再决定生成器。** + +## 在线替代入口 + +如果只是想快速看模板推导或做表达式可视化,有时在线工具更省事: + +- + +它不能完全替代 Templight,但很适合先做快速观察。 diff --git "a/_posts/debug/2026-04-25-\346\212\245\350\255\246\346\216\222\346\237\245&&\346\200\247\350\203\275\345\210\206\346\236\220.md" "b/_posts/debug/2026-04-25-\346\212\245\350\255\246\346\216\222\346\237\245&&\346\200\247\350\203\275\345\210\206\346\236\220.md" new file mode 100644 index 000000000..02219af17 --- /dev/null +++ "b/_posts/debug/2026-04-25-\346\212\245\350\255\246\346\216\222\346\237\245&&\346\200\247\350\203\275\345\210\206\346\236\220.md" @@ -0,0 +1,55 @@ +--- +layout: post +title: 报警排查 && 性能分析的常用步骤 +subtitle: 从"是不是单机房"到"模块互相影响"的五步排查清单 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 排查 + - 性能 + - 运维 +--- + +>原始笔记是一份带有真实案例的排查清单,这里把"通用步骤"和"具体案例"分开,原文要点都保留。 + +## 当前保留内容 + +### 一、如何排查定位 + +固定按下面这条主线往下走,能少走很多弯路: + +1. **判断是否单机房故障** + 先看影响范围是不是只局限在某个机房 / 区域,不要一上来就钻到代码里。 +2. **进一步判断是否单实例问题** + 单机房故障再细化:是某一台 / 一组实例的问题,还是机房整体? +3. **排查最近的变更** + 配置变更、上线、依赖升级、数据迁移……越靠近报警时间点的变更越可疑。 +4. **工作负载分析(宿主机 + 实例)** + 重点回答:"**哪些资源受限了?**" + - 单实例视角:Redis / MySQL 的线程池、连接数; + - 集群视角:Redis / MySQL 集群允许的最大连接数; + - 比对历史正常水位,看哪一项被压到了上限。 +5. **宿主机上部署的模块汇总分析** + 多模块共宿主机时,**模块之间会互相影响**:某一模块或某一接口、功能的流量突增,会拖累同机其他模块的资源。 + +### 二、配套的真实案例 + +按上面的步骤走,曾经定位到的一类问题: + +- 排查发现 **Redis 函数耗时变大**,且这部分耗时能汇聚到某一个业务上; +- 仔细看函数处理逻辑:`save` 消息之后存在清理逻辑,会清理过期的 key; +- 推测原因:**历史消息太多**,清理 key 的过程阻塞了 Redis,进而拉长了耗时; +- 再看 QPS:**有群发消息**,导致短时流量突增,进一步放大了上面的影响。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 每一步对应的常用工具和指标面板(监控看板、Top SQL、慢查询、连接数曲线等) +- 单机房 / 单实例故障常见 root cause 分类 +- "变更回滚"的标准操作流程(SOP) +- 多模块共宿主机时的隔离手段(cgroup、QoS、限流降级等) + +当前这篇先当作一个"通用排查 SOP + 案例库"的待扩充占位条目。 diff --git "a/_posts/debug/2026-04-25-\346\216\222\346\237\245\346\265\201\351\207\217\344\270\213\347\272\277.md" "b/_posts/debug/2026-04-25-\346\216\222\346\237\245\346\265\201\351\207\217\344\270\213\347\272\277.md" new file mode 100644 index 000000000..d9f83035e --- /dev/null +++ "b/_posts/debug/2026-04-25-\346\216\222\346\237\245\346\265\201\351\207\217\344\270\213\347\272\277.md" @@ -0,0 +1,68 @@ +--- +layout: post +title: 流量下线排查清单 +subtitle: 从重启服务到按 logid 和时间戳追链路的最小步骤 +date: 2026-04-25 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Troubleshooting + - Network + - Log +--- + +>原始内容只有 4 条排查动作,这里把它整理成一条更容易回看的最小链路。 + +## 1. 先做一次受控重启 + +第一步先重启相关服务,主要不是为了“碰运气恢复”,而是为了减少历史长连接干扰判断。 + +这一步想解决的问题是: + +- 是否有旧连接长期不释放 +- 当前现象是不是被历史状态放大了 + +## 2. 先从日志确认请求有没有进来 + +原始记录里写的是“排查 log 调用接口”,回看时可以理解成: + +- 先确认流量是否真的到达入口 +- 先找到对应接口、模块或网关层的调用日志 + +如果这一步就没有日志,后面的排查重点就更偏网络入口、路由或上游调用侧。 + +## 3. 用 `netstat` 看连接来源 + +```bash +netstat -antp +``` + +原始笔记里强调的是“查看 `src ip`”。 +所以这一步的核心是确认: + +- 连接是否真的建立 +- 来源 IP 是否符合预期 +- 是否存在异常来源或连接模式 + +## 4. 用唯一标识串起全链路 + +原始记录里保留了两个关键字段: + +- `logid` +- `timestamp` + +真正有价值的是这个排查思路: + +1. 先拿到一次确定有问题的请求标识 +2. 再按 `logid`、时间戳去查相关模块 +3. 对比链路在哪一段中断、超时或被丢弃 + +## 5. 最小回看版步骤 + +以后再遇到“流量像是下线了”的问题,可以先按下面顺序做: + +1. 重启服务,去掉历史长连接干扰 +2. 查入口日志,确认请求是否到达 +3. 用 `netstat -antp` 看连接与来源 IP +4. 用 `logid` 和时间戳串联各模块日志 diff --git "a/_posts/debug/2026-04-25-\350\260\203\350\257\225\344\271\235\346\263\225\346\200\273\347\273\223.md" "b/_posts/debug/2026-04-25-\350\260\203\350\257\225\344\271\235\346\263\225\346\200\273\347\273\223.md" new file mode 100644 index 000000000..a3e85ab85 --- /dev/null +++ "b/_posts/debug/2026-04-25-\350\260\203\350\257\225\344\271\235\346\263\225\346\200\273\347\273\223.md" @@ -0,0 +1,97 @@ +--- +layout: post +title: 《调试九法》读书总结 +subtitle: 把九条规则整理成一份对照清单,便于以后回看 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 读书笔记 + - 调试 + - 方法论 +--- + +>原始笔记是一段连贯的读书随笔,结构稍乱(小标题列了一遍后又重新展开)。这里删掉重复的目录式列表,按"开篇 / 九条规则 / 总结"组织,原文要点保持不动。 + +## 当前保留内容 + +### 开篇 + +最近两周读完了《调试九法》,顺带认真想了一下"调试"究竟是什么—— + +- 对程序员来讲,调试就是 Debug,消除代码中的 Bug。 +- 对硬件工程师来讲,调试就是找出硬件出错的原因。 +- 抽象来讲,调试就是**找出问题的原因,并研究如何去解决**。 + +《调试九法》通过作者亲历的多种案例,逐一说明如何解决问题;按下面九条规则去做,往往能很快找到方法。 + +### 规则一 理解系统 + +遇到问题时,**先判断自己使用工具的方式是不是正确,是否符合预期**。 + +很多简单代码跑出非预期结果,最后发现只是函数调用方式不对,或者参数类型不对——先回去翻一下相关说明文档,往往就能找到答案。 + +### 规则二 制造失败 + +衡量 Bug 的一个指标是**复现率**: + +- 复现率 100% 的 Bug 容易定位、容易观察、容易调试。 +- 复现率低或周期性出现的 Bug,要想办法让它**更容易复现**。 + +注意:**不要"模拟失败"**——模拟环境不一定等价于 Bug 现场,要尽量在现场用工具观察。 + +### 规则三 不要想,而要看 + +调试时**多观察,少猜测**。复现 Bug 后,用 log、GDB 等工具认真去看;单纯靠猜并改代码,往往精疲力尽却毫无头绪。 + +这一条还提到**插装**思路:用各种外部组件,在不影响程序内部运行的前提下,观察其内部状态。 + +### 规则四 分而治之 + +类似二分查找: + +- 可疑区域很广时,用方法逐步缩小可疑区间。 +- Bug 涉及多个分支时,从根节点开始逐一确认,逐步定位到"叶子节点"。 + +### 规则五 一次只改一个地方 + +类似对照实验——对照条件只能有一个,否则无法确定哪个变量带来了变化。 + +如果怀疑两段代码都有问题,**不要同时改**,逐一修改、逐一观察对最终结果的影响,每次修改都要与正常结果对比。 + +### 规则六 保持审计跟踪 + +操作复杂时,一定要用某种方式记录修改结果——经历多轮修改后人很容易忘记自己做过什么。代码版本控制工具就是为此而生:新功能上线后若出现问题,可以方便地回滚到上一个版本,并对比新提交。 + +作者还提到一点:让不熟悉系统的人使用一个工具时,**用尽可能详细的语言描述用法**;这样作为系统开发者也更容易定位问题。 + +### 规则七 检查插头 + +引入新组件后,**在调用代码块里加上异常捕捉逻辑**。复杂组件本质上是黑箱,无法研究内部逻辑,最简单的应对就是封装这个组件、考虑所有可能的异常输出并处理。 + +### 规则八 获得全新观点 + +碰到难题时**多虚心向他人求助**——别人也许已经踩过无数次坑。一个人硬扛,往往浪费精力;站在巨人肩膀上才能看得更高。 + +求助时要**客观陈述事实**,不要带入自己的猜测,否则容易把别人带偏。类比看病:要细说症状,不要先下"我可能感冒了"的结论。 + +### 规则九 如果你不修复 bug,它将依然存在 + +这一条是劝我们**尽可能真的去解决 Bug**。先确认 Bug 是不是真的解决了: + +- 添加解决方法 → Bug 消失 +- 移除解决方法 → Bug 复现 +- 再次添加解决方法 → Bug 消失 + +很多 Bug 会随时间淡出视野,但作为 Bug 的制造者,错误的思维会留在大脑里,下一次极有可能在另一个系统里复现。重要的是**纠正自己错误的想法、找到真正的解决办法**。 + +### 整体总结 + +《调试九法》的确是本好书,里面的思维抽象出来可以延伸到任何问题的解决思路。后续要认真领悟,并尝试把这九个原则用到日常的每个问题上。 + +## 后续可补的方向 + +- 每条规则配 1~2 个自己的实战案例(线上事故 / 偶现 Bug 等) +- 在团队内部把九法落到具体流程:oncall checklist、事故复盘模板 +- 与 SRE 的"ABC——Always Be Curious"、Google 事故复盘文化做对照 diff --git a/_posts/devops/2022-09-07-spark-cheatsheet.md b/_posts/devops/2022-09-07-spark-cheatsheet.md new file mode 100644 index 000000000..7ac168bdf --- /dev/null +++ b/_posts/devops/2022-09-07-spark-cheatsheet.md @@ -0,0 +1,64 @@ +--- +layout: post +title: Spark / Spark SQL 常用命令速查 +subtitle: 正则提取、过滤聚合与官方文档入口整理 +date: 2022-09-07 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Spark + - SQL + - Cheat Sheet +--- + +>原始笔记是几条零散的 Spark / Spark SQL 命令,这里按用途分节整理成速查版本。 + +## 1. 正则提取字段:`regexp_extract` + +从一段日志里抽出某个数字字段的常见写法: + +```sql +CAST(regexp_extract(message, '(.*)(online_push=)([0-9]+)(.*)', 3) AS INT) +``` + +要点: + +- 第三个参数 `3` 表示取第 3 个捕获组,对应 `([0-9]+)` +- 正则里如果要匹配特殊字符,记得加 `\` +- 抽出来后通常还要 `CAST` 成目标类型再聚合 + +## 2. 常用过滤与聚合:`filter` / `groupBy` + +DataFrame 上常用片段: + +```scala +// 按列求和 +dfs.select(sum("online_push")).show() + +// 按某列分组计数 +dfs_ts_pc.groupBy("ts_pc_type").count.show() + +// 先按条件过滤,再分组计数 +dfs_rt.filter($"msg_type" === 4).groupBy("cli_platform").count.show() +dfs_rt.filter($"msg_type" > 4).groupBy("cli_platform").count.show() +``` + +`===` 是 Spark Column 的等值比较;`>`、`<` 等也都重载在 Column 上。 + +## 3. 文档入口 + +需要查 join 或字段类型时,直接跳官方文档更稳: + +- Join 类型: +- DataType: + +原始笔记里两处都指向 DataType 页,这里把 join 改回到对应入口。 + +## 后续可补的方向 + +- 常用 window function(`row_number`、`rank`、`lag`) +- 几种 join(broadcast / sort-merge / shuffle hash)的选择经验 +- 在线上跑 Spark SQL 时的资源参数模板(`executor` / `memory` / `partitions`) + +当前这篇先按速查表用,后续再补查询调优相关内容。 diff --git a/_posts/devops/2023-05-03-wsl_ubuntu_upgrade.md b/_posts/devops/2023-05-03-wsl_ubuntu_upgrade.md new file mode 100644 index 000000000..79147d5e2 --- /dev/null +++ b/_posts/devops/2023-05-03-wsl_ubuntu_upgrade.md @@ -0,0 +1,326 @@ +--- +layout: post +title: WSL Ubuntu 升级与配置小记 +subtitle: 虚拟化开关、do-release-upgrade 报错、桥接模式、wsl 资源与 perf 编译脚本 +date: 2023-05-03 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - WSL + - Ubuntu + - Windows + - Linux Kernel +--- + +>原始笔记把 WSL 升级踩到的坑、网络配置和 perf 编译脚本堆在一起,这里按「升级 / 配置 / 工具」三块整理,原始命令尽量保留。 + +## 1. Hyper-V / 虚拟化开关 + +WSL2 依赖 Windows Hypervisor,必要时可以临时关闭虚拟化(例如调试 VirtualBox 等三方虚拟机): + +```powershell +# 打开 +bcdedit /set hypervisorlaunchtype auto + +# 关闭 +bcdedit /set hypervisorlaunchtype off +``` + +切换后需要重启 Windows 才会生效。关闭后 WSL2 也会失效,记得用完再切回 `auto`。 + +## 2. `do-release-upgrade` 常见报错 + +### 2.1 `Authentication failed` + +```text +authenticate 'focal.tar.gz' against 'focal.tar.gz.gpg' +Authentication failed +``` + +通常是系统密钥损坏,重新安装一次即可: + +```bash +sudo apt install --reinstall ubuntu-keyring +``` + +如果是 DNS 问题(拉不到 keyring 列表),先确认 `/etc/resolv.conf` 配置正确。 + +### 2.2 升级直接 `Restoring original system state. Aborting` + +```text +Hit http://archive.ubuntu.com/ubuntu bionic InRelease +... +[LONG PAUSE] +Restoring original system state +Aborting +``` + +WSL2 上从 18.04 升到 20.04 时,最常见原因之一是 `snapd`。原始笔记里保留的解法是先把 snap 清理掉,再 `do-release-upgrade`: + +```bash +sudo apt-get purge snapd +``` + +参考: + +### 2.3 升级后 OpenMPI 报 `update-alternatives` 错误 + +```text +update-alternatives: error: /var/lib/dpkg/alternatives/mpi corrupt: + slave link same as main link /usr/bin/mpicc +``` + +清理掉 mpi 的 alternatives 后重装: + +```bash +sudo rm -f /etc/alternatives/mpi* /var/lib/dpkg/alternatives/mpi* +sudo apt install open-mpi +``` + +### 2.4 Ubuntu 22.04 上 `Failed to retrieve available kernel versions` + +`needrestart` 在 WSL2 下检测内核 / microcode 升级会失败,关掉对应提示即可: + +```bash +sudo vim /etc/needrestart/needrestart.conf + +# 取消注释并改为: +$nrconf{kernelhints} = 0; +$nrconf{ucodehints} = 0; +``` + +## 3. 一些常用 WSL 配置 + +### 3.1 设置默认编辑器 + +```bash +sudo update-alternatives --config editor # 选 vim 或其它 +git config --global core.editor vim +``` + +### 3.2 关闭 dash(让 sh 指向 bash) + +```bash +sudo dpkg-reconfigure dash +# 选 No 即可 +``` + +### 3.3 桥接模式(Windows 11+) + +WSL2 桥接模式需要 Windows 11 或更高版本。 + +`%UserProfile%\.wslconfig`: + +```ini +[wsl2] +networkingMode=bridged +vmSwitch=wsl +``` + +Windows 侧可以用一份 PowerShell 脚本自动准备好桥接环境(要求以管理员身份执行): + +```powershell +# 检查并以管理员身份重新启动 PowerShell +$currentWi = [Security.Principal.WindowsIdentity]::GetCurrent() +$currentWp = [Security.Principal.WindowsPrincipal]$currentWi +if (-not $currentWp.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + $boundPara = ($MyInvocation.BoundParameters.Keys | + ForEach-Object { '-{0} {1}' -f $_, $MyInvocation.BoundParameters[$_] }) -join ' ' + $currentFile = $MyInvocation.MyCommand.Definition + $fullPara = $boundPara + ' ' + ($args -join ' ') + Start-Process "$psHome\pwsh.exe" -ArgumentList "$currentFile $fullPara" -Verb runas + return +} + +# 先随意执行一条 wsl 指令,确保 WSL 启动,否则不会出现 WSL 网络 +Write-Output "正在检测 WSL 运行状态..." +wsl --cd ~ -e ls + +Write-Output "正在获取网卡信息..." +Get-NetAdapter + +Write-Output "`n正在将 WSL 网络桥接到以太网..." +Set-VMSwitch WSL -NetAdapterName wsl + +Write-Output "`n正在修改 WSL 网络配置..." +wsl --cd ~ -e sh -c ./set_eth0.sh + +Write-Output "`ndone" +pause +``` + +### 3.4 限制 WSL2 可用内存 + +`%UserProfile%\.wslconfig`: + +```ini +[wsl2] +memory=2GB +swap=4GB +localhostForwarding=true +``` + +### 3.5 hosts 文件托管 + +如果不想让 WSL 自动覆盖 `/etc/hosts`: + +```ini +# /etc/wsl.conf +[network] +generateHosts = false +``` + +### 3.6 WSL ip 静态配置(备份用) + +实际很少用到,按需启用: + +```bash +sudo ip addr del $(ip addr show eth0 | grep 'inet\b' | awk '{print $2}' | head -n 1) dev eth0 +sudo ip addr add 192.168.31.164/24 broadcast 192.168.31.255 dev eth0 +sudo ip route add 0.0.0.0/0 via 192.168.31.1 dev eth0 +``` + +### 3.7 WSL 与 Windows 时钟不同步 + +```bash +# 让系统使用 UTC,并以 UTC 重置硬件时钟 +sudo timedatectl set-local-rtc 0 --adjust-system-clock +``` + +也可以把校时脚本挂到 `~/.bashrc`: + +```bash +echo "source ~/update_time.sh" >> ~/.bashrc +``` + +`update_time.sh`: + +```bash +#!/bin/bash +sudo timedatectl set-local-rtc 0 --adjust-system-clock +``` + +参考: + +## 4. 在 WSL2 上编译 `perf` + +WSL2 自带的 `perf` 经常没有,或与当前内核版本对不上。下面这份脚本会自动: + +1. 解析当前内核版本 +2. 拉 `microsoft/WSL2-Linux-Kernel` 仓库 +3. 找到匹配 / 相近的 tag +4. 编译 `tools/perf` 并安装到 `/usr/local/bin/perf` + +`build-perf-wsl2.sh`: + +```bash +#!/bin/bash +# 自动化编译适用于当前 WSL2 内核的 perf 工具 +# 使用方式:chmod +x build-perf-wsl2.sh && sudo ./build-perf-wsl2.sh + +set -euo pipefail + +REPO_URL="https://github.com/microsoft/WSL2-Linux-Kernel.git" +SRC_DIR="/tmp/WSL2-Linux-Kernel-perf" +PERF_BIN="/usr/local/bin/perf" + +echo "🔍 正在获取当前内核版本..." +KERNEL_FULL=$(uname -r) +echo "VMLINUX: $KERNEL_FULL" + +# 提取版本号,支持下面两种格式: +# 5.15.167.4-microsoft-standard-WSL2 → 5.15.167.4 +# 5.15.167.4 → 5.15.167.4 +KERNEL_VERSION=$(echo "$KERNEL_FULL" | grep -oE '^[0-9]+(\.[0-9]+){3}') + +if [ -z "$KERNEL_VERSION" ]; then + echo "❌ 无法从 '$KERNEL_FULL' 解析出版本号。" + echo " 支持格式如:5.15.167.4 或 5.15.167.4-microsoft-standard-WSL2" + exit 1 +fi + +echo "VMLINUX_VERSION: $KERNEL_VERSION" + +TAG_EXACT="linux-msft-wsl-$KERNEL_VERSION" +TAG_V="v$KERNEL_VERSION" + +echo "📁 准备源码目录: $SRC_DIR" +rm -rf "$SRC_DIR" +mkdir -p "$SRC_DIR" +cd "$SRC_DIR" + +echo "🌀 克隆 WSL2 内核源码仓库..." +git clone "$REPO_URL" . +echo "✅ 仓库克隆完成" + +echo "📥 正在拉取所有远程 tags..." +git fetch origin --tags --quiet +echo "✅ Tags 拉取完成" + +# 查找匹配的 tag +MATCHED_TAG="" +if git show-ref -t --verify "refs/tags/$TAG_EXACT" > /dev/null 2>&1; then + MATCHED_TAG="$TAG_EXACT" +elif git show-ref -t --verify "refs/tags/$TAG_V" > /dev/null 2>&1; then + MATCHED_TAG="$TAG_V" +else + # 模糊匹配:找最接近的版本(比如 5.15.167.x) + BASE_VERSION=$(echo "$KERNEL_VERSION" | cut -d. -f1-3) + CANDIDATES=$(git tag -l "linux-msft-wsl-$BASE_VERSION.*" | sort -V | tail -n 5) + if [ -n "$CANDIDATES" ]; then + echo "🟡 未找到精确匹配,尝试使用相近版本:" + echo "$CANDIDATES" + MATCHED_TAG=$(echo "$CANDIDATES" | tail -n 1) + fi +fi + +if [ -z "$MATCHED_TAG" ]; then + echo "❌ 未找到匹配的 tag (尝试过: $TAG_EXACT, $TAG_V)" + echo " 请检查 https://github.com/microsoft/WSL2-Linux-Kernel/tags" + exit 1 +fi + +echo "🎯 匹配到 tag: $MATCHED_TAG" +git checkout "$MATCHED_TAG" || { echo "❌ 切换 tag 失败"; exit 1; } + +BRANCH_NAME="perf-build-$KERNEL_VERSION" +git switch -c "$BRANCH_NAME" +echo "✅ 已创建并切换到分支: $BRANCH_NAME" + +echo "🛠️ 开始编译 perf..." +cd tools/perf + +if ! command -v libelf-dev &> /dev/null; then + echo "📦 安装编译依赖..." + apt-get update && apt-get install -y \ + libelf-dev \ + libdw-dev \ + binutils-dev \ + gcc \ + make \ + pkg-config +fi + +echo "⚙️ 执行 make..." +make -j"$(nproc)" +echo "✅ 编译完成" + +echo "🚚 安装 perf 到 $PERF_BIN" +sudo cp perf "$PERF_BIN" +sudo chmod +x "$PERF_BIN" + +echo "🎉 成功!perf 已安装" +"$PERF_BIN" --version + +echo "✅ 验证: perf stat echo test" +"$PERF_BIN" stat echo "perf 已就绪" + +echo "💡 使用方法: perf stat , perf record , 等" +``` + +## 5. 后续可补的方向 + +- WSL2 与 systemd 在新版 Ubuntu 上的集成方式 +- 在 WSL2 中跑 docker / k3s 的网络抓包配合 +- `wsl --export` / `--import` 做镜像备份与回滚 diff --git a/_posts/devops/2026-04-24-docker-cheat-sheet.md b/_posts/devops/2026-04-24-docker-cheat-sheet.md new file mode 100644 index 000000000..6a2dbe4f3 --- /dev/null +++ b/_posts/devops/2026-04-24-docker-cheat-sheet.md @@ -0,0 +1,253 @@ +--- +layout: post +title: Docker 安装与常用命令记录 +subtitle: 旧环境下的部署笔记与风险提示 +date: 2026-04-24 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Docker + - Linux +--- + +>本文主要记录旧版 CentOS 环境下的 Docker 搭建笔记。文中命令偏向历史环境兼容,不建议直接照搬到现代生产环境。 + +## 环境说明 +当前文档针对历史开发机环境: +$ cat /etc/issue +CentOS release 6.3 (Final) +$ uname -r +3.10.0.514.26.2.el7.x86_64 +一、挂载 cgroup +1. root权限执行 Docker官网上提供的脚本:cgroupfs-mount + + #!/bin/sh + #copyright 2011 Canonical, Inc + # 2014 Tianon Gravi + # Author: Serge Hallyn + # Tianon Gravi + set -e + + # for simplicity this script provides no flexibility + + # if cgroup is mounted by fstab, don't run + # don't get too smart - bail on any uncommented entry with 'cgroup' in it + if grep -v '^#' /etc/fstab | grep -q cgroup; then + echo 'cgroups mounted from fstab, not mounting /sys/fs/cgroup' + exit 0 + fi + + # kernel provides cgroups? + if [ ! -e /proc/cgroups ]; then + exit 0 + fi + + # if we don't even have the directory we need, something else must be wrong + if [ ! -d /sys/fs/cgroup ]; then + exit 0 + fi + + # mount /sys/fs/cgroup if not already done + if ! mountpoint -q /sys/fs/cgroup; then + mount -t tmpfs -o uid=0,gid=0,mode=0755 cgroup /sys/fs/cgroup + fi + + cd /sys/fs/cgroup + + # get/mount list of enabled cgroup controllers + for sys in $(awk '!/^#/ { if ($4 == 1) print $1 }' /proc/cgroups); do + mkdir -p $sys + if ! mountpoint -q $sys; then + if ! mount -n -t cgroup -o $sys cgroup $sys; then + rmdir $sys || true + fi + fi + done + + + # example /proc/cgroups: + # #subsys_name hierarchy num_cgroups enabled + # cpuset 2 3 1 + # cpu 3 3 1 + # cpuacct 4 3 1 + # memory 5 3 0 + # devices 6 3 1 + # freezer 7 3 1 + # blkio 8 3 1 + + # enable cgroups memory hierarchy, like systemd does (and lxc/docker desires) + # https://github.com/systemd/systemd/blob/v245/src/core/cgroup.c#L2983 + # https://bugs.debian.org/940713 + if [ -e /sys/fs/cgroup/memory/memory.use_hierarchy ]; then + echo 1 > /sys/fs/cgroup/memory/memory.use_hierarchy + fi + + exit 0 + +2. 确认是否成功挂载 +$ df -h +Filesystem Size Used Avail Use% Mounted on +/dev/vda1 40G 12G 27G 30% / +cgroup 16G 0 16G 0% /sys/fs/cgroup + +二、搭建网桥 +$ brctl addbr docker0 +$ ip addr add 10.0.4.1/24 dev docker0 +$ ip link set dev docker0 up +$ brctl show +bridge name bridge id STP enabled interfaces +docker0 8000.000000000000 no + + +三、安装docker +$ mkdir /tmp/docker_install && cd /tmp/docker_install +$ wget "http://koala.dmop.baidu.com:8080/fc/getfilebyid?id=8829" -O docker-1.12.5.tar.gz && tar -zxvf docker-1.12.5.tar.gz && rm -rf docker-1.12.5.tar.gz +$ mv docker/* /usr/bin && rm -rf docker +四、启动 +1、配置仓库 +$ vim /etc/docker/daemon.json +仓库配置文件,增加如下内容 +{ + "insecure-registries" : ["",""], + "graph":"/var/lib/docker" +} +将默认路径调整,否则容易出现,no space left。 +如果保存不了,可能是没有 docker 文件夹,先 mkdir /etc/docker +2、启动 + +> 不建议默认监听 `tcp://0.0.0.0:2375`。2375 是未加密且无鉴权的 Docker Remote API,暴露到非可信网络会带来高风险。优先只保留 Unix Socket,或至少绑定到 `127.0.0.1` 并配合额外访问控制。 + +$ nohup /usr/bin/dockerd --bip=10.0.4.1/24 -H unix:///var/run/docker.sock >/dev/null 2>/dev/null & +$ nohup /usr/bin/dockerd --bip=10.0.4.1/24 -H tcp://127.0.0.1:2375 -H unix:///var/run/docker.sock >/dev/null 2>/dev/null & +【注】:docker重启 +1. kill -9 pid +2. 运行上面的启动命令即可 + + + +### ld-linux-x86-64.so.2: bad ELF interpreter +4.12 ld-linux-x86-64.so.2: bad ELF interpreter: No such file or directory +/opt/compiler/gcc-8.2/lib64/ld-linux-x86-64.so.2: bad ELF interpreter: No such file or directory +镜像中增加 +ln -s /opt/compiler/gcc-12/lib64/ld-linux-x86-64.so.2 /opt/compiler/gcc-8.2/lib64/ld-linux-x86-64.so.2 +修复。 + + + +### docker 命令 +docker exec -it -u root run -t -i bca1732dcdeb /bin/bash +docker push xxxxx/xxx_projects/gray/r_centos7u9:gcc12_7_new +tag 9b9b96af4f11 centos7u9:gcc12_7_new +build -f Dockerfile +docker pull _containers/centos7.9:gcc12 + + +docker 命令 +https://www.runoob.com/docker/docker-image-usage.html + +docker rmi -f name:tag +docker rmi -f imageid + + +docker run -u root -t -i bca1732dcdeb /bin/bash + + + +### 修改时区,time +``` +FROM image.weiyun.baidu.com/baidu_projects/hiserver-gray/msg-server_centos7u9:gcc12_7_new +MAINTAINER liuquan04 + +USER root + +#RUN ln -sf /bin/bash /bin/sh +#RUN groupadd -r work && useradd -m -r -g work work && chmod 777 home/work +#RUN echo 'work:work' | chpasswd + +USER root +#RUN mkdir /home/work/project +#RUN mkdir /home/work/logs + +RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' > /etc/timezone + +USER work +CMD /start.sh +~ +``` + + +### gcc12 +``` +FROM image-beta.weiyun.baidu.com/baidu_projects/infoflow-dev1/new-adapter-dev:20220811195616_2d93ebc7 +User root +RUN mkdir -p /home/opt/compiler/gcc-12 +COPY ./gcc-12 /home/opt/compiler/gcc-12 +RUN ln -s /home/opt/compiler/gcc-12 /opt/compiler/gcc-12 +RUN rm /usr/bin/gcc +RUN ln -s /opt/compiler/gcc-12/bin/gcc /usr/bin/gcc +USER work +``` + + +### dockerfile +``` +ROM image-beta.weiyun.baidu.com/baidu_projects/infoflow-dev2/centos7u9:gcc12_3_new +MAINTAINER liuquan04 + +USER root + +#RUN ln -sf /bin/bash /bin/sh +#RUN groupadd -r work && useradd -m -r -g work work && chmod 777 home/work +#RUN echo 'work:work' | chpasswd + +USER root +#RUN mkdir /home/work/project +#RUN mkdir /home/work/logs + +COPY start.sh /start.sh +RUN chown -R work:work /home/work +RUN chown work:work /start.sh +RUN chmod 777 /start.sh +USER work +CMD /start.sh +``` + +### ld-linux-x86-64.so.2: bad ELF interpreter +``` +FROM image.weiyun.baidu.com/baidu_projects/hiserver-gray/msg-server_centos7u9:gcc12_5_new +MAINTAINER liuquan04 + +USER root + +#RUN ln -sf /bin/bash /bin/sh +#RUN groupadd -r work && useradd -m -r -g work work && chmod 777 home/work +#RUN echo 'work:work' | chpasswd + +USER root +#RUN mkdir /home/work/project +#RUN mkdir /home/work/logs + +COPY output.tar.gz /output.tar.gz +RUN chown -R work:work /home/work +RUN chown work:work /output.tar.gz +RUN chmod 777 /output.tar.gz + +RUN mkdir -p /opt/compiler/gcc-8.2/lib64/ +RUN ln -s /opt/compiler/gcc-12/lib64/ld-linux-x86-64.so.2 /opt/compiler/gcc-8.2/lib64/ld-linux-x86-64.so.2 + +USER work +CMD /start.sh +``` + + +## 一键打包命令 +docker build --no-cache -t image.weiyun.baidu.com/baidu_projects/hiserver-gray/msg-server_centos7u9:gcc12_10_new -f Dockerfile_libcurl . + +## 安装libcurl Dockerfile +``` +RUN yum install libcurl3-openssl-dev +``` + + + diff --git a/_posts/devops/2026-04-24-mysql-cheat-sheet.md b/_posts/devops/2026-04-24-mysql-cheat-sheet.md new file mode 100644 index 000000000..c5f2165c3 --- /dev/null +++ b/_posts/devops/2026-04-24-mysql-cheat-sheet.md @@ -0,0 +1,136 @@ +--- +layout: post +title: MySQL 常用操作记录 +subtitle: 导出查询结果与大表删除的安全做法 +date: 2026-04-24 +author: BY +header-img: img/post-bg-unix-linux.jpg +catalog: true +tags: + - MySQL + - Database + - Linux +--- + +>本文保留几个常用 MySQL 片段,但涉及批量删除时请先在测试环境验证,并确认备份、回滚方案和主从延迟监控都已准备好。 + +## 导出查询结果到文件 + +避免把密码直接写进命令行;命令行参数会进入 shell history,也可能被同机其他用户看到。更安全的方式是交互输入密码,或使用 `mysql_config_editor` 预置登录信息。 + +```bash +mysql -h -P -u -p \ + --batch --skip-column-names \ + -e "SELECT id FROM bdim_roam._tablet_789" > /tmp/tablet_ids.txt +``` + +如果需要导出带表头的 TSV/CSV,优先使用 `SELECT ... INTO OUTFILE` 或专门的导出工具,并确认目标路径权限与字符集配置。 + +原始笔记里的写法更接近“临时排查命令”,例如先 `use bdim_roam`,再把某个表的 `id` 重定向到文件。这个思路本身没问题,删掉后确实少了点“为什么这么写”的上下文,所以这里补充回来: + +- 这类导出通常是为了把某批记录主键先落盘,后续再做比对、补数或批处理 +- 如果只是想快速拿一列结果,`--batch --skip-column-names` 会比默认交互输出更适合重定向 +- 真正在线上执行时,还是要优先避免把密码明文写在命令行里 + +## INNER JOIN 示例 + +```sql +SELECT * +FROM im_msgroaming +INNER JOIN im_user1 ON im_msgroaming.uid1 = im_user1.uid1 +INNER JOIN im_user2 ON im_msgroaming.uid2 = im_user2.uid2; +``` + +实际线上查询时,建议只取需要的字段,并确认关联列上已有索引,避免把示例 SQL 直接带到大表环境中造成全表扫描。 + +## 大表批量删除的风险 + +假设表引擎为 InnoDB,且数据量达到千万级以上。一次性执行大范围 `DELETE` 时,通常会带来这些问题: + +- 长事务与大范围锁竞争,影响线上读写 +- 大量 binlog / redo log,放大主从同步压力 +- 删除标记与页碎片增多,空间不会立刻回收 +- 回滚时间长,失败时恢复代价高 + +原始文章里有几段解释其实很有帮助,这里按更安全的表述补回来: + +- 删除一条记录时,并不是“立刻把这一行物理抹掉”这么简单;行记录会先被标记删除,相关索引也要同步维护 +- 这些变更会带来 binlog、redo log 等额外写入,所以删得越多,事务越重,回滚成本也越高 +- 旧记录留下的页碎片不会马上消失;如果表本身已经很大,碎片和页命中率下降都会让后续读写更差 + +所以问题不只是“会不会锁表”,还包括日志放大、主从延迟、碎片累积、空间回收不及时这些连锁影响。 + +因此不要直接执行“无条件大删”,而是优先选择**按主键或其他有索引的条件分批删除**。 + +## 更稳妥的删除方式 + +```sql +DELETE FROM your_table +WHERE id > ? AND id <= ? +ORDER BY id +LIMIT 1000; +``` + +建议做法: + +- 以主键或有索引的时间列分段 +- 单批量控制在小事务范围内,例如 500~5000 行 +- 每批之间短暂 sleep,持续观察 QPS、锁等待和从库延迟 +- 先删冷数据,再安排业务低峰执行 + +原始笔记里提到“加了 `LIMIT` 后更容易走索引、避免一次删太多”,这个理解方向是对的,但在线上真正决定执行方案时,还要再确认: + +- 删除条件是否真的命中了合适的索引 +- 执行计划有没有退化成大范围扫描 +- 单批事务时间是否在业务可接受范围内 + +如果只是需要“归档 + 清空间”,很多场景下更适合: + +1. 新建目标表并回填保留数据 +2. 在维护窗口内切换表名 +3. 最后删除旧表 + +这种“重建并切换”的方式虽然更重,但通常比在原表上长时间删除更可控。 + +## 关于表重建 + +删除完成后,如果确实需要回收空间,可以再评估 `OPTIMIZE TABLE` 或重建表。但这一步不是默认动作,必须结合: + +- MySQL 版本与存储引擎能力 +- 业务是否允许维护窗口 +- 表大小、磁盘空间余量、复制拓扑 + +不要把“删完就在线重建表”当成固定脚本直接执行。 + +原始笔记里给过“删完后通过 `ALTER TABLE ... ENGINE=InnoDB` 思路重建表”的方向。这个思路想表达的是:**分批删除只能缓解锁和事务问题,不等于碎片和空间问题自动解决了。** + +但在现在的整理版本里,更适合把它理解成“一个需要单独评估的场景方案”,而不是默认补刀语句。是否适合重建,取决于: + +- 当前 MySQL 版本是否支持你期望的在线能力 +- 表大小、剩余磁盘空间、维护窗口是否允许 +- 主从复制、备份、回滚预案是否已经准备好 + +## 关于触发器双写 + +“新表 + 触发器同步 + 切换”的思路只适合经过充分测试的迁移方案。触发器本身会增加写入链路复杂度,调试和回滚也更困难;如果只是临时清理历史数据,不建议把触发器方案当成默认选项。 + +原始文章里还写过一个触发器同步到新表的示意 SQL。保留那段内容的价值,主要不是让人直接复制,而是帮助理解一种迁移思路: + +1. 新建一张结构相同的新表 +2. 想办法把旧表的增量写入同步过去 +3. 回填历史数据后,再找合适窗口切换 + +但在真实环境里,是否用触发器,只是“增量同步”方案中的一种。相比直接给一段触发器 SQL,现在更应该强调的是: + +- 触发器会增加写入延迟和链路复杂度 +- 异常处理、幂等、回滚都要提前设计 +- 很多场景下,业务双写、订阅 binlog、离线回填会比临时写触发器更可控 + +## 为什么之前会删掉很多内容 + +主要是因为原文里“解释性内容”和“高风险可执行语句”混在一起了。 + +- 解释性内容本身是有价值的,特别是关于 InnoDB 删除、日志、碎片、重建思路这些说明 +- 但如果连带把高风险 SQL 原样保留,读者很容易直接复制到生产环境 + +所以这次的调整思路是:**尽量把帮助理解的说明补回来,但把容易误导直接执行的部分改写成示意、前提条件和场景说明。** diff --git a/_posts/devops/2026-04-24-redis-cheat-sheet.md b/_posts/devops/2026-04-24-redis-cheat-sheet.md new file mode 100644 index 000000000..14430db47 --- /dev/null +++ b/_posts/devops/2026-04-24-redis-cheat-sheet.md @@ -0,0 +1,158 @@ +--- +layout: post +title: Redis Cheat Sheet +subtitle: 常用命令速查与线上使用注意事项 +date: 2026-04-24 +author: BY +header-img: img/post-bg-unix-linux.jpg +catalog: true +tags: + - Redis + - Database + - Linux +--- + +>这份速查表主要用于回忆命令语法。真正在线上执行前,先确认 Redis 版本、数据规模和命令复杂度,避免把实验环境命令直接带到生产环境。 + +参考: + +- http://www.mykeep.fun/redis +- https://github.com/LeCoupa/awesome-cheatsheets + +## 基础 + +```bash +redis-server /path/redis.conf # 按指定配置启动 Redis +redis-cli # 打开 redis 命令行 +sudo systemctl restart redis.service # 重启 Redis(不同发行版服务名可能不同) +sudo systemctl status redis # 查看 Redis 状态 +``` + +## Strings + +```text +APPEND key value +BITCOUNT key [start end] +SET key value +SETNX key value +SETRANGE key offset value +STRLEN key +MSET key value [key value ...] +MSETNX key value [key value ...] +GET key +GETRANGE key start end +MGET key [key ...] +INCR key +INCRBY key increment +INCRBYFLOAT key increment +DECR key +DECRBY key decrement +DEL key +EXPIRE key 120 +TTL key +``` + +## Lists + +```text +RPUSH key value [value ...] +RPUSHX key value +LPUSH key value [value ...] +LPUSHX key value +LRANGE key start stop +LINDEX key index +LINSERT key BEFORE|AFTER pivot value +LLEN key +LPOP key +LSET key index value +LREM key number_of_occurrences value +LTRIM key start stop +RPOP key +RPOPLPUSH source destination +BLPOP key [key ...] timeout +BRPOP key [key ...] timeout +``` + +## Sets + +```text +SADD key member [member ...] +SCARD key +SREM key member [member ...] +SISMEMBER myset value +SMEMBERS myset +SUNION key [key ...] +SINTER key [key ...] +SMOVE source destination member +SPOP key [count] +``` + +## Sorted Sets + +```text +ZADD key [NX|XX] [CH] [INCR] score member [score member ...] +ZCARD key +ZCOUNT key min max +ZINCRBY key increment member +ZRANGE key start stop [WITHSCORES] +ZRANK key member +ZREM key member [member ...] +ZREMRANGEBYRANK key start stop +ZREMRANGEBYSCORE key min max +ZSCORE key member +ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] +``` + +## Hashes + +```text +HGET key field +HGETALL key +HSET key field value +HSETNX key field value +HSET key field value [field value ...] # 新版本通常直接用 HSET 多字段 +HINCRBY key field increment +HDEL key field [field ...] +HEXISTS key field +HKEYS key +HLEN key +HSTRLEN key field +HVALS key +``` + +`HMSET` 在较新的 Redis 文档里已经不再推荐作为首选写法,通常直接使用支持多字段参数的 `HSET` 即可。 + +## HyperLogLog + +```text +PFADD key element [element ...] +PFCOUNT key [key ...] +PFMERGE destkey sourcekey [sourcekey ...] +``` + +## Pub/Sub + +```text +PSUBSCRIBE pattern [pattern ...] +PUBSUB subcommand [argument [argument ...]] +PUBLISH channel message +PUNSUBSCRIBE [pattern [pattern ...]] +SUBSCRIBE channel [channel ...] +UNSUBSCRIBE [channel [channel ...]] +``` + +## 线上环境要特别注意的命令 + +不要把下面这些命令默认当成“随手可用”: + +- `KEYS pattern`:会阻塞扫描整个 keyspace,实例大时非常危险 +- `SMEMBERS` / `HGETALL`:集合或哈希很大时,返回量可能失控 +- `LRANGE 0 -1`:大列表上会把整个列表拉回客户端 + +线上排查 key 时,优先使用: + +```text +SCAN 0 MATCH pattern COUNT 100 +``` + +如果只是确认某个大 key 的规模,也优先使用更轻量的长度类命令,例如 `LLEN`、`SCARD`、`HLEN`、`STRLEN`。 diff --git a/_posts/devops/2026-04-25-ceph.md b/_posts/devops/2026-04-25-ceph.md new file mode 100644 index 000000000..a8792e64a --- /dev/null +++ b/_posts/devops/2026-04-25-ceph.md @@ -0,0 +1,32 @@ +--- +layout: post +title: Ceph 笔记占位整理 +subtitle: 当前只保留一条文档环境准备命令 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Ceph +--- + +>原始笔记里只留下了一条环境准备命令,这里先整理成可继续补充的最小占位版本。 + +## 当前保留内容 + +如果只是为了准备一套基于 Sphinx 的文档环境,可以先执行: + +```bash +pip install sphinx +``` + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 集群角色与基础概念 +- 常用运维命令 +- 故障排查入口 +- 文档或源码阅读路径 + +当前这篇先当作一个待扩充的占位条目。 diff --git "a/_posts/devops/2026-04-25-db \350\277\201\347\247\273\346\226\271\346\241\210.md" "b/_posts/devops/2026-04-25-db \350\277\201\347\247\273\346\226\271\346\241\210.md" new file mode 100644 index 000000000..5b17592e7 --- /dev/null +++ "b/_posts/devops/2026-04-25-db \350\277\201\347\247\273\346\226\271\346\241\210.md" @@ -0,0 +1,109 @@ +--- +layout: post +title: 数据库迁移与冷热分表方案 +subtitle: 停机迁移 / 双写迁移 / 千万级单表的冷热归档实践 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 数据库 + - MySQL + - 架构 +--- + +>原始笔记开头是一段类似目录的纯文本("停机迁移方案 / 双写迁移方案 / 其他 / 一、现状..."),紧接着才是正文,结构很乱。这里把它整理成"两类迁移方案"+"千万级表冷热分库实践"两大块,分节标号;MyBatis 片段、命令片段保持原样。 + +## 当前保留内容 + +### 1. 停机迁移方案 + +> 凌晨挂公告维护、停写、跑导数工具、改连接配置、上线。 + +最简单的方案:网站或 app 挂个公告,"0 点到早上 6 点进行运维,无法访问"。 + +接着到 0 点停机,系统停掉,没有流量写入,老的单库单表数据库静止。然后跑事先写好的一次性导数工具,把单库单表的数据读出来,写到分库分表里面去。 + +导数完了之后,修改系统的数据库连接配置,包括代码和 SQL 也许有修改,那就用最新的代码直接启动连到新的分库分表上去。 + +验证一下,OK 了,凌晨 4 点打个滴滴回家。 + +这个方案比较 low,谁都能干,下面看看高大上一点的方案。 + +### 2. 双写迁移方案 + +> 不停机、增删改双写、跑导数 + 数据校验循环,最后再切到只用新库。 + +提醒: + +``` +问题是,迁移的过程中,如动态修改配置,服务重启期间存在时间差,导致的数据不一致,所以后续仍然需要进一步的校验!!! +常见的mysql,单个表超过千万已经很多了,超过只能分库分表了!!! +``` + +这是常用的一种迁移方案,比较靠谱,不用停机,不用看凌晨 4 点的风景: + +1. 在线上系统里所有写库的地方,增删改操作都加上对新库的增删改,老库新库同时写。 +2. 系统部署之后,新库数据差太远,跑导数工具把老库读出来写新库;写时根据 `gmt_modified` 这类字段判断这条数据最后修改的时间——除非读出来的数据在新库里没有,或者比新库的数据新才会写。简言之:**不允许用老数据覆盖新数据**。 +3. 导完一轮之后,可能数据还是不一致,再跑一轮自动校验,比对新老库每个表的每条数据;不一样的从老库读再次写。反复循环,直到两个库每个表完全一致。 +4. 数据完全一致后,基于"仅使用分库分表的最新代码"重新部署一次,就完成了切换,几乎没有停机时间。 + +### 3. 千万级单表的冷热分表实践 + +#### 一、现状 + +MySQL 下,某 business 单表已近 2000 万且还在持续增加中,存在多个索引,有较高的查询压力。现业务端使用 guava cache 拦了一道,还能顶得住,但是后台管理系统的全量数据的分页排序查询比较慢,且将来会越来越慢。 + +#### 二、目标 + +业务端 + admin 查询都快。 + +#### 三、解决方案 + +1. 基于实际情况(大家一定要根据实际情况来),把数据库拆为三个: + - **热数据(老表)**:提供 CURD 操作,事务加锁等等,最大限度地不用更改原代码。 + - **半年内数据(history)**:只提供查询业务。 + - **半年之前数据(backup)**:归档,不提供业务查询,只支持手动查库(已跟产品沟通好)。 +2. 数据迁移采用公司统一任务调度平台,注册任务后调度执行,自带 WEB 管理页面,支持暂停、恢复、执行计划、日志查询。 +3. 由于历史数据过千万,需要上线前进行一次手动迁移,初始化数据: + - history 表保存:7 天 ~ 半年,非进行状态的数据。 + - backup 表保存:半年前的,非进行状态的数据。 + - 删除 business 表中,7 天前的,非进行状态的数据。 +4. 后续每天凌晨定时任务迁移数据(迁移时注意:保证 ID 一致): + - business → history:> 7 天,非进行状态的数据。 + - history → backup:> 半年,非进行状态的数据。 +5. admin 切到从库读。主从分离,避免从库读全量数据导致业务端查询缓慢。 + +#### 四、踩坑 + +1. 千万级带索引删除记录,记得**不能一次性直接 delete**。可以根据创建时间来,一次删除百万级数据,多分几次删除。否则容易出现假死、慢查询、kill 不掉执行 SQL。 +2. 注意初始化数据时,可能当天多次执行,所以加上"修改时间在当天前"的条件,这样多次执行也不会出现数据重复。 +3. 写批量插入 SQL 时: + - 不要用函数:SQL 中使用函数极端消耗时间。 + - 不要用 `#`,要用 `$`:避免再次编译消耗时间,这里不用怕 SQL 注入,内部接口。 + +``` + + insert into ${toTableName} () + select + from ${fromTableName} + + id in + + ${id} + + + +``` + +#### 五、结果 + +- 热表数据:一百万内,增删改查极快。 +- 历史数据:一千万内,查询快。 +- 归档数据:千万级以上,慢,但是业务不调用。 + +## 后续可补的方向 + +- 双写阶段如何识别"新库无 / 老库新"的写入冲突,给出基于 binlog 的校验脚本骨架。 +- 冷热三表方案下,跨表查询(例如 admin 端历史检索)的统一查询层设计。 +- 评估 TiDB / 分库分表中间件(ShardingSphere)替代手工冷热分表的成本与收益。 diff --git a/_posts/devops/2026-04-25-run_tee.md b/_posts/devops/2026-04-25-run_tee.md new file mode 100644 index 000000000..0d5203404 --- /dev/null +++ b/_posts/devops/2026-04-25-run_tee.md @@ -0,0 +1,1030 @@ +--- +layout: post +title: OP-TEE 运行与构建笔记整理 +subtitle: QEMU 仿真环境部署、一键构建与 demo 快速验证脚本 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - OP-TEE + - QEMU + - ARM +--- + +> 这篇完整保留原始笔记中可直接复用的脚本:QEMU 仿真起来的最短路径、一键构建脚本(中国大陆网络优化版)、依赖与工具链自动修复、`xtest` / demo 快速验证。需要升级组件(如 mbedtls)时也以这套脚本为基础按需调整版本号即可。 + + +arm + +optee/build 目录下 + +执行 +``` +make run + + +telnet localhost 54320 + +qemu monitor 窗口执行 +continue + +mount? 好像也没生效呢 +mkdir -p /mnt/host +mount -t 9p -o trans=virtio host0 /mnt/host + +xtest 就能看到效果了 + +``` + + +``` +#!/bin/bash + +# ===================================================== +# 🛠️ OP-TEE 构建脚本(中国大陆优化版) +# 💡 特性: +# - repo 工具及自身 manifest 从 清华镜像 下载(避免 gerrit.googlesource.com) +# - OP-TEE 源码从 GitHub 官方同步(保证权威性) +# - 支持断点续传、增量构建、WSL 兼容 +# - 自动修复 multiarch 头文件 +# - 详细日志与错误提示 +# +# 🌐 使用场景:中国大陆网络环境 +# 🔧 OP-TEE 版本:v3.20.0 +# +# 🚀 使用方法: +# chmod +x build_optee_china.sh +# ./build_optee_china.sh +# ===================================================== + +set -euo pipefail # 严格模式 + +# ===================================================== +# 🔧 配置区 +# ===================================================== +WORK_DIR="$HOME/optee" # 工作目录 +BUILD_DIR="$WORK_DIR/build" +TOOLCHAINS_DIR="$WORK_DIR/toolchains" +AARCH64_GCC="$TOOLCHAINS_DIR/aarch64/bin/aarch64-linux-gnu-gcc" +AARCH32_GCC="$TOOLCHAINS_DIR/arm/bin/arm-linux-gnueabihf-gcc" + +OPTEE_RELEASE="3.20.0" # 可改为 latest 或具体标签 +MANIFEST_URL="https://github.com/OP-TEE/manifest.git" +MANIFEST_FILE="default.xml" +JOBS=$(nproc) + +# 清华镜像相关 +REPO_BIN_DIR="$HOME/bin" +REPO_BIN_PATH="$REPO_BIN_DIR/repo" +REPO_URL_TUNA="https://mirrors.tuna.tsinghua.edu.cn/git/git-repo/" + +# 依赖包 +DEPS=("libgmp-dev" "libmpfr-dev" "libmpc-dev" "ninja-build" "rsync" + "python3-pip" "bison" "flex" "libssl-dev" "libglib2.0-dev" + "python3-pyelftools" "python3-pycryptodome" + "libfdt-dev" "libpixman-1-dev" "zlib1g-dev" "device-tree-compiler" "libfdt-dev") + +# ===================================================== +# 📝 日志与工具函数 +# ===================================================== +log() { echo -e "\n👉 $*"; } +info() { echo -e "\n💡 INFO: $*"; } +warn() { echo -e "\n⚠️ WARN: $*"; } +success() { echo -e "\n✅ SUCCESS: $*"; } +error() { echo -e "\n❌ ERROR: $*" >&2; exit 1; } +debug() { [[ "${DEBUG:-0}" == "1" ]] && echo -e "\n🔍 DEBUG: $*"; } + +# ===================================================== +# 🔍 环境检测 +# ===================================================== +check_environment() { + log "🔍 检查系统环境" + debug "OS: $(uname -srm)" + if grep -qi microsoft /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then + info "检测到 WSL 环境" + export ON_WSL=1 + fi +} + +# ===================================================== +# 🌐 配置 repo 使用清华镜像(关键!) +# ✅ 彻底避免访问 gerrit.googlesource.com +# ===================================================== +setup_repo_with_tuna() { + log "🌐 配置 repo 使用清华镜像" + + # 1. 创建 bin 目录 + mkdir -p "$REPO_BIN_DIR" + export PATH="$REPO_BIN_DIR:$PATH" + + # 2. 永久写入 .bashrc(避免重复添加) + for line in "export PATH=\"$REPO_BIN_DIR:\$PATH\"" \ + "export REPO_URL=$REPO_URL_TUNA"; do + if ! grep -qF "$line" ~/.bashrc; then + echo "$line" >> ~/.bashrc + fi + done + + # 3. 设置 REPO_URL(核心:让 repo 自身从清华下载) + export REPO_URL="$REPO_URL_TUNA" + debug "REPO_URL=$REPO_URL" + + # 4. 下载 repo 脚本(如果不存在) + if [ ! -f "$REPO_BIN_PATH" ]; then + info "正在从清华镜像下载 repo 工具..." + if curl -L --retry 3 -f -o "$REPO_BIN_PATH" "$REPO_URL_TUNA"; then + chmod a+rx "$REPO_BIN_PATH" + success "repo 已下载至: $REPO_BIN_PATH" + else + error "无法从清华镜像下载 repo,请检查网络或手动安装" + fi + else + info "repo 已存在,跳过下载" + fi + + # 5. 验证 + if ! repo --version >/dev/null 2>&1; then + error "repo 安装失败,请检查权限或 PATH" + fi + success "repo 已配置为使用清华镜像" +} + +# ===================================================== +# 🌍 初始化并同步 OP-TEE 源码(从 GitHub) +# ===================================================== +init_and_sync_optee() { + log "📦 初始化 OP-TEE 源码 (版本: $OPTEE_RELEASE) → 从 GitHub 同步" + + cd "$WORK_DIR" || error "无法进入工作目录: $WORK_DIR" + + if [ -d ".repo" ]; then + if [ ! -f ".repo/manifest.xml" ]; then + warn ".repo 目录损坏,清理中..." + rm -rf .repo + else + success "✅ 源码已存在,跳过 repo init/sync" + return 0 + fi + fi + + # 执行 repo init(使用 GitHub 官方仓库) + info "初始化 repo 仓库..." + debug "repo init -u $MANIFEST_URL -m $MANIFEST_FILE -b $OPTEE_RELEASE" + if ! repo init -u "$MANIFEST_URL" -m "$MANIFEST_FILE" -b "$OPTEE_RELEASE"; then + error "repo init 失败。请确认: + - 网络是否通畅 + - 版本 '$OPTEE_RELEASE' 是否存在 + - REPO_URL 是否正确设置为清华镜像" + fi + + # 同步源码(从 GitHub) + info "开始同步 OP-TEE 源码(从 GitHub,可能较慢)..." + debug "repo sync -c --no-tags --no-clone-bundle -j$JOBS" + if ! repo sync -c --no-tags --no-clone-bundle -j"$JOBS"; then + warn "多线程同步失败,尝试单线程..." + if ! repo sync -c --no-tags --no-clone-bundle -j1; then + error "repo sync 失败,请检查网络、磁盘空间或代理设置" + fi + fi + + success "🎉 源码同步完成!代码来自 GitHub 官方" +} + +# ===================================================== +# 📦 安装系统依赖 +# ===================================================== +install_dependencies() { + log "🔧 安装系统依赖" + + sudo apt update || error "apt update 失败" + + sudo apt install -y \ + git make gcc gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf \ + g++-aarch64-linux-gnu g++-arm-linux-gnueabihf \ + libc6-dev libc6-dev-arm64-cross libc6-dev-armhf-cross \ + unzip wget curl python3 python3-pip || error "基础依赖安装失败" + + local missing=() + for dep in "${DEPS[@]}"; do + if ! dpkg -l | grep -q "^ii $dep"; then + missing+=("$dep") + fi + done + + if [ ${#missing[@]} -eq 0 ]; then + success "所有依赖已安装" + return + fi + + warn "发现缺失依赖: ${missing[*]}" + read -p "是否安装? [Y/n] " -n1 -r; echo + [[ $REPLY =~ ^[Nn]$ ]] && error "请手动安装: sudo apt install ${missing[*]}" + + sudo apt install -y "${missing[@]}" || error "依赖安装失败" + success "依赖安装完成" +} + +# ===================================================== +# 🧩 修复 multiarch 头文件问题 +# ===================================================== +fix_multiarch_headers() { + log "🔧 修复 multiarch 头文件链接" + + local arch_dir="/usr/include/x86_64-linux-gnu" + local headers=("gmp.h" "mpfr.h" "mpc.h") + + for header in "${headers[@]}"; do + local src="$arch_dir/$header" + local dst="/usr/include/$header" + if [ -f "$dst" ];then + break; + fi + + if [ -f "$src" ] && [ ! -f "$dst" ]; then + sudo ln -sf "$src" "$dst" + info "创建符号链接: $dst -> $src" + elif [ ! -f "$src" ]; then + error "未找到 $src,请确保 libgmp-dev / mpfr-dev / mpc-dev 已安装" + fi + done + + success "头文件修复完成" +} + +# ===================================================== +# 🛠️ 处理 toolchains(清理或重建) +# ===================================================== +handle_toolchains() { + log "🛠️ 检查 toolchains 状态" + + if [ -f "$AARCH64_GCC" ] && [ -f "$AARCH32_GCC" ]; then + success "toolchains 已存在,跳过重建" + return + fi + + warn "toolchains 缺失或不完整,执行清理并重建" + + cd "$BUILD_DIR" || error "进入构建目录失败" + make distclean || true + rm -rf "$TOOLCHAINS_DIR" out build 2>/dev/null || true + + info "重新构建工具链..." + make -j"$JOBS" toolchains || error "工具链构建失败" + success "工具链构建完成" +} + +# ===================================================== +# 🧼 设置干净环境(尤其 WSL) +# ===================================================== +setup_clean_env() { + log "🧼 设置构建环境" + + if [ "${ON_WSL:-0}" = "1" ]; then + info "净化 WSL PATH(避免 Windows 干扰)" + export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + fi + debug "PATH: $PATH" +} + +# ===================================================== +# 🏗️ 构建主系统 +# ===================================================== +build_system() { + log "🏗️ 开始构建 OP-TEE" + + cd "$BUILD_DIR" || error "无法进入构建目录" + make -j"$JOBS" all || error "构建失败" + success "🎉 构建成功!" +} + +# ===================================================== +# ✅ 验证 xtest +# ===================================================== +# ---------- 8. 检查 xtest 是否生成 ---------- +check_xtest() { + local XTEST_BIN="$WORK_DIR/optee_test/out/xtest/xtest" + if [ ! -f "$XTEST_BIN" ]; then + error "xtest 未生成: $XTEST_BIN + +请检查构建日志。常见原因: + - build/conf/buildroot_config 中 BR2_PACKAGE_OPTEE_TEST_EXT=y + - br-ext/package/optee_test_ext/ 存在 + - 已运行 make optee-test-ext" + fi + success "xtest 已就绪: $XTEST_BIN" +} + +check_xtest + +# ---------- 9. 启动 QEMU ---------- +launch_qemu() { + log "启动 QEMU 模拟器" + make run > qemu.log 2>&1 & + QEMU_PID=$! + sleep 8 + + if ! kill -0 $QEMU_PID 2>/dev/null; then + error "QEMU 启动失败,请查看 qemu.log" + fi + success "QEMU 运行中 (PID: $QEMU_PID)" + echo " +📌 登录:root(无密码) +📌 退出 QEMU:Ctrl+A, X +📌 查看日志:tail -f qemu.log +" +} + +# ---------- 10. 提示运行测试 ---------- +run_xtest_hint() { + echo " +📌 请在 QEMU 终端中运行测试: + + /optee_test/run_xtest.sh + +📌 或直接运行: + xtest + +📌 常见测试: + xtest 1000 # TEE Core + xtest 2001 # Crypto + xtest 3010 # Secure Storage +" +} + +# ---------- 询问是否启动 ---------- +ask_to_launch() { + echo + read -p "是否启动 QEMU 并运行测试? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + launch_qemu + run_xtest_hint + else + success "构建完成,未启动 QEMU" + echo " +📌 手动启动: + cd $BUILD_DIR && make run + +📌 运行测试: + 登录后执行:/optee_test/run_xtest.sh +" + fi +} + +# ===================================================== +# 🚀 主流程 +# ===================================================== +main() { + #下面这行有可能是乱码,导致变量不可用 + #info "开始构建 OP-TEE v$OPTEE_RELEASE(中国大陆优化版)" + debug "DEBUG 模式开启" + + check_environment + setup_repo_with_tuna + init_and_sync_optee + install_dependencies + fix_multiarch_headers + handle_toolchains + setup_clean_env + build_system + check_xtest + ask_launch + + success "✅ 所有步骤完成!" +} + +# ===================================================== +# 🏁 入口 +# ===================================================== +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi +``` + + +``` +roborock@DESKTOP-B2S7H04:~/project$ cat run_tee_ultimate.sh +#!/bin/bash + +# ===================================================== +# OP-TEE 智能构建脚本(完整版) +# 功能: +# - 自动安装缺失依赖 +# - 自动处理 multiarch 头文件路径(gmp.h, mpfr.h, mpc.h) +# - 智能判断是否需要清理 toolchains +# - 支持增量构建 +# 用法: +# chmod +x smart_build_optee.sh +# ./smart_build_optee.sh +# ===================================================== + +set -e # 遇错停止 + +# ---------- 配置 ---------- +WORK_DIR="$HOME/optee" +BUILD_DIR="$WORK_DIR/build" +TOOLCHAINS_DIR="$WORK_DIR/toolchains" +AARCH64_GCC="$TOOLCHAINS_DIR/aarch64/bin/aarch64-linux-gnu-gcc" +AARCH32_GCC="$TOOLCHAINS_DIR/arm/bin/arm-linux-gnueabihf-gcc" + +# 关键依赖列表 +DEPS=("libgmp-dev" "libmpfr-dev" "libmpc-dev" "ninja-build" "rsync" "python3-pip" "bison" "flex" "libssl-dev") + +# ---------- 工具函数 ---------- +log() { + echo -e "\n👉 $1" +} + +error() { + echo -e "\n❌ 错误: $1" + exit 1 +} + +success() { + echo -e "\n✅ $1" +} + +# ---------- 1. 检查工作目录 ---------- +log "检查工作目录" +if [ ! -d "$WORK_DIR" ]; then + error "工作目录 $WORK_DIR 不存在,请先初始化仓库" +fi + +cd "$WORK_DIR" + +# ---------- 2. 检查并安装依赖 ---------- +log "检查系统依赖" + +echo "👉 步骤 2: 安装系统依赖" +sudo apt update +sudo apt install -y git make gcc gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf \ + libc6-dev libc6-dev-arm64-cross libc6-dev-armhf-cross \ + bison flex libssl-dev libglib2.0-dev \ + libfdt-dev libpixman-1-dev zlib1g-dev \ + python3 python3-pip unzip wget curl \ + g++-aarch64-linux-gnu g++-arm-linux-gnueabihf + +MISSING_DEPS=() +for dep in "${DEPS[@]}"; do + if ! dpkg -l | grep -q "^ii $dep"; then + MISSING_DEPS+=("$dep") + fi +done + +if [ ${#MISSING_DEPS[@]} -gt 0 ]; then + log "发现缺失依赖: ${MISSING_DEPS[*]}" + read -p "是否安装? [Y/n] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + sudo apt update + sudo apt install -y "${MISSING_DEPS[@]}" || error "依赖安装失败" + success "依赖安装完成" + else + error "请手动安装缺失依赖后重试" + fi +fi + +# ---------- 3. 自动处理 multiarch 头文件 ---------- +fix_multiarch_headers() { + local arch_dir="/usr/include/x86_64-linux-gnu" + local headers=("gmp.h" "mpfr.h" "mpc.h") + + log "处理 multiarch 头文件路径(Debian/Ubuntu 标准)" + + for header in "${headers[@]}"; do + if [ -f "$arch_dir/$header" ] && [ ! -f "/usr/include/$header" ]; then + sudo ln -sf "$arch_dir/$header" "/usr/include/$header" + echo "✅ 创建符号链接: /usr/include/$header -> $arch_dir/$header" + elif [ -f "/usr/include/$header" ]; then + echo "✅ /usr/include/$header 已存在,跳过" + else + error "未找到 $header,请检查 libgmp-dev / libmpfr-dev / libmpc-dev 是否安装" + fi + done +} + +# 执行头文件修复 +fix_multiarch_headers + +# ---------- 4. 检查 toolchains 完整性 ---------- +log "检查 toolchains 状态" +TOOLCHAINS_OK=true + +if [ ! -d "$TOOLCHAINS_DIR" ]; then + TOOLCHAINS_OK=false +else + if [ ! -f "$AARCH64_GCC" ] || [ ! -f "$AARCH32_GCC" ]; then + TOOLCHAINS_OK=false + fi +fi + +# ---------- 5. 决策是否需要清理 ---------- +cd "$BUILD_DIR" || error "无法进入构建目录: $BUILD_DIR" + +NEEDS_CLEAN=false + +if [ "$TOOLCHAINS_OK" = false ]; then + log "检测到 toolchains 不完整或不存在,需要清理重建" + NEEDS_CLEAN=true +else + success "toolchains 完整,跳过重建" +fi + +if [ "$NEEDS_CLEAN" = true ]; then + log "执行深度清理" + make distclean || true + rm -rf "$TOOLCHAINS_DIR" "$WORK_DIR/out" "$BUILD_DIR/build" 2>/dev/null || true + success "清理完成" + + log "重新构建工具链" + make -j$(nproc) toolchains || error "工具链构建失败" +else + success "toolchains 完整,跳过重建" +fi + +# ---------- 检测 WSL 并清理 PATH ---------- +setup_clean_environment() { + log "设置构建环境" + + # 检测 WSL + if grep -qi microsoft /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then + log "✅ 检测到 WSL: $WSL_DISTRO_NAME" + log "🛡️ 正在设置干净 PATH(移除 Windows 路径)" + + # 仅保留 Linux 安全路径 + export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + export PATH="$PATH:/usr/games:/usr/local/games" + + success "干净 PATH 已设置: $PATH" + else + log "🐧 非 WSL 环境,使用当前 PATH" + fi + + # 再次验证 PATH + if echo "$PATH" | tr ':' '\n' | grep -q '[[:space:][:cntrl:]]'; then + error "PATH 仍包含空格或控制字符,请手动清理" + fi +} + +setup_clean_environment + +# ---------- 6. 构建主系统 ---------- +log "开始分步构建 OP-TEE 系统" +make -j$(nproc) all || error "主系统构建失败" +#make -j$(nproc) qemu || error "QEMU 构建失败" +#make -j$(nproc) linux || error "Linux 内核构建失败" +#make -j$(nproc) optee-os || error "OP-TEE OS 构建失败" +#make -j$(nproc) optee-client-ext || error "OP-TEE Client 构建失败" + +# 关键:使用 optee-test-ext +#make -j$(nproc) optee-test-ext || error "OP-TEE Test (ext) 构建失败" +#make -j$(nproc) rootfs || error "RootFS 构建失败" +#make || error "最终整合失败" + +success "🎉 构建成功!" + +# ---------- 7. 检查 xtest 是否生成 ---------- +check_xtest() { + local XTEST_BIN="$WORK_DIR/optee_test/out/xtest/xtest" + if [ ! -f "$XTEST_BIN" ]; then + error "xtest 未生成: $XTEST_BIN + +请检查构建日志。常见原因: + - build/conf/buildroot_config 中 BR2_PACKAGE_OPTEE_TEST_EXT=y + - br-ext/package/optee_test_ext/ 存在 + - 已运行 make optee-test-ext" + fi + success "xtest 已就绪: $XTEST_BIN" +} + +#check_xtest + +# ---------- 8. 启动 QEMU ---------- +launch_qemu() { + log "启动 QEMU 模拟器" + make run > qemu.log 2>&1 & + QEMU_PID=$! + sleep 8 + + if ! kill -0 $QEMU_PID 2>/dev/null; then + error "QEMU 启动失败,请查看 qemu.log" + fi + success "QEMU 运行中 (PID: $QEMU_PID)" + echo " +📌 登录:root(无密码) +📌 退出 QEMU:Ctrl+A, X +📌 查看日志:tail -f qemu.log +" +} + +# ---------- 9. 提示运行测试 ---------- +run_xtest_hint() { + echo " +📌 请在 QEMU 终端中运行测试: + + /optee_test/run_xtest.sh + +📌 或直接运行: + xtest + +📌 常见测试: + xtest 1000 # TEE Core + xtest 2001 # Crypto + xtest 3010 # Secure Storage +" +} + +# ---------- 10. 询问是否启动 ---------- +ask_to_launch() { + echo + read -p "是否启动 QEMU 并运行测试? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + launch_qemu + run_xtest_hint + else + success "构建完成,未启动 QEMU" + echo " +📌 手动启动: + cd $BUILD_DIR && make run + +📌 运行测试: + 登录后执行:/optee_test/run_xtest.sh +" + fi +} + +# ---------- 执行 ---------- +ask_to_launch +``` + + + +``` +#!/bin/bash + +# ===================================================== +# OP-TEE 智能构建脚本(修复完整版) +# 功能: +# - 自动安装缺失依赖 +# - 自动处理 multiarch 头文件路径(gmp.h, mpfr.h, mpc.h) +# - 智能判断是否需要清理 toolchains +# - 支持增量构建 +# - 修复 repo 下载问题 +# 用法: +# chmod +x smart_build_optee.sh +# ./smart_build_optee.sh +# ===================================================== + +set -e # 遇错停止 + +# ---------- 配置 ---------- +WORK_DIR="$HOME/optee" +BUILD_DIR="$WORK_DIR/build" +TOOLCHAINS_DIR="$WORK_DIR/toolchains" +AARCH64_GCC="$TOOLCHAINS_DIR/aarch64/bin/aarch64-linux-gnu-gcc" +AARCH32_GCC="$TOOLCHAINS_DIR/arm/bin/arm-linux-gnueabihf-gcc" + +# 🔧 修复:定义 repo 和 manifest 相关变量 +OPTEE_RELEASE="3.20.0" # 可改为 latest 或具体版本 +MANIFEST_URL="https://github.com/OP-TEE/manifest.git" +MANIFEST_FILE="default.xml" +JOBS=$(nproc) # 并行任务数 + +# 🔧 repo 官方下载地址(HTTPS) +REPO_URL="https://github.com/GerritCodeReview/git-repo/raw/main/repo" + +# 关键依赖列表 +DEPS=("libgmp-dev" "libmpfr-dev" "libmpc-dev" "ninja-build" "rsync" "python3-pip" "bison" "flex" "libssl-dev") + +# ---------- 工具函数 ---------- +log() { + echo -e "\n👉 $1" +} + +error() { + echo -e "\n❌ 错误: $1" + exit 1 +} + +success() { + echo -e "\n✅ $1" +} + +# 🔧 修复:定义 info 和 warn 函数 +info() { + echo -e "\n💡 $1" +} + +warn() { + echo -e "\n⚠️ $1" +} + +# ---------- 修复 repo 工具 ---------- +log "安装或更新 repo 工具" + +# ---------- 安装 repo ---------- +REPO_DIR="$HOME/.bin" +REPO_PATH="$REPO_DIR/repo" +mkdir -p "$REPO_DIR" +export PATH="$REPO_DIR:$PATH" +if ! grep -q 'export PATH="$HOME/.bin:$PATH"' ~/.bashrc; then + echo 'export PATH="$HOME/.bin:$PATH"' >> ~/.bashrc +fi + +if ! command -v repo >/dev/null 2>&1; then + info "Installing repo tool from GitHub..." + curl -L --retry 3 -o "$REPO_PATH" "$REPO_URL" + chmod a+rx "$REPO_PATH" +else + info "Repo tool already installed." +fi + +# ---------- 初始化 ---------- +mkdir -p "$WORK_DIR" +cd "$WORK_DIR" + +info "Initializing OP-TEE manifest (release $OPTEE_RELEASE)..." + +# 自动修复旧的损坏 repo 状态 +if [ -d ".repo/repo" ] && [ ! -f ".repo/repo/main.py" ]; then + warn ".repo/repo seems corrupted, removing..." + rm -rf .repo/repo +fi +if [ -d ".repo" ] && [ ! -f ".repo/manifest.xml" ]; then + warn ".repo directory incomplete, cleaning up..." + rm -rf .repo +fi + +if ! repo init -u "$MANIFEST_URL" -m "$MANIFEST_FILE" -b "$OPTEE_RELEASE"; then + warn "Repo init failed, retrying..." + rm -rf .repo + repo init -u "$MANIFEST_URL" -m "$MANIFEST_FILE" -b "$OPTEE_RELEASE" +fi + +info "Syncing sources..." +repo sync -c --no-tags --no-clone-bundle -j"$JOBS" || { + warn "Sync failed — retrying single-thread..." + repo sync -c --no-tags --no-clone-bundle -j1 +} + + + +mkdir -p ~/bin +export PATH=~/bin:$PATH + +info "Installing or repairing Repo tool..." +if ! command -v repo >/dev/null 2>&1; then + mkdir -p ~/.local/bin + cd ~/.local/bin + git clone https://github.com/GerritCodeReview/git-repo.git repo-tmp + cp repo-tmp/repo repo + chmod +x repo + rm -rf repo-tmp + export PATH="$HOME/.local/bin:$PATH" + echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +else + info "repo 已安装: $(repo --version)" +fi + +# ---------- 初始化工作目录 ---------- +log "初始化工作目录" +mkdir -p "$WORK_DIR" +cd "$WORK_DIR" + +# ---------- 检查并安装依赖 ---------- +log "检查系统依赖" + +sudo apt update +sudo apt install -y git make gcc gcc-aarch64-linux-gnu gcc-arm-linux-gnueabihf \ + libc6-dev libc6-dev-arm64-cross libc6-dev-armhf-cross \ + bison flex libssl-dev libglib2.0-dev \ + libfdt-dev libpixman-1-dev zlib1g-dev \ + python3 python3-pip unzip wget curl \ + g++-aarch64-linux-gnu g++-arm-linux-gnueabihf + +MISSING_DEPS=() +for dep in "${DEPS[@]}"; do + if ! dpkg -l | grep -q "^ii $dep"; then + MISSING_DEPS+=("$dep") + fi +done + +if [ ${#MISSING_DEPS[@]} -gt 0 ]; then + log "发现缺失依赖: ${MISSING_DEPS[*]}" + read -p "是否安装? [Y/n] " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Nn]$ ]]; then + sudo apt update + sudo apt install -y "${MISSING_DEPS[@]}" || error "依赖安装失败" + success "依赖安装完成" + else + error "请手动安装缺失依赖后重试" + fi +fi + + +# ---------- 6. 初始化仓库 ---------- +log "初始化 OP-TEE 仓库 (release $OPTEE_RELEASE)" + +cd "$WORK_DIR" || error "无法进入工作目录: $WORK_DIR" + +if [ ! -d ".repo" ]; then + info "首次初始化 repo 仓库..." + repo init -u "$MANIFEST_URL" -m "$MANIFEST_FILE" -b "$OPTEE_RELEASE" || error "repo init 失败" + + info "同步源码(可能需要几分钟)..." + repo sync -c --no-tags --no-clone-bundle -j"$JOBS" || { + warn "同步失败,尝试单线程重试..." + repo sync -c --no-tags --no-clone-bundle -j1 || error "repo sync 失败" + } + success "源码同步完成" +else + success "仓库已存在,跳过 repo init/sync" +fi + +# ---------- 3. 自动处理 multiarch 头文件 ---------- +fix_multiarch_headers() { + local arch_dir="/usr/include/x86_64-linux-gnu" + local headers=("gmp.h" "mpfr.h" "mpc.h") + + log "处理 multiarch 头文件路径(Debian/Ubuntu 标准)" + + for header in "${headers[@]}"; do + if [ -f "$arch_dir/$header" ] && [ ! -f "/usr/include/$header" ]; then + sudo ln -sf "$arch_dir/$header" "/usr/include/$header" + echo "✅ 创建符号链接: /usr/include/$header -> $arch_dir/$header" + elif [ -f "/usr/include/$header" ]; then + echo "✅ /usr/include/$header 已存在,跳过" + else + error "未找到 $header,请检查 libgmp-dev / libmpfr-dev / libmpc-dev 是否安装" + fi + done +} + +# 执行头文件修复 +fix_multiarch_headers + +# ---------- 4. 检查 toolchains 完整性 ---------- +log "检查 toolchains 状态" +TOOLCHAINS_OK=true + +if [ ! -d "$TOOLCHAINS_DIR" ]; then + TOOLCHAINS_OK=false +else + if [ ! -f "$AARCH64_GCC" ] || [ ! -f "$AARCH32_GCC" ]; then + TOOLCHAINS_OK=false + fi +fi + +# ---------- 5. 决策是否需要清理 ---------- +cd "$BUILD_DIR" || error "无法进入构建目录: $BUILD_DIR" + +NEEDS_CLEAN=false + +if [ "$TOOLCHAINS_OK" = false ]; then + log "检测到 toolchains 不完整或不存在,需要清理重建" + NEEDS_CLEAN=true +else + success "toolchains 完整,跳过重建" +fi + +if [ "$NEEDS_CLEAN" = true ]; then + log "执行深度清理" + make distclean || true + rm -rf "$TOOLCHAINS_DIR" "$WORK_DIR/out" "$BUILD_DIR/build" 2>/dev/null || true + success "清理完成" + + log "重新构建工具链" + make -j$(nproc) toolchains || error "工具链构建失败" +else + success "toolchains 完整,跳过重建" +fi + +# ---------- 检测 WSL 并清理 PATH ---------- +setup_clean_environment() { + log "设置构建环境" + + # 检测 WSL + if grep -qi microsoft /proc/version 2>/dev/null || [ -n "${WSL_DISTRO_NAME:-}" ]; then + log "✅ 检测到 WSL: $WSL_DISTRO_NAME" + log "🛡️ 正在设置干净 PATH(移除 Windows 路径)" + + # 仅保留 Linux 安全路径 + export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + export PATH="$PATH:/usr/games:/usr/local/games" + + success "干净 PATH 已设置: $PATH" + else + log "🐧 非 WSL 环境,使用当前 PATH" + fi + + # 再次验证 PATH + if echo "$PATH" | tr ':' '\n' | grep -q '[[:space:][:cntrl:]]'; then + error "PATH 仍包含空格或控制字符,请手动清理" + fi +} + +setup_clean_environment + + +# ---------- 7. 构建主系统 ---------- +log "开始构建 OP-TEE 系统" +cd "$BUILD_DIR" || error "无法进入构建目录" + +make -j$(nproc) all || error "主系统构建失败" +success "🎉 构建成功!" + +# ---------- 8. 检查 xtest 是否生成 ---------- +check_xtest() { + local XTEST_BIN="$WORK_DIR/optee_test/out/xtest/xtest" + if [ ! -f "$XTEST_BIN" ]; then + error "xtest 未生成: $XTEST_BIN + +请检查构建日志。常见原因: + - build/conf/buildroot_config 中 BR2_PACKAGE_OPTEE_TEST_EXT=y + - br-ext/package/optee_test_ext/ 存在 + - 已运行 make optee-test-ext" + fi + success "xtest 已就绪: $XTEST_BIN" +} + +check_xtest + +# ---------- 9. 启动 QEMU ---------- +launch_qemu() { + log "启动 QEMU 模拟器" + make run > qemu.log 2>&1 & + QEMU_PID=$! + sleep 8 + + if ! kill -0 $QEMU_PID 2>/dev/null; then + error "QEMU 启动失败,请查看 qemu.log" + fi + success "QEMU 运行中 (PID: $QEMU_PID)" + echo " +📌 登录:root(无密码) +📌 退出 QEMU:Ctrl+A, X +📌 查看日志:tail -f qemu.log +" +} + +# ---------- 10. 提示运行测试 ---------- +run_xtest_hint() { + echo " +📌 请在 QEMU 终端中运行测试: + + /optee_test/run_xtest.sh + +📌 或直接运行: + xtest + +📌 常见测试: + xtest 1000 # TEE Core + xtest 2001 # Crypto + xtest 3010 # Secure Storage +" +} + +# ---------- 询问是否启动 ---------- +ask_to_launch() { + echo + read -p "是否启动 QEMU 并运行测试? [y/N] " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + launch_qemu + run_xtest_hint + else + success "构建完成,未启动 QEMU" + echo " +📌 手动启动: + cd $BUILD_DIR && make run + +📌 运行测试: + 登录后执行:/optee_test/run_xtest.sh +" + fi +} + +# ---------- 执行 ---------- +ask_to_launch +``` + + + + + + + + + + + + + + + + + + diff --git "a/_posts/devops/2026-04-25-ubuntu\347\216\257\345\242\203\346\220\255\345\273\272.md" "b/_posts/devops/2026-04-25-ubuntu\347\216\257\345\242\203\346\220\255\345\273\272.md" new file mode 100644 index 000000000..0a205b5a7 --- /dev/null +++ "b/_posts/devops/2026-04-25-ubuntu\347\216\257\345\242\203\346\220\255\345\273\272.md" @@ -0,0 +1,164 @@ +--- +layout: post +title: Ubuntu 环境搭建笔记 +subtitle: apt 换源(清华 / 163 / 阿里云 / 中科大)+ pip / 网络 / 工具杂记 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Ubuntu + - Linux + - 环境搭建 +--- + +>原始笔记是几段标题层级混乱的环境搭建片段,"中科大源"的内容实际上还是阿里云的(直接复用了),netplan / curl / root 几小节零散贴在末尾。这里只把章节按"换源 / Python / 系统配置 / 网络 / 工具"四块归拢,命令片段原样保留,不替换其中可疑的源地址,避免误改。 + +## 当前保留内容 + +### 1. 更换 apt 源(jammy / 22.04) + +#### 1.1 清华源 + +``` +sudo bash -c "cat << EOF > /etc/apt/sources.list && apt update +deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy main restricted universe multiverse +# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy main restricted universe multiverse +deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-updates main restricted universe multiverse +# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-updates main restricted universe multiverse +deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-backports main restricted universe multiverse +# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-backports main restricted universe multiverse +deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-security main restricted universe multiverse +# deb-src https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy-security main restricted universe multiverse +EOF" +``` + +#### 1.2 163 源 + +``` +sudo bash -c "cat << EOF > /etc/apt/sources.list && apt update +deb http://mirrors.163.com/ubuntu/ jammy main restricted universe multiverse +deb http://mirrors.163.com/ubuntu/ jammy-security main restricted universe multiverse +deb http://mirrors.163.com/ubuntu/ jammy-updates main restricted universe multiverse +deb http://mirrors.163.com/ubuntu/ jammy-proposed main restricted universe multiverse +deb http://mirrors.163.com/ubuntu/ jammy-backports main restricted universe multiverse +deb-src http://mirrors.163.com/ubuntu/ jammy main restricted universe multiverse +deb-src http://mirrors.163.com/ubuntu/ jammy-security main restricted universe multiverse +deb-src http://mirrors.163.com/ubuntu/ jammy-updates main restricted universe multiverse +deb-src http://mirrors.163.com/ubuntu/ jammy-proposed main restricted universe multiverse +deb-src http://mirrors.163.com/ubuntu/ jammy-backports main restricted universe multiverse +EOF" +``` + +#### 1.3 阿里云 + +``` +sudo bash -c "cat << EOF > /etc/apt/sources.list && apt update +deb http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-proposed main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-proposed main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse +EOF" +``` + +#### 1.4 中科大 + +> 注:原始笔记此处的内容与"阿里云"一致,疑似复制粘贴时漏改。这里如实保留,待校对后再用真正的中科大源(`mirrors.ustc.edu.cn`)替换。 + +``` +sudo bash -c "cat << EOF > /etc/apt/sources.list && apt update +deb http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-proposed main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-proposed main restricted universe multiverse +deb http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse +deb-src http://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse +EOF" +``` + +换源完成之后,生效命令:`sudo apt update`。 + +### 2. Python / pip + +#### 2.1 pip 升级与查看可用版本 + +``` +sudo pip install --upgrade pip + +查看安装版本 +pip install numpy== +``` + +#### 2.2 Python openssl 源码编译?python3-openssl? + +(待补充。) + +### 3. 系统配置 + +#### 3.1 分区、扩容 + +参考: + +#### 3.2 关闭锁屏 + +(待补充。) + +#### 3.3 Windows 设置输入法(关闭微软自带,更换为搜狗) + +设置 → 时间和语言 → 键盘 → 添加语言并删除原有的微软输入法。 + +#### 3.4 Windows 设置程序的开机启动(类似 macOS) + +(待补充。) + +### 4. 网络(netplan) + +``` +network: + version: 2 + renderer: networkd + ethernets: + enp3s0: + addresses: + - 10.10.10.2/24 + gateway4: 10.10.10.1 + nameservers: + search: [mydomain, otherdomain] + addresses: [10.10.10.1, 1.1.1.1] +``` + +#### 其他 netplan 配置 + +如 1 个网卡多个 IP 等场景,待补充。 + +### 5. 常用工具与账号 + +#### 5.1 curl / net-tools / vim + +(待补充安装清单与最小配置。) + +#### 5.2 root 账号设置密码 + +``` +sudo passwd root +``` + +#### 5.3 安装 GitKraken + +参考: + +## 后续可补的方向 + +- 把"中科大源"修正为真正的 `mirrors.ustc.edu.cn`,并补一份脚本:自动 ping 三家源择最快的写入 `sources.list`。 +- 补全 Python openssl 源码编译的步骤(含 `--with-openssl=` 配置)。 +- netplan 多 IP / 多网卡 / VLAN 的几个常见 yaml 模板各给一份。 diff --git "a/_posts/devops/2026-04-25-\346\231\256\347\275\227\347\261\263\344\277\256\346\226\257-\347\233\221\346\216\247.md" "b/_posts/devops/2026-04-25-\346\231\256\347\275\227\347\261\263\344\277\256\346\226\257-\347\233\221\346\216\247.md" new file mode 100644 index 000000000..fe300dd23 --- /dev/null +++ "b/_posts/devops/2026-04-25-\346\231\256\347\275\227\347\261\263\344\277\256\346\226\257-\347\233\221\346\216\247.md" @@ -0,0 +1,62 @@ +--- +layout: post +title: Prometheus 监控常用查询片段 +subtitle: 收藏几条 pod / 磁盘 / TCP 连接数的 PromQL +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Prometheus + - 监控 + - PromQL +--- + +>原始笔记只有几条 PromQL 片段,这里按用途分节整理,方便后续继续往里塞新的查询。 + +## 当前保留内容 + +### 1. Pod 信息 + +通过 `kube_pod_info` 查某个 Pod 的元信息: + +``` +kube_pod_info{pod="msg-server-default-766b6784f5-9hpg4"} +``` + +按 pod 名匹配即可,常用于先确认 Pod 落在哪个节点 / 命名空间。 + +### 2. 磁盘使用率告警 + +按 instance 维度算磁盘使用率(百分比),超过 77% 触发告警;同时通过 `mountpoint!~"/noah.*"` 把不关心的挂载点排除: + +``` +max by(instance) ( + (node_filesystem_size_bytes - node_filesystem_free_bytes) + / + (node_filesystem_size_bytes{mountpoint!~"/noah.*"} + - node_filesystem_free_bytes + + node_filesystem_avail_bytes) +) * 100 > 77 +``` + +### 3. TCP 当前 ESTABLISHED 连接数 + +查看某台宿主机上 TCP 处于 `ESTABLISHED` 状态的连接数: + +``` +node_netstat_Tcp_CurrEstab{instance="hb-bj-d-c16m64-k8sbeta2002.bcc-bjdd"} +``` + +可以直接画曲线观察峰值,或用 `topk` 找出最高的若干台机器。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- CPU / 内存 / 网络流量等其它常用指标的 PromQL 片段 +- 业务侧自定义指标(QPS、错误率、P99)的标准查询模板 +- 告警规则示例(包含 for / labels / annotations 的完整 yaml) +- 配合 Grafana 面板的常用变量与查询写法 + +当前这篇先当作一个"PromQL 收藏夹"的占位条目,后续遇到好用的查询直接补到对应小节。 diff --git a/_posts/jenkins/2026-04-26-jenkins-checklist.md b/_posts/jenkins/2026-04-26-jenkins-checklist.md new file mode 100644 index 000000000..c6ef07bca --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-checklist.md @@ -0,0 +1,78 @@ +--- +layout: post +title: "Jenkins 上线检查清单:交付 / 安全 / 合规" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, checklist, 安全, 合规] +description: "Jenkins 平台上线前后的交付、安全与合规检查项清单。" +slug: jenkins-checklist +--- + +## 1. 通用上线 Checklist + +- [ ] DNS 记录可解析(内外网均测试) +- [ ] HTTPS 证书有效,浏览器、curl、`jk` 都信任 +- [ ] Jenkins URL 在系统配置中正确 +- [ ] 关闭匿名访问、关闭 Setup Wizard +- [ ] LDAP 登录成功;至少 3 个测试用户:admin / dev / viewer +- [ ] Role Strategy 矩阵生效(dev 看不到生产 job,viewer 不能 build) +- [ ] CSRF 启用、Agent → Master Access Control 启用 +- [ ] CLI over remoting 已禁用 +- [ ] Webhook 触发成功(push、MR/PR) +- [ ] 至少跑通一个 Hello World Pipeline + 一个 Multibranch Pipeline +- [ ] 一个动态 agent + 一个静态 agent 都能正常构建 +- [ ] Artifact 上传/下载、Test 报告解析正常 +- [ ] Prometheus 指标可见,Grafana 面板出图 +- [ ] 日志进入集中平台(Loki/ELK),可按 build 检索 +- [ ] 备份任务运行,`restic snapshots` 有记录 +- [ ] **执行一次完整恢复演练**(从备份还原到一台干净机器) +- [ ] Vault 集成:一个 Job 用 `withVault` 拿到测试密钥 +- [ ] `jk auth login` + `jk run ls` + `jk run trigger` + `jk run watch` 全部通过 +- [ ] 用户登录后能看到 "我的任务"(个人主页 + My Views + `jk run ls --triggered-by $USER`) +- [ ] 安全扫描:Trivy 镜像、`nikto`/Burp 简扫 Web +- [ ] 文档:`docs/runbook.md` 包含告警处置、备份恢复、版本升级 SOP +- [ ] 应急联系人 / 值班表已就位 + +## 2. 方案 A(阿里云 ACK)专属 + +- [ ] NAS 性能 PVC 实测 IOPS/吞吐达标(建议 ≥ 100 MB/s) +- [ ] cert-manager 自动续期成功(手动触发一次) +- [ ] ACK 节点污点 / 容忍度配置正确,controller 不会被驱逐 +- [ ] 动态 agent Pod 能成功拉取 ACR 镜像(imagePullSecret 已配) +- [ ] NetworkPolicy:agent namespace 只能访问必要外部 +- [ ] PodDisruptionBudget 设置(controller minAvailable=1) +- [ ] 跨 AZ 调度策略生效 +- [ ] OSS 备份桶版本控制 + 跨区复制开启 + +## 3. 方案 B(IDC)专属 + +- [ ] Keepalived VIP 主备切换 < 5s +- [ ] Nginx 配置 `proxy_buffering off`、超时 ≥ 300s(长任务日志流) +- [ ] 主备 `jenkins_home` rsync 一致性校验 +- [ ] 防火墙只放行 443 / 50000 / 22 +- [ ] 物理机 BIOS 时间同步(NTP),否则 LDAP/Kerberos 易失败 +- [ ] 磁盘 SMART 监控、RAID 状态监控接入告警 +- [ ] UPS / 机房断电演练 +- [ ] MinIO 备份桶 versioning + 异地副本 + +## 4. 安全合规 Checklist + +- [ ] 所有密钥不在 Git、不在 JCasC YAML 明文(占位符 + Vault / env) +- [ ] Audit Trail 插件开启,日志保留 ≥ 180 天 +- [ ] 禁止使用 root agent;构建容器 `runAsNonRoot` +- [ ] 镜像扫描通过(Trivy / CNNVD) +- [ ] 插件升级策略:staging 控制器先验证 7 天 +- [ ] 漏洞响应 SLA:高危 ≤ 7 天,中危 ≤ 30 天 +- [ ] 数据分类:构建产物保留期、日志保留期文档化 +- [ ] 访问审计:每季度复核 LDAP 组成员 +- [ ] 备份加密 + 异地副本 +- [ ] 控制器无公网直连;外部访问走 SLB 白名单或零信任网关 + +## 5. 用户体验 Checklist("我的任务") + +- [ ] 登录用户首页能看到 People / Build History 入口 +- [ ] `/user//builds` 能列出本人触发的最近构建 +- [ ] My Views 可创建并保存 +- [ ] `jk run ls --triggered-by $USER` 工作正常 +- [ ] 失败构建邮件 / IM 推送到本人 +- [ ] 个人沙箱文件夹 `users//` 存在且仅本人可见 diff --git a/_posts/jenkins/2026-04-26-jenkins-deploy-aliyun-ack.md b/_posts/jenkins/2026-04-26-jenkins-deploy-aliyun-ack.md new file mode 100644 index 000000000..72ab7c844 --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-deploy-aliyun-ack.md @@ -0,0 +1,276 @@ +--- +layout: post +title: "在阿里云 ACK 上部署 Jenkins 控制器" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, 部署, kubernetes, 阿里云] +description: "基于阿里云 ACK(Kubernetes)部署 Jenkins 控制器与 Agent 的端到端手册。" +slug: jenkins-deploy-aliyun-ack +--- + +适用于中大型团队,已具备阿里云 ACK 集群与基础运维能力。 + +## 1. 前置准备 + +- 已购买并初始化 ACK 标准版集群(≥3 worker,每节点 4C8G 起步)。 +- 已开通: + - **NAS**(RWX 文件系统,给 `jenkins_home`) + - **OSS**(备份桶) + - **ACR**(镜像仓库) + - **SLB**(ALB / NLB) +- 内部 DNS 已配置 `jenkins.corp.example.com → ACK Ingress 入口 IP`。 +- LDAP/AD 信息齐备(见 [design.md §6]({% post_url 2026-04-26-jenkins-design %}))。 +- 本机已配置:`kubectl`、`helm 3.12+`、`阿里云 CLI`(可选)。 + +## 2. 步骤 + +### 2.1 创建 namespace 与基础 Secret + +```bash +kubectl create namespace jenkins +kubectl -n jenkins create secret generic ldap-bind \ + --from-literal=managerDN='cn=jenkins-bind,ou=ServiceAccounts,dc=corp,dc=example,dc=com' \ + --from-literal=managerPassword='REDACTED' +``` + +### 2.2 准备 PVC(绑定 NAS) + +如果集群已自带阿里云 NAS CSI,则只需建 PVC;否则先按官方文档安装 `csi-plugin`。 + +```yaml +# pvc.yaml +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jenkins-home-pvc + namespace: jenkins +spec: + accessModes: [ReadWriteMany] + storageClassName: alibabacloud-cnfs-nas + resources: + requests: + storage: 50Gi +``` + +```bash +kubectl apply -f pvc.yaml +``` + +### 2.3 安装 cert-manager(如未装) + +```bash +helm repo add jetstack https://charts.jetstack.io +helm install cert-manager jetstack/cert-manager \ + -n cert-manager --create-namespace --set installCRDs=true +``` + +为内部 CA 或 Let's Encrypt DNS-01(阿里云 DNS provider)配置 ClusterIssuer: + +```yaml +apiVersion: cert-manager.io/v1 +kind: ClusterIssuer +metadata: + name: letsencrypt-dns +spec: + acme: + email: ops@corp.example.com + server: https://acme-v02.api.letsencrypt.org/directory + privateKeySecretRef: + name: letsencrypt-dns-account + solvers: + - dns01: + webhook: + groupName: cert-manager.alidns.com + solverName: alidns-solver + config: + accessKeyIdRef: { name: alidns-secret, key: accessKey } + accessKeySecretRef: { name: alidns-secret, key: secretKey } +``` + +### 2.4 编写 `values.yaml` + +关键片段(节选;完整请按官方 chart 文档补全): + +```yaml +controller: + image: + tag: lts-jdk21 + jenkinsUrl: https://jenkins.corp.example.com + installPlugins: + - configuration-as-code + - kubernetes + - ldap + - role-strategy + - prometheus + - workflow-aggregator + - git + - pipeline-utility-steps + - audit-trail + - hashicorp-vault-plugin + - job-dsl + - matrix-auth + - blueocean + persistence: + existingClaim: jenkins-home-pvc + ingress: + enabled: true + ingressClassName: nginx + hostName: jenkins.corp.example.com + tls: + - secretName: jenkins-tls + hosts: [jenkins.corp.example.com] + annotations: + cert-manager.io/cluster-issuer: letsencrypt-dns + JCasC: + defaultConfig: true + configScripts: + jenkins-base: | + jenkins: + systemMessage: "Managed by JCasC. Changes via Git PR." + numExecutors: 0 + mode: EXCLUSIVE + authorizationStrategy: + roleBased: + roles: + global: + - name: "admin" + permissions: ["Overall/Administer"] + assignments: ["GROUP:cn=ci-admins,ou=Groups,dc=corp,dc=example,dc=com"] + - name: "viewer" + permissions: ["Overall/Read"] + assignments: ["GROUP:cn=all-staff,ou=Groups,dc=corp,dc=example,dc=com"] + securityRealm: + ldap: + configurations: + - server: "ldaps://ldap.corp.example.com:636" + rootDN: "dc=corp,dc=example,dc=com" + userSearchBase: "ou=Users" + userSearch: "sAMAccountName={0}" + groupSearchBase: "ou=Groups" + groupMembershipStrategy: + fromGroupSearch: + filter: "member={0}" + managerDN: "${LDAP_MANAGER_DN}" + managerPasswordSecret: "${LDAP_MANAGER_PASSWORD}" + unclassified: + location: + url: https://jenkins.corp.example.com + adminAddress: ops@corp.example.com +agent: + enabled: true + podTemplates: + java: | + - name: java + label: linux && java + containers: + - name: jnlp + image: jenkins/inbound-agent:latest + - name: maven + image: cr.cn-hangzhou.aliyuncs.com/devops/maven:3.9-jdk21 + command: ["sleep"] + args: ["999d"] +serviceAccount: + create: true +``` + +将 LDAP secret 通过 envFrom 注入: + +```yaml +controller: + containerEnvFrom: + - secretRef: + name: ldap-bind +``` + +### 2.5 安装 + +```bash +helm repo add jenkins https://charts.jenkins.io +helm upgrade --install jenkins jenkins/jenkins -n jenkins -f values.yaml +``` + +等待就绪: + +```bash +kubectl -n jenkins rollout status sts/jenkins +kubectl -n jenkins get pods,svc,ingress +``` + +### 2.6 首次验证 + +```bash +curl -I https://jenkins.corp.example.com/login +# 期望 200 / 403(取决于匿名策略) +``` + +浏览器打开 `https://jenkins.corp.example.com`,使用 LDAP 账号登录。 + +### 2.7 接入 Webhook、Vault、监控、备份 + +- **Webhook**:GitLab/Bitbucket → Jenkins URL `/multibranch-webhook-trigger/...` 或插件提供的 endpoint。 +- **Vault**:系统配置 → Vault URL + AppRole;Pipeline 用 `withVault {}`。 +- **监控**:Prometheus Operator 加 ServiceMonitor 抓 `/prometheus`,导入 Grafana Dashboard ID 9964。 +- **备份**: + +```yaml +# backup-cronjob.yaml(节选) +apiVersion: batch/v1 +kind: CronJob +metadata: { name: jenkins-backup, namespace: jenkins } +spec: + schedule: "0 2 * * *" + jobTemplate: + spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: restic + image: restic/restic:latest + env: + - name: RESTIC_REPOSITORY + value: "s3:https://oss-cn-hangzhou-internal.aliyuncs.com/company-jenkins-backup" + - name: RESTIC_PASSWORD + valueFrom: { secretKeyRef: { name: restic-secret, key: password } } + - name: AWS_ACCESS_KEY_ID + valueFrom: { secretKeyRef: { name: oss-secret, key: ak } } + - name: AWS_SECRET_ACCESS_KEY + valueFrom: { secretKeyRef: { name: oss-secret, key: sk } } + command: ["sh","-c"] + args: + - | + restic backup /jenkins_home --exclude='workspace/*' --exclude='caches/*' && \ + restic forget --keep-daily 14 --keep-weekly 8 --prune + volumeMounts: + - { name: home, mountPath: /jenkins_home, readOnly: true } + volumes: + - name: home + persistentVolumeClaim: { claimName: jenkins-home-pvc } +``` + +### 2.8 `jk` 客户端验证 + +```bash +jk auth login --server https://jenkins.corp.example.com --user $USER --token +jk context list +jk job ls +jk run ls --since 24h +``` + +## 3. 升级 / 回滚 + +```bash +# 升级(先在 staging 验证) +helm upgrade jenkins jenkins/jenkins -n jenkins -f values.yaml + +# 回滚 +helm rollback jenkins -n jenkins +``` + +## 4. 灾难恢复演练(每季度) + +1. 创建一个 `jenkins-dr` namespace。 +2. 用最新备份在临时 PVC 中 `restic restore`。 +3. Helm install 同样的 chart,挂临时 PVC。 +4. 校验 LDAP 登录、关键 job 列表、最近一次构建可重跑。 +5. 销毁该 namespace。 diff --git a/_posts/jenkins/2026-04-26-jenkins-deploy-idc-lan.md b/_posts/jenkins/2026-04-26-jenkins-deploy-idc-lan.md new file mode 100644 index 000000000..111e6c393 --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-deploy-idc-lan.md @@ -0,0 +1,273 @@ +--- +layout: post +title: "在本地 IDC 局域网部署 Jenkins(VM + Docker Compose)" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, 部署, docker, idc] +description: "基于 VM + Docker Compose 在本地 IDC 落地 Jenkins 控制器的实践手册。" +slug: jenkins-deploy-idc-lan +--- + +适用于小到中型团队、传统机房环境,或对 K8s 暂无运维能力的场景。 + +## 1. 前置准备 + +- 2 台 VM 给控制器(主+冷备),4C8G + 200GB SSD 起步,Ubuntu 22.04 LTS。 +- N 台 VM/物理机做 agent,按工种打标签:`linux`, `windows`, `gpu`, `build-heavy`。 +- 1 台 VM(或与 LB 复用)做 Nginx + Keepalived。 +- 内部 DNS:`jenkins.idc.local → 10.0.1.20`(VIP)。 +- 内部 CA 已能签发证书,或 step-ca 已部署。 +- LDAP 信息齐备。 +- MinIO 或现有 S3 可作为备份目的地。 + +## 2. 控制器主机(10.0.1.10) + +### 2.1 系统基线 + +```bash +sudo apt update +sudo apt install -y docker.io docker-compose-plugin ufw fail2ban unattended-upgrades +sudo systemctl enable --now docker +sudo ufw allow 22/tcp +sudo ufw allow from 10.0.10.0/24 to any port 50000 proto tcp +sudo ufw enable +``` + +### 2.2 目录与挂载 + +```bash +sudo mkdir -p /srv/jenkins/{home,casc,backup} +sudo chown -R 1000:1000 /srv/jenkins/home /srv/jenkins/casc +``` + +### 2.3 `/srv/jenkins/docker-compose.yml` + +```yaml +services: + jenkins: + image: jenkins/jenkins:lts-jdk21 + container_name: jenkins + restart: unless-stopped + user: "1000:1000" + environment: + JAVA_OPTS: "-Djenkins.install.runSetupWizard=false -Dhudson.model.DirectoryBrowserSupport.CSP=\"sandbox; default-src 'self'\"" + CASC_JENKINS_CONFIG: /var/jenkins_casc + volumes: + - /srv/jenkins/home:/var/jenkins_home + - /srv/jenkins/casc:/var/jenkins_casc:ro + ports: + - "127.0.0.1:8080:8080" # 仅本机,由 nginx 反代 + - "10.0.1.10:50000:50000" # JNLP 仅监听内网 IP +``` + +### 2.4 JCasC:`/srv/jenkins/casc/jenkins.yaml` + +```yaml +jenkins: + systemMessage: "Managed by JCasC. Changes via Git PR." + numExecutors: 0 + mode: EXCLUSIVE + authorizationStrategy: + roleBased: + roles: + global: + - name: "admin" + permissions: ["Overall/Administer"] + assignments: ["GROUP:cn=ci-admins,ou=Groups,dc=corp,dc=example,dc=com"] + - name: "viewer" + permissions: ["Overall/Read"] + assignments: ["GROUP:cn=all-staff,ou=Groups,dc=corp,dc=example,dc=com"] + securityRealm: + ldap: + configurations: + - server: "ldaps://ldap.corp.example.com:636" + rootDN: "dc=corp,dc=example,dc=com" + userSearchBase: "ou=Users" + userSearch: "sAMAccountName={0}" + groupSearchBase: "ou=Groups" + managerDN: "cn=jenkins-bind,ou=ServiceAccounts,dc=corp,dc=example,dc=com" + managerPasswordSecret: "${LDAP_MANAGER_PASSWORD}" +unclassified: + location: + url: https://jenkins.idc.local + adminAddress: ops@corp.example.com +``` + +把 `LDAP_MANAGER_PASSWORD` 等敏感值放到 `/srv/jenkins/casc/secrets.env`,由 docker-compose `env_file` 注入,并 `chmod 600`。 + +### 2.5 启动并初始化 + +```bash +cd /srv/jenkins +docker compose up -d +docker compose logs -f jenkins # 等到 "Jenkins is fully up and running" +``` + +## 3. 反向代理 + VIP(LB 节点) + +### 3.1 安装 + +```bash +sudo apt install -y nginx keepalived +``` + +### 3.2 `/etc/nginx/sites-available/jenkins.conf` + +```nginx +upstream jenkins_upstream { + server 10.0.1.10:8080 max_fails=3 fail_timeout=10s; + # 备机:手动切换时启用 + # server 10.0.1.11:8080 backup; + keepalive 32; +} + +server { + listen 80; + server_name jenkins.idc.local; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name jenkins.idc.local; + + ssl_certificate /etc/ssl/jenkins/fullchain.pem; + ssl_certificate_key /etc/ssl/jenkins/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 200m; + proxy_buffering off; + proxy_request_buffering off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + + location / { + proxy_pass http://jenkins_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + } +} +``` + +```bash +sudo ln -s /etc/nginx/sites-available/jenkins.conf /etc/nginx/sites-enabled/ +sudo nginx -t && sudo systemctl reload nginx +``` + +### 3.3 `/etc/keepalived/keepalived.conf`(主) + +```conf +vrrp_instance VI_1 { + state MASTER + interface eth0 + virtual_router_id 51 + priority 110 + advert_int 1 + authentication { auth_type PASS; auth_pass changeMe; } + virtual_ipaddress { 10.0.1.20/24 } +} +``` + +备机 `state BACKUP`、`priority 100`。 + +```bash +sudo systemctl enable --now keepalived +``` + +## 4. Agent 接入 + +### 4.1 Linux SSH agent + +控制器 UI → Manage Nodes → New Node → Permanent Agent;或在 JCasC 中声明: + +```yaml +jenkins: + nodes: + - permanent: + name: "agent-linux-01" + labelString: "linux build-heavy" + remoteFS: "/var/lib/jenkins" + launcher: + ssh: + host: "10.0.10.11" + port: 22 + credentialsId: "ssh-agent-key" +``` + +### 4.2 Windows JNLP agent + +1. 在 Jenkins 上创建 inbound agent(`agent-win-01`,标签 `windows`)。 +2. 在 Windows 上以服务方式启动 `agent.jar`,连接 `https://jenkins.idc.local`,端口 50000(需在 LB/防火墙放行 agent 网段到 controller 50000)。 + +> JNLP 50000 端口**不要**经过 nginx,agent 直接连控制器内网 IP(`10.0.1.10:50000`)。 + +## 5. 备份 + +控制器 crontab: + +```bash +0 2 * * * /usr/local/bin/restic-backup.sh >> /var/log/jenkins-backup.log 2>&1 +``` + +`/usr/local/bin/restic-backup.sh`: + +```bash +#!/bin/bash +set -euo pipefail +export RESTIC_REPOSITORY="s3:http://minio.idc.local/jenkins-backup" +export RESTIC_PASSWORD_FILE=/etc/restic/password +export AWS_ACCESS_KEY_ID=$(cat /etc/restic/ak) +export AWS_SECRET_ACCESS_KEY=$(cat /etc/restic/sk) + +restic backup /srv/jenkins/home \ + --exclude='workspace/*' \ + --exclude='caches/*' \ + --exclude='*.log' \ + --tag jenkins-home + +restic forget --keep-daily 14 --keep-weekly 8 --keep-monthly 12 --prune +``` + +`/etc/restic/*` 权限 `600`,属主 root。 + +## 6. 冷备机与切换演练 + +- 备机用同一份 `docker-compose.yml`,**先不启动**。 +- 主控制器 cron 每 30 分钟把 `/srv/jenkins/home` rsync 到备机: + + ```bash + */30 * * * * rsync -aH --delete --exclude='workspace/' --exclude='caches/' \ + /srv/jenkins/home/ jenkins-standby:/srv/jenkins/home/ + ``` + +- 演练:停主 → nginx upstream 改 backup → 备机 `docker compose up -d` → 验证。 + +## 7. 监控 / 日志 + +- Jenkins 安装 Prometheus 插件,Prometheus scrape `https://jenkins.idc.local/prometheus`(带 token)。 +- node_exporter 部署到所有 VM。 +- Filebeat 采集 `/srv/jenkins/home/logs/`、`/var/log/nginx/` 推到 Loki。 +- Grafana 导入:Jenkins (9964)、Node Exporter (1860)、Nginx (12708)。 + +## 8. `jk` 客户端验证 + +```bash +jk auth login --server https://jenkins.idc.local --user $USER --token +jk job ls +jk run ls --since 24h +jk run trigger smoke-test --watch +``` + +## 9. 故障排查速查 + +| 现象 | 排查点 | +|---|---| +| 浏览器证书报错 | 内部 CA 是否分发;证书 SAN 是否含 `jenkins.idc.local` | +| LDAP 登录失败 | `ldapsearch -H ldaps://... -D -W -b '(sAMAccountName=alice)'` | +| Webhook 不触发 | 反代是否屏蔽了源 IP;Jenkins 收到的 X-Forwarded-For 是否正确 | +| Agent 掉线 | 50000 端口、controller 与 agent 时间同步、JNLP secret 是否正确 | +| 构建慢 | 检查磁盘 IOPS、`workspace` 是否在 SSD、是否被 swap | diff --git a/_posts/jenkins/2026-04-26-jenkins-deployment-overview.md b/_posts/jenkins/2026-04-26-jenkins-deployment-overview.md new file mode 100644 index 000000000..6adb4d230 --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-deployment-overview.md @@ -0,0 +1,36 @@ +--- +layout: post +title: "Jenkins + jk 平台部署文档总览" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, 部署, 总览] +description: "Jenkins 控制器 + jk 客户端 落地参考的入口与文档地图。" +slug: jenkins-deployment-overview +--- + +本目录是一份完整的 **Jenkins 控制器 + `jk` 客户端** 落地参考,目标读者: + +- 平台 / SRE 工程师:负责搭建与运维。 +- 应用开发者:通过 `jk` CLI 与 Web UI 使用 Jenkins。 +- 安全 / 合规:审计访问、密钥、备份、补丁。 + +## 文档地图 + +| 文件 | 用途 | +|---|---| +| [`proxy-and-lb-primer.md`]({% post_url 2026-04-26-jenkins-proxy-and-lb-primer %}) | 5 分钟入门:VIP / L4 LB / L7 反向代理(含 Nginx 原理速记) | +| [`topology.md`]({% post_url 2026-04-26-jenkins-topology %}) | 拓扑文档(通用逻辑拓扑、阿里云 ACK、本地 IDC) | +| [`design.md`]({% post_url 2026-04-26-jenkins-design %}) | 设计文档(HA / 权限 / 备份 / 网络 / 合规 / LDAP) | +| [`deploy-aliyun-ack.md`]({% post_url 2026-04-26-jenkins-deploy-aliyun-ack %}) | 阿里云 ACK(K8s)部署手册 | +| [`deploy-idc-lan.md`]({% post_url 2026-04-26-jenkins-deploy-idc-lan %}) | 本地 IDC 局域网(VM + Docker Compose)部署手册 | +| [`checklist.md`]({% post_url 2026-04-26-jenkins-checklist %}) | 上线 / 安全 / 合规 检查清单 | +| [`intake-template.yaml`](/assets/jenkins/intake-template.yaml) | 需要用户/平台方提供的信息模板 | +| [`faq.md`]({% post_url 2026-04-26-jenkins-faq %}) | 常见问题:域名 vs IP、是否需要外置 DB、用户能看自己的任务吗 | + +## 快速决策 + +- **中大型团队、已有 K8s** → 用 [阿里云 ACK 方案]({% post_url 2026-04-26-jenkins-deploy-aliyun-ack %})。 +- **小团队、传统机房、无 K8s 经验** → 用 [本地 IDC 方案]({% post_url 2026-04-26-jenkins-deploy-idc-lan %})。 +- **PoC / 临时环境** → 直接 IDC 单 VM + Docker Compose 起步,后续再迁移。 + +> 落地前请先填好 [`intake-template.yaml`](/assets/jenkins/intake-template.yaml) 中的字段,避免反复返工。 diff --git a/_posts/jenkins/2026-04-26-jenkins-design.md b/_posts/jenkins/2026-04-26-jenkins-design.md new file mode 100644 index 000000000..622bc1c20 --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-design.md @@ -0,0 +1,203 @@ +--- +layout: post +title: "Jenkins 平台设计:HA / 权限 / 备份 / 网络 / 合规 / LDAP" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, 设计, HA, LDAP] +description: "Jenkins 平台关键设计决策:高可用、RBAC、备份恢复、网络分层、合规与 LDAP 接入。" +slug: jenkins-design +--- + +## 1. 设计目标与非目标 + +### 目标 +- 工程师可通过浏览器与 `jk` CLI 安全访问 Jenkins。 +- **配置即代码**(JCasC + Jenkinsfile + Job DSL),可重建、可审计。 +- 故障 30 分钟内恢复(RTO=30 min,RPO=24 h;可按需缩短至 1 h)。 +- 所有访问走 TLS、走 LDAP 认证、走 RBAC 授权。 +- 密钥不落明文。 +- 用户登录后能直观看到"自己的任务"。 + +### 非目标 +- 不追求 active-active 多活(OSS Jenkins 不支持,需 CloudBees CI)。 +- 不替代企业级 DevOps 平台(Jenkins X、Argo Workflows 等)。 + +## 2. 组件选型 + +| 类别 | 选型 | 备注 | +|---|---|---| +| 控制器镜像 | `jenkins/jenkins:lts-jdk21` | LTS 长期支持 | +| 安装方式(A) | Helm chart `jenkins/jenkins` | 官方维护 | +| 安装方式(B) | Docker Compose + systemd | 简单可控 | +| 反向代理(A) | ingress-nginx + cert-manager | K8s 生态标配 | +| 反向代理(B) | Nginx + Keepalived | 成熟稳定 | +| 存储(A) | 阿里云 NAS(RWX) | Pod 重建不丢数据 | +| 存储(B) | 本地 SSD + NFS/rsync 同步到备机 | 性能优先 | +| 认证 | LDAP(AD 也走 LDAP 协议) | 接公司目录 | +| 授权 | Role-based Authorization Strategy | 基于 LDAP group | +| 配置 | JCasC + Job DSL | YAML 入 Git | +| 密钥 | Jenkins Credentials + Vault 插件 | 二级保护 | +| 动态 Agent(A) | Kubernetes plugin | Pod 模板 | +| 静态 Agent(B) | SSH Build Agents 插件 | inbound JNLP 也支持 | +| 监控 | Prometheus 插件 + Grafana | metrics endpoint | +| 日志 | Filebeat → Loki/ELK | 集中查询 | +| 备份 | restic + 对象存储 | 增量、加密 | +| 备份内容 | `$JENKINS_HOME` 全量(排除 `workspace/`、`caches/`) | 关键是 `jobs/`、`users/`、`secrets/`、`credentials.xml` | + +## 3. 是否需要外置数据库? + +**结论:Jenkins 核心不需要外置 DB,绝大多数场景文件存储就够。** 以下情况才需要: + +| 场景 | 是否需要外置 DB | 推荐方案 | +|---|---|---| +| 默认部署(构建/任务/用户/凭据) | **否** | 全部在 `$JENKINS_HOME` 文件存储 | +| 长期审计日志、合规要求 | 推荐 | Audit Trail 插件 → 写入 ELK / RDS PostgreSQL | +| 大规模历史构建检索(百万级) | 推荐 | 外置 ELK/OpenSearch 索引构建数据 | +| 用户自定义 Dashboard / BI | 视情况 | Grafana + 外置 Postgres | +| 插件依赖(Database / PostgreSQL plugin) | 看插件 | 外置 RDS PostgreSQL | +| `jk` 服务端聚合层(如做多控制器聚合视图) | 是 | 外置 Postgres,仅放聚合元数据 | + +设计原则: +1. **`jenkins_home` 是事实唯一真相**,外置 DB 只做"分析/审计/聚合"二级用途,不替代主存储。 +2. 上 RDS 时,强烈建议放在与 Jenkins 同 VPC,启用备份与多可用区。 +3. 外置 DB 不存任何凭据明文(凭据仍走 Vault + Jenkins Credentials)。 + +> 如果你后续要做"多 Jenkins 集群统一视图""跨控制器聚合查询",那 **聚合服务**(不是 Jenkins 本身)会需要一个外置 Postgres / ClickHouse。详见第 8 节"`jk` 服务端扩展(可选)"。 + +## 4. 用户视角:"我的任务"如何呈现 + +### 4.1 Jenkins 原生能力 + +Jenkins 已内置以下机制,让登录用户聚焦自己的任务: + +1. **People → 用户主页**:列出该用户**触发过**或**关联**的所有构建(`/user//builds`)。 +2. **Build History 全局视图**:可按 Filter By Status / Cause 筛选 "Started by me"。 +3. **My Views**:每个用户可创建私有视图,过滤自己关心的 job(如按文件夹、按正则、按标签)。 +4. **Pipeline Stage View / Blue Ocean**:登录后默认聚焦自己最近的运行。 +5. **通知**:邮件、Slack、企业微信、钉钉插件,把"自己的构建结果"主动推给本人。 + +### 4.2 通过 RBAC 让"看到的就是自己的" + +权限模型决定了"用户能看到什么"。我们采用 **Folder + Role Strategy** 双层: + +- 顶层文件夹 = 团队/产品(如 `team-payments/`、`team-search/`)。 +- 文件夹角色:`folder-developer` / `folder-release` / `folder-viewer`,授权给对应 LDAP 组。 +- 全局角色:`overall-read` 给 `cn=all-staff`(仅能看登录页与全局列表);`admin` 给 `cn=ci-admins`。 +- 个人 Job(用户自己的实验性 job)放在 `users//` 文件夹下,仅本人可见 + 管理员只读。 + +效果: + +- 普通开发:登录 → 看到自己团队 + 自己个人文件夹下的 job。 +- Release 工程师:额外看到生产 job。 +- Admin:全局可见。 + +### 4.3 `jk` CLI 的"我的任务"体验 + +`jk` 客户端可以直接利用 Jenkins API 实现 "my view": + +```bash +# 我作为触发者的最近构建 +jk run ls --triggered-by $USER --since 7d --json + +# 我有权限的所有 job +jk job ls --mine --json + +# 给"我"建一个个人 dashboard 文件夹(首次登录引导) +jk folder ensure users/$USER --owner $USER +``` + +> 这部分能力部分已在 `jk` 中实现(参见 [`docs/spec.md`](../spec.md)、[`docs/api.md`](../api.md)),未实现的可按需补 issue。 + +## 5. 高可用与容灾 + +| 项 | 方案 A(ACK) | 方案 B(IDC) | +|---|---|---| +| 控制器副本 | StatefulSet replicas=1(K8s 自动重拉) | 主+冷备 VM,Keepalived VIP | +| 存储 | NAS 多可用区(按订购规格) | RAID10 + 每日 NFS/rsync 同步到备机 | +| 备份 | OSS 跨区域复制,保留 30 天 | restic → MinIO,保留 30 天,月度异地 | +| RTO | ≤ 15 min | ≤ 30 min | +| RPO | ≤ 1 h(可缩短至 15 min) | ≤ 24 h(可缩短至 1 h) | +| 演练 | 每季度一次"删 namespace 重建" | 每季度一次"备机接管" | + +## 6. 认证与授权设计(LDAP) + +**LDAP 接入参数**(向公司目录管理员索取,并填入 [`intake-template.yaml`](/assets/jenkins/intake-template.yaml)): + +- LDAP Server URL:`ldaps://ldap.corp.example.com:636`(强烈建议 ldaps) +- Root DN:`dc=corp,dc=example,dc=com` +- User search base:`ou=Users,dc=corp,dc=example,dc=com` +- User search filter:`sAMAccountName={0}`(AD)或 `uid={0}`(OpenLDAP) +- Group search base:`ou=Groups,dc=corp,dc=example,dc=com` +- Group membership filter:`member={0}` +- Manager DN:例 `cn=jenkins-bind,ou=ServiceAccounts,...` +- Manager Password:放进 Jenkins Credentials / Vault,**不写死 YAML** + +**授权矩阵(Role Strategy)** + +| 角色 | LDAP 组 | 权限范围 | +|---|---|---| +| `admin` | `cn=ci-admins` | 全权限 | +| `developer` | `cn=ci-developers` | 自己团队 Folder Read/Build/Cancel/Workspace | +| `release` | `cn=ci-release` | 生产 Folder 触发 + 凭据使用 | +| `viewer` | `cn=all-staff` | 全只读(含日志、artifact) | +| 匿名 | — | 拒绝(关闭匿名) | + +文件夹级权限用 **Folder-based Authorization**:每个产品/团队一个 Folder,组授权到 Folder。 + +## 7. 网络与安全 + +- 控制器**不开公网**;公网访问统一走 SLB + 白名单 / 零信任网关。 +- **仅放行**:443 (HTTPS), 50000 (JNLP,仅内网/agent 网段)。 +- Jenkins 系统配置: + - 启用 CSRF、启用 Agent → Master Access Control。 + - 禁用 CLI over remoting,仅保留 SSH/HTTP CLI(`jk` 用 REST)。 + - "Jenkins URL" 必须设置成 `https://jenkins.corp.example.com`。 +- Pod/Agent 隔离:动态 agent 用独立 namespace + NetworkPolicy。 +- 镜像来源:所有 builder 镜像走内部 ACR/Harbor,扫描通过才允许。 +- Audit Trail 插件:日志归档 ≥ 180 天。 + +## 8. `jk` 客户端分发设计 + +- 二进制托管:内部 Nexus raw repo / OSS Bucket / 内部 Homebrew tap。 +- 版本:跟随上游 Release,内部冒烟通过后推 stable。 +- 工程师初始化: + + ```bash + jk auth login --server https://jenkins.corp.example.com \ + --user $USER --token <从 Jenkins UI 生成> + jk context list + ``` + +- 多环境:`jk context add prod ...`、`jk context add staging ...`,CI 脚本里 `JK_CONTEXT=prod`。 +- 机器人账号:在 Jenkins 建 `bot-ci` 用户,token 存 Vault,CI runner 启动时注入 `JK_TOKEN`。 +- **强制 TLS**:分发文档里写明必须用 https URL;不接受裸 IP/HTTP。 + +### 8.1 `jk` 服务端扩展(可选,未来路线) + +如果未来要做"跨多控制器统一视图""统计大盘""我的任务全局聚合",可以加一层 **`jk-server`**: + +``` +┌────────┐ ┌──────────────┐ ┌──────────┐ +│ jk CLI │──▶│ jk-server │──▶│ Jenkins A│ +└────────┘ │ (Go, REST) │──▶│ Jenkins B│ + │ + Postgres │ └──────────┘ + └──────────────┘ +``` + +只有这个聚合层才需要外置 DB(Postgres / ClickHouse),Jenkins 控制器本身仍维持文件存储。 + +## 9. 备份与恢复 + +- 工具:`restic`(增量、加密、去重)。 +- 内容:`$JENKINS_HOME` 排除 `workspace/`、`caches/`、`tmp/`、`*.log`。 +- 频率:方案 A 每小时增量;方案 B 每日全量 + 每小时 jobs 目录增量。 +- 保留:日 14、周 8、月 12,月度副本异地。 +- 加密:restic repo password + 对象存储 SSE。 +- **每季度演练**:拉一台干净机器,从备份还原,启动后冒烟测试。 + +## 10. 升级与变更管理 + +- 控制器:每月跟 LTS minor,先在 staging 控制器跑 7 天。 +- 插件:每两周一次集中升级窗口;高危 CVE 走紧急流程。 +- 变更全部走 Git PR(JCasC、Jobfile、Helm values),CI 自动校验 YAML schema。 +- 变更窗口:周六 22:00-24:00 CST(按企业策略调整)。 diff --git a/_posts/jenkins/2026-04-26-jenkins-faq.md b/_posts/jenkins/2026-04-26-jenkins-faq.md new file mode 100644 index 000000000..7a76c450d --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-faq.md @@ -0,0 +1,124 @@ +--- +layout: post +title: "Jenkins 部署 FAQ:域名 vs IP、外置 DB、任务可见性" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, faq] +description: "Jenkins 部署阶段最常被问到的几个问题与建议答案。" +slug: jenkins-faq +--- + +> 如果你对 **VIP / 负载均衡 / 反向代理** 这些术语还不熟,建议先读 [`proxy-and-lb-primer.md`]({% post_url 2026-04-26-jenkins-proxy-and-lb-primer %}),再回来看本 FAQ。 + +## Q1. 公司内部能用 IP 代替域名吗? + +**能用,但强烈建议域名(哪怕是内部域名)**。详细对比: + +| 维度 | 用 IP | 用域名(推荐) | +|---|---|---| +| HTTPS 证书 | 需自签 IP SAN 证书,浏览器/curl/`jk` 都要手动信任 | 内部 CA 或 ACME 可签,统一信任链 | +| 反向代理 / Ingress | Nginx Ingress、K8s Service 通常依赖 Host header | 原生支持 | +| 迁移 / 扩容 | 控制器换机器要改所有客户端配置 | 改 DNS A 记录即可 | +| Jenkins URL | "Jenkins URL" 配成 IP 后 webhook、邮件、Blue Ocean 链接全部写死 | 平滑 | +| LDAP / SSO 回调 | 部分 IdP 校验 host,IP 经常被拒 | 兼容性好 | +| 审计、合规 | IP 没语义,难追溯 | 命名清晰 | + +**最低成本方案**:在公司 DNS(CoreDNS / AD DNS / 路由器)加一条 A 记录: + +- `jenkins.corp.example.com → 10.10.20.5` +- `jenkins.idc.local → 10.0.1.10`(连内部域名都没有就用 `.local` / `.lan`) + +如果完全没有 DNS 控制权,临时: + +- `/etc/hosts` 写死(仅 PoC)。 +- 或起一个 dnsmasq 容器作为内部 DNS。 + +证书方面:内部建议搭一个 [smallstep `step-ca`](https://smallstep.com/docs/step-ca/) 半小时即可; +或用 Let's Encrypt **DNS-01 challenge**,即使域名不对外解析也能签证书(只要能控制 DNS TXT)。 + +--- + +## Q2. 是否一定需要外置数据库? + +**不一定。Jenkins 核心使用 `$JENKINS_HOME` 文件存储,多数场景不需要外置 DB**。 + +什么时候才考虑外置 DB: + +| 场景 | 是否需要 | 推荐方案 | +|---|---|---| +| 默认部署(构建/任务/用户/凭据) | **不需要** | 全部在 `$JENKINS_HOME` 下文件存储 | +| 长期审计日志、合规要求 | 推荐 | Audit Trail 插件 → ELK / 外置 PostgreSQL | +| 大规模历史构建检索(百万级) | 推荐 | 外置 ELK / OpenSearch 索引 | +| 用户自定义 Dashboard / BI | 视情况 | Grafana + 外置 Postgres | +| 某些插件依赖(如 Database 插件) | 看插件 | 外置 RDS PostgreSQL | +| `jk` 服务端聚合多控制器 | 是 | 外置 Postgres / ClickHouse 仅放聚合元数据 | + +设计原则: + +1. `jenkins_home` 是事实唯一真相;外置 DB 只做"分析/审计/聚合"二级用途。 +2. 外置 DB **不存任何凭据明文**,凭据仍走 Vault + Jenkins Credentials。 +3. 上 RDS 时,与 Jenkins 同 VPC,启用自动备份与多可用区。 + +> 简而言之:**起步阶段不要外置 DB,复杂了再加**。先把备份做扎实,比上 DB 更重要。 + +--- + +## Q3. 用户登录后能不能直接看到"自己的所有任务"? + +**可以,组合使用以下机制即可**: + +### 3.1 Jenkins 原生 + +1. **People → 用户主页** `/user//builds`:列出该用户**触发过**或**关联**的最近构建。 +2. **Build History 全局视图**:可按 "Started by me" 过滤。 +3. **My Views**:每个用户可创建私有视图(按 Folder / 正则 / 标签过滤)。 +4. **Pipeline Stage View / Blue Ocean**:登录后默认聚焦自己最近的运行。 +5. **通知**:邮件 / Slack / 企业微信 / 钉钉,把自己的构建结果主动推给本人。 + +### 3.2 RBAC 让"看到的就是自己的" + +我们采用 **Folder + Role Strategy** 双层: + +- 顶层文件夹 = 团队 / 产品(如 `team-payments/`、`team-search/`)。 +- 文件夹角色:`folder-developer` / `folder-release` / `folder-viewer`,授权给对应 LDAP 组。 +- 全局角色:`overall-read` 给 `cn=all-staff`;`admin` 给 `cn=ci-admins`。 +- 个人沙箱:`users//` 文件夹,仅本人可见 + 管理员只读。 + +效果: + +- 普通开发:登录后看到自己团队 + 个人沙箱的 job。 +- Release 工程师:额外看到生产 job。 +- Admin:全局可见。 + +### 3.3 `jk` CLI 视角 + +```bash +# 我作为触发者的最近构建 +jk run ls --triggered-by $USER --since 7d --json + +# 我有权限看到的所有 job +jk job ls --mine --json + +# 关注的多个 job 在一个面板里看 +jk run ls --job-glob 'team-payments/*' --filter result=FAILURE --since 24h +``` + +> 个别选项(如 `--mine`、`--triggered-by`)若当前版本未实现,可以提 issue / 通过 `--filter` 配合 `cause.userId=...` 实现等价效果。 + +--- + +## Q4. 一定要用 K8s 吗?小团队不想搞 K8s 怎么办? + +不必。**< 50 人团队**完全可以用 [`deploy-idc-lan.md`]({% post_url 2026-04-26-jenkins-deploy-idc-lan %}) 的"单 VM + Docker Compose + 静态 agent"方案,运维成本最低,恢复也最简单(拷贝 `jenkins_home` + 起容器即可)。 + +**> 50 人或并发构建 ≥ 30** 时,K8s 的弹性 Pod agent 优势开始显著,再迁移到 [`deploy-aliyun-ack.md`]({% post_url 2026-04-26-jenkins-deploy-aliyun-ack %})。 + +--- + +## Q5. Jenkins 能做真正的双活 HA 吗? + +**OSS Jenkins 不支持 active-active**。controller 是有状态、单实例的设计。可选: + +- **冷备**(本文采用):备机持续同步 `jenkins_home`,故障时切 VIP,分钟级 RTO。 +- **CloudBees CI**(商业):支持 controller 集群 + Operations Center。 +- **拆分小集群**:按团队/产品拆多个独立 controller,降低单点爆炸半径。 diff --git a/_posts/jenkins/2026-04-26-jenkins-proxy-and-lb-primer.md b/_posts/jenkins/2026-04-26-jenkins-proxy-and-lb-primer.md new file mode 100644 index 000000000..c196480a2 --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-proxy-and-lb-primer.md @@ -0,0 +1,410 @@ +--- +layout: post +title: "代理与负载均衡入门:VIP / L4 / L7 / Nginx 速记" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, 网络, 负载均衡, nginx] +description: "5 分钟入门 VIP、L4 LB、L7 反向代理与 Nginx 在 Jenkins 接入侧的角色。" +slug: jenkins-proxy-and-lb-primer +--- + +> 面向第一次接手平台部署的同学:**5 分钟读完、能看懂拓扑图、能和 SRE 对得上术语**。 +> 想直接落地配置可继续看 [`deploy-idc-lan.md`]({% post_url 2026-04-26-jenkins-deploy-idc-lan %}) 与 [`deploy-aliyun-ack.md`]({% post_url 2026-04-26-jenkins-deploy-aliyun-ack %});本篇只讲概念和原理。 +> +> 想专门深入 **Nginx 反向代理**(进程模型、11 个请求阶段、端到端时序、reload 不掉连接的原理)请直接跳到 [§7.1 Nginx 反向代理详解](#71-nginx-反向代理详解进程模型请求阶段时序)。 + +--- + +## 1. 一句话区分三个最容易混的词 + +| 名词 | 一句话定义 | 工作在哪一层 | 典型形态 | +|---|---|---|---| +| **VIP**(Virtual IP,虚拟 IP) | 一个**不绑定单台物理机**的 IP,可以在多台机器之间漂移 | L3(IP 层) | Keepalived/VRRP、云厂商 SLB 的前端 IP、K8s `LoadBalancer` Service 暴露的 IP | +| **LB**(Load Balancer,负载均衡) | 把流量按某种策略**分发到多个后端**的设备/服务 | L4(TCP/UDP)或 L7(HTTP)都可以 | LVS、HAProxy、F5、阿里云 SLB/CLB/NLB/ALB、Nginx、K8s Service | +| **反向代理**(Reverse Proxy) | **代表后端**接收客户端请求,再转发给真正的后端,并把响应回带 | L7(应用协议,最常见是 HTTP) | Nginx、Traefik、Envoy、Apache、Caddy、Ingress Controller | + +**关系记忆口诀**: + +> **VIP 是一个"地址",LB 是一个"动作",反向代理是 LB 的一种 L7 实现。** +> 三者经常**叠在一起出现**:VIP 指向 LB,LB 后面挂反向代理,反向代理再分发到 Jenkins。 + +--- + +## 2. 它们在 Jenkins 部署中分别解决什么问题 + +``` + ┌───────── 问题 ─────────┐ + VIP → 单点故障:LB 机器挂了客户端 DNS 不变也能切 + LB (L4) → 并发/吞吐:把 TCP 连接分摊到多台 + 反向代理 (L7) → HTTPS 终结、Host 路由、鉴权、限流、改 header、缓存 + └────────────────────────┘ +``` + +具体到 `jenkins-cli` 项目: +- **`jk` CLI / 浏览器** 把请求发到一个稳定入口(VIP 或域名)。 +- 入口背后是 **L4 LB / L7 反代**,做 TLS 终结 + 把流量打到 **Jenkins controller**(HA 时是 active-passive 一对,活动那台才接流量)。 +- 反代会注入 `X-Forwarded-For` / `X-Forwarded-Proto`,让 Jenkins 能拿到真实客户端 IP 与原始 scheme。 + +--- + +## 3. 数据面的流向(从客户端到 Jenkins) + +``` + jk / 浏览器 + │ + │ ① DNS 解析 ci.corp.example.com → VIP (10.10.20.100) + ▼ + ┌──────────── VIP (虚拟 IP) ────────────┐ + │ 漂在 LB-A / LB-B 两台机器之上 │ ← Keepalived/VRRP 或云厂商托管 + │ 谁是 MASTER 谁就回应 ARP │ + └──────────────────┬────────────────────┘ + │ ② TCP 到达 LB + ▼ + ┌───── L4 LB (可选)─────┐ + │ 看 IP+端口,按四元组哈希 │ ← LVS / NLB / HAProxy(mode tcp) + │ **不解 HTTP** │ + └────────────┬─────────────┘ + │ ③ TCP/TLS 转给反代 + ▼ + ┌───── L7 反向代理 ──────┐ + │ TLS 终结、解 HTTP │ ← Nginx / Ingress / Envoy + │ 按 Host + path 路由 │ + │ proxy_set_header X-* │ + └────────────┬─────────────┘ + │ ④ HTTP(明文或 mTLS) + ▼ + ┌──── Jenkins controller ────┐ + │ active 节点处理 UI / API │ + │ 把构建任务推给 agent │ + └────────────────────────────┘ +``` + +要点: +- **L4 LB 不看 HTTP**,所以它没法做"按 path 路由""注入 header""跳转 https"——这些必须 L7 反代来做。 +- **VIP + L4 + L7** 三段不是必选三连,按规模剪裁: + - PoC:客户端 → 单台 Nginx 反代 → Jenkins。 + - 中等:客户端 → VIP + Nginx(双机 keepalived)→ Jenkins。 + - 大规模:客户端 → 云 SLB(含 VIP+L4)→ Ingress Controller(L7)→ Jenkins。 + +--- + +## 4. VIP 是怎么"飘"起来的(L3 漂移原理) + +最常见的两种实现: + +### 4.1 Keepalived / VRRP(自建 IDC 用得最多) + +- 两台 LB 机器组一对,配同一个 VIP `10.10.20.100`。 +- 通过 VRRP 协议互相发**心跳**(默认每秒一次组播 224.0.0.18)。 +- 优先级高的当 **MASTER**,它把 VIP 配到自己的网卡上并**主动发 GARP**(Gratuitous ARP)刷新交换机的 ARP 表,让局域网立刻知道 "VIP 现在指向我的 MAC"。 +- MASTER 挂了,BACKUP 在心跳超时后接管 VIP,再发一次 GARP,**客户端连接会断一次但 DNS 不用改、IP 不用改**。 + +> 常见坑:交换机/云上禁了组播或不允许同 IP 漂移(云上自建 keepalived 通常**不可行**,必须用云的 SLB 替代)。 + +### 4.2 云厂商托管 LB 的"VIP" + +- 阿里云 SLB / AWS NLB / K8s `Service: LoadBalancer` 给你一个 EIP,本质上也是 VIP,只是漂移逻辑被云平台用 SDN/ECMP 实现,对你透明。 +- 你**不要再在前面套 keepalived**,会和云的健康检查打架。 + +--- + +## 5. L4 vs L7:到底差在哪 + +| 维度 | L4 LB | L7 反向代理 | +|---|---|---| +| 看到的内容 | IP、端口、TCP/UDP | HTTP method/host/path/header/body、TLS SNI | +| 能否 TLS 终结 | ❌ 一般不解(也有 TLS passthrough) | ✅ 终结后明文转后端,或再起 mTLS | +| 路由能力 | 仅按四元组/SNI 分发 | 按 Host、path、header、cookie 路由 | +| 改写能力 | 无(连接级) | 改 header、改 URI、压缩、缓存、限流 | +| 性能 | 极高(DR/FullNAT,单机百万 QPS 没问题) | 高,但比 L4 低一个量级 | +| 典型实现 | LVS、IPVS、NLB、HAProxy(mode tcp) | Nginx、Envoy、Traefik、ALB | + +**记忆**:L4 像快递分拣中心(只看地址条),L7 像前台秘书(拆开看完内容再决定送谁)。 + +--- + +## 6. 反向代理 vs 正向代理(顺手澄清) + +``` +正向代理:客户端 → [代理] → 互联网 ← 代理"代表客户端",常见:公司出口代理、VPN 出口 +反向代理:客户端 → [代理] → 内部服务 ← 代理"代表服务端",常见:Nginx 挡 Jenkins +``` + +判断依据:**代理是替谁出面**? +- 隐藏客户端身份去访问外网 → 正向代理。 +- 隐藏后端拓扑、统一入口给客户端 → 反向代理。 + +--- + +## 7. Nginx 反向代理"为什么生效"——精简版 + +> 完整原理在网上有大量长文,这里只挑你看部署文档时一定会遇到的点。 + +1. **配置加载**:`nginx -s reload` 让 master 重新解析 `nginx.conf` → 启动新 worker、老 worker 处理完存量请求再退出,**端口不空窗、连接不抖动**。 +2. **请求匹配**:`listen` + SNI/`Host` 选 `server{}`,再按 `=` → `^~` → 正则 → 最长前缀的优先级选 `location{}`。 +3. **`proxy_pass` 在 CONTENT 阶段触发**,由 `ngx_http_proxy_module` 接管:选 upstream peer → 改写 header → 建连/复用 keepalive → 流式双向转发 → 改写响应 header。 +4. **必配 4 行**(少一行就出怪事): + ```nginx + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + ``` +5. **upstream keepalive 三件套**(漏一个就回退到短连接,QPS 暴跌): + ```nginx + upstream jenkins { server 10.0.1.10:8080; keepalive 32; } + proxy_http_version 1.1; + proxy_set_header Connection ""; + ``` +6. **Jenkins 特有注意点**: + - 控制台日志 / SSE / WebSocket(如内嵌 agent 的 WebSocket 接入、`jenkins-cli.jar -webSocket` 等长连接路径)需要 `proxy_buffering off;` + 较大的 `proxy_read_timeout`(如 `1h`),否则长任务会被截断。具体路径以你 Jenkins 版本与插件实际暴露的为准。 + - WebSocket 还需: + ```nginx + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + ``` + - `proxy_pass` 末尾**带不带 `/`** 决定 URI 是否被剥前缀,写错就 404。 +7. **排查神器**:access log 加上 `$upstream_addr $upstream_status $upstream_response_time $request_time`,立刻能定位"慢在客户端还是慢在 Jenkins""有没有触发 next_upstream 重试"。 + +--- + +## 7.1 Nginx 反向代理详解:进程模型、请求阶段、时序 + +> 上一节是"必背速记卡",这一节是"原理图鉴"。看完能在脑子里把 *客户端按下回车* 到 *Jenkins 返回 HTML* 的每一跳都画出来。 + +### 7.1.1 进程模型(master / worker / 共享内存) + +```mermaid +flowchart LR + subgraph Nginx["Nginx 进程模型"] + M[master 进程
读 nginx.conf / 管理信号 / fork worker] + W1[worker #1
事件驱动 epoll/kqueue] + W2[worker #2] + W3[worker #N
= CPU 核数] + SHM[(共享内存 zone
limit_req / upstream / cache keys)] + CACHE[(磁盘 proxy_cache_path)] + end + Client[客户端 TCP SYN] -- accept_mutex / SO_REUSEPORT --> W1 + Client -.-> W2 + Client -.-> W3 + W1 <--> SHM + W2 <--> SHM + W3 <--> SHM + W1 <--> CACHE + M -. SIGHUP/SIGUSR2 .-> W1 + M -. fork/优雅退出 .-> W2 +``` + +要点: +- **master 不处理请求**,只读配置、管子进程、收信号(`reload`、`reopen`、`upgrade`)。 +- **worker 单线程 + 非阻塞 I/O**,一个 worker 同时扛上万连接靠的是 epoll,不是多线程。 +- **共享内存 zone** 让所有 worker 共享 upstream 健康状态、限流计数、缓存索引——所以你 `limit_req_zone` 写一次就对全机生效。 +- **`reload` 平滑原理**:master 收到 `SIGHUP` → 用新配置 fork 出新 worker 接管 listening socket → 老 worker 处理完存量连接后自杀,**端口从不空窗**。 + +### 7.1.2 单个 HTTP 请求在 worker 内部走的 11 个阶段 + +Nginx 把一个请求拆成 11 个 *phase*,每个模块挂在对应 phase 上。理解这张图你就知道为什么 `auth_request` 早于 `proxy_pass`、为什么 `add_header` 改不到上游响应。 + +```mermaid +flowchart TD + A[POST_READ
realip 模块在这里把 X-Forwarded-For 还原成 remote_addr] --> B[SERVER_REWRITE
server 块内的 rewrite/set] + B --> C[FIND_CONFIG
选定最终 location] + C --> D[REWRITE
location 内的 rewrite/set/if] + D --> E[POST_REWRITE
处理内部跳转,最多 10 次] + E --> F[PREACCESS
limit_conn / limit_req 在此] + F --> G[ACCESS
allow/deny / auth_basic / auth_request 在此] + G --> H[POST_ACCESS
satisfy any/all 汇总] + H --> I[PRECONTENT
try_files / mirror 在此] + I --> J[CONTENT
★ proxy_pass / fastcgi_pass / static 在此触发] + J --> K[LOG
access_log 在此写] + + J -. 上游响应回来 .-> L[header_filter 链
add_header / proxy_hide_header / gzip] + L --> M[body_filter 链
sub_filter / gzip 压缩 / chunked] + M --> K +``` + +记忆抓手: +- **`auth_request` 永远先于 `proxy_pass`** —— 因为 ACCESS 在 CONTENT 之前。 +- **`add_header` 默认只对自己产生的响应生效**,要改上游响应里的 header 用 `proxy_hide_header` + `add_header`,并加 `always`。 +- **`limit_req` 触发的 503 不会经过 upstream**,因为请求在 PREACCESS 就被毙了——所以你在 access log 里看不到 `$upstream_addr`。 + +### 7.1.3 反向代理的端到端时序图(HTTPS + keepalive + Jenkins) + +下图覆盖 *DNS → TLS 握手 → Nginx 11 阶段 → upstream 复用连接 → 流式响应 → 长连接保留* 全链路: + +```mermaid +sequenceDiagram + autonumber + participant U as 浏览器 / jk CLI + participant DNS + participant N as Nginx (反代) + participant J as Jenkins controller + participant L as access_log + + U->>DNS: ① 解析 ci.corp.example.com + DNS-->>U: A 记录 = VIP 10.10.20.100 + + U->>N: ② TCP SYN → SYN/ACK → ACK(三次握手) + U->>N: ③ TLS ClientHello (含 SNI = ci.corp.example.com) + N-->>U: TLS 证书 + ServerHello, 完成密钥协商 + + U->>N: ④ HTTP/1.1 GET /job/build-app/ Host: ci.corp... + Note over N: POST_READ → realip 还原真实 IP
SERVER_REWRITE → 选 server{}
FIND_CONFIG → 选 location /
ACCESS → auth/limit_req 通过
CONTENT → 进入 ngx_http_proxy_module + + alt upstream 已有空闲 keepalive 连接 + N->>J: ⑤a 复用已有 TCP(无握手) + else 没有空闲 + N->>J: ⑤b 新建 TCP(耗 1 RTT),加入 keepalive 池 + end + + N->>J: ⑥ 转发请求
Host: $host, X-Real-IP, X-Forwarded-For/Proto + J-->>N: ⑦ HTTP 响应头 (200, Content-Type, Set-Cookie) + Note over N: header_filter 链:
proxy_hide_header Server
add_header X-Frame-Options ... always + + loop 流式 chunk(控制台日志/大页面) + J-->>N: ⑧ body chunk + Note over N: body_filter:gzip / sub_filter + N-->>U: ⑨ 透传 chunk(proxy_buffering off 时 0 缓冲) + end + + J-->>N: ⑩ 末尾 chunk + Trailer + N-->>U: 末尾 chunk + N->>L: ⑪ LOG 阶段写 access_log
$status $upstream_addr $upstream_response_time $request_time + + Note over N,J: TCP 不关闭,归还到 upstream keepalive 池
下一次请求可省掉 ⑤b + Note over U,N: HTTP/1.1 keep-alive:浏览器侧也保持连接
下一次请求省掉 ②③ +``` + +### 7.1.4 不同"阶段视角"的对照速查表 + +不同人讲"Nginx 反向代理的阶段"经常指不同维度,下面把三种视角并排放,看部署文档时对得上号: + +| 维度 | 阶段 | 触发点 / 关键指令 | 你能干的事 | +|---|---|---|---| +| **生命周期** | 1. 启动加载 | `nginx -t` / `nginx -s reload` | 校验语法、热更配置 | +| | 2. 接受连接 | `accept_mutex` / `reuseport` | 控制惊群、绑核 | +| | 3. TLS 握手 | `ssl_protocols` / `ssl_session_cache` | TLS 版本/会话复用/OCSP stapling | +| | 4. 请求处理 | 11 phase(见 7.1.2) | 鉴权、限流、改写 | +| | 5. 上游通信 | `proxy_pass` / `upstream` | 选后端、健康检查、重试 | +| | 6. 响应回写 | header/body filter | 改 header、压缩、替换内容 | +| | 7. 日志收尾 | `access_log` / `log_format` | 打点、链路追踪 | +| **请求处理 11 phase** | POST_READ → LOG | 见 7.1.2 流程图 | 模块挂载点 | +| **代理子流程** | a. 选 peer | `upstream` + `least_conn` / `hash` | 负载策略 | +| | b. 建连/复用 | `keepalive N` + `proxy_http_version 1.1` | 长连接池 | +| | c. 写请求 | `proxy_set_header` / `proxy_pass_request_body` | 改请求 | +| | d. 读响应头 | `proxy_read_timeout` 第一字节 | 超时分级 | +| | e. 流式转发 body | `proxy_buffering` on/off | 缓冲 vs 透传 | +| | f. 错误重试 | `proxy_next_upstream` | 幂等重试范围 | +| | g. 释放/归还 | keepalive 池 | 复用 TCP | + +### 7.1.5 一次 `reload` 的时序(为什么不掉连接) + +```mermaid +sequenceDiagram + participant Op as 运维 + participant M as master + participant Wo as 老 worker + participant Wn as 新 worker + participant C as 在飞的客户端连接 + + Op->>M: nginx -s reload (SIGHUP) + M->>M: 重新解析 nginx.conf + alt 配置语法错 + M-->>Op: 报错并保持老 worker,不切换 + else 配置 OK + M->>Wn: fork 新 worker,继承 listening fd + Note over Wn: 立刻可以 accept 新连接 + M->>Wo: 发送"优雅退出"信号 + Wo-->>C: 继续处理已建立的请求直到完成 + Wo->>Wo: 当前连接数=0 时自杀 + Note over Wn,Wo: 端口从不关闭,新旧 worker 并存到老的退场 + end +``` + +对比:**keepalived 切 VIP** 是 L3/ARP 层的硬切,已建立的 TCP 连接会失效;**Nginx reload** 是同主机进程交接 listening socket,**不会断已建立的连接**。 + +### 7.1.6 把以上落到一段最小可用的 Jenkins 反代配置 + +```nginx +# /etc/nginx/conf.d/jenkins.conf +map $http_upgrade $connection_upgrade { default upgrade; '' close; } + +upstream jenkins { + server 10.0.1.10:8080 max_fails=3 fail_timeout=10s; + keepalive 32; # ← 7.1.3 ⑤a 能复用连接 +} + +server { + listen 443 ssl http2; + server_name ci.corp.example.com; + + ssl_certificate /etc/nginx/ssl/ci.crt; + ssl_certificate_key /etc/nginx/ssl/ci.key; + + # ---- 11 phase 中的 SERVER 级公共设置 ---- + client_max_body_size 100m; # 允许上传插件/构建产物 + proxy_http_version 1.1; + proxy_set_header Connection ""; # ← keepalive 三件套 + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # ---- CONTENT phase:交给 upstream ---- + location / { + proxy_pass http://jenkins; + proxy_redirect default; + proxy_read_timeout 1h; # 控制台日志/长任务 + proxy_buffering on; # 一般页面开缓冲 + } + + # 控制台日志/SSE/WebSocket:关闭缓冲 + 升级协议 + location ~* ^/(.*/(logText/progressiveHtml|logText/progressiveText)|cli|wsagents) { + proxy_pass http://jenkins; + proxy_buffering off; # 流式直出,对应 7.1.3 loop ⑨ + proxy_request_buffering off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } + + # 把 7 个变量都打出来,排障必备 + log_format jenkins '$remote_addr $host "$request" $status ' + '$body_bytes_sent rt=$request_time ' + 'urt=$upstream_response_time ua="$upstream_addr" ' + 'us=$upstream_status'; + access_log /var/log/nginx/jenkins.access.log jenkins; +} +``` + +> 这段配置每一行都能对应到 7.1.1–7.1.5 的某张图,建议把它和图对照着读一遍——以后再看 [`deploy-idc-lan.md`]({% post_url 2026-04-26-jenkins-deploy-idc-lan %}) / [`deploy-aliyun-ack.md`]({% post_url 2026-04-26-jenkins-deploy-aliyun-ack %}) 中的实际片段就完全无障碍。 + +--- + +## 8. 一张组合速查表(对照本仓部署方案) + +| 部署形态 | VIP 由谁提供 | L4 由谁做 | L7 反代 / Ingress | TLS 终结点 | +|---|---|---|---|---| +| 单机 PoC | 无(直接用主机 IP/域名) | 无 | 主机上的 Nginx | Nginx | +| IDC 双机 HA([deploy-idc-lan]({% post_url 2026-04-26-jenkins-deploy-idc-lan %})) | Keepalived/VRRP | Nginx 同机做(stream 模块)或纯 L7 | Nginx | Nginx | +| 阿里云 ACK([deploy-aliyun-ack]({% post_url 2026-04-26-jenkins-deploy-aliyun-ack %})) | 阿里云 SLB | SLB(NLB / CLB-TCP) | Ingress-Nginx / ALB Ingress | SLB 或 Ingress 二选一,看是否要在 Pod 看到 mTLS | + +--- + +## 9. 看完这篇你应该能回答 + +- 为什么 `jk` 配置里建议写**域名**而不是 VIP?→ 见 [`faq.md` Q1]({% post_url 2026-04-26-jenkins-faq %})。VIP 可能因网络方案变化,域名是更稳定的间接层。 +- 为什么 reload Nginx 不会断流,而 keepalived 切换 VIP 会断一次连接?→ 前者复用 listening socket,后者是 L3 层 ARP 切换,已建立的 TCP 连接随旧 MASTER 一起没了。 +- 为什么 Jenkins 控制台日志在浏览器里"卡一大段才出来"?→ 反代默认 `proxy_buffering on`,要为日志/SSE 关掉。 +- 为什么后端日志里看到的客户端 IP 全是反代的 IP?→ 反代没传 `X-Forwarded-For`,或 Jenkins 没启用 `Forwarded` header 解析(`Manage Jenkins → Configure Global Security → "Use Forwarded Header"`)。 + +--- + +## 10. 延伸阅读(仓内) + +- [`topology.md`]({% post_url 2026-04-26-jenkins-topology %}) — 看到本篇术语后再读拓扑图会顺很多。 +- [`design.md`]({% post_url 2026-04-26-jenkins-design %}) — HA、备份、合规设计选择。 +- [`deploy-idc-lan.md`]({% post_url 2026-04-26-jenkins-deploy-idc-lan %}) — keepalived + Nginx 的实际配置片段。 +- [`deploy-aliyun-ack.md`]({% post_url 2026-04-26-jenkins-deploy-aliyun-ack %}) — 云上 SLB + Ingress 的具体做法。 +- [`checklist.md`]({% post_url 2026-04-26-jenkins-checklist %}) — 上线前把本篇提到的"必配 4 行 / keepalive 三件套 / WebSocket / X-Forwarded-*"逐项核对。 diff --git a/_posts/jenkins/2026-04-26-jenkins-topology.md b/_posts/jenkins/2026-04-26-jenkins-topology.md new file mode 100644 index 000000000..3fd7068e4 --- /dev/null +++ b/_posts/jenkins/2026-04-26-jenkins-topology.md @@ -0,0 +1,167 @@ +--- +layout: post +title: "Jenkins 部署拓扑:通用逻辑、阿里云 ACK、本地 IDC" +date: 2026-04-26 10:00:00 +0800 +categories: [jenkins, 架构] +tags: [jenkins, 架构, 拓扑] +description: "Jenkins 控制器与 Agent 的逻辑拓扑,以及阿里云 ACK 与本地 IDC 两种落地形态。" +slug: jenkins-topology +--- + +## 1. 通用逻辑拓扑(两方案共享) + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ 用户 / 工程师终端 │ +│ 浏览器(HTTPS) jk CLI (HTTPS + API Token) CI 机器人 │ +└──────────────┬──────────────────┬─────────────────────┬─────────────┘ + │ │ │ + ▼ ▼ ▼ + ┌──────────────────────────────────────────────────────┐ + │ 零信任网关 / VPN(Tailscale / Teleport / Cloudflare)│ ← 公司外可选 + └──────────────────────────────┬───────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ 反向代理(Nginx / Traefik / ALB)+ TLS 终止 │ + │ Host: jenkins.corp.example.com │ + └──────────────────────────────┬───────────────────────┘ + │ + ▼ + ┌──────────────────────────────────────────────────────┐ + │ Jenkins Controller (Master) │ + │ - JCasC 加载配置 │ + │ - LDAP 认证 + Role Strategy 授权 │ + │ - Vault 取密钥 │ + │ - Prometheus metrics endpoint │ + │ PV: jenkins_home (持久化) │ + └─────┬──────────────┬─────────────┬─────────────┬─────┘ + │JNLP/HTTPS │ │ │ + ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ + │ K8s Pod │ │ Linux VM │ │ Windows │ │ GPU/Build │ + │ Agent │ │ Agent │ │ Agent │ │ Heavy Agent │ + │ (动态) │ │ (静态) │ │ (静态) │ │ (静态) │ + └──────────┘ └──────────┘ └──────────┘ └──────────────┘ + + 外部依赖(旁路): + ├─ LDAP / AD (认证) + ├─ HashiCorp Vault (密钥) + ├─ Nexus / Artifactory (制品/缓存) + ├─ GitLab / Bitbucket (SCM + Webhook) + ├─ Prometheus + Grafana (监控) + ├─ Loki / ELK (日志) + └─ 对象存储 (OSS/S3/MinIO) (备份) +``` + +## 2. 方案 A:阿里云 ACK 物理拓扑 + +``` + 公网 (可选) + │ + ▼ + ┌────────────────────────┐ + │ 阿里云 SLB (ALB/NLB) │ ← 内网或公网,按合规要求 + │ TLS 证书托管在 SLB 或 │ + │ ACK Ingress │ + └───────────┬────────────┘ + │ + ▼ + ┌────────────────────────────────────────────┐ + │ ACK 集群 (3+ master, N worker) │ + │ ┌───────────────┐ ┌────────────────────┐ │ + │ │ ingress-nginx │ │ cert-manager │ │ + │ └───────────────┘ └────────────────────┘ │ + │ │ + │ Namespace: jenkins │ + │ ┌───────────────────────────────────────┐ │ + │ │ Helm release: jenkins/jenkins │ │ + │ │ ├─ controller StatefulSet (1 副本) │ │ + │ │ │ PVC → 阿里云 NAS (RWX) 50Gi │ │ + │ │ ├─ Service (ClusterIP) │ │ + │ │ └─ Ingress → jenkins.corp.example.com│ │ + │ │ │ │ + │ │ k8s plugin → 动态 Pod agent │ │ + │ └───────────────────────────────────────┘ │ + │ │ + │ Namespace: monitoring (kube-prometheus) │ + │ Namespace: vault (可选, 或用阿里云 KMS) │ + └────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────┐ + │ 阿里云 OSS (备份, 加密) │ + │ 阿里云 ACR (镜像) │ + │ 阿里云 NAS (jenkins_home) │ + │ 阿里云 RDS (可选, 审计 DB) │ + └─────────────────────────────┘ + + 企业内网 ←(VPN / 专线 / 高速通道)→ ACK VPC + │ + ├─ AD/LDAP 服务器 + └─ 内部 GitLab / Nexus +``` + +## 3. 方案 B:本地 IDC 局域网集群拓扑 + +``` + 办公网 / VPN 生产 IDC + │ │ + ▼ │ + ┌────────────────────┐ │ + │ 内部 DNS (CoreDNS) │ jenkins.idc.local │ + │ 内部 CA (step-ca) │ → 10.0.1.20 (VIP) │ + └────────────────────┘ │ + │ │ + ▼ ▼ + ┌────────────────────────────────────────────────────────┐ + │ Keepalived VIP 10.0.1.20 + Nginx (主/备 双机) │ + │ TLS 终止, 访问日志, WAF (modsecurity 可选) │ + └─────────────────────────┬──────────────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────────────────────┐ + │ Jenkins Controller VM (10.0.1.10) │ + │ - Ubuntu 22.04 LTS, systemd │ + │ - Docker Compose 起 jenkins/jenkins:lts-jdk21 │ + │ - 挂载 /srv/jenkins_home → 本地 SSD (或 NFS) │ + │ - 备份: cron + restic → MinIO/对象存储 │ + └─┬───────────────┬───────────────┬─────────────────────┘ + │ JNLP 50000 │ SSH 22 │ JNLP/SSH + ▼ ▼ ▼ + ┌─────────┐ ┌──────────┐ ┌─────────────┐ + │ Linux │ │ Windows │ │ GPU 节点 │ + │ Agent×N │ │ Agent×M │ │ Agent×K │ + │ VM/裸机 │ │ 物理机 │ │ 物理机 │ + └─────────┘ └──────────┘ └─────────────┘ + + 旁路: + AD/LDAP 服务器 (10.0.2.5) + GitLab (10.0.3.10) + Nexus (10.0.3.20) + Prometheus/Grafana (10.0.4.x) + MinIO 备份 (10.0.5.10) +``` + +> Jenkins 控制器原生不支持 active-active HA。方案 B 的高可用做法是 **冷备**: +> 第二台 VM 同步 `jenkins_home`,VIP 切换。要真正 HA 需 CloudBees CI 商业版。 + +## 4. 端口与协议 + +| 端口 | 协议 | 用途 | 暴露范围 | +|---:|---|---|---| +| 443 | HTTPS | Web UI / REST API / `jk` CLI | 用户网段 / VPN | +| 80 | HTTP | 跳转 → 443(可禁用) | 同上 | +| 50000 | TCP(JNLP) | inbound agent 连接 | 仅 agent 网段 | +| 22 | SSH | controller → agent(SSH agent 模式) | controller → agent | +| 8080 | HTTP | Jenkins 内部端口 | 仅集群内 / 反代后端 | +| 9100 | HTTP | node_exporter(可选) | 监控网段 | + +## 5. 数据流 + +1. 用户登录:浏览器/`jk` → 反代 → Jenkins → LDAP 验证 → 颁发 cookie / 接受 API token。 +2. 触发构建:Webhook(GitLab/GitHub)→ 反代 → Jenkins → 调度到 agent。 +3. 凭据使用:Pipeline `withVault {}` → Jenkins Vault plugin → Vault → 临时注入到 agent 环境变量。 +4. 备份:cron → restic → 对象存储(OSS / MinIO),加密 + 异地副本。 +5. 监控:Prometheus scrape `https://.../prometheus`(带 token)→ Grafana。 +6. 日志:controller stdout / `$JENKINS_HOME/logs/` → Filebeat → Loki/ELK。 diff --git "a/_posts/leetcode/2023-01-17-\345\210\206\351\232\224\351\223\276\350\241\250.md" "b/_posts/leetcode/2023-01-17-\345\210\206\351\232\224\351\223\276\350\241\250.md" new file mode 100644 index 000000000..b291cb6d4 --- /dev/null +++ "b/_posts/leetcode/2023-01-17-\345\210\206\351\232\224\351\223\276\350\241\250.md" @@ -0,0 +1,98 @@ +--- +layout: post +title: 分隔链表题解 +subtitle: LeetCode 86 双指针解法 +date: 2023-01-17 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 链表 + - 双指针 +--- + +>随便整理的一些自用的leetcode题解 + +## 题目背景 + +LeetCode 86「分隔链表」。给定一个链表与目标值 `x`,要求重排链表,使**所有小于 `x` 的节点出现在所有大于等于 `x` 的节点之前**,且两段内部各自的相对顺序与原链表保持一致。 + +## 概念解释 + +- **稳定分隔**:题目隐含要求"保持原相对顺序",因此不能简单地交换值,需要按指针搬节点。 +- **dummyNode(哨兵节点)**:在头部加一个不存数据的节点,使得"插入到第一个小于 `x` 的位置之前"和"在中间插入"用同一段代码处理,避免单独写 head 改变的逻辑。 +- **双指针法**:用两个指针把链表逻辑地切成"已确认 < x 的前缀"和"待扫描区间"两段,扫描时按需把节点从后段搬到前段末尾。 + +## 实现原理 + +`h1` 指向"已确认 `next->val >= x`:跳过,`h2 = h2->next`; +- `h2->next->val < x`: + - 若 `h1 == h2`,说明前缀还没断开,直接同时前进; + - 否则把 `h2->next` 这一节点从原位置摘下,插入到 `h1->next` 处,然后让 `h1` 前进一格而 `h2` 不动(因为它的 `next` 已经被重新接成了新链)。 + +扫描结束时返回 `dummyNode->next` 即可。整个过程只遍历一次链表,时间复杂度 `O(n)`,空间复杂度 `O(1)`。 + +## 参考实现 + +# 提示代码 +``` +/** + * Definition for singly-linked list. + * struct ListNode { + * int val; + * ListNode *next; + * ListNode() : val(0), next(nullptr) {} + * ListNode(int x) : val(x), next(nullptr) {} + * ListNode(int x, ListNode *next) : val(x), next(next) {} + * }; + */ +class Solution { +public: + ListNode* partition(ListNode* head, int x) { + + ListNode* dummyNode = new ListNode(0); + dummyNode->next = head; + + ListNode* h1 = dummyNode; + ListNode* h2 = dummyNode; + + while(h2->next != nullptr) + { + if(h2->next->val >= x) + { + h2 = h2->next; + }else + { + if(h1 == h2) + { + h1 = h1->next; + h2 = h2->next; + } + else + { + ListNode* t = h2->next; + h2->next = h2->next->next; + + t->next = h1->next; + h1->next = t; + h1 = h1->next; + } + } + } + + return dummyNode->next; + + } +}; +``` + +# 题解 + +#### 思路 + 双指针法,并提前设置dummyNode,避免处理单节点情况。 + + h1指针用于指向所有小于X的节点,遇到小于X的节点,如果,h1==h2, h1,h2同时向后移动,否则,需要将h2的后继节点移动为h1的后继节点,同时h1前进1步,h2不动。 + h2指针用于向后遍历,遇到大于等于X的节点,h2向后移动, diff --git a/_posts/leetcode/2026-04-26-LRU.md b/_posts/leetcode/2026-04-26-LRU.md new file mode 100644 index 000000000..4573859e5 --- /dev/null +++ b/_posts/leetcode/2026-04-26-LRU.md @@ -0,0 +1,128 @@ +--- +layout: post +title: LRU 缓存(哈希表 + 双向链表) +subtitle: LeetCode 146 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - LRU + - Cache +--- + +>原始笔记只贴了一份能跑过题目的代码。这里在不改动实现的前提下,补一段题意与数据结构的说明,便于日后回顾。 + +## 题目背景 + +LeetCode 146「LRU 缓存」。要求设计一个支持 `get(key)` 与 `put(key, value)` 两种操作、容量受限的缓存:当容量满后写入新键时,淘汰**最久未使用**(Least Recently Used)的那一项。两种操作的平均时间复杂度都要做到 `O(1)`。 + +## 概念解释 + +- **LRU**:一种缓存替换策略,假设最近被访问的数据短期内更可能再次被访问,因此淘汰最久未访问的项。 +- **双向链表**:从节点出发可以 `O(1)` 拿到前驱与后继,因此可以在 `O(1)` 完成"从中间摘除"与"插到头部"两个动作。 +- **伪头/伪尾节点(dummy head / dummy tail)**:在链表两端各放一个不存数据的哨兵节点,省去对空链表与边界节点的特判。 + +## 实现原理 + +把哈希表与双向链表组合起来: + +1. 哈希表 `unordered_map` 把 key 映射到链表中对应节点的指针,使**查找**为 `O(1)`。 +2. 双向链表按访问时间排序:越靠近**头部**越新、越靠近**尾部**越旧。 +3. `get`:哈希表命中后通过指针把节点从原位置摘下并 `addToHead`,更新它的"最近被访问"地位。 +4. `put`: + - 已存在则更新 value 并 `moveToHead`; + - 不存在则新建节点、写入哈希表、插到头部;若超出容量,再调用 `removeTail` 同时从哈希表与链表中删除尾节点,保证淘汰的就是最久未使用的那一项。 + +由于每个动作都只涉及常数次指针修改与哈希操作,`get` 与 `put` 都达到了 `O(1)` 的均摊时间复杂度,空间复杂度为 `O(capacity)`。 + +## 参考实现 + +``` +struct DLinkedNode { + int key, value; + DLinkedNode* prev; + DLinkedNode* next; + DLinkedNode(): key(0), value(0), prev(nullptr), next(nullptr) {} + DLinkedNode(int _key, int _value): key(_key), value(_value), prev(nullptr), next(nullptr) {} +}; + +class LRUCache { +private: + unordered_map cache; + DLinkedNode* head; + DLinkedNode* tail; + int size; + int capacity; + +public: + LRUCache(int _capacity): capacity(_capacity), size(0) { + // 使用伪头部和伪尾部节点 + head = new DLinkedNode(); + tail = new DLinkedNode(); + head->next = tail; + tail->prev = head; + } + + int get(int key) { + if (!cache.count(key)) { + return -1; + } + // 如果 key 存在,先通过哈希表定位,再移到头部 + DLinkedNode* node = cache[key]; + moveToHead(node); + return node->value; + } + + void put(int key, int value) { + if (!cache.count(key)) { + // 如果 key 不存在,创建一个新的节点 + DLinkedNode* node = new DLinkedNode(key, value); + // 添加进哈希表 + cache[key] = node; + // 添加至双向链表的头部 + addToHead(node); + ++size; + if (size > capacity) { + // 如果超出容量,删除双向链表的尾部节点 + DLinkedNode* removed = removeTail(); + // 删除哈希表中对应的项 + cache.erase(removed->key); + // 防止内存泄漏 + delete removed; + --size; + } + } + else { + // 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部 + DLinkedNode* node = cache[key]; + node->value = value; + moveToHead(node); + } + } + + void addToHead(DLinkedNode* node) { + node->prev = head; + node->next = head->next; + head->next->prev = node; + head->next = node; + } + + void removeNode(DLinkedNode* node) { + node->prev->next = node->next; + node->next->prev = node->prev; + } + + void moveToHead(DLinkedNode* node) { + removeNode(node); + addToHead(node); + } + + DLinkedNode* removeTail() { + DLinkedNode* node = tail->prev; + removeNode(node); + return node; + } +}; +``` diff --git a/_posts/leetcode/2026-04-26-lru-stl.md b/_posts/leetcode/2026-04-26-lru-stl.md new file mode 100644 index 000000000..0d80e512d --- /dev/null +++ b/_posts/leetcode/2026-04-26-lru-stl.md @@ -0,0 +1,137 @@ +--- +layout: post +title: LRU 缓存(STL list + unordered_map 演示) +subtitle: 用 STL 容器写一遍 LRU +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - LRU + - STL +--- + +>原始笔记是一份直接贴在文件里的可执行 demo,演示如何用 `std::list` + `std::unordered_map` 写一份 LRU。这里只补充结构说明,代码保留原样。 + +## 题目背景 + +与 LeetCode 146 相同:要求 `get`、`put` 都做到 `O(1)`,容量满则淘汰最久未使用项。区别在于这里直接借助 STL 的双向链表 `std::list`,不再手写节点结构。 + +## 概念解释 + +- **`std::list`**:标准库的双向链表,迭代器在 `splice/erase/insert` 等操作下都保持有效(除非该元素被删除),非常适合做 LRU 这种需要"原地搬节点"的场景。 +- **`std::unordered_map`**:用哈希表保存 key 到链表节点迭代器的映射,使得"找到节点"和"把节点搬到表头"都能在均摊 `O(1)` 完成。 + +## 实现原理 + +1. 容器约定:`l` 的**首元素**为最新访问、尾元素为最旧访问;`ump` 把 key 映射到对应迭代器。 +2. `put(key, value)`: + - 不存在且未满 → `emplace_front` 插入新节点,并在哈希表登记迭代器; + - 不存在且已满 → 先依据 `l.back()` 删除尾部最旧节点(同时清理哈希表),再插入新节点; + - 已存在 → 通过哈希表定位旧节点,先删除再 `emplace_front` 重新插入到表头,更新映射。 +3. `get(key)`:未命中返回 `{-1,-1}`;命中后同样把节点搬到表头,体现"刚被访问"。 +4. `print` 仅用于 demo 调试,对算法本身无影响。 + +> 备注:示例中用 `l.remove(*ump[key])` 通过值删除节点,复杂度为 `O(n)`。如果追求严格 `O(1)`,可改用 `l.erase(ump[key])` 或 `l.splice(l.begin(), l, ump[key])` 直接按迭代器搬动。本笔记保留原写法,便于和最初的实现对齐。 + +## 参考实现 + +``` +#include +#include +#include +#include +#include +// can be checked without being set +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +class lru{ +private: + std::list> l; + std::unordered_map>::iterator> ump; + int capacity_; +public: + lru(int capacity):capacity_(capacity){ + + } + + ~lru(){ + + } + + void put(int key,int value){ + if(ump.count(key) == 0) + { + if(l.size() < static_cast(capacity_)) + { + l.emplace_front(std::make_pair(key,value)); + ump[key] = l.begin(); + }else{ + ump.erase(l.back().first); + l.pop_back(); + + l.emplace_front(std::make_pair(key,value)); + ump[key] = l.begin(); + } + }else{ + l.remove(*ump[key]); + ump.erase(key); + + l.emplace_front(std::make_pair(key,value)); + ump[key] = l.begin(); + } + } + + std::pair get(int key){ + if(ump.count(key) == 0) + { + return std::make_pair(-1,-1); + } + + l.remove(*ump[key]); + std::pair t = *ump[key]; + l.emplace_front(std::make_pair(key,t.second)); + ump[key] = l.begin(); + + return t; + } + + void print(){ + for(auto iter = l.begin(); iter != l.end(); ++iter){ + std::cout << iter->first << " " << iter->second << std::endl; + } + } + +}; + +int main() +{ + + lru my_lru(3); + + my_lru.put(1,1); + my_lru.put(2,2); + my_lru.put(3,3); + + // my_lru.print(); + + my_lru.put(4,4); + + // my_lru.print(); + + my_lru.get(2); + + my_lru.print(); +} +``` diff --git "a/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\220\234\347\264\242\346\225\260\345\261\225\345\274\200\344\270\272\346\234\211\345\272\217\345\217\214\351\223\276\350\241\250.md" "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\220\234\347\264\242\346\225\260\345\261\225\345\274\200\344\270\272\346\234\211\345\272\217\345\217\214\351\223\276\350\241\250.md" new file mode 100644 index 000000000..751ab9539 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\220\234\347\264\242\346\225\260\345\261\225\345\274\200\344\270\272\346\234\211\345\272\217\345\217\214\351\223\276\350\241\250.md" @@ -0,0 +1,141 @@ +--- +layout: post +title: 二叉搜索树展开为有序双向循环链表 +subtitle: 剑指 Offer II 题解 / LeetCode 426 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二叉树 + - BST + - 链表 +--- + +>原文只贴了一份 in-place 的中序展开代码。这里把题意、利用 BST 性质的关键观察以及递归返回值的语义补上,代码保持原样。 + +## 题目背景 + +题目要求:把一棵 **BST(二叉搜索树)** 原地(不能新建节点,只能改 `left`/`right` 指针)展开为一个**有序的双向循环链表**。BST 中节点 `left` 在新链表中作为前驱,`right` 作为后继;最后还要把链表头尾相连,形成循环。 + +## 概念解释 + +- **BST 中序遍历有序**:BST 的中序遍历序列必然是按值递增的,因此「按中序拼接」恰好等于「按从小到大顺序拼接」。 +- **双向循环链表**:每个节点既有前驱又有后继,且最后一个节点的 `right` 指向第一个,第一个节点的 `left` 指向最后一个。 +- **指针复用**:本题不允许新建节点,所以中序遍历完成后必须用 BST 节点自身的 `left`/`right` 字段充当链表的前驱/后继。 + +## 实现原理 + +`inorder(root)` 的语义是:把以 `root` 为根的子树就地拼成一段双向链表,并返回这段链表的**最左节点**(也就是子树最小值)。 + +1. 递归处理 **左子树**,得到左侧已经成段的链表 `h1`;若左子树为空,则 `h1 = root`。 +2. 沿 `h1->right` 一直走到这段链表的末端,使其与 `root` 互为前驱后继,把 `root` 衔接到左段的尾部。 +3. 递归处理 **右子树**,得到右段的最左节点 `h2`,把 `root` 与 `h2` 互连;右段已是有序的,自然续在 `root` 之后。 +4. 整段子树展开后,最左节点仍然是步骤 1 得到的 `h`,将其作为返回值传给上一层。 + +最外层 `treeToDoublyList` 在拿到完整的 `h` 后,再走到末端节点 `h1`,把 `h1->right` 和 `h->left` 互连形成循环。 + +时间复杂度 `O(n)`,每个节点只在递归进入和"找尾巴"时被访问常数次;空间复杂度 `O(h)`(递归栈深度,`h` 为树高)。 + +## 参考实现 + +``` +/* +// Definition for a Node. +class Node { +public: + int val; + Node* left; + Node* right; + + Node() {} + + Node(int _val) { + val = _val; + left = NULL; + right = NULL; + } + + Node(int _val, Node* _left, Node* _right) { + val = _val; + left = _left; + right = _right; + } +}; +*/ +class Solution { +private: + // 函数式编程的思路,中序遍历,依次展开,然后,将链表的左侧与右侧连接在一起, + // 函数返回值为最左侧的节点 + Node* inorder(Node* root) + { + Node* h1 = nullptr; + Node* h2 = nullptr; + Node* h = nullptr; + if(root == nullptr) + { + return nullptr; + } + if(root->left == nullptr){ + // return root; + h1 = root; + // return h1; + }else + { + h1 = inorder(root->left); + } + + h = h1; + + while(h1->right != nullptr && h1 != root && h1->right != root) + { + h1 = h1->right; + } + if(root != h1) + { + root->left = h1; + h1->right = root; + } + + if(root->right == nullptr) + { + h2 = nullptr; + }else{ + h2 = inorder(root->right); + } + if(root != nullptr) + { + root->right = h2; + if(h2!=nullptr) + { + h2->left = root; + } + } + + return h; + + } +public: + Node* treeToDoublyList(Node* root) { + if(root == nullptr) + { + return nullptr; + } + + Node* h = inorder(root); + // 循环双链表, 此处应该有优化空间 + Node* h1 = h; + while(h1->right != nullptr) + { + // std::cout << h1->val << " " ; + h1 = h1->right; + + } + + h1->right = h; + h->left = h1; + return h; + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\344\270\255\345\272\217\351\201\215\345\216\206.md" "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\344\270\255\345\272\217\351\201\215\345\216\206.md" new file mode 100644 index 000000000..0ac59fa63 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\344\270\255\345\272\217\351\201\215\345\216\206.md" @@ -0,0 +1,110 @@ +--- +layout: post +title: 二叉搜索树中序遍历(求最小绝对差) +subtitle: LeetCode 530 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二叉树 + - 中序遍历 + - BST +--- + +>原文标题写着「中序遍历」,但实际函数名是 `getMinimumDifference`,对应的是 LeetCode 530「二叉搜索树的最小绝对差」。这里把题意和迭代式中序遍历的原理简单说明一下,代码保留原样。 + +## 题目背景 + +LeetCode 530:给定一棵 **BST**,求**任意两节点值的差的绝对值**的最小值。 + +## 概念解释 + +- **中序遍历(inorder)**:左子树 → 根 → 右子树。BST 的中序遍历结果是**严格递增**的有序序列。 +- **栈式迭代中序遍历**:利用一个显式栈模拟系统栈,避免递归。每次先把所有左孩子压栈,弹栈即得到当前最小未访问节点,再以它的右子树作为新的子问题继续。 + +## 实现原理 + +由于 BST 中序遍历结果有序,**最小绝对差**只可能出现在**有序序列相邻两项之间**(任何非相邻对之间的差都大于某对相邻差)。 + +代码做了两步: + +1. 用栈做迭代式中序遍历,把节点值依次写入 `arr`,得到一个递增数组。 +2. 扫描相邻元素差 `|arr[i] - arr[i-1]|`,取最小值返回。 + +时间复杂度 `O(n)`:每个节点入栈出栈各一次;空间复杂度 `O(h)`,`h` 为树高(栈最大深度),结果数组额外占 `O(n)`。 + +> 备注:原代码用 `0x33333333L` 作为 INT_MAX 初始值,含义是"足够大的初值",实践中可以替换成 `INT_MAX` 让意图更直观。这里保留原写法。 + +## 参考实现 + +# 中序遍历 + +``` +/** + * Definition for a binary tree node. + * struct TreeNode { + * int val; + * TreeNode *left; + * TreeNode *right; + * TreeNode() : val(0), left(nullptr), right(nullptr) {} + * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} + * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} + * }; + */ +class Solution { +private: + std::vector arr; +public: + int getMinimumDifference(TreeNode* root) { + std::stack s; + bool flag = false; + + if(root == nullptr) + { + return -1; + } + + while(true) + { + while(root != nullptr) + { + s.emplace(root); + root = root->left; + } + + if(!s.empty()) + { + TreeNode* t = s.top(); + s.pop(); + arr.emplace_back(t->val); + // std::cout << t->val << std::endl; + + if(t->right != nullptr) + { + root = t->right; + } + }else + { + break; + } + } + + int min = 0x33333333L; + + for(auto i = 1; i < arr.size(); ++i) + { + int t = std::abs(arr[i] - arr[i - 1]); + // std::cout << arr[i] << " " << arr[i - 1] << std::endl; + if(min > t) + { + min = t; + } + } + + return min; + + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\345\261\225\345\274\200\344\270\272\345\215\225\351\223\276\350\241\250.md" "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\345\261\225\345\274\200\344\270\272\345\215\225\351\223\276\350\241\250.md" new file mode 100644 index 000000000..b749af6de --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\345\261\225\345\274\200\344\270\272\345\215\225\351\223\276\350\241\250.md" @@ -0,0 +1,81 @@ +--- +layout: post +title: 二叉树展开为单链表 +subtitle: LeetCode 114 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二叉树 + - 递归 +--- + +>原始笔记给了一句"函数式编程,只考虑函数以及结果"。这里把题意和递归的返回值含义补一下,代码保持原样。 + +## 题目背景 + +LeetCode 114「二叉树展开为链表」。要求把一棵二叉树**原地**展开为一条单链表:所有节点 `left` 置空,`right` 按**先序遍历**顺序依次链接。 + +## 概念解释 + +- **先序遍历(preorder)**:根 → 左子树 → 右子树。 +- **原地修改**:不能借助新数组/新节点,只能调整 `left/right` 指针。 +- **函数式递归思路**:忽略函数内部如何把树拆开,只关心"调用 `flatten_helper(t)` 后会返回一棵已经按先序展开成右链的树"。 + +## 实现原理 + +`flatten_helper(root)` 的契约:把以 `root` 为根的子树就地展开,所有节点串到 `right` 链上,并返回新链表头(实际上仍是 `root`)。 + +1. 暂存 `root` 的左右孩子 `lhv`、`rhv`,把 `root->left` 置空。 +2. 递归展开左子树,作为 `root` 的新右子树挂上去。 +3. 沿着 `right` 一直走到末端节点,那里就是"左子树展开后的尾节点"。 +4. 把 `rhv` 递归展开后接在尾节点的 `right` 上,整段链表头依然是最初的 `root`。 + +直观对应了先序的拼接顺序:根 + 左子树展开结果 + 右子树展开结果。 + +时间复杂度 `O(n^2)` 最坏(每层都要走到链尾),空间复杂度 `O(h)` 递归栈。Morris 解法可以做到 `O(n)`,但本笔记保留递归写法以贴合原始记录。 + +## 参考实现 + +思路: 函数式编程,只考虑函数以及结果,不考虑过程,递归实现即可。 +``` +/** + * Definition for a binary tree node. + * struct TreeNode { + * int val; + * TreeNode *left; + * TreeNode *right; + * TreeNode() : val(0), left(nullptr), right(nullptr) {} + * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} + * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} + * }; + */ +class Solution { + private: + TreeNode* flatten_helper(TreeNode* root) + { + if(root == nullptr) + { + return nullptr; + } + TreeNode* new_root = root; + TreeNode* rhv = root->right; + TreeNode* lhv = root->left; + root->left = nullptr; + root->right = flatten_helper(lhv); + while(root->right != nullptr) + { + root = root->right; + } + + root->right = flatten_helper(rhv); + return new_root; + } +public: + void flatten(TreeNode* root) { + root = flatten_helper(root); + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\347\232\204\346\234\200\344\275\216\345\205\254\345\205\261\347\245\226\345\205\210.md" "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\347\232\204\346\234\200\344\275\216\345\205\254\345\205\261\347\245\226\345\205\210.md" new file mode 100644 index 000000000..c62d10db6 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\344\272\214\345\217\211\346\240\221\347\232\204\346\234\200\344\275\216\345\205\254\345\205\261\347\245\226\345\205\210.md" @@ -0,0 +1,247 @@ +--- +layout: post +title: 二叉树的最低公共祖先(路径回溯法) +subtitle: LeetCode 236 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二叉树 + - 回溯 + - LCA +--- + +>原文先后给了两版"路径栈 + 比较"的写法。这里把题意、概念和两版差异说明一下,代码保留原样以便对照。 + +## 题目背景 + +LeetCode 236「二叉树的最低公共祖先」。给定一棵普通(非 BST)二叉树和两个节点 `p`、`q`,求**深度最大的同时是 `p` 和 `q` 祖先**的节点。所有节点值唯一,且 `p`、`q` 一定都存在于树中。 + +## 概念解释 + +- **最低公共祖先 (LCA)**:在所有同时为 `p` 和 `q` 祖先的节点中,深度最大的那一个。任何节点也是它自己的祖先。 +- **路径回溯**:DFS 过程中维护一条"从根到当前节点"的路径栈,进入子树时入栈,离开时出栈,正好对应递归的入栈/退栈过程。 +- **回溯剪枝(flag 优化)**:找到目标后立刻打个标记,让上层 DFS 不再继续扫描兄弟子树,节省无用搜索。 + +## 实现原理 + +总体思路是先分别求出 **根→p** 和 **根→q** 两条路径,再在两条路径上找最深的公共节点。 + +第一版: + +1. `search_tree` 用 DFS 在路径栈 `s` 中维护当前路径;命中 `target` 时把整个 `s` 拷给 `ret`,作为"根到 target 的路径快照"。 +2. 拿到两条路径栈 `ret1`、`ret2` 后,先把较深的一条 pop 到与另一条等高,再同步 pop 比对栈顶,遇到值相等的节点即为 LCA。 + +第二版(优化): + +- 用 `flag` 表示"是否已经找到 target"。一旦命中,沿递归回溯时**不再 `s.pop()`**,让路径自然保留;同时其它分支的递归继续返回但不影响栈。 +- 这样省掉了拷贝 `ret = s` 的开销,并避免在已找到目标后还遍历无关子树。 + +时间复杂度 `O(n)`,每个节点最多被访问常数次;空间复杂度 `O(h)`,由路径栈与递归栈决定。 + +> 备注:标准答案多用「同时找 p、q 的递归一遍解」即可 `O(n)` 完成本题;这里保留原始的"先求两条路径再比较"写法,便于复盘当时的思考过程。 + +## 参考实现 + +``` +/** + * Definition for a binary tree node. + * struct TreeNode { + * int val; + * TreeNode *left; + * TreeNode *right; + * TreeNode(int x) : val(x), left(NULL), right(NULL) {} + * }; + */ +class Solution { + +private: + // std::stack ret1; + //回溯法 + //s 用来dfs遍历tree + //ret 用来保存最终的结果, + void search_tree(TreeNode* root, TreeNode* target, std::stack& s, + std::stack& ret) + { + if(root == nullptr) + { + return; + } + + s.emplace(root); + + if(root == target) + { + // ret1 = s; + ret = s; + return; + }else + { + if(root->left != nullptr) + { + search_tree(root->left, target, s, ret); + } + + if(root->right != nullptr) + { + search_tree(root->right, target, s, ret); + } + s.pop(); //回溯 + } + + } + + +public: + TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { + + std::stack s1; + std::stack s2; + + std::stack ret1; + std::stack ret2; + + search_tree(root, p, s1, ret1); + search_tree(root, q, s2, ret2); + + while(!ret1.empty() && !ret2.empty()) + { + // TreeNode* t = ret1.top(); + // std::cout << t->val << std::endl; + // ret1.pop(); + + if(ret1.size() > ret2.size()) + { + ret1.pop(); + + }else if(ret1.size() == ret2.size()) + { + TreeNode* t1 = ret1.top(); + TreeNode* t2 = ret2.top(); + + if(t1->val == t2->val) + { + return t1; + }else + { + ret1.pop(); + ret2.pop(); + } + + }else + { + ret2.pop(); + } + + } + return nullptr; + } +}; +``` + +提交代码后,发现效果不是很好,需要进一步优化,想到使用flag,用来判断是否找到目标点, +如果,找到的话,则终止回溯过程,可以节省一些无用操作,优化如下: + +``` +/** + * Definition for a binary tree node. + * struct TreeNode { + * int val; + * TreeNode *left; + * TreeNode *right; + * TreeNode(int x) : val(x), left(NULL), right(NULL) {} + * }; + */ +class Solution { + +private: + // std::stack ret1; + //回溯法 + //s 用来dfs遍历tree + //ret 用来保存最终的结果, + void search_tree(TreeNode* root, TreeNode* target, std::stack& s, + bool& flag) + { + if(root == nullptr) + { + return; + } + + s.emplace(root); + + if(root == target) + { + // ret1 = s; + // ret = s; + flag = true; + return; + }else + { + if(root->left != nullptr) + { + search_tree(root->left, target, s, flag); + } + + if(root->right != nullptr) + { + search_tree(root->right, target, s, flag); + } + if(!flag) + { + s.pop(); //回溯 + } + } + + } + + +public: + TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { + + std::stack s1; + std::stack s2; + + // std::stack ret1; + // std::stack ret2; + bool flag1 = false; + bool flag2 = false; + + search_tree(root, p, s1, flag1); + search_tree(root, q, s2, flag2); + + while(!s1.empty() && !s2.empty()) + { + // TreeNode* t = ret1.top(); + // std::cout << t->val << std::endl; + // ret1.pop(); + + if(s1.size() > s2.size()) + { + s1.pop(); + + }else if(s1.size() == s2.size()) + { + TreeNode* t1 = s1.top(); + TreeNode* t2 = s2.top(); + + if(t1->val == t2->val) + { + return t1; + }else + { + s1.pop(); + s2.pop(); + } + + }else + { + s2.pop(); + } + + } + return nullptr; + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\345\220\203\351\246\231\350\225\211.md" "b/_posts/leetcode/2026-04-26-\345\220\203\351\246\231\350\225\211.md" new file mode 100644 index 000000000..64cc25b07 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\345\220\203\351\246\231\350\225\211.md" @@ -0,0 +1,107 @@ +--- +layout: post +title: 爱吃香蕉的珂珂(答案二分) +subtitle: LeetCode 875 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二分查找 + - 答案二分 +--- + +>原始笔记只贴了一份代码。这里把题意和"对答案二分"的通用模板补一下,代码保持原样。 + +## 题目背景 + +LeetCode 875「爱吃香蕉的珂珂」。给定 `piles[i]` 表示第 `i` 堆香蕉的数量,珂珂每小时只能挑一堆吃且最多吃 `k` 根(吃完不足 `k` 也立刻进入下一小时),要求在 `h` 小时内吃完所有香蕉的**最小整数速度** `k`。 + +## 概念解释 + +- **答案二分(在值域上二分)**:当解空间是一个**单调函数**(速度越大、所需小时越少)时,可以直接对答案取值范围做二分查找,而不是对数组下标二分。 +- **吃完时长公式**:以速度 `k` 吃 `pile` 堆需要 `ceil(pile / k)` 小时,整体时长 `T(k) = Σ ceil(piles[i] / k)`。`T(k)` 关于 `k` 单调递减。 +- **最小可行解**:寻找最小的 `k` 使 `T(k) ≤ h`。 + +## 实现原理 + +1. 速度的下界是 `1`(至少要吃),上界是 `max(piles)`(一小时一堆、绰绰有余)。 +2. 在 `[low, high]` 上二分: + - 若 `eatingSpeed(piles, mid) ≤ h`,说明 `mid` 可行,尝试更小:`high = mid - 1`; + - 否则 `mid` 不够快:`low = mid + 1`。 +3. 循环结束时 `low` 即为最小可行速度。原代码额外做了一次 `eatingSpeed(piles, high)` 的兜底校验,本质上和"返回 `low`"等价,这里保留原写法不动。 + +时间复杂度 `O(n log max(piles))`:值域二分 `O(log max)`,每次 `eatingSpeed` 扫描数组 `O(n)`;空间复杂度 `O(1)`。 + +> 备注:`ceil(pile / k)` 的常用整数等价写法是 `(pile + k - 1) / k`,原代码用的是 `pile / k + (pile % k != 0)` 的等价形式,逻辑一致。 + +## 参考实现 + +``` +class Solution { +private: + int eatingSpeed(vector& piles, int h) + { + if(h == 0) + { + // return 0; + h = 1; + } + int k = 0; + for(int i = 0; i < piles.size(); ++i) + { + // std::cout << h << std::endl; + if(piles[i] % h == 0) + { + k += piles[i]/h; + }else + { + k += piles[i]/h + 1; + } + } + + return k; + } +public: + int minEatingSpeed(vector& piles, int h) { + if(h == 0) + { + return -1; + } + int max = 0; + for_each(piles.begin(),piles.end(),[&](auto item){ + if(max < item) + { + max = item; + } + }); + + int low = 1; + int high = max; + + while(low <= high) + { + int mid = low + (high - low) / 2; + // std::cout << low << " " << high << " " << mid << std::endl; + if(eatingSpeed(piles,mid) <= h) + { + if(mid == 1) + { + return mid; + } + high = mid - 1; + }else{ + low = mid + 1; + } + } + + if(eatingSpeed(piles, high) <= h) + { + return high; + } + return high + 1; + + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\345\220\210\345\271\266k\344\270\252\351\223\276\350\241\250-stl\347\211\210\346\234\254.md" "b/_posts/leetcode/2026-04-26-\345\220\210\345\271\266k\344\270\252\351\223\276\350\241\250-stl\347\211\210\346\234\254.md" new file mode 100644 index 000000000..e989adfce --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\345\220\210\345\271\266k\344\270\252\351\223\276\350\241\250-stl\347\211\210\346\234\254.md" @@ -0,0 +1,99 @@ +--- +layout: post +title: 合并 K 个升序链表(STL 演示版) +subtitle: priority_queue + list iterator 的 K 路归并 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 链表 + - 优先队列 + - STL +--- + +>原始笔记是一份可以直接编译运行的小 demo,借助 `std::list` 与 `std::priority_queue` 演示 K 路归并。这里只补上结构说明,代码保留原样。 + +## 题目背景 + +题意与 LeetCode 23「合并 K 个升序链表」一致,但本笔记不是 LeetCode 的提交版本,而是脱离 `ListNode*` 直接用 STL 容器写的对照实现,便于在本地跑起来观察行为。 + +## 概念解释 + +- **`std::list`**:标准库双向链表,迭代器在容器修改后仍然稳定(除非元素被删),可以放心存到外部数据结构里。 +- **`listIterator = std::pair::iterator>`**:用 `pair` 把链表编号和该链表当前节点的迭代器绑在一起,方便从堆里取出后判断"该往哪条链表前进"。 +- **`std::priority_queue` + lambda**:默认大顶堆;通过自定义比较器 `*(p2.second) < *(p1.second)` 翻转成小顶堆。 + +## 实现原理 + +1. 把每条链表的 `begin()` 迭代器连同它所属的链表下标一起入堆,得到 `k` 个候选。 +2. 反复弹出堆顶(当前值最小的那个迭代器),把对应值追加到结果 `ret` 中。 +3. 弹出后把该链表的迭代器前进一格 `++t.second`;若没到 `end()`,再把更新后的 `(index, iter)` 入堆。 +4. 堆空时归并完成,遍历 `ret` 输出。 + +时间复杂度 `O(N log k)`,空间复杂度 `O(k)`,与提交版本一致。 + +## 参考实现 + +``` +#include +#include +#include +#include +#include +// can be checked without being set +#include +#include +#include +#include + +int main() { + + std::list l1; + l1.emplace_back(1); + l1.emplace_back(20); + l1.emplace_back(25); + std::list l2; + l2.emplace_back(2); + l2.emplace_back(10); + l2.emplace_back(33); + + std::vector> lists = {l1,l2}; + + typedef std::pair::iterator> listIterator; + + auto cmp = [](listIterator p1, listIterator p2){ return *(p2.second) < *(p1.second); }; + + std::priority_queue< listIterator, std::vector, decltype(cmp) > pq(cmp); + + for(size_t i = 0; i < lists.size(); ++i) + { + pq.emplace(std::make_pair(i,lists[i].begin())); + } + + std::list ret; + + while(!pq.empty()) + { + listIterator t = pq.top(); + pq.pop(); + ret.emplace_back(*(t.second)); + + if(++t.second != lists[t.first].end()) + { + pq.emplace(std::make_pair(t.first, t.second)); + } + } + + auto iter = ret.begin(); + while(iter != ret.end()) + { + std::cout << (*iter) << std::endl; + ++iter; + } + + + return 0; +} +``` diff --git "a/_posts/leetcode/2026-04-26-\345\220\210\345\271\266k\344\270\252\351\223\276\350\241\250.md" "b/_posts/leetcode/2026-04-26-\345\220\210\345\271\266k\344\270\252\351\223\276\350\241\250.md" new file mode 100644 index 000000000..93c0f7b3a --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\345\220\210\345\271\266k\344\270\252\351\223\276\350\241\250.md" @@ -0,0 +1,100 @@ +--- +layout: post +title: 合并 K 个升序链表(优先队列) +subtitle: LeetCode 23 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 链表 + - 优先队列 + - 堆 +--- + +>原始笔记只贴了一份代码。这里把题意与"K 路归并 + 小顶堆"的原理简单写一下,代码保持原样。 + +## 题目背景 + +LeetCode 23「合并 K 个升序链表」。给定 `k` 个**已经升序**的链表,将它们合并成一个升序链表。 + +## 概念解释 + +- **K 路归并**:归并排序中"两路归并"的推广。每次从所有候选链表的当前头节点中挑出**最小**的,输出后让该链表前进一格。 +- **小顶堆 / 优先队列**:能在 `O(log k)` 时间内取出 `k` 个候选中的最小值,是 K 路归并的标配数据结构。 +- **lambda 比较器**:`std::priority_queue` 默认是大顶堆,所以需要传入一个返回 `p2 < p1` 的比较器把它变成小顶堆。 + +## 实现原理 + +1. 初始把每个非空链表的头节点放进优先队列(这里存 `pair`,并辅以哈希表 `mp` 记录每个 index 当前指向的节点指针)。 +2. 每次弹出堆顶(值最小的那一项): + - 把对应节点接到结果链表尾部,更新 `m_list`; + - 让 `mp[index]` 指针前进一格,若仍非空,则把新的 `(index, val)` 入堆。 +3. 堆空即归并完毕,返回 `m_list_head`。 + +设总节点数为 `N`,则有 `N` 次堆操作,每次 `O(log k)`,总体时间复杂度 `O(N log k)`,空间复杂度 `O(k)`(堆 + 哈希表大小都受限于链表数量)。 + +> 备注:标准写法可以直接把 `ListNode*` 入堆,避免额外的 `mp`;这里保留原始的 `index → ListNode*` 映射写法,体现作者最初的实现思路。 + +## 参考实现 + +``` +/** + * Definition for singly-linked list. + * struct ListNode { + * int val; + * ListNode *next; + * ListNode() : val(0), next(nullptr) {} + * ListNode(int x) : val(x), next(nullptr) {} + * ListNode(int x, ListNode *next) : val(x), next(next) {} + * }; + */ +class Solution { +public: + ListNode* mergeKLists(vector& lists) { + ListNode* m_list = nullptr; + ListNode* m_list_head = nullptr; + if(lists.size() == 0) + { + return nullptr; + } + + auto cmp = [](std::pair p1, std::pair p2){return p2.second < p1.second;}; + std::priority_queue ,std::vector >, decltype(cmp) > pq(cmp); + + std::unordered_map mp; + + for(int i = 0; i < lists.size(); ++i) + { + if(lists[i] != nullptr) + { + pq.push(std::make_pair(i, lists[i]->val)); + mp[i] = lists[i]; + } + } + + while(!pq.empty()) + { + std::pair t = pq.top(); + if(m_list_head == nullptr) + { + m_list = mp[t.first]; + m_list_head = mp[t.first]; + }else + { + m_list->next = mp[t.first]; + m_list = m_list->next; + } + pq.pop(); + if(mp[t.first]->next != nullptr) + { + mp[t.first] = mp[t.first]->next; + pq.push(std::make_pair(t.first, mp[t.first]->val)); + } + } + + return m_list_head; + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\345\277\253\346\216\222.md" "b/_posts/leetcode/2026-04-26-\345\277\253\346\216\222.md" new file mode 100644 index 000000000..40ac9021b --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\345\277\253\346\216\222.md" @@ -0,0 +1,111 @@ +--- +layout: post +title: 双路快速排序 +subtitle: 分区原理与 LeetCode 笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 排序 + - 快速排序 + - 分治 +--- + +>原始笔记简短地写了"双路快排",主体是一份带调试输出的代码。这里把分区原理与边界条件补上,代码保留原样。 + +## 题目背景 + +快速排序是基于**分治**的内部排序算法,平均时间复杂度 `O(n log n)`,最坏 `O(n^2)`。"双路快排"是它的一种常见优化变体,用两个指针从区间两端向中间扫描,能较好处理含有大量重复元素的输入。 + +## 概念解释 + +- **基准(pivot)**:分区时被参照的那个元素,所有比它小的放左边、比它大的放右边。 +- **闭区间扫描**:本笔记里 `i`、`j` 都是闭区间下标,循环条件 `i <= j` 中包含等号。原代码注释里也专门提示"此处还是要还有=号的,闭区间"。 +- **稳定性**:快排不是稳定排序,相同元素的相对顺序可能改变。 + +## 实现原理 + +`find_num(nums, i, j)` 完成一轮分区: + +1. 取最左端元素 `nums[k]`(`k = i`)作为基准。 +2. 双指针 `i`(已自增过一次)、`j` 同时向中间靠拢: + - 右指针 `j` 向左跳过所有 `>= pivot` 的元素; + - 左指针 `i` 向右跳过所有 `<= pivot` 的元素; + - 当两边都停下且仍 `i < j` 时,交换 `nums[i]` 与 `nums[j]`,再各前进/后退一格。 +3. 循环结束后,`j` 指向**最后一个 `<= pivot` 的位置**,把基准 `nums[k]` 与 `nums[j]` 互换,使基准就位。 +4. 返回基准的最终位置 `j`。 + +`quick_sort` 拿到分割点 `k` 后,对 `[i, k-1]` 与 `[k+1, j]` 两个子区间分别递归即可。平均 `O(n log n)`,原地无需额外空间(递归栈 `O(log n)`)。 + +> 备注:代码里保留了一些 `std::cout` 调试输出,用于当时定位边界问题,可在使用时直接删掉。 + +## 参考实现 + +``` +双路快排。。 + +int find_num(vector& nums, int i , int j) +{ + // std::cout << "i1=" << i << " j1 = " << j << std::endl; + if(i >= j) + { + return j; + } + int k = i++; + // ++i; + std::cout << "k " << k << std::endl; + while( i <= j) + //此处还是要还有=号的,闭区间 + { + // std::cout << i << " " << j << std::endl; + while(i <= j && nums[j] >= nums[k]) + //此处还是要还有=号的,闭区间 + { + --j; + } + while(i <= j && nums[i] <= nums[k]) + //此处还是要还有=号的,闭区间 + { + ++i; + } + if(i < j) + { + int tt = nums[i]; + nums[i] = nums[j]; + nums[j] = tt; + ++i; + --j; + } + } + // std::cout << j << std::endl; + if(k < j ) + { + // std::cout << "k " << k << " j " << j << std::endl; + int tt = nums[k]; + nums[k] = nums[j]; + nums[j] = tt; + } + //返回第j个位置 + return j; +} + +void quick_sort(std::vector& nums, int i , int j) +{ + int k = find_num(nums,i,j); + // std::cout << "k " << k << std::endl; + // if() + if(i < k - 1) + { + // std::cout << "first" << k << std::endl; + quick_sort(nums,i,k-1); + } + if(k + 1 < j) + { + // std::cout << "second" << k << std::endl; + quick_sort(nums,k+1,j); + } + +} +``` diff --git "a/_posts/leetcode/2026-04-26-\346\220\234\347\264\242\345\215\225\350\257\215.md" "b/_posts/leetcode/2026-04-26-\346\220\234\347\264\242\345\215\225\350\257\215.md" new file mode 100644 index 000000000..c19fdae72 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\346\220\234\347\264\242\345\215\225\350\257\215.md" @@ -0,0 +1,134 @@ +--- +layout: post +title: 单词搜索(DFS + 回溯) +subtitle: LeetCode 79 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - DFS + - 回溯 + - 网格 +--- + +>原始笔记已经记下了"DFS + 回溯算法"和"main 循环结束后也要回溯 flag"的关键点。这里在不改代码的前提下,把题意和标准的回溯模板写完整。 + +## 题目背景 + +LeetCode 79「单词搜索」。给一个 `m × n` 的字符网格 `board` 和一个目标单词 `word`,判断能否在网格中按**水平/垂直相邻**的方式拼出 `word`。同一格子在一次拼接中**不能重复使用**。 + +## 概念解释 + +- **DFS(深度优先搜索)**:沿着一条路径深入,走不通再回退尝试别的方向。 +- **回溯(backtracking)**:DFS 中"走过又退回"时,必须把状态(这里是"该格是否已被本次路径占用"的 `flag` 标记)**复原**,否则会污染兄弟分支。 +- **方向数组**:网格四邻域问题常用 `(±1, 0)/(0, ±1)` 四个偏移;本笔记把四个方向直接展开成四段 `if`,逻辑等价。 + +## 实现原理 + +1. 在 `exist` 中双重循环枚举每个起点 `(i, j)`,若 `board[i][j] == word[0]`,则把该格 `flag` 置 1,调用 `dfs(...,1,ended)` 尝试匹配后续字符。 +2. `dfs(arr, flag, i, j, word, k, ended)` 的语义是"已经匹配了 `word[0..k-1]`,当前停在 `(i, j)`",目标是匹配 `word[k]`: + - 若 `k == word.size()` 即已全匹配,置 `ended = true` 返回; + - 否则尝试 `(i, j-1) / (i-1, j) / (i+1, j) / (i, j+1)` 四个邻居,命中且未被占用就标记 `flag` 后递归,递归返回后**立刻把 `flag` 清回 0**,这就是回溯。 +3. 起点自身的 `flag` 也必须在 `dfs` 返回后清 0(无论是否命中),否则后续起点会受影响——这正是原文提示"main 函数 for 循环完成后也需要回溯 flag"的原因。 +4. 一旦 `ended` 为真就直接返回 `true`,提前结束所有搜索。 + +最坏时间复杂度 `O(m·n·4^L)`,其中 `L = word.size()`;空间复杂度 `O(L)` 递归栈 + `O(m·n)` 的 `flag` 数组。 + +## 参考实现 + +``` +解题思路: dfs + 回溯算法 +需要注意的点:main函数内for循环完成后,也需要回溯flag的值 +``` + +``` +class Solution { + +private: +void dfs(const std::vector>& arr, std::vector>& flag, int i, int j, const std::string& word,int k, bool& ended) +{ + // std::cout << "i = " << i << " j = " << j << " k = " << k << std::endl; + if (k == word.size()) + { + ended = true; + return; + } + + if (i > -1 && i < arr.size() && (j - 1) > -1 && (j - 1) < arr[0].size()) + { + //std::cout << "i = " << i << " j - 1 " << j - 1 << std::endl; + if (!flag[i][j - 1] && arr[i][j - 1] == word[k]) + { + flag[i][j - 1] = 1; + dfs(arr,flag,i,j-1,word,k+1, ended); + flag[i][j - 1] = 0; + } + } + + if ( (i-1) > -1 && (i-1) < arr.size() && j > -1 && j < arr[0].size()) + { + //std::cout << "i-1 = " << i-1 << " j " << j << std::endl; + if (!flag[i-1][j] && arr[i-1][j] == word[k]) + { + flag[i-1][j] = 1; + dfs(arr, flag, i-1, j , word, k + 1, ended); + flag[i-1][j] = 0; + } + } + + if (i+1 > -1 && i+1 < arr.size() && j > -1 && j < arr[0].size()) + { + //std::cout << "i + 1 = " << i + 1 << " j " << j << std::endl; + if (!flag[i+1][j] && arr[i+1][j] == word[k]) + { + flag[i+1][j] = 1; + dfs(arr, flag, i+1, j, word, k + 1, ended); + flag[i+1][j] = 0; + } + } + + if (i > -1 && i < arr.size() && j + 1 > -1 && j + 1 < arr[0].size()) + { + //std::cout << "i = " << i << " j + 1 " << j + 1 << std::endl; + if (!flag[i][j + 1] && arr[i][j + 1] == word[k]) + { + flag[i][j + 1] = 1; + dfs(arr, flag, i, j + 1, word, k + 1, ended); + flag[i][j + 1] = 0; + } + } + + //return false; +} +public: + bool exist(vector>& board, string word) { + + std::vector> flag(board.size(), std::vector(board[0].size(),0)); + + bool ended = false; + for (int i = 0; i < board.size(); ++i) + { + for (int j = 0; j < board[0].size(); ++j) + { + ended = false; + if (board[i][j] == word[0]) + { + flag[i][j] = 1; + dfs(board,flag,i,j,word,1,ended); + flag[i][j] = 0; + if (ended) + { + std::cout << ended << std::endl; + return true; + } + + } + } + } + return false; + // std::cout << ended << std::endl; + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\346\227\213\350\275\254\346\225\260\347\273\204\347\232\204\346\234\200\345\260\217\345\200\274.md" "b/_posts/leetcode/2026-04-26-\346\227\213\350\275\254\346\225\260\347\273\204\347\232\204\346\234\200\345\260\217\345\200\274.md" new file mode 100644 index 000000000..9898954b9 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\346\227\213\350\275\254\346\225\260\347\273\204\347\232\204\346\234\200\345\260\217\345\200\274.md" @@ -0,0 +1,79 @@ +--- +layout: post +title: 旋转数组中的最小值 +subtitle: LeetCode 153 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二分查找 + - 旋转数组 +--- + +>原文已经写了二分思路,这里把"旋转数组"的概念和判定原理补全。代码保留原样。 + +## 题目背景 + +LeetCode 153「寻找旋转排序数组中的最小值」。一个原本递增的数组在某个未知点被旋转过一次(如 `[0,1,2,4,5,6,7]` → `[4,5,6,7,0,1,2]`),数组元素互不相同,要求在 `O(log n)` 内找出其中的最小值。 + +## 概念解释 + +- **旋转数组**:把一个有序数组从某下标处切开,前后两段交换位置后得到的数组。它由两段各自有序的子数组拼接而成,最小值就是后一段的起点。 +- **二分查找的关键**:并不一定要"目标值有序",只要能在 `O(1)` 时间内**判断答案在哪一侧**,就可以二分。 + +## 实现原理 + +每一轮拿 `mid` 与 `h`(区间右端值)比较: + +- `nums[mid] < nums[h]`:说明 `[mid, h]` 段是单调递增的,**最小值不可能在 mid 右侧**。再看 `nums[mid-1]` 是否大于 `nums[mid]`,如果是则 `mid` 就是分界点(最小值),否则收缩 `h = mid - 1`。 +- `nums[mid] > nums[h]`:说明断点在 `(mid, h]`,最小值一定在 `mid` 右侧,`l = mid + 1`。 + +当 `l == h` 时收敛到最小值。注意原代码把"找到答案"的判断显式写在条件里,便于尽早返回。 + +时间复杂度 `O(log n)`,空间复杂度 `O(1)`。本题元素互不相同,所以无需考虑 LeetCode 154 那种 `nums[mid] == nums[h]` 退化为线性扫描的情形。 + +## 参考实现 + +# 旋转数组中的最小值 + +解题思路: + 二分法: + 1、判断,递增区间,从而判断,l 或 者 h的变化 + 2、 终止条件: 判断找到了最小值。 + +``` +class Solution { +public: + int findMin(vector& nums) { + + int l = 0; + int h = nums.size() - 1; + + while(l < h) // 这里是小于 + { + int mid = l + (h - l)/2; //防止溢出 + + if(nums[mid] < nums[h]) + { + if(mid ==l) // mid == l ,找到了最小值 + { + return nums[mid]; + }else if(mid > l) // mid > l, 与左侧的值比较一下,判断是否需要继续二分 + { + if(nums[mid-1]> nums[mid]) + { + return nums[mid]; + } + } + h = mid - 1; + }else if(nums[mid] > nums[h]) + { + l = mid + 1; + } + } + return nums[l]; //l ==h ,找到了最小值。 + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\346\234\211\345\272\217\346\227\213\350\275\254\346\225\260\347\273\204\346\220\234\347\264\242.md" "b/_posts/leetcode/2026-04-26-\346\234\211\345\272\217\346\227\213\350\275\254\346\225\260\347\273\204\346\220\234\347\264\242.md" new file mode 100644 index 000000000..813c64d78 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\346\234\211\345\272\217\346\227\213\350\275\254\346\225\260\347\273\204\346\220\234\347\264\242.md" @@ -0,0 +1,113 @@ +--- +layout: post +title: 搜索旋转排序数组 +subtitle: LeetCode 33 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二分查找 + - 旋转数组 +--- + +>原始笔记只贴了一份代码。这里把"旋转数组上的二分"的判定原理写一下,代码保留原样。 + +## 题目背景 + +LeetCode 33「搜索旋转排序数组」。一个原本严格递增的数组被旋转过一次,给一个目标值 `target`,找出它在数组中的下标,找不到返回 `-1`。要求 `O(log n)`。 + +## 概念解释 + +- **半有序性质**:旋转数组的任意一次二分,`nums[l..mid]` 和 `nums[mid..h]` 中**至少有一段是完全有序的**。 +- **判定有序的一段**:通过比较 `nums[mid]` 与 `nums[l]`(或 `nums[h]`)即可判断。本笔记同时考虑了 `==` 的情况,处理"含重复值"的退化分支(实际本题元素唯一,但保留原代码)。 + +## 实现原理 + +每轮根据 `mid` 与端点的关系,先**确定哪一侧有序**,再判断 `target` 是否落在该有序段的值域内: + +- 若 `nums[mid] > nums[l]`,左段 `[l, mid]` 有序: + - `nums[l] ≤ target < nums[mid]` → 收缩到左段 `h = mid - 1`; + - 否则进入右段 `l = mid + 1`。 +- 若 `nums[mid] < nums[h]`,右段 `[mid, h]` 有序: + - `nums[mid] < target ≤ nums[h]` → 收缩到右段 `l = mid + 1`; + - 否则进入左段 `h = mid - 1`。 +- `nums[mid] == nums[l]` 或 `== nums[h]`:无法判定,做最小步收缩,等价于线性退化的兜底。 + +任意一步如果 `nums[mid] == target`,立即返回 `mid`。 + +时间复杂度 `O(log n)`,最坏(出现等值时)退化为 `O(n)`;空间 `O(1)`。 + +## 参考实现 + +``` +class Solution { +public: + int search(vector& nums, int target) { + + + int l = 0; + int h = nums.size() - 1; + + while(l <= h) + //闭区间 + { + int mid = l + (h - l )/2; + // std::cout << mid << std::endl; + if(nums[mid] > nums[l]) + //左侧有序 + { + if(nums[mid] > target && nums[l] <= target) + //判断左侧有序范围是否符合要求 + { + h = mid - 1; + }else if(nums[mid] == target) + { + return mid; + }else + { + l = mid + 1; + } + }else if(nums[mid] == nums[l]) + { + if(nums[mid] == target) + { + return mid; + }else + { + l = mid +1; + } + }else if(nums[mid] < nums[h]) + //右侧有序 + { + if(nums[mid] < target && target <= nums[h]) + //判断右侧有序范围是否符合要求 + { + l = mid + 1; + }else if(nums[mid] == target) + { + return mid; + }else + { + h = mid - 1; + } + }else if(nums[mid] == nums[h]) + { + if(nums[mid] == target) + { + return mid; + }else + { + h = mid - 1; + } + } + + } + + + return -1; + + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\351\200\206\344\270\255\345\272\217\351\201\215\345\216\206.md" "b/_posts/leetcode/2026-04-26-\351\200\206\344\270\255\345\272\217\351\201\215\345\216\206.md" new file mode 100644 index 000000000..403812548 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\351\200\206\344\270\255\345\272\217\351\201\215\345\216\206.md" @@ -0,0 +1,80 @@ +--- +layout: post +title: 把二叉搜索树转换为累加树(反向中序) +subtitle: 剑指 Offer II 054 / LeetCode 538 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 二叉树 + - BST + - 中序遍历 +--- + +>原文标题写着"逆中序遍历",对应的题目其实是「所有大于等于节点的值之和」。这里把题意和反向中序遍历的思想补一下,代码保留原样。 + +## 题目背景 + +剑指 Offer II 054 / LeetCode 538「把二叉搜索树转换为累加树」。给一棵 **BST**,把每个节点的值替换为"原 BST 中所有大于等于该节点值的节点之和"。 + +## 概念解释 + +- **BST 性质**:左子树所有值 < 根 < 右子树所有值。 +- **正向中序**:左 → 根 → 右,遍历结果是**升序**。 +- **反向中序(逆中序)**:右 → 根 → 左,遍历结果是**降序**。这个序列正好是"按 ≥ 当前节点的顺序"逐个访问,非常适合做累加。 + +## 实现原理 + +只要按"右、根、左"的顺序遍历,每遍历到一个节点时,把当前的累加和 `sum` 加上它自己的值,再把 `sum` 写回给该节点,就完成了"把当前节点替换为所有 ≥ 它的值之和"。 + +``` +inverse_inorder(right); +sum += root->val; +root->val = sum; +inverse_inorder(left); +``` + +由于反向中序保证了"所有大于当前节点的节点都已经先被加进 `sum`",所以累加结果天然正确。 + +时间复杂度 `O(n)`,每个节点只被访问一次;空间复杂度 `O(h)` 为递归栈深度。 + +## 参考实现 + +# 剑指 Offer II 054. 所有大于等于节点的值之和 + +``` +/** + * Definition for a binary tree node. + * struct TreeNode { + * int val; + * TreeNode *left; + * TreeNode *right; + * TreeNode() : val(0), left(nullptr), right(nullptr) {} + * TreeNode(int x) : val(x), left(nullptr), right(nullptr) {} + * TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {} + * }; + */ +class Solution { +private: + void inverse_inorder(TreeNode* root, int& sum) + { + if(root == nullptr) + { + return; + } + + inverse_inorder(root->right, sum); + sum += root->val; + root->val = sum; + inverse_inorder(root->left, sum); + } +public: + TreeNode* convertBST(TreeNode* root) { + int sum = 0; + inverse_inorder(root, sum); + return root; + } +}; +``` diff --git "a/_posts/leetcode/2026-04-26-\351\242\221\347\216\207\346\216\222\345\272\217.md" "b/_posts/leetcode/2026-04-26-\351\242\221\347\216\207\346\216\222\345\272\217.md" new file mode 100644 index 000000000..ba806b732 --- /dev/null +++ "b/_posts/leetcode/2026-04-26-\351\242\221\347\216\207\346\216\222\345\272\217.md" @@ -0,0 +1,90 @@ +--- +layout: post +title: 根据字符出现频率排序 +subtitle: LeetCode 451 题解笔记 +date: 2026-04-26 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - leetcode + - 哈希 + - 优先队列 + - 排序 +--- + +>原文记录了两条思路。这里把题意与思路一的实现原理写完整,代码保留原样。 + +## 题目背景 + +LeetCode 451「根据字符出现频率排序」。给定一个字符串 `s`,要求按字符**出现频率从高到低**返回新字符串;频率相同的字符相对位置不强制要求。 + +## 概念解释 + +- **频率统计**:经典的"哈希表/数组计数"模式。Key 是字符,Value 是它出现的次数。 +- **大顶堆 / 优先队列**:要按"次数从大到小"输出,可以把 `(char, count)` 对入堆,`top()` 给出最高频字符。 +- **`map::value_comp`**:`std::map` 的比较器只作用于 key,不能直接按 value 排序,因此原文思路二行不通。 + +## 实现原理 + +思路一(被采用): + +1. 用 `std::map` 遍历 `s`,统计每个字符出现的次数。 +2. 把所有 `(char, count)` 入大顶堆 `priority_queue`,比较器返回 `p1.second < p2.second`,让 `top()` 拿到最高频。 +3. 反复弹出堆顶,把对应字符按 `count` 重复追加到结果字符串里,直至堆空。 + +时间复杂度 `O(n + k log k)`:`n` 为字符串长度,`k = O(unique chars)`,最坏 `k ≤ 128`;空间复杂度 `O(k + n)`(堆 + 输出字符串)。 + +> 备注:思路二想直接用 `map::value_comp` 按 value 排序失败,原因如上:`map` 的比较器只针对 key。如果想"少写一个堆",可以改用 `vector>` 配 `std::sort` 自定义比较器。 + +## 参考实现 + +``` +思路一: 先用map 记录次数,然后使用 优先队列按照频率进行排序 + +思路二: 开始考虑直接使用map 内置的 value_comp,没有成功!!!!! +``` + +``` +class Solution { +public: + string frequencySort(string s) { + + std::map mp; + + auto cmp = [](std::pair p1, std::pair p2){ + return p1.second < p2.second; + }; + std::priority_queue, std::vector >,decltype(cmp)> pq(cmp); + + + for_each(s.begin(),s.end(),[&](auto item){ + mp[item]++; + }); + + auto iter = mp.begin(); + + while(iter != mp.end()) + { + pq.emplace(std::make_pair(iter->first,iter->second)); + ++iter; + } + std::string ret; + while(!pq.empty()) + { + auto t = pq.top(); + // std::cout << t.first << " " << t.second << std::endl; + auto count = t.second; + while(count > 0) + { + ret += t.first; + --count; + } + pq.pop(); + } + + return ret; + + } +}; +``` diff --git "a/_posts/life/2026-04-25-\345\214\227\344\272\254\345\270\202\351\203\212\345\214\272\346\227\205\346\270\270\346\224\273\347\225\245.md" "b/_posts/life/2026-04-25-\345\214\227\344\272\254\345\270\202\351\203\212\345\214\272\346\227\205\346\270\270\346\224\273\347\225\245.md" new file mode 100644 index 000000000..36e0d1cf2 --- /dev/null +++ "b/_posts/life/2026-04-25-\345\214\227\344\272\254\345\270\202\351\203\212\345\214\272\346\227\205\346\270\270\346\224\273\347\225\245.md" @@ -0,0 +1,55 @@ +--- +layout: post +title: 北京市郊区旅游攻略 +subtitle: 一份按区收集的近郊景点清单 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 旅游 + - 北京 + - 攻略 +--- + +>原始笔记只是一份景点列表,这里把它整理成可继续补充的最小占位版本,按所在区简单分组,方便后续按方向规划行程。 + +## 当前保留内容 + +### 1. 海淀 / 门头沟方向 + +- 樱桃沟游览区 +- 红螺寺(门头沟) + +### 2. 怀柔方向 + +- 雁栖湖(怀柔) +- 黄花水长城(怀柔) +- 喇叭沟原始森林风景区 - 白桦林景区 + +### 3. 延庆 / 长城方向 + +- 龙泉峪长城 +- 玉渡山高山草甸 + +### 4. 顺义 / 平谷方向 + +- 舞彩浅山滨水国家登山步道 +- 金海湖(平谷区) + +### 5. 房山方向 + +- 坡峰岭景区 + +> 备注:原文只有名称,没有标注交通方式与时长,分组只是按常见区位归类,具体行政区划与是否需要预约请以官方信息为准。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 每个景点的最佳季节(春赏花 / 夏避暑 / 秋看叶 / 冬看雪) +- 自驾 / 公共交通到达方式与大致车程 +- 一日游 / 两日游的常见组合(按方向打包,避免来回奔波) +- 是否需要提前预约、门票价格、儿童 / 老人优待信息 + +当前这篇先当作一个"北京近郊景点清单"的占位条目,后续每去一个就把实际体验补在对应条目下。 diff --git "a/_posts/life/2026-04-25-\346\260\270\350\277\234\347\232\204\350\265\244\345\220\215\350\216\211\351\246\231.md" "b/_posts/life/2026-04-25-\346\260\270\350\277\234\347\232\204\350\265\244\345\220\215\350\216\211\351\246\231.md" new file mode 100644 index 000000000..167c573fe --- /dev/null +++ "b/_posts/life/2026-04-25-\346\260\270\350\277\234\347\232\204\350\265\244\345\220\215\350\216\211\351\246\231.md" @@ -0,0 +1,109 @@ +--- +layout: post +title: 永远的赤名莉香 +subtitle: 重看《东京爱情故事》之后写下的随笔 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 影视 + - 随笔 +--- + +>原始笔记是一段开场截图加两段未拆分的代码块(其实是普通文本被错放进 ``` ``` 里),里面把整篇随笔挤成了"我喜欢莉香 / 什么都懂仍旧很天真 / 三年后"几个内嵌小标题。这里把代码块去掉、按"开场感受 / 莉香的爱情态度 / 分别与重逢"三节整理,原文一字不改地保留为引用块。 + +## 当前保留内容 + +![202204220939957 (1)](https://github.com/user-attachments/assets/712d462c-200a-442b-9ec5-be976a7923ee) + +### 1. 我喜欢莉香 + +> 电视剧一开始我就被莉香吸引了,当时丸子刚从乡下来到东京,忐忑不安,不知道明天会发生什么,莉香当时告诉他:"正因为不知道明天会发生什么,才有意思不是吗?"我一下子就被这个充满热情的女孩吸引了,那种从骨子里生发的自信与活力是我特别喜欢的。 +> +> 她会蹦蹦跳跳在街上把鞋子扔到树上占卜天气,然后撒娇求丸子帮她拿下来。当丸子问她那个大包包里面装了什么时,她会笑着说:"爱和希望!"。她工作干练,剧里经常出现她不碍于女性身份,利落得搬箱子,公司出现状况,其他人都在担心抱怨,只有莉香在想办法,笑着说:"没事,丸子一定会赶上的!"她人际交往能力一流,同事都很喜欢她,就连合作商都对她印象深刻,不用潜规则,打电话就能搞定业务。 +> +> 生活中的她,很有品味,穿衣打扮靓丽,下班会在家里养绿植,玩拼图,煮咖啡,或者去和同事唱K,小酌,还会去便利店买热乎乎的夜宵,我觉得她开头那句话不是安慰刚从乡下来的丸子,而是她自己人生态度,这是一个多么可爱,烟火气,自由自在的女孩! +> +> 在剧里,很少能看到莉香露出阴霾的样子,她每天都是很用心,很有朝气得生活。 +> +> 作为观众,我经常能被莉香这个"小太阳"温暖,如果身边有这样的人我一定会好好珍惜她。 + +### 2. 什么都懂仍旧很天真 + +> 刚开始对于莉香的爱情态度我觉得有些累,因为丸子的注意力一直在里美身上,即使看完了全剧,我也没看出来丸子爱过莉香。我甚至想到了卑微,但是越看会越觉得莉香和舔狗其实毫无关系。爸爸和我聊的时候说到女主的情况是她看了 剧本,知道接下来会发生什么,但仍旧坚持自己的喜欢。卑微是放弃原则,没有下限的妥协,莉香给我的感觉是她只是在坚持自己对爱情的看法,为此做了一切自己能做的努力。 +> +> 天真的莉香好像一直不会绝望,知道丸子心有所属,也不会轻易举白旗,她会在深夜分别后,飞奔过去抱住丸子表白,她会大大方方地发出邀约:"丸子,我们做爱吧!",她敢在大街上大喊:"丸子,我爱你!" 我看到这儿的时候有种似曾相识地感觉,以前的我和丸子一样,莉香这样的大胆举动我也做不到,后来我明白了相爱的两个人眼里只有对方,不在意其他人了,其实这是一件相当浪漫的事情。 +> +> 她就连对待情敌也坦坦荡荡。 +> +> 莉香和里美的一个区别在我看来就是:莉香希望丸子也爱她,而非得到丸子;里美想尽心思想要得到丸子。莉香的这个特质我很喜欢,我会希望自己像她一样,现实里的我或许还有很多人往往做着里美的事情。 +> +> 莉香对丸子一直以来都是,我喜欢你,我考虑的事情只有一件,那就是努力也让你喜欢我,而不是得到你。里面有段莉香的台词我特别动容: +> +> 努力过了,我这么做了 +> +> 我敲了门 +> +> "丸子, +> +> 你在做什么? +> +> 你快开门呀" +> +> 丸子却还是不理我, +> +> 还是我必须再努力呢, +> +> 是不是我要更努力才行呢? +> +> 还有一处我喜欢莉香的点在于她很本真,她和丸子吵架后,同事劝她对付男人要用技巧,即使喜欢也要偶尔装出讨厌的样子,爱不要太重了,苹果咬到芯就不好吃了。莉香笑着说:"苹果芯很有营养哦",她觉得如果男人就此跑了自己也就认了。莉香不喜欢那些恋爱小伎俩,这里其实和里美来了个对比,里美后期为了得到丸子,某些行为确实有点绿茶了……我喜欢莉香谈恋爱就付出真心,把每一场都当最后一场,不去在意技巧啊,利益得失之类的东西。 +> +> 剧里面的恋爱小细节真的太甜啦,有几个我比较喜欢的。 +> +> 当丸子因为里美的事情一个人落寞的时候,莉香晚上打去电话耍宝逗他开心,后面丸子半夜就会期待莉香的电话哈哈哈。莉香在电话里说: +> +> 所谓恋爱啊,只要是参加了就是有意义的, +> +> 即使是没有结局 +> +> 当你喜欢上一个人的那一刹 +> +> 是永远不会消失的, +> +> 这将成为你活下去的勇气, +> +> 而且会变成你黑暗中的一丝曙光 +> +> 莉香的这些话暖洋洋的,我作为观众都能从中感受到力量。 +> +> 还有莉香去出差,丸子一个人发现自己真的很想莉香,一直盼望着她回来,后来莉香回来了却没有鸟丸子哈哈哈,丸子一脸失望,就在这是突然发现了脚下的箱子,打开发现是莉香带来的"特产",这里真的好心动啊,小雪人也太浪漫啦。 + +![image](https://github.com/user-attachments/assets/9e0f1177-829a-4b82-be63-0ca08e0b7aac) + +### 3. 分别与三年后的重逢 + +> 丸子木讷含蓄,总是不经意说出伤害莉香的话,和莉香的约会也总是迟到失约,就算丸子迟到了五个消失,莉香见到他第一反应还是绽开笑颜,撒撒娇。 +> +> 最后两个人在丸子的家乡做了分别,这里导演处理的有点意思,莉香告诉丸子,自己的火车四点四十八分就开了哦,如果改变心意的话,就在车开走前来找她,不来找就分别了。丸子纠结了一番最终跑去找莉香,等到四十八分跑过去的时候在站台上怎么也找不到莉香,原来莉香坐三十三分的车走了,栏杆上挂着丸子的手帕,写着"再见,丸子。"这场单恋终于结束了。莉香始终坚持着自己的恋爱观,对得起自己也不会后悔,她和丸子说再见的时候已经想的很明白了,已经好好的放下了,那么大家好聚好散,抱着珍惜的回忆离开,这份洒脱让我觉得莉香是一个真正为自己负责的人。 +> +> 202204212251342 +> 三年后,莉香和丸子在街头相遇,丸子和里美已经结婚,丸子用开玩笑的语气说自己有点后悔,和莉香说话会紧张,但是已经和三年前那个自己判若两人了,各方面都成熟了,变得游刃有余。但是这也并不代表他爱过莉香,只是三年时间让他明白了: +> +> 莉香曾经的每一个云淡风轻背后都承受了多大的痛苦; +> +> 他曾经深深伤害了莉香; +> +> 莉香曾经给他多么美好,多么珍贵的爱情 +> +> 容我不怀好意得推测一下,丸子得心未必会全然在里美身上即便结婚。我能看到里美为他系鞋带时丸子眼里有一丝无趣,也许丸子和朋友在家里胡闹喝酒总会有一双哀怨得眼睛盯着他,反正我觉得爱情又不是找个好驾驭得人搭伙过日子哈哈哈,爱情就应该是快乐的,无私的,疯狂的,不顾一切的,就像丸子和莉香表白时说的那样! + +![image](https://github.com/user-attachments/assets/ee5a3e1e-a3c2-453c-9b51-ba183f812cf4) + +![9b99802737da928486ff06f97f71755](https://github.com/user-attachments/assets/30cc5c32-1ff5-4fba-baf6-9e86984a781e) + +## 后续可补的方向 + +- 把几句最打动自己的莉香台词单独拎一节,做成卡片式段落。 +- 写一段对照性的"里美视角",避免完全站在莉香一侧,让人物更立体。 +- 重看一遍后补一节"现在再看会不会有新的感受"。 diff --git "a/_posts/misc/2026-04-25-C#_\345\271\266\345\217\221\350\257\273\345\217\226\346\227\245\345\277\227.md" "b/_posts/misc/2026-04-25-C#_\345\271\266\345\217\221\350\257\273\345\217\226\346\227\245\345\277\227.md" new file mode 100644 index 000000000..7e3c6f924 --- /dev/null +++ "b/_posts/misc/2026-04-25-C#_\345\271\266\345\217\221\350\257\273\345\217\226\346\227\245\345\277\227.md" @@ -0,0 +1,167 @@ +--- +layout: post +title: C# 并发读取日志(未调通版本) +subtitle: 一个基于 ConcurrentBag + Parallel.ForEach 的多线程日志切分 Demo +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C# + - 多线程 + - 日志 +--- + +>原始笔记是一段未做任何拆分的 C# 代码块,开头还有一句"C# 9.0 未调通"的备注。这里按"目标 / 数据结构 / 处理类 / 待修复点"四块整理,代码内容原样保留,方便后续在 .NET 环境里直接调试。 + +## 当前保留内容 + +### 1. 目标与状态 + +- 目标:在多线程下并发读取一份较大的日志文件,按 `[ERROR]` / `[INFO]` / `[DEBUG]` 三类拆分进各自的 `ConcurrentBag`。 +- 当前状态:基于 C# 9.0,**尚未调通**,下面的代码仅作为思路骨架使用。 + +### 2. 三类日志条目 + +``` +public class ErrorLogEntry + { + public DateTime Timestamp { get; set; } + public int ErrorCode { get; set; } + public string Severity { get; set; } + public string Message { get; set; } + } + + public class InfoLogEntry + { + public DateTime Timestamp { get; set; } + public string Source { get; set; } + public string Operation { get; set; } + public string Details { get; set; } + } + + public class DebugLogEntry + { + public DateTime Timestamp { get; set; } + public string ThreadId { get; set; } + public string StackTrace { get; set; } + } +``` + +### 3. 处理类 LogProcessor + +并发读取主体:将文件按 `maxThreads` 切成若干 chunk,使用 `Parallel.ForEach` 各自打开文件流读取自己负责的区间。 + +``` +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + + public class LogProcessor + { + private readonly ConcurrentBag _errors = new(); + private readonly ConcurrentBag _infos = new(); + private readonly ConcurrentBag _debugs = new(); + + public (List, List, List) ProcessLogFile(string filePath, int maxThreads) + { + var chunks = SplitFileIntoChunks(filePath, maxThreads).ToList(); + + Parallel.ForEach(chunks, new ParallelOptions { MaxDegreeOfParallelism = maxThreads }, chunk => + { + using var fs = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + using var reader = new StreamReader(fs); + fs.Seek(chunk.Start, SeekOrigin.Begin); + + string line; + while (fs.Position < chunk.End && (line = reader.ReadLine()) != null) + { + var entry = ParseLogLine(line); + if (entry is ErrorLogEntry error) _errors.Add(error); + else if (entry is InfoLogEntry info) _infos.Add(info); + else if (entry is DebugLogEntry debug) _debugs.Add(debug); + } + }); + + return (_errors.ToList(), _infos.ToList(), _debugs.ToList()); + } + + public IEnumerable<(long Start, long End)> SplitFileIntoChunks(string filePath, int chunkCount) + { + var fileInfo = new FileInfo(filePath); + long chunkSize = fileInfo.Length / chunkCount; + long position = 0; + + for (int i = 0; i < chunkCount; i++) + { + long end = (i == chunkCount - 1) ? fileInfo.Length : position + chunkSize; + + // 确保块结束在换行符处,避免截断行 + using var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read); + stream.Seek(end, SeekOrigin.Begin); + using var reader = new StreamReader(stream); + while (!reader.EndOfStream && reader.Read() != '\n') { } + end = stream.Position; + + yield return (position, end); + position = end; + } + } + + private object ParseLogLine(string line) + { + if (string.IsNullOrEmpty(line)) return null; + + // 按分隔符拆分字段(假设使用竖线分隔) + var parts = line.Split('|', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) return null; + + if (line.StartsWith("[ERROR]")) + { + return new ErrorLogEntry + { + Timestamp = DateTime.Parse(parts[1]), + ErrorCode = int.Parse(parts[2]), + Severity = parts[3], + Message = string.Join('|', parts.Skip(4)) // 合并剩余部分作为消息 + }; + } + else if (line.StartsWith("[INFO]")) + { + return new InfoLogEntry + { + Timestamp = DateTime.Parse(parts[1]), + Source = parts[2], + Operation = parts[3], + Details = string.Join('|', parts.Skip(4)) // 合并剩余部分作为详情 + }; + } + else if (line.StartsWith("[DEBUG]")) + { + return new DebugLogEntry + { + Timestamp = DateTime.Parse(parts[1]), + ThreadId = parts[2], + StackTrace = string.Join('|', parts.Skip(3)) // 合并剩余部分作为堆栈跟踪 + }; + } + + return null; // 忽略无法识别的日志类型 + } + } +``` + +### 4. 已知待修复点 + +- chunk 边界对齐:当前在每个 chunk 末尾会再额外开一个 `FileStream` 找换行,逻辑能跑但读了两遍,性能不佳。 +- `fs.Position < chunk.End` 与 `StreamReader` 的内部缓冲不一致,可能漏读最后一行或读越界,需要换成 `Encoding` 感知的字节计数。 +- `ParseLogLine` 假定字段固定下标,没做容错;在生产日志上很容易抛 `FormatException`。 + +## 后续可补的方向 + +- 把 chunk 切分改成"先按字节切,再向后扫到换行"的一次扫描版本,避免双重打开。 +- 用 `Channel` / `BlockingCollection` 替代三个独立 `ConcurrentBag`,把分类与消费解耦。 +- 加一份单元测试日志样本(含残缺行 / 多种分隔符 / UTF-8 BOM),把这份 Demo 真正调通。 diff --git a/_posts/misc/2026-04-25-ros2_navigation.md b/_posts/misc/2026-04-25-ros2_navigation.md new file mode 100644 index 000000000..b265af854 --- /dev/null +++ b/_posts/misc/2026-04-25-ros2_navigation.md @@ -0,0 +1,36 @@ +--- +layout: post +title: ROS2 Navigation 中文文档环境笔记 +subtitle: 只保留一条把 reST 转成 PDF 的依赖安装命令 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - ROS2 + - Navigation + - Documentation +--- + +>原始笔记里只有一条命令,这里整理成可继续补充的最小占位版本。 + +## 当前保留内容 + +如果只是想把 ROS2 Navigation 的中文 reStructuredText 文档导出成 PDF,可以先准备好转换工具: + +```bash +pip install rst2pdf +``` + +然后再用 `rst2pdf input.rst -o output.pdf` 之类的方式把单篇文档导出。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- ROS2 Navigation 中文文档的来源与版本对应关系 +- 整套文档的本地构建方式(Sphinx + 主题) +- Nav2 各组件(planner / controller / behavior tree)的简要说明与阅读顺序 +- 常见示例工程(`nav2_bringup` 等)的入口 + +当前这篇先当作一个待扩充的占位条目。 diff --git a/_posts/network/2022-09-09-socket-cheat-sheet.md b/_posts/network/2022-09-09-socket-cheat-sheet.md new file mode 100644 index 000000000..5c4e1fc1f --- /dev/null +++ b/_posts/network/2022-09-09-socket-cheat-sheet.md @@ -0,0 +1,219 @@ +--- +layout: post +title: Socket 问题排查整理 +subtitle: accept 队列 / 内核参数 / fd 泄漏 / tcpdump / WebSocket curl +date: 2022-09-09 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Socket + - TCP + - Linux + - Network +--- + +>原始笔记把队列检查、内核参数、fd 排查、抓包和 WebSocket 调试混在一起,这里按排查顺序分节整理,方便回看时快速跳到对应小节。 + +## 1. accept 队列检查 + +### 1.1 全连接队列是否溢出 + +如果出现 `accept queue is full`,先确认内核行为: + +```bash +cat /proc/sys/net/ipv4/tcp_abort_on_overflow +``` + +- `0`:内核丢掉 ack(客户端会感觉「最后一次握手没回应」) +- `1`:内核直接发 RST + +观察是否真的有溢出: + +```bash +netstat -s | grep overflowed +# 例:13924575 times the listen queue of a socket overflowed +``` + +数字持续增长,就说明确实在丢连接。 + +### 1.2 监听端口的队列长度 + +```bash +ss -l | grep ':' +``` + +在「LISTEN 状态」时: + +- `Recv-Q`:当前全连接队列已堆积的数量(已完成三次握手、等待 `accept()` 的 TCP 连接) +- `Send-Q`:当前全连接最大队列长度(即 `backlog`) + +例如 `Send-Q = 128`,表示服务最多排 128 个等待 accept 的连接。 + +> 默认 backlog 经常只有 50,业务上很容易就满。 + +在「非 LISTEN 状态」时: + +- `Recv-Q`:内核已收到但应用进程尚未读走的字节数 +- `Send-Q`:已发送但尚未收到对端 ACK 的字节数 + +### 1.3 当前连接数分布 + +```bash +netstat -ant | awk '/^tcp/ {++S[$NF]} END {for (a in S) print (a, S[a])}' +``` + +可以快速看到 `ESTABLISHED` / `TIME_WAIT` / `CLOSE_WAIT` 等各占多少。 + +## 2. sysctl 内核参数 + +应对短时间大量握手与队列堆积,常调整: + +```text +net.ipv4.tcp_syncookies = 1 +net.ipv4.tcp_max_syn_backlog = 16384 +net.core.somaxconn = 16384 +``` + +接入层(如 nginx)侧也要把 `backlog` 调大: + +```text +backlog = 32768 +``` + +其它常见取舍: + +- 关闭 Nagle 算法:`TCP_NODELAY`(小包对延迟敏感时) +- `SO_SNDBUF` / `SO_RCVBUF` 不建议手动调,让内核自适应通常更稳 + +## 3. CLOSE_WAIT 堆积与 fd 泄漏 + +### 3.1 状态确认 + +```bash +netstat -antp | grep +``` + +如果发现端口大量 `CLOSE_WAIT`,通常是**应用层没有调用 `close()`**。 +原始笔记里就有过这种例子:压测 demo 漏写 `closesocket`,加上之后立即恢复。 + +### 3.2 fd 上限和当前占用 + +```bash +ulimit -a # 系统允许打开的 fd 数 +lsof -p | wc -l # 某进程已经打开的 fd 数 +``` + +### 3.3 进一步看是哪些 fd 在泄 + +```bash +lsof -p > openfiles.log +``` + +对比两个时间点的 `openfiles.log`,常见症状是大量 `can't identify protocol` 的 socket,意味着握手已结束但应用没 close。 + +### 3.4 用 strace 跟系统调用 + +```bash +strace -f -p -T -tt -o /tmp/strace_.log +``` + +参数含义: + +- `-f`:跟踪 fork/clone 出来的子进程/线程 +- `-T`:显示每条系统调用耗时 +- `-tt`:带毫秒时间戳 + +如果是从启动开始跟踪: + +```bash +strace -f -F -o dcop-strace.txt dcopserver +``` + +`-f -F` 同时跟踪 `fork` 和 `vfork` 出来的进程;`-o` 把输出写到文件,方便事后分析。 + +> `strace + lsof` 能解决大部分 fd 泄漏问题。 + +## 4. RST 与抓包确认 + +> 触发 RST 通常是因为客户端**还有未读完的数据**就关闭了 socket,属于不规范操作。 + +确认这类问题最稳的方式是抓包: + +```bash +tcpdump tcp -i xgbe0 -t -s 0 -c 100 and dst port 8863 and src net 10.128.161.11 -w ./target.cap +tcpdump tcp -i xgbe0 -t -s 0 -c 2000 and net 10.128.161.15 and net 10.128.161.11 -w ./target.cap +tcpdump -i any port 4012 -w server.pcap +``` + +常用参数: + +- `-i `:指定网卡(`any` 抓所有) +- `-t`:不显示时间戳(`-tt` 显示) +- `-s 0`:抓完整数据包(默认只抓前 96 字节,会把负载切掉) +- `-c N`:只抓 N 个包就退出 +- `dst port ! 22` / `src net 192.168.1.0/24`:BPF 过滤 +- `-w file.cap`:保存成 pcap,用 Wireshark 分析 + +> 在某些系统里,tcpdump 默认只抓每帧前 96 字节,必须加 `-s 0` 才能看到完整 payload。 + +抓到的 66 字节小包通常对应 TCP 心跳(keep-alive),可以用: + +```bash +sysctl -a | grep net.ipv4 +``` + +确认 keep-alive 相关内核参数。 + +## 5. 一些 Socket 选项 + +- `TCP_NODELAY`:关 Nagle,小包延迟敏感场景常用 +- `SO_KEEPALIVE` + `tcp_keepalive_*`:长连接探活 +- `SO_LINGER`:控制 `close()` 时尚未发送数据的处理方式 +- `SO_REUSEADDR` / `SO_REUSEPORT`:地址重用、多进程监听同端口 + +`SO_LOWDELAY` 在原始笔记里只留了个名字,实际平台支持有限,使用前先确认。 + +## 6. WebSocket 命令行调试 + +### 6.1 用 curl 发 Upgrade 请求 + +```bash +curl --include \ + --no-buffer \ + --header "Connection: Upgrade" \ + --header "Upgrade: websocket" \ + --header "Host: example.com:80" \ + --header "Origin: http://example.com:80" \ + --header "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \ + --header "Sec-WebSocket-Version: 13" \ + http://example.com:80/ +``` + +本地服务也是同样的写法,把地址换成 `127.0.0.1:` 即可。 + +### 6.2 更顺手的工具:websocat + +`curl` 只够确认握手能否完成,要实际发送 / 接收 WebSocket 帧时,更推荐用 `websocat`: + +```bash +websocat ws://127.0.0.1:8123/ +``` + +适合复现某个业务场景或快速验证服务端的消息编解码。 + +## 7. 排查顺序建议 + +按这个顺序通常更快: + +1. 先用 `netstat -ant | awk` 看连接状态分布 +2. `ss -l` 看监听队列是否打满 +3. `netstat -s | grep overflowed` 看是否真的丢连接 +4. `lsof -p` + 时间差对比找 fd 泄漏 / `CLOSE_WAIT` 来源 +5. 必要时 `tcpdump` 抓包,再用 Wireshark 看握手 / RST / Keep-Alive 行为 + +## 8. 后续可补的方向 + +- 各内核参数(`tcp_tw_reuse`、`tcp_fin_timeout` 等)的取舍备忘 +- `SO_REUSEPORT` 在多进程模型里的常见使用模式 +- 用 `bpftrace` / `ss -ti` 替代 `strace` 的轻量观测 diff --git a/_posts/network/2026-04-25-kcp.md b/_posts/network/2026-04-25-kcp.md new file mode 100644 index 000000000..b62b837b9 --- /dev/null +++ b/_posts/network/2026-04-25-kcp.md @@ -0,0 +1,189 @@ +--- +layout: post +title: KCP 协议笔记 +subtitle: 设计目的、缓存控制、丢包模拟与 TCP 单连接吞吐推导 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 网络 + - KCP + - TCP +--- + +>原始笔记基本上是几段从 issue / wiki 摘录下来的内容拼接而成,一段未拆分的引用块里塞了 KCP 设计目的、Latency vs RTT 的辩论、benchmark 图说明等多种信息。这里按"设计目的 / 缓存控制(三类业务场景)/ FEC / 多路复用 / 丢包模拟工具 / TCP 单连接吞吐推导"分节整理,原文与命令保持不动。 + +## 当前保留内容 + +### 1. KCP 的设计目的 + +``` +不是为流量设计的(每秒钟多少 KB),是为流速设计的(RTT) + +KCP 设计目的是比 TCP 更低的 Latency/RTT,而不是更好的带宽利用率或者 KB/s,那么: + +当你的传输速率接近物理带宽极限时,由于 TCP 带宽利用率更充分,所以 TCP 会更快(KB/s)。 +当你的传输没有到物理带宽极限时,当有丢包发生时,KCP 会更快(KB/s)。 +当你的传输没有到物理带宽极限时,当没有丢包发生时,两个一样快(KB/s)。 +那么为什么经常有用 kcp 加速 VPS 翻墙和音视频推流呢?因为一般你上一下 youtube 或者传递下音视频流,带宽远没有达到物理上限(不是你这种 iperf 要榨干网络的传输法),比如公网高峰期 5%-10%的丢包的时候,远距离传输的时候,此时物理带宽的上限远没达到,但是因为延迟和丢包率的存在,导致 tcp 的 latency 极高,KB/s 也上不去,这也是最常见的情况,此时 KCP 的加速效果明显,不管对 Latency 和 KB/s。 + +理解这个原理你就不会用榨干网络带宽的方式来测试 KB/s 了。 + +RTT 30%, 40% 的降低哪里来的? +真实产品 100 多万用户同时在线测试出来的,模拟丢包测试出来的,取个平均值。 + +我自己应用过 KCP 的项目,基本都测试过: +https://github.com/skywind3000/kcp/wiki/HISTORY + +不止我一个人测试,数个团队分别不同层面测试,结论相同。 + +RTT 本身很大程度上是取决于本身网络链路状态,类似于网络的一个基本属性。测试数据也显示并不能有什么提升。 + +你说的 RTT 是物理级别 UDP / ICMP 的 round-trip-time,我指的 RTT 是数据经过 TCP/KCP 之类可靠协议传输走一圈的时间,只要没有触碰到物理带宽上限,当然会比 TCP 快很多,这个数值的测试,KCP 首页末尾也提到了很多,他们的程序和测试环境描述都放在那里,你感兴趣自己去看。 + +test.cpp 也模拟了不同延迟和丢包情况下,这些策略的开关到底能带来多大的提升。这么明显的区别你但凡看过一下都不会说出 "很大程度上是取决于本身网络链路状态" 这种话来。 + +为了准确交流,RTT 指代网络物理层的 udp/icmp ping 值,而 Latency 指数据经过可靠协议传输后走一圈的延迟。标准 Latency 测试要怎么测呢?不是简单弄个 KB/s,你先要画一张表: + +丢包/延迟 10ms 50ms 100ms 200ms +0% .. .. .. .. +5% .. .. .. .. +10% .. .. .. .. +15% .. .. .. .. +20% .. .. .. .. +然后针对每一种情况,计算出一个平均延迟来,比如下面这张图,就是网络延迟 300 毫秒,丢包率 20% 的情况,多种不同的协议传输延迟(Latency)分布图: + +benchmark + +横坐标是延迟,纵坐标是该协议有百分之多少的样本延迟小于等于横坐标代表的值。 + +举个例子: + +青色圆圈,就是 TCP,在上面所述的网络条件下 50% 的样本落在了 542ms 范围内,而 70% 的样本能够落在 800ms 范围内。 +对比裸 KCP 绿色三角(P1),72% 的样本能够落在 542ms 的范围内,90%的样本落在了 600ms 范围内。 +对比 KCP+FEC 黑色十字(P3),98% 的样本落到了 416ms 的范围内。 +而青色方块(P4),是未经任何可靠协议处理的,裸 UDP RTT 时间,你可以理解成网络 RTT 的物理下限,协议做的好,就是无限制的靠近这条青色方块线,协议做的差就会远离。 + +在上面这个图中,就所有样本的平均延迟而言,KCP 472ms 同时 TCP 是 698ms,而最大延迟,KCP 比 TCP 低很多倍(记不得了)。 + +对每种不同网络情况,都做这么一张图,都得到一个平均延迟和最大延迟,填到上面的表上去,30%-40%就是这么测试出来的,搞明白了么? +``` + +![image](https://github.com/user-attachments/assets/65f464ed-224f-41af-ab6f-c00fee7f77ca) + +### 2. 缓存控制 + +参考: + +#### 2.1 游戏控制数据 + +``` +缓存控制:游戏控制数据 +大部分逻辑严密的 TCP游戏服务器,都是使用无阻塞的 tcp链接配套个 epoll之类的东西,当后端业务向用户发送数据时会追加到用户空间的一块发送缓存,比如 ring buffer 之类,当 epoll 到 EPOLL_OUT 事件时(其实也就是tcp发送缓存有空余了,不会EAGAIN/EWOULDBLOCK的时候),再把 ring buffer 里面暂存的数据使用 send 传递给系统的 SNDBUF,直到再次 EAGAIN。 + +那么 TCP SERVER的后端业务持续向客户端发送数据,而客户端又迟迟没能力接收怎么办呢?此时 epoll 会长期不返回 EPOLL_OUT事件,数据会堆积在该用户的 ring buffer 之中,如果堆积越来越多,ring buffer 会自增长的话就会把 server 的内存给耗尽。因此成熟的 tcp 游戏服务器的做法是:当客户端应用层发送缓存(非tcp的sndbuf)中待发送数据超过一定阈值,就断开 TCP链接,因为该用户没有接收能力了,无法持续接收游戏数据。 + +使用 KCP 发送游戏数据也一样,当 ikcp_waitsnd 返回值超过一定限度时,你应该断开远端链接,因为他们没有能力接收了。 + +但是需要注意的是,KCP的默认窗口都是32,比tcp的默认窗口低很多,实际使用时应提前调大窗口,但是为了公平性也不要无止尽放大(不要超过1024) + +``` + +#### 2.2 传送文件 + +``` +缓存控制:传送文件 +你用 tcp传文件的话,当网络没能力了,你的 send调用要不就是阻塞掉,要不就是 EAGAIN,然后需要通过 epoll 检查 EPOLL_OUT事件来决定下次什么时候可以继续发送。 + +KCP 也一样,如果 ikcp_waitsnd 超过阈值,比如2倍 snd_wnd,那么停止调用 ikcp_send,ikcp_waitsnd的值降下来,当然期间要保持 ikcp_update 调用。 +``` + +#### 2.3 实时视频直播 + +``` +缓存控制:实时视频直播 +视频点播和传文件一样,而视频直播,一旦 ikcp_waitsnd 超过阈值了,除了不再往 kcp 里发送新的数据包,你的视频应该进入一个 "丢帧" 状态,直到 ikcp_waitsnd 降低到阈值的 1/2,这样你的视频才不会有积累延迟。 + +这和使用 TCP推流时碰到 EAGAIN 期间,要主动丢帧的逻辑时一样的。 + +同时,如果你能做的更好点,waitsnd 超过阈值了,代表一段时间内网络传输能力下降了,此时你应该动态降低视频质量,减少码率,等网络恢复了你再恢复。 +``` + +### 3. 丢包率高的情况下提高传输效率:FEC + +``` +高丢包率的情况下使用 +https://github.com/cnbatch/kcptube/blob/main/docs/fec_zh-hans.md +``` + +### 4. KCP 如何计算丢包率 + +(待补充。) + +### 5. 多路复用 + +``` +建立多个连接? 连接太多会导致cpu占用升高,比如同时建立 tcp udp +client 端功能 +``` + +### 6. 模拟丢包工具(tc + netem) + +#### 6.1 随机丢包 10% + +``` +sudo tc qdisc add dev eth0 root netem loss 10% +``` + +#### 6.2 延迟 40ms + +``` +sudo tc qdisc add dev eth0 root netem delay 40ms +``` + +#### 6.3 仅作用于某个地址 + +``` +sudo tc qdisc add dev eth0 root handle 1: prio +sudo tc qdisc add dev eth0 parent 1:3 handle 30: netem loss 13% delay 40ms +sudo tc filter add dev eth0 protocol ip parent 1:0 u32 match ip dst 199.91.72.192 match ip dport 36000 0xffff flowid 1:3 +``` + +上面的命令告诉 tc,对发往 `199.91.72.192:36000` 的网络包产生 13% 的丢包和 40ms 的延迟,而发往其它目的地址的网络包将不受影响。 + +#### 6.4 删除规则 + +``` +sudo tc qdisc del dev eth0 root +``` + +### 7. 为什么单个 TCP 连接很难占满带宽 + +``` +计算 TCP吞吐量的公式 + +TCP窗口大小(bits) / 延迟(秒) = 每秒吞吐量(bits) + +比如说windows系统一般的窗口大小为64K, 中国到美国的网络延迟为150ms. + +64KB = 65536 Bytes. 65536 * 8 = 524288 bits + +每秒吞吐量(bits) = 524288 / 0.15 = 3495253 bit/s = 0.41MB/S + +所以就算是10M专线,那么单个Tcp连接也最大只能达到0.41M的速度。 + +计算最优 TCP窗口大小 的公式 + +带宽(bits每秒) * 往返延迟(秒) = TCP窗口大小(bits) / 8 = TCP窗口大小(字节) + +因此在芝加哥和纽约之间 10M 的带宽和 150ms 的延迟的例子中,可以计算如下: + +10 * 1024* 1024 bps * 0.15 seconds = 1572864 bits / 8 = 1,572,864 Bytes = 1.5 MB +``` + +## 后续可补的方向 + +- 把 KCP 关键参数(`nodelay` / `interval` / `resend` / `nc` / `snd_wnd` / `rcv_wnd`)的取值与场景对照表沉淀下来。 +- 给出一份本机 `tc + netem` + KCP 参数扫描脚本,可以自动生成"丢包/延迟 vs Latency"表格。 +- 补一节"KCP 如何计算丢包率"的源码走读,对应 `ikcp.c` 中的 `xmit / lost / rttvar`。 diff --git a/_posts/network/2026-04-25-linux hhwheeltimer.md b/_posts/network/2026-04-25-linux hhwheeltimer.md new file mode 100644 index 000000000..12e755333 --- /dev/null +++ b/_posts/network/2026-04-25-linux hhwheeltimer.md @@ -0,0 +1,40 @@ +--- +layout: post +title: Linux 内核定时器学习入口 +subtitle: 先记下从 timer.c 开始读起的位置 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Linux Kernel + - Timer + - HHWheelTimer +--- + +>原始笔记只有一句指引和一张示意图,这里整理成可继续补充的占位版本。 + +## 当前保留内容 + +Linux 内核里和定时器相关的核心实现集中在: + +```text +kernel/time/timer.c +``` + +后面再继续整理这块时,可以围绕这一份源码作为入口。 + +笔记里附的这张示意图保留下来: + +![image](https://github.com/user-attachments/assets/010366c1-e6ff-4437-9766-1f924c750389) + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 经典的 hashed hierarchical timing wheel 数据结构与原理 +- Linux 内核 timer wheel 的层级结构、tick 推进方式 +- 内核态 `timer_list`、`hrtimer` 与用户态 `timerfd` / `epoll` 的关系 +- `folly::HHWheelTimer` 等用户态实现与内核实现的对比 + +当前这篇先当作一个待扩充的入口条目。 diff --git a/_posts/network/2026-04-25-mbedtls.md b/_posts/network/2026-04-25-mbedtls.md new file mode 100644 index 000000000..ee5cf889c --- /dev/null +++ b/_posts/network/2026-04-25-mbedtls.md @@ -0,0 +1,90 @@ +--- +layout: post +title: mbedTLS / curl 配置 TLS 的踩坑记录 +subtitle: CIPHER_LIST、Android NDK 版本与 LTO、裁剪后的 config 同步 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - mbedTLS + - curl + - Android + - TLS +--- + +>原始笔记是几段没有标题的描述加两张截图,"坑"出现了两次,前后段落没有上下文衔接。这里按"配置疑似未生效 / NDK 版本坑 / 抓包结果 / 裁剪后同步 config"四块整理,原始代码与截图保持原样。AES IV 设置策略与 ECDH 握手时序图相关的补充已挪到 [mmtls 笔记]({{ site.baseurl }}/2026/04/25/mmtls/) 里,与 mmtls 的 HKDF / 签名讨论一起阅读更顺。 + +## 当前保留内容 + +### 1. CIPHER 配置疑似未生效 + +设置 `CURLOPT_SSLVERSION`(强制 TLS 1.2)和 `CURLOPT_SSL_CIPHER_LIST`(限定一组 ECDHE 套件)后,仍未达到预期,疑似未生效: + +``` + CURLcode ret1; + ret1 = curl_easy_setopt(curl_handle, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_2); + if(ret1 != CURLE_OK) + { + MiLogE("request.url: CURLOPT_SSLVERSION failed ! ret = %d", ret1); + } + ret1 = curl_easy_setopt(curl_handle, CURLOPT_SSL_CIPHER_LIST, + // "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" ":" + // "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" + // "TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384" + "ECDHE-ECDSA-AES256-GCM-SHA384" ":" + "ECDHE-ECDSA-AES128-GCM-SHA256" + ":" + "ECDHE-ECDSA-AES256-SHA384" ":" + "DHE-RSA-AES256-GCM-SHA384" ":" + "ECDHE-RSA-AES256-GCM-SHA384" ":" + "ECDHE-RSA-AES128-GCM-SHA256" ":" + "ECDHE-ECDSA-AES128-SHA" ":" + "ECDHE-ECDSA-AES128-SHA256" ":" + "ECDHE-RSA-CHACHA20-POLY1305" ":" + "ECDHE-RSA-AES256-SHA384" ":" + "ECDHE-RSA-AES128-SHA256" ":" + "ECDHE-ECDSA-CHACHA20-POLY1305" ":" + "ECDHE-ECDSA-AES256-SHA" ":" + "ECDHE-RSA-AES128-SHA" ":" + "DHE-RSA-AES128-GCM-SHA256" + ); + // curl_easy_setopt(curl_handle, CURLOPT_SSLVERSION, CURL_SSLVERSION_TLSv1_1|CURL_SSLVERSION_TLSv1_2 | CURL_SSLVERSION_TLSv1_3 | CURL_SSLVERSION_TLSv1 | CURL_SSLVERSION_SSLv2 | CURL_SSLVERSION_SSLv3); + if(ret1 != CURLE_OK) + { + MiLogE("request.url:CURLOPT_SSL_CIPHER_LIST ret = %d", ret1); + } + + // curl_easy_setopt(curl_handle, CURLOPT_TLS13_CIPHERS, + // // "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256" ":" + // // "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384" + // "TLS-ECDHE-ECDSA-WITH-AES-256-GCM-SHA384" + // ); +``` + +### 2. Android NDK 版本坑 + +- Android NDK 版本:NDK 23 可用;NDK 25 / NDK 26 都遇到过问题(具体啥问题记录时已忘)。 +- 实测换到低版本 NDK 后好使,但是没搞清高版本为什么不行。 + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/59fe9871-a8e3-4fc4-b58e-4983a351c30b) + +可能的方向:clang 版本不同 → LTO 支持不同。低版本 NDK 可能不支持 LTO;Android LTO 还有一个坑——cxx link 阶段需要同时增加 `-flto`,否则会报: + +``` +file format not recognized +``` + +### 3. 抓包结果 + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/f753f8f2-27e7-4140-a3f9-0c24991baa4b) + +### 4. 裁剪后的 mbedtls_config.h 同步 + +裁剪 mbedTLS 时,**记得同步修改 `mbedtls_config.h`**,否则编出来的库行为可能与预期不符。 + +## 后续可补的方向 + +- 把"CIPHER_LIST 疑似未生效"复盘到底是 curl 后端编译选项问题,还是 mbedTLS 端不支持这些套件 +- 整理一张 NDK 版本 × clang 版本 × LTO 支持情况的对照表 +- AES IV 策略与 ECDH 握手时序图见 mmtls 笔记,后续可补 0-RTT PSK / 0-RTT PSK-ECDHE 两种变体的时序图 diff --git a/_posts/network/2026-04-25-mmtls.md b/_posts/network/2026-04-25-mmtls.md new file mode 100644 index 000000000..fdb5d6f1e --- /dev/null +++ b/_posts/network/2026-04-25-mmtls.md @@ -0,0 +1,383 @@ +--- +layout: post +title: mmtls 协议笔记 +subtitle: 微信自研 mmtls 与 TLS 1.3 在握手方式 / 公钥派发 / 密钥扩展上的取舍 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 网络 + - 安全 + - TLS + - 微信 + - mbedTLS +--- + +>原始笔记是几张图片夹着大段未拆分的 TLS / mmtls 论述,可读性很差。这里按"握手方式选择 / 签名密钥泄露与撤销 / 签名内容如何与本次握手绑定 / 1-RTT ECDHE 细节 / 密钥扩展(HKDF)/ verify_key 的下发与撤销 / 参考资料"分节整理,原文与配图原样保留。 + +## 当前保留内容 + +![image](https://user-images.githubusercontent.com/8308226/234161851-298a1b93-57cf-4589-828b-b9f2741d3cad.png) + +### 1. 握手方式选择 + +(1) 客户端没有 PSK,为了安全性,这时和长连接的握手方式一样,使用 1-RTT ECDHE。 + +(2) 客户端有 PSK,这时为了减少网络时延,应该使用 0-RTT PSK 或 0-RTT PSK-ECDHE。在这两种握手方式下,由于业务请求包始终是基于 PSK 进行保护的,同一个 PSK 多次协商出来的对称加密 key 是同一个,这个对称加密 key 的安全性依赖于 ticket_key 的安全性,因此 0-RTT 情况下,业务请求包始终是无法做到前向安全性。0-RTT PSK-ECDHE 这种方式只能保证本短连接业务响应回包的前向安全性,这带来安全性上的优势是比较小的,但是与 0-RTT PSK 握手方式相比,0-RTT PSK-ECDHE 在每次握手对 server 会多 2 次 ECDH 运算和 1 次 ECDSA 运算。微信的短连接是非常频繁的,这对性能影响极大,因此综合考虑,在客户端有 PSK 的情况下,我们选择使用 0-RTT PSK 握手。 + +由于 0-RTT PSK 握手安全性依赖 ticket_key,为了加强安全性,在实现上: + +- PSK 必须要限制过期时间,避免长期用同一个 PSK 来进行握手协商; +- ticket_key 必须定期轮换,且具有高度机密的运维级别。 + +三种方式:1-RTT ECDHE 握手、1-RTT PSK 握手、0-RTT PSK 握手。 + +> client 端本身的完整性,非 mmtls 协议保护的范畴;mmtls 仅对 server 端进行 ECDSA 认证——意思是 server 端需要进行 sign,client 端需要进行 verify。 + +### 2. 如何避免签名密钥 sign_key 泄露带来的影响? + +沿用现有逻辑。 + +如果 sign_key 泄露,那么任何人都可以伪造成 Server 欺骗 Client,因为它拿到了 sign_key 就可以签发任何内容,Client 用 verify_key 去验证签名必然验签成功。因此 sign_key 如果泄露必须要能够对 verify_key 进行撤销,重新派发新的公钥。这其实和前一问题(公钥派发)是紧密联系的,本问题是公钥撤销问题。TLS 是通过 CRL 和 OCSP 两种方式来撤销公钥的,但是这两种方式存在撤销不及时或给验证带来额外延迟的副作用。由于 mmtls 是通过内置 verify_key 在客户端,必要时通过强制升级客户端的方式就能完成公钥撤销及更新。 + +另外,sign_key 是需要 Server 高度保密的,一般不会被泄露,对于微信后台来说,类似于 sign_key 这样需要长期私密保存的密钥之前也有存在,早已形成了一套方法和流程来应对长期私密保存密钥的问题。 + +### 3. 用 sign_key 进行签名的内容仅仅只包含 svr_pub_key 是否有隐患? + +回顾一下,上面描述的带认证的 ECDH 协商过程似乎已经足够安全,无懈可击了。但是面对成亿的客户端发起 ECDH 握手到成千上万台接入层机器,每台机器对一个 TCP 连接随机生成不同的 ECDH 公私钥对,这里试想一种情况:假设某一台机器某一次生成的 ECDH 私钥 svr_pri_key1 泄露,这实际上是可能的——因为临时生成的 ECDH 公私钥对本身没有做任何保密保存的措施,是明文、短暂地存放在内存中,一般情况没有问题,但在分布式环境,大量机器大量随机生成公私钥对的情况下,难保某一次不被泄露。 + +这样用 sign_key(sign_key 是长期保存且分布式环境共享的)对 svr_pub_key1 进行签名得到签名值 Signature1。此时攻击者已经拿到 svr_pri_key1、svr_pub_key1 和 Signature1,他就可以实施中间人攻击:让客户端每次拿到的服务器 ECDH 公钥都是 svr_pub_key1。 + +- 客户端随机生成 ECDH 公私钥对 (cli_pub_key, cli_pri_key) 并将 cli_pub_key 发给 Server; +- 中间人将消息拦截下来,将 cli_pub_key 替换成自己生成的 cli_pub_key',并将 svr_pub_key1 和 Signature1 回给 Client; +- Client 通过计算 `ECDH_Compute_Key(svr_pub_key1, cli_pri_key) = Key1`; +- Server 通过计算 `ECDH_Compute_Key(cli_pub_key', svr_pub_key) = Key'`; +- 中间人既可以计算出 Key1 和 Key',这样它就可以用 Key1 和 Client 通信,用 Key' 和 Server 进行通信。 + +发生上述被攻击的原因在于一次握手中公钥的签名值被用于另外一次握手中。如果有一种方法能够使得这个签名值和一次握手一一对应,那么就能解决这个问题。 + +解决办法也很简单:在握手请求的 ClientHello 消息中带一个 Client_Random 随机值,然后在签名的时候将 Client_Random 和 svr_pub_key 一起做签名,这样得到的签名值就与 Client_Random 对应了。mmtls 在实际处理过程中,为了避免 Client 的随机数生成器有问题、造成生成不够随机的 Client_Random,实际上 Server 也会生成一个随机数 Server_Random,然后在对公钥签名的时候将 Client_Random、Server_Random、svr_pub_key 一起做签名——这样由 Client_Random、Server_Random 保证得到的签名值唯一对应一次握手。 + +### 4. 1-RTT ECDHE 细节 + +#### 4.1 随机数生成 + +随机数生成算法、防重放攻击,client_random、server_random,选择第 3 种: + +1) `random` 函数:使用线性同余算法生成伪随机数(最初级随机数,不够随机) +2) `random_device`:linux 系统中为抓取 `/dev/urandom` 设备中生成的随机数流(真随机数,但还是不够随机) +3) `mt19937`:梅森旋转法,理论上可以产生完全随机数,一般使用 `random_device` 作为种子 +4) `uniform_int_distribution`:整数均匀分布,对生成的随机数作处理,转换成一定范围均匀分布的随机数 + +#### 4.2 密钥扩展 + +TLS 1.3 明确要求通信双方使用的对称加密 Key 不能完全一样,否则在一些对称加密算法下会被完全攻破,即使是使用 AES-GCM 算法,如果通信双方使用完全相同的加密密钥进行通信,在使用的时候也要小心翼翼地保证一些额外条件,否则会泄露部分明文信息。另外,AES 算法的初始化向量(IV)如何构造也是很有讲究的,一旦用错就会有安全漏洞。 + +也就是说,对于 handshake 协议协商得到的 pre_master_secret 不能直接作为双方进行对称加密密钥,需要经过某种扩展变换,得到六个对称加密参数: + +``` +Client Write MAC Key (用于Client算消息认证码,以及Server验证消息认证码) +Server Write MAC Key (用于Server算消息认证码,以及Client验证消息认证码) +Client Write Encryption Key(用做Client做加密,以及Server解密) +Server Write Encryption Key(用做Server做加密,以及Client解密) +Client Write IV (Client加密时使用的初始化向量)  +Server Write IV (Server加密时使用的初始化向量) +``` + +当然,使用 AES-GCM 作为对称加密组件,MAC Key 和 Encryption Key 只需要一个就可以了。 + +握手生成的 pre_master_secret 只有 48 个字节,上述几个加密参数的长度加起来肯定就超过 48 字节了,所以需要一个函数来把 48 字节延长到需要的长度。在密码学中专门有一类算法承担密钥扩展的功能,称为密钥衍生函数(Key Derivation Function)。TLS 1.3 使用 HKDF 做密钥扩展,mmtls 也是选用的 HKDF 做密钥扩展。 + +在前文中,用 pre_master_secret 代表握手协商得到的对称密钥,在 TLS 1.2 之前确实叫这个名字,但是在 TLS 1.3 中由于需要支持 0-RTT 握手,协商出来的对称密钥可能会有两个,分别称为 Static Secret (SS) 和 Ephemeral Secret (ES)。从 TLS 1.3 文档中截取一张图进行说明: + +![image](https://user-images.githubusercontent.com/8308226/234161990-9965d48a-17bb-4c1d-a0f9-6563390b1412.png) + +上图中 Key Exchange 就是代表握手的方式: + +- 在 1-RTT ECDHE 握手方式下: + +``` +ES=SS = ECDH_Compute_Key(svr_pub_key, cli_pri_key); +``` + +- 在 0-RTT ECDH 下: + +``` +SS=ECDH_Compute_Key(static_svr_pub_key, cli_pri_key), +ES=ECDH_Compute_Key(svr_pub_Key, cli_pri_Key); +``` + +- 在 0-RTT/1-RTT PSK 握手下: + +``` +ES=SS=pre-shared key; +``` + +- 在 0-RTT PSK-ECDHE 握手下: + +``` +SS=pre-shared key, +ES=ECDH_Compute_Key(svr_pub_key, cli_pri_key); +``` + +mmtls 使用的密钥扩展组件为 HKDF,该组件定义了两个函数来保证扩展出来的密钥具有伪随机性、唯一性、不能逆推原密钥、可扩展任意长度密钥: + +``` +HKDF-Extract( salt, initial-keying-material ) +``` + +该函数的作用是对 initial-keying-material 进行处理,保证它的熵均匀分布,足够伪随机。 + +``` +HKDF-Expand( pseudorandom key, info, out_key_length ) +``` + +参数 pseudorandom key 是已经足够伪随机的密钥扩展材料,HKDF-Extract 的返回值可以作为 pseudorandom key;info 用来区分扩展出来的 Key 是做什么用;out_key_length 表示希望扩展输出的 key 有多长。mmtls 最终使用的密钥是由 HKDF-Expand 扩展出来的。mmtls 把 info 参数分为 length、label、handshake_hash:其中 length 等于 out_key_length;label 是标记密钥用途的固定字符串;handshake_hash 表示握手消息的 hash 值——这样扩展出来的密钥保证连接内唯一。 + +![image](https://user-images.githubusercontent.com/8308226/234162280-f526093e-14d7-40ca-86ae-77848939f98e.png) + +TLS 1.3 草案中定义的密钥扩展方式比较繁琐,如上图所示。为了得到最终认证加密的对称密钥,需要做 3 次 HKDF-Extract 和 4 次 HKDF-Expand 操作,实际测试发现这种密钥扩展方式对性能影响是很大的,尤其在 PSK 握手情况(PSK 握手没有非对称运算)这种密钥扩展方式成为性能瓶颈。 + +TLS 1.3 之所以把密钥扩展搞这么复杂,本质上还是因为 TLS 1.3 是一个通用的协议框架,具体的协商算法是可以选择的,在有些协商算法下,协商出来的 pre_master_key (SS 和 ES) 就不满足某些特性(如随机性不够),因此为了保证无论选择什么协商算法用它来进行通信都是安全的,TLS 1.3 就在密钥扩展上做了额外的工作。而 mmtls 没有 TLS 1.3 这种包袱,可以针对微信自己的网络通信特点进行优化(前面在握手方式选择上就有体现)。mmtls 在不降低安全性的前提下,对 TLS 1.3 的密钥扩展做了精简,使得性能上较 TLS 1.3 的密钥扩展方式有明显提升。 + +在 mmtls 中,pre_master_key (SS 和 ES) 经过密钥扩展,得到了一个长度为 `2*enc_key_length + 2*iv_length` 的一段 buffer,用 key_block 表示,其中: + +``` +client_write_key = key_block[0...enc_key_length-1] +client_write_key = key_block[enc_key_length...2*enc_key_length-1] +client_write_IV = key_block[2*enc_key_length...2*enc_key_length+iv_length-1] +server_write_IV = key_block[2*enc_key_length+iv_length...2*enc_key_length+2*iv_length-1] +``` + +#### 4.3 防重放 + +AES-GCM 使用 nonce(随机数),认证成功即可。 + +### 5. client 端 verify_pub_key 如何更新? + +- **Verify_Key 如何下发给客户端?** + + 这实际上是公钥派发的问题。TLS 是使用证书链的方式来派发公钥(证书),对于微信来说,如果使用证书链的方式来派发 Server 的公钥(证书),无论自建 Root CA 还是从 CA 处申请证书,都会增加成本且在验签过程中会存在额外的资源消耗。由于客户端是由我们自己发布的,可以将 verify_key 直接内置在客户端,这样就避免证书链验证带来的时间消耗以及证书链传输带来的带宽消耗。 + +- **server 端** + + ![image](https://user-images.githubusercontent.com/8308226/234162401-d630b188-b512-4f18-9224-89b684c8145e.png) + +### 6. 参考资料 + +1. + + ![image](https://user-images.githubusercontent.com/8308226/234162488-cfb26107-4044-4455-a332-46ae04a57ce9.png) + +2. + +### 7. 哪些请求适合 0-RTT + +0-RTT 的本质是:客户端在还没拿到服务端 Finished 之前,就用之前缓存的 PSK 把数据加密发出去。这意味着 **early data 缺乏新鲜性保护,会被中间人重放**。所以业务侧第一道筛子是"这条请求被重放一次会不会出问题"。 + +**适合走 0-RTT:** + +- 幂等请求 / 可重复执行不影响状态的请求 +- GET / 查询类请求 +- 拉配置 +- 获取资源 +- 无副作用操作 + +**不适合走 0-RTT:** + +- 转账 +- 下单扣费 +- 改密码 +- 修改状态 +- 创建资源 +- 任何 replay 会造成业务问题的请求 + +#### 7.1 前提:之前已经建立过一次连接 + +要使用 0-RTT,必须先完成过一次正常 TLS 1.3 握手,服务端通过 `NewSessionTicket` 把恢复凭据下发给客户端。 + +客户端需要保存: + +- ticket +- 对应的 PSK 信息 +- cipher suite +- ALPN +- SNI / 域名 +- 最大 early data 大小(`max_early_data_size`)等 + +下一次再连接时,客户端才能尝试 0-RTT。 + +#### 7.2 全流程大图:分两阶段看 + +- **阶段 A:第一次完整握手** —— 建立正常 TLS 连接、服务端发 session ticket、客户端缓存 PSK 材料。 +- **阶段 B:第二次连接走 0-RTT** —— 客户端带着 ticket / PSK 恢复会话,**直接发送 early data**,等服务端 Finished 之后再切到 1-RTT 应用流量密钥。 + +#### 7.3 第一次完整握手(为后续 0-RTT 做准备) + +```mermaid +sequenceDiagram + participant C as Client + participant S as Server + + C->>S: ClientHello + S->>C: ServerHello + S->>C: EncryptedExtensions + S->>C: Certificate + S->>C: CertificateVerify + S->>C: Finished + + C->>S: Finished + Note over C,S: Handshake complete, 1-RTT keys established + + S->>C: NewSessionTicket + Note over C: Store ticket / PSK for future 0-RTT resumption +``` + +第一次握手里: + +- 正常做 ECDHE +- 正常做证书认证 +- 正常建立 1-RTT 密钥 +- 然后服务端额外发 `NewSessionTicket` + +这个 ticket 不是直接明文"密钥本体",而是恢复会话的凭据。客户端以后拿它来派生 PSK 或恢复 PSK 身份。 + +#### 7.4 第二次连接:0-RTT + PSK 流程图 + +```mermaid +sequenceDiagram + participant C as Client + participant S as Server + + Note over C: Has cached PSK/ticket from previous session + + C->>S: ClientHello + key_share + pre_shared_key + early_data + C->>S: 0-RTT Application Data (encrypted with early data key) + + S->>C: ServerHello + S->>C: EncryptedExtensions + S->>C: Finished + Note over S: Server accepts PSK and may accept or reject early data + + C->>S: Finished + Note over C,S: Handshake completes, switch to handshake/application traffic keys + + C->>S: 1-RTT Application Data + S->>C: 1-RTT Application Data +``` + +几个落地时要注意的点: + +- 客户端只在 early_data 里发幂等请求;非幂等请求即使 ticket 还有效,也要等 1-RTT 密钥就绪再发。 +- 服务端可以接受或拒绝 early data。被拒绝时客户端必须能在 1-RTT 密钥就绪后**重发**这批数据,所以业务层不能假设 0-RTT 一定生效。 +- early_data 的总长度受 `max_early_data_size` 限制,超过部分要排队等到握手完成。 +- 服务端需要做反重放(基于 ticket + 时间窗口 + 唯一标识),否则即使是幂等请求,重放放大也可能打穿后端缓存。 + +## mbedTLS 实战补充:AES IV 与 ECDH 握手时序图 + +> mbedTLS / curl 的踩坑记录(CIPHER_LIST、NDK 版本、抓包、裁剪 config)仍在独立的 [mbedtls 笔记]({{ site.baseurl }}/2026/04/25/mbedtls/) 里;这里只补两块和上文 mmtls 协议讨论紧密相关的内容:AES key 配套的 IV / nonce 怎么选、以及完整 1-RTT ECDHE 握手的时序图。 + +### 1. AES key 与 IV 的设置策略 + +下面只讨论"对称会话密钥已经协商好"之后,如何安全地选取 IV / nonce。结论先放在前面: + +- **AES key**:来自 ECDH/HKDF 的派生结果(参见上文 §4.2 的 `key_block`),长度按算法选 16 / 24 / 32 字节,整条会话内不变。 +- **IV / nonce**:**绝不能复用**,不同模式有不同要求(见下表)。 + +#### 1.1 不同模式下 IV 的硬性要求 + +| 模式 | IV / nonce 长度 | 是否需要随机 | 是否可公开传输 | 重复使用的后果 | +| --------------- | ------------------- | ------------ | -------------- | ----------------------------------------- | +| AES-CBC | 16 字节 | 必须不可预测 | 是 | 同 key 同 IV → 相同前缀的明文产生相同密文 | +| AES-CTR | 16 字节(含计数器) | 唯一即可 | 是 | 同 (key, counter) → 直接异或泄漏明文 | +| AES-GCM | 12 字节(推荐) | 唯一即可 | 是 | **同 (key, IV) 直接破坏认证密钥,灾难性** | +| AES-CCM | 7~13 字节 | 唯一即可 | 是 | 同 GCM,认证强度被打穿 | + +#### 1.2 推荐策略(mbedTLS 调用层) + +- **优先 AES-GCM-12B nonce**,构造方式 `nonce = 4B salt || 8B counter`: + - `salt` 由握手期 HKDF 派生(即上文 §4.2 中的 `client_write_IV` / `server_write_IV`),双方各持自己方向的 salt; + - `counter` 从 0 开始,每发一帧 `+1`,到达 2^64-1 之前必须重协商; + - 这样能保证"同一 key 下 nonce 全局唯一",且不需要随机源。 +- **CBC 场景**用 `mbedtls_ctr_drbg_random()` 现取 16 字节作为 IV,**和密文一起传给对端**,不要从计数器派生(CBC 要求"不可预测",单调计数器不满足)。 +- **CTR 场景**把 IV 拆成 `8B nonce || 8B block_counter`,`nonce` 每条消息独立,`block_counter` 在加密块内自增。 +- 任何模式下:**key 轮换**(rekey)触发条件 = 计数器接近上限 / 累计加密字节超过 2^36 / 会话超时,三者取最早。 + +#### 1.3 mbedTLS 调用要点 + +- 加密前用 `mbedtls_gcm_setkey()` 一次,之后每条消息只调用 `mbedtls_gcm_crypt_and_tag()` / `mbedtls_gcm_auth_decrypt()`,传入新的 nonce。 +- 不要直接复用 `mbedtls_gcm_starts()` + `update()` + `finish()` 的中间状态——重置不彻底容易让前一条消息的 nonce/计数器残留。 +- IV 不需要保密,但**必须保证完整性**:在 GCM 中 IV 已经被纳入 GHASH 计算,篡改即认证失败;在 CBC 中要靠外层 MAC(推荐 Encrypt-then-MAC)覆盖 IV。 + +### 2. ECDH 握手时序图 + +下面是与本文前半部分讨论的"带服务端认证的 1-RTT ECDHE"对应的时序图,方便和上一节的 AES key 派生衔接。 + +```mermaid +sequenceDiagram + autonumber + participant C as Client + participant S as Server + participant V as 客户端内置 verify_key + + Note over C: 生成 (cli_pri, cli_pub)
生成 client_random + C->>S: ClientHello { client_random, cli_pub, cipher_suites } + + Note over S: 生成 (svr_pri, svr_pub)
生成 server_random
signature = ECDSA_sign(sign_key,
client_random ‖ server_random ‖ svr_pub) + S-->>C: ServerHello { server_random, svr_pub, signature } + + Note over C,V: ECDSA_verify(verify_key,
client_random ‖ server_random ‖ svr_pub,
signature) == OK + C->>V: 验签 + V-->>C: 通过 + + Note over C: shared_secret = ECDH(cli_pri, svr_pub) + Note over S: shared_secret = ECDH(svr_pri, cli_pub) + + Note over C,S: HKDF-Extract(salt=client_random‖server_random,
IKM=shared_secret) → PRK
HKDF-Expand(PRK, "c2s key/iv") / ("s2c key/iv")
得到 AES key + 12B salt(见上一节 §1.2) + + C->>S: Finished (AES-GCM, nonce = c2s_salt ‖ counter=0) + S-->>C: Finished (AES-GCM, nonce = s2c_salt ‖ counter=0) + + Note over C,S: 业务数据:counter 各自单调递增,
到上限前触发 rekey +``` + +如果 GitHub 没渲染 mermaid,可以读下面这份纯文本对照: + +``` + Client Server + | | + | --- ClientHello (client_random, cli_pub) ->| + | | + | 生成 (svr_pri, svr_pub) + | 签名: + | sig = sign(sign_key, + | client_random || + | server_random || + | svr_pub) + | <-- ServerHello (server_random, svr_pub, sig) -| + | | + verify(verify_key, ..., sig) | + shared = ECDH(cli_pri, svr_pub) shared = ECDH(svr_pri, cli_pub) + | | + | HKDF 派生: c2s_key/c2s_salt, s2c_key/s2c_salt + | | + | -- AES-GCM(c2s_key, c2s_salt||ctr++) ----->| + | <- AES-GCM(s2c_key, s2c_salt||ctr++) ------| + | | +``` + +几个易错点(与上文 §3 的"签名仅含 svr_pub_key 的隐患"呼应): + +- 签名内容**必须**包含 `client_random` 和 `server_random`,否则攻击者可以把一次握手的 `(svr_pub, sig)` 重放到另一次握手里。 +- `cli_pri / svr_pri` 是**临时**密钥对,每次握手新生成,握手完成后立即销毁——这是前向安全(PFS)的来源。 +- HKDF 的 `salt` 用 `client_random ‖ server_random` 而不是常量,能避免不同会话派生出相同 PRK。 +- 客户端做完 ECDH 之后立刻校验 `svr_pub` **不在小子群上**(X25519 由库内置处理;P-256 需要 `mbedtls_ecp_check_pubkey()`),否则会泄漏私钥。 + +## 后续可补的方向 + +- 把三种握手方式(1-RTT ECDHE / 0-RTT PSK / 0-RTT PSK-ECDHE)画成时序图,标出每段密钥的来源与生命周期。 +- 对照 TLS 1.3 的 `HKDF-Expand-Label`,逐步梳理 mmtls 精简后到底省掉了哪几次 Extract/Expand。 +- 跟踪 verify_key 的"客户端强制升级"流程:灰度策略、回滚开关、与旧版本的兼容窗口。 +- 把上文 §1.2 的 AES-GCM nonce 拼装方式写成一个最小 demo,对照 mbedTLS 的 `gcm_self_test` 跑一遍。 +- 在 §2 时序图基础上补 0-RTT PSK 与 0-RTT PSK-ECDHE 两种变体。 diff --git a/_posts/network/2026-04-25-timerThread.md b/_posts/network/2026-04-25-timerThread.md new file mode 100644 index 000000000..1e852342d --- /dev/null +++ b/_posts/network/2026-04-25-timerThread.md @@ -0,0 +1,212 @@ +--- +layout: post +title: 纯 C++ 写一个最小可用的 TimerThread +subtitle: 只用 thread / mutex / condition_variable 的简化版本与设计取舍 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - C++ + - Thread + - Timer +--- + +>原始笔记是繁体中文的一段连续叙述加完整代码,这里整理成「需求 / 接口 / 实现 / 设计取舍」四块,原始代码基本保留。 + +## 1. 设计目标 + +有时候不想直接用平台提供的 Timer API,更希望用纯 C++ 写一个最小可用的版本,至少满足以下三点: + +1. **不过度设计**:只提供「定时执行某个函数」的最基本能力。 +2. **可随时停止**:关闭程序时不必等到定时时间到才能退出。 +3. **task 内部能感知是否被中止**:定时任务自己执行较久时,可以在中间检查点判断 Timer 是否已经被外部停掉,及时跳出。 + +下面接口与实现都围绕这三点展开。 + +## 2. 接口设计 + +`TimerThread.h`: + +```cpp +#pragma once + +#include +#include +#include +#include +#include + +class TimerThread final { +public: + TimerThread(); + TimerThread(const TimerThread&) = delete; + TimerThread(TimerThread&&) = delete; + TimerThread& operator=(const TimerThread&) = delete; + TimerThread& operator=(TimerThread&&) = delete; + ~TimerThread(); + + void setTimerTask(const std::function& task); + void startTimer(long long ms); + void stopTimer(); + bool isRunning() const; + +private: + std::function task_; + std::atomic running_; + std::mutex mutex_; + std::condition_variable cv_timer_; + std::thread thread_; +}; +``` + +### 为什么 task 不放在构造函数里 + +直觉上把 task 作为构造参数看起来更安全: + +```cpp +explicit TimerThread(const std::function& task); +``` + +但这会让灵活性下降很多。例如: + +- 想把 `TimerThread` 当成成员变量时,往往无法在构造时就拿到 task +- 真正的 task 通常需要在某个成员函数里 `std::bind` 一些局部变量 +- 如果坚持构造时传入,最后通常只能改成「指针 + 延迟构造」的写法,绕回原点 + +所以这里把 task 拆出来,用 `setTimerTask()` 单独设定。 + +### `bool isRunning() const` 看起来多余但很重要 + +它提供了一种「在 task 内部检查 Timer 是否已经被停止」的能力。 +如果 task 比较长,使用者可以在中间反复调用 `isRunning()`,一旦发现已经被外部 `stopTimer()`,就尽快退出。 + +例如关闭程序时,没人愿意等一个还要跑很久的 task 把窗口卡在那。 + +## 3. 实现 + +`TimerThread.cpp`: + +```cpp +#include "TimerThread.h" + +#include +#include + +TimerThread::TimerThread() + : task_(), + running_(false), + mutex_(), + cv_timer_(), + thread_() {} + +TimerThread::~TimerThread() { + stopTimer(); +} + +void TimerThread::setTimerTask(const std::function& task) { + assert(thread_.get_id() != std::this_thread::get_id()); + assert(!running_); + task_ = task; +} + +void TimerThread::startTimer(long long ms) { + assert(thread_.get_id() != std::this_thread::get_id()); + assert(task_); + assert(!running_); + + running_ = true; + thread_ = std::thread([this, ms]() { + while (running_) { + { + std::unique_lock lock(mutex_); + cv_timer_.wait_for(lock, std::chrono::milliseconds(ms), [this] { + return !running_; + }); + } + + if (!running_) { + return; + } + + task_(); + } + }); +} + +void TimerThread::stopTimer() { + assert(thread_.get_id() != std::this_thread::get_id()); + running_ = false; + cv_timer_.notify_one(); + if (thread_.joinable()) { + thread_.join(); + } +} + +bool TimerThread::isRunning() const { + return running_; +} +``` + +## 4. 几个关键点的解释 + +### 析构里调用 `stopTimer()` + +```cpp +TimerThread::~TimerThread() { + stopTimer(); +} +``` + +避免使用者忘记停止,也避免发生异常时线程无法回收。 + +### `setTimerTask()` 的两个 assert + +```cpp +void TimerThread::setTimerTask(const std::function& task) { + assert(thread_.get_id() != std::this_thread::get_id()); + assert(!running_); + task_ = task; +} +``` + +- 第一个:防止使用者在 task 内部又调用 `setTimerTask()`,造成「自己等自己」的死锁。 +- 第二个:要求设置新 task 前先停止 Timer,防止线程正在执行旧 task 时被改成新的 task。这里**故意不加锁**,因为 `TimerThread` 本身就不打算做成 thread-safe。 + +### `startTimer()` 用 `condition_variable` 等待 + +```cpp +cv_timer_.wait_for(lock, std::chrono::milliseconds(ms), [this] { + return !running_; +}); +``` + +如果 Timer 设了 60 分钟,关闭程序时不可能等 60 分钟才能退出。 +通过 `cv_timer_.notify_one()` + `running_ = false`,可以在等待过程中立即被唤醒并退出。 + +### `stopTimer()` 中先改标志再 notify + +```cpp +void TimerThread::stopTimer() { + running_ = false; + cv_timer_.notify_one(); + if (thread_.joinable()) { + thread_.join(); + } +} +``` + +先把 `running_` 置为 false,再 notify,避免「被唤醒后发现条件还没变」的伪唤醒浪费一轮检查。 + +## 5. 还有两个使用上要注意的点 + +1. **task 跨线程访问数据要自己保护**:task 是在 Timer 线程里执行的,如果它访问的变量同时被其他线程修改,仍然需要使用者自己加锁。 +2. **task 执行时间过长会拖累下一次触发**:如果设定 1 秒触发一次,但 task 自己跑了 1 秒,下次触发实际上变成了 2 秒后。 + 常见解法是 task 内部把真正的工作转给另一个线程或线程池,Timer 线程只负责「按时间发起调度」。 + +## 6. 后续可补的方向 + +- 同时支持「单次触发」和「周期触发」 +- 多个 task 共享同一个调度线程的 TimerService +- 在内部使用最小堆 / `std::set` 调度多个 timer +- 与 `executor` / `event loop` 框架(如 boost.asio、libuv)的对接方式 diff --git a/_posts/network/2026-04-25-xquic.md b/_posts/network/2026-04-25-xquic.md new file mode 100644 index 000000000..9320e23bf --- /dev/null +++ b/_posts/network/2026-04-25-xquic.md @@ -0,0 +1,168 @@ +--- +layout: post +title: XQUIC 笔记整理 +subtitle: 握手流程、传输模式与简单压测结论 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - QUIC + - XQUIC + - Network +--- + +>把原始记录中的握手流程、测试命令和压测结论收敛成一份便于回看的最小笔记。 + +## 整体流程 + +XQUIC 的连接过程可以先按四段理解: + +1. 初始化:Client / Server 分别创建 UDP socket 和 XQUIC engine +2. 握手:TLS 1.3 跑在 QUIC 上,完成密钥协商 +3. 业务传输:走 transport stream 或 HTTP/3 request +4. 关闭:发送 `CONNECTION_CLOSE`,释放连接对象 + +## 握手时序图 + +```text +sequenceDiagram + participant C as Xquic Client + participant S as Xquic Server + + Note over C,S: 1. Initialization + C->>C: Create UDP Socket & Engine + S->>S: Bind UDP Socket & Create Engine + + Note over C,S: 2. Handshake (TLS 1.3 over QUIC) + C->>S: Initial Packet (ClientHello) + S->>S: Create Conn Object, Trigger on_conn_create + S->>C: Initial + Handshake Packets (ServerHello, Cert) + C->>C: Verify Cert, Derive Keys + C->>S: Handshake Packet (Finished) + S->>S: Verify Finished, Trigger on_handshake_finished + S->>C: Handshake Packet (Finished) + 1-RTT Keys + C->>C: Trigger on_handshake_finished + + Note over C,S: 3. Data Transfer + C->>C: Create Stream / Request + C->>S: 1-RTT Packet + S->>S: Trigger read callback + S->>C: Response + + Note over C,S: 4. Teardown + C->>S: CONNECTION_CLOSE + S->>C: CONNECTION_CLOSE +``` + +## 建立连接过程:最值得记住的点 + +### 1. Client 发起连接 + +- `xqc_connect()` / `xqc_h3_connect()` 先建立内部连接状态 +- 随后发送 QUIC Initial 包,里面带 `ClientHello` + +### 2. Server 收包并创建连接对象 + +- `xqc_engine_packet_process()` 识别到新连接 +- 创建 `xqc_connection_t` +- 回调 `on_conn_create` +- 返回 `ServerHello`、证书和握手包 + +### 3. 双方完成握手 + +- Client 校验证书、导出密钥、发送 `Finished` +- Server 校验通过后进入 established 状态 +- 两边都会触发握手完成回调 + +### 4. 开始传输业务数据 + +- transport 模式:创建 stream,走 `xqc_stream_send` +- HTTP/3 模式:创建 request,发送 headers/body + +## Server 如何管理多个连接 + +原始笔记这部分没展开,但从流程上看,关键点是: + +- 每个新连接都会在 engine 内对应一个连接对象 +- 连接生命周期由收包、定时器和回调共同驱动 +- 关闭时通过 `on_conn_close` 把对象从 engine 中移除并释放资源 + +## transport 模式测试 + +启动服务端: + +```bash +./test_server -p 8843 -c ./server.crt -k server.key +``` + +启动客户端: + +```bash +/mnt/e/BucksClub/xquic/build/tests/test_client -a 127.0.0.1 -p 8843 -t 1 -l d +``` + +## HTTP/3 模式测试 + +```bash +./test_client -a 127.0.0.1 -p 8843 -h test.xquic.com -T 0 -l d +``` + +## 集成 OpenSSL 1.1.1 的一套命令 + +```bash +mkdir -p ~/build_env && cd ~/build_env +wget https://www.openssl.org/source/openssl-1.1.1w.tar.gz + +tar -zxvf openssl-1.1.1w.tar.gz +cd openssl-1.1.1w + +./config --prefix=/usr/local/openssl-1.1.1 \ + --openssldir=/usr/local/openssl-1.1.1 \ + shared zlib + +make -j$(nproc) +sudo make install +``` + +## transport / h3 路径差异示意 + +### HTTP/3 + +- client:`xqc_h3_connect()` +- server:收到 Initial 后创建连接 +- 请求通过 `xqc_h3_request_create()` 创建 +- 服务端在 request 回调里读 header/body 并回包 + +### transport + +- client:`xqc_connect(alpn="transport")` +- 握手完成后 `xqc_stream_create()` +- 服务端在 stream 回调里接收数据并返回响应帧 + +## 压测时关注哪些指标 + +原始记录里已经提到了几项核心指标: + +- 吞吐量(Mbps) +- 平均延迟 / p50 / p95 / p99 / max +- 完成率 +- 失败数 + +还没有直接采集但值得后补的有: + +- 每秒成功握手数 +- RPS +- 服务端 CPU / 内存占用 +- 丢包 / 重传率 + +## 大文件 vs 小文件压测结论 + +原始测试给出的核心观察可以收敛成: + +1. 数据传输不是主要瓶颈,瓶颈更偏向 TLS 握手排队 +2. 文件变大后,吞吐量几乎按比例增长,但延迟变化不明显 +3. 100 并发以内表现还不错,500 并发时明显退化 +4. 高并发退化的主要问题不是发送大文件,而是连接建立阶段太慢 + +整理后的结论就是:**单核 Seastar 服务端的并发瓶颈更接近握手能力,而不是纯数据发送能力。** diff --git "a/_posts/network/2026-04-25-\350\264\237\350\275\275\345\235\207\350\241\241\346\200\235\350\200\203.md" "b/_posts/network/2026-04-25-\350\264\237\350\275\275\345\235\207\350\241\241\346\200\235\350\200\203.md" new file mode 100644 index 000000000..ad3d3ebc0 --- /dev/null +++ "b/_posts/network/2026-04-25-\350\264\237\350\275\275\345\235\207\350\241\241\346\200\235\350\200\203.md" @@ -0,0 +1,47 @@ +--- +layout: post +title: 负载均衡相关的几点思考 +subtitle: 滚动上线导致的不均衡,以及 TCP 长连接负载均衡的几种思路 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 负载均衡 + - 分布式 +--- + +>原始笔记是几条零散的思考,这里把"上线方式"和"TCP 负载均衡"分成两节,并保留原文中的疑问。 + +## 当前保留内容 + +### 1. 重复上线 / 滚动上线带来的不均衡 + +常用的策略是 round robin(rr)或者 random: + +``` +重复上线或者滚动上线,有可能导致负载不均衡。 +滚动的过程中,存在掉线的情况,然后轮询就会导致每台机器上的用户数不一致。 +``` + +核心原因是:滚动期间不断有实例被摘除并重新上线,连接 / 用户的"重新分布"是单向的——已经迁走的连接不会自动回流,最终就会形成长尾的不均衡。 + +### 2. TCP 长连接的负载均衡思路 + +TCP 长连接和无状态 HTTP 不同,简单的四层 LB 不一定够用,原始笔记中列了几个仍然待思考的方向: + +- **client 与 server 建立多个连接**:通过多连接打散流量,降低单连接长尾的影响。 +- **后端额外的 router / 路由模块**:用一个独立模块记录 client 与 server 的连接关系,便于做迁移、亲和性调度等。 +- **TCP 会话保持**:保证同一个 client 始终路由到同一台 server,简化状态管理,但牺牲了部分均衡性。 +- **基于 hash 的分配**:例如 `hash(client_id) % N`,实现简单,但实例数量变化时需要考虑一致性 hash 等手段。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 滚动上线时常见的"再均衡"手段:主动断连、按比例 drain、慢启动等 +- 一致性 hash / rendezvous hash 的对比与适用场景 +- 连接级别 vs 请求级别负载均衡的取舍 +- 真实业务里测过的指标(QPS / 连接数 / 延迟)和踩过的坑 + +当前这篇先当作一个"负载均衡思考"的待扩充占位条目。 diff --git "a/_posts/notes/2026-04-25-\344\270\211\345\244\247\346\263\225\345\256\235.md" "b/_posts/notes/2026-04-25-\344\270\211\345\244\247\346\263\225\345\256\235.md" new file mode 100644 index 000000000..5bbee266e --- /dev/null +++ "b/_posts/notes/2026-04-25-\344\270\211\345\244\247\346\263\225\345\256\235.md" @@ -0,0 +1,55 @@ +--- +layout: post +title: 工程问题的三大法宝 +subtitle: 评估影响、对比定位、再不行就抄 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 方法论 + - Debug +--- + +>原始笔记是三句话,这里把它整理成一个可以反复对照的“处理工程问题的最小检查表”。三条原则的核心信息保持不变。 + +## 三条原则 + +### 1. 评估影响,并检查、检查、再检查 + +任何修改、上线、迁移之前,先把“这件事影响到谁、影响多大、最坏情况怎样”过一遍: + +- 涉及的服务、模块、上下游 +- 是否影响线上数据 / 资金 / 用户体验 +- 失败时的回退路径 + +确认后再动手;动手前后都要复核改动是否符合预期。 + +### 2. 遇到问题,对比正常与异常 + +定位问题时,最稳的姿势是“对比”: + +- **代码维度**:和上一个稳定版本 diff,找最近改动 +- **环境维度**:和正常机器/集群对比配置、依赖、内核参数 +- **数据维度**:对比异常请求和正常请求的入参、上下游 + +实在卡住时,使用两种最朴素的工具: + +- **回滚**:先恢复线上,再慢慢复盘 +- **二分**:把改动一半一半地切,定位到具体引入问题的那部分 + +### 3. 自己想不到,就去抄 + +如果上面都试过仍然没思路,承认“想不到”不丢人,关键是别原地打转: + +- Google / Stack Overflow +- 微软、Amazon、Google 等公开的工程博客或 RFC +- 同公司其它团队的内部文档与历史 case + +照着别人成熟的解法做一次,往往比自己硬想要快得多。 + +## 后续可补的方向 + +- 每一条配一个真实复盘 case +- 增加“评估影响”用的 checklist 模板 +- 整理常用的二分定位手段(git bisect、流量分割等) diff --git "a/_posts/notes/2026-04-25-\344\270\252\344\272\272\345\217\221\345\261\225\350\247\204\345\210\222.md" "b/_posts/notes/2026-04-25-\344\270\252\344\272\272\345\217\221\345\261\225\350\247\204\345\210\222.md" new file mode 100644 index 000000000..61b3c7898 --- /dev/null +++ "b/_posts/notes/2026-04-25-\344\270\252\344\272\272\345\217\221\345\261\225\350\247\204\345\210\222.md" @@ -0,0 +1,130 @@ +--- +layout: post +title: 个人发展规划 +subtitle: 程序开发库清单 / 阅读书单 / 项目开发文档资料 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 个人发展 + - 学习计划 +--- + +>原始笔记是三段并列的复选框列表(学习库 / 书单 / 项目文档),以及末尾一张图。这里只补 front matter 与每段简短说明,列表条目原样保留以保持复选框状态。 + +## 当前保留内容 + +### 1. 程序开发需要学习的库 + +按"通用 / UT / 监控 / 配置 / 存储 / RPC / 压缩 / 加密 / 内存 / 序列化"分大类列出常见 C++ 基础设施。 + +- [ ] 通用公共库 + - [ ] std + - [ ] abseil-cpp + - [ ] boost + - [x] folly + - [ ] tbb +- [ ] UT + - [x] gtest +- [ ] 监控 + - [ ] bvar +- [ ] 配置 + - [ ] gflags + - [ ] yaml-cpp +- [ ] 存储 + - [ ] leveldb + - [ ] rocksdb + - [ ] raft + - [ ] sqlite3 +- [ ] rpc + - [x] brpc + - [ ] grpc +- [ ] 压缩 + - [ ] zlib + - [ ] lz4 + - [ ] snappy +- [ ] 加密 + - [ ] openssl + - [ ] boringssl +- [ ] 内存管理 + - [ ] tcmalloc + - [ ] gperftools + - [ ] jemaloc +- [ ] 序列化 + - [ ] mcpack2pb + - [ ] protobuf-json + - [ ] rapidjson + - [ ] nlohmann-json + - [ ] flatbuffers + +### 2. 学习书籍列表 + +分为"专业书籍"与"经济类"两组。 + +- [ ] 专业书籍 + - [ ] 复习 一个程序员的自我修养 + - [ ] 现代编译原理 c语言描述版 + - [ ] effective modern c++ + - [ ] stl 侯捷 + +- [ ] 经济类 + - [ ] 巴菲特教你读财报 + - [ ] 财报分析必选项,帮助理解分析同花顺财报 + - [ ] 复习博弈论 + - [ ] 置身事内 + - [ ] 县乡中国 + - [ ] 资本论 + - [ ] 国富论 + - [ ] 李光耀观天下 + +### 3. 项目开发文档资料整理 + +按软件工程的六个阶段整理需要产出的文档清单,最后再附上风险管理与沟通机制。 + +#### 阶段一 可行性计划 +- [ ] 可行性研究报告 +- [x] 项目开发计划 + - [ ] 项目参与人员 + +#### 阶段二 需求分析 +- [x] 软件需求说明 +- [x] 数据要求说明书 +- [ ] 用户手册 + +#### 阶段三 设计 +- [ ] 概要设计说明书 +- [ ] 详细设计说明书 +- [ ] 测试计划(初稿) + +#### 阶段四 实现 +- [ ] 模块开发卷宗 + - [ ] 用户手册 + - [ ] 操作手册 + - [ ] 测试计划 + +#### 阶段五 测试 + - [ ] 模块开发卷宗 + - [ ] 测试分析报告 +- [ ] 模块开发总结报告(项目收益) + +#### 阶段六 问题和维护 +- [ ] 问题管理文档 +- [ ] 新增需求管理文档 + +#### 风险管理 +- [ ] 新增需求管理文档 + +#### 沟通机制 +- [ ] 周会:参与人员 +- [ ] 日会:参与人员 +- [ ] 里程碑节点(项目时间节点规划) + + +![image](https://user-images.githubusercontent.com/8308226/218288532-dd34d62f-ee0c-4492-ae43-383a5a42d11d.png) + +## 后续可补的方向 + +- 给每个库挑一个"最小学习路径"(核心 API + 一个 demo + 一个面试要点)。 +- 把书单按"近期 / 中期 / 长期"重新分组,并设定阅读节奏。 +- 项目文档清单与公司实际模板对齐,给每份文档配一个最小骨架链接。 diff --git "a/_posts/notes/2026-04-25-\345\244\215\346\235\202\344\270\226\347\225\214\347\232\204\347\256\200\345\215\225\350\247\204\345\210\231\345\272\224\347\224\250.md" "b/_posts/notes/2026-04-25-\345\244\215\346\235\202\344\270\226\347\225\214\347\232\204\347\256\200\345\215\225\350\247\204\345\210\231\345\272\224\347\224\250.md" new file mode 100644 index 000000000..c5546e806 --- /dev/null +++ "b/_posts/notes/2026-04-25-\345\244\215\346\235\202\344\270\226\347\225\214\347\232\204\347\256\200\345\215\225\350\247\204\345\210\231\345\272\224\347\224\250.md" @@ -0,0 +1,61 @@ +--- +layout: post +title: 复杂世界中的简单规则——一次拆分容量估算 +subtitle: 用一个幂律式的粗估,把"母子公司拆分后群数"算到八九不离十 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 估算 + - 容量 + - 方法论 +--- + +>原始笔记是一段背景描述加一行公式,这里整理成"背景 / 估算 / 验证"三段式,方便后续在同类问题上复用同一个套路。 + +## 当前保留内容 + +### 1. 背景 + +B 公司是 A 公司的子公司,由于业务发展需要,A、B 公司要独立运行,**信息系统也要做拆分**。 + +经过初步统计: + +- A 公司 MySQL 存储大小:**1600 G × 16** +- A 公司 Redis 大小:**360 G** +- A 公司员工数:约 **3.4 万人**(估计值) +- B 公司员工数:约 **0.6 万人** +- A + B 公司总群数:**87 万个** + +要解决的问题:**粗略估计 B 公司拆分后所需的存储大小,以及 B 公司内部大概有多少个群?** + +### 2. 估算 + +群数这一项用了一条经验公式(幂律形式的粗估): + +``` +(870000 * 3 / 20)^(0.85) = 22298 +``` + +直觉上的解释:群数并不是线性按员工比例分配的,B 公司虽然只有 A 公司大约 1/6 的员工,但群数上不会按 1/6 缩小那么多,所以这里用一个 0.85 的指数来体现"亚线性增长"。 + +### 3. 验证 + +事后查询 Redis 实际数据: + +- B 公司真实存在的群数:**24632** +- 估算结果:**22298** + +两者非常接近,nice~ 说明这种"按比例 + 幂指数修正"的粗估,在做拆分 / 容量评估的早期阶段是足够用的。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 把存储(MySQL / Redis)按同样套路也算一遍,形成完整的"拆分容量评估"模板 +- 对 0.85 这个指数的来源做些解释(参考的经验公式、数据观察依据) +- 类似估算在其它场景(DAU、QPS、流量)下的可迁移版本 +- 估算偏差较大时的常见原因与修正方法 + +当前这篇先当作一个"用简单规则估算复杂系统"的案例占位条目。 diff --git "a/_posts/notes/2026-04-25-\345\244\215\347\233\230\346\255\245\351\252\244.md" "b/_posts/notes/2026-04-25-\345\244\215\347\233\230\346\255\245\351\252\244.md" new file mode 100644 index 000000000..2687e09a0 --- /dev/null +++ "b/_posts/notes/2026-04-25-\345\244\215\347\233\230\346\255\245\351\252\244.md" @@ -0,0 +1,69 @@ +--- +layout: post +title: 《极客与团队》摘要:HRT、复盘、开会、保护团队 +subtitle: 把书中几条易记的清单整理成可直接对照执行的版本 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 读书笔记 + - 团队管理 + - 复盘 +--- + +>原始笔记是从《极客与团队》摘抄出来的若干小标题清单,部分项目符号是问号占位。这里把清单转成正常的列表,文字尽量保持原样。 + +## 当前保留内容 + +### 三大原则 + +> 谦虚、尊重、信任(HRT,Humility / Respect / Trust)。 + +### 一份出色的事后检讨(Postmortem)应该包含 + +- 简要 +- 事件的时间线,从发现到调查,再到最终结果 +- 事件发生的主因 +- 影响和损失评估 +- 立即修正问题的步骤 +- 防止事件再次发生的步骤 +- 得到的教训 + +### 关于"导师" + +满足三个条件就基本可以胜任: + +1. 熟悉团队的流程和系统; +2. 向他人解释事物的能力; +3. 估计被指导的人到底需要多少帮助的能力。 + +最后一个条件或许最重要——你要做的就是给学生提供足够的信息:解释得太多或东拉西扯,学生未必会领情,他可能会直接忽略你,而不是礼貌地告诉你"我已经明白了"。 + +### 关于"开会"——五条小贴士 + +1. 只邀请一定要参加的人; +2. 开会前要决定好议程,而且要事先通知所有人; +3. 达成目的后应提早散会; +4. 注意别跑题; +5. 尽量把会议安排在休息时间前后(比如午饭前后、下班前等)。 + +### 关于"保护团队" + +- 写一份**明明白白的任务宗旨**,随时保持专注,知道哪些是目标、哪些不是。 +- **E-mail 讨论要有礼仪**,保留归档;要求新人研读,防范"嘈杂的少数人"。 +- **所有历史都要有记录**——不只是代码历史,还有设计决策、重要的 bug 修复以及过去犯过的错误。 +- **有效地协作**:利用版本控制;代码改动尽可能小,方便审查;扩大"公车因子",避免领地感。 +- **修复 bug、测试、发布软件**要有清晰的政策和流程。 +- 降低新人加入时的壁垒。 +- 依赖**基于共识的决策**;在无法达成共识时,要准备好化解矛盾的方法。 + +### 经验之谈 + +> 不管技术债务有多少,团队也永远不应该花超过三分之一甚至一半的时间和精力去做防御性的工作,否则就等于政治自杀。 + +## 后续可补的方向 + +- 把"事后检讨"模板套到自己经历过的真实事故上,看哪几项最常缺 +- "公车因子"的具体度量方法(关键模块单点风险) +- 对 HRT 三原则做一份对照行为清单(哪些行为算违反 HRT) diff --git "a/_posts/notes/2026-04-25-\345\244\247\346\250\241\345\236\213\345\257\271\346\257\224.md" "b/_posts/notes/2026-04-25-\345\244\247\346\250\241\345\236\213\345\257\271\346\257\224.md" new file mode 100644 index 000000000..0d85ea62b --- /dev/null +++ "b/_posts/notes/2026-04-25-\345\244\247\346\250\241\345\236\213\345\257\271\346\257\224.md" @@ -0,0 +1,47 @@ +--- +layout: post +title: 大模型 / Coding Agent 对比收藏 +subtitle: 常看的几个产品入口与排名网站 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - LLM + - AI + - Tools +--- + +>原始笔记只有一张截图和一组链接,这里整理成可继续补充的最小占位版本。 + +## 当前保留内容 + +### 1. 一张参考截图 + +image + +截图是用来快速比对各家模型 / 产品定位的,具体内容以截图为准。 + +### 2. 常用入口与排名 + +需要试用或者比对模型时,最常打开的几个站点: + +``` +OpenAI Codex: https://chatgpt.com/codex +Claude Code: https://claude.ai +Google Antigravity: https://antigravity.google/ +AI 模型排名: https://artificialanalysis.ai +``` + +前三个是各家的 coding agent / 入口,最后一个是综合性能 / 价格的排行榜,适合在选型前先扫一眼。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 各家 coding agent 的能力矩阵(上下文长度、工具调用、计费方式等) +- 自己实测的对比结果(同一题目下的输出 / 速度 / 成本) +- 国内可访问的模型 / 镜像入口 +- 不同场景的推荐:"写代码 / 写文档 / 长上下文阅读 / 多模态"分别选谁 + +当前这篇先当作一个"AI 工具收藏夹"的占位条目,后续随用随补。 diff --git "a/_posts/notes/2026-04-25-\345\267\245\344\275\234\346\261\207\346\212\245.md" "b/_posts/notes/2026-04-25-\345\267\245\344\275\234\346\261\207\346\212\245.md" new file mode 100644 index 000000000..ccefb9c86 --- /dev/null +++ "b/_posts/notes/2026-04-25-\345\267\245\344\275\234\346\261\207\346\212\245.md" @@ -0,0 +1,55 @@ +--- +layout: post +title: 什么是有效的工作汇报 +subtitle: 从"内容 / 论证 / 表达方式"三个角度梳理汇报要点 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 工作方法 + - 汇报 + - 沟通 +--- + +>原始笔记是一段连贯文字,按"工作汇报 / 论证观点 / 表达方式"三块进行分节,文字本身基本保持原样。 + +## 当前保留内容 + +### 1. 工作汇报 + +- 内容的核心,**不是你想说什么,而是你希望对方听到什么**(注意,这里面有区别)。 +- 内容要明确、有重点、有逻辑。如果你自己不能 3 句话说清自己的核心思想,反思一下是不是还没准备好。 +- 常规汇报的内容,**不是你做了什么,而是你达到了什么效果**(比如上线、用户增加);完成多少代码这样的话是没有意义的。更重要的,是你有什么问题,以至于现在没能完成你想要的(这也是对周报的要求)。 +- 汇报的目的一般来说不是表功,**而是解决问题**;如果项目一切进展很好,可以迅速讲完。 +- 进展不是看你有多忙,而是**看我们离目标还有多远**。 +- 上级是来做决策和支持资源的,不是来做问答题的;除非特殊情况,你不应该期待上级来解决你的问题。 + +### 2. 论证观点 + +- 观点要鲜明,论据要充足,最忌自说自话。自己说"第一"最好没有用,**用户买单是王道**。 +- 对于产品形态,大家都可以发表意见。不过记住:如果没有数据,请尊重专业人士的判断,你的观点只代表个人。 +- **数据说话**,一定要靠谱的数据,经得起推敲。切忌"先有观点,然后找数据说明"。不管是不是自己的项目,都尽量有公正心看待结果。 +- 所有写在 PPT 上的数据,都要知道**来源、计算方法**。最要不得的是拿着一堆你自己都不知道的数据去讲——如果你不知道,说明你没有做好功课;如果你觉得不需要知道,那你也就不需要来讲。 +- 观点就是观点,没有绝对对错。我们选择项目有很多考量因素,没有选择你的观点不代表你不行,不需要意气用事。 + +### 3. 表达方式 + +- 所有的汇报,**核心是内容**,不是文字、不是形式、不是说话的长短。 +- "一图胜千言"指的是有内容的图,不是胡乱的图。 +- PPT 不需要读,大家都认字,**你要讲的是 PPT 上没有的东西**。 +- PPT 技巧的核心,不是花哨的形式,而是用最简洁有效的形式表达。比如:用三角形来写时,三个点上的内容应该是互相支撑的;如果它们没有这种关系,就别用这种形式。 +- 数据要全面,但切忌大量堆砌。**我们不是用 PPT 上数据量的大小来衡量你准备是否充分。** +- 逻辑要自然,用数据来说明问题,结论应该是能"自然得到"的,不能脱节。 +- **每一页有且仅有一个观点**,没有观点的 PPT 不要写。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 周报 / 月报 / 季度汇报的具体模板 +- 不同汇报对象(直属上级 / 跨部门 / 大老板)下的侧重点差别 +- 一些反例与修订前后对比 +- 配套的 PPT 设计原则与示例 + +当前这篇先当作一个"汇报方法论"的占位条目,后续可在每一节下补充自己的实践与反例。 diff --git "a/_posts/notes/2026-04-25-\345\267\264\350\217\262\347\211\271\350\257\273\350\264\242\346\212\245\350\257\273\344\271\246\347\254\224\350\256\260.md" "b/_posts/notes/2026-04-25-\345\267\264\350\217\262\347\211\271\350\257\273\350\264\242\346\212\245\350\257\273\344\271\246\347\254\224\350\256\260.md" new file mode 100644 index 000000000..7402ac602 --- /dev/null +++ "b/_posts/notes/2026-04-25-\345\267\264\350\217\262\347\211\271\350\257\273\350\264\242\346\212\245\350\257\273\344\271\246\347\254\224\350\256\260.md" @@ -0,0 +1,56 @@ +--- +layout: post +title: 《巴菲特教你读财报》读书笔记 +subtitle: 股票价值判断的几个抓手与几个常见弱点 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 读书笔记 + - 投资 + - 财报 +--- + +>原始笔记是若干零散段落 + 几张配图链接,没有结构。这里按"本质 / 价值判断 / 配图记忆点 / 行为经济学"四块整理,正文尽量保持原样。 + +## 当前保留内容 + +### 1. 股票投资的本质 + +寻找一个合适的股价买入股票,并收取公司未来可能带来的收益,同时承担相应的风险。 + +### 2. 如何判断股票的价值 + +可以从几个角度交叉判断: + +1. **招聘信息**——侧面反映公司业务扩张方向。 +2. **招投标信息**——观察上下游对该公司的判断。 +3. **财报**——核心数据来源。 +4. **企业主管人员**——管理层质量影响公司长期表现。 +5. **贴现法**——把"未来 = 多少钱"用于计算债券或股票的价值,需要考虑:通货膨胀 + 固定收益利率 + 风险 + 其他因素。 + +![image](https://user-images.githubusercontent.com/8308226/217095603-ba03b64d-bd40-4543-9759-c2941a6982d4.png) + +### 3. 一个朴素的判断方式 + +> 人好不好?用工资来说话???!!! + +![image](https://user-images.githubusercontent.com/8308226/217100377-5aac8089-7db8-4265-be1f-611d015b22e0.png) + +### 4. 最大的两个弱点 + +![image](https://user-images.githubusercontent.com/8308226/219881578-34336f3f-0d8f-47ad-8b5e-0bfb1a844c26.png) + +### 5. 行为经济学 + +- **阿莱悖论**。 +- 大众心理: + - 面对**损失**:风险偏好; + - 面对**收益**:风险厌恶。 + +## 后续可补的方向 + +- 把上面三张图里的具体观点誊到正文里,避免图挂掉就什么都看不到 +- 配套几个 A 股 / 港股的真实财报案例,按"五个角度"做一次对照 +- 行为经济学这一节再展开:前景理论、损失厌恶系数、锚定效应等 diff --git "a/_posts/notes/2026-04-25-\345\270\270\347\224\250\345\205\254\345\274\217.md" "b/_posts/notes/2026-04-25-\345\270\270\347\224\250\345\205\254\345\274\217.md" new file mode 100644 index 000000000..6262bb900 --- /dev/null +++ "b/_posts/notes/2026-04-25-\345\270\270\347\224\250\345\205\254\345\274\217.md" @@ -0,0 +1,72 @@ +--- +layout: post +title: 性能与建模常用公式速查 +subtitle: QPS、同步/异步选型、cpu.load、逻辑斯蒂曲线 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 性能 + - 公式 + - 速查 +--- + +>原始笔记是若干个 `# 标题 + 代码块`的并列条目,没有上下文。这里按"吞吐 / 选型 / 负载 / 曲线"四块整理,公式和原文保持不动。 + +## 当前保留内容 + +### 1. QPS 统计公式 + +``` +qps = 1/ (cost/count) +``` + +含义:单次平均成本是 `cost/count`,QPS 就是它的倒数。 + +### 2. 同步 / 异步选型公式 + +把 `qps * latency` 与核数对比: + +``` +qps * latency 与核数对比, +a. 同一个数量级,同步 +b. >>>(远远大于) 核数, 异步 +``` + +### 3. cpu.load + +``` +cpu.load 可运行进程与运行中进程的总数 + +对于cpu.load多少开始出现性能问题,外界有不同的说法,有的认为cpu.load/cores最好不要超过1,有的认为cpu.load/cores最好不要超过3,有的认为cpu.load不超过2*cores-2即可。 +针对技术商服务进行压测时,发现load低时,接口TP999响应时间<50ms,而cpu.load/cores>4时,TP999响应时间约为200ms左右,多出来的时间可以理解为线程等待cpu处理的时间,可见cpu.load/cores过高时是影响接口响应时间的。 +因此可以结合预期的接口响应时间,来定义每个服务的cpu.load/cores不超过多少。比如要求接口响应时间尽可能快的,最好确保cpu.load/cores不超过1,而对时间敏感性要求不太高时,一般要求cpu.load/cores不超过3。 +``` + +### 4. 逻辑斯蒂曲线 + +``` +逻辑斯蒂曲线的纵轴极限值通常是1而不是100,因此我假设您的问题中的100是一个笔误。 + +逻辑斯蒂曲线的方程是: + +y = 1 / (1 + e^(-kx)) + +其中,k是曲线的斜率,x是横轴的值,y是纵轴的值。 + +如果横轴的极限值是4,那么k的值可以通过以下方式计算: + +k = 6 / 4 = 1.5 + +现在,我们可以使用x = 2.4和k = 1.5来计算纵轴的值: + +y = 1 / (1 + e^(-1.5*2.4)) ≈ 0.845 + +因此,当横轴的值为2.4时,逻辑斯蒂曲线的纵轴大约是0.845。 +``` + +## 后续可补的方向 + +- 把"同步/异步选型公式"配上一张实际服务的对照案例(QPS、latency、核数实测值) +- 在 cpu.load 段补一张和 TP99 / TP999 的对照曲线,做经验阈值参考 diff --git "a/_posts/notes/2026-04-25-\347\250\213\345\272\217\345\221\230\344\277\256\347\202\274\344\271\213\351\201\223\350\257\273\344\271\246\347\254\224\350\256\260.md" "b/_posts/notes/2026-04-25-\347\250\213\345\272\217\345\221\230\344\277\256\347\202\274\344\271\213\351\201\223\350\257\273\344\271\246\347\254\224\350\256\260.md" new file mode 100644 index 000000000..4d36b1465 --- /dev/null +++ "b/_posts/notes/2026-04-25-\347\250\213\345\272\217\345\221\230\344\277\256\347\202\274\344\271\213\351\201\223\350\257\273\344\271\246\347\254\224\350\256\260.md" @@ -0,0 +1,58 @@ +--- +layout: post +title: 《程序员修炼之道》读书笔记 +subtitle: 前言与"务实的哲学"两章的关键摘抄 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 读书笔记 + - 软件工程 +--- + +>原始笔记是两段大段落的引用块,章节信息混在文本里。这里按章节拆开,每章的原文摘录保持不动。 + +## 当前保留内容 + +### 一、前言 + +``` +编程是一门技艺。简单地说,就是让计算机做你想让它做的事情(或者是你的用户想让它做的事情)。 +作为一名程序员,你既在倾听,又在献策;既是传译,又行独裁;你试图捕获难以捉摸的需求, +并找到一种表达它们的方式,以便仅靠一台机器就可以从容应付。你试着把工作记录成文档,以便他人理解; +你试着将工作工程化,这样别人就能在其上有所建树; +更重要的是,你试图在项目时钟的滴答声中完成所有的这些工作。你每天都在创造小奇迹。 + +你不应该拘泥于任何特定的技术,而应该拥有足够广泛的背景和经验基础,以便在特定的情况下选择合适的解决方案。 +你的背景来自对计算机科学基本原理的理解,而你的经验来自广泛的实际项目。理论结合实践会让你变得强大。 + +调整方法去寻找适应当前的情况和环境。对所有影响项目因素的相对重要性做出判断, +并通过经验找到合适的解决方案。随着工作的进展,你要不断地这样做。 +务实的程序员不仅把工作做完,并且做得很好。 +``` + +### 二、务实的哲学 + +``` +务实的程序员的特质是什么?是他们面临问题时,在解决方案中透出的态度,风格以及理念。 +他们总是越过问题的表面,试着将问题放在更宽泛的上下文中综合考虑,从大局着想。 +毕竟,若不去了解来龙去脉,结合实际从何谈起?又怎能做出明智的妥协和合理的决策? + +当你意识到自己在说"我不知道"时,一定要接着说"——但是我会去搞清楚"。 +用这样的方式来表达你的不知道是非常好的,因为接着你就可以像一个专家一样承担起责任。 + +不要只是因为一些东西非常危急,就去造成附带损害。破窗一扇都嫌太多。 + +批判性地分析你读到和听到的东西,问几个值得思考的问题: + +谁从中受益 +有什么背景:每件事都发生在自己的背景之下,这也是为何"能解决所有问题"的方案通常是不存在的。 +什么时候可以在哪里工作起来:不要停留在一阶思维下(接下来会发生什么),要进行二阶思考(当它结束后还会发生什么?)。 +为什么这是个问题:是否存在一个基础模型以及这个基础模型是怎么工作的? +``` + +## 后续可补的方向 + +- 第三章及之后章节(注重实效的途径、基本工具、务实的偏执……)的摘录 +- 把各章金句整理成可索引的小卡片,便于在项目复盘里直接引用 diff --git "a/_posts/notes/2026-04-25-\347\274\226\347\250\213\346\200\235\350\267\257.md" "b/_posts/notes/2026-04-25-\347\274\226\347\250\213\346\200\235\350\267\257.md" new file mode 100644 index 000000000..c8b55d458 --- /dev/null +++ "b/_posts/notes/2026-04-25-\347\274\226\347\250\213\346\200\235\350\267\257.md" @@ -0,0 +1,39 @@ +--- +layout: post +title: 编程通用思路 +subtitle: 把"分析—建模—实现—优化"七步骤整理成清单 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 方法论 + - 编程 +--- + +>原始笔记只有一份编号清单,这里整理成可继续补充的最小占位版本,把每一步对应的关注点稍微展开一下。 + +## 当前保留内容 + +写代码前,先按下面这个固定顺序过一遍: + +1. **分析问题**:把模糊的需求转成明确的输入、输出、约束。 +2. **建立模型**:用合适的抽象(数据结构、状态机、流程图等)刻画问题。 +3. **拆解步骤**:将整体方案拆成可独立实现 / 测试的小步骤。 +4. **进行实现**:按步骤落地代码。 +5. **时间、空间复杂度分析**:估算最坏 / 平均复杂度,确认能满足规模要求。 +6. **优化策略**:选择合适的数据结构与遍历方式,例如: + - 用 `unordered_map` 提速、用 `map` 保证有序,按场景选其中之一; + - 二维数组用行优先遍历,对 CPU cache 更友好。 +7. **封装简化**:把可复用的部分抽成函数 / 类,降低后续改动成本。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 每一步对应的常见反模式与"踩坑清单" +- 不同问题类型(算法题 / 工程模块 / 系统设计)下侧重点的差别 +- 复杂度分析的常用速记表(容器操作、典型算法) +- 配套的 code review checklist + +当前这篇先当作一个"通用编程思路"的占位条目,后续再逐步补充。 diff --git "a/_posts/perf/2026-04-24-\345\246\202\344\275\225\346\217\220\345\215\207\347\250\213\345\272\217\347\232\204\346\200\247\350\203\275.md" "b/_posts/perf/2026-04-24-\345\246\202\344\275\225\346\217\220\345\215\207\347\250\213\345\272\217\347\232\204\346\200\247\350\203\275.md" new file mode 100644 index 000000000..d2a193cf0 --- /dev/null +++ "b/_posts/perf/2026-04-24-\345\246\202\344\275\225\346\217\220\345\215\207\347\250\213\345\272\217\347\232\204\346\200\247\350\203\275.md" @@ -0,0 +1,112 @@ +--- +layout: post +title: 如何提升程序的性能 +subtitle: 从定位瓶颈到优化落地的检查清单 +date: 2026-04-24 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 性能优化 + - Linux + - C++ +--- + +>性能优化的第一步不是“直接改代码”,而是先确认瓶颈在哪里、瓶颈是否稳定复现,以及优化后如何验证收益。 + +## 先明确优化目标 + +在开始优化前,先回答三个问题: + +- 优化目标是什么:吞吐、延迟、CPU、内存,还是磁盘/网络开销。 +- 问题出现在哪个场景:压测、线上高峰、冷启动,还是特定请求路径。 +- 优化是否可量化:是否有 QPS、TP99、CPU 使用率、内存占用等基线数据。 + +没有基线数据时,优化很容易变成“感觉更快了”,最后无法证明收益。 + +## 先定位热点,再决定手段 + +### 1. 编译器与构建层面 + +常见手段包括: + +- 打开合适的编译优化等级,如 `-O2` 或 `-O3` +- 在合适场景下评估 LTO、BOLT 等链接/布局优化能力 +- 确认是否打开了调试选项、额外日志或 sanitizer,避免把调试开销误判为业务瓶颈 + +这类优化适合在代码路径已经稳定、二进制规模和启动方式明确时评估。 + +### 2. 代码路径本身 + +常见性能问题通常集中在以下几类: + +- I/O 过多:磁盘、网络、频繁 flush +- 锁竞争:线程争用、过粗粒度锁、原子变量滥用 +- 内存分配:频繁分配释放、对象过大、缓存 miss +- 线程切换:线程数过多、任务过细、上下文切换频繁 +- 数据结构选择不当:例如高频 `rehash`、不必要的拷贝、冷热数据混放、伪共享 + +优化时优先处理热点路径上的高频操作,而不是平均分散地“到处微调”。 + +### 3. 用工具确认热点函数 + +- 用户态 CPU 热点:`perf record` / `perf report` +- 堆内存热点:结合 `heap profiler`、`jemalloc` 或 `tcmalloc` 的分析能力 +- 系统层面:`pidstat`、`mpstat`、`vmstat`、`iostat` + +先确认瓶颈是在用户态、内核态、锁等待还是 I/O 等待,再决定是否改代码、改参数或者改部署方式。 + +## 常见优化方向 + +### 减少不必要的数据搬运 + +- 优先减少不必要的 copy +- 合理使用 `std::move` +- 避免在热点路径上构造大对象或频繁做格式化 + +### 降低竞争与同步开销 + +- 缩小锁粒度 +- 让串行路径改为并行时先确认共享数据边界 +- 能异步的地方尽量异步,但要控制队列长度和回压机制 + +### 控制瞬时资源抖动 + +很多性能问题不是平均负载高,而是瞬时尖峰触发: + +- 瞬时流量高峰 +- GC 或后台任务集中执行 +- `vector` 扩容、`unordered_map` rehash +- 突发磁盘读导致 major page fault + +这类问题要重点看峰值期间的监控,而不是只看平均值。 + +### 评估硬件和部署因素 + +如果热点已经明确且代码优化空间有限,再考虑: + +- 提升单机硬件能力 +- 调整 CPU 亲和性(cpu affinity) +- 做水平扩展或拆分服务 + +这一步应建立在前面已经确认瓶颈的前提下,否则只是把问题推迟暴露。 + +## 一个实用的排查顺序 + +1. 先确定现象:慢在哪里,影响范围多大。 +2. 采集基线:延迟、吞吐、CPU、内存、I/O。 +3. 用 profiling 工具确认热点函数和热点路径。 +4. 判断瓶颈属于计算、锁、I/O、内存还是调度。 +5. 只修改最主要的瓶颈点,并在同样场景下复测。 +6. 对比优化前后数据,确认收益和副作用。 + +## 总结 + +性能优化的关键不是“知道很多技巧”,而是建立一条稳定的方法链: + +- 先测量 +- 再定位 +- 后优化 +- 最后复盘 + +这样才能避免把时间花在非瓶颈位置上。 diff --git a/_posts/perf/2026-04-25-ab.md b/_posts/perf/2026-04-25-ab.md new file mode 100644 index 000000000..f099abaff --- /dev/null +++ b/_posts/perf/2026-04-25-ab.md @@ -0,0 +1,35 @@ +--- +layout: post +title: A/B 测试样本量估算工具 +subtitle: 只保留一个常用的在线样本量计算器链接 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - A/B Test + - Tools +--- + +>原始笔记只有一条链接,这里整理成可继续补充的最小占位版本。 + +## 当前保留内容 + +做 A/B 实验前估算需要多少样本量时,常用的在线计算器: + +```text +https://www.abtasty.com/sample-size-calculator/ +``` + +填基线转化率、最小可检测提升、显著性水平和 power,就能算出每组所需样本量。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 实验设计的基本概念:基线、MDE、显著性、统计功效 +- 不同指标类型(比率型 / 均值型)样本量公式上的差别 +- 工程上常踩的坑:分流不均、SRM、提前终止 +- 其它可替代的计算器或自建脚本 + +当前这篇先当作一个待扩充的占位条目。 diff --git "a/_posts/perf/2026-04-25-gperf-tool\345\256\211\350\243\205.md" "b/_posts/perf/2026-04-25-gperf-tool\345\256\211\350\243\205.md" new file mode 100644 index 000000000..c48e2d650 --- /dev/null +++ "b/_posts/perf/2026-04-25-gperf-tool\345\256\211\350\243\205.md" @@ -0,0 +1,79 @@ +--- +layout: post +title: gperftools / tcmalloc 安装与使用要点 +subtitle: 把版本选择、libunwind、链接方式、编译开关汇总在一处 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 性能分析 + - tcmalloc + - C++ +--- + +>原始笔记是若干编号条目混在一起,包含"待确认问题"和"实操配置"两类内容。这里按"待确认 / libunwind / 链接 / 编译 / 使用"五块拆开,原文要点和命令保持原样。 + +## 当前保留内容 + +### 1. 待确认的问题 + +- `gperftools` 选 2.5 还是 1.7?brpc 这边需要哪个版本? + - 参考: +- `tcmalloc` 版本如何选择? +- C++ 版本是否需要 C++17? + +### 2. 关于 libunwind + +在 64 位 Linux 环境下,gperftools 使用 glibc 内置的 stack-unwinder 可能会引发死锁,因此官方推荐在配置和安装 gperftools 之前,先安装 `libunwind-0.99-beta`,最好就用这个版本,版本太新或太旧都可能会有问题。 + +即便使用 libunwind,在 64 位系统上还是会有问题,但只影响 heap-checker、heap-profiler 和 cpu-profiler,TCMalloc 不受影响,因此不再赘述,感兴趣的读者可参阅 gperftools 的 INSTALL。 + +如果不希望安装 libunwind,也可以用 gperftools 内置的 stack unwinder,但需要应用程序、TCMalloc 库、系统库(比如 libc)在编译时开启帧指针(frame pointer)选项。 + +在 x86-64 下,编译时开启帧指针选项并不是默认行为。因此需要指定 `-fno-omit-frame-pointer` 编译所有应用程序,然后在 configure 时通过 `--enable-frame-pointers` 选项使用内置的 gperftools stack unwinder。 + +> 关于在 VirtualBox + Ubuntu 上安装的步骤,可参考: + +### 3. 链接方式 + +``` + target_link_libraries(${TARGET_NAME} + -ltcmalloc + # tcmalloc_and_profiler #不能同时存在 + ) +``` + +> `tcmalloc` 与 `tcmalloc_and_profiler` 不能同时链接。 + +### 4. 编译配置 + +注意:**不能与 `lsan`、`address`(AddressSanitizer)同时使用!** + +``` + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -g -O0 -fno-omit-frame-pointer") + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -g -O0 -fno-omit-frame-pointer") +``` + +### 5. 使用方式(针对程序不能正常退出的情况) + +``` +#if !defined(NDEBUG) +#include +#endif + +#if !defined(NDEBUG) + HeapProfilerStart("my_program test"); +#endif + +#if !defined(NDEBUG) + HeapProfilerDump("end"); +#endif +``` + +## 后续可补的方向 + +- 上面"待确认"问题的最终结论与所选版本依据 +- `pprof` 解析 heap / cpu profile 的常用命令清单 +- 与 brpc 内置 profiler、jemalloc 的对比 +- 在容器、CI 环境下使用 tcmalloc 的注意事项 diff --git a/_posts/perf/2026-04-25-latency.md b/_posts/perf/2026-04-25-latency.md new file mode 100644 index 000000000..51fe04802 --- /dev/null +++ b/_posts/perf/2026-04-25-latency.md @@ -0,0 +1,64 @@ +--- +layout: post +title: 延迟优化排查思路 +subtitle: 从压测构造到链路隔离的最小检查清单 +date: 2026-04-25 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - Performance + - Troubleshooting +--- + +>原始内容只有 5 条提纲,这里把它整理成一份更适合回看的排查顺序:先稳定复现,再逐段量化,最后做组件隔离。 + +## 1. 搭建压测环境 + +先保证问题可以稳定复现,否则后面的优化收益很难量化。 + +重点是先确认: + +- 流量规模是否接近真实场景 +- 压测环境和线上配置差异有多大 +- 压测时是否会被其他噪声任务干扰 + +## 2. 构造请求 + +压测数据要尽量覆盖真实瓶颈触发条件,而不是只跑一组最简单的 happy path。 + +至少要分清: + +- 正常请求和大包 / 慢请求 +- 冷启动流量和热路径流量 +- 是否存在特殊参数会显著拉高耗时 + +## 3. 统计各模块内部耗时 + +先看模块内部哪一段最慢,再决定是否继续深挖实现细节。 + +适合优先补齐: + +- 函数级耗时 +- 关键循环 / 关键锁耗时 +- I/O、序列化、拷贝等高频操作耗时 + +## 4. 统计各模块间耗时 + +单个模块内部不一定慢,问题也可能出在模块之间的调用和排队。 + +这里更适合关注: + +- RPC / IPC 往返耗时 +- 队列等待时间 +- 上下游服务之间的时间分布 + +## 5. 屏蔽链路上的可疑组件 + +最后再通过隔离法确认瓶颈到底落在哪一层,例如: + +- 暂时绕过某些 service +- 暂时绕过 cache +- 暂时绕过 mysql + +如果某个组件被屏蔽后延迟明显下降,就说明下一步应该优先深挖那一层,而不是继续在整条链路上盲目优化。 diff --git "a/_posts/perf/2026-04-25-\345\206\205\345\255\230\347\256\241\347\220\206-cheatsheet.md" "b/_posts/perf/2026-04-25-\345\206\205\345\255\230\347\256\241\347\220\206-cheatsheet.md" new file mode 100644 index 000000000..06bc3b3e9 --- /dev/null +++ "b/_posts/perf/2026-04-25-\345\206\205\345\255\230\347\256\241\347\220\206-cheatsheet.md" @@ -0,0 +1,191 @@ +--- +layout: post +title: 内存管理速查表 +subtitle: freelist / 大内存分配测试 / 伪共享 padding / 内存占用分析 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 内存 + - 性能 + - C/C++ +--- + +>原始笔记是几段散乱的内存相关片段:一段被压扁成一行的虚/实内存测试代码、几个分配器名字孤零零地列着、padding 的 Java/C 例子、以及 `/proc/$pid/smaps` 的字段表。这里按"分配器 / 大内存测试 / 伪共享 padding(Java + C)/ 内存占用分析的四个层级"分节整理,原始代码原样保留。 + +## 当前保留内容 + +### 1. freelist + +``` + +``` + +(待补充:自己写一个 freelist 的最小实现,对比 ptmalloc / jemalloc / tcmalloc 在小对象上的差异。) + +### 2. 分配大量虚存或实存(测试用) + +下面两个函数是当时用来观察"虚拟内存 vs 物理内存"占用差异的小工具。原始笔记里被压成了一行,这里保持原样不展开,避免修改测试结果: + +``` + // 大量分配虚拟内存,每次调用分配20Gvoid virtualMemoryTest(){ int sizeVm = 1 * 1024 * 1024 * 1024; // 1GB for(int i = 0; i < 20; i++){ // 1GB * 20 void* block = mmap(NULL, sizeVm, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, 0, 0); memset(block, 1, 1); list[index++] = block; }} +// 大量分配物理内存,每次调用申请160Mvoid physicalMemoryTest(){ int size = 8 * 1024 * 1024; // 8M for (int i = 0; i < 20; i++) { // 8M * 20 void *block = malloc(size); memset(block, 1, size); list[index++] = block; }} +``` + +### 3. 关注的分配器与基础组件 + +- leveldb +- ptmalloc +- jemalloc +- tcmalloc +- brpc 中的 task area、iobuf + +(每一项都待单独写读源码笔记。) + +### 4. 局部性原理与 padding + +#### 4.1 规避伪共享的两种思路 + +1. 增大数组元素的间隔使得不同线程存取的元素位于不同 cache line,空间换时间。 +2. 在每个线程中创建全局数组各个元素的本地拷贝,然后结束后再写回全局数组。 + +从代码设计角度,要考虑清楚类结构中哪些变量是不变的、哪些是经常变化的、哪些变化是完全相互独立的、哪些属性一起变化。假如业务场景中下面的对象满足几个特点: + +``` +public class Data{ + long modifyTime; + boolean flag; + long createTime; + char key; + int value; +} +``` + +- 当 value 变量改变时,modifyTime 肯定会改变 +- createTime 变量和 key 变量在创建后就不会再变化 +- flag 也经常会变化,不过与 modifyTime 和 value 变量毫无关联 + +当上面的对象需要由多个线程同时访问时,从 Cache 角度,当我们没有加任何措施时,Data 对象所有的变量极有可能被加载在 L1 缓存的一行 Cache Line 中。在高并发访问下会出现这种问题: + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/11a9c5f5-d796-4926-9232-1030634c35cb) + +如上图所示,每次 value 变更时,根据 MESI 协议,对象其他 CPU 上相关的 Cache Line 全部被设置为失效。其他的处理器想要访问未变化的数据(key 和 createTime)时,必须从内存中重新拉取数据,增大了数据访问的开销。 + +#### 4.2 有效的 Padding 方式(Java 示例) + +正确方式是将该对象属性分组:将一起变化的放在一组,与其他无关的放一组,将不变的放到一组。这样当每次对象变化时,不会带动所有的属性重新加载缓存,提升了读取效率。在 JDK1.8 前,一般在属性间增加长整型变量来分隔每一组属性。被操作的每一组属性占的字节数加上前后填充属性所占的字节数,不小于一个 cache line 的字节数就可达到要求。 + +``` +public class DataPadding{ + long a1,a2,a3,a4,a5,a6,a7,a8;//防止与前一个对象产生伪共享 + int value; + long modifyTime; + long b1,b2,b3,b4,b5,b6,b7,b8;//防止不相关变量伪共享; + boolean flag; + long c1,c2,c3,c4,c5,c6,c7,c8;// + long createTime; + char key; + long d1,d2,d3,d4,d5,d6,d7,d8;//防止与下一个对象产生伪共享 +} +``` + +采用上述措施后的图示: + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/26b28c9a-21e6-4296-977d-8a5e10ea5006) + +#### 4.3 C 语言 padding + +在设计数据结构的时候,尽量将只读数据与读写数据分开,并尽量将同一时间访问的数据组合在一起。这样 CPU 能一次将需要的数据读入。譬如,下面的数据结构就很不好: + +``` +struct __a + +{ + + int id; // 不易变 + + int factor;// 易变 + + char name[64];// 不易变 + + int value;// 易变 + +}; +``` + +在 X86 下,可以试着修改和调整它: + +``` +#define CACHE_LINE_SIZE 64 //缓存行长度 + +struct __a + +{ + + int id; // 不易变 + + char name[64];// 不易变 + + char __align[CACHE_LINE_SIZE – sizeof(int)+sizeof(name) * sizeof(name[0]) % +CACHE_LINE_SIZE] + + int factor;// 易变 + + int value;// 易变 char __align2[CACHE_LINE_SIZE –2* sizeof(int)%CACHE_LINE_SIZE ] +}; +``` + +`CACHE_LINE_SIZE – sizeof(int)+sizeof(name)*sizeof(name[0])%CACHE_LINE_SIZE` 看起来不和谐,`CACHE_LINE_SIZE` 表示高速缓存行(64B 大小)。`__align` 用于显式对齐,这种方式使得结构体字节对齐的大小为缓存行的大小。 + +### 5. 内存占用分析 + +#### 5.1 编译后程序各区域的大小 + +``` +size -A bin +``` + +#### 5.2 虚拟内存占用分析 + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/615d48b4-a6c2-4d3a-8942-907d0c78c9e0) + +`/proc/$pid/smaps` 中的字段含义: + +``` +成员名 含义 +Name 进程的名称 +Pid PID。 +VmPeak 进程使用的最大虚拟内存,通常情况下它等于进程的内存描述符mm 中的 total_vm. +VmSize 进程使用的虚拟内存,它等于mm->total_vm。 +VmLck 进程锁住的内存,它等于mm->locked_vm,这里指使用mlock()锁住的内存。 +VmPin 进程固定住的内存,它等于mm->pinned_vm,这里指使用 get_user_page()固定住的内存。 +VmHWM 进程使用的最大物理内存,它通常等于进程使用的匿名页面、文件映射页面以及共享内存页面的大小总和。 +VmRSS 进程使用的最大物理内存,它常常等于VmHWM,计算公式为 VmRSS= RssAnon+RssFile+RssShmem. +RssAnon 进程使用的匿名页面,通过get_mm_counter(mm,MM_ANONPAGES)获取。 +RssFile 进程使用的文件映射页面,通过get_mm_counter(mm, MM_FILEPAGES)获取。 +RssShmem 进程使用的共享内存页面,通过get_mm_counter(mm, MM_SHMEMPAGES)获取。 +RssFile 进程使用的文件映射页面,通过get_mm_counter(mm, MM_FILEPAGES)获取 RssShmem:进程使用的共享内存页面,通过getmm_counter(mm,MM SHMEMPAGES获取。 +VmData 进程私有数据段的大小,它等于 mm->data_vm。 +VmStk 进程用户栈的大小,它等于mm->stack_vm。 +VmExe 进程代码段的大小,通过内存描述符mm中的start_code和end_code两个成员获取。 +VmLib 进程共享库的大小,通过内存描述符mm中的exec_vm和VmExe计算。 +VmPTE 进程页表大小,通过内存描述符 mm 中的pgtables_bytes 成员获取。 +VmSwap 进程使用的交换分区的大小,通过get_mm_counter(mm,MM_SWAPENTS)获取。 +HugetlbPages 进程使用巨页的大小,通过内存描述符 mm 中的 hugetlb_usage成员获取。 +https://blog.csdn.net/weixin_39247141/article/details/126273389 +``` + +#### 5.3 物理内存占用分析 + +![image](https://github.com/20083017/20083017.github.io/assets/8308226/6646e78e-e5e5-4fae-843b-a28343068b17) + +#### 5.4 heap 占用分析 + +`heap_profiler`(gperftools 提供)。 + +## 后续可补的方向 + +- 把"分配大量虚/实内存"代码格式化展开,并配套一份 RSS / VSZ 观察脚本。 +- 用 perf c2c 复现 padding 章节中的伪共享案例,给出"前后对比"的数据。 +- 针对 ptmalloc / jemalloc / tcmalloc 各做一份"小对象 / 大对象 / 多线程"基准对比。 diff --git "a/_posts/perf/2026-04-25-\347\250\213\345\272\217\346\200\247\350\203\275\345\210\206\346\236\220-\344\272\224\350\246\201\347\264\240.md" "b/_posts/perf/2026-04-25-\347\250\213\345\272\217\346\200\247\350\203\275\345\210\206\346\236\220-\344\272\224\350\246\201\347\264\240.md" new file mode 100644 index 000000000..ec410bacb --- /dev/null +++ "b/_posts/perf/2026-04-25-\347\250\213\345\272\217\346\200\247\350\203\275\345\210\206\346\236\220-\344\272\224\350\246\201\347\264\240.md" @@ -0,0 +1,39 @@ +--- +layout: post +title: 程序性能分析的五个要素 +subtitle: 评估 API / 库性能时需要同时关注的五个维度 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 性能 + - API 设计 +--- + +>原始笔记是一段英文要点,这里只做分类与中文简注,方便扫读。 + +## 当前保留内容 + +衡量一个 API / 库的性能不能只看运行时耗时,下面五个维度需要一起看: + +1. **Compile-time speed(编译期开销)** + 你的 API 对客户端程序编译时间的影响。会直接影响用户的开发效率。 +2. **Run-time speed(运行期耗时)** + 调用 API 方法的时间开销。如果方法调用非常频繁,或者需要在不同输入规模下都保持良好扩展性,这一项尤其重要。 +3. **Run-time memory overhead(运行期内存开销)** + 调用 API 方法所带来的额外内存开销。如果对象会被大量创建并长期保留在内存中,这一项不仅影响内存占用,还可能影响 CPU cache 性能。 +4. **Library size(库体积)** + 实现的目标代码尺寸,会被链接进客户端应用。它影响客户端应用整体的磁盘占用和内存占用。 +5. **Startup time(启动时间)** + 动态库加载并完成初始化所需的时间。受多种因素影响:模板解析、未解析符号绑定、静态初始化器调用、库搜索路径等。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 每个维度对应的常用度量工具(如编译耗时统计、benchmark 框架、`size`/`bloaty`、启动 trace 等) +- "牺牲 A 换 B" 的常见取舍案例(如模板内联换运行时性能、付出编译时间换二进制体积) +- 实际项目中按场景排序的优先级建议(库 vs 应用 vs 框架) + +当前这篇先当作一个"性能五要素"的速记占位条目。 diff --git "a/_posts/python/2026-04-24-openssl-1.1.1g and Python3.8.6 \345\256\211\350\243\205.md" "b/_posts/python/2026-04-24-openssl-1.1.1g and Python3.8.6 \345\256\211\350\243\205.md" new file mode 100644 index 000000000..61f701732 --- /dev/null +++ "b/_posts/python/2026-04-24-openssl-1.1.1g and Python3.8.6 \345\256\211\350\243\205.md" @@ -0,0 +1,386 @@ +--- +layout: post +title: OpenSSL 1.1.1g 与 Python 3.8.6 安装记录 +subtitle: 在保留原始经验的基础上,优先使用独立前缀安装 +date: 2026-04-24 +author: BY +header-img: img/post-bg-unix-linux.jpg +catalog: true +tags: + - OpenSSL + - Python + - Linux +--- + +>这篇文章前一次整理时,删除了不少旧笔记内容,主要是因为其中夹杂了“直接替换系统 OpenSSL / 系统库”的高风险操作。为了尽量保留原始经验,这一版把原先有价值的安装、交叉编译、性能优化和 cryptodev 记录补回来,但不再原样保留会破坏系统环境的替换步骤。 + +## 先确认系统自带版本 + +```bash +openssl version +which openssl +whereis openssl +``` + +原始环境中的检查结果大致如下: + +```bash +[root@cnki-120-145-80 ~]# openssl version +OpenSSL 1.0.2k-fips 26 Jan 2017 + +[root@cnki-120-145-80 ~]# which openssl +/usr/bin/openssl + +[root@cnki-120-145-80 ~]# whereis openssl +openssl: /usr/bin/openssl /usr/lib64/openssl /usr/include/openssl /usr/share/man/man1/openssl.1ssl.gz +``` + +如果系统自带版本过老,**不要直接覆盖 `/usr/bin/openssl`、`/usr/include/openssl` 或 `/usr/lib64/libssl.so*`**。更稳妥的做法是保留系统库,另装一份新的,让目标 Python 或业务程序显式链接新库。 + +## 安装 OpenSSL 1.1.1g 到独立前缀 + +下面示例把 OpenSSL 安装到 `/usr/local/openssl-1.1.1g`: + +```bash +cd /opt +wget https://www.openssl.org/source/old/1.1.1/openssl-1.1.1g.tar.gz +tar -xvf openssl-1.1.1g.tar.gz +cd openssl-1.1.1g + +./config --prefix=/usr/local/openssl-1.1.1g \ + --openssldir=/usr/local/openssl-1.1.1g \ + shared + +make -j"$(nproc)" +make test +sudo make install +``` + +原始笔记里也记录过 3.0.4 / 3.0.12 的编译方式,本质上思路类似:不要动系统库,另外编译、另外安装、单独验证。 + +验证时优先直接调用新路径,而不是替换系统命令: + +```bash +/usr/local/openssl-1.1.1g/bin/openssl version +``` + +如果只是当前 shell 里临时使用新版本,可以这样: + +```bash +export PATH=/usr/local/openssl-1.1.1g/bin:$PATH +export LD_LIBRARY_PATH=/usr/local/openssl-1.1.1g/lib:$LD_LIBRARY_PATH +``` + +如需长期使用,优先在单独的启动脚本、systemd unit 或用户 profile 里设置;不要把兼容性问题简单粗暴地变成 `/usr/lib64` 下的手工软链接。 + +## 历史做法(默认不推荐,但作为特殊情况记录保留) + +原始笔记里其实还记录过几段“为了让系统默认命令马上切到新版本”而采用的处理方式。前一次整理时,这些内容被整段删掉了;这次继续把它们补回来,但明确标注为:**只适用于你已经充分评估影响范围、且确实需要让系统默认路径切换到新版本的特殊场景,不是通用升级步骤。** + +### 1. 直接替换系统 `openssl` 命令与头文件 + +原始笔记里的处理命令大致如下: + +```bash +mv /usr/bin/openssl /usr/bin/openssl.bak +mv /usr/include/openssl /usr/include/openssl.bak + +ln -s /usr/local/openssl/bin/openssl /usr/bin/openssl +ln -s /usr/local/openssl/include/openssl /usr/include/openssl +``` + +它的目的很直接:让系统里默认执行到的 `openssl` 命令、以及默认包含到的头文件,立刻指向新安装版本。 + +当时这样做的出发点,是想让 `openssl version`、依赖系统头文件的编译流程立刻“看到”新版本。但这类做法的问题也很明显: + +- 会影响系统自带工具、SSH、包管理器或其他依赖旧 ABI 的程序 +- 一旦链接关系不完整,系统层面的问题会比业务进程问题更难排查 +- 回滚时不仅要恢复命令,还要确认 include、so、缓存是否都恢复一致 + +更稳妥的替代方式仍然是:**保留系统路径不变,只让目标程序显式使用新前缀里的 OpenSSL。** + +### 2. 在系统动态库目录里手工补 `libssl.so*` / `libcrypto.so*` 软链接 + +原笔记里还记录过两段与动态库相关的特殊处理: + +```bash +echo "/usr/local/openssl/lib64" >> /etc/ld.so.conf +ldconfig -v +``` + +以及在某些报错场景下,手工补系统库目录软链接: + +```bash +ln -s /usr/local/openssl/lib/libssl.so.1.1 /usr/lib/libssl.so.1.1 +ln -s /usr/local/openssl/lib/libssl.so.1.1 /usr/lib64/libssl.so.1.1 +ln -s /usr/local/openssl/lib/libcrypto.so.1.1 /usr/lib64/libcrypto.so.1.1 +``` + +这些记录之所以值得保留,是因为它们对应的确实是当时遇到的“特殊情况处理”: + +- 新版本已经编译安装成功,但运行时仍找不到对应 so +- 某些旧环境里,业务程序就是通过系统默认库搜索路径启动的 +- 需要先把问题定位清楚,再决定是否要做系统级可见的补充配置 + +这样做看起来能“快速救火”,但风险在于: + +- 它绕过了系统原本的库版本管理方式 +- 很容易让不同程序在同一台机器上加载到彼此并不兼容的库 +- 后续升级、排障时,很难第一时间意识到是这些手工软链接在生效 + +如果确实需要系统级可见的新库,优先考虑: + +- 在单独的 `ld.so.conf.d/*.conf` 文件中声明库路径 +- 执行 `ldconfig` 统一刷新缓存 +- 或者直接在目标程序的启动脚本 / service 配置里设置运行时库路径 + +### 3. 为什么这次保留“说明”但不保留“原命令” + +因为这些历史做法本身确实反映了当时遇到的问题: + +- 需要让旧系统尽快用上新版本 OpenSSL +- Python 编译或运行时找不到目标版本的 `ssl` +- 动态库路径没有配置好,导致新程序起不来 + +所以这次做的不是“继续删除”,而是把它们保留为**历史特殊处理记录**。只是阅读时要区分清楚: + +- 这些命令说明“以前碰到问题时是怎么处理的” +- 不等于“现在默认就该这样做” +- 真要使用,至少先确认系统工具、SSH、包管理器、业务二进制、回滚路径都在可控范围内 + +## 编译 Python 3.8.6 并链接新的 OpenSSL + +执行编译时可通过 `-j` 选项指定并行任务数来加快速度,通常可结合 `nproc` 使用。原始笔记里也特别提到:**Python 编译通常不需要 root 用户,普通用户安装到自己的 prefix 更合适。** + +```bash +cd /path/to/Python-3.8.6 + +./configure --prefix=$HOME/local/python-3.8.6 \ + --with-openssl=/usr/local/openssl-1.1.1g + +make -s -j "$(nproc)" +make install +``` + +安装后会在 `prefix` 路径下生成 Python 二进制文件,验证方式例如: + +```bash +$HOME/local/python-3.8.6/bin/python3 -c "import ssl; print(ssl.OPENSSL_VERSION)" +``` + +## 常见问题 + +### 1. 运行时找不到 `libssl.so` + +优先检查: + +- `LD_LIBRARY_PATH` 是否包含 OpenSSL 安装目录下的 `lib` +- 目标二进制的 `rpath` / `runpath` 是否正确 +- 是否误把程序链接到了系统旧库 + +如果确实需要系统级动态库配置,也应通过单独的 `ld.so.conf.d/*.conf` 文件管理,再执行 `ldconfig`;不要随手把 so 文件软链接到 `/usr/lib64` 覆盖系统期望的版本。 + +### 2. 某些平台交叉编译报 asm 相关错误 + +这通常和工具链、目标架构及 OpenSSL 版本有关。遇到这类问题时,优先阅读对应版本的 `INSTALL.md`,按目标平台单独验证 `no-asm`、交叉编译前缀和 engine 选项,不要把其他平台的 Makefile 修改片段直接照搬到当前环境。 + +## 补充:原始笔记中的交叉编译与性能记录 + +下面这些内容是原文中比较有参考价值的部分,这里保留为补充记录。它们更偏“平台经验”,不是通用安装步骤,使用前请按自己的工具链和目标板环境逐项验证。 + +### 不同 SSL 库的编译记录 + +```bash +# wolfssl 32位 armv7 平台交叉编译 +./configure \ + --host=arm-linux-gnueabihf \ + CC=/home/liuquan6/.toolchain/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/bin/arm-linux-gnueabihf-9.1.0-gcc \ + AR=/home/liuquan6/.toolchain/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/bin/arm-linux-gnueabihf-9.1.0-ar \ + STRIP=/home/liuquan6/.toolchain/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/bin/arm-linux-gnueabihf-9.1.0-strip \ + RANLIB=/home/liuquan6/.toolchain/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/gcc-sigmastar-9.1.0-2019.11-x86_64_arm-linux-gnueabihf/bin/arm-linux-gnueabihf-9.1.0-ranlib \ + --prefix=/mnt/e/test/wolfssl/output \ + CFLAGS="-march=armv8-a -DHAVE_PK_CALLBACKS -DWOLFSSL_USER_IO -DNO_WRITEV -DTIME_T_NOT_64BIT" \ + --disable-filesystem --enable-fastmath --enable-sp-asm \ + --disable-shared +make +make install +``` + +```bash +# openssl 32位 armv7 交叉编译 +# 某些平台不禁用 asm 会导致编译错误 + +tar -xvf openssl-3.0.12.tar.gz +cd openssl-3.0.12/ + +# 安装目录设为当前目录下的 tmp,no-asm、shared 的功能请结合 INSTALL.md 阅读 +./config no-asm shared --prefix=$PWD/tmp + +# 如果要启用硬件 engine,例如 devcrypto,需要结合目标平台能力单独确认 +# 原始笔记里提到过 enable-devcryptoeng,这类选项一定要先确认目标内核和驱动支持 + +# 之后再按自己的工具链修改 Makefile,例如 CROSS_COMPILE、架构选项等 +make +make install +``` + +### 性能优化策略 + +原始记录里的要点如下: + +```text +1、指令优化:aesni、aesce 等 +2、硬件加密 +3、asm +4、加密 block size 也会影响速度,需要结合业务场景测试 +``` + +这些优化项都强依赖 CPU 架构、编译器、内核和驱动栈,建议单独做基准测试,不要凭单次结果直接推广。 + +### 性能验证 / 联调时常见命令 + +原始笔记虽然更偏“想到什么记什么”,但这类性能相关命令确实有保留价值,至少便于后续复测: + +```bash +# 查看当前 openssl 是否能识别 engine +/usr/local/openssl-1.1.1g/bin/openssl engine -t -c + +# 观察某个算法在当前机器上的速度 +/usr/local/openssl-1.1.1g/bin/openssl speed -elapsed -evp aes-128-gcm +/usr/local/openssl-1.1.1g/bin/openssl speed -elapsed -evp aes-256-gcm + +# 如果平台有硬件加速能力,可对比是否启用 engine / asm 前后的结果 +``` + +这些命令本身不会替换系统库,但它们的结论也不能脱离场景解读。尤其是: + +- `speed` 结果更适合做“同机、同编译参数、同算法”的横向对比 +- `aesni` / `aesce` / `asm` / 硬件 engine 的收益,要结合目标 CPU 指令集与驱动情况 +- 最终仍要以真实业务负载下的吞吐、时延、CPU 占用为准 + +## 补充:cryptodev engine 记录 + +原始笔记中还保留了 cryptodev engine 的截图和示例,这部分对做硬件加速联调时仍然有参考价值。 + +### cryptodev engine + +对应硬件加密时,通常还需要安装 `cryptodev.ko`: + +![image](https://github.com/user-attachments/assets/f714e814-2148-4daf-a71f-e06cc82646f4) + +![image](https://github.com/user-attachments/assets/56ab6e91-789f-475f-8be9-5be04a229c03) + +### cryptodev 示例 + +下面是原笔记保留的一个 `devcrypto` engine 例子: + +```c +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(LIBRESSL_VERSION_NUMBER) +#include +#endif +#if OPENSSL_VERSION_NUMBER >= 0x30000000L +#include +#include +#endif + +int main(int ac, char **av, char **ae) +{ + ENGINE *e = NULL; + if ((e = ENGINE_by_id("devcrypto")) == NULL) { + printf("cryptodev engine not found!\n\n"); + return 0; + } + + const EVP_CIPHER *cipher; + int len; + unsigned char tag[16]; + + unsigned char key[] = { + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, + 0x36, 0x37, 0x38, 0x39, 0x30, 0x31, 0x32, 0x33, + 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x30, 0x31 + }; + unsigned char iv[] = {0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b}; + unsigned char data[] = { + 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, + 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3a, 0x3b + }; + + ERR_load_crypto_strings(); + + EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new(); + cipher = EVP_aes_256_gcm(); + + EVP_EncryptInit_ex(ctx, cipher, NULL, NULL, NULL); + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, sizeof(iv), NULL); + EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv); + + len = sizeof(data); + int h = 0; + + EVP_EncryptUpdate(ctx, data, &len, data, len); + EVP_EncryptFinal(ctx, tag, &h); + + printf("DATA:"); + for (int i = 0; i < sizeof(data); ++i) + printf("%02X,", data[i]); + printf("\n"); + + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, sizeof tag, tag); + printf("TAG:"); + for (int i = 0; i < sizeof(tag); ++i) + printf("%02X,", tag[i]); + printf("\n"); + + ctx = EVP_CIPHER_CTX_new(); + EVP_DecryptInit(ctx, cipher, key, iv); + EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16, tag); + EVP_DecryptInit(ctx, NULL, key, iv); + EVP_DecryptUpdate(ctx, data, &h, data, len); + printf("DATA:"); + for (int i = 0; i < sizeof(data); ++i) + printf("%02X,", data[i]); + printf("\n"); + int dec_success = EVP_DecryptFinal(ctx, data, &h); + printf("TAG: %d\n", dec_success); + + fflush(stdout); + return 0; +} +``` + +## 为什么上次会删掉大量内容 + +简单说,是因为原文里同时混杂了两类内容: + +1. **值得保留的经验笔记**:版本检查、Python 编译、交叉编译、性能优化、cryptodev 示例。 +2. **高风险系统改造步骤**:直接替换 `/usr/bin/openssl`、覆盖系统 include / so、在系统库目录里手工补软链接。 + +前一次整理时,把第二类高风险内容清掉了,但也连带把第一类经验内容删得太多。这次做了折中:**尽量恢复原始信息量,但不再原样保留容易把系统搞坏的步骤。** + +## 结论 + +这类升级最重要的不是“把系统里的 OpenSSL 换掉”,而是: + +1. 保留系统自带库 +2. 用独立前缀安装新库 +3. 让目标 Python 或业务程序显式链接新库 +4. 平台相关的性能和交叉编译问题,单独做验证和记录 + +这样既能保留原始经验,也不会把整个系统环境一起带崩。 diff --git a/_posts/python/2026-04-25-python-cheat-sheet.md b/_posts/python/2026-04-25-python-cheat-sheet.md new file mode 100644 index 000000000..73f3bbee6 --- /dev/null +++ b/_posts/python/2026-04-25-python-cheat-sheet.md @@ -0,0 +1,116 @@ +--- +layout: post +title: Python 常用代码片段速查 +subtitle: 后台运行、读 txt、dict 初始化/打印/排序、按行执行 shell +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Python + - 速查 + - 脚本 +--- + +>原始笔记是若干个 `## 标题` 加代码块的并列条目,开头还有一行散落的 nohup 命令。这里按"后台运行 / 读文件 / dict 操作 / 按行执行 shell"四块整理,命令和脚本本身保持原样。 + +## 当前保留内容 + +### 1. 远程后台执行 Python 脚本并写文件 + +`nohup` + `tee -a` 同时落到日志文件并维持后台运行: + +``` +nohup python3 run.py |tee -a 1.log & +``` + +### 2. 读 txt 文件 + +逐行读 `employeeUid.txt`、去掉首尾空白后转 int 收集到列表: + +``` +f = open("employeeUid.txt") + +line = f.readline() + +uid_list = [] + +while line: + line = line.rstrip() + line = line.lstrip() + if line is not None: + uid_list.append(int(line)) + #print(int(line)) + line = f.readline() + #print(int(line)) +f.close() +``` + +### 3. dict 操作 + +#### 3.1 dict 初始化 + +用 `dict.fromkeys` 给一组 key 批量建 dict(默认值 None): + +``` +m = range(2048) +mp1 = dict.fromkeys(m) +``` + +#### 3.2 打印 dict + +``` +for v in mp: + print(str(v) + " " + str(mp[v])) +``` + +#### 3.3 dict 排序 + +按值排序: + +``` + print(sorted(key_value.items(), key = lambda kv:(kv[1], kv[0]))) +``` + +按 key 排序: + +``` + # 字典按键排序 + for i in sorted (key_value) : + print ((i, key_value[i]), end =" ") +``` + +### 4. 按行执行 shell 命令 + +把 `shell_sql.txt` 中每一行作为一条 shell 命令依次执行: + +``` +#!/usr/bin/python3 + +import os +import re + + +f = open("shell_sql.txt") + +line = f.readline() + +uid_list = [] + +while line: + #line = line.rstrip() + #line = line.lstrip() + #if line is not None: + # uid_list.append(int(line)) + #print(int(line)) + #line = f.readline() + #print(int(line)) + os.system(line) + line = f.readline() +f.close() +``` + +## 后续可补的方向 + +- "读 txt"用 `with open(...) as f:` 上下文管理器重写一份更安全的版本 +- "按行执行 shell"用 `subprocess.run` 替代 `os.system`,处理返回码与异常 diff --git "a/_posts/python/2026-04-25-python\351\200\220\350\241\214\346\211\247\350\241\214\345\221\275\344\273\244.md" "b/_posts/python/2026-04-25-python\351\200\220\350\241\214\346\211\247\350\241\214\345\221\275\344\273\244.md" new file mode 100644 index 000000000..1edd84ebc --- /dev/null +++ "b/_posts/python/2026-04-25-python\351\200\220\350\241\214\346\211\247\350\241\214\345\221\275\344\273\244.md" @@ -0,0 +1,57 @@ +--- +layout: post +title: Python 逐行读取文件并执行命令 +subtitle: 把"读一行 / 跑一行 shell"的小脚本固化成模板 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Python + - 脚本 +--- + +>原始笔记只有一段未加说明的脚本代码。这里补上用途和注意事项,脚本本身保持原样。 + +## 当前保留内容 + +### 用途 + +读取一个外部文本文件(这里是 `shell_sql.txt`),把里面每一行当成一条 shell 命令逐行 `os.system` 执行——典型场景是批量执行预先生成好的命令清单,例如批量导入 SQL、批量调用工具脚本。 + +### 模板代码 + +``` +#!/usr/bin/python3 + +import os +import re + + +f = open("shell_sql.txt") + +line = f.readline() + +uid_list = [] + +while line: + #line = line.rstrip() + #line = line.lstrip() + #if line is not None: + # uid_list.append(int(line)) + #print(int(line)) + #line = f.readline() + #print(int(line)) + os.system(line) + line = f.readline() +f.close() +``` + +> 注释里的几行是早期把每行解析成 `int` 加进 `uid_list` 的旧思路,留作演进记录。 + +## 后续可补的方向 + +- 用 `with open(...)` + `for line in f` 改成更地道的写法 +- 命令失败时如何判断退出码(`subprocess.run(check=True)`) +- 行内变量替换、跳过空行 / 注释行等常见增强 +- 大批量命令时的并发执行版本(`concurrent.futures`) diff --git "a/_posts/tools/2022-09-03-Git\346\214\207\344\273\244\346\225\264\347\220\206.md" "b/_posts/tools/2022-09-03-Git\346\214\207\344\273\244\346\225\264\347\220\206.md" new file mode 100644 index 000000000..474efe279 --- /dev/null +++ "b/_posts/tools/2022-09-03-Git\346\214\207\344\273\244\346\225\264\347\220\206.md" @@ -0,0 +1,399 @@ +--- +layout: post +title: Git指令整理 +subtitle: 常用 Git 操作与排错记录 +date: 2022-09-03 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Mac + - 终端 + - Git +--- + +>记录常用 Git 命令,以及几个容易踩坑的排查点。 + +# ssh-keygen 权限问题 +生成 SSH key 前,建议使用普通用户重新打开终端后再执行 `ssh-keygen`。如果此前在 `~/.ssh` 或仓库目录里使用过 `sudo`,可能会把文件所有者改成 root,导致后续 Git/SSH 操作出现权限错误;遇到这类问题时,优先检查相关文件的属主和权限。 + +# GitHub创建仓库提示代码 + + echo "# 项目名" >> README.md + git init + git add README.md + git commit -m "first commit" + git branch -M main + git remote add origin git@github.com:20083017/项目名.git + git push -u origin main + +这里的 `git branch -M main` 只是把**本地当前分支**重命名为 `main`;如果远端仓库默认分支仍是 `master`,还需要到托管平台上同步调整默认分支设置,或按远端实际分支名推送。 + +若仓库已存在且本地分支准备推送 + + git branch -M main + git remote add origin git@github.com:20083017/test.git + git push -u origin main + +### Git HTTPS 证书问题 +```bash +# 1. 下载可信 CA 证书,例如: +# https://curl.se/ca/cacert.pem + +# 2. 将证书放到 Git 可访问的位置,例如: +# C:\Program Files\Git\mingw64\ssl\certs\cacert.pem + +# 3. 显式指定 CA 证书 + git config --global http.sslCAInfo "C:\Program Files\Git\mingw64\ssl\certs\cacert.pem" +``` + +不要把 `git config --global http.sslVerify false` 当作常规方案;关闭 SSL 校验只适合临时排障,问题定位完成后应立即恢复校验。 + + +# 常用操作 + +#### stash + no valid stashed state found + git stash apply stash@{0} + +#### 创建仓库(初始化) + 在当前指定目录下创建 + git init + + 新建一个仓库目录 + git init [project-name] + + 克隆一个远程项目 + git clone [url] + + 更新submodule + git submodule update --init --recursive + +#### 添加文件到缓存区 + + 添加所有变化的文件 + git add . + + 添加名称指定文件 + git add text.txt + +#### 配置 + + 设置提交代码时的用户信息 + git config [--global] user.name "[name]" + git config [--global] user.email "[email address]" + + +#### 提交 + 提交暂存区到仓库区 + git commit -m "msg" + + # 提交暂存区的指定文件到仓库区 + $ git commit [file1] [file2] ... -m [message] + + # 提交工作区自上次commit之后的变化,直接到仓库区 + $ git commit -a + + # 提交时显示所有diff信息 + $ git commit -v + + # 使用一次新的commit,替代上一次提交 + # 如果代码没有任何新变化,则用来改写上一次commit的提交信息 + $ git commit --amend -m [message] + + # 重做上一次commit,并包括指定文件的新变化 + $ git commit --amend [file1] [file2] ... + +#### 远程同步 + + # 下载远程仓库的所有变动 + $ git fetch [remote] + + # 显示所有远程仓库 + $ git remote -v + + # 显示某个远程仓库的信息 + $ git remote show [remote] + + # 增加一个新的远程仓库,并命名 + $ git remote add [shortname] [url] + + # 取回远程仓库的变化,并与本地分支合并 + $ git pull [remote] [branch] + + # 上传本地指定分支到远程仓库 + $ git push [remote] [branch] + + # 需要覆盖远程分支时,优先使用更安全的 force-with-lease + $ git push [remote] --force-with-lease + + # 推送所有分支到远程仓库 + $ git push [remote] --all + + + +#### 分支 + + # 列出所有本地分支 + $ git branch + + # 列出所有远程分支 + $ git branch -r + + # 列出所有本地分支和远程分支 + $ git branch -a + + # 新建一个分支,但依然停留在当前分支 + $ git branch [branch-name] + + # 新建一个分支,并切换到该分支 + $ git checkout -b [branch] + + # 新建一个分支,指向指定commit + $ git branch [branch] [commit] + + # 新建一个分支,与指定的远程分支建立追踪关系 + $ git branch --track [branch] [remote-branch] + + # 切换到指定分支,并更新工作区 + $ git checkout [branch-name] + + # 切换到上一个分支 + $ git checkout - + + # 建立追踪关系,在现有分支与指定的远程分支之间 + $ git branch --set-upstream-to [remote-branch] [branch] + + # 合并指定分支到当前分支 + $ git merge [branch] + + # 选择一个commit,合并进当前分支 + $ git cherry-pick [commit] + + # 删除分支 + $ git branch -d [branch-name] + + # 删除远程分支 + $ git push origin --delete [branch-name] + $ git branch -dr [remote/branch] + +#### 标签Tags + + 添加标签 在当前commit + git tag -a v1.0 -m 'xxx' + + 添加标签 在指定commit + git tag v1.0 [commit] + + 查看 + git tag + + 删除 + git tag -d V1.0 + + 删除远程tag + git push origin :refs/tags/[tagName] + + 推送 + git push origin --tags + + 拉取 + git fetch origin tag V1.0 + + 新建一个分支,指向某个tag + git checkout -b [branch] [tag] + +#### 查看信息 + + # 显示有变更的文件 + $ git status + + # 显示当前分支的版本历史 + $ git log + + # 显示commit历史,以及每次commit发生变更的文件 + $ git log --stat + + # 搜索提交历史,根据关键词 + $ git log -S [keyword] + + # 显示某个commit之后的所有变动,每个commit占据一行 + $ git log [tag] HEAD --pretty=format:%s + + # 显示某个commit之后的所有变动,其"提交说明"必须符合搜索条件 + $ git log [tag] HEAD --grep feature + + # 显示某个文件的版本历史,包括文件改名 + $ git log --follow [file] + $ git whatchanged [file] + + # 显示指定文件相关的每一次diff + $ git log -p [file] + + # 显示过去5次提交 + $ git log -5 --pretty --oneline + + # 显示所有提交过的用户,按提交次数排序 + $ git shortlog -sn + + # 显示指定文件是什么人在什么时间修改过 + $ git blame [file] + + # 显示暂存区和工作区的差异 + $ git diff + + # 显示暂存区和上一个commit的差异 + $ git diff --cached [file] + + # 显示工作区与当前分支最新commit之间的差异 + $ git diff HEAD + + # 显示两次提交之间的差异 + $ git diff [first-branch]...[second-branch] + + # 显示今天你写了多少行代码 + $ git diff --shortstat "@{0 day ago}" + + # 显示某次提交的元数据和内容变化 + $ git show [commit] + + # 显示某次提交发生变化的文件 + $ git show --name-only [commit] + + # 显示某次提交时,某个文件的内容 + $ git show [commit]:[filename] + + # 显示当前分支的最近几次提交 + $ git reflog + + # 查看某个符号第一次提交的时间 + git log --reverse -S"your_symbol" --pretty=format:"%h | %ad | %s" + + +#### 撤销 + + # 恢复暂存区的指定文件到工作区 + $ git checkout [file] + + # 恢复某个commit的指定文件到暂存区和工作区 + $ git checkout [commit] [file] + + # 恢复暂存区的所有文件到工作区 + $ git checkout . + + # 重置暂存区的指定文件,与上一次commit保持一致,但工作区不变 + $ git reset [file] + + # 重置暂存区与工作区,与上一次commit保持一致 + $ git reset --hard + + # 重置当前分支的指针为指定commit,同时重置暂存区,但工作区不变 + $ git reset [commit] + + # 重置当前分支的HEAD为指定commit,同时重置暂存区和工作区,与指定commit一致 + $ git reset --hard [commit] + + # 重置当前HEAD为指定commit,但保持暂存区和工作区不变 + $ git reset --keep [commit] + + # 新建一个commit,用来撤销指定commit + # 后者的所有变化都将被前者抵消,并且应用到当前分支 + $ git revert [commit] + + # 暂时将未提交的变化移除,稍后再移入 + $ git stash + $ git stash pop + +#### 其他 + + # 生成一个可供发布的压缩包 + $ git archive + +#### stash 部分文件 +git stash push -m "msg_service" msg_service.cpp + +#### 递归下载依赖模块 +git clone --recurse-submodules -j8 https://github.com/google/autofdo.git +git submodule update --init --recursive + + + +#### push 某个commit 到远程仓库 +``` +要将某个特定的 commit 推送到名为 `feature-module-compile` 的分支,可以使用以下命令: + +``` +git push ssh://liuquan6@gerrit.pt.mioffice.cn:29418/miconnect :refs/for/feature-module-compile +``` + +其中,`` 是要推送的 commit 的 SHA 标识符。 + +例如,如果要将 commit `abc123` 推送到名为 `feature-module-compile` 的分支,可以使用以下命令: + +``` +git push ssh://liuquan6@gerrit.pt.mioffice.cn:29418/miconnect abc123:refs/for/feature-module-compile +``` + +请注意,这将向 Gerrit 发起一个 code review 请求,因此您需要在 Gerrit 上进行审核和合并。如果您只想将 commit 直接推送到分支而不进行审核,请使用前面提到的命令: +``` +git push ssh://liuquan6@gerrit.pt.mioffice.cn:29418/miconnect :feature-module-compile +``` +``` + +#### 清理非本分支代码 +``` +git clean -fdx +``` +#### remote unpack failed: error Missing blob +``` +解决办法 +网上说的是git pull --rebase 这样其实会出现问题,导致本地出现远程需要合入的代码。这样是不对的。 +正确的做法git push --no-thin origin head:refs/for/alpha,即可 +``` + +#### git hooks +``` +#!/bin/sh +# +# Builds our assets upon checkout +# +# Args passed to this are: +# $1 - Previous HEAD +# $2 - New HEAD +# $3 - 1 if checking out a branch, 0 if checking out something else, such as a file (rollbacks) +# +if [ '1' == $3 ] +then + echo 'Branch checkout detected. Building assets...' + gulp prebuild +fi +``` + +#### 合并最近的4个change + +重新提交 +git reset --soft HEAD~4 + +![image](https://github.com/user-attachments/assets/28e611be-15e2-495b-afe1-059eb6970890) + + +1 git rebase -i HEAD~4 + +2 修改提交操作指令‌ +在编辑界面中: + +‌3 保留第一个提交的 pick‌ +‌将后三个提交的 pick 改为 s(squash)或 f(fixup) + +‌4 编辑合并后的提交信息‌ +保存退出后,Git 会打开新界面要求编辑合并后的提交信息。可删除旧信息并重写,保存退出即可完成合并‌8。 + +‌处理冲突(如有)‌ +若合并过程中出现冲突,需手动解决冲突后执行: + + +#### git flow +![image](https://github.com/user-attachments/assets/31648a9f-88da-42b3-b8a5-604b344c050b) + + + diff --git a/_posts/tools/2022-09-07-markdown-cheatsheet.md b/_posts/tools/2022-09-07-markdown-cheatsheet.md new file mode 100644 index 000000000..f0e66afb9 --- /dev/null +++ b/_posts/tools/2022-09-07-markdown-cheatsheet.md @@ -0,0 +1,438 @@ +--- +layout: post +title: Markdown 速查表(繁中翻译版) +subtitle: Adam-P 的 Markdown Cheatsheet 中文翻譯版 +date: 2022-09-07 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Markdown + - Cheat Sheet +--- + +>本篇是从 [adam-p / markdown-here](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) 翻译过来的繁中速查表,按 [CC-BY 3.0](https://creativecommons.org/licenses/by/3.0/) 授权保留。 +> +>这次只补上了 Jekyll 所需的 Front Matter 与少量排版整理,正文内容(含原文译文、示例、授权声明)保持原貌。 + +這篇文章旨在作為快速參考與展示。要更多完整的資訊,請見 [John Gruber 原本的規格](http://daringfireball.net/projects/markdown/)與 [Github 偏好的 Markdown(Github-flavored Markdown,簡寫為GFM)資訊頁](http://github.github.com/github-flavored-markdown/)。 + +如果你正在找 Markdown Here 的小抄(Cheatsheet),[這裡](https://github.com/adam-p/markdown-here/wiki/Markdown-Here-Cheatsheet)也有一篇。你也可以看看更多 Markdown 的工具。 + +譯註:可以參考這份[中文版文件](http://markdown.tw/),有更詳盡的 Markdown 語法說明;如果需要可以練習的線上編輯器,可以試試看[HackMD](https://hackmd.io/)。 + +##### 目錄 + +[Headers](#headers) +[Emphasis](#emphasis) +[Lists](#lists) +[Links](#links) +[Images](#images) +[Code and Syntax Highlighting](#code) +[Tables](#tables) +[Blockquotes](#blockquotes) +[Inline HTML](#html) +[Horizontal Rule](#hr) +[Line Breaks](#lines) +[Youtube videos](#videos) + + + +## 標題 + +```no-highlight +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +你也可以使用下劃線的方式標記 H1 與 H2: + +Alt-H1 +====== + +Alt-H2 +------ +``` + +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +你也可以使用下劃線的方式標記 H1 與 H2: + +Alt-H1 +====== + +Alt-H2 +------ + + + +## 重點 + +```no-highlight +重點,又被稱為斜體,在兩邊加上 *星號* 或是 _下劃線_ 。 + +更強的重點,又稱為粗體,在兩邊加上 **兩個星號** 或是 __兩個下劃線__ 。 + +也可以用 **星號與 _下劃線_** 結合重點。 + +刪除線使用兩個波浪符號。 ~~刪除這個。~~ +``` + +重點,又被稱為斜體,在兩邊加上 *星號* 或是 _下劃線_ 。 + +更強的重點,又稱為粗體,在兩邊加上 **兩個星號** 或是 __兩個下劃線__ 。 + +也可以用 **星號與 _下劃線_** 結合重點。 + +刪除線使用兩個波浪符號。 ~~刪除這個。~~ + + + + +## 列表 + +(在這個例子裡,前置與後面的空白以點的方式顯示:⋅) +(In this example, leading and trailing spaces are shown with with dots: ⋅) + +```no-highlight +1. 第一個有序列表項目 +2. 另一個項目 +⋅⋅⋅* 無序子列表 +1. 實際數字不重要,只要它是一個數字 +⋅⋅⋅1. 有序子列表 +4. 與其他項目 + +⋅⋅⋅要在列表項目下加入段落,只要縮進就好了。注意前面的空白行,以及前置的空白(至少要一個空白,不過我們在這裡會使用三個空白以剛好對齊原始的文字)。 + +⋅⋅⋅要使文字段行而不會成為新的段落,你只需要在後面加上兩個空白。⋅⋅ +⋅⋅⋅注意這行已經分開了,不過還是在同樣的段落中。 +⋅⋅⋅(如果不要求後面的兩個空格,就為伴了典型的 GFM 斷行格式。) + +* 無序列表可以使用星號 +- 或減號 ++ 或加號 +``` + +1. 第一個有序列表項目 +2. 另一個項目 + * 無序子列表 +1. 實際數字不重要,只要它是一個數字 + 1. 有序子列表 +4. 與其他項目 + + 要在列表項目下加入段落,只要縮進就好了。注意前面的空白行,以及前置的空白(至少要一個空白,不過我們在這裡會使用三個空白以剛好對齊原始的文字)。 + + 要使文字段行而不會成為新的段落,你只需要在後面加上兩個空白。 + 注意這行已經分開了,不過還是在同樣的段落中。 + (如果不要求後面的兩個空格,就為伴了典型的 GFM 斷行格式。) + +* 無序列表可以使用星號 +- 或減號 ++ 或加號 + + + +## 連結 + +有兩個方法可以建立連結 + +```no-highlight +[這是一個行內樣式的連結](https://www.google.com) + +[這是一個加上標題的行內樣式連結](https://www.google.com "Google 的首頁") + +[這是一個引用樣式的連結][任意不區分大小寫的文字] + +[以相對的文件路徑引用其他文件](../blob/master/LICENSE) + +[可以用數字來宣告一個引用樣式的連結][1] + +或讓他空白並使用[連結文字本身]。 + +網址,或是尖括號中的網址會自動轉換成連結。 +http://www.example.com 或 甚至有時候 example.com 也可以(只是舉例,Github上無效)。 + +引用連結可以在一些文字後面。 + +[任意不區分大小寫的文字]: https://www.mozilla.org +[1]: http://slashdot.org +[連結文字本身]: http://www.reddit.com +``` + +[這是一個行內樣式的連結](https://www.google.com) + +[這是一個加上標題的行內樣式連結](https://www.google.com "Google 的首頁") + +[這是一個引用樣式的連結][任意不區分大小寫的文字] + +[以相對的文件路徑引用其他文件](../blob/master/LICENSE) + +[可以用數字來宣告一個引用樣式的連結][1] + +或讓他空白並使用[連結文字本身]。 + +網址,或是尖括號中的網址會自動轉換成連結。 +http://www.example.com 或 甚至有時候 example.com 也可以(只是舉例,Github上無效)。 + +引用連結可以在一些文字後面。 + +[任意不區分大小寫的文字]: https://www.mozilla.org +[1]: http://slashdot.org +[連結文字本身]: http://www.reddit.com + + + +## 圖片 + +```no-highlight +這是我們的 Logo(把游標指向 Logo 可以看到標題文字) + +行內樣式: +![alt 文字](https://raw.githubusercontent.com/adam-p/markdown-here/master/src/common/images/icon48.png "Logo 標題文字 1") + +引用樣式: +![alt 文字][logo] + +[logo]: https://raw.githubusercontent.com/adam-p/markdown-here/master/src/common/images/icon48.png "Logo 標題文字 2" +``` + +這是我們的 Logo(把游標指向 Logo 可以看到標題文字) + +行內樣式: +![alt 文字](https://raw.githubusercontent.com/adam-p/markdown-here/master/src/common/images/icon48.png "Logo 標題文字 1") + +引用樣式: +![alt 文字][logo] + +[logo]: https://raw.githubusercontent.com/adam-p/markdown-here/master/src/common/images/icon48.png "Logo 標題文字 2" + + + +## 程式碼與語法高亮 + +程式碼區塊是 Markdown 規格的一部分,不過語法高亮不是。無論如何,許多渲染器──如 Github 和 *Markdown Here* ──支援語法高亮。每個渲染器支援的程式語言,以及程式語言的名字寫法都不一樣。*Markdown Here* 支援幾十種語言(以及不一定是真的程式語言,像 diffs 與 HTTP headers)的語法高亮;要看完整的列表,以及語言的名稱,請見 [highlight.js 的示範頁](http://softwaremaniacs.org/media/soft/highlight/test.html)。 + + +```no-highlight +行內的 `程式碼` 用 `反引號` 包圍起來。 +Inline `code` has `back-ticks around` it. +``` + +行內的 `程式碼` 用 `反引號` 包圍起來。 + +程式碼區塊可以用只有三個反引號```的一行圍起來,或是以四個空格縮排。我建議用三個反引號的方式圍起來 ── 這比較簡單,而且只有這個方式支援語法高亮。 + +
```javascript
+var s = "JavaScript 語法高亮";
+alert(s);
+```
+ 
+```python
+s = "Python 語法高亮"
+print s
+```
+ 
+```
+沒有指定程式語言,所以沒有語法高亮。
+不過,我們可以放進一個 標籤。
+```
+
+ + + +```javascript +var s = "JavaScript 語法高亮"; +alert(s); +``` + +```python +s = "Python 語法高亮" +print s +``` + +``` +沒有指定程式語言,所以 Markdown Here 沒有語法高亮(在Github 上可能不一樣)。 +不過,我們可以放進一個 標籤。 +``` + + +
+ +## 表格 + +Markdown 的核心標準沒有表格,不過表格是 GFM 的一部分,而且 *Markdown Here* 支援表格。這是在電子郵件中加入表格的好方法 - 本來是需要從其他應用程式複製貼上的工作。 + +```no-highlight +冒號可以用來標示欄位的對齊方式。 + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| 第三欄 | 靠右對齊 | $1600 | +| 第二欄 | 置中對齊 | $12 | +| 斑馬條紋 | 是整齊的 | $1 | + +每個標頭元件都要用至少三個破折號分隔開來。 +最外面的豎線可以省略,你也不需要讓原始的文字排列整齊。你也可以使用行內樣式的 Markdown。 + +不 | 漂亮 | 的文字 +--- | --- | --- +*依然* | `渲染的` | **很好** +1 | 2 | 3 +``` + +冒號可以用來標示欄位的對齊方式。 + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| 第三欄 | 靠右對齊 | $1600 | +| 第二欄 | 置中對齊 | $12 | +| 斑馬條紋 | 是整齊的 | $1 | + +每個標頭元件都要用至少三個破折號分隔開來。 +最外面的豎線可以省略,你也不需要讓原始的文字排列整齊。你也可以使用行內樣式的 Markdown。 + +不 | 漂亮 | 的文字 +--- | --- | --- +*依然* | `渲染的` | **很好** +1 | 2 | 3 + + + +## 引用文字 + +```no-highlight +> 在電子郵件中,引用文字可以很方便的模擬回應的文字。 +> 這行也在同樣的引用區塊。 + +引用區塊結束 + +> 就算這行很長,只要包裹的好,依然可以很好的被引用。哦,我們繼續寫以保證每個人都確實的被包在裡面。喔,你也可以在引用文字中 *放入* 其他 **Markdown** 語法。 +``` + +> 在電子郵件中,引用文字可以很方便的模擬回應的文字。 +> 這行也在同樣的引用區塊。 + +引用區塊結束 + +> 就算這行很長,只要包裹的好,依然可以很好的被引用。哦,我們繼續寫以保證每個人都確實的被包在裡面。喔,你也可以在引用文字中 *放入* 其他 **Markdown** 語法。 + + + +## 行內 HTML + +你也可以在 Markdown 裡面撰寫原始的 HTML,這些 HTML 一樣大多數運作的很好。 + +```no-highlight +
+
定義列表
+
有時候,人們偶爾會用到。
+ +
在 HTML 中撰寫 Markdown
+
*無法* 運作的 **非常** 好。改用 HTML標籤
+
+``` + +
+
定義列表
+
有時候,人們偶爾會用到。
+ +
在 HTML 中撰寫 Markdown
+
*無法* 運作的 **非常** 好。改用 HTML標籤
+
+ +
+ +## 水平線 + +``` +三個或更多個…… + +--- + +連字符 + +*** + +星號 + +___ + +下劃線 +``` + +三個或更多個…… + +--- + +連字符 + +*** + +星號 + +___ + +下劃線 + + + +## 斷行 + +如果你想要學習斷行如何運作,我基本上建議最好就是實驗看看 ── 按一下(也就是,插入一個換行符號),接著再按一次 (也就是,插入兩個換行符號),看看會發生什麼。你很快就會得到你想要的。「Markdown Toggle」是你的好朋友。 + +你可以試試看這裡的一些方式: + +``` +我們從這一行開始。 + +兩個換行符號分開了這行和前面那一行,所以這會變成 **分開的段落** 。 + +這行也是個分開的段落,不過…… +只有一個換行符號分開這行,所以這是 *同段落* 中的分開兩行。 +``` + +我們從這一行開始。 + +兩個換行符號分開了這行和前面那一行,所以這會變成 **分開的段落** 。 + +這行也是個分開的段落,不過…… +只有一個換行符號分開這行,所以這是 *同段落* 中的分開兩行。 + +(技術提示: *Markdown Here* 使用 GFM 的換行,所以不用使用 Markdown 原先的兩個空格換行法。) + + + +## Youtube 影片 + +Youtube 影片無法直接被加入,不過我們可以加入圖片,在圖片上設定連結到影片,像這樣: + +```no-highlight + +``` + +或是,用單純的 Markdown,不過會無法改變圖片的大小與邊框: + + +```no-highlight +[![圖片 ALT 文字放在這裡](http://img.youtube.com/vi/YOUTUBE影片ID放在這裡/0.jpg)](http://www.youtube.com/watch?v=YOUTUBE影片ID放在這裡) +``` + +可以在 `git commit` 裡面用 #bugID 的方式引用一個 bug,比如說 #1。 + +--- + +License: [CC-BY](https://creativecommons.org/licenses/by/3.0/) + +翻譯自 [adam-p](https://github.com/adam-p/) 的 [wiki](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) + +本文短網址: [bit.ly/mdcheat](https://bit.ly/mdcheat) diff --git "a/_posts/tools/2022-09-07-\345\270\270\347\224\250\347\275\221\347\253\231.md" "b/_posts/tools/2022-09-07-\345\270\270\347\224\250\347\275\221\347\253\231.md" new file mode 100644 index 000000000..b5473de3b --- /dev/null +++ "b/_posts/tools/2022-09-07-\345\270\270\347\224\250\347\275\221\347\253\231.md" @@ -0,0 +1,48 @@ +--- +layout: post +title: 常用工具网站收藏 +subtitle: 先记一个源码查找站点和最基础的 git 命令占位 +date: 2022-09-07 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Tools + - Git +--- + +>原始笔记里只放了一两个工具链接和零散的 git 命令片段,这里整理成可继续补充的最小占位版本。 + +## 当前保留内容 + +### 在线源码查找 + +需要快速跳转到某个 C/C++ 标准库或开源项目的源码定义时,可以先用: + +- + +它适合「我只想看符号定义在哪、不想本地拉一份完整代码」的场景。 + +### git 基础命令片段 + +原始笔记里只留了几条最基础的步骤,这里补上对应说明: + +```bash +# 1. 初始化仓库(在当前目录下) +git init + +# 2. 添加所有改动到暂存区 +git add . +``` + +`git add .` 会把当前目录下所有变化(新增、修改、删除)一起加入暂存区,提交前最好用 `git status` 再确认一遍。 + +## 后续可补的方向 + +这篇后续如果继续整理,建议至少补下面几类内容: + +- 常用的在线源码 / 反编译 / Diff 站点 +- 个人常用的 Git workflow 速查 +- 编辑器、终端、抓包等其他配套工具入口 + +当前这篇先当作一个待扩充的工具链接占位条目。 diff --git a/_posts/tools/2022-09-08-linux-cheatsheet.md b/_posts/tools/2022-09-08-linux-cheatsheet.md new file mode 100644 index 000000000..478d86796 --- /dev/null +++ b/_posts/tools/2022-09-08-linux-cheatsheet.md @@ -0,0 +1,483 @@ +--- +layout: post +title: linux指令整理 +subtitle: 常用 Linux 排查与运维命令整理 +date: 2022-09-08 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Linux +--- + +>按排障和运维场景整理常用 Linux 命令,优先保留能直接定位问题的内容。 + +# 查看系统芯片方案 +cat /proc/cpuinfo +dmesg 日志 + +openwrt 编译时需要选择系统类型用 +能看到 系统类型 是MT7620A,上面的machine参数给的是自己的固件命名。 + +``` +root@IceCreamBox:~# cat /proc/cpuinfo +system type : Ralink MT7620A ver:2 eco:3 +machine : IceCreamBox +processor : 0 +cpu model : MIPS 24KEc V5.0 +BogoMIPS : 385.84 +wait instruction : yes +microsecond timers : yes +tlb_entries : 32 +extra interrupt vector : yes +hardware watchpoint : yes, count: 4, address/irw mask: [0x0ffc, 0x0ffc, 0x0ffb, 0x0ffb] +isa : mips1 mips2 mips32r1 mips32r2 +ASEs implemented : mips16 dsp +shadow register sets : 1 +kscratch registers : 0 +core : 0 +VCED exceptions : not available +VCEI exceptions : not available + + +``` + + +# 二进制显示文件 linux + + od -tx1 -tc -Ax binFile + +# 常用操作 + +#### grep +grep -aiI 'recv packet' *.log* 避免将文件输出为二进制 -a + +#### mount +挂载前先用 `mount | grep ` 确认目标是否已挂载。 +`umount -l /dev/mmcblk0p1` 可用于延迟卸载。 + +#### dmesg +``` +unix_time=echo "$(date +%s) - $(cat /proc/uptime | cut -f 1 -d' ') + 12106473.374733" | bc + +输出结果 s 转换为 unix时间 +``` + +#### strace 系统函数trace + /usr/bin/strace -o output2.txt -T -tt -e trace=all ./chat + /usr/bin/strace -tt -T -v -f -e trace=file -o strace1.log -s 1024 ./chat + /usr/bin/strace -tt -T -v -f -e trace=file -o strace1.log -s 1024 -p pid +#### ltrace 库函数trace + +#### pstack gstack + +#### proc +/proc/N/fd +/proc/net/tcp + +以上2个,配合使用可以定位 socket fd的连接。 + +/proc/locks +/proc/cmdline + +## 清理线上大文件,先设置低优先级,再删除 +考虑到对线上服务 IO 造成的影响,应该将大文件 mv 走之后,设置操作为低优先级再执行 +mv ... && cd .. && nice -n 6 "${command}" + +#### awk-1 + cat 2.log | awk -F" " '{print $1" " $2" " $3" " $4}' | sort -t' ' -k4 -rn +#### awk-2 统计log行数 + grep '2022-07-25 10:' service.log.2022072510 | awk -F' ' '{print $6}' | sort | uniq -c +#### find +find . -type f \(-name "*.cpp" -o -name "*.h" \) | xargs -P 4 grep -i NoNeedTo --col + +#### IO 查询命令 + iostat + iotop + lsof /dev/sda | grep head + cat /proc/$id/io + pidstat -d 1 + +#### CPU使用率低但是负载高 +当系统负载高时,并不意味着CPU资源不足,只是意味着运行的任务过多,这些任务有可能在等待或者使用cpu,也有可能等待IO完成 + +当系统负载高,并且CPU使用率也比较高时,一般意味着CPU资源不足 + +而当系统负载高,而CPU使用率比较低时,一般有如下两种情况 + +CPU频繁的进行上下文切换(比如应用中开启了太多的线程),导致任务执行的时间比较短(利用vmstat命令查看,如果cs列或in列的值很大,说明就是这种情况) +IO任务太多,导致大量进程处于不可中断状态(利用top命令查看,cpu使用百分比中,wa状态(cpu等待io完成)的使用百分比很高时,说明就是这种情况) +执行vmstat,查看in列(每秒中断的次数)和cs列(每秒上下文切换次数)比较高,则表明CPU频繁的进行上下文切换 +top发现cpu的iowait比较高(wa列的值),则利用I/0问题排查套路接着排查 + +#### 如果我们希望对特定的进程进行监控,可以使用pidstat -w命令 +pidstat是sysstat工具的一个命令,用于监控全部或指定进程的cpu、内存、线程、设备IO等系统资源的占用情况 + +pidstat首次运行时显示自系统启动开始的各项统计信息,之后运行pidstat将显示自上次运行该命令以后的统计信息。用户可以通过指定统计的次数和时间来获得所需的统计信息 + +``` +# 每隔3s输出一次数据 +[root@VM-0-14-centos ~]# pidstat -w 3 +Linux 3.10.0-1127.19.1.el7.x86_64 (VM-0-14-centos) 10/10/2021 _x86_64_ (2 CPU) + +08:13:38 PM UID PID cswch/s nvcswch/s Command +08:13:41 PM 0 6 1.66 0.00 ksoftirqd/0 +08:13:41 PM 0 7 0.33 0.00 migration/0 +08:13:41 PM 0 9 20.27 0.00 rcu_sched +``` + +结果中的cswch和nvcswch是我们需要重点关注的对象 + +cswch(自愿上下文切换):进程无法获取所需要的资源,导致的上下文切换。例如IO,内存等系统资源不足时,就会发生自愿上下文切换 nvcswch(非自愿上下文切换):进程由于时间片已到等愿意,被系统强制调度,进而发生的上下文切换。比如,当大量进程都在争抢CPU时,就容易发生非自愿上下文切换 + + +#### 复制整个文件夹(使用r switch 并且指定目录) + 3-1 从本地文件复制整个文件夹到远程主机上(文件夹. 假如是diff) + 先进入本地目录下,然后运行如下命令: + scp -v -r diff root@192.168.1.104:/usr/local/nginx/html/websroot@192.168.1.104:/usr/local/nginx/html/webs + + 3-2 从远程主机复制整个文件夹到本地目录下(文件夹假如是diff) + 先进入本地目录下,然后运行如下命令: + scp -r root@192.168.1.104:/usr/local/nginx/html/webs/diff . + +#### perf + 每s采集99次,-p pid + perf record -F99 -g -a -e cpu-clock -p 23801 + perf report -i perf.data + +#### top + +top 重要:各个参数的含义, 系统cpu使用率, 业务CPU使用率,swap等 +[linux的top参数含义详解]https://www.cnblogs.com/ggjucheng/archive/2012/01/08/2316399.html + +#### 查看线程cpu使用率 +[线程cpu使用率]https://www.cnblogs.com/ghost240/p/3863774.html + +#### 查看进程线程数量 + ps -T -p ${pid} + ps -T -H ${pid} + +#### ip 正则 +ip grep +grep -E "[^^][0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}" +grep -oE "[^^][0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}" 3.log --col + +#### xargs 执行shell + cat 2.txt | xargs -I {} sh -c {} 或者 -i 如果-I报错的话。 +#### scp +scp output/bin/test {user}@{host}:{path} +scp -v -r yun_conf {user}@{host}:{path} +#### 使用gcore +最初统计的时候,发现CPU高的情况会出现1秒多的时间,如果发现CPU高负载时,直接调用gcore {pid}的命令,可以保留堆栈信息,明确具体高负载的位置。 + +将使用gcore的指令,添加到统计工具中取,设置CPU上门限触发。 + +通过gdb看了几个coredump文件,发现堆栈和函数调用基本一致。可以明确的看到,大量的耗时发生在了AddActInfoV3这一函数中: +![image](https://user-images.githubusercontent.com/8308226/188916763-a1e6961a-3e46-407e-97db-465637353bbe.png) + +#### vmtouch +查看linux文件的pagecache情况-vmtouch + +vmtouch - the Virtual Memory Toucher 就是用来查看linux文件缓存(page cache)使用情况,命中率 +线上高负载时,page cache 过多只是可能原因之一。可以先用 `vmtouch` 判断哪些文件占用了较多 page cache,再结合业务影响决定是否处理;`echo 3 > /proc/sys/vm/drop_caches` 属于高影响操作,只应在确认风险、评估业务影响后再执行。 + + +#### 合并txt文件 +``` +ls *.txt | +while read file_name; +do + # 用.为分隔符只要文件名,去掉文件后缀 + cat "$file_name" >> all.txt + echo "" >> all.txt +done +``` + +#### 查看符号 +nm bin文件 + +#### 查看依赖的so文件 +ldd bin文件 + + +### ssh + rsync 互传文件,rsync断点续传 +``` + Start port forwarding backend +ssh -f -N -L 1234:HostC:22 user@HostB +# You can +# 1. Either, login HostC from port 1234 on localhost +ssh -p 1234 user@localhost +# 2. OR, scp directly +scp -P 1234 src_dir/ user@localhost:target_dir/ +``` +``` +# upload +rsync -P --rsh='ssh -p 1234' /data/myfile user@localhost:/data/ +# download +rsync -P --rsh='ssh -p 1234' user@localhost:/data/myfile /data/ +``` + +### 查看cpu核数 +``` +grep 'model name' /proc/cpuinfo | wc -l +``` + +cpu密集型进程/IO密集型进程/大量等待cpu调度进程均会导致负载高,但是IO密集型进程不会导致cpu利用率高 + +sysstat包含常用性能工具,其中 mpstat 和 pidstat 分别查看cpu利用率和进程占用情况的工具 , vmstat 分析系统性能的工具 + +``` +# -P ALL 表示监控所有cpu,后面的数字5表示间隔5s输出一组数据 +mpstat -P ALL 5 + +``` + +### 节点负载高的排查方法 + +uptime 查看负载变化情况 +通过 mpstat 查看cpu计算量大还是进程争抢大或者io过多 + +iowait表示等待io多,cpu表示cpu本身的消耗,根据 二者数值可以判断是计算密集型还是io密集型。 + + +### 查看进程负载高 +``` +# 间隔5s输出一组数据,只输出一次 +pidstat -u 5 1 +``` +如果,cpu高,iowait低,说明是纯计算密集型;如果cpu低,iowait高,纯io密集型,io包含网络io,磁盘io。二者都高,可以继续查看队列情况,上下文切换次数和类型进一步判断。 + +查看整体的上下文切换情况及队列长度,队列长度大于核数说明负载高。 + +``` +每隔5s输出1组数据 + +vmstat 5 + +# r 表示就绪队列长度 +# cs 表示上下文切换次数 +# in 表示每s中断次数 +# b 表示处于不可中断睡眠状态的进程数 +``` + +### 查看每个进程的上下文切换种类 + +``` +# 每隔5s输出一组数据 +pidstat -w 5 + +#cswch 进程每s自愿上下文切换次数,说明进程都在等其他资源,如io +#nvswch 进程每s非资源上下文切换次数,等待线程过多 +#自愿 一般情况下在资源不足,无法获取资源的情况下出现,非自愿 一般在大量线程进行cpu争用的时候出现 +#pidstat 默认展示进程数据, 增加-t 参数显示线程数据 +``` + + +### +``` +ls -l 4472_gid | awk -F' ' '{print $9}' | xargs -i sh -c 'cat 4472_gid/{}' | xargs -i sh -c 'echo -n {} " "; echo "get ig_{}" | ../redis-cli -h ip -p 9000;' | grep 4472 >> new_t.txt & +``` + +### ssh 连接iot设备 +ssh -oHostKeyAlgorithms=+ssh-dss root@192.168.8.109 + + +### shell 修改默认bash dash + + +### linux top 10 检查命令 +``` +当你登录到一台存在性能问题的Linux服务器上时,在头一分钟,你会检查什么? + +我们看看Netflix的性能工程师是怎么做的。 + +Netflix大量使用EC2 Linux服务器,很多时候是用一些较为高层的工具做云或实例层次的分析。不过有时仍然需要登录到某个实例上,运行一些标准的Linux性能工具。 + +在最开始的一分钟内,可以先利用手头的标准Linux工具大致了解性能状况。借助如下10条命令(有些命令需要安装sysstat包),了解系统资源使用状况和正在运行的进程。先检查错误(errors)和饱和度(saturation),再检查资源利用率(resource utilization)。饱和度指的是负载已经超过处理能力,像请求队列的长度,等待时间等。 + +uptime + +dmesg | tail + +vmstat 1 + +mpstat -P ALL 1 + +pidstat 1 + +iostat -xz 1 + +free -m + +sar -n DEV 1 + +sar -n TCP,ETCP 1 + +top + +这里要提一下定位性能瓶颈的USE方法。在Brendan Gregg的《System Performance: Enterprise and the Cloud》(中译本:《性能之巅:洞悉系统、企业与云计算》)一书中有具体的描述。 + +如果手头有这本书的中译本,可以看一下36页: + +USE方法(utilization、utilization、errors)应用于性能研究,用来识别系统瓶颈。一言以蔽之,就是: + +对于所有的资源,查看它的使用率、饱和度和错误。 + +这些术语定义如下。 + +资源:所有服务器物理元器件(CPU、总线……)。某些软件资源也能算在内,提供有用的指标。 +使用率:在规定的时间间隔内,资源用于服务工作的时间百分比。虽然资源繁忙,但是资源还有能力接受更多的工作,不能接受更多工作的程度被视为饱和度。 +饱和度:资源不能再服务更多额外工作的程度,通常有等待队列。 +错误:错误事件的个数。 +…… + +USE方法会将分析引导到一定数量的关键指标上,这样可以尽快地核实所有的系统资源。在此之后,如果还没有找到问题,那么可以考虑采用其他的方法。 + +下面具体看一下这10条命令。 + +uptime +快速查看平均负载(任务对CPU资源的需求)。输出中的“load average:”后面的三个数字,是系统在1分钟、5分钟和15分钟内的平均负载。表示负载随时间的变化情况。 + +它给出的只是一个较为高层的情况,往往需要借助其他工具进一步确认性能问题,有时候需要通过其他一些指标来了解CPU负载,例如vmstat或mpstat。 + +2. dmesg | tail + +查看最后10条系统消息。查找可能会引发性能问题的错误。千万不要漏掉这一步。 + +3. vmstat 1 + +统计虚拟内存信息。参数1指的是打印1秒内的统计信息。 + +要检查的列: + +r:运行队列的长度(这个参数的解释,建议参考《性能之巅》一书)。可以更好地确定CPU的饱和度。“r”值大于CPU数则为饱和。 +free:以kb为单位的空闲内存。如果这个值很大,说明有足够的空闲内存。下面将介绍的第7条命令——“free -m”,可以更好地解释空闲内存的状态。 +si, so:换入的内存和换出的内存。如果它们不为0,说明内存已经耗尽。 +us, sy, id, wa, st:CPU时间的不同组成部分,是所有CPU的平均数。分别表示用户态时间、系统态时间(内核)、空闲、等待I/O以及窃取时间(stolen time,虚拟化环境下,CPU在其他租户上的开销)。 +将用户态时间和系统态时间相加,可以判断CPU是否忙碌。如果一直有等待I/O,表明存在磁盘瓶颈。因为任务阻塞等待磁盘I/O,此时CPU是空闲的。可以将等待I/O看作另一种形式的CPU空闲。 + +I/O处理一定会消耗系统态时间。如果系统时间平均占比很高,比如说超过20%,或许可以深入研究一下:可能是内核处理I/O的效率不高。 + +4. mpstat -P ALL 1 + +打印每个CPU的状况。可以检查各CPU的负载是否均衡。比如,如果一个CPU很热,可能是单线程应用造成的。 + +5. pidstat 1 + +pidstat按进程打印CPU的使用情况。循环输出活动进程的信息。可用于观察模式随时间的变化情况。用户也可以把观察到的信息记录下来,以供分析研究。 + +像图中的例子,可以看到有2个Java进程消耗了大部分CPU时间。“%CPU”这一列是所有CPU的整体情况,“1591%”这个值表明这2个Java进程几乎占用了16个CPU。 + +6. iostat -xz 1 + +这是了解块设备的一个极佳工具,能看到实际负载和性能信息。 + +r/s, w/s, rkB/s, wkB/s:分别表示每秒发给磁盘设备的读请求数,每秒发给磁盘设备的写请求数,每秒从磁盘设备读取的KB数,每秒向磁盘设备写入的KB数。可以使用它们表示负载特性。性能问题可能就是由过多的负载造成的。 +await:平均I/O响应时间,单位为毫秒。包括排队时间和服务时间。如果它大于预期的平均时间,可能是设备已经饱和,也可能是设备存在问题。 +avgqu-sz:提交到设备的平均请求数。如果大于1,设备可能已经饱和。 +%util:设备使用率。设备忙于处理请求的百分比。如果大于60%,通常会导致较差的性能(可以在await中看出来),不过也与具体的设备有关。如果接近100%,通常意味着设备已经饱和。 +如果存储设备是后面有多块磁盘支撑的逻辑磁盘,即使设备使用率是100%,后端磁盘也可能远没有饱和,而是还能处理更多工作。 + +7. free -m + +主要看最右边的两列: + +buffers:用于块设备I/O的缓冲区高速缓存的大小。 +cached:文件系统使用的页缓存大小。 +我们只需要检查这两个值,如果它们接近0,则会导致更高的磁盘I/O(可以使用iostat确认),性能更糟。图中的例子,这个状况看上去还不错。 + +8. sar -n DEV 1 + +使用该工具检查网络接口的吞吐量,以rxkB/s和txkB/s为手段测量负载。 + +9. sar -n TCP,ETCP 1 + +这是一些关键TCP指标的总结。其中包括: + +active/s:每秒本地发起的TCP连接数(比如通过connect())。 +passive/s:每秒远端发起的TCP连接数(比如通过accept())。 +retrans/s:每秒TCP重传数。 +active和passive连接数通常用于粗略地测量服务器负载。方便起见,可以把active看作向外的连接,把passive看作向内的连接;不过也有不严格之处,比如考虑从localhost到localhost的连接。 + +重传数是网络或服务器问题的一个信号:可能是网络不可靠;也可能是服务器过载和丢包。像图中的例子,每秒只有一个新的TCP连接。 + +10. top + +top命令包含很多前面检查过的指标。可以用个命令来检查是不是有指标和之前命令的输出差距很大。 + +top命令有个缺点,很难看出某个指标随时间的变化模式,这种情况下用像vmstat和pidstat这样的命令可能更清楚,它们能提供滚动输出。间歇性问题的一些迹象,如果不能足够快地暂停输出(Ctrl-S暂停,Ctrl-Q继续),可能会错过。 +``` +### ubuntu rm回收站功能 +``` + vi ~/.bashrc +    # 参数说明: + #  -t参数表示target-directory,指定到了unbutu系统默认回收站目录~/.local/share/Trash/file +    # --backup=t表示使用后缀数字的方式区分rm同名文件的版本 + alias rm='mv -t ~/.local/share/Trash/files --backup=t' +source ~/.bashrc +``` + +### vmware 虚拟机 network unplugable +虚拟网络编辑器 重置 + +### 多线程 grep +``` + find . -type f -regextype posix-extended \ + -regex '.*/[0-9]{6}\..*' \ + -printf "%p\n" | \ + awk -F/ '{n=substr($3,1,6); print n " " $0}' | \ + sort -k1,1 | \ + cut -d' ' -f2- | xargs -P 4 grep -i -E 'ArmMoveObjsTo|ArmGraspObject|ArmHelper|ArmController' --color +``` + +#### sudo:/usr/bin/sudo 必须属于用户 ID 0(的用户)并且设置 setuid 位 + +U盘进入 grub 模式 + +如何进入recovery mode, 在适当的时机,长按 hp shift + f11(grub) shift + f10 shift+ f12(network boot)?!!! 也有说 多点 esc的,碰到的是前边这个 +``` + +一、前言 +这是一个神奇的错误,缘由是因为有人将/usr/bin/sudo的权限改为777或其他。 + +解决办法:最终目的只有一个,想办法执行chmod 4755 /usr/bin/sudo 和 chmod 755 /usr 语句 + +二、解决办法 +1.如果知道root密码。 + +su登录root用户,执行命令chmod 4755 /usr/bin/sudo 执行命令chmod 755 /usr + +2.不知道root密码。 + +重启机器,ubuntu下按esc或shift,进入recovery模式(即单人模式),进入后选择root选项,有的会提示输入root密码。 + +(1).不需要输入root密码的情况下,执行如下两条命令。 + +chmod 4755 /usr/bin/sudo + +chmod 755 /usr + +(2)需要输入root密码的难兄难弟们,请往下看。 + +重启的时候,进入ubuntu高级选项(有的系统是英文的,自己翻译,大概是Advanced options for ubuntu这样),之后能看到recovery 啥啥啥的,按e进入,找到linux /boot/vmlinuz-----\*** ro recovery nomodestset 这句话。 + +然后将ro recovery nomodestset啥啥啥一大串修改为 rw single init=/bin/bash,然后ctrl+x进入单人模式。 + +(此时,想要更改root密码的输入passwd <密码>,之后再确认一次就更改成功了。)执行如下命令。 + +chmod 755 /usr + +chmod 4755 /usr/bin/sudo + +然后重启查看。 + +三、题外 +如果重启之后,提示sudo:在加载插件“sudoers_policy”时在 /etc/sudo.conf 第 0 行出错。 + +必须用root登录(如果不知道密码,用第2点第2条进行重置root密码),卸载sudo并重装就可以了。 + +ubuntu命令如下:apt-get remove sudo 执行apt-get install sudo。 +``` + + + diff --git a/_posts/tools/2026-04-25-baidu_ocr.md b/_posts/tools/2026-04-25-baidu_ocr.md new file mode 100644 index 000000000..f96726844 --- /dev/null +++ b/_posts/tools/2026-04-25-baidu_ocr.md @@ -0,0 +1,134 @@ +--- +layout: post +title: 百度 OCR 调用记录 +subtitle: 从创建应用到获取 access token 再到发起识别请求的最小备忘 +date: 2026-04-25 +author: BY +header-img: img/post-bg-debug.png +catalog: true +tags: + - OCR + - Python + - API +--- + +>把原始笔记里零散的“建应用 + 拿 token + 调接口”三段内容收拢成一篇能直接回看的调用记录。 + +## 1. 先明确调用链路 + +这类接口调用最容易忘的不是某一行 Python 代码,而是整体顺序: + +1. 在百度智能云里创建应用 +2. 拿到 `API Key` 和 `Secret Key` +3. 先请求 `access_token` +4. 再带着 `access_token` 去请求具体 OCR 接口 + +原始笔记里最有价值的信息,其实就是这条顺序本身。 + +## 2. 创建应用后要记住哪些参数 + +获取 token 时,原始记录里保留了这几个参数: + +- `grant_type`:固定为 `client_credentials` +- `client_id`:应用的 `API Key` +- `client_secret`:应用的 `Secret Key` + +整理后最重要的提醒是: + +> `API Key` 和 `Secret Key` 只适合保存在本地安全环境、环境变量或密钥管理系统中,不要直接写进公开仓库。 + +## 3. 获取 access token + +原始笔记中提到,`access_token` 有有效期,需要定期重新获取。 + +最小 Python 示例可以整理成下面这样: + +```python +import requests + + +def get_access_token(api_key: str, secret_key: str) -> str: + url = "https://aip.baidubce.com/oauth/2.0/token" + params = { + "grant_type": "client_credentials", + "client_id": api_key, + "client_secret": secret_key, + } + + response = requests.post(url, params=params, timeout=10) + response.raise_for_status() + + data = response.json() + return data["access_token"] + + +if __name__ == "__main__": + token = get_access_token("YOUR_API_KEY", "YOUR_SECRET_KEY") + print(token) +``` + +### 回看这段时重点确认什么 + +- `client_id` / `client_secret` 是否对应同一个应用 +- 当前应用是否真的开通了 OCR 能力 +- token 是否已经过期 + +## 4. 拿到 token 之后再调用 OCR + +原始文档第三段只留下了另一份获取 token 的脚本,没有把真正的 OCR 请求补全。 +为了让这篇笔记更可用,这里整理成一个最小识别请求示例。 + +以通用文字识别接口为例: + +```python +import base64 +import requests + + +def call_general_basic(access_token: str, image_path: str) -> dict: + url = f"https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic?access_token={access_token}" + + with open(image_path, "rb") as f: + image_base64 = base64.b64encode(f.read()).decode("utf-8") + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + payload = { + "image": image_base64, + } + + response = requests.post(url, data=payload, headers=headers, timeout=20) + response.raise_for_status() + return response.json() + + +if __name__ == "__main__": + result = call_general_basic("YOUR_ACCESS_TOKEN", "/path/to/test.png") + print(result) +``` + +## 5. 这篇记录真正想帮自己记住什么 + +以后回看这篇,优先记住下面三点: + +1. 先拿 token,再调 OCR 接口 +2. `API Key` / `Secret Key` 不要直接硬编码进公开文档或仓库 +3. 如果接口失败,先分清楚是 **鉴权失败** 还是 **OCR 请求本身失败** + +## 6. 遇到失败时先排查哪里 + +如果调用没通,优先按这个顺序看: + +1. `API Key` 和 `Secret Key` 是否正确 +2. `access_token` 是否过期 +3. OCR 接口地址是否写对 +4. 图片是否按接口要求编码 +5. 当前账号或应用是否开通了目标 OCR 服务 + +## 7. 最后保留的最小实践建议 + +- 获取 token 和调用 OCR 最好拆成两个函数 +- 密钥放环境变量,不要放源码 +- 调试时优先打印 HTTP 状态码和返回 JSON +- 如果只是临时试验,至少也要把示例值改成占位符再保存笔记 diff --git a/_posts/tools/2026-04-25-git_hooks.md b/_posts/tools/2026-04-25-git_hooks.md new file mode 100644 index 000000000..2e0d3a75d --- /dev/null +++ b/_posts/tools/2026-04-25-git_hooks.md @@ -0,0 +1,161 @@ +--- +layout: post +title: 项目工程化规约:pre-commit 钩子 + 跨平台换行符 +subtitle: 把 .pre-commit-config.yaml、CMake 自动安装、.gitattributes EOL 这三件事合并整理 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Git + - CMake + - 跨平台 + - 工程化 +--- + +>原本是两篇散记:`git_hooks.md`(pre-commit + CMake 自动安装)与 `跨平台.md`(行尾 EOL 规约)。两边都属于"项目根目录里的全员一致工程化约定",这次合并到一起,按"pre-commit 钩子 / 跨平台换行符"两大块组织,原文要点和命令保持原样。 + +## 当前保留内容 + +### A. pre-commit 钩子 + +#### A.1 手动配置 pre-commit + +`clang-format`、`pre-commit` 都可以通过 `pip` 安装,安装完成后在项目根目录新建 `.pre-commit-config.yaml`: + +``` +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: trailing-whitespace + - id: check-added-large-files + - id: check-merge-conflict + - id: end-of-file-fixer + +- repo: https://github.com/pocc/pre-commit-hooks + rev: v1.3.4 + hooks: + - id: clang-format + args: [--style=File] +``` + +#### A.2 通过 CMake 自动配置 pre-commit + +在团队协作中很难要求所有人都手动安装钩子,特别是新人加入时。所以希望工程在初始化时自动安装 `clang-format`、`pre-commit`,并自动执行 `pre-commit install` 把钩子放到每个开发者仓库的 `.git/hooks` 目录下。 + +``` +# Pre-commit hooks +IF (NOT EXISTS ${CMAKE_CURRENT_LIST_DIR}/.git/hooks/pre-commit) + # FIND_PACKAGE(Python3 COMPONENTS Interpreter Development) + IF (POLICY CMP0094) # https://cmake.org/cmake/help/latest/policy/CMP0094.html + CMAKE_POLICY(SET CMP0094 NEW) # FindPython should return the first matching Python + ENDIF () + # needed on GitHub Actions CI: actions/setup-python does not touch registry/frameworks on Windows/macOS + # this mirrors PythonInterp behavior which did not consult registry/frameworks first + IF (NOT DEFINED Python_FIND_REGISTRY) + SET(Python_FIND_REGISTRY "LAST") + ENDIF () + IF (NOT DEFINED Python_FIND_FRAMEWORK) + SET(Python_FIND_FRAMEWORK "LAST") + ENDIF () + FIND_PACKAGE(Python REQUIRED COMPONENTS Interpreter) + MESSAGE(STATUS "Python executable: ${Python_EXECUTABLE}") + EXECUTE_PROCESS(COMMAND sudo ${Python_EXECUTABLE} -m pip install clang-format pre-commit) + EXECUTE_PROCESS(COMMAND pre-commit install WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") +ENDIF () +``` + +### B. 跨平台换行符(EOL)规约 + +#### B.1 推荐采用 LF(`\n`)作为 EOL + +原因: + +- Linux / macOS 默认使用 LF(`\n`)。 +- Windows 默认使用 CRLF(`\r\n`),但现代编辑器(VSCode、CLion、Sublime、Notepad++)都能很好地识别 LF。 +- GitHub、CI/CD、跨平台工具链都推荐 LF,能减少因 EOL 不一致导致的编译 / 补丁 / 合并冲突等问题。 + +#### B.2 Git 层面自动处理:`.gitattributes` + +在项目根目录添加或修改 `.gitattributes`: + +``` +*.cpp text eol=lf +*.hpp text eol=lf +*.h text eol=lf +*.c text eol=lf +*.inl text eol=lf +*.cmake text eol=lf +``` + +这样 Git 在提交时会自动把这些文件的 EOL 转换为 LF,拉取时也保持一致。 + +#### B.3 编辑器设置 + +- **VSCode**:可设 `"files.eol": "\n"`,并用右下角 EOL 按钮批量转换。 +- **CLion / IDEA**:File → Line separators → Unix and OS X (\n)。 +- **Notepad++**:编辑 → EOL 转换 → 转为 UNIX 格式。 + +#### B.4 CMake / 工具链层面 + +- 一般不需要特殊设置;可以用代码格式化工具(如 `clang-format`)统一行尾。 +- 如果有自动代码生成步骤,建议生成脚本里强制用 LF(如 Python 用 `open(..., newline='\n')`)。 + +#### B.5 总结 + +- **最佳实践**:用 `.gitattributes` 管控,开发工具用 LF,团队达成共识。 +- **不建议**:用 CRLF,除非目标平台仅限 Windows 并且有特殊历史兼容需求。 + +#### B.6 不同语言 EOL 不一致带来的典型问题 + +按踩坑严重程度从高到低: + +**Shell(`.sh`)—— 最致命,直接跑不起来** + +- CRLF 的 `\r` 会被 shebang 解释器当成命令的一部分,典型报错: + - `bash: ./run.sh: /bin/bash^M: bad interpreter: No such file or directory` + - `$'\r': command not found` +- 变量值带 `\r`:`VERSION=1.0` 实际是 `VERSION=1.0\r`,`[ "$VERSION" = "1.0" ]` 永远为假,拼出来的 URL 也是坏的。 +- heredoc 结束符匹配失败:`<原始笔记是几行命令和一段报错,这里只做格式整理,并明确这是一份失败/未完成的尝试记录,不是可用的步骤。 + +## 背景 + +当时想自己拉源码用 Node.js + yarn 走一遍 GitKraken 的构建流程。下面这些命令是按顺序执行的,但卡在了 `yarn install` 这一步。 + +## 当时执行的步骤 + +1. 先安装 Node.js(按官方包或 nvm 都行)。 +2. 全局安装 yarn: + + ```bash + npm install --global yarn + ``` + +3. 在源码目录执行: + + ```bash + yarn install + yarn build + ``` + +## 实际遇到的报错 + +`yarn install` 在 Windows 用户目录下直接报找不到 `package.json`: + +```text +yarn run v1.22.22 +error Couldn't find a package.json file in "C:\\Users\\roborock" +info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command. +``` + +也就是说 yarn 是在当前工作目录而不是仓库根目录下执行的,需要先 `cd` 到含 `package.json` 的源码目录再跑。 + +## 后续可补的方向 + +- 明确 GitKraken 哪些版本/哪些组件实际是开源、可自行构建的 +- Windows 下用 PowerShell / Git Bash 执行 yarn 时路径上的注意事项 +- 完整跑通后的产物路径与运行方式 diff --git a/_posts/tools/2026-04-25-gpt-codex-agent.md b/_posts/tools/2026-04-25-gpt-codex-agent.md new file mode 100644 index 000000000..09330158e --- /dev/null +++ b/_posts/tools/2026-04-25-gpt-codex-agent.md @@ -0,0 +1,318 @@ +--- +layout: post +title: Codex / Copilot Agent 使用记录 +subtitle: Homebrew 国内源、Copilot 模型切换报错与一份 Codex Agent Stage 2 脚手架 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Agent + - Copilot + - Codex + - OpenAI +--- + +>原始笔记把 Homebrew 安装、Copilot 模型切换报错和一段 Codex Agent 脚手架混在一起,这里按「环境准备 / 使用问题 / 配置 / 脚手架」分节整理。 + +## 1. Homebrew 国内安装入口 + +国内网络下,官方 Homebrew 安装脚本经常拉不下来,可以用 gitee 镜像做替代: + +```bash +/bin/bash -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)" +``` + +这是一份社区维护的镜像脚本,仅推荐用于个人开发机;公司机器或生产环境优先用官方源或公司内部源。 + +## 2. Agent 会话使用中常见疑问 + +原始笔记里只留了几个关键词,这里整理成提醒自己回看时要确认的问题清单: + +- 会话上下文压缩 / 清理策略:是否会丢失关键历史? +- 上下文累积带来的 token 消耗:什么时候手动清理一次更合适? +- 模型切换是否会让 Agent「忘掉」前面的对话? +- 长任务下,要不要把关键状态写进 memory,而不是只靠对话历史? + +这些都不是某一个工具的具体配置项,而是用 Agent 时长期需要回头复盘的问题。 + +## 3. Copilot 模型切换的一个真实报错 + +从一个模型切到另一个模型时,遇到过: + +```text +Sorry, your request failed. Please try again. Request id: 189fc83e-5b0a-49b4-937d-4ec3011c22f7 + +Reason: Request Failed: 400 {"error":{"message":"Unsupported parameter: 'top_p' is not supported with this model.","code":"invalid_request_body"}} +``` + +原始笔记里的应对结论是:**卸载 Copilot 后重新安装一次,再切回目标模型,就不会再出这个错。** + +可以理解为:客户端缓存了一组对前一个模型还能用、但对当前模型不再支持的请求参数(这里是 `top_p`),重装后请求模板被重置,问题随之消失。 + +后续遇到类似 `Unsupported parameter` 报错时,先按这个思路怀疑客户端缓存,而不是直接去改服务端模型。 + +## 4. OpenClaw 配置入口 + +如果用到 OpenClaw 之类的本地 Agent / Gateway 工具,配置文件目录是: + +```text +~/.openclaw/openclaw.json +``` + +常用命令: + +```bash +# 重启 gateway +openclaw gateway restart + +# 打开 dashboard +openclaw dashboard + +# 修改配置 +openclaw configure +``` + +启动样例输出: + +```text +🦞 OpenClaw 2026.4.10 (44e5b62) + I run on caffeine, JSON5, and the audacity of "it worked on my machine." + +Restarted systemd service: openclaw-gateway.service +``` + +## 5. Codex Agent Stage 2 最小脚手架 + +原始笔记里保留了一份 shell 脚本,用来一次性拉起 Codex Agent 第二阶段所需的目录、依赖和服务。这里按结构分块整理,方便回看。 + +### 5.1 目录与依赖 + +```bash +#!/usr/bin/env bash +set -e + +echo "🚀 初始化 Codex Agent (Stage 2)..." + +mkdir -p codex-agent/{memory,context,tools} +cd codex-agent + +cat > requirements.txt <<'EOF' +fastapi +uvicorn +openai +chromadb +python-dotenv +redis +numpy +EOF +``` + +### 5.2 向量记忆:`memory/vector_store.py` + +```python +import chromadb + +client = chromadb.Client() +collection = client.get_or_create_collection("memory") + + +def save_memory(text: str) -> None: + collection.add( + documents=[text], + ids=[str(hash(text))], + ) + + +def search_memory(query: str, top_k: int = 5, threshold: float = 0.7): + results = collection.query( + query_texts=[query], + n_results=top_k, + ) + + docs = results["documents"][0] + distances = results["distances"][0] + + return [ + doc + for doc, dist in zip(docs, distances) + if dist < (1 - threshold) + ] +``` + +### 5.3 会话存储:`memory/session.py` + +```python +import json + +import redis + +r = redis.Redis(host="localhost", port=6379, decode_responses=True) +SESSION_TTL = 3600 + + +def get_session(session_id: str): + data = r.get(session_id) + if data: + return json.loads(data) + return [] + + +def save_session(session_id: str, messages) -> None: + r.setex(session_id, SESSION_TTL, json.dumps(messages)) +``` + +### 5.4 上下文拼装:`context/builder.py` + +```python +from memory.vector_store import search_memory + + +def build_context(user_input: str, history) -> str: + memories = search_memory(user_input) + + history_text = "\n".join( + f"User: {h['user']}\nAssistant: {h['assistant']}" + for h in history[-5:] + ) + + return f""" +You are a senior engineer agent. + +Conversation history: +{history_text} + +Relevant memory: +{memories} + +User request: +{user_input} +""" +``` + +### 5.5 工具注册与执行:`tools/registry.py` / `tools/executor.py` + +```python +# tools/registry.py +import ast +import operator as op + +# 仅允许常见算术运算符,避免 eval 带来的任意代码执行风险 +_ALLOWED_OPS = { + ast.Add: op.add, + ast.Sub: op.sub, + ast.Mult: op.mul, + ast.Div: op.truediv, + ast.Mod: op.mod, + ast.Pow: op.pow, + ast.USub: op.neg, + ast.UAdd: op.pos, +} + + +def _safe_eval(node): + if isinstance(node, ast.Expression): + return _safe_eval(node.body) + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return node.value + if isinstance(node, ast.BinOp) and type(node.op) in _ALLOWED_OPS: + return _ALLOWED_OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right)) + if isinstance(node, ast.UnaryOp) and type(node.op) in _ALLOWED_OPS: + return _ALLOWED_OPS[type(node.op)](_safe_eval(node.operand)) + raise ValueError("unsupported expression") + + +def echo_tool(input_text: str) -> str: + return f"Echo: {input_text}" + + +def calc_tool(input_text: str) -> str: + try: + tree = ast.parse(input_text, mode="eval") + return str(_safe_eval(tree)) + except Exception: + return "error" + + +def get_tools(): + return { + "echo": echo_tool, + "calc": calc_tool, + } +``` + +```python +# tools/executor.py +from tools.registry import get_tools + +tools = get_tools() + + +def execute_tool(name: str, input_text: str) -> str: + if name in tools: + return tools[name](input_text) + return "Tool not found" +``` + +> 提醒:`calc_tool` 仅做受限的算术表达式求值(基于 `ast` 白名单),避免直接用 `eval()` 带来的任意代码执行风险。如需支持更复杂的表达式,建议引入专门的安全求值库或沙箱。 + +### 5.6 入口服务:`main.py` + +```python +from fastapi import FastAPI +from openai import OpenAI + +from context.builder import build_context +from memory.session import get_session, save_session +from memory.vector_store import save_memory +from tools.executor import execute_tool + +app = FastAPI() +client = OpenAI() + + +@app.post("/chat") +async def chat(input: str, session_id: str = "default"): + history = get_session(session_id) + context = build_context(input, history) + + response = client.responses.create( + model="gpt-4.1-mini", + input=context, + ) + output = response.output[0].content[0].text + + if output.startswith("TOOL:"): + try: + _, tool_name, tool_input = output.split(":", 2) + tool_result = execute_tool(tool_name, tool_input) + output = f"Tool result: {tool_result}" + except Exception: + output = "Tool execution error" + + history.append({"user": input, "assistant": output}) + save_session(session_id, history) + save_memory(f"Q: {input}\nA: {output}") + + return {"response": output} +``` + +### 5.7 启动顺序 + +```bash +echo "📦 安装依赖..." +pip install -r requirements.txt + +echo "🧠 启动 Redis..." +docker run -d -p 6379:6379 redis + +echo "🚀 启动服务..." +uvicorn main:app --reload +``` + +## 后续可补的方向 + +- Agent 会话压缩 / 摘要的具体策略 +- memory 的检索阈值调参经验 +- 工具调用从「字符串约定」升级到结构化 function call +- 多模型切换时如何隔离客户端缓存 diff --git a/_posts/tools/2026-04-25-graphviz.md b/_posts/tools/2026-04-25-graphviz.md new file mode 100644 index 000000000..4557916e6 --- /dev/null +++ b/_posts/tools/2026-04-25-graphviz.md @@ -0,0 +1,135 @@ +--- +layout: post +title: Graphviz 状态图脚本整理 +subtitle: 从 C++ 状态机代码提取状态转移并导出 PNG +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Graphviz + - Shell + - C++ +--- + +>原始内容是一段脚本,这里补上用途、依赖和执行方法,并把状态提取逻辑整理成更容易直接复用的版本。 + +## 这段脚本做什么 + +目标很简单: + +1. 从 C++ 源码里找出 `case xxx:` +2. 再找对应的 `setNextState(...)` +3. 生成 `state_graph.dot` +4. 用 Graphviz 渲染成 `state_graph.png` + +这类脚本适合那种“状态很多、代码能看懂,但整体流转关系一眼看不出来”的场景。 + +## 依赖 + +```bash +sudo apt install graphviz +``` + +或者: + +```bash +brew install graphviz +``` + +## 使用方式 + +```bash +./gen_state_graph.sh file1.cpp file2.cpp +``` + +输出文件: + +- `state_graph.dot` +- `state_graph.png` + +## 脚本 + +```bash +#!/bin/bash + +# 输入:C++ 代码文件路径(可多个) +# 输出:生成状态转移图 state_graph.png + +parse_transitions() { + local code_files="$@" + + awk ' + BEGIN { current_state = "" } + + /case[[:space:]]+[A-Za-z0-9_]+:/ { + current_state = substr($2, 1, length($2)-1) + } + + /setNextState\([A-Za-z0-9_]+\);/ { + if (current_state != "") { + match($0, /setNextState\(([A-Za-z0-9_]+)\);/, arr) + if (arr[1] != "") { + print current_state " " arr[1] + } + current_state = "" + } + } + + /default:/ { + print "DEFAULT stateerror" + } + ' $code_files | sort | uniq +} + +generate_graph() { + local transitions="$1" + + echo "digraph StateTransition {" > state_graph.dot + echo " rankdir=LR;" >> state_graph.dot + echo " node [shape=box, style=rounded];" >> state_graph.dot + + echo "$transitions" | awk '{print $1 "\n" $2}' | sort -u | while read state; do + if [ "$state" = "DEFAULT" ]; then + continue + fi + echo " \"$state\" [label=\"$state\"];" >> state_graph.dot + done + + echo "$transitions" | while read src dst; do + if [ "$src" = "DEFAULT" ]; then + echo " \"其他状态\" -> \"$dst\" [label=\"非法输入\"];" >> state_graph.dot + else + echo " \"$src\" -> \"$dst\";" >> state_graph.dot + fi + done + + echo "}" >> state_graph.dot + + dot -Tpng state_graph.dot -o state_graph.png + echo "Generated state_graph.png" +} + +if [ $# -eq 0 ]; then + echo "Usage: $0 ..." + exit 1 +fi + +transitions=$(parse_transitions "$@") +generate_graph "$transitions" +``` + +## 适用前提 + +这段脚本默认你的代码大致长这样: + +- 状态分支通过 `case state_x:` 表达 +- 状态切换通过 `setNextState(next_state);` 表达 + +如果你的状态机是: + +- 多层函数跳转 +- 宏展开后才出现状态 +- 一个 case 里有多个条件跳转 + +那这段脚本就更适合作为“粗略提图工具”,而不是严格解析器。 diff --git a/_posts/tools/2026-04-25-ida.md b/_posts/tools/2026-04-25-ida.md new file mode 100644 index 000000000..60f8d4b96 --- /dev/null +++ b/_posts/tools/2026-04-25-ida.md @@ -0,0 +1,44 @@ +--- +layout: post +title: IDA Pro 历史版本资源记录 +subtitle: 收藏一份从 0.1 到 8.3 的 Demo/Free/Leak 版本镜像 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - 逆向 + - IDA + - 工具 +--- + +>原始笔记只有一段未拆分的引用块,信息散乱。这里按"来源 / 备份 / 注意事项"三块整理,链接和原文保持不动。 + +## 当前保留内容 + +### 1. 原始来源 + +有位大佬(应该是俄罗斯的)收集了 IDA 历次版本(Demo / Free / Leak),从 0.1 到 8.3,原始地址在: + +``` +http://fckilfkscwusoopguhi7i6yg3l6tknaz7lrumvlhg5mvtxzxbbxlimid.onion/ +``` + +### 2. 已下载备份 + +已下载放在: + + + +分享给大家!有遗漏或其他版本可以补充请反馈,谢谢~ + +> 2023.12.13 + +### 3. 注意事项 + +因为收到 DMCA 邮件,访问需要添加密码:`pediy`。 + +## 后续可补的方向 + +- 整理常用版本(7.x / 8.x)的特性差异与插件兼容性 +- 备份各版本对应的 SDK / IDAPython 版本对照表 diff --git "a/_posts/tools/2026-04-25-markdown-\345\255\246\344\271\240.md" "b/_posts/tools/2026-04-25-markdown-\345\255\246\344\271\240.md" new file mode 100644 index 000000000..8aa26fdce --- /dev/null +++ "b/_posts/tools/2026-04-25-markdown-\345\255\246\344\271\240.md" @@ -0,0 +1,182 @@ +--- +layout: post +title: Markdown 语法学习笔记 +subtitle: 一份 2014 年记的入门笔记,按目录 / 文本 / 链接 / 图片 / 列表 / 代码 / 表格分块 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Markdown + - 笔记 +--- + +>原始笔记是一份 2014 年自己练手写的 Markdown 入门,文档里的标题用的是不带空格的 `##` / `###`(在 GitHub 渲染下会失效),还混杂了 `` 锚点写法。这里只做"包一层 front matter + 在每个示例外面再加一层标题层级"的轻整理,**保持原始正文不动**——它本身就是一个语法演示页面,改了反而失真。 + +## 当前保留内容 + +下面缩进的部分整体来自原笔记。注意:所有 `#一级标题` / `##二级标题` 之类示例为了保持原始示例效果故意写在代码块外,渲染出来仍会被 Jekyll 识别为标题,仅作为对照示例阅读,不要把它们视作本文真正的章节。 + +### 原始正文 + +Markdown语法学习 +============== +这是篇Markdown的学习文件 +------------- +###           写于:14/07/21 +####(换行Tip:将输入法从半角切换到全角之后输入空格即可换行,So Easy) + +======================= + +##目录 +* [有序目录](#order) +* [横线](#line) +* [标题](#title) +* [显示文本](#text) + * 普通文本 + * 单行文本 + * 多行文本 + * 文字高亮 +* [超链接](#link) + * 文字超链接 + * 链接外部URI + * 链接本仓库里的URI + * 锚点 + * 图片超链接(暂无) +* [显示图片](#pic) + * 网络图片 + * Github仓库里的图片 + * 给图片加上超链接 +* [列表](#dot) +* [符号包围](#symbol) +* [代码高亮](#code) +* [插入表格](#table) +* [总结](#summary) + + +##有序列表 +1. 目录1 +2. 目录2 +1. 目录3,前面的数字不影响显示,效果仍然为3 + + + +##***、---、___显示虚横线 +*** +--- +____ + + + + +#一级标题 +##二级标题 +###三级标题 +####四级标题 +#####五级标题 +######六级标题 + + + +##显示文本 +###这是一段普通文本,直接回车不能换行,
要使用\
+ +###或者在两段文本中加一个空行,也能实现换行效果 + +###单行文本 + 这是个单行文本(使用Tab符即可实现单行文本) + +###多行文本 + 这是多行文本 + 在每行行首添加Tab符即可实现多行文本 + +###文字高亮 +这是`文字高亮`字符串,使用两个` `包围起来字符串即可。这个字符是Tab上方,1左边的按键,注意要在`英文输入法`状态下输入 + +####高亮功能可以用来做一篇文章的Tag
例如:
+`Xcode` `Tag` `Github教程` + +####删除线
这是一个~~删除线~~ +####斜体
这是*斜体文字1*
这是_斜体文字2_ + +####粗体
这是**粗体文字1**
这是__粗体文字2__ + +##
文字链接 +###链接外部URL +[百度首页](http://www.baidu.com) +###锚点 +[点击回到目录](#index) + +##显示图片 +###网络图片 +![](http://b.hiphotos.baidu.com/image/pic/item/a8ec8a13632762d0562318bba2ec08fa513dc691.jpg) +###Github仓库中的图片 +![](https://raw.githubusercontent.com/cocoa-chen/CCGirlApp-Swift/master/CCGirlApp-Swift/CCGirlApp-Swift/screenshoot.png) +###给图片加上超链接 +[![baidu]](http://www.baidu.com) +[baidu]:http://www.baidu.com/img/bdlogo.gif + +##列表 +###圆点列表 +* 列表1 +* 列表2 +* 列表3 + +###更多圆点 +* 一级目录 + * 二级目录 + * 三级目录 + +###复选框列表 +- [x] C +- [x] C++ +- [ ] C## + +###缩进 +###列表缩进 +>第一层缩进 +>>第二层缩进 +>>>第三层缩进 +>>>>第四层缩进 +>>>>>第五层缩进 + +###用于引用: +####下面这段话摘自《某本书》 +>"当你定义一个函数时,你可以定义一个或多个有名字和类型的值,作为函数的输入(称为参数,parameters),也可以定义某种类型的值作为函数执行结束的输出(称为返回类型)。" + +##代码高亮 +``` +public static void main(String[] args){} //Java片段 +``` +``` +println("Swift语句") //Swift片段 +``` +``` +UIView *testView = [[UIView alloc] init]; +``` + +##插入表格 +姓名 | 年龄 | 性别 +--- | --- | --- +张三 | 20 | 男 +李四 | 21 | 男 +这是很长的名字|18|女 + +| Name | Age | +| --- | --- | +| 名字1 | 30 | +| 名字2 | 20 | + +| 靠左对齐(左边写:) | 居中对齐(两边写:) | 靠右对齐(右边写:)| +| :--- | :------: | ------: | +| 左边 |中间 | 右边 | +| ←_← | 居中 | →_→ | + +##总结 +###终于把基本的Markdown语法都联系了一下,算是入门了把,继续努力!Over! + +## 后续可补的方向 + +- 把 `` 锚点改成现代 GFM 自动生成的 slug,并校验目录跳转是否仍然正确。 +- 给"代码高亮"小节补上语言标签(如 ```` ```swift ````),让 highlight 真正生效。 +- 补一节 GitHub Flavored Markdown 特性(任务列表、表情、callout、mermaid)。 diff --git "a/_posts/tools/2026-04-25-telegram \346\263\250\345\206\214.md" "b/_posts/tools/2026-04-25-telegram \346\263\250\345\206\214.md" new file mode 100644 index 000000000..53a32e9f3 --- /dev/null +++ "b/_posts/tools/2026-04-25-telegram \346\263\250\345\206\214.md" @@ -0,0 +1,56 @@ +--- +layout: post +title: Telegram 注册流程整理 +subtitle: 虚拟号码、代理、隐私设置等关键步骤一次列清楚 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Telegram + - 工具 +--- + +>原始笔记是若干小标题下的零散提示,这里按"虚拟号码 / 代理 / 客户端 / 隐私设置 / 避坑"五块整理,文字基本保持原样。 + +## 当前保留内容 + +### 1. 虚拟号码 + +- :选择 Telegram,选 USA 号码,约 0.35 美金;有的号码会被占用,需要多试几次。 +- **虚拟号码与 IP 地址必须一致**,否则容易触发风控。 + +### 2. 代理共享模式 + +V2Ray 参数设置 → 允许来自局域网的连接。 + +- 移动热点打开。 +- 移动热点设置:`192.168.1.5 10808 7890 10809`。 + +### 3. Telegram 客户端 + +- **Telegram Proxy**:`socks5 192.168.1.5 10808 7890 10809` +- **Telegram X**:注册完成后,主端 Telegram 即可正常登陆。 + +### 4. 隐私(Privacy)设置 + +- 打开**两步验证**。 +- 设置 **Passkeys / 通行密钥**,方便后续登陆。 +- 手机号码:**不允许任何人**查看。 +- 邀请:**不允许任何人**邀请你。 +- 账号保留时间:改为 **24 个月**。 +- 在线状态:**Nobody**。 + +### 5. 数据和下载 + +- 自动下载文件:开启**防病毒**。 + +### 6. 避坑提醒 + +- **不要购买 TG 小店的号**:买之前一定要确认是否保号。买完发现冻结的话血亏 20 大洋。 + +## 后续可补的方向 + +- 不同地区虚拟号码服务的对比(价格 / 成功率 / 稳定性) +- 一些常见风控触发点(短时间内多次登录、不同 IP)的避免方法 +- iOS / Android / Desktop 三端各自的代理与登陆配置截图 diff --git a/_posts/tools/2026-04-25-vscode-cheatsheet.md b/_posts/tools/2026-04-25-vscode-cheatsheet.md new file mode 100644 index 000000000..c589279b9 --- /dev/null +++ b/_posts/tools/2026-04-25-vscode-cheatsheet.md @@ -0,0 +1,420 @@ +--- +layout: post +title: VS Code 配置整理 +subtitle: 插件清单、clangd / IWYU / lldb / Bash Debug 配置与一份个人 settings.json +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - VSCode + - clangd + - lldb + - C++ +--- + +>原始笔记把插件清单、clangd 参数、各种 launch / settings 片段全部堆在一起。这里按「插件 / C++ 工具链 / 调试 / settings.json」分节整理,原始片段尽量保留。 +> +>参考: + +## 1. 常用插件清单 + +实际使用中并不是每个插件都同样高频,下面表格里 `frequency` 一栏标的是个人主观强度。 + +![C9iOkmPCZB](https://github.com/20083017/20083017.github.io/assets/8308226/29e7540d-f683-47bc-bbd8-8a87d75371d2) + +| | 插件 | frequency | 备注 | +| :-- | :------------------------ | :-------: | :---------------------------- | +| 1. | Bash Debug | | | +| 2. | Bazel | low | | +| 3. | C/C++ (Microsoft) | high | | +| 4. | Clang-tidy Linter | high | | +| 5. | clangd | | 主力 LSP | +| 6. | cmake | low | | +| 7. | cmake tools | high | | +| 8. | cmake integration | high | | +| 9. | cmake-format | | | +| 10. | CodeLLDB | low | | +| 11. | git graph | high | | +| 12. | gitlens | high | | +| 13. | shell | | | +| 14. | makefile Tools | low | 需 `compile_commands.json` | +| 15. | include what you use | | 需 `compile_commands.json` | +| 16. | clang-format | | 需 `compile_commands.json` | +| 17. | todo-tree | | | +| 18. | Bracket Pair Colorizer | | 新版 VS Code 已内置 | +| 19. | markdownlint | | | +| 20. | Markdown All in One | | | + +## 2. C++ 工具链相关插件配置 + +### 2.1 IWYU(include-what-you-use) + +```bash +sudo apt install iwyu +``` + +`settings.json`: + +```json +{ + "iwyu.exe": "/usr/bin/iwyu", + "iwyu.compile_commands": "${workspaceFolder}/build/compile_commands.json" +} +``` + +### 2.2 makefile Tools + +```json +{ + "makefile.compileCommandsPath": ".vscode/compile_commands.json" +} +``` + +### 2.3 clangd 安装与基础参数 + +依赖(一次性安装): + +```bash +sudo apt install clang clangd lldb cmake +``` + +简单说明: + +- **clang**:C / C++ / Objective-C 的编译器前端(LLVM 项目的一部分) +- **clangd**:基于 clang 的语言服务器,提供补全、语义分析、跳转、查找引用、重构等 +- **lldb**:LLVM 项目的调试器,功能与 GDB 类似 +- **cmake**:跨平台开源构建工具 + +最小可用的 clangd 启动参数: + +```text +--compile-commands-dir=${workspaceFolder} +--background-index +--completion-style=detailed +--header-insertion=never +--log=info +``` + +也可以在「clangd 工具绝对路径」一栏显式指向 `clangd` 二进制: + +![image](https://github.com/user-attachments/assets/1d63a602-343c-435a-a9ea-193aa9335bdc) + +### 2.4 多工具链跳转配置(`--query-driver`) + +如果项目里同时用到本机 clang/gcc 和某个 SDK 的交叉编译器,需要把它们都告诉 clangd: + +```text +--query-driver=/usr/bin/clang++, + /usr/bin/**/clang-*, + /bin/clang, + /bin/clang++, + /usr/bin/gcc, + /usr/bin/g++, + /opt/petalinux/2019.2/sysroots/x86_64-petalinux-linux/usr/bin/arm-xilinx-linux/arm-xilinx-linux-gcc, + /opt/petalinux/2019.2/sysroots/x86_64-petalinux-linux/usr/bin/arm-xilinx-linux/arm-xilinx-linux-g++, + /opt/petalinux/2021.2/sysroots/x86_64-petalinux-linux/usr/bin/aarch64-xilinx-linux/aarch64-xilinx-linux-gcc, + /opt/petalinux/2021.2/sysroots/x86_64-petalinux-linux/usr/bin/aarch64-xilinx-linux/aarch64-xilinx-linux-g++ +``` + +> 没列进来的工具链,clangd 会拒绝从对应 `compile_commands.json` 条目里抽参数,导致跳转失败。 + +`.vscode/settings.json` 完整一条样例: + +![image](https://github.com/user-attachments/assets/633352c0-971c-4544-b277-17c49a87e009) + +```json +{ + "clangd.arguments": [ + "--compile-commands-dir=/home/build/build", + "--background-index", + "--completion-style=detailed", + "--header-insertion=never", + "--log=info", + "--query-driver=/usr/bin/clang++,/usr/bin/**/clang-*,/bin/clang,/bin/clang++,/usr/bin/gcc,/usr/bin/g++,/opt/petalinux/2019.2/sysroots/x86_64-petalinux-linux/usr/bin/arm-xilinx-linux/arm-xilinx-linux-gcc,/opt/petalinux/2019.2/sysroots/x86_64-petalinux-linux/usr/bin/arm-xilinx-linux/arm-xilinx-linux-g++,/opt/petalinux/2021.2/sysroots/x86_64-petalinux-linux/usr/bin/aarch64-xilinx-linux/aarch64-xilinx-linux-gcc,/opt/petalinux/2021.2/sysroots/x86_64-petalinux-linux/usr/bin/aarch64-xilinx-linux/aarch64-xilinx-linux-g++" + ] +} +``` + +### 2.5 一份「全开」版的 clangd 参数 + +适合长期常驻、需要补全 + 静态检查 + 后台索引的工程: + +```jsonc +{ + // 开启粘贴 / 输入时的自动格式化 + "editor.formatOnPaste": true, + "editor.formatOnType": true, + + // 让 Microsoft C/C++ 插件不和 clangd 抢工作 + "C_Cpp.errorSquiggles": "Disabled", + "C_Cpp.intelliSenseEngineFallback": "Disabled", + "C_Cpp.intelliSenseEngine": "Disabled", + + "clangd.path": "/usr/bin/clangd", + "clangd.arguments": [ + "--compile-commands-dir=${workspaceFolder}/build", + "--log=verbose", + "--pretty", + "--all-scopes-completion", + "--completion-style=bundled", + "--cross-file-rename", + "--header-insertion=iwyu", + "--header-insertion-decorators", + "--background-index", + "--clang-tidy", + "--clang-tidy-checks=cppcoreguidelines-*,performance-*,bugprone-*,portability-*,modernize-*,google-*", + // "--fallback-style=file", + "-j=2", + // pch 优化的位置:memory 占内存但更快;板子上推荐 disk + "--pch-storage=disk", + "--function-arg-placeholders=false", + "--compile-commands-dir=build" + ] +} +``` + +也可以使用更精简的版本,明确指定 driver: + +```json +{ + "clangd.path": "/usr/bin/clangd-12", + "clangd.arguments": [ + "--clang-tidy", + "--clang-tidy-checks=cppcoreguidelines-*,performance-*,bugprone-*,portability-*,modernize-*,google-*", + "--query-driver=/usr/bin/clang++" + ] +} +``` + +> 配 `--query-driver` 时,路径要参考 `which clang++` 的真实路径。 + +### 2.6 一些工程上的小技巧 + +- 想以管理员身份打开 VS Code(需要写受保护目录): + ```bash + code --user-data-dir="." + ``` +- 在使用 clangd 时建议**关闭 cmaketools 自动识别 `CMakeLists.txt` 变化**的能力,避免和 clangd 后台索引互相抢资源。 +- `cache` 类参数可以提升首次跳转速度,但配不当会导致跳不动;遇到这种情况优先把 cache 清掉重建索引。 + +## 3. 调试器配置 + +### 3.1 LLDB(`launch.json`) + +```jsonc +{ + "name": "(lldb) 启动", + "type": "cppdbg", + "request": "launch", + "program": "${workspaceFolder}/build/t", + "args": [], + "stopAtEntry": false, + "cwd": "${fileDirname}", + "environment": [], + "externalConsole": false, + "MIMode": "lldb", + "miDebuggerPath": "/usr/bin/lldb-mi", + "setupCommands": [ + { + "description": "为 gdb 启用整齐打印", + "text": "-enable-pretty-printing", + "ignoreFailures": true + }, + { + "description": "将反汇编风格设置为 Intel", + "text": "setting set target.x86-disassembly-flavor intel", + "ignoreFailures": true + } + ] +} +``` + +依赖: + +```bash +sudo apt install liblldb-15-dev +``` + +### 3.2 LLDB 行为类配置(`settings.json`) + +```jsonc +{ + // LLDB 指令自动补全 + "lldb.commandCompletions": true, + // LLDB 指针显示解引用内容 + "lldb.dereferencePointers": true, + // 鼠标悬停在变量上时预览变量值 + "lldb.evaluateForHovers": true, + // LLDB 监视表达式的默认类型 + "lldb.launch.expressions": "simple", + // LLDB 不显示汇编代码 + "lldb.showDisassembly": "never", + // 生成更详细的日志 + "lldb.verboseLogging": true +} +``` + +### 3.3 Bash Debug + +```jsonc +{ + "version": "0.2.0", + "configurations": [ + { + "type": "bashdb", + "request": "launch", + "name": "Bash-Debug (simplest configuration)", + "program": "/home/liuquan6/project/test/miconnect/native/build.sh", + "args": ["-c", "make", "-t", "debug", "-p", "linux", "-a", "camera-pro3", "rebuild"] + } + ] +} +``` + +> 原始笔记里这里漏了几个逗号,整理时已补齐。 + +## 4. 个人 settings.json 模板 + +下面是一份长期使用的个人配置,按需挑选。 + +```jsonc +{ + // 编辑器外观与滚动 + "editor.smoothScrolling": true, + "editor.cursorBlinking": "expand", + "editor.cursorSmoothCaretAnimation": "on", + "editor.hover.above": false, + "workbench.list.smoothScrolling": true, + "editor.mouseWheelZoom": true, + "editor.wordWrap": "on", + "editor.lineHeight": 1.5, + "editor.fontSize": 11, + "editor.fontFamily": "Consolas, '等线', monospace", + "editor.fastScrollSensitivity": 10, + + // 括号、补全 + "editor.guides.bracketPairs": true, + "editor.bracketPairColorization.enabled": true, + "editor.suggest.snippetsPreventQuickSuggestions": false, + "editor.acceptSuggestionOnEnter": "smart", + "editor.suggestSelection": "recentlyUsedByPrefix", + "editor.suggest.insertMode": "replace", + + // 不要把中文标到「非基础 ASCII」黄框里 + "editor.unicodeHighlight.nonBasicASCII": false, + + // 自动闭合 + "editor.autoClosingBrackets": "beforeWhitespace", + "editor.autoClosingDelete": "always", + "editor.autoClosingOvertype": "always", + "editor.autoClosingQuotes": "beforeWhitespace", + + // 缩进 + "editor.detectIndentation": false, + "editor.tabSize": 4, + "editor.suggest.preview": true, + + // 代码格式化(按需开启) + // "editor.formatOnSave": true, + // "editor.formatOnSaveMode": "modifications", + // "editor.defaultFormatter": "xaver.clang-format", + // "clang-format.style": "file", + // "clang-format.fallbackStyle": "LLVM", + + // 构建系统 + // "cmake.configureOnOpen": true, + "cmake.generator": "Ninja", + + // 自动保存 / 索引 + "files.autoSave": "onWindowChange", + "search.followSymlinks": false, + + // 窗口与对话框 + "window.dialogStyle": "custom", + "window.density.editorTabHeight": "compact", + "debug.showBreakpointsInOverviewRuler": true, + + // 资源管理器 + "explorer.compactFolders": true, + "notebook.compactView": true, + + // HTML / 链接编辑 + "editor.linkedEditing": true, + "html.format.wrapAttributes": "preserve", + "html.format.wrapLineLength": 80, + // "editor.rulers": [80], + "html.format.indentHandlebars": true, + + // 文件 + "files.autoGuessEncoding": true, + "files.trimTrailingWhitespace": true, + + // 搜索 + "search.searchEditor.singleClickBehaviour": "peekDefinition", + "editor.stickyScroll.enabled": true, + "workbench.tree.enableStickyScroll": true, + + // 主题与图标 + "workbench.iconTheme": "material-icon-theme", + "workbench.list.fastScrollSensitivity": 10, + "workbench.colorTheme": "Pretty Dark Theme", + "workbench.activityBar.location": "bottom", + "editor.foldingImportsByDefault": true, + "workbench.startupEditor": "none", + + // 内联补全 + "editor.quickSuggestions": { + "other": true, + "comments": true, + "strings": true + }, + + // Live Server + "liveServer.settings.donotShowInfoMsg": true, + "liveServer.settings.donotVerifyTags": true, + + // 字符与小地图 + "editor.wordSeparators": "`~!@%^&*()=+[{]}\\|;:'\",.<>/?(),。;:", + "editor.minimap.enabled": false, + "editor.foldingStrategy": "indentation", + + // 更新 & 排除 + "update.mode": "manual", + "search.exclude": { + "**/build": true, + "**/build/**": true, + "**/.*": true, + "**/.*/**": true, + "**/.vscode": true, + "**/.vscode/**": true + }, + + // 大纲 + "notebook.outline.showCodeCellSymbols": false, + "outline.showArrays": false, + "outline.showBooleans": false, + "outline.showConstants": false, + "outline.showNull": false, + "outline.showNumbers": false, + "outline.showObjects": false, + "outline.showOperators": false, + "outline.showPackages": false, + "outline.showStructs": false, + "outline.showEvents": false, + "outline.showFields": false, + "outline.showFiles": false, + "outline.showProperties": false, + "outline.showEnumMembers": false, + "outline.showEnums": false, + "outline.showInterfaces": false, + "outline.showKeys": false, + "outline.showTypeParameters": false +} +``` + +## 5. 后续可补的方向 + +- VS Code Remote 系列(Remote-SSH / Remote-Containers / WSL)配置 +- 更系统的 clang-tidy 检查项分组与白名单管理 +- 配合 `compile_commands.json` 的多工程切换工作流 diff --git "a/_posts/tools/2026-04-25-windows\345\221\275\344\273\244.md" "b/_posts/tools/2026-04-25-windows\345\221\275\344\273\244.md" new file mode 100644 index 000000000..0bddd0ec1 --- /dev/null +++ "b/_posts/tools/2026-04-25-windows\345\221\275\344\273\244.md" @@ -0,0 +1,87 @@ +--- +layout: post +title: Windows / PowerShell 常用命令速查 +subtitle: 软链接、环境变量重载、vcpkg 安装与配置 +date: 2026-04-25 +author: BY +header-img: img/post-bg-ios9-web.jpg +catalog: true +tags: + - Windows + - PowerShell + - vcpkg +--- + +>原始笔记是若干个 `### 小标题` 加代码块的列表,软件清单部分没有任何上下文。这里按"软链接 / 环境变量 / vcpkg 安装与配置"四块整理,命令和 JSON 配置保持原样。 + +## 当前保留内容 + +### 1. 创建软链接 + +PowerShell 下用 `New-Item -ItemType SymbolicLink` 创建软链接: + +``` +New-Item -ItemType SymbolicLink ` + -Path D:\ ` + -Name nvim ` + -Target C:\ProgramData\scoop\apps\neovim\current\bin\nvim.exe + +``` + +### 2. 重启 PowerShell 让环境变量生效 + +修改完系统环境变量后,新开一个 PowerShell 让其重新加载: + +``` +Start-Process powershell -ArgumentList "-NoExit" +``` + +### 3. vcpkg 安装时常配套的工具 + +按需安装即可,下面这些是配套使用 vcpkg 时常装的: + +- gitkraken +- nodejs +- git +- cmake +- ninja + +### 4. vcpkg 配置(CMake `CMakeSettings.json` 示例) + +针对 VS 14(2015)x86 工具链,配合 Ninja 生成器的一份配置示例: + +``` +{ + "environments": [ + { + "environment": "VS_14_x86", + "VC14INSTALLDIR": "C:\\Program Files (x86)\\Microsoft Visual Studio 14.0\\VC", + "WINDOWSKITS": "C:\\Program Files (x86)\\Windows Kits", + "WINDOWSKITS_VERSION": "10.0.17134.0", + "PATH": "${env.PATH};${env.VC14INSTALLDIR}bin;${env.WINDOWSKITS}\\10\\bin\\x86", + "INCLUDE": "${env.VC14INSTALLDIR}\\INCLUDE;${env.VC14INSTALLDIR}\\ATLMFC\\INCLUDE;${env.WINDOWSKITS}\\10\\include\\${env.WINDOWSKITS_VERSION}\\ucrt;${env.WINDOWSKITS}\\NETFXSDK\\4.6.1\\include\\um;${env.WINDOWSKITS}\\10\\include\\${env.WINDOWSKITS_VERSION}\\shared;${env.WINDOWSKITS}\\10\\include\\${env.WINDOWSKITS_VERSION}\\um;${env.WINDOWSKITS}\\10\\include\\${env.WINDOWSKITS_VERSION}\\winrt;", + "LIB": "${env.VC14INSTALLDIR}\\LIB;${env.VC14INSTALLDIR}\\ATLMFC\\LIB;${env.WINDOWSKITS}\\10\\lib\\${env.WINDOWSKITS_VERSION}\\ucrt\\x86;${env.WINDOWSKITS}\\NETFXSDK\\4.6.1\\lib\\um\\x86;${env.WINDOWSKITS}\\10\\lib\\${env.WINDOWSKITS_VERSION}\\um\\x86;", + "LIBPATH": "C:\\windows\\Microsoft.NET\\Framework\\v4.0.30319;${env.VC14INSTALLDIR}\\LIB;${env.VC14INSTALLDIR}\\ATLMFC\\LIB;${env.WINDOWSKITS}\\10\\UnionMetadata;${env.WINDOWSKITS}\\10\\References;C:\\Program Files (x86)\\Microsoft SDKs\\Windows Kits\\10\\ExtensionSDKs\\Microsoft.VCLibs\\14.0\\References\\CommonConfiguration\\neutral;" + } + ], + "configurations": [ + { + "name": "x86-Debug", + "generator": "Ninja", + "configurationType": "Debug", + "inheritEnvironments": [ "VS_14_x86" ], + "buildRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\build\\${name}", + "installRoot": "${env.USERPROFILE}\\CMakeBuilds\\${workspaceHash}\\install\\${name}", + "cmakeCommandArgs": "", + "buildCommandArgs": "-v", + "ctestCommandArgs": "" + + } + ] +} +``` + +## 后续可补的方向 + +- 给"vcpkg 安装清单"补上各工具的实际用途说明(构建、Git GUI、JS 工具链等) +- 把 Windows 与 WSL2 双环境下软链接的差异写清楚(NTFS link vs. WSL symlink) diff --git a/about.html b/about.html new file mode 100644 index 000000000..ef0952dfc --- /dev/null +++ b/about.html @@ -0,0 +1,140 @@ +--- +layout: page +title: "About" +description: "Hey, this is 2008301760049." +header-img: "img/post-bg-rwd.jpg" +--- + + + + + +
+ + +

冰冻三尺 非一日之寒
+ 积土成山 非斯须之作

+ +

这是我的利用 GitHub PagesJekyll 搭建的 个人博客。我在GitHub主页👉GitHub·20083017。如果有什么问题,欢迎提出探讨~

+ +

+ +
Talks
+ + +
+ + + + + + + + + +{% if site.gitalk.enable %} + + + + +
+ +{% endif %} + + + +{% if site.disqus.enable %} + +
+
+
+
+ + + + + +{% endif %} + diff --git a/assets/jenkins/intake-template.yaml b/assets/jenkins/intake-template.yaml new file mode 100644 index 000000000..bc31f0280 --- /dev/null +++ b/assets/jenkins/intake-template.yaml @@ -0,0 +1,118 @@ +# 用户/平台方需提供的信息模板 +# 复制本文件为 intake.yaml,填好后再开始落地,能避免 80% 的返工。 + +# ─────────── 1. 基础信息 ─────────── +deployment_mode: "aliyun_ack" # aliyun_ack | idc_lan | both +environments: + - name: prod + domain: jenkins.corp.example.com # 强烈建议域名;无 DNS 时填 IP + internal_ip: 10.0.1.20 # 备选 + expected_users: 80 + expected_concurrent_builds: 30 + - name: staging + domain: jenkins-staging.corp.example.com + expected_users: 20 + expected_concurrent_builds: 10 + +# ─────────── 2. 网络与证书 ─────────── +network: + ingress_cidr: # 允许访问 Web 的网段 + - 10.0.0.0/8 + agent_cidr: # agent 网段 + - 10.0.10.0/24 + expose_to_internet: false + zero_trust_gateway: "tailscale" # tailscale | teleport | cloudflare_access | none +tls: + source: "internal_ca" # internal_ca | letsencrypt_dns | self_signed + ca_bundle_path: "/etc/ssl/corp-ca.pem" + +# ─────────── 3. LDAP / AD ─────────── +ldap: + url: "ldaps://ldap.corp.example.com:636" + root_dn: "dc=corp,dc=example,dc=com" + user_search_base: "ou=Users,dc=corp,dc=example,dc=com" + user_search_filter: "sAMAccountName={0}" + group_search_base: "ou=Groups,dc=corp,dc=example,dc=com" + group_membership_filter: "member={0}" + manager_dn: "cn=jenkins-bind,ou=ServiceAccounts,dc=corp,dc=example,dc=com" + manager_password_ref: "vault:secret/jenkins/ldap#password" + groups: + admin: "cn=ci-admins,ou=Groups,dc=corp,dc=example,dc=com" + developer: "cn=ci-developers,ou=Groups,dc=corp,dc=example,dc=com" + release: "cn=ci-release,ou=Groups,dc=corp,dc=example,dc=com" + viewer: "cn=all-staff,ou=Groups,dc=corp,dc=example,dc=com" + +# ─────────── 4. 阿里云(仅方案 A) ─────────── +aliyun: + region: cn-hangzhou + ack_cluster_id: c-xxxxxxxx + vpc_id: vpc-xxxxxxxx + nas_filesystem_id: xxx-xxxxx + oss_backup_bucket: company-jenkins-backup + oss_backup_endpoint: oss-cn-hangzhou-internal.aliyuncs.com + acr_instance: cr.cn-hangzhou.aliyuncs.com/devops + slb_id: lb-xxxxxxxx + dns_provider_for_acme: alidns + ram_role_arn_for_csi: "acs:ram::xxxx:role/..." + +# ─────────── 5. IDC(仅方案 B) ─────────── +idc: + controller_primary_ip: 10.0.1.10 + controller_standby_ip: 10.0.1.11 + vip: 10.0.1.20 + lb_node_ips: [10.0.1.30, 10.0.1.31] + agents: + - hostname: agent-linux-01 + ip: 10.0.10.11 + labels: [linux, build-heavy] + - hostname: agent-win-01 + ip: 10.0.10.21 + labels: [windows] + storage: + jenkins_home_path: /srv/jenkins/home + backup_target: "s3:http://minio.idc.local/jenkins-backup" + backup_credentials_ref: "vault:secret/jenkins/minio" + +# ─────────── 6. 集成系统 ─────────── +integrations: + scm: + type: gitlab # gitlab | bitbucket | github_enterprise + base_url: https://git.corp.example.com + webhook_secret_ref: "vault:secret/jenkins/scm#webhook" + artifact: + type: nexus # nexus | artifactory + base_url: https://nexus.corp.example.com + vault: + url: https://vault.corp.example.com + auth_method: approle + role_id_ref: "vault:..." + secret_id_ref: "vault:..." + observability: + prometheus_url: http://prom.corp.example.com:9090 + grafana_url: https://grafana.corp.example.com + loki_url: http://loki.corp.example.com:3100 + notification: + slack_webhook_ref: "vault:secret/jenkins/slack#url" + email_smtp: smtp.corp.example.com:587 + +# ─────────── 7. 外置 DB(可选,多数情况不需要) ─────────── +# 仅当:1) 使用 Audit Trail 写入外置 DB;2) 有独立 jk 聚合服务;3) 大规模历史检索 +external_db: + enabled: false + type: postgres + host: rds.corp.example.com + port: 5432 + database: jenkins_audit + user: jenkins + password_ref: "vault:secret/jenkins/db#password" + purpose: ["audit"] # audit | aggregation | none + +# ─────────── 8. 策略 / 合规 ─────────── +policy: + rto_minutes: 30 + rpo_hours: 24 + backup_retention_days: 30 + log_retention_days: 180 + patch_window: "Sat 22:00-24:00 CST" + on_call_team: "devops-platform" + data_classification: "internal" diff --git a/categories.html b/categories.html new file mode 100644 index 000000000..b917bcb3b --- /dev/null +++ b/categories.html @@ -0,0 +1,35 @@ +--- +title: 文章目录 +layout: default +header-img: "img/tag-bg.jpg" +--- + + +
+
+
+
+
+

{% if page.title %}{{ page.title }}{% else %}{{ site.title }}{% endif %}

+
+
+
+
+
+ + +
+
+
+ {%- comment -%} + 按 `_posts//` 子目录把 posts 分组。 + Jekyll 默认不会把 `_posts` 下的子目录当作 category,所以这里 + 从 `post.path` 解析出第一级目录作为分组键,并跳过直接放在 + `_posts/` 根目录(没有子目录可作为分类)的 post。 + {%- endcomment -%} + + {% include post-folder-browser.html mode="list" %} + +
+
+
diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 000000000..2fbfde44b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +codecov: + token: d8b2c89f-64a9-4b9a-ac44-da4e871caeff diff --git a/css/bootstrap.css b/css/bootstrap.css new file mode 100644 index 000000000..c46af7dfb --- /dev/null +++ b/css/bootstrap.css @@ -0,0 +1,6566 @@ +/*! + * Bootstrap v3.3.2 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + */ + +/*! normalize.css v3.0.2 | MIT License | git.io/normalize */ +html { + font-family: sans-serif; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} +body { + margin: 0; +} +article, +aside, +details, +figcaption, +figure, +footer, +header, +hgroup, +main, +menu, +nav, +section, +summary { + display: block; +} +audio, +canvas, +progress, +video { + display: inline-block; + vertical-align: baseline; +} +audio:not([controls]) { + display: none; + height: 0; +} +[hidden], +template { + display: none; +} +a { + background-color: transparent; +} +a:active, +a:hover { + outline: 0; +} +abbr[title] { + border-bottom: 1px dotted; +} +b, +strong { + font-weight: bold; +} +dfn { + font-style: italic; +} +h1 { + margin: .67em 0; + font-size: 2em; +} +mark { + color: #000; + background: #ff0; +} +small { + font-size: 80%; +} +sub, +sup { + position: relative; + font-size: 75%; + line-height: 0; + vertical-align: baseline; +} +sup { + top: -.5em; +} +sub { + bottom: -.25em; +} +img { + border: 0; +} +svg:not(:root) { + overflow: hidden; +} +figure { + margin: 1em 40px; +} +hr { + height: 0; + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; +} +pre { + overflow: auto; +} +code, +kbd, +pre, +samp { + font-family: monospace, monospace; + font-size: 1em; +} +button, +input, +optgroup, +select, +textarea { + margin: 0; + font: inherit; + color: inherit; +} +button { + overflow: visible; +} +button, +select { + text-transform: none; +} +button, +html input[type="button"], +input[type="reset"], +input[type="submit"] { + -webkit-appearance: button; + cursor: pointer; +} +button[disabled], +html input[disabled] { + cursor: default; +} +button::-moz-focus-inner, +input::-moz-focus-inner { + padding: 0; + border: 0; +} +input { + line-height: normal; +} +input[type="checkbox"], +input[type="radio"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + padding: 0; +} +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + height: auto; +} +input[type="search"] { + -webkit-box-sizing: content-box; + -moz-box-sizing: content-box; + box-sizing: content-box; + -webkit-appearance: textfield; +} +input[type="search"]::-webkit-search-cancel-button, +input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} +fieldset { + padding: .35em .625em .75em; + margin: 0 2px; + border: 1px solid #c0c0c0; +} +legend { + padding: 0; + border: 0; +} +textarea { + overflow: auto; +} +optgroup { + font-weight: bold; +} +table { + border-spacing: 0; + border-collapse: collapse; +} +td, +th { + padding: 0; +} +/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */ +@media print { + *, + *:before, + *:after { + color: #000 !important; + text-shadow: none !important; + background: transparent !important; + -webkit-box-shadow: none !important; + box-shadow: none !important; + } + a, + a:visited { + text-decoration: underline; + } + a[href]:after { + content: " (" attr(href) ")"; + } + abbr[title]:after { + content: " (" attr(title) ")"; + } + a[href^="#"]:after, + a[href^="javascript:"]:after { + content: ""; + } + pre, + blockquote { + border: 1px solid #999; + + page-break-inside: avoid; + } + thead { + display: table-header-group; + } + tr, + img { + page-break-inside: avoid; + } + img { + max-width: 100% !important; + } + p, + h2, + h3 { + orphans: 3; + widows: 3; + } + h2, + h3 { + page-break-after: avoid; + } + select { + background: #fff !important; + } + .navbar { + display: none; + } + .btn > .caret, + .dropup > .btn > .caret { + border-top-color: #000 !important; + } + .label { + border: 1px solid #000; + } + .table { + border-collapse: collapse !important; + } + .table td, + .table th { + background-color: #fff !important; + } + .table-bordered th, + .table-bordered td { + border: 1px solid #ddd !important; + } +} +@font-face { + font-family: 'Glyphicons Halflings'; + + src: url('../fonts/glyphicons-halflings-regular.eot'); + src: url('../fonts/glyphicons-halflings-regular.eot?#iefix') format('embedded-opentype'), url('../fonts/glyphicons-halflings-regular.woff2') format('woff2'), url('../fonts/glyphicons-halflings-regular.woff') format('woff'), url('../fonts/glyphicons-halflings-regular.ttf') format('truetype'), url('../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular') format('svg'); +} +.glyphicon { + position: relative; + top: 1px; + display: inline-block; + font-family: 'Glyphicons Halflings'; + font-style: normal; + font-weight: normal; + line-height: 1; + + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +.glyphicon-asterisk:before { + content: "\2a"; +} +.glyphicon-plus:before { + content: "\2b"; +} +.glyphicon-euro:before, +.glyphicon-eur:before { + content: "\20ac"; +} +.glyphicon-minus:before { + content: "\2212"; +} +.glyphicon-cloud:before { + content: "\2601"; +} +.glyphicon-envelope:before { + content: "\2709"; +} +.glyphicon-pencil:before { + content: "\270f"; +} +.glyphicon-glass:before { + content: "\e001"; +} +.glyphicon-music:before { + content: "\e002"; +} +.glyphicon-search:before { + content: "\e003"; +} +.glyphicon-heart:before { + content: "\e005"; +} +.glyphicon-star:before { + content: "\e006"; +} +.glyphicon-star-empty:before { + content: "\e007"; +} +.glyphicon-user:before { + content: "\e008"; +} +.glyphicon-film:before { + content: "\e009"; +} +.glyphicon-th-large:before { + content: "\e010"; +} +.glyphicon-th:before { + content: "\e011"; +} +.glyphicon-th-list:before { + content: "\e012"; +} +.glyphicon-ok:before { + content: "\e013"; +} +.glyphicon-remove:before { + content: "\e014"; +} +.glyphicon-zoom-in:before { + content: "\e015"; +} +.glyphicon-zoom-out:before { + content: "\e016"; +} +.glyphicon-off:before { + content: "\e017"; +} +.glyphicon-signal:before { + content: "\e018"; +} +.glyphicon-cog:before { + content: "\e019"; +} +.glyphicon-trash:before { + content: "\e020"; +} +.glyphicon-home:before { + content: "\e021"; +} +.glyphicon-file:before { + content: "\e022"; +} +.glyphicon-time:before { + content: "\e023"; +} +.glyphicon-road:before { + content: "\e024"; +} +.glyphicon-download-alt:before { + content: "\e025"; +} +.glyphicon-download:before { + content: "\e026"; +} +.glyphicon-upload:before { + content: "\e027"; +} +.glyphicon-inbox:before { + content: "\e028"; +} +.glyphicon-play-circle:before { + content: "\e029"; +} +.glyphicon-repeat:before { + content: "\e030"; +} +.glyphicon-refresh:before { + content: "\e031"; +} +.glyphicon-list-alt:before { + content: "\e032"; +} +.glyphicon-lock:before { + content: "\e033"; +} +.glyphicon-flag:before { + content: "\e034"; +} +.glyphicon-headphones:before { + content: "\e035"; +} +.glyphicon-volume-off:before { + content: "\e036"; +} +.glyphicon-volume-down:before { + content: "\e037"; +} +.glyphicon-volume-up:before { + content: "\e038"; +} +.glyphicon-qrcode:before { + content: "\e039"; +} +.glyphicon-barcode:before { + content: "\e040"; +} +.glyphicon-tag:before { + content: "\e041"; +} +.glyphicon-tags:before { + content: "\e042"; +} +.glyphicon-book:before { + content: "\e043"; +} +.glyphicon-bookmark:before { + content: "\e044"; +} +.glyphicon-print:before { + content: "\e045"; +} +.glyphicon-camera:before { + content: "\e046"; +} +.glyphicon-font:before { + content: "\e047"; +} +.glyphicon-bold:before { + content: "\e048"; +} +.glyphicon-italic:before { + content: "\e049"; +} +.glyphicon-text-height:before { + content: "\e050"; +} +.glyphicon-text-width:before { + content: "\e051"; +} +.glyphicon-align-left:before { + content: "\e052"; +} +.glyphicon-align-center:before { + content: "\e053"; +} +.glyphicon-align-right:before { + content: "\e054"; +} +.glyphicon-align-justify:before { + content: "\e055"; +} +.glyphicon-list:before { + content: "\e056"; +} +.glyphicon-indent-left:before { + content: "\e057"; +} +.glyphicon-indent-right:before { + content: "\e058"; +} +.glyphicon-facetime-video:before { + content: "\e059"; +} +.glyphicon-picture:before { + content: "\e060"; +} +.glyphicon-map-marker:before { + content: "\e062"; +} +.glyphicon-adjust:before { + content: "\e063"; +} +.glyphicon-tint:before { + content: "\e064"; +} +.glyphicon-edit:before { + content: "\e065"; +} +.glyphicon-share:before { + content: "\e066"; +} +.glyphicon-check:before { + content: "\e067"; +} +.glyphicon-move:before { + content: "\e068"; +} +.glyphicon-step-backward:before { + content: "\e069"; +} +.glyphicon-fast-backward:before { + content: "\e070"; +} +.glyphicon-backward:before { + content: "\e071"; +} +.glyphicon-play:before { + content: "\e072"; +} +.glyphicon-pause:before { + content: "\e073"; +} +.glyphicon-stop:before { + content: "\e074"; +} +.glyphicon-forward:before { + content: "\e075"; +} +.glyphicon-fast-forward:before { + content: "\e076"; +} +.glyphicon-step-forward:before { + content: "\e077"; +} +.glyphicon-eject:before { + content: "\e078"; +} +.glyphicon-chevron-left:before { + content: "\e079"; +} +.glyphicon-chevron-right:before { + content: "\e080"; +} +.glyphicon-plus-sign:before { + content: "\e081"; +} +.glyphicon-minus-sign:before { + content: "\e082"; +} +.glyphicon-remove-sign:before { + content: "\e083"; +} +.glyphicon-ok-sign:before { + content: "\e084"; +} +.glyphicon-question-sign:before { + content: "\e085"; +} +.glyphicon-info-sign:before { + content: "\e086"; +} +.glyphicon-screenshot:before { + content: "\e087"; +} +.glyphicon-remove-circle:before { + content: "\e088"; +} +.glyphicon-ok-circle:before { + content: "\e089"; +} +.glyphicon-ban-circle:before { + content: "\e090"; +} +.glyphicon-arrow-left:before { + content: "\e091"; +} +.glyphicon-arrow-right:before { + content: "\e092"; +} +.glyphicon-arrow-up:before { + content: "\e093"; +} +.glyphicon-arrow-down:before { + content: "\e094"; +} +.glyphicon-share-alt:before { + content: "\e095"; +} +.glyphicon-resize-full:before { + content: "\e096"; +} +.glyphicon-resize-small:before { + content: "\e097"; +} +.glyphicon-exclamation-sign:before { + content: "\e101"; +} +.glyphicon-gift:before { + content: "\e102"; +} +.glyphicon-leaf:before { + content: "\e103"; +} +.glyphicon-fire:before { + content: "\e104"; +} +.glyphicon-eye-open:before { + content: "\e105"; +} +.glyphicon-eye-close:before { + content: "\e106"; +} +.glyphicon-warning-sign:before { + content: "\e107"; +} +.glyphicon-plane:before { + content: "\e108"; +} +.glyphicon-calendar:before { + content: "\e109"; +} +.glyphicon-random:before { + content: "\e110"; +} +.glyphicon-comment:before { + content: "\e111"; +} +.glyphicon-magnet:before { + content: "\e112"; +} +.glyphicon-chevron-up:before { + content: "\e113"; +} +.glyphicon-chevron-down:before { + content: "\e114"; +} +.glyphicon-retweet:before { + content: "\e115"; +} +.glyphicon-shopping-cart:before { + content: "\e116"; +} +.glyphicon-folder-close:before { + content: "\e117"; +} +.glyphicon-folder-open:before { + content: "\e118"; +} +.glyphicon-resize-vertical:before { + content: "\e119"; +} +.glyphicon-resize-horizontal:before { + content: "\e120"; +} +.glyphicon-hdd:before { + content: "\e121"; +} +.glyphicon-bullhorn:before { + content: "\e122"; +} +.glyphicon-bell:before { + content: "\e123"; +} +.glyphicon-certificate:before { + content: "\e124"; +} +.glyphicon-thumbs-up:before { + content: "\e125"; +} +.glyphicon-thumbs-down:before { + content: "\e126"; +} +.glyphicon-hand-right:before { + content: "\e127"; +} +.glyphicon-hand-left:before { + content: "\e128"; +} +.glyphicon-hand-up:before { + content: "\e129"; +} +.glyphicon-hand-down:before { + content: "\e130"; +} +.glyphicon-circle-arrow-right:before { + content: "\e131"; +} +.glyphicon-circle-arrow-left:before { + content: "\e132"; +} +.glyphicon-circle-arrow-up:before { + content: "\e133"; +} +.glyphicon-circle-arrow-down:before { + content: "\e134"; +} +.glyphicon-globe:before { + content: "\e135"; +} +.glyphicon-wrench:before { + content: "\e136"; +} +.glyphicon-tasks:before { + content: "\e137"; +} +.glyphicon-filter:before { + content: "\e138"; +} +.glyphicon-briefcase:before { + content: "\e139"; +} +.glyphicon-fullscreen:before { + content: "\e140"; +} +.glyphicon-dashboard:before { + content: "\e141"; +} +.glyphicon-paperclip:before { + content: "\e142"; +} +.glyphicon-heart-empty:before { + content: "\e143"; +} +.glyphicon-link:before { + content: "\e144"; +} +.glyphicon-phone:before { + content: "\e145"; +} +.glyphicon-pushpin:before { + content: "\e146"; +} +.glyphicon-usd:before { + content: "\e148"; +} +.glyphicon-gbp:before { + content: "\e149"; +} +.glyphicon-sort:before { + content: "\e150"; +} +.glyphicon-sort-by-alphabet:before { + content: "\e151"; +} +.glyphicon-sort-by-alphabet-alt:before { + content: "\e152"; +} +.glyphicon-sort-by-order:before { + content: "\e153"; +} +.glyphicon-sort-by-order-alt:before { + content: "\e154"; +} +.glyphicon-sort-by-attributes:before { + content: "\e155"; +} +.glyphicon-sort-by-attributes-alt:before { + content: "\e156"; +} +.glyphicon-unchecked:before { + content: "\e157"; +} +.glyphicon-expand:before { + content: "\e158"; +} +.glyphicon-collapse-down:before { + content: "\e159"; +} +.glyphicon-collapse-up:before { + content: "\e160"; +} +.glyphicon-log-in:before { + content: "\e161"; +} +.glyphicon-flash:before { + content: "\e162"; +} +.glyphicon-log-out:before { + content: "\e163"; +} +.glyphicon-new-window:before { + content: "\e164"; +} +.glyphicon-record:before { + content: "\e165"; +} +.glyphicon-save:before { + content: "\e166"; +} +.glyphicon-open:before { + content: "\e167"; +} +.glyphicon-saved:before { + content: "\e168"; +} +.glyphicon-import:before { + content: "\e169"; +} +.glyphicon-export:before { + content: "\e170"; +} +.glyphicon-send:before { + content: "\e171"; +} +.glyphicon-floppy-disk:before { + content: "\e172"; +} +.glyphicon-floppy-saved:before { + content: "\e173"; +} +.glyphicon-floppy-remove:before { + content: "\e174"; +} +.glyphicon-floppy-save:before { + content: "\e175"; +} +.glyphicon-floppy-open:before { + content: "\e176"; +} +.glyphicon-credit-card:before { + content: "\e177"; +} +.glyphicon-transfer:before { + content: "\e178"; +} +.glyphicon-cutlery:before { + content: "\e179"; +} +.glyphicon-header:before { + content: "\e180"; +} +.glyphicon-compressed:before { + content: "\e181"; +} +.glyphicon-earphone:before { + content: "\e182"; +} +.glyphicon-phone-alt:before { + content: "\e183"; +} +.glyphicon-tower:before { + content: "\e184"; +} +.glyphicon-stats:before { + content: "\e185"; +} +.glyphicon-sd-video:before { + content: "\e186"; +} +.glyphicon-hd-video:before { + content: "\e187"; +} +.glyphicon-subtitles:before { + content: "\e188"; +} +.glyphicon-sound-stereo:before { + content: "\e189"; +} +.glyphicon-sound-dolby:before { + content: "\e190"; +} +.glyphicon-sound-5-1:before { + content: "\e191"; +} +.glyphicon-sound-6-1:before { + content: "\e192"; +} +.glyphicon-sound-7-1:before { + content: "\e193"; +} +.glyphicon-copyright-mark:before { + content: "\e194"; +} +.glyphicon-registration-mark:before { + content: "\e195"; +} +.glyphicon-cloud-download:before { + content: "\e197"; +} +.glyphicon-cloud-upload:before { + content: "\e198"; +} +.glyphicon-tree-conifer:before { + content: "\e199"; +} +.glyphicon-tree-deciduous:before { + content: "\e200"; +} +.glyphicon-cd:before { + content: "\e201"; +} +.glyphicon-save-file:before { + content: "\e202"; +} +.glyphicon-open-file:before { + content: "\e203"; +} +.glyphicon-level-up:before { + content: "\e204"; +} +.glyphicon-copy:before { + content: "\e205"; +} +.glyphicon-paste:before { + content: "\e206"; +} +.glyphicon-alert:before { + content: "\e209"; +} +.glyphicon-equalizer:before { + content: "\e210"; +} +.glyphicon-king:before { + content: "\e211"; +} +.glyphicon-queen:before { + content: "\e212"; +} +.glyphicon-pawn:before { + content: "\e213"; +} +.glyphicon-bishop:before { + content: "\e214"; +} +.glyphicon-knight:before { + content: "\e215"; +} +.glyphicon-baby-formula:before { + content: "\e216"; +} +.glyphicon-tent:before { + content: "\26fa"; +} +.glyphicon-blackboard:before { + content: "\e218"; +} +.glyphicon-bed:before { + content: "\e219"; +} +.glyphicon-apple:before { + content: "\f8ff"; +} +.glyphicon-erase:before { + content: "\e221"; +} +.glyphicon-hourglass:before { + content: "\231b"; +} +.glyphicon-lamp:before { + content: "\e223"; +} +.glyphicon-duplicate:before { + content: "\e224"; +} +.glyphicon-piggy-bank:before { + content: "\e225"; +} +.glyphicon-scissors:before { + content: "\e226"; +} +.glyphicon-bitcoin:before { + content: "\e227"; +} +.glyphicon-yen:before { + content: "\00a5"; +} +.glyphicon-ruble:before { + content: "\20bd"; +} +.glyphicon-scale:before { + content: "\e230"; +} +.glyphicon-ice-lolly:before { + content: "\e231"; +} +.glyphicon-ice-lolly-tasted:before { + content: "\e232"; +} +.glyphicon-education:before { + content: "\e233"; +} +.glyphicon-option-horizontal:before { + content: "\e234"; +} +.glyphicon-option-vertical:before { + content: "\e235"; +} +.glyphicon-menu-hamburger:before { + content: "\e236"; +} +.glyphicon-modal-window:before { + content: "\e237"; +} +.glyphicon-oil:before { + content: "\e238"; +} +.glyphicon-grain:before { + content: "\e239"; +} +.glyphicon-sunglasses:before { + content: "\e240"; +} +.glyphicon-text-size:before { + content: "\e241"; +} +.glyphicon-text-color:before { + content: "\e242"; +} +.glyphicon-text-background:before { + content: "\e243"; +} +.glyphicon-object-align-top:before { + content: "\e244"; +} +.glyphicon-object-align-bottom:before { + content: "\e245"; +} +.glyphicon-object-align-horizontal:before { + content: "\e246"; +} +.glyphicon-object-align-left:before { + content: "\e247"; +} +.glyphicon-object-align-vertical:before { + content: "\e248"; +} +.glyphicon-object-align-right:before { + content: "\e249"; +} +.glyphicon-triangle-right:before { + content: "\e250"; +} +.glyphicon-triangle-left:before { + content: "\e251"; +} +.glyphicon-triangle-bottom:before { + content: "\e252"; +} +.glyphicon-triangle-top:before { + content: "\e253"; +} +.glyphicon-console:before { + content: "\e254"; +} +.glyphicon-superscript:before { + content: "\e255"; +} +.glyphicon-subscript:before { + content: "\e256"; +} +.glyphicon-menu-left:before { + content: "\e257"; +} +.glyphicon-menu-right:before { + content: "\e258"; +} +.glyphicon-menu-down:before { + content: "\e259"; +} +.glyphicon-menu-up:before { + content: "\e260"; +} +* { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +*:before, +*:after { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +html { + font-size: 10px; + + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} +body { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + line-height: 1.42857143; + color: #333; + background-color: #fff; +} +input, +button, +select, +textarea { + font-family: inherit; + font-size: inherit; + line-height: inherit; +} +a { + color: #337ab7; + text-decoration: none; +} +a:hover, +a:focus { + color: #23527c; + text-decoration: underline; +} +a:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +figure { + margin: 0; +} +img { + vertical-align: middle; +} +.img-responsive, +.thumbnail > img, +.thumbnail a > img, +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + display: block; + max-width: 100%; + height: auto; +} +.img-rounded { + border-radius: 6px; +} +.img-thumbnail { + display: inline-block; + max-width: 100%; + height: auto; + padding: 4px; + line-height: 1.42857143; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + -webkit-transition: all .2s ease-in-out; + -o-transition: all .2s ease-in-out; + transition: all .2s ease-in-out; +} +.img-circle { + border-radius: 50%; +} +hr { + margin-top: 20px; + margin-bottom: 20px; + border: 0; + border-top: 1px solid #eee; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} +h1, +h2, +h3, +h4, +h5, +h6, +.h1, +.h2, +.h3, +.h4, +.h5, +.h6 { + font-family: inherit; + font-weight: 500; + line-height: 1.1; + color: inherit; +} +h1 small, +h2 small, +h3 small, +h4 small, +h5 small, +h6 small, +.h1 small, +.h2 small, +.h3 small, +.h4 small, +.h5 small, +.h6 small, +h1 .small, +h2 .small, +h3 .small, +h4 .small, +h5 .small, +h6 .small, +.h1 .small, +.h2 .small, +.h3 .small, +.h4 .small, +.h5 .small, +.h6 .small { + font-weight: normal; + line-height: 1; + color: #777; +} +h1, +.h1, +h2, +.h2, +h3, +.h3 { + margin-top: 20px; + margin-bottom: 10px; +} +h1 small, +.h1 small, +h2 small, +.h2 small, +h3 small, +.h3 small, +h1 .small, +.h1 .small, +h2 .small, +.h2 .small, +h3 .small, +.h3 .small { + font-size: 65%; +} +h4, +.h4, +h5, +.h5, +h6, +.h6 { + margin-top: 10px; + margin-bottom: 10px; +} +h4 small, +.h4 small, +h5 small, +.h5 small, +h6 small, +.h6 small, +h4 .small, +.h4 .small, +h5 .small, +.h5 .small, +h6 .small, +.h6 .small { + font-size: 75%; +} +h1, +.h1 { + font-size: 36px; +} +h2, +.h2 { + font-size: 30px; +} +h3, +.h3 { + font-size: 24px; +} +h4, +.h4 { + font-size: 18px; +} +h5, +.h5 { + font-size: 14px; +} +h6, +.h6 { + font-size: 12px; +} +p { + margin: 0 0 10px; +} +.lead { + margin-bottom: 20px; + font-size: 16px; + font-weight: 300; + line-height: 1.4; +} +@media (min-width: 768px) { + .lead { + font-size: 21px; + } +} +small, +.small { + font-size: 85%; +} +mark, +.mark { + padding: .2em; + background-color: #fcf8e3; +} +.text-left { + text-align: left; +} +.text-right { + text-align: right; +} +.text-center { + text-align: center; +} +.text-justify { + text-align: justify; +} +.text-nowrap { + white-space: nowrap; +} +.text-lowercase { + text-transform: lowercase; +} +.text-uppercase { + text-transform: uppercase; +} +.text-capitalize { + text-transform: capitalize; +} +.text-muted { + color: #777; +} +.text-primary { + color: #337ab7; +} +a.text-primary:hover { + color: #286090; +} +.text-success { + color: #3c763d; +} +a.text-success:hover { + color: #2b542c; +} +.text-info { + color: #31708f; +} +a.text-info:hover { + color: #245269; +} +.text-warning { + color: #8a6d3b; +} +a.text-warning:hover { + color: #66512c; +} +.text-danger { + color: #a94442; +} +a.text-danger:hover { + color: #843534; +} +.bg-primary { + color: #fff; + background-color: #337ab7; +} +a.bg-primary:hover { + background-color: #286090; +} +.bg-success { + background-color: #dff0d8; +} +a.bg-success:hover { + background-color: #c1e2b3; +} +.bg-info { + background-color: #d9edf7; +} +a.bg-info:hover { + background-color: #afd9ee; +} +.bg-warning { + background-color: #fcf8e3; +} +a.bg-warning:hover { + background-color: #f7ecb5; +} +.bg-danger { + background-color: #f2dede; +} +a.bg-danger:hover { + background-color: #e4b9b9; +} +.page-header { + padding-bottom: 9px; + margin: 40px 0 20px; + border-bottom: 1px solid #eee; +} +ul, +ol { + margin-top: 0; + margin-bottom: 10px; +} +ul ul, +ol ul, +ul ol, +ol ol { + margin-bottom: 0; +} +.list-unstyled { + padding-left: 0; + list-style: none; +} +.list-inline { + padding-left: 0; + margin-left: -5px; + list-style: none; +} +.list-inline > li { + display: inline-block; + padding-right: 5px; + padding-left: 5px; +} +dl { + margin-top: 0; + margin-bottom: 20px; +} +dt, +dd { + line-height: 1.42857143; +} +dt { + font-weight: bold; +} +dd { + margin-left: 0; +} +@media (min-width: 768px) { + .dl-horizontal dt { + float: left; + width: 160px; + overflow: hidden; + clear: left; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; + } + .dl-horizontal dd { + margin-left: 180px; + } +} +abbr[title], +abbr[data-original-title] { + cursor: help; + border-bottom: 1px dotted #777; +} +.initialism { + font-size: 90%; + text-transform: uppercase; +} +blockquote { + padding: 10px 20px; + margin: 0 0 20px; + font-size: 17.5px; + border-left: 5px solid #eee; +} +blockquote p:last-child, +blockquote ul:last-child, +blockquote ol:last-child { + margin-bottom: 0; +} +blockquote footer, +blockquote small, +blockquote .small { + display: block; + font-size: 80%; + line-height: 1.42857143; + color: #777; +} +blockquote footer:before, +blockquote small:before, +blockquote .small:before { + content: '\2014 \00A0'; +} +.blockquote-reverse, +blockquote.pull-right { + padding-right: 15px; + padding-left: 0; + text-align: right; + border-right: 5px solid #eee; + border-left: 0; +} +.blockquote-reverse footer:before, +blockquote.pull-right footer:before, +.blockquote-reverse small:before, +blockquote.pull-right small:before, +.blockquote-reverse .small:before, +blockquote.pull-right .small:before { + content: ''; +} +.blockquote-reverse footer:after, +blockquote.pull-right footer:after, +.blockquote-reverse small:after, +blockquote.pull-right small:after, +.blockquote-reverse .small:after, +blockquote.pull-right .small:after { + content: '\00A0 \2014'; +} +address { + margin-bottom: 20px; + font-style: normal; + line-height: 1.42857143; +} +code, +kbd, +pre, +samp { + font-family: Menlo, Monaco, Consolas, "Courier New", monospace; +} +code { + padding: 2px 4px; + font-size: 90%; + color: #c7254e; + background-color: #f9f2f4; + border-radius: 4px; +} +kbd { + padding: 2px 4px; + font-size: 90%; + color: #fff; + background-color: #333; + border-radius: 3px; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .25); +} +kbd kbd { + padding: 0; + font-size: 100%; + font-weight: bold; + -webkit-box-shadow: none; + box-shadow: none; +} +pre { + display: block; + padding: 9.5px; + margin: 0 0 10px; + font-size: 13px; + line-height: 1.42857143; + color: #333; + word-break: break-all; + word-wrap: break-word; + background-color: #f5f5f5; + border: 1px solid #ccc; + border-radius: 4px; +} +pre code { + padding: 0; + font-size: inherit; + color: inherit; + white-space: pre-wrap; + background-color: transparent; + border-radius: 0; +} +.pre-scrollable { + max-height: 340px; + overflow-y: scroll; +} +.container { + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} +@media (min-width: 768px) { + .container { + width: 750px; + } +} +@media (min-width: 992px) { + .container { + width: 970px; + } +} +@media (min-width: 1200px) { + .container { + width: 1170px; + } +} +.container-fluid { + padding-right: 15px; + padding-left: 15px; + margin-right: auto; + margin-left: auto; +} +.row { + margin-right: -15px; + margin-left: -15px; +} +.col-xs-1, .col-sm-1, .col-md-1, .col-lg-1, .col-xs-2, .col-sm-2, .col-md-2, .col-lg-2, .col-xs-3, .col-sm-3, .col-md-3, .col-lg-3, .col-xs-4, .col-sm-4, .col-md-4, .col-lg-4, .col-xs-5, .col-sm-5, .col-md-5, .col-lg-5, .col-xs-6, .col-sm-6, .col-md-6, .col-lg-6, .col-xs-7, .col-sm-7, .col-md-7, .col-lg-7, .col-xs-8, .col-sm-8, .col-md-8, .col-lg-8, .col-xs-9, .col-sm-9, .col-md-9, .col-lg-9, .col-xs-10, .col-sm-10, .col-md-10, .col-lg-10, .col-xs-11, .col-sm-11, .col-md-11, .col-lg-11, .col-xs-12, .col-sm-12, .col-md-12, .col-lg-12 { + position: relative; + min-height: 1px; + padding-right: 15px; + padding-left: 15px; +} +.col-xs-1, .col-xs-2, .col-xs-3, .col-xs-4, .col-xs-5, .col-xs-6, .col-xs-7, .col-xs-8, .col-xs-9, .col-xs-10, .col-xs-11, .col-xs-12 { + float: left; +} +.col-xs-12 { + width: 100%; +} +.col-xs-11 { + width: 91.66666667%; +} +.col-xs-10 { + width: 83.33333333%; +} +.col-xs-9 { + width: 75%; +} +.col-xs-8 { + width: 66.66666667%; +} +.col-xs-7 { + width: 58.33333333%; +} +.col-xs-6 { + width: 50%; +} +.col-xs-5 { + width: 41.66666667%; +} +.col-xs-4 { + width: 33.33333333%; +} +.col-xs-3 { + width: 25%; +} +.col-xs-2 { + width: 16.66666667%; +} +.col-xs-1 { + width: 8.33333333%; +} +.col-xs-pull-12 { + right: 100%; +} +.col-xs-pull-11 { + right: 91.66666667%; +} +.col-xs-pull-10 { + right: 83.33333333%; +} +.col-xs-pull-9 { + right: 75%; +} +.col-xs-pull-8 { + right: 66.66666667%; +} +.col-xs-pull-7 { + right: 58.33333333%; +} +.col-xs-pull-6 { + right: 50%; +} +.col-xs-pull-5 { + right: 41.66666667%; +} +.col-xs-pull-4 { + right: 33.33333333%; +} +.col-xs-pull-3 { + right: 25%; +} +.col-xs-pull-2 { + right: 16.66666667%; +} +.col-xs-pull-1 { + right: 8.33333333%; +} +.col-xs-pull-0 { + right: auto; +} +.col-xs-push-12 { + left: 100%; +} +.col-xs-push-11 { + left: 91.66666667%; +} +.col-xs-push-10 { + left: 83.33333333%; +} +.col-xs-push-9 { + left: 75%; +} +.col-xs-push-8 { + left: 66.66666667%; +} +.col-xs-push-7 { + left: 58.33333333%; +} +.col-xs-push-6 { + left: 50%; +} +.col-xs-push-5 { + left: 41.66666667%; +} +.col-xs-push-4 { + left: 33.33333333%; +} +.col-xs-push-3 { + left: 25%; +} +.col-xs-push-2 { + left: 16.66666667%; +} +.col-xs-push-1 { + left: 8.33333333%; +} +.col-xs-push-0 { + left: auto; +} +.col-xs-offset-12 { + margin-left: 100%; +} +.col-xs-offset-11 { + margin-left: 91.66666667%; +} +.col-xs-offset-10 { + margin-left: 83.33333333%; +} +.col-xs-offset-9 { + margin-left: 75%; +} +.col-xs-offset-8 { + margin-left: 66.66666667%; +} +.col-xs-offset-7 { + margin-left: 58.33333333%; +} +.col-xs-offset-6 { + margin-left: 50%; +} +.col-xs-offset-5 { + margin-left: 41.66666667%; +} +.col-xs-offset-4 { + margin-left: 33.33333333%; +} +.col-xs-offset-3 { + margin-left: 25%; +} +.col-xs-offset-2 { + margin-left: 16.66666667%; +} +.col-xs-offset-1 { + margin-left: 8.33333333%; +} +.col-xs-offset-0 { + margin-left: 0; +} +@media (min-width: 768px) { + .col-sm-1, .col-sm-2, .col-sm-3, .col-sm-4, .col-sm-5, .col-sm-6, .col-sm-7, .col-sm-8, .col-sm-9, .col-sm-10, .col-sm-11, .col-sm-12 { + float: left; + } + .col-sm-12 { + width: 100%; + } + .col-sm-11 { + width: 91.66666667%; + } + .col-sm-10 { + width: 83.33333333%; + } + .col-sm-9 { + width: 75%; + } + .col-sm-8 { + width: 66.66666667%; + } + .col-sm-7 { + width: 58.33333333%; + } + .col-sm-6 { + width: 50%; + } + .col-sm-5 { + width: 41.66666667%; + } + .col-sm-4 { + width: 33.33333333%; + } + .col-sm-3 { + width: 25%; + } + .col-sm-2 { + width: 16.66666667%; + } + .col-sm-1 { + width: 8.33333333%; + } + .col-sm-pull-12 { + right: 100%; + } + .col-sm-pull-11 { + right: 91.66666667%; + } + .col-sm-pull-10 { + right: 83.33333333%; + } + .col-sm-pull-9 { + right: 75%; + } + .col-sm-pull-8 { + right: 66.66666667%; + } + .col-sm-pull-7 { + right: 58.33333333%; + } + .col-sm-pull-6 { + right: 50%; + } + .col-sm-pull-5 { + right: 41.66666667%; + } + .col-sm-pull-4 { + right: 33.33333333%; + } + .col-sm-pull-3 { + right: 25%; + } + .col-sm-pull-2 { + right: 16.66666667%; + } + .col-sm-pull-1 { + right: 8.33333333%; + } + .col-sm-pull-0 { + right: auto; + } + .col-sm-push-12 { + left: 100%; + } + .col-sm-push-11 { + left: 91.66666667%; + } + .col-sm-push-10 { + left: 83.33333333%; + } + .col-sm-push-9 { + left: 75%; + } + .col-sm-push-8 { + left: 66.66666667%; + } + .col-sm-push-7 { + left: 58.33333333%; + } + .col-sm-push-6 { + left: 50%; + } + .col-sm-push-5 { + left: 41.66666667%; + } + .col-sm-push-4 { + left: 33.33333333%; + } + .col-sm-push-3 { + left: 25%; + } + .col-sm-push-2 { + left: 16.66666667%; + } + .col-sm-push-1 { + left: 8.33333333%; + } + .col-sm-push-0 { + left: auto; + } + .col-sm-offset-12 { + margin-left: 100%; + } + .col-sm-offset-11 { + margin-left: 91.66666667%; + } + .col-sm-offset-10 { + margin-left: 83.33333333%; + } + .col-sm-offset-9 { + margin-left: 75%; + } + .col-sm-offset-8 { + margin-left: 66.66666667%; + } + .col-sm-offset-7 { + margin-left: 58.33333333%; + } + .col-sm-offset-6 { + margin-left: 50%; + } + .col-sm-offset-5 { + margin-left: 41.66666667%; + } + .col-sm-offset-4 { + margin-left: 33.33333333%; + } + .col-sm-offset-3 { + margin-left: 25%; + } + .col-sm-offset-2 { + margin-left: 16.66666667%; + } + .col-sm-offset-1 { + margin-left: 8.33333333%; + } + .col-sm-offset-0 { + margin-left: 0; + } +} +@media (min-width: 992px) { + .col-md-1, .col-md-2, .col-md-3, .col-md-4, .col-md-5, .col-md-6, .col-md-7, .col-md-8, .col-md-9, .col-md-10, .col-md-11, .col-md-12 { + float: left; + } + .col-md-12 { + width: 100%; + } + .col-md-11 { + width: 91.66666667%; + } + .col-md-10 { + width: 83.33333333%; + } + .col-md-9 { + width: 75%; + } + .col-md-8 { + width: 66.66666667%; + } + .col-md-7 { + width: 58.33333333%; + } + .col-md-6 { + width: 50%; + } + .col-md-5 { + width: 41.66666667%; + } + .col-md-4 { + width: 33.33333333%; + } + .col-md-3 { + width: 25%; + } + .col-md-2 { + width: 16.66666667%; + } + .col-md-1 { + width: 8.33333333%; + } + .col-md-pull-12 { + right: 100%; + } + .col-md-pull-11 { + right: 91.66666667%; + } + .col-md-pull-10 { + right: 83.33333333%; + } + .col-md-pull-9 { + right: 75%; + } + .col-md-pull-8 { + right: 66.66666667%; + } + .col-md-pull-7 { + right: 58.33333333%; + } + .col-md-pull-6 { + right: 50%; + } + .col-md-pull-5 { + right: 41.66666667%; + } + .col-md-pull-4 { + right: 33.33333333%; + } + .col-md-pull-3 { + right: 25%; + } + .col-md-pull-2 { + right: 16.66666667%; + } + .col-md-pull-1 { + right: 8.33333333%; + } + .col-md-pull-0 { + right: auto; + } + .col-md-push-12 { + left: 100%; + } + .col-md-push-11 { + left: 91.66666667%; + } + .col-md-push-10 { + left: 83.33333333%; + } + .col-md-push-9 { + left: 75%; + } + .col-md-push-8 { + left: 66.66666667%; + } + .col-md-push-7 { + left: 58.33333333%; + } + .col-md-push-6 { + left: 50%; + } + .col-md-push-5 { + left: 41.66666667%; + } + .col-md-push-4 { + left: 33.33333333%; + } + .col-md-push-3 { + left: 25%; + } + .col-md-push-2 { + left: 16.66666667%; + } + .col-md-push-1 { + left: 8.33333333%; + } + .col-md-push-0 { + left: auto; + } + .col-md-offset-12 { + margin-left: 100%; + } + .col-md-offset-11 { + margin-left: 91.66666667%; + } + .col-md-offset-10 { + margin-left: 83.33333333%; + } + .col-md-offset-9 { + margin-left: 75%; + } + .col-md-offset-8 { + margin-left: 66.66666667%; + } + .col-md-offset-7 { + margin-left: 58.33333333%; + } + .col-md-offset-6 { + margin-left: 50%; + } + .col-md-offset-5 { + margin-left: 41.66666667%; + } + .col-md-offset-4 { + margin-left: 33.33333333%; + } + .col-md-offset-3 { + margin-left: 25%; + } + .col-md-offset-2 { + margin-left: 16.66666667%; + } + .col-md-offset-1 { + margin-left: 8.33333333%; + } + .col-md-offset-0 { + margin-left: 0; + } +} +@media (min-width: 1200px) { + .col-lg-1, .col-lg-2, .col-lg-3, .col-lg-4, .col-lg-5, .col-lg-6, .col-lg-7, .col-lg-8, .col-lg-9, .col-lg-10, .col-lg-11, .col-lg-12 { + float: left; + } + .col-lg-12 { + width: 100%; + } + .col-lg-11 { + width: 91.66666667%; + } + .col-lg-10 { + width: 83.33333333%; + } + .col-lg-9 { + width: 75%; + } + .col-lg-8 { + width: 66.66666667%; + } + .col-lg-7 { + width: 58.33333333%; + } + .col-lg-6 { + width: 50%; + } + .col-lg-5 { + width: 41.66666667%; + } + .col-lg-4 { + width: 33.33333333%; + } + .col-lg-3 { + width: 25%; + } + .col-lg-2 { + width: 16.66666667%; + } + .col-lg-1 { + width: 8.33333333%; + } + .col-lg-pull-12 { + right: 100%; + } + .col-lg-pull-11 { + right: 91.66666667%; + } + .col-lg-pull-10 { + right: 83.33333333%; + } + .col-lg-pull-9 { + right: 75%; + } + .col-lg-pull-8 { + right: 66.66666667%; + } + .col-lg-pull-7 { + right: 58.33333333%; + } + .col-lg-pull-6 { + right: 50%; + } + .col-lg-pull-5 { + right: 41.66666667%; + } + .col-lg-pull-4 { + right: 33.33333333%; + } + .col-lg-pull-3 { + right: 25%; + } + .col-lg-pull-2 { + right: 16.66666667%; + } + .col-lg-pull-1 { + right: 8.33333333%; + } + .col-lg-pull-0 { + right: auto; + } + .col-lg-push-12 { + left: 100%; + } + .col-lg-push-11 { + left: 91.66666667%; + } + .col-lg-push-10 { + left: 83.33333333%; + } + .col-lg-push-9 { + left: 75%; + } + .col-lg-push-8 { + left: 66.66666667%; + } + .col-lg-push-7 { + left: 58.33333333%; + } + .col-lg-push-6 { + left: 50%; + } + .col-lg-push-5 { + left: 41.66666667%; + } + .col-lg-push-4 { + left: 33.33333333%; + } + .col-lg-push-3 { + left: 25%; + } + .col-lg-push-2 { + left: 16.66666667%; + } + .col-lg-push-1 { + left: 8.33333333%; + } + .col-lg-push-0 { + left: auto; + } + .col-lg-offset-12 { + margin-left: 100%; + } + .col-lg-offset-11 { + margin-left: 91.66666667%; + } + .col-lg-offset-10 { + margin-left: 83.33333333%; + } + .col-lg-offset-9 { + margin-left: 75%; + } + .col-lg-offset-8 { + margin-left: 66.66666667%; + } + .col-lg-offset-7 { + margin-left: 58.33333333%; + } + .col-lg-offset-6 { + margin-left: 50%; + } + .col-lg-offset-5 { + margin-left: 41.66666667%; + } + .col-lg-offset-4 { + margin-left: 33.33333333%; + } + .col-lg-offset-3 { + margin-left: 25%; + } + .col-lg-offset-2 { + margin-left: 16.66666667%; + } + .col-lg-offset-1 { + margin-left: 8.33333333%; + } + .col-lg-offset-0 { + margin-left: 0; + } +} +table { + background-color: transparent; +} +caption { + padding-top: 8px; + padding-bottom: 8px; + color: #777; + text-align: left; +} +th { + text-align: left; +} +.table { + width: 100%; + max-width: 100%; + margin-bottom: 20px; +} +.table > thead > tr > th, +.table > tbody > tr > th, +.table > tfoot > tr > th, +.table > thead > tr > td, +.table > tbody > tr > td, +.table > tfoot > tr > td { + padding: 8px; + line-height: 1.42857143; + vertical-align: top; + border-top: 1px solid #ddd; +} +.table > thead > tr > th { + vertical-align: bottom; + border-bottom: 2px solid #ddd; +} +.table > caption + thead > tr:first-child > th, +.table > colgroup + thead > tr:first-child > th, +.table > thead:first-child > tr:first-child > th, +.table > caption + thead > tr:first-child > td, +.table > colgroup + thead > tr:first-child > td, +.table > thead:first-child > tr:first-child > td { + border-top: 0; +} +.table > tbody + tbody { + border-top: 2px solid #ddd; +} +.table .table { + background-color: #fff; +} +.table-condensed > thead > tr > th, +.table-condensed > tbody > tr > th, +.table-condensed > tfoot > tr > th, +.table-condensed > thead > tr > td, +.table-condensed > tbody > tr > td, +.table-condensed > tfoot > tr > td { + padding: 5px; +} +.table-bordered { + border: 1px solid #ddd; +} +.table-bordered > thead > tr > th, +.table-bordered > tbody > tr > th, +.table-bordered > tfoot > tr > th, +.table-bordered > thead > tr > td, +.table-bordered > tbody > tr > td, +.table-bordered > tfoot > tr > td { + border: 1px solid #ddd; +} +.table-bordered > thead > tr > th, +.table-bordered > thead > tr > td { + border-bottom-width: 2px; +} +.table-striped > tbody > tr:nth-of-type(odd) { + background-color: #f9f9f9; +} +.table-hover > tbody > tr:hover { + background-color: #f5f5f5; +} +table col[class*="col-"] { + position: static; + display: table-column; + float: none; +} +table td[class*="col-"], +table th[class*="col-"] { + position: static; + display: table-cell; + float: none; +} +.table > thead > tr > td.active, +.table > tbody > tr > td.active, +.table > tfoot > tr > td.active, +.table > thead > tr > th.active, +.table > tbody > tr > th.active, +.table > tfoot > tr > th.active, +.table > thead > tr.active > td, +.table > tbody > tr.active > td, +.table > tfoot > tr.active > td, +.table > thead > tr.active > th, +.table > tbody > tr.active > th, +.table > tfoot > tr.active > th { + background-color: #f5f5f5; +} +.table-hover > tbody > tr > td.active:hover, +.table-hover > tbody > tr > th.active:hover, +.table-hover > tbody > tr.active:hover > td, +.table-hover > tbody > tr:hover > .active, +.table-hover > tbody > tr.active:hover > th { + background-color: #e8e8e8; +} +.table > thead > tr > td.success, +.table > tbody > tr > td.success, +.table > tfoot > tr > td.success, +.table > thead > tr > th.success, +.table > tbody > tr > th.success, +.table > tfoot > tr > th.success, +.table > thead > tr.success > td, +.table > tbody > tr.success > td, +.table > tfoot > tr.success > td, +.table > thead > tr.success > th, +.table > tbody > tr.success > th, +.table > tfoot > tr.success > th { + background-color: #dff0d8; +} +.table-hover > tbody > tr > td.success:hover, +.table-hover > tbody > tr > th.success:hover, +.table-hover > tbody > tr.success:hover > td, +.table-hover > tbody > tr:hover > .success, +.table-hover > tbody > tr.success:hover > th { + background-color: #d0e9c6; +} +.table > thead > tr > td.info, +.table > tbody > tr > td.info, +.table > tfoot > tr > td.info, +.table > thead > tr > th.info, +.table > tbody > tr > th.info, +.table > tfoot > tr > th.info, +.table > thead > tr.info > td, +.table > tbody > tr.info > td, +.table > tfoot > tr.info > td, +.table > thead > tr.info > th, +.table > tbody > tr.info > th, +.table > tfoot > tr.info > th { + background-color: #d9edf7; +} +.table-hover > tbody > tr > td.info:hover, +.table-hover > tbody > tr > th.info:hover, +.table-hover > tbody > tr.info:hover > td, +.table-hover > tbody > tr:hover > .info, +.table-hover > tbody > tr.info:hover > th { + background-color: #c4e3f3; +} +.table > thead > tr > td.warning, +.table > tbody > tr > td.warning, +.table > tfoot > tr > td.warning, +.table > thead > tr > th.warning, +.table > tbody > tr > th.warning, +.table > tfoot > tr > th.warning, +.table > thead > tr.warning > td, +.table > tbody > tr.warning > td, +.table > tfoot > tr.warning > td, +.table > thead > tr.warning > th, +.table > tbody > tr.warning > th, +.table > tfoot > tr.warning > th { + background-color: #fcf8e3; +} +.table-hover > tbody > tr > td.warning:hover, +.table-hover > tbody > tr > th.warning:hover, +.table-hover > tbody > tr.warning:hover > td, +.table-hover > tbody > tr:hover > .warning, +.table-hover > tbody > tr.warning:hover > th { + background-color: #faf2cc; +} +.table > thead > tr > td.danger, +.table > tbody > tr > td.danger, +.table > tfoot > tr > td.danger, +.table > thead > tr > th.danger, +.table > tbody > tr > th.danger, +.table > tfoot > tr > th.danger, +.table > thead > tr.danger > td, +.table > tbody > tr.danger > td, +.table > tfoot > tr.danger > td, +.table > thead > tr.danger > th, +.table > tbody > tr.danger > th, +.table > tfoot > tr.danger > th { + background-color: #f2dede; +} +.table-hover > tbody > tr > td.danger:hover, +.table-hover > tbody > tr > th.danger:hover, +.table-hover > tbody > tr.danger:hover > td, +.table-hover > tbody > tr:hover > .danger, +.table-hover > tbody > tr.danger:hover > th { + background-color: #ebcccc; +} +.table-responsive { + min-height: .01%; + overflow-x: auto; +} +@media screen and (max-width: 767px) { + .table-responsive { + width: 100%; + margin-bottom: 15px; + overflow-y: hidden; + -ms-overflow-style: -ms-autohiding-scrollbar; + border: 1px solid #ddd; + } + .table-responsive > .table { + margin-bottom: 0; + } + .table-responsive > .table > thead > tr > th, + .table-responsive > .table > tbody > tr > th, + .table-responsive > .table > tfoot > tr > th, + .table-responsive > .table > thead > tr > td, + .table-responsive > .table > tbody > tr > td, + .table-responsive > .table > tfoot > tr > td { + white-space: nowrap; + } + .table-responsive > .table-bordered { + border: 0; + } + .table-responsive > .table-bordered > thead > tr > th:first-child, + .table-responsive > .table-bordered > tbody > tr > th:first-child, + .table-responsive > .table-bordered > tfoot > tr > th:first-child, + .table-responsive > .table-bordered > thead > tr > td:first-child, + .table-responsive > .table-bordered > tbody > tr > td:first-child, + .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; + } + .table-responsive > .table-bordered > thead > tr > th:last-child, + .table-responsive > .table-bordered > tbody > tr > th:last-child, + .table-responsive > .table-bordered > tfoot > tr > th:last-child, + .table-responsive > .table-bordered > thead > tr > td:last-child, + .table-responsive > .table-bordered > tbody > tr > td:last-child, + .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; + } + .table-responsive > .table-bordered > tbody > tr:last-child > th, + .table-responsive > .table-bordered > tfoot > tr:last-child > th, + .table-responsive > .table-bordered > tbody > tr:last-child > td, + .table-responsive > .table-bordered > tfoot > tr:last-child > td { + border-bottom: 0; + } +} +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} +legend { + display: block; + width: 100%; + padding: 0; + margin-bottom: 20px; + font-size: 21px; + line-height: inherit; + color: #333; + border: 0; + border-bottom: 1px solid #e5e5e5; +} +label { + display: inline-block; + max-width: 100%; + margin-bottom: 5px; + font-weight: bold; +} +input[type="search"] { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +input[type="radio"], +input[type="checkbox"] { + margin: 4px 0 0; + margin-top: 1px \9; + line-height: normal; +} +input[type="file"] { + display: block; +} +input[type="range"] { + display: block; + width: 100%; +} +select[multiple], +select[size] { + height: auto; +} +input[type="file"]:focus, +input[type="radio"]:focus, +input[type="checkbox"]:focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +output { + display: block; + padding-top: 7px; + font-size: 14px; + line-height: 1.42857143; + color: #555; +} +.form-control { + display: block; + width: 100%; + height: 34px; + padding: 6px 12px; + font-size: 14px; + line-height: 1.42857143; + color: #555; + background-color: #fff; + background-image: none; + border: 1px solid #ccc; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + -webkit-transition: border-color ease-in-out .15s, -webkit-box-shadow ease-in-out .15s; + -o-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; + transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s; +} +.form-control:focus { + border-color: #66afe9; + outline: 0; + -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6); + box-shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px rgba(102, 175, 233, .6); +} +.form-control::-moz-placeholder { + color: #999; + opacity: 1; +} +.form-control:-ms-input-placeholder { + color: #999; +} +.form-control::-webkit-input-placeholder { + color: #999; +} +.form-control[disabled], +.form-control[readonly], +fieldset[disabled] .form-control { + cursor: not-allowed; + background-color: #eee; + opacity: 1; +} +textarea.form-control { + height: auto; +} +input[type="search"] { + -webkit-appearance: none; +} +@media screen and (-webkit-min-device-pixel-ratio: 0) { + input[type="date"], + input[type="time"], + input[type="datetime-local"], + input[type="month"] { + line-height: 34px; + } + input[type="date"].input-sm, + input[type="time"].input-sm, + input[type="datetime-local"].input-sm, + input[type="month"].input-sm, + .input-group-sm input[type="date"], + .input-group-sm input[type="time"], + .input-group-sm input[type="datetime-local"], + .input-group-sm input[type="month"] { + line-height: 30px; + } + input[type="date"].input-lg, + input[type="time"].input-lg, + input[type="datetime-local"].input-lg, + input[type="month"].input-lg, + .input-group-lg input[type="date"], + .input-group-lg input[type="time"], + .input-group-lg input[type="datetime-local"], + .input-group-lg input[type="month"] { + line-height: 46px; + } +} +.form-group { + margin-bottom: 15px; +} +.radio, +.checkbox { + position: relative; + display: block; + margin-top: 10px; + margin-bottom: 10px; +} +.radio label, +.checkbox label { + min-height: 20px; + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + cursor: pointer; +} +.radio input[type="radio"], +.radio-inline input[type="radio"], +.checkbox input[type="checkbox"], +.checkbox-inline input[type="checkbox"] { + position: absolute; + margin-top: 4px \9; + margin-left: -20px; +} +.radio + .radio, +.checkbox + .checkbox { + margin-top: -5px; +} +.radio-inline, +.checkbox-inline { + display: inline-block; + padding-left: 20px; + margin-bottom: 0; + font-weight: normal; + vertical-align: middle; + cursor: pointer; +} +.radio-inline + .radio-inline, +.checkbox-inline + .checkbox-inline { + margin-top: 0; + margin-left: 10px; +} +input[type="radio"][disabled], +input[type="checkbox"][disabled], +input[type="radio"].disabled, +input[type="checkbox"].disabled, +fieldset[disabled] input[type="radio"], +fieldset[disabled] input[type="checkbox"] { + cursor: not-allowed; +} +.radio-inline.disabled, +.checkbox-inline.disabled, +fieldset[disabled] .radio-inline, +fieldset[disabled] .checkbox-inline { + cursor: not-allowed; +} +.radio.disabled label, +.checkbox.disabled label, +fieldset[disabled] .radio label, +fieldset[disabled] .checkbox label { + cursor: not-allowed; +} +.form-control-static { + padding-top: 7px; + padding-bottom: 7px; + margin-bottom: 0; +} +.form-control-static.input-lg, +.form-control-static.input-sm { + padding-right: 0; + padding-left: 0; +} +.input-sm { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-sm { + height: 30px; + line-height: 30px; +} +textarea.input-sm, +select[multiple].input-sm { + height: auto; +} +.form-group-sm .form-control { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.form-group-sm .form-control { + height: 30px; + line-height: 30px; +} +textarea.form-group-sm .form-control, +select[multiple].form-group-sm .form-control { + height: auto; +} +.form-group-sm .form-control-static { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; +} +.input-lg { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.input-lg { + height: 46px; + line-height: 46px; +} +textarea.input-lg, +select[multiple].input-lg { + height: auto; +} +.form-group-lg .form-control { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.form-group-lg .form-control { + height: 46px; + line-height: 46px; +} +textarea.form-group-lg .form-control, +select[multiple].form-group-lg .form-control { + height: auto; +} +.form-group-lg .form-control-static { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; +} +.has-feedback { + position: relative; +} +.has-feedback .form-control { + padding-right: 42.5px; +} +.form-control-feedback { + position: absolute; + top: 0; + right: 0; + z-index: 2; + display: block; + width: 34px; + height: 34px; + line-height: 34px; + text-align: center; + pointer-events: none; +} +.input-lg + .form-control-feedback { + width: 46px; + height: 46px; + line-height: 46px; +} +.input-sm + .form-control-feedback { + width: 30px; + height: 30px; + line-height: 30px; +} +.has-success .help-block, +.has-success .control-label, +.has-success .radio, +.has-success .checkbox, +.has-success .radio-inline, +.has-success .checkbox-inline, +.has-success.radio label, +.has-success.checkbox label, +.has-success.radio-inline label, +.has-success.checkbox-inline label { + color: #3c763d; +} +.has-success .form-control { + border-color: #3c763d; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-success .form-control:focus { + border-color: #2b542c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #67b168; +} +.has-success .input-group-addon { + color: #3c763d; + background-color: #dff0d8; + border-color: #3c763d; +} +.has-success .form-control-feedback { + color: #3c763d; +} +.has-warning .help-block, +.has-warning .control-label, +.has-warning .radio, +.has-warning .checkbox, +.has-warning .radio-inline, +.has-warning .checkbox-inline, +.has-warning.radio label, +.has-warning.checkbox label, +.has-warning.radio-inline label, +.has-warning.checkbox-inline label { + color: #8a6d3b; +} +.has-warning .form-control { + border-color: #8a6d3b; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-warning .form-control:focus { + border-color: #66512c; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #c0a16b; +} +.has-warning .input-group-addon { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #8a6d3b; +} +.has-warning .form-control-feedback { + color: #8a6d3b; +} +.has-error .help-block, +.has-error .control-label, +.has-error .radio, +.has-error .checkbox, +.has-error .radio-inline, +.has-error .checkbox-inline, +.has-error.radio label, +.has-error.checkbox label, +.has-error.radio-inline label, +.has-error.checkbox-inline label { + color: #a94442; +} +.has-error .form-control { + border-color: #a94442; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); +} +.has-error .form-control:focus { + border-color: #843534; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075), 0 0 6px #ce8483; +} +.has-error .input-group-addon { + color: #a94442; + background-color: #f2dede; + border-color: #a94442; +} +.has-error .form-control-feedback { + color: #a94442; +} +.has-feedback label ~ .form-control-feedback { + top: 25px; +} +.has-feedback label.sr-only ~ .form-control-feedback { + top: 0; +} +.help-block { + display: block; + margin-top: 5px; + margin-bottom: 10px; + color: #737373; +} +@media (min-width: 768px) { + .form-inline .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .form-inline .form-control-static { + display: inline-block; + } + .form-inline .input-group { + display: inline-table; + vertical-align: middle; + } + .form-inline .input-group .input-group-addon, + .form-inline .input-group .input-group-btn, + .form-inline .input-group .form-control { + width: auto; + } + .form-inline .input-group > .form-control { + width: 100%; + } + .form-inline .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio, + .form-inline .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .form-inline .radio label, + .form-inline .checkbox label { + padding-left: 0; + } + .form-inline .radio input[type="radio"], + .form-inline .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .form-inline .has-feedback .form-control-feedback { + top: 0; + } +} +.form-horizontal .radio, +.form-horizontal .checkbox, +.form-horizontal .radio-inline, +.form-horizontal .checkbox-inline { + padding-top: 7px; + margin-top: 0; + margin-bottom: 0; +} +.form-horizontal .radio, +.form-horizontal .checkbox { + min-height: 27px; +} +.form-horizontal .form-group { + margin-right: -15px; + margin-left: -15px; +} +@media (min-width: 768px) { + .form-horizontal .control-label { + padding-top: 7px; + margin-bottom: 0; + text-align: right; + } +} +.form-horizontal .has-feedback .form-control-feedback { + right: 15px; +} +@media (min-width: 768px) { + .form-horizontal .form-group-lg .control-label { + padding-top: 14.333333px; + } +} +@media (min-width: 768px) { + .form-horizontal .form-group-sm .control-label { + padding-top: 6px; + } +} +.btn { + display: inline-block; + padding: 6px 12px; + margin-bottom: 0; + font-size: 14px; + font-weight: normal; + line-height: 1.42857143; + text-align: center; + white-space: nowrap; + vertical-align: middle; + -ms-touch-action: manipulation; + touch-action: manipulation; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.btn:focus, +.btn:active:focus, +.btn.active:focus, +.btn.focus, +.btn:active.focus, +.btn.active.focus { + outline: thin dotted; + outline: 5px auto -webkit-focus-ring-color; + outline-offset: -2px; +} +.btn:hover, +.btn:focus, +.btn.focus { + color: #333; + text-decoration: none; +} +.btn:active, +.btn.active { + background-image: none; + outline: 0; + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn.disabled, +.btn[disabled], +fieldset[disabled] .btn { + pointer-events: none; + cursor: not-allowed; + filter: alpha(opacity=65); + -webkit-box-shadow: none; + box-shadow: none; + opacity: .65; +} +.btn-default { + color: #333; + background-color: #fff; + border-color: #ccc; +} +.btn-default:hover, +.btn-default:focus, +.btn-default.focus, +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + color: #333; + background-color: #e6e6e6; + border-color: #adadad; +} +.btn-default:active, +.btn-default.active, +.open > .dropdown-toggle.btn-default { + background-image: none; +} +.btn-default.disabled, +.btn-default[disabled], +fieldset[disabled] .btn-default, +.btn-default.disabled:hover, +.btn-default[disabled]:hover, +fieldset[disabled] .btn-default:hover, +.btn-default.disabled:focus, +.btn-default[disabled]:focus, +fieldset[disabled] .btn-default:focus, +.btn-default.disabled.focus, +.btn-default[disabled].focus, +fieldset[disabled] .btn-default.focus, +.btn-default.disabled:active, +.btn-default[disabled]:active, +fieldset[disabled] .btn-default:active, +.btn-default.disabled.active, +.btn-default[disabled].active, +fieldset[disabled] .btn-default.active { + background-color: #fff; + border-color: #ccc; +} +.btn-default .badge { + color: #fff; + background-color: #333; +} +.btn-primary { + color: #fff; + background-color: #337ab7; + border-color: #2e6da4; +} +.btn-primary:hover, +.btn-primary:focus, +.btn-primary.focus, +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + color: #fff; + background-color: #286090; + border-color: #204d74; +} +.btn-primary:active, +.btn-primary.active, +.open > .dropdown-toggle.btn-primary { + background-image: none; +} +.btn-primary.disabled, +.btn-primary[disabled], +fieldset[disabled] .btn-primary, +.btn-primary.disabled:hover, +.btn-primary[disabled]:hover, +fieldset[disabled] .btn-primary:hover, +.btn-primary.disabled:focus, +.btn-primary[disabled]:focus, +fieldset[disabled] .btn-primary:focus, +.btn-primary.disabled.focus, +.btn-primary[disabled].focus, +fieldset[disabled] .btn-primary.focus, +.btn-primary.disabled:active, +.btn-primary[disabled]:active, +fieldset[disabled] .btn-primary:active, +.btn-primary.disabled.active, +.btn-primary[disabled].active, +fieldset[disabled] .btn-primary.active { + background-color: #337ab7; + border-color: #2e6da4; +} +.btn-primary .badge { + color: #337ab7; + background-color: #fff; +} +.btn-success { + color: #fff; + background-color: #5cb85c; + border-color: #4cae4c; +} +.btn-success:hover, +.btn-success:focus, +.btn-success.focus, +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + color: #fff; + background-color: #449d44; + border-color: #398439; +} +.btn-success:active, +.btn-success.active, +.open > .dropdown-toggle.btn-success { + background-image: none; +} +.btn-success.disabled, +.btn-success[disabled], +fieldset[disabled] .btn-success, +.btn-success.disabled:hover, +.btn-success[disabled]:hover, +fieldset[disabled] .btn-success:hover, +.btn-success.disabled:focus, +.btn-success[disabled]:focus, +fieldset[disabled] .btn-success:focus, +.btn-success.disabled.focus, +.btn-success[disabled].focus, +fieldset[disabled] .btn-success.focus, +.btn-success.disabled:active, +.btn-success[disabled]:active, +fieldset[disabled] .btn-success:active, +.btn-success.disabled.active, +.btn-success[disabled].active, +fieldset[disabled] .btn-success.active { + background-color: #5cb85c; + border-color: #4cae4c; +} +.btn-success .badge { + color: #5cb85c; + background-color: #fff; +} +.btn-info { + color: #fff; + background-color: #5bc0de; + border-color: #46b8da; +} +.btn-info:hover, +.btn-info:focus, +.btn-info.focus, +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + color: #fff; + background-color: #31b0d5; + border-color: #269abc; +} +.btn-info:active, +.btn-info.active, +.open > .dropdown-toggle.btn-info { + background-image: none; +} +.btn-info.disabled, +.btn-info[disabled], +fieldset[disabled] .btn-info, +.btn-info.disabled:hover, +.btn-info[disabled]:hover, +fieldset[disabled] .btn-info:hover, +.btn-info.disabled:focus, +.btn-info[disabled]:focus, +fieldset[disabled] .btn-info:focus, +.btn-info.disabled.focus, +.btn-info[disabled].focus, +fieldset[disabled] .btn-info.focus, +.btn-info.disabled:active, +.btn-info[disabled]:active, +fieldset[disabled] .btn-info:active, +.btn-info.disabled.active, +.btn-info[disabled].active, +fieldset[disabled] .btn-info.active { + background-color: #5bc0de; + border-color: #46b8da; +} +.btn-info .badge { + color: #5bc0de; + background-color: #fff; +} +.btn-warning { + color: #fff; + background-color: #f0ad4e; + border-color: #eea236; +} +.btn-warning:hover, +.btn-warning:focus, +.btn-warning.focus, +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + color: #fff; + background-color: #ec971f; + border-color: #d58512; +} +.btn-warning:active, +.btn-warning.active, +.open > .dropdown-toggle.btn-warning { + background-image: none; +} +.btn-warning.disabled, +.btn-warning[disabled], +fieldset[disabled] .btn-warning, +.btn-warning.disabled:hover, +.btn-warning[disabled]:hover, +fieldset[disabled] .btn-warning:hover, +.btn-warning.disabled:focus, +.btn-warning[disabled]:focus, +fieldset[disabled] .btn-warning:focus, +.btn-warning.disabled.focus, +.btn-warning[disabled].focus, +fieldset[disabled] .btn-warning.focus, +.btn-warning.disabled:active, +.btn-warning[disabled]:active, +fieldset[disabled] .btn-warning:active, +.btn-warning.disabled.active, +.btn-warning[disabled].active, +fieldset[disabled] .btn-warning.active { + background-color: #f0ad4e; + border-color: #eea236; +} +.btn-warning .badge { + color: #f0ad4e; + background-color: #fff; +} +.btn-danger { + color: #fff; + background-color: #d9534f; + border-color: #d43f3a; +} +.btn-danger:hover, +.btn-danger:focus, +.btn-danger.focus, +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + color: #fff; + background-color: #c9302c; + border-color: #ac2925; +} +.btn-danger:active, +.btn-danger.active, +.open > .dropdown-toggle.btn-danger { + background-image: none; +} +.btn-danger.disabled, +.btn-danger[disabled], +fieldset[disabled] .btn-danger, +.btn-danger.disabled:hover, +.btn-danger[disabled]:hover, +fieldset[disabled] .btn-danger:hover, +.btn-danger.disabled:focus, +.btn-danger[disabled]:focus, +fieldset[disabled] .btn-danger:focus, +.btn-danger.disabled.focus, +.btn-danger[disabled].focus, +fieldset[disabled] .btn-danger.focus, +.btn-danger.disabled:active, +.btn-danger[disabled]:active, +fieldset[disabled] .btn-danger:active, +.btn-danger.disabled.active, +.btn-danger[disabled].active, +fieldset[disabled] .btn-danger.active { + background-color: #d9534f; + border-color: #d43f3a; +} +.btn-danger .badge { + color: #d9534f; + background-color: #fff; +} +.btn-link { + font-weight: normal; + color: #337ab7; + border-radius: 0; +} +.btn-link, +.btn-link:active, +.btn-link.active, +.btn-link[disabled], +fieldset[disabled] .btn-link { + background-color: transparent; + -webkit-box-shadow: none; + box-shadow: none; +} +.btn-link, +.btn-link:hover, +.btn-link:focus, +.btn-link:active { + border-color: transparent; +} +.btn-link:hover, +.btn-link:focus { + color: #23527c; + text-decoration: underline; + background-color: transparent; +} +.btn-link[disabled]:hover, +fieldset[disabled] .btn-link:hover, +.btn-link[disabled]:focus, +fieldset[disabled] .btn-link:focus { + color: #777; + text-decoration: none; +} +.btn-lg, +.btn-group-lg > .btn { + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +.btn-sm, +.btn-group-sm > .btn { + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-xs, +.btn-group-xs > .btn { + padding: 1px 5px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +.btn-block { + display: block; + width: 100%; +} +.btn-block + .btn-block { + margin-top: 5px; +} +input[type="submit"].btn-block, +input[type="reset"].btn-block, +input[type="button"].btn-block { + width: 100%; +} +.fade { + opacity: 0; + -webkit-transition: opacity .15s linear; + -o-transition: opacity .15s linear; + transition: opacity .15s linear; +} +.fade.in { + opacity: 1; +} +.collapse { + display: none; + visibility: hidden; +} +.collapse.in { + display: block; + visibility: visible; +} +tr.collapse.in { + display: table-row; +} +tbody.collapse.in { + display: table-row-group; +} +.collapsing { + position: relative; + height: 0; + overflow: hidden; + -webkit-transition-timing-function: ease; + -o-transition-timing-function: ease; + transition-timing-function: ease; + -webkit-transition-duration: .35s; + -o-transition-duration: .35s; + transition-duration: .35s; + -webkit-transition-property: height, visibility; + -o-transition-property: height, visibility; + transition-property: height, visibility; +} +.caret { + display: inline-block; + width: 0; + height: 0; + margin-left: 2px; + vertical-align: middle; + border-top: 4px solid; + border-right: 4px solid transparent; + border-left: 4px solid transparent; +} +.dropup, +.dropdown { + position: relative; +} +.dropdown-toggle:focus { + outline: 0; +} +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + display: none; + float: left; + min-width: 160px; + padding: 5px 0; + margin: 2px 0 0; + font-size: 14px; + text-align: left; + list-style: none; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .15); + border-radius: 4px; + -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, .175); + box-shadow: 0 6px 12px rgba(0, 0, 0, .175); +} +.dropdown-menu.pull-right { + right: 0; + left: auto; +} +.dropdown-menu .divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.dropdown-menu > li > a { + display: block; + padding: 3px 20px; + clear: both; + font-weight: normal; + line-height: 1.42857143; + color: #333; + white-space: nowrap; +} +.dropdown-menu > li > a:hover, +.dropdown-menu > li > a:focus { + color: #262626; + text-decoration: none; + background-color: #f5f5f5; +} +.dropdown-menu > .active > a, +.dropdown-menu > .active > a:hover, +.dropdown-menu > .active > a:focus { + color: #fff; + text-decoration: none; + background-color: #337ab7; + outline: 0; +} +.dropdown-menu > .disabled > a, +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + color: #777; +} +.dropdown-menu > .disabled > a:hover, +.dropdown-menu > .disabled > a:focus { + text-decoration: none; + cursor: not-allowed; + background-color: transparent; + background-image: none; + filter: progid:DXImageTransform.Microsoft.gradient(enabled = false); +} +.open > .dropdown-menu { + display: block; +} +.open > a { + outline: 0; +} +.dropdown-menu-right { + right: 0; + left: auto; +} +.dropdown-menu-left { + right: auto; + left: 0; +} +.dropdown-header { + display: block; + padding: 3px 20px; + font-size: 12px; + line-height: 1.42857143; + color: #777; + white-space: nowrap; +} +.dropdown-backdrop { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 990; +} +.pull-right > .dropdown-menu { + right: 0; + left: auto; +} +.dropup .caret, +.navbar-fixed-bottom .dropdown .caret { + content: ""; + border-top: 0; + border-bottom: 4px solid; +} +.dropup .dropdown-menu, +.navbar-fixed-bottom .dropdown .dropdown-menu { + top: auto; + bottom: 100%; + margin-bottom: 2px; +} +@media (min-width: 768px) { + .navbar-right .dropdown-menu { + right: 0; + left: auto; + } + .navbar-right .dropdown-menu-left { + right: auto; + left: 0; + } +} +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-block; + vertical-align: middle; +} +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + float: left; +} +.btn-group > .btn:hover, +.btn-group-vertical > .btn:hover, +.btn-group > .btn:focus, +.btn-group-vertical > .btn:focus, +.btn-group > .btn:active, +.btn-group-vertical > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn.active { + z-index: 2; +} +.btn-group .btn + .btn, +.btn-group .btn + .btn-group, +.btn-group .btn-group + .btn, +.btn-group .btn-group + .btn-group { + margin-left: -1px; +} +.btn-toolbar { + margin-left: -5px; +} +.btn-toolbar .btn-group, +.btn-toolbar .input-group { + float: left; +} +.btn-toolbar > .btn, +.btn-toolbar > .btn-group, +.btn-toolbar > .input-group { + margin-left: 5px; +} +.btn-group > .btn:not(:first-child):not(:last-child):not(.dropdown-toggle) { + border-radius: 0; +} +.btn-group > .btn:first-child { + margin-left: 0; +} +.btn-group > .btn:first-child:not(:last-child):not(.dropdown-toggle) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group > .btn:last-child:not(:first-child), +.btn-group > .dropdown-toggle:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group > .btn-group { + float: left; +} +.btn-group > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group .dropdown-toggle:active, +.btn-group.open .dropdown-toggle { + outline: 0; +} +.btn-group > .btn + .dropdown-toggle { + padding-right: 8px; + padding-left: 8px; +} +.btn-group > .btn-lg + .dropdown-toggle { + padding-right: 12px; + padding-left: 12px; +} +.btn-group.open .dropdown-toggle { + -webkit-box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); + box-shadow: inset 0 3px 5px rgba(0, 0, 0, .125); +} +.btn-group.open .dropdown-toggle.btn-link { + -webkit-box-shadow: none; + box-shadow: none; +} +.btn .caret { + margin-left: 0; +} +.btn-lg .caret { + border-width: 5px 5px 0; + border-bottom-width: 0; +} +.dropup .btn-lg .caret { + border-width: 0 5px 5px; +} +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group, +.btn-group-vertical > .btn-group > .btn { + display: block; + float: none; + width: 100%; + max-width: 100%; +} +.btn-group-vertical > .btn-group > .btn { + float: none; +} +.btn-group-vertical > .btn + .btn, +.btn-group-vertical > .btn + .btn-group, +.btn-group-vertical > .btn-group + .btn, +.btn-group-vertical > .btn-group + .btn-group { + margin-top: -1px; + margin-left: 0; +} +.btn-group-vertical > .btn:not(:first-child):not(:last-child) { + border-radius: 0; +} +.btn-group-vertical > .btn:first-child:not(:last-child) { + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn:last-child:not(:first-child) { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-left-radius: 4px; +} +.btn-group-vertical > .btn-group:not(:first-child):not(:last-child) > .btn { + border-radius: 0; +} +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .btn:last-child, +.btn-group-vertical > .btn-group:first-child:not(:last-child) > .dropdown-toggle { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn-group:last-child:not(:first-child) > .btn:first-child { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.btn-group-justified { + display: table; + width: 100%; + table-layout: fixed; + border-collapse: separate; +} +.btn-group-justified > .btn, +.btn-group-justified > .btn-group { + display: table-cell; + float: none; + width: 1%; +} +.btn-group-justified > .btn-group .btn { + width: 100%; +} +.btn-group-justified > .btn-group .dropdown-menu { + left: auto; +} +[data-toggle="buttons"] > .btn input[type="radio"], +[data-toggle="buttons"] > .btn-group > .btn input[type="radio"], +[data-toggle="buttons"] > .btn input[type="checkbox"], +[data-toggle="buttons"] > .btn-group > .btn input[type="checkbox"] { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} +.input-group { + position: relative; + display: table; + border-collapse: separate; +} +.input-group[class*="col-"] { + float: none; + padding-right: 0; + padding-left: 0; +} +.input-group .form-control { + position: relative; + z-index: 2; + float: left; + width: 100%; + margin-bottom: 0; +} +.input-group-lg > .form-control, +.input-group-lg > .input-group-addon, +.input-group-lg > .input-group-btn > .btn { + height: 46px; + padding: 10px 16px; + font-size: 18px; + line-height: 1.3333333; + border-radius: 6px; +} +select.input-group-lg > .form-control, +select.input-group-lg > .input-group-addon, +select.input-group-lg > .input-group-btn > .btn { + height: 46px; + line-height: 46px; +} +textarea.input-group-lg > .form-control, +textarea.input-group-lg > .input-group-addon, +textarea.input-group-lg > .input-group-btn > .btn, +select[multiple].input-group-lg > .form-control, +select[multiple].input-group-lg > .input-group-addon, +select[multiple].input-group-lg > .input-group-btn > .btn { + height: auto; +} +.input-group-sm > .form-control, +.input-group-sm > .input-group-addon, +.input-group-sm > .input-group-btn > .btn { + height: 30px; + padding: 5px 10px; + font-size: 12px; + line-height: 1.5; + border-radius: 3px; +} +select.input-group-sm > .form-control, +select.input-group-sm > .input-group-addon, +select.input-group-sm > .input-group-btn > .btn { + height: 30px; + line-height: 30px; +} +textarea.input-group-sm > .form-control, +textarea.input-group-sm > .input-group-addon, +textarea.input-group-sm > .input-group-btn > .btn, +select[multiple].input-group-sm > .form-control, +select[multiple].input-group-sm > .input-group-addon, +select[multiple].input-group-sm > .input-group-btn > .btn { + height: auto; +} +.input-group-addon, +.input-group-btn, +.input-group .form-control { + display: table-cell; +} +.input-group-addon:not(:first-child):not(:last-child), +.input-group-btn:not(:first-child):not(:last-child), +.input-group .form-control:not(:first-child):not(:last-child) { + border-radius: 0; +} +.input-group-addon, +.input-group-btn { + width: 1%; + white-space: nowrap; + vertical-align: middle; +} +.input-group-addon { + padding: 6px 12px; + font-size: 14px; + font-weight: normal; + line-height: 1; + color: #555; + text-align: center; + background-color: #eee; + border: 1px solid #ccc; + border-radius: 4px; +} +.input-group-addon.input-sm { + padding: 5px 10px; + font-size: 12px; + border-radius: 3px; +} +.input-group-addon.input-lg { + padding: 10px 16px; + font-size: 18px; + border-radius: 6px; +} +.input-group-addon input[type="radio"], +.input-group-addon input[type="checkbox"] { + margin-top: 0; +} +.input-group .form-control:first-child, +.input-group-addon:first-child, +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group > .btn, +.input-group-btn:first-child > .dropdown-toggle, +.input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle), +.input-group-btn:last-child > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.input-group-addon:first-child { + border-right: 0; +} +.input-group .form-control:last-child, +.input-group-addon:last-child, +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group > .btn, +.input-group-btn:last-child > .dropdown-toggle, +.input-group-btn:first-child > .btn:not(:first-child), +.input-group-btn:first-child > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} +.input-group-addon:last-child { + border-left: 0; +} +.input-group-btn { + position: relative; + font-size: 0; + white-space: nowrap; +} +.input-group-btn > .btn { + position: relative; +} +.input-group-btn > .btn + .btn { + margin-left: -1px; +} +.input-group-btn > .btn:hover, +.input-group-btn > .btn:focus, +.input-group-btn > .btn:active { + z-index: 2; +} +.input-group-btn:first-child > .btn, +.input-group-btn:first-child > .btn-group { + margin-right: -1px; +} +.input-group-btn:last-child > .btn, +.input-group-btn:last-child > .btn-group { + margin-left: -1px; +} +.nav { + padding-left: 0; + margin-bottom: 0; + list-style: none; +} +.nav > li { + position: relative; + display: block; +} +.nav > li > a { + position: relative; + display: block; + padding: 10px 15px; +} +.nav > li > a:hover, +.nav > li > a:focus { + text-decoration: none; + background-color: #eee; +} +.nav > li.disabled > a { + color: #777; +} +.nav > li.disabled > a:hover, +.nav > li.disabled > a:focus { + color: #777; + text-decoration: none; + cursor: not-allowed; + background-color: transparent; +} +.nav .open > a, +.nav .open > a:hover, +.nav .open > a:focus { + background-color: #eee; + border-color: #337ab7; +} +.nav .nav-divider { + height: 1px; + margin: 9px 0; + overflow: hidden; + background-color: #e5e5e5; +} +.nav > li > a > img { + max-width: none; +} +.nav-tabs { + border-bottom: 1px solid #ddd; +} +.nav-tabs > li { + float: left; + margin-bottom: -1px; +} +.nav-tabs > li > a { + margin-right: 2px; + line-height: 1.42857143; + border: 1px solid transparent; + border-radius: 4px 4px 0 0; +} +.nav-tabs > li > a:hover { + border-color: #eee #eee #ddd; +} +.nav-tabs > li.active > a, +.nav-tabs > li.active > a:hover, +.nav-tabs > li.active > a:focus { + color: #555; + cursor: default; + background-color: #fff; + border: 1px solid #ddd; + border-bottom-color: transparent; +} +.nav-tabs.nav-justified { + width: 100%; + border-bottom: 0; +} +.nav-tabs.nav-justified > li { + float: none; +} +.nav-tabs.nav-justified > li > a { + margin-bottom: 5px; + text-align: center; +} +.nav-tabs.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-tabs.nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs.nav-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs.nav-justified > .active > a, +.nav-tabs.nav-justified > .active > a:hover, +.nav-tabs.nav-justified > .active > a:focus { + border: 1px solid #ddd; +} +@media (min-width: 768px) { + .nav-tabs.nav-justified > li > a { + border-bottom: 1px solid #ddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs.nav-justified > .active > a, + .nav-tabs.nav-justified > .active > a:hover, + .nav-tabs.nav-justified > .active > a:focus { + border-bottom-color: #fff; + } +} +.nav-pills > li { + float: left; +} +.nav-pills > li > a { + border-radius: 4px; +} +.nav-pills > li + li { + margin-left: 2px; +} +.nav-pills > li.active > a, +.nav-pills > li.active > a:hover, +.nav-pills > li.active > a:focus { + color: #fff; + background-color: #337ab7; +} +.nav-stacked > li { + float: none; +} +.nav-stacked > li + li { + margin-top: 2px; + margin-left: 0; +} +.nav-justified { + width: 100%; +} +.nav-justified > li { + float: none; +} +.nav-justified > li > a { + margin-bottom: 5px; + text-align: center; +} +.nav-justified > .dropdown .dropdown-menu { + top: auto; + left: auto; +} +@media (min-width: 768px) { + .nav-justified > li { + display: table-cell; + width: 1%; + } + .nav-justified > li > a { + margin-bottom: 0; + } +} +.nav-tabs-justified { + border-bottom: 0; +} +.nav-tabs-justified > li > a { + margin-right: 0; + border-radius: 4px; +} +.nav-tabs-justified > .active > a, +.nav-tabs-justified > .active > a:hover, +.nav-tabs-justified > .active > a:focus { + border: 1px solid #ddd; +} +@media (min-width: 768px) { + .nav-tabs-justified > li > a { + border-bottom: 1px solid #ddd; + border-radius: 4px 4px 0 0; + } + .nav-tabs-justified > .active > a, + .nav-tabs-justified > .active > a:hover, + .nav-tabs-justified > .active > a:focus { + border-bottom-color: #fff; + } +} +.tab-content > .tab-pane { + display: none; + visibility: hidden; +} +.tab-content > .active { + display: block; + visibility: visible; +} +.nav-tabs .dropdown-menu { + margin-top: -1px; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.navbar { + position: relative; + min-height: 50px; + margin-bottom: 20px; + border: 1px solid transparent; +} +@media (min-width: 768px) { + .navbar { + border-radius: 4px; + } +} +@media (min-width: 768px) { + .navbar-header { + float: left; + } +} +.navbar-collapse { + padding-right: 15px; + padding-left: 15px; + overflow-x: visible; + -webkit-overflow-scrolling: touch; + border-top: 1px solid transparent; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1); +} +.navbar-collapse.in { + overflow-y: auto; +} +@media (min-width: 768px) { + .navbar-collapse { + width: auto; + border-top: 0; + -webkit-box-shadow: none; + box-shadow: none; + } + .navbar-collapse.collapse { + display: block !important; + height: auto !important; + padding-bottom: 0; + overflow: visible !important; + visibility: visible !important; + } + .navbar-collapse.in { + overflow-y: visible; + } + .navbar-fixed-top .navbar-collapse, + .navbar-static-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + padding-right: 0; + padding-left: 0; + } +} +.navbar-fixed-top .navbar-collapse, +.navbar-fixed-bottom .navbar-collapse { + max-height: 340px; +} +@media (max-device-width: 480px) and (orientation: landscape) { + .navbar-fixed-top .navbar-collapse, + .navbar-fixed-bottom .navbar-collapse { + max-height: 200px; + } +} +.container > .navbar-header, +.container-fluid > .navbar-header, +.container > .navbar-collapse, +.container-fluid > .navbar-collapse { + margin-right: -15px; + margin-left: -15px; +} +@media (min-width: 768px) { + .container > .navbar-header, + .container-fluid > .navbar-header, + .container > .navbar-collapse, + .container-fluid > .navbar-collapse { + margin-right: 0; + margin-left: 0; + } +} +.navbar-static-top { + z-index: 1000; + border-width: 0 0 1px; +} +@media (min-width: 768px) { + .navbar-static-top { + border-radius: 0; + } +} +.navbar-fixed-top, +.navbar-fixed-bottom { + position: fixed; + right: 0; + left: 0; + z-index: 1030; +} +@media (min-width: 768px) { + .navbar-fixed-top, + .navbar-fixed-bottom { + border-radius: 0; + } +} +.navbar-fixed-top { + top: 0; + border-width: 0 0 1px; +} +.navbar-fixed-bottom { + bottom: 0; + margin-bottom: 0; + border-width: 1px 0 0; +} +.navbar-brand { + float: left; + height: 50px; + padding: 15px 15px; + font-size: 18px; + line-height: 20px; +} +.navbar-brand:hover, +.navbar-brand:focus { + text-decoration: none; +} +.navbar-brand > img { + display: block; +} +@media (min-width: 768px) { + .navbar > .container .navbar-brand, + .navbar > .container-fluid .navbar-brand { + margin-left: -15px; + } +} +.navbar-toggle { + position: relative; + float: right; + padding: 9px 10px; + margin-top: 8px; + margin-right: 15px; + margin-bottom: 8px; + background-color: transparent; + background-image: none; + border: 1px solid transparent; + border-radius: 4px; +} +.navbar-toggle:focus { + outline: 0; +} +.navbar-toggle .icon-bar { + display: block; + width: 22px; + height: 2px; + border-radius: 1px; +} +.navbar-toggle .icon-bar + .icon-bar { + margin-top: 4px; +} +@media (min-width: 768px) { + .navbar-toggle { + display: none; + } +} +.navbar-nav { + margin: 7.5px -15px; +} +.navbar-nav > li > a { + padding-top: 10px; + padding-bottom: 10px; + line-height: 20px; +} +@media (max-width: 767px) { + .navbar-nav .open .dropdown-menu { + position: static; + float: none; + width: auto; + margin-top: 0; + background-color: transparent; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + } + .navbar-nav .open .dropdown-menu > li > a, + .navbar-nav .open .dropdown-menu .dropdown-header { + padding: 5px 15px 5px 25px; + } + .navbar-nav .open .dropdown-menu > li > a { + line-height: 20px; + } + .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-nav .open .dropdown-menu > li > a:focus { + background-image: none; + } +} +@media (min-width: 768px) { + .navbar-nav { + float: left; + margin: 0; + } + .navbar-nav > li { + float: left; + } + .navbar-nav > li > a { + padding-top: 15px; + padding-bottom: 15px; + } +} +.navbar-form { + padding: 10px 15px; + margin-top: 8px; + margin-right: -15px; + margin-bottom: 8px; + margin-left: -15px; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, .1), 0 1px 0 rgba(255, 255, 255, .1); +} +@media (min-width: 768px) { + .navbar-form .form-group { + display: inline-block; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .form-control { + display: inline-block; + width: auto; + vertical-align: middle; + } + .navbar-form .form-control-static { + display: inline-block; + } + .navbar-form .input-group { + display: inline-table; + vertical-align: middle; + } + .navbar-form .input-group .input-group-addon, + .navbar-form .input-group .input-group-btn, + .navbar-form .input-group .form-control { + width: auto; + } + .navbar-form .input-group > .form-control { + width: 100%; + } + .navbar-form .control-label { + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio, + .navbar-form .checkbox { + display: inline-block; + margin-top: 0; + margin-bottom: 0; + vertical-align: middle; + } + .navbar-form .radio label, + .navbar-form .checkbox label { + padding-left: 0; + } + .navbar-form .radio input[type="radio"], + .navbar-form .checkbox input[type="checkbox"] { + position: relative; + margin-left: 0; + } + .navbar-form .has-feedback .form-control-feedback { + top: 0; + } +} +@media (max-width: 767px) { + .navbar-form .form-group { + margin-bottom: 5px; + } + .navbar-form .form-group:last-child { + margin-bottom: 0; + } +} +@media (min-width: 768px) { + .navbar-form { + width: auto; + padding-top: 0; + padding-bottom: 0; + margin-right: 0; + margin-left: 0; + border: 0; + -webkit-box-shadow: none; + box-shadow: none; + } +} +.navbar-nav > li > .dropdown-menu { + margin-top: 0; + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.navbar-fixed-bottom .navbar-nav > li > .dropdown-menu { + margin-bottom: 0; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.navbar-btn { + margin-top: 8px; + margin-bottom: 8px; +} +.navbar-btn.btn-sm { + margin-top: 10px; + margin-bottom: 10px; +} +.navbar-btn.btn-xs { + margin-top: 14px; + margin-bottom: 14px; +} +.navbar-text { + margin-top: 15px; + margin-bottom: 15px; +} +@media (min-width: 768px) { + .navbar-text { + float: left; + margin-right: 15px; + margin-left: 15px; + } +} +@media (min-width: 768px) { + .navbar-left { + float: left !important; + } + .navbar-right { + float: right !important; + margin-right: -15px; + } + .navbar-right ~ .navbar-right { + margin-right: 0; + } +} +.navbar-default { + background-color: #f8f8f8; + border-color: #e7e7e7; +} +.navbar-default .navbar-brand { + color: #777; +} +.navbar-default .navbar-brand:hover, +.navbar-default .navbar-brand:focus { + color: #5e5e5e; + background-color: transparent; +} +.navbar-default .navbar-text { + color: #777; +} +.navbar-default .navbar-nav > li > a { + color: #777; +} +.navbar-default .navbar-nav > li > a:hover, +.navbar-default .navbar-nav > li > a:focus { + color: #333; + background-color: transparent; +} +.navbar-default .navbar-nav > .active > a, +.navbar-default .navbar-nav > .active > a:hover, +.navbar-default .navbar-nav > .active > a:focus { + color: #555; + background-color: #e7e7e7; +} +.navbar-default .navbar-nav > .disabled > a, +.navbar-default .navbar-nav > .disabled > a:hover, +.navbar-default .navbar-nav > .disabled > a:focus { + color: #ccc; + background-color: transparent; +} +.navbar-default .navbar-toggle { + border-color: #ddd; +} +.navbar-default .navbar-toggle:hover, +.navbar-default .navbar-toggle:focus { + background-color: #ddd; +} +.navbar-default .navbar-toggle .icon-bar { + background-color: #888; +} +.navbar-default .navbar-collapse, +.navbar-default .navbar-form { + border-color: #e7e7e7; +} +.navbar-default .navbar-nav > .open > a, +.navbar-default .navbar-nav > .open > a:hover, +.navbar-default .navbar-nav > .open > a:focus { + color: #555; + background-color: #e7e7e7; +} +@media (max-width: 767px) { + .navbar-default .navbar-nav .open .dropdown-menu > li > a { + color: #777; + } + .navbar-default .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > li > a:focus { + color: #333; + background-color: transparent; + } + .navbar-default .navbar-nav .open .dropdown-menu > .active > a, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #555; + background-color: #e7e7e7; + } + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-default .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #ccc; + background-color: transparent; + } +} +.navbar-default .navbar-link { + color: #777; +} +.navbar-default .navbar-link:hover { + color: #333; +} +.navbar-default .btn-link { + color: #777; +} +.navbar-default .btn-link:hover, +.navbar-default .btn-link:focus { + color: #333; +} +.navbar-default .btn-link[disabled]:hover, +fieldset[disabled] .navbar-default .btn-link:hover, +.navbar-default .btn-link[disabled]:focus, +fieldset[disabled] .navbar-default .btn-link:focus { + color: #ccc; +} +.navbar-inverse { + background-color: #222; + border-color: #080808; +} +.navbar-inverse .navbar-brand { + color: #9d9d9d; +} +.navbar-inverse .navbar-brand:hover, +.navbar-inverse .navbar-brand:focus { + color: #fff; + background-color: transparent; +} +.navbar-inverse .navbar-text { + color: #9d9d9d; +} +.navbar-inverse .navbar-nav > li > a { + color: #9d9d9d; +} +.navbar-inverse .navbar-nav > li > a:hover, +.navbar-inverse .navbar-nav > li > a:focus { + color: #fff; + background-color: transparent; +} +.navbar-inverse .navbar-nav > .active > a, +.navbar-inverse .navbar-nav > .active > a:hover, +.navbar-inverse .navbar-nav > .active > a:focus { + color: #fff; + background-color: #080808; +} +.navbar-inverse .navbar-nav > .disabled > a, +.navbar-inverse .navbar-nav > .disabled > a:hover, +.navbar-inverse .navbar-nav > .disabled > a:focus { + color: #444; + background-color: transparent; +} +.navbar-inverse .navbar-toggle { + border-color: #333; +} +.navbar-inverse .navbar-toggle:hover, +.navbar-inverse .navbar-toggle:focus { + background-color: #333; +} +.navbar-inverse .navbar-toggle .icon-bar { + background-color: #fff; +} +.navbar-inverse .navbar-collapse, +.navbar-inverse .navbar-form { + border-color: #101010; +} +.navbar-inverse .navbar-nav > .open > a, +.navbar-inverse .navbar-nav > .open > a:hover, +.navbar-inverse .navbar-nav > .open > a:focus { + color: #fff; + background-color: #080808; +} +@media (max-width: 767px) { + .navbar-inverse .navbar-nav .open .dropdown-menu > .dropdown-header { + border-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu .divider { + background-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a { + color: #9d9d9d; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > li > a:focus { + color: #fff; + background-color: transparent; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .active > a:focus { + color: #fff; + background-color: #080808; + } + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:hover, + .navbar-inverse .navbar-nav .open .dropdown-menu > .disabled > a:focus { + color: #444; + background-color: transparent; + } +} +.navbar-inverse .navbar-link { + color: #9d9d9d; +} +.navbar-inverse .navbar-link:hover { + color: #fff; +} +.navbar-inverse .btn-link { + color: #9d9d9d; +} +.navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link:focus { + color: #fff; +} +.navbar-inverse .btn-link[disabled]:hover, +fieldset[disabled] .navbar-inverse .btn-link:hover, +.navbar-inverse .btn-link[disabled]:focus, +fieldset[disabled] .navbar-inverse .btn-link:focus { + color: #444; +} +.breadcrumb { + padding: 8px 15px; + margin-bottom: 20px; + list-style: none; + background-color: #f5f5f5; + border-radius: 4px; +} +.breadcrumb > li { + display: inline-block; +} +.breadcrumb > li + li:before { + padding: 0 5px; + color: #ccc; + content: "/\00a0"; +} +.breadcrumb > .active { + color: #777; +} +.pagination { + display: inline-block; + padding-left: 0; + margin: 20px 0; + border-radius: 4px; +} +.pagination > li { + display: inline; +} +.pagination > li > a, +.pagination > li > span { + position: relative; + float: left; + padding: 6px 12px; + margin-left: -1px; + line-height: 1.42857143; + color: #337ab7; + text-decoration: none; + background-color: #fff; + border: 1px solid #ddd; +} +.pagination > li:first-child > a, +.pagination > li:first-child > span { + margin-left: 0; + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; +} +.pagination > li:last-child > a, +.pagination > li:last-child > span { + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; +} +.pagination > li > a:hover, +.pagination > li > span:hover, +.pagination > li > a:focus, +.pagination > li > span:focus { + color: #23527c; + background-color: #eee; + border-color: #ddd; +} +.pagination > .active > a, +.pagination > .active > span, +.pagination > .active > a:hover, +.pagination > .active > span:hover, +.pagination > .active > a:focus, +.pagination > .active > span:focus { + z-index: 2; + color: #fff; + cursor: default; + background-color: #337ab7; + border-color: #337ab7; +} +.pagination > .disabled > span, +.pagination > .disabled > span:hover, +.pagination > .disabled > span:focus, +.pagination > .disabled > a, +.pagination > .disabled > a:hover, +.pagination > .disabled > a:focus { + color: #777; + cursor: not-allowed; + background-color: #fff; + border-color: #ddd; +} +.pagination-lg > li > a, +.pagination-lg > li > span { + padding: 10px 16px; + font-size: 18px; +} +.pagination-lg > li:first-child > a, +.pagination-lg > li:first-child > span { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} +.pagination-lg > li:last-child > a, +.pagination-lg > li:last-child > span { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; +} +.pagination-sm > li > a, +.pagination-sm > li > span { + padding: 5px 10px; + font-size: 12px; +} +.pagination-sm > li:first-child > a, +.pagination-sm > li:first-child > span { + border-top-left-radius: 3px; + border-bottom-left-radius: 3px; +} +.pagination-sm > li:last-child > a, +.pagination-sm > li:last-child > span { + border-top-right-radius: 3px; + border-bottom-right-radius: 3px; +} +.pager { + padding-left: 0; + margin: 20px 0; + text-align: center; + list-style: none; +} +.pager li { + display: inline; +} +.pager li > a, +.pager li > span { + display: inline-block; + padding: 5px 14px; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 15px; +} +.pager li > a:hover, +.pager li > a:focus { + text-decoration: none; + background-color: #eee; +} +.pager .next > a, +.pager .next > span { + float: right; +} +.pager .previous > a, +.pager .previous > span { + float: left; +} +.pager .disabled > a, +.pager .disabled > a:hover, +.pager .disabled > a:focus, +.pager .disabled > span { + color: #777; + cursor: not-allowed; + background-color: #fff; +} +.label { + display: inline; + padding: .2em .6em .3em; + font-size: 75%; + font-weight: bold; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: .25em; +} +a.label:hover, +a.label:focus { + color: #fff; + text-decoration: none; + cursor: pointer; +} +.label:empty { + display: none; +} +.btn .label { + position: relative; + top: -1px; +} +.label-default { + background-color: #777; +} +.label-default[href]:hover, +.label-default[href]:focus { + background-color: #5e5e5e; +} +.label-primary { + background-color: #337ab7; +} +.label-primary[href]:hover, +.label-primary[href]:focus { + background-color: #286090; +} +.label-success { + background-color: #5cb85c; +} +.label-success[href]:hover, +.label-success[href]:focus { + background-color: #449d44; +} +.label-info { + background-color: #5bc0de; +} +.label-info[href]:hover, +.label-info[href]:focus { + background-color: #31b0d5; +} +.label-warning { + background-color: #f0ad4e; +} +.label-warning[href]:hover, +.label-warning[href]:focus { + background-color: #ec971f; +} +.label-danger { + background-color: #d9534f; +} +.label-danger[href]:hover, +.label-danger[href]:focus { + background-color: #c9302c; +} +.badge { + display: inline-block; + min-width: 10px; + padding: 3px 7px; + font-size: 12px; + font-weight: bold; + line-height: 1; + color: #fff; + text-align: center; + white-space: nowrap; + vertical-align: baseline; + background-color: #777; + border-radius: 10px; +} +.badge:empty { + display: none; +} +.btn .badge { + position: relative; + top: -1px; +} +.btn-xs .badge { + top: 0; + padding: 1px 5px; +} +a.badge:hover, +a.badge:focus { + color: #fff; + text-decoration: none; + cursor: pointer; +} +.list-group-item.active > .badge, +.nav-pills > .active > a > .badge { + color: #337ab7; + background-color: #fff; +} +.list-group-item > .badge { + float: right; +} +.list-group-item > .badge + .badge { + margin-right: 5px; +} +.nav-pills > li > a > .badge { + margin-left: 3px; +} +.jumbotron { + padding: 30px 15px; + margin-bottom: 30px; + color: inherit; + background-color: #eee; +} +.jumbotron h1, +.jumbotron .h1 { + color: inherit; +} +.jumbotron p { + margin-bottom: 15px; + font-size: 21px; + font-weight: 200; +} +.jumbotron > hr { + border-top-color: #d5d5d5; +} +.container .jumbotron, +.container-fluid .jumbotron { + border-radius: 6px; +} +.jumbotron .container { + max-width: 100%; +} +@media screen and (min-width: 768px) { + .jumbotron { + padding: 48px 0; + } + .container .jumbotron, + .container-fluid .jumbotron { + padding-right: 60px; + padding-left: 60px; + } + .jumbotron h1, + .jumbotron .h1 { + font-size: 63px; + } +} +.thumbnail { + display: block; + padding: 4px; + margin-bottom: 20px; + line-height: 1.42857143; + background-color: #fff; + border: 1px solid #ddd; + border-radius: 4px; + -webkit-transition: border .2s ease-in-out; + -o-transition: border .2s ease-in-out; + transition: border .2s ease-in-out; +} +.thumbnail > img, +.thumbnail a > img { + margin-right: auto; + margin-left: auto; +} +a.thumbnail:hover, +a.thumbnail:focus, +a.thumbnail.active { + border-color: #337ab7; +} +.thumbnail .caption { + padding: 9px; + color: #333; +} +.alert { + padding: 15px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 4px; +} +.alert h4 { + margin-top: 0; + color: inherit; +} +.alert .alert-link { + font-weight: bold; +} +.alert > p, +.alert > ul { + margin-bottom: 0; +} +.alert > p + p { + margin-top: 5px; +} +.alert-dismissable, +.alert-dismissible { + padding-right: 35px; +} +.alert-dismissable .close, +.alert-dismissible .close { + position: relative; + top: -2px; + right: -21px; + color: inherit; +} +.alert-success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.alert-success hr { + border-top-color: #c9e2b3; +} +.alert-success .alert-link { + color: #2b542c; +} +.alert-info { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.alert-info hr { + border-top-color: #a6e1ec; +} +.alert-info .alert-link { + color: #245269; +} +.alert-warning { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.alert-warning hr { + border-top-color: #f7e1b5; +} +.alert-warning .alert-link { + color: #66512c; +} +.alert-danger { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.alert-danger hr { + border-top-color: #e4b9c0; +} +.alert-danger .alert-link { + color: #843534; +} +@-webkit-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@-o-keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +@keyframes progress-bar-stripes { + from { + background-position: 40px 0; + } + to { + background-position: 0 0; + } +} +.progress { + height: 20px; + margin-bottom: 20px; + overflow: hidden; + background-color: #f5f5f5; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1); +} +.progress-bar { + float: left; + width: 0; + height: 100%; + font-size: 12px; + line-height: 20px; + color: #fff; + text-align: center; + background-color: #337ab7; + -webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + box-shadow: inset 0 -1px 0 rgba(0, 0, 0, .15); + -webkit-transition: width .6s ease; + -o-transition: width .6s ease; + transition: width .6s ease; +} +.progress-striped .progress-bar, +.progress-bar-striped { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + -webkit-background-size: 40px 40px; + background-size: 40px 40px; +} +.progress.active .progress-bar, +.progress-bar.active { + -webkit-animation: progress-bar-stripes 2s linear infinite; + -o-animation: progress-bar-stripes 2s linear infinite; + animation: progress-bar-stripes 2s linear infinite; +} +.progress-bar-success { + background-color: #5cb85c; +} +.progress-striped .progress-bar-success { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-info { + background-color: #5bc0de; +} +.progress-striped .progress-bar-info { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-warning { + background-color: #f0ad4e; +} +.progress-striped .progress-bar-warning { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.progress-bar-danger { + background-color: #d9534f; +} +.progress-striped .progress-bar-danger { + background-image: -webkit-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: -o-linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); + background-image: linear-gradient(45deg, rgba(255, 255, 255, .15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, .15) 50%, rgba(255, 255, 255, .15) 75%, transparent 75%, transparent); +} +.media { + margin-top: 15px; +} +.media:first-child { + margin-top: 0; +} +.media, +.media-body { + overflow: hidden; + zoom: 1; +} +.media-body { + width: 10000px; +} +.media-object { + display: block; +} +.media-right, +.media > .pull-right { + padding-left: 10px; +} +.media-left, +.media > .pull-left { + padding-right: 10px; +} +.media-left, +.media-right, +.media-body { + display: table-cell; + vertical-align: top; +} +.media-middle { + vertical-align: middle; +} +.media-bottom { + vertical-align: bottom; +} +.media-heading { + margin-top: 0; + margin-bottom: 5px; +} +.media-list { + padding-left: 0; + list-style: none; +} +.list-group { + padding-left: 0; + margin-bottom: 20px; +} +.list-group-item { + position: relative; + display: block; + padding: 10px 15px; + margin-bottom: -1px; + background-color: #fff; + border: 1px solid #ddd; +} +.list-group-item:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} +.list-group-item:last-child { + margin-bottom: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; +} +a.list-group-item { + color: #555; +} +a.list-group-item .list-group-item-heading { + color: #333; +} +a.list-group-item:hover, +a.list-group-item:focus { + color: #555; + text-decoration: none; + background-color: #f5f5f5; +} +.list-group-item.disabled, +.list-group-item.disabled:hover, +.list-group-item.disabled:focus { + color: #777; + cursor: not-allowed; + background-color: #eee; +} +.list-group-item.disabled .list-group-item-heading, +.list-group-item.disabled:hover .list-group-item-heading, +.list-group-item.disabled:focus .list-group-item-heading { + color: inherit; +} +.list-group-item.disabled .list-group-item-text, +.list-group-item.disabled:hover .list-group-item-text, +.list-group-item.disabled:focus .list-group-item-text { + color: #777; +} +.list-group-item.active, +.list-group-item.active:hover, +.list-group-item.active:focus { + z-index: 2; + color: #fff; + background-color: #337ab7; + border-color: #337ab7; +} +.list-group-item.active .list-group-item-heading, +.list-group-item.active:hover .list-group-item-heading, +.list-group-item.active:focus .list-group-item-heading, +.list-group-item.active .list-group-item-heading > small, +.list-group-item.active:hover .list-group-item-heading > small, +.list-group-item.active:focus .list-group-item-heading > small, +.list-group-item.active .list-group-item-heading > .small, +.list-group-item.active:hover .list-group-item-heading > .small, +.list-group-item.active:focus .list-group-item-heading > .small { + color: inherit; +} +.list-group-item.active .list-group-item-text, +.list-group-item.active:hover .list-group-item-text, +.list-group-item.active:focus .list-group-item-text { + color: #c7ddef; +} +.list-group-item-success { + color: #3c763d; + background-color: #dff0d8; +} +a.list-group-item-success { + color: #3c763d; +} +a.list-group-item-success .list-group-item-heading { + color: inherit; +} +a.list-group-item-success:hover, +a.list-group-item-success:focus { + color: #3c763d; + background-color: #d0e9c6; +} +a.list-group-item-success.active, +a.list-group-item-success.active:hover, +a.list-group-item-success.active:focus { + color: #fff; + background-color: #3c763d; + border-color: #3c763d; +} +.list-group-item-info { + color: #31708f; + background-color: #d9edf7; +} +a.list-group-item-info { + color: #31708f; +} +a.list-group-item-info .list-group-item-heading { + color: inherit; +} +a.list-group-item-info:hover, +a.list-group-item-info:focus { + color: #31708f; + background-color: #c4e3f3; +} +a.list-group-item-info.active, +a.list-group-item-info.active:hover, +a.list-group-item-info.active:focus { + color: #fff; + background-color: #31708f; + border-color: #31708f; +} +.list-group-item-warning { + color: #8a6d3b; + background-color: #fcf8e3; +} +a.list-group-item-warning { + color: #8a6d3b; +} +a.list-group-item-warning .list-group-item-heading { + color: inherit; +} +a.list-group-item-warning:hover, +a.list-group-item-warning:focus { + color: #8a6d3b; + background-color: #faf2cc; +} +a.list-group-item-warning.active, +a.list-group-item-warning.active:hover, +a.list-group-item-warning.active:focus { + color: #fff; + background-color: #8a6d3b; + border-color: #8a6d3b; +} +.list-group-item-danger { + color: #a94442; + background-color: #f2dede; +} +a.list-group-item-danger { + color: #a94442; +} +a.list-group-item-danger .list-group-item-heading { + color: inherit; +} +a.list-group-item-danger:hover, +a.list-group-item-danger:focus { + color: #a94442; + background-color: #ebcccc; +} +a.list-group-item-danger.active, +a.list-group-item-danger.active:hover, +a.list-group-item-danger.active:focus { + color: #fff; + background-color: #a94442; + border-color: #a94442; +} +.list-group-item-heading { + margin-top: 0; + margin-bottom: 5px; +} +.list-group-item-text { + margin-bottom: 0; + line-height: 1.3; +} +.panel { + margin-bottom: 20px; + background-color: #fff; + border: 1px solid transparent; + border-radius: 4px; + -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, .05); + box-shadow: 0 1px 1px rgba(0, 0, 0, .05); +} +.panel-body { + padding: 15px; +} +.panel-heading { + padding: 10px 15px; + border-bottom: 1px solid transparent; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel-heading > .dropdown .dropdown-toggle { + color: inherit; +} +.panel-title { + margin-top: 0; + margin-bottom: 0; + font-size: 16px; + color: inherit; +} +.panel-title > a, +.panel-title > small, +.panel-title > .small, +.panel-title > small > a, +.panel-title > .small > a { + color: inherit; +} +.panel-footer { + padding: 10px 15px; + background-color: #f5f5f5; + border-top: 1px solid #ddd; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .list-group, +.panel > .panel-collapse > .list-group { + margin-bottom: 0; +} +.panel > .list-group .list-group-item, +.panel > .panel-collapse > .list-group .list-group-item { + border-width: 1px 0; + border-radius: 0; +} +.panel > .list-group:first-child .list-group-item:first-child, +.panel > .panel-collapse > .list-group:first-child .list-group-item:first-child { + border-top: 0; + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .list-group:last-child .list-group-item:last-child, +.panel > .panel-collapse > .list-group:last-child .list-group-item:last-child { + border-bottom: 0; + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel-heading + .list-group .list-group-item:first-child { + border-top-width: 0; +} +.list-group + .panel-footer { + border-top-width: 0; +} +.panel > .table, +.panel > .table-responsive > .table, +.panel > .panel-collapse > .table { + margin-bottom: 0; +} +.panel > .table caption, +.panel > .table-responsive > .table caption, +.panel > .panel-collapse > .table caption { + padding-right: 15px; + padding-left: 15px; +} +.panel > .table:first-child, +.panel > .table-responsive:first-child > .table:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child { + border-top-left-radius: 3px; + border-top-right-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:first-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:first-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:first-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:first-child { + border-top-left-radius: 3px; +} +.panel > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child td:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child td:last-child, +.panel > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > thead:first-child > tr:first-child th:last-child, +.panel > .table:first-child > tbody:first-child > tr:first-child th:last-child, +.panel > .table-responsive:first-child > .table:first-child > tbody:first-child > tr:first-child th:last-child { + border-top-right-radius: 3px; +} +.panel > .table:last-child, +.panel > .table-responsive:last-child > .table:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child { + border-bottom-right-radius: 3px; + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:first-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:first-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:first-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:first-child { + border-bottom-left-radius: 3px; +} +.panel > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child td:last-child, +.panel > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tbody:last-child > tr:last-child th:last-child, +.panel > .table:last-child > tfoot:last-child > tr:last-child th:last-child, +.panel > .table-responsive:last-child > .table:last-child > tfoot:last-child > tr:last-child th:last-child { + border-bottom-right-radius: 3px; +} +.panel > .panel-body + .table, +.panel > .panel-body + .table-responsive, +.panel > .table + .panel-body, +.panel > .table-responsive + .panel-body { + border-top: 1px solid #ddd; +} +.panel > .table > tbody:first-child > tr:first-child th, +.panel > .table > tbody:first-child > tr:first-child td { + border-top: 0; +} +.panel > .table-bordered, +.panel > .table-responsive > .table-bordered { + border: 0; +} +.panel > .table-bordered > thead > tr > th:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:first-child, +.panel > .table-bordered > tbody > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:first-child, +.panel > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:first-child, +.panel > .table-bordered > thead > tr > td:first-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:first-child, +.panel > .table-bordered > tbody > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:first-child, +.panel > .table-bordered > tfoot > tr > td:first-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:first-child { + border-left: 0; +} +.panel > .table-bordered > thead > tr > th:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > th:last-child, +.panel > .table-bordered > tbody > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > th:last-child, +.panel > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > th:last-child, +.panel > .table-bordered > thead > tr > td:last-child, +.panel > .table-responsive > .table-bordered > thead > tr > td:last-child, +.panel > .table-bordered > tbody > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tbody > tr > td:last-child, +.panel > .table-bordered > tfoot > tr > td:last-child, +.panel > .table-responsive > .table-bordered > tfoot > tr > td:last-child { + border-right: 0; +} +.panel > .table-bordered > thead > tr:first-child > td, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > td, +.panel > .table-bordered > tbody > tr:first-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > td, +.panel > .table-bordered > thead > tr:first-child > th, +.panel > .table-responsive > .table-bordered > thead > tr:first-child > th, +.panel > .table-bordered > tbody > tr:first-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:first-child > th { + border-bottom: 0; +} +.panel > .table-bordered > tbody > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > td, +.panel > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > td, +.panel > .table-bordered > tbody > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tbody > tr:last-child > th, +.panel > .table-bordered > tfoot > tr:last-child > th, +.panel > .table-responsive > .table-bordered > tfoot > tr:last-child > th { + border-bottom: 0; +} +.panel > .table-responsive { + margin-bottom: 0; + border: 0; +} +.panel-group { + margin-bottom: 20px; +} +.panel-group .panel { + margin-bottom: 0; + border-radius: 4px; +} +.panel-group .panel + .panel { + margin-top: 5px; +} +.panel-group .panel-heading { + border-bottom: 0; +} +.panel-group .panel-heading + .panel-collapse > .panel-body, +.panel-group .panel-heading + .panel-collapse > .list-group { + border-top: 1px solid #ddd; +} +.panel-group .panel-footer { + border-top: 0; +} +.panel-group .panel-footer + .panel-collapse .panel-body { + border-bottom: 1px solid #ddd; +} +.panel-default { + border-color: #ddd; +} +.panel-default > .panel-heading { + color: #333; + background-color: #f5f5f5; + border-color: #ddd; +} +.panel-default > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #ddd; +} +.panel-default > .panel-heading .badge { + color: #f5f5f5; + background-color: #333; +} +.panel-default > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #ddd; +} +.panel-primary { + border-color: #337ab7; +} +.panel-primary > .panel-heading { + color: #fff; + background-color: #337ab7; + border-color: #337ab7; +} +.panel-primary > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #337ab7; +} +.panel-primary > .panel-heading .badge { + color: #337ab7; + background-color: #fff; +} +.panel-primary > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #337ab7; +} +.panel-success { + border-color: #d6e9c6; +} +.panel-success > .panel-heading { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} +.panel-success > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #d6e9c6; +} +.panel-success > .panel-heading .badge { + color: #dff0d8; + background-color: #3c763d; +} +.panel-success > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #d6e9c6; +} +.panel-info { + border-color: #bce8f1; +} +.panel-info > .panel-heading { + color: #31708f; + background-color: #d9edf7; + border-color: #bce8f1; +} +.panel-info > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #bce8f1; +} +.panel-info > .panel-heading .badge { + color: #d9edf7; + background-color: #31708f; +} +.panel-info > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #bce8f1; +} +.panel-warning { + border-color: #faebcc; +} +.panel-warning > .panel-heading { + color: #8a6d3b; + background-color: #fcf8e3; + border-color: #faebcc; +} +.panel-warning > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #faebcc; +} +.panel-warning > .panel-heading .badge { + color: #fcf8e3; + background-color: #8a6d3b; +} +.panel-warning > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #faebcc; +} +.panel-danger { + border-color: #ebccd1; +} +.panel-danger > .panel-heading { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} +.panel-danger > .panel-heading + .panel-collapse > .panel-body { + border-top-color: #ebccd1; +} +.panel-danger > .panel-heading .badge { + color: #f2dede; + background-color: #a94442; +} +.panel-danger > .panel-footer + .panel-collapse > .panel-body { + border-bottom-color: #ebccd1; +} +.embed-responsive { + position: relative; + display: block; + height: 0; + padding: 0; + overflow: hidden; +} +.embed-responsive .embed-responsive-item, +.embed-responsive iframe, +.embed-responsive embed, +.embed-responsive object, +.embed-responsive video { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 100%; + height: 100%; + border: 0; +} +.embed-responsive.embed-responsive-16by9 { + padding-bottom: 56.25%; +} +.embed-responsive.embed-responsive-4by3 { + padding-bottom: 75%; +} +.well { + min-height: 20px; + padding: 19px; + margin-bottom: 20px; + background-color: #f5f5f5; + border: 1px solid #e3e3e3; + border-radius: 4px; + -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, .05); +} +.well blockquote { + border-color: #ddd; + border-color: rgba(0, 0, 0, .15); +} +.well-lg { + padding: 24px; + border-radius: 6px; +} +.well-sm { + padding: 9px; + border-radius: 3px; +} +.close { + float: right; + font-size: 21px; + font-weight: bold; + line-height: 1; + color: #000; + text-shadow: 0 1px 0 #fff; + filter: alpha(opacity=20); + opacity: .2; +} +.close:hover, +.close:focus { + color: #000; + text-decoration: none; + cursor: pointer; + filter: alpha(opacity=50); + opacity: .5; +} +button.close { + -webkit-appearance: none; + padding: 0; + cursor: pointer; + background: transparent; + border: 0; +} +.modal-open { + overflow: hidden; +} +.modal { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + display: none; + overflow: hidden; + -webkit-overflow-scrolling: touch; + outline: 0; +} +.modal.fade .modal-dialog { + -webkit-transition: -webkit-transform .3s ease-out; + -o-transition: -o-transform .3s ease-out; + transition: transform .3s ease-out; + -webkit-transform: translate(0, -25%); + -ms-transform: translate(0, -25%); + -o-transform: translate(0, -25%); + transform: translate(0, -25%); +} +.modal.in .modal-dialog { + -webkit-transform: translate(0, 0); + -ms-transform: translate(0, 0); + -o-transform: translate(0, 0); + transform: translate(0, 0); +} +.modal-open .modal { + overflow-x: hidden; + overflow-y: auto; +} +.modal-dialog { + position: relative; + width: auto; + margin: 10px; +} +.modal-content { + position: relative; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #999; + border: 1px solid rgba(0, 0, 0, .2); + border-radius: 6px; + outline: 0; + -webkit-box-shadow: 0 3px 9px rgba(0, 0, 0, .5); + box-shadow: 0 3px 9px rgba(0, 0, 0, .5); +} +.modal-backdrop { + position: absolute; + top: 0; + right: 0; + left: 0; + background-color: #000; +} +.modal-backdrop.fade { + filter: alpha(opacity=0); + opacity: 0; +} +.modal-backdrop.in { + filter: alpha(opacity=50); + opacity: .5; +} +.modal-header { + min-height: 16.42857143px; + padding: 15px; + border-bottom: 1px solid #e5e5e5; +} +.modal-header .close { + margin-top: -2px; +} +.modal-title { + margin: 0; + line-height: 1.42857143; +} +.modal-body { + position: relative; + padding: 15px; +} +.modal-footer { + padding: 15px; + text-align: right; + border-top: 1px solid #e5e5e5; +} +.modal-footer .btn + .btn { + margin-bottom: 0; + margin-left: 5px; +} +.modal-footer .btn-group .btn + .btn { + margin-left: -1px; +} +.modal-footer .btn-block + .btn-block { + margin-left: 0; +} +.modal-scrollbar-measure { + position: absolute; + top: -9999px; + width: 50px; + height: 50px; + overflow: scroll; +} +@media (min-width: 768px) { + .modal-dialog { + width: 600px; + margin: 30px auto; + } + .modal-content { + -webkit-box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + box-shadow: 0 5px 15px rgba(0, 0, 0, .5); + } + .modal-sm { + width: 300px; + } +} +@media (min-width: 992px) { + .modal-lg { + width: 900px; + } +} +.tooltip { + position: absolute; + z-index: 1070; + display: block; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 12px; + font-weight: normal; + line-height: 1.4; + visibility: visible; + filter: alpha(opacity=0); + opacity: 0; +} +.tooltip.in { + filter: alpha(opacity=90); + opacity: .9; +} +.tooltip.top { + padding: 5px 0; + margin-top: -3px; +} +.tooltip.right { + padding: 0 5px; + margin-left: 3px; +} +.tooltip.bottom { + padding: 5px 0; + margin-top: 3px; +} +.tooltip.left { + padding: 0 5px; + margin-left: -3px; +} +.tooltip-inner { + max-width: 200px; + padding: 3px 8px; + color: #fff; + text-align: center; + text-decoration: none; + background-color: #000; + border-radius: 4px; +} +.tooltip-arrow { + position: absolute; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.tooltip.top .tooltip-arrow { + bottom: 0; + left: 50%; + margin-left: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.top-left .tooltip-arrow { + right: 5px; + bottom: 0; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.top-right .tooltip-arrow { + bottom: 0; + left: 5px; + margin-bottom: -5px; + border-width: 5px 5px 0; + border-top-color: #000; +} +.tooltip.right .tooltip-arrow { + top: 50%; + left: 0; + margin-top: -5px; + border-width: 5px 5px 5px 0; + border-right-color: #000; +} +.tooltip.left .tooltip-arrow { + top: 50%; + right: 0; + margin-top: -5px; + border-width: 5px 0 5px 5px; + border-left-color: #000; +} +.tooltip.bottom .tooltip-arrow { + top: 0; + left: 50%; + margin-left: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.tooltip.bottom-left .tooltip-arrow { + top: 0; + right: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.tooltip.bottom-right .tooltip-arrow { + top: 0; + left: 5px; + margin-top: -5px; + border-width: 0 5px 5px; + border-bottom-color: #000; +} +.popover { + position: absolute; + top: 0; + left: 0; + z-index: 1060; + display: none; + max-width: 276px; + padding: 1px; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + font-weight: normal; + line-height: 1.42857143; + text-align: left; + white-space: normal; + background-color: #fff; + -webkit-background-clip: padding-box; + background-clip: padding-box; + border: 1px solid #ccc; + border: 1px solid rgba(0, 0, 0, .2); + border-radius: 6px; + -webkit-box-shadow: 0 5px 10px rgba(0, 0, 0, .2); + box-shadow: 0 5px 10px rgba(0, 0, 0, .2); +} +.popover.top { + margin-top: -10px; +} +.popover.right { + margin-left: 10px; +} +.popover.bottom { + margin-top: 10px; +} +.popover.left { + margin-left: -10px; +} +.popover-title { + padding: 8px 14px; + margin: 0; + font-size: 14px; + background-color: #f7f7f7; + border-bottom: 1px solid #ebebeb; + border-radius: 5px 5px 0 0; +} +.popover-content { + padding: 9px 14px; +} +.popover > .arrow, +.popover > .arrow:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} +.popover > .arrow { + border-width: 11px; +} +.popover > .arrow:after { + content: ""; + border-width: 10px; +} +.popover.top > .arrow { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-top-color: #999; + border-top-color: rgba(0, 0, 0, .25); + border-bottom-width: 0; +} +.popover.top > .arrow:after { + bottom: 1px; + margin-left: -10px; + content: " "; + border-top-color: #fff; + border-bottom-width: 0; +} +.popover.right > .arrow { + top: 50%; + left: -11px; + margin-top: -11px; + border-right-color: #999; + border-right-color: rgba(0, 0, 0, .25); + border-left-width: 0; +} +.popover.right > .arrow:after { + bottom: -10px; + left: 1px; + content: " "; + border-right-color: #fff; + border-left-width: 0; +} +.popover.bottom > .arrow { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: #999; + border-bottom-color: rgba(0, 0, 0, .25); +} +.popover.bottom > .arrow:after { + top: 1px; + margin-left: -10px; + content: " "; + border-top-width: 0; + border-bottom-color: #fff; +} +.popover.left > .arrow { + top: 50%; + right: -11px; + margin-top: -11px; + border-right-width: 0; + border-left-color: #999; + border-left-color: rgba(0, 0, 0, .25); +} +.popover.left > .arrow:after { + right: 1px; + bottom: -10px; + content: " "; + border-right-width: 0; + border-left-color: #fff; +} +.carousel { + position: relative; +} +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; +} +.carousel-inner > .item { + position: relative; + display: none; + -webkit-transition: .6s ease-in-out left; + -o-transition: .6s ease-in-out left; + transition: .6s ease-in-out left; +} +.carousel-inner > .item > img, +.carousel-inner > .item > a > img { + line-height: 1; +} +@media all and (transform-3d), (-webkit-transform-3d) { + .carousel-inner > .item { + -webkit-transition: -webkit-transform .6s ease-in-out; + -o-transition: -o-transform .6s ease-in-out; + transition: transform .6s ease-in-out; + + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + -webkit-perspective: 1000; + perspective: 1000; + } + .carousel-inner > .item.next, + .carousel-inner > .item.active.right { + left: 0; + -webkit-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } + .carousel-inner > .item.prev, + .carousel-inner > .item.active.left { + left: 0; + -webkit-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } + .carousel-inner > .item.next.left, + .carousel-inner > .item.prev.right, + .carousel-inner > .item.active { + left: 0; + -webkit-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } +} +.carousel-inner > .active, +.carousel-inner > .next, +.carousel-inner > .prev { + display: block; +} +.carousel-inner > .active { + left: 0; +} +.carousel-inner > .next, +.carousel-inner > .prev { + position: absolute; + top: 0; + width: 100%; +} +.carousel-inner > .next { + left: 100%; +} +.carousel-inner > .prev { + left: -100%; +} +.carousel-inner > .next.left, +.carousel-inner > .prev.right { + left: 0; +} +.carousel-inner > .active.left { + left: -100%; +} +.carousel-inner > .active.right { + left: 100%; +} +.carousel-control { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 15%; + font-size: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, .6); + filter: alpha(opacity=50); + opacity: .5; +} +.carousel-control.left { + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .5)), to(rgba(0, 0, 0, .0001))); + background-image: linear-gradient(to right, rgba(0, 0, 0, .5) 0%, rgba(0, 0, 0, .0001) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1); + background-repeat: repeat-x; +} +.carousel-control.right { + right: 0; + left: auto; + background-image: -webkit-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + background-image: -o-linear-gradient(left, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + background-image: -webkit-gradient(linear, left top, right top, from(rgba(0, 0, 0, .0001)), to(rgba(0, 0, 0, .5))); + background-image: linear-gradient(to right, rgba(0, 0, 0, .0001) 0%, rgba(0, 0, 0, .5) 100%); + filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1); + background-repeat: repeat-x; +} +.carousel-control:hover, +.carousel-control:focus { + color: #fff; + text-decoration: none; + filter: alpha(opacity=90); + outline: 0; + opacity: .9; +} +.carousel-control .icon-prev, +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-left, +.carousel-control .glyphicon-chevron-right { + position: absolute; + top: 50%; + z-index: 5; + display: inline-block; +} +.carousel-control .icon-prev, +.carousel-control .glyphicon-chevron-left { + left: 50%; + margin-left: -10px; +} +.carousel-control .icon-next, +.carousel-control .glyphicon-chevron-right { + right: 50%; + margin-right: -10px; +} +.carousel-control .icon-prev, +.carousel-control .icon-next { + width: 20px; + height: 20px; + margin-top: -10px; + font-family: serif; + line-height: 1; +} +.carousel-control .icon-prev:before { + content: '\2039'; +} +.carousel-control .icon-next:before { + content: '\203a'; +} +.carousel-indicators { + position: absolute; + bottom: 10px; + left: 50%; + z-index: 15; + width: 60%; + padding-left: 0; + margin-left: -30%; + text-align: center; + list-style: none; +} +.carousel-indicators li { + display: inline-block; + width: 10px; + height: 10px; + margin: 1px; + text-indent: -999px; + cursor: pointer; + background-color: #000 \9; + background-color: rgba(0, 0, 0, 0); + border: 1px solid #fff; + border-radius: 10px; +} +.carousel-indicators .active { + width: 12px; + height: 12px; + margin: 0; + background-color: #fff; +} +.carousel-caption { + position: absolute; + right: 15%; + bottom: 20px; + left: 15%; + z-index: 10; + padding-top: 20px; + padding-bottom: 20px; + color: #fff; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, .6); +} +.carousel-caption .btn { + text-shadow: none; +} +@media screen and (min-width: 768px) { + .carousel-control .glyphicon-chevron-left, + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-prev, + .carousel-control .icon-next { + width: 30px; + height: 30px; + margin-top: -15px; + font-size: 30px; + } + .carousel-control .glyphicon-chevron-left, + .carousel-control .icon-prev { + margin-left: -15px; + } + .carousel-control .glyphicon-chevron-right, + .carousel-control .icon-next { + margin-right: -15px; + } + .carousel-caption { + right: 20%; + left: 20%; + padding-bottom: 30px; + } + .carousel-indicators { + bottom: 20px; + } +} +.clearfix:before, +.clearfix:after, +.dl-horizontal dd:before, +.dl-horizontal dd:after, +.container:before, +.container:after, +.container-fluid:before, +.container-fluid:after, +.row:before, +.row:after, +.form-horizontal .form-group:before, +.form-horizontal .form-group:after, +.btn-toolbar:before, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:before, +.btn-group-vertical > .btn-group:after, +.nav:before, +.nav:after, +.navbar:before, +.navbar:after, +.navbar-header:before, +.navbar-header:after, +.navbar-collapse:before, +.navbar-collapse:after, +.pager:before, +.pager:after, +.panel-body:before, +.panel-body:after, +.modal-footer:before, +.modal-footer:after { + display: table; + content: " "; +} +.clearfix:after, +.dl-horizontal dd:after, +.container:after, +.container-fluid:after, +.row:after, +.form-horizontal .form-group:after, +.btn-toolbar:after, +.btn-group-vertical > .btn-group:after, +.nav:after, +.navbar:after, +.navbar-header:after, +.navbar-collapse:after, +.pager:after, +.panel-body:after, +.modal-footer:after { + clear: both; +} +.center-block { + display: block; + margin-right: auto; + margin-left: auto; +} +.pull-right { + float: right !important; +} +.pull-left { + float: left !important; +} +.hide { + display: none !important; +} +.show { + display: block !important; +} +.invisible { + visibility: hidden; +} +.text-hide { + font: 0/0 a; + color: transparent; + text-shadow: none; + background-color: transparent; + border: 0; +} +.hidden { + display: none !important; + visibility: hidden !important; +} +.affix { + position: fixed; +} +@-ms-viewport { + width: device-width; +} +.visible-xs, +.visible-sm, +.visible-md, +.visible-lg { + display: none !important; +} +.visible-xs-block, +.visible-xs-inline, +.visible-xs-inline-block, +.visible-sm-block, +.visible-sm-inline, +.visible-sm-inline-block, +.visible-md-block, +.visible-md-inline, +.visible-md-inline-block, +.visible-lg-block, +.visible-lg-inline, +.visible-lg-inline-block { + display: none !important; +} +@media (max-width: 767px) { + .visible-xs { + display: block !important; + } + table.visible-xs { + display: table; + } + tr.visible-xs { + display: table-row !important; + } + th.visible-xs, + td.visible-xs { + display: table-cell !important; + } +} +@media (max-width: 767px) { + .visible-xs-block { + display: block !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline { + display: inline !important; + } +} +@media (max-width: 767px) { + .visible-xs-inline-block { + display: inline-block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm { + display: block !important; + } + table.visible-sm { + display: table; + } + tr.visible-sm { + display: table-row !important; + } + th.visible-sm, + td.visible-sm { + display: table-cell !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-block { + display: block !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline { + display: inline !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .visible-sm-inline-block { + display: inline-block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md { + display: block !important; + } + table.visible-md { + display: table; + } + tr.visible-md { + display: table-row !important; + } + th.visible-md, + td.visible-md { + display: table-cell !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-block { + display: block !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline { + display: inline !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .visible-md-inline-block { + display: inline-block !important; + } +} +@media (min-width: 1200px) { + .visible-lg { + display: block !important; + } + table.visible-lg { + display: table; + } + tr.visible-lg { + display: table-row !important; + } + th.visible-lg, + td.visible-lg { + display: table-cell !important; + } +} +@media (min-width: 1200px) { + .visible-lg-block { + display: block !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline { + display: inline !important; + } +} +@media (min-width: 1200px) { + .visible-lg-inline-block { + display: inline-block !important; + } +} +@media (max-width: 767px) { + .hidden-xs { + display: none !important; + } +} +@media (min-width: 768px) and (max-width: 991px) { + .hidden-sm { + display: none !important; + } +} +@media (min-width: 992px) and (max-width: 1199px) { + .hidden-md { + display: none !important; + } +} +@media (min-width: 1200px) { + .hidden-lg { + display: none !important; + } +} +.visible-print { + display: none !important; +} +@media print { + .visible-print { + display: block !important; + } + table.visible-print { + display: table; + } + tr.visible-print { + display: table-row !important; + } + th.visible-print, + td.visible-print { + display: table-cell !important; + } +} +.visible-print-block { + display: none !important; +} +@media print { + .visible-print-block { + display: block !important; + } +} +.visible-print-inline { + display: none !important; +} +@media print { + .visible-print-inline { + display: inline !important; + } +} +.visible-print-inline-block { + display: none !important; +} +@media print { + .visible-print-inline-block { + display: inline-block !important; + } +} +@media print { + .hidden-print { + display: none !important; + } +} +/*# sourceMappingURL=bootstrap.css.map */ diff --git a/css/bootstrap.min.css b/css/bootstrap.min.css new file mode 100644 index 000000000..28f154dec --- /dev/null +++ b/css/bootstrap.min.css @@ -0,0 +1,5 @@ +/*! + * Bootstrap v3.3.2 (http://getbootstrap.com) + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + *//*! normalize.css v3.0.2 | MIT License | git.io/normalize */html{font-family:sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,hgroup,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block;vertical-align:baseline}audio:not([controls]){display:none;height:0}[hidden],template{display:none}a{background-color:transparent}a:active,a:hover{outline:0}abbr[title]{border-bottom:1px dotted}b,strong{font-weight:700}dfn{font-style:italic}h1{margin:.67em 0;font-size:2em}mark{color:#000;background:#ff0}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sup{top:-.5em}sub{bottom:-.25em}img{border:0}svg:not(:root){overflow:hidden}figure{margin:1em 40px}hr{height:0;-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box}pre{overflow:auto}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}button,input,optgroup,select,textarea{margin:0;font:inherit;color:inherit}button{overflow:visible}button,select{text-transform:none}button,html input[type=button],input[type=reset],input[type=submit]{-webkit-appearance:button;cursor:pointer}button[disabled],html input[disabled]{cursor:default}button::-moz-focus-inner,input::-moz-focus-inner{padding:0;border:0}input{line-height:normal}input[type=checkbox],input[type=radio]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box;padding:0}input[type=number]::-webkit-inner-spin-button,input[type=number]::-webkit-outer-spin-button{height:auto}input[type=search]{-webkit-box-sizing:content-box;-moz-box-sizing:content-box;box-sizing:content-box;-webkit-appearance:textfield}input[type=search]::-webkit-search-cancel-button,input[type=search]::-webkit-search-decoration{-webkit-appearance:none}fieldset{padding:.35em .625em .75em;margin:0 2px;border:1px solid silver}legend{padding:0;border:0}textarea{overflow:auto}optgroup{font-weight:700}table{border-spacing:0;border-collapse:collapse}td,th{padding:0}/*! Source: https://github.com/h5bp/html5-boilerplate/blob/master/src/css/main.css */@media print{*,:after,:before{color:#000!important;text-shadow:none!important;background:0 0!important;-webkit-box-shadow:none!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href) ")"}abbr[title]:after{content:" (" attr(title) ")"}a[href^="javascript:"]:after,a[href^="#"]:after{content:""}blockquote,pre{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}img{max-width:100%!important}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}select{background:#fff!important}.navbar{display:none}.btn>.caret,.dropup>.btn>.caret{border-top-color:#000!important}.label{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #ddd!important}}@font-face{font-family:'Glyphicons Halflings';src:url(../fonts/glyphicons-halflings-regular.eot);src:url(../fonts/glyphicons-halflings-regular.eot?#iefix) format('embedded-opentype'),url(../fonts/glyphicons-halflings-regular.woff2) format('woff2'),url(../fonts/glyphicons-halflings-regular.woff) format('woff'),url(../fonts/glyphicons-halflings-regular.ttf) format('truetype'),url(../fonts/glyphicons-halflings-regular.svg#glyphicons_halflingsregular) format('svg')}.glyphicon{position:relative;top:1px;display:inline-block;font-family:'Glyphicons Halflings';font-style:normal;font-weight:400;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.glyphicon-asterisk:before{content:"\2a"}.glyphicon-plus:before{content:"\2b"}.glyphicon-eur:before,.glyphicon-euro:before{content:"\20ac"}.glyphicon-minus:before{content:"\2212"}.glyphicon-cloud:before{content:"\2601"}.glyphicon-envelope:before{content:"\2709"}.glyphicon-pencil:before{content:"\270f"}.glyphicon-glass:before{content:"\e001"}.glyphicon-music:before{content:"\e002"}.glyphicon-search:before{content:"\e003"}.glyphicon-heart:before{content:"\e005"}.glyphicon-star:before{content:"\e006"}.glyphicon-star-empty:before{content:"\e007"}.glyphicon-user:before{content:"\e008"}.glyphicon-film:before{content:"\e009"}.glyphicon-th-large:before{content:"\e010"}.glyphicon-th:before{content:"\e011"}.glyphicon-th-list:before{content:"\e012"}.glyphicon-ok:before{content:"\e013"}.glyphicon-remove:before{content:"\e014"}.glyphicon-zoom-in:before{content:"\e015"}.glyphicon-zoom-out:before{content:"\e016"}.glyphicon-off:before{content:"\e017"}.glyphicon-signal:before{content:"\e018"}.glyphicon-cog:before{content:"\e019"}.glyphicon-trash:before{content:"\e020"}.glyphicon-home:before{content:"\e021"}.glyphicon-file:before{content:"\e022"}.glyphicon-time:before{content:"\e023"}.glyphicon-road:before{content:"\e024"}.glyphicon-download-alt:before{content:"\e025"}.glyphicon-download:before{content:"\e026"}.glyphicon-upload:before{content:"\e027"}.glyphicon-inbox:before{content:"\e028"}.glyphicon-play-circle:before{content:"\e029"}.glyphicon-repeat:before{content:"\e030"}.glyphicon-refresh:before{content:"\e031"}.glyphicon-list-alt:before{content:"\e032"}.glyphicon-lock:before{content:"\e033"}.glyphicon-flag:before{content:"\e034"}.glyphicon-headphones:before{content:"\e035"}.glyphicon-volume-off:before{content:"\e036"}.glyphicon-volume-down:before{content:"\e037"}.glyphicon-volume-up:before{content:"\e038"}.glyphicon-qrcode:before{content:"\e039"}.glyphicon-barcode:before{content:"\e040"}.glyphicon-tag:before{content:"\e041"}.glyphicon-tags:before{content:"\e042"}.glyphicon-book:before{content:"\e043"}.glyphicon-bookmark:before{content:"\e044"}.glyphicon-print:before{content:"\e045"}.glyphicon-camera:before{content:"\e046"}.glyphicon-font:before{content:"\e047"}.glyphicon-bold:before{content:"\e048"}.glyphicon-italic:before{content:"\e049"}.glyphicon-text-height:before{content:"\e050"}.glyphicon-text-width:before{content:"\e051"}.glyphicon-align-left:before{content:"\e052"}.glyphicon-align-center:before{content:"\e053"}.glyphicon-align-right:before{content:"\e054"}.glyphicon-align-justify:before{content:"\e055"}.glyphicon-list:before{content:"\e056"}.glyphicon-indent-left:before{content:"\e057"}.glyphicon-indent-right:before{content:"\e058"}.glyphicon-facetime-video:before{content:"\e059"}.glyphicon-picture:before{content:"\e060"}.glyphicon-map-marker:before{content:"\e062"}.glyphicon-adjust:before{content:"\e063"}.glyphicon-tint:before{content:"\e064"}.glyphicon-edit:before{content:"\e065"}.glyphicon-share:before{content:"\e066"}.glyphicon-check:before{content:"\e067"}.glyphicon-move:before{content:"\e068"}.glyphicon-step-backward:before{content:"\e069"}.glyphicon-fast-backward:before{content:"\e070"}.glyphicon-backward:before{content:"\e071"}.glyphicon-play:before{content:"\e072"}.glyphicon-pause:before{content:"\e073"}.glyphicon-stop:before{content:"\e074"}.glyphicon-forward:before{content:"\e075"}.glyphicon-fast-forward:before{content:"\e076"}.glyphicon-step-forward:before{content:"\e077"}.glyphicon-eject:before{content:"\e078"}.glyphicon-chevron-left:before{content:"\e079"}.glyphicon-chevron-right:before{content:"\e080"}.glyphicon-plus-sign:before{content:"\e081"}.glyphicon-minus-sign:before{content:"\e082"}.glyphicon-remove-sign:before{content:"\e083"}.glyphicon-ok-sign:before{content:"\e084"}.glyphicon-question-sign:before{content:"\e085"}.glyphicon-info-sign:before{content:"\e086"}.glyphicon-screenshot:before{content:"\e087"}.glyphicon-remove-circle:before{content:"\e088"}.glyphicon-ok-circle:before{content:"\e089"}.glyphicon-ban-circle:before{content:"\e090"}.glyphicon-arrow-left:before{content:"\e091"}.glyphicon-arrow-right:before{content:"\e092"}.glyphicon-arrow-up:before{content:"\e093"}.glyphicon-arrow-down:before{content:"\e094"}.glyphicon-share-alt:before{content:"\e095"}.glyphicon-resize-full:before{content:"\e096"}.glyphicon-resize-small:before{content:"\e097"}.glyphicon-exclamation-sign:before{content:"\e101"}.glyphicon-gift:before{content:"\e102"}.glyphicon-leaf:before{content:"\e103"}.glyphicon-fire:before{content:"\e104"}.glyphicon-eye-open:before{content:"\e105"}.glyphicon-eye-close:before{content:"\e106"}.glyphicon-warning-sign:before{content:"\e107"}.glyphicon-plane:before{content:"\e108"}.glyphicon-calendar:before{content:"\e109"}.glyphicon-random:before{content:"\e110"}.glyphicon-comment:before{content:"\e111"}.glyphicon-magnet:before{content:"\e112"}.glyphicon-chevron-up:before{content:"\e113"}.glyphicon-chevron-down:before{content:"\e114"}.glyphicon-retweet:before{content:"\e115"}.glyphicon-shopping-cart:before{content:"\e116"}.glyphicon-folder-close:before{content:"\e117"}.glyphicon-folder-open:before{content:"\e118"}.glyphicon-resize-vertical:before{content:"\e119"}.glyphicon-resize-horizontal:before{content:"\e120"}.glyphicon-hdd:before{content:"\e121"}.glyphicon-bullhorn:before{content:"\e122"}.glyphicon-bell:before{content:"\e123"}.glyphicon-certificate:before{content:"\e124"}.glyphicon-thumbs-up:before{content:"\e125"}.glyphicon-thumbs-down:before{content:"\e126"}.glyphicon-hand-right:before{content:"\e127"}.glyphicon-hand-left:before{content:"\e128"}.glyphicon-hand-up:before{content:"\e129"}.glyphicon-hand-down:before{content:"\e130"}.glyphicon-circle-arrow-right:before{content:"\e131"}.glyphicon-circle-arrow-left:before{content:"\e132"}.glyphicon-circle-arrow-up:before{content:"\e133"}.glyphicon-circle-arrow-down:before{content:"\e134"}.glyphicon-globe:before{content:"\e135"}.glyphicon-wrench:before{content:"\e136"}.glyphicon-tasks:before{content:"\e137"}.glyphicon-filter:before{content:"\e138"}.glyphicon-briefcase:before{content:"\e139"}.glyphicon-fullscreen:before{content:"\e140"}.glyphicon-dashboard:before{content:"\e141"}.glyphicon-paperclip:before{content:"\e142"}.glyphicon-heart-empty:before{content:"\e143"}.glyphicon-link:before{content:"\e144"}.glyphicon-phone:before{content:"\e145"}.glyphicon-pushpin:before{content:"\e146"}.glyphicon-usd:before{content:"\e148"}.glyphicon-gbp:before{content:"\e149"}.glyphicon-sort:before{content:"\e150"}.glyphicon-sort-by-alphabet:before{content:"\e151"}.glyphicon-sort-by-alphabet-alt:before{content:"\e152"}.glyphicon-sort-by-order:before{content:"\e153"}.glyphicon-sort-by-order-alt:before{content:"\e154"}.glyphicon-sort-by-attributes:before{content:"\e155"}.glyphicon-sort-by-attributes-alt:before{content:"\e156"}.glyphicon-unchecked:before{content:"\e157"}.glyphicon-expand:before{content:"\e158"}.glyphicon-collapse-down:before{content:"\e159"}.glyphicon-collapse-up:before{content:"\e160"}.glyphicon-log-in:before{content:"\e161"}.glyphicon-flash:before{content:"\e162"}.glyphicon-log-out:before{content:"\e163"}.glyphicon-new-window:before{content:"\e164"}.glyphicon-record:before{content:"\e165"}.glyphicon-save:before{content:"\e166"}.glyphicon-open:before{content:"\e167"}.glyphicon-saved:before{content:"\e168"}.glyphicon-import:before{content:"\e169"}.glyphicon-export:before{content:"\e170"}.glyphicon-send:before{content:"\e171"}.glyphicon-floppy-disk:before{content:"\e172"}.glyphicon-floppy-saved:before{content:"\e173"}.glyphicon-floppy-remove:before{content:"\e174"}.glyphicon-floppy-save:before{content:"\e175"}.glyphicon-floppy-open:before{content:"\e176"}.glyphicon-credit-card:before{content:"\e177"}.glyphicon-transfer:before{content:"\e178"}.glyphicon-cutlery:before{content:"\e179"}.glyphicon-header:before{content:"\e180"}.glyphicon-compressed:before{content:"\e181"}.glyphicon-earphone:before{content:"\e182"}.glyphicon-phone-alt:before{content:"\e183"}.glyphicon-tower:before{content:"\e184"}.glyphicon-stats:before{content:"\e185"}.glyphicon-sd-video:before{content:"\e186"}.glyphicon-hd-video:before{content:"\e187"}.glyphicon-subtitles:before{content:"\e188"}.glyphicon-sound-stereo:before{content:"\e189"}.glyphicon-sound-dolby:before{content:"\e190"}.glyphicon-sound-5-1:before{content:"\e191"}.glyphicon-sound-6-1:before{content:"\e192"}.glyphicon-sound-7-1:before{content:"\e193"}.glyphicon-copyright-mark:before{content:"\e194"}.glyphicon-registration-mark:before{content:"\e195"}.glyphicon-cloud-download:before{content:"\e197"}.glyphicon-cloud-upload:before{content:"\e198"}.glyphicon-tree-conifer:before{content:"\e199"}.glyphicon-tree-deciduous:before{content:"\e200"}.glyphicon-cd:before{content:"\e201"}.glyphicon-save-file:before{content:"\e202"}.glyphicon-open-file:before{content:"\e203"}.glyphicon-level-up:before{content:"\e204"}.glyphicon-copy:before{content:"\e205"}.glyphicon-paste:before{content:"\e206"}.glyphicon-alert:before{content:"\e209"}.glyphicon-equalizer:before{content:"\e210"}.glyphicon-king:before{content:"\e211"}.glyphicon-queen:before{content:"\e212"}.glyphicon-pawn:before{content:"\e213"}.glyphicon-bishop:before{content:"\e214"}.glyphicon-knight:before{content:"\e215"}.glyphicon-baby-formula:before{content:"\e216"}.glyphicon-tent:before{content:"\26fa"}.glyphicon-blackboard:before{content:"\e218"}.glyphicon-bed:before{content:"\e219"}.glyphicon-apple:before{content:"\f8ff"}.glyphicon-erase:before{content:"\e221"}.glyphicon-hourglass:before{content:"\231b"}.glyphicon-lamp:before{content:"\e223"}.glyphicon-duplicate:before{content:"\e224"}.glyphicon-piggy-bank:before{content:"\e225"}.glyphicon-scissors:before{content:"\e226"}.glyphicon-bitcoin:before{content:"\e227"}.glyphicon-yen:before{content:"\00a5"}.glyphicon-ruble:before{content:"\20bd"}.glyphicon-scale:before{content:"\e230"}.glyphicon-ice-lolly:before{content:"\e231"}.glyphicon-ice-lolly-tasted:before{content:"\e232"}.glyphicon-education:before{content:"\e233"}.glyphicon-option-horizontal:before{content:"\e234"}.glyphicon-option-vertical:before{content:"\e235"}.glyphicon-menu-hamburger:before{content:"\e236"}.glyphicon-modal-window:before{content:"\e237"}.glyphicon-oil:before{content:"\e238"}.glyphicon-grain:before{content:"\e239"}.glyphicon-sunglasses:before{content:"\e240"}.glyphicon-text-size:before{content:"\e241"}.glyphicon-text-color:before{content:"\e242"}.glyphicon-text-background:before{content:"\e243"}.glyphicon-object-align-top:before{content:"\e244"}.glyphicon-object-align-bottom:before{content:"\e245"}.glyphicon-object-align-horizontal:before{content:"\e246"}.glyphicon-object-align-left:before{content:"\e247"}.glyphicon-object-align-vertical:before{content:"\e248"}.glyphicon-object-align-right:before{content:"\e249"}.glyphicon-triangle-right:before{content:"\e250"}.glyphicon-triangle-left:before{content:"\e251"}.glyphicon-triangle-bottom:before{content:"\e252"}.glyphicon-triangle-top:before{content:"\e253"}.glyphicon-console:before{content:"\e254"}.glyphicon-superscript:before{content:"\e255"}.glyphicon-subscript:before{content:"\e256"}.glyphicon-menu-left:before{content:"\e257"}.glyphicon-menu-right:before{content:"\e258"}.glyphicon-menu-down:before{content:"\e259"}.glyphicon-menu-up:before{content:"\e260"}*{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}:after,:before{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}html{font-size:10px;-webkit-tap-highlight-color:rgba(0,0,0,0)}body{font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;line-height:1.42857143;color:#333;background-color:#fff}button,input,select,textarea{font-family:inherit;font-size:inherit;line-height:inherit}a{color:#337ab7;text-decoration:none}a:focus,a:hover{color:#23527c;text-decoration:underline}a:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}figure{margin:0}img{vertical-align:middle}.carousel-inner>.item>a>img,.carousel-inner>.item>img,.img-responsive,.thumbnail a>img,.thumbnail>img{display:block;max-width:100%;height:auto}.img-rounded{border-radius:6px}.img-thumbnail{display:inline-block;max-width:100%;height:auto;padding:4px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:all .2s ease-in-out;-o-transition:all .2s ease-in-out;transition:all .2s ease-in-out}.img-circle{border-radius:50%}hr{margin-top:20px;margin-bottom:20px;border:0;border-top:1px solid #eee}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{font-family:inherit;font-weight:500;line-height:1.1;color:inherit}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-weight:400;line-height:1;color:#777}.h1,.h2,.h3,h1,h2,h3{margin-top:20px;margin-bottom:10px}.h1 .small,.h1 small,.h2 .small,.h2 small,.h3 .small,.h3 small,h1 .small,h1 small,h2 .small,h2 small,h3 .small,h3 small{font-size:65%}.h4,.h5,.h6,h4,h5,h6{margin-top:10px;margin-bottom:10px}.h4 .small,.h4 small,.h5 .small,.h5 small,.h6 .small,.h6 small,h4 .small,h4 small,h5 .small,h5 small,h6 .small,h6 small{font-size:75%}.h1,h1{font-size:36px}.h2,h2{font-size:30px}.h3,h3{font-size:24px}.h4,h4{font-size:18px}.h5,h5{font-size:14px}.h6,h6{font-size:12px}p{margin:0 0 10px}.lead{margin-bottom:20px;font-size:16px;font-weight:300;line-height:1.4}@media (min-width:768px){.lead{font-size:21px}}.small,small{font-size:85%}.mark,mark{padding:.2em;background-color:#fcf8e3}.text-left{text-align:left}.text-right{text-align:right}.text-center{text-align:center}.text-justify{text-align:justify}.text-nowrap{white-space:nowrap}.text-lowercase{text-transform:lowercase}.text-uppercase{text-transform:uppercase}.text-capitalize{text-transform:capitalize}.text-muted{color:#777}.text-primary{color:#337ab7}a.text-primary:hover{color:#286090}.text-success{color:#3c763d}a.text-success:hover{color:#2b542c}.text-info{color:#31708f}a.text-info:hover{color:#245269}.text-warning{color:#8a6d3b}a.text-warning:hover{color:#66512c}.text-danger{color:#a94442}a.text-danger:hover{color:#843534}.bg-primary{color:#fff;background-color:#337ab7}a.bg-primary:hover{background-color:#286090}.bg-success{background-color:#dff0d8}a.bg-success:hover{background-color:#c1e2b3}.bg-info{background-color:#d9edf7}a.bg-info:hover{background-color:#afd9ee}.bg-warning{background-color:#fcf8e3}a.bg-warning:hover{background-color:#f7ecb5}.bg-danger{background-color:#f2dede}a.bg-danger:hover{background-color:#e4b9b9}.page-header{padding-bottom:9px;margin:40px 0 20px;border-bottom:1px solid #eee}ol,ul{margin-top:0;margin-bottom:10px}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;margin-left:-5px;list-style:none}.list-inline>li{display:inline-block;padding-right:5px;padding-left:5px}dl{margin-top:0;margin-bottom:20px}dd,dt{line-height:1.42857143}dt{font-weight:700}dd{margin-left:0}@media (min-width:768px){.dl-horizontal dt{float:left;width:160px;overflow:hidden;clear:left;text-align:right;text-overflow:ellipsis;white-space:nowrap}.dl-horizontal dd{margin-left:180px}}abbr[data-original-title],abbr[title]{cursor:help;border-bottom:1px dotted #777}.initialism{font-size:90%;text-transform:uppercase}blockquote{padding:10px 20px;margin:0 0 20px;font-size:17.5px;border-left:5px solid #eee}blockquote ol:last-child,blockquote p:last-child,blockquote ul:last-child{margin-bottom:0}blockquote .small,blockquote footer,blockquote small{display:block;font-size:80%;line-height:1.42857143;color:#777}blockquote .small:before,blockquote footer:before,blockquote small:before{content:'\2014 \00A0'}.blockquote-reverse,blockquote.pull-right{padding-right:15px;padding-left:0;text-align:right;border-right:5px solid #eee;border-left:0}.blockquote-reverse .small:before,.blockquote-reverse footer:before,.blockquote-reverse small:before,blockquote.pull-right .small:before,blockquote.pull-right footer:before,blockquote.pull-right small:before{content:''}.blockquote-reverse .small:after,.blockquote-reverse footer:after,.blockquote-reverse small:after,blockquote.pull-right .small:after,blockquote.pull-right footer:after,blockquote.pull-right small:after{content:'\00A0 \2014'}address{margin-bottom:20px;font-style:normal;line-height:1.42857143}code,kbd,pre,samp{font-family:Menlo,Monaco,Consolas,"Courier New",monospace}code{padding:2px 4px;font-size:90%;color:#c7254e;background-color:#f9f2f4;border-radius:4px}kbd{padding:2px 4px;font-size:90%;color:#fff;background-color:#333;border-radius:3px;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.25);box-shadow:inset 0 -1px 0 rgba(0,0,0,.25)}kbd kbd{padding:0;font-size:100%;font-weight:700;-webkit-box-shadow:none;box-shadow:none}pre{display:block;padding:9.5px;margin:0 0 10px;font-size:13px;line-height:1.42857143;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border-radius:0}.pre-scrollable{max-height:340px;overflow-y:scroll}.container{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:768px){.container{width:750px}}@media (min-width:992px){.container{width:970px}}@media (min-width:1200px){.container{width:1170px}}.container-fluid{padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}.row{margin-right:-15px;margin-left:-15px}.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{position:relative;min-height:1px;padding-right:15px;padding-left:15px}.col-xs-1,.col-xs-10,.col-xs-11,.col-xs-12,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9{float:left}.col-xs-12{width:100%}.col-xs-11{width:91.66666667%}.col-xs-10{width:83.33333333%}.col-xs-9{width:75%}.col-xs-8{width:66.66666667%}.col-xs-7{width:58.33333333%}.col-xs-6{width:50%}.col-xs-5{width:41.66666667%}.col-xs-4{width:33.33333333%}.col-xs-3{width:25%}.col-xs-2{width:16.66666667%}.col-xs-1{width:8.33333333%}.col-xs-pull-12{right:100%}.col-xs-pull-11{right:91.66666667%}.col-xs-pull-10{right:83.33333333%}.col-xs-pull-9{right:75%}.col-xs-pull-8{right:66.66666667%}.col-xs-pull-7{right:58.33333333%}.col-xs-pull-6{right:50%}.col-xs-pull-5{right:41.66666667%}.col-xs-pull-4{right:33.33333333%}.col-xs-pull-3{right:25%}.col-xs-pull-2{right:16.66666667%}.col-xs-pull-1{right:8.33333333%}.col-xs-pull-0{right:auto}.col-xs-push-12{left:100%}.col-xs-push-11{left:91.66666667%}.col-xs-push-10{left:83.33333333%}.col-xs-push-9{left:75%}.col-xs-push-8{left:66.66666667%}.col-xs-push-7{left:58.33333333%}.col-xs-push-6{left:50%}.col-xs-push-5{left:41.66666667%}.col-xs-push-4{left:33.33333333%}.col-xs-push-3{left:25%}.col-xs-push-2{left:16.66666667%}.col-xs-push-1{left:8.33333333%}.col-xs-push-0{left:auto}.col-xs-offset-12{margin-left:100%}.col-xs-offset-11{margin-left:91.66666667%}.col-xs-offset-10{margin-left:83.33333333%}.col-xs-offset-9{margin-left:75%}.col-xs-offset-8{margin-left:66.66666667%}.col-xs-offset-7{margin-left:58.33333333%}.col-xs-offset-6{margin-left:50%}.col-xs-offset-5{margin-left:41.66666667%}.col-xs-offset-4{margin-left:33.33333333%}.col-xs-offset-3{margin-left:25%}.col-xs-offset-2{margin-left:16.66666667%}.col-xs-offset-1{margin-left:8.33333333%}.col-xs-offset-0{margin-left:0}@media (min-width:768px){.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9{float:left}.col-sm-12{width:100%}.col-sm-11{width:91.66666667%}.col-sm-10{width:83.33333333%}.col-sm-9{width:75%}.col-sm-8{width:66.66666667%}.col-sm-7{width:58.33333333%}.col-sm-6{width:50%}.col-sm-5{width:41.66666667%}.col-sm-4{width:33.33333333%}.col-sm-3{width:25%}.col-sm-2{width:16.66666667%}.col-sm-1{width:8.33333333%}.col-sm-pull-12{right:100%}.col-sm-pull-11{right:91.66666667%}.col-sm-pull-10{right:83.33333333%}.col-sm-pull-9{right:75%}.col-sm-pull-8{right:66.66666667%}.col-sm-pull-7{right:58.33333333%}.col-sm-pull-6{right:50%}.col-sm-pull-5{right:41.66666667%}.col-sm-pull-4{right:33.33333333%}.col-sm-pull-3{right:25%}.col-sm-pull-2{right:16.66666667%}.col-sm-pull-1{right:8.33333333%}.col-sm-pull-0{right:auto}.col-sm-push-12{left:100%}.col-sm-push-11{left:91.66666667%}.col-sm-push-10{left:83.33333333%}.col-sm-push-9{left:75%}.col-sm-push-8{left:66.66666667%}.col-sm-push-7{left:58.33333333%}.col-sm-push-6{left:50%}.col-sm-push-5{left:41.66666667%}.col-sm-push-4{left:33.33333333%}.col-sm-push-3{left:25%}.col-sm-push-2{left:16.66666667%}.col-sm-push-1{left:8.33333333%}.col-sm-push-0{left:auto}.col-sm-offset-12{margin-left:100%}.col-sm-offset-11{margin-left:91.66666667%}.col-sm-offset-10{margin-left:83.33333333%}.col-sm-offset-9{margin-left:75%}.col-sm-offset-8{margin-left:66.66666667%}.col-sm-offset-7{margin-left:58.33333333%}.col-sm-offset-6{margin-left:50%}.col-sm-offset-5{margin-left:41.66666667%}.col-sm-offset-4{margin-left:33.33333333%}.col-sm-offset-3{margin-left:25%}.col-sm-offset-2{margin-left:16.66666667%}.col-sm-offset-1{margin-left:8.33333333%}.col-sm-offset-0{margin-left:0}}@media (min-width:992px){.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9{float:left}.col-md-12{width:100%}.col-md-11{width:91.66666667%}.col-md-10{width:83.33333333%}.col-md-9{width:75%}.col-md-8{width:66.66666667%}.col-md-7{width:58.33333333%}.col-md-6{width:50%}.col-md-5{width:41.66666667%}.col-md-4{width:33.33333333%}.col-md-3{width:25%}.col-md-2{width:16.66666667%}.col-md-1{width:8.33333333%}.col-md-pull-12{right:100%}.col-md-pull-11{right:91.66666667%}.col-md-pull-10{right:83.33333333%}.col-md-pull-9{right:75%}.col-md-pull-8{right:66.66666667%}.col-md-pull-7{right:58.33333333%}.col-md-pull-6{right:50%}.col-md-pull-5{right:41.66666667%}.col-md-pull-4{right:33.33333333%}.col-md-pull-3{right:25%}.col-md-pull-2{right:16.66666667%}.col-md-pull-1{right:8.33333333%}.col-md-pull-0{right:auto}.col-md-push-12{left:100%}.col-md-push-11{left:91.66666667%}.col-md-push-10{left:83.33333333%}.col-md-push-9{left:75%}.col-md-push-8{left:66.66666667%}.col-md-push-7{left:58.33333333%}.col-md-push-6{left:50%}.col-md-push-5{left:41.66666667%}.col-md-push-4{left:33.33333333%}.col-md-push-3{left:25%}.col-md-push-2{left:16.66666667%}.col-md-push-1{left:8.33333333%}.col-md-push-0{left:auto}.col-md-offset-12{margin-left:100%}.col-md-offset-11{margin-left:91.66666667%}.col-md-offset-10{margin-left:83.33333333%}.col-md-offset-9{margin-left:75%}.col-md-offset-8{margin-left:66.66666667%}.col-md-offset-7{margin-left:58.33333333%}.col-md-offset-6{margin-left:50%}.col-md-offset-5{margin-left:41.66666667%}.col-md-offset-4{margin-left:33.33333333%}.col-md-offset-3{margin-left:25%}.col-md-offset-2{margin-left:16.66666667%}.col-md-offset-1{margin-left:8.33333333%}.col-md-offset-0{margin-left:0}}@media (min-width:1200px){.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9{float:left}.col-lg-12{width:100%}.col-lg-11{width:91.66666667%}.col-lg-10{width:83.33333333%}.col-lg-9{width:75%}.col-lg-8{width:66.66666667%}.col-lg-7{width:58.33333333%}.col-lg-6{width:50%}.col-lg-5{width:41.66666667%}.col-lg-4{width:33.33333333%}.col-lg-3{width:25%}.col-lg-2{width:16.66666667%}.col-lg-1{width:8.33333333%}.col-lg-pull-12{right:100%}.col-lg-pull-11{right:91.66666667%}.col-lg-pull-10{right:83.33333333%}.col-lg-pull-9{right:75%}.col-lg-pull-8{right:66.66666667%}.col-lg-pull-7{right:58.33333333%}.col-lg-pull-6{right:50%}.col-lg-pull-5{right:41.66666667%}.col-lg-pull-4{right:33.33333333%}.col-lg-pull-3{right:25%}.col-lg-pull-2{right:16.66666667%}.col-lg-pull-1{right:8.33333333%}.col-lg-pull-0{right:auto}.col-lg-push-12{left:100%}.col-lg-push-11{left:91.66666667%}.col-lg-push-10{left:83.33333333%}.col-lg-push-9{left:75%}.col-lg-push-8{left:66.66666667%}.col-lg-push-7{left:58.33333333%}.col-lg-push-6{left:50%}.col-lg-push-5{left:41.66666667%}.col-lg-push-4{left:33.33333333%}.col-lg-push-3{left:25%}.col-lg-push-2{left:16.66666667%}.col-lg-push-1{left:8.33333333%}.col-lg-push-0{left:auto}.col-lg-offset-12{margin-left:100%}.col-lg-offset-11{margin-left:91.66666667%}.col-lg-offset-10{margin-left:83.33333333%}.col-lg-offset-9{margin-left:75%}.col-lg-offset-8{margin-left:66.66666667%}.col-lg-offset-7{margin-left:58.33333333%}.col-lg-offset-6{margin-left:50%}.col-lg-offset-5{margin-left:41.66666667%}.col-lg-offset-4{margin-left:33.33333333%}.col-lg-offset-3{margin-left:25%}.col-lg-offset-2{margin-left:16.66666667%}.col-lg-offset-1{margin-left:8.33333333%}.col-lg-offset-0{margin-left:0}}table{background-color:transparent}caption{padding-top:8px;padding-bottom:8px;color:#777;text-align:left}th{text-align:left}.table{width:100%;max-width:100%;margin-bottom:20px}.table>tbody>tr>td,.table>tbody>tr>th,.table>tfoot>tr>td,.table>tfoot>tr>th,.table>thead>tr>td,.table>thead>tr>th{padding:8px;line-height:1.42857143;vertical-align:top;border-top:1px solid #ddd}.table>thead>tr>th{vertical-align:bottom;border-bottom:2px solid #ddd}.table>caption+thead>tr:first-child>td,.table>caption+thead>tr:first-child>th,.table>colgroup+thead>tr:first-child>td,.table>colgroup+thead>tr:first-child>th,.table>thead:first-child>tr:first-child>td,.table>thead:first-child>tr:first-child>th{border-top:0}.table>tbody+tbody{border-top:2px solid #ddd}.table .table{background-color:#fff}.table-condensed>tbody>tr>td,.table-condensed>tbody>tr>th,.table-condensed>tfoot>tr>td,.table-condensed>tfoot>tr>th,.table-condensed>thead>tr>td,.table-condensed>thead>tr>th{padding:5px}.table-bordered{border:1px solid #ddd}.table-bordered>tbody>tr>td,.table-bordered>tbody>tr>th,.table-bordered>tfoot>tr>td,.table-bordered>tfoot>tr>th,.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border:1px solid #ddd}.table-bordered>thead>tr>td,.table-bordered>thead>tr>th{border-bottom-width:2px}.table-striped>tbody>tr:nth-of-type(odd){background-color:#f9f9f9}.table-hover>tbody>tr:hover{background-color:#f5f5f5}table col[class*=col-]{position:static;display:table-column;float:none}table td[class*=col-],table th[class*=col-]{position:static;display:table-cell;float:none}.table>tbody>tr.active>td,.table>tbody>tr.active>th,.table>tbody>tr>td.active,.table>tbody>tr>th.active,.table>tfoot>tr.active>td,.table>tfoot>tr.active>th,.table>tfoot>tr>td.active,.table>tfoot>tr>th.active,.table>thead>tr.active>td,.table>thead>tr.active>th,.table>thead>tr>td.active,.table>thead>tr>th.active{background-color:#f5f5f5}.table-hover>tbody>tr.active:hover>td,.table-hover>tbody>tr.active:hover>th,.table-hover>tbody>tr:hover>.active,.table-hover>tbody>tr>td.active:hover,.table-hover>tbody>tr>th.active:hover{background-color:#e8e8e8}.table>tbody>tr.success>td,.table>tbody>tr.success>th,.table>tbody>tr>td.success,.table>tbody>tr>th.success,.table>tfoot>tr.success>td,.table>tfoot>tr.success>th,.table>tfoot>tr>td.success,.table>tfoot>tr>th.success,.table>thead>tr.success>td,.table>thead>tr.success>th,.table>thead>tr>td.success,.table>thead>tr>th.success{background-color:#dff0d8}.table-hover>tbody>tr.success:hover>td,.table-hover>tbody>tr.success:hover>th,.table-hover>tbody>tr:hover>.success,.table-hover>tbody>tr>td.success:hover,.table-hover>tbody>tr>th.success:hover{background-color:#d0e9c6}.table>tbody>tr.info>td,.table>tbody>tr.info>th,.table>tbody>tr>td.info,.table>tbody>tr>th.info,.table>tfoot>tr.info>td,.table>tfoot>tr.info>th,.table>tfoot>tr>td.info,.table>tfoot>tr>th.info,.table>thead>tr.info>td,.table>thead>tr.info>th,.table>thead>tr>td.info,.table>thead>tr>th.info{background-color:#d9edf7}.table-hover>tbody>tr.info:hover>td,.table-hover>tbody>tr.info:hover>th,.table-hover>tbody>tr:hover>.info,.table-hover>tbody>tr>td.info:hover,.table-hover>tbody>tr>th.info:hover{background-color:#c4e3f3}.table>tbody>tr.warning>td,.table>tbody>tr.warning>th,.table>tbody>tr>td.warning,.table>tbody>tr>th.warning,.table>tfoot>tr.warning>td,.table>tfoot>tr.warning>th,.table>tfoot>tr>td.warning,.table>tfoot>tr>th.warning,.table>thead>tr.warning>td,.table>thead>tr.warning>th,.table>thead>tr>td.warning,.table>thead>tr>th.warning{background-color:#fcf8e3}.table-hover>tbody>tr.warning:hover>td,.table-hover>tbody>tr.warning:hover>th,.table-hover>tbody>tr:hover>.warning,.table-hover>tbody>tr>td.warning:hover,.table-hover>tbody>tr>th.warning:hover{background-color:#faf2cc}.table>tbody>tr.danger>td,.table>tbody>tr.danger>th,.table>tbody>tr>td.danger,.table>tbody>tr>th.danger,.table>tfoot>tr.danger>td,.table>tfoot>tr.danger>th,.table>tfoot>tr>td.danger,.table>tfoot>tr>th.danger,.table>thead>tr.danger>td,.table>thead>tr.danger>th,.table>thead>tr>td.danger,.table>thead>tr>th.danger{background-color:#f2dede}.table-hover>tbody>tr.danger:hover>td,.table-hover>tbody>tr.danger:hover>th,.table-hover>tbody>tr:hover>.danger,.table-hover>tbody>tr>td.danger:hover,.table-hover>tbody>tr>th.danger:hover{background-color:#ebcccc}.table-responsive{min-height:.01%;overflow-x:auto}@media screen and (max-width:767px){.table-responsive{width:100%;margin-bottom:15px;overflow-y:hidden;-ms-overflow-style:-ms-autohiding-scrollbar;border:1px solid #ddd}.table-responsive>.table{margin-bottom:0}.table-responsive>.table>tbody>tr>td,.table-responsive>.table>tbody>tr>th,.table-responsive>.table>tfoot>tr>td,.table-responsive>.table>tfoot>tr>th,.table-responsive>.table>thead>tr>td,.table-responsive>.table>thead>tr>th{white-space:nowrap}.table-responsive>.table-bordered{border:0}.table-responsive>.table-bordered>tbody>tr>td:first-child,.table-responsive>.table-bordered>tbody>tr>th:first-child,.table-responsive>.table-bordered>tfoot>tr>td:first-child,.table-responsive>.table-bordered>tfoot>tr>th:first-child,.table-responsive>.table-bordered>thead>tr>td:first-child,.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.table-responsive>.table-bordered>tbody>tr>td:last-child,.table-responsive>.table-bordered>tbody>tr>th:last-child,.table-responsive>.table-bordered>tfoot>tr>td:last-child,.table-responsive>.table-bordered>tfoot>tr>th:last-child,.table-responsive>.table-bordered>thead>tr>td:last-child,.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.table-responsive>.table-bordered>tbody>tr:last-child>td,.table-responsive>.table-bordered>tbody>tr:last-child>th,.table-responsive>.table-bordered>tfoot>tr:last-child>td,.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;margin-bottom:20px;font-size:21px;line-height:inherit;color:#333;border:0;border-bottom:1px solid #e5e5e5}label{display:inline-block;max-width:100%;margin-bottom:5px;font-weight:700}input[type=search]{-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}input[type=checkbox],input[type=radio]{margin:4px 0 0;margin-top:1px \9;line-height:normal}input[type=file]{display:block}input[type=range]{display:block;width:100%}select[multiple],select[size]{height:auto}input[type=file]:focus,input[type=checkbox]:focus,input[type=radio]:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}output{display:block;padding-top:7px;font-size:14px;line-height:1.42857143;color:#555}.form-control{display:block;width:100%;height:34px;padding:6px 12px;font-size:14px;line-height:1.42857143;color:#555;background-color:#fff;background-image:none;border:1px solid #ccc;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border-color ease-in-out .15s,-webkit-box-shadow ease-in-out .15s;-o-transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s;transition:border-color ease-in-out .15s,box-shadow ease-in-out .15s}.form-control:focus{border-color:#66afe9;outline:0;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6)}.form-control::-moz-placeholder{color:#999;opacity:1}.form-control:-ms-input-placeholder{color:#999}.form-control::-webkit-input-placeholder{color:#999}.form-control[disabled],.form-control[readonly],fieldset[disabled] .form-control{cursor:not-allowed;background-color:#eee;opacity:1}textarea.form-control{height:auto}input[type=search]{-webkit-appearance:none}@media screen and (-webkit-min-device-pixel-ratio:0){input[type=date],input[type=time],input[type=datetime-local],input[type=month]{line-height:34px}.input-group-sm input[type=date],.input-group-sm input[type=time],.input-group-sm input[type=datetime-local],.input-group-sm input[type=month],input[type=date].input-sm,input[type=time].input-sm,input[type=datetime-local].input-sm,input[type=month].input-sm{line-height:30px}.input-group-lg input[type=date],.input-group-lg input[type=time],.input-group-lg input[type=datetime-local],.input-group-lg input[type=month],input[type=date].input-lg,input[type=time].input-lg,input[type=datetime-local].input-lg,input[type=month].input-lg{line-height:46px}}.form-group{margin-bottom:15px}.checkbox,.radio{position:relative;display:block;margin-top:10px;margin-bottom:10px}.checkbox label,.radio label{min-height:20px;padding-left:20px;margin-bottom:0;font-weight:400;cursor:pointer}.checkbox input[type=checkbox],.checkbox-inline input[type=checkbox],.radio input[type=radio],.radio-inline input[type=radio]{position:absolute;margin-top:4px \9;margin-left:-20px}.checkbox+.checkbox,.radio+.radio{margin-top:-5px}.checkbox-inline,.radio-inline{display:inline-block;padding-left:20px;margin-bottom:0;font-weight:400;vertical-align:middle;cursor:pointer}.checkbox-inline+.checkbox-inline,.radio-inline+.radio-inline{margin-top:0;margin-left:10px}fieldset[disabled] input[type=checkbox],fieldset[disabled] input[type=radio],input[type=checkbox].disabled,input[type=checkbox][disabled],input[type=radio].disabled,input[type=radio][disabled]{cursor:not-allowed}.checkbox-inline.disabled,.radio-inline.disabled,fieldset[disabled] .checkbox-inline,fieldset[disabled] .radio-inline{cursor:not-allowed}.checkbox.disabled label,.radio.disabled label,fieldset[disabled] .checkbox label,fieldset[disabled] .radio label{cursor:not-allowed}.form-control-static{padding-top:7px;padding-bottom:7px;margin-bottom:0}.form-control-static.input-lg,.form-control-static.input-sm{padding-right:0;padding-left:0}.input-sm{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-sm{height:30px;line-height:30px}select[multiple].input-sm,textarea.input-sm{height:auto}.form-group-sm .form-control{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.form-group-sm .form-control{height:30px;line-height:30px}select[multiple].form-group-sm .form-control,textarea.form-group-sm .form-control{height:auto}.form-group-sm .form-control-static{height:30px;padding:5px 10px;font-size:12px;line-height:1.5}.input-lg{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-lg{height:46px;line-height:46px}select[multiple].input-lg,textarea.input-lg{height:auto}.form-group-lg .form-control{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.form-group-lg .form-control{height:46px;line-height:46px}select[multiple].form-group-lg .form-control,textarea.form-group-lg .form-control{height:auto}.form-group-lg .form-control-static{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333}.has-feedback{position:relative}.has-feedback .form-control{padding-right:42.5px}.form-control-feedback{position:absolute;top:0;right:0;z-index:2;display:block;width:34px;height:34px;line-height:34px;text-align:center;pointer-events:none}.input-lg+.form-control-feedback{width:46px;height:46px;line-height:46px}.input-sm+.form-control-feedback{width:30px;height:30px;line-height:30px}.has-success .checkbox,.has-success .checkbox-inline,.has-success .control-label,.has-success .help-block,.has-success .radio,.has-success .radio-inline,.has-success.checkbox label,.has-success.checkbox-inline label,.has-success.radio label,.has-success.radio-inline label{color:#3c763d}.has-success .form-control{border-color:#3c763d;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-success .form-control:focus{border-color:#2b542c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #67b168}.has-success .input-group-addon{color:#3c763d;background-color:#dff0d8;border-color:#3c763d}.has-success .form-control-feedback{color:#3c763d}.has-warning .checkbox,.has-warning .checkbox-inline,.has-warning .control-label,.has-warning .help-block,.has-warning .radio,.has-warning .radio-inline,.has-warning.checkbox label,.has-warning.checkbox-inline label,.has-warning.radio label,.has-warning.radio-inline label{color:#8a6d3b}.has-warning .form-control{border-color:#8a6d3b;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-warning .form-control:focus{border-color:#66512c;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #c0a16b}.has-warning .input-group-addon{color:#8a6d3b;background-color:#fcf8e3;border-color:#8a6d3b}.has-warning .form-control-feedback{color:#8a6d3b}.has-error .checkbox,.has-error .checkbox-inline,.has-error .control-label,.has-error .help-block,.has-error .radio,.has-error .radio-inline,.has-error.checkbox label,.has-error.checkbox-inline label,.has-error.radio label,.has-error.radio-inline label{color:#a94442}.has-error .form-control{border-color:#a94442;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075)}.has-error .form-control:focus{border-color:#843534;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483;box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 6px #ce8483}.has-error .input-group-addon{color:#a94442;background-color:#f2dede;border-color:#a94442}.has-error .form-control-feedback{color:#a94442}.has-feedback label~.form-control-feedback{top:25px}.has-feedback label.sr-only~.form-control-feedback{top:0}.help-block{display:block;margin-top:5px;margin-bottom:10px;color:#737373}@media (min-width:768px){.form-inline .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-static{display:inline-block}.form-inline .input-group{display:inline-table;vertical-align:middle}.form-inline .input-group .form-control,.form-inline .input-group .input-group-addon,.form-inline .input-group .input-group-btn{width:auto}.form-inline .input-group>.form-control{width:100%}.form-inline .control-label{margin-bottom:0;vertical-align:middle}.form-inline .checkbox,.form-inline .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.form-inline .checkbox label,.form-inline .radio label{padding-left:0}.form-inline .checkbox input[type=checkbox],.form-inline .radio input[type=radio]{position:relative;margin-left:0}.form-inline .has-feedback .form-control-feedback{top:0}}.form-horizontal .checkbox,.form-horizontal .checkbox-inline,.form-horizontal .radio,.form-horizontal .radio-inline{padding-top:7px;margin-top:0;margin-bottom:0}.form-horizontal .checkbox,.form-horizontal .radio{min-height:27px}.form-horizontal .form-group{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.form-horizontal .control-label{padding-top:7px;margin-bottom:0;text-align:right}}.form-horizontal .has-feedback .form-control-feedback{right:15px}@media (min-width:768px){.form-horizontal .form-group-lg .control-label{padding-top:14.33px}}@media (min-width:768px){.form-horizontal .form-group-sm .control-label{padding-top:6px}}.btn{display:inline-block;padding:6px 12px;margin-bottom:0;font-size:14px;font-weight:400;line-height:1.42857143;text-align:center;white-space:nowrap;vertical-align:middle;-ms-touch-action:manipulation;touch-action:manipulation;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-image:none;border:1px solid transparent;border-radius:4px}.btn.active.focus,.btn.active:focus,.btn.focus,.btn:active.focus,.btn:active:focus,.btn:focus{outline:thin dotted;outline:5px auto -webkit-focus-ring-color;outline-offset:-2px}.btn.focus,.btn:focus,.btn:hover{color:#333;text-decoration:none}.btn.active,.btn:active{background-image:none;outline:0;-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn.disabled,.btn[disabled],fieldset[disabled] .btn{pointer-events:none;cursor:not-allowed;filter:alpha(opacity=65);-webkit-box-shadow:none;box-shadow:none;opacity:.65}.btn-default{color:#333;background-color:#fff;border-color:#ccc}.btn-default.active,.btn-default.focus,.btn-default:active,.btn-default:focus,.btn-default:hover,.open>.dropdown-toggle.btn-default{color:#333;background-color:#e6e6e6;border-color:#adadad}.btn-default.active,.btn-default:active,.open>.dropdown-toggle.btn-default{background-image:none}.btn-default.disabled,.btn-default.disabled.active,.btn-default.disabled.focus,.btn-default.disabled:active,.btn-default.disabled:focus,.btn-default.disabled:hover,.btn-default[disabled],.btn-default[disabled].active,.btn-default[disabled].focus,.btn-default[disabled]:active,.btn-default[disabled]:focus,.btn-default[disabled]:hover,fieldset[disabled] .btn-default,fieldset[disabled] .btn-default.active,fieldset[disabled] .btn-default.focus,fieldset[disabled] .btn-default:active,fieldset[disabled] .btn-default:focus,fieldset[disabled] .btn-default:hover{background-color:#fff;border-color:#ccc}.btn-default .badge{color:#fff;background-color:#333}.btn-primary{color:#fff;background-color:#337ab7;border-color:#2e6da4}.btn-primary.active,.btn-primary.focus,.btn-primary:active,.btn-primary:focus,.btn-primary:hover,.open>.dropdown-toggle.btn-primary{color:#fff;background-color:#286090;border-color:#204d74}.btn-primary.active,.btn-primary:active,.open>.dropdown-toggle.btn-primary{background-image:none}.btn-primary.disabled,.btn-primary.disabled.active,.btn-primary.disabled.focus,.btn-primary.disabled:active,.btn-primary.disabled:focus,.btn-primary.disabled:hover,.btn-primary[disabled],.btn-primary[disabled].active,.btn-primary[disabled].focus,.btn-primary[disabled]:active,.btn-primary[disabled]:focus,.btn-primary[disabled]:hover,fieldset[disabled] .btn-primary,fieldset[disabled] .btn-primary.active,fieldset[disabled] .btn-primary.focus,fieldset[disabled] .btn-primary:active,fieldset[disabled] .btn-primary:focus,fieldset[disabled] .btn-primary:hover{background-color:#337ab7;border-color:#2e6da4}.btn-primary .badge{color:#337ab7;background-color:#fff}.btn-success{color:#fff;background-color:#5cb85c;border-color:#4cae4c}.btn-success.active,.btn-success.focus,.btn-success:active,.btn-success:focus,.btn-success:hover,.open>.dropdown-toggle.btn-success{color:#fff;background-color:#449d44;border-color:#398439}.btn-success.active,.btn-success:active,.open>.dropdown-toggle.btn-success{background-image:none}.btn-success.disabled,.btn-success.disabled.active,.btn-success.disabled.focus,.btn-success.disabled:active,.btn-success.disabled:focus,.btn-success.disabled:hover,.btn-success[disabled],.btn-success[disabled].active,.btn-success[disabled].focus,.btn-success[disabled]:active,.btn-success[disabled]:focus,.btn-success[disabled]:hover,fieldset[disabled] .btn-success,fieldset[disabled] .btn-success.active,fieldset[disabled] .btn-success.focus,fieldset[disabled] .btn-success:active,fieldset[disabled] .btn-success:focus,fieldset[disabled] .btn-success:hover{background-color:#5cb85c;border-color:#4cae4c}.btn-success .badge{color:#5cb85c;background-color:#fff}.btn-info{color:#fff;background-color:#5bc0de;border-color:#46b8da}.btn-info.active,.btn-info.focus,.btn-info:active,.btn-info:focus,.btn-info:hover,.open>.dropdown-toggle.btn-info{color:#fff;background-color:#31b0d5;border-color:#269abc}.btn-info.active,.btn-info:active,.open>.dropdown-toggle.btn-info{background-image:none}.btn-info.disabled,.btn-info.disabled.active,.btn-info.disabled.focus,.btn-info.disabled:active,.btn-info.disabled:focus,.btn-info.disabled:hover,.btn-info[disabled],.btn-info[disabled].active,.btn-info[disabled].focus,.btn-info[disabled]:active,.btn-info[disabled]:focus,.btn-info[disabled]:hover,fieldset[disabled] .btn-info,fieldset[disabled] .btn-info.active,fieldset[disabled] .btn-info.focus,fieldset[disabled] .btn-info:active,fieldset[disabled] .btn-info:focus,fieldset[disabled] .btn-info:hover{background-color:#5bc0de;border-color:#46b8da}.btn-info .badge{color:#5bc0de;background-color:#fff}.btn-warning{color:#fff;background-color:#f0ad4e;border-color:#eea236}.btn-warning.active,.btn-warning.focus,.btn-warning:active,.btn-warning:focus,.btn-warning:hover,.open>.dropdown-toggle.btn-warning{color:#fff;background-color:#ec971f;border-color:#d58512}.btn-warning.active,.btn-warning:active,.open>.dropdown-toggle.btn-warning{background-image:none}.btn-warning.disabled,.btn-warning.disabled.active,.btn-warning.disabled.focus,.btn-warning.disabled:active,.btn-warning.disabled:focus,.btn-warning.disabled:hover,.btn-warning[disabled],.btn-warning[disabled].active,.btn-warning[disabled].focus,.btn-warning[disabled]:active,.btn-warning[disabled]:focus,.btn-warning[disabled]:hover,fieldset[disabled] .btn-warning,fieldset[disabled] .btn-warning.active,fieldset[disabled] .btn-warning.focus,fieldset[disabled] .btn-warning:active,fieldset[disabled] .btn-warning:focus,fieldset[disabled] .btn-warning:hover{background-color:#f0ad4e;border-color:#eea236}.btn-warning .badge{color:#f0ad4e;background-color:#fff}.btn-danger{color:#fff;background-color:#d9534f;border-color:#d43f3a}.btn-danger.active,.btn-danger.focus,.btn-danger:active,.btn-danger:focus,.btn-danger:hover,.open>.dropdown-toggle.btn-danger{color:#fff;background-color:#c9302c;border-color:#ac2925}.btn-danger.active,.btn-danger:active,.open>.dropdown-toggle.btn-danger{background-image:none}.btn-danger.disabled,.btn-danger.disabled.active,.btn-danger.disabled.focus,.btn-danger.disabled:active,.btn-danger.disabled:focus,.btn-danger.disabled:hover,.btn-danger[disabled],.btn-danger[disabled].active,.btn-danger[disabled].focus,.btn-danger[disabled]:active,.btn-danger[disabled]:focus,.btn-danger[disabled]:hover,fieldset[disabled] .btn-danger,fieldset[disabled] .btn-danger.active,fieldset[disabled] .btn-danger.focus,fieldset[disabled] .btn-danger:active,fieldset[disabled] .btn-danger:focus,fieldset[disabled] .btn-danger:hover{background-color:#d9534f;border-color:#d43f3a}.btn-danger .badge{color:#d9534f;background-color:#fff}.btn-link{font-weight:400;color:#337ab7;border-radius:0}.btn-link,.btn-link.active,.btn-link:active,.btn-link[disabled],fieldset[disabled] .btn-link{background-color:transparent;-webkit-box-shadow:none;box-shadow:none}.btn-link,.btn-link:active,.btn-link:focus,.btn-link:hover{border-color:transparent}.btn-link:focus,.btn-link:hover{color:#23527c;text-decoration:underline;background-color:transparent}.btn-link[disabled]:focus,.btn-link[disabled]:hover,fieldset[disabled] .btn-link:focus,fieldset[disabled] .btn-link:hover{color:#777;text-decoration:none}.btn-group-lg>.btn,.btn-lg{padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}.btn-group-sm>.btn,.btn-sm{padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}.btn-group-xs>.btn,.btn-xs{padding:1px 5px;font-size:12px;line-height:1.5;border-radius:3px}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:5px}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{opacity:0;-webkit-transition:opacity .15s linear;-o-transition:opacity .15s linear;transition:opacity .15s linear}.fade.in{opacity:1}.collapse{display:none;visibility:hidden}.collapse.in{display:block;visibility:visible}tr.collapse.in{display:table-row}tbody.collapse.in{display:table-row-group}.collapsing{position:relative;height:0;overflow:hidden;-webkit-transition-timing-function:ease;-o-transition-timing-function:ease;transition-timing-function:ease;-webkit-transition-duration:.35s;-o-transition-duration:.35s;transition-duration:.35s;-webkit-transition-property:height,visibility;-o-transition-property:height,visibility;transition-property:height,visibility}.caret{display:inline-block;width:0;height:0;margin-left:2px;vertical-align:middle;border-top:4px solid;border-right:4px solid transparent;border-left:4px solid transparent}.dropdown,.dropup{position:relative}.dropdown-toggle:focus{outline:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:160px;padding:5px 0;margin:2px 0 0;font-size:14px;text-align:left;list-style:none;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.15);border-radius:4px;-webkit-box-shadow:0 6px 12px rgba(0,0,0,.175);box-shadow:0 6px 12px rgba(0,0,0,.175)}.dropdown-menu.pull-right{right:0;left:auto}.dropdown-menu .divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.dropdown-menu>li>a{display:block;padding:3px 20px;clear:both;font-weight:400;line-height:1.42857143;color:#333;white-space:nowrap}.dropdown-menu>li>a:focus,.dropdown-menu>li>a:hover{color:#262626;text-decoration:none;background-color:#f5f5f5}.dropdown-menu>.active>a,.dropdown-menu>.active>a:focus,.dropdown-menu>.active>a:hover{color:#fff;text-decoration:none;background-color:#337ab7;outline:0}.dropdown-menu>.disabled>a,.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{color:#777}.dropdown-menu>.disabled>a:focus,.dropdown-menu>.disabled>a:hover{text-decoration:none;cursor:not-allowed;background-color:transparent;background-image:none;filter:progid:DXImageTransform.Microsoft.gradient(enabled=false)}.open>.dropdown-menu{display:block}.open>a{outline:0}.dropdown-menu-right{right:0;left:auto}.dropdown-menu-left{right:auto;left:0}.dropdown-header{display:block;padding:3px 20px;font-size:12px;line-height:1.42857143;color:#777;white-space:nowrap}.dropdown-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:990}.pull-right>.dropdown-menu{right:0;left:auto}.dropup .caret,.navbar-fixed-bottom .dropdown .caret{content:"";border-top:0;border-bottom:4px solid}.dropup .dropdown-menu,.navbar-fixed-bottom .dropdown .dropdown-menu{top:auto;bottom:100%;margin-bottom:2px}@media (min-width:768px){.navbar-right .dropdown-menu{right:0;left:auto}.navbar-right .dropdown-menu-left{right:auto;left:0}}.btn-group,.btn-group-vertical{position:relative;display:inline-block;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;float:left}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:2}.btn-group .btn+.btn,.btn-group .btn+.btn-group,.btn-group .btn-group+.btn,.btn-group .btn-group+.btn-group{margin-left:-1px}.btn-toolbar{margin-left:-5px}.btn-toolbar .btn-group,.btn-toolbar .input-group{float:left}.btn-toolbar>.btn,.btn-toolbar>.btn-group,.btn-toolbar>.input-group{margin-left:5px}.btn-group>.btn:not(:first-child):not(:last-child):not(.dropdown-toggle){border-radius:0}.btn-group>.btn:first-child{margin-left:0}.btn-group>.btn:first-child:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn:last-child:not(:first-child),.btn-group>.dropdown-toggle:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group>.btn-group{float:left}.btn-group>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .dropdown-toggle:active,.btn-group.open .dropdown-toggle{outline:0}.btn-group>.btn+.dropdown-toggle{padding-right:8px;padding-left:8px}.btn-group>.btn-lg+.dropdown-toggle{padding-right:12px;padding-left:12px}.btn-group.open .dropdown-toggle{-webkit-box-shadow:inset 0 3px 5px rgba(0,0,0,.125);box-shadow:inset 0 3px 5px rgba(0,0,0,.125)}.btn-group.open .dropdown-toggle.btn-link{-webkit-box-shadow:none;box-shadow:none}.btn .caret{margin-left:0}.btn-lg .caret{border-width:5px 5px 0;border-bottom-width:0}.dropup .btn-lg .caret{border-width:0 5px 5px}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group,.btn-group-vertical>.btn-group>.btn{display:block;float:none;width:100%;max-width:100%}.btn-group-vertical>.btn-group>.btn{float:none}.btn-group-vertical>.btn+.btn,.btn-group-vertical>.btn+.btn-group,.btn-group-vertical>.btn-group+.btn,.btn-group-vertical>.btn-group+.btn-group{margin-top:-1px;margin-left:0}.btn-group-vertical>.btn:not(:first-child):not(:last-child){border-radius:0}.btn-group-vertical>.btn:first-child:not(:last-child){border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn:last-child:not(:first-child){border-top-left-radius:0;border-top-right-radius:0;border-bottom-left-radius:4px}.btn-group-vertical>.btn-group:not(:first-child):not(:last-child)>.btn{border-radius:0}.btn-group-vertical>.btn-group:first-child:not(:last-child)>.btn:last-child,.btn-group-vertical>.btn-group:first-child:not(:last-child)>.dropdown-toggle{border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:last-child:not(:first-child)>.btn:first-child{border-top-left-radius:0;border-top-right-radius:0}.btn-group-justified{display:table;width:100%;table-layout:fixed;border-collapse:separate}.btn-group-justified>.btn,.btn-group-justified>.btn-group{display:table-cell;float:none;width:1%}.btn-group-justified>.btn-group .btn{width:100%}.btn-group-justified>.btn-group .dropdown-menu{left:auto}[data-toggle=buttons]>.btn input[type=checkbox],[data-toggle=buttons]>.btn input[type=radio],[data-toggle=buttons]>.btn-group>.btn input[type=checkbox],[data-toggle=buttons]>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:table;border-collapse:separate}.input-group[class*=col-]{float:none;padding-right:0;padding-left:0}.input-group .form-control{position:relative;z-index:2;float:left;width:100%;margin-bottom:0}.input-group-lg>.form-control,.input-group-lg>.input-group-addon,.input-group-lg>.input-group-btn>.btn{height:46px;padding:10px 16px;font-size:18px;line-height:1.3333333;border-radius:6px}select.input-group-lg>.form-control,select.input-group-lg>.input-group-addon,select.input-group-lg>.input-group-btn>.btn{height:46px;line-height:46px}select[multiple].input-group-lg>.form-control,select[multiple].input-group-lg>.input-group-addon,select[multiple].input-group-lg>.input-group-btn>.btn,textarea.input-group-lg>.form-control,textarea.input-group-lg>.input-group-addon,textarea.input-group-lg>.input-group-btn>.btn{height:auto}.input-group-sm>.form-control,.input-group-sm>.input-group-addon,.input-group-sm>.input-group-btn>.btn{height:30px;padding:5px 10px;font-size:12px;line-height:1.5;border-radius:3px}select.input-group-sm>.form-control,select.input-group-sm>.input-group-addon,select.input-group-sm>.input-group-btn>.btn{height:30px;line-height:30px}select[multiple].input-group-sm>.form-control,select[multiple].input-group-sm>.input-group-addon,select[multiple].input-group-sm>.input-group-btn>.btn,textarea.input-group-sm>.form-control,textarea.input-group-sm>.input-group-addon,textarea.input-group-sm>.input-group-btn>.btn{height:auto}.input-group .form-control,.input-group-addon,.input-group-btn{display:table-cell}.input-group .form-control:not(:first-child):not(:last-child),.input-group-addon:not(:first-child):not(:last-child),.input-group-btn:not(:first-child):not(:last-child){border-radius:0}.input-group-addon,.input-group-btn{width:1%;white-space:nowrap;vertical-align:middle}.input-group-addon{padding:6px 12px;font-size:14px;font-weight:400;line-height:1;color:#555;text-align:center;background-color:#eee;border:1px solid #ccc;border-radius:4px}.input-group-addon.input-sm{padding:5px 10px;font-size:12px;border-radius:3px}.input-group-addon.input-lg{padding:10px 16px;font-size:18px;border-radius:6px}.input-group-addon input[type=checkbox],.input-group-addon input[type=radio]{margin-top:0}.input-group .form-control:first-child,.input-group-addon:first-child,.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group>.btn,.input-group-btn:first-child>.dropdown-toggle,.input-group-btn:last-child>.btn-group:not(:last-child)>.btn,.input-group-btn:last-child>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-addon:first-child{border-right:0}.input-group .form-control:last-child,.input-group-addon:last-child,.input-group-btn:first-child>.btn-group:not(:first-child)>.btn,.input-group-btn:first-child>.btn:not(:first-child),.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group>.btn,.input-group-btn:last-child>.dropdown-toggle{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-addon:last-child{border-left:0}.input-group-btn{position:relative;font-size:0;white-space:nowrap}.input-group-btn>.btn{position:relative}.input-group-btn>.btn+.btn{margin-left:-1px}.input-group-btn>.btn:active,.input-group-btn>.btn:focus,.input-group-btn>.btn:hover{z-index:2}.input-group-btn:first-child>.btn,.input-group-btn:first-child>.btn-group{margin-right:-1px}.input-group-btn:last-child>.btn,.input-group-btn:last-child>.btn-group{margin-left:-1px}.nav{padding-left:0;margin-bottom:0;list-style:none}.nav>li{position:relative;display:block}.nav>li>a{position:relative;display:block;padding:10px 15px}.nav>li>a:focus,.nav>li>a:hover{text-decoration:none;background-color:#eee}.nav>li.disabled>a{color:#777}.nav>li.disabled>a:focus,.nav>li.disabled>a:hover{color:#777;text-decoration:none;cursor:not-allowed;background-color:transparent}.nav .open>a,.nav .open>a:focus,.nav .open>a:hover{background-color:#eee;border-color:#337ab7}.nav .nav-divider{height:1px;margin:9px 0;overflow:hidden;background-color:#e5e5e5}.nav>li>a>img{max-width:none}.nav-tabs{border-bottom:1px solid #ddd}.nav-tabs>li{float:left;margin-bottom:-1px}.nav-tabs>li>a{margin-right:2px;line-height:1.42857143;border:1px solid transparent;border-radius:4px 4px 0 0}.nav-tabs>li>a:hover{border-color:#eee #eee #ddd}.nav-tabs>li.active>a,.nav-tabs>li.active>a:focus,.nav-tabs>li.active>a:hover{color:#555;cursor:default;background-color:#fff;border:1px solid #ddd;border-bottom-color:transparent}.nav-tabs.nav-justified{width:100%;border-bottom:0}.nav-tabs.nav-justified>li{float:none}.nav-tabs.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-tabs.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-tabs.nav-justified>li{display:table-cell;width:1%}.nav-tabs.nav-justified>li>a{margin-bottom:0}}.nav-tabs.nav-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs.nav-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs.nav-justified>.active>a,.nav-tabs.nav-justified>.active>a:focus,.nav-tabs.nav-justified>.active>a:hover{border-bottom-color:#fff}}.nav-pills>li{float:left}.nav-pills>li>a{border-radius:4px}.nav-pills>li+li{margin-left:2px}.nav-pills>li.active>a,.nav-pills>li.active>a:focus,.nav-pills>li.active>a:hover{color:#fff;background-color:#337ab7}.nav-stacked>li{float:none}.nav-stacked>li+li{margin-top:2px;margin-left:0}.nav-justified{width:100%}.nav-justified>li{float:none}.nav-justified>li>a{margin-bottom:5px;text-align:center}.nav-justified>.dropdown .dropdown-menu{top:auto;left:auto}@media (min-width:768px){.nav-justified>li{display:table-cell;width:1%}.nav-justified>li>a{margin-bottom:0}}.nav-tabs-justified{border-bottom:0}.nav-tabs-justified>li>a{margin-right:0;border-radius:4px}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border:1px solid #ddd}@media (min-width:768px){.nav-tabs-justified>li>a{border-bottom:1px solid #ddd;border-radius:4px 4px 0 0}.nav-tabs-justified>.active>a,.nav-tabs-justified>.active>a:focus,.nav-tabs-justified>.active>a:hover{border-bottom-color:#fff}}.tab-content>.tab-pane{display:none;visibility:hidden}.tab-content>.active{display:block;visibility:visible}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.navbar{position:relative;min-height:50px;margin-bottom:20px;border:1px solid transparent}@media (min-width:768px){.navbar{border-radius:4px}}@media (min-width:768px){.navbar-header{float:left}}.navbar-collapse{padding-right:15px;padding-left:15px;overflow-x:visible;-webkit-overflow-scrolling:touch;border-top:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1)}.navbar-collapse.in{overflow-y:auto}@media (min-width:768px){.navbar-collapse{width:auto;border-top:0;-webkit-box-shadow:none;box-shadow:none}.navbar-collapse.collapse{display:block!important;height:auto!important;padding-bottom:0;overflow:visible!important;visibility:visible!important}.navbar-collapse.in{overflow-y:visible}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse,.navbar-static-top .navbar-collapse{padding-right:0;padding-left:0}}.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:340px}@media (max-device-width:480px) and (orientation:landscape){.navbar-fixed-bottom .navbar-collapse,.navbar-fixed-top .navbar-collapse{max-height:200px}}.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:-15px;margin-left:-15px}@media (min-width:768px){.container-fluid>.navbar-collapse,.container-fluid>.navbar-header,.container>.navbar-collapse,.container>.navbar-header{margin-right:0;margin-left:0}}.navbar-static-top{z-index:1000;border-width:0 0 1px}@media (min-width:768px){.navbar-static-top{border-radius:0}}.navbar-fixed-bottom,.navbar-fixed-top{position:fixed;right:0;left:0;z-index:1030}@media (min-width:768px){.navbar-fixed-bottom,.navbar-fixed-top{border-radius:0}}.navbar-fixed-top{top:0;border-width:0 0 1px}.navbar-fixed-bottom{bottom:0;margin-bottom:0;border-width:1px 0 0}.navbar-brand{float:left;height:50px;padding:15px 15px;font-size:18px;line-height:20px}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-brand>img{display:block}@media (min-width:768px){.navbar>.container .navbar-brand,.navbar>.container-fluid .navbar-brand{margin-left:-15px}}.navbar-toggle{position:relative;float:right;padding:9px 10px;margin-top:8px;margin-right:15px;margin-bottom:8px;background-color:transparent;background-image:none;border:1px solid transparent;border-radius:4px}.navbar-toggle:focus{outline:0}.navbar-toggle .icon-bar{display:block;width:22px;height:2px;border-radius:1px}.navbar-toggle .icon-bar+.icon-bar{margin-top:4px}@media (min-width:768px){.navbar-toggle{display:none}}.navbar-nav{margin:7.5px -15px}.navbar-nav>li>a{padding-top:10px;padding-bottom:10px;line-height:20px}@media (max-width:767px){.navbar-nav .open .dropdown-menu{position:static;float:none;width:auto;margin-top:0;background-color:transparent;border:0;-webkit-box-shadow:none;box-shadow:none}.navbar-nav .open .dropdown-menu .dropdown-header,.navbar-nav .open .dropdown-menu>li>a{padding:5px 15px 5px 25px}.navbar-nav .open .dropdown-menu>li>a{line-height:20px}.navbar-nav .open .dropdown-menu>li>a:focus,.navbar-nav .open .dropdown-menu>li>a:hover{background-image:none}}@media (min-width:768px){.navbar-nav{float:left;margin:0}.navbar-nav>li{float:left}.navbar-nav>li>a{padding-top:15px;padding-bottom:15px}}.navbar-form{padding:10px 15px;margin-top:8px;margin-right:-15px;margin-bottom:8px;margin-left:-15px;border-top:1px solid transparent;border-bottom:1px solid transparent;-webkit-box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1);box-shadow:inset 0 1px 0 rgba(255,255,255,.1),0 1px 0 rgba(255,255,255,.1)}@media (min-width:768px){.navbar-form .form-group{display:inline-block;margin-bottom:0;vertical-align:middle}.navbar-form .form-control{display:inline-block;width:auto;vertical-align:middle}.navbar-form .form-control-static{display:inline-block}.navbar-form .input-group{display:inline-table;vertical-align:middle}.navbar-form .input-group .form-control,.navbar-form .input-group .input-group-addon,.navbar-form .input-group .input-group-btn{width:auto}.navbar-form .input-group>.form-control{width:100%}.navbar-form .control-label{margin-bottom:0;vertical-align:middle}.navbar-form .checkbox,.navbar-form .radio{display:inline-block;margin-top:0;margin-bottom:0;vertical-align:middle}.navbar-form .checkbox label,.navbar-form .radio label{padding-left:0}.navbar-form .checkbox input[type=checkbox],.navbar-form .radio input[type=radio]{position:relative;margin-left:0}.navbar-form .has-feedback .form-control-feedback{top:0}}@media (max-width:767px){.navbar-form .form-group{margin-bottom:5px}.navbar-form .form-group:last-child{margin-bottom:0}}@media (min-width:768px){.navbar-form{width:auto;padding-top:0;padding-bottom:0;margin-right:0;margin-left:0;border:0;-webkit-box-shadow:none;box-shadow:none}}.navbar-nav>li>.dropdown-menu{margin-top:0;border-top-left-radius:0;border-top-right-radius:0}.navbar-fixed-bottom .navbar-nav>li>.dropdown-menu{margin-bottom:0;border-top-left-radius:4px;border-top-right-radius:4px;border-bottom-right-radius:0;border-bottom-left-radius:0}.navbar-btn{margin-top:8px;margin-bottom:8px}.navbar-btn.btn-sm{margin-top:10px;margin-bottom:10px}.navbar-btn.btn-xs{margin-top:14px;margin-bottom:14px}.navbar-text{margin-top:15px;margin-bottom:15px}@media (min-width:768px){.navbar-text{float:left;margin-right:15px;margin-left:15px}}@media (min-width:768px){.navbar-left{float:left!important}.navbar-right{float:right!important;margin-right:-15px}.navbar-right~.navbar-right{margin-right:0}}.navbar-default{background-color:#f8f8f8;border-color:#e7e7e7}.navbar-default .navbar-brand{color:#777}.navbar-default .navbar-brand:focus,.navbar-default .navbar-brand:hover{color:#5e5e5e;background-color:transparent}.navbar-default .navbar-text{color:#777}.navbar-default .navbar-nav>li>a{color:#777}.navbar-default .navbar-nav>li>a:focus,.navbar-default .navbar-nav>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav>.active>a,.navbar-default .navbar-nav>.active>a:focus,.navbar-default .navbar-nav>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav>.disabled>a,.navbar-default .navbar-nav>.disabled>a:focus,.navbar-default .navbar-nav>.disabled>a:hover{color:#ccc;background-color:transparent}.navbar-default .navbar-toggle{border-color:#ddd}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:#ddd}.navbar-default .navbar-toggle .icon-bar{background-color:#888}.navbar-default .navbar-collapse,.navbar-default .navbar-form{border-color:#e7e7e7}.navbar-default .navbar-nav>.open>a,.navbar-default .navbar-nav>.open>a:focus,.navbar-default .navbar-nav>.open>a:hover{color:#555;background-color:#e7e7e7}@media (max-width:767px){.navbar-default .navbar-nav .open .dropdown-menu>li>a{color:#777}.navbar-default .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>li>a:hover{color:#333;background-color:transparent}.navbar-default .navbar-nav .open .dropdown-menu>.active>a,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.active>a:hover{color:#555;background-color:#e7e7e7}.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-default .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#ccc;background-color:transparent}}.navbar-default .navbar-link{color:#777}.navbar-default .navbar-link:hover{color:#333}.navbar-default .btn-link{color:#777}.navbar-default .btn-link:focus,.navbar-default .btn-link:hover{color:#333}.navbar-default .btn-link[disabled]:focus,.navbar-default .btn-link[disabled]:hover,fieldset[disabled] .navbar-default .btn-link:focus,fieldset[disabled] .navbar-default .btn-link:hover{color:#ccc}.navbar-inverse{background-color:#222;border-color:#080808}.navbar-inverse .navbar-brand{color:#9d9d9d}.navbar-inverse .navbar-brand:focus,.navbar-inverse .navbar-brand:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-text{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav>li>a:focus,.navbar-inverse .navbar-nav>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav>.active>a,.navbar-inverse .navbar-nav>.active>a:focus,.navbar-inverse .navbar-nav>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav>.disabled>a,.navbar-inverse .navbar-nav>.disabled>a:focus,.navbar-inverse .navbar-nav>.disabled>a:hover{color:#444;background-color:transparent}.navbar-inverse .navbar-toggle{border-color:#333}.navbar-inverse .navbar-toggle:focus,.navbar-inverse .navbar-toggle:hover{background-color:#333}.navbar-inverse .navbar-toggle .icon-bar{background-color:#fff}.navbar-inverse .navbar-collapse,.navbar-inverse .navbar-form{border-color:#101010}.navbar-inverse .navbar-nav>.open>a,.navbar-inverse .navbar-nav>.open>a:focus,.navbar-inverse .navbar-nav>.open>a:hover{color:#fff;background-color:#080808}@media (max-width:767px){.navbar-inverse .navbar-nav .open .dropdown-menu>.dropdown-header{border-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu .divider{background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a{color:#9d9d9d}.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>li>a:hover{color:#fff;background-color:transparent}.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.active>a:hover{color:#fff;background-color:#080808}.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:focus,.navbar-inverse .navbar-nav .open .dropdown-menu>.disabled>a:hover{color:#444;background-color:transparent}}.navbar-inverse .navbar-link{color:#9d9d9d}.navbar-inverse .navbar-link:hover{color:#fff}.navbar-inverse .btn-link{color:#9d9d9d}.navbar-inverse .btn-link:focus,.navbar-inverse .btn-link:hover{color:#fff}.navbar-inverse .btn-link[disabled]:focus,.navbar-inverse .btn-link[disabled]:hover,fieldset[disabled] .navbar-inverse .btn-link:focus,fieldset[disabled] .navbar-inverse .btn-link:hover{color:#444}.breadcrumb{padding:8px 15px;margin-bottom:20px;list-style:none;background-color:#f5f5f5;border-radius:4px}.breadcrumb>li{display:inline-block}.breadcrumb>li+li:before{padding:0 5px;color:#ccc;content:"/\00a0"}.breadcrumb>.active{color:#777}.pagination{display:inline-block;padding-left:0;margin:20px 0;border-radius:4px}.pagination>li{display:inline}.pagination>li>a,.pagination>li>span{position:relative;float:left;padding:6px 12px;margin-left:-1px;line-height:1.42857143;color:#337ab7;text-decoration:none;background-color:#fff;border:1px solid #ddd}.pagination>li:first-child>a,.pagination>li:first-child>span{margin-left:0;border-top-left-radius:4px;border-bottom-left-radius:4px}.pagination>li:last-child>a,.pagination>li:last-child>span{border-top-right-radius:4px;border-bottom-right-radius:4px}.pagination>li>a:focus,.pagination>li>a:hover,.pagination>li>span:focus,.pagination>li>span:hover{color:#23527c;background-color:#eee;border-color:#ddd}.pagination>.active>a,.pagination>.active>a:focus,.pagination>.active>a:hover,.pagination>.active>span,.pagination>.active>span:focus,.pagination>.active>span:hover{z-index:2;color:#fff;cursor:default;background-color:#337ab7;border-color:#337ab7}.pagination>.disabled>a,.pagination>.disabled>a:focus,.pagination>.disabled>a:hover,.pagination>.disabled>span,.pagination>.disabled>span:focus,.pagination>.disabled>span:hover{color:#777;cursor:not-allowed;background-color:#fff;border-color:#ddd}.pagination-lg>li>a,.pagination-lg>li>span{padding:10px 16px;font-size:18px}.pagination-lg>li:first-child>a,.pagination-lg>li:first-child>span{border-top-left-radius:6px;border-bottom-left-radius:6px}.pagination-lg>li:last-child>a,.pagination-lg>li:last-child>span{border-top-right-radius:6px;border-bottom-right-radius:6px}.pagination-sm>li>a,.pagination-sm>li>span{padding:5px 10px;font-size:12px}.pagination-sm>li:first-child>a,.pagination-sm>li:first-child>span{border-top-left-radius:3px;border-bottom-left-radius:3px}.pagination-sm>li:last-child>a,.pagination-sm>li:last-child>span{border-top-right-radius:3px;border-bottom-right-radius:3px}.pager{padding-left:0;margin:20px 0;text-align:center;list-style:none}.pager li{display:inline}.pager li>a,.pager li>span{display:inline-block;padding:5px 14px;background-color:#fff;border:1px solid #ddd;border-radius:15px}.pager li>a:focus,.pager li>a:hover{text-decoration:none;background-color:#eee}.pager .next>a,.pager .next>span{float:right}.pager .previous>a,.pager .previous>span{float:left}.pager .disabled>a,.pager .disabled>a:focus,.pager .disabled>a:hover,.pager .disabled>span{color:#777;cursor:not-allowed;background-color:#fff}.label{display:inline;padding:.2em .6em .3em;font-size:75%;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25em}a.label:focus,a.label:hover{color:#fff;text-decoration:none;cursor:pointer}.label:empty{display:none}.btn .label{position:relative;top:-1px}.label-default{background-color:#777}.label-default[href]:focus,.label-default[href]:hover{background-color:#5e5e5e}.label-primary{background-color:#337ab7}.label-primary[href]:focus,.label-primary[href]:hover{background-color:#286090}.label-success{background-color:#5cb85c}.label-success[href]:focus,.label-success[href]:hover{background-color:#449d44}.label-info{background-color:#5bc0de}.label-info[href]:focus,.label-info[href]:hover{background-color:#31b0d5}.label-warning{background-color:#f0ad4e}.label-warning[href]:focus,.label-warning[href]:hover{background-color:#ec971f}.label-danger{background-color:#d9534f}.label-danger[href]:focus,.label-danger[href]:hover{background-color:#c9302c}.badge{display:inline-block;min-width:10px;padding:3px 7px;font-size:12px;font-weight:700;line-height:1;color:#fff;text-align:center;white-space:nowrap;vertical-align:baseline;background-color:#777;border-radius:10px}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.btn-xs .badge{top:0;padding:1px 5px}a.badge:focus,a.badge:hover{color:#fff;text-decoration:none;cursor:pointer}.list-group-item.active>.badge,.nav-pills>.active>a>.badge{color:#337ab7;background-color:#fff}.list-group-item>.badge{float:right}.list-group-item>.badge+.badge{margin-right:5px}.nav-pills>li>a>.badge{margin-left:3px}.jumbotron{padding:30px 15px;margin-bottom:30px;color:inherit;background-color:#eee}.jumbotron .h1,.jumbotron h1{color:inherit}.jumbotron p{margin-bottom:15px;font-size:21px;font-weight:200}.jumbotron>hr{border-top-color:#d5d5d5}.container .jumbotron,.container-fluid .jumbotron{border-radius:6px}.jumbotron .container{max-width:100%}@media screen and (min-width:768px){.jumbotron{padding:48px 0}.container .jumbotron,.container-fluid .jumbotron{padding-right:60px;padding-left:60px}.jumbotron .h1,.jumbotron h1{font-size:63px}}.thumbnail{display:block;padding:4px;margin-bottom:20px;line-height:1.42857143;background-color:#fff;border:1px solid #ddd;border-radius:4px;-webkit-transition:border .2s ease-in-out;-o-transition:border .2s ease-in-out;transition:border .2s ease-in-out}.thumbnail a>img,.thumbnail>img{margin-right:auto;margin-left:auto}a.thumbnail.active,a.thumbnail:focus,a.thumbnail:hover{border-color:#337ab7}.thumbnail .caption{padding:9px;color:#333}.alert{padding:15px;margin-bottom:20px;border:1px solid transparent;border-radius:4px}.alert h4{margin-top:0;color:inherit}.alert .alert-link{font-weight:700}.alert>p,.alert>ul{margin-bottom:0}.alert>p+p{margin-top:5px}.alert-dismissable,.alert-dismissible{padding-right:35px}.alert-dismissable .close,.alert-dismissible .close{position:relative;top:-2px;right:-21px;color:inherit}.alert-success{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.alert-success hr{border-top-color:#c9e2b3}.alert-success .alert-link{color:#2b542c}.alert-info{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.alert-info hr{border-top-color:#a6e1ec}.alert-info .alert-link{color:#245269}.alert-warning{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.alert-warning hr{border-top-color:#f7e1b5}.alert-warning .alert-link{color:#66512c}.alert-danger{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.alert-danger hr{border-top-color:#e4b9c0}.alert-danger .alert-link{color:#843534}@-webkit-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@-o-keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:40px 0}to{background-position:0 0}}.progress{height:20px;margin-bottom:20px;overflow:hidden;background-color:#f5f5f5;border-radius:4px;-webkit-box-shadow:inset 0 1px 2px rgba(0,0,0,.1);box-shadow:inset 0 1px 2px rgba(0,0,0,.1)}.progress-bar{float:left;width:0;height:100%;font-size:12px;line-height:20px;color:#fff;text-align:center;background-color:#337ab7;-webkit-box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);box-shadow:inset 0 -1px 0 rgba(0,0,0,.15);-webkit-transition:width .6s ease;-o-transition:width .6s ease;transition:width .6s ease}.progress-bar-striped,.progress-striped .progress-bar{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);-webkit-background-size:40px 40px;background-size:40px 40px}.progress-bar.active,.progress.active .progress-bar{-webkit-animation:progress-bar-stripes 2s linear infinite;-o-animation:progress-bar-stripes 2s linear infinite;animation:progress-bar-stripes 2s linear infinite}.progress-bar-success{background-color:#5cb85c}.progress-striped .progress-bar-success{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-info{background-color:#5bc0de}.progress-striped .progress-bar-info{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-warning{background-color:#f0ad4e}.progress-striped .progress-bar-warning{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.progress-bar-danger{background-color:#d9534f}.progress-striped .progress-bar-danger{background-image:-webkit-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:-o-linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent)}.media{margin-top:15px}.media:first-child{margin-top:0}.media,.media-body{overflow:hidden;zoom:1}.media-body{width:10000px}.media-object{display:block}.media-right,.media>.pull-right{padding-left:10px}.media-left,.media>.pull-left{padding-right:10px}.media-body,.media-left,.media-right{display:table-cell;vertical-align:top}.media-middle{vertical-align:middle}.media-bottom{vertical-align:bottom}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.list-group{padding-left:0;margin-bottom:20px}.list-group-item{position:relative;display:block;padding:10px 15px;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:4px;border-bottom-left-radius:4px}a.list-group-item{color:#555}a.list-group-item .list-group-item-heading{color:#333}a.list-group-item:focus,a.list-group-item:hover{color:#555;text-decoration:none;background-color:#f5f5f5}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#777;cursor:not-allowed;background-color:#eee}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#777}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;background-color:#337ab7;border-color:#337ab7}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#c7ddef}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.panel{margin-bottom:20px;background-color:#fff;border:1px solid transparent;border-radius:4px;-webkit-box-shadow:0 1px 1px rgba(0,0,0,.05);box-shadow:0 1px 1px rgba(0,0,0,.05)}.panel-body{padding:15px}.panel-heading{padding:10px 15px;border-bottom:1px solid transparent;border-top-left-radius:3px;border-top-right-radius:3px}.panel-heading>.dropdown .dropdown-toggle{color:inherit}.panel-title{margin-top:0;margin-bottom:0;font-size:16px;color:inherit}.panel-title>.small,.panel-title>.small>a,.panel-title>a,.panel-title>small,.panel-title>small>a{color:inherit}.panel-footer{padding:10px 15px;background-color:#f5f5f5;border-top:1px solid #ddd;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.list-group,.panel>.panel-collapse>.list-group{margin-bottom:0}.panel>.list-group .list-group-item,.panel>.panel-collapse>.list-group .list-group-item{border-width:1px 0;border-radius:0}.panel>.list-group:first-child .list-group-item:first-child,.panel>.panel-collapse>.list-group:first-child .list-group-item:first-child{border-top:0;border-top-left-radius:3px;border-top-right-radius:3px}.panel>.list-group:last-child .list-group-item:last-child,.panel>.panel-collapse>.list-group:last-child .list-group-item:last-child{border-bottom:0;border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel-heading+.list-group .list-group-item:first-child{border-top-width:0}.list-group+.panel-footer{border-top-width:0}.panel>.panel-collapse>.table,.panel>.table,.panel>.table-responsive>.table{margin-bottom:0}.panel>.panel-collapse>.table caption,.panel>.table caption,.panel>.table-responsive>.table caption{padding-right:15px;padding-left:15px}.panel>.table-responsive:first-child>.table:first-child,.panel>.table:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child,.panel>.table:first-child>thead:first-child>tr:first-child{border-top-left-radius:3px;border-top-right-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:first-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:first-child,.panel>.table:first-child>thead:first-child>tr:first-child td:first-child,.panel>.table:first-child>thead:first-child>tr:first-child th:first-child{border-top-left-radius:3px}.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table-responsive:first-child>.table:first-child>thead:first-child>tr:first-child th:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child td:last-child,.panel>.table:first-child>tbody:first-child>tr:first-child th:last-child,.panel>.table:first-child>thead:first-child>tr:first-child td:last-child,.panel>.table:first-child>thead:first-child>tr:first-child th:last-child{border-top-right-radius:3px}.panel>.table-responsive:last-child>.table:last-child,.panel>.table:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child{border-bottom-right-radius:3px;border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:first-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:first-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:first-child{border-bottom-left-radius:3px}.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table-responsive:last-child>.table:last-child>tfoot:last-child>tr:last-child th:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child td:last-child,.panel>.table:last-child>tbody:last-child>tr:last-child th:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child td:last-child,.panel>.table:last-child>tfoot:last-child>tr:last-child th:last-child{border-bottom-right-radius:3px}.panel>.panel-body+.table,.panel>.panel-body+.table-responsive,.panel>.table+.panel-body,.panel>.table-responsive+.panel-body{border-top:1px solid #ddd}.panel>.table>tbody:first-child>tr:first-child td,.panel>.table>tbody:first-child>tr:first-child th{border-top:0}.panel>.table-bordered,.panel>.table-responsive>.table-bordered{border:0}.panel>.table-bordered>tbody>tr>td:first-child,.panel>.table-bordered>tbody>tr>th:first-child,.panel>.table-bordered>tfoot>tr>td:first-child,.panel>.table-bordered>tfoot>tr>th:first-child,.panel>.table-bordered>thead>tr>td:first-child,.panel>.table-bordered>thead>tr>th:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:first-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:first-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:first-child,.panel>.table-responsive>.table-bordered>thead>tr>td:first-child,.panel>.table-responsive>.table-bordered>thead>tr>th:first-child{border-left:0}.panel>.table-bordered>tbody>tr>td:last-child,.panel>.table-bordered>tbody>tr>th:last-child,.panel>.table-bordered>tfoot>tr>td:last-child,.panel>.table-bordered>tfoot>tr>th:last-child,.panel>.table-bordered>thead>tr>td:last-child,.panel>.table-bordered>thead>tr>th:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>td:last-child,.panel>.table-responsive>.table-bordered>tbody>tr>th:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>td:last-child,.panel>.table-responsive>.table-bordered>tfoot>tr>th:last-child,.panel>.table-responsive>.table-bordered>thead>tr>td:last-child,.panel>.table-responsive>.table-bordered>thead>tr>th:last-child{border-right:0}.panel>.table-bordered>tbody>tr:first-child>td,.panel>.table-bordered>tbody>tr:first-child>th,.panel>.table-bordered>thead>tr:first-child>td,.panel>.table-bordered>thead>tr:first-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:first-child>th,.panel>.table-responsive>.table-bordered>thead>tr:first-child>td,.panel>.table-responsive>.table-bordered>thead>tr:first-child>th{border-bottom:0}.panel>.table-bordered>tbody>tr:last-child>td,.panel>.table-bordered>tbody>tr:last-child>th,.panel>.table-bordered>tfoot>tr:last-child>td,.panel>.table-bordered>tfoot>tr:last-child>th,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>td,.panel>.table-responsive>.table-bordered>tbody>tr:last-child>th,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>td,.panel>.table-responsive>.table-bordered>tfoot>tr:last-child>th{border-bottom:0}.panel>.table-responsive{margin-bottom:0;border:0}.panel-group{margin-bottom:20px}.panel-group .panel{margin-bottom:0;border-radius:4px}.panel-group .panel+.panel{margin-top:5px}.panel-group .panel-heading{border-bottom:0}.panel-group .panel-heading+.panel-collapse>.list-group,.panel-group .panel-heading+.panel-collapse>.panel-body{border-top:1px solid #ddd}.panel-group .panel-footer{border-top:0}.panel-group .panel-footer+.panel-collapse .panel-body{border-bottom:1px solid #ddd}.panel-default{border-color:#ddd}.panel-default>.panel-heading{color:#333;background-color:#f5f5f5;border-color:#ddd}.panel-default>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ddd}.panel-default>.panel-heading .badge{color:#f5f5f5;background-color:#333}.panel-default>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ddd}.panel-primary{border-color:#337ab7}.panel-primary>.panel-heading{color:#fff;background-color:#337ab7;border-color:#337ab7}.panel-primary>.panel-heading+.panel-collapse>.panel-body{border-top-color:#337ab7}.panel-primary>.panel-heading .badge{color:#337ab7;background-color:#fff}.panel-primary>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#337ab7}.panel-success{border-color:#d6e9c6}.panel-success>.panel-heading{color:#3c763d;background-color:#dff0d8;border-color:#d6e9c6}.panel-success>.panel-heading+.panel-collapse>.panel-body{border-top-color:#d6e9c6}.panel-success>.panel-heading .badge{color:#dff0d8;background-color:#3c763d}.panel-success>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#d6e9c6}.panel-info{border-color:#bce8f1}.panel-info>.panel-heading{color:#31708f;background-color:#d9edf7;border-color:#bce8f1}.panel-info>.panel-heading+.panel-collapse>.panel-body{border-top-color:#bce8f1}.panel-info>.panel-heading .badge{color:#d9edf7;background-color:#31708f}.panel-info>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#bce8f1}.panel-warning{border-color:#faebcc}.panel-warning>.panel-heading{color:#8a6d3b;background-color:#fcf8e3;border-color:#faebcc}.panel-warning>.panel-heading+.panel-collapse>.panel-body{border-top-color:#faebcc}.panel-warning>.panel-heading .badge{color:#fcf8e3;background-color:#8a6d3b}.panel-warning>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#faebcc}.panel-danger{border-color:#ebccd1}.panel-danger>.panel-heading{color:#a94442;background-color:#f2dede;border-color:#ebccd1}.panel-danger>.panel-heading+.panel-collapse>.panel-body{border-top-color:#ebccd1}.panel-danger>.panel-heading .badge{color:#f2dede;background-color:#a94442}.panel-danger>.panel-footer+.panel-collapse>.panel-body{border-bottom-color:#ebccd1}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive.embed-responsive-4by3{padding-bottom:75%}.well{min-height:20px;padding:19px;margin-bottom:20px;background-color:#f5f5f5;border:1px solid #e3e3e3;border-radius:4px;-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-shadow:inset 0 1px 1px rgba(0,0,0,.05)}.well blockquote{border-color:#ddd;border-color:rgba(0,0,0,.15)}.well-lg{padding:24px;border-radius:6px}.well-sm{padding:9px;border-radius:3px}.close{float:right;font-size:21px;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;filter:alpha(opacity=20);opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;filter:alpha(opacity=50);opacity:.5}button.close{-webkit-appearance:none;padding:0;cursor:pointer;background:0 0;border:0}.modal-open{overflow:hidden}.modal{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1040;display:none;overflow:hidden;-webkit-overflow-scrolling:touch;outline:0}.modal.fade .modal-dialog{-webkit-transition:-webkit-transform .3s ease-out;-o-transition:-o-transform .3s ease-out;transition:transform .3s ease-out;-webkit-transform:translate(0,-25%);-ms-transform:translate(0,-25%);-o-transform:translate(0,-25%);transform:translate(0,-25%)}.modal.in .modal-dialog{-webkit-transform:translate(0,0);-ms-transform:translate(0,0);-o-transform:translate(0,0);transform:translate(0,0)}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal-dialog{position:relative;width:auto;margin:10px}.modal-content{position:relative;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #999;border:1px solid rgba(0,0,0,.2);border-radius:6px;outline:0;-webkit-box-shadow:0 3px 9px rgba(0,0,0,.5);box-shadow:0 3px 9px rgba(0,0,0,.5)}.modal-backdrop{position:absolute;top:0;right:0;left:0;background-color:#000}.modal-backdrop.fade{filter:alpha(opacity=0);opacity:0}.modal-backdrop.in{filter:alpha(opacity=50);opacity:.5}.modal-header{min-height:16.43px;padding:15px;border-bottom:1px solid #e5e5e5}.modal-header .close{margin-top:-2px}.modal-title{margin:0;line-height:1.42857143}.modal-body{position:relative;padding:15px}.modal-footer{padding:15px;text-align:right;border-top:1px solid #e5e5e5}.modal-footer .btn+.btn{margin-bottom:0;margin-left:5px}.modal-footer .btn-group .btn+.btn{margin-left:-1px}.modal-footer .btn-block+.btn-block{margin-left:0}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:768px){.modal-dialog{width:600px;margin:30px auto}.modal-content{-webkit-box-shadow:0 5px 15px rgba(0,0,0,.5);box-shadow:0 5px 15px rgba(0,0,0,.5)}.modal-sm{width:300px}}@media (min-width:992px){.modal-lg{width:900px}}.tooltip{position:absolute;z-index:1070;display:block;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:12px;font-weight:400;line-height:1.4;visibility:visible;filter:alpha(opacity=0);opacity:0}.tooltip.in{filter:alpha(opacity=90);opacity:.9}.tooltip.top{padding:5px 0;margin-top:-3px}.tooltip.right{padding:0 5px;margin-left:3px}.tooltip.bottom{padding:5px 0;margin-top:3px}.tooltip.left{padding:0 5px;margin-left:-3px}.tooltip-inner{max-width:200px;padding:3px 8px;color:#fff;text-align:center;text-decoration:none;background-color:#000;border-radius:4px}.tooltip-arrow{position:absolute;width:0;height:0;border-color:transparent;border-style:solid}.tooltip.top .tooltip-arrow{bottom:0;left:50%;margin-left:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-left .tooltip-arrow{right:5px;bottom:0;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.top-right .tooltip-arrow{bottom:0;left:5px;margin-bottom:-5px;border-width:5px 5px 0;border-top-color:#000}.tooltip.right .tooltip-arrow{top:50%;left:0;margin-top:-5px;border-width:5px 5px 5px 0;border-right-color:#000}.tooltip.left .tooltip-arrow{top:50%;right:0;margin-top:-5px;border-width:5px 0 5px 5px;border-left-color:#000}.tooltip.bottom .tooltip-arrow{top:0;left:50%;margin-left:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-left .tooltip-arrow{top:0;right:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.tooltip.bottom-right .tooltip-arrow{top:0;left:5px;margin-top:-5px;border-width:0 5px 5px;border-bottom-color:#000}.popover{position:absolute;top:0;left:0;z-index:1060;display:none;max-width:276px;padding:1px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;font-size:14px;font-weight:400;line-height:1.42857143;text-align:left;white-space:normal;background-color:#fff;-webkit-background-clip:padding-box;background-clip:padding-box;border:1px solid #ccc;border:1px solid rgba(0,0,0,.2);border-radius:6px;-webkit-box-shadow:0 5px 10px rgba(0,0,0,.2);box-shadow:0 5px 10px rgba(0,0,0,.2)}.popover.top{margin-top:-10px}.popover.right{margin-left:10px}.popover.bottom{margin-top:10px}.popover.left{margin-left:-10px}.popover-title{padding:8px 14px;margin:0;font-size:14px;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-radius:5px 5px 0 0}.popover-content{padding:9px 14px}.popover>.arrow,.popover>.arrow:after{position:absolute;display:block;width:0;height:0;border-color:transparent;border-style:solid}.popover>.arrow{border-width:11px}.popover>.arrow:after{content:"";border-width:10px}.popover.top>.arrow{bottom:-11px;left:50%;margin-left:-11px;border-top-color:#999;border-top-color:rgba(0,0,0,.25);border-bottom-width:0}.popover.top>.arrow:after{bottom:1px;margin-left:-10px;content:" ";border-top-color:#fff;border-bottom-width:0}.popover.right>.arrow{top:50%;left:-11px;margin-top:-11px;border-right-color:#999;border-right-color:rgba(0,0,0,.25);border-left-width:0}.popover.right>.arrow:after{bottom:-10px;left:1px;content:" ";border-right-color:#fff;border-left-width:0}.popover.bottom>.arrow{top:-11px;left:50%;margin-left:-11px;border-top-width:0;border-bottom-color:#999;border-bottom-color:rgba(0,0,0,.25)}.popover.bottom>.arrow:after{top:1px;margin-left:-10px;content:" ";border-top-width:0;border-bottom-color:#fff}.popover.left>.arrow{top:50%;right:-11px;margin-top:-11px;border-right-width:0;border-left-color:#999;border-left-color:rgba(0,0,0,.25)}.popover.left>.arrow:after{right:1px;bottom:-10px;content:" ";border-right-width:0;border-left-color:#fff}.carousel{position:relative}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner>.item{position:relative;display:none;-webkit-transition:.6s ease-in-out left;-o-transition:.6s ease-in-out left;transition:.6s ease-in-out left}.carousel-inner>.item>a>img,.carousel-inner>.item>img{line-height:1}@media all and (transform-3d),(-webkit-transform-3d){.carousel-inner>.item{-webkit-transition:-webkit-transform .6s ease-in-out;-o-transition:-o-transform .6s ease-in-out;transition:transform .6s ease-in-out;-webkit-backface-visibility:hidden;backface-visibility:hidden;-webkit-perspective:1000;perspective:1000}.carousel-inner>.item.active.right,.carousel-inner>.item.next{left:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}.carousel-inner>.item.active.left,.carousel-inner>.item.prev{left:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}.carousel-inner>.item.active,.carousel-inner>.item.next.left,.carousel-inner>.item.prev.right{left:0;-webkit-transform:translate3d(0,0,0);transform:translate3d(0,0,0)}}.carousel-inner>.active,.carousel-inner>.next,.carousel-inner>.prev{display:block}.carousel-inner>.active{left:0}.carousel-inner>.next,.carousel-inner>.prev{position:absolute;top:0;width:100%}.carousel-inner>.next{left:100%}.carousel-inner>.prev{left:-100%}.carousel-inner>.next.left,.carousel-inner>.prev.right{left:0}.carousel-inner>.active.left{left:-100%}.carousel-inner>.active.right{left:100%}.carousel-control{position:absolute;top:0;bottom:0;left:0;width:15%;font-size:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6);filter:alpha(opacity=50);opacity:.5}.carousel-control.left{background-image:-webkit-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.5)),to(rgba(0,0,0,.0001)));background-image:linear-gradient(to right,rgba(0,0,0,.5) 0,rgba(0,0,0,.0001) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#80000000', endColorstr='#00000000', GradientType=1);background-repeat:repeat-x}.carousel-control.right{right:0;left:auto;background-image:-webkit-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-o-linear-gradient(left,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);background-image:-webkit-gradient(linear,left top,right top,from(rgba(0,0,0,.0001)),to(rgba(0,0,0,.5)));background-image:linear-gradient(to right,rgba(0,0,0,.0001) 0,rgba(0,0,0,.5) 100%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#00000000', endColorstr='#80000000', GradientType=1);background-repeat:repeat-x}.carousel-control:focus,.carousel-control:hover{color:#fff;text-decoration:none;filter:alpha(opacity=90);outline:0;opacity:.9}.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{position:absolute;top:50%;z-index:5;display:inline-block}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{left:50%;margin-left:-10px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{right:50%;margin-right:-10px}.carousel-control .icon-next,.carousel-control .icon-prev{width:20px;height:20px;margin-top:-10px;font-family:serif;line-height:1}.carousel-control .icon-prev:before{content:'\2039'}.carousel-control .icon-next:before{content:'\203a'}.carousel-indicators{position:absolute;bottom:10px;left:50%;z-index:15;width:60%;padding-left:0;margin-left:-30%;text-align:center;list-style:none}.carousel-indicators li{display:inline-block;width:10px;height:10px;margin:1px;text-indent:-999px;cursor:pointer;background-color:#000 \9;background-color:rgba(0,0,0,0);border:1px solid #fff;border-radius:10px}.carousel-indicators .active{width:12px;height:12px;margin:0;background-color:#fff}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center;text-shadow:0 1px 2px rgba(0,0,0,.6)}.carousel-caption .btn{text-shadow:none}@media screen and (min-width:768px){.carousel-control .glyphicon-chevron-left,.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next,.carousel-control .icon-prev{width:30px;height:30px;margin-top:-15px;font-size:30px}.carousel-control .glyphicon-chevron-left,.carousel-control .icon-prev{margin-left:-15px}.carousel-control .glyphicon-chevron-right,.carousel-control .icon-next{margin-right:-15px}.carousel-caption{right:20%;left:20%;padding-bottom:30px}.carousel-indicators{bottom:20px}}.btn-group-vertical>.btn-group:after,.btn-group-vertical>.btn-group:before,.btn-toolbar:after,.btn-toolbar:before,.clearfix:after,.clearfix:before,.container-fluid:after,.container-fluid:before,.container:after,.container:before,.dl-horizontal dd:after,.dl-horizontal dd:before,.form-horizontal .form-group:after,.form-horizontal .form-group:before,.modal-footer:after,.modal-footer:before,.nav:after,.nav:before,.navbar-collapse:after,.navbar-collapse:before,.navbar-header:after,.navbar-header:before,.navbar:after,.navbar:before,.pager:after,.pager:before,.panel-body:after,.panel-body:before,.row:after,.row:before{display:table;content:" "}.btn-group-vertical>.btn-group:after,.btn-toolbar:after,.clearfix:after,.container-fluid:after,.container:after,.dl-horizontal dd:after,.form-horizontal .form-group:after,.modal-footer:after,.nav:after,.navbar-collapse:after,.navbar-header:after,.navbar:after,.pager:after,.panel-body:after,.row:after{clear:both}.center-block{display:block;margin-right:auto;margin-left:auto}.pull-right{float:right!important}.pull-left{float:left!important}.hide{display:none!important}.show{display:block!important}.invisible{visibility:hidden}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.hidden{display:none!important;visibility:hidden!important}.affix{position:fixed}@-ms-viewport{width:device-width}.visible-lg,.visible-md,.visible-sm,.visible-xs{display:none!important}.visible-lg-block,.visible-lg-inline,.visible-lg-inline-block,.visible-md-block,.visible-md-inline,.visible-md-inline-block,.visible-sm-block,.visible-sm-inline,.visible-sm-inline-block,.visible-xs-block,.visible-xs-inline,.visible-xs-inline-block{display:none!important}@media (max-width:767px){.visible-xs{display:block!important}table.visible-xs{display:table}tr.visible-xs{display:table-row!important}td.visible-xs,th.visible-xs{display:table-cell!important}}@media (max-width:767px){.visible-xs-block{display:block!important}}@media (max-width:767px){.visible-xs-inline{display:inline!important}}@media (max-width:767px){.visible-xs-inline-block{display:inline-block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm{display:block!important}table.visible-sm{display:table}tr.visible-sm{display:table-row!important}td.visible-sm,th.visible-sm{display:table-cell!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-block{display:block!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline{display:inline!important}}@media (min-width:768px) and (max-width:991px){.visible-sm-inline-block{display:inline-block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md{display:block!important}table.visible-md{display:table}tr.visible-md{display:table-row!important}td.visible-md,th.visible-md{display:table-cell!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-block{display:block!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline{display:inline!important}}@media (min-width:992px) and (max-width:1199px){.visible-md-inline-block{display:inline-block!important}}@media (min-width:1200px){.visible-lg{display:block!important}table.visible-lg{display:table}tr.visible-lg{display:table-row!important}td.visible-lg,th.visible-lg{display:table-cell!important}}@media (min-width:1200px){.visible-lg-block{display:block!important}}@media (min-width:1200px){.visible-lg-inline{display:inline!important}}@media (min-width:1200px){.visible-lg-inline-block{display:inline-block!important}}@media (max-width:767px){.hidden-xs{display:none!important}}@media (min-width:768px) and (max-width:991px){.hidden-sm{display:none!important}}@media (min-width:992px) and (max-width:1199px){.hidden-md{display:none!important}}@media (min-width:1200px){.hidden-lg{display:none!important}}.visible-print{display:none!important}@media print{.visible-print{display:block!important}table.visible-print{display:table}tr.visible-print{display:table-row!important}td.visible-print,th.visible-print{display:table-cell!important}}.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}} \ No newline at end of file diff --git a/css/hux-blog.css b/css/hux-blog.css new file mode 100644 index 000000000..794dfe8eb --- /dev/null +++ b/css/hux-blog.css @@ -0,0 +1,1163 @@ +@media (min-width: 1200px) { + .post-container, + .sidebar-container { + padding-right: 5%; + } +} +@media (min-width: 768px) { + .post-container { + padding-right: 5%; + } +} +.sidebar-container { + color: #bfbfbf; + font-size: 14px; +} +.sidebar-container h5 { + color: #808080; + padding-bottom: 1em; +} +.sidebar-container h5 a { + color: #808080 !important; + text-decoration: none; +} +.sidebar-container a { + color: #bfbfbf !important; +} +.sidebar-container a:hover, +.sidebar-container a:active { + color: #0085a1 !important; +} +.sidebar-container .tags a { + border-color: #bfbfbf; +} +.sidebar-container .tags a:hover, +.sidebar-container .tags a:active { + border-color: #0085a1; +} +.sidebar-container .short-about img { + width: 80%; + display: block; + border-radius: 5px; + margin-bottom: 20px; +} +.sidebar-container .short-about p { + margin-top: 0px; + margin-bottom: 20px; +} +.sidebar-container .short-about .list-inline > li { + padding-left: 0px; +} +.catalog-container { + padding: 0px; +} +.side-catalog { + display: block; + overflow: auto; + height: 100%; + padding-bottom: 40px; + width: 195px; +} +.side-catalog.fixed { + position: fixed; + top: -21px; +} +.side-catalog.fold .catalog-toggle::before { + content: "+"; +} +.side-catalog.fold .catalog-body { + display: none; +} +.side-catalog .catalog-toggle::before { + content: "−"; + position: relative; + margin-right: 5px; + bottom: 1px; +} +.side-catalog .catalog-body { + position: relative; + list-style: none; + height: auto; + overflow: hidden; + padding-left: 0px; + padding-right: 5px; + text-indent: 0; +} +.side-catalog .catalog-body li { + position: relative; + list-style: none; +} +.side-catalog .catalog-body li a { + padding-left: 10px; + max-width: 180px; + display: inline-block; + vertical-align: middle; + height: 30px; + line-height: 30px; + overflow: hidden; + text-decoration: none; + white-space: nowrap; + text-overflow: ellipsis; +} +.side-catalog .catalog-body .h1_nav, +.side-catalog .catalog-body .h2_nav, +.side-catalog .catalog-body .h3_nav { + margin-left: 0; + font-size: 13px; + font-weight: bold; +} +.side-catalog .catalog-body .h4_nav, +.side-catalog .catalog-body .h5_nav, +.side-catalog .catalog-body .h6_nav { + margin-left: 10px; + font-size: 12px; +} +.side-catalog .catalog-body .h4_nav a, +.side-catalog .catalog-body .h5_nav a, +.side-catalog .catalog-body .h6_nav a { + max-width: 170px; +} +.side-catalog .catalog-body .active { + border-radius: 4px; + background-color: #F5F5F5; +} +.side-catalog .catalog-body .active a { + color: #0085a1!important; +} +@media (max-width: 1200px) { + .side-catalog { + display: none; + } +} +body { + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; + font-size: 16px; + color: #404040; + overflow-x: hidden; +} +p { + margin: 30px 0; +} +h1, +h2, +h3, +h4, +h5, +h6 { + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; + line-height: 1.1; + font-weight: bold; +} +h4 { + font-size: 21px; +} +a { + color: #404040; +} +a:hover, +a:focus { + color: #0085a1; +} +a img:hover, +a img:focus { + cursor: zoom-in; +} +article { + overflow-x: hidden; +} +blockquote { + color: #808080; + font-style: italic; + font-size: 0.95em; + margin: 20px 0 20px; +} +blockquote p { + margin: 0; +} +small.img-hint { + display: block; + margin-top: -20px; + text-align: center; +} +br + small.img-hint { + margin-top: -40px; +} +img.shadow { + box-shadow: rgba(0, 0, 0, 0.258824) 0px 2px 5px 0px; +} +select { + -webkit-appearance: none; + margin-top: 15px; + color: #337ab7; + border-color: #337ab7; + padding: 0em 0.4em; + background: white; +} +select.sel-lang { + min-height: 28px; + font-size: 14px; +} +.table th, +.table td { + border: 1px solid #eee !important; +} +hr.small { + max-width: 100px; + margin: 15px auto; + border-width: 4px; + border-color: white; +} +pre, +.table-responsive { + -webkit-overflow-scrolling: touch; +} +pre code { + display: block; + width: auto; + white-space: pre-wrap; + word-wrap: break-word; + overflow-wrap: anywhere; +} +.postlist-container { + margin-bottom: 15px; +} +.post-container a { + color: #337ab7; +} +.post-container a:hover, +.post-container a:focus { + color: #0085a1; +} +.post-container h1, +.post-container h2, +.post-container h3, +.post-container h4, +.post-container h5, +.post-container h6 { + margin: 30px 0 10px; +} +.post-container h5 { + font-size: 19px; + font-weight: 600; + color: gray; +} +.post-container h5 + p { + margin-top: 5px; +} +.post-container h6 { + font-size: 16px; + font-weight: 600; + color: gray; +} +.post-container h6 + p { + margin-top: 5px; +} +.post-container ul, +.post-container ol { + margin-bottom: 40px; +} +@media screen and (max-width: 768px) { + .post-container ul, + .post-container ol { + padding-left: 30px; + } +} +@media screen and (max-width: 500px) { + .post-container ul, + .post-container ol { + padding-left: 20px; + } +} +.post-container ol ol, +.post-container ol ul, +.post-container ul ol, +.post-container ul ul { + margin-bottom: 5px; +} +.post-container li p { + margin: 0; + margin-bottom: 5px; +} +.post-container li h1, +.post-container li h2, +.post-container li h3, +.post-container li h4, +.post-container li h5, +.post-container li h6 { + line-height: 2; + margin-top: 20px; +} +.post-container .pager li { + width: 48%; +} +.post-container .pager li.next { + float: right; +} +.post-container .pager li.previous { + float: left; +} +.post-container .pager li > a { + width: 100%; +} +.post-container .pager li > a > span { + color: #808080; + font-weight: normal; + letter-spacing: 0.5px; +} +@media only screen and (max-width: 767px) { + /** + * Layout + * Since V1.6 we use absolute positioning to prevent to expand container-fluid + * which would cover tags. A absolute positioning make a new layer. + */ + .navbar-default .navbar-collapse { + position: absolute; + right: 0; + border: none; + background: white; + box-shadow: 0px 5px 10px 2px rgba(0, 0, 0, 0.2); + box-shadow: rgba(0, 0, 0, 0.117647) 0px 1px 6px, rgba(0, 0, 0, 0.239216) 0px 1px 4px; + border-radius: 2px; + width: 170px; + } + /** + * Animation + * HuxBlog-Navbar using genuine Material Design Animation + */ + #huxblog_navbar { + /** + * Sharable code and 'out' function + */ + opacity: 0; + transform: scaleX(0); + transform-origin: top right; + transition: all 200ms cubic-bezier(0.47, 0, 0.4, 0.99) 0ms; + -webkit-transform: scaleX(0); + -webkit-transform-origin: top right; + -webkit-transition: all 200ms cubic-bezier(0.47, 0, 0.4, 0.99) 0ms; + /** + *'In' Animation + */ + } + #huxblog_navbar a { + font-size: 13px; + line-height: 28px; + } + #huxblog_navbar .navbar-collapse { + height: 0px; + transform: scaleY(0); + transform-origin: top right; + transition: transform 400ms cubic-bezier(0.32, 1, 0.23, 1) 0ms; + -webkit-transform: scaleY(0); + -webkit-transform-origin: top right; + -webkit-transition: -webkit-transform 400ms cubic-bezier(0.32, 1, 0.23, 1) 0ms; + } + #huxblog_navbar li { + opacity: 0; + transition: opacity 100ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; + -webkit-transition: opacity 100ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; + } + #huxblog_navbar.in { + transform: scaleX(1); + -webkit-transform: scaleX(1); + opacity: 1; + transition: all 250ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; + -webkit-transition: all 250ms cubic-bezier(0.23, 1, 0.32, 1) 0ms; + } + #huxblog_navbar.in .navbar-collapse { + transform: scaleY(1); + -webkit-transform: scaleY(1); + transition: transform 500ms cubic-bezier(0.23, 1, 0.32, 1); + -webkit-transition: -webkit-transform 500ms cubic-bezier(0.23, 1, 0.32, 1); + } + #huxblog_navbar.in li { + opacity: 1; + transition: opacity 450ms cubic-bezier(0.23, 1, 0.32, 1) 205ms; + -webkit-transition: opacity 450ms cubic-bezier(0.23, 1, 0.32, 1) 205ms; + } +} +.navbar-custom { + background: none; + border: none; + position: absolute; + top: 0; + left: 0; + width: 100%; + z-index: 3; + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; +} +.navbar-custom .navbar-brand { + font-weight: 800; + color: white; + height: 56px; + line-height: 25px; +} +.navbar-custom .navbar-brand:hover { + color: rgba(255, 255, 255, 0.8); +} +.navbar-custom .nav li a { + text-transform: uppercase; + font-size: 12px; + line-height: 20px; + font-weight: 800; + letter-spacing: 1px; +} +.navbar-custom .nav li a:active { + background: rgba(0, 0, 0, 0.12); +} +@media only screen and (min-width: 768px) { + .navbar-custom { + background: transparent; + border-bottom: 1px solid transparent; + } + .navbar-custom body { + font-size: 20px; + } + .navbar-custom .navbar-brand { + color: white; + padding: 20px; + line-height: 20px; + } + .navbar-custom .navbar-brand:hover, + .navbar-custom .navbar-brand:focus { + color: rgba(255, 255, 255, 0.8); + } + .navbar-custom .nav li a { + color: white; + padding: 20px; + } + .navbar-custom .nav li a:hover, + .navbar-custom .nav li a:focus { + color: rgba(255, 255, 255, 0.8); + } + .navbar-custom .nav li a:active { + background: none; + } +} +@media only screen and (min-width: 1170px) { + .navbar-custom { + -webkit-transition: background-color 0.3s; + -moz-transition: background-color 0.3s; + transition: background-color 0.3s; + /* Force Hardware Acceleration in WebKit */ + -webkit-transform: translate3d(0, 0, 0); + -moz-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + -o-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + -webkit-backface-visibility: hidden; + backface-visibility: hidden; + } + .navbar-custom.is-fixed { + /* when the user scrolls down, we hide the header right above the viewport */ + position: fixed; + top: -61px; + background-color: rgba(255, 255, 255, 0.9); + border-bottom: 1px solid #f2f2f2; + -webkit-transition: -webkit-transform 0.3s; + -moz-transition: -moz-transform 0.3s; + transition: transform 0.3s; + } + .navbar-custom.is-fixed .navbar-brand { + color: #404040; + } + .navbar-custom.is-fixed .navbar-brand:hover, + .navbar-custom.is-fixed .navbar-brand:focus { + color: #0085a1; + } + .navbar-custom.is-fixed .nav li a { + color: #404040; + } + .navbar-custom.is-fixed .nav li a:hover, + .navbar-custom.is-fixed .nav li a:focus { + color: #0085a1; + } + .navbar-custom.is-visible { + /* if the user changes the scrolling direction, we show the header */ + -webkit-transform: translate3d(0, 100%, 0); + -moz-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + -o-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } +} +.intro-header { + background: no-repeat center center; + background-color: #808080; + background-attachment: scroll; + -webkit-background-size: cover; + -moz-background-size: cover; + background-size: cover; + -o-background-size: cover; + margin-bottom: 0px; + /* 0 on mobile, modify by Hux */ +} +@media only screen and (min-width: 768px) { + .intro-header { + margin-bottom: 20px; + /* response on desktop */ + } +} +.intro-header .site-heading, +.intro-header .post-heading, +.intro-header .page-heading { + padding: 85px 0 55px; + color: white; +} +@media only screen and (min-width: 768px) { + .intro-header .site-heading, + .intro-header .post-heading, + .intro-header .page-heading { + padding: 150px 0; + } +} +.intro-header .site-heading { + padding: 95px 0 70px; +} +@media only screen and (min-width: 768px) { + .intro-header .site-heading { + padding: 150px 0; + } +} +.intro-header .site-heading, +.intro-header .page-heading { + text-align: center; +} +.intro-header .site-heading h1, +.intro-header .page-heading h1 { + margin-top: 0; + font-size: 50px; +} +.intro-header .site-heading .subheading, +.intro-header .page-heading .subheading { + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; + font-size: 18px; + line-height: 1.1; + display: block; + font-weight: 300; + margin: 10px 0 0; +} +@media only screen and (min-width: 768px) { + .intro-header .site-heading h1, + .intro-header .page-heading h1 { + font-size: 80px; + } +} +.intro-header .post-heading h1 { + font-size: 30px; + margin-bottom: 24px; +} +.intro-header .post-heading .subheading, +.intro-header .post-heading .meta { + line-height: 1.1; + display: block; +} +.intro-header .post-heading .subheading { + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; + font-size: 17px; + line-height: 1.4; + font-weight: normal; + margin: 10px 0 30px; + margin-top: -5px; +} +.intro-header .post-heading .meta { + font-family: 'Lora', 'Times New Roman', serif; + font-style: italic; + font-weight: 300; + font-size: 18px; +} +.intro-header .post-heading .meta a { + color: white; +} +@media only screen and (min-width: 768px) { + .intro-header .post-heading h1 { + font-size: 55px; + } + .intro-header .post-heading .subheading { + font-size: 30px; + } + .intro-header .post-heading .meta { + font-size: 20px; + } +} +.post-preview > a { + color: #404040; +} +.post-preview > a:hover, +.post-preview > a:focus { + text-decoration: none; + color: #0085a1; +} +.post-preview > a > .post-title { + font-size: 21px; + line-height: 1.3; + margin-top: 30px; + margin-bottom: 8px; +} +.post-preview > a > .post-subtitle { + font-size: 15px; + line-height: 1.3; + margin: 0; + font-weight: 300; + margin-bottom: 10px; +} +.post-preview > .post-meta { + font-family: 'Lora', 'Times New Roman', serif; + color: #808080; + font-size: 16px; + font-style: italic; + margin-top: 0; +} +.post-preview > .post-meta > a { + text-decoration: none; + color: #404040; +} +.post-preview > .post-meta > a:hover, +.post-preview > .post-meta > a:focus { + color: #0085a1; + text-decoration: underline; +} +@media only screen and (min-width: 768px) { + .post-preview > a > .post-title { + font-size: 26px; + line-height: 1.3; + margin-bottom: 10px; + } + .post-preview > a > .post-subtitle { + font-size: 16px; + } + .post-preview .post-meta { + font-size: 18px; + } +} +.post-content-preview { + font-size: 13px; + font-style: italic; + color: #a3a3a3; +} +.post-content-preview:hover { + color: #0085a1; +} +@media only screen and (min-width: 768px) { + .post-content-preview { + font-size: 14px; + } +} +.home-intro { + margin-bottom: 28px; +} +.home-intro .post-title { + margin-top: 8px; +} +.home-eyebrow { + margin: 0; + color: #0085a1; + font-size: 13px; + font-weight: 700; + letter-spacing: 1px; +} +.home-folder-grid { + display: grid; + grid-template-columns: 1fr; + grid-gap: 16px; + margin: 32px 0 48px; +} +@media only screen and (min-width: 768px) { + .home-folder-grid { + grid-template-columns: repeat(2, 1fr); + grid-gap: 18px; + } +} +.home-folder-card { + display: block; + min-height: 138px; + padding: 22px; + border: 1px solid #eeeeee; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.05); + text-decoration: none; + transition: all .2s ease; +} +.home-folder-card:hover, +.home-folder-card:focus { + border-color: #0085a1; + box-shadow: 0 10px 24px rgba(0, 133, 161, 0.12); + text-decoration: none; + transform: translateY(-2px); +} +.home-folder-card:hover .home-folder-title, +.home-folder-card:focus .home-folder-title { + color: #0085a1; +} +.home-folder-title, +.home-folder-description, +.home-folder-count { + display: block; +} +.home-folder-title { + color: #404040; + font-size: 20px; + font-weight: 700; + line-height: 1.3; +} +.home-folder-description { + min-height: 42px; + margin-top: 10px; + color: #808080; + font-size: 14px; + line-height: 1.5; +} +.home-folder-count { + margin-top: 16px; + color: #0085a1; + font-size: 13px; + font-weight: 600; +} +.section-heading { + font-size: 36px; + margin-top: 60px; + font-weight: 700; +} +.caption { + text-align: center; + font-size: 14px; + padding: 10px; + font-style: italic; + margin: 0; + display: block; + border-bottom-right-radius: 5px; + border-bottom-left-radius: 5px; +} +footer { + font-size: 20px; + padding: 50px 0 65px; +} +footer .list-inline { + margin: 0; + padding: 0; +} +footer .copyright { + font-size: 14px; + text-align: center; + margin-bottom: 0; +} +footer .copyright a { + color: #337ab7; +} +footer .copyright a:hover, +footer .copyright a:focus { + color: #0085a1; +} +.floating-label-form-group { + font-size: 14px; + position: relative; + margin-bottom: 0; + padding-bottom: 0.5em; + border-bottom: 1px solid #eeeeee; +} +.floating-label-form-group input, +.floating-label-form-group textarea { + z-index: 1; + position: relative; + padding-right: 0; + padding-left: 0; + border: none; + border-radius: 0; + font-size: 1.5em; + background: none; + box-shadow: none !important; + resize: none; +} +.floating-label-form-group label { + display: block; + z-index: 0; + position: relative; + top: 2em; + margin: 0; + font-size: 0.85em; + line-height: 1.764705882em; + vertical-align: middle; + vertical-align: baseline; + opacity: 0; + -webkit-transition: top 0.3s ease,opacity 0.3s ease; + -moz-transition: top 0.3s ease,opacity 0.3s ease; + -ms-transition: top 0.3s ease,opacity 0.3s ease; + transition: top 0.3s ease,opacity 0.3s ease; +} +.floating-label-form-group::not(:first-child) { + padding-left: 14px; + border-left: 1px solid #eeeeee; +} +.floating-label-form-group-with-value label { + top: 0; + opacity: 1; +} +.floating-label-form-group-with-focus label { + color: #0085a1; +} +form .row:first-child .floating-label-form-group { + border-top: 1px solid #eeeeee; +} +.btn { + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; + text-transform: uppercase; + font-size: 14px; + font-weight: 800; + letter-spacing: 1px; + border-radius: 0; + padding: 15px 25px; +} +.btn-lg { + font-size: 16px; + padding: 25px 35px; +} +.btn-default:hover, +.btn-default:focus { + background-color: #0085a1; + border: 1px solid #0085a1; + color: white; +} +.pager { + margin: 20px 0 0 !important; + padding: 0px !important; +} +.pager li > a, +.pager li > span { + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; + text-transform: uppercase; + font-size: 13px; + font-weight: 800; + letter-spacing: 1px; + padding: 10px; + background-color: white; + border-radius: 0; +} +@media only screen and (min-width: 768px) { + .pager li > a, + .pager li > span { + font-size: 14px; + padding: 15px 25px; + } +} +.pager li > a { + color: #404040; +} +.pager li > a:hover, +.pager li > a:focus { + color: white; + background-color: #0085a1; + border: 1px solid #0085a1; +} +.pager li > a:hover > span, +.pager li > a:focus > span { + color: white; +} +.pager .disabled > a, +.pager .disabled > a:hover, +.pager .disabled > a:focus, +.pager .disabled > span { + color: #808080; + background-color: #404040; + cursor: not-allowed; +} +::-moz-selection { + color: white; + text-shadow: none; + background: #0085a1; +} +::selection { + color: white; + text-shadow: none; + background: #0085a1; +} +img::selection { + color: white; + background: transparent; +} +img::-moz-selection { + color: white; + background: transparent; +} +/* Hux add tags support */ +.tags { + margin-bottom: -5px; +} +.tags a, +.tags .tag { + display: inline-block; + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 999em; + padding: 0 10px; + color: #ffffff; + line-height: 24px; + font-size: 12px; + text-decoration: none; + margin: 0 1px; + margin-bottom: 6px; +} +.tags a:hover, +.tags .tag:hover, +.tags a:active, +.tags .tag:active { + color: white; + border-color: white; + background-color: rgba(255, 255, 255, 0.4); + text-decoration: none; +} +@media only screen and (min-width: 768px) { + .tags a, + .tags .tag { + margin-right: 5px; + } +} +#tag-heading { + padding: 70px 0 60px; +} +@media only screen and (min-width: 768px) { + #tag-heading { + padding: 55px 0; + } +} +#tag_cloud { + margin: 20px 0 15px 0; +} +#tag_cloud a, +#tag_cloud .tag { + font-size: 14px; + border: none; + line-height: 28px; + margin: 0 2px; + margin-bottom: 8px; + background: #D6D6D6; +} +#tag_cloud a:hover, +#tag_cloud .tag:hover, +#tag_cloud a:active, +#tag_cloud .tag:active { + background-color: #0085a1 !important; +} +@media only screen and (min-width: 768px) { + #tag_cloud { + margin-bottom: 25px; + } +} +.tag-comments { + font-size: 12px; +} +@media only screen and (min-width: 768px) { + .tag-comments { + font-size: 14px; + } +} +.t:first-child { + margin-top: 0px; +} +.listing-seperator { + color: #0085a1; + font-size: 21px !important; +} +.listing-seperator::before { + margin-right: 5px; +} +@media only screen and (min-width: 768px) { + .listing-seperator { + font-size: 20px !important; + line-height: 2 !important; + } +} +.one-tag-list .tag-text { + font-weight: 200; + /* Hux learn from + * TypeIsBeautiful, + * [This Post](http://zhuanlan.zhihu.com/ibuick/20186806) etc. + */ + font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Arial", "PingFang SC", "Hiragino Sans GB", "STHeiti", "Microsoft YaHei", "Microsoft JhengHei", "Source Han Sans SC", "Noto Sans CJK SC", "Source Han Sans CN", "Noto Sans SC", "Source Han Sans TC", "Noto Sans CJK TC", "WenQuanYi Micro Hei", SimSun, sans-serif; + line-height: 1.7; +} +.one-tag-list .post-preview { + position: relative; +} +.one-tag-list .post-preview > a .post-title { + font-size: 16px; + font-weight: 500; + margin-top: 20px; +} +.one-tag-list .post-preview > a .post-subtitle { + font-size: 12px; +} +.one-tag-list .post-preview > .post-meta { + position: absolute; + right: 5px; + bottom: 0px; + margin: 0px; + font-size: 12px; + line-height: 12px; +} +@media only screen and (min-width: 768px) { + .one-tag-list .post-preview { + margin-left: 20px; + } + .one-tag-list .post-preview > a > .post-title { + font-size: 18px; + line-height: 1.3; + } + .one-tag-list .post-preview > a > .post-subtitle { + font-size: 14px; + } + .one-tag-list .post-preview .post-meta { + font-size: 18px; + } +} +/* Tags support End*/ +/* Hux make all img responsible in post-container */ +.post-container img { + display: block; + max-width: 100%; + height: auto; + margin: 1.5em auto 1.6em auto; +} +/* Hux Optimize UserExperience */ +.navbar-default .navbar-toggle:focus, +.navbar-default .navbar-toggle:hover { + background-color: inherit; +} +.navbar-default .navbar-toggle:active { + background-color: rgba(255, 255, 255, 0.25); +} +/* Hux customize Style for navBar button */ +.navbar-default .navbar-toggle { + border-color: transparent; + padding: 19px 16px; + margin-top: 2px; + margin-right: 2px; + margin-bottom: 2px; + border-radius: 50%; +} +.navbar-default .navbar-toggle .icon-bar { + width: 18px; + border-radius: 0px; + background-color: white; +} +.navbar-default .navbar-toggle .icon-bar + .icon-bar { + margin-top: 3px; +} +/* Hux customize Style for Duoshuo */ +.comment { + margin-top: 20px; +} +.comment #ds-thread #ds-reset a.ds-like-thread-button { + border: 1px solid #ddd; + border-radius: 0px; + background: white; + box-shadow: none; + text-shadow: none; +} +.comment #ds-thread #ds-reset li.ds-tab a.ds-current { + border: 1px solid #ddd; + border-radius: 0px; + background: white; + box-shadow: none; + text-shadow: none; +} +.comment #ds-thread #ds-reset .ds-textarea-wrapper { + background: none; +} +.comment #ds-thread #ds-reset .ds-gradient-bg { + background: none; +} +.comment #ds-thread #ds-reset .ds-post-options { + border-bottom: 1px solid #ccc; +} +.comment #ds-thread #ds-reset .ds-post-button { + border-bottom: 1px solid #ccc; +} +.comment #ds-thread #ds-reset .ds-post-button { + background: white; + box-shadow: none; +} +.comment #ds-thread #ds-reset .ds-post-button:hover { + background: #eeeeee; +} +#ds-smilies-tooltip ul.ds-smilies-tabs li a { + background: white !important; +} +.page-fullscreen .intro-header { + position: fixed; + left: 0; + top: 0; + width: 100%; + height: 100%; +} +.page-fullscreen #tag-heading { + position: fixed; + left: 0; + top: 0; + padding-bottom: 150px; + width: 100%; + height: 100%; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-box-pack: center; + -webkit-box-align: center; + display: -webkit-flex; + -webkit-align-items: center; + -webkit-justify-content: center; + -webkit-flex-direction: column; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; +} +.page-fullscreen footer { + position: absolute; + width: 100%; + bottom: 0; + padding-bottom: 20px; + opacity: 0.6; + color: #fff; +} +.page-fullscreen footer .copyright { + color: #fff; +} +.page-fullscreen footer .copyright a { + color: #fff; +} +.page-fullscreen footer .copyright a:hover { + color: #ddd; +} diff --git a/css/hux-blog.min.css b/css/hux-blog.min.css new file mode 100644 index 000000000..711189f35 --- /dev/null +++ b/css/hux-blog.min.css @@ -0,0 +1 @@ +@media (min-width:1200px){.post-container,.sidebar-container{padding-right:5%}}@media (min-width:768px){.post-container{padding-right:5%}}.sidebar-container{color:#bfbfbf;font-size:14px}.sidebar-container h5{color:gray;padding-bottom:1em}.sidebar-container h5 a{color:gray!important;text-decoration:none}.sidebar-container a{color:#bfbfbf!important}.sidebar-container a:hover,.sidebar-container a:active{color:#0085a1!important}.sidebar-container .tags a{border-color:#bfbfbf}.sidebar-container .tags a:hover,.sidebar-container .tags a:active{border-color:#0085a1}.sidebar-container .short-about img{width:80%;display:block;border-radius:5px;margin-bottom:20px}.sidebar-container .short-about p{margin-top:0;margin-bottom:20px}.sidebar-container .short-about .list-inline>li{padding-left:0}.catalog-container{padding:0}.side-catalog{display:block;overflow:auto;height:100%;padding-bottom:40px;width:195px}.side-catalog.fixed{position:fixed;top:-21px}.side-catalog.fold .catalog-toggle::before{content:"+"}.side-catalog.fold .catalog-body{display:none}.side-catalog .catalog-toggle::before{content:"−";position:relative;margin-right:5px;bottom:1px}.side-catalog .catalog-body{position:relative;list-style:none;height:auto;overflow:hidden;padding-left:0;padding-right:5px;text-indent:0}.side-catalog .catalog-body li{position:relative;list-style:none}.side-catalog .catalog-body li a{padding-left:10px;max-width:180px;display:inline-block;vertical-align:middle;height:30px;line-height:30px;overflow:hidden;text-decoration:none;white-space:nowrap;text-overflow:ellipsis}.side-catalog .catalog-body .h1_nav,.side-catalog .catalog-body .h2_nav,.side-catalog .catalog-body .h3_nav{margin-left:0;font-size:13px;font-weight:700}.side-catalog .catalog-body .h4_nav,.side-catalog .catalog-body .h5_nav,.side-catalog .catalog-body .h6_nav{margin-left:10px;font-size:12px}.side-catalog .catalog-body .h4_nav a,.side-catalog .catalog-body .h5_nav a,.side-catalog .catalog-body .h6_nav a{max-width:170px}.side-catalog .catalog-body .active{border-radius:4px;background-color:#F5F5F5}.side-catalog .catalog-body .active a{color:#0085a1!important}@media (max-width:1200px){.side-catalog{display:none}}body{font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7;font-size:16px;color:#404040;overflow-x:hidden}p{margin:30px 0}h1,h2,h3,h4,h5,h6{font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7;line-height:1.1;font-weight:700}h4{font-size:21px}a{color:#404040}a:hover,a:focus{color:#0085a1}a img:hover,a img:focus{cursor:zoom-in}article{overflow-x:hidden}blockquote{color:gray;font-style:italic;font-size:.95em;margin:20px 0 20px}blockquote p{margin:0}small.img-hint{display:block;margin-top:-20px;text-align:center}br+small.img-hint{margin-top:-40px}img.shadow{box-shadow:rgba(0,0,0,.258824) 0 2px 5px 0}select{-webkit-appearance:none;margin-top:15px;color:#337ab7;border-color:#337ab7;padding:0 .4em;background:#fff}select.sel-lang{min-height:28px;font-size:14px}.table th,.table td{border:1px solid #eee!important}hr.small{max-width:100px;margin:15px auto;border-width:4px;border-color:#fff}pre,.table-responsive{-webkit-overflow-scrolling:touch}pre code{display:block;width:auto;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:anywhere}.postlist-container{margin-bottom:15px}.post-container a{color:#337ab7}.post-container a:hover,.post-container a:focus{color:#0085a1}.post-container h1,.post-container h2,.post-container h3,.post-container h4,.post-container h5,.post-container h6{margin:30px 0 10px}.post-container h5{font-size:19px;font-weight:600;color:gray}.post-container h5+p{margin-top:5px}.post-container h6{font-size:16px;font-weight:600;color:gray}.post-container h6+p{margin-top:5px}.post-container ul,.post-container ol{margin-bottom:40px}@media screen and (max-width:768px){.post-container ul,.post-container ol{padding-left:30px}}@media screen and (max-width:500px){.post-container ul,.post-container ol{padding-left:20px}}.post-container ol ol,.post-container ol ul,.post-container ul ol,.post-container ul ul{margin-bottom:5px}.post-container li p{margin:0;margin-bottom:5px}.post-container li h1,.post-container li h2,.post-container li h3,.post-container li h4,.post-container li h5,.post-container li h6{line-height:2;margin-top:20px}.post-container .pager li{width:48%}.post-container .pager li.next{float:right}.post-container .pager li.previous{float:left}.post-container .pager li>a{width:100%}.post-container .pager li>a>span{color:gray;font-weight:400;letter-spacing:.5px}@media only screen and (max-width:767px){.navbar-default .navbar-collapse{position:absolute;right:0;border:none;background:#fff;box-shadow:0 5px 10px 2px rgba(0,0,0,.2);box-shadow:rgba(0,0,0,.117647) 0 1px 6px,rgba(0,0,0,.239216) 0 1px 4px;border-radius:2px;width:170px}#huxblog_navbar{opacity:0;transform:scaleX(0);transform-origin:top right;transition:all 200ms cubic-bezier(0.47,0,.4,.99) 0ms;-webkit-transform:scaleX(0);-webkit-transform-origin:top right;-webkit-transition:all 200ms cubic-bezier(0.47,0,.4,.99) 0ms}#huxblog_navbar a{font-size:13px;line-height:28px}#huxblog_navbar .navbar-collapse{height:0;transform:scaleY(0);transform-origin:top right;transition:transform 400ms cubic-bezier(0.32,1,.23,1) 0ms;-webkit-transform:scaleY(0);-webkit-transform-origin:top right;-webkit-transition:-webkit-transform 400ms cubic-bezier(0.32,1,.23,1) 0ms}#huxblog_navbar li{opacity:0;transition:opacity 100ms cubic-bezier(0.23,1,.32,1) 0ms;-webkit-transition:opacity 100ms cubic-bezier(0.23,1,.32,1) 0ms}#huxblog_navbar.in{transform:scaleX(1);-webkit-transform:scaleX(1);opacity:1;transition:all 250ms cubic-bezier(0.23,1,.32,1) 0ms;-webkit-transition:all 250ms cubic-bezier(0.23,1,.32,1) 0ms}#huxblog_navbar.in .navbar-collapse{transform:scaleY(1);-webkit-transform:scaleY(1);transition:transform 500ms cubic-bezier(0.23,1,.32,1);-webkit-transition:-webkit-transform 500ms cubic-bezier(0.23,1,.32,1)}#huxblog_navbar.in li{opacity:1;transition:opacity 450ms cubic-bezier(0.23,1,.32,1) 205ms;-webkit-transition:opacity 450ms cubic-bezier(0.23,1,.32,1) 205ms}}.navbar-custom{background:0 0;border:none;position:absolute;top:0;left:0;width:100%;z-index:3;font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7}.navbar-custom .navbar-brand{font-weight:800;color:#fff;height:56px;line-height:25px}.navbar-custom .navbar-brand:hover{color:rgba(255,255,255,.8)}.navbar-custom .nav li a{text-transform:uppercase;font-size:12px;line-height:20px;font-weight:800;letter-spacing:1px}.navbar-custom .nav li a:active{background:rgba(0,0,0,.12)}@media only screen and (min-width:768px){.navbar-custom{background:0 0;border-bottom:1px solid transparent}.navbar-custom body{font-size:20px}.navbar-custom .navbar-brand{color:#fff;padding:20px;line-height:20px}.navbar-custom .navbar-brand:hover,.navbar-custom .navbar-brand:focus{color:rgba(255,255,255,.8)}.navbar-custom .nav li a{color:#fff;padding:20px}.navbar-custom .nav li a:hover,.navbar-custom .nav li a:focus{color:rgba(255,255,255,.8)}.navbar-custom .nav li a:active{background:0 0}}@media only screen and (min-width:1170px){.navbar-custom{-webkit-transition:background-color .3s;-moz-transition:background-color .3s;transition:background-color .3s;-webkit-transform:translate3d(0,0,0);-moz-transform:translate3d(0,0,0);-ms-transform:translate3d(0,0,0);-o-transform:translate3d(0,0,0);transform:translate3d(0,0,0);-webkit-backface-visibility:hidden;backface-visibility:hidden}.navbar-custom.is-fixed{position:fixed;top:-61px;background-color:rgba(255,255,255,.9);border-bottom:1px solid #f2f2f2;-webkit-transition:-webkit-transform .3s;-moz-transition:-moz-transform .3s;transition:transform .3s}.navbar-custom.is-fixed .navbar-brand{color:#404040}.navbar-custom.is-fixed .navbar-brand:hover,.navbar-custom.is-fixed .navbar-brand:focus{color:#0085a1}.navbar-custom.is-fixed .nav li a{color:#404040}.navbar-custom.is-fixed .nav li a:hover,.navbar-custom.is-fixed .nav li a:focus{color:#0085a1}.navbar-custom.is-visible{-webkit-transform:translate3d(0,100%,0);-moz-transform:translate3d(0,100%,0);-ms-transform:translate3d(0,100%,0);-o-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.intro-header{background:no-repeat center center;background-color:gray;background-attachment:scroll;-webkit-background-size:cover;-moz-background-size:cover;background-size:cover;-o-background-size:cover;margin-bottom:0}@media only screen and (min-width:768px){.intro-header{margin-bottom:20px}}.intro-header .site-heading,.intro-header .post-heading,.intro-header .page-heading{padding:85px 0 55px;color:#fff}@media only screen and (min-width:768px){.intro-header .site-heading,.intro-header .post-heading,.intro-header .page-heading{padding:150px 0}}.intro-header .site-heading{padding:95px 0 70px}@media only screen and (min-width:768px){.intro-header .site-heading{padding:150px 0}}.intro-header .site-heading,.intro-header .page-heading{text-align:center}.intro-header .site-heading h1,.intro-header .page-heading h1{margin-top:0;font-size:50px}.intro-header .site-heading .subheading,.intro-header .page-heading .subheading{font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7;font-size:18px;line-height:1.1;display:block;font-weight:300;margin:10px 0 0}@media only screen and (min-width:768px){.intro-header .site-heading h1,.intro-header .page-heading h1{font-size:80px}}.intro-header .post-heading h1{font-size:30px;margin-bottom:24px}.intro-header .post-heading .subheading,.intro-header .post-heading .meta{line-height:1.1;display:block}.intro-header .post-heading .subheading{font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7;font-size:17px;line-height:1.4;font-weight:400;margin:10px 0 30px;margin-top:-5px}.intro-header .post-heading .meta{font-family:Lora,'Times New Roman',serif;font-style:italic;font-weight:300;font-size:18px}.intro-header .post-heading .meta a{color:#fff}@media only screen and (min-width:768px){.intro-header .post-heading h1{font-size:55px}.intro-header .post-heading .subheading{font-size:30px}.intro-header .post-heading .meta{font-size:20px}}.post-preview>a{color:#404040}.post-preview>a:hover,.post-preview>a:focus{text-decoration:none;color:#0085a1}.post-preview>a>.post-title{font-size:21px;line-height:1.3;margin-top:30px;margin-bottom:8px}.post-preview>a>.post-subtitle{font-size:15px;line-height:1.3;margin:0;font-weight:300;margin-bottom:10px}.post-preview>.post-meta{font-family:Lora,'Times New Roman',serif;color:gray;font-size:16px;font-style:italic;margin-top:0}.post-preview>.post-meta>a{text-decoration:none;color:#404040}.post-preview>.post-meta>a:hover,.post-preview>.post-meta>a:focus{color:#0085a1;text-decoration:underline}@media only screen and (min-width:768px){.post-preview>a>.post-title{font-size:26px;line-height:1.3;margin-bottom:10px}.post-preview>a>.post-subtitle{font-size:16px}.post-preview .post-meta{font-size:18px}}.post-content-preview{font-size:13px;font-style:italic;color:#a3a3a3}.post-content-preview:hover{color:#0085a1}@media only screen and (min-width:768px){.post-content-preview{font-size:14px}}.home-intro{margin-bottom:28px}.home-intro .post-title{margin-top:8px}.home-eyebrow{margin:0;color:#0085a1;font-size:13px;font-weight:700;letter-spacing:1px}.home-folder-grid{display:grid;grid-template-columns:1fr;grid-gap:16px;margin:32px 0 48px}@media only screen and (min-width:768px){.home-folder-grid{grid-template-columns:repeat(2,1fr);grid-gap:18px}}.home-folder-card{display:block;min-height:138px;padding:22px;border:1px solid #eee;border-radius:12px;background:#fff;box-shadow:0 6px 18px rgba(0,0,0,.05);text-decoration:none;transition:all .2s ease}.home-folder-card:hover,.home-folder-card:focus{border-color:#0085a1;box-shadow:0 10px 24px rgba(0,133,161,.12);text-decoration:none;transform:translateY(-2px)}.home-folder-card:hover .home-folder-title,.home-folder-card:focus .home-folder-title{color:#0085a1}.home-folder-title,.home-folder-description,.home-folder-count{display:block}.home-folder-title{color:#404040;font-size:20px;font-weight:700;line-height:1.3}.home-folder-description{min-height:42px;margin-top:10px;color:gray;font-size:14px;line-height:1.5}.home-folder-count{margin-top:16px;color:#0085a1;font-size:13px;font-weight:600}.section-heading{font-size:36px;margin-top:60px;font-weight:700}.caption{text-align:center;font-size:14px;padding:10px;font-style:italic;margin:0;display:block;border-bottom-right-radius:5px;border-bottom-left-radius:5px}footer{font-size:20px;padding:50px 0 65px}footer .list-inline{margin:0;padding:0}footer .copyright{font-size:14px;text-align:center;margin-bottom:0}footer .copyright a{color:#337ab7}footer .copyright a:hover,footer .copyright a:focus{color:#0085a1}.floating-label-form-group{font-size:14px;position:relative;margin-bottom:0;padding-bottom:.5em;border-bottom:1px solid #eee}.floating-label-form-group input,.floating-label-form-group textarea{z-index:1;position:relative;padding-right:0;padding-left:0;border:none;border-radius:0;font-size:1.5em;background:0 0;box-shadow:none!important;resize:none}.floating-label-form-group label{display:block;z-index:0;position:relative;top:2em;margin:0;font-size:.85em;line-height:1.764705882em;vertical-align:middle;vertical-align:baseline;opacity:0;-webkit-transition:top .3s ease,opacity .3s ease;-moz-transition:top .3s ease,opacity .3s ease;-ms-transition:top .3s ease,opacity .3s ease;transition:top .3s ease,opacity .3s ease}.floating-label-form-group::not(:first-child){padding-left:14px;border-left:1px solid #eee}.floating-label-form-group-with-value label{top:0;opacity:1}.floating-label-form-group-with-focus label{color:#0085a1}form .row:first-child .floating-label-form-group{border-top:1px solid #eee}.btn{font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7;text-transform:uppercase;font-size:14px;font-weight:800;letter-spacing:1px;border-radius:0;padding:15px 25px}.btn-lg{font-size:16px;padding:25px 35px}.btn-default:hover,.btn-default:focus{background-color:#0085a1;border:1px solid #0085a1;color:#fff}.pager{margin:20px 0 0!important;padding:0!important}.pager li>a,.pager li>span{font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7;text-transform:uppercase;font-size:13px;font-weight:800;letter-spacing:1px;padding:10px;background-color:#fff;border-radius:0}@media only screen and (min-width:768px){.pager li>a,.pager li>span{font-size:14px;padding:15px 25px}}.pager li>a{color:#404040}.pager li>a:hover,.pager li>a:focus{color:#fff;background-color:#0085a1;border:1px solid #0085a1}.pager li>a:hover>span,.pager li>a:focus>span{color:#fff}.pager .disabled>a,.pager .disabled>a:hover,.pager .disabled>a:focus,.pager .disabled>span{color:gray;background-color:#404040;cursor:not-allowed}::-moz-selection{color:#fff;text-shadow:none;background:#0085a1}::selection{color:#fff;text-shadow:none;background:#0085a1}img::selection{color:#fff;background:0 0}img::-moz-selection{color:#fff;background:0 0}.tags{margin-bottom:-5px}.tags a,.tags .tag{display:inline-block;border:1px solid rgba(255,255,255,.8);border-radius:999em;padding:0 10px;color:#fff;line-height:24px;font-size:12px;text-decoration:none;margin:0 1px;margin-bottom:6px}.tags a:hover,.tags .tag:hover,.tags a:active,.tags .tag:active{color:#fff;border-color:#fff;background-color:rgba(255,255,255,.4);text-decoration:none}@media only screen and (min-width:768px){.tags a,.tags .tag{margin-right:5px}}#tag-heading{padding:70px 0 60px}@media only screen and (min-width:768px){#tag-heading{padding:55px 0}}#tag_cloud{margin:20px 0 15px 0}#tag_cloud a,#tag_cloud .tag{font-size:14px;border:none;line-height:28px;margin:0 2px;margin-bottom:8px;background:#D6D6D6}#tag_cloud a:hover,#tag_cloud .tag:hover,#tag_cloud a:active,#tag_cloud .tag:active{background-color:#0085a1!important}@media only screen and (min-width:768px){#tag_cloud{margin-bottom:25px}}.tag-comments{font-size:12px}@media only screen and (min-width:768px){.tag-comments{font-size:14px}}.t:first-child{margin-top:0}.listing-seperator{color:#0085a1;font-size:21px!important}.listing-seperator::before{margin-right:5px}@media only screen and (min-width:768px){.listing-seperator{font-size:20px!important;line-height:2!important}}.one-tag-list .tag-text{font-weight:200;font-family:-apple-system,BlinkMacSystemFont,"Helvetica Neue",Arial,"PingFang SC","Hiragino Sans GB",STHeiti,"Microsoft YaHei","Microsoft JhengHei","Source Han Sans SC","Noto Sans CJK SC","Source Han Sans CN","Noto Sans SC","Source Han Sans TC","Noto Sans CJK TC","WenQuanYi Micro Hei",SimSun,sans-serif;line-height:1.7}.one-tag-list .post-preview{position:relative}.one-tag-list .post-preview>a .post-title{font-size:16px;font-weight:500;margin-top:20px}.one-tag-list .post-preview>a .post-subtitle{font-size:12px}.one-tag-list .post-preview>.post-meta{position:absolute;right:5px;bottom:0;margin:0;font-size:12px;line-height:12px}@media only screen and (min-width:768px){.one-tag-list .post-preview{margin-left:20px}.one-tag-list .post-preview>a>.post-title{font-size:18px;line-height:1.3}.one-tag-list .post-preview>a>.post-subtitle{font-size:14px}.one-tag-list .post-preview .post-meta{font-size:18px}}.post-container img{display:block;max-width:100%;height:auto;margin:1.5em auto 1.6em auto}.navbar-default .navbar-toggle:focus,.navbar-default .navbar-toggle:hover{background-color:inherit}.navbar-default .navbar-toggle:active{background-color:rgba(255,255,255,.25)}.navbar-default .navbar-toggle{border-color:transparent;padding:19px 16px;margin-top:2px;margin-right:2px;margin-bottom:2px;border-radius:50%}.navbar-default .navbar-toggle .icon-bar{width:18px;border-radius:0;background-color:#fff}.navbar-default .navbar-toggle .icon-bar+.icon-bar{margin-top:3px}.comment{margin-top:20px}.comment #ds-thread #ds-reset a.ds-like-thread-button{border:1px solid #ddd;border-radius:0;background:#fff;box-shadow:none;text-shadow:none}.comment #ds-thread #ds-reset li.ds-tab a.ds-current{border:1px solid #ddd;border-radius:0;background:#fff;box-shadow:none;text-shadow:none}.comment #ds-thread #ds-reset .ds-textarea-wrapper{background:0 0}.comment #ds-thread #ds-reset .ds-gradient-bg{background:0 0}.comment #ds-thread #ds-reset .ds-post-options{border-bottom:1px solid #ccc}.comment #ds-thread #ds-reset .ds-post-button{border-bottom:1px solid #ccc}.comment #ds-thread #ds-reset .ds-post-button{background:#fff;box-shadow:none}.comment #ds-thread #ds-reset .ds-post-button:hover{background:#eee}#ds-smilies-tooltip ul.ds-smilies-tabs li a{background:#fff!important}.page-fullscreen .intro-header{position:fixed;left:0;top:0;width:100%;height:100%}.page-fullscreen #tag-heading{position:fixed;left:0;top:0;padding-bottom:150px;width:100%;height:100%;display:-webkit-box;-webkit-box-orient:vertical;-webkit-box-pack:center;-webkit-box-align:center;display:-webkit-flex;-webkit-align-items:center;-webkit-justify-content:center;-webkit-flex-direction:column;display:flex;align-items:center;justify-content:center;flex-direction:column}.page-fullscreen footer{position:absolute;width:100%;bottom:0;padding-bottom:20px;opacity:.6;color:#fff}.page-fullscreen footer .copyright{color:#fff}.page-fullscreen footer .copyright a{color:#fff}.page-fullscreen footer .copyright a:hover{color:#ddd} \ No newline at end of file diff --git a/css/syntax.css b/css/syntax.css new file mode 100644 index 000000000..101ca47c3 --- /dev/null +++ b/css/syntax.css @@ -0,0 +1,86 @@ +/* wrap long lines in code blocks for easier reading */ +/* from http://stackoverflow.com/a/23393920 */ + +.highlight pre code * { + white-space: inherit; // allow highlighted spans to wrap with the parent +} + +.highlight pre { + overflow-x: auto; // this sets the scrolling in x +} + +.highlight pre code { + white-space: pre-wrap; // preserve formatting while allowing wrap + word-wrap: break-word; + overflow-wrap: anywhere; +} + + +/* + * GitHub style for Pygments syntax highlighter, for use with Jekyll + * Courtesy of GitHub.com + */ + +.highlight pre, pre, .highlight .hll { background-color: #f8f8f8; border: 1px solid #ccc; padding: 6px 10px; border-radius: 3px; } +.highlight .c { color: #999988; font-style: italic; } +.highlight .err { color: #a61717; background-color: #e3d2d2; } +.highlight .k { font-weight: bold; } +.highlight .o { font-weight: bold; } +.highlight .cm { color: #999988; font-style: italic; } +.highlight .cp { color: #999999; font-weight: bold; } +.highlight .c1 { color: #999988; font-style: italic; } +.highlight .cs { color: #999999; font-weight: bold; font-style: italic; } +.highlight .gd { color: #000000; background-color: #ffdddd; } +.highlight .gd .x { color: #000000; background-color: #ffaaaa; } +.highlight .ge { font-style: italic; } +.highlight .gr { color: #aa0000; } +.highlight .gh { color: #999999; } +.highlight .gi { color: #000000; background-color: #ddffdd; } +.highlight .gi .x { color: #000000; background-color: #aaffaa; } +.highlight .go { color: #888888; } +.highlight .gp { color: #555555; } +.highlight .gs { font-weight: bold; } +.highlight .gu { color: #800080; font-weight: bold; } +.highlight .gt { color: #aa0000; } +.highlight .kc { font-weight: bold; } +.highlight .kd { font-weight: bold; } +.highlight .kn { font-weight: bold; } +.highlight .kp { font-weight: bold; } +.highlight .kr { font-weight: bold; } +.highlight .kt { color: #445588; font-weight: bold; } +.highlight .m { color: #009999; } +.highlight .s { color: #dd1144; } +.highlight .n { color: #333333; } +.highlight .na { color: teal; } +.highlight .nb { color: #0086b3; } +.highlight .nc { color: #445588; font-weight: bold; } +.highlight .no { color: teal; } +.highlight .ni { color: purple; } +.highlight .ne { color: #990000; font-weight: bold; } +.highlight .nf { color: #990000; font-weight: bold; } +.highlight .nn { color: #555555; } +.highlight .nt { color: navy; } +.highlight .nv { color: teal; } +.highlight .ow { font-weight: bold; } +.highlight .w { color: #bbbbbb; } +.highlight .mf { color: #009999; } +.highlight .mh { color: #009999; } +.highlight .mi { color: #009999; } +.highlight .mo { color: #009999; } +.highlight .sb { color: #dd1144; } +.highlight .sc { color: #dd1144; } +.highlight .sd { color: #dd1144; } +.highlight .s2 { color: #dd1144; } +.highlight .se { color: #dd1144; } +.highlight .sh { color: #dd1144; } +.highlight .si { color: #dd1144; } +.highlight .sx { color: #dd1144; } +.highlight .sr { color: #009926; } +.highlight .s1 { color: #dd1144; } +.highlight .ss { color: #990073; } +.highlight .bp { color: #999999; } +.highlight .vc { color: teal; } +.highlight .vg { color: teal; } +.highlight .vi { color: teal; } +.highlight .il { color: #009999; } +.highlight .gc { color: #999; background-color: #EAF2F5; } diff --git a/feed.xml b/feed.xml new file mode 100644 index 000000000..022378beb --- /dev/null +++ b/feed.xml @@ -0,0 +1,30 @@ +--- +layout: null +--- + + + + {{ site.title | xml_escape }} + {{ site.description | xml_escape }} + {{ site.url }}{{ site.baseurl }}/ + + {{ site.time | date_to_rfc822 }} + {{ site.time | date_to_rfc822 }} + Jekyll v{{ jekyll.version }} + {% for post in site.posts limit:10 %} + + {{ post.title | xml_escape }} + {{ post.content | xml_escape }} + {{ post.date | date_to_rfc822 }} + {{ post.url | prepend: site.baseurl | prepend: site.url }} + {{ post.url | prepend: site.baseurl | prepend: site.url }} + {% for tag in post.tags %} + {{ tag | xml_escape }} + {% endfor %} + {% for cat in post.categories %} + {{ cat | xml_escape }} + {% endfor %} + + {% endfor %} + + diff --git a/folders/android/index.html b/folders/android/index.html new file mode 100644 index 000000000..cbf2f262a --- /dev/null +++ b/folders/android/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: Android +description: 按时间顺序浏览 Android 相关文章 +permalink: /folders/android/ +folder: android +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/build/index.html b/folders/build/index.html new file mode 100644 index 000000000..042e389d8 --- /dev/null +++ b/folders/build/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 构建工具 +description: 按时间顺序浏览构建工具相关文章 +permalink: /folders/build/ +folder: build +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/cpp/index.html b/folders/cpp/index.html new file mode 100644 index 000000000..badec0361 --- /dev/null +++ b/folders/cpp/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: C++ +description: 按时间顺序浏览 C++ 相关文章 +permalink: /folders/cpp/ +folder: cpp +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/debug/index.html b/folders/debug/index.html new file mode 100644 index 000000000..142fc1a65 --- /dev/null +++ b/folders/debug/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 调试排障 +description: 按时间顺序浏览调试排障相关文章 +permalink: /folders/debug/ +folder: debug +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/devops/index.html b/folders/devops/index.html new file mode 100644 index 000000000..d408cf925 --- /dev/null +++ b/folders/devops/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: DevOps +description: 按时间顺序浏览 DevOps 相关文章 +permalink: /folders/devops/ +folder: devops +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/jenkins/index.html b/folders/jenkins/index.html new file mode 100644 index 000000000..7a7e7b09c --- /dev/null +++ b/folders/jenkins/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: Jenkins +description: 按时间顺序浏览 Jenkins 相关文章 +permalink: /folders/jenkins/ +folder: jenkins +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/leetcode/index.html b/folders/leetcode/index.html new file mode 100644 index 000000000..9f0e97930 --- /dev/null +++ b/folders/leetcode/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: LeetCode +description: 按时间顺序浏览算法题相关文章 +permalink: /folders/leetcode/ +folder: leetcode +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/life/index.html b/folders/life/index.html new file mode 100644 index 000000000..ca6924bca --- /dev/null +++ b/folders/life/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 生活随笔 +description: 按时间顺序浏览生活随笔 +permalink: /folders/life/ +folder: life +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/misc/index.html b/folders/misc/index.html new file mode 100644 index 000000000..e722c59b5 --- /dev/null +++ b/folders/misc/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 杂项记录 +description: 按时间顺序浏览杂项技术记录 +permalink: /folders/misc/ +folder: misc +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/network/index.html b/folders/network/index.html new file mode 100644 index 000000000..6c41d21ea --- /dev/null +++ b/folders/network/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 网络编程 +description: 按时间顺序浏览网络编程相关文章 +permalink: /folders/network/ +folder: network +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/notes/index.html b/folders/notes/index.html new file mode 100644 index 000000000..1af5423dc --- /dev/null +++ b/folders/notes/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 学习笔记 +description: 按时间顺序浏览学习笔记 +permalink: /folders/notes/ +folder: notes +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/perf/index.html b/folders/perf/index.html new file mode 100644 index 000000000..1d9c5570b --- /dev/null +++ b/folders/perf/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 性能优化 +description: 按时间顺序浏览性能优化相关文章 +permalink: /folders/perf/ +folder: perf +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/python/index.html b/folders/python/index.html new file mode 100644 index 000000000..cc3f07d03 --- /dev/null +++ b/folders/python/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: Python +description: 按时间顺序浏览 Python 相关文章 +permalink: /folders/python/ +folder: python +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/folders/tools/index.html b/folders/tools/index.html new file mode 100644 index 000000000..f233c27a6 --- /dev/null +++ b/folders/tools/index.html @@ -0,0 +1,9 @@ +--- +layout: page +title: 工具技巧 +description: 按时间顺序浏览工具技巧相关文章 +permalink: /folders/tools/ +folder: tools +--- + +{% include post-folder-browser.html mode="single" folder=page.folder %} diff --git a/fonts/glyphicons-halflings-regular.eot b/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 000000000..b93a4953f Binary files /dev/null and b/fonts/glyphicons-halflings-regular.eot differ diff --git a/fonts/glyphicons-halflings-regular.svg b/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 000000000..94fb5490a --- /dev/null +++ b/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/fonts/glyphicons-halflings-regular.ttf b/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 000000000..1413fc609 Binary files /dev/null and b/fonts/glyphicons-halflings-regular.ttf differ diff --git a/fonts/glyphicons-halflings-regular.woff b/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 000000000..9e612858f Binary files /dev/null and b/fonts/glyphicons-halflings-regular.woff differ diff --git a/fonts/glyphicons-halflings-regular.woff2 b/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 000000000..64539b54c Binary files /dev/null and b/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/img/404-bg.jpg b/img/404-bg.jpg new file mode 100644 index 000000000..c36f41e12 Binary files /dev/null and b/img/404-bg.jpg differ diff --git a/img/BY_bolg_logo.png b/img/BY_bolg_logo.png new file mode 100644 index 000000000..4007bb088 Binary files /dev/null and b/img/BY_bolg_logo.png differ diff --git a/img/about-BY-gentle.jpg b/img/about-BY-gentle.jpg new file mode 100644 index 000000000..c36f41e12 Binary files /dev/null and b/img/about-BY-gentle.jpg differ diff --git a/img/apple-touch-icon.png b/img/apple-touch-icon.png new file mode 100644 index 000000000..73e5511a6 Binary files /dev/null and b/img/apple-touch-icon.png differ diff --git a/img/avatar-by.jpg b/img/avatar-by.jpg new file mode 100644 index 000000000..c36f41e12 Binary files /dev/null and b/img/avatar-by.jpg differ diff --git a/img/avatar_g.jpg b/img/avatar_g.jpg new file mode 100644 index 000000000..2688e42b9 Binary files /dev/null and b/img/avatar_g.jpg differ diff --git a/img/avatar_m.jpg b/img/avatar_m.jpg new file mode 100644 index 000000000..27a741a78 Binary files /dev/null and b/img/avatar_m.jpg differ diff --git a/img/favicon.ico b/img/favicon.ico new file mode 100644 index 000000000..21871dfda Binary files /dev/null and b/img/favicon.ico differ diff --git a/img/home-bg-art.jpg b/img/home-bg-art.jpg new file mode 100644 index 000000000..3c5a73b4a Binary files /dev/null and b/img/home-bg-art.jpg differ diff --git a/img/home-bg-geek.jpg b/img/home-bg-geek.jpg new file mode 100644 index 000000000..137f0a876 Binary files /dev/null and b/img/home-bg-geek.jpg differ diff --git a/img/home-bg-o.jpg b/img/home-bg-o.jpg new file mode 100644 index 000000000..3f0e91b8b Binary files /dev/null and b/img/home-bg-o.jpg differ diff --git a/img/home-bg.jpg b/img/home-bg.jpg new file mode 100644 index 000000000..d850f3f2e Binary files /dev/null and b/img/home-bg.jpg differ diff --git a/img/post-bg-2015.jpg b/img/post-bg-2015.jpg new file mode 100644 index 000000000..ba4e1024a Binary files /dev/null and b/img/post-bg-2015.jpg differ diff --git a/img/post-bg-BJJ.jpg b/img/post-bg-BJJ.jpg new file mode 100644 index 000000000..a10e13dd9 Binary files /dev/null and b/img/post-bg-BJJ.jpg differ diff --git a/img/post-bg-YesOrNo.jpg b/img/post-bg-YesOrNo.jpg new file mode 100644 index 000000000..5bfb00721 Binary files /dev/null and b/img/post-bg-YesOrNo.jpg differ diff --git a/img/post-bg-alibaba.jpg b/img/post-bg-alibaba.jpg new file mode 100644 index 000000000..8bad8f555 Binary files /dev/null and b/img/post-bg-alibaba.jpg differ diff --git a/img/post-bg-android.jpg b/img/post-bg-android.jpg new file mode 100644 index 000000000..aa63712f4 Binary files /dev/null and b/img/post-bg-android.jpg differ diff --git a/img/post-bg-coffee.jpeg b/img/post-bg-coffee.jpeg new file mode 100644 index 000000000..78f33cb40 Binary files /dev/null and b/img/post-bg-coffee.jpeg differ diff --git a/img/post-bg-cook.jpg b/img/post-bg-cook.jpg new file mode 100644 index 000000000..af6f81fde Binary files /dev/null and b/img/post-bg-cook.jpg differ diff --git a/img/post-bg-debug.png b/img/post-bg-debug.png new file mode 100644 index 000000000..162c67fdc Binary files /dev/null and b/img/post-bg-debug.png differ diff --git a/img/post-bg-desk.jpg b/img/post-bg-desk.jpg new file mode 100644 index 000000000..daf7266a8 Binary files /dev/null and b/img/post-bg-desk.jpg differ diff --git a/img/post-bg-digital-native.jpg b/img/post-bg-digital-native.jpg new file mode 100644 index 000000000..6d6579645 Binary files /dev/null and b/img/post-bg-digital-native.jpg differ diff --git a/img/post-bg-e2e-ux.jpg b/img/post-bg-e2e-ux.jpg new file mode 100644 index 000000000..d73084035 Binary files /dev/null and b/img/post-bg-e2e-ux.jpg differ diff --git a/img/post-bg-github-cup.jpg b/img/post-bg-github-cup.jpg new file mode 100644 index 000000000..a7a6f01b2 Binary files /dev/null and b/img/post-bg-github-cup.jpg differ diff --git a/img/post-bg-hacker.jpg b/img/post-bg-hacker.jpg new file mode 100644 index 000000000..0050e0554 Binary files /dev/null and b/img/post-bg-hacker.jpg differ diff --git a/img/post-bg-iWatch.jpg b/img/post-bg-iWatch.jpg new file mode 100644 index 000000000..c0668890e Binary files /dev/null and b/img/post-bg-iWatch.jpg differ diff --git a/img/post-bg-ios10.jpg b/img/post-bg-ios10.jpg new file mode 100644 index 000000000..69d369a8a Binary files /dev/null and b/img/post-bg-ios10.jpg differ diff --git a/img/post-bg-ios9-web.jpg b/img/post-bg-ios9-web.jpg new file mode 100644 index 000000000..a0f797c5f Binary files /dev/null and b/img/post-bg-ios9-web.jpg differ diff --git a/img/post-bg-ioses.jpg b/img/post-bg-ioses.jpg new file mode 100644 index 000000000..798ea4dcd Binary files /dev/null and b/img/post-bg-ioses.jpg differ diff --git a/img/post-bg-js-version.jpg b/img/post-bg-js-version.jpg new file mode 100644 index 000000000..3992dde9b Binary files /dev/null and b/img/post-bg-js-version.jpg differ diff --git a/img/post-bg-keybord.jpg b/img/post-bg-keybord.jpg new file mode 100644 index 000000000..f5bd35ee4 Binary files /dev/null and b/img/post-bg-keybord.jpg differ diff --git a/img/post-bg-kuaidi.jpg b/img/post-bg-kuaidi.jpg new file mode 100644 index 000000000..574ba2616 Binary files /dev/null and b/img/post-bg-kuaidi.jpg differ diff --git a/img/post-bg-map.jpg b/img/post-bg-map.jpg new file mode 100644 index 000000000..dcd7a8c5f Binary files /dev/null and b/img/post-bg-map.jpg differ diff --git a/img/post-bg-miui-ux.jpg b/img/post-bg-miui-ux.jpg new file mode 100644 index 000000000..fdb36c10f Binary files /dev/null and b/img/post-bg-miui-ux.jpg differ diff --git a/img/post-bg-miui6.jpg b/img/post-bg-miui6.jpg new file mode 100644 index 000000000..e8b13c350 Binary files /dev/null and b/img/post-bg-miui6.jpg differ diff --git a/img/post-bg-mma-0.png b/img/post-bg-mma-0.png new file mode 100644 index 000000000..53f558c08 Binary files /dev/null and b/img/post-bg-mma-0.png differ diff --git a/img/post-bg-mma-1.jpg b/img/post-bg-mma-1.jpg new file mode 100644 index 000000000..e4a549f22 Binary files /dev/null and b/img/post-bg-mma-1.jpg differ diff --git a/img/post-bg-mma-2.jpg b/img/post-bg-mma-2.jpg new file mode 100644 index 000000000..8f7c9253f Binary files /dev/null and b/img/post-bg-mma-2.jpg differ diff --git a/img/post-bg-mma-3.jpg b/img/post-bg-mma-3.jpg new file mode 100644 index 000000000..4eb52f50d Binary files /dev/null and b/img/post-bg-mma-3.jpg differ diff --git a/img/post-bg-mma-4.jpg b/img/post-bg-mma-4.jpg new file mode 100644 index 000000000..1e139f62f Binary files /dev/null and b/img/post-bg-mma-4.jpg differ diff --git a/img/post-bg-mma-5.jpg b/img/post-bg-mma-5.jpg new file mode 100644 index 000000000..4cf787040 Binary files /dev/null and b/img/post-bg-mma-5.jpg differ diff --git a/img/post-bg-mma-6.jpg b/img/post-bg-mma-6.jpg new file mode 100644 index 000000000..9bf8382f7 Binary files /dev/null and b/img/post-bg-mma-6.jpg differ diff --git a/img/post-bg-os-metro.jpg b/img/post-bg-os-metro.jpg new file mode 100644 index 000000000..220a9b4c3 Binary files /dev/null and b/img/post-bg-os-metro.jpg differ diff --git a/img/post-bg-re-vs-ng2.jpg b/img/post-bg-re-vs-ng2.jpg new file mode 100644 index 000000000..012d4d4e9 Binary files /dev/null and b/img/post-bg-re-vs-ng2.jpg differ diff --git a/img/post-bg-rwd.jpg b/img/post-bg-rwd.jpg new file mode 100644 index 000000000..cc6f623fd Binary files /dev/null and b/img/post-bg-rwd.jpg differ diff --git a/img/post-bg-swift.jpg b/img/post-bg-swift.jpg new file mode 100644 index 000000000..85e3fb308 Binary files /dev/null and b/img/post-bg-swift.jpg differ diff --git a/img/post-bg-swift2.jpg b/img/post-bg-swift2.jpg new file mode 100644 index 000000000..f64d22fbd Binary files /dev/null and b/img/post-bg-swift2.jpg differ diff --git a/img/post-bg-universe.jpg b/img/post-bg-universe.jpg new file mode 100644 index 000000000..2ff521875 Binary files /dev/null and b/img/post-bg-universe.jpg differ diff --git a/img/post-bg-unix-linux.jpg b/img/post-bg-unix-linux.jpg new file mode 100644 index 000000000..78f49b279 Binary files /dev/null and b/img/post-bg-unix-linux.jpg differ diff --git a/img/post-sample-image.jpg b/img/post-sample-image.jpg new file mode 100644 index 000000000..de6eca62d Binary files /dev/null and b/img/post-sample-image.jpg differ diff --git a/img/readme-home.png b/img/readme-home.png new file mode 100644 index 000000000..e1315fbb0 Binary files /dev/null and b/img/readme-home.png differ diff --git a/img/readme-side.png b/img/readme-side.png new file mode 100644 index 000000000..daf6f6670 Binary files /dev/null and b/img/readme-side.png differ diff --git a/img/tag-bg-o.jpg b/img/tag-bg-o.jpg new file mode 100644 index 000000000..ddd2bd2a2 Binary files /dev/null and b/img/tag-bg-o.jpg differ diff --git a/img/tag-bg.jpg b/img/tag-bg.jpg new file mode 100644 index 000000000..da0df4d6e Binary files /dev/null and b/img/tag-bg.jpg differ diff --git a/index.html b/index.html new file mode 100644 index 000000000..e6b5858ac --- /dev/null +++ b/index.html @@ -0,0 +1,10 @@ +--- +layout: page +description: "Thinking will not overcome fear but action will." +--- + +
+
+ {% include post-folder-browser.html mode="cards" %} +
+
diff --git a/js/animatescroll.min.js b/js/animatescroll.min.js new file mode 100644 index 000000000..27ea99476 --- /dev/null +++ b/js/animatescroll.min.js @@ -0,0 +1,2 @@ +/* Coded by Ramswaroop */ +(function(e){e.easing["jswing"]=e.easing["swing"];e.extend(e.easing,{def:"easeOutQuad",swing:function(t,n,r,i,s){return e.easing[e.easing.def](t,n,r,i,s)},easeInQuad:function(e,t,n,r,i){return r*(t/=i)*t+n},easeOutQuad:function(e,t,n,r,i){return-r*(t/=i)*(t-2)+n},easeInOutQuad:function(e,t,n,r,i){if((t/=i/2)<1)return r/2*t*t+n;return-r/2*(--t*(t-2)-1)+n},easeInCubic:function(e,t,n,r,i){return r*(t/=i)*t*t+n},easeOutCubic:function(e,t,n,r,i){return r*((t=t/i-1)*t*t+1)+n},easeInOutCubic:function(e,t,n,r,i){if((t/=i/2)<1)return r/2*t*t*t+n;return r/2*((t-=2)*t*t+2)+n},easeInQuart:function(e,t,n,r,i){return r*(t/=i)*t*t*t+n},easeOutQuart:function(e,t,n,r,i){return-r*((t=t/i-1)*t*t*t-1)+n},easeInOutQuart:function(e,t,n,r,i){if((t/=i/2)<1)return r/2*t*t*t*t+n;return-r/2*((t-=2)*t*t*t-2)+n},easeInQuint:function(e,t,n,r,i){return r*(t/=i)*t*t*t*t+n},easeOutQuint:function(e,t,n,r,i){return r*((t=t/i-1)*t*t*t*t+1)+n},easeInOutQuint:function(e,t,n,r,i){if((t/=i/2)<1)return r/2*t*t*t*t*t+n;return r/2*((t-=2)*t*t*t*t+2)+n},easeInSine:function(e,t,n,r,i){return-r*Math.cos(t/i*(Math.PI/2))+r+n},easeOutSine:function(e,t,n,r,i){return r*Math.sin(t/i*(Math.PI/2))+n},easeInOutSine:function(e,t,n,r,i){return-r/2*(Math.cos(Math.PI*t/i)-1)+n},easeInExpo:function(e,t,n,r,i){return t==0?n:r*Math.pow(2,10*(t/i-1))+n},easeOutExpo:function(e,t,n,r,i){return t==i?n+r:r*(-Math.pow(2,-10*t/i)+1)+n},easeInOutExpo:function(e,t,n,r,i){if(t==0)return n;if(t==i)return n+r;if((t/=i/2)<1)return r/2*Math.pow(2,10*(t-1))+n;return r/2*(-Math.pow(2,-10*--t)+2)+n},easeInCirc:function(e,t,n,r,i){return-r*(Math.sqrt(1-(t/=i)*t)-1)+n},easeOutCirc:function(e,t,n,r,i){return r*Math.sqrt(1-(t=t/i-1)*t)+n},easeInOutCirc:function(e,t,n,r,i){if((t/=i/2)<1)return-r/2*(Math.sqrt(1-t*t)-1)+n;return r/2*(Math.sqrt(1-(t-=2)*t)+1)+n},easeInElastic:function(e,t,n,r,i){var s=1.70158;var o=0;var u=r;if(t==0)return n;if((t/=i)==1)return n+r;if(!o)o=i*.3;if(u (this.$items.length - 1) || pos < 0) return + + if (this.sliding) return this.$element.one('slid.bs.carousel', function () { that.to(pos) }) // yes, "slid" + if (activeIndex == pos) return this.pause().cycle() + + return this.slide(pos > activeIndex ? 'next' : 'prev', this.$items.eq(pos)) + } + + Carousel.prototype.pause = function (e) { + e || (this.paused = true) + + if (this.$element.find('.next, .prev').length && $.support.transition) { + this.$element.trigger($.support.transition.end) + this.cycle(true) + } + + this.interval = clearInterval(this.interval) + + return this + } + + Carousel.prototype.next = function () { + if (this.sliding) return + return this.slide('next') + } + + Carousel.prototype.prev = function () { + if (this.sliding) return + return this.slide('prev') + } + + Carousel.prototype.slide = function (type, next) { + var $active = this.$element.find('.item.active') + var $next = next || this.getItemForDirection(type, $active) + var isCycling = this.interval + var direction = type == 'next' ? 'left' : 'right' + var that = this + + if ($next.hasClass('active')) return (this.sliding = false) + + var relatedTarget = $next[0] + var slideEvent = $.Event('slide.bs.carousel', { + relatedTarget: relatedTarget, + direction: direction + }) + this.$element.trigger(slideEvent) + if (slideEvent.isDefaultPrevented()) return + + this.sliding = true + + isCycling && this.pause() + + if (this.$indicators.length) { + this.$indicators.find('.active').removeClass('active') + var $nextIndicator = $(this.$indicators.children()[this.getItemIndex($next)]) + $nextIndicator && $nextIndicator.addClass('active') + } + + var slidEvent = $.Event('slid.bs.carousel', { relatedTarget: relatedTarget, direction: direction }) // yes, "slid" + if ($.support.transition && this.$element.hasClass('slide')) { + $next.addClass(type) + $next[0].offsetWidth // force reflow + $active.addClass(direction) + $next.addClass(direction) + $active + .one('bsTransitionEnd', function () { + $next.removeClass([type, direction].join(' ')).addClass('active') + $active.removeClass(['active', direction].join(' ')) + that.sliding = false + setTimeout(function () { + that.$element.trigger(slidEvent) + }, 0) + }) + .emulateTransitionEnd(Carousel.TRANSITION_DURATION) + } else { + $active.removeClass('active') + $next.addClass('active') + this.sliding = false + this.$element.trigger(slidEvent) + } + + isCycling && this.cycle() + + return this + } + + + // CAROUSEL PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.carousel') + var options = $.extend({}, Carousel.DEFAULTS, $this.data(), typeof option == 'object' && option) + var action = typeof option == 'string' ? option : options.slide + + if (!data) $this.data('bs.carousel', (data = new Carousel(this, options))) + if (typeof option == 'number') data.to(option) + else if (action) data[action]() + else if (options.interval) data.pause().cycle() + }) + } + + var old = $.fn.carousel + + $.fn.carousel = Plugin + $.fn.carousel.Constructor = Carousel + + + // CAROUSEL NO CONFLICT + // ==================== + + $.fn.carousel.noConflict = function () { + $.fn.carousel = old + return this + } + + + // CAROUSEL DATA-API + // ================= + + var clickHandler = function (e) { + var href + var $this = $(this) + var $target = $($this.attr('data-target') || (href = $this.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '')) // strip for ie7 + if (!$target.hasClass('carousel')) return + var options = $.extend({}, $target.data(), $this.data()) + var slideIndex = $this.attr('data-slide-to') + if (slideIndex) options.interval = false + + Plugin.call($target, options) + + if (slideIndex) { + $target.data('bs.carousel').to(slideIndex) + } + + e.preventDefault() + } + + $(document) + .on('click.bs.carousel.data-api', '[data-slide]', clickHandler) + .on('click.bs.carousel.data-api', '[data-slide-to]', clickHandler) + + $(window).on('load', function () { + $('[data-ride="carousel"]').each(function () { + var $carousel = $(this) + Plugin.call($carousel, $carousel.data()) + }) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: collapse.js v3.3.2 + * http://getbootstrap.com/javascript/#collapse + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // COLLAPSE PUBLIC CLASS DEFINITION + // ================================ + + var Collapse = function (element, options) { + this.$element = $(element) + this.options = $.extend({}, Collapse.DEFAULTS, options) + this.$trigger = $(this.options.trigger).filter('[href="#' + element.id + '"], [data-target="#' + element.id + '"]') + this.transitioning = null + + if (this.options.parent) { + this.$parent = this.getParent() + } else { + this.addAriaAndCollapsedClass(this.$element, this.$trigger) + } + + if (this.options.toggle) this.toggle() + } + + Collapse.VERSION = '3.3.2' + + Collapse.TRANSITION_DURATION = 350 + + Collapse.DEFAULTS = { + toggle: true, + trigger: '[data-toggle="collapse"]' + } + + Collapse.prototype.dimension = function () { + var hasWidth = this.$element.hasClass('width') + return hasWidth ? 'width' : 'height' + } + + Collapse.prototype.show = function () { + if (this.transitioning || this.$element.hasClass('in')) return + + var activesData + var actives = this.$parent && this.$parent.children('.panel').children('.in, .collapsing') + + if (actives && actives.length) { + activesData = actives.data('bs.collapse') + if (activesData && activesData.transitioning) return + } + + var startEvent = $.Event('show.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + if (actives && actives.length) { + Plugin.call(actives, 'hide') + activesData || actives.data('bs.collapse', null) + } + + var dimension = this.dimension() + + this.$element + .removeClass('collapse') + .addClass('collapsing')[dimension](0) + .attr('aria-expanded', true) + + this.$trigger + .removeClass('collapsed') + .attr('aria-expanded', true) + + this.transitioning = 1 + + var complete = function () { + this.$element + .removeClass('collapsing') + .addClass('collapse in')[dimension]('') + this.transitioning = 0 + this.$element + .trigger('shown.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + var scrollSize = $.camelCase(['scroll', dimension].join('-')) + + this.$element + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION)[dimension](this.$element[0][scrollSize]) + } + + Collapse.prototype.hide = function () { + if (this.transitioning || !this.$element.hasClass('in')) return + + var startEvent = $.Event('hide.bs.collapse') + this.$element.trigger(startEvent) + if (startEvent.isDefaultPrevented()) return + + var dimension = this.dimension() + + this.$element[dimension](this.$element[dimension]())[0].offsetHeight + + this.$element + .addClass('collapsing') + .removeClass('collapse in') + .attr('aria-expanded', false) + + this.$trigger + .addClass('collapsed') + .attr('aria-expanded', false) + + this.transitioning = 1 + + var complete = function () { + this.transitioning = 0 + this.$element + .removeClass('collapsing') + .addClass('collapse') + .trigger('hidden.bs.collapse') + } + + if (!$.support.transition) return complete.call(this) + + this.$element + [dimension](0) + .one('bsTransitionEnd', $.proxy(complete, this)) + .emulateTransitionEnd(Collapse.TRANSITION_DURATION) + } + + Collapse.prototype.toggle = function () { + this[this.$element.hasClass('in') ? 'hide' : 'show']() + } + + Collapse.prototype.getParent = function () { + return $(this.options.parent) + .find('[data-toggle="collapse"][data-parent="' + this.options.parent + '"]') + .each($.proxy(function (i, element) { + var $element = $(element) + this.addAriaAndCollapsedClass(getTargetFromTrigger($element), $element) + }, this)) + .end() + } + + Collapse.prototype.addAriaAndCollapsedClass = function ($element, $trigger) { + var isOpen = $element.hasClass('in') + + $element.attr('aria-expanded', isOpen) + $trigger + .toggleClass('collapsed', !isOpen) + .attr('aria-expanded', isOpen) + } + + function getTargetFromTrigger($trigger) { + var href + var target = $trigger.attr('data-target') + || (href = $trigger.attr('href')) && href.replace(/.*(?=#[^\s]+$)/, '') // strip for ie7 + + return $(target) + } + + + // COLLAPSE PLUGIN DEFINITION + // ========================== + + function Plugin(option) { + return this.each(function () { + var $this = $(this) + var data = $this.data('bs.collapse') + var options = $.extend({}, Collapse.DEFAULTS, $this.data(), typeof option == 'object' && option) + + if (!data && options.toggle && option == 'show') options.toggle = false + if (!data) $this.data('bs.collapse', (data = new Collapse(this, options))) + if (typeof option == 'string') data[option]() + }) + } + + var old = $.fn.collapse + + $.fn.collapse = Plugin + $.fn.collapse.Constructor = Collapse + + + // COLLAPSE NO CONFLICT + // ==================== + + $.fn.collapse.noConflict = function () { + $.fn.collapse = old + return this + } + + + // COLLAPSE DATA-API + // ================= + + $(document).on('click.bs.collapse.data-api', '[data-toggle="collapse"]', function (e) { + var $this = $(this) + + if (!$this.attr('data-target')) e.preventDefault() + + var $target = getTargetFromTrigger($this) + var data = $target.data('bs.collapse') + var option = data ? 'toggle' : $.extend({}, $this.data(), { trigger: this }) + + Plugin.call($target, option) + }) + +}(jQuery); + +/* ======================================================================== + * Bootstrap: dropdown.js v3.3.2 + * http://getbootstrap.com/javascript/#dropdowns + * ======================================================================== + * Copyright 2011-2015 Twitter, Inc. + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) + * ======================================================================== */ + + ++function ($) { + 'use strict'; + + // DROPDOWN CLASS DEFINITION + // ========================= + + var backdrop = '.dropdown-backdrop' + var toggle = '[data-toggle="dropdown"]' + var Dropdown = function (element) { + $(element).on('click.bs.dropdown', this.toggle) + } + + Dropdown.VERSION = '3.3.2' + + Dropdown.prototype.toggle = function (e) { + var $this = $(this) + + if ($this.is('.disabled, :disabled')) return + + var $parent = getParent($this) + var isActive = $parent.hasClass('open') + + clearMenus() + + if (!isActive) { + if ('ontouchstart' in document.documentElement && !$parent.closest('.navbar-nav').length) { + // if mobile we use a backdrop because click events don't delegate + $('