捣鼓一个电商功能设计
先赞后看,Java进阶一大半
谷歌系统设计面试有一道题是关于如何设计秒杀架构,国外一位老哥给出了5种方法,下图是其中一种。
我是南哥,相信对你通关面试、拿下Offer有所帮助。
敲黑板:以下的功能设计,面试官会这么问你!
- 数据库表你怎么设计的?
- 那商品列表接口怎么保证可用性?
- 商品详情为什么要加缓存?
- 下单逻辑怎么保证安全性?
- 你会怎么设计秒杀抢购功能?
⭐⭐⭐收录在《Java学习/进阶/面试指南》:https://github/JavaSouth
好久之前就想写这么一篇商品功能设计,这几天得空把坑给填了,给南友们多一个 “项目亮点” 的参考。
1. 电商功能设计
1.1 商品表设计
面试官:数据库表你怎么设计的?
南哥先给出电商业务最基础的几个表设计。随着用户量的激增,肯定的是业务复杂性会逐日递增,你会发现简简单单的一个表,不知不觉多出了很多奇奇怪怪的字段。
(1)商品表
CREATE TABLE products (
product_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
stock INT DEFAULT 0,
category_id INT,
status ENUM('active', 'inactive', 'deleted') DEFAULT 'active',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (category_id) REFERENCES product_categories(category_id)
);
(2)商品分类表
CREATE TABLE product_categories (
category_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
parent_id INT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (parent_id) REFERENCES product_categories(category_id)
);
(3)用户购物车表
CREATE TABLE shopping_carts (
cart_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
product_id INT NOT NULL,
quantity INT DEFAULT 1,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id),
FOREIGN KEY (product_id) REFERENCES products(product_id)
);
(4)订单表
CREATE TABLE orders (
order_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
total_price DECIMAL(10, 2) NOT NULL,
status ENUM('pending', 'completed', 'cancelled') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(user_id)
);
1.2 商品列表
面试官:那商品列表接口怎么保证可用性?
商品列表在电商APP有多种形式,例如:热门商品列表、查询条件商品列表、用户推荐商品列表。
(1)热门商品列表
特别针对第一种形式,热门商品列表要重点加上缓存,毕竟该列表所有用户打开APP都需要显示出来,可以把该接口归类为高并发设计接口。
热门商品有一个特点,商品的更新速度快,可能某个商品半小时还在热门,下一秒突然不见。
这里我们采用Redis分布式缓存,后台配置热门商品时更新分布式缓存,而热门商品列表接口直接查询Redis,不把压力落到数据库。
// 后台配置热门商品时更新分布式缓存,而热门商品列表接口直接查询Redis
public List<Product> getHotProductList() {
// 从Redis缓存获取热门商品列表
List<Product> hotProducts = redisTemplate.opsForList().range("hot_products", 0, -1);
if (hotProducts == null || hotProducts.isEmpty()) {
// 如果缓存为空,从数据库查询,并更新缓存
hotProducts = productService.fetchHotProductsFromDB();
redisTemplate.opsForList().rightPushAll("hot_products", hotProducts);
}
return hotProducts;
}
另外需要把热门商品列表缓存到APP端,不至于每次返回主页面就调用一次接口查询。APP端缓存接口设置短些,例如1 分钟,毕竟上文有提到热门商品更新速度是比较快的!
(2)查询条件商品列表
用户的查询条件多种多样,我们可以把用户查询关键词通过埋点记录下来,要求运营给出热度最高的商品查询关键词。
针对热门关键词查询,把查询结果进行缓存。当然整个查询结果会很大,我们设置对前几页进行缓存。
缓存放在哪?
这里我们仍然放在Redis分布式缓存。有人可能会说放到后端本地缓存?MyBatis一级、二级缓存的坑或许他还没遇到,MyBatis一级缓存作用于SqlSession对象,二级缓存作用于Mapper对象。这造成了各个后端服务的本地缓存不同,每次查询的结果都不相同。
当然有些业务可以用到,例如阅读量这些用户不太在意的的数据可以用本地缓存。
// 查询条件商品列表
public List<Product> getProductsByQuery(String query, int page) {
String cacheKey = "query_products:" + query + ":page" + page;
List<Product> products = redisTemplate.opsForList().range(cacheKey, 0, -1);
if (products == null || products.isEmpty()) {
// 如果缓存为空,从数据库查询,并更新缓存
products = productService.fetchProductsByQueryFromDB(query, page);
redisTemplate.opsForList().rightPushAll(cacheKey, products);
// 缓存有效期为10分钟
redisTemplate.expire(cacheKey, 10, TimeUnit.MINUTES);
}
return products;
}
另一个问题,查询结果变化怎么办?
这里我们设置一个定时任务,每隔一段时间更新 “查询条件商品列表” 的缓存结果。
// 定时任务更新缓存
@Scheduled(fixedRate = 600000)
public void updateProductsCache() {
// 重新从数据库获取数据并更新缓存
List<String> hotQueries = analyticsService.getHotQueries();
for (String query : hotQueries) {
List<Product> products = productService.fetchProductsByQueryFromDB(query, 1); // 仅示例:更新第一页数据
String cacheKey = "query_products:" + query + ":page1";
redisTemplate.delete(cacheKey);
redisTemplate.opsForList().rightPushAll(cacheKey, products);
// 重设缓存有效期
redisTemplate.expire(cacheKey, 10, TimeUnit.MINUTES);
}
}
1.3 商品详情
面试官:商品详情为什么要加缓存?
商品详情的特点是更新频率慢,另外用户的操作习惯是:会不断退出重进,反复浏览某个商品的详情页。
猜猜他们在干嘛,用户在反复对比不同商品,劝说自己究竟要买哪一个,毕竟强迫症大家都有的。
基于以上的用户行为、商品详情特点,我们可以把商品详情缓存到APP端。
1.4 商品下单
面试官:下单逻辑怎么保证安全性?
电商业务的订单记录表、商品下单接口是最重要的核心模块,毕竟这一块涉及到了业务赚钱的核心。
(1)校验功能
用户从APP端点击下单按钮,后端服务要走一套怎么样的流程?首先我们需要先进行校验。
- 用户身份校验
- 用户余额校验
- 商品校验
- 商品库存校验
(2)防重复提交
再者,对于下单接口需要添加防重复提交限制,这里可以有多种方案。举个例子,采用Redis分布式锁方案,Redis分布式锁的key设置与用户、商品id相关。
# Redis分布式锁的key
lock:order:{uid}:{product_id}
用户下单某一个商品,会获取Redis分布式锁。对于同一个商品,在前一个商品的逻辑没有处理完成时,不能进行下一次下单请求。
防重复提交的作用主要是防止用户误触,或者同一时间多个重复下单请求造成的数据异常。
(3)事务控制
对于整个下单的流程,包括库存的减少、用户扣费、订单表的创建都应该包含在同一个MySQL事务中,一旦流程中的任何一个逻辑出错,则进行回滚。
(4)异步处理
对于下单成功后的其他操作,例如下单成功信息通知用户等,可以使用任务队列的形式异步去执行,减少下单接口的耗时。
// 用户下单接口
public Order placeOrder(int userId, int productId, int quantity) throws Exception {
// 获取分布式锁
String lockKey = "lock:order:" + userId + ":" + productId;
if (!redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS)) {
throw new Exception("下单过于频繁,请稍后再试");
}
try {
// 检查用户、商品及库存
userService.verifyUser(userId);
Product product = productService.verifyProduct(productId);
inventoryService.checkInventory(productId, quantity);
// 开始事务
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
// 减库存,扣费,生成订单
inventoryService.decreaseInventory(productId, quantity);
userService.debitUserAccount(userId, product.getPrice().multiply(new BigDecimal(quantity)));
Order order = orderService.createOrder(userId, productId, product.getPrice(), quantity);
transactionManager.commit(status); // 提交事务
return order;
} catch (Exception e) {
transactionManager.rollback(status); // 回滚事务
throw e;
}
} finally {
redisTemplate.delete(lockKey); // 释放锁
}
}
1.5 重点:秒杀抢购
面试官:你会怎么设计秒杀抢购功能?
我们可以把秒杀抢购看成是商品下单的特殊场景。秒杀抢购的并发量高,库存有限,且秒杀商品的页面会独立出来,不会和其他商品页面耦合在一起。
基于以上简单的梳理,我们可以这么设计来保证秒杀场景的稳定性。
(1)秒杀页面静态化
把秒杀商品页面设置为静态化,当用户刷新页面时,只需要从服务器获取基础后端数据进行填充。另外当用户点击秒杀按钮后,前端把按钮进行置灰,减少用户的请求。
(2)下单限制
很多程序员的初始设计会把所有请求都进入下单接口流程,完全没必要!!!
如果秒杀库存只有10,在下单接口前面,我们可以设置一个过滤拦截,只有前50个用户才会进入下单流程,拒绝其他用户的下单请求,其他用户甚至不需要进行下单的流程。
后续在由这50个用户抢夺这10个商品库存。
// 决定是否让用户进入抢购流程
public class SeckillController {
@Autowired
private KafkaTemplate<String, SeckillOrderRequest> kafkaTemplate;
public ResponseEntity<String> placeSeckillOrder(int userId, int productId) {
String queueName = "seckill_orders";
String lockKey = "seckill:availability:" + productId;
// 检查是否还有秒杀资格
Long rank = redisTemplate.opsForValue().increment(lockKey);
if (rank == null || rank > 50) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("抱歉,秒杀名额已满。");
}
// 创建秒杀请求
SeckillOrderRequest request = new SeckillOrderRequest(userId, productId);
// 发送到Kafka队列
kafkaTemplate.send(queueName, request);
return ResponseEntity.ok("您的秒杀请求已接收,正在处理中,请耐心等待结果。");
}
}
(3)下单请求任务化
把每一个下单请求都抽象为一个Kafka队列任务,任务一个个执行,减少系统的瞬时压力。
// 出来下单队列任务
@Service
public class SeckillOrderConsumer {
@Autowired
private OrderService orderService;
@Autowired
private ProductService productService;
@Autowired
private InventoryService inventoryService;
@KafkaListener(topics = "seckill_orders", groupId = "seckill_group")
public void consume(SeckillOrderRequest request) {
try {
// 检查库存
if (!inventoryService.checkInventory(request.getProductId(), 1)) {
throw new Exception("库存不足");
}
// 下单处理
Order order = orderService.createSeckillOrder(request.getUserId(), request.getProductId(), 1);
// 其他逻辑处理
notifyUser(order);
} catch (Exception e) {
// 处理失败逻辑
System.out.println("秒杀处理失败:" + e.getMessage());
}
}
private void notifyUser(Order order) {
// 通知用户秒杀结果
}
}
⭐⭐⭐本文收录在《Java学习/进阶/面试指南》:https://github/JavaSouth
我是南哥,南就南在Get到你的点赞点赞点赞。
创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️
- 点赞
- 收藏
- 关注作者
评论(0)