
短信计费这四个字放在Java技术栈里是企业级开发里相当经典的一类场景你有一套短信发送服务上游对接各家云短信网关下游面对众多业务方或终端用户每发一条短信就要记一条账、扣一分钱。听上去就是“发一条扣一条”但真正落地做过的人都知道这东西最容易踩的坑恰恰在于并发超扣、重复计费、账单对不上、网关回执延迟导致状态错乱。这篇文章我就从实际开发过的计费系统出发把整体设计思路、数据库表结构、关键代码实现以及文档里查不到的坑一次性讲清楚。适合正在做或者准备做短信平台、消息通知、营销触达服务的Java后端开发者参考读完你基本能照着自己搭出一套可用的短信计费模块。1. 短信计费系统的整体设计思路1.1 先把核心矛盾拆清楚短信计费系统听起来简单无非就是“发一条、扣一笔、记一笔账”。但一旦拆到业务细节你会发现需求方给出的说法五花八门按条计费、按套餐包计费、按字数计费、营销短信和验证码短信价格还不一样大客户还有阶梯价。更麻烦的是财务要求每一笔扣费都能追溯到具体的人、具体的短信内容、具体的发送时间。所以第一步永远不是写代码而是把计费模型和业务边界定清楚。我一般会把需求拆成四个核心模块账户权益管理、计费规则引擎、发送流程编排、对账补偿机制。账户权益管理负责“用户有多少钱可以花”计费规则引擎负责“这条短信到底该扣多少”发送流程编排负责“怎么把短信发出去并且把账记上”对账补偿机制负责“万一中间任何一步出了偏差怎么把钱和状态纠正回来”。听起来是四个模块但真正落地时全都要围绕一张发件流水表来转这条流水就是整个系统的账本。可以用一个生活化的例子来理解这件事短信计费系统就像小区门口的自助洗衣房用户先买充值卡洗一次扣一次钱。单次扣费很简单难的是要处理“卡里余额不足怎么办”“两台机器同时刷同一张卡会不会扣两次”“机器吞钱了但没开始洗要不要退款”这类异常。短信计费系统也一样它的技术难点从来不在“发送短信”本身而在于如何在各种不确定性面前依然保持账目正确。1.2 技术选型为什么是这套组合我见过不少团队上来就引入微服务、分布式事务最后发现业务量根本撑不起来还把自己绕进去了。做短信计费系统我强烈建议从单体应用起步技术栈就用最成熟的那套Spring Boot MyBatis MySQL Redis RabbitMQ。Spring Boot不用多说生态成熟starter机制让集成变得非常省事无论是Web层、定时任务还是消息消费者一套框架全搞定。MyBatis我用了很多年短信计费这类业务最大的特点是SQL逻辑复杂、需要强控比如余额扣减需要写条件更新语句MyBatis手写SQL的优势就体现出来了比起JPA那种完全对象化的操作MyBatis能让每一行SQL都在你眼皮底下排查问题时非常直观。MySQL是计费账本的底座ACID事务能力是“钱不错”的最低保障这一点是任何NoSQL都替代不了的。Redis在这里承担的职责是分布式锁和幂等标记。短信发送是典型的高并发IO操作营销活动一上线瞬间涌进来几万个请求Redis处理这种短时间高QPS的场景很稳定而且能帮MySQL挡住大部分流量。RabbitMQ用来做发送操作的异步削峰调短信网关不能放在用户请求的同步链路上否则网关一抖动接口就得超时。把发送请求丢进MQ由消费者异步去调网关用户端只需要等一个“已受理”的结果就足够了。有人会问为什么不用Seata分布式事务我的回答是这套系统如果你已经拆成了独立的微服务那另说但如果还是一个单体应用完全没必要为了分布式而分布式一个本地事务就能解决的事情引入Seata只会增加复杂度和故障点。还有人问为什么不用MongoDB做账本因为短信计费的每一条记录都需要强一致的事务保障尤其是扣费和流水写入必须在一个事务里完成MySQL在这方面依然是首选。2. 计费模型与数据库表结构设计2.1 计费模型怎么定才灵活计费模型是这类系统最容易被“写死”的部分。很多团队一开始只做了按条计费结果运营没过多久就提需求要做套餐包、要送短信、要搞阶梯价改动一次表结构就牵一发动全身。我建议一开始就把计费模型抽象成“按条单价”和“套餐余量”两条线并行。按条计费的核心是一个计价规则表字段包括渠道类型、短信类型验证码/通知/营销、计费单价、生效时间。计算时根据业务方和短信类型去命中单价一条短信扣一次。套餐包则是一张用户套餐表记录用户购买的套餐标识、总条数、剩余条数、生效时间和过期时间扣费时优先扣套餐余量套餐用完了再走按条计费。这里有一个特别容易忽略的点长短信怎么计费。一个纯文字短信超过70个汉字就会被网关拆分成多条拆出来的每一条都单独计费。很多刚接触短信业务的开发都会在这一步算错账我做过的最稳妥方案是发送前先根据字符集按规则计算拆分条数把“预计扣费金额”和“实际拆分条数”一起落到发件流水里之后再拿网关的最终回执做校正。这样哪怕网关和本地拆分规则有细微差异也能在对账时拉平。2.2 表结构设计要点与索引细节直接给出我反复调整后确定的四张核心表每张表字段都经过真实业务检验。第一张是账户表我习惯命名sms_account。关键字段包含id、user_id、balance余额、frozen_balance冻结金额、version乐观锁版本号、update_time。余额字段强烈建议用bigint存“分”不要用decimal或float分单位可以彻底避开浮点误差结算时再除以100展示成元。frozen_balance是用来做“预冻结”的调用方发起发送请求时先冻结金额发送成功再扣减发送失败就解冻。这张表的核心索引就是user_id唯一索引一个用户只能有一条账户记录。第二张是发件流水表命名sms_send_record。字段包括id、request_id业务请求唯一键、user_id、phone、content、channel_id、status、fee、before_balance、after_balance、create_time、notify_time。status是整个系统的状态机核心我用的枚举值是INIT待处理、SENDING发送中、SUCCESS发送成功、FAIL_FAILED发送失败、REFUND_ING退款中、REFUNDED已退款。这张表里request_id必须加唯一索引它是整套幂等机制的数据库兜底防线。第三张是扣费流水表命名sms_deduct_log。字段包括id、record_id、user_id、before_balance、after_balance、fee、deduct_type扣费/退款、create_time。这张表有点像财务上的记账凭证任何一次余额变动都要产出一条对应记录而且必须和发件流水表的状态变更在同一个数据库事务里完成。我见过不少团队省略这张表结果出了问题只能靠日志猜那种体验太难受了。第四张是计价规则表命名sms_price_rule。字段包括id、channel_id、sms_type、unit_price、min_count、max_count、effective_time。阶梯计价就靠min_count和max_count控制区间。这张表一般变动不频繁但一定要留有效时间字段因为改价不能影响到历史订单的对账。索引上有几个细节值得说出来。发件流水表除了request_id唯一索引之外一定要加user_id create_time的联合索引因为“按用户按时间查发送记录”是最高频的查询。扣费流水表的record_id要加索引对账时靠它关联发件流水。计价规则表如果数据量不大加一个channel_id sms_type的联合索引就够了查询频率很高但数据量小不用过度设计。3. 核心业务实现与防坑指南3.1 发送与扣费主流程的代码逻辑短信发送和计费的主流程我一般写成五个步骤校验账户、幂等检查、扣减或冻结费用、投递MQ触发发送、异步处理网关回执。先看校验和幂等检查的代码结构我习惯把这段写成独立的service方法public String submitSms(SmsSubmitRequest request) { // 1. 幂等检查同一个requestId只能处理一次 SmsSendRecord exist sendRecordMapper.selectByRequestId(request.getRequestId()); if (exist ! null) { return exist.getStatus(); } // 2. 查询账户并校验余额 SmsAccount account accountMapper.selectByUserId(request.getUserId()); long fee calcFee(request.getChannelId(), request.getSmsType(), request.getContent()); if (account.getBalance() fee) { throw new BusinessException(账户余额不足); } // 3. 核心条件更新扣减余额防止并发超扣 int rows accountMapper.deductBalance( account.getId(), fee, account.getVersion()); if (rows 0) { throw new BusinessException(扣费失败请重试); } // 4. 落发件流水状态为SENDING SmsSendRecord record buildRecord(request, fee, account); sendRecordMapper.insert(record); // 5. 投递MQ异步调用真实短信网关 mqTemplate.convertAndSend(sms.send.queue, record.getId()); return SUCCESS; }重点看第三步的SQL这是我强调过无数次的“条件更新”UPDATE sms_account SET balance balance - #{fee}, version version 1, update_time now() WHERE id #{accountId} AND balance #{fee} AND version #{version}这条SQL的精髓在于把“余额充足判断”和“余额扣减”放到了同一条update语句里。如果采用先select余额再update的方式两个并发请求可能同时读到余额为100的账户然后各自扣5最后余额变成90而不是95这就是经典的先读后写竞态问题。靠数据库行锁配合条件更新才能保证扣费操作是原子的。version字段负责乐观锁兜底当任何一次更新成功版本号就会变化下一个请求的where条件直接匹配不到行返回0业务层就能明确感知到“扣费失败请重试”。3.2 幂等与去重到底怎么设计重复发送是短信计费系统里最严重的事故没有之一。重复发一条短信顶多是用户体验差重复扣一笔钱那就是资损影响很恶劣。我排查过多次重复扣费事故根因无非三类API调用方超时重试、MQ消息重复消费、定时任务重复执行。API调用方超时重试很容易理解业务方在调用你接口时网络超时了它会自动重发一次两次请求带的是同一个requestId。这种情况下你必须在入口处拦住第二次请求。我的方案是双保险先查Redis里的幂等标记然后再依赖数据库的request_id唯一索引。Redis里用SETNX命令设置requestId设置成功说明这是首次请求设置失败说明是重复请求直接返回数据库唯一索引是最后一道保险哪怕Redis里的数据因为异常被删了第二次insert也会直接抛唯一键冲突不会产生重复流水。MQ消息重复消费是另一个隐蔽的坑。生产者把recordId投递到RabbitMQ之后如果消费者处理成功但还没来得及确认ack服务就宕机了消息会被重新投递。消费者拿到同一个recordId就会再次处理。我的做法是消费者在处理前先查发件流水表里该recordId的状态如果已经是从SENDING变成SUCCESS了直接丢弃这条消息。也就是说状态流转本身也要做成幂等的同一个recordId触发两次状态变更结果必须一样。定时任务的重复执行也值得留心。发送成功后的回执更新和对账任务一般用xxl-job或Spring自带的Scheduled如果任务框架配置了集群模式同一时刻可能有两个实例同时扫描同一批数据。处理这个问题的通用思路是扫描时加上状态和时间的限制条件例如“只扫status SENDING且create_time在5分钟前的记录”并配合分布式锁保证同一批数据不会被两个实例同时处理。3.3 先扣费还是先发送的取舍关于先扣费再发送还是先发送再扣费圈子里的讨论一直没停过。先扣费再发送的好处是确保每一笔发出短信必然有对应的扣款记录不会出现“短信出去了但余额没扣”这种吃大亏的情况。坏处是如果网关发送失败需要走退款流程。先发送再扣费则反过来好处是不会多扣用户的冤枉钱坏处是网关已经消耗了成本结果余额没扣到这就是纯亏。我最终选的方案是先冻结费用再发送最后根据回执确定是“扣减”还是“解冻退款”。具体流程是校验余额没问题后不直接减少balance而是把fee加到frozen_balance冻结余额里。等网关回执返回SUCCESS才把冻结余额转成扣款回执返回FAIL就把冻结余额解冻回可用余额。这个方案的巧妙之处在于它把“发送中”这个不确定时间段里的钱锁住了既不会出现“钱已经扣了但发送失败”的退款纠纷也不会出现“发送成功但没扣到钱”的损失只是实现上多了一个冻结字段和两条状态流转分支。冻结方案对用户体验也更友好。短信发送是有并发高峰的一小时内可能发几十万条中间必然有一部分短信发送失败。如果用户看到余额先被扣了一大笔几秒后又退回几笔体验上会觉得很乱。冻结机制能保证余额只在最终成功或失败时变动一次用户看到的账目始终是清晰可信的。3.4 异步发送与回执处理短信网关的调用是典型的慢IO接口耗时通常几百毫秒到几秒不等绝对不能放在用户的同步请求链路里。我的做法是用户请求只负责校验、扣费、落流水、投递MQ然后立刻返回“已受理”。真正的网关调用放在MQ消费者里执行消费者收到recordId后查出发件记录组装参数调用云厂商的短信发送接口。消费者这一层要特别注意两个问题。第一个是重试策略调用网关失败时要按指数退避的方式重试比如第一次失败等2秒再试第二次等4秒最多重试3次超过次数就把状态标记为FAIL_FAILED并触发冻结余额的解冻操作。第二个是超时控制OpenFeign或HttpClient调用都要设置连接超时和读取超时我一般设置连接超时2秒、读取超时5秒宁可主动失败也不要无限等下去。回执处理也有讲究。短信网关的最终状态不会同步返回而是通过异步回调通知你。云厂商会往你配置的回调地址POST一个状态报告包含手机号、消息ID、发送状态、状态描述。你需要在回调接口里根据消息ID找到对应的发件流水把状态更新为SUCCESS或FAIL_FAILED同时执行扣款或退款。这里一定要给回调接口做好签名校验云厂商一般会在Header里带签名或token你必须在业务逻辑之前验签防止有人伪造回调刷你的状态。4. 常见问题与排查技巧实录4.1 并发下余额被扣成负数这是我在项目上线后遇到的第一起线上事故。凌晨做活动几十万条营销短信瞬间涌入当时我的账户扣减SQL还是“先select再update”的老写法结果用户的100元余额被几十个线程同时读到同一笔钱被扣了无数次直接变成负数。排查时打开慢SQL日志发现同一秒内有几十条对同一账户的update基本就定位了。修复方式就是我前面说的条件更新在update语句里加上balance #{fee}的判断。这个where条件除了防超扣还有一个附加作用当余额不足时更新影响行数为0业务层可以根据这个返回值直接抛出“余额不足”异常不需要额外查一次余额。上线后我还加了一个兜底定时任务每隔5分钟扫描一次账户表只要发现balance小于0就告警虽然理论上不会再发生但账务系统里多一层主动巡检总不是坏事。4.2 重复扣费问题排查全过程有一次业务方反馈某个用户的账户在同一天被扣了两次相同的金额但实际只收到一条短信。我第一反应是查幂等结果发现这个接口是从老系统迁移过来的调用方传的requestId居然是为空字符串。空字符串在数据库唯一索引中只能插入一条但业务方每次重试都会拿同一个空串来查于是等于没做幂等。这是很多团队容易忽略的边界幂等键必须强制非空校验。解决方式不只是加空值校验更重要的是默认值策略。对于老接口不传requestId的情况我用userId 短信内容 时间戳哈希生成一个本地requestId这样就保证了即使上游不配合我们自身也能兜住幂等。另外我把发件流水表的request_id唯一索引保留不变同时给consumer消费状态增加了一次状态机校验只有当一条记录处于SENDING时才允许被回执更新为SUCCESS否则直接拒绝从状态流转层面堵住了重复退款和重复扣费的可能。4.3 网关回执延迟导致的状态不一致云厂商的回执时间并不稳定受运营商通道影响偶尔一条短信发出去了回执却迟迟不来。这时候发件流水表的状态一直停留在SENDING用户的冻结余额也一直挂着看起来就像系统卡住了。很多团队会因此怀疑自家代码有问题其实不一定是代码的锅而是缺少对账机制。我的做法是加一个定时对账任务每2分钟执行一次。扫描所有状态为SENDING且创建时间超过10分钟的记录批量调用网关的短信状态查询接口以网关查询到的结果为准强制更新本地状态。如果网关查询结果也是“未知”就保持SENDING并累计查询次数超过3次后把状态置为“疑点单”人工介入处理。这个对账任务上线后线上状态不一致的问题基本归零。这里要特别提醒对账任务的标准必须以网关回执为准不能以本地逻辑来判断短信是否成功。因为短信业务里存在“网关接受了但运营商最终没有下发”的场景只有网关侧的状态查询结果才是权威数据。4.4 锁粒度与性能问题的平衡采用冻结余额方案后一个用户同时发多条短信对这个用户账户的update操作就变成了串行化因为同一行记录的行锁会阻塞其他事务。如果某个大客户一次性群发上万条短信这上万条消息都会竞争同一把行锁MySQL的锁等待和死锁概率都会上升。我压测时就遇到过这种情况每秒几百个事务并发修改同一行账户记录TPS直接掉到底。折中的方案是按用户维度加Redis分布式锁把“查询账户、扣费、落流水”这个短事务保护起来同时把MQ消费者的消费线程数控制在合理范围不要无限加大并发。大客户的群发场景还可以考虑提前把用户余额分散到预充值的子账户里但一般业务阶段不需要做到这种深度。先保证锁粒度正确再观察实际压测数据不要过早优化。5. 写在最后的一些经验心得做了两三套计费相关的系统之后我的体会是短信计费这类业务技术难点从来不在“计费”两个字上而在于怎么在大量不确定性面前依然保证账不错、钱不丢。网络超时、MQ重复消费、网关延迟回执这些都是常态而不是异常设计时必须把它们当成一等公民来对待。我见过太多团队一上来就铺微服务、搞分库分表、上分布式事务最后却被一个幂等和重试问题搞得焦头烂额。其实做账务系统有个很朴素的道理宁可流程慢一点也绝不允许账错一笔。先把状态机画清楚把每一条流水记录设计到位把“幂等对账可追溯”这三件事做扎实系统就稳了一大半。最后再分享一个小技巧所有扣费和入账操作务必把扣费前余额、扣费后余额、费用金额、业务流水ID一起落到日志和扣费流水表里。将来无论什么时候只要有一笔账对不上你都能靠这些快照还原出当时的完整现场而不需要靠猜。这一点在被财务找上门的时候真的能救你一命。