本文不聊八股,只讲实战。带你把架构题从「背概念」变成「说人话」


一、背景:我被这个架构题面了三次

金三银四面了十几家公司,微服务架构这道题几乎每轮都被问到。

第一次被问的时候我是这样答的:

“我们项目用了 Spring Cloud Alibaba,拆了 7 个微服务,用 Nacos 做注册中心,Gateway 做网关,Feign 做服务间调用,大概就是这样。”

面试官听完面无表情,写了几个字,说"好的,先到这里"。我当时就知道挂了。

后来复盘发现——面试官根本不想听你背技术栈列表,他想听的是:你为什么要这么拆?拆完之后遇到了什么问题?你是用什么手段解决的?

第二次我换了个思路,拿项目里的真实场景讲:

“我们最开始其实也是单体项目,后来拆微服务不是因为跟风,是因为秒杀活动一上线整个系统就崩——订单、商品、用户全在一个进程里,秒杀流量一大,用户连登录都登不上。”

这下面试官来兴趣了,接着问我:拆完之后呢?服务之间怎么通信的?分布式事务怎么搞的?权限怎么统一的?

所以今天我就以 mall‑Pro 电商项目的真实架构为例,把微服务面试里最高频的几个架构题盘一遍,全是实战踩坑的血泪总结。


二、第一个灵魂拷问:你为什么拆微服务?

面试潜台词:你是真需要微服务,还是为了简历好看?

这个问题是微服务所有问题的起点。如果连"为什么拆"都说不清楚,后面就不用聊了。

我的真实回答

不拆微服务的痛点,我经历过三个阶段:

第一阶段——单体噩梦

刚上线那会儿只有一个 mall-admin,订单、商品、用户全在一个项目里。某天运营做了个秒杀活动,流量瞬间上来,数据库连接池被打满。结果是什么呢?用户连商品列表都刷不出来,登录接口也超时了,因为所有功能共用一个进程、一个数据库。

这还不是最离谱的。有一次修了一个订单的 Bug,部署之后把商品模块也带挂了——因为部署是整包部署,改一行代码要把整个项目重启。

第二阶段——硬拆微服务,踩了新坑

所以我们决定拆。但没有经验,拆完发现问题比原来还多:

  • 服务之间怎么调用?原来直接 new Service(),拆完要发 HTTP 请求
  • 用户登录状态怎么共享?原来 Session 存内存,拆完每台机器 Session 不一样
  • 一个请求跨了 3 个服务,其中一个挂了怎么办?

这个时候我才意识到:微服务解决了一个问题,但引入了更多问题。 没有准备好的话,拆了反而更糟。

第三阶段——找到自己的方案

最终 mall‑Pro 的架构是这样的:

用户请求 → Gateway(8081) → 路由转发
  ├── mall-user(用户端入口,门面模式,调后面业务服务)
  ├── mall-admin(后台管理)
  │
  ├── mall-order(订单+物流+RocketMQ消费)
  ├── mall-product(商品+SKU+Canal同步ES)
  ├── mall-seckill(秒杀+优惠券+满减)
  ├── mall-search(ES商品搜索)
  └── mall-ai(LangChain4j + Ollama智能客服)

拆的原则不是"按功能拆",而是按业务边界拆:

  • 订单和商品拆开——订单的数据库压力不会影响商品查询
  • 秒杀独立成一个服务——秒杀挂了不影响正常下单和浏览
  • 用户端和管理端分离——C 端高并发和 B 端管理互不干扰

三、微服务之间的「电话线」——Feign 到底该怎么用?

面试高频题:你们服务之间怎么通信的?遇到过什么问题?

大白话解释

Feign 有点像给你每个同事配了一部电话。你想调订单服务的数据,你不用自己拨号、自己说话(写 HTTP 请求),你只需要说"我要调订单服务的某某方法",Feign 自动帮你把电话接通。

代码长什么样

@FeignClient(name = "mall-order", contextId = "orderClient")
public interface OrderFeignClient {
    
    @GetMapping("/order/detail/{id}")
    CommonResult<?> orderDetail(@PathVariable Long id);

    @PostMapping("/order/export")
    void orderExport(@RequestBody Object dto);
}

然后你在 Service 里直接注入调就行,看起来像调用本地方法,实际上是发 HTTP 请求:

@Service
public class OrderToolService {
    private final OrderFeignClient orderFeignClient;

