绑定完请刷新页面
取消
刷新

分享好友

×
取消 复制
分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇
2022-11-28 17:07:16

篇幅太长看着也累,每天进步一点点

欢迎关注公众号「架构染色」交流和学习

一、redis 介绍

Redis 应该是目前受欢迎的高性能的缓存数据库了,在五一期间看到一则 Redis 7.0 发布的消息后,回想起多年前学习黄健宏老师《Redis 从入门到精通》2.x 的月伴时光,不由得感慨 Reids 发展之迅速。搜集了一下 3.0 及之后各版本的知名特性,整理出来方便读者朋友们有个简单了解(感兴趣的朋友还需自行深入研究),情况大致如下:

  • 3.0 开始支持 cluster 集群模式
  • 4.0 开发的 lazyfree 和 PSYNC2 解决了 Redis 长久的大 key 删除阻塞问题及同步中断无法续传的问题
  • 5.0 新增了 stream 数据结构使 Redis 具备功能完整的轻量级消息队列能力
  • 6.0 更是发布了诸多企业级特性如 threaded-io、TLS 和 ACL 等,大幅提升了 Redis 的性能和安全性
  • 7.0 Function 彻底解决了过去 Lua 脚本同步丢失的问题;Multi Part AOF 增强了 Redis 的数据持久化的可靠性
1.1 特性介绍

为满足本篇目标所需,这里着重介绍以下几个关键特性:

  • 数据组织:Redis 中支持多种数据结构,将他们灵活组合搭配即可满足分布式锁在不同场景下的功能需求:
    • Jedis 和 Lettuce 这类框架中常使用 String 来做简易的锁信息存储
    • Redisson 中使用 Hash 结构来存储更多维度的锁信息,如:业务名称作为 key,uuid + 线程 id 作为 field,加锁次数作为 value
    • Redisson 中在公平锁的场景下引入 List 和 ZSet, List 类型用于线程排队,Zset 类型存放等待线程的顺序,分数 score 是等待线程的超时时间戳。

Redis 的数据结构(来自网络)

  • 集群模式:Redis 采用集群模式分片存储数据,整个集群拥有固定的 2 的 32 次方个槽位,数据被分配到这些槽位中,每个实例只分管一部分槽位,而非如 etcd、ZK 这种每个实例中的数据都一致;集群模式提供的是数据规模扩大后的横向 AP 能力,应对单节点的风险需再加上主从模式,但当某个 master 节点挂之后,slave 节点可能还未同步到全部数据,会导致数据丢失;一致性保障能力偏弱

Redis 的集群模式(来自网络)

  • 顺序变更:一种简单的抢锁逻辑是判断 key 是否已存在,Redis 中没有给变更操作附加顺序信息(如 etcd 中的 Revision),但服务端以串行方式处理数据的变更,那就可以结合其他数据结构来记录请求顺序信息,如公平锁的实现也会依赖其他数据结构存储信息,用于判断锁状态;但当用到的数据类型和指令变多后,由于是非原子性操作,自然就会遇到结果与预期不一致这类问题,Redis 提供的 lua 脚本机制可用于解决此类问题 ,用户在客户端编排自定义脚本逻辑:可用多个指令操控多个数据,然后将脚本发送给服务端,服务端执行 lua 脚本,并保障一个 lua 脚本内的所有操作是原子性的

Redis lua 脚本的工作机制(来自网络)

  • TTL 机制:TTL(Time To Live)机制是给单个 key 设置存活时间,超过时间后 Redis 自动删除这个 key

Redis 的分布式锁正是基于以上特性来实现的,简单来说是:

TTL 机制:用于支撑异常情况下的锁自动释放的能力

顺序变更:用于支撑获取锁和排队等待的能力

集群+主从模式:用于支撑锁服务的高可用

Redis 没有提供对分布式锁亲和的监听机制,需要客户端主动轮询感知数据变更。

二. 加锁解锁的流程描述

使用 Jedis 指令实现分布式锁的核心流程如下图所示

  1. 准备客户端、key 和 value

  2. 若 key 不存在,指定过期时间成功写入 Key-Value 则抢锁成功,并定时推后 key 的过期时间

  3. 若 key 已存在,则采用重试策略间歇性抢锁。

  4. 解锁时,删除 key 并撤销推后 key 过期时间的逻辑

其中第 2 和第 4 是核心环节,有几个版本的演进很有趣味:

  1. 插入 key 和设置过期时间并非原子操作:setnx + expire 加锁和设置过期是两个分开的独立操作;若发生异常,导致设置过期操作未执行,则此锁就成了永恒锁,其他客户端就再也抢不到了

  2. 以原子性操作完成插入 key 和设置过期时间:使用 set 的扩展指令,如下:

SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • NX :当 key 不存在时,才插入 Key
  • XX :当插入 key 时,指定值为固定的 lockValue
  • EX second :设置 key 的过期时间单位秒(PX\EX 二选一)
  • PX millisecond :设置键的过期时间单位毫秒(PX\EX 二选一)
if(jedis.set(key, lockValue, "NX""EX", 100) == 1){ //加锁成功
  try {
      do work //执行业务
      //这里缺点什么?
  }catch(Exception e){
      //...
  }finally {
     jedis.del(key); //释放锁,这里可能误删其他client的锁key
  }
}
  1. 引入 lockValue 的随机值校验,避免误释放其它客户端的锁,场景如下:
  • client1 加锁成功,key 10s 后过期,完成逻辑后,删除 key 之前,因 GC 导致持锁超过 10s,Redis 自动删除了 key,之后其他客户端可以抢锁
  • 假如是 client2 接下来成功抢锁,开始处理持锁后的逻辑。而此时 client1 GC 结束了会继续执行删除 key 的操作,但此时释放的其实是 client2 的 key

解决办法是:加锁时指定的 lockValue 为随机值,每次加锁时的值都是的,释放锁时若 lockValue 与加锁时的值一致才可释放,否则什么都不做,逻辑如下:

if(jedis.set(key, randomLockValue, "NX""EX", 100) == 1){ //加锁
   try {
       do something  //业务处理
   }catch(){
 }
 finally {
      //判断是不是当前线程加的锁,是才释放
      //但判断和释放锁两个操作不是原子性的
      if (randomLockValue.equals(jedis.get(key))) {
         jedis.del(key); //释放锁
      }
   }
}

以上代码遗留的问题是判断 randomlockValue 和释放锁两个操作不是原子性的。

  1. 引入 lua 脚本,保障判断 randomlockValue 和删除 key 这两个操作的原子性,逻辑如下:
String script =
        "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                "   return redis.call('del',KEYS[1]) " +
                "else" +
                "   return 0 " +
                "end";
Object result = jedis.eval(script, Collections.singletonList(key),
Collections.singletonList(randomLockValue));
if("1".equals(result.toString())){
    return true;
}

至此依然存在的一个问题是:若持锁后,业务逻辑执行耗时 超过了 key 的过期时间,则锁 Key 会被 Reids 主动删除。

  1. 引入 watchDog 定时推后 key 的过期时间,避免业务未执行完时,key 过期被 Redis 删除。
if(jedis.set(key, randomLockValue, "NX""EX", 100) == 1){ //加锁成功
  try {
      do work //执行业务
      //watchDog定时延后Key的过期时间
  }catch(Exception e){
      //...
  }finally {
     String script =
              "if redis.call('get',KEYS[1]) == ARGV[1] then" +
                      "   return redis.call('del',KEYS[1]) " +
                      "else" +
                      "   return 0 " +
                      "end";
      try {
          Object result = jedis.eval(script, Collections.singletonList(key),
                                  Collections.singletonList(randomLockValue));
          if("1".equals(result.toString())){
              return true;
          }
          return false;
      }catch(Exception e){
      //...
    }
  }
}

三. Jedis 分布式锁的能力

