Posted in

Go支付系统幂等设计失效实录:一次重复扣款引发的5层状态机重构(含状态流转图与etcd版本锁实现)

第一章:Go支付系统幂等设计失效实录:一次重复扣款引发的5层状态机重构(含状态流转图与etcd版本锁实现)

凌晨两点,监控告警触发:某VIP用户在3秒内被连续扣款两次,金额相同、渠道一致、订单号唯一——但支付网关返回了两条独立的成功回调。根因定位后发现,原始幂等键仅依赖 order_id + request_id,而前端重试时未保证 request_id 全局唯一,导致下游服务将两次请求视为不同操作。

旧有幂等逻辑存在三重脆弱性:

  • 幂等校验与业务执行未原子化,中间态(如“已扣款待通知”)无状态保护;
  • Redis SETNX 过期时间硬编码为30s,超时后并发请求可绕过校验;
  • 未区分“处理中”与“终态”,失败重试直接覆盖中间状态。

为此,我们引入五层确定性状态机:
Created → Validating → Charging → Notifying → Succeeded/Failed/Compensated
每层跃迁需满足:前置状态匹配 + etcd CAS 检查 + 版本号递增。状态流转严格单向,禁止跨层跳转(如 Charging → Succeeded 合法,Validating → Succeeded 拒绝)。

关键实现使用 etcd 的 CompareAndSwap 保障状态变更原子性:

// 使用 etcd v3 client 实现带版本校验的状态更新
resp, err := cli.Txn(ctx).
    If(clientv3.Compare(clientv3.Version(key), "=", currentVer)).
    Then(clientv3.OpPut(key, newState, clientv3.WithPrevKV())).
    Commit()
if err != nil || !resp.Succeeded {
    // 状态已被其他协程修改,拒绝本次跃迁
    return errors.New("state transition rejected: version mismatch")
}

状态持久化字段包含:state, version, updated_at, trace_id。所有状态变更必须通过统一状态机引擎驱动,禁止直写DB或绕过校验。

状态 可接受前驱状态 超时自动降级行为
Validating Created 回滚至 Created
Charging Validating 触发对账补偿
Notifying Charging 重试通知(≤3次)
Succeeded Notifying
Compensated Charging / Notifying / Succeeded 启动人工核查流程

该设计上线后,重复扣款归零,幂等校验平均耗时从18ms降至4.2ms(得益于 etcd 单key CAS 的高效性),并支撑日均2700万笔支付请求的强一致性保障。

第二章:幂等性失效根因分析与建模方法论

2.1 幂等边界模糊导致的事务穿透现象:从HTTP重试到数据库写偏的实际案例还原

场景还原:支付回调的“重复扣款”

用户支付成功后,第三方支付平台因网络抖动发起两次 HTTP 200 回调(含相同 out_trade_no),但服务端仅对请求头 X-Request-ID 做了去重,未校验业务幂等键。

数据库写偏的关键路径

// ❌ 错误:幂等校验与业务更新未在同一个事务内原子执行
if (!idempotentRepo.existsByIdempotentKey(req.getOutTradeNo())) {
    idempotentRepo.save(new IdempotentRecord(req.getOutTradeNo())); // 事务A提交
    accountService.deduct(req.getUserId(), req.getAmount());        // 事务B独立执行
}

逻辑分析:existsByIdempotentKeysave 之间存在微小时间窗口;若两次请求并发执行,可能同时通过校验,导致两次 deduct 调用——幂等边界止步于缓存/DB记录层,未覆盖业务状态变更

幂等性失效对比表

维度 表面幂等(当前) 强幂等(应然)
校验粒度 请求ID 业务单据+状态机
持久化时机 更新前异步记录 UPERT + 状态约束
写偏防护 SELECT FOR UPDATE

根本症结流程

graph TD
    A[HTTP重试] --> B{幂等键校验}
    B -->|通过| C[插入幂等记录]
    B -->|通过| D[并发执行扣款]
    C --> E[事务提交]
    D --> F[账户余额写偏]

2.2 分布式时钟漂移与客户端重放攻击叠加下的ID生成缺陷验证(含go test模拟时序乱序)

问题根源:Snowflake类ID生成器的双重脆弱性

当NTP校时滞后 + 客户端恶意重放旧请求时,timestamp 回退 + sequence 未全局隔离 → ID 冲突概率陡增。

