我见过不少做仓库和供应链的团队,手里商品几万个SKU,库存金额压得死死的,但你要问他们:到底哪一批货需要精细化管理,哪一批只要保证不缺货就行?很多人答不上来。原因很简单,大家平时只看了单品的销量或库存数量,没有把"价值贡献"这件事拉开来看。ABC库存分类(帕累托分析)就是解决这个问题的经典工具:把贡献了主要销售额的那一小撮SKU挑出来,重点管;把那些又杂又散的尾部商品归为一类,粗放管。这套东西用Excel也能算,但一旦SKU数量过万,或者需要按周按月滚动刷新,SQL就成了最靠谱的批量计算方式。这篇文章我会把ABC分类背后的规则、SQL实现、边界处理和真实项目里的坑一次性讲透。
1. 先弄清楚ABC分类到底在算什么
1.1 帕累托法则为什么在库存管理里有效
大约在19世纪末,经济学家帕累托研究意大利土地分配时发现,约20%的人掌握了80%的土地。这个"少数关键、多数次要"的分布规律后来被大量行业验证,放在库存管理里,表现就是:一小部分SKU贡献了大部分销售额,另一大堆SKU只贡献很小一部分。
这个规律之所以在库存领域反复出现,本质原因是需求分布天然不均匀。爆款永远只有那几个,长尾商品则数不胜数。如果企业不对商品做区分,对每一款SKU用同一套补货、盘点、周转标准,那结果一定是:人手被长尾商品拖垮,资金压在不值钱的库存上,真正值钱的爆款反而因为管理粗糙而缺货。
所以ABC分类的核心目标不是给商品贴个标签,而是回答一个问题:有限的仓储空间、采购资金和管理精力,应该优先投给哪些商品?这决定了后续的补货策略、盘点频率、安全库存设置。说到底,这是一次"资源分配规则"的梳理,而SQL只是把规则落到数据上的执行手段。
1.2 A/B/C三档到底怎么划
最经典的口径是下面这套:
| 分类 | 累计销售额占比区间 | 管理策略 |
|---|---|---|
| A类 | 0%~70% | 重点管理,高频盘点,安全库存拉高 |
| B类 | 70%~90% | 常规管理,定期复盘,平衡补货 |
| C类 | 90%~100% | 粗放管理,系统自动补货,减少人工干预 |
这个"70/90"来源于管理经验,不是数学铁律。实际操作中,有些公司会把A类的线拉到60%,有些会把B类的线拉到95%,都没有问题。真正重要的是,你一旦定下口径,就要在全公司统一执行,并且把"累计贡献率"的计算方式写清楚,否则财务、运营、仓库各算各的,最后一定会吵架。
需要特别注意的是:ABC划分的底层变量是"累计销售额占比",不是单品销售额排名。两者看着长得像,实际区别很大。单纯看排名,你只知道第10名是谁;看累计占比,你才知道从第1名到第10名的商品合计贡献了全盘多少销售额。后者的信息量才能支撑"资源分配"决策。
1.3 为什么排序选金额而不是数量
讲一个我实际遇到的例子。某包装材料公司有2万个SKU,按出库数量排序,前100名几乎全是几块钱的胶带和气泡膜;按销售额排序,排在最前面的却是单价两三千元的专用设备耗材。如果按数量做重点管理,仓库会拼命备4块钱的胶带,结果耗材缺货的投诉不断。
ABC分析的本质是"钱流管理",因为库存金额、仓储费用、资金占用都和商品单价直接相关。所以绝大多数企业选销售额或毛利额作为排序字段。只有少数行业例外,比如快递包装按体积重量收费,或者生鲜电商按销量摊销损耗,才会考虑用数量或体积作为主维度。选哪一个指标,取决于你的瓶颈资源是什么,这个选择是动手写SQL之前必须想清楚的第一件事。
2. 动手写SQL之前必须定死的一堆口径
2.1 用销售明细还是出库明细
我见过不少新手一上来就写SELECT sku_id, SUM(amount) FROM order_detail GROUP BY sku_id,然后拿到的分类结果被业务方全盘否决。为什么?因为你统计的是"下单金额",里面可能包含用户拍了又退的订单,也可能把赠品、售后补发件算进去了。
做ABC分类,建议优先使用已经出库、且确认收入的交易明细。如果企业有独立的出库流水表或者发货表,用那张表更干净。没有的话,就得在下单明细里通过状态字段过滤,只保留"已支付且未退款"、"已发货且未退货"的单据。口径的差异在整体数据量大时会被放大,C类商品可能因为一股异常退款冲进来好几十万伪销售额。
2.2 统计周期选多长
周期太短,比如只看近7天,冷门但季度性强的商品会被严重低估;周期太长,比如看两年,今年的新品又会被一两年前的爆款压住。我常用的做法是滚动90天,对大多数快消、制造、电商场景都比较稳。
如果你的业务有明显的季节性,比如服装、节日礼品,建议同时算两个窗口:近90天用于日常管理,近一年用于备货计划。你要的ABC分类是跟着管理动作走的,管理动作频率不同,统计窗口就该不同。用SQL实现时,只需要把WHERE order_date >= ...里的日期条件换掉,其他代码完全不用改。
2.3 粒度划到SKU还是品类
粒度决定分类结果的可执行性。按SKU分,颗粒度细,但SKU动辄几万个,A类可能仍然有几百个;按品类分,颗粒度粗,适合集团层面看资源配置,但不适合仓库具体排货。
我的建议是分层做:先用SQL按SKU算出最基础的ABC,得到每个商品的分类;然后按品类聚合,看品类层面的ABC分布。这样无论是采购部门管单品,还是管理层看品牌线,都能找到对应的口径。SQL层面只需要在GROUP BY那里切换字段,逻辑完全复用。
2.4 脏数据怎么处理
这一步必须在SQL里写死,不要指望业务方事后解释。常见要过滤的:测试订单、负数金额的调整单、赠品零金额单、内部领用和员工内购。还要注意退款处理,如果一笔订单在统计期初下单、期末退款,金额可能还在表里,需要按最终实际成交状态过滤。
一个比较稳妥的过滤条件是同时满足:order_status = 'completed'、pay_status = 'paid'、refund_status = 'none'、amount > 0。如果数据质量差的系统里没有这些状态字段,至少要排除掉金额小于等于0的单据,再用业务备注字段补一层过滤。脏数据不清理,ABC分类的结果就是空中楼阁,后面所有管理决策都会跟着歪。
3. 一条SQL算出累计贡献占比的完整写法
3.1 为什么用窗口函数而不是自连接
很多人第一反应是"算累计金额嘛,我用自连接,把比自己销售额大的都加起来"。这个思路本身没错,但存在两个问题:一是自连接是笛卡尔积,几万个SKU做笛卡尔积会产生上亿行中间结果,性能完全扛不住;二是代码又长又难读,维护成本高。
窗口函数SUM() OVER (ORDER BY ...)就是专门干这个的。它在一次扫描中同时保留每个SKU的销售额和到当前行为止的累计值,不需要自连接。SQL从2003标准开始就支持窗口函数,主流数据库都有,所以这条路是通的。
3.2 基于销售明细表的完整SQL示例
下面这段SQL以SQL Server为例,逻辑可以直接平移。前提是明细表sales_detail里每个订单会有多行明细,一行代表一个SKU。
WITH sku_sales AS ( SELECT sku_id, MAX(sku_name) AS sku_name, SUM(amount) AS sales_amount, SUM(qty) AS sales_qty, COUNT(DISTINCT order_no) AS order_cnt FROM sales_detail WHERE order_date >= DATEADD(MONTH, -3, GETDATE()) AND order_status = 'completed' AND refund_status = 'none' AND amount > 0 GROUP BY sku_id ), ranked AS ( SELECT sku_id, sku_name, sales_amount, sales_qty, order_cnt, ROW_NUMBER() OVER (ORDER BY sales_amount DESC, sku_id) AS rn, SUM(sales_amount) OVER ( ORDER BY sales_amount DESC, sku_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS running_amount, SUM(sales_amount) OVER () AS total_amount FROM sku_sales ) SELECT sku_id, sku_name, sales_amount, sales_qty, order_cnt, rn, running_amount, total_amount, ROUND(running_amount * 1.0 / total_amount, 4) AS cum_ratio, CASE WHEN running_amount * 1.0 / total_amount <= 0.70 THEN 'A' WHEN running_amount * 1.0 / total_amount <= 0.90 THEN 'B' ELSE 'C' END AS abc_category FROM ranked ORDER BY rn;这段代码分两层:第一层sku_sales把明细聚合成每个SKU一行;第二层ranked用窗口函数同时算出三样东西——全量总金额total_amount、累计金额running_amount、排序号rn。最后在SELECT里直接算累计占比并套CASE得出ABC分类。
这里有一个非常关键的细节:窗口函数的ORDER BY sales_amount DESC, sku_id。我故意在后面加了sku_id作为次级排序键,目的在下一章讲"并列值"时会详细说明。你先记住,尽量不要只写ORDER BY sales_amount DESC,否则并列销售额的商品之间会以不确定的顺序排列,累计金额结果会不稳定。
3.3 各个数据库的写法差异
如果把上面这段代码移植到其他数据库,只有日期函数和几个关键字不同:
| 数据库 | 日期过滤写法 | 注意事项 |
|---|---|---|
| MySQL 8.0+ | order_date >= DATE_SUB(CURDATE(), INTERVAL 3 MONTH) | 窗口函数支持完整,放心用 |
| PostgreSQL | order_date >= CURRENT_DATE - INTERVAL '3 month' | 语法最标准,* 1.0可省略 |
| SQLite | order_date >= date('now', '-3 month') | 3.25版本之后才支持窗口函数 |
| Hive / Spark SQL | order_date >= add_months(current_date, -3) | 支持ROWS BETWEEN,注意并行度设置 |
MySQL 8.0版本只要把DATEADD(MONTH, -3, GETDATE())换成DATE_SUB(CURDATE(), INTERVAL 3 MONTH),其余代码完全不用动。用MySQL 5.7及以下、SQL Server 2008等不支持窗口函数的旧版本,替代方案我在第5章专门讲,这里先不展开。
4. 分类判定的边界问题
4.1 累计占比刚好等于0.70,算A还是算B
我见过好几个团队在这上面吵起来。有的说"等于0.70说明还没超过70%,应该算A",有的说"已经到线了,算B更稳妥"。我的建议是:把阈值当成闭区间处理,也就是<= 0.70算A、<= 0.90算B、其余算C。
原因很简单:闭区间逻辑直观,不会因为浮点精度问题把恰好在线上的商品算到下一档。SQL里的浮点计算有精度误差,比如实际是0.7000001,你写< 0.70,它就落进了B类;写<= 0.70,它就老老实实待在A类。为了让阈值边界可控,我更推荐把阈值做成变量或参数,比如DECLARE @a_threshold DECIMAL(10,2) = 0.70,后面需要调整时只改变量,不碰CASE逻辑。
4.2 多个SKU销售额完全一样怎么办
这是个常见的坑。假设第49名到第52名的销售额都是8888元,累计到第48名时刚好是69.5%,那第49名能不能进A类?如果只按销售额排序,这四个SKU顺序随机,A类可能只选进其中一个,也可能四个全选进,结果完全取决于数据库的物理顺序。
解决方法是把排序键从"销售额"扩展为"销售额 + 业务唯一键"。我在第3章的SQL里写的ORDER BY sales_amount DESC, sku_id就是这个用途:销售额一样时,按SKU编号决定先后顺序,保证每次跑结果都一致。这样虽然A类末尾可能有销售额相同但编号靠后的SKU被挡在门外,至少结果可复现、可解释,不会第二天重跑一遍就变了。
4.3 零销量和负销量商品怎么处理
零销量商品分两种:一种是刚上架的新品,还没开始产生销售;一种是长期滞销的老库存。它们如果都拉进来参与累计占比计算,会拉低每个SKU的占比数值,还会让A类几乎集中在几个老爆款上,对上新计划没有参考价值。我的建议是把统计周期内销售额为0的SKU直接过滤掉,只对有动销的SKU做ABC,滞销品单独用另一个滞销报表管理。
负销量商品更麻烦。比如大额退货冲减后,某个SKU的汇总销售额变成负数。这种SKU排进排序后会把累计金额往负方向拉,影响后续所有商品的累计占比。处理方式就是前面2.4节说的:先过滤明细,用状态字段排除退款单据,从源头避免负值的产生。如果源头数据实在改不了,在sku_sales这层也要加个HAVING SUM(amount) > 0。
4.4 A类商品数量太多怎么办
有时候你会遇到一种尴尬情况:按累计占比70%一卡,A类居然有40%的SKU数量。这说明你的商品结构极度分散,单品贡献低,所谓爆款并不爆。这时候不要硬套70/90,可以把阈值调整到50/85,或者引入"最小A类数量"约束。
比较实用的办法是在SQL里加一个控制参数,按"累计占比不超过70% 且 排名在前N个"双条件判定A类。这样既尊重帕累托逻辑,又避免A类无限膨胀。实际项目中,A类SKU数量最好控制在全量的15%~25%之间,否则"重点管理"就名存实亡了。
5. 真实项目里的坑与替代方案
5.1 数据库版本太老不支持窗口函数怎么办
真实环境里总有那么一两个旧系统跑着SQL Server 2008或者MySQL 5.7。窗口函数用不了,也不是没有办法。第一招是用相关子查询算累计金额:
SELECT a.sku_id, a.sales_amount, ( SELECT SUM(b.sales_amount) FROM sku_sales b WHERE b.sales_amount > a.sales_amount OR (b.sales_amount = a.sales_amount AND b.sku_id <= a.sku_id) ) AS running_amount, (SELECT SUM(sales_amount) FROM sku_sales) AS total_amount FROM sku_sales a ORDER BY a.sales_amount DESC, a.sku_id;这段代码的逻辑是:对每个SKU,找出所有销售额比它大、或者销售额相等但SKU编号排在它之前的SKU,把它们的金额加总。结果和窗口函数一致,但性能差很多。几万个SKU还能跑,十几万以上就要慎重了。
第二招是用数据库变量,比如在MySQL里这样写:
SET @running := 0; SELECT sku_id, sales_amount, @running := @running + sales_amount AS running_amount FROM sku_sales ORDER BY sales_amount DESC, sku_id;这招性能最好,但有个隐患:变量的赋值顺序依赖数据库对SELECT列的计算顺序,在复杂SQL里容易出bug,而且多线程并发下不建议在生产环境直接用。宁可先用相关子查询把结果算出来,再考虑优化。
5.2 多仓库、多店铺怎么各自独立分类
如果公司有多个仓库或多个线上店铺,直接对全量数据做ABC,结果会被体量大的仓库或店铺带偏,小仓库里的利润款根本进不了A类。正确做法是加一个PARTITION BY,让窗口函数在每个仓库内部独立计算。
WITH wh_sales AS ( SELECT warehouse_id, sku_id, SUM(amount) AS sales_amount FROM sales_detail WHERE ... GROUP BY warehouse_id, sku_id ) SELECT warehouse_id, sku_id, sales_amount, SUM(sales_amount) OVER ( PARTITION BY warehouse_id ORDER BY sales_amount DESC, sku_id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) * 1.0 / SUM(sales_amount) OVER (PARTITION BY warehouse_id) AS cum_ratio FROM wh_sales;注意,这里所有窗口函数都必须带上同一个PARTITION BY,包括求总金额的那个窗口函数。只给累计函数加、不给总数函数加,算出来的累计占比就是仓库累计值除以全公司总值,结果完全错乱。另外,小仓库SKU只有几十个时,A类可能占据一大半,这种仓库需要单独定阈值,不能和主仓库用同一套70/90标准。
5.3 明细表数据量巨大怎么保证性能
千万级甚至亿级明细表直接跑窗口函数,虽然能跑,但没必要。原则是"先瘦身,再计算":先用WHERE把时间范围和状态过滤掉,再用GROUP BY聚合到SKU粒度,让进入窗口函数的数据量从亿级降到万级。
如果数据量大到聚合都要跑很久,再看两件事:一是 WHERE 条件里的日期字段有没有走索引,没有索引就先建;二是把中间结果落到临时表,或者用物化视图,避免每条分析SQL都重新扫全表。我曾经处理过一张日增500万行的订单明细表,就是把每月跑一次的ABC分析改成"月结后把销售汇总写入分类结果表,日常只查结果表",查询耗时从两分钟降到了两秒。
5.4 业务数据里的雷:退货、赠品、测试单
这一节说的坑,几乎每个接手供应链数据的人都会遇到。退货问题前面讲过了,赠品则更隐蔽:订单里赠品的单价是0,但数量是正的,它会把SKU的销量拉高、金额影响不大。如果按数量做ABC,赠品会把真实销售数量挤下去;如果按金额做ABC,零金额赠品倒不影响排序,但会在后续计算平均单价时污染数据。
测试单是另一个常见的雷。业务方拿一个内部测试账号反复下单,金额和数量都真实入表,但没有任何意义。过滤方法是在明细表里加一个is_test字段,或者在账号维度维护一张测试账号表,SQL里用LEFT JOIN把这些账号的订单剔除。没有条件的系统,至少按订单备注里的"测试"关键字做一轮排除,能挡掉七八成。
6. 从基础ABC再往前走一步
6.1 金额维度和数量维度的二维分类
单个ABC维度能解决"什么货值钱",但解决不了"什么货走量大"。有些商品金额贡献高是因为单价高,一年卖不了几件,占用库存资金却很大;有些商品金额贡献高是因为走量大,单价便宜但天天出货。两者管理重点完全不同,前者要控库存防积压,后者要保供应防断货。
用SQL做二维分类的思路是分别对金额和数量排两个名次,再把两个维度组合:
WITH sku_metrics AS ( SELECT sku_id, SUM(amount) AS amt, SUM(qty) AS qty, NTILE(5) OVER (ORDER BY SUM(amount) DESC) AS amt_tile, NTILE(5) OVER (ORDER BY SUM(qty) DESC) AS qty_tile FROM sales_detail WHERE ... GROUP BY sku_id ) SELECT sku_id, amt, qty, CASE WHEN amt_tile <= 2 AND qty_tile <= 2 THEN '重点款' WHEN amt_tile <= 2 AND qty_tile >= 4 THEN '高值慢流' WHEN amt_tile >= 4 AND qty_tile <= 2 THEN '低值快流' ELSE '普通款' END AS strategy_type FROM sku_metrics;NTILE(5)把SKU按金额和数量各分成5等份,前两档算高,后两档算低。这样组合出来的"重点款"就是既值钱又走量的核心商品,资源和精力应该优先压在这类SKU上。
6.2 用窗口函数做周期滚动对比
ABC分类不是一次性的工作,最好每个月滚一次。如果你想知道哪些SKU从上个月的A类掉到了这个月的C类,可以在两张分类结果表之间做关联对比,或者用LAG函数看同一条SKU在连续两个月里的分类变化。
SELECT sku_id, cur_month, cur_category, LAG(cur_category) OVER (PARTITION BY sku_id ORDER BY cur_month) AS prev_category FROM abc_result WHERE cur_month >= '2025-01-01';这种前后对比的价值在于提前发现趋势变化:某个SKU连续两个月从A滑向C,说明需求在萎缩,采购计划该踩刹车了;反过来从C升到A,则可能是新爆款起量,要赶紧补货。SQL能把这些变化自动算出来,省得运营每个月自己肉眼比对Excel。
6.3 分类结果如何落到管理动作
ABC计算得再漂亮,最后落不到管理动作上也是白做。以我的经验,分类结果的落地至少要覆盖三件事:补货频率、盘点周期、库存深度。A类商品可以设置更高的安全库存、每天循环盘点、补货周期缩短到周维度;B类商品保持常规周补货、月度盘点;C类商品让系统自动按最低库存触发补货,人工尽量不碰。
我见过最成功的一个落地案例,是把ABC分类结果直接写进ERP的物料主数据表,然后在采购模块里按分类读不同的审批流程。A类采购单需要计划员和经理双审,C类自动过单。这样分类结果就从一个分析报表变成了日常业务流程的一部分,价值才真正发挥出来。
我个人的体会是:ABC分类的SQL实现并不难,难的是口径想清楚、边界处理好、结果用起来。每次跑ABC之前先问自己三个问题——用什么指标、什么周期、什么粒度;跑完之后再问三个问题——A类会不会太多、边界有没有争议、业务方认不认这个结果。这套问答做顺了,你手里的SQL才真正在为库存管理服务。最后再分享一个小技巧:把ABC计算封装成一个存储过程或定时任务,每月自动跑一遍并把结果写进一张sku_abc_result表,业务部门随时可以自助查询,比临时跑SQL要省心太多。