刚接手一个老系统的时候,产品提了个需求:“把这个商品的实时到手价算出来,展示在前端”。
我一看,商品价格涉及基础价、活动价、会员折扣、区域补贴、优惠券叠加,规则又按季度变。第一反应是“这直接存一个字段不就行了?”,后来才发现,真正把“属性值的计算”做成一个稳定可维护的模块,比想象中复杂得多。你不仅要处理数据从哪来、规则怎么配、依赖怎么梳理,还要面对缓存、并发、一致性这类连锁问题。
这篇文章,我就围绕“属性值计算”这个主题,把我在实际项目里做的一套可落地方案拆开讲。包括前期需求怎么分析、计算引擎怎么设计、核心代码怎么写、线上踩过哪些坑。适合正在做订单、商品、营销这类偏业务后端的开发同学,也适合刚接触规则引擎、计算模块设计的朋友参考。
1. 属性值计算:先想清楚这些基础问题
1.1 “属性值”到底是什么,为什么不存一个固定值
在很多业务系统里,“属性值”并不只是数据库表里的一个字段。它更像是一个对象在某个时刻、某个条件下,经过一系列计算后表现出来的一个“结果”。比如电商商品页上的到手价、库存页上的可售数量、营销活动里的用户标签得分,这些都是属性值。
这类值有一个共同点:它们会随着底层数据变化而变化。如果你把这些值直接存死,比如在商品表里加一个final_price列,那促销改了、会员等级变了、区域补贴调了,你都得写一个定时任务去刷新。刷新期间用户看到的可能是旧价,刷新完又得担心并发写冲突。
所以,更合理的做法不是“存”,而是“算”。也就是在需要展示或使用属性值的时候,由计算模块根据当前最新的规则和数据,实时或近实时地算出来。这样每个属性值都有一个清晰的数据来源,不会因为到处复制数据导致口径不一致。
1.2 四种常见计算模式,选错了后面很被动
我根据实际经验,把属性值计算分为四种典型模式:
| 模式 | 适用场景 | 例子 | 优点 | 缺点 |
|---|---|---|---|---|
| 静态值 | 基本不变的数据 | 商品SKU编码、品牌名 | 查询快、实现简单 | 无法响应变化 |
| 公式计算 | 规则明确、依赖可穷举 | 到手价=基础价-折扣+运费 | 实时性强、无存储 | 依赖链长时性能下降 |
| 聚合计算 | 对大量明细做汇总 | 库存汇总、销售总量 | 数据敏感度高 | 需要异步任务支撑 |
| 外部服务拉取 | 数据在另一个系统 | 实时汇率、第三方风控评分 | 数据权威 | 强依赖外部稳定性 |
你在设计一个新属性的时候,第一件事不是写代码,而是先判断它属于哪一种模式。很多项目做砸了,就是因为把一个“聚合计算”的属性当成“公式计算”来实现,结果每次查询都去扫一遍明细表,数据库直接被压垮。
我个人建议的原则是:数据量小、规则简单,用实时计算;数据量大、可容忍秒级延迟,用异步聚合 + 结果缓存;强依赖外部系统,优先考虑回源 + 本地缓存两层方案。
2. 计算引擎的整体架构怎么拆
2.1 三层结构:属性域、规则层、执行层
一旦明确了属性值要“算”而不是“存”,就要搭一个能承载各种计算场景的架子。我习惯把整体拆成三层。
第一层是属性域模型。这一层负责定义“有哪些属性、属性是什么类型、依赖哪些其他属性、允许的取值范围”。比如一个discount_amount属性,类型是BigDecimal,依赖于member_level和activity_type,取值范围不能小于0。这一层相当于数据字典,把计算模块里所有关键名词先统一起来。
第二层是规则层。规则层解决的是“属性值怎么算”的问题。如果所有计算逻辑都写在Java代码里,那么每次改规则都要发版。更好的做法是把规则从代码里抽出来,放到配置中心、数据库表或者轻量级DSL里。规则层的好处是:运营或产品可以自己维护规则,开发只需要保证规则解析引擎稳定。
第三层是执行层。执行层接收一个“计算请求”,请求里带着对象ID和需要的属性列表,执行层负责按依赖顺序把属性算出来,并对结果做缓存、并发控制、失败降级。
这三层各司其职,出了问题定位起来也快。经常遇到的情况是:规则配置错了,结果开发跑到执行层里查半天才发现是规则层的数据问题。职责边界清楚之后,类似的事故基本可以避免。
2.2 依赖管理与执行顺序:DAG是关键
属性之间往往不是孤立的。举一个最简单的例子:order_line_amount = unit_price * quantity - total_discount,而total_discount又依赖于member_discount和activity_discount。这就是一个典型的依赖图。
在计算属性值时,如果不管理依赖,直接从上到下硬算,就会出现“用了还没算出来的值”这种低级错误。所以我每次做计算引擎,第一件事就是先明确属性之间的DAG(有向无环图)。
DAG的管理有两个核心动作:一是依赖关系的定义,二是拓扑排序。依赖关系可以放在属性域模型的元数据里,比如:
order_line_amount: depends_on: - unit_price - quantity - total_discount total_discount: depends_on: - member_discount - activity_discount执行层在计算某个属性时,先递归解析出这条依赖链,再做一次拓扑排序,确保每个属性计算时,它依赖的属性都已经是最终值。这样也方便做增量计算:如果只改了member_discount的规则,那所有依赖它的属性自动进入待重算集合,其他属性直接用缓存。
循环依赖是最恶心的一个问题。A依赖B、B依赖A,执行时就会死循环。我一般会在启动加载属性定义的时候做一次全量校验,只要发现环,直接启动失败,并打印出完整的依赖链路。这个校验操作只能在初始化阶段做,不能拖到运行期,否则线上出了问题才爆出来,非常被动。
2.3 为什么不要把所有属性都做成“实时计算”
有一类需求特别常见:运营说“我要看所有用户当前最匹配的3个标签”。如果按照实时计算的方法,每次请求都去扫所有用户的特征数据,计算量会大得离谱。
这种场景就应该把“实时计算”改成“异步预计算 + 结果读取”。也就是用一套离线或准实时任务,把用户的标签属性值提前算好,存进Redis或宽表,查询时直接取结果。同理,热词里提到的“云计算”“边缘计算”“图计算”,本质都是在解决“计算放在哪里、什么时候算”的问题。属性值计算也需要类似的思路:不是所有东西都适合在请求线程里现场算。
我的判断标准很简单:一个属性如果满足“计算量不小 + 实时性要求没那么高 + 结果可以被复用”,优先做预计算。只有“数据实时变化 + 每次结果可能不同 + 量级可控”的属性,才放进实时计算链路。
3. 核心代码怎么落地:一个可运行的简化版本
3.1 属性解析器与表达式引擎选型
属性计算背后总离不开表达式。最简单的属性可以直接查字典映射,比如currency = CNY;复杂的属性就要走公式,类似final_price = base_price * (1 - discount_rate)。
对于公式解析,我不建议一上来就自己写一个Benchmark词法分析器,除非你想练手。实际项目里,选一个成熟的表达式引擎更靠谱。我常用的是Aviator、QLExpress和MVEL,它们各有拥趸。
选型上我有几个硬性标准:
- 支持自定义函数注册,这样可以把一些特殊逻辑(比如读配置、查外部服务)封装进表达式。
- 有求值超时机制,防止一条垃圾规则把CPU占死。
- 语法尽量接近Java或脚本,降低团队学习成本。
- 有完善的错误上下文信息,至少能定位到表达式字符串的具体位置。
我最后选了Aviator,主要是它在性能、安全性和上手成本之间比较均衡。当然,你选哪个都行,重要的是后面那套属性计算框架不能绑定死一个表达式引擎,最好在中间做一层适配,避免将来换引擎时大面积改动。
3.2 一个简化的计算核心实现
下面我给你看一个极其精简但能跑通的计算核心示例。假设我们有一个AttributeCalculator,它的职责是接收属性名和上下文,返回计算后的值。
先用一个链表结构来定义属性计算规则:
public interface AttributeRule { Object calculate(EvaluationContext context); default Set<String> dependencies() { return Collections.emptySet(); } } public class DiscountRule implements AttributeRule { @Override public Set<String> dependencies() { Set<String> deps = new HashSet<>(); deps.add("base_price"); deps.add("member_discount_rate"); return deps; } @Override public Object calculate(EvaluationContext context) { BigDecimal basePrice = context.get("base_price"); BigDecimal rate = context.get("member_discount_rate"); return basePrice.multiply(rate).setScale(2, RoundingMode.HALF_UP); } }执行层用一个Map维护属性名到规则的映射,计算时先做依赖解析:
public class CalculationEngine { private final Map<String, AttributeRule> rules = new HashMap<>(); public void register(String name, AttributeRule rule) { rules.put(name, rule); } public Object calculate(String target, EvaluationContext context) { Set<String> visited = new HashSet<>(); return doCalculate(target, context, visited); } private Object doCalculate(String name, EvaluationContext context, Set<String> visited) { // 循环依赖检测 if (visited.contains(name)) { throw new IllegalStateException("Circular dependency detected: " + visited); } visited.add(name); AttributeRule rule = rules.get(name); if (rule == null) { throw new IllegalArgumentException("No rule registered for: " + name); } // 先确保所有依赖属性都已计算 for (String dep : rule.dependencies()) { if (!context.contains(dep)) { context.put(dep, doCalculate(dep, context, visited)); } } Object result = rule.calculate(context); context.put(name, result); return result; } }实际生产环境中,这个代码会复杂很多倍。要加入缓存、并发锁、审计日志、规则版本号、白名单校验。但这个骨架把最重要的东西体现出来了:依赖解析、顺序控制、循环检测、缓存结果。
3.3 触发更新机制:事件驱动还是定时轮询
属性计算引擎最怕的两件事,一是“该更新的时候没更新”,二是“不该更新的时候疯狂重算”。所以触发机制非常关键。
我常用的方式是事件驱动。底层数据发生变化时,系统发出一个领域事件,比如PriceChangedEvent、StockAdjustedEvent。事件里带上对象ID和变化的维度信息,计算引擎收到事件后,只重算依赖这些维度的属性集合。
举个例子,如果用户修改了会员等级,触发的事件里包含一个member_level_updated的标识。计算引擎通过依赖映射表判断:哪些属性依赖会员等级?只有member_discount、final_price这些需要重算,其他属性不受影响。这样就避免了全量刷新。
定时轮询也不是完全没用。在一些没有可靠消息中间件的内部系统里,我会作为兜底策略,每5分钟扫一遍“最近有变更记录但计算结果还未刷新”的数据,把这些数据重新计算一遍。这么做的好处是,即使事件丢了几条,最终也能通过轮询补偿回来。但要注意轮询间隔不能太短,否则数据库压力会很大。
如果你的系统本身有Message Queue,那一定要用事件驱动作为主链路,定时轮询只做补偿。原因很简单:事件驱动是增量计算,轮询是全量扫描,两者的资源消耗差距不在一个量级。
4. 性能、一致性与资源消耗的权衡
4.1 缓存策略:不是所有结果都要实时算
属性值计算最核心的优化手段就是缓存。一个属性如果被大量读取、又依赖稳定的数据,那缓存它的计算结果可以省掉大量重复计算。
缓存设计上,我经历了三个阶段。第一阶段是简单KV缓存,key是属性名+对象ID,value是计算结果,过期时间写死成15分钟。这种方案简单但问题很多,比如维度变了key却没变,导致老价格被返回。
第二阶段是“维度缓存”。key由属性名、对象ID、依赖维度值的版本号组成。比如final_price:sku_123:promo_v2:member_v3。只要依赖维度没变,缓存就命中;变了,key也变了,自然走重算。这种方案很可靠,但key数量可能膨胀很快,需要配合LRU淘汰。
第三阶段是“多级缓存”。本地Caffeine缓存热数据,Redis缓存全量计算结果,数据库只存原始数据。查询时先查本地,未命中再查Redis,再未命中才真正触发计算。这个方案把响应时间从几十毫秒降到几毫秒,吞吐量上去后,数据库的压力也小了很多。
需要特别注意的是“缓存穿透”问题。如果一个属性计算后结果为null或者空,你如果不加处理,下次请求还会再算一次,遇到流量大时,无效计算会直接打满CPU。我一般会在计算结果为空时,给一个短暂的占位缓存,比如10到30秒,把这个空结果也缓存住。
4.2 数据一致性:先更新还是先计算
属性值计算的实时性和一致性天生有矛盾。你永远无法做到“数据变了,计算结果立刻在所有节点同步更新”,只能选择一种取舍。
我在项目中采用的是一种“最终一致 + 读旧值允许短时间”的方案。具体流程是:
- 原始数据变更落库后,发出一个变更事件。
- 计算引擎收到事件,异步重新计算相关属性。
- 新结果写入缓存。
- 在缓存写入完成之前,请求仍然读旧值。
- 旧缓存过期后,新值自然生效。
这个方案的关键在于,事件发出和落库必须在一个事务里,否则可能出现“数据没落库,事件已经发出”的脏情况。还有一点是计算结果写入缓存时要带上计算时间戳和依赖维度版本号。这样即使重算任务乱序,旧结果也不会覆盖新结果。
我遇到过一个典型的坑:两条更新事件同时到达,旧事件先执行、新事件后执行,但由于网络延迟,旧事件的结果反而后写入缓存,导致新计算结果被覆盖。解决办法就是写入缓存前校验版本号,版本低的直接丢弃。
4.3 复杂度评估:别等系统卡了才考虑性能
属性值计算的性能,不只是“快不快”的问题,还涉及资源消耗是否可控。这块可以从时间复杂度和空间复杂度两个角度来看。
时间复杂度上,核心指标是“平均计算深度”。如果一条属性依赖链总共5层,每层都走一次外部服务调用,那单次计算的耗时可能是几百毫秒。这种链路就不能搞成同步实时计算,必须做预计算或异步缓存。
空间复杂度上,核心指标是“缓存key的规模”。以商品价格为例,假设有10万商品、20个促销活动、5种会员等级,那理论上的key规模就是10万205=1000万。如果每个key的value是几百字节,那缓存占用会非常可观。所以在设计缓存key时,不能把维度全部塞进去,要评估哪些维度是真正影响结果的,做降维处理。
我每次上线一个属性计算逻辑前,都会做一个粗略的容量评估表,包含预计属性数、依赖维度数、单次计算耗时、缓存key规模、写入QPS。这些数据不全也没关系,估算就能发现明显问题。很多系统卡死,不是代码逻辑写得差,而是压根没算过这个属性会引入多大的缓存和计算量。
5. 线上踩坑实录与排查技巧
5.1 高频问题速查表
我把这些年做属性值计算遇到的高频问题整理成一个速查表,方便大家直接对照:
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 计算结果偶尔正确偶尔错误 | 规则表达式中引用了未声明的属性 | 在规则解析阶段加静态检查,打印表达式AST |
| 缓存一直命中但数据是旧的 | 缓存key没包含维度版本号 | 检查key设计,给key加版本号 |
| 某个属性计算耗时特别长 | 依赖了外部服务,未做超时控制 | 单独计算该属性,看耗时分布,加超时和降级 |
| 更新事件触发后,部分属性没重算 | 依赖关系映射不完整 | 校验属性依赖表和事件类型映射表 |
| 缓存穿透导致数据库压力大 | 空结果没有做占位缓存 | 缓存空结果,设置较短过期时间 |
| 启动时加载规则报循环依赖 | 规则配置错误 | 初始化时做DAG校验,打印依赖环路径 |
| 表达式执行报错但日志不清晰 | 表达式引擎配置没开详细错误 | 启用错误堆栈,定位到具体表达式字符串和位置 |
这张表每次排查问题时,我基本都会先过一遍。你会发现大多数线上问题,归根结底是“缓存层”、“依赖层”、“规则层”三个环节里的某一个出了问题。
5.2 一次典型的“算错属性值”排查过程
分享一个印象较深的线上事故。当时一个大促活动上线后,运营反馈部分SKU的到手价比预期高了几块钱。
第一步,我确认了计算结果确实错了,不是前端展示问题。第二步,抽查了一个异常SKU,手动把基础价、促销折扣、会员价代入规则算了一遍,发现得到的结果和线上展示结果对不上。第三步,查看缓存,发现该SKU的缓存key停留在一个旧版本上,而这个版本对应的是上一个活动的促销规则。
问题定位到了:规则更新后,缓存key的版本号没有联动变化。因为规则表改了配置,但属性依赖里的活动维度版本号还是旧值,导致重算逻辑认为“依赖没有变化”,没走更新链路。
临时解决方案是清掉异常缓存让系统重算。长期修复方案是:规则更新事件里必须带上规则版本号,并且该版本号要作为缓存key的一部分。同时,在管理后台加了一个“规则变更预览”功能,先计算一条样本数据,确认无误后再发布。
这次事故给我的教训是:属性计算模块里,规则本身也是数据,也要纳入版本管理。否则改规则就像蒙着眼睛开车,线上看到的结果和配置中心里的规则对不上,排查起来非常痛苦。
5.3 几个可以让你少熬夜的实用建议
最后分享几个我在实操中沉淀下来的小建议。这些内容不复杂,但关键时刻能救命。
第一个建议是给每个计算结果加上审计信息。不要只存一个value,要存下“这个结果是由哪条规则、哪个版本、在什么时间算出来的”。可以用一个对象包裹结果值,里面包含value、ruleVersion、calculatedAt、traceId。出了问题,直接根据审计信息回放,比瞎猜快得多。
第二个建议是规则变更一定要走灰度。不要一次性把新规则推广到全量数据。可以先挑一部分测试数据,在后台“试算”一下,对比新旧规则的差异。差异较大的属性,可以先用小流量灰度验证,确认无误后再全量发布。
第三个建议是在做容量评估时,“按最差依赖链估算”。如果一条属性依赖链最长有7层,那绝不能按平均2层来做性能预算。用最差情况去定缓存容量和超时时间,虽然会浪费一点资源,但能换来稳定的SLA。
第四个建议是做好“计算失败降级”。当某个属性计算失败时,不能直接抛异常让整个请求失败。应该返回旧值并标记为“可能过期”,同时触发重试或告警。用户看到慢一点的价格,也比完全看不到页面强。
这四个建议都是从线上事故里提炼出来的,尤其是审计信息和灰度发布,几乎每次规则改动都用得上。你提前落实了,后面就会少很多半夜被电话叫醒的体验。