Posted in

IM消息撤回功能如何保证原子性?Go语言实现MySQL+Redis双写一致性的5种方案与最终落地选择

第一章:IM消息撤回功能的原子性挑战与本质剖析

即时通讯系统中的“消息撤回”看似是单点操作,实则横跨客户端、网关、存储层与对端同步链路,其本质是一次分布式状态协同——既要确保原始消息在所有可见路径中被逻辑抹除,又要维持会话时序一致性与用户感知一致性。这种多副本、多状态、异步传播的场景,天然与ACID中的原子性(Atomicity)形成张力。

撤回操作的分布式边界

一次撤回请求通常需完成以下关键动作:

  • 客户端向服务端提交撤回指令(含消息ID、撤回时间戳、发起方身份)
  • 服务端校验撤回时效性(如微信限制2分钟内)、权限及消息存在性
  • 存储层将原消息标记为 retracted: true(非物理删除),并写入撤回事件日志
  • 推送服务向所有在线会话成员广播撤回通知(含原消息ID与新状态)
  • 离线设备在下次同步时拉取增量状态变更,而非重传原始消息体

原子性断裂的典型场景

场景 失败点 后果
网关收到撤回请求但未成功写入日志 存储层无记录 对端仍可见原消息
撤回状态已落库,但推送服务崩溃未广播 状态不一致 发起方看到“已撤回”,接收方仍见原文
接收方在撤回通知到达前已缓存并渲染消息 客户端状态滞后 出现“消息闪现后消失”的视觉撕裂

保障最终一致性的工程实践

服务端应采用“两阶段状态更新”模式:

# 示例:撤回事务核心逻辑(伪代码)
def retract_message(msg_id, user_id, timestamp):
    # 阶段1:预占位 + 日志持久化(强一致性要求)
    if not db.update("messages", 
                     {"id": msg_id}, 
                     {"status": "pending_retract", "retract_ts": timestamp}):
        raise RuntimeError("Pre-retract update failed")

    # 写入不可变撤回事件(用于补偿与审计)
    event = {"type": "retract", "msg_id": msg_id, "by": user_id, "ts": timestamp}
    kafka_produce("im_events", key=msg_id, value=json.dumps(event))  # 至少一次语义

    # 阶段2:异步广播(失败可重试,依赖事件溯源)
    fire_retract_broadcast.delay(msg_id)  # Celery任务,带指数退避重试

该设计将强一致性约束收敛于数据库预更新与事件写入,其余环节通过幂等广播与客户端状态合并实现最终一致。

第二章:MySQL+Redis双写一致性理论基石与Go语言实践约束

2.1 分布式系统CAP与BASE理论在IM撤回场景下的映射分析

在IM消息撤回场景中,用户发起“撤回ID=abc123”请求后,系统需在多端(手机、PC、Web)达成状态一致,但网络分区与延迟使强一致性难以保障。

撤回操作的CAP权衡本质

  • C(一致性):所有设备立即显示“该消息已撤回”——需同步阻塞写入,牺牲可用性;
  • A(可用性):允许本地先响应“已撤回”,异步同步——但存在短暂不一致窗口;
  • P(分区容忍):当移动端离线时,撤回指令必须持久化并最终投递。

BASE理论的落地实践

采用最终一致性模型:

  • Basically Available:撤回接口始终返回成功(HTTP 200),不因DB主从延迟拒绝;
  • Soft state:消息状态字段 revoke_status ENUM('pending', 'applied', 'failed') 支持中间态;
  • Eventual consistency:依赖MQ重试+幂等消费保障全端收敛。
