资讯详情

资讯详情

微擎小程序商城首页推荐位后端设计:从数据库到缓存的全链路实战

1. 从零拆解商城首页推荐位的后端设计逻辑做微擎模块开发这些年我经手过的小程序商城少说也有几十个首页推荐商品这个模块看着简单实际上是最容易出问题的地方。为什么这么说因为首页推荐位是整个商城的流量入口用户打开小程序第一眼看到的就是它加载速度、数据准确性、排序逻辑任何一个环节掉链子转化率直接往下掉。今天这篇主要聊后端前端那块后面另开一篇细说。先明确一下我们要做的事情微擎模块里小程序商城首页需要展示一组推荐商品这些商品的数据从哪来、怎么查、怎么组装、怎么返回给前端这一整套流程就是后端接口要解决的问题。听起来像是“查个数据库返回JSON”就完事了如果你这么想上线之后大概率会遇到几个经典问题推荐位商品加载慢导致首页白屏、商品下架了推荐位还显示、不同用户看到的推荐商品完全一样导致转化率低、分页加载时数据重复或丢失。这些问题的根源都不在“查询”本身而在于你有没有把推荐位的后端逻辑当成一个独立的业务模块来设计。我的做法是把它拆成四层数据层负责商品基础信息的存储和索引逻辑层处理推荐规则和过滤条件接口层负责参数校验和数据组装缓存层扛住首页的高频访问。这四层各司其职后面排查问题的时候你就能快速定位到底是哪一层出了毛病。注意很多新手直接把商品表的查询逻辑写在接口文件里这在商品数量少的时候没问题一旦商品过千、推荐规则变复杂代码会变得完全不可维护。分层不是过度设计是给自己留后路。1.1 推荐位和普通商品列表的本质区别普通商品列表是“用户主动找商品”推荐位是“商品主动找用户”。这个区别决定了后端设计的思路完全不同。普通列表的核心是筛选和排序用户点了分类、选了价格区间、翻了页码后端照着条件查就行。推荐位不一样它没有用户主动输入的筛选条件所有的规则都是后端预设的而且首页的访问频率远高于任何一个分类页。这意味着推荐位接口必须做到两件事第一是快首页接口的响应时间最好控制在200毫秒以内超过500毫秒用户就能感知到卡顿第二是稳推荐位挂了整个首页就废了不能因为某个商品数据异常导致整个接口报错。我一般会在接口层做降级处理推荐位查不到数据的时候返回一个默认的热销列表至少保证首页不白屏。另一个区别是数据量。普通列表可以分页查每次查20条用户翻到第几页就查第几页。推荐位通常只展示4到8个商品但候选池可能很大你需要从几百个符合条件的商品里挑出最优的几个。这就涉及到推荐算法的问题了后面会详细讲。1.2 微擎环境下后端接口的特殊考量微擎这个框架有它自己的特点做模块开发的时候有几个地方跟原生开发不太一样。首先是数据库操作微擎封装了自己的数据库操作类用pdo_get、pdo_getall、pdo_fetchall这些函数底层还是PDO但表名前缀是自动处理的。你写SQL的时候不用管ims_这个前缀微擎会帮你加上。其次是模块的目录结构。微擎模块的后端接口一般放在module.php或者单独的site.php里小程序端调用的接口通常走app/目录下的控制器。我习惯把推荐位的逻辑单独抽一个类出来放在core/或者library/目录下接口文件只负责接收参数和返回JSON业务逻辑全部交给这个类处理。这样做的好处是以后要改推荐规则只需要动一个文件不用在接口文件里翻来翻去。还有一点是微擎的权限体系。小程序端调接口的时候微擎会通过$_W[uniacid]来区分不同的公众号/小程序实例。推荐位的查询必须带上这个uniacid条件否则A小程序的推荐商品可能跑到B小程序里去这种bug一旦出现就是灾难性的。我见过有人忘了加这个条件测试的时候没发现上线之后两个客户的数据串了排查了一整天才找到原因。2. 数据库设计与推荐商品数据模型推荐位的后端设计第一步就是把数据表结构定好。表结构定错了后面写再多代码都是白搭。我见过太多项目因为一开始表没设计好后面加字段、改索引、迁移数据折腾得死去活来。2.1 商品主表与推荐关联表的分工商品的基础信息肯定存在商品主表里这个没什么争议。商品名称、价格、库存、主图、分类ID、上下架状态这些字段都在主表。推荐位需要额外记录的信息包括这个商品在推荐位里的排序权重、推荐位的类型比如首页顶部轮播、中部精选、底部猜你喜欢、推荐的开始和结束时间、推荐位的状态。这些信息要不要直接加在商品主表里我的建议是不要。原因很简单一个商品可能同时出现在多个推荐位里每个推荐位的排序权重可能不一样。如果你把推荐信息塞进商品主表就只能支持一个推荐位扩展性太差。正确的做法是单独建一张推荐关联表结构大概是这样的CREATE TABLE ims_mall_recommend ( id int(11) NOT NULL AUTO_INCREMENT, uniacid int(11) NOT NULL DEFAULT 0, goods_id int(11) NOT NULL DEFAULT 0, position varchar(50) NOT NULL DEFAULT home_top, sort_order int(11) NOT NULL DEFAULT 0, start_time int(11) NOT NULL DEFAULT 0, end_time int(11) NOT NULL DEFAULT 0, status tinyint(1) NOT NULL DEFAULT 1, create_time int(11) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_uniacid_position (uniacid,position,status), KEY idx_goods (goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表的核心字段是position和sort_order。position用来区分不同的推荐位比如home_top表示首页顶部home_middle表示首页中部。sort_order是排序权重数值越大排越前面。start_time和end_time控制推荐的有效期过了期自动不显示不需要人工去下架。提示uniacid和position的联合索引一定要加推荐位查询几乎每次都会用到这两个条件。没有这个索引商品数量上来之后查询会明显变慢。2.2 商品状态与推荐状态的联动逻辑这里有一个很容易踩的坑推荐关联表里记录了商品ID但商品本身可能被下架了、删除了、或者库存为零了。推荐位查询的时候必须把这些无效商品过滤掉否则用户点进去看到的是一个已下架的商品体验极差。我的做法是在查询的时候做联表用INNER JOIN把商品主表关联进来然后在WHERE条件里加上商品状态判断。具体来说商品主表里一般有status字段1表示上架0表示下架和deleted字段1表示已删除。推荐位查询的条件大概是$condition r.uniacid :uniacid AND r.position :position AND r.status 1 AND r.start_time :now AND r.end_time :now AND g.status 1 AND g.deleted 0;这样写的好处是推荐关联表只负责“推荐关系”商品是否有效由商品主表来决定。两边各管各的逻辑清晰。如果你把商品状态也冗余到推荐关联表里商品下架的时候还得同步更新推荐表维护成本高不说还容易漏掉。还有一个细节是库存。库存为零的商品要不要在推荐位显示这个取决于业务需求。有些商城允许零库存商品展示但不可购买有些则直接隐藏。我一般会在推荐位查询里加一个可配置的开关默认过滤掉零库存商品但运营可以在后台选择是否显示。2.3 推荐位数据的索引优化策略推荐位查询的性能瓶颈通常在排序和分页上。如果推荐位只展示前8个商品那ORDER BY sort_order DESC LIMIT 8这种查询在有索引的情况下是很快的。但如果推荐位需要支持“换一批”功能每次换一批要跳过前面已经展示过的商品这就涉及到偏移量的问题。LIMIT 8 OFFSET 100这种查询在数据量大的时候会越来越慢因为数据库需要先扫描前100条记录再丢弃。解决办法是用游标分页记录上一批最后一个商品的sort_order值下一批从那个值之后开始查。比如上一批最后一个商品的sort_order是50下一批就查sort_order 50的记录这样每次查询都是走索引的不会随着偏移量增大而变慢。另外如果推荐位有“随机推荐”的需求不要用ORDER BY RAND()这个函数在数据量大的时候性能极差因为它需要给每一行生成一个随机数然后排序。更好的做法是先用COUNT查出总数然后随机生成几个偏移量用LIMIT去取。或者维护一个随机排序字段定期更新。3. 推荐接口的核心逻辑与参数设计接口设计这块我踩过的坑最多。早期做项目的时候接口参数想到哪写到哪后来前端换了一波人对接的时候各种问题。从那以后我就养成了一个习惯接口参数必须提前定好写进文档前后端一起评审。3.1 接口路由与请求参数规范微擎小程序端的接口路由一般是这样的/app/index.php?i1centrymmalldorecommendopgetList。其中i是uniacidcentry表示走模块入口mmall是模块名称dorecommend是控制器opgetList是方法。这套路由规则是微擎固定的你只需要按照这个格式来写就行。请求参数方面推荐位接口一般需要这几个参数名类型必填说明positionstring是推荐位标识如home_toppageint否页码默认1page_sizeint否每页数量默认8最大20last_sortint否游标分页时上一批最后的排序值timestampint是请求时间戳用于防重放signstring是签名防止参数被篡改position参数是必须的因为一个商城可能有多个推荐位前端需要告诉后端要取哪个位置的数据。page和page_size控制分页但我不建议把page_size设得太大首页推荐位展示8个就够了设成20反而影响加载速度。timestamp和sign这两个参数是安全相关的。timestamp用来防止请求被重放服务端会检查时间戳和当前时间的差值超过一定范围比如5分钟就拒绝。sign是对所有参数按字母排序后拼接密钥做MD5确保参数没有被篡改。这两个参数在微擎模块里不是必须的但涉及到商品价格、库存这种敏感数据的时候加上会更安全。3.2 推荐商品查询的完整SQL与条件组装推荐位查询的SQL我一般会封装成一个方法接收uniacid、position、limit、offset这几个参数返回商品列表。完整的SQL大概是这样的public function getRecommendGoods($uniacid, $position, $limit 8, $lastSort 0) { $now time(); $sql SELECT g.id, g.title, g.price, g.market_price, g.thumb, g.sales, r.sort_order, r.position FROM . tablename(mall_recommend) . r INNER JOIN . tablename(mall_goods) . g ON r.goods_id g.id WHERE r.uniacid :uniacid AND r.position :position AND r.status 1 AND r.start_time :now AND r.end_time :now AND g.status 1 AND g.deleted 0 AND g.stock 0; if ($lastSort 0) { $sql . AND r.sort_order :last_sort; } $sql . ORDER BY r.sort_order DESC, r.id DESC LIMIT . intval($limit); $params array( :uniacid $uniacid, :position $position, :now $now ); if ($lastSort 0) { $params[:last_sort] $lastSort; } return pdo_fetchall($sql, $params); }这段代码有几个细节值得说。第一ORDER BY用了sort_order DESC, id DESC加id是为了防止sort_order相同的时候排序不稳定导致分页时数据重复或丢失。第二LIMIT的值用了intval强制转整数防止SQL注入。第三游标分页的条件r.sort_order :last_sort是在参数绑定之后才拼上去的这样能保证参数绑定的顺序正确。注意微擎的pdo_fetchall函数在参数绑定的时候如果SQL里用了命名占位符参数数组的键名必须带冒号。这个细节很容易忽略不带冒号的话微擎不会报错但查询结果会是空的排查起来很费时间。3.3 返回数据的组装与格式化查询出来的原始数据不能直接返回给前端需要做一层格式化。格式化的内容包括价格从分转成元、图片URL补全域名、商品链接拼接、是否包邮等营销标签的组装。价格格式化是最容易出问题的。微擎商城的商品价格一般以分为单位存储在数据库里返回给前端的时候需要除以100。但要注意浮点数精度问题19.99在计算机里可能存储为19.989999直接返回给前端显示会变成19.98。我的做法是用number_format($price / 100, 2, ., )来格式化确保始终保留两位小数。图片URL的处理也很关键。数据库里存的是相对路径比如images/goods/123.jpg前端需要完整的URL才能加载。微擎里可以用tomedia()函数把相对路径转成完整URL这个函数会自动加上附件域名。如果商品有多张图片主图取第一张轮播图取全部。商品链接的拼接要看小程序的页面结构。一般来说商品详情页的路径是/pages/goods/detail?idxxx后端返回的时候可以直接拼好前端拿到就能跳转。但我不建议后端拼链接因为前端页面路径可能会变后端拼死了之后改起来麻烦。更好的做法是返回商品ID前端自己根据ID拼链接。4. 缓存策略与性能优化实战首页推荐位接口的QPS每秒查询数远高于其他接口因为每个用户打开小程序都会请求一次。如果不做缓存数据库压力会很大。我做过一个粗略的估算假设日活1万平均每个用户打开小程序3次那就是3万次请求。如果这些请求全部打到数据库按每次查询50毫秒算数据库每秒要处理大约0.35次查询看起来不多但考虑到请求集中在早晚高峰实际峰值可能是平均值的10倍以上。4.1 微擎缓存机制的选择与配置微擎支持多种缓存方式包括文件缓存、Memcache、Redis。文件缓存是默认的不需要额外配置但性能一般适合小规模应用。Redis性能最好但需要服务器上安装Redis服务。我的建议是如果日活超过5000直接上Redis不要犹豫。微擎里使用缓存很简单用cache_write和cache_read这两个函数就行。推荐位的数据缓存时间一般设5到10分钟太短了起不到缓存效果太长了运营改了推荐商品之后用户看不到更新。我一般设300秒也就是5分钟。$cache_key mall_recommend_ . $uniacid . _ . $position; $cache_data cache_read($cache_key); if (empty($cache_data)) { $cache_data $this-getRecommendGoods($uniacid, $position); cache_write($cache_key, $cache_data, 300); } return $cache_data;这段代码的逻辑是先读缓存缓存没有就查数据库查完写回缓存。看起来很简单但有一个坑如果推荐位数据为空cache_read返回的也是空这时候会每次都去查数据库。解决办法是用一个特殊的标记值来表示“空结果”比如存一个[empty true]读到这个标记就直接返回空数组不再查数据库。4.2 缓存更新的触发时机与实现缓存最大的问题是数据一致性。运营在后台把某个商品从推荐位移除了但缓存还没过期用户还能看到这个商品。这个问题怎么解决我的做法是在推荐位的增删改操作里主动清除缓存。微擎模块里后台的推荐位管理页面在保存数据之后调用cache_delete把对应的缓存键删掉。这样下次请求就会重新查数据库拿到最新的数据。// 后台保存推荐位之后 $cache_key mall_recommend_ . $uniacid . _ . $position; cache_delete($cache_key);但这里还有一个问题如果推荐位有多个position你改了一个商品它可能同时属于多个推荐位你需要把所有相关的缓存都清掉。我的做法是在推荐关联表里记录这个商品涉及的所有position更新的时候遍历清除。或者简单粗暴一点直接清空所有推荐位缓存反正缓存重建的成本不高。提示如果你的商城有多个服务器节点文件缓存和Memcache可能不同步因为每台服务器上的缓存是独立的。这种情况下必须用Redis因为Redis是集中式的所有节点读的是同一份缓存。4.3 接口响应速度的实测与调优缓存加上之后接口的响应速度会有明显提升。我做过对比测试不加缓存的情况下推荐位接口的平均响应时间是180毫秒加了Redis缓存之后降到25毫秒左右。这个提升在首页这种高频接口上是非常可观的。但缓存不是万能的有几个地方还是要注意。第一缓存键的设计要合理不能太长也不能太短。太长了占用内存太短了容易冲突。我一般用模块名_功能_参数的格式比如mall_recommend_1_home_top。第二缓存的数据量要控制推荐位一般就8个商品数据量很小但如果你的推荐位返回了商品详情、评价等大量数据缓存的价值就会打折扣因为序列化和反序列化本身也要耗时。还有一个容易被忽略的点是缓存穿透。如果某个position根本不存在每次请求都会查数据库然后返回空缓存也存不住。解决办法是在接口层做参数校验position不在允许的列表里就直接返回错误不查数据库。5. 常见问题排查与避坑经验实录做推荐位后端开发这些年遇到的问题五花八门我挑几个最有代表性的整理出来都是实际踩过的坑希望能帮你少走弯路。5.1 推荐位商品不显示或显示不全的排查思路这是最常见的问题用户反馈“首页推荐位是空的”或者“只显示了3个商品应该有8个”。排查的时候按这个顺序来第一步检查推荐关联表里有没有数据。直接查数据库SELECT * FROM ims_mall_recommend WHERE uniacid 1 AND position home_top AND status 1。如果没有数据那就是运营没配置或者配置的时候status没设成1。第二步检查时间范围。start_time和end_time是不是覆盖了当前时间。我见过有人把end_time设成了0以为是“永不过期”结果查询条件end_time now直接把所有记录都过滤掉了。正确的做法是end_time设一个很大的值比如2147483647。第三步检查商品状态。推荐关联表里有数据但关联的商品可能被下架了、删除了、或者库存为零。用INNER JOIN查一下看看关联的商品是不是都满足status 1 AND deleted 0 AND stock 0。第四步检查缓存。如果前面三步都没问题那可能是缓存里的数据是旧的。手动删掉缓存再试一次。问题现象可能原因排查方法推荐位完全为空推荐表无数据查ims_mall_recommend表推荐位完全为空时间范围不对检查start_time和end_time推荐位完全为空缓存了空结果删除缓存后重试显示数量少于预期商品被下架联表查商品状态显示数量少于预期库存为零被过滤检查stock字段显示数量少于预期分页参数错误检查limit和offset5.2 接口返回数据错乱的几种典型场景数据错乱比数据不显示更可怕因为用户看到的是错误的信息可能直接导致投诉。我遇到过几种典型场景第一种是uniacid条件漏了。A小程序的推荐位显示了B小程序的商品这种问题在测试环境很难发现因为测试的时候通常只有一个uniacid。上线之后多租户同时使用问题就暴露了。解决办法是在所有查询里强制加上uniacid条件并且在代码审查的时候重点检查。第二种是排序不稳定导致的分页重复。ORDER BY sort_order DESC的时候如果两个商品的sort_order相同数据库返回的顺序是不确定的。第一页可能返回了商品A和B第二页可能又返回了B和C。解决办法是在排序字段后面加上id DESC确保排序是确定的。第三种是缓存键冲突。两个不同的position用了同一个缓存键导致数据串了。比如home_top和home_middle都用了mall_recommend_1这个键那先请求的那个会把数据写进缓存后请求的读到的是错误的数据。解决办法是缓存键里必须包含position参数。5.3 推荐位接口的安全防护要点推荐位接口虽然是只读接口但也不能掉以轻心。我见过有人通过遍历position参数来探测商城有哪些推荐位然后批量抓取商品数据。虽然商品数据本身是公开的但被批量抓取对服务器压力很大。基本的防护措施包括限制page_size的最大值比如最大20防止有人传page_size10000把数据库拖垮对position参数做白名单校验只允许预定义的几个值加频率限制同一个IP每秒最多请求10次。如果推荐位涉及个性化推荐比如根据用户浏览历史推荐那还需要加用户身份校验确保用户只能看到自己的推荐数据。微擎里可以通过$_W[openid]来获取当前用户的身份但要注意这个值是从session里取的如果session过期了就拿不到。注意不要在前端直接暴露推荐位的原始SQL或数据库字段名。返回给前端的JSON里只包含必要的字段比如商品ID、名称、价格、图片不要返回sort_order、status这些内部字段减少信息泄露的风险。5.4 高频踩坑点速查与经验总结最后整理一个速查表都是我在实际项目中踩过的坑每条都对应一个具体的解决方案踩坑点现象解决方案忘记加uniacid多租户数据串所有查询强制加uniacid条件end_time设为0推荐位永远为空end_time设为大数值缓存空结果每次都查数据库用特殊标记表示空结果排序不稳定分页数据重复ORDER BY加id字段缓存键冲突数据错乱缓存键包含所有查询参数库存未过滤显示零库存商品WHERE加stock 0图片路径未补全图片加载失败用tomedia()转完整URL价格精度丢失显示价格错误用number_format格式化这些坑看起来都不复杂但每一个都是我花了好几个小时甚至一整天排查出来的。推荐位后端开发的技术难度不高难的是细节的把握和异常情况的处理。把上面这些点都考虑到你的推荐位接口基本上就能稳定运行了。后续如果要做个性化推荐比如根据用户的浏览记录、购买记录来动态调整推荐商品那后端的复杂度会上升一个量级。到时候需要引入用户画像、商品标签、召回排序等一系列机制那是另一个话题了。但不管推荐算法多复杂底层的查询、缓存、接口组装这套逻辑是不变的把基础打牢后面扩展起来才顺手。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →