手把手教你做系统设计 | 青训营笔记

157 阅读4分钟

这是我参与「第五届青训营 」笔记创作活动的第7天

内容来自字节内部课:【实践课】手把手教你做系统设计手把手教你做系统设计之秒杀系统.pptx

一、本堂课重点内容:

  1. 系统设计方法论
  2. 电商秒杀业务介绍
  3. 课程实践
  4. 课程总结

二、详细知识点介绍:

1. 引言

系统设计的问题:

  • 为什么要做系统设计
  • 系统设计的定义是什么
  • 怎么做系统设计,如何落地一个系统
  • 系统功能实现后如何分析瓶颈并优化
  • 如何验证系统的可用性和稳定性

系统设计的定义:

  • 系统:关联的个体,规则运作,组成工作的个体
  • 设计:设想和计划,目的,过程安排

定义:为了达成某种目的,通过个体组成整体的过程

如何做系统设计(4s分析法):

  1. 场景分析( Scenario ):什么系统,需要哪些功能,多大的并发量
  2. 存储设计( Storage )数据如何组织,Sql存储,NoSql存储
  3. 服务设计( Service ):业务功能实现和逻辑整合
  4. 可扩展性( Scale ):解决设计缺陷,提高鲁棒性、扩展性

如何发现系统的瓶颈:

  • 火焰图分析
  • 链路追踪
  • 性能测试

如何保证可用性和稳定性:

  • 链路梳理:
    • 核心链路
    • 流量漏斗(eg.购物车10个商品只下单两个)
    • 强弱依赖(弱依赖可降级,强依赖不可)
  • 可观测性(观测指标):
    • 链路追踪(全链路微服务追踪)
    • 核心监控
    • 业务报警
  • 全链路测试:
    • 压力测试
    • 负载测试
    • 容量测试(eg.双十一)
  • 稳定性控制:
    • 系统限流
    • 业务兜底
    • 熔断降级
  • 容灾演练:
    • 混沌工程(故障注入,演练)
    • 应急手册
    • 容灾预案(机房故障等)

2. 电商和秒杀

电商要素:

  • 人-消费者侧:消费者,用户,流量来源
  • 货-供给侧:商品,商家,供应链
  • 场-交易环境:线下商场,线上电商

电商介绍:

商品:具有交易价值和属性的信息载体

  • SPU: Standard Product Unit (标准化管理)
  • SKU: Stock Keeping Unit (库存保存单元,具有交易属性)

秒杀业务的特点:瞬时流量高;读多写少;实时性要求高

秒杀的挑战:资源成本(资源有限);反欺诈;高性能;防止超卖;流量管控(对无用流量拦截、过滤);扩展性;鲁棒性(系统稳定性、可用性)

3. 设计秒杀系统

场景(Scenario)

  • 功能:秒杀活动发布;秒杀商品详情;秒杀下单
  • 并发:万人参与秒杀;QPS 1w+;TPS 1k+

存储(Storage) 三级存储:MySQL->Redis->LocalCache(比Redis性能更高)

image.png

服务(Service)

  • 子服务:用户服务;风控服务;活动服务;订单服务
  • 基础组件:ID生成器(生成订单ID——分布式ID生成);缓存组建;MQ组件(保护系统——削峰);限流组件(保护系统)

扩展:

  • 流量隔离(与常规流量合理)
  • CDN(静态资源缓存,提高效率)
  • 缓存优化
  • 流量管控(拦截、过滤)
  • 数据库扩展
  • 服务水平扩展(负载均衡、反向代理)
  • MQ扩展(主从架构、多主多从架构)
  • Redis扩展
  • 服务垂直扩展

系统架构图

image.png

4. 实践

秒杀流程图:

image.png

重要代码部分:

src/main/java/com/camp/promotion/service/impl/HPromotionServiceImpl.java

public List<CreatePromoProductModel> CreatePromoActivity(CreateActivityModel createActivityModel)

public HPromoProductModel getPromotionProductDetail(Long promoId, Long skuId, Long spuId)

src/main/java/com/camp/promotion/lock/RedisDistributedLock.java

