[{"content":"博客四年没写过年终小结以外的东西了，今天翻归档，看到 2019 年那篇，突然想写点什么。不是总结，更像是给自己留个锚点。\n2019 年那篇里写\u0026quot;明年想做的事：学点服务端的东西\u0026quot;。现在回头看，这个愿望以一种完全没预料到的方式实现了——不是学了点，而是整个换了赛道。2021 年转岗到基础架构，一晃四年多。\n这四年最大的变化不是技术栈，是看问题的位置。做客户端的时候，后端是一个黑盒：接口慢就是后端的问题，挂了就是后端的问题。换了位置之后才发现，\u0026ldquo;后端\u0026quot;自己也在被数据库、网络、机器、甚至别的团队的黑盒卡着。现在再看当初自己在客户端组提的那些工单，很多问题的真实答案不是\u0026quot;谁错了\u0026rdquo;，而是\u0026quot;信息在哪个环节丢掉了\u0026quot;。\n这个认知反过来让我现在的代码风格变了不少：接口里多传一个字段、日志里多打一行、文档里多写一句\u0026quot;这里为什么这样设计\u0026quot;，成本几乎为零，但可能在两年后帮某个排查问题的人省下一晚上。\n技术上的近况：今年主要精力在把推送网关的下一代架构往前推，长连接网关的协议升级是个又脏又慢的活儿，因为要兼容三年前的老客户端。有段时间挺烦的，觉得在还技术债没有成长。后来想通了——能安全地改一个跑了五年的系统，本身就是一种能力，而且这种能力只有在这种活儿里练得出来。\n下半年想捡起来的是写作。翻归档才发现 2024 年只写了两篇，2025 年到现在只有这一篇。倒不是没东西可写，是\u0026quot;要写就写完整\u0026quot;的强迫症越来越重，一篇东西反复改开头，最后烂在草稿箱。试着降低标准：写得糙一点没关系，记录本身就是价值。这篇就算个开始。\n","permalink":"https://ruijingan.pages.dev/posts/2025/2025-mid-review/","summary":"\u003cp\u003e博客四年没写过年终小结以外的东西了，今天翻归档，看到 2019 年那篇，突然想写点什么。不是总结，更像是给自己留个锚点。\u003c/p\u003e\n\u003cp\u003e2019 年那篇里写\u0026quot;明年想做的事：学点服务端的东西\u0026quot;。现在回头看，这个愿望以一种完全没预料到的方式实现了——不是学了点，而是整个换了赛道。2021 年转岗到基础架构，一晃四年多。\u003c/p\u003e\n\u003cp\u003e这四年最大的变化不是技术栈，是看问题的位置。做客户端的时候，后端是一个黑盒：接口慢就是后端的问题，挂了就是后端的问题。换了位置之后才发现，\u0026ldquo;后端\u0026quot;自己也在被数据库、网络、机器、甚至别的团队的黑盒卡着。现在再看当初自己在客户端组提的那些工单，很多问题的真实答案不是\u0026quot;谁错了\u0026rdquo;，而是\u0026quot;信息在哪个环节丢掉了\u0026quot;。\u003c/p\u003e\n\u003cp\u003e这个认知反过来让我现在的代码风格变了不少：接口里多传一个字段、日志里多打一行、文档里多写一句\u0026quot;这里为什么这样设计\u0026quot;，成本几乎为零，但可能在两年后帮某个排查问题的人省下一晚上。\u003c/p\u003e\n\u003cp\u003e技术上的近况：今年主要精力在把推送网关的下一代架构往前推，长连接网关的协议升级是个又脏又慢的活儿，因为要兼容三年前的老客户端。有段时间挺烦的，觉得在还技术债没有成长。后来想通了——\u003cstrong\u003e能安全地改一个跑了五年的系统，本身就是一种能力\u003c/strong\u003e，而且这种能力只有在这种活儿里练得出来。\u003c/p\u003e\n\u003cp\u003e下半年想捡起来的是写作。翻归档才发现 2024 年只写了两篇，2025 年到现在只有这一篇。倒不是没东西可写，是\u0026quot;要写就写完整\u0026quot;的强迫症越来越重，一篇东西反复改开头，最后烂在草稿箱。试着降低标准：写得糙一点没关系，记录本身就是价值。这篇就算个开始。\u003c/p\u003e","title":"又是一年：从客户端到服务端这几年"},{"content":"内部服务全面从 HTTP/JSON 切到 gRPC 两年了，当初写迁移方案的人里还在组的只剩我一个。趁着记忆还热，把两年里沉淀下来的经验写一篇，算是给这套选型做个中期复盘。\n选对了什么：\n强类型契约的价值被严重低估了。 proto 文件 + 代码生成让接口变更的 review 变得极其直观，破坏性变更在 CI 阶段就能拦住。两年里因为接口不一致引发的线上问题，是零。以前 HTTP/JSON 时代一年总有那么两三次。 grpc-go 的拦截器生态很好用。 我们把日志、trace、限流、panic recovery 全部收敛在 server 端拦截器里，业务代码完全不用感知。这是迁移以来工程体验提升最大的部分。 踩过的坑，按疼痛程度排序：\n默认负载均衡是\u0026quot;假\u0026quot;的。 gRPC 客户端和一条 HTTP/2 长连接通信，如果用普通的 DNS 解析 + 单连接，所有请求会打到同一个后端实例上。必须用 dns:// resolver 加上客户端的 round-robin，或者上 xDS。这个问题在我们灰度的时候表现为\u0026quot;新版本流量永远打不到新实例\u0026quot;，排查了整整两天。 keepalive 参数两端要协商好。 中间一层的 LVS 把\u0026quot;空闲\u0026quot;的 gRPC 连接静默掐掉，客户端还以为连接健在，请求 timeout 谷底式抖动。ping 间隔改到小于 LVS 的空闲超时之后解决。这种网络设备层的问题文档里不会写，只能靠抓包。 deadline 必须全链路传递。 没传 context 的下游超时设置，等于没有超时。我们后来在 lint 里加了规则，gRPC 调用不传 ctx 直接报错。 一个想法：gRPC 的成熟度曲线是陡峭的——入门半天，但进入生产要补的课（连接管理、负载均衡、可观测性）是一整个学期。如果重来一次，小团队内部系统我还是会先推荐 HTTP/JSON，等服务和调用量到了一定规模再切 gRPC，收益才盖得住成本。\n","permalink":"https://ruijingan.pages.dev/posts/2025/grpc-two-years/","summary":"\u003cp\u003e内部服务全面从 HTTP/JSON 切到 gRPC 两年了，当初写迁移方案的人里还在组的只剩我一个。趁着记忆还热，把两年里沉淀下来的经验写一篇，算是给这套选型做个中期复盘。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e选对了什么：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e强类型契约的价值被严重低估了。\u003c/strong\u003e proto 文件 + 代码生成让接口变更的 review 变得极其直观，破坏性变更在 CI 阶段就能拦住。两年里因为接口不一致引发的线上问题，是零。以前 HTTP/JSON 时代一年总有那么两三次。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003egrpc-go 的拦截器生态很好用。\u003c/strong\u003e 我们把日志、trace、限流、panic recovery 全部收敛在 server 端拦截器里，业务代码完全不用感知。这是迁移以来工程体验提升最大的部分。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e踩过的坑，按疼痛程度排序：\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e默认负载均衡是\u0026quot;假\u0026quot;的。\u003c/strong\u003e gRPC 客户端和一条 HTTP/2 长连接通信，如果用普通的 DNS 解析 + 单连接，所有请求会打到同一个后端实例上。必须用 \u003ccode\u003edns://\u003c/code\u003e resolver 加上客户端的 round-robin，或者上 xDS。这个问题在我们灰度的时候表现为\u0026quot;新版本流量永远打不到新实例\u0026quot;，排查了整整两天。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ekeepalive 参数两端要协商好。\u003c/strong\u003e 中间一层的 LVS 把\u0026quot;空闲\u0026quot;的 gRPC 连接静默掐掉，客户端还以为连接健在，请求 timeout 谷底式抖动。ping 间隔改到小于 LVS 的空闲超时之后解决。这种网络设备层的问题文档里不会写，只能靠抓包。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003edeadline 必须全链路传递。\u003c/strong\u003e 没传 context 的下游超时设置，等于没有超时。我们后来在 lint 里加了规则，gRPC 调用不传 ctx 直接报错。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e一个想法\u003c/strong\u003e：gRPC 的成熟度曲线是陡峭的——入门半天，但进入生产要补的课（连接管理、负载均衡、可观测性）是一整个学期。如果重来一次，小团队内部系统我还是会先推荐 HTTP/JSON，等服务和调用量到了一定规模再切 gRPC，收益才盖得住成本。\u003c/p\u003e","title":"gRPC 在生产环境用了两年之后"},{"content":"公司内部系统有个痛点：十几个 Go 服务各自跑着 cron，日志、监控、去重各玩各的，出了问题要去十几个服务里翻。申请专项做统一调度被拒了（理由：收益不明确），于是用了两个月的晚间时间自己撸了个最小可用版本，先在自己负责的服务里跑起来，跑通再说。这篇记录核心设计。\n需求收敛。 首先想清楚不做什么：不做秒级调度（cron 表达式到分钟足够）、不做依赖编排（任务依赖用上线时间错开解决）、不做可视化界面（CLI + 配置文件够用）。砍完之后剩下的核心就三件事：时间触发、任务分发、不重复执行。\n关键设计是抢锁。 多实例部署下同一个任务只能有一个实例执行，我们没有引入额外的调度中心，而是直接用 MySQL 的行锁：\n-- t_job 表，每分钟由各实例尝试 CAS UPDATE job_lock SET owner = ?, locked_until = NOW() + INTERVAL 2 MINUTE WHERE job_name = ? AND (locked_until \u0026lt; NOW() OR owner = ?); 抢到锁的实例负责执行并把心跳写进去，锁过期自然释放。整个方案只依赖已经有的 MySQL，没有新组件。有人会问为什么不用 Redis：因为任务幂等性出错的代价是重复扣库存，而 MySQL 事务的可靠性在我们团队是经过验证的，不熟的东西不引入。\n分片是第二个关键。 单机任务量上来之后，把任务按 jobName 的 hash % 实例数 分片，每个实例只认领自己的那份。简单，但配合上面的锁，二十万级任务/天的量完全够。\n踩的坑：时钟漂移。有两台机器的 NTP 没配好，相差 40 秒，导致任务在整点附近被执行了两次。锁的有效期改成大于任务最长执行时间 + 漂移容忍之后解决。现在新机器进集群先校时，成了硬性流程。\n现在这个调度器稳定跑了三个月，另外两个组也接进来了。回头看，\u0026ldquo;先做一个丑但能跑的版本，让收益自己说话\u0026quot;这个策略是对的——下个季度统一调度的专项预算，批了。\n","permalink":"https://ruijingan.pages.dev/posts/2024/distributed-cron/","summary":"\u003cp\u003e公司内部系统有个痛点：十几个 Go 服务各自跑着 cron，日志、监控、去重各玩各的，出了问题要去十几个服务里翻。申请专项做统一调度被拒了（理由：收益不明确），于是用了两个月的晚间时间自己撸了个最小可用版本，先在自己负责的服务里跑起来，跑通再说。这篇记录核心设计。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e需求收敛。\u003c/strong\u003e 首先想清楚不做什么：不做秒级调度（cron 表达式到分钟足够）、不做依赖编排（任务依赖用上线时间错开解决）、不做可视化界面（CLI + 配置文件够用）。砍完之后剩下的核心就三件事：\u003cstrong\u003e时间触发、任务分发、不重复执行\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e关键设计是抢锁。\u003c/strong\u003e 多实例部署下同一个任务只能有一个实例执行，我们没有引入额外的调度中心，而是直接用 MySQL 的行锁：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-sql\" data-lang=\"sql\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#75715e\"\u003e-- t_job 表，每分钟由各实例尝试 CAS\n\u003c/span\u003e\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eUPDATE\u003c/span\u003e job_lock\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eSET\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eowner\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e, locked_until \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e NOW() \u003cspan style=\"color:#f92672\"\u003e+\u003c/span\u003e INTERVAL \u003cspan style=\"color:#ae81ff\"\u003e2\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eMINUTE\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eWHERE\u003c/span\u003e job_name \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e  \u003cspan style=\"color:#66d9ef\"\u003eAND\u003c/span\u003e (locked_until \u003cspan style=\"color:#f92672\"\u003e\u0026lt;\u003c/span\u003e NOW() \u003cspan style=\"color:#66d9ef\"\u003eOR\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eowner\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e=\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e?\u003c/span\u003e);\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e抢到锁的实例负责执行并把心跳写进去，锁过期自然释放。整个方案只依赖已经有的 MySQL，没有新组件。有人会问为什么不用 Redis：因为任务幂等性出错的代价是重复扣库存，而 MySQL 事务的可靠性在我们团队是经过验证的，不熟的东西不引入。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e分片是第二个关键。\u003c/strong\u003e 单机任务量上来之后，把任务按 \u003ccode\u003ejobName 的 hash % 实例数\u003c/code\u003e 分片，每个实例只认领自己的那份。简单，但配合上面的锁，二十万级任务/天的量完全够。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e踩的坑\u003c/strong\u003e：时钟漂移。有两台机器的 NTP 没配好，相差 40 秒，导致任务在整点附近被执行了两次。锁的有效期改成大于任务最长执行时间 + 漂移容忍之后解决。现在新机器进集群先校时，成了硬性流程。\u003c/p\u003e\n\u003cp\u003e现在这个调度器稳定跑了三个月，另外两个组也接进来了。回头看，\u0026ldquo;先做一个丑但能跑的版本，让收益自己说话\u0026quot;这个策略是对的——下个季度统一调度的专项预算，批了。\u003c/p\u003e","title":"手撸一个简易分布式定时任务的记录"},{"content":"Go 1.18 出泛型的时候，团队讨论过要不要用，当时的结论是观望。去年初我们把最低版本升到 1.21 之后，终于把泛型放进了代码规范，允许使用。一年下来，谈谈真实感受。\n泛型真正帮到我们的地方，比想象中窄。 一年下来高频使用的场景其实就三类：\n容器类工具函数。Map、Filter、Contains 这些，以前用 interface{} + 反射写，又慢又不安全，现在干净多了。 类型安全的并发原语。我们自己封了个 sync.Map 的泛型包装，key 和 value 的类型在编译期就确定，用起来踏实。 数据访问层的通用分页查询，一个 func Query[T any](...) 替掉了以前代码生成器吐出来的几十行重复代码。 但有些地方我们明确禁止用泛型：\n业务结构体不搞泛型。 Service[T]、Repo[T] 这种\u0026quot;框架感\u0026quot;的东西一旦放出来，业务代码的可读性会断崖式下跌。领域模型就该是具体的，UserRepo 比 Repo[User] 好读得多。 能用接口解决的不要用泛型。 泛型和接口最大的区别是：接口是运行时多态，泛型是编译期展开。大部分业务场景需要的是前者。 一个观察：团队里泛型使用量最大的，是以前用 interface{} 最多的那批代码。这说明泛型在 Go 里解决的主要不是\u0026quot;表达力\u0026quot;问题，而是\u0026quot;以前只能靠反射硬扛\u0026quot;的那部分代码的类型安全问题。从这个角度看，泛型是改良，不是革命。\n最后记录一个实用主义的心得：升级到 1.21 之后 min/max 内置函数直接删掉了我们自己写的工具函数，这种小快乐积累起来，也是升级的意义。\n","permalink":"https://ruijingan.pages.dev/posts/2024/go-generics-one-year/","summary":"\u003cp\u003eGo 1.18 出泛型的时候，团队讨论过要不要用，当时的结论是观望。去年初我们把最低版本升到 1.21 之后，终于把泛型放进了代码规范，允许使用。一年下来，谈谈真实感受。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e泛型真正帮到我们的地方，比想象中窄。\u003c/strong\u003e 一年下来高频使用的场景其实就三类：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e容器类工具函数。\u003ccode\u003eMap\u003c/code\u003e、\u003ccode\u003eFilter\u003c/code\u003e、\u003ccode\u003eContains\u003c/code\u003e 这些，以前用 \u003ccode\u003einterface{}\u003c/code\u003e + 反射写，又慢又不安全，现在干净多了。\u003c/li\u003e\n\u003cli\u003e类型安全的并发原语。我们自己封了个 \u003ccode\u003esync.Map\u003c/code\u003e 的泛型包装，key 和 value 的类型在编译期就确定，用起来踏实。\u003c/li\u003e\n\u003cli\u003e数据访问层的通用分页查询，一个 \u003ccode\u003efunc Query[T any](...)\u003c/code\u003e 替掉了以前代码生成器吐出来的几十行重复代码。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e但有些地方我们明确禁止用泛型：\u003c/strong\u003e\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003e业务结构体不搞泛型。\u003c/strong\u003e \u003ccode\u003eService[T]\u003c/code\u003e、\u003ccode\u003eRepo[T]\u003c/code\u003e 这种\u0026quot;框架感\u0026quot;的东西一旦放出来，业务代码的可读性会断崖式下跌。领域模型就该是具体的，\u003ccode\u003eUserRepo\u003c/code\u003e 比 \u003ccode\u003eRepo[User]\u003c/code\u003e 好读得多。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e能用接口解决的不要用泛型。\u003c/strong\u003e 泛型和接口最大的区别是：接口是运行时多态，泛型是编译期展开。大部分业务场景需要的是前者。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e一个观察：团队里泛型使用量最大的，是以前用 \u003ccode\u003einterface{}\u003c/code\u003e 最多的那批代码。这说明泛型在 Go 里解决的主要不是\u0026quot;表达力\u0026quot;问题，而是\u0026quot;以前只能靠反射硬扛\u0026quot;的那部分代码的类型安全问题。从这个角度看，泛型是改良，不是革命。\u003c/p\u003e\n\u003cp\u003e最后记录一个实用主义的心得：升级到 1.21 之后 \u003ccode\u003emin\u003c/code\u003e/\u003ccode\u003emax\u003c/code\u003e 内置函数直接删掉了我们自己写的工具函数，这种小快乐积累起来，也是升级的意义。\u003c/p\u003e","title":"Go 泛型用了一年，说说真实感受"},{"content":"今年公司的基础设施从自建机房迁到了云上的 K8s，运维的活儿有相当一部分落到我们组头上。作为一个写了十年代码没怎么管过机器的人，这半年补了很多课，挑几个印象深的记下来。\n资源 requests/limits 别乱填。 上线前凭感觉给服务配了 limits：内存 4G、CPU 2 核。结果高峰期 CPU 被 throttle，接口 P99 从 80ms 涨到 400ms，监控上还看不到任何异常——throttle 不报错，就是慢。后来用 Grafana 盯着 CPU throttling 指标重新校准了所有服务的配额。教训：limits 配小了比不配还危险，因为故障形态是\u0026quot;变慢\u0026quot;而不是\u0026quot;报错\u0026quot;，最难排查。\n滚动更新的 maxSurge 和 readiness 探针是一对。 我们有个服务启动时要预热本地缓存，要 40 秒。没配 readiness 的时候，新 pod 起来就被接入流量，一堆请求打到没预热完的实例上。配上探针之后，又发现 terminationGracePeriodSeconds 太短，滚动更新时旧 pod 还没处理完存量请求就被杀了。这两个参数要配对一起看，单看哪个都不对。\nHPA 不能只看 CPU。 推送网关那种长连接服务，CPU 很低但连接数高，按 CPU 扩缩容等于没有。最后接了自定义指标（连接数）做 HPA，才算了结。\n最花时间的一次事故：某个周四凌晨所有 pod 同时重启。查下来是节点镜像_gc 策略配置问题，kubelet 把正在用的镜像清了，拉镜像又赶上镜像仓库限流。运维同学说这叫\u0026quot;基础组件的雪崩\u0026quot;，我理解了为什么老运维对\u0026quot;全部 egg 放一个篮子\u0026quot;这么警惕。\n总的来说，K8s 把\u0026quot;部署\u0026quot;这个动作的门槛降低了，但把\u0026quot;理解系统为什么这样运行\u0026quot;的门槛提高了。前者是工具问题，后者是知识问题，知识没法速成。我的学习路径就一条：出了事故就去把对应的原理文档读了，比先读完文档再上手记得牢。\n","permalink":"https://ruijingan.pages.dev/posts/2023/k8s-first-lessons/","summary":"\u003cp\u003e今年公司的基础设施从自建机房迁到了云上的 K8s，运维的活儿有相当一部分落到我们组头上。作为一个写了十年代码没怎么管过机器的人，这半年补了很多课，挑几个印象深的记下来。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e资源 requests/limits 别乱填。\u003c/strong\u003e 上线前凭感觉给服务配了 limits：内存 4G、CPU 2 核。结果高峰期 CPU 被 throttle，接口 P99 从 80ms 涨到 400ms，监控上还看不到任何异常——throttle 不报错，就是慢。后来用 Grafana 盯着 CPU throttling 指标重新校准了所有服务的配额。教训：\u003cstrong\u003elimits 配小了比不配还危险，因为故障形态是\u0026quot;变慢\u0026quot;而不是\u0026quot;报错\u0026quot;\u003c/strong\u003e，最难排查。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e滚动更新的 maxSurge 和 readiness 探针是一对。\u003c/strong\u003e 我们有个服务启动时要预热本地缓存，要 40 秒。没配 readiness 的时候，新 pod 起来就被接入流量，一堆请求打到没预热完的实例上。配上探针之后，又发现 terminationGracePeriodSeconds 太短，滚动更新时旧 pod 还没处理完存量请求就被杀了。这两个参数要配对一起看，单看哪个都不对。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eHPA 不能只看 CPU。\u003c/strong\u003e 推送网关那种长连接服务，CPU 很低但连接数高，按 CPU 扩缩容等于没有。最后接了自定义指标（连接数）做 HPA，才算了结。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e最花时间的一次事故\u003c/strong\u003e：某个周四凌晨所有 pod 同时重启。查下来是节点镜像_gc 策略配置问题，kubelet 把正在用的镜像清了，拉镜像又赶上镜像仓库限流。运维同学说这叫\u0026quot;基础组件的雪崩\u0026quot;，我理解了为什么老运维对\u0026quot;全部 egg 放一个篮子\u0026quot;这么警惕。\u003c/p\u003e\n\u003cp\u003e总的来说，K8s 把\u0026quot;部署\u0026quot;这个动作的门槛降低了，但把\u0026quot;理解系统为什么这样运行\u0026quot;的门槛提高了。前者是工具问题，后者是知识问题，知识没法速成。我的学习路径就一条：出了事故就去把对应的原理文档读了，比先读完文档再上手记得牢。\u003c/p\u003e","title":"第一次正经管 Kubernetes 的一些坑"},{"content":"用 Go 之后一直拿 GORM 当主力 ORM，两年下来项目里大大小小的查询都过过手，这篇是阶段性的反思，有一些想法可能和主流意见不一致。\nORM 用得最爽的地方不是 CRUD，是迁移和事务。 AutoMigrate 在开发阶段确实是生产力，事务的 callback 风格也比手写 begin/commit/rollback 干净。这两块我没打算退回裸写 SQL。\n但我现在很后悔的几件事：\n早期放任了链式调用的滥用。 业务代码里到处是 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 可见性更重要。目前的项目状态是\u0026quot;两者混用\u0026quot;，边界靠 code review 人的自觉维持——这不健康，但也没烂到需要立刻改的程度。\n技术选型这种事，最诚实的答案往往是\u0026quot;当前规模下都行\u0026quot;。真正的问题从来不是工具，是什么时候该承认现在的用法已经不对了。\n","permalink":"https://ruijingan.pages.dev/posts/2023/gorm-reflection/","summary":"\u003cp\u003e用 Go 之后一直拿 GORM 当主力 ORM，两年下来项目里大大小小的查询都过过手，这篇是阶段性的反思，有一些想法可能和主流意见不一致。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eORM 用得最爽的地方不是 CRUD，是迁移和事务。\u003c/strong\u003e AutoMigrate 在开发阶段确实是生产力，事务的 callback 风格也比手写 begin/commit/rollback 干净。这两块我没打算退回裸写 SQL。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e但我现在很后悔的几件事：\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e早期放任了链式调用的滥用。\u003c/strong\u003e 业务代码里到处是 \u003ccode\u003edb.Where().Where().Joins().Preload()\u003c/code\u003e 长链，SQL 长什么样全靠脑补。有一次一个 Preload 链带出来三千行数据，接口直接超时。现在我们要求超过两个条件的查询一律下沉到 SQL 文件（用 sqlc 管理），ORM 只处理简单的单表操作。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e\u003ccode\u003eFirst\u003c/code\u003e 和 \u003ccode\u003eTake\u003c/code\u003e 的区别踩过坑。\u003c/strong\u003e \u003ccode\u003eFirst\u003c/code\u003e 按主键排序，表大了以后 where 条件没走索引的话性能很难看。我的原则变成了：能确定唯一性的用 \u003ccode\u003eTake\u003c/code\u003e，别让 ORM 替你排序。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003ehook 里有业务逻辑是灾难。\u003c/strong\u003e 前任同事在 \u003ccode\u003eAfterUpdate\u003c/code\u003e 里发了 MQ 消息，某天这个逻辑挂了导致所有用户更新接口静默失败，排查了一晚上才找到。现在我们规定：GORM 的 hook 里只允许写与数据库直接相关的逻辑，业务副作用一律上移到 service 层。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e还没想清楚的：\u003c/strong\u003e 要不要全面转向 sqlx 或 sqlc。团队里年轻的同事喜欢 ORM 的省事，老一些的觉得 SQL 可见性更重要。目前的项目状态是\u0026quot;两者混用\u0026quot;，边界靠 code review 人的自觉维持——这不健康，但也没烂到需要立刻改的程度。\u003c/p\u003e\n\u003cp\u003e技术选型这种事，最诚实的答案往往是\u0026quot;当前规模下都行\u0026quot;。真正的问题从来不是工具，是什么时候该承认现在的用法已经不对了。\u003c/p\u003e","title":"GORM 用了两年后的一些反思"},{"content":"折腾了三个月的事终于上线了：把老的推送网关从 Java 迁到了 Go。这篇记录一下过程和数据，也算给自己一个交代。\n为什么要迁。 老网关是五年前写的，Netty + 一堆历史包袱，单机维持 5 万长连接就很吃力，每次大促前都要提前扩容到 20 台机器，还经常半夜被告警叫起来。不能优雅重启是压死骆驼的最后一根稻草：每次发版都断推送几分钟，运营那边意见很大。\n新架构。 接入层用 Gnet 做自定义协议解析（老客户端协议不能动，这点很烦），业务逻辑层独立部署，中间用 NATS 解耦。连接状态存 Redis。选 Gnet 而不是直接用标准库 net，是因为老协议里有粘包处理和一些奇怪的字节操作，想要更细的控制粒度。\n效果数据（灰度一周后的均值）：\n单机长连接：5 万 -\u0026gt; 22 万，内存占用反而从 8G 降到 3G； 发版不再断连：连接迁移方案虽然土（客户端自动重连 + 状态外置），但实测断连窗口小于 2 秒； 机器数：大促预留从 20 台降到 6 台。 goroutine 泄漏的教训这次也吃了个现场版：压测到 30 万连接的时候内存突然起飞，pprof 看下去是心跳超时后的清理 goroutine 阻塞在往一个满载的 channel 里写。改成 select 加 default 分支，接受偶尔的清理延迟，问题消失。在连接数这个量级上，任何\u0026quot;看起来没关系\u0026quot;的分配都要重新审视，一个连接 2KB 的额外开销就是 600MB。\n写 Java 的老同事问我是不是 Go 天生就这么快。我的回答是：语言贡献了一部分，但更大头是老系统这些年积累的暗坑全被我们重新设计掉了。重写永远比改造爽，因为你是带着答案去写题的。\n","permalink":"https://ruijingan.pages.dev/posts/2022/push-gateway-rewrite/","summary":"\u003cp\u003e折腾了三个月的事终于上线了：把老的推送网关从 Java 迁到了 Go。这篇记录一下过程和数据，也算给自己一个交代。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e为什么要迁。\u003c/strong\u003e 老网关是五年前写的，Netty + 一堆历史包袱，单机维持 5 万长连接就很吃力，每次大促前都要提前扩容到 20 台机器，还经常半夜被告警叫起来。不能优雅重启是压死骆驼的最后一根稻草：每次发版都断推送几分钟，运营那边意见很大。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e新架构。\u003c/strong\u003e 接入层用 Gnet 做自定义协议解析（老客户端协议不能动，这点很烦），业务逻辑层独立部署，中间用 NATS 解耦。连接状态存 Redis。选 Gnet 而不是直接用标准库 net，是因为老协议里有粘包处理和一些奇怪的字节操作，想要更细的控制粒度。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e效果数据\u003c/strong\u003e（灰度一周后的均值）：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e单机长连接：5 万 -\u0026gt; 22 万，内存占用反而从 8G 降到 3G；\u003c/li\u003e\n\u003cli\u003e发版不再断连：连接迁移方案虽然土（客户端自动重连 + 状态外置），但实测断连窗口小于 2 秒；\u003c/li\u003e\n\u003cli\u003e机器数：大促预留从 20 台降到 6 台。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003egoroutine 泄漏的教训这次也吃了个现场版：压测到 30 万连接的时候内存突然起飞，pprof 看下去是心跳超时后的清理 goroutine 阻塞在往一个满载的 channel 里写。改成 \u003ccode\u003eselect\u003c/code\u003e 加 default 分支，接受偶尔的清理延迟，问题消失。\u003cstrong\u003e在连接数这个量级上，任何\u0026quot;看起来没关系\u0026quot;的分配都要重新审视\u003c/strong\u003e，一个连接 2KB 的额外开销就是 600MB。\u003c/p\u003e\n\u003cp\u003e写 Java 的老同事问我是不是 Go 天生就这么快。我的回答是：语言贡献了一部分，但更大头是老系统这些年积累的暗坑全被我们重新设计掉了。重写永远比改造爽，因为你是带着答案去写题的。\u003c/p\u003e","title":"用 Go 重写了公司的推送网关"},{"content":"团队接了一个新项目，定代码规范的时候在 error 处理上吵了整整一个下午。这篇文章整理一下我们最终达成的共识，以及为什么。\n先说反模式。 常见的两种流派都有问题：\n\u0026ldquo;warp 派\u0026rdquo;：每一层都 fmt.Errorf(\u0026quot;xxx: %w\u0026quot;, err) 包一层，最后日志里出现五行重复的 query user: query user: query user: record not found。信息量为零，日志长度翻倍。 \u0026ldquo;裸传派\u0026rdquo;：err 原样往上传，顶层打日志的时候只知道出错了，不知道错在哪个业务环节。 我们现在的做法，说起来很朴素：\n只有\u0026quot;当前层新增了语义\u0026quot;的时候才包装。 比如数据层返回 record not found，到 service 层要变成 ErrUserNotFound，这是有语义提升的，值得包。纯粹透传的调用直接 return err。 哨兵错误 + errors.Is 为主，自定义错误类型按需用。 90% 的场景一个 var ErrXxx = errors.New(\u0026quot;xxx\u0026quot;) 就够了，别上来就定义结构体。真需要携带上下文（比如重试次数、限流详情）再上类型断言 errors.As。 打日志和返回 error 只做一件事。 这是我们的铁律：中间层不要既打日志又往上返回，否则同一次错误在日志里出现 N 遍，定位的时候全是噪音。要么就地处理并打日志（返回 nil），要么不打日志继续上传。顶层（通常是 HTTP handler 或 cron 入口）统一打一次。 第三方库的 error 要在边界收口。 库的 error 类型不该泄漏到业务代码里，否则哪天换库就是全项目翻新。在 repository 层把 gorm 的错误翻译成领域错误，业务层只认识领域错误。 另外一个和 error 无关但经常一起争论的问题：panic 什么时候用。我们的边界也很清晰——只在程序初始化阶段（连不上数据库这种\u0026quot;起不来就没意义\u0026quot;的场景）panic，运行期的错误一律返回。以及一个共识：recover 只允许出现在最顶层的中间件里，业务代码里出现 recover 一律打回重写。\n规范本身不重要，团队认账才重要。吵一下午是值得的。\n","permalink":"https://ruijingan.pages.dev/posts/2022/go-error-handling/","summary":"\u003cp\u003e团队接了一个新项目，定代码规范的时候在 error 处理上吵了整整一个下午。这篇文章整理一下我们最终达成的共识，以及为什么。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e先说反模式。\u003c/strong\u003e 常见的两种流派都有问题：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e\u0026ldquo;warp 派\u0026rdquo;：每一层都 \u003ccode\u003efmt.Errorf(\u0026quot;xxx: %w\u0026quot;, err)\u003c/code\u003e 包一层，最后日志里出现五行重复的 \u003ccode\u003equery user: query user: query user: record not found\u003c/code\u003e。信息量为零，日志长度翻倍。\u003c/li\u003e\n\u003cli\u003e\u0026ldquo;裸传派\u0026rdquo;：err 原样往上传，顶层打日志的时候只知道出错了，不知道错在哪个业务环节。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e\u003cstrong\u003e我们现在的做法\u003c/strong\u003e，说起来很朴素：\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e只有\u0026quot;当前层新增了语义\u0026quot;的时候才包装。\u003c/strong\u003e 比如数据层返回 \u003ccode\u003erecord not found\u003c/code\u003e，到 service 层要变成 \u003ccode\u003eErrUserNotFound\u003c/code\u003e，这是有语义提升的，值得包。纯粹透传的调用直接 \u003ccode\u003ereturn err\u003c/code\u003e。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e哨兵错误 + errors.Is 为主，自定义错误类型按需用。\u003c/strong\u003e 90% 的场景一个 \u003ccode\u003evar ErrXxx = errors.New(\u0026quot;xxx\u0026quot;)\u003c/code\u003e 就够了，别上来就定义结构体。真需要携带上下文（比如重试次数、限流详情）再上类型断言 \u003ccode\u003eerrors.As\u003c/code\u003e。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e打日志和返回 error 只做一件事。\u003c/strong\u003e 这是我们的铁律：中间层不要既打日志又往上返回，否则同一次错误在日志里出现 N 遍，定位的时候全是噪音。要么就地处理并打日志（返回 nil），要么不打日志继续上传。顶层（通常是 HTTP handler 或 cron 入口）统一打一次。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e第三方库的 error 要在边界收口。\u003c/strong\u003e 库的 error 类型不该泄漏到业务代码里，否则哪天换库就是全项目翻新。在 repository 层把 gorm 的错误翻译成领域错误，业务层只认识领域错误。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e另外一个和 error 无关但经常一起争论的问题：panic 什么时候用。我们的边界也很清晰——只在程序初始化阶段（连不上数据库这种\u0026quot;起不来就没意义\u0026quot;的场景）panic，运行期的错误一律返回。以及一个共识：\u003ccode\u003erecover\u003c/code\u003e 只允许出现在最顶层的中间件里，业务代码里出现 recover 一律打回重写。\u003c/p\u003e","title":"Go 项目里 error 到底该怎么处理"},{"content":"写 Go 半年了，今天想聊聊我理解中客户端和 Go 在并发上最本质的差别。标题有点大，内容是务实的。\n做移动端的时候，并发是个\u0026quot;危险操作\u0026quot;：线程宝贵，要线程池；主线程神圣不可侵犯；到处想的是怎么避免并发。到了 Go 这边，第一课就是反转：goroutine 很便宜，并发是常态，要担心的是共享状态。\n举一个我实际改过的例子。刚入职时我写了一个聚合接口，习惯性地写成了串行调用：先查用户信息，再查订单，再查优惠券，一个 200ms 的接口被我写成了 600ms。mentor 看了一眼说，这三个没依赖关系，用 errgroup 并发去。改完的代码大概长这样：\ng, 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 把\u0026quot;一个下游失败就取消其他请求\u0026quot;这件事也顺带做了。\n但便宜是有代价的。 goroutine 开起来太容易，泄漏也就太容易。我见过最隐蔽的一个 bug：一个 goroutine 往无缓冲 channel 写数据，下游超时提前返回了，这个 goroutine 就永远阻塞在那里。流量一上来，内存曲线肉眼可见地往上爬。排查工具是 pprof 的 goroutine profile，看到几千个阻塞在同一个位置的 goroutine 时，那个画面我忘不了。\n所以现在我的习惯是：每个 goroutine 都要有明确的退出路径。要么由 ctx 控制生命周期，要么用带缓冲的 channel 并接受可能丢数据，绝不裸开。这大概是我从线程池时代带过来、但用新方式表达的旧智慧：资源永远是有限的。\n","permalink":"https://ruijingan.pages.dev/posts/2021/goroutine-mindset/","summary":"\u003cp\u003e写 Go 半年了，今天想聊聊我理解中客户端和 Go 在并发上最本质的差别。标题有点大，内容是务实的。\u003c/p\u003e\n\u003cp\u003e做移动端的时候，并发是个\u0026quot;危险操作\u0026quot;：线程宝贵，要线程池；主线程神圣不可侵犯；到处想的是怎么避免并发。到了 Go 这边，第一课就是反转：\u003cstrong\u003egoroutine 很便宜，并发是常态，要担心的是共享状态\u003c/strong\u003e。\u003c/p\u003e\n\u003cp\u003e举一个我实际改过的例子。刚入职时我写了一个聚合接口，习惯性地写成了串行调用：先查用户信息，再查订单，再查优惠券，一个 200ms 的接口被我写成了 600ms。mentor 看了一眼说，这三个没依赖关系，用 errgroup 并发去。改完的代码大概长这样：\u003c/p\u003e\n\u003cdiv class=\"highlight\"\u003e\u003cpre tabindex=\"0\" style=\"color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;\"\u003e\u003ccode class=\"language-go\" data-lang=\"go\"\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eg\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerrgroup\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eWithContext\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003evar\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003euser\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eUser\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eg\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eGo\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003eerror\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003evar\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eerror\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003euser\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e = \u003cspan style=\"color:#a6e22e\"\u003euserRepo\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eGet\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003euid\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e})\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003evar\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eorders\u003c/span\u003e []\u003cspan style=\"color:#a6e22e\"\u003eOrder\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#a6e22e\"\u003eg\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eGo\u003c/span\u003e(\u003cspan style=\"color:#66d9ef\"\u003efunc\u003c/span\u003e() \u003cspan style=\"color:#66d9ef\"\u003eerror\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003evar\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003eerror\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#a6e22e\"\u003eorders\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e = \u003cspan style=\"color:#a6e22e\"\u003eorderRepo\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eList\u003c/span\u003e(\u003cspan style=\"color:#a6e22e\"\u003ectx\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003euid\u003c/span\u003e)\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e})\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e\u003cspan style=\"color:#66d9ef\"\u003eif\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e:=\u003c/span\u003e \u003cspan style=\"color:#a6e22e\"\u003eg\u003c/span\u003e.\u003cspan style=\"color:#a6e22e\"\u003eWait\u003c/span\u003e(); \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e \u003cspan style=\"color:#f92672\"\u003e!=\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e {\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e    \u003cspan style=\"color:#66d9ef\"\u003ereturn\u003c/span\u003e \u003cspan style=\"color:#66d9ef\"\u003enil\u003c/span\u003e, \u003cspan style=\"color:#a6e22e\"\u003eerr\u003c/span\u003e\n\u003c/span\u003e\u003c/span\u003e\u003cspan style=\"display:flex;\"\u003e\u003cspan\u003e}\n\u003c/span\u003e\u003c/span\u003e\u003c/code\u003e\u003c/pre\u003e\u003c/div\u003e\u003cp\u003e这段代码现在看稀松平常，但当时给我震撼挺大的：在 OC 里做同样的事要 dispatch_group 加一堆样板，而 Go 里就几行。而且 \u003ccode\u003eWithContext\u003c/code\u003e 把\u0026quot;一个下游失败就取消其他请求\u0026quot;这件事也顺带做了。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e但便宜是有代价的。\u003c/strong\u003e goroutine 开起来太容易，泄漏也就太容易。我见过最隐蔽的一个 bug：一个 goroutine 往无缓冲 channel 写数据，下游超时提前返回了，这个 goroutine 就永远阻塞在那里。流量一上来，内存曲线肉眼可见地往上爬。排查工具是 pprof 的 goroutine profile，看到几千个阻塞在同一个位置的 goroutine 时，那个画面我忘不了。\u003c/p\u003e\n\u003cp\u003e所以现在我的习惯是：\u003cstrong\u003e每个 goroutine 都要有明确的退出路径\u003c/strong\u003e。要么由 ctx 控制生命周期，要么用带缓冲的 channel 并接受可能丢数据，绝不裸开。这大概是我从线程池时代带过来、但用新方式表达的旧智慧：资源永远是有限的。\u003c/p\u003e","title":"从线程池到 goroutine：并发思维的转变"},{"content":"去年年底提了内部转岗，从 iOS 组转到基础架构组，主要语言是 Go。今年三月正式过过去了。趁记忆新鲜，写写一个纯客户端背景的人学 Go 的真实体验，给想转的后端朋友一点参考。\n语法层面：三天就敢写，三个月才敢合并。 Go 的语法是出了名的简单，关键字二十几个，我有 OC 和 Swift 的底子，看完 tour 一天就能上手写。但敢写和敢上线是两回事——前两个月我写的代码被 mentor 挑出的问题比写出来的功能还多：channel 用完不关、err 一路往上传但没人真正处理、map 并发读写直接 panic 上线。\n客户端背景带来的三个思维定势：\n总想找\u0026quot;主线程\u0026quot;。 客户端开发里 UI 更新必须在主线程，这个惯性让我一开始畏手畏脚。后端的世界里没有这个概念，goroutine 随便开，要担心的是别的东西。 不习惯显式的 err。 客户端习惯了异常和回调，if err != nil 的样板代码看得我密集恐惧症都犯了。后来才理解这是设计取舍：错误就是返回值的一部分，不该被藏起来。 低估了部署环境。 客户端最多适配几个 OS 版本，后端要面对的是内存、连接数、磁盘 IO，这些我以前从没操心过的东西，现在都是日常。 学习方法上最有效的一件事：mentor 让我先别看资料，直接读公司里一个成熟服务的源码，遇到不懂的再回头查。比从书上一章章啃快多了，而且读到的是\u0026quot;生产级的 Go\u0026quot;而不是\u0026quot;教科书式的 Go\u0026quot;。\n转岗三个月，最大的感受是：客户端转后端，语法是最不重要的部分，真正要补的是对分布式系统的直觉。这条路还长，慢慢来。\n","permalink":"https://ruijingan.pages.dev/posts/2021/start-learning-go/","summary":"\u003cp\u003e去年年底提了内部转岗，从 iOS 组转到基础架构组，主要语言是 Go。今年三月正式过过去了。趁记忆新鲜，写写一个纯客户端背景的人学 Go 的真实体验，给想转的后端朋友一点参考。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e语法层面：三天就敢写，三个月才敢合并。\u003c/strong\u003e Go 的语法是出了名的简单，关键字二十几个，我有 OC 和 Swift 的底子，看完 tour 一天就能上手写。但敢写和敢上线是两回事——前两个月我写的代码被 mentor 挑出的问题比写出来的功能还多：channel 用完不关、err 一路往上传但没人真正处理、map 并发读写直接 panic 上线。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e客户端背景带来的三个思维定势：\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003cstrong\u003e总想找\u0026quot;主线程\u0026quot;。\u003c/strong\u003e 客户端开发里 UI 更新必须在主线程，这个惯性让我一开始畏手畏脚。后端的世界里没有这个概念，goroutine 随便开，要担心的是别的东西。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e不习惯显式的 err。\u003c/strong\u003e 客户端习惯了异常和回调，\u003ccode\u003eif err != nil\u003c/code\u003e 的样板代码看得我密集恐惧症都犯了。后来才理解这是设计取舍：错误就是返回值的一部分，不该被藏起来。\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003e低估了部署环境。\u003c/strong\u003e 客户端最多适配几个 OS 版本，后端要面对的是内存、连接数、磁盘 IO，这些我以前从没操心过的东西，现在都是日常。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e学习方法上最有效的一件事\u003c/strong\u003e：mentor 让我先别看资料，直接读公司里一个成熟服务的源码，遇到不懂的再回头查。比从书上一章章啃快多了，而且读到的是\u0026quot;生产级的 Go\u0026quot;而不是\u0026quot;教科书式的 Go\u0026quot;。\u003c/p\u003e\n\u003cp\u003e转岗三个月，最大的感受是：客户端转后端，语法是最不重要的部分，真正要补的是对分布式系统的直觉。这条路还长，慢慢来。\u003c/p\u003e","title":"写了七年移动端，我开始学 Go"},{"content":"年初组里立了一个新项目，预算紧、工期紧，老板拍板用 Flutter。作为一个只写过原生的人，半年下来有了一些体感，写下来给同样背景的朋友参考。\n先说好的。 开发效率是真的高，热重载这个体验一旦用上就回不去。UI 还原度也不错，设计师给的稿子基本一比一还原，而且 iOS / Android 两端的差异比我想象中小。我们一个三人小组，半年做了两个业务模块 + 一套通用组件库，换原生至少要五个人。\n再说别扭的。\n混合工程的路由和生命周期管理是重灾区。我们用 flutter_boost 做混合栈，内存占用比纯原生页面高不少，低端机上页面切换能感觉到掉帧。官方对混合开发的支持一直不算稳定，升级 Flutter 版本的时候 flutter_boost 兼容性经常掉链子。 Dart 语言本身没毛病，但生态里的包质量参差不齐。有个下拉刷新的库，作者弃坑半年，issue 堆了几百个没人回，最后我们自己 fork 修的。 崩溃排查体验比原生差。符号表一多，Crash 堆栈经常对不上，需要自己维护一套符号映射的流程，这块我们还在摸索。 我的整体判断：新项目、纯 UI 展示型页面，Flutter 是划算的；但如果是重交互、深度依赖系统能力的 App，混合开发的复杂度会吃掉你在开发效率上省下来的时间。\n下半年项目主体功能交付了，我大概率会回去继续搞 iOS 组件化的收尾。Flutter 的知识先放着，看它后续发展吧。个人感觉跨端这个方向，五年内不会有终局，工具会一直换，但\u0026quot;用一套代码换两端\u0026quot;的需求是永恒的。\n","permalink":"https://ruijingan.pages.dev/posts/2020/flutter-half-year/","summary":"\u003cp\u003e年初组里立了一个新项目，预算紧、工期紧，老板拍板用 Flutter。作为一个只写过原生的人，半年下来有了一些体感，写下来给同样背景的朋友参考。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e先说好的。\u003c/strong\u003e 开发效率是真的高，热重载这个体验一旦用上就回不去。UI 还原度也不错，设计师给的稿子基本一比一还原，而且 iOS / Android 两端的差异比我想象中小。我们一个三人小组，半年做了两个业务模块 + 一套通用组件库，换原生至少要五个人。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e再说别扭的。\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e混合工程的路由和生命周期管理是重灾区。我们用 flutter_boost 做混合栈，内存占用比纯原生页面高不少，低端机上页面切换能感觉到掉帧。官方对混合开发的支持一直不算稳定，升级 Flutter 版本的时候 flutter_boost 兼容性经常掉链子。\u003c/li\u003e\n\u003cli\u003eDart 语言本身没毛病，但生态里的包质量参差不齐。有个下拉刷新的库，作者弃坑半年，issue 堆了几百个没人回，最后我们自己 fork 修的。\u003c/li\u003e\n\u003cli\u003e崩溃排查体验比原生差。符号表一多，Crash 堆栈经常对不上，需要自己维护一套符号映射的流程，这块我们还在摸索。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e\u003cstrong\u003e我的整体判断\u003c/strong\u003e：新项目、纯 UI 展示型页面，Flutter 是划算的；但如果是重交互、深度依赖系统能力的 App，混合开发的复杂度会吃掉你在开发效率上省下来的时间。\u003c/p\u003e\n\u003cp\u003e下半年项目主体功能交付了，我大概率会回去继续搞 iOS 组件化的收尾。Flutter 的知识先放着，看它后续发展吧。个人感觉跨端这个方向，五年内不会有终局，工具会一直换，但\u0026quot;用一套代码换两端\u0026quot;的需求是永恒的。\u003c/p\u003e","title":"Flutter 入坑半年的一点感受"},{"content":"年底了，照例写点流水账，权当给自己的记录。\n今年工作上最大的事是组件化改造落地了，从 5 月开始到现在，底层库全部拆完，业务模块拆了三分之一。中间有一段时间特别沮丧：拆到一半的时候两头不靠，旧代码看不上，新架构没收益，全组人都在怀疑这件事的意义。挺过那一段之后就好了，现在新需求基本都能在独立模块里开发，这钱花得值。\n技术上今年的收获：\n把 Swift 在项目里的占比推到了 60%，剩下的是一些低频改动的老页面，不急着迁； 学了一点点响应式，Combine 发布之后把项目里一个手写的 EventBus 干掉了，代码量少了一半； 开始认真写单测。之前一直觉得 iOS 单测是玄学，用了 Quick/Nimble 之后看法有所改观，虽然覆盖率还是惨不忍睹的 11%。 生活上没什么可说的，跑步从三公里进步到八公里，然后 knee 出了点问题，医生说少跑。明年打算换游泳。\n明年想做的事：把组件化收尾；学点服务端的东西，公司内部有 Go 的技术分享，听了一次感觉挺有意思；看书比今年多一点，今年只读完三本，惭愧。\n最后感谢今年帮过我的同事和网上那些写了高质量博客的陌生人。我现在写这个博客的动力之一，就是希望哪天也能帮到别人一次。\n","permalink":"https://ruijingan.pages.dev/posts/2019/2019-review/","summary":"\u003cp\u003e年底了，照例写点流水账，权当给自己的记录。\u003c/p\u003e\n\u003cp\u003e今年工作上最大的事是组件化改造落地了，从 5 月开始到现在，底层库全部拆完，业务模块拆了三分之一。中间有一段时间特别沮丧：拆到一半的时候两头不靠，旧代码看不上，新架构没收益，全组人都在怀疑这件事的意义。挺过那一段之后就好了，现在新需求基本都能在独立模块里开发，这钱花得值。\u003c/p\u003e\n\u003cp\u003e技术上今年的收获：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e把 Swift 在项目里的占比推到了 60%，剩下的是一些低频改动的老页面，不急着迁；\u003c/li\u003e\n\u003cli\u003e学了一点点响应式，Combine 发布之后把项目里一个手写的 EventBus 干掉了，代码量少了一半；\u003c/li\u003e\n\u003cli\u003e开始认真写单测。之前一直觉得 iOS 单测是玄学，用了 Quick/Nimble 之后看法有所改观，虽然覆盖率还是惨不忍睹的 11%。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e生活上没什么可说的，跑步从三公里进步到八公里，然后 knee 出了点问题，医生说少跑。明年打算换游泳。\u003c/p\u003e\n\u003cp\u003e明年想做的事：把组件化收尾；学点服务端的东西，公司内部有 Go 的技术分享，听了一次感觉挺有意思；看书比今年多一点，今年只读完三本，惭愧。\u003c/p\u003e\n\u003cp\u003e最后感谢今年帮过我的同事和网上那些写了高质量博客的陌生人。我现在写这个博客的动力之一，就是希望哪天也能帮到别人一次。\u003c/p\u003e","title":"2019 年终小结"},{"content":"App 三岁了，单工程 40 万行代码，全量编译要 15 分钟，改一行代码喝杯水回来才能跑真机。终于说服老板让我们做组件化。这篇记录前期的方案取舍，给后来人省点力气。\n先说结论：不要一步到位上\u0026quot;总线\u0026quot;架构。 网上流传的组件化方案大多是 CTMediator 那一派，运行时解注册表调用。演示很漂亮，但真落地会发现调用关系全靠字符串，IDE 跳转变瞎，重构基本靠全局搜索。我们最后的方案保守得多：\n底层工具库（网络、缓存、埋点）拆成独立的私有 pod，用 cocoapods-packager 发到自建的私有源； 业务模块先只在物理上拆目录，通过 protocol 文件互相依赖，暂时不做运行时解耦； 主工程退化为一个壳，只负责组装 tab 和路由。 这个方案跑了半年，编译时间从 15 分钟降到 4 分钟（业务模块可以用 pod install 之后按需引入调试），虽然没到网上吹的\u0026quot;秒级编译\u0026quot;，但对日常开发体验的提升已经非常明显。\n私有源的坑。 自建源用的是 nexus，权限管理一塌糊涂，最后是靠 git tag + CI 校验版本号一致性来保证不出乱子。后来听说公司内部有 Artifactory 的 license，就迁过去了，省心很多。建议准备做组件化的团队先解决制品库的问题，工具链不顺手，组件化推行不下去——这不是技术问题，是工程习惯问题。\n下一阶段的计划是把业务模块间的 protocol 依赖收敛到一个独立的接口层仓库里，谁改接口谁发版。半年后 hopefully 能再写一篇续集，写写中间件方案到底要不要上。\n","permalink":"https://ruijingan.pages.dev/posts/2019/ios-component-architecture/","summary":"\u003cp\u003eApp 三岁了，单工程 40 万行代码，全量编译要 15 分钟，改一行代码喝杯水回来才能跑真机。终于说服老板让我们做组件化。这篇记录前期的方案取舍，给后来人省点力气。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e先说结论：不要一步到位上\u0026quot;总线\u0026quot;架构。\u003c/strong\u003e 网上流传的组件化方案大多是 CTMediator 那一派，运行时解注册表调用。演示很漂亮，但真落地会发现调用关系全靠字符串，IDE 跳转变瞎，重构基本靠全局搜索。我们最后的方案保守得多：\u003c/p\u003e\n\u003cul\u003e\n\u003cli\u003e底层工具库（网络、缓存、埋点）拆成独立的私有 pod，用 \u003ccode\u003ecocoapods-packager\u003c/code\u003e 发到自建的私有源；\u003c/li\u003e\n\u003cli\u003e业务模块先只在物理上拆目录，通过 protocol 文件互相依赖，暂时不做运行时解耦；\u003c/li\u003e\n\u003cli\u003e主工程退化为一个壳，只负责组装 tab 和路由。\u003c/li\u003e\n\u003c/ul\u003e\n\u003cp\u003e这个方案跑了半年，编译时间从 15 分钟降到 4 分钟（业务模块可以用 \u003ccode\u003epod install\u003c/code\u003e 之后按需引入调试），虽然没到网上吹的\u0026quot;秒级编译\u0026quot;，但对日常开发体验的提升已经非常明显。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e私有源的坑。\u003c/strong\u003e 自建源用的是 nexus，权限管理一塌糊涂，最后是靠 git tag + CI 校验版本号一致性来保证不出乱子。后来听说公司内部有 Artifactory 的 license，就迁过去了，省心很多。建议准备做组件化的团队先解决制品库的问题，工具链不顺手，组件化推行不下去——这不是技术问题，是工程习惯问题。\u003c/p\u003e\n\u003cp\u003e下一阶段的计划是把业务模块间的 protocol 依赖收敛到一个独立的接口层仓库里，谁改接口谁发版。半年后 hopefully 能再写一篇续集，写写中间件方案到底要不要上。\u003c/p\u003e","title":"iOS 组件化改造：从私有 pod 源开始"},{"content":"最近接手了一个商品列表页，产品反馈\u0026quot;滑动的时候有一点点卡，但说不清楚哪里卡\u0026quot;。这种模糊的需求最难办，记录一下我的排查过程，下次遇到可以直接翻这篇。\n第一步永远是先量化，不要靠手感。 打开开发者选项里的 GPU 渲染模式，或者直接接 Systrace 看帧耗时。我们的情况是滚动时偶发 30ms+ 的帧，但不是每一帧都卡。这种偶发型卡顿，onCreateViewHolder 的嫌疑最大。\n然后按嫌疑大小逐个排除：\nonBindViewHolder 里有没有做 IO？我们的问题就出在这——图片文案里的表情包配置是从 SharedPreferences 同步读的，第一次滚动到该位置时阻塞了主线程。改成启动时预加载到内存就好了。 item 布局层级是否太深。用 Layout Inspector 看了下，我们最深的一层嵌套了 7 层，把多余的 LinearLayout 换成 ConstraintLayout 之后测量耗时明显下降。 setHasFixedSize(true) 加了吗？数据变化不改变 item 高度的话，加上能省一次全量布局。 DiffUtil 用了没？我们还在用 notifyDataSetChanged()，全量刷新导致大量无意义重绘，换成 DiffUtil 后滑动顺滑了一个档次。 最后一个不算套路的心得：别过早优化。上面这些做完之后帧耗时稳定在 16ms 以内了，我本来还想上 RecyclerView 的 prefetch 调参，试了下收益几乎为零，就回滚了。性能优化到\u0026quot;感知不到卡\u0026quot;就该停手，剩下的时间是业务的时间。\n","permalink":"https://ruijingan.pages.dev/posts/2018/recyclerview-perf/","summary":"\u003cp\u003e最近接手了一个商品列表页，产品反馈\u0026quot;滑动的时候有一点点卡，但说不清楚哪里卡\u0026quot;。这种模糊的需求最难办，记录一下我的排查过程，下次遇到可以直接翻这篇。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e第一步永远是先量化，不要靠手感。\u003c/strong\u003e 打开开发者选项里的 GPU 渲染模式，或者直接接 Systrace 看帧耗时。我们的情况是滚动时偶发 30ms+ 的帧，但不是每一帧都卡。这种偶发型卡顿，\u003ccode\u003eonCreateViewHolder\u003c/code\u003e 的嫌疑最大。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e然后按嫌疑大小逐个排除：\u003c/strong\u003e\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003ccode\u003eonBindViewHolder\u003c/code\u003e 里有没有做 IO？我们的问题就出在这——图片文案里的表情包配置是从 SharedPreferences 同步读的，第一次滚动到该位置时阻塞了主线程。改成启动时预加载到内存就好了。\u003c/li\u003e\n\u003cli\u003eitem 布局层级是否太深。用 Layout Inspector 看了下，我们最深的一层嵌套了 7 层，把多余的 LinearLayout 换成 ConstraintLayout 之后测量耗时明显下降。\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003esetHasFixedSize(true)\u003c/code\u003e 加了吗？数据变化不改变 item 高度的话，加上能省一次全量布局。\u003c/li\u003e\n\u003cli\u003eDiffUtil 用了没？我们还在用 \u003ccode\u003enotifyDataSetChanged()\u003c/code\u003e，全量刷新导致大量无意义重绘，换成 DiffUtil 后滑动顺滑了一个档次。\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003e最后一个不算套路的心得：\u003cstrong\u003e别过早优化\u003c/strong\u003e。上面这些做完之后帧耗时稳定在 16ms 以内了，我本来还想上 RecyclerView 的 prefetch 调参，试了下收益几乎为零，就回滚了。性能优化到\u0026quot;感知不到卡\u0026quot;就该停手，剩下的时间是业务的时间。\u003c/p\u003e","title":"RecyclerView 卡顿排查的几个套路"},{"content":"公司的主 App 跑了三年多 Objective-C，今年年初终于下定决心往 Swift 迁。原计划三个月迁完，实际用了五个多月，中间踩的坑值得记一笔。\n不要想着一次性迁完。 我们最初的方案是新开一个 Swift 分支，集中人力把旧代码全部翻译一遍。干了两个星期就发现这条路走不通：业务需求不会停，分支落后主干越来越多，合并冲突解到怀疑人生。后来改成混合编译，新的模块用 Swift 写，旧的 OC 代码按模块逐步迁，才把节奏理顺。\nbridging header 是个大坑。 项目里如果既有 OC 引用 Swift，又有 Swift 引用 OC，Xcode 的报错信息基本不可读。我们最后的经验是：所有 OC 头文件里用到的第三方类型，尽量在 pch 里收口，别让 Swift 端直接碰到太深的头文件链。\n还有一个血泪教训：Swift 4.2 之前 API 变动太剧烈了，我们迁移中途 Swift 4.1 发布，#selector 和 KVO 相关的写法改了不少，又返工了半个月。现在回头看，选对版本窗口期很重要，大版本发布前千万别开迁。\n代码示例没什么好贴的，唯一想留的是这条规律：迁移期间新代码的 code review 强度要加倍。Swift 写起来太舒服，新人很容易在 optional 处理上放飞自我，! 满天飞。我们在 CI 里加了 SwiftLint 的 implicitly_unwrapped_optional 规则之后，线上 crash 率才慢慢降下来。\n总结：迁移本身不难，难的是在业务不停的情况下迁。如果重来一次，我会在开始前先把模块依赖关系理清楚，从依赖最少的叶子模块开始迁，而不是按页面从上往下迁。\n","permalink":"https://ruijingan.pages.dev/posts/2018/objc-to-swift-migration/","summary":"\u003cp\u003e公司的主 App 跑了三年多 Objective-C，今年年初终于下定决心往 Swift 迁。原计划三个月迁完，实际用了五个多月，中间踩的坑值得记一笔。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e不要想着一次性迁完。\u003c/strong\u003e 我们最初的方案是新开一个 Swift 分支，集中人力把旧代码全部翻译一遍。干了两个星期就发现这条路走不通：业务需求不会停，分支落后主干越来越多，合并冲突解到怀疑人生。后来改成混合编译，新的模块用 Swift 写，旧的 OC 代码按模块逐步迁，才把节奏理顺。\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003ebridging header 是个大坑。\u003c/strong\u003e 项目里如果既有 OC 引用 Swift，又有 Swift 引用 OC，Xcode 的报错信息基本不可读。我们最后的经验是：所有 OC 头文件里用到的第三方类型，尽量在 pch 里收口，别让 Swift 端直接碰到太深的头文件链。\u003c/p\u003e\n\u003cp\u003e还有一个血泪教训：Swift 4.2 之前 API 变动太剧烈了，我们迁移中途 Swift 4.1 发布，\u003ccode\u003e#selector\u003c/code\u003e 和 KVO 相关的写法改了不少，又返工了半个月。现在回头看，选对版本窗口期很重要，大版本发布前千万别开迁。\u003c/p\u003e\n\u003cp\u003e代码示例没什么好贴的，唯一想留的是这条规律：\u003cstrong\u003e迁移期间新代码的 code review 强度要加倍\u003c/strong\u003e。Swift 写起来太舒服，新人很容易在 optional 处理上放飞自我，\u003ccode\u003e!\u003c/code\u003e 满天飞。我们在 CI 里加了 SwiftLint 的 \u003ccode\u003eimplicitly_unwrapped_optional\u003c/code\u003e 规则之后，线上 crash 率才慢慢降下来。\u003c/p\u003e\n\u003cp\u003e总结：迁移本身不难，难的是在业务不停的情况下迁。如果重来一次，我会在开始前先把模块依赖关系理清楚，从依赖最少的叶子模块开始迁，而不是按页面从上往下迁。\u003c/p\u003e","title":"从 Objective-C 到 Swift：一次不太顺利的迁移"},{"content":"2013 年入行，做过六年多移动端，后期偏 iOS。2021 年转后端，现在主要写 Go。\n这个博客从 2018 年开始断断续续写，记的大多是踩坑之后怕忘掉的具体问题，希望对你有点用。\n","permalink":"https://ruijingan.pages.dev/about/","summary":"\u003cp\u003e2013 年入行，做过六年多移动端，后期偏 iOS。2021 年转后端，现在主要写 Go。\u003c/p\u003e\n\u003cp\u003e这个博客从 2018 年开始断断续续写，记的大多是踩坑之后怕忘掉的具体问题，希望对你有点用。\u003c/p\u003e","title":"关于"}]