当编程变得廉价,程序员到底还剩下什么
技术革命带来了什么?又带走了什么? 最近从实习离职到秋招结束,开启了很长的一段空档期,这段时间写写论文、打打游戏、看看书。因为前司的 AI 工具之拉跨,导致与世隔绝了几个月。而最近一段时间的 Agent 使用之旅,让我有了一些感想。身为技术人员,Vibe Coding 到底是提高了程序员的能力,还是程序员需要提高自己的能力来迎接Vibe Coding呢? Vibe Coding 的爽感 从 GPT3 的 ChatBot 形式开始,到现在的 Curosr、Claude Code 以及 Kimi CLI 等形式,不得不说学习以及编程的门槛是真的在下降,可能再过一段时间,Agent 真是 is all you need 了。从在几年前我的剪切板里复制的是一段一段的代码,到现在复制的可能是一大段精心修改的 Prompt,防止因为自己的一个不小心而弄丢提示词。 我现在将我观察到的特点总结为以下内容: 搜索廉价: AI 代替了一部分浏览器的功能。在 GPT3 刚出的时候,常用的人都会发现,它有很严重的幻觉问题,当然,LLM 厂商也发现了这个问题。所以减少幻觉变成了一个研究点。不得不承认的是,虽然到现在为止幻觉问题还没有被完全攻破,但随着模型能调用一些 MCP 工具,或者使用 RAG 等进行增强等,模型吐出来的内容的可靠性不断增加,从而使得我们可以在 ChatBot 中查资料。而 AI 帮你搜索的好处是按照你的问题,AI 随搜索出答案,并且帮你总结聚合,变向增大了你的带宽。 学习成本降低: 搜索廉价为我带来的第一个益处就是学习成本降低。以往学习过程中,某些具有一定学习成本的技能,在 AI 时代这个壁垒被打破了。例如,当我需要实现一个组件来完成我的定制化需求的时候,可能这个组件只占我系统的 5% 甚至更少,而且在未来可能用到这技能的时间也很少。但因为壁垒,以往我可能需要花费大量的时间在这里,这可能也是一种 二八定律 吧。但是 AI 时代的到来,大量数据的投喂,使得 LLM 几乎不可能不知道你所知道的内容(除了近期他没训练到的数据),而这时,AI 完全有能力帮你减少开发成本。(当然不鼓励不学习新东西,而是这时候需要我们有一双慧眼) idea 廉价但仍有价值: 范式的突破往往不是固步自封得来的。为什么这么说呢?LLM 有一点就是他能关联不同领域的内容,因为其训练数据之庞大,几乎覆盖所有领域,所以在对话过程中,很容易从其他领域得到启发,进而构成 idea。很多时候,想要打破一个范式,只了解一个领域的知识是远远不够的,比如说扩散模型我没记错的话,是受热力学中的扩散过程启发而来。虽然模型大部分时候提出的 idea 都是不成功的,但是一千个中有一个能用的,一万个中有一个还不错的,一亿个中有一个闪亮的,是不是也能推动社会的发展? 细思极恐(*^*) 上面讲解了一我观察到的大模型的能力,而随着这几年不断用 AI,我也发现了一些值得反思的内容。 ...
深入浅出 Go-Map(下)
上篇已经聊过 1.24 前的 map,这篇让我们看看 1.24 之后的实现 —— 瑞士表(SwissTable) 1 引 在有一次面试的时候,面试官问了我一个问题,这个问题在我学习 SwissTable 的时候意识到是同一个问题。问题如下: 现在有一百万条数据要做处理,还有一个足够大的内存能放下这一百条数据,现在假设这个数据有两种组织方式,一种是链表,另一种是数组,请问哪一种处理起来效率会更高? 这个问题我当时是这么回答的(当然这里语言组织的比当时好很多^-^):数组处理效率更高,因为 cpu 把数据从内存读到寄存器的时候,不是一次拿一条数据,而是一次拿一块连续的内存放进 cache。此时如果是链表的情况,链表两个节点在内存空间中地址不连续,就会导致我一次拿的内存可能不会覆盖到下一个节点,那么拿下一个节点的时候,又需要访问,带来了额外开销。而数组不一样,连续的内存让他有了很好的空间局部性,拿一块内存,那下一次访问的数据也大概率会在 cache 中找到,减少了访存的次数,从而提高了性能。 1.1 拉链法的性能瓶颈 从上面的这个引子也就知道了,拉链法指针地址的离散型,是对 Cache 不友好的,按照现代计算机的设计来看,离 CPU 越远,获取数据的成本就越高。换言之,我们希望有一种方式能尽可能让 Hash 表中的数据紧密起来,让 CPU 能一次访存,多次复用。 回到解决 Hash 冲突问题本身,除了拉链法,还有一种就是线性探测法。线性探测法是在 Hash 碰撞的时候,向后找位置,找到第一个可以放下该元素的槽(slot),接着插入,如果直到链表查完都没有 slot 可用,就触发扩容。查询的时候,会从 Hash 的起始 slot 往后查,直到查到元素或者查到空 slot 或者查完整个表,结束查询。 线性探测法的链表会呈现出如下的形式。(忘记画slot的状态的,简单看看~) 可以看出来如果是线性探测法的 Hash 表,只需要一个数组就能承担责任,而数组是内存连续的地址,妥妥的cache friendly。而且实现起来也相对简单,每个位置只需要存储 kv 对以及当前 slot 的状态(是空还是已删除还是有数据)。固然缓存友好,但是线性探测法的缺点在哪里? 冲突的 Key 总归是要存储起来的,而其存储会占用其他的 Hash 槽,会导致其扩容频率高于拉链法。 查找过程有大概率会退化到 O(n) 的时间复杂度,而且没法进行像 Java 中的 HashMap 那样将链表转化为红黑树的优化。 1.2 单指令流多数据流(SIMD) 先上结论:SIMD 在 SwissTable 中的应用很大的提高了 Hash 对比的效率。 在具体展开讲 SwissTable 之前,先来回顾一下 SIMD,也就是一条指令操作多个数据。连续内存带来的优势会被 SIMD 进一步放大。与 SIMD 对应的是 SISD,也就是单指令流多数据流。 单指令流多数据流硬件层面的限制,导致了一条指令只能操作一个数据,无法实现数据并行。 而在 SIMD 中,每个ALU 前面都有自己独立的局部寄存器组,当 CU 会把指令下发给所有 ALU,然后 ALU 会从自己的局部寄存器组中获取不同的数据,然后进行一样的操作。举个例子: A = [1, 2, 3, 4],B = [4, 3, 2, 1] 求 A 中元素和 B 中元素对应位置相加。 首先在访存的时候,会分别将 A 和 B 的数据按地址分为四块,放入四个 ALU 中的局部寄存器,然后,CU 发 ADD 指令给每个 ALU,ALU 会从读出局部寄存器的值,然后独立的进行操作,从而实现单指令流多数据流。 这里就看出了 SIMD 的对数据的基本要求: ...
深入浅出 Go-Map(上)
1.24 前后 map 的世界,在 1.24 后,go 的 map 换了实现方式,让我们来康康 1 追踪 map 的起始点 看下面这段代码,我们来定位一下 make 之后发生了什么? package main func main() { _ = make(map[string]int, 16) } 使用 go tool compile -S 编译文件,可以发现 map 实际上是定位到了底层的 runtime.makemap 中,这使得我们有了窥探 map 源码的机会。 ❯ go tool compile -S main.go | grep 'make' 0x0034 00052 (.../main.go:4) CALL runtime.makemap(SB) rel 52+4 t=R_CALLARM64 runtime.makemap+0 在进入具体方法之前,我们先了解一下 1.24 之前 go 的 map 的结构 2 1.24 前的世界 本节 go 的代码是基于 1.23.2 版本的 2.1 map 的基本构造 在 1.24 前,go 的 map 实现是基于桶数组 + 溢出桶实现的,具体而言是基于 runtime 下的这两个结构体实现的。 首先是 hmap,他是宏观上的 map,包含了 map 的键值对数量、状态、桶的数量以及桶的指针等等。当我们创建了一个 map 的时候,宏观来看就是创建 hmap。 ...
Git 之 Cherry-Pick
什么是摘樱桃? 在分支协作中,出现以下这种情况,假设我的 main 分支只想要 feat 分支的 E 或者 F 的改动,此时就需要用到像摘樱桃一样给那几个提交摘过来(一个个小提交就像小樱桃),也就是 cherry-pick。 理解起来不难,但在实际操作中,还是有一些小问题的。我把目前摘樱桃的情况归为三类: 第一类是无冲突直接摘,例如 E 提交创建了一个新文件,这时 main 直接摘 E 过来是没问题的。 第二类是假设 F 是基于 E 的改动,但是我摘的时候,只摘了 F 过来,没有摘 E,此时摘樱桃操作会卡住。 第三类是你摘过来的提交和你本地的提交产生了冲突,此时需要解决冲突,cherry-pick 操作会卡住。 无冲突直接摘 这个没什么好说的,很简单,所以这里介绍一下几个 cherry-pick 的命令,分别是 git cherry-pick A // 只摘提交 A git cherry-pick A..B // 摘提交(A, B] git cherry-pick A^..B // 摘提交[A, B] git cherry-pick --abort // cherry-pick 操作终止,回滚 git cherry-pick --continue // 解决冲突后,cherry-pick 操作继续执行 所摘提交的依赖提交未摘 现在 feat 分支中有一个 main 分支中不存在的文件 READMEX.md,feat 中分别有两个提交,提交 A 是创建文件并写入一些内容,提交 B 是在文件中追加了一些内容。 ...
解析 Rebase & Merge
老 merge 了,但 rebase 弱的一逼,面试被问到,遂复盘 场景 小 A 在 feat1 分支开发,他肯定不能直接在 main 上做修改(为了保护项目的安全性),此时,为了防止大量的冲突,小 A 每次开发前都要拉去 main 分支的代码来看看,没啥冲突就直接合入,这样能有效避免积压大量的冲突。 此时就有问题了,git 中有两种合入代码的方式,分别是 merge 和 rebase,对应命令如下 git merge origin/main git rebase origin/main 到底用哪一个呢?小 A 挠挠头。 这里先给出一个方案,如果你是 main / master 分支的维护者,尽量使用 Merge,如果你是要维护自己的分支,可以用 Rebase,原因我们娓娓道来。 GitGraph GitGraph 是直观的 Git 多分支提交记录的展示,下面是一张示意图 在 GitGraph 中一条分叉就是一个分支,比如说上图,从 C 这个提交分出了一个 Feat 分支,也就是说,Feat 分支拥有含 C 之前的所有 Main 的提交,从 C 之后,Main 和 Feat 形同陌路。 Merge 合入 OK,我们先简单看看,基于上面这张 GitGraph,如果说在小 A 执行 merge 操作,会发什么? 很直观,如果执行 merge,会基于 main 分支的最新提交和 feat 分支的最新提交,进行合并,然后形成一个新的提交,非常直观。 ...
Authorization & Authentication
在面 MoonShot 的时候被问到了这个,当时我只知道认证和鉴权的区别,第一遍听二者的英文名没听懂啥意思,然后就没答出来,我以为是什么其他的芝士点,可恶捏,我得饿补一下芝士点QAQ。 认证(Authentication) 我们常说的登录,这实际上就是认证:Authentication 的完整定义是身份认证,即系统确认你是谁的过程。其本质是用户提供某种可信凭证来证明自己的身份。 平时我们所说的 Jwt 鉴权、Cookie-Session 鉴权这些很多时候讲的就是认证。下面先简单讲讲两个基础认证的手段,后面后统一讲一些框架,比如说 SSO。 Cookie-Session Cookie 和 Session 算是认证的基本芝士了。众所周知,Http 是一个无状态的协议,想象一下,如果没有一种措施让服务器知道你是谁,就得每次操作都需要输一次账号密码了。虽然 Cookie 和 Session 不是专门为认证所打造的,但是其可以实现简单的认证。 下面是一个简单的 Cookie-Session 的图示: 过程相当的简单,这也正是 Cookie-Session 的优点所在,实现起来相当简单,同时 Session 的鉴权信息是保存在服务器上的,一般来说也比较安全,而缺点也很明显。 缺点: 天然非分布式。我们现在的服务器大多都是负载均衡的,而假设我们认证的信息被保存在了服务器 A 上,而后续请求都打到其他服务器上,其他服务器是没有对应的 SessionId 信息的,从而导致无法正常认证。要解决这个问题需要引入其他组件,比如说 Redis 等,进而增加了系统的复杂性。 前端未防护 XSS 的情况下,Cookie 可能会被盗取,从而导致安全问题。 Session 的生命周期管理复杂。一般来说,我们会给 Session 设置一个过期时间,认证时发现过期会拒绝请求,但这些过期的 Session 会造成一定的服务器压力,需要手动清理。 Jwt-Token JwtToken 全称是 Json-Web-Token,是一种无状态的认证手段。这个相较于 Cookie-Session 会更加符合“潮流”一些。我们来看看用 Jwt 是怎么做认证的。 上面就是一个完整的认证流程,从宏观上来看,似乎只是客户端携带的东西不同了。我认为最重要的不同点是服务器存储的信息粒度的变大,服务器无需再存储每个用户的信息,只需要存储一个服务或者一个业务的密钥,就能解决这个业务上所有用户的认证问题,这一个转变解决了下面这些问题: Cookie-Session 的天然不支持分布式的问题,只要一台服务器上有对应服务的密钥,那就 OK。 解决了 Session 的生命周期管理的问题,因为服务器无存储压力,服务器只需要看这个 Jwt 里包含的过期时间有没有过期。 如此完美的方案是否有问题呢?有的兄弟,有的: ...
Mysql 索引以及锁初探
前段时间在面试字节的时候,面试官拿着 Sql 问我这个锁和索引之间的联系是什么的时候?人懵住了,这玩意还有联系?之后意识到是自己学习东西太过于肤浅,没有建立体系,导致这么简单的问题没答出来,悔不当初 QAQ。 Mysql 存储数据的方式 Mysql 底层存储数据是通过一个一个数据页所组成的,一页中存储的数据属于同一张表,但同一张表的数据不一定分布在同一页,从粗到细粒度大致是: 表空间 (Tablespace) └── 段 (Segment) └── 区 (Extent) └── 页 (Page, 16KB) └── 行记录 (Row) 我们这里主要分析页和行的结构 数据页(Data Page) 上图展示的就是一页的数据内容,Mysql 的行数据被单向链表的形式组织起来,为了提高页内检索效率,所以有了槽和分组的概念。他们的对应关系是,一个数据页中有多个槽,每个槽中有一个或多个数据行,数据行以单向链表的形式组织起来,然后链表头部和尾部分别是记录中的最小记录和最大记录标识。通过这样的形式组织起来的数据有什么好处呢?答曰提高检索效率。如果只单纯使用单向链表,那就意味着每次查找只能单向搜索。而使用槽,槽指针指向了一个分组内的最大记录,那就意味着在一页内可以用二分查找来提高检索的时间复杂度。 行记录(Data Row) 上面讲述了一页是如何组织的,下面来看看一行是如何组织的。 这就是一行数据,从左到右分别记载了: 变长字段列表:记录行数据中变长字段占用的字节数 Null 值列表:记录行数据中,可以为 Null 的列是否为 Null,也就是说一列对应一个 Bit 位,如果为 Null,在对应的 bit 位为 0 记录头:这里主要包含了一些辅助信息,比如记录的类型(是否是 B+树的叶子)、下一条记录的位置以及是否被删除标记。 RowId:当表设置了主键或者唯一性约束,则这一列隐藏,如果没设置,则 Mysql 会默认生成 RowId 字段。 TrxId:这条记录是由哪行所生成的。 Rollptr:UndoLog 的列表挂载,用于回滚和 MVCC。 上面就是一行记录,也就是说,当你创建一个表,Mysql 底层就是这么组织的。 索引 Mysql 索引分类 按照数据结构:Hash 索引、FullText 索引、B+ 树索引 按照底层存储:聚簇索引、非聚簇索引 按照建立索引的字段类型:主键索引、唯一索引、前缀索引、普通索引 按照字段数量:单列索引、组合索引 我们主要来关注聚簇索引和非聚簇索引,我们知道聚簇索引就是主键索引,其存储了主键和整行的数据,而非聚簇索引的一行第一列是索引值,第二行是主键值。为什么?Why?为什么第存全量数据?这是因为 Mysql 的底层数据组织结构造成的,B+ 树存储索引是要存储全量的数据页的,如果说非聚簇索引也存储全量数据,就等于在建立索引的时候,需要将所有数据页进行一次全量拷贝 + 组织,就意味着底层会出现大量重复的数据,所以 InnoDB 选择第二列存储主键值,这样当检索到索引后,只需要再去聚簇索引中,找到对应的数据即可。 Mysql 索引的数据组织结构 我们知道 InnoDB 的索引底层是基于 B+ 树的,而索引和页的关系是什么呢?答案是索引由数据页组成。 Mysql 的数据是这样存的,当你创建一个表并存入数据时,Mysql 会默认生成一个基于主键的聚簇索引,就是无论你主动创建索引与否,Mysql 都会生成聚簇索引,这不只是为了提高检索速度,而是 InnoDB 的行锁机制是基于索引的。所以这里也就 CallBack 到了开头的,索引和锁的关系。 ...
深入浅出 Go-GC
&&& Golang GC 流程 Go 如何启动 GC Go 触发 GC 有三种情况: 主动调用 runtime.gc() 可能会触发 Gc 当分配对象时可能会触发 Gc 守护协程定时 Gc runtime/mgc.go const ( // 根据堆分配内存情况,判断是否触发 GC gcTriggerHeap gcTriggerKind = iota // 定时触发 GC gcTriggerTime // 手动触发 GC gcTriggerCycle } func (t gcTrigger) test() bool { // ... switch t.kind { case gcTriggerHeap: // ... // 如果是堆内存分配导致的 GC,会 Check 当前堆内存的使用情况 trigger, _ := gcController.trigger() return atomic.Load64(&gcController.heapLive) >= trigger case gcTriggerTime: // 如果是守护协程 GC,则会check当前距离上一次 GC 是否已经达到 2 min if gcController.gcPercent.Load() < 0 { return false } lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime)) return lastgc != 0 && t.now-lastgc > forcegcperiod case gcTriggerCycle: // ... // 如果是手动触发 GC,check 前一轮 GC 有没有结束 return int32(t.n-work.cycles) > 0 } return true } Gc - Start Golang 的标记清扫算法的关键点就是标记这一步,其采用三色标记法,从 Root 对象开始进行可达性分析,关键步骤如下: ...
大模型调用的流式输出解析
从流式输出到服务端推送技术再到 Java 的 WebFlux 大模型流式输出 像 ChatGPT 这样的网页,我们不难发现问出问题后,大模型吐字是一段接一段的,但我们传统的 Http 请求,一般是每次获取一段数据就要再次发起请求一次。这是一种耗费资源的方式,简言之就是 Http 轮询(短轮询和长轮询 Comet),所以服务端主动推送数据的计数就应运而生。 服务端主动推送技术 这里抛开 Http 轮询计数,主要涉及到了 SSE 和 WebSocket,其实 SSE 和 WebSocket 都是服务于 “实时” 二字的。 SSE 协议 SSE(Server Send Events),顾名思义,服务端发送事件,是指服务端能够主动给客户端发送消息。其基于 Http 协议,需要按照 SSE 协议规范在消息响应体中填充数据,如果需要 SSE 协议,则需要 Http 长连接(默认),并且将请求中的 content-type 设置为 text/event-stream。 其原理实际上是在建立好的 Http 连接上,于客户端协商,返回的类型不为一次性的数据包,而是返回一个 Stream。而基于这个 Strem,服务器可以不断的往内部填入数据,客户端也可以依次接受数据。 其实 SSE 是比较常见的,因为很多时候,只需要服务器推送给客户端,而客户端不需要给服务器发送内容,比如说在一个常用开源容器监控系统 Dozzle 中,就能看到其身影。可以类比,如果一个系统,类似比赛的看板或者日志的看板,就比较适合用 SSE 协议。 因为 SSE 并非一个完全新的协议,而是使用了 Http 协议的功能,并定义一系列规范,所以 SSE 的优点就是: 轻量级(并非全新协议)(相较于 WebSocket) 基于 Http,基本上所有的浏览器都支持、 支持断开重连 缺点: ...
Java 并发串讲
串讲内容 从 Java 的并发的手段出发: Synchorized Volatile ReentrantLock 原子操作类 再到并发的底层 JMM 内存可见性和有序性 再到常见并发工具类 ConcurrentHashMap & HashTable JUC 并发安全手段 Java 的并发一直是面试的考点,这里并发安全手段主要整理了 Synchorized 关键字、Volatile 关键字、ReentranLock、原子操作类这四个 Synchorized 特性: 支持可重入 支持偏向锁、轻量级锁、重量级锁 非公平 Sync 关键字主要作用于方法,作用是标记某个对象或类的某个方法,在同一时刻只能有一个线程进行操作。其主要用法先了解下: // 1. 作用于代码块 synchorized(lock){ } // 2. 作用于普通方法 public synchorized void xxxx(){ } // 3. 作用于静态方法 public static synchorized void xxxx(){ } 上面的代码块,从上到下,Sync 关键字加锁的粒度也是逐渐增大,对于 1 号,锁的粒度是括号里的 lock;对于 2 号,锁的粒度是调用这个方法的对象;对于 3 号,锁的粒度是这个 Class。 ...