返回站点目录

Nova Kwok's Awesome Blog

https://nova.moe/posts/

该站点暂未提供站点简介,可继续查看文章聚合、状态检测与相关资源入口。

生活 随笔

状态

状态正常

随笔——AI 到底是在替你劳动,还是替你思考?

2026/08/08 02:00 · Posts on Nova Kwok's Awesome Blog

博文封面图拍摄于北京。设备:ILCE-7M3 + 28-200MM, 1/40 ISO5000 恍惚间距离上一次博客更新已经过去了大半年,而距离上一次写作其实已经快过去一年了(上篇博文「 」写就于 2025 年 11 月)。 本周末暂时关闭了 Tab, Agents 能力,做点古法写作/编程/思考的工作,尝试找回一点“100% 属于自己大脑输出”的乐子。 车 从 3 年前开始,每年我都会有个念想——今年一定要多花点时间在赛道练习上面。 但是每年都因为这样或者那样的原因而搁置。 这半年内由于有了大模型工具的发展,许多之前想做但是有阻碍的地方几乎都被扫去,其中就包括: 避震器应该如何选型,前后弹簧应该如何搭配才是正确的。通过 LLM 辅助和自己的联网搜索+资料整理,终于汇总了一篇避震器相关的记录并更新在了 ,也同样因此得到了一个自己满意的避震器搭配——BC DS ,定制前轴 6K 后轴 7K(不定制则默认 5K),且有了 -2° 前轮外倾角,同时还基本保持了原车高度。 避震器的后轴阻尼应该如何设定,通过手机陀螺仪+phyphox 记录后轴均速通过减速带的滚动/加速度数据,判断后轴阻尼是否会有欠阻尼或者过阻尼的情况,用重复测试的数据辅助判断当前 setting 是否存在明显振荡或收敛过慢 购买 M9N GPS + ESP32 组件 ,有 Cursor 配合的情况下,在几乎没有嵌入式开发经验的情况下完成了一个 DIY 25Hz GPS 能力,且通过 BLE 接入了 Racechrono ,获得了一个高刷新的 GPS,成本仅 200CNY(对比类似商业产品普遍在 800+CNY) 得益于以上的一些更新(还有些其余的小更新),今年终于有了更多的动力和契机在赛道试车,也成功在 2026/07 在上海天马赛车场完成了一次完整的 shakedown 测试,核心改件整体达到了预期,同时测试也暴露出了后刹制动力分配以及全油门换挡程序两个需要继续调整的问题,详细记录和车载视频见: 希望今年剩余的时间可以再接再厉,努力提升人的水平,并多去一些不同的赛道对车辆进行测试。 回头看,上面这些事情其实都是我很喜欢的大模型使用方式:模型替我扫掉了很多资料搜索、代码实现和陌生领域入门的障碍,但问题应该怎么定义、结果是否可信、最终方案是否有效,仍然需要自己去测量和验证。 但过去这一年,我也越来越明显地感觉到,同样的工具还有另一种完全不同的使用方式——不仅把执行交给模型,也逐渐把问题定义、判断和验证一起交出去。 大模型 由于深知自己对于行业了解的不足,我很少在公开场合对于 AI 相关内容进行评论,但是最近越来越多的事情让我意识到——应该趁现在记录一下自己的想法,无论是为了方便未来对照,还是只是为了捡起自己长期不用的大脑组织语言和写作能力。 这快接近一年的时间内我们看到了大模型能力的飞速进步,在我(这个偶尔写点代码,但是也明确知道自己代码水平并不过关的“程序员”)视角中,从最初 GPT-3.5 Turbo 大规模普及,开源的 LLaMA 出现,然后人均一个 Web based 聊天工具(NextChat/LobeChat/xxChat),再到各类 RAG 知识库并堆积各类 Rerank 能力提升命中度(例如 Qanything),后续大家突然意识到参数量对于模型能力并非线性提升,而是遇到了边际递减,于是 Agent 工具,ReAct 循环,Harness,以及 Reasoning 能力开始被逐渐应用到主流。 社会似乎陷入了一个循环,更好的模型 -> 更少的员工 -> 更多钱给了模型相关公司 -> 更好的模型 -> 更少的员工… 从最近看到的显卡,内存,硬盘价格几乎都到了 ATH 的状态可以越发看到这个循环和泡沫的明显,且短期内好像看不到一个明显的拐点。 但相比这个宏观问题,我最近更在意的是另外一件事:作为使用者,我们自己正在发生什么变化? 我们看什么决定我们是什么 最近重读了一遍「娱乐致死」一书,书中的一个主要观点是——媒介会改变知识/信息传播的方式,而这个方式本身就会改变人的认知和理解知识的能力。 如果时间往两年前回想一下,当时短视频平台的推出,让许多只看电视的中老年人沉迷了手机上不断向上滑动的短视频,让许多小朋友也不断抱着手机翻看着各类短视频(只不过最近可能他们会看到更多 AI 生成的猫猫短片 etc.),我们可以有明显的担忧/顾虑——这么做会扼杀他们的专注力,深度思考能力以及思维深度,并最终成为一个浅薄的人。 或者至少我个人是这么想的,一来我知道这种奶头乐式的应用对于维护社会稳定,提供矛盾缓冲有切实的作用,二来我依然对于创造出这些产品的公司以及他们官网故意凸显的「社会责任」嗤之以鼻,同时很庆幸自己没有直接在这些公司工作过。 开发相关 但是很快,奶头乐来到了开发领域,以往我们要排查一个问题(例如为什么某个 K8s 节点连接不上网了,或者某个 140+ 表的数据库应该如何理清里面的关系制作出一个对应的 kanban 供上游使用)。 对于第一个问题,我们可能需要有大量的 K8s 知识,同时理解集群部署情况,通过一系列指令尝试排查是否可能是网络插件的问题,或者证书下发的问题,或者单纯是某些 Node 的 iptables 被搞坏了,拥有这些知识需要大量的实践并联想(才能整合出完整的排查思路链),在目前有了大模型之后,可能只要配置好 kubectl 命令给模型,直接交给它就能完成绝大部分排查工作并生成一个汇报。 对于第二个问题,模型的解决思路就更加简单了,连接串交给模型,完整的报告就出来了。 当然,上面仅限于 YOLO 或者权限被高度控制在只读时的玩法,对于任何有读写权限的工具而言,都不应该在生产环境交给模型直接操作,这不仅损害自己的专业性,也是对他人的设施不负责任。 这样,对于问题的解决/排查,思路很多时候就变成了: 2024 年前:我想到可以这么解决(想不到就放弃交给别人来处理了),应该需要确认 a,b,c,针对 a,b,c 搜了很多帖子,看了很多文档,本地手写了很多代码验证,和语法搏斗,解决了 a, b ,尝试推出 c ,d 2025 年:我想到可以这么解决,应该需要确认 a,b,c ,分别针对 a,b,c 对模型提问,得到反馈,测试 a,b,c ,得到结论后输出 d ,d 是问题的解决方式 2026 年:我想到大概可以这么解决,交给 Cursor/Codex 的 /plan ,等 /plan 给我 a,b 和我没想到的 c,看上去不错, /build 也许得到了 d 20xx 年:我发现有这个问题,交给 xx 工具, /build , 也许得到了解决 好处是对于常见和覆盖常见路径的解决问题的速度变得越来越快,Human in the loop 的时间越来越少(当然,这个是模型能力变强的目标),大家对于事情解决的效率要求和预期变得越来越高,同样的工作可以交给更少的人来做,剩余的人可以完成更多的工作。 坏处也很明显:人对于跨领域的理解需求变得越来越少,每个人需要并行处理(可能多半是 review 模型的工作)的工作会越来越多,然后可能大家都越来越累了。 自动化偏误(Automation bias) 此外,对于坏处这里还可能会有一些自动化偏误的风险,即—— 不是模型变得足够聪明,而是人在某个时间点已经失去了判断模型究竟聪不聪明的能力。 Automation bias is the propensity for humans to favor suggestions from automated decision-making systems and to ignore contradictory information made without automation, even if it is correct. 例如在上面对于 K8s 网络的排查中,你自己已经想到了 CoreDNS 正常,别的 Node 正常, 但是这个机器的 iptables 好像和别的机器不一样,但是还没仔细分析,这个时候,Codex 告诉你根因是 kubelet certificate expired,建议重新生成证书,然后给你一个很完整、很漂亮、有十几个步骤的操作方案。 我相信大部分人都会认为——模型已经做了这么多研究和探索了,那应该它是对的,然后就开始 /build 更新证书了。(即使你眼前已经有证据与它冲突。) 另外一种则更加隐蔽,例如针对上面数据库的例子中:Agent 做了数据库 Schema 的完整分析,结论是 “已经完整分析 143 张表,没有发现数据一致性风险,同时有 12 个问题需要关注“,但此时 Agent 其实漏掉了一个 legacy_payment_mapping 表。 欸!?这个时候如果是你自己在做(且有对于这个数据库深度的了解和认知)的话会想:Payment 数据在哪?有没有 legacy 表?我是不是漏搜了? 但在这里,现在你的思维变成:Agent 没说有,那大概是没有。 于是… 「“没有 evidence of X”」 悄悄变成了 「“evidence that there is no X”。」 这样一来,LLM 时代就有一个非常漂亮也非常危险的正反馈: 越多认知卸载 → 自己掌握的上下文越少 → 越难判断模型哪里有问题 → 越容易产生自动化偏误 → 越相信自动化 → 更愿意认知卸载。 那作为一个普通的开发人员,第一次尝到了 /plan + /build 的甜头后可能就会像第一次打开了抖音的大门,发现正反馈周期居然可以变得这么短,不再需要有仔细的思考和设计,不再需要仔细推敲细节的实现(因为大部分情况下模型在微观层面做的比你好,除非你用垃圾模型),慢慢也不需要仔细推敲功能的实现(毕竟能跑就没问题,有 bug 就让模型修就好,不是么?),慢慢的,项目层面也不用管了,只要写好 PRD,剩下交给模型猛蹬就行。 这样跟着上面展开一下就是: flowchart TD A["⚡ 反馈越来越快<br/>`/build` 几分钟就给出结果"] B["📦 过程越来越容易外包<br/>不再亲自建模、搜索、实现"] C["🧠 自己拥有的内部模型越来越弱<br/>越来越不知道「正常结果」应该长什么样"] D["🤖 自动化偏误越来越强<br/>「它既然跑通了,应该就是对的」"] E["↗️ 更大胆地继续外包<br/>把更多判断与执行交给模型"] A --> B B --> C C --> D D --> E E -->|"进一步压缩反馈周期"| A 和短视频平台的「向上滑动」,「向上滑动」一样,大部分常规的功能只要 /build , /build ,只要动动手(或者 Codex /build 就好了。) 短视频的内容玩法也是类似,随便给你推一些看上去很震撼的结论和标题,5 分钟的拼接+后期视频里面可能有几句真话你就会习惯性转发到 xx 群里面,结果被质疑了之后为了维护自己的面子可能还会说出「你觉得有道理就看,没道理可以忽略」,「我们需要善于提取有益的信息,不考虑糟粕性的内容」之类没有任何逻辑的话。 这样来看,有了同样的:反馈压缩 -> 认知卸载 -> 自动化偏误的路径之后,我们就会发现如果把 AI 配置成只负责迅速消除认知摩擦的话,它会呈现出和无限滚动内容非常相似的行为激励——一代人有一代人的奶头乐。 当然,以上一些经验不适用于某些(甚至大型)传统企业,这些传统企业的文科管理层可能思维足够固化,AI 落地速度足够慢,内部还是以采购各类 xx 解决方案为主,给各类互联网或者长得像互联网的公司提供了套利空间——毕竟在 2026 年卖一个带 RAG 的聊天工具对于企业内可能都以为得到了香饽饽并为之支付超额代价,同时以为自己得到了信息化的提升和 AI 能力的落地。 从朋友那儿听来的真人真事,而且这个朋友还不是我自己 😓 当然这是题外话了 日常相关 如果说开发相关只是开发人员白天(或者晚上)的工作的话,那日常相关可能就和每个人息息相关了。 从 DeepSeek R1 刚刚出现的时候,配合 C 端 App 的上线,很快人们就发现——以往需要思考很久的问题可以直接外包给模型来做,只要判断一下模型有没有(很明显的)幻觉就行了。 到之后出现了豆包,元宝,千问的大规模普及,模型能力的逐渐提升,幻觉的逐渐被压制,我们逐渐将越来越多的大脑思考的部分给 offload 给了模型,并随着模型的幻觉越来越少,我们对模型的信任和依赖就越来越高,很快,从最无脑外包的角度就可以看到类似这样的一些例子: 来源: 帖子作者也表示:国内很多人根本就不知道 AI 的边界在哪里, AI 存在幻觉也不清楚, 连 AI 实际接入了哪些业务也不清楚, 甚至真的相信 AI 给出的建议和方案。 慢慢的,人们发现长时间的思考不如直接丢给模型思考,长时间的阅读不如丢给模型快点出来一个 TL;DR ,人和人之间的交流就变得越来越急躁/流于表面,对于事实的判断能力越来越差(反正不会了就问大模型就好了),我们又走上了短视频奶头乐的老路,只不过之前是别人创作好的垃圾给你看,现在是自己让模型生成的不确定是不是垃圾的东西给自己看(或者可能还转发给别人参考)。 在 2025 年微软研究团队调查了 319 名知识工作者、936 个实际 GenAI 使用案例,发现一个很有意思的关系: 对 AI 越有信心的人,自报投入的批判性思考越少。 不过,论文同时发现,AI 并非简单消灭思考,而是把思考活动从“搜集信息、亲自解决问题”,转移到了“验证输出、整合答案、监督任务”。 相关研究: 思考一下,当你想要研究一个不那么通用的话题比如——「对于目前既有的新风系统的工作方式而言,如果目标是在不开门窗的情况将室内二氧化碳浓度压制到 500PPM 以内是否现实」,你是直接把上面这段话打给模型,还是先思考一下这个话可以被分解成哪些问题,然后逐个搜索+考证(当然,这里可能依然会用到大模型),然后得出一个结论呢? flowchart TD A["遇到一个陌生问题<br/>例:不开门窗,能否把室内 CO₂ 压到 500 ppm?"] A --> B{"怎么使用 AI?"} B --> C["直接把完整问题丢给模型"] C --> D["得到一个完整、流畅的结论"] D --> E["检查有没有明显幻觉"] E --> F["接受答案"] F --> G["问题解决了<br/>但自己没有建立多少内部模型"] B --> H["先自己拆解问题"] H --> I["室外 CO₂ / 人员产气量<br/>新风量 / 房间体积<br/>渗透率 / 传感器误差……"] I --> J["让 AI 辅助检索、计算和整理"] J --> K["阅读原始资料 / 实测 / 交叉验证"] K --> L["自己形成结论<br/>同时建立对问题的内部模型"] 当然,模型和短视频可能还不一样的点在于——它的用法是多样的,如果你能坚持保留着类似 2025 年的思路: 我想到可以这么解决,应该需要确认 a,b,c ,分别针对 a,b,c 对模型提问,得到反馈,测试 a,b,c ,得到结论后输出 d ,d 是问题的解决方式 那模型可以代劳很多其实不需要承担的体力工作(虽然依然会破坏一些你的搜索引擎等工具使用能力),而保持能力的提升(或者至少不衰退)。 但如果你就是打算躺了,或者工作上的事情让你不得不快速做出一些代码变更/修改/调试,那「丢给模型有什么不好呢?」,所有的文档都不觉得需要自己亲自打开,亲自阅读,甚至本博客的内容也打算丢给模型来看个总结版本,那模型或许在带着我们走向一个深渊。 写到这里,想引用一个 Twitter 推文和一篇文章: 文章是:「 」 推文则是如下: 或许可以给读者带来一些灵感或者想法。 AI 最大的诱惑之一,是它可以消除认知摩擦;但过去很多我们讨厌的摩擦,本身恰恰就是学习发生的地方。 不要停止思考。 以上。

只有自律才能看到真正的自由——Durov Podcast 随笔

2026/02/19 11:00 · Posts on Nova Kwok's Awesome Blog

博文封面图于 2026 年拍摄于蜈支洲岛。设备:ILCE-7M3 + ƒ/8 1/400 200mm ISO100 本文原写就于 2025 年 11 月,由于诸多原因并未将其发表,最近整理自己博客草稿时发现了这篇未完成的文章,便继续完善了一下并发布 前段时间好友发给我一个 Durov 的 Podcast: ,视频总共有 4 小时长,最近断断续续在吃饭的时间和睡觉前的时间把部分视频和全部 Transcript 部分看完了,结合一些自己最近的一些观察和想法有此摘抄和随笔。 本文在某种意义上也是前一篇博文 的一点衍生。 早间专注力 If you open your phone first thing in the morning, what you end up being is a creature that is told what to think about for the rest of the day. This is pretty obvious and it’s not a secret, but because we are bombarded with all kinds of information, that is not really important for us in terms of becoming successful, we often forget the important things, and this is one of them. 很多人起床之后第一件事情就是摸起手机打开各种聊天软件/App/邮件客户端开始阅读邮件,心中焦急地希望「与这个世界同步」,以至于在醒来后到开始工作之间的各种碎片时间(电梯内,地铁上等)大脑中都会充斥着大量的碎片化信息,而无法产生一个深度且线性的思考用来处理任何需要仔细思索的问题,提前消耗了大量的专注能量(Quota)。 或许有部分人 必须 需要在这个时候处理消息(工作邮件,行程安排,滴滴接单等),但是似乎绝大部分人其实并不需要(仔细想想你每天早上看的新闻/群消息真的对你产生了什么积极价值了么?)。 工作同僚和人月神话 Steve Jobs talked about A players and B players, and there’s something that happens when you have B players, which is like the folks you’re talking about. Introduced into a team, they can somehow slow everybody down. They demotivate everybody. And it’s very counterintuitive that you basically, part of the work of creating a great team is removing the B players. It’s not just hiring more, generally speaking. It’s finding the “A players” and removing the people that are slowing things down. 这一段是 Lex Fridman 说的,表达了两个含义: 工作并不是人堆的越多速度就越快,这个是很标准的「人月神话」里面的结论 如果有不合适的人在团队中,会拉低整个团队的效率并降低团队士气(demotivate everybody) 在「人月神话」一书中对于第二点的理解更多的是在于「更多的人意味着更多的协调时间」,以及「所有人都会有更多的损耗在沟通上」 但这里还引申的一个含义是——在沟通以外的情况下不合适的人会在整体气氛上进一步降低团队氛围和状态,下文 Durov 的补充进一步阐释了这一点 Oh, yes, because the other thing that people don’t realize is how demotivating working with a B player is. Everybody can tell if the other person, the other engineer they’re working with is really competent. And it’s very visible if the person is not comfortable. They’re asking the wrong questions, they keep lagging behind. And at a certain point, if you’re an A player, you get this dissatisfaction, this feeling that you are not able to realize your full potential, accomplish what you’re really meant to accomplish because of this person working next to you or pretending to work next to you. 这一段加粗部分是我感触最深的部分,不确定这一点是我的个人缺陷还是如何,在 2017 年有类似项目开发的时候就有遇到类似的情况(可参考 ) 一文中的「YunLoad Story」章节,直到最近,类似的情况不减反增。 以当时的 YunLoad 为例,从管理者的角度来看显然有我未能成功调动团队积极性,同时也未能合理根据大家的兴趣和能力分配工作和整体规划的问题。 而从个人技能方面的角度来看,这样的事情似乎也在不断提醒我自己的技术栈过于狭窄以至于无法独立完成一个相对复杂的任务,以至于只能将本来可以自己完成的部分寄托于他人。 我并不自认为是一个聪明的人,甚至感觉自己没有同龄人聪明,只有付出更多的努力才能达到接近均线的水平,由此,在当下 自我感觉我的时间和专注度是我最珍贵的资源,我在努力尝试用这非常有限的时间换来更多的智慧 ,在无法接受自己“三心二意”工作/生活的同时也因为看到身边其他人的类似行为而心生厌恶。 试想一个场景,在和合作伙伴一起工作的时候在 双方约定的工作时间 发现对方一会儿看一会 Twitter,一会看一会淘宝,然后再回来写几行代码,然后突然打开 Bilibili 开始看视频了是一种什么心里体验? 再加上当你过问对方进度时对方及其不耐烦让你不要催的时候。 会不会同样感觉厌恶感到达了巅峰,并开始怀疑为什么一开始会和这么个人合作?然后逐渐失去自己对于正在工作的项目的兴趣和信心,因为身边的合作伙伴是这样的人。 不断积累的厌恶有害身心健康,进一步破坏自己的心态,在这种情况下,以下的这段话从另一个角度缓解了我的厌恶感: And by the way, in some cases, it’s not because the person is lazy. In some cases it’s just the mental, the intellectual ability is not there. It’s not about experience. Most often it’s about natural ability and persistence. In 90% of cases, it’s just the inability to focus on one task for an extended period of time. Not everybody has this ability. So for people who do have this ability, it’s an insult to work alongside someone who is distracted and cannot go deep in the projects that they’re responsible for. 这让我意识到,很多时候我所观测到的这种"不专注"可能并非主观恶意,而是底层认知能力的确实,这种理解虽然不能消除协作中的痛苦,但是能让我从无谓的情绪消耗中解放出来,从而更加中立地寻找高效的协作方式和边界。 无酒精 这个访谈从 No Phone 突然聊到 Durov 不喝酒时有些超出我的预期,Durov 对于不喝酒是如下解释的: That one is quite easy. When I was 11 years old, my biochemistry teacher, he gave me this book he wrote, it was called The Illusion of Paradise, and there he would describe the biological and chemical processes that happen in your body once you consume this or that substance. It was mainly related to illegal drugs, but alcohol was one of these addictive substances that he covered. So it turns out that when you drink alcohol, the thing that happens is that your brain cells become paralyzed. They become literally zombies. And then next day, sometime after the party is over, some of your brain cells die and never get to normal. So think about this. If your brain is this most valuable tool you have in your journey to success and happiness, why would you destroy this tool for short-term pleasure? This sounds ridiculous. 来源: 我本人也是完全不饮酒,所以在这一点上想展开讲讲,我们知道乙醇的作用机制如下: 酒精在大腦中的作用主要是透過增強一種名為γ-氨基丁酸(簡稱GABA)的神經遞質的作用。[21]GABA是大腦中主要的抑制性神經遞質,由於GABA的作用受到促進後,飲者中樞神經系統的活動遭受抑制。 来源: 所以从药物的角度来讲,这是一种镇定剂,好处是「會產生幸福和欣快、減少焦慮,增加社交行為、鎮靜」,坏处则是「認知、記憶、運動、和感覺系統功能會受損,以及中樞神經系統功能受到全面抑制」 从理性的角度来说,很快就有如下结论:「If your brain is this most valuable tool you have in your journey to success and happiness, why would you destroy this tool for short-term pleasure? This sounds ridiculous.」 此外,酒精的摄入似乎没有安全剂量。 2017年,《NEJM》的一项研究认为:即使饮用通常认为属于“安全”的酒,即每周饮酒14~20单位(每单位8g酒精),也与海马萎缩(该结局与认知障碍及痴呆相关)和认知功能的一项指标受损相关,因此为了使脑处于最佳健康状态,个人应该限制饮酒[10]。 饮酒习惯/文化作为一个广为流传的部分在我的身边自然也不少见,有不少好朋友都会在喝酒的时候和我说「来一杯嘛,尝尝看,喝点酒体验很不错」。 我依然非常清晰的记得大学时候一个和我关系很好的老师讨论相关话题的时候我的回答: 「我感觉有必要让我的大脑在除了睡眠状态以外任何时候都是保持清醒和受我主观控制的,我不能容忍任何非必要的让我失去对我自己控制的行为,这样非常危险」 当时的观念更多的我对于自身的控制能力,直到了解到了酒精没有安全剂量的事实之后进一步强化了对于禁酒的意识,并保持至今。 以至于在许多(本可以避免参加的)宴席上我经常成为破坏氛围的那一个,经常「败了别人的兴致」,或者在被要求饮酒时一走了之… 关于榜样和教育 You shouldn’t give the wrong example to the people around you and in particular to your kids, because you can do the right thing nine times out of 10, but you make a mistake once, and they will instantly copy it. If you’re telling your kids not to use a smartphone, but you’re using a smartphone all the time yourself, and coming up with all kinds of sophisticated, brilliant explanations why they shouldn’t be using a smartphone, it won’t land. It’s bound to fail. So you lead by example. 我们时常能看到一些关于父母不让自己的小孩沉迷手机/游戏的案例,极端一些的甚至看到一些小孩被父母送到所谓「戒网瘾中心」虐待的案例。 不过比较有意思是,更加常见的一个情况是父母不断要求自己的小孩「不要看电视了」,「把手机还过来,不要玩手机了」时,自己「忙中偷闲」开始看手机上的短视频,同时还和孩子解释「因为我是大人/家长,所以可以在 XX 的情况下 XXX」。 很显然,在这些案例下,父母才是需要被「戒网瘾」的那个,如果尝试作为榜样的人无法成为自律的榜样,那对于他人的要求显然天方夜谭,受到暴力压制的孩子并不会真正认同/服从。 小结 科技发展的速度远超人类进化能接受的预期,从 20 年前大家还没有人均一部手机,到现在火车站内可能 70% 的人都在不断翻动着手机来打发自己廉价的空闲时间,在信息量和多样性爆炸的现在,一旦没有足够的自控力便很容易成为欲望的奴隶。从早上起床(以及晚上睡觉前)时不要刷手机,到拒绝一杯纯社交性的酒精,本质上都是在尝试夺回我们对自己身体,对自己大脑的控制权。 只有当习惯性拒绝了碎片信息,拒绝生理诱惑的“不做的自有”时,才能在那 100% 控制自己大脑的方寸之间去构造数属于自己世界的“做的自由”,真正的自由,往往包裹在极度自律的外壳之下。

