​
摘要:本文从工程实现角度拆解一个比价与返利平台的核心链路:如何用关键词搜索聚合同款商品、如何做商品归一化与同款判定、如何通过详情 API 拉取价格与优惠信息、如何计算返利并保证数据一致性。文中给出可运行的 Java 示例代码、接口设计、缓存与限流策略,以及上线后常见的坑与优化方向。

一、需求拆解:比价与返利平台到底要解决什么问题
用户的核心诉求只有一句话:同一个商品,在哪买最便宜、还能返多少。但落到工程上,这句话会被拆成一条很长的链路:

搜索聚合:用户输入关键词,平台需要同时向多个电商渠道(自营商城、第三方联盟、开放平台)发起搜索,把结果合并成一个列表。
同款归一:不同渠道对同一商品的标题、SKU、图片、规格描述都不一样,需要判定“这是不是同一个东西”。
详情补全:搜索结果通常只有标题和主图,价格、券后价、库存、促销规则需要再调详情 API 获取。
优惠计算:满减、优惠券、平台补贴、会员价叠加后,才是用户真正关心的到手价。
返利计算:按渠道佣金比例、平台抽成、用户等级计算预估返利,并明确“预估”与“结算”的差异。
结果展示:按到手价升序、返利降序或综合排序,给出清晰的对比表格。
下面这张图是整体链路,后续章节会逐段展开。

flowchart TD
A[用户输入关键词] --> B[搜索聚合服务]
B --> C1[渠道A 搜索API]
B --> C2[渠道B 搜索API]
B --> C3[渠道C 搜索API]
C1 --> D[结果合并与去重]
C2 --> D
C3 --> D
D --> E[同款归一 / 商品聚类]
E --> F[详情API批量补全]
F --> G[优惠与到手价计算]
G --> H[返利计算]
H --> I[排序与分页返回]
I --> J[前端比价列表]

二、整体架构设计
推荐把系统拆成四层,避免搜索、详情、计算逻辑互相耦合:

层次 职责 关键设计
接入层 接收关键词、分页、排序参数,做参数校验与鉴权 统一响应结构,错误码可枚举
聚合层 并发调用多渠道搜索,合并、去重、同款聚类 线程池隔离 + 超时降级,单渠道失败不影响整体
详情层 按商品 ID 批量拉取价格、优惠、库存 批量接口 + 本地缓存 + 异步刷新
计算层 到手价、返利、排序 纯函数计算,便于单测与规则调整
一个重要的原则是:搜索接口要快,详情接口要准。搜索阶段允许返回不完整数据(比如只有标题和主图),详情阶段再补齐;这样首屏响应时间可以控制在几百毫秒内。

三、关键词搜索聚合:并发、超时与降级
3.1 统一渠道适配接口
不同渠道的搜索 API 参数和返回结构差异很大,先抽象出统一接口,后续新增渠道只需要实现适配器。

public interface SearchProvider {
/** 渠道标识,如 taobao、jd、pdd /
String channel();
/
* 按关键词搜索,返回统一结构的商品摘要 */
List<ItemBrief> search(SearchRequest request) throws ProviderException;
}
public class SearchRequest {
private String keyword;
private int page = 1;
private int pageSize = 20;
private String sort; // price_asc / sales_desc / default
private long timeoutMs = 800;
// getter / setter 省略
}
public class ItemBrief {
private String channel;
private String itemId; // 渠道内商品ID
private String title;
private String mainImage;
private BigDecimal price; // 展示价,可能不含优惠
private String shopName;
private String detailUrl;
// getter / setter 省略
}

3.2 并发聚合与超时控制
多渠道搜索必须并发执行,并且给每个渠道设置独立超时。任何一个渠道超时或报错,都只记录日志并跳过,不能拖垮整个请求。

