从单体到微服务:一个DBA转后端的血泪拆分实录
上个月在成都的一个技术分享会上,我坐在台下听一位大佬讲“微服务架构演进”,越听越坐不住——这不就是我们团队去年折腾了大半年的事儿吗?散场后我直接冲上去跟他聊了半小时,临走他还送了我一本签名版《分布式系统原理》。回程地铁上我就想:得写点东西了,不然对不起那杯花了38块的分享会咖啡(成都搞技术分享真的卷,连咖啡都要自费)。
我是老DBA出身,十年前还在手动调MySQL的innodb_buffer_pool_size,后来被业务逼着学Go写接口,现在算是个“带数据库执念”的后端开发。所以当我接到任务要把公司那个20万行代码的单体应用拆成微服务时,第一反应不是看Spring Cloud文档,而是打开Navicat盯着ER图发呆——数据边界才是服务边界的灵魂,这话我现在逢人就讲。
单体应用快压垮我们的不只是代码量
事情得从去年双11说起。我们那个“祖传”单体应用,用Go写的,但数据库设计还是典型的电商大宽表风格——订单、库存、用户信息全塞在一张主表里,JOIN起来比火锅毛肚还烫嘴。当时凌晨两点收到告警短信,CPU飙到98%,我一边啃泡面一边查慢查询日志,发现是某个促销活动触发了全表扫描。运维小哥在群里哀嚎:“再这样下去服务器要冒烟了!”
产品经理倒是淡定:“加机器呗。”
我说:“加你个头,这张表已经5000万行了,索引都建出花来了!”
其实问题早有苗头。每次上线新功能,测试同学都要跑完整回归测试,因为改个用户地址可能影响支付流程——耦合度高到离谱。更惨的是,不同业务模块的QPS需求天差地别:商品浏览每秒上千请求,而财务对账一天就跑几次。可它们共用同一个数据库连接池,高峰期互相拖累,低峰期又浪费资源。
领导拍板:“拆!必须拆成微服务!”
我内心OS:拆容易,拆完数据一致性怎么保证?事务跨服务了咋办?你们这些提需求的知道分布式事务有多反人类吗?
拆分第一步:用领域驱动设计划清数据疆界
很多团队一上来就狂拆服务,结果拆完发现订单服务要调用户服务查昵称,用户服务又要调商品服务看收藏夹……最后RPC调用链比春熙路的人流还密集。作为DBA转岗的开发者,我坚持先做数据模型解耦。
我们拉着产品、测试开了三天“闭门会议”(其实就是茶水间霸占了三张桌子),用白板画出了核心实体关系:
| 原单体模块 | 潜在微服务 | 数据边界关键点 |
|---|---|---|
| 用户中心 | user-service | 用户基本信息、安全凭证 |
| 商品管理 | product-service | SKU、价格、库存快照 |
| 订单交易 | order-service | 订单主表、子订单、状态机 |
| 库存系统 | stock-service | 实时库存、仓库映射 |
注意看第三列——每个服务只拥有自己数据的“写权限”。比如order-service创建订单时,会通过消息队列异步通知stock-service扣减库存,而不是直接UPDATE对方的库存表。这个原则救了我们无数次线上事故。
工具方面,我们用 GoLand 的 Database插件 直接可视化ER图,配合 PlantUML 生成领域模型。有次实习生想把用户手机号挪到order表里“方便查询”,被我当场拦下:“你这是要制造第二个单体吗?查用户信息去调user-service啊!”
Go微服务落地:选型与踩坑实录
技术栈选择很务实:
- 通信协议:gRPC(内部调用)+ HTTP/JSON(对外API)
- 服务注册发现:Consul(比Eureka轻量,符合我们小团队气质)
- 配置中心:Apollo(感谢携程开源)
- 分布式追踪:Jaeger(UI比Zipkin好看)
关键代码片段:订单服务如何安全扣库存
// order-service/main.go
func CreateOrder(ctx context.Context, req *CreateOrderRequest) (*Order, error) {
// 1. 本地事务:创建订单草稿
tx := db.Begin()
if err := tx.Create(&Order{
UserID: req.UserID,
Status: "draft",
}).Error; err != nil {
tx.Rollback()
return nil, err
}
// 2. 发送可靠消息(防止消息丢失)
msg := &StockDeductMessage{
OrderID: order.ID,
Items: req.Items,
Timestamp: time.Now(),
}
if err := reliableProducer.Send("stock_deduct_topic", msg); err != nil {
tx.Rollback() // 消息发不出去?订单也别建了
return nil, err
}
tx.Commit() // 只有消息成功入队才提交订单
return order, nil
}
这里用了 本地消息表模式 保证最终一致性。stock-service消费消息后执行扣库存,失败就重试(最多3次),再失败就进死信队列人工处理。别信什么Seata、TCC,小团队玩分布式事务就是给自己挖坑——这是我用三次线上故障换来的教训。
数据库设计的执念:每个服务独享DB
很多人图省事让多个服务连同一个MySQL实例,用不同database隔离。错!大错特错! 我们坚持每个微服务独占一个数据库实例(哪怕是同一台物理机上的不同端口),原因有三:
- 权限隔离:stock-service的账号根本看不到user表,SQL注入漏洞影响范围可控
- 性能隔离:order-service的大查询不会拖慢product-service的响应
- 运维自由:给stock-service单独做读写分离?没问题!user-service需要归档旧数据?随便搞!
配置示例(docker-compose.yml片段):
services:
user-db:
image: mysql:8.0
environment:
MYSQL_DATABASE: user_service
ports:
- "3307:3306"
order-db:
image: mysql:8.0
environment:
MYSQL_DATABASE: order_service
ports:
- "3308:3306"
性能优化:那些让我夜不能寐的瞬间
拆完服务只是开始,真正的挑战在性能调优。有次压测发现order-service的P99延迟高达2秒,排查半天才发现是gRPC客户端没复用连接:
// 错误示范:每次请求新建连接
func callUserService(userID string) (*User, error) {
conn, _ := grpc.Dial("user-service:50051") // 天啊!
defer conn.Close()
// ...
}
// 正确姿势:全局连接池
var userSvcClient user.UserServiceClient
func init() {
conn, _ := grpc.Dial("user-service:50051", grpc.WithInsecure())
userSvcClient = user.NewUserServiceClient(conn)
}
还有更隐蔽的坑:N+1查询问题从数据库蔓延到了服务调用!前端要展示订单列表,后端先查order-service拿10个订单ID,再循环调user-service查每个用户的昵称——结果10次RPC把网关干趴了。解决方案?批量接口!
// user-service新增批量接口
func GetUsersByIDs(ctx context.Context, req *GetUsersRequest) (*GetUsersResponse, error) {
var users []User
db.Where("id IN ?", req.Ids).Find(&users) // 一次SQL搞定
return &GetUsersResponse{Users: users}, nil
}
顺便安利个神器:Go自带的pprof。上周五晚上加班时,我直接在代码里加了两行:
import _ "net/http/pprof"
func main() {
go func() { http.ListenAndServe("localhost:6060", nil) }()
// ...其他逻辑
}
然后浏览器访问http://localhost:6060/debug/pprof/heap,内存泄漏点一目了然。运维同事看到后惊呼:“你们Go程序还能这么玩?”
工具链:小团队的自动化生存指南
微服务数量上来后,手动部署等于自杀。我们搭了套轻量级CI/CD:
- GitLab CI:代码推送到dev分支自动跑单元测试
- Docker Buildx:多平台镜像构建(Mac M1也能跑)
- Kustomize:不用Helm也能管理K8s配置
- Prometheus + Grafana:监控每个服务的QPS、错误率、DB连接数
特别提一下 Grafana看板里的数据库指标——作为前DBA,我给每个服务的dashboard都加了这几项:
- 慢查询次数(>100ms)
- 连接池等待时间
- InnoDB缓冲池命中率
有次半夜报警,看板显示order-db的缓冲池命中率暴跌到70%,立马扩容buffer pool,避免了一次雪崩。微服务时代,数据库监控比应用日志更重要,这话我敢跟任何人打赌。
血泪总结:微服务不是银弹,但值得折腾
经过8个月的折腾,系统现在跑得稳多了:
- 订单创建TPS从80提升到1200+
- 故障隔离效果显著:上次user-service挂了,订单下单居然还能走缓存完成
- 团队幸福感飙升:商品组再也不用等财务组联调了
但代价也很真实:
- 运维复杂度指数级增长(现在要管12个服务+8个DB)
- 本地开发环境搭建从10分钟变成1小时(Docker Compose救我狗命)
- 联调成本转移到了接口契约上(protobuf文件成了圣旨)
最近在准备下一场技术分享,主题就叫《微服务拆分中的数据库陷阱》。成都的朋友们要是感兴趣,评论区喊我,人够多我就去申请场地——反正茶馆包间比会议室舒服多了,还能边喝盖碗茶边debug。
最后说句掏心窝子的话:别为了微服务而微服务。如果你的业务还没到单体扛不住的程度,老老实实用好数据库范式、做好读写分离,比盲目拆分强一百倍。毕竟,凌晨三点被慢查询吵醒的痛苦,只有DBA才懂。
(完)
附:避坑清单速查
| 坑点 | 现象 | 解决方案 |
|---|---|---|
| 共享数据库 | 服务互相拖慢 | 每个服务独占DB实例 |
| 同步调用循环依赖 | 服务启动失败 | 用消息队列解耦 |
| gRPC连接未复用 | P99延迟飙升 | 全局连接池+keepalive |
| 缺少批量接口 | N+1 RPC问题 | 设计GetByIds类接口 |
| 事务跨服务 | 数据不一致 | 本地消息表+幂等消费 |
作者:一个在成都写Go代码的前DBA,现沉迷于用EXPLAIN分析微服务SQL
技术分享预告:下月20号望江楼茶社,《当MySQL遇见gRPC:一个DBA的微服务求生指南》

评论 0