简介:一份聚焦企业服务总线(ESB)平台建设的解决方案文档,面向企业架构师、集成开发人员及信息化项目负责人,适用于异构系统互联、业务流程自动化与数据交换等场景。文档围绕 ESB 产品定位、产品概述、客户价值、关键特性及组成功能展开,涵盖协议转换、数据转换、服务编排、服务路由、服务安全、服务质量、服务注册、服务监控、消息机制等模块,并延伸至应用场景说明,可帮助读者梳理企业级集成平台的整体框架、能力边界与落地思路。压缩包内共 1 个 docx 文件,约 1.52MB,结构完整,便于直接查阅与二次整理。目前已有 310 人学习下载,可作为 ESB 选型、方案编写与集成架构设计的参考资料。对于需要搭建统一集成平台、评估管控与运营能力或补充服务治理知识的读者,这份文档能提供较系统的模块化视角与实践参考。
1. 系统集成的接口数量不是线性增长的
五套系统两两直连是十条链路,十套就是四十五条,每新上一套系统,要改动的不是自己,而是所有相关方。这种 N×(N-1)/2 的增长方式,是绝大多数企业做集成时最先撞上的墙。企业服务总线ESB平台要解决的就是这个收敛问题:把点对点的多对多连接拍平成「各系统只对接总线」的一对多关系,寻址、协议转换、报文映射、流量治理全部收到总线上做。
一份「企业服务总线ESB平台方案.docx」里真正要讲清楚的,不是中间件产品的功能清单,而是四件事:谁接入、用什么协议接入、消息按什么规则路由出去、链路断在哪一段怎么查。方案写得虚,落地阶段就会退化成一群人围着接口文档对字段名,联调一周还在纠结大小写。
这套东西适合正在做异构系统集成的架构师和后端工程师,也适合需要划清 ESB 与 OA 系统边界的产品同学。它不追求把老系统重写一遍,而是给老系统套一层统一入口。
2. ESB平台架构与核心组件:协议适配、消息路由、服务注册怎么选
2.1 三种集成拓扑,为什么最后多数落到 ESB
第一种是点对点直连,链路少的时候最省事,链路一多就没人能说清全貌。第二种是 API 网关,解决的是统一入口、鉴权、限流,但它默认两端协议一致,都是 HTTP/JSON。第三种才是企业服务总线,它在网关的基础上多了两件东西:协议形态的转换能力,以及服务虚拟化——调用方看到的地址和数据格式,跟后端真实实现解耦。
判断要不要上 ESB,看三个信号。一是有 SOAP、JMS、文件、数据库直连这类非 HTTP 的老系统必须被集成;二是同一个后端服务被多个调用方以不同报文格式要求对接;三是需要统一审计和链路追踪,而不是每个系统各写一套日志。三个信号里中两个,ESB 的收益就能覆盖它的运维成本。
反过来说,如果全公司都是统一的 REST 接口、没有异构协议、没有报文转换需求,硬上 ESB 只会多一层跳转和一处故障点。常见做法是先用网关撑住,等真出现协议异构再引入总线能力。
2.2 组件拆解:接入网关、路由引擎、协议转换、服务目录
接入层负责把外部流量收进来,屏蔽 HTTP、SOAP、MQ、文件四类入口的差异。路由引擎负责「这条消息该去哪」,匹配依据可以是路径、报文里的字段、来源系统或者租户标识。协议转换和报文映射是 ESB 区别于普通网关的核心,它把入口结构搬到出口结构上,字段名、层级、类型都可能变。服务目录是登记册,所有被集成的后端服务必须在这里注册后才能被路由引用。治理层管限流、熔断、审计、链路追踪。
路由和转换的规则建议全部外置成配置,别写死在代码里。下面这段配置描述了一条从 REST 入口到 SOAP 后端的完整路由:
# esb-route.yaml 一条路由的完整定义:入口、出口、映射、超时 route: id: crm.customer.query # 路由唯一标识,调用日志里靠它聚合 inbound: protocol: rest # 入口协议 path: /esb/crm/customer/{id} # 对外暴露的地址,带路径参数 method: GET outbound: protocol: soap # 出口协议,与入口不同才有转换价值 endpoint: http://crm-internal/ws/CustomerService operation: getCustomerById mapping: - source: $.path.id # 从入口报文的路径参数取值 target: custId # 写到出口报文的 custId 字段 type: string # 显式声明类型,避免数字被截断 timeout_ms: 3000 # 后端慢查询多,先给 3 秒 retry_times: 0 # 查询类接口不重试,写类接口另配字段里有几个坑值得提前说。timeout_ms要小于上游调用方的超时,否则会出现上游已放弃、总线还在等后端、线程被占满的雪崩;retry_times对写接口必须为 0,或者配合幂等键使用,不然一次超时重试就是两条单据;mapping里显式写type能省掉大量联调扯皮,尤其是金额和编号这类字段。
2.3 自研、开源与商业套件的选型边界
| 形态 | 适用规模 | 协议覆盖 | 落地成本 | 主要风险 |
|---|---|---|---|---|
| 自研轻量总线 | 10 个以内系统,3 类协议 | 需自己补 | 低,2 到 4 周出可用版本 | 治理能力薄弱,压测和追踪要自己写 |
| 开源集成框架 | 20 到 50 个系统 | 主流协议齐全 | 中,需专人维护集群 | 版本升级和社区组件兼容性 |
| 商业 ESB 套件 | 跨部门、跨地域集成 | 含老式适配器 | 高,含授权与实施 | 绑定供应商,二次开发受限 |
选型的判断点不在功能多少,而在「转换规则的维护成本」。业务方改一个字段映射,是改配置五分钟生效,还是要改代码、走发布流程,这个差异会直接决定总线在半年后是被用起来还是被绕过去。
3. 最小可跑的ESB服务代理:从接口注册到路由转发
3.1 服务目录与路由表的数据模型
把服务、路由、映射、调用日志拆成独立表,后面排查问题才有的查。服务表登记后端能力,路由表登记对外暴露方式,两者通过service_id关联,一个后端服务可以被多条路由复用。
-- 服务目录:登记被集成的后端系统能力 CREATE TABLE esb_service ( service_id VARCHAR(64) PRIMARY KEY, service_name VARCHAR(128) NOT NULL, protocol VARCHAR(16) NOT NULL, -- rest / soap / mq / file endpoint VARCHAR(512) NOT NULL, owner_dept VARCHAR(64), -- 责任部门,出问题知道找谁 status TINYINT DEFAULT 1, -- 1 启用 0 停用 created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 路由定义:入口地址到后端服务的映射关系 CREATE TABLE esb_route ( route_id VARCHAR(64) PRIMARY KEY, path_prefix VARCHAR(256) NOT NULL, method VARCHAR(8) DEFAULT 'POST', service_id VARCHAR(64) NOT NULL, timeout_ms INT DEFAULT 3000, retry_times TINYINT DEFAULT 0, qps_limit INT DEFAULT 200, status TINYINT DEFAULT 1, KEY idx_path (path_prefix), CONSTRAINT fk_route_svc FOREIGN KEY (service_id) REFERENCES esb_service(service_id) ); -- 调用日志:联调排错和压测复盘共用同一张表 CREATE TABLE esb_call_log ( trace_id VARCHAR(64) NOT NULL, route_id VARCHAR(64) NOT NULL, status_code INT, cost_ms INT, caller_sys VARCHAR(32), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_trace (trace_id), KEY idx_time (created_at) );idx_path是必须的,路由匹配走的是前缀比较,几十条路由无所谓,上千条时全表扫描会直接体现为接口 P99 抖动。owner_dept这个字段看起来像冗余信息,但半夜链路断了,能一眼看出找哪个部门,价值比想象中大。
3.2 路由匹配与转发的核心代码
下面这个代理只做三件事:按最长前缀匹配路由、按规则改写报文、转发并记录调用日志。代码短,但结构可以直接扩到生产。
# esb_proxy.py 最小路由代理:匹配路由 -> 报文映射 -> 转发 -> 落日志 import json, time, uuid import requests from flask import Flask, request, Response from jsonpath_ng import parse as jp_parse app = Flask(__name__) ROUTES = {} # route_id -> 路由定义,启动时从 esb_route 表加载 def match_route(path): # 最长前缀匹配,避免 /esb/crm 抢先命中 /esb/crm/customer hit = None for r in ROUTES.values(): if r["path_prefix"] and path.startswith(r["path_prefix"]): if hit is None or len(r["path_prefix"]) > len(hit["path_prefix"]): hit = r return hit @app.route("/esb/<path:sub>", methods=["GET", "POST", "PUT", "DELETE"]) def dispatch(sub): trace_id = request.headers.get("X-Trace-Id") or uuid.uuid4().hex start = time.time() route = match_route("/esb/" + sub) if route is None: return Response(json.dumps({"code": 404, "msg": "no route"}), status=404, mimetype="application/json") # 1) 组装入口报文上下文,路径参数、查询串、请求体都放进去 src = {"path": request.view_args or {}, "query": request.args.to_dict(), "body": request.get_json(silent=True) or {}} payload = apply_mapping(src, route.get("mapping", [])) # 2) 转发,trace_id 透传给后端,方便跨系统串日志 try: resp = requests.request(route["method"], route["endpoint"], json=payload, headers={"X-Trace-Id": trace_id}, timeout=route["timeout_ms"] / 1000) except requests.Timeout: log_call(trace_id, route["route_id"], 504, int((time.time() - start) * 1000)) return Response(json.dumps({"code": 504, "traceId": trace_id}), status=504, mimetype="application/json") # 3) 落调用日志,耗时和结果码是压测复盘的第一手数据 log_call(trace_id, route["route_id"], resp.status_code, int((time.time() - start) * 1000)) return Response(resp.content, status=resp.status_code, mimetype="application/json") def apply_mapping(src, rules): out = {} for r in rules: for m in jp_parse(r["source"]).find(src): key = r["target"].lstrip("$.") out[key] = str(m.value) if r.get("type") == "string" else m.value return outmatch_route里的最长前缀比较是关键,少了它,/esb/crm这类粗粒度路由会把所有子路径都吃掉。requests的timeout传的是秒,而配置表里习惯写毫秒,这里做了除以 1000 的转换,是实际对接中最容易搞错的一处。apply_mapping对type: string做强转,是因为很多老系统要求工号、订单号必须以字符串传输,一旦被后端按数字解析,前面的零就没了。
3.3 协议转换:把 JSON 组装成 SOAP 信封
面对 SOAP 后端,映射之后还要再包一层信封。用模板加占位符就够了,不必引入重型框架:
# soap_builder.py JSON 报文转 SOAP 信封 SOAP_TMPL = """<?xml version="1.0" encoding="utf-8"?> <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Body> <ns:{operation} xmlns:ns="http://crm.example.com/ws"> <custId>{custId}</custId> </ns:{operation}> </soap:Body> </soap:Envelope>""" def build(payload, operation): # 命名空间必须与后端 WSDL 完全一致,改一个字符就是 500 return SOAP_TMPL.format(operation=operation, custId=payload["custId"])命名空间不匹配是 SOAP 联调里最高频的失败原因,后端往往只回一个没有细节的 500,让人以为是参数问题。排查时先把信封原样丢给 SOAP UI 之类的工具单独调通,再放回总线。
3.4 本地起服务并验证
# 1) 建表并导入一条路由 mysql -uesb -p esb_db < schema.sql mysql -uesb -p esb_db -e "INSERT INTO esb_service VALUES('svc.crm','CRM客户中心','soap','http://crm-internal/ws/CustomerService','客户部',1,NOW());" mysql -uesb -p esb_db -e "INSERT INTO esb_route VALUES('crm.customer.query','/esb/crm/customer','GET','svc.crm',3000,0,200,1);" # 2) 启动代理 export ESB_DB_DSN='mysql://esb:pwd@127.0.0.1:3306/esb_db' python esb_proxy.py --port 8080 # 3) 验证:正常调用看结果,异常调用看 traceId 能否串起来 curl -s -H 'X-Trace-Id: t-1001' http://127.0.0.1:8080/esb/crm/customer/88231 curl -s -o /dev/null -w '%{http_code} %{time_total}\n' http://127.0.0.1:8080/esb/not-exist两条 curl 分别是通链路和断链路的验证,第二条必须回 404 而不是 500,否则说明路由匹配没生效、请求被兜底异常吞掉了。日志表里按trace_id查一次,就能看到完整的入口、出口、耗时三元组。
4. ESB与OA系统对接:组织同步、审批回调与单据回写
4.1 组织架构与人员主数据的同步方式
ESB 与 OA 对接,第一条链路几乎都是组织架构和人员。做法上分全量和增量:全量每天凌晨跑一次,用于纠偏;增量走事件通知,用于实时性。主键选择上,用 HR 系统的工号作为全局唯一标识,OA 侧自己的userid只作为映射关系存在总线侧的一张对照表里,不要让它在跨系统链路中传播。
-- 人员标识映射:源头工号与各系统 ID 的对照 CREATE TABLE esb_id_mapping ( biz_type VARCHAR(32) NOT NULL, -- EMP / ORG / DEPT source_id VARCHAR(64) NOT NULL, -- HR 侧工号 target_sys VARCHAR(32) NOT NULL, -- OA / ERP / CRM target_id VARCHAR(64) NOT NULL, PRIMARY KEY (biz_type, source_id, target_sys) );增量同步常见做法是订阅 HR 的人员变更消息,在总线侧转换成 OA 的组织接口格式再投递。转换时注意部门层级要按「先父后子」的顺序推送,顺序颠倒会让子部门挂到一个还不存在的父节点上,OA 侧直接报错。
4.2 审批流回调接入的三种模式
第一种是 OA 主动回调总线暴露的地址,适合 OA 支持配置回调 URL 的情况,实时性最好。第二种是总线定时轮询 OA 的待办接口,实现简单但有延迟,适合低频审批。第三种是消息中间件,OA 把审批结果投到队列,总线订阅消费,吞吐最高但对 OA 有改造要求。
回调模式要注意三点:回调地址必须校验签名或令牌,否则等于开放了一个任意触发接口;回调要在秒级返回,业务处理丢给异步任务;事件类型要有明确的枚举,别用文案匹配「同意」「已通过」这类中文字符串,OA 改一次界面文案链路就断了。
4.3 单据回写的幂等闸门
审批通过后回写业务库,是整条链路最容易出问题的地方。OA 的回调可能重投,总线可能重试,两边叠加就是不重复投递都难。共识做法是在总线侧加一张幂等表,用「来源系统 + 业务类型 + 业务单号」拼出唯一键。
# idempotent.py 单据回写的幂等闸门 import hashlib def biz_key(payload): # 唯一键只取业务语义字段,不要带上时间戳,否则每次重投都是新键 raw = f"{payload['source']}|{payload['bizType']}|{payload['bizNo']}" return hashlib.md5(raw.encode()).hexdigest() def handle(payload, db): key = biz_key(payload) if db.query("SELECT 1 FROM esb_idempotent WHERE biz_key=%s", key): return {"code": 200, "msg": "duplicated", "bizKey": key} # 重复投递直接返回成功 db.execute("INSERT INTO esb_idempotent(biz_key,status) " "VALUES(%s,'PROCESSING')", key) # 后续业务处理,成功后把 status 更新为 DONE return do_write(payload, key)对重复投递返回成功而不是报错,这一点很多人会写反。回调方只关心「你收到了没有」,回 409 会让它持续重试,链路越堵越死。PROCESSING状态还要配一个超时清理任务,防止进程崩溃后这个键永远卡住。
4.4 联调排错先看哪几个字段
接口不通时,按trace_id查esb_call_log,重点看四个字段:route_id是否命中预期路由、status_code是总线返回还是后端返回、cost_ms是否超过timeout_ms、caller_sys判断是哪一方在调。如果日志里根本没有记录,说明请求连路由匹配都没进,先确认路径前缀和大小写。如果status_code是 504 且耗时接近超时值,问题在后端而不是总线。如果日志里有两条相同trace_id但状态不同,说明发生了重试,去检查retry_times的配置。
5. ESB方案文档里的进阶参数:限流、熔断与压测验证
5.1 限流与熔断的参数怎么定
限流要分两层。入口层按调用方限流,防止单个系统把总线打满;出口层按后端服务限流,防止总线把某个老旧系统打死。参数不用拍脑袋,先看后端的历史峰值 QPS,出口限流设成峰值的 1.2 倍左右,留一点余量;入口限流按调用方的合同约定来配。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 入口 QPS 限流 | 调用方约定值 × 1.1 | 略高于约定,避免正常波动被误杀 |
| 出口 QPS 限流 | 后端历史峰值 × 1.2 | 保护老系统,不追求跑满 |
| 熔断错误率阈值 | 50% | 统计窗口内慢调用也算失败 |
| 熔断最小请求数 | 20 | 样本太小不熔断,避免抖动 |
| 熔断半开探测间隔 | 10s | 太短会反复冲击后端 |
| 连接池上限 | 出口 QPS × 平均耗时(s) | 按利特尔法则估算,避免排队 |
慢调用一定要计入熔断统计。老系统典型的失败模式不是报错,而是响应从 200ms 变成 20s,只有把超时也当作失败,熔断才拦得住。
5.2 压测与链路追踪的验证方法
压测不要只压总线本身,要按真实链路压:客户端到总线到后端,中间不做 mock。用一个简单的脚本按固定并发打目标路由,同时观察日志表里的cost_ms分位数。
# 记录压测前基线 mysql -uesb -p esb_db -e "SELECT COUNT(*), AVG(cost_ms) FROM esb_call_log WHERE route_id='crm.customer.query';" # 200 并发打 30 秒,注意带上 trace 头方便抽样 wrk -t4 -c200 -d30s -s post.lua http://127.0.0.1:8080/esb/crm/customer/88231 # 压测后看 P95 和错误率,作为是否调参的依据 mysql -uesb -p esb_db -e "SELECT status_code, COUNT(*) c, AVG(cost_ms) avg_ms, MAX(cost_ms) max_ms \ FROM esb_call_log WHERE route_id='crm.customer.query' AND created_at > NOW() - INTERVAL 5 MINUTE \ GROUP BY status_code;"链路追踪的验证有一个简单判据:随机抽一条trace_id,能不能在后端系统、总线、调用方三方日志里都找齐。找不齐说明X-Trace-Id在某一段没有被透传,常见漏点是总线转 SOAP 时忘了把头部写进信封的 Header 段,以及后端自己的日志框架没有把这个头部写进 MDC。把这两处补齐,链路才算真正打通。
本文还有配套的精品资源,点击获取