Go语言星球

September 24, 2025

qcrao 的博客

把DDIA读厚(八):数据库复制的“冰与火之歌”

导语:你是否遇到过这样的场景:明明在后台看到了“操作成功”的提示,刷新后数据却“穿越”回了修改前的状态?这并非灵异事件,而是分布式系统中一个经典而残酷的现实。本文将带你深入《设计数据密集型应用》的核心章节,从一个诡异的“数据丢失”谜题开始,揭开数据库复制的两种核心模式——物理复制与逻辑复制的神秘面纱,并最终探索它们如何驱动了现代实时数据架构的脉搏。


序幕——一个“幽灵”数据引发的血案

想象一下,你正在为一个电商系统负责。在一次大促活动前,运营同事紧急修改一件爆款商品的价格,从 ¥ 100 降为 ¥ 80。他在后台点击“保存”,系统弹出“修改成功!”的绿色提示。他长舒一口气。

几分钟后,主数据库服务器因为机房电源抖动而宕机。幸运的是,你们的架构有高可用设计,系统在半分钟内自动将一个从库提升为新的主库,服务恢复。但这时,用户和运营同时发现,那件商品的价格依然是 ¥ 100。那个“成功”的修改,如同幽灵一般,消失得无影无踪。

“我明明存上了!数据库的持久性(Durability)承诺呢?” 运营同事的质问,直击了问题的核心。

要解开这个谜题,我们必须潜入数据库的“复制”世界。复制,即在不同的机器上保存相同数据的副本,其目的无外乎三点:

  1. 高可用性:一台挂了,另一台顶上。
  2. 读扩展性:更多副本分担读请求。
  3. 降低延迟:在用户附近部署副本。

而“幽灵数据”问题的根源,在于主从节点间同步数据的方式——尤其是异步复制(Asynchronous Replication)

在一个异步复制系统中,主节点处理完写操作后,会立刻向客户端返回“成功”,然后才“抽空”将变更发送给从节点。主从之间存在一个微小的时间窗口,我们称之为复制延迟(Replication Lag)。我们的“血案”,正是在这个延迟窗口内,主节点倒下了。新上任的主节点(原从库)根本没来得及收到那次改价操作,导致了数据的“丢失”。

这是为了高性能和高可用性,必须付出的代价。那么,数据库是如何在内部传递这些变更信息的呢?这里,就引出了两条截然不同的技术路线,宛如冰与火之歌,各有千秋——物理复制逻辑复制

物理复制的“凛冬”——PostgreSQL 的 WAL 之道

物理复制,顾名思义,它复制的是数据变更的物理痕跡。它不关心变更的业务含义,只关心“在哪个文件的哪个位置,哪些字节发生了改变”。

这方面最经典的代表,就是 PostgreSQL 的预写日志(Write-Ahead Log, WAL)流复制

什么是 WAL?

首先,WAL 是单机数据库为了保证崩溃安全而设计的。任何对数据文件的修改,都必须先以日志的形式、顺序地写入到 WAL 文件中。如果数据库在修改数据页的半途中崩溃,重启后可以通过回放 WAL 来恢复到一致的状态。

如何用于复制?

PostgreSQL 的工程师们想出了一个绝妙的主意:既然主库为了自身安全必须生成 WAL,何不将这份“操作录像带”直接流式地发送给从库呢?从库接收到后,像快进一样回放这份录像带,在自己数据文件的相同位置上,执行完全相同的字节修改。

WAL
  • 主库:先写 WAL,再改内存 buffer。
  • 从库:先接收 WAL,再改内存 buffer(相当于 replay)。
  • 所以从库的数据页始终是 由 WAL 驱动的只读重放
物理复制的“冰”之特性

这种方式如同寒冰一般,极致、高效、但也非常“僵硬”。

  • 优点:性能极致。这是几乎没有额外开销的复制方式。主库生成 WAL 是刚需,从库应用 WAL 就像是 memcpy,速度极快。
  • 缺点:紧密耦合。这是它的致命弱点。
    • 版本锁定:如果主从库的 PostgreSQL 大版本不同,其内部的数据页存储结构可能存在差异。一份来自 v15 的 WAL “录像带”,在 v14 的从库上“回放”,很可能导致数据文件彻底损坏。
    • 架构锁定:无法跨异构数据库或不同硬件架构进行复制。
    • 灵活性差:你无法只复制一个库或一张表,因为 WAL 记录的是整个实例的物理变化。

物理复制就像一位严谨的复印员,能完美克隆,但对复印稿的格式要求极为苛刻。

逻辑复制的“烈火”——MySQL Binlog 的涅槃

逻辑复制则走上了一条完全不同的道路。它复制的是变更的逻辑含义,它关心的是“发生了什么业务操作”。

这方面的王者,当属 MySQL 的二进制日志(Binary Log, Binlog)

什么是 Binlog?

Binlog 记录的是所有修改了数据库数据的“事件(Events)”。它与具体的存储引擎(如 InnoDB)解耦,位于 MySQL Server 层。最关键的是,它的记录格式是逻辑的。

以目前主流的 ROW 格式为例,当一个修改发生时,Binlog 记录的不是字节变化,而是:

“对于 products 表,主键为 123 的那一行,它的 price 字段的值从 100 变成了 80。”

Binlog
逻辑复制的“火”之特性

这种方式如火焰一般,灵活、强大,能够适应各种形态。

  • 优点:灵活性与解耦
    • 版本兼容:只要数据类型兼容,主从库的 MySQL 版本可以不同。
    • 异构复制:你可以将 Binlog 解析后,写入到任何其他系统,如 PostgreSQL、Elasticsearch 或数据仓库。
    • 选择性复制:可以轻松配置只复制某个库、某几张表,甚至可以过滤某些类型的操作。
  • 缺点:性能开销略高。相比于直接传输字节,生成和解析逻辑日志会带来一些额外的计算开销,但对于大多数场景而言,这种开销是完全可以接受的。

逻辑复制就像一位口齿清晰的信使,他不在乎消息是用什么纸笔写的,只负责清晰地传达消息的内容本身。

现代数据架构的脉搏——“伪装”的艺术

逻辑复制的解耦特性,催生了一项革命性的技术范式:CDC(Change Data Capture,变更数据捕获)

像 Debezium、Canal 这样的 CDC 工具,正是利用了逻辑复制的开放性。它们的工作原理,就是**“伪装”成一个 MySQL 的从库**。

这是如何做到的?答案在于遵循标准的复制协议

  1. 认证:CDC 工具使用一个被授予 REPLICATION SLAVE 权限的 MySQL 账号连接到主库。
  2. “接头暗号”:连接成功后,它不发送 SELECT 查询,而是发送一个特殊的 COM_BINLOG_DUMP 命令,并告诉主库它想从哪个 Binlog 文件的哪个位置开始“收听”。
  3. 收听与转播:MySQL 主库收到这个“暗号”后,便开始将 Binlog 事件源源不断地以二进制流的形式发送给 CDC 工具。
  4. 翻译与发布:CDC 工具接收到二进制流后,将其解析成结构化的逻辑变更事件(通常是 JSON),然后发布到消息队列(如 Kafka)中。
CDC

通过这种“伪装”的艺术,CDC 工具将传统的关系型数据库从一个被动的数据存储中心,转变为了一个主动的、实时的事件源。整个公司的下游应用,都可以订阅这份“数据日报”,实时响应业务的变化。

总结与思考:选择“冰”还是“火”?

回到我们最初的问题,无论是物理复制还是逻辑复制,在异步模式下都无法从根本上消除数据丢失的风险,但这促使我们更深刻地理解了系统设计中的权衡。

  • 物理复制(冰):追求极致的性能和数据的一致性(副本与主库字节级一致),适用于构建同构数据库的高可用集群。它简单、粗暴、高效,但缺乏弹性。
  • 逻辑复制(火):追求极致的灵活性和生态的开放性,是现代异构数据同步和实时数据管道的基石。它优雅、强大、富有创造力。

作为后端工程师,理解这两种复制模式的内核差异至关重要。它不仅能帮助你排查类似“幽灵数据”的诡异问题,更能让你在进行技术选型和架构设计时,拥有更广阔的视野。

在今天,数据库早已不只是业务的终点,更是实时数据的起点。而开启这一切的钥匙,就藏在这冰与火的复制之歌中。

September 24, 2025 12:00 AM

September 23, 2025

qcrao 的博客

把DDIA读厚(七):从SSTable到LSM树,再到MySQL的B+树之辩

在深入数据系统的世界时,我们常常惊叹于上层架构的宏伟,但支撑这一切的,往往是那些看似朴素却充满智慧的底层设计。在 DDIA 第三章中,SSTable 与 LSM 树无疑就是这样的基石。

这趟探索之旅,始于一个简单构件——SSTable,它像一块块乐高积木,虽好用但略显呆板。随后,我们将看到 LSM 树这套“动态系统”是如何赋予这些积木生命,搭建出能抵御写入洪流的坚固堡垒。最后,我们将站在更高的视角,探讨一个经典问题:既然 LSM 树如此强大,为何像 MySQL 这样的关系型数据库巨头,却选择坚守 B+ 树的阵地?

这不仅是对一个数据结构的剖析,更是一场关于设计哲学与工程权衡的深度思辨。


第一幕:SSTable——一块有序且不可变的坚实地基

SSTable(Sorted String Table)是 LSM 树在磁盘上的基本组成单位。它的设计法则纯粹而简单,牢记两点即可:

  1. 内部有序:文件内的键值对,严格按照键(Key)排序。
  2. 不可变(Immutable):一旦写入,永不修改。

一个 SSTable 文件并非一块铁板,而是由多个部分精密组成的,好比一本字典:

  • 数据块 (Data Block):字典的“书页”,存储着一小段连续的、有序的键值对。一个 SST 文件中通常会有成百上千个 Data Block(取决于文件大小和 block size 配置,例如 4MB 数据 flush 下来,block size=16KB,大约会有 256 个 Data Block)。
  • 索引块 (Index Block):字典的“页眉导引词”,记录每个 Data Block 的边界键及其位置,便于快速定位。
  • 元索引块 (Metaindex Block):记录 Bloom Filter、属性信息等元数据块的位置。
  • 文件尾 (Footer):固定在文件末尾,记录索引块和元索引块的位置,并带有魔数,是读取 SSTable 的入口。

这种结构带来了显而易见的好处:

  • 读取高效:在常见缓存命中的场景下,查找通常只需一次磁盘 I/O(目标 Data Block),范围查询更是高效的磁盘顺序读。
  • 合并顺序化:多个 SSTable 的合并过程逻辑上类似归并排序,以顺序读写为主,相对高效。但同时,也会带来 写放大 的问题。

然而,“不可变”的特性,也带来了致命的弱点:无法高效地处理单次写入。为一个新键值对而重写整个几 GB 的文件,无异于天方夜谭。SSTable 是优秀的“只读”构件,但它需要一套动态系统来盘活它。


第二幕:LSM 树的诞生——化零为整的写入艺术

LSM 树(Log-Structured Merge-Tree)就是那套盘活 SSTable 的系统。它没有改变 SSTable,而是设计了一套全新的工作流,其核心思想是:将所有随机、零散的写入,在内存中“攒”成有序的大块,再“铸造”成新的 SSTable。

这个过程由两大核心组件驱动:

  1. MemTable (内存表):一个内存中的有序数据结构(如跳表)。所有写请求(增、删、改)都先在这里完成,速度如电光石火。
  2. 刷盘 (Flush):当 MemTable 达到预设大小(例如 4MB),它会被冻结为 Immutable MemTable。随后后台线程会将它的内容整体写入磁盘,生成一个新的 SST 文件。

关键点:一次 flush = 一个新的 SST 文件。但这个文件内部会根据配置的 block size(如 16KB)被切分成多个 Data Block(例如 4MB ÷ 16KB ≈ 256 个 Data Block),并在文件末尾生成索引块、元索引块和 Footer。

这个过程周而复始。随着系统运行,磁盘上会不断出现新的 SST 文件:0001.sst, 0002.sst, 0003.sst…


第三幕:与“熵增”的对抗——在混沌中维持秩序

多个 SST 文件的存在,引出了 LSM 树设计中最核心的一个事实,也是初学者最容易困惑的地方:新生成的 SST 文件之间,键范围是会重叠的,它们并非全局有序!

0001.sst 可能存储了键 apple 和 zebra,而 0002.sst 可能存储了键 banana。这种“混沌”状态,是为换取极致写入性能的必然结果,但也给系统带来了新的挑战:

  • 读放大:在 Level-0,由于文件范围可能重叠,查找一个键需要从最新文件开始逐个回溯。而在更高层(L1+),文件范围不重叠,可以二分定位到唯一文件,大幅优化读取效率。
  • 空间放大:被更新或删除的旧数据,依然躺在旧 SST 文件中,浪费着磁盘空间。

为了对抗这种无序带来的“熵增”,LSM 树必须依赖它的第三个核心组件: 后台合并 (Compaction):这是一个持续运行的“内务整理”线程。它不知疲倦地:

  • 选择磁盘上若干个 SST 文件;
  • 将它们读入内存,进行归并,丢弃所有过时和已删除的数据;
  • 将合并后的、更紧凑、更干净的结果,写入一个新的、更大的 SST 文件;
  • 最后,安全地删除那些被合并的旧文件。

在更精巧的实现(如 RocksDB)中,Compaction 还引入了**分层(Levels)**策略。新刷盘的文件都位于允许键范围重叠的 Level-0,而 Compaction 会将它们不断合并、推向更高层级(Level-1, Level-2…)。在这些高层级中,SST 文件之间保证键范围互不重叠,从而极大地优化了读取效率。

至此,LSM 树的全貌浮出水面:它是一个由 MemTable、磁盘上 动态增删的 SST 文件集合、以及 后台 Compaction 三者协同工作的精密系统。它牺牲了数据的“规整性”,换来了写入的“流畅性”,再通过后台任务,孜孜不倦地将系统从混沌中拉回秩序。


终幕:B+树之辩——为什么 MySQL 不选择 LSM 树?

理解了 LSM 树的内在权衡,我们便能回答那个经典问题:为何 MySQL(InnoDB)坚守着 B+ 树的阵地?

答案是:它们的“天命”不同。

MySQL/InnoDB 的使命,是服务在线事务处理(OLTP)。 它的战场是电商、金融、社交应用。这些场景最需要的是:

  • 稳定且极低的读取延迟:B+ 树紧凑的页结构,保证了通过主键查找,通常只需 3–4 次磁盘 I/O,延迟稳定可控。LSM 树的读取路径则更长,延迟抖动也更大。
  • 高效的更新模型:B+ 树支持页级原位修改,通常能在少量页操作中完成更新。虽然可能发生页分裂,但整体延迟比 LSM 的“追加新版本”模式更可控。
  • 成熟的事务与锁:B+ 树的行、页结构,是实现行级锁、间隙锁等复杂并发控制的天然土壤。

LSM 树的使命,是服务写入密集型负载。 它的战场在日志、监控、物联网等数据摄取场景。它优先保证的是 极致的写入吞吐量。

因此,这不是一场谁更先进的较量,而是一场 场景适配性 的选择。MySQL 为了最广泛的 OLTP 用户,选择了 B+ 树这件“通用性最强、读取最稳的甲胄”。

当然,世界在融合。Facebook 为 MySQL 开发的 MyRocks 存储引擎,就是将 LSM 树的心脏植入了 MySQL 的身体,以应对其自身独特的、写入超密集且空间敏感的业务。这恰恰证明,没有万能的架构,只有最合适的选择。


结语

从 SSTable 的静态之美,到 LSM 树的动态之衡,再到与 B+ 树的哲学之辩,我们完成了一次深入数据引擎核心的旅行。我们看到,任何一个优秀的设计,都不是在追求某个单一指标的极致,而是在一系列相互冲突的目标之间,寻找一个优雅的平衡点。理解这些权衡,并内化为自己的设计直觉,正是我们“把 DDIA 读厚”的意义所在。

September 23, 2025 12:00 AM

September 09, 2025

qcrao 的博客

把DDIA读厚(六):一次“写入”的奇幻漂流——从应用到磁盘

这是《把 DDIA 读厚》系列的第六篇文章。在 DDIA 的精读之旅中,我们已经聊过了可靠性、可扩展性等宏大主题。今天,我想和你一起,做一次微观探险。我们将聚焦于一个最基础、最频繁的动作——“写入”,跟踪一次小小的写入请求,从它在应用程序中诞生,到最终在磁盘上安家,看看这段旅程中都发生了哪些惊心动魄、充满权衡的故事。

这一切,都始于 DDIA 第三章中的一句话:

“对于极其简单的场景,日志追加式的写入拥有非常好的性能。”

这句话很符合直觉。我们深挖下去,发现无论是对于 HDD 还是 SSD,顺序追加都能完美地顺应硬件的“天性”,获得极佳的性能。不仅如此,我们还了解到,操作系统(OS)的**页缓存(Page Cache)**机制,更是将这个优势发挥到了极致——它将我们应用层无数次微小的写入,在内存中“攒”成大块,再如行云流水般一次性顺序刷入磁盘。

一切看起来如此完美。

但一个致命的疑问也随之浮现:如果 OS 还没来得及把缓存中的数据刷盘,就发生了断电,数据不就丢了吗?

对于任何严肃的业务系统,这都是不可接受的。为了堵上这个漏洞,我们找到了操作系统提供的“保险开关”——fsync系统调用。它能强制命令 OS 将缓存数据刷入磁盘,确保数据“落盘为安”。

然而,当我们尝试为每一次写入都系上fsync这条“安全带”时,一个诡异的现象出现了:我们引以为傲的性能优势不仅消失殆尽,整个写入模式甚至会退化。这正是本次探索的核心谜题:

为什么在一个繁忙的系统里,为一个逻辑上“顺序追加”的文件频繁调用fsync,最终会导致物理上“随机 I/O”的性能表现?

这个看似矛盾的现象背后,隐藏着一场应用、操作系统与硬件之间,关于性能与持久性的复杂博弈。别急,泡杯咖啡,让我们顺着这个疑问,一探究竟。

第一站:理想乡——顺序写入的物理学优势

想象一下最纯粹的写入模型,就像 DDIA 开篇的db_set脚本一样,它只是简单地将数据追加(append)到一个文件末尾。这在物理上意味着什么?

  • 对于机械硬盘(HDD):磁头无需耗费毫秒级的“寻道时间”在盘片上空到处乱飞,只需移动到文件末尾,然后就可以像老式唱片机一样,在盘片旋转时连续不断地刻录数据。
  • 对于固态硬盘(SSD):它避免了 SSD 最头疼的“先擦除再写入”的放大效应。对于一个已有的数据块,原地修改意味着“读取整个块 -> 内存中修改 -> 擦除整个块 -> 写回整个块”的酷刑。而顺序追加,则是轻松地在干净的闪存页上连续写入,轻快而优雅。

无论在哪种介质上,顺序 I/O 都牢牢抓住了硬件的“天性”,是写入操作的“理想态”。

第二站:缓冲区——操作系统的好心与谎言

既然顺序写入这么快,那问题出在哪?答案在我们和硬件之间隔着的第一个“中间商”——操作系统。

当我们调用write()时,应用程序很快就拿到了“写入成功”的回执。但这是一个“善意的谎言”。数据并未抵达磁盘,而是进入了操作系统内核的页缓存(Page Cache)

这是 OS 的一片苦心:

  1. 性能:把成百上千次微小的写入请求,在内存里“攒”成一大块,然后一次性交给磁盘,将零散的写入合并成大块的顺序 I/O,效率倍增。
  2. 响应:让应用程序不必等待缓慢的磁盘,立刻就能返回,继续处理其他任务。

这个缓冲机制也顺带解答了一个底层细节问题:我们逻辑上是连续写入,物理上如何保证磁盘在写完扇区 N 后,紧接着就去写 N+1 呢?答案就在于,文件系统在为这“一大块”攒好的数据申请磁盘空间时,会倾向于分配一片连续的物理块(Extent),从而将逻辑上的顺序追加,转化为了物理上的顺序写入。

然而,这个“中间商”在带来效率的同时,也带来了第一个致命风险:断电会丢数据! 内存是易失的,任何在页缓存里还没来得及去磁盘“安家落户”的数据,都会在断电瞬间烟消云散。

第三站:fsync的审判——追求真理的代价

为了对抗这种风险,操作系统给了我们一个“杀手锏”——fsync()系统调用。它像一张神圣的审判令,强制命令 OS:“放下一切缓冲和优化,把我要求的数据,立刻、马上、同步地刷到磁盘上去!直到确认它安全了再回来见我。”

有了fsync,我们似乎拥有了“数据金身”。但回到我们最初的问题,如果我们为每一次微小的写入都调用fsync,为何反而会陷入“同步随机 I/O”的泥潭?

想象一下你的服务器后台,它是一个繁忙的十字路口:

  1. 你的应用刚为database文件完成了一次fsync,磁头停在了它的末尾。
  2. 就在下几毫秒,操作系统的日志服务syslog抢到了 CPU,要求在/var/log/messages里写一条日志。为此,磁盘磁头不得不长途跋涉,飞到盘片的另一个位置。
  3. 紧接着,你的应用又来了一次写入请求,再次调用fsync。此时,磁头必须从syslog文件的位置,再千里迢迢地飞回来。

看到了吗?在多任务环境下,磁盘磁头的所有权在不同进程间被疯狂抢夺。我们逻辑上对单一文件的顺序追加,在物理层面被“插队”的其他进程打碎,磁头的移动轨迹和随机写入毫无二致。每一次fsync都几乎要支付一次完整的“寻道+旋转”的重税。

我们为了追求极致的“真”(数据持久性),却付出了极致的“慢”作为代价。

第四站:架构师的智慧——“成组提交"的救赎

那么,真正的数据库系统是如何走出这个两难困境的呢?它们引入了一种更高级的博弈策略——预写日志(WAL)与成组提交(Group Commit)

这个策略的精髓在于:用吞吐量换延迟,用批处理摊销成本

当 100 个事务在同一时刻请求提交时,一个繁忙的数据库并不会傻傻地调用 100 次fsync。它会:

  1. 组队:对第一个请求说“稍等”,然后开启一个极短的计时窗口。
  2. 缓冲:将这 100 个事务的日志记录,在内存的 WAL 缓冲区里拼接成一个大大的数据块。
  3. 冲刺:对这个包含了 100 个事务的大数据块,执行一次write()和一次fsync()
  4. 解放:一旦这次fsync成功返回,数据库会同时唤醒那 100 个等待的线程,告诉它们:“你们都成功了!”

这个过程就像坐公交车,虽然第一个到站的乘客需要等其他人上车,牺牲了一点即时性(Latency),但整辆公交车一次运送了大量乘客,极大地提升了系统的总运力(Throughput)。

通过“成组提交”,数据库把对fsync的调用,从“为每一次写入”变成了“为每一批写入”。昂贵的fsync成本被几十上百个事务分摊,而写入 WAL 这个动作本身,又保持了纯粹的顺序 I/O 特性。这是一个近乎完美的工程壮举。

终点站,也是新的起点:不确定性的世界

我们的探险似乎已接近尾声,但最极限的挑战才刚刚开始。一个直击灵魂的问题是:

“如果在fsync成功后,数据库还没来得及通知客户端,就崩溃了,会发生什么?”

这是一个制造了“不确定性”的幽灵时刻:

  • 数据库(重启后):知道事务已成功,数据永不丢失。
  • 客户端:只知道连接断了,完全不知道事务的最终状态。

如果客户端冒失地重试,可能会导致用户被重复扣款。此时,我们发现,问题的边界已经超出了数据库自身。数据库保证了它的D(Durability),但无法解决网络通信的D(Delivery uncertainty)。

解决这个问题的责任,历史性地交到了我们应用架构师手中。业界的标准答案是:设计幂等(Idempotent)接口

通过在请求中加入唯一的事务 ID,让服务器有能力识别出:“哦,这个请求我处理过了,虽然上次没来得及告诉你,但现在可以直接给你成功回执,不会重复执行。”

旅程总结

一次写入的奇幻漂流,带我们穿越了硬件物理、操作系统内核、数据库引擎和应用架构四个截然不同的层面。我们看到:

  • 为了性能,我们拥抱顺序写入和 OS 缓冲。
  • 为了持久性,我们引入fsync进行约束。
  • 为了在持久性下重获吞吐量,我们发明了 WAL 和成组提交。
  • 为了应对系统间的不确定性,我们必须在应用层设计幂等性。

这趟旅程的每一站,都充满了精妙的权衡。没有绝对的“好”与“坏”,只有面向特定场景的“取”与“舍”。而理解这些权衡,并能在自己的设计中运用自如,或许就是“把 DDIA 读厚”的真正意义。

希望这次的探险,能让你在未来每一次写下db.save()时,都能会心一笑,洞察其背后那波澜壮阔的世界。

September 09, 2025 12:00 AM

August 30, 2025

李文周的博客

Go CLI 开发利器:Cobra 简明教程

在 Go 语言的生态中,有许多优秀的库可以帮助我们极大地提升开发效率。今天,我们要聊的是一个在构建命令行(CLI)应用时几乎绕不开的王者级项目——Cobra

August 30, 2025 08:37 AM

July 15, 2025

李文周的博客

Go实战指南:使用 go-redis 执行 Lua 脚本

Redis 是开发中常用的高性能缓存数据库。除了常规的 GET/SET 操作,Redis 还支持通过 Lua 脚本实现复杂的原子操作。本文将带你循序渐进地学习如何在 Go 语言中,利用 go-redis 执行 Lua 脚本,并进一步讲解脚本缓存(script load)与 Go 的 embed 特性的结合使用。

July 15, 2025 02:41 PM

June 30, 2025

李文周的博客

基于泛型的轻量级依赖注入工具 do

在 Go 语言的开发实践中,我们经常需要处理各种依赖关系,例如,一个 service 层可能依赖一个或多个 repository 层。如何优雅地管理这些依赖,是我们在项目开发中需要重点关注的问题。一个好的依赖管理方案,可以显著提高代码的可读性、可维护性和可测试性。

June 30, 2025 01:50 PM

June 22, 2025

qcrao 的博客

把DDIA读厚(五):图数据库实战——手把手带你挖出一个“欺诈团伙”

这是《把 DDIA 读厚》系列的第五篇文章。在上一篇,我们深入探讨了关系模型与文档模型的世纪之争,核心在于它们如何处理数据的“关系”。今天,我们要把“关系”这个词推向极致,聊一聊为“关系”而生的数据模型——图。

引子:当 JOIN 遇见了"六度空间"

你跟产品经理说:“这个‘猜你喜欢’的功能,要查用户好友的好友,还得看共同兴趣,SQL 写起来太复杂,跑起来也慢,不好做。”

产品经理两手一摊:“Facebook 不就能做吗?”

这个场景很真实。当我们的业务需求,不再是简单的“查 A 查 B”,而是变成了“探索 A 和 B 之间千丝万缕的、不确定的、多层次的联系”时,我们熟悉的 JOIN 就开始力不从心了。这时,我们需要一件专门为此而生的神兵利器。

本篇锚点:为"关系"而生的数据模型

我们今天的“锚点”,是 DDIA 第二章关于图模型的核心观点:

图数据模型专为“多对多”关系是常态、数据连接的深度和复杂性是核心挑战的场景而设计。

它的世界里,万物皆为顶点(Nodes),万物之间的联系皆为边(Relationships)。我们的任务,就是在这个由点和线构成的宇宙里,探索那些隐藏的路径和模式。

发散深潜:手把手挖出一个"欺诈团伙"

理论总是枯燥的,我们直接开干。下面,我将手把手带你用当今最流行的图数据库 Neo4j,来完成一次真实的反欺诈“案件侦破”。

第一步:环境准备

请您前往 Neo4j 的官方网站下载并安装 Neo4j Desktop。它对个人开发者完全免费,且安装过程非常简单。

安装后,请按以下步骤操作:

  1. 打开 Neo4j Desktop,新建一个项目(Project)。
  2. 在这个项目里,点击 “Add Database” -> “Create a Local Database”。
  3. 给你的数据库起个名字(比如 fraud-detection),设置一个密码(比如 password),然后点击 “Create”。
  4. 数据库创建好后,点击旁边的 “Start” 按钮启动它。
  5. 启动成功后,点击 “Open”,这会自动在浏览器中打开 Neo4j Browser 操作台。

至此,您的图数据库环境就已经准备就绪了!

第二步:数据建模与导入

在我们的反欺诈场景中,用户设备IP地址 都是顶点。它们之间的 使用来自 等都是关系。现在,请在 Neo4j Browser 的输入框中,一次性地复制并执行以下所有代码。

Cypher

// 使用 MERGE 命令,它能确保节点和关系只被创建一次,重复执行也不会出错
// --- 创建顶点 ---
MERGE (:User {id: 'user-A', name: '张三'});
MERGE (:User {id: 'user-B', name: '李四'});
MERGE (:User {id: 'user-C', name: '王五'});
MERGE (:User {id: 'user-D', name: '赵六'});
MERGE (:User {id: 'user-E', name: '无辜的路人甲'});
MERGE (:Device {id: 'device-123'});
MERGE (:Device {id: 'device-456'});
MERGE (:Device {id: 'device-789'});
MERGE (:IP {id: '192.168.1.10'});
MERGE (:IP {id: '192.168.1.11'});

// --- 创建关系边 ---
// 找到需要连接的节点,然后创建它们之间的关系
MATCH (u1:User {id: 'user-A'}), (d1:Device {id: 'device-123'}) MERGE (u1)-[:USED_DEVICE]->(d1);
MATCH (u2:User {id: 'user-B'}), (d1:Device {id: 'device-123'}) MERGE (u2)-[:USED_DEVICE]->(d1);
MATCH (u1:User {id: 'user-A'}), (ip1:IP {id: '192.168.1.10'}) MERGE (u1)-[:FROM_IP]->(ip1);
MATCH (u3:User {id: 'user-C'}), (ip1:IP {id: '192.168.1.10'}) MERGE (u3)-[:FROM_IP]->(ip1);
MATCH (u3:User {id: 'user-C'}), (d2:Device {id: 'device-456'}) MERGE (u3)-[:USED_DEVICE]->(d2);
MATCH (u4:User {id: 'user-D'}), (d2:Device {id: 'device-456'}) MERGE (u4)-[:USED_DEVICE]->(d2);
MATCH (u5:User {id: 'user-E'}), (d3:Device {id: 'device-789'}) MERGE (u5)-[:USED_DEVICE]->(d3);
MATCH (u5:User {id: 'user-E'}), (ip2:IP {id: '192.168.1.11'}) MERGE (u5)-[:FROM_IP]->(ip2);

第三步:案件侦破 - 探索关系网络

数据已就绪,我们的侦查正式开始。

  • 一度关联查询:“找到和张三用同一台设备的人”

    Cypher

    MATCH (u1:User {name: '张三'})-[:USED_DEVICE]->(d:Device)<-[:USED_DEVICE]-(u2:User)
    WHERE u1 <> u2
    RETURN u1.name, u2.name
    

    解析:这个查询在寻找一个 V 字形的模式:从“张三”出发,沿着 USED_DEVICE 关系找到一台设备,再从这台设备出发,沿着反向的 USED_DEVICE 关系找到另一个用户。WHERE u1 <> u2 是为了排除他自己。结果会清晰地告诉你,是“李四”。

  • 终极武器:不定深度查询 - “挖出整个团伙!”

    现在,我们不知道团伙有多深,只知道他们之间可能通过各种方式关联。我们想看看,从“张三”出发,走 4 步之内能牵扯出多少人。

    Cypher

    MATCH p = (u1:User {name:'张三'})-[*1..4]-(u2:User)
    WHERE u1 <> u2
    RETURN p
    

    解析:这句查询是图数据库的精髓!

    • -[*1..4]-:星号*代表任意类型、任意方向的关系,1..4代表探索的深度在 1 到 4 步之间。
    • p = ...RETURN p:意思是将整个匹配到的**路径(Path)**返回。

    见证奇迹的时刻:执行后,请立刻点击结果框左侧的 “Graph” 视图

    你将看到一幅清晰的图谱,它直观地勾勒出了整个欺诈网络:张三 通过共享设备关联到 李四,通过共享 IP 关联到 王五,而 王五 又通过另一个共享设备关联到 赵六。整个团伙的脉络一目了然!而“无辜的路人甲”则孤零零地,与这个网络毫无瓜葛。

  • 原理解析:为什么这么快?

这背后的核心技术,就是我们之前提到的**“免索引邻接 (Index-Free Adjacency)”**。

MySQL 做多层关联查询,就像一个人在北京西站,想去国贸,但他不知道怎么走。他只能先查站内地图(索引)找到去军事博物馆的路线,到了军事博物馆再查地图去天安门,到了天安门再查地图……每一步换乘都是一次昂贵的查找

而图数据库,就像你上地铁前就拿到了一张完整的线路图。从“张三”这个点出发,它只是顺着已经画好的线路(物理指针),一步步地“走”下去,直到找到所有目的地。这个过程是高效的遍历,而不是低效的重复查找。

收束:我们能学到什么?

给 Go 开发者的代码级清单

  1. 了解 Go 生态:Go 社区有成熟的 Neo4j 官方驱动 neo4j-go-driver。你可以像使用 database/sql 一样,在你的 Go 代码里方便地执行 Cypher 查询,并处理返回的复杂结果。
  2. 切换思维模式:下次遇到涉及“路径发现”(如规划物流路线)、“关系推荐”(如猜你喜欢)、“网络分析”(如社交网络或金融风控)等问题时,可以自问一句:“这本质上是不是一个图的问题?”
  3. 组合使用,而非替代:图数据库不一定要替代你现有的 MySQL。你可以将高度关联的数据(如用户关系、风控特征)放入图数据库,然后通过应用层将它与你存储在 MySQL 中的核心业务数据结合起来,各司其职。

给准架构师的架构级教训

  1. 扩充你的“兵器谱”:一个优秀的架构师,必须知道对于特定类别的问题,图数据库是完成任务的正确工具,而不是一个“锦上添花”的玩具。用错误的工具(如尝试在 MySQL 里做实时的多层图遍历)必然会导致项目失败。
  2. 理解“写时预处理”的成本:图模型的威力,源于它在写入时就将“关系”预处理并存储为物理指针。架构师必须理解这个写路径的成本,并判断它对于应用的读路径性能增益是否是值得的。
  3. 它能创造新的业务可能性:图数据库不仅仅是更快地解决老问题。它强大的关系发现能力,可以催生出用其他模型难以实现的全新产品功能。架构师应该思考,这种能力能为业务创造出什么样的新价值。

June 22, 2025 12:00 AM

June 21, 2025

qcrao 的博客

把DDIA读厚(四):关系模型 vs 文档模型,世纪之争与你的抉择

这是《把 DDIA 读厚》系列的第四篇文章。今天,咱们不聊那些高大上的分布式共识,而是回到一切开始之前,聊一个每个后端工程师都必须面对的、最朴素也最重要的问题:你的数据,到底应该怎么存?

引子:建表,还是塞个 JSON?

老哥们,拿到一个新需求,是不是脑子里第一反应就是“这数据存哪个库,表怎么建”?紧接着,灵魂拷问就来了:

  • 是一板一眼地遵循三范式,把数据拆分到好几张关联的表里,然后靠 JOIN 过活?
  • 还是图个痛快,直接在表里弄个 TEXTJSON 字段,把整个对象序列化之后“一把梭”塞进去?

这个问题,表面上是“规范”与“便捷”之争,实际上,背后是两种数据模型哲学的激烈碰撞。这个选择,将在你写下第一行代码之前,就深远地影响你整个系统的架构、性能和未来的可维护性。

回顾与衔接:那些年,我们维护过的"屎山"

在上一篇的结尾,我们留下了一个关于“屎山”系统的思考题。很多时候,一个系统之所以变得难以维护,正是源于其早期做出的、看似无伤大雅的数据模型选择。一个不恰当的模型,会像一根歪掉的顶梁柱,让后续所有的添砖加瓦都变得异常痛苦。

本篇锚点:一切始于数据模型

我们今天讨论的“锚点”,是 DDIA 在第二章开篇的核心论断:

数据模型可能是软件开发中最重要的部分,它不仅影响软件的编写方式,更影响我们对问题的思考方式。

你选择用关系模型还是文档模型,这个决定,定义了你的数据世界观。接下来,我们就通过一个每个 Go 工程师都感同身受的例子,来看看这两种世界观的巨大差异。

发散深潜:一个 Go struct 的"坎坷下凡路"

1. 天堂:我们的"完美"Go struct

在我们的代码世界里,业务对象是纯洁无瑕、高度内聚的。比如,我们要为一个求职网站设计一个“用户简历”结构体,在 Go 里它长这样,非常自然:

Go

type UserProfile struct {
    ID          int64
    Name        string
    Summary     string
    Positions   []Position  // 工作经历
    Educations  []Education // 教育背景
}

type Position struct {
    JobTitle     string
    Organization string
    StartDate    time.Time
    EndDate      time.Time
}

type Education struct {
    SchoolName string
    Degree     string
    StartDate  time.Time
    EndDate    time.Time
}

在内存里,它就是一个清晰的、自包含的树状结构。我们操作它,就是一个整体。

2. 凡间第一站(关系模型):惨遭"大卸八块"

现在,这个完美的 UserProfile struct 要“下凡”持久化到我们最熟悉的 MySQL 里。于是,一场“悲剧”发生了:

  • ID, Name, Summary 这些简单字段,被存入了 users 表。
  • Positions 这个切片,里面的每一个 Position 元素,都被拆出来,存入了 positions 表。为了知道这些工作经历属于谁,我们还得加个 user_id 外键。
  • Educations 切片也一样,被存入了 education 表,同样需要 user_id

