ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java多用户商城实战:从数据隔离到防超卖与分布式事务

Java多用户商城实战:从数据隔离到防超卖与分布式事务 先交代背景这个项目不是那种“hello world”级别的练习而是把一个真实的多用户商城从零到一搭起来过程中顺手把Java生态里那套东西几乎全用了一遍。如果你正在纠结怎么把Java基础、并发、框架、中间件这些零散知识点串起来或者面试前想有个拿得出手的项目经历这篇应该能给你省不少事。我会从整体设计讲到具体实现再讲落在代码里的细节和踩过的坑尽量让每个环节都能直接参考。1. 项目定位与技术选型为什么是Java全家桶1.1 多用户商城到底“多”在哪很多人在做商城项目时习惯性地把它当成“单店铺商城”来做一套用户系统、一套商品表、一套订单表所有数据大家共用。但“多用户商城”这里的核心差异在于系统里同时存在多个彼此独立却又共享平台的商户每个商户管理自己的商品、库存、订单和资金而平台方则负责统一审核、统一支付、统一对账。这就带来一个关键点从用户表、商品表到订单表几乎所有核心数据都需要带上“商户维度”。一旦没带轻则商户A看到了商户B的订单重则商户A能直接修改商户B的商品价格这在真实业务里属于严重事故。所以我在设计时给自己定了一条硬规矩任何查询和写入都必须先过“数据隔离”这一关再谈业务逻辑。这个思路直接决定了后面的技术选型和表结构设计。1.2 Java全家桶的组件分工从命名上也能看出来这个项目真正的主角是Java生态。我用到的组件大致如下Spring Boot 2.7.x应用的基础框架负责把各个模块快速组装起来。Spring Cloud Alibaba包含Nacos注册与配置中心、Gateway网关、Sentinel限流与熔断。MyBatis-Plus 3.5.x数据库访问层简化CRUD但后续做行级权限拦截器时也要和它底层的SQL解析机制打交道。MySQL 8.0 Redis 6.x核心业务数据和缓存。RocketMQ 4.9订单、库存、支付之间的异步消息流转。Elasticsearch 7.x商品搜索尤其是多商户模式下海量SKU的检索。Seata分布式事务方案但实际落地时我并没有全程依赖AT模式原因后面会讲。XXL-Job定时任务用于订单超时关闭、支付对账等场景。这套组合基本就是国内电商类Java项目的标准画像。你可能会有个疑问用这么重的一套技术栈做一个小商城是不是过度设计了我的看法是如果只追求“能跑”完全不需要微服务一个单体应用加MySQL就够了但如果想把多商户权限、数据一致性、高并发库存这些问题都理清楚这套全家桶反而能帮你把每个问题都映射到一个具体的工具上。面试聊项目时面试官想听的也正是这些映射关系。1.3 单体优先还是微服务一步到位第一次搭这种项目时我差点就顺着“微服务全家桶”的思路把所有模块都拆开了用户服务、商品服务、订单服务、支付服务、商户服务各拆一个独立工程。结果在真正写代码时发现业务早期最痛的不是服务拆分而是数据模型和权限模型的设计。后来我用了折中方案代码上保留模块化边界但部署上先以单体为主。也就是说在一个Spring Boot工程里按照领域划分package如user、merchant、product、order、pay、stock每个领域内部独立开发对外通过Service接口互相调用。当某个模块的QPS真的上来后再把对应模块单独拆出去部署。这种做法的好处很明显调试阶段不需要启动一堆服务IDE里一把跑起来就能断点等后期体量变大按域拆分时因为每个域之间的依赖都收敛到了Service接口拆起来也不会伤筋动骨。如果你是自己学习或者小团队起步我强烈建议你也采用这种“模块化单体”策略而不是一上来就搞一套完整微服务。2. 多用户与行级权限数据隔离是命根子2.1 用户、商户、店铺的角色建模多用户商城里有几类明显的角色平台管理员、商户管理员、商户员工、普通买家。如果角色模型设计得不好后面做权限控制时会被各种“特殊情况”打得措手不及。我的建模方式是sys_user登录账号表包含用户名、密码、手机号、状态。sys_role角色表数据类似PLATFORM_ADMIN平台管理员、MERCHANT_ADMIN商户管理员、MERCHANT_STAFF商户员工、BUYER买家。sys_user_role用户和角色的关联表。merchant_info商户基础信息表包含商户编号、商户名称、联系人、审核状态。merchant_staff商户员工表关联sys_user和merchant_info额外记录员工在商户内的权限级别。在学习Java基础时我们经常说面向对象讲究“抽象”和“封装”这套角色模型就是一个最直观的案例。你把“人”抽象成账号把“身份”抽象成角色和商户归属把“操作权限”抽象成角色对应的菜单和接口权限。数据库表只是这些对象的持久化载体。2.2 行级数据隔离的三种方案多商户数据隔离通常会考虑三种级别独立数据库每个商户一个库隔离性最强但成本高、运维复杂平台很难做跨商户的数据分析和统一运营。共享库、独立Schema每个商户一个schema比独立库省一点但MySQL实例内的schema多了以后备份和迁移还是麻烦。共享库、共享表通过merchant_id区别这是绝大多数SaaS多租户系统的选择也是我最终使用的方案。选择第三种方案后行级隔离就完全依赖SQL层面每次都能正确带上merchant_id条件。这个条件如果靠每个开发人员自觉去where里写早晚会漏。所以在设计初期我就决定必须用一个通用的机制强制注入这个条件。2.3 MyBatis拦截器实现行级权限这里就引出了一个Java开发者面试常被问的高频问题如何实现“行级权限”。网上很多答案是“在SQL里手动加where merchant_id ?”但如果你的系统里有几百张表这个方案不够看而且极容易漏加导致越权。我的实现思路是基于MyBatis的拦截器机制在Executor执行SQL之前解析SQL抽象语法树然后自动把merchant_id 当前登录商户ID这个条件拼进WHERE子句。简单代码如下Intercepts({ Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}), Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class MerchantLineInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 1. 从上下文中获取当前商户ID Long merchantId MerchantContext.getMerchantId(); if (merchantId null) { return invocation.proceed(); } // 2. 获取原始SQL MappedStatement mappedStatement (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; BoundSql boundSql mappedStatement.getBoundSql(parameter); String originalSql boundSql.getSql(); // 3. 使用JSqlParser解析并追加 merchant_id 条件 Select select (Select) CCJSqlParserUtil.parse(originalSql); PlainSelect plainSelect (PlainSelect) select.getSelectBody(); // 这里需要处理表别名并为涉及商户核心数据的表添加条件 // 4. 生成新SQL并替换 // ... } }用这种方式你需要维护一张“哪些表需要做商户隔离”的清单通常就是商品表、订单表、库存表、售后表等。拦截器在解析时只对清单内的表追加条件。这里有几个非常容易踩的坑子查询和JOIN容易漏处理。比如select * from order_item oi left join product p on oi.product_id p.id如果拦截器只处理主表JOIN进来的商品表可能就漏了。分页插件和行级权限拦截器有执行顺序问题。MyBatis-Plus的分页插件本身也是一个拦截器如果你的行级权限拦截器放在分页之后执行count语句和limit语句可能会被解析出错。批量插入没法用拦截器硬改。像订单明细批量插入时每个明细的merchant_id得在业务代码里手动set拦截器主要管查询和修改。这套行级权限机制做完以后我再也不用担心业务SQL里漏写商户条件了。它唯一的问题在于SQL解析有一定的性能损耗但实测一个复杂查询多了几毫秒对于绝大多数电商场景完全可接受。3. 商品与库存SKU模型和防超卖设计3.1 SKU模型与规格组合商城商品有一个经典模型叫SPU/SKU。SPU是“标准化产品单元”比如“iPhone 15 Pro”SKU是“库存量单位”比如“iPhone 15 Pro 黑色 256G”。一个SPU下面可能有多个SKU每个SKU拥有独立的图片、价格、库存。我当时建表的设计是product_spuSPU主表包含店铺ID、分类ID、标题、描述、上下架状态。product_skuSKU表包含SPU ID、规格JSON、价格、库存、销量。product_spec规格项表比如颜色、内存等。product_spec_value规格值表比如黑色、白色、256G等。ELK搜索商品时我同步的是SPU视图数据把SKU列表以数组字段形式存到ES里。搜索时按SPU维度返回点进详情后由服务端返回该SPU下的SKU列表。很多商城会把SKU也同步到ES结果导致搜索结果页面出现同一个商品的好几个SKU非常影响体验Spring Data Elasticsearch里设置父子文档或嵌套文档能解决但性能和复杂度都会上升建议能不做就不做。3.2 库存扣减方案对比库存扣减是电商系统里并发压力最大的点也是JAVA面试中“数据一致性”问题最典型的场景。我见过很多初学者用这种写法ProductSku sku productSkuMapper.selectById(skuId); if (sku.getStock() buyCount) { sku.setStock(sku.getStock() - buyCount); productSkuMapper.updateById(sku); }这个写法在低并发下没问题但一旦多个用户同时买同一个SKU读到的库存可能都是同一个旧值最终把库存扣成负数这就是典型的“超卖”。接下来我把常见的三种方案都验证了一遍数据库乐观锁在SKU表加一个version字段更新时检查version更新成功才算扣减成功。这个方案实现简单但在并发高时大量请求会更新失败需要重试。数据库悲观锁select ... for update锁住行记录强制串行化扣减不会超卖但对数据库行锁竞争压力大高并发时容易拖垮主库。Redis预扣减把库存预加载到Redis使用Lua脚本原子性扣减扣减成功后才允许创建订单再通过异步消息同步数据库。最终我使用的是方案三为主、方案一兜底的组合。为什么因为商城场景下热点SKU的并发往往集中在几个爆款上数据库乐观锁扛不住频繁的更新冲突用Redis把库存扣减的压力前置才能把数据库从高并发写操作中解放出来。3.3 Redis Lua脚本扣库存的完整实现Redis扣库存的Lua脚本是原子性的不用担心并发问题。核心代码如下-- 扣减库存 -- KEYS[1] 是库存key例如 stock:sku:1001 -- KEYS[2] 是已售key例如 sold:sku:1001 -- ARGV[1] 是扣减数量 local stock tonumber(redis.call(get, KEYS[1])) local num tonumber(ARGV[1]) if stock nil then return -1 -- 库存key不存在 end if stock num then return 0 -- 库存不足 end redis.call(decrby, KEYS[1], num) redis.call(incrby, KEYS[2], num) return 1在Java端调用这个脚本的代码大致如下private static final DefaultRedisScriptLong STOCK_DECR_SCRIPT new DefaultRedisScript(); static { STOCK_DECR_SCRIPT.setScriptText(...); // 上面那段Lua STOCK_DECR_SCRIPT.setResultType(Long.class); } public boolean tryDeductStock(Long skuId, Integer count) { String stockKey stock:sku: skuId; String soldKey sold:sku: skuId; Long result redisTemplate.execute( STOCK_DECR_SCRIPT, Arrays.asList(stockKey, soldKey), count.toString() ); return result ! null result 1L; }订单创建成功后我会发送一条OrderCreatedEvent到RocketMQ由库存服务消费消息后把Redis的扣减结果异步同步回MySQL同时扣减SKU表的库存字段。如果同步过程中数据库更新失败则通过定时任务扫描Redis和数据库的库存差异进行补偿。这里还有个特别值得注意的细节Redis预热。商品上架后我需要把数据库库存同步到Redis但如果某个SKU压根没预热或者Redis中的key被LRU淘汰了你去扣它就会直接返回-1。真实线上遇到过凌晨大促时部分冷门SKU的Redis key刚好过期导致用户明明看到有货却下单失败。我的解决方式是扣减返回-1时先从数据库加载库存到Redis再重新执行扣减最多尝试两次。4. 订单与支付跨服务的数据一致性到底怎么做4.1 订单状态机设计订单模块可以说是多用户商城里最复杂的模块因为它要同时和商品、库存、支付、物流、售后打交道。如果订单状态设计得模糊后续所有流程都不敢往上接。我把订单状态定义成以下一组核心状态PENDING_PAYMENT待支付下单创建后的初始状态。PAID已支付等待商家发货。SHIPPED已发货等待买家确认收货。COMPLETED已完成买家确认收货或系统超时自动确认。CANCELLED已取消用户主动取消或超时未支付自动取消。REFUNDING退款中售后发起的退款流程。REFUNDED已退款。每个状态之间的流转必须满足前置条件。比如CANCELLED只能从PENDING_PAYMENT或PAID状态转过来PAID状态下取消订单会额外触发退款流程。我用的是Spring StateMachine实现了这套状态机但说实话如果你的状态流转不超过10个直接用状态模式加一个order_status字段的校验也完全够用。Spring StateMachine虽然概念清晰但调试起来没那么直观需要踩不少配置上的坑。4.2 订单、库存、支付之间的分布式事务订单创建这个操作涉及四个系统订单服务写订单表、库存服务扣库存、支付服务发起支付、商户服务更新统计。如果每个操作都放在一个本地事务里只要一个系统挂了整个流程就是脏数据如果全部用强一致分布式事务比如Seata AT模式性能会明显下降而且长事务容易锁住数据库资源。我这里采用的原则是“能异步就异步能最终一致就不要强一致”。订单创建的流程拆成了以下几步用户下单订单服务创建订单状态为PENDING_PAYMENT。同步扣减Redis库存失败则下单失败。发送OrderCreatedEvent到RocketMQ订单服务和库存服务都订阅这个消息。库存服务消费消息后持久化扣减数据库库存。如果持久化失败通过stock_ops_log表记录失败状态由XXL-Job定时补偿。用户支付成功后支付回调通知支付服务支付服务再发送PaymentSuccessEvent订单服务消费后更新订单为PAID。如果订单超时未支付订单服务发送OrderTimeoutEvent库存服务把Redis库存加回来同时数据库库存也回补。这套方案最终保证了“下单→支付→库存扣减→订单完成”的一致性。有一个面试必问的坑如果用户下单成功但支付前关闭了页面Redis库存一直被占用怎么办我的答案是订单状态仍然是PENDING_PAYMENT由延迟消息处理比如RocketMQ的定时消息在30分钟后将未支付订单标记为CANCELLED并回补库存。4.3 支付回调的幂等与对账支付模块对接过微信支付或支付宝的开发者都知道支付回调最大的问题就是“重复通知”。你还不能直接忽略重复通知因为支付平台为了保证回调送达会按一定策略重试很多次。我的处理方式是建了一张payment_transaction表表内建立payment_no唯一索引。回调到达时try { paymentTransactionMapper.insert(transaction); } catch (DuplicateKeyException e) { // 说明已经处理过这个回调直接返回成功 return success; } // 继续执行订单状态更新这里有个关键点先查后插在并发下不可靠必须用唯一索引兜底。有些同学喜欢先select再判断这种做法在并发低的时候没问题万一支付平台同时回调了两三次就会出现两个线程同时insert成功的竞态条件。回调成功之后还要更新订单状态这一步必须放在另一个事务里并且加行锁select order for update。如果不加锁两个回调线程可能同时读到PENDING_PAYMENT状态然后都往PAID去更新最后日志里就会出现两次“支付成功”的记录。对账这块我用了XXL-Job每半小时拉取支付平台的账单文件和本地流水表做比对差异项进入人工处理队列。这个环节不会在项目初期引发问题但没有它的话资金差错只能靠用户投诉来发现属于必须提前做的基础设施。5. 商城查询性能与高并发优化5.1 商品列表为什么没直接查数据库商品列表页是所有商城流量最大的页面如果每次都请求MySQL一旦并发量上来数据库基本就瘫痪了。我一开始确实偷懒直接查库结果压测时300个并发就把商品列表接口拖到了2秒多被伙伴们笑了好久。优化路径是这样的商品SPU基础信息、SKU价格库存、销量统计分别做成独立的Redis缓存。热点数据使用“缓存预热定时刷新”的策略而不是纯靠缓存穿透时回填。列表页使用Caffeine本地缓存做一级缓存Redis做二级缓存。Caffeine加Redis两级缓存的组合实测效果非常明显。搜索和筛选走Elasticsearch不直接对MySQL做模糊搜索。当用户在搜索页输入“手机 256G”请求会先打到ESES返回匹配的SPU ID列表服务端再从缓存中加载SPU详情最终拼装列表数据。这个链路里ES是抗数据量的Redis和Caffeine是抗并发量的。5.2 排序与分页里容易被忽略的细节看到热搜词里有“java排序”和“冒泡排序java”这里正好说一句业务开发里我们很少手写冒泡排序但排序的思想无处不在。商城列表最常见的排序有综合排序、销量排序、价格排序、上架时间排序。在ES里排序我踩过一个坑价格排序时如果直接对SKU数组字段排序ES默认会取最小值或最大值导致价格排序和用户看到的SKU最低价对不上。解决方式是把SPU的min_price和max_price单独同步到ES排序直接用这个字段。还有一个数据库列表的坑深分页。limit 100000, 20这种写法在数据量大时会越来越慢因为MySQL会扫描前面10万行再丢弃。我把商品列表的分页改成“游标分页”也就是带上last_spu_id参数用where spu_id ? order by spu_id limit 20来实现滚动加载。这个优化在后台管理系统的订单列表里同样适用。5.3 热点Key与接口限流大促时某些爆款商品的库存Key会被高频访问这就是“热Key”。我处理热Key的思路有几个把单个商品Key拆成多个分片比如stock:sku:1001:0、stock:sku:1001:1读取时随机命中其中一个分片。对热点接口加Sentinel限流比如库存扣减接口按SKU维度设置单机QPS上限。使用本地缓存存储商品详情避免每次都打到Redis。这些优化做完之后我给商品详情接口做了压测单机8核16G的机器稳定支撑了1500 QPS99线稳定在80ms以内。虽然和双十一那种百万QPS没法比但作为中小型多用户商城的参考数据这个表现已经及格了。6. 从Java基础和面试视角看这个商城6.1 面向对象、容器和设计模式都落在哪很多学Java的人都会在某个阶段觉得基础知识点太抽象什么面向对象、集合框架、设计模式背完就忘。但当你真正做了一个多用户商城这些概念会自己跳出来找上你。商品规格组合的数据结构让我重新理解了HashMap和List的应用场景订单状态流转让我重新认识了状态模式和策略模式支付渠道对接让我理解了工厂模式和模板方法模式库存扣减让我理解了Java并发中的AtomicLong、synchronized、锁粒度等概念。说白了Java面试题里的“八股文”并不是没有用它们只是缺少一个真实场景来附体。这里举一个具体的例子订单创建时不同支付渠道微信、支付宝、余额的支付方式不同但处理流程骨架一样。我写了一个PaymentChannel接口下面有WechatPayChannel、AlipayChannel、BalanceChannel三个实现类构建订单时通过工厂根据payType返回对应实现。这就是典型的设计模式应用也是面试官想在你项目里听到的东西。6.2 行级权限与数据一致性的底层逻辑行级权限用MyBatis拦截器数据一致性用Redis加MQ这些看起来是工具层面的解法但底层全是Java基础的延伸。拦截器内部用到了JDK动态代理的思想MerchantContext用ThreadLocal存储当前商户IDRedis的Lua脚本调用用到了Java与Redis通信的协议处理MQ的消费幂等用到了ConcurrentHashMap做处理中状态标记。多用户商城的价值就在于它把Java“语言特性-框架机制-中间件原理”整个链路串起来了。刷一百道“java面试题”不如把一个行级权限拦截器写一遍来得深刻。6.3 给正在自学Java的人一个参考路线如果你正在自学Java准备以后做后端开发我个人建议的路线是先打好基础JavaSE语法、集合、IO、并发、JVM。再学数据库和框架MySQL、Redis、Spring Boot、MyBatis。然后学中间件RocketMQ或RabbitMQ、Elasticsearch、Nacos。最后找一个真实项目综合练手多用户商城就是非常好的载体。学完上面这些哪怕只把本项目里某一两个模块彻底搞明白写进简历也足够支撑你面试初级Java工程师的岗位了。重要的是不要停留在“看过视频、抄过代码”的程度要能自己讲清楚为什么要这样设计、如果量更大怎么办。7. 常见问题与排查技巧实录7.1 高频问题速查表在实际开发和联调中我整理了一批频率特别高的问题这里直接给个速查表格问题现象根本原因解决方式商户A能查到商户B的订单行级权限拦截器漏配表或JOIN子查询没处理逐一核对数据隔离清单重点检查JOIN和子查询订单支付成功但订单状态不变支付回调处理异常或消息重复消费问题检查回调幂等逻辑和MQ消费日志库存扣成负数直接查库扣减未加锁改为Redis Lua脚本预扣减数据库乐观锁兜底Redis库存和数据库库存不一致数据库持久化失败或RocketMQ消息丢失建立定时对账任务扫描差异并补偿商品列表前几页正常翻到后面极慢limit深分页改成游标分页或限制最大页码支付回调正常但订单服务重启后丢消息消费组配置问题或消息未持久化设置RocketMQ消息持久化消费成功后记录offsetElasticsearch商品数据滞后搜不到新商品数据同步链路中断使用MQ异步同步并设置同步日志失败自动补偿这张表里的问题每一个我都真实遇到过。有时候线上排查问题光靠“看代码”是不够的必须从调用链路的每一步日志里找突破口。我给自己定了个习惯所有关键步骤都打上带有traceId的日志这样出了问题可以快速串起来。7.2 JVM排查的一些个人经验压测时遇到过几次接口越来越慢最终发现是GC问题。商城项目里的对象创建频率很高尤其是短生命周期对象比如订单DTO、请求参数包装类这些对象大量产生后年轻代不够用就会触发频繁的Minor GC严重的还会导致Full GC。我的排查手段是用jstat -gcutil pid 1000观察GC频率和耗时。用jmap -dump:formatb,fileheap.hprof pid导出堆快照再用MAT分析哪里对象占用最多。用jstack检查是否有线程死锁或长时间等待锁。有一次排查发现行级权限拦截器每次执行时都重新解析一遍SQL生成了大量临时对象。优化方式是引入简易的SQL解析缓存以原始SQL字符串为key缓存解析结果。这个优化做完后GC频率明显下降。7.3 对后来者的几条实操建议第一不要想着把技术栈一步到位全部铺开先把单体跑通再逐步替换和升级。第二数据库表字段一定要设计好merchant_id、create_time、update_time、deleted这些基础字段不然后面做数据隔离和回滚会很难受。第三所有涉及钱的接口都必须做好幂等设计宁可多写一点冗余逻辑也不能留下资金风险。第四压测不是可选项上线前至少用JMeter或wrk对自己的核心接口做一轮压测心里有底再上。我个人在这套项目里最深的体会是真正的成长不是学会了某个框架的API而是在解决一个个“为什么这里会出问题”的过程中把Java和背后的计算机原理逐渐打通了。如果你也想拿商城练手建议从“单体行级权限Redis库存MQ最终一致性”这个组合开始这套组合既不会难到劝退又能覆盖足够的深度。等跑顺了再往里加微服务、容器化、分库分表那时候你自然知道每一步该怎么走。
返回列表