@Service
public class SearchAggregator {
private final List<SearchProvider> providers;
private final ExecutorService pool;
public SearchAggregator(List<SearchProvider> providers) {
this.providers = providers;
// 每个渠道一个线程,避免相互阻塞
this.pool = Executors.newFixedThreadPool(
Math.max(providers.size(), 4),
r -> {
Thread t = new Thread(r, "search-pool");
t.setDaemon(true);
return t;
});
}
public List<ItemBrief> aggregate(SearchRequest request) {
List<Future<List<ItemBrief>>> futures = new ArrayList<>();
for (SearchProvider provider : providers) {
futures.add(pool.submit(() -> provider.search(request)));
}
List&lt;ItemBrief&gt; result = new ArrayList&lt;&gt;();
long deadline = System.currentTimeMillis() + request.getTimeoutMs();
for (int i = 0; i &lt; futures.size(); i++) {
long remain = deadline - System.currentTimeMillis();
if (remain &lt;= 0) {
break;
}
try {
result.addAll(futures.get(i).get(remain, TimeUnit.MILLISECONDS));
} catch (TimeoutException e) {
futures.get(i).cancel(true);
log.warn("渠道 {} 搜索超时,已降级", providers.get(i).channel());
} catch (Exception e) {
log.warn("渠道 {} 搜索失败", providers.get(i).channel(), e);
}
}
return result;
}
}

3.3 结果合并与去重
合并阶段先做粗粒度去重:同一渠道内 itemId 相同直接去重;跨渠道则保留全部,交给下一步同款归一处理。

public List dedup(List items) {
Map map = new LinkedHashMap<>();
for (ItemBrief item : items) {
String key = item.getChannel() + ":" + item.getItemId();
map.putIfAbsent(key, item);
}
return new ArrayList<>(map.values());
}

四、同款商品归一:从标题到 SKU 的判定策略
同款判定是比价平台最难也最有价值的部分。纯靠标题字符串匹配误判率很高,建议采用“多信号加权 + 阈值”的方案。

4.1 可用的判定信号
品牌 + 型号:最可靠的信号,例如“iPhone 15 Pro 256G”。
标准化标题:去掉促销词(包邮、正品、秒杀、官方旗舰店)后做分词与相似度计算。
关键规格:容量、颜色、尺码、版本等,从标题或详情属性中抽取。
图片指纹:主图感知哈希(pHash)相似度,作为辅助信号。
条码 / GTIN:如果渠道返回了条码,直接作为强匹配依据。
4.2 加权打分实现
public class SameItemScorer {
private static final Set<String> NOISE_WORDS = Set.of(
"包邮", "正品", "秒杀", "官方", "旗舰店", "新品", "限时", "特价");
/** 返回 0~1 的相似度分数 /
public double score(ItemBrief a, ItemBrief b) {
double titleSim = titleSimilarity(normalize(a.getTitle()), normalize(b.getTitle()));
double brandSim = Objects.equals(extractBrand(a.getTitle()), extractBrand(b.getTitle())) ? 1.0 : 0.0;
double specSim = specSimilarity(a.getTitle(), b.getTitle());
// 权重可按业务数据回归调整
return 0.5 * titleSim + 0.3 * brandSim + 0.2 * specSim;
}
private String normalize(String title) {
if (title == null) {
return "";
}
String t = title.toLowerCase();
for (String noise : NOISE_WORDS) {
t = t.replace(noise, "");
}
return t.replaceAll("[\s\p{Punct}]+", "");
}
private double titleSimilarity(String a, String b) {
if (a.isEmpty() || b.isEmpty()) {
return 0;
}
// 简化示例:用 Jaccard 相似度,生产环境可换成分词 + 余弦相似度
Set<String> sa = new HashSet<>(Arrays.asList(a.split("")));
Set<String> sb = new HashSet<>(Arrays.asList(b.split("")));
Set<String> inter = new HashSet<>(sa);
inter.retainAll(sb);
Set<String> union = new HashSet<>(sa);
union.addAll(sb);
return union.isEmpty() ? 0 : (double) inter.size() / union.size();
}
private String extractBrand(String title) {
// 生产环境建议维护品牌词典或用 NER 模型
return title == null ? "" : title.split(" ")[0];
}
private double specSimilarity(String a, String b) {
// 抽取 256G / 8+128G / XL 等规格片段做比对
return Objects.equals(extractSpec(a), extractSpec(b)) ? 1.0 : 0.0;
}
private String extractSpec(String title) {
if (title == null) {
return "";
}
Matcher m = Pattern.compile("(\d+\s
[gGtTmM][bB]?|\d++\d+[gG])").matcher(title);
return m.find() ? m.group() : "";
}
}

当分数超过阈值(例如 0.75)时归为同款,聚合成一个“商品组”,组内每个渠道的报价作为一条比价记录。

五、详情 API:批量拉取价格、优惠与库存
5.1 详情接口设计
详情接口一定要支持批量,否则 20 个商品就是 20 次 RPC,延迟不可接受。

public interface DetailProvider {
String channel();
/** 批量查询详情,单次建议不超过 20 个 */
Map<String, ItemDetail> batchDetail(List<String> itemIds) throws ProviderException;
}
public class ItemDetail {
private String channel;
private String itemId;
private BigDecimal price; // 当前售价
private BigDecimal originalPrice; // 划线价
private List coupons; // 可用优惠券
private List promotions;// 满减、限时折扣
private Integer stock;
private String skuInfo;
// getter / setter 省略
}

