ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OCS在线计费系统原理与工程实践:从预留结算到批价入帐

OCS在线计费系统原理与工程实践:从预留结算到批价入帐

简介:面向电信运营商业务支撑领域的OCS在线计费学习文档,定位为计费原理与系统实现的入门至进阶参考,适合计费系统设计、开发与运维人员理解实时计费的核心机制。包内仅1个doc格式文档,约1.87MB,取自华为技术有限公司2008年拟制的内部材料,章节编号与目录层级完整,可按序通读也可按需检索。内容从计费系统由离线批量向实时在线演进的脉络切入,先梳理TAG、业务用量、费用项、产品、订购、入帐关系、模板、策略、事件与子事件等术语,再展开预处理与批价两段流程,串联从事件捕获、费用计算、信用评估与额度控制、账单生成到结算的完整链路,并延伸至计费引擎、数据库、消息中间件等组件实现与安全防欺诈要点。目前已有297人学习,有助于读者建立从概念到实现的整体框架,对照排查计费链路中的疑难问题。

1. 为什么挂断电话的那一刻,扣费其实早就完成了

很多人对 OCS 计费原理的第一印象是「挂机之后算账」:通话结束、话单产生、系统扣钱。真实顺序恰好相反——钱在通话开始前就已经被「圈」住了。一次预付费呼叫刚拨出去,计费侧已经根据余额算出你能打多久,把这段时间对应的额度预留冻结,再把授权时长交还给业务控制侧去掐断。挂机只是把预留结算成实扣,多退少补。搞懂这个「先预留、后结算」的闭环,就搞懂了 OCS 与离线计费最本质的分野:它不是一个算得更快的账单系统,而是一套在资源消耗之前就要给出承诺的信用系统。承诺给多了会欠费,给少了用户体验崩,这才是整套机制真正要解决的事。

2. 计费系统的四代演进:为什么费率必须从代码里赶出去

把 OCS 当成横空出世的东西看,会觉得概念又多又抽象。换个角度,从「上一代系统被什么需求撑爆了」往回推,每个抽象都有明确的救火动机。这段演进大致能分成四段:两张表硬扛的原始期、把费率抽出来的简单期、账户与套餐并存的复杂期、用量与定价计划解耦的在线期。每一段的核心动作都一样——把易变的东西从代码和主干表里赶出去,赶得越远,改一次配置的代价越小。

2.1 两张表的时代:反算为什么撑不住欠费风险

最早的实现朴素得可爱:一张用户表存余额,一张费率表按主被叫归属地、主被叫拜访地四个维度的号段头查单价。预付费的实时性靠「反算」实现——通话前拿余额除以费率,得出可以通话的时长,把这个时长交给呼叫控制侧去监控;挂机后按实际时长补扣一次。这套逻辑在只有本地网、号段规整的年代完全够用。

问题出在维度爆炸。四个维度各自有几十到上百个取值,笛卡尔积就是千万量级的记录;更麻烦的是运营方会频繁调费率,改一个地区的价格要顺着四维组合逐条定位受影响的记录,改漏一条就是资费事故。新增号段同理,每开一段就要把它到各地市的费率全配一遍。这不是性能问题,是可维护性问题——系统能跑,但没人敢动。

2.2 号码分析与计费矩阵:把「改费率」从改数据变成改配置

第一次手术的思路是分层降维,把「查一次费率」拆成三步走:

def resolve_rate(calling, called, cell, lac): # 第一步:号码分析,号段归一成地区号,吃掉维度膨胀 origin_area = number_analysis(calling) # 主叫归属地区号 term_area = number_analysis(called) # 被叫归属地区号 visit_area = location_analysis(cell, lac) # 主叫拜访地区号 # 第二步:计费矩阵,地区组合映射成消费类别(费率索引) classes = [matrix_lookup("BASIC", origin_area, visit_area)] if term_area != origin_area: classes.append(matrix_lookup("LONG", origin_area, term_area)) if visit_area != origin_area: classes.append(matrix_lookup("ROAM", visit_area, origin_area)) # 第三步:费率表,消费类别映射到单价,这张表只有几十条 return [(c, rate_table[c]) for c in classes]

number_analysis的入参是号段,出参是地区号,一个地区下挂多少号段对它毫无影响,维度膨胀在这一层被彻底吸收。matrix_lookup的入参是业务类型加两个地区号,出参是一个费率索引,也就是常说的消费类别——它把一次通话拆成基本费、长途费、漫游费三类独立消费。rate_table才是真正的单价表,因为只按消费类别存,规模小到运营方自己看着都不晕。

