ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java短信计费模块实战:从异步扣费到对账排障的完整拆解

Java短信计费模块实战:从异步扣费到对账排障的完整拆解 1. 计费模块到底有多复杂从一个“发短信扣钱”的需求说起JAVA 短信计费这个项目名字看着像老古董但真正把它拆开做一遍你会发现短信这种最底层的业务里计费模块的复杂度一点都不比支付系统低。业务方最初给我提需求时就说了一句话“短信发出去从客户账户里扣钱就行。”真的进入开发之后我面临的第一个问题是什么叫“发出去”通道接受了算不算网关返回 success 算不算超长短信拆成两条算一次还是两次运营商回执 48 小时不来这笔钱扣还是不扣客户说“我传了 300 字你凭什么扣我 5 条的钱”而通道账单却显示实际只按 4 条结算——这三个口径客户看到的、我拆的、通道计的全部对不齐。这其实代表了短信计费系统的本质它不是“发送成功就 update 余额”的简单逻辑而是一个横跨消息状态机、费率规则、资金结算、对账审计的强一致性交易系统。和电商订单、支付清结算非常像流量进入、计量计量、定价、扣费、对账、出报表每一步都有边界问题。这篇文章我从自己的实际项目出发把短信计费模块从表结构设计到并发扣费、对账排障的完整链路写一遍适合正在做消息类基础服务、刚接手计费/结算模块以及准备往企业级项目方向进阶的 Java 工程师参考。为什么要花力气拆这件事因为我在第一版实现里就写过“update account_balance set balance balance - 0.045 where mobile ?”跑了一个月对账的时候发现差异率到了 1.6%。后来才明白计费模块和普通 CRUD 代码最大的区别在于它必须牺牲一部分“看起来更高性能”的写法去换可审计、可追溯、可对账的强一致性。下面我按我实际推进项目的顺序把这个模块的设计取舍和排障过程完整还原出来。2. 核心设计思路与模块划分不要把计费塞进发送接口里2.1 为什么计费一定要异步化第一版我的确想过短信发送接口在返回成功之前顺手把费用算了、把余额扣了这样客户查询余额的时候永远是最新的多省事。幸好没这么做原因有三条。第一短信发送是一个高并发入口。营销短信群发高峰期一个商户可能一次提交几十万条如果每条都在发送接口里同步做“读费率、算费用、扣余额、插流水”这些数据库操作接口的 TPS 会被压到惨不忍睹。实测下来同步扣费在单库 4 万 QPS 的流量下数据库锁竞争直接变成瓶颈接口 P95 耗时会从 80ms 涨到 2 秒以上。第二计费的真实依据是回执状态不是发送接口的返回值。发送接口返回成功只代表“通道网关接收成功”不代表手机用户收到了。如果按“提交成功”扣费后来回执显示失败你又得做退款流程如果按“回执成功”扣费那计费时点天然就是异步的发送接口根本没法同步把费用算完。第三运营侧的促销活动、阶梯价、临时折扣经常要改计费规则放在同步链路里会让规则变更是“发版级”的大事。异步计费可以将费率解析独立成服务运营在后台配置好下一个计费消息自然按新费率执行。所以我的模块划分方案是发送接口只负责校验、拆分、预检余额、落库、发消息真正“算钱扣钱”的动作全部放到消息队列的消费端异步完成。整体分成四个模块open-api 负责发送/查询接口和鉴权限流message-core 负责消息状态机和回执处理charge-core 负责费率解析和计费扣费billing-service 负责日终对账和统计报表。技术栈是常规的 Spring Boot MyBatis Plus Redis MySQL RocketMQ这套组合在中等规模的短信业务里足够稳不需要引入太重的大数据组件。2.2 消息状态机和计费触发点短信消息在整个生命周期里不是“发送 - 成功”这么简单我最终设计成了一个四状态机CREATED创建、SUBMITTED已提交通道、DELIVERED回执成功、FAILED回执失败。另外附带一个 UNKNOWN超时未知的辅助状态用于回执迟迟不来的场景。真正触发计费的点只有一个DELIVERED。选这个触发点和业务方确认了好几次。有的业务方坚持“提交成功就扣费”理由是“通道已经产生成本了”。但客户体验上完全说不过去——用户手机没收到短信账单上却扣了钱投诉率一定会飙升。我采用的方案是客户侧按 DELIVERED 回执计费成本侧按通道账单为基准两边在日终对账时按差异率阈值比如 2%容忍处理。这个方案对客户友好对平台来说也不过是财务上多承担一点坏账但长期合作的商户投诉明显少了。状态流转的幂等也是重点。回执处理接口是通道主动回调的同一个 msg_id 回调可能到达多次如果每次都更新状态和触发计费就会重复扣费。这里我在数据库层面做了控制sms_message 表里对 msg_id 建唯一约束状态更新使用带有“旧状态条件”的 update 语句比如只有 current_status SUBMITTED 时才允许更新为 DELIVERED影响行数为 0 就说明是重复回调直接忽略。2.3 一个可靠的计费消息如何设计从回执处理到计费服务之间隔着一个消息队列这个环节最容易出问题。我在生产环境遇到过 MQ 消息丢失的情况原因是回执服务发送消息时用的是同步发送但没检查 SendResult后来改成同步发送并检查状态失败就重试三次还不行就落一张本地消息表用定时任务补偿扫描。计费消息体也不能只传一个 msg_id那样计费服务每次消费都要回查好几张表。我传的是一个精简事件msg_id、customer_id、mobile、split_count、channel_type、template_type、submit_time。这样消费端拿到消息后只需要查费率表不用回查发送记录既减少了一次网络 IO也避免“发送记录还没提交成功计费消息已经到了”这种时序问题。消息的消费语义是 at least once也就是说消息可能重复投递。这决定了计费逻辑从第一行代码开始就必须是幂等的不能靠“消息队列保证只消费一次”来兜底。后面我会单独讲幂等的双保险方案这是整个模块里踩得最深的一个坑。3. 费率模型和核心表结构设计配置要灵活账要能查3.1 费率建模不能把价格写死在代码里第一版做费率的时候我图省事直接在枚举类里写了几个常量SMS_CODE_PRICE BigDecimal.valueOf(0.045)。当时觉得短信类型就验证码、通知、营销三种价格也稳定没必要做配置表。结果上线两周就被打脸运营要搞“新客首充 1000 送 500 条”的活动要按客户级别打折通道供应商宣布通知类短信降价 0.005 元一个大客户要求代理类短信按阶梯价结算超过 10 万条单价降到 0.036。如果这些需求每次都要改代码发版那速度完全跟不上。我的重构方案是拆成两张表资费方案表 rate_plan 和资费明细表 rate_plan_item。rate_plan 定义某个客户在某段时间内使用哪套资费字段包括 customer_id、plan_name、effective_time、expire_time、status。rate_plan_item 定义这套资费下具体怎么算钱字段包括 plan_id、channel_type、template_type、billing_mode、unit_price、bracket_condition、bracket_price。匹配优先级上遵循“最精准优先”客户精确配置大于客户所属商户组配置大于平台级默认配置大于通道成本配置。查询时用一个统一的费率解析方法先查客户级没有再查组级再往上找默认最后兜底用通道成本价。这套设计看起来很常规但真正好用的是 bracket_price 字段——阶梯价直接在表里用 JSON 存区间和对应价格解析时反序列化比较即可不需要为了几个档位单独建子表。3.2 核心表结构一张表说清楚计费链路表设计是这个项目里最值得反复打磨的部分。我最终保留了五张核心表每张都有明确职责sms_message 管消息状态、sms_charge_log 管资金流水、rate_plan 和 rate_plan_item 管费率、customer_account 管余额、sms_daily_stat 管日终统计。sms_message 表重点字段是 msg_id唯一索引、customer_id、mobile、content、split_count、fee_status、status、submit_time。split_count 就是短信拆条数这个字段在后面计费和对账里反复用到。fee_status 的取值范围是 0 未计费、1 已计费、2 已调整任何一笔费用的审计都要能通过 fee_status 反查当时的计费动作。sms_charge_log 表是整个计费系统最核心的流水表字段包括 log_id、msg_id唯一索引、customer_id、channel_type、template_type、unit_price、count、amount、charge_status、create_time。amount 就是最终扣费金额。这张表每产生一条记录就是一笔真实的资金变动所以它只允许插入不允许修改和删除。如果发现计费错误正确做法是再插一条负数冲正记录而不是直接 update 原记录。这个习惯我是在财务审计查账的时候被逼出来的——审计要的是“每一笔钱从哪来到哪去”的全链路直接删改会破坏追溯链。customer_account 表字段是 customer_id、balance、freeze_balance、version、updated_at。balance 是可用余额freeze_balance 是冻结金额。version 字段是给乐观锁预留的但我后面实际用的是悲观锁这个字段在余额查询接口里做缓存一致性判断时用上了。五张表的关系是这样的客户先按 rate_plan 算出单价消息成功回执后往 sms_charge_log 插流水同时扣减 customer_account 的余额最后日终任务把当天所有流水汇总进 sms_daily_stat。3.3 BigDecimal 和短信拆分规则是两件必须死磕的小事金额计算第一原则绝不用 double 或 float。原因不用多讲0.1 0.2 不等于 0.3 这种精度问题在计费系统里是致命的。所有金额和单价一律用 BigDecimal单价保留 4 位小数金额结果保留 2 位小数数据库字段用 DECIMAL(10,4) 存单价、DECIMAL(12,2) 存金额。我见过同事直接用 MySQL 的 DOUBLE 字段存金额上线三个月对账差了四百多块最后查出来就是浮点精度导致的舍入差异只能逐笔人工核对。短信拆条规则也得仔细。中文字符按 70 个字符一条英文字符数字按 160 个字符一条长短信按多条计算。但实际场景比这复杂如果内容带签名“【XX公司】”签名部分会占用字符数不同通道对 GSM 编码和 UCS2 编码的拆条结果不一样。业界一个稳妥做法是中文内容按 67 个字符一条折算留出签名和编码余地。我在生产环境踩过的坑是客户传了 300 字通知短信我按 70 字拆成了 5 条结果通道侧实际只按 4 条结算客户却已经在界面上看到“扣费 5 条”投诉我们多扣钱。后来和财务对齐口径计费的条数基准永远是“我方按内容预拆的 count”不是通道返回的条数也不是客户自己数的条数。这个口径会写进客户协议里对账时也按这个基准通道侧的差异走成本差异调整不直接影响客户账单。4. 实操链路全记录从发送预处理到日终对账4.1 发送阶段的三步预处理校验、拆分、预检客户调用发送短信接口时我写的处理流程不是“直接怼给通道”而是先做三步预处理。第一步是基础校验手机号合法性、内容长度、客户状态、签名合规。第二步是拆分计算就是上面说的按内容计算 split_count。第三步是余额预检用拆分后的条数乘上当前费率算出预估费用然后检查账户可用余额够不够。不够就直接返回 4021 余额不足不往下游发送。为什么这里只做“预检”而不做“预扣”因为真正扣费要等 DELIVERED 回执如果发送前就扣了后面回执失败就得退款退款又要走冲正流程把简单问题搞复杂了。但只查不冻也有风险一个大客户一次提交 50 万条群发预估费用 2 万可此时账户余额只剩 1 万按条拆开来每条都能发等真正计费的时候余额早已被几十万个计费线程扣穿。所以我最终采用了“余额预检 预冻结”结合的方式预估费用不超过余额的一定比例比如 80%就放行同时把预估费用写入 freeze_balance。这个冻结在消息成功计费时自动释放在消息失败或超时时按规则回滚。预检主要是避免在发送阶段就把账户扣成负数真正的强一致保障在扣费阶段。发送阶段的代码还有几个细节容易踩坑。一是内容编码要统一用 UTF-8签名和正文的拼接要在发送端完成不能在计费阶段临时改内容否则拆条数和实际发送的条数会不一致。二是要记录 submit_time这个时间戳是日终对账的时间基准。三是发送接口要返回预估费用和拆条数给客户让客户在界面上提前看到“这条短信预计扣费多少钱”减少后续计费时的预期差。4.2 回执处理的边界问题DELIVRD 到底算什么短信通道回调的回执格式各家不太一样但核心信息就三个msg_id、状态码、时间。状态码 DELIVRD 是移动运营商体系里最常见的成功标识联通和电信有些通道用 DELIVRD有些用自己的状态码。我在回执处理模块里做了一个状态码映射把通道返回的各个状态码统一映射成四类成功、失败、未知、重试中。未知状态的消息不触发计费而是进入超时扫描队列。回执处理接口接收回调后第一件事不是 update 状态而是校验签名和鉴权。通道回调的 URL 是公网可以访问的如果不校验来源任何人都可以伪造回调把消息状态改为 DELIVRD 触发扣费。这是安全审计时发现的高危漏洞。鉴权方案用的是回调签名通道在回调时携带 AppId 时间戳 签名串和密钥计算比对。鉴权通过后才是状态更新更新成功后再发送计费消息。这里要注意这两个动作“更新消息状态”和“发送计费消息”不是原子的。如果状态更新成功但发消息失败这条消息就永远不会被计费。我的兜底方案是本地消息表更新状态和写消息表在同一个事务里定时任务扫描消息表中未发送成功的记录做补偿。这套“事务消息表 定时补偿”的方案虽然土但在没有使用 RocketMQ 事务消息的场景下最可靠。4.3 扣费和流水写入的原子性锁、查、算、写、更计费服务消费到计费消息后执行的是最核心的扣费逻辑。我最终写成的事务步骤如下根据 customer_id 锁定账户行SELECT * FROM customer_account WHERE customer_id ? FOR UPDATE。悲观锁这一步不能省后面细讲原因。根据 msg_id 查 sms_charge_log存在就直接返回说明已经计费过。根据消息里的 channel_type 和 template_type 查费率算出 unit_price 和 amount。校验余额balance freeze_balance 是否足够本次扣费。不足则记录欠费标记并按欠费策略处理。扣减余额UPDATE customer_account SET balance balance - ? WHERE customer_id ?。插入 sms_charge_log 流水。更新 sms_message 的 fee_status 为 1。这七步必须在同一个数据库事务里执行任何一个步骤失败全局回滚。还有一个铁律事务内部不能调用任何外部系统比如发通知短信、调审计接口、调缓存同步全部放到事务提交后通过 Spring 的 TransactionSynchronizationManager 注册 afterCommit 钩子执行。我第一版在事务里调了 Redis 做缓存同步结果一个慢 Redis 直接把事务连接占住了导致连接池被打满扣费消息大量堆积这个教训很深刻。4.4 日终对账统计口径和时间切点是最大的坑日终对账是短信计费系统上线后真正能发现问题的地方。我最初的实现是每天凌晨用定时任务按 charge_log 的 create_time 做汇总结果对账差异率一直在 1% 左右波动。排查后发现原因很简单通道回执可能延迟到次日凌晨才到昨天的消息今天才计费按 create_time 汇总就会把今天产生的费用算到今天和客户预期的“发送当天扣费”完全对不上。后来改成以 sms_message 的 submit_time 为时间基准做日汇总先把当天 submit_time 的消息捞出来按状态判断成功数再按成功数和费率算出应收费用然后和 charge_log 里的实际流水比对。对不上的就生成差异明细人工审核。统计口径上还定了一条规则所有时间切点都按自然日 0 点切不管回执什么时候到消息归属到 submit_time 所在的那一天。这样客户看到的账单和日终报表永远一致。日终汇总还有一个容易被忽略的维度按客户汇总的费用必须等于按通道汇总的费用之和。也就是从客户维度看“今天 A 客户应扣多少钱”从通道维度看“今天移动通道发了多少条成功短信”这两个口径的条数可能不同因为一个客户可能同时用到多个通道但金额加起来必须一致。我加了一个复核 SQLSELECT customer_id, SUM(amount) FROM sms_charge_log WHERE create_time 切点 GROUP BY customer_id再用这个结果和 sms_daily_stat 比对完全一致才算对账通过。5. 实战中反复踩到的 4 个典型问题与排查实录5.1 重复计费Redis 判断 数据库唯一索引必须双保险大概上线第三周运营反馈一个客户账户余额不对对账发现少扣了一条。查下来是重复计费场景的反面——漏计费。第一版我的幂等方案只在 Redis 里做 SETNX消费消息时先 set msg_idset 成功才往下走。翻车现场是这样的计费服务消费消息后 Redis 里 set 成功了但数据库事务执行到一半抛了异常回滚消息因为没被 ACK 又被重新消费。第二次消费时 Redis 里已经有这个 msg_id直接 return这条短信就永远没有扣费。Redis 防重失效的根因在于“Redis 操作成功”和“数据库事务成功”之间没有原子性。修复方案是加数据库兜底sms_charge_log 表对 msg_id 建唯一索引插入流水时捕获 DuplicateKeyException捕获到了就说明这条消息已经计费过直接返回成功即可。Redis 作为第一层挡板负责拦截绝大多数重复请求数据库唯一索引作为最终防线两者缺一不可。实测在日请求百万级消息的场景下这套组合没有再出现重复或漏扣。这里还有个细节唯一索引一定要建立在 msg_id 上而不是 log_id 上。log_id 是自增主键如果业务上误传了相同 msg_id唯一索引能挡住但 log_id 挡不住。我后来把所有计费相关的表里凡是业务上应该唯一的字段全加了唯一索引包括 msg_id、customer_id stat_date 这种组合键尽量在数据库层面就把脏数据挡死。5.2 并发扣费时余额判断失效先拿锁再查余额这是短信计费里最经典的并发问题也是一个让我重写了一遍扣费逻辑的教训。第一版扣费代码逻辑是先 SELECT balance 查余额余额够了就 UPDATE 扣减。问题显而易见100 个计费线程同时查到余额 100 元同时判断“余额充足”再同时执行扣减最终余额变成负数扣费照样成功。正确的顺序是先 SELECT ... FOR UPDATE 锁定账户行锁拿到之后再查余额、判断、扣减。也就是“先拿锁再查账”。悲观锁虽然牺牲了一部分并发吞吐但计费这种强一致操作必须要串行化不能让“查余额”和“扣余额”中间出现其他线程的插入操作。我在代码注释里特意写了一句话余额预检可以用缓存扣费判断必须用锁内的最新值。还有一种加固方案是账户冻结机制大客户群发前按预估费用冻结一部分余额实际发送完成后再从冻结金额里划扣。冻结和划扣的一致性由一个数据库事务保证冻结时写一条流水并增加 freeze_balance划扣时减少 freeze_balance 并增加一条扣费流水。这种机制能避免“预估 100 元实际扣 10 万”的极端情况尤其适合一次性提交几十万条的营销场景。不过要注意冻结金额要有过期时间超时未知状态的消息要定期释放冻结否则客户的可用余额会越来越少客户自己都查不清钱去哪了。5.3 回执迟迟不来技术兜底和财务口径要一起解决短信回执的延迟区间很宽快的时候几百毫秒慢的时候几个小时极端情况下 48 小时都没回执。真实生产环境里我遇到过国际短信通道基本不返回回执的情况这类消息永远停留在 SUBMITTED 状态也就永远不会计费。客户当然高兴但平台的通道成本已经产生了日终对账时财务天天来找我。我的解决思路分三层。第一层是技术兜底定时任务扫描 SUBMITTED 超过 24 小时的消息按“未知”状态处理未知消息不扣费但也不释放冻结48 小时后自动释放冻结余额。第二层是财务口径对客户扣费按“未知不算成功”的保守原则成本由平台兜底或走特殊审批尽量不因为技术延迟造成客户侧误扣费。第三层是运营手段对长期无回执的通道做健康度评估回执率低于阈值的通道直接降权把流量切到回执稳定的通道上。实际排障时还有一个排查技巧回执延迟集中爆发通常不是通道问题而是我们的回执处理线程池满了。有次凌晨活动大促回执量突然翻倍我的处理线程池用的是默认的固定线程池加无界队列结果队列越积越多平均回执处理延迟从 200ms 涨到 15 分钟。排查时先看的是 MQ 消费积压后来才发现是回执处理接口自身在 GC 上花了大量时间因为状态更新的 SQL 没有走索引全表扫描。给 sms_message 表的 msg_id 和 status 建好联合索引后问题立刻缓解。这也提醒我回执处理链路和发送链路一样是高性能入口SQL 必须要走索引线程池参数要压测后固定。5.4 拆分口径对不齐计费条数必须有“调整”机制短信计费对账里最隐蔽的问题就是拆分口径不一致。我按 70 字一条拆通道可能按 67 字一条拆客户可能按 160 字一条拆——三方拆出来的条数不一样费用自然对不上。更麻烦的是签名是否占字符不同通道的规则也不一样。有些通道把【签名】算在内容长度里有些通道不算导致同一内容在不同通道的拆条数不同。我的方案是在发送阶段就确定“预拆条数 count”作为客户侧计费的唯一基准通道回执返回的条数记为 channel_count两者不一致时写入一条 difference_log。日终对账脚本会对 difference_log 做聚合分别统计“我方预拆总数”“通道侧总数”“差值条数”然后按配置决定差值的处理方式是补扣、退补还是只走内部成本差异不进客户账单。默认线上规则是“以客户侧预拆 count 为准”因为客户能明确预期你提交什么内容我们就按什么规则拆拆出几条就扣几条的钱。通道侧的成本差异由平台财务吃下不进客户账单。这个规则要在客户协议里写清楚不然客户会拿通道返回的条数跟我们对质运营解释成本很高。还有一点内容模板如果是固定模板预拆结果最好做缓存避免每发一条都要重新算一遍拆分一个长模板的计算也能省下不少性能。6. 写在最后计费系统真正的护城河是什么这套 Java 短信计费模块从第一版“发短信扣钱”的 200 行雏形到后来能支撑日千万级消息、对账差异率控制在 0.01% 以内的稳定系统中间经历了三次大的重构。我个人的体会是计费系统真正的难点从来不在“算钱”而在“每一分钱都能说清楚来龙去脉”。所以设计时所有决策都围绕可追溯、可审计、可对账这三个词来流水只增不改、时间基准统一、状态翻转留痕、幂等双保险。这些约束在开发时确实很繁琐甚至觉得“不就是扣个钱吗搞这么复杂”但上线后每一次对账差异排查、每一次客户投诉都会证明这些约束的价值。如果让我对这个项目再做一次复盘我最想改进的不是计费算法而是监控告警。后来我补上了每小时的计费成功率监控、扣费延迟 P95 监控、对账差异率阈值告警系统出问题的平均发现时间从“客户投诉”提前到“监控告警”。如果你也要做类似的计费系统我建议在写第一行代码前先把“这笔费用出问题时你能多快知道”这个问题想清楚它和“怎么算钱”一样重要。
返回列表