    public String getOrderInfo(String orderNo) {
        AiOrderProductDto dto = orderFeignClient.getByOrderNo(orderNo);
        // 拼接订单信息返回
    }
}

踩坑 1:contextId 冲突

我们一开始只写了 name = "mall-order",结果项目启动报 BeanDefinitionStoreException。

排查发现——一个服务里有多个 FeignClient 指向同一个目标服务(比如订单服务既被用户端调,又被后台调)。Spring 发现多个 Bean 名字一样,直接报错。

解决方案:加 contextId 区分:

@FeignClient(name = "mall-order", contextId = "orderClient")
@FeignClient(name = "mall-order", contextId = "orderAdminClient")

踩坑 2:Feign 超时

有段时间后台管理页面加载特别慢,查了发现是 Feign 调用商品服务超时了,默认 1 秒超时,商品服务因为一条慢查询卡住了。

配置调整(现在写在配置中心,不用改代码):

spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connectTimeout: 5000    # 连接超时 5 秒
            readTimeout: 10000      # 读取超时 10 秒

面试官可能会追问:Feign 和 Dubbo 有什么区别?

这个问题不用答得太复杂,就说三点:

Feign + LoadBalancerDubbo
通信协议HTTP(文本协议,有序列化开销)TCP(二进制协议,性能好)
负载均衡客户端自己做,默认轮询支持更多策略(加权、一致性 Hash)
适用场景Spring Cloud 生态,小团队上手快性能要求高,已有 Dubbo 基础设施

我们选 Feign 的原因很简单:项目全是 Spring Boot,加一个 Feign 依赖就行,不需要额外部署。 Dubbo 还要搭注册中心、管理控制台,对我们当时的体量来说太重了。


四、网关是个「万能入口」——但配置错了能让你排查一天

路由转发

@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
    return builder.routes()
        // 搜索服务
        .route("mall-product-route", r -> r
            .path("/api/product/**", "/api/promotion/**")
            .filters(f -> f.stripPrefix(1))
            .uri("lb://mall-product"))
        // 订单服务
        .route("mall-order-route", r -> r
            .path("/api/order/**", "/api/cart/**")
            .filters(f -> f.stripPrefix(1))
            .uri("lb://mall-order"))
        // 秒杀服务
        .route("mall-seckill-route", r -> r
            .path("/api/seckill/**", "/api/skill/**")
            .filters(f -> f.stripPrefix(1))
            .uri("lb://mall-seckill"))
        .build();
}

注意两个重点:

1. stripPrefix(1) 是啥?

请求进来是 /api/order/detail/1,去掉 /api 这一段,转发给订单服务的是 /order/detail/1。如果不 strip,订单服务收到的请求路径是 /api/order/detail/1,而 Controller 上写的是 @GetMapping("/order/detail/{id}"),路径不匹配,返回 404。

2. lb:// 是啥?

Load Balancer 的缩写,意思是"从注册中心找到所有叫 mall-product 的服务实例,用负载均衡算法选一个转发"。如果 product 部署了 3 台,请求自动分散到不同机器。

踩坑:跨域问题

第一次把前端 Vue 项目对接 Gateway 的时候,浏览器报跨域错误。排查了半天发现——Gateway 基于 WebFlux,不是传统 Servlet,所以不能按 Spring MVC 的方式配置 CORS。

解决方案:全局 CORS 过滤器

@Component
public class CorsOptionsFilter implements GlobalFilter, Ordered {
    
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        
        // 处理 OPTIONS 预检请求
        if (request.getMethod() == HttpMethod.OPTIONS) {
            ServerHttpResponse response = exchange.getResponse();
            HttpHeaders headers = response.getHeaders();
            headers.add("Access-Control-Allow-Origin", "*");
            headers.add("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
            headers.add("Access-Control-Allow-Headers", "*");
            headers.add("Access-Control-Allow-Credentials", "true");
            headers.add("Access-Control-Max-Age", "3600");
            response.setStatusCode(HttpStatus.OK);
            return Mono.empty();
        }
        return chain.filter(exchange);
    }
}

为什么浏览器要先发 OPTIONS 请求? 这是浏览器的安全机制——前后端不同域名时(Vue 在 localhost:8080,后端在 localhost:8081),浏览器先发一个 OPTIONS 请求"试探"服务器是否允许跨域,收到允许的响应后才发真实的请求。


五、分布式事务——面试必问,但大部分人只背了理论