模拟时序乱序的Go测试片段

func TestClockDriftAndReplay(t *testing.T) {
    idGen := NewSnowflake(1) // nodeID=1
    // 正常生成
    id1, _ := idGen.NextID() 
    // 模拟时钟回拨5ms(如NTP瞬时校正失败)
    idGen.SetTime(idGen.GetTime() - 5*time.Millisecond)
    // 客户端重放上一请求(携带原sequence)
    id2, _ := idGen.NextID() // ⚠️ 可能产出相同ID!
    if id1 == id2 {
        t.Fatal("ID collision under clock drift + replay")
    }
}

逻辑分析:SetTime() 强制篡改内部毫秒时间戳;NextID()sequence未按time重置前提下复用,导致同一毫秒窗口内ID重复。参数5*time.Millisecond对应典型局域网NTP最大漂移阈值。

防御策略对比

方案 抗漂移 抗重放 实现复杂度
被动等待(sleep)
分布式序列锁
时间+随机熵混合

核心验证路径

graph TD
    A[客户端发起请求] --> B{服务端校验}
    B --> C[检查timestamp是否≥last]
    C -->|否| D[触发漂移处理:阻塞/告警]
    C -->|是| E[校验nonce或签名]
    E -->|失效| F[拒绝重放]

2.3 基于Saga模式的补偿链路断裂点测绘:支付指令、风控校验、账务记账三阶段日志对齐实践

在分布式事务中,Saga模式将长事务拆解为多个本地事务,并通过补偿操作保障最终一致性。关键挑战在于精准定位补偿触发点——即哪一阶段因异常导致链路断裂。

日志对齐核心字段设计

需统一埋点结构,确保三阶段日志可关联: 字段名 含义 示例
saga_id 全局事务ID saga_8a9b-cd12-ef34
step_id 当前阶段标识 pay_init, risk_check, ledger_post
status 阶段执行状态 SUCCESS, FAILED, COMPENSATING

补偿触发判定逻辑(Python伪代码)

def detect_breakpoint(logs: List[Dict]):
    # 按 saga_id 分组,按 step_id 顺序排序
    sorted_logs = sorted(logs, key=lambda x: (x['saga_id'], STEP_ORDER[x['step_id']]))
    for i in range(len(sorted_logs) - 1):
        curr, next_step = sorted_logs[i], sorted_logs[i+1]
        # 若当前成功但下一阶段缺失,或下一阶段状态非预期,则断裂点为 curr.step_id
        if curr['status'] == 'SUCCESS' and not has_next_valid_step(curr, next_step):
            return curr['step_id']  # 如 'risk_check'

STEP_ORDER 映射阶段执行序({'pay_init':0, 'risk_check':1, 'ledger_post':2});has_next_valid_step 校验后续阶段是否存在且状态合法(如非 PENDINGTIMEOUT)。

断裂点归因流程

graph TD
    A[支付指令日志] -->|saga_id匹配| B[风控校验日志]
    B -->|saga_id匹配| C[账务记账日志]
    B -->|缺失/FAILED| D[补偿起点:risk_check]
    C -->|FAILED| E[补偿起点:ledger_post]

2.4 数据库唯一约束在高并发场景下的幻读盲区:MySQL INSERT ON DUPLICATE KEY UPDATE的原子性边界实验

幻读盲区成因

当多个事务并发执行 INSERT ... ON DUPLICATE KEY UPDATE 时,MySQL 仅对冲突的唯一索引键路径加行锁(如 UNIQUE(name)),但不锁定间隙(Gap Lock)——若 name='alice' 不存在,事务A插入前未被阻塞,事务B可同时进入“判断-插入”临界区,导致双写或更新覆盖。

原子性边界验证代码

-- 模拟并发插入同名用户,触发 ON DUPLICATE KEY UPDATE
INSERT INTO users (name, balance) 
VALUES ('alice', 100) 
ON DUPLICATE KEY UPDATE balance = balance + VALUES(balance);
-- 注:VALUES(balance) 引用 INSERT 子句中的值(非当前行),确保幂等累加

该语句在唯一键冲突时原子更新,但无冲突时的 INSERT 阶段不阻塞其他事务对同一键的 INSERT 尝试,形成幻读窗口。

