多用户商城开发核心:数据边界与一致性实践

发布时间:2026/10/10 9:25:46
多用户商城开发核心:数据边界与一致性实践
最近有个朋友拿着他准备的面试项目来找我说想做一个多用户商城上来就是Spring Boot、Spring Cloud、Redis、RabbitMQ全家桶中间件清单列了八九个。我问他你打算怎么区分一个订单到底是哪个商家的怎么保证两个用户同时买同一件商品不会超卖商家A能不能看到商家B的账单他愣了一下说这个问题还真没想过。这就是我想写这篇东西的原因。多用户商城这四个字含金量不在商城而在多用户。它意味着同样的表结构要同时服务平台方、商家、买家三拨人意味着商品有归属、订单有归属、资金有归属意味着你不能像写单商户demo那样把登录注册加上、CRUD写完就认为系统跑通了。我经历过从单商户电商系统硬改成多商户平台的整个过程中间踩过的坑、推翻过的设计、最后沉淀下来的方案都值得单独拿出来聊聊。这篇内容适合两类人一类是拿多用户商城当练手项目或面试项目的Java开发者另一类是已经在做商城、但被串数据、超卖、订单状态错乱这些事折磨过的后端工程师。我不会给你一个完整的代码仓库但我会把选型逻辑、表设计思路、关键技术点的实现方式以及那些文档里不会写的细节一次讲清楚。1. 多用户商城和单商户电商系统的差别核心不在功能在边界很多人的误区是把多用户商城理解成单商户商城 商家管理后台。实际上多用户商城最根本的差别是每一行业务数据都要回答一个问题这东西是谁的。1.1 业务角色与权限边界的重新划分单商户商城只需要两类角色前台用户和后台管理员。管理员管商品、管订单、管用户权限集中在一个人或几个人身上。多用户商城至少是四类角色平台方管所有商家、管平台级营销、管交易规则、管资金清算商家只管自己店铺的商品、订单、售后、统计买家浏览、下单、支付、申请售后运营处理纠纷、审核商家入驻、管理平台活动权限模型一旦铺开就不是Spring Security里配几个角色那么简单了。平台管理员和商家管理员都能管理商品但商家管理员只能操作shop_id 18的商品。这就是行级权限问题也是热词里反复出现行级权限java的原因——它真的不是面试八股而是多用户商城从第一天起就要面对的设计题。1.2 单商户逻辑直接搬过来会炸的地方我把单商户系统改造成多商户时列了一个改造清单这里直接分享给你模块单商户做法多商户必须做的改动商品表product 表后台直接增删改增加 shop_id每个商家只能操作自己的商品SKU/库存sku 表全局唯一sku 表加 shop_id库存扣减要锁定到商家维度订单表order 表记录用户ID即可加 shop_id 和 buyer_id查询必须双条件过滤购物车一个用户一张购物车需要按店铺维度分组展示不同店铺的商品不能合并结算优惠券平台统一发放要区分平台券和店铺券门槛和适用店铺范围都得设计资金账户商户自己收钱平台、商家、买家三方账户体系涉及分账和结算最痛的不是改表结构而是改业务逻辑里那些默认数据属于我的假设。比如原来写订单查询是where user_id ?改成多商户后必须变成where user_id ? and shop_id ?。漏掉一个shop_id条件后果就是商家A能在后台看到商家B的订单这在线上属于最严重的数据安全事故。1.3 核心结论数据归属是第一等公民我后来总结了一句话给团队多用户商城里的每一条SQL都要在脑子里先过一遍我是以谁的身份在查。平台方查全部数据可以不带租户条件商家端必须带店铺维度过滤买家端必须带买家维度过滤。这个思维不建立起来后面用任何框架、任何中间件都补不了这个洞。所以如果你准备做这个项目第一件事不是选技术栈而是把角色-资源-操作这三者之间的关系画清楚。建议用一张表把每个模块的查询条件列出来谁能查、按什么条件查、查出来能干什么。这个表画完你的数据边界就清晰了。2. 全家桶选型实录哪些组件能留哪些能扔聊完业务边界再聊技术选型。Java生态确实够全家桶从应用框架到中间件应有尽有但多用户商城真正需要的远没有想象中那么多。选型的原则是绑定当前的业务阶段和团队规模而不是绑定技术热度。2.1 起步阶段模块化单体比微服务靠谱我推荐的第一版架构非常简单Spring Boot MyBatis-Plus MySQL Redis RabbitMQ如果需要搜索功能再加一个Elasticsearch。这是一个模块化单体内部按shop、order、product、user、marketing、settlement这些包划分模块每个模块内部保持独立模块之间通过Service接口调用而不是互相抄代码。为什么不一上来就上Spring Cloud全家桶我见过太多人把网关、注册中心、配置中心、分布式事务全部铺上结果业务还没跑通光排查服务间调用问题就耗了两个星期。模块化单体在业务早期有巨大的优势事务边界简单跨模块调用还在同一个JVM里一条事务能解决的事不需要引入分布式事务排查问题链路短不需要在几十个服务之间跳来跳去部署简单一个jar包搞定开发环境、测试环境、生产环境成本都低后面真要拆分模块边界早就划好了按包拆微服务成本可控2.2 各组件到底承担什么职责组件在多用户商城里的核心职责什么时候才必须引入Spring Boot / Spring MVC提供HTTP接口、依赖管理、自动装配基础必选MyBatis / MyBatis-PlusORM、内置多租户插件、代码生成基础必选MySQL订单、商品、用户、账户等核心数据存储基础必选Redis缓存、分布式锁、验证码、防刷计数、延迟队列有并发和缓存需求就上RabbitMQ / RocketMQ订单状态变更解耦、通知商家、异步扣库存要把下单链路拆成异步就用Elasticsearch商品搜索、筛选、排序商品量上万、搜索需求复杂时再上初期用MySQL LIKE就能扛XXL-Job定时任务、订单超时扫描、对账、结算定时任务超过3个就要认真考虑MinIO / 云OSS商品图片、商家资质文件有文件上传就要上本地磁盘存不靠谱Spring Cloud Gateway统一网关、鉴权、限流灰度服务拆分并需要统一入口管控时这里特别想说一下网关。java版本采集网关这个热词对应的是很多时候大家一聊网关就觉得必须用Spring Cloud Gateway。我实际用下来的体会是如果项目还是单体一个Servlet Filter就能干网关90%的活——统一鉴权、生成请求日志、简单限流。网关真正的价值在微服务化以后才体现出来因为那时你需要一个地方统一处理跨服务的鉴权、路由、灰度、限流单体阶段引入网关属于给自己上强度。2.3 选型时容易被忽略的隐性成本中间件不是越多越好每个组件都意味着额外的运维成本、学习成本和故障点。举几个例子MQ 选型RabbitMQ 上手快、路由灵活RocketMQ 对订单事务消息支持更好但部署重。如果团队没接触过 RocketMQ初期用 RabbitMQ 完全够后面再换也来得及。ES 和 MySQL 的数据同步这是一个持续维护的工程MySQL新数据怎么同步到ES、删除怎么处理、更新延迟多大都要设计。初期商品量不大时直接用MySQL查询扛得住就没必要多养一个集群。对象存储不要自己搭建文件服务器存图片除非你有专门的运维。直接上云OSS或者MinIO成熟的SDK接入成本很低。我的建议是把你真正需要的组件写在第一版架构图上其他中间件都标注暂缓。商城这个系统业务闭环跑通、数据不出错比用了几种中间件重要得多。3. 多租户隔离与行级权限所有商家的数据装在同一套库里多用户商城本质上是一个多租户系统每个商家是一个租户。数据隔离方案的选择直接决定了系统的数据安全上限。3.1 三种隔离方案的取舍方案实现方式优点缺点适用场景独立数据库每个商家一个库隔离最彻底故障互不影响成本高、运维难、跨库统计复杂大客户定制、数据合规要求极高共享库独立Schema每个商家一个schema隔离中等共享实例MySQL下schema管理不便迁移麻烦Oracle/PostgreSQL系常见MySQL用得少共享表 tenant_id所有商家数据在同一张表用字段区分成本最低、开发效率高、统计方便必须在所有SQL里带租户条件漏一条就串数据绝大多数商城项目的现实选择绝大多数中小商城选第三项也就是一张表带tenant_id/shop_id字段。我自己也是这么做的关键是如何保证每条SQL都带租户条件这件事不出错。3.2 MyBatis-Plus多租户插件行级权限的工程实现热词里出现行级权限java不是没道理的。在Java生态里最常规的落地方式是MyBatis-Plus内置的TenantLineInnerInterceptor。它的原理不复杂在SQL执行前自动解析SQL语法树往查询条件里拼接租户字段强制带上tenant_id ?。实际配置大致是这样Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 多租户插件 interceptor.addInnerInterceptor(new TenantLineInnerInterceptor( new TenantLineHandler() { Override public Expression getTenantId() { // 从登录上下文获取当前商家的ID return new LongValue(LoginContext.getCurrentShopId()); } Override public String getTenantIdColumn() { return shop_id; } Override public boolean ignoreTable(String tableName) { // 平台级表不需要租户隔离比如平台配置表、字典表 return ignoreTables.contains(tableName); } } )); // 分页插件放在多租户插件后面 interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; } }这里有几个非常关键的细节都是我踩过的平台端查询需要跨所有商家时必须把对应表加入ignoreTable或者临时关闭租户拦截器否则平台运营连所有订单列表都查不出来。自定义SQL如果手写了Select注解或者XML里的复杂SQLMyBatis-Plus拦截器可能不会生效。我曾经遇到过一条多表关联的报表SQL绕过了拦截器导致数据串到了其他商家。排查方法后面踩坑章节会说。LoginContext必须有兜底逻辑如果当前线程没有登录用户或店铺上下文宁可报错也不要执行不带动租户条件的SQL。静默失败会把问题掩盖到数据已经错了才发现。3.3 行级权限的两个层面服务端和接口层拦截器解决的是SQL层强制带条件解决了数据访问层面的大问题。但行级权限还有一个层面接口层的资源归属校验。什么叫资源归属校验比如一个买家订单详情接口GET /order/{orderId}你不能只判断用户是否登录还要判断这个订单是不是当前这个用户的。否则攻击者把orderId从10001遍历到19999就能看到别人的订单。这就是典型的水平越权暴力扫参就能打穿系统。我习惯的做法是做两层校验GetMapping(/order/{orderId}) public OrderVO detail(PathVariable Long orderId) { Order order orderService.getById(orderId); // 第一层资源存在校验 if (order null) { throw new BizException(订单不存在); } // 第二层资源归属校验必须使用当前登录用户ID而不是前端传的用户ID if (!order.getBuyerId().equals(LoginContext.getCurrentUserId())) { throw new BizException(无权查看该订单); } return orderConverter.toVO(order); }所有涉及资源查询的接口都必须在Controller层做归属校验。哪怕下面MyBatis拦截器已经带了buyer_id条件这里也不能省略——因为拦截器管的是SQL接口层管的是这个请求该不该进来。3.4 核心表设计的几个关键字段多用户商城的核心表我放几个典型结构供参考-- 商家/店铺表 CREATE TABLE shop ( id BIGINT NOT NULL AUTO_INCREMENT, shop_name VARCHAR(128) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-待审核 1-正常 2-冻结, owner_user_id BIGINT NOT NULL COMMENT 商家管理员用户ID, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表 CREATE TABLE product ( id BIGINT NOT NULL AUTO_INCREMENT, shop_id BIGINT NOT NULL COMMENT 租户字段, title VARCHAR(200) NOT NULL, status TINYINT NOT NULL DEFAULT 0, category_id BIGINT NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_shop_status (shop_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表订单表是最敏感的表千万不能漏租户字段 CREATE TABLE t_order ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号, shop_id BIGINT NOT NULL COMMENT 租户字段, buyer_id BIGINT NOT NULL COMMENT 买家ID, total_amount DECIMAL(12,2) NOT NULL, status TINYINT NOT NULL COMMENT 订单状态机, pay_time DATETIME NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_shop_status (shop_id, status), KEY idx_buyer (buyer_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个细节想特别提醒订单号不要用自增ID。自增ID会让竞对根据订单量推算你的业务规模而且接口层如果不做严格越权校验遍历ID就是遍历订单。我用的订单号是yyyyMMddHHmmss 8位随机数 业务标识生成时查重性能损耗完全可以接受。金额字段用DECIMAL(12,2)而不是float/double这是Java商城项目最基本的常识。float在金额计算上会有精度丢失做对账结算时一分钱对不上会让你怀疑人生。4. 下单链路的数据一致性从超卖到分布式事务多用户商城的技术难点如果只能挑一个讲我一定选下单链路的数据一致性。这也是热词里java怎么保证数据一致性搜索量那么高的原因——因为大部分教程只讲了理论没讲清楚在商城里到底该怎么落地。4.1 下单要处理的完整动作一个普通的下单请求背后要做的事远比你想象的多校验商品状态、上下架、是否在售卖时间校验SKU库存是否充足计算商品价格、优惠券抵扣、运费生成主订单和子订单扣减库存扣减/锁定优惠券如果是钱包支付还要扣减账户余额通知仓库/商家有新订单这里每一步都可能失败。库存扣了但订单没创建用户能查到库存没了但没订单订单创建了但库存没扣就会超卖。这就是一致性要解决的问题。4.2 第一道防线Redis Lua 防止超卖先聊防超卖。多个用户同时买同一个SKU核心诉求是扣减库存的操作必须原子。最经典的方案是Redis Lua脚本。把扣库存的逻辑写进Lua脚本Redis是单线程执行脚本天然保证原子性避免了下述Java代码里先查再扣的并发空隙-- 扣减库存脚本 -- KEYS[1]: sku库存的Redis key -- ARGV[1]: 购买数量 local stock tonumber(redis.call(GET, KEYS[1])) if not stock then return -1 -- 库存不存在需回源数据库加载 end if stock tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return 1 -- 扣减成功在Java里调用private static final String STOCK_KEY_PREFIX stock:sku:; public boolean deductStock(Long skuId, int count) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(lua/deductStock.lua)); script.setResultType(Long.class); Long result stringRedisTemplate.execute(script, Collections.singletonList(STOCK_KEY_PREFIX skuId), String.valueOf(count)); return Long.valueOf(1L).equals(result); }注意几个要点库存预热商品上架时把数据库库存同步到Redis或者读取时发现key不存在就回源数据库加载并设置到Redis。不要让key不存在和库存为0混淆。数据库库存最终一致性Redis扣减成功后要通过MQ异步通知数据库扣减最后以数据库库存为准做对账。Redis是防线数据库是底线。扣减失败的兜底如果Lua执行返回-1库存key丢失要回源数据库重建缓存并重新尝试扣减。4.3 本地消息表 MQ订单与消息的最终一致性下单链路解耦后比如下单成功后通知商家往往通过MQ实现。但这里有个经典问题先执行下单SQL成功再发MQ消息如果消息发送失败怎么办答案是本地消息表。逻辑是这样的在同一数据库事务里写入订单记录和一条消息记录消息状态是待发送事务提交后通过一个后台任务或Spring事务同步器把待发送消息发给MQ消费者收到消息后处理业务处理完调用确认接口把消息状态改成已消费Transactional public void createOrder(CreateOrderCommand cmd) { // 1. 写订单 orderMapper.insert(order); // 2. 写本地消息表同事务 outboxMessageMapper.insert(OutboxMessage.builder() .eventType(ORDER_CREATED) .payload(JSON.toJsonString(order)) .status(OutboxStatus.PENDING) .build()); // 事务提交后由同步器把消息发到MQ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { mqTemplate.send(order_topic, outboxMessage); } }); }这套方案虽然老但非常稳。你不需要一开始就上Seata那样重的分布式事务框架本地消息表MQ重试补偿在绝大多数商城项目里都够用。它的核心思想是把业务操作和消息发送绑定在同一个事务里消息要么不发发就一定发送成功。4.4 数据库层的防重与状态机流转除了库存订单状态本身的更新也要防重。支付回调、超时关闭、取消订单、发货都是并发操作同一个订单状态。最典型的错误写法是// 错误示范先查再改 Order order orderMapper.selectById(orderId); if (order.getStatus() 0) { order.setStatus(1); orderMapper.updateById(order); }这段代码两个线程同时进来都查到status0都去更新没有任何问题吗问题大了——两个线程都以为是自己把状态改成1第二个线程的更新会覆盖第一个线程的其他字段改动甚至导致重复超时关闭。正确姿势是乐观锁条件更新// 只更新满足条件的状态用影响行数判断是否操作成功 int rows orderMapper.updateStatus( orderId, OrderStatus.WAIT_PAY, // 期待当前状态 OrderStatus.CLOSED, // 目标状态 new Date() // 更新时间 ); if (rows 0) { throw new BizException(订单状态已发生变化请刷新后重试); }对应的SQLUPDATE t_order SET status #{targetStatus}, close_time #{now} WHERE id #{orderId} AND status #{expectStatus};这个写法利用MySQL行锁保证了并发安全多个线程同时执行这条UPDATE时只有第一个线程的影响行数是1其他线程影响行数为0通过行数判断就能识别出谁抢到了状态流转权。订单状态机每一步都这样走就不会出现状态乱跳的问题。4.5 什么时候才需要 TCC / Seata 这类分布式事务很多面试者一聊分布式事务就背TCC、Seata、两阶段提交但我不建议你在商城项目里一开始就上这套。原因很实际TCC对业务侵入太重每个参与方都要实现try、confirm、cancel三个方法写起来容易维护起来痛苦。回滚逻辑复杂比如优惠券回滚、库存回滚、积分回滚每一环都可能再失败。大多数商城场景用本地事务本地消息表定时对账已经能把数据错误控制在极小范围内剩下的靠对账任务发现和人工介入修复。什么时候才非得上呢当订单、支付、库存分别在独立的微服务且用本地消息表无法保证业务成功时。比如用户下单后调用积分服务加积分、调用优惠券服务核销、调用支付服务扣款这些跨服务调用如果要求强一致才需要考虑Seata AT模式或TCC。我自己的结论是中小商城先做可靠消息和事务消息把对账脚本写好比引入分布式事务框架更实在。面试时你能把本地消息表原理和适用边界讲清楚比背一个Seata配置有用得多。5. 订单超时关闭与定时任务延迟队列之外的兜底方案多用户商城一定会碰到这类需求用户下单30分钟未支付自动关闭未付款前发条短信提醒商家发货后7天自动确认收货优惠券过期前提醒。这些需求的技术本质是同一个延迟任务。5.1 几种实现方案的真实对比方案原理优点缺点我的推荐度MQ延迟消息RabbitMQ延迟插件/RocketMQ定时消息消息到达后延迟投递延迟精确、异步、吞吐高依赖MQ额外能力延迟时间上限有限高Redisson延迟队列Redis的ZSet存储过期时间简单、无需额外组件数据在Redis中可能丢需重建中高Quartz/XXL-Job定时扫表定时任务扫描数据库状态可靠、实现简单延迟不精确分钟级有误差DB压力大高做兜底时间轮Netty HashedWheelTimerJVM内存时间轮延迟精确、开销小进程重启丢任务分布式下难统一低我最后用的是组合方案RabbitMQ延迟消息做前台准时关闭XXL-Job定时扫描做兜底。为什么还要兜底因为MQ消息可能丢失、消费者可能宕机、数据可能因为各种异常没有被投递。定时扫表是最后一道防线哪怕延迟几分钟也不能让订单永远挂在待支付状态。5.2 延迟队列实现的核心逻辑在RabbitMQ里延迟消息通常这样实现消息不直接投递到业务队列而是先发到死信交换机给消息设置expiration过期时间消息过期后进入死信队列消费者监听死信队列处理。Configuration public class RabbitDelayConfig { Bean public Queue delayQueue() { return QueueBuilder.durable(order.delay.queue) .deadLetterExchange(order.exchange) .deadLetterRoutingKey(order.close) .build(); } Bean public Queue orderCloseQueue() { return QueueBuilder.durable(order.close.queue).build(); } } // 发送延迟消息30分钟后触发 public void sendDelayCloseMessage(Long orderId, long delayMillis) { Message msg MessageBuilder.withBody(orderId.toString().getBytes()) .setExpiration(String.valueOf(delayMillis)) .build(); rabbitTemplate.send(order.exchange, order.delay, msg); } // 消费订单关闭消息 RabbitListener(queues order.close.queue) public void onOrderClose(String orderIdStr) { Long orderId Long.valueOf(orderIdStr); // 检查订单当前状态如果是待支付则关闭 orderService.closeOrderIfTimeout(orderId); }生产环境要注意同一个队列里的消息如果设置了不同的过期时间RabbitMQ只会检查队头的消息是否过期队头的消息没到期后面的消息即使到期了也不会被投递。这是RabbitMQ延迟消息最经典的坑很多人线上踩了才发现。解决办法是不同延迟时间的消息发到不同的队列或者干脆用RocketMQ的定时消息。5.3 定时扫表兜底XXL-Job的任务幂等设计每天凌晨或者每5分钟扫一次订单表把超时未支付的订单批量关闭XxlJob(orderTimeoutCloseJob) public void orderTimeoutCloseJob() { // 扫描30分钟前创建的、状态为待支付的订单 ListOrder timeoutOrders orderMapper.selectTimeoutOrders( OrderStatus.WAIT_PAY, new Date(System.currentTimeMillis() - 30 * 60 * 1000), PageBounds.of(0, 500) ); for (Order order : timeoutOrders) { try { // 这里必须用条件更新同一订单并发时不重复关闭 boolean closed orderService.closeOrder(order.getId()); if (closed) { // 关闭成功后再回补库存幂等 inventoryService.releaseStockByOrder(order.getId()); } } catch (Exception e) { // 单条失败不要中断整个任务记录错误继续扫描下一批 log.error(关闭超时订单失败, orderId{}, order.getId(), e); } } }定时任务有一个必须时刻记住的原则任务本身要幂等重复执行不能产生副作用。上面代码里关闭订单用的条件更新重复执行时只有一次能成功释放库存要先判断该订单是否已经释放过可以用stock_release_log表记录或者订单上加个stock_released标记字段。XXL-Job相比简单Quartz多了调度中心可以统一查看任务执行日志、手动触发、配置失败重试、分片广播。商城项目里定时任务会越来越多订单超时、自动收货、结算对账、运营报表、优惠券过期我建议定时任务超过3个就把XXL-Job纳入进来这个成本花得值。6. 读多写少的性能优化实践Redis缓存、深分页和排序商城系统的流量分布极不均衡商品详情、首页、搜索结果这些读接口占90%以上流量下单和支付反而占比很小。所以性能优化的重心在读链路核心手段是缓存。6.1 缓存更新策略用Cache Aside而不是更新缓存商品信息被修改后缓存怎么更新最常见的错误是先更新数据库再更新缓存。这个做法在并发下会产生脏数据线程A更新数据库商品价格为100线程B更新数据库商品价格为200B操作晚于A网络原因导致B先更新缓存为200随后A更新缓存为100最终数据库价格是200缓存价格却是100数据不一致。正确做法是Cache Aside模式先更新数据库再删除缓存。下次读的时候发现缓存没有回源数据库重建缓存。删除缓存比更新缓存更安全因为即使删除失败也只是多一次回源不会产生错误数据。public void updateProduct(Product product) { productMapper.updateById(product); // 更新成功后删除缓存让下一次读取回源 redisTemplate.delete(PRODUCT_KEY product.getId()); }6.2 缓存穿透、击穿、雪崩的工程应对这三个词在Java面试题里几乎是必考的但实际项目里真要处理不能停在概念层面穿透请求一个不存在的商品ID缓存没有、数据库也没有每次请求都打到数据库。解决方法缓存空值设置短过期时间比如5分钟或者用布隆过滤器预判。击穿某个热点商品缓存刚好过期大量请求同时涌向数据库重建缓存。解决方法重建缓存时加互斥锁只有一个线程回源数据库其他线程等待缓存重建完成。雪崩大量key同一时间过期数据库压力瞬间爆掉。解决方法过期时间加随机值别用固定过期时间。补一个缓存空值的示例代码这个是最实用的小技巧public Product getProduct(Long productId) { String key PRODUCT_KEY productId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { // 如果缓存是空值标记直接返回null if (EMPTY_MARK.equals(cached)) { return null; } return JSON.parseObject(cached, Product.class); } // 互斥锁重建只允许一个线程查库 String lockKey LOCK_KEY productId; boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(10)); if (locked) { try { Product product productMapper.selectById(productId); if (product null) { // 缓存空值防止穿透 redisTemplate.opsForValue().set(key, EMPTY_MARK, Duration.ofMinutes(5)); } else { redisTemplate.opsForValue().set(key, JSON.toJsonString(product), Duration.ofMinutes(30 RandomUtil.randomInt(300))); } return product; } finally { redisTemplate.delete(lockKey); } } else { // 没抢到锁的线程短暂sleep后重试 ThreadUtil.sleep(50); return getProduct(productId); } }这里有个细节缓存空值的过期时间不要设置太长否则商品上架后用户仍然看到商品不存在。5到10分钟比较合适。6.3 深分页问题limit 100000,20 为什么慢商城的商品列表和订单列表翻页翻到后面LIMIT 100000, 20会非常慢。原因不是MySQL不想给你20条而是它必须扫描前100020行再丢掉前100000行。数据量一大这个操作就是灾难。两个常用解法游标分页App端下拉加载场景用WHERE id lastId ORDER BY id LIMIT 20每次传上一页最后一个ID而不是页码。覆盖索引列表页先查主键或查询需要的索引列再回表取完整数据。比如先SELECT id FROM t_order WHERE shop_id? ORDER BY create_time DESC LIMIT ? OFFSET ?再用id去查详情。如果是后台管理系统的表格分页因为深翻页场景少可以先用覆盖索引顶着但如果是用户端商品列表务必改成游标分页或者接入ES。6.4 关于Java排序和对象深度拷贝的两个实际话题热词里有java排序和冒泡排序这种基础词我拿商城场景说点正经的业务排序永远放在SQL或ES里做而不是把数据load到内存里用Java排序。一台MySQL服务器可以在毫秒内排完几百万行的索引数据而你把1万条商品load到JVM里用冒泡排序内存和时间成本都是灾难。Java的排序算法是基本功面试要会写但工程上不要让排序发生在业务代码里这是架构师和初学者的分水岭。至于java对象深度拷贝商城项目里太多坑。比如商品DO查到后转成VO返回给前端如果VO和DO是浅拷贝VO修改了某个字段DO也变了一不小心就把数据库对象给污染了。我常用的做法是用MapStruct做DTO/VO转换编译期生成转换代码性能好、类型安全。如果要用JSON序列化做深拷贝注意循环引用比如订单→用户→订单这种环Jackson会栈溢出。解决办法是在实体的引用字段上加JsonIgnore或者设计专门的VO不返回关联实体。Mapper(componentModel spring) public interface ProductConverter { Mapping(source salePrice, target price) Mapping(source stockNum, target stock) ProductVO toVO(ProductDO product); }还有个更隐蔽的坑缓存里存的是ListProduct对象从Redis反序列化后直接返回给前端如果后续代码修改了这个list的内容比如排序、过滤可能把缓存对象污染。稳妥的做法是缓存里读到对象后给前端返回前做一次深拷贝或转换为VO再返回。7. Controller层防护与反爬接口安全不能只靠登录多用户商城一旦上线爬虫和脚本就开始盯上你了。商品价格被爬走、优惠券被脚本抢光、短信接口被刷爆、订单接口被遍历。这些问题在Staging环境根本暴露不出来上线第一周就全来了。所以Controller层的防护不是可选项是必须项。7.1 常见攻击场景与对应防护攻击场景现象防护手段商品信息被抓取竞对周期性批量抓取商品详情和价格接口限流、验证码、签名、日志监控异常频率优惠券被脚本抢上架1秒被抢完全是脚本人机验证、风控策略、领取次数限制短信接口被刷短信费用飙涨用户被骚扰手机号频率限制、IP限流、验证码前置订单参数遍历批量遍历订单ID查看他人信息资源归属校验、ID不落地要用业务订单号接口重放同一请求反复提交导致重复下单/重复领取幂等令牌、时间戳校验7.2 防爬虫的落地组合拳先说结论防爬虫不能靠单一手段我自己用的组合是限流 签名 行为验证 异常日志。第一层接口限流。用Redis做一个简单的滑动窗口或令牌桶对IP 接口维度做频率限制。例如商品详情接口同一IP每秒最多允许5次请求public boolean tryAcquire(String key, int limit, int windowSeconds) { String script local count redis.call(INCR, KEYS[1]); if count 1 then redis.call(EXPIRE, KEYS[1], ARGV[1]); end; return count tonumber(ARGV[2]); // 简单实现同IP同接口窗口内请求数上限 Boolean allowed stringRedisTemplate.execute( new DefaultRedisScript(script, Boolean.class), Collections.singletonList(rate: key), String.valueOf(windowSeconds), String.valueOf(limit) ); return Boolean.TRUE.equals(allowed); }这里推荐把限流逻辑写成Lua脚本因为判断计数设置过期时间必须原子操作否则并发下会超限。第二层请求签名防重放。对服务到服务之间的接口调用比如App客户端调用OpenAPI加上签名机制客户端用AppSecret对timestamp nonce body做HMAC签名服务端验签。同时用Redis记录已经使用过的nonce防止同一请求被原样重放。签名的那三个字段缺一不可timestamp过期校验时间差超过5分钟的请求直接拒绝防止抓包后无限期重放nonce随机串一个nonce只能用一次这个幂等逻辑能挡住完全相同的重复请求sign签名保证请求体和参数没有被篡改第三层验证码和行为风控。领券、注册、登录这类真人操作接口至少上一道人机验证。优惠券秒杀类接口我建议再加上行为特征判断同一设备指纹、同一用户、同一IP的领取频率频次异常直接拉黑。第四层监控日志。Controller层打印关键操作日志时把IP、User-Agent、接口路径、响应时间都记录下来。出现同一IP在1秒内请求100次这种异常日志直接接入告警。没有日志和监控的防护等于盲人摸象。7.3 水平越权和越权接口防护这节课本质上是上一章节讲到的行级权限在接口层的延伸再强调一次登录认证解决你是不是合法用户的问题权限校验解决你能否执行这个操作的问题归属校验解决这个资源是不是你的的问题三层都过了接口才算安全。权限框架用Spring Security或Sa-Token都可以但归属校验必须写在业务代码里框架不会帮你做这个订单属于这个用户吗这种判断。我见过太多项目Security配得像模像样一个订单详情接口却把人家的数据看光了。7.4 参数校验与数据脱敏不该省Controller层一定要做参数校验。用Jakarta Bean Validation NotBlank、Size、Pattern 拒绝非法参数进入Service层。原因很实际非法参数可能触发慢查询比如超长字符串被LIKE了一次、可能绕过某些业务判断、可能把脏数据写入数据库。数据脱敏方面手机号、邮箱、身份证号在接口返回值里必须打码。手机号只返前三位和后四位。比如138****8000实现上可以用Sensitive自定义注解配合Jackson序列化器一行注解搞定public class UserVO { Sensitive(prefixLen 3, suffixLen 4) private String mobile; }这在买家信息页、商家后台订单详情里都非常实用。谁也不想自己的手机号被批量爬走后收到骚扰电话。8. 真实踩坑汇总环境变量、启动失败、串数据和死锁排错最后这部分是纯经验分享没有那种复制粘贴就能用的代码全是运维和排错中的血泪教训。很多问题你第一次遇到会一头雾水我当年也是从抓头变成老油条的。8.1 环境变量与启动失败的常规排查java环境变量配置详细教程和java启动失败怎么解决这两个热词我怀疑是初学者搜索最多的。做商城项目也不例外新电脑上跑Spring Boot第一个就是环境问题。我的排错顺序固定是这样java -version能不能执行不能就查JAVA_HOME和PATH。注意Windows和Linux的配置方式不一样Windows配置环境变量后要重开终端Linux上export只对当前会话生效要写入/etc/profile或~/.bashrc。端口被占用8080起不来优先看日志里的Port already in use。Windows用netstat -ano | findstr 8080Linux用lsof -i:8080找到PID杀掉或者改server.port。内存不够Spring Boot 3.x 默认JVM参数可能吃了很多内存Docker容器里尤其明显。我踩过的坑是容器限制512MBJVM默认堆大小却按物理机配置直接把容器撑爆。依赖冲突启动报NoSuchMethodError、ClassNotFoundException这类错十有七八是依赖版本冲突。用mvn dependency:tree查看依赖树重点排查Spring Boot版本和第三方库版本是否兼容。这套顺序能解决90%的启动问题。剩下的10%基本都是配置文件错误打不开页面上看日志比如application.yml里数据源地址写错、Redis密码不对这类低级错误。8.2 串数据事故最难以启齿但必须警惕的线上故障我前面反复说串数据是因为我真的酿过这种事故。情况是这样的上线了一个商家订单列表接口测试环境数据量小没暴露问题。结果商家A在后台看到了商家B的订单而且不止一单。排查下来发现原因特别简单我用了MyBatis-Plus的多租户拦截器但那张订单表有个自定义SQL手写了复杂JOINSQL没有经过MyBatis-Plus的解析器导致shop_id条件没有被自动拼接。排查方式把MyBatis的SQL日志打开一条条核对执行语句发现查询订单详情的SQL确实没有shop_id。修复方式把自定义SQL重写显式传shop_id同时所有Mapper方法都加上单元测试断言最终SQL包含租户条件。这里想给所有人提个醒多租户拦截器不是万能的。每写一条自定义SQL都要在脑子里问自己一遍这条SQL会被哪个角色查看它带不带租户条件线上排查SQL问题的技巧MyBatis配置里打开SQL日志生产环境可以用独立的慢SQL日志文件定期人工审查。现在很多公司用SkyWalking或Arthas在线抓取SQL也能达到同样效果。8.3 数据库死锁与慢SQL的排错思路商城系统最容易死锁的场景是高并发下多线程同时更新同一行数据。比如热门商品的库存扣减、优惠券的核销、账户余额的变动。死锁报错长这样Deadlock found when trying to get lock。排查步骤打开MySQL死锁日志SHOW ENGINE INNODB STATUS;找到LATEST DETECTED DEADLOCK段。看两个事务获取锁的顺序是否一致。比如事务1先锁order表再锁inventory表事务2先锁inventory再锁order两个事务互相等对方的锁就死锁了。修复方式很直白在业务代码层面统一加锁顺序。所有订单操作都先锁订单再锁库存谁也不能反过来。慢SQL的排查也是常规操作。用EXPLAIN看执行计划重点看type这一列ALL全表扫描基本不合格index全索引扫描一般也不理想ref或eq_ref是正常水平system/const是顶级另外注意一个容易被忽略的点对列用了函数后索引会失效。比如WHERE DATE(create_time) 2025-01-01绝对不走create_time索引应该改成create_time 2025-01-01 00:00:00 AND create_time 2025-01-02 00:00:00。8.4 排错方法论先本地复现再开日志最后定方案我在带人的时候经常看他们排错的方式上来就在生产环境改参数试这是最危险的。正确顺序永远是本地复现用最简单的最小用例把问题复现出来打开日志SQL日志、应用日志、中间件日志逐层排查定位根因找到是代码逻辑问题还是环境配置问题按根因修复不要在没定位根因前乱加参数加回归测试证明这个问题不会再出现这套方法论看起来简单但大部分人排错排不进去就是死在第一步——本地复现不出来就开始瞎猜。建议把关键数据表做脱敏后导出到本地让测试环境和生产环境的数据分布尽量一致。我在这个项目上最浪费时间的一天就是把一个偶发超卖问题当作Redis锁问题排查了一天结果原因是数据库行的stock字段被一个定时任务重复回补了。如果早点按方法论排查半小时就能定位。最后说几句多用户商城这个项目Java开发者用来练手和面试都非常有价值但它真正的价值不在你能默写Spring Boot的注解而在你能把多用户带来的数据边界、数据一致性、并发控制这些真问题想明白。我做完这个项目最大的体会是技术栈真的不是瓶颈瓶颈是你有没有想清楚这是谁的数据、谁有权动它、并发下怎么保证不冲突。如果你打算动手做我的建议很直接不要克隆一个开源商城改个名字。从建表开始把用户、商家、商品、订单、购物车、支付回调、超时关闭、库存回补这一整条链路自己敲一遍。那个过程会让你把事务、锁、缓存、消息队列这些Java面试必考点全部在实战里打通。最后分享一个小技巧开发阶段把MyBatis的SQL日志默认打开并养成每次看日志先确认SQL里带没带shop_id的习惯。相信我这个习惯能帮你提前发现90%的串数据问题。祝你在多用户商城这个项目里少踩坑多沉淀。