Timing Wheel 定时轮算法

14 篇文章 2 订阅

最近自己在写一个网络服务程序时需要管理大量客户端连接的,其中每个客户端连接都需要管理它的 timeout 时间。

通常连接的超时管理一般设置为30~60秒不等,并不需要太精确的时间控制。

另外由于服务端管理着多达数万到数十万不等的连接数,因此我们没法为每个连接使用一个Timer,那样太消耗资源不现实。


最早面临类似问题的应该是在操作系统和网络协议栈的实现中,以TCP协议为例:

其可靠传输依赖超时重传机制,因此每个通过TCP传输的 packet 都需要一个 timer 来调度 timeout 事件。

根据George Varghese 和 Tony Lauck 1996 年的论文<Hashed and Hierarchical Timing Wheels: 

data structures to efficiently implement a timer facility>(http://cseweb.ucsd.edu/users/varghese/PAPERS/twheel.ps.Z)

提出了一种定时轮的方式来管理和维护大量的 timer 调度,本文主要根据该论文讨论下实现一种定时轮的要点。


定时轮是一种数据结构,其主体是一个循环列表(circular buffer),每个列表中包含一个称之为槽(slot)的结构(附图)。

至于 slot 的具体结构依赖具体应用场景。

以本文开头所述的管理大量连接 timeout 的场景为例,描述一下 timing wheel的具体实现细节。


定时轮的工作原理可以类比于始终,如上图箭头(指针)按某一个方向按固定频率轮动,每一次跳动称为一个 tick。

这样可以看出定时轮由个3个重要的属性参数,ticksPerWheel(一轮的tick数),tickDuration(一个tick的持续时间)

以及 timeUnit(时间单位),例如 当ticksPerWheel=60,tickDuration=1,timeUnit=秒,这就和现实中的始终的秒针走动完全类似了。


这里给出一种简单的实现方式,指针按 tickDuration 的设置进行固定频率的转动,其中的必要约定如下:

  1. 新加入的对象总是保存在当前指针转动方向上一个位置
  2. 相等的对象仅存在于一个 slot 中
  3. 指针转动到当前位置对应的 slot 中保存的对象就意味着 timeout 了

在 Timing Wheel 模型中包含4种操作:

Client invoke:

1. START_TIMER(Interval, Request_ID, Expiry_Action)

2. STOP_TIMER(Request_ID)

Timer tick invoke:

3. PER_TICK_BOOKKEEPING

4. EXPIRY_PROCESSING


Timing Wheel 实现中主要考察的是前3种操作的时间和空间复杂度,而第4种属于超时处理通常实现为回调方法,

由调用方的实现决定其效率,下面看一个用 Java 实现的 Timing Wheel 的具体例子:

TimingWheel.java

  1. /** 
  2.  * A timing-wheel optimized for approximated I/O timeout scheduling.<br> 
  3.  * {@link TimingWheel} creates a new thread whenever it is instantiated and started, so don't create many instances. 
  4.  * <p/> 
  5.  * <b>The classic usage as follows:</b><br> 
  6.  * <li>using timing-wheel manage any object timeout</li> 
  7.  * <pre> 
  8.  *    // Create a timing-wheel with 60 ticks, and every tick is 1 second. 
  9.  *    private static final TimingWheel<CometChannel> TIMING_WHEEL = new TimingWheel<CometChannel>(1, 60, TimeUnit.SECONDS); 
  10.  * 
  11.  *    // Add expiration listener and start the timing-wheel. 
  12.  *    static { 
  13.  *      TIMING_WHEEL.addExpirationListener(new YourExpirationListener()); 
  14.  *      TIMING_WHEEL.start(); 
  15.  *    } 
  16.  * 
  17.  *    // Add one element to be timeout approximated after 60 seconds 
  18.  *    TIMING_WHEEL.add(e); 
  19.  * 
  20.  *    // Anytime you can cancel count down timer for element e like this 
  21.  *    TIMING_WHEEL.remove(e); 
  22.  * </pre> 
  23.  * <p/> 
  24.  * After expiration occurs, the {@link ExpirationListener} interface will be invoked and the expired object will be 
  25.  * the argument for callback method {@link ExpirationListener#expired(Object)} 
  26.  * <p/> 
  27.  * {@link TimingWheel} is based on <a href="http://cseweb.ucsd.edu/users/varghese/">George Varghese</a> and Tony Lauck's paper, 
  28.  * <a href="http://cseweb.ucsd.edu/users/varghese/PAPERS/twheel.ps.Z">'Hashed and Hierarchical Timing Wheels: data structures 
  29.  * to efficiently implement a timer facility'</a>.  More comprehensive slides are located 
  30.  * <a href="http://www.cse.wustl.edu/~cdgill/courses/cs6874/TimingWheels.ppt">here</a>. 
  31.  * 
  32.  * @author mindwind 
  33.  * @version 1.0, Sep 20, 2012 
  34.  */  
  35. public class TimingWheel<E> {  
  36.   
  37.     private final long tickDuration;  
  38.     private final int ticksPerWheel;  
  39.     private volatile int currentTickIndex = 0;  
  40.   
  41.     private final CopyOnWriteArrayList<ExpirationListener<E>> expirationListeners =   
  42.     new CopyOnWriteArrayList<ExpirationListener<E>>();  
  43.     private final ArrayList<Slot<E>> wheel;  
  44.     private final Map<E, Slot<E>> indicator = new ConcurrentHashMap<E, Slot<E>>();  
  45.   
  46.     private final AtomicBoolean shutdown = new AtomicBoolean(false);  
  47.     private final ReadWriteLock lock = new ReentrantReadWriteLock();  
  48.     private Thread workerThread;  
  49.   
  50.     // ~ -------------------------------------------------------------------------------------------------------------  
  51.   
  52.     /** 
  53.      * Construct a timing wheel. 
  54.      * 
  55.      * @param tickDuration  tick duration with specified time unit. 
  56.      * @param ticksPerWheel 
  57.      * @param timeUnit 
  58.      */  
  59.     public TimingWheel(int tickDuration, int ticksPerWheel, TimeUnit timeUnit) {  
  60.         if (timeUnit == null) {  
  61.             throw new NullPointerException("unit");  
  62.         }  
  63.         if (tickDuration <= 0) {  
  64.             throw new IllegalArgumentException("tickDuration must be greater than 0: " + tickDuration);  
  65.         }  
  66.         if (ticksPerWheel <= 0) {  
  67.             throw new IllegalArgumentException("ticksPerWheel must be greater than 0: " + ticksPerWheel);  
  68.         }  
  69.   
  70.         this.wheel = new ArrayList<Slot<E>>();  
  71.         this.tickDuration = TimeUnit.MILLISECONDS.convert(tickDuration, timeUnit);  
  72.         this.ticksPerWheel = ticksPerWheel + 1;  
  73.   
  74.         for (int i = 0; i < this.ticksPerWheel; i++) {  
  75.             wheel.add(new Slot<E>(i));  
  76.         }  
  77.         wheel.trimToSize();  
  78.   
  79.         workerThread = new Thread(new TickWorker(), "Timing-Wheel");  
  80.     }  
  81.   
  82.     // ~ -------------------------------------------------------------------------------------------------------------  
  83.   
  84.     public void start() {  
  85.         if (shutdown.get()) {  
  86.             throw new IllegalStateException("Cannot be started once stopped");  
  87.         }  
  88.   
  89.         if (!workerThread.isAlive()) {  
  90.             workerThread.start();  
  91.         }  
  92.     }  
  93.   
  94.     public boolean stop() {  
  95.         if (!shutdown.compareAndSet(falsetrue)) {  
  96.             return false;  
  97.         }  
  98.   
  99.         boolean interrupted = false;  
  100.         while (workerThread.isAlive()) {  
  101.             workerThread.interrupt();  
  102.             try {  
  103.                 workerThread.join(100);  
  104.             } catch (InterruptedException e) {  
  105.                 interrupted = true;  
  106.             }  
  107.         }  
  108.         if (interrupted) {  
  109.             Thread.currentThread().interrupt();  
  110.         }  
  111.   
  112.         return true;  
  113.     }  
  114.   
  115.     public void addExpirationListener(ExpirationListener<E> listener) {  
  116.         expirationListeners.add(listener);  
  117.     }  
  118.   
  119.     public void removeExpirationListener(ExpirationListener<E> listener) {  
  120.         expirationListeners.remove(listener);  
  121.     }  
  122.   
  123.     /** 
  124.      * Add a element to {@link TimingWheel} and start to count down its life-time. 
  125.      * 
  126.      * @param e 
  127.      * @return remain time to be expired in millisecond. 
  128.      */  
  129.     public long add(E e) {  
  130.         synchronized (e) {  
  131.             checkAdd(e);  
  132.   
  133.             int previousTickIndex = getPreviousTickIndex();  
  134.             Slot<E> slot = wheel.get(previousTickIndex);  
  135.             slot.add(e);  
  136.             indicator.put(e, slot);  
  137.   
  138.             return (ticksPerWheel - 1) * tickDuration;  
  139.         }  
  140.     }  
  141.   
  142.     private void checkAdd(E e) {  
  143.         Slot<E> slot = indicator.get(e);  
  144.         if (slot != null) {  
  145.             slot.remove(e);  
  146.         }  
  147.     }  
  148.   
  149.     private int getPreviousTickIndex() {  
  150.         lock.readLock().lock();  
  151.         try {  
  152.             int cti = currentTickIndex;  
  153.             if (cti == 0) {  
  154.                 return ticksPerWheel - 1;  
  155.             }  
  156.   
  157.             return cti - 1;  
  158.         } finally {  
  159.             lock.readLock().unlock();  
  160.         }  
  161.     }  
  162.   
  163.     /** 
  164.      * Removes the specified element from timing wheel. 
  165.      * 
  166.      * @param e 
  167.      * @return <tt>true</tt> if this timing wheel contained the specified 
  168.      * element 
  169.      */  
  170.     public boolean remove(E e) {  
  171.         synchronized (e) {  
  172.             Slot<E> slot = indicator.get(e);  
  173.             if (slot == null) {  
  174.                 return false;  
  175.             }  
  176.   
  177.             indicator.remove(e);  
  178.             return slot.remove(e) != null;  
  179.         }  
  180.     }  
  181.   
  182.     private void notifyExpired(int idx) {  
  183.         Slot<E> slot = wheel.get(idx);  
  184.         Set<E> elements = slot.elements();  
  185.         for (E e : elements) {  
  186.             slot.remove(e);  
  187.             synchronized (e) {  
  188.                 Slot<E> latestSlot = indicator.get(e);  
  189.                 if (latestSlot.equals(slot)) {  
  190.                     indicator.remove(e);  
  191.                 }  
  192.             }  
  193.             for (ExpirationListener<E> listener : expirationListeners) {  
  194.                 listener.expired(e);  
  195.             }  
  196.         }  
  197.     }  
  198.   
  199.     // ~ -------------------------------------------------------------------------------------------------------------  
  200.   
  201.     private class TickWorker implements Runnable {  
  202.   
  203.         private long startTime;  
  204.         private long tick;  
  205.   
  206.         @Override  
  207.         public void run() {  
  208.             startTime = System.currentTimeMillis();  
  209.             tick = 1;  
  210.   
  211.             for (int i = 0; !shutdown.get(); i++) {  
  212.                 if (i == wheel.size()) {  
  213.                     i = 0;  
  214.                 }  
  215.                 lock.writeLock().lock();  
  216.                 try {  
  217.                     currentTickIndex = i;  
  218.                 } finally {  
  219.                     lock.writeLock().unlock();  
  220.                 }  
  221.                 notifyExpired(currentTickIndex);  
  222.                 waitForNextTick();  
  223.             }  
  224.         }  
  225.   
  226.         private void waitForNextTick() {  
  227.             for (; ; ) {  
  228.                 long currentTime = System.currentTimeMillis();  
  229.                 long sleepTime = tickDuration * tick - (currentTime - startTime);  
  230.   
  231.                 if (sleepTime <= 0) {  
  232.                     break;  
  233.                 }  
  234.   
  235.                 try {  
  236.                     Thread.sleep(sleepTime);  
  237.                 } catch (InterruptedException e) {  
  238.                     return;  
  239.                 }  
  240.             }  
  241.   
  242.             tick++;  
  243.         }  
  244.     }  
  245.   
  246.     private static class Slot<E> {  
  247.   
  248.         private int id;  
  249.         private Map<E, E> elements = new ConcurrentHashMap<E, E>();  
  250.   
  251.         public Slot(int id) {  
  252.             this.id = id;  
  253.         }  
  254.   
  255.         public void add(E e) {  
  256.             elements.put(e, e);  
  257.         }  
  258.   
  259.         public E remove(E e) {  
  260.             return elements.remove(e);  
  261.         }  
  262.   
  263.         public Set<E> elements() {  
  264.             return elements.keySet();  
  265.         }  
  266.   
  267.         @Override  
  268.         public int hashCode() {  
  269.             final int prime = 31;  
  270.             int result = 1;  
  271.             result = prime * result + id;  
  272.             return result;  
  273.         }  
  274.   
  275.         @Override  
  276.         public boolean equals(Object obj) {  
  277.             if (this == obj)  
  278.                 return true;  
  279.             if (obj == null)  
  280.                 return false;  
  281.             if (getClass() != obj.getClass())  
  282.                 return false;  
  283.             @SuppressWarnings("rawtypes")  
  284.             Slot other = (Slot) obj;  
  285.             if (id != other.id)  
  286.                 return false;  
  287.             return true;  
  288.         }  
  289.   
  290.         @Override  
  291.         public String toString() {  
  292.             return "Slot [id=" + id + ", elements=" + elements + "]";  
  293.         }  
  294.   
  295.     }  
  296.   
  297. }  
/**
 * A timing-wheel optimized for approximated I/O timeout scheduling.<br>
 * {@link TimingWheel} creates a new thread whenever it is instantiated and started, so don't create many instances.
 * <p/>
 * <b>The classic usage as follows:</b><br>
 * <li>using timing-wheel manage any object timeout</li>
 * <pre>
 *    // Create a timing-wheel with 60 ticks, and every tick is 1 second.
 *    private static final TimingWheel<CometChannel> TIMING_WHEEL = new TimingWheel<CometChannel>(1, 60, TimeUnit.SECONDS);
 *
 *    // Add expiration listener and start the timing-wheel.
 *    static {
 *    	TIMING_WHEEL.addExpirationListener(new YourExpirationListener());
 *    	TIMING_WHEEL.start();
 *    }
 *
 *    // Add one element to be timeout approximated after 60 seconds
 *    TIMING_WHEEL.add(e);
 *
 *    // Anytime you can cancel count down timer for element e like this
 *    TIMING_WHEEL.remove(e);
 * </pre>
 * <p/>
 * After expiration occurs, the {@link ExpirationListener} interface will be invoked and the expired object will be
 * the argument for callback method {@link ExpirationListener#expired(Object)}
 * <p/>
 * {@link TimingWheel} is based on <a href="http://cseweb.ucsd.edu/users/varghese/">George Varghese</a> and Tony Lauck's paper,
 * <a href="http://cseweb.ucsd.edu/users/varghese/PAPERS/twheel.ps.Z">'Hashed and Hierarchical Timing Wheels: data structures
 * to efficiently implement a timer facility'</a>.  More comprehensive slides are located
 * <a href="http://www.cse.wustl.edu/~cdgill/courses/cs6874/TimingWheels.ppt">here</a>.
 *
 * @author mindwind
 * @version 1.0, Sep 20, 2012
 */
public class TimingWheel<E> {

    private final long tickDuration;
    private final int ticksPerWheel;
    private volatile int currentTickIndex = 0;

    private final CopyOnWriteArrayList<ExpirationListener<E>> expirationListeners = 
    new CopyOnWriteArrayList<ExpirationListener<E>>();
    private final ArrayList<Slot<E>> wheel;
    private final Map<E, Slot<E>> indicator = new ConcurrentHashMap<E, Slot<E>>();

    private final AtomicBoolean shutdown = new AtomicBoolean(false);
    private final ReadWriteLock lock = new ReentrantReadWriteLock();
    private Thread workerThread;

    // ~ -------------------------------------------------------------------------------------------------------------

    /**
     * Construct a timing wheel.
     *
     * @param tickDuration  tick duration with specified time unit.
     * @param ticksPerWheel
     * @param timeUnit
     */
    public TimingWheel(int tickDuration, int ticksPerWheel, TimeUnit timeUnit) {
        if (timeUnit == null) {
            throw new NullPointerException("unit");
        }
        if (tickDuration <= 0) {
            throw new IllegalArgumentException("tickDuration must be greater than 0: " + tickDuration);
        }
        if (ticksPerWheel <= 0) {
            throw new IllegalArgumentException("ticksPerWheel must be greater than 0: " + ticksPerWheel);
        }

        this.wheel = new ArrayList<Slot<E>>();
        this.tickDuration = TimeUnit.MILLISECONDS.convert(tickDuration, timeUnit);
        this.ticksPerWheel = ticksPerWheel + 1;

        for (int i = 0; i < this.ticksPerWheel; i++) {
            wheel.add(new Slot<E>(i));
        }
        wheel.trimToSize();

        workerThread = new Thread(new TickWorker(), "Timing-Wheel");
    }

    // ~ -------------------------------------------------------------------------------------------------------------

    public void start() {
        if (shutdown.get()) {
            throw new IllegalStateException("Cannot be started once stopped");
        }

        if (!workerThread.isAlive()) {
            workerThread.start();
        }
    }

    public boolean stop() {
        if (!shutdown.compareAndSet(false, true)) {
            return false;
        }

        boolean interrupted = false;
        while (workerThread.isAlive()) {
            workerThread.interrupt();
            try {
                workerThread.join(100);
            } catch (InterruptedException e) {
                interrupted = true;
            }
        }
        if (interrupted) {
            Thread.currentThread().interrupt();
        }

        return true;
    }

    public void addExpirationListener(ExpirationListener<E> listener) {
        expirationListeners.add(listener);
    }

    public void removeExpirationListener(ExpirationListener<E> listener) {
        expirationListeners.remove(listener);
    }

    /**
     * Add a element to {@link TimingWheel} and start to count down its life-time.
     *
     * @param e
     * @return remain time to be expired in millisecond.
     */
    public long add(E e) {
        synchronized (e) {
            checkAdd(e);

            int previousTickIndex = getPreviousTickIndex();
            Slot<E> slot = wheel.get(previousTickIndex);
            slot.add(e);
            indicator.put(e, slot);

            return (ticksPerWheel - 1) * tickDuration;
        }
    }

    private void checkAdd(E e) {
        Slot<E> slot = indicator.get(e);
        if (slot != null) {
            slot.remove(e);
        }
    }

    private int getPreviousTickIndex() {
        lock.readLock().lock();
        try {
            int cti = currentTickIndex;
            if (cti == 0) {
                return ticksPerWheel - 1;
            }

            return cti - 1;
        } finally {
            lock.readLock().unlock();
        }
    }

    /**
     * Removes the specified element from timing wheel.
     *
     * @param e
     * @return <tt>true</tt> if this timing wheel contained the specified
     * element
     */
    public boolean remove(E e) {
        synchronized (e) {
            Slot<E> slot = indicator.get(e);
            if (slot == null) {
                return false;
            }

            indicator.remove(e);
            return slot.remove(e) != null;
        }
    }

    private void notifyExpired(int idx) {
        Slot<E> slot = wheel.get(idx);
        Set<E> elements = slot.elements();
        for (E e : elements) {
            slot.remove(e);
            synchronized (e) {
                Slot<E> latestSlot = indicator.get(e);
                if (latestSlot.equals(slot)) {
                    indicator.remove(e);
                }
            }
            for (ExpirationListener<E> listener : expirationListeners) {
                listener.expired(e);
            }
        }
    }

    // ~ -------------------------------------------------------------------------------------------------------------

    private class TickWorker implements Runnable {

        private long startTime;
        private long tick;

        @Override
        public void run() {
            startTime = System.currentTimeMillis();
            tick = 1;

            for (int i = 0; !shutdown.get(); i++) {
                if (i == wheel.size()) {
                    i = 0;
                }
                lock.writeLock().lock();
                try {
                    currentTickIndex = i;
                } finally {
                    lock.writeLock().unlock();
                }
                notifyExpired(currentTickIndex);
                waitForNextTick();
            }
        }

        private void waitForNextTick() {
            for (; ; ) {
                long currentTime = System.currentTimeMillis();
                long sleepTime = tickDuration * tick - (currentTime - startTime);

                if (sleepTime <= 0) {
                    break;
                }

                try {
                    Thread.sleep(sleepTime);
                } catch (InterruptedException e) {
                    return;
                }
            }

            tick++;
        }
    }

    private static class Slot<E> {

        private int id;
        private Map<E, E> elements = new ConcurrentHashMap<E, E>();

        public Slot(int id) {
            this.id = id;
        }

        public void add(E e) {
            elements.put(e, e);
        }

        public E remove(E e) {
            return elements.remove(e);
        }

        public Set<E> elements() {
            return elements.keySet();
        }

        @Override
        public int hashCode() {
            final int prime = 31;
            int result = 1;
            result = prime * result + id;
            return result;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj)
                return true;
            if (obj == null)
                return false;
            if (getClass() != obj.getClass())
                return false;
            @SuppressWarnings("rawtypes")
            Slot other = (Slot) obj;
            if (id != other.id)
                return false;
            return true;
        }

        @Override
        public String toString() {
            return "Slot [id=" + id + ", elements=" + elements + "]";
        }

    }

}


