Posted in

Go物联网项目上线前必须做的6项压力测试(含JMeter+Prometheus+Grafana全栈监控脚本)

第一章:Go物联网项目压力测试的核心价值与典型场景

在物联网系统中,设备规模呈指数级增长,海量终端并发上报、边缘计算任务调度、云端实时数据聚合等场景对服务稳定性提出严峻挑战。Go语言凭借其轻量级协程、高效网络栈和低内存开销,成为构建高吞吐物联网后端的首选,但其性能优势必须通过科学的压力测试予以验证和保障。

核心价值体现

压力测试并非仅验证“能否扛住流量”,而是揭示系统在真实负载下的行为边界:包括连接泄漏、GC频率异常、goroutine堆积、数据库连接池耗尽等Go特有瓶颈。例如,一个基于net/http+gorilla/mux的设备管理API,在未启用连接复用时,每秒5000设备心跳请求可能触发数千个阻塞I/O等待,导致runtime/pprof显示大量selectgo阻塞态goroutine。

典型物联网测试场景

  • 设备密集接入:模拟万台设备TCP长连接并发注册(使用gomqtt或自定义TCP客户端)
  • 高频时序写入:向TimescaleDB或InfluxDB批量写入传感器数据(每秒10万点)
  • 规则引擎触发:在EdgeX Foundry风格架构中,触发复杂条件判断链(如温度>阈值且持续30s)
  • OTA固件分发:单次下发50MB固件包至500台设备,测试HTTP/2流控与内存缓冲

快速启动压测示例

以下使用vegeta对Go HTTP服务进行基础QPS探测:

# 1. 构建JSON负载(模拟设备上报)
echo '{"device_id":"dev-001","temp":23.5,"ts":1717023456}' > payload.json

# 2. 发起1000并发、持续30秒压测
echo "POST http://localhost:8080/api/v1/metrics" | \
  vegeta attack -body=payload.json -rate=1000 -duration=30s -timeout=5s | \
  vegeta report -type="json" > report.json

# 3. 提取关键指标(需jq工具)
cat report.json | jq '.latencies.mean, .bytes_out.mean, .errors'

该命令直接暴露服务在高并发下的平均延迟、每请求输出字节数及错误率,是定位Go服务http.Server.ReadTimeout配置不足或sync.Pool未复用对象的第一手依据。

第二章:压力测试基础架构搭建与工具链集成

2.1 JMeter分布式压测环境部署与Go服务适配配置

JMeter分布式压测需协调多节点协同发压,同时确保Go服务能精准响应并暴露可观测指标。

环境准备要点

  • 主控机(Controller)安装JMeter并配置jmeter.properties启用RMI通信
  • 多台从机(Agent)需安装相同版本JMeter、Java,并关闭防火墙或开放1099及自定义端口

Go服务适配关键配置

启用HTTP健康检查端点与Prometheus指标暴露:

// main.go 中集成指标暴露
import (
    "net/http"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
    http.Handle("/metrics", promhttp.Handler()) // 指标采集入口
    http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("OK"))
    })
    http.ListenAndServe(":8080", nil)
}

此段代码使Go服务暴露/metrics(供压测监控链路延迟、QPS等)和/health(供JMeter定时探活)。promhttp.Handler()自动聚合Goroutine、HTTP请求计数器等基础指标,无需手动埋点。

JMeter远程启动流程

graph TD
    A[Controller执行jmeter -n -t test.jmx -R 192.168.1.10,192.168.1.11] --> B[通过RMI向Agent发送测试计划]
    B --> C[Agent加载脚本、初始化线程组]
    C --> D[并发执行HTTP取样器,上报结果至Controller]
组件 配置项 说明
Controller remote_hosts=... 列出所有Agent IP:Port
Agent server.rmi.localport=50000 避免端口冲突
Go服务 GODEBUG=mmap=1 降低内存映射抖动,提升压测稳定性

2.2 Prometheus指标埋点规范:从Go pprof到自定义业务指标采集

Go原生pprof提供基础运行时指标(如goroutines、heap_alloc),但缺乏业务语义。需升级为Prometheus原生指标,实现可观察性闭环。

