Java高并发票务系统实战:座位锁定、防超卖与订单状态机
做票务系统这些年我最怕的不是业务复杂度而是开票瞬间那波流量。这个“基于Java的大型赛事门票预订与座位选择系统”本质上就是要在几十万人同时点进来的时候保证座位不超卖、订单不丢、支付能对上账。大多数刚接触这类项目的朋友往往卡在“我知道要写一个预订系统但不知道从哪下手”——是先做数据库还是先做并发控制选座和抢票到底怎么融合成一条链路这篇文章是我做完整个项目后的一次完整复盘会从需求拆解、数据建模、核心链路实现、性能优化到线上踩坑逐个讲透。适合正在做毕业设计、准备Java后端面试、或者想独立从零搭建一个高并发业务系统的朋友。我会尽量把每一步为什么这么做、当时踩了什么坑、实际压测数据长什么样都写清楚你照着这个思路走至少能少折腾半个月。1. 项目整体设计与技术选型1.1 核心需求拆解一场票务系统要扛住什么拿到“大型赛事门票预订与座位选择”这个需求第一步不是写代码而是把业务场景里的矛盾点列出来。我习惯把所有需求抽象成四个核心问题用户要什么能快速看到赛事场次、选到心仪座位、下单支付、收到电子票。座位要什么一个座位同一时刻只能被一个订单锁定不能超卖不能卖重。系统要什么高峰期扛得住高并发库存扣减准确订单状态流转闭环。运营要什么能看到每场次的售卖情况能手动释放座位能处理退款。这四个问题里最难的是第二个和第三个交叉的部分——座位唯一性和高并发。一场热门比赛如果有5万个座位开票瞬间可能有几十万人同时在线抢而数据库里每一行座位记录的状态变更必须绝对准确。这就决定了你选的技术方案必须同时满足强一致性和高吞吐这俩天然有冲突所以整个架构设计的核心其实就是“怎么在冲突里做平衡”。从需求层面还要考虑一个容易被忽略的点赛事门票和普通商品不一样它有强位置属性。用户不是买一个抽象SKU而是买“C区3排12座”这个具体坐标所以传统电商那套“库存总量减一”的打法只能作为辅助真正的主流程必须围绕“座位级锁定”来做这也是这个系统和普通秒杀系统的本质区别。1.2 技术栈选型为什么是这套组合技术栈我最终选的是Spring Boot MyBatis-Plus MySQL Redis RabbitMQ Vue3核心服务用Java 8部署上Nginx做负载均衡。先说为什么用Java而不是Go或者Node——不是因为Java性能碾压而是因为这个场景需要的中间件生态、事务管理、并发控制工具Java这边最成熟网上能查到的生产级案例也最多团队招人、排查问题都方便。具体到框架层面组件选型理由核心框架Spring Boot 2.7生态成熟自动配置省事和中间件集成方便ORMMyBatis-PlusSQL可控性强分页、条件构造器好用性能损耗低数据库MySQL 8.0 InnoDB行级锁支持好事务可靠运维成本低缓存/分布式锁Redis 6.x Redisson热点数据缓存、库存计数、分布式锁一把梭消息队列RabbitMQ异步处理订单通知、支付回调削峰填谷前端Vue3 SVG/Canvas座位图可视化渲染交互响应快选型过程中我做过一次对比像秒杀这种场景有人会说用纯Redis做库存订单异步落库但这套方案在票务系统里有个问题——座位选择需要实时反馈“这个座位能不能点”纯异步会导致用户选完座、提交订单时才发现座位没了体验很糟糕。所以我的主线还是数据库事务保证一致性Redis做加速和计数两条腿走路。1.3 系统总体架构与核心模块我把系统按业务边界拆成了五个模块单体应用内部分层没有一上来就上微服务因为前期业务量用单体完全够微服务带来的分布式事务问题反而会拖慢开发进度。五个模块分别是赛事与场次模块维护体育赛事、场馆、场次信息提供查询接口。这个模块比较简单就是标准的CRUD唯一要注意的是赛事列表和座位图这类读多写少的数据要做缓存。场馆与座位模块管理座位布局和座位状态是整个系统的核心模块之一。座位状态的变更空闲、锁定、已售、预留必须走统一接口不允许其他模块直接改库。订票下单模块处理用户从选座到生成订单的完整链路包含座位锁定、防超卖校验、订单创建是并发压力最集中的地方。订单与支付模块负责订单状态流转、支付回调处理、超时关闭、退款。这里要和支付渠道对接幂等是重中之重。消息与通知模块通过RabbitMQ发送下单成功通知、支付结果通知、座位释放提醒顺便在高峰期做削峰。模块划分的原则是“高内聚、低耦合”每个模块对外只暴露接口内部实现可以随时替换。比如后期如果要把座位锁定逻辑从数据库方案换成Redis Lua脚本方案只改座位模块内部就可以了不会影响订单模块。2. 数据模型设计座位与订单怎么组织才不乱2.1 核心表结构设计数据模型是这种系统的地基设计错了后面所有代码都在填坑。我最终沉淀下来五张核心表先看建表SQL的关键字段-- 场次表 CREATE TABLE session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id BIGINT NOT NULL COMMENT 关联赛事ID, venue_id BIGINT NOT NULL COMMENT 场馆ID, session_time DATETIME NOT NULL COMMENT 场次时间, sale_status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开售 1预售中 2已售罄 3已结束, total_seat_count INT NOT NULL, sold_seat_count INT NOT NULL DEFAULT 0, KEY idx_event_id (event_id), KEY idx_session_time (session_time) ) ENGINEInnoDB COMMENT场次表; -- 座位表 CREATE TABLE seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL COMMENT 场次ID, area_code VARCHAR(10) NOT NULL COMMENT 区域编码如A/B/C, row_no INT NOT NULL COMMENT 排号, col_no INT NOT NULL COMMENT 列号, seat_status TINYINT NOT NULL DEFAULT 0 COMMENT 0空闲 1锁定 2已售 3预留, lock_expire_time DATETIME DEFAULT NULL COMMENT 锁定过期时间, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, seat_code VARCHAR(32) NOT NULL COMMENT 座位编码如A-03-12, UNIQUE KEY uk_session_seat (session_id, seat_code), KEY idx_session_status (session_id, seat_status) ) ENGINEInnoDB COMMENT座位表; -- 订单表 CREATE TABLE ticket_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, user_id BIGINT NOT NULL, session_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, order_status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消 3已退款 4已完成, expire_time DATETIME NOT NULL COMMENT 支付截止时间, pay_time DATETIME DEFAULT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, version INT NOT NULL DEFAULT 0, UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id, order_status), KEY idx_session_status (session_id, order_status) ) ENGINEInnoDB COMMENT订单表; -- 订单元数据表订单和座位的关联 CREATE TABLE order_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, seat_id BIGINT NOT NULL, session_id BIGINT NOT NULL, UNIQUE KEY uk_seat (seat_id) ) ENGINEInnoDB COMMENT订单座位关系表; -- 支付流水表 CREATE TABLE payment_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, payment_no VARCHAR(32) NOT NULL COMMENT 支付流水号, order_no VARCHAR(32) NOT NULL, pay_amount DECIMAL(10,2) NOT NULL, pay_status TINYINT NOT NULL COMMENT 0处理中 1成功 2失败, third_trade_no VARCHAR(64) DEFAULT NULL COMMENT 第三方支付流水号, callback_time DATETIME DEFAULT NULL, UNIQUE KEY uk_payment_no (payment_no), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB COMMENT支付流水表;这几张表里最核心的设计是order_seat表对seat_id建了唯一索引。这意味着同一个座位ID永远不可能出现在两条订单记录里即使上层代码有逻辑漏洞数据库这层也能兜底防重。这是票务系统的安全底线必须靠数据库约束而不是靠应用层逻辑来保证。2.2 座位编码规则与前端可视化映射座位编码看着简单但设计得好不好直接影响后续所有逻辑。我采用的规则是“区域编码-排号-列号”比如A-03-12代表A区3排12座编码里不存放场馆ID和场次ID因为这两个信息已经在seat表里通过session_id关联了编码只需要在场次内唯一就行。设计时要注意两点一是区域编码建议用字母而不是数字因为用户在选座页面上看到的是“A区”“B区”字母和前端展示直接对应排查问题也直观二是排号和列号要支持两位数别用一位数存不然以后场馆扩容要改表结构。前端座位图我用SVG渲染因为座位数量大时Canvas虽然性能更好但SVG的DOM映射天然方便做事件绑定和状态样式切换。后端的座位接口直接返回seat_code和状态前端按“区域 - 排 - 列”构建坐标系每排座位根据列号等距排列锁定状态的座位直接置灰加锁图标。这里有一个小细节返回给前端的座位列表要按区域和排号排序不然前端渲染时会出现座位错位看起来像bug实际是排序问题。2.3 事务边界与索引优化事务边界这块我踩过一个大坑刚开始做的时候把“锁定座位 创建订单 扣减库存 发送消息”全部放在一个事务里结果高峰期事务持有时间过长数据库连接被占满系统直接雪崩。后来我把事务拆成两个第一个事务只做核心操作锁定座位、创建订单、扣减场次已售计数。这个事务内的SQL必须精简化能一条UPDATE完成的绝不用两条。第二个事务或异步任务负责发送通知、记录日志这类非关键操作不参与主事务。设计事务边界时还有一个原则事务内绝对不能调用远程接口或等待外部响应。比如用户选座后要调支付接口这件事绝对不能放在锁定座位的事务里因为支付接口的响应时间不可控一个慢支付能拖死整个事务池。正确的做法是先锁定座位并创建待支付订单事务提交后再异步发起支付链接或前端跳转支付。索引优化方面核心是两条座位表要建(session_id, seat_status)联合索引因为选座页面最常见的查询是“某个场次下所有空闲座位”订单表要建(user_id, order_status)和(session_id, order_status)联合索引分别支撑“我的订单列表”和“后台按场次查订单”这两个高频查询。另外所有订单号、支付流水号这类业务唯一键都要建唯一索引这不仅是查询优化更是幂等控制的最后防线。2.4 订单状态机设计订单状态机我是按这个思路设计的待支付 - 已支付 - 已完成待支付超时后转已取消已支付后可以申请退款转已退款。这个状态机看起来很简单但落地时要注意两个细节。第一个细节是状态流转必须带版本号。也就是说更新订单状态时要带上version条件比如UPDATE ticket_order SET order_status 1, version version 1 WHERE order_no ? AND order_status 0 AND version ?防止并发情况下两个线程同时把同一笔订单从待支付改成已支付。第二个细节是取消和支付是一对矛盾操作——用户可能在最后几秒完成支付同时定时任务判断超时把订单取消了。所以取消订单时不能只判断当前时间还要判断支付流水表里是否有成功的流水记录有就不能取消要等支付状态通知。我额外加了一张payment_record表来记录支付流水这样即使订单状态流转出错也能通过流水表追溯到底发生了什么。支付回调模块按payment_no做幂等同一个回调到达十次也只更新一次。3. 核心环节实现抢票、选座、支付这条链路3.1 防超卖的库存扣减设计票务系统最怕的就是超卖——5万个座位卖出5万零1张票这在体育赛事票务里是重大事故。防超卖我用了三层防护只有三层全过才算真正稳。第一层是SQL级的条件更新。锁定座位的SQL不能写成“先SELECT查状态再UPDATE改状态”因为查和更新之间存在时间窗口两个并发请求可能同时查到座位空闲然后都执行UPDATE导致一个座位被锁两次。正确写法是一条 UPDATE 带上状态条件int rows seatMapper.lockSeat(sessionId, seatId, System.currentTimeMillis() LOCK_EXPIRE_MS); // 实际SQL: // UPDATE seat SET seat_status 1, lock_expire_time ? // WHERE session_id ? AND id ? AND seat_status 0 // AND (lock_expire_time IS NULL OR lock_expire_time NOW())只有当影响的rows等于1时才说明这个座位被你成功锁定否则就是被别人抢先了。这条SQL的意义在于把“检查状态”和“更新状态”合并成一个原子操作完全依赖数据库行锁来保证并发安全。第二层是order_seat表的唯一索引。即使第一层SQL因为某些极端情况没拦住比如通过后台接口强行插入数据唯一索引也会直接报错从数据库层面拒绝同一个座位出现在两个订单里。这层是最终兜底绝对不能省。第三层是Redis库存计数。我在Redis里维护每个场次的剩余座位数用decr命令做预扣扣到负数就拒绝下单。这一层主要用来挡流量高峰——如果所有请求都打到MySQL上去做行锁竞争数据库很快会扛不住所以先用Redis秒级判断“还有没有票”。3.2 座位锁定与临时占用座位锁定是整个系统的灵魂。用户点了一个座位这个座位不能立刻变成“已售”因为用户还没付钱但也不能让别人也能点否则会出现两个人同时买同一个座位的纠纷。正确的做法是引入“临时锁定”状态相当于把座位占住给用户留出支付的时间。我设定的锁定策略是15分钟在座位表里用seat_status 1和lock_expire_time两个字段配合表达“锁定中”这个状态。用户选座后执行上面那条UPDATE语句把座位从空闲改成锁定同时写入过期时间。事情还没完锁定状态必须能被释放否则用户占着座位不支付座位就永远被锁死了。释放分三种途径用户主动取消订单直接改状态为空闲定时任务扫描过期的锁定记录批量释放用户点击另一个座位时前端先请求释放之前的锁定座位。这里要提醒一个细节释放操作也必须带条件更新比如WHERE seat_status 1 AND lock_expire_time NOW()不能无条件清状态否则可能出现用户正在支付定时任务把他座位释放了然后另一个用户买走了这个座位支付成功的用户反而没座了。3.3 分布式锁在选座中的落地单机部署时MySQL的行锁已经能保证并发安全但如果系统扩展成了多个实例就涉及到分布式锁的问题。我一开始没用分布式锁因为座位锁定的SQL本身是原子的多个实例执行同一条UPDATEInnoDB的行锁会自动排队不会出现两个实例同时锁同一个座位。那分布式锁用在哪里我用在两个场景。第一个是后台批量锁座比如运营人员给赞助商预留500个座位这个批量操作必须用分布式锁保证同一时间只有一个线程在跑防止两个运营同时操作同一个区域。第二个是订单超时释放任务这个任务在多个实例上会同时触发不加分布式锁就会重复扫描、重复发通知。分布式锁我用的Redisson因为它的看门狗机制能自动续期不用担心业务执行时间超过锁过期时间。加锁粒度一定要细比如lock(seat: sessionId : seatId)千万不要用lock(seat: sessionId)锁住整个场次那样的话A用户的选座操作会把B用户的选座操作全部阻塞掉性能直接崩。Redisson的锁本身也不是银弹极端情况下Redis主从切换会丢锁但对于票务这种业务来说座位状态最终由数据库兜底Redis锁就算偶尔失效也不会造成超卖只是会把并发压力打回数据库而已。3.4 订单超时关闭与座位释放订单超时关闭我用了“定时任务扫表 延迟消息兜底”两条路并行。定时任务每30秒扫描一次订单表把expire_time小于当前时间且状态为待支付的订单批量改为已取消然后释放对应座位。这种方式实现简单但有一个天然问题扫描存在延迟用户可能在订单过期后30秒内还能完成支付。为了解决这个时间窗口问题我引入了RabbitMQ的延迟队列。用户下单时发送一条延迟15分钟的消息消息到期后进入死信队列消费端收到消息后检查订单是否已支付未支付就执行关闭。延迟队列的优点是准点触发不会像定时任务那样延迟几十秒。两条路径都执行关闭操作也不用担心重复问题因为关闭订单的SQL带了状态条件UPDATE ticket_order SET order_status 2 WHERE order_no ? AND order_status 0第一次执行成功第二次执行时受影响行数是0直接忽略。这是典型的“幂等操作设计”——同一个操作执行多少次结果都一样。4. 性能优化与压测实录4.1 连接池、JVM与线程池调优系统上线前我做了两轮压测第一轮压测让我深刻地认识到很多时候性能瓶颈根本不在这段代码高不高深而在线程池参数、数据库连接池参数这些看起来灰头土脸的配置上。数据库连接池用的HikariCP我最初的配置是maximum-pool-size50压测发现数据库CPU飙到90%以上但接口吞吐量上不去。后来我把连接池降到了20吞吐量反而提升了因为连接数过多时大量线程在争抢数据库CPU和行锁上下文切换开销巨大。这里有个经验值可以参考连接池大小设置为CPU核心数 * 2 1左右如果单库单机16核设30个连接足够了不需要贪多。JVM参数方面我用的是JDK 8 G1垃圾回收器堆大小设置4GB压测时观察Full GC频率只要压测期间Full GC次数不超过个位数就算正常。线程池方面我自定义了一个处理异步任务的线程池核心线程数8、最大线程数16、队列容量1000拒绝策略用CallerRunsPolicy——宁可让调用线程自己慢慢执行也不能把任务直接丢弃。4.2 热点数据缓存与Redis Bitmap方案赛事详情、场次列表这些读多写少的数据我全部缓存到Redis缓存时间设置30分钟。座位图的查询很特殊——一场5万个座位每个座位就是一个对象如果全部用JSON存一次查询要序列化海量数据速度很慢。我最终用的是Redis Bitmap方案。Bitmap的思路是每个场次的座位状态用一段连续的二进制位表示每一位代表一个座位的状态0空闲1占用。5万个座位只需要50000 / 8 6250字节约6KB内存。前端加载座位图时后端直接返回Bitmap的字节数组前端按位解析渲染效率极高。座位状态变更时用SETBIT命令更新对应位O(1)复杂度。不过Bitmap方案有一个限制它只能表达“空闲/占用”两种状态无法区分锁定和已售。我加了一个辅助的Hash结构来记录锁定中的座位集合字段名是座位ID值存的是锁定过期时间查询时先看Bitmap有没有被占再查Hash判断是锁定还是已售。两套数据结构配合既快又准确。4.3 压测场景与瓶颈分析我用JMeter搭了压测场景模拟5000个虚拟用户同时抢购一场热度很高的球赛门票。第一轮压测结果很不理想TPS只有400平均响应时间2.3秒数据库连接池被打满大量请求超时。通过分析慢SQL日志我发现问题主要集中在座位列表查询上——用户刷新选座页面时后端要一次性查出整场座位状态这个SQL没有走索引全表扫描了5万行。优化方案是把“座位状态全量查询”改成“按区域查询”用户默认只加载当前区域的座位切换区域时再加载下一个区域同时把区域座位数据缓存到Redis。优化后TPS直接干到2100平均响应时间降到180毫秒。压测中的另一个瓶颈是座位锁定SQL的锁等待。大量并发UPDATE同一行座位时InnoDB的行锁会让其他线程排队等待锁等待时间一长数据库连接就被占满。我的优化是给座位锁定的SQL加了超时时间innodb_lock_wait_timeout 5同时在前端加了“5秒内只能提交一次选座请求”的限制从源头减少了并发冲突。4.4 高可用与容灾设计虽然这个系统是项目级别的规模但高可用设计不能少至少要把思路理清楚。我做了三个层面的容灾。第一个是Nginx层配置了多台后端实例的负载均衡单台实例宕机后自动摘除不影响整体服务。第二个是Redis和MySQL都做了主从复制主库出问题时手动切到从库这里要注意座位状态变更属于强一致操作如果主从切换可能丢几条更新所以切换后要做一次全量对账——把订单表里的座位和座位表里的状态比对一遍把不一致的修掉。第三个是订单数据每天做全量备份支付流水实施双写一份写MySQL一份写到日志文件万一库数据损坏还能从日志恢复。容灾设计这块我的体会是不要为了高可用而高可用先想清楚系统的恢复时间目标(RTO)和数据恢复点目标(RPO)。对票务系统来说支付数据绝对不能丢座位状态可以接受短暂不一致但必须能对账修复所以备份策略的重心应该放在支付流水和订单数据上。5. 常见问题与排查技巧实录5.1 超卖、重复支付、座位冲突三座大山这三个问题我全踩过每次都是血泪教训分别说一下。超卖第一次压测就超卖了原因是我用了最不该用的“先查后改”模式——先SELECT座位状态再UPDATE改状态。两个并发请求同时查到座位空闲同时执行UPDATE结果都返回成功。这个问题拿行锁或者乐观锁都能解决我用的是条件UPDATESQL里直接带seat_status 0条件更新行数为0就说明被别人抢了。重复支付用户支付时网络抖动他点了两遍支付按钮结果支付渠道回调了两笔成功的支付通知。我最初处理回调的代码没有做幂等导致订单被支付了两次退款逻辑还出了bug。后续引入了payment_record表回调先往这张表插入流水插入失败说明流水号重复直接返回成功忽略。座位冲突这个问题出现的场景比较隐蔽——用户A锁定了座位但没支付用户B在后台用“重置座位”功能把这个座位强制释放了然后用户A支付成功结果一个座位对应了两笔有效订单。解决方法是释放座位前必须检查该座位关联的订单状态只有待支付且已过期的订单才能强制释放。5.2 RedisTemplate 的 increment() 报错问题热词里提到的“RedisTemplate的increment()报错不是integer or out of range”这个坑我在预扣库存的时候也遇到了场景是同一个场次的库存字段有时候用increment(1)有时候用decrement(1)代码内部存储类型不统一报错信息就是ERR value is not an integer or out of range。排查后发现原因有两个一是RedisTemplate默认的ValueSerializer是JDK序列化存进去的值带了类型前缀导致Redis服务端拿到手发现不是纯数字二是同一个key的值被覆盖过比如有人在管理后台手动改了这个key的值为一个字符串再执行increment()就报错。解决方法是统一使用StringRedisTemplate操作计数类key所有数值操作都走字符串序列化同时给库存key设置独立的命名前缀如stock:session:{id}避免和其他类型数据混用。5.3 线程等待与异步任务并行化订单创建成功后我需要同时做几件事发短信通知、发站内信、更新统计报表、推送微信模板消息。如果这些串行执行每个多花200毫秒整体接口就慢了。后来我用线程池做了并行化试过Future和CountDownLatch两种方式。批量锁定座位这个场景用的是CountDownLatch虽然用了多个线程去锁定多个座位但主线程必须等所有座位锁定完成后才能判断是否所有座位都锁成功了否则可能出现锁了A座和B座但C座被抢了订单还是要整体回滚。此时主线程调latch.await(3, TimeUnit.SECONDS)最多等3秒超时还没完成就按失败处理把已经锁定的座位释放还原。异步通知那边则直接用线程池execute不关心执行结果所有通知类任务都放在队列里慢慢消费不能让通知拖慢主流程响应。5.4 排查工具与日志分析线上问题排查我用的最多的工具是Arthas和jstack。有一次系统卡顿jstack抓线程快照后发现有大量线程阻塞在数据库连接获取上瞬间定位到是连接池耗尽有一次接口响应偶尔变慢用Arthas的trace命令看方法执行耗时发现是Redis连接没有复用每次查询都新建连接。日志这块我强烈建议从一开始就引入traceId。我在拦截器里为每个请求生成一个唯一标识放进MDC上下文中日志框架自动打印。这样排查问题时只需要把用户报错的订单号或座位ID关联到请求入口就能通过traceId把一条完整链路的所有日志串起来。没有traceId之前面对几万行并发日志根本无从下手有了之后排查效率提升了一个量级。压测后的慢SQL分析我的习惯是开MySQL慢查询日志设置long_query_time 0.5压测结束后直接捞慢SQL看执行计划。绝大多数慢查询都是索引没建好或者SQL写法让索引失效比如在索引列上使用函数、隐式类型转换这些通过EXPLAIN一查一个准。结尾一点过来人的体会做完这个系统我最深的体会是票务系统的核心从来不是花哨的技术而是把“座位唯一性”和“订单一致性”这两件事用最简单可靠的方式做扎实。很多人一开始总想着上各种高深的中间件结果基础的事务控制和状态机都没做好最后被各种隐蔽的并发bug反复折磨。我的建议是先把最朴素的方案跑通——单库、SQL条件更新、唯一索引、状态机这些看似基础的东西才是系统的承重墙等规模确实撑不住了再照着压测数据做针对性的缓存和架构升级。做项目时多留个心眼把踩过的坑记下来这些实实在在的教训比任何八股文都更能帮你应对下一次挑战。