新闻详情

新闻详情

首页 / 资讯中心 / 详情

用Java手写简易版Redis:从网络编程到核心存储引擎

发布时间:2026/9/28 12:23:45来源:尧图网络
用Java手写简易版Redis:从网络编程到核心存储引擎
1. 立项与其背八股不如亲手拆一个内存数据库我在刚接触Java并发编程那阵子面试题里关于Redis的问题几乎绕不开为什么快、底层单线程为什么还快、缓存穿透怎么解决、过期键怎么清理。背了几天八股越背越觉得心里没底。后来干脆做了一件事——用Java自己写一个简易版Redis。项目不大几百行代码就能跑通核心链路但做完之后再回看那些面试题感觉完全不一样了。这个项目让我把网络编程、协议解析、并发控制、数据结构和IO模型串成了一条线远比自己零散刷题有效得多。这篇文章会把整个项目的设计思路和核心代码拆开讲清楚。目标是让有Java基础知道Socket、集合、多线程的基本用法的读者跟着思路就能写出来一个支持SET、GET、EXPIRE、TTL、DEL以及String/List/Hash等常用操作的迷你内存数据库。它不会有Redis那么强的工程能力但核心骨架是一致的网络层接收命令、协议层解析请求、存储层管理键值、业务层执行命令。搞懂了这些再去读Redis源码或者看那些分布式扩展会轻松很多。1.1 项目功能和边界我准备做到什么程度动手之前最重要的事是划定边界。简易版Redis不是要把真Redis抄一遍那样既不现实也没必要。我在第一版只给自己定了四个目标支持TCP客户端连接能在本地用redis-cli直接连上来敲命令。实现RESP协议保证真正的Redis客户端可以正常收发数据。核心命令支持SET、GET、DEL、EXPIRE、TTL、INCR、LPUSH、LPOP、HSET、HGET这些常用操作。支持过期键机制不管是用惰性删除还是定期清理至少不能让过期的键一直占着内存。至于持久化、集群、哨兵、Lua脚本、事务这些统统不在第一版范围内。但我会在后面的章节里聊一聊持久化可以怎么做因为它是“内存数据库”和“玩具缓存”之间的一道分水岭。1.2 技术选型为什么用Java写而不是C或Go很多做底层的同学会习惯性想到C觉得Redis本身是C写的用C写才能体现功力。但我的观点是项目选语言要服务于学习目标。如果你的目标是搞懂IO模型和内存布局那C确实更好但如果目标是理解“内存数据库的核心设计理念”Java反而更合适——你不用手管内存不用纠结指针可以更专注在数据结构、协议解析和命令语义这些抽象层上。Java这边的方案也有讲究。我不建议一上来就用Netty原因很简单Netty帮你把解码、粘包拆包、线程模型全封装好了你反而看不懂底层发生了什么。第一版我建议老老实实用ServerSocket加线程池最多用一下Socket超时和NIO的知识。等把流程跑通了再去看Netty你才会知道它到底替你解决了哪些问题。1.3 模块划分先画出整个项目的骨架写代码前最好先想清楚包结构。我当时的划分是这样的后面所有的代码都会按这个结构来组织sdb/server/服务端启动入口、连接监听、线程模型。sdb/protocol/RESP协议的编码器和解码器。sdb/store/键空间存储、Value对象、过期策略。sdb/command/命令分发器、各类命令的具体实现。sdb/persistence/持久化和启动加载逻辑。一个项目如果连模块边界都理不清后面一旦加功能很快会变成一锅粥。像我一开始把命令解析逻辑全塞在ServerSocket的循环里后来加HSET时差点把自己绕晕。所以模块划分虽然看起来不“除非目”但它决定了后续开发的体验。2. 骨架先行从ServerSocket到可用的TCP服务端内存数据库首先得是个网络服务否则就只能是个本地Map。这一节我们从最简单的服务端骨架开始逐步把并发处理、超时管理加进去。2.1 第一版服务端一个线程一个连接最简单的TCP服务端模型就是主线程死循环等待连接每来一个连接就丢给一个新线程处理。Java里写这个模型非常顺手核心代码大概长这样ServerSocket serverSocket new ServerSocket(6379); ExecutorService pool Executors.newCachedThreadPool(); while (!serverSocket.isClosed()) { Socket socket serverSocket.accept(); pool.submit(new ClientHandler(socket)); }这里要提醒一下默认端口最好不要用6379因为你本机很可能装了真Redis端口冲突会让你调试的时候怀疑人生。我用的是6380端口测试完可以随时改回来。每个ClientHandler做的事情就是“读命令、执行、写结果”这一个循环。注意这里的循环需要阻塞等待用户输入所以核心是一个while死循环public void run() { InputStream in socket.getInputStream(); OutputStream out socket.getOutputStream(); while (true) { // 第一步从流中读取完整请求 // 第二步解析RESP协议得到命令和参数 // 第三步执行命令 // 第四步把结果编码回写 } }这个模型的问题也很明显每个连接都占用一个线程几千个连接就会把线程资源吃光。但对于一个学习项目来说第一版能跑起来比一开始就搞异步更重要。2.2 线程模型升级线程池和IO超时如果你的简易Redis想扛住几百个并发连接至少要在线程模型上做两件事第一把new Thread换成有界线程池。为什么因为无限制创建线程会让系统频繁进行上下文切换而且一旦有慢客户端线程会被挂住最后内存飙升。我用的是Executors.newFixedThreadPool(16)并且把队列设为有界队列超出队列容量的连接直接拒绝宁可少服务也不能拖垮整个进程。第二给Socket设置超时。如果不加超时一个客户端连接后不发数据线程就会一直阻塞在read上。这等于白白占用一个线程资源。我一般在创建连接后立刻设置socket.setSoTimeout(300000); // 5分钟没数据就断开这个超时时间在开发调试时可以设得短一点比如30秒方便测试断开重连的逻辑。2.3 连接管理统计、关闭和资源清理别小看连接管理一个粗糙的服务端连接断开后资源不释放跑一晚上就OOM了。我在处理类里加了几个细节每次处理完请求后检查socket是否isClosed()连接关闭则退出循环。在finally块里统一关闭InputStream、OutputStream和Socket。用一个ConcurrentHashMapString, Socket记录当前活跃连接方便随时看状态或者强制踢掉某个连接。对于简易版来说这部分代码不多但如果没有这个意识后面压测时你会发现句柄数疯狂上涨最后报错“Too many open files”。这就是典型的连接泄漏。3. 啃下RESP协议让真正的redis-cli能直接连上来写网络服务最怕的不是业务逻辑而是协议。Redis客户端和服务端之间用的是RESPREdis Serialization Protocol。它的设计很巧妙——极简、可读性强、跨语言容易实现。如果你能自己写出RESP的编解码器那么这个项目就成功了一半。3.1 RESP协议的数据类型RESP一共用前缀符号区分五种数据块开头的简单字符串比如OK\r\n。-开头的错误信息比如-ERR unknown command\r\n。:开头的整数比如:100\r\n。$开头的批量字符串后面先跟字节长度再跟内容比如$6\r\nfoobar\r\n。*开头的数组后面先跟元素个数再跟各个元素。你看它其实就是做了一件事用第一个字节告诉对方“后面是什么类型”然后用\r\n作为分隔符。这个设计对服务端解析极其友好。客户端给服务端发命令时实际上是发送一个RESP数组。比如SET name zhangsan在协议层就是*3\r\n $3\r\n SET\r\n $4\r\n name\r\n $8\r\n zhangsan\r\n命令本身也被当成字符串数组里的元素这样你收到的就是[SET, name, zhangsan]根本不需要自己去做字符串分词。3.2 解码器把字节流还原成命令参数这是我写这个项目时花时间最长的部分。坑在于“粘包”和“半包”。客户端可能一次性发来多个命令也可能一个命令分几次才发完。如果你简单用BufferedReader.readLine()读一行很可能读到一半就卡住或者多读了下一个命令的数据。更稳妥的做法是自己维护一个字节缓冲区按RESP的规则一字节一字节地消费。我的实现思路是这样的public static Command decode(InputStream in, ByteArrayOutputStream buffer) throws IOException { // 1. 先看缓冲区里是否有完整数据没有则先从流中读取 // 2. 读取第一个字节判断类型这里只处理数组类型 * // 3. 读取数组长度 // 4. 循环读取每个元素的长度和内容 // 5. 如果数据不完整则回退到解析前的状态等待下一次读取 }举个例子解析批量字符串的时候必须先读一行拿到长度再读指定长度的字节最后还要消费掉结尾的\r\n。很多人写到这里最容易漏掉最后那个回车换行结果就是第二个命令解析出来全是错的。我当时用十六进制把收到的数据打印出来才发现这个问题。3.3 编码器把返回值变成RESP协议服务端的回复也要按RESP格式写回。我实现了一个Encoder工具类针对不同类型的返回值输出不同前缀String ok OK\r\n错误信息-ERR message\r\nInteger n:n\r\n空结果$-1\r\nRedis里的nil相当于null列表先用*n\r\n声明元素个数再拼每个元素这里有个容易踩坑的点返回给客户端的批量字符串长度必须是字节数不是字符数。如果你返回中文张的长度是3字节而不是1字符。Java里直接用String.length()会出问题必须用getBytes(StandardCharsets.UTF_8).length。别问我怎么知道的第一版全乱码的滋味不好受。4. 存储引擎键空间、Value包装与TTL过期机制网络层搞定了接下来就进入核心中的核心——内存数据结构。Redis本质是一个大的HashMapkey, value只不过它的value不是单一类型而是有String、List、Hash、Set、ZSet。我们的简易版不需要完全复刻所有类型但必须设计得更接近真实架构方便以后扩展。4.1 核心存储结构不是简单嵌套Map最容易想到的设计是MapString, Object db new ConcurrentHashMap();然后往里面塞字符串、塞List。这种方式能跑但很快就会出问题你怎么知道这个value是String还是List怎么给某个key加过期时间这些信息都需要额外记录。所以正确的做法是定一个Value包装类public class RedisValue { private int type; // 0String, 1List, 2Hash private Object data; // 实际的数据对象 private long createdAt; // 创建时间 private long expireAt; // 过期时间戳0表示永不过期 }这样键空间就是一个ConcurrentHashMapString, RedisValue每个key都能携带元信息。type字段可以是一个枚举我用int是为了省事你也可以用enum。这个设计还有一层好处将来加Set或者ZSet类型时不需要改动存储引擎的代码只需要在command包里新增类型处理和命令即可。模块之间耦合度很低。4.2 过期键清理策略惰性删除加周期扫描Redis的过期键删除策略是“惰性删除 定期删除”结合我也照这个设计实现了简易版。惰性删除的意思是访问key的时候才判断它是否过期如果过期就直接删除并返回空结果。在GET、SET覆盖、EXPIRE等命令执行前先调用一个isExpired(key)方法做检查private boolean isExpired(String key) { RedisValue v store.get(key); if (v null || v.getExpireAt() 0) { return false; } return System.currentTimeMillis() v.getExpireAt(); }如果过期就store.remove(key)然后返回null。但是如果一个key过期后永远不被访问惰性删除就失效了它会一直占着内存。所以还需要一个后台线程定期扫描一部分key把过期的清掉。我在一个ScheduledExecutorService里每分钟跑一次扫描每次最多遍历200个key防止全量扫描阻塞请求线程scheduler.scheduleAtFixedRate(() - { int scanLimit 200; for (String key : store.keySet()) { if (scanLimit-- 0) break; if (isExpired(key)) { store.remove(key); } } }, 1, 1, TimeUnit.MINUTES);真实场景里Redis并不是全量遍历而是随机抽取一部分key检查。我这里为了简化用了遍历前200个效果类似适合学习时理解思路。4.3 内存限制超过阈值时怎么办既然叫内存数据库内存就是核心资源。真Redis有maxmemory配置超过后根据淘汰策略处理常见的是allkeys-lru。简易版如果完全不做限制数据量大了就会OOM。我实现了一个最简单的策略设置最大键数量超过后拒绝写入并返回错误。为什么用“键数量”而不是“内存字节数”因为计算Java对象真实内存占用比较麻烦如果引入java.lang.instrument来算对象大小又太复杂。键数量虽然粗糙但足够演示“内存上限”这个概念。你可以这样实现if (store.size() maxKeys !store.containsKey(key)) { throw new RuntimeException(max memory limit reached); }这种做法引发的性能问题很直观一旦接近阈值写命令会被拒绝体验和Redis的OOM command not allowed when used memory maxmemory很接近用来做演示和测试完全够用。5. 命令分发器与核心命令实现网络层接收到的是字符串数组比如[GET, name]。存储层只负责数据存储。中间还需要一个“翻译官”把命令文本映射到具体的Java方法。这就是命令分发器。5.1 分发器设计避免一大串if-else第一版我图省事直接在循环里写if (cmd.equals(GET)) { ... } else if (cmd.equals(SET)) { ... }写了十几个之后代码越来越难看。后来改成注册式设计把每个命令的实现做成一个类用Map注册public interface Command { Object execute(ListString args); } MapString, Command commands new ConcurrentHashMap(); commands.put(SET, new SetCommand()); commands.put(GET, new GetCommand());分发的时候一行代码就行了Command handler commands.get(commandName); if (handler null) { return new ErrorReply(ERR unknown command); } return handler.execute(args);这样做的好处有几个一是加新命令时不需要动分发主流程只新增一个类注册一下就行二是每个命令的代码互相独立不容易互相影响三是命令本身的参数校验可以在各自的类里做主流程不用关心这些细节。5.2 String类型与原子自增的实现String的SET和GET是最基础的操作但里面藏着一个面试高频考点INCR为什么是原子的如果让你用Java内存数据库实现INCR如何保证线程安全最直接的实现是给命令处理逻辑加锁public synchronized Object incr(String key) { RedisValue value store.get(key); if (value null) { RedisValue newValue new RedisValue(0, 0); store.put(key, newValue); return 1L; } long current Long.parseLong((String) value.getData()); long newVal current 1; value.setData(String.valueOf(newVal)); return newVal; }但这里有个问题如果你对方法加了synchronized相当于所有INCR操作都串行化性能会很差。更好的思路是使用原子操作把“读取-修改-写回”变成一个不可分割的步骤。Java里最简单的做法是使用AtomicLong作为底层存储类型。但考虑到我们可能需要存储字符串和数值混合类型我在实现时做了区分如果是String类型且内容可以转成long就缓存一个AtomicLong的包装对象保证自增操作不通过锁完成。实际上等你做到这一步会发现Java的并发工具类很多都能派上用场这才是这个项目最有价值的地方——你不再是背八股而是在真实场景里理解原子性到底是什么。5.3 List和Hash的简化落地真Redis的List底层是双向链表加压缩列表Hash底层是哈希表加压缩列表工程实现非常讲究内存效率。我们的简易版直接用JDK的集合类就够了。List我直接用LinkedList或者CopyOnWriteArrayList。有一点需要提一下如果直接用LinkedList并发的读写需要自己加锁。我这里用的是ReentrantReadWriteLock分场景加锁读多写少时性能不错class ListValue { private final LinkedListString list new LinkedList(); private final ReentrantReadWriteLock lock new ReentrantReadWriteLock(); public void lpush(String item) { lock.writeLock().lock(); try { list.addFirst(item); } finally { lock.writeLock().unlock(); } } public String lpop() { lock.writeLock().lock(); try { return list.isEmpty() ? null : list.removeFirst(); } finally { lock.writeLock().unlock(); } } }Hash就更简单了用ConcurrentHashMapString, String直接映射。HSET就做map.put(field, value)HGET就做map.get(field)。为什么我没有在存储层把所有value都加锁而是放在各个类型包装类里因为不同的类型对并发的要求不一样集中加锁会导致所有操作争一把锁性能下降很严重。放到各个类型内部可以让不同类型的操作并行执行。6. 简易持久化如果进程挂掉数据怎么恢复内存数据库最大的软肋就是“重启即丢失”。这一步做不做决定了它到底是个高级缓存还是一个可信的数据库。简易版不用做RDB和AOF两套完整的机制但至少可以“仿真”一下AOF的思路。6.1 AOF追加思路记录每次写命令AOF的核心思想是把每次修改数据的命令原样追加到一个文件里重启时把文件里的命令重新执行一遍数据自然就恢复了。做法很简单在命令分发器执行完写类命令SET、DEL、LPUSH、HSET等之后把原始的命令序列化写入磁盘public void appendToAof(ListString args) { if (!aofEnabled) return; String line protocolEncoder.encodeCommand(args); try { FileWriter writer new FileWriter(aofFile, true); writer.write(line); writer.flush(); writer.close(); } catch (IOException e) { // 写失败可以选择忽略还是记录真Redis这里会阻塞写请求 } }注意AOF文件不能每执行一条命令就关一次文件流那样性能太低了。至少要把FileWriter做成成员变量或者用缓冲区定期flush。我做了一个简化用BufferedWriter每执行10条命令或每秒钟flush一次。6.2 启动加载流程重放命令启动时如果存在AOF文件就逐行读取每行是一个RESP数组把这行内容当作客户端命令一样送给命令分发器执行BufferedReader reader new BufferedReader(new FileReader(aofFile)); String line; while ((line reader.readLine()) ! null) { Object parsed protocolDecoder.decodeLine(line); if (parsed instanceof List? args) { commandDispatcher.execute(args); } }这一步有个细节恢复过程中不需要再写AOF否则会重复记录。所以命令分发器里要有个开关字段replayMode加载阶段设为true不触发AOF写入。6.3 持久化实践教训AOF最怕的不是“文件大”而是“刷盘时机”。如果每条命令都立即写到磁盘性能会急剧下降如果缓冲很久才刷一次盘断电可能丢数据。真Redis提供了appendfsync的三种配置always、everysec、no本质上就是安全性和性能之间的权衡。我建议自己在实现时至少提供一个“每秒刷盘”的配置项并且用ScheduledExecutorService每秒调用一次writer.flush()。跑通之后你会理解持久化不是“把内存save成文件”那么简单它涉及崩溃一致性、刷盘策略、文件写入性能这些工程问题比协议解析更有意思。7. 压测与避坑清单我的简易版到底能扛多少并发代码写完了最兴奋的就是连上客户端实际测一把。这一节分享几个我实际测试中遇到的问题和优化记录会比标准教程更贴近真实。7.1 实测效果用官方redis-cli连接我的6380端口编译启动后我直接打开终端运行redis-cli -p 6380 127.0.0.1:6380 SET hello world OK 127.0.0.1:6380 GET hello world 127.0.0.1:6380 EXPIRE hello 10 (integer) 1 127.0.0.1:6380 TTL hello (integer) 8那一刻的成就感确实很强——因为这不是模拟器而是真正和官方客户端完成了协议层面的完整握手。如果你也做到了这一步恭喜你的协议解析已经没有重大问题。随后我用redis-benchmark简单跑了一下redis-benchmark -p 6380 -t set,get -n 100000 -c 50第一次压测结果惨不忍睹单机只有几千QPS。主要原因就是我在RESP解析时用了大量String.split和Scanner非常慢。后来改成手动按字节解析QPS直接翻了几倍。这个对比让我彻底理解了为什么网络协议解析要用字节操作而不是字符串操作。7.2 我踩过的四个典型坑第一个坑是RESP解析时没有消费\r\n。症状是第一次命令正常第二次开始就报错。排查用十六进制打印流数据一眼就看到前一个命令的尾巴还残留着像是命令“粘”在一起了。第二个坑是并发环境下HashMap死循环。早期的版本用了普通的HashMap压测时出现了CPU飙升100%的情况。换成ConcurrentHashMap后才稳定。Java面试经常问“HashMap为什么线程不安全”在这个项目里我算是亲眼见到了后果。第三个坑是流关闭顺序。我在finally里先关了Socket再去关InputStream结果某些情况下资源被提前释放后续命令莫名断开。正确顺序是先关内层流再关外层流或者直接依赖Socket的关闭来级联关闭。第四个坑是Integer返回和Long返回混淆。RESP里的整数类型很严格客户端期望:数字\r\n你如果传了一个字符串形式的数字有的客户端能容忍有的直接报错。统一用Long类型就没这个问题。7.3 这个项目还能往哪些方向扩展如果你跑通了上面的功能接下来的扩展方向我推荐按难度排序增加更多命令SETNX、GETSET、MSET、MGET这些逻辑都很简单适合练手。实现发布订阅需要一个全局的订阅Map类型是MapString, SetClientConnection再加上推送功能。实现LRU淘汰给每个key维护一个访问计数器或双向链表超过内存上限时淘汰最近最少使用的键。替换成NIO或者Netty这时候你对阻塞IO的瓶颈已经有了感觉再看NIO就会理解为什么它能用少量线程管理大量连接。下载Redis源码看server.c和db.c你会发现官方代码的结构和自己写的高度相似阅读难度大幅下降。我个人觉得这个项目的终点不是代码本身而是给你建立了一张“知识地图”。以后看到Redis的持久化配置、线程模型、内存淘汰策略你不会再觉得它是一个黑盒而是一组能对应到自己代码里的具体设计决策。这种“原来如此”的感受比任何教程都有说服力。如果你也在学Java或者准备面试真心建议花一两个周末动手写一遍收益绝对超出预期。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

Hermes 桌面 GUI 小技巧:调字体、切目录,一次讲清楚 2026/9/29 3:49:30

Hermes 桌面 GUI 小技巧:调字体、切目录,一次讲清楚

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
实操干货!MCP 全解析,手把手教你基于 MCP 开发 Agent(TaoToken 统一 Key 接入篇) 2026/9/29 3:49:30

实操干货!MCP 全解析,手把手教你基于 MCP 开发 Agent(TaoToken 统一 Key 接入篇)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
AI原生IDE配TaoToken:settings.json与config.toml骨架一次讲清 2026/9/29 3:49:30

AI原生IDE配TaoToken:settings.json与config.toml骨架一次讲清

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
2万行SwiftUI App,Claude Code写了95%:TaoToken统一Key接入IDE的config.toml骨架与验证 2026/9/29 3:49:30

2万行SwiftUI App,Claude Code写了95%:TaoToken统一Key接入IDE的config.toml骨架与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
从Non-negative Matrix Factorization说说Clustering:用TaoToken统一Key跑通NMF聚类实验 2026/9/29 3:49:30

从Non-negative Matrix Factorization说说Clustering:用TaoToken统一Key跑通NMF聚类实验

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
BMS仿真:电池平衡控制策略模型与动力电池物理模型Simulink实现——TaoToken统一Key接入配置与验证 2026/9/29 3:49:17

BMS仿真:电池平衡控制策略模型与动力电池物理模型Simulink实现——TaoToken统一Key接入配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