星河避难所

返回

[更新中] 从“能写 Go”到“写得对 Go”:一名 PHP 开发者的补课与重构Blur image

写在前面#

我是一名从事了几年的 PHP 开发者,平时以独立开发为主。主流的 PHP 框架基本都接触过,也做过不少实际跑在线上的项目。

后来因为工作和个人兴趣的原因,开始逐渐接触 Go,也用 Go 做过一些真实的东西。

比如,用 Gin + Vue + Wails 做过 PC 应用,在 Ubuntu 服务器上跑过 Go 服务,也写过一些用于接收 GitHub Webhook 执行 shell 脚本的小工具。

从结果上看,这些项目都能正常运行,功能也正常。

但我自己很清楚,这并不等于我“真正掌握了 Go”。

很多时候,我其实是在用多年写 PHP 积累下来的直觉去写 Go。

代码能跑,但对一些关键细节并没有完全的确定感:struct、slice、map 到底是值还是引用,指针什么时候该用、什么时候不该用,并发写法会不会在某些场景下出问题,这些问题经常是靠经验和感觉在兜底。

说得直白一点就是:我能用 Go 干活,但并不总是确定自己写的是不是“对的 Go”。

市面上的 Go 教程大多从 0 开始,这对我来说反而有点不太合适,从头跟着学,会花大量时间在已经了解的内容上;跳着看,又很容易因为缺失上下文而看不明白真正重要的部分。

而我真正想补的,也并不是 Web 框架的用法,而是那些在 PHP 中不存在、却在 Go 中非常关键的基础差异。

正好距离过年还有大概一个月的时间,我决定把这段时间专门用来系统地补齐这些认知上的空白。

为了逼自己真正学明白,也为了以后可以随时回看,我选择把整个过程整理成一篇持续更新的文章。

这篇文章的目录,是在 AI 的帮助下提前梳理好的。

我会按照这个目录,一个点一个点地去学习、验证、踩坑,然后把结论和经验补充到对应的位置。

目录看起来会比较长,但实际学习中,一个学习日通常会覆盖多个小项,它更多是用来拆解问题和记录思考的。

这不是一篇从 0 开始的 Go 入门教程,也不追求覆盖所有语言特性。

它更像是一名 PHP 开发者,在已经“能写 Go”的前提下,回头把那些一直靠感觉的地方重新补扎实的过程记录。

等全部更新完成之后,我也会把这篇文章整理并发布到其他地方,作为自己这一阶段学习和思考的总结。

姑且算是AI提供的日程安排,放在这里,确定自己的速度没有落后于进度

天数当天主线对应目录计划日期完成日期
Day 1Go 与 PHP 的运行时差异1–42026-01-092026-01-09
Day 2struct 的值语义5–72026-01-102026-01-09
——周日休息——2026-01-11-
Day 3指针的边界8–142026-01-122026-01-10
Day 4slice 的真实行为15–192026-01-13-
Day 5map 的坑20–242026-01-14-
Day 6defer 与资源释放25–282026-01-15-
Day 7error 设计哲学29–332026-01-16-
Day 8goroutine 基础34–372026-01-17-
——周日休息——2026-01-18-
Day 9channel 心智模型38–422026-01-19-
Day 10context 生命周期43–472026-01-20-
Day 11并发安全48–522026-01-21-
Day 12并发踩坑实录53–572026-01-22-
Day 13Go Web 生命周期58–612026-01-23-
Day 14项目结构 & 依赖62–642026-01-24-
——周日休息——2026-01-25-
Day 15日志 & 错误体系65–662026-01-26-
Day 16PHP → Go 重构67–702026-01-27-
Day 17服务启动与关闭71–722026-01-28-
Day 18部署实践73–742026-01-29-
Day 19并发型项目设计75–762026-01-30-
Day 20Worker Pool772026-01-31-
——周日休息——2026-02-01-
Day 21稳定性设计78–792026-02-02-
Day 22性能意识80–832026-02-03-

一、重新认识 Go#

1. 为什么 Go 不适合用「脚本语言思维」去理解#

如果之前没有接触过常驻内存框架,刚开始接触 Go 时,很容易下意识地用写 PHP、Python 这类脚本语言的方式去理解它:

无非是语法更严格一点、类型更强一点、性能更好一点。