埋点演进路径

  • expvarpromhttp暴露标准指标
  • client_golang注册自定义Counter/Histogram
  • ❌ 直接复用/debug/pprof端点(非Prometheus格式)

关键代码示例

// 注册业务请求计数器
var httpRequestsTotal = prometheus.NewCounterVec(
    prometheus.CounterOpts{
        Name: "http_requests_total",
        Help: "Total HTTP requests processed",
    },
    []string{"method", "status", "path"}, // 维度标签,支撑多维下钻
)
func init() {
    prometheus.MustRegister(httpRequestsTotal)
}

逻辑说明:CounterVec支持动态标签组合;MustRegister()自动绑定至默认Registry;method/status/path三元组可支撑API级SLI计算。

指标类型 适用场景 示例
Counter 累计事件次数 订单创建总数
Histogram 观测值分布(延迟) 支付接口P99响应时间
graph TD
    A[HTTP Handler] --> B[业务逻辑]
    B --> C[httpRequestsTotal.WithLabelValues...]
    C --> D[Prometheus Scraping]
    D --> E[PromQL查询与告警]

2.3 Grafana可视化看板构建:IoT设备连接数、消息吞吐量与端到端延迟三维度联动

为实现IoT系统健康态实时感知,需将设备连接数(iot_device_connected_total)、消息吞吐量(iot_message_received_rate{topic=~".+"})与端到端延迟(iot_e2e_latency_seconds_bucket)在单一视图中动态联动。

数据源协同配置

  • Prometheus 作为统一指标后端,启用 native histogram 支持延迟分布聚合
  • 各指标按 device_groupregion 标签对齐,确保下钻一致性

关键面板逻辑示例(PromQL)

# 联动查询:按区域聚合的延迟P95与连接数比值(反映负载效率)
histogram_quantile(0.95, sum(rate(iot_e2e_latency_seconds_bucket[1h])) by (le, region)) 
/ 
sum(iot_device_connected_total) by (region)

此表达式计算各区域每连接平均P95延迟,单位为秒/设备;rate(...[1h]) 消除瞬时抖动,histogram_quantile 基于原生直方图高效估算分位数,避免客户端聚合误差。

面板交互设计

功能 说明
时间范围联动 所有面板共享全局时间选择器
变量过滤 regiondevice_type 下拉变量自动同步刷新
阈值着色 延迟 > 200ms 且吞吐量下降 >15% 时触发红色脉冲动画
graph TD
    A[设备上报指标] --> B[Prometheus抓取]
    B --> C{Grafana查询引擎}
    C --> D[连接数面板]
    C --> E[吞吐量热力图]
    C --> F[延迟分布直方图]
    D & E & F --> G[交叉高亮联动]

2.4 基于Go net/http/pprof与expvar的实时性能探针注入实践

Go 标准库提供的 net/http/pprofexpvar 是轻量级、无侵入式性能观测的黄金组合,无需依赖外部 APM 工具即可实现生产级实时探针。

启用 pprof HTTP 端点

import _ "net/http/pprof"

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil)) // 默认注册 /debug/pprof/*
    }()
    // ... 应用主逻辑
}

该导入触发 pprof 自动注册路由;ListenAndServe 启动独立监控端口,避免干扰主服务流量。端点支持 goroutineheapcpu 等采样(需客户端主动触发 ?seconds=30)。

注册自定义 expvar 指标

import "expvar"

var reqCounter = expvar.NewInt("http_requests_total")
reqCounter.Add(1) // 在请求处理中调用

expvar 提供线程安全的原子变量,暴露为 /debug/vars 的 JSON 接口,天然兼容 Prometheus 抓取。

pprof + expvar 协同观测能力对比

维度 pprof expvar
数据类型 运行时快照(堆栈、内存、CPU) 实时计数器、字符串、映射
采集方式 主动拉取 + 采样(如 CPU profile) 被动查询(HTTP GET)
部署成本 零配置(仅 import) 需显式注册指标

graph TD
A[HTTP 请求] –> B{pprof /debug/pprof/heap}
A –> C{expvar /debug/vars}
B –> D[内存泄漏分析]
C –> E[QPS/错误率趋势]
D & E –> F[根因定位闭环]

2.5 容器化部署下JMeter+Prometheus+Grafana的K8s ServiceMesh监控拓扑设计

