18 架构设计入门

前置知识:全栈开发技巧。本章是工程实战篇的收官:不讲玄学,讲什么规模用什么架构、每个概念解决什么痛。核心立场先亮明——中小团队单体足够,反对过度设计。


一、单体演进史

1.1 四个时代的演进与适用规模

flowchart LR
    A["单体应用<br/>一个 war 包打天下<br/>适用:<10 人团队"] -->|"业务膨胀<br/>发布互相拖累"| B["垂直拆分<br/>按业务线切成多个单体<br/>适用:10-50 人"]
    B -->|"功能重复建设<br/>需要能力共享"| C["SOA 服务化<br/>ESB 总线 + 服务复用<br/>适用:百人级"]
    C -->|"总线成瓶颈<br/>要求独立部署"| D["微服务<br/>细粒度自治<br/>适用:数百人以上"]

    style A fill:#e8f7e8
    style D fill:#e8f4fd
形态优点缺点典型代表
单体开发快、部署简单、事务天然一致代码耦合,一处慢全体慢早期电商、内部系统
垂直拆分业务线独立发布,故障隔离公共逻辑复制 N 份按订单/商品/用户切开的早期淘宝
SOA服务复用、统一治理ESB 中心化重、笨拙传统金融电信
微服务独立部署扩缩容、技术异构分布式复杂度全面爆发现代互联网大厂

演进的动力始终是同一个:规模变了,协作方式必须变。反过来也成立——规模没到就上微服务,是给自己制造分布式复杂度。


二、经典三层与贫血模型争议

2.1 三层职责边界

flowchart TD
    C["表现层 Controller<br/>协议转换/参数校验/组装响应"] --> S["业务层 Service<br/>业务规则/事务边界/编排"]
    S --> D["持久层 Repository/DAO<br/>数据存取,不含业务判断"]

    style C fill:#e8f4fd
    style S fill:#fff4e5
    style D fill:#e8f7e8

边界判据一句话:Controller 不写 if 业务判断;Service 不拼 SQL 细节;DAO 只对表负责。实际项目最常见的越界是把业务规则散落在 Controller 里——换个入口(定时任务/MQ)就要复制一份。

2.2 贫血模型之争

// 贫血模型:对象只有 getter/setter,逻辑全在 Service
public class Order {
    private Long id;
    private BigDecimal amount;
    private String status;
    // 全是字段和存取器,没有任何行为
}
 
@Service
public class OrderService {
    public void pay(Order order) {
        if (!"UNPAID".equals(order.getStatus())) {          // 规则在 Service 里
            throw new BusinessException(ErrorCode.STATE_ERROR);
        }
        order.setStatus("PAID");
    }
}

争议双方:

阵营理由
贫血派(主流实践)简单直接,配合 Spring 的依赖注入习惯顺手;CRUD 业务里领域对象本来就没几个行为
充血派(DDD 主张)对象封装自己的不变量(如”已支付的订单不能再改金额”),规则不会散落多处;Service 膨胀失控时是解药

务实的结论:简单 CRUD 用贫血无妨;当某个业务对象的规则多到 Service 里到处都是 if status == 时,把规则收进实体方法——这就是向充血模型迈的第一步。


三、六边形与整洁架构:依赖指向内

3.1 核心思想

六边形架构(端口与适配器)和整洁架构说的是同一件事:领域逻辑在圆心,数据库、Web 框架、消息队列都是可替换的外围插件,所有依赖箭头指向圆心

flowchart TD
    subgraph outer["外层:技术细节(可替换)"]
        W["Web 适配器<br/>Controller"]
        DB["持久适配器<br/>JPA/Mongo"]
        MQ["消息适配器<br/>Kafka"]
    end
    subgraph inner["内层:领域核心(稳定)"]
        A["应用层<br/>用例编排"]
        E["领域层<br/>实体/领域服务"]
    end
    W --> A
    DB --> A
    MQ --> A
    A --> E

    style E fill:#e8f7e8
    style outer fill:#fde8e8

注意箭头方向:不是领域层调用数据库接口,而是持久适配器实现领域层定义的仓储接口。这样换数据库不动业务代码,测试时用内存实现替换真实库。

3.2 与三层的对照

三层回答”怎么分文件”,六边形回答”依赖往哪指”。小项目不必强上六边形,但记住一条纪律:领域代码里不要 import 任何框架注解和 SQL 方言——做到这条,架构就不会烂到哪里去。


四、DDD 入门

4.1 五个基本构件

