又是一年:从客户端到服务端这几年

博客四年没写过年终小结以外的东西了,今天翻归档,看到 2019 年那篇,突然想写点什么。不是总结,更像是给自己留个锚点。 2019 年那篇里写"明年想做的事:学点服务端的东西"。现在回头看,这个愿望以一种完全没预料到的方式实现了——不是学了点,而是整个换了赛道。2021 年转岗到基础架构,一晃四年多。 这四年最大的变化不是技术栈,是看问题的位置。做客户端的时候,后端是一个黑盒:接口慢就是后端的问题,挂了就是后端的问题。换了位置之后才发现,“后端"自己也在被数据库、网络、机器、甚至别的团队的黑盒卡着。现在再看当初自己在客户端组提的那些工单,很多问题的真实答案不是"谁错了”,而是"信息在哪个环节丢掉了"。 这个认知反过来让我现在的代码风格变了不少:接口里多传一个字段、日志里多打一行、文档里多写一句"这里为什么这样设计",成本几乎为零,但可能在两年后帮某个排查问题的人省下一晚上。 技术上的近况:今年主要精力在把推送网关的下一代架构往前推,长连接网关的协议升级是个又脏又慢的活儿,因为要兼容三年前的老客户端。有段时间挺烦的,觉得在还技术债没有成长。后来想通了——能安全地改一个跑了五年的系统,本身就是一种能力,而且这种能力只有在这种活儿里练得出来。 下半年想捡起来的是写作。翻归档才发现 2024 年只写了两篇,2025 年到现在只有这一篇。倒不是没东西可写,是"要写就写完整"的强迫症越来越重,一篇东西反复改开头,最后烂在草稿箱。试着降低标准:写得糙一点没关系,记录本身就是价值。这篇就算个开始。

2025-08-22 · 芮靖安

gRPC 在生产环境用了两年之后

内部服务全面从 HTTP/JSON 切到 gRPC 两年了,当初写迁移方案的人里还在组的只剩我一个。趁着记忆还热,把两年里沉淀下来的经验写一篇,算是给这套选型做个中期复盘。 选对了什么: 强类型契约的价值被严重低估了。 proto 文件 + 代码生成让接口变更的 review 变得极其直观,破坏性变更在 CI 阶段就能拦住。两年里因为接口不一致引发的线上问题,是零。以前 HTTP/JSON 时代一年总有那么两三次。 grpc-go 的拦截器生态很好用。 我们把日志、trace、限流、panic recovery 全部收敛在 server 端拦截器里,业务代码完全不用感知。这是迁移以来工程体验提升最大的部分。 踩过的坑,按疼痛程度排序: 默认负载均衡是"假"的。 gRPC 客户端和一条 HTTP/2 长连接通信,如果用普通的 DNS 解析 + 单连接,所有请求会打到同一个后端实例上。必须用 dns:// resolver 加上客户端的 round-robin,或者上 xDS。这个问题在我们灰度的时候表现为"新版本流量永远打不到新实例",排查了整整两天。 keepalive 参数两端要协商好。 中间一层的 LVS 把"空闲"的 gRPC 连接静默掐掉,客户端还以为连接健在,请求 timeout 谷底式抖动。ping 间隔改到小于 LVS 的空闲超时之后解决。这种网络设备层的问题文档里不会写,只能靠抓包。 deadline 必须全链路传递。 没传 context 的下游超时设置,等于没有超时。我们后来在 lint 里加了规则,gRPC 调用不传 ctx 直接报错。 一个想法:gRPC 的成熟度曲线是陡峭的——入门半天,但进入生产要补的课(连接管理、负载均衡、可观测性)是一整个学期。如果重来一次,小团队内部系统我还是会先推荐 HTTP/JSON,等服务和调用量到了一定规模再切 gRPC,收益才盖得住成本。

2025-04-17 · 芮靖安

手撸一个简易分布式定时任务的记录

公司内部系统有个痛点:十几个 Go 服务各自跑着 cron,日志、监控、去重各玩各的,出了问题要去十几个服务里翻。申请专项做统一调度被拒了(理由:收益不明确),于是用了两个月的晚间时间自己撸了个最小可用版本,先在自己负责的服务里跑起来,跑通再说。这篇记录核心设计。 需求收敛。 首先想清楚不做什么:不做秒级调度(cron 表达式到分钟足够)、不做依赖编排(任务依赖用上线时间错开解决)、不做可视化界面(CLI + 配置文件够用)。砍完之后剩下的核心就三件事:时间触发、任务分发、不重复执行。 关键设计是抢锁。 多实例部署下同一个任务只能有一个实例执行,我们没有引入额外的调度中心,而是直接用 MySQL 的行锁: -- t_job 表,每分钟由各实例尝试 CAS UPDATE job_lock SET owner = ?, locked_until = NOW() + INTERVAL 2 MINUTE WHERE job_name = ? AND (locked_until < NOW() OR owner = ?); 抢到锁的实例负责执行并把心跳写进去,锁过期自然释放。整个方案只依赖已经有的 MySQL,没有新组件。有人会问为什么不用 Redis:因为任务幂等性出错的代价是重复扣库存,而 MySQL 事务的可靠性在我们团队是经过验证的,不熟的东西不引入。 分片是第二个关键。 单机任务量上来之后,把任务按 jobName 的 hash % 实例数 分片,每个实例只认领自己的那份。简单,但配合上面的锁,二十万级任务/天的量完全够。 踩的坑:时钟漂移。有两台机器的 NTP 没配好,相差 40 秒,导致任务在整点附近被执行了两次。锁的有效期改成大于任务最长执行时间 + 漂移容忍之后解决。现在新机器进集群先校时,成了硬性流程。 现在这个调度器稳定跑了三个月,另外两个组也接进来了。回头看,“先做一个丑但能跑的版本,让收益自己说话"这个策略是对的——下个季度统一调度的专项预算,批了。