在ServiceMesh(如Istio)环境中,需将JMeter作为可观测性负载注入点,与Prometheus指标采集链路深度对齐。

核心组件协同逻辑

  • JMeter通过jmeter-prometheus-plugin暴露/metrics端点(HTTP + OpenMetrics格式)
  • Prometheus通过ServiceMonitor动态发现JMeter Pod,并抓取mesh sidecar(Envoy)与应用容器双维度指标
  • Grafana通过预置Dashboard关联istio_requests_totaljmeter_http_req_duration_seconds等关键指标

指标采集配置示例

# servicemonitor-jmeter.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
spec:
  selector:
    matchLabels:
      app: jmeter-loadgen  # 必须与JMeter Deployment label一致
  endpoints:
  - port: http-metrics     # 对应JMeter容器中暴露的metrics端口
    interval: 15s          # 高频采样以捕获瞬时压测波动

该配置使Prometheus自动关联带app=jmeter-loadgen标签的Pod,interval=15s确保压测期间指标分辨率足够捕捉P99延迟突变。

监控拓扑关系

graph TD
  A[JMeter Pod] -->|/metrics| B[Prometheus]
  C[Envoy Sidecar] -->|stats via /stats/prometheus| B
  B --> D[Grafana Dashboard]
  D -->|实时联动| E[Alertmanager 基于 SLA 触发]
组件 数据角色 关键标签
JMeter 主动负载 + 业务指标源 job="jmeter-scenario-a"
Envoy 网络层指标(mTLS/重试) destination_service="orders"
Prometheus 多源指标聚合与存储 namespace="test-env"

第三章:六大关键压测项的Go原生实现与验证逻辑

3.1 设备海量并发接入(MQTT CoAP双协议模拟)与连接泄漏检测

为验证平台在万级设备混合接入下的健壮性,我们构建双协议压力模拟器:MQTT 使用 Paho Python 客户端维持长连接,CoAP 基于 aiocoap 实现轻量请求/响应式会话。

协议行为差异对比

特性 MQTT(QoS1) CoAP(CON)
连接模型 持久 TCP 连接 无状态 UDP 事务
心跳机制 keepalive=30s ACK_TIMEOUT=2s
连接泄漏诱因 未调用 disconnect() 未显式 client.shutdown()
# CoAP客户端连接池泄漏示例(需修复)
import aiocoap.resource as resource
async def leaky_coap_client():
    context = await aiocoap.Context.create_client_context()
    # ❌ 缺少 context.shutdown() → 连接句柄持续累积
    response = await context.request(aiocoap.Message(code=GET, uri="coap://127.0.0.1:5683/sensor")).response
    return response.payload

该代码未释放 Context 实例,导致 UDP socket 和 event loop task 长期驻留;实际压测中每千设备增加约 3.2 个未关闭 fd。MQTT 侧则需监控 on_disconnect 回调是否被正确触发。

连接泄漏检测策略

  • 实时采集 /proc/<pid>/fd/ 数量趋势
  • 关联设备 ID 与 socket 生命周期日志
  • 对超时 5min 的空闲 MQTT 连接触发强制清理
graph TD
    A[新设备接入] --> B{协议类型}
    B -->|MQTT| C[注册心跳定时器]
    B -->|CoAP| D[绑定临时上下文]
    C --> E[检测PONG超时?]
    D --> F[请求完成即销毁?]
    E -->|是| G[标记疑似泄漏]
    F -->|否| G

3.2 消息风暴场景下的Go channel缓冲区与worker pool动态调优

动态缓冲区策略

面对突发流量,静态 chan int 易阻塞或 OOM。应基于 QPS 与平均处理时长估算初始容量:

// 基于滑动窗口采样动态调整缓冲区大小
bufferSize := int(float64(peakQPS) * avgProcessDuration.Seconds() * 1.5)
jobChan := make(chan Job, bufferSize) // 1.5 倍安全冗余

逻辑分析:peakQPS 来自 Prometheus 实时指标;avgProcessDuration 由 tracer 统计;系数 1.5 抵消瞬时毛刺,避免频繁 realloc。

Worker Pool 弹性伸缩

