接手上一个代号叫 caveman 的项目时,我第一反应是怀疑自己在看一个远古产物。没有微服务目录,没有 Kubernetes,没有一个像样的 API 网关,主程序是一个跑在单台 2 核 4G 机器上的 Go 进程;数据库是一台普通的 PostgreSQL;消息靠 Redis Streams 加一堆定时任务。可就是这套看起来非常“不现代”的系统,撑住了整个客服体系每天十万级的事件流转,过去半年挂在生产环境的事故一只手数得过来。
也正是这段经历让我意识到一件很重要的事:很多复杂度不是业务逼出来的,而是我们自己选择背上去的。caveman 这个名字起得很诚实,它就是想提醒每一个维护者:像原始人一样思考,别觉得丢人。这篇文章想把这个项目的完整复盘写下来,包括当初为什么立下这么奇怪的设计原则、具体选了哪些“笨”组件、落地时踩了哪些坑,以及现在让我再选一次会怎么决策。
1. 项目代号“caveman”的来历与三条设计红线
1.1 起源:先被复杂系统打疼,才想明白要什么
这个项目不是从零开始拍的脑袋。在它之前,客服售后链路是一个拆了七八个微服务的系统:订单服务、审批服务、通知服务、报表服务,个个都能独立部署,但全链路排查一个问题要在四五套环境里翻日志。有一次消息积压,源头队列里堆了近三天的数据,团队定位花了一整天,最后发现是某个服务在升级时把消费组的 offset 提交逻辑改坏了。
我当时最直观的感受是:这不是技术问题,是复杂度管理问题。新项目立项时我就提出,能不能用一个“原始人”方案:单进程、单数据库、尽量少的中间件,把核心链路缩到一台机器能跑完。开始大家以为我在开玩笑,后来在原型阶段验证能扛住两倍预期流量时,这个代号就定下来了。caveman 不是自嘲,是在提醒我们不要把基础能力当成理所当然。
1.2 三条设计红线
caveman 从一开始就写了三条红线,任何新增技术选型只要踩线就往回拉:
第一,不引微服务框架。一个进程能装下的功能绝不拆出去,进程内模块之间直接调函数,只有真正的对外边界才暴露 HTTP 接口。
第二,不引工作流引擎。业务状态流转全部用数据库行记录加定时扫描驱动,打心底接受“用笨办法维护顺序”。
第三,不做分布式事务。跨多个资源的操作用本地事务加最终一致补偿脚本,明确告诉业务方某些状态可能有分钟级延迟,而不是用昂贵的手段让它们看起来同步。
这三条红线不是拍脑袋,每条背后都有真实的失败案例。微服务拆分的代价写在每一个“跨服务调一次加一次超时心跳”的代码里;工作流引擎的代价写在“改一个状态节点要重新发布整个编排文件”的维护记录里;分布式事务的代价写在上一个项目天天盯着两阶段提交协调器监控屏的深夜。限制选择,反而把团队精力往真正重要的业务逻辑上赶。
1.3 适用边界:什么情况才配得上“原始人”
我不会把 caveman 方案吹成万能药。它适合的场景很清楚:内部系统或业务闭环完整的中台服务,团队规模不超过十个后端开发,数据规模还在单机数据库能承载的量级,业务对最终一致性接受度较高。不适合的场景同样清楚:对外高并发 C 端流量,强多活要求,业务复杂到需要独立团队各自交付,或者公司已经有成熟的 PaaS 基础设施和能力强的平台团队。
这里的核心判断标准只有一条:如果引入分布式收益无法用数据证明,就默认不引入。反过来,当数据已经证明阈值到来,也别因为情怀而死守石器时代。caveman 的伟大之处不在于拒绝工具,而在于只选当下最匹配的工具。
2. 技术选型:哪些“先进”组件被我换成了更笨的东西
2.1 选型对照表
这是当时核心选型的对照表,保留了最初的评估结果:
| 常见选型 | caveman 最终选型 | 切换理由 |
|---|---|---|
| Kubernetes / 容器编排 | systemd + 部署脚本 | 团队没人专职做运维,K8s 的学习和排障成本远超收益 |
| Kafka 消息队列 | Redis Streams + 定时补偿 | 峰值消息量几百 QPS,Kafka 集群维护重,Redis 单点足够 |
| 微服务拆分 | 单体进程 + 模块边界 | 排查链路短,部署原子化,回滚简单 |
| gRPC / RPC 框架 | HTTP + 超时重试 | 调用方只需要 curl 和标准库,调试成本最低 |
| MySQL 分库分表 | 单 PostgreSQL + 分区表 | 数据量未到分库线,单库加归档足以应付三年增长 |
这个对照表背后有一个我自己总结了很多次才真正信服的原则:先选维护成本最小的,再选性能最好的。如果性能瓶颈还没出现,提前引入复杂组件就是透支团队的精力。caveman 选择的每一件东西,都满足“随便找个实习生,半小时能说清楚它怎么工作”这个标准。
2.2 数据库:单库分区而不是分库分表
工单核心表每年新增约两千万行,三年累计也到不了一个亿。这个量级在 PostgreSQL 单库里用 range 分区完全没问题,根本不需要分库分表。我把“历史工单归档表”按月份切分,旧分区定期 detach 成独立表,查询时只在最近三个月分区里扫,速度一直可控。
有人会觉得单库是性能定时炸弹,我当时的做法是主动验证而不是猜。上线前用 wrk 对核心写接口压到 600 QPS,CPU 到 70%,延迟平均 30ms;又模拟了一千万行数据做复杂查询,最慢的报表 SQL 在秒级返回。压测数据摆出来,团队自然就接受单库方案了。记住一个数字:如果单表数据没过五千万,先把索引和分区做对,再考虑是不是要分库。
2.3 部署与更新:抢停机窗口而不是搞在线发布
caveman 的部署到今天依然是“停机三十秒”模式。打新版本包,放到固定目录,执行systemctl restart,然后用健康检查脚本确认进程起来、关键接口通、日志无 ERROR。不是我们不会做蓝绿,而是这类内部系统对连续可用性的要求没那么高,凌晨两点的三十秒重启,业务完全没感知,却换来了部署链路的极度简单。
真正要做好的反而是两件事:一是发布清单自动化,把备份、停服务、替换二进制、启动、回滚写成一个人也能跑的脚本;二是严格的灰度顺序,先在测试机跑冒烟用例,再在生产环境从“影子配置”切到真实配置。这套流程让“发布”这个词从玄学变成了确定性操作。
2.4 用真实流量验证容量而不是堆机器
我见过太多项目在流量没起来之前就上了三副本、五副本。caveman 只预留了两倍资源:2 核 4G 的机器理论上能顶住峰值的两倍,等真的超了再扩容也不迟。扩容也不是无脑加机器,而是先看指标:CPU 是否持续 80% 以上、数据库连接池是否打满、响应 p99 是否恶化。只要没触发这三条,就说明当前容量充足,剩下的时间用在写监控报警上。
3. 核心模块设计:没有框架,业务逻辑反而更“肉眼可见”
很多人觉得没有框架就没有约束,代码会变成一锅粥。我的反方向经验是:框架有时只是把复杂度从一个地方搬到另一个地方,真正的约束应该由领域结构和数据表来定。
3.1 目录架构即边界
caveman 的仓库结构刻意保持得极简,边界写在目录上而不是写在服务调用的网状图里:
app/ cmd/caveman/main.go # 唯一入口 internal/ order/ # 工单领域:实体、状态机、仓储 notify/ # 通知领域:发送器、重试、模板 gateway/ # 对外 HTTP 接口,只做参数校验和编排 pkg/ queue/ # Redis Streams 的薄封装 db/ # 数据库连接与迁移一个请求从 HTTP 进来到业务落库,路径不会拐弯,代码里也不需要花里胡哨的依赖注入。新同事入职第一周就能在大脑里画清这个系统的全貌,这是复杂框架给不了的确定性。模块之间的依赖方向也简单:gateway依赖order和notify,但order和notify互不依赖。
3.2 状态机替代流程引擎
业务状态流转是这套系统最核心的部分:工单从pending到approved到processing到completed,中间还有cancelled和rejected。我直接用一张状态变更表描述整个流转:
| 字段 | 含义 |
|---|---|
| id | 变更记录主键 |
| order_id | 工单 ID |
| from_status | 变更前状态 |
| to_status | 变更后状态 |
| event | 触发事件名 |
| operator | 操作人/调用方 |
| created_at | 变更时间 |
每次状态迁移都在同一个数据库事务里完成三件事:先查当前状态是否允许迁移,再更新主表状态,最后插入一条状态变更记录。任何非法迁移在第一步就被挡掉,事后任何一个状态都能顺着变更表倒推出完整因果链。写了大约四十个状态节点之后,我依然没有想去引入工作流引擎,因为这种方案的真实成本只有一张表,收益却是全链路可视。
状态机的核心代码简化后大概是这样:
func Transition(tx *sql.Tx, orderID string, from, to, event string) error { // 1. 查当前状态 var current string err := tx.QueryRow(`SELECT status FROM orders WHERE id=$1 FOR UPDATE`, orderID).Scan(¤t) // 2. 校验是否允许 from -> to if !allowed[current][to] { return errors.New("invalid transition") } // 3. 更新状态并写入变更记录 tx.Exec(`UPDATE orders SET status=$1 WHERE id=$2`, to, orderID) tx.Exec(`INSERT INTO order_status_log ...`, orderID, from, to, event) }有经验的读者会发现这里用了SELECT ... FOR UPDATE,这正是为了保证并发安全。后面我会专门讲它带来的另一个坑。
3.3 事件表加定时补偿,替代分布式事务
通知发送不能和工单状态变更放在同一个事务里,否则数据库事务时间会变得不可控。caveman 的做法是:在状态变更事务里往本地事件表写入一行“待通知事件”,事务提交后由独立的发送进程捞取事件、调用通知服务、标记完成。如果发送失败,事件会一直留在表里,定时补偿任务会反复重试并告警。
这其实是经典的 outbox 模式,但我刻意没引入消息中间件做事务消息,而是让发送进程直接读数据库。好处是逻辑完全透明,坏处是每次都要处理重复发送,所以我们在通知接口上做了幂等键:同一个event_id重复调用只生效一次。这套设计用了半年,没有出现过一次因补偿机制导致的业务重复问题。
3.4 产品交互保持“石器时代”风格
面向内部运营人员的后台,我只给了最基本的三样东西:今日看板、待办列表、工单详情。不做炫酷报表、不做实时大屏、不做复杂筛选。这个刻意“简陋”的选择很反直觉,但运营反馈是最好的:他们每天只需要处理几十单,复杂的仪表盘反而会干扰工作注意力。
危险操作全部要求二次确认,所有批量操作必须填写备注。理由是“原始人不搞误操作,搞了就要靠自己手写恢复脚本”。简单交互不是功能缺失,是把交互成本降到和业务真实需求匹配。
4. 踩过的坑:原始方案并没有免死金牌
caveman 让我把“简单”奉为圭臬,但朴素方案同样会出问题,而且问题往往出在最不起眼的地方。以下四个坑,每一个都值得记进事故手册。
4.1 消息“先确认后处理”与“先处理再确认”的经典二选一
上线早期,我让消费逻辑先从 Redis Streams 里读取消息,立刻回执确认,然后才处理业务。结果有一次处理进程在处理中途崩溃,消息已经被确认过了,重启后这条消息彻底消失,业务表现在缺了一单。
排查链路是这样的:先对比事件表和生产侧的发送日志,发现差异集中在崩溃时间点前后;再查 Redis Streams 的 pending 列表,发现这些消息根本不在,因为已经XACK了;最后定位到代码顺序,确认是先 ack 再处理。修复方式很简单,把顺序改成先处理业务,在业务幂等成功后 ack。这样崩溃时消息留在 pending 里,靠XAUTOCLAIM或定时补偿重新消费。这个坑很基础,但基础坑往往最容易在“明天要上线”的仓促里埋下。
4.2 并发事件的时序错乱
状态机在纸面上很美好,但真实并发会把时序搅乱。有一次,同一个工单的两个事件几乎同时到达:一个是审批通过,一个是买家取消。两个协程同时读取当前状态,一个走到approved,一个走到cancelled,最后到底哪个状态生效,取决于谁先提交事务。
刚开始我用行锁解决了并发写问题,却忽略了另一个问题:后到的旧事件可能会把新状态覆盖回去。比如取消事件先到,状态已经变成cancelled;之后审批通过事件才到,它按照“合法迁移”又想把状态改成approved,这明显不对。最终方案是在事件表里加一个单调递增的seq,只在seq大于当前最后事件的seq时才允许处理,同时用order_id + event_type + seq的唯一约束挡住重复事件。这一步花了两天,比装任何分布式锁都便宜。
4.3 停机更新撞上定时任务
有一次凌晨发布,我把进程重启完就去睡觉了,第二天早上发现数据发了两遍。原因不难猜:服务重启期间,机器上另一个定时任务没有停,在我发布窗口内照常运行,用旧进程把一批事件重新处理了一遍。
根因是发布脚本只停了主服务,忘了巡一遍 crontab。从那以后,所有定时任务入口统一加了一个文件锁包装,防止同一任务并发执行:
#!/usr/bin/env bash exec 9> /var/lock/caveman-task.lock flock -n 9 || exit 0 # 真正的任务逻辑写在这里发布脚本里也会先执行systemctl stop caveman,再等待所有任务锁释放,最后才替换二进制。教训很简单:简单系统的边界,要靠清晰的进程生命周期管理来守住。
4.4 删掉缓存之后,问题自己消失了
项目中期有人反馈某个查询页面变慢,看代码发现前一个同事给热点数据加了 Redis 缓存,但缓存和数据库之间的一致性处理得有问题:数据更新后缓存没及时失效,用户看到的是旧状态,技术群里开始讨论“是不是要上分布式锁”。
我没有急着调锁,而是先把缓存开关关掉,拿真实数据压了一遍慢查询。结果让我惊讶:数据库直连查询平均 18ms,加缓存后平均 12ms,差 6ms 对页面体验完全无感知,而一致性风险却被放大了无数倍。最终决定直接删掉这个缓存,只做数据库索引优化。很多“复杂问题”之所以复杂,是因为我们往系统里塞了原本不需要的复杂度,先做减法往往比做加法更有效。
5. 可抄作业的“caveman守则”清单
项目平稳跑了半年,我把当初的设计原则整理成了一份可执行的检查清单。每次评审设计文档,我都会把它翻出来逐条对照。
5.1 引入任何组件前,先写“如果它让我失望”的撤离方案
新依赖不是不能加,但必须回答四个问题:这个组件解决了哪个具体痛点,对应哪些监控指标?最小验证原型需要几天?如果它半年后成为负担,删除它要改多少行代码?团队里有没有人愿意在未来一年持续维护它?
任何回答不出“能”的新技术,都默认不引入。这条守则挡掉过三次眼馋的冲动:一次是看到别人用 Service Mesh 很酷,一次是想上 Flink 做实时统计,一次是有人建议把通知模块拆成独立服务。事后回头看,全是正确决定。
5.2 每周删一次代码,删除代码也是交付物
caveman 的迭代复盘里加了一个奇怪的指标:这周删了多少行代码。不是单纯数量游戏,而是逼着团队审视“这个抽象真的需要吗”。有一次邮件模板渲染模块写了一大堆泛型接口,最后发现只有两种模板形态,直接改成两个函数加一张配置表,代码少了近一半。
删除不是浪费,删除是在降低未来每个接手者的认知负担。代码量和复杂度不是正相关,有时候恰恰是负相关,因为活下来的代码往往被改得更清楚。
5.3 用数据阈值锁定“升级”时刻
caveman 不是永远拒绝复杂化,而是把复杂化的触发条件显式写下来:
- 当每日消息量稳定超过千万条,或 Redis 内存长期超过 70% 且队列延迟超过五分钟,才允许评估引入独立消息集群。
- 当单表数据超过一亿行,或查询 p99 超过一秒钟,才允许评估分库方案。
- 当部署频率超过每周三次且发布冲突成为常态,才允许评估服务拆分。
这些阈值是硬约束,没有达到前,任何人提议加组件都先按下来。真到了触发点,再带着数据去讨论技术升级,而不是靠感觉。
5.4 一年后我仍然不后悔,但会修正两处
诚实地说,有两处土办法值得修正。第一,定时任务用 crontab 确实太隐蔽。某个任务执行失败时,如果日志没写清楚,人工根本不会注意到。后来我把所有任务收口到一个调度器进程里,每个任务执行前后都写结构化日志,配上成功率和耗时告警,定时任务才从“盲盒”变成“仪表盘”。
第二,手写状态机在四十个节点内很舒服,但如果业务未来冲到上百个节点,我大概率会重新评估可视化工作流工具,而不是死守“我不喜欢框架”的面子。守则不反对新工具,只反对没有数据支撑的新工具。
回看 caveman 这段经历,最值钱的不是某个具体技术方案,而是那一整套“默认相信简单、用数据质疑复杂”的思考方式。它让我在后来的项目里,听到任何“必须上 XX”的论断时,第一反应都是:先给我一个必须上的数据,再给我一套后悔了怎么撤退的路径。如果有人再问我为什么不上一套更时髦的架构,我会告诉他,石器时代的工具虽然丑,但每一把的重量和用途我都能说清楚,这比拿着金光闪闪却不会用的精密设备踏实得多。