可能读者是单篇阅读,这里引入篇《分布式锁上-初探》中的一些内容,一个分布式锁应具备这样一些功能特点:

  • 互斥性:在同一时刻,只有一个客户端能持有锁

  • 安全性:避免死锁,如果某个客户端获得锁之后处理时间超过大约定时间,或者持锁期间发生了故障导致无法主动释放锁,其持有的锁也能够被其他机制正确释放,并保证后续其它客户端也能加锁,整个处理流程继续正常执行

  • 可用性:也被称作容错性,分布式锁需要有高可用能力,避免单点故障,当提供锁的服务节点故障(宕机)时不影响服务运行,这里有两种模式:一种是分布式锁服务自身具备集群模式,遇到故障能自动切换恢复工作;另一种是客户端向多个独立的锁服务发起请求,当某个锁服务故障时仍然可以从其他锁服务读取到锁信息(Redlock)

  • 可重入性:对同一个锁,加锁和解锁必须是同一个线程程,即不能把其他线程持有的锁给释放了

  • 高效灵活:加锁、解锁的速度要快;支持阻塞和非阻塞;支持公平锁和非公平锁

基于上文对 Jedis 分布式锁的介绍,这里简单总结一下 Jedis 的能力矩阵,ZK 请看《分布式锁中-基于 Zookeeper 的实现》,etcd 请看《分布式锁中-基于 etcd 的实现很优雅》 ,表格中标题使用 Redis-简单锁,主要是跟 RedLock 做区分,这种简单锁使用 Jedis 、Lettuce、Redisson 都能实现,任何一把锁的信息只保存在一个 Redis master 实例中,而 RedLock 是 Redisson 提供的高阶分布式锁,它需要客户端同时跟多个 Redis master 实例协作才能完成,即一把锁的信息同时存在于多个 master 实例中。它的情况会在后续文章中补充(感兴趣的读者可以关注本号【架构染色】,文章完成时会主动推送给你)

能力 ZK etcd Redis-简单锁 Redlock MySql
互斥

安全 链接异常时,session 丢失自动释放锁 基于租约,超时自动释放锁 基于 TTL,超时自动释放锁

可用性 相对可用性还好

可重入 服务端非可重入,本地线程可重入 服务端非可重入,本地线程可重入需自研 服务端非可重入,本地线程可重入需自研

加解锁速度 速度不算快 速度快,GRPC 协议优势以及服务端能力的优势 速度快

阻塞非阻塞 客户端两种能力都提供 jetcd-core 中,阻塞非阻塞由 Future#get 支撑 Jedis非阻塞,

Redission提供阻塞能力



公平非公平 公平锁 公平锁 非公平锁,

Redission

提供公平锁


可续期 天然支持 天然支持 Jedis需自研 watchDog,Redission自带

其他因素
技术栈偏,性能不佳
多数公司不熟悉
容易受业务缓存操作干扰


四、Jedis 库实现分布式锁

Jedis 是 Redis 官方推出的用于通过 Java 连接 Redis 客户端的一个工具包,提供了 Redis 的各种命令支持。

1.pom 依赖
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>4.3.0</version>
</dependency>
2. 相关的 API 介绍
  • 使用 SET 的扩展指令加锁(SET key value [EX seconds][px milliseconds] [NX|XX])
 SetParams params = SetParams.setParams().nx().ex(lockState.getLeaseTTL());
 String result = client.set(lockState.getLockKey(), lockState.getLockValue(), params);
  • 使用 lua 解锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Object result = client.eval(script, 1, lockState.getLockKey(), lockState.getLockValue());

3. 分布式锁示例
  • 锁的封装
package com.rock.dlock.jedis;

import com.rock.dlock.common.DtLockException;
import com.rock.dlock.common.KeepAliveAction;
import com.rock.dlock.common.KeepAliveTask;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import redis.clients.jedis.JedisPooled;
import redis.clients.jedis.params.SetParams;

import java.net.SocketTimeoutException;
import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/13 4:44 PM
 */
public class DemoJedisLock {
    private final static Logger log = LoggerFactory.getLogger(DemoJedisLock.class);
    private JedisPooled client;

    private LockState lockState;
    private KeepAliveTask keepAliveTask;

    private int sleepMillisecond;

    private final static String RESULT_OK = "OK";
    private static final Long UNLOCK_SUCCESS = 1L;

    class LockState {
        private String lockKey;
        private String lockValue;
        private String errorMsg;
        private int leaseTTL;
        private long leaseId;
        private boolean lockSuccess;

        public LockState(String lockKey, int leaseTTL) {
            this.lockKey = lockKey;
            this.leaseTTL = leaseTTL;
        }