// 根据 channel 长度与延迟反馈动态扩缩 worker 数量
if len(jobChan) > cap(jobChan)*0.8 && workers < maxWorkers {
    go startWorker(jobChan, resultChan)
    workers++
}

参数说明:cap(jobChan)*0.8 触发扩容阈值;maxWorkers 由内存上限(如每 worker 占 4MB)反向约束。

调优效果对比

场景 平均延迟 丢弃率 内存峰值
静态 buffer=100 120ms 8.2% 1.2GB
动态 buffer+pool 42ms 0% 860MB
graph TD
    A[消息流入] --> B{缓冲区水位 >80%?}
    B -->|是| C[扩容 Worker]
    B -->|否| D[常规分发]
    C --> E[更新 worker 计数器]
    E --> D

3.3 边缘节点断连重试机制在高丢包率下的韧性验证

重试策略设计核心原则

  • 指数退避(Exponential Backoff)避免网络雪崩
  • 最大重试次数与超时阈值解耦,适应动态链路质量
  • 基于RTT波动自适应调整初始重试间隔

数据同步机制

采用带状态感知的重试状态机,仅对未确认的增量操作重发:

def retry_with_jitter(base_delay_ms: int, attempt: int) -> float:
    # base_delay_ms: 基础延迟(毫秒),默认50ms
    # attempt: 当前重试次数(从0开始)
    jitter = random.uniform(0.5, 1.5)  # 避免重试风暴
    delay = min(2 ** attempt * base_delay_ms * jitter, 5000)  # 上限5s
    return delay / 1000  # 返回秒级浮点数

逻辑分析:2 ** attempt 实现指数增长;jitter 引入随机性打破重试同步;min(..., 5000) 防止无限退避。参数 base_delay_ms 可随链路丢包率动态下调(如丢包率>30%时设为20ms)。

高丢包率压测结果(模拟15%–40% UDP丢包)

丢包率 平均恢复时延 成功同步率 节点存活率
15% 320 ms 99.8% 100%
30% 890 ms 97.2% 100%
40% 1.6 s 91.5% 99.97%

状态流转保障

graph TD
    A[断连检测] --> B{是否超时?}
    B -->|是| C[触发重试]
    B -->|否| D[维持心跳]
    C --> E[更新退避计数器]
    E --> F[执行retry_with_jitter]
    F --> G[校验ACK/序列号]
    G -->|成功| H[清除待重发队列]
    G -->|失败| C

第四章:全栈监控脚本工程化落地与自动化闭环

4.1 JMeter测试脚本模板化:支持设备ID轮询、QoS分级与Payload熵值生成

JMeter脚本模板化是高保真物联网压测的核心能力。通过内置函数与JSR223后置处理器,实现三大动态能力:

设备ID轮询机制

使用__CSVRead()配合循环控制器,按行读取设备ID池(如devices.csv),确保每线程独占ID且不重复。

QoS分级注入

// JSR223 PreProcessor - 动态设置MQTT QoS
def qosLevels = [0, 1, 2]
def threadIndex = ctx.getThreadNum() % 3
vars.put("qos", qosLevels[threadIndex].toString())

逻辑分析:基于线程编号模3实现负载均衡的QoS分布;ctx.getThreadNum()获取唯一线程序号,vars.put()将QoS写入当前线程变量作用域,供MQTT Sampler引用。

Payload熵值生成

熵源类型 实现方式 熵值范围
时间戳 System.nanoTime() 10^18
UUID UUID.randomUUID() 128-bit
随机字节 new SecureRandom().nextBytes() 可控长度
// 生成高熵JSON payload
def entropy = new groovy.json.JsonBuilder([
  ts: System.nanoTime(),
  id: UUID.randomUUID().toString(),
  data: java.util.Base64.getEncoder().encodeToString(new byte[16])
]).toString()
vars.put("payload", entropy)

逻辑分析:融合时间、UUID与加密随机数三重熵源;SecureRandom确保密码学安全,Base64编码规避二进制传输问题;最终JSON结构兼容主流IoT协议解析器。

graph TD A[线程启动] –> B{QoS分级策略} B –> C[设备ID轮询] C –> D[熵值Payload生成] D –> E[MQTT Sampler执行]