在早期 PHP 那种“请求即生命周期”的模型下,这种理解其实是成立的。

一次请求执行完,进程结束,内存和状态被整体回收,很多问题都会被运行环境自然兜底,开发者也不需要太关心它们。

这几年,PHP 也出现了 webman、Swoole 这类 ​常驻内存框架,把 PHP 拉进了“长期运行服务”的世界。它们确实在使用体验上缩小了 PHP 和 Go 之间的距离,也让不少原本被隐藏的问题逐渐浮现出来。

但关键的区别在于:

Go 是从语言和运行时层面,就假设程序会长期运行;而 PHP 是在原有的脚本模型之上,通过框架去“补”这一点。

在 Go 里,长期运行不是一种特殊用法,而是一种 ​默认前提

全局状态如何管理、资源如何释放、并发如何调度,这些问题不是“需不需要考虑”,而是语言和运行时必须正面解决的核心设计。

如果仍然用“写完就结束”的脚本思维去理解 Go,很容易忽略这些前提,写出那种 短期跑得通、长期一定会出问题 的代码。

所以,理解 Go 的关键,并不是把它当成“支持常驻内存的脚本语言”

而是意识到:它从一开始,就是为长期运行的服务而设计的语言。

2. Go 的编译模型、运行时与 PHP 的本质差异#

Go 和 PHP 在使用体验上的差异,根源并不在语法层面,而在于它们背后的 ​编译模型和运行时设计

PHP 本质上是一门 ​解释型脚本语言

即便开启了 OPcache,代码依然是在运行时由 Zend VM 解析并执行的。

多年来,PHP 的语言和运行时设计始终围绕着“快速启动、快速执行、快速回收”展开,这也让它天然适配以请求为单位的执行模型。

Go 则是一门 ​编译型语言​。

go build​ 阶段,源码会被编译成一个完整的可执行文件,语言本身、标准库以及运行时都会被整体打包进去。

程序启动时,Go 的 runtime 会先完成调度器、内存管理和 GC 的初始化,然后才进入 main 函数开始执行用户代码。

这带来的一个核心差异是:

PHP 更像是在“执行一段代码”,而 Go 更像是在“启动一个程序”。

在 Go 中,运行时并不是隐藏在背后的执行引擎,而是语言设计的一部分。

goroutine、调度模型、垃圾回收、并发语义,都是在语言层面就被明确建模的能力,而不是依赖框架或扩展后置补齐的功能。

因此,Go 的代码天然假设程序会长期存在,状态会被复用,资源需要被明确管理;

而 PHP 即便在常驻内存或长生命周期的框架下,语言本身依然保留着强烈的脚本时代特征:生命周期并不显式,状态容易通过全局或上下文隐式扩散,也缺乏语言级的并发原语。

并发能力更多是由框架或扩展提供,但开发者在写代码时,很容易默认“这是顺序执行的”。

这也解释了为什么,同样是在“做服务”,Go 更强调启动流程、生命周期和运行时行为,而 PHP 更关注单次请求的处理过程。

两者关注的重点,从一开始就不在同一个位置。

3. Go 项目结构与 go mod 的真实作用#

如果你有 PHP 或前端背景,可以先把 go.mod​ 简单理解成 composer.json​ / package.json​。

go mod tidy​、go mod download​ 的使用体验,也确实很像 composer install​。

在入门阶段,把它们都当作​声明和管理项目依赖的工具,这个理解是完全成立的。

真正的差异不在“怎么写”,而在于:

依赖是在什么时候、以什么方式参与到程序里的

在 PHP 或前端项目中,依赖代码会被拉进项目目录(vendor​ / node_modules​),作为项目源码的一部分存在,并在运行时由解释器或运行环境加载;

而在 Go 中,依赖同样会被下载,但它们存在于 Go 的模块缓存中,不属于项目源码,只在编译阶段被解析、编译,并最终被打包进可执行文件。

这也决定了 go mod​ 的真实关注点:

它关心的并不是“运行时需要哪些库”,而是:

我要构建出一个什么样的程序

同一份源码、同一份 go.mod​,目标是无论在哪台机器上构建,最终得到的都是​行为一致的二进制文件。依赖不是项目的一部分,而是构建过程中的输入。

基于这个前提,Go 的项目结构看起来就会非常克制。

