Posted in

电商订单超时关闭失效?Go分布式事务最终一致性方案(生产环境已验证)

第一章:电商订单超时关闭失效问题的根源剖析

电商系统中订单超时自动关闭功能看似简单,实则极易因分布式环境、时钟漂移、状态竞争与设计疏漏而失效,导致资金占用、库存锁定、风控漏洞等连锁风险。

分布式定时任务的精度陷阱

传统基于单机 Quartz 或 Linux cron 的定时扫描(如每分钟查一次 status = 'unpaid' AND create_time < NOW() - INTERVAL 30 MINUTE)在集群部署下存在严重缺陷:各节点重复执行、扫描窗口遗漏刚超时订单、数据库读写分离导致主从延迟使 NOW() 判断失准。更关键的是,MySQL 的 NOW() 函数返回的是执行节点本地系统时间,若应用服务器与数据库服务器时钟偏差 >5 秒(常见于未启用 NTP 同步的云主机),将直接导致误关或漏关。

状态更新的非原子性风险

以下伪代码揭示典型隐患:

-- ❌ 危险:先查后更,竞态条件高发
SELECT id, status FROM orders WHERE id = 12345 AND status = 'unpaid';
-- 若另一支付回调线程在此刻将 status 更新为 'paid',本事务仍会执行下方 UPDATE
UPDATE orders SET status = 'closed', closed_at = NOW() WHERE id = 12345 AND status = 'unpaid';

正确做法必须采用带状态校验的原子更新:

UPDATE orders 
SET status = 'closed', closed_at = NOW(), updated_at = NOW() 
WHERE id = 12345 
  AND status = 'unpaid' 
  AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE);
-- 检查影响行数:若返回 0,说明订单已支付或已关闭,无需告警;若返回 1,成功关闭

超时判定逻辑的隐性失效场景

场景 失效原因 推荐对策
用户修改收货地址 订单 updated_at 被更新,重置超时计时 超时依据 create_time,而非 updated_at
支付回调异步延迟 支付平台通知延迟超 30 分钟 引入幂等支付状态机,关闭前强制查支付网关最终态
数据库事务长时间挂起 SELECT ... FOR UPDATE 锁表阻塞扫描 使用无锁乐观更新 + 版本号机制,避免长事务依赖

根本解决路径在于放弃“轮询+时间戳判断”范式,转向事件驱动架构:订单创建时投递延时消息(如 RabbitMQ TTL + DLX,或 RocketMQ 定时消息),由消息触发精准关闭动作,彻底规避时钟与状态一致性难题。

第二章:Go语言分布式事务理论与实践基础

2.1 分布式事务模型对比:XA、TCC、Saga与本地消息表

分布式事务演进本质是一致性与可用性权衡的具象化。早期XA强一致但阻塞严重;TCC通过业务补偿解耦资源锁定;Saga以长事务链支持最终一致;本地消息表则用异步可靠投递规避跨库事务。

核心特性对比

模型 一致性级别 实现复杂度 回滚机制 典型适用场景
XA 强一致 低(框架级) 两阶段提交回滚 传统金融核心系统
TCC 最终一致 高(需编码Try/Confirm/Cancel) 业务自定义补偿 高并发电商订单履约
Saga 最终一致 中(需编排或 Choreography) 反向服务调用 微服务间跨域流程
本地消息表 最终一致 中(需消息表+定时任务) 消息重试+死信处理 异步解耦型通知类业务

Saga执行流程示意

graph TD
    A[Order Created] --> B[Try: Reserve Inventory]
    B --> C[Try: Deduct Wallet]
    C --> D[Confirm: Ship Goods]
    D --> E[Confirm: Notify Logistics]
    B -.-> F[Cancel: Release Inventory]
    C -.-> G[Cancel: Refund Wallet]

TCC Confirm 示例(Spring Cloud)

@Compensable(confirmMethod = "confirmDeduct", cancelMethod = "cancelDeduct")
public void tryDeduct(String userId, BigDecimal amount) {
    // 扣减预占余额,非真实扣款
    jdbcTemplate.update("UPDATE wallet SET frozen_balance = frozen_balance + ? WHERE user_id = ?", 
                        amount, userId); // 参数:amount=待冻结金额,userId=业务主键
}
// 逻辑分析:try阶段仅更新frozen_balance字段,避免锁真实余额;confirm时原子更新available_balance并清空frozen_balance;cancel则释放冻结额。所有操作均在本地事务内完成,不依赖全局协调器。