看,为了存储一个对象,我们却要同时操作三张表。当要读取时,又需要一个三表 JOIN 的复杂查询,才能在内存里把这个对象辛辛苦苦地“组装”回来。

这种应用代码里的“单一整体”和数据库里的“多张碎表”之间的转换和映射的别扭感觉,就是 DDIA 所说的“对象-关系阻抗不匹配(Object-Relational Impedance Mismatch)”。

3. 凡间第二站(文档模型):“救赎"与新的"困境”

此时,文档数据库(如 MongoDB)像“救世主”一样出现了。它可以完美地解决上面的问题。整个 UserProfile struct 可以被序列化成一个 JSON,作为一个单一文档存进去。

JSON

{
  "id": 123,
  "name": "张三",
  "summary": "资深后端工程师...",
  "positions": [
    { "job_title": "高级工程师", "organization": "A公司", ... },
    { "job_title": "架构师", "organization": "B公司", ... }
  ],
  "educations": [ ... ]
}

一次写入,一次读取,干脆利落,几乎没有“阻抗”。爽!

但是,爽是有代价的。 当你的数据关系不再是简历这种简单的树状,而是出现了多对多的网状关系时,文档模型的“阻抗不匹配”就来了。

比如,简历里的“公司”应该是一个独立的实体,很多人可能都在同一个“A 公司”工作过。这时,你怎么办?

  • 方案 A(嵌入):你在每份简历里都冗余地存一份“A 公司”的详细信息。如果 A 公司改名了,你就得去更新所有曾在 A 公司工作过的成千上万份“简历”文档。这简直是场灾难。
  • 方案 B(引用):你在“简历”文档里只存一个 company_id。当需要显示公司名时,你的 Go 代码就得先查出简历,再根据 company_id 去发起第二次查询获取公司信息。这等于把 JOIN 的工作从数据库硬生生搬到了你的应用代码里。

看,文档模型并没有消灭“阻抗不匹配”,它只是在这种场景下,将“阻抗”从数据库层转移到了你的应用层。

4. 历史的回响:今天的我们,昨天的他们

DDIA 提出了一个惊人的观点:今天的文档数据库,像极了上世纪 70 年代的层次模型数据库 IMS。IMS 当时也是王者,数据结构和今天的 JSON 如出一辙,同样擅长处理一对多关系,也同样在多对多关系上栽了跟头。

最终,关系模型凭借其灵活的 JOIN 和声明式的 SQL,击败了 IMS 和网络模型,统治了世界三十年。

这段历史给我们的启发是:技术是个圈。我们今天在文档模型上遇到的多对多关系的纠结,半个世纪前的工程师们早已经历过。 理解这一点,能让我们在做技术选型时,多一分清醒,少一分盲从。

看到这里,有经验的工程师可能会有个疑问:今天我们津津乐道的文档模型,把数据按树状结构嵌套存储,这听起来和上世纪 70 年代就被关系模型“淘汰”掉的层次模型数据库(如 IMS)何其相似。难道说,技术发展了半个世纪,只是在原地打转吗?

这当然不是技术的倒退。用“螺旋上升”来形容这个过程,要精确得多。

我们确实是在一个相似的“问题地形”上作战——即如何高效处理**“一对多”的、自包含的树状数据**——但我们今天的武器装备,早已鸟枪换炮。当年的 IMS 运行在内存和算力极其宝贵的巨型机上,而今天的文档数据库,则享受着海量内存、高速网络和原生分布式架构的红利。它们的查询语言、灵活性和容错能力,更是 IMS 望尘莫及的。

然而,尽管技术天翻地覆,那个根本性的架构权衡却从未改变。

这个永恒的权衡就是:当你选择一个为特定场景高度优化的“专家”时,你必然会牺牲它在其他场景下的“通用性”。

文档模型,就是一位处理“树状数据”的顶级专家。它能用最自然、最高效的方式来存取一份简历、一张订单或者一篇博客及其评论。这是它的“专长领域”。

而它为此付出的“代价”,就是在处理高度互联、复杂交织的**“网状数据”(多对多关系)**时,会变得笨拙。这时,反而是看似“传统”的关系模型和它的 JOIN 操作,来得更直接、更优雅。

所以,理解这段历史,不是为了厚古薄今,或者给技术选型下一个简单的结论。它的真正价值在于,赋予我们一种架构上的“模式识别”能力。

它能帮助我们超越“哪个技术更时髦”的表面争论,在接到一个新需求时,能立刻在脑中判断出其核心数据的“形状”,并清醒地自问:“我眼前的这个‘问题地形’,究竟是更像一棵树,还是一张网?”

只有回答了这个问题,我们才能真正做出明智的、经得起时间考验的技术抉择。

收束:我们能学到什么?

给 Go 开发者的代码级清单

  1. 优雅地处理 NULL:当你的 Go 代码与关系型数据库交互时,请善用 database/sql 包中的 sql.NullString, sql.NullInt64 等类型。这能让你清晰地处理数据库中 NULL 和空值(''0)的区别。
  2. 防御性解析 JSON:当你的 Go 代码处理来自文档数据库的 JSON 时,要时刻假设任何字段都可能缺失。在 struct 中使用指针类型 *string,或者利用 json 标签的 omitempty 选项,能帮你更好地处理数据的不确定性。
  3. 选型心法:如果你的核心业务对象是自包含的、很少需要与其他对象做复杂关联的(比如一篇文章和它的评论),文档模型可能非常适合。如果你的对象之间引用关系复杂(比如一个电商订单关联了用户、多个商品、优惠券、仓库等),关系模型通常是更稳妥、长期来看更易维护的选择。

给准架构师的架构级教训

  1. 洞察数据的“关系重心”:作为架构师,首要任务是洞察业务领域的核心数据结构。数据的关系“重心”是层次化的(一对多),还是网络化的(多对多)?这个判断是所有数据存储决策的基石。
  2. 权衡“灵活性”与“约束”:DDIA 提出了“写时模式”与“读时模式”的对比。这本质上是在权衡“前期灵活性”和“长期维护成本”。架构师需要决定,管理数据多样性的“痛苦”应该由谁(数据库还是应用层)、在哪个阶段来承担。
  3. 预判数据的“成长性”:数据之间的连接只会越来越多。今天的简单文档,明天可能就要关联五个新实体。架构师需要选择一个不仅能解决当前问题,更能优雅地演进以支持未来更复杂连接的模型。避免让团队在一年后陷入“模拟 JOIN”的地狱。

June 21, 2025 12:00 AM

June 20, 2025

qcrao 的博客

把DDIA读厚(三):写给“未来你”的系统设计原则

这是《把 DDIA 读厚》系列关于第一章的最后一篇文章。在开始前,我们先快速回顾一下本系列的创作“心法”:我们以 DDIA 的核心思想为锚点,用一个接地气的深潜案例将其“翻译”成我们的实战经验,最后收束为可供 Go 工程师和准架构师借鉴的行动指南。

回顾与衔接:我们究竟在维护什么?

在上一篇的结尾,我们留下了一个拷问灵魂的问题:

我们都经历过维护“屎山”代码的痛苦。回想一下,你觉得那个系统最让你头疼的地方,是它的运维极其复杂(可操作性差),还是代码逻辑绕来绕去难以理解(简单性差),亦或是牵一发而动全身,难以修改(可演化性差)?

这个问题没有标准答案,因为通常一个“屎山”系统,这三个问题会并发出现,形成一个令人绝望的恶性循环。

比如一个陈旧的订单系统:

  • 可操作性差:当一个订单状态卡住时,没有任何有效的监控和后台工具。唯一的办法就是 SSH 到线上机器,用 grep 在几百 GB 的非结构化日志里大海捞针,祈祷能找到点线索。运维团队视其为“禁区”。
  • 简单性差:核心的 Order 结构体有超过 100 个字段,其中一半你都不知道是干嘛的。核心的 ProcessOrder 函数长达 2000 行,里面是层层嵌套的 if-else。没人敢动它,因为没人能完全理解它。
  • 可演化性差:系统与一个古老的支付网关实现紧密耦合。当业务要求接入一个新的支付渠道(比如微信或支付宝)时,你发现支付相关的逻辑像鬼一样散落在 15 个不同的文件里。每次修改都像在拆炸弹。

这三种痛苦,恰好就是 DDIA 为我们总结的“可维护性”的三大支柱。它们是我们今天讨论的起点。

本篇锚点:软件的真正成本

我们今天的“锚点”,是 DDIA 提出的一个朴素但常被忽视的真理:

软件的大部分成本不在于初始开发,而在于其持续的、长期的维护。 1

这个维护工作包括:修复 Bug、保持系统平稳运行、调查失效、适配新的平台、为新的业务场景修改功能、偿还技术债,以及添加新功能。我们写的每一行代码,都是在给“未来的自己”或“未来的同事”挖坑或铺路。

为了让未来的路更好走,DDIA 提出了可维护性的三大设计原则:可操作性(Operability)简单性(Simplicity)**和**可演化性(Evolvability)

发散(一):可维护性的三大支柱

  • 可操作性 (Operability):让运维不再“背锅”

    这指的是,我们的系统设计应该让运维团队的生活尽可能轻松 22。一个具有良好可操作性的系统,应该有好的监控、完善的自动化支持、清晰的文档和可预测的行为 3。这不仅仅是运维团队的事,更是我们开发者的责任。

  • 简单性 (Simplicity):用好的抽象对抗复杂度

    这里的简单,不是指功能简陋,而是指移除“意外的”复杂度(accidental complexity) 4。这种复杂度并非问题本身所固有的,而是由我们拙劣的实现方式引入的。

    对抗复杂度的最强武器,就是好的抽象。一个好的抽象,能将大量的实现细节隐藏在一个干净、易于理解的外观背后 5。DDIA 举了 SQL 的例子:一句简单的 SELECT 查询,背后隐藏了存储引擎、查询优化器、并发控制等极其复杂的实现,但作为使用者,我们无需关心这些细节 6。

  • 可演化性 (Evolvability):让系统拥抱变化

    这指的是我们应该让工程师在未来能轻松地对系统进行修改 777。它也被称为可修改性或可塑性。这是实现敏捷开发在系统层面的基石。

发散(二)深潜:从"代码重构"到"架构重构"的视野升级

马丁·福勒的经典著作《重构》是我们每个开发者的必读物,它教会我们如何在代码层面保持整洁、提高可维护性。

而 DDIA 则将“重构”这个思想,提升到了一个全新的维度——架构重构。书中提到:“在本书中,我们将探索在更大数据系统层面上提高敏捷性的方法,可能由几个不同的应用或服务组成。” 8

我们上一篇文章深入剖析的 Twitter 时间线案例,就是这种“架构重构”的完美体现。我们不妨从可演化性的角度,重新审视那次迁移:

那次从“读放大”到“写放大”的迁移,之所以能够在线上平滑地完成,而不是演变成一场灾难,正是因为其系统设计具备了良好的可演化性

  • 它允许新旧逻辑并行:通过“双写”,新(写时扇出)旧(写入tweets表)两条写路径可以同时存在。
  • 它允许增量迁移:通过“灰度发布”,可以先让一小部分用户使用新的读路径(从缓存读),验证正确性后再逐步放量。
  • 它允许组件解耦:整个迁移可以被拆解为“扇出服务”、“时间线缓存”、“回填批处理任务”等多个独立的组件,由不同团队开发和部署。

这种能力,就是架构层面的“可演化性”。它允许我们对系统的核心进行“外科手术”式的改造,而不需要“推倒重来”。一个无法演进的系统,最终的命运就是被完全重写,而这往往是项目失败的开始。

收束:我们能学到什么?

给 Go 开发者的代码级清单:

  • (可操作性)写给人也写给机器的日志:别再用 fmt.Println 或简单的 log.Print。使用结构化的日志库(如 zerolog, slog),输出 JSON 格式的日志。这能让日志不仅人可读,更可以被 Fluentd、Logstash 等工具轻松地采集和分析。
  • (可操作性)让你的服务会“说话”:使用 prometheus/client_golang 库,为你的服务暴露核心的业务和性能指标。并提供一个 /health 端点,清晰地告诉外部系统你的健康状况。
  • (简单性)用好 Go 的接口(interface):接口是 Go 语言中创造抽象的利器。将你的数据访问逻辑、外部服务调用逻辑等,都隐藏在清晰的接口背后。这能让你的核心业务逻辑与具体的实现细节(比如是用 MySQL 还是 PostgreSQL)解耦。
  • (可演化性)拥抱依赖注入:不要在代码里写死组件的创建逻辑。通过参数传递接口,而不是创建具体的结构体。这能让你的代码极易测试,也为未来更换组件实现铺平了道路。

给准架构师的架构级教训:

  • 像外科医生一样思考“系统接缝”:一个架构师的核心工作之一,就是识别出系统中未来最可能发生变化的“接缝(Seams)”。在这些接缝处,设计出稳定、清晰的接口(如 API、消息格式)。这能让接口两边的系统可以独立演进。
  • 从第一天起就投资“可操作性”:不要把监控、自动化部署、日志规范等当作“以后再说”的事情。它们是一个可维护系统的核心功能,而不是附属品。架构师必须为这些看似“不产出业务价值”的工作争取资源,因为它们决定了系统能活多久。
  • 简单是深思熟虑的结果,而不是起点:一个看起来简单的架构,背后往往是设计者对业务和技术极其深刻的理解,以及对无数种复杂可能性的“拒绝”。架构师的工作,很大程度上是“说不”的艺术——对不必要的复杂性说不,对模糊不清的边界说不。

总结与第一章回顾

DDIA 第一章的三个核心概念,就像是支撑系统设计这座大厦的三根支柱,它们之间相互关联,也相互制约。

我们可以用一个比喻来总结:

  • 可靠性,是确保你这辆车在各种路况下(风霜雨雪、路面坑洼)都能安全地把你送到目的地。
  • 可扩展性,是确保当车上坐满了乘客、装满了行李后,它依然能以足够快的速度平稳行驶。
  • 可维护性,是确保这辆车的设计足够好,让任何一个合格的修理工都能轻松地对它进行保养、维修,甚至在未来给它更换更强大的引擎。

作为系统设计师和架构师,我们的工作,就是在理解业务的前提下,在这三个目标之间做出明智的、有意识的权衡(Trade-offs)。这,就是贯穿 DDIA 全书,也是我们所有后端工程师需要修炼的“心法”。

June 20, 2025 12:00 AM

June 19, 2025

qcrao 的博客

把DDIA读厚(二):从推特时间线,看懂可扩展性的本质

这是《把 DDIA 读厚》系列的第二篇文章。在上一篇,我们聊了“可靠性”,探讨了如何从“凭感觉”的容错,进化到真正的“可靠性工程”。今天,我们来啃下一个更硬、也更容易被误解的骨头:可扩展性(Scalability)。

回顾与衔接:当"加机器"也解决不了问题

在上一篇的结尾,我们留下了一个思考题:

你是否曾遇到过一个性能瓶颈,是简单的水平扩展无法解决的?它背后的“负载模式”是什么?

这是一个非常经典的问题,几乎每个后端工程师的职业生涯里都会遇到。一个常见的例子就是**“热点账户”“热点数据”**问题。

想象一个在线教育平台,所有学员都需要在晚上 8 点准时参加一场热门直播课。8 点一到,成千上万的学员同时涌入,系统需要为每个人记录登录、签到等行为。即使你的应用服务器可以轻松地水平扩展(加机器),但所有的写请求最终都指向了同一个逻辑实体——比如数据库里代表这门课程的同一行数据,或者需要更新的同一个总签到人数

这时,无论你加多少台应用服务器,数据库的那一行数据、那一个计数器,就成了整个系统的瓶颈。所有的请求都在排队等待更新它。这就是一个典型的“加机器”也解决不了的问题。它背后的“负载模式”就是:对单一实体的极度写请求集中

这个问题引出了我们今天要讨论的核心:在思考“扩展性”之前,我们必须先学会如何描述负载

本篇锚点:究竟什么是可扩展性?

我们今天讨论的“锚点”,源自 DDIA 对“可扩展性”的精辟定义:

可扩展性不是一个简单的“是/否”标签,而是关于“如果系统的负载以某种特定的方式增长,我们有哪些应对方案?”的讨论。 1

换句话说,当有人问你“你的系统能扩展吗?”时,一个专业的回答不是“能”或“不能”,而是反问:“你指的是哪方面的扩展性?是应对并发用户数增长,还是读写比例变化,或者是数据总量的增加?”

所以,要谈扩展性,我们必须先拥有一套描述它的语言。书中给了我们两个关键工具:描述负载描述性能

发散(一):扩展性的语言

  • 描述负载 (Describing Load)

    DDIA 告诉我们,负载不能用单一数字来描述,而应该用一组最能反映系统架构特点的**“负载参数”**来刻画 2。比如:

    • Web 服务器的每秒请求数。
    • 数据库的读写比例。
    • 实时聊天室的同时在线人数。
    • 缓存的命中率。
  • 描述性能 (Describing Performance)

    • 响应时间 vs 延迟

      :响应时间是用户感受到的端到端时间,是我们的金标准 3

      。而延迟,通常指请求在队列中等待服务的那部分时间 4

    • 百分位点的重要性

      :别再用“平均响应时间”来衡量性能了!它极具欺骗性。一个耗时 10 秒的请求,会被 99 个耗时 100 毫秒的请求平均得无影无踪。我们必须关注

      p95、p99 甚至 p999 的响应时间

      。DDIA 引用了亚马逊的例子:那些请求耗时最长的用户,往往是账户里数据最多的“高价值用户” 5

      。为了他们的体验,优化长尾延迟至关重要。

发散(二)深潜:Twitter 时间线背后的"读/写放大"之战

掌握了描述负载和性能的语言,我们就可以来解剖一个神级案例了。这个案例几乎是所有后端工程师理解“读/写放大”和架构权衡的必修课。

Twitter 的核心功能之一是展示用户的“主页时间线”(你关注的所有人发的推文列表)。我们来看它的负载参数:发推请求平均 4.6k QPS,而时间线读取请求高达 300k QPS 6。读请求是写请求的近 65 倍

面对这个负载模式,Twitter(或者说我们)有两种截然不同的实现思路:

思路一:读时合并(读放大 Read Amplification)

这是最符合直觉的方案,就像传统的数据库设计。

  • 写操作:当一个用户发推时,操作非常简单,只需向一个全局的 tweets 表里插入一条记录。成本极低。

  • 读操作

    :当一个用户要看自己的主页时间线时,操作非常复杂:

    1. 查找该用户关注的所有人。
    2. 对每一个被关注的人,去 tweets 表里查询他们最近的推文。
    3. 将所有这些推文在内存中合并、按时间排序。 这个过程涉及大量的数据库 JOIN 和计算。一次简单的用户读取,会“放大”成一场数据库的查询风暴。这就是典型的**“读放大”**架构。

思路二:写时扇出(写放大 Write Amplification)

这个方案反其道而行之。

  • 写操作

    :当一个用户(比如拥有 1000 个粉丝的

    user_A
    

    )发推时,操作变得非常复杂:

    1. 将推文写入 tweets 表。
    2. 立刻查询出user_A的 1000 个粉丝。
    3. 将这条新推文的 ID,分别写入这 1000 个粉丝的“时间线缓存”中。 一次用户写入,被“放大”成了 1001 次数据库写入。这就是**“写放大”**架构。
  • 读操作:当一个用户要看自己的主页时间线时,操作变得极其简单:直接从自己的“时间线缓存”里读取推文 ID 列表即可,快如闪电。

权衡的艺术:

Twitter 最终选择了思路二。为什么?因为它们的负载模式(300k 读 vs 4.6k 写)决定了,让少数的写操作付出巨大代价,来换取海量的读操作能极速完成,是绝对划算的买卖 7。

这个案例告诉我们,可扩展性设计的本质,就是识别出你系统中那个被放大得最厉害的负载,然后将你的架构重心倾向于优化它

当然,故事还没完。对于有三千万粉丝的明星用户,一次发推就要写入三千万次缓存,这谁也顶不住。所以 Twitter 最终采用了混合模型:对普通用户使用“写放大”,对明星用户则退回“读放大”的模式,在用户读取时再单独拉取和合并 8。这再次证明了,没有一招鲜的银弹,好的架构总是充满了务实的权衡。

收束:我们能学到什么?

给 Go 开发者的代码级清单:

  • 把观测作为本能:别只满足于 log.Printf。使用 Prometheus 客户端库(prometheus/client_golang)来武装你的 Go 服务。你不仅要记录平均延迟,更要用 HistogramSummary 类型来追踪 p95/p99 延迟。你无法优化你衡量不了的东西。
  • 识别你代码中的“放大”模式:审视你的代码。获取一个列表,然后在 for 循环里挨个查询详情,这是“读放大”。更新一个商品,然后去刷新十个相关的缓存,这是“写放大”。识别它们是优化的第一步。
  • 拥抱批量处理:在你的 Go 服务中,主动提供批量处理的接口(比如 GET /api/users?ids=1,2,3),而不是只有 GET /api/users/:id。这能让你的服务成为一个“友好”的上游,帮助整个系统的其他部分避免“读放大”。

给准架构师的架构级教训:

  • 可扩展性是一个“故事”,而不是一个“数字”:别再问“这个系统能扩展吗?”。要学会问:“这个系统在应对‘读请求/秒’这个负载参数增长时,表现如何?”或者“当‘单个用户数据量’增长时,它的瓶颈在哪里?”。架构师的语言必须是精确的。
  • 找到你系统的“核心矛盾”:你的系统里,哪个负载参数比其他的要高出一到两个数量级?是读 QPS?是写 QPS?还是并发连接数?整个架构设计都应该围绕这个最主要的矛盾来展开。
  • 写路径 vs. 读路径的权衡是一门艺术:Twitter 的案例完美展示了,架构师的一个关键工作,就是决定把计算的复杂性更多地放在“写路径”(如发推时的扇出),还是“读路径”(如读时间线时的合并)。这个决策的杠杆,就是你的核心负载模式。
  • 不存在“万能灵药”:Twitter 对明星用户的特殊处理告诉我们,一个好的架构,往往是多种策略的混合体。不要试图用一个方案解决所有问题,要学会对负载进行切分,并应用不同的优化策略。

总结与下一篇的思考题

可扩展性不是简单地“加机器”,它是一门基于量化分析架构权衡的严谨工程学科。它的核心,在于深刻理解你的系统所承受的独特“负载模式”,并把你的设计重心,放在解决那个被放大得最厉害的矛盾上。

留给你的思考题(我们将在下一篇探讨):

我们都经历过维护“屎山”代码的痛苦。回想一下,你觉得那个系统最让你头疼的地方,是它的运维极其复杂(可操作性差),还是代码逻辑绕来绕去难以理解(简单性差),亦或是牵一发而动全身,难以修改(可演化性差)?

June 19, 2025 12:00 AM

June 18, 2025

qcrao 的博客

把DDIA读厚(一):从“凭感觉”到可靠性工程

这是《把 DDIA 读厚》系列的第一篇文章。在开始前,我想先跟您聊聊这个系列想做什么。市面上解读经典的书不少,但大多是摘要和复述。咱们想玩点不一样的,真正把这本“屠龙宝刀”读厚

我们的方法很简单,称之为 “锚点-发散-收束”

  1. 锚点 (Anchor):每一篇,我们都从 DDIA 中精炼出一个最核心、最关键的思想作为“锚点”,确保我们的讨论不偏离航道。
  2. 发散 (Diverge):我们会围绕这个“锚点”,结合一个你我他在工作中都可能遇到的具体场景,进行深度剖析,把书中的理论“翻译”成看得见、摸得着的工程实践。
  3. 收束 (Conclude):最后,我们会把这些讨论“收束”成可以立即应用的经验和教训,既有给一线开发者的代码级清单,也有给准架构师的架构级思考。

好了,交代完毕。现在,让我们正式开始第一次的“读厚”之旅。

引子:你的服务可靠吗?还是只是"没出事"?

干咱们这行的,谁没在半夜三点被电话叫起来过?当一个新服务上线,我们嘴上说着“应该没问题”,心里可能早就开始“烧香拜佛”了。这种“靠天吃饭”的感觉,其实源于我们对“可靠性”的理解还停留在直觉层面。

DDIA 的第一章,正是要帮助我们完成这个转变:从“凭感觉”做设计,到用“工程思维”构建可靠性。

本篇锚点:故障 (Fault) vs. 失效 (Failure)

DDIA 开篇就给我们扔出了一个最基础,但 90%的工程师都会混淆的概念模型,这也是我们今天讨论的“锚点”:

一个可靠的系统,其目标不是杜绝故障(Fault),而是防止故障演变成失效(Failure)。

这两个词儿必须掰扯清楚:

  • 故障 (Fault):指的是系统里某个零件出问题了。比如,数据库突然一个慢查询,网络抖了一下丢了几个包,你写的一个 Go 服务因为空指针 panic 了。
  • 失效 (Failure):指的是整个系统拉胯了,没法给用户提供服务了。比如,用户的 API 请求直接收到了 500 错误。

这就好比你感觉有点头晕(这是故障),但你还能继续跟产品经理 battle(系统没失效)。可要是你直接晕倒了(这就是失效),那需求评审会就得黄。

理解了这个区别,我们就明白了,我们的工作不是幻想一个“零故障”的乌托邦,而是设计一个皮实的、能容忍故障的系统。

发散深潜:一个"普通"的重试,如何引发"雪崩"?

聊到容错,咱们的肌肉记忆第一反应就是“加上重试”。调用下游服务超时了?没事,加个重试,再加个几十毫秒的随机延迟,齐活。我知道,你肯定也写过这样的代码。别不好意思,我也写过。

在大多数情况下,这个模式工作得很好。 它能有效地处理网络偶尔的抖-动、下游服务临时的、随机的抖动。这些都属于瞬时性、非系统性的故障。

但是,当故障模式改变时,这个“好”模式就可能变成“帮凶”。

场景还原:

假设你的服务 A 需要处理一个请求,这个请求需要去服务 B 获取一批用户的详细信息。服务 B 是一个稳定的第三方服务,但有速率限制:100 QPS。

一个隐藏的“坑”:

你在服务 A 里写了段逻辑,它需要处理一个包含 100 个用户 ID 的列表。最直观的写法,自然就是 for 循环这个列表,然后挨个去用户服务 B 查询。这种“啰嗦”的调用模式,在低负载下,可能并不会暴露问题。

风暴的来临:

某天,一个营销活动让服务 A 的流量飙升到了 2 QPS。现在,服务 A 会尝试在 1 秒内向服务 B 发起 200 次调用。

灾难开始了:

  1. 前 100 次调用成功了,瞬间耗尽了服务 B 在这一秒的全部配额。
  2. 后 100 次调用,全部因为限流而失败(收到了 429 Too Many Requests)。
  3. 这 100 次失败的调用,全部进入了我们那个“看似良好”的重试逻辑。
  4. 紧接着,下一秒到来了,服务 A 新的 200 次请求又来了。但与此同时,上一秒失败的 100 次重试请求也跟着涌入!
  5. 现在,在同一个时间窗口内,有 200 次新请求 + 100 次重试请求,总共 300 个请求涌向了只有 100 QPS 容量的服务 B。
  6. 服务 B 的配额再次被瞬间耗尽,导致更大规模的 429 错误和更多的重试。

系统进入了“重试风暴”,恶性循环,最终雪崩。我们那个平时处理瞬时故障的“好”模式,在面对系统性的、与负载强相关的故障时,不但没有解决问题,反而放大了故障,最终导致了整个功能的“失效”。

收束(一):从"治本"到"治标"的正确姿势

光吐槽不给方案,那是耍流氓。这事儿得两手抓,一手治本,一手治标。

  • 治本(战略层):优化你的调用模式

    最根本的解决方案,是让服务 A 成为一个“友好”的调用方。我们应该修复那个在循环中调用的逻辑,用一次“批量调用”替代多次“循环调用”。先收集所有需要查询的用户 ID,然后通过服务 B 提供的一个批量接口(如 GET /users?ids=1,2,3)一次性获取所有数据。

  • 治标(战术层):用“组合拳”代替“王八拳”

    即使我们优化了调用模式,也仍然可能因为突发流量而遇到限流。此时,我们需要一套比“简单重试”更成熟的战术组合。

    1. 指数退避 + 随机抖动:别傻乎乎地每次都等同样的时间。每次重试的间隔应该指数级增长(如 100ms, 200ms, 400ms…),并在这个基础上增加一个随机量。这能给下游服务真正的恢复时间,并避免所有客户端“同步”重试。
    2. 断路器模式:这是咱们工具箱里的大杀器。就像你家里的保险丝,烧了就断,总比把整个房子点了强。当来自服务 B 的失败在短时间内达到阈值时,断路器“跳闸”,在接下来的一段时间内,服务 A 所有对服务 B 的调用都会在内部立即失败,根本不发网络请求。这既保护了我们自己,也保护了下游。Go 社区有许多成熟的库如 sony/gobreaker 可以轻松实现。
    3. 客户端限流:做个有素质的调用方。如果服务 B 明确告知了它的速率限制,我们可以在服务 A 内部就实现一个对应的限流器(例如使用 Go 官方的 golang.org/x/time/rate 包),主动将对 B 的调用速率控制在限制之内。

收束(二):我们能学到什么?

从这个案例中,不同角色的工程师可以汲取不同的经验。

给 Go 开发者的代码级清单:

  • 区分错误,别一视同仁:在你的 if err != nil 之后,判断错误的类型。网络超时可以重试,但 4xx 类的客户端错误、429 限流,就不应该无脑重试。
  • 让你的写接口支持幂等:这是让调用方敢于重试的底气。最简单的方式,就是让调用方在 Header 里传一个唯一的 X-Request-ID,你在服务端检查并存储它,防止重复处理。
  • 为每一个外部调用包裹 context 超时:无论是数据库、Redis 还是 gRPC 调用,永远使用 context.WithTimeoutcontext.WithDeadline,别让一个慢下游拖垮你的整个服务。
  • 在测试里“搞破坏”:别只测正常流程。用 mock 模拟你的下游依赖返回超时、返回429、返回503。这能逼着你写出更健壮的容错代码。

给准架构师的架构级教训:

  • 定义你的故障模型:作为架构师,你需要思考:“我的系统主要会遇到哪种类型的故障?是随机瞬时的,还是和负载相关的系统性的?” 不同的故障模型,需要完全不同的容错策略。
  • 设计服务间的“契约”:服务间的关系不是随意的。一个好的架构师会去推动定义清晰的“服务契约”,这包括:明确的速率限制、提供批量处理接口(以避免“啰嗦”的调用模式)、以及规范化的错误码。
  • 将“可观测性”作为一级公民:设计系统时,就要想好如何去观测它。我需要哪些 metrics 才能区分出“瞬时网络抖动”和“持续的限流”?日志里需要记录哪些关键信息(比如请求 ID,下游延迟),才能快速定位到是哪个上游在“滥用”我的服务?
  • 选择可预测的失效模式:一个因“重试风暴”而雪崩的系统,其行为是混乱且不可预测的。而一个因“断路器”跳闸而暂时拒绝服务的系统,其行为是可预测的。架构师的工作,很多时候就是选择一种更安全、更可预测的“死法”。

总结与下一篇的思考题

DDIA 第一章“可靠性”部分的核心,是帮助我们建立一种工程化的思维方式,去替代“凭感觉”的直觉。它要求我们深入理解故障的本质,并设计出能够容忍故障,而不是掩盖故障的系统。

我们今天深挖的“重试”案例,正是这一思想的绝佳注脚。

留给你的思考题(我们将在下一篇探讨):

我们经常听到用“加机器”来解决性能问题。但书中 Twitter 的例子告诉我们,有时架构选择比加机器更重要。你是否曾遇到过一个性能瓶颈,是简单的水平扩展无法解决的?它背后的“负载模式”是什么?

June 18, 2025 12:00 AM

June 04, 2025

李文周的博客

使用 gzip 拯救你的 varchar

在处理大量数据时,数据压缩是优化存储和传输效率的重要手段。在 Go 语言中,我们可以通过自定义 JSON 的 Marshal 方法,实现在数据入库前自动进行 gzip 压缩,从而减少存储空间占用并提高传输效率。

June 04, 2025 03:14 PM

April 06, 2025

李文周的博客

使用 chromedp 操作 chrome

chromedp 是一个基于 Go 语言开发的 Chrome/Chromium 浏览器自动化工具,通过 DevTools Protocol 实现高效页面控制。

April 06, 2025 11:32 AM

March 31, 2025

李文周的博客

pulsar 介绍及Pulsar Go client 使用指南

Pulsar 是一种分布式消息流平台,具有高性能、可扩展性和多租户支持,适用于实时数据处理和消息传递。

March 31, 2025 01:14 PM

January 31, 2025

李文周的博客

[译]Go Protobuf:新的 Opaque API

Go Protobuf 新增了一套 Opaque API,通过生成不透明结构体和实现惰性解码,来减少消息体内存占用并提高性能。

January 31, 2025 01:22 PM

December 24, 2024

李文周的博客

Go语言中的迭代器和 iter 包

很多流行的编程语言中都以某种方式提供迭代器,其中包括 C++、Java、Javascript、Python 和 Rust。Go 语言现在也加入了迭代器。iter 包是 Go 1.23 新增的标准库,提供了迭代器的基本定义和相关操作。

December 24, 2024 08:27 AM

November 30, 2024

李文周的博客

SQL优先的 Go ORM 框架——Bun 介绍

Bun 是一个 SQL 优先的 Golang ORM(对象关系映射),支持 PostgreSQL、MySQL、MSSQL和SQLite。它旨在提供一种简单高效的数据库使用方法,同时利用 Go 的类型安全性并减少重复代码。

November 30, 2024 08:09 AM

November 07, 2024

李文周的博客

ORM 框架 ent 介绍

ent 是 Facebook 开源的一款 Go 语言实体框架,是一款简单而强大的用于建模和查询数据的 ORM 框架。

November 07, 2024 01:18 PM

August 04, 2024

李文周的博客

[译] Prometheus 运算符

Prometheus 支持许多二元和聚合运算符。

August 04, 2024 05:36 AM

July 16, 2024

李文周的博客

[译]查询 Prometheus

Prometheus 提供了一种名为 PromQL (Prometheus Query Language) 的功能性查询语言,允许用户实时选择和聚合时间序列数据。表达式的结果既可以显示为图形,也可以在 Prometheus 的表达式浏览器中显示为表格数据,或者被外部系统通过 HTTP API 使用。

July 16, 2024 03:26 PM

July 15, 2024

李文周的博客

Prometheus 介绍

prometheus 是目前主流的一个开源监控系统和告警工具包,它可以与 Kubernetes 等现代基础设施平台配合,轻松集成到云原生环境中,提供对容器化应用、微服务架构等的全面监控。本文将带你快速了解 Prometheus 相关概念。

July 15, 2024 03:31 PM

April 14, 2024

李文周的博客

go-redis配置链路追踪

Open-Telemetry的第三方软件包合集 包括了多个社区中常用库的OpenTelemetry支持。随着 OpenTelemetry的不断迭代,相信整个链路追踪的生态也会越发完善。

April 14, 2024 01:57 PM

GORM配置链路追踪

Open-Telemetry的第三方软件包合集 包括了多个社区中常用库的OpenTelemetry支持。随着 OpenTelemetry的不断迭代,相信整个链路追踪的生态也会越发完善。

April 14, 2024 01:57 PM

zap日志库配置链路追踪

Open-Telemetry的第三方软件包合集 包括了多个社区中常用库的OpenTelemetry支持。随着 OpenTelemetry的不断迭代,相信整个链路追踪的生态也会越发完善。

April 14, 2024 01:44 PM

April 08, 2024

李文周的博客

gRPC的链路追踪

Open-Telemetry的第三方软件包合集 包括了多个社区中常用库的OpenTelemetry支持。随着 OpenTelemetry的不断迭代,相信整个链路追踪的生态也会越发完善。

April 08, 2024 01:03 PM

基于OTel的HTTP链路追踪

Open-Telemetry的第三方软件包合集 包括了多个社区中常用库的OpenTelemetry支持。随着 OpenTelemetry的不断迭代,相信整个链路追踪的生态也会越发完善。

April 08, 2024 01:02 PM

March 24, 2024

李文周的博客

Jaeger快速指南

分布式追踪可观测平台(如 Jaeger)对于架构为微服务的现代软件应用程序至关重要。Jaeger 可以映射分布式系统中的请求流和数据流。这些请求可能会调用多个服务,而这些服务可能会带来各自的延迟或错误。Jaeger 将这些不同组件之间的点连接起来,帮助识别性能瓶颈、排除故障并提高整体应用程序的可靠性。Jaeger是100%开源、云原生、可无限扩展的。

March 24, 2024 11:49 AM

March 17, 2024

李文周的博客

OpenTelemetry Go快速指南

本教程将演示如何在 Go 中使用 OpenTelemetry,我们将手写一个简单的应用程序,并向外发送链路追踪和指标数据。

March 17, 2024 02:21 PM

OpenTelemetry 介绍

OpenTelemetry 可以用于从应用程序收集数据。它是一组工具、API 和 SDK 集合,我们可以使用它们来检测、生成、收集和导出遥测数据(指标、日志和链路追踪),以帮助分析应用的性能和行为。

March 17, 2024 12:04 PM

