Netty源码系列 之 AbstractUnsafe 高低水位线 ChannelOutboundBuffer 源码

目录

AbstractUnsafe.write [注:AbstractUnsafe为Netty定制的Unsafe,非jdk原生的Unsafe]

高低水位线补充

ChannelOutboundBuffer总览 & 高低水位线的剖析


AbstractUnsafe.write [注:AbstractUnsafe为Netty定制的Unsafe,非jdk原生的Unsafe]

Netty定制封装了jdk原生的Unsafe。Netty中使用的Unsafe并不是原生jdk的Unsafe类(suns.misc.Unsafe),而是做了定制优化的Unsafe。Unsafe专门针对于网络通信IO读写的底层操作,read 或 write。

Unsafe:

1.线程不安全的。所以我们要做到线程独享数据

2.Netty需要使用Unsafe去做网络通信IO:I即read(NioByteUnsafe),O即write(AbstractUnsafe)

ctx.writeAndFlush方法:

该方法是写出数据到网卡中,发送给对端。

但是该方法的底层很值得深究,分为两步:

1.write写出 : 把数据写入到应用层面的ByteBuffer缓冲区

2.flush刷新: 把ByteBuffer缓冲区的数据flush刷新到socket内核缓冲区,socket内核缓冲区的数据最终被操作系统写出到网卡设备,进而发送给对端服务器

  • debug所使用的代码
package com.messi.netty_source_03.class_04;import io.netty.bootstrap.Bootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.logging.LoggingHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;import java.net.InetSocketAddress;
import java.nio.charset.Charset;public class MyNettyClient {private static final Logger log = LoggerFactory.getLogger(MyNettyClient.class);public static void main(String[] args) throws InterruptedException {log.debug("myNettyClientStarter------");EventLoopGroup eventLoopGroup = new NioEventLoopGroup();Bootstrap bootstrap = new Bootstrap();bootstrap.channel(NioSocketChannel.class);Bootstrap group = bootstrap.group(eventLoopGroup);//32 ---> 1 IO操作 31线程bootstrap.handler(new ChannelInitializer<NioSocketChannel>() {@Overrideprotected void initChannel(NioSocketChannel ch) throws Exception {ch.pipeline().addLast(new LoggingHandler());ch.pipeline().addLast(new ChannelInboundHandlerAdapter(){@Overridepublic void channelActive(ChannelHandlerContext ctx) throws Exception {if (ctx.channel().isWritable()) {ByteBufAllocator alloc = ctx.alloc();ByteBuf buffer = alloc.buffer();buffer.writeCharSequence("xiaohei", Charset.defaultCharset());ctx.writeAndFlush(buffer);}}});}});Channel channel = bootstrap.connect(new InetSocketAddress(8000)).sync().channel();channel.closeFuture().sync();}
}
package com.messi.netty_source_03.class_04;import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.logging.LoggingHandler;public class NettyServer {public static void main(String[] args) throws InterruptedException {EventLoopGroup eventLoopGroup = new NioEventLoopGroup();ServerBootstrap serverBootstrap = new ServerBootstrap();serverBootstrap.channel(NioServerSocketChannel.class);serverBootstrap.group(eventLoopGroup);serverBootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {@Overrideprotected void initChannel(NioSocketChannel ch) throws Exception {ch.pipeline().addLast(new LoggingHandler());}});Channel channel = serverBootstrap.bind(8000).sync().channel();channel.closeFuture().sync();}
}
  • write 和 flush的总流程:【AbstractUnsafe】

1.channel.write() 把应用程序的数据写入到ByteBuf这一应用层缓冲区中

2.channel.flush() 把ByteBuf应用层缓冲区的数据刷新到send-socket发送方缓冲区

AbstractUnsafe.write 对应等价于 channel.write()。

下面就详细剖析一下write 和 flush的流程:

1.启动服务端

2.debug方式启动客户端

3.

4.

5.

6.

7.

8.使用Netty封装的定制Unsafe去写

9.

详细剖析一下filterOutboundMessage方法:

如果既不是1也不是2的话,那么Netty就不会采用直接内存了。因为非池化的直接内存要比堆内存慢10倍以上。