目录结构更多是在表达 ​package 之间的编译关系,而不是应用层面的分层设计。

一个目录就是一个 package,package 是最小的编译和依赖单位,代码如何组织,本质上是在服务于“如何被编译”和“如何被发布”。

可以用下面这个对照,快速感受这种差异:

对比点PHP / 前端Go
依赖声明文件composer.json / package.jsongo.mod
安装依赖composer install / npm installgo mod tidy / download
依赖存放位置项目目录(vendor / node_modules)模块缓存(不在项目中)
依赖参与阶段运行时加载编译期解析
项目结构关注点应用分层包与编译边界
最终产物源码 + 运行环境单一可执行文件

整体来看,go mod​ 表面上像是一个依赖管理工具,但它真正服务的是 Go 的​编译模型

这也是为什么 Go 项目往往结构简单、层级不多,却非常适合长期运行的工程型服务。

它从一开始,就把“如何构建”和“如何交付”放在了设计的核心位置。

4. package、import 与依赖边界(为什么 Go 讨厌循环依赖)#

在 Go 中,package​ 是最核心的组织单位。

一个目录就是一个 package,而 package 同时也承担着编译边界、依赖边界和可见性边界这几件事情。

代码在 Go 里的归属关系,其实是先属于某个 package,再通过 import 被其他 package 使用,而不是一开始就挂在某个“项目”之下。

从这个角度看,import​ 在 Go 里也不是简单的“引用文件”,它更像是在明确声明一件事:

这个 package 在编译时依赖另一个 package 的产出结果

所以,Go 的依赖关系,本质上是一张​编译期的依赖图

也正因为依赖是在编译期被严格确定的,Go 对 package 之间的依赖边界非常敏感,并且明确禁止循环依赖。

如果 A import B,B 又 import A,在不少脚本语言里,往往还能通过一些方式“绕过去”,比如延迟加载、运行时决定执行顺序等。

但在 Go 的模型里,这种关系本身就无法成立:编译器既无法确定编译顺序,也无法保证依赖结果是稳定的。

不过,禁止循环依赖的意义,并不只是“编译器做不到”。

从 Go 的设计取向来看,​循环依赖本身就被视为一种值得警惕的结构信号,它通常意味着:

  • package 的职责边界不够清晰
  • 抽象层级开始变得混乱
  • 状态和逻辑在不同层之间相互牵扯

在这样的前提下,禁止循环依赖,实际上是在逼着你把依赖关系整理成​单向的、有层次的结构

这也是为什么在不少 Go 项目中,会逐渐形成一些比较一致的结构特征:

  • 偏底层的 package 不依赖上层逻辑
  • 通用能力被拆到更独立的 package 中
  • 通过接口来反转依赖方向,而不是让 package 之间互相 import

换句话说,Go 并不是单纯在“语法层面不允许循环依赖”,而是通过语言规则,把依赖边界这件事提前暴露出来,让你在写代码的时候就必须面对它。

如果简单点来概括这种思路,大概可以这样理解:

  • 在 Go 中,package 更像是在声明编译边界;​
  • import 是在说明依赖方向;
  • 禁止循环依赖,是为了让这些关系始终保持清晰和可推导。

二、值语义:PHP 开发者最容易踩的第一坑#

5. struct 是值,不是对象#

当前问题存在示例代码,可以前往GitHub查看

在 Go 里,有一个和 PHP 认知明显不同的地方:​值语义

在 PHP 中,我们通常把结构体或对象理解为有身份的实体:把它传给函数或者赋值给另一个变量,好像一直在操作同一份数据。

而在 Go 里,struct​ 的默认语义是 ​。这意味着一些看似自然的操作,其实背后发生的是复制:赋值会生成副本,函数传参会生成副本,方法调用在很多情况下也会生成副本。

type Counter struct {
	n int
}

a := Counter{n: 10}
b := a
b.n++
fmt.Println(a.n, b.n) // 10, 11
go

在这个例子里,虽然表面上只是一次赋值,但 a​ 和 b​ 已经是两份独立的数据。

从这个角度看,Go 并没有把共享状态作为默认行为,而是把 数据的传递与复制 放在了显式可见的位置。

6. 函数参数传递:值拷贝 vs 指针#

当前问题存在示例代码,可以前往GitHub查看

值语义在函数参数传递上也会体现出来。