4.2 Prometheus exporter封装:将Go服务内部状态(goroutine数、GC Pause、TLS握手耗时)暴露为标准指标

核心指标选型依据

  • go_goroutines:反映并发负载压力,突增预示协程泄漏
  • go_gc_duration_seconds:直方图指标,捕获STW暂停分布
  • 自定义 tls_handshake_duration_seconds:观测mTLS握手延迟

指标注册与采集逻辑

var (
    goroutines = promauto.NewGauge(prometheus.GaugeOpts{
        Name: "go_goroutines",
        Help: "Number of goroutines currently running",
    })
    gcDuration = promauto.NewHistogram(prometheus.HistogramOpts{
        Name:    "go_gc_duration_seconds",
        Help:    "A histogram of the GC pause duration.",
        Buckets: prometheus.ExponentialBuckets(1e-9, 2, 20), // 1ns ~ 524ms
    })
)

func collectRuntimeMetrics() {
    goroutines.Set(float64(runtime.NumGoroutine()))

    // 获取最近5次GC统计(需启用runtime.ReadMemStats)
    var stats runtime.MemStats
    runtime.ReadMemStats(&stats)
    for _, pause := range stats.PauseNs[:stats.NumGC] {
        gcDuration.Observe(float64(pause) / 1e9) // ns → seconds
    }
}

该代码通过promauto自动注册指标并绑定默认Registry;ExponentialBuckets确保对微秒级GC停顿具备高分辨率覆盖;PauseNs数组按环形缓冲区更新,取有效长度避免越界。

TLS握手延迟采集(HTTP Server中间件)

指标名 类型 标签 说明
tls_handshake_duration_seconds Histogram server_name, status 记录http.Request.TLS.HandshakeComplete耗时

指标暴露流程

graph TD
    A[HTTP Handler] --> B{TLS Handshake?}
    B -->|Yes| C[Start Timer]
    C --> D[Request Serve]
    D --> E[Stop Timer & Observe]
    E --> F[Prometheus Endpoint]

4.3 Grafana告警规则DSL编写:基于IoT业务语义的复合阈值触发(如“连续5分钟设备在线率90%”)

复合条件的语义建模

Grafana 9+ 支持 PromQL 原生 andoffsetcount_over_time 组合,实现时序语义对齐:

# 连续5分钟在线率 < 99.5% AND 同期CPU > 90%
(
  count_over_time(device_online_ratio{job="iot-gateway"}[5m]) 
  < (0.995 * 5 * 60)
)
and
(
  avg_over_time(100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[1m])) * 100)[5m:1m])
  > 90
)

逻辑分析:第一行统计5分钟内每秒采样点中在线率低于阈值的次数(单位:秒),需严格 ≤ 270 秒(即 5×60×0.995);第二行用滑动窗口 avg_over_time(...[5m:1m]) 计算5分钟内每分钟粒度的平均CPU使用率,确保持续高负载。

触发上下文增强

  • ✅ 支持 labels 注入设备类型、区域等业务维度
  • annotations 可引用 $values 动态渲染异常设备列表
  • ❌ 不支持跨数据源布尔运算(需预聚合至同一Prometheus实例)
维度 在线率指标 CPU指标
数据源 IoT Agent 上报 Prometheus Node Exporter
采样频率 15s 1m
延迟容忍 ≤ 30s ≤ 2m
graph TD
  A[原始指标采集] --> B[按device_id分组聚合]
  B --> C[计算5m滚动在线率]
  B --> D[计算5m滚动CPU均值]
  C & D --> E[DSL复合判定]
  E --> F[触发告警并携带device_tags]

4.4 CI/CD流水线集成:GitLab CI触发压测+自动归档报告+异常指标阻断发布

触发压测:GitLab CI Job定义

load-test:
  stage: test
  image: ghcr.io/loadimpact/k6:0.47.0
  script:
    - k6 run --out json=report.json ./test/stress.js
  artifacts:
    - report.json
    - k6-metrics.csv

该Job使用官方k6镜像执行压测脚本,--out json=report.json生成结构化结果供后续解析;artifacts确保报告被持久化传递至下游阶段。

自动归档与阻断逻辑

  • 压测报告经Python脚本解析后上传至MinIO(带时间戳前缀)
  • http_req_failed > 0.5%p95 > 2000ms,则调用GitLab API标记Pipeline为failed并终止部署
