基于SpringBoot+Vue的短链接流量数据分析与可视化系统实战
发布时间:2026/9/29 3:18:45来源:尧图网络
当时我负责的营销小组每发一条短信都得在链接后面带一串很长的URL用户点到哪个链接、什么时间段点的、从哪个渠道进来的后台根本看不出个所以然。渠道方给的统计报表口径又不统一有的算PV有的算UV还有的直接给一张截图。于是我用业余时间做了这套内部代号 ABO 的短流量数据分析与可视化系统。后端采用 SpringBoot MyBatis MySQL前端使用 Vue Element UI ECharts前后端彻底分离最后用 Nginx 部署上线。这篇文章是一份完整的实战复盘从数据库建模到核心代码再到部署细节都会讲清楚。如果你有 Java 基础想完整跑通一个前后端分离项目或者正要选型做营销数据中台可以参考这套系统的设计思路。1. 项目背景与核心需求梳理1.1 短流量分析到底要分析什么做这个系统之前我先把“短流量”三个字定义清楚。这里的短流量不是短视频平台的流量而是短链接被访问后产生的请求流量。一条推广短信里放一个https://t.example.com/x7K2pQ这样的短链接用户点击后先请求短链域名再由服务端重定向到真实的长地址。我们关心的数据就是这中间一次一次被点出来的流量痕迹。运营每天要看的核心指标其实可以归纳成六类点击总量、独立访客数、时间趋势、来源渠道、地域分布、设备环境。每一条对应到数据模型里就是一张访问日志表的不同字段分组。点击总量很简单统计表里有多少条记录独立访客数稍微复杂点需要按 IP User-Agent 去重因为一个用户可能在无线网络和移动流量之间切换IP时间趋势需要按天或按小时聚合才能看出哪个时段投放效果最好来源渠道要通过解析 Http Referer 获得地域分布依赖 IP 归属地库设备环境从 User-Agent 里提取操作系统和浏览器类型。这些指标如果靠第三方统计工具很难做到渠道维度自由组合。自己做系统的最大优势是可以自定义埋点和筛选条件比如我要看某个短码在深圳地区、来自微信公众号内打开的点击量用 SQL 一条语句就能查出来。1.2 为什么选这套技术栈而不是更重型的框架选技术栈的时候我其实纠结过要不要用现成的若依框架。若依的前后端分离版本确实成熟用户权限、代码生成器都现成但它的依赖比较重MyBatis Plus 的封装也把很多原生 SQL 细节藏起来了。这个项目本来就是以学习为主需要把短码生成、日志异步写入、聚合统计这几块核心逻辑掌控在自己手里所以我决定只用原生 MyBatis不用 MyBatis Plus也不引入若依。SpringBoot 的优势是启动快、约定大于配置。SpringBoot 2.7 搭配 MyBatis 2.3 版本再用 MySQL 8.0这一套组合非常稳定资料也多。Vue 这边选择 Vue 2.7 Element UI主要是因为 ECharts 的图表渲染生态比较成熟Element UI 的表格和表单组件能快速把后台页面搭起来。如果项目团队有人熟悉 Vue 3选 Vue 3 Element Plus 也可以核心逻辑完全一致只是写法上略有差异。MySQL 部署门槛低一台 2 核 4G 的服务器完全跑得动数据库表结构也不复杂两张核心表加一张用户表就能承载这个系统的全部业务。对中小团队来说这个技术栈的人力成本和运维成本都是最低的。2. 整体设计与数据库建模2.1 模块划分与后端分层后端我按照经典的三层架构来组织Controller 负责接收 HTTP 请求和参数校验Service 负责业务逻辑Mapper 负责数据库操作。在包结构上按照功能模块拆分而不是按技术层拆分这样在一个业务需求来的时候改动点会比较集中。整个系统分四个功能模块链接管理模块短码生成、长链接存储、短链接删除、过期时间设置访问记录模块接收短链点击请求异步写入访问日志统计报表模块按时间、渠道、地域、设备等多个维度聚合查询系统管理模块用户登录、令牌校验、密码修改每个模块对应一组 Controller、Service、Mapper 接口。比如 StatsController 下面的方法都是查询类的我习惯把统计相关的接口放在一个控制器里前端只用一个/api/stats/**的路径前缀Nginx 配置转发时也省事。2.2 数据库实体关系设计数据库设计是整个项目最核心的一步表结构如果一开始没想清楚后面写 SQL 会非常痛苦。我将核心表分成三张。第一张是短链接表short_link记录短码和长地址的映射关系。设计时要注意几个细节。短码字段必须加唯一索引这是点击跳转的查询条件。状态字段用于表示启用、停用、过期不能直接物理删除否则历史统计记录会变成孤儿数据。创建时间用datetime类型并设置默认值为CURRENT_TIMESTAMP避免应用层每次都要手动填写。第二张是访问日志表access_log这是整个系统数据量增长最快的表。每点击一次短链就产生一条记录字段包括链接ID、访问时间、IP 地址、User-Agent、Referer、解析出来的省份和城市。这张表是典型的写多读少场景索引不宜过多我先在link_id access_time上建了复合索引满足按链接查趋势的统计需求。第三张是用户表user字段就 id、username、password、role。密码使用 BCrypt 加密存储不能存明文。当时我没引入 Spring Security而是自己写了一个拦截器做 JWT 校验用到的依赖只有 jjwt 一个够用且不会把项目弄得太重。建表的 SQL 我贴在这里方便复现时直接执行CREATE TABLE short_link ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, short_code varchar(16) NOT NULL COMMENT 短码, long_url varchar(2048) NOT NULL COMMENT 原始长链接, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 1启用 0停用, expire_time datetime DEFAULT NULL COMMENT 过期时间, create_by bigint(20) DEFAULT NULL COMMENT 创建人, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_short_code (short_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT短链接表;CREATE TABLE access_log ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, link_id bigint(20) NOT NULL COMMENT 短链接ID, access_time datetime NOT NULL COMMENT 访问时间, ip varchar(64) DEFAULT NULL COMMENT 访问IP, province varchar(32) DEFAULT NULL COMMENT 省份, city varchar(32) DEFAULT NULL COMMENT 城市, referer varchar(512) DEFAULT NULL COMMENT 来源地址, ua varchar(512) DEFAULT NULL COMMENT User-Agent, device_type tinyint(4) DEFAULT NULL COMMENT 设备类型 1手机 2电脑 3其他, PRIMARY KEY (id), KEY idx_link_time (link_id, access_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT访问日志表;两张表之间的关系很简单短链接表是主表访问日志表通过link_id外键关联到短链接表。但我在实际设计时没有在数据库层面加物理外键约束而是只加了普通索引因为访问日志写入频率高物理外键在插入时会检查父表记录多一次查询开销高并发下不值得。这个取舍对流量分析系统来说是合理的。2.3 接口设计概览前后端分离项目必须先把接口约定好不然前端等后端接口、后端改前端参数是个无底洞。我梳理的核心接口如表所示接口路径请求方法功能说明/api/auth/loginPOST登录返回JWT令牌/api/linkPOST生成短链接/api/link/{id}GET查询短链接详情/api/link/{id}DELETE删除短链接/api/stats/summary?linkId1GET获取汇总指标/api/stats/trend?linkId1typedayGET获取按天趋势/api/stats/source?linkId1GET获取来源渠道分布/api/stats/geo?linkId1GET获取地域分布统一返回结构我封装了一个ResultT类里面有 code、message、data 三个字段。code 为 0 表示成功非 0 表示业务异常。大量接口返回同一结构有利于前端在 Axios 拦截器里统一处理错误码不用每个页面都写一遍弹错逻辑。3. 核心功能实现细节3.1 短码生成算法与冲突处理短码是短链系统里最基础的部分生成方式直接决定了链接的美观程度和数据库查询压力。我对比过两种常见方案。第一种是随机生成固定长度的字符比如从 62 个字符里随机取 6 位。这种方式的好处是短码不可猜测用户无法通过递增ID遍历到别人家的链接适合营销场景。缺点是随机碰撞的概率随着记录数增大而上升插入时要做唯一索引冲突检测冲突了就重新生成。第二种是自增 ID 转 Base62 编码。好处是绝对不冲突短码有规律方便排障。缺点是可猜测如果接口没做好权限校验别人可以枚举短码批量抓取访问数据。我做营销投放用的系统选的是随机短码方案。核心代码如下private static final char[] BASE62_CHARS abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.toCharArray(); private static final SecureRandom RANDOM new SecureRandom(); public String generateShortCode(int length) { StringBuilder sb new StringBuilder(length); for (int i 0; i length; i) { sb.append(BASE62_CHARS[RANDOM.nextInt(62)]); } return sb.toString(); }这里有个细节SecureRandom比Math.random()更适合生成短码。一方面安全强度更高另一方面在 Linux 环境下Math.random()的种子初始化可能会拖慢高并发下的首次调用SecureRandom会更稳定。长度我设置了 6 位62 的 6 次方大约 560 亿种组合按每天新增十万条短链计算要碰撞一次的概率极低。插入时的冲突处理我放在 Service 层用一个循环尝试插入捕获到 DuplicateKeyException 就重新生成短码再试最多重试三次。代码逻辑不复杂但要注意必须在数据库层面加上唯一索引否则并发场景下两个请求可能同时生成同一个短码应用层的判断形同虚设。3.2 点击跳转与埋点采集短链的点击流程是整个系统中最关键的一条链路。用户在浏览器输入短链地址请求到达后端 Controller服务端先根据短码查出对应的长链接然后把这次访问信息记录下来最后返回一个 302 状态码让浏览器跳转到长链接。这里我推荐用 302 而不是 301。两者的区别很关键301 是永久重定向浏览器和 CDN 会缓存结果下次再访问时可能不会请求到我们的服务器导致统计数据明显偏少302 是临时重定向每次点击都会经过服务端统计口径准确。代价是每次点击都多一次请求延迟但对于短链系统来说这个延迟可以接受。记录访问信息的代码我放在重定向之前执行。为什么不放在重定向之后因为 HTTP 重定向之后服务端和浏览器的交互就结束了没有机会再写日志。核心实现如下GetMapping(/{shortCode}) public void redirect(PathVariable String shortCode, HttpServletRequest request, HttpServletResponse response) throws IOException { String longUrl linkService.resolveShortCode(shortCode); if (StringUtils.isBlank(longUrl)) { response.sendError(404); return; } accessLogService.asyncRecord(longUrl, request); response.setStatus(HttpServletResponse.SC_FOUND); response.setHeader(Location, longUrl); }日志采集最需要关注的是不能影响跳转速度。如果把数据库写入同步放在重定向前面高峰期每次点击都要插入一条日志数据库的响应时间会直接拖慢用户体验。我用了一个固定大小的线程池去做异步写入请求线程只负责把日志对象放进内存队列真正执行INSERT的在后台线程里完成。这个方案虽然简单但在日均几十万次点击的规模下完全够用。IP 转地域我用了一个离线的 IP 归属地库服务启动时加载到内存里解析速度是微秒级别。大厂一般用 MaxMind 的 GeoLite2但国内访问下载不方便。我当时用的是纯真 IP 库的文本格式解析起来也不难按 IP 段范围二分查找就能定位到省份和城市。3.3 统计维度与可视化接口设计统计报表是前端可视化的数据来源后端需要把原始访问日志聚合好尽量减少前端计算工作量。ECharts 这类图表组件最佳数据格式是聚合后的 JSON而不是原始明细。按天趋势的 SQL 最简单SELECT DATE_FORMAT(access_time, %Y-%m-%d) AS access_date, COUNT(*) AS pv, COUNT(DISTINCT ip, ua) AS uv FROM access_log WHERE link_id #{linkId} AND access_time BETWEEN #{startTime} AND #{endTime} GROUP BY access_date ORDER BY access_date;来源渠道分析稍微复杂一点先从 Referer 字段里提取域名。Referer 是浏览器自动携带的上游页面地址比如用户从微博点进来Referer 就是https://weibo.com/xxx。如果 Referer 为空说明用户直接输入地址或从 App 内打开。我按域名前缀做分组映射分为“微博、微信、直接访问、其他”。地域分布的 SQL 用到省份字段SELECT province, COUNT(*) AS cnt FROM access_log WHERE link_id #{linkId} AND access_time BETWEEN #{startTime} AND #{endTime} GROUP BY province ORDER BY cnt DESC;后端的统计 Service 把这些查询结果包装成统一的趋势对象和分布对象前端拿到后直接填充到 ECharts 的 series 里。有一点容易忽略当查询时间跨度大于一个月时前端最好让用户选择按天还是按小时聚合否则折线图点数太多图表会显得拥挤交互体验很差。3.4 用户认证与权限控制系统最初的版本没有做登录直接暴露出所有数据接口。后来部署到测试环境被同事抱怨谁都能看到投放数据才把 JWT 认证加上。这里我建议从一开始就把认证做进去省得到后面重构。我的方案是在 Spring Boot 中注册一个拦截器拦截除了/api/auth/login和短链跳转接口以外的所有/api/**请求。拦截器从请求头Authorization字段取出 token用 JWT 解析出用户ID放行或者返回 401。JWT 的密钥放在配置文件中通过Value注入不要把密钥硬编码在类里面。密码加密使用 BCrypt最直观的好处是同一个密码每次加密出来的密文都不一样即使数据库泄露攻击者也很难通过彩虹表反推出明文。用户注册接口在部署后我就关了系统用户直接通过 SQL 初始化或者后续做一个简单的管理员注册接口。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (StringUtils.isBlank(token) || !token.startsWith(Bearer )) { response.setStatus(401); return false; } // 解析token逻辑略 return true; } }这种拦截器方案比引入 Spring Security 轻量得多适合数据量不大、并发要求不高的管理后台项目。如果你的团队习惯于 Security 的过滤器链也可以换成 Spring Security但学习成本会高一些。4. 前后端联调与部署上线4.1 后端打包与数据库初始化到了部署阶段第一步是检查本地环境。我当时的后端开发环境是 JDK 8Maven 3.6MySQL 8.0。这里要注意 Spring Boot 2.7 支持 JDK 8 到 JDK 17如果你的 SpringBoot 版本比较新比如 3.x那就要用 JDK 17否则启动会直接报错。如果本地装的是 JDK 17建议优先选择 Spring Boot 2.6 以上版本避免低版本不兼容的问题。数据库初始化我用一个初始化 SQL 脚本一键执行建库语句明确指定字符集CREATE DATABASE abo_system DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE abo_system;utf8mb4 为什么不写 utf8因为 utf8 在 MySQL 里是 3 字节编码存不了四字节的 emoji 字符和部分生僻字。UA 字段里如果用户浏览器版本中含有特殊字符3 字节编码下就会报错。这是一个很容易踩的坑。后端打包成 jar 后部署就简单了。mvn clean package -DskipTests java -jar target/abo-system-0.0.1-SNAPSHOT.jar --spring.profiles.activeprod生产环境的数据库连接配置放在application-prod.yml里和开发环境分开。这里特别提醒一句不要把生产环境的数据库密码提交到 Git 仓库里。我当时就在 GitHub 上吃过亏好在仓库是私有的及时改了密码。如果你在写教程务必习惯性地把这类信息从示例中抹掉。如果你需要部署到 Tomcat 容器里Spring Boot 项目也可以打包成 war。步骤是修改 pom 文件的打包方式为 war启动类继承SpringBootServletInitializer并重写configure方法。但我个人更推荐独立 jar 部署运维成本低也不用同时维护 Tomcat 和 Nginx 两套服务。4.2 前端构建与 Nginx 静态资源服务前端项目我使用 npm 管理依赖。Vue 项目在部署机上不需要安装 Node 环境吗如果只是部署编译好的静态文件服务器确实不需要 Node。但如果你要在服务器上重新打包就必须安装 Node 和 npm。本地构建命令如下npm install npm run build构建完成后会在dist目录下生成index.html和一堆静态资源文件。把这些文件上传到服务器比如/opt/abo/frontend/再配置 Nginx 指向这个目录。核心配置如下server { listen 80; server_name t.example.com; root /opt/abo/frontend; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080/api/; 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 / { try_files $uri $uri/ /index.html; } }这里面最关键的是try_files这一行。Vue Router 如果使用 history 模式浏览器直接访问https://t.example.com/stats时会请求服务器上的/stats路径但实际上这个路径不存在如果 Nginx 没有回退到 index.html就会返回 404。try_files $uri $uri/ /index.html的意思是先找对应文件找不到就全部交给前端的 index.html 去处理由 Vue Router 决定该渲染哪个页面。另一个关键点是反向代理/api/到后端服务。前端生产环境的请求地址我在.env.production文件里配置为相对路径/api这样 Nginx 才能准确转发。如果用绝对地址http://localhost:8080浏览器里就会出现跨域报错这个问题下面细说。4.3 开发环境跨域和接口联调开发阶段前端跑在 8080 端口后端跑在 9090 端口必然遇到跨域问题。解决办法有两种。第一种是后端开启 CORS。在 Spring Boot 中添加一个配置类Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(*) .allowedHeaders(*); } }不过生产环境不建议把 allowedOriginPatterns 设置为*这样等于放弃同源策略任何网站都能调用你的接口。开发环境可以放开生产环境我会精确配置前端域名。第二种是前端开发环境利用 Webpack 的代理功能在 vue.config.js 里配置devServer: { proxy: { /api: { target: http://localhost:9090, changeOrigin: true } } }这种方案的好处是浏览器请求的一直是同源的 8080 端口后端不会收到 Origin 头省去后端的 CORS 配置。我建议开发环境用代理方案生产环境用 Nginx 反向代理后端不额外开启 CORS让同源策略保持默认的严格状态。5. 常见问题与排查技巧5.1 后端接口异常排查我在项目开发中遇到过两个高频问题值得单独拎出来讲。第一个是 MySQL 连接失败报错信息是Access denied for user rootlocalhost。这个通常不是密码写错而是修改密码之后没有刷新权限表。在 MySQL 里执行ALTER USER或SET PASSWORD后要跟着执行FLUSH PRIVILEGES。另外 MySQL 8.0 默认的认证插件是 caching_sha2_password如果你的 JDBC 驱动版本过旧会提示认证插件不支持需要升级 mysql-connector-java 版本我用的是 8.0.33兼容性没问题。第二个是 MyBatis 的驼峰映射问题。数据库字段create_time在 Java 实体类里对应createTime如果 MyBatis 配置里没有开启驼峰映射查询出来的create_time字段会赋值不到对象的createTime属性上结果是所有时间字段都是 null。解决办法在application.yml里加一行配置mybatis: configuration: map-underscore-to-camel-case: true这个问题新手极易忽略而且表现形式很隐蔽接口不报错但返回的数据里时间字段全是 null。5.2 前端部署后的白屏与静态资源404前端项目部署后如果出现白屏优先打开浏览器控制台看两个东西加载的 JS 和 CSS 文件路径以及接口请求的路径。如果控制台出现404 Not Found且指向 static 目录大概率是publicPath配错了。Vue CLI 项目的vue.config.js里有一个配置项module.exports { publicPath: process.env.NODE_ENV production ? / : / }如果你把前端部署在服务器根目录publicPath 配成/没问题。但如果部署在子路径比如通过https://example.com/abo/访问publicPath 要改成/abo/。否则打包出来的 HTML 会引用绝对路径的 JS浏览器会去根目录找资源自然就 404 了。另一个常见问题是接口请求失败导致页面空白。Vue 页面通常会在created生命周期里请求数据如果接口失败且没有做错误处理整个页面就一直空白。我当时把所有接口封装在 Axios 拦截器里统一捕获错误并弹出提示不管是开发还是排查问题都舒服得多。5.3 部署后的性能优化建议系统上线运行一段时间后我发现访问日志表增长很快一周就积累了上百万条数据。如果继续按原来的表结构做聚合查询响应速度肉眼可见地变慢。我采取的优化措施有三条。第一按天分表。访问日志表改成每天一张表名带日期后缀比如access_log_20250601。每天凌晨的定时任务建第二天的表查询时根据时间范围动态拼接表名。这样单表数据量永远不会太大索引效果也能保持。第二打开 MySQL 的查询缓存或引入 Redis 缓存热点数据。短链的跳转查询是典型的读多写少场景短码到长链接的映射关系可以通过 Redis 缓存缓存命中时免除一次数据库查询。我这里没有引入 Redis把减少数据库交互做到了线程池异步日志和短码索引上但对于更高并发的场景Redis 是值得接入的。第三Nginx 开启 gzip 压缩和静态资源缓存。Vue 打包后的 JS 文件体积通常有几百 KBgzip 压缩后能减少 70% 左右的传输量。在 Nginx 配置里加入gzip on; gzip_min_length 1k; gzip_types text/plain text/css application/javascript application/json;对于图片、字体这类长期不变的静态资源可以设置较长的缓存时间减少浏览器重复请求。5.4 关于 MyBatis 扩展点的小结这个项目没有用 MyBatis Plus但我在原生 MyBatis 下自定义了一个 TypeHandler用于把访问日志里的加密字段自动解密或者把逗号分隔的标签字符串自动映射成 List。如果你也遇到查询字段和 Java 类型不匹配的情况可以参考 MyBatis 内置BaseTypeHandler的写法实现setNonNullParameter和getNullableResult两个方法然后在 Mapper XML 的 resultMap 中指定 typeHandler。MyBatis 的二级缓存在这个项目里没有开启因为统计查询本身就追求实时性缓存造成的脏数据问题比性能收益更棘手。6. 一些实操体会与后续扩展建议这个项目从设计到上线前后花了两周时间。回头看我踩过最大的坑不是技术难题而是前期数据库设计时对统计维度预估不足。最初访问日志表没有独立的 referer 字段只在长链接 URL 里存了来源参数导致后来想在 ECharts 里画来源渠道饼图时不得不把原有的历史数据重新清洗一遍。所以我的体会是做数据类系统先想清楚“将来要分析哪些维度”再去建表顺序不能反过来。如果你也想复刻这套系统建议按照“短码生成 - 跳转埋点 - 聚合统计 - 接口联调 - 部署上线”的顺序来做每一步跑通了再进入下一步。后续如果要扩展我建议往三个方向走一是接入 Redis 做短码缓存和热点数据加速二是把访问日志从 MySQL 迁移到 ClickHouse支撑更大的数据量三是在前端加入更多交互式图表比如地域热力图和用户转化漏斗。这套系统本身不大但作为前后端分离项目的练手案例它的技术覆盖面相当完整值得自己动手做一遍。
网站建设高端定制企业官网