这篇文章想聊的,是Java开发里面最容易被低估的一段路:从一堆业务描述,到一个真正能扛住线上流量、出了问题还能快速定位的服务。之所以想写它,是因为我最近刚把一个库存中心的单体逻辑改造成独立服务,过程中踩了不少坑,也把很多平时“能跑就行”的代码重新理解了一遍。如果你工作了一两年,写过不少CRUD,但还没完整经历过一个服务从设计、开发、压测到上线的全过程,那这篇内容应该能给你一个可参考的样本。
我复盘的是某电商业务线的库存服务,涉及Spring Boot、MySQL、Redis、消息队列和并发控制,核心要解决的是“防超卖”“库存流水可追溯”“接口性能达标”三件事。下文会按我的真实操作路径来写:先讲怎么把业务需求翻译成技术边界,再讲技术选型的取舍,然后是核心代码的实现节奏,接着是压测阶段才暴露出来的隐患,最后聊上线前必须做对的三件事和几个让我熬夜的坑。
1. 需求落地的第一步:把业务描述翻译成技术边界
1.1 我接到的是一个什么样的需求
需求原文其实很短:“现在库存是在订单服务里同步扣的,库存老不准,性能也不行,要拆出来做一个独立的库存服务,保证不超卖。”
这种描述在业务方嘴里很正常,但落到技术上,需要立刻追问几个问题:拆出来之后,订单服务通过什么方式调用?扣减库存是同步还是异步?超卖的定义是什么?这里的“不准”是指数据对不上,还是指接口超时引发的用户体验问题?如果这些问题不问清楚,后面写出来的代码大概率是“表面上完成了拆分,实际上把问题搬了个家”。
我梳理出来的核心业务规则有三条:第一,扣库存必须精确,不允许出现库存扣成负数;第二,每一次扣减和回滚都要有流水记录,能回答“某个订单在某时刻扣了多少、因为什么原因回滚”这类审计问题;第三,高峰期的扣减操作不能拖垮订单主链路,接口响应时间要有明确上限。
1.2 从用例到领域模型的翻译过程
把业务规则变成代码之前,我会先画一遍核心实体和它们之间的关系。库存服务里最核心的三个概念是:库存条目(sku对应的可售数量)、库存流水(每一次变动记录)、库存冻结/释放动作(订单生成时冻结,支付完成时扣减,取消时释放)。
有人会问:这是不是要用DDD那套体系?我的看法是,对于这种单一业务域的服务,完全不需要把DDD模式化地铺开。我更倾向于“轻量领域建模”:把库存条目作为聚合根,所有针对库存的写操作都从它进入,库存流水作为不可变的事件记录跟着事务一起写入。这样一来,业务规则收敛在一个地方,不会出现多个Service各写各的逻辑。
这个模型的涵义是:扣减操作一定要走统一的领域入口,不能出现某个接口直接改库存数量却不写流水的情况。我在项目里用一个InventoryService作为唯一入口,任何调用方都不允许绕过它直接操作Mapper。
1.3 范围界定:哪些该做,哪些不该做
一次拆分最容易犯的错就是把无关功能一起卷进来。当时业务方希望“顺便”把库存盘点、预警通知也一起做了。我的判断是,第一版只做扣减、回滚、查询、流水四个能力,盘点和预警作为后续迭代。因为盘点涉及对账逻辑,预警涉及阈值规则配置,这两块一旦在核心链路里抢时间,很容易让接口变慢,也会让上线风险变大。
这个决策的副产品是:服务边界清楚了,压测目标也更容易制定。我给自己定的非功能指标是:单机支撑300 TPS的扣减请求,P99耗时小于200毫秒,数据库和缓存双写容忍最终一致,但扣减动作本身必须强一致。
2. 技术选型的取舍逻辑:不追新,只追可维护
2.1 Spring Boot版本选型:为什么选2.7而不是3.x
技术选型这件事,我在很多团队里看到两种极端:一种是“什么新上什么”,一种是“永远停留在老版本”。我的判断标准很简单:团队熟悉度、生态兼容性、以及官方维护周期。
当时Spring Boot 3.x已经发布,但3.x基于Spring Framework 6和Jakarta EE 9+,很多中间件客户端、老的自定义starter对它的兼容还不够稳。同时团队里多数人对2.x的自动配置机制更熟,遇到问题能快速定位。所以我最终选的是Spring Boot 2.7.x,搭配JDK 11。这个组合的好处是:有足够多的线上实践可参考,有问题能搜到现成解决方案,而且2.7还在社区维护期内。
表格化呈现我的选型考量:
| 维度 | 选择 | 理由 |
|---|---|---|
| 框架版本 | Spring Boot 2.7.x | 生态兼容成熟,团队熟悉,维护期内 |
| JDK版本 | JDK 11 | 长期支持版本,G1垃圾回收器在JDK 11下更稳定 |
| 数据库 | MySQL 8.0 | 云上标准配置,事务能力强,适合做最终兜底 |
| 缓存 | Redis 6.x | 单线程模型天然适合做原子扣减,Lua脚本支持 |
| ORM | MyBatis-Plus | 团队习惯SQL可控,复杂统计好写,避免JPA的隐式关联坑 |
| 消息队列 | RabbitMQ | 业务量级还没到Kafka的使用场景,RabbitMQ足够且好运维 |
| 接口协议 | REST + JSON | 简单通用,调用方接入成本低 |
2.2 为什么用Redis做扣减,而不是纯靠数据库行锁
这是整个项目里最核心的选型决策。先说结论:我用了“Redis缓存扣减 + 数据库兜底流水”的混合方案。
为什么不能完全靠数据库?如果直接用UPDATE inventory SET quantity = quantity - 1 WHERE sku_id = ? AND quantity > 0,在数据量不大、并发不高的时候确实没问题。但库存查询和扣减是最热的数据访问路径,每个扣减都打到MySQL上,会频繁更新同一行,引发行锁竞争和间隙锁问题,数据库连接池也容易被占满。更麻烦的是,热点商品会导致这一行成为整个系统的串行瓶颈。
Redis的扣减优势在于单线程模型和Lua脚本原子性。用Lua脚本把“检查库存是否充足”和“执行扣减”放在同一个脚本里提交,不存在多个命令之间的并发间隙。配合Redis的吞吐能力,轻松支撑上万TPS的扣减。
但Redis不能当作唯一的事实来源。因为Redis是内存态,存在宕机丢数据的风险。所以我的方案是:Redis负责扣减和快速查询,MySQL负责记录最终的流水和库存快照。流量高峰时依赖Redis速度,每个请求还会异步发送一条流水消息,由消费者把流水落库并更新MySQL库存余量。如果Redis出现异常,服务可以降级到数据库直扣模式。
2.3 消息队列在这里的真正作用
很多人一上来就引入消息队列,我认为这是“为了异步而异步”。在这个项目里,流水落库就是一个典型的消息场景:我不需要用户等流水写完才返回扣减成功,但又必须保证流水最终能写进数据库。
用RabbitMQ的好处是削峰和缓冲:扣减请求打进来是突发的,数据库的写吞吐是有限的,中间加一层消息队列,可以让数据库以一个稳定的速率消费流水,避免瞬时写入造成慢查询。更重要的是,消费者可以通过手动ack保证消息不丢。如果消费失败,消息会重新进入队列,保证流水最终一致。
当然,消息队列也引入了新的复杂度:重复消费。所以流水表必须在设计上具备幂等能力,我会在下一节讲具体的表结构时提到。
3. 核心链路实现的代码节奏:从数据模型到接口落地
3.1 数据模型设计:库存表与流水表的关键字段
表结构这件事,我强烈建议先写数据库设计文档再写代码,因为后面接口什么样子,几乎完全由表结构决定。
库存主表inventory最核心的字段是:sku_id、quantity(当前可售数量)、locked_quantity(被冻结的数量)、version(乐观锁版本号或时间戳)。这里的locked_quantity很多初学者会忽略:订单创建时冻结库存,支付成功后真正扣减,取消时则释放。如果不区分“冻结”和“扣减”,会导致取消订单时把已售出的库存又加回去,产生数据错乱。
库存流水表inventory_flow的关键字段是:id、flow_no(业务流水号)、order_id、sku_id、change_type(冻结/扣减/释放/回滚)、change_quantity(变动数量,可以是正数或负数)、before_quantity、after_quantity、create_time。
这里有两个细节特别重要。一是flow_no必须有唯一索引,它直接承担幂等控制:重复消费消息时,通过INSERT IGNORE或者先查后插,能保证一条流水不会被写入两次。二是before_quantity和after_quantity这两个字段一定要存,它们是事后对账的根数据,没有它们,出了问题只能靠猜。我在项目里通过MYSQL的唯一约束uk_flow_no来兜底,即使消费者逻辑有bug,数据库层也能拦住。
3.2 数据库乐观锁扣减版本:什么时候用它
写代码之前先明确一个底线:Redis扣减是主路径,但数据库的扣减能力必须保留。因为Redis宕机或者连接池出问题时,核心链路不能停。数据库扣减我用的是乐观锁机制,SQL长这样:
UPDATE inventory SET quantity = quantity - #{quantity}, version = version + 1 WHERE sku_id = #{skuId} AND quantity >= #{quantity} AND version = #{version}这个SQL的灵魂在quantity >= #{quantity}和version = #{version}两个条件上。前者从数据库层面保证不超卖,后者保证多人并发修改时只有一个能成功,失败的人会收到影响行数为0的结果,然后由上层抛出“库存不足或版本冲突”的异常。
对应的Mapper接口代码大致是这样:
@Mapper public interface InventoryMapper { @Update("UPDATE inventory SET quantity = quantity - #{quantity}, " + "version = version + 1 " + "WHERE sku_id = #{skuId} AND quantity >= #{quantity} AND version = #{version}") int deductStock(@Param("skuId") Long skuId, @Param("quantity") Integer quantity, @Param("version") Integer version); }在Service层,我会先查出当前版本号,再执行更新。如果返回0,有两种可能:版本号被别的请求更新了,或者库存不够了。可以再查一次库存来区分原因,给调用方返回不同的错误码。
这套方案的好处是简单、可靠、没有锁等待;坏处是乐观锁在冲突激烈时需要重试,会造成部分请求需要第二次执行。所以在高并发主路径上,我依然以Redis为主,数据库乐观锁作为降级预案。
3.3 Redis Lua扣减脚本:主路径的原子实现
Redis主路径的扣减,我直接在Lua脚本里完成,核心代码如下:
local key = KEYS[1] -- 库存key,例如 inventory:{skuId} local quantity = tonumber(ARGV[1]) -- 扣减数量 local stock = redis.call('GET', key) if not stock then return -1 -- 库存不存在,走数据库兜底 end if tonumber(stock) < quantity then return 0 -- 库存不足 end redis.call('DECRBY', key, quantity) return 1 -- 扣减成功我通过Spring Data Redis的DefaultRedisScript执行这个Lua脚本,返回值有三种:1表示成功、0表示库存不足、-1表示key不存在需要降级。这里为什么坚持用Lua而不是用Java代码“先GET再DECRBY”?因为如果拆成两条命令,高并发下会出现竞态:两个请求都查到库存为5,都判断可以扣6,其中一条会成功,另一条会把库存扣成负数。Lua脚本在Redis单线程模型下是原子提交的,不存在这个问题。
库存key在扣减成功后,我还需要同步处理一个细节:如果Redis里的ticket数量长期没有被数据库兜底数据校准,会因为Redis过期策略导致缓存和数据库不一致。所以我对库存key设置了合理的过期时间,并通过后台定时任务每5分钟把数据库的库存快照刷新到Redis。
3.4 事务边界的控制:@Transactional的隐坑
Java开发里事务处理是重灾区。我在这个服务里定了一条铁律:
事务只放在最外层的应用服务方法上,不要在领域服务或Mapper层随意加事务。
原因是Spring的声明式事务默认基于代理实现,如果在一个类内部通过this.xxx()调用另一个方法,@Transactional注解会失效。我看到过很多次这种问题:开发者在Service里写了一个私有方法,给它加了事务,结果数据异常时就是不回滚,查半天才发现是自调用绕过代理。
另外,事务和Redis操作绝对不能混在同一个事务里。因为Redis不在Spring事务管理的数据库连接资源范围内,事务回滚不会撤销Redis的扣减。所以我的实现策略是:先执行Redis扣减,再发流水消息,最后在主流程成功后才通知调用方;万一Redis扣减成功但流水消息发送失败,则通过定时补偿任务对比Redis库存和数据库库存的差异,做自动回滚。
核心流程的代码骨架大致是这样:
public DeductResult deduct(DeductCommand command) { // 第一步:Redis原子扣减 Long result = redisExecutor.execute(script, key, String.valueOf(command.getQuantity())); if (result == null || result == -1) { return DeductResult.fallback(); // 触发数据库兜底 } if (result == 0) { return DeductResult.noStock(); } // 第二步:发送流水消息 FlowMessage message = FlowMessage.create(command, FlowType.DEDUCT); rabbitTemplate.convertAndSend("inventory.flow", message); return DeductResult.success(); }兜底逻辑用的是前面提到的数据库乐观锁,如果数据库也失败了,会返回明确的失败码,让上游订单服务去处理。
4. 并发测试与数据一致性:那些在压测里才现形的隐患
4.1 我的压测方案:场景脚本怎么设计才有参考价值
很多人压测就用JMeter模拟一堆线程发请求,看个平均响应时间就算完事。我这次没有走那条路。因为扣库存是写多读少的场景,必须模拟真实业务里的“读-判断-写”混合比例,还要模拟部分请求失败后重试的噪音。
我的压测方案分三组:第一组是稳定流量组,模拟100个并发线程持续请求扣减,目标是把少数行的热点请求打到数据库和Redis上;第二组是突发流量组,用阶梯加压的方式从50并发涨到300并发,观察系统在流量爬坡时的表现;第三组是异常注入组,把Redis短暂停掉,验证服务是否按预期降级到数据库扣减。
每个组的压测数据都收集三层指标:应用层的接口P99耗时和错误率、Redis层的命令耗时和连接占用、数据库层的慢查询和行锁等待次数。
4.2 压测里暴露的第一个问题:ABA与幂等回滚
第一次压测跑了一万次扣减请求,重启服务后对账发现,库存流水记录数比扣减请求数多了37条。我第一反应是消费者重复消费了消息,检查后确认确实有重复:RabbitMQ在消费者处理超时时会重新投递同一份消息,而我的流水表虽然有唯一索引,但因为插入时机是在“先查后插”的非原子操作中,并发重复消息会同时通过查询,然后一个插入成功,另一个在唯一索引上抛异常——异常被吞掉,进而导致数据差异。
这个案例本质上就是幂等处理没做好。我最后的修复方案是:消费者直接执行INSERT IGNORE,把幂等的判断完全交给数据库的唯一索引。INSERT IGNORE返回的受影响行数为0时,说明之前已经插入过,后续逻辑直接跳过,不抛异常,不中断流程。
4.3 压测里暴露的第二个问题:缓存与数据库的最终一致
压测到第二轮,我故意把Redis的库存预加载脚本直接杀了,模拟缓存失效的场景。这时候服务自动降级到数据库扣减,但问题是:数据库扣减成功了,Redis里的旧库存却没有更新。等Redis恢复,服务切回主路径时,Redis显示库存还剩5000,实际数据库只剩3000,超卖风险直接暴露。
这个问题靠定时刷新只是缓解,不能根治。我最后的处理方式是“双写一致性保障 + 异步校准”:扣减成功走Redis的请求,同时发送流水消息,消息消费者在写流水的同时更新数据库库存;另外每5分钟有一个校准任务对比Redis库存和数据库库存,差异超过阈值时以数据库为准覆盖Redis。这套机制保证了最终一致,极端情况下只存在几秒钟的读取偏差,但扣减的强一致仍然由Redis或数据库任一节点保证。
4.4 关于“压测只是验证性能”的误区
我特别想强调一点:压测不只是为了看性能数字,更是为了暴露代码在极端时序下的逻辑错误。你光看代码很难想象,同一个商品在并发120次扣减时,会出现幂等失效、缓存穿透、死锁三种问题同时发生。这些只有在流量模拟中才现形。所以每次压测发现错误,我的第一反应不是“调参数”,而是“查逻辑”。参数调优是最容易做的事,逻辑缺陷才是真正要命的。
5. 上线前必做对的三件事:日志、监控与开关
5.1 日志规范:没有traceId的日志等于没有日志
分布式日志定位问题,最关键的是链路追踪ID。我在库存服务的所有入口过滤器中,会判断请求头里有没有trace_id,没有就自己生成一个UUID,再放入SLF4J的MDC中。这样所有日志在输出时会自动带上这个ID,不管日志散落在多少行,都可以通过一个traceId把所有相关日志捞出来。
我用的logback输出格式里,模式串是这样的:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{traceId}] - %msg%n</pattern>关键操作(扣减成功、库存不足、降级触发)必须输出业务标记,比如DeductResult=SUCCESS skuId=123 orderId=456这种结构。日志的意义不只是给人看,更是给未来的自己看。三个月后你收到告警,第一件事肯定是去日志平台搜traceId。
5.2 监控指标:只看QPS远远不够
上线前我在服务里埋了这样几类指标:
- 接口指标:扣减接口的QPS、P99耗时、错误率
- 资源指标:Redis连接数、数据库连接池活跃数、线程池活跃数
- 业务指标:剩余库存总量、冻结库存总量、流水积压量
- 降级指标:数据库兜底触发次数、补偿任务执行次数
这里我特别看重“流水积压量”。如果RabbitMQ里的流水消息堆积超过阈值,说明消费者跟不上了,数据一致性迟早出问题。我把它做成一个强告警指标,超过500条就告警,这意味着要马上扩容消费者或者检查数据库性能。
监控数据用Prometheus采集,Grafana绘图,这个组合是Java服务监控的通用标准。Grafana面板上我固定放了四个核心视图:库存扣减趋势、Redis命中率、数据库慢查询、RabbitMQ堆积数。
5.3 开关设计:关键时刻能救命的配置项
线上环境出问题时,最快止血的方式不是改代码,而是通过配置中心关掉一个功能。我在库存服务里设计了三个开关。
第一个是inventory.deduct.redis.enable,控制是否走Redis扣减,关掉后强制走数据库兜底;第二个是inventory.deduct.async.enable,控制流水是同步落库还是异步消息,同步模式下可靠性最高但性能下降;第三个是inventory.flow.repay.enable,控制补偿任务是否执行,在数据库维护期间必须关闭。
这三个开关全部放在配置中心里,无需发版就能动态修改。它们不会频繁使用,但在关键时刻,一键降级能让系统不至于全线崩溃。我认为每个服务都应该保留这种“救命开关”,这是线上系统运维的基本意识。
6. 踩坑记录:让我熬夜改代码的三个真问题
6.1 MySQL间隙锁引发的死锁
数据库兜底路径刚上线时,死锁日志频繁出现。我检查后发现一个微妙场景:在乐观锁更新语句的WHERE sku_id = ? AND quantity >= ?语句中,当quantity条件无法命中索引时,MySQL会在索引间隙上加锁,两个事务分别锁住了不同的间隙,然后在互相需要对方间隙时形成死锁。
解决方法是给sku_id单独加索引,让更新通过sku_id精确命中最小范围的记录,避免范围扫描和间隙锁。另外,我在Service层增加了重试机制:死锁异常时sleep一个随机退避时间重新执行,最多重试3次。这种问题在单机测试时根本不会出现,只有并发压测下才露出真面目。
6.2 Redis连接池耗尽引发的“雪崩”事故
有一次压测过程中,我发现把并发数调到300时,Redis的平均响应时间从1毫秒涨到了350毫秒。查后台Redis的连接数发现,连接数非常多,而且每个命令都在等待获取连接。根源是我在代码里通过RedisTemplate获取连接后,在业务代码里循环多次执行Lua脚本,每次执行都申请和释放连接,导致连接获取的争抢。
修复办法有两个。第一步,在单次请求内复用同一个Redis连接,把多次操作合并进同一条Lua脚本,减少往返次数。第二步,合理调大连接池上限,并设置获取连接的超时时间,超时后快速失败并降级到数据库,而不是无限等待。
这种事后的体会是:Redis本身性能再好,连接池参数和个人使用习惯不对,照样会把服务搞挂。千万别觉得连接池只是“配置几个数字”而已。
6.3 分页统计里的深坑:深分页引发的慢查询
库存管理员需要查询“某商品的流水明细”,我一开始直接用LIMIT 500000, 20做分页,线上压测时就发现这个查询耗时超过3秒,轻松拖垮数据库连接池。MySQL的深分页需要先把前50万行全部扫描出来再丢掉,代价极大。
我的优化是改成“游标分页”:客户端传入上一页最后一条记录的id,查询条件变成WHERE id > #{lastId} ORDER BY id ASC LIMIT 50。这样每次查询走的都是主键索引,性能稳定在几十毫秒内。这个改动很简单,但它让我意识到:代码里每一行SQL都可能成为下一个生产事故,尤其是那些看起来不起眼的统计查询。
我还测试过“覆盖索引”方案:先查询主键列表,再通过主键关联回表查询完整数据。但最终因为流水表查询条件频繁变化,没有游标分页稳定。所以最后上线版本用的就是游标分页。
写在最后的几点体会
这个项目做完,我最大的感受不是技术多难,而是“把常规技术用正确、用严谨”需要付出大量精力。Java开发的技术栈本身大家都懂,Spring Boot、Redis、MySQL、RabbitMQ这些工具,几乎每个后端简历都会写,但真正拉开差距的是:有没有把事务边界控制住、有没有让幂等设计覆盖到所有入口、有没有在压测前就预判到并发场景的独特问题。
后来我又接手过其他服务,发现这套思路可以复用:先理清业务边界再动代码、主路径和兜底路径都设计好、日志和监控从第一天就嵌入、所有并发操作都问一句“它幂等吗”。这些经验不来自书本,全是靠踩坑换来的。我希望这篇复盘能帮你少踩几个我踩过的坑,至少在你自己的库存类或订单类服务里,提前把答案写对。