第一章:Go语言OA文档中心性能翻倍的架构演进全景
面对日均千万级文档元数据查询与百TB级文件并发读取压力,原有基于单体Spring Boot + MySQL + MinIO的文档中心在高峰期平均响应延迟突破1.8秒,CPU持续饱和。团队以Go语言为核心重构服务层,通过四阶段渐进式演进,最终实现P95延迟从1820ms降至790ms,QPS提升2.3倍,资源消耗下降41%。
核心瓶颈诊断
- 数据库连接池争用:MySQL单实例最大连接数被文档元数据服务独占87%
- 文件IO阻塞:同步读取MinIO对象触发Goroutine阻塞,goroutine数量峰值达12,000+
- 序列化开销:JSON.Marshal/Unmarshal占CPU耗时34%,尤其在嵌套权限结构体场景
零拷贝文件流式传输
采用io.Copy直通HTTP响应体,绕过内存缓冲区:
func serveDocument(w http.ResponseWriter, r *http.Request) {
obj, err := minioClient.GetObject(r.Context(), "docs", docID, minio.GetObjectOptions{})
if err != nil { panic(err) }
// 设置Content-Disposition避免浏览器自动解析
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s\"", filename))
w.Header().Set("Content-Type", "application/octet-stream")
// 零拷贝传输:底层复用TCP sendfile系统调用
_, err = io.Copy(w, obj) // 不经[]byte中间态,减少GC压力
if err != nil && !errors.Is(err, io.ErrClosedPipe) {
log.Printf("stream failed: %v", err)
}
}
异步元数据缓存策略
构建两级缓存:本地LRU(10MB)存储高频访问文档属性,Redis集群(TTL=15m)承载全量索引。关键优化点:
- 使用
groupcache替代传统Redis客户端,消除连接池竞争 - 元数据变更时通过
Pub/Sub广播失效消息,各节点同步清理本地缓存
并发模型重构对比
| 维度 | 旧架构(Spring Boot) | 新架构(Go + Goroutine) |
|---|---|---|
| 单请求协程数 | 1(线程阻塞) | 3(元数据查+权限校验+文件流) |
| 连接复用率 | 62% | 98%(基于net/http.Transport复用) |
| 内存占用/请求 | 4.2MB | 1.1MB |
所有服务模块通过go.mod统一版本约束,CI流水线强制执行pprof火焰图分析,确保每次提交满足延迟基线要求。
第二章:FSM状态机驱动的PDF预览生命周期管理
2.1 状态建模:从文档上传到预览就绪的7个核心状态定义与Go结构体实现
文档生命周期需精确刻画流转语义。以下7个不可约简的状态构成完整可观测链路:
Uploaded:原始文件已持久化,校验通过QueuedForProcessing:进入异步处理队列ParsingStarted:解析器开始提取文本/元数据ThumbnailGenerated:首帧缩略图生成完成TextIndexed:全文内容已写入搜索索引PreviewReady:渲染资源(PDF/HTML)就绪Failed:任一环节不可恢复中断
type DocStatus string
const (
Uploaded DocStatus = "uploaded"
QueuedForProcessing DocStatus = "queued_for_processing"
ParsingStarted DocStatus = "parsing_started"
ThumbnailGenerated DocStatus = "thumbnail_generated"
TextIndexed DocStatus = "text_indexed"
PreviewReady DocStatus = "preview_ready"
Failed DocStatus = "failed"
)
// StatusTransition 表示合法状态迁移规则(仅展示部分)
var ValidTransitions = map[DocStatus][]DocStatus{
Uploaded: {QueuedForProcessing},
QueuedForProcessing: {ParsingStarted, Failed},
ParsingStarted: {ThumbnailGenerated, Failed},
// ...其余省略
}
该结构体设计保障状态机强一致性:DocStatus 为枚举类型,避免字符串误赋;ValidTransitions 显式声明有向边,支撑运行时校验。
| 状态 | 触发条件 | 关键副作用 |
|---|---|---|
ThumbnailGenerated |
图像处理服务回调成功 | 写入 CDN 缓存 URL |
TextIndexed |
Elasticsearch bulk 响应 success | 更新 search_score 字段 |
graph TD
A[Uploaded] --> B[QueuedForProcessing]
B --> C[ParsingStarted]
C --> D[ThumbnailGenerated]
D --> E[TextIndexed]
E --> F[PreviewReady]
C --> G[Failed]
D --> G
E --> G
2.2 状态迁移:基于go-fsm库的事件驱动迁移逻辑与并发安全状态锁实践
事件驱动迁移核心机制
go-fsm 将状态迁移建模为 (current_state, event) → next_state 的纯函数映射,迁移由 fsm.Event() 触发,自动校验合法性并执行注册的回调。
并发安全设计要点
- 内部使用
sync.RWMutex保护状态字段与迁移历史 - 所有公开方法(
Event,CurrentState,GetHistory)均加读锁或写锁 - 避免在回调中阻塞或嵌套调用
Event
示例:订单状态机迁移
fsm := fsm.NewFSM(
"created",
fsm.Events{
{Name: "pay", Src: []string{"created"}, Dst: "paid"},
{Name: "cancel", Src: []string{"created", "paid"}, Dst: "canceled"},
},
fsm.Callbacks{
"enter_paid": func(e *fsm.Event) { log.Println("Payment confirmed") },
},
)
逻辑分析:
Src定义合法触发源状态(支持多态),Dst指定唯一目标;enter_paid在进入"paid"后同步执行。fsm.Event("pay")返回nil表示成功,否则返回错误(如非法事件)。
| 锁类型 | 作用域 | 调用频率 |
|---|---|---|
RWMutex.RLock() |
CurrentState, GetHistory |
高频读取 |
RWMutex.Lock() |
Event, 状态变更 |
低频写入 |
graph TD
A[created] -->|pay| B[paid]
A -->|cancel| C[canceled]
B -->|cancel| C
2.3 状态持久化:etcd一致性存储状态快照与断点续传能力设计
etcd 通过 WAL(Write-Ahead Log)与快照(Snapshot)双机制保障状态强一致与故障可恢复。
快照触发策略
- 每 10,000 条 WAL 日志写入后自动触发
raft.Snapshot() - 内存中未压缩状态树大小超 64MB 时强制落盘
- 支持
--snapshot-count运行时动态调优
WAL + Snapshot 协同流程
# etcd 启动时恢复逻辑片段(伪代码)
if lastWALIndex > lastSnapshotIndex {
replayWAL(from: lastSnapshotIndex+1, to: lastWALIndex)
} else {
loadSnapshot(lastSnapshotIndex) # 加载最新快照
}
该逻辑确保状态重建既避免全量重放开销,又杜绝日志截断导致的数据丢失。
lastWALIndex与lastSnapshotIndex均由raft模块原子更新,保障恢复起点严格单调递增。
断点续传关键参数对照表
| 参数 | 默认值 | 作用 | 调优建议 |
|---|---|---|---|
--snapshot-count |
10000 | 触发快照的最小日志条数 | 高频写场景可设为 5000 |
--wal-dir |
default.etcd/member/wal |
WAL 存储路径 | 建议 SSD 独立挂载 |
--snapshot-save-interval |
— | 无自动时间间隔,仅基于日志计数 | 不支持时间维度触发 |
graph TD
A[客户端写请求] --> B[写入 WAL 文件]
B --> C{是否满足 snapshot-count?}
C -->|是| D[触发快照生成]
C -->|否| E[追加至内存状态树]
D --> F[保存 snapshot.db + 更新 snapshot metadata]
F --> G[定期清理旧 WAL]
2.4 状态可观测性:Prometheus指标埋点与Grafana状态流转热力图看板
埋点设计原则
- 优先采集业务语义明确的状态维度(如
order_status{from="created",to="paid",result="success"}) - 避免高基数标签(如
user_id),改用预聚合或哈希脱敏 - 使用
counter类型记录状态跃迁次数,gauge跟踪当前各状态实例数
Prometheus 指标定义示例
# metrics.yaml —— 状态跃迁计数器
order_status_transition_total:
type: counter
help: "Total count of order status transitions"
labels: [from, to, result] # from/to 构成状态流转边,result 标识成功/失败
该配置声明了一个带三元标签的计数器:
from和to定义有向状态边(如created→confirmed),result区分执行结果,支撑后续热力图的「源状态×目标状态」二维聚合。
Grafana 热力图数据查询
| X轴(源状态) | Y轴(目标状态) | 值(跃迁频次) |
|---|---|---|
| created | paid | 1247 |
| paid | shipped | 983 |
| shipped | delivered | 956 |
状态流转可视化逻辑
graph TD
A[created] -->|+1247| B[paid]
B -->|+983| C[shipped]
C -->|+956| D[delivered]
B -->|+29| E[canceled]
C -->|+17| E
热力图通过 sum by (from, to)(rate(order_status_transition_total[1h])) 实时聚合,X/Y轴分别映射 from/to 标签,颜色深浅表征跃迁密度。
2.5 状态异常熔断:超时、OOM、渲染失败三重兜底策略与自动降级机制
当核心服务遭遇不可控异常,需在毫秒级内完成状态判定与响应切换。三重熔断器协同工作,形成纵深防御:
三类异常的判定阈值与响应动作
| 异常类型 | 触发条件 | 降级动作 | 恢复策略 |
|---|---|---|---|
| 超时 | latency > 800ms(连续3次) |
切至缓存快照 + 返回206 Partial Content |
指数退避探测,5分钟内渐进放行 |
| OOM | heap_used_ratio > 0.92 && gc_time_1m > 3s |
紧急释放非关键渲染线程池,禁用CSS-in-JS动态注入 | JVM内存水位回落至75%后触发全量健康检查 |
| 渲染失败 | React.render() throw 或 Canvas.getContext() === null |
启用轻量SVG占位模板,关闭交互动画 | 客户端上报错误堆栈,服务端比对白名单版本后下发修复补丁 |
熔断决策流程(同步阻塞路径)
graph TD
A[请求进入] --> B{是否已熔断?}
B -- 是 --> C[执行降级策略]
B -- 否 --> D[启动计时器+内存采样]
D --> E{超时/OOM/渲染失败?}
E -- 是 --> F[更新熔断状态+记录traceID]
E -- 否 --> G[正常处理]
自动降级代码片段(前端拦截器)
// 基于PerformanceObserver与ErrorEvent的联合熔断钩子
const fuse = new Fuse({
timeout: { threshold: 800, consecutive: 3 },
oom: { heapRatio: 0.92, gcTime: 3000 },
render: { errorPatterns: [/Canvas/, /React.*render/] }
});
fuse.on('trip', (reason, context) => {
// context包含traceID、内存快照、堆栈摘要等元数据
degradeToStaticTemplate(); // 切换至预加载SVG模板
reportAnomaly({ reason, ...context }); // 上报至APM平台
});
该钩子在requestIdleCallback中低优先级运行,避免干扰主线程渲染帧率;degradeToStaticTemplate()内部采用document.createElementNS('http://www.w3.org/2000/svg')构建零依赖占位,确保即使JS引擎崩溃仍可呈现基础结构。
第三章:MinIO分片加载的底层优化实践
3.1 分片协议设计:HTTP Range请求适配PDF流式解析的Go标准库改造
为支持大体积PDF在浏览器端渐进式渲染,需将net/http的ResponseWriter与io.Reader抽象层解耦,注入Range-aware缓冲策略。
核心改造点
- 替换
pdfcpu.Read默认读取器为rangeReader - 在
http.ServeContent前预检Accept-Ranges: bytes与Content-Range语义一致性
rangeReader 实现节选
type rangeReader struct {
src io.ReadSeeker
start int64
limit int64 // end byte (inclusive)
}
func (r *rangeReader) Read(p []byte) (n int, err error) {
if r.start >= r.limit { return 0, io.EOF }
_, _ = r.src.Seek(r.start, 0)
n, err = io.ReadFull(r.src, p[:min(len(p), int(r.limit-r.start+1))])
r.start += int64(n)
return
}
start与limit由ParseRange解析Range: bytes=1024-2047得出;ReadFull确保不越界,配合io.LimitReader形成双重边界防护。
| 组件 | 原生行为 | 改造后行为 |
|---|---|---|
http.ServeContent |
全量读取Content-Length |
按Range头动态截断Content-Length与Content-Range响应头 |
pdfcpu.Parse |
依赖io.ReadSeeker全文件定位 |
接收rangeReader,仅加载所需PDF对象交叉引用表片段 |
graph TD
A[HTTP Client] -->|Range: bytes=0-8191| B[Handler]
B --> C{ParseRange}
C -->|Valid| D[NewRangeReader]
D --> E[pdfcpu.Parse with partial xref]
E --> F[Stream PDF pages incrementally]
3.2 分片缓存协同:LRU+LFU混合缓存策略在MinIO客户端层的Go泛型实现
为应对高并发小对象读取场景下热点识别滞后与冷热误判问题,MinIO客户端引入分片式混合缓存:按对象哈希分片,每片独立运行 LRU(时序淘汰)与 LFU(频次加权)双队列协同机制。
核心结构设计
- 分片数
ShardCount = 64,通过hash(key) % ShardCount定位 - 每分片含
lruList *list.List与lfuMap map[string]*lfuNode - 节点访问时同步更新 LRU 位置 + LFU 计数器(带衰减)
泛型缓存接口定义
type Cache[K comparable, V any] interface {
Get(key K) (V, bool)
Put(key K, value V)
Evict() // 触发混合驱逐:优先淘汰 LFU 计数最低且最久未用者
}
混合驱逐逻辑流程
graph TD
A[触发Evict] --> B{LFU计数 < 阈值?}
B -->|是| C[从LRU尾部选节点]
B -->|否| D[扫描LFU最小堆Top3 + LRU位置校验]
C & D --> E[执行原子删除并释放内存]
| 维度 | LRU贡献 | LFU贡献 |
|---|---|---|
| 响应延迟 | O(1) 链表操作 | O(log n) 堆更新 |
| 热点适应性 | 弱(仅依赖最近) | 强(长期频次加权) |
| 内存开销 | 低(单链表) | 中(计数器+最小堆) |
3.3 分片预取调度:基于用户行为预测的Go协程池智能预加载引擎
核心设计思想
将用户会话轨迹建模为马尔可夫链,结合滑动窗口实时更新转移概率矩阵,驱动分片级预取决策。
协程池动态扩缩逻辑
// PreloadPool manages goroutines for predictive prefetching
type PreloadPool struct {
pool *ants.Pool
scores map[string]float64 // path → prediction score
}
func (p *PreloadPool) Schedule(shardKey string, weight float64) {
if weight > 0.7 && p.pool.Active() < p.pool.Cap() {
_ = p.pool.Submit(func() { preloadShard(shardKey) })
}
}
weight 表示该分片被后续访问的概率置信度;0.7 为预取触发阈值,兼顾精度与资源开销。
预取策略对比
| 策略 | 命中率 | 平均延迟 | 冗余加载率 |
|---|---|---|---|
| LRU缓存 | 62% | 18ms | 31% |
| 固定分片预热 | 58% | 22ms | 44% |
| 本引擎(动态) | 89% | 9ms | 12% |
执行流程
graph TD
A[用户操作日志] --> B{实时特征提取}
B --> C[路径转移概率更新]
C --> D[分片权重计算]
D --> E[协程池调度决策]
E --> F[异步预加载执行]
第四章:PDF预览缓存策略的全链路性能攻坚
4.1 缓存分级体系:内存层(bigcache)、本地磁盘层(bbolt)、对象存储层(MinIO)的Go多级缓存协同
多级缓存需兼顾低延迟、高吞吐与持久化可靠性。典型分层策略如下:
- 内存层:
bigcache提供无锁、GC友好的大容量键值缓存,适合毫秒级热数据访问 - 本地磁盘层:
bbolt以 mmap 实现 ACID 嵌入式 KV 存储,承载中频/冷数据,避免频繁刷盘 - 对象存储层:
MinIO作为最终一致性后备,存储原始二进制大对象(如图片、日志快照)
数据同步机制
采用「写穿透 + 异步回填」策略:写请求直达 bigcache 与 bbolt,失败时降级至 MinIO;读请求按 bigcache → bbolt → MinIO 逐级回源,并异步预热上层。
// 初始化三级缓存协调器
cache := NewMultiLevelCache(
bigcache.NewBigCache(bigcache.Config{Shards: 1024, LifeWindow: 10 * time.Minute}),
bbolt.Open("cache.db", 0600, nil),
minioClient,
)
Shards=1024 平衡并发与内存碎片;LifeWindow 控制内存项自动过期;bbolt.Open 启用只读事务优化读路径;minioClient 预置带重试的 HTTP 传输层。
| 层级 | 平均延迟 | 容量上限 | 适用场景 |
|---|---|---|---|
| bigcache | 数GB | 高频会话、计数器 | |
| bbolt | ~1ms | 数十GB | 用户配置、元数据 |
| MinIO | ~50ms | PB级 | 原始日志、归档 |
graph TD
A[Client Request] --> B{bigcache HIT?}
B -->|Yes| C[Return immediately]
B -->|No| D{bbolt HIT?}
D -->|Yes| E[Load & Warm up bigcache]
D -->|No| F[Fetch from MinIO → Persist to bbolt → Warm up]
4.2 缓存一致性:基于Redis Pub/Sub的跨节点缓存失效广播与Go原子操作校验
数据同步机制
当商品库存更新时,主服务向 Redis Pub/Sub 频道 cache:invalidation 发布失效消息:
// 发布缓存失效指令(含版本戳)
err := rdb.Publish(ctx, "cache:invalidation",
fmt.Sprintf("product:%s:%d", skuID, atomic.LoadInt64(&versionCounter))).Err()
versionCounter 为全局原子递增计数器,确保每次变更携带唯一单调递增版本号,避免重复或乱序失效。
校验与原子防护
各节点订阅后,仅当本地缓存版本低于消息版本时才执行删除:
| 检查项 | 类型 | 说明 |
|---|---|---|
atomic.LoadInt64 |
Go 原子操作 | 无锁读取当前本地版本 |
strconv.ParseInt |
安全解析 | 防止恶意消息导致 panic |
// 订阅端原子校验逻辑
pubsub := rdb.Subscribe(ctx, "cache:invalidation")
for msg := range pubsub.Channel() {
if ver, err := strconv.ParseInt(msg.Payload, 10, 64); err == nil && ver > atomic.LoadInt64(&localVersion) {
atomic.StoreInt64(&localVersion, ver)
rdb.Del(ctx, "product:"+skuID)
}
}
该模式结合 Pub/Sub 的广播能力与 atomic 的线性一致性,实现低延迟、无中心协调的最终一致。
4.3 缓存冷启动优化:Go定时任务驱动的PDF热点文档预热与访问模式学习
在高并发PDF服务场景中,冷缓存导致首字节延迟(TTFB)飙升。我们采用双阶段策略:访问模式学习 + 定时预热。
数据同步机制
每日凌晨触发 cron 任务,拉取近7天Nginx访问日志,提取 /pdf/[a-f0-9]{32}.pdf 路径及频次,写入Redis Sorted Set(key: pdf:hot:score,score=访问次数)。
预热执行器
func warmUpHotPDFs() {
// 获取TOP 100热点PDF哈希
hashes, _ := redisClient.ZRevRange(ctx, "pdf:hot:score", 0, 99).Result()
for _, hash := range hashes {
go func(h string) {
pdfBytes, _ := storage.Get(h) // 从对象存储加载原始PDF
cache.Set(ctx, "pdf:"+h, pdfBytes, 24*time.Hour) // 写入LRU缓存
}(hash)
}
}
逻辑说明:
ZRevRange按热度降序取值;cache.Set设置24小时TTL,避免陈旧热点长期驻留;并发goroutine提升预热吞吐,但需配合限流防压垮存储。
热度衰减策略
| 周期 | 权重 | 说明 |
|---|---|---|
| 当日访问 | 1.0 | 实时反映突发热点 |
| 前1日 | 0.7 | 指数衰减基础权重 |
| 前7日 | 0.3 | 长期行为平滑因子 |
graph TD
A[解析Nginx日志] --> B[聚合路径频次]
B --> C[加权衰减计算]
C --> D[更新Redis有序集]
D --> E[定时触发预热]
4.4 缓存安全加固:PDF内容哈希校验、内存映射防篡改、缓存密钥动态轮换
PDF内容哈希校验
对PDF文件计算SHA-256哈希并嵌入元数据,加载时比对实时哈希值:
import hashlib
def pdf_content_hash(path: str) -> str:
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest() # 全量读取确保完整性
逻辑说明:
f.read()避免流式截断风险;哈希值需与PDF元数据中/HashDigest字段绑定校验,防止元数据伪造。
内存映射防篡改
使用mmap.PROT_READ | mmap.PROT_EXEC只读+可执行映射,禁用写权限:
| 映射标志 | 安全效果 |
|---|---|
PROT_READ |
允许读取,保障解析可用性 |
PROT_EXEC |
支持PDF解析器JIT代码执行 |
~PROT_WRITE |
阻断运行时内存注入篡改 |
缓存密钥动态轮换
graph TD
A[定时器触发] --> B[生成新AES-GCM密钥]
B --> C[加密旧密钥密文]
C --> D[更新Redis密钥槽]
第五章:性能实测对比与生产稳定性验证
测试环境配置说明
所有实测均在阿里云ECS(ecs.g7.4xlarge,16核64GB内存,ESSD PL3云盘)上完成,操作系统为Ubuntu 22.04.4 LTS,内核版本6.5.0-1025-aws。Kubernetes集群版本为v1.28.11,CNI采用Calico v3.27.2,监控栈由Prometheus v2.47.2 + Grafana v10.2.1构成。压测客户端部署于同可用区独立节点,确保网络延迟可控(RTT
基准负载模型设计
采用基于真实业务日志重放的混合负载:65% HTTP JSON API请求(含JWT鉴权)、20% gRPC流式调用(实时风控决策)、15% WebSocket长连接心跳+消息广播。QPS阶梯式递增:500 → 2000 → 5000 → 10000,每阶段持续15分钟,采集P99延迟、错误率、CPU/内存水位及Pod重启次数。
多引擎吞吐量对比(单位:req/s)
| 引擎类型 | 500 QPS时P99延迟(ms) | 5000 QPS时错误率 | 10000 QPS时CPU平均占用率 | 内存泄漏(72h增长) |
|---|---|---|---|---|
| Node.js v20.12 | 42 | 0.87% | 82.3% | +1.2 GB |
| Go 1.22.5 | 28 | 0.03% | 61.7% | +84 MB |
| Rust 1.79.0 | 21 | 0.00% | 53.9% | +12 MB |
生产灰度验证过程
2024年6月12日起,在支付网关服务中分三批次灰度Rust实现:首批5%流量(持续48h),观测到GC暂停时间从Node.js的127ms降至Rust的0μs;第二批30%流量(7天),通过eBPF探针捕获到TCP重传率下降至0.0017%(原Node.js为0.042%);第三批全量切换前,执行了连续168小时无中断压力测试,期间触发3次自动扩缩容事件,所有Pod均在42秒内完成健康就绪。
故障注入韧性测试
使用Chaos Mesh对Rust服务注入以下故障:
- 每30秒随机kill一个Pod(共12次)
- 持续注入200ms网络延迟(持续10分钟)
- 同时限制内存至512MB(OOME触发边界)
结果:服务端到端成功率保持99.992%,熔断器未触发,Prometheus指标显示recovery_time_p99=1.8s,远低于SLA要求的5s阈值。
# 实时验证内存稳定性命令(生产环境持续运行)
watch -n 30 'curl -s http://localhost:9091/metrics | grep "process_resident_memory_bytes" | awk \'{print $2/1024/1024 " MB"}\''
长周期稳定性数据
自2024年7月1日上线以来,Rust网关已稳定运行43天(1032小时),累计处理请求28.7亿次,平均日志错误率0.00014%,JVM类比场景下等效节省GC停顿时间达17.3小时。Grafana面板显示内存RSS曲线呈高度平滑线性增长,斜率仅为0.0021 MB/h,证实无隐式内存泄漏路径。
真实业务指标收敛验证
接入订单创建链路后,核心接口P95延迟从118ms降至39ms,用户侧感知首屏加载耗时下降2.3s(iOS端Webview实测)。支付失败归因分析显示,因服务端超时导致的失败占比从12.7%降至0.19%,其中83%的改善源于Rust零拷贝序列化对Protobuf解析耗时的压缩(从14.2ms→1.8ms)。
graph LR
A[HTTP请求抵达] --> B{Nginx Ingress}
B --> C[Rust网关入口]
C --> D[JWT解析<br>(ring-crypto)]
D --> E[路由匹配<br>(ahash哈希表)]
E --> F[下游gRPC转发]
F --> G[响应组装<br>(zero-copy bytes)]
G --> H[返回客户端]
style C fill:#4CAF50,stroke:#388E3C,color:white
style G fill:#2196F3,stroke:#0D47A1,color:white 