三步拆完,改费率只动费率表,加号段只动号码分析表,两张表互不牵连。这是计费系统第一次尝到「配置驱动」的甜头,也是后面所有抽象的祖师爷。

2.3 定价计划与订购:为什么优惠必须能「装上」和「卸下」

套餐时代到来后,需求变成了消费积分、专款专销、充值分月赠送、越打越便宜这类组合拳。这一代做了三件事:把实时计费和定期事务拆成两个模块,前者管每次呼叫的批价,后者按天跑后台任务处理赠送和月租;把余额抽象成账户,一个用户可以挂多个账户,分别存长途免费时长、市话免费时长、赠送金额、累计消费;把消费类别从纯费率索引扩展成「能扣哪些账户 + 扣减优先级 + 分档费率」的复合定义。

到这一步,问题变成了耦合:一个「20 元套餐送 300 分钟市话、200 条网内短信、省内漫游一毛一分钟」的活动,要同时在两套系统里配数据,一边配赠送,一边调费率,配完还得祈祷别影响存量用户。消费类别这个模型也撑不住了——它只能表达「一类时长消费」,表达不了「两瓶矿泉水加四个面包」这种多业务量混合的场景,更别提流量、条数、直接扣款这些非时长单位。

在线计费这一代给出的答案是三层解耦。把消费类别升级成业务用量,语义变成「消费的是什么 + 消费了多少」,新增一种计费请求只是新增一种用量类型,不改主干。把每种计费请求抽象成计费事件,影响计费的参量抽象成计费要素,新增事件类型是线性扩展,不碰老分支。最后把可复用的计费能力做成模板——赠送模板、呼叫计费模板,把费率和适配条件都变成参数;把一次次优惠活动做成定价计划,一个定价计划就是调用若干模板的参数包。

真正让这套模型在现网站得住的是订购。定价计划是规则,订购是「谁受这套规则约束」。新增活动只新增数据,不订购的用户完全无感;测试时把测试用户单独订购上,就能只影响测试用户。可测试性和兼容性不是靠流程保证的,是靠数据模型天然隔出来的。

3. 计费概念坐标系:TAG、业务用量、费用项如何串成一条链

理解了演进动机,再看名词就不会觉得是硬造概念。这几个词其实各管一段:TAG 管输入怎么进来,业务用量管这次消费被拆成了什么,费用项管该收多少钱,产品与订购管用哪套规则,入帐关系管钱从哪个账户出。串起来就是一条从事件到扣款的完整链路,中间任何一环对不上,用户看到的就是「话费不对」。

3.1 TAG:为什么宁可忍受数字也不忍受字符串

TAG 本质就是变量,作用类似老系统里的 CID,差别在于计费侧习惯用纯数字标识而不是名字。比如不会说「请求通话时长这个变量的值是 300」,而是说「TAG 1080 的值是 300」。某省级局点的呼叫主流程里,基本费时长是 2003、漫游费时长是 2004、长途费时长是 2005,这些数字直接决定了批价时按哪个费率段取价。

用数字不是为了炫技。计费报文要在网元之间高频传递,数字 TAG 定长、解析快、可直接当数组下标命中取值表,一次通话里几十个 TAG 反复读写,字符串哈希的开销会被放大到不可忽略。批量配置工具也更容易处理「给所有用户补一个 TAG 默认值」这种运维动作。

代价是可读性。新人对着一堆数字完全不知道在算什么,排查时看到某个 TAG 缺失也未必意识到严重性——很多故障的根因就是上游漏填一个 TAG,下游默默取默认值 0,最后费用算成零还一声不吭。

TAG含义典型值缺失后果
1080业务控制侧送来的请求通话时长300批价时长为 0,费用为 0
2003基本费时长300本地通话不计费
2004漫游费时长300漫游费漏收
2005长途费时长300长途费漏收

注意:TAG 缺失不会天然报错,它只是一个取值失败。是否兜底、兜什么值,取决于具体 TAG 在流程里的处理约定,这类约定往往不在配置界面上。

3.2 业务用量与费用项:一个管「用了多少」,一个管「该收多少」

业务用量是一次消费被拆解之后的量。主叫漫游状态下拨打长途 5 分钟,这条链路里基本费用量、长途费用量、漫游费用量都是 300 秒;如果没漫游,漫游费用量就是 0。哪些用量类型存在,是设计阶段就规划好的,跟消费类别的思路一脉相承,但语义从「费率索引」变成了「消费内容 + 数量」,能表达条数、字节数、金额,不再被时长绑架。

