升鲜宝这类供应链系统里,B端客户价格的复杂度往往被严重低估。业务方提需求时经常一句话带过——“给这个客户报个价就行”,但真正落地到数据库层面,你会发现一堆绕不开的问题:一个客户在不同区域、不同商品线、不同账期条件下,价格到底怎么算?总部价、区域价、客户单独协议价谁优先?客户下级的门店是统一价还是独立价?这些如果不在表结构设计阶段就把“价格域”这个概念理清楚,后面业务一跑起来,数据改起来就是灾难。
这篇文章就围绕升鲜宝供应链管理系统中B端客户价格域的表结构设计,把业务场景、表模型、字段设计逻辑、价格匹配流程和实操踩坑一次性讲透。适合正在做供应链、电商中后台、ERP/CRM价格模块的产品经理、后端研发和DBA参考,尤其是Bi端多层级组织架构下的定价规则设计,这部分的通用性很强,看完可以直接拿去改自己的业务模型。
1. 为什么B端价格必须做“价格域”,而不是简单存一个价格字段
很多系统在最开始的时候,B端客户价格就是客户表上加一个price字段或者一个discount字段,简单粗暴。但这个做法在供应链行业几乎撑不过三个月,原因很现实。
1.1 B端定价是多维度的,不是单一值
生鲜供应链的业务现场是这样的:一个连锁餐饮客户,总部在北京,上海和成都有分公司,每个分公司的采购品类和采购量完全不同。总部跟升鲜宝签的可能是全品类统一框架价,但上海分公司因为走量特别大,单独申请了猪肉类的特价协议;成都分公司则因为新拓市场,总部给了一个首月85折的新客扶持价。这时候如果你问“这个客户的价格是多少”,答案是不确定的——得看哪个组织、哪个品类、哪个时间窗口。
更麻烦的是,价格还会叠加各种业务条件:现结和月结价格不同、起送量阶梯价、账期超过30天要加价、某些生鲜品类因为损耗率高不允许打折。这些都是业务侧非常合理的诉求,但落到表结构上,如果只是“客户表存一个价格字段”,你根本没法表达。价格域的本质就是把“定价”从客户主数据里拆出来,做成独立的、可组合、可叠加的领域模型。
我一直把价格域理解成“定价的坐标系”——客户是横轴,商品/品类是纵轴,时间、组织、结算方式是纵深维度,任意一个坐标点命中一个价格值。这个坐标系一旦建好,后续的版本管理、权限隔离、价格审批、历史追溯全都有了清晰的落点。
1.2 价格域要解决的三类核心问题
结合升鲜宝这个场景,价格域至少要扛住三类问题:
第一类是适配问题。一个客户可能横跨多个区域、多个法人主体、多个结算主体,不同组合下价格不能串。比如同一家连锁客户,A城市门店和B城市门店因为冷链配送成本不同,同类商品的配送价相差5%-8%,这就必须靠价格域把区域维度切开。
第二类是优先级问题。不同层级的定价策略同时命中时,谁说了算?典型场景:客户协议价 > 客户组协议价 > 区域标准价 > 总部标准价。而且实际业务中还有“特批价”“促销价”“阶梯价”这些临时策略叠加,如果没有一个明确的优先级机制,就会出现同一张订单不同人看到不同价格的情况。
第三类是生命周期问题。报价单是有时效的,合同一年一签,促销可能只有一周,损耗补偿价只针对某批次。价格域必须有版本、生效期、失效期、状态这几个基本属性,否则过期价格会成为对账时最大的烂账来源。
这三点想清楚了,表结构设计的方向就不会跑偏。
2. 价格域核心表结构设计:从客户域到价格域的完整模型
下面直接进入实操。我以升鲜宝实际项目里的表结构为蓝本,把核心表拆开讲。整体模型分四层:组织与客户相关表、价格域主表、价目明细表、规则辅助表。
2.1 客户与组织基础表:价格域的“坐标轴”
价格域不是凭空存在的,它首先要锚定在客户维度上。这里涉及三张基础表:客户主表、客户组织关系表、客户分组表。
客户主表是最基本的,核心字段如下:
-- 客户主表(核心字段) CREATE TABLE `crm_customer` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `customer_code` varchar(32) NOT NULL COMMENT '客户编码(业务唯一)', `customer_name` varchar(128) NOT NULL COMMENT '客户名称', `customer_type` tinyint(4) NOT NULL COMMENT '客户类型:1-集团客户,2-区域分公司,3-单店客户', `parent_id` bigint(20) DEFAULT NULL COMMENT '上级客户ID(用于集团->分公司->门店层级)', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:0-停用,1-启用', `price_level` tinyint(4) DEFAULT '3' COMMENT '默认价格层级:1-总部价,2-区域价,3-协议价', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_customer_code` (`customer_code`), KEY `idx_parent_id` (`parent_id`) ) ENGINE=InnoDB COMMENT='B端客户主表';这里有个容易被忽略的点:很多系统把“集团客户”和“单店客户”混在一张表里,靠parent_id自关联。实际运营中,集团客户和单店客户的结算方式、开票主体、配送地址差异巨大,业务上建议用customer_type做区分,但物理上保持一张表。这样既方便统一查询,又能在价格域里通过“是否包含下级”来控制价格适用范围。
客户组织关系表解决的是“客户的下级组织也有独立定价或独立结算”的问题,核心字段包括:
-- 客户组织关系表 CREATE TABLE `crm_customer_org_rel` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `customer_id` bigint(20) NOT NULL COMMENT '客户ID', `org_id` bigint(20) NOT NULL COMMENT '组织ID(对应组织架构表中的分公司/门店)', `org_type` tinyint(4) NOT NULL COMMENT '组织类型:1-分公司,2-门店/配送点,3-仓库', `is_price_scope` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否参与独立定价:1-是,0-否', PRIMARY KEY (`id`), UNIQUE KEY `uk_customer_org` (`customer_id`, `org_id`) ) ENGINE=InnoDB COMMENT='客户与组织关系表';这张表最大的价值是打破了“客户=单一法人”的思维惯性。连锁客户的不同门店可能由不同区域仓配送,而区域仓的成本结构不同,所以价格域必须能精确到“客户+组织”这一层。
客户分组表则是价格批量应用的关键,运营人员可以把一批客户加入“KA客户组”“社区团购客户组”,然后在价格域里直接对组定价,省去逐客户配置的成本:
-- 客户分组表 CREATE TABLE `crm_customer_group` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `group_code` varchar(32) NOT NULL COMMENT '分组编码', `group_name` varchar(64) NOT NULL COMMENT '分组名称', `description` varchar(255) DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_group_code` (`group_code`) ) ENGINE=InnoDB COMMENT='客户分组表';另外还需要一张客户分组明细表,将客户和分组关联起来。实际设计时建议用“分组-客户”一对多的关系表,而不是在客户表里加group_id字段,这样客户可以被归入多个分组,价格命中的灵活性会大幅提升。
2.2 价格域主表:定义“一个价格策略的边界”
价格域主表是整个模块的核心,它定义了一个价格策略的适用范围、优先级、状态和生命周期。我把它命名为price_domain,字段设计如下:
-- 价格域主表 CREATE TABLE `price_domain` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `domain_code` varchar(32) NOT NULL COMMENT '价格域编码(业务唯一,如PD-2024-0001)', `domain_name` varchar(128) NOT NULL COMMENT '价格域名称', `domain_type` tinyint(4) NOT NULL COMMENT '价格域类型:1-总部标准价,2-区域标准价,3-客户协议价,4-客户组协议价,5-特殊审批价', `customer_id` bigint(20) DEFAULT NULL COMMENT '客户ID(当domain_type=3或5时必填)', `customer_group_id` bigint(20) DEFAULT NULL COMMENT '客户分组ID(当domain_type=4时必填)', `org_id` bigint(20) DEFAULT NULL COMMENT '组织ID(区域价时必填)', `start_date` date NOT NULL COMMENT '生效日期', `end_date` date DEFAULT NULL COMMENT '失效日期(NULL表示长期有效)', `priority` tinyint(4) NOT NULL DEFAULT '10' COMMENT '优先级:数字越小优先级越高', `status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '状态:0-草稿,1-已发布,2-已失效,3-已废弃', `check_status` tinyint(4) NOT NULL DEFAULT '0' COMMENT '审批状态:0-待提交,1-审批中,2-已通过,3-已驳回', `price_type` tinyint(4) NOT NULL DEFAULT '1' COMMENT '价格类型:1-固定价,2-加价率,3-折扣率,4-阶梯价', `currency` char(3) NOT NULL DEFAULT 'CNY' COMMENT '币种', `remark` varchar(255) DEFAULT NULL, `create_by` varchar(32) DEFAULT NULL COMMENT '创建人', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_domain_code` (`domain_code`), KEY `uk_customer_start_date` (`customer_id`, `start_date`), KEY `idx_group_start_date` (`customer_group_id`, `start_date`) ) ENGINE=InnoDB COMMENT='价格域主表';这张表里有几个字段值得展开说。
domain_type决定了价格域的“坐标轴”是谁。类型1只绑定商品,不绑定客户;类型3绑定客户但不绑定组织;类型5则可以是“客户+商品+批次”这种颗粒度。不同的类型天然有不同的优先级层级,这个在设计价格引擎时会用到。
priority字段非常重要。B端价格命中的核心逻辑就是:先按规则筛选出所有命中的价格域,再取优先级最高的那个。我习惯用数字越小优先级越高,而且把优先级的跨度拉大一些,比如总部价100、区域价80、客户组协议价50、客户协议价30、特殊审批价10,这样中间插入新层级时不需要改大量已有数据。
end_date留空表示长期有效,但注意查询时永远要带“当前日期在start_date和end_date之间”的条件。很多价格事故都出在“只看了status=1,没看日期范围”。
这里还建议加一个parent_domain_id字段,用来做价格域的版本关联。比如2025年的客户协议价是从2024年的协议价复制修改过来的,通过这个字段可以追溯到历史版本的沿革。这在审计和合同续签场景下非常有用。
2.3 价格域明细表:每个SKU或品类一个价格条目
价格域主表只是定义了“这是谁的价,哪个区域的价,什么时间有效”,真正的价格明细在子表里。我设计了price_domain_detail来装具体的SKU价格:
-- 价格域明细表 CREATE TABLE `price_domain_detail` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `domain_id` bigint(20) NOT NULL COMMENT '价格域主表ID', `sku_id` bigint(20) NOT NULL COMMENT '商品SKU ID', `category_id` bigint(20) DEFAULT NULL COMMENT '品类ID(按品类定价时使用,与sku_id二选一)', `base_price` decimal(10,2) NOT NULL COMMENT '基准价(含税)', `min_price` decimal(10,2) DEFAULT NULL COMMENT '最低限价(防止误操作)', `max_price` decimal(10,2) DEFAULT NULL COMMENT '最高限价', `quote_price` decimal(10,2) NOT NULL COMMENT '最终报价', `min_qty` decimal(10,2) DEFAULT NULL COMMENT '最小起订量', `unit` varchar(16) NOT NULL COMMENT '计价单位:斤、箱、件等', `tax_rate` decimal(5,2) NOT NULL DEFAULT '9.00' COMMENT '税率(%)', `is_active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_domain_sku` (`domain_id`, `sku_id`), KEY `idx_category_id` (`category_id`) ) ENGINE=InnoDB COMMENT='价格域明细表';写到这里必须强调一个现实问题:生鲜供应链的SKU数量动辄几千上万,如果一个客户协议价要在明细表里逐SKU录一遍,运营会做到崩溃。所以设计上必须支持sku_id和category_id两种粒度并存。
我的做法是:明细表允许sku_id为空、category_id非空,表示这个价格应用于该品类下的全部SKU;如果某SKU有专属价格,则sku_id非空、优先命中。在实际查询时,价格引擎先找SKU级,找不到再向上找品类级。这个逻辑很像路由表的“最长前缀匹配”,看着简单,但能覆盖90%的定价场景。
base_price和quote_price分开设计是刻意为之。base_price是标准成本价或参考价,quote_price是给客户的报价。两者分开的好处是,财务对账时可以算出“这单到底让了多少利”,运营复盘时也能分析“这个客户毛利率是多少”。如果只存一个最终价,这些分析就全做不了。
min_price和max_price是安全护栏。B端业务中经常会因为手工录入失误把报价改错,比如把一个30块钱的蔬菜报价写成3块钱。有了上下限,在订单审核环节就能直接拦截,比事后追责强一万倍。
2.4 阶梯价与折扣规则表:把“复杂业务规则”从价格表中拆出来
生鲜行业的定价不只是“一口价”,阶梯价和折扣规则几乎天天用。阶梯价表示采购量越大单价越低,比如:0-500斤按6.5元/斤,500-1000斤按6.2元/斤,1000斤以上按5.9元/斤。
这种场景可以在price_domain_detail的price_type=4时,通过阶梯规则子表来实现:
-- 阶梯价规则表 CREATE TABLE `price_step_rule` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `detail_id` bigint(20) NOT NULL COMMENT '价格明细ID', `start_qty` decimal(10,2) NOT NULL COMMENT '区间起始量', `end_qty` decimal(10,2) DEFAULT NULL COMMENT '区间结束量(NULL表示无上限)', `price` decimal(10,2) NOT NULL COMMENT '该区间单价', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_detail_id` (`detail_id`) ) ENGINE=InnoDB COMMENT='阶梯价规则表';查询时,在订单明细中带入实际下单数量,匹配start_qty <= qty < end_qty(注意边界,我用的是包含下限、不包含上限的规则)的区间即可。这里有三个容易踩的坑:
区间必须连续且不能重叠,否则就会出现“同一种商品同一种数量匹配到两个价格”的情况。我的建议是在业务逻辑层增加区间重叠校验,而不是靠数据库约束。
边界值的定义要统一。建议统一采用>= start_qty AND < end_qty,方便开发和运维共同理解,避免出现“正好买500斤算哪个价”这种扯皮问题。
阶梯价表不建议频繁更新。每次调价都应该生成新的价格明细记录或版本,而不是在原记录上update,这样月度对账才能追溯“这个价格是什么时候生效的”。
折扣规则表也是类似的思路,但更灵活。升鲜宝的实践中,折扣分为两种:一种是对整张订单的折扣,另一种是对特定品类的折扣。我单独建了一张price_discount_rule表,字段包括:rule_type(订单级/品类级)、discount_type(折扣率/减免金额)、discount_value、priority,同时绑定到价格域主表。这里要注意的是,折扣规则的优先级应该比价格域的优先级低一级,因为先决定基准价,再在基准价之上打折,这个顺序不能反。
3. 价格匹配流程:数据库表怎么支撑“一单出价”
表结构设计得再好,最终也要落在“订单创建时,系统如何自动带出价格”这个动作上。这一节讲清楚升鲜宝的价格匹配流程和对应的SQL实现思路。
3.1 价格确定分四步走
第一步,确定客户的价格身份。比如客户是集团客户还是单店客户;这一步确定后,系统就知道要在客户协议价、客户组协议价、区域标准价、总部标准价之间做筛选。这里的关键是要把客户的层级关系带出来,因为集团客户下挂的分公司可能复用集团价格,也可能有独立协议价。
第二步,匹配商品范围和品类范围。将订单中的SKU逐条拿到价格域里匹配。优先匹配SKU级,如果SKU级没有,则匹配品类级。品类匹配要按照商品类目树的路径向上递归,比如“大白菜”先匹配“叶菜类”,没有再匹配“蔬菜类”。这个递归层级建议控制在3层以内,否则性能会很难看。
第三步,过滤时间、区域和状态。价格域的生效时间必须覆盖当前订单日期,区域ID必须匹配客户的配送组织,状态必须为“已发布”且审批“已通过”。
第四步,按优先级取值。在步骤一到三之后命中的所有价格域中,按照priority取值,数字最小者胜出。如果存在多个相同优先级的价格域命中,则取创建时间最新的那一条,并记录告警日志以便运营排查。
这四步看着不复杂,但必须做成独立的PriceEngine模块,不要散落在订单Service里。因为价格计算不仅下单要用,报价单、合同审批、订单改价、售后赔付都会用到,独立成服务才能保证各处价格口径一致。
3.2 核心SQL:获取某客户某SKU在当前时间的价格
我用一条示例SQL来演示价格匹配的关键逻辑。假设要查询客户ID=1001、区域=2002、SKU=3003的当前价格:
SELECT pd.domain_type, pd.priority, pdd.id AS detail_id, pdd.quote_price, pdd.min_qty, pd.start_date, pd.end_date FROM price_domain pd INNER JOIN price_domain_detail pdd ON pd.id = pdd.domain_id WHERE pd.status = 1 AND pd.check_status = 2 AND pd.start_date <= CURDATE() AND (pd.end_date IS NULL OR pd.end_date >= CURDATE()) AND ( -- 客户协议价 (pd.domain_type = 3 AND pd.customer_id = 1001 AND pd.org_id IS NULL) OR -- 客户+区域协议价 (pd.domain_type = 3 AND pd.customer_id = 1001 AND pd.org_id = 2002) OR -- 客户组协议价 (pd.domain_type = 4 AND pd.customer_group_id IN ( SELECT group_id FROM crm_customer_group_detail WHERE customer_id = 1001 )) OR -- 区域标准价 (pd.domain_type = 2 AND pd.org_id = 2002) OR -- 总部标准价 (pd.domain_type = 1 AND pd.org_id IS NULL) ) AND (pdd.sku_id = 3003 OR (pdd.sku_id IS NULL AND pdd.category_id IN ( -- 向上递归品类 SELECT category_id FROM cms_category WHERE category_path LIKE '%/3003的内部/% ))) ORDER BY pd.priority ASC, pd.create_time DESC LIMIT 1;这条SQL在数据量不大(价格域明细几十万条、联合索引命中良好)时是够用的,但一旦价格域明细突破百万级别,或者订单并发很高,建议做两层优化。第一层是“区域+客户”的缓存预热,把常用的客户-商品价格组合预载到Redis里,key设计为price:{customerId}:{skuId}:{date},value就是价格JSON;第二层是独立价格查询服务,用倒排索引的思路把“客户/品类/时间”映射到价格域ID列表,避免每次都全表扫描。
实际项目中,我见过很多团队纠结“能不能一条SQL搞定价格匹配”,我的建议是不要在一条SQL里追求完美。把匹配拆成两个步骤——先用条件筛出候选价格域列表(这一步数据量小,很快),再根据优先级排序取值,这样更清晰,也更容易做缓存和日志。
3.3 特殊场景:阶梯价和折扣在匹配后怎么算
订单中如果商品命中的价格域是阶梯价(price_type=4),上面的SQL只能拿到明细ID,还需要用订单数量再去price_step_rule表里查一次区间价格。如果商品命中的价格域还绑定了品类折扣,那么最后的成交价按如下顺序计算:
最终成交价 = 阶梯价或固定价 × (1 - 品类折扣率)注意折扣率和减免金额互斥,在设计接口时要防止业务人员同时对同一客户设置两种类型的折扣。为了防止这种冲突,我在price_discount_rule表上加了约束:同一个detail_id下的discount_type必须唯一。业务逻辑上如果有减免金额的需求,可以另建特殊审批通道,不走通用折扣。
4. 版本管理与价格变更审核:价格域表结构设计中最容易忽略的环节
新版的价格域表结构如果只管“当前生效价格”,那只能算及格,远不能说优秀。供应链系统里价格变更是高频操作,合同续签、季节性调价、临时促销、恶意低价纠正,每个月都会发生。价格域表结构设计中最容易忽略的就是版本管理和变更审批。
4.1 用状态机管理价格域的完整生命周期
在price_domain表里,我设计了status和check_status两个字段配合使用。status表示业务状态,check_status表示审批状态。两者分开的好处是:一个价格域可以是“审批通过但还没生效”,比如下个月才执行的新价格;也可以是“已生效但临时停用”,比如发现价格异常需要紧急下架。
实践经验里最好用的组合是:
| status \ check_status | 待提交 | 审批中 | 已通过 | 已驳回 |
|---|---|---|---|---|
| 草稿 | 有效 | - | - | - |
| 已发布 | - | - | 有效 | - |
| 已失效 | - | - | 有效期过后自动置为 | - |
| 已废弃 | - | - | 手动作废 | - |
状态变更必须记录日志。我建议单独建一张price_domain_log表,每次修改主表或明细表都记录操作人、操作时间、变更前后的关键字段快照。虽然会增加一点写入量,但在对账纠纷和审计时价值极大。
4.2 新版本生效,老版本保留
价格域更新时禁止直接修改原记录。正确的做法是复制原价格域生成新版本,修改需要变更的字段,然后走审批流程。这样设计的收益非常直接:
- 订单始终关联“创建时的价格域版本”,历史订单不会因为价格调整而出现数据漂移。
- 合同到期续签时,直接复制上个版本再批量改价,操作效率高且不容易漏SKU。
- 万一新价格有误,回滚时只需要把新版本失效、恢复旧版本即可,不需要做逆向update。
这里我在price_domain表上额外增加了一个parent_domain_id字段用来串起版本链。比如PD-2024-001的价格域是PD-2024-000的复制版本,那么前者的parent_domain_id就指向后者。版本链和domain_code的唯一性约束配合,能在很大程度上避免“分不清是哪个版本在生效”。
我踩过一个挺惨的坑:有次运营需要紧急调价,直接在生产库update了价格明细,结果第二天对账时发现前一天下午所有订单价格全部使用了调价后的新值。原因很简单,订单创建时没有把价格快照写入订单表,查询时实时join价格域。从那以后,我在订单明细表里强制增加了price_snapshot字段,下单时直接把当时的报价、折扣、税率全部冗余进来。这个调整非常关键。
4.3 价格审批流程怎么融入表结构
价格域的状态流转需要审批。虽然审批动作本身是业务逻辑,但表结构设计要为它留好扩展位。我的方案是复用通用的审批表approval_flow_instance,然后在price_domain表里通过process_instance_id关联审批实例。
这样做的好处是不用为价格单独建审批表,审批流程变更(比如增加区域总经理节点)不影响价格域的数据结构。审批通过与驳回的回调接口里更新check_status,同时触发消息通知给创建人。这一块建议用事件驱动的方式去写,别在审批回调里同步更新价格,因为价格生效后还要清缓存、通知下游ERP、更新客户门户展示价格,这些异步做会稳很多。
5. 基于表结构的查询、缓存与性能优化
B端价格查询的链路往往很长,从客户门户浏览商品列表到下单生成订单,每个环节都要触发价格计算。如果不做优化,数据库压力会非常大。
5.1 索引优化:联合索引是生命线
上面的ERD就是核心索引设计,我再额外强调一下price_domain_detail表的最关键查询模式:先通过domain_id定位到某个价格域,再查明细。所以uk_domain_sku(domain_id+sku_id)这个唯一索引就是生命线。查询时必须先让数据库能够快速过滤价格域列表,再进明细表。
实际上,价格域列表的过滤(customer_id + start_date + status)才是最大瓶颈。我给price_domain表设计了uk_customer_start_date和idx_group_start_date两个联合索引,就是为了让“客户+日期”能快速定位候选价格域。在实际项目中,这条查询语句是系统最高频SQL之一,索引设计不当会直接把数据库CPU打爆。
5.2 缓存策略:按客户维度做价格快照
由于价格域的查询有明确的“客户+日期+SKU”维度,非常适合做缓存。我推荐二级缓存策略:
第一级是进程内缓存(如Caffeine),适合单机高频查询,TTL设置为5分钟,命中率极高。第二级是Redis缓存,key格式为price:domain:{customerId}:{date}:{skuId},TTL设置为1小时或价格域变更时主动失效。
在价格域数据变更时,发送一个消息到Redis进行key清理,同时更新一个price_version:{customerId}:{date}的版本号,查询时先校验版本号,不一致则重新加载。这套方案成本不高,但能把价格查询接口的RT从200ms降到个位数毫秒级。
这里必须提醒:缓存里的价格和数据库的价格不应该被视为不同来源。数据库是唯一可信数据源,Redis只是性能加速器,任何写入都必须先落入数据库再异步刷新缓存。因为一旦缓存先写而事务回滚,就会出现缓存污染。
5.3 数据归档:历史价格域无限增长怎么办
price_domain和price_domain_detail表的数据增长速度比想象中快得多。每次合同续签、每次调价都会生成新版本,两年下来轻松几十万条记录。虽然这个量级对MySQL还不至于构成严重性能问题,但查询会越来越慢,管理和排查也会越来越吃力。
我的方案是:按年份对价格域表做归档。当前年份+最近一年的数据放在主表,更早的数据迁移到price_domain_2023、price_domain_2024这样的归档表。归档操作每个月执行一次,通过定时任务把end_date小于当前年份且没有关联有效订单的价格域迁走。
注意归档不能破坏订单关联。订单明细中的price_snapshot和price_domain_id需要保留,所以归档时订单表不能做物理删除,只能做历史表的引用同步。归档前先跑一遍对账脚本,确保没有未结算订单关联到归档数据,这一点需要格外小心。
6. 常见问题速查与避坑指南
价格域表结构上的问题,往往不是看代码能发现的,都是业务跑起来后一个个暴露出来的。我按真实遇到过的频率,整理了一张速查表。
| 问题现象 | 根因 | 解决方案 |
|---|---|---|
| 同一个客户在不同入口看到的价格不一样 | 客户门户和下单后台走了两套价格查询逻辑 | 统一封装PriceEngine,所有入口强制走同一服务 |
| 历史订单价格对不上账 | 订单没有存价格快照,实时join价格表 | 订单明细表增加price_snapshot字段,下单时冻结价格 |
| 新价格生效了但客户门户没变 | 只改了数据库,没有通知缓存清理 | 价格变更走事件发布,订阅端主动清缓存 |
| 阶梯价区间重叠导致价格错乱 | 运营手工录入时区间填写重叠 | 增加区间重叠校验,并限制同一SKU同一价格域只能由系统生成阶梯区间 |
| 新客户没有价格,下单直接报错 | 没有创建价格域 | 初始化客户时同步生成“默认总部价”价格域引用,无价格时落日志并告警 |
| 审批通过的价格不生效 | 校验了status但没同步check_status | 审批回调里必须同时更新status和check_status |
下面几个是新手最容易忽视的坑,单列出来再唠叨一遍。
价格值和币种单位要分离。升鲜宝有些客户涉及跨境结算,价格域表里如果只存数值不存currency,后续汇率换算会成一笔糊涂账。我的建议是:价格域明细表统一以人民币存储为基准,同时保留quote_currency和quote_rate字段,在订单生成时再做币种换算。
税率字段不可省略。生鲜品类里蔬菜9%、肉类13%,增值税率不同直接导致含税价和无税价差异。价格域明细表里的tax_rate必须由税务系统配置驱动,不要在价格计算时硬编码税率,否则季度报税时财务会找上门。
客户协议价和“一口价”不要混用。有些业务喜欢在订单上直接改价,改多了就会依赖“订单改价”而忽略协议价维护。正确的逻辑是:订单改价必须走特批流程,且记录改价原因;正常的合同价、促销价都应在价格域中维护。这个规矩不立起来,价格域最终会形同虚设。
7. 实操经验总结与后续扩展方向
最后说说我个人在实际操作中的体会。升鲜宝的价格域从最初“客户表加个价格字段”演进到现在这套模型,中间经历了不少业务侧的考验。最核心的认知转变是:价格不是一个“值”,而是一个“规则集合”。只要这个认知到位,后面的表结构设计都会很顺。
具体到设计技巧上,我还想分享一个小建议:在设计价格域时,一定要和财务、运营一起过一遍“对账场景”。财务关注的是订单价格和历史价格的追溯,运营关注的是调价效率和批量处理,两者诉求不同,对表结构的理解也会不同。提前让他们参与评审,远比上线后提需求再改表结构要省力得多。
这个模块后续还可以往两个方向扩展。一是引入价格预测和智能定价,基于历史成交价、采购成本、市场竞争数据自动生成建议价格,再走人工审批流程;二是把价格域和合同管理系统打通,合同里的账期、预付比例等条款直接联动到价格计算逻辑中。这两块都需要以当前的价格域模型为基础,所以建议在搭建前期就把扩展位预留好,比如在price_domain表里加上contract_id、settlement_type字段,避免后续再大改。
希望这套价格域表结构设计能给正在做B端供应链系统的朋友一些参考。具体字段名和分表方案可以根据自己项目调整,但“价格是规则集合、价格域要独立建模、价格必须带版本和快照”这几个原则,放到什么系统里都成立。