December 10, 2023

李文周的博客

go-elasticsearch使用指南

本文是 go-elasticsearch 库的使用指南。

December 10, 2023 08:10 AM

December 05, 2023

qcrao 的博客

如何一键生成前端代码

作为后端程序员,我一直想独立开发一个产品,哪怕只是一个简单的落地页。但由于前端开发对我来说颇有难度,这个愿望一直未能实现。

直到 ChatGPT 发布,我才借助它来共同开发了一个名为“Bulk Delete ChatGPT”的插件,至今已拥有超过1600名用户,并时常收到好评。

由于插件依赖于 ChatGPT 官网的页面样式,而官网经常更新,因此需要频繁更新插件代码。但由于我的能力限制,无法及时发布最新版本,这也导致了一些差评。

在我发布了三次变更后,我就想能不能在插件里加一个通知功能啥的,这样万一官网有升级,插件不能工作了,也有个通知让用户知晓。而且当我发布新版本后,也可以给用户发布一条通知。但是最终感觉这个有点难度,而且需要申请很多权限,审核难度比较大。最终放弃了。

不过因为我经常更新版本的行为感动了自己,所以我加了个乞讨按钮,企图得到国际赞助,然而并没有:

后来,我听说开发网站并吸引流量后可以投放广告、赚取广告费。因此,我决定尝试制作一个落地页,以探索这一流程。

直到最近,几款一键生成前端代码的工具的出现,帮助我成功制作了一个落地页。我尝试了两种工具:

先用了 tldraw,发现只能生成一次,之后想要优化就不知道怎么做了。(也许是我没找到方法)

之后又用了 screenshot-to-code 这个工具,他可以方便地进行迭代:

最终出来的效果是这样的:

网址是:https://chatgpt-bulk-delete.qcrao.com/

效果非常好,缺点是这两个工具都需要有 OpenAI 的 API key,要花钱的。

整个过程是这样的,希望能给你一点参考。

我先画了一张草图,要求它生成一个初版网站:

初版和草图比较像,都是黑白色:

接着我让它进行调整,根据一个主色做一个渐变调整:

背景色调成黑色:

中间也碰到了一些其他的具体问题,不过可以把代码喂给 ChatGPT 来解决。一些知识性的问题问 ChatGPT 就太合适了。比如我不知道 Tailwind CSS 里代码的作用:

我的这段经历,虽然充满了技术挑战和不断地调整,但它也展示了一个重要的道理:在现代技术的辅助下,即使是非前端专家,也能创造出令人满意的作品。这不仅是对个人能力的一种挑战,更是一次新技术应用的探索。我通过实践学到了很多,也体会到了技术带来的便利。

即使面对看似难以克服的技术障碍,只要我们愿意尝试新方法,就总有解决问题的途径。我的例子或许不是最完美的,但它证明了一个观点:不断学习、适应新技术,是我们在这个快速发展的时代中保持竞争力的关键。

最后,我想说的是,无论你是一名程序员、设计师,还是任何领域的专业人士,都不要害怕技术的快速发展。拥抱变化,利用新兴技术,将你的创意变为现实。

December 05, 2023 12:00 AM

December 03, 2023

李文周的博客

更友好的并发库conc介绍

要在Go语言中实现并发太容易了,对于初学者来说也容易掉入并发陷阱。社区中经验老道的Gopher为我们封装了一个并发工具包——conc,使用它可以轻松应对绝大多数并发场景。

December 03, 2023 07:14 AM

November 18, 2023

李文周的博客

Canal介绍和使用指南

Canal 是阿里开源的一款 MySQL 数据库增量日志解析工具,提供增量数据订阅和消费。使用Canal能够实现异步更新数据,配合MQ使用可在很多业务场景下发挥巨大作用。

November 18, 2023 03:28 AM

October 19, 2023

李文周的博客

GORM Gen使用指南

Gen是一个基于GORM的安全ORM框架,其主要通过代码生成方式实现GORM代码封装。使用Gen框架能够自动生成Model结构体和类型安全的CRUD代码,极大提升CRUD效率。

October 19, 2023 11:28 AM

August 19, 2023

李文周的博客

Go操作Kafka之kafka-go

Kafka是一种高吞吐量的分布式发布订阅消息系统,本文介绍了如何使用kafka-go这个库实现Go语言与kafka的交互。

August 19, 2023 02:22 PM

July 05, 2023

李文周的博客

依赖注入工具-wire

本文主要介绍什么是依赖注入和为什么要在开发中使用依赖注入工具,同时也介绍了一下Go常用的依赖注入工具——wire的使用和它的一些高级特性。

July 05, 2023 04:31 PM

June 04, 2023

qcrao 的博客

如何使用 Raycast 一键打开预设工作环境

工作中,你一定遇到过这样的场景:你正在认真写代码,线上突然出现报警。看到报警信息之后,你不得不打开浏览器,点开收藏夹,打开监控页面、告警页面、trace 页面、日志搜索平台……有时,还需要打开特定的文件或者软件,比如你记在本地的一些常用的命令文件、iterm2 等等。

这些网页、文件、软件,很可能每次遇到 报警时都要打开。这种重复的工作有没有可能一键自动完成呢?

可以。借助 Raycast 可以非常方便地做到(本文介绍的方法在 mac 系统下生效)。

Raycast是一个强大的工具,能够提高用户使用电脑的效率。它为用户提供了一种快速和简单的方式来控制他们的设备和各种应用,不论是发邮件,查看日历,还是管理任务。通过设置快捷键,用户可以无缝地在不同应用之间切换,大大提高工作效率。

需要说明的是,一键打开预设工作环境的实现方法可能有很多。本文采用的方法,是在 chatGPT 的帮助下“独立”完成的。如有雷同,纯属巧合。

总共分三步:指定命令的目录;创建 AppleScript 脚本;更改 AppleScript 脚本。

第一步,指定命令的目录。打开 raycast 设置页面,选择 Scripts tab,点击 “+” 号。

第二步,创建 AppleScript 脚本。

这里的 title 就是之后唤起脚本的命令。

顺便介绍一下 AppleScript:

AppleScript 是一种基于 Apple 事件的自动化技术,允许用户编写脚本来控制 Mac 操作系统中的各种应用程序。这意味着用户可以通过 AppleScript 自动执行繁琐的任务,比如批量修改文件,整理电子邮件,甚至创建复杂的文档。它支持过程和事件驱动编程,具有语法简洁、易于阅读和学习的特点,使得非程序员也能编写出有效的脚本。总的来说,AppleScript 是一个强大且用户友好的工具,用于增强 Mac 用户的生产力和工作效率。

完成之后,在第一步设置的路径下,就会出现一个 daily.applescript 文件。

第三步,就是修改 AppleScript 脚本,让它来完成打开指定网页、启动指定软件的功能。

虽然 AppleScript 写起来很直观,但是对没写过的人来说,还是有一定的学习成本。所以将编写脚本的工作交给 chatGPT 最为合适,因为打开指定网页和启动指定软件是最基础的功能,利用 chatGPT 可以更加高效。

果不其然,chatGPT 很快就抛出来了代码,没有修改就能工作了,让人直呼内行,效率简直翻 10 倍。

当然,基于此,我还有一些额外的要求。我希望它能新建一个浏览器窗口,打开这些网址,并将浏览器放在屏幕的左半部分。然后,我希望它能打开 Roam Research,并将其放置在屏幕的右半部分,因为我需要记笔记。

下面,我将直接展示最后的 AppleScript 代码,其实它并不复杂:

 1#!/usr/bin/osascript
 2
 3# Required parameters:
 4# @raycast.schemaVersion 1
 5# @raycast.title daily
 6# @raycast.mode compact
 7
 8# Optional parameters:
 9# @raycast.icon 🤖
10
11# Documentation:
12# @raycast.author qcrao
13# @raycast.authorURL https://raycast.com/qcrao
14
15log "Hello World! daily"
16
17-- 列出想要打开的网址
18set urls to {"https://www.wanqu.co/","https://www.reddit.com/r/ChatGPT/","https://www.reddit.com/r/golang/","https://news.ycombinator.com/","https://www.producthunt.com/","https://github.com/trending"}
19
20tell application "Google Chrome"
21	activate
22	make new window
23end tell
24
25do shell script "open -g 'raycast://extensions/raycast/window-management/left-half'"
26
27repeat with i from 1 to count urls
28	if item i of urls starts with "http" then
29		tell application "Google Chrome"
30			tell window 1
31				make new tab with properties {URL:item i of urls}
32			end tell
33		end tell
34	else
35		do shell script "open " & quoted form of (item i of urls)
36	end if
37end repeat
38
39-- 打开 "Roam Research" 应用
40tell application "Roam Research"
41	activate
42end tell
43
44do shell script "open -g 'raycast://extensions/raycast/window-management/right-half'"

值得一提的是,Raycast 的窗口管理功能也很强大。你可以通过自然语言将软件安排到指定的位置。比如,我想把当前激活的软件放到屏幕左 3/4,我只需要先用 cmd+space 唤起 Raycast,然后搜索:first,就能出现:

然后,点击回车,完事。相当优雅与高效。

AppleScript 很强大,很多例行的事情都可以借助它来进行自动化,提升效率。尤其有了 chatGPT 后,不会写的代码,直接请教 chatGPT 就行了,非常流畅。

最后,展示一下效果,我在 Raycast 里敲完“daily”后,直接回车,下面就是最终的效果:

要打开的网页、软件,都放在了正确的位置,优雅。

本文就写到这里,希望能提升一点你的工作效率,或者带来一些启发。

June 04, 2023 03:08 PM

May 28, 2023

qcrao 的博客

🎉ChatGPT 与我合力开发 xargin blog archive

之前写的批量删除 chatGPT 对话的插件,最近我收到了一个五星好评:

20230528134150

虽然不赚钱,交个朋友嘛,还是挺高兴的。而且借助 chatGPT,我是在与全世界的用户交流,想想就激动。

最近我发现自己让 chatGPT 帮忙写前端代码有点上瘾,这不又上架了一个 chrome 插件:Xargin Blog Archive。它的主要功能就是在曹大的博客 xargin.com 上添加一个 archive 页面。

20230528135100

由于 xargin.com 并没有 archive 页面,所以没法方便地点击历史文章,装上插件之后的效果是这样的:

235591311-a84765a9-ec13-42e0-8284-546c6fe5cb1e

之前要想浏览历史文章只能一次次地点击“上一篇”,我还特地手动把曹大所有文章看了一遍,人工做了一个文章目录:

20230528134535

但我想要一个真的“archive”页面,但又不会写前端代码,而且也从来没写过 chrome 插件,也就一直没法真正上手去做。

当 chatGPT 出现后,写这样的代码就很简单了,只要清晰地描述出需求,剩下的就是一些简单的调试,改 bug 工作了。仅管描述清楚需求也不是那么简单,但是相比自己手动去 google、stackoverflow、垃圾文章里翻找一些代码相比,还是简单太多。

实际上,我的思路很简单:

  1. 写一个爬虫,从 xargin.com 上的第一篇文章开始爬标题,内容。
  2. 从内容里找到元素“Previous Post”,顺着链接找到上一篇内容。
  3. 不断重复爬上一篇文章,直到找到第一篇文章。到这里,就把所有的文章爬了下来。
  4. 将所有文章的标题、url、发布时间用 markdown 表格列出来,保存到一个 md 文件里,放到 github 仓库。
  5. 配置一个 github actions,定期运行爬虫,更新 md 文件。
  6. 最后写一个 chrome 插件,当检测到在浏览 xargin.com 页面时,将 md 文件拉取到本地,渲染成 html,插入到 achive 页面上。

要完成上面列的这些步骤需要的都是很通用的“能力”,比如写爬虫、将 md 渲染成 html,都是大家做烂了的工作,这种事情让 chatGPT 来帮忙,简直不要太容易。

实际上,我也是这么让 chatGPT 一步步地来做的,非常顺利。

唯一费了一些时间的是,我想调整 archive 页面的风格以适应暗黑模式。不过这个过程中我也跟 chatGPT 学到了暗黑模式的原理。

20230528134746

我建议读者们都去安装这个插件,这样阅读曹大的文章时就更加方便了。如果想要学习写这样的插件,我把和 chatGPT 的对话放在这里,各位可以自行研究。

对每个程序员来说,做出自己的作品都是一个梦想,这两个小作品算是一个小的开始。

May 28, 2023 09:30 AM

May 07, 2023

qcrao 的博客

和 GPT-4 结队编程开发批量删除 chatGPT 对话插件

我和 GPT-4 一起开发了一个 chrome 插件,可以批量删除 chatGPT 网页版上的对话,废话少说,先看效果:

视频号地址(手机上可以用微信扫码):

背景

作为一名后端工程师,基本没写过啥前端代码。但是自己独立写一个有 UI 界面的作品出来给用户使用,一直是个梦想。无奈动手写的成本太高,也尝试过学习前端语言,但是时间精力问题,一直也没成功。

最近,我在社交媒体上看到很多人借助 chatGPT 实现了自己的 chrome 插件,甚至是开发了自己的 APP,不少都上架了应用商店了。这就又让我眼馋和心动了,也想开始开发一个插件。

另一方面,我在使用 chatGPT 网页版的过程中,会收集一些好用的 prompt,比如“翻译大师”、“变量名取名大师”……但是啊,平时经常会发起一些临时性的对话,就是随便问一些东西。问完之后,就要删掉,以便控制对话数量不要膨胀太快。

问题是,现在想要删除 chatGPT 页面上的对话,还挺麻烦。得先点击相应的对话,进入到对话详情页,弹出删除图标,点击删除图标,再点击确定,最后才能删除。所以,想要一次删除多个对话就很繁琐。

还有一个可能需要用到批量删除对话的场景是:多人合用一个账号,删除对话是刚需,批量删除能节省很多时间。

基于以上的原因,我便开始愉快地和 GPT-4 结队编程,一起开发起批量删除 chatGPT 对话插件。

过程梳理

尝试梳理一下全过程,由于是第一次开发 Chrome Web Store 插件,并且以前从来没有写过前端代码,所以前后花了挺长时间。

原始总共有 3 个对话,本文会将其中的主要节点和对话展示出来,更详细地可以看原对话:

Bulk Delete ChatGPT(1) Bulk Delete ChatGPT(2) Bulk Delete ChatGPT(3)

尝试调用接口失败

在向 GPT-4 提问之前,我用 google 浏览器的 inspect 功能看了下 chatGPT 对话页面的接口调用情况。关于删除一个对话的过程如下:

用户手动点击某个对话,页面会调用一个接口,拿到这个对话的属性,核心的数据就是 Coversation ID。

响应为:

响应里面包含了所有的对话信息。

如果点击删除按钮,再点确定,页面会调一个 PATCH 方法,执行删除:

我一看,这不是挺简单嘛!

接着用 inspect 查看对话对应的 html 元素:

傻眼了,chatGPT 做了混淆,所以没办法知道某个对话的 Conversation ID。

但是别慌,这不是还能拿到标题嘛!刷新页面就会有另一个接口来获取所有的对话数据:

每次拿 20 条对话,对话的 Title 和 Conversation ID 有对应关系:

利用这个对应关系,应该可以根据对话的 Title 拿到对话的 Conversation ID,进而调用接口删除对话。

所以,我开始向 GPT-4 提出了我的需求:

GPT-4 马上给出了回答:

20230430231303

注:为了行文简洁,我删除了部分代码。

这个回答非常全面,一个 chrome 插件的基本文件都有了,像模像样。

我按照 GPT-4 的回答,还别说,真地就开发出来了一个插件。并且加载插件之后,还真就出现了两个按钮。但是问题是按钮上的文字是乱码,按钮也没反应:

20230430215308

按照回答进行修改之后,按钮上的文字正常了,但是点击按钮还是没有响应。

又经过两轮问答,按钮终于有了响应,可以正常在每个对话前加上复选框,但是点击复选框之后,复选框就消失了。

又尝试了两轮修改,可以出现复选框了。我把修改后的代码反馈给 GPT-4,以便让它能跟踪到我的最新进展。顺便一说,在和 GPT-4 结队编程的过程中,我经常这样做。

然后 GPT-4 也记得它的任务,马上就要进行下一步了:

20230430220227

照做后,“复选框没法选中,点击复选框之后会进入鼠标所在的那个对话”。GPT-4 马上意识到:

这是因为点击复选框时,点击事件冒泡到了对话元素,导致进入对话。要解决这个问题,我们需要阻止点击复选框时的事件冒泡。

它又给出了新的 js 代码。我照做后,无法删除对话。因为 GPT-4 前面告诉我的只是修改哪些地方,它默认我使用的都是它给我的代码,但“微调”一下太正常不过了。为了让他更清楚当前的状况,我把当前 js 代码全部复制过来,让它看应该怎么办。

GPT-4 接下来又给了一些修改,但是都不 work。我观察到应该是 Conversation ID 不对。

20230430220820

它构造映射方式是手动调用前面提到过的 conversations 接口。不太行,调用这个接口还得传入 token,这个我还不知道怎么处理。

能不能直接拿页面获取好了的结果。所以我又提出了新的想法:

20230430221110

又经过了多轮的对话,GPT-4 无法搞定构造映射这个需求。我在经过了一番折腾之后,也没耐心了,直接想要 reset 掉构造映射这一轮的对话,于是我又把当前的代码抛了出来:

20230430221438

GPT-4 这时提出换一种方法:

20230430224621

不过最终这个尝试并没有成功。

接下来,这个事情就搁置了几天,暂时不知道该怎么推进了。

模拟手动点击

有一天,我突然想到可以换一种思路,直接模拟页面上的按钮点击。虽然这个方法看起来比较笨,但是实现起来比较简单,也更安全。

毕竟直接调 chatGPT 的 API 接口,万一官方不允许这样做,把账号封了就不好了。

20230430221931

这次就比较顺利了。

过程中遇到的一些问题,GPT-4 马上就能意识到错在哪里,并快速给出方案:

20230430222051

当然,我自己也犯了一些错,我没给全信息,但是 GPT-4 没有任何抱怨:

20230430222246

接下来,又遇到了另一些问题,选中删除按钮、确认按钮并不容易。最终,GPT-4 通过相对位置(第一个是确认,第二个是取消)来选择按钮:

20230430222600

搞定按钮选择问题后,已经可以模拟点击按钮了,但随后出现了一个大问题:

20230430224716

插件只能删一个对话,且删除后,页面会自动刷新,原来的 checkbox 就没了。这时,我提出了新想法:

20230430223036

通过加延时(最终是通过检测页面上是否有复选框元素来判断有没有完成刷新),在页面刷新后,又加上了复选框。但是之前选中的对话也没了:

20230430223300

GPT-4 提出了要将之前选中的对话存起来,尝试了但是没有成功。

现在我们选中对话其实存的是 index,就是对话在左侧页面上的顺序,第一个对话的 index 是 1,第二个对话的 index 就是 2。很遗憾,删除了一个对话后,页面会刷新,所有选中的对话的 index 都会变。所以不能简单地存储 index,还得对 index 进行加减操作,以便在页面刷新后能正确选中上一轮中未被删除的已经选中过的对话。

经过了多次探讨,GPT-4 还是没能解决。不过,它建议加一些调试日志,看一下点击删除后,index 会怎么变化。于是,没学过前端的我,也开始上手调试代码。

最终,我把 GPT-4 用循环实现的逻辑变成了用递归实现,最终比较丝滑地解决了问题。

20230430224146

过程中,也遇到了一些小问题。比如,当我重复点击“添加复选框”的按钮时,它会在对话前添加多个复选框。GPT-4 拿到问题后,很快就给出了解决方案:

这个问题的原因是每次点击 “添加复选框” 按钮时,addCheckboxes 函数都会再次执行,导致每个对话前都添加了新的复选框。为了解决这个问题,我们可以在 addCheckboxes 函数中检查每个对话前是否已经存在复选框,如果不存在,则添加复选框。

这样,通过模拟页面点击来实现批量删除对话的功能就实现了。

美化插件页面

插件基本的功能完成后,就可以用 CSS 代码来美化页面。

20230430224355

当然,我也可以“PUA”它页面不好看,让它重新给代码:

20230430224813

总之就是不断地进行调试,来美化页面效果。

最终的页面效果是这样的,咱也没专业学过设计,所以就凑合看看:

上传代码到 github

至此,插件的功能就完成地差不多了。剩下的工作就是将代码上传到 Github 以及发布到 Chrome Web Store 了。当然,Github 的 README 就不用自己写了。毕竟整个过程 GPT-4 都全程参与,写个说明文档小菜一碟。

20230430225408

确定了 README 的内容后,我还让它给我翻译成英文,同样是简单得要命:

20230507201547

发布插件到 Chrome Web Store

因为我是第一次开发 Chrome Web 插件,具体的步骤还真不知道。这要是在以前,直接就是 google 搜出来一篇靠谱的前人文章,然后照着步骤做。虽然大概率也能达到目的,但是还得自己手动甄别,极有的可能做法是一口气打开 5 篇文章,然后选择其中一篇看起来不错的,照着做。

但现在不一样了,只需用自然语言告诉 GPT-4,它会立即提供完整的步骤,关键是真的有用。很多事情,就是因为更方便了一些,完成他的动力就更强了一些,最终做成的概率就更大了。

我甚至还询问了 GPT-4 对这个扩展的 Logo 有什么建议:

20230430230602

最终在 GPT-4 的帮助下,我成功地发布了插件,现在可以直接安装使用了。

启发

从开始启动开发,到最终上架 Chrome Web Store,花费了不少时间。过程中,我也学会了一些插件开发的技巧,这不,在这之后我又开发了个 chrome 浏览器插件:Xargin Blog Archive,这回就顺畅很多了,当然这是后话。

有几点启发,记录在此,希望能对你有所帮助。

  1. 和 GPT-4 对话可以用自然语言。这和我们用搜索引擎时的体验完全不一样,搜索引擎是关键词匹配,我们说得越多,反而匹配越困难。GPT-4 则不然,我们说的要求越具体,它的理解就可能越对,提供的代码质量就更高。
  2. 可以用两个对话来进行一个任务。一个用 GPT-4,一个用 chatGPT。避免 GPT-4 的额度用完之后,得等待一段时间后才能再次进行对话。注意:如果额度用完之后,还是继续对话,那之后的模型就会变成 chatGPT,额度恢复后无法再次变回 GPT-4。
  3. 当陷入困境后,一定要提供更多信息。如果只是一味地说:不行,这不 work。仅管 GPT-4 会一次次地让你尝试其他方式,但是基本上都不 work。
  4. 及时告诉 GPT-4 阶段进展。有时,我们会对 GPT-4 提供的代码做一些自己的修改,这个时候需要及时和他同步,这样在后续的对话过程中才更能保持“默契”。

最后

本文讲述了我在开发“Bulk Delete ChatGPT”这个插件的全过程,包括背景、实现过程、发布过程等,最后还总结出来几点经验。

总体上,整个开发过程很有意思。GPT-4 能让不会写前端代码的后端工程师动手开发一个纯前端的插件,属实厉害。尽管过程比较曲折,但是有了经验以后,之后再做类似的事情肯定会更高效。最后,希望这篇文章也能给你带来启发。

May 07, 2023 03:57 PM

April 26, 2023

李文周的博客

singleflight

本文主要介绍Go语言中的singleflight包,包括什么是singleflight以及如何使用singleflight合并请求解决缓存击穿问题。

April 26, 2023 04:21 PM

April 16, 2023

qcrao 的博客

如何使用 GPT-4 为博客目录页打造炫酷前端效果

前不久我用 cmd markdown 写了篇文章《项目 TO 的自我修养》,文章的目录如下:

当我把它发布到线上后,目录却只展示出了二级标题:

这哪行!我猜这个可能就是加个配置啥的就能修复。于是马上就问 GPT-4 怎么办?

虽然不仅仅是改配置,但看起来很简单,于是我立即照做。

改完之后,打开博客,这回连目录都没有了,而且是一闪而过。接着问吧,GPT-4 也很快给出了新方案:

这回有目录了,但是还是只有 h2 层级。给的答案还是不对,我猜有可能它理解错我的意思了。于是继续追问:

这次,GPT-4 给出了一个修改配置文件的答案。这正合我意:

但是这下目录内容又只有从 h1 层级开始的了:

然后 GPT-4 又给出了一些不靠谱的答案,都不 work。例如:

尝试了下,还是不行。

一来一去我有点烦了。我尝试把配置文件里的 ordered 这一项改成了 true。竟然就看到了希望:

虽然文字都有了,但是样式太难看了:

按照这个来修改 CSS 代码,果然奏效:

1.post-toc-content ol {
2  padding-left: 20px;
3}

根据我有限的前端知识,我认为接下来只需要用 CSS 进行样式美化。

纯改 CSS 就方便了,我把目录页面的 CSS 一股脑丢给 GPT-4,让它来修改:

GPT-4 给了一份修改后的 CSS 代码:

可惜我改完后不生效。继续问它:

我照做了之后还是不行。

接着我做出了关键的一步操作。我右键看了下页面的源代码,并把目录部分的源码挑出来:

GPT-4 果然没有让人失望,它马上发现了问题所在,并给出了新的 CSS 代码:

改完之后,又有问题了。只展示 h1 层级标题了:

GPT-4 马上又给了解决方案:

试了果真有效:

问它原因它也能回答:

之后,就是让 GPT-4 不断尝试修改 CSS 代码,来美化目录页面样式:

我要求给我换个配色:

效果不错:

到这里,目录页面的改造基本完成了。之后就是不断让 GPT-4 美化页面效果。它确实没有让我失望。

最终的效果:

不得不说,最后目录页的呈现效果还是不错的,毕竟我对前端了解甚少。在 GPT-4 之前,我经常会避免自己去修改页面,实在是要改的话,也只能是 google 搜出多篇文章,对着抄代码,还不一定能成功。而拥有 GPT-4 这样趁手的武器,可以让你能做成很多以前不敢想的事情。

在整个过程中,提供的信息越多、越准确,得到的答案也越好。例如在我给出源代码后,GPT-4 马上就看出了问题所在,并给出了正确的答案。

参考资料

  1. 我和 GPT-4 的对话记录: https://sharegpt.com/c/OeJvZWu,https://sharegpt.com/c/4gEyavw

April 16, 2023 12:54 PM

April 02, 2023

qcrao 的博客

项目 TO 的自我修养

最近作为项目 TO 在公司内完成了一个涉及面比较广的项目,对于如何推动项目上线有一些经验和大家分享。希望刚毕业几年、没有参与过大型项目的同学,从中能学到一些方法,为今后担任项目主力做一些准备。

所谓的 TO,是 Technical Owner 的缩写,是一个项目的技术负责人。互联网公司里,每个需求都需要确定一个 owner,他负责需求的方案评审、进度对齐、问题解决、整体上线。大一点的项目,就要强调 TO,PO(产品 owner)这些,当然这时一般会有 PMO 参与进来。TO 的职责包括但不限于分析业务需求和技术方案的可行性、确定开发所需的技术栈和工具链、制定系统架构和设计文档、跟进开发进度和质量、组织联调和测试,保证系统的稳定性和可靠性,以及跟进上线和后续维护工作。一个优秀的 TO 能够确保项目顺利进行,达成预期目标。

下面我从技术方案、进度管理两方面展开,总结从本次项目里积累的经验。

技术方案

一般大一点的需求都需要在开发前做技术方案评审,也就是所谓的 trd(Technical Requirements Document)评审,它为后续项目的顺利进行打下基础。在技术方案中,不仅要考虑到功能的正确实现,也要考虑非功能性需求,也就是稳定性需求。

业界经常谈的所谓的稳定性三板斧:可监控、可灰度、可应急,这几点一般要在方案中讨论到。

实现功能是最基本的要求,否则不可能上线。但是稳定性同样不可忽视,区别初级工程师和高级工程的一个很重要的点是:能否给出稳定性方案。

总体视角

总体视角对 TO 来说是很重要的。

在一个大项目里,如果只是负责其中的一小块,例如你的服务只是提供一个接口,那可以只关注自己的服务。作为 TO,即使你自身负责的服务改动比较小,也必须关注整体的改动点,将全链路串连起来,包括上下游的依赖关系、数据的流动方向。否则,一个小问题可能就会阻塞整个项目。

梳理项目全貌

写技术文档的时候,需要理清项目全貌。

从 APP/Web 端,到网关,到后续的服务,整个调用链路需要理清。谁调用谁、谁依赖谁,这些都需要摸清楚,后续发布计划会用到。

另外,数据链路也需要理清,从数据源到途经的服务,到最后的落库都需要理清。代码逻辑的回滚相对来说很好做,但涉及到数据的回滚是很难的。因为数据一旦落库,用户已经感知到,或者老数据被覆盖,是很难回滚的。因此梳理项目全貌,提前考虑回滚方案是很有必要的。

把握关键技术细节

如果只是粗浅的了解了项目全貌,其实是不够的。关键地方的细节还要深入研究一下,魔鬼都在细节里。

例如有的服务只是调用接口进行页面展示,那只用了解到这一程度就够了,至于接口字段和页面内容的对应关系就不用深究了。

但是有些服务调用接口后,会根据结果做进一步的计算,那就要多问一步:我们的服务这样改动后,对你的影响是什么?你要做的对应改动是什么?为什么?

题外话,我自己就遇到过一次细节不清导致的事故。我弄错了接口对于不同类型的账户(例如子母账户)的不同表现,据此写的代码的计算逻辑全错了。一个接口的逻辑实现有非常多的细节,但可能关键的就那么几个,我们要掌握它们。

梳理全系统的改动点

技术文档里的一个核心要点就是梳理全系统的改动点。作为项目 TO,有责任弄清所有改动点。虽然不可能每个细节都弄清楚,但大致的依赖关系,数据流向还是得弄清。

识别关键人

如果涉及的系统非常多,而我们又不可能了解所有的服务,那应该如何快速理清改动点?

一个好的办法是找到关键人。每个服务可能会涉及 PM、RD、QA,有些是真正写代码的,有些是合周报的……我们要定位到核心负责人,通常是写代码的那一位,然后向他请教。

我通常的做法是先自己根据已有的文档做出自己的理解,过程中提出一系列问题,列在文档上,再约相关的人语音会议,会上对着文档依次讨论。必要的话,还可对会议进行录屏,避免遗忘。

分析上下游依赖

分析服务间的依赖关系非常关键。一方面有助于我们理清全系统的改动点,另一方面,有助于我们制定发布计划。被依赖的服务应该先上线,依赖服务后上线。如果没有弄清依赖关系,很有可能导致事故。

梳理依赖关系时可以用架构图来表示,在图上的连线里用 1/2/3/4 标出请求或都数据流向的步骤。当然也应该用时序图来梳理业务流程。

制定发布计划

项目最终是要上线的,我们在写 trd 时,就应该考虑到上线方案要怎么做。

当整体进行提测后,我们就应该把精力全部放到发布计划上了。

发布计划通常包含:各系统的发布顺序、中间件变更(如数据库的变更、新 topic 申请)、发布关键节点、稳定性三板斧……

首先,我们依据服务间的依赖关系,列出各系统的发布顺序,在发布当天,严格按照顺序进行发布。

其次,一些中间件的变更应该提前一天完成。例如创建新表,增加表字段;申请新的 kafka topic 等等。

另外,发布计划还应该考虑到时间节点。当涉及到的服务变动特别多时,可以考虑分批发布,例如某些旁路的服务提前一天发布到线上,这样可以给关键的服务留下充足的变更时间,出了问题也能有更多时间处理。

发布计划通常也要包含关键节点,例如 XX 服务发布后,系统某些地方就能感知到部分变更,这时应该设置检查项,发布当天对照这些检查项进行检查,没有问题后,再进行之后的发布流程。

最后,稳定性相关的问题一定要着重考虑:可监控、可灰度、可应急。

我个人的习惯是每个新需求开发之前都先想一下与之对应的监控项应该怎么定义。通过监控项,我们才能在服务上线后方便地判断变更的逻辑是否正确。例如,上线了一项功能是过滤掉不合规的用户,那么这时就可以增加一个过滤掉的不合规用户的 QPS,如果上线后观察到 QPS 与预期的不符,就要去排查。另外,如果变更会对性能带来比较大的影响,那就应该增加性能监控项,发布时关注性能是否有问题。

灰度其实在互联网公司里是很常见的思想。通常我们发布时会按小流量、中流量、全量的顺序推进。而当我们发布一个新功能时,对于灰度就要考虑地更多了。

灰度是为了控制发布失败时影响的范围。通常的方案包括按用户 ID 的尾号、城市进行灰度。例如我们先让尾号为 0 的用户看到最新的变更,即使发布过程失败,影响的也只是这一部分用户,损失会降到最低。

可应急指的是一旦出现问题,应该如何处置。如果服务只有逻辑上的变更,直接回滚代码就好了。但是通常逻辑的变更会带来数据的变更,涉及到数据的变更就没那么方便回滚了。这一块要详细设计回滚的流程,最好是在预发环境进行充分地验证。

进度管理

一般而言,研发做项目 TO 时,技术方案上不太会有大问题,但进度管理可能就不太顺畅了。即使项目有 PMO 把控进度,我们也应该学习一些进度管理的方法。毕竟不是所有的项目都会有 PMO 参与,而且了解各方的资源和进度也是 TO 要做的事情。

协同管理

在 prd、trd 时期,我们应该与各业务方,各协作方进行良好的沟通,确保各方的目标一致。在项目进行的过程中,对于可能存在问题的地方,也需要与他们密切沟通。

这里还是要强调:识别关键人是非常有用的一招。涉及到很多业务方的需求,我们不一定记得住所有人,但是关键服务的关键人一定要记在文档里,遇到问题可以第一时间找到关键人。有时关键人不一定能处理,但是他一定能帮助 TO 找到对应的人。

排期与关键节点

在 trd 评审完成后,排期时间就应该全部确定,包括提测时间、测试时间、预发布时间、正式发布时间。有些倒排项目,排期是先于 trd 时间确定的,这时就应该倒排法确定各个节点的时间了。

排期需要得到各方的确认。每个改动方都应该知晓关键的时间节点,例如提测时间、正式发布时间等。有些服务方改动点比较小,手上还有其他优先级比较高的项目,提测时间不一定能赶上,但经过沟通,只要能赶上正式发布时间就可以,那也没问题。

对于非常大的项目,还需要设立多个交付节点,分阶段提交成果。否则,都积累到最后一批提交,很可能就是灾难了。

风险识别

对于一些倒排项目,trd 提出的方案不一定是最优,如果存在一定的风险,要提前指出来,并且要做好应对方案。

对于我做的这次项目而言,涉及到了数据的变更,在正式发布前,我们对生产数据进行了预计算,发现其中的某项数据有问题,不合预期。于是在发布前我们找到业务方、PM 进行协商,并对方案做了一定的更改,最终项目得以顺利上线。

当然,有同学可能会说,这应当是在项目初期就做的事情。具体到这一件事情确实是这样,但是有些倒排项目不一定给你留了足够的时间,所以最后一道关就是在正式发布前做一下验证,确保万无一失。

周会与日会

一旦项目进入正式开发后,应当召开周会或日会,及时同步各方进展、遇到的问题、风险。

作为 TO,在对齐进展的时候,应当化身 PMO,假装我们不知道任何项目的细节,只问进度。我自己的经验是,因为我不是一个能必安理得 push 别人的人,而且有时候会默认考虑到项目的细节,觉得他这块会比较麻烦,所以就不太好意思追问进度。但在会上,需要抛开自己的研发身份,纯粹以一个 PMO 的视角来问进度,如果进度不理想,那就要追问解决措施是什么。一旦“切换”了身份,会发现问进度是最简单的事情。

一个小细节是会前要准备好文档。不要直接上来就尬聊,当你有准备的时候,一定是会给其他人好印象的,对方大概率也会认真配合的。

其他

前不久,我在 Go 夜读分享了自己使用 things3 管理项目的经验,其中讲到的需求开发模板可能会对你有帮助:

最后

工作这几年下来,开发一个需求并推动上线早已不是问题,但涉及到众多合作方并作为项目技术 TO 的项目还是第一次。过程中,不仅要考虑自身所负责项目的开发,还需要整体考虑所有业务方的改动点,还是有一些挑战的。做完这个项目之后,自觉执行层面就没啥大问题了。

April 02, 2023 03:44 AM

February 19, 2023

qcrao 的博客

如何写一个 things3 client

Things3 是一款苹果生态内的任务管理软件,是一家德国公司做的,非常好用。我前后尝试了众多任务管理软件,最终选定 things3,以后有机会会写文章介绍我是如何用 things3 来管理我的日常任务。

本文主要介绍欧神写的 tli 工具来学习如何写一个定制的通过邮件和 things3 沟通的工具。很多软件都有类似的邮件功能,例如给绑定的 kindle 邮件地址发送电子书文件,就可以在 kindle 设备上看到。学会写工具的套路后,今后就能自己写类似的工具了。

使用场景

正常情况下,我们可以在 mac/ipad/iphone 上可以通过软件界面添加 TODO 事项,而且 things3 本身也有全局快捷录入的功能,非常方便。但是 things3 只能在苹果生态内使用,当我们临时切换到 windows 或者 linux 上工作,就不好操作了。这时如果产生了新的 TODO,通过 tli,打开 terminal 工具就能将 TODO 加到 inbox 里。

命令行操作:

同步到 things3:

初始化配置

因为要通过邮件来和 things3 沟通,因此需要配置发送邮件的邮箱、SMTP 服务器、用户名、密码、things3 给我们的专属邮件地址。

