基于Netty的IM系统设计
mhr18 2025-08-03 06:15 3 浏览 0 评论
一、基本思路:
在上一篇中分享了短连接下IM设计,这篇来分享基于长连接设计的IM系统。
长连接由于不用每次请求后就释放连接,所以其效率性能比短连接的高上不少,但是长连接的实现比短连接要复杂不少,需要通过心跳去维护连接,避免连接因为长时间没有发消息而被关闭,而且由于移动端网络极其不稳定,网络抖动厉害,而心跳检测有一定的延时性,因此极其容易丢消息,因此需要一个机制来保证消息的可靠性,在消息丢失后能进行消息重试。
在IM-Service部署了多个实例后还会面临新的问题,首先是路由问题,客户端只能连接到其中一个实例上,因此客户端需要先拉取IM-Service实例列表,通过一个路由算法路由到其中一个实例并连接,得到一个Channel,以后发消息都通过这个Channel进行。另一个问题是消息的发送方和接收方连接的不是同一个服务实例的问题,如下图所示:
上图所示,客户端C1连接上服务实例S1,客户端C2连接上服务实例S2,现在C1要给C2发消息,C1首先将消息发到S1上,但是S1上没有C2连接的Channel,因此S1无法将消息推给C2,解决办法就是S1将消息转给S2,然后S2发现当前服务实例上有C2的连接Channel,因此S2可以将消息推给C2。
这里只是举例两个服务实例的情况,实际生产环境中可能有多个服务实例,S1要如何知道C2连接的服务实例是哪个了,一般有两种解决办法:
1、增加一个Register服务实例登记所有的客户端连接的服务实例的映射关系,每个服务实例有客户端连接上时就登记到Register服务,客户端下线时就从Register服务删除,C1给C2发消息时发现C2不在S1服务实例上,于是S1从Register服务中查询到C2所在的服务实例,然后S1将消息转给这个服务实例,由这个服务实例将消息推送给客户端C2。这种方式下其他与这条消息无关的服务实例不用做无用的操作,但是会增加调用链路的长度,切同时如果Register服务出现故障就会导致整个系统无法工作,因此Register服务要部署多个服务实例来保证高可用。
2、每个服务实例在启动时都订阅MQ上一个指定的Topic,当S1发现当前服务实例上没有C2的连接Channel,就向MQ指定的Topic发布一条消息,然后所有的服务实例都能收到这条消息,然后都在自己的连接Channel池中查找是否有接收客户端的Channel,有的话则通过这个Channel推送消息到客户端。这种方式实现简单,可靠性简单,缺点是与这条消息无关的服务实例都要做一次无用的查找。
在本IM系统设计中采用了方案二,网络通讯框架采用了Netty,MQ可以采用RabbitMQ, RocketMQ等等,本实例中我们采用了Redis的发布订阅,下面将介绍Redis的发布订阅机制。
二、redis的发布订阅机制:
1、基本概念:
Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者 (sub) 接收消息。
Redis 客户端可以订阅任意数量的频道。
下图展示了频道 channel1 , 以及订阅这个频道的三个客户端 —— client2 、 client5 和 client1 之间的关系:
当有新消息通过 PUBLISH 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的三个客户端:
2、发布订阅命令:
例如下面创建了订阅频道名为 MyChannel
127.0.0.1:6379> subscribe MyChannel
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "MyChannel"
3) (integer) 1
然后在另一个客户端给这个频道发布消息:
127.0.0.1:6379> publish MyChannel aaaa
(integer) 1
3、SpringBoot中使用Redis的发布订阅:
首先要引入Redis的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<scope>provided</scope>
</dependency>
接下来发布消息到频道:
ini复制代码String message = node.toString();
redisTemplate.convertAndSend("SocketCloseTopic", new DistributedMessage(SendType.ALL, channelType, message));
最后是订阅消息,在框架中定义了一个MessageListener接口,我们只需要实现这个接口即可收到指定频道的消息:
@Component
public class WebSocketChannelCloseListener implements MessageListener {
private RedisSerializer<String> stringSerializer = new StringRedisSerializer();
@Override
public void onMessage(@NonNull Message message, @Nullable byte[] bytes) {
byte[] channel = message.getChannel();
byte[] body = message.getBody();
String msgChannel = stringSerializer.deserialize(channel);
String msgBody = stringSerializer.deserialize(body);
logger.trace("监听到WS-ChannelClose消息,channel:{},body:{}", msgChannel, msgBody);
DistributedMessage distributedMessage = JSON.parseObject(msgBody, DistributedMessage.class);
//........省略逻辑处理代码...........
}
}
那么我们定义的这个MessageListener是监听的哪个频道的消息了,在定义
RedisMessageListenerContainer的bean时指定的:
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(StringRedisTemplate stringRedisTemplate, WebSocketChannelCloseListener webSocketChannelCloseListener) {
RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
redisMessageListenerContainer.setConnectionFactory(stringRedisTemplate.getRequiredConnectionFactory());
redisMessageListenerContainer.addMessageListener(webSocketChannelCloseListener, new ChannelTopic("SocketCloseTopic"));
return redisMessageListenerContainer;
}
三、系统设计:
1、整体架构:
1)、客户端封装了一个SDK,在 SDK中首先从注册中心Eureka中拿到所有的IM-Service的实例列表缓存,然后用一个负载均衡算法将当前客户端路由到其中一个服务实例上,最后通过Socket / WebSocket连接上指定的服务实例;
2)、服务端的IM-Service整个基于Netty框架,客户端的请求首先达到Netty框架,IM-Service实现接收到消息的处理逻辑;
3)、IM-Service启动时会注册到注册中心,关闭时会从注册中心删除,同时IM-Service会订阅Redis指定的Topic;
4)、IM-Service收到消息后,如果接收方在当前实例上则通过Netty直接推送给客户端,如果不在当前服务实例上则向Redis发布一个消息,其他IM-Service实例收到消息,查找消息接收方是否连接的当前服务实例,如果是的话则推送给客户端;
5)、将消息推送给客户端的同时,启动异步任务将消息保存到MySQL数据库。
2、Netty初始化:
public void start() {
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 1024)
.childHandler(nettyChannelInitializer)
.childOption(ChannelOption.SO_KEEPALIVE, true);
//绑定端口,开始接收进来的连接
ChannelFuture f = serverBootstrap.bind(new InetSocketAddress(port)).sync();
//等待服务器socket关闭
f.channel().closeFuture().sync();
}
//NettyChannelInitializer.initChannel
protected void initChannel(Channel ch) {
ChannelPipeline pipeline = ch.pipeline();
//解码
pipeline.addLast(new LengthFieldBasedFrameDecoder(100 * 1024 * 1024, 0, 4, 0, 4));
pipeline.addLast(nettyReqDecoder);
//编码
pipeline.addLast(new LengthFieldPrepender(4));
pipeline.addLast(nettyReqEncoder);
pipeline.addLast(nettyServerHandler);
}
public class NettyServerHandler extends SimpleChannelInboundHandler<NettyReqWrapper> {
@Autowired
private NettySessionManager nettySessionManager;
@Autowired
private NettyHandlerManager nettyHandlerManager;
protected void channelRead0(ChannelHandlerContext ctx, NettyReqWrapper nettyReqWrapper) {
Channel incoming = ctx.channel();
Attribute<SessionUser> attribute = incoming.attr(NettyServer.REDIS_ATTR_SOCKET);
SessionUser sessionUser = attribute.get();
if (null != sessionUser && sessionUser.isAuthorized()) {
//回执消息直接返回,不做任何处理
if (nettyReqWrapper.getCmd() < 0) {
return;
}
//handler处理
nettyHandlerManager.process(incoming, sessionUser, nettyReqWrapper);
} else {
checkAndLogin(nettyReqWrapper, incoming, attribute);
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
Channel incoming = ctx.channel();
SessionUser sessionUser = incoming.attr(NettyServer.REDIS_ATTR_SOCKET).get();
nettySessionManager.removeSession(sessionUser.getUserId(), sessionUser.getChannelType());
ctx.close();
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
Channel incoming = ctx.channel();
SessionUser sessionUser = incoming.attr(NettyServer.REDIS_ATTR_SOCKET).get();
nettySessionManager.removeSession(sessionUser.getUserId(), sessionUser.getChannelType());
ctx.close();
}
}
start以及
NettyChannelInitializer.initChannel方法中的代码很好理解,就是启动Netty服务并初始化Netty的流水线,对Netty稍微有点了解的都很好理解。
初始化时指定了业务处理Handler类NettyServerHandler,channelRead0接收到客户端的消息,读取到SessionUser,然后交给nettyHandlerManager进行处理,如果读取不到User则调用登录处理,这块后面再介绍。channelInactive、exceptionCaught部分就是关闭和删除Session及连接。
3、Session管理:
实现Session管理的类是NettySessionManager,从这个类中不仅可以读取到当前连接的客户端信息,而且还将所有连接这台服务实例的客户端的Channel保存到一个Map中。代码如下:
public class SessionUser implements Serializable {
private boolean authorized;
private Long loginTime;
private String userName;
private Integer gender;
private String userHead;
private String city;
//...........
}
public class NettySessionManager extends AbstractSessionManager {
@Override
public AttributeKey<SessionUser> getAttributeKey() {
return WebSocketServer.REDIS_ATTR_WEBSOCKET;
}
}
public abstract class AbstractSessionManager {
// 用户会话缓存
protected static final ConcurrentHashMap<Long, ConcurrentHashMap<ChannelType, Channel>> sessionCache = new ConcurrentHashMap<>();
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private MachineApi machineApi;
public void addSession(Long userId, ChannelType channelType, Channel channel) {
ConcurrentHashMap<ChannelType, Channel> userMap = sessionCache.get(userId);
if (null == userMap) {
userMap = new ConcurrentHashMap<>();
userMap.put(channelType, channel);
sessionCache.put(userId, userMap);
return;
}
Channel oldSession = userMap.get(channelType);
// 若用户重复登录,覆盖信息,并断开本地前一次连接
if (oldSession != null) {
oldSession.close();
}
//断开分布式服务器中的同渠道连接
ChannelClose channelClose = new ChannelClose(userId, machineApi.getMachineId());
redisTemplate.convertAndSend(getSocketCloseTopic(), new DistributedMessage(SendType.ALL, channelType, JSON.toJSONString(channelClose)));
//本地缓存替换为新的连接
userMap.put(channelType, channel);
}
public Channel getSession(Long userId, ChannelType channelType) {
ConcurrentHashMap<ChannelType, Channel> userMap = sessionCache.get(userId);
if (null == userMap) {
return null;
}
return userMap.get(channelType);
}
//...............
}
1)、通过
NettySessionManager.getAttributeKey可以获取到连接的客户端信息,信息内容见SessionUser;
2)、AbstractSessionManager保存了所有连接到这个服务实例的客户端的Channel列表,这个Map分两层,第一层的Key为UserId,第二层的Key为ChannelType,即客户端类型,有App、web等;
3)、addSession首先查找是否已经存在相同UserId和ChannelType的Channel,如果不存在则直接添加到Map中,如果存在则将原Channel关闭,并将新的Channel添加到Map。
4)、getSession实现了按照userId和ChannelType的查找;
4、消息发送:
public <T extends IReq> boolean send(Long receiveId, ChannelType channelType, @NonNull T data) {
JsonNode node = objectMapper.convertValue(data, JsonNode.class);
String message = node.toString();
Channel channel = nettySessionManager.getSession(receiveId, channelType);
if (null != channel && channel.isActive()) {
return localSend(receiveId, channelType, message);
} else {
redisTemplate.convertAndSend(NettyServer.REDIS_TOPIC_SOCKET, new DistributedMessage(receiveId, channelType, message));
return true;
}
}
public boolean localSend(Long receiveId, ChannelType channelType, String message) {
Channel channel = nettySessionManager.getSession(receiveId, channelType);
if (null != channel && channel.isActive()) {
NettyReqWrapper nettyReqWrapper = new NettyReqWrapper(cmd, message);
channel.writeAndFlush(nettyReqWrapper);
return true;
}
return false;
}
send方法首先在当前服务实例缓存的Channel的map中查找Channel,如果查找到了则调用localSend发送,localSend其实就是通过Channel直接将消息推送给客户端;如果没查找到则给redis发布消息,其他服务实例收到这个消息则在本实例查找Channel,查找到则直接推送消息:
public class NettySendMessageListener implements MessageListener {
private RedisSerializer<String> stringSerializer = new StringRedisSerializer();
@Autowired
private NettySendManager nettySendManager;
@Override
public void onMessage(@NonNull Message message, @Nullable byte[] bytes) {
byte[] channel = message.getChannel();
byte[] body = message.getBody();
String msgChannel = stringSerializer.deserialize(channel);
String msgBody = stringSerializer.deserialize(body);
DistributedMessage distributedMessage = JSON.parseObject(msgBody, DistributedMessage.class);
nettySendManager.localSend(distributedMessage.getReceiveId(), distributedMessage.getChannelType(), distributedMessage.getMessage());
}
}
这个类实现了MessageListener接口,因此就能在onMessage中接收到上一步给redis发布的消息。onMessage中也是先解析出消息中的内容,调用localSend查找Channel,查找到则通过Channel推送;
5、编码解码器:
在Netty初始化时即在流水线中指定了编码器NettyReqEncoder和解码器NettyReqDecoder:
public class NettyReqWrapper implements Serializable {
private int cmd;
private String message;
//.........
}
public class NettyReqEncoder extends MessageToMessageEncoder<NettyReqWrapper> {
@Override
protected void encode(ChannelHandlerContext ctx, NettyReqWrapper nettyReqWrapper, List<Object> out) {
byte[] byteMsg = new byte[0];
if (StringUtils.hasText(nettyReqWrapper.getMessage())) {
byteMsg = nettyReqWrapper.getMessage().getBytes(charset);
}
ByteBuf byteBuf = ctx.alloc().buffer(byteMsg.length + 4);
byteBuf.writeInt(nettyReqWrapper.getCmd());
if (byteMsg.length > 0) {
byteBuf.writeBytes(byteMsg);
}
out.add(byteBuf);
}
}
public class NettyReqDecoder extends MessageToMessageDecoder<ByteBuf> {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
NettyReqWrapper nettyReqWrapper = new NettyReqWrapper();
int len = in.readableBytes();
if (len < 4) {
in.skipBytes(len);
throw new BusinessException("NettyReqDecoder ByteBuf readableBytes is less than 4");
}
int cmd = in.getInt(0);
nettyReqWrapper.setCmd(cmd);
in.skipBytes(4);
nettyReqWrapper.setMessage(in.toString(charset));
out.add(nettyReqWrapper);
}
}
可以看到在编码器NettyReqEncoder中,只是在消息内容前面加了一个四个字节的CmdId,解码器NettyReqDecoder中首先解析出CmdId,然后再将消息内容进入下一步流程处理。
其实CmdId就是定义了一系列的常量,如下:
//登录
public static final int CMD_LOGIN = 1000;
//心跳
public static final int CMD_HEARTBEAT = 1001;
//送礼
public static final int CMD_SEND_GIFT = 3000;
//直播间爆灯
public static final int CMD_SEND_LIGHT = 4000;
//------begin服务端请求---------
//登录鉴权结果
public static final int CMD_LOGIN_RESULT = 2000;
//响应的转换因子
private static final int CMD_RSP = -1;
6、客户端消息处理器:
客户端消息处理器实现在NettyHandlerManager中:
public class NettyHandlerManager extends AbstractHandlerManager {
@Autowired
private HeartbeatHandler heartbeatHandler;
public void process(Channel channel, SessionUser sessionUser, NettyReqWrapper nettyReqWrapper) {
Cmd cmd = inputMap.get(nettyReqWrapper.getCmd());
//回复收到消息的回执
Req receipt = new Req(CmdConstants.getReceiptCmd(cmd.getUniqueCode()));
channel.writeAndFlush(new NettyReqWrapper(receipt.getCmd(), JSON.toJSONString(receipt)));
//记录心跳数据
heartbeatHandler.heartbeat(sessionUser);
//............
}
}
process中首先根据消息中的CmdId查找到对应的Cmd实例,然后给客户端发送消息回执,相当于告诉客户端你发来的这条消息我已经收到了。最后记录心跳数据到redis缓存,其实这里不光认为只有心跳包是心跳数据,收到的任何消息都可以认为是心跳数据。这里的记录心跳数据其实就是把UserId和在线状态更新到redis,这样方便客户端查询在线用户信息。
inputMap根据CmdId查找Cmd实例,inputMap的初始化代码如下:
public Set<InputCmd> initInput() {
Set<InputCmd> cmdSet = new HashSet<>();
cmdSet.add(new NettyInputCmd<>(CmdConstants.CMD_LOGIN, "系统", "登录", LoginReq.class, null));
cmdSet.add(new NettyInputCmd<>(CmdConstants.CMD_HEARTBEAT, "系统", "心跳", Req.class, null));
//................
return cmdSet;
}
Cmd的定义如下:
public interface Cmd<M extends IReq> {
Protocol getProtocol();
FromType getFromType();
int getUniqueCode();
String getModule();
String getRemark();
Class<M> getMClass();
BiConsumer<SessionUser, M> getConsumer();
}
public abstract class InputCmd<M extends IReq> implements Cmd<M> {
private Protocol protocol;
private FromType fromType = FromType.CLIENT;
private int uniqueCode;
private String module;
private String remark;
private Class<M> mClass;
private BiConsumer<SessionUser, M> consumer;
//............
}
鉴权如何处理了,在
NettyServerHandler.channelRead0中读取到消息后判断如果如果没有鉴权信息则执行鉴权操作:
private void checkAndLogin(NettyReqWrapper nettyReqWrapper, Channel incoming, Attribute<SessionUser> attribute) {
LoginReq loginReq = JSON.parseObject(nettyReqWrapper.getMessage(), LoginReq.class);
String userId = loginReq.getUserId();
String token = loginReq.getToken();
String appVersion = loginReq.getAppVersion();
String devType = loginReq.getDevType();
ChannelType channelType = ChannelType.of(loginReq.getDevType());
if (enableAuth) {
//鉴权
LoginResultReq loginResultReq = authManager.checkUserToken(tenantId, userId, token, DevType.getEnumByType(loginReq.getDevType()));
if (!ResponseConstant.SUCCESS.getCode().equals(loginResultReq.getCode())) {
// 鉴权失败消息
incoming.writeAndFlush(new NettyReqWrapper(loginResultReq.getCmd(), JSON.toJSONString(loginResultReq)));
incoming.close();
return;
}
}
//保存会话信息
SessionUser sessionUser = new SessionUser();
sessionUser.setAuthorized(true);
//..............
attribute.set(sessionUser);
nettySessionManager.addSession(Long.valueOf(userId), channelType, incoming);
//登录成功
LoginResultReq loginResultReq = new LoginResultReq(CmdConstants.CMD_LOGIN_RESULT, ResponseConstant.SUCCESS.getCode(), ResponseConstant.SUCCESS.getMsg());
incoming.writeAndFlush(new NettyReqWrapper(loginResultReq.getCmd(), JSON.toJSONString(loginResultReq)));
}
首先解析出鉴权的必要信息,然后调用
authManager.checkUserToken进行鉴权,接着构造SessionUser信息到attribute并添加Session,最后给客户端推送鉴权成功的消息。
四、常见问题:
1、丢消息问题:
客户端将消息发送到服务端有可能会丢消息,服务端将消息推送到客户端也有可能会丢消息。
解决丢消息问题的最好方法就是Ack回执机制,即将消息发送出去后,同时将消息丢到MQ的延时队列,对方收到消息后就发送回执Ack消息,收到回执后可以将回执信息保存到redis缓存中;当MQ中延时队列的消息超时后就会再次被消费,消费时首先检查有没有回执消息,有的话说明消息对方确认收到了,则丢弃返回,如果没有的话则说明消息可能丢失了,需要再重试发送出去,同时再次丢到MQ的延时队列。这里可以设置重试的最大次数,比如设置为3次,如果连续三次都没能成功推送出去,说明客户端可能不在线,则不再继续推送,而是将消息保存到数据库中作为离线消息。当客户端下次登录上来时调用接口查询离线消息。
2、消息乱序的问题:
由于消息的发送是异步的,因此有可能出现先发的消息后到达,后发的消息先到达,就会出现消息乱序的问题。
第一反应是在消息里面加一个时间戳,但是由于客户端的时间有可能就是错的,这样会导致消息的顺序错的更离谱。如果在消息达到服务端后,由服务端添加时间戳了,虽然能解决大部分情况下的消息顺序问题,但是如果服务实例由多个,服务器本地时间有误差,会导致时间依然不是很准确。
一个比较好的方法是通过一个发号器来生成一个有序的唯一ID,所有消息在达到服务端后,都会调用发号器来生成一个有序唯一的ID,不管服务实例有多少个,都会调用这个发号器,消息按照调用发号器的顺序来排序,这样就不会出现乱序的问题。
3、消息重复问题:
消息发送之后都会等待对方发送回执消息过来,如果回执消息丢失了,那么发送方就认为对方没收到消息而再次重试发送,这样会导致对方一条消息重复收到多次的问题。
解决方法也很简单,由发送方client-A生成一个消息去重的msgid,保存在“等待ack队列”里,同一条消息使用相同的msgid来重传,供client-B去重。
链接:
https://juejin.cn/post/7323488258025177103
相关推荐
- Java面试题及答案总结(2025版)
-
大家好,我是Java面试陪考员最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Redis、Linux、SpringBoot、Spring、MySQ...
- Java面试题及答案最全总结(2025春招版)
-
大家好,我是Java面试分享最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Spring、MySQL、JVM、Redis、Linux、Spring...
- Java面试题及答案最全总结(2025版持续更新)
-
大家好,我是Java面试陪考员最近很多小伙伴在忙着找工作,给大家整理了一份非常全面的Java面试题及答案。涉及的内容非常全面,包含:Spring、MySQL、JVM、Redis、Linux、Sprin...
- 蚂蚁金服面试题(附答案)建议收藏:经典面试题解析
-
前言最近编程讨论群有位小伙伴去蚂蚁金服面试了,以下是面试的真题,跟大家一起来讨论怎么回答。点击上方“捡田螺的小男孩”,选择“设为星标”,干货不断满满1.用到分布式事务嘛?为什么用这种方案,有其他方案...
- 测试工程师面试必问的十道题目!全答上来的直接免试
-
最近参加运维工程师岗位的面试,笔者把自己遇到的和网友分享的一些常见的面试问答收集整理出来了,希望能对自己和对正在准备面试的同学提供一些参考。一、Mongodb熟悉吗,一般部署几台?部署过,没有深入研究...
- 10次面试9次被刷?吃透这500道大厂Java高频面试题后,怒斩offer
-
很多Java工程师的技术不错,但是一面试就头疼,10次面试9次都是被刷,过的那次还是去了家不知名的小公司。问题就在于:面试有技巧,而你不会把自己的能力表达给面试官。应届生:你该如何准备简历,面试项目和...
- java高频面试题整理
-
【高频常见问题】1、事务的特性原子性:即不可分割性,事务要么全部被执行,要么就全部不被执行。一致性或可串性:事务的执行使得数据库从一种正确状态转换成另一种正确状态隔离性:在事务正确提交之前,不允许把该...
- 2025 年最全 Java 面试题,京东后端面试面经合集,答案整理
-
最近京东搞了个TGT计划,针对顶尖青年技术天才,直接宣布不设薪资上限。TGT计划面向范围包括2023年10月1日到2026年9月30日毕业的海内外本硕博毕业生。时间范围还...
- idGenerator测评
-
工作中遇到需要生成随机数的需求,看了一个个人开发的基于雪花算法的工具,今天进行了一下测评(测试)。idGenerator项目地址见:https://github.com/yitter/IdGenera...
- 2024年开发者必备:MacBook Pro M1 Max深度体验与高效工作流
-
工作机器我使用的是一台16英寸的MacBookProM1Max。这台电脑的表现堪称惊人!它是我用过的最好的MacBook,短期内我不打算更换它。性能依然出色,即使在执行任务时也几乎听不到风扇的...
- StackOverflow 2022 年度调查报告
-
一个月前,StackOverflow开启了2022年度开发者调查,历时一个半月,在6月22日,StackOverflow正式发布了2022年度开发者调查报告。本次报告StackO...
- 这可能是最全面的SpringDataMongoDB开发笔记
-
MongoDB数据库,在最近使用越来越广泛,在这里和Java的开发者一起分享一下在Java中使用Mongodb的相关笔记。希望大家喜欢。关于MongoDB查询指令,请看我的上一篇文章。SpringD...
- Mac M2 本地部署ragflow
-
修改配置文件Dockerfile文件ARGNEED_MIRROR=1//开启国内镜像代理docker/.envREDIS_PORT=6380//本地redis端口冲突RAGFLOW_IMA...
- 别再傻傻分不清!localhost、127.0.0.1、本机IP,原来大有讲究!
-
调试接口死活连不上?部署服务队友访问不了?八成是localhost、127.0.0.1、本机IP用混了!这三个看似都指向“自己”的东西,差之毫厘谬以千里。搞不清它们,轻则调试抓狂,重则服务裸奔。loc...
- 我把 Mac mini 托管到机房了:一套打败云服务器的终极方案
-
我把我积灰的Macmini托管到机房了,有图有真相。没想到吧?一台在家吃灰的苹果电脑,帮我省了大钱!对,就是控制了自己的服务器,省了租用云服务器的钱,重要数据还全捏在自己手里,这感觉真爽。你可...
你 发表评论:
欢迎- 一周热门
-
-
Redis客户端 Jedis 与 Lettuce
-
高并发架构系列:Redis并发竞争key的解决方案详解
-
redis如何防止并发(redis如何防止高并发)
-
Java SE Development Kit 8u441下载地址【windows版本】
-
redis安装与调优部署文档(WinServer)
-
开源推荐:如何实现的一个高性能 Redis 服务器
-
Redis 入门 - 安装最全讲解(Windows、Linux、Docker)
-
一文带你了解 Redis 的发布与订阅的底层原理
-
Redis如何应对并发访问(redis控制并发量)
-
Oracle如何创建用户,表空间(oracle19c创建表空间用户)
-
- 最近发表
- 标签列表
-
- oracle位图索引 (74)
- oracle批量插入数据 (65)
- oracle事务隔离级别 (59)
- oracle主从同步 (56)
- oracle 乐观锁 (53)
- redis 命令 (83)
- php redis (97)
- redis 存储 (67)
- redis 锁 (74)
- 启动 redis (73)
- redis 时间 (60)
- redis 删除 (69)
- redis内存 (64)
- redis并发 (53)
- redis 主从 (71)
- redis同步 (53)
- redis结构 (53)
- redis 订阅 (54)
- redis 登录 (62)
- redis 面试 (58)
- redis问题 (54)
- 阿里 redis (67)
- redis的缓存 (57)
- lua redis (59)
- redis 连接池 (64)