2.2 Go生态主流事务框架选型分析(Dtm、Seata-Go、Asynq+Redis Streams)

核心能力对比

框架 分布式事务模式 Go原生支持 运维复杂度 适用场景
Dtm TCC/Saga/XA/Msg ✅ 低 高一致性金融级业务
Seata-Go AT/TCC/Saga ⚠️(适配层) 中 多语言混合架构迁移场景
Asynq+Redis Streams 最终一致性(事件驱动) ✅ 低 异步解耦、高吞吐后台任务

Dtm Saga事务示例

// 定义Saga事务:扣库存 → 创建订单 → 发送通知
saga := dtmcli.NewSaga(dtmServer, dtmcli.MustGenGid(dtmServer)).
    Add("http://stock-service/Reduce", "http://stock-service/RevertReduce", map[string]interface{}{"goodsId": 1001, "num": 1}).
    Add("http://order-service/Create", "http://order-service/RevertCreate", map[string]interface{}{"userId": 123, "amount": 99.9}).
    Add("http://notify-service/Send", "http://notify-service/CancelSend", map[string]interface{}{"orderId": "OID123"})
err := saga.Submit()

该代码声明式编排补偿链路,Submit() 触发正向执行;失败时自动按逆序调用各Revert*接口。dtmcli.MustGenGid 生成全局唯一事务ID,确保幂等与可追溯性。

数据同步机制

graph TD
A[业务服务] –>|HTTP请求| B[Dtm Server]
B –> C[协调状态持久化到MySQL]
C –> D[并发调用各子事务]
D –> E{任一失败?}
E –>|是| F[触发补偿链路]
E –>|否| G[标记Saga成功]

2.3 基于TimeWheel+Redis ZSet的高精度订单超时调度实现

传统定时轮(TimeWheel)在单机场景下具备 O(1) 插入/到期检测优势,但无法应对分布式订单系统中节点故障与水平扩展需求。为此,采用 本地时间轮 + Redis ZSet 协同调度 架构:ZSet 存储 <订单ID, 过期时间戳>,作为全局可靠的时间索引;本地 TimeWheel 仅缓存未来 30 秒内即将到期的订单,减少 Redis 频繁轮询。

核心协同机制

  • 定时任务每 100ms 扫描 ZSet 中 score ≤ now 的订单(ZRANGEBYSCORE order:zset -inf now WITHSCORES LIMIT 0 100)
  • 成功获取后通过 Lua 脚本原子性 ZREM 并触发超时处理
  • 新订单写入时,同时写入 ZSet 并根据剩余时间插入本地 TimeWheel 槽位
-- Lua 脚本:安全弹出并删除已到期订单
local orders = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'WITHSCORES', 'LIMIT', 0, 100)
if #orders > 0 then
  redis.call('ZREM', KEYS[1], unpack(orders))
end
return orders

该脚本确保“读取-删除”原子性,避免多节点重复消费;ARGV[1] 为当前毫秒时间戳,KEYS[1] 为 ZSet key(如 order:timeout:zset)。

数据同步机制

  • 本地 TimeWheel 槽位更新由 ZSet 扫描结果驱动,无主动同步开销
  • 故障节点重启后,通过全量 ZSet 扫描自动恢复待处理队列
组件 职责 精度保障
Redis ZSet 全局持久化、去重、容错 毫秒级 score 支持
本地 TimeWheel 内存加速、降低 Redis QPS 槽粒度可配(默认 50ms)
graph TD
  A[新订单创建] --> B[写入 Redis ZSet<br>score=expireAt]
  A --> C[计算 slot = (expireAt - now) / 50ms]
  C --> D[插入本地 TimeWheel 对应槽]
  E[每100ms定时扫描] --> F[执行 Lua 获取并移除到期订单]
  F --> G[触发超时业务逻辑]

2.4 Go并发安全的订单状态机设计与原子状态跃迁实践

订单状态机需在高并发下单(如秒杀)中杜绝竞态,确保 Created → Paid → Shipped → Completed 跃迁严格有序且不可逆。

