microrain头像

SagooIoT 用一份物模型同时决定表结构与取值

在 TDengine 里 DESCRIBE 一张 SagooIoT 建出来的超级表,会看到这样的字段序列:

taos> DESCRIBE product_air_sensor;
     field      |         type         |   length   | note |
================================================================
 ts             | TIMESTAMP            |          8 |      |
 p_temperature  | FLOAT                |          4 |      |
 p_temperature_time | TIMESTAMP        |          8 |      |
 p_humidity     | FLOAT                |          4 |      |
 p_humidity_time| TIMESTAMP            |          8 |      |
 p_switch       | BOOL                 |          1 |      |
 p_switch_time  | TIMESTAMP            |          8 |      |
 device         | VARCHAR              |        255 | TAG  |

这张表不是谁手工建的,也不是在建库脚本里写死的。它上面每一个 p_ 开头的列、每一个跟着 _time 的伴生列,都是从产品物模型里算出来的。产品里加一个属性,表里就多两列;改一个属性的类型,下次建表就用新类型。

这篇文章讲的就是这条链路:一份物模型 JSON,如何同时决定时序库的表结构、上报报文的解析结果、以及最终写进库里的值长什么样。文中的实现细节来自本地专业版发行版的源码,涉及 internal/logic/product、internal/model、internal/queues、pkg/tsd、network/core/logic/model/up/property 几个目录;产品概念与使用方式可对照文档站 https://iotdoc.sagoo.cn ,社区版源码在 https://github.com/orgs/sagoo-cloud/repositories 。

---

一、物模型是产品级的一份 schema

SagooIoT 的物模型(TSL)不是一个给前端画页面用的表单,它是产品维度的数据结构定义,四段成员:

// internal/model/dev_tsl.go
type TSL struct {
	Key        string        `json:"key"`        // 产品标识
	Name       string        `json:"name"`       // 产品名称
	Properties []TSLProperty `json:"properties"` // 属性
	Functions  []TSLFunction `json:"functions"`  // 功能
	Events     []TSLEvent    `json:"events"`     // 事件
	Tags       []TSLTag      `json:"tags"`       // 标签
}

四类成员各管一件事:

成员方向典型用途是否落成表结构
属性 Properties设备 → 平台温度、湿度、开关状态是,每个属性两列
功能 Functions平台 → 设备下发指令、设置参数否,走服务调用
事件 Events设备 → 平台告警、故障、按键否,走告警与消息
标签 Tags静态描述安装位置、批次、型号是,落成 TAG 列

属性本身也只有五个字段,但每个字段都有约束:

type TSLProperty struct {
	Key        string       `json:"key"        v:"required|regex:^[A-Za-z_]+[\\w]*$#请输入属性标识|标识由字母、数字和下划线组成,且不能以数字开头"`
	Name       string       `json:"name"       v:"required#请输入属性名称"`
	AccessMode int          `json:"accessMode" v:"required#请选择是否只读"` // 0=读写,1=只读
	ValueType  TSLValueType `json:"valueType"  v:"属性值"`
	Desc       string       `json:"desc"       v:"描述"`
}

这里的 regex 校验值得单独说一句。属性标识被限制成「字母或下划线开头,后面只能是字母数字下划线」,原因很实际:这个标识最终会变成数据库列名,而 TDengine 的列名不接受数字开头、不接受中划线、不接受空格。把约束放在录入界面而不是建表那一刻,用户在产品配置阶段就被拦住,不会等到发布产品时才报一条看不懂的 SQL 错误。

同一个物模型内部,标识还必须全局唯一——属性和功能、事件、标签之间也不能重名:

// 检查标识Key是否存在,物模型模块下唯一
func checkExistKey(key string, tsl model.TSL) bool {
	for _, v := range tsl.Properties {
		if strings.EqualFold(v.Key, key) {
			return true
		}
	}
	for _, v := range tsl.Functions {
		if strings.EqualFold(v.Key, key) {
			return true
		}
	}
	// events、tags 同理
	return false
}

