淘宝订单同步方案 - 丢单终结者
订单管理是很多卖家工具的必备功能之一,而订单同步则是订单管理中的数据来源,如何保证订单同步的实时、高效、低碳和不丢单是非常重要的事情。
订单同步接口
1. taobao.trades.sold.get,根据订单创建时间查询3个月内已卖出的订单。
2. taobao.trades.sold.increment.get,根据订单修改时间查询1天内的增量订单。
3. taobao.trade.fullinfo.get,根据订单ID查询订单的详细信息。
丢单原因分析
一、没有检查订单同步接口的返回值是否成功。
二、只使用taobao.trades.sold.get同步订单,此接口是按照订单创建时间查询的,一个订单创建后何时被修改(付款、发货、确认收货)是不确定的,所以采用这种方案无法确定该同步哪个时段内的订单,除非你每次都同步3个月内的订单(严重浪费资源,应该没人会这么做),否则不管选择什么时段同步都有丢单的可能。
三、没有记录每次订单同步成功后的时间点。比如每10分钟增量同步一次订单,如果系统恰好在某个同步时刻出现异常,则这次的同步就有可能被中止。
四、整点误差(时/分/秒)。比如每10分钟增量同步一次订单:第一次同步00:00:00 ~ 00:10:00时段的订单,第二次同步00:10:01 ~ 00:20:00时段的订单。这种方式就有可能丢失00:10:00的一部分订单,特别是店铺参加聚划算活动时更容易出现。
五、按状态同步订单,这种方式的问题在于订单状态过多,有可能会出现状态遗漏,而且性能低效。
推荐同步方案
同步流程图
流程图解释
1. 用户第一次登录时使用taobao.trades.sold.get同步3个月内的订单,并把用户登录的时间做为之后增量同步的时间起点。
2. 同时后台启动定时任务进行增量订单同步,根据店铺订单量的不同和客户来访时间,可设置不同的同步频率,每次增量同步完毕后,需要把增量同步的时间点记录下来,以做为下次增量同步的起点。
订单同步技巧
1. 使用taobao.trades.sold.get同步3个月内的订单时,最好把3个月分隔成若干个时段来查询,否则很容易出现超时。由于订单的创建时间不会变化,所以分页时从前翻页还是从后面翻页都无所谓(前提是翻页的过程中不能改变查询时间)。
2. 使用taobao.trades.sold.increment.get增量同步订单时,查询到的订单是按修改时间倒序返回的,所以分页时必须从最后一页开始翻页,否则有可能出现丢单。这是因为如果从第一页开始翻页,则翻页过程中发生变更的订单就会减少订单总数,使翻页出现误差。
3. 使用taobao.trades.sold.increment.get增量同步订单时,可以先通过只查询tid字段得到指定时段的订单总数,然后计算出分页数,后继采用倒序翻页时,设置use_has_next=true可以禁止API接口去统计订单总数,减少每次查询时都做统计的开销,可以大大提高查询性能。
4. 根据订单量的不同,需要采用不同的同步时段。对于日均订单量在1000左右的店铺,如果设置每页查询50条记录,每10分钟同步一次,则每次同步基本上只需要一次分页查询就能完成同步。
5. 时刻记录每次成功同步的时间点(比如存储到数据库中),避免重复劳动。
6. 对于用户量较大,实时性要求较高的应用,最好采用多线程同步的方式。可建立一个固定大小的线程池(需要根据硬件条件和网络状况不同设置不同的线程池大小),为每个用户启动一个线程去同步订单。
7. 由于API调用是有频率限制的,采用多线程同步订单时,有可能需要每次API调用后做一些短暂的停顿,以免调用超频,造成长时间不可访问API。
8. 如果批量订单查询返回的数据不够,需要通过订单详情接口获取时,强烈推荐批量查询订单时,只查询tid字段,然后通过taobao.trade.fullinfo.get查询订单详情。
9. 使用taobao.time.get获取的时间作为当前时间。否则,如果ISV服务器的时间比淘宝服务器的时间快,则有可能提前同步订单导致丢单。
10. 使用taobao.trades.sold.increment.get接口时,设置的查询时间段返回的总记录数最好不要超过2万,否则很容易发生超时。
特别提醒:针对光棍节大促,由于订单量很大,如果使用倒序需要返回total_results,建议大商家抓单时间间隔设置小于5分钟,使每次抓单尽量不要超过2万单,避免数目过多导致的性能和超时问题。
附JAVA示例代码:http://tbtop.googlecode.com/files/TradeSync.java
订单同步接口
1. taobao.trades.sold.get,根据订单创建时间查询3个月内已卖出的订单。
2. taobao.trades.sold.increment.get,根据订单修改时间查询1天内的增量订单。
3. taobao.trade.fullinfo.get,根据订单ID查询订单的详细信息。
丢单原因分析
一、没有检查订单同步接口的返回值是否成功。
二、只使用taobao.trades.sold.get同步订单,此接口是按照订单创建时间查询的,一个订单创建后何时被修改(付款、发货、确认收货)是不确定的,所以采用这种方案无法确定该同步哪个时段内的订单,除非你每次都同步3个月内的订单(严重浪费资源,应该没人会这么做),否则不管选择什么时段同步都有丢单的可能。
三、没有记录每次订单同步成功后的时间点。比如每10分钟增量同步一次订单,如果系统恰好在某个同步时刻出现异常,则这次的同步就有可能被中止。
四、整点误差(时/分/秒)。比如每10分钟增量同步一次订单:第一次同步00:00:00 ~ 00:10:00时段的订单,第二次同步00:10:01 ~ 00:20:00时段的订单。这种方式就有可能丢失00:10:00的一部分订单,特别是店铺参加聚划算活动时更容易出现。
五、按状态同步订单,这种方式的问题在于订单状态过多,有可能会出现状态遗漏,而且性能低效。
推荐同步方案
同步流程图
流程图解释
1. 用户第一次登录时使用taobao.trades.sold.get同步3个月内的订单,并把用户登录的时间做为之后增量同步的时间起点。
2. 同时后台启动定时任务进行增量订单同步,根据店铺订单量的不同和客户来访时间,可设置不同的同步频率,每次增量同步完毕后,需要把增量同步的时间点记录下来,以做为下次增量同步的起点。
订单同步技巧
1. 使用taobao.trades.sold.get同步3个月内的订单时,最好把3个月分隔成若干个时段来查询,否则很容易出现超时。由于订单的创建时间不会变化,所以分页时从前翻页还是从后面翻页都无所谓(前提是翻页的过程中不能改变查询时间)。
2. 使用taobao.trades.sold.increment.get增量同步订单时,查询到的订单是按修改时间倒序返回的,所以分页时必须从最后一页开始翻页,否则有可能出现丢单。这是因为如果从第一页开始翻页,则翻页过程中发生变更的订单就会减少订单总数,使翻页出现误差。
3. 使用taobao.trades.sold.increment.get增量同步订单时,可以先通过只查询tid字段得到指定时段的订单总数,然后计算出分页数,后继采用倒序翻页时,设置use_has_next=true可以禁止API接口去统计订单总数,减少每次查询时都做统计的开销,可以大大提高查询性能。
4. 根据订单量的不同,需要采用不同的同步时段。对于日均订单量在1000左右的店铺,如果设置每页查询50条记录,每10分钟同步一次,则每次同步基本上只需要一次分页查询就能完成同步。
5. 时刻记录每次成功同步的时间点(比如存储到数据库中),避免重复劳动。
6. 对于用户量较大,实时性要求较高的应用,最好采用多线程同步的方式。可建立一个固定大小的线程池(需要根据硬件条件和网络状况不同设置不同的线程池大小),为每个用户启动一个线程去同步订单。
7. 由于API调用是有频率限制的,采用多线程同步订单时,有可能需要每次API调用后做一些短暂的停顿,以免调用超频,造成长时间不可访问API。
8. 如果批量订单查询返回的数据不够,需要通过订单详情接口获取时,强烈推荐批量查询订单时,只查询tid字段,然后通过taobao.trade.fullinfo.get查询订单详情。
9. 使用taobao.time.get获取的时间作为当前时间。否则,如果ISV服务器的时间比淘宝服务器的时间快,则有可能提前同步订单导致丢单。
10. 使用taobao.trades.sold.increment.get接口时,设置的查询时间段返回的总记录数最好不要超过2万,否则很容易发生超时。
特别提醒:针对光棍节大促,由于订单量很大,如果使用倒序需要返回total_results,建议大商家抓单时间间隔设置小于5分钟,使每次抓单尽量不要超过2万单,避免数目过多导致的性能和超时问题。
附JAVA示例代码:http://tbtop.googlecode.com/files/TradeSync.java
package com.taobao.top.tool; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.List; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import com.taobao.api.ApiException; import com.taobao.api.DefaultTaobaoClient; import com.taobao.api.TaobaoClient; import com.taobao.api.domain.Trade; import com.taobao.api.request.TimeGetRequest; import com.taobao.api.request.TradeFullinfoGetRequest; import com.taobao.api.request.TradesSoldGetRequest; import com.taobao.api.request.TradesSoldIncrementGetRequest; import com.taobao.api.response.TimeGetResponse; import com.taobao.api.response.TradeFullinfoGetResponse; import com.taobao.api.response.TradesSoldGetResponse; import com.taobao.api.response.TradesSoldIncrementGetResponse; import com.taobao.top.util.TestData; public class TradeSync { private static final Log log = LogFactory.getLog(TradeSync.class); private static final String TOP_URL = TestData.ONLINE_SERVER_URL; private static final String APP_KEY = TestData.TEST_APP_KEY; private static final String APP_SECRET = TestData.TEST_APP_SECRET; private static final ExecutorService threadPool = Executors.newFixedThreadPool(12); private static final TaobaoClient client = new DefaultTaobaoClient(TOP_URL, APP_KEY, APP_SECRET); public static void main(String[] args) throws Exception { // 新用户登录后调用此方法 // getLast3MonthSoldTrades(null); // 系统启动后创建此定时任务 Timer timer = new Timer(); timer.schedule(new TimerTask() { public void run() { // 每个卖家启动一个线程去同步增量订单 final Date end = getTaobaoTime(); Listusers = getUsersFromDB(); for (final UserInfo user : users) { final Date start = user.getLastSyncTime(); threadPool.submit(new Runnable() { public void run() { try { getIncrementSoldTradesByPeriod(start, end, user.getSessionKey()); user.setLastSyncTime(end); updateUserToDB(user); } catch (ApiException e) { log.error("同步" + user.getUserId() + "的增量订单失败:" + start + "-" + end, e); } } }); } } }, 0, 1 * 60 * 1000L); // 每10分钟增量同步一次 Thread.sleep(100000); } private static List getUsersFromDB() { // TODO 从数据库中查询已授权的用户信息 List users = new ArrayList (); UserInfo user = new UserInfo(); user.setUserId(123456789L); user.setSessionKey("410253676dfef08550cce6f76ac549da2e2a5679429OOd5HfMv88371"); users.add(user); return users; } private static void updateUserToDB(UserInfo user) { // TODO 保存更新后的用户信息到数据库 } /** * 新用户登录后调用:同步三个月内的订单。 */ public static void getLast3MonthSoldTrades(final UserInfo user) { Date end = getTaobaoTime(); Date start = addMonths(end, -3); // 最多只能查询3个月内的订单 // 切隔时间(公式为:24*每页记录数[推荐50]/日均订单量),如日均订单量为100的店铺,可按每24*50/100=12小时切割一段 List dateList = splitTimeByHours(start, end, 24); for (final Date[] dates : dateList) { // 由于3个月的订单数量较大,建议采用多线程的方式同步,但是要注意APP的调用频率 threadPool.submit(new Runnable() { public void run() { try { getSoldTradesByPeriod(dates[0], dates[1], user.getSessionKey()); } catch (ApiException e) { log.error("同步" + user.getUserId() + "的已卖出订单失败:" + dates[0] + "-" + dates[1], e); } } }); } // 把获取3个月内已卖出订单的结束时间做为下次增量订单同步的开始时间 user.setLastSyncTime(end); updateUserToDB(user); } private static void getSoldTradesByPeriod(Date start, Date end, String sessionKey) throws ApiException { TradesSoldGetRequest req = new TradesSoldGetRequest(); req.setFields("tid"); req.setStartCreated(start); req.setEndCreated(end); req.setPageNo(1L); req.setPageSize(50L); long pageCount = 0L; TradesSoldGetResponse rsp = null; do { rsp = client.execute(req, sessionKey); log.info(rsp.getTotalResults() + "=>>" + req.getPageNo()); if (rsp.isSuccess()) { for (Trade t : rsp.getTrades()) { Trade trade = getTradeFullInfo(t.getTid(), sessionKey); if (trade != null) { // TODO 更新订单数据到本地数据库 } } req.setPageNo(req.getPageNo() + 1); pageCount = getPageCount(rsp.getTotalResults(), req.getPageSize()); } else { // 错误响应直接重试 } } while (req.getPageNo() <= pageCount); } /** * 后台线程定时调用:增量同步订单。 */ public static void getIncrementSoldTradesByPeriod(Date start, Date end, String sessionKey) throws ApiException { TradesSoldIncrementGetRequest req = new TradesSoldIncrementGetRequest(); req.setFields("tid"); req.setStartModified(start); req.setEndModified(end); req.setPageNo(1L); req.setPageSize(50L); TradesSoldIncrementGetResponse rsp = client.execute(req, sessionKey); if (rsp.isSuccess()) { long pageCount = getPageCount(rsp.getTotalResults(), req.getPageSize()); while (pageCount > 0) { req.setPageNo(pageCount); req.setUseHasNext(true); rsp = client.execute(req, sessionKey); if (rsp.isSuccess()) { log.info(rsp.getTotalResults() + " >> " + req.getPageNo()); for (Trade t : rsp.getTrades()) { Trade trade = getTradeFullInfo(t.getTid(), sessionKey); if (trade != null) { // TODO 更新订单数据到本地数据库 } } pageCount--; } else { // 错误响应直接重试 } } } else { getIncrementSoldTradesByPeriod(start, end, sessionKey); } } private static Trade getTradeFullInfo(Long tid, String sessionKey) throws ApiException { TradeFullinfoGetRequest req = new TradeFullinfoGetRequest(); req.setFields("tid,buyer_nick,seller_nick,status,payment,created"); req.setTid(tid); TradeFullinfoGetResponse rsp = client.execute(req, sessionKey); if (rsp.isSuccess()) { return rsp.getTrade(); } else { // API服务不可用或超时,则重试 if ("520".equals(rsp.getErrorCode())) { return getTradeFullInfo(tid, sessionKey); } else { log.error("查询订单详情失败:" + tid); return null; } } } /** * 获取淘宝服务器时间作为当前时间,避免部分ISV机器时间提前时导致同步漏单现象。 */ private static Date getTaobaoTime() { TimeGetRequest req = new TimeGetRequest(); try { TimeGetResponse rsp = client.execute(req); if (rsp.isSuccess()) { return rsp.getTime(); } } catch (ApiException e) { } return new Date(); } private static List splitTimeByHours(Date start, Date end, int hours) { List dl = new ArrayList (); while (start.compareTo(end) < 0) { Date _end = addHours(start, hours); if (_end.compareTo(end) > 0) { _end = end; } Date[] dates = new Date[] { (Date) start.clone(), (Date) _end.clone() }; dl.add(dates); start = _end; } return dl; } private static long getPageCount(long totalCount, long pageSize) { return (totalCount + pageSize - 1) / pageSize; } private static Date addMonths(Date date, int amount) { Calendar c = Calendar.getInstance(); c.setTime(date); c.add(Calendar.MONTH, amount); return c.getTime(); } private static Date addHours(Date date, int amount) { Calendar c = Calendar.getInstance(); c.setTime(date); c.add(Calendar.HOUR_OF_DAY, amount); return c.getTime(); } } class UserInfo { private Long userId; // 用户ID private String sessionKey; // 访问授权码 private Date lastSyncTime; // 上次增量订单同步时间 public Long getUserId() { return this.userId; } public void setUserId(Long userId) { this.userId = userId; } public String getSessionKey() { return this.sessionKey; } public void setSessionKey(String sessionKey) { this.sessionKey = sessionKey; } public Date getLastSyncTime() { return this.lastSyncTime; } public void setLastSyncTime(Date lastSyncTime) { this.lastSyncTime = lastSyncTime; } }