聊聊后端架构这些年走过的弯路
昨晚哄完老二睡觉,已经快十一点了。我轻手轻脚地关上儿童房的门,溜回书房,打开电脑,给自己倒了杯凉透的咖啡。老婆在卧室喊了一句"别熬太晚",我嘴上应着"马上睡",手已经诚实地打开了VS Code。
这就是我,一个两个娃的奶爸程序员,白天在新公司搬砖,晚上等娃睡了才能有点自己的时间。入职这家公司刚满两个月,还在疯狂熟悉业务的阶段。最近项目组在搞架构升级,领导让我牵头调研从单体到云原生的演进方案,正好趁这个机会把思路理一理。
说实话,刚接到这个任务的时候我是有点懵的。上一家公司虽然也做微服务,但说白了就是把一个大单体拆成了几个小单体,本质上还是"换汤不换药"。这次新公司的业务体量完全不一样,日活百万级,高峰期QPS能飙到两三万,原来的架构确实扛不住了。
那个让我决定写这篇文章的夜晚
上周五晚上,线上出了一个P1级事故。
当时我正在给老二冲奶粉,手机突然疯狂震动,钉钉告警群消息刷屏。运维兄弟在群里@all,说核心交易服务挂了,用户下单全部超时。我一手抱着娃,一手掏出笔记本连VPN,看了一眼监控面板——好家伙,CPU直接干到100%,内存也快到极限了。
排查了半个多小时,定位到问题:一个批量导出报表的接口,因为数据量暴增,直接把数据库连接池打满了,连带着其他接口全部阻塞。当时真的想砸电脑,这种"一个接口拖垮整个系统"的事情,在单体架构里简直太常见了。
第二天复盘会上,CTO拍板:架构必须升级,不能再这么裸奔了。于是这个烫手山芋就落到了我手上。
单体架构:成也萧何败也萧何
咱们先聊聊单体架构。说实话,对于初创团队来说,单体架构真的是 yyds。
想当年我刚入行的时候,一个Spring Boot项目打天下,从用户模块到订单模块再到支付模块,全塞在一个工程里。部署也简单,打个jar包往服务器上一扔,java -jar 一跑,齐活。那时候觉得写代码真快乐,改一行代码全量生效,调试也方便,断点一打,整个世界都是你的。
但问题也来得很快。
| 痛点 | 具体表现 | 影响 |
|---|---|---|
| 代码耦合 | 改个用户模块的bug,不小心把订单模块搞挂了 | 上线如履薄冰 |
| 扩展困难 | 订单模块压力大,但没法单独扩容 | 资源浪费严重 |
| 技术栈锁定 | 想引入新技术?对不起,整个项目都得改 | 技术债务越积越多 |
| 部署风险 | 一个小改动要全量发布 | 发布频率越来越低 |
| 团队协作 | 十几个人改同一个代码库,冲突不断 | 每天都在解冲突 |
我记得最离谱的一次,我们团队8个人,一个迭代下来,光合并代码冲突就花了两天。产品经理还催着要功能,测试同学天天提bug,大家焦头烂额。那时候我就在想,这架构是不是该换了。
微服务:理想很丰满
后来公司上了微服务,用的Spring Cloud全家桶。说实话,刚拆的时候真的很爽,各个服务独立部署,团队各管各的,互不干扰。但很快我们就发现,微服务并不是银弹。
分布式事务就是个头疼的问题。原来一个@Transactional就能搞定的事情,现在要搞什么TCC、Saga,代码复杂度直接翻倍。有一次因为分布式事务没处理好,用户付了钱但订单状态没更新,客诉直接爆了。
服务治理也是个大坑。服务一多,调用链路就变得巨复杂。一个请求进来,可能经过网关、用户服务、订单服务、库存服务、支付服务,任何一环出问题都可能导致整个链路挂掉。我们后来上了SkyWalking做链路追踪,才勉强能看清调用关系。
运维成本更是直线上升。原来几台机器就能搞定,现在光服务就几十个,再加上注册中心、配置中心、消息队列、网关……没有专门的运维团队根本玩不转。
那段时间,我头发掉得比娃的奶粉消耗还快。
云原生:真正的解药?
说回现在这个新项目。CTO要求的"云原生",不是简单地往K8s上一扔就完事了。我们做的调研和方案设计,大概分了这么几个层面:
容器化是第一步
这个没啥好说的,Docker + K8s 是标配。但有几个坑需要提前避开:
# 一个典型的Go服务部署配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: registry.company.com/order-service:v1.2.3
resources:
requests:
memory: "256Mi"
cpu: "200m"
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
这里有个细节,resources的requests和limits一定要设置合理。我们之前有个服务,limits设太高,结果K8s调度器把它放到了一个资源紧张的节点上,反而导致OOM。后来我们根据实际压测数据,把每个服务的资源配额都精细化了一遍。
为什么我们选了Go
这是方案评审时争议最大的一个点。团队里大部分人都是Java出身,对Go不太熟悉。但我坚持推Go,理由很实在:
启动速度。Java应用启动慢是老大难问题,Spring Boot项目动辄几十秒甚至几分钟的启动时间,在弹性扩缩容场景下是致命的。Go编译出来就是一个二进制文件,启动时间毫秒级,K8s扩容的时候体验完全不一样。
内存占用。同样一个服务,Java版可能要吃掉500MB内存,Go版可能只要50MB。在K8s环境下,这意味着同样的机器能跑更多实例,成本直接降下来。
并发模型。Go的goroutine天生适合高并发场景,写起来也比Java的线程池舒服。最近我在研究一些开源项目的源码,发现很多云原生领域的项目——Docker、K8s、Etcd——都是Go写的,这不是没有原因的。
当然,Go也有短板。泛型支持来得太晚(虽然1.18终于有了),错误处理的if err != nil写到让人怀疑人生,生态相比Java还是差一截。但综合来看,对于云原生场景下的后端服务,Go确实是个更合适的选择。
// Go的错误处理虽然啰嗦,但很清晰
func (s *OrderService) CreateOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) {
// 参数校验
if err := req.Validate(); err != nil {
return nil, fmt.Errorf("invalid request: %w", err)
}
// 库存扣减
if err := s.inventoryClient.Deduct(ctx, req.SkuID, req.Quantity); err != nil {
return nil, fmt.Errorf("deduct inventory failed: %w", err)
}
// 创建订单
order, err := s.repo.Create(ctx, req)
if err != nil {
// 库存回滚
s.inventoryClient.Rollback(ctx, req.SkuID, req.Quantity)
return nil, fmt.Errorf("create order failed: %w", err)
}
return order, nil
}
服务网格:解耦业务和基础设施
这是我觉得最有意思的一部分。我们引入了Istio做服务网格,把服务治理的能力从业务代码中剥离出来。
以前在代码里要写熔断、限流、重试的逻辑,现在这些全部下沉到Sidecar代理里。业务代码只需要关心业务逻辑,干净了很多。
# Istio VirtualService 配置示例
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: order-service
spec:
hosts:
- order-service
http:
- route:
- destination:
host: order-service
retries:
attempts: 3
perTryTimeout: 2s
fault:
abort:
percentage:
value: 1
httpStatus: 500
上面这个配置里,我还加了一个1%的故障注入,用于测试系统的容错能力。这个招数是跟一个开源项目学来的,具体名字我记不太清了,反正就是定期往系统里注入各种故障,看看系统能不能扛住。有点像给系统打疫苗,提前产生抗体。
可观测性:不能当瞎子
云原生架构下,服务数量暴增,如果没有好的可观测性方案,出了问题就是盲人摸象。我们搭了一套完整的可观测性体系:
- 日志:EFK(Elasticsearch + Fluentd + Kibana),统一收集所有服务的日志
- 指标:Prometheus + Grafana,监控各种业务和技术指标
- 链路追踪:Jaeger,追踪请求在各个服务间的调用链路
这里分享一个实战经验:一定要在网关层做好Trace ID的透传。我们之前有个服务,日志里打的Trace ID跟上游传过来的对不上,排查问题的时候简直要命。后来在中间件层统一处理了这个问题,世界终于清净了。
一些关于数据库设计的思考
架构演进不只是应用层的事,数据库层面也要跟着变。
分库分表是绕不过去的话题。我们用的是ShardingSphere,按用户ID做分片键。这里有个坑:如果你的查询场景经常需要按非分片键查询,就会触发全路由,性能会很差。所以分片键的选择一定要结合业务场景来定。
读写分离也要注意数据一致性的问题。我们之前遇到过,主库写完数据立刻去从库读,结果读不到,因为主从同步有延迟。后来加了一个"强制走主库"的逻辑,对于写后立刻要读的场景,直接路由到主库。
-- 分片规则配置示例
spring:
shardingsphere:
datasource:
names: ds0,ds1
sharding:
tables:
t_order:
actual-data-nodes: ds$->{0..1}.t_order_$->{0..3}
database-strategy:
inline:
sharding-column: user_id
algorithm-expression: ds$->{user_id % 2}
table-strategy:
inline:
sharding-column: user_id
algorithm-expression: t_order_$->{user_id % 4}
那些让我又爱又恨的新玩意儿
最近研究云原生的过程中,接触了不少新东西,有几个让我印象特别深。
Manus 这个名字可能很多人还不熟悉。它是一个AI Agent框架,可以自主完成复杂的任务。我在想,以后运维工作是不是可以交给这种AI Agent来做?比如自动扩缩容、自动故障恢复、自动性能调优。虽然目前还比较早期,但这个方向我觉得很有想象空间。想象一下,半夜系统出了问题,不用被钉钉叫醒,Manus自动帮你排查、恢复、发报告,第二天早上起来一看,问题已经解决了。这简直是奶爸程序员的福音啊。
AI视频生成也是一个让我觉得"未来已来"的领域。虽然跟后端架构关系不大,但我最近在用一些AI视频工具给娃做动画故事。技术上来说,AI视频生成背后需要大量的GPU计算和分布式任务调度,这其实也是云原生架构的一个典型应用场景。我们公司的推荐系统团队就在用K8s跑各种模型训练和推理任务,资源调度那块的设计还是挺有参考价值的。
写在最后
凌晨一点半了,老二刚才又醒了一次,老婆起来哄的。我趁着这功夫把文章收尾。
回头看后端架构这些年走过的路,从单体到微服务再到云原生,每一次演进都不是为了追时髦,而是被业务逼的、被线上事故打的、被成本压力逼的。没有最好的架构,只有最合适的架构。
对于正在考虑架构升级的团队,我有几点建议:
不要为了微服务而微服务。如果你的团队就三五个人,业务也没那么复杂,单体架构完全够用。过早引入微服务只会增加不必要的复杂度。
云原生的核心不是K8s。而是容器化、不可变基础设施、声明式API、服务网格这些理念。K8s只是实现这些理念的一个工具。
可观测性先行。在拆服务之前,先把监控、日志、链路追踪搞好。不然拆完之后出了问题,你连问题在哪都不知道。
小步快跑。不要搞什么"大爆炸"式的重构,一个服务一个服务地迁,迁一个稳定一个。
团队能力要跟上。技术再好,团队不会用也白搭。我们为了推Go,搞了好几次内部分享,还买了书让大家一起学。
好了,咖啡喝完了,眼皮也开始打架了。明天还要早起送老大上学,这篇文章就写到这吧。
如果你也在做架构升级,或者对云原生有什么想法,欢迎在评论区交流。毕竟,带娃之余能跟同行聊聊技术,也是一种放松嘛。
最后吐槽一句:产品经理今天又提了个需求,说要加个"AI智能客服"功能。我心想,等Manus成熟了,第一个就把你替了。
晚安,各位打工人。


评论 0