ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Web的美食探店平台开发与性能优化实战

基于Web的美食探店平台开发与性能优化实战 1. 项目定位与核心需求拆解1.1 从“搜店”到“探店”这个平台到底在解决什么问题先说清楚这个项目是干什么的。市面上美食类产品其实已经很多大众点评、小红书、抖音本地生活都在做内容种草和门店引流。那为什么还要自己动手做一个基于web的美食探店平台我做这个项目的初衷是发现一个很具体的痛点小体量商家和社区型美食店的线上曝光路径是断裂的。大平台流量分配机制复杂新店冷启动困难用户搜到的永远是那几家评分最高的连锁店真正有特色的社区老店反而沉在底下。这个项目的核心定位不是“又一个点评系统”而是做一个探店内容驱动、位置属性明确、互动反馈即时的web端平台。用户可以在上面发布探店图文按位置和标签筛选门店收藏、点赞、评论商家侧有完整的信息展示页管理员后台负责内容审核和门店数据维护。整个项目围绕“人、店、内容”三个核心实体展开通过web端web页面统一承载这也是标题里“基于web”三个字的含义——不做原生App一个浏览器全搞定。适合谁来参考这个项目如果你正在做毕业设计需要一个功能完整、技术栈主流、演示效果好web项目这个选题很合适如果你是想练手的企业级web开发者想完整走一遍从需求分析到部署上线的流程这个项目也能给你不少可复用的经验。我在设计的时候刻意考虑了两个指标功能覆盖度要够广能展示的内容丰富技术难度要可控不至于让新手做到一半想放弃。1.2 角色边界与功能矩阵做系统设计的第一步永远是划分角色角色定了功能边界就定了。这个平台我划分了三种角色分别是游客、注册用户、平台管理员。这里有个容易被忽略的设计细节游客只给浏览权限注册用户可以发布探店内容管理员负责审核与门店管理。为什么要这样设第一内容质量可控。探店文如果谁都能发、发完就上墙平台会迅速被广告和低质内容淹没。引入审核机制后注册用户的探店文先进入待审状态管理员通过后才会展示在信息流中这是很多初级项目根本没考虑到的环节。第二权限边界清晰开发时不容易乱。前端的路由守卫、后端的方法级权限都可以按角色对号入座调试起来非常方便。功能矩阵我按模块梳理如下用户模块注册登录邮箱/用户名、个人信息维护、我的收藏、我的发布、关注关系门店模块门店信息展示名称、地址、分类、人均消费、营业时间、门店图片、门店搜索与筛选、附近门店按距离排序探店内容模块图文探店发布、内容审核、信息流展示、点赞、收藏、评论管理后台模块门店数据维护、探店内容审核、用户管理、基础数据统计这个功能矩阵在你写需求文档的时候可以直接用不用重新想。每个模块开发的时候再拆细但顶层结构保持这样逻辑清晰演示的时候也方便一条线走完用户登录 - 发布探店 - 管理员审核 - 前台展示 - 其他用户互动这个流程跑通项目就已经完成了八成。1.3 技术选型为什么选这套组合而不是别的技术选型是每个web项目里最容易被问“为什么”的环节。我最终定的组合是前端 Vue 3 Element Plus Vite后端 Spring Boot 2.7 MyBatis-Plus MySQL Redis搜索场景引入 Elasticsearch部署环境是 Nginx 阿里云轻量服务器。逐一说理由。前端选 Vue 3 而不是 React不是因为 React 不好而是在这个项目的场景下Vue 生态的中文资料更全配合 Element Plus 做后台管理系统几乎是开箱即用。Vite 相比 Webpack 的构建速度快得太明显了热更新基本毫秒级开发体验不是一个档次。这套组合在你做毕设答辩时也容易讲组合式的开发效率、响应式工具链、组件化开发思想每一点都能展开说。后端选 Spring Boot 是目前 web项目里最稳的选择没有之一。它的自动配置机制让环境搭建五分钟就能搞定内置 Tomcat 也让部署省去额外配置。MyBatis-Plus 比原生 MyBatis 强在单表 CRUD 不用写 SQL但复杂查询仍然可以手写 SQL保留灵活性。MySQL 存结构化数据Redis 做缓存和热点数据存储Elasticsearch 解决搜索和地理位置筛选。我做一个技术选型的对比表格方便你答辩时或者写文档时直接用技术栈选型方案备选方案选择理由前端框架Vue 3React 18学习曲线平缓中文生态好配合 Element Plus 效率高构建工具ViteWebpack开发服务器热更新快配置更简洁后端框架Spring Boot 2.7SSM 传统组合自动配置省事社区庞大问题排查容易持久层MyBatis-PlusSpring Data JPA复杂 SQL 可控性强单表操作封装完善数据库MySQL 8.0PostgreSQL主流稳妥文档齐全部署方便缓存Redis无点赞、排行、登录态存储需要搜索引擎Elasticsearch 7MySQL 模糊查询地理位置筛选和全文搜索性能优势明显部署Nginx 服务器Docker Compose静态资源分离反向代理配置简单这套组合还有一个隐藏优势每一层都有清晰的“为什么选它”的逻辑而不是拍脑袋定的。比如地理位置搜索最初我用 MySQL 的ST_Distance函数实现附近门店查询数据量到一万条后响应速度明显下降才下定决心引入 Elasticsearch。这种优化过程和故事性反而是项目经验分享里最值钱的部分后面我会展开讲。2. 系统架构设计与核心功能实现2.1 前后端分离架构下的工程组织方式整个项目采用前后端完全分离的开发模式前端工程和后端工程是两个独立仓库通过 RESTful API 通信。前端跑在 5173 端口后端跑在 8080 端口开发时用 Vite 的 proxy 配置做跨域代理生产环境则由 Nginx 统一反向代理把/api前缀的请求转发到后端服务。工程目录结构是这样划分的。前端web-food-frontend/ ├── public/ ├── src/ │ ├── api/ # 所有接口请求封装 │ ├── assets/ # 静态资源 │ ├── components/ # 通用组件 │ ├── router/ # 路由配置 │ ├── stores/ # Pinia 状态管理 │ ├── views/ # 页面视图 │ ├── utils/ # 工具函数token处理、axios拦截器 │ ├── App.vue │ └── main.js ├── vite.config.js └── package.json后端采用标准的 Maven 多模块结构food-backend/ ├── src/main/java/com/food/ │ ├── config/ # 配置类Redis、CORS、拦截器 │ ├── controller/ # 控制层 │ ├── service/ # 业务逻辑层 │ ├── mapper/ # 数据访问层 │ ├── entity/ # 实体类 │ ├── dto/ # 数据传输对象 │ ├── vo/ # 视图对象 │ ├── common/ # 通用类统一返回结果、异常处理 │ └── utils/ # 工具类JWT、文件上传 └── src/main/resources/ ├── mapper/ # MyBatis XML 映射文件 └── application.yml为什么要有 entity / dto / vo 这么一层划分很多新手项目会把数据库实体直接返回给前端这是坑。数据库字段往往有敏感信息比如用户表里的密码哈希值直接序列化返回等于把口子敞开。我的做法是 controller 层一律返回 VO把需要展示的字段组装好DTO 用于接收前端请求参数entity 只在 service 层内部流转。层与层之间职责清晰后面加字段、改需求的时候不用翻遍全项目找影响范围。2.2 统一响应结构与全局异常处理的设计前后端分离之后接口契约必须非常稳定。我定了一套统一的响应结构{ code: 200, message: success, data: {} }code 为 200 表示成功其它为各类错误码。400 参数错误401 未登录403 无权限500 服务器内部错误。前端在 axios 响应拦截器里统一判断 code不是 200 就弹 Message 提示不用每个页面单独写错误处理这个约定完成后几乎所有页面的请求逻辑都能简化为“调接口、拿数据、渲染页面”三步操作。全局异常处理用RestControllerAdvice实现。为什么必须做全局异常处理我给你举一个实际场景用户发布探店内容时请求 body 里可能有缺失字段、非法枚举值、内容超长等一堆问题如果不统一拦截每个 controller 里都得 try-catch代码又臭又长还容易漏。统一之后我在自定义异常类 BizException 中携带错误码和提示语业务逻辑里直接throw new BizException(ErrorCode.PARAM_ERROR, 门店名称不能为空)全局处理器捕获后自动转换为统一的错误 JSON 返回。调试和定位问题都要轻松很多。2.3 核心功能链路从发布探店文到信息流展示整个平台体验最好的核心链路是这样一个闭环用户登录后点击“发布探店”填写门店选择、探店标题、正文内容、图片提交后文章状态变为PENDING管理员在后台审核通过后状态变PUBLISHED信息流查询接口只返回已审核通过的内容。我用一张状态流转来描述DRAFT(草稿) - PENDING(待审核) - PUBLISHED(已发布) \- REJECTED(已驳回)这里有一个我特意加的设计审核驳回时管理员必须填写驳回原因系统自动通知用户。这个功能看起来不起眼但实际运营时非常重要——用户知道自己为什么被拒才能修改后重新提交。做项目不能只做表面功能用户被拒绝后的反馈路径才是完整的业务流程。发布探店文的接口请求数据结构大致如下POST /api/articles { shopId: 1024, title: 藏在巷子里的二十年老面馆, content: 如果不是本地人带路..., coverImage: https://cdn.example.com/images/xxx.jpg, images: [https://cdn.example.com/images/xxx1.jpg], tags: [面馆, 老店, 性价比] }后端接收到请求后做的处理顺序是校验字段合法性 - 校验门店是否存在 - 保存至文章主表和标签关联表 - 将探店文档写入 Elasticsearch 索引状态标记为待审核 - 返回文章 ID。这里有个细节既然信息流查的是审核通过的内容为什么发布时就把文档写入 Elasticsearch因为后期搜索直接查 ES 索引不能等审核通过后再补写异步双写更容易造成数据不一致。我采用的做法是 ES 文档中包含status字段查询时过滤status PUBLISHED这样无论审核状态如何变更最终展示的数据始终是对的。2.4 门店搜索与地理位置应用的落地门店搜索是整个平台使用频率最高的功能也是技术上最有深度的一块。基本的搜索需求有按关键词搜索门店名和分类标签、按城市筛选、按距离排序。第一次实现时我图省事直接在 MySQL 里用 LIKE 和ST_Distance函数硬写门店表上万条数据后就开始卡。当时测试环境的库里有大约三万条门店数据用LIKE %牛杂%这种模糊查询全表扫描耗时在 800ms 到 1.2s 之间如果同时按经纬度做距离排序查询耗时直接超过 2s。这个性能瓶颈没法靠调 SQL 优化解决索引也很难覆盖到%关键词%这种模糊匹配场景。所以我引入了 Elasticsearch门店在写入 MySQL 的同时同步到 ES业务查询全部走 ES 的全文检索和地理位置聚合。门店索引的 mapping 大致是这样的{ shop: { properties: { shopId: { type: keyword }, shopName: { type: text, analyzer: ik_max_word }, categoryName: { type: keyword }, city: { type: keyword }, location: { type: geo_point }, avgPrice: { type: integer }, status: { type: keyword } } } }搜索时组合 Bool Querymust里放关键词匹配filter里添加城市和状态过滤再用geo_distance排序按距离升序排列。这个组合写起来不复杂但是性能的提升非常明显同样的三万条数据检索加距离排序的响应时间从 2s 降到 50ms 以内。这组前后对比数据我建议你在项目文档里重点写它很直观地体现了一个敏感的从业者是如何定位和解决性能问题的。选定 Elasticsearch 需要在服务器上部署一个单节点实例内存分配不能太小我给轻量服务器的 ES JVM 堆设置了 512MB实际使用中勉强够用。这里提个醒如果你部署的时候发现 ES 启动后内存占用超过一个 G多半是没设置ES_JAVA_OPTS环境变量默认堆是 2G小内存服务器会直接卡死。这是我踩过的坑后面细讲。3. 数据库设计与 Redis 缓存方案3.1 核心表结构详解数据库设计是后端项目的基础表建不好后续要返工。我设计的表有这些user、shop、article、article_image、comment、favorite、user_like_article、tag、article_tag。挑几个核心表说明字段设计思路。用户表CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(255) NOT NULL COMMENT 加密后的密码, nickname VARCHAR(50) COMMENT 昵称, avatar VARCHAR(255) COMMENT 头像URL, role TINYINT DEFAULT 0 COMMENT 角色0-普通用户 1-管理员, status TINYINT DEFAULT 1 COMMENT 状态1-正常 0-禁用, create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 注册时间 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表;密码字段我存的是 BCrypt 加密后的哈希值绝对不能明文存这是底线。有一次答辩时有同学被老师直接问“如果你的数据库被拖库用户密码泄露了怎么办”这就是测试你有没有最基本的web安全常识。用户名做唯一索引防止重复注册。门店表稍微复杂些关键字段是经纬度、分类、营业状态CREATE TABLE shop ( id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 门店ID, shop_name VARCHAR(100) NOT NULL COMMENT 门店名称, category_id INT COMMENT 分类ID, province VARCHAR(30) COMMENT 省, city VARCHAR(30) COMMENT 市, district VARCHAR(30) COMMENT 区县, address VARCHAR(255) COMMENT 具体地址, longitude DECIMAL(10, 7) COMMENT 经度, latitude DECIMAL(10, 7) COMMENT 纬度, avg_price INT COMMENT 人均消费, business_hours VARCHAR(100) COMMENT 营业时间, phone VARCHAR(20) COMMENT 联系电话, rating DECIMAL(2, 1) DEFAULT 5.0 COMMENT 评分, status TINYINT DEFAULT 1 COMMENT 状态1-营业中 0-已停业 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT门店表;经纬度的类型用 DECIMAL(10,7)经度范围 0-180纬度范围 0-907位小数精度能达到 1 厘米左右完全够用。这里我顺便说一下为什么不用 FLOAT 或 DOUBLE——浮点数在计算时会有精度损失做距离排序时容易产生几米到几十米的误差。DECIMAL 是固定精度存经纬度最稳妥。文章表的字段设计要考虑到信息流的展示需求封面图和大字段分开存查询列表时不查正文全文。正文这种大字段用TEXT类型封面图用单独字段图片列表放关联表这样列表查询只需取封面图正文只在详情页加载数据库 IO 压力能减轻不少。3.2 Redis 在点赞、收藏与排行场景的实际用法Redis 在这个项目里承担了三类任务。第一存储登录态。用户登录成功后后端用 JWT 生成令牌同时把 token 存入 Redis 并设置过期时间每次请求时在拦截器里校验 token 是否存在且有效这比纯 JWT 方案多了一层可控性——需要在后台强制下线用户时直接删掉 Redis 里的 token 即可。第二处理点赞和收藏的高频操作。如果每次点赞都直接写 MySQL在高并发下数据库连接池会被瞬间打满。我的方案是点赞操作先写 Redis 的 Set 集合键名为like:article:{articleId}元素为 userId用户取消点赞时从集合中移除。定时任务每隔一段时间把集合数据批量同步到 MySQL 的user_like_article表。这里的细节是定时任务不能乱同步我记录了一个上次同步的增量标记避免重复插入。第三热门探店排行榜。信息流页有一个“本周热门探店”模块按照点赞数加评论数加权排名。这个榜单如果用 SQL 实时计算性能开销还是不小我直接在 Redis 里用 ZSet 维护文章热度分每产生一次点赞或评论就对对应文章的分数做INCRBY操作前端请求榜单时直接从 ZSet 按分数倒序取出前 10 名ZSet 的内部实现保证了取 TopN 操作的时间复杂度为 O(log(N))性能极好。我在这里建议你写项目时不要简单地把 Redis 用到“存了个值”就结束了要体现出为什么需要 Redis 而不是 MySQL。上面这三个场景分别体现了 Redis 的过期时间控制、集合操作和高性能排序能力答辩时能讲出这几层逻辑项目的技术含量立刻不一样。3.3 数据一致性问题缓存和数据库如何保持一致用 Redis 就必须面对缓存和数据库的一致性问题。最危险的方案是先更新数据库再更新缓存同时不做任何处理并发请求下缓存绝对脏读。我采用的策略是Cache Aside 模式具体步骤是读操作先查 Redis命中直接返回未命中则查 MySQL把结果写入 Redis设置过期时间写操作先更新 MySQL然后删除 Redis 中的缓存不主动更新缓存为什么更新后删除缓存而不是更新缓存因为更新缓存的操作本身可能是错的——并发场景下两个线程 A 和 B 同时写A 先写数据库B 后写数据库但 B 的缓存更新操作可能比 A 先完成最后缓存里存的是旧值。删除缓存则没有这个问题下次读取时缓存缺失自然回源数据库。不过删除缓存也会留下一个时间窗口在“更新数据库完成”和“删除缓存完成”之间的极小时间段内可能有请求读到旧值。这个问题在绝大部分业务场景下可以接受毕竟这个时间窗口是毫秒级的而且不会产生永久性脏数据。我在项目文档里也明确说明了这个取舍这是业界标准做法。4. 前端页面实现与操作体验设计4.1 页面流程与路由设计前端页面我用 Vue Router 做单页应用路由管理页面结构围绕用户行为路径展开。核心页面有首页信息流、门店详情页、探店文章详情页、发布探店页、个人中心页、管理后台。路由配置上有几个值得说明的点。路由守卫是必须的。我给路由元信息添加了requiresAuth和roles字段比如发布探店页要求登录管理后台页面要求管理员角色。全局前置守卫里写判断逻辑router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } if (to.meta.roles to.meta.roles.length 0) { const userInfo useUserStore() if (!userInfo.roles.includes(to.meta.roles[0])) { next({ path: /403 }) return } } next() })这里有两个容易踩坑的地方我提醒一下。第一token 存在 localStorage 里虽然简单但 XSS 攻击可以偷走更安全的方式是存 HttpOnly Cookie。考虑到这个项目的演示性质我用 localStorage 并做了说明但如果你做企业级web项目建议直接用 Cookie。第二路由守卫里不能只判断“有没有 token”还要判断 token 是否过期我是在 axios 响应拦截器里对状态码 401 做统一的跳转处理这比在路由守卫里去调验证接口效率更高。4.2 信息流和门店卡片的关键交互设计首页信息流是整个产品的门面交互设计我花了不少功夫。信息流页面采用卡片式布局每张卡片展示探店文章的封面图、标题、作者昵称、门店名、点赞数和收藏数。用 Element Plus 的el-card组装卡片但关键的交互设计不在这层而在两点。第一是图片懒加载。信息流很容易出现十几张卡片每张封面图都有几百 KB全部加载会白屏好几秒。我用v-lazy指令实现滚动到视窗范围内再加载首屏加载时间从 4.5s 降到了 1.2s。这个优化在移动端网络环境下的感知特别强建议你不管做什么 web 信息流类项目都必须做。第二是“当前用户是否已点赞”的状态管理。后端返回文章详情时会携带一个likedByCurrentUser字段。前端点赞按钮点击后立即把按钮状态变成已点赞状态同时发送接口请求如果请求失败再回滚状态。这就是乐观更新用户体验上按钮无延迟。这里要注意并发问题——不能让用户对同一篇文章连点三次直接刷三个赞我在后端做了幂等控制同一个用户 ID 对同一文章 ID 先检查 Set 集合再写重复提交会被拦截。门店详情页的设计采用从上到下的信息层级顶部是门店轮播图和核心信息卡片中间是详细资料展开区底部是门店相关的探店文章列表。门店的地理位置展示是我写了一个简单封装——引用 Leaflet 地图组件根据门店经纬度标记点位。Leaflet 轻量不像高德地图那样需要申请复杂的 key同时能展示坐标位置已经够用。4.3 管理后台用表格和表单搭建的审核中枢管理后台我单独做了个布局左侧是菜单右侧是内容区。之所以和前台分开是因为后台的操作模式完全不同——后台重点是信息密度高、操作效率高不需要精美的卡片动效。探店内容审核页是后台最核心的页面用表格展示待审核文章的信息标题、作者、门店名称、发布时间、状态。操作列放“通过”和“驳回”两个按钮驳回时弹出对话框要求填写驳回原因。审核状态我加了 Tab 页签切换待审核 / 已发布 / 已驳回方便管理员快速处理。门店管理页支持新增、编辑、下线门店表单里包含门店基本信息、经纬度、分类、营业时间等。新增门店时经纬度可以直接输入数值也可以地图选点。我做了一个小改进地图上点击会自动回填经纬度输入框这个功能在录入真实门店数据时非常节省时间。后台的数据统计页展示了平台的基础数据用户总数、门店总数、探店文章总数、今日新增发言数等。这些统计数据的来源就是各表COUNT(*)数据量不大时可以直接查库。但如果后续数据量上来建议用定时任务把统计结果预聚合到单独的表里避免每次打开页面都跑全表 COUNT。5. 部署上线与运维经验5.1 服务器环境搭建与项目部署步骤项目完成后部署上线是不可避免的环节。我用的是一台 4核8G 的云服务器操作系统是 Ubuntu 22.04。需要部署的组件有Nginx、MySQL、Redis、Elasticsearch、后端 jar 包、前端静态文件。我给服务器分别规划了目录/opt/ ├── backend/food-backend.jar # 后端可执行 jar ├── frontend/ # 前端构建后的静态文件 ├── nginx/conf.d/ # 站点配置文件 └── scripts/ # 启动与管理脚本后端打包用 Maven 的package命令生产环境的配置文件放在外部不打进 jar 包。Spring Boot 默认读取外部application-prod.yml的配置这样修改数据库密码或者 Redis 地址时不需要重新打包。我用nohup方式启动后端nohup java -jar food-backend.jar --spring.profiles.activeprod /opt/logs/backend.log 21 为什么用nohup而不是直接用java -jar前台运行因为前台运行的话关闭 SSH 终端后进程就会被 kill 掉。如果你做的是正式项目建议配 systemd 服务文件实现开机自启和进程守护但毕设或演示项目用nohup是最快的方案。Nginx 配置里有几个地方容易不注意我给出一个核心配置段server { listen 80; server_name food.example.com; location /api/ { proxy_pass http://localhost:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } location / { root /opt/frontend; try_files $uri $uri/ /index.html; } }location /api/把后端接口反向代理到 Spring Boot前端请求接口时就不用直接暴露后端端口。try_files $uri $uri/ /index.html是 SPA 路由的关键——前端路由是 history 模式用户直接访问/shop/1024时服务器上不存在这个路径必须重写到 index.html 再交给前端路由处理不然刷新页面就 404。5.2 HTTPS 配置与web服务器安全加固我在部署时顺手把 HTTPS 也配了。现在让用户在一个明文 HTTP 的网站上登录、发布内容其实很难让人放心。证书我用的是 Let‘s Encrypt免费三个月自动续期配置过程用certbot工具基本是交互式的生成证书后修改 Nginx 配置增加 SSL 监听即可。web服务器安全方面有一些基础且必须要做的加固措施。第一生产环境的 MySQL 不允许 root 远程登录只让 localhost 回环地址连接并单独为应用创建数据库账号权限只分配当前数据库的增删改查。第二Redis 一定要设置密码同时关闭保护模式并绑定内网地址。我记得第一次裸奔部署 Redis结果公网扫描端口没几天就被人连接进来了这是我印象很深的教训——这些东西看着“小事”出了事就是大事。第三后端接口要在 Nginx 层面加请求大小限制防止恶意提交超大请求体把后端打崩。这些内容放到项目文档里是很好的加分项老师提问“你这个系统的安全性怎么样”的时候你能说出 HTTPS、参数校验、密码加密、权限控制、Redis 密码这几层远比只说“我们做了权限验证”要可信得多。5.3 常见部署问题与排查过程部署过程我至少踩了六七个坑挑几个有价值的说说。第一个坑是 Elasticsearch 启动就崩溃。服务器内存只有 8GES 默认的 JVM 堆设置成了 2G加上 Spring Boot 的一个 G系统内存直接不够了。排查的时候看日志发现是Native memory allocation (mmap) failed。解决办法是给 ES 设置ES_JAVA_OPTS-Xms512m -Xmx512m强制限制堆内存大小问题立刻解决。后来我还顺手设置了vm.max_map_count参数否则 ES 在 Linux 上启动会报max virtual memory areas vm.max_map_count [65530] is too low需要执行sysctl -w vm.max_map_count262144调整。第二个坑是前端访问接口跨域。本地开发时接口地址是http://localhost:8080前端页面是http://localhost:5173浏览器直接发请求会被 CORS 拦截。这个问题的本质是浏览器安全策略不属于服务器端错误。我用了两种方式配合解决开发环境在 Vite 的proxy配置中把接口请求转发到后端生产环境用 Nginx 反向代理一层层把同源问题解决掉。第三个坑是静态资源加载路径问题。Vue Router 使用 history 模式时刷新页面会 404因为 Nginx 找不到对应的物理路径。解决方法是配置try_files规则这在上面的配置段已经写了。但还有一个相关的问题非常隐蔽——打包后index.html里面引用的 JS 和 CSS 路径如果vite.config.js里没有配置base: /部署到子目录时资源会全部加载失败当时排查问题时发现控制台全是net::ERR_ABORTED404 报错。第三个容易忽略的是文件上传路径。本地开发时图片保存到了本机的/upload/目录部署到服务器后这个目录可能不存在导致上传报错。我的做法是把上传目录独立配置到application-prod.yml里upload: path: /data/upload然后 Nginx 增加一个静态资源映射把/upload/前缀的访问映射到该目录保证图片可以正常访问。在上传图片存储这块做企业级项目时更推荐对象存储方案比如七牛云或阿里云 OSS既能减轻服务器磁盘压力又容易对接 CDN但对于单服务器项目本地目录存储完全够用。6. 项目优化记录与踩坑避坑指南6.1 性能优化记录从 2 秒到 50 毫秒整个项目开发过程中我最满意的一段优化记录就是门店搜索的响应时间。第一阶段用 MySQL 硬查2s第二阶段为搜索场景做索引优化1s第三阶段彻底切换到 Elasticsearch50ms。这段优化日志我完整记录在了项目文档里因为每一次瓶颈定位都对应了不同的技术考虑。具体拆解一下。第一阶段定位到问题后我试过给shop_name字段加普通索引但LIKE %牛杂%这种前模糊查询根本走不了索引索引优化失败。后来试过全文索引MySQL 的全文字段匹配对中文支持一般而且和现有的查询条件组合起来比较复杂。最终选择 Elasticsearch 是因为它本身的全文检索能力就是为这个场景设计的而且地理位置聚合查询geo_distance排序也是开箱即用的两者结合的性能远超 MySQL。这个决策过程中对“为什么不用”的分析比“为什么用”更重要面试或答辩时这套思考过程非常有价值。信息流接口也做了一轮优化。最初是查文章表后循环查询每个作者和门店信息N 篇文章就会执行 N1 次 SQL接口响应时间随数据量线性上涨。后来用 MyBatis-Plus 的selectBatchIds一次性查询作者和门店集合再在内存里组装SQL 次数从 N1 降到了 3响应时间稳定在 200ms 以内。6.2 开发期最容易踩的坑前端调用后端接口的细节前后端联调时最容易出问题的并不是接口逻辑而是一些看似不起眼的细节。我用一个小的清单把这些坑列出来这是从实际开发中总结出的血泪经验。第一时间字段的时区问题。后端返回的create_time默认是 UTC 格式前端直接渲染会显示比本地时间少 8 小时。我统一在后端配置了 Jackson 的序列化格式在application.yml里设置spring.jackson.time-zone: GMT8和date-format: yyyy-MM-dd HH:mm:ss。第二Long 类型精度问题。数据库自增主键是BIGINT到前端 JavaScript 中 Number 类型能安全表示的最大整数是2^53-1超过这个值精度就会丢失最典型的翻车现场是文章 ID 返回后最后几位变成了 0。解决办法是在后端配置 Jackson 将 Long 类型序列化为字符串。这个坑真的是非常经典我第一次踩到的时候百思不得其解直到在浏览器控制台打印接口返回值才发现 ID 被截断了。第三文件上传请求头的坑。上传图片使用 FormData 格式前端不能用 axios 默认的Content-Type: application/json要把请求头设置为multipart/form-data同时不能手动设置 boundary让浏览器自动生成。6.3 用户并发与数据一致性的实战处理在最后上线测试阶段我找了三五个同学同时使用模拟真实用户并发操作结果发现几个只有在并发场景下才会暴露的问题。点赞操作的并发问题是最早暴露的。有两个同学同时对同一篇文章点赞发现记录插入两次造成重复点赞。因为不是简单地先查后插而是需要保证同一用户同一文章只有一条记录。我的解法是在数据库层面建唯一索引UNIQUE KEY uk_user_article(user_id, article_id)再配合 Redis Set 的幂等判断。数据库唯一索引是最后的兜底防线Redis 判断只是减少无效请求打到数据库。文章发布时的重复提交问题也很有意思。用户填写完探店内容后点了两次“发布”按钮后端的insert语句执行了两遍产生了两条几乎一样的文章记录。这个问题的标准解法是前端在点击后将按钮设成 loading 并禁用按钮但更可靠的做法是后端做幂等处理。我给发布接口增加了一个requestId参数前端在页面加载时生成一个唯一 ID 随请求发送后端在 Redis 中检查 requestId 是否已处理已处理则直接返回上次的结果。这样即使用户连点十次也只会产生一篇文章。6.4 项目文档与答辩展示要点代码写完不是终点项目文档和演示效果决定了一个项目的最终呈现水平。我花了不少精力整理 README这份文档的结构可以作为你的参考项目背景与功能简介技术栈与版本选择及理由系统架构图与流程说明核心表结构说明接口文档用 Swagger 生成部署步骤与配置说明性能优化记录与对比数据遇到的问题与解决过程答辩或演示时的操作路径我建议设计成一条完整的业务故事线注册用户 - 发布一篇探店文章 - 管理员审核通过 - 信息流展示 - 另一个用户点赞收藏 - 查看热门榜单。这条线走完评审既能看到前端页面的各个模块也能看到后台管理的操作还能体会到一个内容平台的核心业务流程是怎么跑的。演示时千万不要临时操作提前把数据准备好该审核的文章已经审核好该展示的页面已经打开演示过程中卡壳非常减分。7. 项目后续扩展方向项目做到能上线运行这一步已经算完整交卷但说实话这个平台离一个真正可运营的产品还有不少距离。如果要把这个项目继续往下做我梳理了几个具体可落地的扩展方向也算是给接手这个项目的人指个路。第一接入地图 SDK 做位置服务。目前的门店地图交互用的是 Leaflet 内置瓦片体验还行但缺少路线规划、周边查询等能力。替换成高德地图或百度地图 SDK 后可以增加“从当前位置导航到门店”的功能这是美食场景里的高频需求。第二引入图片审核机制。探店内容大量依赖用户上传图片目前上传后只是人工审核效率很低而且有风险。引入图像审核 API对图片内容做机器审核可以大大减轻管理员工作量同时避免平台上出现违规内容。这一步如果要做建议在发布探店文的图片上传接口里直接串联审核流程而不是等到审核文章时再检查图片。第三搭建简单的个性化推荐。信息流目前是按发布时间倒序展示所有已审核文章最多加一个热门榜。如果引入用户兴趣标签比如用户常看“火锅”类探店文就可以做一个简单的基于标签的推荐逻辑热度权重加时间衰减推荐效果会有明显提升。当然这个方向要做到抖音小红书那样就复杂了但做一个基于标签过滤的推荐版本投入产出比很高。第四移动端适配与小程序版本。现在的前端页面虽然做了响应式适配但手机上浏览体验还是不如原生 App 或小程序顺手。如果想让这个项目有更大的想象空间可以把核心浏览链路迁移到小程序上后端接口完全复用前端按小程序的页面规范重写。这样一个项目同时覆盖 PC 端和移动端技术亮点也更丰富。根据我的实际经验后续扩展不用全做选一到两个方向做深就足够了。比如把个性化推荐做一个基础版本加一个简单的协同过滤模块项目深度和复杂度立刻就上来了。做项目最忌的是功能铺开面太大但每个都浅尝辄止重点做透一个方向比做出十个半成品有价值得多。
返回列表