
接商品数据这活儿的人基本都会在SPU和SKU这两个词上卡一次。我最早做多规格商品的API数据建模时天真地以为就是一个父商品带几个子商品结果第一次拉回来的一批数据里同一个SPU下面挂着47个SKU颜色和容量交叉出来本该是12种组合却多出一堆看起来一模一样的规格再往下查又发现某些SKU的价格是字符串、库存是-1、主图字段是空的。这类问题不解决后面做比价、做库存预警、做前端规格选择器全是空中楼阁。这篇东西讲的就是SKU与SPU关系解析这件事多规格商品在电商系统里是怎么被拆成两层结构的API返回的字段骨架长什么样落到自己的数据库里该怎么建模以及我在实际对接中踩过的那些坑。它适合正在做商品数据同步、做选品分析、做电商中台或者单纯想把商品数据结构吃透的人看。不管你是刚接需求的新手还是已经写过几版同步脚本的老手下面这些拆解应该都能对上你的某些痛点。全文的接口示例都做了脱敏处理字段名用的是业界通用的命名习惯换成任何一家平台的开放接口思路是一样的。1. 先把概念掰开SPU和SKU到底各自代表什么1.1 用一个生活化类比理解两层结构我习惯这么解释SPU是菜谱上的一道菜SKU是你在收银台结账的那一份。宫保鸡丁这道菜在菜单上只有一个条目代表它是什么、属于哪个品类、用什么食材做的但你真点的时候服务员会问你微辣还是特辣加不加花生你选完的那一份才是实际下单的东西。菜谱上的条目就是SPUStandard Product Unit标准产品单元你实际买单的那一份就是SKUStock Keeping Unit库存量单位。这个类比能解释掉大部分疑惑。SPU回答的是这是什么它承载的是商品的描述性信息标题、品牌、类目、型号、材质、产地、卖点、详情页。SKU回答的是买哪一个它承载的是交易性信息具体规格、价格、库存、条码、重量、独立主图。同一道菜的食材说明对所有份都适用所以放在SPU上这份多少钱、还剩几份只对单独一份成立所以放在SKU上。从这个角度去看数据建模就能明白为什么不能把它们拍平成一张表。一旦拍平SPU层面的字段比如品牌、类目会在每一个SKU行里重复一遍改一个标题要更新几十行而且极易出现同一SPU下不同行品牌不一致的脏数据。反过来如果把价格、库存也提到SPU层那多规格商品就没法独立定价和独立扣减库存了。两层的存在不是为了炫技是被业务逼出来的。1.2 销售属性与非销售属性区分可选的和描述的真正决定一个商品要不要拆成多个SKU的是销售属性也叫规格属性、关键属性。这类属性的特点是消费者购买时必须从中选一个不同取值对应不同的实物、不同的价格、不同的库存。典型的有颜色、尺码、容量、版本、口味、套装组合、净含量。与之相对的是非销售属性也叫参数属性、描述属性它们只用来描述和筛选不参与SKU拆分品牌、型号、上市时间、屏幕尺寸、机身材质、适用季节、产地。你买一件T恤不会因为产地不同而选两次但你会因为尺码不同而下两个单。这里有个容易翻车的点同一个属性名在不同类目下的归属可能完全不同。在服装类目颜色是销售属性必须拆SKU在某些配件类目颜色可能只是外观描述商家只挂一个SKU颜色写在参数里。所以做API数据建模时绝不能在代码里写死颜色销售属性、品牌非销售属性这种映射必须从接口返回的类目属性模板里动态读取每个属性的isSale或attrType标识。我在第一版脚本里就干过这种蠢事硬编码了一张属性归属表前三个月相安无事后来接入一个新类目发现那个类目里规格是销售属性而颜色是描述属性结果整批SKU被错拆成零个下游报表直接空了三天。这个教训值一句提醒属性归属跟着类目走不跟着字段名走。1.3 为什么平台要这么设计从检索、交易、库存三个视角站在检索角度用户搜索轻薄笔记本时系统要命中SPU而不是几十个SKU否则搜索结果会被同一款商品的不同配色刷屏体验极差。所以搜索索引建在SPU层加上价格区间、库存聚合状态这些派生字段。站在交易角度下单、加购、支付的最小单位必须是SKU。购物车里放的是深空灰 16G512G这一条订单行也是它退款退货也是按它算。SKU层必须带唯一编码、独立价格、独立库存、独立重量影响运费。站在库存角度仓储系统管理的是实物实物就是SKU。同一个SPU下的不同配色可能放在不同仓库库存量各自独立。SPU层的有货/无货只是SKU库存的聚合投影绝不能拿它做扣减。把这三个视角想清楚你就知道建模时哪些字段该放哪一层是没得商量的。检索相关的聚合字段最低价、最高价、SKU总数、可售SKU数、总库存是派生数据可以冗余在SPU表里加速查询但必须由SKU层计算得出不能反着来。2. 数据建模的整体思路拆解2.1 三层模型类目属性模板、SPU、SKU我最终落地的模型是三层而不是两层多出来的那一层是类目属性模板。原因很简单属性不是凭空来的每个类目规定了这个类目下有哪些属性、哪些是销售属性、属性值从哪个字典里选、是否必填、是否可多选。这层模板是所有商品数据的元数据不建它后面所有校验都无从谈起。具体结构是这样dim_category存类目树一级到末级带路径字段方便做手机通讯手机智能手机这种前缀查询dim_attr存属性定义属性ID、属性名、所属类目、是否销售属性、是否多值、是否必填、输入类型是枚举还是文本dim_attr_value存枚举属性的可选值值ID、值名、所属属性、排序号。SPU表只放类目、品牌、标题、卖点、主图、详情、上下架状态、审核状态这些描述性字段外加一批派生聚合字段。SKU表放SKU编码、所属SPU、价格、库存、条码、重量体积、独立图、可售状态。中间再一张rel_sku_sale_attr纵向表记录每个SKU在每个销售属性上取的值。这样一张SKU对应多行属性记录行数 销售属性个数查询时按 sku_id 聚合回来即可。2.2 为什么不做成扁平大宽表有人会问为什么不直接把颜色、尺码、容量做成三列塞进SKU表查询多快。短期看确实快长期看是灾难。原因有三个。第一销售属性数量不固定。服装是2个颜色、尺码家电可能是3到4个颜色、容量、版本、能效家具甚至能到5个。宽表要么预留一堆attr1到attr10的空列要么频繁改表结构。前者浪费且语义不明后者在数据量上来之后改表极其痛苦。第二属性名会变。同一个类目下早期叫机身内存后来规范成存储容量同一个attr1列里混着两种语义做统计时你根本分不清。第三跨类目查询会崩。你写WHERE color 深空灰在手机类目成立在图书类目根本没有这个字段。纵向表 字典表的组合把这三种问题一次性解决。代价是查询要多一次 JOIN 或者一次聚合但这个代价用一张spu_sku_agg的物化表就能补回来——我一般让定时任务把SKU 规格串预先拼好存进去前端直接读。实测在千万级SKU下物化表的查询延迟比实时JOIN低一个数量级。2.3 属性表设计属性、属性值、属性分组dim_attr里有几个字段值得单独说说。attr_type分两类销售属性参与拆SKU和描述属性不参与。value_type分三类枚举从字典选、数值带单位比如重量 1.5kg、文本自由填写。这三类在入库时的处理逻辑完全不同枚举要做值ID映射数值要做单位和精度归一文本要做长度截断和敏感词过滤。is_multi这个字段容易被忽略。有些属性天然多值比如适用场景可能同时是办公、游戏、设计。多值属性在API里通常用逗号或竖线分隔也可能是数组。如果不做拆分你在做筛选办公本的时候会漏掉一大批数据。我还会额外加一张dim_attr_group用来记录属性的分组展示顺序比如基础参数屏幕参数性能参数。这不是平台接口给的是我自己按类目整理的。别小看这个前端商品详情页的参数区如果顺序乱了用户观感会很差运营也会来找你。2.4 关系表与约束设计唯一键、幂等键关系表rel_sku_sale_attr的主键我用(sku_id, attr_id)联合主键天然防重。dim_sku的主键是sku_iddim_spu的主键是spu_id。这两个ID我建议直接用平台的下发ID不要自增原因是对接多个数据源时自增主键会撞而且排查问题时用平台ID能直接对上省掉一次映射查询。除了主键还要建几个唯一索引dim_spu上(spu_id, source)dim_sku上(sku_id, source)rel_sku_sale_attr上(sku_id, attr_id, value_id)。source字段是数据来源标识多平台同步时必不可少。幂等键我一般用md5(spu_id | sku_id | version)其中 version 取自接口返回的更新时间戳或版本号。没有版本号的时候就用整条记录的字段哈希。这张sync_log表记录每次写入的幂等键和结果重跑任务时先查这个表已经成功的直接跳过避免重复写导致的自增序列膨胀和触发器误触发。注意有些平台的sku_id会带前后空格或者不可见字符入库前统一trim并且只保留可见ASCII字符否则会出现看起来一样但对不上的诡异问题。我遇到过一次两条记录肉眼一模一样length()差1查了两小时。3. API 数据结构拆解与字段映射3.1 一次商品详情请求的返回骨架不同平台的字段名各异但骨架高度相似。下面这段是脱敏后的典型结构我保留了字段语义命名做了通用化处理{ code: 0, message: success, data: { spuId: 100012043978, spuName: 某品牌轻薄笔记本 14英寸 高性能版, categoryId: 670, categoryPath: 电脑办公电脑整机笔记本, brandId: 8557, brandName: 某品牌, status: 1, descriptionAttrs: [ {attrId: 301, attrName: 屏幕尺寸, attrValue: 14英寸, unit: 英寸}, {attrId: 302, attrName: 机身材质, attrValue: 铝合金, unit: } ], saleAttrDefs: [ {attrId: 1001, attrName: 颜色, valueList: [ {valueId: 2001, valueName: 深空灰}, {valueId: 2002, valueName: 月光银} ]}, {attrId: 1002, attrName: 存储容量, valueList: [ {valueId: 2101, valueName: 512GB}, {valueId: 2102, valueName: 1TB} ]} ], skuList: [ { skuId: 100012043979, price: 5299.00, marketPrice: 5999.00, stock: 32, status: 1, imageUrl: https://img.example-mall.com/a1.jpg, barcode: 6901234567890, saleAttrs: [ {attrId: 1001, attrName: 颜色, valueId: 2001, valueName: 深空灰}, {attrId: 1002, attrName: 存储容量, valueId: 2101, valueName: 512GB} ] } ] } }这段结构里有三个层级SPU层含描述属性和销售属性定义、SKU列表层、每个SKU的销售属性取值层。解析时要严格按这三层拆不要图省事直接遍历skuList把SPU字段复制进去。3.2 销售属性组合的几种表达方式与解析陷阱同样是一个SKU的规格是什么接口至少有四种表达方式我在不同数据源上都见过表达方式示例解析要点风险结构化数组saleAttrs: [{attrId, valueId, valueName}]按 attrId 对齐最稳个别源不返回 attrId拼接字符串spec: 颜色:深空灰;容量:512GB按分隔符切注意中英文冒号混用值里含分隔符会截断扁平字段color: 深空灰, capacity: 512GB需维护字段名到属性名的映射字段名不统一组合码specCode: 2001_2101按 valueId 顺序拼接需对照定义顺序不固定会错位最坑的是第二种和第四种。拼接字符串里如果属性值本身含冒号比如接口类型:Type-C作为一个值名一刀切下去就废了。稳健做法是先按分号切分条目再从最后一个冒号左侧切出属性名剩下全归值名。组合码那种如果接口没保证valueId顺序你必须先用saleAttrDefs里的属性定义顺序做一次排序再拼码比对否则同一个SKU每次解析出来的规格串都可能不一样导致每日全量比对时报出大批变更白折腾一晚上。还有个更隐蔽的属性名重复。同一个SPU下可能出现颜色和机身颜色两个属性前者是销售属性后者是描述属性也可能出现两个都叫颜色但 attrId 不同的情况多见于老数据结构混入新数据。所以匹配必须用attrIdattrName只用于展示绝不能当关联键。3.3 价格、库存、图片在SPU与SKU上的归属这三样是最容易被放错层的。价格权威值在SKU。SPU层显示的5299起是min(sku.price)只有全部SKU同价时才显示单一价格。这里要注意有些平台会在SPU层给一个priceRange字段但那个字段经常滞后于SKU实际价格我一般不用它自己从SKU算。有些平台返回的价格单位是分有的是元有的是字符串带币种符号入库前必须统一成DECIMAL(12,2)的元。我在代码里写了normalize_price()专门干这事遇到5,299.00这种带千分位的要先去逗号。库存同样在SKU。stock -1或stock 999999通常代表不限库存或未同步不能当成真实库存做预警。要结合status字段判断SKU状态为下架时库存应该视为0。SPU层的总库存是派生值用于列表页显示仅剩3件这种营销话术但业务逻辑判断必须回到SKU。图片SKU图优先SPU主图兜底。很多商品只有SPU主图SKU图字段是空的这时候前端规格选择器切换时就没法变图体验会打折。我的处理是建一张dim_sku_imageSKU图缺失时按同SPU 同颜色值去找一张有图的SKU借用还找不到才回落到SPU主图。这个借图逻辑上线后规格切换的空图率从17%降到了2%以内。3.4 字段类型与清洗规则表接口文档写的类型和实际返回的类型经常不是一回事。下面这张表是我这些年攒下来的清洗规则直接抄能用字段文档类型实际常见类型清洗规则pricenumberstring / 带逗号 / 带符号去非数字字符转 DECIMAL(12,2)stocknumberstring / -1 / 极大值-1 视为未知入库存表时置0并打标statusnumberstring / boolean统一映射为 0下架 1上架 2审核中imageUrlstring相对路径 / 多图数组补全协议头多图取第一张为主图weightnumberstring 带单位 1.5kg提取数值单位换算为克barcodestringnumber丢前导零强制按字符串取左补零到13位saleAttrarrayarray / string / nullnull 视为单规格商品走单SKU分支barcode那条特别值一提。条码是数字很多JSON解析器默认会把它当整数前导零直接丢掉0123456789012变成123456789012长度对不上下游对接仓储时整批报错。解决方式是在解析阶段就对这类字段强制转字符串别指望类型声明。4. 实操落地从拉取到入库的完整链路4.1 请求参数与分页、限流策略商品接口一般有两种拉取模式按类目分页拉全量按更新时间拉增量。全量用于首次初始化增量用于日常同步。增量拉取的关键参数是startTime和endTime我的经验是留15分钟的重叠窗口上次同步的结束时间往前推15分钟作为本次的起点。原因是平台侧的更新时间可能有延迟事务提交时间和索引更新时间不一致不留重叠会漏数据。重叠带来的重复用幂等键解决代价远小于漏数据。分页大小一般上限是100实际我建议用50。听起来效率低了但大页在平台侧更容易超时一旦超时重试反而更慢。用50配合稳定的游标分页整体吞吐更平稳。限流要算预算。举个例子日更20万SPU平均每个SPU 8个SKU单次请求平均耗时120ms接口QPS上限20。单线程理论吞吐 8.3 次/秒实际跑到 5 次/秒就不错了。20万次请求 ÷ 5 40000秒 ≈ 11小时太慢。所以至少4并发把时间压到3小时以内留足缓冲。并发数别超过QPS上限的一半遇到抖动要能自动退避。我用的是令牌桶 指数退避失败重试间隔 1s、2s、4s、8s超过4次放弃并进死信队列人工兜底。4.2 Python 解析示例下面这段是核心解析逻辑的简化版重点看它与上面字段表的对应关系import re from decimal import Decimal def norm_price(v): if v is None: return None s re.sub(r[^\d.], , str(v)) return Decimal(s).quantize(Decimal(0.01)) if s else None def norm_stock(v): try: n int(str(v).strip()) except (TypeError, ValueError): return 0 return 0 if n 0 or n 999999 else n def parse_spu(payload): d payload[data] spu { spu_id: str(d[spuId]).strip(), spu_name: d.get(spuName, ).strip(), category_id: d.get(categoryId), brand_id: d.get(brandId), status: int(d.get(status, 0)), desc_attrs: [], skus: [], } for a in d.get(descriptionAttrs) or []: spu[desc_attrs].append({ attr_id: a[attrId], attr_name: a.get(attrName, ), attr_value: str(a.get(attrValue, )).strip(), unit: a.get(unit, ), }) # 销售属性定义用于后续顺序对齐与校验 defs {x[attrId]: x for x in d.get(saleAttrDefs) or []} for s in d.get(skuList) or []: attrs [] for a in s.get(saleAttrs) or []: aid a.get(attrId) if aid is None: # 兜底用属性名反查 aid next((k for k, v in defs.items() if v[attrName] a.get(attrName)), None) if aid is None: continue attrs.append({ attr_id: aid, value_id: a.get(valueId), value_name: str(a.get(valueName, )).strip(), }) attrs.sort(keylambda x: x[attr_id]) spu[skus].append({ sku_id: str(s[skuId]).strip(), price: norm_price(s.get(price)), market_price: norm_price(s.get(marketPrice)), stock: norm_stock(s.get(stock)), status: int(s.get(status, 0)), image_url: s.get(imageUrl) or , barcode: str(s.get(barcode) or ).zfill(13), sale_attrs: attrs, spec_key: |.join(f{x[attr_id]}:{x[value_id]} for x in attrs), }) return spuspec_key这个派生字段是我强烈建议加的。它是属性ID:值ID按 attrId 排序拼接的结果用来做同一SPU内SKU的唯一性判断。有了它检查有没有两个SKU规格完全相同就是一条GROUP BY spec_key HAVING COUNT(*) 1比逐字段比对快太多。4.3 入库DDL与UPSERT语句表结构我按 MySQL 8 的语法写字段长度留了余量CREATE TABLE dim_spu ( spu_id VARCHAR(32) NOT NULL, source VARCHAR(16) NOT NULL DEFAULT default, spu_name VARCHAR(255) NOT NULL, category_id BIGINT NULL, brand_id BIGINT NULL, status TINYINT NOT NULL DEFAULT 0, min_price DECIMAL(12,2) NULL, max_price DECIMAL(12,2) NULL, sku_total INT NOT NULL DEFAULT 0, sku_on_sale INT NOT NULL DEFAULT 0, stock_total BIGINT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL, PRIMARY KEY (spu_id, source), KEY idx_cat_status (category_id, status), KEY idx_updated (updated_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE dim_sku ( sku_id VARCHAR(32) NOT NULL, source VARCHAR(16) NOT NULL DEFAULT default, spu_id VARCHAR(32) NOT NULL, price DECIMAL(12,2) NULL, market_price DECIMAL(12,2) NULL, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, image_url VARCHAR(512) NULL, barcode VARCHAR(32) NULL, spec_key VARCHAR(255) NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (sku_id, source), KEY idx_spu (spu_id), KEY idx_spec (spu_id, spec_key) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE rel_sku_sale_attr ( sku_id VARCHAR(32) NOT NULL, source VARCHAR(16) NOT NULL DEFAULT default, attr_id BIGINT NOT NULL, value_id BIGINT NULL, value_name VARCHAR(128) NOT NULL, PRIMARY KEY (sku_id, source, attr_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;写入用INSERT ... ON DUPLICATE KEY UPDATE只更新会发生变化的字段updated_at每次写当前时间。注意别在 UPDATE 子句里写主键MySQL 会报警告。批量写建议 500 条一批太大容易超max_allowed_packet太小事务开销高。4.4 增量同步与幂等同步任务的骨架是先查sync_log拿到上次水位 → 拉数据 → 逐条解析 → 计算幂等键 → 查sync_log是否存在 → 不存在则入库 → 记录幂等键 → 提交水位。水位只在整批成功后才推进中途失败要回滚水位否则会漏数据。有个细节容易被忽略删除。平台侧商品下架甚至物理删除后增量接口通常不会返回那条记录你本地就永远留着它。我的做法是每周做一次轻量对账用本地有但接口查不到的差集把超过7天没更新且接口返回404/已下架的记录标记为失效而不是物理删除。物理删除会破坏历史订单关联标记失效更安全。4.5 校验与对账每批同步完成后跑五个校验SKU所属SPU是否存在防孤儿SKU同一SPU内spec_key是否唯一防重复规格销售属性个数是否等于该类目销售属性定义个数防漏属性价格是否在合理区间比如大于0且小于1000万防脏值SKU组合数是否小于等于笛卡尔积大于必然有错第5条值得展开。假设某SPU有3种颜色、4种容量笛卡尔积是12。实际返回SKU可能是10个这是正常的——有些配色不出某个容量。但如果返回13个一定有问题要么属性解析错位要么平台数据本身脏。我在线上就遇到过返回14个的查下去发现有两个SKU的saleAttrs里颜色的valueId是同一个但valueName不同一个深空灰一个深空灰色属于平台侧值字典不一致只能打标隔离不能直接丢因为丢掉会导致商品缺规格。5. 常见问题与排查实录5.1 规格组合缺失或错位症状是前端规格选择器上选深空灰 1TB提示无货但后台明明有这个SKU。八成是组合错位。排查顺序先看rel_sku_sale_attr里该SKU的属性行数对不对再看attr_id有没有串最后看前端传过来的属性顺序和展示顺序是否一致。我遇到过一次前端把属性顺序写死成颜色、容量而某些类目下销售属性是容量、颜色导致前端拼出来的组合串跟后端索引对不上。修法是后端返回saleAttrDefs时带上明确顺序前端严格按顺序拼接别自己做排序。5.2 单SKU商品与套装商品不是所有商品都有多规格。单规格商品在接口里可能表现为saleAttrs为空数组也可能表现为只有一个SKU且saleAttrDefs为空。这两种情况要统一处理成单SKU商品spec_key置为空串不要置NULL——NULL在唯一索引里不参与去重会允许重复插入。套装商品是另一个坑。一个套装SKU可能包含多个实物价格是套装价库存是所有组件里最少的那个。这类数据在接口里通常有独立的bundleList或subItems字段如果不解析你的库存就会虚高。我的处理是建一张rel_sku_bundle记录套装SKU到组件SKU的映射和数量计算库存时取min(组件库存 / 组件数量)。5.3 价格区间与起字显示列表页显示5299起的逻辑要谨慎。如果SPU下有已下架SKU价格更低直接取min会显示出一个买不到的价格。正确做法是只在status1的SKU上取min和max。如果两者相等显示单一价不等才显示起。还有一种情况是促销价。SKU的原价、促销价、会员价可能是三套字段展示用促销价计算毛利用原价结算用会员价。这些字段一定要分开存别混在一个price里否则后面做价格分析根本还原不出来。5.4 上下架与库存不同步最常见的线上事故SPU是上架状态但所有SKU都下架了用户点进去发现没得买。根因是SPU状态和SKU状态是两套字段平台侧可能不同步。兜底逻辑必须自己加SPU可售 SPU状态上架 且 至少有一个SKU状态上架 且 该SKU库存0或库存未知。反过来SPU下架了但某个SKU还标着上架这种数据在同步后要主动把SKU状态刷成下架避免被下游误用。5.5 常见问题速查表现象最可能原因排查动作处理方式规格选不到属性顺序不一致比对 attrId 顺序按接口顺序统一下发同SPU出现重复规格valueName 不一致但 valueId 相同查spec_key重复以 valueId 为准打标库存异常大-1 或默认占位值看原始返回值置0并打标未知条码对不上数字转整数丢前导零检查长度强制字符串左补零增量漏数据水位无重叠窗口比对总数起点前推15分钟详情页空图SKU图为空统计空图率借图兜底逻辑价格显示为0单位是分或字段为null看样例值统一换算空值剔除套装库存虚高未解析组件清单查bundleList建映射表取最小6. 一些性能与扩展上的经验6.1 存储与索引设计千万级SKU的单表在MySQL里还是能扛的前提是索引别乱建。dim_sku上我只留了三个索引主键、spu_id、(spu_id, spec_key)。有人喜欢给price、stock也建索引结果写入性能掉一半而实际查询里这两个字段极少单独作为条件。真要做范围筛选走ES或者预聚合表更合适。分区我按source或者按updated_at月份做看数据分布。如果多平台接入按source哈希分区能有效隔离如果是单平台海量数据按月分区更利于归档和历史数据清理。rel_sku_sale_attr这张表行数会是SKU数的2到4倍涨得最快。我一般把它做成冷热分离近90天的热数据留在MySQL更早的归档到列式存储查询走联邦。归档脚本按sku_id范围分批删一次删5000行加LIMIT防止长事务锁表。6.2 缓存与前端渲染商品详情页的规格数据是典型读多写少适合放缓存。我用 Redis 存spu:{spu_id}:spec这个 key值是序列化后的完整规格树TTL 30分钟写入时主动删缓存而不是更新缓存避免并发写导致的旧值覆盖。前端渲染规格选择器时有个细节不可售组合要做灰化而不是隐藏。直接隐藏会让用户觉得规格选项在跳变体验很差。灰化的判定逻辑是该组合对应的SKU不存在或库存为0或状态下架判定结果在接口里以available: false标记前端只管渲染。还有个大坑是属性值排序。平台返回的valueList顺序可能和运营后台设置的不一样如果前端直接按返回顺序渲染会出现1TB排在512GB前面这种反直觉的情况。我的做法是在dim_attr_value里存一个sort_no容量类按数值大小排颜色类按预设色系顺序排其他按拼音排。这套排序规则统一在服务端算好下发。6.3 多平台适配的抽象层如果只接一家怎么写都行。接三家以上就必须做抽象层了。我的做法是定义一个内部的标准模型StandardProduct包含spu、sku_list、attr_defs、desc_attrs四个部分每个数据源写一个适配器负责把平台原始结构翻译成标准模型。上层入库、校验、派生计算全部只认标准模型。适配器里最花时间的是属性映射。每个平台的属性ID体系都不一样需要维护一张map_attr表(source, src_attr_id) - std_attr_id。这张表初期靠人工映射跑一段时间后用属性名相似度 类目相同做半自动推荐人工确认。别指望全自动属性名里的同义词太多了存储容量机身内存运行内存三者经常被混为一谈机器分不清得靠人。标准模型的好处是后来要加一个新平台只需要写一个适配器 补一批映射两天能上线而不用动主流程一行代码。我在实际项目里最深的一条体会是SPU和SKU的解析难点从来不在结构本身而在于数据的不规整。结构看文档半小时就能懂真正吃掉时间的是那些文档没写、样例没有、线上才冒出来的脏数据。所以我现在的习惯是拿到任何一家商品接口先不用写正式代码先拿200条真实数据跑一遍把每个字段的实际类型、空值率、异常值分布统计出来做成一张字段画像表。这张表出来之后后面的建模和清洗规则基本就是照单填写返工率能降一大截。另一个小技巧是给每个SPU算一个数据健康分由属性完整度、SKU覆盖率、空图率、价格异常率加权得出。分数低于阈值的商品自动进人工复核队列。这套机制上线后我们下游的比价报表准确率从82%提到了97%剩下的3%基本都是平台侧自身数据问题标注清楚就行不用死磕。