所以说:并不是调用了该newDirectBuffer(buf)就一定会申请直接内存,只是有可能会申请直接内存【1或2】

1和2都是使用?如下:

1是池化的直接内存

2是通过ThreadLocal构建的Stack对象池的直接内存。二者都是池化的,因为非池化的直接内存申请起来是极其耗费性能的,要比堆内存慢10倍以上。

ThreadLocalDirectBuffer()也属于池化的直接内存,但是它是属于线程独享局限性的池化。它是在通过当前线程所独享的内存空间通过ThreadLocal这一工具类去维护的Stack对象池来存储对象。

详细分析一下ThreadLocalDirectBuffer():

【但是默认情况下我们是不使用ThreadLocalDirectBuffer的,我们需要显示指定jvm参数:io.netty.threadLocalDirectBuffer=true,这样才会创建ThreadLocal-Stack对象池的直接内存】

进入ObjectPool:

进入Recycler:

当前线程独享的对象池,对象池由Stack维护的,维护的都是创建完成的ByteBuf直接内存。便于当前线程后续高性能的收发使用池化的ByteBuf直接内存。

详细剖析一下outboundBuffer.addMessage方法:

Entry.newInstance(xxx):

entry.pendingSize:为实际传输的消息数据大小加上一个固定死的头部字段长度

核心逻辑:高低水位线处理

到此为止,write的逻辑分析完毕,如果新增后msg总数据不高于高低水位线的上限,那么已经把msg写入到ByteBuffer缓冲区(应用层)啦。ByteBuffer缓冲区属于JVM级别的内存。

补充:为什么要设计一个高低水位线?Netty为了防止消息的堆积,设置了一个高低水位线,后续每一个在往缓冲区里面写数据时,都会做累加计算,与高水位线进行比较,如果超过高水位线了,那么就不能再继续写了,并且会通知NioSocketChannel。

10.接下来该分析flush的逻辑啦

flush0方法:

核心逻辑:

以下是doWrite方法中所调用的方法的分析,后续还会继续细致分析:

clearOpWrite方法:

nioBuffers方法:

为什么要一个Entry对应转化成一个独立的ByteBuffer?

因为这样容易操作,如果把多个Entry放到同一个ByteBuffer,也不是不可以,而是Netty需要特别复杂的通过flip(),write()等各种NIO方法进行调配,还不如一个Entry对应一个ByteBuffer,这样容易编写逻辑,不用纠结细节的各种指针的转换

nioBufferCount方法:获取到我们转换出来的ByteBuffer缓冲区的个数,便于后续真正的把ByteBuffer缓冲区数据写入到socket内核缓冲区

adjustMaxBytesPerGatheringWrite方法:

补充上图中的else-if条件的逻辑分析:

当attempted大于MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD且written小于尝试写入字节数的一半时(written < attempted >>> 1),表示实际写入的字节数较少。在这种情况下,将socket内核缓冲区的大小设置为尝试写入字节数的一半,并更新到NioSocketChannel的配置中。

这个逻辑的目的是根据实际写入字节数的情况来动态调整socket内核缓冲区的大小。当尝试写入的字节数较大而实际写入的字节数较少时,减小socket内核缓冲区的大小可以避免过度分配内存和网络资源,提高写操作的效率。

需要注意的是:MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD为socket内核缓冲区大小所对应attempted值的最低阈值!!如果attempted值小于该阈值,那么else if条件逻辑也不会成立!

这段代码中的常量MAX_BYTES_PER_GATHERING_WRITE_ATTEMPTED_LOW_THRESHOLD是一个预定义的阈值,用于控制何时触发socket内核缓冲区的大小的调整。具体的阈值大小和适用场景可能需要根据实际情况进行调整。

  • 下面对Unsafe#write方法 或 flush方法中的细节进行继续剖析

Unsafe#write:

1、outboundBuffer.addMessage方法:

按步骤分析:

第一步:

RECYCLER.get():

jdk原生的ThreadLocal or Netty定制的FastThreadLocal:

其实都是通过工具类xxxThreadLocal,把对象预先存储到线程所对应的内存中。