2024-12-03 · 芮靖安

Go 泛型用了一年,说说真实感受

Go 1.18 出泛型的时候,团队讨论过要不要用,当时的结论是观望。去年初我们把最低版本升到 1.21 之后,终于把泛型放进了代码规范,允许使用。一年下来,谈谈真实感受。 泛型真正帮到我们的地方,比想象中窄。 一年下来高频使用的场景其实就三类: 容器类工具函数。Map、Filter、Contains 这些,以前用 interface{} + 反射写,又慢又不安全,现在干净多了。 类型安全的并发原语。我们自己封了个 sync.Map 的泛型包装,key 和 value 的类型在编译期就确定,用起来踏实。 数据访问层的通用分页查询,一个 func Query[T any](...) 替掉了以前代码生成器吐出来的几十行重复代码。 但有些地方我们明确禁止用泛型: 业务结构体不搞泛型。 Service[T]、Repo[T] 这种"框架感"的东西一旦放出来,业务代码的可读性会断崖式下跌。领域模型就该是具体的,UserRepo 比 Repo[User] 好读得多。 能用接口解决的不要用泛型。 泛型和接口最大的区别是:接口是运行时多态,泛型是编译期展开。大部分业务场景需要的是前者。 一个观察:团队里泛型使用量最大的,是以前用 interface{} 最多的那批代码。这说明泛型在 Go 里解决的主要不是"表达力"问题,而是"以前只能靠反射硬扛"的那部分代码的类型安全问题。从这个角度看,泛型是改良,不是革命。 最后记录一个实用主义的心得:升级到 1.21 之后 min/max 内置函数直接删掉了我们自己写的工具函数,这种小快乐积累起来,也是升级的意义。

2024-05-09 · 芮靖安

第一次正经管 Kubernetes 的一些坑

今年公司的基础设施从自建机房迁到了云上的 K8s,运维的活儿有相当一部分落到我们组头上。作为一个写了十年代码没怎么管过机器的人,这半年补了很多课,挑几个印象深的记下来。 资源 requests/limits 别乱填。 上线前凭感觉给服务配了 limits:内存 4G、CPU 2 核。结果高峰期 CPU 被 throttle,接口 P99 从 80ms 涨到 400ms,监控上还看不到任何异常——throttle 不报错,就是慢。后来用 Grafana 盯着 CPU throttling 指标重新校准了所有服务的配额。教训:limits 配小了比不配还危险,因为故障形态是"变慢"而不是"报错",最难排查。 滚动更新的 maxSurge 和 readiness 探针是一对。 我们有个服务启动时要预热本地缓存,要 40 秒。没配 readiness 的时候,新 pod 起来就被接入流量,一堆请求打到没预热完的实例上。配上探针之后,又发现 terminationGracePeriodSeconds 太短,滚动更新时旧 pod 还没处理完存量请求就被杀了。这两个参数要配对一起看,单看哪个都不对。 HPA 不能只看 CPU。 推送网关那种长连接服务,CPU 很低但连接数高,按 CPU 扩缩容等于没有。最后接了自定义指标(连接数)做 HPA,才算了结。 最花时间的一次事故:某个周四凌晨所有 pod 同时重启。查下来是节点镜像_gc 策略配置问题,kubelet 把正在用的镜像清了,拉镜像又赶上镜像仓库限流。运维同学说这叫"基础组件的雪崩",我理解了为什么老运维对"全部 egg 放一个篮子"这么警惕。 总的来说,K8s 把"部署"这个动作的门槛降低了,但把"理解系统为什么这样运行"的门槛提高了。前者是工具问题,后者是知识问题,知识没法速成。我的学习路径就一条:出了事故就去把对应的原理文档读了,比先读完文档再上手记得牢。

2023-11-16 · 芮靖安

GORM 用了两年后的一些反思