博文封面图于 2025 年拍摄于镰仓海边。设备:ILCE-7M3 + 50mM f0.95, 1/500 ISO200 这是本博客 Random Thoughts Tag 下自 2019 以来的第一篇文章,作为一篇随笔,简单聊聊我眼中的婚姻观和价值观,既是对于当下我的思路的一次记录(快照),也是希望通过写作的方式对自己思路的整理,更是希望做一个简单的分享,对有类似的想法的同学一些可能的启示或者思考。 请注意: 本文中会有大量本人主观的看法和意见,请在阅读时仔细分辨,避免被带入。 本文假设了讨论的男女双方已经有了 非常良好的基础关系 并目前只是在是否「领证结婚」这个状态, 下文简称「λ 状态」 ,其余状态 不属于 本文讨论的范畴 好,我们开始! 序 不知不觉已经快要进入 30 岁的阶段,在这个阶段但凡身边有朋友的,几乎一定会有符合如下分类的: 已婚未育 已婚已育 且,一旦处于「λ 状态」下,几乎不可避免的会遇到一些父母关于推进结婚进程的压力,普遍的说辞是「不以结婚为目的的谈恋爱就是耍流氓」,「赶紧结婚,这样我们的目的就完成了」,「到这个年纪该结婚了,你看 xxxx」,「结了婚双方才有凝聚力,法律有保障」。 基于个人身边有接触到过大量类似的案例,也有见到一些服从了上述案例(无论是心理上服从还是只是希望减少和父母的摩擦成本而被迫服从)就不明不白结婚的人,遂有本文对于类似事件的一点随想。 回想一下之前大学时候的博文,如果是那个时候的我可能本文将充斥着对于上述父母压力和社会压力的批判,但到如今也许是年龄的增长或者接触的事物的变化,对于上述相关的冲突我个人更多的是想到了两条分支可能带来的利弊(PnL),本文将尽量从客观的角度来展开。 当我们在谈论婚姻的时候我们实际上在讨论什么? 无论你是处于哪个状态,是否可以仔细停下来想想,当你「想要结婚」,或者其他人「想要你结婚」的时候,你的目的,或者那个「想要你结婚」的人的目的是什么?婚姻(结婚证本身以及周边相关配套,例如彩礼,婚宴等)带来的是什么? 一般而言父母对此的说法是「结了婚双方才有凝聚力,法律有保障」,除了这里想到的一点以外,另外想到的一个可能的点是:双方家庭资源交换。 总结一下,大家常常会想到的点是: 获得凝聚力? 法律保护婚姻 双方家庭资源交换 凝聚力? 首先是凝聚力,婚姻本身如何保障了凝聚力?如果我们将凝聚力作为双方不分开(分手/离婚)的反向指标的话,结婚本身是否会减少双方分开的概率? 这一点需要仔细考量,如果是从沉默成本的角度来说,考虑到大部分人结婚会同时带上彩礼/嫁妆/婚宴等配套设施,在双方各类亲友面前进行一段较为尴尬的行为艺术表演之后确实带来了额外的沉默成本——即:「大家都看到你们在一起了,这个时候分开是怎么个事?」的心里压力,从而变相减少了分开的概率。 从这个角度来说,那确实是提升了「凝聚力」,但是这样的凝聚力带来的关系是否健康,我深表怀疑。而如果是从别的角度来说,似乎想不到结婚这个行为是如何提升了凝聚力的。 法律保护婚姻 这是许多家长喜欢说的另一个点:「你们结婚了就是受到法律保护的」。 我们思考一下,法律保护了什么?由于这里的场景是中国大陆,婚姻相关我们参考「《中华人民共和国民法典》第五编 婚姻家庭」( ),来看看法律到底保护了我们什么。 首先当然「第一千零四十六条 结婚应当男女双方完全自愿,禁止任何一方对另一方加以强迫,禁止任何组织或者个人加以干涉。」,这一点似乎催婚的父母没有意识到,自己的行为(干涉或者强迫)可能是违反法律的。 当然,你接受/妥协了那就是你自己的问题了。 对于财产上,法律能保障的点有如下: 「第一千零六十一条 夫妻有相互继承遗产的权利。」 「第一千零六十二条 夫妻在婚姻关系存续期间所得的下列财产,为夫妻的共同财产,归夫妻共同所有:」 「第一千零七十一条 非婚生子女享有与婚生子女同等的权利,任何组织或者个人不得加以危害和歧视。」 上面的法律定义了遗产,财产的分割和保障,需要注意:共同财产制主要是保护收入更少的弱势方。 「第一千零七十三条 对亲子关系有异议且有正当理由的,父或者母可以向人民法院提起诉讼,请求确认或者否认亲子关系。」 这一点保证了:「如果你怀疑你的孩子不是你的孩子」(似乎只有可能是父,而不是母)的情况下你可以要求确认,但可能仅限于此了,可能起诉会赔钱,但是赔的不多。 我们来看一个案例:(2018)渝0103民初69号 来源: , 李某与蒋某于2007年4月26日登记结婚,蒋某于2011年12月3日生育一女李大某,于2016年10月1日生育一子李小某。在共同生活中,双方因性格及李某长期在国外工作等问题产生矛盾,导致夫妻感情受到影响。蒋某曾于2017年6月起诉要求与李某离婚,因其未举示证据证明双方感情确已破裂且李某不同意离婚,法院判决驳回了蒋某的诉讼请求。 李某与蒋某结婚后生育一女李大某、一子李小某,在无相反证据证明的情况下,应当推定二子女均为两人的婚生子女。但是庭审中根据李某提供的医学资料,李某的血型为O型,蒋某的血型为A型,李小某的血型为B型,依据遗传学关于血型遗传规律的理论,李某与蒋某结合生育子女的血型不可能为B型,因此,李小某与李某不存在亲子关系有高度可能性,在蒋某未提供其他证据予以反驳,又不同意进行亲子鉴定的情况下,可以据此推定李小某与李某不存在亲子关系,这种推定是建立在高度可能性前提下的推定,而且是在法院向蒋某释明拒绝亲子关系鉴定的后果之后的推定,蒋某在明知相应后果的情况下仍然拒绝亲子关系鉴定,应当自行承担不利的法律后果。 裁判结果:支付精神损害抚慰金5万元。 同样还有关于冷静期的说法,例如「第一千零七十七条 自婚姻登记机关收到离婚登记申请之日起三十日内,任何一方不愿意离婚的,可以向婚姻登记机关撤回离婚登记申请。」 冷静期以及新婚姻法在知乎上其实有很多段子了,有兴趣的读者可以自行搜索。 双方家庭资源交换 这一点可能是最为实际的一个作用,通过在婚宴等形式上的联系,活动(例如邀请各自利益相关的同事/长辈),在 双方家庭有潜在合作背景 的情况下可以促成一些资源的交换和人脉的扩展。 前提是:提前可以确认有这样的潜在机会。 婚宴 && 彩礼 说到婚宴,想必参加过的同学(无论是否是主角)应该有面临过 4 点钟起床,5 点钟化妆,吃饭,拍照,婚车等场景,一般到了下午新人双方已经累的动不了了并表示只希望今天能早点过去。 在传统叙事中,婚礼被视为幸福的庆典;但在资源配置的视角下,它是一次高强度的资本与精力透支,我们从经济和面子两个角度进行论述。 在当前的婚庆市场中,服务定价存在显著的“价格歧视”。同样的餐饮标准、场地租赁或摄影服务,一旦冠以“婚宴”之名,其溢价率通常在30%至150%之间。这种“婚礼工业复合体”利用了文化规范中的“不可重复性”心理(即“一辈子只有一次”),迫使理性消费者在这一特定消费场景中放弃价格敏感度。 以上照片来自去年参加过一个的上海好友的婚礼时。 更为关键的是,对于已经共同生活多年的伴侣而言,婚礼的边际效益极低。 对于新结识就结婚的情侣:婚礼具有“公示效应”,向社会网络宣告两人的结合,有助于建立新的社会资本。 对于长期同居伴侣:两人的社会网络(朋友、同事)往往已经重叠或早已知晓其关系。此时举办婚礼,其“信息传递”价值几乎为零,主要功能退化为满足双方父母的社交展示需求(即收回过去送出的礼金,或展示家族联姻的实力)。 这意味着,这对伴侣需要投入约 200-300 个工时的筹备精力(精力磨损),以及数十万元的现金流(费用磨损),来购买一个 主要服务于上一代人 的社交产品。这种资源错配往往导致伴侣双方在筹备时发生冲突。 由于是「服务上一代人」,所以父母一代通常将婚礼视为其“完成父母责任”的终极仪式。如果伴侣坚持不办婚礼或简办,可能会被父母解读为“对自己不尊重”或“关系见不得光”。这种情感勒索构成了巨大的心理损耗,使得许多伴侣为了维持家庭和平而被迫妥协,进而产生深层的无力感和对婚姻制度的厌恶。 此外,婚宴/婚礼本身也是一个充满了主观色彩的活动,任何的激素变动(Hint🤓)或者环境变化都可以将同一个类型的婚礼描述成——很浪漫,一般或者糟透了。 说完了婚宴我们来说说彩礼,在「λ 状态」下双方的财务往来基于自愿与互助,遵循“礼物经济”逻辑,而一旦进入彩礼谈判的流程,关系很容易被拉入“交易经济”逻辑。 即使金额不高,双方父母的介入也会迫使伴侣站在各自原生家庭的立场上进行博弈。这种博弈会将原本模糊的、基于情感的边界清晰化、对立化,从而由于外部力量的强行介入,破坏原本有机的内部平衡。 这样来看如果仅从仪式感角度考量,维持「λ 状态」能完全规避掉这种巨大的初期沉没成本。除非婚姻证书能带来后续巨大的经济或法律收益,否则单纯为了“给个交代”而结婚,在经济学上是极度不理性的。 从“催婚”到“催生”的压力传导 注意:这里可能是「滑坡谬误」,即假设某件事发生,则一系列不可控的、不必要的连锁事件肯定会发生,而没有足够的证据支持这种因果链。 结婚证在代际博弈中,不仅仅是一张纸,更是一份“授权书”。 在中国传统的家庭伦理中,婚姻从来不是两个人的私事,而是家族延续的制度化开端。 在「λ 状态」下,伴侣双方在面对父母催生时,拥有一个强大的逻辑盾牌——“我们还没定下来”、“名不正言不顺”。父母虽然焦虑,但在文化脚本中缺乏强行要求“非婚生子”的底气。这种状态下,伴侣双方处于“准成年”状态,享有一定的由不确定性带来的自由。 而一旦结婚,婚后的防线崩塌:结婚证的签署,标志着这对伴侣正式进入了“成年育龄”的社会角色。父母的逻辑瞬间切换为:“证都领了,生孩子是天经地义的下一步”。此时,不生孩子不再是“时机问题”,而被解读为“生理问题”或“态度问题”。 婚后,双方父母(特别是公婆)会认为自己拥有了介入小家庭生活的合法性。这种介入往往以“备孕”为切入点,包括送补品、询问生理周期、甚至搬来同住“照顾生活”,例如如下知乎问题: 「去年在双方家人及他的催促下领取结婚证」,「以及父母劝说,领取结婚证是对女生在男女关系中权益的保障,再加上对方父母意欲早点开始装修新房,并且口头上承诺以后不干预我们的生活」,「他父母亲一听到三十左右生小孩,立马气急,对我说“结婚就是为了生孩子,你不生孩子结婚干嘛?」 来源: 许多父母会将子女的生育进度与其社交圈(老同事、亲戚)进行对比。在“三孩政策”的宏观宣传背景下 ,这种微观层面的 Peer Pressure 会被放大,转化为对子女的高频骚扰。 或许可以得出一个结论:如果伴侣双方目前没有坚定的生育意愿,或者希望按照自己的节奏生活,维持同居状态是维持家庭边界的最有效物理手段。结婚会撤销这道防线,让双方直接暴露在两个家族的生育意志之下。 当然,客观地讲,如果双方有坚定的生育计划,目前的行政体系(尤其是学区房入学顺位、生育保险报销流程)对婚姻状态依然有明显的路径依赖。在这种情况下,领证可能是一种为了孩子而进行的‘行政妥协’,但这属于为了特定功能而购买服务,与感情无关。 职业生涯 人活在这个世上,还是要恰饭的嘛。 已婚未育 「λ 状态」和已婚状态最大的变化 直接影响 给到了女性,尽管《妇女权益保障法》及2024年的新规明确禁止询问婚育状况,但调查显示,仍有62.5%的女性在求职中被问及此类问题 。虽然这一比例较往年有所下降,但企业的筛选逻辑并未改变,只是变得更加隐蔽。 「职场女性求职被问婚育比例升至 62.5%」 智联研究院:2025中国女性职场现状调查报告 从 HR 的角度来讲:在企业的人力资源成本核算中,一名已婚未育的女性员工被视为携带了“看涨期权”的高风险资产。企业预期她极大概率会在入职后的1-2年内行使休产假的权利(通常为98天基础产假+各地奖励假,如广东80天,共计178天)。 这不仅意味着半年的劳动力缺失,还涉及社保公积金的持续缴纳、替代性劳动力的招聘成本以及返岗后的适应期。 而且你还不能开除她。 这样便创造了另一个对立,一边我们能在小红书上看到许多人进了公司之后许愿或者报喜「自己的宝宝真争气,刚好在 xx (公司裁员期间)时候怀上了」,一边是许多公司 HR (代表公司意志)对于已婚未育女性的职场歧视。 996 的工作制在中国大陆尚未被重视,对于这类暗中的职场歧视的制裁更是无从谈起。 虽然,其实这是一个「生育收益全社会共享,但是财务成本全丢给企业」的被企业视为一种无法对冲的系统性风险,目前的政策也仅仅通过“下达行政命令”(如延长产假、强制不准辞退)来保护女性,但这本质上是慷企业之慨,政策开出了方子,却让企业去药店付钱。 每每想到这个话题就很想对于催婚的父母一辈说: 所谓爱女儿并催着赶紧嫁出去,女儿听从了导致职场上的被歧视导致自己事业断层,这里产生的直接精神损失和间接经济损失应该由谁来承担呢?父母么? 职场回避制度 有许多单位(包括但不限于金融,某些互联网公司,传统企业,医院等)实行亲属回避,若双方职业生涯道路接近在同一行业(如都在某头部互联网公司或某国有银行体系)工作,且发展前景良好,此时已婚的状态会直接被要求申报,带来的后果: 未入职时只有一方可以入职 已入职的情况可能某一方会被迫牺牲(离职)或者被迫调岗至非核心部门,导致职业生涯断层。 某些极端情况下如果一方在甲方,另一方在乙方的关键供应商处任职,也会触发回避条款,导致其中一方必须辞职。 身边有好朋友的朋友就遇到过类似的情况(这个朋友真不是我),在遇到类似和其父母分享时得来的反馈是「这公司不行啊,换个公司吧,别耽误了结婚的大事」。也有听到另外的好朋友分享的案例是夫妻双方直接办理离婚,然后等另一方成功加入了之后再重新结婚(上海浦西某三甲医院)。 莫名其妙的磨损就砸到了自己头上。当然,在这种情况发生时,催/劝你结婚的人可能巧妙的不见了。 购房 在中国的环境下,房子是婚姻中最大的资产,也容易是最大的矛盾爆发点。 从住所的角度来考虑,买房的行为是一个纯粹的商业行为(如果联名买房,那是按出资比例持有份额,类似 LLP 有限责任合伙),但是一旦涉及到婚姻的接入,房产通过“加名”、“还贷”、“增值部分”、“出资装修”会将事情变得极其复杂。 且,「λ 状态」下房产证是很清晰的产权证明,而如果买房的事件放到了婚姻中,房产证是一张模糊的期权兑换券,最终解释权归离婚律师和法官所有。 看过各类案件的你应该知道我在说什么 不过这个时候有人就会说了,夫妻双方如果合并用来买房的话可以合并双方的公积金获得两倍的贷款额度,例如上海公积金+补充公积金单人贷款限额是 80 万,结婚后可以获得 160 万低利率公积金贷款额度,省下了一笔钱。 我们来计算一下,假设夫妻双方需要购买一套价值 600 万的外环外“刚需房”的话,情况会是如何的,调研的时间是 2026 年 1 月,此时首套房公积金贷款利息为 2.6%(5 年以上),商业贷款利息为 3.05% (5 年以上)。 5 年内能还清的应该普遍不需要贷款吧? 维度 λ 状态 婚后状态(双人申请) 差额 公积金贷款额度 80 万(含补充) 160 万(含补充) + 80 万 商贷金额 340 万 260 万 - 80 万 月供总计 17,629 元 17,437 元 - 192 元 30 年总利息 2,146,476 元 2,077,457 元 - 69,019 元 注:数据基于 2026 年 1 月上海市公积金及商贷利率模型测算,模型由 Gemini 3 Pro 辅助构建 这样对比下来节约的钱为 69019 元,如果引入 NPV(净现值) 的概念并把折现率设定在 2.5%(保守估计的长期通胀/理财收益率)的话,那这么个月节省的 192 元大约 NPV 为 4.85 万元。 月供占比:节省的 192 元仅占月供的 1.08%。在上海,这笔钱可能只能刚好覆盖一对伴侣每月多出来的水电费 风险不对等:而为了这 4.85 万 的 NPV 收益,双方在法律上进入了“无限责任合伙”状态。一旦产生职场回避导致的收入断层、代际干预导致的心理内耗,或者离婚时的财产分割纠纷,其潜在损失可能以百万为单位计算。 可能有一个潜在的好处是如果有一方住房公积金用不掉的话可以使用公积金还款 这样我们会发现一个令人沮丧的真相:在 2026 年的上海,一张结婚证在 600 万资产面前带来的金融溢价,仅仅相当于 4.8 万元的现值。 在金融上,锁定时间越长、风险越高的资产,要求的折现率就必须越高。(毕竟利息等于对资金持有人延迟使用资金(即“时间”)的补偿)如果按私募股权或创业投资的逻辑,面对婚姻这种‘退出机制极度不友好’的项目,折现率起码应该定在 8% 以上。 而如果按 8% 计算,这每月 192 元的利息节省,其 NPV 甚至不足 2 万元——如果你觉得你未来 30 年的自由、职业前景和家庭宁静加起来的主要动机只是为了薅这 2 万块的话,emmmm… 签证 我们经常有听到一句话,叫做「一辈子在办签证的中国人」,婚姻在签证部分有明显的加成效果。 无论是美国的 H4/F2,还是欧洲各国的家庭团聚签,婚姻是解锁“随行居住权”最快、最合法的途径。而在「λ 状态」下如果一方要出国工作,另一方通常只能通过申请学签或找工作来硬闯,门槛和难度都显著提升。 在大多数国家的绿卡申请中,配偶身份往往能享受更短的等待期或额外的配偶加分。对于许多由于排期(如中国申请者)而无法拿到身份的人来说,与已持证/外籍人士结婚几乎是唯一的“超车”手段。 对于旅游签证而言也有明显增益。签证官的核心博弈点只有两个:“你是否有足够的财力支付旅费” 以及 “你是否有足够的动力按时回国”。 在移民官眼中,婚姻状态不仅是一张纸,它更像是一份“人身质押契约”,如果你是“领证”状态,且配偶留在国内,这在行政逻辑上被视为你具有极强的“回国动机”。 当然,「超额收益也带来了超额风险」,许多国家的配偶签(如早期的 H4)是不允许合法工作的。这意味着你在法律层面被迫成为了伴侣的“附属品”,你的职业生涯会因为这一张签证而出现数年的断层,经济独立权被剥夺。 且你的合法身份完全取决于这段关系的存续。一旦发生婚变或家暴,受害方往往因为担心“离婚即遣返”而被迫忍气吞声。这种“身份要挟”可能是婚姻制度在签证语境下最黑暗的一面。 医疗和遗产继承 医疗 上面说了那么多婚姻带来的弊端,我们也来看看婚姻带来的一些好处,例如医疗签字权和遗产。 首先是医疗,根据「医疗机构管理条例」( ),第三十二条: 第三十二条 医务人员在诊疗活动中应当向患者说明病情和医疗措施。需要实施手术、特殊检查、特殊治疗的,医务人员应当及时向患者具体说明医疗风险、替代医疗方案等情况,并取得其明确同意;不能或者不宜向患者说明的,应当向患者的近亲属说明,并取得其明确同意。因抢救生命垂危的患者等紧急情况,不能取得患者或者其近亲属意见的,经医疗机构负责人或者授权的负责人批准,可以立即实施相应的医疗措施。 简单来说:医院不会眼睁睁看你死,但可能会让你“多等一会儿”,当你生命体征暂时稳定,但需要进行具有伤残风险、高后遗症风险的重大手术时,法律逻辑就变了。这时候,医生必须寻求“知情同意”,而这个时候「知情同意」几乎必须得是近亲属。 虽然这个 Bug 可能可以通过已经公证的「意定监护书」来绕过,但是对比直接的婚姻关系而言,走意定监护的路径引入了额外的沟通磨损。 遗产继承 关于遗产方面,首先我们都知道人终有一死,可能是死于跳伞,可能是死于骑行新国飙,也有可能死于各类稀奇古怪的交通事故。 《民法典》第1061条规定夫妻有相互继承遗产的权利,表明夫妻之间彼此为法定继承人,有权通过法定继承或者遗嘱继承的方式继承对方的遗产,此时如果在没有婚姻绑定或者公证后的遗嘱的情况下,例如房产(哪怕是共同出资但只写了一人名字)和资产 什么?你说你有数字货币?私钥没给伴侣的话那没了 会全部由其父母继承,另一方可能面临“人财两空”甚至被扫地出门的风险。 上述两个问题有一定的缓解方式(「意定监护书」和「遗嘱」)来防止自己或者伴侣被清算(真正的 Liquidation),但是对比婚姻关系带来的法律执行力而言,还是有所差异,需要额外注意。 小结 回到文章标题的那个问题:法律上当路人,生活中当爱人,契约上当合伙人,是亲密关系的最优解吗? 在父辈的年代,由于个体生存能力的匮乏,购买这个“全家桶”是生存的刚需。但在个体原子化、女性经济独立且社会分工极度细化的今天,我们是否有必要为了其中某一项功能(比如签证,或者那微薄的房贷利差),而去背负整个大礼包中潜在的巨大负债(如无限连带责任和代际摩擦)? 传统观念我们一般认为,由于有高昂的退出成本(由于婚姻关系带来的:法律程序,财产分割,婚宴等时候的公开露面)构建了婚姻的安全感。但是从博弈的视角下,当一个人是因为「离婚太麻烦」或者「沉没成本」太大而不得不留在一段关系中时,这段关系是否已经从「合作」变成了「绑架」,从我个人的经历来看,身边有见过不少类似的案例,甚至有见到个人感觉已经算是「绑架」的场景下发展出了斯德哥尔摩综合征的表现,每每想起让我倍感无奈和惋惜。 上文中的「λ 状态」,本质上是对于亲密关系的一次解耦,个人感觉这个状态迷人之处在于:它通过降低退出的门槛,反向验证了在一起的纯度, 没有退出门槛的留存,或许才是最真实的留存。 因为没有那个法律证件的捆绑,也没有法律赋予的无限责任,甚至没有那可能不到 5 万元的 NPV 的诱惑,每一天的相处都像是一个个 24hr 有效期的独立契约。 这当然是一条更难的路。因为它要求你有更强的抗风险能力、更清晰的边界意识,以及直面世俗偏见的勇气。 如果明天醒来,我依然选择和你在一起,不是因为法律规定我不能走,也不是因为走了会损失一半财产,仅仅是因为——我依然想和你在一起。 以上。

许多时候当我们需要对接一个交易所的数据用于本地分析或者别的什么事情的时候都需要从 Websocket 获得连续的市场信息并在本地构建一个 Orderbook 方便分析,本文是本人作为一个 Rust 新手的一次练习笔记。 Raw data 这次的目标练习交易所是 Lighter,建立 Websocket 连接之后通过发送如下信息即可开始订阅: { "type" : "subscribe" , "channel" : "order_book/{MARKET_INDEX}" } 得到的数据节选如下,订阅成功后会先得到一个全量的 orderbook,这里有所删减,实际上获得的第一条快照数据 subscribed/order_book 的 asks 和 bids 都有 2500 条以上。 { "channel" : "order_book:1" , "offset" : 12837513 , "order_book" : { "code" : 0 , "asks" : [ { "price" : "87194.4" , "size" : "1.03736" }, { "price" : "87194.5" , "size" : "0.02980" } ], "bids" : [ { "price" : "87194.0" , "size" : "0.00020" }, { "price" : "87191.6" , "size" : "0.03676" } ], "offset" : 12837513 , "nonce" : 3901590619 }, "timestamp" : 1766059985139 , "type" : "subscribed/order_book" } 后续的 update/order_book 数据结构类似如下: { "channel" : "order_book:1" , "offset" : 12837556 , "order_book" : { "code" : 0 , "asks" : [ { "price" : "87208.2" , "size" : "0.00000" } ], "bids" : [ { "price" : "87173.2" , "size" : "0.01389" } ], "offset" : 12837556 , "nonce" : 3901590736 }, "timestamp" : 1766059985417 , "type" : "update/order_book" } 不难理解,对于第一次推送是给了 orderbook 的一个全量快照,后续是给的增量数据,如果 size 是 “0.00000” ,那么就是删除 orderbook 中对应的数据,此外需要根据 offset 严格自增判断,不能丢任何一条数据,丢了就得断连之后重新从快照开始。 Cargo run 以下代码运行方式均为: cargo run ,而不是 cargo run --release 。 First attempt——220µs 作为新手,比较直观的想法肯定是——我先跑起来再说! 示意图如下(Gemini 画图) +-----------------------------------------------------------------------+ | 🧠 核心思路: "直观映射" (Intuitive) | | "JSON 是什么样,我就定义什么样的 Struct,存下来再说!" | +-----------------------------------------------------------------------+ | | 1. JSON 输入 (Incoming Data) V +--------------------------+ | JSON Object | | { | | "type": "...", | serde 自动反序列化 | "bids": [...], | ========================> | "asks": [...] | | } | +--------------------------+ | | 2. Rust 内存结构 (Memory Layout) V +-------------------------------------------------------------+ | struct LighterOrderBook (Root) | +-------------------------------------------------------------+ | - channel: Option<String> | | - offset: Option<u64> | | - type_: String | | - timestamp: Option<u64> | | | | [!] 嵌套层级 (Nested Layer) | | - order_book: Option -------------------------------------> + +-------------------------------------------------------------+ | +---------------------------------------------------+ | v +-------------------------------------------------------------+ | struct LighterOrderBookData | +-------------------------------------------------------------+ | - code: u32 | | | | [!] 列表直接转 Vec (List -> Vec) | | - asks: Vec<LighterOrderBookEntry> ---+ | | - bids: Vec<LighterOrderBookEntry> ---|---+ | +-------------------------------------------------------------+ | | +-----------------------------+ | | | v v +----------------------------+ +----------------------------+ | Entry (Ask) | | Entry (Bid) | +----------------------------+ +----------------------------+ | - price: String (Heap 🐢) | | - price: String (Heap 🐢) | | - size: String (Heap 🐢) | | - size: String (Heap 🐢) | +----------------------------+ +----------------------------+ ^ ^ | | +--- "既然是 List,那就存俩 List 本地更新完事" ---+ 所以第一反应无脑想法是跟着 JSON 的结构搞一个 Rust 的 Struct 出来: 你 bids 和 asks 不是俩 List 么?那我也存俩 List 本地更新不就完事了? #[derive(Debug, Deserialize)] pub struct LighterOrderBookEntry { pub price : String , pub size : String , } #[derive(Debug, Deserialize)] pub struct LighterOrderBookData { pub code : u32 , pub asks : Vec < LighterOrderBookEntry > , pub bids : Vec < LighterOrderBookEntry > , } #[derive(Debug, Deserialize)] pub struct LighterOrderBook { pub channel : Option < String > , pub offset : Option < u64 > , pub order_book : Option < LighterOrderBookData > , #[serde(rename = "type" )] pub type_ : String , pub timestamp : Option < u64 > , } 然后对于每一次 Websocket 收到的数据都去更新一下我们本地的一个 Orderbook: fn update_order_book_data ( order_book_data : & mut LighterOrderBookData , updated_entries : & LighterOrderBookData , ) { for update_order in & updated_entries . asks { let new_size_float : f64 = update_order . size . parse (). unwrap (); if new_size_float == 0.0 { order_book_data . asks . retain ( | order | { order . price != update_order . price }); } else if let Some ( existing_order ) = order_book_data . asks . iter_mut () . find ( | order | order . price == update_order . price ) { existing_order . size = update_order . size . clone (); } else { order_book_data . asks . push ( LighterOrderBookEntry { price : update_order . price . clone (), size : update_order . size . clone (), }, ); } } for update_order in & updated_entries . bids { // bids 是一样的道理 } } 然后我们来监控一下这里的耗时: let start_time = Instant :: now (); update_order_book_data ( order_book_data , update_order_book ); let duration = start_time . elapsed (); total_parsed_txs += 1 ; total_parse_time_cost += duration ; let average_parse_time_cost = total_parse_time_cost / total_parsed_txs ; println! ( "update_order_book_data 耗时: {:?} " , duration ); println! ( "update_order_book_data 平均耗时: {:?} " , average_parse_time_cost ); 结果如下: update_order_book_data 平均耗时: 225.065µs update_order_book_data 耗时: 181.152µs update_order_book_data 平均耗时: 224.999µs update_order_book_data 耗时: 503.89µs update_order_book_data 平均耗时: 225.416µs update_order_book_data 耗时: 115.778µs update_order_book_data 平均耗时: 225.252µs update_order_book_data 耗时: 80.782µs update_order_book_data 平均耗时: 225.037µs update_order_book_data 耗时: 79.84µs update_order_book_data 平均耗时: 224.821µs update_order_book_data 耗时: 85.672µs update_order_book_data 平均耗时: 224.614µs update_order_book_data 耗时: 320.204µs update_order_book_data 平均耗时: 224.756µs 这里有一个好消息和一个好消息,好消息1是对于一个Rust超级新手的我来说,这个代码跑起来了,Orderbook也在正确更新了(在别的地方通过 Log 确认),第二个好消息就是——这平均 230µs 的时间看上去也太快了叭! BTreeMap Refined——20µs(4µs average) 但是显然我们都知道这个实现非常的挫,在前文中我们知道「实际上 asks 和 bids 都有 2500 条以上」,那这里每一次 retain 和 .iter_mut().find() 这类遍历数组+复制内存都是挺要命的操作。 所以不要被交易所返回的结构带沟里了,这里我们优化一下存储的思路,本地每个 Price-Size 对不要作为 Vec 的一个元素了,而是放到一个 BTreeMap 里面。 我们将存储的结构做一些修改,将 Price 作为 Key,同时为了避免掉进 String 对比价格无法正确获得盘口(Asks 最小和 Bids 最大)的坑,我们引入 ordered-float 包,现在我们本地的 Orderbook 定义如下: #[derive(Debug, Deserialize, Default)] pub struct FastLighterOrderBookData { pub code : u32 , // Key: Price, Value: Size pub asks : BTreeMap < OrderedFloat < f64 > , String > , pub bids : BTreeMap < OrderedFloat < f64 > , String > , } 这样我们的解析+更新本地 Orderbook 的函数就可以被大幅简化: fn update_order_book_data ( local_order_book_data : & mut FastLighterOrderBookData , updated_entries : & LighterOrderBookData , ) { for update_order_entry in & updated_entries . asks { let new_size_float : f64 = update_order_entry . size . parse (). unwrap (); let new_price_float_key = OrderedFloat ( update_order_entry . price . parse (). unwrap (), ); if new_size_float == 0.0 { local_order_book_data . asks . remove ( & new_price_float_key ); } else { local_order_book_data . asks . insert ( new_price_float_key , update_order_entry . size . clone (), ); } } for update_order_entry in & updated_entries . bids { let new_size_float : f64 = update_order_entry . size . parse (). unwrap (); let new_price_float_key = OrderedFloat ( update_order_entry . price . parse (). unwrap (), ); if new_size_float == 0.0 { local_order_book_data . bids . remove ( & new_price_float_key ); } else { local_order_book_data . bids . insert ( new_price_float_key , update_order_entry . size . clone (), ); } } } 再也不需要遍历数组或者复制内存了,我们运行一段时间看看效果: update_order_book_data 平均耗时: 18.807µs update_order_book_data 耗时: 13.515µs update_order_book_data 平均耗时: 18.795µs update_order_book_data 耗时: 12.674µs update_order_book_data 平均耗时: 18.782µs update_order_book_data 耗时: 33.072µs update_order_book_data 平均耗时: 18.812µs update_order_book_data 耗时: 9.097µs update_order_book_data 平均耗时: 18.792µs update_order_book_data 耗时: 27.842µs update_order_book_data 平均耗时: 18.811µs 好,10 倍的性能提升就这么摸出来了! Ratio 这里数量级已经比较小了,为了更加精确统计,而不要受到实际更新的数量的影响,这里修改一下统计的部分代码: let update_order_book_total_length = update_order_book . asks . len () + update_order_book . bids . len (); let start_time = Instant :: now (); update_order_book_data ( & mut local_lighter_order_book , update_order_book , ); let duration = start_time . elapsed (); let ratio_duration = duration / update_order_book_total_length . try_into (). unwrap (); total_parsed_txs += 1 ; total_parse_time_cost += ratio_duration ; let average_parse_time_cost = total_parse_time_cost / total_parsed_txs ; println! ( "update_order_book_data 加权耗时: {:?} , 实际总耗时: {:?} " , ratio_duration , duration ); println! ( "update_order_book_data 加权平均耗时: {:?} " , average_parse_time_cost ); 每次统计的耗时会除以实际的需要被修改的 bids/asks 的数量,获得一个平均耗时,重新运行上述代码,数据如下: update_order_book_data 加权耗时: 5.48µs, 实际总耗时: 5.48µs update_order_book_data 加权平均耗时: 3.994µs update_order_book_data 加权耗时: 1.77µs, 实际总耗时: 37.181µs update_order_book_data 加权平均耗时: 3.989µs update_order_book_data 加权耗时: 1.38µs, 实际总耗时: 48.311µs update_order_book_data 加权平均耗时: 3.982µs update_order_book_data 加权耗时: 1.32µs, 实际总耗时: 33.002µs update_order_book_data 加权平均耗时: 3.976µs update_order_book_data 加权耗时: 1.188µs, 实际总耗时: 30.898µs update_order_book_data 加权平均耗时: 3.969µs String to f64 ——12µs(2.9µs average) 现在有另一个明显的可以优化的地方,就是这里: BTreeMap < OrderedFloat < f64 > , String > 由于交易所给的数据是字符串,这里想当然立即写了个 String,但是由于 A String is a wrapper over a Vec . 所以 String 的分配是在堆上,在一个高频对数据修修改改的程序来说,在堆上使用内存绝对不是一个什么好的注意: if you are allocating, you are losing 所以这里的一个小优化就是修改为全部 f64 的结构,如下: #[derive(Debug, Deserialize, Default)] pub struct FastLighterOrderBookData { pub code : u32 , // Key: Price, Value: Size pub asks : BTreeMap < OrderedFloat < f64 > , f64 > , pub bids : BTreeMap < OrderedFloat < f64 > , f64 > , } 然后在每次收到了 WS 数据的时候将 String 转换为 f64 并存储,此时运行效果如下: update_order_book_data 加权耗时: 3.354µs, 实际总耗时: 20.128µs update_order_book_data 加权平均耗时: 2.909µs update_order_book_data 加权耗时: 2.995µs, 实际总耗时: 5.991µs update_order_book_data 加权平均耗时: 2.91µs update_order_book_data 加权耗时: 4.114µs, 实际总耗时: 12.343µs update_order_book_data 加权平均耗时: 2.914µs 从 4µs 进一步稳定下降到了 2.9µs! Tick Table——**µs(990ns average) 使用 BTreeMap 已经极大的提升了我们的速度,但是 BTreeMap 背后的树状结构和指针还是太慢了,我让 Gemini 画了个 ASCII 的图方便大家理解: +------------------+ | Stack (栈) | +------------------+ | root_ptr ----------------+ +------------------+ | (1. 指针跳转) v +---------------------------+ | Heap Addr: 0x1000 (Root) | <--- 第一次内存读取 |---------------------------| (耗时 ~70ns) | Keys: [88000, 89000...] | | Children Ptrs: | | [0x5000, 0x9000...] | +------------+--------------+ | | (2. 指针跳转 - Pointer Chasing) v +---------------------------+ | Heap Addr: 0x9000 (Node) | <--- 第二次内存读取 |---------------------------| (耗时 ~70ns) | Keys: [88700, 88800...] | | Children Ptrs: | | [0x2000, 0x3000...] | +------------+--------------+ | | (3. 指针跳转 - Pointer Chasing) v +---------------------------+ | Heap Addr: 0x3000 (Leaf) | <--- 第三次内存读取 |---------------------------| (耗时 ~70ns) | Key: 88797.9 | | Value: 0.03268 | <--- 终于找到数据 +---------------------------+ 当我们查找价格 88797.9 时,CPU 必须像“跳房子”一样,从一个内存地址跳到另一个完全不相关的内存地址。每一次指针跳转 (Pointer Chasing) 都可能导致 CPU 缓存未命中 (Cache Miss),迫使 CPU 停下来等待慢速的主内存响应。 现在这里进一步优化的思路是将树状结构的操作改为数组的操作——Tick Table。 const TICK_SIZE_MULTIPLIER : f64 = 10.0 ; // 对应 tick_size 0.1 const MAX_PRICE_CAPACITY : usize = 2_000_000 ; #[derive(Debug, Deserialize, Default)] pub struct FlatLighterOrderBookData { pub code : u32 , // {"price":"88797.9","size":"0.03268"} // -> // asks[887979] -> "0.03268" pub asks : Vec < f64 > , pub bids : Vec < f64 > , pub best_ask_idx : usize , pub best_bid_idx : usize , } 原理如下:将 Price * TICK_SIZE 变成整数作为数组的 Key, size 直接作为数组的 Value,例如假设这里是处理的 BTC,交易所返回的数据都是精确到 0.1 (见上文),所以可以将实际数据乘以 10 作为 Key,同时维护两个游标——best_ask_idx 和 best_bid_idx 用于方便得到盘口 BBO。 示意图如下: 数组索引 (Index) 还原价格 (Price) Asks 数组 (Size) Bids 数组 (Size) 游标状态 (Cursors) 887981 88798.1 0.55000 0.00000 887980 88798.0 1.02000 0.00000 ⬅️ best_ask_idx (卖一) 887979 88797.9 0.00000 0.03268 ⬅️ best_bid_idx (买一) 887978 88797.8 0.00000 0.15000 887977 88797.7 0.00000 0.00000 (空档位,size为0) 这个时候对于价格的更新就是直接在数组中进行更新: #[inline(always)] fn price_to_index ( price : f64 ) -> usize { // 加 0.5 是为了处理浮点数精度误差 (epsilon),例如 0.999999 -> 1.0 ( price * TICK_SIZE_MULTIPLIER + 0.5 ) as usize } pub fn update ( & mut self , price : f64 , size : f64 , is_bid : bool ) { let price_idx = Self :: price_to_index ( price ); if price_idx >= MAX_PRICE_CAPACITY { eprintln! ( "Error: Price {} out of bounds" , price ); return ; } if is_bid { self . bids [ price_idx ] = size ; if size > 0.0 { if price_idx > self . best_bid_idx { self . best_bid_idx = price_idx ; } // Same as current best_bid_idx and size == 0 // best_bid_idx is deleted } else if price_idx == self . best_bid_idx { let mut scan_price_idx = price_idx ; loop { if scan_price_idx == 0 { self . best_bid_idx = 0 ; // 流动性没了 break ; } scan_price_idx -= 1 ; if self . bids [ scan_price_idx ] > 0.0 { self . best_bid_idx = scan_price_idx ; break ; } } } } else { self . asks [ price_idx ] = size ; // 剩余部分和上面 Bids 差不多 } } 我们再来运行一下看看这次的耗时情况。 update_order_book_data 加权耗时: 618ns, 实际总耗时: 2.475µs update_order_book_data 加权平均耗时: 959ns update_order_book_data 加权耗时: 1.245µs, 实际总耗时: 4.98µs update_order_book_data 加权平均耗时: 963ns update_order_book_data 加权耗时: 2.274µs, 实际总耗时: 2.274µs update_order_book_data 加权平均耗时: 978ns update_order_book_data 加权耗时: 1.132µs, 实际总耗时: 2.264µs update_order_book_data 加权平均耗时: 980ns update_order_book_data 加权耗时: 1.613µs, 实际总耗时: 1.613µs update_order_book_data 加权平均耗时: 987ns update_order_book_data 加权耗时: 663ns, 实际总耗时: 2.655µs update_order_book_data 加权平均耗时: 984ns update_order_book_data 加权耗时: 1.703µs, 实际总耗时: 1.703µs update_order_book_data 加权平均耗时: 992ns 进一步将更新时间下降到了 990ns 附近! 最后,我们 cargo run --release 来看看效果吧: update_order_book_data 加权耗时: 576ns, 实际总耗时: 4.608µs update_order_book_data 加权平均耗时: 477ns update_order_book_data 加权耗时: 1.052µs, 实际总耗时: 1.052µs update_order_book_data 加权平均耗时: 483ns update_order_book_data 加权耗时: 127ns, 实际总耗时: 1.273µs update_order_book_data 加权平均耗时: 479ns update_order_book_data 加权耗时: 360ns, 实际总耗时: 1.443µs update_order_book_data 加权平均耗时: 478ns update_order_book_data 加权耗时: 1.372µs, 实际总耗时: 1.372µs update_order_book_data 加权平均耗时: 488ns update_order_book_data 加权耗时: 947ns, 实际总耗时: 1.894µs update_order_book_data 加权平均耗时: 492ns 492ns! 总结 我们一步步从模仿交易所的 asks 和 bids 数组,到改用 BTreeMap ,到 String -> f64 的小优化,最后到使用 Tick Table 的实践,从写了一个看上去实际可用的程序的出发学到了 Rust 的一些内容(还有一些 Tokio 等运行时并没有在本文中包括)。 当然,我们也得时不时回头看看我们的优化价值到底有多大,毕竟—— Premium Account (Opt-in) – Suitable for HFT, the lowest latency on Lighter. Fees: 0.002% Maker, 0.02% Taker maker/cancel latency: 0ms taker latency: 150ms part of volume quota program Standard Account (Default) – Suitable for retail and latency insensitive traders. fees: 0 maker / 0 taker taker latency: 300ms maker: 200ms cancel order: 100ms 夺少?!150ms?! Suitable for HFT?!