strings.EqualFold 而不是 ==,说明大小写不敏感也被算进去了。对使用者来说这是件好事:Temperature 和 temperature 在产品里只能存在一个,不会出现两个语义相同、列名不同的属性。

---

二、一条 CREATE STABLE 里的三次约定

产品的物模型配完、点发布,平台会去时序库里建一张超级表。这段代码是整个设计的核心:

// pkg/tsd/internal/tdengine/tdengine.go
func (m *TdEngine) CreateDeviceStable(tsl *model.TSL) error {
	// 属性字段
	columns := []string{"ts TIMESTAMP"}
	for _, v := range tsl.Properties {
		maxLength := 0
		if v.ValueType.TSLParamBase.MaxLength != nil {
			maxLength = *v.ValueType.TSLParamBase.MaxLength
		}
		columns = append(columns, m.column(v.ValueType.Type, v.Key, maxLength))
		// 属性上报时间
		columns = append(columns, m.column("date", v.Key+"_time", 0))
	}

	// 标签字段
	tags := make([]string, len(tsl.Tags)+1)
	tags[0] = "device VARCHAR(255)"
	for i, v := range tsl.Tags {
		maxLength := 0
		if v.ValueType.TSLParamBase.MaxLength != nil {
			maxLength = *v.ValueType.TSLParamBase.MaxLength
		}
		tags[i+1] = m.column(v.ValueType.Type, v.Key, maxLength, 1)
	}

	tConent := ""
	if len(tags) > 0 {
		tConent = fmt.Sprintf("TAGS (%s)", strings.Join(tags, ","))
	}
	table := comm.ProductTableName(tsl.Key)
	sqlStr := fmt.Sprintf("CREATE STABLE IF NOT EXISTS %s.%s (%s) %s", m.Option.Database, table, strings.Join(columns, ","), tConent)
	_, err := m.exec(sqlStr)
	return err
}

短短三十行里有三次约定,每一次都对着一个具体的现场问题。

第一次约定:一个属性两列。 p_temperature 存值,p_temperature_time 存这个值发生的时间。为什么不能只靠 ts 主时间列?因为设备是汇聚上报的,一批数据可能是补传、可能是缓存重发,同一批里的每个值未必发生在同一毫秒。把「值」和「值的时间」绑成一对,才敢在主时间列上按入库顺序写,同时保住每个测点自己的时刻。

第二次约定:类型跟着物模型走。

func (m *TdEngine) column(fType, key string, maxLength int, isTag ...int) string {
	tdType := ""
	switch fType {
	case "int":
		tdType = "INT"
	case "long":
		tdType = "BIGINT"
	case "float":
		tdType = "FLOAT"
	case "double":
		tdType = "DOUBLE"
	case "string":
		if maxLength == 0 {
			maxLength = 150
		}
		tdType = "NCHAR(" + strconv.Itoa(maxLength) + ")"
	case "boolean":
		tdType = "BOOL"
	case "date":
		tdType = "TIMESTAMP"
	default:
		if maxLength == 0 {
			maxLength = 150
		}
		tdType = "NCHAR(" + strconv.Itoa(maxLength) + ")"
	}
	...
}

字符串没有指定长度时默认 NCHAR(150),而不是等到插入时才发现超长;long 走 BIGINT,不会因为设备报了个 13 位时间戳就被截断。类型这件事在建模阶段定一次,之后所有设备共用,写入侧不需要做任何类型判断。

第三次约定:命名空间前缀。 表名带 product_、device_、log_,属性列带 p_,标签列带 t_,全部转小写、中划线转下划线:

// pkg/tsd/comm/consts.go
const (
	TdProductPrefix  = "product_"
	TdDevicePrefix   = "device_"
	TdLogPrefix      = "log_"
	TdPropertyPrefix = "p_"
	TdTagPrefix      = "t_"
)

