
暑假 Java 后端实习总结两个月里我做了什么又学到了什么本文记录我的一次 Java 后端实习经历。出于一些考虑文中的产品名称、业务数据、表名和代码都做了一些简化。前言暑假我在一家武汉小规模的互联网公司实习了两个月岗位是 Java 后端开发实习生。这次实习所在的团队并不大技术部前后端测试一共9个人项目也不是什么“日活千万、每秒几万请求”的大型系统就是一个有真实用户、已经运行了一段时间的社区产品每日活跃用户大概1w。不过对当时的我来说它依然比学校里做过的项目复杂很多。学校项目一般是自己从头开始搭数据库表、接口和代码结构都比较清楚。公司项目完全不一样代码量比较大有不少历史逻辑同一个字段可能在用户端、运营后台和定时任务里都被修改。有时候需求看上去只需要改几行代码真正开始做之后才发现它还会影响缓存、统计、通知、审核记录和旧数据。实习期间我参与了帖子、评论、角色、头像、内容审核和群聊机器人等模块的开发也处理了一些测试环境和线上环境的问题。这篇文章不会按照工作日报逐条罗列而是挑几个我印象比较深的需求和问题聊一聊自己当时是怎么做的以及后来有哪些新的理解。一、项目大概是什么样的我参与的是一个二次元为主的兴趣社区项目。用户可以使用不同的虚拟角色发帖、评论、加入群聊也可以申请新的角色和上传角色头像。运营人员则通过管理后台审核帖子、评论、角色和头像同时管理群聊机器人。项目主要使用Java 8Spring BootMongoDBMySQLRedisRedissonMyBatis-PlusCompletableFutureJUnit 5项目整体上是一个 Maven 多模块单体。帖子、评论、角色、头像和群组等数据主要放在 MongoDB 中。系统配置、机器人配置和部分统计记录放在 MySQL 中。Redis 主要用于缓存、登录状态、频率限制和分布式锁。这些技术我之前在学校项目里多多少少接触过一些但那时候基本只是“会用”。真正进入一个已经运行的项目后我才慢慢理解为什么要这样用以及使用之后还会带来哪些问题。二、刚接手项目时我有点不知道从哪里开始实习第一周比较直接的感受就是代码太多了我主要看的部分有admin的前后端代码以及app的后端代码。在自己学习做的项目里一个请求通常就是Controller ↓ Service ↓ Mapper ↓ MySQL但公司项目中一个帖子接口可能同时涉及帖子本身发布角色星球信息话题评论数点赞状态好友关系审核状态缓存消息通知成就系统。一开始我接到需求后会直接搜索接口名称然后从 Controller 往下看。后来发现只看接口调用链还不够。因为有些状态不只在这个接口里修改运营后台、定时任务和异步线程也可能修改同一条数据。后面我逐渐形成了一个比较固定的习惯先找到接口入口看主要 Service找到对应实体和数据库字段全局搜索关键字段的所有读写位置查看相关代码的历史提交列出这个需求可能影响的其他模块最后再开始修改。这个过程看起来有点慢但是借用ai去做效率也是不低的而且很大程度上降低了ai乱改动代码的风险比改完之后不断补 Bug 要好一些。三、第一次真正注意到“异步结果可能已经过期”实习期间我参与比较多的是 AI 内容审核。帖子、评论、角色和头像提交后系统会调用外部 AI 服务进行审核。因为 AI 接口响应比较慢所以部分审核任务是异步执行的。流程大概是用户提交内容 ↓ 后端先保存数据 ↓ 状态设置为“审核中” ↓ 把 AI 审核任务放进线程池 ↓ 接口先返回给用户 ↓ AI 返回结果后再更新数据库一开始我觉得这个流程比较简单无非就是调用接口、解析 JSON然后把结果写回数据库。后来测试时出现了一个问题运营人员已经在后台完成人工审核但过了一会儿状态又被 AI 修改了。3.1 问题是怎么产生的可能的执行顺序是用户提交申请 ↓ AI 开始审核 ↓ 运营人员先人工审核通过 ↓ AI 结果稍后返回 ↓ AI 把状态重新修改原来的逻辑大概相当于AuditRecordrecordfindById(recordId);record.setAiAuditResult(aiResult);record.setStatus(targetStatus);update(record);只要这条数据还存在AI 就会继续更新并不知道人工已经处理过了。我最开始想到的办法也是先查一下状态if(record.getStatus()AUDITING){updateAiResult(record);}但后来继续分析发现这样其实还是不安全。因为“查询”和“更新”是两步操作。可能在线程查询到“审核中”之后人工审核刚好完成接着 AI 线程仍然会继续更新。3.2 把判断条件放进数据库更新里最后采用的思路是不要先查询再判断而是在更新数据库时直接带上条件。简化后的写法类似QueryquerynewQuery();query.addCriteria(Criteria.where(_id).is(recordId).and(status).is(AUDITING).and(audit_time).is(null));UpdateupdatenewUpdate().set(ai_audit_status,aiResult).set(ai_audit_reason,aiReason).set(status,targetStatus);longupdatedCountupdateFirst(query,update);只有状态仍然是“审核中”并且人工审核时间还是空时AI 才允许更新。如果更新数量是 0就说明数据已经被其他流程处理了这个 AI 结果已经过期直接忽略即可。这也是我第一次在真实业务里比较直观地理解“条件更新”的作用。以前学习数据库时知道单条文档更新是原子的但没有真正想过它可以用来解决这种并发状态问题。3.3 用户重新提交也会产生旧结果覆盖处理完人工审核覆盖后又发现了另一个类似的问题。如果用户修改角色资料并重新提交就可能同时存在两次 AI 请求第一次提交 ↓ AI 请求 A 开始 用户修改后再次提交 ↓ AI 请求 B 开始 请求 A 比请求 B 更晚返回 ↓ 第一次结果覆盖第二次提交仅仅判断当前状态是不是“审核中”已经不够了因为第二次提交后状态同样是审核中。所以每次提交时还会生成一个新的审核请求 IDStringrequestIdgenerateRequestId();并保存到当前申请中UpdateupdatenewUpdate().set(ai_audit_request_id,requestId).unset(ai_audit_status).unset(ai_audit_reason);AI 回写时除了匹配业务 ID 和审核状态还要匹配请求 IDCriteria.where(_id).is(recordId).and(status).is(AUDITING).and(ai_audit_request_id).is(requestId);如果用户已经重新提交数据库中的请求 ID 就会发生变化旧 AI 请求自然无法更新新版本的数据。我后来把它理解成一个比较简单的业务版本号。3.4 AI 服务异常时怎么办外部 AI 服务并不总是稳定实际开发中遇到过不少需要兼容的情况请求超时返回状态码异常返回体为空缺少约定字段返回内容不是合法 JSONJSON 外面带着 Markdown 代码块原本约定返回英文实际返回了中文返回了系统没有定义的状态。刚开始写这类代码时我容易把注意力放在正常响应上。但真正联调后会发现异常响应的处理量可能并不少。审核系统有一个比较重要的原则AI 调用失败时不能直接当作审核通过。否则一旦第三方服务异常所有内容都有可能绕过审核。项目中不同内容的处理方式不完全一样有的会继续等待人工审核有的会进入限制可见状态。但总体思路都是记录异常原因保留业务 ID不默认公开。3.5 为什么还需要审核历史角色申请可以被用户多次修改。如果每次都只覆盖当前记录那么最后只能看到最新内容不知道前几次提交了什么也不知道为什么被拒绝。因此审核完成后还需要保存一份快照包括申请 ID第几次提交审核结果审核人拒绝原因审核时间当时的角色名称、简介和头像等信息。保存时可以使用“申请 ID 提交次数”作为业务唯一条件。同一个提交版本重复保存时更新原记录不会不断产生重复历史。当然这种方案保留的是每个提交版本的最终结果。如果以后要求保留运营人员的每一次点击操作还需要单独设计操作流水。3.6 这部分给我的最大收获以前提到异步我想到的主要是“提高响应速度”。做完这部分后我才发现异步最麻烦的不是怎么创建线程而是任务执行完成时原来的业务条件是否还成立后面再看到异步代码时我会主动多想几个问题结果可能乱序吗同一个任务可能执行多次吗用户可能已经提交新版本吗人工可能已经修改状态吗失败之后需要重试吗旧结果回来后如何识别这些都是学校项目里比较少遇到的。四、原以为只是“计个数”的发帖频率限制另一个让我印象比较深的需求是发帖频率限制。需求本身不难理解用户不能在短时间内连续发太多帖子每天也要有一个发布上限。但开始设计后需要确定的问题还挺多按账号、角色还是 IP 限制普通帖和投票帖是否一起计算编辑帖子算不算发帖参数校验失败算不算一次发布失败后要不要计数多个服务实例同时收到请求怎么办修改配置后什么时候生效Redis 异常后是放行还是拒绝这些问题如果没有提前确认最后很容易出现后端认为正确、产品认为不对的情况。4.1 为什么按照账号限流这个产品允许同一个账号使用多个角色。如果按照角色 ID 限制用户切换角色后就可以继续发帖相当于绕过限制。如果只按照 IP又可能误伤同一个网络环境下的多个用户。所以最终使用登录账号的userId作为主要限流维度。4.2 短时间窗口为什么使用 ZSet短时间限制使用 Redis ZSet。score 保存发帖时间戳member 使用“时间戳 随机值”。每次检查时删除时间窗口外的记录查询当前窗口内还有多少条判断是否达到限制发布成功后再记录本次时间。简化后的代码类似longnowSystem.currentTimeMillis();redis.opsForZSet().removeRangeByScore(key,0,now-windowMillis);Longcountredis.opsForZSet().zCard(key);if(count!nullcountlimit){thrownewRuntimeException(发帖频率过快);}发布成功后再添加记录Stringmembernow:UUID.randomUUID();redis.opsForZSet().add(key,member,now);redis.expire(key,Duration.ofSeconds(windowSeconds));member 后面拼随机值是为了避免同一毫秒出现多个请求时互相覆盖。4.3 为什么还需要分布式锁虽然 Redis 的单个命令是原子的但完整流程不是一个命令检查冷却状态 ↓ 删除过期记录 ↓ 查询当前数量 ↓ 发布帖子 ↓ 记录次数如果两个请求同时执行可能都查询到还没有超过限制然后一起发布成功。因此项目中按照userId使用 Redisson 分布式锁。同一个账号的并发发帖请求需要依次处理不同账号之间不受影响。RLocklockredissonClient.getLock(POST:FREQUENCY:LOCK:userId);booleanlockedlock.tryLock(waitSeconds,TimeUnit.SECONDS);锁需要在finally中释放并且释放前要判断是不是当前线程持有。4.4 为什么发布成功后才增加次数如果一进入接口就增加次数可能发生用户提交请求 ↓ 计数加一 ↓ 参数校验失败 ↓ 帖子没有发布这样用户没有成功发帖却消耗了一次额度。所以限流流程是checkLimit();Postpostpublish();recordSuccessfulPublish();returnpost;只有真正发布成功后才计数。不过这个方案也不是完全没有问题。如果帖子已经写入 MongoDB但应用在写 Redis 前发生异常就可能少计算一次。对于普通社区发帖这种小概率误差可以暂时接受。如果是支付或者强风控业务就需要使用更加严格的事务事件或额度预占方案。这也是我在实习中慢慢学到的一点很多技术方案不是“绝对正确”而是看当前业务能接受什么程度的误差。4.5 Redis 异常时该不该放行这个问题当时也让我思考了很久。如果 Redis 异常时所有请求都拒绝那么限流组件本身会影响正常发帖。如果 Redis 异常时直接放行又可能在故障期间失去限流保护。当前业务不是支付和资金相关业务限流主要是防止灌帖所以更倾向于记录异常后降级放行优先保证主流程可用。如果换成登录防爆破、支付或高风险操作策略可能完全不同。4.6 动态配置和本地缓存限流参数保存在 MySQL 系统配置表中。如果每次发帖都查询一次数据库会产生没有必要的压力所以代码中增加了几秒钟的本地缓存。privatevolatileLimitConfigcachedConfig;privatevolatilelongconfigExpireAt;缓存过期后再使用同步块刷新。这里用到的技术并不复杂但是真正在项目里遇到后我才对volatile的可见性和双重检查有了更具体的理解。以前背概念时比较抽象现在能够对应到“多个请求线程同时读取动态配置”这个实际场景。4.7 这个方案还可以怎么改现在回头看当前方案还有一些不足一次检查包含多次 Redis 操作分布式锁会覆盖整个发帖过程短窗口和每日计数不是同一个原子操作Redis 计数失败后没有补偿缺少限流命中次数和降级次数监控。如果继续优化可以用 Lua 脚本合并部分 Redis 操作减少网络交互。但因为帖子必须发布成功后才能确认计数所以即使用 Lua也仍然需要考虑数据库写入和 Redis 计数之间的一致性。五、一个“小需求”牵出来的评论数问题还有一个需求表面上非常简单某些无意义评论需要隐藏但发布者自己仍然能够看到。一开始我以为只要给评论加一个“隐藏”状态然后查询时过滤就可以了。真正改起来才发现它会影响很多地方评论列表评论内容展示帖子评论数回复数量帖子热度删除逻辑成就进度后台审核。5.1 “本人可见”到底指谁这个社区允许同一个账号使用多个角色。因此“隐藏评论只有本人可见”最后确认的含义是只有发布这条评论的具体角色可以看到。同一个账号切换成另一个角色后也不能看到这条隐藏评论。查询条件大概是CriteriavisibilitynewCriteria().orOperator(Criteria.where(status).is(ONLINE),Criteria.where(status).is(HIDDEN).and(author_role_id).is(currentRoleId));5.2 为什么评论数会对不上假设一个帖子有10 条公开评论 1 条角色 A 发布的隐藏评论那么角色 A 应该看到 11 条 角色 B 应该看到 10 条 未登录用户应该看到 10 条如果隐藏评论创建后直接把帖子中的公共replyCount加一其他用户就会看到评论数是 11但点进去只有 10 条。所以隐藏评论不能进入所有用户共享的公开评论数。返回帖子数据时再根据当前角色补充其自己的隐藏评论数。5.3 避免逐个帖子查询帖子列表一次可能返回很多条数据。如果每个帖子都单独查询一次当前角色的隐藏评论就会产生 N1 查询。最后采用 MongoDB 聚合一次传入这一页所有帖子 ID按帖子 ID 分组统计当前角色可见的评论数再将结果填充回帖子列表。这个需求让我比较直观地感受到接口中的一个数字也需要和实际数据展示保持同一口径。5.4 删除逻辑也不能直接复用隐藏评论创建时没有增加公开评论数也没有增加一些公开评论相关的成就进度。因此删除隐藏评论时也不能照搬普通评论删除逻辑。否则可能把帖子评论数错误减一或者把用户的成就进度错误回退。我以前写业务代码时容易把“删除”理解成修改删除状态。后来发现删除前必须先知道这条数据创建时产生过哪些副作用删除时才能决定需要撤销哪些内容。六、机器人管理中的跨数据库问题运营后台需要配置群聊机器人一个机器人还可以加入多个群。机器人配置放在 MySQL 中群成员关系放在 MongoDB 中。MySQL 中采用“一机器人一群一行”的方式保存后台查询时再按照用户 ID 和角色 ID 聚合成一个机器人。6.1 已经在群里的用户为什么不能成为机器人有一次遇到的问题是某个角色本来已经在群里运营再把它设置成机器人时保存失败。排查后发现新增机器人时会固定调用一次加群逻辑。角色已经是群成员再次加群就会触发“重复入群”的校验导致后面的机器人配置没有保存。原来的流程相当于新增机器人 ↓ 执行加群 ↓ 保存机器人配置修改后变成查询是否已经是群成员 ↓ 不是群成员先加群 已经是群成员跳过加群 ↓ 保存机器人配置这个问题的根本原因是“已经在群里” 和 “已经被配置成机器人”实际上是两个不同的状态不能认为已经在群里就不需要继续后面的操作。6.2 先查询再插入也不是绝对安全虽然增加前置查询可以解决大部分重复操作但两个请求同时执行时仍然可能都查询到不存在然后一起插入。所以关键业务还需要数据库唯一索引兜底例如user_id role_id group_id以前我经常认为“代码里判断过了就不会重复”实习后才慢慢意识到应用层判断和数据库约束解决的是不同层次的问题。6.3Transactional解决不了所有事务问题机器人配置在 MySQL群成员在 MongoDB。即使方法上添加了Transactional也不能自动让两个数据库一起提交、一起回滚。可能出现MongoDB 加群成功 ↓ MySQL 保存机器人配置失败 ↓ 两个数据库状态不一致实习期间的方案主要是通过前置检查、幂等和异常日志降低问题概率并没有实现严格的跨库强一致。如果以后要继续完善可以增加带状态的任务表失败重试补偿逻辑定时对账Outbox事务消息。这部分我目前也只是有了基本认识还没有真正独立设计过完整的分布式事务方案。七、关于机器人数据看板除了机器人配置我还参与了机器人触发记录和数据看板。看板需要统计今日触发次数昨日触发次数活跃用户数不同触发类型按小时或按天的趋势关键词分布用户高频问题新手群高频问题。其中有几个细节以前很容易忽略。7.1 时间范围使用左闭右开时间查询使用created_atstart_timeANDcreated_atend_time例如统计 8 月 1 日[2026-08-01 00:00:00, 2026-08-02 00:00:00)这样 8 月 2 日零点的数据只会进入第二天不会被两天重复统计。7.2 昨日数量为 0 时不能直接算增长率增长率一般是(今日数量 - 昨日数量) / 昨日数量如果昨日为 0就会除以 0。项目中可以返回空值让前端展示“暂无对比”而不是随便返回 0%。这个问题很小但如果没有处理接口就可能在没有历史数据时直接异常。7.3 实时统计并不适合所有数据量当前数据规模下可以直接从触发记录中按照时间和类型进行GROUP BY。这样实现简单结果也比较及时。但数据量增大后按时间分组、关键词分组和COUNT(DISTINCT user_id)都可能变慢。如果以后数据量增加可以改成按小时预聚合原始记录 ↓ 小时统计任务 ↓ 小时统计表 ↓ 日报、周报从统计表查询实习期间我没有真正接触特别大的数据量所以这方面更多是根据当前实现做的思考不能说自己有大数据系统经验。八、测试和问题排查给我的帮助实习期间我使用 JUnit 5 和 Mockito 给部分核心逻辑补充了单元测试。例如发帖限流会测试限流关闭时是否直接放行达到阈值后是否进入冷却冷却期间是否阻止发布发布失败后是否增加计数跨北京时间零点时如何重置。评论隐藏会测试发布角色能否看到原文其他角色是否看不到隐藏评论是否影响公共评论数删除隐藏评论时是否错误扣减计数。AI 审核会测试再次提交时是否清理旧结果请求 ID 是否更新AI 状态映射是否正确审核快照是否保存同一提交版本是否产生重复历史。我以前写测试比较容易只覆盖“正常返回成功”的情况。实习后发现真正值得固定下来的往往是边界规则。8.1 我常用的问题排查过程遇到测试或线上问题后我一般会先确认用户 ID业务对象 ID发生时间操作入口预期结果和实际结果。然后根据这些信息检索日志再查询 MongoDB 或 MySQL 中的数据状态。大致流程是确认问题现象 ↓ 根据业务 ID 搜索日志 ↓ 找到请求和异步任务 ↓ 查询数据库当前状态 ↓ 根据时间还原状态变化顺序 ↓ 搜索所有可能修改该字段的代码 ↓ 构造复现场景 ↓ 修复并验证我印象比较深的就是前面提到的 AI 覆盖人工审核问题。单看日志时每一步似乎都成功了把 AI 请求时间、人工审核时间和数据库更新时间放在一起才发现问题出在执行顺序上。8.2 日志不能只写“操作失败”以前我可能会写log.error(AI审核失败);这种日志真正出问题时帮助不大。后来会尽量带上业务类型业务 ID请求 ID当前状态目标状态异常堆栈。例如log.error(AI审核失败, bizType{}, bizId{}, requestId{},bizType,bizId,requestId,exception);尤其是异步任务如果没有业务 ID很难和最初的用户请求对应起来。九、这次实习中做得不够好的地方回头看这两个月肯定也有不少做得不够好的地方。1. 有时过于关注当前需求刚开始接需求时我经常只关注产品明确写出来的主流程没有主动想到旧数据、重复请求和异步结果。后面出现问题之后才逐渐养成全局搜索状态字段和检查其他写入口的习惯。2. 对监控和性能了解得不够我参与了日志排查和数据库查询但没有完整负责过服务监控、容量评估和性能压测。所以我可以分析某个 SQL 或聚合在数据量增大后可能出现的问题但不能把自己包装成有高并发系统经验。3. 跨库一致性只处理了具体问题机器人配置中使用了 MongoDB 和 MySQL我主要处理的是重复加群、状态检查和幂等问题并没有实现完整的跨库事务方案。这部分也是我后续想继续学习的内容。4. 对历史代码的理解有时不够完整项目中部分审核逻辑经过多次调整新旧实现同时存在。刚开始修改时如果只看当前方法很容易忽略其他模块中的旧逻辑。后来我会更多地查看 Git 历史和全局引用但这方面仍然需要继续积累经验。十、两个月实习后我对后端开发的理解实习前我觉得后端开发主要是设计数据库表 写接口 实现业务逻辑 返回数据实习后我觉得还要加上很多内容并发时会不会出错 重复执行是否安全 异步结果是否过期 第三方失败后怎么处理 缓存和数据库是否一致 旧版本是否还能使用 统计口径是否统一 出了问题能不能快速找到原因一个接口正常返回并不代表一个需求已经真正完成。例如AI 审核接口调用成功不代表回写状态一定正确隐藏评论保存成功不代表评论数一定正确机器人加群成功不代表 MySQL 配置一定成功Redis 限流生效不代表数据库和计数绝对一致操作日志保存成功也不代表日志语义一定准确。这些问题听起来比较琐碎却是我这次实习中接触最多的内容。结语这次实习所在的公司规模不大项目也没有特别夸张的并发量其实可以说几乎没有什么并发问题。但对一个第一次正式参与真实项目的大三学生来说这两个月还是让我学到了很多学校项目中接触不到的东西。特别是git相关以及不同环境部署Linux服务器相关的查询日志排查错误以及使用宝塔配置定时任务等。我现在仍然有很多不了解的地方也还没有真正经历过大型系统的设计和优化。因为公司人员少项目需求多公司老师也很少指导我相关技术以及业务代码的规范小公司更看中能不能解决这个需求而对于实习生的培养并没有特别上心但至少经过这次实习我对后端开发以及职场有了更基本的认识也知道了自己接下来应该朝什么方向去走。