费用项是批价的输出,可以理解为消费清单。它用链表串起来,一条记录对应一种消费类型、数量与金额。上面那次漫游长途,市话一毛一分钟、长途两毛一分钟、漫游三毛一分钟,批价出来的清单大致是这样:

业务用量数值金额(分)费用项
2003 基本费时长300501 本地通话费用项
2005 长途费时长3001003 长途通话费用项
2004 漫游费时长3001502 漫游通话费用项

输入是业务用量,输出是费用项链表,中间那个乘法的规则来自定价计划,而每一行最终落到哪个账户,由入帐关系说了算。这条链最容易被误解的地方是:批价只负责「算出应收多少」,不负责「从哪扣、扣多少」。把这两件事混在一起,就会在免费资源和余额并存时反复算错。

3.3 产品、订购与入帐关系:钱最后落在哪个账户

产品在账务侧的等价物是定价计划,也就是一套完整的费用计算规则包。订购描述用户名下挂了哪些定价计划,决定了某次消费该走哪套规则。入帐关系回答的是另一个问题:这条费用项该在哪类账户上扣,多类账户都可用时按什么顺序扣。

专款专销是最能说明问题的例子。用户花 50 元买 500 分钟长途时长,花 10 元买 200 分钟市话时长,这些额度分别落在长途免费账户和市话免费账户里;超出部分才走基本余额。对应的入帐关系大致是:长途费类型的费用项,可扣账户依次是长途免费、赠送金额、基本余额,优先级从前往后;本地费类型的费用项只认市话免费和基本余额。

账户类型可承接的费用项扣减优先级
长途免费时长长途通话1
市话免费时长本地通话1
赠送金额全部2
基本余额全部3

这张表一旦配错顺序,用户就会遇到「明明有免费时长还在扣钱」,而这类问题的排查入口不是话单,是入帐关系配置。批价算得再准,落错了账户,用户感受到的仍然是计费错误。

4. 批价内核:业务识别怎么判、费用计算 API 怎么算

批价是整个系统里最像「算法」的一段,也是配置错误最集中的地方。它的输入是一条计费事件和该用户的订购关系,输出是一条费用项链表。中间隔着两个动作:业务识别负责把事件翻译成若干条业务用量,批价处理负责把每条用量乘上一组单价。看似简单,难点全在「适配条件」和「分档边界」上。

4.1 业务识别:把一次事件拆成若干条业务用量

一次事件里能影响到计费的参量,统称计费要素:事件类型、主叫号码、被叫号码、用户当前位置、发生时间、请求用量。业务识别就是拿这些要素去匹配定价计划里声明的适配条件,命中的计划才有资格参与后续计算。

def identify_usage(event, subscription): usages = [] for plan in subscription.active_plans(event.time): # 适配条件:事件类型 + 号码群 + 位置区 + 生效时间段 if not plan.match(event): continue # 一个计划可能同时产出多条用量:基本费、长途费、漫游费 usages.extend(plan.emit_usages(event)) return usages

active_plans带时间参数,是因为定价计划有生效期,同一用户在不同时间点命中的规则可能完全不同。plan.match里的条件判断顺序会影响性能,一般把区分度最高、最容易失败的条件放前面,比如号码群匹配。emit_usages是业务识别的核心:漫游与否、长途与否,都在这一步决定要产出哪几条用量,以及每条用量的值是多少。

这里有个容易被忽略的设计点:同一次通话可能同时命中多套定价计划。套餐内的优惠计划和套餐外的基础计划并存时,谁先生效、是否互斥,由适配条件里的排他标记和优先级决定。这个顺序一旦含糊,就会出现同一段时长被算两次或者一次都没算。

4.2 批价处理:从业务用量到费用项链表

拿到用量之后,批价逐条处理:取对应的费率段、切分档、算金额、生成费用项挂到链表上。

def rate_usages(usages, event): fee_items = FeeItemList() for usage in usages: plan = usage.plan # 按分档规则把用量切成若干段,每段有自己的单价 for seg in plan.split_tiers(usage.value, usage.used_before): if seg.volume <= 0: continue amount = seg.volume * seg.price / seg.unit # 量 × 单价 ÷ 计费单位 fee_items.append( tag=usage.tag, volume=seg.volume, amount=round(amount, 2), item_id=plan.fee_item_id, ) return fee_items