原子状态跃迁核心结构

type Order struct {
    ID     string
    status int32 // 使用atomic操作,避免锁
    mu     sync.RWMutex
}

func (o *Order) Transition(from, to int32) bool {
    return atomic.CompareAndSwapInt32(&o.status, from, to)
}

atomic.CompareAndSwapInt32 保证状态更新的原子性:仅当当前值为 from 时才设为 to,失败即返回 false,天然支持乐观并发控制。

合法状态迁移表

当前状态 允许目标状态 是否可逆
Created Paid 否
Paid Shipped 否
Shipped Completed 否

状态校验流程

graph TD
    A[收到支付回调] --> B{Current == Paid?}
    B -->|Yes| C[拒绝重复处理]
    B -->|No| D{Transition Created→Paid?}
    D -->|Success| E[持久化+发MQ]
    D -->|Fail| F[重查DB确认终态]

2.5 分布式锁在订单关闭幂等性保障中的精细化应用(Redlock vs Redisson vs Etcd)

订单关闭需严格保证单次生效、多次调用不重复执行,分布式锁是核心防线。三类实现路径在可靠性、性能与运维成本上差异显著。

锁语义与场景适配

  • Redlock:多Redis实例仲裁,理论强一致性,但时钟漂移易致脑裂
  • Redisson:基于Netty的成熟封装,支持看门狗自动续期与联锁(MultiLock)
  • Etcd:通过CompareAndSwap+租约(Lease)提供线性一致性,天然规避时钟依赖

典型关闭流程(Redisson 示例)

RLock lock = redisson.getLock("order:close:" + orderId);
try {
    // 等待最多3s,锁自动续期30s(watchdog默认)
    if (lock.tryLock(3, 30, TimeUnit.SECONDS)) {
        if (orderService.isClosed(orderId)) return; // 幂等校验前置
        orderService.closeOrder(orderId);
    }
} finally {
    if (lock.isHeldByCurrentThread()) lock.unlock();
}

tryLock(3, 30, ...):阻塞3秒获取锁,成功后自动以30秒为周期续期;避免因GC或网络延迟导致误释放。

方案对比

维度 Redlock Redisson Etcd
一致性模型 弱(异步复制) 弱(主从同步) 强(Raft线性一致)
故障恢复 需人工干预 自动重连+重试 租约自动过期
graph TD
    A[订单关闭请求] --> B{查DB状态}
    B -- 已关闭 --> C[直接返回]
    B -- 未关闭 --> D[尝试获取分布式锁]
    D -- 成功 --> E[执行关闭逻辑]
    D -- 失败 --> F[降级重试/限流]
    E --> G[更新DB+发MQ]

第三章:最终一致性方案的核心架构设计

3.1 订单域事件驱动架构(EDA)建模与CQRS分离实践

订单创建、支付成功、发货完成等关键业务动作天然具备时序性与因果性,适合建模为领域事件。采用事件溯源(Event Sourcing)持久化订单状态变更,配合 CQRS 实现读写分离。

事件建模示例

// OrderPlacedEvent:订单创建事件,含幂等ID与快照元数据
public record OrderPlacedEvent(
    UUID orderId, 
    String userId, 
    BigDecimal totalAmount,
    Instant occurredAt,
    String idempotencyKey // 防重核心参数
) implements DomainEvent {}

逻辑分析:idempotencyKey 由客户端生成并贯穿全链路,确保上游重复提交仅触发一次状态更新;occurredAt 用于事件时间窗口计算与最终一致性对账。

CQRS 查询模型同步策略

同步方式 延迟 一致性模型 适用场景
直接数据库监听 弱一致 订单列表页
Kafka + CDC ~500ms 最终一致 用户订单详情页
批量物化视图 5min+ 会话一致 运营报表统计

事件流拓扑

graph TD
    A[OrderService] -->|OrderPlacedEvent| B[Kafka Topic]
    B --> C{Event Processor}
    C --> D[Write DB: OrderAggregate]
    C --> E[Update Read Model: order_view]
    C --> F[Trigger Notification]

3.2 可回溯的补偿事务链路设计:正向操作与逆向补偿的Go泛型封装

核心抽象:CompensableStep

通过泛型接口统一正向执行与逆向补偿行为:

type CompensableStep[T any] interface {
    Execute(ctx context.Context, input T) (T, error)
    Compensate(ctx context.Context, input T) error
}
  • Execute 执行业务逻辑,返回更新后的状态(如订单ID、库存版本号),供后续步骤或补偿使用;
  • Compensate 接收相同类型输入,确保幂等性与状态一致性;
  • 泛型 T 支持任意上下文载体(如 struct{ OrderID string; Version int }),避免运行时类型断言。

补偿链路编排示例

graph TD
    A[CreateOrder] --> B[ReduceInventory]
    B --> C[NotifyPayment]
    C -.->|失败| Cc[NotifyPayment.Compensate]
    Cc --> Bc[ReduceInventory.Compensate]
    Bc --> Ac[CreateOrder.Compensate]

关键设计约束

约束项 说明
幂等性保障 Compensate 必须支持重复调用不变更终态
上下文透传 每步输出作为下一步输入,形成可追溯链路
错误隔离 单步失败不阻塞补偿触发,依赖最终一致性

3.3 基于Kafka事务性生产者与Exactly-Once语义的事件可靠投递

为什么需要Exactly-Once?

传统At-Least-Once语义易导致重复消费,而Kafka 0.11+引入事务性生产者(Transactional Producer),配合幂等性与消费者isolation.level=read_committed,实现端到端精确一次投递。

核心机制依赖

  • 启用enable.idempotence=true
  • 配置唯一transactional.id
  • 显式调用initTransactions()、beginTransaction()、commitTransaction()/abortTransaction()

示例:事务性写入代码

producer.initTransactions();
try {
    producer.beginTransaction();
    producer.send(new ProducerRecord<>("orders", "key", "value"));
    producer.send(new ProducerRecord<>("notifications", "key", "alert"));
    producer.commitTransaction(); // 仅当全部成功才提交
} catch (Exception e) {
    producer.abortTransaction(); // 原子回滚
}

逻辑分析:transactional.id绑定Producer实例与事务协调器(TC),Kafka Broker通过__transaction_state内部主题持久化事务状态;commitTransaction()触发两阶段提交(2PC),确保跨分区写入原子性。参数max.in.flight.requests.per.connection=1与retries=Integer.MAX_VALUE为幂等前提。

EOS保障关键配置对比

配置项 推荐值 作用
enable.idempotence true 启用幂等写入,防止Broker端重复追加
isolation.level read_committed 消费者跳过未提交事务消息
transaction.timeout.ms ≤ max.poll.interval.ms 防止事务超时被自动中止

端到端流程示意

graph TD
    A[Producer initTransactions] --> B[beginTransaction]
    B --> C[send to multiple partitions]
    C --> D{all ack?}
    D -->|Yes| E[commitTransaction]
    D -->|No| F[abortTransaction]
    E --> G[TC写入__transaction_state]
    F --> G

第四章:生产级订单超时闭环系统落地实战

4.1 订单超时检测服务:Go+gRPC微服务化部署与水平扩缩容策略

架构设计原则

采用事件驱动 + 周期轮询双模机制,兼顾实时性与资源可控性。服务通过 gRPC 接口暴露 CheckTimeout 方法,支持高并发订单状态探查。

核心服务定义(proto)

service TimeoutChecker {
  rpc CheckTimeout(TimeoutRequest) returns (TimeoutResponse);
}
message TimeoutRequest {
  string order_id = 1;     // 必填,全局唯一订单标识
  int64 created_at = 2;   // Unix 时间戳(毫秒),用于超时阈值计算
}

该定义轻量、无状态,便于横向扩展;created_at 由上游服务注入,避免时钟漂移依赖。

水平扩缩容策略

扩缩指标 阈值 动作
CPU 使用率 >70% 自动扩容 1 实例
gRPC 请求延迟 P95 >200ms 触发熔断+扩容
待检订单积压量 >5000 启动预扩容缓冲池

流程协同示意

graph TD
  A[订单创建] --> B[写入Redis有序集]
  B --> C[TimeoutChecker轮询ZSET]
  C --> D{超时判定}
  D -->|是| E[调用OrderService.Cancel]
  D -->|否| F[更新TTL并继续监听]

4.2 多阶段关闭流水线:库存回滚、优惠券释放、支付单撤回的协同编排

