简介:《OCS计费原理与实现(排版后)》是一份面向电信运营商计费领域的技术文档,适合业务需求分析、系统架构设计、开发与运维人员阅读。内容从离线计费演进到实时在线计费的背景讲起,先交代系统定位与导读,再进入扫盲篇,详细解释TAG、业务用量、费用项、产品、订购、入帐关系、模板与策略等核心术语;随后围绕事件驱动的计费机制,梳理事件捕获、事件处理、账单生成与结算的完整流程,并细化到预处理、批价等关键环节。文档还介绍了计费引擎、数据库、消息中间件等实现组件,覆盖额度控制与安全性设计,帮助读者理解OCS从业务规则到系统落地的整体架构。资源为单个doc文档,体积约1.87MB,内容排版清晰、目录完整,既可作新人入门材料,也可作方案设计与技术评审的参考资料。目前已有297人浏览学习。
1. OCS计费到底在解决什么问题:先看清它和离线计费的本质差别
现实中的计费系统不是月底算账就完事的。用户在套餐额度里打电话、刷视频时,网络侧必须实时做出决定:这个请求放不放行?放行后立刻扣多少?这就是在线计费系统(OCS,Online Charging System)要干的事。OCS计费与实现的核心,是解决三件事:给网元批多少配额、从账户扣多少余额、以及两边最终怎么对得上账。离线计费可以容忍延迟几小时出账,OCS却必须在几百毫秒内响应,否则语音接不通、数据包被丢弃,业务直接翻车。这套逻辑在电信语音、移动数据、物联网流量包乃至云上API计量计费里都通用,做业务支撑系统或网元控制面的人基本绕不开它。
2. OCS的架构骨架:从配额鉴权到扣费的消息闭环
2.1 核心组件拆解:CTF、OCF与ABMF的职责边界
一个能落地的OCS实现,大体沿用3GPP定义的那套分工:网络侧有CTF(计费触发功能),计费中心里有OCF(在线计费功能)和ABMF(账户余额管理功能)。我先讲清三者边界,因为在自研项目里,边界划不清楚,后面改造成本非常高。
CTF一般部署在网元附近,负责监控业务事件。比如语音软交换里,CTF监控呼叫开始、呼叫结束;数据网关里,它监控每个会话的上行下行流量。CTF不直接管钱,它只做两件事:向OCF发起信用控制请求,然后按OCF下发的授权结果执行放行或阻断。
OCF是决策面。它接收CTF的信用控制请求消息,调用费率引擎和余额查询,决定本次授予多少配额、以什么费率计费,然后通过应答消息把决定返回。OCF还会维护会话状态——哪些用户正在使用服务、已经用了多少、配额有效期到什么时候。多数自研项目里,OCF是一个无状态服务,状态放Redis,方便水平扩展。
ABMF则是账户和余额的数据面,保存账户ID、余额、冻结金额、计费会话关联信息。行业里常见做法是ABMF不直接暴露给网元,只对OCF提供余额扣减、冻结、解冻和冲正接口。ABMF不复杂,但它每个操作都是热点,并发控制要点全在这一层。
| 组件 | 位置 | 职责 | 典型存储 |
|---|---|---|---|
| CTF | 网络侧 | 触发计费事件、执行策略 | 无状态,跟随网元 |
| OCF | 计费中心 | 信用控制决策、会话管理 | Redis缓存 |
| ABMF | 数据面 | 余额扣减、冻结、冲正 | 数据库集群 |
从协议上看,CTF与OCF之间用的是Diameter协议族的Ro接口,信令面上就是CCR/CCA消息对;如果还需要离线话单,就用Rf接口把CDR交给后处理系统。很多团队第一次上手时以为要自己写一套协议,其实不用,直接复用成熟的开源Diameter协议栈做编解码即可。真正的业务复杂度不在协议栈,而在状态管理和余额事务。
2.2 计费会话生命周期:CCR-I/CCR-U/CCR-T怎么流转
一次语音通话或一条数据连接,在OCS里对应一个“计费会话”。会话开始、中间、结束这三个阶段,分别对应三对消息:
- CCR-I(初始请求):CTF发现新业务事件,请求配额。OCF验余额、算费率、下发配额。
- CCR-U(更新请求):CTF的配额快用完,上报已用量并请求新的配额。CCR-U是会话心跳机制的关键,稳定程度直接决定计费正确性。
- CCR-T(终止请求):业务结束,CTF上报最终用量,OCF关闭会话并做最终结算。
需要熟悉两个核心AVP:Requested-Service-Unit和Granted-Service-Unit,分别表示CTF要多少和OCF批多少。里面可再分CC-Time、CC-Total-Octets、CC-Input-Octets等子单元,分别对应按时长、按总流量、按上下行流量计费的场景。
| 字段 | 含义 | 典型值 |
|---|---|---|
| CC-Request-Type | 初始化/更新/终止 | 1/2/3 |
| Requested-Service-Unit | 本次请求的配额 | “CC-Time: 60” |
| Granted-Service-Unit | 本次授予的配额 | “CC-Time: 120” |
| Validity-Time | 配额有效期 | 30~300秒 |
| Result-Code | 成功2001,余额不足4012 | 2xxx/3xxx |
| Final-Unit-Indication | 最后一笔配额标记 | true/false |
时序可以这样理解:语音呼叫开始时CTF发送CCR-I,OCF查余额后授予120秒。到100秒时CTF觉得配额快用完,发送CCR-U上报已用100秒,此时OCF按新余额再授予。如果用户挂断,CTF发送CCR-T上报最终用量,OCF终止会话。配额没有用完的部分,按预扣模型做返还;过期配额则在Validity-Time到期后由网元主动终止或申请续期。
这里容易误以为CCR-U只能等到配额用完才触发,实际不是。OCF通过Validity-Time限定了配额的“保质期”,强制CTF必须定期续发。也就是说,即使配额没用完,只要有效期到了,CTF也得发起CCR-U。这其实就是一个标准的“心跳机制实现”:会话保活和配额续期是同一件事,消息节奏由OCF控制。把这个看透后,再处理超时、断链、重试就都有抓手了。
3. 配额管理是计费的心脏:授信、余额与单元换算
3.1 配额计算与余额扣减
在OCS实现中,配额不是一个拍脑袋的数字,它是余额、费率和系统策略共同作用的结果。常见做法是先定义计费单元:时间计费按秒记CC-Time,流量计费按字节记CC-Total-Octets,事件计费按条记。然后再把余额按单价换算成可授权的配额。
授信的计算式可以写成:授权配额 = min(余额除以单价,单次授权上限,CTF请求量)。这个公式有三个要素,缺一个都会出问题:不除以单价,用户可能透支几千元;不设单次上限,CTF请求1GB就把这个月全部授出;不尊重CTF请求量,可能给一个只需要10秒的连接授了120秒,后面还要做退款。
实际项目里,语音场景常见的首次授权是60到120秒,续期授权是30到60秒;数据流量场景首次授权是1到5MB,续期在1MB左右。这些参数不是拍出来的,要看网元的CCR-U触发频率。我们曾经给某数据网关配了10MB的首次配额,用户短视频刚开始就连续触发续期,反而把CCR-U频率带高了。后面我一般会把首次授权压到用户无感的最低阈值,既不让消息风暴,也不透支余额。
配额扣减的模式,业内常见有两种:
| 模式 | 授权时 | 结束时 | 风险 | 适用场景 |
|---|---|---|---|---|
| 预扣/冻结模式 | 从余额扣授权全额 | 按用量退还 | 会话失控导致冻结资金挂账,需冲正 | 语音、实时互动 |
| 后扣/账单模式 | 不扣,只检查余额 | 按用量一次性扣 | 会话期间可能超额,并发读改写冲突 | 低值短会话、事件计费 |
使用预扣模式时,ABMF需要支持“冻结”“扣减”“解冻”“冲正”四种操作。这是计费和普通订单系统最不同的地方:订单扣款只有一次,计费却有“预扣后再返还”的状态变换,只要某个环节忘记返还,账户里就会积压一笔永远用不上的“空气钱”。
3.2 时延、并发与一致性
OCS计费延迟直接决定用户体验。CCR发出后如果200毫秒才收到应答,用户没有感知,但网元侧的等待资源、内存、超时定时器全被占住;端到端超过500毫秒的极端情况会直接丢会话。业界一般把OCF与ABMF之间的交互控制在30到80毫秒,整个信用控制往返控制在200到300毫秒。
要做到这一点,ABMF不能每笔扣款都开一次数据库事务。我见过的做法里,最稳的是把余额的检查、扣减放到一条Redis的Lua脚本里,脚本内同时完成“扣减”和“返还”的原子操作,数据库只做异步落账和对账。热点账户是这类方案路上的核心坎:一个热门账户在同一瞬间可能被几百个会话同时扣减,如果单线程Lua脚本批量执行,延迟会被拉高。后面我在第5章会专门讲这个坑。
一致性的第一个问题是消息重复。Diameter协议里,CCR带了CC-Request-Number,同一个会话的每一条消息序号都会递增。OCF必须把“会话编号+请求序号+请求摘要”缓存起来,发现重发时直接返回之前的应答,否则用户会被扣两次钱。
一致性的第二个问题是计费会话状态的持久化。OCF进程如果崩了,内存里的会话状态全部丢失,用户正在进行的语音会话会因为没有配额续期而被掐断。常见做法是把会话状态写入Redis并带过期时间,同时把“授权记录”打在数据库落账日志里。OCF重启后,从Redis恢复活跃会话,对无法恢复的会话做强制终止,并把此刻还没扣的授权回补。
另一个很多人忽略的一致性问题,是CTF上报的用量和OCF自己记录的耗时或流量可能不一致。CTF可能因为网元重启丢失部分用量,所以OCS一般会“以己方计费周期为准”,CTF上报值只作为明细留档,不做最终结算的唯一依据。听上去像“信你一半”,但这恰恰是长期打磨出来的血泪经验:如果完全信任网元上报的Usage,出账对不上时,你能查的依据就少了一半。
4. 用代码跑通一个最小OCS流程:从CCR构建到CCA解析
4.1 一个最小可运行的OCS原型:消息构建与授信
我不会一上来就让你在工程里从零写一套完整Diameter协议栈,那是重复发明轮子。常见做法是先用一份最简单的消息结构把业务逻辑跑顺,验证“配额申请—授信—扣款—返还”这条链路,再把对应部分替换为开源协议栈的编解码层。
原型里有三个基础件:CCR消息、余额单元、OCF处理器。先把消息和AVP写出来:
import uuid # ---------- 消息结构 ---------- def make_ccr(cc_type, session_id, requested=None, used=None, prev_grant=0, req_num=1): """构建一条信用控制请求消息。 cc_type: 1=INITIAL_REQUEST, 2=UPDATE_REQUEST, 3=TERMINATION_REQUEST """ return { "code": 272, # Diameter Credit-Control命令码 "session_id": session_id, # 一次计费会话的唯一标识 "cc_type": cc_type, "req_num": req_num, # 消息序号,用于幂等 "requested": requested or {}, # 本次请求的配额 "used": used or {}, # 本次已用的配额 "prev_grant": prev_grant, # 上一次授权的配额,用于返还 }这段代码构建了一条最小CCR。code是Diameter信用控制的命令码272,session_id对应一次计费会话的唯一标识,在真正的协议栈里它是一个FQDN格式的字符串。req_num对应CC-Request-Number,后面做幂等判断时要拿它来判断消息是否重复。requested和used是AVP容器,我用Python字典简化,实际协议栈里会换成结构化对象。
接下来写余额扣减和授信逻辑。用全局变量模拟ABMF账户,后续可以替换成Redis或数据库:
# ---------- ABMF余额与授信 ---------- ACCOUNT_BALANCE = 30.0 # 用户余额,单位:元 RATE_PER_SEC = 0.05 # 0.05元/秒,模拟语音通话费率 MAX_GRANT_SEC = 120 # 单次授权上限,秒 MIN_GRANT_SEC = 10 # 最小授权阈值,低于则视为余额不足 def query_balance(): return ACCOUNT_BALANCE def reserve_and_grant(requested_sec): """预扣配额对应的金额,并返回可授权的秒数。""" global ACCOUNT_BALANCE can_afford = int(ACCOUNT_BALANCE / RATE_PER_SEC) # 余额能撑多久 grant = min(requested_sec, can_afford, MAX_GRANT_SEC) if grant < MIN_GRANT_SEC: return 0 ACCOUNT_BALANCE -= grant * RATE_PER_SEC # 预扣 return grant def handle_ccr(msg): """CCR分发器:根据cc_type决定处理路径。""" global ACCOUNT_BALANCE if msg["cc_type"] == 1: # CCR-I:初始授权 req = msg["requested"].get("time", 60) grant = reserve_and_grant(req) if grant <= 0: return {"cca_type": 1, "result": 4012, "granted": 0, "final_unit": True} return {"cca_type": 1, "result": 2001, "granted": grant, "final_unit": (grant == MAX_GRANT_SEC)} if msg["cc_type"] == 2: # CCR-U:续期,并返还上一次未用量 prev = msg["prev_grant"] or 0 used = msg["used"].get("time", 0) refund = max(0, (prev - used)) * RATE_PER_SEC ACCOUNT_BALANCE += refund # 续期本质是一次新的初始授权 return handle_ccr(make_ccr(1, msg["session_id"], requested={"time": 60}, req_num=msg["req_num"] + 1)) if msg["cc_type"] == 3: # CCR-T:完全结束,退回全部剩余授权 prev = msg["prev_grant"] or 0 used = msg["used"].get("time", 0) refund = max(0, (prev - used)) * RATE_PER_SEC ACCOUNT_BALANCE += refund return {"cca_type": 3, "result": 2001, "granted": 0}授信核心逻辑是reserve_and_grant:先算余额可支撑的秒数,再取“请求量、承受能力、单次上限”三个值里的最小值。grant小于最小阈值时直接返回4012余额不足,避免授出1秒2秒这种没有意义的碎片。预扣发生在授权时,返还发生在CCR-U或CCR-T时,这样余额始终只反映“已授权未结算”的资金。
代码里没有考虑同一个会话重复发送CCR-I的情况。真实OCS里,CCR-I在一个会话中只能出现一次,如果CTF重复发初始请求,OCF要能识别并拒绝。为了看到完整流程,再补一个驱动脚本:
# ---------- 模拟一次完整的语音通话 ---------- session_id = str(uuid.uuid4()) # 1. 话务开始,请求60秒 cca1 = handle_ccr(make_ccr(1, session_id, requested={"time": 60})) print("初始授信:", cca1["granted"], "s", "余额:", round(query_balance(), 2)) assert cca1["result"] == 2001 # 2. 用了40秒发起续期:应退回(60-40)*0.05=1.0元,再请求新的60秒 cca2 = handle_ccr(make_ccr(2, session_id, requested={"time": 60}, used={"time": 40}, prev_grant=60)) print("续期授信:", cca2["granted"], "s", "余额:", round(query_balance(), 2)) # 3. 通话结束,上报最终用量50秒,退回(60-50)*0.05=0.5元 cca3 = handle_ccr(make_ccr(3, session_id, used={"time": 50}, prev_grant=60)) print("最终余额:", round(query_balance(), 2))预期输出能算出这样一条线:初始授信后余额30减3元变27,续期时退还1元到28,再预扣3元到25,结束时退还0.5元到25.5。用这个简单例子做基准,之后引入并发和持久化时,可以快速判断逻辑是否已经被破坏。
4.2 四个必调的参数与它们的影响
最小原型里有四个参数需要盯紧,它们直接决定消息频率和透支风险:
| 参数 | 初始值 | 调高的影响 | 调低的影响 |
|---|---|---|---|
| RATE_PER_SEC | 0.05元/秒 | 授信秒数缩短,CCR-U频率变高 | 透支风险增加 |
| MAX_GRANT_SEC | 120秒 | 单次授权大,CCR-U频率低,资金占用长 | 消息风暴,系统压力变大 |
| MIN_GRANT_SEC | 10秒 | 能过滤极小额度请求 | 可能拒绝低余额用户,体验变差 |
| Validity-Time | 30~300秒 | 配额寿命长,续期少,透支窗口变大 | 会话频繁续期,心跳压力大 |
特别提醒一点:CCR-U的续期不是“用完再报告”,而是“到点上报一次用量、同时请求下一段配额”。因此OCF在收到CCR-U时要做两步动作:先返还上一轮未用配额,再预扣新一轮配额。这个“返还加预扣”的双写操作必须原子完成,否则余额会出现短暂的虚高或虚低。
原型里用全局变量模拟ABMF,严格说是有问题的:一旦改成并发,全局变量的读改写就不是原子的。真正落地时,我一般会把ACCOUNT_BALANCE替换成Redis的字符串Key,把reserve_and_grant替换成一段Lua脚本,保证“查询—扣减—返回授权”在单线程里执行,这样能去掉大部分并发扣款的坑。
5. OCS落地避坑:最容易翻车的5个场景
5.1 授权前没做余额预扣,超授权导致欠费
现象:用户余额只剩0.10元,费率是1元/MB,系统却一次性授权了100MB。用户跑完这个流量后,余额变成负数。
原因:授信逻辑只查了余额“够不够本次请求”,没有做预扣。等到会话结束再按用量扣款,期间其他并发会话已经把余额用掉,最终扣款超过实际余额。
解决:授权前先按授信额度做预扣,会话结束后返还未用部分。如果确实需要后扣模式,则必须给ABMF加行级锁,并在授权时冻结等额信用。用Redis Lua实现最小方案:
-- reserve.lua local balance = tonumber(redis.call('GET', KEYS[1]) or '0') local max_quota = math.floor(balance / tonumber(ARGV[1])) local quota = math.min(tonumber(ARGV[2]), max_quota, tonumber(ARGV[3])) if quota <= 0 then return 0 end redis.call('DECRBY', KEYS[1], quota * tonumber(ARGV[1])) return quota这段脚本把“查余额、算上限、扣款、返回授权”放在一个原子步骤里,四个操作不会因为并发而互相覆盖。KEYS[1]是账户余额Key,ARGV[1]是单价,ARGV[2]是请求配额,ARGV[3]是单次上限。这就是前面reserve_and_grant的并发版本。别在Java或Python代码里做“先查再扣”,两个操作之间一定有时间窗,那个时间窗就是翻车的窗口。
5.2 CCR-U超时导致配额失效
现象:日志里没有扣款异常,但用户反馈“正在直播时断网,恢复后立刻好”。网元侧看到的是应答下发成功,可是配额过了有效期才到达。
原因:OCF通过Validity-Time限定了配额有效期,CTF要在到期前发起下一轮CCR-U。当续期队列出现堆积,或者CTF处理线程被阻塞,从有效期截止到下一轮应答到达之间有几十秒的窗口,网元严格按“没配额就断”执行。
解决:给CCR-U独立的处理通道,不要和CCR-I、CCR-T混在同一个消费者里;OCF侧对续期消息的处理逻辑要足够轻,只做“返还加预扣”,不落明细,明细异步写。同时在网元侧设置合理的配额过期宽容值,业界常见做法是允许0.5到1秒的过度容忍窗口,窗口内CTF还来得及把上次配额用完。更深一层,会话“心跳机制实现”要支持合并:如果同一个会话的CCR-U在队列里积压了多条,消费者只处理最新一条,旧消息直接丢弃并返回超时。
5.3 重发导致双扣
现象:同一用户短时间内出现两条金额完全一样的余额流水,授权时间差毫秒级,用户实际并没有发起两次通信。
原因:CCR-I或CCR-U在网络抖动时超时,CTF重发同一请求,但OCF没做幂等判断,把它当成新请求又扣了一次。
解决:OCF维护一个会话缓存,Key为“会话ID加请求序号”,收到消息时先查缓存;命中便不再扣款,直接把上一次的应答原样返回。缓存过期时间要大于网络重发的最大周期。遇到无法确定是否已应答的场景,宁可再次返回上一次的应答,也不要再次扣款。
这个坑我在压测时真踩过。当时把超时时间从2秒改成500毫秒,压测脚本的重发风暴瞬间翻倍,账务库里多出几十条重复扣款流水。从那以后,“幂等优先,宁可重复应答,不可重复扣款”就成了OCS开发的第一原则。
5.4 热点账户:一行余额把整个OCS拖垮
现象:充值活动期间,某账户余额Key的读写热度极高,数据库行锁等待时间飙升,周边数百用户的计费延迟全被放大。
原因:ABMF以账户ID为粒度加锁,同一账户的所有扣减只能串行执行。热点账户是计费系统的典型冲击场景,每次大促、抽奖、流量包秒杀都会碰到。
解决:对账户做“预拆分”,把一个主账户拆成多个子账户,授权时优先扣减最大余额的子账户,返还时回到原子账户。更实用的一招是热点账户下“短配额”:把单次授权从120秒砍到10秒,让锁持有时间变短,从而降低等待时延。配合监控,活动前就要把热点账户的请求日志单独采样,压测时先验证短配额和分片都生效再放量。
5.5 Final-Unit-Indication只提示不关闭,会话堆积
现象:余额仅够几十秒,OCF已经下发final_unit=true且granted=0,CTF却没有终止会话,依然继续发起CCR-U,OCF反复返回失败,计费会话状态始终不退场。
原因:CTF在处理Final-Unit-Indication时的逻辑分支太少,只知道配额用完要续期,不知道“最后一笔配额”意味着应该让业务主动下线。OCF侧也没把收到final后仍继续请求的会话兜底关闭。
解决:CTF状态机里必须识别final_unit=true且granted=0,直接进入终止流程;OCF侧对“已经给过final却仍收到CCR-U”的会话,直接返回4012并强制关闭会话,释放所有计费状态。两边都做处理,才是双保险,只靠一侧迟早出问题。
6. 上线前验证:用模拟器和压力脚本守住计费正确性
OCS的业务逻辑藏在几百行状态机代码里,最怕的就是“改一个参数,隔两天才在账上显出来”。所以上线前必须先做三类验证:功能正确性、并发压力、故障恢复。
第一类验证用模拟器把计费场景完整跑一遍:按顺序发送“初始—续期—终止”,每一步断言余额跟公式对得上。我把这套脚本叫“迷你题库”,像刷题一样每次改动后都回归一遍。例如:
def verify_voice_charge(): balance_before = query_balance() sess = str(uuid.uuid4()) c1 = handle_ccr(make_ccr(1, sess, requested={"time": 60})) c2 = handle_ccr(make_ccr(2, sess, requested={"time": 60}, used={"time": 40}, prev_grant=60)) c3 = handle_ccr(make_ccr(3, sess, used={"time": 50}, prev_grant=60)) cost = 50 * RATE_PER_SEC # 最终确认计费50秒 assert abs(query_balance() - (balance_before - cost)) < 1e-9这个断言的边界情况很实际:如果续期时返还逻辑漏了,最终余额会偏低;如果终止请求忘记返还,余额会偏高;如果最终用量取错,断言立刻失败。任何一次改动后,只要跑一遍这套脚本,配额返还那条链路有没有被破坏一眼可见。
第二类并发压测,把1000个不同会话同时打进来,观察账户余额是否出现负数、扣款流水是否与授权总量对得上。压测时重点不要看平均时延,要看P99。我一般把目标设在80毫秒以下,超了就回到参数里找原因:单次授权是不是设太大、热点账户是不是没拆分、CCR-U处理通道是不是和高负载任务混在一起。
第三类是故障注入:让OCF进程在会话中途重启,再检查Redis里的会话是否按过期时间被清理、有没有“僵尸会话”把授权永远冻结。做完这三类验证,才敢让OCS接真实流量。
从我的经历看,OCS这类系统最贵的不是开发成本,而是出账后的查账成本。有次上线新计费套餐,对账脚本晚了一个月才补进流水线,结果上线第二天就有一批配额返还数据对不上,人力核了整整一天。后来把对账脚本放进发布流水线,才彻底止住这类问题。对账脚本就是计费系统的后悔药,越早准备越便宜。希望帮到你。
本文还有配套的精品资源,点击获取