func TsdColumnName(key string) string {
	key = strings.ToLower(key)
	if key == "ts" {
		return "ts"
	}
	return TdPropertyPrefix + key
}

前缀不是为了好看。用户可能把属性叫 ts、叫 device、叫 status,这些词在时序库里都有特殊含义或已被平台占用。加一层前缀,属性命名空间和系统列就彻底分开了,ts 保留给主时间列、device 保留给设备标识 TAG,其余一律加前缀。

这三条约定合起来,超级表就变成一个稳定的契约:同一个产品的所有设备,写同一张表、同一组列,聚合查询天然对齐。

一台设备接入时,平台再基于这张超级表建子表:

// CreateDeviceTable 创建设备子表
func (m *TdEngine) CreateDeviceTable(table, stable string) error {
	tag := table
	stable = comm.ProductTableName(stable)
	table = comm.DeviceTableName(table)
	sqlStr := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s USING %s (device) TAGS ('%s')", table, stable, tag)
	_, err := m.exec(sqlStr)
	return err
}

产品是超级表、设备是子表,正好对上 TDengine 的一设备一表模型。查询时按设备过滤是子表扫描,按产品做趋势聚合是超级表扫描,两种访问模式都不需要额外索引。

---

三、改物模型,就是改表结构

物模型能改,但改的时候表结构得跟着动,否则新属性写不进去。SagooIoT 的做法是:元数据和存储放在同一个事务里改。

// internal/logic/product/dev_tsl_property.go  AddProperty 片段
tsl.Properties = append(tsl.Properties, in.TSLProperty)
metaData, _ := json.Marshal(tsl)

err = dao.DevProduct.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
	_, err = dao.DevProduct.Ctx(ctx).
		Data(dao.DevProduct.Columns().Metadata, string(metaData)).
		Where(dao.DevProduct.Columns().Key, in.ProductKey).
		Update()
	if err != nil {
		return err
	}

	if tsdEnable {
		tsdDb := tsd.DB()
		if tsdDb == nil {
			return errors.New("时序数据库连接失败")
		}
		if p.MetadataTable == 1 {
			maxLength := 0
			if in.ValueType.TSLParamBase.MaxLength != nil {
				maxLength = *in.ValueType.TSLParamBase.MaxLength
			}
			err = tsdDb.AddField(p.Key, in.Key, in.ValueType.Type, maxLength)
			if err != nil {
				return err
			}
		}
	}
	return nil
})

AddField 里同样是一对操作,加值列之后立刻加时间列:

func (m *TdEngine) AddField(table, field string, fType string, len int) error {
	table = comm.ProductTableName(table)
	sqlStr := fmt.Sprintf("ALTER STABLE %s.%s ADD COLUMN %s", m.Option.Database, table, m.column(fType, field, len))
	_, err := m.exec(sqlStr)
	if err == nil {
		sqlStr = fmt.Sprintf("ALTER STABLE %s.%s ADD COLUMN %s", m.Option.Database, table, m.column("date", field+"_time", 0))
		_, err = m.exec(sqlStr)
	}
	return err
}

两件事一起成功、一起回滚,就不会出现「物模型里有这个属性、表里没这列」的中间态。批量的 BatchAddProperty 也是同一个事务里循环加列,导入一份 TSL 文件时不会留下半张表。

改也不是无限制的。三个写操作开头都有一句相同的判断:

if p.Status != 0 {
	return errors.New("产品已发布,无法新增") // 或"无法修改"、"无法删除"
}

产品发布之后物模型就锁定了。这个取舍很符合工业现场:表结构变更会影响历史数据的可比性,一条趋势曲线如果前半段来自 FLOAT、后半段来自 NCHAR,看图的人会莫名其妙。所以改结构必须发生在设备大规模接入之前;已经上线的产品要演进,就走新建产品、迁移设备这条路,历史数据各归各表。

删除属性是这几个操作里最重的。如果删的是最后一个属性,超级表就没有业务列了,平台会把整张表重建:

