做这个基于Python的畅联智购平台,最初是从一个很具体的痛点切入的:线下零售渠道多点开花,但数据全散在各自系统里,用户在前面选得费劲,运营在后面对数也费劲。当时我刚接手一个区域连锁零售方向的模拟项目,门店、小程序、H5商城三套体系互不相通,同一个商品在不同端价格、库存、展示信息经常不一致。用户问“附近门店有没有货”,客服只能手工翻表格;线上下单之后,仓库那边还可能因为库存没同步而发不出货。畅联智购平台就是在这个背景下立项的,核心目标是用一套Python中间件,把所有销售渠道的商品、库存、订单、用户行为汇总到一个统一中心,再在中心之上做智能推荐和跨渠道履约调度。
很多人第一反应是:这不就是一套电商后台加推荐系统吗?但真正落地之后你会发现,难点不在写接口,而在渠道数据整合和推荐结果在真实业务中能不能被接受。平台整个技术栈选型是偏Python系——接口层用FastAPI,数据建模用Django ORM,异步任务用Celery,推荐部分用pandas、NumPy和implicit做离线计算,Redis承担缓存和实时特征,MySQL存主数据。这个选择不是赶时髦,而是靠着Python生态把“采集、清洗、建模、服务化”整个链路串起来,团队维护成本低,业务迭代速度也快。
这篇文章不打算重复官方文档,我会从方案选型、数据表设计、推荐算法、后端集成、部署监控、踩坑实录几个方面展开,把我们在畅联智购平台里真正落地的做法、参数和代码片段放出来。内容比较长,适合正在做类似多端零售、聚合电商、智能导购项目的开发者和产品同学参考。
1. 项目定位与技术选型思路
1.1 这个平台到底要解决什么问题
畅联智购平台本质上不是从零造一个电商网站,而是一个“多渠道零售数据接入与智能分发”的基础设施。模拟场景里,某区域连锁品牌有3个线下门店、1个微信小程序商城、1个H5活动页,每个渠道都有一套独立商品库和订单库。用户在小程序看到的价格是活动价,但到店后店员说系统里不是这个价;线上下单后,订单系统不会自动匹配最近门店的库存,导致履约延迟。这些割裂问题背后都是数据没有贯通。
平台要解决的核心问题可以拆成四个:
- 商品主数据统一:各渠道SKU编码规则不同,需要做映射和清洗。
- 库存实时聚合:线上线下库存能够联动,销售订单能按距离、库存水位自动分配履约门店。
- 用户行为沉淀:把浏览、搜索、加购、下单事件收集起来,形成可用的用户画像特征。
- 智能推荐输出:基于用户历史行为和新品热销规则,在小程序首页、搜索结果页、购物车关联推荐位输出个性化商品流。
这四个问题环环相扣。如果没有商品主数据统一,后面的库存和推荐都是在脏数据上做文章;如果没有行为沉淀,推荐就只能靠拍脑袋。所以整个项目的第一阶段没有直接去做高深的算法,而是先把数据管道跑通,形成一套可信的事实层。
从应用场景看,畅联智购平台适合用在这些地方:本地生活类小程序里的“附近好货”、连锁门店的线上下单到店自提、多品牌聚合电商的搜索导购、以及会员精细化运营。它和普通B2C电商最大的区别是,它必须同时处理“人、货、场”三者的实时状态,而不仅仅是“人找货”。
1.2 为什么选择Python而不是别的语言
技术选型的时候,团队内部也不是没有争议。有同学提出用Java或者Go,理由是并发能力强、生态成熟。但最终我们还是定了Python,核心原因有三个。
第一,数据清洗和推荐算法验证的效率。在项目早期,我们需要频繁处理来自不同渠道的Excel、JSON接口、半结构化文本。Python的pandas、openpyxl、requests组合起来,可以半天内完成一个渠道的接入联调;如果用Java,光是把各种数据格式揉成统一Schema就要多花不少时间。后面做离线推荐模型,scikit-learn、implicit都有现成实现,不需要额外造轮子。
第二,全链路技术栈统一。从同步脚本到Web API,从离线任务到在线特征服务,都用Python,等于减少了跨语言协作成本。我们不需要一个Java团队和一个Python团队来回对接口,一个后端开发从头跟到尾,代码流转非常顺。对于一个小规模团队来说,这种统一红利是很实际的。
第三,性能瓶颈不在语言本身。畅联智购平台的业务形态是零售和导购,在线接口的QPS压力并不像社交信息流那么大。真出现性能问题,我们可以在网关层、缓存层、异步任务层做补偿。举个例子,商品详情页用Redis缓存后,FastAPI单实例能轻松承载每秒2000以上的请求,这已经覆盖了模拟业务的峰值。
当然Python也有短板:CPU密集型的在线推理不够快,GIL在极端并发下会拖后腿。因此我们做推荐排序时,尽量把重计算放到离线,在线只做查表和轻量加权,把计算压力转移给Redis和预计算结果。这是一个很关键的架构决策:不在线做重活。
1.3 核心模块划分与工程结构
畅联智购平台的工程结构是按业务域拆分的,而不是按技术分层堆代码。项目早期用Django单应用把商品、订单、用户放一起,后来发现渠道同步和推荐任务开始抢占业务接口的资源,才逐步拆成下面几个模块:
- gateway:统一API入口,做鉴权、限流、路由转发。
- product_center:商品主数据、类目、品牌、SKU映射。
- inventory_center:多门店库存、库存同步、库存预占。
- order_center:订单创建、支付回调、履约门店分配。
- behavior_center:埋点接收、行为清洗、特征聚合。
- recommend_engine:离线召回、在线排序、推荐解释。
- task_worker:Celery异步任务,负责渠道同步、订单通知、模型产出。
这个划分解决了一个很实际的维护问题:每个模块可以独立部署、独立扩容、独立做数据库表。比如库存模块因为访问量大,可以单独分出去;推荐模块因为不稳定,可以随时降级而不影响下单主流程。
模块之间通过内部API和消息队列通信,不直接共享数据库表。这样做的好处是边界清晰,坏处是需要额外管理接口版本和消息契约。我们当时的折中方案是:核心主数据(商品、库存)允许模块之间通过只读视图访问,订单和用户行为必须走接口,避免跨模块写依赖。
2. 数据层设计与关键实现
2.1 数据库选型与核心表结构
畅联智购平台的主数据库用了MySQL 8,全部表默认InnoDB,字符集utf8mb4。库存和价格这种热点数据用Redis做缓存,搜索和商品列表召回用Elasticsearch,但ES在我们项目里只承担“检索”和“候选召回”,不承担事务性写入。
核心表结构有六张,我挑几张有代表性的说一下。
商品主表product,核心字段包括product_id、product_name、category_id、brand_id、status、created_at、updated_at。这张表只存商品公共属性,不存价格和库存,因为价格库存是有时效性的,频繁更新会影响主表性能。
SKU表sku承接商品的多规格属性,比如颜色、尺码、包装。sku_id是全局唯一编码,它是所有渠道映射的基准。每个渠道内部编码会作为渠道SKU映射表的一个字段存下来,同步时通过映射关系转换成统一编码。之所以不直接在sku表里加一个channel_code列,是因为一个SKU在同一个渠道下可能有多套编码,比如一个渠道的A门店和B门店编码不同,所以用单独映射表更灵活。
库存表inventory,字段是sku_id、store_id、available_qty、locked_qty、sold_qty。available_qty是当前可售数量,locked_qty是订单预占数量,sold_qty是累计销量。这里没有把“总库存”直接存成一个字段,因为总库存的计算可能涉及多门店、多渠道、活动库存池等等,拆开存更容易排查问题。
用户行为表user_behavior,核心字段是user_id、sku_id、behavior_type、scene_id、behavior_time、extra_json。behavior_type区分view、search、cart、order_finish、share。我们把extra_json设为JSON字段,用来放一些不定长的信息,比如搜索关键词、来源页面、实验桶ID。这类半结构化数据不适合展开成大量列,用JSON字段配合MySQL的JSON索引能力就够了。
为了查询性能,订单表按order_id做哈希分片,用户行为表按月分表。分表规则不复杂:behavior_202501、behavior_202502这样滚动建表。定时任务每月创建下月分区,避免手工维护。
2.2 多渠道商品数据采集与清洗Pipeline
多渠道同步是整个平台最容易翻车的地方,因为每个渠道的数据格式差异很大。有的渠道开放标准REST接口,有的只提供每天一次的报表文件,还有的字段名是拼音缩写。我们做了一套统一的同步Pipeline,分四步:拉取、校验、转换、写入。
拉取由Celery Beat定时任务触发,每个渠道一个同步任务。任务内部先调用渠道接口,把原始数据落地到一张staging表,保证任务重跑时不会因为网络抖动丢数据。
校验阶段检查必填字段和值域。比如商品价格不能为负,SKU编码不能为空,库存不能小于0。校验失败的数据不会直接丢弃,而是进入异常队列,由运营人工核对。这里有个细节:所有清洗规则都用配置表维护,不硬编码在代码里。比如某个渠道的价格字段单位是分,另一个渠道单位是元,就通过channel_config表里的price_unit字段统一换算。
转换阶段会做三件核心事情:SKU映射、类目映射、价格清洗。SKU映射通过渠道编码查映射表,查不到就进入待映射队列。类目映射是因为每个渠道有自己的类目树,要映射到平台统一的两级类目。价格清洗则要处理促销价、会员价、满减价等多套价格,最终确定一个展示默认价和一个渠道活动价。
下面是一段价格清洗的简练示例,核心思路是“先标准化、再业务化”:
import pandas as pd def clean_price(raw_df): # 统一到“分”单位,避免浮点误差 if raw_df["price_unit"].iloc[0] == "yuan": raw_df["price"] = (raw_df["price"] * 100).round(0).astype(int) # 过滤异常价格 df = raw_df[(raw_df["price"] > 0) & (raw_df["price"] < 100000000)] # 如果渠道没有默认价,使用活动价作为展示价 df["display_price"] = df["sale_price"].fillna(df["price"]) return df[["sku_no", "display_price", "channel_price", "updated_at"]]这个Pipeline跑起来之后,最大的收益不是自动化本身,而是让“人工捉虫”变成了“配置改规则”。以前渠道对接人员发现问题要改代码,现在只需要在后台规则库里调整映射和清洗逻辑,发布也不影响主流程。
2.3 用户行为埋点与存储
用户行为是推荐系统最核心的信息资产。畅联智购平台的埋点不是一开始就做全的,是先埋了四个关键事件:曝光、点击、加购、下单。后续才补了搜索、分享、收藏。
前端SDK统一上报到behavior_center的collect接口,接口校验完基本参数后,把原始事件写入Redis Stream,然后由消费者任务异步落库。为什么先写Redis Stream而不是直接写MySQL?因为行为事件在促销高峰会有短时流量尖峰,直接写MySQL容易打满连接池,先放到流里可以让消费速度平滑可控。
落库的清洗任务会做三件事:会话划分、去重、实体校验。会话划分主要根据用户id和事件间隔,超过30分钟没有新事件就视为新会话。去重是为了应对前端重复上报,我们以event_id作为唯一标识,写库时用INSERT IGNORE方式避免重复。
实体校验是把行为事件里的sku_id、store_id等外键和主数据比对,如果发现SKU已下架,会把这个事件标记为invalid,避免推荐模块用到脏数据。用户特征表fe_user_feature会在离线任务里按天聚合,统计用户近7天、30天、90天的类目偏好、价格带偏好、活跃时段等特征。
3. 推荐引擎与核心算法落地
3.1 冷启动方案:基于规则的初版推荐
推荐引擎在畅联智购平台里并不追求一步到位。第一个版本没有上协同过滤,也没有上深度学习,而是用一套可配置的规则排序,先把业务跑起来。
规则排序的核心逻辑是:用户没有任何行为时,展示商品池按“新鲜度、热度、距离、毛利”四个维度的加权分排序。新鲜度指商品上架天数,上架7天内的商品有新品加权;热度指近24小时在门店范围内的曝光点击率;距离指用户当前位置到商品所在门店的直线距离,近的加权;毛利分则是运营配置的优先级,用来控制利润较高的商品排序位置。
规则用Python字典配置,每天由运营在后台更新,不需要发版。实际排序函数长得这样:
def cold_start_score(item, user_context, config): freshness_score = max(0, 1 - item["days_on_shelf"] / config["freshness_decay_days"]) popularity_score = item["click_rate_24h"] / config["max_click_rate"] distance_score = max(0, 1 - user_context["distance_km"] / config["max_distance_km"]) profit_score = item["margin_rank"] / config["max_margin_rank"] total = ( freshness_score * config["weight_freshness"] + popularity_score * config["weight_popularity"] + distance_score * config["weight_distance"] + profit_score * config["weight_profit"] ) return total这套冷启动方案看起来“笨”,但对新用户非常友好。它不会因为用户没有历史行为就直接给热门单品刷屏,而是把线下门店的距离因素纳入排序,让用户看到的商品和TA当前所在位置强相关。上线后冷启动用户的点击率明显高于原来的手工推荐位置。
规则方案的代价是人工调权重的频率比较高。我们每周会根据AB实验数据调整四个权重,比如某段时间门店自提业务占比上升,就把distance_score的权重调高。这不是最优解,但在数据量不够大的阶段,它比直接训练模型要稳定得多。
3.2 协同过滤的离线计算
平台跑了两个月之后,用户行为数据有了一定规模,我们才开始上线基于物品的协同过滤。
为什么选择物品协同过滤而不是用户协同过滤?因为零售场景下商品数量相对稳定,用户和商品的交互矩阵极稀疏。物品协同过滤计算的是商品之间的相似度,相似关系可以提前算好存Redis,在线推荐时直接查表,响应时间可控。用户协同过滤则需要在线计算用户之间的相似度,复杂度高,不好做实时推荐。
离线计算过程是:每天凌晨从行为宽表里读取近90天的有效行为,构建用户-商品隐式反馈矩阵。交互分值的定义是:曝光记1分,点击记3分,加购记5分,下单完成记10分。之所以这样加权,是因为单纯“点击”有误触和好奇的成分,而“下单完成”代表真实购买意愿,权重必须更高。
矩阵构建完成以后,用implicit库的ALS模型进行矩阵分解。训练参数上,embedding维度选了32,正则系数0.1,迭代次数15次。这些参数不是拍脑袋,而是用一个月的交易数据做了网格搜索,按推荐结果的召回率和点击率评估选出来的。训练完成后,为每个商品输出Top30相似商品列表,写入Redis,key格式是similar:{sku_id}。
输出相似商品时一定要做业务过滤,不能直接返回算法结果。我们强制剔除了“已下架商品”、同门店无库存商品、明显规格不一致的商品(比如把手机和手机壳算成相似是合理的,但是把手机和手机膜算成相似就会影响体验)。过滤规则写在模型输出之后,属于后处理逻辑。
3.3 实时特征更新与排序融合
纯离线推荐的最大问题是时效性差。用户今天看了某个品牌,明天这个品牌的偏好才算进模型,体验很割裂。所以在畅联智购平台里,我们在离线召回的基础上加了一层实时特征。
实时特征主要存在Redis里,结构是user_id:{sku_id}:score,TTL设为30分钟。每当用户产生点击、加购行为,后端会自动给对应商品增加一个短期热度分。比如点击一次加3分,加购一次加15分,但会做衰减,每5分钟衰减10%,避免一个旧行为永远霸榜。
在线推荐时,最终排序分数由三部分融合:离线协同过滤分、实时行为分、冷启动规则分。实际公式类似:
final_score = ( offline_cf_score * 0.5 + realtime_score * 0.3 + cold_start_score * 0.2 )这里有一个很重要的问题:三类分数的量纲不一致。离线分数的取值范围可能是0到10,实时分数是0到100,冷启动分数也是0到1。直接加权会被量纲大的分数带偏。所以我在代码里先对三类分数做min-max归一化,再加权。这个细节如果不处理,模型效果再好也会被排序阶段放大噪声。
对于黄金位(首页第一屏),我们还会做一次粗排和一次精排。粗排阶段从全量商品池里选出300个候选,精排阶段用融合分数排序,取前20个返回。整个过程控制在50毫秒以内,主要耗时在Redis批量读取。
4. 后端API与系统集成
4.1 接口风格与登录鉴权
畅联智购平台面向小程序的API采用REST风格,返回结构统一为:
{ "code": 0, "message": "ok", "data": {} }code为0表示成功,非0表示业务异常。这样设计是为了让前端能够统一处理异常提示,而不是每个接口自定义字段。分页参数固定用page和page_size,排序字段用order_by和order_type,避免每个接口各搞一套。
鉴权方面,C端用户使用JWT,内部服务之间使用HMAC签名。JWT的payload里只放user_id和session_id,不放敏感信息;过期时间设为一小时,小程序端配合refresh_token续期。内部服务调用网关时,通过请求头X-Service-Token做签名校验,签名内容是时间戳加请求路径加密钥的HMAC值,防止内网接口被恶意遍历。
有一段时间我们遇到过一个问题:某合作渠道的回调接口频繁被刷,原因是回调地址泄露后,外部可以反复通知订单状态。后来我们在网关层对回调接口单独加了一个防重放机制:每个回调请求必须带nonce和timestamp,5分钟内同一个nonce不能重复使用。这个机制不复杂,但省了很多麻烦。
FastAPI里实现JWT校验的依赖很简单,核心代码如下:
from fastapi import Depends, HTTPException, Header import jwt async def get_current_user(authorization: str = Header(...)): token = authorization.removeprefix("Bearer ") try: payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) return {"user_id": payload["user_id"]} except jwt.ExpiredSignatureError: raise HTTPException(status_code=401, detail="token expired") except jwt.InvalidTokenError: raise HTTPException(status_code=401, detail="invalid token")这个依赖可以直接加到需要登录态的接口上,非常省事。
4.2 异步任务与订单通知链路
订单是零售系统的核心实体,稍有不慎就会引发资损。我们在设计下单链路时,没有把通知、库存同步、积分变动全部同步放在用户请求里,而是拆成了三道异步任务。
下单主接口做的事情很克制:校验商品状态、预占库存、创建订单记录、发送一条订单创建消息到Celery队列,立即返回“订单创建成功”。后面的任务按顺序执行:
- 支付回调任务:监听支付结果,更新订单状态。
- 履约分配任务:根据用户收货地址、门店库存和距离,分配履约门店。
- 通知任务:推送小程序订阅消息、短信通知、内部IM提醒。
消息队列用的是Celery + RabbitMQ,而不是Redis作为broker。原因是订单类消息不允许丢失,RabbitMQ的持久化和ack机制更可靠。如果只是行为事件这类允许丢失的消息,我们才用Redis Stream。
异步任务里最容易忽略的一点是幂等性。订单通知因为网络抖动可能重试多次,如果不做幂等,用户会收到重复短信。我们的做法是:在通知表里增加biz_id字段,任务执行前先查询该biz_id是否已经处理过,如果处理过则跳过。这里必须配合数据库唯一索引,才能在高并发下防住并发重复。
4.3 缓存、限流与降级策略
高并发场景下,缓存是整个平台的保命符。商品详情是高频访问点,我们把detail接口的缓存粒度控制在“字段级”。什么意思?就是商品基础信息、实时库存、活动价格分别缓存,而不是整个详情JSON缓存。因为库存变化频繁,如果整个JSON缓存,库存一更新就得重建缓存;分开缓存后,库存只刷新库存字段,商品信息的缓存可以很长时间不动。
库存扣减使用Redis Lua脚本保证原子性。脚本逻辑是:检查available_qty是否足够,足够则减去购买数量,同时增加locked_qty。Lua脚本在Redis单线程模型里天然原子,不会出现超卖。脚本大致如下:
local available = tonumber(redis.call('HGET', KEYS[1], 'available_qty')) local qty = tonumber(ARGV[1]) if available == nil or available < qty then return -1 end redis.call('HINCRBY', KEYS[1], 'available_qty', -qty) redis.call('HINCRBY', KEYS[1], 'locked_qty', qty) return 0限流方面,平台在网关层用了令牌桶,每个用户每分钟限制120次请求。推荐接口单独设置更严格的限制,因为推荐计算需要查Redis,不能让人刷接口把缓存打爆。超过限制时直接返回304给前端,提示“请求过于频繁”。
降级策略要提前设计好,不能等到线上出问题再写。我们定义了三类降级:推荐降级、库存降级、搜索降级。推荐引擎如果拿不到协同过滤结果,直接走冷启动规则;库存服务如果Redis超时,不再实时查库存,而是返回门店自提点附近的平均库存水位;搜索服务不可用,则返回用MySQL LIKE查询的热门商品。这些降级分支都写成了可配置开关,通过配置中心一键启停。
5. 性能优化与部署实践
5.1 数据库查询与索引优化
上线初期,平台在商品列表页出现了几次慢查询,每次都要几百毫秒。用slow_query_log抓到的问题SQL基本都是同一个模式:在product表上做多条件筛选,但没走索引。
商品列表常见的查询条件是category_id、brand_id、price_range、status,有时候还要按销量排序。优化思路是给组合查询建联合索引,而不是单独给每个字段建索引。我们实际加的索引是:
ALTER TABLE product ADD INDEX idx_category_brand_status (category_id, brand_id, status); ALTER TABLE sku ADD INDEX idx_spu_category_price (product_id, category_id, price);建完索引后,列表查询从几百毫秒降到了几十毫秒。但要注意,索引不是越多越好。写多读少的表,比如inventory,频繁更新索引反而会增加写入压力。库存表只在available_qty变化时更新,所以给库存表建索引要克制,只建sku_id + store_id的复合唯一索引就够了。
另一个优化思路是查询拆分。原来商品详情一个SQL要把product、sku、brand、category全部联表查出来,结果关联表多、效率低。后来拆成三次简单查询,第一次查product主信息,第二次查sku列表,第三次查品牌和类目名称,然后由Python代码组装返回。这样的好处是每次查询都能走主键索引,而且可以分别做缓存。
5.2 Gunicorn与容器化部署
接口层部署用的是FastAPI + Gunicorn + Uvicorn Worker。很多初学者会直接用uvicorn启动服务,但uvicorn是单进程的,扛不住多核压力。Gunicorn可以管理多个Worker进程,命令大致是:
gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4 -b 0.0.0.0:8000 --timeout 60Worker数量不是越大越好。我们的实践是:容器CPU核数乘以2再加1。如果容器是2核,Worker设为5个左右;再多不仅内存吃紧,进程切换也会拖慢响应。这里要特别注意超时设置。如果某个接口偶尔需要超过60秒处理,比如导出报表,就不适合放在API进程里,应该做成异步任务,否则Worker会被长时间占用。
部署我们用了Docker Compose管理,没有一上来就上Kubernetes。因为模拟项目规模不大,用容器化简化环境一致性就够用了。Dockerfile里有一个细节:不要把整个项目目录复制进镜像后再装依赖,而是先把requirements.txt复制进去装依赖,再把代码复制进去。这样做可以利用Docker层缓存,代码改动后不会重新装一遍依赖,部署速度会快很多。
5.3 监控告警与日志聚合
平台上线前我们就把监控体系搭好了,不然后期排查问题全靠猜。监控分三层:基础指标、业务指标、数据质量指标。
基础指标关注CPU、内存、磁盘IO、网络带宽,用Prometheus + node_exporter采集。业务指标关注接口QPS、响应时间、错误率,以及核心漏斗转化率:曝光到点击、点击到加购、加购到下单。数据质量指标对畅联智购这种多通道系统特别关键,包括:渠道同步失败率、SKU映射缺失率、库存同步延迟、推荐召回覆盖率。
日志聚合用了轻量方案:所有服务将日志以JSON格式写入stdout,用Filebeat收集后发送到Elasticsearch,再通过Grafana展示。每一条日志必须包含request_id,这个ID在网关层生成并透传到所有内部调用,这样排查一个请求的完整链路会高效很多。
告警规则里我最看重两条:库存同步延迟超过10分钟,以及订单支付回调延迟超过5分钟。这两条直接影响用户体验和资金安全,必须配置电话或者IM提醒。商品推荐点击率下降超过30%也需要告警,但不是因为服务器挂了,而是可能模型数据或者规则配置出了偏差。
6. 踩坑记录与代码片段详解
6.1 数据库连接池和事务的坑
上线初期,我们遇到一个诡异的问题:数据库时不时出现lost connection to MySQL server during query。排查后发现,不是数据库挂了,而是连接池中的连接被服务端空闲超时断掉后,Django ORM没有及时重建。
解决办法有三个层面:MySQL服务端wait_timeout调大一点,同时应用层在数据库初始化时配置连接池的回收时间,确保在服务端断开前回收。此外,为重要的读写路径增加连接预检,每次从连接池取连接时先执行SELECT 1,如果失败就重连。这个问题在低峰期几乎不出现,一到高峰期就容易触发,所以一定要在压测阶段就覆盖到连接池回收场景。
另一个事务相关的问题在后来的库存扣减中出现过:两个订单同时对同一SKU扣库存,如果代码里先查后扣,不保证原子性,会出现超卖。我们最开始用select_for_update锁行,看似解决了问题,但后来几个事务可能因为锁顺序不一致而死锁。例如事务A先锁sku_1再锁sku_2,事务B先锁sku_2再锁sku_1,互相等锁就死锁了。后来统一规定:所有涉及多库存扣减的操作,必须按sku_id升序加锁,死锁概率降到了零。
6.2 推荐候选取样过慢的优化
协同过滤刚上线时,推荐接口的响应特别慢,最差能到800毫秒。定位发现耗时不在于Redis读取相似商品,而在于“候选池生成”这一步。当时代码逻辑是:把用户有交互的商品的所有相似商品全部取出,再去重,结果一个活跃用户可能累积几千个候选商品,在线排序需要遍历这几千个商品并计算加权分,耗时自然高。
优化办法是给候选池做分层。离线任务已经为每个用户预先算好“每日Top200候选”,存到Redis里,在线接口直接读取这个候选列表。如果用户今天没有新行为,就直接用离线候选;如果用户在实时会话里有新点击,再把实时行为对应的相似商品插入候选列表,但总长度控制在300以内。这样在线排序的计算量从几千降到几百,响应时间压到70毫秒以内。
这个优化让我意识到,推荐系统的性能问题很多时候不是模型算力问题,而是工程上的数据规模控制问题。宁可牺牲一点点召回覆盖率,也要保证核心接口的响应时间稳定。
6.3 接口幂等性导致的重复订单
还有一次比较严重的线上事故:用户在小程序里点击“提交订单”后,因为前端网络抖动重试了两次,结果后端创建了两笔相同订单。虽然正常情况下用户不会这么操作,但小程序弱网环境下的重试机制是真实存在的。
解决办法是引入幂等键。前端在进入结算页时向服务端申请一个request_id,提交订单时把它作为报文字段传上来。后端在订单创建逻辑里,先查order_apply表,看request_id是否已经处理过,如果已处理,直接返回原订单号,而不是新建订单。这个表要建唯一索引:
ALTER TABLE order_apply ADD UNIQUE INDEX uk_request_id(request_id);幂等键要生成的足够随机,不能简单用用户id加时间戳,否则并发场景还是有撞车风险。我们用UUID,同时在网关层做了一次MD5,确保长度合适。这个方案上线后,重复订单问题再也没有出现过。
最后分享一个我自己踩出来的经验
畅联智购平台从0到1做下来,最大的体会是:做业务系统,最耗时间的不是写代码,而是梳理业务规则和统一数据口径。很多技术问题看起来是“性能问题”“算法问题”,真正追下去会发现问题出在“渠道A的价格单位是分,渠道B是元,我们没转干净”。所以如果你也准备做类似的多渠道聚合、智能导购项目,我建议你先花一半的精力去做数据规范和建模,哪怕推荐模型先用规则顶上,也要保证底层数据是一致的、干净的。
另一个非常实际的经验是:从最小闭环开始。不用一上来就规划十个微服务、三套推荐模型、复杂的消息架构。先把一条链路跑通:一个渠道的商品同步进来、用户在页面看到、能下单、能库存扣减、能履约,这就是一个完整的闭环。在这个闭环上再去加智能推荐、加多级缓存、加容器编排,每一步都有明确的业务收益,项目才不会做成一堆“造了一半的轮子”。
如果你现在正在做类似平台,希望这篇文章的踩坑记录和技术选型思路能帮你少走几段弯路。后续我会把推荐排序的在线性能调优和渠道数据一致性方案单独拆出来再写两篇,到时候可以直接对着里面的代码跑一版。