在高并发订单取消场景中,原子性保障不能依赖单一事务,需通过状态机驱动的分布式协同完成最终一致性。

核心协同流程

graph TD
    A[订单取消请求] --> B{状态校验}
    B -->|可取消| C[冻结库存回滚]
    B -->|已核销| D[跳过优惠券释放]
    C --> E[释放未使用的优惠券]
    E --> F[调用支付网关撤回预授权]
    F --> G[持久化终态:CANCELLED]

关键参数与幂等控制

  • cancelId:全局唯一取消操作ID,作为所有子任务的traceKey
  • version:乐观锁版本号,防止并发重复执行
  • timeoutMs=15000:各阶段超时阈值,避免长事务阻塞

数据同步机制

步骤 数据源 同步方式 一致性保障
库存回滚 Redis + MySQL 双写+补偿任务 TCC模式预留扣减量
优惠券释放 CouponDB 消息队列异步 幂等消费+本地事务表
def rollback_inventory(order_id: str, items: List[dict]):
    # items: [{"sku_id": "S1001", "quantity": 2, "reserve_id": "r_789"}]
    for item in items:
        # 基于reserve_id精准回滚,避免超量返还
        redis.decr(f"stock:{item['sku_id']}:reserved", item['quantity'])
        mysql.execute("UPDATE inventory SET reserved = reserved - %s WHERE sku_id = %s AND reserve_id = %s",
                      (item['quantity'], item['sku_id'], item['reserve_id']))

该函数确保库存回滚精确到原始预留动作;reserve_id隔离不同订单的扣减上下文,decr与SQL更新通过本地事务+Redis Lua脚本保证原子性。

4.3 全链路可观测性建设:OpenTelemetry集成、关键路径埋点与熔断阈值配置

OpenTelemetry SDK 自动注入配置

在 Spring Boot 3.x 应用中,通过 JVM Agent 方式启用自动仪器化:

# 启动参数注入 OpenTelemetry Java Agent
-javaagent:/opt/otel/opentelemetry-javaagent.jar \
-Dotel.exporter.otlp.endpoint=http://collector:4317 \
-Dotel.resource.attributes=service.name=order-service,environment=prod

该配置启用 HTTP、gRPC、JDBC 等默认插件,无需修改业务代码即可采集 span;service.name 是服务发现与拓扑聚合的关键标签。

关键路径手动埋点示例

对订单创建核心链路添加语义化 span:

@WithSpan
public Order createOrder(@SpanAttribute("user.id") String userId) {
  Span.current().setAttribute("order.amount", 299.99);
  // ... 业务逻辑
}

@WithSpan 触发新 span 创建,@SpanAttribute 将方法参数自动注入 span 属性,便于按用户 ID 下钻分析。

熔断阈值联动配置(Sentinel + OTel)

指标类型 阈值 触发动作 数据来源
P99 延迟 >800ms 降级 fallback OTel Metrics
错误率 >5% 熔断 30s OTel Logs + Traces
并发请求数 >200 拒绝新请求 Sentinel 实时统计
graph TD
  A[HTTP 请求] --> B[OTel 自动拦截]
  B --> C[生成 TraceID/Context]
  C --> D[关键方法 @WithSpan 埋点]
  D --> E[Metrics 上报至 Prometheus]
  E --> F[Prometheus AlertManager 触发阈值告警]
  F --> G[Sentinel 动态更新熔断规则]

4.4 灰度发布与故障注入:基于Chaos Mesh验证超时关闭SLA达标率

在微服务架构中,灰度发布需与可观测性深度耦合。我们通过 Chaos Mesh 注入网络延迟与 Pod 故障,模拟下游服务超时场景:

# chaos-mesh-delay.yaml:注入 3s 网络延迟(95% 分位超时阈值)
apiVersion: chaos-mesh.org/v1alpha1
kind: NetworkChaos
metadata:
  name: service-timeout-test
spec:
  action: delay
  mode: one
  selector:
    namespaces: ["prod"]
    labelSelectors:
      app: payment-service
  delay:
    latency: "3000ms"
    correlation: "0"

该配置精准模拟支付服务调用超时,触发上游熔断逻辑;latency 对齐 SLA 中定义的 P95 响应时间上限,correlation=0 确保延迟独立分布,避免误判抖动。

