资讯动态

非标商品平台全链路实践:搜索筛选、推荐与交易闭环的架构取舍

发布时间:2026/10/19 5:07:58 来源:尧图企业网站定制
接手 artcraft 这个项目的那段时间可以说是我近几年做得最过瘾也最累的一个迭代。名字听起来像某个设计工作室实际上它是我负责的一个艺术品与手工艺品展示交易平台核心是让独立创作者的作品被更多人用更自然的方式找到、看懂、买走。artcraft 面对的不是标准工业品而是陶瓷、木工、编织、首饰、数字插画这类高度非标的物件颜色、风格、工艺、尺寸都是筛选维度传统电商那套“类目关键词价格排序”根本撑不住。这篇文章主要是把我在 artcraft 里做技术选型、搜索筛选、推荐、交易链路的全过程以及踩过的坑记录下来里面不少内容是我在文档里翻不到的现场经验适合正在做非标商品类平台、想了解全链路落地经验的后端或全栈开发者参考。1. 项目背景与需求拆解artcraft 到底在解决什么问题1.1 服务对象与核心场景artcraft 主要服务两类人。一类是独立创作者比如做陶艺、木工、编织、首饰、数字艺术的人他们需要一个地方把作品照片、创作过程、背后故事展示出来还要能管理库存、价格、发货另一类是买家他们往往不是带着精确关键词来的而是“想找一件暖色调的陶瓷花器”“想看风格偏极简的首饰”。这种需求在传统电商里很难表达因为关键词搜索解决不了“感觉型”需求。从业务场景上分最核心的有四个作品发布与管理、组合条件筛选浏览、下单交易闭环、个性化推荐。这四个场景互相依赖但技术上可以拆开处理。我在项目初始就把它们当成四个独立模块来设计这样任何一个模块出问题都不会影响其他链路。比如搜索服务挂了买家仍然能通过首页精选位和创作者主页去浏览作品不会出现全站瘫痪。另外还有一个容易被忽略的点审核与合规。艺术品平台涉及版权、图像内容审核创作者上传的作品必须过一道内容审核流程这会直接影响上架状态的数据设计。不能只用一个上/下架布尔值否则草稿状态、待审核状态、违规下架状态根本分不开后续运营想追溯审核历史也没有依据。所以状态字段从一开始就设计成可扩展的枚举而不是一个布尔开关。1.2 把非标需求收敛成可开发的功能非标商品最大的问题是“元数据不稳定”。一个木工作品可以有“木材种类”这个属性而数字插画作品完全没有这个字段但买家搜索时又希望所有作品都能被“风格”“颜色”“价格区间”这类通用维度过滤。我最终把数据拆成三层结构基础字段包含标题、描述、价格、主图通用维度包含风格、用途、主色调、尺寸范围扩展属性由创作者类目决定用 JSON 存储比如木工类目下的木材类型、陶瓷类目下的烧制工艺。这样既能保证核心筛选的稳定性又能兼顾长尾属性。同时业务上必须约定“什么字段能筛、什么字段只能展示”。我要求产品把所有筛选维度收敛成不超过 8 个类目、风格、颜色、价格、尺寸、工艺、适用场景、上架时间。所有维度统一维护成标签或枚举值而不是开放文本输入。这里非常关键因为开放文本会让搜索结果不可控后续内容审核的成本也会直线上升。打个比方标签体系像固定的抽屉创作者把作品放进合适的抽屉里买家按抽屉去找开放文本则是把所有东西堆在仓库里买家只能靠运气翻。1.3 优先级排序与落地节奏整个项目按两个阶段推进。第一阶段先把“作品上架-浏览-组合筛选-加购-下单-支付-发货”这条交易主链路跑通不碰智能推荐只做“精选新品热门”三个固定策略。第二阶段再上线个性化推荐、图像相似度、颜色搜索这类增量体验功能。这样说起来简单但实际操作里我有个很深的体会交易链路比想象中复杂得多库存扣减、订单状态、支付回调一致性每一项都可能拖很久。我个人的习惯是排期按“核心流程 70%、体验功能 30%”划分。不要一上来就追求推荐算法多炫交易闭环不稳推荐做得再好也留不住用户。项目初期最容易犯的错就是把特征提取、模型训练这类看起来很酷的事排得太靠前结果核心购物流程漏洞百出用户第一次下单失败就再也不回来了。这也是 artcraft 能顺利上线的一个重要原因先做稳再做聪明。2. 技术选型与架构设计为什么是这套组合拳2.1 技术栈选型先定主库再补搜索artcraft 后端的核心选型是这样的Java 17 Spring Boot 3.x 负责业务接口MySQL 8.0 作为主库存储创作者、作品、订单这类核心数据Redis 承担缓存和分布式锁Elasticsearch 负责组合筛选与关键词检索图片文件走对象存储加 CDN再用一套异步任务把 MySQL 里的作品数据同步到 ES。为什么引入 Elasticsearch因为艺术品筛选条件多且任意组合。用 MySQL 动态 SQL 在数据量达到几十万作品后非常吃力一个带聚合统计的复杂查询可能把数据库 CPU 打满。ES 的 filter 上下文会缓存之前的过滤结果多个过滤条件任意组合时查询性能依然稳定。但要强调一点ES 不是替代 MySQL而是镜像订单、支付、库存这类强一致数据必须留在 MySQL。两边的数据通过 binlog 监听或定时任务同步我选的是监听 binlog 加定时对账兜底。对于推荐服务我单独开了一个 Python 服务来做离线的用户行为分析和特征计算在线推荐接口仍然由 Java 去聚合。这个异构架构听着复杂其实边界很清楚在线高并发链路用 Java离线计算和算法迭代用 Python两边通过接口和消息队列交互。这样设计的好处是算法团队可以独立迭代模型不会影响交易链路发布节奏。2.2 核心数据模型作品不是普通电商商品这一步我花了最多时间。artcraft 的作品数据模型和传统 SPU/SKU 差异很大很多手工艺品本身就是孤品每一件只有一个库存不存在“红色款 5 件、蓝色款 3 件”这种 SKU 维度。所以我没有建 SKU 表而是直接让作品表本身承载库存。作品表核心字段大概是这样作品ID、创作者ID、标题、描述、类目ID、主图、价格、库存、状态、审核意见、创建时间。状态分为草稿、待审核、已上架、已下架、违规下架。扩展属性放 JSON 列通用标签建了多对多关联表标签本身有类型比如风格、用途、工艺也有层级比如一级类目、二级类目。价格这里有个细节艺术家可能卖原作也卖授权打印版两种价格体系差别很大。我设计了价格类型字段包含一口价、竞价、按尺寸计价初期只实现了一口价其他类型靠扩展字段预留不阻塞上线。这算是个典型的“领域模型贴业务走”的案例不能照抄标准电商模型。订单模型我反而做得更“电商化”订单主表、订单明细表、支付流水表、退款单表、物流信息表。原因很简单交易环节必须稳定通用越标准越好业务的特殊性体现在商品侧而不是交易侧。商品侧可以各种花活交易侧一旦搞特殊化后续做对账、做财务、做供应链都会非常痛苦。2.3 不做微服务的理由还有一个架构决策值得说。很多团队看到搜索、推荐、交易、图片处理第一反应是拆微服务。但我在 artcraft 里刻意没有一开始就拆而是做成单应用的多个模块。理由有三第一初期团队体量小拆了服务意味着要维护多套部署、注册中心、链路追踪第二业务边界虽然清晰但很多模块初期量级很小硬拆只会增加沟通成本和接口联调成本第三单体应用在需要拆分时模块边界已经画好了随时可以按模块拆出去这个代价远小于一开始就拆。这也说明架构没有绝对正确只有“在这个阶段合理”。等创作者、作品、订单量级上来以后再按交易、搜索、推荐三个边界去拆才是顺势而为。项目上线前我还专门检查过一个东西模块之间的调用是否都走领域接口而不是直接互相操作对方的数据表。只要边界守住单体变多服务就是一次物理搬迁而不是一次重构。3. 搜索与筛选让买家按“感觉”找到作品3.1 组合筛选的实现思路用好 ES 的 Filter 上下文核心查询需求是用户选中“类目陶瓷、风格极简、颜色暖色、价格200-800、尺寸中号”然后还要按“综合、最新、价格、热度”排序。这种查询在 ES 里我用 bool query 的 filter 数组去组合filter 不参与打分可以走缓存性能远好于 must。下面是一个简化后的查询体真实场景里还会带上分页和排序参数{ query: { bool: { filter: [ { term: { category_id: 102 } }, { terms: { style_tags: [minimal] } }, { range: { price: { gte: 200, lte: 800 } } } ] } }, sort: [ { heat_score: { order: desc } } ], size: 20 }类目是层级结构我存入文档时会把一级类目ID、二级类目ID、叶子类目ID分别建字段查询时按用户选择的层级去匹配避免递归查询。标签用数组字段存储关系型数据库里的一对多关系到了 ES 就变成数组的 term 匹配查询逻辑反而更简单。排序部分有两个关键原则评分排序只用于关键词搜索组合筛选场景更多按业务规则排序“综合排序”不能用默认相关度要自己定义。我们最初把综合排序定义为热度权重 0.4、最近上架时间权重 0.3、价格适中度 0.3后来又根据点击数据调过两版。这个权重不是拍脑袋定的前两周上线后我发现头部作品长期霸榜新品很难出头才把上架时间的权重提了起来。3.2 主图色彩提取与颜色搜索从像素到色值颜色搜索是 artcraft 的特色功能。买家选“暖橙色”或者“莫兰迪灰绿”系统就要把主图色调接近的作品筛出来。这需要两步离线提取颜色特征在线用特征做匹配。离线提取我用的是“中位切分算法”来获取主色调。这个算法的思路很朴素把图片所有像素点放进 RGB 三维颜色空间看成一个立方体每次选体积最大的盒子沿最长边按像素中位数切成两半反复切直到得到指定数量的颜色盒子最后取占比最高的几个盒子作为主色调。这类计算用 Python 写起来很直观核心思路是递归切分颜色盒子def extract_dominant_colors(image_path, box_count5): img Image.open(image_path).resize((64, 64)) pixels list(img.getdata()) color_box [(0, 0, 0, 255, 255, 255, pixels)] # (r_min, g_min, b_min, r_max, g_max, b_max, pixel_list) while len(color_box) box_count: box max(color_box, keylambda b: (b[3]-b[0])*(b[4]-b[1])*(b[5]-b[2])) color_box.remove(box) channel max(range(3), keylambda i: box[i3] - box[i]) sorted_pixels sorted(box[6], keylambda p: p[channel]) mid len(sorted_pixels) // 2 color_box.append(split_box(box, channel, sorted_pixels[:mid])) color_box.append(split_box(box, channel, sorted_pixels[mid:])) return sorted(color_box, keylambda b: len(b[6]), reverseTrue)[:3]每张作品图我会提取 3 个主色并转化成 HSV 色值存储。为了方便查询我把色值做了量化映射把 HSV 的色相分成 12 个区间饱和度和明度各分成 2 档得到一个 48 维的颜色标识。用户选“暖橙色”时系统直接匹配对应色标集合不需要在 ES 里做昂贵的向量距离计算。我的体会是用户能感知的颜色差异不需要精确到 RGB 小数位粗量化反而让搜索结果更符合直觉。3.3 性能兜底缓存与索引怎么配合虽然 ES filter 性能不错但热门筛选条件比如“全部陶瓷作品”会出现访问热点再加上列表页要聚合出每个筛选维度的可用数量压力更大。我的做法是对这些热点查询结果做 Redis 缓存key 设计成“筛选参数哈希 排序规则 分页参数”value 存作品ID列表然后按 ID 回 MySQL 取详情再组装。这样 ES 只负责筛选和排序不再承担详情字段的大流量加载。这里有个大坑筛选条件组合几乎是无限的缓存很容易被穿透。我的控制办法是只缓存排序规则固定、参数命中前 N 个热门组合的查询冷门组合直接走 ES同时设置 60 秒左右的较短 TTL。另一个重要优化是列表页不直接返回作品详情的全量字段ES 里只存核心字段详情统一走一个“根据ID批量查作品”的接口。这样把 ES 的压力转移到 MySQL 主键查询上反而更快更稳因为主键查询是数据库最擅长的操作。4. 个性化推荐与图像相似度从“能找到”到“逛得久”4.1 冷启动阶段推荐策略个性化推荐上线之前artcraft 只有固定策略新品、热门、精选。这三块本质是运营规则稳定但千人一面。我设计的推荐系统第一步是“用户行为埋点”记录浏览、点击、收藏、加购、下单事件数据先进消息队列然后异步写入用户行为宽表。这一步一定要在上线推荐功能之前就做否则没有历史数据后面再想补就来不及了。冷启动阶段没有行为数据怎么办我用标签召回根据用户注册时选择的偏好标签和当前浏览的作品标签计算用户向量和作品向量的余弦相似度取 TopN。这个方案实现简单推荐结果也不算差用户能明显感觉到系统“有点懂我”。但要注意召回和排序分开召回阶段多路并行包括标签召回、热门召回、相似作品召回、新品召回排序阶段再统一打分。千万不要只做单路召回否则结果会非常单调用户逛十分钟就腻了。4.2 图像相似度计算的两种实践为了支持“找相似作品”功能我对比了两种方案。一种是感知哈希也就是 pHash把图片缩小到 32x32转成灰度图做离散余弦变换取左上角低频系数计算哈希指纹两张图的海明距离越小越相似。这个方案工程上非常轻部署简单、速度极快但只适合“构图和色调都比较接近”的场景换个角度拍摄可能就被判为不相似。另一种方案是用预训练视觉模型把图片编码成高维向量用余弦相似度计算语义相似度。这个方案对“两张图构图完全不同但是同一个风格”识别更好但模型推理、向量存储和检索都会复杂很多ES 里要用 dense_vector 字段内存开销也不小。我最终采用了折中方案pHash 用来做近重复图片过滤和同源作品识别语义向量用来做“风格相似”召回。两套特征都放进了 ES各取所长既控制了工程复杂度又照顾了推荐质量。4.3 推荐结果质量与业务约束推荐系统不能把算法分数最高的 TopN 直接丢给用户而是要叠加一堆业务规则。在我的实现里至少包括这几条下架和违规作品必须过滤同一创作者的作品在一屏内不能出现太多用户近期已经点过“不喜欢”的创作者或标签要降低权重竞价作品不能出现在普通推荐流中。这些规则看起来琐碎但每一件都在真实影响用户体验。另外还有一个常见问题推荐位点击率不等于成交率。我曾经只看点击指标把一款很吸睛但不实用的装饰品顶到首屏结果点击很高转化很差。后来把“加购率”和“下单率”加入排序权重推荐质量才真正提升。做推荐的人一定要盯业务结果而不是只盯技术指标。这个道理说起来简单但我见过太多团队陷在 CTR 优化里出不来最后推荐列表越调越“标题党”。5. 购物车、订单与支付对账的工程化实现5.1 购物车设计合并、失效与价格快照购物车我用了 MySQL 表存储而不是只放在 Redis。原因很简单买家可能换设备登录购物车需要持久化Redis 只用来做热点商品的库存预扣减和加购频控。购物车表结构是用户ID、作品ID、数量、加入时间、选中状态。因为一件孤品只能被一个买家买走数量字段基本恒为 1但为了未来支持组合购买和预售还是保留了数量字段。两个容易踩坑的点创作者可能在你加购后改价格或下架作品。我的处理是购物车列表返回时实时查询作品当前状态和价格并标记“价格已变动”或“已失效”下单时再重新校验并以后端实时价格为准。绝对不能用加入购物车时的价格直接下单否则会出现买家享受过期低价、创作者不认账的纠纷。这种问题一旦出现客服和运营都要花大量精力去解释还不如一开始就在代码里堵死。5.2 订单状态机与超时关单订单状态我严格控制为几个状态待支付、已支付、已发货、已完成、已取消、退款中、已退款。每个状态之间的转移必须通过专门的方法不允许直接改订单表的 status 字段。我用了状态机模式把状态转移条件、前置校验、后续动作统一放在一个类里管理。比如待支付到已取消必须校验当前状态是待支付、库存要回补、还要发消息通知用户待支付到已支付必须校验支付流水存在且金额一致。待支付订单的自动取消是个经典问题。我选的是“延迟消息 定时任务兜底”的双保险创建订单时发一条延迟 30 分钟的取消消息消费时如果订单仍然是待支付状态就执行取消并释放库存同时每 5 分钟跑一次定时任务扫描超时未支付的订单做兜底取消防止延迟消息丢失。取消前一定要再次检查支付状态我踩过定时任务把刚支付成功但状态尚未更新的订单取消掉的坑这个后面再展开说。5.3 支付回调幂等与对账支付回调是交易系统里最容易出问题的一环。我的核心经验是回调处理必须幂等状态判断必须以最新支付结果为准绝不能只以本地订单状态做唯一依据。我的实现里有一个支付流水表支付回调进来后先拿“支付平台订单号 回调金额 回调状态”去流水表查重如果已经处理过就直接返回成功不做任何业务操作。这样可以防止支付平台因为网络问题重试导致的重复发货。对账方面每天凌晨跑一次定时任务把本地已支付订单和支付平台账单明细做比对。对账脚本要按天拉取账单文件逐行比对订单号、金额、状态差异记录入库并触发告警。这个看起来最无聊的功能其实是交易系统最后的保险。我见过太多因为漏了对账退款异常金额过了一个月才发现的事故。对账不是可选项是强约束。6. 上线前的性能压测与容量评估6.1 压测目标与实测数据上线前我们针对核心接口做了压测。目标是首页接口 QPS 1000、搜索筛选接口 QPS 500、下单接口 QPS 200P95 延迟都在 500ms 以下。压测环境用的是与生产等配置的 4 台 8 核 16G 应用服务器数据库和 ES 各自独立部署。压测结果大致是首页接口因为走了缓存单机 QPS 就超过了目标搜索接口在 ES 侧 CPU 达到 60% 时接近目标下单接口因为涉及事务和多表写入成了瓶颈单机只能到 200 QPS 左右。这个结果提醒了我下单链路不能只看应用服务数据库连接数和行锁冲突更关键。后来我把下单中的“加购频控、库存校验、订单写入、支付单创建”拆成短事务把不必要的操作移出事务QPS 才提上来。比如订单创建成功后发送消息通知创作者这个动作绝对不能放在事务里否则消息中间件抖动会直接拖垮整个下单接口。6.2 容量规划与资源评估容量评估我一般喜欢用最坏情况倒推假设日活用户 5 万平均每人浏览 30 个作品产生约 150 万次内容浏览请求集中在 4 小时黄金时段峰值大概是平均值的 4 倍也就是每秒大概 400 次内容请求。这类读请求靠首页缓存和列表缓存基本能抗住。下单峰值我按日访问量的千分之一估算大约每秒 1 到 2 单远低于压测值所以下单链路的压力其实不大但数据库连接数仍然要按并发高峰预留。Redis 内存估算也有个笨办法每个作品的核心缓存对象按 1KB 算缓存 20 万作品就是 200MB加上用户会话和其他热点数据给 2GB 完全够用。ES 节点我额外留了 30% 余量因为索引段合并和向量字段都比较吃内存。这里想强调一句容量规划不要按平均值做要按“峰值 × 安全系数”做否则大促和活动一上线第一个挂掉的就是缓存。6.3 限流、降级与兜底策略所有写接口我都加了令牌桶限流下单接口限制每个用户每分钟最多 5 次这个阈值是综合考虑了正常购买频率和刷单风险定的搜索接口则限制每 IP 每分钟 60 次。限流策略分开做严格保护写接口读接口相对宽松避免把正常分享链接的流量误伤。降级策略我梳理过一轮推荐服务挂了直接降级成热门作品接口内容审核回调阻塞创作者上传先进入待审核而不是报错搜索服务不可用时回退到 MySQL 按类目和价格做基础查询。降级方案一定要在平时演练而不是故障发生时在线想。我每次上线前都会拉一个“降级开关清单”逐个开关验证。这个清单看着不起眼但真到故障时能救命因为人一紧张脑子是空的。7. 踩坑实录与排查思路这些问题值得记下来7.1 索引失效一个字段类型引发的搜索事故上线初期搜索筛选偶尔会很慢。排查时发现 ES 里“价格区间筛选”走了脚本排序而不是 range 过滤。原因是索引映射里价格字段被动态映射成了 text 类型还带了一个 keyword 子字段。range 查询遇到 text 类型走的不是倒排索引性能惨不忍睹。修复方式是显式定义 mapping把价格设成 double 类型同时把所有数值字段都检查了一遍。这件事给我的教训很简单ES 一定要管好 mapping不要依赖自动映射尤其涉及范围查询的字段。7.2 热点空数据导致的缓存穿透有一段时间首页“精选”位偶尔出现大量慢查询。查日志发现是一个运营配置错误让精选位返回空列表但缓存代码只缓存了非空结果导致每次请求都穿透到数据库。修复方式是对空结果也做缓存TTL 设置短一些同时加布隆过滤器做第一层拦截。这个案例很典型缓存穿透很多不是因为高并发而是业务数据异常导致的热点空查询。所以排查性能问题不能只看流量还要看数据本身。7.3 重复消息让创作者收到了两条订单通知订单状态变更会发异步消息给创作者端有段时间部分创作者重复收到通知。查下来是消息消费逻辑在“发送成功但返回超时”的情况下重试而消费端没有做幂等。修复方式是给每条业务消息生成全局唯一消息 ID消费端建去重表处理过就直接丢弃。消费幂等不是可选项是必须项。尤其是交易域的消息重复通知只是烦人重复退款或者重复发货就是事故了。7.4 图片特征任务把服务器资源打满颜色提取和向量编码这类任务都是计算密集型的我一开始直接在线程池里跑结果大促前批量处理图片时线程池把应用服务器 CPU 打到了 95%正常接口也跟着变慢。修复方式是所有离线特征计算全部迁移到独立的计算任务集群队列里限并发同时给正常业务接口单独留线程池并做隔离。这件事给我的启发是不要让后台任务和在线流量抢同一份资源。看着是同集群省成本实际上是把稳定性当成了赌注。7.5 定时任务误取消已支付订单这是我印象最深的一次故障。某日凌晨定时任务扫描待支付订单发现一批订单超时直接把它们改成已取消并释放了库存。不巧的是部分订单其实已经支付成功只是支付回调因为网络延迟还没有到达本地。等回调到来时订单状态已经是已取消流程直接错乱。修复方案有两层定时任务取消前必须用分布式锁锁定订单并二次查询支付平台的实时状态支付回调到达时如果发现订单已取消但支付成功了要自动发起原路退款而不是忽略。这个教训让我把“状态更新前先核对外部状态”写进了代码规范。为了更直观我把这次项目里最典型的几个问题和对应解法整理成了一张速查表排查故障时可以快速对照问题根因解决思路价格筛选查询慢ES 数值字段被映射成 text显式定义 mapping数值字段用 double 或 integer空列表造成缓存穿透只缓存非空结果空结果也做短 TTL 缓存创作者重复收通知消费端缺少幂等全局消息 ID 去重表特征计算拖垮在线服务后台任务与在线流量共用线程池独立任务集群线程池隔离定时任务误取消已支付订单状态竞态未处理分布式锁 二次查询支付状态 支付成功后自动退款artcraft 前后做了大半年我现在回头看最值钱的不是选了什么框架、用了什么搜索引擎而是那些以为很稳却差点翻车的瞬间。技术方案没有绝对最优每个取舍背后都是业务阶段、团队体量、故障容忍度这些东西在拉扯。如果你也在做艺术品电商或者任何非标商品平台希望这些决策过程和踩坑记录能帮你少走几步弯路。如果你刚接手类似项目记住一句话先保证交易闭环滴水不漏再让算法和体验去发光。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