简介:基于SpringBoot实现的电影院购票系统完整源码,面向计算机、电子信息工程等专业的学生,适合作为毕业设计、课程设计或期末大作业。系统采用B/S架构与MVC模式,整合Java、Maven、Mybatis、Ajax、Vue等技术栈,涵盖用户购票、场次管理、订单处理等典型业务模块。压缩包共773个文件,约26.14MB,包含109个Java源码、58个Vue前端组件、156个JavaScript脚本、49个CSS样式,以及数据库脚本、配置文件、说明文档和演示视频等,方便直接导入IDEA运行调试。代码经严格测试,并配有构建启动脚本(如1-install.bat、2-run.bat、3-build.bat),可快速搭建环境。目前已有614人学习下载。对于需要掌握SpringBoot全栈开发流程、完成课程项目或快速复用一套可用系统的学习者,这份资源提供了清晰的代码结构和完整的前后端交互示例,能有效减少从零搭建的时间成本。
1. 电影院购票系统代码的核心:不是 CRUD,是最后一把椅子的归属
电影院购票系统在 Java 项目里常被当作练手 CRUD,但真正决定这套代码能不能上线、能不能扛住周末晚场抢座的,从来不是排片表和用户表怎么增删改查,而是“同一场次的最后一个座位,两个人同时点下单,最后卖给谁”。卖票的本质是并发抢一个座位资源,所以这篇就围绕 Java 电影院购票系统代码的主线展开:先拆数据模型,再实现锁座、下单、支付回调,最后给出一份能直接复现的并发验证方法。适合 Java 基础已过关、想搞懂并发下单边界的人,也适合准备把影院项目写进项目经历、等着被面试官追问的人。
2. 先建表再写代码:把“座位”当作有状态的资源来建模
2.1 为什么不能只建一张订单表:拆开才能让数据库帮忙兜底
很多人写第一版电影院购票系统,都是先建一张订单表,字段里塞一个seat_ids字符串,下单时把座位号拼接进去。单机调试怎么跑都通,一上并发就露馅:MySQL 行锁的粒度是行,不是字段,你把三张座位号塞进一个字符串,数据库根本没法对单个座位做并发约束。两个人同时读同一行、查到同样三个座位号、各自拼接成订单,后写的人直接覆盖前一个,一票两卖就这么出来的。
用面向对象编程的思路重新建模,按领域把数据拆成四张表:排片表showtime、场次座位表showtime_seat、订单表ticket_order、订单座位表order_seat。每张表只回答一个明确的问题:这场的电影几点放、这个场次的某个座位现在什么状态、谁买了什么、订单里的座位明细是什么。这个分层不是过度设计,它直接决定了后面锁座能不能用一条带条件的 UPDATE 写完,也决定了事务回滚时哪些数据能被恢复。
2.2 建表 SQL:showtime_seat 的 status 字段是整个系统的核心
我一般用 Spring Boot + MyBatis + MySQL,JDK 8+ 起步,数据库引擎必须 InnoDB。四张表的建表语句如下:
-- 排片表:一场电影在一个影厅里的一个放映时间 CREATE TABLE showtime ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL COMMENT '电影ID', hall_id BIGINT NOT NULL COMMENT '影厅ID', start_time DATETIME NOT NULL COMMENT '开场时间', end_time DATETIME NOT NULL COMMENT '散场时间', price DECIMAL(8,2) NOT NULL COMMENT '基础票价', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-售票中 0-已停售', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_start_time (start_time), KEY idx_movie_id (movie_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='排片表'; -- 场次座位表:某个场次里每个座位的实时状态 CREATE TABLE showtime_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, showtime_id BIGINT NOT NULL COMMENT '排片ID', seat_row VARCHAR(4) NOT NULL COMMENT '排号,如A排', seat_no INT NOT NULL COMMENT '座号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0-可售 1-锁定 2-已售', price DECIMAL(8,2) NOT NULL COMMENT '本场次该座位售价', locked_by VARCHAR(32) DEFAULT NULL COMMENT '锁定该座位的订单号', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁预留', UNIQUE KEY uk_showtime_seat (showtime_id, seat_row, seat_no), KEY idx_showtime_status (showtime_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场次座位表'; -- 订单表 CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '业务订单号', user_id BIGINT NOT NULL, showtime_id BIGINT NOT NULL, total_amount DECIMAL(8,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待支付 1-已支付 2-已取消 3-已退款', expire_at DATETIME NOT NULL COMMENT '支付截止时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_id (user_id), KEY idx_showtime_id (showtime_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表'; -- 订单座位表:订单和座位的关联,唯一索引是兜底保险 CREATE TABLE order_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, showtime_id BIGINT NOT NULL, seat_row VARCHAR(4) NOT NULL, seat_no INT NOT NULL, UNIQUE KEY uk_order_seat (showtime_id, seat_row, seat_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单座位表';showtime_seat.status是整个系统里最关键的字段:0 可售、1 锁定、2 已售。锁定和已售必须分开,因为锁定是临时的,一个用户锁了座只有 15 分钟支付时间,超时后这台座位要“后悔药”——被释放回可售;已售则代表这笔钱已经进了账,永远不能因为超时被放回去。如果把两个状态合并成一个“不可用”,定时释放时会把别人已经付完款的座位也清了,那才是大事故。
order_seat上的联合唯一索引是最后一道保险。锁座逻辑理论上已经阻止了并发,但万一哪天代码改坏、状态字段被脏写,唯一索引会直接拒绝第二条插入,保证一个场次的一个座位只能出现在一个订单里。这个设计是“事故兜底”,不是业务主键,要保留。
2.3 Maven 工程结构与实体类怎么摆:分层的边界就是事务的边界
项目结构按 Spring Boot 惯例分四层,事务逻辑只允许出现在 service 层:
src/main/java/com/cinema ├── controller/OrderController.java ├── service/OrderService.java ├── mapper/ShowtimeSeatMapper.java ├── mapper/TicketOrderMapper.java ├── mapper/OrderSeatMapper.java ├── entity/ShowtimeSeat.java ├── enums/SeatStatus.java └── util/OrderNoGenerator.javacontroller 只做参数校验和返回值包装,mapper 只做 SQL 映射,真正调用事务注解、判断锁座结果、决定回滚的,全部在OrderService里。这个边界定死以后,事务才不会因为“在某处多 new 了一个 service”而悄悄裂成两个事务。
实体类里最重要的不是字段齐全,而是状态字段不要用魔法数字。定义一个SeatStatus枚举:
public enum SeatStatus { AVAILABLE(0, "可售"), LOCKED(1, "锁定"), SOLD(2, "已售"); private final int code; private final String desc; SeatStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } }ShowtimeSeat实体就对应那张核心表,字段名和表字段一一对应。这里有个参数要说明:price要从排片表冗余到每张场次座位表上,因为影院常见“早场特惠、晚场原价”,同一个座位在不同场次价格不同,锁座那一刻必须把价格定死在座位的行记录上,订单金额直接从锁到的座位算,否则排片表改了价,已锁订单金额也跟着变,对账永远对不平。
3. 用 Java 跑通售票核心链路:锁座、下单、支付回调
3.1 锁座的核心:带状态条件的 UPDATE
很多人第一次写锁座,都是先查再改:
// 错误示例:并发下两个线程都查到 status=0,然后都往下走 ShowtimeSeat seat = showtimeSeatMapper.selectById(seatId); if (seat.getStatus() == 0) { showtimeSeatMapper.updateStatus(seatId, 1); }这段 Java 代码在并发下必翻车:两个线程同时 SELECT 到 status 都是 0,然后依次执行 UPDATE,都以为自己锁座成功,最后一个座位就卖给了两个人。SELECT 和 UPDATE 之间不是原子的,这就是所谓“先查后改”的竞态。
正确做法是把状态判断直接写进 UPDATE 的 WHERE 条件里,让数据库的行锁来裁决:
@Update("UPDATE showtime_seat SET status = 1, locked_by = #{orderNo}, version = version + 1 " + "WHERE showtime_id = #{showtimeId} AND seat_row = #{seatRow} " + "AND seat_no = #{seatNo} AND status = 0") int lockSeat(@Param("showtimeId") Long showtimeId, @Param("seatRow") String seatRow, @Param("seatNo") Integer seatNo, @Param("orderNo") String orderNo);这条 UPDATE 在 InnoDB 里是“当前读”:执行时会对命中的那一行加排他锁,然后判断 status 是否为 0。两个事务同时执行时,第一个事务拿到行锁并修改 status 为 1,第二个事务等到锁释放后再判断 WHERE 条件,发现 status 已经不是 0,影响行数为 0。MyBatis 的lockSeat返回值是 int,也就是 affected rows,用这个返回值判断就能知道自己的锁到底成没成。
3.2 事务里失败的整单回滚与 Redis 锁的补偿差异
锁座很少只锁一个座,通常是一下子选三四张连座。只要有一个座位没锁到,整个订单就得作废,之前锁到的那些座位也必须还回去。这件事交给@Transactional做最省心:
@Service public class OrderService { private static final long ORDER_TIMEOUT_MINUTES = 15; @Resource private ShowtimeSeatMapper showtimeSeatMapper; @Resource private TicketOrderMapper ticketOrderMapper; @Resource private OrderSeatMapper orderSeatMapper; @Transactional(rollbackFor = Exception.class) public String reserve(Long userId, Long showtimeId, List<String> seatKeys) { // 座位格式如 "A:1","A:2",先排序固定加锁顺序,避免死锁 seatKeys.sort(Comparator.comparing(k -> k.split(":")[0]) .thenComparing(k -> Integer.parseInt(k.split(":")[1]))); String orderNo = OrderNoGenerator.generate(); for (String seatKey : seatKeys) { String[] parts = seatKey.split(":"); int affected = showtimeSeatMapper.lockSeat( showtimeId, parts[0], Integer.parseInt(parts[1]), orderNo); if (affected == 0) { throw new BizException("座位已被锁定或售出: " + seatKey); } } // 所有座位都锁到了,再写订单 BigDecimal total = calculateTotal(showtimeId, seatKeys); LocalDateTime expireAt = LocalDateTime.now().plusMinutes(ORDER_TIMEOUT_MINUTES); ticketOrderMapper.insert(orderNo, userId, showtimeId, total, expireAt); for (String seatKey : seatKeys) { String[] parts = seatKey.split(":"); orderSeatMapper.insert(orderNo, showtimeId, parts[0], Integer.parseInt(parts[1])); } return orderNo; } }几个参数和边界要说明:
rollbackFor = Exception.class必须写。Spring 默认只对 RuntimeException 回滚,如果你在锁座后抛了一个受检异常,事务不会回滚,前面锁掉的座位会永久卡在“已锁定”,那比业务报错更恶心。- 锁座的循环顺序要先排序。四个座位的锁定有先后,如果两个用户恰好选了两组交叉的座位,各自按不同顺序去锁行,就可能互相等对方释放行锁,形成死锁。按座号排序后,所有人锁行顺序一致,死锁概率降到最低。
- 这段逻辑里不需要手动“补偿释放”之前锁到的座位。因为 MySQL 行锁产生的“状态改为 1”和订单写入在同一个事务里,任何一步抛异常导致回滚,前面所有 UPDATE 都会被回滚,座位自动回到 0。但如果你在锁座前拿了 Redis 分布式锁,Redis 锁不在数据库事务管辖范围内,必须用 try-finally 手动释放,这是两条完全不同的释放路径,混在一起最容易漏。
3.3 支付回调的幂等写法:重复通知不能重复出票
支付回调是整个链路里最容易变成黑匣子的地方:支付渠道的超时重试、消息队列的重复投递、人工补单,每一个环节都可能把同一个支付结果送过来两次。回调处理的第一件事不是改状态,而是查状态:
@Transactional(rollbackFor = Exception.class) public void handlePayCallback(String orderNo) { TicketOrder order = ticketOrderMapper.selectByOrderNo(orderNo); if (order == null) { throw new BizException("订单不存在: " + orderNo); } // 幂等返回:已经支付过的订单,重复回调不重复处理 if (order.getStatus() == 1) { return; } // 订单已取消或已退款,不允许支付回调把状态改回去 if (order.getStatus() == 2 || order.getStatus() == 3) { throw new BizException("订单已关闭,无法支付"); } // 超过支付截止时间,钱收了也不能出票,要进入退款流程 if (order.getExpireAt().isBefore(LocalDateTime.now())) { throw new BizException("订单已超时,请联系退款"); } ticketOrderMapper.updateStatus(order.getOrderNo(), 1); showtimeSeatMapper.markSoldByOrderNo(order.getOrderNo()); }markSoldByOrderNo的 SQL 同样带条件:
UPDATE showtime_seat SET status = 2 WHERE locked_by = #{orderNo} AND status = 1这里有个容易被忽略的细节:支付成功后,座位状态从 1 锁定直接改成 2 已售,条件里必须带着status = 1。如果去掉这个条件,一旦订单状态机出错、已取消的订单进了支付回调,就会把已经释放回可售的座位直接标成已售,场上凭空少一个座位,比锁座失败更难排查。
4. 并发下单的两种锁方案:MySQL 行锁和 Redis 分布式锁怎么选
4.1 MySQL 行锁能扛住多大的购票并发
一个普通影厅少则 80 座,多则 200 座,同一场次的座位天然就是有限的。单机 MySQL 的 InnoDB 行锁完全可以扛住这种量级的抢座压力——2012 年春运的秒杀系统还有不少是数据库行锁扛下来的,更别说一个影院的晚场。前提是锁座的 UPDATE 必须命中索引,InnoDB 才能精准锁那几行;如果showtime_id, seat_row, seat_no上没有索引,这条 UPDATE 会变成全表扫描,锁的是整张表,一个用户锁座时整个影院所有场次都动弹不得。
连接池参数我一般这样设:
| 参数 | 建议值 | 理由 |
|---|---|---|
| 初始连接数 | 5 | 平时低负载,不占资源 |
| 最大连接数 | 20 | 单机并发选座 20 个并发事务足够,再多是给数据库加压力 |
| 连接等待超时 | 3 秒 | 拿不到连接快速失败,比排队等死好 |
单场 200 个座位意味着极端情况下同时有 200 个用户对应 200 个事务在锁行,但这个数字是理论峰值。实际影院的晚场黄金位就那几十个,热点行的竞争远没有那么大。所以第一版方案不需要上 Redis,把数据库这条锁链路做扎实,90% 的场景已经够用。
4.2 Redis 分布式锁:跨场次、跨库时才需要站在 MySQL 前面
什么情况下 MySQL 行锁不够用?最常见的两种:一是系统按场次分库分表了,锁座事务可能落在不同的库上,数据库行锁管不住跨库资源;二是同时开了多个应用实例,请求被负载均衡到不同 JVM,每个 JVM 各自连数据库,虽然数据库行锁仍然有效,但大量无效的锁竞争请求会打到 MySQL 上。这时常见做法是把 Redis 分布式锁放在 MySQL 行锁前面,先做一轮快速筛选,锁不到的请求直接返回“座位已被抢”,不再去数据库排队:
public boolean tryLockSeat(Long showtimeId, String seatKey, String requestId) { String lockKey = String.format("cinema:lock:%d:%s", showtimeId, seatKey); // 加锁,3 秒自动过期,防止进程挂了锁变死锁 Boolean ok = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 3, TimeUnit.SECONDS); return Boolean.TRUE.equals(ok); } public void unlockSeat(Long showtimeId, String seatKey, String requestId) { String lockKey = String.format("cinema:lock:%d:%s", showtimeId, seatKey); // 用 Lua 保证“比对+删除”原子执行,防止删掉别人刚拿到的锁 String lua = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(lua, Long.class), Collections.singletonList(lockKey), requestId); }requestId用 UUID 生成,是这次加锁的唯一凭证。释放锁时必须带着它去 Lua 里比对,只删自己加的那把锁。很多人在这一步简化成“先 GET 再 DEL”,两行代码之间如果锁刚好过期、另一个线程恰好拿到了同一把锁,就会把别人的锁误删,然后各自都以为自己在临界区里,分布式锁形同虚设。
4.3 锁过期时间、重试次数与降级开关怎么设
Redis 锁的过期时间是最难拍的参数。太短,业务还没执行完锁就到期,后面的请求冲进来;太长,用户锁住座位不支付,座位被别人占着。我一般按“预估业务耗时 × 3 + 余量”来定:锁座加订单写入的数据库操作在几十毫秒内完成,3 秒足够;但如果你的业务里还要调远程接口核销会员优惠券,网络抖动一下就到几百毫秒,3 秒仍然够。真遇到极端慢查询,宁可让锁超时后被人抢走,也不能让锁长时间占着,因为用户可以重新下单,资源绝不能死锁。
还有一个容易忽略的降级开关:Redis 不可达时,不能直接让整个下单接口报 500。我会在配置里加一个开关,Redis 锁失败时放行到 MySQL 行锁那条路,让数据库来兜底。多一次无效查询顶多是性能差一点,但至少不会因为缓存集群抖动导致一场票完全卖不了。这个降级开关在线上要默认打开,并且要监控 Redis 锁失败的次数,如果频繁触发降级,说明锁过期时间或业务耗时已经失衡了。
5. 五个把购票系统搞挂的坑:现象、原因、解决
5.1 一票两卖不是玄学:先查后改在并发下必翻车
现象:压测报告里显示同一个座位被两个订单同时支付成功,客诉电话打爆。
原因:代码写成了“SELECT 查座位状态,if (status == 0) 再 UPDATE 改成锁定”,这中间隔着一次网络往返和一个 JVM 线程调度,两个线程完全可能都读到 0,然后都执行 UPDATE。SELECT 不锁行,它只给你一个过期的快照。
解决:把状态判断写进 UPDATE 的 WHERE 条件,用 affected rows 判断胜负。这条规则同样适用于所有“先查后写”的资源占用类逻辑,库存扣减、优惠券领取、房间预订,全是同一个套路。
5.2 锁座成功但订单没有落库:事务边界画错
现象:数据库里 showtime_seat 的 status 变成了 1,但 ticket_order 表里找不到对应的订单,座位被白白锁住。
原因:锁座和订单写入不在同一个事务里。常见的是把锁座抽成了一个独立方法并用@Transactional标注,然后在另一个事务方法里调用它,Spring 的默认传播行为是 REQUIRED,正常情况下会合并到同一个事务。真正翻车的是在 service 里自己 try-catch 吞掉了异常,或者事务方法内部抛的是受检异常而rollbackFor没配,导致 Spring 认为“执行完了”而提交了前面的锁座 UPDATE。
解决:@Transactional(rollbackFor = Exception.class),并且不要在事务方法内部吞异常。如果业务确实需要捕获某些已知异常,捕获后要么抛出另一个 RuntimeException,要么标记TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
5.3 订单取消后座位一直锁着:缺少自动释放
现象:用户选了座进支付页,最后没付钱,15 分钟后这个座位仍然显示“已锁定”,后面的观众买不了。
原因:没有“订单超时释放座位”这个定时任务。锁座只是临时的,必须有配套的回收机制。
解决:我一般用定时任务每 30 秒扫一次,SQL 用 JOIN 精确定位超时订单锁的座位,并且只回收“待支付且已过期”的订单,绝不碰已支付订单:
UPDATE showtime_seat st JOIN ticket_order t ON st.locked_by = t.order_no SET st.status = 0, st.locked_by = NULL WHERE t.status = 0 AND t.expire_at < NOW() AND st.status = 1这里的 JOIN 条件st.locked_by = t.order_no是钥匙:它保证了只释放“那个超时订单自己锁的座位”,不会因为状态判断失误把别的订单的座位也释放了。SQL 里的三个条件缺一不可,尤其是最后的st.status = 1,防止扫到一个已经是已售状态的座位又把它置回 0。
5.4 Redis 锁被误删:释放时没比对 requestId
现象:某次线上告警显示同一座位有两个用户同时进入下单流程,但数据库最终没有卖重。看起来“没出事”,但分布式锁已经失效了。
原因:线程 A 拿到锁后业务执行超过 3 秒,锁自动过期;线程 B 拿到同一把锁;A 执行完 finally 释放锁时,直接用无条件 DEL,把 B 的锁删了;线程 C 也拿到锁进来了。A、B、C 同时在临界区里。
解决:释放锁必须用 Lua 脚本比对requestId,只有值对得上才删除。这不算高性能优化,这是分布式锁正确性的底线。代码在 4.2 节,拿来直接用,不要改成 GET + DEL 两行。
5.5 支付回调重复执行:幂等表没建唯一索引
现象:渠道方超时重发支付结果,同一笔订单被回调四次,订单表状态被来回改写,最后出票记录和支付流水对不上账。
原因:回调处理函数没有先查订单当前状态,直接在收到通知时执行“状态改成已支付、座位标记已售”,重复通知就把状态又“覆盖”了一遍。更隐蔽的坑是并发场景下两次回调几乎同时到达,都通过了自己的状态判断。
解决:两层保险。业务上先查订单状态,已经是已支付就直接返回成功;数据库层依赖order_seat的唯一索引兜底,重复插入会抛 DuplicateKeyException,事务回滚后幂等返回。不要把幂等判断只写在代码里,代码判断在极端并发下同样有竞态窗口,只有唯一索引是绝对的。
6. 把并发验证写成一个 Java 小工具:100 个线程抢一个座位
系统的并发逻辑对不对,不能靠“我点开两个浏览器试了一下”来验证。我习惯在写完锁座功能后立刻写一个并发验证小工具,用 CountDownLatch 模拟 100 个用户同时抢同一个场次的同一个座位,看最终成功下单数是不是 1:
public class ReserveConcurrencyTest { public static void main(String[] args) throws Exception { int threadCount = 100; Long showtimeId = 1L; String seatKey = "A:1"; CountDownLatch ready = new CountDownLatch(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); AtomicInteger successCount = new AtomicInteger(); String url = "http://localhost:8080/api/v1/order/reserve?showtimeId=" + showtimeId + "&seatKeys=" + seatKey; for (int i = 0; i < threadCount; i++) { final long userId = 10000 + i; new Thread(() -> { ready.countDown(); try { start.await(); // 用 userId 区分请求,用 HttpClient 发真实 HTTP 请求 int code = HttpClientUtil.post(url, "userId=" + userId); if (code == 200) { successCount.incrementAndGet(); } } catch (Exception ignored) { // 失败不计数 } finally { done.countDown(); } }).start(); } ready.await(); start.countDown(); done.await(10, TimeUnit.SECONDS); System.out.println("成功下单数: " + successCount.get()); System.out.println(successCount.get() == 1 ? "锁座逻辑正确" : "锁座逻辑已被并发击穿"); } }运行前先把 Spring Boot 服务启动好,然后用测试用户的 token 或者直接调无鉴权的测试接口。注意每个线程用不同的 userId,否则会被 controller 的幂等参数拦截,测的就不是座位锁而是接口去重了。这个工具的价值在于:它能让你在 CI 里一键跑出“同座位并发下单成功率”,而不是靠肉眼在数据库里翻状态字段。我自己曾经就吃过亏,开发环境一切正常,压测一上来,数据库连接池被打满,原因是锁座 SQL 没走索引锁了全表。从那以后,每个服务上线前的并发验证都会写上这类脚本,而不是等线上客诉来敲门。希望帮到你。
本文还有配套的精品资源,点击获取