构件一句话定义电商例子
实体 Entity有唯一标识、状态会变、生命周期连续订单 Order(id 变了就不是同一单)
值对象 Value Object无标识、不可变、靠属性值判等收货地址、金额(Money=数值+币种)
聚合根 Aggregate Root一致性边界的守门人,外部只能通过它改内部订单及其明细行
领域服务 Domain Service承载不属于任何单个实体的跨对象逻辑库存扣减策略
防腐层 Anti-Corruption Layer隔离外部系统模型,防止其概念污染本域把第三方物流的报文转成本地模型

4.2 电商下单:聚合边界怎么划

问题:订单包含多个订单项,下单要扣库存、减优惠券,边界怎么定?

flowchart TD
    subgraph agg["聚合:订单边界内(强一致)"]
        O["Order 订单(聚合根)"]
        OI["OrderItem 订单项<br/>价格快照随订单生成,永不单独存在"]
        O --- OI
    end
    S["Stock 库存<br/>高并发热点,划入订单会让所有人抢一把锁<br/>=> 划出去,用领域事件异步扣减"]
    CP["Coupon 优惠券<br/>用户资产,有独立生命周期 => 独立聚合"]

    O -.->|"只引用 productId"| S
    O -.->|"只引用 couponId"| CP

    style agg fill:#e8f7e8
    style S fill:#fde8e8
    style CP fill:#fff4e5

判定标准三条:

  1. 不变量必须在事务内保证的才放同一聚合(订单总额 = 明细之和);
  2. 聚合之间只引用 ID 不引用对象(OrderItem 存 productId 而非 Product 实体);
  3. 一个事务只改一个聚合,跨聚合的最终一致用领域事件补偿(下单成功后发事件异步扣库存)。

新手最常犯的错就是把整个 ER 图塞进一个大聚合,结果并发性能稀烂——聚合越小越好划,宁可事件补偿也别做大聚合。

4.3 防腐层示例

// 第三方物流返回的模型直接渗透进业务代码,就是被"腐蚀"
// 防腐层:翻译外来语言,让领域只认识自己的模型
@Component
public class LogisticsACL {
    public ShipmentInfo toShipment(LgApiResponse resp) {   // LgApi* 是外部模型
        return new ShipmentInfo(
                resp.getWayBillNo(),
                "DELIVERED".equals(resp.getState()) ? ShipmentState.FINISHED : ShipmentState.TRANSIT);
    }
}

价值:第三方改字段名、换供应商时,只有这一层需要动。


五、微服务拆分原则

5.1 限界上下文:拆分的刀口

DDD 的限界上下文(Bounded Context)指一个模型成立的有效范围。“商品”这个词在不同上下文含义完全不同:商品中心关心类目属性,交易上下文关心价格库存快照,物流上下文只关心重量体积。每个上下文内部模型自治,之间通过 API/事件通信——微服务的边界就该沿着限界上下文切,而不是沿着数据库表切。

实用检验法:如果两个服务经常需要同步调用对方才能完成一次操作、或总是同时修改,多半切错了地方。

5.2 康威定律

设计系统的组织,其产生的设计等同于组织内部的沟通结构。

四个团队挤在一个服务里,每次发版四个人抢锁;反之,服务边界照着团队边界画,各管各的仓库、流水线、值班——这是微服务能规模化协作的根本原因。所以拆服务前先问:组织上有几个人能独立负责这块? 没有对应的团队结构,拆出来的服务只会更乱。


六、微服务基础设施全景

flowchart LR
    U["客户端"] --> GW["Gateway 网关<br/>统一入口/路由/鉴权/限流"]
    GW --> A["服务 A"]
    GW --> B["服务 B"]
    A -.->|"OpenFeign 声明式调用"| B
    A & B & C0["服务 C"] --- N["Nacos 注册中心+配置中心<br/>服务发现与动态配置"]
    GW & A & B --- SN["Sentinel<br/>流控/熔断/降级"]
    A & B & C0 -.-> SW["SkyWalking 链路追踪<br/>一次请求跨服务的完整路径"]
    A & B --> M[(MySQL)]
    A & B --> R[(Redis)]

    style GW fill:#fff4e5
    style N fill:#e8f4fd
    style SN fill:#fde8e8
    style SW fill:#e8f7e8

各组件一句话定位:

组件定位
Nacos服务注册发现(谁在线)加配置中心(参数热更新),替代 Eureka+Config
Gateway全站统一入口:路由转发、JWT 校验、全局限流的门卫
OpenFeign把 HTTP 调用写成接口调用的声明式客户端,带负载均衡
Sentinel流量防卫兵:QPS 限流、下游异常熔断、兜底降级
SkyWalking分布式追踪:一条请求经过哪些服务、每段耗时多少,拓扑一目了然

