捣鼓一个电商功能设计

举报
JavaSouth南哥 发表于 2024/11/12 15:57:42 2024/11/12
【摘要】 如果秒杀库存只有10,在下单接口前面,我们可以设置一个过滤拦截,只有前50个用户才会进入下单流程,拒绝其他用户的下单请求,其他用户甚至不需要进行下单的流程。随着用户量的激增,肯定的是业务复杂性会逐日递增,你会发现简简单单的一个表,不知不觉多出了很多奇奇怪怪的字段。对于整个下单的流程,包括库存的减少、用户扣费、订单表的创建都应该包含在同一个MySQL事务中,一旦流程中的任何一个逻辑出错,则进行回滚。

先赞后看,Java进阶一大半

谷歌系统设计面试有一道题是关于如何设计秒杀架构,国外一位老哥给出了5种方法,下图是其中一种。

在这里插入图片描述

我是南哥,相信对你通关面试、拿下Offer有所帮助。

敲黑板:以下的功能设计,面试官会这么问你!

  1. 数据库表你怎么设计的?
  2. 那商品列表接口怎么保证可用性?
  3. 商品详情为什么要加缓存?
  4. 下单逻辑怎么保证安全性?
  5. 你会怎么设计秒杀抢购功能?

⭐⭐⭐收录在《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端点击下单按钮,后端服务要走一套怎么样的流程?首先我们需要先进行校验。

  1. 用户身份校验
  2. 用户余额校验
  3. 商品校验
  4. 商品库存校验

(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到你的点赞点赞点赞。

在这里插入图片描述

创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️

【版权声明】本文为华为云社区用户原创内容,转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息, 否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。