Many times, when we need to interface with an exchange’s data for local analysis or other tasks, we need to obtain continuous market information from a Websocket and build an Orderbook locally to facilitate analysis. This article is a practice note from my perspective as a Rust newbie. Raw data The target exchange for this practice is Lighter. After establishing a Websocket connection, you can start subscribing by sending the following message: { "type" : "subscribe" , "channel" : "order_book/{MARKET_INDEX}" } A selection of the data obtained is as follows. After a successful subscription, you will first receive a full Orderbook snapshot. It has been trimmed here; in reality, the asks and bids in the first snapshot data subscribed/order_book both have over 2500 entries. { "channel" : "order_book:1" , "offset" : 12837513 , "order_book" : { "code" : 0 , "asks" : [ { "price" : "87194.4" , "size" : "1.03736" }, { "price" : "87194.5" , "size" : "0.02980" } ], "bids" : [ { "price" : "87194.0" , "size" : "0.00020" }, { "price" : "87191.6" , "size" : "0.03676" } ], "offset" : 12837513 , "nonce" : 3901590619 }, "timestamp" : 1766059985139 , "type" : "subscribed/order_book" } The structure of subsequent update/order_book data is similar to this: { "channel" : "order_book:1" , "offset" : 12837556 , "order_book" : { "code" : 0 , "asks" : [ { "price" : "87208.2" , "size" : "0.00000" } ], "bids" : [ { "price" : "87173.2" , "size" : "0.01389" } ], "offset" : 12837556 , "nonce" : 3901590736 }, "timestamp" : 1766059985417 , "type" : "update/order_book" } It is not hard to understand that the first push provides a full snapshot of the orderbook, and subsequent pushes provide incremental data. If size is “0.00000”, it means deleting the corresponding data from the orderbook. Additionally, one must strictly judge based on the auto-incrementing offset; if any single piece of data is missed, you must disconnect and restart from the snapshot. Cargo run All the following codes are run using: cargo run , not cargo run --release . First attempt——220µs As a novice, the more intuitive idea is definitely—let me just get it running first! The schematic diagram is as follows (Gemini drawing): +-----------------------------------------------------------------------+ | 🧠 Core Idea: "Intuitive Mapping" (Intuitive) | | "Whatever the JSON looks like, I'll define that Struct and save it!" | +-----------------------------------------------------------------------+ | | 1. JSON Input (Incoming Data) V +--------------------------+ | JSON Object | | { | | "type": "...", | serde automatic deserialization | "bids": [...], | ========================> | "asks": [...] | | } | +--------------------------+ | | 2. Rust Memory Layout V +-------------------------------------------------------------+ | struct LighterOrderBook (Root) | +-------------------------------------------------------------+ | - channel: Option<String> | | - offset: Option<u64> | | - type_: String | | - timestamp: Option<u64> | | | | [!] Nested Layer | | - order_book: Option -------------------------------------> + +-------------------------------------------------------------+ | +---------------------------------------------------+ | v +-------------------------------------------------------------+ | struct LighterOrderBookData | +-------------------------------------------------------------+ | - code: u32 | | | | [!] List -> Vec | | - asks: Vec<LighterOrderBookEntry> ---+ | | - bids: Vec<LighterOrderBookEntry> ---|---+ | +-------------------------------------------------------------+ | | +-----------------------------+ | | | v v +----------------------------+ +----------------------------+ | Entry (Ask) | | Entry (Bid) | +----------------------------+ +----------------------------+ | - price: String (Heap 🐢) | | - price: String (Heap 🐢) | | - size: String (Heap 🐢) | | - size: String (Heap 🐢) | +----------------------------+ +----------------------------+ ^ ^ | | +--- "Since it's a List, I'll save two Lists and update locally" ---+ So the immediate brainless thought is to create a Rust Struct following the JSON structure: Aren’t bids and asks just two Lists? Then I’ll just store two Lists and update them locally, done. #[derive(Debug, Deserialize)] pub struct LighterOrderBookEntry { pub price : String , pub size : String , } #[derive(Debug, Deserialize)] pub struct LighterOrderBookData { pub code : u32 , pub asks : Vec < LighterOrderBookEntry > , pub bids : Vec < LighterOrderBookEntry > , } #[derive(Debug, Deserialize)] pub struct LighterOrderBook { pub channel : Option < String > , pub offset : Option < u64 > , pub order_book : Option < LighterOrderBookData > , #[serde(rename = "type" )] pub type_ : String , pub timestamp : Option < u64 > , } Then, for every piece of data received from the Websocket, we go and update our local Orderbook: fn update_order_book_data ( order_book_data : & mut LighterOrderBookData , updated_entries : & LighterOrderBookData , ) { for update_order in & updated_entries . asks { let new_size_float : f64 = update_order . size . parse (). unwrap (); if new_size_float == 0.0 { order_book_data . asks . retain ( | order | { order . price != update_order . price }); } else if let Some ( existing_order ) = order_book_data . asks . iter_mut () . find ( | order | order . price == update_order . price ) { existing_order . size = update_order . size . clone (); } else { order_book_data . asks . push ( LighterOrderBookEntry { price : update_order . price . clone (), size : update_order . size . clone (), }, ); } } for update_order in & updated_entries . bids { // bids work the same way } } Then let’s monitor the time consumption here: let start_time = Instant :: now (); update_order_book_data ( order_book_data , update_order_book ); let duration = start_time . elapsed (); total_parsed_txs += 1 ; total_parse_time_cost += duration ; let average_parse_time_cost = total_parse_time_cost / total_parsed_txs ; println! ( "update_order_book_data time cost: {:?} " , duration ); println! ( "update_order_book_data average time cost: {:?} " , average_parse_time_cost ); The results are as follows: update_order_book_data average time cost: 225.065µs update_order_book_data time cost: 181.152µs update_order_book_data average time cost: 224.999µs update_order_book_data time cost: 503.89µs update_order_book_data average time cost: 225.416µs update_order_book_data time cost: 115.778µs update_order_book_data average time cost: 225.252µs update_order_book_data time cost: 80.782µs update_order_book_data average time cost: 225.037µs update_order_book_data time cost: 79.84µs update_order_book_data average time cost: 224.821µs update_order_book_data time cost: 85.672µs update_order_book_data average time cost: 224.614µs update_order_book_data time cost: 320.204µs update_order_book_data average time cost: 224.756µs Here is one piece of good news and another piece of good news. Good news #1 is that for a Rust super-newbie like me, this code runs, and the Orderbook is updating correctly (confirmed via logs elsewhere). The second piece of good news is—this average time of 230µs looks way too fast! BTreeMap Refined——20µs (4µs average) But obviously, we all know this implementation is very clumsy. We learned earlier that “actually asks and bids both have over 2500 entries,” so operations like retain and .iter_mut().find() —traversing arrays + copying memory every time—are pretty deadly. So, let’s not be misled by the structure returned by the exchange. Let’s optimize our storage approach here. Instead of storing each Price-Size pair locally as an element of a Vec, we put them into a BTreeMap. We will make some modifications to the storage structure, using Price as the Key. At the same time, to avoid the pitfall of String price comparison failing to correctly identify the spread (lowest Asks and highest Bids), we introduce the ordered-float crate. Our local Orderbook definition is now as follows: #[derive(Debug, Deserialize, Default)] pub struct FastLighterOrderBookData { pub code : u32 , // Key: Price, Value: Size pub asks : BTreeMap < OrderedFloat < f64 > , String > , pub bids : BTreeMap < OrderedFloat < f64 > , String > , } This way, our function for parsing + updating the local Orderbook can be significantly simplified: fn update_order_book_data ( local_order_book_data : & mut FastLighterOrderBookData , updated_entries : & LighterOrderBookData , ) { for update_order_entry in & updated_entries . asks { let new_size_float : f64 = update_order_entry . size . parse (). unwrap (); let new_price_float_key = OrderedFloat ( update_order_entry . price . parse (). unwrap (), ); if new_size_float == 0.0 { local_order_book_data . asks . remove ( & new_price_float_key ); } else { local_order_book_data . asks . insert ( new_price_float_key , update_order_entry . size . clone (), ); } } for update_order_entry in & updated_entries . bids { let new_size_float : f64 = update_order_entry . size . parse (). unwrap (); let new_price_float_key = OrderedFloat ( update_order_entry . price . parse (). unwrap (), ); if new_size_float == 0.0 { local_order_book_data . bids . remove ( & new_price_float_key ); } else { local_order_book_data . bids . insert ( new_price_float_key , update_order_entry . size . clone (), ); } } } No more iterating through arrays or copying memory. Let’s run it for a while and see the effect: update_order_book_data average time cost: 18.807µs update_order_book_data time cost: 13.515µs update_order_book_data average time cost: 18.795µs update_order_book_data time cost: 12.674µs update_order_book_data average time cost: 18.782µs update_order_book_data time cost: 33.072µs update_order_book_data average time cost: 18.812µs update_order_book_data time cost: 9.097µs update_order_book_data average time cost: 18.792µs update_order_book_data time cost: 27.842µs update_order_book_data average time cost: 18.811µs Alright, a 10x performance improvement just like that! Ratio The magnitude here is already quite small. To get more precise statistics and not be affected by the actual number of updates, let’s modify the statistics part of the code: let update_order_book_total_length = update_order_book . asks . len () + update_order_book . bids . len (); let start_time = Instant :: now (); update_order_book_data ( & mut local_lighter_order_book , update_order_book , ); let duration = start_time . elapsed (); let ratio_duration = duration / update_order_book_total_length . try_into (). unwrap (); total_parsed_txs += 1 ; total_parse_time_cost += ratio_duration ; let average_parse_time_cost = total_parse_time_cost / total_parsed_txs ; println! ( "update_order_book_data weighted time cost: {:?} , actual total time: {:?} " , ratio_duration , duration ); println! ( "update_order_book_data weighted average time cost: {:?} " , average_parse_time_cost ); The time cost for each statistic is divided by the actual number of bids/asks that need to be modified to obtain an average time cost. Re-running the code above, the data is as follows: update_order_book_data weighted time cost: 5.48µs, actual total time: 5.48µs update_order_book_data weighted average time cost: 3.994µs update_order_book_data weighted time cost: 1.77µs, actual total time: 37.181µs update_order_book_data weighted average time cost: 3.989µs update_order_book_data weighted time cost: 1.38µs, actual total time: 48.311µs update_order_book_data weighted average time cost: 3.982µs update_order_book_data weighted time cost: 1.32µs, actual total time: 33.002µs update_order_book_data weighted average time cost: 3.976µs update_order_book_data weighted time cost: 1.188µs, actual total time: 30.898µs update_order_book_data weighted average time cost: 3.969µs String to f64 ——12µs (2.9µs average) Now there is another obvious place that can be optimized, which is here: BTreeMap < OrderedFloat < f64 > , String > Since the data provided by the exchange are strings, I instinctively wrote String . However, since: A String is a wrapper over a Vec . Allocating String happens on the heap. For a program that modifies data at high frequency, using memory on the heap is definitely not a good idea: if you are allocating, you are losing So, a small optimization here is to change everything to an f64 structure, as follows: #[derive(Debug, Deserialize, Default)] pub struct FastLighterOrderBookData { pub code : u32 , // Key: Price, Value: Size pub asks : BTreeMap < OrderedFloat < f64 > , f64 > , pub bids : BTreeMap < OrderedFloat < f64 > , f64 > , } Then, every time WS data is received, convert String to f64 and store it. The running effect is now as follows: update_order_book_data weighted time cost: 3.354µs, actual total time: 20.128µs update_order_book_data weighted average time cost: 2.909µs update_order_book_data weighted time cost: 2.995µs, actual total time: 5.991µs update_order_book_data weighted average time cost: 2.91µs update_order_book_data weighted time cost: 4.114µs, actual total time: 12.343µs update_order_book_data weighted average time cost: 2.914µs It steadily dropped further from 4µs to 2.9µs! Tick Table——**µs (990ns average) Using BTreeMap has already vastly improved our speed, but the tree structure and pointers behind BTreeMap are still too slow. I asked Gemini to draw an ASCII chart to help everyone understand: +------------------+ | Stack | +------------------+ | root_ptr ----------------+ +------------------+ | (1. Pointer Jump) v +---------------------------+ | Heap Addr: 0x1000 (Root) | <--- 1st Memory Read |---------------------------| (Time ~70ns) | Keys: [88000, 89000...] | | Children Ptrs: | | [0x5000, 0x9000...] | +------------+--------------+ | | (2. Pointer Jump - Pointer Chasing) v +---------------------------+ | Heap Addr: 0x9000 (Node) | <--- 2nd Memory Read |---------------------------| (Time ~70ns) | Keys: [88700, 88800...] | | Children Ptrs: | | [0x2000, 0x3000...] | +------------+--------------+ | | (3. Pointer Jump - Pointer Chasing) v +---------------------------+ | Heap Addr: 0x3000 (Leaf) | <--- 3rd Memory Read |---------------------------| (Time ~70ns) | Key: 88797.9 | | Value: 0.03268 | <--- Finally found data +---------------------------+ When we look up the price 88797.9, the CPU must play “hopscotch,” jumping from one memory address to another completely unrelated memory address. Every Pointer Chasing can cause a Cache Miss, forcing the CPU to stop and wait for the slow main memory to respond. The idea for further optimization now is to change the tree structure operation to an array operation—the Tick Table. const TICK_SIZE_MULTIPLIER : f64 = 10.0 ; // corresponds to tick_size 0.1 const MAX_PRICE_CAPACITY : usize = 2_000_000 ; #[derive(Debug, Deserialize, Default)] pub struct FlatLighterOrderBookData { pub code : u32 , // {"price":"88797.9","size":"0.03268"} // -> // asks[887979] -> "0.03268" pub asks : Vec < f64 > , pub bids : Vec < f64 > , pub best_ask_idx : usize , pub best_bid_idx : usize , } The principle is as follows: Transform Price * TICK_SIZE into an integer to serve as the Key of the array, and use size directly as the Value of the array. For example, assuming we are processing BTC here, the data returned by the exchange is precise to 0.1 (see above), so we can multiply the actual data by 10 to use as the Key. At the same time, we maintain two cursors— best_ask_idx and best_bid_idx —to easily obtain the BBO (Best Bid Offer). The schematic is as follows: Array Index (Index) Restored Price (Price) Asks Array (Size) Bids Array (Size) Cursor State (Cursors) 887981 88798.1 0.55000 0.00000 887980 88798.0 1.02000 0.00000 ⬅️ best_ask_idx (Sell 1) 887979 88797.9 0.00000 0.03268 ⬅️ best_bid_idx (Buy 1) 887978 88797.8 0.00000 0.15000 887977 88797.7 0.00000 0.00000 (Empty slot, size is 0) At this time, updating the price is simply updating directly within the array: #[inline(always)] fn price_to_index ( price : f64 ) -> usize { // Adding 0.5 is to handle floating point precision errors (epsilon), e.g., 0.999999 -> 1.0 ( price * TICK_SIZE_MULTIPLIER + 0.5 ) as usize } pub fn update ( & mut self , price : f64 , size : f64 , is_bid : bool ) { let price_idx = Self :: price_to_index ( price ); if price_idx >= MAX_PRICE_CAPACITY { eprintln! ( "Error: Price {} out of bounds" , price ); return ; } if is_bid { self . bids [ price_idx ] = size ; if size > 0.0 { if price_idx > self . best_bid_idx { self . best_bid_idx = price_idx ; } // Same as current best_bid_idx and size == 0 // best_bid_idx is deleted } else if price_idx == self . best_bid_idx { let mut scan_price_idx = price_idx ; loop { if scan_price_idx == 0 { self . best_bid_idx = 0 ; // liquidity is gone break ; } scan_price_idx -= 1 ; if self . bids [ scan_price_idx ] > 0.0 { self . best_bid_idx = scan_price_idx ; break ; } } } } else { self . asks [ price_idx ] = size ; // The rest is similar to Bids above } } Let’s run it again to see the time consumption this time. update_order_book_data weighted time cost: 618ns, actual total time: 2.475µs update_order_book_data weighted average time cost: 959ns update_order_book_data weighted time cost: 1.245µs, actual total time: 4.98µs update_order_book_data weighted average time cost: 963ns update_order_book_data weighted time cost: 2.274µs, actual total time: 2.274µs update_order_book_data weighted average time cost: 978ns update_order_book_data weighted time cost: 1.132µs, actual total time: 2.264µs update_order_book_data weighted average time cost: 980ns update_order_book_data weighted time cost: 1.613µs, actual total time: 1.613µs update_order_book_data weighted average time cost: 987ns update_order_book_data weighted time cost: 663ns, actual total time: 2.655µs update_order_book_data weighted average time cost: 984ns update_order_book_data weighted time cost: 1.703µs, actual total time: 1.703µs update_order_book_data weighted average time cost: 992ns We have further reduced the update time to around 990ns! Finally, let’s cargo run --release and check the results: update_order_book_data weighted time cost: 576ns, actual total time: 4.608µs update_order_book_data weighted average time cost: 477ns update_order_book_data weighted time cost: 1.052µs, actual total time: 1.052µs update_order_book_data weighted average time cost: 483ns update_order_book_data weighted time cost: 127ns, actual total time: 1.273µs update_order_book_data weighted average time cost: 479ns update_order_book_data weighted time cost: 360ns, actual total time: 1.443µs update_order_book_data weighted average time cost: 478ns update_order_book_data weighted time cost: 1.372µs, actual total time: 1.372µs update_order_book_data weighted average time cost: 488ns update_order_book_data weighted time cost: 947ns, actual total time: 1.894µs update_order_book_data weighted average time cost: 492ns 492ns! Summary We went step by step from mimicking the exchange’s asks and bids arrays, to switching to BTreeMap , to the small optimization of String -> f64, and finally to the practice of using a Tick Table. Starting from writing a program that looked practically usable, I learned some content about Rust (and some things like the Tokio runtime which were not included in this article). Of course, we also have to look back occasionally to see how much value our optimization really has, after all— Premium Account (Opt-in) – Suitable for HFT, the lowest latency on Lighter. Fees: 0.002% Maker, 0.02% Taker maker/cancel latency: 0ms taker latency: 150ms part of volume quota program Standard Account (Default) – Suitable for retail and latency insensitive traders. fees: 0 maker / 0 taker taker latency: 300ms maker: 200ms cancel order: 100ms How much?! 150ms?! Suitable for HFT?!

这篇文章原写就于:2025-05-16 后便一直在草稿中,最近在看到了「 」一文后感触很多,便继续完善了本文并进行发布。 引子 从大学毕业加入 PingCAP 开始正式参与工作之后便感觉自己的专注力越来越差,经过一些思考和观察感觉这里专注力差的来源应该有如下几个方面的原因。 首先是年龄逐渐变大(这是生理极限,但应该影响不会很大,且应该不会跃变),所以这应该只是一个 minor factor。 其次是信息过载和上下文切换,在大学期间每天主要的活动局限于——上课,研究自己的项目(且几乎没有并发),根据自己的某些项目写代码做实验。但是到了工作之后就可能会高并发处理 N 个同时发生的问题,设想一下,当你在尝试阅读 Jenkins 的 Groovy 语法的时候有人来找你问什么公司的专线开始丢包了,同时你还想着半个小时前和 A 同事说你要尝试修改一下机房某些路由的事情,这个时候,X 聊天工具突然蹦出来一个不知道是谁的人说「Hi Nova,我们 ZZZ 账户的管理是找你嘛?」。 和我们往一个随机读写能力不行的存储器上复制文件一样,我们的未根据现在生活状态进化大脑一旦获得了并发的请求便会开始大量进行上下文切换(因为无法真正实现并发,只能通过上下文切换模拟并发)并大幅降低效率,和 CPU 一样,每次上下文切换都会带来大量的性能开销让工作变慢,对于并发复制文件的例子便是速度只和比不上顺序复制的 1/2,而对于人脑来说,便是工作效率非常低,导致工作出错率提升,且很容易很累。 图片来源: 对于并发处理事务(不可避免的带来上下文切换)带来的代价有多大呢?上图的文章中有一个简单的概括: 如果你一次只处理一项任务,那么你可以将100%的时间投入其中。如果再添加一项任务,你将有20%的时间用于上下文切换,最终每项任务只能分配40%的时间。如果再添加一项任务,你将有近一半的时间用于上下文切换。即使你尝试只处理一项任务,回复新邮件或查看桌面通知也会对整体产生深远的影响。 上下文切换 在从工作之后逐渐有了以上观察的情况下,由于我一直信奉上下文切换是这一切的根因,且自诩自己是一个追求极高效率的人,我努力发觉生活中可能带来上下文切换的东西并减少切换发生的次数来尝试换来更高的专注力和思维的深度。 在这里我想问读者一个问题,无论上班与否,你是否在完成一个项目的中途习惯性摸起手机开始玩? 例如在运行了 cargo build (或者 bun run build ) 等待的时间想着摸起手机看一会 傻逼 小红书或者打开开心消消乐? 例如在尝试设计一个较为复杂的功能在构思的时候突然 Telegram/WeChat 上有个朋友给你发来了山西大同「订婚强奸案」的一些诡异评论而暂时丢下目前的构思并开始回复消息,同时习惯性打开 Google 搜索这个案子的更多上下文信息并和朋友开始聊天? 例如设计完成了多个功能的一个部分的时候(比如某个项目登录过程中的 JWT 部分)感觉松了一口气于是很自然的 Ctrl-T ,然后打开 Twitter 开始刷推? 例如在某个绿色聊天软件上和朋友聊天发完消息,并在对面还没回复的时候就习惯性点「发现」然后打开「朋友圈」刷一下? 为什么我可以很自然的举出上面的例子,因为 我曾经 周围的人就是这样🤪 还记得上文吗?由于我自诩自己一个追求极高效率的人,最近一个偶然的机会重新捡起「自控力」一书,并尝试带着更多的经验积累尝试从书中获得一些或许是新的感悟。(为什么是「或许是新的感悟」,因为我记得我之前读过这本书,但是我并不记得我什么时候读过了) 豆瓣上对于本书的简介如下: 作为一名健康心理学家,凯利·麦格尼格尔博士的工作就是帮助人们管理压力,并在生活中做出积极的改变。多年来,通过观察学生们是如何控制选择的,她意识到,人们关于自控的很多看法实际上妨碍了我们取得成功。 例如,把自控力当作一种美德,可能会让初衷良好的目标脱离正轨。所以,麦格尼格尔要求她的学生了解影响自控的生理学基础、心理陷阱和各种社会因素。麦格尼格尔吸收了心理学、神经学和经济学等学科的最新洞见,为斯坦福大学继续教育项目开设了一门叫做“意志力科学”的课程,参与过这门课程的人称其能够“改变一生”。 这门课程就是《自控力》一书的基础。本书为读者提供了清晰的框架,讲述了什么是自控力,自控力如何发生作用,以及为何自控力如此重要。 概要 当下阅读这本书的主要感觉是全书不长,且带有许多作者本人以及教学途中来自学生的经验,但是有过阅读类似「亲密关系」这类个人感觉介于论文和书本之间的书后会感觉本书的例子和论证还是缺少一些论文证据的举证,所以这里也建议对本书有兴趣的读者在阅读时可以审慎地阅读并尝试分析作者提出的观点是否有前后矛盾的地方或者论据不足的地方。 而本文也会着重从自己的观察出发记录一些从书中获得到的收获和感想。 如果你好奇我说的「介于论文和书本之间的书」是怎么个样子的话,这是从「亲密关系」中的一段引用: 能量预算模型 在《自控力》第二章的位置作者引用了 X T Wang 1 , Robert D Dvorak 的「能量预算」理论,对于大脑来说能量就是金钱,实验邀请 65 位实验对象通过一系列经典的自控力测试,比如「今天拿 120 美元还是一个月后拿 450 美元」之类来判断实验对象的自控力水平。 这里自控力测试在原文中的描述是「future discounting」,即对未来获得的奖励的折扣。 (例如如果现在获得 100 美元,future discounting 是 10%,那现在获得 100 美元给大脑的奖励等于一个月后获得 1000 美元) 在让实验对象做出选择后研究人员给部分研究对象喝含糖苏打水(可以提升血糖)作为实验组,给另一部分实验对象喝含代糖的苏打水(味道可能和含糖无差,但是没法提升血糖),作为对照组。 休息 10 分钟后重新给实验对象测量血糖,并进行另一系列自控力测试。 实验的结论表示血糖上升后的实验对象在新的自控力测试中表现得更加有自控力,或者说「future discounting」更低。 这个论文的题目是「 Sweet future: fluctuating blood glucose levels affect future discounting 」在 Pubmed 上的地址是 , DOI 是 10.1177/0956797609358096 一点小结:如果饿着肚子就没法专心思考问题以及获得长期自控力(同时更加容易受到短期诱惑(比如刷一会 傻逼 小红书)),但注意这里的结论并不是让我们通过不断提高对于糖的摄入来保持一个高血糖浓度水平(毕竟还有胰岛素在参与,且持续高糖摄入会导致肥胖等问题)。 多巴胺带来的承诺 许多人可能会对多巴胺的效果有一些误解(包括我也是),认为是多巴胺直接让我们产生了快乐的感觉。但是其实多巴胺释放会带给我们的其实不是快乐本身,而是一种激励,欲望,或者说推动我们去寻找快乐。 大眾普遍認為多巴胺是產生愉悅的物質,但目前藥理學研究認為多巴胺其實是記錄誘因顯著性的物質。[6][7][8]換句話說,多巴胺表示對某個結果的欲望或厭惡,然後推動人去使它實現,或是避免它實現。[8][9] 在《自控力》第五章的位置作者提到了 Brian Knutson 的一项 2001 年的研究,「让被试者看到屏幕上的符号就期待自己能赢钱,想要赢钱的话只要按一个按钮就可以获得奖励」表示「当奖励系统活跃的时候,被试者感受到的是期待而不是快乐」。 针对这个研究我搜索了 Pubmed 找到了标题是「 Anticipation of increasing monetary reward selectively recruits nucleus accumbens 」,Pubmed 地址是: 不过论文中似乎没有提到期待的部分,请读者阅读时注意。 由于明白了多巴胺带来的是承诺和推动我们获得快乐的动力,所以如许多人说的「做顺应人性的事情就能赚钱」,我们也就不难理解例如刷 傻逼 小红书/淘宝/PornHub/Bilibili 带来的一次次多巴胺释放指挥大脑尝试做的事情了,大脑会不断要求更多的刺激,直到我们觉得满意。 不过,除了看 PornHub 以外,我们真的会有「觉得满意」的时候么?还是说只是到了比较累的时候,或者不得不回到自己本职工作的时候才会勉强停止?而此时你已经累了,很难专注起来了 这一点也呼应了《娱乐致死》中对于「现在科技让我们的专注力越来越弱」的描述,只不过那本书的侧重点是在于我们的大脑逐渐适应了短平快的内容和高速的内容种类切换,让我们难以习惯需要长期保持注意力的工作。 情绪低落/面临压力带来欲望和下降的自控力 当你情绪低落/面临压力的时候是不是第一反应是——我吃点东西/喝点酒/看会电视/玩会游戏/刷一会 傻逼 小红书之后就会变得心情好一些了? 或者当你看 Bilibili 视频看到了凌晨 1 点之后想到明天早上 8 点要打卡上班感觉心怀愧疚和压力但是却又点开了下一个视频? 在书的第六章表示上面的第一反应主要来源于我们大脑对于「如何缓解压力」的一个主流的错误预测——即大脑不知道怎么才能让我们快乐/放松压力,导致我们被大脑引导组织做了真正让可以给我们带来快乐/缓解压力的事情。 真正能释放压力的不是释放多巴胺或者依赖「奖励的承诺」,而是增加大脑中改善情绪的化学物质,如血清素,γ-氨基丁酸(简称GABA)和催产素,当大脑不再对压力产生反应,减少身体里的压力荷尔蒙,产生有治愈效果的放松反应,因为他们不像释放多巴胺的物质那样让人兴奋,所以我们往往忽略了他们的作用。 这里作者引用了一个调查: 最有效的减压方式包括锻炼/参与体育运动,阅读,听音乐,与家人朋友相处,按摩,外出散步,培养有创意的爱好 最没效果的包括:赌博(开 50x 合约?),购物(手机淘宝!),抽烟,喝酒(Poor!Stress!Drink!),暴饮暴食,玩游戏(看我打开开心消消乐!),上网(小红书,启动!),花两个小时以上看电视或者电影(Netflix,启动!)。 高标准,不完美,和自控力惩罚 经常用 LLM 的同学肯定知道,可以通过设置各种参数来调节 LLM 输出的内容,其中一个参数就是频率惩罚(Frequency Penalty) The frequency penalty parameter tells the model not to repeat a word that has already been used multiple times in the conversation. 经常熬夜看视频到凌晨 1 点的同学可能也会有类似的经验——淦我居然看到了这个时候,明天还要上班,这样下去明天一天都会没有精神,我真失败,真是没有自控力,明天我一定要早睡然后开始早起。 书的第八章表示这样的罪恶感的产生以及自我惩罚其实反而会导致更差的心情,大脑会第一反应想要引导人做一些可以它认为可以快速改善心情的事情,也就回到了上一个章节中说的「情绪低落/面临压力带来欲望和下降的自控力」,反而进入了恶性循环。 书中提到的一个观点: 众多研究显示,自我自我批评会降低积极性和自控力,而且也是最容易导致抑郁的因素。它不仅耗尽了“我要做”的力量,还耗尽了“我想要”的力量。相反,自我同情自毁提升积极性和自控力。 从这里联想出来的另一个个人的经历就是对自己要求的高标准,以及对未能达到高标准的自我惩罚,每次想到高标准的时候脑海中都能想到 10 年前看过的一篇博文「 」。 由于某些设计上的疏忽导致了一些需要额外 workaround 的设计带来了自我惩罚,到未来尝试做某事(或者某个设计)而害怕自己设计不完美而迟迟不开始行动便是一个典型的例子。然后由于迟迟没有做出行动不断自我否定「搞(重构)这个东西干嘛?这么做有什么意义?」会进一步消磨自己的自信心,行动力,然后事情就一直无法推进了。🤷 书中对于这类问题的建议是「与自己和解」,正视自己的「罪恶感」并仔细观察这些「罪恶感」的来源并完善的记录下来,但并不要因此对自己有惩罚的想法,这样有助于在未来帮助自己理解这些不同感受出现的原因和时机,以及什么时候会自然消去。 如果只按照「正确」和「错误」来判断做过的事,而不是牢记我们真正想要的东西,就会带来与目标相抵触的冲动,并允许我们做出妨碍自己的行为。 一点小结 不长的全书在利用一周的睡前的空闲时间读完,整体读完的思路也从「我想看看如何进一步提升自控力来强化自己的效率表现」变为逐步理解自控力的来源,消耗方式,同时也认识到了要通过 自我惩罚无法获得真正的自控,只会让我们像被大人要求的小孩一样获得短暂的自我控制(Self-control)并消耗更多的行动信心,而真正实现自控的是「对于长期最终目标的追求」,这里的目标才是引导我们逐渐成为「成年人」和实现自控的最终解决方案 。 自控力最强的人不是与自我的较量中获得自控,而是学会了如何接受相互冲突的自我,并将这些自我融为一体 那么,你的长期目标是什么?是什么驱动着你的所有行为?