学习顺序建议:先手写服务间 HTTP 调用体会痛点,再逐个引入组件——不知道 pain 在哪,工具就只是名词。


七、REST vs RPC

维度REST(HTTP+JSON)gRPC(HTTP/2+Protobuf)Dubbo(RPC 框架)
可读性文本,curl 可调试二进制,需工具二进制(hessian2 等)
性能一般高(序列化+多路复用)
契约松散,OpenAPI 辅助proto 文件强约束,代码生成接口强约束
流式支持双向流原生有但生态弱些
跨语言天然好(多语言生成)以 Java 为主
适用对外 API、前后端交互内部高性能微服务、异构栈Java 系内部治理

选型直觉:对外一律 REST(通用、可缓存、易联调);内部追求性能且团队可控再上 gRPC/Dubbo。中小项目 OpenFeign 调 REST 就够了。


八、分布式事务概念

单体的本地事务在服务拆分后失效:下单服务和库存服务各自有数据库,一个成功一个失败怎么办?

两条路线:

8.1 SEATA AT 模式(强一致的工程折中)

原理一句话:一阶段提交本地事务并记录 undo log,二阶段全局提交则删日志、回滚则用日志反向补偿。业务几乎无侵入,加个 @GlobalTransactional 注解即可:

@GlobalTransactional
public void placeOrder(OrderReq req) {
    orderService.create(req);       // 分支一:写订单
    stockService.deduct(req);       // 分支二:扣库存(SEATA 代理数据源自动参与)
}
// 任一分支失败,SEATA 协调各分支用 undo log 回滚

代价:全局锁带来的并发下降与中间件依赖。适合中低并发、强一致诉求的内部链路。

8.2 最终一致性(互联网主流)

接受短暂不一致,通过消息 + 重试 + 对账收敛:

flowchart LR
    A["下单成功"] --> B["发送订单已创建事件<br/>事务消息/本地消息表"]
    B --> C["库存服务消费事件扣库存"]
    C -->|"失败则重试"| C
    C --> D["兜底:定时对账任务<br/>比对两边数据修复差异"]

    style D fill:#fff4e5

选型经验:资金相关用 AT/TCC 保强一致;其余绝大多数场景最终一致性足够,且吞吐高得多。


九、幂等性设计

重复请求不可避免:用户双击、网络重试、MQ 至少一次投递。幂等 = 同一操作执行多次,结果与执行一次相同。三种经典手段:

// 手段一:Token 机制——先领令牌,提交时核销
@PostMapping("/token")
public String token() { String t = UUID.randomUUID().toString();
    redis.opsForValue().set("pay:tk:" + t, "1", 5, MINUTES); return t; }
 
@PostMapping("/pay")
public ApiResult<Void> pay(@RequestParam String token) {
    // 原子删除:删成功说明第一次执行,失败则是重复请求
    Boolean first = redis.delete("pay:tk:" + token);
    if (!Boolean.TRUE.equals(first)) return ApiResult.fail(ErrorCode.DUPLICATE);
    doPay(); return ApiResult.ok(null);
}
-- 手段二:唯一索引——数据库层面兜底,最可靠
INSERT INTO orders(order_no, user_id, amount)
VALUES ('20260823001', 9527, 99.00);
-- 同一 order_no 重复插入直接 DuplicateKeyException,捕获后按成功处理
// 手段三:状态机——UPDATE 带前置状态条件,天然防重放
int n = jdbc.update(
    "UPDATE orders SET status='PAID' WHERE id=? AND status='UNPAID'", orderId);
if (n == 0) { /* 已支付过,幂等返回 */ }

实战组合拳:前端防抖挡第一道,网关/Token 挡第二道,唯一索引或状态机做最终防线。支付回调场景(见第十五章)用的正是状态机方案。


十、限流三算法与降级隔离

10.1 三种限流算法

计数器(固定窗口):
  每分钟最多 100 个,超了拒绝
  缺点:窗口边界突刺——第 59 秒来 100 个、第 61 秒又来 100 个,
       两秒内实际 200 个,后端照样可能被打挂
 
漏桶:
  请求进队列,以恒定速率流出处理
  优点:出口绝对平滑;缺点:无法应对突发,排队有延迟
 
令牌桶(Guava RateLimiter/Sentinel 默认):
  匀速往桶里放令牌,请求拿到令牌才处理,桶有上限
  优点:允许攒下的令牌支持合理突发,兼顾平滑与弹性
       => 生产默认选它