ExpirationListener.java

  1. /** 
  2.  * A listener for expired object events. 
  3.  *  
  4.  * @author mindwind 
  5.  * @version 1.0, Sep 20, 2012 
  6.  * @see TimingWheel 
  7.  */  
  8. public interface ExpirationListener<E> {  
  9.       
  10.     /** 
  11.      * Invoking when a expired event occurs. 
  12.      *  
  13.      * @param expiredObject 
  14.      */  
  15.     void expired(E expiredObject);  
  16.       
  17. }  
/**
 * A listener for expired object events.
 * 
 * @author mindwind
 * @version 1.0, Sep 20, 2012
 * @see TimingWheel
 */
public interface ExpirationListener<E> {
	
	/**
	 * Invoking when a expired event occurs.
	 * 
	 * @param expiredObject
	 */
	void expired(E expiredObject);
	
}



我们分析一下这个简化版本  TimingWheel 实现中的 4 个主要操作的实现:


START_TIMER(Interval, Request_ID, Expiry_Action) ,这段伪代码的实现对应于TimingWheel的 add(E e) 方法。

  • 首先检查同样的元素是否已添加到 TimingWheel 中,若已存在则删除旧的引用,重新安置元素在wheel中位置。这个检查是为了满足约束条件2(相等的对象仅存在于一个 slot 中,重新加入相同的元素相当于重置了该元素的 Timer)
  • 获取当前 tick 指针位置的前一个 slot 槽位,放置新加入的元素,并在内部记录下该位置
  • 返回新加入元素的 timeout 时间,以毫秒计算(一般的应用级程序到毫秒这个精度已经足够了)
  • 显然,时间复杂度为O(1)