验证闭环流程

  • 每次灰度批次部署后自动触发 Chaos Experiment
  • Prometheus 抓取 http_request_duration_seconds_bucket{le="3"} 指标
  • Grafana 看板实时计算 SLA 达标率(成功请求 / 总请求)
指标 正常态 注入后 SLA 要求
P95 延迟 1200ms 3150ms ≤3000ms
请求成功率 99.98% 98.2% ≥99.5%
graph TD
  A[灰度发布] --> B[Chaos Mesh 启动实验]
  B --> C[采集延迟/成功率指标]
  C --> D{SLA 达标率 ≥99.5%?}
  D -- 是 --> E[自动推进下一灰度批次]
  D -- 否 --> F[回滚并告警]

第五章:方案演进与行业最佳实践总结

多阶段架构迁移的真实路径

某头部券商在2021–2023年完成核心交易系统从单体Java应用向云原生微服务的演进,分三阶段实施:第一阶段(6个月)剥离行情订阅模块,采用Sidecar模式接入Service Mesh;第二阶段(9个月)将订单路由引擎容器化并部署至Kubernetes集群,通过Istio实现灰度发布与熔断策略;第三阶段(12个月)重构风控引擎为事件驱动架构,基于Apache Flink实时计算持仓风险,吞吐量从800 TPS提升至4200 TPS。关键决策点在于保留原有Oracle RAC作为最终一致性底座,而非激进替换为分布式数据库。

混合云治理的落地约束条件

企业实际采用混合云时面临以下硬性约束:

约束类型 典型表现 应对方案
数据主权 金融客户要求交易日志必须本地留存 采用KubeEdge边缘节点+中心集群双写审计日志
合规审计 PCI-DSS要求支付链路全程TLS 1.3+双向认证 在Envoy代理层注入mTLS策略,证书由HashiCorp Vault动态签发
成本敏感 跨AZ流量费用占云支出37% 通过eBPF程序在内核态拦截跨AZ调用,自动降级为本地缓存兜底

开源组件选型的失败复盘

某电商中台项目曾选用Consul作为服务发现组件,上线后出现大规模健康检查风暴:

  • 问题根源:默认配置下每秒向所有节点发送TCP心跳,500+服务实例导致控制平面CPU持续92%;
  • 解决路径:
    # 替换为轻量级替代方案
    helm install nacos nacos/nacos --set mode=standalone \
    --set mysql.enabled=false \
    --set service.type=ClusterIP

    同时将健康检查协议从TCP改为HTTP GET /actuator/health,并启用Consul的passing_only查询参数过滤异常节点。

可观测性体系的渐进式建设

某IoT平台采用分层采集策略:

  • 边缘设备层:Telegraf采集传感器原始指标(温度、电压、上报延迟),通过MQTT压缩传输;
  • 网关层:OpenTelemetry Collector进行采样(固定1:100)、标签注入(device_id、firmware_version);
  • 中心层:Prometheus联邦抓取关键指标,Jaeger追踪端到端请求链路,Grafana看板按地域维度聚合告警。
    该方案使平均故障定位时间从47分钟缩短至6.3分钟。
flowchart LR
A[设备端原始数据] --> B[Telegraf MQTT上传]
B --> C[OTel Collector采样]
C --> D[Prometheus联邦存储]
C --> E[Jaeger链路追踪]
D --> F[Grafana多维分析]
E --> F
F --> G[自动触发Ansible修复剧本]

生产环境混沌工程实施清单

  • 注入故障类型:网络延迟(tc netem)、磁盘满(fallocate -l 100G /var/log/full)、DNS劫持(CoreDNS ConfigMap篡改);
  • 执行窗口:每周二凌晨2:00–3:00,持续时间≤30分钟;
  • 防御机制:自动终止条件包括API错误率>5%持续60秒、P99延迟>2s持续120秒;
  • 验证指标:订单创建成功率、库存扣减一致性、退款流水完整性。

某次模拟数据库主节点宕机时,发现订单服务未触发熔断器降级,暴露出Hystrix配置中execution.isolation.thread.timeoutInMilliseconds被误设为30000毫秒,经调整为2000毫秒后恢复预期行为。

对 Go 语言充满热情,坚信它是未来的主流语言之一。

发表回复

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