Anyone who has worked on RAG-related requirements would know that as your data volume grows, you not only need to address the issue of increasingly poor recall, but you’ll also notice that your vector database keeps expanding until it eventually hits your memory limit. At that point, a large-memory monolithic server is no longer sufficient to meet availability and performance needs (since a single machine will always have a memory ceiling, can’t perform smooth rolling upgrades of the cluster version, and high-memory machines are generally more expensive than multiple smaller ones). For example, there was a recent requirement to migrate a relatively large standalone Qdrant instance to a clustered deployment mode, which led to this brief note. Realized it’s been almost a year since I last wrote a blog post with some technical content… Original Standalone Qdrant Deployment The existing Qdrant deployment is very simple—a standalone docker-compose.yml file, as shown below: qdrant : image : qdrant/qdrant:v1.14.0 restart : always ports : - 6333 : 6333 - 6334 : 6334 volumes : - ./volumes/qdrant:/qdrant/storage This Qdrant instance runs on a Hetzner CCX53 machine with 32 cores and 128GB of memory. The qdrant directory is about 200GB in size, with over 21,000,000 vectors. { "result" : { "status" : "yellow" , "optimizer_status" : "ok" , "indexed_vectors_count" : 21297932 , "points_count" : 21337945 , "segments_count" : 10 , } } Cluster Environment Basic Configuration Here we need to explain the machines used for the cluster. Since Qdrant uses Raft as the consensus protocol, our deployment should involve at least 3 machines. We start with 3 machines in our initial setup, deployed in a test environment on Hetzner, with the following IPs: 10.0.0.6 10.0.0.7 10.0.0.9 Note: These are just for demonstration purposes. If you’re also using Hetzner, you can quickly deploy a Qdrant cluster using Cloud init with internal IPs, as described at the end of this article. Since we’re using Docker for deployment, we only need to create a docker-compose.yml file on 10.0.0.6 with the following content: services : qdrant_node1 : image : qdrant/qdrant:v1.14.0 restart : always volumes : - ./qdrant_storage:/qdrant/storage - ./qdrant_snapshots:/qdrant/snapshots ports : - "6333:6333" - "6334:6334" - "6335:6335" environment : QDRANT__CLUSTER__ENABLED : "true" command : "./qdrant --uri http://10.0.0.6:6335" On 10.0.0.7 , create a docker-compose.yml file with the following content: services : qdrant_node2 : image : qdrant/qdrant:v1.14.0 ports : - "6333:6333" - "6334:6334" - "6335:6335" volumes : - ./qdrant_storage:/qdrant/storage - ./qdrant_snapshots:/qdrant/snapshots environment : QDRANT__CLUSTER__ENABLED : "true" command : "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.7:6335" On 10.0.0.9 , create a docker-compose.yml file with the following content: services : qdrant_node3 : image : qdrant/qdrant:v1.14.0 ports : - "6333:6333" - "6334:6334" - "6335:6335" volumes : - ./qdrant_storage:/qdrant/storage - ./qdrant_snapshots:/qdrant/snapshots environment : QDRANT__CLUSTER__ENABLED : "true" command : "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.9:6335" Then run docker-compose up -d on each machine to start the services. Once all nodes are up, you can access the cluster status by visiting http://localhost:6333/cluster on any machine. For example: { "result" : { "status" : "enabled" , "peer_id" : 5395257186314509 , "peers" : { "3095816753490206" : { "uri" : "http://10.0.0.9:6335/" }, "5395257186314509" : { "uri" : "http://10.0.0.6:6335/" }, "4182395837949771" : { "uri" : "http://10.0.0.7:6335/" } }, "raft_info" : { "term" : 1 , "commit" : 41 , "pending_operations" : 0 , "leader" : 5395257186314509 , "role" : "Leader" , "is_voter" : true }, "consensus_thread_status" : { "consensus_thread_status" : "working" , "last_update" : "2025-05-17T02:31:10.703071457Z" }, "message_send_failures" : {} }, "status" : "ok" , "time" : 0.000011782 } Creating a Collection After setting up a distributed Qdrant cluster, as described in the official documentation at , we can see: When you enable distributed mode and scale up to two or more nodes, your data does not move to the new node automatically; it starts out empty. To make use of your new empty node, do one of the following: Create a new replicated collection by setting the to 2 or more and setting the to a multiple of your number of nodes. If you have an existing collection which does not contain enough shards for each node, you must create a new collection as described in the previous bullet point. If you already have enough shards for each node and you merely need to replicate your data, follow the directions for . If you already have enough shards for each node and your data is already replicated, you can move data (without replicating it) onto the new node(s) by . Since the current Qdrant instance we have is a single node, the collection on it has only a single shard. The collection configuration is as follows: { "params" : { "vectors" : { "size" : 1536 , "distance" : "Cosine" , "on_disk" : true }, "shard_number" : 1 , "replication_factor" : 1 , "write_consistency_factor" : 1 , "on_disk_payload" : true }, } ... So the first step is to create a new collection on the new cluster with the same parameters as the current collection, except that shard_number and replication_factor need to be modified. Just like ClickHouse, creating a cluster doesn’t automatically mean your data is distributed and replicated—you have to manually specify your data. For the settings of the two parameters above, the official documentation recommends: If you anticipate a lot of growth, we recommend 12 shards since you can expand from 1 node up to 2, 3, 6, and 12 nodes without having to re-shard. Having more than 12 shards in a small cluster may not be worth the performance overhead. “Anticipate a lot of growth” sounds very appropriate for our scenario, so here I create a new collection with shard_number set to 12 and replication_factor set to 2. The relevant script is as follows: from qdrant_client import QdrantClient import qdrant_client.http.models as models collection_name = "new_collection" client = QdrantClient ( host = "10.0.0.6" , port = 6333 ) vectors_config = models . VectorParams ( size = 1536 , distance = models . Distance . COSINE , on_disk = True ) hnsw_config = HnswConfigDiff ( m = 0 , payload_m = 16 , ef_construct = 100 , full_scan_threshold = 10000 , max_indexing_threads = 100 , on_disk = True , ) client . create_collection ( collection_name = collection_name , vectors_config = vectors_config , shard_number = 12 , replication_factor = 2 , hnsw_config = hnsw_config ) Migrating Data Once the new cluster is created, we need to import the data from the existing cluster. Initially, I tried to create a snapshot on the original cluster and import it into the new cluster, but encountered the following error: {"status":{"error":"Wrong input: Snapshot is not compatible with existing collection: Collection shard number: 3 Snapshot shard number: 1"},"time":1107.142566774} Here we need to use a Beta version tool provided by Qdrant to perform the migration: Usage is as follows: docker run --net = host --rm -it registry.cloud.qdrant.io/library/qdrant-migration qdrant \ --source-url 'http://localhost:6334' \ --source-collection 'new_collection' \ --target-url 'http://10.0.0.6:6334' \ --target-collection 'new_collection' Note: registry.cloud.qdrant.io/library/qdrant-migration is an older version. It’s recommended to build the image manually using the latest code. It’s also recommended to manually patch the grpc.MaxCallRecvMsgSize parameter and increase the batch-size (default is 50, can be increased to 20000) to achieve faster import speed. Related issue: To ensure maximum import speed, you can refer to this article: , and disable hnsw_config on the new cluster, while setting a reasonable indexing_threshold , for example: PATCH /collections/your_collection { "hnsw_config": { "m": 0 }, "optimizer_config": { "indexing_threshold": 10000 } } The former ensures that HNSW indexes are not built during import, and the latter ensures that vectors are flushed to disk when reaching 10,000 to prevent memory overflow due to vector accumulation. After the import is completed, HNSW can be re-enabled to build the index. Cluster Information After Migration To conveniently observe the Shard information on each node, we can use the /collections/<collection_name>/cluster API, for example, it returns the following response: { "result" :{ "peer_id" : 5395257186314509 , "shard_count" : 12 , "local_shards" :[{ "shard_id" : 1 , "points_count" : 1794606 , "state" : "Active" },{ "shard_id" : 2 , "points_count" : 1450924 , "state" : "Active" },{ "shard_id" : 4 , "points_count" : 1902963 , "state" : "Active" },{ "shard_id" : 5 , "points_count" : 1774613 , "state" : "Active" },{ "shard_id" : 7 , "points_count" : 1753521 , "state" : "Active" },{ "shard_id" : 8 , "points_count" : 1687892 , "state" : "Active" },{ "shard_id" : 10 , "points_count" : 1477543 , "state" : "Active" },{ "shard_id" : 11 , "points_count" : 2051536 , "state" : "Active" }], "remote_shards" :[{ "shard_id" : 0 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 0 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 1 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 2 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 3 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 3 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 4 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 5 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 6 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 6 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 7 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 8 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 9 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 9 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 10 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 11 , "peer_id" : 3095816753490206 , "state" : "Active" }], "shard_transfers" :[]}, "status" : "ok" , "time" : 0.00011113 } But… this is really not very intuitive. So we need to write a small tool to easily check the distribution of Shards across each node (Peer). Let’s go with Python! import requests base_url = "http://10.0.0.6:6333" cluster_endpoint = "/cluster" collections_endpoint = "/collections/<collection_name>/cluster" def get_data_from_api ( endpoint ): response = requests . get ( base_url + endpoint ) return response . json () def parse_cluster_peers ( cluster_data ): peers = cluster_data . get ( "result" , {}) . get ( "peers" , {}) ip_peer_map = {} for peer_id , peer_info in peers . items (): uri = peer_info . get ( "uri" , "" ) ip_address = uri . split ( "//" )[ - 1 ] . split ( ":" )[ 0 ] ip_peer_map [ ip_address ] = int ( peer_id ) return ip_peer_map def parse_shards ( collections_data ): local_shards = collections_data . get ( "result" , {}) . get ( "local_shards" , []) remote_shards = collections_data . get ( "result" , {}) . get ( "remote_shards" , []) peer_shard_map = {} for shard in local_shards : peer_id = collections_data . get ( "result" , {}) . get ( "peer_id" ) shard_id = shard . get ( "shard_id" ) peer_shard_map . setdefault ( peer_id , []) . append ( shard_id ) for shard in remote_shards : peer_id = shard . get ( "peer_id" ) shard_id = shard . get ( "shard_id" ) peer_shard_map . setdefault ( peer_id , []) . append ( shard_id ) return peer_shard_map def main (): cluster_data = get_data_from_api ( cluster_endpoint ) collections_data = get_data_from_api ( collections_endpoint ) ip_peer_map = parse_cluster_peers ( cluster_data ) peer_shard_map = parse_shards ( collections_data ) ip_shard_map = {} for ip , peer_id in ip_peer_map . items (): if peer_id in peer_shard_map : ip_shard_map [ ip ] = peer_shard_map [ peer_id ] else : ip_shard_map [ ip ] = [] for ip , shard_ids in ip_shard_map . items (): peer_id = ip_peer_map [ ip ] print ( f "IP: { ip } , Peer ID: { peer_id } , Shard IDs: { shard_ids } " ) if __name__ == "__main__" : main () Once we run the script, we can easily see the Shard distribution on each node: IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10] IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11] IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11] We can see that all the Shards are evenly distributed across the three machines. At this point, if any single machine goes offline or is damaged, it won’t lead to complete loss of any Shard replica, so no data loss will occur. Scaling Out The “ROSE” strategy is designed to ensure that scaling is both effective and safe . It consists of four stages: Resuscitation, Optimization, Stabilization, and Evacuation. Suppose our business keeps growing and three nodes are no longer sufficient. We’ll need to scale out. Since we initially set shard_number to 12, we can scale in multiples of 3. Now we have 3 nodes, so we add 3 more. The IPs of the new nodes are: 10.0.0.10 10.0.0.11 10.0.0.12 Creating them is the same as before: just set each node’s --url and --bootstrap http://10.0.0.6:6335 , and after they join, the /cluster endpoint responds as follows: { "result" : { "status" : "enabled" , "peer_id" : 5395257186314509 , "peers" : { "3095816753490206" : { "uri" : "http://10.0.0.9:6335/" }, "4182395837949771" : { "uri" : "http://10.0.0.7:6335/" }, "3841618339255269" : { "uri" : "http://10.0.0.10:6335/" }, "3658649898688837" : { "uri" : "http://10.0.0.12:6335/" }, "5395257186314509" : { "uri" : "http://10.0.0.6:6335/" }, "8689864553665627" : { "uri" : "http://10.0.0.11:6335/" } }, "raft_info" : { "term" : 1 , "commit" : 50 , "pending_operations" : 0 , "leader" : 5395257186314509 , "role" : "Leader" , "is_voter" : true }, "consensus_thread_status" : { "consensus_thread_status" : "working" , "last_update" : "2025-05-17T02:49:29.351230053Z" }, "message_send_failures" : {} }, "status" : "ok" , "time" : 0.000011121 } Now, if you’re a seasoned GlusterFS user, your first instinct might be to run the following command to rebalance the data: gluster volume rebalance VOLNAME start Unfortunately, the open-source version of Qdrant doesn’t support this (though it’s available in their Cloud service): It’s worth mentioning that Qdrant only provides the necessary building blocks to create an automated failure recovery. Building a completely automatic process of collection scaling would require control over the cluster machines themself. Check out our cloud solution, where we made exactly that. Shards are evenly distributed across all existing nodes when a collection is first created, but Qdrant does not automatically rebalance shards if your cluster size or replication factor changes (since this is an expensive operation on large clusters). See the next section for how to move shards after scaling operations. — From: At this point, running the script again reveals: IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11] IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11] IP: 10.0.0.12, Peer ID: 3658649898688837, Shard IDs: [] IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10] IP: 10.0.0.11, Peer ID: 8689864553665627, Shard IDs: [] IP: 10.0.0.10, Peer ID: 3841618339255269, Shard IDs: [] The newly added nodes are just idle — all Shards still reside on the old Peers. So, what should we do next? Rebalancing Like Catching Rain? Since the official documentation has provided an API to move shards: curl -X POST http://localhost:6333/collections/collection_name/cluster \ -H "api-key: <apiKey>" \ -H "Content-Type: application/json" \ -d '{ "move_shard": { "shard_id": 1, "to_peer_id": 1000000, "from_peer_id": 1000000 } }' A natural solution comes to mind — manually rebalance the shards. First, we calculate how many shards each node (peer) should have. In this scenario: (Total Shards * Number of Replicas) / Number of Machines That is, (12*2)/6 = 4 Then we can determine which peers are overfilled and which are underfilled. We can compute a “migration path” — redistribute from the rich to the poor: ⚠️ Important: Do not assign two replicas of the same shard to the same peer! If that peer fails, you lose the data for that shard. underfilled_peers = [] for peer_id , shard_ids in peer_shard_map . items (): if len ( shard_ids ) < average_shards_per_peer : underfilled_peers . append ( peer_id ) overfilled_peers = [] for peer_id , shard_ids in peer_shard_map . items (): if len ( shard_ids ) > average_shards_per_peer : overfilled_peers . append ( peer_id ) print ( "underfilled_peers" ) print ( underfilled_peers ) print ( "overfilled_peers" ) print ( overfilled_peers ) rebalance_operations = [] for overfilled_peer in overfilled_peers : for underfilled_peer in underfilled_peers : for overfilled_peer_shard in peer_shard_map [ overfilled_peer ]: if ( len ( peer_shard_map [ underfilled_peer ]) < average_shards_per_peer and overfilled_peer_shard not in peer_shard_map [ underfilled_peer ] and len ( peer_shard_map [ overfilled_peer ]) > average_shards_per_peer ): print ( f "Moving shard_id { overfilled_peer_shard } from peer_id { overfilled_peer } to peer_id { underfilled_peer } " ) rebalance_operations . append (( overfilled_peer , underfilled_peer , overfilled_peer_shard )) peer_shard_map [ underfilled_peer ] . append ( overfilled_peer_shard ) peer_shard_map [ overfilled_peer ] . remove ( overfilled_peer_shard ) else : continue Of course, the logic above is rough and simple — I’m sure you , the reader, can come up with something much better. Assuming no bugs, we’ll get a list of migration operations like this: Shard ID lists for each peer: {5395257186314509: [1, 2, 4, 5, 7, 8, 10, 11], 4182395837949771: [0, 1, 3, 4, 6, 7, 9, 10], 3095816753490206: [0, 2, 3, 5, 6, 8, 9, 11]} Underfilled peers: [3658649898688837, 3841618339255269, 8689864553665627] Overfilled peers: [5395257186314509, 4182395837949771, 3095816753490206] Move shard_id 1 from peer_id 5395257186314509 to peer_id 3658649898688837 Move shard_id 4 from peer_id 5395257186314509 to peer_id 3658649898688837 ... Final Shard Mapping After Rebalance: Peer ID: 5395257186314509, Shard IDs: [2, 5, 8, 11] Peer ID: 4182395837949771, Shard IDs: [1, 4, 7, 10] Peer ID: 3095816753490206, Shard IDs: [2, 5, 8, 11] Peer ID: 3658649898688837, Shard IDs: [1, 4, 7, 10] Peer ID: 3841618339255269, Shard IDs: [0, 3, 6, 9] Peer ID: 8689864553665627, Shard IDs: [0, 3, 6, 9] Now we just need to wrap the shard-moving logic: def rebalance_shards ( from_peer , to_peer , shard_id ): url = f " { base_url } /collections/new_collection/cluster" payload = { "move_shard" : { "shard_id" : shard_id , "from_peer_id" : from_peer , "to_peer_id" : to_peer } } r = requests . post ( url , json = payload ) And execute: for from_peer , to_peer , shard_id in rebalance_operations : rebalance_shards ( from_peer , to_peer , shard_id ) ⚠️ Always remember: this is an expensive operation on large clusters After some time, you should end up with a fully rebalanced 6-node cluster. You can then configure your Load Balancer to route to these nodes. Your application can connect to any of them and resume operations! For downscaling, simply move all shards off the peer to be removed (similar to kubelet drain ), then use the API to remove that peer. Disaster Recovery With a cluster of 3+ nodes, according to Raft, as long as more than 50% of nodes are online, and no single shard is fully hosted on offline nodes, all operations remain unaffected. Oddly, Qdrant docs do not mention split-brain scenarios. So: If some machines go offline but no shard loses all its replicas: Operations are unaffected If you can restore the machine, great If not, delete the peer via API, spin up a new one, and rebalance If all replicas of a shard are on offline machines: Fix the machines ASAP 🤣 Or start planning your escape Good news: With cloud providers, total machine failure is rare unless something catastrophic (e.g. datacenter fire) happens. Most downtime is due to networking or OOM. As long as you have backups, recovery is generally possible. There’s another gotcha: if a node is restored with a different IP, Qdrant might or might not notify the cluster of the update: 2025-05-10T07:52:31.601762Z WARN storage::content_manager::consensus::persistent: Replaced address of peer 3994356516252114 from http://10.0.0.5:6335/ to http://10.0.0.9:6335/ If it doesn’t notify, you’ll need to manually edit the /qdrant_storage/raft_state.json file on every node and restart them all. Hopefully it won’t come to that 😇 If you’re using Hetzner Cloud, here are some extra tips: Use Placement Groups to ensure VMs are not on the same physical host: “In spread Placement Groups, all virtual servers are running on different physical servers.” Enable Backups Backups are stored in a different availability zone (except for Hillsboro, Ashburn, and Singapore which have only one) Docs: Automated Deployment Remember we said you could deploy quickly on Hetzner? Hetzner provides internal metadata APIs: For example, on a VM: curl http://169.254.169.254/hetzner/v1/metadata/private-networks You’ll get something like: - ip: 10.0.0.3 alias_ips: [] interface_num: 1 mac_address: 86:00:00:c3:bf:16 network_id: 3493377 network_name: us-west-network network: 10.0.0.0/16 subnet: 10.0.0.0/24 gateway: 10.0.0.1 Use that to generate a cloud-init that installs Docker + deploys a Qdrant node: #cloud-config write_files : - path : /root/create_docker_compose.sh permissions : "0755" owner : root:root content : | #!/bin/bash # Fetch the private network metadata METADATA=$(curl -s http://169.254.169.254/hetzner/v1/metadata/private-networks) # Extract the IP address from the metadata PRIVATE_IP=$(echo "$METADATA" | awk -F': ' '/ip:/ {print $2}' | tr -d ' ') # Generate the docker-compose.yml file cat <<EOF > /root/docker-compose.yml services: qdrant: image: qdrant/qdrant:v1.14.0 restart: always volumes: - ./qdrant_storage:/qdrant/storage ports: - "6333:6333" - "6334:6334" - "6335:6335" environment: QDRANT__CLUSTER__ENABLED: "true" command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://$PRIVATE_IP:6335" EOF - path : /root/install_docker.sh permissions : "0755" owner : root:root content : | #!/bin/bash # Install Docker curl -fsSL https://get.docker.com -o install-docker.sh bash install-docker.sh # Install Docker Compose wget https://github.com/docker/compose/releases/download/v2.36.0/docker-compose-linux-x86_64 -O /usr/bin/docker-compose chmod +x /usr/bin/docker-compose runcmd : # Update package lists - apt-get update - apt-get install -y curl wget # Execute the install docker script - /root/install_docker.sh # Execute the script to create the docker-compose.yml file - /root/create_docker_compose.sh # Start Docker Compose - cd /root && docker-compose up -d Update the bootstrap IP for the first node, then paste this config when creating new Hetzner VMs: Very convenient! Hetzner’s low pricing also makes this approach very cost-effective. VM Price Comparison Lastly, let’s do some shameless pricing comparison: Shared CPU VMs Dedicated CPU VMs If you find Hetzner’s pricing attractive, feel free to use my referral link: 👉 👈 You’ll get €20 credit on signup, and I get €10 too 😘 That’s all — see you in the next post!

用 Docker 快速在 Hetzner 上搭建 Qdrant 集群并迁移数据小笔记

2025/05/17 02:00 · Posts on Nova Kwok's Awesome Blog

做过 RAG 相关需求的同学肯定知道,当你数据量越来越大的时候,除了需要解决召回率变得稀烂的问题,还会发现你的向量数据库越来越大逐渐逼近你的内存极限,此时一个大内存的单体服务器就已经没法满足可用性和速度的需求了(因为单体机器内存总会有个上限,而且也不能平稳的滚动升级集群版本,此外普遍单大内存机器价格是高于多个小内存机器的),比如最近有一个需求需要将一个相对比较大的 Qdrant 单体迁移到 Qdrant 集群的部署模式中,所以有了这一篇小小的笔记。 发现我已经快一年没有写过带点技术的博文了… 原始单节点 Qdrant 部署 目前已有的 Qdrant 部署方式非常简单,就是一个单独的 docker-compose.yml 文件,内容如下: qdrant : image : qdrant/qdrant:v1.14.0 restart : always ports : - 6333 : 6333 - 6334 : 6334 volumes : - ./volumes/qdrant:/qdrant/storage 这个 Qdrant 运行在一个 32 核心,128G 内存的 Hetzner CCX53 机器上, qdrant 目录约 200G,总共有 21,000,000+ 个向量。 { "result" : { "status" : "yellow" , "optimizer_status" : "ok" , "indexed_vectors_count" : 21297932 , "points_count" : 21337945 , "segments_count" : 10 , } } 集群环境 基本配置 这里我们要交代一下集群的环境的机器,由于 Qdrant 使用 Raft 作为共识协议,所以我们的部署应该 >=3 台机器,这里我们初始方案从 3 台机器开始,实验环境在 Hetzner 上,新建的三台机器 IP 如下: 10.0.0.6 10.0.0.7 10.0.0.9 注:这里只是为了演示方便使用,如果你也用 Hetzner 可以使用 Cloud init 配合内部 IP 快速部署 Qdrant 集群,文末有详细介绍。 由于使用 Docker 部署,所以我们只需要在 10.0.0.6 上创建 docker-compose.yml 文件,内容如下: services : qdrant_node1 : image : qdrant/qdrant:v1.14.0 restart : always volumes : - ./qdrant_storage:/qdrant/storage - ./qdrant_snapshots:/qdrant/snapshots ports : - "6333:6333" - "6334:6334" - "6335:6335" environment : QDRANT__CLUSTER__ENABLED : "true" command : "./qdrant --uri http://10.0.0.6:6335" 在 10.0.0.7 上创建 docker-compose.yml 文件,内容如下: services : qdrant_node2 : image : qdrant/qdrant:v1.14.0 ports : - "6333:6333" - "6334:6334" - "6335:6335" volumes : - ./qdrant_storage:/qdrant/storage - ./qdrant_snapshots:/qdrant/snapshots environment : QDRANT__CLUSTER__ENABLED : "true" command : "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.7:6335" 在 10.0.0.9 上创建 docker-compose.yml 文件,内容如下: services : qdrant_node3 : image : qdrant/qdrant:v1.14.0 ports : - "6333:6333" - "6334:6334" - "6335:6335" volumes : - ./qdrant_storage:/qdrant/storage - ./qdrant_snapshots:/qdrant/snapshots environment : QDRANT__CLUSTER__ENABLED : "true" command : "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://10.0.0.9:6335" 然后在各自的机器上 docker-compose up -d 启动即可。 所有的节点启动后我们访问任意机器的 http://localhost:6333/cluster 就可以看到集群状态了,例如: { "result" : { "status" : "enabled" , "peer_id" : 5395257186314509 , "peers" : { "3095816753490206" : { "uri" : "http://10.0.0.9:6335/" }, "5395257186314509" : { "uri" : "http://10.0.0.6:6335/" }, "4182395837949771" : { "uri" : "http://10.0.0.7:6335/" } }, "raft_info" : { "term" : 1 , "commit" : 41 , "pending_operations" : 0 , "leader" : 5395257186314509 , "role" : "Leader" , "is_voter" : true }, "consensus_thread_status" : { "consensus_thread_status" : "working" , "last_update" : "2025-05-17T02:31:10.703071457Z" }, "message_send_failures" : {} }, "status" : "ok" , "time" : 0.000011782 } 创建 collection 在有了分布式的 Qdrant 集群之后,在官方文档 中我们可以知道: When you enable distributed mode and scale up to two or more nodes, your data does not move to the new node automatically; it starts out empty. To make use of your new empty node, do one of the following: Create a new replicated collection by setting the to 2 or more and setting the to a multiple of your number of nodes. If you have an existing collection which does not contain enough shards for each node, you must create a new collection as described in the previous bullet point. If you already have enough shards for each node and you merely need to replicate your data, follow the directions for . If you already have enough shards for each node and your data is already replicated, you can move data (without replicating it) onto the new node(s) by . 由于目前我们已有的 Qdrant 是一个单节点,上面已有的 Collection 也是只有单一的 Shard, collection 的配置如下: { "params" : { "vectors" : { "size" : 1536 , "distance" : "Cosine" , "on_disk" : true }, "shard_number" : 1 , "replication_factor" : 1 , "write_consistency_factor" : 1 , "on_disk_payload" : true }, } ... 所以这里的第一步就是需要在新的集群上面创建一个和目前的 collection 一样参数的 collection,除了 shard_number 和 replication_factor 需要修改。 和 ClickHouse 一样,创建了集群并不代表上面的数据是分布式+Replicated,你得手动指定你的数据 对于上面两个参数的设定,官方文档是这么建议的: If you anticipate a lot of growth, we recommend 12 shards since you can expand from 1 node up to 2, 3, 6, and 12 nodes without having to re-shard. Having more than 12 shards in a small cluster may not be worth the performance overhead. anticipate a lot of growth 听上去很符合我们的场景,这里我就使用 shard_number 为 12, replication_factor 为 2 的方式创建新的 collection,相关脚本如下: from qdrant_client import QdrantClient import qdrant_client.http.models as models collection_name = "new_collection" client = QdrantClient ( host = "10.0.0.6" , port = 6333 ) vectors_config = models . VectorParams ( size = 1536 , distance = models . Distance . COSINE , on_disk = True ) hnsw_config = HnswConfigDiff ( m = 0 , payload_m = 16 , ef_construct = 100 , full_scan_threshold = 10000 , max_indexing_threads = 100 , on_disk = True , ) client . create_collection ( collection_name = collection_name , vectors_config = vectors_config , shard_number = 12 , replication_factor = 2 , hnsw_config = hnsw_config ) 迁移数据 在创建好了集群之后我们就需要将目前已有的集群数据导入到新的集群上,这里我一开始尝试在原有集群上创建 Snapshot 并导入新集群,但是这样会遇到报错: {"status":{"error":"Wrong input: Snapshot is not compatible with existing collection: Collection shard number: 3 Snapshot shard number: 1"},"time":1107.142566774} 这里需要使用 Qdrant 的一个 Beta 版本的工具来进行迁移: 使用方式如下: docker run --net = host --rm -it registry.cloud.qdrant.io/library/qdrant-migration qdrant \ --source-url 'http://localhost:6334' \ --source-collection 'new_collection' \ --target-url 'http://10.0.0.6:6334' \ --target-collection 'new_collection' 注: registry.cloud.qdrant.io/library/qdrant-migration 版本较老,建议手动用最新代码构建镜像运行 代码建议手动 Patch 一下 grpc.MaxCallRecvMsgSize 参数并调高 batch-size (默认是 50 ,可以调整到 20000)获得更高的导入速度,相关 Issue: 导入数据的时候为了保证最大的导入速度,可以参考 文章,将新集群的 hnsw_config 关闭,并设定一个合理的 indexing_threshold 例如: PATCH /collections/your_collection { "hnsw_config": { "m": 0 }, "optimizer_config": { "indexing_threshold": 10000 } } 前者可以保证导入过程中不会建立 HNSW 索引,后者保证导入的 Vector 能在到达 10000 的时候进行落盘,防止 Vector 全部堆积在内存中导致 OOM。 导入完成后可以重新打开 HNSW 建立索引。 迁移后的集群信息 为了方便观测每个节点的 Shard 信息,我们可以使用 /collections/<collection_name>/cluster API 来观测,例如此时响应如下: { "result" :{ "peer_id" : 5395257186314509 , "shard_count" : 12 , "local_shards" :[{ "shard_id" : 1 , "points_count" : 1794606 , "state" : "Active" },{ "shard_id" : 2 , "points_count" : 1450924 , "state" : "Active" },{ "shard_id" : 4 , "points_count" : 1902963 , "state" : "Active" },{ "shard_id" : 5 , "points_count" : 1774613 , "state" : "Active" },{ "shard_id" : 7 , "points_count" : 1753521 , "state" : "Active" },{ "shard_id" : 8 , "points_count" : 1687892 , "state" : "Active" },{ "shard_id" : 10 , "points_count" : 1477543 , "state" : "Active" },{ "shard_id" : 11 , "points_count" : 2051536 , "state" : "Active" }], "remote_shards" :[{ "shard_id" : 0 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 0 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 1 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 2 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 3 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 3 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 4 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 5 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 6 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 6 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 7 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 8 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 9 , "peer_id" : 3095816753490206 , "state" : "Active" },{ "shard_id" : 9 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 10 , "peer_id" : 4182395837949771 , "state" : "Active" },{ "shard_id" : 11 , "peer_id" : 3095816753490206 , "state" : "Active" }], "shard_transfers" :[]}, "status" : "ok" , "time" : 0.00011113 } 只是…这样看也太不直观了,所以我们需要自己手写一个小工具来方便检查每个节点(Peer)上的 Shard 分布情况。 这就 Python 冲一个! import requests base_url = "http://10.0.0.6:6333" cluster_endpoint = "/cluster" collections_endpoint = "/collections/<collection_name>/cluster" def get_data_from_api ( endpoint ): response = requests . get ( base_url + endpoint ) return response . json () def parse_cluster_peers ( cluster_data ): peers = cluster_data . get ( "result" , {}) . get ( "peers" , {}) ip_peer_map = {} for peer_id , peer_info in peers . items (): uri = peer_info . get ( "uri" , "" ) ip_address = uri . split ( "//" )[ - 1 ] . split ( ":" )[ 0 ] ip_peer_map [ ip_address ] = int ( peer_id ) return ip_peer_map def parse_shards ( collections_data ): local_shards = collections_data . get ( "result" , {}) . get ( "local_shards" , []) remote_shards = collections_data . get ( "result" , {}) . get ( "remote_shards" , []) peer_shard_map = {} for shard in local_shards : peer_id = collections_data . get ( "result" , {}) . get ( "peer_id" ) shard_id = shard . get ( "shard_id" ) peer_shard_map . setdefault ( peer_id , []) . append ( shard_id ) for shard in remote_shards : peer_id = shard . get ( "peer_id" ) shard_id = shard . get ( "shard_id" ) peer_shard_map . setdefault ( peer_id , []) . append ( shard_id ) return peer_shard_map def main (): cluster_data = get_data_from_api ( cluster_endpoint ) collections_data = get_data_from_api ( collections_endpoint ) ip_peer_map = parse_cluster_peers ( cluster_data ) peer_shard_map = parse_shards ( collections_data ) ip_shard_map = {} for ip , peer_id in ip_peer_map . items (): if peer_id in peer_shard_map : ip_shard_map [ ip ] = peer_shard_map [ peer_id ] else : ip_shard_map [ ip ] = [] for ip , shard_ids in ip_shard_map . items (): peer_id = ip_peer_map [ ip ] print ( f "IP: { ip } , Peer ID: { peer_id } , Shard IDs: { shard_ids } " ) if __name__ == "__main__" : main () 我们执行脚本,就可以方便看到每个节点的 Shard 分布了: IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10] IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11] IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11] 可以看到所有的 Shard 均匀地分布在了 3 台机器上,此时任何 一台 机器掉线/损坏都不会导致任何 Shard 的副本全丢而导致数据丢失。 扩容操作 Rose策略主要是为了保证 扩容要有效且安全 ,该策略的四个阶段分别为复苏(Resuscitation)、优化(Optimization)、稳定(Stabilization)、去复苏(Evacuation)。 假设我们的业务越做越大了,3 个节点就开始逐渐无法满足我们的业务需求了,所以我们需要对节点进行扩容,由于上面我们使用了 shard_number 为 12,所以我们可以以 3 的整数倍进行扩容,现在是 3 节点,那我们继续扩容 3 个节点出来,节点 IP 分别为 10.0.0.10 10.0.0.11 10.0.0.12 创建方式和文初一样,只要每个节点设定好 --url 和 --bootstrap http://10.0.0.6:6335 就可以,节点加入完成后 /cluster 接口响应如下: { "result" : { "status" : "enabled" , "peer_id" : 5395257186314509 , "peers" : { "3095816753490206" : { "uri" : "http://10.0.0.9:6335/" }, "4182395837949771" : { "uri" : "http://10.0.0.7:6335/" }, "3841618339255269" : { "uri" : "http://10.0.0.10:6335/" }, "3658649898688837" : { "uri" : "http://10.0.0.12:6335/" }, "5395257186314509" : { "uri" : "http://10.0.0.6:6335/" }, "8689864553665627" : { "uri" : "http://10.0.0.11:6335/" } }, "raft_info" : { "term" : 1 , "commit" : 50 , "pending_operations" : 0 , "leader" : 5395257186314509 , "role" : "Leader" , "is_voter" : true }, "consensus_thread_status" : { "consensus_thread_status" : "working" , "last_update" : "2025-05-17T02:49:29.351230053Z" }, "message_send_failures" : {} }, "status" : "ok" , "time" : 0.000011121 } 这个时候如果你是一个熟练使用 GlusterFS 的用户的话,你的第一反应肯定是通过以下指令进行 rebalance 来平衡一下各个节点的数据: gluster volume rebalance VOLNAME start 但是很不幸,Qdrant 开源版本没有这样的功能(但是他们的 Cloud 上有): It’s worth mentioning that Qdrant only provides the necessary building blocks to create an automated failure recovery. Building a completely automatic process of collection scaling would require control over the cluster machines themself. Check out our cloud solution, where we made exactly that. Shards are evenly distributed across all existing nodes when a collection is first created, but Qdrant does not automatically rebalance shards if your cluster size or replication factor changes (since this is an expensive operation on large clusters). See the next section for how to move shards after scaling operations. ——来自:https://qdrant.tech/documentation/guides/distributed_deployment/#choosing-the-right-number-of-shard 此时我们继续运行上面的脚本就可以发现: IP: 10.0.0.6, Peer ID: 5395257186314509, Shard IDs: [1, 2, 4, 5, 7, 8, 10, 11] IP: 10.0.0.9, Peer ID: 3095816753490206, Shard IDs: [0, 2, 3, 5, 6, 8, 9, 11] IP: 10.0.0.12, Peer ID: 3658649898688837, Shard IDs: [] IP: 10.0.0.7, Peer ID: 4182395837949771, Shard IDs: [0, 1, 3, 4, 6, 7, 9, 10] IP: 10.0.0.11, Peer ID: 8689864553665627, Shard IDs: [] IP: 10.0.0.10, Peer ID: 3841618339255269, Shard IDs: [] 新加入的节点都在打酱油啊, Shard 全部都在老的 Peer 上,这该怎么办? rebalance 接雨水? 既然官方表示已经提供了对应的接口用来移动 Shard: curl -X POST http://localhost:6333/collections/collection_name/cluster \ -H "api-key: <apiKey>" \ -H "Content-Type: application/json" \ -d '{ "move_shard": { "shard_id": 1, "to_peer_id": 1000000, "from_peer_id": 1000000 } }' 我们可以很自然的想到一个解决方式,我们来手动 rebalance 各个 Shard,首先判断一下每个节点(Peer)应该有多少个 Shard,在这里的场景下是: (Shard 数量 * Replica 数量) / 机器数量 也就是 (12*2)/6 = 4 然后我们可以计算出哪些节点上的 Shard 多于这个数量,哪些节点少于这个数量,计算一个移动的路径(所谓劫富济贫): ⚠️ 需要注意不要把两个同样的 Shard 调度到一个 Peer 上了,这样这个 Peer 没了你的这个 Shard 的数据就玩完了。 underfilled_peers = [] for peer_id , shard_ids in peer_shard_map . items (): if len ( shard_ids ) < average_shards_per_peer : underfilled_peers . append ( peer_id ) overfilled_peers = [] for peer_id , shard_ids in peer_shard_map . items (): if len ( shard_ids ) > average_shards_per_peer : overfilled_peers . append ( peer_id ) print ( "underfilled_peers" ) print ( underfilled_peers ) print ( "overfilled_peers" ) print ( overfilled_peers ) rebalance_operations = [] for overfilled_peer in overfilled_peers : # Check if overfilled_peer_shard is not already in underfilled_peer, do not move two shards to the same peer for underfilled_peer in underfilled_peers : for overfilled_peer_shard in peer_shard_map [ overfilled_peer ]: if len ( peer_shard_map [ underfilled_peer ]) < average_shards_per_peer and overfilled_peer_shard not in peer_shard_map [ underfilled_peer ] and len ( peer_shard_map [ overfilled_peer ]) > average_shards_per_peer : print ( f "将 shard_id { overfilled_peer_shard } 从 peer_id { overfilled_peer } 移动到 peer_id { underfilled_peer } " ) rebalance_operations . append (( overfilled_peer , underfilled_peer , overfilled_peer_shard )) peer_shard_map [ underfilled_peer ] . append ( overfilled_peer_shard ) peer_shard_map [ overfilled_peer ] . remove ( overfilled_peer_shard ) else : # Already in target peer, skip continue 当然,上面的逻辑写的比较简单+粗暴,我相信作为读者的你肯定可以写的更好 这样,如果没有出 Bug 的话,我们就可以获得一个移动路径列表了: peer_id 对应的 shard_id 列表: {5395257186314509: [1, 2, 4, 5, 7, 8, 10, 11], 4182395837949771: [0, 1, 3, 4, 6, 7, 9, 10], 3095816753490206: [0, 2, 3, 5, 6, 8, 9, 11]} 不足分片的 peer_id 列表: [3658649898688837, 3841618339255269, 8689864553665627] 过多分片的 peer_id 列表: [5395257186314509, 4182395837949771, 3095816753490206] 将 shard_id 1 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837 将 shard_id 4 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837 将 shard_id 7 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837 将 shard_id 10 从 peer_id 5395257186314509 移动到 peer_id 3658649898688837 将 shard_id 0 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269 将 shard_id 3 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269 将 shard_id 6 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269 将 shard_id 9 从 peer_id 4182395837949771 移动到 peer_id 3841618339255269 将 shard_id 0 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627 将 shard_id 3 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627 将 shard_id 6 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627 将 shard_id 9 从 peer_id 3095816753490206 移动到 peer_id 8689864553665627 Rebalance 后的 peer_id 对应的 shard_id 列表: Peer ID: 5395257186314509, Shard IDs: [2, 5, 8, 11] Peer ID: 4182395837949771, Shard IDs: [1, 4, 7, 10] Peer ID: 3095816753490206, Shard IDs: [2, 5, 8, 11] Peer ID: 3658649898688837, Shard IDs: [1, 4, 7, 10] Peer ID: 3841618339255269, Shard IDs: [0, 3, 6, 9] Peer ID: 8689864553665627, Shard IDs: [0, 3, 6, 9] 此时我们只要包装一下移动 Shard 的函数: def rebalance_shards ( from_peer , to_peer , shard_id ): url = f " { base_url } /collections/new_collection/cluster" payload = { "move_shard" : { "shard_id" : shard_id , "from_peer_id" : from_peer , "to_peer_id" : to_peer } } r = requests . post ( url , json = payload ) 就可以把移动路径传进去然后开冲了: for from_peer , to_peer , shard_id in rebalance_operations : rebalance_shards ( from_peer , to_peer , shard_id ) 时刻谨记:this is an expensive operation on large clusters 经过一段时间的 Rebalance,我们就可以获得一个 6 节点,且 rebalance 好的集群了,此时可以配置你的 Load balancer 指向这些机器的 IP,然后应用程序连接上任何一个节点或者你的 Load balancer 地址并开始继续猛用了! 如果要缩容,那么就需要将即将被裁员的 Peer 上的 Shard 给 Move 走(类似 kubelet drain ),然后通过 API 裁掉对应的 Peer 即可。 灾备 既然有了一个 3+ 集群的节点,根据 Raft,只要有 >50% 的节点在线,且我们的场景下只要掉线的节点不要包含了同一个 Shard ,那么数据是完整的,且所有操作都不会受到影响。 奇怪的是,Qdrant 的文档里面没有提及 Brain split 的情况。 所以: 如果你的某几个机器因为各种原因掉线了,且没有任何 Shard 的所有副本在掉线的机器上 那所有的操作都不会受到影响 如果你能原地恢复机器,那就恢复 如果不能恢复机器的话,用 API 删除这个 Peer,创建新机器加入集群,然后执行 rebalance 操作 如果有某个/些 Shard 的所有副本刚好在掉线的机器上 那你最好能把机器修好 🤣 或者建议尽快跑路 好消息是,在用 Cloud 的情况下机器掉线且修不好的概率其实不大(除非服务商着火了),基本掉线的原因可能是服务商的网络问题或者机器上 OOM 了,所以只要做好 Backup 基本可以保证没有数据救不回来的情况。 还有一个需要注意的情况是,如果你的机器 somehow 掉线了并且不可恢复,然后你通过 Snapshot 恢复了一个新的 VM,且这个 VM 获得了和之前已经掉线的机器不一样的,这个新的 VM 在加入集群的时候 有概率 会通知其他节点更新自己的 IP: 2025-05-10T07:52:31.601762Z WARN storage::content_manager::consensus::persistent: Replaced address of peer 3994356516252114 from http://10.0.0.5:6335/ to http://10.0.0.9:6335/ 也有可能不会通知其他节点,在这种情况下,我们需要手动修改 所有机器上的 /qdrant_storage/raft_state.json 文件,并将对应机器的 IP 进行修改,并滚动重启所有节点。 希望你不会需要走到这一步 😇 如果你使用的 Hetzner 的 Cloud,那有额外如下建议可供参考: 创建机器的时候将机器放在 Placement Group 中,这样 Hetzner 会保证 VM 不在同一个 Host 上,减少 Host 翻车导致 VM 集体翻车的情况,相关文档参考: In spread Placement Groups, all virtual servers are running on different physical servers. This decreases the probability that some instances might fail together. 似乎其他 VPS 服务商很少有看到能提供这种选项的,可能默认大家都是分散的 Spread? 机器开启 Backup Hetzner 机器的 Backup 会和机器不在一个可用区,减少可用区翻车导致 VM 和 Backup 同时翻车的概率,参考 注意:美国西部(Hillsboro)/美国东部(Ashburn)/新加坡由于只有一个可用区,所以 Backup 会和机器在一个机房中 自动化部署 还记得文章开头我们提到的如果你用 Hetzner 机器可以快速部署的方式嘛? Hetzner 对内提供一个 API: 例如在 Hetzner 的 VM 上 cURL 一下 http://169.254.169.254/hetzner/v1/metadata/private-networks 就可以得到你的机器内网 IP。 curl http://169.254.169.254/hetzner/v1/metadata/private-networks - ip: 10.0.0.3 alias_ips: [] interface_num: 1 mac_address: 86:00:00:c3:bf:16 network_id: 3493377 network_name: us-west-network network: 10.0.0.0/16 subnet: 10.0.0.0/24 gateway: 10.0.0.1 只要组合一下搓出一个 cloud-init 就可以自动安装 Docker + 部署 Qdrant 节点,并设定好 --url 等参数,参考如下: #cloud-config write_files : - path : /root/create_docker_compose.sh permissions : "0755" owner : root:root content : | #!/bin/bash # Fetch the private network metadata METADATA=$(curl -s http://169.254.169.254/hetzner/v1/metadata/private-networks) # Extract the IP address from the metadata PRIVATE_IP=$(echo "$METADATA" | awk -F': ' '/ip:/ {print $2}' | tr -d ' ') # Generate the docker-compose.yml file cat <<EOF > /root/docker-compose.yml services: qdrant: image: qdrant/qdrant:v1.14.0 restart: always volumes: - ./qdrant_storage:/qdrant/storage ports: - "6333:6333" - "6334:6334" - "6335:6335" environment: QDRANT__CLUSTER__ENABLED: "true" command: "./qdrant --bootstrap http://10.0.0.6:6335 --uri http://$PRIVATE_IP:6335" EOF - path : /root/install_docker.sh permissions : "0755" owner : root:root content : | #!/bin/bash # Install Docker curl -fsSL https://get.docker.com -o install-docker.sh bash install-docker.sh # Install Docker Compose wget https://github.com/docker/compose/releases/download/v2.36.0/docker-compose-linux-x86_64 -O /usr/bin/docker-compose chmod +x /usr/bin/docker-compose runcmd : # Update package lists - apt-get update - apt-get install -y curl wget # Execute the install docker script - /root/install_docker.sh # Execute the script to create the docker-compose.yml file - /root/create_docker_compose.sh # Start Docker Compose - cd /root && docker-compose up -d 如果是第一台机器,只需要稍微修改一下上述参数即可。 然后在创建 Hetzner 机器的时候填入这个信息就可以自动启动节点了: 非常方便,同时 Hetzner 友好的价格也可以保证在相对较低的成本下支撑较大体量的业务。 VM 价格对比 文章最后我们再来碰瓷一下别的服务商价格吧! Shared CPU VM 独立 CPU VM 如果你也觉得 Hetzner 价格很香,也欢迎使用我的 Ref 来注册, ,这样你可以在注册成功后直接获得 20EUR 的可用额度,我们也可以获得 10EUR 的奖励😘。 以上,我们下篇文章见!

