新电商项目搭建避坑:3个核心模块完整示例拆解
刚学完 Python 或 Java 语法,对着教程敲代码没问题,但真要动手搭一个像样的新电商后台,脑子立马一片空白。很多开发者卡在“知道怎么写,却不知怎么连”这一步。别急,今天不聊虚的,直接上完整示例,拆解新电商系统中最核心的三个模块:商品聚合、价格同步、库存扣减。这些代码在 CSDN 上被无数博主贴过,但大多只贴片段,缺乏上下文。我结合实战经验,把源码逻辑揉碎了讲,帮你打通从语法到架构的任督二脉。
入口定位:从 Controller 到 Service 的链路
很多人写电商系统,喜欢把所有逻辑堆在 Controller 里。这是大忌。新电商的高并发场景下,Controller 必须薄如蝉翼。
以商品详情接口为例,入口代码看似简单,实则暗藏玄机。我们看一段典型的 Spring Boot 入口代码:
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {@Autowiredprivate ProductService productService;// GET /api/v1/products/{id}@GetMapping("/{id}")public ResponseEntity<ProductVO> getProduct(@PathVariable Long id) {// 1. 参数校验if (id == null || id <= 0) {throw new BusinessException(ErrorCode.PARAM_ERROR);}// 2. 调用 Service 层获取数据ProductVO product = productService.getDetail(id);// 3. 返回统一响应结构return ResponseEntity.ok(product);}
}
逐行解析:
@RestController:告诉 Spring 这是一个 REST 控制器,返回值直接序列化为 JSON。@Autowired:依赖注入,解耦 Controller 与业务逻辑。@GetMapping:映射 GET 请求,路径变量{id}接收商品 ID。- 关键点:这里没有 try-catch。异常应该由全局异常处理器(
@ControllerAdvice)统一捕获。如果在 Controller 里写 try-catch,代码会变得极其臃肿,且容易遗漏。 ProductVO:注意,这里返回的是 VO(View Object),而不是 Entity。数据库里的Product表可能有敏感字段(如成本价),不能直接透传给前端。VO 是专门给前端看的数据视图。
很多新手直接返回 Product 实体,导致接口暴露内部字段,这是安全漏洞,也是代码坏味道。记住:入口只做参数接收和响应包装,业务逻辑全部下沉到 Service。
核心片段:Redis 缓存与数据库的双写陷阱
新电商最头疼的不是功能,而是性能。商品详情页是高频读操作,直接查数据库会扛不住。于是引入 Redis 缓存。但缓存与数据库的一致性是永恒的话题。
来看一段常见的“先更新数据库,再删除缓存”代码:
@Service
public class ProductService {@Autowiredprivate ProductMapper productMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;// 更新商品库存@Transactionalpublic void updateStock(Long id, Integer stock) {// 1. 更新数据库productMapper.updateStock(id, stock);// 2. 删除 Redis 缓存String key = "product:detail:" + id;redisTemplate.delete(key);}// 获取商品详情public ProductVO getDetail(Long id) {String key = "product:detail:" + id;// 1. 查缓存Object cached = redisTemplate.opsForValue().get(key);if (cached != null) {return (ProductVO) cached;}// 2. 查数据库Product product = productMapper.selectById(id);if (product == null) {throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND);}// 3. 回填缓存,设置 30 分钟过期ProductVO vo = convertToVO(product);redisTemplate.opsForValue().set(key, vo, 30, TimeUnit.MINUTES);return vo;}
}
逐行解析与避坑:
@Transactional:保证数据库操作的原子性。但注意,Redis 操作不在事务回滚范围内。如果updateStock成功,但redisTemplate.delete失败,缓存和数据库就不一致了。- 为什么是“删除”而不是“更新”缓存? 因为“更新”缓存需要处理并发写冲突,且缓存数据可能是多字段组装的(比如关联了品牌、分类),直接更新容易出错。删除缓存,让下次请求懒加载,更稳妥。
- 缓存穿透问题:如果查询不存在的商品 ID,数据库查不到,就不会写缓存。下次同样的请求还会打到数据库。解决思路是:缓存空值,或者使用布隆过滤器。上述代码中,
product == null时直接抛异常,没有缓存空值,这在高并发下可能导致数据库被击穿。建议改为:redisTemplate.opsForValue().set(key, null, 2, TimeUnit.MINUTES);并返回一个默认的空对象。 - TTL 设置:30 分钟过期是经验值。对于库存这种高频变化数据,TTL 应更短,或者采用“主动更新 + 被动过期”结合的策略。
这段代码在 CSDN 的众多教程中很常见,但大多数文章忽略了“删除缓存失败”的后果。在生产环境中,建议使用延迟双删或基于 Binlog 的缓存更新(如 Canal)来保证一致性。
设计思想:为什么不用本地缓存?
有人问,为什么不用 Guava Cache 或 Caffeine 做本地缓存?因为新电商是分布式系统。
假设你有 10 台服务器,用户 A 在服务器 1 上修改了库存,服务器 1 的本地缓存更新了。但用户 B 请求到了服务器 2,服务器 2 的本地缓存还是旧数据。这就是缓存不一致问题。
Redis 作为分布式缓存,解决了多节点数据一致性问题。虽然网络延迟比内存访问高(约 1ms vs 100ns),但相比数据库查询(约 10-50ms),Redis 依然快得多。
设计权衡:
- 本地缓存:速度最快,适合读多写极少且数据一致性要求不高的场景(如商品分类、品牌列表)。
- Redis 缓存:速度次之,适合读多写多且数据一致性要求较高的场景(如商品详情、库存)。
- 数据库:最慢,但保证强一致性,适合写操作和低频读。
新电商的核心链路,必须采用 Redis + 数据库 的组合。本地缓存可以作为一级缓存,进一步减轻 Redis 压力,但会增加代码复杂度。初学者建议先搞定 Redis,再考虑本地缓存。
手写简化版:库存扣减的乐观锁实现
电商最核心的场景之一是库存扣减。如果两个用户同时抢购最后一件商品,如何处理?
悲观锁(SELECT ... FOR UPDATE)性能差,会锁表。生产环境通常用乐观锁。
public int decreaseStock(Long id, int count) {// 1. 查询当前库存Product product = productMapper.selectById(id);if (product == null) {throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND);}// 2. 检查库存是否足够if (product.getStock() < count) {throw new BusinessException(ErrorCode.STOCK_NOT_ENOUGH);}// 3. 执行更新,带上版本号条件int rows = productMapper.updateStockWithVersion(id, count, product.getVersion());// 4. 判断更新是否成功if (rows == 0) {// 版本号不匹配,说明并发冲突,抛出异常触发重试throw new BusinessException(ErrorCode.CONCURRENT_CONFLICT);}return rows;
}
对应的 SQL:
UPDATE product
SET stock = stock - #{count}, version = version + 1
WHERE id = #{id} AND version = #{version};
逐行解析:
selectById:先查一遍,获取当前version。updateStockWithVersion:SQL 中带有AND version = #{version}条件。如果数据库中的 version 与查询时不一致,说明有其他线程已经修改了数据,更新影响行数为 0。rows == 0:表示更新失败。此时可以抛出异常,由上层调用者决定是重试还是返回“抢购失败”。- 优点:无锁,高并发下性能优异。
- 缺点:存在“读-改-写”的中间状态,如果并发极高,重试次数可能很多。对于秒杀场景,可以结合 Redis 预扣减库存,减少数据库压力。
这个逻辑在 CSDN 的《Java 高并发实战》系列文章中被反复提及,是面试高频考点,也是生产环境必备技能。
应用场景与扩展:从单体到微服务
上述代码是基于 Spring Boot 单体架构的。当你把项目拆分成微服务时,这些模块会变成独立的 Service。
常见拆分方案:
- 商品服务:负责商品 CRUD、分类、品牌管理。
- 库存服务:负责库存查询、扣减、回滚。
- 订单服务:负责下单、支付、发货。
跨服务调用: 订单服务下单时,需要调用库存服务扣减库存。这时候不能用本地方法调用,必须用 Feign 或 Dubbo 进行 RPC 调用。
@FeignClient(name = "inventory-service")
public interface InventoryClient {@PostMapping("/api/v1/inventory/decrease")Result<Void> decreaseStock(@RequestParam("id") Long id, @RequestParam("count") int count);
}
注意:
- 远程调用失败怎么办?需要加入重试机制和熔断降级(如 Sentinel)。
- 数据一致性怎么保证?分布式事务是难点。常用方案是最终一致性(消息队列 + 补偿机制),而不是强一致性(2PC)。
对于初学者,建议先在单体架构下跑通全流程,再逐步拆分为微服务。不要一开始就上 Kubernetes、Service Mesh,那是过度设计。
结尾互动:
你在项目里踩过这个坑吗?比如缓存不一致、库存超卖、或者服务拆分后的调用超时?评论区聊聊,咱们一起避坑。