Coherence(4)-替代PutAll

Oracle Coherence的PutAll方法 通常 用于 执行批量更新缓存的操作使得更新 更有效率. 大多数Oracle Coherence开发人员发现当他们使用coherence产品1到2周之后会发现putAll有一些缺陷,特别是当他们使用触发器或cache stores。这篇文章阐述了一种替代方案用于替代coherence的标准PutAll方法,解决PutAll在一些使用上的局限性和缺陷。这篇文章的的理念在 我的一个同事 最近的一篇文章中提到了,在他的博客上www.benstopford.com有阐述它。这篇文章谈到了为什么我们需要实现一个新的putall来替代coherence原有的putall方法,以及简单的谈如何去实现它.
     当我们使用PutAll的时候,会存在一些隐患,特别是你在使用触发器或者一个 直写式CacheStore的时候,因为他们可能会抛出更新缓存异常 。Oracle Coherence是一个分布式缓存, 系统的数据被分布在各个节点 ,在 调用 putAll会分发给那些所有集群成员让他们自己去更新存在他们节点的数据。如果有任何的更新都会抛出异常,他将返回给客户端。如果有多个异常,那么客户端将只接受其中之一。在接收到客户异常无法确定有多少数据在这次putAll失败,有多少数据实际上已经更新。缓存现在处于未知状态,这使得恢复异常变得非常困难。更糟糕的是,如果你是使用连续写入来更新您的数据库,那后果将会不堪设想。
     我目前从事的项目需要一个更可靠的putAll操作;也就是说,我们需要知道的所有数据的操作是成功或者失败。我们认识到,一些数据 在putAll中 触发器 可能会失败 ,但只要我们知道的所有失败的数据我们就可以采取纠正措施,知道缓存将处于一致的状态。经过几次迭代后的设计后我们提出了一个可行的方法,我将在下面的文章中进行描述。
     首先简要解释实现思路。在解释之前,我认为你对Coherence已经有了一个基本的了和概念,如分布式缓存分区,调用服务,EntryProcess等,这些是我们去实现的基础:
     1:先根据keys计算这些keys所属于的节点,然后将他们分组放入Map中.  
     2:将每组数据分别发给各自的成员节点  
     3:成员的遍历并将它们存入缓存中。所有数据都归这成员,这些都将本地更新。  
     4:在更新每个缓存数据的时候我们把本地捕获任何异常返回。
     5:当完成所有数据的存储之后我们在讲相应出错的数据和异常返回给客户端.

基本代码实现
     实现起来是简单的。本文中的大多数代码是Java代码,它的大部分将在服务器端运行。可以使用其他类型的Coherence的代码扩展客户API,c#或c++,我们将 最后 讨论。我要描述的代码首先从扩展客户端执行, 在本文后面 我们将谈论使它运行在集群成员。
    第一次的实现我们是基于3.5版本的coherence,现在项目是基于3.7版本的(译者翻译这篇文章的时候Coherence已经是12.1.1.0版本),但是代码上基本上没有什么不一样——不过 在这里的代码 我们增强了一些描述从而使 我们的一些用例满足 特定的需求。我们将本文的其余部分迭代方法和最基本的代码需要做这项工作以后再优化,提高它。希望这会给你一些洞察我们通常如何处理这种需求。
     要实现上面的功能最简单的办法是通过实现一个Invocation Service。从一个扩展客户运行我们需要配置两个调用服务,远程调用服务的客户机和代理相应的代理服务,然后是一个标准调用服务,最后在所有集群成员上运行。
     所以,第一步,我们需要一个可 从客户端执行 调用的 InvocationService ,使得我们的数据将被更新,从而调用这个PutAll类。显然PutAll需要知道需要更新哪个缓存,因此我们将添加一个以 缓存名称 构造函数参数。就像正常putAll方法缓存,我们希望能够提供值的映射,所以我们也将添加待更新的数据的map参数作为构造参数。不要忘记,我们需要让这个PortableObject和进行一些序列化的实现。
public class PutAll extends AbstractInvocable implements PortableObject {
 
    private String cacheName;
    private Map<Object,Object> values;
 
    public PutAll() {
    }
 
    public PutAll(String cacheName, Map<Object, Object> values) {
        this.cacheName = cacheName;
        this.values = values;
    }
 