if plen > 1 {
	if err = tsdDb.DelField(p.Key, in.Key); err != nil {
		return err
	}
} else {
	// 删除超级表
	if err = tsdDb.DropDeviceStable(p.Key); err != nil {
		return err
	}
	// 删除子表,并把设备置为未启用
	devList, err := service.DevDevice().GetAllForProduct(ctx, p.Key)
	...
	existTable = 0
	existStatus = model.ProductStatusOff
}

不是留一张空表在那里等人踩,而是把产品状态、设备状态、metadata_table 标志一起复位,回到「没建过表」的干净状态。配置文件和数据库状态始终一致,不会出现悬空的表。

---

四、报文进来先认 method

表结构定好了,接下来是数据怎么进去。设备发上来的是一段 JSON,平台第一步不是取值,而是认这个方法名:

// internal/logic/product/dev_tsl_parse.go
func (s *sDevTSLParse) ParseData(ctx context.Context, device *model.DeviceOutput, data []byte) (iotModel.ReportPropertyData, error) {
	if len(data) == 0 {
		return nil, errors.New("data is empty")
	}
	if device == nil {
		return nil, errors.New("device not found")
	}

	var reportData sagooProtocol.ReportPropertyReq
	if err := json.Unmarshal(data, &reportData); err != nil {
		g.Log().Debugf(ctx, "parse data error: %s, message:%s, message ignored", err, string(data))
		return nil, err
	}

	if len(reportData.Params) == 0 {
		return nil, errors.New("report data is empty")
	}

	switch reportData.Method {
	case "thing.event.property.post":
		return s.HandleProperties(ctx, device, reportData.Params)
	case "thing.event.property.pack.post":
		return s.handlePackPost(ctx, device, reportData.Params)
	default:
		return nil, errors.New("unsupported method")
	}
}

thing.event.property.post 是单设备直报,params 里就是属性键值。thing.event.property.pack.post 是打包上报,一个网关把自己和挂载的子设备的数据打在一个包里发上来:

case "properties":
	properties, ok := value.(map[string]interface{})
	if !ok {
		g.Log().Debug(ctx, "Invalid properties data format")
		continue
	}
	if prop, err := s.HandleProperties(ctx, device, properties); err == nil {
		allProperties = mergePropertyData(allProperties, prop)
	}
case "subDevices":
	subDevices, ok := value.([]interface{})
	...
	for _, sub := range subDevices {
		handleSubDeviceProperties(ctx, device, subData, &allProperties)
	}

子设备那段里有个身份校验,identity.deviceKey 必须和当前连接设备一致才会解析:

deviceKey, ok := identity["deviceKey"].(string)
if !ok || deviceKey != device.Key {
	g.Log().Debug(ctx, "Invalid or unmatched device key")
	return
}

也就是说,网关不能替别人上报数据,报文里的身份必须和 TCP/MQTT 连接上认证过的身份对得上。设备身份由连接决定,报文只能声明自己的数据,不能声明自己是谁——这条边界放在解析入口,比放在业务逻辑里可靠得多。

---

五、十一个转换器:让脏数据进不了库

真正把 "23.456789" 变成 23.46 的,是这一组类型转换器。入口只有一个:

func (t TSLValueType) ConvertValue(v interface{}) interface{} {
	var transfer Transfer
	switch t.Type {
	case consts.TypeInt:
		transfer = TInt(t.TSLParam)
	case consts.TypeLong:
		transfer = TLong(t.TSLParam)
	case consts.TypeFloat:
		transfer = TFloat(t.TSLParam)
	...
	case consts.TypeArray:
		transfer = TArray(t.TSLParam)
	case consts.TypeObject:
		transfer = TObject(t.TSLParam)
	default:
		return nil
	}
	return transfer.Convert(v)
}

type Transfer interface {
	Convert(interface{}) interface{}
}

