
新电商项目搭建避坑:3个核心模块完整示例拆解
刚学完 Python 或 Java 语法,对着教程敲代码没问题,但真要动手搭一个像样的新电商后台,脑子立马一片空白。很多开发者卡在“知道怎么写,却不知怎么连”这一步。别急,今天不聊虚的,直接上完整示例,拆解新电商系统中最核心的三个模块:商品聚合、价格同步、库存扣减。这些代码在 CSDN 上被无数博主贴过,但大多只贴片段,缺乏上下文。我结合实战经验,把源码逻辑揉碎了讲,帮你打通从语法到架构的任督二脉。
入口定位:从 Controller 到 Service 的链路
很多人写电商系统,喜欢把所有逻辑堆在 Controller 里。这是大忌。新电商的高并发场景下,Controller 必须薄如蝉翼。
以商品详情接口为例,入口代码看似简单,实则暗藏玄机。我们看一段典型的 Spring Boot 入口代码:
@RestController
@RequestMapping(/api/v1/products)
public class ProductController {
@Autowired
private ProductService productService;
// GET /api/v1/products/{id}
@GetMapping(/{id})
public ResponseEntityProductVO 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 {
@Autowired
private ProductMapper productMapper;
@Autowired
private RedisTemplateString, Object redisTemplate;
// 更新商品库存
@Transactional
public 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)
ResultVoid decreaseStock(@RequestParam(id) Long id, @RequestParam(count) int count);
}
注意:
远程调用失败怎么办?需要加入重试机制和熔断降级(如 Sentinel)。
数据一致性怎么保证?分布式事务是难点。常用方案是最终一致性(消息队列 + 补偿机制),而不是强一致性(2PC)。
对于初学者,建议先在单体架构下跑通全流程,再逐步拆分为微服务。不要一开始就上 Kubernetes、Service Mesh,那是过度设计。
结尾互动:
你在项目里踩过这个坑吗?比如缓存不一致、库存超卖、或者服务拆分后的调用超时?评论区聊聊,咱们一起避坑。