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 · 芮靖安

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 · 芮靖安

从线程池到 goroutine:并发思维的转变

写 Go 半年了,今天想聊聊我理解中客户端和 Go 在并发上最本质的差别。标题有点大,内容是务实的。 做移动端的时候,并发是个"危险操作":线程宝贵,要线程池;主线程神圣不可侵犯;到处想的是怎么避免并发。到了 Go 这边,第一课就是反转:goroutine 很便宜,并发是常态,要担心的是共享状态。 举一个我实际改过的例子。刚入职时我写了一个聚合接口,习惯性地写成了串行调用:先查用户信息,再查订单,再查优惠券,一个 200ms 的接口被我写成了 600ms。mentor 看了一眼说,这三个没依赖关系,用 errgroup 并发去。改完的代码大概长这样: g, ctx := errgroup.WithContext(ctx) var user User g.Go(func() error { var err error user, err = userRepo.Get(ctx, uid) return err }) var orders []Order g.Go(func() error { var err error orders, err = orderRepo.List(ctx, uid) return err }) if err := g.Wait(); err != nil { return nil, err } 这段代码现在看稀松平常,但当时给我震撼挺大的:在 OC 里做同样的事要 dispatch_group 加一堆样板,而 Go 里就几行。而且 WithContext 把"一个下游失败就取消其他请求"这件事也顺带做了。 但便宜是有代价的。 goroutine 开起来太容易,泄漏也就太容易。我见过最隐蔽的一个 bug:一个 goroutine 往无缓冲 channel 写数据,下游超时提前返回了,这个 goroutine 就永远阻塞在那里。流量一上来,内存曲线肉眼可见地往上爬。排查工具是 pprof 的 goroutine profile,看到几千个阻塞在同一个位置的 goroutine 时,那个画面我忘不了。 所以现在我的习惯是:每个 goroutine 都要有明确的退出路径。要么由 ctx 控制生命周期,要么用带缓冲的 channel 并接受可能丢数据,绝不裸开。这大概是我从线程池时代带过来、但用新方式表达的旧智慧:资源永远是有限的。

2021-09-23 · 芮靖安

写了七年移动端,我开始学 Go

去年年底提了内部转岗,从 iOS 组转到基础架构组,主要语言是 Go。今年三月正式过过去了。趁记忆新鲜,写写一个纯客户端背景的人学 Go 的真实体验,给想转的后端朋友一点参考。 语法层面:三天就敢写,三个月才敢合并。 Go 的语法是出了名的简单,关键字二十几个,我有 OC 和 Swift 的底子,看完 tour 一天就能上手写。但敢写和敢上线是两回事——前两个月我写的代码被 mentor 挑出的问题比写出来的功能还多:channel 用完不关、err 一路往上传但没人真正处理、map 并发读写直接 panic 上线。 客户端背景带来的三个思维定势: 总想找"主线程"。 客户端开发里 UI 更新必须在主线程,这个惯性让我一开始畏手畏脚。后端的世界里没有这个概念,goroutine 随便开,要担心的是别的东西。 不习惯显式的 err。 客户端习惯了异常和回调,if err != nil 的样板代码看得我密集恐惧症都犯了。后来才理解这是设计取舍:错误就是返回值的一部分,不该被藏起来。 低估了部署环境。 客户端最多适配几个 OS 版本,后端要面对的是内存、连接数、磁盘 IO,这些我以前从没操心过的东西,现在都是日常。 学习方法上最有效的一件事:mentor 让我先别看资料,直接读公司里一个成熟服务的源码,遇到不懂的再回头查。比从书上一章章啃快多了,而且读到的是"生产级的 Go"而不是"教科书式的 Go"。 转岗三个月,最大的感受是:客户端转后端,语法是最不重要的部分,真正要补的是对分布式系统的直觉。这条路还长,慢慢来。

2021-03-18 · 芮靖安