usage.used_before是这条用量在本次事件之前已经累计的数值,分档能不能算对全靠它。split_tiers把用量切成段,是因为越打越便宜这类规则的分档边界通常按累计用量切而不是按本次用量切:头 3 分钟 4 毛,之后 10 分钟 2 毛,再往后 1 毛,如果用户上一通电话已经打了 2 分半,这一通的第一段就只有 30 秒是按 4 毛算的。忽略了累计值,边界就会整体错位,用户看到的是一分钱级别的偏差,排查起来极其费劲。seg.unit表示计费单位,按分钟计费时是 60,按秒计费时是 1,这个参数直接决定金额的量级。

fee_items.append里带的item_id是费用项类型,它决定了这条记录后面走哪条入帐关系。批价和入帐的耦合点就在这一个字段上,所以费用项类型的规划必须在批价之前就定死,中途加类型意味着入帐侧也要跟着改。

4.3 费用计算 API:模板为什么要把费率变成参数

把计费能力做成模板,动机很实际:运营方推活动的速度,决定了系统能不能跟上市场。如果每来一个新套餐就改一次批价主干代码,改动量是线性的,风险是指数级的——老套餐随时可能被新逻辑误伤。

模板的做法是把「怎么算」和「算什么」分开。呼叫计费模板定义了「按时长乘以单价、支持分档、支持免费额度抵扣」这套算法,费率、计费单位、适配条件全部作为参数从定价计划传入;赠送模板定义了「在指定时间点向指定账户注入指定额度」这套动作,赠送内容、赠送时点、分几个月到账都是参数。推出「省内漫游一毛一分钟」这种活动时,只要新建一个定价计划、调用呼叫计费模板、把适配条件填成省内漫游、费率填成一毛,不需要动任何一行批价代码。

这套设计换来的是可测试性:新计划未订购不生效,测试用户单独订购即可验证,存量用户零影响。代价是配置复杂度转移给了运维,一个定价计划可能调用七八个模板,参数之间的相互作用不写在任何代码里,只体现在配置界面和文档上。这也是为什么现网排查时,读配置往往比读代码更花时间。

5. 流程闭环与五类翻车:三段式用量怎么走、错了怎么查

批价算得对,不等于用户看到的话费对。从预留到结算这条链上,任何一个环节的状态没同步,结果都会偏。这一段把三种用量语义讲清楚,再落到五个真实场景里最容易翻车的地方——每一条都有明确的现象、可复现的原因和验证过的解法。

5.1 请求、授权、已使用:预付费与后付费共用的一套用量语义

把用量拆成三种状态,是在线计费能同时支撑预付费和后付费的关键。业务控制侧在使用资源之前发起请求,传入请求业务用量;计费侧检查账户可用额度,返回授权业务用量;资源使用完毕后,实际发生的量作为已使用业务用量回传,用于结算和释放。

# 预付费:通话前预留额度 requested = 300 # 请求业务用量,单位秒 price_per_min = 0.2 # 元/分钟 granted = reserve(user, requested, price_per_min, accounts=["FREE_LONG", "BASIC"]) # 返回可授权的量 # 通话结束,业务控制侧回传实际用量 used = 187 release(user, granted - used) # 未用完的预留必须回滚 settle(user, used) # 按实际用量实扣

reserve内部会按账户优先级逐层扣减可用额度,扣到哪一层就冻结哪一层的量;release是把冻结但未消费的部分还回去,这一步漏了,余额就会被「锁死」,用户明明有钱却打不出去。后付费走的路径略有不同:使用完成后话单进来,用量直接作为已使用业务用量参与批价和入帐,不需要预留和释放,但批价与入帐两段逻辑完全复用。

三段式还顺带解决了「一卡多用」。用户有 10 元余额,A 业务预留 1 元之后还剩 9 元,B 业务发起流量请求时看到的是这 9 元,不必等 A 业务挂机。老的反算模式做不到这一点,因为它按「一次通话独占全部余额」来算时长。

5.2 免费资源、累计项与月结回赠:定时器和实时流程的交界处

免费资源的扣减顺序由入帐关系决定,批价只负责算出「这段长途值 6 元」,至于这 6 元是先扣长途免费时长、再扣赠送金额、最后扣基本余额,完全是入帐关系的事。两个模块各管一段,好处是规则改动互不影响,坏处是排查时必须同时看两边——话单显示长途费 6 元,账单显示扣了 6 元现金,中间那一步「为什么没走免费额度」的答案不在话单里。

累计项是另一条隐形的线。累计消费金额、累计通话时长这类数据,既是优惠触发的条件,又是批价的输入。一次通话结束后,是先按老累计值批价、再把本次用量累加进去,还是先累加再批价,结果完全不同。常见的实现思路是读完即算、算完回写,在一次事件内保持一致,跨事件才生效——这意味着本次通话不会立刻享受本次通话带来的累计优惠,用户在临界点上的体验会和预期有偏差。

