记使用sjson的一次小事故

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导致代码崩溃的现象出现。

相关推荐
Asthenia041241 分钟前
Spring扩展点与工具类获取容器Bean-基于ApplicationContextAware实现非IOC容器中调用IOC的Bean
后端
bobz9651 小时前
ovs patch port 对比 veth pair
后端
Asthenia04121 小时前
Java受检异常与非受检异常分析
后端
uhakadotcom1 小时前
快速开始使用 n8n
后端·面试·github
JavaGuide1 小时前
公司来的新人用字符串存储日期,被组长怒怼了...
后端·mysql
bobz9652 小时前
qemu 网络使用基础
后端
Asthenia04122 小时前
面试攻略:如何应对 Spring 启动流程的层层追问
后端
Asthenia04122 小时前
Spring 启动流程:比喻表达
后端
Asthenia04123 小时前
Spring 启动流程分析-含时序图
后端
ONE_Gua3 小时前
chromium魔改——CDP(Chrome DevTools Protocol)检测01
前端·后端·爬虫