Redis + Lua 可以做出集群级限流(多实例共享配额),单机版 Guava 的 RateLimiter.create(100) 一行就能用。

10.2 降级与隔离

手段做法例子
降级非核心功能在压力下主动关闭,返回兜底大促关闭商品推荐,返回静态榜单
线程池隔离每个下游调用独立线程池,一个下游堵死不拖垮全部推荐服务的池满了,订单服务主流程不受影响
信号量隔离轻量计数限制并发数,无线程切换开销内部快速调用

三者合起来就是 Sentinel 的日常:给核心接口配 QPS 规则,给远程调用配熔断规则,给非核心链路配降级开关(最好做成配置中心里的开关,运营都能拨)。


十一、CAP 与 BASE 通俗解释

CAP:分布式系统三个性质——一致性 C、可用性 A、分区容忍 P。网络分区(机器间断联)无法避免,所以 P 是必选项,真正的选择只在分区发生时:拒绝服务保一致(CP,如 ZooKeeper 选主)还是继续服务容忍读到旧数据(AP,如 Nacos/Eureka)。通俗比喻:银行转账宁可暂停也不能算错钱(CP);社交点赞晚几秒同步无所谓(AP)。

BASE 是 AP 路线的落地哲学:基本可用(Basically Available,降级也算可用)、软状态(Soft state,允许中间态如”支付处理中”)、最终一致(Eventually consistent,延迟收敛)。它解释了为什么互联网系统敢用消息队列替代分布式事务——只要最终对得上账,过程中的暂时不一致是可以接受的商业决策。


十二、什么时候不需要微服务

反直觉的事实:大多数团队的正确答案是”不拆”

信号判断
团队 < 20 人,日活 < 百万级单体加模块化足够
一个 war 包 10 分钟启动,但没人抱怨还没到痛点
为了简历好看想上微服务明确反对:你会花 70% 时间运维而非业务
已有 5 个以上服务却只有一个团队维护已经过度设计,考虑合并

微服务的真实成本清单:分布式事务、链路排障、环境搭建、数据一致性、运维复杂度、联调成本。这些成本只有在”多人协作互相阻塞、模块负载差异巨大需要独立扩缩容”成为主要矛盾时才值得付。

务实的演进路线:模块化单体 → 按限界上下文预留边界 → 有真实的组织与流量痛点再拆。拆的时候从单体里长出来的清晰模块边界,会让迁移顺滑得多——这也是本章前面强调分层与 DDD 边界的原因:好的单体是微服务的前身。


十三、架构师书单

按阅读顺序推荐,标注难度:

书籍定位难度
《架构整洁之道》Robert C. Martin依赖倒置与边界设计,本章第三节的理论源头入门
《微服务设计》Sam Newman拆分、契约、演进的系统性方法论入门
《领域驱动设计》Eric EvansDDD 原著,艰涩但值得啃进阶
《实现领域驱动设计》Vaughn Vernon更落地的 DDD 操作手册(红书)进阶
《数据密集型应用系统设计》(DDIA)存储/复制/一致性的一流教材,强烈推荐进阶
《凤凰架构》周志明中文免费在线,演进式架构与现代治理入门首选
《Site Reliability Engineering》GoogleSRE 运维体系,衔接第十六章应急话题进阶

读法建议:《凤凰架构》与《架构整洁之道》先行建立框架,DDIA 打底数据系统认知,DDD 两本结合本章第四节反复对照实践。


十四、实战:图书服务拆分 Mini 微服务

目标:把第十五章的 Todo 思路升级——图书(book-service)与借阅(borrow-service)两个服务,借阅时要调图书服务校验并扣减可借数量,用 OpenFeign 完成服务间调用。

14.1 项目结构与公共依赖

mini-ms/
├── pom.xml                    ← 父工程统一管版本
├── api-common/                ← 两服务共享的 DTO 与 Feign 接口
├── book-service/              ← 图书服务(端口 9001)
└── borrow-service/            ← 借阅服务(端口 9002)

父 pom 关键片段(Spring Boot 3.x + Cloud 2023.x):

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework.cloud</groupId>
      <artifactId>spring-cloud-dependencies</artifactId>
      <version>2023.0.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

两个服务都引入 spring-cloud-starter-openfeign;为了聚焦,本例不用注册中心,Feign 直接配 url 指向对方地址(生产中换成 Nacos 服务名即可)。

14.2 book-service:图书与扣减接口

@Entity
@Data
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private Integer available;   // 可借数量
}
 
@RestController
@RequestMapping("/api/v1/books")
@RequiredArgsConstructor
public class BookController {
 
    private final BookRepository repo;
 
