去年年中,我们团队的代码仓库里第一次出现了一个叫 financial-services 的模块。这个名字听着覆盖面极宽,但实际落到代码里,它是整个线上资金流转的中枢:账户开立、余额变更、交易流水、记账对账全都要从它身上过。当时我们内部讨论过好几次,到底要不要把它做成一个独立服务,还是像之前的做法那样,继续在业务单体里塞一个子目录。后来上线跑了半年多,我可以明确地说,幸好选了独立服务这条路线。这篇文章就把这个模块从设计到上线的完整过程捋一遍,重点讲清楚我们为什么那样选型、核心模型是怎么设计的、以及踩过的那些坑。如果你是做支付、清结算、账务中间件或者任何强资金属性的后端系统,这篇内容应该能帮你少走不少弯路。
1. 为什么 financial-services 必须独立成模块,而不是塞进业务主程序
先说结论:资金域代码天然不适合跟普通业务代码混在同一个工程里。这个结论不是从架构书上看来的,是我们被真实事故教育之后得出的。把资金逻辑放进业务主程序,早期确实方便,一个订单一个事务全搞定,开发和联调都快。但从长期维护的角度看,资金域代码有几个特征,让它很难跟普通业务代码和平共处,这几个特征我总结成了三个“不能忍”。
第一个特征是变更频率差异大。业务侧的活动、页面、促销规则可能一天上线两三次,而资金逻辑的每一次改动都直接影响资金安全,必须走更严格的需求评审、代码评审和测试流程。把它们放在同一个工程里,就意味着每次业务发版都要带着资金模块一起做回归,成本极高,而且容易在某个不起眼的活动需求里误改到与资金相关的代码。我们之前就出过一次事故:一个同学改营销活动的优惠券计算逻辑,顺手动了一个公共的金额工具类,结果支付链路的金额校验被影响,线上出现了整整十五分钟的错误扣款提示,最后全靠日志回放才定位到问题。
第二个特征是权限边界不一样。能碰交易流水的人,和能碰用户标签的人,通常不应该是同一拨人。代码一旦放一个工程,Git权限很难做到文件级隔离,最后只能靠口头约定“大家别动那个目录”,这种约定长期来看基本守不住。尤其是团队扩张之后,新人很容易在搜索代码时误改到资金相关方法,review也不一定能看出来。与其依赖人的自觉,不如从物理层面分开,让想改资金代码的人必须经过额外的权限申请和发布流程。
第三个特征是故障爆炸半径。业务模块出问题顶多挂一个页面,资金模块出问题就可能引发重复扣款、少记一笔、对账不平这类事故,影响面会顺着资金链路传导到所有依赖它的业务。所以我们要尽量把它和普通业务物理隔离开,哪怕服务的机器挂了,也不至于因为一个边缘业务的高频调用而拖垮整个资金域。综合这三点,我们确定 financial-services 不能是一个普通子模块,必须是独立部署的服务,有自己的存储、自己的发布节奏、自己的监控告警。
1.1 我们对“独立服务”的理解:物理隔离 + 数据自治 + 对外只开放API
独立服务不是简单把一个目录拆出来,它要满足三个硬性条件,少一个都不算真正的隔离。第一是物理隔离,它的进程、资源、数据库都独享一份,不跟业务主程序共用连接池,避免互相影响。我们见过不少团队硬拆微服务,拆完之后服务确实独立了,但数据库还是共用同一个实例,业务侧一条 SQL 就能连到资金表上做查询,资源竞争和安全隐患一点没少。
第二是数据自治,资金相关的表只能由 financial-services 自己读写,其他任何服务不允许直连它的数据库。所有数据访问必须通过服务提供的 API 或者内部消息事件,不允许在别的项目里引入资金表的 Mapper。我们甚至在建库时给资金库单独建了账号,用数据库层面的权限控制来兜底,防止某个开发在代码里手滑写错连接串,直接连到了生产资金库。第三是对外只开放 API,所有业务方对资金的操作,都走我们定义的接口契约,参数要校验、权限要校验、幂等要处理。这样即使将来接口内部逻辑大改,只要契约稳定,调用方基本无感。
这个设计思路也反映在我们的目录结构上。服务内部按端口适配器模式组织,领域层不依赖任何具体框架或数据库,infrastructure 层才放真实的 DAO 实现和消息客户端。第一次这么写的人可能会觉得多此一举,但它的回报在后期维护时特别明显:换数据库、升级框架、加中间件,都不会污染核心资金逻辑。我们后来从 MySQL 5.7 升到 8.0,只改了 infrastructure 层的数据源配置,领域层一行代码没动。
2. 核心模型:账户、流水、台账是怎么落库的
2.1 账户模型:不该用“余额”字段来记账
刚开始接手资金模块的人,第一反应往往是设计一张 account 表,里面放一个 balance 余额字段,每次扣款就update account set balance = balance - ? where id = ?。这种设计在并发和账务一致性上都有隐患。余额是可变状态,每次变更都要锁行,一旦业务量上来,账户行会成为整个系统最大的热点锁,所有资金操作都堵在同一个行的行锁上,数据库 CPU 不高但事务就是提交不上去。
我们最终采用的模型是“账户只存最近状态 + 所有变更都由流水推导”。也就是说,account 表里确实可以有一个 current_balance 字段,但它只作为读优化用的冗余,核心数据来源是另外两张只追加、不更新的流水表和台账表。这样每次资金变更在 DB 层面只是插入一条流水,再在同一个事务里更新账户余额,热点锁的粒度被大幅降低。有人会问,那余额不一致怎么办?这里的关键是,余额永远不是权威数据,流水才是。对账时如果发现账户余额跟流水汇总对不上,以流水为准进行纠偏。所以在我们的系统里,账户表更像是一个缓存,而不是事实来源。
2.2 流水与台账:单向追加和借贷平衡
流水表记录每一笔资金动作的原始事实,包括请求幂等键、账户ID、变动方向、变动金额、交易类型、关联订单号、渠道、发生时间等。它的特点是一旦写入就不允许 UPDATE 和 DELETE,我们直接在数据库账号层面回收了这两个权限,从机制上保证历史记录不会被篡改。即使是补偿操作,也是新插入一条冲正流水,而不是把原来的流水改掉。这个规矩一开始执行起来有点麻烦,很多开发习惯性想 update,但坚持一段时间后,大家发现查账非常轻松,所有历史动作都完整保留,很少出现“这钱到底怎么没的”这种谜案。
台账表则更像会计里的明细账。它按照“借贷平衡”的思路设计,一个业务动作会拆成多行分录,比如用户支付 100 元,可能对应账户 A 减 100,账户 B 加 100,或者一部分进平台收入、一部分进平台暂存。每一批分录有一个 batch_id,必须满足SUM(debit_amount) = SUM(credit_amount),否则事务整体回滚。有人可能觉得这就是普通的事务嵌套,但它在处理“平账”时特别有用。比如退款、手续费分账、优惠券核销这种多账户动作,如果只记流水不记台账,事后很难快速判定哪些账户需要轧差。有了台账,每一笔业务动作的资金流向都在同一批分录里看得明明白白。
2.3 表结构示例与索引设计要点
这里给一个简化版的 DDL 作为参考,实际生产环境的字段会比这多不少,比如会加二方的商户号、渠道手续费、优惠分摊金额等,但核心骨架是这样:
CREATE TABLE account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, account_no VARCHAR(32) NOT NULL UNIQUE, user_id VARCHAR(64) NOT NULL, currency CHAR(3) NOT NULL, current_balance BIGINT NOT NULL DEFAULT 0 COMMENT '单位:分', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 2冻结 3关闭', version INT NOT NULL DEFAULT 0, created_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, KEY idx_user (user_id) ) ENGINE=InnoDB; CREATE TABLE txn_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, txn_no VARCHAR(64) NOT NULL COMMENT '业务交易号', idempotent_key VARCHAR(128) NOT NULL COMMENT '幂等键', account_no VARCHAR(32) NOT NULL, direction TINYINT NOT NULL COMMENT '1流入 2流出', amount BIGINT NOT NULL COMMENT '单位:分', txn_type VARCHAR(32) NOT NULL, ref_order_no VARCHAR(64) NOT NULL, channel VARCHAR(32) NOT NULL, status TINYINT NOT NULL, occurred_at DATETIME(3) NOT NULL, UNIQUE KEY uk_idempotent (idempotent_key), KEY idx_account_time (account_no, occurred_at), KEY idx_ref_order (ref_order_no) ) ENGINE=InnoDB; CREATE TABLE ledger_entry ( id BIGINT PRIMARY KEY AUTO_INCREMENT, batch_id VARCHAR(64) NOT NULL COMMENT '分录批次', account_no VARCHAR(32) NOT NULL, direction TINYINT NOT NULL COMMENT '1借 2贷', amount BIGINT NOT NULL COMMENT '单位:分', entry_type VARCHAR(32) NOT NULL, txn_no VARCHAR(64) NOT NULL, memo VARCHAR(255), created_at DATETIME(3) NOT NULL, KEY idx_batch (batch_id), KEY idx_account (account_no, created_at) ) ENGINE=InnoDB;这里面有几个设计要点,值得单独拿出来说。金额一律用整数,单位用“分”。浮点数在金额计算中是禁区,因为二进制无法精确表示所有十进制小数,哪怕单笔误差一厘,汇总到千万笔就是无法忽视的差额。我们用 BIGINT 存分,导出报表时再转成元,这样加减乘除全部是整数运算,不会有精度问题。幂等键必须唯一,uk_idempotent是这整张表最重要的约束,它保证同一笔业务请求即使被客户端重试、被 MQ 重复投递,在 DB 层面都无法插入第二条记录。索引要覆盖查询路径,账户流水的查询通常是“某个账户一段时间内的流水”,所以组合索引account_no + occurred_at比单列索引高效很多;对账系统则经常按ref_order_no反查业务单据,单独建索引可以避免全表扫描。
3. 资金逻辑里最容易翻车的三件事:幂等、状态机、对账
3.1 幂等:同一笔请求重试一百次,结果必须一致
资金服务最常见的故障来源是重复请求。上游超时之后会重试,MQ 消费失败后会重新投递,前端用户卡顿会连点支付按钮。如果我们不在入口做幂等,一次支付动作可能被连续执行两遍,直接导致重复扣款。我们的做法是,每个涉及资金变更的接口都要求调用方传入一个全局唯一的幂等键,比如业务订单号。服务端先检查这个幂等键是否处理过,处理过就直接返回上次的结果,没处理过才真正执行。
落到代码上,可以用一个前置中间件,把幂等检查做在业务逻辑之前,但中间件只能算第一道防线,最终的幂等还是要靠数据库的唯一索引兜底。两道防线缺一不可,因为在极端并发下,两个请求可能同时通过中间件检查,但最终还是被唯一索引挡住一个。建议在代码里把唯一索引冲突当成正常分支处理,而不是当成异常报警,否则线上会因为重复请求刷出一堆误报警告,真正的异常反而被淹没。
func IdempotentMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { key := r.Header.Get("X-Idempotency-Key") if key == "" { http.Error(w, "missing idempotency key", http.StatusBadRequest) return } if result, ok := cache.Get(key); ok { w.Header().Set("Content-Type", "application/json") w.Write(result) return } // 用分布式锁防止并发请求同时到达 lock := redisLock("idem:" + key) if !lock.TryLock() { http.Error(w, "duplicate request", http.StatusConflict) return } defer lock.Unlock() // 再次检查,因为锁等待期间可能已经被处理 if result, ok := cache.Get(key); ok { w.Write(result) return } next(w, r) } }这里还要注意,缓存里保存的返回结果必须和真正成功时的返回结果完全一致,包括状态码和响应体。否则上游重试时拿到一个“成功”的缓存响应,但内部业务状态其实是“处理中”,会让调用方产生误解。我们在缓存里存的是序列化后的完整响应对象,并且设置了合理的过期时间,一般跟订单支付超时时间对齐,避免一个幂等结果被永久缓存。对于超时后一直没处理完的请求,我们设置了一个兜底任务,定期扫描处理中状态的单据,超时后转入失败或人工处理队列。
3.2 状态机:交易状态不能允许任意跳转
交易状态如果没有约束,代码里到处写if status == 2 { status = 3 },很快就会失控。一个支付单可能会从“待支付”被重复推进到“已支付”再被错误地“取消”,然后对账怎么都对不上。我们在系统里为每一类金融单据定义了明确的状态机,例如支付单:待支付 -> 支付中 -> 支付成功,待支付 -> 已关闭,支付中 -> 支付失败。凡是状态变更,统一走状态机服务或至少一个状态校验函数,不允许业务代码里直接 assign。
实际编码时,我们用一个 map 保存合法迁移关系,每次更新都带条件where status = ?做乐观锁控制。这种方式比在业务逻辑里写一堆 if-else 要清晰得多,也更容易做并发控制。两个请求同时想改状态时,数据库的更新条件会挡住一个,保证状态不会因为并发而错乱。后来我们甚至把状态机单独抽成了一个配置化组件,每种单据类型只需要定义节点和合法迁移路径,代码里不再出现任何魔法数字,也让测试可以针对每个节点穷举所有可能的跳转。
var payStateTransitions = map[int][]int{ StatePending: {StateProcessing, StateClosed}, StateProcessing: {StateSuccess, StateFailed}, StateSuccess: {}, StateFailed: {StatePending}, // 允许部分场景下失败后重试 StateClosed: {}, } func Transition(current, target int) error { for _, allowed := range payStateTransitions[current] { if allowed == target { return nil } } return fmt.Errorf("illegal transition: %d -> %d", current, target) }状态机的另一个作用是方便做对账和排查。当一张单据出现异常时,我们可以顺着状态流转历史定位它到底卡在哪一步。我们在每张单据表里都加了一个 state_history 字段或单独的历史表,记录每一次状态变化的时间、操作人、原因。排查问题时,不用再靠日志大海捞针,直接查状态历史就能还原整条时间线。这个习惯后来帮我们解决过不少棘手的客诉问题,比如用户说“我明明支付成功了,为什么订单显示未支付”,一查状态历史发现是回调通知丢了,单据停留在“支付中”,问题归属一目了然。
3.3 对账:实时一致性做不到,就用最终对得上
资金系统里最让人头疼的问题就是“两边账对不上”。业务订单系统说这笔支付成功了,资金服务却找不到对应流水;渠道侧说已经扣款,我们的数据库里却没有这笔记录。要解决这种问题,不能只靠实时事务,必须建立一套对账机制。我们每天凌晨会把三份数据进行比对:内部流水、内部台账、渠道账单。比对的主体是金额和单号,差异会落到差异表里,由系统自动分类处理。
业务方有单、资金无流水,一般标记为“资金漏记”,这种情况需要转人工核查,确认是消息丢失还是接口漏调,必要时发起补账。资金有流水、业务方无单,可能有两种原因:一种是调用方传了错误的订单号,另一种是异常入账,需要检查是否存在非法调用或测试数据混入生产。金额不一致通常是最麻烦的,它可能是渠道手续费、优惠分摊逻辑有问题,也可能是某个金额字段在不同系统里的单位不一致,比如一个用分、一个用元。我们在对账差异表里专门加了一个差异类型字段,方便把这些问题分类统计,按优先级推进处理。
这套机制虽然不是实时的,但它能保证即便线上出现了问题,也能在 T+1 发现并纠正,不会一直错下去。我给团队立过一个规矩:实时一致性做不到,就用最终一致性加定期对账来兜底。对账不是可有可无的运维操作,它和幂等、状态机一样,是资金系统的基础设施。对账脚本本身也需要测试,我们会在测试环境故意注入几类差异数据,确认系统能正确识别和告警,防止对账逻辑形同虚设。
4. 安全护栏:权限、加密和审计日志的工程实现
4.1 越权访问:每个接口都要过一遍资源归属校验
资金服务最怕的问题不是被暴力破解,而是越权。一个用户如果可以利用订单号遍历其他人的交易明细,那加密做得再好都没用。我们在所有涉及资金数据的接口里,都会做两层校验。第一层是身份认证,确认调用者是谁,走统一的登录态和鉴权服务。第二层是资源归属校验,确认这个调用者是否有权操作目标资源。比如查询账户流水的接口,除了要求登录态,还必须校验所查询的 account_no 是否属于当前用户;如果是内部运营平台调用,还要校验运营人员是否有对应菜单的数据权限。
有一种容易漏的情况是批量接口。单个查询校验了归属,批量查询经常因为拼接条件时忘加 user_id 过滤条件而变成全量查询。我们后来统一封装了一个数据权限组件,所有查询都在 SQL 生成层自动追加归属条件,避免业务同学在写代码时凭记忆加条件。这个改动带来的安全收益比后来做的任何一次渗透测试都实在。除此之外,我们还会对敏感操作做频控,同一账号短时间内的查询次数超过阈值就自动触发告警,防止恶意遍历或者数据爬取。
4.2 敏感字段:存储层加密和脱敏返回是两件事
资金相关系统里,身份证号、银行卡号、手机号都属于敏感数据。很多团队把“敏感字段加密”和“接口返回值脱敏”混为一谈,实际上这是两件独立的事。存储层加密解决的是数据库泄露后数据仍然不可读的问题。我们使用字段级加密,对身份证号、银行卡号这类字段加密后再入库,查询时在服务内解密。这里的要点是加密密钥要托管在独立的密钥管理系统里,定期轮换,不能把私钥写在配置文件里跟代码一起提交。
接口返回值脱敏解决的是展示层泄露的问题。即使内部服务有权限查看明文,返回到前端或第三方时,也要按规则打码。比如手机号只显示前三位和后四位,银行卡号只显示后四位。我们用一个统一的序列化注解或脱敏函数处理,不在每个业务方法里手动拼接字符串,避免漏脱敏。这里特别提醒,日志里千万别打印明文敏感字段。我们有次排查线上问题,开发临时在日志里打印了整个请求体,里面带着完整银行卡号,日志又接入了第三方检索平台,最后不得不连夜做日志脱敏和清理。这类事故比代码 bug 更隐蔽,也更容易被忽略。
4.3 审计日志:谁在什么时间对哪笔资金做了什么
资金系统对审计有刚需,任何一笔资金操作,事后都必须能回答:谁、在什么时间、对哪笔资金、做了什么操作、前后值分别是什么。我们在写操作入口统一埋点,记录操作人 ID、操作人 IP、请求 traceId、接口名、入参摘要、变更前后的状态和金额、操作结果。这些审计日志独立存储,保留周期要比普通业务日志长很多,且不允许一般开发人员删除或修改。
实现审计日志时不要用业务日志表来代替,业务日志可以被截断、可以被业务代码写乱,审计数据需要单独的存储和权限管理。我们后来干脆把所有写接口的审计数据异步发送到独立的审计消息队列,再由审计服务单独落库,这样即使 finance 服务整体重启,审计链路也不会丢数据。审计日志的查询也要做好隔离,运营人员只能通过内部平台查询,不能直连数据库。审计日志的写入不能影响主链路性能,所以异步化是必须的,但异步化会带来一定的延迟,如果某个操作立刻需要查询审计结果,需要通过 traceId 去追踪,这个体验需要提前跟业务方说清楚。
5. 压测与上线:我们用一组量化指标说服了所有人
5.1 关注的三个核心指标
上线前压测其实是项目经理问得最多的问题:性能到底行不行?我们没有拍脑袋,而是先定义了三个核心指标。第一个是交易成功率,正常压测场景下要求不低于 99.99%,失败率不能因为并发上升而明显放大。第二个是 P99 延迟,资金接口的 P99 不能超过 300 毫秒,这个值对用户体验和上游超时设置都比较友好。第三个是数据库连接池水位,在目标压力下连接使用率不能超过 70%,要留出缓冲应对突发流量。
| 指标 | 目标值 | 说明 |
|---|---|---|
| 交易成功率 | ≥ 99.99% | 失败率不能随并发上升而恶化 |
| P99 延迟 | ≤ 300ms | 兼顾用户体验与上游超时配置 |
| 连接池水位 | ≤ 70% | 预留突发流量和慢查询的缓冲 |
这三个指标要预先写进测试计划里,而不是压测完再对结果做解释。否则很容易出现“压测结果不太好看,但开发说业务场景特殊,然后大家就认可了”的情况,最后带着隐患上线。资金服务尤其不能靠“感觉可以”上线,我们要的是可量化的数字,最好能落到发布检查单里,每次发版都自动核对一遍,避免引入新的性能回归。
5.2 压测场景设计
压测不是把并发数拉到很高然后看整体指标,那样只能测出系统能扛多少次裸请求。我们设计了三个贴近真实的场景。第一个场景是正常支付链路,从鉴权、幂等检查、资金扣减、记账、发消息一路走到底,模拟日常高峰期的主链路。第二个场景是超时重试风暴,人为制造上游超时,让同一批请求大量重试,目的是验证幂等和锁能不能扛住重复流量。第三个场景是慢查询干扰,人为在某个关联表上模拟慢 SQL,看主链路会不会被拖垮。
这三个场景跑完,我们发现慢查询干扰场景最容易暴露问题,因为资金服务内部查询模式非常固定,一旦某条新需求引入了一个没走索引的查询,整体连接池就可能被拖住。后来我们规定所有涉及资金表的 SQL 上线前必须 EXPLAIN 确认走索引,并把这条写进发布检查单。超时重试风暴场景也很重要,因为它能检验幂等中间件在压力下是否真的有效。有一次压测就发现分布式锁在极端竞争下出现了大量冲突报错,虽然没造成数据错误,但影响了用户体验,后来我们优化了锁的重试策略,才把错误率降下来。
5.3 上线回放验证
压测再充分,和生产流量之间还是存在差距。我们上线时用了一个“回放验证”的土办法:把测试环境的流量录下来,或者用影子表方式在预发环境回放,对比回放前后的账户余额、流水总数和台账总数。只要三个数能对齐,就说明这次变更没有破坏资金账务的闭环。实际执行时,影子表方案会比全链路压测简单很多,我们用一个标记位把回放流量路由到影子库,数据写进去后跑对账 SQL,不占用生产主库压力。
这套机制在验证数据库迁移、状态机调整、查询逻辑优化时都非常好用,哪怕发现问题也只会污染影子库,不会影响真实用户。回放验证不是万能的,它只能验证“逻辑等价”,不能验证“性能是否达标”,所以它和压测是互补关系。我们在发版checklist里固定了两步:先跑一次小流量灰度,观察核心指标是否有异常,再逐步放量到全量。金融服务的每一次变更都值得用这种谨慎的方式对待,因为线上资金事故的修复成本,往往比任何演练成本都高。
6. 联调与生产中踩过的真实问题
6.1 时钟漂移导致的时间戳排序错乱
联调阶段,最让人意外的问题来自时间戳。多个服务分别记录交易时间,一开始都用各自服务器的本地时间。结果有两台机器时钟漂移,同一笔交易在服务 A 记录的时间比服务 B 晚了十几秒,展示给用户时交易顺序完全错乱,对账脚本按时间排序也总是出现倒挂。这个问题在功能测试阶段很难发现,因为测试环境只有一两台机器,时间差异不明显,但到预发环境多机部署后就暴露了。
我们很快统一改用数据库的时钟来写入 occurred_at,应用层不再直接使用本机时间作为资金记录的时间源。这样做的代价是性能上稍微多一次数据库时间获取,但对资金系统来说,时间源的统一远比那一点性能重要。如果用数据库当前时间,要注意应用层拿到的值和数据库实际写入值可能还有微小时差,所以我们干脆在 INSERT 语句里直接写NOW(3),让数据库来定时间,避免应用层二次赋值。对于跨地域的分布式部署,还要进一步考虑用统一的 NTP 服务和时间同步策略。
6.2 数据库连接池被打满,原因居然是慢日志
有次线上报警显示数据库连接数接近上限,一开始以为是流量峰值,后来一查发现是某条新上线的流水查询 SQL 没走索引,单次查询耗时接近两秒,因为连接持有时间被拉长,连接池自然被打满。这个问题其实在压测阶段出现过类似情况,但当时压测的查询条件正好命中索引,没暴露出来,属于典型的“测试数据分布和生产不一致”导致的问题。生产环境的历史数据量远大于测试环境,索引失效的影响被放大了几十倍。
这个坑让我们总结出一条经验:资金服务的数据库连接池配置,不仅要看平时水位,还要考虑“最慢 SQL 的持有时间”。我们把连接池最大连接数从经验值改成了按 P99 查询耗时和 QPS 估算:目标连接数约等于 QPS × P99 耗时 × 1.5 的安全系数。那个 1.5 的系数就是留给慢查询和突发流量的缓冲。同时也加强了慢查询监控,任何超过 500ms 的 SQL 都会实时告警,DBA 和开发一起排查,避免慢 SQL 再次拖垮连接池。
6.3 缓存与数据库的一致性:资金数据不能随便缓存
资金服务上线一段时间后,有同学为了提高查询性能,把账户余额加了一层本地缓存。听起来没什么,但在一个并发变更频繁的账户上,缓存会导致页面显示的余额和数据库实际余额出现短暂不一致。对普通业务来说忍忍就过去了,但资金业务里,一个用户看到充值后余额没变,或者在重复扣款后余额没减少,产生的客诉和对账成本都远超缓存省下的那点数据库开销。
我们的结论是:资金数据的读取可以走只读从库,可以走预聚合报表,但不要轻易加业务缓存。如果实在需要缓存,只能缓存那些不可变或低频变更的数据,比如账户状态字典、交易类型配置等。余额、流水、台账这类数据,必须实时读库。我也理解性能优化的压力,但资金域的性能瓶颈很少出现在单行余额查询上,更多是出现在跨行汇总、复杂统计和报表导出上,这些问题应该通过合理的索引、读写分离和预计算来解决,而不是靠缓存绕过一致性。这套原则我们后来写进了团队的资金开发规范,成为所有新同学入职必读的一部分。