资讯详情

单体应用撑起十万级事件流转:caveman项目的极简架构复盘

📅 2026/10/29 8:19:37 | 华诺云谱 👁 阅读
单体应用撑起十万级事件流转:caveman项目的极简架构复盘
接手上一个代号叫 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 定时补偿峰值消息量几百 QPSKafka 集群维护重Redis 单点足够微服务拆分单体进程 模块边界排查链路短部署原子化回滚简单gRPC / RPC 框架HTTP 超时重试调用方只需要 curl 和标准库调试成本最低MySQL 分库分表单 PostgreSQL 分区表数据量未到分库线单库加归档足以应付三年增长这个对照表背后有一个我自己总结了很多次才真正信服的原则先选维护成本最小的再选性能最好的。如果性能瓶颈还没出现提前引入复杂组件就是透支团队的精力。caveman 选择的每一件东西都满足“随便找个实习生半小时能说清楚它怎么工作”这个标准。2.2 数据库单库分区而不是分库分表工单核心表每年新增约两千万行三年累计也到不了一个亿。这个量级在 PostgreSQL 单库里用 range 分区完全没问题根本不需要分库分表。我把“历史工单归档表”按月份切分旧分区定期 detach 成独立表查询时只在最近三个月分区里扫速度一直可控。有人会觉得单库是性能定时炸弹我当时的做法是主动验证而不是猜。上线前用 wrk 对核心写接口压到 600 QPSCPU 到 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工单 IDfrom_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(current) // 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”的论断时第一反应都是先给我一个必须上的数据再给我一套后悔了怎么撤退的路径。如果有人再问我为什么不上一套更时髦的架构我会告诉他石器时代的工具虽然丑但每一把的重量和用途我都能说清楚这比拿着金光闪闪却不会用的精密设备踏实得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