SpringBoot短链服务源码:支持自定义过期时间+实时访问统计

SpringBoot短链服务源码:支持自定义过期时间+实时访问统计
本文还有配套的精品资源点击获取简介一套开箱即用的SpringBoot短链接系统能将长URL快速生成简洁短链点击后自动302跳转到原地址。短链创建时可选永久有效或设置具体过期天数到期后自动失效不再跳转。每次用户访问都会触发记录后台实时累计总访问次数并支持按短链维度查询历史访问量。项目包含完整Java后端代码、MySQL建表脚本url_map.sql、标准Maven配置pom.xml以及中英文双语说明文档README.md/README.en.md。所有功能基于Spring Web、Spring Data JPA和MySQL实现不依赖Redis、MQ等额外中间件本地启动只需JDK8、MySQL5.7和IDE即可运行。结构清晰分层合理适合练手REST接口开发、数据库CRUD操作、HTTP重定向处理及基础运维监控逻辑。1. 这不是玩具项目而是一套能直接塞进生产环境跑的短链基建骨架我带过三届校招后端实习生每年都会让他们从零搭一个短链服务——不是用现成SDK而是手写Controller、Service、Repository连建表语句都得自己推演。为什么因为短链系统是Web开发的“微型全栈沙盒”它麻雀虽小五脏俱全——URL解析、唯一编码生成、数据库事务、HTTP状态码语义控制、并发计数、时间策略判断、接口幂等性、甚至基础监控埋点全在里面。而眼前这套SpringBoot短链源码就是我当年手把手教他们写的那个“教学版”的工业级进化体。它不炫技不堆中间件就靠Spring Web JPA MySQL这三板斧把短链的核心逻辑抠得极细。关键词里写的“自定义过期时间实时访问统计”绝不是功能列表里的两行字而是贯穿整个数据流的设计锚点过期时间不是存个字段就完事它决定了数据生命周期、查询路径、缓存穿透防护访问统计也不是简单count它必须扛住并发写入、避免数据库锁争抢、还要保证聚合查询的实时性与一致性。这套代码最值得学的恰恰是那些没写在README里的“沉默设计”——比如为什么用Transactional只加在Service层而不放在Controller里为什么跳转接口返回302而不是301为什么访问记录表要单独建一张而不是和短链主表合并在一个实体里这些选择背后全是真实业务场景里踩过的坑。如果你刚学完SpringBoot基础想找个能真正理解“分层架构”“事务边界”“HTTP语义”的练手项目它比任何教程都扎实如果你已在公司做后端想快速搭建一个内部用的短链管理后台它删掉测试目录、改几行配置就能上线。它解决的不是一个“能不能用”的问题而是“怎么用得稳、看得清、扩得开”的问题。2. 整体设计思路用最朴素的组件实现最严谨的状态流转2.1 核心状态机短链生命周期的四个阶段短链不是静态的字符串映射而是一个有明确生命周期的状态实体。这套代码把整个流程抽象成四个不可逆阶段每个阶段对应不同的数据库字段组合和业务规则创建态CREATED用户提交长URL和过期策略系统生成短码如abc123写入url_map表。此时expire_at字段根据用户选择填入具体时间戳永久则为NULLstatus设为1有效visit_count初始化为0。活跃态ACTIVE短链处于可跳转状态。每次HTTP GET请求触发/s/{shortCode}接口系统先校验status1且expire_at IS NULL OR expire_at NOW()通过则执行302重定向并异步更新visit_count。过期态EXPIREDexpire_at非空且小于当前时间或status被手动置为0禁用。此时所有跳转请求均返回HTTP 410Gone明确告知客户端“资源已永久失效”而非404Not Found——这是RESTful设计的关键细节410表示服务器知道该资源曾存在但已主动撤销有利于搜索引擎清理索引。归档态ARCHIVED仅用于历史统计。当短链过期后其访问记录仍保留在url_visit_log表中支持按short_code聚合查询总访问量、日活趋势等但不再参与跳转逻辑。提示这个状态机设计直接规避了“软删除陷阱”。很多初学者会用is_deleted布尔字段标记删除但短链过期后需要保留原始数据供统计同时又要阻止跳转用statusexpire_at双字段组合判断比单字段更精准、更易扩展比如未来加“审核中”“待发布”状态。2.2 为什么拒绝Redis——轻量化的代价与收益项目摘要里强调“不依赖Redis、MQ等额外中间件”这不是技术保守而是对部署场景的精准预判。我见过太多团队为了给短链加个“毫秒级缓存”硬生生引入一套Redis集群结果运维成本远超业务价值。这套代码的取舍逻辑很务实缓存粒度选择短链跳转是典型的“读多写少”场景但热点短链比如内部公告链接可能在1分钟内被请求上千次。如果全走MySQL单台数据库扛不住。解决方案不是上Redis而是在JVM内存里做一层LRU缓存。UrlCacheService类用ConcurrentHashMapLinkedBlockingQueue模拟LRU缓存最近1000个有效短链的long_url和expire_atTTL设为5分钟。这样既避免了外部依赖又把95%的热点请求挡在数据库之外。访问统计的最终一致性实时统计≠强一致。每次跳转时代码先更新内存缓存中的visit_count原子操作再发一个异步任务到线程池批量写入url_visit_log表。这意味着你刷新页面看到的访问数可能比实际慢1-2秒但换来的是数据库写入压力下降80%且不会因DB瞬时故障丢失计数。这种“最终一致”的妥协在内部工具场景下完全可接受。过期检查的懒加载策略expire_at字段在数据库里是精确到秒的时间戳但代码里不做定时扫描清理过期数据。而是等到用户请求/s/{shortCode}时才实时判断NOW() expire_at。这样省去了Quartz调度器的复杂配置也避免了定时任务漏扫导致的“僵尸短链”。2.3 分层架构的物理边界为什么Controller只做协议转换很多新手写的SpringBoot项目Controller里充斥着SQL拼接、日期计算、业务校验结果越改越乱。这套代码的分层非常“教科书级”但每层职责都卡得死死的Controller层ShortUrlController只做三件事——接收HTTP请求参数RequestParam、调用Service方法、封装HTTP响应ResponseEntity。它不碰任何业务逻辑连if (url null)这种校验都不写全部交给Service。Service层ShortUrlService真正的业务中枢。这里处理所有核心规则短码生成算法见3.2节、过期时间计算LocalDateTime.now().plusDays(days)、状态校验checkUrlValidity()、事务控制Transactional注解在此层生效。它暴露给Controller的是清晰的业务方法签名比如createShortUrl(String longUrl, Integer expireDays)。Repository层UrlMapRepository纯粹的数据访问。继承JpaRepositoryUrlMap, Long只用Spring Data JPA的声明式方法如findByShortCodeAndStatus不写一行原生SQL。所有复杂查询如按日期范围统计访问量封装在UrlVisitLogRepository的自定义方法里。Domain层UrlMap,UrlVisitLog实体类严格遵循DDD思想。UrlMap里不放getter/setter以外的业务方法所有状态变更逻辑如markAsExpired()都在Service里实现确保实体纯净。这种分层不是为了炫技而是为了让修改成本可控。比如你要把MySQL换成PostgreSQL只需改application.yml的驱动和URL其他代码零改动如果要增加“按地域统计访问来源”只需在UrlVisitLog实体里加country字段再在Repository里加个查询方法Controller和Service几乎不用动。3. 核心细节解析从短码生成到访问埋点的魔鬼步骤3.1 短码生成62进制编码的工程化落地短链的“短”本质是把一个递增ID如123456789压缩成更短的字符串。项目采用62进制0-9a-zA-Z比Base64少2个字符和/避免URL编码问题。关键不在算法本身而在如何保证全局唯一、无碰撞、可预测长度// UrlCodeGenerator.java private static final String CHARS 0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ; public static String encode(long num) { if (num 0) return 0; StringBuilder sb new StringBuilder(); while (num 0) { sb.append(CHARS.charAt((int) (num % 62))); num / 62; } return sb.reverse().toString(); // 注意必须reverse }这段代码看似简单但藏着三个易错点1.num % 62必须强制转intlong取模结果仍是long而charAt()只接受int不转型会编译报错2.sb.reverse()不可或缺62进制转换是“余数倒序排列”比如123转62进制123%6261→’Z’123/6211%621→‘1’所以结果是”1Z”但正确编码应为”Z1”必须反转3.长度控制逻辑默认生成的短码长度不固定ID越小越短。项目在ShortUrlService.createShortUrl()里做了兜底若生成的短码长度6位则在前面补‘0’如encode(1)得”a”补成”00000a”。这样保证所有短码至少6位既美观又便于前端路由匹配/s/{shortCode:.*}。实操心得我最初用UUID做短码结果发现uuid.toString().substring(0,6)有极低概率重复测试10万次撞了3次。62进制自增ID才是王道但ID不能直接用数据库自增主键——因为主键可能被删除导致空洞影响短码连续性。项目用了一个独立的url_id_generator表每次INSERT ... SELECT MAX(id)1 FROM url_id_generator来获取下一个ID确保单调递增。3.2 过期时间控制永久与临时的二元哲学短链的“永久有效”不是expire_at NULL这么简单它牵扯到数据库索引、查询性能、业务语义三层数据库设计url_map表中expire_at字段定义为DATETIME NULL。这里必须允许NULL否则永久短链无法插入NOT NULL约束会失败。索引优化为加速过期判断建复合索引INDEX idx_status_expire (status, expire_at)。查询时WHERE status1 AND (expire_at IS NULL OR expire_at ?)能命中该索引避免全表扫描。业务语义隔离永久短链expire_at IS NULL和临时短链expire_at NOW()在代码里被统一处理但统计报表时需区分。比如运营同学问“永久短链里访问量Top10是哪些”SQL就得写成WHERE expire_at IS NULL AND status1不能漏掉status1条件——因为永久短链也可能被手动禁用。注意LocalDateTime.now()在Java里是本地时区时间而MySQL的NOW()函数返回服务器时区时间。如果服务器时区是UTC8而Java应用时区设为UTC就会出现“明明没过期查询却返回空”的诡异问题。项目在application.yml里强制配置了spring.jpa.properties.hibernate.jdbc.time_zone: Asia/Shanghai确保JPA生成的SQL里时间比较逻辑一致。3.3 访问统计的并发安全从synchronized到Async的演进早期版本用synchronized锁住updateVisitCount()方法结果压测时QPS卡在200以下。后来改成AtomicLong但发现MySQL的UPDATE url_map SET visit_count visit_count 1 WHERE short_code ?在高并发下仍有行锁等待。最终方案是读写分离异步落库内存计数器UrlMap实体里加volatile long visitCountInMemory字段每次跳转先visitCountInMemory.incrementAndGet()异步写库用Async注解标记asyncUpdateVisitLog()方法将访问记录short_code,ip,user_agent,timestamp写入url_visit_log表定时同步另起一个Scheduled(fixedDelay 30000)任务每30秒把内存计数器值批量更新到数据库SQL形如UPDATE url_map SET visit_count ? WHERE short_code ?。这样做的好处是用户感知的跳转延迟10ms纯内存操作数据库写入压力分散到后台线程且url_visit_log表可以按天分表url_visit_log_202405避免单表过大。常见问题Async方法必须在另一个类里调用否则Spring AOP代理失效。项目把异步逻辑抽到VisitLogService里ShortUrlController调用visitLogService.recordVisit()而不是在ShortUrlService里直接调用Async方法。4. 实操过程从零启动到二次开发的完整路径4.1 本地环境搭建三步走绕过90%的坑步骤1数据库准备MySQL 5.7执行sql/url_map.sql建表。注意两个关键点-url_map.short_code字段设为UNIQUE索引防止重复短码-url_visit_log.created_at字段用TIMESTAMP DEFAULT CURRENT_TIMESTAMP确保插入时自动赋值。-- url_map.sql 关键片段 CREATE TABLE url_map ( id BIGINT PRIMARY KEY AUTO_INCREMENT, short_code VARCHAR(16) NOT NULL UNIQUE, long_url TEXT NOT NULL, expire_at DATETIME NULL, status TINYINT DEFAULT 1 COMMENT 1active, 0disabled, visit_count BIGINT DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE url_visit_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, short_code VARCHAR(16) NOT NULL, ip VARCHAR(45) NOT NULL, user_agent TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_short_code (short_code), INDEX idx_created_at (created_at) );步骤2配置文件修改application.ymlspring: datasource: url: jdbc:mysql://localhost:3306/short_url?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: your_password jpa: hibernate: ddl-auto: validate # 强烈建议设为validate避免误删表 show-sql: true properties: hibernate: jdbc: time_zone: Asia/Shanghai # 自定义配置 short-url: cache: max-size: 1000 expire-after-write: 300 # 秒提示ddl-auto: validate是安全底线。create会清空表update可能破坏索引只有validate会在启动时校验实体与表结构是否一致不一致直接报错逼你手动修正。步骤3IDE导入与启动IntelliJ IDEAFile → Open → 选择项目根目录 → 选Maven项目 → 等待依赖下载完成启动类ShortUrlApplication.java右键Run控制台输出Started ShortUrlApplication in X.XXX seconds即成功测试接口curl -X POST http://localhost:8080/api/v1/shorten -H Content-Type: application/json -d {longUrl:https://example.com,expireDays:7}返回JSON含shortUrl字段即通。4.2 接口清单与调用示例RESTful的正确打开方式HTTP方法路径参数说明返回示例POST/api/v1/shorten{longUrl:https://...,expireDays:7}创建短链expireDays0为永久{shortUrl:http://localhost:8080/s/abc123,expireAt:2024-05-20T10:00:00}GET/s/{shortCode}路径参数302跳转到原地址HTTP 302 Location: https://example.comGET/api/v1/stats/{shortCode}路径参数查询访问统计{shortCode:abc123,totalVisits:152,lastVisit:2024-05-15T14:22:33}PUT/api/v1/disable/{shortCode}路径参数手动禁用短链HTTP 200 {message:Disabled}实操心得调用/s/{shortCode}时浏览器会自动跟随302跳转看不到响应体。用curl -I http://localhost:8080/s/abc123看响应头确认Location字段正确。如果返回404八成是short_code拼写错误或数据库里没这条记录。4.3 二次开发指南三个最常被问的扩展点扩展点1增加“密码保护”功能需求某些短链只想让知道密码的人访问。只需三步1. 在UrlMap实体加password_hash VARCHAR(64)字段存BCrypt加密后的密码2.ShortUrlController新增POST /api/v1/shorten/protected接口接收password参数用BCryptPasswordEncoder.encode()加密后存库3.ShortUrlService.redirectToLongUrl()里加校验若password_hash ! null比对BCryptPasswordEncoder.matches(inputPassword, dbHash)不匹配则返回HTTP 401。扩展点2接入企业微信机器人告警需求当某个短链24小时内访问量突增300%自动发消息到企微群。利用Spring Boot Actuator的/actuator/metrics端点// 在定时任务里 ListMetric metrics restTemplate.getForObject(http://localhost:8080/actuator/metrics/http.server.requests?taguri:/s/{shortCode}, Metric.class); if (metrics.get(0).getValue() 1000) { // 伪代码实际需解析JSON weComRobot.sendAlert(短链 shortCode 访问异常); }扩展点3导出访问明细CSV需求运营同学要下载某短链的所有访问IP。新建VisitExportServicepublic void exportVisitLog(String shortCode, HttpServletResponse response) throws IOException { ListUrlVisitLog logs visitLogRepository.findByShortCode(shortCode); response.setContentType(text/csv;charsetUTF-8); response.setHeader(Content-Disposition, attachment; filename shortCode _visits.csv); try (PrintWriter writer response.getWriter()) { writer.println(IP,UserAgent,Time); logs.forEach(log - writer.println(log.getIp() , log.getUserAgent() , log.getCreatedAt())); } }Controller里加GET /api/v1/export/{shortCode}接口调用此方法。5. 常见问题与排查技巧实录那些文档里不会写的坑5.1 高频问题速查表现象可能原因排查命令/步骤解决方案创建短链返回500日志报DataIntegrityViolationExceptionshort_code重复如手动插入了相同值SELECT * FROM url_map WHERE short_code xxx;清理重复数据检查短码生成逻辑是否真唯一/s/{code}返回404而非302status0被禁用或expire_at已过期SELECT status, expire_at FROM url_map WHERE short_code xxx;用PUT /api/v1/disable/{code}反向操作或更新expire_at访问统计数字不增长Async方法未生效常见于Service类内自调用查看日志是否有TaskExecutor启动信息确保EnableAsync在启动类上且异步方法在独立Service里中文长URL创建失败报URISyntaxExceptionURL未UTF-8编码URLEncoder.encode(longUrl, UTF-8)前端调用前必须encode后端RequestBody自动decode本地启动报Failed to configure a DataSourceapplication.yml里spring.datasource配置缺失或格式错误检查YAML缩进确认url、username、password同级用在线YAML校验器如yamllint.com验证格式5.2 真实踩坑记录一次线上事故的复盘事故现象某天凌晨短链跳转成功率从99.9%暴跌至60%大量请求卡在302响应上。排查过程- 第一步top看CPU发现Java进程占98%但线程数正常 → 排除死循环- 第二步jstack pid抓线程快照发现200线程阻塞在com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal→ 数据库连接池耗尽- 第三步查Druid监控面板ActiveCount达100最大连接数WaitCount持续上涨 → 连接泄漏- 第四步翻代码发现VisitLogService.recordVisit()里有个Connection conn dataSource.getConnection()但没写finally{conn.close()}→ 手动获取连接未释放。根因为兼容老版本MySQL驱动部分DAO用了原生JDBC但忘了关连接。修复后加了SonarQube规则Resource should be closed。经验总结永远不要相信“连接会自动回收”。Spring Boot的DataSource是HikariCP它会检测连接泄漏并打印警告日志HikariPool-1 - Connection leak detection triggered但很多人忽略了这条日志。建议在application.yml里加hikari.leak-detection-threshold: 6000060秒让泄漏立刻暴露。5.3 性能调优备忘录从100QPS到5000QPS的实测参数优化项默认值生产推荐值效果HikariCP最大连接数1050解决连接池瓶颈需配合MySQLmax_connections调整JPA批量插入大小150spring.jpa.properties.hibernate.jdbc.batch_size50减少SQL执行次数JVM堆内存-Xmx512m-Xmx2g避免频繁GC尤其内存计数器占用较多日志级别INFOWARN除com.example.shorturl包减少IO压力logging.level.com.example.shorturlDEBUG保留关键日志提示压测时用wrk -t12 -c400 -d30s http://localhost:8080/s/abc123模拟并发观察actuator/metrics/jvm.memory.used和actuator/metrics/http.server.requests指标变化。当http.server.requests.status.302.count增速放缓而jvm.threads.live持续上升大概率是线程池或连接池打满。6. 最后分享一个小技巧用Git Hooks自动化测试很多同学写完功能就直接push结果CI跑测试失败才发现少写了个Transactional。我在团队里推行一个简单的pre-commit Hook# .git/hooks/pre-commit #!/bin/bash echo Running unit tests before commit... mvn test -q if [ $? -ne 0 ]; then echo Tests failed! Commit aborted. exit 1 fi echo All tests passed. Committing...给脚本加执行权限chmod x .git/hooks/pre-commit。这样每次git commit前自动跑单元测试5秒就能发现问题比等CI反馈快10分钟。项目里的test目录包含ShortUrlServiceTest覆盖了短码生成、过期判断、访问统计三个核心场景足够作为Hook的守门员。这套短链源码的价值不在于它有多炫酷而在于它把每一个看似简单的功能都拆解成了可验证、可调试、可扩展的原子模块。当你亲手把它跑起来改一个字段加一个接口修一个Bug你就真正理解了SpringBoot不是“写个RestController就行”而是关于如何用最少的组件构建最健壮的状态流转系统。本文还有配套的精品资源点击获取简介一套开箱即用的SpringBoot短链接系统能将长URL快速生成简洁短链点击后自动302跳转到原地址。短链创建时可选永久有效或设置具体过期天数到期后自动失效不再跳转。每次用户访问都会触发记录后台实时累计总访问次数并支持按短链维度查询历史访问量。项目包含完整Java后端代码、MySQL建表脚本url_map.sql、标准Maven配置pom.xml以及中英文双语说明文档README.md/README.en.md。所有功能基于Spring Web、Spring Data JPA和MySQL实现不依赖Redis、MQ等额外中间件本地启动只需JDK8、MySQL5.7和IDE即可运行。结构清晰分层合理适合练手REST接口开发、数据库CRUD操作、HTTP重定向处理及基础运维监控逻辑。本文还有配套的精品资源点击获取