新大灣區遊記——澳門篇

2025/05/06 16:00 · Posts on Nova Kwok's Awesome Blog

五年前,當我還在讀大學的時候有幸有機會來過一次香港,當時完成了博文「 」。 是啊,沒想到這一過就是 5 年了 5 年後的最近又一次得以有空前往,考慮這次經濟狀況和時間窗口不像大學時那麼拮据,便決定多花一些時間嘗試一窺整個大灣區的狀態。 見聞和認知會改變一個人對於事物的理解,以及旅行時心態的變化,我本身是一個很討厭拍照打卡和跟團遊的人,因爲認爲這並不是旅行,而是一場被安排好的定向徒步和消費,不僅不能增加對於個人的見識並改變自己的固有認知,還會讓精心策劃這些文案的人賺的盆滿鉢滿,是一個單輸的結局。 本次行程從上海出發,直飛深圳,之後抵達珠海前往澳門,後折返回深圳前往香港,歷時 N 天。 由於需要記錄的內容較多,本次「新大灣區遊記」將會分爲多篇文章記錄,列表如下: 「 」 「新大灣區遊記——澳門篇」(就是本文) 「 」 和往常的文章類似,本文不會按照流水帳記錄行程,而是會挑個人覺得有意思的點進行記錄。 澳門 終於我們來到了新大灣區遊記的最後一站——澳門! 雖然在澳門的停留窗口只有短暫的一天(由於澳門簽證不能像香港一樣每天都續,以及澳門酒店數量和價格(即使對比香港)也不太宜人),但是由於初期對於澳門的少量認知讓我對於澳門沒有提前做任何的「 行程 攻略」,如文初所述,對於澳門的旅遊規劃在整個新大灣區遊記中反而是最爲隨性而遊的狀態,畢竟對於澳門一開始的預期就是——賭場和賭場。 在前往澳門的前一天晚上有些睡不着,想起之前看到的「 」重新閱讀了一遍,獲得了一點思路和預期。 在澳門的行程其實較爲單調,通過拱北口岸通關後看到來來往往的人羣有一種鄉下人進城的感覺——我在哪兒,這兒怎麼這麼多人,大家都在去哪兒,我要去哪兒? 這種迷茫的感覺有些因爲沒有提前做行程攻略的後悔,又在不斷提醒我旅行並不是奔着一個個目的地趕路+拍照,看了一下周圍普遍大家有三個去處: 旅遊大巴(後來發現是賭場的發財車) 公交車(澳巴) 出租車 想了一下只有公交車符合當下我的需求,便打開高德地圖看了一下可能要去的「大三巴」附近的位置,選擇了 3X 公交車上車開沖。 澳門口岸附近的房子的破敗感和口岸大樓的豪華形成鮮明的對比 乘車時一邊好奇車上的葡語播報一邊看着高德地圖(沒錯,不是 Google Map)發現或許在「柏港停車場」下車可以獲得比「殷皇子馬路」更加靠近大三巴的位置。 以上根據記憶寫成,可能站名並不準確。 下車後跟着地圖和人羣一陣亂走便看到了傳說中的新葡京大廈。 同時也很快經過了第一個可能的 POI:市政署大樓。 同時還發現了澳門的密雪冰城 大三巴和大炮台公園 我相信很多去澳門的人都會去看「大三巴」,但是很多人或許並不知道爲啥叫大三巴,只是去打卡拍照而已。 從大炮台公園俯瞰大三巴 我們來看看 Wiki: 大三巴牌坊(葡萄牙語:Ruínas de São Paulo)是澳門天主之母教堂(即聖保祿教堂)正面前壁的遺址,屬於「聖保祿學院天主之母教堂遺址(大三巴牌坊、前地及石階)」的組成部分之一,亦是澳門的標誌性建築物之一。 … 其中文「三巴」一名則來自聖保祿的葡萄牙文「São Paulo」的漢語譯音;此外,為了與聖保祿學院的分院——聖若瑟修院的聖堂作區別,亦因前者的規模較後者為大,故華人稱聖保祿教堂為「大三巴」,稱聖若瑟修院為「小三巴」或「三巴仔」[1]。 … 1835年,一場大火燒毀了聖保祿學院及教堂,至今殘剩教堂的正面前壁、大部分地基以及教堂前的68級石階,由於本地人認為教堂前壁形似中國傳統牌坊,故將之稱為「大三巴牌坊」[2]。 總結一下——一個教堂被大火燒了,只剩一下一面牆,被成爲牌坊,然後因爲名字叫「São Paulo」,所以叫三巴… Anyway,由於看過了 Wiki 之後對「大三巴牌坊」徹底失去了興趣,於是在中午酒足飯飽之後便沿着一條比重慶的山路還陡的路開始莫名爬山,然後到了被稱爲「大炮台公園」的地方。 你說這是重慶我也能信 「大炮台公園」像是澳門的一個制高點,在上面除了被太陽曬以外可以看到澳門的各個地方,比如上文中的「俯瞰大三巴」視角,比如可以看到對面的珠海 有趣的車 在澳門街頭可以看到許多中國大陸看不到的奇怪的車。 比如在爬山路上看到坡道停着的被稱爲 Jazz 但是長的和 Fit 一樣的車。 改裝佬最喜歡的 MOMO 木質方向盤? 改了直通和桶椅的 Toyota MR-S Hondaman 的 Accord Euro R,感覺在大陸是 1087 預訂,不知道澳門如何管理車輛改裝 威尼斯人 在去澳門前看各種社交媒體發佈的「威尼斯人」的照片都感覺很玄幻,實際進入後感覺是——一個帶天幕的購物中心+內部水池。 仔細看天幕上可以看到一些突起(不知道是不是煙霧報警器類似的東西) Again,由於沒有做過攻略,最虧的便是以爲「威尼斯人」內部沒有吃飯的地方,便找了個麥當勞吃了一頓,吃完後才發現有一塊專門的餐飲區域: 娛樂場 嚴格來說到了澳門之後第一個進入的「娛樂場」是新葡京(因爲從「大炮台公園」下來沒走多遠就到了),在樓下進入前看到一些指示牌還以爲賭桌會放在某個樓層以上,不過看到來來往往的遊客,我也跟着進去了。 進門時還被安保攔下來要求檢查證件確認年齡(可能看我長的如此年輕?( 且個人感覺澳門幾乎所有「娛樂場」的安保都不是亞洲人,不知道是不是什麼特別的法規限制 進門之後發現從一樓開始就直接是各種遊戲機一樣的賭桌。 很不幸,這裏沒有照片,因爲不讓拍照,請允許我放一個網圖: 來源:https://news.now.com/home/finance/player?newsId=444903 可能由於有豐富的 50x+ 合約操(爆)盤(倉)經歷,這種花花綠綠的遊戲機在我的眼裏只感覺無趣和花哨,甚至感覺有些供氧不足(「威尼斯人」內部好一點,不過也差不太多)。 由於以上,新葡京只是進去逛了一下就走了,最終嘗試下場還是在「威尼斯人」內部。 澳門似乎所有酒店都提供「娛樂場」 由於和真人荷官的賭博完全看不明白頭緒,加上預算非常有限(只有 100MOP),我選擇了一個非常無腦的機器——骰寶,簡單來說規則如下: 機器內有三個骰子 插入紙幣之後在 Session 中就獲得了紙幣金額(是的,沒有用戶管理系統,插入紙幣就開始了,遊戲界面感覺上個世紀 .Net 風格) 可以選擇押注某一個,比如「大(11~17)」「小(4~10)」 以及各種組合,大概如下圖,注意不同的下注有不同的最小下注金額 然後拍一下按鈕就會開始自動彈骰子,機器自動計算骰子面朝上的數字只和,判斷你是否猜中。 由於幾乎不用動腦子,總結一下我的「資產變化」爲:100MOP -> 20MOP -> 160MOP -> 140MOP 最後走的有些上頭,離場時摸出了 20MOP 又玩了一把,然後丟掉了 20MOP 你說好玩吧,也許?但是你說刺激吧?我只能說你沒玩過合約( 圖片來自:https://fsr-develop.com/blog-cscalp/tpost/xb7t7pmht1-how-to-calculate-binance-profit-on-the-f 不 要 做 賭 狗 不 要 做 賭 狗 不 要 做 賭 狗 讓我們來看一下這個行業可以帶來多少收入,根據「澳門特別行政區政府博彩監察協調局」的網頁: ,我們可以看到下表: 1 港元 = 1.03澳門元 (單位: 百萬澳門元) 月份 毛收入 (2024年) 毛收入 (2023年) 變動率 累計毛收入 (2024年) 累計毛收入 (2023年) 變動率 一月份 19,337 11,580 +67.0% 19,337 11,580 +67.0% 二月份 18,486 10,324 +79.1% 37,823 21,904 +72.7% 三月份 19,503 12,738 +53.1% 57,326 34,642 +65.5% 四月份 18,545 14,722 +26.0% 75,872 49,364 +53.7% 五月份 20,188 15,565 +29.7% 96,059 64,929 +47.9% 六月份 17,694 15,207 +16.4% 113,753 80,136 +41.9% 七月份 18,595 16,662 +11.6% 132,348 96,798 +36.7% 八月份 19,754 17,213 +14.8% 152,102 114,011 +33.4% 九月份 17,253 14,937 +15.5% 169,355 128,947 +31.3% 十月份 20,787 19,501 +6.6% 190,142 148,449 +28.1% 十一月份 18,438 16,043 +14.9% 208,580 164,492 +26.8% 十二月份 18,202 18,567 -2.0% 226,782 183,059 +23.9% 比如 2024 年 1 月的收入就是 193 億澳元 🫡 根據「澳門特別行政區政府統計暨普查局」統計,「2024年終澳門人口為688,300人」,根據「澳門博彩業未來的發展趨勢* ──基於澳門居民博彩行為演變分析」一文我們可以知道 博彩業是澳門最重要的產業。2018年,在澳門的就業人口中,有約22%在博彩行業工作(包 括博彩仲介), 1 是澳門僱員數量最大的行業。澳門特區政府的直接稅,有92%來自博彩業, 2 如果再加上博彩企業貢獻的其他稅費,博彩業對特區政府財政收入的貢獻還更大。 只不過每次看到澳門有些老舊的街頭和金碧輝煌的「酒店」總是有種說不上來的感覺,只想到了一句話: 贏家通吃 離開威尼斯人之後出門正好是酒店「發財車」的停車場,由於已經走了一天,便隨着人流一起上了前往「關閘」的大巴,一路聽着車上的人用各種各樣的口音分享自己的贏錢/輸錢經歷慢慢的回到了口岸。 迷幻的澳門之旅結束了,該回大陸了!

新大湾区游记——深圳/珠海篇

2025/02/14 04:00 · Posts on Nova Kwok's Awesome Blog

五年前,当我还在读大学的时候有幸有机会来过一次香港,当时完成了博文「 」。 是啊,没想到这一过就是 5 年了 5 年后的最近又一次得以有空前往,考虑这次经济状况和时间窗口不像大学时那么拮据,便决定多花一些时间尝试一窥整个大湾区的状态。 见闻和认知会改变一个人对于事物的理解,以及旅行时心态的变化,我本身是一个很讨厌拍照打卡和跟团游的人,因为认为这并不是旅行,而是一场被安排好的定向徒步和消费,不仅不能增加对于个人的见识并改变自己的固有认知,还会让精心策划这些文案的人赚的盆满钵满,是一个单输的结局。 本次行程从上海出发,直飞深圳,之后抵达珠海前往澳门,后折返回深圳前往香港,历时 N 天。 由于需要记录的内容较多,本次「新大湾区游记」将会分为多篇文章记录,列表如下: 「 」 「 」 「新大湾区游记——珠海/深圳篇」(就是本文) 和往常的文章类似,本文不会按照流水帐记录行程,而是会挑个人觉得有意思的点进行记录。 这一次在深圳的行程可以简单总结为——深圳真暖和,猛开焕新款 Model 3,电鸡之城和「哇,蓝色的海!」,而珠海 的行程则是——「这就是慢节奏?感觉就是老破小呀?」 深圳 拍摄于深圳某地铁站内 电动自行车 在 2024 年偶然发现「新国飙电动自行车」在上海的使用场景很香了之后便开始关注这个领域,这一次到了深圳之后也许是所处地区的原因(福田区),感觉走在路上到处都可以看到「新国飙电动自行车」,而且(对比上海来说)许多车都经过改装,「九号」的覆盖率很高,而且较大部分的人直接在机动车道上骑行,且不戴头盔(甚至还带人),此外有相当比例的电动自行车可以直接听到控制器散热风扇的声音。 之前在 Twitter 上便有听说广东是「电鸡之城」,这回到了深圳感觉印象颇深。 气候 「去过了深圳才知道上海的冬天是有多冷」,这句话从飞机落地的那一刻,直到回到上海的家中都一直在我脑海中萦绕。 在上海出发前还是秋衣+两层毛衣+羽绒服的搭配,且需要处处当心静电,到了深圳之后立即就变成了秋衣+外套一路猛冲,完全让人意识不到这是冬天该有的状态,舒适的湿度完全不用担心静电也没有任何很潮湿的感觉,这才应该是适宜的居住温度嘛! 蓝色的海(大鹏,鹿嘴山庄) 虽说上海也是海边城市,但是上海附近的海全部是清一色的黄色,如果要看到蓝色的海可能得去舟山(嵊泗)某处(见我之前的文章「 」),所以作为一个在上海生活了很久的人来说,其实并没有见到过 “真正好看的大海”。 这次来到深圳之后由于天气很不错,便拐上大学时好朋友 Allen Lau 白嫖了一波 Model 3 从福田附近一路冲刺来到了大鹏。 然后就是我目前见到过的最漂亮的海了! 较为晴朗的天气,非常舒适的温度和湿度,没有任何的 DDL 需要赶,在大鹏的那个下午是整段旅行中最惬意和放松的部分。 在海边走了一段时间时候 Allen 提议去附近的一个度假区看看,于是在「杨梅坑」附近吃了午饭之后乘船出发去被称为「鹿嘴山庄」,距离不是很远,但是要前往只有两种途径: 船 观光车 于是我们选择了乘船去,观光车返回的路线: 过去的船票价格不算贵(35CNY),体验是相当刺激,一路以 40KM/h 的速度在海上冲刺。 本来以为是坐船,实际上是做了个头发 到了「鹿嘴山庄」之后才知道这里是「美人鱼」的拍摄地之一,于是就能看到标志性的东西: 走了大约 20 分钟便到达了真取景地的海边: 对于我一个很少看过蓝色大海的人来说,风景绝赞 当时的心情只能用「乐不思沪」来形容,甚至有想法搬到深圳长期居住。 这个洞就是「美人鱼」取景地的洞 说实话,这个电影和特效感觉和电影的时间明显脱节,剧情只能说符合周星驰的风格,但是整体观感比较一般 Model 3 离开了「鹿嘴山庄」之后回福田的路就正好白嫖了一把 Model 3(当然,还有其他时间也嫖了的) 整体感觉焕新版(或者说:无转向灯版) Model 3 驾驶体验符合在上海是静态体验和短暂试驾的状态,可能由于我的 FK7 行驶质感太差了,驾驶 Model 3 就是哪儿哪儿都非常舒服。 按钮(震动反馈)转向灯的设定在激烈驾驶的时候偶发有遇到按下去但是没有震动,且较小的转向灯提示音量会让驾驶员怀疑有没有开启转向灯(至少对于我来说会怀疑),但是如果日常开的话似乎只要 20 分钟左右的时间便能很快适应。 然后好像新版的 Model Y 又把转向灯杆给加回来了,非常抽象 此外作为汽油车车主还发现一个有意思的情况:Model 3 的动能回收不可关闭,且只要松开了油门后面的刹车灯就会亮起来,什么强制不允许 Coasting 行为(入弯要么给油,要么刹车,绝对不会 Coasting) 未来如果这辆车二手价格能到 13W CNY 附近的话,似乎买来用来娱乐和代步还真是个不错的选择。 欢乐海岸 要不是有本地人介绍,可能深圳之行就去世界之窗了。 另一个深圳的目的地就是欢乐海岸,属于下午-傍晚放松散步的好地方: 岸边有许多海鸥和收费拍照服务。 旁边能看到深圳湾大桥,对面就是香港。 这个时候要是有个皮划艇该多好 珠海 回味完了深圳,我们这就来到了珠海! 船! 虽然坐船只要 1hr 就能到,但是一到了珠海就有一种回到了三线小县城的感觉,当然也有可能和所处的香洲区有关: 在上海调研路线时发现深圳到珠海有船可以坐,这不得一定来体验一下? 坐船全程 1hr,速度大概在 50KM/h 左右,途中部分地方会彻底搜不到信号,且有一定概率可以搜到来自香港的信号。 破旧和慢节奏 在去珠海前和 Allen 吃饭的时候有聊到对于珠海的看法,和很多人一样,Allen 对于珠海的评价也是——慢节奏,我又问过什么是慢节奏,但是没有得到明确的回答。 最近也有和前 PingCAP 同事聊到不同的城市的居住体验,也有聊到云南是「慢节奏」城市,同样,我也问了一样的问题:「什么是慢节奏」,得到的回复是「那边的民宿的老板,还有饭店的老板,还有本地的居民,和他们聊天都感觉得到他们比较平静,状态也很好」。 细想一下,可能慢节奏是指——没有过度的经济追求的欲望带来的心态不平衡以及平和的生活? 如果这样想的话,那确实,北京/上海等城市更大的开销(主要是房产,北京的话可能饮食也是一个大头)带来的许多(尤其是非上海本地有房有车的)人对于收入上的追求以及心态的变化。 比如说在上海作为程序员相关工作 3 年没个 40K/mo 可能根本在朋友面前抬不起头来,并暗自想自己未来也要 40K/mo 比如说看到同事家里人均两辆车,一辆电车代步,一辆 Civic Type-R 作为玩具,并暗自想自己未来也要搞两辆车,而且都得是沪牌! 对比之下如果不在一线城市,而在一个消费不高的城市,可能 10K/mo 不到的月薪已经足以支撑每月较为舒适的生活了。 当然,这个还是得看自己想要的是什么,以及自己想要的是不是真的自己想要的,还是外界给灌输的,或者是由于生活的城市的房价给压的。 如何获得一个平和的心态呢?多少的现金才会让自己满意和安心呢?哪个城市/国家才是最适合自己需求的呢? 我没有答案。 骑车和日月贝 在上海我是一个经常骑车的人(无论是公路自行车还是新国飙电动自行车),但是出来旅行的时候似乎会习惯性忽略身边的骑行工具,导致在城市之间穿梭的时候靠腿走路,不仅行动速慢,而且还走不了多远。 这次来到了珠海之后学会了使用共享单车的技术,开始把身上的装备(装了相机和电脑的背包和三脚架)放在共享单车的车筐内。 行动速度一下子就 x5 了! 由于住在香洲区,情侣中路附近,所以骑车沿着海边溜达成为了一个必备项目,发现了周边被称为「日月贝」的建筑,一路猛骑。 期间还发现有直升机游览项目,价格 400CNY 附近,考虑了一下性价比和天气决定放弃了(虽然感觉还是有点小遗憾) 由于珠海作为一个从深圳到澳门的中转站,只停留了很短的时间,至此珠海的主要行程已经结束。 小结 从短暂的停留来看珠海和深圳是两个风格迥异的城市,即使这俩城市只相隔了 1hr 的车程,可能有点像是上海和嘉兴的关系,珠海整体消费水平更低换来的是感觉明显更差/脏的居住环境,但是环境整体情况又会和经济水平挂钩,除非像澳门/香港一样引入重罚(即便如此也改变不了太多「破旧」的整体风格)。 呆惯了上海这样的城市之后就会有带着上海的眼光去评判其他城市的布局(当然,不得不说,杭州/苏州的交通情况是真的糟糕)和概要,从这个视角来看深圳和上海体验是差不多的(甚至路上的车基本都会使用转向灯),所以体感除了电驴多了许多以及冬季气候更加舒服以外没有感受到太大差异,而珠海就是印象中的老城区的感觉了,从深圳到珠海后还得适应适应。 那么,我们即将在最后一站——澳门见!

新大灣區遊記——香港篇

2025/01/27 04:00 · Posts on Nova Kwok's Awesome Blog

五年前,當我還在讀大學的時候有幸有機會來過一次香港,當時完成了博文「 」。 是啊,沒想到這一過就是 5 年了 5 年後的最近又一次得以有空前往,考慮這次經濟狀況和時間窗口不像大學時那麼拮据,便決定多花一些時間嘗試一窺整個大灣區的狀態。 見聞和認知會改變一個人對於事物的理解,以及旅行時心態的變化,我本身是一個很討厭拍照打卡和跟團遊的人,因爲認爲這並不是旅行,而是一場被安排好的定向徒步和消費,不僅不能增加對於個人的見識並改變自己的固有認知,還會讓精心策劃這些文案的人賺的盆滿鉢滿,是一個單輸的結局。 本次行程從上海出發,直飛深圳,之後抵達珠海前往澳門,後折返回深圳前往香港,歷時 N 天。 由於需要記錄的內容較多,本次「新大灣區遊記」將會分爲多篇文章記錄,列表如下: 「新大灣區遊記——香港篇」(就是本文) 「 」 「 」 和往常的文章類似,本文不會按照流水帳記錄行程,而是會挑個人覺得有意思的點進行記錄。 簽證 這次行程對我印象最深的便是簽證了,5 年前的文章中「雖然由於計劃原因在到達香港的當天就直接返回大陸了」很大一部分原因其實就是因爲——沒錢,當時拖着行李箱走到香港的旅遊區被 700 HKD 一晚的破舊酒店直接勸退,體驗可太差了。 加上考慮到當時通行證簽註從香港回到大陸之後無法立即獲得新的簽註(無法第二天再次前往),所以就草草結束了香港之行。 這一次前往前原定計劃是頂着高價在香港住正常酒店一個晚上,不過到了深圳之後才意識到目前已經可以使用 24 小時自助簽註機,從香港回來之後的當晚即可獲得新的簽註用於第二天前往,這個消息應該是整個行程中最大的好消息,意味着可以繼續保持住在相對低價和高質量的深圳酒店,同時保持行李放在酒店,只用輕裝上陣(雖然其實也不是很輕)即可。 不過有意思的是可能因爲不是深圳戶口,在酒店附近的自助簽註機上得到的是團隊旅遊簽,雖然實際在口岸通關的時候使用起來和個人簽註沒有任何區別。 而對於深圳居民而言似乎可以辦理特別的簽註可以隨時往返香港,非常神奇。 對比之下澳門回來之後似乎還是會有冷靜期,不能像香港這樣無限續杯。(也許因爲許多人去澳門都是奔着賭博去的? 羅湖口岸 羅湖口岸對比五年前其實差異不是很大,不過令我疑惑的是廣九直通車的橋似乎被封起來了,可能因爲「高速鐵路」開放了之後就不再需要這一絛路線了? 2024 年 7 月 5 日,因广州东站往返香港西九龙站的 G 字头列车已实质上取代普速广九直通车服务,中华人民共和国国务院批准关闭广州和东莞的内地与香港跨境普速列车(城际直通车)铁路口岸,海关总署 7 月 31 日正式公告关闭上述普速铁路口岸[6]。 2025 年 1 月 17 日,港铁宣布从当日起撤销红磡站的过境限制区(包括 5、6 号月台及车站大堂部分区域等)[7]。 這是 5 年前 這是最近 我儘量保證了一樣的視角和焦距,所以對比着看是不是有一種時間流逝的感覺。 感覺真應該拍點延時攝影的 這裏的橋可以明顯看到已經被封住了 不過依然可以看到對面的 MTR 東鐵線列車 我們再來回憶一下去往香港的標識,在 5 年前看到的是這樣的: 而現在,是這樣的 我依然記得在 5 年前涉世未深的我第一次看到這個「往香港」牌子時的激動和興奮,感覺跨越了深圳河就會到達一個魅美麗的新世界,一個自由,平等,法制的新區域… 當然,這些很大程度上來源於認知不足和眼界不夠寬廣時的幻想,雖然可能從實際情況來說,那個時候的香港確實對比如今的香港會更加貼近「國際金融中心」的名聲? 5 年過去了,這 5 年發生了諸多事件,例如: —— 2020 年 5 月 —— 2021 年 12 月 —— 2023 年 8 月 也一步步改變了香港在我心中的地位和看法,印象中 5 年前第一次去香港的時候滿懷期待要在當地開卡設立戶頭,但是到了現在由於這一系列變故加上從更多維度對於香港的瞭解,開立賬戶也只是成爲了「解決某些實際需求」中的一環。 或者說,可能我更加務實了?我不知道… 彩虹邨 5 年前的計劃中其實就有「彩虹」,但是當年時間過於緊張未能成行,這一次又來到了「彩虹」,決定一探究竟。 我們還是來看看這裏的背景是什麼: 彩虹邨(英語:Choi Hung Estate)是香港早期興建之公共屋邨之一,獲稱為「香港最美屋邨」[2],佔地逾 5.1 公頃[3],位於九龍黃大仙區牛池灣,由香港屋宇建設委員會發展,巴馬丹拿建築及工程師有限公司設計[4],融入包豪斯風格元素[5]。因為設計獨特,彩虹邨榮獲了香港首個建築獎項——1965 年香港建築師學會年度最高榮譽「銀牌獎」[6]。現時由香港房屋委員會管理。 2024 年 10 月,房委會向黃大仙區議會公布重建計劃方案,首期預計於 2028 至 2029 年率先拆卸碧海樓、金碧樓、丹鳳樓、街市,以及聖公會兩間已遷出的小學校舍,原有位置預計於 2035 至 2036 年完成重建後將會安置下一期(錦雲樓、紅萼樓、金漢樓及白雪樓)的居民。最後一期(翠瓊樓、金華樓、綠晶樓、 紫薇樓及多層停車場)預計將會於 2042 至 2043 年拆卸,原有居民屆時將會獲安置到第二期的新樓宇,整個重建計劃預計於 2048 至 2049 年完成 。[10] 還好來的快,要是再拖 5 年就沒了。 由於對新加坡的房產結構有一些瞭解,所以看到了 Wiki 之後就立即明白這個很像新加坡的 HDB(政府组屋) 和網圖不同,實際看彩虹邨的樓已經掉色,靠近內部的看的時候有一種非常強烈的年代感(可惜沒有拍照),是那種可以立即將你拉回 20 年前的感覺,水泥的過道,金屬的大樓門,以及若有若無的炒菜的香味。 這裏的拍攝地其實是一個多層停車場的樓頂,下樓之後也去額外關注了一下這裏的停車價格,照片如下: 從這個時租價格來看,似乎和之前在 PingCAP 外灘 SOHO 的停車費有得一拼 和大陸對比那肯定是很貴,雖然實際上也是很貴(尤其是考慮到汽油價格),這麼一看也不難理解爲啥那邊的車感覺人均改裝了,例如這個改了輪轂的 Fit: 還有這個 BMW 以及這個 GR86 香港迪士尼 雖然身在上海,但是很少去上海迪士尼,從個人的角度來看,我很討厭上海迪士尼,感覺是一個過度割韭菜的地方,並且對大家的行爲感到不解。 同樣作爲用來放鬆的樂園,如果在上海迪士尼持有普通門票,則是接近 500CNY 的價格,且似乎從 2022 年開始上海迪士尼常年是旅遊高峰日,從下地鐵開始便會經歷接近 1hr 的入園排隊,入園後使用 Disney 的 App 會發現幾乎所有的項目都是 40+min 的排隊,基本如果不購買「快通」的話就會是走路+排隊一整天,總共可能只能玩 3 個項目的節奏,體驗非常的糟糕。 2016 年 6 月 16 日,上海迪士尼正式开园,仅运营一年就实现盈利,接待了超过 1100 万名游客,远远超过迪士尼集团的预期,这也是迪士尼历史上第一个开园首年即实现财务收支平衡的主题乐园。 运营三年后,于 2019 年,上海迪士尼全年营收达到 70 亿元,成为迪士尼集团全球最赚钱的主题乐园。 當然,這個也是市場決定的,如果需求量沒有如此大的超過供給量的話,也不會有這樣的局面,雖然,我個人把這個解讀爲——韭菜足夠多,都搶着被收割。 於是這一次來香港便要來看看香港的迪士尼情況是如何的,能不能有所改觀。 香港迪士尼樂園開園時面積僅 28 公頃。經過第一輪和第二輪擴建計劃後,面積已達 35 公頃[2],香港迪士尼仍在進行第三輪擴建[3]。香港迪士尼樂園目前是面積最小的迪士尼樂園。 從門票價格的角度來看,由於可以從「閒魚」上買門票,獲得了實際 500CNY 的門票,此外還購買了一個 259CNY 的餐券(可以獲得園內標定 160HKD 以內的午餐和晚餐加一個小吃券),從價格來看,甚至似乎和上海迪士尼持平了,但是這是香港啊。 所以在上海迪士尼能體驗到香港的物價,算不算某種一舉兩得呢?( 要前往香港迪士尼似乎只有兩種途徑——港鐵或者開車(這一點也和上海迪士尼一樣) 迪士尼綫原稱「竹篙灣鐵路綫」,建築成本原本為 26 億元,最後減至 20 億元[4]。本綫工程於 2002 年底動工興建[5],是為配合香港迪士尼樂園而建;2005 年 4 月竣工,欣澳站於 2005 年 6 月 1 日啟用,全綫於 2005 年 8 月 1 日正式通車;全長 3.5 公里,行車時間 5 分鐘,是港鐵系統最短的重鐵路綫。本綫只有 3 列列車,是港鐵系統中列車數量最少的路綫,列車停泊和維修與機場快綫、東涌綫共用小蠔灣車廠,是港鐵系統中唯一不設中途站以及車廠設於路綫範圍以外的本地重鐵路綫。本綫是原地鐵系統以及整個港鐵系統目前唯一完全位於新界區內以及只在單一行政分區(荃灣區)行走的重鐵路綫;也是兩鐵合併前,地鐵最後一條通車的路綫。 下了港鐵之後就能看到和上海迪士尼類似的大門和長長的路,經過短暫的檢票之後就可以看到…迪士尼的內部環線火車站。 繞過火車站之後就可以看到標誌性的「城堡」了。 不得不說香港的迪士尼不愧是面積最小的迪士尼,「城堡」整體體積甚至感覺不如上海任意常見的商品房。 和上海迪士尼一樣,香港迪士尼也有一個自己的套殼瀏覽器 App,打開 App 之後可以發現許多項目的排隊時間都在 15min 左右,進門的右手邊就是「明日世界」區域,對應上海迪士尼的 e-Tron (創:光速戰記),但是查閱 Wiki 就可以發現上海的「創:光速戰記」反而是個特例。 飛越太空山初次於 1975 年在華特迪士尼世界度假區,於 1977 年在迪士尼樂園度假區,於 1983 年在東京迪士尼樂園,於 1995 年在巴黎迪士尼樂園,及於 2005 年在香港迪士尼樂園開始營運。2016 年开业的上海迪士尼樂園的明日世界不设有「飛越太空山」(Space Mountain)景点,取而代之的是以《創:光速戰記》(Tron)為主題的半室內骑行雲霄飛車「創極速光輪」。 從整體排隊時間來看,香港迪士尼要有絕對的好評,尤其是對比上海迪士尼而言,當然,這也是供需關係所決定的產物。 整體上來說,在香港迪士尼處處可以看到上海迪士尼的影子,又處處能感受到和上海迪士尼的不同,但是由於供需關係等原因,你可以以接近上海的價格獲得體驗明顯更好的遊園感受——畢竟來迪士尼玩是來找樂子的,而不是來罰站或者給自己錢包開個大窟窿的,你說不是麼? 開立銀行賬戶 來香港的一個重要的 KR 就是銀行開戶,5 年前就有這個計劃,但是由於各種原因沒有成功,這一次準備的開戶的賬戶有 ZA Bank,BOC HK 和 HSBC。 在出行前發現這三個賬戶都已經可以線上開戶,只需要 GPS 定位在香港境內並上傳出入境記錄即可(好像 ZA Bank 會要求連接上香港本地 Wifi)。 在香港迪士尼休息的時間便摸出手機開始線上開戶,成功提交了 ZA Bank 和 BOC HK 的賬戶,HSBC 賬戶不知道爲何提交失敗,於是決定放棄。 回到上海之後也是依次接到了開戶通過的通知並陸續收到了實體卡,從實際體驗上來看有以下初步結論: ZA Bank 的卡片綁定支付寶只能扣除 HKD 餘額,而且有逃不掉的 1.95% 的手續費(官方標稱是 VISA 收 1%,ZA 收 0.95%) BOC HK 的「中銀卡」可以直接綁定雲閃付扣款,國內消費 CNY 對應 1:1 扣除離岸人民幣 這個政策甚至是 2024 年 12 月開始的 BOC HK 的萬事達扣張卡每筆消費有 0.5% 的反現,但是我綁定到支付寶使用的話會隨機失敗(有的時候重試又能成功),不知道爲啥,尤其是如果被掃的場景,可能失敗了支付寶就自動 Fallback 到後續卡片了,用起來體驗不是很好,而且綁定支付寶消費的話大於 200 CNY 會被支付寶方面額外收一筆手續費 高速鐵路 香港西九龍站(英語:Hong Kong West Kowloon Station[4][5][6]),規劃及建設期間稱為西九龍總站,通常簡稱為西九龍站或西九站[7][8],是位於香港西九龍油尖旺區的鐵路車站,本站為廣深港高速鐵路的南端起訖站,鄰近西九文化區。其佔地 11 公頃,耗資 122 億 3 千萬港元興建,樓面總面積達 43 萬平方公尺[9]。站內實施一地兩檢,設有內地口岸區,由中國內地派駐執法人員執行中國內地法律[10][11]。 香港西九龍站的鐵路被香港稱爲「高速鐵路」,此前我不以爲然,感覺不就是普通的動車組列車麼,高速在哪兒。 本次出行除了有一次沒有趕上福田-西九龍的火車導致從落馬洲通關乘 MTR 以外均乘坐「高速鐵路」往返。 從價格上說,接近 70CNY 的售價(從福田-香港西九龍)的確不算很便宜,但是對比港鐵大約 50CNY 的價格來說算得上是符合市場定價。 但是,從福田-西九龍的鐵路只要 15 分鐘,而如果乘坐港鐵的話則是 50 分鐘。 從福田站開始就一路全程在隧道中(有點北京機場快線的感覺),15 分鐘後便到達了香港西九龍站,幾乎橫跨整個九龍半島。 和要繞路一圈的港鐵比起來那真是 Blazing Fast,再搭配上可以通過自助簽註機隨時簽註的策略來看,晚上回深圳住,白天在香港玩似乎會越來越稱爲高性價比和更好體驗的香港旅遊主流方案。 一點 Fun Facts: 現時在內地口岸區連接車站 WiFi 可直接免費上網,服務由香港網絡供應商提供,無需提供實名登記電話號碼或微信賬號[53]。同時,旅客在內地口岸區使用行動通訊服務(包括使用數據訪問網絡),仍接收香港的網絡訊號,香港流動電訊營辦商的用戶不會被收取漫遊費用,且不實行內地的網絡內容審查[54],非香港本地電信運營商的用戶則是按照在香港漫遊的正常情況提供服務並收取漫遊費用。 消費 5 年前初次前往香港的時候還額外自備了港幣,這一次前往感覺幾乎所有需要付費的地方都覆蓋了支付寶支付(澳門也是,支付寶甚至可以直接乘坐澳巴),隨着支付寶的滲透,港幣現金的需求越來越少。 更加減弱了香港作爲境外旅遊地區的超脫感,整體的旅遊體驗更加無縫地接近在大陸的日常生活體驗。 法制/罰款? 沒趕上「高速鐵路」的那一次不得不從落馬洲通關,到了「旺角東」下車後印象最深的便是這個: 根據《僱傭條例》,僱主如故意及無合理辯解而不依時支付 工資給僱員,或不支付欠薪的利息,可被勞工處檢控,一經定罪,拖欠工資最高可被罰款 35 萬元及監禁 3 年,不支付利息最高可被罰款一萬元。 立即就想到了許多大陸公司隨意裁員不依法給補償然後然後一副:「你去仲裁我好了」的態度了。 此外還有一些關於亂丟垃圾的罰款: 這就不得不想到經常大陸的街頭,以及高速上,例如: 來源:https://x.com/tualatrix/status/1882687325240795345 可能是有這些法律的存在,香港整體街邊環境給我一種破舊但是乾淨的感覺,類似也許上海不使用轉向燈變道會被扣分罰款,所以上海馬路上幾乎所有車變道都會使用轉向燈。 對比之下蘇州就像蠻荒之地一樣,應急車道隨便走,實線隨便壓,我是不理解這但凡裝個攝像頭開始罰款都可以在一天內回本的事情爲什麼不做?要麼執法者和地方官員本身也是這麼開車的? 也許中國大陸也應該引入這些法律,這樣所有人的生活都會更好? 城區 由於時間規劃的原因,這一次在香港境內去了三個地方: 維多利亞港 彩虹邨 香港迪士尼 從 MTR「金鐘」站下車穿過立法會大樓就到了維多利亞港邊,舒適的廣東氣候讓我這樣一個常住上海的人完全沒法理解這是冬天該有的樣子。 溫暖甚至有些刺眼的陽光,沒有呼嘯的寒風和由於超低溼度帶來的大量靜電,藍色的"海岸",這似乎應該才是「南方城市」該有的樣子。 走到岸邊時已經有些走累,坐在岸邊休息,看着旁邊來來往往的人羣,一瞬將思緒帶回前灘休閒公園,這不就是前灘休閒公園的樣子麼? 背後的辦公樓,中午有員工下樓散步聊天(雖然是粵語),面前來來往往的船隻,一個和諧的下午的畫面… 除了這面前的水不是泥色以外 回到上文對於務實的討論,坐在岸邊休息的我在想——如果現在給我一個機會選擇香港和上海,那我應該如何選呢? 兩邊都有較高的房價,一邊有更加暖和舒適的冬天(這個確實是優點),但是上海作爲內地城市有着更加低的物價水平,在同等收入的情況下似乎會有更好的生活質量,但是內地城市的缺點也很明顯(例如監管,執法,食品安全等)。 我得不到答案,或許這個問題也可以被修改爲——如果是新加坡和上海選哪個,或者瑞典和上海選哪個。 各有利弊,做不到既要又要,想明白了這點,再看看週遭這溫暖的環境,內心還是有些遺憾,但是也對自己的需求和想法有了一點點更深的認識了。 看着岸邊的信標還有圍欄旁邊的遊客,心中各種關鍵詞不斷跳動——法制,高物價,現代化,溫暖的氣候,更差的居住條件,寬鬆的外匯管制,被大陸不斷滲透的法律… 可能真要爲未來做出選擇也得在香港多旅居一段時間,現在也僅僅是內心的幻想… 然後鬧鐘響了,將我從幻想帶回現實,我終究只是個「訪港遊客」,是時候離開了,再不走就趕不上回上海的飛機了… 從西九龍乘車回到福田的路上,我內心還在回味幾個小時前在維多利亞港的遐想,直到列車進入內地區域之後發到手機上的短信告知今晚 2030 起飛前往上海的飛機將延遲到 2315 起飛之後,思緒便瞬間收回。 好你個金鵬航空! 再見香港!

皖J保住了?2024 年秋季杭州西湖-黄山行

2024/11/21 04:00 · Posts on Nova Kwok's Awesome Blog

上一次来黄山可是上一次了,接近 20 年前,回顾着老照片再次登上黄山,整体给我的感觉便是——不像最近多年来火热的「网红旅游区」,黄山似乎整体保持了一个很正常的状态(当然,也有可能因为是在山上,没法网红起来。 从 PingCAP 离职后的这两年虽然处于失业状态(甚至还要每个月自费缴纳 2K+ CNY 的上海灵活就业),但是每天依然有许多琐事缠绕,虽然时间上获得了自由,但是精神上始终处于较为紧绷的状态,每天在家从 0800 起床后便在电脑前工作(注意:这里工作不是指上班)/学习到 2300 才休息,少有彻底放松的时间和想法,也是被经常吐槽为啥时间自由了还不放松放松出门转转,或许可以获得一些新的思路。 这不,在一个合适的契机下,我终于决定丢下工作几天,出门看看,看看屏幕以外的世界。 行程 这一次的黄山之旅从上海出发,途径杭州,并在和大学室友一起(再次)游览了西湖并在杭州停留了一晚后从杭州出发到达黄山脚下住宿,并于第二天正式登山,如果整理时间线的话便是: T - 2 从上海出发到杭州并游玩西湖 T - 1 从杭州出发前往黄山脚下酒店 T 后山上开始爬黄山,晚上住在山上 T + 1 下午下山,并当天驱车直接返回上海 可能由于太久没有户外运动,整体的行程细腻度也随着时间的推移逐渐变得粗糙,到了最后一天由于小腿+膝盖过于劳累部分操作已经开始走形了,照片的拍摄和感悟的记录也变得有些粗糙。 不过问题不大,让我们按照时间顺序开启这次旅行吧! T - 2 上海 🚗 杭州 一路上中规中矩,加满了 98 号汽油的 FK7 挂着 ACC 在沪昆高速上悠闲巡航,期间路过著名服务区「嘉兴」后停车休息放水。 下午到达了杭州之后便拉上前一天 0200 才睡觉的 游西湖。 杭州/西湖其实已经来过多次,不过过去几次来的时候不是热死(某个夏天),就是冻死(某个雨雪交加的冬天),在这种合适的气温下到达的还是唯一一次。 水光潋滟晴方好,山色空蒙雨亦奇。 到达时正直日落,便举起相机,拍了不少照片(这一天所有照片都是用 TTArtisan 50MM F0.95 拍摄) 西湖的景很让人放松,看着太阳缓缓落下,周边漫步的人群,和车速快的感觉可以随时创死人的观光车,那个下午应该是最近两年来放松程度的一个极小值,不再去想为啥某个指标没法达到预期,不再想到接下来该为什么参数优化,也没有随时可能被 @ 的紧绷感,只有我自己,和这西湖(当然,还有那可以随时创死人的观光车)。 随着天色继续变暗,也沿着西湖边走了不下 5km 之后,正好看到一个游船码头,考虑腿已经有点走累了,便是乘船游西湖的时间了,面对 50CNY/人 的船票,放在之前可能我会犹豫不少,但是想在机会成本远大于这船票成本之后,便义无反顾开冲了。 50MM F0.95 的镜头在船上这种极暗环境下依然可以在 2000 以内的 ISO 下提供较快的快门速度,夜间手持拍摄也可以轻松获得低噪点成像效果,夜之眼(虽然说的是 Leica 那款)不是浪得虚名。(当然,如果你没对上焦,那就是另一回事了) 下船之后便继续往北,路过了湖滨银泰in77搓了一顿吃饭皇帝大之后便继续开始逛街,期间路过一家 Sony 店,利用自带 Sony 机身的优势把各种感兴趣的镜头都套了个遍,种草了几个镜头,也拔草了不少镜头。 一天的游玩结束,便是回到酒店开始处理一些相对比较紧急的工作内容,并为第二天的游玩准备。 T - 1 杭州 🚗 黄山 这一天可以浓缩为——爆震不断的调车记录 一早(1030+)杭州出发便开始冲向黄山,杭州的高架设计很糟糕,杭州的交通情况也很糟糕,期间在下匝道时候差点被 SUV 直接往右挤上墙,闪了灯之后 SUV 便开始打着左转向灯继续右转挤我 🤬 要不是考虑(我是出来旅游的|我的车有改装|这是杭州可能交警会和稀泥|我的车左前没有旧伤)我一定地板油把它顶下高架( 犹豫在杭州没有什么机会加油,在高速上距离目的地还有 180+ KM 的时候我的车已经剩余不到一半的油了,便路过一个服务区加油,看到只有 92/95 的选择,便只能选择了 95,想着这段路也就是巡航,涉及不到高负载场景,辛烷值低一点应该影响不大。 但是看到「本站支持反向加油」,以及加完油之后加油的工作人员还帮我擦了一下后视镜之后就开始感觉事情可能不太妙。 2024-12-08 更新:搜索了一下发现「反向加油」似乎指的是加油管足够长,可以不用区分加油口位置加油,而非我想象的油可以卖给油站。 果然,开出服务区上路之后 5KM 不到,我的 Knock Control 数值就从 60% 不到快速爬升到了 90%+,爆震传感器在 5 分钟内就检测到了接近 50 个爆震。 由于爆震集中在 1,2 缸,且之前在上海即使用 98 的油在巡航的时候也有发现容易爆震的情况,当时的猜测是进气岐管供气不均匀或者喷油嘴有积碳之类的导致,便在下一个服务区下了高速,用 Hondata 将 ECU 还原原厂程序继续观察。 换成原厂程序之后再上路,虽然爆震传感器显示没有爆震了,但是 Knock Control 爬升到最高 125% (真就「只要我 IGN 足够推迟,爆震就追不上我」呗?) 路上想了一下犹豫 1,2 缸的爆震导致整车动力下降其实很不划算,不如给 1,2 缸额外修正一下让 4 个缸负载能均匀一些。 于是又在下个服务区下高速,写回之前的程序,并给 1,2 缸额外加浓喷油,并推后点火提前角。 就这么一路调调改改,到达黄山脚下——汤口镇的时候已经到了傍晚。 计划中是到了黄山脚下后可以去「翡翠谷」看看的,这么一路耽误了之后只能作罢,简单吃了顿烤肉之后发现附近有几条省道看上去路况不错,于是开车去溜了溜。 <这里没有视频和照片,因为我在限速 40 的山路上开到了 *** KM/h> 在有了调整后的程序之后犹豫在山路上基本都是全油门冲刺,反而没有遇到太多新的爆震,更加进一步加强了我对于喷油嘴积碳猜测的可能性,由于喷油嘴有积碳导致轻负载(比如高速定速巡航,低喷油量)的时候某几个缸喷油雾化不好导致空燃比偏稀,而之前爆震问题没有这么多可能因为是加的 98 号抗爆震性能非常好,所以没有这么明显。 更绝的是,冲完回来发现南门换乘中心有个中石化,而且有 98 号汽油可以加 😇 T 爬山 ⛰️ 由于酒店门口免费停车,车就丢在了酒店,并在早上坐酒店提供的面包车前往车站。 黄山如果你要爬山,那几乎一定坐官方的大巴到半山腰的索道入口,然后才能选择是爬山还是索道上山。 当然,也有看到过一些野路子的人可以绕过大巴直接走路到索道入口的 参考许多人的做法,这里我的选择是通过云谷寺(后山)上,前山下的模式。 由于对自己体力和接下来的自驾有充足的信心,这里上下山全部选择索道,大巴到了云谷寺索道时候便是索道上山。 随着海拔逐渐升高,山上的雾也越来越大,等到了索道站的上端点的时候,已经几乎没有任何视野了。 当时心想:完了,这还看个锤子,这么大的雾应该这两天都这样的 于是到了山上之后就开始沿着为数不多的两条小路走,穿过了蘑菇亭走到了始信峰。 到了始信峰山顶的时候,视野是这样的。 用 Racechrono 看 GPS 信息是这样的 这么大的雾看个锤子! 但是想着来都来了,(啥都看不到的)照也拍了,那就继续走咯?于是便沿着为数不多的路继续前行 这里我需要引用一张地图方便读者理解行程: 地图来源:https://you.ctrip.com/travels/120061/3928111.html 从始信峰往下走了一点之后,云突然散开了,并保持了接近一整天。 云雾散开之后,整个人精神抖擞,也对接下来的旅程充满了信心,接下来就是愉快的黄山徒步之旅。 这张图片拍摄于上面地图的「黑虎松」和「始信峰」之间,是观测「梦笔生花」的最佳地点。 用小红书的话来说就是——这里非常出片。只要用长焦段+大光圈,可以拍出很不错的压缩感。 此地也可以看到始信峰山顶的人们(200MM 显得有些焦距不足),此时照片中可以看到有大量的落灰,猜猜看是谁没有在雾中拍摄之后把镜头 UV 镜给擦干净? 稍作停留后继续往下便看到了正在修正的北海宾馆,从 可以看到是 「北海宾馆环境整治改造项目室外管网开挖及新建电缆沟工程」 在此地买了一份 10 元 3 个非常难吃的热狗之后便继续上山,去往猴子观海方向。 期间: 「喂,好像线上有个环境有点问题,你在哪儿」 「黄山上面」 「那你看看处理一下? 」 「好,等我开电脑」 在到达猴子观海前的一个观景平台上,看到了一个形状非常独特的石头,你说它像什么呢? 在云雾的效果下,远处的山变得格外好看,让我们来看看带上 Lightroom 的 Dehaze 之后的效果 ƒ/6.3 1/250 46mm ISO100 等到达猴子观海的观景台的时候,雾又非常巧合的出现了,可以看到很多人在雾中不知道在拍些什么,同时占据着有利地形 在雾散开的瞬间可以拍到「猴子」 我就很纳闷了,黄山上怎么这么多奇怪形状的石头 好不容易有个空档人离开了,我倒是要看看这个上面风景有多好! 怎么说呢,脚滑了人就没了,拍了一段 POV 参考如下: 从差点摔死的猴子观海观景台下来之后便到达了当晚要入住的「西海饭店」。 西海饭店于1987年由黄山风景区管委会和香港新利酒店(集团)公司合作兴建,经营21年,已超过设计使用年限,且当时没有设置消防喷淋系统,存在严重的安全隐患,经规划,建设,环保等部门的严格审核,批准西海饭店拆除后在原址重建。 在酒店丢下一些东西之后便继续上路往西海大峡谷方向摸索,不过由于云雾持续变浓,这里并未拍到什么非常好看的照片. 从山上可以看到西海大峡谷小火车轨道 绕了一圈发现雾越来越大之后便开始往回了,在酒店登记入住的时候听前台说丹霞峰一般可以用来看日落,虽然当时山上起着较浓的雾,但是看到天色还比较早,本着「来都来了」的想法便决定爬一波丹霞峰之后再回饭店。 图为爬丹霞峰 1/3 的位置拍摄西海饭店 黄山上松鼠很多,且基本都不怎么怕人(当然,这里是指看到人不会疯狂逃跑并不是指可以摸到) 爬丹霞峰的路上几乎一路无人,到了山顶之后才发现这里聚集了一小撮人尝试在浓雾的情况下「看日落」。 由于雾很大,几乎什么都看不到,但可以想象到的(或者从其他人的分享来看)在没有雾的情况下应该是很美,不过很可惜,直到日落,也全部云雾覆盖。 日落后温度开始快速下降,便开始一路冲刺下山(此处由于丹霞峰下山楼梯非常陡峭),冲刺下山对于膝盖和小腿控制力有非常大的考验。 回到酒店之后本来打算吃在某 App 上看到的「猴轻松」套餐(价格只要 50CNY 不到),但是到了酒店之后发现只有「自助餐」和「点菜」可以选,点菜的价格如下: 想了一下决定换为 180CNY 的自助餐了 考虑到黄山的运输能力,这个价位感觉尚能接受,且味道是真的不错(也有可能因为爬了一天的山基本没怎么正经吃过东西)。 西海饭店可能由于建设时间比较早,有个比较大的 Bug——空调只有中央空调,考虑到这次基本是秋冬季节,房间内的空调只有热风和不出风两个选择,再加上是在山上,所以晚上只有热+干或者干两个选择,体验有点小差(当然,除了空调这一岔以外,其余的体验还是对得起这 1100+CNY 的售价的)。 风景区星级 在休息前,我们来一起看看黄山的一些介绍吧,从 中我们可以看到: 黄山位于中国安徽省南部黄山市境内,南北长约40公里,东西宽约30公里,山脉面积1200平方公里,核心景区面积约160.6平方公里,主体以花岗岩构成,最高处莲花峰,海拔1864.8米。 黄山1982年入选第一批国家重点风景名胜区;1986年黄山被评选为中国十大风景名胜之一,且是中国十大风景名胜中唯一的山岳风景区;1990年12月黄山风景名胜区作为一项文化与自然双重遗产被联合国教科文组织列入世界遗产名录;2004年2月入选世界地质公园。2015年被世界自然保护联盟列为首批最佳管理自然保护地绿色名录。 那么,什么是「国家级风景名胜区」呢?根据 可以看到: 中华人民共和国的风景名胜区,根据其《风景名胜区条例》,是指具有观赏、文化或者科学价值,自然景观、人文景观比较集中,环境优美,可供人们游览或者进行科学、文化活动的区域。 从百度百科中我们可以知道,黄山的「景区级别」是「AAAAA级(世界地质公园)」,要了解这么多 A 代表什么,我们需要知道这些 A 是怎么来的 根据 中我们知道: 国家5A级旅游景区依照《旅游景区质量等级的划分与评定》国家标准(标准中对AAAAA级旅游景区提出了12项条件,即旅游交通、游览、旅游安全、卫生、邮电服务、旅游购物、经营管理、资源和环境的保护、旅游资源吸引力、市场吸引力、年接待游客量及游客抽样满意率),经“全国旅游景区质量等级评定委员会”组织评定,由中华人民共和国文化和旅游部发布。 在 这里可以看到《旅游景区质量等级评定与划分》的评定细则,比较让我意外的是,这里面对于「带动当地社会就业」以及「通过国际互联网宣传」也有明确指标,甚至对于「建成数字虚拟景区」也是一个加分项(这就不得不想到 2010 年上海世博会的时候搞的一个很抽象的线上游世博的游戏了,记得安装包有 2.4G) T + 1 继续爬山 ⛰️ 休息好了嘛?那我们继续! 从西海饭店出来,发现天气不错! 便开始往下山方向出发,我们再来引用一下这里的地图: 路线是:西海饭店 -> 排云楼宾馆 -> 飞来石 -> 光明顶山庄 -> 天海宾馆 -> 海心亭 -> 一线天 -> 莲花亭 -> 玉屏楼宾馆 -> 玉屏索道下山 虽然已经休息了一晚,小腿不再酸痛,但是一路上上下下对于膝盖依然是不小的考验 虽然路上也拍了些许照片,但是可能是由于有一些审美疲劳(不就是山山山么),兴致已经不如第一天高涨(要是这一天继续起雾估计就一张照片都不会拍了) 图为飞来石 视野绝佳的狙击圣地 云海? 运输 在前往光明顶的路上我们聊聊运输,我们知道黄山有一只特别的队伍,被称为——挑夫/挑山工。 接近 20 年前第一次造访黄山时便可看到他们的身影,这 20 年后,依然存在。 进行了一番搜索,得到了如下信息(可靠度未知): 他们的工钱是按斤收费的,一斤八毛钱,为了挑一次能多赚些钱,他们基本上一次要挑一百多斤 基本都是汤口镇的居民 在黄山上我们已经可以不时看到无人机的身影,从官方的报道来看 我们可以得出目前挑山工队伍已经人数逐渐减少(但疑惑的是这近 20 年来居然没有减少到 0),且无人机似乎可以在这个场景下起到部分作用 作为国内山岳型景区典型代表的黄山风景区,为有效解决山地物资运输难题,率先利用大疆运载无人机开辟了无人机运输航线。据统计,目前单机单日最大运输量可达2000斤,累计运输各类物资达19.315万余斤,基本涵盖景区日常经营物资种类需求,有效缓解了山上山下物资运输压力。 每年4月到11月旅游旺季时,日均物资运输量(不含工程物资运输)可达15-20吨,12月到次年3月旅游淡季物资运输需求最多也将近10吨。据悉,黄山风景区目前主要运输物资分为两大块,上行物资补给以米、水、油、面、布草为主;下行运输主要以布草、垃圾运输为主,物资运输需求方主要为山上的酒店住所。 目前黄山景区物资运输主要以“索道+人力二次运输”为主——即山下物资通过索道运输到达索道上站,再通过人工运送到山上各宾馆酒店等。目前在大客流情况下不光物资二次搬运存在挑山工短缺、后继无人,一些建筑物料的运输或索道运输不便的物资,仍需要通过纯人力搬运的方式从山下运往山上。纯人力运输,肩运员必须依托游步道搬运,尤其在旅游高峰时段,共用游步道后,降低了游客的游览体验。此外,近年来由于年龄结构老化,加之愿意从事重体力劳动的青壮年日益减少,黄山肩运员人数大幅锐减,在无新生力量补充的情况下,人数严重不足,运输压力逐年加大。据悉,目前景区肩运员队伍平均年龄达57岁,而景区高峰期有300-400个肩运员,目前剩下不到130人,平均每年人数下降10%。 虽然从 DJI 官网对于 FC30 的参数 结合接近 10W CNY 的价格来看,我个人是有点怀疑这里作为主力运输的实际成本和可能性 最大起飞重量 95 千克(标配货箱,海平面附近) 最大悬停时间(满载)双电(载重 30 千克):18 分钟 那么问题来了,当我们在山上看到这些挑夫的辛劳时,我们的注意力应该是如下问题的中哪一个呢? 有没有更加廉价+高效的方式可以替代这类纯体力工作? 他们的工作是否应该被科技替代(如果有的话)? 以及,他们的收入情况如何,如果收入情况不乐观,为什么他们上了年纪依然需要从事如此体力活? 光明顶 从西海饭店走到光明顶,比想象中耗时更长,走的更累,到了光明顶之后是在黄山上的两天中看到人最多的地方,让我意识到所在景区当中 虽然这里人多,但还是一副老派旅游景区的模样,没有「我在黄山顶上很想你」的字样,没有各种奇怪的卖 xx 特色小吃的地方,只有简单的商店,和到处在休息的人,从西海饭店过来的路上经历了一路上上下下之后终于到达光明顶的时候,我内心想到的最贴切的画面是这样的: Half-Life Hazardous Course 2 爬到楼顶之后的画面 说回光明顶: 光明顶是黄山的主峰之一。位于黄山中部,海拔1860米 [5],为黄山第二高峰,与天都峰、莲花峰并称黄山三大主峰。顶上平坦而高旷,可观东海奇景、西海群峰,炼丹、天都、莲花、玉屏、鳌鱼诸峰尽收眼底。 这是拍摄于 20 年前的照片 当时的光明顶周围还未如此开发 这是鳌鱼峰 这是 20 年前的鳌鱼峰 这是现在 这是 20 年前 沉睡的记忆,都回来了。 下山 过了光明顶开始下山之后游客就开始变多,仿佛大家都是从前山上的(可能因为前一天山上有雾,后山上山的游客没有那么多)。 此时膝盖和小腿已经接近极限,无心观景,只想快速下山,只拍摄了些许照片: 这次爬一线天的人也特别多,考虑到安全因素+腿/膝盖实在不太行了,未能前往(下次或许一定) 经过一番走路(此处各种劳累按下不表),终于能看到下山索道和索道下站了。 坐上索道的时候感觉人终于放松了,不用继续走路了(当然,要是还能多做停留的话,或许休息两天之后再次从前山上山并去看看迎客松啥的或许也是可以接受的,就是个人感觉只是一颗普通的松树而已,不是特别值得一看) 迎客松,学名黄山松,位于安徽省黄山市黄山区黄山玉屏景区,地处海拔1680米处,倚青狮石破石而生,树高10.08米,树围2.24米,树龄约1000年。 [5]迎客松如同热情好客的主人展臂盛迎八方来客,以其矫健挺拔的神姿位列“黄山十大名松”之首,是黄山“五绝”之一,享有“国宝”和“天下第一松”的美誉;是安徽省的象征之一。 [1-4] Wait, 树龄约1000年?那下次再去看看~ 下山之后怒搓一顿徽味楼,便开始了黄山->上海的旅程。 回程路上在上高速前走很多风景优美的省道,不过由于太累了无心拍照并没有留下照片纪念,让我最印象深刻的是「东黄山旅游度假区」附近一条没有建成的索道下端点,从名字上看叫做「云谷索道下端」,看上去黄山试图解决南端汤口镇过于拥挤,所有游客必须从汤口(黄山南部)出发的单点问题,从 文章中也能看到一些信息: 云谷索道下半段项目是黄山东部开发的关键和核心,是连接黄山东大门与黄山主景区的重要交通纽带,也是安徽省2022年重点投资项目和“十四五”时期长三角一体化发展重点支持项目。项目总投资7.6亿元,选址于黄山风景区东北角和黄山区谭家桥镇,索道线路全长6638米,高差1197米,最大运量2100人/小时,计划2025年6月竣工。 晚上 2100 ,终于将 FK7 安全带回,这次黄山自驾旅到这里也就结束了,非常感谢你能看到这里,从文字+图片上和我一起进行了这段神奇的旅程。 那么,我们下次再见!

很多人"改车"缺乏对于车辆的基本认知,许多 “改装店” 的师傅/老板可能连制动力平衡都不太了解,每次问到如果改了前刹之后如何保证制动力平衡相关的问题的时候总是会有类似如下的语句来搪塞,比如: 前刹车本来就承担 80% 的制动力,只改前刹没有影响 改卡钳不会改变制动力平衡,车子有 EBD 会自动调节的 属于是看着大家也不会改完之后严谨的测试 100-0 的距离就使劲忽悠咯?最后改完之后要么制动力比例大幅向前导致制动距离变长,要么就是导致 ABS 抽风导致制动距离变长,当然,换了卡钳之后可能由于各种原因(比如新的卡钳活塞更小,或者新换的刹车片摩擦系数更高等)给车主一个更加有力的 “前段” 体验,让车主感觉,嗯,这个卡钳改的好,刹车力这么强,制动距离肯定变短了的错觉。 有些车主奔着缩短制动距离(或者店家这么忽悠的)去换的刹车卡钳,然后一通操作反而制动距离变长了, 想想就是一个多么讽刺的事情。 不过苦于我不是车辆工程相关专业的人,所以只有一些基本的民科经验,从我自己的改造记录来看得到了一些不一定普适的结论,被我记录成了如下文章: 最近一个偶然的机会在 这里读到一篇对于小白挺友好的文章,在部分借助 ChatGPT 的情况下翻译成了中文,同时加入了部分本田维修手册中的内容以帮助读者理解,希望可以给想动刹车系统且有一定清醒头脑的人一点帮助,译文如下: 防抱死制动系统(ABS)、电子制动力分配(EBD)、车辆动态控制(VDC)、动态后比例分配(DRP)、电子稳定程序(ESP)——这些集成的底盘控制技术不断涌现,不仅仅是在豪华车上。也许我们应该花点时间退一步,看看这些系统的组成和它们的敏感性……在我们对汽车进行可能影响其性能的改动之前。 话虽如此,为了让我们在讨论这些技术时都在同一水平线上,有必要进行一些定义的介绍。这可能有点枯燥,但精彩内容只在一两页之后。 定义 ABS / TCS / ESP 现代 ABS / TCS / ESP 的校准是一个复杂且耗时的过程,涉及设置或“调校”成千上万个算法变量。这些变量定义了基础车辆特性(包括制动系统)、控制限值以及车辆对控制活动的预期反应。因此,一个控制算法可能会在多个应用中使用,但每辆车都需要自己独特的变量集或表格。 在调校这些变量时,基础车辆动态响应是最重要的,从制动系统、悬挂系统和轮胎的角度来看。例如,调校和校准的 ABS 期望车辆对其控制信号有特定的反应。简单来说,如果 ABS 控制算法确定车辆的某个轮子需要减小制动压力,它会根据车轮端制动组件的压力-扭矩和/或压力-体积特性计算出激活压力释放电磁阀所需的时间。在校准系统时,开发工程师基本上需要一个一个地“教会” ABS 这些特性。对 TCS 和 ESP 重复此过程。 由于 ABS / TCS / ESP 使用“学习”逻辑来根据当前控制周期的活动修改下一个控制周期,任何用于“重新学习”这些特性的时间都会在控制周期中产生连锁反应,可能影响稳定性、可操控性和/或制动距离。简而言之,基础制动系统特性(硬件)的变化可能会影响 ABS / TCS / ESP 在这三个方面中的任何一个或全部。 EBD 在这里描述的四种技术中,EBD 可能是最容易定义的,但对基础车辆制动性能的影响可能最为广泛。虽然它不像 ABS、TCS 或 ESP 那样依赖变量,但由于基础制动硬件的变化而导致的任何“重新学习”车辆特性的时间都可能影响车辆在部分制动操作期间的制动系统平衡或偏差。 我是不是刚才读了两遍同样的内容? 听起来像是很多重复,对吧?事实是,这四个控制系统的功能非常相似。当然,每种技术都有许多实现方式,技术供应商也有意这样做,但归根结底,我们处理的是底盘控制系统,它们: “评估”驾驶员的请求 “测量”车辆的实际动作 “计算”两者之间的差异或误差 “互动”以使 #2 等于 #1 现在,我们不再单独对每种技术进行下一步的讨论,而是看看四种技术中最常见的一种——ABS,看看仅仅对基础制动系统的改动如何对 ABS 性能造成完全的影响。一旦我们理解了 ABS 控制对上述项目的敏感性,其他三种技术也就迎刃而解了。开始吧。 ABS 控制的超级慢动作 为了最好地解释 ABS 如何“依赖”基础制动系统,让我们从处理算法的角度来看一个典型的 ABS 事件。 假设你在高速公路上以 75 英里/小时(当然,这可是限速内的哦)行驶,突然前面的卡车将天然泉水的货物撒在了所有三条车道上。这本身并不算太糟糕,除了水还封在 55 加仑的桶里——其中一个肯定会把你的车前脸一套全部干烂。是时候采取紧急措施了。 作为一个受过高速度训练的人,你立即松开油门,踩下离合器(你开的是手排车,对吧?),同时猛踩刹车……但在紧急情况下,你踩得有点太用力了。 同时,ABS 在旁边观察,看着世界飞快地过去,从它的四个车轮速度传感器那里看到一连串的 75 英里/小时信号。我们称之为“观察模式”。然而,当你踩下刹车时,ABS 立即警觉起来,天线竖起,准备行动。毕竟你刚刚踩了刹车,谁知道接下来会发生什么。 经过 50 毫秒(实际上更快——7 到 10 毫秒是典型值——但这样算起来更容易),ABS 再次拍摄车轮速度信息的快照,试图弄清楚发生了什么。这次,车轮速度传感器都报告了 74 英里/小时的速度。经过快速计算,ABS 确定在 50 毫秒内减速 1 英里/小时,车轮必须以 0.91g 的速度减速。因为你开的是一辆跑车,校准系统的工程师“教”了 ABS 你的车可以以这个速率减速,所以 ABS 继续观察。目前没有问题。 然而,接下来的 50 毫秒更有趣。这次,车轮报告的速度是 72.5 英里/小时。现在,这似乎不是一个大的跳跃,但在 50 毫秒的窗口内减速 1.5 英里/小时相当于 1.36g 的减速。这并不令人惊讶,但 ABS “知道”基于这个减速水平,车轮可能开始比应有的滑动更多一点——毕竟,你的车可能没有以 1.36g 的速度减速……任何两者之间的误差都表明滑动。 ABS 现在处于“准备模式”。可能还为时过早介入,因为车轮可能会在下一个 50 毫秒循环中自行加速,但情况肯定看起来不妙! 当第一桶泉水向左向右弹开,离你的车只有几英寸时,你继续踩着刹车,但用力更大。这次,左前车轮速度传感器显示 68 英里/小时——在过去的 50 毫秒内下降了 4.5 英里/小时,或 4.1g 的减速速度。比你快得多地进行数学计算(毕竟你正忙于躲避泉水桶),ABS 迅速得出结论,与此刻的左前车轮不同,车不可能以 4.1g 的速度减速。最好的情况是,在过去的 50 毫秒内,车以 1.0g(或接近)减速,所以“真实”的车速仍然在 71.5 英里/小时左右,尽管左前车轮速度显示为 68 英里/小时——误差为 3.5 英里/小时。 因此,基于 4.1g 的车轮减速、5% 的滑动水平(3.5 英里/小时 71.5 英里/小时)和其他一些未列出的因素,ABS 介入并进入“隔离模式”。(注意,车轮还远未接近“车轮完全抱死”——100% 滑动点。)ABS 首先关闭主缸到左前卡钳的液压管路,将驾驶员隔离开——毕竟,是驾驶员让我们陷入了这个困境。 接下来,ABS 开始在“泄压模式”下工作,释放左前卡钳的多余压力,以使左前车轮重新加速到车辆的实际速度——在这种情况下为 71.5 英里/小时。由于 ABS 知道车轮的减速速度(4.1g)、车的实际速度(71.5 英里/小时)以及左前卡钳/刹车片/刹车盘的压力-扭矩特性(我们稍后会回到这个问题),它可以精确计算打开释放阀的时间以排放多余的压力,留下足够的压力在卡钳中以保持 1.0g 的减速(或接近)。 假设计算出的时间为 10 毫秒(这使后面的数学计算更容易)。砰!阀门打开,压力释放,10 毫秒后关闭,留下刚好足够的压力在卡钳中,使车轮重新加速到正好 71.5 英里/小时,但继续以 1.0g 的速度减速。一切按计划进行。 是时候关闭循环并进入“增压模式”。一旦 ABS 看到左前车轮重新接近“真实”车速,它会慢慢重新施加主缸的压力,以确保使用最大可持续的制动力。为此,ABS 精确计算打开隔离阀的时间,慢慢在左前卡钳中建立压力,直到左前车轮再次开始滑动。它基于——你猜对了——车轮重新加速的速度、车的实际速度以及卡钳/刹车片/转子组件的压力-扭矩特性进行计算。 在我们假设的小世界中,ABS 计算出需要四次 5 毫秒的脉冲才能将车轮压力重新建立到车轮再次开始滑动的点,回到“隔离模式”。 这个循环在所有四个车轮上同时重复,直到驾驶员松开刹车踏板,或者直到车停下来。希望这不包括在 ABS 保持所有四个车轮滑动在 5%-10% 范围内时撞到一两个水桶,允许你尽情转向和躲避,因为桶从你的路径中弹开。快乐的车,快乐的驾驶员。 “大刹车”可能的影响 现在,让我们在相同的情景中增加一个变化:你刚从安装了梦寐以求的大刹车套件的地方回家。你知道,就是那个需要新的 18 英寸轮毂才能避开的 8 活塞卡钳和 16 英寸刹车盘的套件。在停车场里开车时,你简直不敢相信它们在踏板感觉和初段脚感方面的改进。这些东西在高速下一定能像锚一样把车停下来,对吧? 好吧,让我们看看 抵制住在快车道上以三位数速度行驶的诱惑,你再次发现自己在 75 英里/小时的泉水卡车后面。桶飞来飞去,你再次猛踩刹车,但这次你对新硬件能及时减速充满信心。此外,你现在知道 ABS 是如何工作的,所以你踩下踏板,确信你将拥有减速和可操控性。再好不过了。 像情景 1 一样,在最初的 50、100 和 150 毫秒后,ABS 拍摄车轮速度信息的快照,并记录左前车轮的 0.91g、1.36g 和 4.1g。再次 ABS 迅速得出结论,与此刻的左前车轮不同,车不可能以 4.1g 的速度减速。最好的情况是,在过去的 50 毫秒内,车以 1.0g(或接近)减速,所以“真实”的车速仍然在 71.5 英里/小时左右,尽管左前车轮速度显示为 68 英里/小时——误差为 3.5 英里/小时。到目前为止,一切都像上次一样。 然而,事情开始变得有趣。ABS 进入“隔离模式”,关闭主缸到左前卡钳的液压管路,将驾驶员隔离开。接下来,ABS 开始在“泄压模式”下工作,并再次计算出需要 10 毫秒的时间来释放左前卡钳的多余压力,以使左前车轮重新加速到车辆的实际速度——在这种情况下为 71.5 英里/小时。不幸的是,这个计算是基于标准车辆的左前卡钳/刹车片/刹车盘的压力-扭矩特性。让我们在桶滚得更近时简要谈谈这个问题。 压力-扭矩和压力-体积关系 当设计和安装制动系统时,选择的组件提供一定的减速水平,以驾驶员施加在刹车踏板上的一定力。虽然总体关系至关重要,但有许多方法可以实现相同的目标……但根本上,部件是作为一个系统一起工作的。 对于 ABS 工程师来说,最重要的关系之一是卡钳/刹车片/刹车盘的压力-扭矩(P-T)关系。简而言之,对于给定的制动液压力 X,卡钳/刹车片/刹车盘会产生一定量的扭矩 Y。为了讨论方便,让我们假设在我们的示例车辆中,增加 100 PSI 的制动压力会产生 100 英尺-磅的扭矩。 另一个重要关系是系统的压力-体积(P-V)特性。这一关系定义了制动系统在给定压力增加下的膨胀或扩展。让我们也假设我们的标准车辆制动系统每增加 100 PSI 会“膨胀” 1cc。 不幸的是,今天有几种大刹车系统不考虑原始车辆的 P-T 或 P-V 关系……事实上,许多系统故意在这些关系中进行重大改变,以给消费者“增加咬合力”的感觉。虽然好处是更坚固的踏板和相同踏板力下的更高部分制动减速,但代价可能是 ABS 的困惑。 注:关于这里的压力-体积特性,大伙可以参考 RedBlazeUltra 的 中的内容。 回到桶的问题 所以,回到我们的例子——ABS 刚刚计算出需要 10 毫秒的压力减少脉冲来释放多余的压力,留下足够的压力在卡钳中以保持 1.0g 的减速(或接近)……但新的系统由于其减少的 P-V 特性(增加的刚度!)在相同的 10 毫秒窗口内释放的压力是标准系统的两倍(相当于标准系统 20 毫秒的脉冲)!当然,增加的 P-T 特性(更大的刹车盘)也没有帮助,因为现在从车轮上移除的扭矩是标准系统的三到四倍,只留下足够的扭矩以约 0.3g 的速度减速车轮。在 ABS 世界中,这被称为“减速孔”,感觉就像你瞬间松开了制动踏板。 现在,鉴于巨大的压力下降,ABS 迅速进入“增压模式”,试图纠正并将压力重新建立到接近车辆的最大可持续制动力。这需要时间,而时间等于失去的制动距离。 ABS 精确计算打开隔离阀的时间,确定需要四次 5 毫秒的脉冲,就像之前一样。然而,由于新的 P-T 和 P-V 特性,在仅仅两次脉冲后,车轮再次被迫进入滑动状态,使 ABS 感到困惑,不知道发生了什么。由于不期望车轮如此迅速地滑动,ABS 迅速释放压力以试图恢复,但刹车距离已经开始变长了。 注:我个人开过一些改装卡钳的车,在踩出 ABS 的时候车辆明显感觉有抽动感——在稳定踩住刹车踏板到底的时候车辆出现明显滑移(抱死的声音)后突然被松开一下然后又是滑移,可能和这里描述一致 这个循环在所有四个车轮上同时重复,直到驾驶员松开制动踏板,或者直到车停下来……但这次 ABS 总是落后一步。在某些情况下,ABS 对基础制动系统的适度变化具有强大的适应能力,但在极端情况下,可能会对车辆的可操控性(由于控制不良而增加的前轮滑动)产生显著的负面影响,并导致制动距离的增加(多次“补救”减少脉冲)。 因此,你及时停车或躲避一个弹跳的桶的机会减少了。在这个场景中,每一英寸都很重要,你确实需要每一英寸。 TCS / ESP / EBD 影响 上述类比直接适用于 TCS / ESP / EBD 子系统,无一例外。像 ABS 一样,这三种技术在很大程度上依赖于原厂系统的 P-T 和 P-V 特性,任何变化都可能在制动、加速或动态操作中表现出来。 你是在告诉我改大刹车是不好的主意吗? 那么,所有的大刹车升级都会对你最喜欢的车上的底盘控制系统造成破坏吗?不一定。事实上,如果设计和选择得当,这些升级可以在提供这些套件所具有的所有冷却和热稳定性优势的同时,充分利用这些控制技术。 制动系统兼容性的“秘密”在于并没有什么秘密——只需要基本的工程专业知识和设计诀窍。 如前所述,今天市场上有太多的大刹车升级套件不考虑汽车原本的 P-T 或 P-V 特性。事实上,今天有些套件的 P-T 特性比它们所取代的原厂系统的输出增加了一倍以上——“200% 的制动能力”肯定比原厂好,对吧? 在大多数情况下,这些供应商采购大量的大刹车盘和红色卡钳,制造一个适配器支架将它们安装到各种不同的悬挂系统上,并将该套件作为“一刀切”的解决方案进行销售,而没有首先确定该系统是否与剩余的基础制动系统兼容,更不用说电子底盘控制了。当然,这样做快速、经济有效,并且通过你的 18 英寸刹车盘看起来价值百万,但最终性能如何呢?

本文最后更新于 2024-09-09,更新了后轴换用 Dixcel Z 刹车片后的成绩 对于刹车的改装,如我的 一文,是我觉得对于整车影响很大的一个变化,所以我倾向于称为改装/改动而不是升级/优化/强化。 这次改动的列表如下: AP CP9440(CP9440-2 / -3S4L) CZV 330MM*28MM 刹车盘 CZV 钢合头和桥位 Goodridge 十代思域专用钢喉 MX72 刹车片 继续使用 Dixcel 328 刹车油 首先我们回忆一下十代思域刹车的基本信息: 十代思域活塞直径约为56mm,折合成面积为24.6cm2,由于浮动式卡钳的特殊性,其理论面积相当于实际面积两倍,所以理论面积为49.2 cm²。 十代思域前刹车盘直径为 280MM,更换前为 Dixcel FS 盘 然后我们记录一下 CP9440 卡钳的一些重要信息: 47.12cm² Piston Area Suits Ø330.0mm or 315.0mm x 28mm thick disc. 所以根据以上信息我们得出以下推测: 由于刹车盘直径从 280MM 变更为 330MM,如果不考虑刹车盘摩擦系数的变化的话制动力臂变大 活塞直径从 49.2 cm² 变为 47.12cm²,意味着同样制动力下需液量更低(同样制动力有更短的踏板行程) 由于后刹车在本次修改中没有动,所以这一次修改后车辆的刹车情况如下: 前轮 CZV 330MM 刹车盘 + 9440 卡钳 + MX72 刹车片 后轮原厂刹车盘 + MX72 刹车片 修改后可以预计到会导致制动力(Brake bias)分配靠前,于是我在更换前进行了测试。 制动力分配 为什么要额外提制动力分配,玩过 ACC/AC 的同学(尤其是 ACC)应该会对制动力分配有着非常敏感的认知(除非你是那个从来不看车辆 Setup 就直接开始猛冲的男人),以我最喜欢的 BMW M4 GT3 为例,车辆 Safety Preset 的制动力分配是 59%,Aggresive Preset 的制动力分配是 54% 左右,我们从 Safety Preset 开始仅仅将制动力分配从 59% 改为例如 52%,在赛道中即可明显感觉到入弯更加灵活(而不是猛推头)。 现在的车辆普遍带有 ABS + EBD(电子制动力分配),但是在我的实测中即使不同搭配的刹车片和刹车盘都会带来肉眼可见的制动距离变化,这里容我引用一下我自己的 中的记录: A052 轮胎(225/45/R17) + MX72 前刹车片(原厂前刹车盘) + 原厂后刹车片:31.97M A052 轮胎(225/45/R17) + Dixcel Z 前刹车片(原厂前刹车盘) + 原厂后刹车片:34.64M AD09 轮胎(235/40/R18) + Dixcel Z 前刹车片(Dixcel FS 前刹车盘) + 原厂后刹车片:35.09M AD09 轮胎(235/40/R18) + MX72 前刹车片(Dixcel FS 前刹车盘) + MX72 后刹车片:31.13M 注:这里多次测试并非同天测试,但都选择了同路面,温度接近且路面情况差不多的场景测试,建议作为定性参考而非定量比较。 可以看到第三个记录 「Dixcel Z 前刹车片(Dixcel FS 前刹车盘) + 原厂后刹车片」前面换成了高摩擦系数的刹车片和刹车盘,但是后轮制动力完全不变的情况下(相当于制动力分配非常靠前),做到了一个非常差的制动距离。 所以对于刹车系统的改动一定要仔细考虑制动力分配的问题,更换高摩擦系数的盘/片或者更大的刹车盘都会提升一个轴的制动力比例。 更换前的测试 在更换 AP9440 前,刹车系统情况如下: 前轮 Dixcel FS 刹车盘 + MX72 刹车片 后轮原厂刹车盘 + MX72 刹车片 Dixcel 328 刹车油 测试温度 34 度,油箱容量 2/3,后备箱中没有备胎(-9KG),但是有 27KG 的 AP9440 刹车套装(作为 BOP 🤣),温胎胎压 2.2(42度),测试流程如下: 在一个单向 600M 左右的沥青路面进行测试(和上文记录中是同一个位置) 加速到表显 120KM/h 后全力制动到车辆完全停止 掉头以 80KM/h 左右的速度回到测试开始点停车测温并检查成绩 胎面状态如下: 以上步骤循环六次,测试完成之后热胎胎压 2.4(63 度),成绩如下: 制动距离 前刹车盘温度 后刹车盘温度 33.42M 144.3 忘了测了 32.70M 218 忘了测了 31.79M 257 忘了测了 33.71M 273 183 32.85M 330 208 34.35M 333 221 AP9440 卡钳 开箱的过程总是非常快乐的,这里就主要放点照片了 由于我的暴力使用,前刹车盘拆除起来有点费力 🤣 新盘+合头上车: 这个卡钳上的弹簧片是让我最不爽的一点,感觉很容易导致拖刹或者在刹车片剩余不多的时候导致偏磨,但是问了一下安装的师傅表示如果不用这个弹簧片的话刹车片可能会往上浮(刹车片只有下半部分接触刹车盘)🤔,这里有待继续研究。 装车前后对比: Bedding In 装了卡钳就开始爆踩容易导致钱包穿孔。 安装了卡钳之后就是磨合的过程了,虽然用的是竞技卡钳,这里我还是参考 AP 的 Road 的用法 For road car installations the process needs to be as follows:- For the first 10 miles, light braking from 50/60 mph down to 30 mph if possible in blocks of 5. Do not attempt any high-speed stops down to zero at this point, as only the faces will heat up with the mass remaining cool along with the mounting area. For the next 100 miles increase the braking pressures similar to stopping in traffic, again avoiding if possible full stops from above 70 mph. By now the area around the mounting bolts should be a light blue temper colour. This is a good indication that the correct heat soak has been achieved. For the next 100 miles gradually increase the braking effort after this full power stops can be used. The disc should now be an even dark to light blue temper colour, depending on the pad type and the braking effort being used during the process. This process must be completed before any race circuit use. 更换后的测试 在已经完成了至少 300KM 的街道磨合以及较高负载的多次 110KM/h -> 50KM 磨合之后,感觉制动脚感已经逐渐稳定,挑了气温天气相仿的一天在同一个路段进行了测试,测试流程和更换前完全一致,且这一次额外测量了卡钳的温度,测试数据如下: 制动距离 前刹车盘温度 前卡钳温度 后刹车盘温度 35.25M 110 81 115 35.44M 124 93.5 131 35.14M 137 112 141 34.79M 171 119.3 161 34.37M 168 127.7 175.5 34.69M 198.9 137.2 174 35.30M 188.9 141.6 176 2024-09-09 更新,后轴换成了摩擦系数很高的 Dixcel Z 刹车片之后在未完全磨合的情况下的测试: 制动距离 前刹车盘温度 前卡钳温度 后刹车盘温度 33.86M 138 83 166 34.63M 173 98.2 189 34.12M 188.5 114.4 215 33.92M 174.6 123.4 196 35.37M - - - 35.43M - - - 33.06M 170.6 125.3 219 可以看到后轴由于 Dixcel Z 刹车片摩擦系数很高,升温快很多,而且制动距离已经从稳定的 35 米下降到了 34 米附近,倒数两次 35+M 由于选择的刹车地面情况不太好,车辆出现了弹跳和轮胎大幅锁死,成绩无效。 同时也发现在刹车片没有完全磨合的情况下制动距离不太稳定,还是得多磨合磨合之后再进行测试。 我们把原厂卡钳(前更换 Dixcel FS 刹车盘,前后 MX72 刹车片)的数据贴过来对比着看: 制动距离 前刹车盘温度 前卡钳温度 后刹车盘温度 33.42M 144.3 忘了测了 32.70M 218 忘了测了 31.79M 257 忘了测了 33.71M 273 183 32.85M 330 208 34.35M 333 221 可以得出以下结论: 更换了 AP9440 + 330MM 的刹车盘之后,散热性能或者热容量得到了很大的提升,即使连续 7 脚 100-0km/h 的测试盘的温度都没有超过 200 摄氏度,而原厂尺寸的 Dixcel FS 盘在第 5 脚就已经超过了 330 度了 AP9440 可以得到更加稳定的制动距离 从后刹车的温度以及平均制动距离稳定提升了(变长)大约 2M 可以看出制动力比例确实往前了不少,前轮制动力过大,后轮承受了更少的制动力 MX72 的摩擦系数为: Average friction coefficient/0.37~0.47 对于制动分配的问题,我在考虑给后轮更换为摩擦系数更高的 Dixcel Z 刹车片看看是否会有缓解,Dixcel Z 刹车片标定摩擦系数如下: Max. μ:0.67/Ave. μ:0.57/Min. μ:0.50 当然,这样的代价可能就是刹车的初段会变得更加不好细腻控制,或许能前轮找到一个摩擦系数特别低(比如 0.3~0.35)但是同样耐高温(标定至少得到 800 度)的刹车片是更优的选择。 意料之外的异响 我上传到了 Bilibili ,可以参考: 这个问题非常神奇,感觉和 Model 3 轮毂高度相关,之前使用 Dixcel FS 的时候也是遇到过这个声音(当时是激烈驾驶之后方向打到底的情况下会响,现在是热车后直线刹车等情况也会响) 目前除了更换轮毂以外还没有明确的解决思路。 2024-09-15 更新:定位到声音来源为螺帽和轮毂接触面以及轮毂和合头的接触面,在螺帽锥面以及轮毂法兰面上抹上了一些消音膏之后声音大幅缓解(只有很少的场景,比如冷车上车时会响一下) 更换卡钳之后的体感变化 在上文中我提到,在更换卡钳之前通过活塞面积的计算可以预测出更短的制动踏板行程,在更换完成之后确实也是符合预期。 换上了卡钳之后最明显的提升就是”坚硬的脚感“,如果你骑过带油碟的自行车/电动自行车的话就会很容易明白我想表达的意思,如果没有的话,那我尝试用文字描述一下: 原厂刹车系统+MX72 刹车片的情况下大约刹车踏板有 10% 的体感纯空行程(虚位),随后随着脚上用力变大,踏板行程越来越深,感觉脚上的压力在 80% 左右的位置开始指数级变大,然后触发 ABS,此时踏板行程可能是 85~90%。 更换了卡钳之后大概有 5% 的体感纯空行程,然后就能明显感觉到制动力开始线性增加,脚上会感觉踏板行程没有大幅变大,但是脚上的压力在线性变大,感觉像踩在了一块石头上,但是对着这个体感行程变化不大的石头逐渐用力会感觉到制动力逐渐提升,且提升幅度和脚上压力高度正相关,只要脚上用力稳定,踏板给到的反作用力可以帮助稳定踏板力,间接稳定制动力,持续施加制动力,直到 65~75% 左右的位置即达到轮胎锁死边缘,触发 ABS。 目前我没有使用任何油门增高块之类的东西,即使整个踏板行程变短了,体感对于跟趾动作几乎没有影响(而且由于踏板在行程变化不大的情况下反作用力稳定,所以跟趾稳定性反而更高了),这一点打消了在改动之前的疑虑。 Wrap up 我们总结一下,这一次改动中,获得的收益是: 刹车踏板变得更加 “坚硬” 了,脚感提升了很多 从原来 280MM 刹车盘换成了 330MM 刹车盘并且更换了卡钳之后热容量提升了(主要是刹车盘带来的,其次是卡钳),散热效果应该也有了提升(主要是卡钳带来的),所以整个前轴温度压力更小了 同时引入了新的问题有: 前后都使用 MX72 刹车片的情况下, 由于 MX72 刹车片的摩擦系数问题,在前轮这样改动后显得后轴制动力不够(导致制动距离反而变长),可能需要更换更高摩擦系数的刹车片解决(或者后轮加大盘,但是 CZV 只有一个 326MM 的刹车盘,感觉又有点太大了) 轮毂和合头之间的异响是意料之外的,暂时还没解决的思路,不过好在应该没有安全隐患,属于偏重要但不紧急的问题 以上,便是我对于十代思域 AP9440 安装/测试记录,希望可以给到和多年前的我一样同样在观望或者担心奇怪影响的车友一些思路和参考。

十代思域通过拔除原厂避震塔顶 Guide Pin 推倾角记录

2024/07/07 06:00 · Posts on Nova Kwok's Awesome Blog

偶然在看十代思域 HKS HIPERMAX S 避震说明书时发现避震塔顶其实有可调空间,在说明书上说可以最大额外获得 1 度的倾角: 这对于不想额外增加设备想获得倾角的我来说喜出望外,增加了倾角可以有效增加劈弯时前轴抓地力,同时缓解因为换了 Model 3 轮毂+235/40/R18 轮胎之后在避震行程较大时磨到内衬的问题(见 「 」 一文)。 仔细研究了一下发现思域的塔顶并非直接螺丝孔,而是每个孔位的地方有一个小的槽可以用来调整塔顶上固定点的位置: 上图是已经拔去了导向销的状态(那个空洞),可以看到螺丝位有可调的空间。 看了一下维修手册上,也确实有针对这个的说法: 目前唯一的阻碍就是那个"导向销",说明书的说法是「仅用于工厂组装,拆卸后可报废」。 那就直接拆,这里我的做法是参考 YouTube: 的做法,直接用钳子夹住然后左右摇动,辅以锤子左右敲击,需要大力出奇迹,然后导向销就下来了: 拆除了导向销之后直接将避震顶端推到最内侧锁紧螺丝,理论获得了原厂塔顶给到的最大倾角。 避震以及车辆信息 详情可以参考: 简单来说,使用了 ST X 避震,调节参数如下: 位置 数据 前轴 Measurement A (李子串紧固螺丝到避震弹簧和调节器交界处的距离) 19cm (已经达到说明书上建议的 17cm-19cm 中最大值) 前轴 Measurement B (轮毂中央到翼子板边缘距离) 34cm 前轴翼子板距离地面距离 65cm 四轮定位 为了客观评价这个变化,在拆除导向销前后进行四轮定位测量数据 拆除前数据 拆除后数据 可以看到前轴的 Camber 从: 左 -0°22’ 放大到了 -0°39’,放大了 17’ 右 -0°50’ 放大到了 -1°11’,放大了 21' 附上说明书四轮定位数据供参考: 可见这里即使车高降低了,顶部调整了倾角之后的增益依然不大,基本符合原厂维修手册的 19’ 的调整范围。 要大倾角还是得鱼眼塔顶哇( 以上,希望给有同样想法的同学一点参考。

又到苏 U/E 的地盘——一日西山岛自驾旅记(上)

2024/06/27 13:00 · Posts on Nova Kwok's Awesome Blog

缥缈峰——在我初高中时期对公路自行车较为迷恋的时候常常在一些骑行群中看到作为大家的周末休闲骑目的地,可惜当时没有公路自行车,也没有勇气骑行 100+ 公里前往。 缥缈峰是江苏省苏州市西山的最高峰,位于西山岛西南部,海拔336.6米,为太湖七十二峰之首。可登顶瞭望太湖风光,从水月坞、涵村坞有盘山公路和步行道上山。 最近一个偶然的机会听到好友提起西山岛,便重新查阅了这个岛的信息: 西山全名为洞庭西山,古称包山,位于江苏省苏州市吴中区的太湖之中。面积达79.8平方公里,是中国最大的湖岛,现有中国内湖第一长桥——太湖大桥与西山之相连。岛上的缥缈峰海拔336.6米。 距离上海不是很远,单程大约 1.5hr 可达,甚至可以当天往返,便在某天上午睡醒之后,开启了这次说走就走的旅行。 由于 Civic Hatchback 是 Hatchback 结构,考虑到提前调研的岛上风光可能有傍晚环岛骑车兜风的环节,于是决定使用 C(ar)+B(ike) 的模式出门。 虽然实际上这一次自行车使用率为 0% 这次虽然带上了无人机,但是由于忘记带 GoPro,无人机和 GoPro 共用一张存储卡,所以这次 GoPro 没拍成,无人机也没飞到 🫠 凤凰台 上岛前的第一站便是凤凰台,不过这里已经被人盘下开启了一个店,虽然院子门口写着商业区域游客请勿入内,但是还是本着——来了我可以是游客也可以是客户的心态直接冲了进来。 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 面朝渔阳山景区可以看到来来往往的车辆 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 西山岛 湖边 上岛之后便开始顺时针游览,第一站便是可以在湖边停车的一个不知名路边,大概在这个位置: 由于从上海出发时已经接近中午,到达时差不多是最热的时候,顶不住太阳的暴晒于是决定在车内吹会儿空调看看太湖,以及,对面的东山岛。 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 石公山 位于西山岛的右下角,是许多游记中推荐去处。 在湖边休息了一段时间之后便开始前往第一个「景点」——石公山,门票 50CNY。 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 石公山的感觉其实有点像上海的佘山,只不过更加高一点,山上的路稍微窄一点,由于是下午最热的时候,顶着太阳(走楼梯)爬山的体验总归不是很好,好在山上有几个小店可以买水/冰淇淋(且溢价不高)不至于直接渴死在山上。 「一线天」应该是我个人感觉石公山上最有意思的地方了,因为没有护栏,没有抓手,不能给自己赋能,而且在往上爬的过程中会感受到两侧石头给自己带来的压力,且由于坡度 非 常 陡,感觉这个地方如果在往上爬的过程中不慎后仰的话是真的会死人的,脚滑往前摔的话牙也是有可能在石头上磕掉的。 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 「一线天」俯视图如下,照片中可能看不太出来这个陡峭程度,但是相信我,你绝对不会想从上往下走的 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 穿过了「一线天」之后就到达了山顶,山顶的视野非常不错,凉爽的风瞬间让爬山时的燥热烟消云散,在太阳暴晒的天气下,如果能直接缆车到达山顶的话绝对是个 5 星避暑好去处。 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 走到山脚准备离开的时候为了找厕所发现出口右侧还有很大一片地方可以直接来到湖边,适合散步,拍照 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 日落 离开石公山的时候已经差不多 1700,根据之前看到的一些游记决定继续顺时针环岛走到岛的东侧看看是否可以看到日落,本来根据计划是直接导航到「湖与 Coffee」的,但是在去的路上发现路边有多处地方可以停车并躺在草坪上露营,于是我也停下车,拍了一些照片。 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 湖与 Coffee 实在是饿得受不了了,于是去「湖与 Coffee」买了点吃的,比如这个 228CNY 的拼盘 确实没看到别的啥看上去好吃的,这是唯一一个看上去比较正常的,有一说一,除了虾饼以外其他的都确实好吃,就是太贵 iPhone 湖与 Coffee 的风景确实不错,这里是一个民宿+餐厅的结合体,二楼餐厅的风景和单体大玻璃看上去非常赞。 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 返程 吃饱喝足后时间已经来到了 1940,一边吃着拼盘一遍看着日落的确别有一番风味(就是拼盘太贵) 下楼再拍了两张照片: SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 照片中的二楼就是上文中吃饭+拍照的地方 SONY ILCE-7M3 + Tamron 28-200mm F/2.8-5.6 Di III RXD 时间不早了,是时候回上海了,接下来就又是一段 ACC + LKAS 在高速上巡航的里程。 此处省略使用 ACC 巡航的细节… 遗憾 & 后记 看本文的标题叫做「又到苏 U/E 的地盘——一日西山岛自驾旅记(上)」,那么自然是有一些遗憾在这一次旅行中,例如: 无人机没有飞成 没能登上缥缈峰 有个大家推荐的苏式面馆没有能去吃成 没能在傍晚骑上自行车来个湖边骑行 没能发现什么跑山路线 对了,岛上和上岛前的路上(以及太湖服务区)似乎均没有能加 98 号汽油的加油站,如果你的车有这个需求的话,记得提前加满加油。(不然就会像我一样快上岛的时候发现车已经快没油了,然后额外浪费了 40+ 分钟绕路找加油站) iPhone 那么,让我们期待下一次说走就走的旅行再一次经过这里吧~ 也许是另一个季节的时候啦!

借助 pandas, numpy 自动化修正 Hondata AFM 曲线

2024/06/05 00:00 · Posts on Nova Kwok's Awesome Blog

在之前的文章 「 」 中我们知道,如果更换了进气套件或者滤芯的话,可以通过 Hondata 对 AFM 曲线进行修正。 例如,你录制了一段 Datalog,通过 Hondata 的 XY 图 Plot 出来 AFM.v 和 S.Trim 发现是下图这样的话,你就会知道在 1.1v 左右的位置有个坑(最低到了 -8%),说明标定中对应的电压值对应的进气量偏高(电脑会根据标定喷油,然后发现喷浓了 8%,于是通过 S.Trim 减少 8% 的喷油量) 这个时候我们需要找到 AFM 表格并修改对应区间的数据,减少 8% 左右来修正这个情况: 然后通过更多的不同工况的 Datalog 进行反复路试+修正,获得一个最平滑的 AFM.v - S.Trim 曲线,以上过程被称为——AFM 修正,这也是程序调整中非常重要的根基。 如果 AFM 修正不正确车辆的响应会比较奇怪,且在 WOT(全油门) 下由于不走 S.Trim 所以会导致 AF(实际空燃比) 没法稳定跟上 AFCMD(电脑请求空燃比) 如果上文内容你不太能理解的话,建议回顾一下我的「 」这篇文章。 好,目前我们已经知道了整个流程,但是手动修 AFM 曲线听上去就过于「匠人」了,我们仔细想一下 AFM.v - S.Trim 图,其实就是把 Datalog 中每一帧的 AFM.v 和 S.Trim 给放在了散点图上,且: 数据越多越好,因为如果数据量过少的话可能只是偶尔的抽动,我们应该忽略 时间变量在这里没有意义,因为只是 AFM.v - S.Trim 的关系 明白了上面的点之后我们就会发现,如果我们要用 Hondata 手动修正 AFM 曲线的话,我们需要有多段不同的 Datalog,或者一个 Datalog 中覆盖大量的场景,考虑到我们不是在台架上进行实验,更加真实的情况是有多段不同时间录制的 Datalog,那我们就需要手动一个个看对应 Datalog 的 XY 图,并尝试找出共性,并加以修正。 这太慢了! Automated Calibration 我们需要一个可以自动化分析 Datalog 并给 AFM 修正的能力,流程分为如下几步: 获得结构化的 Datalog 数据并叠加在一起 通过大量数据的均值获得每个电压值下对比 0 的偏差量 反向对 AFM 表格进行修正 Export to CSV 好在 Hondata 给我们提供了导出为 CSV 的功能: 导出之后的 CSV 除了行首有些垃圾行以外,每一行就是每一帧的所有传感器的数据: 这样我们便可以导出多个 Datalog 的 CSV 并合并在一起,获得大量不同时间,不同工况的 Datalog 数据。 CSV to Dataframe 在上一步中我们拥有了大量的数据,现在我们就可以尝试导入到 Dataframe 中进行分析了: Fuel Status 为 2 表示闭环状态,我们只希望修正闭环(非 WOT(全油门))部分 import pandas as pd import matplotlib.pyplot as plt import numpy as np df = pd . read_csv ( 'exported.csv' ) # Only reserve the rows with 'Fuel Status' = 2 df = df [ df [ 'Fuel Status' ] == 2 ] x = df [ 'AFM.v' ] y = df [ 'S.TRIM' ] plt . scatter ( x , y , label = 'Data Points' , color = 'blue' , alpha = 0.5 ) bins = np . linspace ( x . min (), x . max (), 100 ) bin_centers = 0.5 * ( bins [: - 1 ] + bins [ 1 :]) mean_values = [ y [( x >= bins [ i ]) & ( x < bins [ i + 1 ])] . mean () for i in range ( len ( bins ) - 1 )] plt . plot ( bin_centers , mean_values , label = 'Mean Curve' , color = 'red' , linewidth = 2 ) plt . xlabel ( 'AFM.v' ) plt . ylabel ( 'S.TRIM' ) plt . legend () plt . title ( 'Scatter Plot and Mean Curve of AFM.v vs S.TRIM' ) plt . show () 这里我们使用 np.linspace 把数据分成 100 个分箱并计算平均值,然后画出平均值曲线 是不是看上去和 Hondata 的 XY 图一样? Import AFM and adjust 有了上面的 Dataframe 和对应的平均值之后,我们可以导入目前已有的 AFM 表格的 电压-进气量 关系,在 Hondata 中,AFM 表格有 64 列,下面 k_values 为电压值, v_values 为标定进气量(g/s): k_values = [ 0.00 , 0.00 , 0.35 , 0.43 , 0.51 , 0.59 , 0.66 , 0.74 , 0.82 , 0.90 , 0.98 , 1.05 , 1.13 , 1.21 , 1.29 , 1.33 , 1.37 , 1.41 , 1.45 , 1.52 , 1.56 , 1.60 , 1.68 , 1.72 , 1.76 , 1.84 , 1.88 , 1.91 , 1.99 , 2.03 , 2.07 , 2.15 , 2.19 , 2.23 , 2.30 , 2.34 , 2.38 , 2.46 , 2.50 , 2.54 , 2.62 , 2.70 , 2.77 , 2.85 , 2.93 , 3.01 , 3.09 , 3.16 , 3.24 , 3.32 , 3.40 , 3.48 , 3.55 , 3.63 , 3.71 , 3.79 , 3.87 , 4.02 , 4.18 , 4.34 , 4.49 , 4.65 , 4.84 , 5.00 ] v_values = [ 0.00 , 0.00 , 0.00 , 0.00 , 0.03 , 0.08 , 0.17 , 0.28 , 0.43 , 0.62 , 0.87 , 1.16 , 1.48 , 1.93 , 2.34 , 2.60 , 2.88 , 3.25 , 3.58 , 4.31 , 4.70 , 5.10 , 5.98 , 6.46 , 6.97 , 8.11 , 8.55 , 9.19 , 10.54 , 11.25 , 12.00 , 13.18 , 14.00 , 15.15 , 17.04 , 18.05 , 19.44 , 21.63 , 23.24 , 24.45 , 27.06 , 29.89 , 32.82 , 35.89 , 39.23 , 42.77 , 45.62 , 49.55 , 53.67 , 57.47 , 61.44 , 66.23 , 71.30 , 76.67 , 80.70 , 86.52 , 92.63 , 105.67 , 119.80 , 135.13 , 151.92 , 170.44 , 196.45 , 220.14 ] df_kv = pd . DataFrame ({ 'AFM.v' : k_values , 'AFM Intake' : v_values }) 然后我们使用线性插值的方式将上文提到的 Mean 曲线插值在 k_values 上,并找到和对应的 v_values 的差异: expected_values = np . interp ( df_kv [ 'AFM.v' ], bin_centers , mean_values ) expected_values = np . nan_to_num ( expected_values , nan = 0.0 ) 之后我们根据百分比计算需要调整的差异量即可: df_kv [ 'AFM Intake Adjusted' ] = df_kv [ 'AFM Intake' ] * ( expected_values / 100 + 1 ) print ( df_kv . to_string ()) 看,这样就可以啦! AFM.v AFM Intake AFM Intake Adjusted 0 0.00 0.00 0.000000 1 0.00 0.00 0.000000 2 0.35 0.00 0.000000 3 0.43 0.00 0.000000 4 0.51 0.03 0.029600 5 0.59 0.08 0.078933 6 0.66 0.17 0.167733 7 0.74 0.28 0.276267 8 0.82 0.43 0.424267 ... 43 2.85 35.89 35.015254 44 2.93 39.23 38.783246 45 3.01 42.77 41.663699 46 3.09 45.62 44.038772 47 3.16 49.55 48.285323 48 3.24 53.67 53.670000 49 3.32 57.47 55.442512 ... 我们还可以将调整前后的曲线叠加在一起展示出来: plt . plot ( df_kv [ 'AFM.v' ], df_kv [ 'AFM Intake' ], label = 'Original' , color = 'blue' ) plt . plot ( df_kv [ 'AFM.v' ], df_kv [ 'AFM Intake Adjusted' ], label = 'Adjusted' , color = 'red' ) Hondata Auto AFM Correction Website 有了这样的想法之后,我便想到制作一个简单的网站,用户只要上传 Datalog 的 CSV 文件和当前的 AFM 表格即可自动计算,于是继续和 的御用前端 合作,制作了 Hondata Auto AFM Correction 网站,域名是: 欢迎大家来体验!

Automating Hondata AFM Curve Correction with Pandas and Numpy

2024/06/04 23:50 · Posts on Nova Kwok's Awesome Blog

In the previous article, “ ”, we learned that if you replace the intake kit or filter, you can correct the AFM curve using Hondata. For example, if you record a Datalog and plot AFM.v and S.Trim using Hondata’s XY plot, and you see something like the figure below, you will know that there is a dip around 1.1v (down to -8%), indicating that the intake volume corresponding to the voltage value in the calibration is too high (the computer injects fuel based on the calibration, then finds that it is 8% too rich, so it reduces the fuel injection by 8% through S.Trim). At this point, we need to find the AFM table and modify the data in the corresponding range, reducing it by about 8% to correct this situation: Then, through more Datalogs under different conditions, we perform repeated road tests and corrections to obtain the smoothest AFM.v - S.Trim curve. This process is known as AFM correction, which is also a very important foundation in program adjustment. If the AFM correction is not done correctly, the vehicle’s response will be quite strange, and at WOT (Wide Open Throttle), because it does not use S.Trim, the AF (actual air-fuel ratio) will not be able to stably follow the AFCMD (computer requested air-fuel ratio). If you do not quite understand the above content, I suggest you review my article “ ”. Alright, now we know the whole process, but manually correcting the AFM curve sounds too “artisan”. Let’s think carefully about the AFM.v - S.Trim plot, which is actually just putting the AFM.v and S.Trim of each frame in the Datalog on a scatter plot, and: The more data, the better, because if the amount of data is too small, it may just be occasional fluctuations, which we should ignore. The time variable is meaningless here because it is just the relationship between AFM.v and S.Trim. Understanding the above points, we realize that if we want to manually correct the AFM curve using Hondata, we need multiple different Datalogs, or a single Datalog covering a large number of scenarios. Considering that we are not conducting experiments on a test bench, the more realistic situation is to have multiple Datalogs recorded at different times. We then need to manually look at the XY plot of each corresponding Datalog, try to find common patterns, and make corrections. This is too slow! Automated Calibration We need a capability to automatically analyze Datalogs and correct the AFM. The process is divided into the following steps: Obtain structured Datalog data and overlay them together. Obtain the deviation amount from 0 for each voltage value through the mean of a large amount of data. Reverse correct the AFM table. Export to CSV Fortunately, Hondata provides the function to export as CSV: The exported CSV has some junk rows at the beginning, but each row is the data of all sensors for each frame: Thus, we can export multiple Datalog CSVs and merge them together to obtain a large amount of Datalog data recorded at different times and under different conditions. CSV to Dataframe In the previous step, we have a large amount of data. Now we can try to import it into a Dataframe for analysis: Fuel Status of 2 indicates closed-loop status. We only want to correct the closed-loop (non-WOT) part. import pandas as pd import matplotlib.pyplot as plt import numpy as np df = pd . read_csv ( 'exported.csv' ) # Only reserve the rows with 'Fuel Status' = 2 df = df [ df [ 'Fuel Status' ] == 2 ] x = df [ 'AFM.v' ] y = df [ 'S.TRIM' ] plt . scatter ( x , y , label = 'Data Points' , color = 'blue' , alpha = 0.5 ) bins = np . linspace ( x . min (), x . max (), 100 ) bin_centers = 0.5 * ( bins [: - 1 ] + bins [ 1 :]) mean_values = [ y [( x >= bins [ i ]) & ( x < bins [ i + 1 ])] . mean () for i in range ( len ( bins ) - 1 )] plt . plot ( bin_centers , mean_values , label = 'Mean Curve' , color = 'red' , linewidth = 2 ) plt . xlabel ( 'AFM.v' ) plt . ylabel ( 'S.TRIM' ) plt . legend () plt . title ( 'Scatter Plot and Mean Curve of AFM.v vs S.TRIM' ) plt . show () Here we use np.linspace to divide the data into 100 bins and calculate the average value, then plot the mean curve. Doesn’t it look like Hondata’s XY plot? Import AFM and adjust With the above Dataframe and corresponding mean values, we can import the existing AFM table’s voltage-airflow relationship. In Hondata, the AFM table has 64 columns. Below, k_values are the voltage values, and v_values are the calibrated airflow (g/s): k_values = [ 0.00 , 0.00 , 0.35 , 0.43 , 0.51 , 0.59 , 0.66 , 0.74 , 0.82 , 0.90 , 0.98 , 1.05 , 1.13 , 1.21 , 1.29 , 1.33 , 1.37 , 1.41 , 1.45 , 1.52 , 1.56 , 1.60 , 1.68 , 1.72 , 1.76 , 1.84 , 1.88 , 1.91 , 1.99 , 2.03 , 2.07 , 2.15 , 2.19 , 2.23 , 2.30 , 2.34 , 2.38 , 2.46 , 2.50 , 2.54 , 2.62 , 2.70 , 2.77 , 2.85 , 2.93 , 3.01 , 3.09 , 3.16 , 3.24 , 3.32 , 3.40 , 3.48 , 3.55 , 3.63 , 3.71 , 3.79 , 3.87 , 4.02 , 4.18 , 4.34 , 4.49 , 4.65 , 4.84 , 5.00 ] v_values = [ 0.00 , 0.00 , 0.00 , 0.00 , 0.03 , 0.08 , 0.17 , 0.28 , 0.43 , 0.62 , 0.87 , 1.16 , 1.48 , 1.93 , 2.34 , 2.60 , 2.88 , 3.25 , 3.58 , 4.31 , 4.70 , 5.10 , 5.98 , 6.46 , 6.97 , 8.11 , 8.55 , 9.19 , 10.54 , 11.25 , 12.00 , 13.18 , 14.00 , 15.15 , 17.04 , 18.05 , 19.44 , 21.63 , 23.24 , 24.45 , 27.06 , 29.89 , 32.82 , 35.89 , 39.23 , 42.77 , 45.62 , 49.55 , 53.67 , 57.47 , 61.44 , 66.23 , 71.30 , 76.67 , 80.70 , 86.52 , 92.63 , 105.67 , 119.80 , 135.13 , 151.92 , 170.44 , 196.45 , 220.14 ] df_kv = pd . DataFrame ({ 'AFM.v' : k_values , 'AFM Intake' : v_values }) Then we use linear interpolation to interpolate the mean curve mentioned above onto the k_values and find the difference with the corresponding v_values : expected_values = np . interp ( df_kv [ 'AFM.v' ], bin_centers , mean_values ) expected_values = np . nan_to_num ( expected_values , nan = 0.0 ) Next, we calculate the adjustment amount based on the percentage difference: df_kv [ 'AFM Intake Adjusted' ] = df_kv [ 'AFM Intake' ] * ( expected_values / 100 + 1 ) print ( df_kv . to_string ()) See, it’s done like this! AFM.v AFM Intake AFM Intake Adjusted 0 0.00 0.00 0.000000 1 0.00 0.00 0.000000 2 0.35 0.00 0.000000 3 0.43 0.00 0.000000 4 0.51 0.03 0.029600 5 0.59 0.08 0.078933 6 0.66 0.17 0.167733 7 0.74 0.28 0.276267 8 0.82 0.43 0.424267 ... 43 2.85 35.89 35.015254 44 2.93 39.23 38.783246 45 3.01 42.77 41.663699 46 3.09 45.62 44.038772 47 3.16 49.55 48.285323 48 3.24 53.67 53.670000 49 3.32 57.47 55.442512 ... We can also overlay the adjusted and original curves for comparison: plt . plot ( df_kv [ 'AFM.v' ], df_kv [ 'AFM Intake' ], label = 'Original' , color = 'blue' ) plt . plot ( df_kv [ 'AFM.v' ], df_kv [ 'AFM Intake Adjusted' ], label = 'Adjusted' , color = 'red' ) Hondata Auto AFM Correction Website With this idea in mind, I thought of creating a simple website where users can upload the Datalog CSV file and the current AFM table to automatically calculate the corrections. I continued to collaborate with and our dedicated frontend developer to create the Hondata Auto AFM Correction website. The domain is: Feel free to give it a try!

This post is also available in English, at 曾经有人戏称——参与开源软件最快的方式就是给开源仓库修 Typo,这其实没问题。 不过也有人看不起修 Typo 的人,认为只是为了刷工作量,代码中的 Typo 有的时候并不需要修,毕竟,代码在能跑的情况下有点 Typo 在代码/注释中其实也无伤大雅,毕竟, 汉字的序顺并不定一能影阅响读 。 当年我刚刚加入 PingCAP 的时候提交的第一个给 @pingcap 的 PR 是 ,将文档中的 http 改成 https 除了代码本身以外,有一类文档个人感觉还是值得修修 Typo 的,那就是各大文档仓库,比如: … 文档本身作为产品的门面,如果你是一个用户,看到这种文档你会有如何想法: 来源 来源 来源 , kong 这个页面上 availability 全部写成的 availibilty 这怎么行?我得去 reoprt 个 abouse! How to fix typo 我们修 Typo 和 Redis 缓存过期一样分为两种模式: passive way 当我们阅读文档到某个部分发现有个 Typo,这个时候正义感爆棚的我们找到「Edit this docs」按钮,登录自己的 GitHub 去提交 PR active way 想办法主动发现 Typo 并提交 前者有些太慢了,为了修 Typo 需要 仔阅细读 (注意, 因为汉字的序顺并不定一能影阅响读 ,例如上面 Kong 的文档中你不仔细看是不是不容易发现 availibilty 其实是 Typo)所有文档。 你发现上面我写的是「仔阅细读」而不是「仔细阅读」了么? 所以本文的主旨在于介绍后者,提供一个「静态检查 -> GPT 判断 -> 快速人工处理」的方式。 Der aktive Weg(The active way) 为了尽可能加速我们修 Typo 的能力,本文提出一个「静态检查 -> GPT 判断 -> 快速人工处理」的方式,我们按照这个顺序依次介绍。 由于 中的 Typo 挺多,加上我住的地方离 Kong 上海办公室很近,所以本文我们以这个仓库作为示例。 拍摄于 Kong 上海办公室楼下 静态检查+初筛选 本来这一步是想手搓一个工具的,但是发现 GitHub 上有个很好用的库叫做 typos , ,在安装之后只要到项目目录下 typos 即可标记出所有的 潜在 Typo ,例如,我们在 docs.konghq.com/app 目录下: find . -name "*.md" | xargs -I {} typos {} 就可以看到不少 Typos 标记: error: `hexidecimal` should be `hexadecimal` --> ./_src/gateway/plugin-development/pdk/kong.request.md:335:61 | 335 | * Percent-encoded values of reserved characters have their hexidecimal | ^^^^^^^^^^^ | error: `Hashi` should be `Hash` --> ./_src/gateway/reference/configuration/configuration-3.4.x.md:2223:58 | 2223 | resurrected for when they cannot be refreshed (e.g., the HashiCorp vault is | ^^^^^ | error: `mis` should be `miss`, `mist` --> ./_src/gateway/reference/configuration/configuration-3.4.x.md:4138:11 | 4138 | note that mis-management of keyring data may result in irrecoverable data loss. | ^^^ 但是如你所见,这个里面有不少的 False positive 的案例,比如 HashiCorp 他认为 Hashi 应该改为 Hash,比如 mis-management 他认为应该改为 miss-management。 对于这种情况我们可以编写一个 typos.toml 进行简单的初筛,内容如下: [default.extend-words] Hashi = "Hashi" mis = "mis" 然后将命令换为: find . -name "*.md" | xargs -I {} typos {} --config /path/to/typos.toml 但是通过这种方式标记出来的 Typo 我们需要人工判断,并手动找到对应的文件做修改,依然是一个比较费时费力的操作,所以我们需要让 typos 输出程序可以理解的方式交由下一步处理,好在 typos 支持 --format json 参数,加上这个参数之后输出的内容就变成了类似如下: { "type" : "typo" , "path" : "/path/to/workspace/docs.konghq.com/app/_src/gateway/how-kong-works/routing-traffic.md" , "line_num" : 685 , "byte_offset" : 81 , "typo" : "fo" , "corrections" :[ "of" , "for" , "do" , "go" , "to" ]} { "type" : "typo" , "path" : "/path/to/workspace/docs.konghq.com/app/_src/gateway/breaking-changes/30x.md" , "line_num" : 124 , "byte_offset" : 6 , "typo" : "fuction" , "corrections" :[ "function" ]} { "type" : "typo" , "path" : "/path/to/workspace/docs.konghq.com/app/_src/gateway/production/tracing/api.md" , "line_num" : 2 , "byte_offset" : 19 , "typo" : "Referenece" , "corrections" :[ "Reference" ]} 我们暂时称这个文件为——脏 Typo JSON。 GPT 标记 在上文中我们已经可以让 潜在 Typo 以一行一个 JSON 字符串的方式记录下来了,下一步我们需要做的就是 读取「脏 Typo JSON」的每一行内容 找到对应行的内容 找到潜在的 Typo 替换掉潜在的 Typo 并问问 GPT 这个替换它认为是否合理 这里的难点在于 Prompt,我的 Prompt 如下: { "messages" : [ { "role" : "system" , "content" : """ 你是一个熟悉互联网公司名称和词语和拼写的判官,我会给你一个句子和句子中需要替换的单词,你需要以0-100之间的数字告诉我这个单词是否应该被替换,无论什么情况,你都只能回答 0到100 之间的数字,数字越大表示越有概率需要更换,如果你不能确定,请回答概率数字,不能有任何额外的解释或者注释,只回答数字,\n 同时你需要判断是否是特定公司名(例如 Hashicorp 是个公司名字,不应该被替换),或者是否是无意义字符串来决定,如果是特定公司名或者无意义字符串,你需要回答 0,如果是一个普通的单词,你需要回答 100,\n 请首先判断需要被改写的句子是否是一个有意义的句子,如果不是有意义的句子,你需要回答 0\n 例如 Time-to-live (in seconds) of a HashiCorp vault miss (no secret). 中 Hashi 是 HashiCorp 的一部分,并不是一个 Typo,所以不应该被替换,需要回答 0\n 例如 02:21:00:86:ce:d0:fc:ba:92:e9:59:16:1c:c3:b2:11:11:ed: 中的 ba 由于是一个示例字符串的一部分而不是任何有意义的句子,所以不应该被替换,需要回答 0\n 例如 X-Kong-Admin-Request-ID: ZuUfPfnxNn7D2OTU6Xi4zCnQkavzMUNM 中的 OTU 由于是一个示例字符串的一部分而不是任何有意义的句子,所以不应该被替换,需要回答 0\n """ }, { "role" : "user" , "content" : "{} \n {} 改为 {}" } ], "stream" : False , "model" : "gpt-4" , "temperature" : 0.5 , "presence_penalty" : 0 , "frequency_penalty" : 0 , "top_p" : 1 } 等 Prompt 写好之后就是花钱找 OpenAI 的 API Key 然后对接的事情了,这里由于主要是个 PoC ,所以就用 Python 完成,关键代码如下: gpt_rate_response = client . chat . completions . create ( messages = formatted_message [ 'messages' ], model = formatted_message [ 'model' ], stream = formatted_message [ 'stream' ], top_p = formatted_message [ 'top_p' ], temperature = formatted_message [ 'temperature' ], presence_penalty = formatted_message [ 'presence_penalty' ], frequency_penalty = formatted_message [ 'frequency_penalty' ] ) gpt_rate = gpt_rate_response . choices [ 0 ] . message . content 上面代码中的 gpt_rate 就是一个 0~100 之间的数字,不过可能由于我的 Prompt 没有写的很好,只会输出 0 或者 100,这个时候我们只要丢掉打分是 0 的就行了。 在这里我想感谢 的「头顶冒火」服务,地址是 ,在这个项目中 的服务给我提供了非常多的支持。 目前新用户注册送 $1 邀请好友送 $1 加频道(https://t.me/mikuri520)送 $2 同时进一步感受到了——「没有钱,就没法做科研」的道理 这里 GPT 标记完成之后我们其实只是清理掉了「脏 Typo JSON」文件中大概率误判的内容并写回,我们称这个 GPT 清理过的文件为「干净 Typo JSON」 快速标记替换 现在我们终于有了「干净 Typo JSON」了,我们需要有个方式来对文件快速完成替换,为了人机工程考虑,我们引入的操作模式为:「屏幕上每次出现两行,第一行是原始行 ,第二行是替换了之后的行,用户只需要按 Y 即可确认替换,按 N 表示放弃替换」,界面如下: 部分代码实现如下: def do_replace ( file_path , line_num , typo , correction ): lines = [] with open ( file_path , 'r' , encoding = 'utf-8' ) as file : lines = file . readlines () lines [ line_num - 1 ] = lines [ line_num - 1 ] . replace ( typo , correction ) with open ( file_path , 'w' , encoding = 'utf-8' ) as file : file . writelines ( lines ) def get_ch (): fd = sys . stdin . fileno () old_settings = termios . tcgetattr ( fd ) try : tty . setraw ( sys . stdin . fileno ()) ch = sys . stdin . read ( 1 ) finally : termios . tcsetattr ( fd , termios . TCSADRAIN , old_settings ) return ch ... # Display the original and corrected lines, and give color to the corrected word typo_word_index = orignal_line . find ( typo_word ) print ( "Path: " , json_obj [ 'path' ]) print ( f " { orignal_line [: typo_word_index ] }{ Back . RED }{ orignal_line [ typo_word_index : typo_word_index + len ( typo_word )] }{ Style . RESET_ALL }{ orignal_line [ typo_word_index + len ( typo_word ):] } " ) print ( f " { corrected_line . replace ( json_obj [ 'corrections' ][ 0 ], Back . GREEN + json_obj [ 'corrections' ][ 0 ] + Style . RESET_ALL ) } " ) print ( "Do you want to continue? [y/n]: " ) ch = get_ch () if ch == 'y' : do_replace ( json_obj [ 'path' ], json_obj [ 'line_num' ], typo_word , json_obj [ 'corrections' ][ 0 ]) print ( "Replaced!" ) elif ch == 'n' : continue else : print ( "Exiting..." ) exit ( 0 ) 当然,如果 Typo 足够多的话,即使能这样快速按 Y/N 来替换,对着电脑按 10+ 分钟也和做狗推没啥区别 进一步提升 ROI 使用本文提出的「静态检查 -> GPT 判断 -> 快速人工处理」方式,我完成了对 仓库中绝大部分(GPT 可能会有少量 false negative 反向误判)Typo 的修复,总共提交了 3 个 PR: (Merged) (Merged) (Merged) 同时还顺手在 Cloudflare 和 Halo 的仓库上实践了一下: (Merged) (Merged) 涉及 68 个分散在仓库各处的文件的改动,总用时(Clone 仓库+运行脚本+手动提交)大约 30 分钟,简称「Typo 仙人」。 目前这个流程中最耗时的时间还是莫过于「快速人工处理」,即使已经有了上面可以快速按 Y/N 的 TUI 程序,人肉手动做最终的判定还是会受到操作者(也就是我自己)的瓶颈限制,所以这里可能有如下思路: 假设「静态检查 -> GPT 判断」之后如总共有 100 个 Typo,但由于「GPT 判断」还是会有些 False Positive 的部分导致其中 10 个是假的但是没判断出来,这样我们的修改中就有 90 个真 Typo 和 10 个假 Typo,这里如果我们按照上面的「快速人工处理」的话还是得手动按 100 次 Y/N,比较费力,不如这个时候我们直接本地在「GPT 判断」完成之后自动完成对文件的修改然后把 PR 提交上去,那么: 由于库的维护者 应该 是要手动看一遍的,他 应该 看到有 10 个假 Typo,此时他有如下选择: 直接关闭 PR,但是对于大型开源项目来说这么做可能不是很友好,其他人会质疑这么做的动机 关闭了之后自己重新开,但是因为大部分是真 Typo,手动重新开 PR 会非常耗时 会帮忙把 10 个 Typo 修了并且合并,概率较大 在这种方式下可以做到最终只有一个人需要手动 Review 一遍修改,而由于(如果是负责的)维护者本身就要人工 Review 一遍,这样可以将这个有瓶颈的工作分摊到各个仓库的维护者身上,极大提升了修 Typo 效率,当然… 后记 不知道 Kong 的维护者看到这三个 PR 内部包含的这么多文件更改的时候有何感想 在 AI/GPT 如此盛行的时代,作为对自己能力没啥信心,不太会写代码,也不太会追热点的"开发者",我找到了一个除了用来做日常问答和 Copilot 以外看上去好像挺实用的一个 AI 场景,希望本文可以给读者带来某种启发~

这篇文章有简体中文版本,在 「 」 Once, someone jokingly said that the fastest way to participate in open source software is to fix typos in the repository, which is not wrong. However, some people look down on those who fix typos, thinking that they are just padding their workload. Sometimes typos in code do not need to be fixed. After all, a bit of typo in the code/comments is no harm if the code can run. When I first joined PingCAP, the first PR I submitted to @pingcap was , changing http to https in the document. Apart from the code itself, there is a type of document that I personally feel is worth fixing typos, namely, the various major document repositories, such as: … Documents themselves serve as the facade of the product. If you are a user, what would you think when you see such a document: Source: Source: Source: , on the Kong page, availability is all written as availibilty . How can this be? I have to reoprt an abouse! How to fix typos We fix typos in the same way as Redis cache expiration, divided into two modes: Passive way When we read a document and find a typo, we, filled with a sense of justice, find the “Edit this docs” button, log into our GitHub account and submit a PR. Active way Find a way to actively discover typos and submit them. The former is a bit slow. To fix typos, you need to read carefully (note, because the order of Chinese characters does not necessarily affect reading comprehension , for example, in the Kong document above, you won’t easily notice that availibilty is actually a typo) all the documents. So the main purpose of this article is to introduce the latter, providing a “static check -> GPT judgment -> quick manual processing” method. Der aktive Weg (The active way) In order to speed up our ability to fix typos as much as possible, this article proposes a “static check -> GPT judgment -> quick manual processing” method. We will introduce it in this order. Since there are quite a few typos in , and the place where I live is very close to Kong’s Shanghai office, we will use this repository as an example in this article. Taken downstairs at Kong’s Shanghai office Static check + preliminary screening Originally, I wanted to handcraft a tool for this step, but I found a very useful library on GitHub called typos, . After installing, you can just go to the project directory and use typos to mark all potential typos . For example, in the docs.konghq.com/app directory: find . -name "*.md" | xargs -I {} typos {} You can see quite a few typos marked: error: `hexidecimal` should be `hexadecimal` --> ./_src/gateway/plugin-development/pdk/kong.request.md:335:61 | 335 | * Percent-encoded values of reserved characters have their hexidecimal | ^^^^^^^^^^^ | error: `Hashi` should be `Hash` --> ./_src/gateway/reference/configuration/configuration-3.4.x.md:2223:58 | 2223 | resurrected for when they cannot be refreshed (e.g., the HashiCorp vault is | ^^^^^ | error: `mis` should be `miss`, `mist` --> ./_src/gateway/reference/configuration/configuration-3.4.x.md:4138:11 | 4138 | note that mis-management of keyring data may result in irrecoverable data loss. | ^^^ But as you can see, there are quite a few false positives in this, such as HashiCorp where he thinks Hashi should be changed to Hash, and mis-management where he thinks it should be changed to miss-management. For this situation, we can write a typos.toml for a simple preliminary screening, the content is as follows: [default.extend-words] Hashi = "Hashi" mis = "mis" Then change the command to: find . -name "*.md" | xargs -I {} typos {} --config /path/to/typos.toml But for the typos marked in this way, we need to manually judge and manually find the corresponding file to make changes, which is still a relatively time-consuming and laborious operation. So we need to let typos output in a way that the program can understand for the next step. Fortunately, typos supports the --format json parameter. After adding this parameter, the output content becomes like this: { "type" : "typo" , "path" : "/path/to/workspace/docs.konghq.com/app/_src/gateway/how-kong-works/routing-traffic.md" , "line_num" : 685 , "byte_offset" : 81 , "typo" : "fo" , "corrections" :[ "of" , "for" , "do" , "go" , "to" ]} { "type" : "typo" , "path" : "/path/to/workspace/docs.konghq.com/app/_src/gateway/breaking-changes/30x.md" , "line_num" : 124 , "byte_offset" : 6 , "typo" : "fuction" , "corrections" :[ "function" ]} { "type" : "typo" , "path" : "/path/to/workspace/docs.konghq.com/app/_src/gateway/production/tracing/api.md" , "line_num" : 2 , "byte_offset" : 19 , "typo" : "Referenece" , "corrections" :[ "Reference" ]} We temporarily call this file – dirty Typo JSON. GPT marking In the previous section, we have been able to record potential typos in the form of one JSON string per line. The next step we need to do is Read each line of the “dirty Typo JSON” Find the corresponding line content Find the potential typo Replace the potential typo And ask GPT if it thinks the replacement is reasonable The difficulty here lies in the Prompt. My Prompt is as follows: { "messages" : [ { "role" : "system" , "content" : """ You are a judge who is familiar with the names and words and spelling of internet companies. I will give you a sentence and the word in the sentence that needs to be replaced. You need to tell me whether this word should be replaced with a number between 0-100. Under any circumstances, you can only answer a number between 0 and 100. The larger the number, the more likely it is to be replaced. If you are not sure, please answer the probability number. You can't have any additional explanations or comments, just answer the number. At the same time, you need to judge whether it is a specific company name (for example, Hashicorp is a company name and should not be replaced), or whether it is a meaningless string to decide. If it is a specific company name or a meaningless string, you need to answer 0. If it is a common word, you need to answer 100. Please first judge whether the sentence that needs to be rewritten is a meaningful sentence. If it is not a meaningful sentence, you need to answer 0. For example, in the sentence " Time-to-live (in seconds) of a HashiCorp vault miss (no secret). ", Hashi is part of HashiCorp and is not a typo, so it should not be replaced. You need to answer 0. For example, in " 02 : 21 : 00 : 86 : ce : d 0 : fc : ba : 92 : e 9 : 59 : 16 : 1 c : c 3 : b 2 : 11 : 11 : ed : ", ba is part of an example string and not any meaningful sentence, so it should not be replaced. You need to answer 0. For example, in " X-Kong-Admin-Request-ID : ZuUfPfnxNn 7 D 2 OTU 6 Xi 4 zCnQkavzMUNM ", OTU is part of an example string and not any meaningful sentence, so it should not be replaced. You need to answer 0. """ }, { "role" : "user" , "content" : "{} \n {} change to {}" } ], "stream" : False , "model" : "gpt-4" , "temperature" : 0.5 , "presence_penalty" : 0 , "frequency_penalty" : 0 , "top_p" : 1 } After the Prompt is written, it’s time to pay for the OpenAI API Key and then connect it. Here, since it’s mainly a PoC, it’s done in Python, with key code as follows: gpt_rate_response = client . chat . completions . create ( messages = formatted_message [ 'messages' ], model = formatted_message [ 'model' ], stream = formatted_message [ 'stream' ], top_p = formatted_message [ 'top_p' ], temperature = formatted_message [ 'temperature' ], presence_penalty = formatted_message [ 'presence_penalty' ], frequency_penalty = formatted_message [ 'frequency_penalty' ] ) gpt_rate = gpt_rate_response . choices [ 0 ] . message . content In the above code, gpt_rate is a number between 0~100, but it may be because my Prompt is not written very well, it will only output 0 or 100. At this time, we just need to discard the ones with a score of 0. Here, after GPT marking, we just cleaned up the likely misjudged content in the “dirty Typo JSON” file and wrote it back. We call this GPT cleaned file “clean Typo JSON”. Quick marking replacement Now we finally have the “clean Typo JSON”. We need a way to quickly complete the replacement of the file. For the consideration of human-machine engineering, we introduce the operation mode of: “two lines appear on the screen each time, the first line is the original line, the second line is the line after the replacement, users only need to press Y to confirm the replacement, press N to give up the replacement”. The interface is as follows: Part of the code implementation is as follows: def do_replace ( file_path , line_num , typo , correction ): lines = [] with open ( file_path , 'r' , encoding = 'utf-8' ) as file : lines = file . readlines () lines [ line_num - 1 ] = lines [ line_num - 1 ] . replace ( typo , correction ) with open ( file_path , 'w' , encoding = 'utf-8' ) as file : file . writelines ( lines ) def get_ch (): fd = sys . stdin . fileno () old_settings = termios . tcgetattr ( fd ) try : tty . setraw ( sys . stdin . fileno ()) ch = sys . stdin . read ( 1 ) finally : termios . tcsetattr ( fd , termios . TCSADRAIN , old_settings ) return ch ... # Display the original and corrected lines, and give color to the corrected word typo_word_index = orignal_line . find ( typo_word ) print ( "Path: " , json_obj [ 'path' ]) print ( f " { orignal_line [: typo_word_index ] }{ Back . RED }{ orignal_line [ typo_word_index : typo_word_index + len ( typo_word )] }{ Style . RESET_ALL }{ orignal_line [ typo_word_index + len ( typo_word ):] } " ) print ( f " { corrected_line . replace ( json_obj [ 'corrections' ][ 0 ], Back . GREEN + json_obj [ 'corrections' ][ 0 ] + Style . RESET_ALL ) } " ) print ( "Do you want to continue? [y/n]: " ) ch = get_ch () if ch == 'y' : do_replace ( json_obj [ 'path' ], json_obj [ 'line_num' ], typo_word , json_obj [ 'corrections' ][ 0 ]) print ( "Replaced!" ) elif ch == 'n' : continue else : print ( "Exiting..." ) exit ( 0 ) Of course, if there are enough typos, even if you can press Y/N quickly to replace, pressing the computer for 10+ minutes is no different from doing dog pushing. Further increase ROI Using the “static check -> GPT judgment -> quick manual processing” method proposed in this article, I completed the repair of most (GPT may have a small amount of false negative misjudgment) typos in the repository, and submitted a total of 3 PRs: (Merged) (Merged) (Merged) At the same time, I also practiced on the repositories of Cloudflare and Halo: (Merged) (Merged) The changes involved 68 files scattered throughout the repository, with a total time (Clone repository + run script + manual submission) of about 30 minutes, commonly known as “Typo Immortal”. Currently, the most time-consuming part of this process is undoubtedly the “quick manual processing”. Even though we already have the above TUI program that can quickly press Y/N, manual final judgment is still bottlenecked by the operator (me). So here might be the following idea: Assume that after “static check -> GPT judgment”, there are a total of 100 typos, but because “GPT judgment” will still have some False Positive parts that are not judged, so there are 10 fake ones in our modification but not judged. So we have 90 real typos and 10 fake typos. Here, if we follow the above “quick manual processing”, we still have to manually press 100 times Y/N, which is quite laborious. It’s better to automatically complete the modification of the file after “GPT judgment” and then submit the PR. Then: Because the maintainer of the repository should manually review it, they should notice that there are 10 false typos, at which point they have the following choices: Close the PR directly, but for large open source projects, this might not be very friendly, and others might question the motive of doing so. Close it and then reopen it themselves, but because the majority are real typos, manually reopening the PR would be very time-consuming. They might help fix the 10 typos and then merge it, which is quite likely. With this approach, only one person needs to manually review the changes in the end, and since the responsible maintainer themselves needs to manually review it once, this can distribute this bottlenecked work among the maintainers of various repositories, greatly increasing the efficiency of fixing typos, of course… Postscript I wonder what the Kong maintainers thought when they saw so many file changes contained within these three PRs In an era where AI/GPT is so prevalent, as a “developer” who doesn’t have much confidence in their abilities, doesn’t write code very well, and doesn’t chase trends very well, I found a seemingly quite practical AI scenario, besides using it for daily Q&A and Copilot. I hope this article can bring some inspiration to the readers~