十一种类型各实现一个 Convert,每种都在做同一件事:把设备给的任意值,收敛成物模型承诺的那个形状。

类型收敛规则代码要点
int转整数后夹紧到 [min, max]越界返回边界值
long转 int64 后夹紧同上
float夹紧 → 按 decimals 舍入 → 降为 float32默认两位小数
double夹紧 → 按 decimals 舍入保留 float64
string转字符串 → 超 maxLength 截断按字节长度
boolean转 bool → 映射 trueValue/falseValue支持反转语义
date按两种 layout 自适应解析秒级/毫秒级
timestamp毫秒时间戳 → 格式化字符串UnixMilli
enum命中枚举值才返回,否则空串枚举白名单
array逐元素递归 ConvertValue元素类型可声明
object按 Properties 逐字段递归结构体嵌套

两个转换器值得展开看。浮点这边:

type TFloat TSLParam

func (tFloat TFloat) Convert(v interface{}) interface{} {
	number := gconv.Float64(v)
	if tFloat.TSLParamBase.Min != nil && float32(*tFloat.TSLParamBase.Min) > float32(number) {
		number = float64(*tFloat.TSLParamBase.Min)
	}
	if tFloat.TSLParamBase.Max != nil && float32(*tFloat.TSLParamBase.Max) < float32(number) {
		number = float64(*tFloat.TSLParamBase.Max)
	}
	defaultDecimal := 2
	if tFloat.TSLParamBase.Decimals != nil {
		defaultDecimal = *tFloat.TSLParamBase.Decimals
	}
	number32, _ := strconv.ParseFloat(strconv.FormatFloat(number, 'f', defaultDecimal, 64), 32)
	return float32(number32)
}

一个温度传感器接反了、报了个 -273.15 以下的数,或者模块故障吐出 9999,转换器会把它夹到量程边界上。这样库里不会出现离谱的尖峰把整条趋势图的纵轴拉爆,看图的人也不必每次都先怀疑传感器。小数位则是先按 decimals 格式化、再降为 float32,保证「库里存的位数」和「界面上看到的位数」一致。

枚举这边更直接:

func (tEnum TEnum) Convert(v interface{}) interface{} {
	tE := gconv.String(v)
	if tEnum.TSLParamExtension.Elements == nil {
		return ""
	}
	for _, node := range tEnum.TSLParamExtension.Elements {
		if node.Value == tE {
			return node.Value
		}
	}
	return ""
}

设备报了个协议之外的枚举值(固件升级多了一个状态码),这个值不会进库,库里只会留下空值。空值在统计上是可识别的缺失,比塞进一个没人认识的字符串好处理得多。

布尔类型还有个实用设计——真值和假值可以配置:

type TBoolean TSLParam

func (tBoolean TBoolean) Convert(v interface{}) interface{} {
	b := gconv.Bool(v)
	if tBoolean.TSLParamBase.TrueValue != nil && *tBoolean.TSLParamBase.TrueValue == b {
		return true
	}
	if tBoolean.TSLParamBase.FalseValue != nil && *tBoolean.TSLParamBase.FalseValue == b {
		return false
	}
	return b
}

有的设备用 1/0,有的用 "ON"/"OFF",有的高电平代表断开。物模型里把语义写在 trueText/falseText 上,展示层用它渲染「是/否」「开/关」,库里统一是 BOOL。协议差异在边缘收敛,数据在中台统一,这是这一层存在的意义。

---

六、一个属性两个时间是怎么填的

前面说每个属性两列,那 _time 列的值从哪来?看这个函数:

// CreatePropertyNode 创建属性节点
func (s *sDevTSLParse) CreatePropertyNode(property *model.TSLProperty, value interface{}, defaultTime time.Time) iotModel.ReportPropertyNode {
	var timestamp int64
	var val interface{}

	if mapInfo, ok := value.(map[string]interface{}); ok {
		if timeVal, timeOK := mapInfo["time"].(float64); timeOK {
			timestamp = int64(timeVal)
		} else {
			timestamp = defaultTime.Unix()
		}
		val = property.ValueType.ConvertValue(mapInfo["value"])
	} else {
		timestamp = defaultTime.Unix()
		val = property.ValueType.ConvertValue(value)
	}

	return iotModel.ReportPropertyNode{
		CreateTime: timestamp,
		Value:      val,
	}
}

