资讯动态

基于Node.js的协同过滤:二手交易网站推荐系统实战

发布时间:2026/10/15 4:38:54 来源:尧图企业网站定制
聊个有意思的事二手交易平台的推荐系统和淘宝、京东那套“猜你喜欢”的逻辑骨子里是两码事。新品电商的推荐可以依赖商品属性、类目层级、库存深度但二手平台的商品是C2C的今天上架明天成交下架没有SKU稳定性也没有标准化的规格参数甚至同一个人同一部手机能反复上架三次。而“Node.js协同过滤算法的二手物品交易网站系统”这类项目核心难点不在CRUD不在登录注册恰恰就在这个“推荐”上——怎么让刚打开网站的用户从几万条又杂又乱又有时效性的商品里快速看到对他真正有用的东西。我去年完整做过一个类似的系统技术栈就是Node.js推荐模块用的协同过滤。这个过程中踩了不少坑也总结出一套在二手场景下还算管用的工程化方案。这篇就从头到尾复盘一遍为什么协同过滤在二手场景里要这么改、数据模型怎么设计、核心算法怎么落地、冷启动怎么破、工程上怎么降级。适合正在做电商推荐系统、毕业设计或者想入行推荐方向的朋友参考尤其是用的Node.js技术栈的可以直接抄作业。1. 先想清楚业务再选算法二手市场为什么更适合“基于物品的协同过滤”很多人的第一反应是协同过滤嘛不就是找相似用户或者相似商品吗对但二手场景直接套经典方案效果会很差。先看业务特征再定算法这一步想清楚了后面的代码才有意义。1.1 二手交易和标准电商的几个根本差异第一商品生命周期极短。新品电商一个SKU可以挂一年二手商品平均上架周期按周算手机数码可能几天就出掉了。这意味着推荐系统拿到的行为数据是高度“时效敏感”的去年今天用户收藏了一台手机对现在的推荐几乎没有参考价值。第二行为数据极其稀疏。新品电商有大量流量灌进有限的SKU二手平台则是海量商品、少量访客每个商品平均获得的行为寥寥无几。用户矩阵的稠密度可能连1%都不到经典协同过滤在这种矩阵上非常容易退化。第三商品属性不完整、不标准。二手商品没有规范的参数表卖家用自然语言写标题和描述图片质量也参差不齐。基于内容的推荐打标签、算内容相似度在二手场景里孱弱、不可靠冷启动只能作为辅助手段。第四地理位置的权重极高。买二手冰箱的用户基本不会看一眼跨省的商品——运费比货值还高。这决定了推荐结果的排序里距离必须作为一个强加权因子。1.2 为什么选ItemCF基于物品的协同过滤而不是UserCF基于用户的协同过滤理论上的经典答案是UserCF适合用户数少、兴趣稳定的场景ItemCF适合物品数少、用户个性化强的场景。二手平台两类算法都能跑但实际体验下来ItemCF有两个决定性优势。第一物品的“共现关系”比用户的“相似关系”稳定得多。用户的行为是稀疏的找相似用户经常找到一对乱七八糟的人但物品共现矩阵是基于“同一用户对两件商品产生过行为”这种客观事实只要有数据就不太容易失真。第二推荐结果可解释性强售后麻烦少。ItemCF向用户推荐的理由是“你收藏的商品A和B有相似度”用户能接受。UserCF的推荐理由是“有个和你相似的人买了B”在二手C2C场景里用户会很困惑“你怎么知道我的谁和我相似”所以在系统里我最终采用的是ItemCF下面所有实现也都围绕它展开。如果你非要用UserCF也不是不能跑但对数据稀疏度的容忍度更低二手上线几乎必崩。对比维度ItemCF基于物品UserCF基于用户行为稀疏容忍度较高重共现轻画像低用户行为太少时相似度失真推荐可解释性好“因为你关注了A”差“有个相似的人……”计算稳定性物品共现相对稳定用户兴趣漂移大冷启动难度新商品难处理需内容辅助新用户难处理需热门兜底二手场景适配度高低2. 表结构和技术选型Node.js生态里这套推荐系统怎么搭底子推荐算法再花哨数据模型铺不好全白搭。我用的技术栈是Node.jsExpress MongoDB Redis行为日志异步写入不阻塞主流程。MySQL也能做但二手商品字段高度不固定MongoDB的文档模型更香。2.1 Node.js技术栈选型时我在想什么Express框架不用多说生态成熟、上手快。关键的选型决策在数据库二手商品没有统一属性模板。手机有“内存、成色、是否有拆修”书籍有“版本、品相”家具家电有“尺寸、运输方式”。用MySQL硬建模要么搞一堆可空字段要么建EAV表查询和推荐特征提取都难受。MongoDB的文档模型可以自由存各种自定义属性丢进一个attributes嵌套对象里读取和检索都对推荐模块友好。Redis就更重要了。协同过滤的相似度矩阵如果每次请求实时去算QPS稍微上来一点就扛不住。我的方案是离线任务把相似度矩阵算好落Redis在线接口只做“读缓存 过滤 排序”。这一套架构下面第五节详细展开。2.2 核心集合设计围绕推荐需求铺表用户集合users不复杂和普通系统差不多{ _id: ObjectId, nickname: String, creditScore: Number, location: { city: String, lng: Number, lat: Number }, interestedCategories: [手机数码, 图书乐器, ...], createdAt: Date }商品集合products是重头。注意一个细节二手平台上商品状态必须有“上架中、已售出、下架、已删除”的完整流转推荐逻辑里过滤已售和已下架是硬性要求。很多项目只存“上架”和“下架”两个状态结果用户收藏的商品第二天就下架了推荐模块反复选出无效商品体验很差。{ _id: ObjectId, sellerId: ObjectId, title: String, description: String, images: [String], category: String, price: Number, condition: Number, // 成色1-10 attributes: { brand: String, model: String, ... }, location: { city: String, lng: Number, lat: Number }, status: on_sale, // on_sale / sold / off_shelf / deleted tags: [iPhone, 国行, 95新], createdAt: Date, soldAt: Date }行为集合behaviors是推荐系统的“原油”。这里容易犯的错是只统计浏览次数把行为聚合进商品表里比如给products加一个views字段。这是绝对不行的——协同过滤的输入是行为序列不是行为计数。没有序列就做不了时间衰减也做不了“同一用户先后关注了哪些商品”的共现推导。{ _id: ObjectId, userId: ObjectId, productId: ObjectId, behaviorType: browse, // browse / favorite / contact / bid / purchase weight: 1, location: { city: String, lat: Number, lng: Number }, createdAt: Date }行为权重的设定我是这样调的浏览1收藏3联系卖家点击查看联系方式5出价8成交10。为什么聊胜于无的“联系卖家”比收藏还重因为在二手场景里“联系”是强烈的购买意向信号很多用户想买但不需要收藏直接点联系相反收藏可能只是“先存着看看”。这个权重直接影响后续相似度计算的质量值得反复调。3. ItemCF核心代码拆解从共现矩阵到相似商品TopN这段是整个系统的重头戏。我直接按我生产环境里的实现逻辑分步讲每一步都说明为什么这么写。3.1 构建用户行为序列并生成商品共现矩阵协同过滤的起点是行为日志。离线任务先拉取近30天的有效行为按用户分组得到每个用户的商品序列。注意这里要做已售商品过滤和重复行为去重——同一个用户一天点同一商品十次只算一次不然矩阵会被人为刷爆。商品共现矩阵的含义是如果用户A对商品P和Q都产生过行为那(P,Q)的共现次数加1。核心实现不需要专门的矩阵库普通Map就够了// 构建商品共现矩阵 function buildCooccurrenceMatrix(behaviorGroups) { const coMatrix new Map(); // key: productIdA_productIdB, value: 共现次数 for (const [userId, items] of Object.entries(behaviorGroups)) { if (items.length 2) continue; // 关键优化同一用户的商品序列内部做两两配对 for (let i 0; i items.length; i) { for (let j i 1; j items.length; j) { const a items[i].productId; const b items[j].productId; const key a b ? ${a}_${b} : ${b}_${a}; // 规范键避免重复 coMatrix.set(key, (coMatrix.get(key) || 0) 1); } } } return coMatrix; }这段代码有个容易忽略的细节key的拼接必须按大小顺序统一不然A_B和B_A会被当成两对矩阵就乱了。我早期踩过这个坑排查半天发现相似度对称性被破坏了。3.2 相似度计算余弦相似度与热门商品惩罚共现矩阵算完后每个商品对都有了个裸共现次数。但直接拿这个数当相似度是有问题的一个爆款商品每天都和大量商品共现它跟谁“相似”都会被严重高估。和搜索引擎里IDF的思想一样要给热门物品做惩罚。实际用的公式是sim(P, Q) coCount(P, Q) / sqrt(behaviorCount(P) * behaviorCount(Q))行为数少的商品共现一次就能获得较高的相似分行为数多的爆款共现次数被归一化拉低。这个公式本质上就是余弦相似度在二元行为矩阵上的简化形式不需要把每个用户的行为向量完整铺开计算上省很多事。代码实现function computeSimilarity(coMatrix, itemBehaviorCount) { const simMap new Map(); // key: A_B, value: 相似度 for (const [key, coCount] of coMatrix.entries()) { const [a, b] key.split(_); const norm Math.sqrt(itemBehaviorCount[a]) * Math.sqrt(itemBehaviorCount[b]); if (norm 0) continue; // 热门惩罚行为数越多相似度越被稀释 let sim coCount / norm; // 附加惩罚对极端热门物品额外降权 const hotThreshold 200; if (itemBehaviorCount[a] hotThreshold) sim * 0.6; if (itemBehaviorCount[b] hotThreshold) sim * 0.6; simMap.set(key, sim); } return simMap; }那个额外0.6的惩罚因子是经验值可以按实际数据规模调。它的作用是防止“全网都在看iPhone12所以iPhone12和所有东西都相似”这种荒谬结果。加了之后推荐结果的多样性肉眼可见地变好了。3.3 生成每个物品的TopN相似列表离线计算的最终产物不是完整矩阵太大了N个商品就是N^2存储而是一张“每个商品只保留TopN相似商品”的表落Redis。对每个商品A遍历相似度Map中找到所有含A的条目按相似度降序截取前N个。N我设为20二手场景下够用再多会引入低质量的远邻噪声。生成逻辑简单但存储结构要想清楚。我用的Redis结构是sim:product:{productId} - Hash field: 相似商品ID value: 相似度取数时一条HGETALL就搞定方便在线接口快速读取。这样设计读取复杂度是O(1)推荐接口本身根本不做计算。3.4 在线推荐候选集合并、过滤与排序离线把相似表备好之后在线推荐接口的逻辑反而简单了。用户进入推荐页时流程是取用户近期行为商品浏览、收藏、联系过的按时间降序取最近10个 → 逐个取它们的相似Top20 → 合并候选集累加相似度作为推荐分数 → 硬性过滤 → 时间衰减和位置加权 → 输出TopN核心代码async function getRecommendations(userId, limit 20) { // 1. 获取用户近期行为商品 const recentItems await db.behaviors .find({ userId, createdAt: { $gte: new Date(Date.now() - 30*24*3600*1000) } }) .sort({ createdAt: -1 }) .limit(10) .toArray(); // 2. 取相似商品合并分数 const scoreMap new Map(); for (const item of recentItems) { const simHash await redis.hgetall(sim:product:${item.productId}); const timeDecay Math.exp(-0.02 * daysSince(item.createdAt)); for (const [candidateId, simScore] of Object.entries(simHash)) { scoreMap.set(candidateId, (scoreMap.get(candidateId) || 0) simScore * timeDecay); } } // 3. 硬性过滤 排序过滤逻辑见下节 // ... // 4. 按分数降序返回 return topN; }时间衰减用了指数函数参数0.02是调出来的30天前的行为衰减到e^(-0.6)≈0.55影响减半7天内的行为衰减不大。二手商品时效性强这个衰减幅度比较合适。如果做新品电商参数要调小很多因为购买行为的影响周期更久。4. 冷启动与数据稀疏二手平台推荐系统最头疼的两个问题为什么把冷启动单独一章因为在二手平台冷启动不是“偶尔出现”的边界情况而是日常每天都有大量新商品上架每周都有新注册用户。处理不好推荐系统只对“老用户老商品”有效整体转化率上不去。4.1 新用户冷启动兴趣引导 热门兜底 位置优先新用户没有任何行为数据itemcf完全失效。我的做法分为三步第一步注册时设置兴趣类目选择页。让用户选3-5个感兴趣的类目手机数码、家具家居、运动户外、图书乐器……。这个数据不复杂但效果立竿见影。用户选择后推荐接口直接走“该类目下最新上架 高信用卖家 距离近”的排序逻辑。第二步如果用户跳过了兴趣选择直接用热门商品兜底。热门商品的计算不能只按浏览量——要按“有效浏览 收藏 联系”的加权热度算而且必须加时间窗口只看最近7天的热度。否则容易把一个月前爆火但已经下架的商品推上来。第三步所有冷启动结果里地理位置过滤是硬性的。我在商品和用户都用经纬度存储推荐时先按城市过滤再按距离排序实际效果比单纯用热度好得多。二手交易物流成本高这个约束永远不能放松。4.2 新商品冷启动内容画像映射 用相似老商品借热度新商品没有行为数据ItemCF天然拿不到它的相似表。这里必须引入基于内容的思路做桥梁。思路是把新商品的关键特征类目、品牌、价格区间、成色、标签映射成一组特征向量在特征空间里找最接近的N个“老商品”作为它的伪邻居。然后给新商品一个临时的推荐入口当用户行为序列里包含某个老商品时尝试把新商品作为该老商品的扩展候选。具体实现用了个比较取巧的办法标签匹配 类目 价格带。给每个商品提取一组标签title分词 卖家填写 类目推导算标签重合度function contentSimilarity(newItem, oldItem) { const tagOverlap newItem.tags.filter(t oldItem.tags.includes(t)).length; const categoryScore newItem.category oldItem.category ? 0.4 : 0; const priceDistance Math.abs(newItem.price - oldItem.price) / Math.max(newItem.price, oldItem.price); const priceScore priceDistance 0.3 ? (0.3 - priceDistance) * 0.5 : 0; return tagOverlap * 0.5 categoryScore priceScore; }新商品临时推荐时不追求精准只求“别推得太离谱”。等到新商品积累了至少3条有效行为浏览以上级别就自动纳入ItemCF体系走正常流程。这个阈值我实测下来很关键设太低行为样本不足相似度噪声大设太高新商品长期没有推荐入口。3条是比较平衡的值。4.3 二手场景特有的硬性过滤规则这里提几个“非典型”过滤规则都是二手业务的特殊性逼出来的不做推荐系统一定会翻车过滤自己发布的商品。用户看到自己挂在货架上的东西出现在“推荐给TA”的列表里体验极差拉黑功能警告。过滤自己已售、已下架的商品。这个上面提过但强烈建议单独写一个定时任务定期清理推荐结果缓存里的失效商品比每次在线过滤高效得多。过滤卖家信誉过低的商品。信用分低于某个阈值的卖家的商品推荐时要降权甚至剔除不然推荐系统会把垃圾品推到首页平台整体信任被透支。过滤重复上架的商品。同一卖家多次发布同一件物品内容特征几乎一致如果不做去重推荐列表会出现两三条一模一样的商品。调理方式下面说。5. 工程落地离线定时任务、Redis缓存与推荐降级方案算法是核心但工程化是让推荐系统真正上线跑稳的关键。实际部署中我采用的架构是“离线算 在线读 兜底降级”按这个思路走性能和稳定性能兼顾。5.1 整体架构离线算矩阵在线读结果收银台一样的逻辑不可能每个客人来了现场算账单而是后厨提前备好菜高峰来了直接出菜。离线任务用node-cron跑每30分钟执行一次。为什么用30分钟而不是每天一次因为二手商品上下架太频繁相似度矩阵如果一天一更新新上架的热门商品有最长半天拿不到推荐对转化率影响明显。实测30分钟一次在当前数据量下计算开销可以接受如果商品量到百万级再拉长间隔。任务流程读近30天行为日志 → 清洗去重、剔除已售、剔除异常行为 → 构建共现矩阵 → 计算相似度 → 生成TopN相似表 → 写入Redis5.2 Redis缓存的设计要点缓存键体系设计成三类# 在线推荐结果缓存带失效时间 rec:user:{userId} # 商品相似表长期有效每次离线任务整体覆盖 sim:product:{productId} # 行为序列缓存用于在线接口快速获取用户近期行为 recent:user:{userId}写这一节时我想强调一个容易被忽视的问题推荐结果缓存不能设置太长过期时间。二手商品动销快缓存1小时前的推荐结果可能已经包含失效商品。但太短又失效了缓存意义。我的做法是TTL设为15分钟配合结果里主动剔除失效商品。而且用户每次刷新推荐页都会触发后台异步更新保证用户看到的尽量新鲜。5.3 降级方案推荐系统挂了不能拖垮主站在线推荐接口必须做降级这是上线前就要铺好的保险丝。我的降级链路分三级第一级Redis挂了或者相似表为空。此时不走推荐算法直接按“用户所在城市 用户兴趣类目 最新上架”排序出商品列表。虽然推荐个性化了但至少有东西可看且这些内容对用户不是完全无关的城市和类目是有意义约束。第二级行为序列不足。用户行为数少于阈值时推荐接口不再等待完整的ItemCF流程直接走热门商品列表加城市过滤。这本质上是主动选择冷启动策略。第三级接口本身异常。用try/catch包住整个推荐逻辑任何异常都返回空数组由前端fallback到普通商品列表。绝不能让推荐接口的报错拖垮整个商品浏览页。app.get(/api/recommendations, async (req, res) { try { const list await getRecommendations(req.user.id); res.json({ code: 0, data: list }); } catch (err) { // 降级返回热门商品 const fallback await getHotItems(req.user.location.city); res.json({ code: 1, data: fallback, degraded: true }); } });6. 上线前必须测的边界情况与我在实践中踩过的坑如果只看前面几节系统看起来已经完整了。但真正上线后还有很多“数据层面”的坑这些坑光看代码是看不出来的必须跑真实数据才能暴露。这一节就当是替你们提前踩雷了。6.1 重复上架推荐系统被“同商品多链接”污染二手平台特别容易出现重复上架——手机上周没卖出去卖家下周删了重发标题改了几个字价格降了一百。从用户行为看这是完全不同的两个商品ID但本质是同一个物品。问题在于用户A对商品P产生行为用户B对商品Q其实是同一部手机产生行为P和Q在共现矩阵里却毫无关系。更麻烦的是卖家反复重发行为永远分散在新ID上商品永远积累不起有效的共现数据推荐系统死活不给它推荐机会。我们的解法商品入库时生成一个“内容指纹”——把标题清洗后取核心词 图片哈希 价格区间拼成一个指纹字段。离线任务计算前先按指纹合并相近商品的行为共现矩阵用合并后的数据算。这样重复上架的商品终于能积累行为推荐结果也稳定很多。6.2 行为日志偏差误点、爬虫与无效曝光这个坑更隐蔽。上线初期推荐结果总觉得很怪后来分析日志发现大量“浏览”行为其实来自两类污染源。第一类是爬虫搜索引擎的爬虫或数据采集脚本跑到行为日志里第二类是误点用户滑手机时手指不小心点到商品图就退出了这种浏览根本不是兴趣信号。处理方式分两层。数据层行为埋点增加停留时长判断仅当用户在商品详情页停留超过3秒才算有效浏览低于3秒直接丢弃。代码层离线任务做IP和设备指纹过滤剔除明显的机器行为。在实际环境下只加一个“3秒阈值”的效果就很明显推荐列表的点击率提升了大概三成可见无效浏览对矩阵的污染有多大。6.3 数据倾斜爆款邻居把整个列表带偏了还有一个经典问题。二手平台虽然没有真正意义上的“全网爆款”但总有某个型号的手机、某款热门球鞋短期内获得大量行为。ItemCF计算时它们成了“超级节点”和半壁江山的商品都产生了共现。我在相似度计算里加了热门惩罚因子前面代码里的0.6但还不够推荐结果多样性依然不足。后来又加了一步在线排序时对候选商品做类目平衡——同一个类目的商品在Top20里最多出现5个。实现方式很简单排序时加一个类目计数器超了就跳过。加了这步用户看到的推荐列表从“一水儿的手机数码”变成了覆盖三到四个类目的混合列表体验明显好转。6.4 测试推荐质量的小工具用“推荐点击率”做快速验证最后分享一个实操技巧。推荐系统的效果不能靠肉眼看几个截图判断我用了一个简单可落地的指标推荐点击率RCTR。RCTR 用户从推荐位进入商品详情页的次数 / 推荐坑位曝光次数上线前把这个指标埋进日志跑两周对比不同参数的效果。比如实验变量基线方案调整方案观察结果时间衰减系数无衰减0.02/天RCTR提升约22%热门惩罚无惩罚0.6因子RCTR提升约8%类目平衡无平衡每类目最多5个多样性提升RCTR持平3秒浏览阈值无开启RCTR提升约30%没有这些数据支撑调参就是瞎调。推荐系统的难点在于每次改动背后都有“为什么”要讲清楚而不是把一堆参数堆上去碰运气。就我个人经验来说这套基于Node.js的ItemCF方案跑了大半年最深的体会是推荐算法本身并不复杂复杂的是业务约束和数据质量。只要你把二手场景的特殊性吃透把冷启动、重复上架、行为清洗这些问题处理干净哪怕是教科书级别的ItemCF也会很好用。反过来算法再高级数据不干净推荐结果照样是垃圾。所以最后再提醒一次别急着堆模型先把行为日志和商品数据治理好比什么都管用。

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

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

免费获取报价 →
↑