    @Override
    public void run() {
    }
 
    @Override
    public void readExternal(PofReader pofReader) throws IOException {
        cacheName = pofReader.readString(1);
        values = pofReader.readMap(2, new HashMap());
    }
 
    @Override
    public void writeExternal(PofWriter pofWriter) throws IOException {
        pofWriter.writeString(1, cacheName);
        pofWriter.writeMap(2, values);
    }
}

 接下来我们要去实现run方法,让我们回顾下这里我们需要做的事情:
     1:根据key所在的节点来对这些数据进行分组
     2:使用另外一个Invocable将这些分组好组的数据发送给各个节点进行处理


因为我们知道每个Invocable将会在各个数据存在的节点执行——在这种情况下,调用服务扩展代理的代理服务。这意味着我们可以对缓存的引用,我们将更新和使用该缓存的CacheService告诉我们哪些集群成员拥有哪些键。
     run方法实现如下的第一步
@Override
public void run() {
    NamedCache cache = CacheFactory.getCache(cacheName);
    PartitionedService cacheService = (PartitionedService) cache.getCacheService();
 
    Map<Member,Map<Object,Object>> valuesByMember = new HashMap<Member,Map<Object,Object>>();
    for (Map.Entry entry : values.entrySet()) {
        Object key = entry.getKey();
        Member member = cacheService.getKeyOwner(key);
        if (!valuesByMember.containsKey(member)) {
            valuesByMember.put(member, new HashMap<Object,Object>());
        }
        valuesByMember.get(member).put(key, entry.getValue());
    }
}
我们遍历值和使用PartitionedService getKeyOwnwer()进行分组.我们现在拥有一个发送到每个集群成员映射值,所以我们现在需要发送他们。这就是我们使用第二个Invocable,InvocationService将让他在所有集群成员上运行。我们将调用第二个Invocable-PutAllForMember,他的构造参数包括需要更新的缓存名称以及等待更新的数据。
下面PutAllForMember类框架代码。这是一个可调用和实现PortableObject的类:
public class PutAllForMember extends AbstractInvocable implements PortableObject {
 
    private String cacheName;
    private Map<Object,Object> values;
 
    public PutAllForMember() {
    }
 
    public PutAllForMember(String cacheName, Map<Object, Object> values) {
        this.cacheName = cacheName;
        this.values = values;
    }
 
    @Override
    public void run() {
    }
 
    @Override
    public void readExternal(PofReader pofReader) throws IOException {
        cacheName = pofReader.readString(1);
        values = pofReader.readMap(2, new HashMap());
    }
 