5.2 缓存策略
价格是高频变化数据,但也不是每次都要实时拉取。推荐分层缓存:

数据类型 缓存位置 TTL 说明
商品基础信息(标题、主图) Redis 24 小时 变化慢,可长缓存
价格与库存 Redis 60~120 秒 短缓存 + 异步刷新
优惠券信息 Redis 5 分钟 券有有效期,需校验时间
返利比例 本地缓存 + Redis 10 分钟 变更频率低
@Service
public class DetailService {
private final Map<String, DetailProvider> providerMap;
private final StringRedisTemplate redis;
private final ObjectMapper mapper = new ObjectMapper();
public ItemDetail getDetail(String channel, String itemId) {
String key = "detail:" + channel + ":" + itemId;
String cached = redis.opsForValue().get(key);
if (cached != null) {
try {
return mapper.readValue(cached, ItemDetail.class);
} catch (Exception ignored) {
// 反序列化失败则回源
}
}
DetailProvider provider = providerMap.get(channel);
if (provider == null) {
throw new IllegalArgumentException("未知渠道: " + channel);
}
Map<String, ItemDetail> map = provider.batchDetail(List.of(itemId));
ItemDetail detail = map.get(itemId);
if (detail != null) {
try {
redis.opsForValue().set(key, mapper.writeValueAsString(detail), Duration.ofSeconds(90));
} catch (Exception e) {
log.warn("详情缓存写入失败", e);
}
}
return detail;
}
}

六、到手价计算:优惠叠加的正确姿势
到手价不是简单的“售价减券”,要按渠道规则顺序计算,并且每一步都要校验可用性。

