json.Unmarshal精度丢失问题分析

206 阅读4分钟

背景

上游服务将原始数据通过JSON字符串发送至下游服务,其中Data字段被定义为interface{}类型

下游服务使用encoding/json包下的Unmarshal方法将接收到的JSON反序列化到结构体hkEvent中。然而,在反序列化过程中,偶现精度丢失问题,例如id值从原始的10133102110768521变为10133102110768520。通过查询日志和分析链路结果发现,这一问题并非100%发生,仅在部分情况下出现

问题分析

原始数据中的id是一个17位大整数10133102110768521,其数值未超出int64的最大值(2^63-1 = 9223372036854775807),也未因其他情况引发报错。因此,问题很可能发生在对interface{}类型数据操作时。往下查询发现,JSON的number类型默认被解析为float64类型,而float64的精度仅约为15位十进制数字。由于原始id有17位,超出float64的精确表示范围,可能会导致精度丢失

验证

原始 id: 10133102110768521,这个数字有17位,而 float64 的精度是15-16位有效数字,因此用float64表示此数字时可能发生舍入错误。测试一下:

package main
import (
   "fmt"
)
func main() {
   var f float64 = 10133102110768521
   fmt.Printf("%.0f\n", f) // 输出:10133102110768520
}

运行结果显示输出为10133102110768520,而不是原始值10133102110768521,证实了精度丢失的原因

解决方案

方案1:使用具体结构体代替 interface{}

将interface{}替换为int64或string等具体类型。由于这是上游服务的结构体封装,无法直接修改,因此不采用此方案

方案2:下游服务结构体封装采用 json.Number类型

公共结构体,暂不进行这样的改动

方案3:使用 json.Decoder 并启用 UseNumber(采用的这种)

repo := redisRepo.NewTaskPoolRepo()
cmdText, err := repo.PopCommand(ctx, taskId)
if err != nil {
	if errors.Is(err, redis.Nil) {
        result.RuntimeStatus = StatusFinish
        return result
	}
}
taskItem := new(entity.TaskItem)
decoder := json.NewDecoder(strings.NewReader(cmdText))
decoder.UseNumber()
err = decoder.Decode(taskItem)
//err = json.Unmarshal([]byte(cmdText), taskItem)
if err != nil {
	result.RuntimeStatus = StatusNormal
	return result
}

方案4:使用自定义解码器

自定义 UnmarshalJSON 方法,实现 UnmarshalJSON 接口。完成特定字段的反序列化逻辑,如将大整数解析为 string 或 big.Int,demo示例:

import (
    "encoding/json"
    "fmt"
)

type Data struct {
    ID string `json:"id"`
}

func (d *Data) UnmarshalJSON(data []byte) error {
    type Alias Data
    aux := struct {
        ID interface{} `json:"id"`
        *Alias
    }{Alias: (*Alias)(d)}
    if err := json.Unmarshal(data, &aux); err != nil {
        return err
    }
    d.ID = fmt.Sprintf("%v", aux.ID)
    return nil
}

func main() {
    jsonStr := `{"id": 101331033307280607}`
    var data Data
    err := json.Unmarshal([]byte(jsonStr), &data)
    if err != nil {
        panic(err)
    }
    fmt.Println("ID:", data.ID)
}

通过自定义 UnmarshalJSON,代码将 "id" 字段先解析为 interface{},然后强制转换为字符串,确保保留所有位数

优点:通过 interface{} 可以处理不同类型的JSON值,无需修改上游JSON格式,有一定的灵活性

缺点:显式地将 interface{} 转换为字符串有些繁琐,且有类型限制,如当JSON "id" 字段是其他类型(对象或数组),需要额外的类型检查和处理,也会带来一定的性能开销

方案5:其他包下的方法(推荐)

如 json-iterator/go包下的 UseNumber 选项,通过配置 UseNumber: true,强制将JSON中的所有数字解析为 jsoniter.Number 类型(一个字符串表示的数字),从而避免默认解析为 float64 导致的大整数精度丢失,代码示例:

package main

import (
	"fmt"
	jsoniter "github.com/json-iterator/go"
)

type hkEvent struct {
	ID   jsoniter.Number `json:"id"`
	Name string          `json:"name"`
}

func main() {
	msg := []byte(`{"id": 101331033307280607, "name": "TestEvent"}`)
	json := jsoniter.Config{UseNumber: true}.Froze()
	var hkEvent hkEvent
	err := json.Unmarshal(msg, &hkEvent)
	if err != nil {
        panic(err)
	}
	id, _ := hkEvent.ID.Int64()
	fmt.Printf("ID: %d\n", id)
	fmt.Printf("Name: %s\n", hkEvent.Name)
}