月结和回赠属于定期事务,跟实时流程共用账户和累计项,但不共用触发时机。充值送 40 元分四个月到账,就是月结任务在指定时点向赠送账户注入额度;月租扣取、累计项清零也在同一个窗口完成。跨月边界的通话最容易被这套机制误伤:23 点 59 分开始的通话,按开始时间归档还是按结束时间归档,直接决定它算不算进本月累计。

5.3 五条踩坑记录:现象、原因与解决

第一坑:免费时长明明还有,却直接扣了现金。现象是用户投诉套餐里的 300 分钟没用完就被扣钱。原因多半是入帐关系里费用项类型和账户类型的对应关系配错,比如漫游通话费用项没有挂上长途免费账户,或者定价计划的订购生效时间晚于话单发生时间。解决方式是先查这笔费用项走的哪条入帐关系,再核对用户在该时刻的有效订购;批量问题时优先怀疑订购生效时间,单点问题优先怀疑入帐关系。

第二坑:挂机后余额没回来,用户被锁死。现象是通话很短,余额却少了一大截,或者下一次拨号直接提示余额不足。原因是预留额度没有释放,可能是业务控制侧没发释放消息,也可能是计费侧的会话超时时间设得过长。排查入口看预留记录的状态字段,长期停在「已预留」的就是泄漏;解决办法是缩短会话超时并补一个按时清理的兜底任务,靠人工补账只能救一时。

注意:预留泄漏在低话务量下几乎看不出来,往往是节假日话务高峰后才集中暴露,这时候存量脏数据已经积了一批。

第三坑:分档费率在第二通电话里算错。现象是头 3 分钟 4 毛的规则,用户第一通打了 2 分钟,第二通第 1 分钟就被按 2 毛收了。原因是分档只按本次用量切,没有把累计用量带进来。解决方式是确认分档函数的入参是否包含累计值,并检查累计项的更新时机是否早于批价——这两点只要有一个不对,边界就会整体偏移。

第四坑:累计消费跨月没清零,或者被清了两次。现象是月初的优惠没触发,或者用户刚达标就掉档。原因是月结任务的重跑机制不幂等,重跑一次就多清一次累计,或者时区处理不一致导致清零时点偏移。解决办法是给清零动作加幂等标记,按账期号而不是按执行次数判断是否已处理,重跑前先校验账期状态。

第五坑:某个 TAG 没填,费用算成零还没人发现。现象是话单能生成、账单也正常出,就是金额为 0。原因是上游某个环节没填 TAG,下游取值拿到默认值,链路上一路无异常。这类问题最阴,因为它不报错。解决办法是在批价入口对关键 TAG 做存在性校验,缺失时打告警而不是静默兜底,宁可算不出也不要算错。

6. 设计取舍:为什么这套模型把灵活交给数据、把稳定留给代码

回头看整套机制,所有抽象都指向同一条分界线:容易变的东西放进数据,不容易变的东西钉在代码里。费率、优惠、适配条件、赠送内容这些运营方每周都要调的东西,被做成模板参数和定价计划,改它们只需要配数据、走订购、单独验证;业务量语义、三段式用量、费用项链表的生成逻辑这些一旦改错就全量崩的东西,被牢牢钉在批价主干里,宁可牺牲一点灵活性也不开放给配置。

这个取舍的代价很实在。配置复杂度从开发转移到了运维,一个定价计划调用七八个模板,参数之间的相互作用没有任何代码约束,只能靠规范和评审。可观测性也变差了:金额算错时,代码里找不到那行逻辑,因为它根本不在代码里,而在某张配置表的某个字段上。TAG 用数字、批价只算应收不管实扣、累计项读完即算算完回写,这些设计单看都不优雅,合在一起却是为了让实时链路在高峰期也能在毫秒级给出确定答案。

如果想验证自己是否真的理解了这套机制,有个笨办法很有效:挑一笔有争议的话单,顺着 TAG 一路走到业务用量,再走到费用项,再走到入帐流水,每一步都问一遍「这个值是哪个环节写进来的、写的时候依据什么」。走通一遍,比读十遍文档管用。

我早年排查过一次「免费时长没用完却扣钱」的问题,从话单查到批价、从批价查到配置,来回折腾两天,最后发现是订购的生效时间比话单时间晚了三秒。从那以后我每次碰计费相关的问题,都强制自己先按「事件时间 → 订购生效时间 → 入帐关系 → 账户余额」这个顺序走一遍,再去看代码和配置,多数时候能省掉大半天。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进