面试潜台词:你知道 CAP、BASE 吗?你是怎么落地的?

我们遇到的真实场景

秒杀支付的流程涉及 3 个数据源:

Redis(扣预存库存)
  → RocketMQ(发消息)
    → MySQL(扣真实库存 + 创建订单 + 使用优惠券)

如果用传统数据库的 @Transactional,搞不定——因为涉及 Redis 和 RocketMQ,它们不支持 JDBC 事务。

我们的方案:最终一致性 + 补偿回滚

核心思路:别想着强一致,那在分布式场景下不现实。设计好补偿机制,保证最终一致就行。

具体实现(这段代码是我们 RocketMQ 消费端的核心逻辑):

@Override
public void onMessage(String message) {
    PayDTO payDTO = JSON.parseObject(message, PayDTO.class);
    PayDTO.ProductItemDTO product = payDTO.getProducts().get(0);
    String orderNo = product.getOrderNo();

    // ======== 第1步:幂等检查 ========
    String retryKey = "seckill:retry:flag::" + orderNo;
    if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(retryKey))) {
        log.info("重复消息,跳过:{}", orderNo);
        return;
    }

    // ======== 第2步:分布式锁 ========
    RLock lock = redissonClient.getLock("lock:seckill:" + userId + ":" + skuId);
    boolean lockSuccess = lock.tryLock(3, 10, TimeUnit.SECONDS);
    if (!lockSuccess) return;

    try {
        // ======== 第3步:DB 原子扣库存 ========
        // 注意:stock >= #{buyNum} 是条件,MySQL 自身保证原子性
        int rows = seckillStockExtMapper.decrStock(skuId, buyNum);
        if (rows <= 0) { /* 库存不足,return */ }

        // 创建订单 + 使用优惠券(在同一事务中)
        orderService.save(order);
        myCouponExtMapper.useCoupon(couponId);
        success = true;
    } catch (Throwable e) {
        // 兜底1:报错了 → 回滚 Redis
        compensateRedis(stockKey, userKey, buyNum);
    } finally {
        // 兜底2:没报错但逻辑上也没成功 → 也回滚
        if (!success) {
            compensateRedis(stockKey, userKey, buyNum);
        }
    }
}

// Redis 回滚
private void compensateRedis(String stockKey, String userKey, int buyNum) {
    stringRedisTemplate.opsForValue().increment(stockKey, buyNum);
    stringRedisTemplate.delete(userKey);
}

为什么 catch 和 finally 都要回滚?

这个问题面试官特别喜欢追问。

catch 捕获的是 try 块里的异常——数据库连接断了、SQL 报错了、网络超时了。

但有一种情况 catch 抓不到:业务代码没有抛异常,只是逻辑上没成功(比如 rows <= 0 直接 return,没跑到末尾的 success = true)。所以 finally 再兜一次底,确保不管什么路径,只要没成功就回滚。

面试官追问:如果回滚 Redis 的时候 Redis 也挂了怎么办?

承认这是个边界情况,然后说我们的对策:

  1. 监控报警:Redis 挂了有告警,运维第一时间介入
  2. 人工入口:运营后台预留了"手动同步库存"的按钮
  3. 改进方向:后续可以引入 RocketMQ 事务消息,把"扣 Redis"和"发 MQ"放在一个事务里

面试官要的不是"100% 完美的方案"——分布式系统里不存在。他要的是你有没有考虑到这些边界情况、有没有 Backup Plan。


六、权限架构——Spring Security + JWT 实战

JWT 比 Session 好在哪?

一句话:Session 存在服务器内存,多台机器就得做 Session 共享;JWT 存在客户端,服务器不存任何状态,天然支持分布式。

这就是为什么我们的 SecurityConfig 里要写:

.sessionManagement(session -> 
    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

告诉 Spring Security:别给我创建 Session,所有认证信息从 Token 里取。

踩坑:Token 密钥

一开始我们的密钥是这样写的:

private static final String SECRET_KEY = "mySecret123"; // ❌ 太短了!

结果新版 JJWT 直接报错——HS512 算法要求密钥至少 512 位(64 字节),“mySecret123” 才 11 个字节。

后来改成这样:

// ✅ Keys.secretKeyFor() 自动生成符合 HS512 长度要求的密钥
private static final SecretKey SECRET_KEY = Keys.secretKeyFor(SignatureAlgorithm.HS512);

但新的问题是:每次重启项目,密钥都重新生成,之前签发的 Token 全部失效。

生产上正确的做法是:把密钥写到配置文件里固定下来,重启也不变。

完整的认证流程(面试直接背这个)

① 用户输入账号密码登录
② AdminUserDetailsService 查数据库,获取用户 + 权限列表
③ 权限转成 SimpleGrantedAuthority,返回 LoginUser
④ 登录成功 → JwtTokenUtil 生成 Token(存用户名 + 过期时间)
⑤ 前端存 Token 到本地
⑥ 后续请求 Header: Authorization: Bearer xxxxx
⑦ JwtUserTokenFilter 拦截请求,解析 Token
⑧ 从 Token 拿用户名 → 查数据库拿最新权限
⑨ 设置到 SecurityContextHolder
⑩ Spring Security 的 @PreAuthorize 判断是否有权限

七、面试避坑:这些「架构答法」面试官一听就想挂

错误答法问题在哪正确答法
“我们用微服务,拆了 7 个模块”只说了做了什么,没说为什么做“拆是因为秒杀活动打崩过整个系统,拆完秒杀挂了不影响正常下单”
“用 Feign 做远程调用”只说技术不说场景“用 Feign 调订单服务,遇到过 contextId 冲突,加了个参数解决的”
“用 Redis 做缓存”太模糊“秒杀库存用 Redis 预扣,Redis 单机 QPS 10 万+,扛住秒杀流量”
“分布式事务我们用最终一致性”只背了概念“Redis 扣库存后发 MQ,消费失败时 catch+finally 双重回滚 Redis”
“JWT 做认证”太简单“JWT 无状态天然支持多实例,不用做 Session 共享”

八、总结:一套通用的「微服务架构」面试话术

如果你现在被问到"你们项目架构是怎么设计的",可以按这个框架组织语言:

  1. 为什么拆 — 真实痛点驱动(秒杀打崩、单体耦合)
  2. 怎么拆的 — 按业务边界拆(订单/商品/秒杀独立)
  3. 拆完的挑战 — 服务通信(Feign)、数据一致(分布式事务+补偿)、认证(JWT+Spring Security)
  4. 未来改进 — 链路追踪(SkyWalking)、配置中心(Nacos Config)、容器化(K8s)

最后贴一下 mall‑Pro 的整体架构图,面试的时候自己画一遍,比背十篇八股文都管用:

                  ┌─────────────────────┐
                  │  Nacos 注册中心(8848) │
                  └──────────┬───────────┘
                             │ 服务注册/发现
                  ┌──────────▼───────────┐
                  │ Spring Cloud Gateway  │
                  │    mall-gateway(8081) │
                  └──────────┬───────────┘
                             │ 路由转发
        ┌────────────────────┴────────────────────┐
        ▼                                          ▼
┌───────────────────┐                    ┌───────────────────┐
│    mall-admin     │                    │     mall-user     │
│    (8086)         │                    │     (8205)        │
└──────────┬────────┘                    └──────────┬────────┘
           │ OpenFeign + LoadBalancer               │
┌──────────┬──────────┬──────────┬──────────┬──────────┐
▼          ▼          ▼          ▼          ▼          ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
│ 商品    │ │ 订单    │ │ 秒杀    │ │ 搜索    │ │ AI     │
│ Redis   │ │ Redis  │ │ Redis  │ │ ES     │ │Ollama  │
│ Canal   │ │RocketMQ│ │ Lua    │ │        │ │RAG     │
└────────┘ └────────┘ └────────┘ └────────┘ └────────┘

写在最后的避坑技巧

  1. 微服务不是你拆了就行,拆完后的运维成本才是大头——日志收集、链路追踪、监控告警,这些在上微服务之前就要想好
  2. 分布式事务别追求"完美"——强一致在分布式场景下又慢又贵,最终一致性 + 补偿机制才是电商系统该走的路
  3. 面试时多说"为什么"——你说"用了 Feign"面试官不 care,你说"因为项目全是 Spring Boot,加一个依赖就行,不需要额外部署",面试官会觉得你是真做过选型的人
  4. 架构没有银弹——单体不是原罪,微服务也不是万能药。关键是你有没有想清楚你的业务场景需要什么

如果这篇文章对你有帮助,欢迎点赞收藏。下一篇打算写 mall‑Pro 的秒杀系统高并发设计硬核拆解,从 Redis 预扣到 MQ 削峰到分布式锁,把超卖问题彻底盘清楚。

更多推荐