由于通过 tli 发送 TODO 是一次性的任务,因此这些配置项需要保存在某个文件中,之后用到的时候直接读取就好了。

具体的配置项包括:

1type tliConf struct {
2	SMTPHost   string `yaml:"smtp_host"`
3	SMTPPort   string `yaml:"smtp_port"`
4	Avatar     string `yaml:"avatar"`
5	EmailAddr  string `yaml:"email_addr"`
6	Username   string `yaml:"username"`
7	Password   string `yaml:"password"`
8	ThingsAddr string `yaml:"things_addr"`
9}

我用的 gmail 作为发送邮件,配置的 SMTP 参数是:smtp.gmail.com:587。EmailAddr 就是 gmail 地址,Password 需要设置一个专用的。

使用 user.Current() 方法可以拿到当前用户的信息,包括 home directory,用户名等等。tli 将配置文件保存到 home 目录下。

使用 bufio 包,在 terminal 里读取用户的输入:

1s := bufio.NewScanner(os.Stdin)
2log.Printf("SMTP Host Address: ")
3if !s.Scan() {
4	log.Println("init was canceled.")
5	return
6}
7info := s.Text()
8tli.SMTPHost = info

将配置序列化成 yaml 后,写入 ~/.tli_config。

 1func (c *tliConf) save() {
 2	checkhome()
 3	data, err := yaml.Marshal(c)
 4	if err != nil {
 5		log.Fatalf("cannot save your data, err: %v", err)
 6	}
 7
 8	f, err := os.OpenFile(homedir+"/"+pathConf,
 9		os.O_CREATE|os.O_RDWR, 0600)
10	if err != nil {
11		return
12	}
13	defer f.Close()
14
15	all := []byte("---\n")
16	all = append(all, data...)
17	if _, err := f.Write(all); err != nil {
18		return
19	}
20}

读取 title 和 body

用户执行 todo 命令时,可输入 title 和 body。且 body 支持多行输入,输入空行或者按 ctrl+C 时取消输入。

TODO title 通过命令行直接传入,body 则通过一个 for 循环等待用户输入:

 1func (a *tliTODO) waitBody() bool {
 2	s := bufio.NewScanner(os.Stdin)
 3	fmt.Println("(Enter an empty line to complete; Ctrl+C/Ctrl+D to cancel)")
 4
 5	sigCh := make(chan os.Signal, 1)
 6	signal.Notify(sigCh, os.Interrupt)
 7
 8	line := make(chan string, 1)
 9	go func() {
10		for {
11			fmt.Print("> ")
12			if !s.Scan() {
13				sigCh <- os.Interrupt
14				return
15			}
16			l := s.Text()
17			if len(l) == 0 {
18				line <- ""
19				return
20			}
21			line <- l
22		}
23	}()
24
25	for {
26		select {
27		case <-sigCh:
28			return false
29		case l := <-line:
30			if len(l) == 0 {
31				return true
32			}
33			a.body = append(a.body, l)
34		}
35	}
36}

并且监听了取消息信号,异步启动一个协程去监听输入,再在 for select 中监听 sigCh,若用户手动取消了,则返回 false。若用户输入了空行,则返回 true,代表输入完成,之后就可以发送邮件。

发送邮件

因为 things3 有 2000 字的限制,所以需要做一个分割,防止被截断。

 1func (a *tliTODO) Range(f func(string, string)) {
 2	whole := strings.Join(a.body, "\n")
 3
 4	if len(whole) < maxlen {
 5		f(a.title, whole)
 6		return
 7	}
 8
 9	count := 1
10	for i := 0; i < len(whole); i += maxlen {
11		f(a.title+fmt.Sprintf(" (%d)", count), whole[i:min(i+maxlen, len(whole))])
12		count++
13	}
14}

调了 smtp.SendMail 方法发送邮件。

注意,需要对中文字符做一个编码,否则 things3 里会出现乱码。

TODO 历史

每次执行 todo 命令时,都会保存到历史中,同样是用 yaml 序列化。之后,执行 log 命令时,可将其读出来,展示历史。

每条记录前加一个”—“用于分离,读的时候,就可以读出多条记录:

 1rs := []record{}
 2for {
 3	var r record
 4	err = d.Decode(&r)
 5	if err != nil {
 6		if err == io.EOF {
 7			break
 8		}
 9		log.Fatalf("corrupted ~/.tli_history file, err: %v", err)
10	}
11	rs = append(rs, r)
12}

cobra 命令行

这是一个比较常用的库了,用于写命令行工具。

定义 init, log, todo 三个 command,再定义一个 root command,说明用法。

总结

总体来说这个项目比较简单,不到 500 行,但也能学到不少写工具软件的技巧,之后写类似的工具时可以参考。

  • 使用 cobra 创建不同的命令。
  • 将配置文件保存到用户 home 目录下。
  • 如何从控制台接收用户的输入文本。
  • 使用 smtp.SendMail 发送邮件。

February 19, 2023 12:38 PM

February 02, 2023

李文周的博客

何时使用Go泛型【译】

Go 1.18版本增加了一个主要的新语言特性: 对泛型的支持。在本文中,我不会描述泛型是什么,也不会描述如何使用它们。本文讨论在 Go 代码中何时使用泛型,以及何时不使用它们。

February 02, 2023 03:18 PM

泛型

Go 1.18版本增加了对泛型的支持,泛型也是自 Go 语言开源以来所做的最大改变。

February 02, 2023 03:18 PM

January 16, 2023

李文周的博客

Go kit教程06——服务发现和负载均衡

本文主要介绍了如何使用 Go kit 实现基于consul的服务发现和负载均衡。

January 16, 2023 02:25 PM

December 25, 2022

qcrao 的博客

几个小设置让 mac 更好用

今天在 youtube 上看到一个视频,讲新 mac 到手后一定要做的几个设置,有几个之前我不知道的小设置,非常好用,看完马上就用上了。

一些我常见的就不列了,比如说设置点按、三指拖拽,不知道的可以去搜索了解,属于是基操。

finder 设置

  1. 搜索时,默认搜索当前文件夹里的内容而不是整个 mac。整个 mac 搜索起来会很慢,即使你真的是想搜索整个 mac,也可以在结果出来后,点“这台 mac”重新搜索。

image-20221225203146963

  1. 将 finder 边栏上不用的项目去掉,例如影片、音乐等等,留下足够的空间添加快捷路径。

image-20221225203131991

  1. finder 菜单栏,显示 -> 显示路径栏,在 finder 底部即可出现当前文件夹的路径。

image-20221225204932365

Dock 栏设置

  1. 取消窗口自动排序(之前经常发现窗口位置动了,很恼人,没想到还能设置)

img

  1. 取消 dock 栏常用软件分区。

img

img

  1. 顶部 bar 上的图标也可以去掉一些不用的,例如 siri、time machine 等等。

Spotlight 因为直接可以用快捷键唤醒,放在这里也没啥必要。

img

  1. 删掉 dock 栏上不用的应用图标。

dock 栏上很多不用的图标可以删掉,不然一直在那挤占空间。比如地图、邮件等等。经常看有人共享屏幕时一堆不用的图标在那杵着,很让人着急。

回看一下以上的几个设置项,基本都是增加显示有用的信息,去掉无用的信息,提高信息密度。虽然这些设置不能让你立马升职加薪,但起码多了一点对 mac 的掌控感。

December 25, 2022 03:39 PM

李文周的博客

Go kit教程05——调用其他服务

本文主要介绍了如何使用 Go kit 作为RPC客户端调用其他微服务。

December 25, 2022 03:20 PM

December 11, 2022

qcrao 的博客

深度阅读之《100 Go Mistakes and How to Avoid Them》

《Mastering Go》《Concurrency in Go》之后,这是我精读的第 3 本 Go 主题的英文书了。全书 390+ 页,从开始读到全部读完,快 2 个月了,😓。

前不久曹大连接发了几个关于《100 mistakes》的视频,多猜他大都是看看标题,看看代码,就知道要说什么了,并且很快就跳过去,速度飞快。我开始设想的是除了读懂内容,还想练习一下英语阅读,慢就慢吧。不过,我过后也确实加快了速度,毕竟人家半小时的进度我要两周,稍微有点离谱。

简单谈一下这本书:全书“凑”了 100 个关于 Go 的错误。有些是非常经典且常见的错误,例如在 for 循环中保存迭代变量的指针、并发 append slice 等等,书中做了非常详细的讲述。另外有一些错误则见得不多,有凑数的嫌疑,例如很多错误是不知道 xxx、不懂 xxx……读来稍微有点别扭。还有一些瑕疵的地方是第 8 章关于 M 的描述是错误的……

关于书名,作者还找了几个为什么要从 mistakes 中学习的理由:我们印象最深的知识点一定是在犯错的场景下学到的。

Tell me and I forget. Teach me and I remember. Involve me and I learn.

我们最近正在组织这本书的翻译,估计明年 5 月左右能上市,不过还是建议大家读读英文版。


以下是我在读书的过程中所做的一些笔记,记下我认为今后可能会遇到的坑。

  1. Go 很简单,但不容易掌握

Go is simple but not easy.

简单意味着易懂,Go 语法基本上花 2 小时就能全部看完。但是要想掌握它、写好它却不容易。比如,goroutine 和 channel 该简单了吧,但是使用 channel 出错的 case 数不胜数。

之前有篇讲 Concurrency bugs 的论文《Understanding Real-World Concurrency Bugs in Go》说:尽管人们普遍认为通过 channel 来传递消息更少出错误,但是论文里研究的 bug 表明,正好相反,用 mutex 才更少出错。

  1. The bigger the interface, the weaker the abstraction

Rob Pike说:The bigger the interface, the weaker the abstraction。当一个接口的方法越多,它的抽象能力越弱。像接口 Reader/Writer 为何很强大,因为它们就只有一个方法。

他还说:Don’t design with interfaces, discover them. 意思就是只有在实现过程中发现需要 interface 时才需要定义。是自下而上的过程,而非相反。

  1. net 包和 net/http 包并没有层级关系

可以认为是两个不同的包,它们仅仅是文件位置有层级关系而已。

  1. 包名要反映这个包能提供什么能力,而不是它包含了哪些内容。

函数名反映它做了什么,而不是怎么做。虽然命名一直是编程界的难题,但不断尝试好的命名也是必要的。日常的 util, common, base 这些包名其实并不好。任何对外暴露的内容:包、函数、方法、变量都应该给出说明。

  1. nil slice 的几个特点

不分配内存。对于一个函数的返回值而言,返回 nil slice 比 emtpy slice 要更好。

在 marshal 时,nil slice 是 null,而 empty slice 是 []。因此在使用相关库函数时,要特别注意这两者的区别。

nil slice 和 empty slice 不 equal。

以下代码中前 2 个是 nil slice,后两个不是。

  1. copy 函数拷贝的元素数量是 min(len(dst), len(min))
  2. 初始化 map 时,指定一个长度

它能给 runtime 以提示,这样后续可以减少重新分配元素的开销。并且要注意:这个长度并不是说 map 只能放这么多元素,这里面有一个公式会计算。

map 的 buckets 数只会增,不会降。所以当在流量冲击后,map 的 buckets 数扩容到了一个新高度,之后即使把元素都删除了也无济于事。内存占用还是在,因为基础的 buckets 占用的内存不会少。

关于这一点,之前专门写过一篇Go map 竟然也会发生内存泄漏?去讲,私以为比书里讲得更详细。

  1. 不要边遍历 map 边写入 key

在遍历 map 的过程中,新写入的 key 可能被遍历出来,也可能不被遍历出来,可能会与预期的行为不符,因此不要边遍历边写入。

下面这个例子输出的结果不确定:

 1func main() {
 2	m := map[int]bool{
 3		0: true,
 4		1: false,
 5		2: true,
 6	}
 7
 8	for k, v := range m {
 9		if v {
10			m[10+k] = true
11		}
12	}
13	
14	fmt.Println(m)
15}
  1. break 可以作用于 for, select, switch

break 只能跳出一重循环,因此要注意,break 是否跳到了你预想的地方。可以用 break with label 来解决。毕竟标准库里也这样用了:

  1. for 循环加指针,老司机也会掉的坑

在 for range 循环里保存迭代变量的指针是一个非常容易犯的错误,Go 老手也会犯。原因是迭代变量至始至终都是同一个值,对它取地址得到的值也是相同的:

  1. rune 代表一个“字”,等于 Unicode 中的 code point。

因为在 UTF-8 中,一个字被编码成 1-4 个 bytes,因此 rune 被定义成了 int32。例如,汉字的编码是:0xE6, 0xB1, 0x89。

 1func main() {
 2	// len 返回的是 Byte 数量
 3    // 3
 4	fmt.Println(len("汉"))
 5
 6	s := string([]byte{0xE6, 0xB1, 0x89})
 7    // 汉
 8	println(s)
 9	
10	// 查看 rune 数量
11    // 1
12	fmt.Println(utf8.RuneCountInString(s))
13}
  1. TrimLeft, TrimRight 的坑

TrimLeft, TrimRight 会从 source string 里移除给定字符串里的字符(只要存在就移除),直到碰到一个不存在于给定字符串里的字符时结束;TrimPrefix, TrimSuffix 则要完全匹配,才会移除。Trim 等同于 TrimLeft+TrimRight。 13. 因为 Go 里面的 string 是不可变的,因此使用 += 来连接字符串时,其实是重新分配了一个新字符串。

使用 strings.Builder 时,可以用 Grow 方法来预分配内存,我自己之前一直忽略了预分配。因为它的底层是一个 slice,所以预分配 slice 是有必要的。

  1. string 和 []byte 之间的转换会有内存分配发生

所以除了一些 hack 方式的转换外,另外一个可替代的做法是在一些情况下直接用 bytes 包的方法,从而避免转换成 string:strings 包有的方法,byte 包也基本都有,比如 Split, Contains 等等。

转 string 的做法在标准库中是这么做的,见 strings.Clone 方法:

当我们需要取出一个 slice 里的小部分元素时,为了防止取字符串子串时内存泄漏,下面这种做法可能会在编译器中“误伤”,但这种转换是必要的,它发生了内存分配,因此和原字符串脱离了关系。另一种可选的方法是调用 strings.Clone 方法:

  1. 关于具名返回值。

什么时候需要给返回值命名呢?没有一个必须遵循的原则。取名字有两个场景:增加可读性(例如返回经度、纬度两个字段,如果不命名,鬼知道哪个前哪个后);利用它会自动初始化为零值,能让代码更短一些,当然,代码本身也得比较短。

另外,关于 return 时加不加名字。函数代码比较长时,还是带上比较好,增加可读性,不然看代码的人一直要记住返回值是什么。

在同一个函数里,统一返回值的风格,不要一会儿返回带名字的参数,一会儿又直接 return。

即使给返回值命名了,也不意味着一定要直接 return,还是可以带名字 return。

  1. 方法的语法糖

Having a nil receiver is allowed, and an interface converted from a nil pointer isn’t a nil interface.

这句话非常绕,也很容易犯错。前半句,当 receiver 是 nil 的时候,依然可以调用方法,因为实际上方法是一个语法糖。

当返回参数是一个自定义的 interface 时,尤其是自定义的 Error interface 时,直接返回 nil,而不要返回一个 nil 的 pointer,因为它不是 nil,且这往往造成后续的判空逻辑出错,这同样是一个很常见的错误。

  1. defer 一个 func 时,参数马上就会求值

然后这个函数调用就会被压栈,等函数 return 时再来执行,参数值用的是之前已经算好了的,如果参数不是指针,那程序的行为可能就不是预期的那样了。

这种情况还可以用闭包解决,闭包内里的参数就是在真正执行的时候才去求值的。下面这个闭包同时还包含一个参数:

  1. panic 和 error

一般 error 都是作为返回值的最后一个。有些错误处理方案不处理 error,企图直接在 defer 里看有没有 panic,这其实是模拟的 Java/C++ 等语言里对异常的处理方法。Go 一般不这么做。

panic 发生时,程序执行流程会一直“出栈”直到当前进程退出或者被 recover 掉。

为什么 recover 一定要写在 defer 里才生效呢?因为只有在 defer 里的语句才能在发生 panic 后也能执行。还有个问题是为什么 recover 非得要包一层才能有效呢?这是 Go 明确规定的。可能有两方面原因:recover 有一个返回值,它表示 panic 的原因,所以得有地方把它“打印”出来;Go 在实现上需要用到栈的层级关系。具体的就需要深入研究下源码。stackoverflow

  1. 当我们要返回一个确定的、预期内的错误时,应该返回一个预先定义的 error value,也被称为 sentinel error;当返回非预期的错误时,返回特定的 error type。前者用 errors.Is 判断,后者用 errors.As 判断。

  1. 几种不同错误处理方式。用 %w 是 wrap,用 %v 是转换。前者可以看到 source error,可以用 As/Is 比较,后者看不到。

  1. 关于 context 取消

A Context carries a deadline, a cancellation signal, and other values across API boundaries.

context 被取消时,可以通过 Done() 方法返回的 channel 感知到。当 cancel 方法被调用、deadline 过期时,context 被取消。Done() 返回的 channel 被关闭。通过 Err() 方法可以感知到 context 为什么会被取消。

另外,context 是并发安全的。

channel 有一个魔法是:关闭 channel,可以让所有的 receiver 感知到。而向 channel 发送数据,只能有一个 receiver 能收到。

  1. context 的 key 类型如何设置

当设置 key/value 时,key 和 value 可以是任意类型;对于 key 而言,通常不是直接用字符串,而是用一个非导出的类型,这样不会发生冲突。

如何通过自定义的方式来继承一个 context 里的 value,而不继承它的信号。

 1type detach struct {
 2	ctx context.Context
 3}
 4
 5func (d detach) Deadline() (time.Time, bool) {
 6	return time.Time{}, false
 7}
 8
 9func (d detach) Done() <-chan struct{} {
10	return nil
11}
12
13func (d detach) Err() error {
14	return nil
15}
16
17func (d detach) Value(key any) any {
18	return d.ctx.Value(key)
19}
  1. 闭包是一个使用函数体外变量的匿名函数。它和 goroutine, for 循环结合使用时,经常会出现意料之外的问题,老司机也经常在这里翻车。
 1package main
 2
 3import "fmt"
 4
 5func listing1() {
 6	s := []int{1, 2, 3}
 7
 8	for _, i := range s {
 9		go func() {
10			fmt.Print(i)
11		}()
12	}
13}
14
15func listing2() {
16	s := []int{1, 2, 3}
17
18	for _, i := range s {
19		val := i
20		go func() {
21			fmt.Print(val)
22		}()
23	}
24}
25
26func listing3() {
27	s := []int{1, 2, 3}
28
29	for _, i := range s {
30		go func(val int) {
31			fmt.Print(val)
32		}(i)
33	}
34}

listing1 里因为是闭包,所以 Print 是在打印的时候才会真正求 i 的值,而 goroutine 什么时候执行是不确定的。因此打印时,可能是 2,也可能是 3,且 goroutine 打印的值还可能重复。例如打印出 233 时图解如下:

listing2 用本地变量,可以解决。

listing3 不用闭包,同样能解决问题。

  1. 用 map[K]struct{} 这种形式来表示 set 不光是节省内存,还能明确表达出这是一个 set 的含义;如果把 struct{} 换成 bool 意义就没这么明确了。

  2. context 相关的并发问题

书里给了一个etcd里的例子,用 context 里的 k-v 做 key,然后遇到了并发(一个 goroutine 读所有的 value,另一个 goroutine 会更新某个可变的 value,例如 key 是一个指针,指向 struct)的问题,所以就自定义了一个 blankCtx 来拦截 String() 方法,消除并发问题。

这种问题应该还挺多的。context 里的 value 如果有可变类型,那么就会很容易导致 data race 的问题。

The fix https://github.com/etcd-io/etcd/pull/7816 was to not rely on fmt.Sprintf to format the map’s key to prevent traversing and reading the chain of wrapped values in the context. Instead, the solution was to implement a custom streamKeyFromCtx function to extract the key from a specific context value that wasn’t mutable.

  1. 为什么 slice 不能并发 append?

其实是看有没有同时 touch 同一个索引,也就是同一块内存。如果有的话就会有 data race 的问题。对于 map 而言,即使不是 touch 同一个 key 也会导致 data race。因为即使是不同的 key 也可能会被分到同一个 bucket。

当不同的 goroutine 并发写不同的索引时,不会发生 data-race。

我问chatGPT关于data race有什么坏处,得到的回答:

  1. sync.WaitGroup 的正确用法是:在父 goroutine 中调用 Add 方法,在子 goroutine 中调用 Done 方法。

sync.Cond 不太常用,它可以重复地给多个 goroutine 发送信号。与之相对的是, 关 channel 只能发送信号只能用一次。

A condition variable is a container of threads (here, goroutines) waiting for a certain condition.

  1. errgroup 是 golang 的一个库,它提供了一种简单的方式来处理多个并发任务的错误。它的主要作用是用来管理多个 goroutine,在所有的 goroutine 都完成后再进行错误处理。有两个方法:

  1. time.After 会创建一个 channel,只会在过期的时候才会释放资源。

  2. sql.Open 在有些 driver 下并不会连接到数据库,所以对于强依赖数据库的服务,需要先调用一下 Ping 或 PingContext 方法来保证数据库能连通,然后再启动服务。

这个我之前看项目的代码时,对这个还有一些疑问,认为没有必要。看书还是能涨知识的。

sql.Open 返回 *sql.DB struct,它的底层是一个连接池,我们需要设置连接池的下面这些属性,否则它们会用默认值(通常不太 work)。

最大连接数,不能太大,否则会把数据库打垮。

最大空闲连接数,需要增加,否则,流量一来,连接数不够,进而创建了一堆连接,因为 idle conn 数太少,最后又都释放掉。

最大空闲时间,默认是无限长,一旦碰到突发流量,连接一直保持在内存里,内存会爆掉。所以这个需要减少。

连接最大生存时间,如果需要负载均衡的话,连接的生存时间就不要太长,因为它会一直请求同一个负载。

  1. 对于 http 包里的 resp,只要 err 为空的话,就可以用 defer 来关闭,而不用根据 resp 是否为空来决定是否关闭 body。

原因这里有解释:

只要是实现了 io.Closer 接口的资源,都应该在某个时间点调用 Close 方法,防止资源泄漏。

rows 没关闭的话,该连接不会被再次放到连接池里。

一个新手常犯的错误是使用默认的 http client,并且不设置任何超时的参数。

正确地如下:

另外,http client 底层也是有连接池的,所以相关的参数也得设置一下,默认的有问题,例如 http.Transport.MaxIdleConnsPerHost 的默认值是 2,就太小了。

http server 端的几个 timeout 参数设置:

另外,server 端的连接也可以设置 idle timeout,否则就只有等着 client 来关闭了。

  1. 发生 false sharing 的原因是,cache line 而非某个变量是 CPU 更新的粒度。

False sharing occurs when a cache line is shared across two cores when at least one goroutine is a writer.

  1. Reader 的 Read 方法的 API 设计成目前这样的好处是:[]byte 不会直接就在堆上分配,而是由调用者决定,它有可能会分配在栈区,从而提升性能。

  1. 查看函数是否被 inline

inline 的好处是除了节省函数调用的开销外,还可能让之前逃逸到堆上的变量重新回到 stack。

  1. pprof 可以在查看 heap profile 之前强制 GC,可以直接在命令参数里开启

pprof 的 block profile 默认不会开启,需要在代码里手动执行 runtime.SetBlockProfileRate() 设置多少次阻塞上报一次,才会开启,开启之后会在后台一直上报 goroutine 阻塞的事件。阻塞可能发生于:

  • Sending or receiving on an unbuffered channel

  • Sending to a full channel

  • Receiving from an empty channel

  • Mutex contention

  • Network or filesystem waits

pprof 的 mutex profile 也是默认不开启,开启办法是调用 runtime.SetMutexProfileFraction()。

如果怀疑有 goroutine 被阻塞了很久,可以用 debug=2 参数 dump 所有的 goroutine,一眼看出是否真被阻塞住了:

  1. 垫内存对 GC 阈值的影响不是线性的,但是直接改 GOGC 是。

December 11, 2022 02:56 PM

李文周的博客

Go kit教程04——中间件和日志

本文主要介绍了Go kit 中的中间件,并以日志中间件为例演示了如何设计和实现中间件。

December 11, 2022 12:55 PM

December 03, 2022

李文周的博客

Go kit教程03——代码分层

本文主要介绍了如何使用 Go kit 编写的项目代码如何按请求进行分层,从而提升代码的可读性。

December 03, 2022 12:36 PM

November 22, 2022

李文周的博客

Go kit教程02——gRPC

本文主要介绍了如何使用 Go kit 构建基于 gRPC 的微服务,并额外补充了如何为 gRPC Server编写本地测试代码。

November 22, 2022 02:15 PM

November 20, 2022

李文周的博客

Go kit教程01——基础示例

本文主要介绍了go-kit的主要组件和设计思路,并带领大家编写了一个基本的rpc示例。

November 20, 2022 02:18 PM

November 13, 2022

qcrao 的博客

Go map 竟然也会发生内存泄漏?

Go 程序运行时,有些场景下会导致进程进入某个“高点”,然后就再也下不来了。

比如,多年前曹大写过的一篇文章讲过,在做活动时线上涌入的大流量把 goroutine 数抬升了不少,流量恢复之后 goroutine 数也没降下来,导致 GC 的压力升高,总体的 CPU 消耗也较平时上升了 2 个点左右。

有一个 issue 讨论为什么 allgs(runtime 中存储所有 goroutine 的一个全局 slice) 不收缩,一个好处是:goroutine 复用,让 goroutine 的创建更加得便利,而这也正是 Go 语言的一大优势。

最近在看《100 mistakes》,书里专门有一节讲 map 的内存泄漏。其实这也是另一个在经历大流量后,无法“恢复”的例子:map 占用的内存“只增不减”。

之前写过的一篇《深度解密 Go 语言之 map》里讲到过 map 的内部数据结构,并且分析过创建、遍历、删除的过程。

在 Go runtime 层,map 是一个指向 hmap 结构体的指针,hmap 里有一个字段 B,它决定了 map 能存放的元素个数。

hamp 结构体代码如下:

1type hmap struct {
2	count     int
3	flags     uint8
4	B         uint8
5	
6	// ...
7}

若我们想初始化一个长度为 100w 元素的 map,B 是多少呢?

用 B 可以计算 map 的元素个数:loadfactor * 2^B,loadfactor 目前是 6.5,当 B=17 时,可放 851,968 个元素;当 B=18,可放 1,703,936 个元素。因此当我们将 map 的长度初始化为 100w 时,B 的值应是 18。

loadfactor 是装载因子,用来衡量平均一个 bucket 里有多少个 key。

如何查看占用的内存数量呢?用 runtime.MemStats:

 1package main
 2
 3import (
 4	"fmt"
 5	"runtime"
 6)
 7
 8const N = 128
 9
10func randBytes() [N]byte {
11	return [N]byte{}
12}
13
14func printAlloc() {
15	var m runtime.MemStats
16	runtime.ReadMemStats(&m)
17	fmt.Printf("%d MB\n", m.Alloc/1024/1024)
18}
19
20func main() {
21	n := 1_000_000
22	m := make(map[int][N]byte, 0)
23	printAlloc()
24
25	for i := 0; i < n; i++ {
26		m[i] = randBytes()
27	}
28	printAlloc()
29	
30	for i := 0; i < n; i++ {
31		delete(m, i)
32	}
33	
34	runtime.GC()
35	printAlloc()
36	runtime.KeepAlive(m)
37}

如果不加最后的 KeepAlive,m 会被回收掉。

当 N = 128 时,运行程序:

1$ go run main2.go
20 MB
3461 MB
4293 MB

可以看到,当删除了所有 kv 后,内存占用依然有 293 MB,这实际上是创建长度为 100w 的 map 所消耗的内存大小。当我们创建一个初始长度为 100w 的 map:

 1package main
 2
 3import (
 4	"fmt"
 5	"runtime"
 6)
 7
 8const N = 128
 9
10func printAlloc() {
11	var m runtime.MemStats
12	runtime.ReadMemStats(&m)
13	fmt.Printf("%d MB\n", m.Alloc/1024/1024)
14}
15
16func main() {
17	n := 1_000_000
18	m := make(map[int][N]byte, n)
19	printAlloc()
20
21	runtime.KeepAlive(m)
22}

运行程序,得到 100w 长度的 map 的消耗的内存为:

1$ go run main3.go
2293 MB

这时有一个疑惑,为什么在向 map 写入了 100w 个 kv 之后,占用内存变成了 461MB?

我们知道,当 val 大小 <= 128B 时,val 其实是直接放在 bucket 里的,按理说,写入 kv 与否,这些 bucket 占用的内存都在那里。换句话说,写入 kv 之后,占用的内存应该还是 293MB,实际上却是 461MB。

这里的原因其实是在写入 100w kv 期间 map 发生了扩容,buckets 进行了搬迁。我们可以用 hack 的方式打印出 B 值:

 1func main() {
 2	//...
 3
 4	var B uint8
 5	for i := 0; i < n; i++ {
 6		curB := *(*uint8)(unsafe.Pointer(uintptr(unsafe.Pointer(*(**int)(unsafe.Pointer(&m)))) + 9))
 7		if B != curB {
 8			fmt.Println(curB)
 9			B = curB
10		}
11
12		m[i] = randBytes()
13	}
14
15	//...
16
17	runtime.KeepAlive(m)
18}

运行程序,B 值从 1 一直变到 18。搬迁的过程可以参考前面提到的那篇 map 文章,这里不再赘述。

而如果我们初始化的时候直接将 map 的长度指定为 100w,那内存变化情况为:

1293 MB
2293 MB
3293 MB

当 val 小于 128B 时,初始化 map 后内存占用量一直不变。原因是 put 操作只是在 bucket 里原地写入 val,而 delete 操作则是将 val 清零,bucket 本身还在。因此,内存占用大小不变。

而当 val 大小超过 128B 后,bucket 不会直接放 val,转而变成一个指针。我们将 N 设为 129,运行程序:

10 MB
2197 MB
338 MB

虽然 map 的 bucket 占用内存量依然存在,但 val 改成指针存储后内存占用量大大降低。且 val 被删掉后,内存占用量确实降低了。

总之,map 的 buckets 数只会增,不会降。所以在流量冲击后,map 的 buckets 数增长到一定值,之后即使把元素都删了也无济于事。内存占用还是在,因为 buckets 占用的内存不会少。

对于 map 内存泄漏的解法:

  • 重启;
  • 将 val 类型改成指针;
  • 定期地将 map 里的元素全量拷贝到另一个 map 里。

好在一般有大流量冲击的互联网业务大都是 toC 场景,上线频率非常高。有的公司能一天上线好几次,在问题暴露之前就已经重启恢复了,问题不大。

November 13, 2022 04:17 AM

October 05, 2022

李文周的博客

gRPC中的名称解析和负载均衡

本文介绍了gRPC中名称解析和负载均衡的设计。

October 05, 2022 01:45 PM

October 01, 2022

李文周的博客

基于游标的分页

本文介绍了基于游标的分页模式,并使用Go语言实现了一个简版的游标分页功能。

October 01, 2022 02:27 PM

September 17, 2022

李文周的博客

gRPC Transcoding

本文介绍了如何使用 .proto 文件中的注释来指定从 HTTP/JSON 到 gRPC 的数据转换。

September 17, 2022 04:01 PM

July 31, 2022

qcrao 的博客

你说的下游是 upstream 吧?

工作中,有一些术语比较容易混淆,聊半天,最后发现双方对术语的理解不一致。这个时候用英文原本的表达或者换一种方式来表述能让沟通更顺畅。

像我们经常说的『上下游』便是经常发生混淆的一对名词。

以前,我经常说『梳理一下我们依赖的下游』,后来发现这种说法是错误的。正确的是:梳理一下我们依赖的上游。

是不是听着很奇怪?

可以这样理解,越是上游的地方,越是离源头更近的地方,源头就是指数据源。

对于互联网服务用户而言,数据沿着源头、上游、下游,一直流到用户的设备上。源头可能是数据库,上游可能是后端服务、下游可能是 gateway。对于某个微服务的 owner 也一样:你的服务做的事就是从上游获取某项数据,然后经过一些加工处理,吐出加工后的数据,数据会流向下游。

有人可能会反问:服务之间的交互,一问一答,请求和响应都有数据,那流向该怎么算?其实这里的数据是指响应数据,是终端用户最终需要的数据:可能是短视频,可能是公众号文章。

我们记住这张图就可以了:

上面这张图来自这篇文章,文中介绍了好几种 downstream/upstream,但对于后端研发来说,弄清服务调用间的上下游就足够了。

实在不好区分的,想想 nginx 中的 upstream 配的是什么地址能就回忆起来。

最后,在有可能要频繁说起上下游的场合,一定要先和大家约定好名词的定义。这时用 upstream、downstream 可能会更好一些;或者改叫调用方、被调用方也很清晰。

July 31, 2022 10:56 AM

李文周的博客

protobuf中使用oneof、WrapValue和FieldMask

本文介绍了在Go语言中如何使用oneof字段以及如何通过使用google/protobuf/wrappers.proto中定义的类型区分默认值和没有传值;最后演示了Go语言中借助fieldmask-utils库使用google/protobuf/field_mask.proto实现部分更新的方法。

July 31, 2022 08:40 AM

July 19, 2022

qcrao 的博客

将博客迁移到了 Cloudflare Pages

上个月把博客从 hexo 迁移到了 hugo,博客数据、发布流程全部托管到 github。之后把之前写的一篇《那些年曹大写的文章》搬了过来,其他文章暂时下线了。

上周在折腾博客 css 的时候,aofei 说不如迁移到 Cloudflare,还能全球 cdn 加速。于是又动手迁移到 Cloudflare Pages,顺便又修改了一些 css,目前博客样式比较顺我的意。这篇文章记录下折腾的过程,希望能给读者带来一些参考。

迁移到 Cloudflare Pages

Cloudflare Pages 和 Github Pages 都能方便地部署静态博客页面,前者功能更强大,不仅支持自动部署、设置页面规则将 www.qcrao.com 解析到 qcrao.com,还能配置 url 自动重定向,并且能够分析页面访问量(之前是基于 google analytics)。

迁移过程很简单,在 Cloudflare Pages 页面,创建部署,目录设置成 github 上的 blog repo。设置构建命令,接着只要 github 上的 blog repo 有更新,这边就会自动部署。

这里有一点要注意的是:通过设置环境变量来控制 hugo 的版本和本地一致,否则在本地和线上看到的页面效果会有差异。我当时遇到的问题是 Cloudflare 生成的页面不能点击图片进行放大,本地则是 OK 的。

DNS 配置

Cloudflare Pages 上对 DNS 的配置步骤有提示和说明,比较友好。

我之前的域名在腾讯云上托管,这回得修改 DNS 服务器到 Cloudflare,需要去腾讯云域名管理页面修改。

下面这条 www -> qcrao.com 的记录是在为了让我们在输入 https://www.qcrao.com 时跳转到 https://qcrao.com

另外,Cloudflare 会自动将 https 证书设置好,完全不需要我们操心。

老文章重定向

之前用 hexo 发布文章后,url 里会带上日期,非常长且没有什么意义。切到 hugo 后,url 没有日期了,且加上了一个 post 路径。同一个 md 文件,发布之后链接不一样:

1【老地址】https://qcrao.com/2019/04/02/dive-into-go-slice
2
3->
4
5【新地址】https://qcrao.com/post/dive-into-go-slice/

如果不设置重定向,原来的地址就会失效。

Cloudflare 刚好有一个重定向的功能,非常方便,一行命令就解决了:

