json.Unmarshal精度丢失问题分析
背景
上游服务将原始数据通过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 = 10133102110768521fmt.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 = StatusFinishreturn 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 = StatusNormalreturn 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 Dataaux := 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 Dataerr := 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 mainimport ("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 hkEventerr := 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)
}