-- 撤回状态表(含版本号与过期时间,支持乐观并发控制)
CREATE TABLE msg_revoke_log (
  id BIGINT PRIMARY KEY,
  msg_id CHAR(16) NOT NULL,      -- 原消息唯一标识
  operator_uid BIGINT NOT NULL,  -- 操作者ID
  version INT DEFAULT 0,         -- 并发控制版本号
  expires_at DATETIME,           -- 撤回指令有效期(防重复投递)
  created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

逻辑说明:version 防止并发撤回覆盖;expires_at 结合TTL机制避免僵尸指令重放;msg_id 作为分布式事务的幂等键,确保多端消费同一撤回事件时状态收敛。

理论维度 IM撤回典型表现 技术手段
CAP-C 强一致撤回(金融级IM) 2PC + 同步复制 + 全链路锁
CAP-A 秒级可见撤回(社交IM主流) 异步MQ + 客户端兜底UI提示
BASE “撤回中…” → “已撤回”渐进态 状态机 + 本地缓存 + 轮询补偿
graph TD
  A[用户点击撤回] --> B[写入revoke_log with version=1]
  B --> C{DB主库写入成功?}
  C -->|Yes| D[推送MQ: revoke_event msg_id=abc123]
  C -->|No| E[降级为本地草稿撤回+告警]
  D --> F[各端消费者幂等更新UI]

2.2 Go语言并发模型(goroutine+channel)对双写事务边界的天然影响

Go 的 goroutine 轻量级并发与 channel 同步机制,天然消解了传统双写事务中“原子性边界”的强依赖。

数据同步机制

双写场景下,DB + Cache 更新常因失败导致不一致。Go 中可通过 channel 协调写入顺序:

// 向 channel 发送待写入的键值对,由单一 goroutine 串行处理
writeCh := make(chan KeyValue, 10)
go func() {
    for kv := range writeCh {
        db.Write(kv.Key, kv.Value)   // 先 DB
        cache.Set(kv.Key, kv.Value)  // 后 Cache
    }
}()

该模式将“双写”收束为单 goroutine 的线性执行流,规避竞态,隐式建立事务边界。

并发控制对比

方式 事务边界显式性 故障隔离能力 实现复杂度
数据库两阶段提交 弱(跨服务)
Go channel 串行化 隐式(逻辑边界) 强(goroutine 级)
graph TD
    A[业务请求] --> B{启动 goroutine}
    B --> C[send to writeCh]
    C --> D[单 goroutine 消费]
    D --> E[DB 写入]
    D --> F[Cache 写入]

2.3 MySQL事务隔离级别与Redis原子操作的协同失效边界实测

数据同步机制

当MySQL(READ-COMMITTED)与Redis(INCR/SETNX)混合使用时,幻读+缓存击穿可导致库存超卖。典型场景:秒杀下单前查DB库存、写Redis计数器、再扣减DB——中间无强一致性保障。

失效复现代码

-- Session A(MySQL)
START TRANSACTION;
SELECT stock FROM items WHERE id = 1; -- 返回 10
-- 此刻Session B已扣减DB并更新Redis,但A尚未提交
UPDATE items SET stock = 9 WHERE id = 1 AND stock >= 1;
COMMIT;

逻辑分析:MySQL RC隔离级不阻止幻读;Redis原子操作无法感知未提交事务状态;stock >= 1校验在读取后发生,存在竞态窗口。参数 innodb_lock_wait_timeout=50 加剧阻塞可见性。

协同失效边界对比

场景 MySQL隔离级 Redis操作 是否超卖
高并发扣减 READ-COMMITTED INCR + GET ✅ 是
带CAS校验的写入 REPEATABLE-READ SETNX + Lua脚本 ❌ 否

根本约束流程

graph TD
    A[应用发起扣减] --> B{MySQL SELECT stock}
    B --> C[Redis INCR counter]
    C --> D[MySQL UPDATE stock WHERE stock > 0]
    D --> E{DB行锁是否生效?}
    E -->|否| F[超卖发生]
    E -->|是| G[最终一致]

2.4 消息撤回状态机建模:从「已发送→已撤回→已同步」的Go结构体定义与状态流转验证

状态枚举与结构体定义

type RecallStatus int

const (
    StatusSent     RecallStatus = iota // 已发送(初始态)
    StatusRecalled                     // 已撤回(服务端生效)
    StatusSynced                       // 已同步(客户端确认完成)
)

type MessageRecall struct {
    ID        string       `json:"id"`
    Status    RecallStatus `json:"status"`
    Timestamp int64        `json:"timestamp"`
    // 可选:撤回操作人、同步目标设备列表等扩展字段
}

RecallStatus 使用 iota 实现类型安全的状态枚举;MessageRecall 封装唯一标识、当前状态及时间戳,为状态跃迁提供上下文基础。

合法状态流转规则

当前状态 允许转入状态 触发条件
StatusSent StatusRecalled 服务端收到撤回请求且未过期
StatusRecalled StatusSynced 所有目标终端ACK上报完成

状态迁移验证逻辑

func (m *MessageRecall) Transition(to RecallStatus) error {
    switch m.Status {
    case StatusSent:
        if to == StatusRecalled {
            m.Status, m.Timestamp = to, time.Now().Unix()
            return nil
        }
    case StatusRecalled:
        if to == StatusSynced {
            m.Status, m.Timestamp = to, time.Now().Unix()
            return nil
        }
    }
    return fmt.Errorf("invalid transition: %v → %v", m.Status, to)
}

该方法强制单向、非循环迁移,避免非法状态(如 StatusSynced → StatusRecalled),保障一致性。

状态机流程可视化

graph TD
    A[StatusSent] -->|撤回请求成功| B[StatusRecalled]
    B -->|全端同步完成| C[StatusSynced]

2.5 基于Go testbench的双写异常注入框架:模拟网络分区、Redis宕机、MySQL主从延迟场景

数据同步机制

双写场景下,应用需同时更新 MySQL(主库)与 Redis 缓存。一致性依赖写顺序与下游可用性,异常时易产生脏读或丢失更新。

异常注入设计

框架基于 testing.TB 扩展,通过 testbench.Inject() 动态启用故障策略:

// 注入 Redis 网络分区(连接超时)
testbench.Inject("redis", testbench.NetworkPartition{
    Timeout: 3 * time.Second,
    RejectAll: true,
})

逻辑分析:RejectAll=true 拦截所有 net.Dial 调用,模拟 DNS 不可达或防火墙阻断;Timeout 触发客户端重试退避,暴露应用熔断逻辑。

支持的故障类型

故障类型 触发方式 影响面
网络分区 TCP 连接拦截 + 延迟注入 Redis/MySQL 连接失败
Redis 宕机 redis-server 进程 kill 缓存层不可用
MySQL 主从延迟 pt-heartbeat 注入 lag 从库读取陈旧数据

故障编排流程

graph TD
    A[启动双写服务] --> B[注入异常策略]
    B --> C{执行业务请求}
    C --> D[捕获panic/timeout/log]
    D --> E[验证数据一致性断言]

第三章:五种双写一致性方案的Go实现对比与选型依据

3.1 方案一:强一致同步双写(MySQL事务内嵌Redis Pipeline)的Go实现与性能压测

数据同步机制

在 MySQL 事务提交前,通过 redis.Pipeline() 批量写入 Redis,确保二者原子性——若 Redis 写入失败,MySQL 事务回滚。

Go 核心实现

func writeWithPipeline(tx *sql.Tx, redisClient *redis.Client, userID int, data string) error {
    pipe := redisClient.Pipeline()
    pipe.Set(ctx, fmt.Sprintf("user:%d", userID), data, 24*time.Hour)
    pipe.Incr(ctx, "user:counter")
    _, err := pipe.Exec(ctx) // 非阻塞聚合,但需显式触发执行
    if err != nil {
        return err // 触发 tx.Rollback()
    }
    _, err = tx.Exec("INSERT INTO users (id, name) VALUES (?, ?)", userID, data)
    return err
}

pipe.Exec() 是关键同步点:它原子性地发送所有命令并等待全部响应;ctx 控制超时,建议设为 500ms 防止 Redis 延迟拖垮 DB 事务。

压测对比(QPS)

并发数 MySQL 单写 双写(Pipeline) 吞吐下降
100 8,200 6,150 -25%
500 9,400 5,800 -38%

执行时序(mermaid)

graph TD
    A[Start Tx] --> B[Prepare Redis Pipeline]
    B --> C[Exec Pipeline]
    C --> D{Success?}
    D -->|Yes| E[Exec MySQL INSERT]
    D -->|No| F[Rollback Tx]
    E --> G[Commit Tx]
    F --> G

3.2 方案二:最终一致异步双写(基于Go Worker Pool的可靠消息队列中转)

数据同步机制

核心思想:业务写主库后,通过可靠投递+幂等消费解耦双写,容忍短暂不一致,保障终态一致。

架构组件

  • Go Worker Pool:控制并发消费速率,防消息积压打垮下游
  • Redis Stream:持久化消息队列,支持ACK与重试
  • 消费者组:自动负载均衡与故障转移

关键代码片段

// 启动固定容量Worker Pool处理Redis Stream消息
func StartConsumerPool(poolSize int, streamKey string) {
    pool := make(chan struct{}, poolSize)
    for i := 0; i < poolSize; i++ {
        go func() {
            for msg := range readStream(streamKey) { // 阻塞拉取
                pool <- struct{}{} // 获取令牌
                go func(m redis.XMessage) {
                    defer func() { <-pool }() // 归还令牌
                    if err := writeToSecondaryDB(m); err == nil {
                        ackStream(msg.ID) // 成功则ACK
                    }
                }(msg)
            }
        }()
    }
}

poolSize 控制最大并发消费数,避免下游数据库连接耗尽;ackStream() 确保至少一次投递;readStream() 封装XREADGROUP阻塞读,支持消费者组自动偏移管理。

可靠性保障对比

特性 直连双写 本方案
故障隔离 ❌ 级联失败 ✅ 消息暂存缓冲
一致性模型 强一致 最终一致(秒级延迟)
运维可观测性 ✅ Stream长度/ACK延迟监控
graph TD
    A[业务请求] --> B[写入MySQL主库]
    B --> C[发送消息至Redis Stream]
    C --> D{Worker Pool}
    D --> E[消费并写入ES/从库]
    E --> F[ACK确认]
    F --> G[消息归档/监控]

3.3 方案三:读时修复策略(Go middleware层拦截+Redis Lua脚本兜底校验)

该方案在请求链路关键节点实施轻量级一致性保障:HTTP middleware 拦截读请求,对高风险字段(如 user_balance)触发实时校验;若缓存缺失或版本不一致,则调用原子化 Lua 脚本完成「查DB→比对→回填」闭环。

数据同步机制

  • 中间件仅校验读热点字段,避免全量比对开销
  • Lua 脚本封装 DB 查询与缓存更新逻辑,保证原子性
  • 引入 cache_version 字段实现乐观校验

Lua 校验脚本示例

-- KEYS[1]: cache_key, ARGV[1]: db_version, ARGV[2]: db_value
local cache = redis.call("HGETALL", KEYS[1])
if #cache == 0 or tonumber(cache[2]) < tonumber(ARGV[1]) then
    redis.call("HMSET", KEYS[1], "value", ARGV[2], "version", ARGV[1])
    redis.call("EXPIRE", KEYS[1], 3600)
end
return cache

脚本接收缓存键、DB最新版本号与值;仅当缓存为空或版本落后时才更新,避免写放大。HGETALL 返回字段数组({field1,val1,field2,val2}),索引 2 对应 version 字段。

执行流程

graph TD
    A[HTTP Request] --> B[Go Middleware]
    B --> C{cache exists?}
    C -->|No/Outdated| D[Lua Script: DB Query + Atomic Update]
    C -->|Valid| E[Return Cache]
    D --> E

第四章:生产级落地方案的Go工程化细节与稳定性加固

4.1 基于Go context.WithTimeout的双写超时熔断与降级开关设计

在分布式数据双写(如 DB + Redis)场景中,主写成功但缓存写入超时易引发数据不一致。引入 context.WithTimeout 实现可编程超时控制,是轻量级熔断与降级的第一道防线。

数据同步机制

双写流程需满足“主库强一致,缓存最终一致”,超时即放弃缓存写入,避免阻塞主链路。

超时熔断逻辑

ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()

// 异步写缓存,带超时控制
if err := cacheClient.Set(ctx, key, value, ttl).Err(); err != nil {
    if errors.Is(err, context.DeadlineExceeded) {
        log.Warn("cache write timeout, skip for consistency")
        return // 降级:跳过缓存更新,不抛错
    }
}
  • 300ms 是经验阈值,低于 Redis P99 延迟;
  • context.DeadlineExceeded 是唯一需识别的超时错误类型;
  • defer cancel() 防止 goroutine 泄漏。

降级开关策略

开关类型 触发条件 行为
自动降级 单次超时或连续2次失败 跳过缓存写入
手动熔断 运维配置 cache.enabled=false 全局禁用缓存操作
graph TD
    A[开始双写] --> B{DB写入成功?}
    B -->|否| C[返回错误]
    B -->|是| D[启动带超时的Cache写入]
    D --> E{context.Done?}
    E -->|是| F[记录warn,降级完成]
    E -->|否| G[Cache写入成功]

4.2 Redis+MySQL数据比对工具链:用Go编写增量CRC校验与自动修复CLI

核心设计思想

采用「键空间快照 + 增量CRC双哈希」策略:对MySQL按主键分片生成CRC32Adler32,Redis侧同步计算对应key的复合校验值,规避单哈希碰撞风险。

CRC校验核心逻辑(Go)

func calcKeyCRC(key string, val []byte) uint64 {
    crc := crc32.ChecksumIEEE(val)
    adler := adler32.Checksum(val)
    return uint64(crc)<<32 | uint64(adler) // 高32位CRC,低32位Adler
}

calcKeyCRC 将原始value同时送入两种轻量级校验算法,拼接为64位唯一指纹。key仅用于日志追踪,不参与计算——确保相同value在不同key下仍可识别重复数据。

差异处理流程

graph TD
    A[加载MySQL分片] --> B[并发计算行级CRC64]
    B --> C[批量GET Redis对应key]
    C --> D{CRC匹配?}
    D -- 否 --> E[标记diff并触发upsert]
    D -- 是 --> F[跳过]
修复模式 触发条件 安全级别
--dry-run 仅输出差异报告 ★★★★★
--auto-fix 自动写入Redis覆盖 ★★☆☆☆
--sync-mysql 反向回填MySQL ★☆☆☆☆

4.3 撤回操作幂等性保障:Go原子操作(atomic.Value)与Redis SETNX联合去重实现

核心挑战

撤回请求可能因网络重试、客户端重复提交或消息队列重复消费而多次抵达服务端,需确保“同一撤回指令仅生效一次”。

技术协同设计

  • atomic.Value 缓存本地已处理的撤回ID(毫秒级响应)
  • Redis SETNX 提供分布式锁语义,兜底跨实例去重

Go层幂等校验代码

var processedIDs atomic.Value

// 初始化为map[string]struct{}
processedIDs.Store(make(map[string]struct{}))

func isAlreadyRevoked(id string) bool {
    cache := processedIDs.Load().(map[string]struct{})
    if _, ok := cache[id]; ok {
        return true // 本地命中,快速返回
    }

    // 未命中则尝试Redis SETNX(过期时间设为30s防死锁)
    ok, _ := redisClient.SetNX(ctx, "revoked:"+id, "1", 30*time.Second).Result()
    if ok {
        // 写入本地缓存(注意并发安全:需替换整个map)
        newCache := make(map[string]struct{})
        for k, v := range cache {
            newCache[k] = v
        }
        newCache[id] = struct{}{}
        processedIDs.Store(newCache)
    }
    return !ok
}

逻辑分析:先查atomic.Value中只读map(零分配、无锁),未命中再调用Redis SETNX。成功写入Redis后,构造新map并Store()——因atomic.Value不支持原地修改,必须整体替换。参数"revoked:"+id为key前缀,30s是业务容忍的最久撤回窗口。

状态一致性对比表

维度 仅atomic.Value 仅Redis SETNX 联合方案
响应延迟 ~10ns ~1ms(网络RTT) 首次~1ms,后续~10ns
跨实例一致性 ❌(仅本进程有效) ✅(Redis为唯一真相源)
内存增长风险 ⚠️(无淘汰策略) ❌(自动过期) ✅(本地缓存带TTL感知)

执行流程

graph TD
    A[收到撤回请求] --> B{atomic.Value 查ID}
    B -->|命中| C[返回已撤回]
    B -->|未命中| D[Redis SETNX key]
    D -->|成功| E[更新本地cache并返回成功]
    D -->|失败| F[返回已撤回]

4.4 全链路可观测性集成:OpenTelemetry + Go pprof + 自定义Metrics埋点实践

构建生产级可观测性需融合追踪、指标与运行时性能分析。OpenTelemetry 提供统一数据采集标准,Go 原生 pprof 暴露 CPU/heap/block 配置端点,而自定义 Metrics(如业务成功率、延迟分位)填补语义空白。

数据同步机制

OpenTelemetry SDK 通过 PeriodicExporter 每 30 秒批量推送 trace/metric 数据至 OTLP endpoint:

exp, _ := otlpmetrichttp.New(context.Background(),
    otlpmetrichttp.WithEndpoint("otel-collector:4318"),
    otlpmetrichttp.WithInsecure(), // 生产环境启用 TLS
)
provider := metric.NewMeterProvider(metric.WithReader(metric.NewPeriodicReader(exp, metric.WithInterval(30*time.Second))))

此配置启用低频批量导出,降低网络开销;WithInsecure() 仅用于内网调试,实际部署需配合 WithTLSClientConfig

埋点组合策略

维度 工具 典型用途
分布式追踪 OpenTelemetry SDK 跨服务调用链路与错误传播定位
运行时剖析 net/http/pprof CPU 热点、goroutine 泄漏诊断
业务指标 prometheus.Counter 订单创建量、支付失败率统计
graph TD
    A[HTTP Handler] --> B[OTel Span Start]
    B --> C[pprof.Profile CPU]
    B --> D[custom_metrics.Inc()]
    C --> E[OTel Metric Export]
    D --> E

第五章:总结与面向超大规模IM的演进思考

架构韧性在千万级并发场景下的实证反馈

某金融级IM平台在2023年双十一流量洪峰中承载峰值1280万长连接,消息端到端P99延迟稳定在187ms以内。其核心依赖于分层降级策略:当Redis集群响应超时(>50ms)时,自动切换至本地Caffeine缓存+异步写回模式;网关层通过gRPC流控插件动态限制单用户QPS≤30,避免雪崩。监控数据显示,该机制使服务可用性从99.92%提升至99.995%,故障自愈平均耗时缩短至4.3秒。

存储选型的工程权衡矩阵

维度 TiDB(当前) ScyllaDB(POC) 自研LSM-Tree引擎(灰度)
写入吞吐(万QPS) 8.2 24.6 31.7
消息检索延迟(P95) 42ms 18ms 9ms
运维复杂度 高(需TiKV调优) 中(类Cassandra) 低(嵌入式部署)
成本/百万日活 ¥12.8万 ¥7.3万 ¥4.1万

实际落地中,ScyllaDB因JVM GC抖动导致消息乱序问题,在支付通知链路被弃用;自研引擎通过WAL预分配+内存映射文件优化,成功支撑某社交App日均23亿条已读回执持久化。

flowchart LR
    A[客户端心跳包] --> B{网关鉴权}
    B -->|通过| C[接入层路由]
    C --> D[一致性哈希定位Region]
    D --> E[Region内Peer节点负载均衡]
    E --> F[消息投递至Topic分区]
    F --> G[消费组拉取+ACK确认]
    G --> H[落库前校验幂等Token]
    H --> I[双写TiDB+对象存储归档]

安全合规驱动的协议栈重构

为满足GDPR数据主权要求,某出海IM产品将全球节点划分为EU/US/SG三大合规域。所有跨境消息强制经过TLS 1.3加密隧道,并在应用层注入ISO/IEC 27001认证的密钥协商模块。实测显示,启用端到端加密后,消息加解密CPU开销增加17%,但通过ARM64 NEON指令集加速SM4算法,将单条消息处理耗时控制在8.2μs以内,未影响实时音视频信令通道。

实时协同场景的冲突消解实践

在文档协作IM子系统中,采用OT(Operational Transformation)算法替代CRDT处理高频率光标位置更新。针对10人同时编辑同一段落的典型场景,将操作序列压缩为带向量时钟的Delta包,通过服务端合并器实现无冲突同步。压测表明,该方案比纯客户端OT减少32%的网络往返,且在弱网(300ms RTT)下仍保持光标位置误差≤1像素。

资源调度的细粒度弹性模型

Kubernetes集群中为IM工作负载定制VerticalPodAutoscaler策略:基于Netty EventLoop线程池利用率(非CPU)触发扩缩容。当EventLoop阻塞率持续3分钟>65%时,自动将Pod内存从4GB提升至6GB并重启Netty实例。该策略使某电商客服系统在促销期间资源浪费率下降41%,同时避免了传统CPU指标导致的误扩容问题。

超大规模IM系统的演进本质是持续对抗熵增的过程,每一次架构调整都需在确定性保障与创新成本间寻找新的平衡点。

守护数据安全,深耕加密算法与零信任架构。

发表回复

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