    @GetMapping("/{id}")
    public ApiResult<BookDTO> detail(@PathVariable Long id) {
        return repo.findById(id)
                .map(b -> new BookDTO(b.getId(), b.getTitle(), b.getAvailable()))
                .map(ApiResult::ok)
                .orElseThrow(() -> new BusinessException(ErrorCode.BOOK_NOT_FOUND));
    }
 
    /** 扣减可借数量,带状态机式条件更新防超借 */
    @PostMapping("/{id}/deduct")
    public ApiResult<Void> deduct(@PathVariable Long id) {
        int n = repo.deductIfAvailable(id);   // UPDATE ... SET available=available-1 WHERE id=? AND available>0
        if (n == 0) throw new BusinessException(ErrorCode.BOOK_UNAVAILABLE);
        return ApiResult.ok(null);
    }
}
 
public interface BookRepository extends JpaRepository<Book, Long> {
    @Modifying
    @Query("update Book b set b.available = b.available - 1 where b.id = :id and b.available > 0")
    int deductIfAvailable(@Param("id") Long id);
}

14.3 borrow-service:Feign 声明式调用

api-common 中定义 Feign 接口,两个服务共用 DTO:

@FeignClient(name = "book-service", url = "${book.service.url:http://localhost:9001}")
public interface BookClient {
 
    @GetMapping("/api/v1/books/{id}")
    ApiResult<BookDTO> detail(@PathVariable("id") Long id);
 
    @PostMapping("/api/v1/books/{id}/deduct")
    ApiResult<Void> deduct(@PathVariable("id") Long id);
}

借阅服务的主流程:

@SpringBootApplication
@EnableFeignClients(basePackages = "com.demo.common")
public class BorrowApplication { public static void main(String[] args) {
    SpringApplication.run(BorrowApplication.class, args); } }
 
@Service
@RequiredArgsConstructor
@Slf4j
public class BorrowService {
 
    private final BookClient bookClient;
    private final BorrowRepository repo;
 
    @Transactional
    public Borrow borrow(Long bookId, Long userId) {
        // 1. 远程查询图书信息(演示用;生产应本地缓存减少调用)
        ApiResult<BookDTO> info = bookClient.detail(bookId);
        if (info.getCode() != 0) throw new BusinessException(ErrorCode.BOOK_NOT_FOUND);
 
        // 2. 远程扣减库存——失败抛异常,本地事务回滚,不会出现"借书记录有了书没扣"
        bookClient.deduct(bookId);
 
        // 3. 本地落借阅记录
        Borrow b = new Borrow();
        b.setBookId(bookId);
        b.setUserId(userId);
        b.setBorrowedAt(LocalDateTime.now());
        return repo.save(b);
    }
}

14.4 失败模拟与思考

把 book-service 停掉再发起借阅,Feign 抛连接异常,借阅接口 500——这正是第六节全景图里 Sentinel 要解决的问题。补上降级:

@FeignClient(name = "book-service", url = "${book.service.url}",
        fallback = BookClientFallback.class)
public interface BookClient { /* 同上 */ }
 
@Component
public class BookClientFallback implements BookClient {
    public ApiResult<BookDTO> detail(Long id) { return ApiResult.fail(ErrorCode.SYSTEM_ERROR); }
    public ApiResult<Void> deduct(Long id) { throw new BusinessException(ErrorCode.SYSTEM_ERROR); }
}

这个 mini 项目留了三个刻意的坑供练习:扣减成功但保存借阅记录失败时如何补偿(提示:本地消息表);如何引入 Nacos 把 url 换成服务名;如何用 Testcontainers 写集成测试验证两服务联动。做完这三件事,你对”为什么要那些中间件”的理解会超过背一百遍八股。


小结

  • 架构演进由规模驱动:单体、垂直拆分、SOA、微服务各有适用区间,规模不到别硬拆;
  • 三层管分工,六边形管依赖方向——领域核心不 import 框架,是一切架构的底线纪律;
  • DDD 五构件里聚合边界最关键:一个事务只改一个聚合,聚合间传 ID,跨聚合靠事件;
  • 康威定律提醒我们:先有团队结构再有服务边界,拆分本质是组织问题;
  • 幂等三板斧 Token、唯一索引、状态机,是分布式世界的必修防御工事;
  • 限流默认选令牌桶,配降级开关与线程池隔离,构成系统的呼吸阀;
  • CAP 里 P 是必选题,CP 还是 AP 取决于业务对错误数据的容忍度,BASE 是 AP 的落地哲学;
  • 中小团队请先把单体做干净——好的模块化单体,才是未来微服务最好的前身。