指标 阈值 动作
错误率 >0.5% 阻断发布
P95响应时间 >2000ms 阻断发布
吞吐量下降 发出告警

流程协同

graph TD
  A[GitLab CI Pipeline] --> B[执行k6压测]
  B --> C[解析JSON报告]
  C --> D{指标达标?}
  D -->|是| E[归档至对象存储]
  D -->|否| F[标记失败+终止部署]

第五章:压测结果分析与Go物联网系统优化决策指南

压测数据核心指标解读

在对某智能电表边缘网关服务(基于Go 1.21 + Gin + Redis集群)执行持续30分钟、峰值QPS达8,200的压测后,关键指标呈现显著分层特征:平均响应时间从基准态42ms跃升至217ms,P99延迟突破680ms;CPU使用率在第18分钟达到92%,但内存增长平缓(仅+18%),表明瓶颈集中于CPU密集型协程调度与锁竞争。火焰图显示sync/atomic.LoadUint64调用占比达34%,指向高频计数器未做批处理优化。

瓶颈定位与根因验证

通过pprof CPU profile对比发现,deviceManager.GetStatus()方法中嵌套的sync.RWMutex.Lock()调用耗时占该函数总耗时的67%。进一步注入延迟探针验证:当模拟锁等待超5ms时,请求失败率从0.02%骤升至12.7%,证实读写锁粒度粗是核心瓶颈。同时,Wireshark抓包显示23%的HTTP请求携带重复设备ID查询参数,暴露API设计缺陷。

Go运行时调优实践

调整GOMAXPROCS=48(匹配物理核数),并启用GODEBUG=madvdontneed=1缓解内存归还延迟;将http.Server.ReadTimeout从30s降至8s,配合context.WithTimeout在业务层统一控制超时链路。实测后GC pause时间由平均12ms降至3.1ms,STW阶段减少62%。

并发模型重构方案

将全局设备状态锁拆分为按设备ID哈希分片的128个独立sync.RWMutex实例,配合sync.Pool复用状态结构体。重构后压测数据显示锁等待时间下降91%,QPS提升至11,400,且P99延迟稳定在320ms内:

优化项 QPS P99延迟(ms) 错误率 CPU峰值(%)
原始版本 8,200 680 0.82% 92
分片锁+Pool 11,400 320 0.03% 71

生产灰度验证策略

在3台边缘节点部署新版本,通过Envoy流量镜像将5%真实IoT上报流量复制至新旧服务比对。监控显示新服务在相同负载下goroutine数量降低40%,Redis连接池复用率达99.2%(原为76%)。采用Prometheus告警规则检测go_goroutines{job="gateway"} > 5000作为回滚触发条件。

// 设备状态分片锁示例
type ShardedMutex struct {
    mu [128]sync.RWMutex
}

func (s *ShardedMutex) Lock(id string) {
    idx := int(hash(id)) % 128
    s.mu[idx].Lock()
}

func (s *ShardedMutex) Unlock(id string) {
    idx := int(hash(id)) % 128
    s.mu[idx].Unlock()
}

资源水位联动扩缩容机制

基于压测得出的资源拐点阈值(CPU>85%、goroutine>4500、Redis pending commands>200),构建Kubernetes HPA自定义指标控制器。当连续3次采样超阈值时,触发StatefulSet滚动扩容,新Pod启动后自动加入Consul健康检查集群。

graph LR
A[压测报告] --> B{CPU持续>85%?}
B -->|Yes| C[触发分片锁重构]
B -->|No| D[检查Redis连接池]
D --> E[Pending cmds>200?]
E -->|Yes| F[启用连接池预热]
E -->|No| G[评估GC频率]

持续压测基线维护

建立Jenkins Pipeline每日凌晨执行轻量级压测(2,000 QPS/5min),自动比对历史基线生成diff报告。当http_request_duration_seconds_bucket{le="0.5"}下降超15%时,标记为性能回归并阻断发布流水线。当前基线已覆盖MQTT协议解析、OTA固件校验、时序数据批量写入三大核心路径。

一线开发者,热爱写实用、接地气的技术笔记。

发表回复

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