STOP_TIMER(Request_ID),这段伪代码的实现对应于TimingWheel的 remove(E e) 方法。

  • 获取元素在 TimingWheel 中对应 slot 位置
  • 从中 slot 中删除
  • 显然,时间复杂度也为O(1)

 PER_TICK_BOOKKEEPING,伪代码对应于 TimingWheel 中 TickerWorker 中的  run() 方法。

  • 获取当前 tick 指针的 slot
  • 对当前 slot 的所有元素进行 timeout 处理(notifyExpired())
  • ticker 不需要针对每个元素去判断其 timeout 时间,故时间复杂度也为 O(1)

 EXPIRY_PROCESSING,伪代码对应于TimingWheel 中的 notifyExpired() 方法

  • 实现了对每个 timeout 元素的 Expiry_Action 处理
  • 这里时间复杂度显然 是 O(n)的。

在维护大量连接的例子中:

  • 连接建立时,把一个连接放入 TimingWheel 中进入 timeout 倒计时
  • 每次收到长连接心跳时,重新加入一次TimingWheel 相当于重置了 timer
  • timeout 时间到达时触发 EXPIRY_PROCESSING
  • EXPIRY_PROCESSING 实际就是关闭超时的连接。

这个简化版的 TimingWheel 实现一个实例只能支持一个固定的 timeout 时长调度,不能支持对于每个元素特定的 timeout 时长。

一种改进的做法是设计一个函数,计算每个元素特定的deadline,并根据deadline计算放置在wheel中的特定位置,这个以后再完善。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值