当 struct 作为函数参数传入时,Go 会生成一份副本:

func inc(c Counter) {
	c.n++
}

c := Counter{n: 10}
inc(c)
fmt.Println(c.n) // 10
go

可以看到,函数内部对 c​ 的修改没有影响外部的 c。如果希望函数内部修改能够影响外部,就需要使用指针:

func incPtr(c *Counter) {
	c.n++
}

incPtr(&c)
fmt.Println(c.n) // 11
go

方法调用遵循同样规则:接收者是值还是指针,决定了方法内部操作的是副本还是原始数据。

func (c Counter) incByValue()    { c.n++ }  // 操作副本
func (c *Counter) incByPointer() { c.n++ }  // 操作原值

c := Counter{n: 10}
c.incByValue()
fmt.Println(c.n) // 10

c.incByPointer()
fmt.Println(c.n) // 11
go

总结起来:

  • Go 的 struct 默认是值类型,赋值、函数传参、方法调用都会生成副本。
  • 想要在函数或方法里修改原数据,需要显式使用指针。
  • slice、map、channel 等引用类型除外,它们内部数据的修改会反映到外部,但整体赋值仍然是复制副本。

相比 PHP 的对象语义,这种行为更加明确:共享必须被显式表达,而不是隐式存在。

7. 返回 struct、返回指针、返回 interface 的区别#

当前问题存在示例代码,可以前往GitHub查看

在 Go 里,函数返回值既可以是值类型,也可以是指针类型,还可以是接口类型。我在学习中对这三种返回方式的行为做了观察,总结如下:

当函数返回一个 struct 时,返回的是一份​副本。调用方拿到的是独立的数据,修改返回值不会影响函数内部或其他实例:

type Counter struct { n int }

func NewCounterVal() Counter {
	return Counter{n: 1}
}

c := NewCounterVal()
c.n++
fmt.Println(c.n) // 2
go

每次返回都是独立副本,适合小型 struct,不需要共享状态。内存上会复制整个 struct,如果 struct 较大,可能有开销。返回 struct 类似于函数传值,强调复制而非共享。

返回 struct 的指针时,调用方拿到的是原始对象的引用,可以修改原始数据:

func NewCounterPtr() *Counter {
	return &Counter{n: 1}
}

c1 := NewCounterPtr()
c2 := c1
c2.n++
fmt.Println(c1.n) // 2
go

返回值是指针,操作共享同一份数据,避免复制大型 struct,提高性能。修改原始数据变得显式可见,和函数传指针参数语义一致。

接口返回值稍微复杂一些。接口内部存储的是​类型信息 + 值:如果返回值实现是 struct 值,接口内部存储的是副本;如果返回值实现是指针,接口内部存储的是指针,调用方法会修改原对象。

type Counterer interface {
	Inc()
	Value() int
}

func NewCounterInterface() Counterer {
	return &Counter{n: 1} // 返回指针实现
}

c := NewCounterInterface()
c.Inc()
fmt.Println(c.Value()) // 修改生效
go

接口返回行为取决于传入的是值类型实现还是指针类型实现。对 PHP 开发者来说,接口不像对象那样天然共享状态,需要理解内部存储机制。

返回类型内部行为是否共享原数据适用场景
struct复制整个 struct小型 struct,不修改状态
*struct复制指针修改状态或大型 struct
interface存储值或指针取决于实现抽象类型,可存放值或指针

核心规律:Go 的函数返回值和参数传递类似,默认是值拷贝。想要共享原数据,需要显式使用指针。接口稍微复杂,需要理解内部存储机制。

8. 方法接收者:值接收者 vs 指针接收者#

当前问题存在示例代码,可以前往GitHub查看

在 Go 中,方法本质上就是带接收者参数的函数:

func (c Counter) IncByValue()    { ... } // 值接收者
func (c *Counter) IncByPointer() { ... } // 指针接收者
go

接收者类型决定了方法内部修改是否会影响原对象。

值接收者方法内部操作的是​副本,修改不会影响原对象。适合小型 struct 或只读操作:

c := Counter{n: 10}
c.IncByValue()
fmt.Println(c.n) // 10,原值不变
go

指针接收者方法内部操作的是​原始对象,修改会反映到原对象。适合需要修改状态或大型 struct:

c := &Counter{n: 10}
c.IncByPointer()
fmt.Println(c.n) // 11,修改生效
go

方法接收者选择的原则大致如下:只读或小型 struct 可用值接收者;需要修改状态或大型 struct 通常用指针接收者;接口方法一般使用指针接收者,保证修改行为一致。

接收者类型操作数据是否修改原对象适用场景
值接收者副本小 struct,只读操作
指针接收者原始对象修改状态或大型 struct

核心规律:方法接收者语义和参数传递一致,默认值传递,显式使用指针才能共享数据。

9. 「什么时候必须用指针」的经验法则#

观察下来,大概可以这样理解:

  1. 修改原始数据的时候

    • 方法或者函数内部如果希望改变外部的数据,必须用指针。
    func IncPtr(c *Counter) { c.n++ }       // 参数是指针
    func (c *Counter) Inc() { c.n++ }       // 方法接收者是指针
    go
  2. struct 较大或者复制成本明显的时候

    • 当 struct 字段比较多,或者占用内存不小,传值会复制整个结构体。
    • 指针传递可以避免这个复制开销,这时使用指针不是为了共享状态,而只是效率考虑。
    func ProcessLargeStruct(s *LargeStruct) { ... } // 避免复制大对象
    go
  3. 接口方法涉及内部状态修改

    • 如果一个 struct 实现接口,方法会修改内部状态,那么接收者通常要用指针。
    • 因为接口内部存的是类型 + 数据,如果传入的是值类型,实现方法会操作副本,修改不会反映到原对象。
    func NewCounterInterface() Counterer {
        return &Counter{n:1} // 返回的是指针
    }
    go
  4. 希望行为统一或者更容易理解的时候

    • 即便 struct 本身不大,如果想让所有方法行为一致,也可以统一用指针接收者。
    • 这样方法调用不会因为值还是指针而表现不同,接口实现也更直观。

整理下来,我的理解可以这样总结:

  • 必须修改原对象 → 用指针
  • struct 较大或复制开销明显 → 用指针
  • 接口方法需要修改内部状态 → 用指针
  • 希望行为统一 → 可以统一使用指针接收者

总体感觉是:Go 的默认行为是值语义,复制是自然发生的,共享状态不会自动发生,需要用指针显式表达。值类型适合只读或者独立副本操作,指针类型则可以让修改和共享变得明确。


三、指针:只学 Go 中真正需要的那一部分#

​10. &​ 和 * 的真实含义#

当前问题存在示例代码,可以前往GitHub查看

刚接触 Go 的时候,我会下意识用 PHP 的视角去理解 &​ 和 *​,把它们当成“引用传递”的另一种写法。但在实际对比之后,我发现这两个符号并不是在讨论“怎么传参”,而是在明确区分一件更基础的事情:​当前操作的是值,还是值所在的位置

在 PHP 中,这层区分基本是被隐藏的。变量更像是“名字指向值”,至于值是否被复制、是否共享,通常由运行时决定。即使使用 &​,它也更偏向一种语义层面的开关,用来改变变量之间的绑定关系,而不是一个可以被单独拿出来传递、存储或返回的实体。某种程度上,PHP 的 & 已经把“位置”和“通过位置修改值”这两件事打包在一起了。

Go 的选择正好相反。&​ 表达的是一个非常具体的动作:把某个值所在的位置本身当作一个值暴露出来;* 则表示通过这个位置去访问或修改对应的数据。它们并不是在模拟 PHP 的引用语义,而是在显式引入“位置”这一概念,并要求调用方和使用方分别表态:一边决定是否交出位置,一边决定是否通过位置操作数据。

从这个角度看,函数签名里的差异就变得很直接了。参数是值,还是指向值的位置,在定义阶段就已经确定,不需要依赖函数内部实现来判断是否会产生副作用。这一点在 struct 上尤其明显:Go 中的 struct 是值,而不是对象,只有显式地传递位置,修改才会作用到同一份数据上。

因此,&​ 和 * 更像是一种边界标记。写下它们,并不只是为了“能改到外面的变量”,而是在明确区分“数据的副本”和“数据本身”。和 PHP 那种由语言替你处理这些细节的方式相比,Go 更倾向于把选择提前,并且要求你把这个选择直接写进代码里。

11. 指针并不是“性能优化工具”#

在一开始理解指针的时候,很容易把它和“性能优化”直接挂钩,尤其是从 PHP 这种对内存细节高度抽象的语言过来时,会下意识认为:传指针是不是就是为了少一次拷贝、快一点。

但在实际对比之后,我更倾向于把指针理解为​语义工具,而不是性能工具。它首先解决的并不是“快不快”,而是“这份数据是不是被共享、是不是允许被修改”。

在 Go 里,是否使用指针,直接影响的是代码表达的含义。一个函数接收值,意味着它只能操作这份数据的副本;一个函数接收指针,意味着它明确依赖并可能修改某个已有的数据。这种区分本身就是 API 设计的一部分,而不是隐藏在实现细节里的性能技巧。

当然,从结果上看,指针确实可能减少拷贝,尤其是在数据结构较大的情况下。但这是​使用指针之后自然产生的副作用,而不是它存在的主要目的。Go 的编译器本身已经会在很多场景下帮你做逃逸分析和拷贝优化,如果只是为了“少拷一次”,往往并不需要手动引入指针。

更重要的是,一旦把指针当成性能工具使用,代码的语义边界反而会变得模糊。一个函数之所以接收指针,究竟是因为它需要修改外部状态,还是只是为了“快一点”,从签名上已经无法判断。这种不确定性,往往比那点拷贝成本更昂贵。

所以在 Go 里,是否使用指针,更像是在回答一个设计问题:​这是不是一份需要被共享和协同修改的数据。性能因素当然存在,但它更适合出现在已经明确语义之后,而不是作为引入指针的第一理由。

12. nil 指针与零值的区别#

在一开始接触 nil​ 的时候,我很容易把它和“空值”划等号,甚至会下意识地把它当成某种“默认的零”。但在 Go 里,对比下来会发现,nil 指针和零值并不在同一个层面上,它们描述的是两种不同的状态。

零值描述的是“这个类型本身处在一个合法但未初始化的状态”。比如一个 int​ 的零值是 0​,一个 struct 的零值是各字段的零值组合。它们都是完整、可用的值,可以被读取、传递,也可以参与计算。零值关注的是“值是什么”。

nil​ 则更像是在回答另一个问题:这里有没有一个实际存在的东西。当一个指针是 nil,并不是说它指向的值是“空的”,而是说它根本没有指向任何位置。它关注的不是值的内容,而是“指向关系是否存在”。

这也是为什么同样是“什么都没初始化”,零值的 struct​ 可以直接使用,而 nil 指针却不能被解引用。前者是一个完整的值,只是内容处在默认状态;后者则缺少了最基本的前提——并不存在一个可以通过它访问的对象。

如果把这种差异放回到 PHP 的语境中,会更容易看清楚。PHP 的 null​ 更像是一种通用的“空”的表达:它既可能表示“没有值”,也可能表示“尚未初始化”或“没有结果”。同一个 null,在不同场景下承担的是不同的语义,更多是一种语言层面的兜底状态。

而 Go 并没有用 nil​ 去覆盖所有“空”的情况。一个 int​ 不可能是 nil​,一个 struct​ 也不可能是 nil​,因为它们本身就是值,始终是存在的。只有那些需要依附于某个底层实体的类型,才会有“存在 / 不存在”这层状态,因此才会出现 nil​。这使得 nil 的含义相对单一,也更容易被约束。

从这个角度看,nil​ 本身并不是零值的替代品,而是一种额外的状态标记。是否允许出现 nil​,本身就是 API 设计的一部分:返回零值,往往意味着“这是一个合法但内容处在默认状态的结果”;而返回 nil,则更明确地表达“这里没有结果”或者“这个对象尚未存在”。

因此,在 Go 里区分 nil​ 指针和零值,关键不在于语法差异,而在于它们各自表达的语义边界。相比 PHP 用 null 统一兜住各种“空”的情况,Go 更倾向于把“值是否存在”和“值的内容是什么”拆开表达,把选择和含义直接暴露在类型和签名中。

13. 指针在业务代码中的合理边界#

它并不是用得越多越好,而是用来标记哪些数据具有共享和可变的属性。

在大多数业务场景里,值语义本身已经足够。请求参数、配置快照、计算中间结果,这些数据更像是一次性输入或阶段性产物,用值来传递反而更清晰:函数拿到的是一份拷贝,能做的事情是受限的,也更容易推断行为。