实验对比结果

场景 事务A执行 事务B执行 实际结果
无索引 双插入成功 双插入成功 主键冲突报错
有UNIQUE索引 插入成功 Duplicate key → 触发UPDATE balance=200(正确)
高并发无索引间隙锁 A/B同时判定“不存在” 同时INSERT 唯一键冲突,仅一事务成功

核心结论

INSERT ... ON DUPLICATE KEY UPDATE 的原子性仅覆盖“检测+更新”路径,不覆盖“检测+插入”路径的并发竞态;需配合 SELECT ... FOR UPDATE 或应用层分布式锁补全防护。

2.5 etcd v3 Watch机制在状态同步中的延迟突变观测:通过pprof+trace定位Watch事件积压导致的状态覆盖

数据同步机制

etcd v3 的 Watch 采用长期连接 + 增量事件流模型,客户端通过 Watch() 接口注册监听,服务端按 revision 顺序推送 Put/Delete 事件。当事件处理速度 clientv3.Watcher 内部 channel)溢出,触发事件丢弃与状态覆盖

延迟突变根因

使用 go tool pprof -http=:8080 http://localhost:9999/debug/pprof/trace?seconds=30 捕获 trace 后发现:

  • watchServer.sendLoop goroutine 阻塞在 send() 调用,CPU 占用骤降;
  • watchableStore.syncWatcherswatcher.send() 耗时从 0.2ms 突增至 120ms。

关键诊断代码

// 在 watchableStore.syncWatchers 中插入采样日志
log.Printf("watcher[%d] send queue len=%d, lastRev=%d", 
    w.id, len(w.ch), w.minRev) // w.ch 是事件发送 channel

逻辑分析:len(w.ch) 持续 > 1000 表明事件积压;w.minRev 滞后当前 cluster revision 超过 5000,说明客户端已丢失中间状态,后续 Put 会直接覆盖旧值。

观测指标对比表

指标 正常值 积压突变时
etcd_debugging_mvcc_watcher_total ~120 86
etcd_network_client_grpc_received_bytes_total 1.2 MB/s 40 KB/s
etcd_disk_backend_fsync_duration_seconds > 120ms

修复路径

  • 客户端启用 WithProgressNotify() 主动感知断连;
  • 服务端调优 --max-watchers=10000--max-request-bytes=33554432
  • WatchOption 中设置 WithPrevKV() 减少重同步开销。

第三章:五层状态机设计原理与Go语言实现范式

3.1 状态跃迁契约建模:使用Go interface定义TransitionRule与StateValidator的契约驱动开发

契约驱动的核心在于解耦状态变更逻辑与具体业务实现。通过两个精简接口,明确“谁可以变”与“变成什么样”的责任边界:

接口定义与职责分离

// TransitionRule 定义状态跃迁的前置条件(如权限、时间窗)
type TransitionRule interface {
    CanTransition(from, to State) bool
    Reason() string // 失败时提供可读原因
}

// StateValidator 确保目标状态自身语义合法(如订单金额非负、库存充足)
type StateValidator interface {
    Validate(s State) error
}

CanTransition 聚焦跃迁可行性(跨状态约束),Validate 聚焦状态自洽性(单状态内禀约束)。二者正交组合,支撑可验证的状态机演进。

典型实现策略对比

实现类型 关注点 示例场景
RBACRule 角色权限校验 Editor → Publishedpublish 权限
InventoryValidator 业务规则内聚 Reserved → Shipped 前校验库存余量
graph TD
    A[StateChangeRequest] --> B{TransitionRule.CanTransition?}
    B -->|true| C{StateValidator.Validate?}
    B -->|false| D[Reject: Reason()]
    C -->|nil| E[Apply State Change]
    C -->|error| F[Reject: error]

3.2 状态持久化一致性保障:基于GORM钩子与pgx.TxManager实现状态变更与业务操作的原子提交

数据同步机制

在订单履约场景中,状态更新(如 OrderStatus → SHIPPED)必须与库存扣减、物流单创建等业务操作共处同一事务边界。GORM 的 BeforeUpdate 钩子仅触发于模型层,无法捕获跨库或外部服务调用——因此需将状态变更逻辑下沉至事务管理器。

原子提交实现