        public LockState(String lockKey, String value, int leaseTTL) {
            this.lockKey = lockKey;
            this.lockValue = value;
            this.leaseTTL = leaseTTL;
        }

        public String getLockKey() {
            return lockKey;
        }

        public void setLockKey(String lockKey) {
            this.lockKey = lockKey;
        }

        public String getLockValue() {
            return lockValue;
        }

        public void setLockValue(String lockValue) {
            this.lockValue = lockValue;
        }

        public String getErrorMsg() {
            return errorMsg;
        }

        public void setErrorMsg(String errorMsg) {
            this.errorMsg = errorMsg;
        }

        public long getLeaseId() {
            return leaseId;
        }

        public void setLeaseId(long leaseId) {
            this.leaseId = leaseId;
        }

        public boolean isLockSuccess() {
            return lockSuccess;
        }

        public void setLockSuccess(boolean lockSuccess) {
            this.lockSuccess = lockSuccess;
        }

        public int getLeaseTTL() {
            return leaseTTL;
        }

        public void setLeaseTTL(int leaseTTL) {
            this.leaseTTL = leaseTTL;
        }
    }


    public DemoJedisLock(JedisPooled client, String key, String value, int ttlSeconds) {
        //1.准备客户端
        this.client = client;
        this.lockState = new LockState(key, value, ttlSeconds);
        this.sleepMillisecond = (ttlSeconds * 1000) / 3; //抢锁的重试间隔可由用户指定
    }


    public boolean tryLock(long waitTime, TimeUnit waitUnit) throws DtLockException {
        long totalMillisSeconds = waitUnit.toMillis(waitTime);
        long start = System.currentTimeMillis();
        //重试,直到成功或超过指定时间
        while (true) {
            // 抢锁
            try {
                SetParams params = SetParams.setParams().nx().ex(lockState.getLeaseTTL());
                String result = client.set(lockState.getLockKey(), lockState.getLockValue(), params);
                if (RESULT_OK.equals(result)) {
                    manualKeepAlive();
                    log.info("[jedis-lock] lock success 线程:{} 加锁成功,key:{} , value:{}", Thread.currentThread().getName(), lockState.getLockKey(), lockState.getLockValue());
                    lockState.setLockSuccess(true);
                    return true;
                } else {
                    if (System.currentTimeMillis() - start >= totalMillisSeconds) {
                        return false;
                    }
                    Thread.sleep(sleepMillisecond);
                }
            } catch (Exception e) {
                Throwable cause = e.getCause();
                if (cause instanceof SocketTimeoutException) {//忽略网络抖动等异常
                }
                log.error("[jedis-lock] lock failed:" + e);
                throw new DtLockException("[jedis-lock] lock failed:" + e.getMessage(), e);
            }

        }
    }

    //此实现中忽略,网络通信异常部分的处理,可参考tryLock
    public void unlock() throws DtLockException {
        try {
            // 首先停止续约
            if (keepAliveTask != null) {
                keepAliveTask.close();
            }
            String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            Object result = client.eval(script, 1, lockState.getLockKey(), lockState.getLockValue());

            if (UNLOCK_SUCCESS.equals(result)) {
                log.info("[jedis-lock] unlock success 线程 : {} 解锁成功,锁key : {} ,路径:{}", Thread.currentThread().getName(), lockState.getLockKey(), lockState.getLockValue());
            } else {
                log.info("[jedis-lock] unlock del key failed ,线程 : {} 解锁成功,锁key : {} ,路径:{}", Thread.currentThread().getName(), lockState.getLockKey(), lockState.getLockValue());
            }
        } catch (Exception e) {
            log.error("[jedis-lock] unlock failed:" + e.getMessage(), e);
            throw new DtLockException("[jedis-lock] unlock failed:" + e.getMessage(), e);
        }
    }

