需求背景

目前,网易云商·七鱼智能客服数据库缓存使用了 spring-data-redis 框架,并由自研的缓存组件进行管理。该组件使用 Jackson 框架对缓存数据进行序列化和反序列化,并将其以明文 JSON 的形式存储在 Redis 中。

这种方式存在两个问题:

  • 速度慢,CPU占用高

在应用服务中,读写缓存数据时需要进行字符串的反序列化和序列化操作,即将对象转换为 JSON 格式再转换为字节数组,但是使用 Jackson 序列化方式的性能并不是最优的。此外,在线上服务分析中发现,对于缓存命中率较高的应用,在并发稍微高一点的情况下,Jackson 序列化会占用较多的 CPU 资源。

  • 存储空间大,资源浪费

对于 Redis 集群来说,JSON 数据占用的存储空间较大,会浪费 Redis 存储资源。

在对同类序列化框架进行调研后,我们决定使用 ProtoStuff 代替 Jackson 框架。本文将简要介绍 ProtoStuff 的存储原理,并讨论在替换过程中遇到的一些问题。

关于 ProtoStuff

什么是 ProtoStuff?

ProtoStuff 是一种基于 Google Protocol Buffers(protobuf)协议的序列化和反序列化库,它可以将 Java 对象序列化为二进制数据并进行网络传输或存储,也可以将二进制数据反序列化为 Java 对象。与其他序列化库相比,ProtoStuff 具有更高的性能和更小的序列化大小,因为它使用了基于标记的二进制编码格式,同时避免了 Java 序列化的一些缺点,例如序列化后的数据过大和序列化性能较慢等问题。因此,ProtoStuff 被广泛应用于高性能的分布式系统和大规模数据存储系统中。

Protostuff 的序列化编码算法与 Protobuf 基本相同,都采用基于 Varint 编码的变长序列化方式,以实现对编码后的字节数组的压缩。此外,Protostuff 还引入了 LinkedBuffer 这种数据结构,通过链表的方式将不连续内存组合起来,从而实现数据的动态扩张,提高存储效率。

Varint 编码是一种可变长度的整数编码方式,用于压缩数字数据,使其更加紧凑。它使用 1 个或多个字节来表示一个整数,其中每个字节的高位都用于指示下一个字节是否属于同一个数。较小的数字使用较少的字节编码,而较大的数字则需要更多的字节编码。这种编码方式被广泛应用于网络传输和存储领域。

LinkedBuffer

简单看一下 LinkedBuffer 的源码:

public final class LinkedBuffer{/*** The minimum buffer size for a {@link LinkedBuffer}.*/public static final int MIN_BUFFER_SIZE = 256;/*** The default buffer size for a {@link LinkedBuffer}.*/public static final int DEFAULT_BUFFER_SIZE = 512;final byte[] buffer;final int start;int offset;LinkedBuffer next;
}

byte[] buffer 是用来存储序列化过程中的字节数组的,默认的大小是 512,最低可以设置成 256。LinkedBuffer next 指向的是下一个节点。start 是开始位置,offset 是偏移量。

链表大概长这样,这样就可以把几块连续的内存块给链接到一起了。

Schema 接口

除了 LinkedBuffer 这个类,还有一个关键的接口:Schema,这是一个类似于数据库 DDL 结构的东西,它定义了序列化对象的类的结构信息,有哪些字段,字段的顺序是怎么样的,怎样序列化,怎样反序列化。

在使用的时候一般用的是 RuntimeSchema 这个实现类。