使用 pgx.TxManager 统一管控事务生命周期,结合 GORM 的 Session(&gorm.Session{NewDB: true}) 避免会话污染:

err := txManager.BeginTx(ctx, pgx.TxOptions{}, func(tx *pgx.Tx) error {
    // 1. 更新订单状态(GORM + 显式事务绑定)
    if err := db.Session(&gorm.Session{Context: ctx}).Transaction(func(tx *gorm.DB) error {
        return tx.Model(&order).Where("id = ?", order.ID).Update("status", "SHIPPED").Error
    }); err != nil {
        return err
    }
    // 2. 扣减库存(原生 pgx 查询,共享同一 *pgx.Tx)
    _, err := tx.Exec(ctx, "UPDATE inventory SET stock = stock - $1 WHERE sku = $2", 1, order.Sku)
    return err
})

逻辑分析txManager.BeginTx 返回的 *pgx.Tx 被透传至 GORM Session 和原生 SQL,确保所有操作在 PG 同一事务中执行;ctx 传递保障超时/取消信号同步;pgx.TxOptions 可配置 IsoLevel(如 pgx.ReadCommitted)。

关键保障能力对比

能力 仅用 GORM Transaction pgx.TxManager + GORM Session
跨库 DML 支持 ✅(复用 *pgx.Tx
外部服务调用编排 ✅(事务内嵌回调控制点)
隔离级别精细控制 ⚠️(依赖默认) ✅(显式 TxOptions
graph TD
    A[业务入口] --> B{BeginTx}
    B --> C[GORM 状态更新]
    B --> D[pgx 库存扣减]
    B --> E[第三方物流 API 调用]
    C & D & E --> F{全部成功?}
    F -->|是| G[Commit]
    F -->|否| H[Rollback]

3.3 状态机可观测性增强:集成OpenTelemetry tracing并注入state_id、transition_id、retry_count上下文标签

为精准追踪状态流转路径,需将业务语义注入分布式追踪上下文。核心是在状态跃迁(onTransition)和重试入口处动态注入结构化标签。

标签注入点设计

  • state_id:当前状态唯一标识(如 "ORDER_CREATED"
  • transition_id:跃迁动作ID(如 "CREATE_TO_PAID"
  • retry_count:当前重试次数(从 开始计数)

OpenTelemetry Span 增强示例

from opentelemetry import trace
from opentelemetry.trace import SpanKind

def on_transition(state_id: str, transition_id: str, retry_count: int):
    span = trace.get_current_span()
    span.set_attribute("state_id", state_id)
    span.set_attribute("transition_id", transition_id)
    span.set_attribute("retry_count", retry_count)

逻辑分析:trace.get_current_span() 获取活跃 span;set_attribute() 将字符串/整型值写入 span 的 attributes 字段,确保在 Jaeger/Grafana Tempo 中可过滤与聚合。retry_count 作为整型直接上报,避免字符串解析开销。

关键标签语义对照表

标签名 类型 示例值 用途
state_id string "PAYMENT_FAILED" 定位状态节点
transition_id string "RETRY_PAYMENT" 区分不同跃迁路径
retry_count int 2 识别幂等性异常或退避策略

追踪链路示意

graph TD
    A[StateEntry] -->|span.start| B[onTransition]
    B --> C{retry_count > 0?}
    C -->|yes| D[set_attribute retry_count]
    C -->|no| E[set_attribute retry_count=0]
    D & E --> F[Span.end]

第四章:etcd版本锁在幂等控制中的工程落地

4.1 etcd Compare-and-Swap(CAS)原语封装:构建LeaseAwareEphemeralLock结构体与自动续期机制

LeaseAwareEphemeralLock 利用 etcd 的 Txn(事务)+ Lease 实现强一致、带租约的临时锁,避免会话失效导致的脑裂。

核心设计要点

  • 锁节点路径为 /locks/{name},值存储客户端唯一 ID(如 client-uuid
  • 绑定 Lease 后,etcd 自动清理过期锁;续约通过后台 goroutine 调用 KeepAlive
  • CAS 条件:仅当键不存在(version == 0)或当前值匹配自身 ID 时才允许更新

CAS 锁获取逻辑(Go 示例)

func (l *LeaseAwareEphemeralLock) TryAcquire(ctx context.Context) error {
    txn := l.cli.Txn(ctx)
    resp, err := txn.If(
        clientv3.Compare(clientv3.Version(l.key), "=", 0), // 未被占用
    ).Then(
        clientv3.OpPut(l.key, l.id, clientv3.WithLease(l.leaseID)),
    ).Commit()
    if err != nil {
        return err
    }
    if !resp.Succeeded {
        return ErrLockAlreadyHeld
    }
    return nil
}

逻辑分析Compare(Version, "=", 0) 确保首次写入原子性;WithLease 将 key 与 lease 关联。失败时 resp.Succeeded==false,表示竞争失败。

自动续期状态机

graph TD
    A[Start KeepAlive] --> B{Lease TTL > 0?}
    B -->|Yes| C[Send KeepAlive RPC]
    B -->|No| D[Stop & Release Lock]
    C --> E[Update local TTL]
    E --> B

关键参数说明

参数 说明 典型值
leaseTTL 租约有效期(秒) 15s
keepAliveInterval 续约间隔(建议 ≤ TTL/3) 5s
sessionTimeout 客户端心跳超时阈值 10s

4.2 幂等Key空间分片策略:按商户ID哈希路由+租约TTL分级(支付类30s/退款类120s)的Go实现

为保障幂等操作在分布式环境下的强一致性,本策略将 idempotent_key 映射到固定分片,并通过租约机制防止脑裂重入。

核心路由逻辑

func ShardID(merchantID string, shardCount int) int {
    h := fnv.New64a()
    h.Write([]byte(merchantID))
    return int(h.Sum64() % uint64(shardCount))
}

使用 FNV-64a 哈希确保分布均匀性;shardCount 通常为 256 或 1024,避免热点分片。

租约TTL分级配置

业务类型 TTL(秒) 触发场景
支付 30 高频、低延迟要求
退款 120 低频、需更长重试窗口

分布式租约获取流程

graph TD
    A[客户端请求] --> B{业务类型判断}
    B -->|支付| C[申请30s租约]
    B -->|退款| D[申请120s租约]
    C & D --> E[Redis SETNX + EX]
    E --> F[成功则执行业务]

4.3 锁失效降级路径设计:当etcd集群不可用时自动切换至本地LRU+RedisProbabilisticCounter双校验方案

当 etcd 集群不可用时,分布式锁服务需保障业务连续性。系统采用三级健康探测机制(HTTP /health + gRPC Status + 心跳租约超时)触发降级。

降级决策流程

graph TD
    A[etcd健康检查失败] --> B{连续3次失败?}
    B -->|是| C[触发降级开关]
    C --> D[启用本地LRU缓存锁状态]
    C --> E[启用RedisProbabilisticCounter限流校验]

双校验协同逻辑

  • 本地LRU:容量1024,TTL=30s,仅缓存已确认的锁持有者(避免误释放)
  • RedisProbabilisticCounter:基于布隆过滤器+计数器,误差率

校验代码示例

func (d *DegradedLocker) TryLock(key string) bool {
    // 1. LRU快速命中(无锁读)
    if d.lru.Get(key) != nil {
        return true // 已持锁
    }
    // 2. Redis概率计数器校验(防穿透)
    ok, _ := d.pcounter.Increment(key, 1)
    if ok {
        d.lru.Add(key, struct{}{}, cache.WithExpiration(30*time.Second))
    }
    return ok
}

Increment 内部采用 INCRBY key 1 + EXPIRE key 30 原子组合,并在失败时回退至 SET key 1 EX 30 NXpcounterkey 经 SHA256 哈希后截取前16字节,兼顾分布性与内存开销。

4.4 版本锁性能压测对比:wrk+vegeta实测QPS 12.8K下etcd锁平均延迟

压测环境配置

  • 3节点 etcd v3.5.15(TLS启用,--quota-backend-bytes=8589934592
  • 客户端:wrk(pipeline=16) + vegeta(target rate=12800/s,duration=5m)
  • 锁路径 /locks/ordering/{shard},Lease TTL=15s(自动续期)

核心压测脚本片段

# vegeta攻击命令(含lease grant前置开销模拟)
echo "POST http://etcd:2379/v3/lease/grant" | \
  vegeta attack -rate=12800 -duration=5m -body='{"TTL":15}' | \
  vegeta report

此命令显式触发 lease 创建,确保后续 Txn 操作中 PutLeaseID 非零。-rate=12800 精确匹配目标QPS,避免突发流量导致 etcd leader 抢占抖动。

关键延迟分解(单位:ms)

阶段 P95延迟 说明
Lease Grant 8.2 含Raft日志提交与apply
Lock Acquire (Txn) 9.6 Compare-and-Swap + Watch
合计 17.8 符合标题

数据同步机制

graph TD A[Client] –>|1. Grant Lease| B[etcd Leader] B –> C[Replicate to Followers] C –> D[Apply & Return LeaseID] A –>|2. Txn with LeaseID| B B –>|3. Raft Propose Lock Key| C

第五章:总结与展望

核心成果回顾

在本项目实践中,我们完成了基于 Kubernetes 的微服务可观测性平台搭建,覆盖日志(Loki+Promtail)、指标(Prometheus+Grafana)和链路追踪(Jaeger)三大支柱。生产环境已稳定运行 147 天,平均单日采集日志量达 2.3 TB,API 请求 P95 延迟从 840ms 降至 210ms。关键改进包括:自研 Prometheus Rule 模板库(含 68 条 SLO 驱动告警规则),以及统一 OpenTelemetry Collector 配置中心,使新服务接入耗时从平均 4.5 小时压缩至 22 分钟。

真实故障复盘案例

2024 年 Q2 某电商大促期间,平台触发 http_server_duration_seconds_bucket{le="1.0"} 指标持续低于 85% 阈值告警。通过 Grafana 看板下钻发现,订单服务中 /v2/checkout 接口在 Redis 连接池耗尽后出现级联超时。根因定位路径如下:

flowchart LR
A[Prometheus 告警] --> B[Grafana 热力图定位时间窗口]
B --> C[Jaeger 追踪链路筛选慢请求]
C --> D[查看 span 标签 redis.client.address]
D --> E[确认连接池配置为 maxIdle=16]
E --> F[对比历史部署版本发现配置被覆盖]

最终通过 ConfigMap 版本回滚与 Helm hook 预检机制修复,MTTR 缩短至 11 分钟。

技术债清单与优先级

问题项 当前状态 影响范围 预估工时 依赖方
日志采集中文乱码(UTF-8-BOM) 已复现 全量 Java 服务 16h Logback 插件组
Prometheus 远程写入偶发丢点 生产偶发 监控数据完整性 24h 存储团队
Jaeger UI 不支持跨集群服务拓扑渲染 待验证 多云运维效率 32h 前端组

下一阶段落地计划

  • 在金融核心支付链路中试点 eBPF 原生网络指标采集(替换现有 cAdvisor 方案),目标降低容器网络延迟观测误差至 ±5ms 内;
  • 构建自动化 SLO 健康评分体系:基于过去 30 天 error budget 消耗率、告警响应时效、变更失败率三项加权计算,每日生成服务健康雷达图;
  • 完成 OpenTelemetry 自动注入器的 Istio 1.21+ 兼容适配,已在预发集群通过 107 个服务灰度验证,CPU 开销增幅控制在 3.2% 以内;
  • 启动 AIOps 异常检测模型训练:使用 6 个月历史指标数据(含 23 类已标注故障样本),当前在测试集上达到 91.7% 的 F1-score。

社区协作进展

已向 CNCF Prometheus 社区提交 PR #12849(增强 remote_write 重试幂等性),获 maintainer “lgtm” 评论;将 Loki 的多租户日志路由策略文档贡献至 Grafana Labs 官方知识库,被收录为 v2.9.x 版本标准实践指南。内部技术分享会累计输出 17 场,覆盖 DevOps、SRE、前端三类角色,其中《如何用 PromQL 定位 JVM GC 飙升》课件被 5 家合作企业采用为内部培训材料。

可持续演进机制

建立“观测即代码”(Observability-as-Code)CI/CD 流水线:所有监控配置变更需经 Terraform 验证 + Prometheus Rule 语法检查 + Grafana Dashboard JSON Schema 校验三道门禁;每月自动扫描未被任何告警或看板引用的指标,生成废弃建议报告并推送至对应服务 Owner 邮箱;2024 年已清理冗余指标 1,243 个,释放 Prometheus TSDB 存储空间 42TB。

擅长定位疑难杂症,用日志和 pprof 找出问题根源。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注