指针更适合出现在那些具有明确生命周期和身份的对象上。比如聚合根、长期存在的上下文、需要被多处协同修改的状态。这里使用指针,并不是为了避免拷贝,而是在表达:这不是一份临时数据,而是一个被持续引用和演化的实体。

从接口设计的角度看,指针往往意味着副作用是设计的一部分。如果一个函数接收指针,通常可以预期它会对外部状态产生影响;而只接收值的函数,则更接近于纯逻辑处理。这种区分一旦稳定下来,代码的可读性会比任何注释都强。

同时,指针的边界也应该尽量收敛。越靠近业务边缘,比如 handler、service 层,对指针的使用就越需要谨慎。指针在这些层级一旦被随意传递,很容易让状态修改在调用链中扩散,最终变成难以追踪的隐式依赖。相反,把指针限制在领域内部或基础设施层,往往更容易控制其影响范围。

所以在业务代码中,是否使用指针,更多是在回答一个设计问题:​这里是不是一个需要被共享、被持续修改的对象。当这个问题的答案不明确时,优先选择值,通常会得到一个更稳定、更容易演进的结构。

14. Go 为什么不鼓励随意暴露指针#

Go 并不是反对指针本身,而是不鼓励它被随意暴露。这种克制并不是出于安全限制,而更像是一种设计取向。

一旦指针被暴露出去,暴露的其实并不只是一个数据访问方式,而是​对内部状态的直接操作权。拿到指针的一方,不需要经过任何额外的约束,就可以修改其指向的数据,这会让原本清晰的状态边界变得模糊。数据“属于谁”、由谁负责维护,不再只保持在定义处,而是开始向调用方扩散。

从 API 设计的角度看,指针会把实现细节向外泄漏。一个返回指针的函数,实际上是在告诉使用者:这里有一块可以被直接操作的内部数据。这种暴露一旦发生,后续的重构空间就会被压缩——内部结构是否还能调整、是否还能增加校验逻辑,都会受到限制。

相比之下,返回值或只接收值参数,意味着调用方只能通过你定义好的入口与数据交互。修改行为被集中在有限的函数中,状态变化路径也更容易被追踪。这并不会减少灵活性,而是在用结构换取长期的可维护性。

另外,指针的暴露还会影响代码的阅读方式。看到一个值,默认可以认为它是局部、受控的;看到一个指针,则需要额外思考它可能在什么地方被修改过。随着指针在调用链中不断传递,这种不确定性会迅速累积,最终让代码的理解成本超过它带来的便利。

因此,Go 对指针的克制使用,更像是在强调一件事:​共享状态本身就是一种需要被谨慎对待的设计选择。指针并不是被禁止的工具,但它更适合被限制在清晰的边界之内,而不是作为默认的数据暴露方式存在。


四、slice:Go 中最“像魔法”的数据结构#

15. slice 的底层结构:指针、长度、容量#

占位中,等待更新

16. slice ≠ array:为什么 append 会出问题#

占位中,等待更新

17. 扩容带来的引用断裂问题#

占位中,等待更新

18. slice 作为函数参数的常见误解#

占位中,等待更新

19. slice 在并发场景下的风险#

占位中,等待更新


五、map:看起来简单,实则暗雷密布#

20. map 是引用类型,但不是并发安全#

占位中,等待更新

21. nil map vs make(map)#

占位中,等待更新

22. map 在函数间传递的行为#

占位中,等待更新

23. map 并发读写为什么会直接 panic#

占位中,等待更新

24. 使用 map 时的防御性写法#

占位中,等待更新


六、defer 与资源生命周期#

25. defer 的执行时机与栈模型#

占位中,等待更新

26. defer + loop 的经典坑#

占位中,等待更新

27. defer 在 Web 请求中的正确使用方式#

占位中,等待更新

28. Go 中资源释放为什么必须显式#

占位中,等待更新


七、error:Go 的“显式异常系统”#

29. error 是值,而不是异常#

占位中,等待更新

30. ​if err != nil 为什么是设计选择#

占位中,等待更新

31. 错误向上传递的最佳实践#

占位中,等待更新

32. panic、recover 的合理使用场景#

占位中,等待更新

33. 错误包装(wrap)与错误定位#

占位中,等待更新


