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

相关推荐
wn53135 分钟前
【Go - 类型断言】
服务器·开发语言·后端·golang
希冀1231 小时前
【操作系统】1.2操作系统的发展与分类
后端
GoppViper1 小时前
golang学习笔记29——golang 中如何将 GitHub 最新提交的版本设置为 v1.0.0
笔记·git·后端·学习·golang·github·源代码管理
爱上语文2 小时前
Springboot的三层架构
java·开发语言·spring boot·后端·spring
serve the people3 小时前
springboot 单独新建一个文件实时写数据,当文件大于100M时按照日期时间做文件名进行归档
java·spring boot·后端
罗政8 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
拾光师9 小时前
spring获取当前request
java·后端·spring
Java小白笔记11 小时前
关于使用Mybatis-Plus 自动填充功能失效问题
spring boot·后端·mybatis
JOJO___13 小时前
Spring IoC 配置类 总结
java·后端·spring·java-ee
_小许_13 小时前
Go语言的io输入输出流
golang