系统设计八股分析

设计模式

1.当被问及如何在多个接口中统一管理以避免代码重复时

你的初步想法是提取一个公共方法。面试官进一步引导你思考过滤器和拦截器。

方案1:使用Spring MVC的HandlerInterceptor(拦截器)

HandlerInterceptor是Spring MVC提供的AOP实现,专门用于在Controller方法执行前后进行预处理和后处理。它与请求生命周期紧密耦合,是处理用户认证、日志记录、上下文设置等横切关注点的标准方式。

  1. 创建一个类实现HandlerInterceptor接口。
  2. preHandle方法中,从请求(如Header)中获取Token,解析出用户信息,然后调用工具类的set()方法将用户信息存入ThreadLocal
  3. afterCompletion方法中,无论Controller方法执行成功还是失败,都调用工具类的remove()方法清理ThreadLocal,通常放在finally块中以确保执行。
  4. 创建一个配置类实现WebMvcConfigurer,重写addInterceptors方法,将你的拦截器注册到Spring容器中,并配置其拦截路径(如/api/**)。

方案2:使用Servlet的Filter(过滤器)

  1. 创建一个类实现javax.servlet.Filter接口。
  2. doFilter方法中,在调用chain.doFilter(request, response)之前,执行ThreadLocalset()操作。
  3. 使用try...finally结构,在finally块中执行ThreadLocalremove()操作,确保无论后续处理是否异常,都能清理资源。
  4. 使用@Component@Order注解(或通过FilterRegistrationBean)将Filter注册为Spring Bean。

Interceptor类似,实现了解耦和统一管理。由于作用范围更广,可以拦截静态资源等非Spring MVC处理的请求。

方案3:使用自定义AOP切面(@Aspect

  1. 创建一个类,并使用@Aspect@Component注解。
  2. 定义一个切点(Pointcut),例如@Pointcut("within(@org.springframework.web.bind.annotation.RestController *)"),用于匹配所有RestController类中的方法。
  3. 创建一个@Around环绕通知。在通知方法的try块中,执行ThreadLocalset()操作,然后调用proceedingJoinPoint.proceed()执行目标方法。在finally块中,执行remove()操作。

功能上与前两者类似,但提供了最大的灵活性,可以切入到Service层甚至任意Bean的方法。@Around通知需要手动调用proceed(),如果忘记调用,目标方法将不会被执行。

2.策略方法怎么去解决具体调用哪一个策略

  • 为了避免在业务代码中使用大量的if-elseswitch来选择策略,我们创建了一个策略工厂(Strategy Factory)
  1. 在项目启动时,Spring容器会扫描并加载所有CouponStrategy的实现类。
  2. 我们创建一个CouponStrategyFactory类,它在构造时注入一个Map<String, CouponStrategy>。Spring会自动将所有策略实现类注入到这个Map中,其中Key是Bean的名称(例如"fullDiscountStrategy"),Value是Bean实例。
  3. 我们约定优惠券类型(例如"FULL_DISCOUNT", "PERCENTAGE_DISCOUNT")与Bean名称有映射关系。
  4. 工厂类提供一个getStrategy(String couponType)方法。当业务代码需要使用某个策略时,它只需要传入优惠券类型字符串,工厂就会从Map中返回对应的策略对象。
  5. 我们完全消除了业务代码中的if-else判断。当未来需要增加一种新的优惠券时,我们只需要新增一个策略实现类,而不需要修改任何现有的业务逻辑代码,这完全符合开闭原则,使得系
  6. 统非常易于扩展和维护。”

3.100个有序文件,如何拼接保证整体有序?

我们有100个已经内部有序的数据源(文件),需要将它们合并成一个单一的、全局有序的输出。这正是归并排序中“归并(Merge)”这一步的经典应用。由于文件可能很大,无法一次性全部读入内存,所以这是一个外部排序问题。

我们可以使用最小堆来解决

  • 创建一个大小为100的最小堆
  • 为100个文件,每个文件都打开一个文件读取流(Reader)。
  • 每个文件中读取第一个数字,并将这个数字连同它所属的文件源信息(例如,文件索引)一起,封装成一个对象(如Node(value, fileIndex)),放入最小堆中。此时,堆中有100个元素。
  • 循环执行以下操作,直到堆为空: a. 取出最小元素:从最小堆的堆顶取出一个Node。这个Nodevalue就是当前全局最小的数字。 b. 写入输出文件:将这个value写入到最终的输出文件中。 c. 补充新元素:根据取出的Node中的fileIndex,我们知道这个数字来自哪个文件。我们立即从那个文件中读取下一个数字。 d. 处理文件结束:如果那个文件已经读完,则什么也不做。如果还能读到新数字,就将这个新数字和它的fileIndex再次封装成一个新的Node插入到最小堆中
  • 当最小堆为空时,意味着所有文件都已被读取完毕,输出文件也就包含了所有数字,并且是全局有序的。

这个问题本质上是一个典型的多路归并排序问题,特别是在处理无法完全加载到内存的大文件时,属于外部排序的范畴

4.设计一个高并发的系统

面试官您好,设计一个高并发秒杀系统,核心挑战在于如何在瞬时巨大流量下,保证库存扣减的绝对正确性系统的整体高可用。我的设计方案将围绕“层层过滤、异步处理、最终一致”的核心思想展开,严格遵循题目要求的几个方面进行阐述。

整体架构:

首先我会将整个秒杀流程进行动静分离垂直分层,构建一个清晰的数据流。

  • 前端层:商品详情页静态化,通过CDN分发,降低服务器压力。秒杀按钮在倒计时结束前置灰,并通过定时器从服务端获取最新时间,防止客户端时间不准导致提前请求。
  • 接入层:
    • Nginx/网关:负责反向代理、初步限流、过滤恶意请求。
    • 秒杀服务(独立部署):这是核心业务逻辑所在,与普通商品服务物理隔离,避免秒杀流量冲垮主站。
  • 数据处理流:
    1. 用户请求首先到达Nginx/网管。
    2. 通过限流后,请求进入秒杀服务。
    3. 秒杀服务在Redis中完成资格校验库存预扣减
    4. 预扣减成功后,立即向用户返回“排队中”或“抢购成功”的提示,并将订单信息异步发送到RocketMQ
    5. 订单服务作为消费者,从MQ拉取消息,进行数据库层面的订单创建库存真实扣减
    6. 后续的支付、履约流程由订单服务驱动。

数据模型:

在Mysql中会有一个promo_stock (秒杀库存表),promo_id (秒杀活动ID, 索引),item_id (商品ID, 索引),version (int, 乐观锁版本号)

Redis换成设计:

  • promo:stock:{promo_id} (String): 存储秒杀活动的总库存数量。用于快速判断库存是否售罄。

  • promo:soldout:{promo_id} (String/Bitmap): 一个售罄标记。一旦库存为0,设置此标记,后续请求可以直接在接入层拦截,无需再访问Redis。

  • promo:user:history:{promo_id} (Set/HyperLogLog): 存储已成功抢购的userId,用于防止用户重复下单

数据一致性:

在秒杀场景下,我们采用‘缓存预扣减,数据库异步更新’的策略,追求的是最终一致性

  1. 库存预热:秒杀活动开始前,通过定时任务将MySQL中的库存数量加载到Redis的promo:stock:{promo_id}中。
  2. 缓存预扣减:用户的抢购请求直接在Redis中通过DECR原子操作进行库存扣减。
  3. 异步更新数据库:Redis扣减成功后,将订单信息发送到MQ。订单服务消费消息后,再对MySQL中的stock_count进行UPDATE ... SET stock_count = stock_count - 1操作。
  4. 数据不一致的风险与兜底:
    • 风险:如果消息丢失或订单服务消费失败,会导致Redis库存减少,而MySQL库存未变。
    • 兜底:我们会有一个定时对账任务,定期(如每5分钟)比对Redis中的已售数量和MySQL中的已创建订单数量,如果不一致,则进行修复或告警。

限流和短融:

限流是保护系统的第一道防线,必须在多层级部署

  1. 前端层限流:通过JS控制,用户在点击秒杀按钮后,按钮会置灰一段时间,防止用户疯狂点击,造成不必要的请求。
  2. Nginx/网关层限流:
    • limit_req_zone:基于漏桶算法,对用户的IP或UID进行请求速率限制,例如,限制单个用户每秒只能请求1次。
    • limit_conn_zone:限制单个IP的最大连接数,防止恶意攻击。
  3. 业务服务层限流:
    • 使用SentinelGuava RateLimiter,对秒杀接口本身进行QPS限制。这个值应该根据压测结果设定,略高于系统的最大处理能力,作为最后的保险丝。
  4. 熔断:
    • 同样使用Sentinel,我们会对秒杀服务依赖的下游服务(如订单服务、用户服务)的调用进行熔断配置。
    • 策略:当在指定时间窗口内,对订单服务的调用错误率平均响应时间超过阈值时,熔断器会打开。在接下来的一个时间窗口内,所有对订单服务的调用都会被直接拒绝,并快速失败(返回“系统繁忙”),避免因下游故障导致的秒杀服务线程池耗尽和雪崩。

热点和超卖的数据处理:

这是秒杀系统的核心,我采用了‘Redis原子操作 + 分布式锁 + 数据库乐观锁’的三重保障来彻底杜绝超卖。

  1. 热点数据处理:
    • 库存预热:已在一致性策略中提及,将MySQL的热点库存数据提前加载到Redis中,所有读写操作都在Redis完成,避免直接冲击数据库。
  2. 防超卖机制(核心流程):
    • 第一重防护:Redis原子操作:
      • 在用户请求到达时,首先检查Redis中的售罄标记promo:soldout:{promo_id}。如果存在,直接返回“已售罄”。
      • 然后,使用DECR promo:stock:{promo_id}进行库存预扣减。这是一个原子操作,天然地避免了多线程下的并发问题。如果DECR后的返回值小于0,说明库存已不足,我们将库存INCR加回去,并返回“已售罄”。
    • 第二重防护:分布式锁(可选,用于更复杂逻辑):
      • 如果扣减库存的逻辑不仅仅是DECR,还包含了用户资格校验(如检查是否重复购买),那么“校验+扣减”这两个操作就不是原子的。
      • 此时,我们会使用Redisson分布式锁 + Lua脚本。将“检查用户是否在promo:user:history集合中”和“DECR库存”这两个逻辑封装在一个Lua脚本中,然后在获取到分布式锁后,原子化地执行这个脚本。
    • 第三重防护(最终兜底):数据库乐观锁:
      • 订单服务在消费MQ消息,准备真实扣减MySQL库存时,会使用乐观锁。
      • SQL语句为:UPDATE promo_stock SET stock_count = stock_count - 1, version = version + 1 WHERE promo_id = ? AND stock_count > 0 AND version = ?
      • 如果这条SQL执行后返回的影响行数为0,说明在并发情况下,库存已被其他事务修改(stock_count变为0或version不匹配)。此时,我们会认为这是一个无效的订单,进行记录并丢弃,不会创建订单。这确保了数据库层面的最终正确性。

异步队列+补偿处理:

  1. 异步队列(RocketMQ)的作用:
    • 流量削峰:秒杀的瞬时流量是巨大的,但后端数据库的处理能力是有限的。MQ像一个蓄水池,将瞬时的写请求缓冲起来,让下游的订单服务可以按照自己的节奏平稳地进行消费,保护了数据库。
    • 业务解耦:秒杀服务只负责最核心的库存预扣减,成功后即可返回。创建订单、发送通知等非核心、耗时的操作被解耦到下游服务,大大降低了秒杀接口的响应时间。
  2. 补偿机制:
    • 消息可靠性:我们会使用RocketMQ的事务消息生产者发送确认+重试机制,确保库存预扣减成功的消息一定能被发送到MQ。
    • 消费失败处理:如果订单服务消费消息失败(例如,数据库暂时不可用),我们会让消息进入重试队列
    • 死信队列(DLQ):如果经过多次重试后仍然失败,消息会被投递到死信队列。我们会有一个专门的后台任务告警系统来监控死信队列,一旦有消息进入,就立即通知开发人员进行人工介入和补偿

压测:

  1. 工具:使用JMeternGrinder等分布式压测工具。
  2. 压测目标:模拟秒杀开始瞬间,在极短时间内(如1秒内)发起远超系统处理能力的并发请求(例如,模拟10万用户同时抢购1000件商品)。
  3. 监控指标:
    • 业务指标:下单成功率、最终创建的订单数是否与库存数严格相等(验证正确性)。
    • 性能指标:系统的QPS/TPS、接口的平均响应时间99%分位线
    • 资源指标:压测过程中,密切监控所有组件(Nginx, Redis, 秒杀服务, 数据库)的CPU、内存、网络、磁盘I/O等资源使用率。
  4. 瓶颈定位:通过观察各个环节的监控指标,找出最先达到瓶颈的组件,然后针对性地进行优化(例如,升级Redis集群、优化SQL、增加秒杀服务实例等),再进行下一轮压测,如此循环,直到系统达到预期的性能目标。

5.模板方法的回答

模板方法模式定义了一个操作中的算法骨架,而将一些可变的步骤延迟到子类中去实现。

在一个抽象的父类中,会有一个 final 的模板方法,它定义了整个流程的执行顺序。这个模板方法会调用一系列的抽象方法(由子类实现)和具体方法(父类实现)。

优点是复用了算法的公共部分,并将变化的部分进行隔离。比如,AbstractList 中的 addAll 方法就是一个模板方法,它定义了批量添加的流程,而具体的 add(index, element) 则由子类 ArrayListLinkedList 去实现。

6.30分钟自动关闭

  1. 下单时: 用户下单成功后,除了创建订单,我们还会向 RocketMQ 发送一条延时等级为 30 分钟的延时消息,消息内容包含订单号。
  2. 消费者: 我们有一个专门的消费者来消费这些延时消息。
  3. 30分钟后: Broker 会将这条消息投递给消费者。
  4. 处理逻辑:消费者收到消息后,会根据订单号去查询数据库中该订单的支付状态。
    • 如果订单状态仍是“未支付”,则执行关单操作
    • 如果订单状态已经是“已支付”,则直接忽略这条消息。

7.如何设计全局统一异常处理

全局统一异常处理是Spring Boot项目中用于解耦业务代码和异常处理逻辑、并向前端提供统一响应格式的重要机制。它的实现主要依赖两个核心注解:

  1. @RestControllerAdvice: 我会创建一个类,并使用这个注解。它是一个组合注解,相当于@ControllerAdvice + @ResponseBody,表示这个类是一个全局的AOP切面,用于增强所有被@RestController注解的控制器,并会将方法的返回值序列化为JSON。

  2. @ExceptionHandler: 在这个类里面,我会定义多个方法,每个方法使用

    1
    @ExceptionHandler

    注解并指定它能处理的异常类型。例如:

    • 一个方法处理自定义的业务异常,如@ExceptionHandler(BusinessException.class)
    • 一个方法处理参数校验异常,如@ExceptionHandler(MethodArgumentNotValidException.class)
    • 一个兜底的方法处理所有其他未被捕获的异常,如@ExceptionHandler(Exception.class)

在这些方法内部,我会构建一个统一的响应对象(例如ApiResult),包含状态码、错误信息等,然后通过ResponseEntity包装后返回。这样做的好处是,业务代码中只需要专注于业务逻辑,当发生错误时直接throw new BusinessException(...)即可,异常的捕获和格式化响应都由这个全局处理器统一完成,代码非常清晰和易于维护。

8.如何设计一个订单表

主表:

字段名 (Column) 数据类型 (Type) 约束/备注 (Constraint/Note) 设计目的
id BIGINT UNSIGNED PRIMARY KEY, AUTO_INCREMENT 唯一标识:作为主键,保证每条订单的唯一性。使用BIGINT以应对海量订单。
order_no VARCHAR(64) UNIQUE, NOT NULL 业务订单号:给用户和客服看的订单号,通常包含日期、随机数等信息,具有业务含义,且必须唯一。
user_id BIGINT UNSIGNED NOT NULL, INDEX 关联用户:外键关联到用户表,标识下单用户。加索引以加速按用户查询订单。
total_amount DECIMAL(10, 2) NOT NULL 订单总金额:商品总价。使用DECIMAL避免浮点数精度问题。
discount_amount DECIMAL(10, 2) DEFAULT 0.00 优惠金额:记录使用的优惠券、活动折扣等总金额。
pay_amount DECIMAL(10, 2) NOT NULL 实际支付金额total_amount - discount_amount,冗余存储以方便查询和对账。
payment_method TINYINT NOT NULL 支付方式:例如 1-微信支付, 2-支付宝, 3-银行卡。使用数字代码节省空间,并便于扩展。
order_status TINYINT NOT NULL, INDEX 订单状态:核心字段。例如 10-待支付, 20-已支付, 30-已发货, 40-已完成, 50-已取消, 60-退款中。状态流转是订单系统的关键,加索引以加速按状态查询。
receiver_name VARCHAR(50) NOT NULL 收货人姓名:收货信息。
receiver_phone VARCHAR(20) NOT NULL 收货人电话
receiver_address VARCHAR(255) NOT NULL 收货人地址:省市区详细地址。
remark VARCHAR(255) 用户备注:用户下单时填写的备注信息。
create_time DATETIME NOT NULL 下单时间:记录订单创建时间,用于统计和超时未支付等场景。
payment_time DATETIME 支付时间:用户支付成功的时间。
shipping_time DATETIME 发货时间
finish_time DATETIME 完成时间:用户确认收货或系统自动确认的时间。
is_deleted TINYINT(1) NOT NULL, DEFAULT 0 逻辑删除:用于软删除,保护数据。0-未删除, 1-已删除。
  • 地址信息冗余:为什么不直接关联用户地址表?因为用户的默认地址是会变的。订单生成时必须“快照”当前的收货地址,保证交易契约的有效性,即使之后用户修改了地址,也不影响这笔订单。
  • 金额字段冗余pay_amount可以通过计算得出,但冗余存储可以简化查询,避免每次都做计算,尤其是在报表统计时性能更好。

关联表:

一个订单通常包含多个商品,所以需要将订单与商品的关系拆分出来。

字段名 (Column) 数据类型 (Type) 约束/备注 (Constraint/Note) 设计目的
id BIGINT UNSIGNED PRIMARY KEY, AUTO_INCREMENT 唯一标识。
order_no VARCHAR(64) NOT NULL, INDEX 关联订单:关联到订单主表的order_no,加索引以加速查询。
product_id BIGINT UNSIGNED NOT NULL 关联商品:关联到商品表的ID。
product_name VARCHAR(100) NOT NULL 商品名称快照:冗余存储商品名称,防止商品信息变更影响历史订单。
product_image VARCHAR(255) 商品图片快照:同上。
product_price DECIMAL(10, 2) NOT NULL 下单时单价快照:记录下单时的商品价格,防止价格变动。
quantity INT NOT NULL 购买数量
total_price DECIMAL(10, 2) NOT NULL 商品总价product_price * quantity,冗余存储方便计算。
  • 商品信息快照:这是order_item表设计的核心思想。商品的名称、价格、图片等信息都可能在后台被运营人员修改。为了保证订单的历史记录是准确的,必须在下单的瞬间将这些信息冗余存储到订单商品表中,形成交易快照。

可扩展点:

  • 订单支付流水表 (order_payment): 如果支持分期支付或多种支付方式组合支付,需要一个单独的表来记录每一笔支付流水,关联到订单号。
  • 订单物流表 (order_shipping): 记录订单的物流信息,包括物流公司、运单号、物流状态等。一个订单可能分多个包裹发出,所以order_shippingorders可以是一对多的关系。
  • 订单状态流转日志表 (order_status_log): 记录每一次订单状态的变更(谁、在什么时间、从什么状态变成了什么状态),用于问题排查和数据分析。

性能优化:

  • 索引:在user_id, order_no, order_status等频繁用于查询条件的字段上建立索引。
  • 分库分表:当订单量达到千万甚至亿级别时,单表性能会急剧下降。需要考虑垂直拆分(将订单信息、物流信息等拆到不同库)和水平拆分(按user_id或时间进行分片)来分散压力。
  • 冷热数据分离:对于已完成或已取消超过一定时间(如一年)的历史订单,可以将其归档到历史订单表中,保持主表的“瘦身”,提升查询性能。

9.一大批量用户或者订单变多,如何保证服务器不爆炸

要保证服务器在瞬时大批量请求下不“爆炸”,绝不能依赖单一的技术,而必须构建一个多层次、纵深化的防御体系

我会从“流量进来前”、“流量进来时”和“流量进来后”这三个阶段,来系统性地阐述我的设计思路。

进来之前:

这个阶段的核心是预测和分流,在流量到达我们的核心业务服务器之前,就对其进行过滤和疏导。

  1. CDN (内容分发网络)

    • 作用: 将网站的静态资源(如图片、CSS、JS文件)分发到离用户最近的边缘节点。
    • 效果: 大部分静态资源请求由CDN直接处理,不会到达我们的源站服务器。这能过滤掉至少70%以上的流量,是保护服务器的第一道、也是最有效的防线。
  2. 浏览器端/客户端优化

    • 前端限流: 在秒杀、抢购等场景,可以在前端按钮上设置一个短暂的“冷却时间”(disable状态),防止用户因手抖或焦虑而在一秒内发起多次无效请求,从源头上减少请求量。

    • 验证码:增加人机验证,有效拦截恶意脚本和机器人发起的瞬时批量请求。

流量进来:

接入层防御 (Nginx/API Gateway),

  1. 负载均衡 (Load Balancing): 这是必须的。使用Nginx、F5等设备,将流量均匀地分发到后端的多个无状态应用服务器上,避免单点过载。
  2. 接入层限流: 这是保护后端的第一道硬性关卡。我们可以使用Nginx的limit_req_module模块,基于IP或用户ID等维度,设置一个请求速率阈值(如单个IP每秒最多5次请求)。超过阈值的请求,可以直接返回503 Service Unavailable错误,或者放入漏桶/令牌桶中平滑处理。这能有效拦截恶意的DDoS攻击或接口滥用。

应用层优化,

  • 动静分离: 将动态业务逻辑(需要查询数据库、计算)和静态数据(如商品详情页)彻底分开。静态数据可以提前预热到CDN或分布式缓存(如Redis)中,应用服务器只需提供动态接口。

  • 缓存大法 (Cache is King):

    这是应对读请求洪峰的“银弹”。

    • 多级缓存: 构建“CDN缓存 -> Nginx本地缓存 -> 分布式缓存(Redis) -> 数据库”的多级缓存体系。力求95%以上的读请求都能在缓存层命中并返回,最大限度地减少对数据库的访问。
    • 缓存预热: 对于可预见的活动,提前将热点数据(如秒杀商品信息)加载到Redis中,避免活动开始瞬间大量请求穿透缓存导致“缓存雪崩”。

异步削峰,将同步的写操作,变为异步的消息投递

  1. 应用服务器在接收到创建订单的请求后,不直接去操作数据库。
  2. 而是快速地进行一些基本校验,然后将这个请求封装成一个消息,丢到消息队列(如RocketMQ, Kafka)中。这个过程非常快,内存操作,可以轻松应对极高的并发。
  3. 应用服务器立刻向用户返回一个“排队中/处理中”的友好提示。
  4. 下游的订单处理服务(消费者),则根据自己的实际处理能力(特别是数据库的承受能力),按照自己的节奏,平滑地从MQ中拉取消息进行消费,并持久化到数据库。

流量进来后:

  1. 业务降级:
    • 目的: 牺牲非核心功能,保全核心功能。
    • 实现: 通过配置中心(如Nacos, Apollo)设置降级开关。当系统压力过大时,可以手动或自动关闭一些非核心服务。例如:
      • 关闭商品评论、推荐系统、用户积分计算等。
      • 只保留浏览商品、加入购物车、下单这三个最核心的交易链路。
  2. 熔断与限流:
    • 目的: 防止单个服务的故障引发整个系统的“雪崩效应”。
    • 实现: 使用Sentinel,Hystrix等服务治理框架。
      • 熔断 (Circuit Breaker): 当某个下游服务(如库存服务)的错误率或响应时间超过阈值时,熔断器会“跳闸”,在接下来的一段时间内,所有对该服务的调用都会直接失败并快速返回,而不是去调用那个已经出问题的服务,给它恢复的时间。
      • 应用级限流: 除了接入层的限流,在业务应用内部也可以做更精细化的限流。比如限制某个核心接口的总QPS不能超过2000,保护其依赖的数据库或其他资源。