设备可以报两种形态:裸值 "temperature": 23.5,或者带时间的对象 "temperature": {"time": 1735689600, "value": 23.5}。前者用服务器接收时刻补齐,后者用设备自己给的时间戳。

两种写法都支持,落到库里就是「主时间列 = 入库时刻」和「p_temperature_time = 设备声称的时刻」两套。这个设计在排查现场问题时特别有用:设备时钟漂了、数据补传了、边缘侧缓存积压了几小时才发上来,两条时间线一比就清楚——数据是什么时候到的,和它自己认为发生在什么时候,是两件事。

---

七、同一份报文,为什么要解析两次

在接入链路上,一条属性上报进来后,处理函数大致是这样排的:

// network/core/logic/model/up/property/reporter/reporter.go
go baseLogic.InertTdLog(ctx, consts.MsgTypePropertyReport, data.DeviceDetail.Key, reportData.Id, reportData)
go baseLogic.UpdateShadow(ctx, consts.MsgTypePropertyReport, data.ProductKey, data.DeviceDetail, reportData.Params)
//解析数据
reportDataInfo, err := service.DevTSLParse().ParseData(ctx, data.DeviceDetail, data.PayLoad)
if err != nil {
	return err
}
//北向属性上报消息
north.WriteMessage(ctx, north.PropertyReportMessageTopic, nil, data.ProductKey, data.DeviceDetail.Key, iotModel.PropertyReportMessage{
	Properties: reportDataInfo,
})
//告警处理
err = service.AlarmRule().Check(ctx, data.ProductKey, data.DeviceKey, consts.AlarmTriggerTypeProperty, reportDataInfo)
// 场景规则投递到队列

这里已经解析过一次了,得到 reportDataInfo 给告警、北向、场景规则用。而那行 InertTdLog 只是把原始报文按日志写走,不解析。半小时后,落库任务从队列里取出一批日志,又解析了一次:

// internal/queues/deviceDataTsdSave.go
for _, devLog := range items {
	if devLog.Type == consts.MsgTypePropertyReport {
		device, err := deviceCache.GetDeviceDetailInfo(devLog.Device)
		if err != nil {
			continue
		}
		deviceData, err := service.DevTSLParse().ParseData(context.Background(), device, []byte(devLog.Content))
		if err != nil {
			continue
		}
		deviceDataList[devLog.Device] = append(deviceDataList[devLog.Device], deviceData)
	}
}
if len(deviceDataList) > 0 {
	db := tsd.GetDB()
	defer db.Close()
	_, err = db.BatchInsertMultiDeviceData(deviceDataList)
}

同一个函数被调用两次,不是重复劳动,而是两条路径各自的职责不同:

  • 接入侧那次是实时路径。它要在毫秒级给出告警判定、要立刻响应设备 ACK、要把消息推给北向系统和场景规则。它不能被数据库拖住,所以算完就走。
  • 落库侧那次是批量路径。它要攒批、要按设备分组、要一次 INSERT 多行。它拿的是日志原文,重新解析一遍,才能把同一设备的多条数据拼成一次写库。

好处是两条路径彻底解耦:数据库抖动或者批量任务积压,接入侧照常收数、照常告警,ACK 照常返回;反过来,接入侧重启也不影响队列里已经排着的落库任务。而 ParseData 本身是纯函数——输入是「设备详情 + 报文字节」,输出是「属性键值对」,同一个输入永远同一个输出,所以两次解析的结果一定一致,不存在实时侧和落库侧对不上的情况。

顺带一句,落库任务里用了 sync.Pool 复用 DeviceLog 对象,每批几百条日志不必反复分配:

deviceLogPool = sync.Pool{
	New: func() interface{} {
		return new(iotModel.DeviceLog)
	},
}

---

八、写进去的时候是一条 INSERT 多行

批量路径最终落到 BatchInsertMultiDeviceData,SQL 是按设备拼的:

// pkg/tsd/internal/tdengine/device.go
table := comm.DeviceTableName(deviceKey)
keys, fieldList := comm.GetDeviceField(deviceDataList[0])
var (
	ts         = time.Now().UnixMilli()
	baseSQL    = "INSERT INTO " + table + " (" + strings.Join(fieldList, ",") + ") VALUES"
	sqlBuilder strings.Builder
)
sqlBuilder.WriteString(baseSQL)

for i, row := range deviceDataList {
	ts++
	value := comm.GetDeviceValue(keys, row)
	sqlBuilder.WriteString(fmt.Sprintf(" (%s)", "'"+time.UnixMilli(ts).Format(time.RFC3339Nano)+"',"+strings.Join(value, ",")))
	if sqlBuilder.Len() > 15*1024 || i == len(deviceDataList)-1 {
		_, err := m.db.Exec(strings.TrimRight(sqlBuilder.String(), " "))
		sqlBuilder.Reset()
		sqlBuilder.WriteString(baseSQL)
	}
}

几个细节值得注意。

主时间列按毫秒递增。 ts++ 保证同一批数据在主时间列上严格有序,不会因为同一毫秒内多条数据导致覆盖或乱序。真实时刻在 _time 列里,主时间列只负责排序和分区。

SQL 按 15KB 分段。 不是一条数据一条 SQL,也不是无限拼接到内存爆掉。攒到 15KB 就发一次,既拿到了多行插入的吞吐,又把单条语句的大小控制住。

字段顺序取自第一条数据。 GetDeviceField 用第一行数据的 key 生成字段列表,GetDeviceValue 再按这个固定顺序取值:

func GetDeviceValue(field []string, data iotModel.ReportPropertyData) []string {
	var value []string
	//跟据统一的key列表顺序,对数据值排序输出
	for _, key := range field {
		for k, v := range data {
			_, ok := data[key]
			if ok {
				if k == key {
					value = append(value, "'"+gvar.New(v.Value).String()+"'")
					value = append(value, "'"+gtime.New(v.CreateTime).Format("Y-m-d H:i:s")+"'")
				}
			}
		}
	}
	return value
}

Go 的 map 遍历顺序是随机的,如果不先固定一份 key 序列,同一批数据的字段顺序就会飘,拼出来的 SQL 列和值容易错位。先取一份顺序、再按顺序取值,这个小动作是批量写入正确性的前提。

---

九、启动自检:表结构和物模型对不齐时自己补

再稳的流程也会有例外:换了时序库、迁移了环境、有人手工删了表。所以 dev_init 里有一段开机自检,带 Redis 锁避免多实例同时建:

var list []*entity.DevProduct
c := dao.DevProduct.Columns()
err = dao.DevProduct.Ctx(ctx).Where(c.Status, model.ProductStatusOn).Where(c.MetadataTable, 1).Scan(&list)
if err != nil || len(list) == 0 {
	return
}

// 检测td表结构是否存在,不存在则创建
for _, p := range list {
	stable := comm.ProductTableName(p.Key)
	b, _ := tsdDb.CheckDeviceStable(stable)
	if b {
		continue
	}
	var tsl *model.TSL
	err = json.Unmarshal([]byte(p.Metadata), &tsl)
	if len(tsl.Properties) == 0 {
		g.Log().Errorf(ctx, "产品 %s 物模型数据异常", p.Key)
		continue
	}
	err = tsdDb.CreateDeviceStable(tsl)
}

物模型 JSON 就存在产品表的 metadata 字段里,是唯一事实来源。只要这份 JSON 还在,超级表随时可以重建——表结构是可推导的产物,不是需要备份的资产。这个性质让环境迁移变得很轻:数据库带过去、时序库空着启动就行,平台自己会把表建齐。

同样的自检也覆盖设备子表:metadata_table 标志是 1 但子表不见了,就补建一张。

---

十、一份数据结构,五个下游

回头看 reportDataInfo 这个 map[string]ReportPropertyNode,它被五处消费:

消费方拿它做什么对数据的期待
告警规则阈值、越界、离线判定已按量程夹紧、类型正确
设备影子更新 reported 态值与时间成对
时序库批量落库字段名与列名一一对应
场景规则触发联动结构稳定、可序列化
北向接口转发给上层系统与物模型定义一致

五处用的是同一个结构,意味着它们看到的数据一定是一样的。告警判的是 23.46,库里存的也是 23.46,大屏上显示的也是 23.46,不会出现「告警说超了、曲线看着没超」这种各说各话的场面。

这也是物模型这层抽象真正的价值:它把「这台设备报的字段叫什么、是什么类型、量程多少、单位是什么」从代码里挪到了配置里。接一个新厂家的设备,不用改解析代码,只要在物模型里把属性建好,再写一段脚本把厂家的私有报文转成物模型的键——脚本那层在另一篇文章里讲过,这里只说结果:同一个产品的设备,无论来自哪个厂家、走哪种协议,落到库里都是同一张表、同一组列。

---

十一、这套设计的收益

把上面的链路串起来看,几个收益是明确的。

加属性不用动代码。 产品详情里加一行属性,事务里自动 ALTER STABLE ADD COLUMN 两列,从下一条上报开始就能存。设备端不需要升级协议、不需要改字段顺序、不需要重启。

换厂家不用改表。 报文差异被脚本层吸收,物模型是稳定的中间层。同一批传感器换了供应商,历史曲线照样能连起来看。

数据可比。 同一产品的所有设备共用超级表和列定义,跨设备对比、按产品聚合、按标签分组,SQL 都是一句话的事。

脏数据在入口就被收敛。 类型转换、量程夹紧、枚举白名单、字符串截断,四道处理全部发生在解析阶段。库里的类型永远是物模型承诺的类型,下游不用做防御性判断。

边界清晰。 产品发布后物模型锁定,结构变更与数据写入彻底分开:变更只在配置阶段发生一次,写入路径上没有任何 DDL。这也是写入侧能放心攒批、拼 15KB 大 SQL 的前提。

如果要给这套设计下一个定义,它更像用一份 schema 驱动三层:向上驱动界面表单和报表,向中驱动报文解析和校验,向下驱动时序库的表结构与列类型。三层共用一份定义,才有资格叫「物模型」,否则只是几张配置表。

---

结语

物联网平台里最容易出问题的地方,往往不是协议对接,而是「同一个物理量在不同环节有不同解释」:设备说 235(0.1℃ 为单位),平台说 23.5℃,数据库存 23(INT),大屏显示 24。每一层都做了自己的四舍五入,最后一圈下来谁也对不上。

SagooIoT 的处理方式是把解释权收归一处——物模型说它是 float、两位小数、量程 -40~85,那么解析、告警、影子、时序库、北向全按这个口径来。表结构是它算出来的,值是它裁剪过的,时间是它补齐的。

一份配置,管住了从报文到曲线的整条路。

---

项目地址

  • 文档站:https://iotdoc.sagoo.cn
  • 社区版源码:https://github.com/orgs/sagoo-cloud/repositories
  • 本文涉及的实现取自本地专业版发行版源码:internal/logic/product(物模型增删改与解析)、internal/model/dev_tsl*.go(TSL 结构与类型转换器)、pkg/tsd(TDengine 建表、加列、批量写入)、internal/queues/deviceDataTsdSave.go(落库队列)、network/core/logic/model/up/property(接入侧上行处理)。

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/microrain/article/details/169520139

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--