FastThreadLocal是对java原生ThreadLocal的优化,可能在对象存取方面性能变得更加优秀了。但是最终还是会保存在线程所对应的内存中的。

使用FastThreadLocal有什么好处吗?

我们是预先把对象数据存储到线程内存中的,在应用层面我们就是维护了一个对象池。我们知道,一个客户端连接到服务端,我们服务端都会分配一个线程来处理该客户端的各种操作【当然,线程NioEventLoop可以复用,如何复用的?肯定是通过Selector复用器,以及Reactor架构来共同完成复用的!Selector是复用的基础,Reactor就是多个线程对应多个独立的Selector,分治处理,如何分治处理的?一个或两个线程[一个或两个Selector]来处理Accept连接事件,多个线程来处理IO事件】。无论何时,只要是某一个客户端来连接上服务端,服务端都会甄别出该客户端所对应的线程,该线程可以通过FastThreadLocal这个数据结构进行取出对象池中预先存储好的ByteBuffer对象,这样效率极高。

CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD:

CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD这个常量标识其实就是记录着Entry这个类的所有属性所对应的元数据信息记录。

为什么该常量值可以通过JVM参数进行自定义设置?

灵活性是肯定的。

情况1:96字节是相对64bit计算机对应的JVM来说的,如果是32bit的计算机所对应JVM,肯定不会是96字节这么大。

情况2:如果使用了一些对象压缩技术进行存储msg对象(Entry),那么Entry所对应的属性值什么的肯定都会被压缩,那么96字节肯定使用不了这么多,肯定需要变化。

// Assuming a 64-bit JVM:

// - 16 bytes object header 16字节的头信息数据

// - 6 reference fields 6个引用类型属性

// - 2 long fields 2个long类型属性

// - 2 int fields 2个int类型属性

// - 1 boolean field 1个boolean类型属性

// - padding 空白填充数据【JVM要求类数据长度大小是8字节的整数倍,所以要padding填充】

CHANNEL_OUTBOUND_BUFFER_ENTRY_OVERHEAD的结构所对应到Entry属性如下:

经过计算:

2 long 8*2 = 16字节
2 int 4*2 = 8字节
1boolean 1 个字节
6 个引用 8*6 = 48个字节 【假设为64位,最大一个引用类型为8字节大小】
对象头信息 16字节 【对象头信息存储包含着Mark Word(32位机器占4字节,64位占8字节) 和 类的指针(32位机器占4字节,64位占8字节),假设是64位,那么对象头信息一共占用16字节大小】
以上加起来一共有89字节
padding 对齐填充 7字节

以上一共加起来就是默认的96字节大小!


JVM虚拟机 对象占用的内存都是8个字节的整数倍,所以96字节符合要求

第二步:

当加入一个msg时:当前msg会被封装成Entry0

当再加入一个msg时:当前msg被封装成Entry1

当再加入一个msg时:当前msg被封装成Entry2

总览图:

扩展:当执行到Unsafe#flush过程后,调用addFlush方法后会怎么样?

其实还是操作这个Entry链表,只不过会改变unflushedEntry为flushEntry

上图Entry链表变为如下所示:

单纯只是把unflushedEntry 变为 flushedEntry,其余一概不变,使用的还是原先的Entry链表

第三步:

实际开发过程中,如何使用到高低水位线来甄别是否可以写数据到ByteBuffer缓冲区?

使用API:ctx.channel().isWritable()