public void unlock() {
    // 为了保证可靠性采用lua脚本
    // 在父类中定义了close方法——自动解锁
    Long result;
    ...

src/main/java/com/camp/promotion/controller/CategoryController.java

public ResponseData<?> testLock() {
    // 把锁的创建放在try后,不用写finally了,自动调用close方法
    try (RedisDistributedLock lock = new RedisDistributedLock("test_key", 10000)) {
        if (lock.tryLock()) {
            return ResponseData.Success("ok");
        }
    } catch (Exception e) {
        throw new RuntimeException("lock fail");
    }
    ...

src/main/java/com/camp/promotion/mq/OrderProducer.java

public boolean send(HOrder order) {
    byte[] body = JSON.toJSONBytes(order);  // 想提升性能可用protobuff(序列化方式)
    Message message = new Message(topicName, "create_order" + order.getId(), body);

src/main/java/com/camp/promotion/service/IdGenerateService.java

public synchronized long generateId() {
    ...
    // ID生成器,雪花算法,性能很高
    /*
     * (currentMilliseconds - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) 时间毫秒数往右移动22位,避开workId和sequence,放到高位的41位
     * (getWorkerId() << WORKER_ID_LEFT_SHIFT_BITS) 将workId左移到随后的10位上
     * 最后是sequence占据低位的12位
     *
     * 最后用或运算将三个部分组合到一个long中,返回最终的结果
     */
    return ((currentMilliseconds - EPOCH) << TIMESTAMP_LEFT_SHIFT_BITS) | (getWorkerId() << WORKER_ID_LEFT_SHIFT_BITS) | sequence;
}

src/main/java/com/camp/promotion/service/CacheService.java

public HPromoProductModel getVal(String key) {
    ...

    } else {
        // 如果qps非常高,这一步会成为瓶颈,那就没必要做了
        String stockKey = Constant.generatePromoStockKey(promoId, skuId, spuId);
        // 从redius中再获取,因为库存是实时的数据
        Integer stock = (Integer) redisService.getVal(stockKey);
        if (stock != null) {
            model.getSkuModel().setPromoStock(stock);
        }
    }

    return model;
}

src/main/java/com/camp/promotion/mq/OrderPullConsumer.java

public void doConsumer() throws InterruptedException {
    while (isRunning.get()) {
        ...

        executorService.execute(() -> {
            try {
                // 线程池里做异步消费
                processOrder(messageExts);
            } catch (Exception e) {
                log.error("process order fail, e = ", e);
            }
        });

        ...
    }
}

@Transactional(rollbackFor = Exception.class)
public void processOrder(List<MessageExt> messageExts) {
    ...
    
    // 真正从数据库中扣减
    int updateStockRes = this.hPromoProductService.decreaseStock(orders.get(0).getActivityId(), orders.get(0).getSkuId(), decreaseCount);
    if (updateStockRes < 1) {
        log.error("process order fail, size = {}, data = {}", orders.size(), JSON.toJSONString(orders));
        this.deadQueue.addAll(orders);
        throw new BizException(ResponseEnum.CONSUMER_FAIL);
    }

    // 批量插入(优化了性能)
    int insertRes = this.hOrderService.batchInsert(orders);
    if (insertRes < orders.size()) {
        log.error("process order fail, size = {}, data = {}", orders.size(), JSON.toJSONString(orders));
        this.deadQueue.addAll(orders);
        throw new BizException(ResponseEnum.CONSUMER_FAIL);
    }
}

服务器操作:

看redis是不是单线程的:

ps -ef | grep redis
top -HP 1076955

jmeter:简单压测工具

思考题:

  • 发起订单请求100个,数据库中库存剩1,redis中为0,为什么?
  • 我们在业务中用MQ做了缓存,MQ中用户消费成功,但订单服务还没有这个消息,若此时用户立马去查看或取消该订单(在业务逻辑中还没有处理),我们如何解决该问题?

总结:

  • 服务无状态:当前应用服务不存储状态、数据
  • 批量写入:上面的项目中消费者采用了
  • 最终一致性:解释了用redis等技术产生了数据副本,但仍有最终一致性