1/:year/:month/:day/* /post/:splat

前面是老 url,后面是重定向的新地址。将老 url 里的年、月、日匹配上,splat 表示 * 号内容,这样就能把年月日从 url 中去掉,并且加上了 /post。重定向的功能就完成了,且非常优雅且顺滑。

当我们访问 https://qcrao.com/2019/04/02/dive-into-go-slice 时,会自动跳转到:

CSS 美化

很惭愧,我并不会 CSS,他们都说特别简单,下次我一定学😅。

一开始想要美化 CSS 的原因是在手机端看博客文章时,结尾部分的版权声明字体过于大,而文章正文的字体又显得特别小(我特地和曹大博客在手机端的效果做了对比)。

当然我自己是不知道怎么『美化』的,所以请教了 aofei,几行代码就解决了:

 1@media only screen and (max-width: 800px) {
 2    .post-archive {
 3        font-size: 16px;
 4    }
 5
 6    .post-content {
 7        font-size: 16px;
 8    }
 9
10    .post-archive h2 {
11          margin: 0;
12          font: bold 16px / 1.1 "ff-tisa-web-pro", Cambria, "Times New Roman", Georgia, Times, sans-serif; 
13    }
14
15    .post-archive .date {
16        font-size: 16px;
17        padding-right: .7em; 
18    }
19}

第一行代码限定在小屏幕下,执行之后的 CSS。具体匹配哪些元素要用谷歌浏览器的『检查』功能。

问了几次 aofei 如何修改 CSS 后,我自己成功地将 cmd markdown 渲染出来的引用格式移植到了博客上。

过程也很简单:先用『检查』功能找到 cmd markdown 页面上的引用对应什么元素,然后在它的 css 文件里找到对应的代码,copy 到本地 style.css 里就 ok 了。好在 hugo 足够快,改完代码能立马看到效果,我就可以不断地尝试。

static 目录下面的文件会移到网站根目录

当我们想让一些文件在执行 hugo 命令后出现在网站根目录下,只需要将它们放到 themes/maupassant/static 就行了。例如,我在博客首页右边栏放了一张《Go 程序员面试笔试宝典》,它是一个全局资源。

因此图片路径要用 /book.png。一开始我用的是 book.png,结果我切到『归档』页面后,图片无法展示。


关于博客的样式可能暂时折腾到这里为止,之后要有规律地更新文章了。

毕竟内容才是最重要的!

July 19, 2022 02:20 PM

June 25, 2022

李文周的博客

gRPC-Gateway使用指南

gRPC-Gateway 是一个 protoc 插件。它读取 gRPC 服务定义并生成一个反向代理服务器,该服务器将 RESTful JSON API 转换为 gRPC。此服务器根据 gRPC 定义中的自定义选项生成。

June 25, 2022 06:18 AM

June 22, 2022

qcrao 的博客

那些年曹大写的文章

某天晚上看到曹大在群里指点江山,折服。感叹为何曹大如此渊博,遂决定从头到尾研读完他所有的博文。

前后共花了一个月的时间,今天终于读完了(2020-11-24~2020-12-26),总共 118 篇。从 15 年 10 月 31 日开始的第一篇,到今天,总共写了 5 年多的时间。基本上每半个月产出一篇,非常稳定。

从最初讲具体的工作,例如将 MySQL 数据导入到 ES,到近期的《中台的末路》、《架构的腐化》、《工程师应该怎么学习》等名篇,水平一步步提高,视野也在一步步变大。

这些博文里很多内容都是从工作中提炼、总结出来的,这需要对自己所做的工作非常熟悉,并且需要做很多思考才行。这对我们而言,是有启发的。

还有一些是论文或文档的翻译,翻译它们而不是仅仅看一遍,对我们深刻理解内容是很有帮助的。连曹大都这样做了,我们有什么理由不做呢?

总的感受是,我们需要不断思考、反思、总结,并且持续不断地写出来。在所有文章里,如果只推荐一篇的话,那无疑就是《工程师应该怎么学习》这篇。其中最激励我的三段是:

人这一辈子,最重要的是能把路越走越宽。对于工程师来说,能够锻炼软技能的场合其实不是很多,但也不代表完全没有。即使没有也可以自己创造机会,例如组内、组间、部门内的技术分享都是不错的机会。

更大规模的技术分享可能因为主办方“势力眼”,在你级别不高或者影响力不大的时候,不提供给你这样的机会,但是作为一个向上的人,迟早会有走到这一步的一天。你所要做的是提前做好准备,在那一天到来的时候,在聚光灯下旁征博引,谈笑风生。

祝大家都能成为更好的自己!

我们只有保持终生学习的姿态,才有可能不被时代抛弃。

If you don’t keep moving, you’ll quickly fall behind.

下面是详细的文章内容介绍,最后有一张表格,列出了所有的文章链接和概览。因为 xargin.com 没有 archive 功能,所以这篇文章算是全网最全的、最方便的博客入口。


第 1 篇是 15 年 10 月 31 日开始的,到今天已经 5 年了,主要讲如何使用 vagrant 来搭建一套 lnmp(linux/nginx/mysql/php)开发环境,解决一些诸如只用线上才出现的 bug,以及新同学如何能快速搞定开发环境。

第 2 篇主要讲的是一致性哈希。我又查了下其他资料,总结下一致性哈希的优点:当增减 server 时,可以移动最少的 keys;因为数据是均匀分布的,所以更容易水平扩展。现实世界中,比如 Amazon’s Dynamo 数据库的分区组件、Apache Cassandra 跨集群的数据分区等等都用到了一致性哈希。

第 3 篇是将 MySQL 里的数据导入到 Solr 来满足一些特定的查询需求。

第 4 篇把 Solr 换成 ES,再来一次。

第 5 篇主要内容是说用 PHP 实现服务发现很难,不如 Java 那样方便。人家亚马逊的贝索斯在 2002 年就要求服务化,而阿里则是 2009 年则开始的。

第 6 篇主要内容是讲从 MySQL 导入大量数据时,碰到 GC 问题,导致连登陆都不行,最后通过加大 JVM 的运行时内存解决;Stop the world 机制简称 STW,即在执行垃圾收集算法时,Java 应用程序的其他所有除了垃圾收集帮助器线程之外的线程都被挂起;思想其实很朴素,用空间来换时间。

第 7 篇是关于乐观锁的内容,悲观锁用 select for 先锁定记录,然后再 update 更交换机数据;而乐观锁则比较前后的版本(例如订单,也可以比较 status)来解决并发更新时的数据覆盖问题。记住这个时间点,5 年前,曹大刚听说乐观锁^_^。

第 8 篇是一份 Redis 事务相关的文档翻译。主要内容有事务的使用、事务的错误、使用 watch 等。

第 9 篇还是一个连续剧,书接第 6 篇,在实际操作的过程中,将 MySQL 的数据导入到 ES 中遇到的导入数据有丢失的问题,主要原因是有主从延迟。处理办法就是每次都去向前多取一点:select * from [业务表] where update_time > date_sub(now(), 10 minute);。嗯,曹大会刻意总结在工作中遇到的问题及思考的解决方案,即使看起来比较简单。

第 10 篇其实也是一个续集(一致性哈希),书接第 2 篇,主要讲了 2 种缓存客户端如何生成虚拟节点的算法。并且,从这篇文章得知,PHP 是没有什么可以全局复用的全局变量的,所以每次 web 请求从 nginx 到 cgi 都会重新走各种 web 框架的 index.php。

第 11 篇主要是总结了在开发的过程中遇到的问题及解决方案,它同时也是曹大在公司做过的分享。比较重要的点有:没有填 update_time 而采用了 create_time,且由于主从同步或者时钟或者其他问题导致的“工单系统是有可能在未来创建过去一段时间的工单的”,或者说“一个时刻创建了在这个时刻之前”,这个是通过每次都向前多取 10 分钟的变更数据。还有一个点是每天低峰期删数据导致的大量删除 binlog 会对系统造成压力。另外,这时就已经用了 gin 框架了。

第 12 篇是对当时同步 MySQL 和 ES 的方案的一个总结反思,说明了优点和缺陷。嗯,估计是最后一篇了。前后也有四、五个月了。

第 13 篇曹大喷了一个 php 框架 laravel 里的一个实现:闭包套闭包在函数调用的时候类似于递归调用,也存在压栈压爆的问题。最后一段:没错,拿技术解决问题,但是不要为了炫技而炫技。(特别是你的特技可能连一个“丑陋”的解决方案都打不过。找到一篇 Go 相关的责任链文章,对照看一下。

第 14 篇是看一个用 C 写的消息队列的源码。重点在于曹大对于“如何做分布式”的总结和思考:proxy 和 smart client。

第 15 篇讲的是如何设计一个灰度发布系统。从作用到分桶/分类策略,到用哈希算法对 key 进行哈希分桶从而实现千分比灰度。最后还提了几个问题并作了解答:使用 md5 和 sha1 不能保证均匀分布怎么办?如何选取分桶?使用 md5 或者 sha1 对 CPU 消耗太高怎么办?学到了两个如何对字符串算 md5 和 sha1 的命令:

 1echo -n 15810321343 |openssl dgst -sha1
 2md5 -s 15810321343
 3
 415810321343
 5
 6md5=>
 705eadde36e5e5c3a00015a8f07d98d6b
 8
 9sha1=>
107962e1ba260de074ef895af44c62ad353ee36c2c

第 16 篇从数据库、检索服务、日志三个方面来谈 ES 能做的事,以及优势和限制。企业内部使用的 elasticsearch 是提供垂直搜索的一种方案,内容可能是一些结构化的数据,而不像大搜索那样都是杂乱的内容。数据库层面,查询条件可以转化为 bool 查询;单表 count 也容易解决;但 ES 不能实现 Join,事务。在检索服务层面,集群便可以非常方便地进行动态扩展,数据也不容易丢失;缺点是分词不是很科学。日志方面,ELK,每天建一个索引。

第 17 篇介绍了曹大自己开发的工具:elasticsql,使用 SQL 来查询 ES,目前 629 星。工作中遇到了问题,然后就手动开发一个工具,并且写文章总结,赞!

第 18 篇讲迅雷。文件会按 16KB 进行切分,每个文件块用 sha1 算法计算一个哈希值,用作小块的校验。将一个文件的所有小块的 sha1 连接得到的字符串再进行 sha1,得到整个文件的 id。之后简单讲了 server hub、peer hub、file server 的工作原理。因为迅雷成立时,互联网上的轮子并没有那么多,所以很多都得自研,文档也不全。最难解决的问题不是 P2P 下载,而是各种乱七八糟的 case。

第 19 篇列出了实际工作过程中碰到的各种低级的做法。我自己印象深刻的且之前没碰到过的有:滥用回调,增加系统复杂性;访问数据库不做批量;树形结构的表结构设计问题;工作流系统update不判断修改前的状态;抱怨接口性能是语言问题。嬉笑怒骂,皆成文章。

第 20 篇,把 logstash/kibana/elasticsearch 之类的东西统统变成 5.0 的过程中,遇到的一些问题及解决。

第 21 篇,公司登陆验证用的 Google Authenticator 来做校验的原理。也就是将一个 key 分发给某个具体的帐户,然后服务端和客户端可以每隔 30 s 算一个 token。将 token 和帐户验证即可确定身份。

第 22 篇,翻译的一个后 GitHub 上的后端面试项目,并给出一些问题的回答。这些题基本都是比较开放的问题,很少一问一答这种。5 年前的了,不过我看原项目地址 7 个月前还有更新。

第 23 篇,HTTP 中间件的形式如何写。这里给出了一些前置、后置、耗时统计的控制的例子,不过最后给的一个例子有点问题,不是太好理解。我找到了一篇叶剑峰大佬写的比较好理解的,能直接运行的例子。可以看到,《Go 语言高级编程》中也有这篇内容,可见平时的积累是非常重要的。

第 24 篇是问题 kafka 消息重复问题的排查,其实也没有各种现场排查,最终仅通过文档的说明就发现了问题:消费者在 poll 的时候才发送心跳,那如果处理消息的时间稍长就会被判失活,导致将正在消费的 partition rebalance 给其它消费者。因此解决办法就是升级到 0.10。这也告诉我们不要太快使用新发布的软件,因为有很多问题还没有生产环境中发现。

第 25 篇又是一篇吐槽文,工作中有时总是会遇到太“笨”的合作方,人家不会转一点脑子,什么问题都来问你,本来你就已经清楚地说明了。但一旦有什么在他们预期之外的事情,他就会来麻烦你。文中给出一些应对的办法,这当然需要后端去学习一些前端知识。工程师,终身学习是基本的。

第 26 篇,讲的是 redis 的 SDS 是不是二进制安全的。简单来说,通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,还可以保存任意格式的二进制数据。起因是看到群里有人看到 redis 里有 strlen 的调用,就怀疑 redis 的 SDS 的二进制安全是不是真的。之后,一番实验和看代码,发现真正调用 sdsnew 函数的是在内部字符串用或者是测试用。

嗯,2016 年的博客看完了,总共 26 篇,基本两周一篇的节奏。

第 27 篇,是一篇译文。Redis 客户端和 Redis 服务器使用 RESP 协议通信,RESP 是 REdis Serialization Protocol 的简称。虽然 redis 协议设计得十分对阅读友好(human readable)并且十分容易实现,但实际实现起来和二进制协议的性能依然比较接近。例如,RESP 使用了前缀的 length 来传输 bulk 数据,所以没有必要像 JSON 一类的数据结构需要通过扫描来查找特殊字符,也没必要在发送数据的时候给数据加上引号(quote the payload)之类的。

第 28 篇,在某个本来想打游戏度过的周末,听说有 B 站的分享,于是去听了,结尾感慨:本来用来打游戏的周末又学了不少东西。整篇文章就是对这次分享的一次总结,其中又结合自己平时的思考。有一点是讲业务和基础架构绑在一起的好处。

第 29 篇,发现曹大有个习惯比较好,就是经常会翻译一些比较重要的文档。因为人总是容易遗忘,即使某个东西你现在很清晰,过了一段时间之后就会忘记。我想记住的具体的知识点就是:leader 会维护一个 in-sync replica (ISR) 的集合:是和 leader 的进度完全一致的那些 follower。其他的先不深究。

第 30 篇是一篇思考,关于系统如何处理错误,直白一点是如何向用户、向研发展示错误。应该管用户一些提示,而不是直接白屏,用户在故障之后恢复时能正常使用系统,而不是像有些权限系统设计的那样缓存个几天。对研发而言,根据错误能快速定位到错误的地方是最重要的。开头整理的一些错误场景总结如下:依赖组件挂了;依赖服务挂了;依赖方超时了;调用方的参数有问题;调用方的参数无法正确地通过校验;用户的某种操作在业务逻辑上不具有合理性,不能够接着让他执行下去;程序自身出错了,比如数组越界,对字符串和数字进行加和操作,或者是把 null 当成了某种合法的数据结构,通过点或者下标来获取某种属性。

第 31 篇,吐槽 Go 包非中心化管理的一些问题。最主要的一个问题是不好和开源项目“合作”的问题。

第 32 篇,介绍了分布式如何实现。首先是用 MySQL 实现,但解决不了租期的问题;接着用 redis 的 setexnx 命令,也有一个问题是:因为 redis 的主从同步是异步行为,在主上加锁成功,数据没有同步之前 master 挂掉了,那之后就可能会有多个实例(用户进程)持有锁,这显然不是我们希望的。(这里我的理解是其他用户进程会能向新主申请到锁);然后就是 redis 的作者提出了 Redlock 的加锁方法。

第 33 篇,使用代码示例讲解如何使用 parser 包做一些“高级”的事情:基于 ast 做很多静态分析、自动化和代码生成的事情,非常酷炫。

第 34 篇,利用 MySQL 的 information_schema 表来自动生成代码,依然很酷炫。

第 35 篇,讲 awesome go 的入选标准,主要关注两方面:1. goreport(包含 gofmt/go_vet/gocyclo/golint/license/ineffassign/mispell) 的结果;2. 测试覆盖率(coveralls,或者 gocover)。

第 36 篇,17 年 6 月份,那时我快毕业了。当年如日中天的 ofo 也早已经一地鸡毛,用户的押金也得到几百年之后才能还清。想起那时的创始人何等风光,如今安在?本问主要探讨密码锁的一些方案设计。

第 37 篇,关于 pprof 和火焰图的使用,嗯,曹大少有的教程类文章。这里有个如果涉及到 http handler 的压测,profile 的时候可以有技巧地把 Write/Read 的开销除开。

第 38 篇,公司都是要盈利的,都有自己的业务,业务驱动其实是常态,老板只关心有没有实现想要的功能,你如何实现,实现的代码质量如何这些并不重要,这也是现在你看到的公司的各种垃圾代码,这个问题也没法解。但对于个人而言,多看好的开源代码、多看《重构》这样书,以及使用 test 等方法能一定程度上解决问题。

第 39 篇,一个比较常见的问题,之前我也写过一篇类似的,当时我用的一个主要工具就是:perf top

第 40 篇,回顾从 07 年入校以来的点滴,真情流露。曹大多才多艺!

第 41 篇,先是场景引入(今天刚好看到 caoz 讲的如何做分享),再层层递进。得出如果在内存中计算交集的一个方法:map[int]int。

第 42 篇,讨论阿里的一个开源库 ApsaraCache(类 redis) 所做的优化是如何实现的。主要有两点:增加对 MemCache 的支持;优化短链接。

第 43 篇,举了两个 Go 语言中 panic 无法被用户 recover 的情况:并发 map 读写;reflect.Call。结论:go 服务不用 supervisor 又不加监控就等着被开除吧。哈哈哈~

第 44 篇和第 45 篇是关于《Clean Architecture》的读书笔记,摘一句:对于商业公司的金主来说,软件系统有两方面的价值,一方面是软件的行为价值,也就是指软件的业务功能;另一方面是软件的结构,指软件的架构,易变性,可维护性,属于软性价值。软件工程师的职责是保证系统两方面的价值都能够达到最大。但是实际情况是,大多数人就只会聚焦在某一个方面,顾此失彼。

第 46 篇,宣告《Go 语言高级编程》正在搞,而且会继续搞完。周末写完了 router 一节。

第 47 篇,讨论了部门内的一个不均衡的负载均衡算法。数学证明的部分没太看懂~

第 48 篇,除了拿 debugger 来 debug。还可以用 debugger 来了解了解程序运行的机制,或者用 disass 来查看程序运行的汇编码。在查资料的时候,看到鸟窝写的文章:最近看到滴滴的工程师分享的使用debugger在调试Go程序,我觉得有必要在尝试一下这方面的技术了。估计说的就是曹大这篇。

gc 编译器产生的代码可能会包含内联的优化,这不方便调试器调试,为了禁止内联, 你可以使用 -gcflags “-N -l” 参数。

我跟着这篇讲 lldb 调试的文章走了一遍调试。设置断点有点问题,没完事做完。

这篇文章里还介绍了 dlv 的功能。由于好久没用到这些工具,暂时先不深入了。

第 49 篇,是对业务系统的接口上的思考,从功能上来说,可以抽象成 SQL。但实现起来,并且对用户友好,可能并没有那么简单。最后结论:支持什么 SQL/GraphQL 啊,thrift 大法好。

第 50 篇,这又是一篇译文(还是挺多的,所以这也是一种学习方法),关于 Go 汇编的。后来曹大在 Go 夜读上的 Go 汇编分享,这篇文章亦有贡献。

第 51 篇,关于协作式和非协作式抢占调度的特点以及关系。Go 实际上在 1.14 才真正实现了抢占式调度,使得 for 无限循环在 GC 的时候不会阻塞整个进程的执行,从而“卡死”。

第 52 篇,曹大开始进行源码阅读了,记住这个时间:2018-04-05。这篇文章开始会简单展示核心的关于 channel 的代码,包括读、写、关闭等,后面就主要是源码注释了。学到了一个在线的 markdown 编辑工具,好处是可以和 dropbox、github 绑定。

第 53 篇,启动流程分析。由于之前我已经追求一遍相关代码,不深入研究。

第 54 篇和第 50 篇来源于同一个 github 项目,但作者已经跑路了。文章是比较硬核的,作者的研究方法是从 binary asm 反推实现原理。

第 55 篇,这是曹大在 Go 夜读上所做分享的原文,牛逼。领先我们好几年。

第 56 篇,思考业务系统中的问题及解法。讨论的问题是如何收集、计算主业务流程相关的指标。开头由 DDIA 一书引入话题,然后说明场景,再谈解决办法,然后说问题,再来说方法……从这里也可以看到,曹大在 18 年就已经看完了 DDIA。我则是今年才开始看这本书,差距。

第 57 篇,G 和 P 的状态流转图,用的是“自动”生成的。相比我之前手动画的,改起来也比较容易。

第 58 篇,什么 TDD,BDD,DDD 都是浮云,事故驱动开发才是王道。不出事故,一切都好说,管你什么代码质量,有什么用?出事故,那马上得复盘、甚至罚款。结尾列出的三个案例也是值得好好看,尤其是尽可能要用 defer。

第 59 篇,喜大普奔,从 hexo 升级到 ghost,且升级了 gitalk 评论系统,直接和 github 打通。

第 60 篇,讲的《Concurrency In Go》里的一个活锁的例子,原因是没有协调好加锁的顺序,且使用的是 tryLock 的这种形式,即没有加锁成功,就先返回失败,再尝试。当然,如果不是 trylock 的话,不一致的加锁顺序会直接导致死锁。

如果已经发现了活锁导致的问题,解决手段很简单,只要规定好加锁顺序,并且大家都按同样的顺序去加锁就可以了。活锁比较麻烦的是难以发现,因为在活锁状态下的程序实际上看起来很正常,只是性能表现会稍微差一些。

另外,不得不说一句,我是今年才看的这本书。差距啊!

第 61 篇,毕业四年,列出的计划单,非常叼。我印象比较深刻的有:有个人的稳定的科学查资料方案,改天有机会当面请教一下。

第 62 篇,调度器的源码分析,由于我已经写过相关文章,不深入看,略过。

第 63 篇,《Concurrency In Go》的读书笔记,恰好我几个月前也写了一篇类似的读书笔记。不同的是,曹大这篇,代码样例为主,我的则以理解为主,代码样例则基本没有。

这有张操作 channel 的结果对照表,可以一用:

第 64 篇,timer 源码分析,不深入看。

第 65 篇,syscall 原理,以后需要的时候再来仔细研究。

第 66 篇,列举了一些英文书的出版社。如 O’Reilly,NoStarch,Manning,Apress,packt,最近我也看了 packt 出版社的《Distributed Computing With Go》,当然也是跟着曹大的书单读的。嗯,落后了 2 年。

第 67 篇,map 的源码分析,当初我写《深度解密 Go 语言之 map》时,参考了不少。

第 68 篇,select 源码分析。

第 69 篇,slice 源码分析。

第 70 篇,报告阶段性胜利,《Go 语言高级编程》初稿完成。曹大写的是关于 Go Web 的部分,字里行间可以看出他花了大力气进行总结:对于个人来说,通过这本书,把所有 web 领域相关的知识全部进行了梳理和总结(虽然有些还没有写)。可以比较自信地认为在 web 开发方面,本人已经没有任何方面的盲点和短板。要说问题,那可能也就是业务领域相关的问题了。

第 71 篇,业务遇到的问题,最终查到是 Go 语言自身的一个 bug:循环中的指针变量做 map 的 key 使用,会在 GC 时触发该 bug。在 1.9.2 上是必现的。

第 72 篇,sync 包源码分析,以后再看。

第 73 篇,semaphore 源码分析,以后再看。

2018 年的文章完了,这一年,曹大主攻 Go 语言,完成了 golang-notes,并且完成了《Go 语言高级编程》。

第 74 篇,《中台的末路》,去年在公众号“码农桃花源”上发出后,全网疯转,仅“码农桃花源”上就达到 7w+ 阅读,其他公众号、平台的阅读数就更不知多少了。

第 75 篇,又是列举了几个实际工作中碰到了案例,一个开源库对 sync.Pool 的使用,和实际场景中对开源的使用并没有发挥 sync.Pool 的作用,且在高并发场景下导致了锁竞争;第 2 个例子是 metrics 上报也会遇到锁竞争;第 3 个例子是打日志。得出结论:不可能通过看源码就能看出问题,早做压测保平安。

上面说的几个问题实际上本质都是并发场景下的 lock contention 问题,全局写锁是高并发场景下的性能杀手,一旦大量的 Goroutine 阻塞在写锁上,会导致系统的延迟飚升,直至接口超时。在开发系统时,涉及到 sync.Pool、单个 FD 的信息上报、以及写日志的场景时,应该多加注意。早做压测保平安。

第 76 篇,ghost 支持了mermaid。

第 77 篇,年终总结和年初计划。这里看到了曹大和欧神的互动,名场面。

国外的兄弟

大佬间的交流

第 78 篇,从功能上来讲,规则引擎的基本就是一个 bool 表达式的解析和求值过程。可以直接使用 Go 的内置 parser 库完成上面一个基本规则引擎的框架。

第 79 篇,主要内容应该是曹大之前竞选 Gopher China 讲师时准备的。又是根据实际业务系统的一次精彩总结,希望以后自己也能写这样的东西。即使不能公开,也能私下总结一下。

第 80 篇,流式计算中的分布式快照的算法介绍,看不太懂。主要内容是最早的一篇论文及之后 FaceBook 出的一篇新论文的介绍。

第 81 篇,从这篇开始,是一个序列专门讲微服务相关的内容,比较精彩。本文主要是说通用语言的问题,比如高层讲战略用的是中文,但底层程序员用的是英文,这中间有一个翻译导致的信息差。这在微服务上是很难解决的,例如对快车的翻译在不同的系统中叫法是不一样的。

上面提到的 fastcar 出现在我们系统提供给别人所用的 api 的关键字段中,quickcar 出现在我们内部数据库的字段名中,kuaiche 出现在异步发送的消息中。

但我的现公司其实用的是统一的一个 idl 库,可以解决很多问题。

第 82 篇,继续说微服务的问题,进行拆分之后,各个小模块理论上可以用最适合的语言去实现,但这就造成了技术栈不统一的一些毛病,例如同一个 client 需要用各种不同的语言来实现,当遇到组织架构变动的时候,接手都很麻烦。B 站是很早就统一了技术栈的。

第 83 篇,新增功能时,可能同时需要改很多模块,从设计原则上来讲,逻辑上相同或者类似的代码应该放在一个地方来实现。这个稍微学过一点 SOLID 中的 SRP 原则就应该知道。这样可以避免逻辑本身过于分散,好处是:“一个类(模块)只会因为一个理由而发生变化”,其实就是相同的需求,尽量能够控制在单模块内完成。但职场上很多人并不是这么考虑的,做这件事是否有收益是第一要务。

第 84 篇,业界有个名词叫 dependency hell,指的是软件系统因依赖过多,或依赖无法满足时会导致软件无法运行。服务之间的循环依赖也有很多。程序员在当前的微服务架构下,将持续地被外部的垃圾 SDK 和各种莫名其妙的依赖问题所困。

第 85 篇,拆分成微服务后,一致性是个大问题。大多数公司的架构师嘴里的最终一致,依靠的都是人肉而非技术。

第 86 篇,现实中总要考虑上“政治”因素,推动事情并不容易,尤其是跨部门的协作。

如果一个公司的组织架构已经基本成型了,那么基本上设计出的系统架构和其人员组织架构必然是一致的。

之前和同事一起得到了一个在大公司内推进事情的靠谱结论,如果一件事情在一个部门内就可以解决,那可以开开心心地推动它解决。如果一件事情需要跨部门,那还需要本部门的大领导出面才能解决,哪怕这事情再小。如果一件事情需要跨两个部门,那就没治了,谁出面都不行。这种事情做不了的。而如果一件事情和你要跨的部门 KPI 有冲突,那就更别想了,把部门重组了才能解决,这是 CTO 才能干的事情。

第 87 篇,一篇回答知乎上“有哪些优秀的 Go 面试题”的答案。当初第一次看文章的时候,很多不懂,现在看很多地方懂了。

第 88 篇和 89 篇是参与 tidb talent-plan 的题解。前者是一个 merge_sort,后者是 map-reduce。

第 90 篇,尝试对接入做到完全配置化,从 SQL 语句得到启发,通过定义的元组来描述外部的数据、内部的存储。其实我没太懂,以后再研究下。

第 91 篇,讲了 Go 1.13 在 defer 上的一些优化。主要在某些情况下,可以用 deferprocStack 来代替以前的从堆上分配资源,提升性能。

第 92 篇,探索一个案例代码的优化过程。起因是看到群里有人问的一个含有 dead code 的样例代码是如何优化的,然后使用 GOSSAFUNC=main go build com.go 看了下优化过程。结论是:优化是在编译器后端做的。关于后端的优化过程,这里有一个欧神开发的在线小工具,可以很方便的查看:https://golang.design/gossa。

从词法分析到语法分析一般被称为编译器的前端(frontend),而中间代码生成和目标代码生成则是编译器后端(backend)。

第 93 篇,某团圆节(应该是中秋节吧)线上发上的真实案例,不是事故:下游系统抖动了,超时,很多 g 挂在 gopark 上,没法复用;只能创建更多的 g,这些 g 会被 append 进 allgs 数组,在 sysmon/GC 等时机会扫描 allgs;即使下游恢复了,allgs 数组也不会收缩,使得 CPU 消耗变大。只能通过重启恢复。

第 94 篇,博客用 caddy2 上 https,看着导航栏左边的小绿锁,更开心了。

第 95 篇,一个小的程序代码,只用 Rlock 的时候竟然“发现”有锁冲突,不可思议,经过排查发现:大量的 g 调度下,在 g 的执行过程中如果有调用任意的会切换 g 调度的情况下,下次回到调度该 g 的时间无法保证。还是挺有意思的。

第 96 篇,依赖反转原则,在很多地方都被人用不同的名词说过。名词不同,但本质相同。

第 97 篇,讲 ACL 的一些问题,不太能 Get 到精髓。

第 98 篇,MQ 的数据生产方、消费方、MQ 的维护者,三方各怀鬼胎,真正关心 MQ 数据的只有整个消息流的末端团队,但他控制不了生产者。很多事故的发生都是因为生产者重构导致发出有问题的消息。本问提出了 2 种可能的数据检验方案。

2019 年的文章看完了。这一年,曹大比较关注工程领域,微服务方面有不少输出。另外,排查了很多线上 Go 相关的问题。上一年读完源码,这一年就大展身手了。

第 99 篇,2020 年第一篇,是一道知乎上的题目,有些地方可能还会拿来做面试题,但其实只要运行一下就知道答案。再看看逃逸分析、SSA 优化就可以知道为什么了。文章最后一句,送给面试官:要是哪位工程师拿这个去做面试题,那就太缺德啦!

第 100 篇,线上的一个 panic case,偶现。最终排查的原因是 waitgroup 使用不当,造成并发读写 map,程序崩溃。文章里写一开始系统负责人声称一定是离职员工的锅,连代码都不愿意看。但曹大三下五除二,就还原了事实真相。

第 101 篇,举了一个打日志也可能造成锁竞争的例子,原因是最后都会加锁:fd.writeLock()。

如果提前有一些预见性,做好针对性压测,那就不会让你的用户在关键时刻靠重启续命了。

第 102 篇,主要讲了切片截取会导致内存泄露,并给出了相关的例子。

第 103 篇,《工程师应该怎么学习》,推荐所有的技术人都看看,我前前后后看了很多遍。据说是曹大跑路的时候发到内网上的。写得很赞,看书、看英文书、看博客、读论文、实践、总结、写博客、独立思考、代码库、笔记库、演讲能力,哪一个都值得我们认真学习、实战。

你所要做的是提前做好准备,在那一天到来的时候,在聚光灯下旁征博引,谈笑风生。

第 104 篇,内行的吐槽更为致命。因转到蚂蚁搞 mosn,这里就是过程中遇到的 go mod 的依赖问题,最后通过 replace 解决。

第 105 篇,因为 Go 错误处理的原因,提高 Go 项目的测试覆盖率其实比较难。

第 106 篇,又是一篇犀利的吐槽。架构师不常见,天下系统都一样。言必谈 DDD,中台,战略,一到了落地环节提不出合理的见解和建议。对于个人来说,脚踏实地,打好基础,从解决实际问题开始。

第 107 篇,首先用压测工具 wrk fasthttp,标准库,rust 写的 hello 程序,发现 fasthttp 几乎和 rust 一样快。fasthttp 快的原因:goroutine workerpool,对创建的 goroutine 进行了重用;在整个 serve 流程中,几乎所有对象全部都进行了重用。当然,因为 ctx 的重用,某些场景下会掉坑。

第 108 篇,介绍了一些 golang linter,以及怎么集成到代码 CR 的流程中。

所以如果你在维护有节操的开源项目的话,可以考虑给你的项目加个 github action 了,试用下来,感觉 reviewdog 是最简单直观的。

第 109 篇,分析了一些常见组件的连接池的具体实现。包括:http 标准库、http2、fasthttp、gRPC、thrift、redigo、go-redis/redis、database/sql。

第 110 篇,Go context 的源码实现分析。

第 111 篇,Go 如何实现自动抓取 profile,快速定位问题。这篇介绍了实现原理。

第 112 篇,如何 patch 私有函数,原理+代码实现,非常过瘾。主要原理就是找到原私有函数的代码位置,将前面的数个字节用一段跳转指令覆盖,这样就能实现“劫持”,跳转到目标函数。真牛逼。

在 mac 上跑的时候,函数名有一小点不同,修改如下:

1func main() {
2	m := generateFuncName2PtrDict()
3
4	heiheiPrivate()
5	origin := replaceFunction(m["main.heiheiPrivate"], (uintptr)(getPtr(reflect.ValueOf(Replace))))
6	heiheiPrivate()
7	copyToLocation(m["main.heiheiPrivate"], origin)
8	heiheiPrivate()
9}

在“劫持”完之后,再“恢复现场”,再次调用 heiheiPrivate(),又恢复原样。妙啊!

第 113 篇,介绍了 LockOSThread 的奇技淫巧,它可以“杀”死线程:在退出的时候和当前 g 绑定的线程就会直接销毁。

第 114 篇,曹大看的 youtube 上的一个吐槽微服务的视频的总结笔记,很有曹大的风格,花式吐槽。

第 115 篇,自动 dump 的实现及样例说明。这是 mosn 上的一个开源项目,有了这个项目,绩效稳了。

第 116 篇,一篇 2013 年 Google 对 packetdrill 的论文翻译。看不太懂,先跳过,以后有用到的时候再来看。

第 117 篇,介绍了一个 Google 的 subset 算法解决微服务场景下连接数量的问题。

第 118 篇,详细解释 Go 1.14 中 defer 的优化。


序号 时间 题目 内容
1 2015-10-13 从零搭建 lnmp 环境 介绍如何使用 vagrant 搭建开发 lnmp 环境
2 2015-11-01 一致性哈希详解 介绍一致性哈希原理
3 2015-11-05 Solr 5.3.1索引MySQL数据配置流程 一篇实战教程
4 2015-11-13 Elasticsearch环境搭建和river数据导入 一篇实战教程,把 Solr 换成 ES 2.0 了
5 2015-11-15 漫谈服务化、微服务(一) PHP 怎么做服务间调用
6 2015-11-16 Elasticsearch环境搭建和river数据导入(二) 接着第 4 篇,如何解决导入大表数据时虚拟机卡住的问题
7 2020-11-28 关于乐观锁 乐观锁,是一种写入、更新数据库时的逻辑特性
8 2020-11-30 [译]Redis事务详解 官方 Transactions 小节的文档翻译
9 2016-01-10 Elasticsearch环境搭建和river数据导入(三) 因为 MySQL 主从延迟导致的丢数据问题的解决
10 2016-02-01 一致性哈希-虚拟结点生成 介绍了 groupcache 和 memcached 如何生成虚拟节点
11 2016-03-05 elasticsearch服务开发总结 开发过程中遇到的问题及解决
12 2016-03-09 Elasticsearch环境搭建和river数据导入(四) 估计是 ES 最后一篇总结了
13 2016-04-11 中间件与责任链模式 laravel 框架里的一个实现
14 2016-06-26 beanstalkd 源码剖析 一个用 C 写的消息队列源码分析
15 2016-08-09 关于灰度发布 灰度发布的一些概念和实践
16 2016-08-10 谈一谈es的优势和限制 从数据库、检索服务、日志三个方面来谈 ES 能做的事,以及优势和限制
17 2016-08-28 使用sql来查询es 开发了一个 elasticsql 项目,可以用 SQL 来查 ES
18 2016-10-14 关于迅雷 简要介绍迅雷下载的原理
19 2016-10-23 初级程序员常犯错误一览 列举“初级”程序员的做法,共勉~
20 2016-11-06 es stack升级5.0了 把 logstash/kibana/elasticsearch 之类的东西统统变成 5.0
21 2016-11-12 关于我们每天都在用的token 公司登陆验证用的 Google Authenticator 来做校验的原理
22 2016-12-09 后端程序员面试题 翻译的一个后 GitHub 上的后端面试项目,并给出一些问题的回答
23 2016-12-16 重新探讨middleware Go web 中间件如何写
24 2016-12-22 一次kafka 0.9的重复消费问题排查 仅通过读文档将发现了问题
25 2016-12-25 如何与低水平web开发联调 吐槽和低水平前端联调的事,并给出了应付的办法
26 2016-12-26 sds与二进制安全 redis set 命令是否是二进制安全的实验
27 2017-01-24 [译]redis通信协议 一篇 redis client 和 server 的通信协议
28 2017-02-19 周末 听 B 站分享的一个总结
29 2017-03-11 [译]Kafka Replication 一篇 Kafka 消息 Replication 的原理的译文
30 2017-03-30 业务系统错误设计 从用户和研发的角度,来看业务系统如何处理错误的一些思考
31 2017-04-25 关于go的包管理 Go 包管理的一些问题,这时还是 GOPATH
32 2017-04-27 分布式锁 从 MySQL 到 redis 再到 redlock
33 2017-05-10 golang 和 ast 直接用源码展示如何用 parser 生成 ast,以及实际应用
34 2017-05-20 从 information_schema 到自动生成的 web dao 利用 information_schema 自动生成代码
35 2017-05-24 如何使你的 golang 项目达到 awesome go 的入选标准 关注两点:goreport、测试覆盖率(coveralls,或者 gocover)
36 2017-06-20 关于 ofo 关于 ofo 密码锁的一些方案设计
37 2017-07-11 pprof 和火焰图 一篇实例教程
38 2017-06-21 企业级应用与屎一样的代码 公司级的代码质量问题及一些解决办法
39 2017-08-31 如何定位 golang 进程 hang 死的 bug 如何定位 Go 的协作式调度的一个 bug
40 2017-09-12 十周年 回顾本科、工作
41 2017-10-11 从求交集开始 求交集的一个计法
42 2017-10-28 ApsaraCache 源码 diff 分析 分析优化如何实现
43 2017-11-30 recover 并不是无懈可击的 有些情况下加 recover 也没用,例如并发 map 读写
44 2017-12-28 clean architecture(上) 《Clean Architecture》读书笔记 1
45 2017-12-30 clean architecture(下) 《Clean Architecture》读书笔记 2
46 2018-01-07 开源书 宣告一下,《Go 语言》高级编程还在搞,而且会搞完
47 2018-01-22 你的负载均衡真的均衡么? 实验+推理说明一个均衡算法并不均衡
48 2018-01-31 使用 debugger 学习 golang lldb 和 dlv 的功能和使用
49 2018-02-09 如何在 kv 系统中支持简单的 SQL 对在线特征系统的接口的思考
50 2018-03-08 [译]go 和 plan9 汇编 一篇 Go 汇编的翻译
51 2018-03-30 协作/非协作式抢占 宏观上描述两者的运行过程
52 2018-04-05 Go 系列文章1:Channel 从使用到源码分析 channel 的使用及源码分析
53 2018-04-08 Go 系列文章2:Go 程序的启动流程 Go 程序启动流程
54 2018-04-14 [译]Go 和 interface 探究 interface 如何组装等等
55 2018-04-22 Go 系列文章3 :plan9 汇编入门 Go 汇编相关,非常好
56 2018-05-01 分布式系统中的不可靠复制问题 业务指标如何收集、计算
57 2018-05-17 goroutine 的状态切换 整理的 G 和 P 的状态切换图
58 2018-06-09 事故驱动开发 过于真实的互联网开发指南
59 2018-06-09 blog 升级了。。 喜大普奔,博客升级
60 2018-06-09 livelock 《Concurrency In Go》里的一个活锁的例子
61 2018-06-10 2018 年的几个目标 年终总结
62 2018-06-17 Go 系列文章4 : 调度器 源码分析
63 2018-06-18 concurrency in go 读书笔记 记录的一些代码样例
64 2018-06-23 Go 系列文章5 : 定时器 timer 源码分析
65 2018-06-28 Go 系列文章6: syscall syscall 原理
66 2018-07-04 packt 出版的书吐槽 一些英文书的出版社
67 2018-07-07 Go 系列文章 7: map map 源码分析
68 2018-07-16 Go 系列文章 8: select select 源码分析
69 2018-08-31 Go 系列文章 9: slice slice 源码分析
70 2018-09-01 松一口气 《Go 语言高级编程》初稿完成
71 2018-09-17 Go 1.9.2 的 bug Go 语言自身的一个 bug 复现
72 2018-10-04 Go 系列文章 10: sync sync 包源码分析
73 2018-11-24 Go 系列文章 11: semaphore semaphore 源码分析
74 2019-01-01 中台的末路 中台的困境
75 2019-01-06 几个 Go 系统可能遇到的锁问题 几个锁相关的案例
76 2019-01-24 在 ghost 中支持 mermaid 博客的优化
77 2019-02-06 2018 总结 && 2019 目标 年终总结和年初计划
78 2019-02-08 基于 Go 的内置 Parser 打造轻量级规则引擎 使用 parser 可以实现一套规则引擎
79 2019-04-13 一套实时特征系统的迭代过程 真实的业务场景的迭代升级过程,非常精彩
80 2019-04-14 流式计算中的分布式快照 最早的一篇论文及之后 FaceBook 出的一篇新论文的介绍
81 2019-05-01 微服务的灾难-通用语言 通用语言的问题,很难重构解决
82 2019-05-01 微服务的灾难-技术栈 技术栈不统一也是有问题的
83 2019-05-02 微服务的灾难-拆分 依赖设计原则划分责任是不太可能的
84 2019-05-02 微服务的灾难-依赖地狱 依赖过多、多重依赖、依赖冲突、依赖循环
85 2019-05-03 微服务的灾难-最终一致 人肉最终一致
86 2019-05-03 微服务的灾难-康威定律和 KPI 冲突 除了组织架构的问题,还需要考虑 KPI 的问题
87 2019-05-11 一些问题的答案 一篇知乎回答
88 2019-05-11 talent-plan tidb 部分个人题解-week 1 tidb 题解 1
89 2019-05-11 talent-plan tidb 部分个人题解-week 2 tidb 题解 2
90 2019-08-31 一劳永逸接入所有下游数据系统 接入做到完全配置化
91 2019-09-04 Go 1.13 defer 的变化 用 deferprocStack 提升性能
92 2019-09-22 查看 Go 的代码优化过程 探索一个样例代码的优化过程
93 2019-09-22 为什么 Go 模块在下游服务抖动恢复后,CPU 占用无法恢复 线上发生的真实案例
94 2019-10-04 从 nginx 切换到 caddy 博客系统用 caddy2 上 https
95 2019-10-13 一个和 RLock 有关的小故事 如何用 trace 查案
96 2019-10-20 依赖反转相关 很多不同的概念,但本质是一个东西
97 2019-11-02 ACL 和俄罗斯套娃 ACL 的一些问题
98 2019-11-24 MQ 正在变成臭水沟 MQ 的问题以及提出数据检验方案
99 2020-01-06 一个空 struct 的“坑” 如何看穿一些弱智面试题
100 2020-01-09 map 并发崩溃一例 waitgroup 使用不当造成的并发读写 context,导致 panic
101 2020-01-13 生于非阻塞,死于日志 写日志也可能会导致锁竞争
102 2020-01-19 slice 类型内存泄露的逻辑 切片截取会导致内存泄露
103 2020-01-26 工程师应该怎么学习 终生学习,成为更好的自己
104 2020-04-19 go mod 的智障版本选择 Go mod 的问题
105 2020-05-01 为什么提升 Go 项目的测试覆盖率有点难 因为 Go 的错误处理
106 2020-06-06 架构的腐化是必然的 业务驱动下,能上升的不是系统做的好的,而是堆业务的
107 2020-06-14 fasthttp 快在哪里 fasthttp 快的原因是几乎所有对象全部都进行了重用
108 2020-07-06 reviewdog 如何在提 RP 的时候,检测代码质量
109 2020-07-11 一些连接池相关的总结 常用组件的连接池实现
110 2020-07-11 Go context Go context 源码分析
111 2020-08-13 无人值守的自动 dump(一) 自动抓取 profile
112 2020-09-04 在 Go 语言中 Patch 非导出函数 如何 patch 私有函数,原理加代码实现
113 2020-09-18 极端情况下收缩 Go 的线程数 LockOSThread 的奇技淫巧
114 2020-10-06 10 个让微服务完全失败的 tips 老外的一个演讲,花式吐槽微服务
115 2020-11-03 无人值守的自动 dump(二) 书接第 111 篇,自动 dump 的实现及说明
116 2020-11-04 packetdrill 简介 论文翻译
117 2020-11-28 用 subsetting 限制连接池中的连接数量 介绍的一个 Google 的算法 subsetting
118 2020-12-03 open coded defer 是怎么实现的 Go 1.14 对 defer 的优化

术语

KVM:Kernel-based Virtual Machine is a virtualization module in the Linux kernel that allows the kernel to function as a hypervisor.

Consistent hashing:consistent hashing is a special kind of hashing such that when a hash table is resized, only n/m keys need to be remapped on average where n is the number of keys and m is the number of slots. In contrast, in most traditional hash tables, a change in the number of array slots causes nearly all keys to be remapped because the mapping between the keys and the slots is defined by a modular operation.

二八原则:网站开发有一个比较著名的二八原则,就是 80% 的用户其实访问的都是 20% 的数据。所以实际上你只要把这 20% 的数据缓存好,就可以让网站整体的响应和吞吐量上一个等级。

Solr:Solr is an open-source enterprise-search platform, written in Java, from the Apache Lucene project. Its major features include full-text search, hit highlighting, faceted search, real-time indexing, dynamic clustering, database integration, NoSQL features and rich document handling.

June 22, 2022 01:37 AM

June 21, 2022

qcrao 的博客

最重要的是内容

最近,看曹大依然在坚持固定频率发新的文章,非常佩服。今年是我写博客的第四年,因为各种原因,上半年基本没有发表新东西,非常惭愧。养成一个好习惯很难,破坏却很容易

这次将博客改用 hugo 搭建。最早 qcrao.com 是用 hexo 在 mac 渲染,然后将 public 推到 github 上的 qcrao.github.io。坏外是需要在 mac 上安装一堆前端的依赖,这些依赖大都年久失修。中间换了台 mac 废了很大劲安装环境,之后再也不想调整任何东西。即使非常想去掉标签和分类,也不想再去折腾。今年再次换了 m1,彻底不想再安装一次环境,索性直接换成 hugo,没有任何依赖项。换 hugo 其实也酝酿了很久了,迟迟没行动。

行动之前是找一个简洁、好看的主题。花了很多时间,看了好多主题,都不甚满意。前几天又去看了眼飞雪无情的博客,我记得他将 Maupassant 主题 copy 到了 hugo,他自己也用的这个主题。不过之前页面上挂了很多广告,看着内容很乱,所以我很早就排除了。现在去掉了这些广告后,看着非常清爽,遂决定开始行动。

首先按自己的喜好调整下了博客样式。先将之前看着不爽的东西去掉了,例如标签和分类,这两个基本没什么用,最多用分类就够了。顺便将字体改大了,看起来更省力。

然后修改了我的写作流程。现在的流程变成了:我在 github 上的私人 repo 提交文章的 md 文件,触发 github actions 执行 hugo 生成 public 文件夹,然后上传到 qcrao.github.io,利用 github 的能力自动部署博客页面。这一切都是由 github 托管,我不用去管博客部署这些琐事,完全将精力用于思考内容上。

之前老的内容,经过再次 review 后,再放上来。在此之前,读者也可以去这里贴出来的其他平台地址去查阅。

对于一个博客而言,内容是最重要的。长期来看,只有内容才能带来持续的价值。

June 21, 2022 02:00 PM

为梦想而努力!

向大佬们看齐,见贤思齐焉!这是他们的书单:

大佬 书单地址
曹大 https://xargin.com/readings/
芮神 https://xiaorui.cc/archives/3342

技术类

《MySQL 必知必会》100%,工作以后就没怎么参与用到 MySQL 的项目,基本的 SQL 语法都有点虚,用这本书重新复习一下。本书只讲了基本的语法,也就入个门的水平,读完找找感觉。『必知必会』这个系列的书都是比较容易读懂的,之后碰到问题再来翻翻。22.09

《gRPC Up and Running》100%,之前一直用的 thrift,新公司用 gRPC,找本入门书看看,对 gRPC 相关的内容也了解得七七八八了。gRPC 出生就含着金钥匙,现在它的生态也越来越强了,很多知名项目都默认支持,如 etcd, k8s 等等。22.10

《100 Go Mistakes and How to Avoid Them》100%,前后看了 2 个月了,后期提升了下速度。笔记在这里。22.12

《微服务架构设计模式》100%,这本书出了有 5,6 年了,主要讲如何从单体切换到微服务,很多内容今天看已经是公理了。书中用的 Java 示例,并且用了 spring 框架,看不太懂,代码部分基本就跳过去了。23.03

《MySQL 技术内幕 InnoDB 存储引擎》匆匆翻完全书,索引那部分着重看了下。23.03

《Wireshark 网络分析就是这么简单》100%,这本小书基本是一口气看完,写得很好。很久没看到对技术如此专业的文章了,看得热血沸腾。深度掌握一门“手艺”,才能融会贯通。作者在最后一节里写到对技术要先深度后广度。精通了某一门技术后,其他技术很容易达到更高的水平。但如果都是浅尝辄止,效果不一定好。23.03

《Wireshark 网络分析的艺术》100%,这本同样读地很顺畅,如果技术书都能写得像这样好读就好了。读的过程感觉像当年读吴军的《数学之美》那样有意思。因为看了这本书,最近也在 youtube 上看 wireshark 相关的视频。23.03

非技术类

《生命的反转》100%,急诊室的故事,书里讲的很多是疑难杂症,往往是给病人做了各种检查没有发现,经历各种“磨难”后才找到真正的病因。当然作者也指出,现实中并不都是这种疑难杂症,很多情况下都能很快查出病因。诊疗过程和我们处理线上出事故时的情景很像,服务有日志、打点,医生能做各种检查;服务有 ebpf,医生能做穿刺;服务需要快速止损,医生能输血、上呼吸机保命。关键时刻都需要冷静,才能想出应对办法。见多识广有时能“一击毙命”。线上碰到服务“死机”,一个 perf 命令可解;规培医生一句我之前在另一个医院碰到过类似的症状,最后发现是XX病,马上就能救病人于危难。22.09

《见识》100%,吴军在得到上的硅谷来信合集,都是他对人对事的思考。印象最深的就是做减法,事情不是做的越多越好,做最有价值的事情最重要。22.09

《金字塔原理》100%,精华:结论先行、以上统下、归类分组、逻辑递进。“背景、冲突、疑问、解答”,我们在写技术文时,如果能用这个形式,就不怕文章枯燥了。任何技术都是解决当前存在的问题,当然它也可能会引入其他问题,如果我们把这些梳理清楚,抽丝剥茧,层层递进,文章自然会引人入胜。有时候问题不好发现,但是我们有了这个形式(或者说模板)后,通过深入思考,也能总结出来。22.09

《态度》100%,吴军的另一本书,书里是他和他写个两个女儿的书信,解答女儿在人生各个阶段的困惑。例如选专业、求职、待人处事……看书的过程好几次都感叹如此美好的家庭氛围,如此开明的父母(虽然母亲没有在书中出现,但也能感知到),教育出来的下一代该有多好!这本书值得每一对父母和孩子看一遍,尤其是父母,对教育子女绝对有帮助。22.09

《把你的英语用起来》100%,书里面有很多让人耳目一新的观点和方法,我们在学校里学到的很多方法都是错误的。比如想学好英语,靠日积月累不行,得一鼓作气;单词需要在原著中存活,单纯背单词没有用。书里还介绍了很多顶级的学习资源,比如 ESLPOD 播客,非常适合外国人学习英语。总之,进入职场之后,如果想把英语捡起来,就一定要制定一个长达一两年的计划,一鼓作气地拿下英语,否则不如直接放弃。22.09

《微信背后的产品观》100%,在任何时候,逻辑、条理、结构都是最重要的。比如书里说设计就是分类,现实中很多产品往往做不好的原因是分类没有做好。只有分类做好了,对用户才显得亲切易懂,结构清晰。在 UI 里面最重要的是条理清晰,并不是大家误以为地怎么把用户界面表现得更绚丽一些。22.09

《饥饿的盛世》100%,封建独裁对人民的控制是空前绝后的,民间没有活力,社会没有生气。国家的命运完全决定于帝王的品质,所以盛世不常有。伴君如伴虎,一切都没有确定性,全凭皇帝一句话。22.11

《可能性的艺术》100%,民主是一种决策程序,而不是决策本身。它最重要的功能是通过给民众制度化的发言权,来解决统治者任意妄为的问题,“把权力关进笼子里”。22.12

《三国配角演义》100%,马亲王在故纸堆里寻找蛛丝马迹,还原历史,功力深厚,非常好看。23.01

《紫云寺大唐狄公案》100%,外国人写的一系列讲狄云杰探案的书,扑朔迷离。23.01

《大医-破晓篇》100%,马亲王的小说,几个医生在乱世瘟疫中的努力。23.01

《盐镇》100%,讲了各个年龄阶段的女人们的命运,大多是一地鸡手。这些故事仿佛发生在我身边一样。读后对很多事情有了更多的同情之理解。23.04

《小强升职记》100%,在飞机上快速看完的一本小书。回顾一下 GTD 的概念。最近发现 thing3 上的很多事情都没推进,一直挂着。原因其实是没有分解好下一步的任务,导致迟迟无法行动。23.04

《你是你吃出来的》100%,在飞机上看完的一本书。有很多“颠覆”的观念,病从口入,吃是很重要的一件事,但要吃的有营养。而这是需要知识的!这本书就是一个很好的材料。23.04

《缉凶,我在重案队的故事》100%,真实的故事,破案的过程远没有小说那么精彩,但贵在真实。23.05

《我在北京送快递》100%,很有意思,前半部分送快递那部分的工作内容读得很顺滑,作者描述的一份工作最后那些时间的感觉很有共鸣。23.05

《大医-日出篇》100%,回国的飞机上看完,不舍对书里的人物。23.06

《热血医生:记录真实的急诊科故事》100%,实习医生在医院的故事,见到各种不同的人和事,但文字里还是能感觉到热血,因为就业环境的问题,不少人选择了离开。23.06

《重来 3》100%,两天间断看完的一本小书,这是一个 CEO 写的书,工作这么多年,经历了职场上的各种奇葩后,看本书简直是各种“卧槽,就应该这样”。这家公司真的是很酷,非常注重保护员工的注意力,不刻意搞些 deadline,不定各种目标,人家就是活得很好。后来我还发现,我很喜欢的 youtube 博主 Peter Akkies 用的一个邮件管理 app,hey 也是 basecamp 公司开发的。23.07

《卡片笔记写作法》100%,还是要坚持之前的做法:边看书,看记录总结有用的内容,即对未来决策或写作有帮助的东西。要写下来,留着之后使用。磨刀不误砍柴功,只有这样,才能真正学到东西,因为只有自己理解的东西才能记得住。想通过重复来记住内容,不可靠。23.08

《第二大脑》100%,卡片笔记写作法的实战版。作者前两年还在王树义老师的公众号学习双链笔记软件,没想到他用双链笔记软件写的书都已经出来了,佩服。23.08

《相信》100%,一口气看完,深深地被蔡磊的不放弃的精神打动。我觉得如果给他 10 年,一定会把渐冻症攻克下来。看完书,去抖音看他的直播,说话都非常困难了。23.08

《回家》100%,孙海洋口述,他女儿孙悦执笔的书。有一个感觉是他就像《相信》里面的蔡磊一样,都是深陷困境,然后通过自己的能力做了一些事情,引起大众媒体的关注,从而推动了这个领域的一些进展。非常令人敬佩。书里对孩子被拐的描述非常之细腻,让人揪心。23.09

《太白金星有点忙》100%,马伯庸的最新小说,西游记里师徒四人其实是在取经前台,后台各派力量的角逐更加精彩。23.09

《金钱心理学》100%,财富可以让我们获取对时间的掌控,在任何时候和喜欢的人去做喜欢的事而且想做多久就做多久的能力,才是财富带给我们的最大红利。投资的钱要是“闲”的,这样才不至于在家庭发生意外的时候要被迫卖掉股票什么的。

June 21, 2022 12:13 AM

分享列表

公开的技术分享是必须的,也是必要的。

目前为止,我做过的公开技术分享都是关于 Golang 主题,且都是在『Go 夜读』这个平台。


主题 作者 观看链接 课件
Go defer 和逃逸分析 饶全成 YouTube, Bilibili -
Go map 源码阅读 饶全成 YouTube, Bilibili -
Go Scheduler 源码阅读 饶全成 YouTube, Bilibili -
如何用 things 3 管理我们的工作和生活? 饶全成 YouTube, Bilibili -

June 21, 2022 12:13 AM

关于我

一线互联网码农,热衷探究技术背后的原理。喜欢情景喜剧、相声、小品,阅读,终生学习者。

其他平台

Github:https://github.com/qcrao/Go-Questions/

博客园:https://www.cnblogs.com/qcrao-2018/

知乎主页:https://www.zhihu.com/people/raoquancheng/activities

掘金:https://juejin.im/user/5b616fd35188251b3c3b408f

 


微信公众号:码农桃花源

公众号,扫码关注

扫码关注

June 21, 2022 12:13 AM

May 28, 2022

李文周的博客

Apollo配置中心

Apollo(阿波罗)是携程开源的一款可靠的分布式配置管理中心,它能够集中化管理应用不同环境、不同集群的配置,配置修改后能够实时推送到应用端,并且具备规范的权限、流程治理等特性,适用于微服务配置管理场景。

May 28, 2022 02:26 PM

May 21, 2022

李文周的博客

基于 consul 实现服务注册与发现

Consul 是一个分布式、高可用性和数据中心感知的解决方案,用于跨动态、分布式基础设施连接和配置应用程序。

May 21, 2022 04:12 PM

May 14, 2022

李文周的博客

Protocol Buffers V3中文语法指南[翻译]

本文是官方protocol buffers v3指南的翻译。

May 14, 2022 02:03 PM

May 07, 2022

李文周的博客

RPC原理与Go RPC

本文介绍了RPC的概念以及Go语言中标准库rpc的基本使用。

May 07, 2022 02:51 PM

March 19, 2022

李文周的博客

处理并发错误

我们可以在Go语言中十分便捷地开启goroutine去并发地执行任务,但是如何有效的处理并发过程中的错误则是一个很棘手的问题,本文介绍了一些处理并发错误的方法。

March 19, 2022 02:51 PM

February 21, 2022

qcrao 的博客

你应该如何选择笔记软件

前言

市面上有非常多的笔记软件,让人眼花缭乱。如何选择适合自己的呢?我从下面几点谈谈我的理解。

这个视频里展示了一张非常全的笔记软件全家福,里面的软件我多多少少都用过或听说过。

搜索引擎能替代笔记软件吗

当我们记得笔记越来越多的时候,找内容就只能靠搜索了。于是有人就想,我不记笔记了,直接用搜索引擎多好啊。

仔细想想如果我们什么笔记都不记,一切都依赖搜索引擎,可行吗?毕竟所有的知识都可以在搜索引擎上找到。

且不说移动互联网时代,各家 App 的内容都沉淀到了自己的数据库里,不管什么搜索引擎都统统靠边。很多东西在搜索引擎上找不到回答,至少是找不到好的回答。

就算搜出了好的回答,你还是得重新甄别一下内容,远不如自己当初记一遍笔记,现在直接拿过来用更扎实。

另外,做笔记也是一个整理思路的过程,这个是搜索引擎给不了的东西。

记笔记是为了更好地决策

笔记软件不是阅读软件。印象笔记的剪藏如此好用,以至于这些年我们收藏了一堆文章,硬生生地把笔记软件用成了阅读软件。

少楠曾说:读书的目的并不是为了炫耀你在豆瓣上标记了多少,也不是记下一个非常漂亮的脑图,更不是为了记住书中所有知识点。而是为了更好地思考、更好地决策。所以对着目录画一个脑图并没有什么意思,有意思的是将书里的内容大卸八块,吸收进自己的体系里。^^重要的不是记录,而是更好地思考。^^

费曼也曾说过:「Notes aren’t a record of my thinking process. They are my thinking process.」

赵赛坡在《现代数字笔记指南》里说:笔记更像是一种聚拢思维,简言之,就是用户为了追求对事物的深刻理解,而不断聚焦再聚焦的思维模式。

无压力输入

传统软件采用的是树状的结构,每条笔记只属于一个节点。这也使得我们在新记录笔记的时候得小心思考应该把这则笔记放在哪个地方,然后再一层层地找到相应的地方,最后写下了可能是一句话的笔记。

我们随时都会有很多灵感、想法,如果不记下来,可能很快就忘了。在传统笔记软件里做这件事,并不容易:你得先找到记录的地方,再来写。很可能折腾一番后就失去了写笔记的动力。因为传统笔记软件基本是用文件夹层层分类,找到一条笔记的归属,不是简单的事情。这一点flomo就做得非常好,随时、多设备上无压输入,它通过标签来管理层级,没有文件夹。

而像Roam Research这些有 daily notes 的软件,记录灵感也很容易。每天都是一个新的以当天日期命名的页面,直接在这里输入,不管之前做的怎么样,今天的笔记都是一片空白,重头开始。而像 notion,仪式感非常强,你得从设定好的分类进去,层层打开、找到对应的页面,这时我们灵光一现想到的那些只言片语,可能也不好意思写下来了。

双链的好处

如果我们在笔记 A 里提到了笔记 B,就可以将 B 的超链接放到 A 笔记里,从 A 可以直接跳转到 B。但是当我们在看笔记 B 的时候,无法知道有哪些笔记链接到了它。

双链笔记则不同,从 A 能知道 B,看到 B 的时候,也能知道 A 提到了它,也就是链接到了它。当然,我们可以手动地在 A、B 这两篇笔记里都加上对方的链接来达到类似的效果。但是,就像在编程语言里,它不是一等公民,虽然形似,最终的效果却差之千里。

举个例子,我在整理 devonthink 这款软件使用方法的过程中,提到了王树义老师的视频:

那么我在王树义老师这个笔记页面里同样可以看到 devonthink 相关的这一条笔记。

大纲和 block

和思维导图类似,大纲这种形式是一种自上而下的记笔记方法。大纲可以清晰地展现思路,上钻、下钻、聚焦、拖拽,非常灵活。相反,把所有笔记拍平,一锅烩,复习起来很费劲。

Block 是一条原子化的笔记,它只讲一个事情,方便进行组合、复用。

双链笔记软件中,Roam Research 和 Logseq 支持 block 级别的双链引用。其他的诸如 obsidian、幕布只支持文件级别,两者使用体验相差甚远。

Roam 里的每一条笔记都是一个 Block,这些 Block 可以自由组合,变化无穷。假设你在不同的场景下分别记录了几次笔记,对应在印象笔记里面,就是新建了好几个笔记。每个笔记内部,有很多内容。这在 Roam Research 里面体现出来的就是很多个 Block,或者叫 Bullet;对比在印象笔记里面,就是一个大的页面,所有的内容都混杂在里面,一团浆糊。

图片,视频用超链接引入

我们用的笔记软件都有自己特有的数据格式,笔记很难在不同的笔记软件中间自由迁徙。但如果我们用文本来做笔记,就不一样了。unix 里有很多处理文本的小工具,日常也有很多编辑器来处理文本。即使不用笔记软件,一样可以做各种处理。有些人只用一个文本文档,照样风生水起,将工作、生活管理得井井有条。

在云时代,我们可以将图片、视频上传到 aws, github 这些地方,然后得到一个链接,插入到笔记里。今后,我们可以方便地将这些链接进行移动,在不同的地方使用,而图片在云端里好好地保存着,不用我们操心。反之,如果记笔记的同时,还维护着我们的图片、视频,想要移动他们是很麻烦的。

在 mac 上,结合一款叫做 hook 的软件,你可以将本地任何一个文件变成一个链接,放到笔记内容里。点击链接即可直达文件,即使你之后移动了文件的位置,依然可以准确定位到。这里有一篇王树义老师的文章讲了 hook 软件的一些其他高级用法。

另外,在 Roam Research 里,我们可以直接内嵌一条 youtube 视频链接,并在这条视频下做笔记,神奇的是笔记和视频时间戳绑定,直接点时间戳,马上定位到视频相应位置。

youtubeGo 语言线上问题定位与优化pprofGo Profiling and Observability from Scratch

数据安全

大数据时代,我们的每一份数据都很珍贵,笔记数据更应受到重视。我们也经常看到一些网络服务停服,如果我们没有关注到消息并及时备份数据,那就损失惨重。例如,一些图床服务器的停服会导致很多页面里的图片“裂开”。

很多 Roam Research 的用户都是很会折腾的主,开发出了不少定时保存数据的脚本,通过 github action 可以定期地将数据进行备份。即使将来笔记软件跑路,至少我们还有一份保拷贝。这里有王树义老师的视频教程,还有立青的一个升级版本,它能将图片从 google 云下载到本地。

立青做的和Roam Research结合的视频,王树义老师的自动备份 roam 视频

另外,有一些软件把数据放在本地,更安全,像 obsidian 就是本地数据。所以需要自己负责同步、备份,稍显麻烦。

除了数据安全,还有隐私层面的问题:笔记软件的维护者如果能看到你的笔记内容是不是很可怕?比如说,向 flomo 的服务号发送消息可以同步到 flomo,但是在服务号后台是能看到笔记内容的。所以少楠和他的伙伴约定:我们都保证不看。

目前,roam research 已经做到了端到端的加密,即使是官方的人也无法看到你的笔记内容。

笔记导入和导出

很多软件为了让用户来了就跑不了,故意不提供导出数据的功能。当我们在一款软件上积累地越久,也就越不想迁移到新的软件去。这也是很多人放不下印象笔记的原因。

不过,一键迁移看似酷炫、方便,一股脑地将旧笔记拷贝到新系统里并没有那么必要。也许用到了哪一条笔记,再手动地迁移到新软件上去,效果会更好。因为这相当于强迫复习了一次,加深印象。那些可能几年都不会再用到的笔记,一键迁移过来干什么呢?一条条地手动迁移意义更大。

所以,当我们看到更适合自己的笔记软件时,不要让导入问题成为阻碍选择新工具的原因。

费用

最近几年,很多服务开始收费了。相比于免费服务,付费服务也有自己的优势。服务提供者就是依靠会员费赚钱,他会专注做好服务,而不是憋着将来把用户当作流量卖给广告公司。当然有些吃相难看的笔记,即使交了钱,也依然不断地给你弹广告,这种就拉黑不用好了。

目前最贵的笔记软件应该就是 roam research 了,一年一百刀,就这还是五年一起交的价格。不过,算下来,一天不到 2 块钱,也还行。关键是要能提供相应的价值,相比于自己的成长,付费也是值得的。

当然了,免费的也有很多。例如 Logseq, obsidian,这两款软件都有很多开源爱好者在活跃,贡献了很多插件,极大丰富了生态,对于用户来说是很有利的。

别废话,到底用什么

我目前在用 roam research,私以为这是目前为止市面上最好用、功能最强大的笔记软件。

曹大在调研了一波笔记软件后,决定用 logseq + obsidian + icloud 这个组合,优点是开源、免费、数据保存在本地。顺便提一下,曹大之前还用过一段时间的 notion,最近因为“太卡了”转而寻求其他的工具。

举个我实际使用的例子。

不过,今年做的几次技术分享,这个软件的帮助很大。我会先确认好主题,接着在我之前的笔记里找相关的案例和资料。如果我之前做的笔记很详细,上下文都非常完备,我可以直接把它拖到新的主题里。神奇的是,这里的拖动就像编程里的指针一样,只是原来笔记的一个“引用”,一处修改,处处修改。而且,不光这次分享可以用,下次其他主题的分享,我还是可以拿过去当素材。

后记

都说差生文具多,笔记软件也不例外。很多人尝试了非常多的软件,最后发现记下的笔记数量可能还不如用过的笔记软件数量多。当我们在选择笔记软件的时候,一方面要选择适合自己的;另一方面也要回到问题的本质:记笔记是为了更好地思考。

February 21, 2022 12:00 AM

February 20, 2022

李文周的博客

Go单测从零到溜系列6—编写可测试的代码

本文是Go单测从零到溜系列的最后一篇,在这一篇中我们不再介绍编写单元测试的工具而是专注于如何编写可测试的代码。

February 20, 2022 02:52 PM

February 19, 2022

李文周的博客

Error接口和错误处理

Go 语言中的错误处理与其他语言不太一样,它把错误当成一种值来处理,更强调判断错误、处理错误,而不是一股脑的 catch 捕获异常。

February 19, 2022 03:50 PM

December 22, 2021

qcrao 的博客

二分法如何排查问题版本

二分法表面上看很简单,但历史上出现第一个没有 bug 的二分法代码还颇费了一番工夫。虽然我们在日常工作中不用手写二分法,但它的思想却很有用,例如用于排查 master 分支上有问题的 commit。

场景

通常来说,master 分支上的代码需要保证没有 bug,随时能够发布。但在实际的工作场景中,为每个 commit 做严格的 ab 测试、验证是很麻烦的事情。有时候,只改一行代码,改动非常小,直接就合入 master 了。

假设我们每周固定上一次线,master 上已经积累了十几个 commit 了。直接全量上线最新的 commit 可能会引入问题:业务指标不平、耗时不平、错误率不平……

因此要想办法能将这些 commit 安全地推上线,找到其中有问题的 commit。

原理

假设线上有 1000 个实例,拿出 50 台作为 base 组,50 台作为 abtest 组,也就是拿 10% 流量来做 ab 实验。前者就是基准,以这个为标准,如果 abtest 组符合标准,那就认为其没问题。

master 分支

在上面这张图中,C0 代表项目的第一个提交,C100 代表当前线上正在跑的版本。C101 到 C110 是一周以来所做的变更,我们的目标是将 C110 推全到线上。

问题是 C101 -> C110 之间可能存在一些有问题的 commit,任务就是找到这个 commit。

我们将 C100 部署到 base 组,将 C110 部署到 abtest 组,线上跑一天时间。

ab 实验

通过收集各种业务指标、服务指标、工程指标等,来比较两个版本间的差异。

如果我们发现 base 和 abtest 组的指标基本一致,说明 C101->C110 中间的版本没有问题,是安全的。那就可以直接推全 C110;否则,说明中间存在有问题的 commit,接下来就用二分法来定位。

总之,我们需要一个可以做 ab 实验的系统、一个可以比较各种指标的系统。

做法

理解了原理,做法就比较简单了。但这中间还是有一些稍微有点绕的地方,如果不深入研究一下,很可能会错过这些细节。

第一次先做个大跨度的 ab 实验:当前线上的版本(C100) -> master 上最新的版本(C110),将多个 commit 包括进来,发现指标不平,说明有问题的 commit 就在这个之间。

再在中间选择一个版本 C105,开一个 ab:C105->C110,发现有问题;同时,开一个 C100->C105 的 ab,没有问题。说明到 C105 为止都没有问题,C105 成为最新的安全版本。

接着又开了一个 ab:C105->C107,如果这时 base 组用的是 C106,其实是不行的。因为前面的实验只能说明 C105 没有问题,并不能说明 C106 没问题。即 到 C100 安全传递到了 到 C105 安全。

再继续开 C107->C110……

总结规律:整个过程就是一个通过二分查找不断扩大安全版本范围,缩小问题版本范围的过程

首先有一个已知的安全版本(通常是线上正在跑的版本),这个作为 base,然后另一侧先往外探一大步,通常是最新的提交;这一次 ab 实验 通常是有问题的,不然也没有接下来的二分查找。

接着在中间找一个版本,分别开两个 ab:start->mid,mid->end。运气好的话,start->mid 可以排除,那么安全范围会迅速扩大。然后是同样的思路,递归做下去。

总结

本文描述了一个如何用二分法检验 master 分支未上线 commit 的过程。

其中有一个关键的点是当确定了 mid 没问题时,下一次要以 mid 为起点,不能用 mid+1。因为 mid 被证明安全,可以作为 base,不能说明 mid+1 也安全。言外之意是 base 是免检的,其他 commit 都应该作为待检对象。

进一步抽象一下:给定一个包含 0 和 1 的数组,1 占多数,代表没问题的 commit,0 则代表有问题的 commit,需要找到其中的 0。

最简单的办法是一个个地找,线性找的复杂度是 O(n),改用二分查找法降为 O(logN),加快速度。

December 22, 2021 12:00 AM

November 21, 2021

qcrao 的博客

一次流量不均衡问题的排查记录

讲一个这周排查的访问流量不均的事儿。

下游同学反馈我们的服务调用流量不均,最高的实例有 1k+ QPS,最低的才 400+ QPS,相差太大。

流量不均

于是拉了平台的 oncall,询问是否开了 mesh,没开。那就是框架的事了。

再拉框架的 oncall,询问是否自己加了流量均衡的策略,也没加。那就是用的默认的流量调度策略:“加权随机”。

什么是加权随机?

加权是指按节点权重进行流量分配,随机意味着相同权重下的实例随机选择。

去查下游各个 host 的 weight 值。发现确实有些 host 的 weight 值相差比较大。有的值是 10,有的值是 50。看起来是符合预期的。

这时又提出有两个 host 的 weight 值一样,但 QPS 相差 4 倍。

有同学说,直接去 access 日志里捞一下就行了。一行日志代表一个访问,积累出每秒钟的访问量,结果不就出来了吗?

1grep '2021-11-20 10:01' xxx.log | awk '{print substr($3,1,8)}' | sort | uniq -c

结果会打印出在 10:01 这一分钟每秒的请求数,即 QPS。

果然,前面提到的这两台 host 访问量基本相同。看起来是监控打点出了问题。

找到其中 QPS 比较低的这一台机器,发现部署的 metricsserver CPU 受限很严重,说明丢了很多点,于是就造成了流量不均衡的假象。

之后找 metrics 的同学升级了套餐,上线完成之后,打点恢复正常。流量是均衡的。

这样一个简单的问题,还花费了一点时间。以后碰到类似的问题,第一时间看监控是否有问题。有些机器上的服务打点多,metricsserver 扛不住,丢点是在所难免的。

之前也碰到过几次打点不准的问题,查了半天,最后发现乌龙了。因此对于一些不太符合常理(例如本文的访问流量不均)的问题,先要确定打点没有问题。

November 21, 2021 12:00 AM

November 17, 2021

qcrao 的博客

Go map[int64]int64 写入 redis 占用多少内存

我们在系统设计面试或者在实际工作中,免不了要进行一些估算。之前的文章里讲过一些技巧,今天来个实战。

这是我最近在做的一个工作,将内存中的一个超大的 map[int64]int64 写入到 redis,map 里的元素个数是千万级的。设计方案的时候,需要对 redis 的容量做一个估算。

如果不了解 redis 的话,可能你的答案是用元素个数直接乘以 16B(key 和 value 各占 8B)。我们假设元素个数是 5kw,那估算结果就是:5kw _ 16B=50kk _ 16B = 800MB。

答案是错的。

为了解决这个问题,需要深入地研究一下 redis 的数据结构。

整个 redis 数据库就是一个大的 map,它容纳了所有的 key,我们都知道 key 都是 string 类型,而 value 则有 string, list, set, hashmap, zset……等类型。

Redis 中的一个 k-v 对用一个 entry 项表示,其中每个 entry 包含 key、value、next 三个指针,共 24 字节。由于 redis 使用 jemalloc 分配内存,因此一个 entry 需要申请 32 字节的内存。这里的 key, value 指针分别指向一个 RedisObject:

redis entry

1typedef struct redisObject {
2    unsigned type:4;
3    unsigned encoding:4;
4    unsigned lru:LRU_BITS;
5    int refcount;
6    void *ptr;
7} robj;

RedisObject 对应前面提到的各种数据类型,其中最简单的就是 redis 内部的字符串了。它有如下几种编码格式:

SDS 编码

图中的元数据包括 type,encoding,lru, refcount,分别表示数据类型,编码类型,最近一次访问的时间戳,引用次数。

当字符串是一个整型时,直接放在 ptr 位置,不用再分配新的内存了,非常高效。

解析一下 44 字节的原因:元数据和 ptr 共占 16 字节,加上 44 字节,再加上字符串末尾的 ‘\0’,共 61 字节。因为字符串的长度只有 44,因此 len 和 alloc 各用 1 个字节就够了。再加上 1 个字节的 flags,刚好是 64 字节。超过了这个值,SDS 就需要单独再申请一块内存,导致访问的时候就多了一跳指针。

多提一句,redis 最大支持 512MB 大小的字符串。

回答本文的问题,恰好我们要写入 redis 的 map 中的 key 和 value 都是整数,因此直接将值写入 ptr 处即可。

于是 map 的一个 key 占用的内存大小为:32(entry)+16(value)+16(value)=64B。于是,5kw 个 key 占用的内存大小是 5kw*64B = 50 kk * 64B = 3200MB ≈ 3G。

假如我们在 key 前面加上了前缀,那就会生成 SDS,占用的内存会变大,访问效率也会变差。

总之,我们根据要写入 redis 中的字符串的长度可以很方便地估算占用内存的总大小。如果 key 和 value 恰好都是 int64 类型的,那么尽量不要在 key 前加前缀,这样可以直接使用 key 的个数乘以 64B 就能算出占用内存的大小。

November 17, 2021 12:00 AM

September 23, 2021

李文周的博客

Go单测从零到溜系列5—goconvey的使用

这是Go语言单元测试从零到溜系列教程的第5篇,介绍了如何使用goconvey更好地编写单元测试,让单元测试结果更直观、形象。

September 23, 2021 02:12 PM

September 22, 2021

qcrao 的博客

介绍一个欧神写的剪贴板同步神器

经常会遇到这样的场景:手机上看到某位大佬发了一段醍醐灌顶的话,马上想记录到自己的笔记系统里去。但电脑上并没有登录微信,所以还得先登录电脑端微信,再自动同步消息,找到那段话,复制,记录……

如果我们用的是苹果全家桶,情况稍好一点:iphone 上复制之后,在 mac 端直接粘贴就行了。但“接力”功能有时也会失灵,不太可靠。

而如果手机用的是 iphone,电脑用的是 win,那日子就会更难过一点。如果恰好又要用 Linux 桌面版玩一些机器学习的项目,简直就太麻烦了,目前好像也没有太多的解决办法……

欧神开源的这个工具 midgard 正是解决剪贴板多端同步的问题,包括 mac,win,Linux,iphone。

剪贴版自动在 mac 和 win,桌面板 Linux 间同步,iphone 上用捷径获取、上传剪贴板。

多端同步

除了剪贴板同步,midgard 还有另外 2 个超级好用的功能:

  1. 图床
  2. 代码片段生成好看的图片

先说图床,在 mac 端的使用流程是这样的:截图;按下快捷键 ctrl+alt+s。图片被上传到服务器,并且图片会自动备份到你的 github 上。然后这张截图的链接就静静地躺在本地 mac 的剪贴板上,这时只需要 ctrl+v,就可以将图片链接贴到文章里,非常优雅。

再说第二个功能。之前 Go 夜读知识星球里有一个读代码的打卡活动,欧神每次都是在地铁上用手机看代码,完成打卡。需要一个工具能将 iphone 剪贴板上的代码片段转成好看的图片,再发表在星球上:

carbon

它就是 code2img,现在 code2img 也集成到 midgard 里来了。

介绍完了功能之后,再来简单看一下原理。

架构图

在 mac 端部署一个后台常驻进程,设置成开机启动。它通过 websocket 和 server 端保持同步,同时它会捕获本地系统快捷键和剪贴板的变化。每当本地剪贴板发生变化时它会将内容同步到 server,server 再将内容广播到其他端;当捕获到 ctrl+alt+s 快捷键后,会调用 allocate 接口将图片上传到 server 的 ./data 目录下,server 返回图片链接,并写入本地剪贴板。

最后,欢迎大家亲自试试,项目里有详细的安装文档和使用文档,中英文都有。另外,欧神的代码写得很好,值得多学习,有问题本文留言。

September 22, 2021 10:29 AM

September 21, 2021

李文周的博客

Go单测从零到溜系列4—使用monkey打桩

这是Go语言单元测试从零到溜系列教程的第4篇,介绍了如何在单元测试中使用monkey进行打桩。

September 21, 2021 03:12 PM

September 15, 2021

李文周的博客

Go单测从零到溜系列3—mock接口测试

这是Go语言单元测试从零到溜系列教程的第3篇,介绍了如何在单元测试中使用gomock和gostub工具mock接口和打桩。

September 15, 2021 03:11 PM

September 14, 2021

李文周的博客

Go单测从零到溜系列2—MySQL和Redis测试

这是Go语言单元测试从零到溜系列教程的第2篇,介绍了如何使用go-sqlmock和miniredis工具进行MySQL和Redis的mock测试。

September 14, 2021 02:31 PM

September 13, 2021

李文周的博客

Go单测从零到溜系列1—网络测试

这是Go语言单元测试从零到溜系列教程的第1篇,介绍了如何使用httptest和gock工具进行网络测试。

September 13, 2021 04:27 PM

Go单测从零到溜系列0—单元测试基础

这是Go语言单元测试从零到溜系列教程的第0篇,主要讲解在Go语言中如何编写单元测试以及介绍了表格驱动测试、回归测试和单元测试中常用的断言工具。

September 13, 2021 02:07 PM

September 09, 2021

qcrao 的博客

写 Go 时如何优雅地查文档

某天写代码时发现自己对 IDE 的依赖非常深,如果没了 Goland 就不会写代码了,心里为之一惊。

Goland 的自动补全功能已经是必需品了,只要打出相关的几个字符,不管是变量名还是函数调用,都能帮你直接补全。我们只需要往相应的位置填东西就行了。

进而又想到,当补全功能缺失或者暂时失灵的情况下,该如何快速地查出某个函数的具体用法呢?

假设我们想要对字符串做 split,却忘了具体用法,下面是几种常见的查文档方法。

Google

google

在设置了语言是 english 的情况下,还是挺精准的。直接定位到 Go 官方文档。

Dash

Dash

同样很准确,搜索词不需要很精准。

devdocs.io

devdocs

这个也不错,而且支持很多种语言。

pkg.go.dev

pkg.go.dev

优点是官方文档,最权威,逼格最高。缺点是要准确地记住包名+函数名。

go doc

cmd

优点是直接 iTerm2 里就可以查看,缺点是需要准确地记住包名+函数名。

有些大佬用 vim 写代码,在 shell 环境里直接能查文档,还是很有用的。不过对我等用 Goland 的菜鸡用处不大。😂


上面这几种方法我用得最多的还是 Google,可能这并不是最快的方式,但是它总是能帮你找到所有有用的信息。没有 Google,我可能也不会写代码了。

最近看到一篇文章,就讲了如何利用 Go 标准库做出一个好用的查文档工具。

原理是利用 Go 提供的包解析工具,把所有的导出类型列出来。然后在我们搜索的时候用模糊匹配的方式找到符合的类型,再用这个精确的类型调用 go doc

流程如下:

gdoc 原理

在 Linux 下结合 dmenu,使用非常顺滑:

gdoc-cmd

偷个懒,直接用原文的动图。😀

当然,不嫌弃浏览器的情况下,还提供了一个可视化的界面,同样有模糊匹配的功能且可以一键直达 pkg.go.dev 对应的页面。比 google 可能快一点。

gdoc-web

选中其中一个,会直接跳转过来:

跳转到 pkg.go.dev

后记

不过,即使知道了这些方法,可能最后还是会退化到用 Google 直接搜,因为啥都不需要记,所有的东西都可以用 Google 搜索出来。

这也是最方便的方法,什么额外的事情都不用做。因为方便,成本低,自然就想把所有的事情都挪到它上面来做,即使有很多专业的查文档工具的情况下,还是会这么做。

一件事,如果容易,那就会经常做。反之,如果成本比较高,结果不是做这件事花的时间更多,而是我们选择不去做它。

不知道你平时查文档时用的什么方法,欢迎留言一起讨论。

September 09, 2021 06:38 AM

August 29, 2021

qcrao 的博客

[]int 能转换为 []interface 吗?

这个问题的答案是:不能。

如果你还想知道更多的信息,就往下看。^_^

有些时候我们希望有这样的写法:定义一个参数为 []interface 的函数,在程序运行的过程中,传入 []int 或其他类型的 slice,以此来达到少写一些代码的目的。譬如下面这个弱智的求 slice 和的例子:

 1package main
 2
 3import "fmt"
 4
 5func sliceSum(inters []interface{}) (res interface{}) {
 6	nums := inters.([]int)
 7
 8	sum := 0
 9	for _, num := range nums {
10		sum += num
11	}
12
13	return sum
14}
15
16func main() {
17	is := []int{7, 8, 9, 10}
18
19	fmt.Println(sliceSum(is))
20}

为了把这个程序写得更通用一点,参数和返回值都是用的 interface 类型。编译,会报错:

1./inter.go:6:16: invalid type assertion: inters.([]int) (non-interface type []interface {} on left)
2./inter.go:19:22: cannot use is (type []int) as type []interface {} in argument to sliceSum

第一个错:不能将左边的 []interface{} 转换成右边的 []int,因为 []interface 本身并不是 interface 类型,所以不能进行断言。

第二个错:sliceSum 函数不能接受 []int 类型的参数,因为 []int 不是 []interface 类型。

先把程序改成正确的:

 1package main
 2
 3import "fmt"
 4
 5func sliceSum(inters []interface{}) (res interface{}){
 6	sum := 0
 7	for _, inter := range inters {
 8	  sum += inter.(int)
 9	}
10
11	return sum
12}
13
14func main() {
15	is := []int{7, 8, 9, 10}
16  
17	iis := make([]interface{}, len(is))
18	for i := 0; i < len(is); i++ {
19	  iis[i] = is[i]
20	}
21
22	fmt.Println(sliceSum(iis))
23}

直接在循环的地方,对 inters 里的每个元素进行断言后再累加。

再来研究下 Go 官方说的:[]int[]interface{} 内存模型不一样是什么意思。

之前的 slice 文章讲过,slice 底层有 3 个属性:

slice

interface文章讲过,interface 底层有两个属性:

interface

用 dlv 来调试,在关键地方打上断点:

image

知道了 slice 地址后,打印出该地址处的数据:

1x -fmt hex -len 24 0xc000055f30

int slice

第一行即 slice 底层的数组地址,0x04, 0x04 分别指的是长度、容量。0x07、0x08、0x09、0x0a 则是数组的四个元素。

slice memory

同样的方法,来看看 interface slice 的内存布局:

interface slice

其实也非常清楚,它的数据部分占 64 字节:因为一个 interface{} 占用 16 个字节,4 个元素所有是 64 个字节。

interface memory

最后,总结一下:Go 官方规定,[]int 不能转换成 []interface{},因为两者是不同的类型,[]interface 不是 interface 类型,且两者的内存布局并不相同。

解决办法就是泛型。那泛型的原理是什么呢?又是怎么实现的呢?问就是不知道~😛

注:本文内容主要来自于 Eli 的博客

August 29, 2021 01:05 PM

August 08, 2021

qcrao 的博客

曹大带我学 Go(11)—— 从 map 的 extra 字段谈起

熟悉 map 结构体的读者应该知道,hmap 由很多 bmap(bucket) 构成,每个 bmap 都保存了 8 个 key/value 对:

hmap

有时落在同一个 bmap 中的 key/value 太多了,超过了 8 个,就会由溢出 bmap 来承接,即 overflow bmap(后面我们叫它 bucket)。溢出的 bucket 和原来的 bucket 形成一个“拉链”。

对于这些 overflow 的 bucket,在 hmap 结构体和 bmap 结构体里分别有一个 extra.overflowoverflow 字段指向它们。

如果我们仔细看 mapextra 结构体里对 overflow 字段的注释,会发现这里有“文章”。

1type mapextra struct {
2	overflow    *[]*bmap
3	oldoverflow *[]*bmap
4
5	nextOverflow *bmap
6}

其中 overflow 这个字段上面有一大段注释,我们来看看前两行:

1// If both key and elem do not contain pointers and are inline, then we mark bucket
2// type as containing no pointers. This avoids scanning such maps.

意思是如果 map 的 key 和 value 都不包含指针的话,在 GC 期间就可以避免对它的扫描。在 map 非常大(几百万个 key)的场景下,能提升不少性能。

那具体是怎么实现“不扫描”的呢?

我们知道,bmap 这个结构体里有一个 overflow 指针,它指向溢出的 bucket。因为它是一个指针,所以 GC 的时候肯定要扫描它,也就要扫描所有的 bmap。

而当 map 的 key/value 都是非指针类型的话,扫描是可以避免的,直接标记整个 map 的颜色(三色标记法)就行了,不用去扫描每个 bmap 的 overflow 指针。

但是溢出的 bucket 总是可能存在的,这和 key/value 的类型无关。

于是就利用 hmap 里的 extra 结构体的 overflow 指针来 “hold” 这些 overflow 的 bucket,并把 bmap 结构体的 overflow 指针类型变成一个 unitptr 类型(这些是在编译期干的)。于是整个 bmap 就完全没有指针了,也就不会在 GC 期间被扫描。

1overflow    *[]*bmap

另一方面,当 GC 在扫描 hmap 时,通过 extra.overflow 这条路径(指针)就可以将 overflow 的 bucket 正常标记成黑色,从而不会被 GC 错误地回收。

当我们知道上面这些原理后,就可以利用它来对一些场景进行性能优化:

map[string]int -> map[[12]byte]int

因为 string 底层有指针,所以当 string 作为 map 的 key 时,GC 阶段会扫描整个 map;而数组 [12]byte 是一个值类型,不会被 GC 扫描。

我们用两种方法来验证优化效果。

主动触发 GC

这里的测试代码来自文章《尽量不要在大 map 中保存指针》

 1func MapWithPointer() {
 2    const N = 10000000
 3    m := make(map[string]string)
 4    for i := 0; i < N; i++ {
 5        n := strconv.Itoa(i)
 6        m[n] = n
 7    }
 8    now := time.Now()
 9    runtime.GC()     
10    fmt.Printf("With a map of strings, GC took: %s\n", time.Since(now))
11
12    // 引用一下防止被 GC 回收掉
13    _ = m["0"]
14}
15
16func MapWithoutPointer() {
17    const N = 10000000
18    m := make(map[int]int)
19    for i := 0; i < N; i++ {
20        str := strconv.Itoa(i)
21        // hash string to int
22        n, _ := strconv.Atoi(str)
23        m[n] = n
24    }
25    now := time.Now()
26    runtime.GC()
27    fmt.Printf("With a map of int, GC took: %s\n", time.Since(now))
28
29    _ = m[0]
30}
31
32func TestMapWithPointer(t *testing.T) {
33    MapWithPointer()
34}
35
36func TestMapWithoutPointer(t *testing.T) {
37    MapWithoutPointer()
38}

直接用了 2 个不同类型的 map:前者 key 和 value 都是 string 类型,后者 key 和 value 都是 int 类型。整个 map 大小为 1kw。

测试结果:

1=== RUN   TestMapWithPointer
2With a map of strings, GC took: 150.078ms
3--- PASS: TestMapWithPointer (4.22s)
4=== RUN   TestMapWithoutPointer
5With a map of int, GC took: 4.9581ms
6--- PASS: TestMapWithoutPointer (2.33s)
7PASS

于是验证了 string 相对于 int 这种值类型对 GC 的消耗更大。正如这篇文章的标题所说:

Go语言使用 map 时尽量不要在 big map 中保存指针。

用 pprof 看对象数

第二种方式就是直接开个 pprof 来看 heap profile。这次我们将 string 类型的 key 优化成数组类型:

 1package main
 2
 3import (
 4	"fmt"
 5	"io"
 6	"net/http"
 7	_ "net/http/pprof"
 8)
 9
10// var m = map[[12]byte]int{}
11var m = map[string]int{}
12
13func init()  {
14	for i := 0; i < 1000000; i++ {
15		// var arr [12]byte
16		// copy(arr[:], fmt.Sprint(i))
17		// m[arr] = i
18
19		m[fmt.Sprint(i)] = i
20	}
21}
22
23func sayHello(wr http.ResponseWriter, r *http.Request) {
24	io.WriteString(wr ,"hello")
25}
26
27func main() {
28	http.HandleFunc("/", sayHello)
29	err := http.ListenAndServe(":8000", nil)
30	if err != nil {
31		fmt.Println(err)
32	}
33}

注意,去掉代码里的注释即可将 key 从 string 优化成数组类型。

直接在 init 里构建 map,然后开 pprof 看 profile:

key 为 string

key 为数组

对象数从 33w 下降到 1.5w,效果非常明显。

map 的 key 和 value 要不要在 GC 里扫描,和类型是有关的。数组类型是个值类型,string 底层也是指针。

不过要注意,key/value 大于 128B 的时候,会退化成指针类型。

那么问题来了,什么是指针类型呢?**所有显式 *T 以及内部有 pointer 的对像都是指针类型。

——来自董神的 map 优化文章

关于超过 128 字节的情况,源码里也有说明:

1	// Maximum key or elem size to keep inline (instead of mallocing per element).
2	maxKeySize  = 128
3	maxElemSize = 128

总结

当 map 的 key/value 是非指针类型时,GC 不会对所有的 bucket 进行扫描。如果线上服务使用了一个超大的 map ,会因此提升性能。

为了不让 overflow 的 bucket 被 GC 错误地回收掉,在 hmap 里用 extra.overflow 指针指向它,从而在三色标记里将其标记为黑色。

如果你用了 key 是 string 类型的 map,并且恰好这些 string 是定长的,那么就可以用 key 为数组类型的 map 来优化它。

通过主动调用 GC 以及开 pprof 都可观察优化效果。

August 08, 2021 06:38 AM

August 03, 2021

qcrao 的博客

曹大带我学 Go(10)—— 如何给 Go 提性能优化的 pr

之前写了一篇《成为 Go Contributor》 的文章,讲了如何给 Go 提一个 typo 的 pr,以此熟悉整个流程。当然,离真正的 Contributor 还差得远。

开课前曹大在 Go 夜读上讲了他给 Go 提的一个关于 tls 的性能优化,课上又细讲了下,本文就带大家来学习下他优化了啥以及如何看优化效果。

第一次提的 pr 在这里,之后又挪到了一个新的位置,前后有一些代码上的简化,最后看着挺舒服。

优化前每个 tls 连接上都有一个 write buffer,但是活跃的连接数很少,很多内存都被闲置了,这种就可以用 sync.Pool 来优化了。

conn

用 sync.Pool 缓存 []byte,并顺带将连接上的一个 outBuf 字段给干掉了:

files changed

整体上改动挺少,效果也不错。

虽然一开始给了 _test 文件,但其实并不能太好反映性能的提升。因此后面曹大又写了一个简单的 client 和 server 来实际测试。

我在开发机上测了一下,优化还是挺明显的。这又是一个使用 pprof 查看性能优化的好例子。

client 的代码如下:

 1package main
 2
 3import (
 4	"crypto/tls"
 5	"fmt"
 6	"io/ioutil"
 7	"net/http"
 8	"os"
 9	"strconv"
10	"sync"
11
12	"go.uber.org/ratelimit"
13)
14
15func main() {
16	url := os.Args[3]
17	connNum, err := strconv.ParseInt(os.Args[1], 10, 64)
18	if err != nil {
19		fmt.Println(err)
20		return
21	}
22
23	qps, err := strconv.ParseInt(os.Args[2], 10, 64)
24	if err != nil {
25		fmt.Println(err)
26		return
27	}
28
29	bucket := ratelimit.New(int(qps))
30
31	var l sync.Mutex
32	connList := make([]*http.Client, connNum)
33
34	for i := 0; ; i++ {
35		bucket.Take()
36		i := i
37		go func() {
38			l.Lock()
39			if connList[i%len(connList)] == nil {
40				connList[i%len(connList)] = &http.Client{
41					Transport: &http.Transport{
42						TLSClientConfig:     &tls.Config{InsecureSkipVerify: true},
43						IdleConnTimeout:     0,
44						MaxIdleConns:        1,
45						MaxIdleConnsPerHost: 1,
46					},
47				}
48			}
49			conn := connList[i%len(connList)]
50			l.Unlock()
51			if resp, e := conn.Get(url); e != nil {
52				fmt.Println(e)
53			} else {
54				defer resp.Body.Close()
55				ioutil.ReadAll(resp.Body)
56			}
57		}()
58	}
59
60}

逻辑比较简单,就是固定连接数、固定 QPS 向服务端发请求。

server 的代码如下:

 1package main
 2
 3import (
 4	"fmt"
 5	"net/http"
 6	_ "net/http/pprof"
 7)
 8
 9var content = make([]byte, 16000)
10
11func sayhello(wr http.ResponseWriter, r *http.Request) {
12	wr.Header()["Content-Length"] = []string{fmt.Sprint(len(content))}
13	wr.Header()["Content-Type"] = []string{"application/json"}
14	wr.Write(content)
15}
16
17func main() {
18	go func() {
19		http.ListenAndServe(":3333", nil)
20	}()
21	http.HandleFunc("/", sayhello)
22
23	err := http.ListenAndServeTLS(":4443", "server.crt", "server.key", nil)
24	if err != nil {
25		fmt.Println(err)
26	}
27}

逻辑也很简单,起了一个 tls server,并注册了一个 sayhello 接口。

启动 server 后,先用 1.15(1.17 之前的版本都可以,曹大的改动还没合入)测试:

1go run server.go
2
3# 1000 个连接,100 个 QPS
4go run client.go 1000 100 https://localhost:4443

查看 server 的内存 profile。后面还会用 --base 的命令,比较前后两个 profile 文件的差异。

pprof 的命令如下:

1go tool pprof --http=:8000 http://127.0.0.1:3333/debug/pprof/heap

Go 1.15 mem profile

看看这个大“平顶山”,有那味了(平顶山表示可以优化,如果是那种特别窄的尖尖就没办法了)~

因为这个 pr 已经合到了 1.17,我们再用 1.17 来测一下:

1go1.17rc1 run server.go
2go1.17rc1 run client.go 1000 100 https://localhost:4443

Go 1.17 mem profile

为了使用 --base 命令来进行比较,需要把 profile 文件保存下来:

1curl http://127.0.0.1:3333/debug/pprof/heap > mem.1.14
2curl http://127.0.0.1:3333/debug/pprof/heap > mem.1.17

最后来比较优化前后的差异:

1go tool pprof -http=:8000 --base mem.1.15 mem.1.17

&ndash;base

优化效果还是很明显的。我们来看菜单栏里的 view->top

view-&gt;top

整个优化从最终的提交来看还挺简单,但是能发现问题所在,并能结合自己的知识储备进行优化还是挺难的。我们平时也要多积累相关的优化经验,到关键时候才能顶上去。像 pprof 的使用,要自己多加练习。

August 03, 2021 03:43 PM

July 21, 2021

qcrao 的博客

曹大带我学 Go(9)—— 开始积累自己的工具库

不知道你有没有这样的经验:看了很多计算机相关的书,觉得自己懂得很多,但是一遇到实际问题,就不会解。

再看身边的老司机,执行几行命令,看了几个指标,就准确地定位问题了。他可能也没看那么多理论,但实战能力确实强,心里一下子就失衡了。

这其中有很多原因,我认为其中有一个比较重要的就是:工具的使用。老司机因为经验多,积累了很多 命令、shell 脚本、代码库……这些东西就像瑞士军刀,关键时刻,直接就可以派上大用场。在线上出问题的时候,云淡风轻地说,这行代码有问题,删掉就可以了。潇洒至极!

今天我就把我最近积累的一些工具,包括一些软件、命令,这些是可以直接用于实战的。希望看完之后,能提升你的战斗力。

效率工具

今天推荐 2 个我日常用得比较多的,提升效率的软件:aText、paste。

aText

aText 是一个输入映射的软件,输入预先设定的字符串,就可以转成设定好的目标字符串。我用 aText 存了很多有用的映射,例如,我把打开博客文章的命令缩写成了 XPosts

因为博客文件所在的路径比较长,如果我每次都直接敲出完整路径的话,会很麻烦。有了这个映射后,只用输入 XPosts 就自动变成了我要的文件路径。

还有很多场景可以使用 aText,尤其是你经常要输入的相同的内容,非常方便。

paste

paste 管理剪贴版的历史,只要是你复制过的内容,它都会保存下来,甚至可以对文本内容进行搜索。

比如可以把开发、排查问题时常用的链接、命令都放到一个 tab 下面,要用的时候,直接快捷键调出,怎一个优雅了得。

命令

这部分挑了一些非常有用的命令出来,大家可以记在笔记里,关键时候直接拿出来用。

查看 cache size

看 Go 源码的时候,经常能看到一些 pad 字段,这个字段主要是用来防止 false sharing,一般是根据 cache line size 来算 pad 大小的。那么查看这个 size 的大小呢?

1getconf LEVEL1_DCACHE_LINESIZE

查看内核版本

有时候会遇到一些和内核版本相关的问题,例如 Go 语言里面的内存归还策略在 Go 1.12 有一个改动,将 MADV_FREE 改成了 MADV_DONTNEED,导致线上应⽤的 RSS ⼤幅上升。

使用 MADV_FREE 方式,程序内存不会立刻回收,即 RSS 值不会立刻下降,只有当 OS 内存紧缺时才会回收 Go 程序的内存;

而 Go 1.11 以及之前的版本默认采用的是 MADV_DONTNEED 方式,程序 RSS 值下降很快。

因此如果需要使程序内存占用下降很慢的话,可设置环境变量 GODEBUG=madvdontneed=1

另外,MADV_FREE 只在 Linux 4.5 及之后的版本才有,所以当你遇到 RSS 一直降不下去的场景时,要想确认是不是这个问题导致的,还得看你的内核版本是啥。那就用这个命令:

1uname -a

这个归还内存的策略在 Go 1.16 又改回去了。因此只有在 Go 1.12-Go 1.15 之间,且是 Linux 4.5 及之后的内核版本才会有这个问题。

输出代码行号

有时候需要用 cat 命令输出一段代码,截图发给别人。这时如果需要对着代码行号做一些说明的话,把代码行号一并输出来是非常 nice 的,只需要用这个命令:

1cat -n a.go

汇总展示代码构成

当我们想看一个开源项目的代码行数的时候,并且能看到各种类型的语言各占多少的时候,怎么办呢?一个命令搞定:

1tokei ./

就问你强不强!

这个用在什么场景呢?太多了,例如你准备看一个稍微简单一点的框架,有几个侯选的:chi, echo, gin……执行一下命令,看看代码行数,选择一个最少的。

后记

工具是很重要的,积累了很多前人的智慧,我们拿来直接用,不需要自己再从零开始。当然,适当地学习原理也是必须的。

我们要记住这些命令,存入自己的工具库,要用的时候直接调出来。它们可以节省我们大脑的内存,把宝贵的资源用在思考真正的问题上,而不是记住这些命令。

而且当我们有了自己的代码码、脚本库、工具库的时候,遇到问题,拿上相应的家伙上场,马上就可以得到很多相关的信息,我们再根据这些信息做决策。

今天就先列这些吧,其实还准备了好几个关于 Go 的,先不放出来。如果这篇比较受欢迎,就下期再写了。

最后,也欢迎在留言区分享你的工具,无论是软件,还是一行命令。我会汇总后再分享给大家,切实有效地提升大家的能力。

July 21, 2021 03:42 PM

July 19, 2021

qcrao 的博客

曹大带我学 Go(8)—— 一个 metrics 打点引发的事故

最近线上事故频发,搞得焦头烂额,但是能用上跟曹大学的知识并定位出了问题,还是值得高兴一把的。毕竟“打破砂锅问到底”,“定位出根因”一直是技术人的优良品质。

虽然我们总是逃不过事故驱动开发的魔咒,但吃一堑长一智,看别人的事故,学到的是自己的能力。

现象

一个平凡的午高峰,服务在全量上线的过程中,碰到一个非常重要的下游接口超时(后来发现该下游也在上线,可用实例数变少,正常实例负载变高,超时了一丢丢),拿不到该拿的数据,阻塞了启动。这样,打到线上正常实例上的流量就增加了。

不大会儿,线上大量实例 OOM,报警满天飞。不得已,回滚(遇到事故,第一件事就是回滚)。没想到,回滚无效,OOM 的实例数还是一直在增加,给跪了。

中间查到服务在启动过程中,一直在尝试调那个超时的下游,就临时把超时时间加大,并取消回滚,紧急上线了一把。

之后,正常启动的实例越来越多,服务逐渐恢复。

第一天排查

发生事故之后就是排查过程了。

第一天,只查到了 OOM 的实例 goroutine 数暴涨,接口 QPS 有尖峰,比正常翻了几倍。所以,得到的结论就是接口流量太多,超过服务极限,导致 OOM。

接着尝试在预览环境压测,没有复现 OOM,gg。

结论有问题!

第二天排查

第二天运气比较好,发现了线上有一个实例在不断地重启,一看监控,发现正常启动后,5 分钟左右就 OOM 了。

有现场就不慌。

正好跟着曹大学会了如何用 pprof 查问题,马上就安排,三下五除二就搞定了。

先看 inuse_space:

inuse_space

虽然,这个 model 占用的内存比较高,但这是正常的业务逻辑,看了看相关的代码最近也没有改动。

所以,这个只能是果,不是因。

再看 CPU:

CPU

中间加粗的红色线条和方框就差要告诉我有问题的代码在哪一行了!图上方有调 metrics 的函数名(这里没展示出来),一搜就搜出来了。

再看了下 goroutine:

goroutine

结论还是一样的。

顺着图上的函数名,马上就在一个多重嵌套循环里找到了一处不那么显眼的打点。

metrics 打点的剧本我熟,之前看了曹大的“几个 Go 系统可能遇到的锁问题”的文章(点击阅读原文可读),逻辑大概是这样的:

由于 metrics 底层是用 udp 发送的,有文件锁,大量打点的情况下,会引起激烈的锁冲突,造成 goroutine 堆积、请求堆积,和请求关联的 model 无法释放,于是就 OOM 了。

然后我们替换 bin 上到这台 OOM 的机器,果然恢复正常了,收工!

最后看了下 metrics 的代码,其实还不是文件锁的原因。不过也差不多了,也是一把大锁,所有 goroutine 的打点都会先 append 到一个 slice 里,append 前要先加锁。

由于这个地方的打点非常多,几十万 QPS,一冲突,goroutine 都 gopark 去等锁了,持有的内存无法释放,服务一会儿就 gg 了。

总结

引发这次事故的代码其实是一年多前写的,跑了一年都没出事,这次就遇到了,你说可气不?

幸亏不是自己写的,但也要敲个警钟:我写的每一行代码,将来都可能会引发事故,一定要认真对待。

遇到 OOM 不可怕,有现场就行。拿不到现场,我也没啥好办法。实在不行,用曹大的 holmes 试试,这个工具还是很厉害的,还帮曹大贡献了个 golang 的 mr。

平时要多看相关的事故排查文章,必要的时候练习一下 pprof 工具的使用,关键的时候还是能顶用的。

July 19, 2021 03:28 PM

July 15, 2021

qcrao 的博客

曹大带我学 Go(7)—— 如何优雅地指定配置项

最近一个年久失修的库导致了线上事故,不得不去做一些改进。

这个陈年库的作用是调用第三方的 RPC 拿一些比较重要的配置,业务代码中有段逻辑会根据读到的配置调用不同端的下游。如果没拿到配置,就会默认地调一个兜底下游。恰好这个兜底下游最近新上了一些逻辑,不兼容这种跨端调用,直接把它打挂了。

先抛开这个下游不健壮不谈,假设它是健壮的。

陈年库的问题在于:进程启动时它会去调一个下游拿数据,之后会定时更新。但如果启动时调用失败就直接 panic 了,所以之后也不会定时更新。理论上这个也没什么问题,服务在初始化时如果检测到了库的 panic,进程退出,重启就好了。

但是阻塞启动是比较危险的,所以有些服务就会吞掉 panic。于是,整个进程生命周期内这个配置就一直是缺失的状态。

因为阻塞服务的启动风险太高,所以当前的状态是把 panic recover 住了,但是之后这个配置也就一直没有更新的机会了。而陈年库其实是可以在后台静默更新数据的。

因此我要对陈年库要做一点改进:如果初始化时拉取配置失败,不 panic,后台静默修复。这个设置要在调用 Init 函数时设置,因为库就暴露了 Init 和 Get 函数。

但因为这个库有很多使用方,所以不可能更改函数签名和现在的行为,否则影响其他人使用。万一有业务都对这个是强依赖,就是要感知 panic,初始化失败就进程退出,你改了不就 gg 了。

我们知道,Go 语言里面有可变参数,调用它的时候可以不传实参,或者传多个实参。向陈年库函数的 Init 函数签名后加一个可变参数:

1func Init(a int)

变成:

1func Init(a int, opts ...optionFunc)

这样就不影响已有的用户了,并且我可以增加更多的设置项。这里的关键是 optionFunc 的实现原理是什么?

它其实是一个函数类型,它接受 options 结构体指针:

1type optionFunc func(*options)

再定义一个 options 结构体用于放 bool 型变量 PanicWhenInitFail,表示 Init 失败后是否 panic:

1type options struct {
2	PanicWhenInitFail bool
3}

再来定义一个导出的函数,用户传入 bool 型变量就可以设置 options,而不用定义 options 对象。这种方法美妙的地方就在这里,要多次回味才能感受到:

1func WithPanicWhenInitFail() optionFunc {
2	return func(o *options) {
3		o.PanicWhenInitFail = true
4	}
5}

初始时,Init 函数的实现如下:

1func Init(a int) {
2	fmt.Println(a)
3}

修改后:

 1func Init(a int, opts ...optionFunc) {
 2	fmt.Println(a)
 3
 4	var gOpt = &options{PanicWhenInitFail: false}
 5
 6	for _, opt := range opts {
 7		opt(gOpt)
 8	}
 9
10	fmt.Println(gOpt)
11
12}

这样,main 函数就可以非常优雅地设置 PanicWhenInitFail 了:

1func main() {
2	Init(8)
3	Init(8, WithPanicWhenInitFail())
4}

不管加不加后面的配置,两种调用方式都可以编译成功,不会影响现有的用户,完美。

为什么这篇文章和曹大扯上关系,因为在曹大写的 mosn/homels 这个库里也有类似的代码。当然,本文这种形式很常见,可以算作标配了。不过,有一点点不同之处,曹大定义了一个 interface,不过看起来感觉有点更难懂了。😇

 1// Option holmes option type.
 2type Option interface {
 3	apply(*options) error
 4}
 5
 6type optionFunc func(*options) error
 7
 8func (f optionFunc) apply(opts *options) error {
 9	return f(opts)
10}

去 Google 上一查,其实这种形式,叫 Functional Options Pattern,早在 2014 年 Rob Pike 就写过一篇博文来说这个事,没几行代码,但是真的很优雅。

总结一下,当我们要修改已有的函数时,为了不破坏原有的签名和行为,可以使用 Functional Options Pattern 的形式增加可变参数,即可以增加设置项,又能兼容已有的代码。

July 15, 2021 03:28 PM

July 11, 2021

李文周的博客

Go结构体的内存布局

本文介绍了Go语言结构体的内存对齐现象和对齐策略,并通过一些具体示例介绍了Go语言中结构体内存布局的特殊场景。

July 11, 2021 12:35 PM

June 09, 2021

qcrao 的博客

曹大带我学 Go(6)—— 技术之外

这篇文章主要来讲一下怎么做动画。

其实只要掌握几个核心的要点,就可以学会怎么用 Figma 做动画了。

我们想一下小时候看的那种胶片电影:

胶片电影

每一张胶片上的影像都是静止的,但是当胶片连续滚动时,静止的图片就变成了连续的视频。

或者想像一下小时候我们看的那种武打的小人书,连着翻页,就能看到一个连续的打斗场景,非常神奇!

用 Figma 做动画呢,也是类似的原理。

我们可以创建一组画布,在不同的画布之间,相同名字图形的变化(大小、透明度、颜色、旋转等),通过 smart animate 就可以自动“脑补”出动画。

Figma 画布

这组画布连起来,就形成了动画。

原理就是这么简单,具体怎么做,大家看个视频教程就全会了。

如果做一个比较复杂的动画,涉及到很多的图形,就比较复杂了。我自己想到的一个方法是,先画出一个全貌作为“母画布”,然后再构建每一张子画布,这时就像做减法一样。因为单张画布,其实都是这个“母画布”的子集。

最终呈现的效果是这样的:

动画截图

视频地址在这里

讲的就是之前的文章《迷惑的 goroutine 执行顺序》,这次用动画的形式展现了,是不是非常精彩?

动画可以更直观地展示原理,在一些技术分享的场合还是很有用的。尤其是很多人都还停留在满篇的文字,或者“装逼”一张幻灯片就一个关键字、对着讲 5 分钟、有几张图就算不错了的情况下,这时你啪来一个动画,你就是全场最靓的仔~

June 09, 2021 04:10 PM

June 07, 2021

qcrao 的博客

曹大带我学 Go(5)—— 哪来里的 goexit?

有同学在用 dlv 调试时看到了令人不解的 goexit:goexit 函数是啥,为啥 go fun(){}() 的上层是它?看着像是一个“退出”函数,为什么会出现在最上层?

其实如果看过 pprof 的火焰图,也会经常看到 goexit 这个函数。

我们来个例子重现一下:

 1package main
 2
 3import "time"
 4
 5func main() {
 6	go func ()  {
 7		println("hello world")
 8	}()
 9	
10	time.Sleep(10*time.Minute)
11}

启动 dlv 调试,并分别在不同的地方打上断点:

1(dlv) b a.go:5 
2Breakpoint 1 (enabled) set at 0x106d12f for main.main() ./a.go:5
3(dlv) b a.go:6
4Breakpoint 2 (enabled) set at 0x106d13d for main.main() ./a.go:6
5(dlv) b a.go:7
6Breakpoint 3 (enabled) set at 0x106d1a0 for main.main.func1() ./a.go:7

执行命令 c 运行到断点处,再执行 bt 命令得到 main 函数的调用栈:

1(dlv) bt
20  0x000000000106d12f in main.main
3   at ./a.go:5
41  0x0000000001035c0f in runtime.main
5   at /usr/local/go/src/runtime/proc.go:204
62  0x0000000001064961 in runtime.goexit
7   at /usr/local/go/src/runtime/asm_amd64.s:1374

它的上一层是 runtime.main,找到原代码位置,位于 src/runtime/proc.go 里的 main 函数,它是 Go 进程的 main goroutine,这里会执行一些 init 操作、开启 GC、执行用户 main 函数……

1fn := main_main // proc.go:203
2fn() // proc.go:204

其中 fnmain_main 函数,表示用户的 main 函数,执行到了这里,才真正将权力交给用户。

继续执行 c 命令和 bt 命令,得到 go 这一行的调用栈:

10  0x000000000106d13d in main.main
2   at ./a.go:6
31  0x0000000001035c0f in runtime.main
4   at /usr/local/go/src/runtime/proc.go:204
52  0x0000000001064961 in runtime.goexit
6   at /usr/local/go/src/runtime/asm_amd64.s:1374

以及 println 这一句的调用栈:

10  0x000000000106d1a0 in main.main.func1
2   at ./a.go:7
31  0x0000000001064961 in runtime.goexit
4   at /usr/local/go/src/runtime/asm_amd64.s:1374

可以看到,调用栈的最上层都是 runtime.goexit,我们跟着注明了的代码行数,顺藤摸瓜,找到 goexit 代码:

1// The top-most function running on a goroutine
2// returns to goexit+PCQuantum.
3TEXT runtime·goexit(SB),NOSPLIT,$0-0
4    BYTE	$0x90	// NOP
5	CALL	runtime·goexit1(SB)	// does not return
6	// traceback from goexit1 must hit code range of goexit
7	BYTE	$0x90	// NOP

这还是个汇编函数,它接着调用 goexit1 函数、goexit0 函数,主要的功能就是将 goroutine 的各个字段清零,放入 gFree 队列里,等待将来进行复用。

另一方面,goexit 函数的地址是在创建 goroutine 的过程中,塞到栈上的。让 CPU “误以为”:func() 是由 goexit 函数调用的。这样一来,当 func() 执行完毕时,会返回到 goexit 函数做一些清理工作。

下面这张图能看出在 newg 的栈底塞了一个 goexit 函数的地址:

goexit 返回地址

对应的路径是:

1newporc -> newporc1 -> gostartcallfn -> gostartcall

来看 newproc1 中的关键几行代码:

1newg.sched.pc = funcPC(goexit) + sys.PCQuantum
2newg.sched.g = guintptr(unsafe.Pointer(newg))
3gostartcallfn(&newg.sched, fn)

这里的 newg 就是创建的 goroutine,每个新建的 goroutine 都会执行这些代码。而 sched 结构体其实保存的是 goroutine 的执行现场,每当 goroutine 被调离 CPU,它的执行进度就是保存到这里。进度主要就是 SP、BP、PC,分别表示栈顶地址、栈底地址、指令位置,等 goroutine 再次得到 CPU 的执行权时,会把 SP、BP、PC 加载到寄存器中,从而从断点处恢复运行。

回到上面的几行代码,pc 被赋值成了 funcPC(goexit),最后在 gostartcall 里:

 1// adjust Gobuf as if it executed a call to fn with context ctxt
 2// and then did an immediate gosave.
 3func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
 4	sp := buf.sp
 5	...
 6	sp -= sys.PtrSize
 7	*(*uintptr)(unsafe.Pointer(sp)) = buf.pc
 8	buf.sp = sp
 9	buf.pc = uintptr(fn)
10	buf.ctxt = ctxt
11}

sp 其实就是栈顶,第 7 行代码把 buf.pc,也就是 goexit 的地址,放在了栈顶的地方,熟悉 Go 函数调用规约的朋友知道,这个位置其实就是 return addr,将来等 func() 执行完,就会回到父函数继续执行,这里的父函数其实就是 goexit

一切早已注定。

不过注意一点,main goroutine 和普通的 goroutine 不同的是,前者执行完用户 main 函数后,会直接执行 exit 调用,整个进程退出:

exit

也就不会进入 goexit 函数。而普通 goroutine 执行完毕后,则直接进入 goexit 函数,做一些清理工作。

这也就是为什么只要 main goroutine 执行完了,就不会等其他 goroutine,直接退出。一切都是因为 exit 这个调用。

今天我们主要讲了 goexit 是怎么被安插到 goroutine 的栈上,从而实现 goroutine 执行完毕后再回到 goexit 函数。

原来看似很不理解的东西,是不是更清晰了?

源码面前,了无秘密。

June 07, 2021 03:26 PM

June 01, 2021

qcrao 的博客

曹大带我学 Go(4)—— 初始 ast 的威力

抽象语法树是编译过程中的一个中间产物,一般简单了解一下就行了。但我们可以把 Go 语言的整个 parser 和 ast 包直接拿来用,在一些场景下有很大的威力。

什么是 ast 呢,我从维基百科上摘录了一段:

在计算机科学中,抽象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

核心就是说 ast 能以一种树的形式表示代码结构。有了树结构,就可以对它做遍历,能干很多事。

假定一个场景

假定一个场景:我们可以从司机平台的某个接口获取司机的各种特征,例如:年龄、订单数、收入、每天驾驶时长、驾龄、平均车速、被投诉次数……数据一般采用 json 来传递。

司机平台的运营小姐姐经常需要搞一些活动,例如选出:

  • 订单数超过 10000,且驾龄超过 5 年的老司机
  • 每天驾驶时小于 3 小时,且收入超过 500 的高效司机
  • 年龄大于 40,且平均速度大于 70 的“狂野”司机
  • ……

这些规则并不是固定的,经常在变化,但总归是各种司机特征的组合。

为了简化,我们选取 2 个特征,并用一个 Driver 结构体来表示:

1type Driver struct {
2	Orders         int
3	DrivingYears   int
4}

为了配合运营搞活动,我们需要根据运营给的规则来判断一个司机是否符合要求。

如果公司人多,可以安排一个 rd 专门伺候运营小姐姐,每次做活动都来手动修改代码,也不是不可以。并且其实挺简单,我们来写一个示例代码:

 1// 从第三方获取司机特征,json 表示
 2func getDriverRemote() []byte {
 3	return []byte(`{"orders":100000,"driving_years":18}`)
 4}
 5
 6// 判断是否为老司机
 7func isOldDriver(d *Driver) bool {
 8	if d.Orders > 10000 && d.DrivingYears > 5 {
 9		return true
10	}
11	return false
12}
13
14func main() {
15	bs := getDriverRemote()
16	var d Driver
17	json.Unmarshal(bs, &d)
18	fmt.Println(isOldDriver(&d))
19}

直接来看 main 函数:getDriverRemote 模拟从第三方 RPC 获取一个司机的特征数据,用 json 表示。接着 json.Unmarshal 来反序列化 Driver 结构体。最后调用 isOldDriver 函数来判断此司机是否符合运营的规则。

isOldDriver 根据 Driver 结构体的 2 个字段使用 if 语句来判断此司机是否为老司机。

确实还挺简单。

但是每次更新规则还得经过一次完整的上线流程,也挺麻烦的。有没有更简单的办法呢?使得我们可以直接解析运营小组姐给我们的一个用字符串表示的规则,并直接返回一个 bool 型的值,表示是否满足条件。

有的!

接下来就是本文的核心内容,如何使用 ast 来完成同样的功能。

直观地理解如何用 ast 解析规则

使用 ast 包提供的一些函数,我们可以非常方便地将如下的规则字符串:

1orders > 10000 && driving_years > 5

解析成一棵这样的二叉树:

规则二叉树

其中,ast.BinaryExpr 代表一个二元表达式,它由 X 和 Y 以及符号 OP 三部分组成。最上面的一个 BinaryExpr 表示规则的左半部分和右半部分相与。

很明显,左半部分就是:orders > 10000,而右半部分则是:driving_years > 5。神奇的是,左半部分和右半部分恰好又都是一个二元表达式。

左半部分的 orders > 10000 其实也是最小的叶子节点,它可以算出来一个 bool 值。把它拆开来之后,又可以分成 X、Y、OP。X 是 orders,OP 是 “>",Y 则是 “10000”。其中 X 表示一个标识符,是 ast.Ident 类型,Y 表示一个基本类型的字面量,例如 int 型、字符串型……是 ast.BasicLit 类型。

右半部分的 driving_years > 18 也可以照此拆分。

然后,从 json 中取出这个司机的 orders 字段的值为 100000,它比 10000 大,所以左半部分算出来为 true。同理,右半部分算出来也为 true。最后,再算最外层的 “&&",结果仍然为 true。

至此,直接根据规则字符串,我们就可以算出来结果。

如果写成程序的话,就是一个 dfs 的遍历过程。如果不是叶子结点,那就是二元表达式结点,那就一定有 X、Y、OP 部分。递归地遍历 X,如果 X 是叶子结点,那就结束递归,并计算出 X 的值……

这里再展示一个用 ast 包打印出来的抽象语法树:

Go 打印 ast

上图中,1、2、3 表示最外层的二元表达式;4、5、6 则表示左边这个二元表达式。

结合这张图,再参考 ast 包的相关结构体 代码,就非常清晰了。例如 ast.BinaryExpr 的代码如下:

1// A BinaryExpr node represents a binary expression.
2BinaryExpr struct {
3	X     Expr        // left operand
4	OpPos token.Pos   // position of Op
5	Op    token.Token // operator
6	Y     Expr        // right operand
7}

它有 X、Y、OP,甚至还解析出了 Op 的位置,用 OpPos 表示。

如果你还对实现感兴趣,那就继续看下面的原理分析部分,否则可以直接跳到结尾总结部分。

原理分析

还是用上面那个例子,我们直接写一个表达式:

1orders > 10000 && driving_years > 5

接下来用 ast 来解析规则并判断真假。

1func main() {
2	m := map[string]int64{"orders": 100000, "driving_years": 18}
3	rule := `orders > 10000 && driving_years > 5`
4	fmt.Println(Eval(m, rule))
5}

为了简单,我们直接用 map 来代替 json,道理是一样的,仅仅为了方便。

Eval 函数判断 rule 的真假:

 1// Eval : 计算 expr 的值
 2func Eval(m map[string]int64, expr string) (bool, error) {
 3	exprAst, err := parser.ParseExpr(expr)
 4	if err != nil {
 5		return false, err
 6	}
 7
 8	// 打印 ast
 9	fset := token.NewFileSet()
10	ast.Print(fset, exprAst)
11
12	return judge(exprAst, m), nil
13}

先将表达式解析成 Expr,接着调用 judge 函数计算结果:

 1// dfs
 2func judge(bop ast.Node, m map[string]int64) bool {
 3    // 叶子结点
 4	if isLeaf(bop) {
 5		// 断言成二元表达式
 6		expr := bop.(*ast.BinaryExpr)
 7		x := expr.X.(*ast.Ident) // 左边
 8		y := expr.Y.(*ast.BasicLit) // 右边
 9
10		// 如果是 ">" 符号
11		if expr.Op == token.GTR {
12			left := m[x.Name]
13			right, _ := strconv.ParseInt(y.Value, 10, 64)
14			return left > right
15		}
16		return false
17	}
18
19	// 不是叶子节点那么一定是 binary expression(我们目前只处理二元表达式)
20	expr, ok := bop.(*ast.BinaryExpr)
21	if !ok {
22		println("this cannot be true")
23		return false
24	}
25
26	// 递归地计算左节点和右节点的值
27	switch expr.Op {
28	case token.LAND:
29		return judge(expr.X, m) && judge(expr.Y, m)
30	case token.LOR:
31		return judge(expr.X, m) || judge(expr.Y, m)
32	}
33
34	println("unsupported operator")
35	return false
36}

judge 使用 dfs 递归地计算表达式的值。

递归地终止条件是叶子节点:

 1// 判断是否是叶子节点
 2func isLeaf(bop ast.Node) bool {
 3	expr, ok := bop.(*ast.BinaryExpr)
 4	if !ok {
 5		return false
 6	}
 7
 8	// 二元表达式的最小单位,左节点是标识符,右节点是值
 9	_, okL := expr.X.(*ast.Ident)
10	_, okR := expr.Y.(*ast.BasicLit)
11	if okL && okR {
12		return true
13	}
14
15	return false
16}

总结

今天这篇文章主要讲了如何用 ast 包和 parser 包解析一个二元表达式,并见识到了它的威力,利用它可以做成一个非常简单的规则引擎。

其实利用 ast 包还可以做更多有意思的事情。例如批量把 thrift 文件转化成 proto 文件、解析 sql 语句并做一些审计……

想要更深入的学习,可以看曹大这篇《golang 和 ast》,据曹大自己说,他可以在 30 分钟内完成一个项目的一个 api 的编写,非常霸气!不服喷他……

June 01, 2021 03:26 PM

May 27, 2021

qcrao 的博客

曹大带我学 Go(3)—— 如何用汇编打同事的脸

今天介绍几个常用的查看 Go 汇编代码、调试 Go 程序的命令和工具,既可以在平时和同事、网友抬杠时使用,还能在关键时刻打他们的脸。

比如,有同事说这段代码:

 1package main
 2
 3type Student struct {
 4	Class int
 5}
 6
 7func main() {
 8	var a = &Student{1}
 9	println(a)
10}

的执行效率要高于下面这段代码:

 1package main
 2
 3type Student struct {
 4	Class int
 5}
 6
 7func main() {
 8	var a = Student{1}
 9	var b = &a
10	println(b)
11}

并且给你讲了一通道理,你好像没法辩赢他。怎么办?

直接用一行命令生成汇编代码,马上可以戳穿他,打他的脸。

go tool 生成汇编

其实很简单,有两个命令可以做到:

1go tool compile -S main.go

和:

1go build main.go && go tool objdump ./main

前者是编译,即将源代码编译成 .o 目标文件,并输出汇编代码。

后者是反汇编,即从可执行文件反编译成汇编,所以要先用 go build 命令编译出可执行文件。

二者不尽相同,但都能看到前面两个示例代码对应的汇编代码是一致的。同事的“谣言”不攻自破,脸都被你打疼了。

找到 runtime 源码

Go 是一门有 runtime 的语言,什么是 runtime?其实就是一段辅助程序,用户没有写的代码,runtime 替我们写了,比如 Go 调度器的代码。

我们只需要知道用 go 关键字创建 goroutine,就可以疯狂堆业务了。至于 goroutine 是怎么被调度的,根本不需要关心,这些是 runtime 调度器的工作。

那我们自己写的代码如何和 runtime 里的代码对应起来呢?

前面介绍的方法就可以做到,只需要加一个 grep 就可以。

例如,我想知道 go 关键字对应 runtime 里的哪个函数,于是写了一段测试代码:

1package main
2
3func main() {
4	go func() {
5		println(1+2)
6	}()
7}

因为 go func(){}() 那一行代码在第 4 行,所以,grep 的时候加一个条件:

1go tool compile -S main.go | grep "main.go:4"
2
3// 或
4
5go build main.go && go tool objdump ./main | grep "main.go:4"

go func

马上就能看到 go func(){}() 对应 newproc() 函数,这时再深入研究下 newproc() 函数就大概知道 goroutine 是如何被创建的。

用 dlv 调试

那有同学问了,有没有其他可以调试 Go、以及和 Go 程序互动的方法呢?其实是有的!这就是我们要介绍的 dlv 调试工具,目前它对调试 Go 的程序支持是最好的。

之前没我怎么研究它,只会一些非常简单的命令,这次学会了几个进阶的指令,威力挺大,也进一步加深了对 Go 的理解。

下面我们带着一个任务来讲解 dlv 如何使用。

我们知道,向一个 nil 的 slice append 元素,不会有任何问题。但是向一个 nil 的 map 插入新元素,马上就会报 panic。这是为什么呢?又是在哪 panic 呢?

首先写出让 map 产生 panic 的示例程序:

1package main
2
3func main() {
4	var m map[int]int
5	m[1] = 1
6}

接着用 go build 命令编译生成可执行文件:

1go build a.go

然后,使用 dlv 进入调试状态:

1dlv exec ./a

使用 b 这个命令打断点,有三种方法:

  1. b + 地址
  2. b + 代码行数
  3. b + 函数名

我们要在对 map 赋值的地方加个断点。先找到代码位置:

1cat -n a.go

看到:

hello.go

赋值的地方在第 5 行,加断点:

1(dlv) b a.go:5
2Breakpoint 1 set at 0x45e55d for main.main() ./a.go:5

执行 c 命令,直接运行到断点处:

运行到断点处

执行 disass 命令,可以看到汇编指令:

disass

这时使用 si 命令,执行单条指令,多次执行 si,就会执行到 map 赋值函数 mapassign_fast64

mapassign_fast64

这时再用单步命令 s,就会进入判断 h 的值为 nil 的分支,然后执行 panic 函数:

panic

至此,向 nil 的 map 赋值时,产生 panic 的代码就被我们找到了。接着,按图索骥找到对应 runtime 源码的位置,就可以进一步探索了。

除此之外,我们还可以使用 bt 命令看到调用栈:

调用栈

使用 frame 1 命令可以跳转到相应位置。这里 1 对应图中的 a.go:5,也就是我们前面打断点的地方,是不是非常酷炫。

上面这张图里我们也能清楚地看到,用户 goroutine 其实是被 goexit 函数一路调用过来的。当用户 goroutine 执行完毕后,就会回到 goexit 函数做一些收尾工作。当然,这是题外话了。

另外,用 dlv 也能干第二部分“找到 runtime 源码”的活。

总结

今天系统地讲了几招通过命令和工具查看用户代码对应的 runtime 源码或者汇编代码的方法,非常实用。最后再汇总一下:

  1. go tool compile
  2. go tool objdump
  3. dlv

使用这些命令和工具,可以让你在看 Go 源码的过程中事半功倍。

May 27, 2021 03:21 PM

May 21, 2021

qcrao 的博客

曹大带我学 Go(2)—— 迷惑的 goroutine 执行顺序

上一篇文章我们讲了 Go 调度的本质是一个生产-消费流程。

生产端是正在运行的 goroutine 执行 go func(){}() 语句生产出 goroutine 并塞到三级队列中去。

消费端则是 Go 进程中的 m 在不断地执行调度循环,从三级队列中拿到 goroutine 来运行。

生产-消费过程

今天我们来通过 2 个实际的代码例子来看看 goroutine 的执行顺序是怎样的。

第一个例子

首先来看第一个例子:

 1package main
 2
 3import (
 4	"fmt"
 5	"runtime"
 6	"time"
 7)
 8
 9func main() {
10    runtime.GOMAXPROCS(1)
11    for i := 0; i < 10; i++ {
12        i := i
13        go func() {
14            fmt.Println(i)
15        }()
16    }
17
18    var ch = make(chan int)
19    <- ch
20}

首先通过 runtime.GOMAXPROCS(1) 设置只有一个 P,接着创建了 10 个 goroutine,并分别打印出 i 值。你可以先想一下输出会是什么,再对着答案会有更深入的理解。

揭晓答案:

 19
 20
 31
 42
 53
 64
 75
 86
 97
108
11fatal error: all goroutines are asleep - deadlock!
12
13goroutine 1 [chan receive]:
14main.main()
15        /home/raoquancheng/go/src/hello/main.go:16 +0x96
16exit status 2

程序输出的 fatal error 是因为 main goroutine 正在从一个 channel 里读数据,而这时所有的 channel 都已经挂了,因此出现死锁。这里先忽略这个,只需要关注 i 输出的顺序:9, 0, 1, 2, 3, 4, 5, 6, 7, 8

我来解释一下原因:因为一开始就设置了只有一个 P,所以 for 循环里面“生产”出来的 goroutine 都会进入到 P 的 runnext 和本地队列,而不会涉及到全局队列。

每次生产出来的 goroutine 都会第一时间塞到 runnext,而 i 从 1 开始,runnext 已经有 goroutine 在了,所以这时会把 old goroutine 移到 P 的本队队列中去,再把 new goroutine 放到 runnext。之后会重复这个过程……

因此这后当一次 i 为 9 时,新 goroutine 被塞到 runnext,其余 goroutine 都在本地队列。

之后,main goroutine 执行了一个读 channel 的语句,这是一个好的调度时机:main goroutine 挂起,运行 P 的 runnext 和本地可运行队列里的 gorotuine。

而我们又知道,runnext 里的 goroutine 的执行优先级是最高的,因此会先打印出 9,接着再执行本地队列中的 goroutine 时,按照先进先出的顺序打印:0, 1, 2, 3, 4, 5, 6, 7, 8

是不是非常有意思?

第二个例子

别急,我们再来看第 2 个例子:

 1package main
 2
 3import (
 4	"fmt"
 5	"runtime"
 6	"time"
 7)
 8
 9func main() {
10    runtime.GOMAXPROCS(1)
11    for i := 0; i < 10; i++ {
12        i := i
13        go func() {
14            fmt.Println(i)
15        }()
16    }
17
18    time.Sleep(time.Hour)
19}

和第一个例子的不同之处是我们把读 channel 的代码换成 Sleep 操作。这一次,你还能正确回答 i 的输出顺序是什么吗?

我们直接揭晓答案。

当我们用 go1.13 运行时:

 1$ go1.13.8 run main.go
 2
 30
 41
 52
 63
 74
 85
 96
107
118

而当我们用 go1.14 及之后的版本运行时:

 1$ go1.14 run main.go
 2
 39
 40
 51
 62
 73
 84
 95
106
117
128

可以看到,用 go1.14 及之后的版本运行时,输出顺序和之前的一致。而用 go1.13 运行时,却先输出了 0,这又是什么原因呢?

这就要从 Go 1.14 修改了 timer 的实现开始说起了。

go 1.13 的 time 包会生产一个名字叫 timerproc 的 goroutine 出来,它专门用于唤醒挂在 timer 上的时间未到期的 goroutine;因此这个 goroutine 会把 runnext 上的 goroutine 挤出去。因此输出顺序就是:0, 1, 2, 3, 4, 5, 6, 7, 8, 9

go 1.14 把这个唤醒的 goroutine 干掉了,取而代之的是,在调度循环的各个地方、sysmon 里都是唤醒 timer 的代码,timer 的唤醒更及时了,但代码也更难看懂了。所以,输出顺序和第一个例子是一致的。

总结

今天通过 2 个实际的例子再次复习了 Go 调度消费端的流程,也学到了 time 包在不同 go 版本下的不同之处以及它对程序输出造成的影响。

有些人还会把例子中的 10 改成比 256 更大的数去尝试。曹大说这是考眼力,不要给自己找事。因为这时 P 的本地队列装不下这么多 goroutine 了,只能放到全局队列。这下程序的输出顺序就不那么直观了。

所以,记住本文的核心内容就行了:

  1. runnext 的优先级最高。
  2. time.Sleep 在老版本中会创建一个 goroutine,在 1.14(包含)之后不会创建 goroutine 了。

如果被别人考到,知道三级队列,以及 time 包在 1.14 的变更就行了。

May 21, 2021 06:38 AM

May 20, 2021

qcrao 的博客

曹大带我学 Go(1)—— Go 调度的本质

首先抛出本文的结论:Go 调度的本质是一个生产-消费流程。

生产者-消费者

生产者-消费者模型

我们平时用 Go 最爽的一点莫过于用一句 go func(){}() 就启动了一个 goroutine 来并发地执行任务。这比用 C/C++ 启动一个线程并发地去执行任务方便太多。这句代码实际上就生产出了一个 goroutine,并进入可运行队列,等待 m 来找它从而可以得到运行。

熟悉 GMP 模型的朋友都知道,goroutine 最终在 m 上得以执行,因为操作系统感知不到 goroutine,它只能感知线程,并且线程可以看成是 m。

所以,m 拿到 goroutine 并运行它的过程就是一个消费过程。

生产-消费过程

生产过程——三级队列

生产出的 goroutine 需要找一个地方存放,这个地方就是可运行队列。在 Go 程序中,可运行队列是分级的,分为三级:

三级可运行队列

runnext 实际上只能指向一个 goroutine,所以它是一个特殊的队列。

那把 goroutine 放到哪个可运行队列呢?看情况。

首先,如果 runnext 为空,那么 goroutine 就会顺利地放入 runnext,接下来,它会以最高优先级得到运行,即优先被消费。

如果 runnext 不为空,那就先负责把 runnext 上的 old goroutine 踢走,再把 new goroutine 放上来。具体踢到哪里呢?又得分情况。

local queue 是一个大小为 256 的数组,实际上用 head 和 tail 指针把它当成一个环形数组在使用。如果 local queue 不满,则将 runnext 放入 local queue;否则,P 的本地队列上的 goroutine 太多了,说明当前 P 的任务太重了,需要减负,因此需要得到其他 P 协助。从而,将 runnext 以及当前 P 的一半 goroutine 一起打包丢到 global queue 里去。

当然,这部分课程里有非常生动的动画,这里贴一个截图大家感受一下:

生产者动画

消费过程——调度循环

之前的文章里也讲到过调度循环是咋回事,它实际上就是 Go 程序在启动的时候,会创建和 CPU 核心数相等个数的 P,会创建初始的 m,称为 m0。这个 m0 会启动一个调度循环:不断地找 g,执行,再找 g……

伪代码是这样的:

调度循环

随着程序的运行,m 更多地被创建出来,因此会有更多的调度循环在执行。

那边生产者在不断地生产 g,这边 m 的调度循环不断地在消费 g,整个过程就 run 起来了。

找 g 的过程中当然也是从上面的三级队列里找:

先看 runnext,再看 local queue,再看 global queue。当然,如果实在找不到,就去其他 p 去偷。

总结

今天的文章只用记住一个观点:Go 调度的本质是一个生产-消费流程。这个观点非常新颖,之前我没有从哪篇文章看到过,这是曹大自己的感悟。

读者即使之前没见过类似的说法,但是一旦听曹大讲出来,就马上感觉醍醐灌顶。

这种熟悉加意外的效果其实就是你成长的时机。

May 20, 2021 12:13 AM

May 12, 2021

qcrao 的博客

深度解密 Go 语言之基于信号的抢占式调度

不知道大家在实际工作中有没有遇到过老版本 Go 调度器的坑:死循环导致程序“死机”。我去年就遇到过,并且搞出了一起 P0 事故,还写了篇弱智的找 bug 文章

识别事故的本质,并且用一个非常简单的示例展示出来,是功力的一种体现。那次事故的原因可以简化成如下的 demo:

demo-1

我来简单解释一下上面这个程序。在主 goroutine 里,先用 GoMAXPROCS 函数拿到 CPU 的逻辑核心数 threads。这意味着 Go 进程会创建 threads 个数的 P。接着,启动了 threads 个数的 goroutine,每个 goroutine 都在执行一个无限循环,并且这个无限循环只是简单地执行 x++

接着,主 goroutine sleep 了 1 秒钟;最后,打印 x 的值。

你可以自己思考一下,输出会是什么?

如果你想出了答案,接着再看下面这个 demo:

demo-2

我也来解释一下,在主 goroutine 里,只启动了一个 goroutine(虽然程序里用了一个 for 循环,但其实只循环了一次,完全是为了和前面的 demo 看起来更协调一些),同样执行了一个 x++ 的无限 for 循环。

和前一个 demo 的不同点在于,在主 goroutine 里,我们手动执行了一次 GC;最后,打印 x 的值。

如果你能答对第一题,大概率也能答对第二题。

下面我就来揭晓答案。

其实我留了一个坑,我没说用哪个版本的 Go 来运行代码。所以,正确的答案是:

Go 版本 demo-1 demo-2
1.13 卡死 卡死
1.14 0 0

这个其实就是 Go 调度器的坑了。

假设在 demo-1 中,共有 4 个 P,于是创建了 4 个 goroutine。当主 goroutine 执行 sleep 的时候,刚刚创建的 4 个 goroutine 马上就把 4 个 P 霸占了,执行死循环,而且竟然没有进行函数调用,就只有一个简单的赋值语句。Go 1.13 对这种情况是无能为力的,没有任何办法让这些 goroutine 停下来,进程对外表现出“死机”。

demo-1 示意图

由于 Go 1.14 实现了基于信号的抢占式调度,这些执行无限循环的 goroutine 会被调度器“拿下”,P 就会空出来。所以当主 goroutine sleep 时间到了之后,马上就能获得 P,并得以打印出 x 的值。至于 x 为什么输出的是 0,不太好解释,因为这是一种未定义(有数据竞争,正常情况下要加锁)的行为,可能的一个原因是 CPU 的 cache 没有来得及更新,不过不太好验证。

理解了这个 demo,第二个 demo 其实是类似的道理:

demo-2 示意图

当主 goroutine 主动触发 GC 时,需要把所有当前正在运行的 goroutine 停止下来,即 stw(stop the world),但是 goroutine 正在执行无限循环,没法让它停下来。当然,Go 1.14 还是可以抢占掉这个 goroutine,从而打印出 x 的值,也是 0。

Go 1.14 之前的版本,能否抢占一个正在执行死循环的 goroutine 其实是有讲究的:

能否被抢占,不是看有没有调用函数,而是看函数的序言部分有没有插入扩栈检测指令。

如果没有调用函数,肯定不会被抢占。

有些虽然也调用了函数,但其实不会插入检测指令,这个时候也不会被抢占。

像前面的两个 demo,不可能有机会在函数扩栈检测期间主动放弃 CPU 使用权,从而完成抢占,因为没有函数调用。具体的过程后面有机会再写一篇文章详细讲,本文主要看基于信号的抢占式调度如何实现。

preemptone

一方面,Go 进程在启动的时候,会开启一个后台线程 sysmon,监控执行时间过长的 goroutine,进而发出抢占。另一方面,GC 执行 stw 时,会让所有的 goroutine 都停止,其实就是抢占。这两者都会调用 preemptone() 函数。

preemptone() 函数会沿着下面这条路径:

1preemptone->preemptM->signalM->tgkill

向正在运行的 goroutine 所绑定的的那个 M(也可以说是线程)发出 SIGURG 信号。

注册 sighandler

每个 M 在初始化的时候都会设置信号处理函数:

1initsig->setsig->sighandler

信号执行过程

我们从“宏观”层面看一下信号的执行过程:

信号执行过程

主程序(线程)正在“勤勤恳恳”地执行指令:它已经执行完了指令 m,接着就要执行指令 m+1 了……不幸在这个时候发生了,线程收到了一个信号,对应图中的

接着,内核会接管执行流,转而去执行预先设置好的信号处理器程序,对应到 Go 里,就是执行 sighandler,对应图中的

最后,执行流又交到线程手上,继续执行指令 m+1,对应图中的

这里其实涉及到了一些现场的保护和恢复,内核都帮我们搞定了,我们不用操心。

dosigPreempt

当线程收到 SIGURG 信号的时候,就会去执行 sighandler 函数,核心是 doSigPreempt 函数。

1func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
2    ...
3    
4    if sig == sigPreempt && debug.asyncpreemptoff == 0 {
5		doSigPreempt(gp, c)
6	}
7	
8	...
9}

doSigPreempt 这个函数其实很短,一会儿就执行完了。

1func doSigPreempt(gp *g, ctxt *sigctxt) {
2	...
3	if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
4		// Adjust the PC and inject a call to asyncPreempt.
5		ctxt.pushCall(funcPC(asyncPreempt), newpc)
6	}
7	...
8}

isAsyncSafePoint 函数会返回当前 goroutine 能否被抢占,以及从哪条指令开始抢占,返回的 newpc 表示安全的抢占地址。

接着,pushCall 调整了一下 SP,设置了几个寄存器的值就返回了。按理说,返回之后,就会接着执行指令 m+1 了,但那还怎么实现抢占呢?其实魔法都在 pushCall 这个函数里。

pushCall

在分析这个函数之前,我们需要先复习一下 Go 函数的调用规约,重点回顾一下 CALL 和 RET 指令就行了。

call 和 ret 指令

call 指令可以简单地理解为 push ip + JMP。这个 ip 其实就是返回地址,也就是调用完子函数接下来该执行啥指令的地址。所以 push ip 就是在 call 一个子函数之前,将返回地址压入栈中,然后 JMP 到子函数的地址执行。

ret 指令和 call 指令刚好相反,它将返回地址从栈上 pop 到 IP 寄存器,使得 CPU 从这个地址继续执行。

理解了 callret,我们再来分析 pushCall 函数:

1func (c *sigctxt) pushCall(targetPC, resumePC uintptr) {
2	// Make it look like we called target at resumePC.
3	sp := uintptr(c.rsp())
4	sp -= sys.PtrSize
5	*(*uintptr)(unsafe.Pointer(sp)) = resumePC
6	c.set_rsp(uint64(sp))
7	c.set_rip(uint64(targetPC))
8}

注意看这行注释:

1// Make it look like we called target at resumePC.

它清晰地说明了这个函数的作用:让 CPU 误以为是 resumePC 调用了 targetPC。而这个 resumePC 就是上一步调用 isAsyncSafePoint 函数返回的 newpc,它代表我们抢占 goroutine 的指令地址。

前两行代码将 SP 下移了 8 个字节,并且把 resumePC 入栈(注意,它其实是一个返回地址),接着把 targetPC 设置到 ip 寄存器,sp 设置到 SP 寄存器。这使得从内核返回到用户态执行时,不是从指令 m+1,而是直接从 targetPC 开始执行,等到 targetPC 执行完,才会返回到 resumePC 继续执行。整个过程就像是 resumePC 调用了 targetPC 一样。而 targetPC 其实就是 funcPC(asyncPreempt),也就是抢占函数。

于是我们可以看到,信号处理器程序 sighandler 只是将一个异步抢占函数给“安插”进来了,而真正的抢占过程则是在 asyncPreempt 函数中完成。

异步抢占

当执行完 sighandler,执行流再次回到线程。由于 sighandler 插入了一个 asyncPreempt 的函数调用,所以 goroutine 原本的任务就得不到推进,转而执行 asyncPreempt 去了:

asyncPreempt 调用链路

mcall(fn) 的作用是切到 g0 栈去执行函数 fn, fn 永不返回。在 mcall(gopreempt_m) 这里,fn 就是 gopreempt_m。

gopreempt_m 直接调用 goschedImpl

goschedImpl

dropg

最精彩的部分就在 goschedImpl 函数。它首先将 goroutine 的状态从 running 改成 runnable;接着调 dropg 将 g 和 m 解绑;然后调用 globrunqput 将 goroutine 丢到全局可运行队列,由于是全局可运行队列,所以需要加锁。最后,调用 schedule() 函数进入调度循环。关于调度循环,可以看这篇文章

运行 schedule 函数用的是 g0 栈,它会去寻找其他可运行的 goroutine,包括从当前 P 本地可运行队列获取、从全局可运行队列获取、从其他 P 偷等方式找到下一个可运行的 goroutine 并执行。

至此,这个线程就转而去执行其他的 goroutine,当前的 goroutine 也就被抢占了。

那被抢占的这个 goroutine 什么时候会再次得到执行呢?

因为它已经被丢到全局可运行队列了,所以它的优先级就会降低,得到调度的机会也就降低,但总还是有机会再次执行的,并且它会从调用 mcall 的下一条指令接着执行。

还记得 mcall 函数的作用吗?它会切到 g0 栈执行 gopreempt_m,自然它也会保存 goroutine 的执行进度,其实就是 SP、BP、PC 寄存器的值,当 goroutine 再次被调度执行时,就会从原来的执行流断点处继续执行下去。

总结

本文讲述了 Go 语言基于信号的异步抢占的全过程,一起来回顾下:

  1. M 注册一个 SIGURG 信号的处理函数:sighandler。
  2. sysmon 线程检测到执行时间过长的 goroutine、GC stw 时,会向相应的 M(或者说线程,每个线程对应一个 M)发送 SIGURG 信号。
  3. 收到信号后,内核执行 sighandler 函数,通过 pushCall 插入 asyncPreempt 函数调用。
  4. 回到当前 goroutine 执行 asyncPreempt 函数,通过 mcall 切到 g0 栈执行 gopreempt_m。
  5. 将当前 goroutine 插入到全局可运行队列,M 则继续寻找其他 goroutine 来运行。
  6. 被抢占的 goroutine 再次调度过来执行时,会继续原来的执行流。

May 12, 2021 03:12 PM