    // 定时将Key的过期推迟
    private void manualKeepAlive() {
        final String t_key = lockState.getLockKey();
        final int t_ttl = lockState.getLeaseTTL();

        keepAliveTask = new KeepAliveTask(new KeepAliveAction() {
            @Override
            public void run() throws DtLockException {
                // 刷新值
                try {
                    client.expire(t_key, t_ttl);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, t_ttl);
        keepAliveTask.start();
    }
}
  • 异常类的简单实现
package com.rock.dlock.common;

public class DtLockException extends RuntimeException{
    public DtLockException(String message) {
        super(message);
    }

    public DtLockException(String message, Throwable cause) {
        super(message, cause);
    }

    public static DtLockException clientException(){
        return new DtLockException("client is empty");
    }
}
  • watchDog 的任务抽象

package com.rock.dlock.common;

public interface KeepAliveAction {
    void run() throws DtLockException;
}
  • watchDog 的简单实现
package com.rock.dlock.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/7 4:20 PM
 */
public class KeepAliveTask extends Thread {
    private static final Logger LOGGER = LoggerFactory.getLogger(KeepAliveTask.class);
    public volatile boolean isRunning = true;
    /**
     * 过期时间,单位s
     */
    private long ttlSeconds;
    private KeepAliveAction action;
    public KeepAliveTask(KeepAliveAction action, long ttlSeconds) {
        this.ttlSeconds = ttlSeconds;
        this.action = action;
        this.setDaemon(true);
    }

    @Override
    public void run() {
        final long sleep = this.ttlSeconds * 1000 / 3; // 每隔三分之一过期时间,续租一次
        while (isRunning) {
            try {
                // 1、续租,刷新值
                action.run();
                LOGGER.debug("续租成功!");
                TimeUnit.MILLISECONDS.sleep(sleep);
            } catch (InterruptedException e) {
                close();
            } catch (DtLockException e) {
                close();
            }
        }
    }

    public void close() {
        isRunning = false;
        this.interrupt();
    }
}

4. 测试锁
import com.rock.dlock.jedis.DemoJedisLock;
import redis.clients.jedis.JedisPooled;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

/**
 * @author zs
 * @date 2022/11/13 4:51 PM
 */
public class TestJedisLock {
    public static void main(String[] args) {

        JedisPooled jedis = new JedisPooled("127.0.0.1", 6379);
        DemoJedisLock demoEtcdLock1 = new DemoJedisLock(jedis, "rock", UUID.randomUUID().toString(), 10);
        DemoJedisLock demoEtcdLock2 = new DemoJedisLock(jedis, "rock", UUID.randomUUID().toString(), 10);

        boolean lock1 = demoEtcdLock1.tryLock(20, TimeUnit.SECONDS);
        if (lock1) {
            try {
                System.out.printf("do something");
            } finally {
                demoEtcdLock1.unlock();
            }
        }
        demoEtcdLock1.tryLock(20, TimeUnit.SECONDS);
        demoEtcdLock2.tryLock(20, TimeUnit.SECONDS);//等待锁,超时后放弃
    }
}

五、使用 Jedis 的一些注意事项

通常分布式锁服务会和业务逻辑使用同一个Redis 集群,自然也使用同一个 Jedis 客户端;当业务逻辑侧对 Redis 的读写并发提高时,会给 Redis 集群和 Jedis 客户度带来压力;为应对一些异常情况,我们除了解功能层面的 API,还需要了解一下客户端的一些配置调优,主要是池化管理和网络通信两个方面

5.1 池化管理

在使用 Jedis 时可以配置 JedisPool 连接池,池化处理有许多好处,如:提高响应的速度、降低资源的消耗、方便管理和维护;JedisPool 配置参数大部分是由 JedisPoolConfig 的对应项来赋值的,在生产中我们需要关注它的配置并合理的赋值,如此能够提升 Redis 的服务性能,降低资源开销。下边是对一些重要参数的说明、默认及设置建议:

参数 说明 默认值 建议
maxTotal 资源池中的大连接数 8
maxIdle 资源池允许的大空闲连接数 8
minIdle 资源池确保的少空闲连接数
blockWhenExhausted 当资源池用尽后,调用者是否要等待。只有当值为 true 时,下面的maxWaitMillis才会生效。 true 建议使用默认值。
maxWaitMillis 当资源池连接用尽后,调用者的大等待时间(单位为毫秒)。 -1(表示永不超时) 不建议使用默认值。
testOnBorrow 向资源池借用连接时是否做连接有效性检测(ping)。检测到的连接将会被移除。 false 业务量很大时候建议设置为 false,减少一次 ping 的开销。
testOnReturn 向资源池归还连接时是否做连接有效性检测(ping)。检测到连接将会被移除。 false 业务量很大时候建议设置为 false,减少一次 ping 的开销。
jmxEnabled 是否开启 JMX 监控 true 建议开启,请注意应用本身也需要开启。

空闲 Jedis 对象的回收检测由以下四个参数组合完成,testWhileIdle是该功能的开关。

名称 说明 默认值 建议
testWhileIdle 是否开启空闲资源检测。 false true
timeBetweenEvictionRunsMillis 空闲资源的检测周期(单位为毫秒) -1(不检测) 建议设置,周期自行选择,也可以默认也可以使用下方JedisPoolConfig 中的配置。
minEvictableIdleTimeMillis 资源池中资源的小空闲时间(单位为毫秒),达到此值后空闲资源将被移除。 180000(即 30 分钟) 可根据自身业务决定,一般默认值即可,也可以考虑使用下方JeidsPoolConfig中的配置。
numTestsPerEvictionRun 做空闲资源检测时,每次检测资源的个数。 3 可根据自身应用连接数进行微调,如果设置为 -1,就是对所有连接做空闲监测。

通过源码可以发现这些配置是 GenericObjectPoolConfig 对象的属性,这个类实际上是 rg.apache.commons.pool2.impl apache 提供的,也就是说 jedis 的连接池是依托于 apache 提供的对象池来,这个对象池的声明周期如下图,感兴趣的可以看下:

5.2 网络调优
  • max-redirects:这个是集群模式下,重定向的大数量;举例说明,比如台挂了,连第二台,第二台挂了连第三台,重新连接的次数不能超过这个值

  • timeout:客户端超时时间,单位是毫秒

Rsdis 节点故障或者网络抖动时,这两个值如果不合理可能会导致很严重的问题,比如 timeout 设置为 1000,maxRedirect 为 2,一旦出现 redis 连接问题,将会导致请求阻塞 3s 左右。而这个 3 秒的阻塞在可能导致常规业务流量下的线程池耗尽,需根据业务场景调整。

五、总结

本篇介绍了如何基于 Redis 的特性来实现一个分布式锁,并基于 Jedis 库提供了一个分布式锁的示例,呈现了其关键 API 的用法;此示例尚未达到生产级可用,如异常、可重入、可重试、超时控制等功能都未补全,计划在下一篇介绍完 redlock 之后,再介绍一个健壮的分布式锁客户端要如何抽象设计,如何适配 ZK 、Redis 、etcd 。

分布式锁系列内容规划如下,本篇是第 4 篇:

  1. 《分布式锁上-初探
  2. 《分布式锁中-基于 Zookeeper 的实现是怎样》
  3. 分布式锁中-基于 etcd 的实现很优雅
  4. 分布式锁中-基于 Redis 的实现需避坑 - Jedis 篇(本篇)
  5. 分布式锁中-基于 Redis 的实现很多样 - Redission 篇(写作中)
  6. 分布式锁中-多维度的对比各种分布式锁实现(写作中)
  7. 分布式锁下-分布式锁客户端的抽象、适配与加固(写作中)

六、后说一句(请关注,莫错过)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙扫描下发二维码关注一下,关注公众号:【 架构染色 】,进行交流和学习。您的支持是我坚持写作大的动力。

参考和感谢

https://juejin.cn/post/7156190989114146846 https://view.inews.qq.com/a/20220211A01JGQ00 https://cloud.tencent.com/developer/article/2052387


分享好友

分享这个小栈给你的朋友们,一起进步吧。

Redis
创建时间:2021-12-14 14:15:44
Redis
展开
订阅须知

• 所有用户可根据关注领域订阅专区或所有专区

• 付费订阅:虚拟交易,一经交易不退款;若特殊情况,可3日内客服咨询

• 专区发布评论属默认订阅所评论专区(除付费小栈外)

技术专家

查看更多
  • 飘絮絮絮丶
    专家
  • ittttliu
    专家
  • LCR_
    专家
戳我,来吐槽~