尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Spring Boot归档与分享模块实战:状态设计、表结构与核心接口

Spring Boot归档与分享模块实战:状态设计、表结构与核心接口 做后端时间长了你会慢慢发现一个功能模块难不难跟功能多少没关系关键是它的“状态”复不复杂。归档与分享模块就是个典型例子归档涉及业务数据的状态流转分享涉及资源对外暴露的状态控制两者还经常交叉——用户想把归档后的历史数据分享给同事这个需求非常普遍但实现起来远比想象中麻烦。我在前后端分离项目实战里完整做过三轮类似模块从最初的简单加个status字段到后来逐步演化成一套独立的归档与分享子系统踩过的坑不少。这篇就把整体的设计思路、表结构、核心接口和排查方法完整梳理出来给正在做Java后端、特别是Spring Boot项目的同学一个可参考的样板。这个模块适合谁看如果你正在处理“历史数据归档”、“内容分享”、“报表外发”这类需求或者在做后台管理系统的数据治理那么这篇的分量和价值会很高。我会用Spring Boot 3 MyBatis-Plus这套在国内后端团队中使用率最高的组合来讲代码结构尽量贴近真实项目而不是教学demo。前端部分只会讲接口协议设计不涉及页面实现但会覆盖前后端联调时的跨域、重复提交、Token传递这些高频问题。整个过程我会尽量把“为什么这么做”讲透这些才是网上文档里最稀缺的内容。1. 模块需求梳理与整体设计思路1.1 先搞清楚归档与分享到底要解决什么问题归档这个需求最早往往不是产品经理提的而是后端自己扛不住的产物。主表数据量到了几千万列表接口越来越慢DBA开始抱怨这个时候“归档”就成了救火方案。但业务侧的诉求完全不一样用户觉得某类数据不常用了想从默认视图里收起来但又不能删——合规要求数据的完整可追溯性。分享需求的出发点又不同通常是业务协作驱动的。用户需要把一条订单记录、一份文档、一个报表页面发给团队外部的人看。既然要外发就得考虑有效期、访问密码、是否能被再次转发、分享者能否随时撤销。你注意这两个需求放在一起时的一个天然矛盾归档是把数据“藏起来”分享是把数据“露出去”同一个数据对象同时被归档和分享时到底按哪个状态生效这就是模块设计中最核心的业务规则。我在第一版设计里简单粗暴地规定“归档数据不允许分享”结果被业务方追着改了三版。最终落地的规则是归档状态与分享状态互相独立是否可见取决于查询端的过滤条件是否可访问取决于分享授权。所以在做任何一张表设计、任何一条接口逻辑之前先把这三个问题拍死归档是逻辑标记还是物理迁移分享的最小粒度是一条记录还是一个集合文件夹/列表/报表被归档的数据能否被新创建分享已有分享是否随归档失效这三个问题不明确后面写代码就是空中楼阁。1.2 方案选型逻辑归档优先物理迁移兜底关于归档方案我见过不少团队一上来就搞“物理归档”把旧数据INSERT到历史表再从主表DELETE。这个思路听起来干净但实际落地非常痛——主表和历史表之间的JOIN操作做了吗业务代码里那些直接查主表的SQL全都要改跨表查询怎么办数据迁移过程中刚好有用户在下单ID冲突怎么办我的建议很明确第一优先级永远是逻辑归档也就是在数据表上增加归档状态字段查询时默认过滤掉已归档数据。逻辑归档的代价是主表数据量不会减少所以它适合数据总量可控、查询性能尚未被击穿的场景。如果数据量真的大到需要物理迁移也要设计成“先逻辑标记、后分批迁移”的渐进式方案而不是一把梭。物理迁移的模式大概是这样的定时任务扫描已经逻辑归档超过30天的数据批量迁移到归档表或者冷存储迁移成功后把主表记录标记为已迁移。主表保留近30天入口查询接口先查热数据未命中再查归档表。这种方式对业务代码的侵入比较小因为归档表结构和主表保持一致MyBatis-Plus这类框架可以直接复用实体类。分享方案选型就简单得多核心是设计一张独立的分享关系表。分享的载体用一个全局唯一的shareToken来标识所有分享访问都通过这个Token定位。不支持自定义短链因为后端项目没必要跟短链服务较劲。分享的具体对象通过“对象类型 对象ID”来抽象这样一套分享逻辑就能覆盖文档、报表、数据条目等多种资源。2. 数据模型设计归档状态与分享关系表2.1 归档字段不要为了省事只加一个标志位有些项目图省事直接复用逻辑删除字段来做归档用deleted_flag表示归档状态。这是我最反对的做法。逻辑删除字段的语义非常严肃它表示“这条数据没了”很多基础组件比如MyBatis-Plus的自动过滤、数据同步CDC工具、搜索引擎的增量更新都会默认过滤掉逻辑删除的数据。如果你把归档状态也塞进去会导致一个可怕的结果归档的数据在所有系统里都消失了根本达不到“可追溯”的要求。我建议单独设计归档字段并且不止一个。来看我常用的归档字段组ALTER TABLE biz_order ADD COLUMN archive_status TINYINT NOT NULL DEFAULT 0 COMMENT 归档状态0-未归档 1-已归档 2-已撤销归档, ADD COLUMN archive_time DATETIME NULL COMMENT 归档时间, ADD COLUMN archive_reason VARCHAR(255) NULL COMMENT 归档原因, ADD COLUMN archive_by BIGINT NULL COMMENT 归档操作人ID, ADD COLUMN archive_version INT NOT NULL DEFAULT 0 COMMENT 归档乐观锁版本号;archive_status用TINYINT而不是BOOLEAN是因为归档场景必然会演化出“撤销归档”这个状态。只给0和1两个值的字段遇到需要反向操作的业务就只能硬删或者新增字段很被动。archive_reason一定要保留。很多时候归档操作不是用户主动发起的而是系统触发的比如订单完成180天后自动归档。没有归档原因后续排查“这条记录为什么不见了”就会变成一场灾难。archive_version是给乐观锁用的防止两个请求同时操作同一条数据的归档状态后面接口部分会详细说。需要特别注意的是索引。归档后最常见的查询模式是“查自己名下未归档的数据”所以归档状态必须进到联合索引里而且放的位置有讲究。比如订单列表查询条件是owner_id archive_status create_time索引设计为(owner_id, archive_status, create_time)而不是(owner_id, create_time, archive_status)。因为等值条件archive_status在前范围条件create_time在后这样索引才能最大程度生效。2.2 分享表拆主表和明细表不要做成一个JSON字段分享需求最大的变数在于分享对象不固定。今天分享的是订单记录明天可能变成分享整个报表。如果直接在分享表里设计一堆业务字段order_id、report_id、doc_id这表最终会变成一张臃肿的大杂烩。正确做法是拆成两张表分享主表和分享明细表。分享主表存分享这件事的公共属性每个分享行为一行记录CREATE TABLE share_info ( id BIGINT PRIMARY KEY AUTO_INCREMENT, share_token VARCHAR(64) NOT NULL COMMENT 分享Token全局唯一, sharer_id BIGINT NOT NULL COMMENT 分享发起人, share_type TINYINT NOT NULL COMMENT 分享类型1-链接分享 2-二维码分享 3-定向分享, password_hash VARCHAR(128) NULL COMMENT 访问密码哈希为空表示无密码, expire_time DATETIME NULL COMMENT 过期时间空表示永久, max_visit_count INT NOT NULL DEFAULT 0 COMMENT 最大访问次数限制0不限制, current_visit_count INT NOT NULL DEFAULT 0 COMMENT 当前访问次数, status TINYINT NOT NULL DEFAULT 1 COMMENT 1-有效 0-已撤销, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, cancel_time DATETIME NULL COMMENT 撤销时间, UNIQUE KEY uk_share_token (share_token), KEY idx_sharer_id (sharer_id), KEY idx_status_expire (status, expire_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT分享主表;分享明细表存具体分享了哪些资源一个分享可以对应多条明细CREATE TABLE share_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, share_id BIGINT NOT NULL COMMENT 分享主表ID, item_type TINYINT NOT NULL COMMENT 资源类型1-订单 2-文档 3-报表 4-目录, item_id BIGINT NOT NULL COMMENT 资源ID, item_name VARCHAR(255) NULL COMMENT 分享时的资源名称快照, item_data JSON NULL COMMENT 扩展数据快照存放分享时刻的关键信息, KEY idx_share_id (share_id), KEY idx_item (item_type, item_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT分享明细表;item_name字段是个容易被忽视但必须加的“快照字段”。为什么因为用户分享一个订单后这个订单的标题可能在业务侧被修改了。如果你在接收方页面动态加载名称就会出现“分享链接打开后标题变了”的尴尬。设计上增加分享时刻的名称快照接收方看到的内容就和分享者当时分享的内容保持一致。item_data这个JSON字段则用来存放一些业务侧需要展示但不想额外查库的信息。比如分享订单时可以把订单金额、商品名称、下单时间冗余进来。这样接收方打开分享链接时主链路只需查分享主表 JSON字段大部分场景不用回查业务表性能非常可观。2.3 三个约束索引、外键与软删除的平衡分享主表和明细表之间一定不能建物理外键。互联网高并发场景下物理外键带来的锁竞争和死锁风险不值得为了图省事去冒。用一个share_id逻辑关联就够了代码里保证事务一致性。archive_status这个字段建议加到业务主表的DDL里而不是用一张独立的归档关系表。原因很简单归档状态的读写频次非常高独立表会让每次查询都多一次关联性能损失不必承担。3. 核心接口实现从归档到分享的完整闭环3.1 归档与撤销归档接口状态机 乐观锁先看归档接口。它的核心逻辑不是UPDATE一条SQL那么简单而是要确保状态流转是合法的。未归档的数据才能归档已归档的才能撤销归档归档中的数据要限制某些敏感操作。这些规则用状态机来控制而不是散落在各种if-else里。Service public class OrderArchiveService { Resource private OrderMapper orderMapper; Transactional(rollbackFor Exception.class) public ArchiveResult archive(Long orderId, Long operatorId, String reason) { // 查询当前数据 Order order orderMapper.selectById(orderId); if (order null) { throw new BizException(404, 数据不存在); } // 使用乐观锁字段防止并发状态下重复归档 int rows orderMapper.update(null, new LambdaUpdateWrapperOrder() .eq(Order::getId, orderId) .eq(Order::getArchiveStatus, 0) .set(Order::getArchiveStatus, 1) .set(Order::getArchiveTime, LocalDateTime.now()) .set(Order::getArchiveReason, reason) .set(Order::getArchiveBy, operatorId) .set(Order::getArchiveVersion, order.getArchiveVersion() 1)); if (rows 0) { throw new BizException(409, 该数据已被其他人归档请刷新后重试); } // 可选归档后要撤销该数据的有效分享业务层往往要求“已归档数据不再对外分享” shareService.cancelShareByItem(order, orderId, 数据已归档); return new ArchiveResult(orderId, order.getArchiveStatus()); } Transactional(rollbackFor Exception.class) public void unarchive(Long orderId, Long operatorId) { Order order orderMapper.selectById(orderId); if (order null) { throw new BizException(404, 数据不存在); } int rows orderMapper.update(null, new LambdaUpdateWrapperOrder() .eq(Order::getId, orderId) .eq(Order::getArchiveStatus, 1) .set(Order::getArchiveStatus, 0) .set(Order::getArchiveTime, null) .set(Order::getArchiveBy, operatorId) .set(Order::getArchiveVersion, order.getArchiveVersion() 1)); if (rows 0) { throw new BizException(409, 该数据已处于未归档状态); } } }这段代码的核心是eq(Order::getArchiveStatus, 0)这个条件它和乐观锁版本号构成了双重保护。更新影响行数为0时能确定是并发冲突或者状态已经被别人改了不用再查一次库对比。这比先查再更的“查改模式”可靠得多因为查改模式中两个并发请求可能同时读到status0然后都执行UPDATE成功导致状态被覆盖。有人会问有了archive_version为什么还要在UPDATE条件里eq archive_status细心的话能发现eq一个状态字段是天然的判断条件能挡掉大部分非法请求能减少无谓的版本号冲突。两者其实是“集约简”和“兜底”的关系。3.2 创建分享接口Token生成与过期策略创建分享的接口要考虑的事情很杂Token不能重复、有效期不能乱、访问密码要加密存储并发还要防重。public class ShareService { Resource private ShareInfoMapper shareInfoMapper; Resource private ShareItemMapper shareItemMapper; public ShareTokenVO createShare(CreateShareRequest request) { // 1. 生成全局唯一的分享Token这里用UUID去除横线后截取前24位 // 加上随机盐可以同时控制长度和不可预测性 String token generateUniqueToken(); ShareInfo shareInfo new ShareInfo(); shareInfo.setShareToken(token); shareInfo.setSharerId(request.getOperatorId()); shareInfo.setShareType(request.getShareType()); shareInfo.setExpireTime(request.getExpireTime()); shareInfo.setMaxVisitCount(request.getMaxVisitCount()); sha.ifPresent(pwd - shareInfo.setPasswordHash(encryptPassword(pwd))); // 2. 保存主表 shareInfoMapper.insert(shareInfo); // 3. 批量保存分享明细 ListShareItem items request.getItems().stream() .map(item - buildShareItem(shareInfo.getId(), item)) .collect(Collectors.toList()); shareItemMapper.insertBatch(items); // 4. 返回分享链接 return new ShareTokenVO(token, buildShareUrl(token)); } }Token生成这里有个细节容易被忽略。直接用UUID当分享链接参数URL会很长而且在一些日志系统里会被截断导致链接不完整。我采用的处理方式是UUID去掉横线后截取24位再拼接6位随机字符最终得到30位长度的Token。Unicode空间下30位随机字符串的碰撞概率已经足够低配合数据库的唯一索引即使真碰撞了INSERT也会报错只需要重试一次即可。过期策略建议做成“双重过期校验”。一是数据库的expire_time字段这是最终依据二是在Redis里设置一个key过期时间设为和分享有效期一致。这样每次访问时先查Redis如果Redis里没有这个key再查MySQL判断是否过期并删除Redis中的缓存。为什么要多此一举因为在分享访问量大的场景下每次都查MySQL判断过期时间会浪费数据库IO加上Redis缓存层可以让大部分过期判断在缓存层完成。3.3 访问分享接口密码校验与访问控制访问分享是高频接口也是安全风险最集中的地方。完整的访问链路是这样的public ShareAccessResult accessShare(String token, String password, Long visitorId, String ip) { // 1. 从Redis缓存取分享信息缓存没有则查MySQL并回填 ShareInfo shareInfo getShareInfoCached(token); if (shareInfo null) { throw new BizException(404, 分享不存在或已被撤销); } // 2. 状态校验 if (shareInfo.getStatus() 0) { throw new BizException(403, 分享已撤销); } // 3. 过期时间校验 if (shareInfo.getExpireTime() ! null shareInfo.getExpireTime().isBefore(LocalDateTime.now())) { throw new BizException(410, 分享已过期); } // 4. 访问次数校验 if (shareInfo.getCurrentVisitCount() shareInfo.getMaxVisitCount()) { throw new BizException(410, 分享访问次数已达上限); } // 5. 密码校验 if (StringUtils.hasText(shareInfo.getPasswordHash())) { if (!isPasswordMatch(password, shareInfo.getPasswordHash())) { throw new BizException(403, 访问密码错误); } } // 6. 更新访问次数这里用独立UPDATE避免占用主行锁太久 shareInfoMapper.increaseVisitCount(shareInfo.getId()); // 7. 返回分享明细 ListShareItem items shareItemMapper.selectByShareId(shareInfo.getId()); return new ShareAccessResult(shareInfo, items); }步骤6里单独提一个increaseVisitCount的自增UPDATE而不是在读取数据后执行整行UPDATE是为了减少对整行数据的锁竞争。访问接口并发量高如果每次都更新整行特别是在版本号字段存在的情况下会频繁触发乐观锁冲突严重影响分享主表后面的数据修改。密码校验这里有个安全细节不要明文存储密码。存的应该是加盐哈希值比如BCrypt或者SHA-256(salt password)。登录密码怎么处理分享密码就怎么处理。很多团队因为分享密码是产品经理提的“临时方案”就放松了安全标准这是大忌。3.4 前后端联调跨域与按钮重复提交前后端分离项目联调阶段最典型的两个问题就是跨域和重复提交。归档和分享模块因为涉及状态变更对这两个问题尤其敏感。跨域问题如果在网关层没处理就得在后端单独配置CORS。Spring Boot 3里建议用WebMvcConfigurer集中配置不要每个Controller写CrossOrigin注解那样太散乱Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowedOriginPatterns(*)和allowCredentials(true)的组合。生产环境中不能无脑放行所有来源建议把前端域名收敛成一个配置项放到Nacos或者application.yml里方便后续修改。按钮重复提交的校验我推荐用Token Redis的“防重令牌”方案。前端在点击“创建分享”按钮时先向后端申请一个幂等Token然后把这个Token放到Request Header里提交业务请求。后端在执行业务逻辑前先校验幂等Token是否存在于Redis且未消费消费成功才继续业务。无论前端怎么重复点击后端同一时刻只会有一个请求能拿到令牌。public class IdempotentAspect { Around(annotation(Idempotent)) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { HttpServletRequest request getRequest(); String token request.getHeader(Idempotent-Token); if (StringUtils.isEmpty(token)) { throw new BizException(400, 缺少幂等Token); } String key idempotent: token; // 在Redis里尝试原子地提交消费记录 // 如果setnx成功说明第一次请求放行否则说明重复提交 Boolean firstCall redisTemplate.opsForValue().setIfAbsent(key, 1, Duration.ofMinutes(30)); if (Boolean.FALSE.equals(firstCall)) { throw new BizException(429, 操作太频繁请勿重复提交); } return joinPoint.proceed(); } }这个方案比“前端disabled按钮”可靠得多。前端按钮禁用只能挡住普通用户挡不住脚本和网络重试。真正的幂等保障必须落在后端前端禁用只是体验优化。4. 常见问题与排查技巧实录4.1 归档后数据从列表消失但用户反馈“还能搜到”这个问题很经典出现原因通常是搜索引擎或数据同步工具没有消费归档状态字段。比如Elasticsearch同步任务只监听了UPDATE事件但同步脚本的过滤条件里还是旧的逻辑把归档状态变化忽略了。解决方案是让同步任务在消费到archive_status变化时同步给索引数据打上归档标记。排查手法第一步永远不是改代码而是查同步日志确认这条数据的变更事件是否被正常消费。4.2 分享链接打不开排查三步走第一步查Redis里Token对应的分享信息是否存在。如果Redis没有查MySQL里的share_info。如果MySQL里也没有基本是Token生成时写库失败了或者被误删了。第二步查过期时间。如果过期时间字段为NULL但业务上认为它应该过期多半是创建接口的入参校验漏了。第三步查状态。如果status0——正常。如果status1但同时expire_time时间比当前时间小说明写了一个“已过期但未撤销”的脏数据需要补一个定时任务兜底清理。4.3 并发归档与分享冲突归档撤销了分享但分享访问只挡住了一半这个问题我实际遇到过。归档接口在事务里先UPDATE了业务表再调用了shareService撤销分享。但因为分享撤销和业务更新不在同一个事务中极端情况下会出现业务数据已归档但分享状态还是有效用户通过旧分享链接访问到了已归档的数据。这个问题的根治方案是引入事务消息或本地消息表。本地消息表的思路是在主事务里先写入一条“撤销分享”事件主事务提交成功后异步任务读取事件并执行分享撤销。这样能保证业务数据归档成功撤销分享的操作一定在后续执行最终一致。如果还要更严格的强一致可以把分享撤销SQL也放到同一个数据库事务里只要分享表和业务表在同一个MySQL实例就能做到。4.4 大量归档数据查询变慢索引也建了为什么还慢索引不是万能的尤其是“归档后数据堆积在同一个大表里状态字段过滤区分度很低”的时候。如果一张表里90%的数据都已经归档status0的查询条件过滤出来的数据量可能好几百万。索引本身无法改变“需要扫描很多页”的事实。解法有几个方向归档数据分区。按archive_time做RANGE分区每一个月的数据落一个分区查询时带上归档时间范围就能走分区裁剪。归档冷热分离。把已归档数据迁移到单独的归档库或冷表主表只保留近期数据。这个方案最彻底能根治主表膨胀问题。增加汇总层。如果归档数据只用于审计查询可以把关键字段冗余到一张轻量的归档汇总表查汇总表而不是主表。我在实际项目里通常是把1和2结合先用逻辑归档保持业务连续性后台定时任务把90天前的归档数据物理迁移到归档表。归档表只读不写查询性能非常稳定。5. 归档与分享模块的扩展思路模块的第一版做完后业务方往往不会就此打住。归档场景可能演化出“定时自动归档”“归档审批流”“归档数据导出”等需求分享场景可能演化出“分享水印”“仅可预览不可下载”“分享文件上传”等需求。设计阶段尽量预留扩展点能让后续迭代轻松很多。比如分享明细表里的item_data JSON字段就是给业务方自定义分享内容展示用的。有的业务需要在分享页面展示一条自定义提示语有的需要展示额外的审批信息。这些在JSON字段里扩展就行不用频繁增加列。但也要注意JSON字段不能滥用涉及查询、排序、聚合的数据必须拆成独立字段否则数据库会很难受。我做归档与分享模块最大的体验是这类功能没有“做完”的时候。归档和分享本质上是数据治理的外围能力业务越复杂对这两个模块的要求就越高。一开始把它当提供一个接口就完事后续一定会付出重构的代价。在需求阶段把状态流和权限边界理清楚把表结构设计得略微冗余一些把幂等和并发防护提前做上后面会省很多事。6. 实操总结与一点个人经验最后再分享一个很实际的建议归档与分享模块的接口设计一开始就要把“操作审计”做进去。谁在什么时间归档了什么数据谁创建了分享、谁访问了分享、访问了几次这些日志在出问题时是你的救命稻草。我见过太多项目上线半年后用户反馈“我好像分享过这个数据但找不到了”然后全链路日志一查发现用户在创建分享时因为接口报错根本没写库。没有审计日志这种问题只能靠猜。另外一个小技巧分享链接打开页的数据接口尽量做成“只读快照”不要实时关联业务主表。这样一方面能减轻主库压力另一方面也避开了“分享的数据被修改后历史分享内容也跟着变”的尴尬。快照字段加上分享时刻的数据内容用户在分享页面看到的就是当时的样子既满足协作需求也满足追溯需求。归档与分享模块其实没有太多高深的技术但每个细节都需要扎实的工程判断。希望这篇梳理能帮你少踩一些我踩过的坑。
返回列表