简介一份面向Java分布式课程学习的RMI远程调用实验资源适用于华南理工大学分布式实验2的独立完成场景也可供其他高校学生参考。内容围绕学生成绩或教师信息查询程序展开覆盖远程接口定义、服务器端目录服务与远程对象注册、客户端访问调用等完整环节。资源包共16个文件含6个Java源码、5个class编译产物、2个jar依赖包、1个doc说明文档、1个txt与1个sql数据库脚本压缩包2.56MB源码与可运行产物齐全便于直接导入与对照学习。其中JDBC相关jar和sql脚本可用于数据库方式存取数据doc文档则帮助梳理实验结构。已有363人学习或下载适合正在完成RMI实验、需要参考接口设计、服务注册或数据库连接写法的初学者。 能选到“华南理工大学分布式实验2”这门课的同学大概率已经在第一轮实验里被 Hadoop 伪分布式搭建折磨过了。而这一次实验的重心会明显从“把集群跑起来”转向“让分布式系统真正协同工作”——分布式锁、分布式事务、分布式缓存这些关键词会集中出现数据库和缓存的一致性、多节点下的并发控制才是这次实验真正想考察的东西。这篇文章我按自己当年做实验、以及后来带新人时的思路整理一遍先讲清楚实验的整体设计和选型逻辑再把分布式锁、分布式事务这两个核心机制从原理到落地完整走一遍最后附上我在实际验证中踩过的坑和排查技巧。不管你现在是刚拿到题目没头绪还是写完了代码但结果老是差一口气这篇都能给你一个可以直接抄作业的完整路径。1. 实验整体设计与思路拆解1.1 题目到底要我们做什么分布式实验2和第一次的最大区别在于第一次是“搭环境”第二次是“写业务”。实验题目通常是让你基于已有的分布式基础组件去实现一个具备高并发读写、数据一致性保障的小型业务系统。我在做得那份题目里核心要求是这样的设计并实现一个订单与库存管理系统要求支持多客户端并发下单时库存不超卖、订单状态一致并保证系统在部分节点异常时仍然可用。这里其实就隐藏了三件事第一多个服务实例同时处理请求时要防止库存超卖这需要分布式锁来保证临界区互斥第二订单创建和库存扣减跨了多个数据源或多个服务这需要分布式事务来保证最终一致第三热点数据要放到缓存里降低数据库压力这涉及分布式缓存与数据库的一致性。这三件事恰恰对应了热词搜索里那几个高频关键词——redis分布式锁、seata分布式事务原理、分布式缓存。1.2 技术选型为什么是 Redis MySQL Spring Boot技术栈上绝大多数高校实验室给的是 Spring Boot MyBatis-Plus MySQL Redis有些学校会加一个 Seata 来做分布式事务也有学校让你自己用 Zookeeper 实现锁。我当时用的是后者更常见的组合Spring Boot 做服务框架MySQL 存订单和库存数据Redis 做缓存和锁Seata 做事务协调。选这套组合的理由很实在。Spring Boot 生态成熟写业务逻辑快能让你把精力放在分布式问题上而不是框架配置上Redis 天然支持SETNX这类原子命令是实现分布式锁的最低成本方案MySQL 大家都熟事务隔离级别和行锁机制是理解分布式事务的基础Seata 则是最容易上手的分布式事务中间件它对业务代码侵入小AT 模式下你甚至不需要改 SQL。注意如果学校硬性要求用 Zookeeper 实现分布式锁也别慌。ZK 的临时顺序节点实现锁的思路和 Redis 大同小异核心都是“抢占唯一资源”只是 Redis 的 SETNX 更直观ZK 的 Watcher 机制能帮你实现阻塞锁各有优势。我后面讲的原理部分可以照搬到 ZK 场景。2. 核心机制原理解析与实操要点2.1 分布式锁从 SETNX 到 Redisson 看门狗先想一个问题为什么单机版的synchronized或ReentrantLock在这里不够用因为实验要求模拟多个服务实例每个实例是一个独立的 JVM 进程JVM 的锁只能锁住自己进程内的线程锁不住其他进程的线程。所以分布式锁的本质是找一个所有实例都能访问到的第三方存储用它来记录“锁被谁占用了”所有实例在进入临界区之前都去这个存储上抢锁。Redis 实现分布式锁的第一版代码非常简单核心就是一条命令SET key value NX PX 30000。NX表示只有 key 不存在时才设置成功这就保证了“同时只有一个实例能抢到锁”PX设置过期时间防止持有锁的实例宕机后锁永远不释放。这个方案能跑通实验的基础功能但它有一个经典的坑锁的过期时间到了但业务还没执行完锁被自动释放了另一个线程拿到锁进来了于是两笔订单同时扣减同一件库存——超卖还是发生了。实验中要避开这个坑我建议直接引入 Redisson 的RLock。Redisson 内置了看门狗机制当你没有指定锁的过期时间时它默认会给你 30 秒的锁持有时间并且每 10 秒自动续期一次。也就是说只要持有锁的线程还活着看门狗就会一直续期锁永远不会因为业务执行太久而提前释放。这在实验演示阶段特别重要因为你的业务代码里可能会写日志、调接口甚至人为加Thread.sleep()来模拟耗时操作如果没有看门狗稍微卡顿几秒锁就没了。Autowired private RedissonClient redissonClient; public boolean deductStock(Long productId, Integer count) { String lockKey stock:lock: productId; RLock lock redissonClient.getLock(lockKey); try { // 尝试加锁最多等待5秒锁自动过期时间30秒看门狗会续期 if (lock.tryLock(5, TimeUnit.SECONDS)) { // 查库存、扣库存、创建订单 Stock stock stockMapper.selectById(productId); if (stock.getCount() count) { return false; } stock.setCount(stock.getCount() - count); stockMapper.updateById(stock); // ... 创建订单 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } return true; }这段代码里有个细节很多人会忽略finally里释放锁之前一定要判断isHeldByCurrentThread()。因为如果当前线程在等待锁的时候被中断或者压根没抢到锁那么lock.isLocked()可能是 true但这个锁是别人持有的你调用unlock()会把别人的锁释放掉这就是经典的“误删锁”问题。实验报告里如果能写出这个细节老师会对你另眼相看。2.2 分布式事务Seata AT 模式怎么协调多个服务分布式锁解决了并发下的互斥问题但实验里通常还有一个隐藏考点订单服务和库存服务可能是两个独立的数据源甚至两个独立的服务。这时候“扣库存成功但创建订单失败”就会导致数据不一致需要分布式事务来兜底。分布式事务最笨但最直观的方案是两阶段提交2PC但它在实际落地上问题很多同步阻塞导致性能差、协调者单点故障、参与者无法在外面第三方没有实现标准接口时接入。Seata 的 AT 模式本质上是对 2PC 的改良第一阶段Seata 拦截你的 SQL生成“前镜像”和“后镜像”把这些信息写入 undo_log 表同时完成业务 SQL 的执行并提交本地事务第二阶段如果所有分支都成功了异步删除 undo_log如果某个分支失败用 undo_log 里的反向 SQL 回滚数据。我在实验里用 Seata 的流程是这样的下载 seata-server 并启动默认端口 8091然后在自己的服务里引入spring-cloud-starter-alibaba-seata依赖配置file.conf和registry.conf让服务能找到 seata-server最后在业务入口方法上标注GlobalTransactional注解。GlobalTransactional(name create-order-tx, rollbackFor Exception.class) public void createOrder(OrderRequest request) { // 1. 扣减库存操作库存库 stockService.deduct(request.getProductId(), request.getCount()); // 2. 创建订单操作订单库 orderService.create(request); // 3. 锁住用户资金或积分操作账户库 accountService.deduct(request.getUserId(), request.getAmount()); }这段代码是实验里最常见的“跨服务写操作”场景。Seata 做的事情是让这三个数据库操作在逻辑上成为一个全局事务任何一个失败其他已成功的操作都会通过 undo_log 回滚。注意rollbackFor Exception.class这一项很多同学只标了GlobalTransactional没写 rollbackFor结果抛了受检异常事务不回滚排查了很久才发现是这个原因。提示Seata AT 模式要求业务表必须有主键且数据库账号要有创建表、插入 undo_log 的权限。另外MySQL 的binlog建议开启因为 Seata 对数据源做代理时需要一些额外的解析能力虽然 AT 模式不强制依赖 binlog但在某些版本下不开启会在启动时报警告。3. 完整实操流程与关键实现3.1 实验环境搭建与初始化实验的第一步是搭建基础环境。以我用的技术栈为例需要准备的东西清单如下JDK 1.8 或以上版本我用的 1.8稳定不折腾MySQL 5.7 或 8.0注意密码策略和远程连接权限Redis 5.0 以上Linux 下直接apt install redis-server或者用 DockerNacos 或 Eureka 做服务注册发现我用的 NacosSeata-server 1.4.x 版本版本不要选最新和 Spring Boot 的兼容性容易出问题Spring Boot 2.3.x Spring Cloud Alibaba 2.2.x这个组合是当时验证过最稳的先启动 MySQL 和 Redis然后启动 Nacos再启动 seata-server。顺序不能乱因为 seata-server 启动时如果发现注册中心连不上它会尝试用本地文件模式继续运行但后续服务接入时可能出现注册信息不一致的问题。接着创建实验数据库我用了两个库db_stock和db_order模拟不同数据源。每个库里建一张业务表再加上一张 undo_log 表Seata 需要。-- db_stock 中的库存表 CREATE TABLE stock ( id bigint(20) NOT NULL, product_id bigint(20) DEFAULT NULL, count int(11) DEFAULT NULL, version int(11) DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 每张业务库都要建 undo_log 表 CREATE TABLE undo_log ( id bigint(20) NOT NULL AUTO_INCREMENT, branch_id bigint(20) NOT NULL, xid varchar(100) NOT NULL, context varchar(128) NOT NULL, rollback_info longblob NOT NULL, log_status int(11) NOT NULL, log_created datetime NOT NULL, log_modified datetime NOT NULL, ext varchar(100) DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY ux_undo_log (xid,branch_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;3.2 库存扣减与订单创建的落地实现环境准备好之后核心业务代码其实就是一个“下单接口”。我拆成了两个服务stock-service负责库存操作order-service负责订单操作。两个服务分别连自己的数据库通过 Feign 相互调用。预留一个细节库存扣减之前我先把商品的库存信息放到了 Redis 缓存里缓存 key 是product:info:{id}value 是 JSON 字符串。读库存的时候先读缓存缓存没有再查数据库查完之后写回缓存并设置过期时间。这里就牵扯到分布式缓存的一致性实验里由于有 Seata 兜底我采用的策略是“先更新数据库再删除缓存”这也是最不容易出错的 Cache Aside 模式。Service public class StockServiceImpl implements StockService { Autowired private StringRedisTemplate redisTemplate; Autowired private StockMapper stockMapper; Override public boolean deduct(Long productId, Integer count) { String cacheKey product:stock: productId; // 预扣缓存降低并发穿透 Long remain redisTemplate.opsForValue().decrement(cacheKey, count); if (remain 0) { // 扣超了回滚并返回失败 redisTemplate.opsForValue().increment(cacheKey, count); return false; } // 数据库扣减实际以DB为准 int rows stockMapper.deductStock(productId, count); if (rows 0) { // 数据库扣减失败补偿缓存 redisTemplate.opsForValue().increment(cacheKey, count); return false; } return true; } }这一版代码比较直白适合实验演示。但注意它有一个问题如果stockMapper.deductStock执行成功而 Redis 的 decrement 因为网络原因失败了那缓存和数据库就错了。我在实验里为了演示效果把缓存预扣和数据库扣减放在同一段逻辑里并用 Redis 锁保证同一商品的并发扣减串行化这样即便缓存与数据库短暂不一致也能在最终一致性层面被 Seata 的全局事务兜住。3.3 验证与演示环节代码写完还不行实验验收一般要现场演示并发效果所以你得准备一个压测工具。我用的是 JMeter也可以用简单的 shell 脚本配合 curl 模拟并发请求。验证点有两个一是启动 100 个线程同时扣减一个库存只有 50 件的商品最终库存不能为负数二是在订单服务里人为抛出一个异常观察 stock-service 里的库存是否自动回滚。第一个验证点如果你用了分布式锁结果应该是剩余库存为 0也就是不多扣一条记录。第二个验证点你在createOrder方法里故意加一行if (true) throw new RuntimeException(测试回滚)然后调用下单接口再查库存表你会发现库存恢复到下单前的值。这就是 Seata 在日志里生成了反向 SQL 并执行的成果。提示演示时一定要把 Seata 的client.log.file和seata-server的日志等级调成 info 或 debug这样在出现问题时你能通过日志里的 xid 和 branch_id 追踪到具体执行流程。别问我为什么强调这个——我第一次演示时事务没回滚整整查了半小时最后发现是日志里报警告“Could not found global transaction xid”说明GlobalTransactional所在的方法没有被 Seata 的代理拦截。4. 常见问题与排查技巧实录4.1 分布式锁失效类问题集群模式下 Redis 锁丢了吗如果实验环境用的 Redis 是主从集群主节点挂了之后锁还没来得及同步到从节点新的主节点上就没有锁数据别的线程就能再次拿到锁。这属于极端场景实验中一般不会模拟主从切换但如果老师问到你应该能说出来。Redisson 的RedLock算法可以缓解这个问题但真正业界现在更倾向用强一致存储来实现锁。我实验里没上 RedLock因为单机 Redis 足够覆盖验收场景但报告里我写了一段 RedLock 的算法描述算是加分项。锁的粒度太大一开始我把整个下单流程都锁住了锁 key 是order:create导致所有商品的下单请求全部串行化吞吐量惨不忍睹。后来改成stock:lock:{productId}让不同商品的并发互不影响瞬间吞吐量就正常了。这个优化很简单但体现了对锁粒度影响并发性能的理解。看门狗什么时候失效Redisson 的看门狗是默认开启的但如果你手动给lock.lock(10, TimeUnit.SECONDS)传了过期时间看门狗就会退化为普通定时检查不再自动续期。实验中如果非要手动指定过期时间我建议设成 30 到 60 秒然后确保业务逻辑能在超时前执行完毕。4.2 事务回滚不生效类问题GlobalTransactional与Transactional的边界实验中最常遇到的坑是分支服务里的Transactional先提交了本地事务而全局事务随后回滚导致本地已提交的数据没法回滚干净。Seata AT 模式的处理方式是本地事务照常提交但 undo_log 里记录了反向 SQL全局回滚时靠执行反向 SQL 恢复数据。所以你的本地事务必须能正常提交并且 undo_log 能正确写入否则就无法回滚。实践中我每个分支服务里都加了Transactional让 Seata 在数据源代理层截获和记录语句。注册中心扫不到 Seata 服务这个问题通常是 Seata 的registry.conf里的注册中心地址和你的 Nacos 服务地址不一致导致的。检查三项namespace 是否一致group 是否一致cluster 名称是否一致。我那次排错就是因为在 Nacos 里看到的服务列表 cluster 是default而 seata-server 配置的是hust服务一直报找不到全局事务服务。数据库连接池配置Seata 对数据源做了代理如果你的连接池配置了druid的防火墙或者 SQL 过滤规则某些情况下会把 Seata 生成的 SQL 给拦截掉。我当时用的阿里 Druid报了一堆“SQL 被拦截”的日志后来在wall过滤器里放行了 seata 相关的 schema 才恢复正常。4.3 高并发验证中的其他坑高并发压测本身也会暴露不少问题。我整理了一张表按出现频率排的问题现象可能原因解决方式线程池拒绝请求Tomcat 最大线程数不够调大server.tomcat.threads.max数据库连接池耗尽每个请求持有的连接时间过长调大 Druid 连接池大小Redis 连接超时并发太高lettuce 连接构建太慢改用 Jedis 或调大 timeout库存字段出现负数锁没生效或缓存和DB不一致检查锁 key 的粒度与执行顺序订单重复创建锁过期导致两个线程同时进入临界区使用 Redisson 看门狗并延长锁 timeSeata 回滚超时全局事务包含太多分支事务缩短分支事务耗时或提高 SQL 效率这里我想重点说一个很多人容易忽略的问题全局事务超时时间。Seata 默认的全局事务超时是 60 秒如果你的分支事务里有 HTTP 调用这个调用阻塞了 70 秒全局事务就会被 Seata 标记为超时并触发回滚。实验里我遇到过一整个页面卡死的情况最后发现是某个 Feign 调用的默认超时时间是 5 秒而下游服务响应缓慢导致全局事务一直在等待。解决方式是给 Feign 配置合理的超时时间或者将 Seata 的globalTimeout调大。再补一个关于 Redis 缓存的细节。如果你用了缓存预扣库存的方式那么你需要考虑以下几点初始缓存数据必须从数据库加载进来而且不能被多个实例同时加载否则会出现缓存重复初始化。我用了 Redis 的setIfAbsent来保证只有一个实例执行初始化逻辑。后面加了一个Scheduled定时任务每 10 秒把缓存里的库存写回数据库这个思路也顺手用到了 xxl-job 上——虽然实验不一定要求引入 xxl-job但如果你能用它调度一个库存同步任务整体实验的“分布式”味道会更浓。Scheduled(fixedRate 10000) public void syncStockToDB() { // 从 Redis 缓存获取所有商品的库存 SetString keys redisTemplate.keys(product:stock:*); for (String key : keys) { Long productId Long.parseLong(key.substring(product:stock:.length())); Long count redisTemplate.opsForValue().get(key); // 更新数据库 stockMapper.updateCount(productId, count); } }5. 实验做完之后留点思考题给自己如果你做完实验还有余力建议你再去想三个问题这几个问题能帮你把实验报告从“功能完成”的水平拉到“理解透彻”的水平。第一个问题是Redis 分布式锁和 Zookeeper 分布式锁在“性能”和“一致性”这两个维度上各自的取舍是什么实验里你用 Redis 是因为快但它在极端场景下会丢失锁ZK 做锁一致性更可靠但每次加解锁都要经过磁盘写和集群同步性能差很多。你能把这个 trade-off 讲清楚比单纯会写代码更有价值。第二个问题是Seata AT 模式和 TCC 模式的区别是什么AT 模式适合你这种“不改业务 SQL全靠拦截器生成反向语句”的场景TCC 模式则需要你手写 try/confirm/cancel 三个方法代码侵入性强很多但对那些没法用反向 SQL 回滚的业务来说TCC 是必须的。实验里我用的是 AT因为库存和订单的回滚都适合用 SQL 解决。第三个问题是如果去掉 Redis 缓存你的系统还能不能跑如果能那缓存优化到底优化了什么我实际的体会是去掉缓存后系统在 100 并发下数据库连接池会瞬间被打满接口响应时间从 30ms 涨到 1.5 秒。加上缓存后读请求大部分走 Redis数据库只承担写操作整体吞吐量翻了三倍。把这些数据量化到实验报告里说服力比大段文字强得多。最后再讲一个我实验过程中印象最深的小教训在一次模拟节点宕机的演示里我把 stock-service 直接停了这时候 order-service 远程调用库存接口必然失败。由于有 Seata 全局事务订单创建被回滚这没问题。但问题在于 Redis 缓存里的库存已经被预扣了我在deduct方法里先 decrement 了缓存然后调数据库数据库异常后虽然返回了 false但缓存的 decrement 没有被回滚。结果就是缓存里库存 49数据库里库存 50。这个不一致在后续读请求里被无限放大直到下一次定时任务同步缓存时才被纠正。为避免这种问题我在代码里增加了“异常回滚缓存”的逻辑并在 finally 块里做了幂等处理。说实话分布式实验和平时写 CRUD 的最大不同就在这里不是代码跑通就行而是要考虑任何一个环节挂掉之后系统能否自愈和收敛到一致状态。这也是分布式系统最迷人的地方——把不可避免的故障当作设计约束而不是bug来对待。希望这篇复盘能帮你把实验做完也能帮你把分布式这门课真正想传递的东西学到手。本文还有配套的精品资源点击获取