public class PriceCalculator {
/**

  • 计算到手价
  • 顺序:售价 -> 满减 -> 优惠券 -> 平台补贴 */ public BigDecimal finalPrice(ItemDetail detail) { BigDecimal price = detail.getPrice(); // 1. 满减:取满足门槛的最大优惠 BigDecimal bestFullReduce = BigDecimal.ZERO; for (Promotion p : detail.getPromotions()) { if ("FULL_REDUCE".equals(p.getType()) &amp;&amp; price.compareTo(p.getThreshold()) &gt;= 0 &amp;&amp; p.getDiscount().compareTo(bestFullReduce) &gt; 0) { bestFullReduce = p.getDiscount(); } } price = price.subtract(bestFullReduce); // 2. 优惠券:只取一张最优券 BigDecimal bestCoupon = BigDecimal.ZERO; for (Coupon c : detail.getCoupons()) { if (price.compareTo(c.getThreshold()) &gt;= 0 &amp;&amp; c.getAmount().compareTo(bestCoupon) &gt; 0) { bestCoupon = c.getAmount(); } } price = price.subtract(bestCoupon); // 3. 兜底,不允许出现负数 return price.max(BigDecimal.ZERO).setScale(2, RoundingMode.HALF_UP); } }

七、返利计算:预估与结算要分开
返利是平台的核心卖点,也是最容易产生客诉的地方。关键是把“预估返利”和“实际结算返利”明确区分。

public class RebateCalculator {
/** 渠道佣金比例,例如 0.05 表示 5% /
private final BigDecimal commissionRate;
/
* 平台抽成比例 /
private final BigDecimal platformRate;
/
* 用户等级加成,例如 1.0 / 1.2 /
private final BigDecimal userLevelFactor;
public RebateCalculator(BigDecimal commissionRate,
BigDecimal platformRate,
BigDecimal userLevelFactor) {
this.commissionRate = commissionRate;
this.platformRate = platformRate;
this.userLevelFactor = userLevelFactor;
}
/
* 预估返利 = 实付金额 * 佣金比例 * (1 - 平台抽成) * 用户等级系数 */
public BigDecimal estimate(BigDecimal paidAmount) {
return paidAmount
.multiply(commissionRate)
.multiply(BigDecimal.ONE.subtract(platformRate))
.multiply(userLevelFactor)
.setScale(2, RoundingMode.DOWN);
}
}

展示时建议同时给出三个数字:到手价、预估返利、返利后实际成本,让用户一眼看懂。

渠道 售价 优惠 到手价 预估返利 返利后成本
渠道 A 2999.00 满 2000 减 200 2799.00 83.97 2715.03
渠道 B 2899.00 券 100 2799.00 55.98 2743.02
渠道 C 3050.00 无 3050.00 122.00 2928.00
八、接口设计与返回结构
对外提供一个聚合比价接口,内部完成搜索、归一、详情、计算全流程。

@RestController
@RequestMapping("/api/compare")
public class CompareController {
private final CompareService compareService;
public CompareController(CompareService compareService) {
this.compareService = compareService;
}
@GetMapping("/search")
public Result<PageResult<CompareGroup>> search(
@RequestParam String keyword,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int pageSize,
@RequestParam(defaultValue = "final_price_asc") String sort) {
if (keyword == null || keyword.trim().isEmpty()) {
return Result.fail("关键词不能为空");
}
return Result.ok(compareService.compare(keyword.trim(), page, pageSize, sort));
}
}

返回结构建议按“商品组”组织,组内是各渠道报价,前端可以直接渲染成对比卡片:

{
"code": 0,
"data": {
"total": 128,
"list": [
{
"groupId": "g_10086",
"title": "iPhone 15 Pro 256G 原色钛金属",
"mainImage": "https://cdn.example.com/1.jpg",
"offers": [
{
"channel": "A",
"shopName": "官方旗舰店",
"price": 2999.00,
"finalPrice": 2799.00,
"couponText": "满2000减200",
"rebate": 83.97,
"stock": 12,
"detailUrl": "https://example.com/item/1"
}
]
}
]
}
}

九、性能与稳定性优化
并行化:搜索与详情都走线程池,按渠道隔离,避免慢渠道拖垮全局。
批量接口:详情必须批量,单次 20 个以内,减少 RPC 次数。
多级缓存:本地缓存挡热点,Redis 挡中频,回源只处理冷数据。
限流熔断:对每个渠道单独限流,失败率超阈值自动熔断,恢复后逐步放量。
异步刷新:缓存过期时先返回旧值,后台异步刷新,避免缓存击穿。
降级兜底:详情拉取失败时,用搜索返回的展示价兜底,并标注“价格可能有变动”。
// 缓存击穿保护示例:只允许一个线程回源
public ItemDetail getWithLock(String channel, String itemId) {
String key = "detail:" + channel + ":" + itemId;
String lockKey = "lock:" + key;
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (Boolean.TRUE.equals(locked)) {
try {
return loadFromRemote(channel, itemId);
} finally {
redis.delete(lockKey);
}
}
// 未抢到锁,短暂等待后读缓存
sleepQuietly(50);
return getDetail(channel, itemId);
}

十、常见坑与经验总结
同款误判:标题相似不代表同款,尤其是配件和套装。务必引入规格与品牌信号,并保留人工纠错入口。
价格不一致:搜索价与详情价经常不同,展示时以详情价为准,并注明更新时间。
优惠券失效:券有库存和有效期,计算前必须校验,否则用户下单时价格对不上。
返利口径不清:预估返利要写清计算依据和结算周期,避免客诉。
渠道规则变更:把渠道规则做成配置,不要硬编码在代码里。
合规问题:只使用官方开放平台或授权联盟接口,遵守各渠道的数据使用协议。
整体来看,比价与返利平台的技术难点不在单个接口,而在于聚合链路的稳定性和同款判定的准确性。先把搜索聚合和详情补全做稳,再逐步优化同款算法和返利规则,是比较务实的落地路径。如有任何疑问,欢迎留言探讨!

​