    @Override
    public void writeExternal(PofWriter pofWriter) throws IOException {
        pofWriter.writeString(1, cacheName);
        pofWriter.writeMap(2, values);
    }
}
现在我们必须实现局部更新的实际代码。正如我们已经说过的,基本上运行方法都需要迭代更新缓存的值与捕获每一个异常。然后将他们返回。 
@Override
public void run() {
    Map<Object,Throwable> errors = new HashMap<Object,Throwable>();
    NamedCache cache = CacheFactory.getCache(cacheName);
    for (Map.Entry entry : values.entrySet()) {
        try {
            cache.put(entry.getKey(), entry.getValue());
        } catch (Throwable t) {
            errors.put(entry.getKey(), t);
        }
    }
    setResult(errors);
}
现在我们有Invocable本地更新使我们可以回去完成PutAll.run()方法。
@Override
public void run() {
    NamedCache cache = CacheFactory.getCache(cacheName);
    DistributedCacheService cacheService = (DistributedCacheService) cache.getCacheService();
 
    Map<Member,Map<Object,Object>> valuesByMember = new HashMap<Member,Map<Object,Object>>();
    for (Map.Entry entry : values.entrySet()) {
        Object key = entry.getKey();
        Member member = cacheService.getKeyOwner(key);
        if (!valuesByMember.containsKey(member)) {
            valuesByMember.put(member, new HashMap<Object,Object>());
        }
        valuesByMember.get(member).put(key, entry.getValue());
    }
 
    Map<Object,Throwable> results = new HashMap<Object,Throwable>();
    InvocationService service = (InvocationService) CacheFactory.getService("InvocationService");
    for (Map.Entry<Member,Map<Object,Object>> entry : valuesByMember.entrySet()) {
        PutAllForMember putAllForMember = new PutAllForMember(cacheName, entry.getValue());
        Map<Member,Map<Object,Throwable>> results = service.query(putAllForMember, Collections.singleton(entry.getKey()));
        results.putAll(results.get(entry.getValue());
    }
 
    setResult(results);
}

上面的代码对我们之前已经分好的组进行分批的发送给远程的节点去执行,并且获取他们返回的结果.
到此,基本代码就已经完成了,我们可以想如下进行调用:
Map<Object,Object> values = new HashMap<Object,Object>();
// --- Populate the values map with the key/value pairs to put ---
PutAll putAll = new PutAll("dist-test", values);
InvocationService service = (InvocationService) CacheFactory.getService("remote-invocation-service");
Map<Member,Map<Object,Throwable>> results = service.query(putAll, null);
Map<Object,Throwable> errors = results.values().iterator().next();
// --- check the errors map for any failures ---
经过一些测试,上面的程序确实可以正常的执行和工作,不过测试结果不让人满意,执行速度太慢了.


优化
虽然上面的代码得到正常的运作,执行效率虽然不是最佳的,但是确实解决了我们最初设计他的问题,如果你自己构建和编译执行了上面的代码,你会发现他的执行会有点慢,我做了一个基本的测试,就是先通过原来的PutAll操作存储1000个数据,大概在170ms左右,但是通过我们的新的PutAll存储相同的数据大概在700ms左右,这个测试环境是在我的MacBook Pro电脑上,启动的缓存节点个数是3个,虽然这个不是严谨的测试,不过我们只关注的是相对的性能,显然这个性能不足以让人满意.


异步调用
首先我们可以对PutAll的run方法进行改造,目前的代码是我们在每一次迭代中去调用PutAllForMember去执行,其中每次都需要等待结果返回才能进行下一次的迭代.Coherence允许我们异步提交,通过调用InvocationService的execute()方法,而且会被回调在每次调用结束后,这将是更有效的,因为我们可以提交所有PutAllForMember Invocables
它允许我们使用异步调用方法,所以我们需要编写另一个类来接收这些回调,这是一个InvocationObserver的实现。这个类有许多事情要做.
1:监听每个调用的结果,这些调用有可能会成功完成,也有可能会在执行的时候失败或者调用节点的离开
2:从每个调用收集并返回结果
3:有一个阻塞方法,等到所有的invocables完成全部结果集的返回。
下面是InvocationObserver基本代码,可以称之为PutAllObserver。
public class PutAllObserver implements InvocationObserver {
 
    private Map<Member,Map<Object,Object>> membersAndValues;
    private Map<Object,Throwable> results;
    private volatile int actualCount = 0;
 
    public PutAllObserver(Map<Member,Map<Object,Object>> membersAndValues) {
        this.membersAndValues = membersAndValues;
        this.results = new HashMap<Object,Throwable>();
    }
 
    public synchronized Map<Object,Throwable> getResults() {
    }
 
    @Override
    public synchronized void memberCompleted(Member member, Object memberResult) {
    }
 
    @Override
    public synchronized void memberFailed(Member member, Throwable throwable) {
    }
 
    @Override
    public synchronized void memberLeft(Member member) {
    }
 
    @Override
    public synchronized void invocationCompleted() {
    }
}

在构造函数中我们传入了所有节点对应数据的map,这样使得PutAllObserver知道我们有多少个调用和期待多少个结果返回,在构造函数中我们也创建了一个空的map去接受所有的返回结果我们有一个计数字段来跟踪有多少调用完成。我们所有的同步方法可以被多个线程调用结果进来所以我们想成为线程安全的。我们还添加了getResults()方法将由PutAll类和调用会阻塞,直到所有invocables都完成了。在getResult方法我们会判断进行阻塞知道所有调用都返回结果.
public synchronized Map<Object,Throwable> getResults() {
    while(actualCount < membersAndValues.size()) {
        try {
            this.wait();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
    return results;
}

这是一个非常简单的方法。它需要做的就是检查actualCount是否小于membersAndValues Map大小,因为这是调用的结果,我们希望得到的数量。在计数低于预期我们只是等到通知,或者直到中断。一旦计数达到所需的数量我们就返回结果的Map的通知.
下一个方法是memberCompleted()方法,这意味着调用成功完成。
@Override
public synchronized void memberCompleted(Member member, Object memberResult) {
    results.putAll((Map<Object,Throwable>) memberResult);
    actualCount++;
    this.notifyAll();
}

通过它我们来收集结果并且重置完成的调用数,然后通知阻塞线程去判断是否继续执行. 最后是 memberLeft  方法
@Override
public synchronized void memberLeft(Member member) {
    Throwable throwable = new PutAllMemberLeftException(member);
    for (Object key : membersAndValues.get(member).keySet()) {
        results.put(key, throwable);
    }
    actualCount++;
    this.notifyAll();
}

这个可能以为在执行更新的节点在还没执行结束就不在集群内了,我们也需要吧异常进行收集和处理;
我们现在有一个InvocationObserver我们时,我们可以使用异步可调用调用,我们可以将它添加到PutAll运行方法。
@Override
public void run() {
    NamedCache cache = CacheFactory().getCache(cacheName);
    DistributedCacheService cacheService = (DistributedCacheService) cache.getCacheService();
 
    Map<Member,Map<Object,Object>> valuesByMember = new HashMap<Member,Map<Object,Object>>();
    for (Map.Entry entry : values.entrySet()) {
        Object key = entry.getKey();
        Member member = cacheService.getKeyOwner(key);
        if (!valuesByMember.containsKey(member)) {
            valuesByMember.put(member, new HashMap<Object,Object>());
        }
        valuesByMember.get(member).put(key, entry.getValue());
    }
 
    InvocationService service = (InvocationService) CacheFactory.getService("InvocationService");
    PutAllObserver observer = new PutAllObserver(valuesByMember);
    for (Map.Entry<Member,Map<Object,Object>> entry : valuesByMember.entrySet()) {
        PutAllForMember putAllForMember = new PutAllForMember(cacheName, entry.getValue());
        service.execute(putAllForMember, Collections.singleton(entry.getKey()), observer);
    }
 
    Map<Object, Throwable> results = observer.getResults();
    setResult(results);
}
 您可以看到异步execute()方法现在用在第20行使调用使用PutAllObserver InvocationService。后所有调用调用然后在第23行我们称之为getResults PutAllObserver这将阻塞,直到所有的invocables已经完成了。
      如果我们现在运行这个性能测试工具,我们得到的时间是241 ms,虽然效果更好了但是还是低于标准的Coherence的putAll,所以我们还有一些工作要做。
序列化
我们的PutAll在对数据被更新之前 实现执行大量的序列化和反序列化操作。在标准COHERENCE的putAll实现则不会如此频繁。我们现在可以看看如何优化自己的序列化。
在Oracle coherence中有许多的方法可以达成同一个目的,所以我相信有一些办法来优化我的序列化方式,我在这里提出了我的方法,也欢迎你们提出你们更好的方式,在 PutAll中我传入了缓存名称和数据Map, 在上面的代码中,我们需要cacheName和我们需要制定分区键反序列化形式的所有者,但是我们不需要去反序列化值数据.
@Override
public void writeExternal(PofWriter pofWriter) throws IOException {
    pofWriter.writeString(1, cacheName);
    pofWriter.writeInt(2, values.size());
    Serializer serializer = pofWriter.getPofContext();
    int id = 3;
    for (Map.Entry entry : values.entrySet()) {
        pofWriter.writeObject(id++, entry.getKey());
        pofWriter.writeBinary(id++, ExternalizableHelper.toBinary(entry.getValue(), serializer));
    }
}
 
@SuppressWarnings({"unchecked"})
@Override
public void readExternal(PofReader pofReader) throws IOException {
    cacheName = pofReader.readString(1);
    values = new HashMap<Object,Object>();
    int count = pofReader.readInt(2);
    int id = 3;
    for (int i=0; i<count; i++) {
        Object key = pofReader.readObject(id++);
        Binary value = pofReader.readBinary(id++);
        values.put(key, value);
    }
}

首先在 writeExternal  方法里面我们使用ExternalizableHelper PofWriter类和序列化器自己去序列化,而不是使用 pofWriter直接去存,我们先统计出map的大小,然后再单独的去存储数据.

然后再放序列化的时候,我们先获取map的大小,然后进行一些特殊处理,我们不需要去反序列化具体的值,因为他是没有必要的.

这让我们面临一个问题:我们PutAllForMember类将会取得一个map,key是对象形式,而值是二进制的形式,但是调用cache.put()需要的是对象的形式。但是我们不想反序列化值,目的在于避免反序列化。相反,我们需要一种方法来直接更新 对应的键 的二进制值。一种方式我们可以使用back map的api来进行操作,来把key先转成二进制形式,不过这种方式会绕过任何触发器,这显然不是个好的方式。所以,我们在更新的时候需要确保我们没有绕过什么,我们可以使用一个EntryProcessor这访问BinaryEntry和做一个二进制更新。你可能会说之前你不是说ep处理会比较慢嘛,是的,但是这个ep它不需要做一些序列化的操作,只会对一些数据上锁,所以这个不是太糟糕.下面是一个二进制的更新器的实现:
ublic class BinaryValueUpdater extends AbstractProcessor implements PortableObject {
 
    private Binary binaryValue;
 
    public BinaryValueUpdater() {
    }
 
    public BinaryValueUpdater(Binary binaryValue) {
        this.binaryValue = binaryValue;
    }
 
    @Override
    public Object process(InvocableMap.Entry entry) {
        ((BinaryEntry)entry).updateBinaryValue(binaryValue);
        return null;
    }
 
    @Override
    public void readExternal(PofReader pofReader) throws IOException {
        binaryValue = pofReader.readBinary(1);
    }
 
    @Override
    public void writeExternal(PofWriter pofWriter) throws IOException {
        pofWriter.writeBinary(1, binaryValue);
    }
}

到此基本上就完成了。我们现在可以修改PutAllForMember run()方法使用的二进制值Map
@Override
public void run() {
    Map<Object,Throwable> errors = new HashMap<Object,Throwable>();
    NamedCache cache = CacheFactory.getCache(cacheName);
    for (Map.Entry entry : values.entrySet()) {
        try {
            cache.invoke(entry.getKey(), new BinaryValueUpdater((Binary) entry.getValue()));
        } catch (Throwable t) {
            errors.put(entry.getKey(), t);
        }
    }
    setResult(errors);
}

现在PutAll可调用run方法并缓存。调用使用BinaryValueUpdater和二进制值,所以我们保存任何反序列化。运行性能测试我们现在可以做1000键/值对92 ms -现在我们实际上是速度是和Coherence的putAll相比,斌不是太差.

进一步增强
还有其他各种各样的强化,可以让代码添加其他功能。

让集群成员执行
The code above would only work from a Coherence*Extend client as it relies on the PutAll invocable being serialized over the wire as the first step. We can make some changes to allow it to be called from a cluster member too. In the system that we originally wrote this for we have a way to determine whether we are running an invocable from a client or a cluster member and take the appropriate action. We can amend the PutAll invocable so it can be used from cluster members too. If running on a cluster member we do not need the first invocation call but we still need to make sure we have serialized the values into Binary so we add a flag, defaulted to false, to tell us whether the values have been converted. The readExternal method will have read Binary values so it can set the flag to true. We then check the flag in the run method and if it is false we can call a method to convert the values to Binary. The code looks like this…
public class PutAll extends AbstractInvocable implements PortableObject {
    public static final String INVOCATION_SERVICE_NAME = "InvocationService";
 
    private String cacheName;
    private Map<Object,Object> values;
    private boolean valuesAreBinary = false;
 
    public PutAll() {
    }
 
    public PutAll(String cacheName, Map<Object, Object> values) {
        this.cacheName = cacheName;
        this.values = values;
    }
 
    @SuppressWarnings({"unchecked"})
    @Override
    public void run() {
        NamedCache cache = CacheFactory.getCache(cacheName);
        DistributedCacheService cacheService = (DistributedCacheService) cache.getCacheService();
 
        ensureValuesAreBinary(cacheService.getSerializer());
 
        Map<Member,Map<Object,Object>> valuesByMember = new HashMap<Member,Map<Object,Object>>();
        for (Map.Entry entry : values.entrySet()) {
            Object key = entry.getKey();
            Member member = cacheService.getKeyOwner(key);
            if (!valuesByMember.containsKey(member)) {
                valuesByMember.put(member, new HashMap<Object,Object>());
            }
            valuesByMember.get(member).put(key, entry.getValue());
        }
 
        InvocationService service = (InvocationService) CacheFactory.getService(INVOCATION_SERVICE_NAME);
        PutAllObserver observer = new PutAllObserver(valuesByMember);
        for (Map.Entry<Member,Map<Object,Object>> entry : valuesByMember.entrySet()) {
            PutAllForMember putAllForMember = new PutAllForMember(cacheName, entry.getValue());
            service.execute(putAllForMember, Collections.singleton(entry.getKey()), observer);
        }
 
        Map<Object, Throwable> results = observer.getResults();
        setResult(results);
    }
 
    @SuppressWarnings({"unchecked"})
    @Override
    public void readExternal(PofReader pofReader) throws IOException {
        cacheName = pofReader.readString(1);
        values = new HashMap<Object,Object>();
        int count = pofReader.readInt(2);
        int id = 3;
        for (int i=0; i<count; i++) {
            Object key = pofReader.readObject(id++);
            Binary value = pofReader.readBinary(id++);
            values.put(key, value);
        }
        valuesAreBinary = true;
    }
 
    @Override
    public void writeExternal(PofWriter pofWriter) throws IOException {
        pofWriter.writeString(1, cacheName);
        pofWriter.writeInt(2, values.size());
        Serializer serializer = pofWriter.getPofContext();
        int id = 3;
        for (Map.Entry entry : values.entrySet()) {
            pofWriter.writeObject(id++, entry.getKey());
            pofWriter.writeBinary(id++, ExternalizableHelper.toBinary(entry.getValue(), serializer));
        }
    }
 
    private synchronized void ensureValuesAreBinary(Serializer serializer) {
        if (!valuesAreBinary) {
            Map<Object, Object> converted;
            if (this.values.isEmpty()) {
                converted = this.values;
            } else {
                converted = new HashMap<Object,Object>();
                for (Map.Entry entry : values.entrySet()) {
                    converted.put(entry.getKey(), ExternalizableHelper.toBinary(entry.getValue(), serializer));
                }
            }
            values = converted;
            valuesAreBinary = true;
        }
    }
}
为了只让一个集群成员调用PutAll,我们第一步先不使用 invocation service 我们直接调用PutAll运行方法然后调用getResult()方法的错误。
Map<Object,Object> values = new HashMap<Object,Object>();
// --- Populate the values map with the key/value pairs to put ---
PutAll putAll = new PutAll("dist-test", values);
putAll.run();
Map<Object,Throwable> errors = putAll.getResult();
// --- check the errors map for any failures
Ordering

One use of putAll in the system I am currently working on is part of the initial data population when the cluster starts up. Because of the way data is loaded we do multiple updates for the same key as we build up the data. We needed the putAll to preserve the ordering of the key/value pairs so we could in effect do a putAll where we sent multiple values for the same key in a single putAll call but ensuring we maintained the order. This is easy to do by replacing the Maps holding the keys and values in the PutAll and PutAllFormember invocables with a List of some sort of tuple class to hold the key and corresponding value. Instead of iterating over a Map we iterate over a List so maintaining order.

Setting Expiry

The NamedCache class has a version of the put method that takes a third parameter to specify the expiry time for the key and value being put but there is no putAll equivalent of this method. We could enhance out version of PutAll to set a bulk expiry value for all the entries; we just need to pass a long value to the PutAll and PutAllForMember invocables constructors, not forgetting to add them to the readExternal and writeExternal methods.

Conclusions

So there you have it, an alternative version of Coherence putAll that allows you to recover from exceptions thrown from updates. The approach has proved to be work well and remarkably fast in a stable cluster - that is one in which partitions are not moving. Given that partitions only move when nodes leave or join the cluster that is not too bad. If a node leaves the cluster then there might be problems if that node was executing one of the PutAllForMember invocables, but you should be able to catch the error and recover. But then you really don't want nodes leaving your cluster very often, if ever, do you.





  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值