1. 前言
之前在设计一个兼容函数的时候,使用了sjson动态设入参数,从而实现一些参数的兼容。大致的逻辑如下所示:
sql
// 有一堆不规则的json数据
{
"a":"aaa",
"b":"bbb",
"any_key1":{"key":"value"},
"any_key2":{"key":"value"}
}
// 因为any_key1和any_key2这样的数字字符串的key还会有新增,所以这个是无法固定的,没有办法给到一个具体的结构体解析,于是就人为的兼容,给它们在处理的时候包一个外层的key
{
"a":"aaa",
"b":"bbb",
"number_key_data":{
"any_key1":{"key":"value"},
"any_key2":{"key":"value"}
}
}
// 这样处理之后,我只要定义number_key_data,我就可以获取到具体的字符串数字为key的数据
2. 实现
采用sjson对对应的非特定key进行新的结构值设入,然后做一个兼容即可实现上面的逻辑,于是有这段代码出来了。
go
package gjson_set_study
import (
"encoding/json"
"fmt"
"github.com/tidwall/sjson"
"testing"
)
func TestGjsonSet(t *testing.T) {
data := `{"a":"aaa","b":"bbb","any_key1":{"key":"value"},"any_key2":{"key":"value"}}`
dataMap := map[string]interface{}{}
err := json.Unmarshal([]byte(data), &dataMap)
if err != nil {
panic(err)
}
fmt.Println(adaptNumberKey(data, dataMap))
// output
// {"a":"aaa","b":"bbb","number_key_data":{"any_key1":{"key":"value"},"any_key2":{"key":"value"}}} <nil>
}
type Data struct {
A string `json:"a"`
B string `json:"b"`
NumberKeyData map[string]struct {
Key string `json:"key"`
Value string `json:"value"`
} `json:"number_key_data"`
}
var specificKeys = map[string]bool{
"a": true,
"b": true,
}
func adaptNumberKey(data string, dataMap map[string]interface{}) (string, error) {
var err error
for k, v := range dataMap {
if _, ok := specificKeys[k]; ok {
continue
}
data, err = sjson.Delete(data, k) // remove first
if err != nil {
return "", fmt.Errorf("delete key data error, key=%s, err=%w", k, err)
}
data, err = sjson.Set(data, "number_key_data."+k, v)
if err != nil {
// log...
return "", fmt.Errorf("set number_key_data error, key=%s, err=%w", k, err)
}
}
return data, nil
}
上面的实现,不出意外的话是不会有任何的问题,但是不出意外的意外出现了,当类似的逻辑代码上线之后,我们发现一个问题:容器会爆内存。这代码也实现了自测,也过了QA的测试,为什么会突然爆内存呢?
3. 问题
容器爆内存的问题出现了,但并不是所有的数据都爆内存,有一组数据100%会爆内存,它们的数据类似:
go
{"a":"aaa","b":"bbb","1000000":{"key":"value"},"50000000":{"key":"value"}}
比较明显的是出现了数字字符串的key,然后结合代码看了一下,刚开始也没看出啥异常,觉得这个修改后的数据应该是:
go
{"a":"aaa","b":"bbb","number_key_data":{"1000000":{"key":"value"},"50000000":{"key":"value"}}}
但后面发现爆内存的问题,又想起了设置数组的方式,当前的代码逻辑如果遇到数字key,就会被认为是在设置数组,开辟几百万甚至上千万长度的数组?(细思极恐) 于是就发现了爆内存的问题所在:数字key在未经特殊标识的情况下,会被认定为数组,于是这个设置key的过程,就变成了对一个key的长为1000000的数组设置值(后者是50000000),可怕。
4. 解决方法
于是参看源码,照着sjson的set方法一路向下看,可以发现如果在parsePath
中我们对路径添加了:
的前缀,sjson会强制把这个key当做string key,而在atoui
中不会将其解析为一个具体的数字,进而导致对字符串key的设置,变成对数组的设值。
go
func parsePath(path string) (res pathResult, simple bool) {
var r pathResult
if len(path) > 0 && path[0] == ':' { // 如果含有:符号,这个key会被强制认定为key
r.force = true
path = path[1:]
}
for i := 0; i < len(path); i++ { // 对path进行分解
if path[i] == '.' {
r.part = path[:i]
r.gpart = path[:i]
r.path = path[i+1:]
r.more = true
return r, true
}
if !isSimpleChar(path[i]) {
return r, false
}
if path[i] == '\\' {
// go into escape mode. this is a slower path that
// strips off the escape character from the part.
// ...
}
return r, true
}
// atoui does a rip conversion of string -> unigned int.
func atoui(r pathResult) (n int, ok bool) {
if r.force {
return 0, false
}
for i := 0; i < len(r.part); i++ {
if r.part[i] < '0' || r.part[i] > '9' {
return 0, false
}
n = n*10 + int(r.part[i]-'0')
}
return n, true
}
于是修改代码逻辑,将所有key的前缀都加上:
的标识。
go
func adaptNumberKey(data string, dataMap map[string]interface{}) (string, error) {
var err error
for k, v := range dataMap {
if _, ok := specificKeys[k]; ok {
continue
}
data, err = sjson.Delete(data, k) // remove first
if err != nil {
return "", fmt.Errorf("delete key data error, key=%s, err=%w", k, err)
}
data, err = sjson.Set(data, "number_key_data."+":"+k, v)
if err != nil {
// log...
return "", fmt.Errorf("set number_key_data error, key=%s, err=%w", k, err)
}
}
return data, nil
}
// Output: {"a":"aaa","b":"bbb","number_key_data":{"1000000":{"key":"value"},"50000000":{"key":"value"}}} <nil>
5. 小结
忽然想到遇到的这个小问题,当时就觉得还是自己单测的场景不够全面,导致了这次爆内存的问题发生,还好有临时解决方案,不然对线上服务造成的影响还真不小。通过这个事例,再一次告诫自己在后续的代码编写中,对于通用功能的逻辑代码,要尽可能的思考一些边缘case,从而避免在上线后边缘case导致代码崩溃的现象出现。