用 Go 之后一直拿 GORM 当主力 ORM,两年下来项目里大大小小的查询都过过手,这篇是阶段性的反思,有一些想法可能和主流意见不一致。 ORM 用得最爽的地方不是 CRUD,是迁移和事务。 AutoMigrate 在开发阶段确实是生产力,事务的 callback 风格也比手写 begin/commit/rollback 干净。这两块我没打算退回裸写 SQL。 但我现在很后悔的几件事: 早期放任了链式调用的滥用。 业务代码里到处是 db.Where().Where().Joins().Preload() 长链,SQL 长什么样全靠脑补。有一次一个 Preload 链带出来三千行数据,接口直接超时。现在我们要求超过两个条件的查询一律下沉到 SQL 文件(用 sqlc 管理),ORM 只处理简单的单表操作。 First 和 Take 的区别踩过坑。 First 按主键排序,表大了以后 where 条件没走索引的话性能很难看。我的原则变成了:能确定唯一性的用 Take,别让 ORM 替你排序。 hook 里有业务逻辑是灾难。 前任同事在 AfterUpdate 里发了 MQ 消息,某天这个逻辑挂了导致所有用户更新接口静默失败,排查了一晚上才找到。现在我们规定:GORM 的 hook 里只允许写与数据库直接相关的逻辑,业务副作用一律上移到 service 层。 还没想清楚的: 要不要全面转向 sqlx 或 sqlc。团队里年轻的同事喜欢 ORM 的省事,老一些的觉得 SQL 可见性更重要。目前的项目状态是"两者混用",边界靠 code review 人的自觉维持——这不健康,但也没烂到需要立刻改的程度。 技术选型这种事,最诚实的答案往往是"当前规模下都行"。真正的问题从来不是工具,是什么时候该承认现在的用法已经不对了。

2023-03-08 · 芮靖安

用 Go 重写了公司的推送网关

折腾了三个月的事终于上线了:把老的推送网关从 Java 迁到了 Go。这篇记录一下过程和数据,也算给自己一个交代。 为什么要迁。 老网关是五年前写的,Netty + 一堆历史包袱,单机维持 5 万长连接就很吃力,每次大促前都要提前扩容到 20 台机器,还经常半夜被告警叫起来。不能优雅重启是压死骆驼的最后一根稻草:每次发版都断推送几分钟,运营那边意见很大。 新架构。 接入层用 Gnet 做自定义协议解析(老客户端协议不能动,这点很烦),业务逻辑层独立部署,中间用 NATS 解耦。连接状态存 Redis。选 Gnet 而不是直接用标准库 net,是因为老协议里有粘包处理和一些奇怪的字节操作,想要更细的控制粒度。 效果数据(灰度一周后的均值): 单机长连接:5 万 -> 22 万,内存占用反而从 8G 降到 3G; 发版不再断连:连接迁移方案虽然土(客户端自动重连 + 状态外置),但实测断连窗口小于 2 秒; 机器数:大促预留从 20 台降到 6 台。 goroutine 泄漏的教训这次也吃了个现场版:压测到 30 万连接的时候内存突然起飞,pprof 看下去是心跳超时后的清理 goroutine 阻塞在往一个满载的 channel 里写。改成 select 加 default 分支,接受偶尔的清理延迟,问题消失。在连接数这个量级上,任何"看起来没关系"的分配都要重新审视,一个连接 2KB 的额外开销就是 600MB。 写 Java 的老同事问我是不是 Go 天生就这么快。我的回答是:语言贡献了一部分,但更大头是老系统这些年积累的暗坑全被我们重新设计掉了。重写永远比改造爽,因为你是带着答案去写题的。

2022-10-27 · 芮靖安

Go 项目里 error 到底该怎么处理

团队接了一个新项目,定代码规范的时候在 error 处理上吵了整整一个下午。这篇文章整理一下我们最终达成的共识,以及为什么。 先说反模式。 常见的两种流派都有问题: “warp 派”:每一层都 fmt.Errorf("xxx: %w", err) 包一层,最后日志里出现五行重复的 query user: query user: query user: record not found。信息量为零,日志长度翻倍。 “裸传派”:err 原样往上传,顶层打日志的时候只知道出错了,不知道错在哪个业务环节。 我们现在的做法,说起来很朴素: 只有"当前层新增了语义"的时候才包装。 比如数据层返回 record not found,到 service 层要变成 ErrUserNotFound,这是有语义提升的,值得包。纯粹透传的调用直接 return err。 哨兵错误 + errors.Is 为主,自定义错误类型按需用。 90% 的场景一个 var ErrXxx = errors.New("xxx") 就够了,别上来就定义结构体。真需要携带上下文(比如重试次数、限流详情)再上类型断言 errors.As。 打日志和返回 error 只做一件事。 这是我们的铁律:中间层不要既打日志又往上返回,否则同一次错误在日志里出现 N 遍,定位的时候全是噪音。要么就地处理并打日志(返回 nil),要么不打日志继续上传。顶层(通常是 HTTP handler 或 cron 入口)统一打一次。 第三方库的 error 要在边界收口。 库的 error 类型不该泄漏到业务代码里,否则哪天换库就是全项目翻新。在 repository 层把 gorm 的错误翻译成领域错误,业务层只认识领域错误。 另外一个和 error 无关但经常一起争论的问题:panic 什么时候用。我们的边界也很清晰——只在程序初始化阶段(连不上数据库这种"起不来就没意义"的场景)panic,运行期的错误一律返回。以及一个共识:recover 只允许出现在最顶层的中间件里,业务代码里出现 recover 一律打回重写。 ...

2022-04-11 · 芮靖安