public final class RuntimeSchema<T> implements Schema<T>, FieldMap<T>
{private final FieldMap<T> fieldMap;public static <T> RuntimeSchema<T> createFrom(Class<T> typeClass, Set<String> exclusions, IdStrategy strategy) {// 省略部分代码final Map<String, java.lang.reflect.Field> fieldMap = findInstanceFields(typeClass);final ArrayList<Field<T>> fields = new ArrayList<Field<T>>(fieldMap.size());int i = 0;boolean annotated = false;for (java.lang.reflect.Field f : fieldMap.values()) {if (!exclusions.contains(f.getName())) {if (f.getAnnotation(Deprecated.class) != null) {i++;continue;}final Tag tag = f.getAnnotation(Tag.class);final int fieldMapping;final String name;if (tag == null) {// 省略部分代码fieldMapping = ++i;name = f.getName();}else {// 省略部分代码annotated = true;fieldMapping = tag.value();// 省略部分代码name = tag.alias().isEmpty() ? f.getName() : tag.alias();}final Field<T> field = RuntimeFieldFactory.getFieldFactory(f.getType(), strategy).create(fieldMapping, name, f,                        strategy);fields.add(field);}}return new RuntimeSchema<T>(typeClass, fields, RuntimeEnv.newInstantiator(typeClass));}static void fill(Map<String, java.lang.reflect.Field> fieldMap, Class<?> typeClass) {if (Object.class != typeClass.getSuperclass())fill(fieldMap, typeClass.getSuperclass());for (java.lang.reflect.Field f : typeClass.getDeclaredFields()) {int mod = f.getModifiers();if (!Modifier.isStatic(mod) && !Modifier.isTransient(mod) && f.getAnnotation(Exclude.class) == null)fieldMap.put(f.getName(), f);}}@Overridepublic List<Field<T>> getFields()    {return fieldMap.getFields();}@Overridepublic final void writeTo(Output output, T message) throws IOException {for (Field<T> f : getFields())f.writeTo(output, message);}}

根据 fill 方法的实现,我们可以得知 fieldMap 是通过调用当前类及其父类的 getDeclaredFields 方法所获取的所有字段。接着,在 createFrom 方法中,我们遍历所有字段,获取每个字段的序列化序号 fieldMapping。在序列化过程中,我们调用 writeTo 方法,将每个字段按照 fieldMapping 的顺序写入字节数组中。

众所周知,Java 的 getDeclaredFields 方法返回的字段数组不是按照特定的顺序排列的。字段的顺序取决于具体的 JVM 实现以及编译器等因素。因此,在不使用 Tag 注解的时候,序列化的字段顺序是不固定的。如果在原有的字段中间随意插入一个字段,或者在合并代码的时候调换了字段的顺序,反序列化的数据不仅会错乱,很大概率还会报错。

在 ProtoStuff 的官方文档里,推荐使用 @Tag 注解来显式的声明字段序列化的顺序。Tag 注解对于小项目或者固定不会变的类对象确实是挺好用的,但是对于老项目序列化框架迁移来说,多个代码仓库超过 400 个对象需要加 Tag 注解,代码改动量和影响范围将会非常庞大。而且一旦有字段加了 Tag 注解,那么后续添加的所有字段都需要添加注解,并且需要保证新增字段的顺序是递增的,会有一定的维护成本和风险。

自适应 ProtoStuff 的改造方案

为了减少序列化框架迁移过程的代码改动范围和风险,降低后期编码维护成本,我们需要一个可以在序列化与反序列化时自动适配字段的改造方案。

主要思路

序列化

  • 将 getDeclaredFields 方法获取到的当前类及其父类所有的字段,根据字段名称进行排序。

  • 遍历排序后的字段列表,将字段转换成 ProtoStuff 需要的 Field 列表,再调用 RuntimeSchema 的构造方法新建一个对象。通过 RuntimeSchema 对象完成序列化操作,生成字节数组。

  • 由于 ProtoStuff 的编码是 T-L-V 格式的,只存了对象字段的下标和具体的值,没有存完整的类路径,而且 spring-data-redis 反序列化的时候不知道目标对象的类型,因此还需要一个包装类来存储额外的信息。

  • 对统一包装对象进行序列化,返回生成的字节数组。

  • 将缓存对象的类结构信息缓存到 Redis 中,以便反序列化时使用。

为了提供序列化的效率,还可以将 RuntimeSchema 对象缓存到本地。

反序列化

  • 将字节数组反序列化成通用的包装类。

  • 从包装类中获取到源数据的类路径,版本号,字段哈希值。先判断源数据类是否是集合或者基本数据类型,如果是基本数据类型,直接返回 source 字段的内容。如果是集合类,判断本地版本号是否与包装类获取到的版本号一致,一致的时候返回 source 字段的内容。

  • 源数据类型既不是集合也不是基本数据类型,获取本地对象的版本号,如果本地对象版本号大于缓存版本号,则将缓存数据淘汰掉。

  • 如果本地对象的版本号和缓存中的版本号一致,就直接使用本地类进行转换,获取到 RuntimeSchema 进行反序列化。

  • 如果本地对象的版本号小于缓存中的版本号,则需要根据类路径 + 缓存版本号 从 Redis 中获取到对应的类结构信息,将本地的字段进行重新排序,获取到和缓存数据对应的字段顺序值,再生成相应的 RuntimeSchema 进行反序列化。

代码实现

ProtoStuff 的入门使用是很简单的,只需要引入 ProtoStuff 的依赖,然后在需要使用序列化的类字段上加上 Tag 注解即可使用。也可以不使用注解,ProtoStuff 会根据字段顺序来确定缓存中的顺序。

增加 Maven 依赖

<!--        protostuff        --><dependency><groupId>io.protostuff</groupId><artifactId>protostuff-core</artifactId><version>1.7.4</version></dependency><dependency><groupId>io.protostuff</groupId><artifactId>protostuff-runtime</artifactId><version>1.7.4</version></dependency>
public class ProtoStuffWrapper implements Serializable {private static final long serialVersionUID = 6310017353904821602L;// 版本号@Tag(1)private int version;// 包装类型的完整路径名@Tag(2)private String className;// 包装对象序列化后的字节数组@Tag(3)private byte[] data;// 是否是没有包装的类型@Tag(4)private boolean noWrapperObject;// 用于存储集合对象@Tag(5)private Object source;// 类字段hash@Tag(6)private int classHash;// 省略 get set 和 构造方法
}

对于基本数据类型和一些 Java 的基础对象,以及集合,Map 类对象,会直接将数据放在 source 中。

重写序列化方法

实现 org.springframework.data.redis.serializer.RedisSerializer 接口, 重写序列化方法。

流程图

代码

public class ProtostuffRedisSerializer implements RedisSerializer<Object> {private static final Map<String, ProtoSchema> SCHEMA_CACHE = new ConcurrentHashMap<>(200);private static final Map<String, Schema> REMOTE_CLASS_SCHEMA_CACHE = new ConcurrentHashMap<>(200);private static final Delegate<Timestamp> TIMESTAMP_DELEGATE = new TimestampDelegate();private static final DefaultIdStrategy ID_STRATEGY = (DefaultIdStrategy) RuntimeEnv.ID_STRATEGY;private static final ThreadLocal<LinkedBuffer> BUFFER = ThreadLocal.withInitial(() -> LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));private static final Schema WRAPPER_SCHEMA = RuntimeSchema.getSchema(ProtoStuffWrapper.class);private static final int SECONDS_OF_THIRTY_DAYS = 30 * 60 * 60 * 24;private static final long MILLISECOND_OF_THIRTY_DAYS = SECONDS_OF_THIRTY_DAYS * 1000L;private final StringRedisTemplate redisTemplate;static {ID_STRATEGY.registerDelegate(TIMESTAMP_DELEGATE);}public ProtostuffRedisSerializer(StringRedisTemplate redisTemplate) {this.redisTemplate = redisTemplate;}@Overridepublic byte[] serialize(Object o) throws SerializationException {if (source == null) {return EMPTY_ARRAY;}LinkedBuffer buffer = BUFFER.get();byte[] data = new byte[0];try {String className = getClassName(source);Class<?> typeClass = source.getClass();Object serializeObj;if (isBasicType(source, className) || isArrayType(typeClass)) {int classVersion = 0;if (isArrayType(typeClass)) {classVersion = readVersion(source);}serializeObj = new ProtoStuffWrapper(className, classVersion, source);} else {ProtoSchema protoSchema = getCachedProtoSchema(className, source);try {data = ProtostuffIOUtil.toByteArray(source, protoSchema.getSchema(), buffer);} finally {buffer.clear();}serializeObj = new ProtoStuffWrapper(className, data, protoSchema);}data = ProtostuffIOUtil.toByteArray(serializeObj, WRAPPER_SCHEMA, buffer);} catch (Exception e) {logger.error("protostuff serialize fail", e);} finally {buffer.clear();}return data;}@Overridepublic Object deserialize(byte[] bytes) throws SerializationException {return deserialize(source, Object.class);}
}

从上面的 deserialize 方法的定义中可以看到,入参只有一个字节数组,出参是一个 Object,没有 Class 类的参数,因此必须要有一个统一的包装类来保存目标类的定义信息。

Timestamp 序列化代理

对于 Timestamp 类型的字段需要自己写一个序列化代理去处理,不然会有解析失败的问题。

public class TimestampDelegate implements Delegate<Timestamp> {@Overridepublic WireFormat.FieldType getFieldType() {return WireFormat.FieldType.FIXED64;}@Overridepublic Timestamp readFrom(Input input) throws IOException {return new Timestamp(input.readFixed64());}@Overridepublic void writeTo(Output output, int number, Timestamp timestamp, boolean repeated) throws IOException {output.writeFixed64(number, timestamp.getTime(), repeated);}@Overridepublic void transfer(Pipe pipe, Input input, Output output, int number, boolean repeated) throws IOException {output.writeFixed64(number, input.readFixed64(), repeated);}@Overridepublic Class<?> typeClass() {return Timestamp.class;}
}

ProtoSchema

本地缓存对象,用来缓存序列化对象的 RuntimeSchema 和类的相关信息。

public class ProtoSchema {// 版本号private int version;// 类字段hashprivate int hash;// 序列化对象的RuntimeSchemaprivate Schema schema;// 本地缓存生效开始时间private long createTime;// 省略 get set 和 构造方法
}

getCachedProtoSchema

获取序列化对象的 RuntimeSchema 和类的相关信息。本地缓存中存在则直接使用缓存中的数据,不存在时,解析类对象,根据排序后的字段构建 RuntimeSchema 来进行序列化。

private ProtoSchema getCachedProtoSchema(String className, Object source) {ProtoSchema protoSchema = SCHEMA_CACHE.get(className);if (protoSchema != null) {if (protoSchema.getVersion() == 0) {// 基本类型包装类直接返回return protoSchema;}if (System.currentTimeMillis() - protoSchema.getCreateTime() < MILLISECOND_OF_THIRTY_DAYS) {// 本地缓存在有效期内直接返回,不在有效期的重新加载类结构信息return protoSchema;}}Class<?> typeClass = source.getClass();List<Field<?>> fields = new ArrayList<>();LinkedHashMap<String, java.lang.reflect.Field> fieldMap = new LinkedHashMap<>();fill(fieldMap, typeClass);java.lang.reflect.Field[] declaredFields = fieldMap.values().toArray(new java.lang.reflect.Field[0]);// 按字段名进行排序Arrays.sort(declaredFields, Comparator.comparing(java.lang.reflect.Field::getName));int length = declaredFields.length;List<ProtoFieldDescription> fieldDescriptionList = new ArrayList<>(length);java.lang.reflect.Field f;Class<?> type;io.protostuff.runtime.Field<?> field;ProtoFieldDescription d;int index = 0;for (java.lang.reflect.Field declaredField : declaredFields) {f = declaredField;type = f.getType();d = new ProtoFieldDescription(f.getName(), ++index, type.getCanonicalName());fieldDescriptionList.add(d);field = RuntimeFieldFactory.getFieldFactory(type, ID_STRATEGY).create(d.getIndex(), d.getFieldName(), f, ID_STRATEGY);fields.add(field);}RuntimeSchema schema = new RuntimeSchema(typeClass, fields, RuntimeEnv.newInstantiator(typeClass));String[] fieldNames = fieldDescriptionList.stream().map(ProtoFieldDescription::getFieldName).toArray(String[]::new);protoSchema = new ProtoSchema(readVersion(source), Arrays.hashCode(fieldNames), schema);// 本地缓存ProtoStuffSchemaSCHEMA_CACHE.putIfAbsent(className, protoSchema);// 缓存类结构信息到RediscacheFieldDescription(getCacheKey(className, protoSchema.getVersion()), JSON.toJSONString(fieldDescriptionList));return protoSchema;}static void fill(Map<String, java.lang.reflect.Field> fieldMap, Class<?> typeClass) {if (Object.class != typeClass.getSuperclass()) {fill(fieldMap, typeClass.getSuperclass());}for (java.lang.reflect.Field f : typeClass.getDeclaredFields()) {int mod = f.getModifiers();if (!Modifier.isStatic(mod) && !Modifier.isTransient(mod) && f.getAnnotation(Exclude.class) == null) {fieldMap.put(f.getName(), f);}

将 ProtoStuffSchema 缓存在本地,可以避免每次都重复解析类的结构,优化性能。本地缓存增加了有效期,可以保存 Redis 中的类结构信息和本地缓存中的一致,从而避免出现 Redis 中的数据过期导致老版本应用没法读取到对应版本类结构信息的情况。

RuntimeSchema(java.lang.Class, java.util.Collection<io.protostuff.runtime.field>, io.protostuff.runtime.RuntimeEnv.Instantiator) 这个构造方法是自适应的关键,正是因为有了这个构造方法,我们才能自己构建字段的顺序。

重写反序列化方法

流程图

首先,需要对字节数组进行解析,以得到相应的统一包装类。随后,需要根据缓存版本号和本地类版本号进行比较,以确定是否需要使用缓存中的数据。

生成版本号的逻辑是:基础版本号加上类的字段数量。如果版本号相同,我们还需要检查类的字段哈希值,然后根据字段哈希值获取排序后的字段名的哈希值。

代码

public <T> T deserialize(byte[] source, Class<T> type) throws SerializationException {if (isEmpty(source)) {return null;}try {ProtoStuffWrapper wrapper = new ProtoStuffWrapper();ProtostuffIOUtil.mergeFrom(source, wrapper, WRAPPER_SCHEMA);int cacheVersion = wrapper.getVersion();if (wrapper.isNoWrapperObject()) {// 集合数组,基本类型包装类 缓存对象,缓存与本地版本不一致,直接淘汰掉if (cacheVersion == 0 || cacheVersion == inferVersion(wrapper.getSource())) {return (T) wrapper.getSource();}return null;}String className = wrapper.getClassName();if (StringUtils.isNotEmpty(className)) {Class<?> typeClass = Class.forName(className);ProtoSchema protoSchema = getProtoSchema(className, typeClass);int localVersion = protoSchema.getVersion();if (cacheVersion >= localVersion) {Schema cachedSchema = getCachedSchema(wrapper, typeClass, protoSchema);if (cachedSchema != null) {Object newMessage = cachedSchema.newMessage();ProtostuffIOUtil.mergeFrom(wrapper.getData(), newMessage, cachedSchema);return (T) newMessage;}}}} catch (Exception e) {// 缓存,本地结构不一致, 打印一个错误日志}return null;}private ProtoSchema getProtoSchema(String className, Class<?> typeClass) throws InstantiationException, IllegalAccessException {ProtoSchema protoSchema = SCHEMA_CACHE.get(className);if (protoSchema != null) {return protoSchema;}return getCachedProtoSchema(className, typeClass.newInstance());}private Schema getCachedSchema(ProtoStuffWrapper wrapper, Class<?> typeClass, ProtoSchema protoSchema) {if (wrapper.getVersion() == protoSchema.getVersion()) {if (protoSchema.getHash() == wrapper.getClassHash()) {return protoSchema.getSchema();} else {// 缓存,本地结构不一致, 打印一个错误日志// logger.error("警告,本地与缓存中的版本号一致,但是字段顺序不一致,应用存在异常。请重新部署, className:{}", wrapper.getClassName());}}// 缓存中为新版本,本地为老版本return getSchemaFromCache(typeClass, wrapper);}

getCachedSchema

本地版本为老版本,缓存版本为新版本时,反序列化的时候需要先获取到 Redis 中新版本的类描述信息。为了避免重复请求 Redis,类描述信息也会在本地缓存一份数据。

 private <T> Schema<T> getSchemaFromCache(Class<?> typeClass, ProtoStuffWrapper wrapper) {String cacheKey = getCacheKey(wrapper.getClassName(), wrapper.getVersion());Schema schema = REMOTE_CLASS_SCHEMA_CACHE.get(cacheKey);if (schema != null) {return schema;}Map<String, ProtoFieldDescription> fieldDescriptionMap = getProtoFieldDescriptionMap(cacheKey);if (MapUtils.isEmpty(fieldDescriptionMap)) {return null;}java.lang.reflect.Field[] declaredFields = typeClass.getDeclaredFields();final ArrayList<io.protostuff.runtime.Field<T>> fields = new ArrayList<>(declaredFields.length);ProtoFieldDescription d;for (java.lang.reflect.Field field : declaredFields) {d = fieldDescriptionMap.get(field.getName());if (d != null) {Class<?> type = field.getType();if (Objects.equals(d.getType(), type.getCanonicalName())) {// 字段类型一致io.protostuff.runtime.Field<T> pField = RuntimeFieldFactory.getFieldFactory(type, ID_STRATEGY).create(d.getIndex(), d.getFieldName(), field, ID_STRATEGY);fields.add(pField);}}}schema = new RuntimeSchema(typeClass, fields, RuntimeEnv.newInstantiator(typeClass));REMOTE_CLASS_SCHEMA_CACHE.putIfAbsent(cacheKey, schema);return schema;}private Map<String, ProtoFieldDescription> getProtoFieldDescriptionMap(String key) {String cache = getStringFromRedis(key);if (StringUtils.isEmpty(cache)) {return new ConcurrentHashMap<>();}List<ProtoFieldDescription> fieldDescriptionList = JSON.parseArray(cache, ProtoFieldDescription.class);if (fieldDescriptionList == null) {return new ConcurrentHashMap<>();}return fieldDescriptionList.stream().collect(Collectors.toMap(ProtoFieldDescription::getFieldName, Function.identity(), (a, b) -> b));}

总结

ProtoStuff 是一个非常优秀的 Java 序列化框架,具有高效性、空间占用小、易用性和可扩展性等优点。

本方案在设计之初,考虑到数据库缓存序列化框架作为缓存组件的一部分,需要更多地为使用的业务方考虑。因此,改造方案花费了大量精力将框架做成自适应的。此举的目的在于,让接入方在使用过程中无需担心新增字段可能会引发的反序列化顺序问题,也无需额外维护 Tag 标签的顺序,更不需要对历史代码进行兼容改造。只要简单的升级一下依赖的二方包,就可以实现组件的升级。

附上官网文档地址:

https://protostuff.github.io/docs/protostuff-runtime/

网易云商·七鱼智能客服自适应 ProtoStuff 数据库缓存实践相关推荐

  1. 网易AI平台开放多项技术,助力网易七鱼智能客服升级

    杭州2018年9月11日电 /美通社/ -- 人工智能如何影响生活一直是个热议话题.近日,网易七鱼在其新媒体渠道上构想了一座"七鱼智慧生活街区",从与盼达用车.网易云音乐.来伊份. ...

  2. 网易云商-七鱼客服使用感受

    本次使用 七鱼客服 的体验不是很好,但是呢,遇到的问题,他们又给积极的解决了.说是不好吧,也还可以.只是给开发人员带去了不愉快. 售前咨询 在七鱼客服的首页,可以点击联系客服,就会跳转到聊天界面,开始 ...

  3. 流量封顶时代,容联七陌智能客服构筑企业“第二增长曲线”

    科技云报道原创. 流量封顶时代,大量企业为了活下去陷入竞争"内耗". 市场营销绞尽脑汁,客户服务也从幕后走向台前,脱离了传统的.被动的面对消费者的语境,成为完成客户体验闭环.主动触 ...

  4. 网易七鱼在线客服系统web端对接

    网易七鱼在线客服系统web端对接 文档:http://docs.qiyukf.com/?page_id=28 具体接入代码 function get_WYQY_info(){/*--------七鱼配 ...

  5. 生日快乐!为网易云商七周年Cheers!

       干货资料 免费领取    点击下方卡片或扫描二维码即可免费领取!

  6. 2023中国智能客服领域最具商业合作价值企业盘点

    ‍数据智能产业创新服务媒体 --聚焦数智 · 改变商业 随着科技的飞速发展,人工智能已经逐渐渗透到我们生活的方方面面.在这个波澜壮阔的时代背景下,智能客服作为人工智能与现代服务业的跨界融合,正以一种前 ...

  7. 直播回顾丨年终业绩怎么冲?增长黑盒、有赞、网易云商联合给你支招啦

    双11--观察中国消费的一扇窗. 今年双11前夕,全球性管理咨询公司贝恩(Bain & Company)发布了一份关于双11的"前瞻性"报告,在报告<2022年&qu ...

  8. 智能客服赛道:网易七鱼、微洱科技打法迥异

    配图来自Canva可画 随着时代的发展进步,不少行业相较于之前也发生了翻天覆地的变化,客服行业就是其中之一.在人工智能等技术的落地应用影响下,智能客服产品的渗透率是一路走高,整个智能客服行业也展现出了 ...

  9. 唯一智能客服企业!快商通入选猎云网企业服务领域最具影响力企业

    12月4日上午,猎云网在"逆势生长-NFS2020年度CEO峰会暨猎云网创投颁奖盛典"上颁布了2020「年度企业服务领域最具影响力创新企业TOP20」榜单,快商通凭借在智能客服领域 ...

最新文章

  1. debian 10 ssh简单配置
  2. 如何理解JS的单线程?
  3. MySQL技术内幕 InnoDB存储引擎 之 InnoDB体系架构
  4. 警告:push.default未设置;它的隐含值在Git 2.0中发生了变化
  5. 学会python能找工作吗-Python学到什么程度才可以去找工作?掌握这4点足够了!...
  6. 【Linux】目录组织结构、文件类型和文件权限
  7. SpringBoot 2.0静态资源映射
  8. new 实例化对象是啥意思_前端经典面试题解密:JS的new关键字都干了什么?
  9. 在置信区间下置信值的计算_使用自举计算置信区间
  10. python输入input数组_python怎么输入数组
  11. Spring MVC核心知识
  12. kafka官方文档学习笔记3--配置简述
  13. 树的平衡之AVL树——错过文末你会后悔,信我
  14. ClassNotFoundException和NoClassDefError之间的区别
  15. 如何用python刷屏_利用python实现在微信群刷屏的方法
  16. android如何使用代码截屏,android实现截屏功能代码
  17. 程序员的编辑器 notepad++ || XML编辑器
  18. dns服务器优化 360,360超级dns解析速度提升10倍
  19. 苹果手机各种尺寸详细表以及iPhoneX、iPhone 11、iPhone 12、iPhone 13 屏幕适配,状态栏高度问题
  20. Tortoise SVN Clean up失败的解决方法

热门文章

  1. php 如何获取一个变量的名字
  2. unity Shader Lab(cg hlsl glsl)着色器入门教程 以及 vs2019 支持unity shader语法(更新中2019.9.5)
  3. 标准IO:fseek/rewind/ftell 文件IO:lseek
  4. Java基础之流程控制语句
  5. 微盛等发布第三期私域运营白皮书
  6. 日立中端存储Gx00区分?
  7. 拼多多黄峥,背后的大学师妹
  8. ssm+jsp计算机毕业设计中医养生客户管理系统c3z16(程序+lw+源码+远程部署)
  9. 小米路由器4A千兆版刷入OpenWrt教程
  10. (转)IOS App中揉合讯飞SDK功能详细