Spring Boot景点推荐系统实战:从数据库建模到协同过滤算法落地

发布时间:2026/10/16 14:37:50
Spring Boot景点推荐系统实战:从数据库建模到协同过滤算法落地 每年一到做项目的旺季总有人问“Spring Boot旅游景点推荐管理系统”该怎么做。大多数人第一反应是景点表加用户表套几个增删改查接口再写个前台页面和后台管理页这不就完了话是没错但你要是真照着这个思路做下去交付出来的大概率只是一个大号管理系统离标题里“推荐”这两个字差了十万八千里。这篇文章就以Spring Boot MyBatis-Plus MySQL Redis这套主流技术组合为例把这个系统的设计思路、数据库建模、推荐算法落地、后台管理和上线前后的优化完整拆一遍。项目我已经实际开发过一轮也踩过不少坑下面这些内容基本属于“照着做就能少走一半弯路”的程度。如果你正在做类似选题或想把普通CRUD项目升级成带推荐能力的完整产品这篇可以直接当参考。1. 需求拆解与整体架构设计1.1 项目到底要解决什么问题很多同学拿到“旅游景点推荐管理系统”这个题目第一件事就是打开数据库工具建表这是典型的顺序搞反。一个项目能不能站得住脚不在于表建得多花哨而在于需求有没有被真正拆透。这个标题里藏着三个角色游客、运营人员和管理员。游客要的是“打开系统后不用费劲搜索系统能主动告诉我哪些地方适合我”运营要的是“把优质景点推给合适的人最终提升收藏量、评论量和到访量”管理员要的是“景点信息维护、分类调整、用户管理、评论审核这些日常工作能在一个后台里高效完成”。一句话总结推荐不是锦上添花而是系统的核心业务能力。它要解决的是旅游信息过载的问题——当用户面对成百上千个景点时如何快速筛出他最可能感兴趣的若干条。再往细了说推荐功能不是拍脑袋从数据库里随机捞几条数据展示给用户。它至少需要覆盖四个场景新用户注册进来系统还没有他的行为数据怎么推荐老用户浏览收藏过一些景点系统怎么根据他的历史行为推荐新内容一个景点发布很久了浏览量高但收藏转化低怎么处理同一个分类下一次性推太多内容用户看腻了怎么办这些场景如果能在设计阶段就想清楚后面的开发就会非常顺。反之等代码写了一半再去思考“推荐”到底是什么多半只能拿热门榜单糊弄过去。1.2 前端与后端的功能边界怎么划分这个系统我建议采用前后端分离的结构后端统一提供RESTful接口前端独立部署。原因不复杂旅游系统的用户端需要频繁调整展示样式和交互方式管理端又要面对复杂表单和多维度的筛选条件两者风格差异很大硬耦合在一起会非常难受。用户端的功能列表大致是这样的注册、登录、个人偏好设置景点分类浏览、关键词搜索、多条件筛选景点详情页包含图片、介绍、开放时间、票价等收藏景点、发表评论、浏览历史记录首页推荐、分类推荐、附近推荐管理端的功能列表相对更集中景点信息的增删改查、上下架管理景点分类的树形结构维护用户账号的管理与禁用评论内容的审核与删除数据看板访问量、收藏量、推荐位点击量等核心指标推荐位的运营配置比如设置热门景点和置顶内容技术选型上后端我用Spring Boot MyBatis-Plus数据库MySQL缓存Redis权限认证用JWT。这套组合的好处在于生态成熟、上手资料多、出了问题容易排查而且MyBatis-Plus能把单表CRUD代码量压缩一大半把精力省下来放到推荐算法实现上性价比非常高。1.3 一次完整请求的链路理清链路比写代码更重要。以“首页推荐列表”为例一次请求会经过这样一条路浏览器发起请求Nginx转发给后端网关网关先校验JWT合法后进入ControllerController调用推荐ServiceService先查Redis缓存缓存没有则查数据库数据库返回后按照推荐规则组合数据最后包装成统一格式返回给前端。这条链路里最需要注意的有三点。第一权限校验要前置不能等业务代码执行完再判断。第二缓存和数据库的读取顺序要统一避免出现“先查库再回填缓存”和“先查缓存再查库”两种逻辑并存的情况。第三接口返回值必须一套格式前端才能统一处理否则一个项目里几百个接口各自返回不同结构联调阶段会让你怀疑人生。这里提前说一下我见过不少人把推荐接口直接做成“查景点表按浏览量排序”然后把热门榜标成推荐。这不是推荐这是列表排序。真正的推荐系统即使第一版很简单也要包含用户行为和内容特征的采集这部分在第3章我会详细展开。2. 数据库建模核心表结构与建模细节2.1 用户、景点、分类三类基础表数据库设计是这个系统中最不能图快的环节。一张表少一个字段后期补起来就得改代码、写数据迁移脚本成本成倍上升。我的建议是建表之前先把业务对象完整盘一遍。用户表是最简单的基本字段如下CREATE TABLE tb_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录账号, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(50) DEFAULT COMMENT 昵称, avatar VARCHAR(255) DEFAULT COMMENT 头像地址, gender TINYINT DEFAULT 0 COMMENT 0未知 1男 2女, city VARCHAR(50) DEFAULT COMMENT 常驻城市, preference_tags VARCHAR(255) DEFAULT COMMENT 偏好标签逗号分隔, status TINYINT DEFAULT 1 COMMENT 1正常 0禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );密码不要用MD5MD5撞库太容易了直接用Spring Security提供的BCryptPasswordEncoder安全性高一个档次。preference_tags这个字段很关键它在用户注册时收集一次后续会作为内容推荐的种子数据所以设计时要预留充足长度。景点表是信息量最大的表。这里我建议把图片字段拆开封面图一个字段详细图集一个字段用JSON字符串存URL数组避免为了多张图片单独建一张子表导致查询复杂度上升。CREATE TABLE tb_scenic ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 景点名称, category_id BIGINT NOT NULL COMMENT 所属分类, province VARCHAR(30) DEFAULT COMMENT 省份, city VARCHAR(30) DEFAULT COMMENT 城市, address VARCHAR(255) DEFAULT COMMENT 详细地址, cover_url VARCHAR(255) DEFAULT COMMENT 封面图, images TEXT COMMENT 图集JSON数组, price DECIMAL(10,2) DEFAULT 0.00 COMMENT 票价, score DECIMAL(3,1) DEFAULT 0.0 COMMENT 评分, open_time VARCHAR(50) DEFAULT COMMENT 开放时间, description TEXT COMMENT 详细介绍, view_count INT DEFAULT 0 COMMENT 浏览量, fav_count INT DEFAULT 0 COMMENT 收藏量, comment_count INT DEFAULT 0 COMMENT 评论量, weight INT DEFAULT 100 COMMENT 人工权重运营可调, status TINYINT DEFAULT 0 COMMENT 0下架 1上架, is_hot TINYINT DEFAULT 0 COMMENT 是否热门, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );weight字段是运营给景点设置的“人工推荐力度”优先级高于算法自动计算的分数。这个字段会直接参与推荐结果的排序后面讲算法时会用到。分类表用树形结构设计不做枚举。原因是旅游景点的分类粒度经常会调整比如“自然风光”下面可能要拆出“山岳”“湖泊”“森林”用枚举写死在代码里运营每次想调整都得找开发改版本极不友好。CREATE TABLE tb_category ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, parent_id BIGINT DEFAULT 0 COMMENT 父分类ID, sort INT DEFAULT 0 COMMENT 排序号 );2.2 用户行为数据怎么存推荐系统需要的用户行为数据主要有三类收藏、评论、浏览记录。这些数据不仅是页面上展示的功能更是推荐算法判断用户兴趣最重要的依据。收藏表要注意唯一索引防止同一个用户重复收藏同一个景点CREATE TABLE tb_favorite ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_scenic (user_id, scenic_id) );评论表用自关联设计支持回复功能CREATE TABLE tb_comment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, scenic_id BIGINT NOT NULL COMMENT 景点ID, user_id BIGINT NOT NULL COMMENT 评论人ID, content VARCHAR(500) NOT NULL COMMENT 评论内容, parent_id BIGINT DEFAULT 0 COMMENT 回复的评论ID0表示顶评, like_count INT DEFAULT 0 COMMENT 点赞数, status TINYINT DEFAULT 1 COMMENT 1显示 0隐藏, create_time DATETIME DEFAULT CURRENT_TIMESTAMP );浏览记录表是推荐算法训练的核心数据源。用户点击了哪个景点、停留了多久这些信息比用户自己填写的偏好标签更真实。设计上不要做物理删除保留全部历史记录后续如果要升级推荐策略这些原始数据就是最宝贵的资产。CREATE TABLE tb_browse_history ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, scenic_id BIGINT NOT NULL, browse_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_browse (user_id, browse_time) );我开发的时候在浏览记录表上加了联合索引 idx_user_browse因为之后查“某用户最近看过的景点”非常多没这个索引数据只要上万条查询延迟立刻翻好几倍。2.3 建模时最常见的几个坑第一个是时间字段类型。有人图省事用VARCHAR存字符串时间结果排序和区间查询全部出问题。时间字段一律用DATETIME且建表时设置默认值CURRENT_TIMESTAMP更新时用ON UPDATE CURRENT_TIMESTAMP自动刷新。第二个是价格字段。景区票价一定会打折、改价用FLOAT或DOUBLE存储会造成精度丢失必须用DECIMAL(10,2)。第三个是状态字段。景点表的status是上下架状态user表的status是账号状态评论表的status是显示状态不要偷懒省字段。推荐接口条件里永远要带着status判断避免把下架景点和隐藏评论推给用户。第四个是跟地理位置有关的字段。如果要实现“附近景点推荐”经纬度字段必须在设计阶段预留后面再加字段还要同步更新历史数据非常折腾。3. 推荐引擎的四种落地算法3.1 冷启动新用户进系统先推荐什么推荐系统第一个绕不开的问题就是冷启动。用户刚注册行为表里空空如也这时候给任何精确的个性化推荐都没有数据支撑。我的方案是“城市优先 热门兜底 随机打散”三层递进。第一层根据用户注册时填写的城市字段筛选出该城市的景点。第二层同一城市内将景点按热度分值排序但不要全部按分数降序。第三层在热门景点中做随机扰动保证每次推荐列表都不完全一样。具体来说热度分我放在Redis里维护用一个ZSet存储。Score的计算可以先用一个简单公式热度分 浏览量 0.3 收藏数 0.5 评论数 0.2。运营手动加权的景点在Score基础上再加上weight值。实现时我会维护一个定时任务每30分钟重新计算一次全量景点的热度分并写回Redis。新用户进入首页时后端接口直接从Redis里读取该城市热度最高的前10个景点加上随机偏移量返回。这套逻辑代码量不大但效果立竿见影。用户打开系统第一眼看到的是自己所在城市的热门景点推荐的可接受度远高于全国范围的随机内容。3.2 基于标签的内容推荐可解释性最强当用户产生了浏览、收藏行为后推荐系统就能开始“学习”兴趣了。第一种推荐策略是基于标签的内容推荐核心思路是找出用户喜欢过景点的共同标签然后推荐带这些标签的其他景点。这个策略在旅游场景下特别管用。比如用户收藏过“西湖”“乌镇”很容易推断出他对“江南水乡”和“自然风光”这类标签感兴趣。系统再推送同类标签下的其他景点解释起来也自然“因为你喜欢江南水乡类景点为你推荐以下内容”。实现时我给每个景点关联分类ID。用户在收藏或浏览景点后给该景点所属分类累加权重。分类权重的计算放在用户表或单独一张用户偏好表里。定期执行标签权重更新任务把结果存到Redis或一张user_tag_weight表里查询时直接取出Top5分类然后在景点表里按这些分类做召回最终用热度分排序。 这个方案理解成本低代码量少效果也不差。我这里给出一段简化的后端Service代码供参考public ListScenic recommendByTag(Long userId) { // 获取用户兴趣标签权重key为分类IDvalue为权重 MapLong, Integer tagWeight userTagService.getUserTagWeight(userId); if (tagWeight.isEmpty()) { return getHotScenicListByCity(cityOf(userId), 20); } ListLong categoryIds new ArrayList(tagWeight.keySet()); // 按分类召回待候选的景点池 ListScenic candidateList scenicMapper.selectByCategoryIds(categoryIds); // 按热度人工权重综合排序取前20 return candidateList.stream() .sorted(Comparator.comparing(this::hotScore).reversed()) .limit(20) .collect(Collectors.toList()); }3.3 基于用户的协同过滤从行为里找相似用户基于标签内容推荐有一个缺陷它只能推荐与用户历史兴趣相似的景点容易让人停留在“舒适区”。这时就要用到协同过滤它解决的是“发现新兴趣”的问题。协同过滤的基本思想是找与你行为模式相似的用户把那些用户喜欢但你还没看过的景点推荐给你。我在这个项目里用的是最简单的基于用户的协同过滤而且做出了适合课程设计和中小型项目落地的方式。第一步从收藏表和浏览记录表拉取所有用户-景点行为矩阵。第二步计算当前用户与其他用户的行为相似度这里我采用Jaccard系数两个用户收藏景点的交集数量除以两个用户收藏景点的并集数量。第三步找出最相似的N个用户他们收藏过但当前用户未收藏的景点作为候选。第四步对候选景点按热门程度和相似用户的数量加权生成推荐列表。这个过程的计算量集中在第二步所以生产环境不能实时计算。我在项目中采用离线计算策略每日凌晨定时任务跑一次相似用户计算把结果写入RedisKey为 userId:similar_usersValue是相似用户ID列表。用户请求推荐时会直接读取这些列表再动态召回景点数据。下面是相似度计算的简化逻辑public ListUserSimilarity calcSimilarity(Long userId, ListUserItem allUsers) { SetLong myItems getFavoriteScenicIds(userId); ListUserSimilarity result new ArrayList(); for (UserItem other : allUsers) { if (other.getUserId().equals(userId)) continue; SetLong otherItems other.getItemIds(); // Jaccard系数 double intersection myItems.stream().filter(otherItems::contains).count(); double union myItems.size() otherItems.size() - intersection; if (union 0) continue; result.add(new UserSimilarity(other.getUserId(), intersection / union)); } result.sort(Comparator.comparingDouble(UserSimilarity::getScore).reversed()); return result.stream().limit(10).collect(Collectors.toList()); }3.4 热度衰减与多样性控制推荐的最后一公里是排序和打散。如果一个景点发布后热度一直靠前它就会永久霸榜导致新景点永远没有出头之日。所以热度分一定不能是单纯的累加值需要引入时间衰减。我在项目里用的是类似评论排序的公式热度分 (浏览量 0.3 收藏数 0.5 评论数 0.2) / pow((发布至今的小时数 2), 1.5)这个公式的含义就是内容发布越久分母越大热度分会自然衰减。而浏览量等指标越高的内容抗衰减能力越强保证真正优质的内容依然能留在榜单上。另外推荐结果的多样性也要控制。如果推荐列表里8个景点全部是“山水自然”用户第一时间就会觉得系统只会做一种推荐。我的方案是同一分类下的推荐结果最多占列表数量的30%超过的部分用下一分类候补替换。实现时我维护一个HashMap记录每个分类已经出现的次数按热度分降序遍历候选景点当某个分类名额已满就跳过直到选满推荐列表长度。4. 后端核心业务接口实现4.1 统一返回格式与全局异常处理开发过程中最影响体验的事情之一就是接口返回格式不统一。有的接口返回直接是List有的返回对象有的报错时返回一段字符串到了前端联调阶段全是麻烦。我在设计接口时要求所有返回都走同一个Result统一封装类。Data public class ResultT { private Integer code; private String message; private T data; private Long timestamp; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(操作成功); result.setData(data); result.setTimestamp(System.currentTimeMillis()); return result; } public static T ResultT error(Integer code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); result.setTimestamp(System.currentTimeMillis()); return result; } }为了统一处理业务报错我会配合一个全局异常处理器让不同错误类型返回不同的状态码。比如参数校验失败返回400未登录返回401无权限返回403服务器异常返回500。前端只判断Result的code字段即可不用到处写try catch处理不同的异常结构。4.2 景点检索接口搜索、筛选、分页景点列表接口是使用频率最高的接口之一。用户在首页搜索框里输入关键词或者在分类页勾选城市、价格区间、评分星级系统要根据这些条件动态拼接查询。使用MyBatis-Plus的LambdaQueryWrapper可以很干净地完成多条件拼接从根本上杜绝SQL注入问题public PageScenic searchScenic(ScenicQuery query) { PageScenic page new Page(query.getPageNum(), query.getPageSize()); LambdaQueryWrapperScenic wrapper new LambdaQueryWrapper(); wrapper.eq(Scenic::getStatus, 1) .and(StringUtils.hasText(query.getKeyword()), w - w .like(Scenic::getName, query.getKeyword()) .or().like(Scenic::getDescription, query.getKeyword())) .eq(query.getCategoryId() ! null, Scenic::getCategoryId, query.getCategoryId()) .eq(StringUtils.hasText(query.getCity()), Scenic::getCity, query.getCity()) .ge(query.getMinPrice() ! null, Scenic::getPrice, query.getMinPrice()) .le(query.getMaxPrice() ! null, Scenic::getPrice, query.getMaxPrice()) .ge(query.getMinScore() ! null, Scenic::getScore, query.getMinScore()) .orderByDesc(Scenic::getWeight) .orderByDesc(Scenic::getViewCount); return scenicMapper.selectPage(page, wrapper); }排序上我默认把运营人工置顶的景点放最前面然后是浏览量高的自然排后面。搜索接口的响应时间必须控制住尤其是关键词搜索这种扫描范围较大的查询一定要在name和description字段上建立全文索引否则数据量上了十万条之后接口会非常吃力。4.3 推荐接口的多源数据拼装推荐列表不能只调用一种算法我用“多种召回 统一排序”的方式实现。具体来说推荐Service同时请求三个数据源基于标签内容推荐的召回结果基于协同过滤的召回结果基于热门榜单的兜底结果拿到三份候选数据后先做去重按景点ID合并。接着给每个数据源一个权重系数标签推荐权重最高协同过滤次之热门兜底最低。最终排序字段是综合权重乘以热度分加时间衰减取出前N条作为首页推荐列表。这样的好处是即使协同过滤召回的数据为空也有标签推荐顶上。标签推荐为空时还有热门推荐兜底。三个逻辑层层递进系统几乎不会给用户返回空列表。推荐接口还有一个细节接口需要返回推荐理由字段。我在响应对象中设计了reason字段比如“因为你关注了自然风光”或者“和你有相似喜好的用户也很喜欢这里”。这个字段成本很低但用户感知度提升非常明显。4.4 收藏与评论的并发事务处理收藏景点是一个典型的写操作。我的实现思路是先插入收藏记录靠唯一索引防重插入成功后对应景点的fav_count加一。代码里用事务包装这两个操作保证要么同时成功要么同时失败。但高并发情况下直接用一条SQL在收藏表执行insert另一个线程同时给fav_count做update可能出现计数不准确。一个简单的办法是直接把收藏数量的更新放在同一事务里Transactional(rollbackFor Exception.class) public void favorite(Long userId, Long scenicId) { try { Favorite favorite new Favorite(); favorite.setUserId(userId); favorite.setScenicId(scenicId); favoriteMapper.insert(favorite); scenicMapper.incrementFavCount(scenicId); } catch (DuplicateKeyException e) { throw new BizException(5001, 请勿重复收藏); } }评论的审核逻辑也要注意。用户提交评论后status先设为0管理端审核通过后改为1。未审核的评论前台一律不展示这个逻辑不写管理端的存在感就少了一半。5. 管理后台与运营数据看板5.1 权限控制与景点上下架流程管理后台使用的权限模型比较简单我采用的是角色标识加拦截器校验的方式没有引入整套用户权限框架避免给项目徒增复杂度。用户表增加role字段0代表普通游客1代表运营人员2代表管理员。管理接口统一在WebMvcConfig注册自定义拦截器专门拦截路径中包含/admin/的请求校验当前用户的角色权限没有权限直接返回403。前端再配合路由守卫把管理入口藏起来双端都做校验避免只靠前端隐藏而接口裸奔的问题。景点上下架功能看起来简单但它涉及到一个很容易被忽略的环节缓存清理。一个景点从“上架”改为“下架”后如果推荐列表已经缓存到Redis用户那边还是会看到这个景点点击详情才发现已经下架。所以我在上下架接口里必须同时删除该景点相关的Redis缓存Key包括推荐列表Key、详情缓存Key和热门榜单Key。图片上传也是一个容易出问题的模块。本地存储时图片文件名不能直接存原始中文名必须用UUID重命名防止跨平台编码问题。上传后返回给前端的不要是物理磁盘路径而是项目配置的虚拟访问路径后续如果切换到云存储改动成本会很低。5.2 运营看板的统计口径与SQL数据看板是运营人员的主要工作台包括用户总量、今日新增、访问趋势、收藏榜、热门景点排名这几个模块。统计SQL的核心是分组聚合。比如近7天的新增用户数SELECT DATE(create_time) AS day, COUNT(*) AS cnt FROM tb_user WHERE create_time DATE_SUB(CURDATE(), INTERVAL 7 DAY) GROUP BY DATE(create_time) ORDER BY day;热门景点排名直接查景点表的view_count、fav_count字段按综合权重排序。这里要注意一个问题如果每次看板请求都实时扫描全表数据量大时很慢。我的做法是每日凌晨把统计结果跑好存入一张统计汇总表运营后台第二天看到的全是昨天的数据。实时性要求不高的场景下完全够用。6. 缓存优化与常见问题排查6.1 Redis缓存热点数据的正确姿势推荐系统在技术层面最大的性能瓶颈往往是热点数据的重复查询。同一个热门景点可能同时被成千上万个用户点击查看详情每次都查MySQL显然扛不住。我的方案是给景点详情、首页推荐列表、城市热门榜都加上Redis缓存。缓存策略我用经典的Cache Aside模式读请求先查Redis未命中则查MySQL然后回填Redis写请求先更新数据库再删除Redis缓存。为什么不更新缓存而直接删除因为更新缓存还要考虑并发情况下的中间状态太容易出错删除后下次读请求自然回填简单可靠。缓存过期时间不是固定值。我给热门榜设置10分钟过期景点详情设置30分钟过期并加上随机过期时间防止大批量Key在同一时间失效导致MySQL被打满。随机范围一般控制在60秒到300秒之间这个细节在流量高的场景尤其重要。6.2 慢查询与深分页优化数据库层面的问题一般出现在列表接口。常见的深分页写法limit 100000, 20会让MySQL从第一行开始扫到十万行再跳过性能极差。我推荐两种优化方式。第一种是游标分页利用主键顺序跳转查询条件带上上次返回的最大ID直接执行WHERE id ? ORDER BY id DESC LIMIT 20。这种方式适合信息流推荐场景因为用户是不断往下刷不需要跳页。第二种是限制最大页码超过一定页数进行全表扫描也没意义直接提示“请使用筛选条件缩小范围”。运营后台的列表接口通常有大量筛选条件配合联合索引基本不会出问题。排查慢查询时我习惯先在MySQL执行explain查看执行计划重点看type字段是不是到了range或ref级别。如果全是ALL全表扫描第一件事就是检查索引是否生效而不是盲目改代码。6.3 几个真实的排坑经验第一个坑推荐结果里出现下架景点。这个问题我在联调时才遇到。原因是推荐列表缓存了景点数据但景点的状态已经在后台改为下架推荐接口读缓存时并没有校验状态字段。解决方法是推荐列表缓存里只存ID查询详情时再校验状态和缓存详情避免脏数据透出。第二个坑登录状态的token过期时间太短用户正在浏览景点详情突然弹出重新登录体验非常差。最后我把token有效期调整到7天同时加了Redis续期机制用户在活跃状态下不会被打断。第三个坑定时任务重复执行导致热门榜数据异常。部署时多个实例同时跑任务结果Redis里热门榜被反复覆盖。解决方法是给定时任务加分布式锁或者直接指定单实例执行具体用公司基础设施里现成的分布锁服务。第四个坑统计报表数据对不上。运营反馈后台收藏数和景点表fav_count不一致。排查后发现是事务没包住收藏记录插入成功但计数更新时出现异常导致数据不一致。加上事务后问题解决同时异常时抛错让前端也能感知。一个小总结做这类系统我个人的体会是别把推荐想得太玄乎。真正的推荐能力是从最简单的地方长出来的先有行为数据采集再有规则推荐然后逐步引入标签相似度和协同过滤。好多组件的升级不是推翻重来而是在原打法上不断叠加。只要数据和缓存设计留好了余地后续算法升级就是多写几个类的事。如果你现在正卡在项目开发的某个环节优先检查三个地方数据库索引有没有建全、推荐接口有没有兜底逻辑、管理端操作有没有把缓存清理干净。这三个地方不出问题整个系统基本就能稳定跑起来了。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询