[{"content":"Uniswap Uniswap 是以太坊上 DeFi 的基石。它用 AMM（自动做市商）代替传统订单簿，让用户在没有中间做市商的情况下直接兑换代币。\n核心演进可以看成三步：\nv1 / v2：全区间恒定乘积 \\(xy = k\\) v3：把流动性集中到指定价格区间 v4：AMM 算法基本不变，改的是合约架构（单体、闪电记账、hooks） Uniswap v1 / v2 v1 只允许 ETH / ERC20 组成交易对。v2 放开了限制，ERC20 / ERC20 也可以组 pair。\n兑换、加池、撤池都围绕同一个公式：\n$$ x \\cdot y = k $$\\(x\\)、\\(y\\) 是池子里两种代币的储备，\\(k\\) 在一笔 swap 过程中保持不变（手续费会让 \\(k\\) 缓慢变大）。\n下面用一组贯穿的数字：\nx = 2000 y = 100 k = 2000 * 100 = 200000 总 LP = 1000 添加流动性 添加流动性前后要保持 \\(x / y\\) 的比例不变。想加 \\(100\\) 个 \\(x\\)，就必须同时加 \\(5\\) 个 \\(y\\)：\n$$ \\frac{100}{2000} = \\frac{5}{100} $$获得的 LP 数量取两侧份额的较小值：\n$$ \\begin{aligned} \\mathrm{lp}_x \u0026= \\frac{100}{2000} \\times 1000 = 50 \\\\ \\mathrm{lp}_y \u0026= \\frac{5}{100} \\times 1000 = 50 \\\\ \\mathrm{lp} \u0026= \\min(\\mathrm{lp}_x, \\mathrm{lp}_y) = 50 \\end{aligned} $$对应 v2 合约里的：\n$$ L = \\min\\left( \\frac{\\Delta x \\cdot S}{x},\\; \\frac{\\Delta y \\cdot S}{y} \\right) $$其中 \\(S\\) 是当前 totalSupply。\n首次添加流动性时还没有 \\(S\\)，公式换成：\n$$ \\mathrm{lp} = \\sqrt{xy} - L_{\\min} $$\\(L_{\\min}\\) 即 MINIMUM_LIQUIDITY，默认是 \\(1000\\)，会永久锁到 address(0)，避免池子被抽空后价格被任意操纵。\n加完后池子变成：\nx = 2100 y = 105 总 LP = 1050 兑换 兑换前后保持 \\(k\\) 不变，但协议会先从输入里抽走 0.3% 手续费。\n用户用 \\(100\\) 个 \\(x\\) 换 \\(y\\)：\n$$ \\begin{aligned} 2100 \\times 105 \u0026= \\bigl(2100 + 100 \\times (1 - 0.003)\\bigr) \\times (105 - \\Delta y) \\\\ \\Delta y \u0026\\approx 4.759 \\end{aligned} $$等价于合约里的 \\(997 / 1000\\) 算法：\n$$ \\begin{aligned} \\text{amountInWithFee} \u0026= 100 \\times 997 \\\\ \\text{amountOut} \u0026= \\frac{\\text{amountInWithFee} \\times 105} {2100 \\times 1000 + \\text{amountInWithFee}} \\approx 4.759 \\end{aligned} $$手续费留在池子里，所以实际 \\(k\\) 会略微增大，这就是 LP 的收益来源。\n移除流动性 销毁自己持有的 LP，按份额取回两种代币。假设用户有 \\(100\\) LP，当时总供应 \\(1000\\)，池子 \\(x = 2000\\)、\\(y = 100\\)：\n$$ \\begin{aligned} x_{\\mathrm{out}} \u0026= \\frac{100}{1000} \\times 2000 = 200 \\\\ y_{\\mathrm{out}} \u0026= \\frac{100}{1000} \\times 100 = 10 \\end{aligned} $$Uniswap v3 v2 的流动性均匀铺在 \\((0, +\\infty)\\) 上，大部分永远用不上。v3 允许 LP 把流动性集中到自己选择的价格区间，同样的资金能提供更高的深度。\n价格被离散成 tick：\n$$ P(\\mathrm{tick}) = 1.0001^{\\mathrm{tick}}, \\quad \\mathrm{tick} \\in [-887272,\\, 887272] $$每个 fee tier 对应一个 tickSpacing，流动性只能加在能被 tickSpacing 整除的 tick 上：\nfee tickSpacing 0.01% 1 0.05% 10 0.30% 60 1.00% 200 v3 不再发可替换的 ERC20 LP token，每个头寸是一张 NFT（tokenId），因为不同区间、不同手续费档位无法互相替代。\n添加流动性 区间内的虚拟流动性 \\(L\\) 和两种代币数量的关系：\n当前价格在 \\([P_a, P_b]\\) 内：\n$$ \\begin{aligned} \\Delta y \u0026= L \\bigl(\\sqrt{P} - \\sqrt{P_a}\\bigr) \\\\ \\Delta x \u0026= L \\left(\\frac{1}{\\sqrt{P}} - \\frac{1}{\\sqrt{P_b}}\\right) \\end{aligned} $$当前价格 \\(P \u003c P_a\\)（区间在上方，只需要 \\(x\\)）：\n$$ \\Delta x = L \\left(\\frac{1}{\\sqrt{P_a}} - \\frac{1}{\\sqrt{P_b}}\\right), \\quad \\Delta y = 0 $$当前价格 \\(P \u003e P_b\\)（区间在下方，只需要 \\(y\\)）：\n$$ \\Delta y = L \\bigl(\\sqrt{P_b} - \\sqrt{P_a}\\bigr), \\quad \\Delta x = 0 $$链上用 \\(\\mathrm{sqrtPriceX96} = \\sqrt{P} \\cdot 2^{96}\\) 存价格，所以实际计算会多一个 \\(Q96 = 2^{96}\\) 的定点缩放：\n$$ \\begin{aligned} \\Delta y \u0026= L \\cdot \\frac{\\mathrm{sqrtPriceX96} - \\mathrm{sqrtLowerX96}}{Q96} \\\\ \\Delta x \u0026= L \\cdot Q96 \\cdot \\frac{\\mathrm{sqrtUpperX96} - \\mathrm{sqrtPriceX96}} {\\mathrm{sqrtPriceX96} \\cdot \\mathrm{sqrtUpperX96}} \\end{aligned} $$例子：\ntickSpacing = 60 currentTick = 0 # 当前价格 = 1 tickLower = -60 tickUpper = 60 amount0 = 100 sqrtPriceX96(-60) = 78990846045029531151608375685 sqrtPriceX96(0) = 79228162514264337593543950336 sqrtPriceX96(60) = 79466191966197645195421774832 由 \\(\\Delta x = 100\\) 反解 \\(L\\)，再代入 \\(\\Delta y\\) 公式，整数除法得到 \\(\\Delta y = 99\\)。区间关于 \\(P = 1\\) 对称，实数计算下两种代币几乎相等；链上 floor 之后差 1 个 wei 量级的单位。\nSwap v3 的 swap 不再一次走完整条曲线，而是沿着 tick 一段一段走：\n确定方向：\\(x \\to y\\)（zeroForOne）还是 \\(y \\to x\\) 沿该方向找到下一个已初始化（有人挂过流动性）的 tick 用当前这段的 \\(L\\) 和上面的 \\(\\Delta x / \\Delta y\\) 公式，算出这段最多能换多少 输入在这段就耗尽：直接结束 还有剩余：跨过这个 tick，更新当前 \\(L\\)（加上 liquidityNet），继续下一段 跨 tick 时还要翻转该 tick 的 feeGrowthOutside，供后面计算区间内手续费用。\n移除流动性 和添加是同一套公式，只是方向相反。同样要看当前价格落在哪：\n在区间内：\n$$ \\begin{aligned} \\Delta x \u0026= L \\left(\\frac{1}{\\sqrt{P}} - \\frac{1}{\\sqrt{P_b}}\\right) \\\\ \\Delta y \u0026= L \\bigl(\\sqrt{P} - \\sqrt{P_a}\\bigr) \\end{aligned} $$高于上限（只能取回 \\(y\\)）：\n$$ \\Delta y = L \\bigl(\\sqrt{P_b} - \\sqrt{P_a}\\bigr) $$低于下限（只能取回 \\(x\\)）：\n$$ \\Delta x = L \\left(\\frac{1}{\\sqrt{P_a}} - \\frac{1}{\\sqrt{P_b}}\\right) $$价格已经走出区间后，头寸不再承担成交，但之前累计的手续费仍可以领取。\n手续费记账 v2 的手续费直接混进储备里，所有 LP 按份额均分。v3 的头寸区间不同，必须按「这段时间有没有在范围内」分别记账。分三层：\n层级 记什么 Pool feeGrowthGlobal0/1：全池每单位流动性累计的手续费 Tick feeGrowthOutside0/1：该 tick 外侧累计的手续费 Position 上次结算时的 feeGrowthInside 一个 \\([\\mathrm{tickLower}, \\mathrm{tickUpper}]\\) 区间内部累计的手续费：\n$$ \\begin{aligned} \\text{feeGrowthInside} \u0026= \\text{feeGrowthGlobal} \\\\ \u0026\\quad - \\text{feeGrowthOutside}(\\mathrm{lower}) \\\\ \u0026\\quad - \\text{feeGrowthOutside}(\\mathrm{upper}) \\end{aligned} $$outside 的含义取决于当前 tick 在该 tick 的哪一侧，跨 tick 时会翻转一次。头寸未领取的手续费：\n$$ \\text{unclaimed} = L \\cdot (\\text{feeGrowthInside}_{\\mathrm{now}} - \\text{feeGrowthInside}_{\\mathrm{last}}) $$Uniswap v4 v4 的 AMM 数学和 v3 一样，仍然是集中流动性 + tick。变化在合约架构，主要是三点。\n单体架构（Singleton） v3 每个 pool 单独部署一个合约。v4 把所有池子放进同一个 PoolManager，用\nPoolId = hash(currency0, currency1, fee, tickSpacing, hooks) 区分。少了一次次 CREATE2 部署，创建池子和跨池路由的 gas 都更低。\n闪电记账（Flash Accounting） v3 每一步都立刻 transfer ERC20。v4 在一次 lock / unlock 里只记余额 delta（用 transient storage），最后再统一 settle / take。中间的加池、swap、跨池操作可以互相抵消，少很多次代币转账。\n约束是：unlock 时所有 currency 的 delta 必须归零，否则 revert。\nHooks 每个池子可以绑定一个 hook 合约，在生命周期前后插入自定义逻辑，例如：\nbefore/afterInitialize before/afterSwap before/afterAddLiquidity / before/afterRemoveLiquidity before/afterDonate 动态手续费、内部限价单、自定义 oracle、挂钩稳定币曲线，都可以做在 hook 里，而不用分叉整个 AMM。\n","permalink":"https://blog.brianliu.top/posts/web3/uniswap/","summary":"","title":"Uniswap"},{"content":"etcd 读写流程 etcd 用在服务发现、分布式锁，以及 Kubernetes 的集群状态存储。读写都要过同一套模块，只是读尽量不写 Raft 日志，写必须走共识：\ngRPC KV → KVServer → 读：线性读等待（可选）→ MVCC → 写：quota / 校验 → Raft propose → commit → apply → MVCC → MVCC = 内存 treeIndex + BoltDB treeIndex 用 key 查 revision；BoltDB 用 revision 当 key 存真正的 value。中间还有一层 write buffer，刚 apply、还没刷盘的数据先从这里读。\n读流程 默认是线性读。可串行化读跳过 Raft，更快，但可能读到旧数据。\nclient 对某个 key 发起 Range / Get。 请求进 KVServer。gRPC 按方法分到不同 handler（Range、Put、Txn…），顺带打监控。 先区分读模式，再进 MVCC（原文把 treeIndex 查找放在线性读等待之前，顺序反了）： 可串行化读（serializable）：不和 leader 对齐，直接读本节点当前状态。follower 上可能落后于集群。 线性读（linearizable，默认）：向 leader 做 ReadIndex，拿到此刻的 committedIndex，然后等到本节点 appliedIndex \u0026gt;= 这个 index。保证读到的是这条读请求开始之前已经 commit 的全部写入。这里比的是 Raft index，不是 MVCC 的 version。 进入 MVCC。treeIndex 用 key 找到对应 revision（创建 revision、当前 revision、修改次数、历史 revision 列表）： 指定的 revision 已被 compact：返回错误。 key 不存在：返回空结果，不是报错。 拿着 revision 先查 buffer，命中直接返回；没有再回 BoltDB。 线性读的代价是一次和 leader 的对齐，以及可能的短暂等待。所以 watch、大量本地读有时会显式开 serializable。\n写流程 写一定要进 Raft。本节点 apply 完成，client 才收到成功。\nclient 发起 Put / Delete / Txn。 quota：看 BoltDB 是否已经摸到空间上限。超了直接返回，不再提案，避免把盘写爆。 进 KVServer，提案前做几项校验： committedIndex - appliedIndex \u0026gt; 5000：前面堆积了太多还没 apply 的日志，拒绝新提案（ErrTooManyRequests），避免缺口继续拉大。 token / 权限是否合法。 包是否超过 MaxRequestBytes。 KVServer 把请求打成 Raft 提案： leader 追加本地 log 并写 WAL。 同时复制给 follower。 超过半数确认之后才 commit，然后进入 apply。append 和 apply 不是一步：过半之前只是写了日志，状态机还没变。 apply 要幂等。etcd 在 backend 里记 consistentIndex（已应用到状态机的 Raft index）。重启或重复 apply 时，index \u0026lt;= consistentIndex 的条目直接跳过。 真正改数据（同一笔 backend 事务里）： 分配新的 revision，以 revision 为 key 写入 BoltDB，value 是用户的 key/value 以及创建 revision、mod revision、version。 更新内存 treeIndex：key → 这串 version 元数据。 把 consistentIndex 写成当前这条 Raft index。 所以一次成功的写，对外可见的顺序是：多数派 commit → apply 进 MVCC → 后续线性读能看到。只在 leader 本地 WAL 里、还没过半的数据，读路径看不到。\n","permalink":"https://blog.brianliu.top/posts/etcd/etcd-read-write-process/","summary":"","title":"Etcd 读写流程"},{"content":"高性能服务 服务能不能长期扛住线上流量，是衡量工程师功力的基本指标。这里的「高性能」不只是延迟和吞吐，通常要同时过三关：\n可维护性：逻辑架构是否清晰、是否易扩展、出错是否易排查。 性能：并发架构、IO 模型、数据库、缓存。 稳定性：限流、熔断、隔离、降级、发布。 缺任何一维，另外两维都会很快被拖垮：架构乱了就找不到热点；没有限流，性能再好也会被打穿。\n代码的可维护性 可维护性决定了后面两维还能不能改得动。热点定位、限流策略、降级开关，最后都要落回代码结构上。\n架构设计 架构是可维护性和可扩展性的基石，也是性能优化的前提。几条常用原则：\n依赖接口，不依赖具体实现。存储、RPC、缓存都可以替换，单测也不用绑死真实依赖。 高内聚、低耦合。模块边界清楚，对外只暴露统一且尽量小的接口。 匹配当前团队规模和业务目标。不要为了「以后可能」提前上微服务、消息队列或过度抽象。 出错是否易排查，同样属于可维护性：错误要能归到明确的模块和调用链上，而不是散落在一堆隐式副作用里。日志、指标、追踪是另一篇文章的主题，但架构上必须先留出观察点。\n服务的性能 性能要先能量，再谈优化。没有目标的优化，最后往往只是把代码改复杂。\nGo 里常用的定位工具：\npprof：CPU、堆、goroutine、block/mutex trace：调度、GC、syscall 的时间线 benchmark：局部函数的回归对比 改善性能的一般流程：\n设定目标 → 定位问题 → 分析原因 → 解决方案 → 测试结果 目标要可验证，例如 P99 延迟、QPS、内存上限。改完必须用同样的指标证明有效，否则不算做完。\n定位时先看三类资源，再决定动哪一层：\n资源 常见信号 优先检查 CPU 打满、P99 升高 热点函数、锁竞争、多余序列化 内存 RSS 持续涨、频繁 GC 泄漏、大对象、无界缓存 IO 等待时间长、goroutine 堆积 下游超时、数据库、磁盘、网络 真正动手时，优化面通常落在这四处。\n并发架构 并发模型决定了机器能被吃满，还是在自己锁自己。\ngoroutine 不是越多越好，要对齐下游容量，用 worker 池或信号量把并发度卡住。 共享状态能无锁就无锁；必须加锁时，缩小临界区，避免一把全局锁串行化所有请求。 注意惊群、重复计算、无界 channel，这些比「少写几行代码」更容易把延迟打爆。 IO 模型 服务耗时大部分不在本地计算，而在等网络和磁盘。\n能批量就不要一条条打下游；能 pipeline / 并发的请求，不要串行排好再发。 给所有下游调用设超时和连接池上限，避免慢调用占满 goroutine。 同步阻塞不是原罪，无界的等待才是。先把调用链上的等待点摸清，再决定要不要异步化。 数据库 数据库经常是第一个真实瓶颈。\n先看慢查询和索引，再谈分库分表。 避免 N+1 和在循环里访问 DB；能一次取回的不要拆成很多次。 连接池大小要和实例数、数据库 max_connections 一起算，池开太大等于把压打到库上。 缓存 缓存是放大性能的杠杆，也会放大故障。\n先确认瓶颈在读、在计算，再加缓存。缓存解决的是重复访问，不是错误的数据模型。 提前想好穿透、击穿、雪崩：空值缓存、互斥回源、过期时间打散。 缓存和 DB 的一致性策略要写清楚，不要默认「最终一致就行」。 服务的稳定性 稳定是服务是否成功的标志。性能优化解决的是「正常负载下有多快」，稳定性解决的是「异常负载下会不会死」。\n限流 先保护自己。给服务设一个最大容量，超出的请求直接失败，而不是把队列和线程吃满再一起超时。\n常见算法：\n滑动窗口：实现简单，适合做接口级计数。 漏桶：流出速率恒定，能把突发削平。 令牌桶：允许一定突发，更贴近真实流量。 限流阈值要按实例容量来，而不是拍一个全局数字；扩容之后阈值也要跟着变。\n熔断与降级 下游出错时，继续重试只会把故障放大。\n熔断：连续失败或错误率超过阈值，就暂时断开，过一段时间再放少量探测。 降级：核心链路保持，非核心功能关停或走兜底数据，例如推荐失败就返回默认列表。 限流管的是「进来多少」，熔断管的是「出去还打不打」，降级管的是「打失败了用户还能不能用」。\n隔离 核心业务不要和非核心业务抢同一份资源。\n业务隔离：核心接口和非核心接口分开部署或分开限流。 数据隔离：核心表、核心缓存不要被离线任务或后台导出打满。 资源隔离：线程池、连接池、队列按依赖拆开，一个慢下游不能抽干整个进程。 隔离的目标不是永不故障，而是故障半径可控。\n发布 很多线上事故不是流量打出来的，是发出来的。\n先灰度再全量，观察错误率、延迟、核心业务指标。 发布必须能快速回滚，回滚路径要事先演练，而不是出了问题再找人。 配置变更按发布对待：同样需要灰度、审计和回滚。 ","permalink":"https://blog.brianliu.top/posts/high-performance/high-performance/","summary":"","title":"高性能服务"},{"content":"可观测性 可观测性衡量的是：服务上线之后，出了问题能不能被看见、被定位、被复盘。监控告诉你「机器还活着」，可观测性要回答的是「这次请求为什么慢、错在哪一段」。\n三大支柱：\nLogging：运行过程中重要事件的离散记录，回答「发生了什么」。 Metrics：定时采集的时间序列，回答「现在是什么趋势」。 Tracing：一次请求穿过的完整调用链，回答「慢在哪一段」。 三柱要能对上同一件事：Metrics 发现异常，Tracing 把请求拆到具体 span，Logging 给出那个点上的上下文。对不齐的话，三套系统只是三份账单。\nLogging 日志适合记录不可聚合的事实：错误堆栈、关键状态跳转、一次请求的入参摘要。\n用结构化字段（key=value 或 JSON），不要把全部信息塞进一句自然语言。 每条日志带上 request_id / trace_id，才能从一条错误跳到整条链路。 级别要分清：error 可告警，info 可复盘，debug 默认不要在生产全量开。 日志基数高、存储贵，不要用它替代 Metrics。user_id、完整请求体这类字段尤其要克制。 Metrics 指标适合看趋势和做告警：低基数标签 + 可加总的数值。\n线上最常用的几类：\n流量：QPS、并发 错误：错误率、按错误码分类的计数 延迟：P99 / P999，不要只看平均值 饱和度：队列长度、连接池、goroutine、CPU / 内存 标签基数必须受控。interface、code、instance 可以；user_id、url 原文会把时序库打爆。\nMetrics 能告诉你「支付接口 P99 从 80ms 变成 800ms」，但说不清是 DB、Redis 还是下游 RPC。这就是 Tracing 的位置。\nTracing 一次请求会穿过多层函数、多个下游。Trace 把这次请求收成一棵 span 树：\n同一个 TraceID 标识整次请求 每个 Span 有自己的 SpanID、耗时和名称 ParentSpanID 把 span 连成树 当前 span 放进 context，往下游传，子调用才能挂到正确的父节点上 下面是一个只覆盖进程内传播的最小实现：不采样、不导出，End() 时直接打印。\ntrace.go // trace.go package trace import ( \u0026#34;context\u0026#34; \u0026#34;crypto/rand\u0026#34; \u0026#34;encoding/hex\u0026#34; \u0026#34;fmt\u0026#34; \u0026#34;time\u0026#34; ) type TraceID [16]byte type SpanID [8]byte type Span struct { TraceID TraceID SpanID SpanID ParentSpanID SpanID Name string StartTime time.Time EndTime time.Time } type spanKey struct{} func newTraceID() TraceID { var id TraceID _, _ = rand.Read(id[:]) return id } func newSpanID() SpanID { var id SpanID _, _ = rand.Read(id[:]) return id } func (id TraceID) String() string { return hex.EncodeToString(id[:]) } func (id SpanID) String() string { return hex.EncodeToString(id[:]) } func spanFromContext(ctx context.Context) (*Span, bool) { span, ok := ctx.Value(spanKey{}).(*Span) return span, ok } func Start(ctx context.Context, name string) (context.Context, *Span) { var traceID TraceID var spanID SpanID if span, ok := spanFromContext(ctx); ok { traceID = span.TraceID spanID = span.SpanID } else { traceID = newTraceID() } span := \u0026amp;Span{ TraceID: traceID, SpanID: newSpanID(), ParentSpanID: spanID, Name: name, StartTime: time.Now(), } ctx = context.WithValue(ctx, spanKey{}, span) return ctx, span } func (s *Span) End() { s.EndTime = time.Now() fmt.Printf( \u0026#34;trace_id=%s span_id=%s parent_span_id=%s name=%q duration=%s\\n\u0026#34;, s.TraceID.String(), s.SpanID.String(), s.ParentSpanID.String(), s.Name, s.EndTime.Sub(s.StartTime), ) } Start 的规则很简单：context 里已有 span，就继承它的 TraceID，并把它的 SpanID 当作父节点；没有则新开一条 trace。根 span 的 ParentSpanID 是零值。\nmain.go // main.go package main import ( \u0026#34;context\u0026#34; \u0026#34;time\u0026#34; \u0026#34;your-path/trace\u0026#34; ) func main() { ctx := context.Background() ctx, span := trace.Start(ctx, \u0026#34;main\u0026#34;) defer span.End() queryDB(ctx) } func queryDB(ctx context.Context) { ctx, span := trace.Start(ctx, \u0026#34;queryDB\u0026#34;) defer span.End() time.Sleep(100 * time.Millisecond) callRedis(ctx) } func callRedis(ctx context.Context) { _, span := trace.Start(ctx, \u0026#34;redis.GET\u0026#34;) defer span.End() time.Sleep(50 * time.Millisecond) } 调用关系是 main → queryDB → redis.GET。queryDB 和 callRedis 都必须把传入的 ctx 交给 Start，否则子 span 会变成一条新的、互不相干的 trace。\n输出示例 trace_id=f13ab1e98bfe04d070ba0c2c6c21d082 span_id=ac2457ed96cda12a parent_span_id=6390a0de25c1605f name=\u0026#34;redis.GET\u0026#34; duration=50.382959ms trace_id=f13ab1e98bfe04d070ba0c2c6c21d082 span_id=6390a0de25c1605f parent_span_id=a0acf62459855909 name=\u0026#34;queryDB\u0026#34; duration=150.616125ms trace_id=f13ab1e98bfe04d070ba0c2c6c21d082 span_id=a0acf62459855909 parent_span_id=0000000000000000 name=\u0026#34;main\u0026#34; duration=150.621167ms 几点可以直接从输出读出来：\n三条记录同一个 trace_id，说明还在一次请求里。 parent_span_id 串起来就是 main → queryDB → redis.GET。根 span 的 parent 是全 0。 打印顺序是子 span 先结束：defer 后进先出。 queryDB 的 duration ≈ 150ms，等于自己的 100ms 加上 Redis 的 50ms。父 span 的耗时包含子调用。 生产环境中的 Tracing 上面的实现只在本进程 printf。生产环境至少还要补三块，也就是 SDK 里常见的三层：\nSampler（采样器）：决定这条 trace 记不记。全量采集成本很高，通常按比例或按错误/慢请求强制留下。采样必须在根 span 上决定，并随 context 传下去，否则同一条请求会缺段。 Exporter（导出器）：用什么协议发出去（OTLP / gRPC / HTTP），以及一条条发还是批量发。批量能省网络，进程退出前要能 flush。 Connector（连接器）：接到哪一个收集器。本机 agent、集群 collector、还是厂商入口，决定的是导出的目的地，不是 span 模型本身。 还有一件最小实现故意没做的事：跨服务传播。出进程时要把 trace_id / span_id 放进 RPC / HTTP header（例如 W3C traceparent），对端再解出来继续 Start。否则链路会在服务边界断开，Metrics 看到的慢接口和 Tracing 对不上。\n","permalink":"https://blog.brianliu.top/posts/observability/observability/","summary":"","title":"可观测性"}]