ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot实战:网购比价系统的架构设计与实现

2026/10/1 12:45:46 拓冰建站 浏览量
Spring Boot实战:网购比价系统的架构设计与实现 先说一个我经常拿来跟学生举的例子同款无线鼠标京东自营卖89元淘宝某第三方店卖69元但运费收8块拼多多百亿补贴最低做到过59元包邮。你手动开三个App来回切换、复制商品名去搜同款折腾十分钟还不一定能看清最终到手价。这就是网购比价服务的核心场景——把分散在多个平台的同一商品信息汇聚起来算清楚哪个渠道最划算。我基于Spring Boot完整设计和实现过一套网购优选比价服务系统从架构拆分、数据采集、价格分析到定时任务和缓存优化都走了一遍这篇文章把整个设计过程、关键代码思路和踩过的坑整理出来给正在做类似Spring Boot课程设计或毕业设计的朋友一个可复现的参考。1. 比价系统的需求本质别把查价格做成爬网页拿到这类题目第一反应往往是我要写爬虫去抓商品价格。方向上没错但如果你真把一个比价系统只做成了定时抓取价格存数据库那这个项目无论从设计深度还是实用价值上都很浅。比价系统真正的难点在于三个层面数据从哪来、数据准不准、推荐是否符合用户预期。1.1 明确的用户痛点网购人群的典型比价行为是在A平台看到心仪商品复制标题去B平台搜索同款再手动对比价格、运费、优惠券。这个过程的痛点很明显平台间商品标题表述不一致手动判断是不是同款很费劲到手价计算复杂商品价、满减、优惠券、运费叠加后用户心里没有准数价格是动态的用户没办法24小时盯盘错过低价是常事所以我在做系统设计时把核心功能定位成三个多平台商品信息聚合、同款商品智能匹配、价格趋势监控与降价提醒。在此基础上扩展用户收藏、综合评分排序等功能让系统不只是展示价格而是真正辅助用户做购买决策。1.2 功能性需求与非功能性需求功能性需求可以从用户端和管理端两个维度梳理用户端商品搜索、比价列表、同款匹配、价格历史曲线、降价订阅、收藏管理管理端采集任务配置、平台管理、数据统计、采集日志查看非功能性需求往往被忽略但恰恰是答辩和面试的高频问题数据采集的时效性多久更新一次价格、接口响应速度用户搜索不能等太久、系统可扩展性新增一个电商平台要能低成本接入。1.3 为什么选Spring Boot而不是别的框架Spring Boot在这个项目里有不可替代的优势。首先是开发效率约定大于配置一个空的Web项目从创建到跑通只需要十几分钟。其次是生态整合MyBatis-Plus、Redis、定时任务、异步处理这些组件都是现成的starter不需要自己造轮子。最关键的一点Spring Boot的自动装配和Starter机制非常适合你这个系统的分层架构——采集模块、业务模块、定时任务模块各司其职依赖注入把模块间的耦合降到最低。2. 整体架构设计一个系统两个端四个模块比价系统最怕架构混乱。采集逻辑和业务逻辑混在一起定时任务和接口调用互相干扰后期随便加个功能都可能引发连锁故障。我把系统拆成了四个模块采集模块、业务服务模块、定时任务模块、数据存储模块。2.1 模块划分与职责边界我的模块划分思路是一个数据流走到底模块核心职责典型技术采集模块拉取平台商品信息、解析字段、清洗标准化HttpClient、Jsoup、自定义解析规则业务服务模块用户接口、比价逻辑、收藏、提醒Spring MVC、MyBatis-Plus定时任务模块周期性触发采集、价格快照、数据归档Spring Scheduled、分布式锁数据存储模块商品信息、价格历史、用户数据持久化MySQL、Redis这个拆分的好处是采集模块和业务模块完全解耦。以后要新增采集平台只需要在采集模块里新增一个平台的解析器不需要动业务代码。业务接口也不会因为采集任务卡顿而超时。2.2 数据流转的核心链路系统运行时的主链路是这样的定时任务触发采集每天固定时间比如每6小时对已配置的采集任务发起抓取采集模块解析原始页面提取标题、价格、店铺名、销量、商品图片、规格参数等字段数据清洗与标准化对平台名、价格精度、商品标识做统一处理同款匹配判定根据商品特征判断这个商品是否已经在数据库中存在价格快照记录无论价格是否变化都写入价格历史表用于后续趋势曲线推送降价通知如果价格低于用户订阅的提醒阈值生成提醒消息这里有个设计细节要注意采集和数据清洗必须分离。采集只负责把原始数据拿回来并落盘清洗和标准化是独立步骤。原因是原始页面格式可能随时变化如果清洗逻辑耦合在采集逻辑里平台页面一改整个采集链就崩了。2.3 RESTful接口设计的关键约定接口设计我在开发中踩过不少坑这里直接给出一份合理的约定统一返回格式code message data用统一响应体包装前端和联调都省事分页参数统一pageNum和pageSize从1开始避免不同接口不同约定搜索接口用GET比价搜索是查询操作语义上用GET参数通过Query传递订阅提醒用POST涉及创建操作用POST语义更清晰核心接口可以这样定义// 比价搜索接口 GET /api/compare/search?keyword无线鼠标pageNum1pageSize10 // 商品价格历史接口 GET /api/products/{productId}/price-history // 订阅降价提醒 POST /api/price-alert/subscribe3. 商品数据采集层比价服务的地基工程数据采集层是整个系统的数据源头这一层做不好后面所有比价和推荐逻辑都是空中楼阁。我建议把采集能力设计成可配置的规则引擎而不是把解析逻辑写死在代码里。3.1 采集方式的选择与合规边界数据获取最稳妥的方式是优先对接平台开放的API或官方数据接口。但现实中很多电商平台对普通开发者并不开放商品查询能力所以项目里常用的方式是网页采集。这里必须强调合规性采集前要遵守目标平台的robots协议控制访问频率不能对目标站点造成压力采集到的数据只能用于学习或内部研究。我在项目里对每个采集源都配置了请求间隔、超时时间和重试策略避免短时间内高频请求。3.2 页面解析与字段抽取我使用的是请求 解析器的模式。先用HttpClient或OkHttp请求商品页面或搜索结果页再用Jsoup解析HTML抽取字段。为了让解析器更灵活我把CSS选择器配置放到了数据库里不同平台对应一套自己的选择器规则。以某电商平台的商品搜索页为例public class ProductPageParser { public Product parse(String html, PlatformConfig config) { Document doc Jsoup.parse(html); Product product new Product(); // 取标题 product.setTitle(doc.selectFirst(config.getTitleSelector()).text()); // 取价格 String priceText doc.selectFirst(config.getPriceSelector()).text(); product.setPrice(parsePrice(priceText)); // 取店铺名 product.setShopName(doc.selectFirst(config.getShopSelector()).text()); // 取销量 product.setSales(doc.selectFirst(config.getSalesSelector()).text()); return product; } }注意价格解析一定要单独抽一个方法。原因很现实不同页面展示的价格格式差异很大¥89.00、89.00元、券后价89都是常客。统一做一个parsePrice方法内部处理货币符号、千分位、中文单位能省掉后期大量复查工作。我在开发中发现一个平台的价格格式每月都会有微调集中处理比写死在解析器里好得多。3.3 数据清洗与同款匹配清洗阶段主要做三件事去除HTML标签残留、统一单位、识别商品唯一标识。同款匹配是比价系统的灵魂也是最容易翻车的地方。我采用的匹配策略分三层第一层商品条码匹配如果数据源提供了条码或SKU编码直接用该编码关联第二层品牌 型号匹配从标题中提取品牌和型号组成品牌空格型号的核心特征串第三层基于标题相似度的兜底匹配使用编辑距离结合关键词权重打分举个例子三个平台分别叫某品牌无线蓝牙耳机Pro版、某品牌/Pro蓝牙耳机、某品牌蓝牙耳机Pro 2024款前两层匹配会失败第三层通过关键词权重和编辑距离才能把它们关联起来。我建议同款匹配的结果要落到一张独立的映射表里比如product_mapping表字段设计为sourceProductId、targetProductId、matchType这样即使算法优化后想重新匹配也不需要动历史数据。4. 价格分析与优选推荐把比价从能用做到有用数据采集解决了有没有的问题价格分析进阶到好不好和准不准。这一章是系统的核心价值也是答辩时最能体现设计深度的地方。4.1 到手价计算规则比价不能只比较裸商品价格这一点是我在项目后期才彻底想明白的。用户真正关心的是到手价 商品价格 运费 - 优惠金额。所以我设计了价格计算模型public BigDecimal calculateActualPrice(Product product, BigDecimal shippingFee) { BigDecimal price product.getPrice(); // 减去平台优惠券金额 if (product.getCouponAmount() ! null) { price price.subtract(product.getCouponAmount()); } // 满足满减条件时减掉满减金额 if (product.getFullReductionAmount() ! null price.compareTo(product.getFullReductionThreshold()) 0) { price price.subtract(product.getFullReductionAmount()); } return price.add(shippingFee).max(BigDecimal.ZERO); }这里有一个很重要的取舍满减、优惠券这些数据并不总是能采集到所以在存储设计时这些优惠字段要允许为空。不能为了算法完整性把采集不到的数据硬编码成默认值否则用户看到的到手价就是不准确的。宁可显示暂无优惠信息也不要给一个错误的最低到手价。4.2 优选排序的加权评分模型比价列表要回答用户一个问题这么多选择里哪个最值得买排序策略我用了加权评分而不是单纯按价格排。评分模型包含四个维度价格得分权重0.5到手价越低得分越高店铺信用得分权重0.2基于店铺评分或销量等级归一化配送速度得分权重0.2同城有货、次日达等作为加分项历史价格稳定度得分权重0.1近期频繁涨价的不给高分加权求和后得到综合评分然后按分数降序排列。在实际开发中我会把权重设计成可配置项存在配置表里。理由很朴素不同用户的偏好不同预算敏感型用户希望价格权重再高一些品质敏感型用户对店铺信用和配送时效更看重。虽然毕设阶段的用户画像系统可以简化为不同排序模式性价比优先、品质优先切换但权重的可配置设计能为后续扩展留下空间。4.3 价格历史趋势与降价提醒价格历史模块的价值在于让比价有记忆。我在比对商品时不只是展示当前价格还会展示最近30天的价格曲线特征最低价、最高价、平均价、近7天是否处于低位。降价提醒的实现思路用户针对某个商品设定期望价格系统每次采集新价格后如果当前到手价低于或等于用户期望价就生成一条提醒消息。为了避免重复提醒需要加一个去重机制——同一个商品对同一个用户一天内最多提醒一次。我在提醒记录表中增加了last_notify_date字段通过日期唯一约束配合SQL查询把这个逻辑严密落地。5. Spring Boot核心实现定时任务、缓存与异步处理架构设计得再好最终要靠Spring Boot的代码落地。这里挑三个技术点讲透定时采集任务的可靠性、Redis缓存的正确用法、异步采集如何不阻塞主业务流程。5.1 定时采集与分布式锁定时采集用Spring自带的Scheduled注解就能实现。但如果你在单机环境开发会忽略一个隐患同一个定时任务在多实例部署时会被重复执行。解决方式是用分布式锁。我采用Redis分布式锁实现定时任务互斥Service public class PriceCollectTask { private static final String LOCK_KEY price:collect:cron:lock; private static final long LOCK_EXPIRE 60 * 1000L; Scheduled(cron 0 0 0/6 * * ?) // 每6小时执行一次 public void collectPriceTask() { // 尝试获取锁 boolean locked redisTemplate.opsForValue() .setIfAbsent(LOCK_KEY, locked, Duration.ofMillis(LOCK_EXPIRE)); if (!locked) { // 已有节点在执行本次采集 log.info(采集任务已被其他实例执行跳过本次调度); return; } try { collectService.executeAllPlatformCollect(); } finally { redisTemplate.delete(LOCK_KEY); } } }这个方案的关键是锁的过期时间要大于任务执行时间。如果采集任务跑20分钟锁只能撑10秒另一个实例就会并发执行。稳妥做法是根据最坏情况估算任务耗时设定一个更宽松的过期时间同时在任务结束时主动释放锁。5.2 Redis缓存策略比价系统的高频读场景集中在商品搜索结果和价格详情上这些数据变化频率不高但访问量大非常适合做缓存。我用了两个层次的缓存策略第一层是热门关键词搜索结果的缓存。用户搜索一个商品先查Redis缓存key设计为search:keyword:{关键词}:page:{页码}缓存时间设置为10分钟。这样同一关键词的大量重复搜索不会直接打到数据库。第二层是商品详情页价格的缓存。价格数据虽然来自采集但6小时内基本不变可以用product:price:{productId}作为key缓存时间与采集周期对齐。查询接口优先读缓存缓存没有命中再查数据库然后回填缓存。有一个细节值得注意在缓存价格时一定要把到手价相关的优惠字段一并缓存避免用户看到缓存裸价。缓存值使用JSON序列化字段结构要完整。5.3 Async异步处理采集任务业务接口和采集任务必须异步隔离。用户点击立即比价时如果系统同步去抓取三个平台的数据接口响应可能要十秒以上体验非常差。我设计成异步流程Controller收到比价请求后快速返回一个任务ID后台线程异步执行采集、解析、匹配、评分前端轮询任务状态接口采集完成后获取结果Spring Boot的Async注解可以轻松实现Service public class CompareService { Async(compareExecutor) public CompletableFutureCompareTask startCompare(String keyword) { // 异步执行采集和比价逻辑 return CompletableFuture.completedFuture(buildResult(keyword)); } }线程池必须单独配置不建议直接用默认的SimpleAsyncTaskExecutor。我配置了一个核心线程数8、最大线程数16、队列容量200的线程池并且设置了拒绝策略为CallerRunsPolicy。这样采集任务繁忙时新任务不会无限制堆积导致内存溢出。6. 数据库设计与性能优化价格历史表最容易拖垮系统数据库设计直接影响系统能否跑长久。我在项目开发中发现最典型的问题开发者只建了商品表和价格表结果运行一个月价格历史表轻松突破百万行查询越来越慢。下面这张表结构是一个经过验证的方案。6.1 核心表结构商品主表product字段名类型说明idbigint主键product_namevarchar(255)商品名称platform_idint所属平台product_urlvarchar(512)商品原始链接main_imagevarchar(255)主图地址brandvarchar(100)品牌modelvarchar(100)型号statustinyint上下架状态价格历史表price_history字段名类型说明idbigint主键product_idbigint关联商品pricedecimal(10,2)商品裸价shipping_feedecimal(10,2)运费coupon_amountdecimal(10,2)优惠信息actual_pricedecimal(10,2)到手价collect_timedatetime采集时间created_atdatetime记录创建时间有一张容易漏掉的表平台配置表platform_config里面存储每个采集平台的CSS选择器规则、请求头模板、是否启用的标记。把采集规则配置化后新增平台完全不用改代码只要往表里插入一条规则记录。6.2 索引设计与查询优化价格历史表是高频写入、中频查询的表。索引设计需要特别谨慎因为索引过多会拖慢写入索引缺失会拖慢查询。我的建议必建索引一product_id collect_time组合索引覆盖查询某商品最近N天价格走势的场景必建索引二product_id actual_price覆盖降价提醒的场景禁止索引不要在price_history表的price字段单独建索引意义不大6.3 数据归档策略价格历史数据理论上要永久保留但每6小时全量采集一次一年下来商品价格记录会有几十亿的规模。我的处理策略是冷热数据分离最近3个月的数据保留在业务库的price_history表3个月以上的数据通过定时任务按月归档到price_history_archive表归档时同步删除业务库中的过期数据控制主表体积查询历史价格曲线时按时间范围决定查询哪张表。这个设计在项目答辩时非常加分因为它是真实业务规模下的合理考虑。7. 实操踩坑记录五个让我加班到深夜的真实问题最后这部分我把自己开发过程中踩过的坑整理成清单希望能帮你少走弯路。7.1 采集框架假装自己是浏览器第一个坑是目标平台识别出采集请求并返回验证页面。问题根源是请求头不完整。我的解决方案是仔细对齐真实浏览器的请求头包括User-Agent、Accept、Accept-Language、Referer等并用一个共享连接池维持稳定的会话。同时严格遵守平台规则控制请求频率并把采集时间分散开。7.2 同款匹配的标题差异陷阱真实场景中同一商品在两个平台的标题可能差异巨大一个写官方正品保证另一个写2024新款升级。我的兜底策略是使用文本相似度算法同时引入人工确认机制——置信度低于阈值的匹配结果记录到待确认列表管理员在后台确认后固化映射关系。这相当于给自动匹配加了一层人工兜底效果比纯算法靠谱得多。7.3 定时任务重复执行导致重复数据开发环境单实例跑没事到了部署阶段我一不留神起了两个实例定时任务跑了两遍价格历史表里出现了大量完全相同的重复快照。处理方案就是前面说的Redis分布式锁而且锁的key设计要带上任务名称不然后续加新任务时会出现错误互斥。7.4 接口响应超时的根因不在数据库排查搜索结果慢的问题时我一度认为SQL慢后来发现索引没问题最后定位到问题在序列化层——商品对象关联了平台配置对象Jackson序列化时把配置对象也带上接口返回了数据量大时耗时暴涨。解决方案是使用专门的DTO数据传输对象只暴露需要的字段并开启Jackson的懒加载线程安全配置。7.5 降价提醒的重复漏听最初设计降价提醒时每抓取一次价格就发一次提醒用户在一个降价周期内可能收到三四条相同信息的通知非常打扰。后来加了每日去重逻辑同一个商品对同一个用户一天只提醒一次。同时把提醒推送做成异步任务避免采集线程阻塞。这个系统我从第一行代码写到最后一次重构最大的感受是比价系统的技术门槛不在某个单一技术而在把Spring Boot的生态组件合理编排起来让数据采集、业务服务、定时任务、缓存和数据库各司其职。对于正在做选题的你从系统设计视角去呈现项目远比堆砌我用了Spring Boot MyBatis Redis这几个名词更有说服力。最后一个建议把采集规则的配置化、价格历史数据的归档方案和分布式锁这三个细节想清楚你的设计和答辩就已经超过大多数同类项目了。