if (ctx.channel().isWritable()) {

//该方法isWritable()如果为true,说明没有超过高低水位线的上限,那么可以写到ByteBuffer缓冲区。如果为false,说明超过高低水位线的上限,那么设为不可写。

eg:

高低水位线补充

参考文章:https://blog.51cto.com/u_11259325/3055544

总结:高低水位线是针对应用程序是否可以写数据到应用层缓冲区的一种规则限制!进而实现流控,避免应用层缓冲区爆裂导致内存不足。

  • 高低水位线在Netty中的应用

在下一点中会介绍到

ChannelOutboundBuffer总览 & 高低水位线的剖析

其实在前面总结Unsafe#write and flush中已经对ChannelOutboundBuffer这一部分进行了重复性内容的分析,这里会再一次总览一下。

ChannelOutboundBuffer就是应用层缓冲区Buffer的一个具体实现!

1.write方法

2.outboundBuffer#addMessage

把msg消息数据存储到应用层outboundBuffer缓冲区中,但是注意存储方式:是把msg消息对象转换成一个个对应的Entry对象,然后构建成一个Entry链表进行存储起来。但是也不是无限制的存储,如果在应用层缓冲区中无限制的存储msg消息数据,应用层缓冲区空间是不是就爆裂了?对吧。一旦msg消息数据堆积过多,那么内存就会被撑爆。所以高低水位线的上限就起到很大作用啦。后续会细致分析高低水位线的上限和下限

addMessage这个方法执行完,所有Entry会被链接为一个链表。

3.每一次我们想要把msg消息数据写入到outBoundBuffer缓冲区时,都会做一次累加操作,把msg消息数据长度size累加,如果当前累加长度已经大于高低水位线的上限的话,那么就可以再写入msg消息数据到应用层缓冲区啦【在代码层面的体现为:setUnwritable(invokeLater)】

4.write流程做完,此时数据已经加载到应用层缓冲区啦。此时会执行flush操作,flush才是真正的把应用层缓冲区数据写到操作系统级别的socket内核缓冲区

5.

6.addFlush方法

该方法是做flush标记,表示可以把应用层缓冲区的数据flush刷新到socket内核缓冲区啦

addFlush方法执行完毕后,链条如下图所示:

7.flush0方法

8.doWrite方法

9.ByteBuffer[] nioBuffers = in.nioBuffers(1024, maxBytesPerGatheringWrite);

这个方法的作用:一句话说完,就是把Netty层面封装的一个个Entry对象进行一 一对应转换成Nio层面封装的ByteBuffer对象

如果Entry对象有多个,那么转换封装出来的ByteBuffer对象就有多个。

该方法会求出ByteBuffer的个数和所有ByteBuffer累加的size长度。

所以:nioBufferCount属性封装的就是ByteBuffer的个数。nioBufferSize封装的就是所有ByteBuffer累加的size长度。

细节剖析:

in.nioBuffers(1024, maxBytesPerGatheringWrite);这个方法会通过FastThreadLocal进行预先创建出一组长度为1024的ByteBuffer数组,但是这个ByteBuffer数组是无值的。后续我们需要通过nioBuffers这个方法的逻辑进行封装一个个的Entry对应成一个个的ByteBuffer对象,把ByteBuffer对象存储到FastThreadLocal预创建的ByteBuffer数组中。但是由于长度的限制和maxCount的限制,最多存储ByteBuffer的个数为1024。所以一次最多处理1024个Entry【1024个msg消息对象】

FastThreadLocal:之前也总结过FastThreadLocal的概述,这里不再过多阐述

while循环处理逻辑:

完成对一个个Entry的封装,封装成对应一个个ByteBuffer对象。并且把ByteBuffer对象存储到FastThreadLocal所对应预先创建的ByteBuffer数组中。

如果有多个Entry,那么调用该重载方法进行处理,处理封装成多个ByteBuffer。

10.in.removeBytes(localWrittenBytes);

remove方法:

removeEntry(e)方法:

decrementPendingOutboundBytes方法:

这个方法使用到了低水位线。在把应用层缓冲区的ByteBuffer数据写出到socket内核缓冲区后,这个方法会做累计减的操作,如果当前累计减之后的size大小是小于高低水位线的下限时,那么需要恢复或再一次申明为可写。这个可写是针对:应用程序可以写数据到应用层缓冲区ByteBuffer中。

高低水位线的初始化:

举个例子:

  • 总结

write过程:应用程序把数据写出到应用层缓冲区

把一个个msg对象封装成一个个Entry对象,一个个的Entry对象使用链表维护起来。这其实就是存储到应用层缓冲区啦。

flush过程:把应用层缓冲区数据写出到socket内核缓冲区
1.flush方法: 把Entry状态 改成Flush
2.dowrite方法:把一个个Entry对象对应转换成一个个ByteBuffer对象。把ByteBuffer对象数据写出到socket内核缓冲区。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://xiahunao.cn/news/2774063.html

如若内容造成侵权/违法违规/事实不符,请联系瞎胡闹网进行投诉反馈,一经查实,立即删除!

相关文章

学习好并用好大模型

大模型是个好东西&#xff0c;学好并用好益处多多~ 1. 运用大模型服务我们的工作 运用大模型服务于工作&#xff0c;可以从以下几个方面着手&#xff1a; 知识管理与检索&#xff1a; 利用大模型强大的自然语言处理能力&#xff0c;建立企业内部的知识库系统。员工可以通过提问…

Python速成篇(基础语法)上

引言 都是我手欠非要报什么python的计算机二级&#xff0c;现在好了假期不但要冲C艹&#xff0c;还要学个python&#xff0c;用了几天的时间速成了一下python的基础语法&#xff0c;其实在学会C的基础上&#xff0c;py学起来是非常的快啊。这篇博客呢&#xff0c;建议有一定语…

Golang 基础 Go Modules包管理

Golang 基础 Go Modules包管理 在 Go 项目开发中&#xff0c;依赖包管理是一个非常重要的内容&#xff0c;依赖包处理不好&#xff0c;就会导致编译失败&#xff0c;本文将系统介绍下 Go 的依赖包管理工具。 我会首先介绍下 Go 依赖包管理工具的历史&#xff0c;并详细介绍下…

Wireshark不显示Thrift协议

使用Wireshark对thrift协议进行抓包&#xff0c;但是只显示了传输层的tcp协议&#xff1a; "右键" -> "Decode As" 选择thrift的tcp端口 将“当前”修改为Thrift&#xff0c;然后点击“确定” 设置后&#xff0c;可以发现Wireshark里面显示的协议从Tcp变…

#Z2322. 买保险

一.题目 二.思路 1.暴力 训练的时候&#xff0c;初看这道题&#xff0c;这不就打个暴力吗&#xff1f; 2.暴力代码 #include<bits/stdc.h> #define int long long using namespace std; int n,m,fa,x,y,vis[1000001],ans; vector<int> vec[1000001]; void dfs(i…

网站被攻击有什么办法呢?

最近&#xff0c;德迅云安全遇到不少网站用户遇到攻击问题&#xff0c;来咨询安全解决方案。目前在所有的网络攻击方式中&#xff0c;DDoS是最常见&#xff0c;也是最高频的攻击方式之一。不少用户网站上线后&#xff0c;经常会遭受到攻击的困扰。有些攻击持续时间比较短影响较…

Redis——缓存设计与优化

讲解Redis的缓存设计与优化&#xff0c;以及在生产环境中遇到的Redis常见问题&#xff0c;例如缓存雪崩和缓存穿透&#xff0c;还讲解了相关问题的解决方案。 1、Redis缓存的优点和缺点 1.1、缓存优点&#xff1a; 高速读写&#xff1a;Redis可以帮助解决由于数据库压力造成…

以“防方视角”观JS文件信息泄露

为方便您的阅读&#xff0c;可点击下方蓝色字体&#xff0c;进行跳转↓↓↓ 01 案例概述02 攻击路径03 防方思路 01 案例概述 这篇文章来自微信公众号“黑白之道”&#xff0c;记录的某师傅从js文件泄露接口信息&#xff0c;未授权获取大量敏感信息以及通过逻辑漏洞登录管理员账…

docker安装-centos

Docker CE 支持 64 位版本 CentOS 7&#xff0c;并且要求内核版本不低于 3.10 卸载旧版本Docker sudo yum remove docker \ docker-common \ docker-selinux \ docker-engine使用yum安装 yum 更新到最新版本: sudo yum update执行以下命令安装依赖包&#xff1a; sudo yum…

e5 服务器具备哪些性能特点?

随着云计算和大数据技术的不断发展&#xff0c;服务器作为数据中心的核心设备&#xff0c;其性能特点也日益受到关注。其中&#xff0c;E5服务器作为当前主流的服务器类型之一&#xff0c;具备许多优秀的性能特点。本文将详细介绍E5服务器的性能特点&#xff0c;帮助读者更好地…

day7(2024/2/8)

mainui.h(第二个界面) #ifndef MAINUI_H #define MAINUI_H#include <QWidget>namespace Ui { class MainUi; }class MainUi : public QWidget {Q_OBJECTpublic:explicit MainUi(QWidget *parent nullptr);~MainUi();public slots:void main_ui();private:Ui::MainUi *u…

人工智能基础部分24-人工智能的数学基础,汇集了人工智能数学知识最全面的概况

、 大家好&#xff0c;我是微学AI&#xff0c;今天给大家介绍一下人工智能基础部分24-人工智能的数学基础&#xff0c;汇集了人工智能数学知识最全面的概况&#xff0c;深度学习是一种利用多层神经网络对数据进行特征学习和表示学习的机器学习方法。要全面了解深度学习的数学基…

Spark安装(Yarn模式)

一、解压 链接&#xff1a;https://pan.baidu.com/s/1O8u1SEuLOQv2Yietea_Uxg 提取码&#xff1a;mb4h tar -zxvf /opt/software/spark-3.0.3-bin-hadoop3.2.tgz -C /opt/module/spark-yarn mv spark-3.0.3-bin-hadoop3.2/ spark-yarn 二、配置环境变量 vim /etc/profile…

VTK 体渲染设置帧率

当我们的mapper采样距离设置较低或者硬件性能不太好时&#xff0c;体渲染交互会有卡顿现象。为了提高交互时的流畅性&#xff0c;可以设置交互器的SetDesiredUpdateRate来降低采样率进而避免卡顿现象。 vtkNew<vtkRenderWindowInteractor> iren; iren->SetDesiredUpd…

WordPress如何实现随机显示一句话经典语录?怎么添加到评论框中?

我们在一些WordPress网站的顶部或侧边栏或评论框中&#xff0c;经常看到会随机显示一句经典语录&#xff0c;他们是怎么实现的呢&#xff1f; 其实&#xff0c;boke112百科前面跟大家分享的『WordPress集成一言&#xff08;Hitokoto&#xff09;API经典语句功能』一文中就提供…

Unity类银河恶魔城学习记录4-4 4-5 P57-58 On Hit Impactp- Attack‘direction fix源代码

Alex教程每一P的教程原代码加上我自己的理解初步理解写的注释&#xff0c;可供学习Alex教程的人参考 此代码仅为较上一P有所改变的代码 【Unity教程】从0编程制作类银河恶魔城游戏_哔哩哔哩_bilibili Entity.cs using System.Collections; using System.Collections.Generic;…

【操作系统】MacOS虚拟内存统计指标

目录 命令及其结果 参数解读 有趣的实验 在 macOS 系统中&#xff0c;虚拟内存统计指标提供了对系统内存使用情况和虚拟内存操作的重要洞察。通过分析这些指标&#xff0c;我们可以更好地了解系统的性能状况和内存管理情况。 命令及其结果 >>> vm_stat Mach Virtu…

Qt程序设计-读写CSV文件

本文实例演示Qt读写CSV文件实现 创建项目 添加两个按钮和一个显示路径的label 界面如下 UI界面 <?xml version="1.0" encoding="UTF-8"?> <ui version="4.0"><class>MainWindow</class><widget class="QM…

Redis篇之集群

一、主从复制 1.实现主从作用 单节点Redis的并发能力是有上限的&#xff0c;要进一步提高Redis的并发能力&#xff0c;就需要搭建主从集群&#xff0c;实现读写分离。主节点用来写的操作&#xff0c;从节点用来读操作&#xff0c;并且主节点发生写操作后&#xff0c;会把数据同…

年货大数据(电商平台年货节数据):水果销售额增长72%,海鲜肉类涨幅高于蔬菜

春节临近&#xff0c;生鲜又成了线上线下“叫卖”狠&#xff0c;竞争大&#xff0c;盈利好的行业之一。无论是线下商超&#xff0c;还是线上电商&#xff0c;生鲜行业在年货节期间不愁没有市场需求。 根据鲸参谋数据显示&#xff0c;1月前三周京东平台生鲜市场整体销量超3300万…