1. 先把这套东西拆开看:它到底解决什么问题
第一次接触 GoFly 这套内部代号的项目时,我脑子里冒出来的第一个念头是:市面上中后台框架已经多到看不过来,为什么还要自己攒一套?带着这个疑问把代码翻了一遍,又跟着跑了两个真实项目之后,我大概理解了它的定位——它不是又一个"万能脚手架",而是把医疗行业中后台这一个极其具体的场景里反复出现的脏活累活,提前固化成了基础设施。换句话说,它解决的不是"怎么写 CRUD",而是"怎么让十个租户的数据不串、让每一次敏感操作都留痕、让运营端和业务端共用一套代码又不互相拖累"。
这套框架的技术底座是 Go + Vue 的前后端分离:后端用 Go 承担网关、鉴权、多租户上下文、审计、业务接口;前端用 Vue 做 Admin(平台运营端)和 Business(租户业务端)两套界面,共享组件库与请求层。适用人群很明确——手里有 3 到 10 人规模的后端小团队,需要在一两个月内交付一个能同时服务多个客户机构的中后台系统;或者你正在维护一个已经有点失控的老项目,想看看别人是怎么把租户隔离、权限、审计这几件事做成"横切能力"的。基础要求不高,会写 Go 的 handler、看得懂 Vue 的 Composition API,剩下的这篇里都能补。
我特别想先把一件事讲透:"医疗级"这三个字在工程上到底意味着什么。很多人第一反应是"要过认证",但我实际接触下来,它在代码层面体现为几条很硬的约束——数据必须能追溯来源和变更、任何越权访问都要被拦住、敏感字段默认不可见、批量导出要单独管控、删除不能是真删。这些约束如果不在框架层解决,而是等到每个业务模块自己去做,最后一定是每个模块的做法都不一样,验收时挨个返工。GoFly 的价值就在于它把这些做成了默认行为,业务开发只要"不主动破坏",天然就是合规的。
而 SAAS 多租户隔离和 Admin/Business 双端一体化,是这套框架另外两根支柱。前者决定了你的产品能不能一个实例服务多个客户,后者决定了你的团队要不要维护两套几乎一样的代码。这两件事听起来都不难,真正动手做过的都知道坑有多密——租户 ID 漏传一个接口就是数据泄露级别的 bug,双端共用代码一旦边界没划清,改运营端的功能会莫名其妙影响业务端。下面我就按"怎么想—怎么做—怎么踩坑—怎么排查"的顺序,把这几块拆开讲。
2. 整体设计与选型:为什么是这套组合
2.1 中后台项目真正的时间黑洞在哪
一个新中后台项目从零开始,真正写业务逻辑的时间通常只占三成左右,剩下七成全耗在"人人都需要、但谁写都差不多"的地方:登录鉴权、菜单权限、按钮权限、数据字典、文件上传、操作日志、导入导出、定时任务、消息通知、分页封装、统一异常处理、接口文档。这些模块单拎出来任何一个都不复杂,但加起来的重复劳动非常可观,而且每个新同事进来都要重新熟悉一遍写法。
我的判断标准很朴素:如果一套框架能让我在拿到需求后的第一天就开始写"患者随访记录"这种真正的业务表,而不是先花三天搭登录页,那它就值。GoFly 的路由分层、中间件链、代码生成器基本做到了这一点——建完表跑一次生成,列表页、详情页、增删改查接口、权限点就都出来了,剩下的时间全部投在业务规则上。这个"起步速度"对承接项目制交付的团队来说,是能直接换算成成本的。
2.2 医疗场景给框架带来的额外约束
普通电商中后台,删错一条订单大不了赔钱道歉;医疗相关的系统里,一条记录被改错、被越权看到、被删掉找不回来,后果完全不是一个量级。所以这套框架在设计上有几处"反直觉"的坚持,我认为都很值得借鉴:
- 逻辑删除是默认,物理删除几乎不开放。表结构里统一带
deleted_at,业务层提供的删除接口实际执行的是标记删除,真要清理得走后台的运维流程并二次确认。 - 写操作强制留痕。新增、修改、删除、导出、审批这几类动作会被审计中间件捕获,记录操作人、租户、IP、请求参数(脱敏后)、耗时、结果。
- 敏感字段在 DTO 层就切断。手机号、证件号、住址这类字段在实体里是完整存储的,但出参结构体里默认不返回,需要单独授权才解密展示。
- 权限的最小可用原则。默认拒绝,菜单、按钮、接口、数据范围四层都要显式授权,新加的功能点不会因为"忘了关"而默认开放。
这几条如果让每个业务团队自己约定,几乎不可能执行一致。做成框架默认行为之后,最差的情况也只是"有人写了个绕过审计的自定义接口",而这种情况在代码评审里是一眼能看出来的。
2.3 Admin/Business 双端一体化到底省了什么
先说清楚双端是什么:Admin 端是平台方自己用的,管理租户开通、套餐、配额、全局配置、租户健康度;Business 端是租户方用的,也就是每个客户机构的员工登录后看到的那套业务界面。两端的用户体系、权限体系、菜单体系是同源的,但可见范围完全不同。
一体化不等于"一套界面两种角色",那是偷懒,最后会做成四不像。GoFly 的做法是同一仓库、同一套基础设施、两个独立入口:共享的有请求封装、状态管理、权限指令、基础组件、工具函数;独立的是路由树、布局、菜单配置、以及各自的页面目录。构建时通过环境变量区分入口,产出两份静态资源,部署时挂在不同的路径下(比如/admin和/console),也可以分开部署到不同的域名。
这么设计的好处很实在:修一个日期选择器的 bug,两端同时生效;但运营端加一个"租户配额调整"页面,不会让租户端多出半行代码。我的经验是,双端复用一定要在"基础设施层"复用,绝对不要试图在"页面层"复用——凡是出现if (isAdmin)的页面组件,最后都会变成维护噩梦。
2.4 SAAS 多租户三种隔离方案与选择理由
这是整个项目里最需要想清楚的一个决策,因为它一旦定下来,后期改动成本极高。三种主流方案的对比我整理成了下面这张表:
| 隔离级别 | 数据存放方式 | 隔离强度 | 运维成本 | 适用规模 |
|---|---|---|---|---|
| 独立数据库 | 每租户一个 DB | 最高 | 高,扩容迁移都麻烦 | 少量大客户、强监管场景 |
| 共享库独立 Schema | 一个库多个 schema | 较高 | 中,连接数增长快 | 中等数量租户 |
| 共享库共享表 | 单表加tenant_id | 依赖代码纪律 | 低 | 大量中小租户 |
GoFly 默认走的是共享库共享表 +tenant_id字段隔离,同时保留了通过配置切换到独立库的能力(大客户单独开一套)。选它的理由有三条:一是租户数量预期在几百这个量级,独立库的连接池和迁移成本扛不住;二是运营端需要做跨租户的聚合统计,共享表一条 SQL 就能出结果,独立库就得写循环;三是框架层能通过 GORM 的全局 Scope 把tenant_id条件自动注入,人为漏写的概率被压得很低。
注意:共享表方案的安全边界完全依赖"每条 SQL 都带租户条件"。所以框架里必须有一层强制的自动注入,并且在代码评审时把任何手写原生 SQL 的地方都标红检查。我见过太多项目,业务跑了两年,某个统计接口忘了加租户条件,直接把 A 客户的汇总数据展示给了 B 客户。
3. 核心机制拆解:租户隔离是怎么落到代码里的
3.1 租户识别链路:从登录请求到 context
租户识别是整条链路的起点,做错了后面全错。这套框架的识别顺序是:登录时确定租户 → 签发带租户信息的令牌 → 请求进来后由中间件解析并注入上下文 → 全链路透传。
登录接口本身是"无租户"的,它需要先根据账号或者租户编码定位到具体租户,再校验该租户下的用户。这里有个细节值得说:Business 端登录页通常要求用户输入"机构编码 + 账号 + 密码",机构编码就是为了定位租户;Admin 端的运营人员则属于平台级租户(一般tenant_id = 0或者一个保留 ID),可以跨租户查看。两套登录逻辑共用一套令牌签发,但令牌里的 claims 不同。
// 令牌载荷,租户信息必须签进去,不能靠前端传 type Claims struct { UserID int64 `json:"uid"` TenantID int64 `json:"tid"` Scope string `json:"scope"` // platform / tenant jwt.RegisteredClaims } // 鉴权中间件:解析令牌,把租户和用户塞进 context func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ") claims, err := ParseToken(token) if err != nil { c.AbortWithStatusJSON(401, gin.H{"code": 40101, "msg": "登录状态失效"}) return } ctx := context.WithValue(c.Request.Context(), ctxKeyTenant, claims.TenantID) ctx = context.WithValue(ctx, ctxKeyUser, claims.UserID) ctx = context.WithValue(ctx, ctxKeyScope, claims.Scope) c.Request = c.Request.WithContext(ctx) c.Next() } }注意一个特别容易犯的错:租户 ID 绝对不能从请求头、query 参数或者 body 里取。只要允许前端传,就等于把隔离开关交给了浏览器。我见过一个项目为了"方便调试"加了个
X-Tenant-Id头做覆盖,上线三个月后被人扫出来,直接成了跨租户读数据的入口。
3.2 GORM 层自动注入租户条件
拿到 context 里的租户 ID 之后,怎么保证每条查询都带上它?框架的做法是封装一个TenantDB(ctx)方法,返回一个已经挂好租户 Scope 的*gorm.DB实例,业务代码统一从这里拿句柄,不允许直接用全局db。
func TenantDB(ctx context.Context) *gorm.DB { tid := TenantIDFromCtx(ctx) return globalDB.WithContext(ctx).Scopes(WithTenant(tid)) } // 全局 Scope:自动追加 tenant_id 条件 func WithTenant(tid int64) func(*gorm.DB) *gorm.DB { return func(db *gorm.DB) *gorm.DB { if tid == 0 { // 平台级查询,显式放行 return db } return db.Where("tenant_id = ?", tid) } }这个写法能覆盖 90% 的场景,但有几个坑必须提前知道。第一,Updates和Delete同样会走 Scope,这是好事,但如果你用db.Model(&X{}).Where(...)却没从TenantDB拿句柄,Scope 就不生效。第二,Joins关联查询的租户条件需要手动补,因为 Scope 只作用于主表。第三,原生 SQL(Raw)完全绕过 Scope,这是最危险的口子。
我的处理办法是加一条团队约定:所有Raw调用必须在旁边写一行注释说明租户条件是怎么保证的,代码评审时重点看这一行。同时在测试环境做一个"租户穿透测试"——用 A 租户的令牌去请求所有接口,断言返回里不包含 B 租户的主键,这个用例跑起来很土但极其有效,能挡住绝大多数低级泄漏。
3.3 平台侧合法越权与跨租户聚合
运营端要看"所有租户的日活趋势",这个查询天然是跨租户的。如果粗暴地给平台账号也塞一个tenant_id = 0,那所有业务表都会查到空数据。所以框架里区分了两种上下文:租户上下文和平台上下文。
平台上下文下,TenantDB不追加任何条件,但要额外经过一道"跨租户操作审计"——这类查询会被单独记录,包含操作人、涉及租户数量、耗时。涉及明细数据的接口(比如"查看某租户的用户列表")必须显式传入目标租户 ID,并且校验该运营人员是否有该租户的授权范围,不能因为"是平台账号"就无差别放行。
聚合统计我建议单独走一条读路径,直接查从库或者预聚合表,不要复用业务查询。原因很简单:跨租户的GROUP BY tenant_id在数据量大之后会拖垮主库,而且这类查询的 QPS 和业务查询完全不是一个量级。我们后来的做法是每天凌晨跑一次汇总任务,把每个租户的关键指标落到一张统计表里,运营端只查这张表,响应时间从秒级降到了几十毫秒。
3.4 租户开通与数据初始化
新租户开通看起来只是"插一条记录",实际上要做的事情不少:创建租户主记录、初始化该租户的套餐和配额、生成默认角色和菜单权限、初始化业务字典、创建第一个管理员账号、发开通通知。这一串动作必须在一个事务里完成,失败要整体回滚,否则会出现"租户建了但管理员没建"的僵尸状态。
func ProvisionTenant(ctx context.Context, req ProvisionReq) error { return globalDB.Transaction(func(tx *gorm.DB) error { tenant := &Tenant{Code: req.Code, Name: req.Name, Status: StatusActive} if err := tx.Create(tenant).Error; err != nil { return err } // 默认角色与权限模板 if err := initDefaultRoles(tx, tenant.ID); err != nil { return err } // 业务字典 if err := initDicts(tx, tenant.ID); err != nil { return err } // 首个管理员 admin := &User{TenantID: tenant.ID, Account: req.AdminAccount, Source: "provision"} if err := tx.Create(admin).Error; err != nil { return err } return nil }) }套餐配额我建议做成"软限制":接近上限时提醒,超过上限时只拦截写操作、不拦读操作。硬拦所有请求的体验非常差,客户会觉得系统"突然坏了",而实际上只是配额用完。软限制配合告警,运营能提前介入沟通,转化的成功率反而更高。
3.5 数据库连接池参数怎么算
这是很多团队凭感觉填、最后被打脸的地方。共享库方案下,所有租户共用一个连接池,参数设错的直接后果是高峰期大面积超时。我的估算方法是这样:
假设单实例的峰值 QPS 是 800,平均每个请求的 SQL 耗时 15ms,那么并发所需的连接数大约是800 × 0.015 ≈ 12。但这是平均值,实际上短事务和慢查询混在一起,所以要在平均值上给 3 到 5 倍冗余,MaxOpenConns设 50 到 80 比较合理。MaxIdleConns通常设为MaxOpenConns的 60% 左右,避免频繁建连。ConnMaxLifetime建议 30 分钟,比数据库侧的空闲断开时间短,防止拿到服务端已经关闭的死连接。
datasource: max_open_conns: 64 max_idle_conns: 40 conn_max_lifetime: 30m conn_max_idle_time: 10m还有一条经验:连接数不是越多越好。数据库侧的max_connections是全局的,你一个应用开 200 个连接,多副本部署就是 600 个,很容易把数据库打满。宁可应用侧排队,也不要让数据库侧雪崩。压测时我会专门看两个指标——连接池等待时长、以及WaitCount的增长趋势,前者超过 5ms 就说明该调了。
4. 权限、审计与合规要点
4.1 三层权限模型:功能、数据、字段
中后台的权限如果只做到"这个菜单能不能看见",迟早会出问题。GoFly 分了三层,每层的实现方式完全不同,值得单独说。
功能权限是最外层,控制菜单和按钮的可见性,本质是一串权限码(比如biz:record:create),存在角色上,角色赋给用户。前端通过自定义指令v-perm控制元素渲染,后端通过中间件校验接口权限码。关键点是后端校验必须独立完成,不能因为前端隐藏了按钮就认为没人能调这个接口——打开开发者工具直接发请求是最常见的越权方式。
数据权限是中间层,控制"能看到哪些行"。典型场景是科室隔离:某个医生只能看本科室的记录。实现方式是在查询里追加数据范围条件,范围规则挂在角色上(本人、本部门、本部门及下级、全部)。这块最容易出 bug 的地方是规则的组合——一个用户可能有多个角色,数据范围要取并集还是交集?我们的做法是取并集(权限放宽),但要记录一条"多角色"的提示,让管理员知道这个人的可见范围可能超出预期。
字段权限是最内层,控制"能看到哪些列"。这层在医疗场景尤其重要,比如检验结果里的敏感项默认打码,需要单独授权才能查看明文。实现上是在 DTO 转换层做的,出参结构体里对敏感字段做条件赋值,未授权时置空或替换为掩码。
type RecordVO struct { ID int64 `json:"id"` Name string `json:"name"` Phone string `json:"phone"` } func toVO(u *Record, hasFieldPerm bool) *RecordVO { vo := &RecordVO{ID: u.ID, Name: u.Name, Phone: maskPhone(u.Phone)} if hasFieldPerm { vo.Phone = u.Phone // 有授权才给明文 } return vo }4.2 审计日志:记什么、怎么记、怎么存
审计日志的价值在于"事后能还原",所以字段设计要围绕还原这件事来。我们最终定下来的必填字段是:操作人、租户、操作类型、资源类型、资源主键、变更前后快照(脱敏)、IP、UA、请求耗时、结果状态、链路 ID。其中变更前后快照是最有用的,客户投诉"这个数据被谁改了"的时候,直接对比快照就能给出答案。
性能上必须做两件事:一是异步落库,审计日志走独立的 goroutine 池或者消息队列,不阻塞主流程;二是分表或按时间分区,日志表的增长速度远超业务表,一个中等规模的租户一年就能产出几千万条记录。我们的做法是按月分表,只在线保留 12 个月,更早的归档到冷存储。
注意:异步落库一定要处理"队列满了怎么办"。我们的策略是队列使用率超过 80% 时降级为同步写,宁可慢一点也不能丢审计记录。丢一条审计日志,在验收时可能就是致命问题。
4.3 敏感字段的加密与脱敏
什么时候用加密、什么时候用脱敏,很多团队分不清。简单区分:脱敏是显示层面的处理,数据库里还是明文;加密是存储层面的处理,数据库里存的是密文。前者防的是"别人偷看屏幕",后者防的是"数据库被拖走"。
我的判断标准是:手机号、邮箱这类需要模糊查询或者做提醒的字段,用脱敏 + 存储明文(加列级权限);证件号、住址、病历描述这类既敏感又不需要检索的字段,用加密存储。加密用 AES-GCM,密钥放在配置中心或环境变量里,绝不进代码仓库。加密后如果需要按该字段查询,就只能做精确匹配(存哈希索引),这一点在设计阶段就要想清楚,否则后期改起来极其痛苦。
4.4 双端鉴权模型的差异
Admin 端和 Business 端虽然共用令牌签发逻辑,但授权模型确实不同。Admin 端是岗位制,权限跟着岗位走,运营、客服、财务各管一摊,权限变化不频繁;Business 端是角色制,每个租户可以自定义自己的角色和权限组合,灵活度要求高,而且租户管理员自己就能改。
这带来一个实现上的差异:Admin 端的菜单树在代码里静态定义,权限码硬编码,升级时直接覆盖;Business 端的菜单树从数据库读,租户初始化时根据套餐复制一份模板,之后租户自己调整。这意味着 Business 端的菜单要做租户级别的版本管理——我们吃过亏,全局模板更新之后,部分租户的菜单出现了"父菜单没了子菜单还在"的孤儿节点,前端直接白屏。后来加了初始化脚本做一致性校验,才彻底解决。
5. 从零到跑通:一个业务模块的完整实操
5.1 环境准备与本地起服务
本地环境需要 Go 1.21 以上、Node 18 以上、MySQL 8.0(低于 8.0 的话窗口函数和一些 JSON 特性用不了,统计模块会受影响)、Redis 7。依赖管理后端用 go mod,前端用 pnpm。
# 后端 cp configs/config.example.yaml configs/config.local.yaml # 修改数据库、Redis 连接串,然后 go run ./cmd/server -c configs/config.local.yaml # 前端 cd web && pnpm install pnpm dev:admin # 运营端,默认 5173 pnpm dev:business # 业务端,默认 5174第一次启动会自动执行迁移脚本,建表并写入基础数据(平台租户、超级管理员、默认菜单)。我建议本地开发时把 SQL 日志打开(log.level: debug),能直观看到每条查询有没有带租户条件——这是最便宜的隔离检查手段。
5.2 建表与租户字段规范
业务表的设计有几条硬性约定,违反了后面的代码生成和 Scope 注入都会出问题:主键统一bigint自增;必须包含tenant_id bigint not null、created_at、updated_at、deleted_at;所有需要唯一约束的字段,唯一索引必须带上tenant_id。
CREATE TABLE biz_followup ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, tenant_id BIGINT UNSIGNED NOT NULL, patient_no VARCHAR(32) NOT NULL, content TEXT, status TINYINT NOT NULL DEFAULT 0, created_by BIGINT UNSIGNED NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), deleted_at DATETIME(3) NULL, PRIMARY KEY (id), UNIQUE KEY uk_tenant_patient (tenant_id, patient_no, deleted_at), KEY idx_tenant_created (tenant_id, created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个经典坑:
deleted_at参与唯一索引,是为了让"删除后可以重建同编号记录"。但 MySQL 里 NULL 不参与唯一性判断,所以同一条记录可以被逻辑删除多次而不报错。解决办法是用deleted_at存时间戳(默认 0 表示未删),或者干脆加一个deleted_flag。我们最后选了后者,简单直接。
5.3 后端接口实现
用代码生成器生成基础 CRUD 之后,我一般会重写 List 和 Create 两个方法,把租户和审计的细节落实。列表接口的重点是分页参数、数据权限、以及避免count(*)拖慢查询。
func (s *FollowupService) List(ctx context.Context, req ListReq) (*ListResp, error) { db := TenantDB(ctx).Model(&Followup{}). Scopes(DataScope(ctx, "biz_followup")) // 数据权限 if req.PatientNo != "" { db = db.Where("patient_no = ?", req.PatientNo) } var total int64 if err := db.Count(&total).Error; err != nil { return nil, err } var rows []Followup err := db.Order("id DESC"). Offset((req.Page - 1) * req.Size).Limit(req.Size). Find(&rows).Error if err != nil { return nil, err } return &ListResp{Total: total, Items: toVOList(rows)}, nil }创建接口除了写业务数据,还要做两件事:从上下文补tenant_id和created_by(绝不允许前端传),以及触发审计事件。审计我封装成了一个装饰器式的方法,业务代码里只写一行audit.Record(ctx, "biz:followup:create", row),具体落库逻辑在中间层统一处理。
分页查询有个性能细节:数据量大时Count会是瓶颈。我的做法是当total不需要精确时(比如前端只显示"约 1 万条"),用EXPLAIN估算行数;需要精确时,把Count的查询条件尽量收窄,或者单独走从库。
5.4 前端页面与动态路由
Business 端的路由是动态的,登录后拉取当前用户的菜单树,转换成路由配置再addRoute。这里最容易出两个问题:一是刷新页面后动态路由丢失,表现为 404;二是路由守卫死循环,表现为白屏。
// 路由守卫的正确写法:先判断是否已加载,再决定拉菜单还是直接放行 router.beforeEach(async (to, from, next) => { if (to.path === '/login') return next() const store = useUserStore() if (!store.token) return next('/login') if (store.routesLoaded) return next() try { await store.loadMenus() // 拉菜单 + addRoute store.routesLoaded = true return next({ ...to, replace: true }) // 关键:重新匹配一次 } catch (e) { store.reset() return next('/login') } })next({ ...to, replace: true })这一行是关键,直接next()的话,当前这次导航用的还是旧的路由表,匹配不到新加的路由,就 404 了。这个问题我至少见过五六个项目踩。
5.5 联调与验收清单
功能写完不算完,我习惯在提测前过一遍这张单子,能省掉大量返工:
| 检查项 | 检查方式 | 通过标准 |
|---|---|---|
| 租户隔离 | 用 A 租户令牌遍历所有接口 | 响应中不出现 B 租户数据 |
| 越权防护 | 去掉按钮权限后直接发请求 | 统一返回 403 |
| 审计完整 | 执行增删改导出 | 日志表均有记录且字段齐全 |
| 敏感字段 | 未授权账号查看详情 | 敏感列显示掩码 |
| 大数据量 | 列表页 10 万条数据翻页 | 首屏 1s 内,翻页 500ms 内 |
| 导出 | 导出 5 万行 | 不超时,文件可正常打开 |
6. 部署与性能:上线前必须过的几道关
6.1 单二进制打包与前后端同源
Go 的一个很实用的特性是能把前端构建产物用embed打进二进制,部署时只需要传一个文件,配合配置文件就能跑起来。这对私有化交付特别友好——客户现场往往只有一台机器,没有 CI,多传一个静态资源目录就多一分出错的可能。
//go:embed all:dist var adminFS embed.FS func AdminAssets() http.FileSystem { sub, _ := fs.Sub(adminFS, "dist") return http.FS(sub) }注意all:前缀,不加的话以点开头的文件(比如.well-known下的内容)会被忽略。另外单页应用要配一个 fallback 规则,非静态资源的路径统一返回index.html,否则用户直接访问深层路由会 404。
6.2 数据库迁移与灰度
多租户系统的迁移有三条铁律:可重复执行、向前兼容、先改代码再改结构。加字段没问题,删字段和改类型一定要分两次上线——第一次让代码兼容新旧两种结构,第二次再真正删掉旧字段。否则灰度期间新旧实例同时跑,老实例读到新结构直接报错。
迁移脚本我会放在代码库里做版本管理,用一张schema_migrations表记录已执行的版本号,启动时自动比对。大表加字段在 MySQL 8.0 上基本是 online DDL,但如果表超过千万行,我建议还是走 gh-ost 或者手工分批,避免长时间锁表。
6.3 压测看什么指标
压测不是看 TPS 数字好不好看,而是找瓶颈在哪。我一般只盯四个指标:P99 延迟、错误率、连接池等待、慢查询数量。前两个反映用户体感,后两个定位问题根源。
一个实际的例子:压测时 P99 从 80ms 突然涨到 900ms,错误率没变。翻了慢查询日志发现是某个跨租户的统计接口在做全表扫描,没走索引。加了一个(tenant_id, created_at)的联合索引之后恢复正常。这类问题在功能测试阶段完全看不出来,只有压测才暴露。我建议每个新上线的列表页都至少跑一次 500 并发的短压测,成本很低,收益很大。
7. 常见问题与排查实录
7.1 高频故障速查表
下面这些是我在几个项目里真实遇到过的问题,按出现频率排序:
| 现象 | 可能原因 | 排查方法 | 处理方式 |
|---|---|---|---|
| 数据串租户 | 用了全局 db 而非 TenantDB | 搜代码里所有globalDB.直接调用 | 改为 TenantDB,加穿透测试 |
| goroutine 里查不到数据 | context 未透传,租户 ID 为 0 | 日志打印上下文里的 tid | 显式传 ctx,禁止context.TODO() |
| 刷新页面 404 | 动态路由未重新匹配 | 看路由守卫逻辑 | next({...to, replace:true}) |
| 唯一索引冲突 | deleted_at为 NULL 不参与唯一性 | 查重复数据 | 改用deleted_flag或时间戳 |
| 时间差 8 小时 | 数据库时区与 Go 时区不一致 | 对比SELECT NOW()与程序时间 | 统一 UTC 存储,展示层转换 |
| 导出超时 | 一次性加载全部数据 | 看内存和 SQL 耗时 | 分批流式写,加进度反馈 |
| 菜单白屏 | 孤儿节点导致路由树构建失败 | 校验父子关系 | 初始化脚本做一致性检查 |
| 审计日志丢失 | 异步队列满后直接丢弃 | 看队列使用率监控 | 超阈值降级为同步写 |
7.2 几个值得单独说的坑
第一个坑:goroutine 里丢租户上下文。典型场景是异步发通知。主流程里起了个go func(),里面直接调了 service 方法,结果 service 从 context 里拿不到租户 ID,查出来是空的,报错又因为异步被吞掉了,最后表现为"通知有时候发不出去"。解决办法很简单——起协程前先把需要的上下文值取出来显式传进去,别依赖隐式的 context 传递。
// 正确做法 tid := TenantIDFromCtx(ctx) uid := UserIDFromCtx(ctx) go func(tenantID, userID int64) { ctx2 := WithTenantCtx(context.Background(), tenantID, userID) notify(ctx2, ...) }(tid, uid)第二个坑:逻辑删除与唯一约束的冲突。前面提过一次,这里补充完整。如果deleted_at是DATETIME NULL,那么被逻辑删除的记录deleted_at有值,理论上参与唯一性判断,但同一行连续删两次就会冲突(第二次把值改成新的时间,中间可能有毫秒级重复)。更稳妥的方案是加一个is_deleted TINYINT DEFAULT 0参与唯一索引,删除时置 1,重建时插 0,简单可靠。
第三个坑:时区。存储全用 UTC,展示层按用户所在的时区转换,这条原则大家都知道,但实际做的时候经常被time.Now()破坏。我们的规矩是:数据库连接串里明确写loc=UTC&parseTime=true,业务代码里禁止直接time.Now(),统一走封装好的clock.Now(),方便测试也方便统一处理。这个规矩在跨时区客户上线时救过一次命——某个客户的服务器时区设成了本地时间,如果没有统一封装,查出来的数据会整体偏移 8 小时。
第四个坑:批量导出把内存打爆。一开始的实现是查出全部数据再序列化,5 万行的时候内存直接飙到 2G。后来改成流式写 CSV:游标分批查(每批 2000 条),边查边写csv.Writer,内存稳定在 100M 以内。同时加了导出任务表,异步执行 + 完成后站内通知,用户不用一直盯着页面转圈。
第五个坑:租户切换后的缓存脏数据。有些配置做了本地缓存,key 里忘了拼租户 ID,结果 A 租户改了配置,B 租户读到了 A 的值。教训是:凡是和租户相关的缓存 key,一律以租户 ID 开头,这条规则写进团队的编码规范里,评审时必查。
8. 我在实际项目里的几点体会
踩过这些坑之后,我对这类框架的评价标准变了一个方向——不再看它功能有多少,而是看它在你不注意的时候能不能兜住底。功能多是加分项,但兜底能力是及格线。GoFly 让我比较认可的地方,恰恰是它把"租户条件必须带""敏感字段默认不返回""写操作必须留痕"这几件事做成了默认行为,你写得再随意,也不太容易越过红线。
另外一个很实际的感受是,共享表的多租户方案一定要配一套自动化的隔离测试。我们现在每次发版前都会跑一个脚本:随机挑三个租户,用各自的令牌把所有接口遍历一遍,比对返回结果里是否出现了其他租户的主键。这套东西写起来不到两百行,但它拦下来的问题,比我做过的所有代码评审加起来都多。
最后分享一个小技巧。框架自带的权限点、菜单、字典这些"基础数据",很容易在多人协作时被改乱。我的做法是在仓库里放一份基准快照(YAML 格式),CI 里跑一个比对任务,一旦发现有人在数据库里直接改了权限却没同步到快照,就报警。这个机制看着有点笨,但确实把"线上线下权限不一致"这类玄学问题挡在了上线之前。