八、Go 的并发模型:从根上和 PHP 不一样#

34. goroutine 不是线程#

占位中,等待更新

35. GMP 调度模型的直觉理解#

占位中,等待更新

36. 为什么 Go 可以“随便起协程”#

占位中,等待更新

37. 并发 ≠ 更快:什么时候并发是负担#

占位中,等待更新


九、channel:通信,而不是共享内存#

38. channel 的设计哲学#

占位中,等待更新

39. 无缓冲 channel vs 有缓冲 channel#

占位中,等待更新

40. 谁负责关闭 channel#

占位中,等待更新

41. 使用 channel 避免共享状态#

占位中,等待更新

42. channel 常见死锁场景分析#

占位中,等待更新


十、context:协程的生命周期管理#

43. context 的设计初衷#

占位中,等待更新

44. context.WithCancel / Timeout / Deadline#

占位中,等待更新

45. 为什么 context 不应该传业务参数#

占位中,等待更新

46. Web 请求结束后 goroutine 应该如何退出#

占位中,等待更新

47. context 泄漏的隐患#

占位中,等待更新


十一、并发安全:不是所有地方都要锁#

48. data race 是怎么产生的#

占位中,等待更新

49. mutex 与 RWMutex 的使用边界#

占位中,等待更新

50. sync.Once 的实际应用场景#

占位中,等待更新

51. 用 channel 替代锁的设计思路#

占位中,等待更新

52. 复制数据 vs 加锁的取舍#

占位中,等待更新


十二、并发错误:必须亲手踩过的坑#

53. goroutine 泄漏的几种常见写法#

占位中,等待更新

54. channel 永远阻塞的原因#

占位中,等待更新

55. for + goroutine 的经典陷阱#

占位中,等待更新

56. 并发 map 写导致的 panic#

占位中,等待更新

57. 如何通过结构设计避免并发 bug#

占位中,等待更新


十三、Go Web 中的生命周期意识#

58. HTTP 请求在 Go 中的完整生命周期#

占位中,等待更新

59. handler、middleware、service 的职责边界#

占位中,等待更新

60. request 级资源的创建与释放#

占位中,等待更新

61. Web 中的并发模型与 goroutine 数量控制#

占位中,等待更新


十四、工程化:写“可长期维护的 Go 服务”#

62. internal / pkg 的设计目的#

占位中,等待更新

63. 依赖方向与反向依赖的处理#

占位中,等待更新

64. 配置管理(viper)与环境区分#

占位中,等待更新

65. 结构化日志(zap)的使用原则#

占位中,等待更新

66. 错误、日志、trace 的协作关系#

占位中,等待更新


十五、从 PHP 项目到 Go 项目的思维迁移#

占位中,等待更新

67. 哪些 PHP 设计可以直接迁移#

占位中,等待更新

68. 哪些 PHP 写法在 Go 中是反模式#

占位中,等待更新

69. Go 中“复制数据”往往比“共享数据”更安全#

占位中,等待更新

70. 典型 PHP 模块的 Go 化重构思路#

占位中,等待更新


十六、部署与稳定性#

71. Go 程序的启动与退出流程#

占位中,等待更新

72. 信号处理与优雅关闭#

占位中,等待更新

73. systemd 部署 Go 服务的实践#

占位中,等待更新

74. 为什么 Go 天然适合做常驻服务#

占位中,等待更新


十七、并发型实战项目#

75. 并发 webhook 接收与处理服务#

占位中,等待更新

76. 并发执行 shell 的任务调度器#

占位中,等待更新

77. Worker Pool 的设计与实现#

占位中,等待更新

78. 限流、超时与失败控制#

占位中,等待更新

79. 从“能跑”到“稳定”的改造过程#

占位中,等待更新


十八、性能与运行时感知#

80. Go GC 的基本行为#

占位中,等待更新

81. 什么时候需要关注内存分配#

占位中,等待更新

82. pprof 的基础使用#

占位中,等待更新

83. 常见性能误区(过度并发、过度抽象)#

占位中,等待更新

[更新中] 从“能写 Go”到“写得对 Go”:一名 PHP 开发者的补课与重构
https://hejunjie.life/blog/aodj2421
作者 何俊杰
发布时间 2026年1月8日
版权信息 CC BY-NC-SA 4.0
评论似乎卡住了,尝试刷新?✨